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 nodetag:collie-linux. Authorization = pairedStableID+ 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;.xcodeprojis 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-sysbut not built or tested. Any other OS is a compile error. - 2026-10-04: the node tag is per OS:
tag:collie-macon macOS,tag:collie-linuxon Linux.collied loginandcollied runrefuse 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 stopkeeps it off across reboots untilcollied 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.tomlonly, 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_changedis per pane andpane.updateddoes not fire on status changes. collied keeps one subscription connection: all lifecycle events plus one status entry per agent pane, rebuilt (subscribe, thensession.snapshot) onpane.created/closed/moved/exited/agent_detected,workspace.closedandevents_lost. An entry for a vanished pane goes silent rather than erroring, so rebuilds are driven by lifecycle events. A 1 sagent.listreconcile pluspingcovers silent streams: it sendsagent.statuswhen an agent'sstate_change_seq, status, title, name, kind or cwd changes, or, while a phone has a session open, its transcript values (below), andflock.changedwhen agents come, go or change workspace, or a workspace is renamed or renumbered. -
Agents are keyed by
terminal_id;pane_idchanges on cross-workspace moves and is never sent to the phone. Status set isidle | 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 pollsagent.read {source: recent_unwrapped, lines, format: ansi}at 4 Hz, with thelinesthe phone sent inagent.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 throughsanitize_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 sendslow_data: trueonagent.watchandterminal.watch, and collied polls that watch at 1 Hz throughout.low_datacan 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 areagent.output_patch {terminal_id, base, head, skip, keep, tail, trail, truncated}when that carries at most half the text: the new text is theheadlines, linesskip..skip + keepof the text pushed before (the longest run of lines both share), thetaillines, then the lasttraillines of the text pushed before, joined with\n. The trailing lines must start at or afterskip + 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).baseis 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 whenbasematches, otherwise it re-issuesagent.watch, which starts again with a fullagent.output. - The phone sends the depth chosen in Settings > Terminal > History (200, 500 or 1000 lines, 200 by default, kept per device) on
agent.watchand on its reads ofrecent; 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'swrapsandsplits(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 anHH:MMtimestamp; Copilot wraps the text at the columns before that timestamp like wrap-ansi withtrim: 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_unwrappedcannot undo. For a Claude Code agent,agent.readand each changed watch push read the pane width with herdrpane.layoutand list the rows that continue a paragraph wrapped at that width:wraps(after a space) andsplits(inside a word), onTerminalRead, and onagent.output_patchonly 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 standalone48;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'sTerminalSnapshot.reflowed) and wraps it at its own width; with it off, it shows the Mac layout unchanged.
- The first push of each watch is a full
-
agent.promptrefuses blocked agents, but herdr'sagent.send_keysdoes not. collied rejectsagent.promptfor an agent it sees asblocked(re-read withagent.getjust before the call).agent.send_keysandagent.type_textreach 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, andagent.type_textalso a plan's feedback field (Prompts without decisions), so a permission prompt can only be answered throughapproval.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_keysandagent.prompt(which herdr ends with Enter) to a Claude Code agent withagent_not_readywhen 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, noop_idsince it is idempotent) sends Claude Code's Ctrl+End (scroll:bottom) as the fixed bytesESC[1;5Fthroughpane.send_text, since herdr 0.9.3 has no key name for it, only to aclaudeagent 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 isblockedagain with a newstate_change_seq, so its approval is raised again. The phone shows a Jump to bottom button whileTerminalSnapshot.jump_banneris 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 asblocked, 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 more1: Understood 2: Chat in main session 0: Dismiss, the feedback rows after a Dismiss, the session rating question1: 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::noticereads 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 Β· /effortornew 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) then0: Dismisslast (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,digitone of"0"to"4",labelthe option's label as the phone showed it, audited with the terminal, the digit and the label, noop_id: a replay is refused once the notice is gone) sends that digit alone throughpane.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 aclaudeagent that is ready (neverblocked, 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 fromTerminalSnapshot.noticefor 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 lists4: Disable: the first 4 relabels itPress again to disable, and a second 4 fills the box with the same/plugin disablecommand, 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 withy,nord, 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}onagent.prompt/agent.send_keys); to be proposed upstream. -
Mutations (
agent.prompt,agent.send_keys,agent.type_text,task.new(with or withoutnew_folder),task.archive,terminal.run,terminal.send_keys,attachment.begin,attachment.commit) carry a clientop_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.optionsalso returnsroots, the canonical[tasks] roots.task.folders {path}(Drive class liketask.new, audited with the requested path andok folders=<n>, plustruncated, never the names; noop_idsince 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 withO_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, carriesUF_HIDDEN, so~/.sshor~/Libraryis never listed. Of its entries it keeps names that are validFolderNames (1 to 255 bytes, no/or\, no control, format or other invisible character, no leading dot or surrounding whitespace), then, byfstatatwithout following, directories and symlinks that resolve to a directory under the roots, and on macOS skips entries flaggedUF_HIDDEN(~/Library). It scans at most 10,000 entries, sorts case-insensitively and returns at most 500, withtruncatedwhen any were left out, and the canonical path; nothing inside the folders is read.task.newtakes an optionalnew_folder(aFolderName):cwdis 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 withmkdirat(parent_fd, name, 0755)(the umask applies; an existing entry, symlink included, isfolder 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; nogit 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 theop_idcovers 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 (taskBasesinprefs.json), saved in the canonical formtask.foldersreturned, 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 anycwd. -
Clone.
task.newtakes an optionalclone(aCloneSource), exclusive withnew_folderandworktree:owner/repo(a GitHub owner and repository name),https://host[:port]/pathorgit@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.gitand must be aFolderName. collied creates it insidecwdexactly as fornew_folder(so an existing entry isfolder already existsbefore anything runs), then clones into it:owner/repowithgh repo clone -- owner/repo <folder>whenghis in/opt/homebrew/bin,/usr/local/binor/usr/bin, elsegit 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 withgit clone -- <url> <folder>. The child gets null stdin, noGIT_*variables from collied's environment,GIT_TERMINAL_PROMPT=0,GH_PROMPT_DISABLED=1, noGH_TOKENorGITHUB_TOKEN, and throughGIT_CONFIG_COUNTprotocol.allow=neverwith onlyhttpsandsshallowed (explicitly denyingfile,ext,gitandhttp), which outranks config files and still applies after aurl.<base>.insteadOfrewrite. 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'sworktreecan 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 runsgh pr view N --json headRefName,headRefOid,isCrossRepositoryin its checkout, withghfrom/opt/homebrew/bin,/usr/local/binor/usr/binin 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) andgit 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 runsgit fetch --no-tags --no-recurse-submodules --no-write-fetch-head origin -- +refs/heads/<branch>:refs/remotes/origin/<branch>in the clone's environment (noGIT_*from collied, no prompts, HTTPS and SSH only, its own process group), checks thatorigin/<branch>is the head commit gh named (so anoriginthat is not the pull request's repository, or a push in between, fails withtry again), and creates the local branch withgit 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'sworktree.createthen 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, noop_idsince it only reads, audited with the path andok 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 amongclaudeandcodex, leaving out sessions a herdr agent already reports as itsagent_session. Claude Code sessions are the<uuid>.jsonlfiles 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 issession_metawith that exactcwd, the id of the file name and acliorvscodesource (notcodex execor a subagent). Of each kind only the 400 newest files are opened, with the sameO_NOFOLLOW | O_NONBLOCKopen as summaries, and only their first 256 KiB (64 KiB for a rollout'ssession_metaline) 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-chainuserrecord withorigin.kindhuman, a slash command giving its marker and its output skipped as forlast_prompt, Codex's firstUserMessageoruser_messageevent), cleaned and capped at 160 characters likelast_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.newtakes an optionalresume(aSessionId), exclusive withnew_folder,worktreeandclone, and thenpromptis optional. collied refuses a kind other thanclaudeorcodex, a session whose transcript is not in that folder (Claude's file at the project path, or a Codex rollout found by id whosesession_metanames thatcwd), and a session that a herdr agent already reports as itsagent_session(the session is already open in a herdr pane). It then creates the workspace as usual and starts the agent withargs: ["--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.newtakesultracode(default false), refused for any agent butclaude(ultracode needs Claude Code). It is the Ultracode switch of Claude Code's/effortpicker (Tab), which Claude Code 2.1.299 also takes at launch as the session-scoped settingultracode, so collied adds["--settings", "{\"ultracode\":true}"]to the agent'sargs(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 theultracodetag (βββ ultracode β). If it never does, the start fails withagent_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 (taskUltracodeinprefs.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/effortpicker. -
Agent kinds.
[tasks] agentsincollied.tomldefaults to["claude", "codex", "copilot"].task.optionsoffers those herdr has a manifest for (server.agent_manifests), the first as the default, andtask.newrefuses any other. Collie passesargs: ["--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/newand resume reports bound to that pane, and leaves already running sessions unchanged. herdr 0.9.5 starts all three withagent.start(kind namesclaude,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 andcollied doctorwarns until it is reinstalled. Codex falls back tounknownwhen no rule matches, so a Codex agent that isunknownandinteractive_readycounts as started. A startup prompt (Codex's or Copilot's folder trust, Codex's update picker) fails the start withagent_blocked, to be answered in the terminal. herdr does not rule every oneblocked, 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 anagent.startwhose launch line is longer than the terminal's canonical input limit while the shell starts (agent_start_input_failed); collied answerstoo_largewith 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 itsgodirective to 1.27.2. Requires Go β₯ 1.27.2; builds useGOTOOLCHAIN=local,-mod=vendor(vendored withgo mod vendor, below),-trimpath,-buildvcs=false,-ldflags=-w. tailscale_getremoteaddrwas 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_loopbackwould open a 127.0.0.1 TCP listener (SOCKS5 + LocalAPI) on the machine and is never used either. The patch series applied bytailscale-sys:tailscale_accept_with_addrreturns the peerip:portfrom 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 whoseMSG_WAITALLread was cut short, and the short-read path keeps the control message (upstream drops it and miscounts onEINTR). 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.rschecks 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.tailscale_whois_json(sd, addr)via the in-memory LocalClient (same mechanism astailscale_status_json).- 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 latersetenvis invisible to Go. - Guard the per-handle
lastErrwith a mutex (also wheregetremoteaddrsets it): upstream writes it unsynchronized, a Go data race on a string when one handle is used from several threads. - 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).
tailscale_dial_timeoutandtailscale_set_advertise_tags.- Socketpair proxy: non-blocking Go end so close always delivers EOF (a lost shutdown wakeup left readers hanging), shutdowns through
Controlso 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. UserLogfdiscarded: tsnet otherwise prints the interactive login URL to stderr every few seconds.- Listener socket close waits for in-flight
sendmsg(and patch 5 sends each fd inside itsos.FileControl), so no fd number can be reused under a send. tailscale_rebind(sd, default_if): recordsdefault_if(the interface NWPathMonitor reports) withnetmon.UpdateLastKnownDefaultRouteInterfaceon 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 LocalAPIrebindandrestundebug actions (magicsockRebind, 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, thenReSTUN). 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.logtail.Disable()next toTS_NO_LOGS_NO_SUPPORT: with uploads off, every log line was still written to thetailscaled.log*.txtbuffer, read back and compressed.crates/tailnet/tests/end_to_end.rsasserts 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 aftergo 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.rsandcrates/e2eassert the process has no kernel TCP listener;collied doctorchecks 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 sotsnet.Server.Closecannot hang. - tailscale.com
wgengine/magicsock/magicsock.go:TS_DEBUG_MAGICSOCK_BIND_LOOPBACK=1binds the UDP sockets to 127.0.0.1 and ::1 instead of every interface. Only the tailnet tests set it, next toTS_DISABLE_PORTMAPPER, so the macOS application firewall never prompts for a newly built test binary; collied and the app leave it unset.
- tailscale.com
- 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/testcontrolrequires the same tailscale.com as libtailscale'sgo.mod(crates/tailscale-sys/tests/versions.rs). To run a newer tailscale.com than upstream pins, rungo get tailscale.com@vX.Y.Zandgo mod tidyin a copy of the submodule with the series applied, save thego.mod/go.sumdiff as a patch, do the same intestcontroland point that test at the patched version. - What control sees: Hostinfo
Applibtailscale,Packagetsnet,IPNVersion1.104.1-ERR-BuildInfo(the tailscale.com version;-ERR-BuildInfobecause of-buildvcs=false) andGoVersion. 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=...andshortStampis 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_idincollied.tomlwhen set; otherwise the first phone confirmed on the machine (collied pair, y/N showing node, StableID and login) fixes it inpeers.json, and every later peer must match. Then:- paired
StableIDwith a pinned TLS key β full session (hellofirst, then the allowlist). While a pairing window is open,pair.completefrom 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'sunpairrevokes it ascollied peers revokedoes: 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:
helloandpair.completeonly, one attempt, then close; - otherwise answer a fixed
403without 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.
- paired
- Phone: takes the Mac's IP from its own netmap (
tailscale_status_json), from the entry with the Mac's DNS name, theStableIDpinned at pairing and the pinned tag, checks it with whois, thentailscale_dials that IP; no DNS lookup (tailnet.md). It never usestailscale_loopback(iOS reclaims that listener on suspend). tailscale_upblocks until Running and is not cancellable; collied drives its own state machine fromstatus_json(BackendState,AuthURL) and always callstailscale_startfirst.- Accepted fds are AF_UNIX socketpairs: await readability with
AsyncFd, thentailscale_accept, thenset_nonblocking, thentokio::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=, invitev=2), and the phone keeps it as the machine'skey. 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.jsontls_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
403before 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) asKeyRefused. Both stop retrying and ask to pair again. - After the handshake collied runs the gate again under the peers, pairing and sessions locks (
server.rsconnection). 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_VERSION3: an app and a collied from before mutual TLS cannot talk to the new ones. 4 addsagent.output_patch, which an older app would drop and so freeze its terminal view. 5 addslinestoagent.watch, which an older collied refuses as an unknown field. 6 addsterminal_keytopair.completeand the terminal methods. 7 addstrailtoagent.output_patch, which an older app would apply wrongly. 8 addslow_datatoagent.watchandterminal.watch, which an older collied refuses as an unknown field. 9 addsagent.scroll_bottom, which an older collied refuses as an unknown method. 10 addsagent.answer_notice, which an older collied refuses as an unknown method. 11 addsagent.slash_draft, which an older collied refuses as an unknown method. 12 addslabeltoagent.answer_notice, which an older collied refuses as an unknown field, and its digit"4"; collied and the app ship together. 13 addstask.foldersandnew_folderontask.new, which an older collied refuses as an unknown method and field, androotsontask.options. 14 addsmute_donetopush.register, which an older collied refuses as an unknown field. 15 addstask.worktreesand the worktree option ontask.new. 16 addstask.archiveand its result. 17 addsroottoagent.changes, which an older collied refuses as an unknown field, andworktreesand thecommittedsection to its result, which an older app cannot read. 18 addsscopetoagent.changes, which an older collied refuses as an unknown field, andbaseand each file'shashto its result; the branch scope comparesHEADwithorigin/HEAD, thenmain, thenmaster, which collied resolves itself. 19 addsclonetotask.new, which an older collied refuses as an unknown field. 20 addstask.sessions, which an older collied refuses as an unknown method, andresumeontask.newwithpromptoptional when it is set, which an older collied refuses as an unknown field or a missing prompt. 21 addsagent.watch_pull_request, which an older collied refuses as an unknown method, andpr_watchonAgent. 22 addsultracodetotask.new, which an older collied refuses as an unknown field. 23 addstask.archive_on_merge, which an older collied refuses as an unknown method. 24 adds thepull_requestmode totask.new'sworktree, which an older collied refuses as an unknown variant. 25 addsbackspaceand the Control lettersctrl+a,b,e,k,l,uandwtoKey, 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
claudeand a rule inprompt::CLAUDE_MENU_RULES(herdr claude manifest 2026.10.11.1). herdr 0.9.5'slive_blocked_form_named_sessionis a question in a session named with/renameor--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_namedrops the name rule and what follows it before anything else reads the screen, and reads the rest aslive_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 incrates/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 rulelive_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 everyapproval.decideis 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
PermissionRequesthook hides the terminal prompt until the hook exits, gets a staleHERDR_PANE_IDfrom 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 withapprovals_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'spermissionRequesthook 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@experimentalpermission RPCs and gets the session's whole RPC surface. Neither is used. Revisit ifapprovals_revieweris 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:
/effortand, 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 kindclaudeand rulelive_blocked_form(orlive_blocked_form_named_sessiononce 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./advisoralso still raises one: its "No advisor" option reads as a decision, so keys are refused and its approval'schoicesare the only way to answer it from the phone./modelneeds nothing: herdr leaves its status unchanged (rulemodel_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 alive_blocked_formdialog 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 againand 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 evendeny: questions (AskUserQuestion), plan prompts and other free-form menus. - Choices.
choicesis the menu as read, whenever one parsed (permission prompts included):{index (0-based), label, current},currenton theβ―option. Labels have controls, bidi and format characters stripped and are capped at 120 chars (limits::MAX_CHOICE_LABEL_CHARS). An approval with nooptionsis answered by choosing one:approval.decide {decision: "choose", choice: <index>}. A permission prompt never takeschoose: picking its options by index would bypass the decision they map to (Deny sent as Esc). - Note.
supports_noteis set when the menu offersapproveordenyand "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'sPermissionRequesthook, reads the hook input (hook_event_name,session_id,tool_name,tool_input) and sends{session_id, tool_name, shown}over the control socket,shownbeing 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: eachshownpart 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 forAskUserQuestion). collied keeps the last report per session (64 sessions at most, each for the approval TTL). With a matching report the approval'stoolis{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_seqonly moves on status changes, so a new question under a still-blockedagent 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, orabout N minutes/about N secondsin place ofM:SS, since 2.1.295, in a session it takes for unattended) changes every second, soprompt::without_deny_countdowntakes 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 kindclaude, 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 leavesblockedand the approval ends.
approval.decide
- Rate limit per paired
StableID: 1/s, burst 5 (rate_limited). - Claim. Any attempt on a pending approval burns it, whatever the result; a burned id answers
approval_already_resolved, an unknown oneapproval_not_found. - Expiry, nonce (constant time), decision offered.
chooseis offered only whenoptionsis empty, for an index inside the menu; anything else isinvalid_params.choiceis set exactly whendecisionischoose(checked by the decoder). Anote(a one-linePromptTextof 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 withapproveordeny, and only on an approval withsupports_note; otherwiseinvalid_params. - Re-read the screen: still
blocked, samestate_change_seq, same fingerprint, elsesuperseded. Re-check that the peer is still authorized. - Keys, from a fixed set (
up,down,enter,esc), never pane text:- Deny is a single
escwhen 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, thenenterin 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. choosealways takes this path (arrows, re-read,enter), whatever the option's label: it never sendsesc.- With a
note, Approve and Deny also take it (Deny by arrows, neveresc), and beforeenter: 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 nextorNo, 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'spane.send_text; polls until the option readsYes, <note>orNo, <note>(whitespace ignored, wrapped lines included) with the rest of the dialog unchanged; peer re-checked, thenenter. Each poll also requires the agent stillblockedwith the samestate_change_seq. On any mismatch nothing more is sent, not evenesc(in the amend field it cancels the whole prompt):superseded, and the next reconcile issues a new approval for what the screen then shows.
- Deny is a single
- Settle: poll
agent.listevery 250 ms for up to 3 s (approvals::SETTLE) for the agent to leaveblockedor move itsstate_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.
| Outcome | When |
|---|---|
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 |
superseded | fingerprint, 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 |
expired | past 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_keysforwards 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 orderedpane.send_inputwrite. 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_inputandhas_text_fieldremain 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.promptremains the normal prompt API and still refuses blocked agents. The session composer routes blocked answers throughagent.type_textinstead. Attachments stay available for the next normal prompt. Structuredapproval.decideactions, 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_idthrough the outcome cache. Audit: keys to a blocked agent are logged with target<terminal_id> blocked keys=<keys>;agent.type_textwith the terminal and result only, never the text. - collie-core:
type_text(machine_id, terminal_id, text);ApprovalDecision::Choose { choice }ondecide, reported back asApplied/Unconfirmedwith 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, thechoosedecision and the two outcomes are additive. An older app ignoreschoicesand decodes the new outcomes asother; an older collied sends nochoices(it defaults to empty) and noaccepts_input,has_text_fieldorsupports_note(all default to false), answerschooseand anotewithinvalid_paramsandagent.type_textwithunknown_method.
APNs
[apns]incollied.toml:key_id,team_id(10 uppercase alphanumerics),bundle_id, and the key's location.keyandkey_pathat once is an error; a store for the other OS is a config error.collied apns testsends a test alert to every registered device of a paired phone.
| Key location | macOS | Linux |
|---|---|---|
key = "keychain" | the default when omitted | config error |
key = "systemd-creds" | config error | the default when omitted (APNs key on Linux) |
key_path (a 0600 file owned by the user, opened without following symlinks) | legacy; collied doctor warns | the 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 (teamRM3UT3MMSR) and identifierdev.rbstp.colliedreads 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), identifierdev.rbstp.collied, hardened runtime, timestamp), installs it to~/.cargo/bin/colliedas a fresh inode and restarts the launchd agent if it is loaded. A plaincargo installbuild 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 thekey_pathchecks, checks it is an ES256 key, stores it (an existing item is kept), reads it back and compares, rewriteskey_pathtokey = "keychain"incollied.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,codesignline), 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.p8left in<data dir>/apns.- Tokens arrive by
push.registerand are stored perStableIDinpush.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);UnregisteredandBadDeviceTokendrop 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 typebackground, priority 5,apns-expiration0 (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-notificationbackground mode), removes the delivered alerts with thatnode_idandapproval_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
workingand ends when it next showsdoneoridle;blockedinside it keeps the start (its time counts), whileblockedtoidle(a denial, Esc) andunknownend 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 typealert, priority 10,apns-expiration0 (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 ofblocked, which supersedes that approval, and the one send queue keeps the order. Nomutable-content, so ColliePush never runs, and noenc. A device that registered withmute_done(the app's "Notify when an agent finishes" setting off, stored inpush.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.
| Field | Value |
|---|---|
aps.alert.title | herdr agent name, else agent kind, else agent; never the terminal title or a title the pane's program reports |
aps.alert.body | Blocked in <workspace label> |
aps.thread-id | terminal_id |
aps.mutable-content | 1 (ColliePush) |
aps.category | APPROVAL, only when both approve and deny are offered |
approval_id, node_id | lookup keys for the app |
enc | the 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 field | Value |
|---|---|
aps.alert.title | as the approval alert's |
aps.alert.body | Done in <workspace label> |
aps.thread-id | terminal_id |
node_id, terminal_id | a 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, servicedev.rbstp.collie.notify, account = Mac node ID,AfterFirstUnlockThisDeviceOnly, access group$(AppIdentifierPrefix)dev.rbstp.collie.sharedshared by the app and ColliePush) and passes it toregister_push(machine_id, apns_token_hex, environment, notification_key, mute_done). collie-core holds each machine's registration in memory only and sends it inpush.register(notification_key: base64url without padding, 43 chars, canonical; redacted inDebug) 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,UnregisteredorBadDeviceToken. Apush.jsonwritten before this change loads with no key: those devices get the plaintext alert until the app registers again. enc= base64 (standard, padded) ofnonce(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 withChaChaPoly.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 givesBash: <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 startingTip:, 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
APPROVALhas two actions, Approve and Deny (no "approve always"), bothauthenticationRequired: iOS makes the user unlock the device first, then launches the app in the background without UI. AppModel.decideFromNotificationholds a background task assertion and callsdecide_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 orapproval.list, thenapproval.decide. It never retries past the budget.- Immediate
Unauthorized(no waiting) when Tailscale is not set up on the phone, the node reportsNeedsLoginorNeedsMachineAuth, the netmap violates the pin, orhelloreports 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 outcome | Follow-up | Opens the approval |
|---|---|---|
applied | "Approved/Denied: agent. The agent moved on." | no |
unconfirmed | "..., not confirmed": keys sent, agent still looks blocked | yes |
expired | expired, nothing was sent | no |
superseded | the prompt changed, nothing was sent | yes |
already_resolved, not_found | no longer pending, nothing was sent | no |
unreachable at decide | could not confirm the decision reached collied | yes |
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 freshLAContexteach 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
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;sizeis 1 byte to 20 MiB;sha256is lowercase hex of the whole file.attachment.chunk {upload_id, offset, data}in order:offsetmust equal the bytes received so far,datais 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.attachment.commit {op_id, upload_id}answers{path}once the received size equalssizeand the SHA-256 matches; otherwise the partial file is deleted and the error isinvalid_params(size) orchecksum_mismatch.attachment.abort {upload_id}is best effort and always answersok.
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
| Limit | Value |
|---|---|
| File size | 20 MiB (limits::MAX_ATTACHMENT_BYTES), checked by the phone, the protocol decoder and collied |
| Chunk | 32 KiB decoded, in order, never past the declared size |
| Uploads in flight | 2 per session (rate_limited beyond) |
| Idle upload | dropped, with its directory, after 60 s without a chunk |
| Stored bytes | 200 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 entries | 1000 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 rate | own 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/attachmentson 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.partopenedcreate_newwithO_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,attachmentwhen nothing is left (.envbecomesattachment.env). - One audit line per committed upload:
attachment.commit, target = sanitized name, resultok size=<bytes> sha256=<first 12 hex>.attachment.beginis audited with the sanitized name; chunks and aborts are not. Content and base64 are never logged. collied doctorprints anattachmentsline: 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
.partfrom 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,nullwhen there is nothing the phone may pre-fill (another agent kind, no box to read, or an opaque box, below). It readspane.read source=visible format=ansithroughsanitize_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.prompttakes an optionalexpected_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, elsedraft_not_clearedand nothing is sent; - any other draft, or no
expected_draft:draft_changed, nothing is sent.ErrorBody.draftcarries the current text so the phone can show it and send again to replace it. The audit line isdraft_changedalone: 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:
downN-1 times (a no-op on the last row), then N timesctrl+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'sKeyallowlist holds.ctrl+cis never used (it interrupts a working agent), norctrl+d(it exits Claude Code on an empty input); none of the keys used interrupts a working agent. herdr has nodeletekey 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_promptthat is not null and differs from the one at load (or at the last check) makes it readagent.draftonce: an empty box means the prompt was sent on the machine, so the field empties and its attachments stay.last_promptcomes from human prompts only, so text deleted on the machine, an approval or a turn a background task starts leaves the field alone, as doeslast_promptgoing null while the transcript cannot be read. - No version bump: the method, response, field and error codes are additive. An older collied answers
agent.draftwithunknown_method, which collie-core returns as no draft, so the phone never sends itexpected_draft. An older app sends noexpected_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, noop_id: it is idempotent, audited with the terminal only, never the command or a draft) takes aSlashCommand,""or/followed by up to 63 ofA-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 (neverblocked, launch pending, or another agent or session) for agent kindclaudeonly and no "Jump to bottom" banner on a visible read;replace_draftwithexpected_draftexactly as foragent.prompt(an empty box passes, the box the phone named is cleared, an opaque or unreadable box givesdraft_not_cleared, other textdraft_changed); for an emptycommand, nothing more; otherwise the ready check and the banner again, a visible read that must show an emptyβ―box (elseagent_not_ready, nothing written), authorization, then onepane.send_textofESC[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 givesdraft_changedwith it, an empty or unreadable oneagent_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. Adraft_changedshowing 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 (
keysShowninprefs.json). Shift and Control are selected on the phone and never sent alone: the next key goes out as oneKey(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 oneKey, or text throughagent.promptwithexpected_draft: ""(collied refuses it asdraft_changedwhile a Claude Code input box holds unsent text; Codex and Copilot CLI take it after any text there), or, for a Terminal shortcut only, throughterminal.runin 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.draft150 ms later and takes the box's command into its draft, keeping its own arguments (/mod opusand β₯ 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: adraft_changedwhose 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_keyswithenterorctrl+enterto 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_keysandagent.slash_drafthold 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, thenagent.promptclears the token (it equalsexpected_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_draftfor 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 (
/resumeof 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, neverargvorcmdline; a failed call keeps the pids last listed) and readssessions/<pid>.jsonfor each, with the transcript's open, capped at 64 KiB. A file counts only when itspidmatches its name, the pid answerskill(pid, 0), the process started no later than the file'sstartedAt(2 s of slack, for Linux's whole-second start time), and itscwdis the pane's. When every such file agrees on onesessionId, 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 latestcontinued-inrecord (continuedInSessionId) whose target's transcript is in the same project directory, or a/resume(systemlocal_commandwithcommandRun.commandresume) whose output endsrunning in the background (<8 hex>), taken only when exactly one live process has asessions/<pid>.jsonwithkindbg, thatjobId, asessionIdstarting 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_promptandsubagentsall 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>.jsonlunderCLAUDE_CONFIG_DIRor~/.claude, elseprojects/*/<id>.jsonlfor 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 acompact_boundaryentry with no reply since, itscompactMetadata.postTokensinstead. The window is not in the transcript: collied takes the session'scontext_window.context_window_sizefrom 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 lastuserentry withorigin.kind: "human"and the lastlast-promptentry. A skill or custom slash command entry (its origin is alsohuman) gives its marker, such as "/review pr 12", as Claude Code's ownlastPromptdoes, and a<local-command-stdout>entry is skipped. -
Codex:
sessions/YYYY/MM/DD/rollout-<local time>-<id>.jsonlunderCODEX_HOMEor~/.codex. The id is a UUIDv7, so the UTC day it was created and the day on each side are searched. The latesttoken_countevent giveslast_token_usage.total_tokensandmodel_context_window, and the percentage is Codex's own:(W - 12000 - max(used - 12000, 0)) / (W - 12000).item_completedevents give the lastAgentMessageandUserMessage. -
Running subagents (
subagents, Claude Code only, additive, no version bump): one entry per subagent running in the session, withagent_type,title,activity,started_at_ms,tokensandcalled_tools. They are the session'ssubagents/agent-<id>.jsonland workflow agents'subagents/workflows/<workflow>/agent-<id>.jsonl, each with its.meta.json:agent_typeis itsagentType(workflow-subagentfor workflow agents, whose metadescriptionis the workflow label such asreview:correctness).titleis the metadescription(the Agent call'sdescription, the title Claude Code lists), elseagent_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, soactivityis 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'sdescription), and the meta description before the first one.tokensfollows 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_toolspicks the panel's arrow (β once a tool was called, β before). A subagent has finished when its latest assistant record ends its turn (stop_reasonend_turn) or callsSubagentHandbackorStructuredOutput(a workflow agent's result), or is an API error (<synthetic>withisApiErrorMessage), or a[Request interrupted by userrecord follows; a later prompt or follow-up (a user record that is notisMeta, or has acoordinator,peerorhumanorigin) starts a new run, whose time isstarted_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 keepssessions/<pid>.json(pid,sessionId,startedAt) besideprojectswhile it runs, read with the same open and capped at 64 KiB, cached by size and mtime, itspidmatching 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 sendsagent.statuson 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 anagent.statusevent and on eachflock.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 sendsagent.statuswhen 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), andghis in/opt/homebrew/bin,/usr/local/binor/usr/bin; withoutghevery agent loses its pull request. For each agent it takes the checkout asagent.changesdoes (foreground_cwd, elsecwd, throughresolve_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 withgh 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 foundMERGEDis kept instead of read again while the checkout'sHEAD, read locally withgit rev-parse --verify --quiet HEAD, is still itsheadRefOid; a kept result is read withghagain 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 whileghran is dropped. Any change to what the phone is shown sendsflock.changed.gh, here and forgh poi, runs with a fixed path,GH_PROMPT_DISABLED=1, noGH_TOKEN,GITHUB_TOKENor enterprise token variables, null stdin, a timeout (30 s here) andkill_on_drop. Its output is parsed into those fields; a state other than the four, or aurlthat is nothttps://or holds whitespace or control characters, drops the read.failing_checksholds 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.conflictingismergeablebeingCONFLICTING. - Checks: from
statusCheckRollup. A status context'sSUCCESSpasses,PENDINGandEXPECTEDare pending, anything else fails; a check run notCOMPLETEDis pending, aSUCCESS,NEUTRALorSKIPPEDconclusion passes, any other fails. Any failure makesfailing, else any pendingpending, elsepassing; an empty rollup isnone. - 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 ishttps, 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 whenchecksisfailing, Address review comments whenremarksis above 0, Resolve conflicts whenconflicting; Explain this PR always. Each fills the composer with one line holding theurl(and, for failing checks, the quotedfailing_checksnames), appended after a blank line to any draft, and sends nothing. collied doctorwarns whenghis missing orgh auth statusfails.
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-fvariables 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.contextandbaseRef.rules.nodes.parameters.requiredStatusChecks.contextfromRequiredStatusChecksParameters), 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 ofEXPECTED. An optional check failing or running does not hold it. With no required check, every listed check must pass and the rollup must not bePENDINGorEXPECTED. 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
ghuser. 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. mergeableturningCONFLICTING;UNKNOWNkeeps 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 throughDriver::promptand all its guards with noexpected_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.
- The prompt is one line naming at most 10 changes:
- 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, somute_donesilences 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 viewthat finds the pull requestMERGED, 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=nonein 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.gitmodulesor the config say; the repository's filter commands are blanked as for diffs), andgit ls-files -v --recurse-submoduleslists no assume-unchanged (lowercase tag, ascore.ignoreStatsets) or skip-worktree (S) entry, in the checkout or a submodule, sincegit statusdoes not look at those files;gh pr view --json number,state,headRefOidagain, which must still sayMERGEDwith the same number; andgit 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). ThenDriver::archive_mergedruns the archive oftask.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 beforeworktree.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 rungh 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, somute_donesilences it, andflock.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 statuslinereads 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-UUIDsession_id,context_window.context_window_size(1K to 100M), and drops the rest (spend_limitincluded). It merges them intousage.json(0600) in the data dir under an exclusiveflockonusage.lock, so concurrent sessions do not lose each other's windows, then replaces the file atomically withoutsync_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 laterresets_atwins, 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_msmoves 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 withoutrate_limitskeeps 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:
Transcriptsholds the reader. It re-readsusage.jsononly when its size or mtime changed (O_NOFOLLOW, regular file, at most 64 KiB): forflock.snapshotandapproval.list, when it derives aclaudeagent's transcript (for the session's window), and on the reconcile loop's existing 1 s herdr poll, which costs onefstat. The plan is the machine's, not a session's, so it goes out on the snapshot asFlock.plan_usage(five_hourandseven_day, eachused_percentandresets_at_ms, andrecorded_ms), with or without a Claude Code agent open. When it changes and a phone has a session open, that tick sendsplan.usagefor a present plan orflock.changedto 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 forcontext_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 usagesays 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 sendsflock.changedso every session refetches. Starring readssession.snapshotand answersnot_foundfor 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, asterminal_idtopane_id. herdr gives every pane a newterminal_idwhen it restores its session (after a reboot), but keeps thepane_id; a cross-workspace move does the reverse. On eachflock.snapshot, and every second in the reconcile loop while a star is not in that tick'sagent.listat the pane it was last seen in (a shell pane, a closed pane, a restore or a move; a phone connected or not, one moresession.snapshotper 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_idstill 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 (unpairclears 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, andcollied.tomlis rewritten only bycollied apns import. - Off:
flock.snapshothasterminals: []and noterminals_enabled; everyterminal.*request butterminal.lockanswersterminals_disabled("terminals are off on this machine; set [terminals] enabled = true in collied.toml and restart collied") before any herdr call, audited.terminal.lockanswersokwhatever the setting. pane.readreads agent panes only, whatever the setting: a shell is read throughterminal.watch, under a grant.
What is a shell
- A pane is a shell when its
PaneInfo.agentis absent and itsterminal_idis not insession.snapshot.agents(which lists an agent herdr is still launching before its pane reports it). collied reads this from a freshsession.snapshotbefore every write, never from a cache, and also refuses while an approval is pending on thatterminal_id(hook-driven approvals are keyed by it, and report sooner than herdr's process detection). flock.snapshotlists shells asTerminal {terminal_id, workspace_id, label, cwd}:labelis the name given in herdr (herdr pane rename),cwdthe 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 sendsflock.changed.
Grants
- Terminal key. The phone has a second Secure Enclave P-256 key besides its TLS key (
TerminalKey.swift, Keychain itemdev.rbstp.collie.identity/terminal), created with[.privateKeyUsage, .userPresence]andkSecAttrAccessibleWhenPasscodeSetThisDeviceOnly: 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.completecarriesterminal_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 (auditedinvalid terminal key). The y/N prompt on the machine showsterminal key: none | new | unchanged | replaces the existing one, and "replaces an existing pairing of this node" for a paired phone. The key is stored inpeers.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 inmachines.json(terminal_key) and answersTerminalKeyMissing(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_missingotherwise) 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, overu32be(len) || bytesof"collie terminal grant v1", the machine's node ID, theterminal_idand 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}; otherwiseterminal_lockedwith 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 acollied.tomlkey), 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 withterminal.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 withagent.watch: it emits the sameagent.outputandagent.output_patchevents, readingpane.read source=recent_unwrappedwith 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 ofPromptText(no newline, tab, ESC or other control, no bidi or invisible format character, checked by the decoder), sent with herdr'spane.send_input {pane_id, text, keys: ["enter"]}, the one ordered writeherdr pane runmakes (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 answerstoo_largewith 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 closedKeylist (at most 16) throughpane.send_keys. ctrl+d, ctrl+z and ctrl+r are not in it: adding them would widenagent.send_keystoo. 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_idoutcome cache. Right before the herdr write collied reads the snapshot again (still a shell, elsenot_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_lockedotherwise; nevernot_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 bypane_idonly (aterminal_idis refused aspane_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.lockends every grant of the session and a terminal watch, and answersok.- New shell panes (
tab.create) are not offered: a follow-up.
Audit
| Method | Target | Result |
|---|---|---|
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,enter | ok or the error |
terminal.lock | none | ok |
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.terminalsandterminals_enabled;AgentView.terminal,terminal_lockedandterminals_enabled. A new connection starts locked and never watches a shell again by itself: grants are per session. lock_terminalslocks every terminal on the phone at once, then sendsterminal.lockto 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.backgroundonly (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 Groupgroup.dev.rbstp.collieand the notification keys' Keychain access group with the app. No network. - With an
encit reads the key for the alert'snode_idand 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) toreachability.jsonin the App Group. If the alert'snode_idlast failed, the NSE appends " (machine may be unreachable, open collie to check)" to the body. Everything else passes through unchanged.
Menu bar app (macOS)
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(thenconfirm), andpeers_list. It opens no network listener and reads none of collied's data files (Open Audit Log handsaudit.logto its default app, Console); peers come frompeers_list, and with collied off the menu says so. watchis read-only and not audited, likestatus: 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 answerserror: shown as running, with "Update collied".- Off, on and Quit run
collied stopandcollied start, the executable named in the launchd plist (ProgramArguments[0]), with no shell, so launchd's semantics (disabled untilstart, across reboots) stay in one place. The socket cannot do it: a stopped collied has no socket, and launchd'sKeepAlivewould restart one stopped from inside. Quit leaves collied off until Turn On orcollied 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 andStableID, login and user ID, the terminal key change and "replaces an existing pairing", every phone string escaped likecollied::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'stcflush; 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-installsigns it with the Developer ID (teamRM3UT3MMSR) 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(thenconfirm) andpeers_list, the exact lines CollieBar sends. The service keeps onewatchconnection: open is running, EOF or a failed connect is off, anerrorreply is an outdated collied; it retries every 2 s while off.peers_listruns 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'sExecStartwithstoporstart, by argv, never through a shell, so systemd's semantics stay incollied stopandcollied 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 likecollied::printableand 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 ASCIIon stdin, never in argv, a file or a log (Quickshell keepsconsole.login/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'sStdioCollectoruntil the next pairing. - Quickshell's JS has no
\p{}classes, soPrintable.jsholds the escaped code point ranges, generated from node's Unicode data byLinux/Tests/gen-printable.mjs, and checked against CollieBar's vectors under node and insideqs. - A bar plugin cannot quit the shell: Quit runs
collied stop, thenomarchy-plugin-disable rbstp.collie. The shell loads enabled plugins at login, which stands for Open at Login. Open Audit Log runslesswithLESSSECURE=1(no editor, no shell escape) in a terminal throughomarchy-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-testchecks the copy is identical. just tray-testruns the model tests under Node andService.qmlin a standaloneqsagainstLinux/Tests/fake_collied.cjs: the watch states, Pair ignored before it is armed, oneconfirmline only after the answer, cancel as EOF,collied stopthrough the unit's executable, and that the invite never reaches the output. It never touches the real control socket.just tray-installvalidates the plugin (omarchy-plugin-validaterefuses symlinks, soMenuIcon.svgis 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 frompushTokenUpdatesgoes to that agent's Mac withregister_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 callsend_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 inactivity-ends.jsonin the state dir (0600, atomic, written on every change), per machine id, each with itsactivity_idand 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?}andpush.activity_end {activity_id}, bothMethodClass::Push.activity_idis ActivityKit'sActivity.id([A-Za-z0-9-], at most 64),tokenis the update token in hex (64 to 256 characters, redacted inDebug).shows_approvals(default false, omitted when false) says the activity shows an approval's command and its buttons; collie-core always sendstrue, since every activity the app starts carries the Mac's node id.- Additive, no version bump. An older collied answers
not_implementedorunknown_method, which the phone ignores. A collied from beforeshows_approvalsrejects 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) underactivities:{stable_id, activity_id, terminal_id, token, environment, registered_at, shows_approvals}(shows_approvalsomitted when false, so entries stored before it load as false). A file withoutactivitiesloads 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 sameactivity_idor the sameterminal_idfrom 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 frompush.register(not_foundwithout one). At most 8 per device (too_largebeyond).push.activity_endfor an id the device does not have answersok. - Dropped on
push.activity_end, on revoke, at start for peers no longer paired, onUnregisteredorBadDeviceTokenfor that token, when the terminal is gone, and quietly (no push, audited asended: expired) 8 h afterregistered_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 anactivity_idcollied already has keeps itsregistered_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:
endwithdismissal-date= now + 60 s, then the token is dropped. A missedagent.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, orended: agent goneandended: expiredfrom collied); tokens are never logged or audited.
| Header | Value |
|---|---|
apns-push-type | liveactivity |
apns-topic | <bundle_id>.push-type.liveactivity |
apns-priority | 10 for the update that carries an approval alert, 5 for every other update, refresh and end |
apns-expiration | the 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-id | none |
{"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) anddismissal-dateare Unix seconds.relevance-scoreranks the agent that most needs the user first, so of several followed agents iOS shows it in the Dynamic Island:blocked100,done75,working50,idle25,unknown0.alertonly on the update that carries an approval alert, so the Dynamic Island expands; its text is the approval alert's.content-statedecodes intoAgentActivityAttributes.ContentStatewith ActivityKit's default decoder:statusisidle | working | blocked | done | unknown;statusSinceis whole seconds since 2001-01-01T00:00:00Z (Unix seconds β 978307200), Swift's defaultDateCodable value, with no custom date strategy on either side;titleis the alert title (herdr agent name, else agent kind, elseagent);kindis the herdr agent kind, only when the widget has an icon for it (claude | codex | copilot, the fixture'skinds), else omitted (an older app ignores it, an older collied never sends it);workspaceis the workspace label;approvalscounts the agent's pending approvals (0 or 1);approvalIdandencare 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; itsencis 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_approvalstakes 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_duelets 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'saps.alertand the content state withapprovalIdandenc: the context sealed to that device's notification key exactly like the alert'senc(fresh nonce, AAD =approval_id,{"v":1,"body":...}; noencwithout 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_duekeeps quiet still reaches the activity, as an update with priority 5, no alert and the newapprovalIdandenc, 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.
encis 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 leavesblocked, the next update omitsapprovalIdandenc; it is a normal coalesced update without alert. Anendnever carries them.- On the phone,
AgentActivityAttributescarries the Mac'snodeId. The widget reads the notification key for it from the shared Keychain access group, opensenc(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, areButton(intent:)with aLiveActivityIntentwhoseauthenticationPolicyis.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:
| Step | Done when | Otherwise |
|---|---|---|
| login | the 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-install | unload 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 |
| service | the 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 mtime | service 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 |
| doctor | no fail line | stop: fix, then run setup again |
| APNs | [apns] is in collied.toml | offer 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 |
| pair | never: phones may need to pair again after an update | offer 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:
| Step | Done when | Otherwise |
|---|---|---|
| login | as 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 service | systemctl --user stop (the unit stays enabled), then collied login; if login fails, collied start |
| service | the 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 mtime | service 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
| Target | State |
|---|---|
aarch64-apple-darwin | built and tested |
x86_64-unknown-linux-gnu | built and tested |
aarch64-unknown-linux-gnu | mapped in tailscale-sys (GOARCH arm64); needs a cross C compiler in CC; not built or tested |
| any other OS | compile_error! in collied |
tailscale-syson Linux: GOOSlinux,CCfrom the environment (defaultcc, rerun when it changes), no-targetor-isysrootflags, 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
| What | macOS | Linux |
|---|---|---|
| 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 |
| Logs | collied.out.log, collied.err.log in the data dir | the user journal |
- For the data dir, the attachments root and the unit path, an
XDG_*_HOMEthat is unset, empty or not absolute is ignored, as the XDG spec says. The herdr resolver mirrors herdr and takesXDG_CONFIG_HOMEas 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.lockandapns/.status.json(when collied first saw each agent's status, for the phone's "how long ago") is replaced atomically, 0600, withoutsync_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, elseHERDR_SOCKET_PATH, elseHERDR_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 underXDG_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_TAGistag:collie-macon macOS andtag:collie-linuxon Linux.collied loginadvertises it.- On both OSes,
collied loginandcollied runrefuse a node whose own tags lackNODE_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 ofNODE_TAG(tailnet.md), runcollied loginagain; phones pair again.runchecks once the node is Running with a self node, before it serves. collied statusprintstags:.collied doctorhas atagline: fail when the running node lacksNODE_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-linuxand the same TCP 8457 grant from the user to it as totag:collie-mac. - The e2e tests advertise
tag:collie-macfor the test node on every OS (PHONE_PINNED_TAGincrates/e2e/tests/common/mod.rs).
systemd user unit
collied service install:
- Makes sure the data dir is private, canonicalizes the executable and
--configpath. - Writes the unit 0644 through
collied.service.tmpand a rename. It is rewritten on every install. daemon-reload, then checks that the user manager loads the unit from that file (FragmentPath). With another unit of that name, or a differentXDG_CONFIG_HOMEin the manager, the install fails there (the file written stays) instead of reporting success for a unit that never starts.enable, thenrestart.
# 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:
| Field | Rule |
|---|---|
ExecStart= executable | absolute UTF-8 path with no ", ' or \, else install refuses; quoted, % doubled (systemd does not expand $ there) |
ExecStart= arguments | quoted; \ and " escaped; % and $ doubled |
Environment= | the whole KEY=value quoted; \ and " escaped; % doubled ($ is literal there) |
| every field | control 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.
| Command | systemctl --user |
|---|---|
collied stop | disable --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 start | enable, then start unless already active. Fails when the unit file is not installed |
collied service uninstall | disable --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-lingerkeeps it running without one. The launchd agent equally runs only in the GUI session. just collied-installon Linux: release build, copied to~/.cargo/bin/colliedas 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 (state0A) of/proc/<pid>/net/tcpandtcp6. The table header is checked. An fd closed during the listing is skipped, and so is a missingtcp6table 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 runssystemd-creds,systemctlandsystemd-analyzeby absolute path, never fromPATH:/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_pathchecks (0600 regular file owned by the user, opened withO_NOFOLLOW).
collied apns import AuthKey_<ID>.p8:
- The same checks as on macOS:
[apns]ids, refuses a file named for another key ID, thekey_pathchecks, an ES256 key. No signature check. - 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. - 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.
- Rewrites
[apns]tokey = "systemd-creds"(nothing to rewrite when no key line was there, since that is the default). - 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:
| State | Result |
|---|---|
| credential, host key | ok, "host key only (no usable TPM2)"; warn if a TPM2 is usable now (import again to bind it) |
| credential, host key and TPM2 | ok, "host key and TPM2" |
| credential, TPM2 only | ok, "TPM2" |
| credential, null key | fail, "null key, not encrypted" (integrity only) |
| credential, unrecognized seal | warn, "unrecognized seal" |
| credential missing or not decrypting | fail |
key_path, systemd-creds works | warn: encrypt it with collied apns import |
key_path, systemd-creds unavailable | ok: "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_funcinitializer, 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.completereply (machine.name), which collie-core keeps aslabelinmachines.jsonand never refreshes. Rename (a machine's context menu or its detail in the Machines tab) callsrename_machine: the new name (trimmed, 1 to 64 characters, no control characters) replaceslabel, 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 asdefault_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 (thenliveis 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 passesdeviceOwnerAuthenticationWithWristDetection(a freshLAContexteach time). - The phone (
AppModel.decideFromWatch) re-readswatchDecisionsfromprefs.jsonon every request (off by default; turning it on needsdeviceOwnerAuthentication), 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-coreapprovals_in_background(15 s budget): the lock-screen path's node start and pinned one-shot session per paired Mac,approval.listonly, 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,liveis false, and the result goes out as the application context, so decisions check it as usual. The reply is{state, silent},silentnaming 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 failedupdateApplicationContextgets no state in the reply. - The watch app is the
UNUserNotificationCenterdelegate on the watch and registersAPPROVALwithout actions. Any response to an alert carryingnode_idandapproval_idopens 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.jsonin its own App Group container and reloads theCollieUsagecomplication (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 supportsaccessoryRectangularfor the Smart Stack and rectangular watch-face slots: three native.accessoryCirculargauges 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 tocollie-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 sharedGlassEffectContainer, 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_protocolerror); 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 isUnavailable("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 anUnavailablelink 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_ERRORto exercise these errors without a live machine. - The Agents tab reads the core's cache every 3 s, which
agent.statusevents 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 forflock.snapshotwhen 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 sendsflock.changed; the refetch fails withherdr_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
NWPathMonitorand reports the interface it routes through (Wi-Fi, cellular or wired, never a VPN tunnel; empty when there is no path) withnetwork_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, categorycore) 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.snapshotreturns theseqit reflects; every event frame carriesseq; the client dropsseq <= 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 theop_idan unanswered identical prompt still holds (kept 300 s of wall time from its first send; collie-core reportsUnreachableonly while it holds it) and otherwise refuses withNotResent, sending nothing; only the user's Send mints anop_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 theop_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 itsop_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
| Risk | Impact | Mitigation |
|---|---|---|
| libtailscale has no git tags and lags tailscale.com releases | security updates can be ours to carry | pin SHA + patch series; track upstream (0.2.0 at d1dc45d) |
Peer attribution race in getremoteaddr (fixed upstream in 0.2.0) | wrong peer authorized | patch 1; never call getremoteaddr |
| Go runtime size | ~20 MB per binary (iOS stripped), ~25 MB collied | measure per slice; Go archives built with -w |
| Cold start in a background launch | Approve from lock screen fails | tsnet 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 state | slow reconnects | re-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 interface | no reconnect until a relaunch | NWPathMonitor 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 panes | measure; fall back to agent.list polling if needed |
| UniFFI + Swift 6 | build friction | Swift 5 mode bindings target; no async foreign traits on hot paths |
APNs .p8 is team wide | leak allows pushes to every app on the team | dedicated 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 Apple | project names exposed | alert 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 approvals | bypasses nonce/expiry | collied 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 exit | prompt runs as a shell command | foreground check before send; conditional input in herdr upstream |
| Retried mutations | duplicate prompts/tasks | op_id outcome cache |
| A paired phone writes files on the Mac | disk use, files an agent may read | size, 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 collied | decode failures | additive events/errors/statuses decode to Unrecognized; anything else bumps PROTOCOL_VERSION |
| Shell input from a paired phone | command execution as the user | off 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 |