Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

collie

collie lets you watch and steer your herdr coding agents from an iPhone and an Apple Watch. A small daemon, collied, runs on the Mac or Linux machine where herdr runs. The Collie app reaches it over your own tailnet, so you can check every agent, read its screen, send it a prompt, answer an approval from the lock screen and start new work while you are away from the keyboard.

What you can do

  • See every herdr agent on every paired machine, with its status, live terminal preview and remaining context.
  • Read an agent's conversation in Chat or its raw screen in Terminal, then reply by typing, dictating or sending keys.
  • Approve or deny a blocked Claude Code agent from a notification, a Live Activity or the Apple Watch.
  • Start Claude Code, Codex or Copilot CLI in a folder, a new folder, a Git worktree, a fresh clone or a pull request branch.
  • Review an agent's Git changes and send line comments back to it.
  • Follow plan usage for Claude Code and Codex on the phone and on a watch complication.

How the phone reaches your machine

collie never opens a port on your network and does not use the Tailscale app.

  1. collied embeds its own Tailscale node, tagged tag:collie-mac or tag:collie-linux. It listens on TCP port 8457 inside the tailnet only.
  2. The Collie app embeds a second Tailscale node that belongs to you. It only dials out to the machine.
  3. Every connection is checked with Tailscale's whois before anything else: the phone must be untagged, owned by you and paired with this machine.
  4. Inside the tunnel, both sides prove their keys with mutual TLS. The machine's key is pinned from the pairing QR code; the phone's key lives in its Secure Enclave and never leaves it.

The only socket on the real network is Tailscale's own WireGuard UDP socket. See the Security overview for the full picture.

What stays on your machine

herdr, your agents, their transcripts and your files stay on the machine. The phone fetches conversation history over the encrypted session only while Chat is open, and none of it goes into logs or notifications. Push notifications go through Apple, so collied keeps them short: the agent and workspace names, and pull request numbers and their status, in clear, and the pending command encrypted end to end for your phone. There is no collie server and no account to create.

The parts

PartRuns onRole
colliedmacOS on Apple silicon, Linux on x86_64Talks to herdr over its local socket and serves the phone over the tailnet
CollieiPhoneThe app, with a notification extension and Live Activities
CollieWatchApple WatchApprovals, agents and a usage complication, all through the iPhone
CollieBarmacOSMenu bar companion for collied
CollieTrayLinux with OmarchyBar widget with the same menu

Where to start

  1. Check the requirements.
  2. Add the tailnet policy.
  3. Set up collied on macOS or Linux.
  4. Install the app and pair your phone.

The source, issues and releases are on GitHub.

Requirements

To run collie

  • A machine for the agents: an Apple silicon Mac, or an x86_64 Linux machine with a systemd user manager. Intel Macs are not supported. On Linux, collied is built and tested on Arch Linux.
  • herdr 0.9.5, the version collied is tested against.
  • An iPhone on iOS 26 or later. An Apple Watch on watchOS 26 or later is optional.
  • A Tailscale account whose policy file you can edit.
  • An Apple Developer account, with a Developer ID Application certificate (collied is always signed on macOS, never on Linux) and an APNs key if you want push notifications.
  • Optional, for the Linux bar widget: Omarchy 4 with qrencode, which Omarchy installs.

Note

On Linux, the encrypted APNs key needs systemd 256 or later (systemd-creds --user). Older versions fall back to a file readable only by your user.

To build on macOS

  • Rust (the version in rust-toolchain.toml) and Go
  • Xcode 27 with the watchOS platform and an iPhone 18 Pro simulator
  • just, XcodeGen, cargo-deny, cargo-nextest and jq

The libghostty-vt build downloads its own pinned Zig.

To build collied on Linux

  • Rust (the version in rust-toolchain.toml) and Go 1.27.2 or later
  • A C compiler and libclang
  • just, cargo-deny and cargo-nextest

Your own identifiers

The justfile and Collie/project.yml use the maintainer's Apple team ID and dev.rbstp bundle identifiers. Change them to your own before you build.

Tailnet policy

collied joins your tailnet as a tagged device, and your phone reaches it on TCP port 8457. The policy file must allow exactly that and nothing more.

Add the entries

In the Tailscale admin console, open Access controls and add:

  1. A tag owner for tag:collie-mac (and tag:collie-linux for a Linux machine), listing your login.
  2. A grant from your login to tag:collie-mac (and tag:collie-linux) on tcp:8457.
{
  "tagOwners": {
    "tag:collie-mac": ["you@example.com"],
    "tag:collie-linux": ["you@example.com"],
  },
  "grants": [
    { "src": ["you@example.com"], "dst": ["tag:collie-mac"], "ip": ["tcp:8457"] },
    { "src": ["you@example.com"], "dst": ["tag:collie-linux"], "ip": ["tcp:8457"] },
  ],
}

Replace you@example.com with your login. The full policy also has tests that Tailscale runs on every save.

Warning

Grants only add access. Remove the default allow-all grant, and make sure no other rule uses *, autogroup:tagged or the collie tags. Otherwise other devices can reach the machine, or the machine can reach yours.

Check it

  1. On the Preview rules tab, select your user: the only collie entries must be tag:collie-mac:8457 and tag:collie-linux:8457 over TCP.
  2. Select any other user: neither collie tag may appear.
  3. After setup, Machines shows the machine with its collie tag and no owner, and the phone under your user with no tag.

collied setup prints the entries to add when the tag owner or the grant is missing, and collied doctor warns when no device of yours can reach the machine.

Tags, the owner, key expiry and revocation are covered in Tailnet setup.

collied on macOS

collied is built from a checkout, signed with your Developer ID Application certificate and installed to ~/.cargo/bin.

Install

  1. Clone the repository with its submodules and build:

    git clone --recurse-submodules https://github.com/rbstp/collie
    cd collie
    just setup
    just collied-install
    
  2. Run the interactive setup:

    collied setup
    

What setup does

collied setup runs these steps in order and skips any step that is already done, so running it again resumes where it stopped:

  1. collied login: open the printed link and sign in as a tag owner. The node becomes tag:collie-mac.
  2. collied service install: installs the launchd agent that runs collied at login. It is installed again, restarting collied, when it points at another binary or config, is stopped, or runs an older binary.
  3. collied doctor: setup stops on a fail line. On a new machine, warn lines for peers, apns, hooks, codex hook and plan usage are expected, and one for reach until your phone is signed in to Tailscale.
  4. Push notifications, when [apns] is not configured yet. You can skip this and come back later: see Push notifications.
  5. collied pair, offered at the end: see Pair a phone.

Note

Setup needs a terminal, and ~/.cargo/bin/collied must be the signed regular file, not a symlink. Setup never confirms a pairing for you: that stays your y on the machine.

Files

collied.toml, the node state, paired phones and the audit log live in ~/Library/Application Support/collie/. See Configuration.

Next

Install the app, then pair your phone. The menu bar app is optional.

collied on Linux

There is no package yet. collied is built from a checkout, unsigned, and runs as a systemd user unit.

Install

  1. Clone the repository with its submodules and build:

    git clone --recurse-submodules https://github.com/rbstp/collie
    cd collie
    just setup
    just collied-install
    
  2. Run the interactive setup:

    collied setup
    

Setup runs the same steps as on macOS, with these differences:

  • collied login tags the node tag:collie-linux, and collied refuses to run until the node has that tag.
  • collied service install writes the systemd user unit ~/.config/systemd/user/collied.service.
  • The APNs key is stored as a systemd user credential, encrypted with the host key and sealed to the TPM2 when one is usable. Where systemd-creds cannot encrypt, it falls back to a file readable only by your user. collied doctor reports which one you have.

Warning

Setup stops when the user manager loads the unit from another file, applies a collied.service.d drop-in to it, or the unit pins another XDG_DATA_HOME. Remove the override and run setup again.

Files and logs

  • The data directory is $XDG_DATA_HOME/collie, else ~/.local/share/collie. It holds collied.toml, the node state, paired phones and the audit log.
  • Logs go to the user journal: journalctl --user -u collied.
  • A systemd user unit runs while you have a session. loginctl enable-linger keeps it running without a login.

A system Tailscale on the same machine

A system tailscaled is a separate node and plays no part in collie. Both run side by side: the embedded node has its own keys, uses a userspace network stack (no TUN device, no routes) and its own WireGuard port.

Next

Install the app, then pair your phone. On Omarchy, the bar widget is optional.

Install the app

Collie is not on the App Store yet. You build it and install it on your own iPhone.

  1. Turn on Developer Mode on the iPhone and connect it to the Mac.

  2. Install the app and its watch app:

    just ios-run-device
    
  3. Open Collie and sign in to Tailscale inside the app with your own account. This is the account that owns collied, not a tagged key.

Note

The watch app installs with the iPhone app. To sign it, register the paired Apple Watch with your team once: connect it in Xcode's Devices and Simulators window, or add its UDID in the developer portal.

The app runs its own Tailscale node, collie-phone. It does not need the Tailscale app, and a Tailscale app already on the phone plays no part in collie.

Next

Pair your phone with each machine.

Pair a phone

Pairing records the phone's keys on the machine and the machine's keys on the phone. Each machine is paired on its own.

  1. On the machine, start a pairing window. Use one of:
  2. In the app, open Machines, tap Add machine and scan the QR code.
  3. Back on the machine, check the login name and device shown, then confirm with y (or Pair in the companion window).

The window stays open for 120 seconds, and the first attempt closes it, right or wrong. If you do not answer within 60 seconds, the answer is no.

Warning

Until the first pairing, collied has no owner yet. Check that the login name on the confirmation prompt is yours. The first phone you confirm sets the owner, unless owner_user_id is set in collied.toml.

Pair again

Pair again after you delete and reinstall the app, or when the app asks you to (for example to use terminals with a phone paired before they existed). Remove the old phone with collied peers revoke and in the Tailscale admin console.

Remove a machine

Removing a machine in the app also revokes the phone on that machine when the machine is reachable. Otherwise run collied peers revoke <label or StableID> on the machine. See Commands.

Push notifications

Push notifications are optional. Without them, the app still shows everything while it is open. With them, you get approval alerts, done alerts, Live Activity updates and Apple Watch alerts.

Each machine sends its own pushes with its own APNs key, so you can revoke one machine's key without touching the others.

With setup

When [apns] is not configured, collied setup asks for the .p8 file and the team and bundle IDs, adds the [apns] section, imports the key and restarts collied. The .p8 must already be readable only by you:

chmod 0600 AuthKey_<KEY_ID>.p8
collied setup

By hand

  1. Add an [apns] section to collied.toml:

    [apns]
    key_id = "<KEY_ID>"
    team_id = "<TEAM_ID>"
    bundle_id = "<your app's bundle ID>"
    
  2. Import the key:

    collied apns import AuthKey_<KEY_ID>.p8
    
  3. Check the configuration, then send a test alert:

    collied doctor
    collied apns test
    

On macOS the key goes into the login Keychain, readable without a prompt only by the signed collied. Always reinstall with just collied-install, never cargo install, or the daemon cannot read the key. On Linux it becomes a systemd user credential (or a file readable only by you, as a fallback); collied doctor says which, and how it is sealed.

Test a notification that opens a session

collied status
collied apns test --terminal-id <terminal-id>

Tapping that alert should open the agent's session. Test alerts carry no approval actions. More on testing in Notifications, and the full Apple setup in Release.

Tip

collied apns test reports that Apple accepted the push, not that the phone showed it. Check the phone too.

Claude Code hook and plan usage

Both are optional and only concern Claude Code. collied doctor checks each one.

The PermissionRequest hook

Without the hook, an approval alert shows the tool call as read from the screen. With it, the alert names the exact command or file. The hook only reports the call to collied; Claude Code's own dialog does not change.

Add it to ~/.claude/settings.json:

{
  "hooks": {
    "PermissionRequest": [
      { "hooks": [{ "type": "command", "command": "~/.cargo/bin/collied hook", "timeout": 5 }] }
    ]
  }
}

Plan usage

Claude Code passes the plan's 5-hour and weekly limits only to the status line command. To share them with collie, add this line to your status line script, after it reads its input into input (for example with input=$(cat)):

printf '%s' "$input" | ~/.cargo/bin/collied statusline >/dev/null 2>&1 &

It prints nothing, so your status line looks the same. It records the limits and each session's context window size, so the context ring uses the exact window. Everything else in the status line input is dropped.

If your statusLine.command is a single command rather than a script, point it at a small script that reads input=$(cat), runs the line above, then pipes printf '%s' "$input" into your old command.

Note

The limits exist only for claude.ai Pro and Max plans, after the session's first reply. collied doctor reports when the tap last recorded them.

The limits show in Usage and on the Apple Watch.

Terminals

Terminals are plain shell panes you can use from the phone. Shell input is command execution, so they are off by default, and only collied.toml on the machine can turn them on, never the phone.

Turn them on

  1. Add this to collied.toml:

    [terminals]
    enabled = true
    
  2. Restart collied:

    collied stop
    collied start
    
  3. The phone must have a passcode, and must be paired after both the app and collied were updated, because pairing records the phone's terminal key. A phone paired earlier shows "Pair this phone again to use terminals on this machine": pair it again.

Use them

Terminals are listed under Terminals in the app. When an agent exits, its pane becomes a shell and shows up there.

  1. Open a terminal and unlock it with Face ID or the passcode. One unlock covers that terminal for 5 minutes.
  2. The command field runs one line at a time. The key strip works as in an agent session.

collied refuses to write to a pane where an agent now runs, and the audit log records each unlock and command without its text.

Run Claude Code from your phone

Claude Code gets the most complete support in collie.

What works

FeatureClaude Code
Grid preview, status and InboxYes
Chat and TerminalYes
Remaining context ringYes
Approve, Deny and option choices from the app, the lock screen, a Live Activity or the watchYes
Approval alerts naming the exact commandWith the hook
Plan usage (5-hour and weekly)With the status line tap
Slash command menu mirrored from the phoneYes
Notices as buttonsYes
Draft sync with the machineYes
Watch a pull requestYes
Resume and Ultracode in New taskYes

Set up

  1. Install the PermissionRequest hook so approvals name the exact tool call.
  2. Add the status line tap for plan usage and exact context windows.
  3. Check both with collied doctor.

Approvals

When Claude Code blocks on a permission prompt, the alert names the tool call. Approve, Deny and the prompt's other options need Face ID or the passcode for each decision. Folder trust uses the same actions: Approve trusts the folder, Deny ends the session. You can add a one-line note to an approval. Settings pickers such as /effort and /config raise no notification.

Questions and trust prompts can also be answered with the terminal buttons or by typing in the session composer, without Face ID. See Approvals and alerts.

Slash commands

While a prompt on the phone starts with /, the command is mirrored into Claude Code's input box, so its command menu appears live on the phone. Use ↑ and ↓ to highlight a command and ⇥ to take it into the phone's prompt; only Send runs it.

Notices

The options of a notice above the input box, such as the "Heads up" tip or the session rating question, show as buttons. A tap sends that single digit, and only while the screen still shows that option above an empty input box. The tip feedback options make Claude Code send Anthropic a feedback signal about the tip, never its text or your transcript.

Draft sync

A prompt typed on the machine but not sent shows up in the phone's prompt field, and sending from the phone replaces it.

Fullscreen mode

Claude Code's fullscreen mode ("tui": "fullscreen") keeps its history out of the pane, so Terminal shows one screen. When its transcript is scrolled up on the machine, a Jump to bottom button brings it back down; keys and prompts wait until it is back at the bottom.

Run Codex from your phone

What works

FeatureCodex
Grid preview, status and InboxYes
Chat and TerminalYes, with herdr's Codex hook
Remaining context ringYes
Blocked notificationsYes, without Approve or Deny
Answering promptsTerminal buttons or typing in the composer
Plan usageMonthly credits for a credit-metered workspace
Resume in New taskYes

Set up

  1. Install herdr's Codex hook, and run it again after every herdr update:

    herdr integration install codex
    

    The hook tells herdr which Codex session runs in each pane; Chat reads that session. herdr 0.9.5 installs version 10 of the hook. collied doctor warns when it is missing or older, once Codex has its ~/.codex folder (or $CODEX_HOME).

  2. Start Codex tasks from the phone, or start Codex with --no-daemon yourself.

Why --no-daemon

New Codex tasks started from collie run codex --no-daemon, so each pane reports its own session, including after /new or a resume. This costs a server startup and some memory per window.

Sessions already running on a shared daemon are not changed. To move one over, finish the current turn, then relaunch it in the same pane:

codex --no-daemon resume <session-id>

Use the session's real ID, not one guessed from the folder.

Plan usage

For a credit-metered workspace, the Usage view shows the monthly used and allowed credits and the reset date. collied reads them from the local Codex app-server every five minutes; Codex's sign-in token stays with Codex. The row clears after you sign out or switch to an account without a monthly limit.

Status and done alerts

herdr reads Codex's status from its screen. If Codex statuses or done alerts look wrong, see Troubleshooting.

Startup prompts

A startup prompt, such as folder trust or Codex's update picker, stops a new task with a message. Answer it in Terminal; collie never types into the update picker.

Run Copilot CLI from your phone

GitHub Copilot CLI agents are supported through Terminal.

What works

FeatureCopilot CLI
Grid preview, status and InboxYes
TerminalYes
ChatNo
Blocked notificationsYes, without Approve or Deny
Answering promptsTerminal buttons or typing in the composer
Key shortcuts just for Copilot CLIYes
New taskYes

Set up

Install Copilot CLI on the machine as copilot, and keep it in collied.toml's [tasks] agents list (it is there by default). herdr starts it and reads its status from the screen.

Terminal

Copilot's scrollbar column is removed, and its prose and sent prompts wrap to the phone's width with line wrapping on.

Startup prompts

Copilot's folder trust prompt stops a new task with a message. Answer it in Terminal, with the key strip or by typing in the composer.

Agents

The Agents tab lists every herdr agent on every paired machine, grouped by machine with blocked agents first. Each agent shows its status: idle, working, blocked or done. A machine that is asleep or off shows as offline without slowing the others.

Pull down to retry every machine, or tap a machine's header to retry that one.

Layouts

The Agents tab has three layouts.

Grid

The default. Each card is a live preview of the agent's screen. Claude Code, Codex and Copilot CLI cards keep the agent's output and drop its input box and status lines; a blocked agent's card shows its whole screen.

  • Star a card with a long press. Starred cards lead their machine's section at twice the height. Stars are kept on the machine, shared by your phones, until the pane closes or the machine is removed.
  • Reorder by dragging a card to an insertion line. Moving a card between the starred and unstarred groups also stars or unstars it. The order is saved on this phone.

List

A compact list of agents, with their status as icons.

Inbox

Search agents across machines and filter them:

  • Needs attention: blocked agents and recent completions.
  • Working: agents that are working, including blocked ones.
  • Inactive: idle agents and completions older than 24 hours.

Each row shows the latest reply, your last prompt, the workspace, agent kind, machine and elapsed time.

Swipe a row to the right and choose Snooze to hide it for 1 hour, 4 hours, until 8:00 the next morning or until a time you pick. A snoozed agent comes back early when it blocks or finishes another turn. Blocked agents cannot be snoozed. Snoozed agents wait in a collapsed Snoozed section; long-press one and choose Wake now to bring it back. Snoozes are kept on this phone.

Remaining context

A small ring shows how much context a Claude Code or Codex agent has left, read from its transcript on the machine.

Agent actions

Long-press an agent, or use the agent screen's menu, to:

Focus on followed by the machine name brings the agent's pane to the front in herdr.

Appearance

Settings > Appearance > Accent color offers Blue (the default), Orange, Green, Red and Dark Gold. The choice stays on this phone. Destructive actions stay red and status colors keep their meaning.

Chat and Terminal

A Claude Code or Codex session has two views: Chat and Terminal. Switch between them with the toggle at the top of the session. Settings > Sessions > Default view picks which one opens first; Chat is the default. Copilot CLI and plain terminals use Terminal only.

Chat

Chat reads the agent's real conversation from its transcript on the machine.

  • Replies render as Markdown, with selectable code blocks and Copy actions.
  • Consecutive tool calls collapse into one row; expand it to see each call with its result.
  • A recent page loads first. Tap Load older messages for more.
  • The search button finds text across the history, with highlighted matches and previous and next buttons.
  • New messages arrive while you read older ones, without jumping.

When an interactive prompt needs terminal keys, Chat offers Continue in Terminal.

Note

Chat needs the agent's session ID from herdr's SessionStart integration. If it is missing, Chat says so and retries when the ID arrives; it never guesses a transcript from the folder. For Codex, install herdr's hook: see Codex.

Each read is capped at 2 MiB and 40 message blocks, and long text is shortened with a mark. History is fetched only while Chat is open, never written to logs or notifications, and loading pauses when you switch to Terminal or leave the app.

Terminal

Terminal shows the last 200 lines of the agent's pane (500 or 1000 in Settings > Terminal > Scrollback), rendered with libghostty-vt in the MesloLGS NF font.

  • Turn on word wrapping to fit lines to the phone's width.
  • Long-press to select and copy text (Universal Clipboard included) or to open an http or https link.
  • The machine checks the pane four times a second, and once a second after 5 seconds without change or while the iPhone is in Low Data Mode.

The prompt

The composer at the bottom works in both views.

  • A prompt that could not reach the machine is sent again up to 5 times, a minute apart or as soon as the connection is back, while the send button spins. Editing the prompt or tapping the spinner stops it.
  • Settings > Prompt can keep the keyboard open after sending.
  • With the keyboard down, a long press on the prompt field copies it.
  • An unsent prompt stays with its agent on this phone, and its attachments for up to 23 hours.

See also Key strip and gestures, Dictation and attachments and Saved prompts.

Key strip and gestures

The key strip

The Keys button, right of the paperclip, shows or hides the key strip. It starts hidden, and the choice applies to every session on this phone.

The first row is:

⇧  ⌃  E  ←  ↑  ↓  →  ⇥  ⌫  ⏎

E sends Escape. Tap ⇧ or ⌃, then a key, to send the two together, such as ⇧⇥ or ⌃⏎. With ⌃ selected, a letter typed on the keyboard sends Control and that letter (A, B, C, E, K, L, U or W). A modifier applies to the next key only; tap it again to clear it.

The second row scrolls: ⇧⇥ ⌃⏎ ⇧← ^C in one tap, then your key shortcuts, then + to add one. Keys a session cannot use are turned off.

Key shortcuts

Add your own buttons in Settings > Prompt > Key shortcuts, or with + on the strip:

  • a key or a two-key combination, such as Shift+Tab;
  • text, such as /effort high.

A shortcut can apply to every session or to one kind: Claude Code, Codex, Copilot CLI or Terminal. Text is sent at once and leaves your draft alone. Claude Code refuses it while its input box holds unsent text; Codex and Copilot CLI take it after any text there. Swipe to delete a shortcut and use Edit to reorder. Shortcuts stay on this phone.

Gestures

In Settings > Terminal > Gestures:

GestureAction
Double-tapPaste into the prompt field
PinchChange the font size
Swipe sidewaysSwitch agents (while lines wrap)
Triple-tapSend Esc (off by default)

Dictation and attachments

Dictation

The mic button dictates into the prompt field with Apple's on-device speech models, in English (US) or French (Canada). The audio never leaves the phone. While you speak, a waveform fills the dictation bar so you can see quiet speech and pauses, and the language and stop controls stay within reach.

Dictated text stays in the prompt field until you send it.

Attachments

Use the paperclip to attach up to 10 photos or files to a prompt. They upload over the tailnet and show as thumbnails you can remove before sending. The agent receives their paths on the machine.

On the machine, attachments are capped at 20 MiB per file and 200 MiB in total, stored in a private folder, and deleted after 24 hours.

Saved prompts

Saved prompts are reusable, named prompts.

  • Add them in Settings > Prompt > Saved prompts, or from the paperclip menu in an agent or in New task.
  • Use one by tapping it: it fills an empty composer or is appended to your current draft. Edit it, then send.
  • Edit or delete one by swiping it.

Saved prompts stay on this phone and are shared across projects and machines.

Git changes and feedback

Open a session's More menu and choose Git changes to review what the agent changed in its checkout.

Pick the checkout

When the repository has other worktrees, such as one the agent created for its branch, pick it under Branch. The choice stays for that session.

Two views

  • Uncommitted: staged, unstaged and untracked files, listed separately with additions and deletions.
  • Branch: every file the branch changed since it left its default branch (origin/HEAD, then main, then master), named at the top.

Mark a file Viewed from its diff header: it collapses and moves after the others, and the mark stays on this phone until the file's diff changes.

Read a diff

Open a file for a diff with old and new line numbers and tinted additions and deletions. Wrap lines switches between phone-width wrapping and full lines with horizontal scrolling. Binary and empty files get their own explanatory views.

Listings show up to 200 entries, diffs are previewed up to a limit, and untracked file previews stop at 1 MiB.

Send feedback

  1. Select text in a diff and choose Feedback next to Copy. Write a comment; it appears below the selected lines, quoting only the selected code. The file header's feedback button adds a comment about the whole file.
  2. Collect comments across files and edit them as needed.
  3. Tap Send feedback to send them all to the same agent at once.

Your composer's prompt and attachments are left alone. Feedback survives closing and reopening Git changes, and a failed send. Sending respects approval safeguards and refuses to replace unsent text in the agent's input box.

Note

Git changes needs an updated collied, and the checkout must be inside the machine's task roots ([tasks] roots in collied.toml).

Pull requests

When an agent works on a branch with a GitHub pull request, collie shows it. collied reads it with the gh CLI on the machine, so gh must be installed and signed in. collied doctor warns when it is not.

Status

A badge on grid cards, Inbox rows and the session header shows the pull request's number and state (open, draft, merged or closed), with its checks while it is open or a draft.

On the session header, tap the badge for a menu:

  • Open PR;
  • Fix failing checks, when checks fail;
  • Address review comments, when there are reviews or comments;
  • Resolve conflicts, when the branch conflicts;
  • Explain this PR, always.

Each prompt fills the composer with one line naming the pull request, ready for you to edit and send.

Watch a pull request

For a Claude Code agent with an open or draft pull request, the session menu offers Watch PR, checking every 2, 5, 15 or 30 minutes. collied keeps watching with or without the phone, and wakes the agent with a one-line prompt when:

  • a check fails;
  • all required checks pass;
  • someone else comments or reviews;
  • the branch starts to conflict.

It only wakes an agent that is idle or done and still on the watched branch, and sends you a "Woke" alert with a short summary. The watch stops when the pull request is merged or closed, after 10 wakes in a row made only of comments, after 8 failed reads, or when you choose Stop watching.

Archive when the pull request merges

A merged pull request's badge reads Merged, and the session menu puts Archive first.

Turn on Settings > Sessions > Archive when the PR merges to let collied archive the task itself. It only archives a linked worktree whose agents are all idle or done and whose checkout has no uncommitted changes, then sends an "Archived" alert. A merge is noticed within about 2 minutes.

Done, Woke and Archived alerts all follow Settings > Notifications > Notify when an agent finishes.

New task

New task starts Claude Code, Codex or Copilot CLI in a new herdr workspace on the machine you pick.

Choose a folder

Each machine can have a base folder (Machines > the machine > Base folder). Use a full path such as /Users/you/git, since ~ is not expanded. It must be inside one of collied's task roots ([tasks] roots in collied.toml, your home folder by default).

  • Type a name to mean that folder inside the base, with matching folders offered as you type.
  • Browse lists the base's folders.
  • Recent folders and absolute paths inside a task root also work.
  • New folder creates an empty folder (no git init) in the base, or in a task root when no base is set, and starts the agent there. The folder stays if the agent fails to start.

Clone a repository

Give owner/repo, an https:// URL or a git@host:path address. collied clones it into a new folder inside the chosen folder with gh or git, then starts the agent there.

Git worktree

Choose Git worktree to work on a branch in its own checkout:

  • Create makes a new branch (a name is generated unless you type one) and puts the checkout under the repository's .worktree/ folder.
  • Open picks an existing checkout from the list.
  • Pull request takes the number of a pull request of that repository. collied asks gh for its branch, fetches it from origin and creates the checkout. Pull requests from forks are refused.

Check the branch and path shown before you tap Start.

Resume a session

For Claude Code and Codex, New task can list a folder's recent sessions with their first prompt, and resume one in a new workspace. A session already open in a herdr pane is not listed.

Options

  • Ultracode (Claude Code only, off by default) starts the session with Claude Code's Ultracode setting. The task fails without sending the prompt if Claude Code does not turn it on.
  • Codex tasks start with --no-daemon: see Codex.

Archive

Archive in a session's menu closes a linked worktree's workspace and removes its checkout, including uncommitted changes, then runs gh poi to clean up merged branches and worktrees. For a regular folder, Archive closes only the pane and leaves the folder in place. A banner shows progress, then confirms for 10 seconds.

Usage

Switch the Agents tab to Usage, or swipe left, to see plan usage. There is one card per machine that has recorded a plan, with how long ago it was recorded.

AgentShowsSource
Claude Code5-hour and weekly limitsThe status line tap
CodexMonthly used and allowed credits, and the reset dateThe local Codex app-server, every five minutes, for a credit-metered workspace

Each block has a bar with a tick for the time elapsed in the window, the amount used and the reset time. Usage beyond the tick means you are ahead of an even pace.

The same readings feed the Apple Watch complication and widget.

Approvals and alerts

With push notifications set up, collied alerts you when an agent needs you or finishes.

Approval alerts

When an agent blocks on a permission prompt, a notification names the tool call: the exact command or file with the Claude Code hook, otherwise what was read from the screen. Tapping it opens the matching session.

For Claude Code:

  1. Approve or Deny from the notification, after unlocking the iPhone, or in the app.
  2. Confirm with Face ID or the passcode. Every decision asks again.

collied re-reads the screen before it answers. If the prompt changed in the meantime, the answer is refused as superseded rather than pressed into the new prompt.

Codex and Copilot CLI send blocked notifications without Approve or Deny. Answer them in the session.

The notification is removed once the agent moves on, on a best-effort basis.

Answering by hand

Approvals, questions and trust prompts from all three agents can also be answered with the terminal buttons, or by typing in the session composer. Typed answers are pasted into the current prompt and submitted with Enter. This does not ask for Face ID, and it is not tied to one approval: if the prompt changes, the new prompt receives your input.

Done alerts

When an agent finishes a turn that took 30 seconds or more, you get one alert with its name and workspace, unless it is on screen. Tapping it opens the agent. Turn them off in Settings > Notifications > Notify when an agent finishes.

What Apple can see

The visible part of an alert only says which agent is blocked and where. The command is encrypted end to end with a key generated on your phone, and decrypted by the app's notification extension. Apple still sees agent and workspace names, pull request numbers and their status in the Woke and Archived alerts (such as "CI failed on #123" or "PR #123 merged"), opaque IDs and timing. Details are in the threat model.

Live Activities

Follow an agent to keep it on the Lock Screen and in the Dynamic Island.

  1. Open the agent screen's menu, or long-press the agent, and choose Follow on Lock Screen.
  2. Up to 5 agents can be followed at once. Each shows its status and how long it has been in it.

When a followed Claude Code agent blocks on a permission prompt, the activity shows the command with Approve and Deny buttons. Other prompts, and Codex or Copilot CLI agents, arrive as a regular notification instead.

Following is off by default. A followed agent's status changes travel through Apple's push service with their names in clear, while the pending command stays encrypted.

Apple Watch

CollieWatch installs with the iPhone app. It holds no keys and never talks to collied: everything goes through the iPhone, which works while it is locked.

In the watch app

  • Pending approvals, with the command and a countdown.
  • Agents, as the iPhone last saw them, like the Inbox.
  • Usage, with each window's percentage and reset countdown.

The watch asks the iPhone for fresh approvals when it opens, at most once a minute unless an approval alert opened it.

Deciding from the watch

Approvals are read-only on the watch until you turn on Settings > Apple Watch > Decide from Apple Watch on the iPhone, which needs Face ID or the passcode. Then:

  • the watch must be unlocked and on your wrist;
  • decisions are made in the watch app, never from the alert itself;
  • there is no Approve always on the watch.

The iPhone checks the setting again and only sends an answer it showed the watch.

Complication and widget

  • The circular complication has three rings, outer to inner: Claude 5-hour, Claude weekly and Codex monthly. A ring's length is the percentage used, and its color moves from green through yellow, orange and red as usage grows. A white tick marks the elapsed share of each window.
  • The Plan usage widget in the Smart Stack, also available as a rectangular complication, shows three gauges with the provider's logo and a reset countdown. A small orange dot marks readings at least 30 minutes old.

Tap either one to open a compact usage list. They refresh about every 15 minutes when watchOS allows it.

CollieBar on macOS

CollieBar is a menu bar app that controls collied on the Mac.

Install

just mac-install

This builds CollieBar, signs it with your Developer ID and installs it to ~/Applications. It is not notarized, which is only needed to run it on another Mac: see Release.

Use

  • The icon is bright while collied runs, dim when it is off, and shows a dot while an approval is pending.
  • The menu turns collied off and on, pairs a phone, lists the paired phones, opens the audit log and quits. Quitting also stops collied.
  • Open at Login in its menu starts it at login. It is off by default.

Update it with git pull and just mac-install.

CollieTray on Linux

CollieTray is a bar widget for Omarchy, with the same icon states and menu as CollieBar. It needs Omarchy 4 and qrencode.

Install

just tray-install

This copies the widget to ~/.config/omarchy/plugins/rbstp.collie and puts it on the right of the bar. It loads with the shell at every login while it is enabled.

Use

  • The icon is bright while collied runs, dim when it is off, and shows a dot while an approval is pending.

  • The menu turns collied off and on, pairs a phone, lists the paired phones, opens the audit log and quits.

  • Quit stops collied and takes the widget off the bar. Bring it back with:

    omarchy plugin enable rbstp.collie
    

Update it with git pull and just tray-install.

Commands

CommandWhat it does
collied setupInteractive setup: login, service, doctor, push and pairing. Safe to run again
collied statusThe running daemon's state: its node and tags, sessions, paired phones, herdr version and the agents it sees
collied doctorChecks the config, service, herdr, the tailnet node, its tag and reach, push, hooks, plan usage and gh
collied pairOpens a pairing window and shows the QR code
collied peers listLists paired phones
collied peers revoke <label or StableID>Revokes a phone and closes its live sessions
collied stopTurns collied off and keeps it off, across reboots, until collied start
collied startStarts it again
collied loginSigns the machine's node in to Tailscale
collied service installInstalls the launchd agent (macOS) or systemd user unit (Linux)
collied service uninstallStops and removes the launchd agent or systemd user unit
collied apns import <file>Imports the APNs key
collied apns testSends a test notification to the paired phones

Reading collied doctor

Each line is ok, warn or fail with a check name and a detail. Start every investigation here: Troubleshooting is organized by symptom and names the doctor line to look at.

Configuration

Where things live

macOSLinux
collied.toml, paired phones, audit log~/Library/Application Support/collie/$XDG_DATA_HOME/collie, else ~/.local/share/collie
Tailscale node statetsnet/ in that foldertsnet/ in that folder
Servicelaunchd agentsystemd user unit ~/.config/systemd/user/collied.service
Logsjournalctl --user -u collied

collied refuses a node state folder that other users can read.

collied.toml

Every section is optional. Restart collied (collied stop, then collied start) after a change.

[tailnet]
hostname = "collie-studio"     # defaults to collie-<host name>
port = 8457                    # change the tailnet grant to match
owner_user_id = 123456789      # pins the owner; see below

[tasks]
roots = ["/Users/you/git"]     # where New task may work; your home folder by default
agents = ["claude", "codex", "copilot"]

[terminals]
enabled = false                # plain shells from the phone; see Terminals

[apns]
key_id = "<KEY_ID>"
team_id = "<TEAM_ID>"
bundle_id = "<bundle ID>"
  • owner_user_id is the numeric Tailscale user ID of the only person allowed to use this collied. Without it, the first phone you confirm at pairing sets the owner. Read the ID from the pairing prompt or collied peers list; copying it here keeps the owner pinned even if the paired phones file is deleted.
  • [tasks] agents limits which agents New task can start, the first being the default.
  • [terminals]: see Terminals.
  • [apns]: see Push notifications.

Updating

Update the app and collied together. When only one side is updated, the app tells you which one to update: see Version mismatch.

collied

git pull
just setup
just collied-install
collied setup

just collied-install restarts the running service. collied setup keeps the node, the paired phones and the config, and offers to pair a phone again.

The app

just ios-run-device

Desktop companions

  • CollieBar: just mac-install.
  • CollieTray: git pull, then just tray-install.

herdr

After updating herdr, install its Codex hook again: herdr integration install codex. collied doctor warns when the hook is missing or older.

Version mismatch

The app and collied speak a versioned protocol. When their versions do not match, the app stops dialing that machine and says what to update, both in the Machines tab and in the machine's header on the Agents tab.

MessageMeaningFix
"Update collied on Studio: run just collied-install there." (with your machine's name)The app is newer than colliedUpdate collied on that machine
"Update the app."collied is newer than the appUpdate the app
"This app and collied on Studio use different versions. Update both."The versions differ but collied did not say which it runsUpdate both

No new pairing is needed: after the update, pull to refresh or reopen the app and it connects again.

Some features also need both sides to be recent even when the protocol matches, such as Chat, Git changes and typed answers to prompts. The app says so where it applies.

Troubleshooting

Start with collied doctor on the machine. Each line is ok, warn or fail, with the name of the check. Then find your symptom below.

collied doctor

The machine shows offline

The phone could not reach the machine over the tailnet.

  1. Check that the machine is awake and collied runs: the daemon line in collied doctor, or collied status.
  2. If you ran collied stop, run collied start.
  3. A warn on the tailnet line saying "no node state (not logged in)", or a fail on the daemon line whose node is not Running, means the node is logged out: run collied login. A fail on the tag line means the node lost its tag; collied login prints what to fix. A fail on the tailnet line is a problem with the node state folder itself, such as its permissions.
  4. A warn on the reach line means no device of yours can reach the node: check the tailnet policy and that the phone is signed in to Tailscale in the app.
  5. Pull to refresh on the Agents tab to retry at once.

Connection errors are shown in full in the Machines tab, with a button to copy them.

"Update collied" or "Update the app"

The app and collied speak different protocol versions. Update the side named in the message: see Version mismatch.

No push notifications

  1. Look at the apns line in collied doctor. If it is not configured, follow Push notifications. On macOS, a key the daemon cannot read usually means collied was rebuilt without just collied-install: reinstall it.
  2. Run collied apns test. It reports whether Apple accepted the push; then check the phone.
  3. Check that notifications are allowed for Collie in the iPhone's Settings.
  4. Done alerts only come for turns of 30 seconds or more, never for an agent that is on screen, and not at all when Settings > Notifications > Notify when an agent finishes is off.

Chat unavailable

  • The agent has no session ID: Chat says so and retries when one arrives. For Codex, install herdr's hook with herdr integration install codex, and again after each herdr update. The codex hook line in collied doctor warns when it is missing or older.
  • The session was started before the hook: for Codex, finish the turn and relaunch it in the same pane with codex --no-daemon resume <session-id>.
  • collied is older than the app: update collied to enable Chat.
  • The transcript is missing: Chat shows the history as unavailable, with a button to open Terminal.

Codex status or done alerts missing

herdr reads Codex's status from its screen. If a Codex agent stays unknown, or finished turns send no done alert:

  1. Make sure herdr is the version collied is tested against (see Requirements) and install its Codex hook again: herdr integration install codex.
  2. Check the agent's status in collied status while it works. A turn that ends in unknown sends no done alert.
  3. Codex tasks started from collie use --no-daemon. Relaunch older shared-daemon sessions as described in Codex.

Pairing refused

  • The window closed: a pairing window lasts 120 seconds and the first attempt closes it, right or wrong. Run collied pair again.
  • No answer on the machine: without a y within 60 seconds, the answer is no.
  • Another user: collied only accepts the owner (owner_user_id, or the user of the first confirmed phone). Sign in to Tailscale in the app with the owner's account.
  • A tagged or shared-in phone: the phone's node must be untagged and belong to your tailnet. Do not sign the app in with a tagged auth key.
  • The policy blocks the port: check the reach line and the tailnet policy.

Still stuck

Open an issue: see Support.

Support

collie is an open source project maintained by Richard Boisvert.

Get help

  1. Check Troubleshooting, which starts every symptom from collied doctor.
  2. Search the existing issues.
  3. Open an issue with:
    • what you expected and what happened;
    • the app version and your iOS version;
    • the output of collied doctor and collied status, after removing anything private such as machine names or paths.

Never paste APNs keys, auth keys, pairing QR codes or invite links in an issue.

Report a security problem

Do not put the details of a vulnerability in a public issue. Open an issue that only asks for a private contact, and the maintainer will follow up.

Privacy

What collie collects and sends is described in the privacy policy.

Privacy policy

Last updated: October 15, 2026.

This policy covers the Collie app for iPhone and Apple Watch, its notification extension and widgets, and collied, the daemon you run on your own Mac or Linux machine.

In short

  • collie has no server and no account. The developer does not receive your data.
  • The app talks to your own machines over your own tailnet, end to end encrypted.
  • The app contains no analytics, advertising or tracking code. Apart from its own connection to your machines, the only component that talks to the network is the embedded Tailscale client.
  • The services involved are Tailscale, to connect your devices; Apple's push notification service, if you turn on notifications; and, from your machine and with your own credentials, GitHub through gh and OpenAI through Codex, for the features described below.

What stays on your devices

On your machine, collied keeps in its data folder:

  • its configuration and its Tailscale node keys;
  • the paired phones, and each phone's push notification token and notification key;
  • the audit log, and its service log (the user journal on Linux);
  • recorded plan usage;
  • starred agents, and the pull requests you watch, with their URLs;
  • attachments you sent from the phone, deleted after 24 hours.

Your agents' transcripts and your files stay where the agents keep them; collied reads them only to answer the phone.

On your iPhone, the app keeps:

  • its Tailscale node keys and the machines you paired, in a state folder excluded from iCloud and Finder backups;
  • its signing keys and per-machine notification keys, in the iPhone's Keychain, available on this device only and not synced. The TLS signing key is generated in the Secure Enclave and cannot be exported;
  • your settings, saved prompts, key shortcuts and other preferences, and unsent drafts (with their attachments for up to 23 hours). These can be included in your device and iCloud backups.

Deleting the app removes its files, but iOS can keep the app's Keychain items on the device, and the app uses them again if you reinstall it.

On your Apple Watch, the watch app keeps the last approvals, agent list and plan usage that the iPhone sent it.

What goes between your phone and your machine

The phone and collied talk only over your tailnet, inside mutual TLS. This traffic carries what you see and do in the app: the agent list, terminal screens, conversation history while Chat is open, Git diffs, pull request status, plan usage, your prompts, keys and attachments, and approval decisions. The phone also gives collied its push notification token and a notification key it generated, so the machine can send it encrypted alerts.

Conversation history and the summary lines shown in the Inbox are never written to logs or put in notifications by collied.

Tailscale

Both the app and collied embed a Tailscale client. You sign in to your own Tailscale account, and Tailscale's coordination service learns about these devices as it does for any device on your tailnet. Traffic between your devices is encrypted with WireGuard and may pass through Tailscale's relay servers when a direct connection is not possible; the relays cannot read it. Log upload to Tailscale is turned off in both embedded clients. Tailscale's handling of your account and device data is covered by Tailscale's privacy policy.

Push notifications

If you set up notifications, collied on your machine sends them through Apple Push Notification service. Apple can see:

  • the agent and workspace names shown in the alert, and opaque identifiers;
  • pull request numbers and what happened to them, in alerts such as "Woke" with "CI failed on #123", or "Archived" with "PR #123 merged";
  • when alerts are sent, how often, and the size of the encrypted part;
  • for agents you choose to follow in a Live Activity, their status changes and the time of each.

The pending command or question is encrypted with a key generated on your phone, and only the phone can read it. Apple's handling of this data is covered by Apple's privacy policy.

GitHub and Codex, from your machine

collied runs these tools on your machine, as you, with the credentials you already gave them. collie has no credentials of its own.

  • GitHub CLI (gh): while the app is connected, or when you turned on archiving merged pull requests, collied asks GitHub about the pull request of each agent's branch, about every 2 minutes. It also polls the pull requests you watch, at the interval you chose, with or without the app. When you ask, it clones repositories, fetches pull request branches and runs gh poi to clean up merged branches.
  • Codex: collied asks the local Codex app-server for your account's usage limits every five minutes, which Codex reads from OpenAI.

GitHub's and OpenAI's handling of these requests is covered by their own policies.

Device features

  • Camera: used only to scan the pairing QR code. Images are not stored or sent.
  • Microphone and speech: dictation uses Apple's on-device speech recognition. Audio never leaves the phone. iOS may download the speech model for your language from Apple.
  • Face ID or passcode: handled by iOS. The app only learns whether you passed.
  • Photos and files: only the ones you attach are read, and they go only to the machine you send them to.

Diagnostics

The app writes connection diagnostics to the iPhone's own system log. They stay on the device unless you share them, for example in a sysdiagnose. If you opt in to sharing analytics with app developers in iOS, or use a TestFlight build, Apple may share crash reports, and any feedback you choose to send, with the developer under Apple's terms.

Your agents

The coding agents you run (Claude Code, Codex, Copilot CLI) talk to their own services under their own terms, whether or not you use collie. What you send them from the phone, such as prompts, attachments and answers, reaches them as if you had typed it on the machine. For example, answering a Claude Code tip's feedback question from the phone makes Claude Code send Anthropic the same feedback signal it would send if you answered on the machine.

Children

collie is a developer tool and is not directed at children.

Changes

Changes to this policy are published on this page with a new date. If a future version of collie adds a service that handles your data, such as a push relay for an App Store release, this policy will describe it before that version is released.

Contact

Questions about this policy: open an issue on GitHub, or see Support.

Security overview

Security is collie's first requirement: the phone can drive every agent on your machine, so collied treats every request as hostile until proven otherwise. This page is a summary. The threat model covers each threat, its mitigations and what remains.

Network

  • Tailnet only. collied accepts connections only through its embedded Tailscale node, on one port. A test asserts it opens no kernel TCP listener.
  • Whois gate. Every connection is checked with Tailscale whois before anything else. The peer must be untagged, not shared in from another tailnet, owned by the collie owner, and a paired phone whose node still belongs to the user it was paired with. Authorization is checked again before every message, and revoking a phone cuts its live sessions.
  • Mutual TLS. Inside the tunnel, the phone pins the machine's TLS key from the pairing QR code, and collied pins the phone's key, which lives in the Secure Enclave. A copied Tailscale node key, or a node injected by a compromised control plane, gets no session.

Pairing

A pairing needs the QR code shown by collied pair and your y on the machine. The window lasts 120 seconds and the first attempt closes it.

What the phone can do

collied accepts a fixed list of requests and rejects anything else before reading it. herdr's privileged methods cannot be reached from the phone.

  • Approvals carry a one-time nonce and a fingerprint of the prompt on screen. collied re-reads the screen and answers only if the fingerprint still matches.
  • Apple Watch decisions are off by default, need the watch unlocked and on your wrist, and go through the iPhone's same checks.
  • Terminals are off unless collied.toml turns them on. Each unlock is signed by a second Secure Enclave key after Face ID or the passcode, for one terminal and 5 minutes, and audited without the command text.
  • Attachments are size-capped, checksummed, stored privately and deleted after 24 hours.

Notifications

The visible part of an alert says which agent is blocked and where. The command is encrypted end to end (ChaCha20-Poly1305) under a key generated on the phone. Apple still sees agent and workspace names, pull request numbers and their status (such as "CI failed on #123" or "PR #123 merged"), opaque IDs and timing.

Secrets on the machine

  • macOS: the APNs key is in the login Keychain, readable without a prompt only by the Developer ID signed collied.
  • Linux: it is a systemd user credential, encrypted with the host key and sealed to the TPM2 when one is usable, with a file readable only by you as the fallback. Any process running as your user can decrypt it.

Your part

  • Keep the tailnet policy tight, and check the login name when you pair.
  • When a phone is lost, revoke it with collied peers revoke and remove its node in the Tailscale admin console. See Revocation.
  • Revoke a machine's APNs key in the Apple Developer portal if that machine is compromised.

Threat model

Scope: collied on the Mac or on a Linux machine, Collie.app on the iPhone, the tailnet between them, herdr as collied's local backend, and APNs. On Linux collied runs as a systemd user unit and advertises tag:collie-linux. Tailnet configuration is in tailnet.md, design in architecture.md.

Assets

AssetWhere
Control of every shell and agent on the computerherdr socket (0600, no auth: same UID means full control); reachable from the phone only through collied's allowlist
Mac node keys~/Library/Application Support/collie/tsnet (0700)
Linux node keys<data dir>/tsnet (0700), data dir $XDG_DATA_HOME/collie, else ~/.local/share/collie. Separate from a system tailscaled's state (/var/lib/tailscale): the embedded node is its own device with its own keys
Phone node keysapp container Application Support/collie/tsnet (0700, completeUntilFirstUserAuthentication, excluded from backup)
Machine TLS key (proves the machine to the phone)tls-key.json in collied's data dir (P-256 PKCS#8, 0600, made on first start, read only as a regular file, not a symlink, owned by the user with no group or world access); its pin in the pairing QR and the phone's machines.json
Phone TLS key (proves the phone to collied)the phone's Secure Enclave: a P-256 key, non-exportable, .privateKeyUsage, AfterFirstUnlockThisDeviceOnly and no user presence, so lock-screen decisions can connect; collie-core gets a signing callback, never the key; its pin in peers.json (tls_key), recorded when the pairing is confirmed
Pairings (StableID list) and the owner user IDpeers.json in collied's data dir (0600); optional owner_user_id in collied.toml
Pairing codethe QR that collied pair draws as text in the terminal, and the invite URI it also prints with --show-uri; 16 random bytes, one window at a time, 120 s, burned by the first attempt, redacted in Debug
Approval noncescollied memory and the phone session (collie-core only, never handed to Swift); 32 random bytes, single use
APNs .p8 key (macOS)login Keychain item dev.rbstp.collied.apns/<key ID>, ACL trusting only the Developer ID signed collied (legacy: 0600 file named by key_path); a backup in the user's password manager
APNs .p8 key (Linux)systemd user credential <data dir>/apns/<key ID>.cred (0600), encrypted by systemd-creds with the host secret (/var/lib/systemd/credential.secret, root, 0400) and the TPM2 when one is usable, bound to the user and to the name collied-apns-<key ID>; fallback when systemd-creds cannot encrypt: 0600 file <data dir>/apns/AuthKey_<key ID>.p8 named by key_path; a backup in the user's password manager. Each machine has its own key and key ID
Live Activity update tokenscollied's push.json (0600) next to the device token, bound to the device's StableID; collie-core memory. Never logged, audited or written to disk on the phone; redacted in Debug
Notification keys (32 bytes, one per paired Mac)iOS Keychain (dev.rbstp.collie.notify/<Mac node ID>, AfterFirstUnlockThisDeviceOnly, shared with ColliePush); a mirror file per Mac in the App Group container (notify-keys/<node ID>.key: 0600, completeUntilFirstUserAuthentication, excluded from backup), because a Live Activity renders in a process with no Keychain (errSecNotAvailable) and needs the key to show the command; collied's push.json (0600) next to the device token; collie-core memory. Never logged, audited or written elsewhere; redacted in Debug. The mirror is weaker than the ThisDeviceOnly Keychain item against a jailbroken or forensically imaged phone (user decision, 2026-10-03)
Terminal content, agent and workspace namesherdr, the tailnet session, the phone
Agent transcripts (whole conversations, tool output)Claude Code's ~/.claude/projects/*/<session>.jsonl (or under CLAUDE_CONFIG_DIR) and Codex's ~/.codex/sessions/YYYY/MM/DD/rollout-*-<session>.jsonl (or under CODEX_HOME), 0600, written by the agents; collied reads them and keeps only values derived from them, in memory
Plan usage and context window sizescollied's usage.json (0600) in the data dir, written from Claude Code's status line input and the local Codex app-server: Claude's 5-hour and weekly limits, Codex's monthly used and allowed credits and reset date, and up to 64 Claude session ids with their window size; collied memory. Codex handles its OAuth token; collied keeps none of it.
Terminal key (signs terminal grants)the phone's Secure Enclave: a P-256 key apart from the TLS key, non-exportable, .privateKeyUsage and .userPresence, WhenPasscodeSetThisDeviceOnly, so every signature needs Face ID or the passcode and removing the passcode destroys it; its public key in peers.json (terminal_key), recorded when the pairing is confirmed on the machine, and in the phone's machines.json
Terminal challenges and grantscollied memory only, per session: one challenge (32 random bytes, 60 s, single use), grants per terminal for 5 min. Never logged or written to disk
Attachments (photos and files sent from the phone)the phone; collied's attachments dir (~/Library/Caches/dev.rbstp.collied/attachments on macOS, $XDG_CACHE_HOME/collied/attachments, else ~/.cache/collied/attachments, on Linux; 0700 dirs, 0600 files) for up to 24 h; the tailnet session

Trust boundaries

  • Tailnet to collied. The only network entry point is the tsnet listener on TCP 8457. Every connection passes the whois gate (untagged, not shared in, owned by the owner once one is known, paired StableID with its paired user and a pinned TLS key, or an open pairing window), then mutual TLS against the key the gate pinned for that peer (any P-256 key in a pairing-only session), then the same gate again under the peers, pairing and sessions locks, which must reach the same decision (a pairing or revoke during the handshake refuses it), all before the WebSocket upgrade; then a fail-closed method allowlist (parse_client_frame: unknown methods rejected before params are decoded, unknown fields denied, herdr privileged methods unreachable). Terminal methods are their own allowlist class, refused with terminals_disabled unless [terminals] enabled = true, and need a grant signed by the phone's terminal key.
  • collied to herdr. Same UID. Nothing on the computer running as the user is a boundary. On Linux the systemd unit's sandboxing is defense in depth, not a boundary against the same UID.
  • Tailscale control plane. Trusted for node identity: whois and the phone's netmap check both come from it.
  • Apple. APNs sees what collied pushes: labels and ids in clear, the pending action only as ciphertext, and the Live Activity content of followed agents.

Status legend

  • built: in the tree and tested today.

Threats from the spec

Stolen phone

AssetsPaired session (read and control of agents); phone node keys
AttackThief opens the unlocked app, or unlocks the phone, and drives agents or answers approvals.
MitigationsiOS passcode and data protection on the node state (built). Revocation: collied peers revoke plus removing the node in the admin console (built, tailnet.md). Phone node key expiry (Tailscale default 180 days) bounds a forgotten revocation. Closing a pane or a workspace needs confirm: true, else confirm_required before herdr is called (built); the phone sets the flag itself, so it guards against an accidental close, not against a thief. Every frame is rate limited per paired StableID (20/s, burst 40; attachment.chunk frames have their own bucket, 128/s) and every method other than hello, flock.snapshot, approval.list, attachment.chunk, attachment.abort and an issued terminal.challenge is written to the audit log (server.rs finish; a refused challenge is logged); of a run of frames over the limit only the first is logged, as rate_limited (built). Approvals: in-app decisions need LocalAuthentication (deviceOwnerAuthentication, fresh context per decision), lock-screen actions need an iOS unlock (authenticationRequired), approval.decide is limited to 1/s, burst 5, per StableID, and every attempt that passes the frame limit is audited (built). Terminals (off by default) need a grant that collied verifies, signed by the Secure Enclave terminal key, which signs only after Face ID or the passcode (built).
ResidualUntil revoked, an unlocked phone drives agents (prompts, keys, new tasks, closes) without any per-action check, and an agent can run any command: only approval decisions and terminal grants ask for Face ID or the passcode, so the terminal grant does not make the phone as a whole safer against a thief holding it unlocked. A thief who knows the passcode passes both approval gates and the terminal grant.
PhaseRevocation built. Approval authentication built. App-level gate for the other drive methods: not planned.

Leaked pairing QR

AssetsPairing code, Mac tailnet node name, Mac StableID, the pin of its TLS key (public)
AttackSomeone photographs the QR, or copies it or the invite URI (printed next to it by collied pair --show-uri) from terminal scrollback, a screen share or recording, or a herdr pane that other same-UID code can read. They then pair their own device. The menu bar app's pairing window shows the same QR.
MitigationsThe code alone grants nothing: the pairing session is only offered to an untagged, not-shared-in node (owned by the owner, once one is known) and only while a locally opened window is active. One window at a time, 120 s; the first pair.complete from any connection burns it, right or wrong code. With the right code, collied shows the candidate's device label, node name, StableID, login name and user ID and asks y/N on the Mac, 60 s timeout. Code compared in constant time; carried in the URI fragment and redacted in Debug (built). The policy grants port 8457 to the owner's devices only.
ResidualWhen owner_user_id is not configured and nothing is paired yet, the owner is set by the first confirmed pairing (trust on first use), so any tailnet member the policy lets reach the port can race the window with a leaked code; the y/N prompt, which shows the login name, is then the only check. An attacker controlling a node logged in as the owner (account compromise) can race the window at any time. Any node admitted to a pairing-only session can burn an open window (denial of pairing only). The QR in the menu bar app's window is on screen for the same 120 s, and screen recordings capture it like the terminal's. The QR drawn in the terminal, and the URI with --show-uri, stay in scrollback and screen recordings after the window closes; they are useless once burned or expired, but a capture read live during the 120 s is as good as a scan. Host name and node ID in the QR are not secret.
PhaseBuilt

Malicious herdr plugin on the Mac

AssetsEverything on the computer (Mac or Linux); the phone's view of agents and approvals
Attackherdr plugins are unsandboxed code running as the user with the full herdr CLI. A plugin can drive every pane directly, read collied's data dir (node keys, tls-key.json, peers.json, .p8; on Linux it also decrypts the APNs credential), drive collied's control socket, and write pane text that collied turns into approval snippets to mislead the phone.
MitigationsNone against the computer side: same UID is out of scope for file modes. Snippets are capped at 200 chars with controls, bidi and format characters stripped (built), so a plugin cannot inject escapes into the phone UI through them. Approval decisions only send up, down, enter or esc, never text from the pane (built).
ResidualFull compromise of the computer. A plugin can draw a fake but well-formed Claude Code menu in a pane and so show the phone a fake approval. It also controls the gate's inputs: it can rewrite collied.toml and peers.json (owner and paired StableIDs), answer y/N over the control socket, and run collied's node and TLS keys elsewhere. Other users' devices are then kept out only by the tailnet policy, whose src names the owner alone and which only the admin account can change.
PhaseBuilt as far as it goes (see same-UID malware for the Keychain item's limits). Otherwise not planned (install only trusted plugins).

Compromised Tailscale coordination plane

AssetsNode identity, which every collie authorization decision rests on
AttackThe control server (or a stolen admin account) adds a node with its own key and gives it the phone's StableID and the owner's user ID, or changes the tag owners and grants, or points the phone's netmap at a fake Mac.
MitigationsAdmin account protection (IdP, MFA) is the user's. collied pins StableID + user ID + untagged, and the phone pins the machine's StableID + the tag seen at pairing, tag:collie-mac or tag:collie-linux (built; the owners of either tag are trusted for the tag half of the pin), which stops casual misconfiguration but not a control plane that lies. The y/N prompt at pairing catches a new device only during pairing. Mutual TLS inside the tunnel, independent of Tailscale keys (built): the phone pins the machine's TLS key from the pairing QR, which is shown on the machine and so out of band, and collied pins the phone's Secure Enclave key, recorded when the pairing is confirmed. A node the control plane injects with the phone's StableID, or a fake machine in the phone's netmap, fails the TLS handshake.
ResidualLow: a lying control plane can still cut or delay traffic, and can learn which nodes talk. It cannot get a full session without the phone's Secure Enclave key, or pass as a machine without its TLS key.
PhaseBuilt (Phase 6).

Replayed notification

AssetsApproval decisions
AttackAn old push (or a captured approval decision) is replayed to approve something again or something else.
MitigationsThe push payload never carries the nonce, only approval_id and node_id as lookup keys (built). A lock-screen action fetches the nonce over the tailnet (approval.list) from the pinned, paired Mac and decides there; the app validates both keys and trusts nothing else in the payload (built). Nonce: 32 random bytes, constant-time compare, burned by the first attempt past the rate limit, whatever its result, approval_ttl 10 minutes; a burned id answers approval_already_resolved (built). Every attempt that passes the per-peer frame limit, including replays and expired ones, is audited; a run of frames over that limit logs only its first, as rate_limited (built). Decisions are bound to a fingerprint and fail as superseded if the prompt changed (built). APNs apns-expiration is the approval's expiry, and the terminal_id collapse id makes a reissued alert replace the dead one (built). The Nonce type rejects malformed values and is redacted in Debug (built).
ResidualA replayed or stale push shows a notification whose approval_id no longer resolves: acting on it answers "no longer pending, nothing was sent". A fresh alert for the same terminal can follow each reissue while the agent stays blocked.
PhaseBuilt

Leaked APNs key

Assets.p8 key (team wide), device tokens
AttackWith the key and a device token, an attacker sends arbitrary pushes to Collie (or, the key being team wide, to any app of the team) to phish the user.
MitigationsDedicated, revocable key, never in GitHub (release.md). On macOS it is stored in the login Keychain by collied apns import, which deletes the file after confirmation; the item's ACL lets only collied signed with the team's Developer ID (just collied-install) read it without a prompt; collied doctor warns about a key_path config (built). On Linux collied apns import stores it as a systemd user credential instead (or a 0600 file where systemd-creds cannot encrypt) and offers to delete the original; collied doctor warns about a key_path config when systemd-creds works; limits in APNs key storage on Linux (built). On both, collied doctor warns about a .p8 left in collied's apns dir other than the configured key_path (built). Each machine uses its own key with its own key ID, so a key leaked from one machine is revoked in the portal without touching the others. Pushes carry no nonce and cannot create or decide an approval: approval_id and node_id are validated lookup keys, and a lock-screen action decides only what the pinned, paired Mac returns over the tailnet (built).
ResidualA leaked key sends fake notifications to Collie (and to every app of the team) until revoked in the portal: a phishing alert with Approve/Deny buttons whose approval_id matches nothing ends as "no longer pending, nothing was sent", but its text is the attacker's (as a plaintext body only: without the notification key it cannot produce an enc the NSE opens). A forged background push naming a genuine approval_id (16 random bytes, seen only by Apple, the paired devices and the Mac) can remove that alert from the phone; the approval stays pending in the app. Copies outside the Keychain or the systemd credential (the downloaded file before import, the password manager backup) are protected only by where they sit.
PhaseBuilt

Phone node key extraction

AssetsPhone node keys, which pass the whois gate as the paired phone
AttackKeys read from the device (forensic tools, jailbreak, exploit) or from a backup, then used from another machine.
MitigationsApp sandbox; state dir 0700 with data protection completeUntilFirstUserAuthentication, both set by StateDirectory.swift; collie-core refuses a state dir with group or world access or another owner (built). State dir excluded from backups, also set by StateDirectory.swift (built). Revocation and key expiry as for a stolen phone.
ResidualNode keys are files, readable after first unlock by code inside the app's sandbox. They no longer suffice: collied also requires the phone's TLS key, a Secure Enclave P-256 key that cannot leave the device (ThisDeviceOnly, usable after first unlock). Code running on the unlocked phone, inside the app's sandbox, can still use that key there.
PhaseSecure Enclave TLS key: built (Phase 6). Backup exclusion for the node state: built.

Threats from the design

Same-UID malware on the Mac

Assetsherdr, collied's data dir, the APNs key, the control socket
AttackAny process running as the user controls herdr directly, reads node keys, tls-key.json and peers.json, uses the 0600 control socket.
MitigationsNone by design for herdr and collied's files: same UID already owns every shell. Data dir 0700, files and control socket 0600, control socket peer UID checked: these stop other local users only (built). On macOS the APNs key is the exception: it is a Keychain item whose ACL trusts only collied's designated requirement (Developer ID Application, team RM3UT3MMSR, identifier dev.rbstp.collied), and the binary is signed with the hardened runtime, so other programs cannot read the key bytes without a Keychain prompt (built). On Linux there is no such exception: any same-UID process decrypts the systemd credential with no prompt (APNs key storage on Linux). The systemd unit's sandboxing (NoNewPrivileges, ProtectSystem=strict, a system call filter and more) limits what a compromised collied can reach, but it does not constrain other processes of the same UID, and a user manager skips it where unprivileged user namespaces are unavailable (built).
ResidualFull compromise of the computer side. Stolen node keys and tls-key.json let the malware impersonate the computer to the phone and show fake state or fake approvals. It can report a tool call through the control socket's hook request that differs from the dialog, so the alert names another call; the decision still binds to the screen's fingerprint, and the in-app approval also shows the screen's snippet. It controls the gate's configuration (collied.toml, peers.json) and can answer the y/N prompt, so the gate no longer protects anything; other users' devices are kept out only by the tailnet policy, which needs the admin account to change. Keychain ACL limits (macOS): malware can raise a prompt that the user may accept; anyone with the login password can change the ACL or export the item; it can run the signed collied itself (its own --config or HOME) to make it send pushes with the key, so it can push through collied without holding the key; a key_path config or an un-deleted .p8 gets none of this protection. On Linux the malware reads the key itself and can push from anywhere until the key is revoked.
PhaseBuilt (Keychain item, systemd credential). Otherwise out of scope.
AssetsThe invite (pairing code) while its QR is shown; the y/N decision on a candidate phone; the state of collied
AttackAnother app or a stray click approves a phone in the app's pairing window; the invite leaks from the app; phone-provided strings disguise the candidate; the app becomes a way in from the network.
MitigationsThe app is a local client of the 0600, same-UID control socket only, with the CLI's requests plus a read-only watch; it opens no network listener. Developer ID with the hardened runtime and no entitlements. The invite is never shown as text, copied, logged or persisted, and window restoration is off. Pair is not the default button and is live only 1 s after the candidate appears; one answer per candidate, never automatic; collied refuses after 60 s. The candidate's strings are escaped like collied::printable (controls, format and bidi characters, combining marks, default-ignorable characters and non-ASCII spaces). Closing the window cancels the pairing (built).
ResidualSame-UID code can drive the control socket or the app itself, as under Same-UID malware on the Mac: it can already answer the y/N prompt. The QR is on screen for up to 120 s, like the terminal's.
PhaseBuilt

Bar widget (Linux)

AssetsThe invite while its QR is shown; the y/N decision on a candidate phone; the state of collied
AttackA stray click or key approves a phone; the invite leaks into argv, a file or the shell's log; phone strings render as rich text or disguise the candidate; the audit log is edited through the viewer; the widget becomes a way in from the network.
MitigationsA plugin of the Omarchy shell, a local client of the 0600, same-UID control socket only, with CollieBar's three requests; no listener, no network access, no change to collied. The invite reaches qrencode on stdin only and is never logged or shown as text; the QR is dropped with the pairing, but qrencode's output, which encodes the one-time invite, stays in the shell's memory until the next pairing. Pair is bound to no key, is live only 1 s after the candidate appears, and sends one answer; collied refuses after 60 s; closing the panel cancels. Phone strings are escaped like collied::printable and drawn as plain text. Commands run by argv, never a shell string. The audit log opens in less with LESSSECURE=1 (built).
ResidualSame-UID code can drive the control socket, the shell or the plugin files under ~/.config/omarchy/plugins, as under Same-UID malware on the Mac. Any other shell plugin runs in the same Quickshell process. The QR is on screen for up to 120 s.
PhaseBuilt

collied setup

AssetsThe y/N decision on a candidate phone; the launchd agent or the systemd user unit; collied.toml and the APNs key
AttackSetup runs with piped input (yes into it) so a pairing is confirmed without a human; it installs an unsigned agent, or a service running a build output; it leaves the key or config readable; on Linux it logs in a new node over the paired one.
MitigationsRefuses unless stdin is a terminal and the binary is ~/.cargo/bin/collied, on macOS also Developer ID signed as dev.rbstp.collied, before any step: the service then never points at a build output that the next cargo build replaces unsigned. Never confirms a pairing: it runs the collied pair flow, whose y follows a tcflush. Reuses service install and apns import (Keychain, read back, offer to delete the .p8); a new collied.toml is created 0600, an existing one is rewritten atomically keeping its mode ([apns] holds no secret, the key is in the Keychain). It changes no bind, gate or pairing rule, refuses to drop the --config the service runs with, and stops on any doctor fail line, listen included (built). On both, ~/.cargo/bin/collied must be a regular file, so a symlink cannot point the service at a build output. On Linux there is no signing: it requires ~/.cargo/bin/collied only, writes the unit through service install (0644, no secret, UMask=0077), and imports the key through apns import (systemd credential, or the 0600 fallback, read back, offer to delete the .p8; the key is never printed). It refuses a unit loaded from another file, with a collied.service.d drop-in it cannot check, or pinning another XDG_DATA_HOME, so it never logs in a fresh node beside the paired one (built).
ResidualAs under Same-UID malware on the Mac: same-UID code can drive a terminal or the control socket.
PhaseBuilt

APNs key storage on Linux

AssetsThe machine's APNs .p8 key; the systemd host secret /var/lib/systemd/credential.secret
AttackRead the key from <data dir>/apns/<key ID>.cred or the fallback file: a process running as the user, another local user, root or a polkit admin, or someone holding an offline copy of the file or of the disk.
Mitigationscollied apns import applies the same file checks as on macOS ([apns] ids, refuses a file named for another key ID, 0600 regular file owned by the user, opened with O_NOFOLLOW, ES256 key), encrypts it with systemd-creds --user under the name collied-apns-<key ID>, writes the credential itself (0600, O_NOFOLLOW, atomic), decrypts it back and compares before and after storing, then asks y/N to delete the original (only if it is still the same inode). An existing credential is kept only if it decrypts to the same key. systemd seals it with AES-256-GCM under a key derived from the host secret (root, 0400), the TPM2 when one is usable, the uid, the user name and the machine-id. An offline copy of the .cred alone decrypts nothing; with a TPM2 seal, decryption also needs that TPM chip. Other unprivileged users cannot decrypt it: the key is bound to the uid and user name, and systemd asks polkit for admin authentication (auth_admin_keep) before decrypting another user's credential. collied passes --no-ask-password, so it never raises a polkit prompt itself. The name is bound into the credential and every decrypt passes it, so a credential made under another name does not decrypt as this key. The daemon, collied apns test and doctor decrypt through stdin and stdout, never to a file. Doctor reads the seal from the credential header and reports it ("host key only (no usable TPM2)", "host key and TPM2" or "TPM2"); it fails on a credential that does not decrypt or has a null key, warns on an unrecognized seal, and warns when a host-only credential exists but a TPM2 is now usable (re-import to bind it). When systemd-creds fails to encrypt (for example no systemd, no systemd-creds.socket, systemd older than 256, a container), import falls back to a 0600 copy at <data dir>/apns/AuthKey_<key ID>.p8 named by key_path and says so; doctor reports that file as protected by its mode only while systemd-creds stays unusable, and warns to import again once it works (built).
ResidualNo per-application binding: any process running as the user decrypts the credential with no prompt, so same-UID malware reads the key itself, where on macOS the Keychain ACL leaves it the signed collied or a Keychain prompt. Root, and anyone who authenticates as an admin through polkit, decrypts it too. An unprivileged caller cannot choose the seal: systemd picks host key and TPM2 when a TPM2 is usable, else host key only, and falls back to host key only if TPM sealing fails; doctor shows which one applies. Without a usable TPM2 the seal is host key only (the dev machine: T2 Mac hardware, where systemd-analyze has-tpm2 reports partial), so whoever reads the unlocked disk (host secret, machine-id and credential) can decrypt it off the machine. The credential stops decrypting after an OS reinstall, a machine-id change, a uid or user name change, or a restore to another machine: import again from the backup. The 0600 fallback file is protected by its mode only, like key_path on macOS.
PhaseBuilt

Live Activity on the lock screen

AssetsFollowed agents' status and labels; the update tokens
AttackSomeone looks at the locked phone, or a party holding the APNs key and a token pushes fake content.
MitigationsNothing is shown unless the user follows the agent, which is off by default. The activity shows status, labels, elapsed time and the approvals count. While an approval routed to it is pending it also shows the command, decrypted on the phone by the widget with the per-Mac notification key and marked privacySensitive, so iOS redacts it while the phone is locked or the display dimmed, and Approve and Deny, only when enc opens with the approvalId as AAD (an id pushed without a valid enc shows "Approval needed" and no buttons). The buttons are a LiveActivityIntent with authenticationPolicy = .requiresAuthentication, so iOS asks for Face ID or the passcode before the decision runs, then the same background path as the notification actions, one decision at a time per approval (nonce fetched from the pinned, paired Mac over the tailnet, fingerprint checked, approval.decide rate limited and audited). Tapping elsewhere only opens the app on that agent, which validates both ids and decides nothing. Tokens live only in collied's push.json (0600) and collie-core memory, are never logged or audited, are bound to the registering device's StableID (another paired device can neither replace nor end them) and are dropped on revoke, on Unregistered/BadDeviceToken, on end, when the terminal is gone and 8 h after the activity first registered (ActivityKit's activity lifetime), so a dead activity stops receiving plaintext; at most 8 per device, one per terminal (built). The alert that expands the Dynamic Island is the approval alert itself, under the same rules (a new prompt alerts, the same prompt reissued within 30 s does not), and replaces the approval notification on that device, so a flapping agent cannot turn it into an alert stream (built in collied).
ResidualAnyone who sees the locked phone sees the followed agents' names, workspaces, whether they wait for approval and the Approve and Deny buttons, but not the command. With the APNs key and a token, an attacker can make an activity show any status or text until it ends, including a fake "Approval needed"; buttons need an enc sealed under the notification key, and a decision still needs an unlock and the Mac's nonce. enc is bound to the approval id but not to the terminal, so such an attacker can replay a real approval's approvalId and enc onto another followed agent's activity: the real command shows, under the other agent's name.
PhaseBuilt; to be verified on device.

Lock-screen and in-app approval

AssetsThe decision on a blocked agent
AttackSomeone holding the phone approves or denies from a notification or from the app, or a fake alert tricks the owner into an action.
MitigationsLock-screen Approve and Deny are authenticationRequired on the notification and .requiresAuthentication on the Live Activity's intent: iOS asks for Face ID or the passcode before the app runs; collie adds no check of its own there (built for notifications). "Approve always" is not offered on the lock screen. In the app, each decision first passes LocalAuthentication deviceOwnerAuthentication with a fresh LAContext, so one unlock never covers a later approval (built). The background decision runs a 20 s capped one-shot session: never a Tailscale login (NeedsLogin fails at once as Unauthorized), pinned Mac StableID, paired hello, then the nonce from the Mac; the follow-up notification claims success only on applied (built).
ResidualThe lock-screen gate is iOS's unlock and nothing more: a device unlocked recently enough for iOS to skip the prompt, or a known passcode, passes it. The alert title and body come through Apple and are shown before any check.
PhaseBuilt; timings to be measured on device.

Apple Watch approvals

AssetsThe decision on a blocked agent; the commands, agent lines and plan usage shown on the watch
AttackSomeone wearing or holding the owner's watch approves or denies, a watch message claims to be allowed, or a watch request names an approval or an answer the phone never showed.
MitigationsWatch decisions are off by default; turning them on in the iPhone's Settings needs LocalAuthentication deviceOwnerAuthentication, and turning them off needs nothing. On every request the phone re-reads the setting from prefs.json (missing, unreadable or before first unlock: off) and trusts no flag from the watch. It decides only an unexpired approval, and an answer it offered, that is in the last application context it sent the watch; anything else is refused with nothing sent. On the watch, each decision first passes deviceOwnerAuthenticationWithWristDetection with a fresh LAContext. The phone then takes the lock-screen path: the same one-shot background decide (pinned Mac StableID, paired hello, the nonce from the Mac, fingerprint checked, approval.decide rate limited and audited). No Approve always, notes or typed text. When the watch app opens, or opens from an alert, it asks the phone for the current state; out of the foreground the phone lists each paired Mac's approvals over the same one-shot sessions (approval.list, no decision) and sends them as the state the watch may decide on, so the shown-state check applies to them unchanged. The watch holds no keys, never links CollieCore and never talks to collied.
ResidualThe phone cannot verify the wrist check, so the setting is the phone-side boundary. Decisions work while the iPhone is locked (after its first unlock). A decision in flight when the setting turns off completes. Alerts mirrored from the iPhone: on device, tapping the iPhone's Approve or Deny on the watch opened the watch app and decided nothing. The watch app is the notification delegate on the watch and turns any response to an alert naming an approval (either button or the alert) into opening that approval in the watch app, so a decision still needs the watch path. The watchOS SDK documents no rule for which app's category a mirrored alert takes; the watch app registers APPROVAL without actions. If watchOS ever sent such a response to the iPhone instead, it would be the lock-screen path, governed by iOS authenticationRequired and not by this setting. A refresh happens whatever the setting, so a locked phone hands the watch the pending commands, as the published state already did; each refresh wakes the phone and dials every paired Mac, at most once a minute while the watch app is in front (each wrist raise makes it active again), about every 15 minutes in the background (a watchOS background app refresh, at most 4 an hour), on every approval alert opened, and on every approval alert received while it is in front, and a Mac it cannot reach keeps the approvals last shown for it. Switching the iPhone to another watch turns the setting off. Commands, agent lines and plan usage reach the watch over WatchConnectivity, the system's encrypted channel to the paired watch, and stay in the watch app's last received context.
PhaseBuilt; to be verified on device.

LAN attacker

AssetsAny socket the computer or the phone exposes
AttackScan and connect to ports on the computer or the phone from the local network.
MitigationsNo kernel TCP listener (built): collied listens only through tsnet; tailscale_loopback (127.0.0.1 SOCKS5 and LocalAPI) is never called; the peerapi kernel listener, which upstream binds on every interface in netstack mode, is patched out. crates/tailnet/tests/end_to_end.rs asserts the process has no TCP LISTEN socket, and collied doctor checks the running daemon the same way. The check (tailnet::kernel_tcp_listeners) runs /usr/sbin/lsof on macOS (lsof's warning for an unstat-able mount point is the one stderr accepted); on Linux it matches the process's socket inodes in /proc/<pid>/fd against LISTEN rows of /proc/<pid>/net/tcp and tcp6. A read or parse error is an error, never an empty list, so a failed check never reads as "no listener". The only real-interface socket is magicsock UDP, which accepts only WireGuard and disco packets authenticated against netmap keys. Control socket is a Unix socket (built).
Residualmagicsock UDP is reachable and parses unauthenticated packets before rejecting them. On the computer, its NAT port mapping (UPnP, NAT-PMP, PCP) can expose that UDP port beyond the LAN; the phone's library is built without a port mapper. Traffic metadata (timing, DERP use) is visible. On the tailnet side, the phone node is user-owned, so any policy rule reaching the owner's devices reaches it: it opens no listener of its own, but netstack serves Tailscale's peerapi to those peers in-process. On Linux a system tailscaled on the same machine is a separate node with its own state and its own listeners, outside this check.
PhaseBuilt

Prompt injection into the terminal

AssetsThe shells behind herdr panes
AttackA prompt or key sequence sent from the phone, or a compromised phone session, escapes the agent and runs commands in the shell.
MitigationsPromptText rejects ESC and every C0/C1 control except newline and tab, preventing an embedded paste terminator. Key is a closed list of nineteen keys (no ctrl+d, ctrl+z, ctrl+r, ctrl+j, ctrl+m, ctrl+o or ctrl+t). Privileged herdr methods are not exposed directly to the phone. For manual agent.type_text, collied validates the live agent identity and authorization, then sends bracketed-paste text and Enter through pane.send_input in one ordered write. This deliberately accepts approval answers without parsing their prompt (Keys and text on a blocked prompt). Shell terminal.run and terminal.send_keys still require enabled terminals and a live grant, with no agent or pending approval in the target pane. Structured approval notes remain one line and are sent only into a verified amend field. agent.scroll_bottom sends only fixed Ctrl+End bytes to a Claude Code scrolled-up screen. agent.answer_notice sends one listed digit without Enter, only above an empty input box under the displayed label. agent.slash_draft sends a bounded slash token as a bracketed paste without Enter, only to an unblocked, unscrolled Claude Code input box, then verifies its contents. Draft replacement clears only the exact expected text in a readable, ordinary input box and rechecks that it is empty before prompting. These writes share a lock and recheck authorization. Enter and Ctrl+Enter remain refused for mirrored slash commands when Claude Code is not blocked. Agent startup and new folders remain scoped to configured task roots and agent kinds, with occupied panes refused and authorization checked before every write.
Residualherdr checks the foreground agent before queuing the text and the delayed Enter: if the agent exits in between, the shell receives the prompt. Closing it needs conditional input in herdr upstream. Text an agent reads from a repository can still steer the agent itself; that is outside collie. A dialog Claude Code draws between agent.slash_draft's last read and its write receives the command token as a paste, the same gap as agent.answer_notice but narrower: the token always starts with /, is never a bare digit and carries no Enter. Dictation through the iOS keyboard's own mic, and the keyboard's paste, reach the prompt field like typing, so a dictated or pasted /word can show in the machine's input box; it is never submitted without a send.
PhaseBuilt; herdr conditional input: upstream proposal.

Approval TOCTOU

AssetsThe decision the user meant to make
AttackThe question under a still-blocked agent changes (new tool call, different command) between display and decision, so "approve" answers a different question.
MitigationsApproval bound to a SHA-256 fingerprint of agent kind, matched rule id, terminal_id, agent session id and the dialog region with its cursor. Claude Code's deny countdown line is left out of the region, the cursor fingerprints, the snippet and the context, so its ticking does not supersede the approval; only its exact wording (a fixed sentence whose only variable part is the time, so it carries nothing the approver needs), only for agent kind claude, and only where Claude Code draws it, below the command block's closing ╌ rule when the dialog has one, else anywhere under the dialog's top rule, at the dialog's margin (indent of 1 or less), and above the question over option 1 of the parsed menu, is dropped; a copy in output or in the command, in a Codex or Copilot dialog, or a line with anything else on it, stays, and nothing inside the command block is ever dropped. The countdown's time is never read, so it changes neither expiry nor alerts. Re-read before any key (same state_change_seq and fingerprint, else superseded). Arrows and Enter are separate calls: after the arrows collied re-reads and sends Enter only if the screen matches the target option's cursor fingerprint. Deny uses esc where the menu labels it (esc) and on the trust prompt, so it does not depend on the cursor. The only unnumbered menu read is Claude Code's folder trust prompt, under the same checks, and only when each option is one aligned line mapping to its own decision under exactly one cursor on a whole dialog; anything else unnumbered is not answered. A note goes through the same path, nonce, fingerprint, cursor check and LocalAuthentication included, only with Approve or Deny on a prompt showing "Tab to amend", and at most 200 characters (longer, it would wrap past what the parser reads back, and could never be confirmed): after the cursor check, tab, then a re-read until the target option reads as its amend field with the rest of the dialog unchanged, the note typed, a re-read until the option reads as Yes, <note> or No, <note> with the rest unchanged, then Enter; the agent must stay blocked with the same state_change_seq and the peer authorized before each write. Any mismatch stops without Enter and without esc (which would cancel the prompt), as superseded. Typed into anything but the amend field, a note could act as digit shortcuts; the field is proven open by a label that changed to the amend wording on the target option. The note is never logged. No key after the 6 s budget. Outcome is applied only if the agent leaves blocked within 3 s, otherwise unconfirmed; neither the app nor the lock-screen follow-up claims success otherwise. 10 minute expiry (all built).
ResidualThe gap between the last re-read and the final key remains until herdr offers conditional input: a question that replaces the dialog inside it, with the same option layout, gets that key.
PhaseBuilt; herdr conditional input: upstream proposal.

Keys and text on a blocked prompt

AssetsThe decision on a blocked agent
AttackA paired phone (or a stolen, unlocked one) uses agent.send_keys or agent.type_text to answer a permission prompt with Enter or y, skipping the approval's nonce, expiry, fingerprint and the app's LocalAuthentication.
MitigationsAgent identity is checked through agent.list and agent.get before manual input, and authorization is re-checked before the herdr write. Keys use the closed allowlist. Typed answers are bounded to 32 KiB, reject unsafe control, bidi and invisible formatting characters, and are bracketed-pasted with Enter in one ordered write. Newlines and tabs are allowed inside the paste. Every send is audited without logging its text. Structured approval.decide actions and notes still use their nonce, fingerprint, expiry and LocalAuthentication checks. Shell panes still require terminal authentication. Ordinary Claude Code keys and prompts still respect scrolled transcripts and mirrored slash commands while the agent is not blocked. agent.answer_notice and agent.slash_draft remain refused while blocked or scrolled up.
ResidualManual input can approve any permission or plan with the paired session alone as the gate. It is not tied to the prompt shown on the phone, so a changed prompt can receive unintended input. The gap between checking the live agent and writing to its pane also remains until herdr offers conditional input; an agent that exits in that gap can leave a shell receiving the write. Notice buttons still have their separate read/write gap: a newly drawn dialog can receive the digit. Rating follow-ups or plugin bands can capture subsequent input, and installed plugin actions run under the displayed label without separate LocalAuthentication. Notice answer processing waits up to one second for the input box to change, but a slower fill can still replace a concurrently submitted prompt. Tip-feedback buttons send only their feedback signal; disabling tips fills a slash command and still requires an explicit send while the ordinary slash-command safeguards apply.
PhaseBuilt

Manual keys and text intentionally have no approval-specific restrictions for Claude Code, Codex or Copilot. They can grant permissions, trust folders, or accept plans without an approval nonce, expiry, fingerprint or LocalAuthentication. All displayed buttons remain available, including Codex's Shift+Left, and key batches are allowed. There is no prompt-rule check, automatic field selection, or answer readback. The user must select the intended control and verify the live terminal. A changed prompt receives the input, and an unlocked paired phone can approve any displayed operation. This replaces the earlier Claude/Codex input restrictions and the Copilot-only key exception.

Attachment uploads

AssetsThe computer's disk; files an agent may read; the contents of attached files
AttackA paired phone (or a stolen, unlocked one) can now write files on the computer. It tries to fill the disk, to write outside the attachments dir or over an existing file (a crafted name, .., a symlink), to plant an executable or a hidden dotfile, to tamper with or hijack another session's upload, or to get file content into the logs.
MitigationsSame gate as agent.prompt: paired full session, re-checked on every frame and before begin, each chunk and the commit (built). Caps: 20 MiB per file (phone, decoder and collied), 32 KiB chunks in order and never past the declared size, 2 uploads in flight per session, idle uploads dropped after 60 s, 200 MiB stored (each file counted at least at its allocated blocks) including what is reserved by uploads in flight, at most 1000 entries under the root so a flood of one-byte uploads cannot use more disk than the byte cap suggests nor make the per-begin scan unbounded, chunk frames rate limited per StableID (128/s) and the other frames by the shared 20/s bucket (built). The phone's name is a hint only: AttachmentName rejects separators, ., .., controls, bidi and format characters, then collied keeps [A-Za-z0-9._-], drops leading dots and dashes and caps the length. Each upload writes into a fresh random 0700 directory created with create_dir under a root that must be a 0700 directory owned by the user, not a symlink; the partial file is opened create_new with O_NOFOLLOW, mode 0600, and renamed inside that directory only, so no existing file is ever overwritten and nothing is executable. The SHA-256 and size are checked before the rename; a mismatch deletes the partial file. An upload id is 16 random bytes bound to the session and StableID that began it. Files expire after 24 h; partials of a previous run are removed at start. The audit log gets the sanitized name, size and a SHA-256 prefix, never content or base64; ChunkData is redacted in Debug (built).
ResidualUp to 200 MiB of phone-chosen bytes sit on the computer for 24 h, and the path is put into a prompt: a file is as trusted as the phone that sent it, and an agent told to read it may follow instructions inside it (prompt injection through the file, as with any repository content). Contents are readable by anything running as the user and are not encrypted at rest beyond FileVault on macOS, or the disk encryption the Linux machine uses, if any. The name, while sanitized, is still the phone's choice and shows up in the prompt and in the audit log.
PhaseBuilt

Task folders from the phone

AssetsThe names of folders under the task roots; the disk under the roots
AttackA paired phone (or a stolen, unlocked one) lists or creates folders outside the roots through .., a symlink, or a folder swapped for a symlink between the check and the use; reads file names, hidden entries or what is inside the folders; sends a name that renders as something else (bidi, invisible or control characters) or that is a path; floods the disk with folders or collied with listings.
MitigationsSame gate as task.new: paired full session, the per-peer token bucket, the method allowlist with params that deny unknown fields, authorization re-checked before the listing and again right before the folder is created, and an audit line for each request (built). The path is canonicalized and must lie under a root, then opened one component at a time from the root's descriptor with O_NOFOLLOW, so a swapped component fails rather than leading outside, and a folder below the root that starts with a dot or carries macOS UF_HIDDEN is refused, so nothing is listed or created inside ~/.ssh, a .git folder or ~/Library; the listing reads that descriptor, and the folder is created with mkdirat on it, which makes exactly one entry and never follows a symlink at the name. The name is a FolderName: one component of 1 to 255 bytes, no / or \, no control, format or other invisible character (Hangul fillers, braille blank, variation selectors), no leading dot (so no ., .. or hidden folder), no surrounding whitespace, checked on the phone and in collied. Creation needs a name that does not exist yet and a path under 1024 bytes, makes an empty 0755 folder owned by the user (umask applies) and nothing else, checks that its path resolves under the roots to the folder it made, and is one op_id, so a retry replays rather than creating again. A listing returns only names that pass FolderName, of directories and of symlinks that resolve to a directory under the roots, skips macOS UF_HIDDEN entries, scans at most 10,000 entries and returns at most 500; contents and file names are never read or sent. Listed folder names never reach the audit log or the server log; for task.new the created path (<path> (created)) or the requested name (<cwd> new_folder="<name>") is the audit target (built, tested).
ResidualEmpty folders can pile up under the base, as fast as the shared rate limit lets new tasks start (one at a time per session). A folder stays if the agent then fails to start; the error names it. herdr opens the path by name after collied's check, as for any task.new, so a process running as the user could swap it in between (Same-UID malware on the Mac). The names of folders directly inside any visible folder under the roots (the home folder by default) are visible to the paired phone, which could already have an agent list them. The base folder is a phone setting: collied re-checks every path against the roots, whatever the phone stored.
PhaseBuilt

Task clone from the phone

AssetsThe user's account on the computer; the disk under the task roots; git and SSH credentials
AttackA paired phone (or a stolen, unlocked one) sends a clone source that git reads as an option (-u, --upload-pack), a local path or another transport (file://, ext::, fd::), credentials in a URL, or a target that escapes the roots or overwrites an existing folder; relies on an insteadOf rewrite or a protocol.<name>.allow in the user's config to reach a dangerous transport; or hangs collied on a credential prompt.
MitigationsSame gate as task.new with new_folder: paired full session, one task at a time, an op_id, authorization re-checked before the folder is created and again before the clone runs, and an audit line naming the source (built). CloneSource admits only owner/repo, https://host[:port]/path and git@host:path with a narrow ASCII alphabet, no leading - in any part, no userinfo, query or ..; owner/repo never reaches git as typed. The source and target follow -- for both git clone and gh repo clone. The target is a new empty folder made with mkdirat under the roots, so an existing one is refused before anything runs. The child has null stdin, GIT_TERMINAL_PROMPT=0, GH_PROMPT_DISABLED=1, no inherited GIT_* variables, GH_TOKEN or GITHUB_TOKEN, and command-line config that allows only https and ssh, which outranks the user's config files and applies after URL rewriting. It runs in its own process group with a 75 s timeout and kill_on_drop. Submodules are not cloned.
ResidualThe user's own git config still applies otherwise: url.<base>.insteadOf may send a source to another HTTPS or SSH host the user configured, and credential helpers, core.sshCommand and hooks from init.templateDir or core.hooksPath run as configured. A GUI credential helper or SSH askpass could show a prompt on the Mac until the timeout. A clone that times out leaves its partial folder in place; the error names it. The cloned repository is untrusted content the agent then works in (prompt injection, as with any repository).
PhaseBuilt

Task on a pull request's branch

AssetsThe user's account on the computer; the repository's branches; git and SSH credentials
AttackA paired phone (or a stolen, unlocked one) sends a pull request number that reaches gh as something else; a pull request whose head branch name git reads as an option or a revision expression, or that turns into a checkout path outside .worktree/; a fork's branch, whose project settings an agent would load; an origin that is not the pull request's repository, so another commit is checked out; a fetch that hangs or prompts.
MitigationsSame gate as a worktree task.new: paired full session, one task at a time, an op_id, authorization re-checked before gh, before the fetch and before herdr's worktree.create, and an audit line with the number (built). The number is an integer from 1 to 2^32 - 1, checked when the frame is parsed, and reaches gh pr view as decimal digits. gh runs as for linked pull requests. A cross-repository pull request is refused. The head branch name passes the typed branch rules (ASCII letters, digits, /._-, no .., no leading -, no trailing /) and git check-ref-format --branch; the checkout folder is the name with / as -, must be a FolderName and must not exist under .worktree/, and herdr's answer must name that path. The fetch takes only +refs/heads/<branch>:refs/remotes/origin/<branch> after --, without tags, submodules or FETCH_HEAD, in the clone's environment (HTTPS and SSH only, no prompts, no inherited GIT_*, own process group); the gh read, the checks and the fetch share one 75 s budget. origin/<branch> must then be exactly the head commit gh named. A local branch of that name is never moved and must be at that commit, or the task is refused; a missing one is created tracking origin/<branch>.
Residualgh picks the repository from the checkout's remotes, and the fetch always uses origin; a checkout whose origin is a fork of the pull request's repository fails the commit check. The branch is the repository's own code, which the agent then works in (prompt injection, as with any repository). If herdr then fails, the new local branch stays; the error says so.
PhaseBuilt

Task resume from the phone

AssetsAgent transcripts; the first prompt line of each session; the agent session the user resumes
AttackA paired phone (or a stolen, unlocked one) lists sessions of a folder outside the roots, sends a session id that reaches a file path or the agent's command line (.., a separator, --fork-session, a leading -), resumes a session of another folder, or resumes a session already running in a pane so two agents write the same transcript; the listed prompt lines end up in logs, the audit log or a push.
MitigationsSame gate as task.new: paired full session, one task at a time, an op_id, authorization re-checked before the herdr writes. task.sessions and task.new resolve cwd under the roots. SessionId admits only 36 characters of 0-9a-f- not starting with -, checked when the frame is parsed and again with collied's own is_session_id; the agent arguments are built by collied, never taken from the phone. A resume needs the session's transcript in that folder; a session any herdr agent reports is not listed and is refused. Transcript files are read as for summaries (O_NOFOLLOW, O_NONBLOCK, regular files only), bounded in count and bytes. Prompt lines are capped at 160 characters with controls, bidi and format characters dropped, sent only to the paired phone, shown as plain text, and never logged, audited or pushed; the audit line has the folder and the count, or the session id for a resume (built).
ResidualThe first prompt lines can carry secrets the user typed, like the summary's last_prompt. A session open outside herdr (a plain terminal, the Codex app) is not detected, so two processes may append to the same session. Claude Code's project folder name is lossy (/a/b-c and /a/b/c share one), so such folders list each other's Claude sessions. Codex sessions started through a symlinked path that differs from the canonical folder are not listed.
PhaseBuilt

Task archive from the phone

AssetsA task's checkout and uncommitted files; merged branches and worktrees in its repository
AttackA paired phone archives another task or removes files outside the configured task roots. A replay repeats cleanup.
Mitigationstask.archive requires confirmation, a paired full session and an op_id for one cached outcome. collied locates the terminal and its workspace in a fresh herdr snapshot, checks the linked checkout and repository against the task roots, verifies that herdr associates that checkout with the workspace, and re-checks authorization before worktree.remove. For a regular folder it closes only the pane. It runs gh poi from a repository inside the roots, with GH_TOKEN, GITHUB_TOKEN and the enterprise token variables removed, and reports cleanup failures separately from the close or removal.
ResidualArchive deliberately forces removal of a linked worktree, including uncommitted changes. gh poi may remove other merged branches and worktrees in the repository, even when archiving from a regular folder. Repository paths can change between checks and subprocess execution under the same user account.
PhaseBuilt

Linked pull requests

AssetsThe user's gh login; the repository and branch of each agent's checkout
AttackA crafted checkout or GitHub response makes collied run gh somewhere it should not, with a token it should not use, or makes the phone open a hostile link.
Mitigationscollied runs gh pr view only while a phone is connected or a paired phone has "Archive when the PR merges" on (then only for agents it could archive), only in checkouts that resolve inside the task roots, with no argument taken from the checkout, a fixed binary path, GH_PROMPT_DISABLED=1, GH_TOKEN, GITHUB_TOKEN and the enterprise token variables removed, null stdin, a 30 s timeout and kill_on_drop. Only the number, state, check outcome, an https:// URL without whitespace or control characters, up to 10 failing check names (no control, format characters or quotes, 100 characters each), the count of reviews and comments (counted inside gh by --jq, so no text reaches collied) and whether it conflicts reach the phone, which opens only https links. The prompts the badge menu fills hold only the URL and those check names, and are sent only when the user taps Send. Nothing from gh is logged.
Residualgh reads the checkout's git config and remotes, as gh poi does. A repository whose remote points at another GitHub repository shows that repository's pull request.
PhaseBuilt

Pull request watch

AssetsThe agent's input; the user's gh login
AttackSomeone who can comment on, review or run checks on a watched pull request uses the wake to steer the agent, or a crafted answer makes collied type something else into the agent.
MitigationsStarting a watch is a paired-phone request, audited, only for a Claude Code agent's linked open pull request with a GitHub-shaped URL, at an interval from a fixed list, with gh signed in. The GraphQL query asks for no comment or review body. The prompt holds only the pull request number and URL, check names stripped of controls, format characters and quotes and cut at 100 characters, and https:// comment and review URLs. It is a PromptText, so no control character reaches the paste. It goes through Driver::prompt with no expected_draft, only to an idle or done agent still on the watched branch, never over text in the input box, which is never cleared. Right before the write, collied checks that the watch still exists and that the phone that started it is still paired. Revoking that phone ends its watches. Each wake is audited. Comment-only wakes stop after 10 in a row, and failed reads after 8.
ResidualThe woken agent may open a review or comment URL and read its text, which is untrusted, as it would if the user asked it to.
PhaseBuilt

Archive when the pull request merges

AssetsA worktree task's checkout and its uncommitted or unpushed work; the agents and panes in its workspace; other worktrees and branches of the repository
AttackA merge, a crafted gh answer or a race makes collied remove a checkout that still holds work, a regular folder, or a workspace where an agent, its subagents or the user are still busy, or reach other tasks' worktrees, or a revoked or forgotten phone keeps the automation on.
MitigationsOff by default; on only while a paired phone that turned it on (task.archive_on_merge, audited, kept by StableID and dropped on revoke or unpair) is still paired, checked again right before the removal; any phone turning it off clears it for all, and a phone where the user never picked it sends nothing. Only a fresh read finds the merge, and gh pr view is read once more before archiving. Only a linked worktree whose every pane is an idle or done agent with no running subagent (a subagent scan that left some out counts as running), in a fresh herdr snapshot, is archived; a regular folder is refused, never just closed. The checkout must have no uncommitted or untracked file, no dirty submodule or submodule off its recorded commit (--ignore-submodules=none overrides .gitmodules), no assume-unchanged or skip-worktree index entry in the checkout or a submodule (whose edits git status hides), and its HEAD must be an ancestor of the merged pull request's head commit (a hex id only, so nothing from gh reaches git as an option). The checkout herdr is about to remove must be the one whose pull request was read, and these checks run on it again right before worktree.remove. gh poi is never run automatically, so no other branch or worktree is touched. Each archive is audited and sends one alert, which mute_done silences.
Residualworktree.remove still forces, so a file written between the last check and the removal (milliseconds) is lost, as is anything git ignores and commits a submodule holds on another branch. An agent that starts working in that window is closed. A sparse checkout (skip-worktree entries) is never archived automatically. A pull request answered by gh for a repository other than the checkout's (a remote pointing elsewhere) can only archive a checkout whose HEAD is already in that pull request's history.
PhaseBuilt

Shell input from the phone

AssetsEvery shell herdr runs, which is the user's account on the computer
Attack1. A paired session without the user present (an app bug, an instrumented app, a stolen unlocked phone) types commands into a shell. 2. A grant is replayed to another session, terminal or machine. 3. The feature is turned on from the phone. 4. Commands leak through logs. 5. Input lands in a pane where an agent now runs, answering its permission prompt outside approvals. 6. A compromised app registers a key that signs without Face ID.
MitigationsOff by default; only collied.toml, read at start, turns it on, and no method changes it. When off, the snapshot lists no shells, and every terminal.* request (but terminal.lock) is refused with terminals_disabled before herdr is called, audited; pane.read reads agent panes only, whatever the setting. A separate allowlist class, params that deny unknown fields, one line of PromptText (no newline, tab, ESC or other control), the closed Key list. Grant: a 32-byte challenge bound to the session and the terminal, 60 s, burned by any attempt; the phone signs the domain-separated machine node ID (from its pinned machine record), terminal ID and challenge with the Secure Enclave terminal key after Face ID or the passcode, with a fresh LAContext each time; collied verifies it against the key recorded at the confirmed pairing. The grant lasts 5 min for that terminal in that session, is bound to the key it was verified with, and is dropped on terminal.lock (sent when the app goes to the background; any other answer closes the session), when the session ends, on revoke or unpair, when the phone pairs again, and as soon as collied sees an agent on that terminal. A running terminal.watch reads nothing while its session holds no grant for the terminal under the current key, so output stops with the grant. Before every write collied re-reads the snapshot (no agent on the pane, launching ones included), re-checks the peer, the grant of the session that sent the operation (a retry from the next session replays it and finds no grant) and that no approval is pending there (hook-driven approvals know of an agent before herdr detects it). Pairing refuses a terminal key that is not P-256 or is the session's TLS key, and the machine's y/N prompt says whether the pairing brings a new key or replaces one, and whether it replaces an existing pairing. Grants are audited, keys by name, and run with the terminal and result only, never the text; a herdr error from these writes keeps its code only. No push, alert or Live Activity carries terminal text; notification actions and the Live Activity intent never send terminal.*, and the terminal key cannot sign while the phone is locked. Shell titles a program sets never leave the machine (built, tested).
ResidualThe passcode plus the unlocked phone gives 5 min of shell input per grant. Within a grant the session is the only gate, as for prompts. The gate assumes the app was genuine when it paired: an app instrumented at pairing time can register a key that signs without Face ID (App Attest is out of scope), and the only way to swap the key later is a pairing confirmed on the machine. Against a compromised app the grant adds nothing overall: approvals ask for Face ID on the phone only, and an agent the phone prompts can run any command, edit collied.toml and restart collied, so the opt-in is no boundary against a phone that drives agents; it guards the plain-shell path and keeps it off by default. Agents herdr does not detect (inside ssh or tmux, or an unknown name) count as shells. An agent starting between collied's check and the write gets the line as a prompt (until herdr has conditional input), and herdr reuses pane ids, so a pane closed and recreated in that window of milliseconds gets the write. Text left on the shell's command line on the machine is joined with the line sent from the phone; the phone shows the screen first. The audit log shows that a command ran, not which one; the shell's history may show it. Granted output can carry secrets to the phone, as the agent screen already does. A shell's scrollback is not confidential once an agent starts in that pane: agent.read and agent.watch (any paired session, no grant) read the pane's recent lines, which include the shell commands and output from before the agent, as they always have. Phones paired before this must pair again to use terminals.
PhaseBuilt

Agent transcripts on the Mac

AssetsAgent transcripts; the context left, latest reply line and latest prompt derived from them
Attackcollied now reads files that hold whole conversations. A crafted session id from herdr (or a same-UID process reporting one) points it at another file through .. or a separator; a symlink or FIFO put in a transcript's place makes it read another file or hang; the derived text ends up in logs, the audit log, push.json, a push payload or an approval; a hostile line in a transcript (prompt injection that the agent echoes, or a same-UID writer) is taken as an instruction.
MitigationsOnly for a live herdr agent of kind claude or codex, whose agent_session id is exactly a lowercase UUID (36 characters of 0-9a-f-): the id is the only part of the path that comes from herdr, next to the working directory mapped to [A-Za-z0-9-]. The file is opened with O_NOFOLLOW and O_NONBLOCK and must be a regular file; summary reads cover its last 1 MiB and only when its size or mtime changed (cached per session id, dropped when herdr no longer reports the session). It is read for flock.snapshot and by the 1 s agent reconcile, the latter only while a paired phone has a session open. Summary values leave the Mac as optional Agent fields (context_left, last_line, last_prompt, last_activity_ms): a percentage, two single lines with controls, bidi and format characters dropped and capped at 160 characters, and the file's mtime, sent only to paired phones in the whois-gated, mutual-TLS session, the same class of data as the terminal content the phone already reads. They are never logged, audited, written to disk by collied, put in a push (alerts and Live Activities are built from herdr's agent record, not from Agent), or used by approvals. collied status does not read transcripts. The phone shows them as plain text (no Markdown, no links) and acts on none of them (built, tested).
ResidualThe derived lines can carry secrets an agent printed or the user typed (a token pasted into a prompt): they reach the phone's memory and screen, as the terminal view already does. A same-UID process can write a transcript that shows false text, a false context level or a fresh activity time; the inbox and ring are display only. Claude Code does not record the context window in the transcript, so unless the status line tap recorded the session's window, collied infers it from the model name (1M for Opus 4.7 and later, Sonnet 5 and later and Fable, else 200K, and 1M once usage passes 200K): a session held to 200K (CLAUDE_CODE_DISABLE_1M_CONTEXT, a plan without 1M) shows more context left than it has, and an Opus 4.6 or Sonnet 4 session with the 1M window shows less until it passes 200K.
PhaseBuilt

Running subagents (Agent.subagents) come from the session's own subagents/agent-<id>.jsonl, subagents/workflows/<workflow>/agent-<id>.jsonl and their .meta.json, reached only through real directories under the session's directory, for ids of letters and digits and workflow names of letters, digits, _ and -, with the same O_NOFOLLOW, O_NONBLOCK regular-file open. Liveness comes from Claude Code's sessions/<pid>.json, opened the same way and capped at 64 KiB, whose pid must match its file name and answer kill(pid, 0); only subagent files written since that process started count. A pid reused by another process after Claude Code was killed without removing its file keeps that session's later-written subagents listed until they finish. Only files whose size or mtime moved are opened, at most 8 per refresh, each read from where its last read ended (at most 512 KiB) or else only its last 64 KiB, lines over 64 KiB left out unheld, and only while a phone is connected. The type, title and activity line are cleaned like the summary lines and capped at 64, 120 and 120 characters; an activity line can carry a file name, a search pattern, a URL or a Bash command's first line when the call has no description, the same class of text Chat already shows. They are never logged, audited, pushed or used by approvals.

herdr's session id is cross-checked against the pane's own processes: their pids come from herdr's pane.process_info (whose argv and cmdline are dropped unread, since they can hold secrets), and each one's sessions/<pid>.json is trusted only when its pid matches its name, kill(pid, 0) succeeds, the process's start time (from the kernel) is no later than the file's startedAt plus 2 s, so a file left by a process whose pid was later reused is refused, and its cwd is the pane's. Disagreeing files keep herdr's id. Hand-offs are followed only from the session's own transcript, to a session whose transcript is in the same project directory, at most 4 hops, never around a loop; a /resume into a background session needs exactly one live, checked kind: bg process of that job and directory. A same-UID process could still write a matching file for a live process it does not own; it can already write the transcripts themselves. Ids stay lowercase UUIDs before they become paths, and the mismatch log line carries ids only.

Chat additionally sends bounded native conversation pages over that same authenticated session. It reads at most 2 MiB per request on a blocking worker, checks the live agent identity again before replying, and binds cursors to the session and file. Missing transcripts are unavailable, not empty. Chat content and search queries are never added to logs, push storage, notifications or approvals. The phone renders Markdown and tool text as display content, with no automatic tool execution or remote image fetching; a user can follow a rendered HTTP or HTTPS link with a host. File URLs and custom schemes are discarded. Tool content remains plain text behind an explicit disclosure. Long text is marked when shortened, oversized or malformed records can be omitted with a notice, and search operates before display truncation. The same transcript confidentiality and same-UID tampering residuals apply to full conversation pages.

Status line tap

AssetsThe plan's usage and reset times; the usage.json collied reads
AttackThe status line input holds the working directory, the transcript path, the session name, costs and PR links, which could end up on disk, in logs or on the phone. A same-UID process feeds the tap or writes usage.json to show false usage, make collied read another file (symlink, FIFO, a huge file), or plant a session id that becomes a path. Two sessions writing at once corrupt the file.
Mitigationscollied statusline keeps only the two windows' percentages and reset times and, per lowercase-UUID session id, the window size within 1K to 100M; nothing else from the input is written, and it prints and logs nothing. The data dir must be 0700 and owned by the user; usage.json is written 0600 through a temporary file created with O_EXCL and rename, under an exclusive lock, and read only through peers::load_json (0600, owned, regular file). The daemon opens it with O_NOFOLLOW and O_NONBLOCK, requires a regular file of at most 64 KiB, and reads it only when its size or mtime changed; the session ids in it are used as map keys, never as paths. The phone receives Flock.plan_usage, the plan.usage event and the approval.list reply's plan_usage (two percentages, two reset times and when they were recorded) only in the whois-gated, mutual-TLS session. It is never logged, audited, put in a push or a Live Activity, or used by approvals (built, tested). It reaches the paired Apple Watch over WatchConnectivity, where only the five-hour percentage and its reset time are written, for the complication.
ResidualA same-UID process can write false usage or a false window size: both are display only (the ring and the usage line), and the phone compares a recorded_ms from the future without overflowing. Claude Code's input names no account, so two subscriptions used on one Mac (a second CLAUDE_CONFIG_DIR) would merge into one figure. The values are as fresh as the last status line refresh on this machine; usage from another machine or claude.ai shows up only after the next refresh here, which the age shown on the phone hints at. The user adds the tap by hand to the status line script; collied never edits ~/.claude.
PhaseBuilt

Apple sees push payload metadata

AssetsProject and agent names, activity timing
AttackApple (or anyone with APNs access) reads payloads and metadata.
MitigationsAlert title is the herdr agent name or the agent kind, never the terminal title or a title the pane's program sets; body is Blocked in <workspace label>; plus approval_id, node_id and terminal_id (thread and collapse id). The background push that clears an alert once its agent moves on carries only approval_id and node_id. The done alert, when an agent finishes a turn of 30 s or more, carries the same title, Done in <workspace label>, node_id and terminal_id, and no enc: nothing about the turn, no terminal or transcript text, never stored by APNs; the phone's "Notify when an agent finishes" setting (mute_done on push.register, inside the authenticated session) turns it off for that device. The pending action (up to 600 chars of the command or question) travels only in enc: ChaCha20-Poly1305 under the phone's per-Mac notification key, fresh random nonce per send, bound to the approval_id as AAD, opened by ColliePush on the phone. The key is generated on the phone and reaches collied only inside the authenticated tailnet session. Never the snippet or nonce; details are fetched over the tailnet (built). Live Activity pushes go only to agents the user follows (off by default) and carry the status, when it started, the same title and workspace label as the alert, the agent kind when it is claude, codex or copilot, and the pending approvals count, in clear: ActivityKit cannot decrypt a content state. While an approval routed to the activity is pending they also carry its approvalId in clear (as the alert does) and the context only as enc, sealed exactly like the alert's; the widget opens it on the phone, so Apple sees only ciphertext. No terminal text, terminal title or nonce (built).
ResidualAgent and workspace names, opaque ids, the ciphertext's length (so roughly the command's length), timing, frequency and the device token are visible to Apple. For a followed agent Apple also sees every status change (idle, working, blocked, done) with its time, and the activity's update token. From the clear push, Apple also sees when each alerted approval's agent stopped being blocked, for every agent, followed or not. From the done alert, unless the phone turned it off, Apple sees when any agent finished a turn of 30 s or more. A leaked notification key (from push.json or a Mac compromise, which already exposes far more; from the phone's Keychain) lets whoever also has the APNs payloads read past and future alert contexts for that Mac until the phone pairs again; it grants no decision, since the nonce never travels in a push. With a leaked APNs key as well, it also lets an attacker forge an alert whose body the NSE shows as genuine context.
PhaseBuilt.

collie architecture 🐑

iPhone (Collie.app)                                   Mac (or Linux)
┌───────────────────────────────┐                     ┌──────────────────────────────────────┐
│ SwiftUI (Swift 6, UI only)    │                     │ collied (launchd agent, user)        │
│  └ CollieCore (UniFFI)        │   tailnet (WG)      │  ├ tailnet node  tag:collie-mac      │
│     ├ protocol codec          │ ── WebSocket/JSON ─▶│  │   tailscale_listen :port only     │
│     └ tailnet node (libts)    │   dial only         │  ├ whois gate → paired node IDs      │
│ CollieWidgets  (Swift only)   │                     │  ├ protocol allowlist + audit log    │
│ ColliePush NSE (Swift only)   │◀── APNs ────────────│  ├ approvals (nonce, expiry, replay) │
└───────────────────────────────┘                     │  ├ APNs client (Keychain .p8, HTTP/2)│
                                                      │  └ herdr client ─▶ herdr.sock (0600) │
                                                      └──────────────────────────────────────┘

On Linux, collied is a systemd user unit, its node is tag:collie-linux and the .p8 is by default a systemd user credential: Linux.

Decisions (2026-10-03)

  • Phone node is user-owned (interactive login); Mac node is tag:collie-mac, Linux node tag:collie-linux. Authorization = paired StableID + configured owner user ID + untagged.
  • libtailscale: upstream submodule pinned by SHA + patch series in crates/tailscale-sys/patches.
  • iOS project generated by XcodeGen from Collie/project.yml; .xcodeproj is not checked in.
  • APNs alert: herdr agent name (else agent kind) and workspace label in clear; the pending action only sealed to the phone's per-Mac notification key. Never terminal text, snippet or nonce in clear.
  • macOS: arm64 only.
  • 2026-10-04: collied also runs on Linux. x86_64 is built and tested; aarch64 is mapped in tailscale-sys but not built or tested. Any other OS is a compile error.
  • 2026-10-04: the node tag is per OS: tag:collie-mac on macOS, tag:collie-linux on Linux. collied login and collied run refuse a node without it, on both.
  • 2026-10-04: on Linux the service is a systemd user unit with the launchd agent's semantics: kept alive, and collied stop keeps it off across reboots until collied start.
  • 2026-10-04: each machine sends its own pushes with its own APNs key (own key ID, revoked on its own). On Linux the key is a systemd user credential (host key, plus the TPM2 when systemd finds one usable), or a 0600 file when systemd-creds is unavailable.
  • 2026-10-06: plain shell panes from the phone (Terminals): off by default, turned on per machine in collied.toml only, never from the phone; their own allowlist class, audited like prompts; opening one and typing into it need a grant that collied verifies, signed by a second Secure Enclave key on the phone that signs only after Face ID or the passcode. One grant covers one terminal for 5 minutes in one session.

herdr integration (verified against 0.9.3 source and live socket)

  • The socket answers one request per connection (NDJSON, 5 s timeouts, 1 MiB line). collied opens a connection per call; long calls (events.subscribe, waits) get their own.

  • herdr has no auth: same-UID means full control. collied's allowlist is the only boundary between the phone and every shell on the machine.

  • No global agent-status stream. pane.agent_status_changed is per pane and pane.updated does not fire on status changes. collied keeps one subscription connection: all lifecycle events plus one status entry per agent pane, rebuilt (subscribe, then session.snapshot) on pane.created/closed/moved/exited/agent_detected, workspace.closed and events_lost. An entry for a vanished pane goes silent rather than erroring, so rebuilds are driven by lifecycle events. A 1 s agent.list reconcile plus ping covers silent streams: it sends agent.status when an agent's state_change_seq, status, title, name, kind or cwd changes, or, while a phone has a session open, its transcript values (below), and flock.changed when agents come, go or change workspace, or a workspace is renamed or renumbered.

  • Agents are keyed by terminal_id; pane_id changes on cross-workspace moves and is never sent to the phone. Status set is idle | working | blocked | done | unknown.

  • With no paired phone session connected, status changes do not read or locate transcripts or build status events. Status times, pane cleanup, approvals, done alerts and followed Live Activities still update. Disconnecting releases the session's terminal watch and event subscription before its close handshake; reconnecting fetches a fresh snapshot. A lost connection without a close frame is detected by the existing 15 s heartbeat and 45 s silence limit. Tailscale maintenance and configured APNs notifications are independent of the app connection.

  • No output stream in the JSON API. While a phone watches an agent (agent.watch), collied polls agent.read {source: recent_unwrapped, lines, format: ansi} at 4 Hz, with the lines the phone sent in agent.watch (clamped to 1 to 1000, 200 when absent), and pushes only when the content changes: a hash of herdr's raw text is compared first, and only changed text goes through sanitize_ansi, the reflow and the diff. Once that raw text has not changed for 5 s, the poll drops to 1 Hz, and the first change brings it back to 4 Hz, so the first change after a quiet spell can arrive up to 750 ms later. When the phone is in Low Data Mode (NWPath.isConstrained) it sends low_data: true on agent.watch and terminal.watch, and collied polls that watch at 1 Hz throughout. low_data can only slow a watch down, never speed it past 4 Hz. The app tells the core on every path update; a change applies to the next watch issued (opening an agent, a re-watch after a patch that did not apply, or the next session, which every return from the background starts), not to a running one. herdr terminal session observe (binary protocol, exact version match) is a later option.

    • The first push of each watch is a full agent.output. Later ones are agent.output_patch {terminal_id, base, head, skip, keep, tail, trail, truncated} when that carries at most half the text: the new text is the head lines, lines skip..skip + keep of the text pushed before (the longest run of lines both share), the tail lines, then the last trail lines of the text pushed before, joined with \n. The trailing lines must start at or after skip + keep. Only the changed lines are resent, not the unchanged input box and status line below them, so a Claude Code spinner tick costs a few hundred bytes instead of about 1 to 3 KB depending on the pane width (a full read is about 120 KB). base is the 64-bit FNV-1a of the previous text; collie-core applies a patch only to the text its watch delivered last (not to a read reply) and only when base matches, otherwise it re-issues agent.watch, which starts again with a full agent.output.
    • The phone sends the depth chosen in Settings > Terminal > History (200, 500 or 1000 lines, 200 by default, kept per device) on agent.watch and on its reads of recent; a change applies to the next watch, which the agent screen issues each time it appears. The phone renders up to 2000 rows (TerminalScreen.maxRows). In wrap mode the oldest lines that would not fit are dropped before rendering, counting the rows each line breaks into, so soft wraps stay tracked; only a line taller than counted (a character Ghostty draws wider than counted) still scrolls the screen and leaves them unknown (wrapsUnknown). Lines break after the last space that fits (a word longer than a row breaks by character) and continue at the line's indent, or past a leading symbol and its spaces like ⏺ or a bullet; each break is a NEL, so selection, copy and links read the line whole. A box-drawn row wider than the phone (a panel padded to the Mac's width, like /workflows) has its space runs after the first character cut to the longest length that lets it fit (one cell when none does), borders and column order kept, so the indentation inside a box stays while the padding before a border goes first. Without wrapping, the grid is 500 columns wide (TerminalScreen.maxColumns) but each row is read only up to the most columns the snapshot can reach. The view renders on the main thread (about 3 ms for 1000 wrapped lines, measured on a Mac), so a snapshot that arrives while the view is being scrolled is rendered when the scroll stops.
    • Claude Code's fullscreen mode ("tui": "fullscreen") draws in the alternate screen and keeps its transcript itself, so the pane has no scrollback and herdr returns only the visible rows for an ANSI read.
    • Copilot's terminal-cell scrollbar is removed from reads and changed watch output before either wrap mode renders it. Only a trailing ┃ at the pane width, separated from content by a space, is removed; box borders and bars inside text are kept. A separately styled scrollbar beside a box is removed without removing the box's border. When herdr cannot give the width, repeated, separately styled scrollbar cells outside boxes supply it; unstyled bars are never used to guess. Wrap detection runs on the original rows so the scrollbar's column still counts. Copilot prompt boxes retain one continuous dark background while wrapping, without adding indentation to copied text. Copilot's wraps and splits (as for Claude Code, below) cover its prose under ● and its sent prompts, read from the screen alone. A sent prompt is a shaded box from a ▄ row to a ▀ row whose first row is ❯, the text and an HH:MM timestamp; Copilot wraps the text at the columns before that timestamp like wrap-ansi with trim: false, so the width comes from the box, not the pane. A row joins the next only where Copilot certainly broke it after a word: it ends with one space in the text's style and the next word would not have fit. A full row (one ending at a word, a long token such as a URL, a path or CJK text without spaces cut at the width, and the row after it, which may start with a hidden space), blank rows, rows starting with a space and a box whose top row scrolled away stay as they are, since a typed newline looks the same there. Where a prompt's first row joins, the phone moves the timestamp after the joined prompt, and spaces joining shaded rows keep the shade.
    • Claude Code wraps its prose at the pane width with real line ends, which recent_unwrapped cannot undo. For a Claude Code agent, agent.read and each changed watch push read the pane width with herdr pane.layout and list the rows that continue a paragraph wrapped at that width: wraps (after a space) and splits (inside a word), on TerminalRead, and on agent.output_patch only when they changed. Only prose under an indent-0 ⏺ or ※ row counts, at exactly the hang of the row above, and a shaded sent prompt's rows after a row Claude Code ended with a space in the text's style and the next word would not have fit, never list items, tool call headers (their ⏺ blinks while the tool runs), tool output under ⎿, box and table rows, the input box or the status line. A zoomed tab, or a width herdr does not give, joins nothing. Claude Code's true black background (a standalone 48;2;0;0;0, which its logo uses) is replaced with the default background in both, so it does not show as black cells on the phone. With line wrapping on, the phone shows the text with those rows joined (collie-core's TerminalSnapshot.reflowed) and wraps it at its own width; with it off, it shows the Mac layout unchanged.
  • agent.prompt refuses blocked agents, but herdr's agent.send_keys does not. collied rejects agent.prompt for an agent it sees as blocked (re-read with agent.get just before the call). agent.send_keys and agent.type_text reach a blocked agent only when the prompt, read at that moment, is a Claude Code question form with nothing that grants a permission on it, and agent.type_text also a plan's feedback field (Prompts without decisions), so a permission prompt can only be answered through approval.decide.

  • Scrolled-up Claude Code. While Claude Code's fullscreen transcript is scrolled up on the machine, a permission prompt scrolls out of view with it and herdr reports the agent idle, so keys would answer it unseen. collied refuses agent.send_keys and agent.prompt (which herdr ends with Enter) to a Claude Code agent with agent_not_ready when the screen (pane.read source=visible format=ansi, read just before the write) shows the "Jump to bottom" or "N new messages" banner (protocol::jump_banner: Claude Code draws the banner centered over whatever row is there, on an SGR background, padded by a space on each side. It matches, on any row, a run of text on a background that is exactly the label alone or the label and its hint with that padding; and, since herdr writes no escape between the banner and a prompt's text drawn in the same style (white on the prompt's grey), a label and hint inside a background run, padded, whose center is within half a column of the row's own center, in display columns, so wide and combining characters count as the terminal draws them). agent.scroll_bottom {terminal_id} (Drive class, audited with the terminal, no op_id since it is idempotent) sends Claude Code's Ctrl+End (scroll:bottom) as the fixed bytes ESC[1;5F through pane.send_text, since herdr 0.9.3 has no key name for it, only to a claude agent that is ready or blocked and only while that banner shows; nothing the phone sends becomes input. Ctrl+End never moves a dialog's selection, and a prompt it brings back is blocked again with a new state_change_seq, so its approval is raised again. The phone shows a Jump to bottom button while TerminalSnapshot.jump_banner is set for a Claude Code agent, with the key strip off. Text that quotes the banner reads as scrolled up only when drawn the same way, until it scrolls away: replies have no background, but a prompt row, a diff line or other text on a background matches where a run of it is exactly the banner with a space on each side (text between two inline code spans, say) or holds it padded at the row's center. Not detected: a banner drawn without color (FORCE_COLOR=0, say) or in reverse video. Not covered: a prompt that appears while the transcript is already scrolled up is not seen as blocked, so no approval or notification is raised until it is scrolled back down.

  • Claude Code notices. Claude Code answers its notices above the input box (the "Heads up" tip 1: Learn more 2: Knew this already 0: Dismiss, the explanation after Learn more 1: Understood 2: Chat in main session 0: Dismiss, the feedback rows after a Dismiss, the session rating question 1: Bad 2: Fine 3: Good 0: Dismiss) with a single keypress, which a prompt cannot carry (herdr pastes it and ends it with Enter). protocol::notice reads the options of any notice from the screen, labels included, in screen order, by one rule rather than a list of known notices: anchored on the input box: the last row on screen starting at column 0 with ❯, directly below the box's top rule (a row of ─, or one carrying a renamed session's title the way Claude Code draws it, ─── <title> ─ with exactly one ─ after the title), so a dialog, which replaces the box, never matches and a box whose rule does not read this way gives no options rather than a rule higher up, at most three blank rows (the box's one-row top margin, which a hint draws over, and the two blank rows a real screen showed above it) and any right-aligned hint rows (indented by three or more spaces, such as ● high · /effort or new task? /clear to save ...) between the options and the rule, option rows indented by two spaces whose cells all read <digit>: <label>, the digits exactly 1 to n (n from 1 to 4) then 0: Dismiss last (so a wrapped row of the notice's text that reads like an option breaks the order), each label non-empty, at most 64 characters, with no control or format character and no :. Above the options is the notice's lead: a row starting at column 0 with ✦ (Claude Code's tips) or ● (its rating) and a space, then any text, and rows indented by two spaces or blank below it (wrapped text, an explanation's cards). The lead row may carry the first options itself after its text (✦ Dismissed. 1: That was helpful 2: Didn’t understand 0: Dismiss), with the rest wrapped whole onto option rows, and then nothing else sits between them. The transcript's own rows at column 0 start with other glyphs (⏺ for a reply on macOS, ❯ for a prompt, ✻ for a finished turn), so a reply ending in that shape directly above the box does not match; on Linux Claude Code marks replies with ● (see the threat model). Where the lead holds a blank row, as an explanation does between its header, its cards and its options, the options must stand apart from the text above them by a blank row and sit directly on the box's top margin or on the two blank rows above a hint, with no other blank row below them, since the cards are text a side agent wrote and every card ends with a blank row. Anything else gives no options. agent.answer_notice {terminal_id, digit, label} (Drive class, digit one of "0" to "4", label the option's label as the phone showed it, audited with the terminal, the digit and the label, no op_id: a replay is refused once the notice is gone) sends that digit alone through pane.send_text, no Enter (after any answer it keeps the input box lock until the box shows something other than empty or the digit, for up to 1 s: Claude Code shows the digit for 400 ms, clears the box and runs the option, which can then fill it, so a prompt cannot be pasted before Claude Code's fill replaces it and herdr's Enter submits the fill), only to a claude agent that is ready (never blocked, where a digit could select a permission option), when the visible screen read just before the write shows no "Jump to bottom" banner, and the visible screen, read last, shows a notice listing that digit with that label directly above an empty input box (notices reuse digits with other meanings, so a tap on a screen that has since changed is refused) (Claude Code's dim placeholder counts as empty, so a digit is never appended to a draft and a second tap during Claude Code's keypress debounce is refused). The phone shows the options as buttons from TerminalSnapshot.notice for a Claude Code agent that is not blocked or scrolled up, hides them after a tap until the next screen, and restores them with the error if collied refuses and no newer screen arrived. It shows them only 600 ms after it first saw them, since Claude Code ignores a digit typed sooner after the notice appears and leaves it in the input box. Understood marks the topic known; Chat in main session fills the input box with the explanation as a quoted note (Here is a note offered by a side agent:), unsent, which shows in the phone's prompt field the next time the agent's screen opens, as draft sync reads the box once per visit (Claude Code submits it directly only when it has no input box, and collied answers only while one shows empty). On the feedback row after a Heads up's Dismiss, 1 to 3 log a feedback event about the tip (enums and counts, never the tip's text or the transcript), and 1 also marks the tip known; 4, with the box empty, fills it with /plugin disable cc-plugin-you-should-know@builtin, unsent, shown on the phone like the note: tips turn off only when that command is sent, and Enter from the key strip is refused while it shows (the box starts with /); 0 closes the row. On internal builds the Heads up also lists 4: Disable: the first 4 relabels it Press again to disable, and a second 4 fills the box with the same /plugin disable command, unsent. Any installed Claude Code plugin whose band draws this shape above the box is answered the same way, and its option's code runs (see the threat model). What follows a rating (a transcript sharing question answered with y, n or d, a free-text feedback field, [1] for /feedback) does not take this shape and is answered on the machine.

  • Prompt text rejects ESC and C0/C1 controls because herdr pastes it with bracketed paste.

  • Residual race (herdr side): herdr checks the foreground agent before queuing the text and the delayed Enter. If the agent exits in between, the shell receives the prompt. Closing it needs a conditional input in herdr (expect: {terminal_id, state_change_seq, foreground pid} on agent.prompt / agent.send_keys); to be proposed upstream.

  • Mutations (agent.prompt, agent.send_keys, agent.type_text, task.new (with or without new_folder), task.archive, terminal.run, terminal.send_keys, attachment.begin, attachment.commit) carry a client op_id; collied keeps a per-peer outcome cache so a retry after a dropped connection returns the first result instead of re-running.

  • Task folders. task.options also returns roots, the canonical [tasks] roots. task.folders {path} (Drive class like task.new, audited with the requested path and ok folders=<n>, plus truncated, never the names; no op_id since it only reads) lists the folders directly inside a folder that resolves under the roots: collied canonicalizes the path, finds its root, then opens it one component at a time from the root with O_DIRECTORY | O_NOFOLLOW (openat), so a folder swapped for a symlink after the check fails instead of leading outside, and refuses it (folder is hidden) when any folder below the root starts with a dot or, on macOS, carries UF_HIDDEN, so ~/.ssh or ~/Library is never listed. Of its entries it keeps names that are valid FolderNames (1 to 255 bytes, no / or \, no control, format or other invisible character, no leading dot or surrounding whitespace), then, by fstatat without following, directories and symlinks that resolve to a directory under the roots, and on macOS skips entries flagged UF_HIDDEN (~/Library). It scans at most 10,000 entries, sorts case-insensitively and returns at most 500, with truncated when any were left out, and the canonical path; nothing inside the folders is read. task.new takes an optional new_folder (a FolderName): cwd is then the parent, opened and refused when hidden the same way, a path that would reach 1024 bytes is refused, and collied creates the folder with mkdirat(parent_fd, name, 0755) (the umask applies; an existing entry, symlink included, is folder already exists), checks that <parent>/<name> resolves under the roots to the folder it made (same device and inode), then starts the agent there as usual; no git init, no file. The created folder is the audit target (<path> (created), or <cwd> new_folder="<name>" when it was not created or the check right after creating it failed, whose error then names the folder left in place) and the op_id covers the whole request, so a retry replays the first result instead of failing on the folder it made. If the start then fails, the folder is left in place and the error says so. The base folder is kept on the phone only, per machine (taskBases in prefs.json), saved in the canonical form task.folders returned, so a folder collied refuses is never stored, and dropped when the machine is removed; the New Task sheet lists it once each time it opens (never per keystroke), completes names locally, and sends <base>/<name>, which collied resolves and checks like any cwd.

  • Clone. task.new takes an optional clone (a CloneSource), exclusive with new_folder and worktree: owner/repo (a GitHub owner and repository name), https://host[:port]/path or git@host:path, where every path segment is ASCII letters, digits, ., _ or - and starts with neither . nor -; any other transport, a local path, userinfo, a query, .. or a leading - is refused when the frame is parsed, on the phone and in collied. The folder is the last segment without .git and must be a FolderName. collied creates it inside cwd exactly as for new_folder (so an existing entry is folder already exists before anything runs), then clones into it: owner/repo with gh repo clone -- owner/repo <folder> when gh is in /opt/homebrew/bin, /usr/local/bin or /usr/bin, else git clone -- https://github.com/owner/repo.git <folder>, which is also the second attempt when gh exits 4 (not logged in, nothing run) and left the folder empty; a URL with git clone -- <url> <folder>. The child gets null stdin, no GIT_* variables from collied's environment, GIT_TERMINAL_PROMPT=0, GH_PROMPT_DISABLED=1, no GH_TOKEN or GITHUB_TOKEN, and through GIT_CONFIG_COUNT protocol.allow=never with only https and ssh allowed (explicitly denying file, ext, git and http), which outranks config files and still applies after a url.<base>.insteadOf rewrite. It runs in its own process group, killed when 75 s have passed since the first attempt or when dropped. A failed clone returns the tail of git's stderr (at most 1000 bytes, without control or invisible characters) and removes the folder if it is empty again, or names it. The audit target is <cwd> clone="<source>", or <folder> (cloned) clone="<source>" once the clone succeeded; if the agent then fails to start, the clone stays and the error names its path. The phone waits 165 s for the reply.

  • Pull request worktree. task.new's worktree can be {"mode": "pull_request", "number": N}, N from 1 to 2^32 - 1 (anything else is refused when the frame is parsed). collied lists the repository with herdr as for Create, then runs gh pr view N --json headRefName,headRefOid,isCrossRepository in its checkout, with gh from /opt/homebrew/bin, /usr/local/bin or /usr/bin in the environment of the linked pull request read (30 s). A pull request from a fork (isCrossRepository) is refused: its branch is someone else's code, and a project's agent settings in it would run on the machine. The head branch name is untrusted: it must pass the rules for a typed branch name (ASCII letters, digits, /, ., _ and -, no .., no leading -, no trailing /, at most 255 bytes) and git check-ref-format --branch, and the head commit must be 40 or 64 lowercase hex digits. The checkout path is .worktree/<branch with / as -> as for Create and must not exist. collied then runs git fetch --no-tags --no-recurse-submodules --no-write-fetch-head origin -- +refs/heads/<branch>:refs/remotes/origin/<branch> in the clone's environment (no GIT_* from collied, no prompts, HTTPS and SSH only, its own process group), checks that origin/<branch> is the head commit gh named (so an origin that is not the pull request's repository, or a push in between, fails with try again), and creates the local branch with git branch --track -- <branch> origin/<branch> unless it exists. An existing local branch must be at that same commit, otherwise the task is refused (a local branch <branch> exists at another commit ...; open it with Open or delete it) and the branch is not moved. herdr's worktree.create then checks out that existing branch, and the task continues as Create. The gh read, the checks and the fetch share one 75 s budget. The audit target is <cwd> pull_request=<N>. The phone waits 165 s for the reply.

  • Resume. task.sessions {cwd} (Drive class, no op_id since it only reads, audited with the path and ok sessions=<n>, never the prompts) lists up to 20 recent sessions of a folder that resolves under the roots, newest first by the transcript's mtime, for the allowed kinds among claude and codex, leaving out sessions a herdr agent already reports as its agent_session. Claude Code sessions are the <uuid>.jsonl files directly in ~/.claude/projects/<project dir of cwd>/; Codex sessions are the rollouts under $CODEX_HOME/sessions/YYYY/MM/DD/ (newest folders first, at most 10,000 listed) whose first line is session_meta with that exact cwd, the id of the file name and a cli or vscode source (not codex exec or a subagent). Of each kind only the 400 newest files are opened, with the same O_NOFOLLOW | O_NONBLOCK open as summaries, and only their first 256 KiB (64 KiB for a rollout's session_meta line) are read. Each entry has the kind, the session id (a lowercase UUID, SessionId), the first line of the first human prompt (Claude's first main-chain user record with origin.kind human, a slash command giving its marker and its output skipped as for last_prompt, Codex's first UserMessage or user_message event), cleaned and capped at 160 characters like last_prompt, and the mtime. When that head holds no complete prompt, the session is listed with an empty one if the transcript is longer than the head (the prompt sits in a line the head cuts, such as one with an inline image, or after it), and left out otherwise. The prompt line goes to the phone only: it is never logged, audited or pushed. task.new takes an optional resume (a SessionId), exclusive with new_folder, worktree and clone, and then prompt is optional. collied refuses a kind other than claude or codex, a session whose transcript is not in that folder (Claude's file at the project path, or a Codex rollout found by id whose session_meta names that cwd), and a session that a herdr agent already reports as its agent_session (the session is already open in a herdr pane). It then creates the workspace as usual and starts the agent with args: ["--resume", <id>] for Claude Code (2.1.296) or ["resume", <id>, "--no-daemon"] for Codex (0.162.1), built by collied from the validated id; without a prompt nothing is typed and the startup dialog check that guards the prompt's Enter is skipped. The audit target is <cwd> resume=<id>. Chat then reads the same transcript, since a resumed session keeps its id.

  • Ultracode. task.new takes ultracode (default false), refused for any agent but claude (ultracode needs Claude Code). It is the Ultracode switch of Claude Code's /effort picker (Tab), which Claude Code 2.1.299 also takes at launch as the session-scoped setting ultracode, so collied adds ["--settings", "{\"ultracode\":true}"] to the agent's args (after --resume <id> on a resume) and sends no keys to the picker; herdr 0.9.4 quotes each argument for the shell. Claude Code leaves it off when it is unavailable (dynamic workflows off, or a model without it), so once the agent is ready collied reads its screen (pane.read source=detection) for up to 1 s until the input box's top rule, the rule directly above the last ❯ row, carries the ultracode tag (─── ultracode ─). If it never does, the start fails with agent_not_ready ("ultracode could not be enabled; nothing was sent"), the workspace is left open and the prompt is not sent. New task shows the switch for Claude Code only (not for a resume), off by default, and keeps the last choice per machine (taskUltracode in prefs.json). Tests run screens captured from Claude Code 2.1.299 (crates/collied/tests/fixtures/claude-2.1.299): a new session without and with the setting, and its /effort picker.

  • Agent kinds. [tasks] agents in collied.toml defaults to ["claude", "codex", "copilot"]. task.options offers those herdr has a manifest for (server.agent_manifests), the first as the default, and task.new refuses any other. Collie passes args: ["--no-daemon"] for Codex so its SessionStart hook inherits the launching pane environment instead of the environment retained by a shared app-server. This applies to folder and worktree tasks, keeps /new and resume reports bound to that pane, and leaves already running sessions unchanged. herdr 0.9.5 starts all three with agent.start (kind names claude, codex, copilot, each its canonical executable; a CLI that is not installed fails the start and the workspace is left open) and reads their state from its screen manifests (codex 2026.10.11.1, copilot 2026.08.29.1). Codex's hook (version 10, herdr integration install codex) reports only its session; the turn hooks of herdr 0.9.4's version 9 are gone and collied doctor warns until it is reinstalled. Codex falls back to unknown when no rule matches, so a Codex agent that is unknown and interactive_ready counts as started. A startup prompt (Codex's or Copilot's folder trust, Codex's update picker) fails the start with agent_blocked, to be answered in the terminal. herdr does not rule every one blocked, so before the prompt collied reads a Codex or Copilot agent's screen and fails the start the same way if it shows a numbered option (❯, › or no cursor) or folder trust wording, or cannot be read. For Codex's update picker (an "Update available" heading and an "Update now" option, legacy or 0.159+), the message ends with "Codex is asking to update. Answer it in Terminal.", the sentence New task shows on the phone; nothing is typed into the picker. On macOS herdr refuses an agent.start whose launch line is longer than the terminal's canonical input limit while the shell starts (agent_start_input_failed); collied answers too_large with a message to wait for the prompt and try again.

  • Folder browser and completion selections fill the full child path from the daemon's canonical listing, for both task folders and worktree sources. Manually entered relative names still resolve against the saved base folder.

  • The New Task prompt has the same keyboard-collapse control as the agent prompt. It clears focus without changing the prompt or attachments. Browsing folders does not cancel the task form; cancellation belongs to dismissal of the entire sheet.

Tailscale (libtailscale, pinned commit, never tagged upstream)

  • libtailscale main@d1dc45d (bindings 0.2.0, with the listener fd-passing fix 258321b) as a git submodule. Upstream pins tailscale.com v1.104.1; patch 0001 only raises its go directive to 1.27.2. Requires Go ≥ 1.27.2; builds use GOTOOLCHAIN=local, -mod=vendor (vendored with go mod vendor, below), -trimpath, -buildvcs=false, -ldflags=-w.
  • tailscale_getremoteaddr was unsafe for identity before 0.2.0: its map was keyed by the Go-side fd number, so with concurrent fd churn it returned the wrong peer 17% of the time in testing. 0.2.0 sends each connection's address in a fixed 64-byte record with its fd and keys the map by the received fd; collie still never calls it. tailscale_loopback would open a 127.0.0.1 TCP listener (SOCKS5 + LocalAPI) on the machine and is never used either. The patch series applied by tailscale-sys:
    1. tailscale_accept_with_addr returns the peer ip:port from the record that carries the fd (upstream's record holds the IP only). A per-listener lock keeps concurrent accepts from reading between the pieces of a record whose MSG_WAITALL read was cut short, and the short-read path keeps the control message (upstream drops it and miscounts on EINTR). A failed accept closes any fd it received, and an accept that waited for the lock re-checks that the listener is still open before reading. crates/tailnet/tests/end_to_end.rs checks that every accepted connection's address and whois match its dialer, from four source addresses, with eight threads accepting at once from a backlog of queued records and with one accept loop.
    2. tailscale_whois_json(sd, addr) via the in-memory LocalClient (same mechanism as tailscale_status_json).
    3. Disable log upload to log.tailscale.com in Go (TS_NO_LOGS_NO_SUPPORT), since a c-archive snapshots the environment at load and a later setenv is invisible to Go.
    4. Guard the per-handle lastErr with a mutex (also where getremoteaddr sets it): upstream writes it unsynchronized, a Go data race on a string when one handle is used from several threads.
    5. Keep the Go copy of each accepted fd open until C has received it: macOS's unix-socket GC can dispose of a socket whose only reference is in flight, and the peer's first bytes are lost (reproduced at about 1 in 150 connections).
    6. tailscale_dial_timeout and tailscale_set_advertise_tags.
    7. Socketpair proxy: non-blocking Go end so close always delivers EOF (a lost shutdown wakeup left readers hanging), shutdowns through Control so they never reach a reused fd number, cleanup exactly once as soon as either direction ends (upstream waits for both), no fd-number-keyed map.
    8. UserLogf discarded: tsnet otherwise prints the interactive login URL to stderr every few seconds.
    9. Listener socket close waits for in-flight sendmsg (and patch 5 sends each fd inside its os.File Control), so no fd number can be reused under a send.
    10. tailscale_rebind(sd, default_if): records default_if (the interface NWPathMonitor reports) with netmon.UpdateLastKnownDefaultRouteInterface on Apple platforms, as Tailscale's own apps do, so new sockets bind to it instead of netmon's cached default; injects a netmon event and waits (1.5 s at most) until netmon has published it, so netmon re-reads the interfaces (through a routing-table sysctl, not its route socket) and a move to another interface publishes a major link change (engine rebind, DERP reconnect, control connection closed) before the call returns and the links redial; then runs the LocalAPI rebind and restun debug actions (magicsock Rebind, which re-binds the UDP sockets, pings each DERP connection and reconnects one that does not answer within 3 s, and forgets the peers' direct paths, then ReSTUN). On iOS netmon has no time-jump check, and iOS can defunct its route socket while the app is suspended (XNU exempts only local and system sockets), after which netmon only logs read errors and never sees another change; without this the node keeps binding to the Wi-Fi interface the phone left.
    11. logtail.Disable() next to TS_NO_LOGS_NO_SUPPORT: with uploads off, every log line was still written to the tailscaled.log*.txt buffer, read back and compressed. crates/tailnet/tests/end_to_end.rs asserts both files stay empty.
  • The iOS archive is built with -tags=ios,ts_omit_portmapper: no NAT-PMP, PCP or UPnP probes of the Wi-Fi gateway on every netcheck. collied keeps its port mapper, so a direct path to a phone behind a hard NAT can still form from the Mac's side.
  • Vendored module patches (patches/<import path>/<file>.patch, applied after go mod vendor; go.sum still verifies every module):
    • tailscale.com ipn/ipnlocal/peerapi.go: never open the kernel peerapi listener when netstack is present. Upstream binds it to every interface in netstack mode, which exposed a TCP port to the LAN. crates/tailnet/tests/end_to_end.rs and crates/e2e assert the process has no kernel TCP listener; collied doctor checks the running daemon the same way.
    • gvisor tcp/connect.go + stack/stack.go: a SYN reusing a 4-tuple in TIME-WAIT is handed to the forwarder instead of dropped (was the 63 s dial stall).
    • tailscale.com wgengine/netstack/netstack.go: close link queues before aborting endpoints so tsnet.Server.Close cannot hang.
    • tailscale.com wgengine/magicsock/magicsock.go: TS_DEBUG_MAGICSOCK_BIND_LOOPBACK=1 binds the UDP sockets to 127.0.0.1 and ::1 instead of every interface. Only the tailnet tests set it, next to TS_DISABLE_PORTMAPPER, so the macOS application firewall never prompts for a newly built test binary; collied and the app leave it unset.
  • Every patch is applied with patch --fuzz=0, so a hunk whose context lines changed fails the build, naming the patch, instead of applying with fuzz. A hunk whose unchanged context only moved still applies at the new position, so a bump still needs upstream's changes to those files reviewed.
  • Bumping: move the submodule, rebase the patches and review upstream's changes to the files they touch. A patch that no longer applies is redone by hand on the new version, never forced. crates/tailnet/testcontrol requires the same tailscale.com as libtailscale's go.mod (crates/tailscale-sys/tests/versions.rs). To run a newer tailscale.com than upstream pins, run go get tailscale.com@vX.Y.Z and go mod tidy in a copy of the submodule with the series applied, save the go.mod/go.sum diff as a patch, do the same in testcontrol and point that test at the patched version.
  • What control sees: Hostinfo App libtailscale, Package tsnet, IPNVersion 1.104.1-ERR-BuildInfo (the tailscale.com version; -ERR-BuildInfo because of -buildvcs=false) and GoVersion. libtailscale reports no version of its own, so control cannot tell 0.2.0 from an older libtailscale on the same tailscale.com. What Tailscale keys its libtailscale notice on is unknown: collie's nodes were flagged while already reporting 1.104.1. Stamping the version with -X tailscale.com/version.longStamp=... and shortStamp is an option to revisit once Tailscale says what it reads.
  • Gate per connection, before the WebSocket upgrade: whois(peer) must show an untagged, not shared-in node owned by the owner user. The owner is [tailnet] owner_user_id in collied.toml when set; otherwise the first phone confirmed on the machine (collied pair, y/N showing node, StableID and login) fixes it in peers.json, and every later peer must match. Then:
    • paired StableID with a pinned TLS key → full session (hello first, then the allowlist). While a pairing window is open, pair.complete from a paired phone goes through the same code and y/N confirmation as a new phone and replaces its record (a phone that removed the machine while it was unreachable pairs again without a revoke). A paired phone's unpair revokes it as collied peers revoke does: collied answers, then removes it, drops its push tokens and closes its sessions. The app sends it when the machine is removed while connected;
    • unpaired (or paired without a TLS key), while a locally opened pairing window is active → pairing-only session: hello and pair.complete only, one attempt, then close;
    • otherwise answer a fixed 403 without reading the request, close, and log; the phone treats 403 as final and stops retrying.
    • At most 4 live sessions per node: a fifth evicts that node's oldest (a phone reconnecting after iOS killed its sockets is never locked out). Global cap 64. Revocation cancels in-flight replies and re-checks authorization before every frame.
  • Phone: takes the Mac's IP from its own netmap (tailscale_status_json), from the entry with the Mac's DNS name, the StableID pinned at pairing and the pinned tag, checks it with whois, then tailscale_dials that IP; no DNS lookup (tailnet.md). It never uses tailscale_loopback (iOS reclaims that listener on suspend).
  • tailscale_up blocks until Running and is not cancellable; collied drives its own state machine from status_json (BackendState, AuthURL) and always calls tailscale_start first.
  • Accepted fds are AF_UNIX socketpairs: await readability with AsyncFd, then tailscale_accept, then set_nonblocking, then tokio::net::UnixStream.
  • magicsock binds a UDP socket on real interfaces: that is the WireGuard tunnel itself and is the one unavoidable non-tailnet socket. No TCP listener exists outside the tailnet.

Mutual TLS

Inside the tunnel, after the whois gate and before the WebSocket upgrade: TLS 1.3 only, with RFC 7250 raw public keys (no certificates), ECDSA P-256 with SHA-256 only, no session resumption (a resumed session would skip the key check). Each side pins the other's key by the SHA-256 of its SubjectPublicKeyInfo (KeyPin, base64url). The shared verifiers are in crates/tls.

  • collied makes its P-256 key on first start (tls-key.json, PKCS#8, 0600 in the data dir). Its pin is in the pairing QR (k=, invite v=2), and the phone keeps it as the machine's key. A new key means pairing every phone again.
  • The phone's key is a Secure Enclave key (Identity.swift), non-exportable, kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, so lock-screen decisions can connect. collie-core holds only its public key and asks the app to sign each handshake (IdentitySigner). The simulator uses a software key.
  • A full session needs the phone's pinned key (peers.json tls_key). A pairing session takes any P-256 key, and confirming the pairing records it. A phone paired before mutual TLS has no key: the gate treats it as unpaired, so it pairs again through a window (collied pair, scan), which replaces its record.
  • A peer the gate rejects still gets the fixed clear 403 before any TLS, so nothing from it is parsed. The phone recognizes it from the first bytes it read (Sniff) and stops retrying. A machine that presents another key is a pin violation; collied refusing the phone's key arrives on the phone's first read (TLS 1.3 checks the client after the client's side is done) as KeyRefused. Both stop retrying and ask to pair again.
  • After the handshake collied runs the gate again under the peers, pairing and sessions locks (server.rs connection). A different decision, such as a revoke, a pairing or the window closing during the handshake, refuses the connection. The per-node session cap and the registration happen under those same locks.
  • PROTOCOL_VERSION 3: an app and a collied from before mutual TLS cannot talk to the new ones. 4 adds agent.output_patch, which an older app would drop and so freeze its terminal view. 5 adds lines to agent.watch, which an older collied refuses as an unknown field. 6 adds terminal_key to pair.complete and the terminal methods. 7 adds trail to agent.output_patch, which an older app would apply wrongly. 8 adds low_data to agent.watch and terminal.watch, which an older collied refuses as an unknown field. 9 adds agent.scroll_bottom, which an older collied refuses as an unknown method. 10 adds agent.answer_notice, which an older collied refuses as an unknown method. 11 adds agent.slash_draft, which an older collied refuses as an unknown method. 12 adds label to agent.answer_notice, which an older collied refuses as an unknown field, and its digit "4"; collied and the app ship together. 13 adds task.folders and new_folder on task.new, which an older collied refuses as an unknown method and field, and roots on task.options. 14 adds mute_done to push.register, which an older collied refuses as an unknown field. 15 adds task.worktrees and the worktree option on task.new. 16 adds task.archive and its result. 17 adds root to agent.changes, which an older collied refuses as an unknown field, and worktrees and the committed section to its result, which an older app cannot read. 18 adds scope to agent.changes, which an older collied refuses as an unknown field, and base and each file's hash to its result; the branch scope compares HEAD with origin/HEAD, then main, then master, which collied resolves itself. 19 adds clone to task.new, which an older collied refuses as an unknown field. 20 adds task.sessions, which an older collied refuses as an unknown method, and resume on task.new with prompt optional when it is set, which an older collied refuses as an unknown field or a missing prompt. 21 adds agent.watch_pull_request, which an older collied refuses as an unknown method, and pr_watch on Agent. 22 adds ultracode to task.new, which an older collied refuses as an unknown field. 23 adds task.archive_on_merge, which an older collied refuses as an unknown method. 24 adds the pull_request mode to task.new's worktree, which an older collied refuses as an unknown variant. 25 adds backspace and the Control letters ctrl+a, b, e, k, l, u and w to Key, which an older collied refuses as unknown variants.

Approvals

The agent reconcile (approvals.rs) issues one approval per blocked terminal_id: {approval_id (16 B), terminal_id, agent_label, workspace_label, snippet, tool, options, choices, accepts_input, has_text_field, supports_note, nonce (32 B), created_at_ms, expires_at_ms}. expires_at_ms is created_at_ms + approval_ttl (ServerConfig, approvals::TTL = 600 s; not a collied.toml key). The screen comes from agent.explain (matched rule id; never exposed, its output carries raw pane text) and pane.read source=detection.

  • Menu. Only Claude Code's select menu is answered: agent kind claude and a rule in prompt::CLAUDE_MENU_RULES (herdr claude manifest 2026.10.11.1). herdr 0.9.5's live_blocked_form_named_session is a question in a session named with /rename or --name, where Claude Code draws the name on a rule (─── <name> ─) under the form's footer, with its task panel, if any, below it. That rule takes nothing from the phone by itself. Under it only, prompt::without_session_name drops the name rule and what follows it before anything else reads the screen, and reads the rest as live_blocked_form, so the menu, snippet, alert body, keys and settings picker check see the form as in an unnamed session. Its check is looser than herdr's regex and relies on herdr having matched: the last rule on screen must carry text after its dashes (a plain box border never does, so a menu typed into an idle renamed session's input box is not read), the last line above it must end in "Esc to cancel", and below it must be only blank lines and task panel rows (N tasks (...), ◻, ◼ or ✔ rows, … +N pending). Anything else is read whole under herdr's rule, raising an approval with no options. A known limit: a footer wrapped inside "Esc to cancel" (a very narrow pane) is not cut, so that question is answered in the terminal. With Claude Code 2.1.300 in a renamed session the permission prompts, the dynamic workflow prompt (dynamic_workflow_prompt, whose footer is not a form footer) and the settings pickers (live_blocked_form) draw no such rule and match as before (fixtures in crates/collied/tests/fixtures/claude-2.1.300). The menu is the last numbered block on screen, numbered from 1 without gaps, with exactly one ❯, in the dialog after the last rule (a numbered block above it is old output); a horizontal rule directly between two options is part of it (Claude Code 2.1.289 draws one above a question's "Chat about this") and does not start the dialog region. The one unnumbered menu read is Claude Code's folder trust prompt (since 2.1.289: "No, exit" first, then "Yes, I trust this folder"; same rule live_blocked_form): in the dialog after the last rule, with no numbered line, exactly one ❯ line, its options one line each at the cursor's label column, a blank line and the body above, a blank line and a hint below with nothing indented past the cursor, every option mapping to its own decision and one carrying trust wording. A wrapped or misaligned label, a second or missing cursor, an unknown option, a partial screen or any other unnumbered menu is not read. Its options are then handled as a numbered menu's (decisions, keys, cursor fingerprints). Anything else still raises an approval, with no options: the phone shows it, nothing can be decided.
  • Other agent kinds. Codex (codex) and GitHub Copilot CLI (copilot) prompts are not parsed as Claude menus. A blocked agent still raises an approval, but its alert has no actions, it never goes on a Live Activity, and every approval.decide is refused (invalid_params, nothing sent). All agents can instead be answered manually through the session's terminal buttons and composer, without approval-specific input restrictions. A pending approval ends when its snippet no longer matches the screen, so a new prompt under a still-blocked agent is not shown with the old command. Permission and trust fixtures cover these paths.
  • Why no approval actions. Copilot has no usable non-screen source for its prompts and Codex's is not built (checked with codex-cli 0.160.1 and Copilot CLI 1.0.94); an approval is never taken from screen parsing alone. Codex's PermissionRequest hook hides the terminal prompt until the hook exits, gets a stale HERDR_PANE_ID from the shared daemon's environment, and each hook must be trusted by hand. The shared app-server daemon does send approvals (item/commandExecution/requestApproval, with thread, turn and item ids) to every same-uid client, but with approvals_reviewer = "auto_review" Codex asks its hooks and then its guardian, and asks the user only when neither decides (in a test the guardian approved the request itself), so a phone path would rarely fire; it would also need to bind each request to a pane (herdr rule, dialog command, thread cwd and lsof agreeing), and is not built. Copilot's permissionRequest hook fires before allow rules, locks the terminal while it waits, never sees path prompts and carries no request id; the SDK extension path needs the global experimental flag, uses @experimental permission RPCs and gets the session's whole RPC surface. Neither is used. Revisit if approvals_reviewer is set to "user" (Codex) or Copilot's extension and permission APIs become stable.
  • Settings pickers. A Claude Code settings picker opened from a slash command raises no approval, so no alert, Live Activity count or watch entry: /effort and, in Claude Code 2.1.294, /config's time format, output style and flagged message pickers, each a setting change that runs no tool and grants nothing (prompt::settings_picker). Each needs agent kind claude and rule live_blocked_form (or live_blocked_form_named_session once its name rule is cut, see Menu), and is matched on the dialog under the last ▔ top border, whitespace removed so wrapping changes nothing: the effort slider must read "Effort", "Faster … Smarter", a track of ─ with one ▲, then end on its exact footer ("←/→ to adjust · Enter to confirm · s for this session only · Esc to cancel"), with no ❯ line; the other pickers must show their exact text and nothing else, the ❯ cursor and ✔ mark aside. Any other dialog under the same rule (a question, the trust prompt, /ide, /memory, /mcp, /config's other sessions, project instructions, language and update channel pickers) still raises one, and a picker whose text changes in a later Claude Code alerts again until it is added. /advisor also still raises one: its "No advisor" option reads as a decision, so keys are refused and its approval's choices are the only way to answer it from the phone. /model needs nothing: herdr leaves its status unchanged (rule model_picker_menu). The picker still shows on the agent screen, and the agent screen's arrows, Enter and Esc still answer it, as before: it is a live_blocked_form dialog with nothing that grants a permission (Prompts without decisions), and a test checks that every suppressed picker stays open to keys. Tests run screens captured from Claude Code 2.1.294 (crates/collied/tests/fixtures/claude-2.1.294) and, in a renamed session, 2.1.300 (crates/collied/tests/fixtures/claude-2.1.300).
  • Options. Each decision (approve, approve_always, deny) maps to exactly one option label (Yes / Yes, proceed / Yes, I trust; Yes, and don't ask again and similar; No / No, ...). A decision matching zero or several options is not offered; so are other options, such as Claude Code 2.1.289's "Yes, and switch to auto mode". A menu with no approve or approve-always option is not a permission prompt and offers no decision at all, not even deny: questions (AskUserQuestion), plan prompts and other free-form menus.
  • Choices. choices is the menu as read, whenever one parsed (permission prompts included): {index (0-based), label, current}, current on the ❯ option. Labels have controls, bidi and format characters stripped and are capped at 120 chars (limits::MAX_CHOICE_LABEL_CHARS). An approval with no options is answered by choosing one: approval.decide {decision: "choose", choice: <index>}. A permission prompt never takes choose: picking its options by index would bypass the decision they map to (Deny sent as Esc).
  • Note. supports_note is set when the menu offers approve or deny and "Tab to amend" shows under its last option (Claude Code 2.1.289's footer "Esc to cancel · Tab to amend"; the body above the options can quote any text, so it does not count).
  • Tool. collied hook, registered as Claude Code's PermissionRequest hook, reads the hook input (hook_event_name, session_id, tool_name, tool_input) and sends {session_id, tool_name, shown} over the control socket, shown being what the dialog shows of the call: for Bash the command, then Claude's description; else the file path, URL, query or pattern (other tools send nothing). It never answers: no output, exit 0, a 2 s limit, so Claude Code shows its dialog as usual, collied running or not. Claude Code 2.1.289 runs the hook beside the dialog, not before it, and a session's dialogs can be answered before collied looks, so a report names an approval only when its dialog shows the call: each shown part must be a run of whole dialog lines once whitespace is removed (wrapping only splits lines, and a shorter command never matches inside a longer line), and the dialog must offer decisions (the hook does not fire for AskUserQuestion). collied keeps the last report per session (64 sessions at most, each for the approval TTL). With a matching report the approval's tool is {name, summary} (every line kept, cleaned like the context, at most 600 chars with the name) and the alert context is <Tool>: <summary>; otherwise the screen's context as before.
  • Snippet and labels. Snippet: the end of the dialog (for a menu, its last 12 body lines), at most 200 chars, in whole lines (a line is cut, with a leading …, only when it alone does not fit), without Claude Code's tips (the same margin and dashed-rule rule as the context below). Labels: at most 64 chars. Both have controls, bidi and format characters and box borders stripped.
  • Fingerprint. state_change_seq only moves on status changes, so a new question under a still-blocked agent would not change it. The approval is bound to SHA-256 over the length-prefixed agent kind, rule id, terminal_id, agent session id and dialog region (last horizontal rule through the last option, with the cursor; spinners and status lines outside it are excluded). collied also keeps one fingerprint per option: the region as it reads with the cursor on that option. Claude Code's deny countdown (⚠ Claude Code will automatically deny this request in M:SS, to avoid blocking progress on an unattended session, or about N minutes / about N seconds in place of M:SS, since 2.1.295, in a session it takes for unattended) changes every second, so prompt::without_deny_countdown takes it out of the screen text before anything reads it, the approvals and the drive paths that type into a dialog alike: only for agent kind claude, only when the dialog's last option block parses as a menu, and only where Claude Code draws it (below the dialog's last ╌ rule when it has any, with the rules paired, else under its top rule, then a blank line, the question, and option 1 of that block), at the dialog's margin, in that exact wording on one line or wrapped over up to three, with nothing else on its lines. A copy in the transcript or the command stays. The fingerprint, the cursor fingerprints, the snippet and the context never see it, so its ticking neither reissues nor supersedes the approval. The time is not read: expiry and alerts are as for any approval, and when Claude Code denies on its own the agent leaves blocked and the approval ends.

approval.decide

  1. Rate limit per paired StableID: 1/s, burst 5 (rate_limited).
  2. Claim. Any attempt on a pending approval burns it, whatever the result; a burned id answers approval_already_resolved, an unknown one approval_not_found.
  3. Expiry, nonce (constant time), decision offered. choose is offered only when options is empty, for an index inside the menu; anything else is invalid_params. choice is set exactly when decision is choose (checked by the decoder). A note (a one-line PromptText of at most 200 characters with no bidi or invisible format character, checked by the decoder, so the amend field still parses once it wraps: the parser takes an option wrapped onto at most 4 rows) goes only with approve or deny, and only on an approval with supports_note; otherwise invalid_params.
  4. Re-read the screen: still blocked, same state_change_seq, same fingerprint, else superseded. Re-check that the peer is still authorized.
  5. Keys, from a fixed set (up, down, enter, esc), never pane text:
    • Deny is a single esc when its label ends in (esc), or on the folder trust prompt, whose "Esc to cancel" exits like "No, exit". It does not depend on the cursor.
    • Otherwise the arrows to the target option, 150 ms pause, re-read: the fingerprint must equal that option's cursor fingerprint (else superseded), peer re-checked, then enter in its own call. Arrows and Enter are never sent together: lost arrows would confirm another option. Digit shortcuts are not used; their select or submit behaviour varies across Claude Code versions.
    • choose always takes this path (arrows, re-read, enter), whatever the option's label: it never sends esc.
    • With a note, Approve and Deny also take it (Deny by arrows, never esc), and before enter: peer re-checked, tab, then polls (every 100 ms, up to 1 s) until the target option reads as its amend field (Yes, and tell Claude what to do next or No, and tell Claude what to do differently, case-insensitive prefix, changed from before Tab) with the cursor on it and every other option and the body unchanged; peer re-checked, the note typed with herdr's pane.send_text; polls until the option reads Yes, <note> or No, <note> (whitespace ignored, wrapped lines included) with the rest of the dialog unchanged; peer re-checked, then enter. Each poll also requires the agent still blocked with the same state_change_seq. On any mismatch nothing more is sent, not even esc (in the amend field it cancels the whole prompt): superseded, and the next reconcile issues a new approval for what the screen then shows.
  6. Settle: poll agent.list every 250 ms for up to 3 s (approvals::SETTLE) for the agent to leave blocked or move its state_change_seq.

The decide budget is 6 s from the claim to the last key; no key is sent after it. With the 3 s settle, a decision answers in under 10 s.

OutcomeWhen
applied {decision, by}final key sent, agent left blocked within the settle
unconfirmed {decision, by}final key sent (or its send timed out, it may have arrived), agent still blocked
chosen {choice, by}, chosen_unconfirmed {choice, by}the same two for choose; their own outcomes so an older app decodes them as other rather than failing on an unknown decision. A multi-question form stays blocked on its next question: chosen_unconfirmed, then a new approval
supersededfingerprint, seq or cursor mismatch; amend field or note not shown; budget spent before the final key; bad nonce; decision not offered; peer no longer authorized; herdr failed; on reconcile, the agent left blocked or moved seq without a decision
expiredpast expires_at_ms, at decide or on reconcile

Every attempt is audited with its decision (choose <index> for a choice, <decision> with note with a note) and result, including replays, rate-limited and expired ones (a run of frames over the per-peer frame limit logs only its first, as rate_limited, and never reaches this limiter); sent keys are logged as terminal=<id> keys=<keys>. The note itself is never logged.

Reissue. A resolved approval is never reused. If the agent is still blocked, the next reconcile issues a new approval (new id and nonce), sent at once to live sessions. Its alert is rate limited per terminal: a new blocked episode, a changed screen (seq or fingerprint) or an expired previous alert always alerts; the same prompt reissued within 30 s of the last alert does not, so bad attempts cannot flood the phone. Alerts carry terminal_id as APNs collapse id and thread-id, so a new alert replaces the dead one on the phone.

Drift. state_change_seq does not move on a new question under a still-blocked agent (the next question of a form, one answered on the Mac) or on a dialog that finishes drawing after the approval was built. So each reconcile re-reads the screen of every pending, undecided approval of a claude agent and supersedes it when what it tells the phone changed: snippet, options, the menu labels, accepts_input, has_text_field or supports_note (the cursor alone does not count, so arrows do not reissue). The same reconcile rebuilds it. Within 30 s of the last alert of that blocked episode the rebuilt approval does not alert, so typing on the Mac cannot turn into an alert stream.

Prompts without decisions

Manual input is available on permissions, trust prompts, questions and plans for every agent. All displayed terminal buttons stay enabled, including Codex's Shift+Left. The session composer sends a typed answer whenever the agent is blocked, an approval is pending, or a Codex question editor is open.

  • agent.send_keys forwards the closed key list, including batches, after checking the live agent identity and peer authorization. There is no approval rule, menu, decision or question-footer check. Blocked startup prompts also accept input. Normal Claude Code keys and typed answers still respect a scrolled transcript and a mirrored slash command when the freshly read agent is not blocked, even if the phone has stale approval metadata.
  • agent.type_text {op_id, terminal_id, text} sends bracketed-paste text and Enter in one ordered pane.send_input write. It does not select a free-text option, read an answer back, or bind input to a prompt fingerprint. Use the terminal buttons to focus the intended control first. Text can contain newlines and tabs; unsafe controls, bidi and invisible formatting characters are rejected, with the existing 32 KiB limit.
  • The phone queues terminal keys and typed answers together, including answers sent from the in-session approval card. Earlier navigation, including Shift+Left, finishes before an answer is submitted; later keys follow it. Adjacent keys can still be batched. A failed input cancels queued actions with a visible error, and an agent-to-shell transition discards pending agent input.
  • Manual input uses the paired session, not an approval nonce, expiry or LocalAuthentication. It can grant permissions, accept plans or trust a folder. A changed prompt receives whatever input the user sends.
  • accepts_input and has_text_field remain descriptive approval metadata for older clients and the optional answer field. They do not restrict session input. The guided typing and Codex answer-verification helpers have been removed.
  • agent.prompt remains the normal prompt API and still refuses blocked agents. The session composer routes blocked answers through agent.type_text instead. Attachments stay available for the next normal prompt. Structured approval.decide actions, notes, notifications and Live Activities are unchanged. Update the app and collied together; older daemons can still refuse manual input.
  • Authorization is re-checked before every herdr write; both carry an op_id through the outcome cache. Audit: keys to a blocked agent are logged with target <terminal_id> blocked keys=<keys>; agent.type_text with the terminal and result only, never the text.
  • collie-core: type_text(machine_id, terminal_id, text); ApprovalDecision::Choose { choice } on decide, reported back as Applied/Unconfirmed with that decision; decide(..., note), a one-line note of at most 200 characters with Approve or Deny (checked before sending); PendingApproval.choices, .accepts_input, .has_text_field, .supports_note. The lock-screen path sends no note.
  • No version bump: the method, choices, accepts_input, has_text_field, supports_note, note, the choose decision and the two outcomes are additive. An older app ignores choices and decodes the new outcomes as other; an older collied sends no choices (it defaults to empty) and no accepts_input, has_text_field or supports_note (all default to false), answers choose and a note with invalid_params and agent.type_text with unknown_method.

APNs

  • [apns] in collied.toml: key_id, team_id (10 uppercase alphanumerics), bundle_id, and the key's location. key and key_path at once is an error; a store for the other OS is a config error. collied apns test sends a test alert to every registered device of a paired phone.
Key locationmacOSLinux
key = "keychain"the default when omittedconfig error
key = "systemd-creds"config errorthe default when omitted (APNs key on Linux)
key_path (a 0600 file owned by the user, opened without following symlinks)legacy; collied doctor warnsthe fallback when systemd-creds is unavailable; collied doctor warns while systemd-creds works
  • Keychain item (macOS): generic password in the login keychain, service dev.rbstp.collied.apns, account = key ID. Its ACL trusts only the program that stored it, recorded by its designated requirement, so a rebuild signed with the same Developer ID Application identity (team RM3UT3MMSR) and identifier dev.rbstp.collied reads it without a prompt; any other program prompts or is refused.
  • just collied-install (macOS) builds collied in release, signs it (Developer ID Application: ... (RM3UT3MMSR), identifier dev.rbstp.collied, hardened runtime, timestamp), installs it to ~/.cargo/bin/collied as a fresh inode and restarts the launchd agent if it is loaded. A plain cargo install build is ad hoc signed and cannot read the item unattended.
  • collied apns import <AuthKey_ID.p8> (macOS), run from the signed binary: needs [apns] ids, refuses a file named for another key ID, reads the file with the key_path checks, checks it is an ES256 key, stores it (an existing item is kept), reads it back and compares, rewrites key_path to key = "keychain" in collied.toml (atomic, same mode, never through a symlink), then asks y/N before deleting the file (only if it is still the same inode).
  • collied doctor (macOS) checks the signature (Developer ID requirement and identifier, codesign line), reads the item with user interaction disabled (what it reads is what the daemon reads unattended), signs a provider token as a dry run, and warns about any .p8 left in <data dir>/apns.
  • Tokens arrive by push.register and are stored per StableID in push.json (0600, atomic), with the device's notification key (below). Each token carries its environment (Debug builds: sandbox, TestFlight: production); one collied keeps a client for each. Every send re-checks the pairing (a registration racing a revoke never gets an alert); Unregistered and BadDeviceToken drop the token; revoking a peer forgets its tokens. Queue of 64 alerts, dropped when full; 10 s per send.
  • Approval alert: push type alert, priority 10, apns-expiration = approval expiry, apns-collapse-id = terminal_id. A device whose Live Activity for the approval's terminal shows approvals gets the approval on that activity instead, and the alert only if the activity push fails or the approval is still pending 2 min later (Approval on the activity).
  • Approval clear: once the agent on a terminal whose approval alerted is no longer blocked (answered in the terminal or from any device, or gone), collied pushes {"aps":{"content-available":1},"approval_id":…,"node_id":…} to every registered device: push type background, priority 5, apns-expiration 0 (never stored: APNs keeps one undelivered push per app, so a stored clear would displace a pending alert), no collapse id. It names the approval the phone's alert carries (a quiet reissue keeps it, a later alert for the terminal replaces it), so one clear per alert at most. The record is in memory only: an alert whose agent moves on while collied is stopped gets no clear. The app, woken in the background (remote-notification background mode), removes the delivered alerts with that node_id and approval_id; its handler does nothing else. iOS may delay or drop it; whenever the app runs it also removes the alerts of approvals no longer pending.
  • Done alert: when an agent's turn of 30 s or more ends, on the 1 s reconcile after the approvals pass. A turn starts when collied sees the agent go working and ends when it next shows done or idle; blocked inside it keeps the start (its time counts), while blocked to idle (a denial, Esc) and unknown end it without an alert. The first poll of an agent never alerts; a turn already under way when collied first sees it (a collied restart, a new pane) is timed from that sight. The 30 s, equal to the approvals' alert gap, skips quick replies and slash commands and caps a flapping agent at one alert per 30 s per terminal. The record is in memory only. Push type alert, priority 10, apns-expiration 0 (never stored, for the same reason as the clear), apns-collapse-id = terminal_id, so one notification per terminal: a done alert replaces that terminal's previous one and the next approval alert replaces it. It cannot replace a pending approval: done needs the agent out of blocked, which supersedes that approval, and the one send queue keeps the order. No mutable-content, so ColliePush never runs, and no enc. A device that registered with mute_done (the app's "Notify when an agent finishes" setting off, stored in push.json, omitted when false) gets none. A followed agent's Live Activity still gets only its silent status update, so the done alert is the one alert. The app in the foreground shows no banner or sound for the done alert of the agent on screen.
  • Live Activity update tokens are stored next to the device's token and pushed to with their own headers: Live Activities.
FieldValue
aps.alert.titleherdr agent name, else agent kind, else agent; never the terminal title or a title the pane's program reports
aps.alert.bodyBlocked in <workspace label>
aps.thread-idterminal_id
aps.mutable-content1 (ColliePush)
aps.categoryAPPROVAL, only when both approve and deny are offered
approval_id, node_idlookup keys for the app
encthe pending action, sealed for the device (below); absent when the device registered no key or there is no context

The payload never carries the snippet, terminal text or the nonce in clear: it is a hint, and the decision goes over the tailnet.

Done alert fieldValue
aps.alert.titleas the approval alert's
aps.alert.bodyDone in <workspace label>
aps.thread-idterminal_id
node_id, terminal_ida tap opens that agent in the app (a Mac that is not paired is ignored)

Encrypted alert context

  • The phone generates a 32-byte notification key per paired Mac (CryptoKit SymmetricKey), keeps it in the iOS Keychain (generic password, service dev.rbstp.collie.notify, account = Mac node ID, AfterFirstUnlockThisDeviceOnly, access group $(AppIdentifierPrefix)dev.rbstp.collie.shared shared by the app and ColliePush) and passes it to register_push(machine_id, apns_token_hex, environment, notification_key, mute_done). collie-core holds each machine's registration in memory only and sends it in push.register (notification_key: base64url without padding, 43 chars, canonical; redacted in Debug) on that machine's connection, on connect and on every reconnect. Pairing again rotates the key; unpairing drops the registration.
  • collied stores the key next to the device's token in push.json, never logs or audits it, and forgets it with the token on revoke, Unregistered or BadDeviceToken. A push.json written before this change loads with no key: those devices get the plaintext alert until the app registers again.
  • enc = base64 (standard, padded) of nonce(12) || ciphertext || tag(16): ChaCha20-Poly1305 under the device's key, a fresh random nonce per device and send, AAD = approval_id (UTF-8), plaintext {"v":1,"body":"<context>"}. ColliePush opens it with ChaChaPoly.SealedBox(combined:). Test vector: protocol/notification-vector.json, checked by both sides.
  • Context (prompt::context, at most 600 chars with … when cut, controls, bidi and format characters stripped, lines joined with \n, which the NSE keeps): from the dialog after the last horizontal rule, up to the last option block. A Claude Code Bash prompt gives Bash: <every non-empty line between the header and the last "Do you want to" question>. Claude's description (under the command, or above it since Claude Code 2.1.289) is included, since plain text cannot tell it apart from a command line, and nothing inside the command block is ever dropped: the approver acts on this text, so a command must not be able to hide its own lines (a line starting Tip:, a blank line, a fake question or option). Tip: lines are skipped only at the dialog's own margin (indent of at most one column) and never between the first and last dashed rule (╌): Claude Code 2.1.289 prints the command there, at that same margin; older versions indent command lines further. Another tool prompt gives <Tool>: <target> (Edit: src/main.rs); else the question line; else the dialog's first line (with the next one when it ends in :, as on the trust prompt).

Lock-screen approval

  • Category APPROVAL has two actions, Approve and Deny (no "approve always"), both authenticationRequired: iOS makes the user unlock the device first, then launches the app in the background without UI.
  • AppModel.decideFromNotification holds a background task assertion and calls decide_from_notification(node_id, approval_id, decision, budget_ms: 20000). The budget is capped at 20 s; 0 is refused. It is a one-shot session, independent of the connection supervisor: start the node from cached state (never a login), wait for the pinned Mac in the netmap, dial, hello, take the nonce from the cached snapshot or approval.list, then approval.decide. It never retries past the budget.
  • Immediate Unauthorized (no waiting) when Tailscale is not set up on the phone, the node reports NeedsLogin or NeedsMachineAuth, the netmap violates the pin, or hello reports the phone unpaired.
  • The report carries the outcome and per-step timings (node up, connect, lookup, decide), logged on device. A follow-up local notification in the same thread never claims success unless the outcome is applied:
Background outcomeFollow-upOpens the approval
applied"Approved/Denied: agent. The agent moved on."no
unconfirmed"..., not confirmed": keys sent, agent still looks blockedyes
expiredexpired, nothing was sentno
supersededthe prompt changed, nothing was sentyes
already_resolved, not_foundno longer pending, nothing was sentno
unreachable at decidecould not confirm the decision reached colliedyes
unreachable earlier, unknown_machine, unauthorized, failed"Couldn't reach collied, open to decide"yes
  • Tapping the alert itself opens the matching agent session on the Agents tab. The iPhone has no separate Approvals tab; the Apple Watch approval list remains. A pending approval is resolved by node and approval id before navigation, with a validated notification thread used to open the agent after an approval has ended. Loading and missing-machine errors remain visible. Structured in-app approval actions pass LocalAuthentication (deviceOwnerAuthentication, a fresh LAContext each time); manual terminal input is separate.

Attachments

The Agent screen attaches a photo or a file to a prompt: the phone uploads it over the tailnet session, collied stores a private copy on the machine and returns its absolute path, and the app inserts that path into the prompt draft. Claude Code then reads the file from disk like any path in a prompt. Nothing is sent to herdr.

Flow

  1. attachment.begin {op_id, name, size, sha256} answers {upload_id} (16 random bytes, hex). name (AttachmentName: 1 to 64 characters, no control, bidi or format characters, no /, \, . or ..) is only a hint; size is 1 byte to 20 MiB; sha256 is lowercase hex of the whole file.
  2. attachment.chunk {upload_id, offset, data} in order: offset must equal the bytes received so far, data is base64 (standard, padded) of 1 byte to 32 KiB, so a chunk frame stays under the 64 KiB frame cap. collie-core sends one chunk at a time and awaits each reply.
  3. attachment.commit {op_id, upload_id} answers {path} once the received size equals size and the SHA-256 matches; otherwise the partial file is deleted and the error is invalid_params (size) or checksum_mismatch.
  4. attachment.abort {upload_id} is best effort and always answers ok.

An upload is bound to the session (and peer StableID) that began it: another session, even of the same phone, gets not_found. Nothing resumes across connections: closing the session drops its uploads, and collie-core reports the upload as interrupted. begin and commit go through the op_id outcome cache like the other mutations, so a commit retried after a dropped reply returns the stored path. Authorization is the agent.prompt gate: a paired, full session, re-checked on every frame and again before begin, each chunk and the commit.

collie-core: upload_attachment(machine_id, name, data, progress) checks the name and size locally, computes the SHA-256, then runs begin, chunks and commit, calling progress.on_progress(sent, total) after each acknowledged chunk; any failure sends attachment.abort. max_attachment_bytes() returns the 20 MiB limit. UniFFI 0.32 does not forward Swift task cancellation to Rust, so the app cancels with cancel_uploads(machine_id), which fails the upload with Cancelled and aborts it on the Mac.

No version bump: the methods, responses and error codes (too_large, checksum_mismatch) are additive. An older collied answers unknown_method, which collie-core reports as "update collied".

Limits

LimitValue
File size20 MiB (limits::MAX_ATTACHMENT_BYTES), checked by the phone, the protocol decoder and collied
Chunk32 KiB decoded, in order, never past the declared size
Uploads in flight2 per session (rate_limited beyond)
Idle uploaddropped, with its directory, after 60 s without a chunk
Stored bytes200 MiB: committed files (the larger of length and allocated blocks) plus the declared size of every upload in flight; begin answers too_large beyond
Stored entries1000 entries under the root, uploads in flight included, so many tiny files cannot exhaust the disk or slow the scan begin runs; begin answers too_large beyond
Chunk rateown bucket per paired StableID, 128/s, burst 128 (4 MiB/s); collie-core backs off and retries a rate limited chunk up to 8 times. Other attachment frames use the shared 20/s bucket

Storage

  • Root: ServerConfig::attachments_dir, by default ~/Library/Caches/dev.rbstp.collied/attachments on macOS and $XDG_CACHE_HOME/collied/attachments (else ~/.cache/collied/attachments) on Linux (no spaces, so the path reads cleanly in a prompt). Created 0700; an existing root that is a symlink, owned by someone else or not exactly 0700 stops collied at start.
  • Each upload gets a fresh <root>/<16 hex>/ (0700, create_dir, fails if it exists). Data goes to .part opened create_new with O_NOFOLLOW, mode 0600; the commit fsyncs it and renames it, inside that directory, to the sanitized name. Files are never executable.
  • Sanitized name: [A-Za-z0-9._-] kept, each run of anything else becomes -, leading dots and dashes dropped, at most 64 characters keeping an extension of up to 16 alphanumerics, attachment when nothing is left (.env becomes attachment.env).
  • One audit line per committed upload: attachment.commit, target = sanitized name, result ok size=<bytes> sha256=<first 12 hex>. attachment.begin is audited with the sanitized name; chunks and aborts are not. Content and base64 are never logged.
  • collied doctor prints an attachments line: root, mode, bytes stored.

Cleanup

  • Session close: its uploads in flight are dropped and their directories removed.
  • Every 10 s: uploads idle for 60 s are dropped.
  • At start and every hour: entries of the root older than 24 h are removed (symlinks are removed as links, never followed). At start, directories still holding a .part from a previous run are removed too.

Draft sync

Claude Code keeps unsent text in its input box, and herdr's agent.prompt pastes after it. For agent kind claude only (other kinds ignore all of this):

  • agent.draft {terminal_id} answers {text}: the box's text, "" when it shows only its dim placeholder, null when there is nothing the phone may pre-fill (another agent kind, no box to read, or an opaque box, below). It reads pane.read source=visible format=ansi through sanitize_ansi. The box is the last block on screen between two horizontal rules drawn from column 0 (the top one may carry a label), whose first row starts at column 0 and whose other rows are indented by two spaces, so a typed row of ─ never closes it. A first row other than ❯ (bash mode's !), or a collapsed paste or image placeholder ([Pasted text #1 +40 lines], [Image #1], [...Truncated text #1 ...], dim or not) makes the box opaque: its screen text is not its content. Dim text inside a ❯ box (the placeholder, completion hints) is not typed text. Claude Code 2.1 soft-wraps the draft with wrap-ansi at the terminal width minus 4 and hides the space at each wrap, so a row the next word would not have fit on joins the next with a space, a full row that split a long word joins without one, and a row starting with a space follows a typed newline. The text has controls, bidi and format characters dropped, trailing whitespace trimmed per row and at the end, and is capped at 32 KiB.
  • agent.prompt takes an optional expected_draft, what the phone last saw. Once the agent passed the ready check, collied reads the box before the paste and fails closed:
    • an empty box: prompt as before;
    • no box, or an opaque one: draft_not_cleared, nothing is sent (the agent is not blocked, so whatever replaced the box may still take the paste, and an opaque box may be a shell command or hold content the phone never saw);
    • a draft equal to expected_draft (both normalized): clear it, then re-read every 100 ms for up to 1 s until the box is empty, else draft_not_cleared and nothing is sent;
    • any other draft, or no expected_draft: draft_changed, nothing is sent. ErrorBody.draft carries the current text so the phone can show it and send again to replace it. The audit line is draft_changed alone: draft text is never logged.
  • Clearing, verified on Claude Code 2.x in herdr 0.9.3 for a draft of N visual rows with the cursor anywhere: down N-1 times (a no-op on the last row), then N times ctrl+e, ctrl+u, backspace (clear a row and join it to the one above; a no-op on an empty first row). Sent in batches of at most 16 keys, authorization re-checked before each. collied sends these keys itself, whatever the phone's Key allowlist holds. ctrl+c is never used (it interrupts a working agent), nor ctrl+d (it exits Claude Code on an empty input); none of the keys used interrupts a working agent. herdr has no delete key name.
  • The phone loads the box's text into an empty prompt field once per visit to the agent's screen, and remembers that the field came from the machine until the phone edits it (keyboard, dictation, paste). While it does, a last_prompt that is not null and differs from the one at load (or at the last check) makes it read agent.draft once: an empty box means the prompt was sent on the machine, so the field empties and its attachments stay. last_prompt comes from human prompts only, so text deleted on the machine, an approval or a turn a background task starts leaves the field alone, as does last_prompt going null while the transcript cannot be read.
  • No version bump: the method, response, field and error codes are additive. An older collied answers agent.draft with unknown_method, which collie-core returns as no draft, so the phone never sends it expected_draft. An older app sends no expected_draft, so a Mac draft now refuses its prompt instead of being appended to.
  • Slash command mirror. While the phone's draft starts with /, only its command token (the / and what follows up to the first whitespace) is mirrored into the box, so Claude Code's command menu shows on the machine and in the phone's terminal view. agent.slash_draft {terminal_id, command, expected_draft} (Drive class, no op_id: it is idempotent, audited with the terminal only, never the command or a draft) takes a SlashCommand, "" or / followed by up to 63 of A-Z a-z 0-9 _ : . - (no space, ESC, control or invisible character, so nothing can end the paste early). collied runs, in order: the ready check (never blocked, launch pending, or another agent or session) for agent kind claude only and no "Jump to bottom" banner on a visible read; replace_draft with expected_draft exactly as for agent.prompt (an empty box passes, the box the phone named is cleared, an opaque or unreadable box gives draft_not_cleared, other text draft_changed); for an empty command, nothing more; otherwise the ready check and the banner again, a visible read that must show an empty ❯ box (else agent_not_ready, nothing written), authorization, then one pane.send_text of ESC[200~, the command, ESC[201~, then a visible read every 100 ms for up to 1 s until the box holds exactly the command. Only then does collied reply and release the lock: a paste takes up to about 110 ms to render, and the next call's first read would otherwise see the box empty, skip the clear and paste after this command (/stat/statu). A box that shows other text by then gives draft_changed with it, an empty or unreadable one agent_not_ready; nothing more is written. herdr 0.9.3 has no paste without Enter, so collied frames the paste itself; Claude Code takes it as typed text and lists the matching commands. Never Enter, never a key. A paste that did not land heals on the next call: the box reads empty and the token is pasted again.
  • The phone mirrors only what the keyboard typed in the prompt field (its own dictation, paste, a restored draft and text it sets itself never reach the machine), only for a Claude Code agent that is not blocked or scrolled up, while not dictating or sending, and only while it knows the box's text and that text is empty or a command. The iOS keyboard's own dictation and paste reach the field as typing. Calls start 150 ms after the last keystroke, one at a time, each toward the latest draft, so a burst of typing makes one or two. Typing after the first space changes nothing on the machine. When the draft stops starting with / or is emptied, the box is cleared once. A draft_changed showing the command this screen last asked to paste (a reply that was lost) is replaced; any other text pauses the mirror and is shown, as on a send, until the next send, so text typed on the machine is never replaced unseen. A reopened screen takes the box's text as known only when it is a bare command equal to its draft's token. The phone compares tokens, not text, and always names the box's text as collied last read it, so a hint Claude Code draws after a completed command (/rename [name], in a color the parser cannot tell from text) never triggers a write and is cleared by the send.
  • Key strip: hidden until the Keys button in the composer (right of the attachment menu) shows it, for every session (keysShown in prefs.json). Shift and Control are selected on the phone and never sent alone: the next key goes out as one Key (shift+tab, shift+left, ctrl+enter, or with Control a letter typed on the keyboard) and the selection clears, so a combination is never more than two keys and collied still sees only its closed list. Key shortcuts (strip-shortcuts.json, this phone only) send one Key, or text through agent.prompt with expected_draft: "" (collied refuses it as draft_changed while a Claude Code input box holds unsent text; Codex and Copilot CLI take it after any text there), or, for a Terminal shortcut only, through terminal.run in a shell. A key shortcut whose key name the app no longer knows is not shown.
  • Picking a command: ↑ ↓ on the key strip move Claude Code's highlight and ⇥ takes it. Before keys the mirror catches up; after keys sent while the box held a command, the phone reads agent.draft 150 ms later and takes the box's command into its draft, keeping its own arguments (/mod opus and ⇥ becomes /model opus; a bare command gets a trailing space, so what is typed next is an argument). A missed read is caught on send: a draft_changed whose command extends the draft's updates the draft the same way, shown before sending again; any other command is shown and left alone. ↑ or ↓ leaves the box's text unchanged, so a send would run the box's command rather than the highlighted one: after them Send is refused until ⇥ or an edit of the command. ⏎ and ⌃⏎ are off while the box holds a command and no prompt covers it (they would run the highlighted one), and keys queued before the mirror landed lose their Enter. collied enforces it too: agent.send_keys with enter or ctrl+enter to a Claude Code agent that is not blocked is refused (agent_not_ready) when a visible read just before shows the box starting with /. agent.prompt, agent.send_keys and agent.slash_draft hold one lock in collied, so another session's send or keys never land between a mirror's read and its paste. Only the phone's send runs a mirrored command: it waits for a mirror call in flight, then agent.prompt clears the token (it equals expected_draft) and pastes the whole draft with Enter, refused while blocked as always. Sending a partial command runs Claude Code's top match for it, as a typed prompt does; ⇥ picks a specific one.
  • Codex and Copilot keep today's behaviour: collied refuses agent.slash_draft for them and the phone never calls it. collied cannot read their input box, so it could neither check the box before a write nor clear it, and their prompts paste after whatever the box holds, so a mirrored token would lead the prompt sent.
  • Residual: text typed on the Mac between collied's read and its clearing keys is cleared too; text typed after the empty re-read is still appended to by the paste. A typed newline exactly where the next word would not have fit reads as a space. When Claude Code limits the box to a viewport (a draft taller than its maximum visible rows), the rows outside it are not on screen: the phone sees, and the clearing keys walk, only the visible rows: part of the draft is cleared and the re-read refuses the prompt.

Context and inbox

herdr reports each agent's session id (agent_session.value in agent.list and session.snapshot, set by its SessionStart hooks, so it follows /clear, resume and compaction). collied maps it to the agent's own transcript and derives four optional Agent fields: context_left (0 to 100), last_line (the first non-empty line of the latest reply), last_prompt and last_activity_ms (the transcript's mtime). No version bump: the fields are additive, and Agent is server-to-phone, so an older app ignores them and an older collied leaves them out.

  • Live Claude Code session: herdr's id can be stale (a session restarted while herdr's server was down) or name a client that only shows another conversation (/resume of a background session). So for a Claude Code agent collied asks herdr for the pane's foreground pids (pane.process_info, every 10 s per pane and when the pane id changes; only the pids are kept, never argv or cmdline; a failed call keeps the pids last listed) and reads sessions/<pid>.json for each, with the transcript's open, capped at 64 KiB. A file counts only when its pid matches its name, the pid answers kill(pid, 0), the process started no later than the file's startedAt (2 s of slack, for Linux's whole-second start time), and its cwd is the pane's. When every such file agrees on one sessionId, it replaces herdr's (logged once per terminal, ids only); otherwise herdr's id stands. From that session, collied follows where its own transcript says the conversation went, reading its last 64 KiB: the latest continued-in record (continuedInSessionId) whose target's transcript is in the same project directory, or a /resume (system local_command with commandRun.command resume) whose output ends running in the background (<8 hex>), taken only when exactly one live process has a sessions/<pid>.json with kind bg, that jobId, a sessionId starting with it, the same project directory and that transcript, with the same pid checks. A later reply or human prompt in that session drops the hand-off. At most 4 hops; a loop or a longer chain keeps the starting session. Chat history, context_left, last_line, last_prompt and subagents all read the session found. For the summaries it is cached per terminal: worked out again when herdr's id or the pane's pids are refreshed, and the hand-offs followed again when the starting transcript's size or mtime moves.

  • Claude Code: projects/<cwd with every non-alphanumeric character as '-'>/<id>.jsonl under CLAUDE_CONFIG_DIR or ~/.claude, else projects/*/<id>.jsonl for a session started in another directory. The latest main-thread (isSidechain: false), non-<synthetic> assistant entry gives the usage: input_tokens + cache_creation_input_tokens + cache_read_input_tokens, as Claude Code's status line counts it; after a compact_boundary entry with no reply since, its compactMetadata.postTokens instead. The window is not in the transcript: collied takes the session's context_window.context_window_size from the status line tap (see Plan usage below) when it has recorded one, else infers it from the model: 1M for Opus 4.7 and later, Sonnet 5 and later and Fable, 200K otherwise, and 1M once the usage passes 200K. So an Opus 4.6 or Sonnet 4 session with the 1M window shows too little left until it passes 200K (5% at 190K, then 79% at 210K), and one held to 200K on a 1M model shows too much. The prompt is the later of the last user entry with origin.kind: "human" and the last last-prompt entry. A skill or custom slash command entry (its origin is also human) gives its marker, such as "/review pr 12", as Claude Code's own lastPrompt does, and a <local-command-stdout> entry is skipped.

  • Codex: sessions/YYYY/MM/DD/rollout-<local time>-<id>.jsonl under CODEX_HOME or ~/.codex. The id is a UUIDv7, so the UTC day it was created and the day on each side are searched. The latest token_count event gives last_token_usage.total_tokens and model_context_window, and the percentage is Codex's own: (W - 12000 - max(used - 12000, 0)) / (W - 12000). item_completed events give the last AgentMessage and UserMessage.

  • Running subagents (subagents, Claude Code only, additive, no version bump): one entry per subagent running in the session, with agent_type, title, activity, started_at_ms, tokens and called_tools. They are the session's subagents/agent-<id>.jsonl and workflow agents' subagents/workflows/<workflow>/agent-<id>.jsonl, each with its .meta.json: agent_type is its agentType (workflow-subagent for workflow agents, whose meta description is the workflow label such as review:correctness). title is the meta description (the Agent call's description, the title Claude Code lists), else agent_type; the phone's row shows it with the elapsed time and tokens. Claude Code's agent panel shows a model-written progress summary that it keeps in memory only, so activity is its fallback line for the latest tool call (Running <description, else the command's first line> for Bash, Reading/Editing/Writing <file name>, Searching for/Finding <pattern>, Fetching <url>, else the call's description), and the meta description before the first one. tokens follows the panel's count, rounded to the nearest 1,000: the latest assistant usage's input, cache creation and cache read, plus the run's output tokens, records of one message (message.id) counted once; the panel also adds a streaming estimate, and output written before the first read's window is missed. called_tools picks the panel's arrow (↓ once a tool was called, ↑ before). A subagent has finished when its latest assistant record ends its turn (stop_reason end_turn) or calls SubagentHandback or StructuredOutput (a workflow agent's result), or is an API error (<synthetic> with isApiErrorMessage), or a [Request interrupted by user record follows; a later prompt or follow-up (a user record that is not isMeta, or has a coordinator, peer or human origin) starts a new run, whose time is started_at_ms (else the file's first record, else the earliest one read; so a resumed subagent first read after its follow-up left the window shows its first run's start). Subagents count as running only while a Claude Code process of the session is alive: Claude Code keeps sessions/<pid>.json (pid, sessionId, startedAt) beside projects while it runs, read with the same open and capped at 64 KiB, cached by size and mtime, its pid matching the file name and alive (kill(pid, 0)), and only files written since the latest such start count. Of those, a file whose size and mtime did not move is not opened (its last values stand), and at most 8 others are opened per refresh, newest first, from the session's directory and its 8 most recently modified workflow directories, with the transcript's checks (no symlink, non-blocking, real directories under the session's own directory, ids of letters and digits, workflow names of letters, digits, _ and -). A file is read from where its last read ended when it grew by at most 512 KiB, else only its last 64 KiB, leaving out any line longer than 64 KiB without holding it (plus up to 64 KiB of its head while no start time is known). Text is cleaned of control characters and capped at 120 characters, and goes to the phone only, never to logs, pushes or the audit log. It is refreshed with the other transcript values on the 1 s reconcile tick, only while a phone is connected. A new or finished subagent or a new activity line sends agent.status on that tick; a change to tokens or the arrow alone goes out at most every 5 s. The phone counts the elapsed time up locally.

  • Copilot CLI: its session events carry no window size and no running token count, so a Copilot agent has no ring; its prompt and reply are not read either.

  • Cost: only the last 1 MiB of a transcript is read (they reach tens of MB; the values sit near the end), lines are scanned from the end until all three values are found, and the result is cached per session id by size and mtime, so an unchanged transcript costs one open and fstat. A value not found in the tail (a single tool result longer than 1 MiB) keeps the one found before for the same session. A transcript not found is looked for again before an agent.status event and on each flock.snapshot, so a reconnecting phone picks up a transcript created while it was away, but not on every reconcile. While a phone has a session open, the 1 s reconcile derives every agent's values and sends agent.status when the context left, the reply line, the prompt or the running subagents changed, so they follow within about a second; the activity time is not compared (it moves on every transcript write) and rides along with those events and snapshots. A phone connecting or leaving sends nothing, since values read are compared only with values read. With no session open, collied reads no transcript between snapshots. While an agent works, its transcript is re-read (up to its last 1 MiB) about once a second.

  • Phone: a small static ring on list rows, grid cards and the agent screen's header, colored by the context used (100 minus the context left) on the watch usage scale (WatchUsage.usedColor: bright green, yellow at 60%, orange at 80%, red at 90%, deep red at 100%); VoiceOver reads "Context N% left". The Agents tab has a third layout, Inbox, between Grid (the default) and List: Working (working and blocked, blocked first), Done (completed within 24 hours) and Inactive (idle, unknown or completed at least 24 hours ago). Idle and completed rows use the status change time, since transcript metadata can update its mtime without agent activity; working rows use transcript activity. Search matches agents across machines, with Needs attention, Working and Inactive filters. Each row shows the latest reply line (else the title), "You: <last prompt>", the workspace tag, the agent kind's icon, the machine and the ring. Rows open the agent like the list. Only live herdr agents are shown: listing sessions whose pane is closed would mean scanning every transcript on the machine.

Security: Agent transcripts on the Mac.

Linked pull requests

Each Agent can carry an optional pull_request: number, state (open, draft, merged, closed), checks (passing, failing, pending, none), url, failing_checks (the names of failing checks), remarks (reviews plus comments) and conflicting. No version bump: the fields are additive on a server-to-phone type, and both enums read an unknown value as unknown.

  • Daemon: a task beside the reconcile loop wakes every 30 s and does nothing unless a phone is connected (events.receiver_count() > 0) or a paired phone has "Archive when the PR merges" on (then, with no phone connected, it reads only the agents it could archive; see below), and gh is in /opt/homebrew/bin, /usr/local/bin or /usr/bin; without gh every agent loses its pull request. For each agent it takes the checkout as agent.changes does (foreground_cwd, else cwd, through resolve_cwd, so only inside the task roots), then the repository root and branch with git; an agent outside a repository or on a detached head has none. The pull request is read with gh pr view --json number,state,isDraft,url,statusCheckRollup,mergeable,reviews,comments,headRefOid --jq '.reviews |= length | .comments |= length' run in the checkout, so reviews and comments reach collied as counts only, never their text, with no argument (gh finds the current branch's pull request itself, so no branch name reaches the command line), at most once per 2 minutes per checkout and branch. A pull request the last good read found MERGED is kept instead of read again while the checkout's HEAD, read locally with git rev-parse --verify --quiet HEAD, is still its headRefOid; a kept result is read with gh again at least every 30 minutes, so a new pull request opened from the same commit is still found. Open, draft and closed pull requests, and an entry whose last read failed, are read every 2 minutes. "no pull requests found" caches none; any other failure (no login, network, timeout) keeps the last value for one refresh, then shows none, and logs nothing. A read whose branch changed while gh ran is dropped. Any change to what the phone is shown sends flock.changed. gh, here and for gh poi, runs with a fixed path, GH_PROMPT_DISABLED=1, no GH_TOKEN, GITHUB_TOKEN or enterprise token variables, null stdin, a timeout (30 s here) and kill_on_drop. Its output is parsed into those fields; a state other than the four, or a url that is not https:// or holds whitespace or control characters, drops the read. failing_checks holds at most 10 distinct names of failing check runs (name) or status contexts (context), in rollup order, each without control or format characters or " and cut at 100 characters, as for a watch. conflicting is mergeable being CONFLICTING.
  • Checks: from statusCheckRollup. A status context's SUCCESS passes, PENDING and EXPECTED are pending, anything else fails; a check run not COMPLETED is pending, a SUCCESS, NEUTRAL or SKIPPED conclusion passes, any other fails. Any failure makes failing, else any pending pending, else passing; an empty rollup is none.
  • Phone: a badge with the state's icon and color and the number, plus a check icon while the pull request is open or a draft, on grid cards, Inbox rows and the agent screen's header. Tapping it opens the url, only when it is https, except on the agent screen, where it opens a menu: Open PR, then the prompts that apply. While the pull request is open or a draft: Fix failing checks when checks is failing, Address review comments when remarks is above 0, Resolve conflicts when conflicting; Explain this PR always. Each fills the composer with one line holding the url (and, for failing checks, the quoted failing_checks names), appended after a blank line to any draft, and sends nothing.
  • collied doctor warns when gh is missing or gh auth status fails.

Watching a pull request

For a Claude Code agent whose linked pull request is open or a draft, the session menu offers "Watch PR" with a choice of how often to check it: every 2, 5, 15 or 30 minutes. While the terminal has a watch, whichever pull request it follows, the menu offers "Stop watching #N, every M min" instead. Agent.pr_watch (number, url, every_minutes) carries it, and the badge shows "Watching" (only the eye on grid cards) when it follows the linked pull request. agent.watch_pull_request {terminal_id, watching, every_minutes} starts or ends the watch. Starting needs every_minutes of exactly 2, 5, 15 or 30, a Claude Code agent, its linked pull request open or a draft with a URL of the form https://host/owner/name/pull/number in GitHub's characters, the agent's checkout on a branch, gh installed and gh auth status passing (otherwise the error asks to run gh auth login on the machine). Stopping takes no every_minutes. Watching the same pull request again only changes the interval. Watches are the machine's, one per terminal, kept in pr-watches.json (0600) in the data directory with the interval, the checkout and branch, and the StableID of the phone that started them, so a watch survives a restart.

  • Reads: a daemon task beside the reconcile loop ticks every 30 s. It reads each watched pull request when its interval has passed since the last read (at once for a new watch, a watch started again, and after a restart), with or without a phone, through gh api graphql --hostname <host> run in the data directory, with the owner and name as -f variables and the number as -F, in the same environment as the linked pull request read. The query asks for the signed-in user, the state, mergeable, the head commit, the base branch's required check names (baseRef.branchProtectionRule.requiredStatusChecks.context and baseRef.rules.nodes.parameters.requiredStatusChecks.context from RequiredStatusChecksParameters), the head commit's rollup state with its check runs and status contexts (name, outcome, isRequired), and the author, URL and time of the last 20 comments and 20 reviews. It asks for no body, so no comment or review text reaches collied.
  • Wakes:
    • A check that fails: each check once until it passes again or the head moves.
    • All required checks passing: once per head commit. The required names are the base branch's, from its protection rule and rulesets, plus any listed check marked isRequired. Each must be listed and pass, so a required check that has not reported yet holds it, and so does a rollup state of EXPECTED. An optional check failing or running does not hold it. With no required check, every listed check must pass and the rollup must not be PENDING or EXPECTED. A required check from a rule the signed-in user cannot read is known only once it reports.
    • A comment or review newer than the newest one seen, by someone other than the signed-in gh user. The time is compared and URLs break ties within a second, so a deleted comment that lets an older one into the window wakes nothing. The first read only records what is there.
    • mergeable turning CONFLICTING; UNKNOWN keeps the last answer.
    • An empty check list keeps the last check state. Changes queue on the watch until the agent takes them.
  • Prompt: while changes are queued, each tick looks for the agent in agent.list. Only an idle or done Claude Code agent whose checkout is still on the watched repository root and branch is woken. Otherwise the wake waits, for example after the agent moved to another branch. The wake goes through Driver::prompt and all its guards with no expected_draft, and the agent's status is read again right before the write. An agent that has just started working, a box holding text, a scrolled screen or a blocked agent refuses it, nothing is cleared, and it is tried again on the next tick. The status read right before the write also refuses an agent that is no longer Claude Code. Right before the write, the authorization check confirms the watch still exists for that URL and the phone that started it is still paired, so "Stop watching" or a revoke during the attempt types nothing.
    • The prompt is one line naming at most 10 changes: Pull request #N (<url>) changed: check "<name>" failed; new review <url>; ....
    • Check names lose controls, format characters and quotes and are cut at 100 characters. Comment and review URLs must be https:// without whitespace or control characters.
    • A head move drops queued check changes. A queued failure, pass or conflict that no longer holds before the wake is dropped.
  • After a wake: an audit line (pr.wake, the terminal id, the summary and the pull request URL) and an alert "Woke <agent>" with the summary (for example "CI failed on #123") through the done alert path, so mute_done silences it.
  • Stops: the watch ends, and anything queued with it, when:
    • the pull request is merged or closed;
    • 10 wakes in a row were made only of comments and reviews (a wake with any other change resets the count);
    • 8 reads in a row failed (no gh, no login, network, an unreadable answer);
    • the terminal is no longer in a non-empty agent.list;
    • the phone that started it is revoked or unpairs, or is no longer paired when the task ticks (a revoke while collied was stopped);
    • the user picks "Stop watching". The menu and badge follow through flock.changed.

Archiving when the pull request merges

A merged pull request's badge reads "Merged" (only the icon on grid cards), and the session menu puts Archive first. Settings > Sessions > "Archive when the PR merges" (off by default, archiveOnMerge in prefs.json, absent until the user picks it) makes collied archive the task itself. collie-core sends task.archive_on_merge {enabled} (Drive class, audited with enabled=) to every connected machine when it changes and to each machine at the start of every session, after push.register; the app sets it at launch only once the user has picked it, so a phone that never did sends nothing and leaves the Macs' setting alone. collied keeps the StableIDs of the phones that turned it on in archive-on-merge.json (0600) in the data directory, so it holds with no phone connected and across a restart. It is on while any phone in that file is still paired. Any phone turning it off empties the file, so a StableID a phone no longer uses (a reinstall) cannot keep it on; a revoke or unpair removes that phone.

  • When: on the linked pull request task's 30 s tick. While the setting is on and no phone is connected, the task still runs, with one herdr snapshot per tick, only for agents that pass the workspace check below, with the same once per 2 minutes per checkout and branch, so a merge is noticed within about 2 minutes either way. Those reads do not change the pull requests a phone is shown. Only a fresh gh pr view that finds the pull request MERGED, or a kept merged result (see above), makes the agent a candidate, at most once per 2 minutes; a value cached within those 2 minutes never does, and a candidate that fails a check is tried again at the next fresh or kept read.
  • Checks, all read fresh in this order (Driver::archive_if_merged): the setting is on; a herdr snapshot in which the terminal's workspace is a linked worktree and every pane of that workspace is an agent that is idle or done with no subagent running (a plain shell pane, an agent working, blocked or unknown, a Claude Code agent with a running subagent, one whose transcript cannot be read, or one whose subagent scan left something out holds it: more than 8 workflow directories, or files past the 8 opened per scan that were never read before); git status --porcelain --untracked-files=normal --ignore-submodules=none in the checkout prints nothing (ignored files do not count; a submodule with changes, untracked files or a commit other than the recorded one does, whatever .gitmodules or the config say; the repository's filter commands are blanked as for diffs), and git ls-files -v --recurse-submodules lists no assume-unchanged (lowercase tag, as core.ignoreStat sets) or skip-worktree (S) entry, in the checkout or a submodule, since git status does not look at those files; gh pr view --json number,state,headRefOid again, which must still say MERGED with the same number; and git merge-base --is-ancestor HEAD <headRefOid>, so the checkout holds no commit the pull request does not (a 40 or 64 hex character id only). Then Driver::archive_merged runs the archive of task.archive: it checks the workspace again on its own snapshot, refuses a regular folder (it never only closes the pane), and, after its last authorization check and right before worktree.remove, requires the checkout herdr is about to remove to be the one whose pull request was read, and runs the git checks on it once more. Its authorization check is that a paired phone still has the setting on, so turning it off or revoking the phone stops an archive in flight before the removal. Unlike a manual archive it does not run gh poi, which deletes every merged branch and removes other clean worktrees of the repository, whatever their agents are doing.
  • After: one audit line per archive attempt that reached archive_merged (collied, task.archive, the terminal id, PR #N merged: <result>); candidates that fail a check before it are not audited. On success, one alert "Archived <agent>" with "PR #N merged" through the done alert path, so mute_done silences it, and flock.changed.

Conversation history

agent.history is a read method on the existing paired connection. The phone sends a terminal ID and optionally before or after, plus a case-insensitive query for search. Cursors bind the agent kind, session UUID, file device/inode, byte offset and the preceding 64 bytes' hash. Replacement, truncation, changed cursor bytes or session changes reset the page. The daemon resolves the live agent before reading and verifies its identity again before replying. Missing agent identity or unreadable files return available: false with a distinct unavailable_reason. Session identity comes from herdr, never a folder/recency guess; reporting a missing identity lets the next poll recover. An existing empty transcript returns available: true with no messages. An older daemon rejects the additive method and the phone keeps Terminal accessible. No protocol version bump is needed.

The reader uses the same configured Claude and Codex transcript locations as context summaries. A blocking worker reads at most a 64 KiB format header and a 2 MiB history window without holding the shared transcript mutex. Only newline-terminated records are consumed, so a partial append is retried. Backward pages and forward updates carry distinct cursors; replies contain at most 40 message blocks and 40 KiB of serialized messages, below the 64 KiB transport limit. Oversized records are skipped with an explicit notice and cursors still progress. Large text is truncated on UTF-8 boundaries for display; search matches before truncation. No transcript content or search text is written to audit, push or notification state.

Claude reads main-thread user/assistant content blocks, including tool_use and tool_result, and excludes synthetic replies, foreign session records and user records the harness injected rather than the user typed (isMeta, such as skill text and subagent hand-backs, compaction summaries, and task-notification origins). A subagent hand-back and a task notification leave a one-line event role message instead: "Subagent finished: <description>", the description (or agent type) from the subagent's meta file, and "Background task finished", since the notification has no structural status or summary field. A slash command (a user record with no promptSource and not isMeta, whose string content starts with <command-name> or <command-message> and names a / command) leaves its name and arguments, such as "/effort high", and its <local-command-stdout> record is left out; a typed prompt always has a promptSource, so it is never taken for one. None of these lines carries other record text; the line is capped at 120 characters without control characters and is left out of search. Current Codex (0.160 and later, or a header window containing item_completed) uses UserMessage/AgentMessage completion events for conversation text, avoiding their overlapping response_item messages. Older Codex uses response_item messages. Both read function_call/custom_tool_call and their outputs with call IDs; encrypted reasoning and internal instructions are excluded. Stable record/block IDs prevent duplicates across pages and reconnects.

Claude Code keeps each subagent's records in <session>/subagents/agent-<id>.jsonl beside the session's transcript, with agent-<id>.meta.json naming the parent Agent call (toolUseId). The reader adds a subagent's records, tagged with that call's ID in parent_call_id (additive, no version bump), right after the Agent call; forward pages also add them after its hand-back (origin.from), so an open Chat gets the steps when a background subagent finishes. It opens these files like the transcript (no symlink, non-blocking), only for an id of letters and digits and only through real directories under the session's own directory. The subagent files read for one page share one more 2 MiB tail window, plus up to 64 KiB of each file's head for its prompt, and the page is marked omitted when the records in between are left out; a subagent that does not fit what is left of it starts the next page. Subagent records get the same per-record caps and count toward a page's 40 blocks and 40 KiB: a subagent larger than the room left on the page keeps its first step and its latest ones and marks the page as omitted, and its call starts the next page only when not one step fits. Context and the last line still ignore subagents.

The SwiftUI session keeps its composer and approval paths and switches only the content area. Chat projects records into a lazy conversation timeline: text replies stay visible and consecutive tools form one collapsed activity row, pairing calls and results by call ID. Activity row IDs survive prepends and appended results; idle polls do not rebuild the timeline. An Agent call's disclosure nests its subagent's prompt, tool calls (collapsed) and reply, matched by parent_call_id wherever the call sits in the loaded timeline, and its activity row counts the subagent steps. Search keeps every matching record visible and highlights matched text. Chat uses ID-based scroll position, loads older pages on demand, and polls only the appended portion while visible and foregrounded. Search scans bounded batches until the beginning, can be cancelled by clearing it or leaving Chat, and navigates matching messages by stable ID. Once the older scan completes it polls for new matches. Native Liquid Glass controls expose search, activity disclosure, Copy and return-to-latest actions. Terminal keys and wrapping remain in Terminal, with a Chat handoff button for interactive prompts. Terminal scrollback remains bounded because it controls repeated screen snapshots and still serves shells and unavailable transcripts.

Plan usage

Claude Code passes the plan's limits only to the status line command, as rate_limits.five_hour and rate_limits.seven_day (used_percentage, 0 to 100, and resets_at, Unix seconds) on its stdin, on each refresh: a new reply, /compact, a mode change, or a window reaching its reset. It stores them nowhere on disk except a cache refreshed by /usage (in ~/.claude.json, which also holds account data collied should not read). Codex's rollout token_count events carry rate_limits, but primary and secondary are null for credit-metered workspaces. The daemon asks the local Codex app-server for account/read and account/rateLimits/read every five minutes and keeps only individualLimit's used credits, allowance and reset date. A confirmed sign-out or successful response without a limit clears the Codex reading and publishes the change to the phone; a failed fetch leaves the last reading intact. Codex manages its own OAuth token.

  • Tap: collied statusline reads the status line JSON from stdin (at most 1 MiB), keeps the two windows (percentage rounded and clamped to 0 to 100, reset time in ms) and, for a lowercase-UUID session_id, context_window.context_window_size (1K to 100M), and drops the rest (spend_limit included). It merges them into usage.json (0600) in the data dir under an exclusive flock on usage.lock, so concurrent sessions do not lose each other's windows, then replaces the file atomically without sync_all (a full drive flush on macOS; a lost write is redone by the next refresh). Each session reports the limits from its own last reply, so they merge per window: the later resets_at wins, and within the same window the higher percentage, since usage only grows there; a window missing from one session's input is kept until its reset time. recorded_ms moves only when the input agrees with the merged result, so a quiet session's older figures neither replace newer ones nor look fresh. An unchanged value is stamped again at most once a minute, and an input that changes nothing leaves the file alone, as the status line runs several times a second while a reply streams. Input without rate_limits keeps the last limits, so a session before its first reply does not erase them. At most 64 sessions' windows are kept, the oldest seen first out. It starts no runtime and prints nothing; the README line runs it in the background.
  • Daemon: Transcripts holds the reader. It re-reads usage.json only when its size or mtime changed (O_NOFOLLOW, regular file, at most 64 KiB): for flock.snapshot and approval.list, when it derives a claude agent's transcript (for the session's window), and on the reconcile loop's existing 1 s herdr poll, which costs one fstat. The plan is the machine's, not a session's, so it goes out on the snapshot as Flock.plan_usage (five_hour and seven_day, each used_percent and resets_at_ms, and recorded_ms), with or without a Claude Code agent open. When it changes and a phone has a session open, that tick sends plan.usage for a present plan or flock.changed to refresh a cleared plan. The phone's core applies the new plan or snapshot to its cached flock. No timer is added. The session's window replaces the model table for context_left, and the transcript cache is keyed by it too. No version bump: the field is additive on a server-to-phone type, and an older app ignores the unknown event.
  • Phone: the Agents tab has a segmented control, Agents and Usage, above whichever layout is chosen; the layout picker is disabled while Usage shows. Usage shows one card per machine that has a plan recorded: the machine and how long ago it was recorded ("just now", "3m ago", "1d ago"), then a Claude Code row with a "5-hour usage limit" and a "Weekly usage limit" block, and a Codex row with a "Monthly usage limit" block when Codex reports an individual limit. Each block is a title, a full-width bar with an elapsed-time tick, then the amount used on the left (percent for Claude, used and allowed credits for Codex) and the reset on the right (the 5-hour reset as a clock time, the weekly one as a countdown, Codex as a date). A window past its reset is hidden. Claude data more than 5 minutes old and Codex data more than 10 minutes old are dimmed. A TimelineView(.everyMinute) redraws the card while it is on screen; nothing is fetched for it beyond the list's existing cache refresh.
  • collied doctor: plan usage says when the tap last recorded the limits and how many sessions' windows it holds, or warns that it has recorded nothing.

Security: Status line tap.

Starred agents

  • agent.star {terminal_id, starred} (Drive class: audited and rate limited) stars or unstars a pane, and sends flock.changed so every session refetches. Starring reads session.snapshot and answers not_found for a terminal herdr does not list; unstarring reads nothing, so it works for a pane already gone.
  • collied keeps the stars in stars.json (0600, data dir), one set for the machine that all of the owner's paired phones share, as terminal_id to pane_id. herdr gives every pane a new terminal_id when it restores its session (after a reboot), but keeps the pane_id; a cross-workspace move does the reverse. On each flock.snapshot, and every second in the reconcile loop while a star is not in that tick's agent.list at the pane it was last seen in (a shell pane, a closed pane, a restore or a move; a phone connected or not, one more session.snapshot per tick), collied keeps a star whose terminal is listed (updating its pane id), moves one whose terminal is gone to the terminal now in its pane, and drops the rest. A snapshot with no pane changes nothing: herdr answers before it restores its panes. pane_id still never leaves the machine.
  • The snapshot carries them as Flock.starred (additive, no version bump, but the app and collied update together since client params deny unknown fields). A star survives a reinstall of the app (the first snapshot after pairing again lists it), a phone offline or reconnecting (the phone never prunes; it shows what the cached snapshot lists), an agent exiting (its pane stays) and a herdr restore. It goes when its pane closes, or when a phone removes the machine (unpair clears them all). Starring needs the machine reachable. Gaps: a pane id that herdr reuses for a new pane within about a second (or while herdr is unreachable) takes the old star with it, and a move followed by a herdr restore in that window loses the star. Closing every pane keeps their stars, since an empty list is not trusted, and a new pane that gets one of their pane ids inherits that star.

Terminals

A pane with no agent is a plain shell: herdr keeps the pane, with its terminal_id, when the agent in it exits (verified on 0.9.3). The phone lists such panes, shows their screen and types into them, under the rules below. Security: Shell input from the phone.

Opt-in

[terminals]
enabled = true   # false, the default, when the section or the key is absent
  • Read at start (ServerConfig.terminals); a change needs a restart, which ends every session. No method changes it, and collied.toml is rewritten only by collied apns import.
  • Off: flock.snapshot has terminals: [] and no terminals_enabled; every terminal.* request but terminal.lock answers terminals_disabled ("terminals are off on this machine; set [terminals] enabled = true in collied.toml and restart collied") before any herdr call, audited. terminal.lock answers ok whatever the setting.
  • pane.read reads agent panes only, whatever the setting: a shell is read through terminal.watch, under a grant.

What is a shell

  • A pane is a shell when its PaneInfo.agent is absent and its terminal_id is not in session.snapshot.agents (which lists an agent herdr is still launching before its pane reports it). collied reads this from a fresh session.snapshot before every write, never from a cache, and also refuses while an approval is pending on that terminal_id (hook-driven approvals are keyed by it, and report sooner than herdr's process detection).
  • flock.snapshot lists shells as Terminal {terminal_id, workspace_id, label, cwd}: label is the name given in herdr (herdr pane rename), cwd the foreground process's, else the pane's. Titles a program sets (terminal_title, which a zsh theme fills with the running command) never leave the machine. The phone titles a shell by its label, else its folder, else "Terminal". A shell created on the machine appears with the next snapshot; an agent exiting changes the flock and sends flock.changed.

Grants

  • Terminal key. The phone has a second Secure Enclave P-256 key besides its TLS key (TerminalKey.swift, Keychain item dev.rbstp.collie.identity/terminal), created with [.privateKeyUsage, .userPresence] and kSecAttrAccessibleWhenPasscodeSetThisDeviceOnly: every signature needs Face ID or the passcode, and removing the passcode destroys it. The simulator uses a software key behind a LocalAuthentication prompt; a device never falls back to one.
  • Registration. pair.complete carries terminal_key (base64url of the 91-byte SubjectPublicKeyInfo). collied checks it is a P-256 key and not the session's TLS key (an app sending its TLS key would sign without Face ID), else the pairing fails (audited invalid terminal key). The y/N prompt on the machine shows terminal key: none | new | unchanged | replaces the existing one, and "replaces an existing pairing of this node" for a paired phone. The key is stored in peers.json (terminal_key); pairing again replaces the whole record, so a pairing without a key forgets it. The phone keeps the key it paired with in machines.json (terminal_key) and answers TerminalKeyMissing (pair again) without asking for Face ID when its current key differs: paired before this feature, the passcode removed since, or no Secure Enclave key at pairing.
  • Challenge. terminal.challenge {terminal_id} answers {terminal_id, challenge, ttl_ms: 60000} once the peer has a key (terminal_key_missing otherwise) and the pane is a shell. 32 random bytes, one outstanding per session (a new one replaces it), 60 s. It is quiet in the audit log when issued.
  • Grant. terminal.grant {terminal_id, challenge, signature}: any attempt burns the session's challenge. The signature is ECDSA P-256 with SHA-256, DER, base64url, over u32be(len) || bytes of "collie terminal grant v1", the machine's node ID, the terminal_id and the challenge (protocol::terminal_grant_message, built the same way by collied and collie-core; collie-core takes the node ID from the machine pinned at pairing). collied verifies it with the stored key (collie_tls::verify, ring) and answers {terminal_id, ttl_ms: 300000}; otherwise terminal_locked with the reason (no challenge was issued, challenge expired, challenge does not match, signature did not verify).
  • A grant covers one terminal in one session for 5 minutes from its issue (ServerConfig.terminal_grant_ttl, terminal::GRANT_TTL; not a collied.toml key), and is bound to the key it was verified against. At most 16 per session. Grants and challenges are in collied's memory only (State.terminals, lock order peers, sessions, terminals). They end with terminal.lock, the session, a revoke or unpair, a re-pairing of the phone (its record replaced), an agent seen on that terminal (by a write's check, the watch, or the 1 s reconcile), and a collied restart.

Watch and input

  • terminal.watch {terminal_id, lines?, low_data?} needs a live grant and shares the session's one watch with agent.watch: it emits the same agent.output and agent.output_patch events, reading pane.read source=recent_unwrapped with the same halving on herdr's line limit, and ends (flock.changed) when the pane closes or an agent starts in it. It reads nothing while the session has no live grant for that terminal under the peer's current key (expired, terminal.lock, a re-pairing); a new grant in the same session resumes it. agent.watch {terminal_id: null} stops either kind.
  • terminal.run {op_id, terminal_id, text}: one line of PromptText (no newline, tab, ESC or other control, no bidi or invisible format character, checked by the decoder), sent with herdr's pane.send_input {pane_id, text, keys: ["enter"]}, the one ordered write herdr pane run makes (verified on 0.9.3 with sh and zsh: the line runs). On macOS herdr 0.9.5 refuses a line longer than the terminal's canonical input limit while the pane reads in canonical mode, such as a shell still starting (pane_send_failed, its message naming canonical mode); collied answers too_large with a message to wait for the prompt and try again, and keeps no other part of herdr's message.
  • terminal.send_keys {op_id, terminal_id, keys}: the closed Key list (at most 16) through pane.send_keys. ctrl+d, ctrl+z and ctrl+r are not in it: adding them would widen agent.send_keys too. Nor are ctrl+j and ctrl+m, which submit like Enter without its checks, or ctrl+o and ctrl+t, which open Claude Code and Codex transcript views that the scrolled-up check may not see, so later keys may answer a prompt hidden behind them.
  • Both go through the op_id outcome cache. Right before the herdr write collied reads the snapshot again (still a shell, else not_found "the pane now hosts an agent", and the terminal's grants are dropped), then checks the peer, its grant in the session that sent the operation under the peer's current key, and no pending approval (terminal_locked otherwise; never not_paired, which would stop the phone reconnecting). A retry from the phone's next session replays the first attempt, which still checks the first session's grant. herdr targets panes by pane_id only (a terminal_id is refused as pane_not_found) and reuses pane ids, so the snapshot is read again before each write. A herdr error from these writes keeps its code only, since its message may quote the input. There is no foreground-process check: ctrl+c and answers reach the program running in the shell.
  • terminal.lock ends every grant of the session and a terminal watch, and answers ok.
  • New shell panes (tab.create) are not offered: a follow-up.

Audit

MethodTargetResult
terminal.challenge<terminal_id>not logged when issued; refusals are
terminal.grant<terminal_id>granted ttl=300s, or the error with its reason; every attempt
terminal.watch<terminal_id>ok or the error
terminal.run<terminal_id>ok or the error; never the text or its length
terminal.send_keys<terminal_id> keys=ctrl+c,enterok or the error
terminal.locknoneok

Phone

  • collie-core: set_terminal_key(public_key) (the app calls it at start and on every foreground, since the Keychain item is readable only while unlocked), terminal_challenge(machine_id, terminal_id) -> bytes to sign, terminal_grant(machine_id, terminal_id, signature), watch_terminal(machine_id, terminal_id, lines), terminal_run, terminal_send_keys, lock_terminals(). MachineFlock.terminals and terminals_enabled; AgentView.terminal, terminal_locked and terminals_enabled. A new connection starts locked and never watches a shell again by itself: grants are per session.
  • lock_terminals locks every terminal on the phone at once, then sends terminal.lock to each connected machine; any other answer (rate limited, a timeout, a drop) closes that machine's session, which ends its grants. The app calls it on .background only (the Face ID sheet itself makes the app inactive), before the sessions are suspended.
  • The agent screen turns into the pane's terminal when its agent exits: the header shows the Ghostty "Terminal" label and Locked or Unlocked, and a card says "The agent exited. This pane is now a shell." with "Unlock with Face ID" (or the off and pair-again messages). Face ID runs only from that button or an input, never on its own, with the reason "Open a terminal in <workspace> on <machine>" and a fresh LAContext (never one another prompt evaluated, no reuse duration). A Send or a key while locked unlocks first, in the same action; a cancelled prompt sends nothing. The prompt field becomes a one-line "Command" field without autocorrection, capitals, smart quotes or dashes, or dictation, and refuses a newline ("One command at a time"). The key strip is the agents' one. When an agent starts there again, the screen goes back to the agent.
  • The Agents list and grid list a machine's shells under "Terminals" (no live preview on the grid: it would need a grant), with Close pane on a long press. The inbox, swiping and Live Activities stay agents only. Notification actions, the Live Activity intent and the lock-screen decide never send terminal.*.

ColliePush (notification service extension)

  • Pure Swift, never links CollieCore; shares only Sources/Shared, the App Group group.dev.rbstp.collie and the notification keys' Keychain access group with the app. No network.
  • With an enc it reads the key for the alert's node_id and opens it; on success the body becomes the context (the title is kept), on any failure the plaintext fallback stays.
  • collie-core mirrors the last connection result per machine node ID (last_ok_ms, last_fail_ms, nothing else) to reachability.json in the App Group. If the alert's node_id last failed, the NSE appends " (machine may be unreachable, open collie to check)" to the body. Everything else passes through unchanged.

CollieBar (Mac/) is a separate XcodeGen project, so the iOS project and its recipes are untouched. Pure Swift with no packages: it never links CollieCore. A SwiftUI MenuBarExtra with LSUIElement, so no Dock icon.

  • It talks to collied only over the control socket, as the CLI does, with three requests: watch, pair (then confirm), and peers_list. It opens no network listener and reads none of collied's data files (Open Audit Log hands audit.log to its default app, Console); peers come from peers_list, and with collied off the menu says so.
  • watch is read-only and not audited, like status: collied answers {"type":"watch","pending_approvals":N} at once, then one line each time the count of pending approvals changes, from a tokio watch channel the approvals update under their lock. It does not subscribe to the phone event broadcast, whose receiver count means a phone is connected. The open connection is the running state, EOF is off; the app retries a failed connect every 2 s, its only polling. An older collied answers error: shown as running, with "Update collied".
  • Off, on and Quit run collied stop and collied start, the executable named in the launchd plist (ProgramArguments[0]), with no shell, so launchd's semantics (disabled until start, across reboots) stay in one place. The socket cannot do it: a stopped collied has no socket, and launchd's KeepAlive would restart one stopped from inside. Quit leaves collied off until Turn On or collied start. A collied run by hand, not by launchd, is not stopped.
  • Pairing matches collied pair: the QR (CoreImage, correction level M), then the candidate's device label, node name and StableID, login and user ID, the terminal key change and "replaces an existing pairing", every phone string escaped like collied::printable, and 60 s to answer. Pair is not the default button (Return does nothing) and is live only 1 s after the candidate appears, the window's form of the CLI's tcflush; one answer is sent, never on its own: at the end of the 60 s collied refuses. The invite is never shown as text, selectable, copied, logged or kept: only the QR image lives in the view's state. Window restoration is off, and closing the window closes the connection, which cancels the pairing as Ctrl-C does.
  • Open at Login uses SMAppService.mainApp, off by default.
  • just mac-install signs it with the Developer ID (team RM3UT3MMSR) and the hardened runtime, no App Sandbox (it would block the socket and running collied) and no entitlements, so it needs no App ID or provisioning profile. Notarization is optional (just mac-notarize), only for running it on another Mac without a Gatekeeper prompt.
  • Its unit tests have no host app, so they never launch it against the live collied's socket.

Bar widget (Linux, Omarchy)

CollieTray (Linux/CollieTray) is CollieBar for the Omarchy shell: a third-party Quickshell plugin, rbstp.collie, with a service (Service.qml, one instance for the whole shell) and a bar-widget (Panel.qml, one per monitor's bar, all reading the service). The control socket protocol is unchanged and shared with CollieBar.

  • The same three requests, sent with Quickshell's Socket: watch, pair (then confirm) and peers_list, the exact lines CollieBar sends. The service keeps one watch connection: open is running, EOF or a failed connect is off, an error reply is an outdated collied; it retries every 2 s while off. peers_list runs when the menu opens. Pairing uses its own connection, created for the pairing and destroyed with it.
  • The socket path comes from the unit's pinned XDG_DATA_HOME, else the shell's, else ~/.local/share, as collied resolves it. Turn Off, Turn On and Quit run the executable named in the unit's ExecStart with stop or start, by argv, never through a shell, so systemd's semantics stay in collied stop and collied start.
  • Pairing matches CollieBar: the QR, then the candidate's device label, node and StableID, login and user ID, the terminal key change and "replaces an existing pairing", every phone string escaped like collied::printable and drawn as plain text, never rich text. Pair is not bound to Return, the panel's activate key or any default, and is live only 1 s after the candidate appears; one answer is sent. Esc means Don't Pair. Closing the panel, another popout taking its place, or a plugin reload closes the connection, which cancels.
  • The invite goes to qrencode -l M -t ASCII on stdin, never in argv, a file or a log (Quickshell keeps console.log in /run/user/<uid>/quickshell, and the plugin logs nothing). The matrix is drawn as one rectangle per module, the wifi QR card's way, and dropped with the pairing; qrencode's output stays in the process's StdioCollector until the next pairing.
  • Quickshell's JS has no \p{} classes, so Printable.js holds the escaped code point ranges, generated from node's Unicode data by Linux/Tests/gen-printable.mjs, and checked against CollieBar's vectors under node and inside qs.
  • A bar plugin cannot quit the shell: Quit runs collied stop, then omarchy-plugin-disable rbstp.collie. The shell loads enabled plugins at login, which stands for Open at Login. Open Audit Log runs less with LESSSECURE=1 (no editor, no shell escape) in a terminal through omarchy-launch-tui.
  • The icon is a copy of CollieBar's MenuIcon.svg, recoloured to the bar's foreground, dimmed when off, with an urgent-coloured dot while approvals are pending. just tray-test checks the copy is identical.
  • just tray-test runs the model tests under Node and Service.qml in a standalone qs against Linux/Tests/fake_collied.cjs: the watch states, Pair ignored before it is armed, one confirm line only after the answer, cancel as EOF, collied stop through the unit's executable, and that the invite never reaches the output. It never touches the real control socket. just tray-install validates the plugin (omarchy-plugin-validate refuses symlinks, so MenuIcon.svg is a copy) and copies it through a dot directory the shell's watcher ignores, then enables it.

Live Activities

The lock screen and the Dynamic Island show an agent only while the user follows it: off by default for every agent, at most 5 at once on the phone. The phone starts each activity itself, from the foreground; collied only updates and ends it. Push-to-start is not used.

Phone and collie-core

  • Following starts an Activity (pushType: .token) with the agent's current state. Each token from pushTokenUpdates goes to that agent's Mac with register_activity_token(machine_id, activity_id, terminal_id, token_hex); every foreground registers the tokens of all running activities again. The title the app shows for the agent (its terminal title, at most 64 characters) goes in the activity's static attributes, which stay on the phone, so the lock screen and the expanded island show it while collied's pushes keep the agent name or kind; it is fixed until the activity restarts. Unfollowing ends the activity at once and calls end_activity(machine_id, activity_id).
  • collie-core keeps the push and activity token registrations per machine in memory only (never on disk) and sends them on every connection, after push.register, since collied takes the APNs environment from it. Calls are queued in call order, so the app registers push before its activities. An end the Mac has not acknowledged is sent with every connection until it answers, after an app kill too: collie-core keeps the pending ends in activity-ends.json in the state dir (0600, atomic, written on every change), per machine id, each with its activity_id and the time it ended, nothing else. At most 16 per machine. At start it loads only the ends of paired machines that are under 8 h old (collied has expired the activity by then) and ignores an unreadable file; removing a machine drops its ends.

Protocol

  • push.activity_token {activity_id, terminal_id, token, shows_approvals?} and push.activity_end {activity_id}, both MethodClass::Push. activity_id is ActivityKit's Activity.id ([A-Za-z0-9-], at most 64), token is the update token in hex (64 to 256 characters, redacted in Debug). shows_approvals (default false, omitted when false) says the activity shows an approval's command and its buttons; collie-core always sends true, since every activity the app starts carries the Mac's node id.
  • Additive, no version bump. An older collied answers not_implemented or unknown_method, which the phone ignores. A collied from before shows_approvals rejects it as an unknown field (invalid_params), so collied is upgraded before the app; an app from before it registers without it and keeps getting approval notifications.

collied

  • Stored in push.json (0600, atomic) under activities: {stable_id, activity_id, terminal_id, token, environment, registered_at, shows_approvals} (shows_approvals omitted when false, so entries stored before it load as false). A file without activities loads as before; an empty list is not written.
  • Bound to the session's StableID: a device registers, replaces and ends only its own activities. The same activity_id or the same terminal_id from the same device replaces the old entry (the phone keeps one activity per followed agent, so an activity it never ended cannot hold a slot); a token is stored once. The environment is the device's own from push.register (not_found without one). At most 8 per device (too_large beyond). push.activity_end for an id the device does not have answers ok.
  • Dropped on push.activity_end, on revoke, at start for peers no longer paired, on Unregistered or BadDeviceToken for that token, when the terminal is gone, and quietly (no push, audited as ended: expired) 8 h after registered_at: ActivityKit ends an activity 8 h after it starts. The phone registers right after the start and again on every foreground; a registration of an activity_id collied already has keeps its registered_at, so the 8 h count from the first one.
  • Updates come from the 1 s agent reconcile, after the approvals pass, never from terminal output. Per activity:
    • first sight (a new activity, a new token, or a collied restart): one update without alert, so the activity shows what the Mac sees;
    • content changed: an update, at most one per 2 s per activity (a change inside the gap goes out on the first reconcile after it); a status change alone never alerts on the activity (the end of a turn is the done alert);
    • an approval routed to the activity (below): sent in the same reconcile, whatever the 2 s gap;
    • nothing changed: the same content again every 10 min, to move the stale date while the Mac is up;
    • the terminal missing from two reconciles in a row: end with dismissal-date = now + 60 s, then the token is dropped. A missed agent.list (herdr down) changes nothing.
  • Every send re-checks the pairing, like alerts. One audit line per register (push.activity_token, target <terminal_id> activity=<activity_id>) and per end (push.activity_end, from the phone, or ended: agent gone and ended: expired from collied); tokens are never logged or audited.
HeaderValue
apns-push-typeliveactivity
apns-topic<bundle_id>.push-type.liveactivity
apns-priority10 for the update that carries an approval alert, 5 for every other update, refresh and end
apns-expirationthe approval's expiry for the update that carries an approval alert, as for the approval alert it replaces; now + 5 min for every other
apns-collapse-idnone
{"aps": {
  "timestamp": 1791028800,
  "event": "update",
  "content-state": {"status": "blocked", "statusSince": 812721600, "title": "api-fixer", "kind": "claude", "workspace": "api",
                    "approvals": 1, "approvalId": "apr_test", "enc": "oKGio6Slpqeoqaqr7QAv18tqJkZ41BDjm3Mz/9NV7Tim7Fe0ZJoerlF+PbZlhhy7Ws9jjgfEGOzE+w=="},
  "stale-date": 1791029700,
  "relevance-score": 100,
  "alert": {"title": "api-fixer", "body": "Blocked in api"}
}}
  • timestamp, stale-date (now + 15 min) and dismissal-date are Unix seconds. relevance-score ranks the agent that most needs the user first, so of several followed agents iOS shows it in the Dynamic Island: blocked 100, done 75, working 50, idle 25, unknown 0. alert only on the update that carries an approval alert, so the Dynamic Island expands; its text is the approval alert's.
  • content-state decodes into AgentActivityAttributes.ContentState with ActivityKit's default decoder: status is idle | working | blocked | done | unknown; statusSince is whole seconds since 2001-01-01T00:00:00Z (Unix seconds − 978307200), Swift's default Date Codable value, with no custom date strategy on either side; title is the alert title (herdr agent name, else agent kind, else agent); kind is the herdr agent kind, only when the widget has an icon for it (claude | codex | copilot, the fixture's kinds), else omitted (an older app ignores it, an older collied never sends it); workspace is the workspace label; approvals counts the agent's pending approvals (0 or 1); approvalId and enc are optional and present only while an approval routed to this device is pending (an older app ignores them). The fixture protocol/live-activity-content-state.json has both forms; its enc is the notification test vector's. Both are checked by collied's and the app's tests.
  • The content state is plaintext to Apple: status, its start, the agent and workspace labels, one of three agent kinds, a count and an approval id. The context only as enc; never the terminal title, snippet or nonce (threat-model.md).

Approval on the activity

  • A device that follows an agent gets its approvals that offer Approve and Deny on the activity alone (any other, such as a question, a plan or a Codex or Copilot prompt, goes as the approval alert, without actions): one alert, the Dynamic Island expands, the command and Approve and Deny are on the activity, and no approval notification.
  • Only an activity registered with shows_approvals takes the approval; a device whose activity for the terminal lacks it (an older app) gets the approval alert as before, and its activity only the status.
  • When alert_due lets an approval alert go out, every paired device without such an activity for that terminal gets the approval alert as before. A device with one gets, in the same reconcile, an update of that activity with priority 10, the approval alert's aps.alert and the content state with approvalId and enc: the context sealed to that device's notification key exactly like the alert's enc (fresh nonce, AAD = approval_id, {"v":1,"body":...}; no enc without a key or a context), except that a context over 1800 bytes once JSON-escaped is cut there with …, so the update stays under APNs' 4 KB with labels at their caps. The same rules as for alerts decide whether it alerts (a new episode or a changed prompt does, the same prompt reissued within 30 s does not), so an approval alerts once either way.
  • A reissue that alert_due keeps quiet still reaches the activity, as an update with priority 5, no alert and the new approvalId and enc, so its buttons never decide a burned approval. It reaches no other device, as before.
  • Fallback: if the activity push fails (any APNs error, a network error or a dead token, which is dropped as usual), or the activity or the agent is gone by the time collied would push, the device gets the approval alert at once.
  • Late fallback: APNs accepts a push to an activity the phone has ended (dismissed or ended by iOS while the app was suspended, Live Activities turned off, an update dropped by iOS's budget, or replaced by a later update while the phone was offline), and nothing tells collied. So an approval that alerted on an activity and is still pending and not being decided 2 min later also goes to that device as the approval alert, once (never twice with the early fallback). The cost: a user who has not answered within 2 min gets the notification as a reminder; a dead activity delays the alert by up to 2 min. Both timers live in collied's memory; a restart reissues pending approvals, which alert again.
  • enc is sealed once per device and approval and repeated on later updates (refresh, new token), so coalescing still sees an unchanged state. Once the approval resolves (applied, denied, superseded, expired) or the agent leaves blocked, the next update omits approvalId and enc; it is a normal coalesced update without alert. An end never carries them.
  • On the phone, AgentActivityAttributes carries the Mac's nodeId. The widget reads the notification key for it from the shared Keychain access group, opens enc (PushContext.open) and shows the command on the lock screen and in the expanded Dynamic Island (privacySensitive: redacted while locked), else "Approval needed" without buttons. Approve and Deny, shown only with a command that decrypts, are Button(intent:) with a LiveActivityIntent whose authenticationPolicy is .requiresAuthentication: iOS makes the user unlock first, then the intent runs in the app process and takes the same path as the notification actions (decideFromNotification, background budget; the nonce comes from the Mac over the tailnet).

collied setup

One interactive command for adding a Mac or a Linux machine, or updating one in place. It reuses the subcommands' code (daemon::login, service::install, doctor::run, push::import, the pair flow) through a small setup::Host trait, so the order and the skip rules are unit tested against a fake without touching launchd, systemd, the tailnet, the Keychain or systemd-creds. One Host implementation serves both OSes; only the service backend (service::state, unload, install, log_tail) differs. Each step checks its own done state, so a rerun resumes where the last one stopped:

StepDone whenOtherwise
loginthe daemon answers status with backend Running (it only serves once its node is tagged); a loaded job gets 20 s first, for a daemon just restarted by just collied-installunload a loaded job without disabling it (a crash loop or a logged-out daemon holds the node lock or the socket), then collied login; if login fails, start the job again
servicethe plist on disk is the one install would write, the job is loaded, and the daemon's started_ms is not older than the binary's mtimeservice install (bootout, then bootstrap, so a restart), then wait up to 60 s for the daemon, waiting up to 10 s of that while its netmap shows no untagged device, so a node that has just started does not trigger the policy hint; on a timeout print the end of collied.err.log. A plist with another --config than setup's stops setup instead: run collied --config <path> setup
doctorno fail linestop: fix, then run setup again
APNs[apns] is in collied.tomloffer it: ask for the .p8 (key ID from AuthKey_<KEY_ID>.p8), team and bundle IDs, append [apns] (new file 0600, else the atomic rewrite keeping the mode), apns import; put the old config back if the import fails; restart the service so the daemon reads it
pairnever: phones may need to pair again after an updateoffer collied pair

Before any step it refuses unless stdin is a terminal, so yes | collied setup cannot answer the pairing prompt, and unless the running binary is ~/.cargo/bin/collied (a regular file, not a symlink), on macOS also Developer ID signed as dev.rbstp.collied (the check apns import already makes), so it never installs an unsigned agent or one pointing at target/release, which the next cargo build replaces (unsigned, on macOS). It never confirms a pairing: the y on the computer, after the tcflush, stays the only confirmation.

On Linux the service rows read:

StepDone whenOtherwise
loginas on macOS; "loaded" is any ActiveState but inactive and failed, so a unit waiting to restart after a crash (activating) counts; while a daemon answers, only active counts, so a foreground collied run beside a restart loop is refused as running outside the servicesystemctl --user stop (the unit stays enabled), then collied login; if login fails, collied start
servicethe unit file on disk is the one install would write (executable, --config, pinned XDG_*_HOME), the unit is loaded, and the daemon's started_ms is not older than the binary's mtimeservice install (daemon-reload, enable, restart), then wait up to 60 s; on a timeout print the last 20 lines of journalctl --user -u collied.service

Before deciding, service::state stops setup when the user manager loads collied.service from another file or applies a collied.service.d drop-in to it, when the unit pins another XDG_DATA_HOME than setup's (that data dir holds another node: setup would otherwise log in a new one), or when the unit runs another --config. The APNs step is the same code: push::import encrypts the key with systemd-creds or falls back to the 0600 file, and never prints it.

Doctor's service line reports the same state on both OSes: up to date, or a warn naming what setup would do; it is skipped with a warn when doctor is not ~/.cargo/bin/collied, since the state compares the service against the running binary. On Linux it adds that the unit stops at logout when the user does not linger (loginctl show-user).

The control socket's status reply gained started_ms (when the daemon started) and user_peers (untagged nodes of any user in the node's netmap, which for a tag-owned node are the devices the policy lets reach it), both with serde defaults; an older daemon reports neither, which setup treats as outdated. The phone protocol is unchanged.

The policy check uses what the Mac can see. collied login prints Tailscale's last login error from the node's health (You are logged out. The last login error was: ...) and, when it mentions a tag, stops with the tagOwners and grant entries for this node's tag (daemon::policy_snippet, tested against tailnet.md); the untagged-node error and the login timeout print them too. Doctor's reach line warns when user_peers is 0, and setup then prints the grant. The exact text control sends for a user who is not a tag owner has not been checked against a live tailnet.

Linux

collied runs on Linux with the same protocol, gate, allowlist and approvals as on macOS. What differs: the build, the paths, the node tag, the service manager, the listener check and where the APNs key lives. Built and tested on Arch (systemd 261, herdr 0.9.3).

On the phone, pairing accepts a node tagged tag:collie-mac or tag:collie-linux and records which one as the machine's kind in machines.json (entries from before Linux support are Macs). Every later connection requires that same tag, so a paired machine cannot switch kind. The app shows the kind as an icon next to the machine's name.

Platforms and build

TargetState
aarch64-apple-darwinbuilt and tested
x86_64-unknown-linux-gnubuilt and tested
aarch64-unknown-linux-gnumapped in tailscale-sys (GOARCH arm64); needs a cross C compiler in CC; not built or tested
any other OScompile_error! in collied
  • tailscale-sys on Linux: GOOS linux, CC from the environment (default cc, rerun when it changes), no -target or -isysroot flags, no Apple frameworks, no libresolv. Same pinned libtailscale, same patch series and vendored module patches: all apply unchanged.
  • Needs Go ≥ 1.27.2, a C compiler and libclang (bindgen).
  • No code signing on Linux.

Paths

WhatmacOSLinux
Data dir~/Library/Application Support/collie$XDG_DATA_HOME/collie, else ~/.local/share/collie
Attachments root~/Library/Caches/dev.rbstp.collied/attachments$XDG_CACHE_HOME/collied/attachments, else ~/.cache/collied/attachments
Service~/Library/LaunchAgents/dev.rbstp.collied.plist$XDG_CONFIG_HOME/systemd/user/collied.service, else ~/.config/systemd/user/collied.service
Logscollied.out.log, collied.err.log in the data dirthe user journal
  • For the data dir, the attachments root and the unit path, an XDG_*_HOME that is unset, empty or not absolute is ignored, as the XDG spec says. The herdr resolver mirrors herdr and takes XDG_CONFIG_HOME as given.
  • The data dir holds collied.toml, tsnet/, tls-key.json, peers.json, peers.lock, push.json, status.json, stars.json, usage.json, usage.lock, audit.log, control.sock, node.lock and apns/. status.json (when collied first saw each agent's status, for the phone's "how long ago") is replaced atomically, 0600, without sync_all, a full drive flush on macOS: a power cut can lose those times, and they restart from when collied next sees each status.
  • herdr socket: the same resolver on both OSes. [herdr] session, else HERDR_SOCKET_PATH, else HERDR_SESSION; then $XDG_CONFIG_HOME/herdr[/sessions/<name>]/herdr.sock, else ~/.config/herdr/.... Verified: herdr 0.9.3 on Linux puts its socket at $XDG_CONFIG_HOME/herdr/herdr.sock (mode 0600), not under XDG_RUNTIME_DIR.
  • tsnet's upstream path logic creates an empty ~/.local/share/tailscale (0700) for a non-root user. collied's node state is only in <data dir>/tsnet. A system tailscaled (root, /var/lib/tailscale) is untouched: the embedded node is a separate device with its own keys.

Node tag

  • daemon::NODE_TAG is tag:collie-mac on macOS and tag:collie-linux on Linux. collied login advertises it.
  • On both OSes, collied login and collied run refuse a node whose own tags lack NODE_TAG. A node without it is user-owned: the phone refuses it and the port 8457 grant does not cover it. Logging in again does not change the tags of a registered node, so the error names the way out: remove the node in the Tailscale admin console (Machines), delete <data dir>/tsnet, make the user a tag owner of NODE_TAG (tailnet.md), run collied login again; phones pair again. run checks once the node is Running with a self node, before it serves.
  • collied status prints tags:. collied doctor has a tag line: fail when the running node lacks NODE_TAG, warn when the daemon does not report its tags (node status failed, or a daemon older than the check).
  • Tailnet policy: a tag owner for tag:collie-linux and the same TCP 8457 grant from the user to it as to tag:collie-mac.
  • The e2e tests advertise tag:collie-mac for the test node on every OS (PHONE_PINNED_TAG in crates/e2e/tests/common/mod.rs).

systemd user unit

collied service install:

  1. Makes sure the data dir is private, canonicalizes the executable and --config path.
  2. Writes the unit 0644 through collied.service.tmp and a rename. It is rewritten on every install.
  3. daemon-reload, then checks that the user manager loads the unit from that file (FragmentPath). With another unit of that name, or a different XDG_CONFIG_HOME in the manager, the install fails there (the file written stays) instead of reporting success for a unit that never starts.
  4. enable, then restart.
# Written by `collied service install`; rewritten on every install.
[Unit]
Description=collie daemon: herdr agents over the tailnet
Documentation=https://github.com/rbstp/collie
StartLimitIntervalSec=0

[Service]
Type=exec
ExecStart="<exe>" "--config" "<config>" "run"
Environment="XDG_DATA_HOME=<dir>"
Environment="XDG_CACHE_HOME=<dir>"
Environment="XDG_CONFIG_HOME=<dir>"
Restart=always
RestartSec=2s
RestartSteps=5
RestartMaxDelaySec=1min
UMask=0077
NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
RestrictSUIDSGID=yes
RestrictRealtime=yes
RestrictNamespaces=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK

[Install]
WantedBy=default.target

The --config pair is present only when --config was given. No User=, no socket activation, no listener.

Quoting follows systemd.syntax, per field:

FieldRule
ExecStart= executableabsolute UTF-8 path with no ", ' or \, else install refuses; quoted, % doubled (systemd does not expand $ there)
ExecStart= argumentsquoted; \ and " escaped; % and $ doubled
Environment=the whole KEY=value quoted; \ and " escaped; % doubled ($ is literal there)
every fieldcontrol characters and paths that are not UTF-8 are refused

A test runs systemd-analyze --user verify on a unit with such characters in its paths, where systemd-analyze and a user manager exist.

Pinned XDG. XDG_DATA_HOME, XDG_CACHE_HOME and XDG_CONFIG_HOME are pinned to what the installing CLI resolved, fallbacks included. Units started with the user manager (at first login, or at boot with linger) do not get XDG_*_HOME from the session (verified on the dev machine). The data dir, the attachments root and the herdr socket all derive from them: without the pin, the daemon could use another data dir than the CLI (control socket, peers) and look for another herdr socket. HERDR_* is not captured, as with launchd: [herdr] session in collied.toml picks a session.

Restart. Restart=always with a delay of 2 s growing over 5 steps to 1 min, and StartLimitIntervalSec=0 so systemd never gives up: launchd's KeepAlive without a hot loop. collied exits when the tailnet listener fails for good, so the manager restarts it.

Sandboxing is best effort. In a user manager most of these settings rely on unprivileged user namespaces, and systemd skips them where those are unavailable. ProtectSystem=strict leaves the home directory and /run/user writable in a user unit. UMask=0077 applies either way.

Commandsystemctl --user
collied stopdisable --now, then reset-failed: stopped, and off across logouts and reboots until collied start; the unit file stays. Fails when the unit file is not installed
collied startenable, then start unless already active. Fails when the unit file is not installed
collied service uninstalldisable --now (when the file exists), remove the file, daemon-reload, reset-failed; refuses when the unit runs but its file is not at that path
  • Logs: the user journal (journalctl --user -u collied), no log files.
  • A user unit runs while the user has a session; loginctl enable-linger keeps it running without one. The launchd agent equally runs only in the GUI session.
  • just collied-install on Linux: release build, copied to ~/.cargo/bin/collied as a fresh inode (the running daemon keeps its copy until it restarts), then restarts the unit if it is active.

Kernel TCP listener check

tailnet::kernel_tcp_listeners(pid) lists the kernel TCP sockets in LISTEN state that a process holds. It first checks that the pid is alive (signal 0).

  • macOS: /usr/sbin/lsof, by absolute path. Its stderr must be empty except for exact copies of the three-line warning lsof prints for a mount point it cannot stat() (macOS mounts disk images under the temp folder). That warning only affects file system names of file rows, never socket rows. Any other stderr, or an exit other than 0 or 1, is an error, never an empty list.
  • Linux: the socket inodes from the socket:[N] links in /proc/<pid>/fd, matched against the LISTEN rows (state 0A) of /proc/<pid>/net/tcp and tcp6. The table header is checked. An fd closed during the listing is skipped, and so is a missing tcp6 table when there is no IPv6 stack (no /proc/sys/net/ipv6); any other read or parse error is an error, never an empty list.

Used by doctor's listen line and by the tailnet end_to_end, e2e and collied server tests.

APNs key on Linux

Each machine has its own APNs key (.p8, own key ID, revoked on its own in the Apple Developer portal), so a Linux machine pushes when the Mac is off. Same [apns] ids. The default store is key = "systemd-creds" (APNs).

  • Credential name collied-apns-<KEY_ID>, file <data dir>/apns/<KEY_ID>.cred. The name is sealed in the credential: a credential renamed or copied for another key ID does not decrypt.
  • systemd-creds is called as systemd-creds --user --no-ask-password {encrypt|decrypt} --name=collied-apns-<KEY_ID> - -: input on stdin, output on stdout (at most 64 KiB each way; the decrypted key is zeroized after use), never a file. collied runs systemd-creds, systemctl and systemd-analyze by absolute path, never from PATH: /usr/bin, else /bin, else /run/current-system/sw/bin. The name is always passed: an empty or missing name skips systemd's check of the sealed name.
  • The credential file is read with the key_path checks (0600 regular file owned by the user, opened with O_NOFOLLOW).

collied apns import AuthKey_<ID>.p8:

  1. The same checks as on macOS: [apns] ids, refuses a file named for another key ID, the key_path checks, an ES256 key. No signature check.
  2. Encrypts it. collied writes the credential itself (0600, O_NOFOLLOW, atomic), since systemd-creds would write its output file with the umask's mode. The new credential is decrypted and compared before it is stored, and read back and compared after.
  3. An existing credential that decrypts to the same key is kept, unless it has no TPM2 seal and the new one has: then the new one replaces it. An existing credential that no longer decrypts, or holds another key, fails the import.
  4. Rewrites [apns] to key = "systemd-creds" (nothing to rewrite when no key line was there, since that is the default).
  5. y/N to delete the original file, only if it is still the same inode.

If systemd-creds cannot encrypt (no systemd, no systemd-creds.socket, systemd older than 256, a container), import says so, copies the key to a 0600 file at <data dir>/apns/AuthKey_<KEY_ID>.p8 (an existing file there is kept and must hold the same key), sets key_path to it and offers the same delete (not when the file imported is that copy). When <KEY_ID>.cred already exists, a failed encrypt fails the import: it never falls back to a plaintext file.

The daemon, collied apns test and doctor decrypt the credential each time they load the key.

Seal. An unprivileged caller cannot choose the key with --with-key: systemd's varlink service (io.systemd.Credentials, behind systemd-creds.socket) decides. It seals with the host key plus the TPM2 when a TPM2 is usable, the host key alone otherwise, and silently falls back to the host key alone if TPM2 sealing fails. So collied reads the seal from the credential header: the first 16 bytes after base64 decoding are the key type id (ids from systemd src/shared/creds-util.h, v261 and main). Doctor runs systemd-analyze has-tpm2 only to warn that a host-only credential could now be bound to the TPM2. The dev machine (T2 Mac hardware) has no TPM2: host key only.

The first encrypt on a machine makes systemd create /var/lib/systemd/credential.secret (root, 0400). The credential stops decrypting after an OS reinstall, a machine-id change, a uid or user name change, or a restore to another machine: import again from the backup.

Doctor on Linux has no codesign line. Its apns line:

StateResult
credential, host keyok, "host key only (no usable TPM2)"; warn if a TPM2 is usable now (import again to bind it)
credential, host key and TPM2ok, "host key and TPM2"
credential, TPM2 onlyok, "TPM2"
credential, null keyfail, "null key, not encrypted" (integrity only)
credential, unrecognized sealwarn, "unrecognized seal"
credential missing or not decryptingfail
key_path, systemd-creds workswarn: encrypt it with collied apns import
key_path, systemd-creds unavailableok: "0600 file fallback (systemd-creds unavailable): protected by its mode only"

Whether systemd-creds works is a live probe, run only with key_path: doctor encrypts and decrypts a test value under the name collied-apns-doctor-probe (on a machine with no credential yet, that first encrypt makes systemd create the host secret). Like on macOS, doctor then signs a provider token as a dry run and warns about any other .p8 in <data dir>/apns.

Storage difference. Other unprivileged users cannot decrypt the credential, but any process of the same UID can, without a prompt: there is no per-application binding like the Keychain ACL that trusts only the Developer ID signed collied. Same-UID malware on Linux can read the key, not only use collied to send pushes. The 0600 fallback is protected by its mode only. Details: threat-model.md.

collied status

On both OSes, collied status also prints tags: ((none) for an untagged node, (not reported) when the node status failed) and, when herdr answers, agents: N and one line per agent: status, name (else kind, else agent), workspace label (? when unknown), terminal_id, escaped for the terminal. The agents are what the phone would see: the daemon's herdr session.snapshot, mapped as for flock.snapshot. A flock too large for one control line is dropped and status prints too many to list here, so the status request, also the liveness probe, never fails on a large session. The control socket reply gained tags, flock and flock_too_large, all with serde defaults, so a newer CLI still reads an older daemon's reply.

iOS

  • Only the app target links CollieCore (Rust + Go). The Go runtime starts at image load through a __mod_init_func initializer, so the widget extension and the NSE stay pure Swift and share an App Group container (reachability hint, Live Activity state). The watch targets, CollieWatch and CollieWatchWidgets, are pure Swift too.
  • UniFFI 0.32.2 bindings compile in Swift 6 language mode, as does the app.
  • One xcframework, one static library per slice (device, simulator): the Rust staticlib bundles the Go c-archive linked by tailscale-sys.
  • Terminal font: MesloLGS NF (Apache 2.0, romkatv/powerlevel10k-media at 145eb9fbc2f42ee408dacd9b22d8e6e0e553f83d), bundled in the GhosttyTerminal package and loaded from data without registering it system-wide, so Nerd Font prompt and status-line glyphs render as on the Mac. SHA-256: Regular d97946186e97f8d7c0139e8983abf40a1d2d086924f2c5dbf1c29bd8f2c6e57d, Bold b6c0199cf7c7483c8343ea020658925e6de0aeb318b89908152fcb4d19226003, Italic 6f357bcbe2597704e157a915625928bca38364a89c22a4ac36e7a116dcd392ef, Bold Italic 56b4131adecec052c4b324efb818dd326d586dbc316fc68f98f1cae2eb8d1220. Glyphs it lacks fall back through CoreText's cascade.
  • Machine names: a machine shows under the name collied gives in its pair.complete reply (machine.name), which collie-core keeps as label in machines.json and never refreshes. Rename (a machine's context menu or its detail in the Machines tab) calls rename_machine: the new name (trimmed, 1 to 64 characters, no control characters) replaces label, which every surface reads (Machines tab, inbox, grid, session header, Usage, approval cards, follow-up alerts, the watch, Live Activities started afterwards), and the name from pairing is kept as default_label. A blank name, the default name or Reset restores it. Only this phone sees the name: the pairing, host, link, machine ID and collied are unchanged, and pairing the machine again brings back the name from pairing. A running Live Activity keeps the name it started with, and collied's alerts carry no machine name.

Apple Watch

  • CollieWatch (embedded under Collie.app/Watch) and its CollieWatchWidgets complication hold no keys, never link CollieCore and never talk to collied. Everything goes through the phone over WatchConnectivity.
  • Phone to watch: updateApplicationContext(["state": WatchState JSON]), sent only when it changes (a change to the agents alone only while the watch app is reachable, at most once a minute), from the app's 1 s poll loop once each machine has a snapshot or is known down, and on going to the background (then live is false). It carries at most 5 unexpired approvals (agent, place, the decrypted command, the snippet, Approve/Deny and menu choices, never Approve always, never a nonce), at most 30 agents as in the inbox, the newest Claude plan usage (5-hour and weekly windows) and newest Codex monthly reading and whether watch decisions are allowed. Texts are capped so it fits in an application context. Nothing is published from a background launch except a refresh's answer (below).
  • Watch to phone: sendMessage(["decide": {nodeId, approvalId, decision}]), which wakes the iPhone app; the reply is {title, body, answered}: the follow-up text, and whether the watch need not offer the approval again (false when nothing reached collied). The watch first passes deviceOwnerAuthenticationWithWristDetection (a fresh LAContext each time).
  • The phone (AppModel.decideFromWatch) re-reads watchDecisions from prefs.json on every request (off by default; turning it on needs deviceOwnerAuthentication), and decides only an unexpired approval, with an answer, that is in the last application context it sent (WCSession.applicationContext). Then it takes the lock-screen path, decide_from_notification, so the nonce, expiry, replay and fingerprint checks on the machine are unchanged.
  • Watch refresh: sendMessage(["refresh": true]) when the watch app becomes active (at most once a minute after the phone answered with its state, even with a Mac that did not: each such refresh of a locked phone runs up to its 15 s budget, so approvals from a Mac that comes back can reach the watch up to a minute later), opens from an alert, or receives an approval alert while in front, and from a background app refresh (.backgroundTask(.appRefresh)) the app schedules for 15 minutes later on going to the background and at the start of each run, which watchOS may delay or skip. That one has the same once-a-minute limit and gives up after 20 s instead of 25 s, keeping the last state and figure. In the foreground the phone publishes and answers with its state. Otherwise (AppModel.refreshForWatch) it calls collie-core approvals_in_background (15 s budget): the lock-screen path's node start and pinned one-shot session per paired Mac, approval.list only, whose reply also carries the Mac's plan usage. The new approvals replace the last shown ones (a Mac that did not answer keeps its own), the newest listed plan usage replaces the last shown one (and a Mac's cached plan when newer, so the app's return does not publish the older plan before its next snapshot), the agents stay as last shown, live is false, and the result goes out as the application context, so decisions check it as usual. The reply is {state, silent}, silent naming the Macs that did not answer; with no reply in 25 s the watch keeps what it has; with no reply, or with a Mac that did not answer, it says it could not refresh. A failed updateApplicationContext gets no state in the reply.
  • The watch app is the UNUserNotificationCenter delegate on the watch and registers APPROVAL without actions. Any response to an alert carrying node_id and approval_id opens that approval's detail (read-only unless watch decisions are on) after a refresh; nothing decides from an alert.
  • The watch app writes the plan usage to watch-usage.json in its own App Group container and reloads the CollieUsage complication (accessoryCircular). Its three concentric rings show Claude 5-hour, Claude weekly, and Codex monthly usage, outer to inner. Each ring fills clockwise from noon with percent used, colored by a smooth usage scale (bright green at 0%, yellow at 60%, orange at 80%, red at 90%, deep red at 100%). The Codex interval starts one calendar month before its reported reset, in UTC. The timeline schedules elapsed-time markers every five minutes in one-hour batches, with entries at stale transitions and resets within that horizon. This bounds each batch to at most 18 entries so watchOS does not need to archive a full day of rectangular layouts at once. At the end it requests another timeline from cached data. Snapshot requests outside the widget gallery also read cached usage; sample values are reserved for previews. Empty or expired timelines retry after 15 minutes rather than stopping indefinitely. The watch republishes cached usage and requests a widget reload when opened or when receiving unchanged usage, at most once a minute; changed usage reloads immediately. Failed cache writes remain eligible for retry. A reset dims that ring until fresh usage arrives. Missing readings also show a dim track. Usage colors update with any refresh or received context, including the background refresh. The same widget also supports accessoryRectangular for the Smart Stack and rectangular watch-face slots: three native .accessoryCircular gauges sit side by side, each holding the shared Claude or Codex logo, with the reset countdown below. The gauges share the rings’ usage-color scale and dim the unused arc. Both families share the same timeline and reload path. White ticks mark elapsed time against used allowance. Fill beyond a tick indicates usage ahead of an even pace. The phone’s Codex usage bar also marks elapsed calendar-month time against its usage fill, using the same UTC month interval as the watch. Claude and Codex source timestamps travel independently with their latest readings; absent timestamps count as stale. A stale rectangular reading shows an orange dot beside its countdown. A missing or expired rectangular reading shows a dim gauge with “No data” below; missing circular tracks are dashed, with -- in the center when all three are unavailable. Both widget families link to collie-watch://usage, opening a watch details screen with percentages, exact reset times, reading ages, and the last phone sync. Its cards use watchOS 26 Liquid Glass inside a shared GlassEffectContainer, with the stable outer scroll view preserving touch and Digital Crown scrolling. The watch persists the successful phone receipt time separately, so receiving old data never makes its source timestamp fresh.

Client runtime

  • One connection supervisor per paired machine in collie-core, each with its own backoff, request queue and link state, so a machine that is off only affects its own section: backoff 3, 4, 8, 16, 30, 60 s; reset after 30 s connected; wait while the phone's tailnet is offline, re-checked every 3 s and at once when the node first reaches Running; stop on auth failures. A protocol version mismatch also stops, saying which side to update (collied sends its version in the unsupported_protocol error); requests meanwhile fail at once with that message, and the next resume or manual retry dials again, so an update needs no new pairing. A machine the netmap reports offline is not dialed (a 20 s dial to a dead peer runs on the node every machine shares): its link is Unavailable ("offline" in the app, with "not reachable, it may be off or asleep"), is re-checked locally after 3, 6, 12, then every 15 s (every 3 s for 30 s after a resume, and from 3 s again after any other outcome) and dialed as soon as it is online, and still dialed every 15 min in case control is wrong. Pairing and lock-screen decisions dial regardless, and so does each reconnect request (a resume or a manual retry), once: the phone's netmap can be stale right after a resume. An attempt that ends before the dial (the node's status call failed, the phone's key is not loaded) does not use that dial up. A session that receives nothing for 45 s (collied pings every 15 s) ends as a timeout and reconnects, so a machine that slept or lost power without closing the connection does not stay Connected. On foreground after 10 s or more in background, reconnect immediately (iOS suspends sockets without a close); shorter gaps get a 3 s probe. For 10 s after a foreground resume, failures (the old session's close, a node still rebuilding its paths after the suspend, the node not yet running) retry after 0.5, 1, then 2 s and keep the link Connecting without surfacing the error; a failure the app saw just before the resume is cleared and retried at once, the resume restarts the normal backoff, auth failures still stop at once, and later failures fall back to the normal backoff. A snapshot request cut off by the reconnect answers from the cache. A resume also moves an Unavailable link back to Connecting and dials it at once.
  • Manual retry (reconnect(machine_id)): pull to refresh on the Agents list retries every machine, a tap on a machine's header retries that one. A live session gets the 3 s probe, any other link is dialed at once, past the offline flag.
  • Connection errors appear only in the Machines tab, inside a compact glass card, without a disclosure row. Centered footnote text and a small dark glass clipboard button expose the full error without navigating or retrying the connection. The button keeps a 44-point hit target. The Agents layouts retain connection status indicators but no diagnostic text, except a version mismatch: the machine's section header also shows which side to update. Copying briefly replaces the clipboard icon with the green Done checkmark, without a visible label. Debug terminal demos accept COLLIE_DEMO_CONNECTION_ERROR to exercise these errors without a live machine.
  • The Agents tab reads the core's cache every 3 s, which agent.status events keep current, and every 0.25 s while a machine waits for a dial or its first list, or for the node during the 20 s after the tab appears (an offline link with no error can also be a node that never runs, logged out or with no network). These fast reads are the cache only: a machine whose last read failed is left to the 3 s read. It asks connected machines for flock.snapshot when it appears, on pull to refresh, after a close and every 60 s, which covers shells (no event lists them) and an older collied that sends no title changes. When reconcile first finds herdr unreachable, collied sends flock.changed; the refetch fails with herdr_unavailable, which collie-core keeps on the link's view until a snapshot works, so the list shows the outage and asks for a snapshot every 3 s until it ends.
  • Node recovery, for a node whose paths died while the app was suspended (more likely on cellular: carrier NATs drop idle UDP mappings and TCP state silently, so the home DERP connection looks open until its 120 s read deadline; on any network iOS can defunct the node's UDP sockets, after which wireguard-go's receive loop for them has exited, health "The MagicSock function ReceiveIPv4 is not running", and a rebind replaces the sockets without restarting it, so the node receives only through DERP until it is restarted). After a resume from a suspend or a background of 10 s or more: if no machine is connected 3 s later, the node is rebound (tailscale_rebind); not sooner, since a dial in flight during a rebind can stall for its whole 5 s attempt, and a healthy reconnect takes well under 3 s. If no machine has connected 10 s after the resume, while the node reports Running and a machine still dialing or failing is online (or the node cannot report its status), and also on a manual retry while dials fail, the node is restarted as a relaunch would: taken out of the slot, closed once nothing holds it (the lock-screen decide holds the node until it is answered; a dial in flight finishes its attempt but starts no other on it), then a new node starts from the same state and every link redials. On a phone on the same Wi-Fi as the Mac, every reconnect that came at all did so within 7 s of the resume, the rebind's included, while a node the rebind did not help stayed unreachable until a restart or a relaunch, which then connected within seconds. Requests queued while nothing is connected wait for the new node. The old node is put back instead when a machine connects meanwhile, so no session is cut, or when the app is suspended, so a lock-screen decide waiting for the node gets it at once; a restart whose node another restart already replaced does nothing. The restart holds the node-start lock, so a lock-screen decide waiting for the node gets the new one, and two nodes never share the state dir. If the new node cannot start, the restart starts a node again at once, and otherwise the next resume or manual retry does.
  • Network changes: the app watches the path with NWPathMonitor and reports the interface it routes through (Wi-Fi, cellular or wired, never a VPN tunnel; empty when there is no path) with network_changed. The first report is only recorded. On a move to another interface (Wi-Fi to cellular and back), or back after no path, once the path has held for 1 s (it can flap at the edge of Wi-Fi), the node is rebound with the new interface. A live session then gets the 3 s probe and is redialed only when it fails: its TCP runs inside the node and outlives the move once the node reaches the machine again, but iOS reports no error when it does not (it would otherwise last until the 45 s silence limit). Every other link is redialed past the offline flag with the resume grace. If nothing connects within 15 s (three dial attempts) while the node reports Running and a machine still dialing or failing is online, the node is restarted as above. That check, and the 10 s one after a resume, is dropped when the path changed or a resume came since, and only restarts the node the flow started with, so a newer node gets its own grace. A move reported while the core is suspended rebinds the node at once, so a lock-screen decide binds its sockets to the new interface, and the redial waits for the next resume, whichever of the two the app sees first. A Wi-Fi network replaced by another on the same interface without a gap is not seen as a move.
  • Diagnostics: each resume writes one line to the device log (subsystem dev.rbstp.collie, category core) with the background time, whether the core was suspended, the node's backend state and health warnings, and per machine the link phase before the resume, the time since its last dial, its last error and whether the netmap reports it online. Each path change is logged (network path: en0 to pdp_ip0), and rebinds and restarts with their reason and outcome. No keys, tokens or nonces.
  • Snapshot plus sequence: flock.snapshot returns the seq it reflects; every event frame carries seq; the client drops seq <= last applied; unknown events still advance the cursor.
  • Mutations are never replayed automatically, except a prompt to an unreachable machine: up to 5 attempts a minute apart (at once when the link reconnects while its agent screen is open), none later than 270 s after the first. The app holds one prompt per agent from its first attempt, in memory only, so its resends go on after the screen closes until the app process ends, and stop when the machine is removed. A screen that opens meanwhile shows it with its spinner, even over text loaded from the machine's input box; editing it, tapping the spinner, the agent exiting, or a different prompt sent from that screen stops it (the different one waits for any attempt in flight). If it ends without arriving and no screen shows it, it goes back to the agent's saved draft with a "may not have been sent" note shown once. The agent's last prompt is compared only when it was read with the link up. Not detected: a new agent started in the same pane before the resend would receive it. Each retry is resend_prompt, which collie-core sends only under the op_id an unanswered identical prompt still holds (kept 300 s of wall time from its first send; collie-core reports Unreachable only while it holds it) and otherwise refuses with NotResent, sending nothing; only the user's Send mints an op_id. collied then replays an attempt that arrived instead of typing the prompt twice. Limits: collied's outcome cache is in memory for 600 s, so a collied restart forgets the op_id; before each retry the phone treats the prompt as sent when the agent's last prompt changed to its first line, which covers most of that case. A retry reuses its op_id.
  • Live Activities: push-to-start is unreliable from background wakes. Start activities from the foreground, register update tokens on every foreground, priority 5 for routine updates and 10 for alerting ones, always set a stale date. Push-to-start is not used (Live Activities).
  • No prior art for authenticated lock-screen approve/deny. collie's flow is described under Lock-screen approval; its timings still need measuring on device.

Risks

RiskImpactMitigation
libtailscale has no git tags and lags tailscale.com releasessecurity updates can be ours to carrypin SHA + patch series; track upstream (0.2.0 at d1dc45d)
Peer attribution race in getremoteaddr (fixed upstream in 0.2.0)wrong peer authorizedpatch 1; never call getremoteaddr
Go runtime size~20 MB per binary (iOS stripped), ~25 MB colliedmeasure per slice; Go archives built with -w
Cold start in a background launchApprove from lock screen failstsnet state cached, node started from cache (never a login), 20 s budget cap, follow-up local notification on every outcome; measure on device
Suspend/resume leaves stale DERP/magicsock stateslow reconnectsre-dial on foreground, forced past a stale offline flag; rebind when nothing reconnects within 3 s; node restart when nothing connects within 10 s; resume diagnostics in the device log
Wi-Fi to cellular leaves the node bound to the old interfaceno reconnect until a relaunchNWPathMonitor feeds the interface to the node; on a move: rebind with a netmon refresh once the path holds 1 s, live sessions probed and other links redialed, node restart when nothing connects within 15 s; path changes in the device log
herdr server load (status entry polls pane.get every 100 ms)CPU with many panesmeasure; fall back to agent.list polling if needed
UniFFI + Swift 6build frictionSwift 5 mode bindings target; no async foreign traits on hot paths
APNs .p8 is team wideleak allows pushes to every app on the teamdedicated revocable key per machine; on macOS a Keychain item whose ACL trusts only the Developer ID signed collied; on Linux a systemd user credential, which any same-UID process can decrypt; payload never carries the nonce; limits in threat-model.md
APNs payload transits Appleproject names exposedalert carries agent and workspace labels in clear; the pending action only sealed to the phone's notification key; never the nonce; detail fetched over the tailnet. Live Activity content (status, labels, a count, an approval id; the context only sealed) only for followed agents
Answering a blocked prompt outside approvalsbypasses nonce/expirycollied refuses prompts while blocked, and keys and text unless the prompt, re-read at that moment, is a Claude Code question form with nothing that grants a permission on it; audited
Prompt delivered after agent exitprompt runs as a shell commandforeground check before send; conditional input in herdr upstream
Retried mutationsduplicate prompts/tasksop_id outcome cache
A paired phone writes files on the Macdisk use, files an agent may readsize, rate, in-flight, 200 MiB and 1000 entry caps; private 0600 non-executable files in a 0700 cache dir; 24 h retention; threat-model.md
Older app vs newer collieddecode failuresadditive events/errors/statuses decode to Unrecognized; anything else bumps PROTOCOL_VERSION
Shell input from a paired phonecommand execution as the useroff by default, collied.toml only; its own method class; a grant signed by a Secure Enclave key behind Face ID, per terminal, session and 5 minutes; the pane re-read before every write; audited without the text; threat-model.md

Tailnet setup

collie runs two Tailscale nodes of its own, both embedded through libtailscale (tsnet, userspace netstack):

NodeRuns inIdentityLogin
Maccolliedtag:collie-mac (tag-owned, no user)collied login: interactive by a tag owner, or a tag:collie-mac auth key
Linux machinecolliedtag:collie-linux (tag-owned, no user)collied login: interactive by a tag owner, or a tag:collie-linux auth key
PhoneCollie.app (CollieCore)user-owned by youinteractive login inside the app, or an auth key for your own user

No Tailscale app is needed or used on either device. Neither node installs a VPN profile, a Network Extension or a system tailscaled; each is a separate device in the admin console. A Tailscale app already installed on the Mac or the iPhone is a different node and plays no part in collie.

The phone only dials (TCP to the Mac on port 8457). The Mac only listens through tailnet::Node::listen on that port; it never dials the phone.

Mac node

  • collied login starts the node with advertise_tags = ["tag:collie-mac"] and prints the login URL (and its QR). Open it and sign in as a user listed in tagOwners for tag:collie-mac. The node then belongs to the tag, not to you: tags replace user ownership.
  • If Tailscale refuses the login, collied login prints its last login error; when the error is about the tag, it stops and prints the tagOwners and grant entries to add. collied doctor warns on its reach line when no device of yours can reach the node, which means the grant below is missing (or the phone is not signed in yet). collied setup runs both checks.
  • Instead of the interactive login, collied login accepts an auth key in COLLIE_TS_AUTHKEY. Use a key that applies tag:collie-mac. collied reads the variable and removes it from its own environment before doing anything else; collied run ignores it. The key is only needed once: the node keys persist in the state directory.
  • Hostname defaults to collie-<mac hostname> ([tailnet] hostname in collied.toml overrides it).
  • Node state (machine and node keys) lives in ~/Library/Application Support/collie/tsnet, a 0700 directory owned by your user. tailnet::Node::new refuses a directory that is group or world accessible.
  • The pairing QR carries the Mac's tailnet node name (its netmap DNS name) and StableID. The phone matches them in its own netmap and dials the node's IP directly, so no DNS lookup is involved (see Phone side). MagicDNS should therefore not be required; this is not yet verified on a tailnet with MagicDNS off.
  • The port is [tailnet] port in collied.toml (default 8457). If you change it, change the grant and the tests below to match.

Linux node

  • Same as the Mac node, with tag:collie-linux instead of tag:collie-mac, hostname collie-<hostname>, and node state in $XDG_DATA_HOME/collie/tsnet (else ~/.local/share/collie/tsnet), 0700. The login error, the reach line and collied setup print the entries for tag:collie-linux.
  • collied login and collied run refuse a node whose own tags do not include tag:collie-linux. Logging in again does not change the tags of a node that is already registered: remove it in the admin console (Machines), delete the tsnet directory, check that your user is a tag owner of tag:collie-linux, then run collied login again (phones pair again).
  • A system Tailscale (tailscaled, the tailscale CLI) on the same machine is a different node and plays no part in collie. The two run side by side: the embedded node has its own state and keys, uses a userspace netstack (no TUN device, no routes) and its own WireGuard UDP port.
  • The phone accepts a Linux node at pairing and from then on requires tag:collie-linux on it.

Phone node

  • The app starts its node (hostname collie-phone) and opens the Tailscale login page. Sign in as the user who owns collied (see Owner).
  • The onboarding screen also accepts an auth key, used once and not stored. It must be a key for your own user, not a tagged key: a tagged phone node is refused by collied.
  • The node must stay untagged and must not be shared in from another tailnet: collied refuses both.
  • State lives in the app container under Application Support/collie (node keys in tsnet/, paired Macs in machines.json, pending Live Activity ends in activity-ends.json, ids and times only). Collie/Sources/Collie/StateDirectory.swift creates it 0700 with data protection completeUntilFirstUserAuthentication, and collie-core refuses a directory that is group or world accessible or owned by another user. Deleting the app deletes the node identity.
  • The state directory must be excluded from iCloud and Finder backups. Otherwise restoring a backup, on this phone or another device, brings back the same node keys and StableID, which collied accepts as the paired phone. StateDirectory.swift excludes it from backup.

Policy file

Replace you@example.com with your login. HuJSON, current grants syntax:

{
  "tagOwners": {
    "tag:collie-mac": ["you@example.com"],
    "tag:collie-linux": ["you@example.com"],
  },

  "grants": [
    // Your devices may open TCP 8457 on the collie Mac node. No rule names
    // tag:collie-mac as a source, so the Mac node cannot initiate anything.
    {
      "src": ["you@example.com"],
      "dst": ["tag:collie-mac"],
      "ip": ["tcp:8457"],
    },
    // The same for a Linux machine running collied.
    {
      "src": ["you@example.com"],
      "dst": ["tag:collie-linux"],
      "ip": ["tcp:8457"],
    },
  ],

  "tests": [
    {
      "src": "you@example.com",
      "proto": "tcp",
      "accept": ["tag:collie-mac:8457"],
      "deny": ["tag:collie-mac:22", "tag:collie-mac:8458"],
    },
    {
      "src": "you@example.com",
      "proto": "udp",
      "deny": ["tag:collie-mac:8457"],
    },
    {
      "src": "tag:collie-mac",
      "deny": ["you@example.com:8457", "you@example.com:22", "you@example.com:443"],
    },
    {
      "src": "you@example.com",
      "proto": "tcp",
      "accept": ["tag:collie-linux:8457"],
      "deny": ["tag:collie-linux:22", "tag:collie-linux:8458"],
    },
    {
      "src": "tag:collie-linux",
      "deny": ["you@example.com:8457", "you@example.com:22", "you@example.com:443", "tag:collie-mac:8457"],
    },
  ],
}

Grants only add access, so this snippet is only tight if nothing else in the policy reaches tag:collie-mac or lets it reach anything:

  • Remove the default allow-all grant ({"src": ["*"], "dst": ["*"], "ip": ["*"]}) or its legacy acls form ("src": ["*"], "dst": ["*:*"]). Both include tagged nodes in both directions. If you want your own devices to keep reaching each other, {"src": ["autogroup:member"], "dst": ["autogroup:self"], "ip": ["*"]} does that without touching tagged nodes.
  • No other grant may use *, autogroup:tagged, tag:collie-mac or tag:collie-linux in src or dst, nor anything that covers the Mac node's tailnet addresses: a CIDR such as 100.64.0.0/10 or fd7a:115c:a1e0::/48, a hosts alias, or an ipsets entry.
  • The phone node is user-owned, so any rule whose dst covers your devices (for example autogroup:self) also reaches it. It opens no listener of its own; see the threat model for what remains reachable.
  • To add another user, give them their own collie Mac tag and grant; do not widen src. Each collied serves one owner.

Checking it in the admin console

  1. Access controls: paste the snippet, save. The tests block runs on every save and rejects a policy that breaks it.
  2. Access controls, Preview rules tab: select your user. For collie the only entries involving the collie tags must be tag:collie-mac:8457 and tag:collie-linux:8457 over TCP. Any broader entry (*, other ports) means another rule still matches.
  3. Select another user, if the tailnet has one: neither collie tag may appear. This matters most before the first pairing, when collied has no owner yet (see below).
  4. Machines: the Mac node shows the collie-mac tag and no owner; the phone node shows your user and no tag.

What collied enforces anyway

The policy is the first filter, not the authorization. collied checks every accepted connection before the WebSocket upgrade (crates/collied/src/gate.rs):

  1. The peer address comes from tailscale_accept_with_addr (patched libtailscale), returned with the connection fd itself. The fd-keyed tailscale_getremoteaddr, which mis-attributed peers under concurrency, is never used.
  2. whois(peer) must return a node that is untagged and not shared in (Sharer == 0).
  3. If an owner is known, the node must be owned by that user (WhoIsNode::is_owned_by).
  4. Then:
    • its StableID is in peers.json with the same user ID it was paired with and a pinned TLS key: full session (hello first, then the method allowlist). A paired StableID that now reports another user is refused;
    • it is not paired, or was paired before mutual TLS and has no TLS key, and a pairing window opened locally with collied pair is active: pairing-only session (hello, pair.complete, then close);
    • otherwise: closed before the upgrade and audited.
  5. The TLS handshake must prove the key pinned for that phone (any P-256 key in a pairing-only session), and the gate then decides again under the peers, pairing and sessions locks: a different decision (a revoke, a pairing, or the pairing window closing during the handshake) closes the connection.

So a device of yours that the policy lets through still gets nothing until it is paired, and a tagged or shared-in node is refused even if a policy mistake lets it reach port 8457.

Pairing

  • One window at a time, open for 120 s. The first pair.complete from any connection closes it, whether the code is right or wrong.
  • With the right code, collied pair shows the candidate's device label, node name, StableID, login name and numeric user ID (from whois) and asks y/N on the Mac. No answer within 60 s means no.
  • Only a confirmed candidate is written to peers.json.

Owner

owner_user_id is the numeric Tailscale user ID of the person allowed to use this collied. It comes from one of two places:

  • [tailnet] owner_user_id in collied.toml. When set, the gate refuses every other user and pairing refuses to store one. collied refuses to start if it differs from the owner recorded in peers.json.
  • Otherwise, the first pairing you confirm records that device's user as the owner in peers.json (trust on first use). Revoking every phone does not clear it.

Until an owner is known, step 3 is skipped: any untagged, not-shared-in node that the policy lets reach the port can open a pairing-only session while a window is open, and only the pairing code and your y/N stand in its way. Before the first pairing, make sure the policy admits only you (check 3 above), and on the y/N prompt check that the login name is yours.

Neither device has a Tailscale CLI, so the numeric ID is not shown anywhere else: read it from the y/N prompt or, after the first pairing, from collied peers list. Copying it into collied.toml keeps the owner pinned even if peers.json is deleted.

Phone side

The phone checks the other direction (crates/collie-core/src/pin.rs). It does not use a DNS answer: it takes the machine's IP from its own netmap entry whose DNS name matches and whose StableID is the one pinned at pairing, requires that entry to carry the tag seen at pairing (tag:collie-mac or tag:collie-linux; pairing accepts either), and dials that IP once whois confirms the IP is that node and not shared in. The TLS handshake must show the machine's key pinned at pairing, and hello must report the pinned node ID. A different node under the name, a different key, or a machine that lost or changed its tag, is a pin violation and is not retried automatically.

Key expiry

NodeRecommendationOn expiry
Mac or Linux machine (tag:collie-mac, tag:collie-linux)Expiry disabled. Tailscale disables it by default for a device tagged at first authentication; confirm "Expiry disabled" on the Machines page.collied's node goes to NeedsLogin, the phone cannot connect, collied login re-authenticates.
Phone (user-owned)Keep the tailnet's expiry (default 180 days). It bounds how long a lost phone's node key works if revocation is forgotten.The app's node goes to NeedsLogin and shows the login URL. Re-authenticating the same node should keep its StableID, so the pairing stays valid (not yet verified on a device). Signing in as another user changes the node's user and collied refuses it.

Deleting and reinstalling the app creates a new node with a new StableID: pair again and remove the old device in the admin console.

Revocation

Do both steps; each covers what the other cannot.

  1. collied peers revoke <label or StableID> removes the phone from peers.json and drops its push tokens. Removing the machine in the app does the same when the machine is reachable at that moment; otherwise run the command. With the daemon running, it saves first and then closes every live session of that node; with the daemon stopped, it edits peers.json directly. It stops collie access immediately, even if the node stays in the tailnet.
  2. Admin console, Machines, the phone node: Remove. This deletes its node key from the tailnet, so it can no longer reach anything, collie or not. Expire key alone only forces a re-login, which a thief could complete if the phone still holds a signed-in session with your identity provider.

For a compromised Mac: remove the Mac node in the admin console, delete ~/Library/Application Support/collie/tsnet, and revoke the APNs key in the Apple Developer portal. For a compromised Linux machine: the same with its own node, ~/.local/share/collie/tsnet (or $XDG_DATA_HOME/collie/tsnet) and its own APNs key; each machine's key is revoked on its own.

Notification testing and navigation

Delivery-only test

collied apns test

This sends "Test notification from collied" to registered devices belonging to paired phones. It has no machine or session identifiers. Tapping it opens the app, but does not select an agent session.

The CLI reports APNs acceptance, not confirmation that the phone displayed the alert. Check the phone as well. Debug builds register sandbox tokens; production builds use production tokens. The sender uses each device's registered environment.

Session-linked test

Get an agent's terminal ID from collied status, then send:

collied apns test --terminal-id <terminal-id>

The daemon must be running so the CLI can get its node ID. The terminal ID is validated before sending.

This adds node_id, terminal_id, and the terminal's aps.thread-id to the test alert. Tapping it opens that agent's session on the Agents tab. The phone maps the node ID to a paired machine and validates the session link.

This is a harmless navigation test. It has no approval ID, approval actions, or tool command, and does not approve or deny anything. It tests the normal session-notification path, not a real permission decision.

Approval notifications

Actual approval alerts carry a node ID and approval ID. Their notification thread identifies the terminal. Tapping an alert first resolves the pending approval to its machine and terminal. If the approval has already ended, a valid thread can still open the original session.

Loading shows the connection phase, and missing or unavailable machines produce a visible error. Connecting lookups have a 15-second retry window; an in-flight request still has its own RPC timeout. Connection failures are not reported as expired approvals, and a valid notification thread can still open the session to show its connection state.

Selecting another tab or session, or pressing Back to return to the Agents list, cancels a pending lookup so a delayed notification cannot replace the user's newer navigation. The initial empty navigation path does not cancel cold-start notification loading.

The iPhone has no separate Approvals tab. In-session approval controls, notification actions, Live Activities, and the Apple Watch approval list remain available. Structured approval actions still require their existing authentication and approval checks; manual terminal input is separate.

Observations from device testing

  • The generic test reached the iPhone and opened the app, but did not open a session because its payload had no session target.
  • The session-linked test reached the iPhone, and the user confirmed that it opened the intended session.
  • Opening the app alone does not demonstrate that session routing worked. Test the targeted payload and check the resulting screen.

The payload helpers are in crates/collied/src/push.rs. iPhone action classification is in Collie/Sources/Collie/Notifications.swift; session routing is in AppDelegate.swift, AppModel.swift, and ApprovalsModel.swift.

Release

.github/workflows/testflight.yml builds, signs and uploads the iOS app to TestFlight. The Apple side is set up once, by hand.

One-time Apple setup

Team RM3UT3MMSR, in the Apple Developer portal and App Store Connect:

  1. App Group: register group.dev.rbstp.collie.

  2. App IDs (explicit):

    • dev.rbstp.collie, the app, and dev.rbstp.collie.push, the ColliePush notification service extension, each with Push Notifications and App Groups (assign group.dev.rbstp.collie) enabled;
    • dev.rbstp.collie.widgets, the CollieWidgets extension (Live Activities), with App Groups (assign group.dev.rbstp.collie; the widget reads the notification key mirror there). No push capability: Live Activity pushes are addressed to the app's topic and use the same APNs key.
    • dev.rbstp.collie.watchkitapp, the CollieWatch app, and dev.rbstp.collie.watchkitapp.widgets, its usage complication, each with App Groups (assign group.dev.rbstp.collie; on the watch it is the watch's own container). No push capability and no keychain group.
  3. Provisioning profiles: App Store Connect distribution, the team's Apple Distribution certificate, named exactly as Collie/project.yml and Collie/ExportOptions.plist refer to them:

    • Collie App Store for dev.rbstp.collie;
    • Collie Push App Store for dev.rbstp.collie.push;
    • Collie Widgets App Store for dev.rbstp.collie.widgets;
    • Collie Watch App Store for dev.rbstp.collie.watchkitapp;
    • Collie Watch Widgets App Store for dev.rbstp.collie.watchkitapp.widgets.

    A profile is a snapshot of its App ID's capabilities: after any capability change, regenerate the profile (Edit, Save) and update its secret.

  4. App Store Connect app record for dev.rbstp.collie, with an internal TestFlight group.

  5. APNs key for collied, one per machine: Certificates, Identifiers & Profiles, Keys, add a key with Apple Push Notifications service (APNs), environment Sandbox & Production. Debug builds installed with just ios-run-device register sandbox tokens, TestFlight builds production tokens, and one collied serves both. Download the .p8 (offered once) and note the Key ID. Each machine that runs collied gets its own key, with its own Key ID: it pushes on its own and is revoked on its own. The key is a runtime secret of the daemon on that machine, separate from the App Store Connect API key below, and is never stored in GitHub.

    On the Mac, it lives in the login Keychain, readable only by collied signed with your Developer ID Application identity:

    # ~/Library/Application Support/collie/collied.toml
    [apns]
    key = "keychain"
    key_id = "<KEY_ID>"
    team_id = "RM3UT3MMSR"
    bundle_id = "dev.rbstp.collie"
    
    just collied-install                      # signed build to ~/.cargo/bin/collied, restarts the agent
    chmod 0600 AuthKey_<KEY_ID>.p8
    collied apns import AuthKey_<KEY_ID>.p8   # stores, reads back, offers to delete the file
    collied doctor                            # codesign and apns lines must be ok
    collied apns test
    

    collied setup offers these steps when [apns] is not configured: it asks for the .p8 path and the IDs (defaults RM3UT3MMSR and dev.rbstp.collie), writes the section, imports the key and restarts collied. The .p8 must already be mode 0600.

    Import with the signed binary: the Keychain item trusts the program that created it. Re-run just collied-install (never cargo install) after every change to collied, or the daemon cannot read the key without a prompt. A config still using key_path is moved over by the same collied apns import <key_path file>, which rewrites that line to key = "keychain". Revoke the key in the portal if the Mac is compromised.

    On Linux, it is a systemd user credential, encrypted with the host key (and the TPM2 when one is usable). There is no code signing:

    # ~/.local/share/collie/collied.toml ($XDG_DATA_HOME/collie/collied.toml when absolute)
    [apns]
    key = "systemd-creds"                     # the default on Linux, may be omitted
    key_id = "<KEY_ID>"
    team_id = "RM3UT3MMSR"
    bundle_id = "dev.rbstp.collie"
    
    just collied-install                      # release build to ~/.cargo/bin/collied, restarts the unit if active
    chmod 0600 AuthKey_<KEY_ID>.p8
    collied apns import AuthKey_<KEY_ID>.p8   # encrypts, decrypts back, offers to delete the file
    collied doctor                            # apns line must be ok and shows the seal
    collied apns test                         # needs a paired phone (see collied on Linux)
    

    collied setup offers these steps on Linux too, the same way as on macOS; the import picks the credential or the 0600 fallback as below.

    Import writes the credential to apns/<KEY_ID>.cred in the data directory. systemd picks the seal, not collied: host key and TPM2 when a TPM2 is usable, host key only otherwise. Doctor's apns line says which, and warns when a TPM2 becomes usable after a host-only import. Import keeps an existing credential that decrypts to the same key, so to bind it to the TPM2, delete apns/<KEY_ID>.cred and import the .p8 again. Where systemd-creds cannot encrypt (for example no systemd-creds.socket, systemd older than 256, a container), import falls back to a 0600 copy at apns/AuthKey_<KEY_ID>.p8 in the data directory, sets key_path to it and says so. Doctor reports the fallback, and warns once systemd-creds works: import that file to encrypt it. The credential stops decrypting after an OS reinstall, a machine-id change, a uid or user name change, or a move to another machine: keep a copy of the .p8 offline and import it again. Revoke the key in the portal if the machine is compromised.

just mac-install signs the menu bar app with the Developer ID Application certificate and the hardened runtime; it runs on the Mac that built it without notarization. Nothing is needed in the developer portal: no App ID and no provisioning profile, as it has no entitlements.

To run it on another Mac without a Gatekeeper prompt, notarize it. notarytool needs one credential, stored once in the login keychain:

  • an App Store Connect API team key with the Developer role (the TestFlight key works): xcrun notarytool store-credentials collie --key AuthKey_<KEY_ID>.p8 --key-id <KEY_ID> --issuer <ISSUER_ID>
  • or an Apple ID app-specific password (appleid.apple.com): xcrun notarytool store-credentials collie --apple-id <apple id> --team-id RM3UT3MMSR --password <app-specific password>

The latest Program License Agreement must be accepted, or submissions are refused. Then just mac-install and just mac-notarize (or just mac-notarize <profile>), which submits, waits, staples the app in target/mac and in ~/Applications, and checks it with spctl. On a failure, xcrun notarytool log <submission id> --keychain-profile collie says why.

collied on Linux

There is no package. Install from a checkout, with the prerequisites in the README:

just setup                # submodules
just collied-install      # release build to ~/.cargo/bin/collied as a fresh inode
collied setup             # login, service install, doctor, APNs, pairing

collied setup runs collied login (advertises tag:collie-linux, refuses until the node has it), collied service install (systemd user unit, ~/.config/systemd/user/collied.service), collied doctor, the APNs import and collied pair, each skipped when already done.

Update: git pull, just setup, just collied-install, which restarts the user unit if it is active, then collied setup, which keeps the node and the paired phones.

Repository secrets

All repository-level, base64 values encoded with base64 -i <file> | gh secret set <NAME>:

SecretContent
APPLE_DIST_CERT_P12Apple Distribution certificate and key, .p12, base64
APPLE_DIST_CERT_PASSWORDpassword of that .p12
APPLE_PROVISIONING_PROFILECollie App Store profile, .mobileprovision, base64
APPLE_PROVISIONING_PROFILE_PUSHCollie Push App Store profile, .mobileprovision, base64
APPLE_PROVISIONING_PROFILE_WIDGETSCollie Widgets App Store profile, .mobileprovision, base64
APPLE_PROVISIONING_PROFILE_WATCHCollie Watch App Store profile, .mobileprovision, base64
APPLE_PROVISIONING_PROFILE_WATCH_WIDGETSCollie Watch Widgets App Store profile, .mobileprovision, base64
APPLE_KEY_P8App Store Connect API key (Developer role), .p8, base64
APPLE_KEY_IDthat key's ID
APPLE_ISSUER_IDApp Store Connect issuer ID

The certificate and the profiles expire after a year; renew them and update their secrets.

Cutting a release

  • Merging a pull request into master ships a build, unless its title contains [skip-release] or it only touches .github/, docs/ (the documentation site's sources, in docs/site/, included), Mac/ or README.md. Changes only to docs/site/ and docs/deploy-pages.md also skip the macOS ci jobs, on pull requests and on master; the site has its own workflow, pages (deploy-pages.md).
  • Version: the latest v* tag bumped by minor when any commit since that tag (or the pull request title) is a feat, by patch otherwise. Build number: the workflow run number.
  • A manual run (Actions, testflight, Run workflow, on master only) ships the given version, for example 1.0.0.
  • After the upload, the workflow pushes the tag v<version>.

App icon and TestFlight

  • The icon is a single 1024x1024 universal image, Collie/Resources/Assets.xcassets/AppIcon.appiconset/AppIcon.png; Xcode derives every other size. The app's Info.plist sets CFBundleIconName to AppIcon and project.yml sets ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon: App Store validation rejects an upload whose icon is only in the asset catalog without CFBundleIconName.

The iOS app embeds the watch app, so every iOS build, test and archive also builds for watchOS: both workflows install the watchOS platform on the xcode-27 runner when it is missing. The watch app has its own copy of the icon, Collie/CollieWatch/Assets.xcassets/AppIcon.appiconset/AppIcon.png.

Export compliance

This is not legal advice; confirm with counsel before an App Store release.

The app implements standard encryption on top of Apple's: WireGuard (Curve25519, ChaCha20-Poly1305) in libtailscale and TLS 1.3 (rustls with ring) to collied. Notification payloads use CryptoKit. There is no proprietary or non-standard cryptography, and the source is public.

  • US (EAR): once generally available on the App Store, mass market under Note 3 to Category 5 Part 2, self-classified as 5D992.c under 15 CFR 740.17(b)(1). No CCATS. Since the 2021 rule (86 FR 16482), the annual self-classification report of 740.17(e)(3) covers components and certain executable software, not an end-item app like this one, so none is filed; this section is the classification record. If one is ever filed, it is due February 1 for the previous year, by email to crypt-supp8@bis.doc.gov and enc@nsa.gov.
  • Apple: Info.plist sets ITSAppUsesNonExemptEncryption to false. Apple's "exempt" means exempt from uploading documentation: standard algorithms not provided by the OS need a document only for the App Store in France (Apple's table). The key answers the encryption question for every build, so TestFlight uploads never stop at Missing Compliance. Never set true without the matching ITSEncryptionExportComplianceCode: builds then stop at Missing Compliance, and a code Apple did not issue can fail the upload.
  • Internal TestFlight (today): builds reach only the owner in Canada, and Apple applies US export law only to distribution outside the US or Canada. Nothing else to do.

Before an App Store release:

  1. In App Store Connect, Pricing and Availability, App Availability, choose specific countries or regions and leave out France; All Countries or Regions includes it.
  2. If App Store Connect asks about encryption (App Information, App Encryption Documentation, or Manage next to a build), answer: uses encryption, yes; algorithms, "Standard encryption algorithms instead of, or in addition to, using or accessing the encryption within Apple's operating system"; available on the App Store in France, no. No document is needed.
  3. To ship in France instead: file a declaration with ANSSI (up to a month), fill in the App Description and availability, upload the declaration under App Encryption Documentation with France set to yes, wait for Apple's approval, then in one commit set ITSAppUsesNonExemptEncryption to true and ITSEncryptionExportComplianceCode to the key value Apple shows next to the approved document.

An external TestFlight group is not limited by App Store availability, so leaving out France does not keep its testers out of France. Apple's France requirement names only the App Store; until that is settled, invite no external testers located in France.

Adding a signed extension target

CollieWidgets went in this way. Collie/ExportOptions.plist maps each bundle ID to its profile name under provisioningProfiles (dev.rbstp.collie.widgets to Collie Widgets App Store), and the workflow installs APPLE_PROVISIONING_PROFILE_WIDGETS with the others.

  1. Register its App ID (with the capabilities its entitlements use) and an App Store profile for it.
  2. Add its secret, for example APPLE_PROVISIONING_PROFILE_<NAME>, to the env of the "check for the signing secrets" and "import the signing certificate and profiles" steps; every APPLE_PROVISIONING_PROFILE* variable is checked and installed.
  3. Map its bundle ID to the profile name in Collie/ExportOptions.plist and set PROVISIONING_PROFILE_SPECIFIER in its Release settings in Collie/project.yml.

Documentation site

The documentation site at https://collie.rbstp.dev is built with mdBook from docs/site/ and published to GitHub Pages by .github/workflows/pages.yml.

Sources

  • docs/site/book.toml: the mdBook configuration.
  • docs/site/src/: the pages. SUMMARY.md is the table of contents.
  • docs/site/src/reference/: each page is a single mdBook include directive for architecture.md, threat-model.md, tailnet.md, notifications.md, release.md and this file, so those documents have one source.
  • docs/site/theme/: the Dark Gold theme (collie.css), the favicon and logo (favicon.png, the app icon at 128 px) and head.hbs, which puts the logo in the top bar.

The site lives under docs/, so a change to it starts no TestFlight build (testflight.yml ignores docs/**). ci.yml ignores docs/site/** and docs/deploy-pages.md on pushes to master. On a pull request, its changes job lists the changed files, and when nothing outside those two paths changed, the rust and ios jobs are skipped. A job skipped by its condition counts as passing, so the required checks are still met. Other docs stay out of that list because tests read docs/protocol/ and docs/tailnet.md.

The workflow

pages runs on pull requests and on pushes to master that touch docs/**, README.md or the workflow itself, and on demand (Actions, pages, Run workflow, on master).

  1. build, on every run: installs mdBook 0.5.4 (checked against its SHA-256), runs docs/site/stage-site.sh into a temporary directory, then docs/site/check-site.sh on the result. A pull request stops here, so the merge gate and the deploy gate are the same checks.
  2. On master only, build uploads the staged directory with actions/upload-pages-artifact, and deploy runs actions/configure-pages and actions/deploy-pages in the github-pages environment. Only deploy gets pages: write and id-token: write; it is in the pages concurrency group with cancel-in-progress: false, so a deployment is never cut off halfway.

stage-site.sh <outdir> builds the book and adds:

  • reference/protocol/, a copy of docs/protocol/, which the included reference pages link to;
  • CNAME with collie.rbstp.dev (override with SITE_DOMAIN), in the artifact as a record of the domain. GitHub ignores CNAME files for sites deployed by a custom Actions workflow: the custom domain lives only in Settings > Pages;
  • .nojekyll.

mdBook writes 404.html itself, from src/404.md. With site-url = "/", it carries <base href="/">, so its links work at any depth. mdBook also adds a content hash to every asset name, so a deploy never mixes new pages with cached old assets.

check-site.sh <site-dir> fails when:

  • a required page, the search index, CNAME or .nojekyll is missing;
  • CNAME is not collie.rbstp.dev (or SITE_DOMAIN);
  • a page still shows an mdBook directive, which means an include failed;
  • any href or src that is not an external URL points at a missing file, or at an id that does not exist in its target page.

book.toml sets create-missing = false, so a SUMMARY.md entry without its file fails the build instead of creating an empty page.

Build locally

Download mdBook 0.5.4 for your platform from its releases, then:

MDBOOK=/path/to/mdbook docs/site/stage-site.sh /tmp/collie-site
docs/site/check-site.sh /tmp/collie-site
python3 -m http.server -d /tmp/collie-site 8080    # http://127.0.0.1:8080/

Theme

The palettes are built around the app's Dark Gold accent (AppAccent.gold in Collie/Sources/Collie/DevicePrefs.swift): #805C14 in light mode and #C79E40 in dark mode. Gold is used for links, the active sidebar item, callout edges and titles, and focus rings. mdBook's Light and Rust themes use the light palette; Coal, Navy and Ayu use the dark one. The theme menu offers Auto, which follows the system, Light and Coal.

Every text color passes WCAG AA (4.5:1) on the backgrounds it is drawn on:

TextLightDark
Body text#24211B on #FBF9F4: 15.25#E8E3D8 on #16140F: 14.38
Body text in tables (header)#24211B on #EAE3D3: 12.56#E8E3D8 on #2E2A21: 11.17
Body text in tables (alternate row)#24211B on #F5F1E8: 14.24#E8E3D8 on #1C1914: 13.69
Body text in callouts#24211B on #F5F0E6: 14.13#E8E3D8 on #1F1C16: 13.28
Code text#24211B on #F2EDE2: 13.75#E8E3D8 on #211E18: 12.99
Search highlight, links in it included#24211B on #F0DCA6: 11.84#E8E3D8 on #5C4716: 6.93
Search field#24211B on #FFFFFF: 16.05#E8E3D8 on #211E18: 12.99
Theme menu#24211B on #FFFDF8: 15.79#E8E3D8 on #1D1A15: 13.55
Links#805C14 on #FBF9F4: 5.77#C79E40 on #16140F: 7.36
Links in table headers#805C14 on #EAE3D3: 4.75#C79E40 on #2E2A21: 5.71
Links in callouts, callout titles#805C14 on #F5F0E6: 5.35#C79E40 on #1F1C16: 6.79
Links in code#805C14 on #F2EDE2: 5.20#C79E40 on #211E18: 6.64
Active sidebar item#805C14 on #F3EEE3: 5.25#C79E40 on #1D1A15: 6.93
Sidebar text#3B362C on #F3EEE3: 10.37#D2CABA on #1D1A15: 10.65
Muted text, icons#5E5647 on #FBF9F4: 6.89#B3AB9A on #16140F: 8.07
Caution title#B3261E on #F5F0E6: 5.75#F2867A on #1F1C16: 6.85
Code comments#6B6252 on #F2EDE2: 5.15#A39A88 on #211E18: 5.96
Code strings#3F6B21 on #F2EDE2: 5.39#A9C77E on #211E18: 8.83
Code keywords#8A3B12 on #F2EDE2: 6.63#E39A6B on #211E18: 7.20
Code numbers#7A4A9E on #F2EDE2: 5.45#C9A6E8 on #211E18: 8.00
Code keys#805C14 on #F2EDE2: 5.20#C79E40 on #211E18: 6.64
Code titles#1F5C7A on #F2EDE2: 6.27#86C1D9 on #211E18: 8.42

Recompute these when a color in collie.css changes.

1. GitHub settings

These are repository settings, not files, so a pull request cannot set them. Do them in this order:

  1. Settings > Pages > Build and deployment > Source: GitHub Actions. Until this is set, Pages keeps building the repository root with Jekyll from the branch, and the workflow cannot deploy.
  2. Add the Cloudflare record (below).
  3. Settings > Pages > Custom domain: collie.rbstp.dev, then Save. GitHub checks the DNS record on save, so it must exist first.
  4. Wait for the certificate, then tick Enforce HTTPS. The checkbox stays unavailable until the certificate is issued, which usually takes minutes and can take up to an hour.

The github-pages environment is created on the first deployment. If it ever gets protection rules, keep master as an allowed deployment branch.

2. Cloudflare DNS

In the Cloudflare dashboard, zone rbstp.dev, DNS > Records > Add record:

FieldValue
TypeCNAME
Namecollie
Targetrbstp.github.io
Proxy statusDNS only (gray cloud)
TTLAuto

Use a CNAME, not A records: collie is a subdomain. Nothing else in the zone changes.

Pitfalls

  • Proxying before the certificate exists. GitHub gets its Let's Encrypt certificate with an HTTP challenge on http://collie.rbstp.dev/.well-known/acme-challenge/. A proxied (orange) record can hide that request from GitHub, and GitHub's domain check sees Cloudflare's addresses instead of its own. Keep the record gray. If you proxy it later, after Enforce HTTPS is on, set SSL/TLS to Full (strict); Flexible loops with GitHub's HTTPS redirect (ERR_TOO_MANY_REDIRECTS).
  • Always Use HTTPS. This zone setting answers the plain HTTP challenge with a redirect, but only for proxied records. A gray record is not affected.
  • CAA records. If the zone has CAA records, one must allow letsencrypt.org, or GitHub can never get a certificate.
  • HSTS with includeSubDomains. Browsers then refuse plain HTTP on collie.rbstp.dev, so expect a TLS error rather than a page until the certificate is issued.
  • A wildcard record. If *.rbstp.dev exists, collie.rbstp.dev resolves before you add anything, so a working dig proves nothing. The explicit record wins once added.

3. Verify

dig +short CNAME collie.rbstp.dev          # rbstp.github.io.
dig +short A collie.rbstp.dev              # 185.199.108.153 to 185.199.111.153 (gray cloud)
dig +short CAA rbstp.dev                   # empty, or must allow letsencrypt.org

curl -sSI https://collie.rbstp.dev/ | grep -i '^server:'               # server: GitHub.com
curl -sSI http://collie.rbstp.dev/ | sed -n '1p;/^[Ll]ocation:/p'      # 301 to https://collie.rbstp.dev/

echo | openssl s_client -connect collie.rbstp.dev:443 -servername collie.rbstp.dev 2>/dev/null \
  | openssl x509 -noout -issuer -dates -ext subjectAltName                # Let's Encrypt, collie.rbstp.dev

for p in / /privacy.html /support.html /troubleshooting.html /reference/threat-model.html; do
  printf '%-32s %s\n' "$p" "$(curl -sS -o /dev/null -w '%{http_code}' "https://collie.rbstp.dev$p")"
done                                                                     # all 200

curl -sS -o /dev/null -w '%{http_code}\n' https://collie.rbstp.dev/no/such/page    # 404
curl -sS https://collie.rbstp.dev/no/such/page | grep -o 'Page not found'

If something is wrong

SymptomCause
The site still shows the READMESource is still "Deploy from a branch". Set it to GitHub Actions and run the workflow.
The deploy job failsSource is not GitHub Actions, or github-pages protection rules exclude master.
No deploy after a mergeThe push touched nothing under docs/, README.md or the workflow. Run it by hand: Actions, pages, Run workflow.
The custom domain is rejectedThe DNS record has not propagated: check dig first.
Enforce HTTPS stays unavailable for over an hourNo certificate. Check that the record is gray and the CAA records.
ERR_TOO_MANY_REDIRECTSProxied with SSL/TLS set to Flexible. Use Full (strict), or go back to gray.
The custom domain is blankSet it again in Settings > Pages. With an Actions deploy, that setting is the only source; the artifact's CNAME does not restore it.