@mu-crew/mu 1.6.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/VISION.md CHANGED
@@ -137,19 +137,11 @@ lives behind `MuxBackend` in `src/mux/`. Two implementations, not an
137
137
  anticipatory abstraction: a third earns its way in on its own
138
138
  friction evidence.
139
139
 
140
- ### 6. Pi-first, and what the mux knows
140
+ ### 6. Pi-first, with reported agent state
141
141
 
142
- Mu's own status detection (`busy` / `needs_input` / `needs_permission`)
143
- is pi-only, plus a Braille-spinner fallback that catches most TUI
144
- wrappers. `--cli <name>` accepts other strings, but on the tmux
145
- backend no other CLI ships with a detector, so an unrecognized pane
146
- tends to read `needs_input`.
147
-
148
- A mux that classifies panes itself changes this. The herdr backend
149
- reports agent state natively across the agent kinds it knows, so on
150
- that backend mu takes the mux's word and skips scrollback scraping
151
- entirely. Detection is a property of the **mux backend**, not a fixed
152
- property of mu.
142
+ mu does not infer agent state from pane text. The herdr backend reports
143
+ state through the mux. On tmux, murmur's pi extension reports state from
144
+ inside the agent process; without murmur, state is `unknown` with a reason.
153
145
 
154
146
  `--cli` and `MU_<UPPER_CLI>_COMMAND` stay useful for swapping the pi
155
147
  binary: set `MU_PI_COMMAND=<name>` once and every spawn picks it up.
@@ -244,9 +236,9 @@ mu does not own:
244
236
  - **Tool routing decisions.** Pi (and any other CLI you spawn) owns
245
237
  tool allowlists, MCP servers, extensions. Mu doesn't proxy or
246
238
  inspect them.
247
- - **Output interpretation.** Mu reads pane contents to detect
248
- `busy / needs_input / idle / done` (a 4-state classification).
249
- It does not parse model output for facts, claims, or tool calls.
239
+ - **Output interpretation.** Mu does not parse pane contents for agent
240
+ state, facts, claims, or tool calls. murmur or herdr reports runtime
241
+ agent state.
250
242
  The `--evidence` payload is whatever the agent says it is; mu
251
243
  records it without interpretation.
252
244
 
@@ -344,12 +336,10 @@ orchestrator stays in charge.
344
336
  `~/.local/state/mu/mu.db`. Cross-machine state moves as
345
337
  append-only JSONL segments in a shared folder; you own transport.
346
338
 
347
- 3. **Pi-first.** mu's own status detection targets pi. `--cli pi` is
348
- the meaningful default; `--cli` accepts other strings as a key for
349
- the `MU_<UPPER_CLI>_COMMAND` env var resolver. On tmux no other CLI
350
- has a detector; on a backend that classifies panes itself (herdr),
351
- mu defers to it. Either way we optimize for
352
- false-negative-then-poll over false-positive-then-act.
339
+ 3. **Pi-first.** `--cli pi` is the meaningful default; `--cli` accepts
340
+ other strings as a key for the `MU_<UPPER_CLI>_COMMAND` env var
341
+ resolver. Agent state comes from murmur on tmux and herdr on herdr;
342
+ absence is `unknown` rather than an inferred value.
353
343
 
354
344
  4. **Send is delivered, not acknowledged.** `mu agent send` gets the
355
345
  text into the pane and warns loudly when it cannot confirm
@@ -28,13 +28,16 @@ defined here, fix the doc. If you need a new term, add it here first.
28
28
  | **remote worker** | A worker whose process and workspace are on another machine while its pane and orchestrator DB remain local. The durable inventory record is a task-note line in the exact `REMOTE: <host>:<path>` shape; pair it with `REMOTE_BASE: <agent>:<sha>` so `mu task claim --for <agent>` can print the one-shot poll-and-close step. `mu state` lists `REMOTE:` lines and `mu state --json` exposes them as `remoteWorkers`. | "remote agent record", "host registry" |
29
29
  | **actor** | The party that *caused* a state change. May or may not be a registered worker. Recorded in `ops.actor` for every op. The orchestrator running mu from a top-level shell is an actor but not a worker. | "caller", "author" (only on notes) |
30
30
  | **crew** | *Informal* collective noun for the agents in a workstream | (no API surface; prose only) |
31
- | **task** | A node in the DAG. Has mandatory `impact` and `effort_days`. Status one of `OPEN`, `IN_PROGRESS`, `CLOSED` (see **task status** below). | "issue", "ticket", "item" |
32
- | **task status** | One of 3 states. **OPEN** = ready to be claimed; **IN_PROGRESS** = claimed and active; **CLOSED** = finished and the only state that satisfies a `--blocked-by` edge. Record postponed or won't-do rationale in a **note**, then close the task. | "state" |
31
+ | **task** | A node in the DAG. Has mandatory `impact` and `effort_days`. Has a **task status** and a **substate**, shown as a pair (`OPEN/parked`, `CLOSED/wontfix`). | "issue", "ticket", "item" |
32
+ | **task status** | The lifecycle phase, one of 3: **OPEN** = not started; **IN_PROGRESS** = claimed and active; **CLOSED** = ended. Status alone decides edge satisfaction: any `CLOSED/*` satisfies a `--blocked-by` edge. | "state" |
33
+ | **substate** | What the **task status** means for this task. Never null; each status has a default. `OPEN`: `todo` (default), `parked`. `IN_PROGRESS`: `active`. `CLOSED`: `done` (default), `wontfix`, `duplicate`, `superseded`. Rendered as a pair `STATUS/substate`; a default substate renders as the bare status. Valid pairs live in the `task_substates` table. Never affects edges. Refer to a non-`done` close as a *closed substate*. | "resolution", "reason", "sub-status" |
34
+ | **park** | Verb: set an `OPEN` task to `OPEN/parked` with a required `--why` note. A parked task leaves **ready** and `next`, `claim` refuses it without `--force`, and it stays in **goals**. Its dependents stay blocked. Refuses `IN_PROGRESS` (release first) and `CLOSED` (open first). | "defer", "snooze", "shelve" |
35
+ | **unpark** | Verb: return an `OPEN/parked` task to `OPEN/todo`. | "resume", "reactivate" |
33
36
  | **task DAG** / **graph** | The directed acyclic graph of tasks. | "task list", "todo", "tree" (it's a DAG, not a tree) |
34
37
  | **edge** | A `blocks` relationship between two tasks. The single edge type. `A blocks B` = A must close before B can start. | "dependency" (use only in prose) |
35
38
  | **track** | An independent subtree of the DAG identified by parallel-track detection | "branch", "lane" |
36
39
  | **diamond merge** | When two tracks share a prerequisite, parallel-track detection collapses them into one track to prevent two agents from colliding on the shared dependency. | "join", "converge" |
37
- | **ready** | An OPEN task with no unresolved blockers. Exposed as the `ready` SQL view. | "unblocked", "available" |
40
+ | **ready** | An `OPEN/todo` task with no unresolved blockers (parked tasks are excluded). Exposed as the `ready` SQL view. | "unblocked", "available" |
38
41
  | **goals** | Tasks with no outgoing blocks-edges (graph endpoints). Exposed as the `goals` SQL view. | "leaves", "targets" |
39
42
  | **sort key** | Argument to `mu task list / next / ready --sort <key>`. One of `roi` (impact / effort_days, default for `next` / `ready`), `recency` (`updated_at` DESC — "what did I touch most recently"), `age` (`created_at` ASC — "what's gone stale"), `id` (`local_id` ASC, default for `task list`). The two time-based keys also render an `updated`/`created` relative-time column in the table view. | "order by", "sort by" |
40
43
  | **subtree** / **scope** | The set of tasks reachable from a root via blocks-edges | "subgraph" (only for technical descriptions) |
@@ -46,16 +49,14 @@ defined here, fix the doc. If you need a new term, add it here first.
46
49
  | **owner** | The **worker** name in `tasks.owner`. Set by claim. NULL when the task is unowned OR was claimed via `--self` (anonymous, attributed via `ops.actor` instead). Owners are **machine-local** — they reference `agents`, so ownership never syncs. | "claimer", "assignee" |
47
50
  | **anonymous claim** | A claim made via `--self` where the **actor** isn't a registered **worker**. `tasks.owner` stays NULL; the actor is recorded in `ops.actor` for the auto-emitted `task.claim` op. The orchestrator-doing-direct-work pattern. | "self-claim" (in code; "anonymous claim" in prose), "unowned claim" |
48
51
  | **release** | Verb: clear `tasks.owner` | "unclaim", "unassign" |
49
- | **free** | Verb: mark an agent's `status = 'free'` (idle, available) | "park", "idle" (verb) |
50
- | **status** | Persisted enum on `agents` (busy/needs_input/free/...) | "state" (use only "lifecycle state") |
51
- | **lifecycle state** | A position in the agent state machine | "state" alone, "phase" |
52
+ | **agent state** | Runtime state reported by the **state source**: `busy`, `needs_input`, `needs_permission`, or `unknown`. It is not persisted in mu. | "agent status", "lifecycle state" |
52
53
  | **role** | `full-access` or `read-only` capability flag | "permission" (avoid), "tier" |
53
54
  | **persistent** | Agent that stays alive across tasks | "long-lived" (only in prose) |
54
55
  | **one-shot** | Agent that exists for a single task and then terminates | "ephemeral", "transient" |
55
56
  | **workspace** | A VCS-isolated checkout (jj workspace / sl worktree / git worktree / cp). **In mu, "workspace" is always the VCS sense** — never the mux sense. herdr calls its session-level container a "workspace" too; in mu docs and code that is a **mux session**. | "branch" (it has one but isn't one), "checkout" (only for `none` backend), the herdr sense |
56
57
  | **ghost** | An `agents` row whose `pane_id` no longer exists in the **mux**. Pruned by **reconcile**, which runs in `mu agent list` and `mu doctor`. | "dead agent" (a dead agent may still have a pane), "stale row" |
57
58
  | **reaper** | The part of reconcile that releases a **ghost**'s tasks: `IN_PROGRESS → OPEN`, owner cleared. Makes `mu task wait` exit 6. | "garbage collector", "janitor" |
58
- | **needs attention** | The condition `mu task wait --stuck-after` detects: an IN_PROGRESS task whose **owner** has sat in `needs_input` for >= N seconds. Deliberately cause-NEUTRAL — the predicate cannot distinguish a worker that finished without closing, one waiting on an answer, or one at an approval prompt, and those need opposite responses. So every surface names the observation and points at `mu agent read <owner>`. A worker asking a question is desirable behaviour, so never phrase this as worker negligence. Formerly `agent_close_discipline_gap`, which presumed the first cause; now `agent_attention_required`. | "stuck" / "stalled" (the `stuck` JSON field and exit-7 `STALL_DETECTED` keep their names for compatibility), "close discipline", "negligent worker" |
59
+ | **needs attention** | The condition `mu task wait --stuck-after` detects: an IN_PROGRESS task whose **owner** has been in `needs_input` for at least N seconds according to murmur's `since`. `unknown` never counts. The predicate cannot distinguish a worker that finished without closing, one waiting on an answer, or one at an approval prompt, so every surface points at `mu agent read <owner>`. | "stuck" / "stalled" (the `stuck` JSON field and exit-7 `STALL_DETECTED` keep their names for compatibility), "close discipline", "negligent worker" |
59
60
  | **workspace orphan** | A directory under `<state-dir>/workspaces/<workstream>/` with no row in `vcs_workspaces`. Blocks subsequent `--workspace` spawns. Surfaced by `mu workspace orphans -w X`, `mu state -w X`, and `mu doctor`'s `ws-dirs` row. | "stray dir", "leftover workspace" |
60
61
  | **stranded** | A **workspace orphan** whose parent *workstream* row is also gone. Reported separately because no workstream-scoped verb can reach it — `mu workspace orphans --all` and `mu doctor` are the only surfaces. | "double orphan", "abandoned" |
61
62
  | **missing workspace dir** | The INVERSE of a **workspace orphan**: a `vcs_workspaces` row whose `path` is not on disk (usually a hand-run `rm -rf` where `mu workspace free` was wanted). More dangerous than an orphan, because every read surface reports the row as a healthy workspace and the next send fails inside the VCS backend rather than at the row that lied. `mu doctor`'s `ws-rows` row. | "broken workspace", "dangling row" |
@@ -64,7 +65,7 @@ defined here, fix the doc. If you need a new term, add it here first.
64
65
  | **stale workspace** | A workspace whose `parent_ref` is N commits behind the project's default branch HEAD (per the workspace's local refs cache). Rendered as a color-coded `behind` column (green ≤2, yellow 3–9, red ≥10) in `mu workspace list` and `mu state`; ≥10 triggers a one-line warn in `mu state`, `mu task claim --for`, and `mu agent send` (or refusal on the two dispatch verbs with `--strict-staleness`). Pure observation — mu never auto-fetches. | "out of date", "drifting" |
65
66
  | **refresh** | `mu workspace refresh <agent>` — rebase the agent's workspace onto a fresh base (default = backend's tracked main; `--from <ref>` overrides) WITHOUT touching the agent or pane. Refuses on dirty WC; surfaces conflicts as exit 5 with a resolve-in-place hint. The `none` backend errors (no VCS to rebase). | "recycle", "reset" (overloaded) |
66
67
  | **backend** | Implementation of `MuxBackend` or `VcsBackend`. Always qualify which (**mux backend** / **VCS backend**) when both are in scope. | "driver", "provider" |
67
- | **detector** | Per-CLI pattern matcher for busy/permission/ready, used when the **mux backend** cannot report status itself. On tmux that is always (`detectPiStatus` in `src/detect.ts`, covering vanilla pi + any TUI wrapper using Braille spinner glyphs); on herdr the mux classifies panes natively across its known agent kinds, so the detector is bypassed. The seam is the optional `MuxBackend.paneStatus?()`: absent means "ask the detector". herdr's states map `working` → `busy`, `blocked` → `needs_permission`, and `idle` / `done` / `unknown` → `needs_input` — `unknown` never becomes `free`, since herdr documents that it does not prove completion, and no detector of either kind may mint `free`. Other CLIs spawned via `--cli <other>` may misclassify on tmux; trust scrollback over the emoji. | "matcher", "parser" |
68
+ | **state source** | The system that reports **agent state**: herdr's `paneStatus()` on herdr, or murmur on tmux. mu reads local murmur pane options in one tmux call and uses `murmur status --json` only for remote workers. No source yields `unknown` with a reason. | "scraper" |
68
69
  | **op** | The atomic unit of change: one row in the `ops` table, written by a trigger inside the same transaction as the mutation it records. Carries `(hlc, machine_id, group_id, actor, intent, entity, key, op, payload)`. A **semantic partial update** — the payload holds only the columns that actually changed, which is what makes per-field merge free. | "event", "delta", "change" (all overloaded) |
69
70
  | **ops log** | The `ops` table: the single append-only record of every change, and the substrate **sync**, **undo**, and history are all queries or replays over. Canonical and ACID because it lives in `mu.db` alongside the tables it records. | "event log", "journal", "WAL" (reserved by SQLite) |
70
71
  | **intent** | The semantic label on an **op** (`task.close`, `task.reparent`, `agent.spawn`). Set once per public SDK function via **op context**, not per mutation. `mu log` renders prose from it through one formatter. | "verb" (overloaded by CLI verbs), "action" |
@@ -165,36 +166,25 @@ window.
165
166
 
166
167
  ---
167
168
 
168
- ## Status, lifecycle, and the verbs that touch them
169
-
170
- ### Agent status enum (persisted in `agents.status`)
171
-
172
- Glyphs are named, not spelled out: the codepoints live in
173
- `src/glyphs.ts` (`AGENT_STATUS_GLYPH`) and a table that duplicates
174
- them has drifted from production once already.
175
-
176
- | Value | Glyph slot | Meaning |
177
- | ----------------- | --------------------- | ---------------------------------------------- |
178
- | `spawning` | nf-fa-hourglass_start | Pane created, agent process booting |
179
- | `busy` | nf-fa-play | Actively working (detector saw busy marker) |
180
- | `needs_input` | nf-fa-moon_o | Idle prompt visible, waiting for input |
181
- | `needs_permission`| nf-fa-lock | Permission prompt visible (e.g., "Allow once") |
182
- | `free` | nf-fa-check_circle | Available; retained for persisted/runtime status compatibility |
183
- | `managed` | — | Under external orchestration; mu observes only |
184
- | `unreachable` | nf-fa-question_circle | Transport down, status uncertain |
185
- | `terminated` | nf-fa-times_circle | Process gone, awaiting reaping |
186
-
187
- All of them are classic Nerd Font `nf-fa-*` (Font Awesome 4)
188
- codepoints, single-codepoint and one cell wide, so `cli-table3`
189
- columns line up. `busy` is deliberately the same glyph murmur's dash
190
- paints for a `running` pane — they drive the same panes, so one symbol
191
- means one thing on both surfaces. Non-agent state glyphs (blocked,
192
- dirty, stale, ok/warn/fail, filter toggles) come from the `GLYPH`
193
- record in the same file.
194
-
195
- **Source of truth:** the substrate — the **mux backend**, plus the
196
- **detector** when that backend cannot classify panes itself. The DB is
197
- a cache; `mu agent list` reconciles on every call.
169
+ ## State, lifecycle, and the verbs that touch them
170
+
171
+ ### Agent state
172
+
173
+ Agent state is runtime data and is not persisted. The codepoints live in
174
+ `src/glyphs.ts` (`AGENT_STATE_GLYPH`).
175
+
176
+ | Value | Glyph slot | Meaning |
177
+ | --- | --- | --- |
178
+ | `busy` | nf-fa-play | Actively working |
179
+ | `needs_input` | nf-fa-moon_o | Waiting for input |
180
+ | `needs_permission` | nf-fa-lock | Waiting for a human answer |
181
+ | `unknown` | nf-fa-question_circle | No state source or no usable reading; the reason accompanies the state |
182
+
183
+ The state source is herdr on the herdr backend and murmur on the tmux
184
+ backend. mu maps murmur `working` to `busy`, `blocked` to
185
+ `needs_permission`, and `idle`, `done`, and `crashed` to `needs_input`.
186
+ The retained `agents.status` column is deprecated and always contains
187
+ `spawning`; no runtime read uses it.
198
188
 
199
189
  ### Agent lifecycle verbs
200
190
 
@@ -211,8 +201,11 @@ a cache; `mu agent list` reconciles on every call.
211
201
  | Verb | Effect |
212
202
  | ------------------------------------- | ----------------------------------------------------- |
213
203
  | `mu task add <id> ...` | Creates a new OPEN task. `--note <text>` appends an initial note in the same transaction; `--note-author <name>` overrides the note author. |
214
- | `mu task close/open <id>` | Lifecycle transition |
215
- | `mu task claim <task> [--for <agent>]` | Atomic: sets `owner`, flips status to `IN_PROGRESS` |
204
+ | `mu task close <id> [--as <substate> --why <text>]` | Sets `CLOSED` (default `CLOSED/done`). A non-`done` `--as` requires `--why` and prints the dependents it unblocked. |
205
+ | `mu task open <id>` | Reopens a task to `OPEN/todo`. |
206
+ | `mu task park <id> --why <text>` | `OPEN` → `OPEN/parked`. Leaves ready/next; stays in goals. |
207
+ | `mu task unpark <id>` | `OPEN/parked` → `OPEN/todo`. |
208
+ | `mu task claim <task> [--for <agent>] [--force]` | Atomic: sets `owner`, flips status to `IN_PROGRESS/active`. Refuses a parked task unless `--force`. |
216
209
  | `mu task release <task>` | Clears `owner`. Auto-flips `IN_PROGRESS` → `OPEN` (so the task re-enters the ready set); `CLOSED` is preserved. `--reopen` forces `OPEN` from `CLOSED`. |
217
210
  | `mu task note <task> "..."` | Appends to `task_notes`. Never edits prior notes. |
218
211
  | `mu task notes <task> [--tail N \| --since <iso> \| --since-claim]` | List notes (oldest first). `--tail N` (alias `--last N`) prints last N; `--since <iso>` filters by `created_at`; `--since-claim` auto-resolves to the most recent `task claim` event timestamp. `--since` and `--since-claim` are mutually exclusive. |
Binary file
@@ -0,0 +1,333 @@
1
+ # Agent state from murmur (mu 2.0.0, murmur 1.0.0)
2
+
3
+ Status: approved design, 2026-09-28. Task `crew/mu_status_from_murmur`.
4
+
5
+ ## Problem
6
+
7
+ mu works out what an agent is doing by reading the last 20 lines of its
8
+ pane (`src/detect.ts`), and stores the answer in `agents.status`. This fails
9
+ in three ways:
10
+
11
+ - **Remote workers.** The local pane holds an ssh client that renders a
12
+ nested tmux. `skills/mu/REMOTE_WORKERS.md` records wrong answers in both
13
+ directions in one session: a stall fired at 300 s on a worker that was
14
+ mid-turn, and `mu agent list` showed `needs_input` for a worker that was
15
+ working.
16
+ - **Wrappers.** Each pi wrapper with different chrome has needed its own
17
+ patch (the Braille-spinner fallback).
18
+ - **Stale copies.** The stored status is only as recent as the last
19
+ reconcile, and `mu agent show` needed a fix to re-detect before it printed.
20
+
21
+ murmur's pi extension reports state from inside the agent on its own host,
22
+ and it publishes the state on each pane (`@murmur_pane_state`) and across
23
+ machines (`murmur status --json`). mu already exports the variables that
24
+ murmur uses to recognise mu's workers (`MU_MANAGED_AGENT`, `MU_AGENT_NAME`,
25
+ `MU_WORKSTREAM`).
26
+
27
+ ## Decision
28
+
29
+ **murmur is the answer for agent state.** mu owns the work: the DAG, task
30
+ owners, workspaces and panes. murmur owns what an agent is doing. mu does
31
+ not read pane text to decide agent state, and it does not store agent state.
32
+
33
+ Sources of runtime state, in order:
34
+
35
+ 1. herdr's `paneStatus()` on the herdr backend. This is herdr's own report,
36
+ not scraping.
37
+ 2. On tmux, murmur. The local read is the pane's `@murmur_pane_state` and
38
+ `@murmur_pane_since`. The remote read is `murmur status --json`, matched
39
+ on `agent_name` and `workstream`.
40
+ 3. Otherwise `unknown`, and mu says why.
41
+
42
+ This follows ZEN rules 2 (report state, don't scrape it), 3 (absence means
43
+ absence) and 5 (standalone tools that compose). mu's core needs none of this
44
+ to work: the DAG, `claim`/`close`, `task wait`, workspaces, spawn, send and
45
+ read.
46
+
47
+ ## Scope
48
+
49
+ In:
50
+
51
+ - A state resolver module that replaces every state read from
52
+ `detectPiStatus`:
53
+ - reconcile, including `mu agent list` and `mu state`
54
+ - `mu agent show`
55
+ - `mu agent wait`
56
+ - stall detection in `mu task wait`
57
+ - spawn readiness
58
+ - `computeAgentIdle`
59
+ - the TUI cards and popups
60
+ - Stop writing `agents.status` after the initial insert, and stop reading it
61
+ everywhere.
62
+ - A new `--json` shape for agents: `state` and `source`, with no `status`.
63
+ - `mu doctor` checks for murmur and its pi extension.
64
+ - Updates to the README, the skill, `REMOTE_WORKERS.md`, ARCHITECTURE,
65
+ DECISIONS and ROADMAP.
66
+ - murmur 1.0.0: a contract section in its ARCHITECTURE, and the release.
67
+
68
+ Out:
69
+
70
+ - The two input-timing checks in `send`. `awaitPaneQuiescence` waits out
71
+ pi's re-init modal, which eats the Enter. `isTextStranded` detects a
72
+ prompt that was typed but never submitted. Both are about typing into a
73
+ terminal, not about agent state, and murmur cannot see either. They keep a
74
+ private helper for spinner and work-marker text. It is used only by
75
+ `send`, returns no status, and is not exported.
76
+ - `detectSpawnStartupError` in `src/agents/spawn.ts`. It scans a new pane
77
+ for provider and auth errors (a dud spawn), which is a spawn failure, not
78
+ agent state. It stays for the same reason as the `send` checks.
79
+ - Dropping the `agents.status` column. mu has no migration path: v10 is both
80
+ the minimum and the current schema. The drop waits for the first change
81
+ that needs a v11 anyway, and ROADMAP records it.
82
+ - Reading murmur's SQLite files. Reading them would couple mu to murmur's
83
+ private schema.
84
+ - Any feature added to murmur for mu. murmur only documents what it already
85
+ publishes.
86
+
87
+ ## Design
88
+
89
+ ### Caller's view
90
+
91
+ ```ts
92
+ const states = await readAgentStates(db, agents); // one tmux read, ≤1 murmur call
93
+ const s = states.get(agent); // { state: "busy", source: "murmur", since: 1790581553851 }
94
+ if (s.state === "unknown") hint(s.reason); // "murmur not installed" | ...
95
+ ```
96
+
97
+ `mu agent list --json`:
98
+
99
+ ```json
100
+ { "name": "worker-1", "pane": "%4", "state": "busy", "source": "murmur",
101
+ "since": "2026-09-28T07:45:53Z" }
102
+ { "name": "worker-9", "pane": "%9", "state": "unknown", "source": "none",
103
+ "reason": "murmur not installed" }
104
+ ```
105
+
106
+ ### State resolver (`src/agent-state.ts`, new)
107
+
108
+ ```ts
109
+ type RuntimeState = "busy" | "needs_input" | "needs_permission" | "unknown";
110
+ type StateSource = "murmur" | "herdr" | "none";
111
+ interface StateReading {
112
+ state: RuntimeState;
113
+ source: StateSource;
114
+ since: number | null; // ms; when the state last changed, from the source
115
+ reason?: string; // set when state is unknown
116
+ }
117
+ function readAgentStates(db: Db, agents: AgentRow[]): Promise<Map<AgentRow, StateReading>>;
118
+ ```
119
+
120
+ - It takes a batch of agents, so a whole display costs one read.
121
+ - **herdr backend:** calls `paneStatus()` per pane, as today.
122
+ - **tmux, local:**
123
+ - One `list-panes -a -F '#{pane_id}\t#{@murmur_pane_state}\t#{@murmur_pane_since}'`
124
+ call.
125
+ - A pane with a value is read from murmur. A pane with no value is a
126
+ candidate for the remote read.
127
+ - murmur publishes `@murmur_pane_state` only for a pane its extension has
128
+ claimed (fixed in murmur `3c8c1a7`).
129
+ - **tmux, remote:** the agents with no local value.
130
+ - One `murmur status --json` call, cached per mu process for
131
+ `MURMUR_CACHE_MS` (10 s), so the 1 s poll in `mu agent wait` does not
132
+ start Node every tick. murmur limits its own ssh collect to one per
133
+ 30 s ± 10 s.
134
+ - A row matches when `agent_name` and `workstream` are equal and `local`
135
+ is false.
136
+ - Zero matches gives `unknown`, with reason "murmur has no row". More
137
+ than one match gives `unknown`, with reason "ambiguous: N hosts". A
138
+ peer that murmur reports as stale gives `unknown`, with reason "remote
139
+ snapshot stale".
140
+ - **`since`:**
141
+ - For a local pane, it is `@murmur_pane_since`: the time the pane entered
142
+ its current state (see murmur 1.0.0 below).
143
+ - For a remote row, it is the row's `updated_at`. That is an
144
+ approximation, because it also moves on runtime updates. It is used
145
+ only for remote idle and stall timing, where scraping had no answer at
146
+ all.
147
+ - Only the remote path starts `murmur`. A machine with no remote workers
148
+ never runs it.
149
+ - **When murmur is missing:** no `murmur` on PATH, or the pi extension not
150
+ linked (`~/.pi/agent/extensions/murmur.ts` missing).
151
+ - Every tmux agent gets `unknown`, with reason "murmur not installed" or
152
+ "murmur pi extension not linked".
153
+ - The resolver checks this once per process.
154
+
155
+ ### Mapping
156
+
157
+ | murmur | mu |
158
+ | --- | --- |
159
+ | `working` | `busy` |
160
+ | `idle` | `needs_input` |
161
+ | `done` | `needs_input` |
162
+ | `blocked` | `needs_permission` |
163
+ | `crashed` | `needs_input` |
164
+
165
+ - `done` and `idle` both mean "waiting for a prompt".
166
+ - `blocked` means waiting on a human answer, which is what a permission
167
+ dialog means.
168
+ - For `crashed`, pi is gone but the pane may still be alive. The mapping
169
+ makes waits and stall detection fire. Pane death stays with the reaper.
170
+
171
+ herdr's mapping does not change: `working` gives `busy`; `blocked` gives
172
+ `needs_permission`; `idle`, `done` and `unknown` give `needs_input`.
173
+
174
+ ### Consumers
175
+
176
+ - **reconcile.** It keeps pruning dead panes and refreshing titles. It
177
+ stops detecting and writing status. `report-only` mode stays read-only.
178
+ - **`mu agent list`, `mu state`, `mu agent show`, TUI.** These show
179
+ `state` from the resolver. An `unknown` state shows `?` and a one-line
180
+ hint, printed once per invocation: "agent state needs murmur (see mu
181
+ doctor)".
182
+ - **`mu agent wait`.** It fires on busy → not busy, as today. `unknown`
183
+ never fires. If every watched agent is `unknown` at the first tick, it
184
+ prints the reason once and keeps waiting until `--timeout`.
185
+ - **`mu task wait` stall detection.** The stall timer measures time in
186
+ `needs_input` using `since`. `unknown` never counts as a stall. The rest
187
+ of `task wait` is unchanged: it polls the DB, which is exact.
188
+ - **Spawn readiness.**
189
+ - With murmur present, spawn waits until the pane's `@murmur_pane_state`
190
+ is set, within the existing budget. murmur publishes it right after the
191
+ extension claims the pane.
192
+ - Without murmur, spawn skips the wait and keeps the "pane died on spawn"
193
+ check. The first `send` waits for pi through its input-timing checks.
194
+ - **`computeAgentIdle`.** An agent is idle when it is `needs_input` and
195
+ `since` is older than the threshold, taken from the resolver. It was
196
+ `agents.updated_at`.
197
+ - **`mu doctor`.** It reports murmur on PATH, the version (≥ 1.0.0), the pi
198
+ extension linked, and whether the identity exists. A failure is a
199
+ warning, not an error.
200
+
201
+ ### `agents.status` in 2.0
202
+
203
+ - It is deprecated in place.
204
+ - Inserts write the fixed value `'spawning'` to satisfy `NOT NULL` and the
205
+ CHECK. Nothing updates it and nothing reads it.
206
+ - Mid-spawn is already marked by the `pending:` pane-id placeholder.
207
+ - `free` (from `adopt`) and the "sticky until activity" overwrite rule
208
+ disappear, because nothing overwrites anything.
209
+ - `db.ts` marks the column deprecated.
210
+ - The SDK drops `AgentStatus`, `shouldOverwriteAgentStatus`,
211
+ `updateAgentStatus` and the `detectPiStatus` export. It exports
212
+ `RuntimeState`, `StateReading` and `readAgentStates`.
213
+
214
+ ### murmur 1.0.0
215
+
216
+ 1.0.0 means the interfaces mu reads are stable. A breaking change to them
217
+ needs a major version.
218
+
219
+ - **New: `@murmur_pane_since`.** This is the time (ms since the epoch)
220
+ the pane entered its current `@murmur_pane_state`.
221
+ - It is set in the same tmux command as the state, and only when the
222
+ state changes: `if -F '#{!=:#{@murmur_pane_state},<new>}' 'set -p
223
+ @murmur_pane_since <now>' ; set -p @murmur_pane_state <new>`.
224
+ - The compare-and-set runs inside tmux, so every writer (the extension,
225
+ the collector, `clear`) agrees, and a repeated write of the same state
226
+ leaves the value alone. This was checked on a scratch server:
227
+ `working → working → idle` kept the first time and then moved.
228
+ - It is unset together with the state. It needs no store schema change.
229
+ - **The contract**, listed in the "Contract" section of its ARCHITECTURE:
230
+ - `@murmur_pane_state` and its five values, and `@murmur_pane_since`
231
+ - the `status --json` fields `panes[].{pane, local, agent_name,
232
+ workstream, driver, activity, attention, freshness, updated_at}`
233
+ - the `MU_*` variables murmur reads
234
+ - **Release:** it ships main (the idle-label fix) plus
235
+ `@murmur_pane_since`, with a CHANGELOG entry that says what 1.0 means.
236
+ There is no 0.6.2.
237
+ - **Order:** murmur 1.0.0 first, then mu 2.0.0.
238
+
239
+ ## Cost
240
+
241
+ Measured on the dev machine with 5 local agents:
242
+
243
+ | Read | Before (scrape) | After |
244
+ | --- | --- | --- |
245
+ | local state, 5 agents | 5 × `capture-pane`, 13 ms | 1 × `list-panes`, 2 ms |
246
+ | `agent wait`, per 1 s tick | 5 captures, plus detection in JS | 1 × `list-panes` |
247
+ | local idle and stall timing | a DB timestamp that only moved on reconcile | `@murmur_pane_since`, read in the same call |
248
+ | remote agents | captures that gave wrong answers | `murmur status --json`, 90 ms, cached for 10 s |
249
+
250
+ `mu agent list` and `mu state` take about 0.8 s, and most of that is mu's
251
+ startup (`mu --version` takes 0.30 s). This change removes work from both
252
+ commands. No local path starts a second Node process.
253
+
254
+ ## Testing
255
+
256
+ - **Unit tests** for the resolver, using a fake tmux and a fake `murmur`
257
+ runner:
258
+ - the local value
259
+ - a remote match
260
+ - no match, an ambiguous match and a stale peer
261
+ - murmur missing and the extension not linked
262
+ - the herdr path
263
+ - cache reuse within `MURMUR_CACHE_MS`
264
+ - no `murmur` process started when every agent has a local option
265
+ - **Consumer tests:**
266
+ - `agent wait` never fires on `unknown`
267
+ - stall detection uses `since` and ignores `unknown`
268
+ - spawn readiness with murmur and without it
269
+ - `computeAgentIdle`
270
+ - **Removal checks:**
271
+ - No state path imports the send helper. A grep test in the style of
272
+ murmur's cheap-tick check enforces this.
273
+ - `src/detect.ts` exports no status.
274
+ - **Integration:**
275
+ - With real tmux and a fake `@murmur_pane_state` set with `tmux set -p`:
276
+ `mu agent list --json` shows `source: murmur`.
277
+ - Without the option, it shows `unknown` with its reason.
278
+ - **Live check** on this machine: spawn a worker, send it work, and check
279
+ `mu agent list` for busy and then needs_input, plus `mu agent wait` and
280
+ `mu task wait --on-stall exit`.
281
+ - **Gates:**
282
+ - mu: `npm run typecheck && npm run lint && npm run test`
283
+ - murmur: `npm run check`
284
+
285
+ ## Implementation checklist
286
+
287
+ 1. murmur: publish `@murmur_pane_since` with a compare-and-set in
288
+ `setPaneState`, with tests (set on change, kept on a repeat, unset with
289
+ the state). Add the Contract section to ARCHITECTURE and the 1.0.0
290
+ CHANGELOG entry. Live check, then release 1.0.0 through CI.
291
+ 2. mu: add `src/agent-state.ts` (the resolver, mapping and cache) and its
292
+ unit tests.
293
+ 3. mu: switch reconcile, `agent list/show`, `mu state` and the TUI to the
294
+ resolver. Stop writing status.
295
+ 4. mu: switch `agent wait`, `task wait` stall detection and
296
+ `computeAgentIdle` to the resolver.
297
+ 5. mu: switch spawn readiness to the murmur option, or skip it when murmur
298
+ is missing.
299
+ 6. mu: move the send input-timing checks to a private helper, remove the
300
+ state exports from `src/detect.ts`, and add the grep test.
301
+ 7. mu: add the `--json` shape (`state`, `source`, `since`, `reason`), and
302
+ make the SDK export changes.
303
+ 8. mu: add the `mu doctor` murmur check.
304
+ 9. mu docs:
305
+ - README: "agent state needs murmur"
306
+ - the skill and `REMOTE_WORKERS.md`: drop the "ask murmur by hand" step
307
+ - ARCHITECTURE: rewrite § "Status detection"
308
+ - DECISIONS: this decision and what it rejects
309
+ - ROADMAP: the column drop, and update § murmur
310
+ - VOCABULARY
311
+ 10. mu: CHANGELOG 2.0.0 (breaking: `status` is gone, murmur is needed for
312
+ state); do the live check, then release through CI.
313
+
314
+ ## Rejected
315
+
316
+ - **Scraping as a fallback.** It keeps two answers, one for each source. It
317
+ also keeps the wrapper-patch cycle, and the misreads return whenever
318
+ murmur is absent. `unknown` is honest.
319
+ - **Scraping only for remote workers.** Remote is exactly where scraping is
320
+ wrong.
321
+ - **Storing runtime state in `mu.db`.** The copy goes stale. The data
322
+ belongs in murmur.
323
+ - **Dropping the column now.** It would make this release mu's first
324
+ migration, and the only gain would be deleting an unused column.
325
+ - **Reading murmur's `state.db`.** It couples mu to a private schema.
326
+ - **Making murmur a hard dependency.** It pulls murmur's native
327
+ `better-sqlite3` build into installs that want only the task graph.
328
+ - **Removing the send checks.** They fixed measured failures, such as a
329
+ prompt lost to pi's session-naming modal, and murmur cannot replace them.
330
+
331
+ ## Open questions
332
+
333
+ None.