This page defines the terms of Hand as its code implements them. The design and its reasons are in spec.md. What a command accepts and prints is owned by the command itself and its tests.
People and agents
Operator
The one person Hand is built for. The operator owns intent and every irreversible choice: they talk to the supervisor, answer decisions, and approve what the supervisor proposes. Their standing preferences live in the operator memory file.
Supervisor
The single agent the operator talks to in a fleet. It captures each request as a task, writes plans and briefs, starts attempts, reads reports, and asks the operator through decisions. It does not write the code itself; workers do.
Normally Hand runs it: the managed supervisor lives in the background in the fleet's Luvus session, started from the fleet home in full-auto mode, and the operator reaches it from the board. Each launch gets a ref sN, and a new session's launch message begins You are supervisor sN of the Hand fleet NAME; a resumed one gets no launch message, so hand orient prints you: sN for the running supervisor's own pane (from $LUVUS_PANE_ID) and you: not the supervisor for any other pane. It prints no you: line when the variable is unset. A session that sees a running supervisor and no you: sN line is not the supervisor. At most one supervisor is live at a time. A long-lived process records the board generation it started under, and any close or signal of a supervisor pane re-reads board.generation; when the two differ, or the generation it holds is no longer alive, it records supervisor.refused with both and changes nothing. A process started with no board records no generation, so it guards nothing until a board writes one.
Resuming continues the same harness session under a new sN. hand orient names the current sN and adds the first one of that session, as in s6 claude sonnet high running (resumes s1). hand supervisor show names the current one and adds a resumes: line with the first. After the relaunch settles, Hand types one message into the pane: [hand v1 resume] you are now s6 (resumes s1). A session whose first Hand row predates the fleet move cannot be resumed from the new home, so resume starts a fresh one, records supervisor.fresh and prints fresh: (see Move).
A rollover replaces a managed claude or omp supervisor whose context has grown past supervisor.rollover_tokens (default 300000; 0 turns it off) with a fresh session on the same harness, model and effort, so a session no longer grows without bound. The watcher starts one when the supervisor's context, read from its transcript, is over the limit and the supervisor is quiet: Luvus reads it as idle or done with its screen unchanged for 30 seconds (the test the Luvus switch uses), no message or wake waits for it, its screen shows no usage-limit line, no model switch is pending, and no hand update runs. It sends one message, [hand v1 rollover], asking the supervisor to write its handoff note to memory/handoff.md in the fleet home and answer done. Once the note is written (a file that is new or changed since the request, not empty) and the supervisor's turn has ended, Hand stops the session and starts a fresh one whose first message says to run the full hand orient and read the note; the fresh session takes over the old one's wake cursor, so wakes not yet delivered reach it. It records supervisor.rolled_over on the new sN with the old and new session ids and the token count, and never rolls over twice within an hour. When the note does not come within 5 minutes, or the harness is missing, or the watcher's board generation is stale, or the request fails three times, Hand keeps the supervisor, alerts once (supervisor.rollover) and does not ask that supervisor again; a fresh session that cannot start after the old one stopped is replaced by a resume of the old one (when Luvus gave no answer to the launch, the alert says it may still start). A switch asked during the exchange cancels the rollover. Other harnesses are not rolled over. Hand holds the supervisor lock for the whole exchange (up to 5 minutes plus the launch), so a Board or CLI stop, send or switch waits; interrupt does not. A message or wake that arrives meanwhile reaches the fresh session.
A supervisor has the same six states as an attempt. It is stopped after hand supervisor stop or a model switch, and interrupted after a reboot or a Luvus server restart.
An agent the operator opens by hand in the fleet folder is not the managed supervisor. It waits with hand wait instead of receiving wakes, and it stands down while a managed supervisor runs.
Worker
The harness process that runs one attempt. It works in the attempt's worktree, never in the fleet home, and it sends its result back with hand report add from inside that worktree. Hand finds the attempt from the worktree's path.
Harness
The agent CLI that Hand launches as a supervisor or a worker. There are six: claude (Claude Code), codex (Codex), opencode (opencode), agy (Antigravity), copilot (GitHub Copilot CLI) and omp (oh-my-pi). All but copilot can run the supervisor or a worker; copilot runs workers only, and hand supervisor start and switch refuse it. Each runs in full-auto mode:
| Harness | Launch flags | Model and effort |
|---|---|---|
claude | --dangerously-skip-permissions, with --session-id UUID and --strict-mcp-config (see MCP) for a worker | verified against the model list Claude Code reports for itself: its aliases and ids, and the names its settings and environment add, each with the efforts it supports. Before the first list: the aliases opus, sonnet, haiku and fable with effort low, medium, high, xhigh or max. An alias or a claude-* name is accepted unlisted. Any other name made of letters, digits and . _ : / @ [ ] # + - that the list lacks (a gateway name, a Bedrock or Vertex id, opusplan) starts with a warning; so does an effort the model's entry does not list. The watcher reads the model back from the newest assistant record of the session transcript (requestedModel, else the served message.model; never a sidechain or a <synthetic> message) and its perTurnEffort, else effort, so a /model or /effort switch in the pane shows within one sweep (--every, 30s by default) after the next reply, and records them with a model.changed event when they differ. These columns are also the launch spec that hand supervisor resume and switch and the route track rows read, so a model that is only what the stored alias resolves to (claude-sonnet-5-5 for sonnet or sonnet[1m], claude-opus-5-5 for opus, a dated claude-haiku-4-5-20251001 for haiku or claude-haiku-4-5, a claude-3-5-haiku-… id for haiku, an opus or sonnet id for opusplan) leaves the alias in place, and switch calls that spec already running; any other model (a switch, a gateway name) is stored as given, [1m] included, which a resume passes to --model. A fallback reply keeps the model that was asked for, because requestedModel does not change; a transcript without requestedModel shows the fallback's served id, and the next reply returns it. A model or effort that fails the token checks below (a space, a quote, a leading -) is ignored and the stored values stay. A reply without an effort keeps the stored effort, also when the model changed. /clear or a resume inside a worker's pane may start a new session file, which the watcher does not follow (unverified) A supervisor is read by its session column, and a reply older than the supervisor row, or without a timestamp that parses, is ignored, so a switch keeps its launch model until the first reply after the resume. A worker is read by the UUID after --session-id in its launch line; a worker launched before this change has none and keeps its launch model |
codex | --dangerously-bypass-approvals-and-sandbox --disable hooks --cd DIR | verified against the model list, read from codex debug models --bundled, else from Codex's $CODEX_HOME/models_cache.json, which is also the answer before the first list. A model not in the list, an effort the model does not list, or no list at all starts with a warning. A hidden model is known but not offered. The watcher reads the running model and effort back from the newest turn_context of the session's rollout, so a /model switch in the pane shows after the next turn starts, within one sweep (--every, 30s by default), and records them in the model and effort columns with a model.changed event when they differ. A supervisor is read by its session column, and a turn older than the supervisor row, or one without a timestamp that parses, is ignored, so a switch keeps its launch model until the first turn after the resume. A turn without an effort keeps the stored effort while the model is unchanged. A worker has no stored session id, so the watcher finds its rollout by the attempt's folder, its start time and its launch prompt, and tries again each sweep until it appears |
opencode | --standalone --auto --session ses_hand<26 hex> | optional. A model is provider/id, split at the first / (the provider is any name without a /, as a key of opencode's provider config is, so the id may hold more slashes, colons, @ or spaces, but no #), and the effort is opencode's own variant name for it, which may not be an effort level (fast, thinking, none); an effort needs a model. opencode's TUI has no --model, so with a model Hand first runs opencode api --standalone session.create with no shell, before it creates the pane, giving the Hand-chosen session id, the model, the variant (left out when empty) and the folder, and then starts the TUI on that session; a failure, or an answer that is not a session with the id Hand asked for, fails the launch with opencode's message or Hand's own and creates no pane. A resume runs no session.create, because the session keeps its model. Without a model Hand runs no session.create and opencode uses the model from its own configuration. Some versions submit the --prompt themselves and some only prefill it. Hand asks opencode, by the session id it chose, with one opencode api --standalone session.message.list --param sessionID=ID --param type=user --param order=desc --param limit=1 call per look, bounded at 5 seconds: a user message in the reply means the brief was submitted, and nothing is pressed. When that call shows no user message, fails, times out or returns an unexpected shape, the screen decides: the input box (the ┃ lines above the last ╹▀▀▀ line) holds nothing but its Build … · model status line and the agent is working or done. The screen cannot confirm a brief in a wide pane, where opencode's sidebar text sits on the box's empty rows, so there only the API call confirms it. A brief that is not submitted that way gets Enter, up to three times, only while the box holds the When you finish, report to Hand line (or the supervisor's You are supervisor line) and the agent is idle; Hand never presses Enter into a running turn. Hand otherwise prints prompt: not submitted or prompt: sent unconfirmed, and notes the attempt blocked, which the next working reading from the watcher clears. Hand warns, and starts anyway, when the model is not in the model list (hand models opencode) or there is none yet; it warns, and starts anyway, when the list gives variants for the model and the variant is none of them (default always passes), because opencode resets an unknown variant to default and runs an unavailable model without sending the launch prompt. When the launch prompt is not submitted, or Enter did not get it going, and the status block of the input box (the ┃ lines from the last empty one down to the ╹▀▀▀ line) shows (unavai after a ·, so a pane that wraps the line, or truncates it after (unavai, still counts (an id too long for the pane hides it and gets the Enter hint), Hand notes the attempt or supervisor blocked with opencode model X is unavailable: connect its provider or pick another model, X being the model Hand launched, or, when Hand passed none, the name as the status line shows it (opencode replaces an unavailable configured default with its own default model, without any note), and does not suggest pressing Enter, which cannot help. The board's Start row takes the model and the effort, and hand attempt start --harness opencode --model local/m2 --effort deep names both. The watcher reads the model back from opencode and records it in the model column as opencode's provider/id (split at the first /, so the id may hold more slashes, colons or spaces), and the variant in the effort column unless it is default, which stays empty; the variant is opencode's own name for it and may not be an effort level (fast, thinking, none). When a model was asked for at launch (or at a supervisor's start or its resume) and the model column later differs from it, or the variant asked for (other than default) differs from the variant column, hand attempt show and hand supervisor show print model_note: Asked for X, running Y., the attempt page and the supervisor's model menu on the Board show the same line, and it goes away when the two match again. The request is kept in a model.requested event written when the row is added and again, with the new model, by a successful hand attempt switch (a switch that fails keeps the old request), so a model that was already stored without a read-back is never reported. A variant that opencode reset to default shows as running X default. One opencode api --standalone GET /api/session?limit=1000 per sweep, which also answers the busy question of the Agent state paragraph, gives the selected model of the 1000 most recently updated sessions, so a switch in the opencode TUI shows within one watcher sweep (--every, 30s by default), before the agent replies. A session that call lacks or lists without a model is read from opencode session export once, and again each sweep until that export has a model, as is every session when the call fails or its shape is unexpected: there the newest assistant or model-switched message wins, else the session's own model. A track row uses the last model seen, so an attempt that ended before its model was read has an empty one. A gateway (OpenRouter auto, a LiteLLM alias) shows only the alias the user picked, never the model it routed to, in opencode and in every other surface, so a track row for a gateway alias mixes the models behind it. A row is keyed on the stored model and effort, and a mid-attempt switch credits the whole attempt to the last model seen. Hand chooses the session id (ses_hand and 26 hex characters): a worker's is kept in the attempt's stored command after --session, a supervisor's in its session column, and a resume passes the same id, so the watcher reads each model by that id and never lists sessions. An opencode supervisor started by an older Hand keeps the session id Hand found for it, and is read and resumed by it. One with no id stored keeps running and receives messages, but its model is not read and hand supervisor resume refuses it: start a new one with hand supervisor start. An opencode worker launched before this change keeps running and its model is not read. A worker keeps the session it launched in: /new inside a worker leaves the old session's model displayed. Its model names are in the model list (hand models opencode) |
agy | --dangerously-skip-permissions, with -i BRIEF for a new session or --conversation ID to resume | verified against the model list read from agy models, whose id names the effort level (gemini-3.8-flash-low); no effort. A model the list lacks, or no list yet, starts with a warning |
copilot | --no-auto-update --allow-all-tools --allow-all-paths --session-id UUID -i BRIEF, a worker only | --model is required (auto always works); --reasoning-effort is passed only when an effort is given, and Auto has none. Hand verifies the model against the model list read from copilot help config, plus auto, and the effort against the flag's own set (none, minimal, low, medium, high, xhigh, max; Auto takes none); a model the list lacks, or no list yet, starts with a warning. Copilot does not refuse a model its plan lacks: it prints Model "X" from --model flag is not available. Using "auto" instead. and runs on Auto; Hand notes the attempt blocked with the model's name when it sees that line at launch (best effort: a line that scrolled off the pane first is missed); the note is an ordinary attempt.blocked that neither the watcher's sweep nor a watcher start clears, so it stays until the first turn ends. The watcher records the model of the latest assistant message of the worker's session (the one after --session-id in its launch line) in the model column with a model.changed event when it differs, so a worker started on Auto shows the model that answered; the effort stays as launched |
omp | --auto-approve --no-title --model SELECTOR [--thinking LEVEL], with BRIEF for a worker or the launch prompt for a supervisor, -- before the prompt, and --resume ID to resume | --model is required and is the whole provider/id selector, whose id may hold more slashes; Hand never splits it. Hand verifies it against the model list read from omp models --json. An effort must be one of omp's levels (off, minimal, low, medium, high, xhigh, max, auto): a model's thinking array is a menu, not a rejection list, and omp runs an unlisted level too. A model the list lacks, a level omp does not accept, or no list yet starts with a warning. -- keeps a brief that starts with @ from becoming a file include and one that starts with - from becoming a flag. The watcher reads the served model from the latest assistant message (its provider and model), because a resume with a new --model appends no model_change, and keeps the requested effort because a resume with a new --thinking appends no thinking_level_change. A supervisor is found by its launch cwd and launch marker under sessions/*/*.jsonl in the agent folder; a worker has no stored session id, so the watcher finds its session by the attempt's folder, start time and launch prompt. Measured on Linux only; Windows and macOS have not run a real omp |
Hand refuses only what is malformed: an empty model (not for opencode, which takes an empty model with an empty effort), an empty effort (not for agy, copilot, opencode or omp), a model of any harness but opencode other than 1 to 200 characters of letters, digits and . _ : / @ [ ] # + - that starts with a letter or digit (so no space, control character, NUL, quote, $, ; or leading -), an effort other than 1 to 32 lowercase letters, digits, _ or -, an effort for agy, an opencode model that is not provider/id, holds a # or a control character, or is over 200 characters, an opencode effort without a model, and an unknown harness. Everything else is the harness's to accept, so Hand starts it and prints a warn field naming the harness, the model and why it is unverified (`codex model acme-coder-1 could not be checked: no model list yet; run hand models --refresh codex; starting anyway). The warning is a field of hand attempt start, hand supervisor start, resume and switch, and a note on the board after Start (a model that has [left the list](#model-list) of a fresh Codex or agy list is refused). These start paths read the cached list to check the model and never run a harness for that check. An opencode launch with a model still runs session.create before its pane (see the opencode` row). If the harness then rejects the model, it says so in its pane, and the failed launch names the harness and the model.
--disable hooks turns off every Codex hook in managed sessions, including hooks the user set up on purpose, so anyone relying on hooks for auditing or policy checks loses them.
Agent state
Luvus's reading of the agent in a pane: idle, working, done or blocked. Hand's messages go through Luvus's fenced input, which refuses them while the agent is not ready for input, for example at a permission prompt. For a worker, the watcher notes attempt.blocked when the first observation (a status event or the start-up catch-up) and the next observation (the next status event, else the next reconcile tick) both read blocked, so a block that clears first records nothing and wakes nobody; the confirming observation records only when the screen shows one of the prompt shapes Hand reads (codex's ? N question screen, a question with a choice row at the bottom of the screen or within the next line or two, an Allow once/Deny row, a trust screen or a Claude permission or dialog screen), not a box corner, a rule line or a status-bar fragment, and it records the block with Luvus's hint when it cannot read the screen; it turns working after a block into attempt.working (no wake, and it ends the blocked label; for opencode also when hand attempt start noted the block at launch, which the sweep checks, while another harness's launch note stays until the turn ends, and so does a block of another harness answered while the watcher was down), and done, or idle after the worker worked or blocked, into attempt.quiet. It records no turn end while a turn that starts at attempt.running, or at the first attempt.sent or attempt.keys after a turn end, is less than 30 seconds old, because a freshly launched harness can read idle while it starts; for a turn it watched start, the age is measured from the working it read. That first idle becomes the baseline, and a turn that ends after that window is recorded as before. A new attempt.limited is still recorded in that window. Before it records either for opencode, it asks opencode whether the attempt's session or a subagent session under it is still busy. Where a session has time.busy (opencode 2.2.2 and later), it is busy while time.busy is set and later than time.idle; without it (2.2.0 and 2.2.1), a subagent session is busy while its time.idle is missing or older than its time.updated, and the attempt's own session only while its time.idle is missing, because opencode also moves time.updated for a title, a model or an archive. A session not updated for 60 minutes is not busy. The question is the sweep's opencode api --standalone GET /api/session?limit=1000 call, bounded at 5 seconds, that runs beside the watcher's event loop and answers for every opencode attempt waiting on it. While a session is busy, the turn has not ended and nothing is recorded (its 60-minute clock keeps running, so attempt.long still comes); each sweep asks again while any attempt waits, and the turn end is recorded from the agent state Luvus then reads once the session and its subagents are idle. A watcher that reconnects keeps the turns it holds. A watcher that starts finds an opencode worker already idle, asks once, and holds it with its clock rebuilt if a subagent runs. Otherwise it records nothing, unless opencode says the session went idle after the attempt's turn began, a turn that ended during the downtime: that turn end is recorded, as attempt.idle (which wakes nobody) after a done or stuck report, and as attempt.quiet after attempt.running or a progress report. One failed, slow or unreadable call keeps the hold for one more sweep; a second in a row records the turn as before. Luvus reads a wide opencode pane as idle while a background subagent runs after the turn that started it. When the attempt's newest event is already attempt.quiet or attempt.idle, or is a done or stuck report, the supervisor already knows, so the watcher records that turn as attempt.idle instead, which stays in the timeline, hand orient and the board and never reaches the supervisor. A turn that ends after a progress report stays attempt.quiet: it is the likely false done. Declining a permission prompt, or Claude Code's own two-minute auto-deny, ends the worker's turn this way. The watcher records no attempt.blocked, attempt.working, attempt.quiet, attempt.idle, attempt.limited or attempt.long for an attempt that has ended, also when hand attempt stop and a turn end race: the check is in the transaction that would record it.
A worker that hits its harness's usage limit does not exit: it goes idle at its prompt. When a turn ends with a limit line on the screen, the watcher records attempt.limited with that line instead of attempt.quiet, unless that line, without the time Hand adds, is the one the attempt's latest attempt.limited recorded. That line then belongs to an earlier turn. A turn end whose screen Hand cannot read is retried when the watcher reconnects, and is not recorded as quiet. attempt.limited always wakes the supervisor and is never recorded as attempt.idle, even right after a done report, because the limit is new. A turn that ends after it is attempt.quiet. The lines are claude's You've hit your … limit · resets …, codex's You've hit your usage limit … try again at …, and agy's Individual quota reached … Resets in DURATION, to which Hand adds the reset time in UTC. copilot's is Session limit reached. Increase or unset the session limit to continue., shown only when a session limit is set (--max-ai-credits or /limits; Hand sets none). It has no reset time, so a second hit that follows the first with no other limit line between is recorded as attempt.quiet. A spent Copilot plan allowance or rate limit has no line yet: its text has not been captured. opencode and omp have none yet. Claude Code continues by itself at the reset; copilot waits until the session limit is raised or unset.
A turn that stays working for 60 minutes without a break records one attempt.long, with the detail working for 60m. The watcher checks on its reconcile tick, reads no screen, and records it once per turn: the next working to idle or done change or a blocked that holds to the next observation ends the turn, and a later turn can record it again. A restarted watcher rebuilds the turn's start from the attempt's events: the attempt.running, or the first attempt.sent or attempt.keys after the last attempt.blocked, attempt.quiet, attempt.idle or attempt.limited. It neither restarts the 60 minutes nor records a second attempt.long for that turn. A turn that began with no such event, because the worker resumed working on its own, counts from the restart. attempt.long always wakes the supervisor and is never recorded as attempt.idle.
Fleets and files
Hand
The CLI, one Go binary. It owns state, worktrees, the watcher and the board. It calls itself by its binary's name, so a build installed as hand-next writes hand-next into the fleet's AGENTS.md, its skill, its help lines and its errors.
Channel
Where a hand binary comes from. hand version prints it next to the version and the commit:
source: a localgo build;edge: the rolling build ofmainthat passed CI, published as theedgepre-release;stable: the binaries attached to avX.Y.Zrelease.
install.sh installs stable by default, and edge with HAND_INSTALL_VERSION=edge. On Windows, install.ps1 does the same, also with -Edge or -Version, from hand-windows-amd64.zip and its .sha256. hand update stays on the channel of the running binary, and hand update --channel edge|stable moves to the other one.
Update
What hand update does, in order:
- downloads and checks the newest build of the channel;
- fetches the hand-iroh of that release, when the new
handnames one and the release carries it, and checks it, before anything changes; - pins the Luvus version that build was tested with, when it is newer than the pin;
- backs up each fleet's
hand.dbtobackups/in the fleet folder and the old binary to$SECONDHAND_HOME/backups/, keeping two of each; - replaces the sidecar, then the binary;
- for each fleet:
- stops its watcher by
watch.pid; - switches its Luvus server to the pin when the fleet is stale, quiet and settled (see Luvus switch below), otherwise reports
pending: <reason>; - starts that watcher again the way
hand supervisor startdoes, when it stopped one;
- stops its watcher by
- stops the board by
board.pidand starts it again the wayhand opendoes, when it stopped one; - runs
hand initin each fleet.
hand update --check walks steps 6 and 7 on the same path without changing anything: it fills the same units rows, each marked would run, and stops no process, no supervisor and no Luvus server and runs no hand init.
Luvus switch
On every OS a fleet's server is stale when its UHP ping version differs from the pin's. A server that does not answer is not running and a server that answers without a version is unknown; neither is switched, and hand update --check lists such a fleet under that name and plans no switch for it. A stale fleet is switched only when it is:
- quiet (issue #774): no live attempt, an idle supervisor, no queued input, and no live pane besides the supervisor's and the shell Luvus opens when it starts;
- settled: no event in the fleet newer than 15 minutes, and, for
hand update --auto, noluvus.failedevent for this pin version; - not the fleet this update runs inside: a
hand updatewhose environment hasLUVUS_SESSIONequal to the fleet's session stayspendingfor that fleet. The board's timer scrubs that variable, so it may switch the fleet later.
The steps, where any failed check leaves the fleet pending: <reason> and changes nothing more than the watcher's stop and start:
- check quiet and settled;
- stop the fleet's watcher by
watch.pidand journal it; - read the supervisor pane's screen
content_revision, wait 30 seconds, read it again, and check quiet again: typing or output keeps the fleet pending; hand supervisor stop(journaled), then check quiet again; a queued input or a new pane resumes the supervisor and keeps the fleet pending;- stop the server:
server.stop, then wait up to 10 seconds for the process inserver.pid(in the session folder thatluvus session list --jsonreports) to exit, and end it itself (on Windows its whole process tree) only when the stop was acknowledged orpingno longer answers; a recycled process ID, whose start marker differs from the one inserver.pid, is never signalled, and a server that answerspingbut refusesserver.stopis never forced; - start the server from the pin (
luvus.Ensure) and require thatpingreports the pin's version; - resume the supervisor (a new
sNon the same session), start the watcher, and append the eventluvus.switchedwith the detailOLD -> NEW.
A failure at step 4 to 6 (the supervisor's stop, the server's stop or start, a wrong version) appends luvus.failed with the detail PIN: reason (a server that comes back with another version is one), resumes the supervisor and starts the watcher again as the journal's repair path does, and the update exits non-zero. The board's timer, which passes --auto, never tries the same pin again; a manual hand update does. Both events show in hand orient's recent events, as one line in the board's chat (Luvus 0.14.3 → 0.15.0 (switched while quiet)), in the fleets row (switched, pending: <reason>, failed: <reason>) and in update.log.
Accepted losses: a hand attach viewer is disconnected, and text typed but not sent in the supervisor's pane is lost when the screen stayed still for 30 seconds. No worker loses work, since a switch needs no live attempt and no pane Hand did not start.
While update.lock is held, the board starts no watcher, so it cannot start one in the middle of a switch.
Stopping is the same on every OS: read the pid file (PID START-MARKER BINARY), skip it when the process no longer matches its start marker, skip it (reported as skipped: runs BINARY, and the fleet's Luvus switch stays pending) when it records another binary than the one being updated, then send SIGTERM and wait up to 10 seconds (Windows: TerminateProcess); a process that is still alive fails the step and is not started again. A pid file from a build that recorded no binary counts as the updated one. The process exits cleanly, so a systemd unit with Restart=on-failure does not bring it back. An installed secondhand-board unit is started again by the board's start; an installed watch unit stays inactive while the transient secondhand-watch-<fleet id> unit runs the watcher, and hand update disables and removes it when its HAND_HOME is relative or gone, no fleet is registered there, or a transient unit already runs that fleet's watcher (--check only plans that removal). It counts a loaded transient unit, which systemd-run --collect unloads when the watcher exits. watch.lock and the board's listen address keep a second watcher or board from running: the second exits (status 3 for the watcher, which systemd does not retry).
Before its first change it writes $SECONDHAND_HOME/update.json, a journal of the steps still to run, and removes it at the end. If an update dies part way, the next hand update finishes those steps first: it starts the watchers, resumes a supervisor it stopped (a supervisor whose resume failed after a Luvus switch stays in the journal until a resume succeeds), starts the board and runs hand init. That report reads status: repaired.
hand update --keep-luvus runs the same steps but never switches a Luvus server: every fleet whose server differs from the pin stays pending: --keep-luvus, and when the binary is already current and no newer Luvus needs pinning it returns up to date before it stops anything. Without --keep-luvus, a current binary and no newer Luvus still return up to date when no fleet is stale; otherwise hand update visits only the stale fleets: it stops no board, runs no hand init and leaves every other fleet's watcher alone, and its fleets rows show skipped: current under init. hand update holds a lock on $SECONDHAND_HOME/update.lock for the whole run (not --check): a second run exits at once with exit code 3. The journal is written to a temporary file, synced and renamed, and its folder is synced on Linux and macOS.
Update timer
hand board --update-every DURATION (0, the default, is off; 10 minutes at least) makes the board loop that keeps the watchers alive also start hand update --channel CHANNEL --auto as a detached child every DURATION, with its output in $SECONDHAND_HOME/update.log and LUVUS_* variables other than LUVUS_HOME scrubbed from its environment. It starts none while the previous child lives or update.lock is held, and none from a build whose channel is source (the board says why once). The board records DURATION in board.update-every, so the board an update restarts keeps it; a board started without the flag removes the file. With the timer off nothing switches a Luvus server by itself.
A backup restores with the units stopped: remove the stale hand.db-wal and hand.db-shm, then copy a backup over hand.db: the newest hand.db.<stamp> from hand update, or a daily hand-YYYYMMDD.db from the watcher, both in the fleet's backups/ folder, or in $SECONDHAND_HOME/backups/<fleet id>/ when an older hand made them.
Secondhand
The family and storage name around Hand: the shared folder ~/.secondhand, the secondhand skill, and the secondhand-* Luvus sessions and systemd units.
Fleet
One supervisor's world: a fleet home with its projects, tasks, attempts, Luvus session and watcher. Several fleets can run side by side, and one board serves them all. hand fleet list shows the registered fleets and whether each home is ok, missing, moved or unreadable.
Fleet ID and fleet name
The first hand init in a folder gives the fleet an ID that never changes: f followed by 12 hex digits. The ID names the fleet's link in the shared folder, its worktree folder, its Luvus session, its branches and its board path. The name is what people see. It defaults to the folder's name, and hand init --name NEW changes it. A move follows the folder while the name still equals the old folder's base name (on Windows, ignoring case); a custom name stays.
Fleet home
Any folder that holds hand.db, which hand init creates. Commands find the fleet home in this order:
--home DIR, given before the command;- the working directory or the nearest folder above it that holds
hand.db; $HAND_HOME.
A HAND_HOME that differs from the fleet home found from the working directory is refused while you are inside a fleet. A fleet home cannot sit inside another fleet or inside the shared folder. To move a fleet, use Move; a plain mv leaves every recorded home path behind. A copy of a fleet home is refused while the fleet still sits at its registered home: delete the original or move it off that path, which can keep it on disk as an archive, and hand init in the copy then adopts it.
A fleet home holds:
hand.db: the SQLite state;AGENTS.mdandCLAUDE.md, which Hand owns and rewrites on everyhand init;- the
secondhandandsecondhand-cleanupskills under.claude/,.agents/,.grok/and.pi/, whichhand initowns, and the skillshand skills addcopied beside them, markedmanaged-by: hand-skillsand recorded in.agents/skills/.hand-skills.json; memory/: the operator memory;routing.json: the watcher'snotifycommand and itsstall,inboxandretentionsettings, andsupervisor.autoresumeandsupervisor.rollover_tokens;inbox/: the files attached on the board, and its trash (see Inbox folder and trash);reports/andshots/: the documents and screenshots workers leave for the operator (see Retention);scratch/: each attempt's scratch folder,scratch/tN-aM(see Scratch);board.token, made the first time the board serves the fleet;fleet.home: the homes the fleet has lived at, with.fleet.home.lockbeside it, which serialises its writes;fleet.host: the host marker, present while a watcher runs;hand.version: the version pin;- run-time files such as
luvus/,locks/,backups/,cache/,watch.lockandwatch.log.
Move
Moving a fleet is hand fleet move --yes PATH: one transaction rewrites every text column's old home to the new home in its plain, file://, \/ and JSON \u002f spellings (and on Windows its slash and ASCII case variants), records the new home in the fleet row, renames the fleet to the new folder's name when its name still equals the old folder's name (fleet.renamed, in the same transaction), verifies that no recognised spelling remains as a path in the database and refuses naming each cell it cannot reach, checks foreign keys and integrity, and writes a fleet.moving marker; then the folder moves, the bookmark in ~/.secondhand/index follows and a fleet.moved event records the move. The fleet ID, refs, branch names and worktree paths do not change; fleet.moved rows are history and are never rewritten by a later move. ~/<old folder> becomes ~/<new folder> only when both are direct children of $HOME; otherwise it becomes the absolute destination, and a ~/<old folder> that is not the old home is left for you to edit, which the dry run lists and the move refuses. A path followed by ., +, a space or a non-ASCII byte is a longer name (fleet-hand.bak, fleet-hand backup), never the fleet, except that a . at the end of the cell or before whitespace ends the path; / and \ separate it from what follows. --dry-run lists each cell with its absolute and ~ occurrence counts and the verification's hits; it writes nothing to hand.db, though it can create or change hand.db-shm and hand.db-wal. --from OLD rewrites a recorded home other than the folder the command runs in, and --rewrite-only --yes does that rewrite and verification without moving the folder, which is how a copy on another filesystem is rewritten by hand; the first hand command in a copy adopts it once the original is deleted or moved, and a folder whose database records another home, which --from alone cannot move: rewrite first with --rewrite-only --yes --from OLD. If the process stops after the rewrite and before the rename, hand orient reports the interrupted move and a rerun of hand fleet move --yes PATH finishes it without touching the fleet.moved history. After the rename, a gitdir: prefix in a worktree gitfile under ~/.secondhand/worktrees/<fleet id>/ that names the old home is rewritten to the new home, leaving the rest of the file alone; the move reports the rewritten, already correct and broken worktree counts, and refuses when one is left broken (a missing .git file or a missing target). A move refuses a live supervisor, attempt, watcher or board, pending supervisor input, an armed timer, a destination that exists and is not empty, the same or a nested path, another filesystem and a fleet missing from the registry; a folder whose database records another home is refused even with --from, so rewrite it first with --rewrite-only --yes --from OLD. The watcher and board checks trust their pid files, so a stale file or another $SECONDHAND_HOME is not seen. A supervisor's harness session is not in hand.db, so it cannot move with the fleet: for a session whose first Hand row predates a move or an adoption, hand supervisor resume starts a fresh session instead of handing the harness a session that names the old folder, records supervisor.fresh with the move, and does not carry the earlier conversation over.
Adopt
Adopting is the rewrite a fleet gets at its new path: hand fleet adopt [--from OLD]... [--check], or the first hand command that opens the fleet when fleet.home's current or the fleet row's home differs from the folder it runs in and no old home still holds a hand.db of this fleet (another fleet's database, a file that is not a Hand database and a folder this user cannot enter do not count). One transaction rewrites every text column's known old homes to the folder in the same spellings as a move, verifies that none remains (a ~/<old folder> that is not a direct child of the running user's $HOME is left alone, not refused), checks foreign keys and integrity, and appends fleet.adopted with the old homes, the cell count and the occurrence count. fleet.adopted rows are history and are never rewritten. A path followed by a space or a name byte is a longer name and keeps the old path, as in a move; the verification does not see it. A home recorded as a Windows path is matched in its backslash, escaped, slash and file:// spellings, with the drive letter in either case, on every system, and the separators after it become / when the new home is not a Windows path. A rewrite a column cannot hold fails naming the table, column and ref and changes nothing. It refuses while the watcher (watch.lock), the supervisor or the board (when its index names this fleet) runs on this machine, while an old home still holds a hand.db of this fleet, while the new home is inside an old home and while a move is pending; hand supervisor stop and hand supervisor interrupt never adopt. It does not rename the fleet. --check rehearses the rewrite on a scratch copy and writes nothing to hand.db. A run with nothing to rewrite writes no event, and backups/pre-adopt-<time> stays only when the rewrite changed something.
Host marker
fleet.host in the fleet home, JSON {"host","boot","pid","at"}: the host name, the boot id (Linux boot_id, macOS kern.bootsessionuuid, Windows ticks:<unix second of the boot> from GetTickCount64, equal within 5 seconds), the watcher's pid and its last beat. The watcher writes it on start and every 15 seconds, removes it on a clean stop, and stops itself when it reads another host's name there. A command that opens the fleet leaves a marker of this machine (the same boot id, or the same host name and boot id) alone however old it is, refuses a marker of another host up to 90 seconds old (naming the host and hand fleet unlock), and takes over from an older one, or from an earlier boot of this host when watch.lock is free: it removes the marker, appends fleet.took_over and ends the live attempts whose process is not alive here (running becomes interrupted, launching becomes failed), in one transaction. hand fleet unlock clears it and appends fleet.unlocked; it refuses while this machine's watcher holds watch.lock. hand doctor and hand fleet list never write it and show open on another host and unclean close rows when they apply. On Linux and macOS watch.pid and board.pid add a second line boot <boot id> <host>, and a pid from another boot or host is never signalled; the Windows tick form is never written there. See Fleets.
Version pin
hand.version in the fleet home, JSON {"version","commit","schema"}: the version and commit of the hand that last opened or migrated the fleet, and the schema it reads; written to a temporary name and renamed. hand init writes it, and so do the commands that open and migrate the fleet with a different hand (every fleet command, the Board, hand fleet adopt, hand fleet move and hand fleet import), never over a newer pin or one that cannot be read. Those commands, and hand update before it swaps anything, read it first and refuse, exit 2 and no write, when it names a newer schema, or a newer version when both parse (a pre-release or dev build is decided by the schema alone), naming both and the command that reaches the newer hand: hand update --channel edge from a stable build, hand update from an edge one, hand update --channel stable from source. hand fleet unlock and the read-only --check and --dry-run runs do not check it; hand fleet import checks both fleets' pins. A pin that cannot be read warns and is left alone; only a missing file means no pin. hand doctor shows a hand.version row, never writes it, and skips the fleet checks while the pin is newer. See Fleets.
Import
hand fleet import SRC INTO DST: the archive copy of one registered fleet into another. It adds the source projects and tasks (the tasks at t + offset, with only tN references in their title and goal shifted), copies its content folders, and exports its report bodies under reports/archive-<source id>/; it imports no attempt, report, decision, timer, event or supervisor input row. A project with the same repository is reused, and its lesson file follows the destination project name; a project with the same name and another repository is refused, and a file whose destination path already holds different content is left alone and reported. The source home keeps its files and gains an imported-into marker; the destination gains one fleet.imported event whose hash covers the inserted project and task rows only and is bound to the destination's task offset. A lock on both homes and a marker/event check inside the transaction make a concurrent import, into one destination or two, exit with a conflict instead of duplicating rows. --dry-run prints the manifest and writes no row, file or marker; SQLite may create hand.db-shm/-wal beside an opened database, and a dry run cannot detect a destination that refuses writes.
Inbox folder and trash
inbox/ in the fleet home holds the files the operator attaches on the board, named YYYYMMDD-HHMMSS-N.ext or YYYYMMDD-HHMMSS-N-<name>, with or without an extension (see Supervisor input). Hand deletes nothing there except through a prune:
hand inbox sizecounts those files, sums their size and gives the ages of the oldest and the newest, by modification time, and the same count and size for the trash.hand inbox prune --older-than Nd(at least1d) moves each such file older than N days frominbox/into the trash,inbox/.trash/(mode 0700), by a rename on the same disk, and stamps it with the time of the move. It then deletes each such file in the trash older than 7 days by that stamp.--dry-runlists both and changes nothing.- A prune touches only regular files with those names, directly inside
inbox/or, for the expiry,inbox/.trash/. It never touches a symlink, a folder, an.upload-*file the board is still writing, or a file with another name, and it refuses aninbox/orinbox/.trashthat is a symlink rather than a real folder. A file it cannot move or delete is reported, and the others still go. A prune that is not a dry run holds the fleet'slocks/inbox.lock, so a second one at the same time exits 3. - A prune that moves files records the event
inbox.prunedwith their count and size:3 files, 12 MiB. Deleting expired trash records no event, since each file's move was recorded. "inbox": {"keep": "30d"}inrouting.jsonmakes the watcher run that prune once a UTC day (see Watcher)."off", the default, prunes nothing.
Retention
Hand removes what it can prove safe, and only warns about the rest. The knobs are the retention object in routing.json, {"worktrees": "7d", "warn_size": "20GB", "warn_days": "30d"}; each value takes "off", a missing one keeps its default, and "retention": "off" turns all three off. worktrees and warn_days are days from 1d to 9999d; warn_size is a size in MB, GB or TB (powers of 1024).
- Worktrees. Once a day (UTC) the watcher looks at every attempt that is not live, not cleaned and ended at least
worktreesago, and removes its worktree the wayhand attempt clean aNdoes, only when all hold: no terminal is open in it, no process of your user has its working folder inside it, a process that started the watcher included (checked on Linux only; the reaper never signals a process, unlikehand attempt clean), its working tree is clean (untracked files count), every commit ofHEADis on a remote-tracking branch (pushed, or merged and pushed), and no ignored file looks like config or secrets (.env*,*.local,*.pem,*.key,*credential*,*secret*,.npmrc,.netrc,id_rsa*,id_ed25519*,id_ecdsa*; the reason names them). Other ignored files, such as build caches, are removed with the worktree. The branch is kept. A removal recordsattempt.cleanedwith the detailaN: removed by retention after 7d, plus; N ignored file(s), SIZE removedwhen ignored files went. Otherwise the worktree stays and Hand stores the reason in the attempt'skeptcolumn, recordsattempt.keptonce per reason, and the Board shows a Needs itemWorktree kept: REASONuntil the attempt is cleaned. The check runs git only for attempts past the window, once a day and once per watcher start, never on a watcher tick; a repository with no remote reads every commit as unpushed, so its worktrees are always kept and the Needs item counts the whole history; a repository where the branch was squash-merged and its remote branch deleted reads as unpushed after a fetch with prune, so clean it by hand (--discard) after checking the PR. A git failure, such as a changed project setting, is a reason too.retention.worktreesoffremoves nothing. reports/andshots/. Hand never deletes in them by itself.hand orientprintsartifacts: "reports/ and shots/ hold 31.2 GB, over the 20 GB limit; 5120 files are older than 30d (6.1 GB)"once they hold more thanwarn_sizeor any file is older thanwarn_daysby modification time, nothing below both limits, with a help line that names thesecondhand-cleanupskill. The Board shows the same line as a Needs item (checked at most every 5 minutes).orientreads every file's size and age, which took about 0.45 s for 120 thousand files on one machine.hand gc --older-than Ndlists the files of those two folders not modified for N days, grouped by their first folder underreports/orshots/, with count, bytes and the oldest date, and the total; it deletes nothing. With--yesit deletes exactly those files, or only those under the scopes named after the flags, then removes the folders that became empty, recordsgc.removedwith their count and size, and prints the deleted groups with their file count, bytes and oldest date underdeleted(the dry run'scandidates), and reports each file it could not delete infailed(exit 1). It scans again at--yes, so files that crossed the age since the dry run are deleted too: compare the dry run's totals just before it. It never follows a symlink, and lists only regular files. A secondhand gc --yesat the same time exits 3.- Never touched:
memory/,briefs/,inbox/(see Inbox folder and trash) and anything unpushed. - Workers are told not to store large machine files (
.obb,.pack,.apk,.aab, bundles) underreports/, and to record the path and hash in the report instead. Such a file, and any path or file a worker wants to keep, must live outside its worktree: removing the worktree deletes ignored files too (see Worktrees below).
Backward compatibility is not kept for this: the attempt.kept column is schema 17, and a hand older than this refuses a database migrated to it and a routing.json that holds retention; going back needs a restored backup (see Restore a backup).
The same holds for task.parent_id, which is schema 20: a hand older than this refuses a database migrated to it (newer than this hand (19)), and going back needs a restored backup.
The same holds for the attempt_status index, which is schema 21 (newer than this hand (20)).
The same holds for the alert table, which is schema 22: a hand older than this refuses a database migrated to it (newer than this hand (21)), and going back needs a restored backup.
The same holds for the socket column of attempt and supervisor, which is schema 23 (newer than this hand (22)). It records the Luvus socket path (luvus.SocketPath) a terminal was launched under. A row from before the bump has none and keeps the old behaviour.
Shared folder
~/.secondhand, or $SECONDHAND_HOME. It is not a fleet. index/<fleet id> is a bookmark that holds the path of that fleet's home, written by a temporary file and a rename so it works on a filesystem without hard links, worktrees/<fleet id>/ holds every worker worktree outside every fleet, luvus/ holds the Luvus copies that servers started before the fleet kept its own, backups/hand.<stamp> holds the old binaries, models/ is the machine-wide freshness gate of the model list, board.addr records where the running board listens, board.generation holds the running board's PID MARKER for the supervisor work it owns, board.peer holds the peer preface secret, board.iroh holds the board's --iroh setting and the sidecar's last state, iroh/ holds the sidecar's endpoint key (one per board, and a board serves every fleet, so it stays here), board.log holds the output of a board hand open started detached, board.start.lock makes two simultaneous hand open calls start one board, and board.pid holds the running board's PID MARKER BINARY. update.lock is the lock a hand update holds, update.log the last automatic update's output, and board.update-every the board's --update-every for the board hand update restarts. A fleet home also holds watch.pid for its running watcher. hand update, hand board stop and hand watch stop stop the processes these files name, only while the recorded start time and binary still match.
Scratch
The folder Hand gives every attempt at <fleet home>/scratch/tN-aM, mode 0700, created when the attempt's pane starts. The hidden hand attempt _exec wrapper makes it and exports it to the worker as TMPDIR, TMP, TEMP, GOTMPDIR and CLAUDE_CODE_TMPDIR; it sets no HOME, XDG_CACHE_HOME or CARGO_TARGET_DIR. It accepts only a --dir that resolves to exactly <fleet home>/scratch/tN-aM for a fleet home that holds a hand.db, is registered in the shared folder the launcher passes as --root, and has an attempt worktree at <shared folder>/worktrees/<fleet id>/tN-aM; it reads no database, resolves symlinks, and refuses anything else with the expected shape before it creates a folder or changes a mode, so a mistyped path cannot make another folder private. The folder is for temporary files, build output, downloads and logs, so a worker's default writes do not land in the operator's home folder. It lives inside the fleet home and moves with it; worktrees and the peer secret stay outside until the layout change. A Unix socket path holds at most 104 bytes on macOS and 108 on Linux, the socket's own file name included; when the scratch path leaves less than 8 bytes of that, _exec prints a warning in the pane, because a harness socket inside it may fail with AF_UNIX path too long. It is deliberately outside reports/ and shots/, so the artifact counts hand orient, the Board and hand gc use never include it, and hand update and the watcher back up hand.db, not the scratch folder. It is not a sandbox: a worker that ignores the variables can still write anywhere its account can.
hand attempt clean aN removes it, unless --keep-scratch is given, and the watcher removes it with the worktree after retention.worktrees days. hand attempt start --continue aN gets a fresh scratch folder, because aN had to be cleaned first. hand attempt gc lists what is left, with its size, once the worktree is gone.
Operator memory
Plain files under memory/ that Hand never rewrites. hand init creates memory/operator.md once; it holds the operator's standing constraints, and hand orient prints up to 4000 bytes of it. memory/projects/NAME.md, when it exists, holds one project's lessons, and the supervisor reads it before writing a brief.
Skill
The instructions hand init installs for the supervisor. The secondhand skill describes the supervision loop. The secondhand-cleanup skill guides freeing space in reports/ and shots/ with hand gc and the operator's approval (see Retention).
Work
Project
A short name (1–64 lowercase letters, digits and hyphens, starting with a letter or digit, and never a Windows device name such as con or lpt1) for a git repository at an absolute path on this machine: hand project add api /home/you/src/api. Hand does not clone or copy the repository. It adds worker worktrees and branches to that repository with git worktree add. Every task belongs to one project.
Project note
A dated paragraph in notes/NAME.md under the fleet home: hand project note NAME TEXT appends one, and hand project notes NAME prints the file, or no notes when it does not exist. A new file starts with # NAME — notes; an entry is a blank line, ## YYYY-MM-DD, a blank line, TEXT without its trailing newlines, and a blank line. NAME follows the Project rule, so the file is always a plain file. hand fleet move carries notes/ with the fleet.
Project git settings
Some settings in a project's git configuration name programs that git runs, such as core.sshCommand, a filter's smudge, a credential.helper, a config-defined hook (hook.NAME.command) or an include.path. The list in internal/gitcfg has every key that git help config (git 2.56.0) documents as naming a program, except those git ignores in a repository's own configuration, plus the contents of .git/info/attributes. hand project add records a digest of their values for the project in the project.settings column of hand.db (schema 13); it refuses a repository whose configuration git cannot read, and a folder that is no repository records nothing until Hand first runs git there. A project registered before schema 13 records them the first time Hand runs git in it, and hand orient shows the event project.adopted; a project that had already changed by then is accepted as it is.
Before every git command it runs for a project, Hand reads the same settings the way git does and compares them with the record. It reads the repository's own configuration with the files it includes, a worktree's own config.worktree and .git/info/attributes, and runs none of the programs. It does not look at the global or system configuration, a submodule's configuration or the environment. When a value differs, Hand runs nothing, exits 2 and names the changed settings, never their values. A setting kept only in an attempt worktree's own config.worktree is named with (worktree), and hand project trust does not accept it: remove it from that worktree, or delete the worktree's folder and run the command again. Reading the settings and running the command are two steps, so a change made between them is caught by the next command. An alias is not listed, because git never resolves an alias over the built-in commands Hand runs, and neither are remote.NAME.url and url.BASE.insteadOf, whose ext:: form needs protocol.allow or protocol.ext.allow, which are listed. The commands that run git in an attempt's worktree (hand attempt clean) also check that worktree.
hand project trust NAME is the way to accept the current values. It asks nothing, prints each setting and its value (control characters written as escapes), records them and the event project.trusted, and prints status: unchanged when nothing differs. hand doctor has a project NAME row. hand init skips the worktree repair of a project that is refused and says so. hand report add finds its worktree with git rev-parse --show-toplevel, which runs no configured program, and is not checked.
Task
tN. The durable record of one operator request, with a project, a one-line title of 1–200 characters, and a goal that says what done looks like. The supervisor creates it with hand task add before any other work, so nothing lives only in chat.
| State | Meaning | Reached with |
|---|---|---|
inbox | captured, not started | hand task add |
active | work has begun; only now can attempts start | hand task start |
done | finished; refused while an attempt is live or a report is unread | hand task done |
abandoned | dropped; refused while an attempt is live | hand task abandon |
An inbox task can become active or abandoned, and an active task can become done or abandoned. done and abandoned are final, and reaching either withdraws the task's open decisions.
A task may sit under a parent task and is then a sub-task of it. hand task add --parent tN creates one, hand task parent tN --parent tM moves one and hand task parent tN --root makes it a root again; each change records task.parented (detail tM or root; task.added carries tM for a child) in the same transaction. Nesting has no depth limit and no rule beyond one: a task cannot sit under itself or under one of its own descendants (conflict, exit 3, naming the chain). It cascades nothing. A parent may be done or abandoned while its children are open, a child may be in another project, and a done or abandoned task may still be re-parented or parent a follow-up. hand task done and hand task abandon on a parent with open children print a warn and finish the task. hand task list places each child after its parent, with a parent column that appears only when a row has one; a child whose parent is not in the listing (filtered out or cut by --limit) is listed as a root and keeps its parent ref; --flat prints the plain table. hand task show prints parent and a children table, and hand orient orders its active and inbox rows the same way. The Board nests a sub-task under its parent inside a group (see Board) and its task page says "Sub-task of tM" and lists the sub-tasks; the Tasks page's search results stay flat. wait, wakes, timers, plans, decisions and reports stay per task.
Plan and plan revision
The supervisor's written plan for a task. hand plan set --body-file PLAN.md tN adds a new revision, numbered p1, p2 and so on within that task. The newest revision is the current plan, and older ones are kept. Only an inbox or active task takes a new revision. hand plan show tN prints the current plan.
Brief
The text the supervisor gives a worker with hand attempt start --prompt-file BRIEF.md: the goal, the constraints, the exact acceptance check, and what to commit. Hand appends the instructions for reporting back. Luvus takes at most 16 KiB per argument, so the brief and that footer must fit in 16 KiB; for a longer brief, point the worker at a file (Long prompts). On Windows the footer asks the worker to write the report to a file outside the worktree and run hand report add --status done --file PATH, which works in PowerShell and Git Bash, instead of piping a here-document. The footer also asks the worker to wait for CI and reviews inside one blocking foreground command, never to end its turn to wait, and to report only when done or stuck.
Attempt
aN. One worker's run on one task. hand attempt start needs an active task with no live attempt. Hand then:
- makes a worktree at
worktrees/<fleet id>/tN-aMin the shared folder, on the new branchhand/<fleet id>/tN-aM, from--baseof the project's repository. Without--baseHand runsgit fetch originthere (20 s at most) and starts from the branchorigin/HEADpoints to, elseorigin/main, elseorigin/master; a repository with noorigin, or with none of those refs, starts from its current branch. When the start is a remote ref, unpushed local commits are not in the worktree unless--base HEADis given. If the fetch fails, the worktree starts from the last fetched ref, or from the current branch when there is none, andhand attempt startprints awarning:line saying which and why. With--continue aMthere is no new branch: the worktree checks outaM's existing branch, and the new attempt records that it continuesaM, whichhand attempt showand the board's task page name.aMmust belong to the same task and be ended and cleaned, and--basedoes not apply (theworktrees/folders are created with mode0700, and Hand's own git calls run no repository hooks and nocore.fsmonitorcommand, so a hook a worker plants never runs inside Hand; the other settings that name a program, such asdiff.external, a filter'ssmudge,core.sshCommandand config-defined hooks, are not switched off but checked, and the real boundary is still the Safe use guidance indocs/safe-use.md); - starts the harness there in a Luvus terminal, with the brief as its prompt, through a hidden
hand attempt _execwrapper that makes the attempt's scratch folder and points its temporary-directory variables at it (see Scratch).
A task keeps all its attempts. At most one is live, that is launching or running.
| State | Meaning |
|---|---|
launching | recorded; the worktree and terminal are being made |
running | the worker's terminal is live in a pane |
exited | the terminal or its process ended by itself |
interrupted | the Luvus server restarted, or the terminal's root process changed |
stopped | hand attempt stop ended it |
failed | the launch failed, or did not finish within two minutes |
launching becomes running or failed, and running becomes exited, interrupted or stopped. The watcher and every hand attempt, hand supervisor and hand attach command first check the live attempts against Luvus.
Each attempt and supervisor row records the Luvus socket its terminal was launched under. When one ends because of a Luvus check, its note reads ; requested by PROCESS only if that process signalled the root (the root was still alive), and ; recorded by PROCESS if it only recorded an end that had already happened. Two spellings of one socket (an uncleaned path, a symlinked folder, the same file) are the same socket; on Windows without HAND_LUVUS_SOCKET the identity is the session name, which cannot tell two Luvus servers of the same fleet id apart. A live row whose recorded socket differs from the checking process's own is not that Luvus's terminal: it ends interrupted with terminal belongs to another luvus (SOCKET) and ; recorded by PROCESS, and nothing is signalled, closed or swept for it. A running row from before the socket was recorded has none and is checked as before, until a check finds its terminal healthy in this Luvus: that check records this process's socket on it (event luvus.socket), and the row is protected from then on. hand attempt clean refuses an attempt that belongs to another Luvus (exit 3, nothing removed), because its worktree can hold that Luvus's live worker. The stamp is recorded as luvus.socket, not attempt.socket or supervisor.socket, so it never becomes an attempt's latest signal.
When the watcher ends an attempt, it keeps the last screen it read from that attempt's pane, if it read one: about 2 KB from the end, with escape sequences and control characters removed. hand attempt show aN prints it as last_screen. The screen is stored only in the attempt row. It never appears in an event, a wake, hand orient or the board, because a worker can print anything. An attempt the watcher did not end, or whose pane it never read, has no last_screen. The screen is read when a turn ends, so it is that turn's last screen and not the pane's final state.
The board's attempt page, /attempt/aN, shows what Hand recorded about the attempt: the harness, model and effort, status and reason, times, branch and worktree, the brief and the launch line (both stored as the attempt's launch command, whose last word is the brief), its reports, and every event that names it, and for a copilot worker its transcript. It never shows last_screen.
hand attempt clean aN removes an ended attempt's worktree and keeps its branch. It also removes the attempt's own scratch folder, unless --keep-scratch is given, and the Claude Code directory described below. It refuses uncommitted changes, and commits on no branch, tag or remote, unless --discard is given. The watcher does the same by itself for a finished attempt after retention.worktrees days, under stricter conditions (see Retention).
Claude Code keeps a scratch directory per working folder, <tmp>/claude-<uid>/<worktree path with every non-alphanumeric character replaced by "-">/, where <tmp> is, on Linux, CLAUDE_CODE_TMPDIR, else TMPDIR, else /tmp. The _exec wrapper gives the worker CLAUDE_CODE_TMPDIR=<fleet home>/scratch/tN-aM, so a new worker's directory is inside the attempt's scratch folder and goes with it; hand attempt clean and hand attempt gc look for the older ones outside it from the environment of the hand command. On macOS Claude Code's root is not measured, so Hand looks under CLAUDE_CODE_TMPDIR else /tmp, and under TMPDIR too. It looks only in a claude-<uid> folder that is a real directory owned by the running user. On a RAM-backed /tmp a worker that writes captures or logs there fills memory, and Claude Code never removes it. After removing the worktree of a claude attempt, hand attempt clean aN removes the directories it finds, the attempt's scratch folder and each one outside it, printing them under scratch_removed with their size in bytes; a failure to remove one is a warn field, not an error, and it still removes every other directory it found. --keep-scratch leaves them and prints them under scratch_kept; hand attempt gc lists them once the worktree is gone. A worker must copy anything it needs from its scratchpad into its report or branch before the attempt is cleaned. Hand deletes only a directory directly under <tmp>/claude-<uid>/ whose name ends in worktrees-<fleet id>-tN-aM, never by pattern, and never a symbolic link. The other harnesses have none. Linux and macOS only.
hand attempt gc lists the attempt scratch folders of worktrees that no longer exist (see Scratch) and the directories under <tmp>/claude-<uid>/ named for a worktree of this Hand home (worktrees/<fleet id>/tN-aM under ~/.secondhand) whose folder no longer exists: what earlier cleans, a crashed launch or a removed folder left. It is a dry run; --apply removes them, prints the ones it removed, and prints a warn for each it could not, and --older-than DURATION (for example 72h) skips those with any file or folder modified more recently. A directory of an existing worktree, and of every attempt that is live in hand.db, is never listed. Neither command touches transcripts (~/.claude/projects, which hand attempt transcript and the board read) or any harness's shared database.
hand attempt start prints a warn field when 5 or more attempts are already live in the fleet, and starts anyway; see Concurrent workers.
Hand ends a worker in three places: hand attempt stop aN; a hand command or the watcher that settles an attempt whose terminal is gone, whose server restarted or whose launch failed; and hand attempt clean aN, just before it removes the worktree. What it reaches depends on the OS:
- Linux and macOS send SIGTERM to the worker's process group, SIGKILL after 5 seconds if the root is still alive, and then close the Luvus terminal. A command the worker started in its own session or process group (
setsid, a daemonised server, a browser) is not in that group, and neither is anything once the root has already exited; - Linux then also ends every process of your user whose working folder is the attempt's worktree or inside it: SIGTERM, and SIGKILL after 2 seconds. It runs also when the root is already gone. Hand reads
/procbefore SIGTERM and again before SIGKILL, so a process forked during the grace is ended too and one forked after the second read survives. It takes each process's start marker before it signals, checks it again first so that a recycled process ID is never signalled, and never signals itself or the processes that started it. It sweeps only a path of the formworktrees/<fleet id>/tN-aM, never theworktreesfolder, a fleet's folder, your home folder or/. A shell of yours left inside the worktree ends too, so leave the folder first. A leftover that runs in another folder is not found; - macOS has no
/proc, so that second step does nothing there: a command outside the worker's process group survives and Hand does not report it; - Windows ends the root and its descendants at once, found by parent process ID, with no graceful phase. The pane's root is
hand, not the harness:_execcannot replace the process there, so it spawns the harness as its child, while Linux and macOS replace it and the harness is the root. A process whose parent already exited is not reached, and Windows has no working-folder sweep.
Report
rN. A worker's account of its attempt, added with hand report add --status STATUS from inside its worktree. Only a running attempt can report, and the body is 1 byte to 64 KiB.
| Status | Meaning |
|---|---|
progress | a milestone; work goes on |
done | the worker finished |
stuck | the worker cannot continue |
A report stays unread until someone acknowledges it: the supervisor with hand report ack rN, or the operator with Mark read on the board. hand task done is refused while any report of the task is unread.
Deliverable
Whatever a worker or the supervisor delivered: a PR or merge request on any forge, a document or page URL, or the path of a file or folder. The report footer asks the worker to end its report with one line Delivered: <link or absolute path> for each. A task the supervisor did itself has no report, so it passes each with hand task done --deliverable PATH_OR_URL tN (repeatable), which records one task.delivered event per value in the same transaction as task.done; the same rule applies, but a bad value is refused with an error that names it, and then nothing is recorded and the task is not finished. For a task that is already done (a backfill, or a result that comes late), or one still active, hand task deliver tN PATH_OR_URL... records the same events without changing the task; a value the task already has as an event is skipped. Hand reads report deliverables on demand, with no stored column: Delivered: lines first, and only for a report with none, the change-request URLs in its body (GitHub /pull/N, Gitea and Forgejo /pulls/N, GitLab /-/merge_requests/N, Bitbucket /pull-requests/N). A value that is neither an http(s) URL nor an absolute path (a POSIX or Windows path, judged by its text) is ignored. Every reader takes both sources together, newest first: hand task show, the board's task card (the newest) and task page, and hand open tN --delivered. The board labels each PR, MR, link or file; a file chip links to the document viewer when the path is inside the fleet home and the viewer serves it, and stays text when the path is outside the fleet home, gone, or refused. hand open tN --delivered opens the newest, in the document viewer when its file chip would link there, and never a UNC path.
Decision
dN. A question the supervisor asks the operator about one inbox or active task: hand decision ask tN "QUESTION", or hand decision ask --file - tN with a longer question on stdin; a question holds at most 8000 characters. The question's first line is its headline, at most 120 characters; the lines after it are the body, shown with the board's markdown. A numbered line in the body that reads N. Label — consequence (recommended) is an option: the board lists the options as rows (the label in bold, the consequence under it, a Recommended badge) with an optional note and one Send, and answers with N. Label, then a blank line and the note. The note is optional, except that an option whose label starts with Other needs one. The consequence and the badge are optional, and a body without numbered lines gets a plain answer box. The secondhand skill makes it the only way the supervisor asks the operator anything.
| Status | Meaning |
|---|---|
open | waiting for the operator |
answered | the operator answered on the board or with hand decision answer dN "ANSWER" |
withdrawn | the supervisor withdrew it with hand decision withdraw dN, or its task became done or abandoned |
Runtime
Luvus
The terminal runtime Hand runs every agent in (RizRiyz/luvus). Hand speaks UHP 1.x to it over a Unix socket (on Windows a named pipe, whose address luvus session list --json reports and whose server must run as the same user) and uses only exact-argv terminals, agent status, fenced input and events. Hand keeps its own state and worktrees, and never uses Luvus's task ledger or worktree commands.
Each fleet has its own Luvus session, secondhand-<fleet id>. In a systemd user session, Hand starts that session's server in a transient scope (systemd-run --user --scope), so restarting the watcher or the board never stops an agent. Without a user session, or when systemd-run fails, Hand starts the server directly. Inside a Hand fleet, omp runs with PI_NOTIFICATIONS=0, because the fleet's server is started with it and every pane inherits that environment; a server that was already running keeps its old environment until hand update restarts it (a Luvus switch), or you stop it yourself and let the next hand command start it.
Luvus pin
The copy of a Luvus binary that Hand runs, kept in the fleet folder under luvus/<os>-<arch>/<version>-<sha8>/luvus (luvus.exe on Windows) and chosen by luvus/<os>-<arch>/pin.json, which holds the copy's path relative to its own folder, so a fleet that moves to another mount keeps its pin. A fleet on a disk used by several systems holds one pin per <os>-<arch>. Each fleet pins its own copy, so a system upgrade never changes the Luvus a fleet runs. A fleet that has none yet uses the machine pin of an older hand in $SECONDHAND_HOME/luvus/ until the next fleet command or hand init copies it in. The copy must run from the fleet's disk: a volume mounted noexec cannot run it.
hand initpins theluvusonPATHwhen there is no pin, and downloads the tested version, checked against its SHA-256, when there is none onPATHeither.hand luvus pinwithoutBINdoes the same when there is no pin.hand luvus pin [BIN]pins another binary, and old copies stay for rollback.- The server start and
hand attachcheck the copy's sha256 and refuse a copy that changed on disk. - A new pin reaches a running server only when that server restarts.
hand luvus showprints the server's version (from a UHPping) and whether it is the pinned copy:yes,no,not runningorunknown. Restarting it ends every live pane, so Hand does it only for a quiet, settled fleet (Luvus switch, under Update):hand updateor the board's timer switches such a fleet, or you stop the server yourself, let the nexthandcommand start the pinned copy, and resume the supervisor.
Pane
The Luvus view of one terminal. The running supervisor and every running attempt each have a pane. Hand reads the screen and sends keys through it, and hand attach supervisor or hand attach aN opens it in your terminal.
Luvus refuses keys and messages (agent_not_ready) to a pane it does not know as an agent, and it names an agent from the program's file name, which never matches codex.exe or claude.exe. So Hand asks Luvus for a process scan of the pane (terminal.backend.processes) before the first key or message it sends to that pane in a run, and once more after an agent_not_ready, then, when that scan reports observed, sends the key up to 3 times, 200 ms apart, because Luvus can still refuse for a few milliseconds after it; a scan that observes nothing gets one more send. A scan that fails, or a Luvus without the method, never blocks a key that would have worked: the refusal then ends with a note that Luvus could not scan.
Blocked screen and screen revision
A screen that waits for an answer, such as a trust or permission prompt, puts the agent in the blocked state. hand attempt read aN prints the screen as an indented multi-line block (screen: |, then the lines, with escape sequences and control characters other than newline and tab removed) and its revision. hand attempt keys --revision N aN KEY... sends keys only if the screen is still at that revision, so a stale answer is never typed. The supervisor's own screen works the same way, through hand supervisor show and hand supervisor keys --revision N KEY..., with the keys enter, esc, up, down, 1, 2 and 3. Claude Code's auto-deny countdown moves the revision every second, so the board also sends --screen DIGEST, a digest of the screen with the countdown taken out: when the revision moved but the digest still matches, the keys go out once at the new revision. The Board's Screen page sends an ordered sequence of allowed keys in one press, which its Ask card uses to move through a card without numbers. A key press on the supervisor's screen is recorded as a supervisor.keys event.
Hand answers the trust screens of agy, Claude Code, Codex and Copilot when they name the attempt's own worktree (the fleet home for the supervisor), and prints the result as trust: (accepted, not asked or not pressed). A Codex screen that also names a repository root is answered only when that root is the project's registered repository or its resolved path; a supervisor has no project, so it never is. Codex saves what it accepts, so hand attempt start and hand supervisor start also print trusted:, and an attempt.trusted or supervisor.trusted event records the screen, the folder and the repository root. Codex's Working directory · Resume screen is not answered, and shows as a blocked screen. A Codex screen that shows ? N question (a question the worker asks the operator) puts question: and the text above it in the attempt.blocked detail, so the Board's Needs item and hand wait show it; the wake only says the screen shows a question. Codex answers it with Shift+Left, and Luvus refused four spellings of that key as unknown key (0.12.0), so Hand cannot answer it: the question times out and the worker goes on with its own default. Copilot's screen (Do you trust the files in this folder?, with 1. Yes selected) shows the folder in a box, wrapped at the pane width without spaces; Hand joins the lines and presses Enter only when the result is the attempt's own worktree. It never takes 2. Yes, and remember this folder, which would write the operator's Copilot config; a trust given with 1. Yes lasts for the session, so Hand prints no trusted:.
MCP
A Claude worker starts with --strict-mcp-config, so it loads no MCP server: not the ones in the operator's own Claude config, not the repository's .mcp.json, not a plugin's. hand attempt start --mcp FILE adds one MCP config: a path inside the fleet home (a relative path, never .., never absolute, never a folder) to a file the operator maintains, for example --mcp mcp.json. Hand resolves symlinks in the fleet home and in the file: the resolved file must be a regular file inside the resolved home, so a link out of the home is refused, a link to another file inside the home is allowed, and anything under scratch/ is refused because workers write there (worktrees live outside the home). That worker starts with --strict-mcp-config --mcp-config=RESOLVED_ABSOLUTE_PATH and no other source. hand attempt show aN prints mcp: PATH when the launch holds one, and hand attempt start refuses to launch, before any worktree exists, when the file is missing. --continue does not inherit it: pass it again. Hand never reads the user's own Claude config (~/.claude.json) and never reads the MCP file either: Claude does. Why a worker needs --mcp: a server in the operator's config can drive a logged-in browser, so every worker, including one that follows text from an untrusted issue, would have a path to it; and each server is a process per worker.
Measured on Claude Code 2.1.316 with a mock model and a scratch HOME whose Claude config holds two @playwright/mcp 0.0.84 servers (idle, no page opened), summing VmRSS over the worker's process tree ten seconds after its first request: before, 3 processes and about 540 MB (the Claude process 267 to 269 MB and two servers of 135 to 138 MB each; two runs); after, 1 process and about 253 MB. With a real run (TestClaudeMCP), a server in the user config and one in the repository's .mcp.json did not start for a default worker, and only the file named by --mcp started for a worker given --mcp mcp.json. This is a break: a worker used to inherit every server of the user's config. An operator who relies on a browser or another MCP server in workers puts the servers in mcp.json in the fleet home and starts those workers with --mcp mcp.json.
Only claude supports it. hand attempt start --mcp refuses any other harness and a path outside the fleet home; hand supervisor start and switch have no --mcp, so the supervisor starts as before. Codex, opencode, agy and Copilot keep loading the MCP servers of their own configuration, as before. Their installed binaries were checked for a way to start with only a named MCP file: Codex 0.166.0 (--ignore-user-config skips the whole config.toml, which is more than MCP, and was not measured), opencode 2.2.5 and agy 1.4.12 have none in --help, and Copilot was not installed where this was checked. Hand claims no isolation for them.
Watcher
hand watch, one per fleet. It:
- follows Luvus's events and checks the live attempts at least every 30 seconds (
--every); - records the
attempt.blocked(once a second observation still readsblocked),attempt.quiet,attempt.idle,attempt.limitedandattempt.longevents from Luvus's agent status (an opencode turn end only once its session and subagents are idle, see Agent state), anattempt.workingevent when a worker whose newest event isattempt.blockedworks (it ends the blocked label inhand orient, and wakes nobody; for opencode also for a block thathand attempt startnoted at launch: each sweep costs one query per running opencode attempt, and a status read only for one whose newest event isattempt.blocked), and on every start catches up on a blocked screen or an ended turn it missed while it was down; - delivers queued messages and wakes to the managed supervisor, and alerts once each time a supervisor reaches a usage limit, counting a limit as over once a screen read shows no limit line or another supervisor takes over;
- wakes the managed supervisor with
supervisor.stalledwhile it idles with unwatched work (see Unwatched and stalled), and withtimer.firedwhen a timer comes due (see Timer: it needs a managed supervisor); - snapshots the fleet's
hand.dbonce a day (UTC) withintegrity_checktobackups/hand-YYYYMMDD.dbin the fleet folder, and keeps the newest 7, retrying a failed snapshot only after the next UTC day starts or the watcher restarts;hand updatekeeps its ownhand.db.<stamp>files in that folder, and neither removes the other's; - removes the worktrees of finished attempts once a UTC day, in the background, as Retention describes, and records
attempt.cleanedorattempt.kept; whilerouting.jsonis invalid it says so once and removes nothing; - prunes the inbox folder once a UTC day when
routing.jsonsetsinbox.keep, ashand inbox prune --older-than KEEPdoes, and writes the day toinbox/.trash/.prunedso a restart that day does not prune again; it logs only the files that failed, which wait for the next day's run; whilerouting.jsonis invalid it says so once and does not prune; - resumes an interrupted or exited supervisor when it starts, if
routing.jsonturns onsupervisor.autoresume; - starts a fresh session for a claude or omp supervisor whose context passes
supervisor.rollover_tokens, after the supervisor writes its handoff note (see Supervisor, Rollover); - alerts when an attempt ends, blocks, hits a usage limit or goes quiet (not for
attempt.idle, which wakes nobody), when a decision is asked while it runs, and once per stall episode, on the first stall wake at or after 60 minutes idle; - records each alert it sends in the
alerttable, the alert feed, at the same place in the code and under the same conditions; - sends each alert through the
notifycommand inrouting.json(an argv;HAND_NOTIFY_KIND,HAND_NOTIFY_REF,HAND_NOTIFY_TEXT,HAND_NOTIFY_URL(the page on the laptop) andHAND_NOTIFY_APP_URL(the open link) in its environment), else throughnotify-send(on macOS,osascript; on Windows, none), unless--notify=false. On Linux thenotify-sendnotification offers Open, which opens the item's address with the OS opener; Hand waits up to 10 minutes for the click, keeps at most 8 waiting at once, and sends a plain notification whennotify-sendlacks--actionand--wait(checked once withnotify-send --help), when the click command fails without printing the action, or whenboard.addris not a board address (a loopback host with an explicit port; no login, query or fragment).
The running board keeps it alive. hand unit board prints the board's systemd user unit on Linux, and exits 2 on macOS and Windows.
hand supervisor start, resume and switch start it when its watch.lock is free: as the transient user unit secondhand-watch-<fleet id> (Restart=on-failure, RestartSec=5, RestartPreventExitStatus=3) in a systemd user session, otherwise detached with its output in watch.log. hand orient reports watch: running or watch: missing from the same lock, without creating it. The running board starts it the same way, at startup and every 30 seconds, for each fleet that has a live supervisor or that has autoresume in routing.json and an ended supervisor to resume (the last one that started is interrupted or exited), so a crashed or killed watcher comes back on macOS and Windows too and after a reboot once the board is up. A fleet with neither gets none, a fleet whose home is gone or whose hand.db will not open is skipped, and a failed start is written to the board's log once per change of error. That probe holds the lock for an instant, so a starting watcher tries it five times, 20 ms apart, before it gives up.
Event and cursor
Every state change is one transaction that also appends an event with a sequence number, a kind such as task.active or attempt.reported, a task and a detail. The one exception is an alert row, written without an event because the change it announces has its own. A cursor is the sequence number of the last event seen. hand orient prints the current one.
Wake
A message that tells the supervisor something changed, that a timer it armed came due, or that it idles with unwatched work, so it never polls and spends no tokens while idle.
- Managed supervisor: Hand sends its pane a message whose first line is
[hand v1 wake], followed by oneKIND DETAILline for each event since the supervisor's wake cursor. A line about a task ends with that task, e.g.(t1 "Fix the login redirect"). Hand sends a wake only while the supervisor is idle or done, and only after every queued operator message has gone out. While the supervisor's screen shows its harness's usage-limit line, Hand holds wakes and leaves the cursor where it is, because the harness would answer them with the limit error. It holds them too when it cannot read that screen. The hold lifts once the line leaves the screen. Operator messages still go out. Luvus types the message and then presses Enter 45 ms later. Codex swallows an Enter that comes within 120 ms of a paste when its terminal has no bracketed paste, as on Windows, so when a Codex supervisor has not left idle 1.5 seconds after the message, Hand presses Enter itself once. When Luvus refuses the message withagent_not_readyalthough Claude Code is idle or done and its screen shows an empty prompt (Luvus judges the input box from the cursor and its rails, which a Windows console can place elsewhere), Hand types the message key by key, ashand supervisor forcedoes, and records the delivery as typed. - Supervisor opened by hand: it runs
hand wait --after CURSOR, which returns the wake events and the next cursor. It has no timers:hand wakeis refused without a managed supervisor, so it waits withhand wait --timeout.
The wake kinds are:
| Kind | Meaning |
|---|---|
attempt.reported | a worker added a report |
attempt.quiet | the worker's turn ended; the detail says whether it reported since its last quiet turn, or api error: <line> when claude's screen has a line starting API Error: |
attempt.limited | the worker's turn ended at its harness's usage limit; the detail is the limit line |
attempt.long | the worker's turn has run 60 minutes without a break; the detail is working for 60m |
attempt.blocked | the worker waits at a screen that needs an answer, recorded once a second observation still reads blocked, or Hand noted a launch problem: a trust screen it did not answer, a prompt it could not submit, a copilot that fell back to Auto or is not signed in |
attempt.exited | the attempt ended by itself; the reason reads terminal exited (code N) or terminal exited (signal NAME) when the watcher saw Luvus's terminal.exited event within 1 second of noticing the terminal gone, else terminal exited; a hand command that notices the exit first records the bare reason |
attempt.interrupted | the attempt was cut off by a Luvus restart or a changed process |
attempt.failed | the attempt's launch failed |
decision.answered | the operator answered a decision |
supervisor.stalled | the supervisor idles with unwatched work; the detail names the tasks and the idle time |
timer.fired | a timer came due; the detail is wN, how late it fired when that is a minute or more, and its note |
A progress report never wakes the supervisor on its own; attempt.limited and attempt.long wake it unless the wake leaves them out, as described below. A progress report rides in the next wake with an event that does, or goes out once 50 such reports wait. attempt.idle is not a wake kind: it never reaches the supervisor and sends no desktop or notify-command alert.
A wake leaves out what the supervisor already handled: an attempt.blocked, attempt.quiet or attempt.long of an attempt that is no longer running when the wake goes out, an attempt.limited, or an attempt.quiet with api error, of an attempt that is stopped or cleaned (an exit or an interruption not yet cleaned keeps the limit line and its reset time in the wake), and an attempt.exited, attempt.interrupted or attempt.failed of an attempt already cleaned. The events stay in the timeline. hand wait leaves them out the same way.
Wakes reach the managed supervisor through the watcher, so hand watch must run for the fleet.
Unwatched and stalled
A fleet's work is unwatched when at least one task is active, no attempt is live, no decision is open and no timer is armed that is due within 6 hours while a managed supervisor runs: no event will ever wake the supervisor for it. While a managed supervisor runs, a task with an armed waits timer (see Timer) is left out, and its timer does not count for the other tasks. Its idle time counts from the fleet's last event other than supervisor.stalled, supervisor.delivered and model.changed, which only record that something already recorded reached the supervisor or that the model a harness reports changed. hand orient lists such tasks as unwatched[N] (at most 15, then unwatched_more) with unwatched_idle, and prints neither otherwise.
The watcher records supervisor.stalled, a wake, when the work is unwatched and the supervisor is running and idle: Luvus reads it as idle or done, its screen revision has not changed for 30 seconds (the test the Luvus switch uses), no message waits for it, its screen shows no usage-limit line, and no model switch is pending. A stopped, blocked, limited or switching supervisor gets none. An episode's first stall comes when the idle time reaches after; each later one waits twice as long as the one before, up to every: by default 20 minutes, then 40, then every 60. Any other event ends the episode, so the idle time and the delays start again. A turn holds the next stall until after has passed since the watcher last saw the supervisor working. The detail names the tasks and the idle time: `s3: t139 and t142 are active, no attempt is running, idle 23m. Start a worker, arm a timer with hand wake, or finish the task.` The first stall wake of an episode at or after 60 minutes idle also alerts the operator, once.
routing.json sets the delays with "stall": {"after": "20m", "every": "60m"}, each from 1m to 24h (one left out keeps its default), and "stall": "off" turns the stall wake off. Without stall the defaults apply. A bad value makes routing.json invalid with an error that names it; the watcher says so once in its output and keeps the defaults.
Timer
wN. A wake the supervisor arms for later with hand wake, for something outside Hand such as a PR review: once after a delay (--after 30m), once at a time (--at <UTC time, RFC 3339>), or repeatedly (--every 30m). --while tN ties it to a task: finishing or abandoning the task cancels it. --note says what to check, in one line of at most 200 characters. --after and --every take Go durations or whole days (3d). --waits (with --while tN, one-shot, due at most 30 days away, one armed per task) makes the timer a wait: tN is waiting on an outside party until it fires, and the note is the reason. Timers belong to the managed supervisor: hand wake is refused while no managed supervisor runs (a supervisor on the current Luvus server, started by hand supervisor start or resume), and a supervisor opened by hand waits with hand wait --timeout instead. hand wake list and hand wake cancel work without one. A timer is due at least 1 minute ahead and repeats at most once a minute, and a fleet holds at most 20 armed timers. It is stored in hand.db as armed, fired or cancelled, so it survives a watcher restart, a supervisor switch and a resume. hand wake list shows the armed ones, hand wake cancel wN cancels one, and hand orient lists them as timers (id, due time, note).
The watcher fires a due timer while the managed supervisor runs, on its start and on every reconcile tick, by recording timer.fired, a wake, whose detail is w3: check PR 909. A timer that came due while the watcher or the supervisor was down fires once when both are back, marked with how late it is: w3, 2h10m late: check PR 909. A repeating timer then moves to its next due time, or to every after now when that has passed, so it never fires twice for one gap. hand wake, a fire and a cancel record timer.armed, timer.fired and timer.cancelled with the timer's task. An armed timer keeps the work from being unwatched only while a managed supervisor runs and it is due within 6 hours (the watch horizon); with no managed supervisor, or one from an earlier Luvus server, no armed timer counts, the work shows as unwatched, and hand orient marks each timer (no managed supervisor: not fired). A timer armed further out, such as --at a month away, does not stop the stall wake, and hand orient marks it (beyond the horizon) while the stall wake names the nearest one (t1 is active, no attempt is running, idle 20m; w2 is armed but due in 31d). A repeating timer counts while its next due time is within the horizon, and a timer that comes within it starts counting then. The watcher logs a fire as timer.fired w3: check PR 909, the form of the event. While a wait is armed and a managed supervisor runs, its task is not unwatched, and it leaves the Board's STALLED, FAILED and INTERRUPTED items and the stall wake's task list; the wait fires once like any one-shot timer, then the task is plain active again and all of that returns. Finishing or abandoning the task, hand wake cancel wN and hand attempt start on the task end a wait early (timer.cancelled). hand orient marks a wait's timer (waits tN) in place of (beyond the horizon), hand task show tN lists it as waits, and the Board shows Waits until Nov 3: reason on the task's page and its Tasks row. Hand never asks a forge or CI anything for a timer: it only wakes the supervisor.
Supervisor input
iN. A message queued for the supervisor, from the board's composer, the desktop's, hand supervisor send --text TEXT, or POST /api/v1/message. It is at most 16 KiB of UTF-8 with no control characters other than newline and tab, and it may not start with [hand v1, which Hand keeps for its own messages. Inputs are delivered in order as soon as the supervisor can take them.
An input can carry files. Each one the operator pastes, drops or picks on the board, of any type and up to 10 MiB, is copied into the fleet's inbox/ folder as YYYYMMDD-HHMMSS-N-<safe name>: the original base name with every run of characters outside [A-Za-z0-9._-] turned into _, leading dots and dashes stripped, and at most 80 bytes with the extension kept. An image (png, jpeg, webp or gif, by its bytes, not its name) takes the extension of its type, and any other file named like an image gets .bin appended. The board holds them as chips beside the message box and, on send, appends a line [image: PATH] for an image and [file: PATH] for any other file after the operator's text, one per file; older images are named YYYYMMDD-HHMMSS-N.ext. The supervisor reads that path with its own tools, as data. Chat shows each such line from this fleet's inbox/ as a thumbnail for an image and a file card for anything else, and a dashed card once the file is gone. A prune may later move the file to the trash, and it matches only these names Hand gives, never another file in inbox/ (see Inbox folder and trash).
Model list
What Hand knows about the models each harness can use: one list per harness, kept in cache/models/<harness>.json in the fleet folder and also in $SECONDHAND_HOME/models/<harness>.json, which is what keeps several fleets from each refreshing the same list (a refresh writes both, and a reader takes the newer; folder mode 0700, files 0600, written to a temporary file and renamed). It holds names only. Each model has a name, a label, the efforts it supports and a source: built-in for what a harness carries itself (Claude Code's aliases and ids, Codex's bundled catalog), discovered for what it read or fetched (the lists of opencode, agy and Copilot, Codex's own models_cache.json), and custom for a name Claude Code lists that is neither an alias nor a Claude id (a gateway name, a pin, a modelPicker entry). Claude Code claims every effort for such a name, which Hand cannot confirm, so hand models marks them unverified. A Codex model whose visibility is not list is hidden: valid, but not offered. The file also holds a format number, the harness's version, a key (the binary's size and modification time, and those of the config files whose change should invalidate the list, hashed; Hand only stats them), the time it was read, the names that were listed once and are gone, and the reason of the last refresh that failed.
Hand reads a list with one command per harness, no prompt and no tokens: Claude Code's initialize control request over claude -p with stream-json in and out (only the models array of the answer is decoded), codex debug models --bundled (else Codex's own cache file), opencode models --standalone (names) and opencode api --standalone GET /api/model (the variant ids of each model, kept as its efforts; when that call fails the names stay without variants), agy models, copilot help config (the quoted names under model, which it prints offline; auto is added, and every other model gets the flag's own effort levels, marked unverified because the page names no effort per model; a changed format gives no list and a note) and omp models --json (each selector with its thinking levels; an empty answer is a failure, not a list with no models). Each run has a timeout, an output limit, no terminal and a minimal environment, and is killed with its process group; a harness that is missing, signed out, too old or garbled gives no list and a short reason, and nothing is blocked (what Hand runs). Claude Code's list reflects the environment and the folder it ran in, so it is the list for the process that refreshed it.
A list is stale after 1 hour for Claude Code, whose answer follows environment pins and settings that the key cannot see and costs about 0.4 seconds and no network, and after 6 hours for the others, or as soon as its key changes. A stale list is still used. A failed refresh keeps the previous list, records its reason and is tried again after 15 minutes. Two refreshers at once run one command, under a lock file beside the cache.
Only three things run a listing command: the watcher, for every stale or missing list of an installed harness, while it runs (it logs a new failure once); hand models --refresh [HARNESS]; and the board's Refresh models action, which runs the same refresh in the background (one per harness at a time, at most one every 30 seconds, laptop sessions only). Everything else, the board's pages included, reads the cache and never runs a harness. With no cache, Claude Code shows its four aliases, Codex its own cache file, and opencode, agy, Copilot and omp no list. hand models prints the lists, and hand doctor one models NAME row per harness.
A model that was in a list and is not in the fresh one has left the list. For Codex, agy, Copilot and omp, whose lists name every model they accept, a start refuses it while the list is fresh, saying model X is no longer listed by H; for Claude Code, whose custom names no list can vouch for, and for a stale list, the start goes ahead with that warning. A model that was never listed is only unverified. A list read from Codex's own cache file can be narrower than its bundled catalog, so it never marks a model as left, and a model it lists is not left.
Model switch
A pending change of the supervisor's model or effort that keeps its harness and its conversation: hand supervisor switch --model M --effort E, or the model menu on the board. Hand applies it when the current turn ends, by stopping the supervisor and resuming the same session with the new model under a new sN. --cancel drops a pending switch. For opencode the model is provider/id and the effort is its variant, which a switch may leave empty: a switch to another model sends no variant unless --effort gives one, and --effort default is sent but not stored. Hand refuses an opencode model that is not in opencode models (after refreshing the list once). Before the resume Hand runs opencode api --standalone session.switchModel on the supervisor's session, and after any opencode resume it checks that opencode took the one-line resume note, and holds the messages until a switch or a new supervisor that answers when it did not; a failure leaves the supervisor stopped, hand supervisor resume runs it again, and --cancel also drops the switch of the newest stopped supervisor.
hand supervisor switch --harness H changes harness instead. It is refused while the supervisor is working; between turns it stops the supervisor and starts the next one on the new harness, with a launch message that names the one it replaces. The new supervisor starts a new session and orients from hand orient; the board keeps the earlier session in Chat under a divider. hand models and the board's model menu list each harness's models and efforts from its model list: the variants of each opencode model as its efforts (none when opencode api could not be read), and no efforts for agy. The board's Start and switch forms group the model select by source, narrow the effort select to the chosen model, and add an "Other model" box next to the select; a name typed there wins over the select (the form posts it as other beside model), and a name outside the list starts with the warning.
A worker's model switch is hand attempt switch aN --model provider/id [--effort VARIANT], for an opencode attempt only. It never waits: the attempt must be running, its pane idle or done at the prompt (checked again just before the stop), and opencode's session list must not show its session busy, else the command refuses and changes nothing, as it does for a model that is not in opencode models or one the session already runs. The route is the supervisor's restart without its resume note, because the worker's identity does not change: stop the pane's process (not the worktree sweep of attempt stop), session.switchModel on the attempt's session, then the same --session launch with no brief. One transaction points the attempt at the new terminal, writes the new model and effort into the attempt (with a model.changed event when they differ), and appends attempt.switched, whose detail reads aN: switched to MODEL EFFORT, pane P; the argv stored on the attempt, brief included, is unchanged, and the watcher's next sweep reads the model back from the session and corrects the column when it differs. If switchModel fails, the old pane is started again, and attempt.switched reads back on OLD when the session reads back on its old model, back on its pane, on X when it reads back on another model, else back on its pane, model unverified; the stored model is the one read back, else it stays. If the new pane cannot start, the session is put back on the old model (when it is known, else the error says where the session stays), and the attempt ends interrupted with the reason switch to MODEL failed: …; its worktree and session stay, and hand attempt clean aN, then hand attempt start tN --continue aN, starts it again on the same branch. A switch killed after the stop and before the new pane leaves the attempt running with no pane; the next command ends it exited (terminal exited), and the same two commands continue it, on whichever model the session ended on. A switch takes about 4 seconds, plus up to 30 seconds while the new pane settles; a send before then can be refused as not at a prompt.
Surfaces
Orient
hand orient: a bounded summary of the fleet, rendered from state plus one scan of reports/ and shots/ (the artifacts line). It shows the home, the fleet, the supervisor (supervisor: s21 opencode anthropic/claude-sonnet-5-5 running: its ref, harness, model and effort when it has them, and status; the line is quoted when a value needs it, as for a model with a :), the task counts, the cursor, the unwatched tasks and their idle time when there are any, the armed timers when there are any (one due beyond 6 hours is marked (beyond the horizon), a wait (waits tN), and every one (no managed supervisor: not fired) while none runs), inbox_folder with the count and size of the inbox folder's files when there are more than 500 or they pass 100 MiB (with a help line pointing to hand inbox size), artifacts when reports/ and shots/ are over the retention limits (with a help line naming the secondhand-cleanup skill), the active tasks with their plan, attempt (with quiet, or blocked: and the screen's hint, when that is its newest signal, and (waits until 2026-11-03) while the task has a wait), report and open decisions, then the open decisions, the unread reports, the inbox, recent events and the operator memory. A title, question, hint or report summary that is too long is cut at a word boundary, so a URL is never cut in half. It stays under 6000 bytes: when it would not fit, it drops rows and says which command lists the rest. The supervisor runs it at the start of a session and after a resume.
hand orient --since CURSOR is the short form for each wake and before the supervisor ends a turn. It takes the cursor of an earlier hand orient or --since run (the same event cursor hand wait --after takes) and prints since, the new cursor, you when LUVUS_PANE_ID is set, watch: missing when no watcher runs, the task counts, the unwatched tasks with their idle time when there are any (the same check as the full form), then open_decisions and unacked_reports for the whole fleet (the full form's rows and caps, so one from before the cursor shows until it is answered or acked), changed rows (with the task's status) for the up to 15 tasks any event after the cursor names, newest first, and the 20 newest events, with attempt.blocked, attempt.limited, attempt.quiet, supervisor.stalled and timer.fired before the plain ones. It prints no home, fleet, supervisor, timers, inbox, recent events, operator memory or help. It stays under 3000 bytes by dropping plain events, then changed rows, then reports, decisions and last the wake events; every section it cuts, by cap or by bytes, prints <section>_more and sets more, and the supervisor then runs the full form. A cursor ahead of the fleet's last event, or below 0, is refused.
Doctor
hand doctor: a check of this machine and the fleet it runs in, for when something does not work. It prints the binary's version, channel and path, git's version, the pinned Luvus and its server's version, then a checks table of check,state,fix rows, where the state is ok, warn, fail or info and every row that is not ok names its fix. The checks: hand (this binary is the one on PATH; a source build), git (found; core.longpaths on Windows), harness NAME for each harness found on PATH (a harness found only as a .cmd or .bat is a warn that names the file, because Hand never starts a batch file; one harness warn row when none is installed), models NAME for each of them (always info: how many models its model list holds and from which sources, how old the cache is, or why there is no list), systemd on Linux (a user session, information only), luvus pin (pinned, unchanged and runnable; warn while the pin is still the machine copy of an older hand), iroh (the hand-iroh sidecar: info when it is not installed, installed and off, or the board is not running; warn for a version other than the one this hand pins, a failed sidecar, one that reached no relay, or a recorded --iroh with no sidecar installed; ok while it is ready; the iroh field then holds its version and path and iroh_id the endpoint ID), and inside a fleet schema, fleet (registered under ~/.secondhand), project NAME for each project (ok; info before its git settings are recorded; warn when its repository cannot be read; fail naming the settings that changed since), routing.json (a fail with the removal message while it still holds profiles), luvus server (its version against the pin), board (a board for this fleet answers at board.addr), open on another host (fail) and unclean close (warn), each shown only when the host marker calls for it, machine-local (always info: what this machine keeps outside the fleet), restore points (info: how many hand.db backups exist in the fleet's backups/ and the older machine folder, and the newest), old machine copies (info, only when copies of the old layout are left in $SECONDHAND_HOME) and watcher. Outside a fleet it skips the fleet checks and says so. It exits 1 when a check fails, else 0. It repairs nothing, starts no server, agent or board, reads the model lists without refreshing them, and spends no tokens. It opens hand.db as every command does, so a database of an older schema is migrated then. It reads no credential, so it cannot tell whether a harness is logged in, and it checks the shell's PATH, not the board's.
Board
hand board: a local web page from the same binary, and the operator's interface. One board process serves every registered fleet. /<fleet id>/ is one fleet's page, and / lists the fleets while the board listens on a loopback address; on a network address, and for a request that a proxy forwarded (X-Forwarded-For or Forwarded), / shows no list, so open a fleet by its link, unless the request holds a valid paired phone session: then it lists the fleets that phone is paired with, and no others. The default address is 127.0.0.1:7777 (--addr); --peer-addr opens a peer listener beside it. hand open starts a board when none answers its /<fleet id>/proof check: systemctl --user start of an installed secondhand-board unit, else a transient unit of that name, else a detached process (macOS, Windows, no user manager) logging to board.log; it waits up to 10 seconds for the proof. /<fleet id>/connect is the Phone page, where the laptop shows a pairing code and lists and revokes paired phones, and /<fleet id>/pair is the page a phone pairs on.
A fleet's page has two parts. At 900px and wider, the work pane (Needs, then Tasks) sits beside the supervisor pane, with a separator between them that the operator drags to size the chat (the board remembers one width); below that Needs, Tasks and Chat are three tabs, which merge into the masthead on short screens. /<fleet id>/supervisor is the supervisor's Screen page: it reads the pane unconditionally (40 lines, whatever the agent state), shows the text with its revision and digest, the fixed key toolbar and, when the screen is a dialog, an Ask card. A dialog is a supervisor whose reported state is blocked, or a screen whose question line sits directly above its option block and the prompt's own footer (select, ↑/↓ or esc) in the last rows; a bullet or a numbered transcript line alone is not a dialog. A numbered card presses 1, 2, 3; a card whose options are marked ◯ and ◉ moves from the cursor to the option it names (up or down once per row, then enter), so the focused option sends enter only. Each press carries the screen's revision, digest and last press, and a stale screen is refused. On a network address the page shows the screen and no forms. The page carries the same live status strip as the fleet page, so its tab title, favicon and Needs count follow the fleet, and a dropped stream shows the connection band with Reload. The rail lists the pages and the fleets:
- Needs: what waits on the operator, most severe first: the supervisor's blocked screens; a worker's blocked screen or its turn that ended without a report, once no supervisor is running or the supervisor has left it for 10 minutes; a worker stopped at a usage limit, at once and until it works again, and the supervisor while its screen shows a limit line; failed, exited or interrupted attempts (not those of a task that waits, while its wait is armed); "Work is waiting for a supervisor" while work waits on one; the idle supervisor once a stall wake went out (
Supervisor idle for 1h12m with 3 active tasks and no running attempt), until the work is watched, another event arrives or the supervisor works; a supervisor that stopped unexpectedly; open decisions; unread reports; an attempt whose turn has run 60 minutes (attempt.long); a finished attempt whose worktree Hand keeps and why (Kept);reports/andshots/over the retention limits (Old files). The items sit in bands, one head per kind (Blocked, Failed, Decision, Report and so on); the top item is open and the rest are one-line rows. Below them sits the task list, grouped under Active and Inbox, with each attempt's agent state. With nothing waiting it reads "All clear"; - Chat: the supervisor pane: the conversation, read from the harness's own session record without tool calls or thinking (the supervisor's replies on the pane, the operator's messages as bubbles on the right, Hand's own events as one-line notes), plus the message box and the supervisor controls. The rail's Conversation page shows the same conversation on a page of its own.
When a fleet has more than one registered project, every task row, every decision and every report in Needs, and every row of the Tasks page carry a neutral badge with the task's project name (cut with an ellipsis, the full name in its tooltip); the badge links nowhere. Each task and each decision also has its own page; from the fleet page, tasks, attempts, decisions and reports open in the viewer dialog, outside links open in a new tab, and the rail, the crumbs and the pager navigate in place. The Tasks list under Needs keeps active and inbox tasks and the five most recent finished ones; Browse finished tasks (N) opens /<fleet id>/tasks, the Tasks page: a search over title, goal and ref (every word must match, in any case) that covers every task; filters for status (default (every status once there is a query, finished without one), or one chosen status), project (when the fleet has several) and a UTC date range, and results newest first, grouped by day with a count, 50 to a page. hand task list --q searches every status the same way. A task's finish date is the time of its task.done or task.abandoned event; a task still open dates by its last update. ?all=1 on a fleet page still lists every task. A sub-task is listed under its parent when both are in the same group; otherwise it is a root of its group with an "under tM" link. Every action answers with a short receipt, and Chat shows key presses and the supervisor's starts, stops and switches as Hand lines. The board is a projection of state, so restarting it loses nothing. The supervisor controls work only while the board listens on a loopback address. On a network address, only answering decisions and marking reports read still work.
JSON API
The board's read views and its actions as JSON under /<fleet id>/api/v1, plus the host's /api/v1/fleets (the fleets a session proves) and /api/v1/meta (Hand's version, the sorted features a client checks, and the sidecar's state), for a local client such as the desktop window and for a phone paired over the peer listener. It serves the fleet status and Needs, the timeline, the tasks list, task, attempt, decision and report detail, and the actions: send a message to the supervisor, answer a decision, acknowledge a report, and run the supervisor controls. The blocked supervisor's status carries its revision, digest and pressed sequence, and, when the screen is a dialog with a real question line ending in ?, the parsed ask (the question, clipped to 200 characters, and at most 8 option labels clipped to 60, each with its keys); it carries no ask when a line looks like a secret. It never returns last_screen or raw screen text, except the excerpt, title and options of a blocked or worker Needs item, which a client renders as plain text, never logs and never follows as instructions. Each success is one envelope with the fleet, a change cursor and generated_at; the host's two paths have no fleet and no cursor. It needs the same session cookie as the pages, refuses a mutation without the session's CSRF token in X-Hand-CSRF (read at GET /api/v1/session), answers only on a loopback board, to the laptop's session or to a phone paired over the peer listener, and never returns last_screen or a terminal screen, except those same Needs fields. api.md has the endpoints, the actions and the error contract.
Change cursor
The opaque cursor in a JSON API envelope, and the X-Hand-Cursor header: it holds the fleet and a signed fingerprint of the fleet's name and that response's data, including the transcript and pane facts read outside the database, not a time and not just the event sequence. A client sends it back as ?since=: the same fingerprint answers 304 Not Modified with no body, an earlier one answers the new data, and one this board did not issue answers 400 bad_cursor.
/<fleet id>/doc/<path> is the document viewer: a read-only page for a file or folder inside the fleet home, on a loopback or a network address. It renders Markdown up to 1 MiB in document mode (relative links, relative images and task lists, still without raw HTML), shows other text of any size as numbered lines (a file up to 8 MiB whole, in chunks of 500 lines; a larger one as a window of about 4 MiB that opens at its end, moved by ?at=BYTE or ?line=N, with ?find=TEXT listing up to 200 matching lines over the whole file, and ?from=BYTE&line=N returning the complete lines appended since BYTE as the Follow feed, or the header X-Hand-Reset when the file shrank or was replaced), serves an image inline when both its bytes and its name say image, previews JSON (pretty up to 16 MiB), CSV and TSV (a header and a table of the first 2,000 rows), diffs, HTML (a sandboxed frame without scripts), SVG (an <img>), PDF (a same-origin frame), and video and audio (only when the sniffed type and the extension agree, with HTTP Range), each with a Source, Text or As written toggle where the file has text (Markdown up to 1 MiB has Rendered and Source, remembered in the browser as hand.doc.source), through ?raw headers set per type (sandbox CSP, nosniff, no allow-scripts or allow-same-origin), lists a folder (folders first, then names in any case order with the exact name as the tie-break, dot entries hidden, every entry in pages of 500 by ?page=N, a last page for a number past the end, and a refused symlink as a row with its reason and no link), gives an image its own page with ?view (Fit) and ?view=actual, steps a file's page to the neighbouring files of its folder (Previous and Next, with ← and →, skipping folders and refused entries), and offers every file as a download; a Markdown or text file has a Wide toggle (the dialog opens wide, the page in a reading width), and a Markdown table wider than its box has a Pin first column toggle, both remembered in the browser; a file whose first 8 KiB holds a NUL or invalid UTF-8 shows a notice instead of text. A text response never reads an unbounded file into memory and never writes unbounded HTML, and the line writer escapes every byte: ANSI colour (SGR 30 to 37 and 90 to 97, bold) becomes x- classes, other CSI and OSC sequences are dropped, C0 and C1 controls and bidirectional controls show as visible marks (boxed for the first 20,000 in a response, plain symbols after that, so the HTML stays within a few times the file's size), and a line is cut at 256 KiB. It refuses dot paths, the files at the top of the fleet home (hand.db, board.token, AGENTS.md and the rest), luvus/, locks/, projects/, anything that is not a regular file or a folder, and any path whose real path, after symlinks, leaves the fleet home; the same rules decide whether a file deliverable links to it, whether hand open tN --delivered opens it there, and whether an absolute path in a reply, decision, report, plan or document becomes a link (at most 50 paths in one message; a path that is missing or refused stays text). A path relative to the fleet home (reports/t1/design.md, ./shots/t2/) links only inside backticks, and only when it exists inside the fleet home and passes the same rules; bare text links absolute paths only. To show the operator a file, put it under reports/ and name it there: the Board cannot open a path outside the fleet home.
Desktop
hand-desktop: a MyGo v0.7.4 native window, pinned and cgo-free, and a separate binary from hand (docs/platforms.md). It is a client of the Board, never a second owner of state: it reads the JSON API over loopback, opens no fleet database, keeps its only local file, the draft, and imports neither the CLI nor the board. v1 reads, sends, answers and controls: the window has two panes, the Needs and Tasks on the left and the supervisor's conversation with its pinned composer on the right, divided by a draggable divider (the chat starts at 38% of the window clamped to 400 to 720 px, and a drag may take it down to 360 px, never leaving the work pane under 440 px, as on the Board; the width lasts while the window runs; the window is never narrower than 860 px, so that rule holds until the narrow tabs exist); the rail switches fleets and opens its labels only where the work pane keeps 440 px (a window under 1033 px keeps it collapsed), and its Board item returns to those two panes, and its Tasks, Conversation, Screen and Phone items do nothing until the pages they name exist, since the conversation and the tasks are always on screen; the pane head over the conversation holds the supervisor controls that apply (Force there, while blocked, stands in for the Board's "Screen misread?" until the blocked item is built) and an overflow menu with Refresh now and Open in browser (with no supervisor ever, one Open in browser button, since the API cannot start a first supervisor, and the status's composer hint under the composer), a conversation that cannot be read shows its own error with Retry in the chat pane and keeps what it held, a blocked supervisor's question with one button per option (or the key toolbar when the Board's parser has no ask for the screen) shows under it, and the Needs row of a blocked supervisor opens nothing; a task, attempt, decision or report opens in place with Back, an open decision offers its options and a note field, a report offers Mark read, an answer or a read opens the top remaining Needs item when the item was opened from Needs (back to Needs when that item is a blocked supervisor's row or nothing remains), and the conversation (oldest first, opened at its newest entry) has the composer pinned below it, whose Ctrl+Enter or Cmd+Enter sends and whose Enter starts a new line. The overflow menu's Open in browser runs hand open (the hand next to hand-desktop, else on PATH) with the screen's task, decision, report or attempt ref, or none for the fleet page, in the fleet's folder, so the browser has the fleet's session; a failure shows inline and the window stays responsive. Reports, decision questions and answers, goals, plans, briefs and the conversation render as Markdown (tables, code blocks with Copy, task lists), and the text of a screen can be selected and copied; a delivered item on a task, attempt or report screen is a button on the same rules (an http or https link opens in the system browser, a file with an href goes to the viewer through hand open --doc PATH, and a file outside the fleet stays plain text); a link opens in the system browser only when it is http or https, or a path under the open fleet's own /<fleet id>/ on the board (absolute links to the Board's own address are allowed too), and every other scheme or path (file:, javascript:, mailto:, //host, ..) does nothing. A link's visible text can differ from its target: MyGo's tooltip shows the target unless the link has a title. A Board-path link goes through hand open when it names a task, decision, report, attempt, the fleet page or a document (hand open --doc PATH), so the browser has the session; any other Board path shows an inline error. A read or an action runs off the render path: the window stays responsive and shows a loading state until the board answers, and an error keeps the screen and the composer's text. Its writes all go through the JSON API: a message, an answer, an acknowledgement, a supervisor control and a key press; its only local write is its draft. It auto-refreshes the selected fleet: the status, the open screen's data and the conversation poll every second while the window is focused and every 5 seconds while it is not, and not at all while it is minimised, each sending the change cursor it last read, so an unchanged board answers 304 and costs no body, except that while the held status lists a blocked worker item it reads the status without a cursor, because a worker's screen-only change does not move the status cursor. The loop pauses with no fleet selected, is cancelled by a fleet switch and by close, applies a 200 to the screen only while that screen is still open, backs off each failing read on its own (the status, the open screen and the conversation each double their wait to 60 seconds and are skipped meanwhile, so one failing read never slows the others) and shows Stale · retrying in the masthead for a failing status or screen, and the conversation's own error with Retry in the chat pane, instead of an error card; a tick is bounded at 5 seconds. A refresh never touches the composer draft. Closing the window stops neither the board nor a watcher. It is a single instance per user: a second hand-desktop hands its arguments to the running window, which comes forward, and exits. hand-desktop --open LINK takes the open link of an item (handboard://open/<fleet id>/ and the fleet's page, task/tN, decision/dN, attempt/aN or task/tN#rN), selects that fleet and opens the ref (a #rN opens the report) with Back going to the two panes; it refuses any other text, with the grammar in its message, and refuses a fleet that is not registered (exit 2 from a first launch, an inline error in a running window), and it opens no page while no board answers, leaving the board error. The Linux alert's Open runs it (Alerts).
It finds the board the way hand open does, through board.addr in $SECONDHAND_HOME, and lists a fleet when that fleet's link resolves and its hand.db is there. A fleet whose board.token is missing or cannot be read stays in the list and shows the error with the hand open fix. It sends that token as the fleet's session cookie, hand_board_<fleet id>, the value hand open exchanges for a browser's session; the token never goes in a URL and is never printed. A fleet whose token the board refuses, a board that is not running and a board on a network address show the error and its fix instead of data. The API's actions and its CSRF check are its only additions; there is no local-trust path.
Linux runs the window first; it builds on macOS and Windows, and their real windows have not run yet (see Platform support). A release carries hand-desktop for Linux amd64 and arm64 only, with a .sha256 beside it; hand update puts it next to hand after checking that checksum and the version and commit its --version prints, hand doctor has a desktop row (ok only when both report the same known commit, and info when either build carries no commit stamp, so it cannot be verified), and other platforms stay in the browser. On Linux hand update also writes the launcher entry $XDG_DATA_HOME/applications/hand-desktop.desktop and its icon $XDG_DATA_HOME/icons/hicolor/512x512/apps/hand-desktop.png while the window is installed, and removes them when it is not; hand doctor has a desktop entry row (ok, or warn when the entry is missing, points at another path, is not Hand's, or is left over without a window).
Supervisor controls
Start, resume, stop, interrupt, force and keys, from the Board's console and blocked screen and from the desktop's pane head. Each is an action in the JSON API that answers the Status it serves. On the desktop the pane head shows only the controls that apply: Resume (with New session in the overflow menu) while no supervisor is live and the last one has a session, and otherwise Start new, same as that supervisor, interrupt only while it runs and works unblocked, force only while it is blocked, and stop while it runs, which needs a second press within 4 seconds, the Board's own window, and reads "Press again to stop" in between; the second press counts only for the supervisor the first one armed, so a replacement that appears meanwhile needs its own two presses. While the supervisor is blocked the desktop shows the status's ask (the question and one button per option, each sending that option's keys as one press), and only when there is none the key toolbar, with the fixed keys enter, esc, up, down, 1, 2 and 3; each press sends the blocked status's revision, digest and pressed sequence, so a moved screen or another press refuses it. Stop, interrupt, force and each key press send the displayed supervisor's ref and the board refuses the action with a conflict when a different supervisor runs by then, so a click never stops, types into or presses a replacement. A control that ran but whose status the board could not read back answers accepted instead of a Status, and the desktop says the action went through and asks for a refresh rather than reporting a failure. Beyond its ask, the API carries a blocked item's screen only as the excerpt, title, options and deny_at on its Needs item (the Board's own last lines, never the full screen), and the digest is only the stale-screen guard the web form already sends.
Composer
The message box at a Board fleet page's Chat, and in the desktop window the field below the Conversation. It sends one supervisor input and nothing else. On the desktop, as on the Board, Ctrl+Enter or Cmd+Enter sends, Enter starts a new line, Send sends the bound text, and Clear discards it; a send, a Clear and a successful open read the fleet's draft. The Board keeps its composer's text in the browser's local storage; the desktop keeps it in its own state folder. A failed send keeps the text and shows the board's scrubbed message.
The desktop's composer sends text only in v1. The Board's composer also accepts attachments, which it copies into inbox/ and writes into the input as [image: PATH] or [file: PATH]; the JSON API does not carry a host path, so attaching from the desktop waits for a contract that names the inbox file instead.
Draft
Composer text the operator typed but did not send. The desktop stores one draft per fleet at $XDG_STATE_HOME/hand-desktop/drafts/<fleet id>.txt, falling back to ~/.local/state/hand-desktop/drafts/ and, on Windows, %LOCALAPPDATA%\hand-desktop\drafts, with mode 0600. It is not a secret and holds no token. Opening the window or switching fleets restores that fleet's draft; a successful send and Clear delete it; a failed send leaves it. The draft file is the desktop's only local write.
Transcript
What a harness wrote down about a session, which the board reads in place, line by line, never loading a whole file, and skips a line it cannot read. The Chat reads the supervisor's; for omp it reads the session file under the agent folder's sessions/, lists its user and assistant messages, reads the served model from the latest assistant message and the context gauge from that message's usage, and keeps the launch's effort because omp does not record a resume's change. The attempt page reads a copilot worker's: $COPILOT_HOME/session-state/UUID/events.jsonl (COPILOT_HOME defaults to ~/.copilot), where UUID is the word after --session-id in the attempt's launch line. It lists the latest 50 entries oldest first: the worker's messages (worker), each tool call as the tool's name and the first line of its command or path (tool, never its output), and a message sent to the worker after the brief. The details column adds Answered by, the model of the worker's latest message. A worker message keeps its first 4000 bytes. When the session's selectedModel is set, is not auto and differs from that model as typed (a dated alias of the same model would warn too), a warning line reads Asked for X, answered by Y.: Copilot started on Auto in place of a model it does not offer. The transcript has no turn or usage rows. A launch line without --session-id says so, and an attempt on another harness has no transcript on its page.
Viewer dialog
A thing's page opened in place: a <dialog> in every Board page, outside the live regions (the fleet page's five and the supervisor page's sup) and the composer, that shows the page the Board fetched (a document, a task, an attempt, a decision, or a report as its task's page at the report's card), so it can never differ from the page. A click on a link to one opens it with showModal() and pushes the page's address (pushState) tagged with a depth of 1; a link followed inside the dialog pushes another entry one deeper, up to 20, so Back and the strip's Back step through the dialog's pages, Back at depth 1 closes it, Forward opens it again and a reload shows the normal page. Close, Escape and the backdrop go back the whole depth. Ctrl, Cmd, Shift and middle clicks, the dialog's Open as a page link (which always carries the page shown now) and a page without the script use the link's own href in a new tab. A task or attempt page in the dialog refreshes itself every 10 seconds while the page is visible, with one timer that stops when the dialog changes page or closes. The forms of those pages work in it (a decision's answer and a report's Mark read post with fetch, and on success the dialog's page and the page behind refresh); the answer box keeps its own draft key, dialog:<decision id>, beside the Needs box's. A form outside that table would be disabled under a line that sends the reader to the page. The live refresh and the event stream of the page behind keep using the address the Board page was opened on, not the dialog's.
Rail
The Board's navigation: a 56px column of icons at the left edge from 900px, and below that a sheet opened from the masthead's menu button. It holds Board (with the live Needs count), Tasks, Conversation, Screen, Phone and the fleets. A name shows on hover, on focus, or for every item at once from the label button. It lists the current fleet and only the other fleets whose token the browser holds, or whose pairing the phone holds, and that fleet still accepts; it shows no other fleet's Needs count. On a network address it lists only its own fleet.
Board token
board.token in each fleet home. It guards that fleet's page, and the browser exchanges it for a cookie named for that one fleet, which only that fleet accepts. The cookie is kept for 400 days, or until board.token changes, only when the login link carries keep=1 (plain hand open adds it), the board is on a loopback address and the request has no proxy header (X-Forwarded-For or Forwarded). Any other login, such as a hand open --print link, a proxy, a header-less forwarder or ssh -L, or a network address, lasts until the browser quits. The cookie goes to every page of the board, which is how the rail knows the other fleets the browser can open. hand open logs the browser in with it without printing it, and only that login is kept; hand open --print prints a login link that holds it, for another device or browser. A paired phone never holds it, and changing it signs every paired phone out.
Hand's Android Companion
The phone app that dials the laptop's hand-iroh endpoint ID and shows the Board in a web view. It is for Android only; the Android app is built outside this repository against the same contract. Hand's iOS Companion is deferred, and nothing targets iOS.
Peer listener
The second loopback listener that hand board --peer-addr 127.0.0.1:7778 opens beside the board, where every request counts as remote whatever header it carries. Before pairing it serves only /<fleet id>/pair, POST /<fleet id>/api/v1/pair and what the pair page loads; after pairing it serves the whole board except /<fleet id>/proof. It takes loopback addresses only, and only beside a loopback --addr.
Hand-iroh
The hand-iroh sidecar, built from iroh/, that hand board --iroh starts. It opens an iroh endpoint, and turns each stream a phone dials into one connection to the peer listener with the peer preface in front. Hand looks for it next to hand, then on PATH, and runs it only at the version this hand pins.
Peer preface
The HAND-PEER 1 <endpoint id> <secret> line a peer writes before its HTTP on the peer listener. The secret is board.peer, and a connection that sends it has the identity iroh:<peer ID>; one without it is anonymous remote and shares that listener's pairing rate limit.
Paired phone
An Android device that redeemed a pairing code and holds the cookie the fleet set for it. It opens the fleets it is paired with on the board, and on a --peer-addr connection its cookie is bound to the peer ID in the peer preface.
Pairing code
The one-time code in the companion link; the whole link is at most 213 bytes, and the code never goes in a query, server log or report. It lasts 5 minutes and works once: the app redeems it with POST /<fleet id>/api/v1/pair, or through the pair page, and receives the cookie that makes it a paired phone.
Phone access over iroh
The board's --iroh option and the board.iroh setting that records it: the board opens a free loopback peer listener and starts hand-iroh as its child. A restart by hand open or hand update keeps it only when the board's own command carries --iroh.
Companion contract
The page companion.md and the test vectors beside it, which fix what a phone app may rely on when it reaches the peer listener through the hand-iroh sidecar: the protocol name hand-board, one bidirectional stream per TCP connection, the close and reset codes 1 to 5, the caps, the companion link, what the app opens, and the version rule. It has one whole-number version, 1 today. The app is a separate program built against it; the Board's side, the Phone page's choice and the effect of revoking on an open connection, is built, and the page marks what remains of step 3. iOS is deferred.
Companion link
What the Phone page shows, as a QR code and as text, to pair the Companion app: handboard://c#v=1&e=ENDPOINTID&f=FLEETID&c=CODE, and &r=RELAY (a percent-encoded relay URL) when the laptop does not use the public relays. It is at most 213 bytes, and everything in it is in the fragment, so a query, a server or a log never holds the pairing code. The app checks it against 15 reject rules in a fixed order and, once the user confirms, opens http://localhost:PORT/<fleet id>/pair#CODE in its web view, with the name localhost and never an IP address.
Open link
What an alert sends to open an item in the Hand app: handboard://open/<fleet id>/<page>, with <page> empty for the fleet page, task/tN, decision/dN or attempt/aN, and #rN after task/tN for a report. Hand puts it in HAND_NOTIFY_APP_URL and prints it as app with hand open --print. It holds no token, code or endpoint ID. The host open keeps it apart from the companion link, whose host is c. The grammar and its test vectors are in companion.md.
Alert feed
The alert table of hand.db and the route that serves it to the Hand app: GET /<fleet id>/alerts/feed?after=SEQ&wait=25 on the peer listener, held for at most 25 seconds. The watcher writes a row (seq, at, kind, ref, task_id, text, link) for every alert it sends to notify, so the feed is exactly what notify gets, and Hand keeps 7 days. It is not the event log: its rows are not events, appear on no task page and in no API response, and an alert is written without an event of its own because the change it announces already has one. Each row also has fetched_at, when the Board wrote it to an answer, and acked_at, when the next request arrived with after at or past its seq; /<fleet id>/alerts lists them with the delay between raising and acknowledging. See alerts.md and the contract.
Board app
Removed. The Board no longer offers an installable app, a manifest, an app icon or the /offline page, and app.js registers no worker. For one release /sw.js stays as a worker that has no fetch handler, deletes every cache and unregisters itself, so a home-screen app installed earlier stops caching and loses its worker; delete its icon by hand. The release after drops the route.
TOON
The compact text format of every command's output: key: value fields, name[N]{fields}: tables, and help[N]: hints that name the next command.
Refs
Every record has a short ref that commands, the board and wakes share.
| Ref | Record | Numbered |
|---|---|---|
tN | task | across the fleet |
pN | plan revision | within its task |
aN | attempt | across the fleet; the board links it to the attempt page |
rN | report | across the fleet |
dN | decision | across the fleet |
sN | supervisor launch | across the fleet |
iN | supervisor input | across the fleet |
wN | timer | across the fleet |
Changed since 0.7
Hand 0.7 (0.7) used some terms that the redesign dropped or changed:
- Supervisor was the operator's own interactive session. It is now the managed background agent
sN. A session opened by hand still works, but stands down while a managed supervisor runs. - Supervisor harness and worker harness were separate registries. There is now one list of harnesses,
claude,codex,opencode,agy,copilotandomp, used for both roles, except thatcopilotis a worker only. Grok and Pi are no longer launched, althoughhand initstill installs the skill for them. - Fleet identity in
state/hand.dbis now the fleet ID inhand.dbat the root of the fleet home, and fleets also have a name. - Fleet registry (
~/.secondhand/registry.db) is replaced by the bookmarks in~/.secondhand/index/and thefleet.homerecord inside each fleet. - Current invocation context has no term of its own. The fleet home is still found from the working directory or
HAND_HOME, but the working directory now wins: aHAND_HOMEnaming another fleet is refused while you are inside one.--home DIRis new. - Duplicate fleet remains a refusal: a copied fleet home is refused until one copy is deleted.
- Supervisor orientation is still
hand orient. Monitor targets and currentness tokens are gone, and it prints an event cursor instead. - Monitor target, currentness token and wake hint are replaced by events, the cursor and the
[hand v1 wake]message. - Legacy Herdr namespace is gone: Herdr and Treehouse are replaced by Luvus and plain
git worktree. - Task kind (
scout,ship) and execution class (mechanical,standard,deep) are dropped. A task has only a goal. Whether it ships code is up to the brief. - Brief front matter (
execution_class,planned_against,model,effort) is dropped. A brief is plain text, and the harness, model and effort come from the attempt's flags. - Routing profile, Access (
readonly), Approval (ask) and Grant are gone:routing.jsonhas noprofilesand a worker or supervisor starts with--harness,--modeland--effort. Route, the mapping from task kind and execution class to a profile, was dropped before.hand route listis gone:hand modelslists models andhand attempt trackprints the track record. - Project was cloned, adopted or created under the fleet home. It is now a name for an existing repository, which Hand uses in place.
- Delivery mode and gate (
direct-pr,local-only,no-mistakes) are dropped, and Hand has no merge, deliver or teardown step. - Worker is recognised by its worktree's path.
HAND_ROLE=workeris gone. - Report was a scout's file,
data/<id>/report.md. It is now a record of any attempt, with a status, and it is read and acknowledged through Hand. - Plan, decision and supervisor input are new records with their own refs.