acpmux: a Claude workflow's phases and agents from its progress snapshots

Merged
TE
12d ago
+754-58

Leo, 2026-10-24: "lawrence also sent some of this stuff with dynamic workflows claude "trajectories" ... we can use that to help think about how cmux-next/acpmux wants to expose/reveal/render dynamic workflows... we can look at claude code desktop and see how we can do better". Design: manaflow-ai/cmuxterm-hq#3720 (debated; this is slice 1).

acpmux owner notice. plans/cmux-next/acpmux-audit.md names the acpmux owner as session cmux-4f (PL-acpmux). That session can't be reached from here, so this is the notice. The change is in claude_stdio/workflows.rs, inbound.rs (end on a terminal task_updated) and subagents.rs (fields passed through). It makes the daemon do less work per workflow progress line: no 4 MB journal re-read per line, and at most one usage-only update a second.

Risk: medium. It changes which subagent_progress updates a Claude workflow sends, and ends a workflow on a task_updated that says it was paused or killed. Older panes ignore the new fields. The pane-side reader is #24818.

What changed

Claude Code's workflow task_progress lines carry the run's whole state in workflow_progress: one item per phase and one per agent, with label, phase, state, model, tokens, tool calls, timings, retries and short prompt and result previews. Claude Code's own workflow board is drawn from that field. acpmux dropped it and sent only how many agents returned. It counted those by re-reading the run's journal.jsonl (up to 4 MB) on every progress line, and Claude sends one every 16 ms while agents stream.

subagent_progress for a workflow now carries:

  • runId (wf_…) from the launch, which a resume names again.
  • phases: [{index, title}], the whole list, only when it changes.
  • workflowAgents: each agent that changed since the last update, as its index and only the fields that changed (a removed field is null). A client merges them by index. The fields: label, state (queued, running, completed, failed, skipped, blocked), phase, phaseIndex, agentId, kind, model, fallbackModel, tokens, toolUses, elapsedMs, queuedAt, startedAt, lastProgressAt, attempt, retryReason, cached, isolation, remoteSessionId, action, prompt, result, error.
  • agents: {done, started, failed?, queued?}, taken from the snapshot. An agent that failed before it started doesn't count as started, matching the journal. Until the first snapshot, the journal is read from where the last read stopped (whole lines only), not re-read from the start.
  • At the run's end (its task_notification, or a task_updated that ends it), one last update carries the final usage and every agent whole, marked workflowAgentsFull: true. It goes out before the end itself, so a reader of recent history alone still has each agent.

Lifecycle: a workflow's task_updated that says it was paused or killed now ends its subagent, because Claude Code sends no notification after a pause, so a paused run used to stay "running" forever. A paused run ends as cancelled with reason: "paused"; resuming it starts a new task with the same runId. A completed or failed workflow, and every plain background agent, still ends only with its notification, which carries its result and final usage. A client merges agents by the workflow's subagentSessionId plus index, never by runId alone.

Other behaviour:

  • A workflow's usage-only progress lines send at most one update a second. A line with a snapshot always sends one.
  • Previews are secret-masked and cut to 240 characters.
  • Agents whose item did not change (every finished one) are not mapped or masked again.
  • The hub's subagent model (_meta.acpmux.subagents) passes the new fields through.
  • Unknown agent states go out as Claude names them, never as running.
  • Older panes copy only the subagent fields they know, so the new fields do nothing for them. The capability workflow-agents-v1 says this daemon sends them for its own chats. A peer's chat comes from the peer's daemon, so a pane draws rows from the payload.
  • The pane-side reader is #24818 (stacked on this one), and the gallery's workflow summaries (#24874) are built on it.

Why deltas: each snapshot repeats every agent so far (about 1 KB per agent, measured on a real run). A large run sends one with every agent start and end, so resending it whole would grow a chat's log by snapshots Ɨ agents. Only what changed goes out here.

This is the data the gallery's workflow summary and detail sheet (#24762, #24764, #24766) wait for. It adds no UI.

Size: 812 changed lines, about 430 of them tests.

Merge review: Codex (xhigh, read-only, a different model) SHIP at 041a6f521bc60b56d6b416b65ff8f357dab4463d. The receipt is in the PR comments.

Testing

  • cargo test -p acpmux --lib -- claude_stdio subagents capabilities hub::tap protocol at the head: 77 passed. Ten tests are new:
    • Phases and agents go out, then only what changed, with counts from the snapshot even when the journal disagrees.
    • Failed, skipped, cached and retried agents each say so.
    • Only agents that started count as started, and unknown states pass through.
    • Usage-only lines send at most once a second.
    • The end carries the final usage and every agent, before the end itself.
    • A pause ends the run with its reason, and a notification with no status still ends it.
    • A plain background agent ends only with its notification.
    • A preview never shows part of a secret behind a long blank run, and reads a bounded amount of a million-space flood.
    • The journal is read once in all, leaving a half-written line for the next read.
    • The hub's model carries the new fields.
  • An independent cross-model review (Codex, xhigh, read-only) found the pause leak, the dropped final usage, the uncached journal reads and the counting of pre-start failures. All four are fixed.
  • Codex's merge review then blocked on three points, all fixed:
    • a plain background agent could end on a task_updated before its notification. Only a workflow ends there now, and only on paused or killed.
    • the launch's args went out unmasked. The resume inputs (scriptPath, args) moved to their own follow-up PR with a secret guard, which also keeps this one under the size cap.
    • previews masked their whole source before cutting it. A preview now folds whitespace, keeps a bounded head, masks it and then cuts (as the lane step's masked line does), so no part of a secret behind a blank run can show. Codex's next passes caught that the first bounded version cut before folding, and that whitespace splitting still scanned a huge blank run whole. A preview now reads at most 64 times its length, folds as it reads, and leaves out a word the read stopped inside. Both cases are tested.
  • cargo clippy -p acpmux --lib --tests: no findings in the changed files.
  • cargo fmt --check: clean.
  • scripts/cmux-next/prepush-gate.py: no new violations.

No app build or UI capture: this change is daemon-only.

Agent session: huge-blue

šŸ¤– Generated with Claude Code