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 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