Skip to content

Latest commit

 

History

History
171 lines (87 loc) · 81.2 KB

ARCHITECTURE.md

File metadata and controls

171 lines (87 loc) · 81.2 KB

Architecture

How peon-code works inside. For what it does from the user's side, see README.md.

lib/deps.sh shares tmux 3.2+ and Python 3.10+ version checks across the installer and launchers. Only install.sh offers a package-manager command when tmux is missing; runtime checks never install packages.

Adding a CLI

Steps:

  1. Copy a provider file in lib/cli/ to <name>.sh, where <name> is the bare command name, such as foo, the first word of the agent command; a path such as /opt/bin/foo in the agent command matches no provider.
  2. Rename its functions with the <name>_ prefix and fill each concern below.
  3. Run tests/test_peon_code.sh.

lib/cli.sh loads every file in that directory. cli_call <bin> <concern> args... selects its function with declare -F; an unknown CLI keeps a positional prompt and has no trust, transcript store, context parser, placeholder, or known prompt marker.

Per-pane provider selection

The launch stores each pane's binary in the @peon_bin pane option. When lib/cli.sh is sourced, it joins every provider's marker into CLI_ALL_PROMPT_MARKERS (|-separated) and collects every placeholder into CLI_ALL_PASTE_PLACEHOLDERS. cli_select_pane <pane> reads that pane's @peon_bin and sets the working globals CLI_PROMPT_MARKERS, CLI_PASTE_PLACEHOLDERS, and CLI_PANE_HAS_PROMPT_MARKER for that provider alone. Marker and placeholder fall back to the CLI_ALL_ values independently, so a provider with a marker but no placeholder still matches every known placeholder. A pane with no @peon_bin, such as one from an older launch, gets the merged values and counts as having a marker. A pane whose provider prints no marker, such as gemini or an unknown CLI, gets the merged markers and CLI_PANE_HAS_PROMPT_MARKER=0; its placeholder list is the merged one only when the provider prints no placeholder either.

The globals are per call, not per pane, so each entry point selects before it parses: with_pane_delivery in lib/delivery.sh (around every locked paste, send, slash command, key, rebrief, and launch_command_locked, the launch-command paste that lib/launch.sh and the watcher's restart share), pane_box_ready in lib/input.sh, and answer_dialog and wait_pane_settled in lib/tmux.sh. The awk parsers in lib/input.sh (pane_has_menu, pane_free_text_number, strip_styles, pane_box_text) and box_is_paste_placeholder read the globals and rely on that earlier selection.

wait_pane_settled uses the marker when the pane has one: it returns once two consecutive captures match and contain a marker. A pane without a marker returns after three identical captures that are not blank, about 0.6 s. Either way a capture that holds a menu does not count, and the call returns 1 after the tries cap (100 tries of 0.3 s by default). The only startup caller is claude_brief_after_start, and claude has a marker, so the no-marker branch runs only in the wait before the rebrief that follows compact or clear (lib/commands.sh). It is reached only when the provider declares no marker but the pane draws one of the merged markers: the input-box parsers need a marker on screen, so send, rebrief, compact, and clear refuse a pane that draws no marker peon-code knows before any wait.

Every provider defines launch_command, trust_project, resume_dir, resume_id, context_tokens, paste_placeholder, and prompt_marker; usage_tokens, weekly_limit, compact_count, resume_args, own_thread, child_threads, swap_args, probe_command, and quit_command are optional (see Usage report, Weekly limit, Context watcher, Resume lookup, and Swap). own_thread takes a transcript path and returns 1 to reject it as a subagent thread; its fallback returns 0. child_threads prints the child transcript paths of a transcript, one per line; its fallback returns 1, meaning none. swap_args prints the model and effort flags to append, probe_command the headless check command, and quit_command the slash command that ends the agent. resume_args prints the arguments that resume its quoted id argument, with a leading space; claude and codex define it and use it in their own launch_command, and a CLI without it returns 1, so the watcher restarts that CLI fresh. launch_command prints the shell command and takes the binary, argument string (with its leading space), quoted resume id, quoted prompt expression, and pane index. trust_project takes the physical and logical project paths. resume_dir sets the caller's local dir and optional cwd filter; resume_id prints the id from its transcript path argument. context_tokens prints the last request's context size from its transcript path argument; without arguments it returns 0 when supported and 1 when unsupported. The optional compact_count takes a transcript path and prints its number of compaction boundaries; providers without this hook skip boundary tracking. An unsupported parser called with a transcript returns 0 with no output. paste_placeholder prints its anchored regular expression. prompt_marker prints one literal marker or a |-joined list, or nothing when unknown. Markers contain no regular-expression metacharacters. Claude reports ❯|!: its shell-mode ! counts only in column zero, so punctuation inside text and the indented shell-mode footer do not count as prompts. The box reader returns the shell command with status 5. Message, brief, and slash-command pastes refuse this status; an explicit key --submit Enter can submit the command the user typed.

A provider whose brief follows TUI startup can also define brief_after_start; Claude uses it for its resume picker and delayed paste. It reads the existing globals i (pane index), RID (resume id), BRIEF (prompt text), NAMES, and PANE_IDS, and writes PASTE_RC for the paste result. Other providers need no function for this concern. tests/test_peon_code.sh checks all seven required functions, the marker character contract, generic launch behavior, per-pane marker and placeholder selection (test_cli_pane_selection), and both settle rules (test_cli_pane_settled).

Pane identity

Each agent pane carries its name in the @peon_name tmux pane option and its brief file path in @peon_brief, both set at launch; an app cannot overwrite a pane option, unlike the pane title, so the pane border shows @peon_name too. Every subcommand acts only on panes carrying @peon_name, and the session-scoped ones only on sessions marked @peon_code.

Pasting

Text goes in through a tmux buffer as a bracketed paste, so a multi-line message stays in the input line instead of submitting early.

compact and clear call slash_then_rebrief in lib/commands.sh. Before a slash paste, slash_locked rejects copy mode, an active turn, a menu, and typed input. For /compact, it sets @peon_compact_owned to 1 after these checks and before the paste, and clears it if the paste or submission fails. pane_is_busy in lib/tmux.sh uses the shared set of known prompt markers to find the last prompt line. It checks only the four non-blank rows above that line for a working row that starts with a spinner glyph, has at most 40 characters between that glyph and the opening parenthesis, and opens those parentheses with elapsed seconds or minutes and seconds. Long replies and wrapped quotes higher in the output do not match. This is one elapsed-time shape for Claude and Codex; a CLI without it needs a provider concern. After the slash is submitted, the command polls for both a settled screen and no working row within one 120-second cap. If the pane stays busy, it sets the pane option @peon_rebrief_pending to 1 and leaves the brief unsent. A running watcher checks pending briefs every tick, before the transcript read gate. It waits up to 10 settle tries, checks the working row again, sends the stored brief through rebrief_pane, and clears the option only on success. It then logs <name> brief re-sent after compaction once.

Every paste, key, and slash command to a pane runs under with_pane_delivery (lib/delivery.sh), which takes a per-pane lock by mkdir of /tmp/peon-code-delivery-$UID/<key>, where <key> is the cksum of the tmux socket path and pane id. The root is created under umask 077, and a root that is not a directory owned by the user, or is a symlink, is refused with exit 75. A held lock is retried every 0.2 s up to 25 times, about 5 seconds, then refused with exit 75 and a message that prints the lock path. A caller that already holds the lock runs its command directly. The lock directory holds an owner file with the guard's pid, which nothing reads, and an EXIT trap removes both on a normal exit. A lock left by a killed delivery is treated as held, since a tmux child of the killed guard might still be running, so it is removed by hand: after confirming no delivery to that pane is running, delete the owner file and the empty directory at the printed path. web/server.py also serializes the browser's send, swap, key, and explain requests behind one server-wide lock.

Browser workspace

peon-code-web.sh resolves its install symlink and starts the optional Python stdlib server. web/launch.py owns CLI parsing, platform browser opening, and the single-command SSH workflow; port 8765 is its canonical default. Without --port, the local server chooses a free port when 8765 is busy. SSH requests a loopback-only forward whose local end likewise falls back to a free port, while the remote server stays on 8765. Explicit --port N pins the local server port, or both ends of an SSH forward, to N. SSH starts the remote installed command with safely quoted arguments and a PATH-independent fallback, validates its JSON launch rendezvous, and opens locally. With --config PATH, an extra SSH call first copies the local file bytes to ~/.config/peon-code/uploads/<name>.conf, using a sanitized session name or project basename. The copy uses umask 077 and applies chmod 600 after writing, so an existing upload also gets private permissions. The forwarded command receives that literal ~ path through --config; the remote parser expands it on the agent host. The remote user config is unchanged. Remote stdio EOF stops the SSH-owned web server. An unresolved conflict on an explicit port fails before team creation. --dir PATH resolves the project on the agent host and derives the default name from its basename; a positional session name overrides it. Missing sessions use the existing headless launcher in that directory. A supplied config reaches peon-code.sh -c <absolute path> <session>, so it takes precedence over the project and user config files. Existing sessions require the peon marker and matching project metadata, falling back to stable session_path for older teams. Reserved launcher-command names cannot trigger creation. The browser + tab posts to /api/open, which calls the same open_project(); a config file, UTF-8 and at most 200 KiB, is written under its own file name to a fresh mkdtemp directory team-* in ~/.config/peon-code/uploads/, and the directory is removed when the launch returns (web/server.py:528, web/server.py:557). The optional roles list, at most 20 entries that validate_role() checks (web/server.py:116), is written as roles/<name>.md files with type: and description: frontmatter in that same directory, so resolve_role (lib/config.sh) finds each bare name next to the config file. The server refuses roles without a config, duplicate role names, a shipped role name, and a config named roles, which would collide with the role directory (web/server.py:122, web/server.py:487-493).

GET /api/roles returns name, type, and description for each shipped roles/*.md with a type: frontmatter line, without protocol.md (web/server.py:93). The new-session form puts Add role above Add agent, each button in a full-width .form-line wrapper at the left edge with its rows below it (web/index.html:27-30, web/style.css:91); each row is cloned from a template in web/index.html. updateAgentRoles() fills each agent row's role select with -, the shipped names, and the names typed in the role rows, and keeps a selection only while that name still exists (web/app.js:100). On submit, agent rows and a config upload are mutually exclusive (web/app.js:163). The agent rows compose config_text with config_name team.conf, one name command role line per row (web/app.js:169). The row whose Main radio is checked gets the leading *, and the command appends --model, then --effort for claude or -c model_reasoning_effort= for any other CLI. Closing the form empties both row lists (web/app.js:135-136). The agent-row path always sends save true, and an upload never sends it, so an uploaded config is never kept (web/app.js:167). The server refuses save without a config (web/server.py:497). With save, it resolves the project folder and derives the session name the way the launcher does, then runs tmux has-session and refuses a session that already runs, so nothing is written for a team that would only attach (web/server.py:508). It creates a missing project folder only with create, as open_project() does. It then targets <project>/peon-code.conf and <project>/roles/<name>.md, and refuses with 409 when any of them exists, even as a symlink, or when roles is a symlink or not a directory (web/server.py:517-524). Every file is opened with exclusive create (xb or x), and each path joins the written list as soon as it is created (web/server.py:531, web/server.py:539). All writes finish before open_project() runs, so the launcher reads the kept config through -c and the session's @peon_conf names it (web/server.py:542). When any step up to open_project() raises, the finally block unlinks every path in written; a folder it created stays (web/server.py:553-555). Without save, the files go to the mkdtemp directory described above.

web/bridge.py discovers marked sessions and agent roles, with launch metadata and a brief fallback for older sessions. Session project metadata is recorded at creation; older sessions fall back to pane_current_path via a separate display response preserving whitespace. Its styled display capture uses -e -N and keeps physical rows so the browser does not re-wrap soft-wrapped lines and a redraw does not change the row count. It drops trailing blank rows only up to the excess pane height over the browser row count; captures without a browser row count stay unchanged. Plain screen and styled styledScreen capture visible physical rows without -J; menu checks, cursor rows, and input-box and hint parsing use these unjoined screens. Explicit window/active styles apply to the display. web/ansi.js renders safe text spans with SGR foreground/background/style attributes; terminal data never enters HTML. Unknown local terminal palettes use standard xterm indexed colors. SGR 38;2 and 48;2 truecolor values pass through as rgb(). Without a tmux window style, the default colors are #d0d0d0 on #000000 (web/ansi.js:20), because tmux cannot report the client terminal's own palette or default colors to the server. web/navigation.js owns session/pane selection and unread navigation; web/app.js preserves per-pane drafts/selections and freezes selected output. In each card's template the message form sits right after the output, so native Tab order goes from the output to the message box and then to the buttons; order on .pane > .message-form and .pane > .feedback in web/style.css keeps both at the bottom of the flex column on screen. The Keys chevron is a CSS ::after on .keys-toggle that switches from ▸ to ▾ on aria-expanded="true", which web/app.js sets when the row opens. A capture change in an inactive manager-role pane marks it unread (web/app.js:1118, pane.role === 'manager'); worker and reviewer panes never do, and initial history does not, because a new card has no previous capture. A roleless team's first pane counts as its manager. A session tab is unread when any of its pane cards is (web/navigation.js:38), so in effect only through its manager; viewing the pane clears it.

The font buttons set the CSS variable --output-font on :root and save the size under the localStorage key peon-font, clamped to 8..32. refresh() in web/app.js is the one place that measures cols and rows; the font buttons and a 150 ms debounced resize listener schedule it through scheduleRefresh().

The regular poll GET /api/panes also carries cols and rows, the first .output of the visible card divided by the measured cell size, sent only within 20 to 500 columns and 5 to 300 rows, together with session and pane, the session and id of the visible pane. A poll runs about every 1.2 seconds, and a window resize schedules one after 150 ms. With cols and rows, web/server.py visits every session of the poll.

A detached session gets one tmux window per pane, because tmux sizes a window and panes that share it share its width. Only the session that session names is changed, and only when pane belongs to it. If the session option @peon_pane_order is empty, the server first records in it the pane ids of the agents window in their layout order. It then runs break-pane -d on every pane whose window holds more than one pane, which moves that pane to its own window named p<number>. break-pane keeps the pane id, so send, msg, and the watcher still reach every pane. Last, it resizes only the window of pane to cols by rows plus that window's border rows (window_height minus pane_height), and skips the resize when the window already has that size. A one-pane agents window keeps its top border row; a broken-out window has none. Other panes, other sessions, and a poll without session and pane get no resize. With several browser tabs open, the last poll for a pane sets its size.

An attached session gets its terminal layout back, whatever session names. When its agents window has window-size manual or a pane of the session is in another window, the next poll joins each outside pane into agents with join-pane -d, in the order of @peon_pane_order, or in pane id order when the option is empty. It then uses swap-pane -d until the panes stand in that order, unsets window-size, sets main-pane-width to 60%, applies select-layout main-vertical, and unsets @peon_pane_order. goto_session in lib/session.sh runs the same join, swap, and layout steps before it attaches or switches the client, so peon-code <session> opens on the terminal layout; it then unsets the option. A headless caller exits before these steps. Only these two paths join the panes. A client that attaches by hand sees the separate windows until the next poll that carries cols, about one second later while a browser page shows a card. With no such page open, the windows stay separate until peon-code <session> runs or a browser polls again.

Scrollback is a deeper live capture, not a store. Each card tracks its own depth lines, starting at 1,000. When the user scrolls up within 200 px of the top with no selection, and the last capture already reached the current depth, web/app.js adds 1,000 lines. It stops once the depth covers the pane's #{history_size}, which tmux's history-limit bounds and web/bridge.py reads with the pane identity, or at 200,000. The next poll adds lines=<n> to the pane=<id> of the visible pane only when the depth exceeds 1,000; web/server.py rejects a lines value outside 1,000 to 200,000 with 400, and captures only that pane with capture-pane -S -<n>, while other panes keep 1,000. Rendering waits until a capture reaches the requested depth, then keeps the view in place as the next paragraph describes. Within 40 px of the bottom the depth resets to 1,000. Polling continues at every depth, and nothing is persisted.

A screen scrolled up keeps its text in place while output grows. A screen within 40 px of its end follows the live output. For every other screen, updateOutput() calls scrollAnchor() in web/app.js before the render. scrollAnchor() finds the line at the top left of the visible text with caretPositionFromPoint, or caretRangeFromPoint where only that exists. From that line it moves down to the first line that is not blank and whose 3-line window, that line and the next two, occurs only once in the old text. If no visible window is unique, it uses the first non-blank visible line. It stops with no anchor when all visible lines are blank. It records the pixel offset of that line from the top of the screen. updateOutput() stores the capture start, max(0, history - capturedLines), in card.renderedStart and counts only forward movement as rolled rows. After the render, the returned function finds each position of that window in the new text. It takes the match closest to the old index minus the lines that rolled off the top, plus the change in line count, and sets scrollTop so the line keeps its pixel offset. When all visible lines are blank or the new text has no match, the screen holds its distance from the bottom. When history size stops growing at tmux's history limit and no unique window is visible, the repeated-line fallback can move by one repeat period. Each screen keeps its own anchorRemainder, the fraction of a pixel that scrollTop rounding drops, and adds it to the next anchored scroll; a user scroll or the fallback sets it to 0.

Extra screens are a browser-only view of one pane; the server sees only the size of the first screen. In web/index.html each card holds a .screens column with one .screen that wraps the .output. The + button (.add-screen) clones the first .screen with its current DOM, appends a .close-screen button, and starts the clone at the bottom. wireScreen() in web/app.js attaches the same scroll, key, and context-menu handlers to every screen, all bound to the one card, so selection, explain, key sends, and depth growth act on that pane from any screen. updateOutput() renders the capture with renderAnsi() into the first .output only, then copies its child nodes into each other .output with cloneNode(true). It measures and restores the scroll position of each screen on its own, and each screen keeps its last scrollTop for the scroll-up check. The depth resets to 1,000 only when every screen is within 40 px of its bottom. The Stack order button (.stack-order) flips card.stack between 'vertical', the default, and 'horizontal', toggles the horizontal class on .screens, which sets flex-direction: row in web/style.css, and restores each screen's scroll position. refresh() measures cols and rows from the first .output, so for a detached session a side-by-side split narrows the window of the viewed pane, and a vertical split shortens it. Other panes keep their size.

The screen layout of a card survives a reload in localStorage under the key peon-screens:<session>/<pane name>. saveScreens() in web/app.js stores {stack, screens}, with one {anchor} for each screen. anchor is null for a screen at the live output; otherwise it is the scrollAnchor() record {text, pixelOffset, index, lineCount}, where text is the 3-line window. Adding a screen, closing a screen, and the stack toggle write at once. A scroll writes after 300 ms with no further scroll, and pagehide writes any write that is still pending. The entry is removed when the card is one vertical screen at the live output, when the card is discarded, and when its pane is gone; a removal skips a key that another open card still uses. createCard() reads and validates the entry, sets the stack order, adds the saved screens, and gives each screen its saved anchor. The first updateOutput() passes that anchor to scrollAnchor(output, saved), which finds the window in the new text as for a live render. When the saved text is not in the loaded history, the screen goes to the bottom. A read or write error in localStorage is ignored.

The HTTP server checks token, Host/Origin, and marked-session pane membership. allowed() accepts a Host header only when its hostname is loopback, 127.0.0.1, localhost, or [::1], with any port or none, and an Origin header, when one is sent, only as http:// plus the same pattern (web/server.py:172-180). The port is not checked, because an SSH forward can land on another local port than the server's own. The hostname check guards against DNS rebinding, so a page served under a foreign hostname is still refused with 403, even when that name resolves to loopback. The token check applies only to authenticated paths and compares X-Peon-Token with secrets.compare_digest (web/server.py:181). It reuses explain (cmd_explain in lib/commands.sh) and send via stdin and subprocess argument arrays, preserving safe-paste checks. The Close session button posts to /api/dismiss, which checks that the session is still in the pane list and runs peon-code.sh dismiss <session> with a 10 second timeout. Browser sends are serialized to avoid simultaneous empty-input checks.

Uploads go to POST /api/upload?session=<s>&name=<n>&dir=<d> with Content-Type: application/octet-stream and the raw file bytes as the body, 0 to 64 MiB (web/server.py:323). do_POST routes on urlsplit(self.path).path, so the query string does not affect dispatch. The server takes projectDir from a live pane of that session and refuses the request when no such pane exists. name must be a basename: not empty, ., or .., no / or \, no control characters, at most 255 UTF-8 bytes. dir defaults to upload when absent; it must be a relative path of at most 1024 UTF-8 bytes with no empty, ., or .. component and no control characters, and trailing / characters are dropped. The folder must resolve inside the resolved project directory (Path.resolve().is_relative_to), so a symlink that points out of the project is refused. The server creates the folder on demand and writes with open("xb"), so it never replaces a file. It returns 200 with Saved <dir>/<name>, 409 when the file exists, 400 for bad input, a short body, or a folder path that is a file, and 503 for other OSErrors. In the sidebar of web/index.html, the Upload files button #upload sits above Swap agent and works like it: class swap, aria-expanded, and aria-controls="upload-form". It shows only while the current card is open, and an open form closes when the current card is gone or closed, or when the selection moves away from a card that is gone or closed; a move between two open cards keeps it open (web/app.js:427, web/app.js:441). A click opens #upload-form (class swap-form) and focuses the Folder field, default upload; a second click, Cancel, or Escape closes it (web/app.js:189). The form also holds a Choose files submit button, a hidden multiple-file input, and the #upload-status line. Submit opens the file picker instead of sending the form (web/app.js:198). web/app.js:202 sends one request per chosen file in order, with the trimmed folder or upload when the field is blank. It disables Upload files and Choose files until the last request ends, and writes one <file>: <result> status line per file, marked as an error when any file fails. After a batch, it saves the folder under the localStorage key peon-upload-dir:<session> (web/app.js:231); when the current session changes, updateSwap() restores that value, or upload, into the Folder field (web/app.js:434). Each time the form opens, loadUploadFolders() fills the #upload-folders datalist with the folder names of the project root listing (web/app.js:162). A revision counter drops a listing that returns after the session changed or the form closed, and a failed listing leaves the list empty, since a typed folder still works.

#agents holds four details groups in this order: Manager, Workers, Reviewer, and Agents for panes with no role type (web/index.html:48-51). reconcileNavigation() sorts panes by session, then by roleOrder (manager, worker, reviewer, then the rest), then by pane number (web/navigation.js:87). updateNavigation() hides an empty group and labels each summary <label> (<count>) (web/navigation.js:45). Open state is kept per session and role in navigation.groups, keyed by JSON.stringify([session, role]). When the viewed session changes, each group saves its open state under the old key and takes the state of the new key, open by default (web/navigation.js:51-52). A closed group carries the unread, needs-answer, and waiting-text flags of its panes to its summary class and aria-label, so a collapsed agent still signals (web/navigation.js:58-62). #agents takes flex: 1 1 auto with min-height: 0 and overflow-y: auto, so the agent list scrolls and fills the free height, and the action buttons after it sit at the foot of the sidebar (web/style.css:24). A sidebar form takes flex-shrink: 1, min-height: 0, and overflow-y: auto, so an open form scrolls inside the sidebar instead of pushing the buttons out (web/style.css:22). The sidebar's padding-bottom is the CSS variable --pane-foot, 0 when unset (web/style.css:20). updateSidebarFoot() sets it to the height of the visible card below its .screens block: the card's bottom minus the bottom of .screens, which covers the action row, the keys strip, the message form, and the feedback line (web/app.js:613, web/app.js:623-624). So Close session, the last sidebar button, ends level with the bottom of the black .output. A ResizeObserver watches the visible card and its .screens and calls the same function, so the padding follows a keys strip that opens, a message box that grows, or a window resize (web/app.js:612, web/app.js:619). When the visible card changes, the function disconnects the observer and observes the new card; with no card the padding is 0. The wrapped updateNavigation() calls it after every navigation render (web/app.js:627). Below the 650 px breakpoint the sidebar takes padding-bottom: 0, its forms neither shrink nor scroll, and the list takes flex: none and overflow: visible, so nothing in the sidebar scrolls (web/style.css:119, web/style.css:125-127).

Add agent (#add-agent) sits after the upload form, right above Swap agent (web/index.html:63), and works like Swap agent: it shows while the current card is open, and its form closes when the session of the current card changes (web/app.js:483). Opening it closes the swap and upload forms, and opening either of those closes it. The form's role select lists -, the shipped roles, and New role, which shows an inline copy of the role template without its remove button (web/app.js:471). Submit posts session, name, cli, model, effort, args, and role to /api/add, plus newRole for a new role (web/app.js:513). While the request runs, addPending sets the card busy, dims every button through updateBusy(), and disables Add agent and Swap agent (web/app.js:486, web/app.js:583). The server checks the name, the CLI against available_clis(), and model and effort against the swap token pattern, and composes the command the same way the agent rows do (web/server.py:403). The role must be -, a shipped role, or the new role's name, and validate_role() keeps the new name off the shipped names. Under the send lock the server only checks that the session still has a live pane; it releases the lock before it runs peon-code.sh add with a 300 second timeout, so sends to other panes go on during the launch (web/server.py:429-446). A new role is written to roles/<name>.md in a mkdtemp directory add-* in ~/.config/peon-code/uploads/, passed as an absolute path, and removed in a finally block once the command returns (web/server.py:437, web/server.py:449). A nonzero exit returns 409 with the command's stderr. The form's Keep for future launches checkbox starts checked, and its value travels as save (web/index.html:72, web/app.js:519). The server requires a boolean save and appends --save to the add arguments when it is true (web/server.py:405, web/server.py:445).

Two token-checked GET endpoints read the session project folder (web/server.py:171). GET /api/files?session=<s>&dir=<d> lists one folder; dir absent or empty means the project root. GET /api/file?session=<s>&path=<p> returns one file, or one folder as a zip archive. Both go through project_path() (web/server.py:144), which applies the upload folder rules: a relative path of at most 1024 UTF-8 bytes, no empty, ., or .. component, no control characters, trailing / characters dropped, and a resolved target inside the resolved project directory. Both accept the root. A path that breaks a rule returns 400, a missing path or a listed path that is not a folder returns 404, and another OSError returns 503. The listing reads os.scandir and calls stat(follow_symlinks=False), so a symlink is type other, never dir or file. Each entry has name, type, size (files only), and modified in whole seconds. The server stops after 2000 entries in scandir order, then sorts folders first and names by casefold() (web/server.py:179, web/server.py:186), so in a larger folder the 2000 kept entries are not the first 2000 by name. The file endpoint follows symlinks that resolve inside the project, returns 404 for anything that is not a regular file, and returns 413 when the file is over 256 MiB (MAX_FILE_BYTES, web/server.py:32), checked on stat and again on the bytes read. It sends Content-Type from mimetypes.guess_type, or application/octet-stream, with no charset, and Content-Disposition: attachment; filename*=UTF-8''<percent-encoded name> (web/server.py:120). Every response also has X-Content-Type-Options: nosniff. When path names a folder, the project root included, the server sends a zip archive built in memory (web/server.py:191). It walks the folder with os.walk(followlinks=False), so it does not enter a symlinked folder, and an error during the walk is raised to the same 404 and 503 handlers. It keeps only regular files by lstat, so symlinks and other file types are skipped, and it writes no directory entries, so an empty folder does not appear in the archive. It adds the lstat sizes as it walks and returns 413 as soon as the total passes 256 MiB, before it builds the archive. The cap counts uncompressed bytes, and the server does not check the size again when it reads each file. zipfile.ZipFile writes each member with ZIP_DEFLATED, under its path relative to the requested folder, and strict_timestamps=False lets files dated before 1980 in. The response has Content-Type: application/zip and the attachment name <folder basename>.zip, or <project basename>.zip for the root (web/server.py:210).

The browser cannot point an img or iframe at /api/file, because that request cannot carry the X-Peon-Token header. fetchFile() (web/app.js:259) fetches the file with the header and the page shows the result through URL.createObjectURL. For this reason the Content-Security-Policy allows img-src 'self' blob: and frame-src blob: (web/server.py:127); all other sources stay at default-src 'self'. The Download/view files button #files shows and hides with Swap agent and opens the modal #files-dialog (web/app.js:344). loadFiles() (web/app.js:307) draws a breadcrumb that starts at Project, one row per entry, and a button that opens each folder row. Folder rows also get Download (web/app.js:329). File rows show the size in bytes and a Download button. The dialog header has a Download button for the current folder, which names the archive after the last folder segment, or after the project basename at the root (web/app.js:354). Names that end in .png, .jpg, .jpeg, .gif, .webp, .svg, or .pdf also get View. openFile() (web/app.js:267) shows View in the second dialog #viewer, in an iframe for a PDF and an img otherwise, and revokes that blob URL when the viewer closes. Download clicks a temporary <a download> on the blob URL, removes it, and revokes the URL after one second. For a folder, the client saves <name>.zip, and replaces that name with the decoded filename* of the response Content-Disposition when it can parse it (web/app.js:290). A failed download writes its error to the dialog status line. Upload here is disabled at the project root, because the upload endpoint needs a folder. In a subfolder it copies the current folder into the Folder field, closes the dialog, and opens the upload form (web/app.js:367). fileDir stays set after the dialog closes, so the dialog opens again at the last folder of the same session. If that folder now returns 404, the dialog falls back to the root. A different session starts at the root. The dialog closes when the session changes or the card closes (web/app.js:437, web/app.js:440). Revision counters for the listing and the viewer drop responses that come back after a newer request or a close.

buttons/*.md holds the tracked prompt-button seeds. web/server.py defines BUTTONS_DIR as that installation directory. Its runtime config_dir() reads XDG_CONFIG_HOME, or defaults to ~/.config, and appends peon-code; both PID files and custom button storage use it. Authenticated GET /api/buttons reads seeds and custom files from config_dir()/buttons, sorts by (order, name), and returns file stems, one-line description frontmatter, and exact prompt bodies in a plain array. order is an optional frontmatter integer, 0 when absent; a value that is not an integer from 0 to 1000 makes the entry invalid. Invalid or unreadable entries are omitted independently; CRLF headers leave prompt bodies intact. POST /api/buttons validates the same fields, trims surrounding spaces from names, creates a custom file exclusively, rejects names already in either directory with 409, and returns the full array. Installation seeds are unchanged. The browser loads this list once and refreshes it after saving a button. Each pane renders the list and sends its prompt through /api/send without append.

The web shell launcher reuses offer_update before loading Python and restarts after successful pulls. Its SSH stdio mode emits an update event with a sanitized hostname; the client validates the event and hostname, prompts locally only with a TTY, and writes an explicit yes/no response. NonTTY local launches read update replies from /dev/null; the remote stdin channel remains open for server lifetime control after the handshake.

The busy check

A pane's input box is read as everything from the prompt marker (claude draws ❯, or ! in shell mode, and codex draws ›) to the end of the cursor's row, with the CLI's hint text left out, so text the cursor was moved back over still counts. Enter follows a paste only once the box reads back the pasted text or the CLI's placeholder row for a long paste, such as [Pasted text #2 +15 lines]. The shared menu check also recognizes an anchored approval row ending in (y), (a), or (n) when its contiguous block has at least two choice rows, with the same cursor and quoted-marker guards. This check only decides whether a menu is open; the key command sends only Tab, Up, Down, Left, Right, Enter, Escape, BSpace, and C-c; the browser sends only Tab, Up, Down, Left, Right, Enter, Escape, Backspace, and C-c, so a choice is reached by moving the highlight, never by typing its digit or letter. The server maps browser Backspace to CLI BSpace. A focused pane output passes ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Enter, Escape, and Backspace through as the matching keys (web/app.js:844). While a menu is open, the menu shortcuts replace the Up, Down, Enter, Escape, and Backspace buttons; the Left and Right buttons stay visible, because no menu shortcut duplicates them (web/app.js:787). The browser sends C-c from Ctrl+C in a focused pane output only when no text is selected, so Ctrl+C still copies a selection, and it sends C-c even while that pane is busy with another send, because C-c is the interrupt; the Ctrl+C key button stays enabled while busy for the same reason, though it dims with the other buttons. During a send, every button on the page dims, Ctrl+C and the sidebar included. The send sets card.busy (web/app.js:731) and calls setSelection(), which calls updateBusy(); that toggles the class busy on body while any card is busy or an add is pending (web/app.js:705-707). The rule body.busy button, body.busy summary in web/style.css:109 sets opacity .45 with transition: opacity 0s .3s, the same as button:disabled on line 108, and the button, summary rule on line 107 restores full opacity with transition: opacity 0s, so the agent group headings dim with the buttons. So a send under 300 ms shows no change, and a longer one dims every button together. The dim is visual only. Blocking stays per button: card.busy disables the card's prompt buttons, add-button and its form, screen buttons, and Send (web/app.js:711, web/app.js:717), and updateSwap() gives Swap agent, Download/view files, Upload files, and the controls of both forms the same disabled state (web/app.js:570, web/app.js:588-591, web/app.js:608). A module-level uploading flag, set and cleared by the file input's onchange handler, keeps the upload button and form disabled when a refresh calls updateSwap() during an upload (web/app.js:235, web/app.js:286, web/app.js:307). Ctrl+C and the pane tabs (web/navigation.js:18) stay clickable while dimmed. Enter without --submit is refused unless the pane is on a menu, a check that runs inside the delivery lock before the key send.

Folder trust

launch_agents (lib/launch.sh) writes trust before its per-agent loop, so no CLI starts first. The trusted paths are pwd -P, plus $PWD when the logical path differs. Each CLI's step runs once per launch and only when a team command starts with claude or codex. A failure prints a launch note and the launch continues; the CLI then shows its own dialog unless the config already trusts the path.

Claude: an inline python3 script loads ${CLAUDE_CONFIG_DIR:-$HOME}/.claude.json, sets projects[<path>].hasTrustDialogAccepted to true with setdefault, so other keys in the entry survive, and writes nothing when every path is already true. A missing file or a missing python3 skips the step; the file is never created. The write resolves symlinks with realpath and writes the target, so the link stays. It serializes to a mkstemp file in the target's directory, restores the original mode with fchmod, and commits with os.replace, so a failed write leaves the original intact. The whole file is re-serialized with two-space indentation.

Codex: the step resolves ${CODEX_HOME:-$HOME/.codex}/config.toml through up to 40 readlink hops and writes the final target. If any line of the file assigns projects with =, bare or quoted key, as an inline projects = { ... } table does, the step prints a note on stderr and skips codex: TOML cannot add a [projects."<path>"] table to a table defined inline, so the append would make the file invalid. The match is a line-start grep -E and does not check which table the line sits in. Codex then shows its own dialog unless the file already trusts the path. Otherwise the path is escaped as a TOML basic string, and the step appends [projects."<path>"] and trust_level = "trusted". The duplicate guard is a fixed-string grep for the path in double-quoted form, or in single-quoted literal form when the path holds no single quote or line break. A path the file already names is never appended again, so a user's explicit untrusted entry stays and a second table for the same key, which TOML rejects, is never written. The step copies the file with cp -p to a mktemp file in the same directory, appends, and moves it over the original. A missing file or directory is created; the new file gets the temp file's private mode.

Briefs

create_agent_session passes -e "PEON_CODE_SESSION=$session" and -e DISABLE_AUTO_UPDATE=true to tmux new-session and split-window, so each pane inherits the session name with support for tmux 3.2 and newer, and an oh-my-zsh shell skips its update check, whose read -k 1 at shell start would consume the first key of the pasted launch command.

After the trust step, launch_agents makes two passes over the panes. The first pass writes each brief file and sends each pane its launch command: a paste, a fixed one-second pause, then Enter, with no readiness wait except the shell check after the scope line described below. The second pass goes through the panes in order: it waits for the pane to leave the shell, and for a claude pane it answers a resume picker, waits for the pane to settle, and pastes the brief. Because every CLI is already starting when the second pass begins, the waits on earlier panes overlap the startup of later ones; only the per-pane checks are serial. A pane whose launch command failed is not skipped: it times out in the readiness wait and joins the failed list.

Before the first pass, launch_agents probes once for a systemd user manager by running systemd-run --user --scope -q -p OOMPolicy=continue -- true; there is no OS check, so the probe itself is the gate. When the probe passes, the first pass pastes one extra line into each pane before its launch command, systemd-run --user --scope -q -p OOMPolicy=continue -- "${SHELL:-sh}" -l, then sleeps one second, calls wait_shell_ready, and pastes the launch command unchanged. The reason is that a tmux built with systemd support starts each pane in its own transient tmux-spawn-*.scope, and systemd gives that scope its DefaultOOMPolicy, which is stop unless the user manager sets otherwise (a tmux built without systemd support, and macOS, create no such scope): when the kernel out-of-memory killer takes any process in that scope, systemd stops the whole scope, so one killed child, such as a build or test the agent ran, takes down the pane shell and the agent with it. The nested login shell runs in its own scope with OOMPolicy=continue, so only the OOM victim dies, and because it is a login shell of the user's own $SHELL, the launch command resolves aliases and functions the same way it would in the outer shell. This has three limits. The agent process itself still dies when it is the OOM victim. Without systemd-run, or when the probe fails (macOS, or Linux without a user manager), nothing extra is pasted and the launch is unchanged. When the scope line fails inside a pane, the pane stays at the outer shell and the launch command runs there without the protection; the result of wait_shell_ready is not checked, so the one-second sleep is what gives systemd-run time to start the nested shell. Because a pane that is still inside a transient systemd-run has not reached the agent, wait_agent_ready in lib/tmux.sh counts systemd-run as a pane command that has not started yet, together with the shell names.

Every brief is written to a file under $TMPDIR. A CLI that takes its prompt on the command line launches as <cmd> "$(cat <file>)", so the command line stays one line instead of thousands of escaped characters. A pane can still open on a dialog, for example the folder-trust check when the trust step above could not write. A claude pane gets its brief pasted in once that dialog is gone, its input line is drawn, and the pane has stopped changing: any other menu or dialog counts as unsettled, so the launcher waits up to 30 seconds (2 minutes when resuming) and then names the pane in a status message telling you to run peon-code rebrief <name>.

A role pane's brief is the role file's body (its frontmatter stripped) followed by roles/protocol.md with the duties sections of the other types cut out, so the board and message rules appear once per brief and never inside a role file. The frontmatter type: selects the ## <Type> duties section that stays, and the roster line shows every pane's type and description so each agent knows who dispatches, who works, and whether a reviewer exists. A roleless pane carries no protocol, so the shared brief spells out the claim, alert, completion, and stale-row rules for it; a role pane gets those only from the protocol, and the shared brief keeps just the send mechanics, the board header, and the numbered rules that are not about coordination. The config reader rejects a role file without a type and a team with roles that lacks a manager-type, a worker-type, or a reviewer-type role; a roleless CLI team skips the check.

Write-ups

A writer never edits code: it reads the code for a single row inline, uses one subagent per row when it holds two or more rows, and writes one markdown file per subject under innovation_summary/, which the launcher adds to .gitignore in a git working directory. The role file fixes the shape of the output so the reviewer can judge it without a diff or a check to run: each innovation is one section with four labeled segments in order, Problem (what the code has to solve), Importance (why it matters and what it costs to get wrong), Innovation (the mechanism and the alternative it rejects), Implementation (how the code does it, with file paths and line numbers). The reviewer reads the output file directly, verifies every cited path and line, and fails the row when a segment is missing or the write-up tours files instead of naming a technique.

Git denial

Only the main agent launches with full git. Every other agent launches with commit and the destructive git commands denied (git reset, checkout, restore, switch, clean, stash, rm, commit, branch -D/-M/-f/--delete, push, rebase, reflog expire, update-ref, filter-branch, gc): a claude pane gets a --settings deny file that also binds every subagent it spawns; other CLIs have no launch-time permission file, so they get a hard prohibition line in their brief, which their subagents do not see. Denying git commit on every other pane keeps commits in the main pane, which is what the shared brief rule tells the agents. The deny rules cover Bash commands only, not file writes, and a pane can rewrite the deny file or relaunch its CLI without it. The deny file goes only to a pane whose command starts with the literal token claude. The deny rules match literal command prefixes, so they do not block git invoked with a global flag such as git -C <dir> reset or git --work-tree=<dir> clean. A claude pane whose config line already passes --settings keeps that file and gets no deny file, and a pane passing --dangerously-skip-permissions gets the deny file but ignores it; both cases print a line to stderr at launch.

Agents draw on the normal screen so tmux keeps their scrollback for the browser's deeper captures. Codex gets --no-alt-screen appended after its own arguments unless that exact token is already there (lib/cli/codex.sh:5-8). Claude reads only one --settings file and a second would be lost (comment at lib/cli/claude.sh:6-7), so the "tui": "default" key rides inside whichever file the pane already gets. At load, lib/launch.sh writes both files under the session's mktemp brief directory: deny-git.json carries tui next to permissions.deny for every non-main claude pane, and normal-screen.json holds only {"tui":"default"} for the main pane, which needs no deny rules (lib/launch.sh:91-103, lib/cli/claude.sh:16-17). A pane that passes its own --settings gets neither file and a stderr warning that it may stay fullscreen. Nothing under ~/.claude is written. Other CLIs get no normal-screen flag, so in alternate-screen mode only their visible screen is captured.

Terminal title

The session sets set-titles for itself, so the terminal tab caption is <session> : <directory>, the directory being the base name of the active pane's current one. Other tmux sessions keep their own title setting.

Resume lookup

Each brief carries the phrase agent <name> of peon-code session <session>,, trailing comma included, so one session name that is a prefix of another never matches the other's transcripts. resume searches each CLI's own transcript store for that phrase within the first 20 lines of a file (head -n 20 | grep -F), newest first, over the last 30 days: every *.jsonl file under ~/.claude/projects/<path>/, ~/.codex/sessions/, ~/.copilot/session-state/, ~/.gemini/tmp/<sha256 of the working-directory path>/chats/, and ~/.qwen/projects/<path>/chats/, where <path> is the full working-directory path with every character other than letters and digits replaced by -. The codex and copilot stores hold every directory's transcripts, so a match there must also record the current working directory. The marker is unique per agent and session, so every pane reopens its own conversation rather than same-CLI panes landing in the newest one. A subagent's transcript can quote the parent's brief, so last_thread_file (lib/resume.sh) first asks the provider's optional own_thread concern about each candidate: claude rejects any path under a subagents/ directory, and codex rejects a file whose first line carries "thread_source":"subagent" or a "source":{"subagent" object, which covers subagent forks and guardian reviews. Resume, the watcher, the crash and swap restarts, and the usage report all go through last_thread_file, so none of them takes a child thread as a pane's own.

Context watcher

The launcher backgrounds peon-code watch <session> <tokens> with nohup before it attaches; the loop ends when tmux has-session fails. Each watcher tries to record its PID in /tmp/peon-code-watch-$UID/<session>.pid, with the directory and file created under umask 077. A failed write does not stop the watcher. Before starting a replacement, the launcher checks the recorded positive PID with ps -ww -o args= -p <pid> and sends TERM only when the full command identifies peon-code.sh watch for the exact session. On normal exit, a watcher removes the PID file only if it still records its own PID. Each watcher also writes its PID to the @peon_watch_pid session option at start and exits on a scan that finds another PID there, so after a resume recreates the session under the same name the newest watcher owns it and an older one stops within one scan. A scan runs every PEON_WATCH_TICK seconds (default 5); a floor of PEON_WATCH_MIN seconds (default 60) applies to both the shared transcript re-read beat and cached-path lookups; tests override both. Each scan lists the agent panes, reads the @peon_bin pane option the launch set, and for a claude or codex pane finds the transcript with the same marker lookup resume uses (last_thread_file). The lookup runs on the pane's first scan. An empty result is retried on every scan; a cached path is refreshed once the floor has passed since the last successful lookup. That lookup returns the newest marked file, so after a clear or a relaunch, which start a new transcript with the same marker, the watcher moves to it within one floor. When the lookup returns a path other than the cached one, the new path replaces it and the pane's last-read entry is dropped; an empty result clears the path and skips the pane until the next scan. A pane gets a full read on the scan that caches a new path and retries every scan until context tokens or usage appear. After that, it is re-read only when wc -c reports a changed size and the shared beat is due. Each scan takes one timestamp before reading the panes. When that timestamp is at least one floor after last_beat, every changed pane is read in that pass; after the pass, last_beat is set once to that timestamp, even if no transcript changed. A pane's startup read does not reset the shared beat. A transcript that stops changing is never re-read after its first record. The transcript read state lives in three newline-separated shell variables inside cmd_watch: files holds <pane> <path>, lookups holds <pane> <epoch> of the last successful lookup, and reads holds <pane> <size> of the last full read with context tokens or usage. A full read writes the codex weekly figure (below) and takes the context size. The context size comes from the last usage record in that file: claude's assistant messages carry usage with input plus the two cache fields, codex's token_count events carry last_token_usage.input_tokens; both are the full request size the window limit applies to. A pane at or above the threshold is compacted through the compact subcommand, run with TMUX_PANE cleared so the watcher's own pane is never taken for a target, and is disarmed only after the slash command is accepted, until a later reading falls below the threshold, so a pane whose post-compact floor stays above it is compacted once, not on every full read. The watcher logs the compaction note only after compact succeeds. Failed attempts stay silent and drop the cached read so the next tick retries without waiting for a transcript change or the read beat. The screen is read for the working row and pending-brief readiness; context size still comes from the transcript. A scan with a positive compaction threshold also calls the optional compact_count hook when the transcript size changes, before the gate for full transcript reads. Claude counts lines with subtype equal to compact_boundary through grep -c. The compacts list stores <pane> <size> <count> and resets on a new transcript path or a pane restart. Its first read seeds the count. A later increase consumes and clears @peon_compact_owned without another brief when peon-code issued the compact, whether from the watcher, a terminal command, or the browser. A boundary with no ownership mark sets @peon_rebrief_pending to 1, including CLI auto-compaction or /compact typed directly in the pane. The next idle tick sends the pending brief through the existing retry branch. A CLI that does not log usage is named once and left to its own compaction.

The launcher starts the watcher when compact-at is above 0 or restart-dead is yes, and records the restart setting as the session option @peon_restart (1 or 0) right after it creates the session; the watcher reads that option once at start, so the watch command line still carries only the threshold. With a threshold of 0 the scan runs only the dead check and skips every transcript lookup and full read. In restart mode a scan lists every named pane, not only those running an agent, and keeps a states line of <pane> <count> <hash> per pane. A pane gets that line on the first scan whose pane_current_command is not a shell, so a pane whose agent never started is never restarted. On a later scan where the command is a shell name or systemd-run, the watcher hashes the visible screen with cksum of tmux capture-pane; the count resets to 0 when the hash differs from the last one and then goes up by 1, so a shell screen that holds still on two consecutive scans reaches 2 and marks the agent dead, and a screen that keeps changing, such as a user typing, never does. The command to run is the @peon_launch pane option, which launch_agents sets once per pane from launch_command with an empty resume id, so it never holds one; for codex it carries the brief through the prompt argument, for claude it does not. watch_restart then looks up the newest marked transcript with last_thread_file, limited to files newer than a reference file stamped with the session's creation time so a previous session's transcript is never resumed, and takes its id with resume_id. When an id is found and the provider's resume_args returns arguments, they go right after the binary in the stored command and the restart is a resume; otherwise the stored command runs as it is and the restart is fresh. Under the pane's delivery lock, watch_launch_locked sends C-u to clear any partial shell line and calls launch_command_locked from lib/delivery.sh, which pastes the command through paste_only, sleeps one second, and sends Enter; keys typed in that second can still join the command. The watcher then waits in wait_agent_ready, loads the brief from the @peon_brief file, and calls the provider's brief_after_start with the same globals the launch sets, so a claude pane answers the resume picker and gets its brief pasted, and a codex pane, whose brief is in its command line, needs no hook. The restart does not reuse the systemd scope line; the command goes into whatever shell the pane shows, which after a scoped launch is the nested login shell, though that the agent stays in the scope is not tested. Restarts run inside the serial scan, so a slow one delays the scan of the other panes. The cap lives in two more variables in cmd_watch: restarts holds <pane> <epoch> per attempt, and capped the panes already noted. Before an attempt the watcher drops entries an hour old or older and counts the pane's rest; at 3 it shows the cap note once and leaves the pane at the shell. Each attempt is recorded before its result is known, so a failed restart counts, and the window rolls, so the pane is tried again once its oldest entry ages out. An attempt also resets the pane's count to 0 and drops its cached transcript path, read, lookup, and disarm entries, so a restart that fails and leaves a shell is retried after two more still scans. A new watcher starts with empty counts. Both notes go through tmux display-message to the first attached client, or to the watch log when none is attached.

Swap

cmd_swap (lib/commands.sh) checks its three fields against ^(-|[A-Za-z0-9._=/][A-Za-z0-9._=/-]*)$, finds the pane by @peon_name, and reads the pane options the launch set: @peon_cmd holds the full agent command, @peon_bin its binary, @peon_main 1 for the main pane and 0 otherwise (it picks claude's normal-screen.json or deny-git.json settings file), and @peon_launch the launch command without a resume id that the watcher relaunches. A CLI can be swapped only when its provider defines the optional swap_args concern; cli_call returns 1 for any other CLI, and only claude and codex define it. For the same CLI, cli_swap_args (lib/cli.sh) splits the stored arguments into shell words in awk, keeping quotes, escapes, and command substitutions literal, drops only the provider's model and effort flags in their separate-word and = forms (an attached form such as -mgpt-5 is kept), and appends the new ones through swap_args; for a different CLI the arguments start empty. The launch line then comes from the new provider's launch_command as at launch.

Before any change, swap_reserve_locked claims the pane under its delivery lock by setting @peon_swap_owner (the job's log path), @peon_swap_identity, @peon_swap_started, and the status text @peon_swap to checking <command>, and refuses a pane another live swap holds. swap_start_job starts peon-code.sh __swap_job detached with setsid, or nohup without it, so the caller returns at once. The job re-runs itself inside systemd-run --user --scope -q -p OOMPolicy=continue when that works, so the out-of-memory policy of the caller's pane scope does not reach it, and publishes its pid in @peon_swap_pid. It runs the probe from the optional probe_command concern, claude -p "reply ok" --max-turns 1 or codex exec "reply ok" with the new arguments, from the project directory with stdin closed, in its own process group when setsid exists (and its own scope when systemd-run works); without setsid it is stopped with pkill -P and its pid. swap_check polls it 600 times at 0.1 s instead of calling timeout, which macOS lacks, then sends TERM as above, and KILL after one more second. Only the exit status counts. On failure @peon_swap becomes failed: <detail>, the last stderr line, timed out after 60 seconds, or the exit status, and a status-line note shows it.

On a pass the job writes folder trust with the new provider's trust_project, continuing with a note when that fails, and swap_commit_locked saves the brief and the @peon_cmd, @peon_bin, @peon_launch, @peon_fresh, @peon_weekly, and @peon_usage options to a backup file named in @peon_swap_backup. It rewrites the brief's identity line, roster line, and sender tags, sets the new command options, sets @peon_fresh to 1 only when the CLI changed, unsets @peon_weekly and @peon_usage, writes the marker @peon_swap_pending as waiting <pid>:<epoch> <command>, and sends the optional quit_command concern, /exit for both CLIs, through the same refusal checks as other slash commands. If the pane is not back at a shell within 30 seconds, swap_abort_locked restores the backup and the swap fails. With @peon_restart set to 1, the job then hands the relaunch to the watcher: swap_handoff_locked restamps the marker with the pid in @peon_watch_pid, retrying for 30 seconds, and fails with no live watcher claimed the swap when no watcher is running. With @peon_restart 0, the job calls watch_restart itself.

The watcher accepts a pane with a pending marker at a shell even when it never saw the agent running, and claims the marker as running <pid>:<epoch>:<random> <command>. A marker restart neither checks nor adds to the per-pane cap of 3 restarts an hour. watch_restart_agent skips the transcript lookup when @peon_fresh is 1, so a different CLI starts fresh, and otherwise resumes the newest own thread of the session as a crash restart does, so a same-CLI swap keeps its conversation when one is found; it then delivers the brief through brief_after_start and resets @peon_fresh to 0. watch_swap_finish_locked sets @peon_swap to done <command>, or a failed text when the agent did not become ready with its brief, and clears the marker. A marker is stale when its pid is dead, or when the pid is alive but is neither the session's @peon_watch_pid nor the swap job, whose command line must name the @peon_swap_owner log path. A checking status with no published pid counts as live for 10 seconds from @peon_swap_started. watch_swap_reap_locked runs from the watcher loop, from a new reservation, and before a restart; it aborts a stale waiting marker, restoring the backup when the agent still runs, and sets a failed status. @peon_swap is never unset; the next reservation overwrites it.

The browser posts to /api/swap with pane, identity, cli, model, and effort in a JSON body. web/server.py checks each field against the same token pattern as the shell, requires a CLI other than - to be in available_clis(), the lib/cli/*.sh files that define <name>_swap_args (also served at GET /api/clis), and requires the pane and identity to match a live pane. It runs peon-code.sh swap through run_delivery with TMUX_PANE cleared and a 20 second timeout. It answers 200 with the message on exit 0, 409 with the command's output otherwise, 400 for a field that fails validation, and 503 on a timeout or an OS error. web/bridge.py sends @peon_cmd as agent_command and @peon_swap as swap. web/app.js fills the form from @peon_bin and from the --model, --effort, and model_reasoning_effort= words of agent_command, so a codex -m flag leaves the model field blank. It sends - for a blank or unchanged field, and clears a done or failed state text 5 seconds after it appears.

Add

cmd_add (lib/commands.sh:719) runs in a subshell, so its cd and the launch globals it fills stay local. It takes --session <name> from any position and reads the other words as name, role, and command; command words after the first are quoted with printf %q. The role resolves through resolve_role with the current directory as the config directory, before the cd (lib/commands.sh:741). The session must exist and carry the peon marker.

It rebuilds the launch arrays from the session's panes. Each pane gives @peon_cmd, @peon_role, @peon_role_label, @peon_role_desc, @peon_role_type, @peon_main, and @peon_brief, and the first pane that has one gives @peon_brief_dir. initialize_agent_pane stores the role options and @peon_brief_dir on every pane at launch for this reason (lib/launch.sh:3). From them cmd_add rebuilds each roster line in the pane <id>: <name> (<command>) - <label> (<type>): <description> form the launch uses. It refuses a duplicate name, a second manager-type role, a pane whose stored brief file is missing, and a role agent in a session with no roles (lib/commands.sh:755, lib/commands.sh:764, lib/commands.sh:775).

It then enters @peon_project_dir, runs prepare_launch_settings, splits a new pane into the agents window with PEON_CODE_SESSION set, and reapplies the main-vertical layout (lib/commands.sh:820). The new pane gets initialize_agent_pane and a launch_agents call limited to its index (lib/launch.sh:140). Its brief file is pane-<id>.md, named from the stable pane id instead of an index (lib/launch.sh:77), and it holds the full roster with the new line. If the launch fails or the agent never becomes ready, the pane is killed and the command exits nonzero (lib/commands.sh:831-833).

On success, each existing brief file gets New teammate, add to the roster above: <roster line> appended through a mktemp copy and mv (lib/commands.sh:835-839), so rebrief, compact, clear, and a watcher restart send the updated roster. Each existing pane then gets [from peon-code] New teammate: <roster line> through cmd_send; a refused send prints a note and does not fail the add (lib/commands.sh:841). Last, when the session's @peon_compact_at is above 0 or @peon_restart is 1, it reads @peon_watch_pid and checks that process's arguments with ps. It starts peon-code.sh watch <session> <threshold> with nohup only when no watcher for that session runs (lib/commands.sh:876-880).

The launch stores the config it read as the session option @peon_conf (lib/launch.sh:224). peon-code.sh makes that path absolute, and sets it empty when agent commands on the command line replaced the config or no config file exists (peon-code.sh:231-235). --save (lib/commands.sh:726) picks its target before the new pane starts (lib/commands.sh:778-816). The target is @peon_conf when that path is inside @peon_project_dir, is still a file, and is not the user config $HOME/.config/peon-code/peon-code.conf (lib/commands.sh:779-783). Any other case sets rebuild and targets <project>/peon-code.conf, which must not exist yet, not even as a symlink (lib/commands.sh:787-788). When the target file does not exist, each current pane's @peon_role_label must name a shipped role or a file in roles/ next to the target, and saved_roles records that label, or - for a pane without a role type (lib/commands.sh:792-801). A role given as a path that is not a shipped file is saved under its label, which must match [A-Za-z0-9_-]+ and must not be a shipped name (lib/commands.sh:805-809). Its copy target is roles/<label>.md next to the config, and an existing target must match it byte for byte under cmp -s (lib/commands.sh:810-813).

The write runs after the launch and the teammate notes (lib/commands.sh:845-870). It repeats the existence checks, so a file that appeared during the launch is still refused. A failure in this step exits nonzero and leaves the new agent running, unsaved. It copies the role file with mkdir -p and cp only when no identical copy exists (lib/commands.sh:853). It builds the new config in a mktemp file next to the target and moves it over the target, so the result has the temp file's private mode (lib/commands.sh:856). An existing config is copied first, with a newline added when its last byte is not one (lib/commands.sh:859). A rebuilt config instead gets one name command role line per existing pane from NAMES, CMDS, and saved_roles, with * on the MAIN index (lib/commands.sh:861-865). The new agent's line follows, and @peon_conf is set to the target, so a second --save appends to the same file (lib/commands.sh:869).

Usage report

print_usage in lib/session.sh backs both usage and dismiss, which prints it before tmux kill-session. Token counts are an optional provider concern, usage_tokens: called with no arguments it returns 0 when supported, and the cli_call fallback returns 1 for a CLI without it, which the report shows as no usage logged. Called with a transcript path it prints four integers, input, cache read, cache write, and output, or nothing when it finds no usage. The transcript comes from the same marker lookup the watcher uses: last_thread_file with agent <name> of peon-code session <session>,, run from the pane's current path. That lookup returns one file, the newest own transcript modified in the last 30 days with the marker in its first 20 lines, so an agent's earlier conversations are not counted. cli_usage_sum (lib/cli.sh) adds the threads that file spawned: it parses the own file, then every path the optional child_threads concern prints, skipping a child that has no usage and any path already counted, which a list of seen paths compared one by one enforces. claude_child_threads lists agent-*.jsonl under <session id>/subagents/ beside the own transcript. codex_child_threads reads the session_meta line of every *.jsonl under ${CODEX_HOME:-$HOME/.codex}/sessions modified in the last 30 days, takes its payload.id and parent_thread_id, and walks breadth first from the own thread's id to depth 5, keeping its own seen list so a thread reached twice is listed once. The search covers the whole sessions tree, bounded by the 30 days and the depth, not by date directory. The context size and the weekly limit still come from the own file only. Claude's transcript repeats a request's usage across the lines of one response, so claude_usage_tokens keys each usage object by its requestId, keeps the last one per id, and sums over the unique ids: input_tokens, cache_read_input_tokens, cache_creation_input_tokens, and output_tokens. Codex's token_count events carry a cumulative total_token_usage, so codex_usage_tokens takes only the last one. Its input_tokens includes the cached part, so the parser subtracts cached_input_tokens from input and shows that part as cache read; cache write is its cache_write_input_tokens, which current codex transcripts record as 0, and a missing key also prints 0. Both parsers scan lines with awk patterns rather than parsing JSON. The per-CLI totals are summed in one awk pass over the agent lines.

Weekly limit

The @peon_weekly and @peon_usage pane options are the two hand-off points between the bash side and the browser. @peon_weekly holds <used percent> <reset epoch seconds>, such as 42.0 1760000000, with the percent held to one decimal and clamped to 100 by each writer. There are two writers. For codex, each full read of the watcher calls the optional weekly_limit provider concern on the transcript it already found: codex_weekly_limit takes the last token_count line, looks at its rate_limits primary then secondary entry, and uses the first whose window_minutes is 10080, printing used_percent and an integer resets_at. The cli_call fallback returns 1 for a CLI without the concern, and the watcher writes @peon_weekly only for a non-empty result, so it never clears a value another writer set. For claude, the launch reads .statusLine.command from ${CLAUDE_CONFIG_DIR:-$HOME/.claude}/settings.json with python3 and adds a statusLine entry to both generated settings files, normal-screen.json and deny-git.json, whose command is peon-code.sh statusline followed by the shell-quoted user command; the entry holds only type and command, so other keys of the user's statusLine, such as padding, are dropped. Only that user settings scope is read; a status line set in project or local settings is replaced by the wrapper and not run. Without python3 no statusLine key is written. A pane that passes its own --settings gets none of the generated settings, the wrapper included, and the launch says on stderr that its weekly limit is not recorded. cmd_statusline (lib/commands.sh) reads all of stdin, keeping trailing newlines, takes used_percentage and an integer resets_at from the last "seven_day":{...} object with sed, writes @peon_weekly on $TMUX_PANE, then runs the user command with sh -c on the same stdin bytes and leaves its output and exit status untouched. web/bridge.py reads @peon_weekly as one more list-panes field and accepts it only when the percent is finite and between 0 and 100 and the reset is all digits; anything else sends weekly: null. A reset time already past reads as 0% used, since the limit has reset but no new reading has arrived. web/bridge.py also sends the @peon_bin pane option, the first word of the agent command that the launch set, unchanged as the cli field, an empty string when the option is unset. web/app.js appends <cli> weekly <100 minus used>% left, resets <local time> to the card meta line, without the <cli> prefix when cli is empty, and nothing when weekly is null.

@peon_usage holds <input> <cache read> <cache write> <output>, four integers separated by single spaces. Its one writer is the watcher: each full read runs cli_usage_sum on the transcript it already found, the same own plus child sum peon-code usage uses (print_usage above), and writes the option only when the result matches four digit runs, so a failed parse leaves the last value in place. The watcher moves to a newer marked transcript within one floor (see Context watcher), so after a clear the option describes the new conversation once that transcript holds a usage record; until then the last value stays. web/bridge.py reads it as one more list-panes field and sends usage: {input, cacheRead, cacheWrite, output} when it splits into exactly four all-digit fields, otherwise usage: null. web/navigation.js sums the four and labels the sidebar tab <name> · <total>, or <name> alone when usage is null. The unit is chosen by the rounded value, so the M cut is 999950 and the B cut is 999995000.

Update check

On a start or resume, offer_update in lib/config.sh asks for the newest commit of the upstream branch with git ls-remote, which changes no local ref. It asks PEON_UPDATE_URL (default https://github.com/UnaryLab/peon-code.git) first, then the clone's own remote when that fails or replies empty, printing a notice naming both. The public probe is always capped at PEON_FETCH_TIMEOUT (default 3 s) with GIT_TERMINAL_PROMPT=0; the remote probe is uncapped and may prompt only when stdin is a terminal and the mode is not stdio; otherwise it has the same cap and GIT_TERMINAL_PROMPT=0. The cap is a portable poll loop (kill -0 every 0.1 s, then TERM, then KILL after 1 s), not coreutils timeout. Both failing prints a failure line and starts the current version. There is no offer when the answer equals HEAD, is an ancestor of HEAD, or is a local commit that HEAD does not precede (diverged); a hash missing locally still gets an offer, and an ff-only pull then fails on divergence. The offer reads update available upstream; pull now? [y/N], or a JSON line in stdio mode, which web/launch.py turns into a prompt on the Mac for --ssh, after the Mac's own check. The pull is pull --ff-only from the source that answered: from the URL with an empty --refmap so no tracking ref moves, or from the remote followed by update-ref of the upstream ref. A y pulls, prints updated; starting, waits three seconds (PEON_UPDATE_PAUSE overrides it for tests) so the outcome is read before the screen moves on, and restarts the launcher with the same arguments, because bash reads the script lazily and a file rewritten under it can execute a mix of old and new lines. The check is synchronous, so a start waits up to the cap per capped probe, and a push shows up on the next start. The other subcommands never touch git.

Task board protocol

The board file opens with a header stating the editing rules, above a table of id | who | task | files | status: one row per task, edited in place by its owner; ids (T1, T2, ...) are assigned by the row's creator and never reused; the status cell holds exactly one of in progress, done, reviewed pass, reviewed fail, and a status change overwrites the cell rather than appending to it; findings travel by message, not on the board. Because the header rides in the file, agents re-read the rules every time they read the board, so the rules survive a clear or compact. The row is the claim: an agent writes its claim before starting, sends no start message, and does not touch files another agent listed. Messages are alerts, one line naming the row id (T3 done, T3 blocked: ...); the board is the record that lasts. Edits that fall at the same moment, like several claims at launch, go in one edit, but a status change is never delayed to batch it with another. An agent sets its own row to done before sending its completion message; a message never substitutes for the row edit. A row the reviewer sets to reviewed fail goes back to its author: the author sets it to in progress for the rework and to done when the fix is ready, and a row back at done counts as unreviewed and gets a fresh review. The main pane's brief adds a verification duty: on a completion message it checks the sender's row and sets it to done itself if the sender did not, before dispatching new work, and it dispatches with one message per agent listing that agent's row ids. Once the reviewer records reviewed pass it deletes the row; the deletion is the acknowledgment, so the board lists only open work. After a clear, compact, or rebrief, an agent trusts a row only if its status matches reality, and confirms with the owner before redoing work a row shows as still open but that already looks done.