Skip to content

The sb server

The sb server is the Straw Boss backend as a process of its own, so that the towers, the link holder, and the page do not depend on which pi session happens to run them (spec). It owns the process record, the control socket, the log, the connection to the intercom broker, and the GUI server (the page, its API, the dialog hub, remote access, the stash and close routes). The link holder runs in the primary server (the link holder); the towers run in it as the tower service, on by default ("towerRuns": { "enabled": false } in config.json keeps them resident pi sessions instead).

In this section: what it is, starting and restarting, states for a shell, desk mode; then the tower service: the service, the standing pane, when a main calls the tower, local runs, the boss conversation, tower memory, and ally runs.

One server per slot: a (state directory, Herdr socket) pair. The slot of the default Herdr is the primary and keeps its files in <pi agent dir>/straw-boss/; a pi on another Herdr socket uses a secondary slot under servers/<first 16 hex of sha256 of the socket>/, whose server holds no link; the first 8 hex of that hash (slot8) names its tower identities (sb-tower-<slot8>-w1) when towerRuns.enabled puts the towers in the server. The server is dist/server/cli.mjs and main.mjs, built from src/server/ by npm run build:server (esbuild, run by prepack). The build fails on a value import of @earendil-works/pi-tui, typebox, or @earendil-works/pi-coding-agent, which are peer dependencies that are not on disk where the server runs; type imports are erased.

Details

The one runtime dependency, tsx, is resolved from this package (its ESM entry dist/esm/api/index.mjs, read from tsx’s exports: tsImport run from the CommonJS entry fails on Node 22.22.1 and 22.22.2 and works on 22.23.4 and 24) and used only through its scoped tsImport() to load pi-intercom’s TypeScript client (pi-intercom 0.18.1 or newer, checked at start: this release requires it, and an older pi-intercom stops the server from starting), so the server’s own code is never transformed; a load that fails logs one line naming the Node version. The server connects to the broker as sb-server-<8 hex> through the headless kit and leaves 60 s after its Herdr stops answering.

Files in the slot’s folder, all mode 600 in a 700 folder: server.json (the record: pid and start time, version, mode detached or serve, whether the desk switch is on, the control socket or Windows named pipe and its hello secret, the PI_INTERCOM scope and transport the server was started with, stopped, restarting {began, since, reason, by}, lastRestart, lastError), server.lock (holder pid and start time; a dead holder loses it at the next look, and a lock older than 120 s is taken even from a live holder), start-error.json (why the last start failed: loader, intercom, or acl; cleared when a start attempt begins and after hello), openings.json (the workspaces the page’s open/base made, by opening id), server.log (5 MB, 3 files; events and errors only, never message text or secrets), and server.sock (a socket path over about 100 bytes does not fit sun_path, so then the socket is /tmp/sb-<uid>/<hash>.sock, in a private folder of the user’s own; a server never unlinks a socket that still accepts a connection).

The control socket answers only a connection that opens with the secret (compared in constant time); any other is closed with no answer. On Windows the state folder’s ACL is set to the owner and SYSTEM, protected, and checked by SID in one PowerShell call before the server starts (a failure is acl in start-error.json); the call prints ASCII-only JSON, so an account name in any code page reads back, and it also tells the server’s own start time, so the server makes no other PowerShell start.

None of the PowerShell scripts on the server’s start path (launch, ACL, owner read, start time, process table) calls a cmdlet of a module (Get-CimInstance, Get-Acl, ConvertTo-Json, Test-Path, New-Object and the like): the first one in a fresh Windows PowerShell 5.1 builds its module cache, 41 to 48 s on a cold runner, which is most of the 60 s a start waits for hello. The scripts use .NET calls, [wmisearcher] for a start time, and a small JSON writer instead (the LAN setup of firewall.ts is off the start path and still calls its network cmdlets). Start times are computed from the CIM datetime string itself, so a DST fall-back hour reads right.

Constrained Language Mode is not supported: the ACL script refuses it, and the start-time and process-table reads need FullLanguage. The folder is protected first, without /T (which would leave the files already in it, the lock and the log, with an empty ACL), and everything below it is then reset to inherit from it by one icacls <dir>\* /reset /T /C /Q, so the cost does not grow with the number of files; the read-back includes every slot’s server.json. The server does not wait for its own start time to answer hello (elsewhere it fills it into the record when it arrives), and the lock does not wait for its holder’s.

Whoever starts a server writes restarting (reason starting) into the record before it spawns and touches it until the server answers, so status says restarting during a slow cold start and never gone; a start waits 60 s for hello on Windows (20 s elsewhere). server.log has one phase line each for the loader, the ACL call, and listening, and start-requested, start-answered, and stop-done lines with their times. A connection that sends a few KB without a complete hello line is closed. server.log is rotated by one writer at a time (server.log.lock), as the server, sessions, and the CLI all write it.

On macOS and Linux the detached server leads a session of its own, so killing the starter’s process group does not end it. On Windows detached: true does not leave the starter’s job object, and a pane or an ssh session may hold its children in a kill-on-close job, so the server is created through WMI (Win32_Process.Create, in one PowerShell start) with CREATE_BREAKAWAY_FROM_JOB, hidden and with no console, and with the starter’s whole environment as Win32_ProcessStartup.EnvironmentVariables (nothing is written to disk).

Details

The same PowerShell start makes the server’s ACL check (the start time of the new pid, the reset and read-back of the state folder) and leaves it in a file only the server’s --acl-token names; the server checks every SDDL itself before it writes its secret, so the launch and the check are one PowerShell start, not two (serve and the plain-spawn fallback still make their own); with the owner read before the first file is created, a cold start on Windows makes two (one if this process has already read this folder’s owner, as a pi session restarting a server has). That start is the log phase breakaway, with createMs and aclMs.

When WMI cannot start it, a plain spawn does, and that is said: up warns breakaway_failed, and status, boss_server status, and /boss server show that the server ends with the pane or ssh session that started it. A PowerShell that does not answer in time, or whose answer is missing after WMI may have made the server, is a failed start, with no second one; once WMI made the process, a failure of the check’s marker only makes the waiting server end with acl at once.

A PowerShell that dies before it reaches the create (a language mode that refuses a method, an AMSI or EDR block) is told apart by a line the script prints before the create, and falls back to the plain spawn, visibly. PowerShell failure texts are read from the error records of its CLIXML stderr, never shown as XML; the calls are started with -OutputFormat Text and the progress stream off. Known: a Win32_Process.Create that throws after WMI made the process still falls back (an RPC drop), and the second server then ends on the lock.

The state folder is read (whoami, icacls /save, no PowerShell) at every start before the first file in it, the lock, the placeholder record, or the log, is created, and protected and read back when its permissions are wrong (a new one is made under a temporary name and protected before it takes its name); a folder that is a link is refused.

A folder that is not there is made under an unpredictable name beside it, protected and read back there, and renamed into place, so the server’s own creation of straw-boss\ never stands under its name with the parent’s broad permissions (other modules, such as the board, stash, link and handover code, still create that folder with a plain mkdirSync when they get there first; that code is older than this and is known). The temporary folder stands for 35 to 130 ms with the parent’s inherited permissions under a name nobody looks for; the final name never does.

The rename is tried again for about two seconds when a scanner holds the folder, and a temporary folder a crash left is removed after a minute. The launch script also reads the starter’s own start time, and nothing else asks for it; a cold start’s other PowerShell is the owner read. A server that runs but refused this process for lack of permission (an elevated server seen from a filtered token of the same user) is shown as live and unreachable and is never started over or marked stopped; stop says to stop it from an elevated shell.

stop, status and a second up start no PowerShell beyond that one owner read: a server that answers hello is proof enough. Constrained Language Mode is told as that. The control connection proves that the server knows the secret (a keyed answer to a fresh nonce), and a refusal for lack of permission counts only for the process the record names.

The scripts walk what is below the state folder ONCE, without following links, reading each entry’s link type and owner as they go (the read-back reuses the list, and reads each SDDL through .NET rather than one Get-Acl per file: 3,000 files in link\inbox had made the check take 7 to 9 s), and refuse a junction or symbolic link (cloud placeholders such as OneDrive’s are walked as ordinary files); every folder below, and the files the read-back names (the five top-level files, every file under link\, each secondary slot’s server.json), must be the user’s. A refusal lists the first five problems and how many more.

No native call changes an owner (a successful /setowner would take over a folder another account made and granted the user the right to take; the temporary folder of a new start could be swapped for such a folder): an existing folder is read with icacls /save and left alone when its permissions are already the owner’s and SYSTEM’s, and a wrong one is changed only after one PowerShell has read its owner as a SID and found the user, BUILTIN\Administrators or SYSTEM (and the folder is checked to be the same one right before the call; a swap in that one process start is a documented residual).

The owner is gated again in PowerShell before any secret exists, and the folder’s own owner and every entry’s owner are read before anything below it is changed; the same three owners are accepted everywhere. Before the first file is created in a state folder (an existing one, or a newly made one right after it is made), one PowerShell reads its owner (remembered by the folder’s file identity, so a pi session reads it once): another account’s folder is refused with nothing created or written in it, the reason going to stderr and the JSON of up, not into the folder.

A look at a folder that holds a record (a session’s look, status, up, stop, restart) passes the same owner gate before it asks whether the pid lives, sends a hello to the record’s pipe, or tells anything the record says: another account’s folder is refused with the same acl error, and nothing is sent or written. The read is the memo’s, so a pi session spends one PowerShell on it at its first look (a refusal and a failed read are remembered too, so a look every 2 s starts none), and a shell command, being a new process, spends one; a folder with no record is not read.

A cold up and a stop of a server that is down spend one as before. The read never holds a pi session’s event loop: it is an asynchronous child process (a PowerShell start is 2 to 5 s on the VM), the requests that come while it runs (a look, a link request, the GUI) wait for the one read and get its verdict, and a folder replaced while it was read is read again instead of remembered.

Where there is no ACL (macOS and Linux) the same gate is a stat of the state folder, the slot’s folder and the record, before the record is read: one that another uid owns is refused with the same acl error and the same words (“belongs to another account”, exit 3 in a shell), and no process is started. The walk and the reset calls are not atomic: an entry planted between them is a known, bounded residual. A start refused because the folder belongs to another account writes nothing: the reason is in the error and the JSON of up (a folder that is not ours gets no start-error.json).

A record is no longer trusted without the owner read (R13-F003, closed before the server carries session content). icacls /T /L descends a junction, so the refusal of junctions in the walk is what protects their targets; a junction planted between the walk and the calls is a known, bounded residual. An account that can write the parent of the state folder can always win the swap of the temporary folder and make the first up fail (nothing is taken over). A server that cannot protect the folder writes start-error.json, so up exits 3 with acl. A server.lock that cannot be opened is repaired with icacls /reset once.

The stopped record of a server that is gone does not pin the pi-intercom scope for the next start. The folder’s exact owner (O:) is checked in PowerShell: before the server is created (a folder another account owns ends up with acl, exit 3) and again before any secret is written; the folders below are made the user’s and checked too. A failing icacls is told by a fixed message for its step and exit code with what to do (for a folder an elevated run made: run pi once elevated, or delete it), and the program’s own text only as printable ASCII detail.

The system’s programs (powershell, whoami, icacls, cmd, clip) are called from %SystemRoot%\System32 by absolute path, and every other program the package starts (herdr, git, tailscale, ps, sh, the opener and clipboard commands) is found by a PATH search that skips empty, ., and relative entries and never looks in the current folder, then spawned by the path found, so a git.exe planted in a repository is never run.

The owner gate costs PowerShell starts on Windows

Section titled “The owner gate costs PowerShell starts on Windows”

Before a request trusts a record, or writes into an existing state folder, one PowerShell reads the owner of the state folder, the slot’s folder and the record (a foreign owner is refused with exit 3 and nothing is sent or written; a read that runs out of time is a wait, exit 1, and is read again). A pi session reads it once per folder identity (its first look; later looks cost none); a shell command is a new process and reads it itself.

Details

Each read is followed by one icacls /save that checks the folder is closed (the owner’s and SYSTEM’s DACL only): a folder that is open is protected right there (icacls /inheritance:r /grant:r, then another /save; any other explicit grant it still shows, such as the BUILTIN\Administrators ACE of a folder an elevated token made, is taken off by SID with icacls /remove:g *<SID> and the folder is read once more, the check itself unchanged), and then remembered.

If that protect fails it is told once (a process warning for cli.mjs; in a pi session the look tells it once, since pi silences process warnings), and the folder is read again at most every 60 s (at once when a record in it changed identity), not at every look. Counted per command, in PowerShell starts and (in brackets) icacls /save calls (a count pinned by fake runners; the Windows time is not measured): a session’s first look at a folder with a record 1 [1], later looks 0; status 1 [1] (and a whoami); stop of a live server 1 [1] (and a whoami); a second up 1 [1]; restart of a live server 2; a cold up 2 [2: the gate’s, then secure’s DACL read; a new folder is protected and read back before the gate, so its gate adds one more]; an open folder adds one protect call and one more /save to its first read.

Before the gate status, stop and a second up cost none, so a shell that polls status on Windows pays one start (2 to 5 s) per poll; this is accepted. Where there is no ACL the gate is a stat of the owner (a link’s own owner first, then its target’s; root counts as the user’s), and costs no process. A folder that a sudo run made and the user cannot use is told as that (sudo chown -R it, or remove it with sudo).

A pi session with a screen in Herdr looks at session start and every 2 s: when no live server runs for its slot, it starts one detached (detached: true, stdio: "ignore", windowsHide: true) from its own package, under server.lock, and holds the lock until the server’s hello. The environment it builds drops the session’s identity (PI_INTERCOM_SESSION_ID, PI_INTERCOM_STABLE_ID, PI_SUBAGENT_*, STRAW_BOSS_ROLE) and keeps PI_INTERCOM_SCOPE_ID, PI_INTERCOM_TRANSPORT, and PI_INTERCOM_TCP, which the record keeps; a session on another scope or transport uses no server and says so once. A failed start is told once with its reason and tried again after a minute. A subagent and a session without a screen start nothing.

Details

Versions. A session never starts a server from an older package than the one that runs; it says so once. A session on a newer package asks the live server to upgrade, which restarts it from the newer package (restart reason=upgrade in the log). Going back to an older package happens only through /boss server restart --downgrade (or cli.mjs restart --downgrade), after a confirm that says it is older.

sequenceDiagram
    participant R as Requester
    participant O as Old server
    participant N as New server
    R->>O: restart, with a reason (holding server.lock)
    O->>O: write "restarting" to server.json, exit
    R->>R: touch "since" every 2 s
    N-->>R: hello
    Note over R,N: a record not touched for 10 s counts as gone

Every restart keeps the server’s port when it is free and the GUI’s token (see Browser view). The requester takes server.lock and asks the server to restart with a reason; the old server writes restarting {began, since, reason, by} into server.json and exits (code 75 for a serve server), and the requester then touches since every 2 s until the new server’s hello (the starting server touches it too), writing only while the record is still the old server’s, so it never overwrites a newer server’s record. A restarting record not touched for 10 s counts as gone.

Details

The requester holds the lock until the new server answers, except for a serve server: its supervisor starts the next serve, which needs the lock, so the requester lets go of it for that wait and takes it again only to start a detached server itself when no supervisor did; if a serve holds the lock past the start budget (20 s, 60 s on Windows) while it starts, the requester reports the restart as done once that server answers, not busy. A server that is down starts whatever the once-a-minute limit says; a serve server that is down while another requester’s restarting is fresh is waited for the same way instead of being replaced by a detached one.

A restart is refused when the requester’s broker scope or transport is not the record’s (serve taking over a detached server likewise), and serve never replaces a server of a newer package (started from an older package, a serve therefore exits 1 each time its supervisor launches it again, under KeepAlive or Restart=always, until the package is upgraded or the newer server is gone). stop is refused from another scope or transport the same way.

Only a restart someone asked for (an agent, /boss server restart, cli.mjs restart) counts for the once-a-minute limit that agents are held to; an upgrade or a serve takeover does not, and the limit is checked under the lock, so two asks at once get one restart. A session judges an upgrade by the version of the package on disk beside the cli.mjs it would start, not by the code it loaded, and treats an upgrade whose new server is not newer than the old one as a failure (told once) instead of asking again at every look; it asks again only when the server’s version or the package’s changes, never as time passes, so a stale dist/ cannot restart the server once a minute.

cli.mjs (node dist/server/cli.mjs …, or npm run server -- … in a checkout, which runs src/server/cli.ts through tsx; a session in a checkout without dist/ starts the server as node src/server/cli.ts, which needs Node 22.18 or newer):

Command Effect
up [--dir FOLDER --label NAME] [--timeout SECONDS] [--exact] Make sure a server runs; print one JSON line (url (the GUI’s, from the server’s own answer once it answers 200), pid, started, version, package, mode, desk, server = found, started, restarted, or upgraded, log, steps (herdr, version, server, gui; with --dir also integration, base, tower), warnings). up is the boss’s own start, so it starts a GUI that was stopped on purpose; remote access that cannot resume is the remote_off warning, and Wi-Fi access that is wanted and could not come back is lan_off. --timeout bounds the waits of the call, the lock wait and the start wait together, and every Herdr call of the desk steps (90 s by default on Windows, 30 s elsewhere), counted from the moment up begins its steps in the running node process: starting node and loading the CLI before that, and leaving it after the answer is written, are outside it (about 0.5 s on a quiet machine, and seconds more on a starved one: with 16 busy processes on 4 vCPU --timeout 5 ended after 11.7 s on the wall, 5.3 s of it the call’s own), so a caller’s own limit on up should be the timeout plus a margin; when a wait runs out the JSON on stderr names it beside step as wait: lock, start, hello, or exit. A running newer server is server_newer in warnings, or exit 1 at step version with --exact. --dir and --label add the desk steps and need STRAW_BOSS_DESK=1 (Desk mode): integration, then base and tower before gui, and workspace, pane, tower, created, integration in the output.
serve Run the server in the foreground, for a supervisor (systemd, launchd). It takes over a detached server on the same port (under the same token); another live serve makes it exit 4.
stop Stop the server on purpose (stopped in server.json); pi sessions do not start it again (their looks leave a stopped server alone), and only a boss command (up, serve, /boss server) clears it. Refused for a serve server, exit 5: stop its supervisor.
status Print one JSON line: state (live, restarting, stopped, gui-stopped, or gone, States for a shell), the record’s facts (never the secret), the GUI’s url, the live server’s uptime and remote access (remote), Wi-Fi access (lan: whether it is wanted and on, the port, the entry url without the token, the served addresses with their interfaces, the count of pending pair requests, and a note), and the last start error when it is gone. Works with the server down.
restart [--reason TEXT] [--by TEXT] [--downgrade] Restart on the same port, under the same token (a --downgrade to a package from before D27 draws a new token and unpairs every device). The reason is 1 to 200 characters on one line; --by names the asker in the log (the GUI’s Restart button uses it).
logs [--lines N] [--follow] Print the log’s tail (200 lines by default).

Exit codes: 0 done; 1 a step failed (one JSON line on stderr); 2 usage; 3 the server cannot run (start-error.json says why); 4 serve found a live serve; 5 stop found a serve server.

After cli.mjs stop the sessions do not restart the server: it was stopped on purpose, and only up, serve, or /boss server start it again. /boss server shows the state and starts the server when it is down, also when it was stopped on purpose. /boss server stop stops it on purpose after a confirm that says every workspace then loses its live tower and mains are demoted 15 s later. /boss server restart [--downgrade] [reason] restarts it after a confirm. The /boss server restart command also clears a stop on purpose (it is the boss’s own start).

Details

The boss_server tool, registered in a joined session with a screen, gives an agent status (works with the server down), logs (lines, at most 200), and restart with a reason of 1 to 200 characters on one line, which goes into the log with the asking session. A restart through the tool is refused while the boss stopped the server on purpose, is taken once a minute for the whole machine, and starts a server that is down. A remote turn cannot call boss_server at any level, dispatch included. That block is on the tool only: a peer at dispatch level still has bash, so it can run cli.mjs stop or restart, or kill the server’s pid. Until the server checks run tokens (the tower steps), the block is not a boundary at that level.

stateDiagram-v2
    state "GUI stopped" as gs
    state "Server stopped" as ss
    [*] --> live
    live --> restarting: restart
    restarting --> live: hello
    restarting --> gone: 10 s
    live --> gs: GUI stop
    live --> ss: stop
    ss --> live: up
    gone --> live: up

A program that shows the GUI (the straw-boss-desk shell) tells the stack’s state from server.json (and gui.json), or reads it from cli.mjs status. restarting.since is touched every 2 s by the server that is starting, so a restart counts as under way only while that touch keeps coming, however long the start takes; there is no fixed overall limit.

Details
State How to tell from the files cli.mjs status What the shell does
live server.json has no stopped and no restarting; its pid is alive and started at started; a GET of the URL answers 200 {"state":"live", …} Show the page.
moved live, but gui.port or gui.token differs from the URL the shell loaded (a taken port, a lost store, or /boss gui rotate changes the token; a plain restart does not) live, with the new url Load the new URL once it answers 200; the old one answers 403.
restarting server.json has restarting whose since is within the last 10 s restarting, with reason Keep the page until the restart ends; it reconnects to the same URL (the token is kept), and a new URL appears as moved only when the token changed (D27).
gui stopped live, and gui.stopped (gui.json’s stopped) is set gui-stopped Say the GUI was stopped; up starts it again.
server stopped server.json has stopped stopped Run up at the next launch (it clears stopped).
gone none of the above: the PID is dead or started at another time, or restarting.since is older than 10 s, or the URL has not answered for 10 s while nothing touched since gone, with lastError and the start-error.json reason when there is one Run up.

On the happy path the shell reads no file at all: cli.mjs up prints everything it needs (url, pid, server, steps, warnings), and cli.mjs status prints the state above as one JSON line. Files are for watching between launches, and cli.mjs logs for debugging.

Migration, in three releases that the shell pins one at a time: S1 (the server ships) writes gui.json under the unchanged contract and server.json as its documented successor, with up, status, and logs, and nothing has to change in the shell; S2 (one minor later) makes server.json the documented contract, which the shell uses through up’s output and status, keeping gui.json as a fallback; S3 (after the shell confirms S2 in its pin) drops the fallback, and gui.json stays on disk for older shells, marked deprecated here, and is never removed without a note in the release. No schema version is added: the shell keeps pinning the pi-straw-boss version.

A shell that shows the page itself (straw-boss-desk) starts herdr server with STRAW_BOSS_DESK=1 and runs one command, which makes the shell’s home base and finds its tower (desk spec, S3 to S6, as the sb server spec, Desk mode: what changes, has them):

Terminal window
node <pi-straw-boss>/dist/server/cli.mjs up --dir <folder> --label <name> [--timeout <seconds>] [--exact]
Details

The GUI no longer waits for a tower pi: the sb server serves the page from its own start, so up returns as soon as the server, the base, and the tower’s standing pane are there. What the shell can count on:

  • The switch is STRAW_BOSS_DESK=1 in the environment that started herdr server, so every pane inherits it, and in the environment of up. up --dir refuses with exit 2 when up’s own environment lacks it, which catches a shell that forgot it; --dir and --label go together (exit 2 otherwise). A server that runs without the switch is restarted with it (server: "restarted"); one that runs with it, found by an up without it, is left alone with the desk_on warning. Nothing changes for a machine that does not set it.
  • The state directory is $PI_CODING_AGENT_DIR/straw-boss/ and no other variable moves it (a second variable that one pane missed would split one machine into two GUIs). Files a shell may read there: gui.json and server.json (the contract, States for a shell), and desk.json, {workspace, dir, label}, the home base (written by rename, mode 600). desk.lock and everything else is for pi-straw-boss.
  • The steps, each named in a failure (one JSON line {step, reason, steps, warnings, log} on stderr, exit 1; the exit codes of up otherwise stand):
    1. herdr: Herdr’s socket answers.

    2. integration: herdr integration status is read, and herdr integration install pi is run unless its pi: line says current (an outdated one is installed again, so a Herdr update brings it along); the line must say current afterwards. Herdr’s install fails (exit 1, “extension directory not found”) when <agent dir>/extensions/ does not exist (a fresh agent directory), so up makes the folder first: the folder of the file Herdr’s own pi: status line names, wherever the environment points. Without the integration Herdr never names a pane’s pi session and the pane stays out of the net, which every session also warns about 20 s after its start. The output says current or installed (the integration_installed warning). Normal mode never installs anything.

    3. version, server: as without --dir (start, find, restart for the switch, or upgrade).

    4. base: under desk.lock (a second up --dir while one holds it fails here with “busy”; a lock of a dead process, or of one that started at another time than the lock says, or older than ten minutes is taken over, one run at a time, as server.lock is; on Windows a process’s start time arrives from a background read that takes seconds, later than a short up lasts, so there a lock whose pid a live process now holds is not told apart from its holder and stays until the ten minutes are up, rather than every busy up --dir waiting for the read), --dir must be an existing folder (make it first, and put it in a visible folder under home such as ~/Straw Boss: a hidden folder is not pickable, so the page’s folder steps in that base would start at home).

      While Herdr lists the workspace desk.json names, it is the base, whatever --dir and --label say this time (a Herdr restore keeps workspace ids), as long as it still matches the record: its label is the recorded one (the label up made it with) and is not just the folder name of one of its panes (Herdr gives an unlabelled workspace that name and follows a cd, so such a label proves nothing), or a pane of it starts in the recorded folder (compared as real paths; case-folded on Windows, which is not verified on a real machine). A base renamed by hand whose panes have all left the folder no longer matches: a new base is made, with desk_base_replaced.

      A tower tab proves nothing, since every workspace’s tower tab carries the same label; so a base whose tower was quit and whose other tabs work elsewhere is still the base and gets a new tower, and a workspace of the person’s with a tower of its own is not taken for it. Herdr numbers a new workspace from the highest id it restored, so a quit base’s id can return as a workspace of the person’s; that one is left alone, a new base is made, and the desk_base_replaced warning says so. A kept base opens its tower in the recorded folder; --dir is checked only when a base is made.

      Otherwise up creates a workspace in --dir with --label, labels its first tab as a tower tab and marks its root pane sb_role=tower, so the base has no stray shell tab, and records it. Its shell gets no Straw Boss environment. A base that cannot be marked is closed again. A desk.json that is no record is told in the desk_json_unreadable warning and the base is made again.

    5. tower: ensure-tower on the base’s workspace (The tower service): the server takes that pane as the standing pane, registers the tower’s identity, and writes the tower token; no pi starts and nothing waits for a restored one (a pi a restore resumes in the standing pane is quit). When up made the base in this run it tells ensure-tower the root pane and the time; the server believes that only when the pane is the workspace’s only pane and the claim is under 30 seconds old.

      The same claim then ends a tower service still registered for that workspace id (Herdr killed with its session unsaved numbers the new base like the lost one), whatever that service did in the pane meanwhile (it may have proven the new base by its glyph, which is any tower’s, typed the prompt there, and shown its stand-in), so the new tower, with the base’s label and folder, gets its tokens at once instead of at the next 4-minute refresh; the lost service’s looks stop at that moment.

      The pane counts as a fresh shell, so on Windows (where Herdr cannot tell an idle shell from a busy one) the tower> prompt is typed into it, only when it is also marked as a tower, holds no agent, and the server has typed nothing into that terminal since (claimGate): the prompt is typed into a pane once, whichever service stands for it. Any other pane is never typed into there. A tower the person closed has dropped its record, so the next up opens a new one in the same base; a base the person quit is made again. It needs the tower service (towerRuns.enabled, on by default): with it off the step fails with the server’s own words.

    6. gui: the URL answers 200 (gui.json as the contract).

  • The output is the JSON of up plus workspace (the base), pane (the tower’s standing pane), tower (its intercom id), created (whether this run made the base or the tower), and integration. A second run prints the same base and created: false and opens no tab; after herdr server stop and a new server, up finds the base by its workspace id and the restored tower, and opens nothing.
  • What the page hides in a desk stack: GUI remote access (the settings page’s Phone & devices) and stash (each base’s Stash, the HUD’s Stashed), as presentation only (the routes stay); /boss gui prints the URL and opens no browser; the folder pickers offer the shell’s native dialog when its preload exposes strawBossDesk.pickFolder (in the page and the session).
  • Stopping is the shell’s: cli.mjs stop, then herdr server stop. Its next launch runs up again. The server up starts is detached and recorded in server.json (pid, started), which is what the shell stops with cli.mjs stop.
  • Straw Link keeps one state directory, so link/ sits beside gui.json; the link holder is the primary server. The home tower works in the shell’s folder, so it holds no team’s messages (delivery goes to a tower whose folder is inside a team’s project); they wait in the inbox until a base in the project is open.

One switch, towerRuns in ~/.pi/agent/straw-boss/config.json: { "towerRuns": { "enabled": false } } (max, the most pi runs at once, default 4, is for the local runs). It is on by default: a missing config.json, or a towerRuns without enabled: false, means on. A config.json that cannot be read or parsed also means on, but the server says so once: an error line with code=config in the log and the status’s lastError, since the file’s own enabled: false is not obeyed meanwhile. With enabled: false, towers stay resident and nothing in this section happens.

Details

The server reads it at start and at every ensure-tower, so changing it needs no restart. Turning it off takes effect for the server within one refresh (4 minutes): it releases its tower identities, clears its tower tokens, and purges every service.json, queue, and ledger; until then a session that finds the live identity’s token still reaches it, and what it sends is queued and never read. A server that starts with the switch off purges the leftovers too. The service stands in for the tower; what wakes a pi for the events it gets is the local runs below (spec, The tower service).

flowchart TD
    c["A promotion, /boss tower,<br/>call_tower from a main"] --> e["ensure-tower<br/>over the control socket"]
    e --> t["Reuse a marked tab,<br/>or make the tower tab"]
    t --> i["Register the tower identity<br/>with pi-intercom"]
    i --> w["Write the tower token<br/>and service.json"]

A workspace’s tower is not a pi any more: opening one asks the server ensure-tower over the control socket. The server reuses a marked tower tab or makes one, registers the tower identity with pi-intercom, and writes the workspace’s tower token and service.json. A sub workspace is answered with its parent’s tower.

Details

openTower (a promotion, a member that dispatches, the two-member rule, /boss tower, call_tower from a main) asks the server ensure-tower {workspace} over the control socket instead of starting a pi; a live tower token is the tower and is not asked about. The server reuses a marked tower tab or a bare one (the radio tower glyph, one shell pane, no agent), else makes the tab with no Straw Boss environment, moves it first, marks its pane sb_role=tower (the standing pane, left at its shell prompt), registers the tower identity with pi-intercom (id sb-tower-<workspace id in lowercase>, or sb-tower-<slot8>-<id> on a secondary slot; name tower · <label>; cwd <slot>/tower/<workspace id>, a folder no pi runs in; herdrPaneId = the standing pane), and writes the workspace token tower and tower/<workspace id>/service.json.

A sub workspace is answered with its parent’s tower. /boss tower is the boss’s own command, so it also starts a server that was stopped on purpose; an automatic open against a stopped server fails with “the sb server was stopped on purpose; /boss server starts it again”.

An attended device wakes one for a teammate

Section titled “An attended device wakes one for a teammate”

When a message from a teammate waits for a project that has no tower, the link holder asks the server to wake the tower of a workspace this computer already has: a workspace with a pane inside the project, or the one the sender named. The sender’s trust must be reply or higher (a member with no link.trust entry is at reply, so every member of the team qualifies; a sender who left the roster is dropped first).

Details

The server never makes a workspace or a base for it, and moves no focus and types into no other pane; the one thing it may make is the tower’s tab in that existing workspace, when the workspace has none (the boss’s ruling of 2026-11-07). With no such workspace it makes nothing and says so. A name the sender wrote that is no workspace of this computer (or is the head or a sub workspace) costs no wake: the holder answers it itself, and so it does when towerRuns is switched off.

One wake runs per project and workspace asked for (by its id), a sender has at most 3 in flight, and the next try waits a minute after the last ended, then two, four, and so on up to 30 minutes (10 for a failed one); a wake that found no workspace is tried again at once when a workspace with a pane inside the project appears. The link log has tower-wake, tower-woke, tower-wake-none and tower-wake-failed (no message text) when the state changes, not at each try. While a wake is queued or runs, and for two minutes after the first that worked, the Link button and pi show no notice for the message.

They show one only when the wake failed (the reason is in plain words), when no workspace exists or tower runs are off (it says what to do), or when a wake that worked has not got the message taken in two minutes. The message then gets its ally run as for any tower the server runs.

An unattended device opens one for a controller

Section titled “An unattended device opens one for a controller”

When a message from a sender at dispatch (the policy file’s trust) waits for a project that has no tower, the link holder asks the server to open one: a workspace of Herdr with a pane inside the project, else a new workspace at the project’s real path, then ensure-tower on it. Only an unattended device does this, never for a sender below dispatch, never outside the project, and never a workspace whose name the sender chose. One open runs per project, and the next one waits 60 seconds after the last ended (tower-open, tower-opened, tower-open-failed in the link log). The message then gets its ally run as for any tower the server runs. An ally run at dispatch opens its main with boss_open_main (the run-tool request), which a run token’s control-socket open-main refusal does not touch.

The tower token and the pane’s sb_role and sort key every 4 minutes (their TTL is 10). The head watch: on one events connection, a change of the head workspace is noted as a digest line in every tower workspace. The opener (boss_open_main) and the close-main ask run in the server for the identity: the identity registers the straw-boss/control channel, so a main’s check that the sender is the workspace’s tower token holder holds, and the opener’s ledger (sender, a hash of sender and request, outcome, tab, main) is kept in tower/<workspace id>/opened.json for 24 hours, so a request already tried is not tried again after a restart. Control commands: ensure-tower, close-tower, open-main, close-main.

A resident tower of an older build keeps its workspace: the service waits while it lives, and a read that fails (the server’s own broker connection is not up yet) counts as “may live”. A live service checks before each publish and yields to a foreign holder of the token. A hand-over whose first ask failed asks once more.

Details

A roles/ record becomes a service only when its pane is in the recorded tab and that tab carries the tower glyph, or the pane carries the live tower’s sb_role=tower mark and runs the record’s own session file (that tab gets the glyph); since 0.75.0 the record and the file alone make none, for a session can write both. Every events reconnection after the first (Herdr restarted) opens the one-minute recovery window, whichever of it and the watch’s takeover comes first. The queue keeps at most 1000 events per workspace (the oldest go first) and none older than 24 hours.

Closing the tower’s tab (or the pane, or the workspace) in Herdr, or close-tower, ends the service: the identity leaves, the tokens go with sb_tower_quit=<members it left> as a quitting tower writes it, and service.json is deleted; the queue stays 24 hours for a tower reopened in that time. A pane or tab list that Herdr fails to answer is unknown, not a tab that is gone: the service stays and nothing is demoted. A server that stops or restarts only releases its identities: tokens and files stay, and the guard counts a tower identity of its own slot as live while that slot’s server.json has a fresh restarting that began less than 30 s ago.

A new Herdr server has no tokens. The server finds each tower tab again by the recorded pane and tab, else (for a minute after the takeover or the server’s start) by a tab labelled with the glyph that has one pane in the workspace, writes the marks again, and registers the identity again when the pane changed. A tab not back within that minute ends its service. The ids prove nothing.

Details

Herdr numbers workspaces, tabs, and panes with short counters that repeat across Herdr servers, so a Herdr that lost its saved session can give the boss’s own new workspace the id the tower’s had (the server would type the tower command into the boss’s shell, mark it, and start a run there). After a Herdr came back, or when the server starts from service.json, a service is seated only once the pane under its recorded id is in a tab labelled with the tower glyph (seatGate in typegate.ts; the glyph is what a restore keeps of a tab the server made, while tokens and the pane’s environment are gone).

Until then it types nothing, writes no marks, and wakes no run, and its Herdr connection or watch firing again voids a proof read before it. The proof is for one terminal. Herdr gives every pane of a new server a new terminal_id, and the connections are acknowledged again only up to two seconds after the new server answers, so a flag voided by them alone would be believed for a stranger’s shell under a reused id in between.

Each act that rests on the proof (a typing, the marks, a run’s start, and the start of pi itself after the prompt has left) therefore asks proofGate of a listing of the pane read just before: a pane listed in another terminal than the one proven means a new Herdr, which voids every proof and opens the minute at once (tower-unseated in the log), and the look proves the seats again; a listing that fails proves nothing, and a wake refused for it is made again at the service’s next refresh.

A proof made for a pane the server made a moment ago takes the terminal of the first listing that shows it; where Herdr lists no terminal ids the proof is the glyph’s alone. A run that began before the change ends as herdr-restart without having started pi, and its pane is not marked fresh. A pane in any other tab is a stranger’s: for a minute after the restart the glyph tab is looked for under other ids and followed if it is there; else the service ends as a quit tower’s does (tower-closed, the queue kept 24 hours), and the stranger’s workspace and pane are not touched, not even to clear a mark.

A tab that is gone from a service not seated on this Herdr ends it the same way unless the workspace under the recorded id still carries the label the service made: otherwise the workspace gets no sb_tower_quit (RVSEAT6-F006). A pane or tab Herdr does not list yet proves nothing either way and is waited for. A tab the boss renames away from the glyph cannot be proven after a restart.

A live resident tower (an older pi holding the tower token) keeps its workspace: the service waits and registers only once that session leaves (and ends if it closed its tab). A resident tower of this build, started by an older session or brought back by a Herdr restore, asks ensure-tower for its own pane at its start, quits once the server has written the token, and leaves the pane as the standing pane; a server that cannot take it leaves it resident, with a notice.

Details

The same ask is made at every session_start of a tower, whatever its reason, so a live resident tower of this build that the boss reloads (/reload), resumes, forks, or replaces with /new is retired by that: it asks, the server takes the pane, and the tower quits. Nothing in the build opens a resident tower with the service on: openTower (a promotion, call_tower, a broadcast to towers, /boss tower) asks the server, which makes the tab and starts no pi until an event wakes a run.

While the service waits, the page shows the resident, not the service: its own comms and terminal, its composer, and its Quit tower (which asks its bridge) work as they did before the service. A resident tower of an older build that is never reloaded keeps its workspace until it leaves (the service waits for it, as above); to retire one, close its tab in Herdr (or quit it with /quit): the service registers the identity once its token is gone, and the next promotion or event gets the server’s tower.

roles/ records with role tower whose pane is in this Herdr, in a tower tab or marked as the live tower’s, become service.json and are deleted (main records are left).

A message to the identity is written to tower/<workspace id>/queue/<seq>-<id>.json, flushed to disk, with the text as received; the log holds no text. The receipt queued goes to the sender only after the fsync, and a message already taken (same sender, base message id with a retry’s ~r<n> cut off, and text hash; seen.jsonl, 48 hours, 5,000 per workspace) is not queued again but still gets its receipt. A send to a tower identity that fails is retried for up to 30 seconds by Straw Boss’s own sender (src/reach.ts), under <request id>~r<n>.

Details

A queue written before seen.jsonl existed is read as it is: retry suffixes are cut, events older than a day are dropped, at most 1,000 are kept per workspace. A link message for a tower the server runs waits in link/inbox/ and is offered to no session; the boss sees one notice (a Straw Link message waits … ally runs come with the next step) until B6.

Between runs the tower’s pane (the standing pane) is not a bare shell. The server types the tower prompt into its idle shell, and while the prompt runs there it reports a stand-in agent tower on the pane (spec, The standing pane). The tower thus keeps its row in Herdr’s Agents list and in the net view, at the head of its workspace, between runs.

node "<cli.mjs>" prompt --workspace <id> --state-dir "<dir>" (src/towerprompt/), a first word every shell takes, so Windows needs no bash and the node on the PATH is the one the boss runs pi from. It clears the pane’s screen when it has its first state to show, so the command the server typed does not stay on it (the visible screen only; the scrollback keeps the last run’s output), and draws a title (the radio tower glyph of the tab and tower · <label>, bold), a state line (a glyph and a colour per state, then standing by, 3 queued, waking up, running, closing, or held; printed above what the boss is typing when the state changes), five key rows as the hint (⏎ wakes the tower, /new, /wait, /quit and ⌃D, /boss tower <message> from any pi session), and the prompt 󰐻 ❯ .

Details

The lines it says in between (waking the tower, the next conversation is a new session, the server does not answer, the tower did not take it, a command it does not know, leaving the pane) carry a glyph and a colour of their kind. The Nerd Font glyph is the tower tab’s own, so the font is assumed to have it. NO_COLOR (set and not empty) or TERM=dumb takes the colours; STRAW_BOSS_ASCII=1 in the shell the pane runs takes every glyph outside ASCII (# tower - <label>, o standing by, Enter, tower> ); TERM=dumb also takes the clear.

With no terminal (a pipe, a log) the output is plain ASCII with no escape sequence, unless STRAW_BOSS_ASCII=0 asks for the glyphs; the look is src/towerprompt/look.ts. The words are English, and Traditional Chinese when the shell’s LC_ALL, LC_MESSAGES, or LANG says zh_TW, zh_HK, zh_MO, or zh-Hant. It polls the server every 2 seconds over the control socket with the secret of the state folder’s record, as the CLI does (the record is read once the folder’s owner is judged), starts nothing, and holds no run token; a server that is down or loading is waited for and said once.

  • The wait cards (src/towerprompt/waitblock.ts, pick.ts). Under the state line the prompt prints a block of the cards open for this pane’s tower (the head’s own on the head pane), once per change of the server’s digest, which prompt-poll carries cut to 120 characters per field: W2 choice · <do> · from <raiser> · parked, five lines and 3 more: /wait, or a note that none is open; there is no bell. Card text is cleaned again before it is written, since the prompt writes raw to a terminal.

    /wait or /boss wait takes the keyboard (the prompt becomes wait> ): it marks the pane as this computer’s keyboard first (a pane that cannot be marked refuses the mode and nothing is read), lists every tower’s cards with this tower’s first, and a number picks a card, then an act; the press, a skip’s reason and y/N, and a reply are as in pi, and a refusal is shown with the list read again.

    Only typed keys press: a paste (bracketed, or lines that arrive together) ends the mode with nothing pressed or posted, a run that starts drops it (what is on the line is never posted as the boss’s words, the prompt says the cards are now in the run’s pane with /boss wait, and a press already sent is answered first), and digits typed in the mode are never an event. The prompt takes /wait among its commands, and an empty line at the list leaves the mode.

  • Enter posts the line as a boss event (present) through the endpoint of the GUI’s composer and /boss tower, prints waking the tower…, and the prompt stays, asking every quarter second (instead of every 2) for as long as the server shows a wake pending, however long the start takes (a Windows claim took 10 to 46 s), until the server starts the run’s pi in the pane and tells it to leave (a tower that stands by with nothing queued 3 seconds after the post has no wake pending, and the asking is slow again): the boss conversation.

    What the boss types in that second is held on the prompt’s line and posted as a second event at the leave; a prompt that exited at once let those keys land in the shell’s line, where the run’s start typed over them and timed out. /new alone makes the next one a new session and the prompt stays; /quit alone leaves the pane to the shell, as Ctrl+D does (no conversation is live at the prompt, so Leave would have nothing to end).

    Any other first line that starts with / (a /reload typed from habit) is not posted: the prompt answers with one line that lists what it takes, and a message that begins with / is written with a space before it (a line that begins with // is posted as typed). Inside a paste the lines are one message, whatever they begin with. Lines that arrive within 40 ms are one paste, one message; a paste between the terminal’s bracketed-paste markers stays open until they end, however far apart its pieces arrive, and a paste whose last line has no line end yet (Windows PowerShell sends the text, then the Enter a moment later) waits for that Enter and is posted whole with it, never as two messages.

    The last line is held as part of a paste only when it arrived in the same group of input reads as the paste’s lines. A group is the reads of a bracketed paste, from its start marker to its end marker, plus any read that follows the one before within 40 ms and carries more than one key, and a single key that follows a read that left a line unfinished (a pty may split a big paste into reads, and may leave only its last character for one).

    A key is one character (one code point, é or 中 as well as x; the bytes of a split character are put together first, so a half character is never a key) or one complete escape sequence (an arrow, Alt+b). A typed /quit and a key typed a moment after it, even a few ms later, are two groups, and /quit is obeyed; one read that holds both /quit⏎ and more than one further key cannot be told from a paste and is held. A paste whose last line is a single character in a read of its own, after a line end, is two messages.

    A bracketed paste whose end marker never comes is closed 2 seconds after its last data. The prompt does not turn bracketed paste on itself (the shell sets that mode at each of its prompts, a prompt killed before it could turn it off would leave the boss’s shell in a mode it did not ask for, and a Windows console may not know it): the markers are honoured when the terminal sends them. An empty Enter wakes the tower with a line that says the boss is at the keyboard and wrote nothing: it is how the boss talks to the tower now (see The boss in a run). A line the server refuses is shown (the tower did not take it: …) and the prompt stays.

  • Ctrl+D or Ctrl+C leaves the pane to the boss’s shell, whatever the prompt holds (a paste not yet posted or a post on its way; what was held is dropped, never posted later), and a closed input ends the prompt the same way, so the prompt is never left running with dead input: the server is told, the stand-in goes, and nothing is typed there again, however long the boss stays, until a tower run takes the pane and ends or Herdr restarts (no clock gives the pane back: a prompt typed into a shell the boss is using would join their half-written line or take their next command for a message).

    The boss gets the prompt back by waking the tower (/boss tower <message> from any pi session, or any event); when that run ends the prompt returns. The boss may start pi there by hand: a run of their own. A tower prompt the boss starts by hand there ends the park as soon as it polls; Retry does not end it.

  • A run that is starting makes the prompt’s next poll say so; it prints waking the tower… (once: not again when the boss wrote a moment ago), posts what the boss had typed on the line (and the lines in the 40 ms paste window) as their boss event first, so nothing they were writing is lost (if the server does not take it, the words are printed under the refusal), and exits 0. Keys the boss types after the prompt has exited, before the run’s pi has started, still land in the shell (the prompt cannot hold a terminal it has given to pi); the window is the start of pi, not the second between Enter and the run.

  • When the server has no tower for the workspace (the tower closed), the prompt says so and exits.

A look at every standing pane each 3 seconds. It types the command only into a pane whose foreground is its shell at an idle prompt (Herdr’s pane process-info), that has no agent, was not parked by Ctrl+D, and did not get the command in the last 15 seconds (and not for 15 seconds after the prompt posted the boss’s words and exited: a run starts in a second), and no run is about to take the pane (an event waits and its wake is due within 5 seconds: a prompt typed then would have to start, poll, and leave before the run’s pi can, 29 s in a PowerShell pane on the VM): a build or an editor the boss left there is theirs.

Details

“Idle prompt” is proven by the shell’s pid and the foreground process group Herdr gives on macOS and Linux (the group is the shell’s own). Windows lists the shell alone whether a build runs under it or not (and its foreground_process_group_id is always the shell’s pid, so the server does not believe it there: a ping -t in the pane still looked idle), so a pane without a believed group is typed into only when the server knows it is fresh: the tab it made, a pane a run freed, the pane a resident tower handed over (the hand-over carries the pi’s PID: the pane counts as a bare shell once that process is gone, within a minute of the hand-over and for a minute after, so the prompt is typed after a /reload too and never while the pi still runs behind a cmd.exe /c wrapper that Herdr lists as a shell), or any pane after a Herdr restart, and then only if every foreground process is a shell; an empty list is unknown, never idle.

A prompt that dies on Windows is therefore not typed again until the next run or Herdr restart; the server logs prompt-lost once (a prompt typed there that never started is logged prompt-not-starting once instead, and not typed again). A prompt that never starts (no node on the shell’s PATH, a shell that cannot take the quoting) is typed again after 15 seconds, then 30, 60, and so on up to every 10 minutes, and the server logs prompt-not-starting once, after the third try; a poll ends the backoff.

A typing that Herdr refuses (pane run fails) is no try: the server logs error code=prompt-typed with Herdr’s exit code and whole stderr, tries again 15 seconds later while the pane is still believed fresh, and after three refusals in a row logs prompt-not-typed once and types nothing more, on any platform, until the next run, a resident tower’s hand-over, or a Herdr restart. Only an exit code Herdr itself gave is a refusal: a pane run that the 5-second timeout cut off may have typed the command already, so it counts as typed (one error code=prompt-typed line with the signal) and is not typed again.

A service held because the pane runs a program of the boss’s is started again by itself once that program is gone: where Herdr gives the foreground group, when the group is the shell’s; on Windows, when the program’s PID is gone and the list shows the shell alone at two looks in a row (the program is found in the process table below the pane’s shell, since the foreground list shows the shell alone there). A hold with any other cause waits for the boss’s Retry or a word to the tower.

Retry with no event waiting and no run gives the pane back as a run’s end would, so the prompt is typed again; it changes nothing while the prompt polls (a prompt that runs is never typed into, whether or not Herdr kept its label), and it never ends the boss’s Ctrl+D park (the pane stays theirs).

On Windows, where the foreground list cannot tell an idle shell from a running program, Retry reads the process table first and types only when that read succeeds and finds no program of the boss’s below the pane’s shell (the tower prompt aside): a read that fails says so and types nothing, a program found holds the service on it again and names it, and Retry asks again. The give-back marks the pane fresh for a minute at most, and not at all while the pane is parked. A notice of a start that failed goes when Retry is pressed, when the boss writes to a held tower, and when a later run claims.

It reports the stand-in while the prompt has polled in the last 6 seconds, again when the message changes and every 4 minutes. Before a run starts pi in the pane it releases the stand-in (Herdr refuses agent start in a pane with an agent: agent_pane_busy), tells the prompt to leave, waits up to 3 seconds for it to be gone, and sends Ctrl+C only when the pane’s foreground is that very prompt (by PID).

Then, on macOS and Linux, it waits up to 2 seconds for the prompt’s process itself to be gone (the prompt saying it leaves is not its exit: Herdr admits agent start a few ms after the exit while zsh does not read yet, and the command’s Enter is lost) and 300 ms more to settle; a prompt typed in the last 15 seconds that has not polled yet is waited for the same way, and one that does not exit in time is named in the log (run-yield) and the run goes on.

A start that Herdr fails is noted as herdr <code>: <stderr> | <message>; a run ends, and the next look types the prompt again, as it does after a Herdr restart (every pane is a fresh shell) or a crashed prompt.

Whether the server may type the command into a standing pane is decided in one place, src/server/typegate.ts: the look (standing.ts), a run’s end, Retry, and the process-table proof (runs.ts) only gather facts about the pane and act on the gate’s answer, a type or a no with the rule that said no. A change to when the server types is a change there, and its table test (tests/typegate.test.ts) pins every rule.

Details

The facts are asked in the gate’s order, and the prompt’s poll is asked again after each await (the stand-in’s release, Herdr’s process info), so a poll that lands while Herdr answers is never typed over; with a run due within 5 seconds the look answers run-soon without re-reading the poll after the release (it logs no prompt-lost for a poll that lands then). The process-table proof asks Retry’s rule again after its read: an event that came meanwhile leaves the pane to the run.

A pane that is not seated (seatGate, A Herdr restart) gets the answer unseated before any other rule, and the seat is asked again after each await like the poll; before the typing the pane is listed again together with its process info, and the seat is judged on that listing (proofGate). The look also keeps when the command was last typed into each pane and in which terminal: a service that replaces a lost one for the same pane waits as the first would, the desk’s claim asks it (claimGate), and a service that is lost types nothing from a look it had in flight.

Two marks are facts and are set where they happen, not by the gate: fresh at the pane Herdr just made and after a Herdr restart.

Herdr (0.9.5) drops a reported agent whose label it does not know within a second of finding the pane’s foreground at an idle shell; while a process other than a shell holds the foreground, the label stays (52 minutes with no refresh were watched), and it goes 0.3 seconds after that process ends. A known agent kind would stay on a bare shell, but it would put another agent’s name on the row. So the prompt is a process that holds the foreground, and the label is the tower’s own.

Details

If Herdr does not keep the stand-in (three reports in a row that the next look does not find), the server logs standin-unsupported once and stops reporting for that prompt: the tower has no row between runs, as before, and nothing else changes. The report has no resume command and no session, so a Herdr restore never relaunches it, and a report after pi’s exit works at once (Herdr does not bring it back by itself).

The boss decided (D18, D19) that coordination goes through the tower, and that a main calls it at three points only. The main’s prompt (prompts/main.md) names them, and call_tower takes the reason as why:

why When Reply
milestone A task landed, a deliverable is ready, or what a tower asked is done. reply: false unless the main needs an answer.
shared Before the main touches anything outside its tab: a port, a database, a shared branch, a deploy target, another app’s checkout, a running service. It says what and for how long, then waits for the answer. Expected.
blocked The main waits on someone outside its tab, or on a decision its authorizations do not cover. A member reports coordination friction the same way. Expected.
Details

Routine progress is on the work board and wakes nothing. The code writes the reason into the header (why=<kind> after reply=). In pi’s tool why is required for a main and may be left out by a tower, and without it the header says why=unspecified and the event wakes as blocked does. A word outside the set is refused before anything is sent. A tower’s call_tower takes why as optional (it is a blocked call); the resident tower prompt (prompts/tower.md, towerRuns off) is left without why= guidance on purpose, to stay in its word budget. Non-blocking incidents go on the work board and wake nothing.

An intercom event whose header says why=milestone and reply=none, with no local run live, waits up to 30 seconds (hold) for others, counted from the first such event, so mains that finish together land in one run. Every other event (shared, blocked, unspecified, a reply expected, the boss, a notice, a timer) wakes at 1 second as before and takes any held milestones with it. A live run gets each event at its next idle moment, with no hold. The server reads the header at the very start of the event text only, so a message that spells out why=milestone further in, or in a quoted name, is not held.

Details

The tower’s run prompt (prompts/tower-run.md) says what to do with each reason: a milestone updates its obligations, tells whoever waits on it, and closes a main it opened; a shared call (a main writing to or changing something outside its tab, not reading) is checked against the board and its obligations and answered go or wait with the owner named; a main that gets no answer in 10 minutes tells the boss and leaves it alone; a blocked call is routed to its owner, except a decision only the boss can make, which the tower names in its handoff note as waiting on the boss and tells the main so, not sending it back.

A main opened by a tower reports its done report with call_tower (why: milestone, no reply). It never asks a main for progress, since board_read has it.

Three kinds of run share the machinery below:

flowchart TD
    ev["Event<br/>a main's call_tower,<br/>the boss's words,<br/>a teammate's message"] --> q["Queue<br/>per workspace"]
    q --> who{"Who sent it?"}
    who -->|"a local session"| er["Event run<br/>fresh pi, ends with tower_done"]
    who -->|"the boss"| bc["Boss conversation<br/>resumes the last session"]
    who -->|"a teammate's device"| ar["Ally run<br/>beside the standing pane"]
    er --> sp["Standing pane<br/>standing by"]
    bc --> sp
    ar --> sp
Details

With the tower service on (the default), an event for a tower identity wakes a pi in the tower’s standing pane (spec, The wake protocol, Tower tools in a run). One local run per workspace at a time and at most towerRuns.max (default 4) across the machine; a burst of events starts one run (the wake waits 1 second; a finished milestone waits up to 30 seconds, see When a main calls the tower). boss events (the GUI’s composer, /boss tower <prompt>) go first.

Start. The server runs the settings check (assertSettingsSafe) on the pane’s folder before every run and starts nothing when it refuses: the events stay queued, the tower card shows an error notice, and the next try comes with the next event, at most once a minute. It starts pi --name "tower run · <label> · <run8>" --sb-run <run id> --tools <the run's tools> in the pane with no Straw Boss environment, and records the run’s PID and start time. A boss conversation adds --session <file> before --tools; an ally run is started without --tools (see No shell in a local run below).

A run’s pi proves it is the run the server started by claiming before it joins anything: it sends its run id, pane, PID, and start time over the control socket. The server answers a run token, the run’s kind, and the brief only when all of these are the recorded ones and the run was not claimed already; a refused pi quits with the reason.

Details

A pi with --sb-run asks the server claim {run id, pane, pid, start time} over the control socket before it joins anything. The server answers a run token (kept in memory), the run’s kind, and the brief, only when the run id is one it started and has not ended, the pane, PID, and start time are the recorded ones (the server finds pi’s own process itself, in its process table: when Herdr lists only the wrapper pi was started through, such as cmd.exe /c pi.cmd on Windows, the recorded PID is the first process below the wrapper that carries the run id and is not itself a wrapper, and a stop or a timeout ends pi and then the wrapper, each checked in the process table at that moment; a run whose start failed ends only a process in its pane whose command line carries the run id), every Herdr takeover it saw is processed, and the run was not claimed already; a refused pi quits with the reason.

A pi without the flag in a standing pane asks pane-role (a resident tower, which carries STRAW_BOSS_ROLE=tower and has its tower entry in the session, never does, and neither does a process that already became a resident, which the process remembers: the server answered none, or no server was there to answer. Its /reload, /new, /resume or /fork is its own switch, and the new branch has no entry yet.

A pi that is undecided or plain is no resident, whatever its switch; the env alone is not enough, since it stays in a migrated standing pane’s tab and a pi the boss types there is a hand run: a /reload that finds its pane already marked by the server would otherwise make it a hand run that never hands over; it hands over itself): it is told to quit for 2 minutes after a Herdr restart (Herdr’s pi integration resuming the old run) or becomes a run of its own (hand; one whose pi is gone before its first poll, a claim nothing released because the pi quit while a late answer was on its way, ends as quit, not as a crash, so it does not count toward the crash loop).

For a pi with only the env the ask is bounded (2.5 s) but an answer after the bound is not a guess to act on: the session stays undecided (it joins nothing, hands nothing over, quits nothing), the ask stays in flight, and the answer is acted on when it comes (hand makes the hand run with that claim, none or an older server makes it the resident that hands over, quit quits), a session that ended meanwhile releases the late claim, and an ask that fails or never answers leaves a plain session with one notice.

An undecided or plain pi that is /reloaded, /newed, /resumed or /forked keeps its one ask (a second ask of a pane whose hand claim was given is told the pane is held) and the plain one stays plain; a /quit of a pi that never became a normal session leaves the standing pane open, and waits up to 2 s for an ask in flight so that the hand claim it carries is released; the process start time is read once, before the bound’s clock (on Windows the read takes seconds).

A hand run keeps its claim across /reload, /new, /resume and /fork (same process, same pane: asking again would be told the pane is taken, and the run ended crashed after 30 s). The boss’s words in a run protect its turn from the abort whether or not the server heard them. A claim goes through the session’s look at the server, so the record is passed through the owner gate before a secret is sent anywhere. A run whose server stays out of reach aborts its turn before it ends, unless the boss is in it (a hand run, or their words in the run): there it waits for idle.

A quit the server orders while the run’s turn is busy is held by the run until the turn ends: pi keeps a deferred quit with no way to take it back, so a server that restarted meanwhile (the run then claims again with its run id) would otherwise end a run it never stopped, as crashed. A claim again drops the held quit.

It joins no net, publishes no tokens, runs no guard. Its role section is prompts/tower-run.md and the brief the server built (who and why, mains it supervises, open obligations, the memory, the digest, the last handoff note, the tools, flags; 8,000 characters at most). It polls the server once a second, and at each idle moment takes one waiting event as a straw-tower message with triggerTurn, then confirms it taken.

Details

Its tools are the tower’s own (boss_status, call_tower, tower_list, board_read, tower_send, boss_open_main, boss_close_main, boss_server) plus tower_reply, tower_memory (Tower memory), and tower_done, each a request to the server over the socket under the run token: the server does the work as the tower identity, so everything leaves under id=sb-tower-<ws> and replies come back to the server. pi-intercom’s intercom tool is blocked with a pointer to tower_reply. A run ends with tower_done {note, wakeAfter?} (the note, 1 to 1,200 characters, goes to the next local run; wakeAfter, 5 to 240 minutes, asks for a follow-up run, one pending per workspace); it is refused while events are still for the run.

An intercom event reaches the model under a sender line the code writes ([intercom from <name> (<id>)]), so text that claims to be the boss’s instruction is told apart from a real boss event. The name and id keep only letters, digits, spaces and ._@- (80 characters each), so the line stays one line and the real id is always the last group in ASCII parentheses; known limits: a name may hold letters that look like brackets (e.g. ᐸ…ᐳ), and scripts written with combining marks (e.g. Devanagari) lose their marks.

A run dispatches no subagents (subagent and subagent_resume are blocked), takes no /boss command except /boss wait typed by the boss in its own pane (Waits), and looks at nothing of the server (it starts or upgrades none). A server restart does not end a run: its next poll finds the token unknown and claims again with its run id; while the new server has not loaded the runs or registered the identity it answers the claim wait, and a run that gets no answer or only refusals waits up to 30 seconds before it ends. /boss server stop (not a restart) tells every live run to quit, ends the PID that stays, and records the run quit.

A pi in a standing pane that Herdr resumed without the flag (even when its pane lost its tokens, if its tab has the tower label) asks the server for its role and waits up to 30 seconds for a live server, so the server’s two-minute quit rule reaches it.

A run can be handed text that is not the boss’s (a main’s or a peer’s message, a remote board entry, an old conversation), so the sb server starts its pi with --tools naming only what a tower needs: the tower tools (boss_status, call_tower, boss_server, tower_list, board_read, tower_send, boss_open_main, boss_close_main, tower_memory, tower_reply, tower_done, link_request) and read-only file access (read, grep, find, ls: the merge-train routing reads a project’s .straw-boss/apps.json, and an answer about where work stands may need a file). pi registers no other tool, so bash, write, edit, subagent, and every tool of the boss’s other extensions (web search, Linear) are not there, and an extension that calls setActiveTools cannot bring one back.

Details

MCP tools (mcp__*) are the exception to “not registered”: pi keeps them registered under --tools, but they stay inactive (the list has no tool_search), and the tool_call backstop below refuses them. An extension that registers a tool named read, grep, find, or ls would replace the built-in and be allowed; the path check sees only that tool’s path, pattern, and glob.

The run mode refuses the same names again at tool_call as a backstop, and refuses read, grep, find, and ls a path of the sb server’s state folder (server.json, gui.json, gui-secrets.json, tower/<workspace>/memory.json, the LAN, pairing, remote, and link files), of the agent folder, of the home folder’s secret folders (.pi, .ssh, .aws, .config, .gnupg), and of key files (.env*, *.pem, *.key, id_*, *.p12), as written or through a symlink, and after resolving the path the way pi resolves it (a mirror of pi’s resolveToCwd in resolveArg: file:// URLs through fileURLToPath with percent escapes, a leading @, ~, odd spaces, and on Windows /c/..., /mnt/c/..., /cygdrive/c/..., ~\; a Windows network path (\\host\share) is refused outright); only the path arguments are read as places (path in all four tools, pattern in find, glob in grep), so a grep regex that starts with / is a regex; a search whose root holds one of those folders (grep -r from the home folder) is refused too, so a run searches below a project only.

That closes the run’s own writing of memory.json, its reading of the hello secret (and so a forged tower-event or tower-memory), and its reading of GUI and link secrets. What stays: a run can still ask a main with a shell to act (boss_open_main, tower_reply), which is bounded by the mains’ own prompts and hooks, and mains keep their shell. The boss’s own !cmd typed in a run’s pane is pi’s shell for the user, not a tool call, and works as before. The path denial (not the list) also holds two runs that keep their shell.

An ally run is held to it at every trust level, dispatch included, so a peer’s run cannot read the state folder, the link key, or auth.json with the file tools, in addition to the gate of its sender’s level (link/hooks.ts). A hand run (the boss’s own pi in the standing pane, with the tools the boss chose) is held, in every turn the boss’s own words did not start, to the local run’s tool list as well (LOCAL_RUN_TOOLS, enforced at tool_call: bash, write, edit, and every other tool off the list is refused with a message pointing to the boss’s own turn or boss_open_main) and to the path denial: events of a peer, a main, or the board reach it while the boss is silent.

A turn is the boss’s only when their words (input from the keyboard) start it while the session is idle, until the agent settles; a steer they type in the middle of an event’s turn does not make that turn theirs. The boss’s own turns keep every tool. Not covered: a resident tower (the legacy mode), and the shell of an ally run. bash can still name a guarded path in a command (cat ~/.pi/agent/straw-boss/server.json, a variable, a glob, a cd), and a command line cannot be checked reliably, so no filter on it is attempted: a dispatch peer on an attended computer has this computer’s user authority, the sb server’s boss credentials included.

That is the choice left to the boss: give the ally run the local run’s tool list at dispatch (it then needs open-main to do real work), or keep the shell and trust the peer as the level says.

Kinds of token. The server holds every request that carries a run token to the run’s kind. A local run may read status, use the link holder for list, board-read, send, confirms, and the approved-request ops (always as its own tower identity), and open-main and close-main for its own workspace. An ally run’s token gets none of them. gui, gui-remote, gui-pair, close-tower, restart, and the rest of the control socket are refused to any run token: the hello secret is the credential of a session or a shell, a run token only of a run.

Once the boss speaks in a run, it does not end on its own. They speak by writing at the tower prompt (the run it wakes is theirs from its claim), by typing in the run’s pi (its extension tells the server at the first word and at most every 15 seconds after: input from the keyboard only, never an event or a steer of the server’s), or by starting pi by hand in the standing pane. While the boss is in the run, tower_done answers that they are here, keeps no note, and the run goes on, so the model keeps answering them.

Details

The run ends when the boss quits it (/quit ends it as done with the note (the boss left the run), (boss quit) in a boss conversation; the events it had not taken wait for the next run; a quit of a run the boss is not in is a crash as ever) or after 10 minutes of their silence (no input, no turn), when the run gets the wrap-up below.

The bound: the cap and the idle limits below do not apply while the boss is in the run, in the middle of a turn too, for 4 hours from their first word at the longest (counted from the first word of the run, not restarted by a return after their silence); after that the cap applies at once, and a later sign of the boss changes nothing. After the boss’s silence the run has the idle wrap-up to itself (6 minutes), and the cap does not cut it short. A server restart forgets who was in a run until their next word.

A run that has not polled for 30 seconds is lost and ended as crashed, unless the computer slept: when the server’s own watch finds a gap of more than that between the end of one of its ticks and the start of the next (a slow tick is no sleep), no run could poll in it, so every run’s silence is counted from that tick.

Details

A run that settles idle without tower_done gets one steer after 60 seconds and quit 5 minutes later; at 18 minutes it is asked to wrap up, at 20 minutes it is quit (a pi that has not exited 10 seconds after quit gets Escape and /quit through Herdr, and 5 seconds later SIGTERM to the PID herdr pane process-info reports).

On Windows a pi ended with TerminateProcess leaves its pane’s console in a mode where the next herdr agent start there types a mangled command and times out, so the waits are 30 seconds and 20 seconds, a stopping server waits 8 seconds for its runs to leave and never signals a pi that stays (the run is left without an outcome, as a restart leaves it: that pi reclaims it from the next server, or ends itself at its next idle moment once the server has been away for lostMs: a pi in the middle of a turn aborts the turn first, since pi keeps a shutdown until its next idle moment and a model call can last minutes, and the next server records the run quit, not crashed, when that pi is gone), a pane in which a pi was signalled is replaced by a fresh standing pane before the next run, and only the standing pane is closed, and only when it holds a shell or the run’s own pi (a pane running the boss’s program, and the other panes of its tab, stay; the tab then loses the tower label; the service keeps its queue and notices), and a start that Herdr reports with the error code timeout is tried once more in a fresh pane without counting toward the crash loop (any other failure ends the run).

A crash returns its events to the queue, flagged possibly handled by a run that crashed: check before acting; an event taken by two runs that both crashed or timed out is parked (the card shows Deliver again and Drop); three crashed or timed-out runs in 10 minutes hold the service (the card shows Retry). A pi that dies with Herdr is no crash. Herdr takes its panes’ processes with it when it stops.

The server’s watch of Herdr’s socket says when a server that had answered stops (herdrLost; the log line herdr-lost carries its time, to compare with a run’s run-end when a clean stop still ends crashed), and a takeover under way ends every live run herdr-restart when its look is done; a pi seen gone meanwhile (or within 30 seconds of the stop, the time a run may be silent) waits for that, and neither counts toward the hold. A pi seen gone with Herdr up is a crash only when the next tick sees it gone too, since the socket may close a moment after the panes’ processes do.

The same holds for ally runs, whose message then stays in the inbox for the run that follows. A start that fails while a program of the boss’s (not a shell, not the tower prompt) runs in the standing pane is the pane’s doing: nothing of theirs is closed (and a start that is not Herdr’s timeout code gets no new pane), the run counts toward the loop like any crash, the one start notice names the program (its executable’s file name, no path or arguments; on Windows, where Herdr’s foreground list shows the shell alone, the program is looked for below the pane’s shell in the process table when Herdr answers agent_pane_busy), and the held service starts again by itself once the pane’s foreground is back at its shell, as it does on Retry or a word from the boss.

boss_server and the restart limit. In a local run boss_server goes through the server: it refuses restart, status, and logs for an ally token, and the one-restart-a-minute limit is kept by the server itself, so two agents asking at once cannot both pass it.

The tower unit stays on the map as the identity: working while a run works, waiting on a dialog, standing by (待命) when no run is live. The service’s own state has its own word on the map, in the dossier, and in the comms header (waking, closing, held, ally run · device), followed by how the last run ended (crashed, timed out, quit) and how many runs failed lately; the map card carries a count chip of the messages waiting. Waiting events show as the unit’s queued rows (kind, sender, first line), labelled as waiting for the next run, with no Edit (the server’s queue is not pi’s).

Details

The notices, with their Retry, Deliver again, and Drop buttons, show on the map card and again in the tower’s comms; a button is off and says “Working…” until the server answers, and a refusal shows under the row. A run’s events are custom_message entries of type straw-tower, and the comms read each by the kind the server sent it with (details.kind: boss, intercom, notice, timer, link; a steer is a server notice): the boss’s own message is a YOU bubble with the server’s marker line taken off (a crash flag after it shows as a note inside the bubble), an intercom message, a server notice, and a wake-up call are incoming notes with their labels and no marker line, and a teammate’s message (an ally run’s input, a main’s link_reply, an answer from a local session) is labelled by what it is (request from, reply to your ask from, answer from, message from the device) with its text inside and the verified header folded behind “details”.

Only details.kind makes a message the boss’s, never its text, and only in a run of a tower the sb server runs: in any other session (a main, an ally run’s comms) a straw-tower entry of kind boss shows as a labelled notice with its text as written, never as YOU. An intercom message shows the sender’s name and id from the server’s own sender line (cleaned to letters, digits, spaces and ._-@, at most 80 characters) and says it is from this computer; a teammate’s message through Straw Link says teammate, so two senders with one name never read alike.

A tower that stands by with nothing said yet explains what standing by is and that the first message starts a run. Ally runs show beside the tower’s state: the state reads ally run · <device label> (or a count) instead of “standing by” while one works, and a chip per run (ally run · <device label>, with a count first when there are several) sits on the map card, in the workspace view, and under the comms header.

A chip opens the run’s comms read only while the run’s session file is known: the net makes each such run a unit of its own (key ally-<run id>, no pane, so input, keys, and focus answer 409 not_live), keeps the path off the page, and drops the key from the allow list ten minutes after the run left the net, as for any session; the header says “read only” instead of an open channel, the box and the attach button are off, and the composer says a teammate’s message drives the run.

The run reports its pi session id with its file, and the server keeps the file only when it is named for that session (<time>_<session id>.jsonl); the run reports both, so this stops a slip and not a run that lies, which is the shared residual of reading files by path. The workspace view carries the same queue chip and notices (Retry, Deliver again, Drop) as the card. A message that waits for an ally run (served) says an ally run has not taken it and points to the tower’s card; a forwarded confirm names the teammate’s tower it comes from.

The dialog of a link_request stays in the language the extension wrote it in (English): its title is one string of the server’s wording, the agent’s reason, and the names of the asker and the device, and a page that took the fixed words out of it would break silently with the next rewording, so the page shows it as written beside its own localized chrome. The composer says “Wakes the tower” and Send posts a boss event (nothing is typed into a shell); its keys, mirror, and focus answer 409 standing while no run is live, and the Terminal tab says so in a sentence instead of showing a key pad.

The unit’s comms are the boss session once there is one (the live conversation’s file while it works), else the live run’s session, else the last run’s; event runs are chips beside it, read only (see The boss conversation).

/boss tower <prompt>. Ensures the tower as a bare /boss tower does, posts the prompt as a boss event with present (it starts or continues the boss conversation, as the GUI’s composer does), and answers “Handed to the tower” with no change of focus. /boss tower status, parked, retry, deliver <id>, drop <id>, leave, and new are the commands of that section. A bare /boss tower still opens and focuses the tab.

A local run has link_request: the server opens the request, the boss’s approval dialog (the same one a main gets: action, every argument, the reason, the asking session) is shown by this run’s pi, and the action is carried out by the holder only on a yes; the server is given the approval’s call id and takes it only against an explicit yes (the run’s dialog closed with allow), so a run cannot approve itself. tower_send takes device, reply: true, and for, a live main of the workspace that asked: the server records the ask (tower/<workspace id>/awaiting.json, 24 hours) and the device’s answer goes to that main directly (see Ally runs). A mailbox flush from pi-intercom’s broker is marked delivered without a receipt; the proposed fix is written up in the pi-intercom proposal.

A boss conversation is the local run a boss event with present (the tower prompt’s Enter, the GUI’s composer, /boss tower <prompt>) starts when no local run is live (spec, The boss conversation; D23). It resumes the boss’s last session; every other local run, an event run, starts a fresh session and ends with tower_done, as before. One conversation per workspace, whatever the boss wrote it from: the GUI’s composer is the same server function as the prompt (runs.boss(…, {present: true})), so a message from either continues the one conversation.

tower/<workspace id>/boss-session.json ({file, run, startedAt, lastAt, turns, fresh?}, mode 600) names it. Only a conversation moves it, by its own run-file report and only for a .jsonl under pi’s sessions folder (real path, so a link out of it is not followed). pi writes a new session’s file only at its first assistant message, after the run reported it, so a .jsonl under that folder that is not on disk yet is held back (no error) and kept, with the same checks, at the run’s next poll, the boss’s next word, or the run’s end; a file that never appears leaves no pointer, and a path that could never qualify is refused and logged when it is reported.

Details

The wait, the turns taken meanwhile, and a New asked meanwhile (/new of the tower prompt, the GUI, or pi’s own) are kept in the run record, so a server restart does not lose them, and the file is kept marked fresh. An event run never moves it, and neither does the boss typing into an event run’s pi. A boss message that arrives while an event run is live goes to that run (it does not wait for the boss), and the tower’s comms then show that run until it ends; the run record keeps when the boss first spoke in it (bossIn), and the next conversation’s brief names the run and its handoff note.

The server starts the conversation with pi --name "tower · boss · <label>" --sb-run <id> --session <file> when every one of these holds, else with no --session and the brief says why: the file is a regular file under sessions with no symlink anywhere in its path below that folder and the boss’s own (POSIX uid), it is at most 4 MB, the boss’s last turn in it was at most 7 days ago, no live pane’s agent_session names it, and the boss did not ask for a new one. turns counts the boss events the conversation took and the boss’s typing in its pi (at most every 15 seconds).

The claim, the run token, and the run mode are the event run’s; an ally run never resumes it. A run token cannot start, leave, or restart a conversation, or write as the boss: tower-event, tower-leave, tower-new, and the other boss commands are refused to every run token (rungate.ts), so a resumed session’s agent traffic gains no standing; the run’s prompt says only an event that opens with the boss line is the boss’s, however old.

The boss is in the run from its claim, so everything under The boss in a run holds: events that arrive are delivered to it at its idle moments, tower_done is answered with a stay, and the 20-minute cap and the 60-second idle steer do not apply. The brief’s first section says Boss conversation: the boss is at this pane. Resumed session from <date>, <n> boss turns or New session: <why>, and, for a resumed one, Since you last talked with the boss: with the first line of up to 3 event-run notes written since (1,200 characters together, 1,400 for the section).

Details

A live run is given a newer brief (a brief in the poll’s answer) when the memory or the last local note moved; its next turn reads it. The boss’s silence for 10 minutes while the run is idle, or the 4-hour cap, gives the wrap-up: a steer (“The boss has left: call tower_done with your handoff note.”), then quit 5 minutes later with the note (ended idle) or (ended at the 4 h cap), recorded quit (a conversation is never a timeout, so the cap never counts toward the crash loop). tower_done, and the boss’s /quit in the conversation’s pi (note (boss quit), recorded done, also after a long silence), end it at once. A server restart keeps a conversation on its feet, counted from its start.

/boss tower leave (the GUI’s Leave conversation, a /quit typed in the tower’s composer, tower-leave on the socket) tells the pi to quit and ends it as /quit does; /boss tower new (the GUI’s New conversation, /new in the composer or at the tower prompt, tower-new) marks the pointer so the next conversation is a new session, and ends a live one the same way. /new typed in the conversation’s own pi has the same effect. Leave is refused when no conversation is live (an event run is not one) or it has not claimed yet. The words are exactly /quit and /new: with more words they are a message.

Details

/boss tower. status (the service state, the conversation with its deadlines, notices, parked events, recent runs), parked, retry, deliver <id>, drop <id>, leave, new; anything else is a message for the tower. Each is one control command the GUI’s buttons reach too (tower-retry, tower-deliver, tower-drop, tower-leave, tower-new, and status), with the server’s refusals; the writes are refused in a pi with no screen, as /boss memory’s are. The words come in English and Traditional Chinese. A prompt that is exactly one of these words is a command.

Another tower, the head, and answering a wait

Section titled “Another tower, the head, and answering a wait”

/boss tower -w <workspace id | label | head> <command | message> acts on another workspace’s tower: the workspace is found by id or label as /boss memory -w finds it (a label two workspaces share is refused with their ids listed: name the id; the head’s own Herdr workspace, by id or label, is head), every write there asks in pi and names the tower, and a message goes as a tower-event with present as the GUI’s composer sends it, without opening or focusing the tower (it needs the tower runs on).

Details

head is the head tower: its commands are head-retry, head-deliver, head-drop, head-leave, and head-new, which /boss head retry | parked | deliver <id> | drop <id> sends without a confirm; that is the canonical spelling, and -w head sends the same commands but asks first and says “the head tower”, as every write at another tower does. -w head <message> is /boss head <message>, and a message that is one head command word alone (close) is refused there: write more words, or use /boss head.

--answers W<n> (before or after -w; on /boss tower and /boss head) sends the message with answers: the server marks the wait answered once the tower took the message, as the page’s Answer does (answered is the id, or false when the memory no longer holds it), and the rest of the line is then always the message; exactly /quit or /new with answers is refused (A command is not an answer to a wait), as the page’s composer refuses it. A pi with no screen refuses -w writes; parked and status read.

/boss send <name|key> [--after] <text> posts sessions/<key>/input {text, as: "send" | "followUp", guarded: true} to a tower, main, member, or subagent, as the page’s composer does (--after is the composer’s After this; a bare --after with no text is usage, not a message). A name with spaces is quoted (/boss send "fix login" hi); a name two sessions share lists them.

Details

The post comes from this computer, but a paired device may have typed the command (its composer, or the key pad letter by letter), so guarded: true makes the server hold it to every guard a paired device’s own write meets, whatever its address: a local-only text (localOnlyText, which also reads the text of a /boss send, at any depth, as it reads /boss open’s), an open link_request, and the /boss gui lan screen’s mark. The usual guards stay too (a dialog the session waits on, the length limit), and a command that ends or replaces the session is asked in pi, naming the session, before it is sent again with confirmed. This session itself is refused.

/boss stop <name|key> asks as the page’s Stop asks (it needs the pi screen), then posts {keys: ["esc"]} to sessions/<key>/keys; Esc alone needs no guarded. Both are the boss’s typed input: a tower run or an ally run takes no /boss command, and no tool reaches them.

/boss answer lists the dialogs other sessions wait on; /boss answer <name|key> [<choice number | text> | --cancel] answers one, as the page does. The list is the page’s own reading: a plain message of the local events stream (readStream, which closes it again), whose waiting units carry pending.answer (the dialog id, its digest, the select or questions).

Details

The stream sends its cached reading first, after hello, and with no page listening that reading is up to 10 s old; so a net that follows hello is read on for 0.8 s and the newest one stands (a net that comes before hello was read just now and stands at once), and a dialog opened or closed a moment ago is listed as it is. The list and the confirm show each choice’s note and each option’s description under it, as the page does, and a problem the stream reports is shown without its JSON quotes.

Reading the stream counts as a page for the operate host (host.page()), so for about 60 s after a list the controller keeps its operate sessions to the boxes open, though a box’s dialogs are left out here; only a server flag could save that. A unit of an unattended box (base.box) and this session itself are left out.

An answer is built as /boss link answer builds one (bosslink.ts’s build: a number for a select, numbers or words for a question, pi’s own prompts when nothing is typed, one at a time for several questions), shown whole with the question in a confirm that only this computer answers (hereOnlyOpts() with the pane marked, heremark.ts), then posted to sessions/<key>/answer {dialog, digest, answerId, choice | answers | cancel}, the route the page posts to, from this computer as its local page does. The server therefore keeps every check (checkAnswer, the digest of the question as shown, one open dialog per session, hereOnly choices for a paired device), and an outcome of stale, changed, or unknown is worded from its answer.

A secret is never answered from here: a wait is a secret when its pending.tool is secret_drop or its why is SECRET (ask.ts, the reading dialogs.ts gives a secret dialog, so the page and this command share the one string); it is listed as present and refused before anything else is asked or posted, even if its pending carried an answer (the server never gives one), and no request of this command carries a value.

It is the boss’s typed input: a tower run or an ally run takes no /boss command, no tool reaches it, and control.ts’s BOSS_LOCAL keeps a paired device’s composer from typing it (also inside a /boss send or /boss open text, as for link decide).

The tower’s comms are the boss session (stable across runs; the live conversation’s file while it works): a divider opens each conversation run (Boss conversation · resumed · 10:04 or · new session ·, with how the run ended and its note once it has), and the event runs are chips under the header (event run · 10:12, a read-only unit of the net while their session file is known, the outcome and note in the tooltip).

Details

The state word says boss conversation · ends after 10 min idle, … · waiting for you · … while the run waits for the boss (the tower then shows idle, not working), or … · closing; the box above the composer says how it began (resumed with the turns, or new and why), what it does now, the idle deadline, and the hard cap, with Leave conversation and New conversation. The composer’s /quit and /new ask for no confirmation in a tower.

The marker line of a boss event (BOSS_MARK) is unchanged, byte for byte: a resumed session holds the old lines, and the page reads them by that text; the writer (GUI, prompt, pi) is not told to the model, so there is no via.

Event runs start fresh, so what has to outlive a run is kept apart in tower/<workspace id>/memory.json (spec, Tower memory): the boss’s standing instructions (up to 12) and decisions (up to 24), each at most 300 characters, the tower’s waits (up to 12 open; for an agent 200 characters, for the boss a card of short fields), and the proposals runs made that the boss has not answered (up to 12). Every local run’s brief carries them in a Memory section (4,900 characters at most, after the open obligations; the standing instructions always fit whole, within a 2,400-character budget; the pending proposals come last, labelled as not confirmed).

Details

When the brief as a whole passes 8,000 characters the memory is cut first, so the tools, the tower_done line, the crash flags, and the 20-minute line always stay. An ally run’s brief never carries memory, and server.log holds only the op and the id of a write.

stateDiagram-v2
    [*] --> proposal: a run proposes with tower_memory
    proposal --> live: the boss confirms
    proposal --> [*]: rejected, or 7 days pass
    [*] --> live: the boss adds it with /boss memory
    live --> [*]: retired

The boss does, with /boss memory in any joined pi session of the workspace: /boss memory lists (the tower’s proposals first, each with the boss message it cites), /boss memory confirm P3 and reject P3 answer a proposal, add standing|decision <text>, edit S2 <text>, delete D4, and resolve W1 [outcome] change the rest (-w <workspace> names another workspace; the words are English or Traditional Chinese by your locale). pi is the first interface: the Memory panel of the tower’s comms and the tower card in the GUI call the same server function with the same rev check and add nothing of their own.

Details

A local run, through tower_memory, can only propose a standing instruction or a decision, or retiring one: the proposal waits, apart from the live entries, for 7 days, and takes effect only when the boss confirms it (a confirm that a full list or a changed entry cannot apply is refused and the proposal stays). The tool has no confirm, the control socket’s tower-memory command is refused to every run token, and a run takes no /boss command but /boss wait, which presses a card of the boss’s own list and makes no instruction (below), so no run’s tool, ally, link envelope, or message can make an instruction.

That is the whole guarantee: it holds against those routes, not against a same-user process with a shell. Any agent with a shell (a main as well as a run) can read the secret in server.json and send the control socket a tower-memory command, write memory.json directly, type pi -p "/boss memory …", or send text into a pane. /boss memory refuses every write when its pi session has no screen (pi -p, RPC), which closes the pi -p path only; the rest is the same-user trust the whole server rests on (limit T3R-F005, which covers mains too). A run can read the whole memory and the proposals with read.

At a full cap the boss’s own add is refused with a message; nothing is dropped. An ally run is refused the tool by its token’s kind.

Waits are the tower’s own notes, never instructions. A wait for: "boss" is a card with fields the tower fills (type, happened, do, url or command, after, options, secret.name, and from, the session id of the message that raised it, checked against what the run received), written in sb’s own locale; sb checks the shape, the lengths, the URL and the command, and that nothing is secret-shaped, refuses the card back with every field to fix, and never judges the language (spec).

It reaches the boss as an alert while it is under a day old: the dock’s dialog and the Zen card draw it whole (what happened, what to do, where, what happens after, who raised it and how long ago), and the tower card, its comms and the head page draw it folded to one line (the kind’s tag, what to do, who raised it; a tap unfolds the panel, and a folded row has no button of its own); a system notification says it once while the page is hidden.

The card is the question panel the alert confirm card is (gui/askview.ts, shared with gui/ask.ts), in Standard, Geek, Raven and Zen: its head holds the kind’s tag, who raised it and how long ago, and the Parked for your return mark of auto mode; what to do is its question; its acts are the rows of a radiogroup, and every press is pick, then Send (a tap on a row only marks it, nothing is preselected, so no stray tap presses; every row, Send and link is 44 px on a phone).

The rows follow its kind. An action card has I’m done; a choice has one row per option, with what that pick leads to under it; a secret card has Start entering, which tells the raiser to call secret_drop (the value is typed in its terminal, never on a page; a paired device shows “Enter it at the computer” and no start button), then I’ve entered it. Every card ends with the rows Reply to the tower and Skip, and Open is a quiet link beside Send. A wait written before cards is drawn with the tower’s old words and the row Ask the tower to rewrite it.

A URL is text that a click opens (nothing is opened or fetched by itself) and a command is text with Copy: sb never runs it. A press goes to the memory route’s press op, which tells the raiser by its session id (a wait_card message) and then the tower, whose event says whether the raiser got it. Skip is two taps as well (pick it, then Send): picking it shows who will be told and an optional reason box, and Send tells the tower and the raiser that the boss will not do this; the wait stays resolved for a day instead of vanishing.

Reply to the tower, picked, shows an answer box under the rows: Enter or Send tells the tower, the words going to the tower’s session input exactly as its comms’ composer sends them; a refusal shows under the kept draft, the dialog closes when the tower took it, the Zen card leaves with the knock, the box on a tower card’s row or in its comms empties and unpicks the row (so a second Enter sends nothing), and Esc in Zen puts the row back with the draft kept, but not while the answer is on its way.

The dialog and Zen’s card are titled with the role and the place once (in zh-TW “塔台 · Harbor API”), not with the unit’s raw English name; the comms link and Later are in the dialog’s footer. A head tower’s card says the head tower in its own words (zh-TW 主塔台, 鴉閣 in Raven; the _head twins of the card’s messages) where a workspace’s says the tower (管家 in Raven).

The message names the wait it answers (answers: "W3" beside its text), and once the tower took it the server marks that wait answered (the memory route’s answer): it stays in the tower’s memory for the run to resolve (the run prompts, head tower included, say what to do with a row that reads answered by the boss; it takes no place from the 12 open waits a memory keeps, and expires after 7 days like an open one), but it is no alert, so no view raises it again while the tower handles it, and the card and the comms line say what was pressed and who was told, or “Answered, waiting for the tower” (/boss memory says “you answered it; the tower has not resolved it yet”).

Only the wait’s own box marks it: an answer typed in the tower’s comms composer is a plain message and leaves the wait open, and a command (/quit, /new) sent with answers is refused with not_an_answer. A wait the tower writes anew asks again. The wait also stays as a line on the tower card and in its comms until it is resolved, dropped, or expired, so a wait nobody can answer is never stuck there; a day-old wait raises no alert, so a page that opens does not buzz at it.

A brief shows an open wait a day old with (open 2d 3h), and the run checks it and resolves, drops, or restates it; a wait for: "agent" names a live session, and a message from it marks the wait answered (the run judges whether it answers). An open wait expires after 7 days (shown once in the next brief as expired); a resolved or expired one stays 24 hours, struck through in the panel. The standing pane’s “waits for you” line comes with the tower prompt. The demo’s ?waits=cards shows one card of each kind and state, and ?waits=cards&head=waiting adds the head tower’s own card on its page.

Pressing a card in a terminal (todo #144, spec). Every press the page offers is also typed: /boss wait [-w workspace | label | head] in any joined pi lists the open cards of every tower and the head (this tower’s first; -w one tower’s) in a select dialog and then the acts of the card you chose in another (the kind and state decide the acts: done and skip for an action, one per option for a choice, start entering and I’ve entered it for a secret, restate for a wait written before cards, and always reply and back), and sends the very press op of the page, so nothing is pressed that the page could not press.

Skip asks a reason in an input dialog and then a confirm dialog; a reply is an input dialog, sent to the tower as the boss with answers. Numbers and y/N are typed only at the stand-in prompt, which has no dialogs (the standing pane, above). The server lists them with boss-waits (a control command refused to every run token, as tower-memory is), each card cleaned again when read, with parked and the server’s auto flag.

The picker’s dialogs are terminal-only (terminalOnlyOpts), run as the boss’s own command, and sit inside the here mark for the whole picker: the pane must be marked as this computer’s keyboard, and one that cannot be marked refuses the picker, so a phone’s key pad cannot drive it. The secret’s first step is offered only there, and not while auto mode is on (the line says to turn it off first). Each press is logged with its door (via=pi, prompt, run-pane, or gui), never the text; the control socket’s tower-memory takes via from pi, the stand-in prompt, and a run’s pane only, and the GUI’s route is always gui.

The memory file is the boss’s text, so it is kept when the tower service is switched off or its tab is closed; delete its entries with /boss memory delete or in the panel to clear it.

flowchart TD
    m["A Straw Link message for<br/>a tower this server runs"] --> i["Waits in link/inbox"]
    i --> s["One run per device,<br/>up to 5 messages"]
    s --> p["A fresh pi in a pane split<br/>beside the standing pane"]
    p --> t["Works at the sender's<br/>trust level"]

A Straw Link message for a tower the server runs is worked in an ally run: a fresh pi in a pane split off to the right of the tower’s standing pane (same tab, no new tab, no new workspace), at the sender’s trust level, with no local context (spec, Ally runs). This amends the Straw Link rule that an attended device opens no session for a remote message: it opens a session, never a workspace.

Details

An attended device may add the tower’s tab to a workspace it already has (never a workspace or a base, no focus, nothing typed into another pane); a message for a project with no workspace of this computer still waits in link/inbox/ for 7 days with one notice, and the server opens nothing for it (the wake paragraph of Straw Link has the rules).

The ally pane is a row of the Agents list like a subagent’s: the server writes sb_role=ally and the four display tokens on it at the split, and writes them again with the tower’s 4 minute publish (a run lasts up to 20 minutes, the tokens 10). sb_order is the tower’s key plus one segment (000.0 → 000.0.000, the second ally run’s 000.0.001), so the row sits right under the tower; sb_mark is [ally] and the device’s label (cut to 24 characters); sb_indent is ├─ while a later ally pane is marked and └─ on the last; sb_name is ally run.

Details

The server clears them before it closes the pane when the run ends, and the panes of the other ally runs move up. Without them the row fell to the end of the list among the agents the net does not know, and its role token lapsed after 10 minutes.

The pane is marked, started in, and closed only with a fact a stranger under a reused id cannot have (F10). The fact is the terminal the split made, kept in the run record, so a run recovered after a server restart proves its pane too; a recovered run whose record names none is never bound by id alone, and neither is a live run whose split listing never named its pane (the listing is read up to three times): where Herdr lists terminal ids such a run marks and closes nothing by id after a Herdr restart.

Details

A start that waits on a busy pane asks it before each retry and stops when the run has ended or the pane is in another terminal; a pane in an unknown terminal on a Herdr that lists no terminal ids holds as before (the proof is the glyph’s alone). The keys that ask a stuck pi to quit (esc, /quit, enter) go only into a pane proven the run’s (an ally’s terminal, a local run’s tower seat); without the proof they are skipped, and the signal that checks the pid by its start time ends the run.

After a Herdr restart every terminal is new, so the server never touches an old ally pane again: no close, no mark or unmark, no start or retry, no keys. The run ends herdr-restart (its queue and claim rules are as before) and the pane is left for the boss to close; a stranger under the old id, a shell, the boss’s own pi or claude, is left the same way. One gate (allyPaneGate in typegate.ts) allows an ally pane action, and only when the pane is still listed in the terminal its split made.

A pi that Herdr resumes in an old ally pane and asks the server for its role sends its own session file with the ask, and says whether its session carries the ally marker: an ally run writes a straw-boss-ally entry into its own session when it starts, and pi keeps it across every resume.

It is answered quit when that file is the session file recorded by an ally run (before and after the restart is seen) or when it reports the marker; the marker is the durable one, for the run records are pruned (the newest 50 of a workspace are kept) and a session resumed a second time long after its run would otherwise run unpinned at the boss’s level with a teammate’s text in its context. Any other pi in that pane, the boss’s own pi or claude included, and a pi that sends neither (an older client), is answered none and runs as the boss’s ordinary pi.

The server takes the client’s word for the marker because it grants less than none would: quit only makes that pi shut itself down, and no claim, role, or pane action follows from it. The server never touches the pane or sends keys in either case: the resumed ally session leaves by itself, so a teammate’s messages never run at the boss’s level. A boss pi that itself opens an ally run’s session (/resume, /fork) carries the marker and is quit the same way.

The holder leaves the inbox item where it is; the server lists the messages waiting per sender, and starts one run for the device with up to 5 of them, oldest first (a message that comes during the run waits for the next run). The device keeps its run until the run’s end has handed its messages back to the holder (a message listed for that moment is not given to a second run), and the server remembers for ten minutes which messages it handed back: a message the holder still lists for a moment after is never admitted again.

Details

A run reads its messages once more before its pane is split, and one left with none starts nothing (VM recheck A6). One ally run per workspace beside its local run, and it counts toward towerRuns.max. A sender that left the roster has its messages dropped and starts nothing; one that leaves during the run gets the run ended with quit (the server looks at every watch tick and every tool call) and its tool calls refused meanwhile.

The run is claimed at the sender’s level, from link.trust or the unattended policy; the run’s hooks read the policy again and take the lower. The whole session is one remote turn pinned to that sender: the same allowlist, deny list, gate, and forward confirm as a tower’s remote turn (src/link/hooks.ts). A gated call is asked of the boss through the server (the run’s pi shows the dialog; at forward the confirm goes to the sender’s device and its answer comes back at the poll), and the server runs a gated tool call of an ally run only against the gate’s recorded yes for that call id over arguments with that digest, once. At dispatch the gate is off.

Details

The server holds the run to the lower of its claimed level and the device’s level now (lowering trust takes effect on a running run; a raise does not lift it). A claim that names no sender, or a session that cannot pin the turn, shuts the run down. tower_done is free at every level. The server notes the boss’s yes from its start, whether or not the GUI listens, so a terminal answer works with the GUI stopped.

Free at every level: boss_status, tower_list and board_read (both scoped to the sender’s team: its project’s workspaces and that team’s boards), tower_send to the sender’s device (device is required; any other device or target is refused, an Allow or not), tower_done, and the run’s own read, grep, find, and ls inside the project’s tracked files, as in a remote turn. Anything else of the tower’s own tools (tower_reply, boss_open_main, boss_close_main) the server runs only against the gate’s recorded yes (below), except at dispatch, where they run without a confirm.

Details

call_tower, boss_server, link_request, and a tower_reply to a tower identity of this computer are refused whatever the level; close-tower and the other control commands are refused to every run token, and so are the link holder ops that need a session’s identity (taken, gui, request-open, request-run, sending as a tower; B3R-F003). boss_open_main with role: "train" is refused at every level, and with role: "integrator" it is taken only for a car slip a main opened for the run’s own device sent (below). Everything the run sends to a local session carries origin=remote:<sender device id>, stamped by the server.

What a local session says back to an ally run’s tower_reply (with reply: true) or to a main it opened is stored for the device (ally-routes.json, ally-answers.json, 24 hours), not queued for the local run, and waits for the device’s next ally run; the mains an ally run opens have their own ledger (ally-opened.json). The local brief never lists them. A main an ally run opened keeps sending its reports to the device, not just the first answer: its route is outside the 40-route cap, it is renewed by each report it relays, and a main silent for 7 days is let go.

Details

Opened routes are bounded at 8 per device and 20 in all (rows of mains the broker no longer lists go first, then the oldest), a row of a main the broker no longer lists is dropped when the next main opens once it has been silent for 10 minutes, and closing a main drops its row. The device’s own tower_reply with reply: true to its main adds no second route and does not replace the opened one. The terminal’s yes of a gated call reaches the server even when the server serves no GUI: it names its session in hub.json for the bridges.

The sender of a request an ally run hands to boss_open_main is the device, not a session of this computer, so the run passes the header’s id= as it stands (link:<device id>; the short id after link: works too, and a doubled link:link: is read as one prefix). The server takes it only when it names the device the run was claimed for (the id comes from the claim, not from the argument), and refuses any other link: sender, a bare label, or a short id without link: as a device: those are looked up among the live sessions as for a local run.

Details

Nothing else changes in what the run may do: below dispatch the open still needs the boss’s confirm, and a device that left the team opens nothing. The main’s handoff says the request is a peer’s, that it reports to the tower with call_tower and cannot reach the sender, and that its report leaves this computer as written. The route that was always made for an opened main (ally-routes.json) then does the relay: each report is stored as an answer for that device, never queued for a local run, and wakes a run of that device at once, which sends it with tower_send.

A report is read by a run of the device it was opened for and no other, and is dropped, with no run begun, when that device is no longer a member. When the run that opened the main carried exactly one ask (asks=<id>), the route keeps it, the report’s header shows it again, and the run answers it with inReplyTo, so the report reaches the main that asked on the other computer; an ask the run itself already answered is not kept, the ask is spent once a run answers it (a later report carries none), and the role prompt tells the run to send the “under way” note and any progress report without inReplyTo and to answer the ask only with the main’s final report.

An ally run’s tower_reply to a main opened for another device is refused, from any workspace (the main’s own sb_opened_by pane token and the route table are two witnesses, and either one naming another device refuses the write, so a token a local process sets never outvotes the table), and before the boss’s Allow is asked, so no Allow is spent on it: the report of that main stays with the device it was opened for.

The refusal does not repeat the name; a target that does not exist or is ambiguous is not refused there but goes on to the gate like one that exists, so a run below dispatch is told the same of a missing session and an existing one (the gate’s confirm, with no Allow spent), and only a main held for another device is told apart. An opened row keeps the main’s pane and is dropped as dead only when the broker and Herdr both no longer show the main. The opened route is read first when a session has several rows. The set of asks a run answered is forgotten after 24 hours. With several asks in the run none is kept.

A main opened for a device works on this computer, and for an app with integration it ends by sending a car slip with call_tower why=shared, which the route above hands to the device’s next ally run. That run has one door to the project’s integrator, the same as a local run’s: boss_open_main with role: "integrator", the slip, and the main as sender.

Details

The server takes it only for a call_tower (why=shared) this run took from a main opened for the run’s own device (the route table says so, and the main’s sb_opened_by token, when it names a device, must agree), and refuses everything else before the gate, so no Allow is spent: a slip the run made up, a slip of a session that was only written to, a request to open an integrator.

Below dispatch the open still needs the boss’s confirm, and the confirm shows the call’s arguments: so the request has to be the slip exactly as it came (line ends and trailing blanks aside), and any other text is refused before the gate; what the boss allows is the text that is forwarded. The workspace’s checkout has to be inside the project the run’s team binds on this computer. The integrator is opened by the tower’s own opener, as the tower’s own main: it gets no ally route, so its reports and its trains go to the local runs, and it counts toward neither the device’s one main nor the workspace’s two.

The forward carries origin=remote:<device id> beside fwd and fwd-from, and its fwd is a digest of the answer’s id, since an answer has no event id. The coordinator of the car is the device’s main, so the sb server sends it the car notices by session id, and its reports reach the device as before. A device still gets one main: asking an ally run for a second one to act as integrator is refused as before (the case of 2026-11-10 that this closes).

Brief and thread. The run’s role section is prompts/ally-run.md plus a brief the server builds (who and at which level, what the sender may see of the workspace, the last handoff note for this device, the asks the run carries, the last six rows of the device’s thread, and its tools; 4,000 characters at most). The thread store (link/threads/<device id>.jsonl, mode 600) keeps both directions, the last 20 messages per device, none older than 7 days, and is deleted with the device’s roster entry. It holds peer text at rest.

A device’s answer never enters a tower run, so a tower that sends work to a device for a local session must make an ask or nobody here gets the answer. tower_send with device and reply: true and no for takes the requester itself: the one session that sent the run a call_tower expecting an answer (reply=expected, from the header the code wrote) among the events the run took, when it is a live main (since 0.58.2 of any workspace on this computer, not only this tower’s; see below); the result says so (“The answer will come to

directly (the main that called you for this)”).

Details

Two askers, an asker that is a member or is gone, a call that expected no answer, a boss event, and reply: false bind nothing; with reply: true the result then says that no session is bound (with reply: false it adds nothing) and the answer reaches an ally run, so the tower does not promise a relay it cannot make. for still wins; it names a main of the workspace, or a main of another workspace on this computer that sent this run a call_tower expecting an answer.

A requester or a for from another workspace is bound only by that call: the server reads the asker from the same events, checks it against the broker and Herdr’s pane token (sb_role=main), and refuses for for a session outside this workspace that did not call this tower in this run (a main of this workspace needs no call). The requester is looked up by session id only, never by name. The ask is recorded under the tower’s workspace for that session id, and the holder offers the answer to the session by id, so nothing in the delivery depends on the workspaces matching. The ask is single-use, 24 hours, bound to the device asked, and dropped with it when the device is revoked, as before.

caps is a team-wide message each holder sends at each stream start, when its build changes, and hourly: the features it takes (tower.awaitsReply, tower-reply); one older than 2 hours counts as none. tower_send with for (above) sends a plain 4-field tower to a device whose caps lack tower.awaitsReply, and a tower with the ask id as its fifth field to one that has it; the tool’s answer says which. A message that carries an ask id shows asks=<id> in its header, and the ally run answers it with tower_send({ device, text, inReplyTo }); an inReplyTo that is not one of its own asks is refused.

Details

The answer goes as kind tower-reply to a device whose caps list it, else as a plain tower (also when the relay refuses the kind with unknown-kind; logged once per device and hour). The asking computer takes a tower-reply as a reply only for an open, unused ask from that device, closes the ask, and offers it over straw-boss/link to the main that asked, which holds it until idle and puts it into its session as a straw-link message with triggerTurn under the verified header plus kind=link_reply and for=<tower>. That main then runs a remote turn at the replying device’s level.

A main that has gone, or that has not taken the offer within 5 minutes, gets an ally run instead, whose brief says which main asked; so does any other reply. Against an older build nothing is dropped: the ask comes back as a plain tower and gets an ally run.

boss_server and the restart limit. An ally run’s boss_server is refused by its kind; for any run the server keeps the limit of one restart a minute itself, so two agents asking at once cannot both pass.

A controller’s work ends with its dispatch

Section titled “A controller’s work ends with its dispatch”

An ally run claimed at dispatch is quit by the watcher when its device has left the team or has fallen below dispatch (two ticks in a row, and only while the holder can read the team’s roster), with the reason in the end notice. The mains an ally run opened at dispatch (the route keeps the run’s level; a row without one counts as dispatch) are checked every 15 seconds by revoke.ts against the roster and the policy, and a main whose device is gone or below dispatch is asked to quit like boss_close_main does, asked again while it is busy, and let go with its route when it quits; the team’s admins are told once that it still runs and once that it ended (spec).

Details

Crashes and limits are the local run’s (settles idle, 20 minutes, three failures in 10 minutes hold the service), per device.

The wire. caps, the 5-field tower, and tower-reply are in src/link/kinds.ts and tests/link/vectors.json (the Straw Link spec’s Message kinds table has the rows); straw-relay carries the same two rows and a copy of the vectors, and is redeployed first.