Voice interface for AI coding agents. Push-to-talk from any device to tmux sessions running Claude Code.
No Backwards Compatibility - Pre-launch, no customers. Change things completely, no legacy fallbacks.
uv tool install caches builds and ignores source changes.
# Step 0 — after a remote merge, pull first. rebuild reinstalls whatever is
# checked out, so a never-pulled main silently ships stale code.
git pull --ff-only
# During development (picks up changes instantly)
agentwire portal start --dev
# After structural changes (pyproject.toml, new files)
agentwire rebuild
# After code changes: ALWAYS do both
git pull --ff-only && agentwire rebuild && agentwire portal restart --devRebuild alone = stale static files. Restart alone = stale Python. The MCP server runs as a separate process started by Claude Code — session restart required after rebuild to pick up MCP changes. rebuild now refuses (warn + --force to override) when the checkout is behind origin/main, and agentwire doctor flags a behind-main checkout, a disabled kill switch (enabled: false in ~/.agentwire/damagecontrol.yml), damage-control rule/hook/matcher drift, and any of ~/.agentwire/ · .env · portal.token · machines.json readable beyond its owner (doctor --yes tightens them; #887).
The PR gate has to stay fast or it gets bypassed. Rules for adding tests:
- One assertion repeated over N inputs is ONE test. Loop, collect the offenders,
assert not bad, bad— a failure then lists every bad input at once. Neverparametrizea range or a cross-product just to get N test items. - A test must fail against the bug it guards. Mutate the code it protects once and watch it go red; a pin that passes against the behavior it was written to forbid proves nothing.
- Test behavior, not text. No tests that grep source files or docs prose for strings — they fail on refactors and pass on regressions.
- No real waits, no real tmux in the fast set. Stub the probe or the clock. A test that genuinely needs a real tmux or real git process is marked
requires_tmux/slow: PRs run-m "not requires_tmux and not slow"with no coverage; pushes to main run everything hermetic with coverage. agentwire/safety/_core.pycompiles regexes through_rx()(memoized) — keep new dynamic patterns on it, or eachcheck_commandre-pays ~90 ms of compilation.
All session/machine logic lives in CLI commands (agentwire/__main__.py). The portal (agentwire/server.py) is a thin wrapper that:
- Calls CLI via
run_agentwire_cmd(["command", "args"]) - Parses JSON output (
--jsonflag) - Adds WebSocket/real-time features
When adding new functionality:
- Implement in CLI first with
--jsonoutput - Portal calls CLI, doesn't duplicate logic
- Never bypass CLI with direct tmux/subprocess calls
CLI layout (#495): the old __main__.py monolith is split per-domain. Shared
helpers (machine config, SSH _run_remote, JSON output, session resolution, etc.)
live in agentwire/core.py; each command group lives in its own agentwire/<domain>_cli.py
module exposing a register_<domain>_parser(subparsers) registrar. build_parser()
imports them and runs the _REGISTRARS loop — adding a command means writing a new
*_cli.py + appending its registrar, not editing a god-file. Example: the portal git
endpoints (api_check_path / api_check_branches) shell out to agentwire repo-info
and agentwire branches (in repo_cli.py) rather than embedding their own git/SSH logic.
Full CLI command reference lives in the agentwire-cli skill.
Agents running in agentwire sessions should use MCP tools instead of CLI commands.
The agentwire-mcp-tools skill has the full reference (sessions, panes, voice, tasks, outbound channels, scheduler, desktop UI, handoffs). Rule of thumb: MCP for agents, CLI for humans/scripts.
Three independent axes (#716): every session has a ROLE, a TOPOLOGY, and a ROOTING — none inferable from the others.
- ROLE ∈ {
orchestrator,worker,reviewer} — authority + etiquette. Orchestrator = durable window, reviews + merges, directs children (a replaceable persona — explicit--rolesswaps it out cleanly). Worker = scoped task, report-back, draft-PR-don't-merge (a non-overridable safety rail —--rolesSTACKS on top, never replaces it). Reviewer = worker's rail inverted (#827) — adversarially reviews a sibling's PR, never opens/merges its own, reports a verdict vianotify_parent; also non-overridable, parented like worker (not rooted). Says nothing about location. - TOPOLOGY ∈ {
main,worktree,pane} — where it runs. A worker's concrete etiquette differs by topology (a worktree worker pushes a branch and opens a draft PR, keeps voice, and can ask via prompt-routing; a pane/main-topology worker is headless, writes an exit-summary, and gets auto-killed) — but the ROLE name itself (worker) is the same either way. - ROOTING ∈ {root, parented} (#715) —
created_by, drives prompt routing + notify-parent.
Note: MCP tools don't support git worktree creation. For worktree-isolated work, use the CLI directly — and pick the right primitive:
| Term | Command | What you get |
|---|---|---|
| Worktree session | agentwire worktree <name> -p <repo> |
Standalone tmux session named {project}-<name>, new branch <name> from origin/main, worktree at ~/worktrees/<project>/<name>/ (nested per project, mirroring ~/projects/). Survives independently; report-back via agentwire notify-parent --to <orchestrator>. Role defaults to worker (zero behavior change) — --kind orchestrator makes it a durable, replaceable-persona project window instead of a subordinate (see below). Etiquette for the default role (isolation, no rebuild/restart, verify in-worktree, draft PR + notify-back) is intrinsic — the worker-worktree role is auto-injected by the verb, so first prompts only need the task. |
| Worker pane | agentwire spawn --branch <name> |
A pane inside the current session (pane 1+), worktree on <name>. Inherits the session's dashboard; idle hook reaps it. |
When the owner says "worktree session", they mean the standalone session (agentwire worktree), never spawn panes. Worker panes are for small subtasks watched by the orchestrator; worktree sessions are for parallel autonomous work. Every spawn defaults to the bypass posture — workers included; damage-control hooks are the guard, not tool-locking — so no --posture is needed for the common case.
agentwire orchestrator [name] -p <project> is sugar for worktree --kind orchestrator — the durable-window one-liner: worktree topology + orchestrator role + rooted by default (a durable orchestrator shouldn't inherit whoever spawned it — the joint default below). name defaults to "orchestrator" if omitted.
Rooting (#715 + #716): agentwire worktree/new record the calling session as the new session's parent (created_by, drives prompt routing + notify-parent) only when the target repo is the caller's own project — spawning into a genuinely different project defaults to a standalone root instead of nesting under the caller. Same-project fan-out (a worktree session spawning another worktree of its own project) still parents as before. --created-by <name> forces a specific parent regardless of project; --created-by '' forces standalone even within the same project. Joint default with role: an explicit --kind orchestrator (on new or worktree, e.g. via the orchestrator sugar verb) also defaults created_by to '' (root) unless --created-by says otherwise — a durable orchestrator shouldn't answer to whoever happened to spawn it. The default-derived orchestrator (a plain branchless name, no --kind given) keeps the ordinary same-project-inherit behavior above unchanged. Detail: docs/wiki/sessions/prompt-routing.md.
Dangling-PR detection (#716): agentwire worktree --dangling (and agentwire doctor) flag LIVE worker sessions with an OPEN PR and no live recorded parent — the concrete failure mode a rooted-but-still-subordinate session hits: it correctly refuses to self-merge, so the PR just dangles with nothing positioned to act on it. Distinct from --list's "orphan" state (a dead session whose worktree dir is left on disk).
Worktree paths come from git, not a convention (#837 + #855). One helper creates+registers (worktree.create_and_register_worktree) and one resolves a session's real worktree by ASKING GIT (worktree.find_git_worktree / git worktree list --porcelain). All five creation sites route through the first — spawn --branch, new -s project/branch (what every scheduler dispatch shells out to), worktree, recreate, fork — so nothing is invisible to --list/--dangling/--prune/--remove anymore. --remove/--status route through the second: the documented layouts (~/worktrees/<project>/<name>/ and ~/projects/<project>-worktrees/<name>/, both live in the wild) are a default, never a guarantee, so string-building a path and acting on it is how a teardown reports success while removing nothing. When nothing real resolves, --remove now FAILS loudly and lists what git does know, rather than tearing down a guess. Registry entries carry topology — pane entries (from spawn --branch) name the OWNING session, so teardown never kills it and --dangling skips them. Never write ~/worktrees/<project>/<name> in new code; call the helpers.
Session NAMES come from the same SSOT (#868, #878). The path axis isn't the only one a convention can lie about. tmux treats . and : as its address separators (session.window, session:window) and silently rewrites both to _, so a project dir with a dot (~/.claude, dotdev.dev) gets a session named _claude-<name>, not .claude-<name> — and -s proj:x gets you proj_x. worktree.tmux_safe_name() is the one implementation of that mapping and worktree.worktree_session_name() applies it; never inline the substitution or build f"{project}-{name}" by hand. Those two are the COMPLETE 1:1 substitution set, measured by sweeping every printable ASCII char through real tmux (#878) — don't widen it by guess. (tmux also vis-escapes \ and control chars, but that escaping isn't idempotent, so such names have no fixed point and are deliberately left alone.) Resolution prefers reality over the registry (a live pane's cwd names its own session) and re-sanitizes a recorded name, so entries written before the fix heal on read — no data migration. And teardown states the session's fate explicitly in all three cases via worktree.teardown_session_note() — killed / deliberately left alone (pane topology) / no live session matched. That last one rendering as silence is what turned a name mismatch into a session leaked into a deleted directory under a line claiming it was removed.
Conversation identity is RECORDED, not reconstructed (#871). Same SSOT shape, third axis. agentwire MINTS the conversation UUID in build_agent_command and passes claude --session-id <uuid>, so ~/.agentwire/sessions/<name>/metadata.json is authoritative instead of a guess scraped from tmux scrollback or from whichever ~/.claude/projects/<encoded-cwd>/*.jsonl was newest. core.record_session_launch() is the ONE writer and core.load_session_metadata() the one reader; every path that launches a session in tmux calls it exactly once (new — hence worktree/orchestrator/helper/scheduler dispatch — plus recreate, fork, history resume, dev). spawn deliberately does not: a pane is not a session, and this store is keyed by session name, so a pane writing here would overwrite its OWNING session's record. Two flag properties are load-bearing and verified, not assumed: --session-id hard-errors on collision ("already in use", scoped to the launch cwd) so you must mint a fresh uuid4 and never re-pass a recorded id; and --resume <old> --fork-session --session-id <new> composes, which is why conversation_ids is a chain — --fork-session mints a new id on every resume. The flag is single-use, and the stored launch line is not (#901): AGENTWIRE_LAUNCH_CMD exists to be re-eval'd (#856/#866), so a line carrying a fixed --session-id died with "already in use" the moment the session had taken one turn and exited — 13 live sessions stranded at a bare shell. The flags are therefore resolved by the shell at launch (core._conversation_flags_shell): no transcript → --session-id <new>, transcript present → --resume <new>, explicit resume → the fork pair, and a vanished <old> degrades to fresh-with-role rather than to a bare shell. The cwd encoding is mirrored in shell as history.HISTORY_DIR_SHELL (pwd -P, because Claude keys by the PHYSICAL cwd) — change it and encode_project_path together. Test it with a SECOND launch; a single-launch test cannot see this. repo/branch/worktree_path come from core.git_identity(), which asks git; absent keys mean unknown, never a default. The write itself is loud (#885): store_session_metadata raises (atomically — a failed write can't truncate a good record) and record_session_launch warns on stderr instead of propagating, since the session is already live by then. Remote launches record role and honor an explicit --created-by, but never GUESS a parent — same-project inheritance reads the caller's live tmux cwd, which can't speak for a path on another machine (#886). The role prompt now lives at ~/.agentwire/role-prompts/<conversation-id>.txt (core.role_prompts_dir() — a FUNCTION, so it follows a patched CONFIG_DIR; as an import-time constant it silently ignored the test seam, which is a data-destruction trap once anything sweeps that dir), never /var/folders — macOS GC'd the old temp file and, since the launch line reads it BY PATH (--append-system-prompt "$(<file)"), a session older than the GC window relaunched with an empty system prompt: conversation intact, role silently gone. Remote launches mirror it to the same durable path via core.mirror_role_prompt_remote(). Detail: docs/wiki/sessions/conversation-identity.md.
agentwire restart -s <session> is what the record is FOR. /exit, REGENERATE the flags from the recorded roles/posture/model via build_agent_command, relaunch at the same cwd with --resume — same conversation, same tmux session, nothing on disk touched (recreate force-removes the worktree and recursively deletes the directory; history resume forks into a NEW session). Works on a session that isn't running (the post-reboot case). It regenerates rather than replays the stored AGENTWIRE_LAUNCH_CMD: the flags come from the record, so a role/posture/model change takes effect on relaunch, and restart stays independent of whatever an older session is carrying in its tmux env (#901 separately made that stored line safe to re-run, which is a different guarantee). A recorded id does not guarantee a resumable conversation: everything reduces to one predicate, resumable(id, cwd) == exists(<encoded_cwd>/<id>.jsonl) — history.locate_conversation, the ONE probe restart, doctor and history migrate share. The transcript is created LAZILY on the first turn, and ~/.claude/projects/ entries were observed vanishing (563→544 in ~25min, cause undetermined) — so restart walks the chain newest-first, and when nothing resolves it starts FRESH with the role intact and says so, distinguishing orphaned (exists under another cwd key — a moved directory; recoverable via history migrate) from gone. doctor surveys the whole chain rather than stopping at the first resumable id — restart's question is "newest resumable?" (first match wins), doctor's is "is ANY link orphaned?", and collapsing the two made restart silence the warning one turn later. It reports orphans (scored) and live sessions with no history at all (stated, never scored — it can't say why, and it would flag hundreds of dead records).
Teardown is two questions, not one (#941). (1) Is the work durable? — committed and pushed, so branch and PR exist independently of the session. This is what authorizes tearing down the session and worktree: --remove refuses a DIRTY worktree (uncommitted changes are the only thing removal can destroy — committed work survives on the branch ref; --discard-changes overrides), and refuses when a live session it didn't resolve is running inside the worktree (#954). (2) Has it been integrated? — merged, issue CLOSED (not just the PR shown green). This is what authorizes deleting the branch, and merge must be verified BEFORE branch deletion — never teardown-then-check. For a branch that by design never merges (a spike), question 1 alone legitimizes reaping the session; the branch and PR persist. agentwire worktree --remove refuses to delete a branch that isn't confirmed merged; --force-delete-branch overrides that for plain unmerged/local-only work, but it does not bypass an OPEN PR on the branch (#756) — deleting the remote head branch of an open PR silently closes it. That guard fires only on the force path (an unmerged branch with no PR, or a merged/closed one, is unaffected) and refuses by naming the PR number; the explicit escape hatch is --close-pr-branch, only for when you actually mean to close that PR. gh absent/no GitHub remote → best-effort, proceeds like today. Same guard on --prune --gc-merged (shared _teardown_entry → _delete_branch_if_safe). Recovering a branch whose PR got closed this way: git fetch origin refs/pull/<N>/head → git push origin <sha>:refs/heads/<branch> → gh pr reopen <N>.
| File | Purpose |
|---|---|
config.yaml |
Main config (see agentwire-config skill) |
.env |
All API keys/secrets — the one blessed spot, loaded on every entry point. chmod 600. See docs/wiki/security/secrets.md |
machines.json |
Remote machines registry |
scripts/ |
Machine-specific helper scripts (TTS, startup, service wrappers). Local only, not version controlled. ~/bin/ entries should symlink here. |
voices/ |
Custom TTS voice samples |
uploads/ |
Uploaded images for cross-machine sharing |
artifacts/ |
Agent-generated HTML for artifact windows |
wiki/ |
LLM-maintained knowledge base (Karpathy LLM Wiki pattern) |
logs/ |
Audit logs for damage-control |
Per-project config lives in .agentwire.yml at the project root — keep it gitignored (personal config; a tracked copy makes worktree-dispatched runs silently use the stale committed version). .agentwire.yml is purely declarative (type/roles/voice/parent/worktree) and agent-writable; named tasks (pre/post/on_task_end/shell) live in the separate, protected sibling .agentwire.tasks.yml, authored via agentwire tasks review/promote (#720). See agentwire-project-config skill for fields and task schema.
- agentwire sessions coordinate via voice, delegate to workers
- worker panes spawn within the orchestrator's session (visible dashboard)
- Pane 0 = orchestrator, panes 1+ = workers
- Damage-control hooks block dangerous ops (
rm -rf,git push --force, etc.) - Unattended guardrail — scheduler dispatches are marked
AGENTWIRE_UNATTENDED=1; the hook resolvesask-tier commands by failing closed (block + email owner) unless the matched rule carries anunattended_allowgrant. Two carve-outs on the ambiguity fallback (#934/#925): an operand-position substitution (echo "$(basename $p)"— 54% of all unattended blocks) is ALLOWED unattended (every rule already scanned the masked form; the dangerous-operand case is caught by its verb's own rule), while a VERB-CONCEALING shape (eval, base64-pipe, substitution deciding what runs or feeding a wrapper's payload —psql -c "$(cat x)",ssh host "$(…)") isunverifiable: never demoted by bypassPermissions/auto (it stays a routed confirm), never granted, always a block unattended — obscuring a payload must never be the permissive path. An entry is a bare rule id (unscoped) or{id, paths}(path-scoped, #914) — "commit under~/.claude/projects/*/memory/" rather than "commit". Precedence, not union: the most specific layer naming a rule (task > host config >DEFAULT_UNATTENDED_ALLOW) defines its grant outright, so a scoped entry genuinely narrows instead of being swallowed by an unscoped default — and naming a rule binds it even when the entry is malformed, or a typo in a scope path would inherit the unscoped default. A child session inherits BOTH the marker and the grant, transitively and across projects; the marker tightens, the grant loosens, and what makes that safe is the scope riding along with it (a fan-out task's children must commit, so a grant that stopped at the parent could not fix a delegating task at all). Scope evaluation reads the raw command — cwd,cd <literal> &&,-C/--git-dir/--work-tree,GIT_DIR-family env assignments, and the enclosing repo root (git walks UP, so a scope over<repo>/subdirwould otherwise grant over<repo>) — requires ALL of them in scope in both lexical andrealpathform, and REFUSES rather than guesses on anything else (sh -c,cdjoined by;, subshells, unmodelledVAR=assignments). Substitution is judged by position, not presence (#942/#943): one in a directory-deciding spot (acdtarget,-C, aGIT_DIR=value, the segment head, a git config key) refuses; one in a-mmessage does not. Thecore.worktreetwo-step (#927) is closed on the commit side — the resolved repo's config is read and the redirect target measured against the scope, with the-c core.worktree=/include.*spellings refused outright; the redirect command itself is still unruled (rule-file work). Not closed, and said so: the TOCTOU window.agentwire tasks review+doctorwarn at authoring time when a task is specified to run commands its posture will refuse, or names a rule id that doesn't exist. Seedocs/wiki/internals/damage-control.md. - A wrapped command is the command (#924/#921/#923).
ssh <host> "<payload>"re-scans the remote command as a command in its own right, so EVERY rule applies over ssh automatically — never write an ssh twin of a local rule (remote.yamlkeeps only the ssh-ONLY surface: reboot/shutdown/service-stop + a deliberately-stricter docker rm -f). DB clients (psql -c,mysql -e,mongosh --eval) emit their quoted statement as payload text the SQL rules read;git -ckeys that name a program git runs (core.sshCommand,core.fsmonitor,core.pager,alias.x=!…, …) get their value re-scanned the same way plus an ask-tier rule (git.config-exec-key). Channel parity:NotebookEditroutes through the edit hook, and everymcp__*tool call gets its path-valued arguments screened (zero-access secrets always; protected control plane for tools whose policy says they mutate). Per-tool policy (#923) is data:mcpToolPoliciesentries in the rule YAMLs classify tools by tier (allow/ask/block, first match wins, stable ids thatunattended_allow/disabled_rulesname — a tool call has no directory, so grants must be unscoped). The teardown/kill core (worktree_remove/prune,session_kill,pane_kill,session_recreate,machine_remove) isask-tier — bypass demotes it interactively, unattended fails closed unless granted; messaging tools (session_send/msg_send) stay ungated BY DECISION (their effect is a message to a session running its own damage control, and the Bash twin is unruled). Unclassified tools default to allow-with-screening; the #1036 write-shaped-name heuristic survives only as themcp.writeish-namedata entry plus an in-hook bridge for rules dirs predatingmcp-tools.yaml. - Merged is not deployed (#936 + #916).
heal_damage_controlis the one function that puts rules/tooldefs/hook scripts on a machine, and it failed in OPPOSITE directions: hook scripts werecopy2'd on any byte difference with no ordering (so a worktree on a stale branch reinstalled a pre-#918 hook machine-wide within an hour of the P0 being deployed — andcopy2preserves mtime, so the downgrade looked older than the deployment it replaced), while rules/tooldefs wereif not target.exists(), so no supported command ever updated one — 9 of 15 live rule files were stale, costing 40 patterns and 14 anchors. Three separate guards now: provenance (only the canonically installed tool may write machine-global files;agentwire/safety/provenance.py, statescanonical/bootstrap/worktree/foreign, override--allow-foreign-source, and no env override — a leadingVAR=valueis masked bymasked_subcommands, so a command-position rule could not see the bypass being set) —rebuildcarries the same guard, because it is the only installer-adjacent command that CHANGES the answer the others read: it reinstalls the tool FROM a checkout, so a worktree it installs from BECOMES canonical, and guarding only the heal blocks the one-step while permitting the two-step that the Dev Workflow documents. Bound to--allow-foreign-source, never torebuild --force(which means "behind origin/main" — folding them would make a staleness override silently grant a machine-global one). The guard also lives at the symlink itself (#1118): every machine-global link goes throughhooks_cli._link_into_claude, which refuses a target outside the canonical install (or inside a worktree when none exists) BEFORE removing the existing link, anddoctorflags any hook symlink that is dangling/outside/in a worktree or temp tree, naming it; the~/.claudedirs resolve at call time and the test guard (tests/home_guard.py) DETECTS (audit hook, after the fact — it does not block) any test writing under the real~/.claudeor~/.agentwire, failing that test. Keyed on the PACKAGE ROOT, never the cwd (~/.local/bin/agentwireimports site-packages wherever you invoke it, sohooks installinside a worktree was always safe whileuv run agentwirefrom a stale checkout never was) and neverPATH(uv runshadows it); ordering (the five generated hooks carryAGENTWIRE_HOOK_STAMP, so drift readsolder/newer/staleand anewerinstalled copy is never overwritten without--force— prospective, since a pre-stamp install can only reportstale); and three-way sync (agentwire/safety/rule_baselines.jsonships the sha256 of every version of each rule/tooldef ever shipped, sooutdated= a pristine older release and is brought forward, whileunknown= matches nothing we shipped and is reported + left alone,--forcereplacing it with a.bak). Content-addressing, not an install-time manifest — every existing machine predates the mechanism. Repairing tooldef drift is a PERMISSIONS CHANGE, not a chore: the pinnedid:fields are whatDEFAULT_UNATTENDED_ALLOWnames, so restoring them makesgit add/commit/push+gh pr createlive for unattended runs in ANY repo — the heal says so out loud. Duplicate rule ids are recorded (_duplicate_rule_ids) and reported: they makedisabled_rules/unattended_allowambiguous and are the ONLY detector for a half-applied rule set. Detection only — a wrongful refusal in the unattended tier is a silent loop. Doctor grades directionally now: removed protections[!!]and NAMED, local additions[..]. - Smart TTS routing — audio goes to browser if connected, local speakers if not
Workers auto-kill after sending idle notification. The idle hook captures output, sends alert to pane 0, then kills the worker. Manual kill if needed: agentwire kill --pane 1.
One command installs/refreshes everything agentwire-owned:
agentwire hooks install # permission hook, idle handler, queue processor, slash commands
agentwire doctor # verify (flags stale copies, not just missing ones)Installs (symlinks by default, --copy to copy):
| File | Target | Purpose |
|---|---|---|
agentwire-permission.sh |
~/.claude/hooks/ |
Permission dialogs in portal (registered as PermissionRequest hook) |
idle-handler.sh |
~/.claude/hooks/ |
Worker notifications + scheduled task completion (registered as Notification hook) |
queue-processor.sh |
~/.agentwire/ |
Sends queued alerts with 15-second gaps to avoid overwhelming orchestrators |
These files are agentwire-owned: hooks install replaces any copy that drifts from the packaged source. Re-run it after agentwire rebuild to pick up hook changes.
agentwire doctor # all components
tail -f /tmp/claude-hook-debug.log # hook debug
tail -f /tmp/queue-processor-debug.log # queue processorLLM-maintained knowledge base at ~/.agentwire/wiki/ using the Karpathy LLM Wiki pattern. Research and debugging knowledge compounds across sessions. Authoring is in-context — the session that learns something writes the page itself (there's no batch ingester); the mechanical ops are deterministic, via the agentwire wiki CLI and the wiki_query / wiki_lint / wiki_status MCP tools (status / query / lint / new / done — stdlib-only, no rebuild needed; see the wiki skill). Before researching: agents call wiki_query first. After discovering: agents write/update a page (agentwire wiki new scaffolds it). raw/ is an optional verbatim-source inbox; archive a consumed source with agentwire wiki done.
/handoff distills the current conversation into a shareable bundle: ai-handoff.md for another LLM and show-the-story.html for humans. The agent does the distillation in-context (free); the CLI/MCP renders deterministically. Outputs in ~/.agentwire/artifacts/handoff-<slug>/. Full reference: docs/wiki/communication/handoff.md.
Deterministic (zero-LLM) recovery from the Claude Code usage-limit dialog: a launchd watchdog (agentwire limits tick, 60s) plus ensure's completion poll detect the dialog, park the session (option 1: stop and wait), parse the reset time, email the owner via Resend, and nudge the session to continue after reset. Park state under ~/.agentwire/usage-limit/ guards every surface — ensure exits 7 (usage_limit), the scheduler skips dispatch, the idle hook never reaps a parked session. CLI under agentwire limits .... Full reference: docs/wiki/usage-limit-recovery.md.
Every bundled rules/*.yaml / tooldefs/*.yaml is owned by one entry in agentwire/safety/groups.py: a default group (on, incl. git + registry; a human may opt out — agentwire safety groups disable <g>; the kernel control-plane + core never can) or an opt-in vendor pack (aws, gcp, firebase, cloud-hosting, databases, containers, infrastructure, publish — off until agentwire safety packs enable <name>). State is packs: / disabled_groups: in the protected ~/.agentwire/damagecontrol.yml; mutations are human-only (tty gate + PROTECTED_COMMAND_PATTERNS), reads (list|status, MCP safety_packs_list/safety_groups_list) are open. The hooks never read the state — they load what is installed, so "off" means "not installed" (heal removes only a pristine file of a disabled entry, never an edited one). A pre-packs install is migrated by recording every pack with any installed file as enabled, so upgrading changes nothing. Agents SUGGEST enabling a pack when the user's work touches that vendor; they never run it. Detail: docs/wiki/internals/damage-control.md.
An expired Claude login refuses every turn with a synthetic assistant message (Login expired · Please run /login, error: authentication_failed, zero tokens, 16ms) and then goes idle. The pane is alive and the agent process is running, so every liveness check passes forever — which is how #867 cost memory-manager 7217s and ai-morning-briefing 14400s on the same 2026-08-04 outage, reported as incomplete — Timeout waiting for task completion. The detector reads the TRANSCRIPT, never the pane (agentwire/auth_expired.py): Claude renders this inline, not as a select-menu, so the phrase also appears in any pane merely quoting the incident, and a false positive here halts the whole board. Keyed on the structured error field, and on the LAST assistant turn only — so recovery needs no reset mechanism. Field-proven across two outages that render DIFFERENTLY (Login expired 08-04 / Not logged in 07-07, same error), which is the argument for the structured field; the residual risk is a future RESTRUCTURING of that field, not a rewording. All 16 real rate_limit rows are structurally identical to an auth refusal and reach the same predicate — only the error-value check keeps a transient blip from gating the machine. Transcripts are located by conversation_ids (#871) or, when the record predates that, by files in the project's history dir written since the attempt began — never "the newest .jsonl". That anchor is load-bearing: the refusal lands ~15ms after the prompt submits, seconds before the completion wait is entered, so anchoring at the wait misses it entirely. Detection is machine-wide (~/.agentwire/auth-expired/state.json) — one outage, one owner email (throttled 1h, shared Resend wiring), detected_at carried forward, an OUTAGE_TTL so a stale flag can never wedge the scheduler, and a clear_state() on the completion success path so a successful turn genuinely reopens the gate rather than the docs merely claiming it does. Surfaces: ensure exits 8 (auth_expired) and never retries, scheduler dispatch skips without consuming last_run, worktree finalize opens no PR, agentwire doctor reports it. No credential pre-flight: the secret is Keychain-gated on macOS, and expiresAt is the access token's, which lapses routinely and refreshes silently. Full reference: docs/wiki/auth-expired.md.
A turn can end with a background shell/monitor still running; the harness re-wakes the session when it reports. idle-handler.sh asks agentwire idle-gate (agentwire/background_tasks.py) first and defers that idle — for the parent notice, the worker-pane reap, and the scheduled-task summary/exit pass alike — so the idle that follows the last report is the real one (#1103). Pane turn-end line (live position only) is authoritative, transcript (backgroundTaskId / <task-notification><status>) the fallback. Unknown means HOLD, never reap: an unparsable "still running" or truncated footer reads as pending, so the worker-pane reap and scheduled-task /exit fail to keep; the 30-min bound releases only a qualified parent notice (hook, or the idle_deferred watchdog stage), never a kill; a gate crash on a reap path holds too (only the parent notice fails open to send). Behaves as before only when nothing is readable at all (a bare shell). Detail: docs/wiki/internals/troubleshooting.md.
Interactive prompts (permission, plan-approval, AskUserQuestion, resume-from-summary) hitting a child session are routed as text to its parent/orchestrator — hook path for permissions (seconds), pane-sweep riding the limits watchdog for the rest (≤60s). Parent resolution: worker pane → pane 0; else creator recorded at agentwire new; else .agentwire.yml parent:. Answer ONLY with agentwire prompts answer -s <session> --expect <hash> <key> (compare-and-send; raw send-keys races the portal). Deliveries are safety-gated (never paste into a live menu, a bare shell, or a parked session). CLI under agentwire prompts ....
A blocked pane looks perfectly healthy (#905). claude --resume on a large conversation opens a "resume from summary?" menu before doing anything, and a session sitting there is invisible to every liveness surface we have: the agent process is running, so pane_current_command is the agent and worktree --list / the idle handler / any fleet roll-up all report fine — while safe_deliver correctly refuses to paste into a live menu, so every inbound message queues behind it and the session goes silent in both directions. Four sessions sat like that for hours after the #901 recovery. Only reading pane CONTENT sees this; process liveness never will. Now kind=resume routes like any other prompt, agentwire doctor reports blocked panes (prompt_router.blocked_panes() — read-only), and a root session's prompt, which has no parent by design and used to sit at status=no_parent forever, emails the owner (dead-letter precedent; throttled to once an hour per prompt, since a no-parent prompt re-routes every tick). Detector anchors on the dialog's string literal from the shipped binary; the age/token numbers stay OUT of the content hash or a redraw re-notifies every 60s; and the anchor is a \s+ regex located on the RAW capture, because that sentence is 122 columns and wraps on the 64/80-column panes this fleet runs (testing it wrap-insensitively on normalized text and then slicing wrap-sensitively crashed at 54 of 101 widths). The sweep is now guarded per pane — an unguarded raise abandoned every remaining pane AND the marker GC, which limits_cli's stage isolation then swallowed, so one bad pane silently killed ALL routing fleet-wide. The guard is containment, not silence: a bare except: continue would make a crashed detector indistinguishable from a healthy pane (#885's shape again), so failures are logged (detect_failed), returned under sweep()'s failed key, and reported by doctor as detector_error. Full reference: docs/wiki/sessions/prompt-routing.md.
If you spawn child sessions, call agentwire wait --children (MCP wait_children) — never just "wait". Idle ≠ done for a parent with outstanding children: the idle handler reads idle as done, prompts for a roll-up you can't write yet, and /exits you while the children are still working (#852). Blocking inside the tool call isn't idle, so the hook never fires. Bounded (--timeout) and re-callable; exit 1 means children are still pending, so loop.
Every agentwire new / worktree auto-enrolls in the caller's cohort ledger (~/.agentwire/cohorts/<parent>.json) — no bookkeeping to forget. Cohort is not rooting: created_by drops the parent link for a cross-project spawn (#715), but lifecycle membership survives it, or a fan-out would be silently half-protected. Opt out with --no-cohort; an explicit --kind orchestrator never enrolls (durable by definition). wait collects each report from the inbox and then kills the child — the reverse order dead-letters the report and emails the owner via gc_sender. Two backstops: the idle-handler guard (fails open on a missing/corrupt/expired ledger) and the watchdog sweeper (reaps a cohort whose parent died, marks children that exited). Teardown is session-only and skips worktree children entirely — they hold a branch/open PR (teardown follows merge verification, #756) and are where review fix-ups get sent; wait reports them under left_alive, and worktree --dangling still flags an abandoned one. Full reference: docs/wiki/sessions/fan-out-cohorts.md.
agentwire msg is the non-interrupting sibling of agentwire send. send/session_send paste into the prompt and press Enter immediately — and clobber a human's half-typed draft if the box is occupied. msg drops a typed JSON message into a per-recipient file inbox (~/.agentwire/inbox/<session>/) and the watchdog injects it only when the input box is empty (prompt_router.prompt_is_empty) and safe_deliver guards pass (not parked, agent pane, no live dialog). Drain rides the limits watchdog (≤60s), coalescing queued messages into one paste; deferred messages bump attempts and dead-letter after 40 — except busy reasons (target_busy, or the queued_placeholder "Press up to edit queued messages" state) defer without penalty so a legitimately-busy session never burns a report-back to death, while a recipient that positively doesn't exist dead-letters fast (target_gone, ~5 min — #694; msg send warns at enqueue time when the named target is gone). Delivery is idempotent (#621): a send_verified box-cleared confirm can false-negative even though the paste landed, so the drain dedups against the recipient's scrollback by full rendered line per-message (session_ready.message_on_scrollback) and send_verified's Phase-2 confirm keys on "the box no longer holds our text" (not a spinner/echo) — a landed paste can't redeliver forever, and the same fix covers notify-parent/session_send. Escape hatches: agentwire msg purge <session> (MCP msg_purge) drops a wedged pending queue, msg flush --force force-drains past the empty-box gate, and agentwire kill GCs an exited sender's still-pending outbound (load-bearing kinds dead-letter+escalate). Typed kinds (note/done/request/escalation), @all broadcast (live agent sessions minus sender). Dead-lettered messages carry their drop reason + timestamp; load-bearing kinds (done/request/escalation) also email the owner on dead-letter (shared Resend wiring, best-effort) so the loss is never silent even unwatched. agentwire msg dead [-s <session>] (MCP msg_dead) lists them (no -s = GLOBAL, even from inside a session — #693), and agentwire msg dead --purge [-s <session>] [--older-than 7d] clears the graveyard (doctor surfaces it). CLI agentwire msg send|inbox|dead|flush|purge, MCP msg_send/msg_inbox/msg_dead/msg_flush/msg_purge. Use msg for routine peer updates; reserve send/session_send for forcibly driving a session now. Full reference: docs/wiki/sessions/messaging.md.
Multi-soul orchestrator sitting, namespaced by <name> so independent councils run concurrently: agentwire-council-<name> fans prompts out to lens sessions (council-<name>-brain, council-<name>-conscience, …), each replying take/ack/pass through a file inbox under ~/.agentwire/council/<name>/prompts/; the orchestrator collects and synthesizes with attribution. Targeting: --name → cwd-repo-slug if it matches a live sitting → sole live sitting → else error+list; every command echoes which sitting it hit. The standard soul role self-excludes from any council-* session. council minutes renders a sitting's persisted record (question + attributed verbatim takes + optional --synthesis) into a self-contained HTML artifact at ~/.agentwire/artifacts/council-<name>-minutes/; council stop renders it by default when any prompt exists (--no-minutes to skip). CLI under agentwire council ... (incl. council list), 7 MCP council_* tools. Full reference: docs/wiki/council.md.
Asymmetric-verbosity orchestration: a terse, human-facing anchor (persona role; replaces orchestrator) fans out exhaustively-verbose correspondent worktrees (worktree_create(roles="correspondent", prompt=…); stacks on the worker-worktree rail). Correspondents file deep reports into the dropbox (agentwire research ensure / research_dir() → ~/.agentwire/research/<anchor>/) and signal passively — msg send --kind ingest --ref <path> is never auto-delivered, so it never drives the anchor. The anchor stays quiet until the human cues it, then msg pulls the pointers, reads the files, and briefs across two channels in one call: say(text="<spoken headline>", display="<richer toast card>") — different content per channel; the toast (notify_user) renders a safe markdown subset. Teardown via worktree_remove / worktree_prune. The notify-* family is split by target: notify_user (human toast), notify_parent (your orchestrator), notify_event (portal lifecycle). Full reference: docs/wiki/briefing-mode.md.
Splitting one huge file across parallel worktree sessions (a worktree per disjoint slice, each PR'ing into a shared feature branch the orchestrator controls) has one load-bearing gotcha: the branches conflict on positional interleaving, not logic. Even when slices are logically disjoint, their functions interleave by line position in the shared file, so once one branch merges, the rest get large adjacency conflicts — and regenerating them all onto the same base does NOT fix it. The resolution is regenerate-against-fresh-base, merged sequentially: merge one, then each next branch takes the file fresh from the new tip, re-applies only its own removal (now contiguous), git reset --soft <base> to one clean commit, push. Plus: foundation-first (extract shared helpers to core.py + a registrar dispatch seam before fanning out), verify with the FULL suite (unit + integration — integration tests patch moved symbols), and grep the real coupling graph rather than trusting a recon model. Reach for this when the work is a big file-split across parallel agents; skip it for inline refactors or work with no shared file. Full reference: docs/wiki/internals/parallel-refactor.md.
Reference detail lives in skills under .claude/skills/ — invoke as needed:
| Skill | When to use |
|---|---|
agentwire-cli |
Running or composing any agentwire ... shell command |
agentwire-mcp-tools |
Picking the right MCP tool from inside an agent session |
agentwire-config |
Editing ~/.agentwire/config.yaml (TTS, channels, services, etc.) |
agentwire-project-config |
Editing .agentwire.yml (session config) / .agentwire.tasks.yml (tasks), roles, idle notifications |
agentwire-scheduler |
Scheduled task gates/schedule/priority |
agentwire-desktop-ui |
Editing portal static files (sidebar, windows, artifacts) |
- CLI:
agentwire --helporagentwire <cmd> --help docs/wiki/INDEX.md— feature reference manual (sessions, communication, scheduling, integrations, deployment, TTS, internals)
How work is tracked is a contributor preference, not something agentwire ships — the product imposes no PM mandate. For this repo, the convention is GitHub issues as the source of truth, with Closes #N in the PR body to link and auto-close. If you keep cross-repo tracking preferences in ~/.claude/rules/project-tracking.md, those govern your own workflow and override this. Post-ship reference content (concepts, architecture, troubleshooting) belongs in docs/wiki/.