detached 84a9305

Merge pull request #119 from bnema/feat/subagent-execution-trace

feat(subagents): trace factual execution operations
commit
84a93054db1c18b27d8061efe107f95f94e3fda6
author
bnema · · 2026-08-12 07:55 UTC
committer
GitHub · · 2026-08-12 07:55 UTC
merge of
6b178f8 + be0c47d · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
29 files · +1659 -927
M README.md +5 -5
@@ -318,7 +318,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
318318 | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. |
319319 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
320320 | `/btw` | Side chat with full context that doesn't add to the main thread. |
321-| `/subagents` | Spawn, monitor, and chat with background subagents. Each is a separate long-lived process with bounded lifecycle state, structured results, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
321+| `/subagents` | Spawn, inspect, and chat with background subagents. Each is a separate long-lived process with structured results, factual execution observations, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
322322 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
323323 | `/compact` | Summarize the transcript into one message to free up context. |
324324 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zut/main.go`. |
@@ -400,7 +400,7 @@ Background subagents that run alongside your main session. Each one is a separat
400400 /subagents remove <id> # delete durable state
401401 ```
402402
403-**Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with process/turn state, queue position, age, workspace mode, and current activity. Keys:
403+**Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with its current trace-observed operation (or last fact), age, workspace mode, and task. It does not infer `working`, `alive`, or `idle` from a process lifecycle. Keys:
404404
405405 | Key | Action |
406406 |---|---|
@@ -419,9 +419,9 @@ Background subagents that run alongside your main session. Each one is a separat
419419
420420 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
421421
422-**Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
422+**Persistence across zut restarts** — every spawn writes durable metadata and a session file under `$ZUT_HOME/subagents/agents/<id>/`; a completed or terminated worker writes structured `result.json`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
423423
424-**Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
424+**Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
425425
426426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
427427
@@ -448,7 +448,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
448448 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
449449 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
450450 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
451-- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then running subagents. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places live delegated-turn rows above or below the input; it defaults below the input and shows the profile or agent ID, current activity, and elapsed time since the last activity/heartbeat. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. Idle, detached, and terminal turns disappear from this area immediately. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields).
451+- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places trace-observed open-operation rows above or below the input; it defaults below the input and shows the profile or agent ID, operation, and elapsed operation duration. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. A process lifecycle or heartbeat alone never creates a row. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields).
452452 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
453453 - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).
454454 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
@@ -318,7 +318,7 @@ Slash command names are case-insensitive in the TUI and messaging backends; argu
318 | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. |318 | `/session` | Four ops on the current session: `export` to a portable `.zutsession` file, `import` one back in, `fork` from a past user message into a new branch, `tree` to switch between branches. Opens a picker without an argument; direct forms: `/session export [path]`, `/session import <path>`, `/session fork`, `/session tree`. Default export destination is `~/Downloads`. `Esc` `Esc` from an idle, empty editor is a shortcut for `/session tree` when the terminal supplies two parsed bare Escape events. |
319 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |319 | `/jump` | Scroll the chat to a previous turn (or `/jump <text>` to filter). |
320 | `/btw` | Side chat with full context that doesn't add to the main thread. |320 | `/btw` | Side chat with full context that doesn't add to the main thread. |
321-| `/subagents` | Spawn, monitor, and chat with background subagents. Each is a separate long-lived process with bounded lifecycle state, structured results, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |321+| `/subagents` | Spawn, inspect, and chat with background subagents. Each is a separate long-lived process with structured results, factual execution observations, and optional Git worktree isolation. Named profiles can be selected with `agent`; see [docs/subagents.md](docs/subagents.md). |
322 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |322 | `/skills` | List discovered skills (SKILL.md files) and preview their bodies. |
323 | `/compact` | Summarize the transcript into one message to free up context. |323 | `/compact` | Summarize the transcript into one message to free up context. |
324 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zut/main.go`. |324 | `/study` | Run the canned prompt "Read and understand everything in the current directory." so the agent has full project context before you start asking targeted questions. Pass a path — typed, drag-dropped, or selected via `@` — to target a specific file or directory instead: `/study [dir:packages/]`, `/study cmd/zut/main.go`. |
@@ -400,7 +400,7 @@ Background subagents that run alongside your main session. Each one is a separat
400 /subagents remove <id> # delete durable state400 /subagents remove <id> # delete durable state
401 ```401 ```
402 402
403-**Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with process/turn state, queue position, age, workspace mode, and current activity. Keys:403+**Dashboard (`/subagents` with no arg)** — a list of every agent for the current session, with its current trace-observed operation (or last fact), age, workspace mode, and task. It does not infer `working`, `alive`, or `idle` from a process lifecycle. Keys:
404 404
405 | Key | Action |405 | Key | Action |
406 |---|---|406 |---|---|
@@ -419,9 +419,9 @@ Background subagents that run alongside your main session. Each one is a separat
419 419
420 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.420 **Session scoping** — each agent is stamped with the host session that spawned it and only shows up in that session's dashboard. Swap sessions with `/sessions` and the dashboard re-narrows accordingly. Agents from other sessions keep running in the background and reappear when you switch back.
421 421
422-**Persistence across zut restarts** — every spawn writes a durable manifest, lifecycle summary, append-only event log, session file, and structured `result.json` under `$ZUT_HOME/subagents/agents/<id>/`. On the next `zut` launch the compact summary restores each agent as **detached** without replaying its full history. Open its transcript to hydrate the durable event log, then use `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.422+**Persistence across zut restarts** — every spawn writes durable metadata and a session file under `$ZUT_HOME/subagents/agents/<id>/`; a completed or terminated worker writes structured `result.json`. A local execution trace is an optional separate diagnostic bundle. On the next `zut` launch, retained session and result data can be used with `R` or `/subagents resume-session <id>` to continue the existing session. `/subagents restart-task <id>` is the explicit operation that intentionally replays the stored task.
423 423
424-**Where state lives** — per-agent manifests, session files, events, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.424+**Where state lives** — per-agent metadata, session files, results, and patches live under `$ZUT_HOME/subagents/agents/<id>/`; inbox sockets are runtime-only and permission-restricted. When enabled, trace bundles live below `ZUT_SUBAGENT_TRACE_DIR`; `zut debug trace <bundle>` reports the open operation, terminal fact, or last event. Shared-mode edits land directly in the repo. Worktree-mode edits are captured as durable patches and changed-file summaries before cleanup.
425 425
426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.426 **`/session export` does NOT bundle subagents.** A `.zutsession` is just the main chat transcript; per-agent state (session file, unix-socket inbox) is machine-local and doesn't round-trip through a JSONL file. To share what an agent said, copy it out of the transcript view manually.
427 427
@@ -448,7 +448,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
448 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.448 - **jail new sessions by default**: start every new agent with tools confined to its working directory. Off by default. The setting applies to interactive, print, JSON, RPC, and background-agent runs, persists as `jail_by_default`, and immediately updates the current interactive session. `/jail` and `/unjail` remain session-scoped overrides and do not change this default.
449 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.449 - **compact transcript rendering**: reduce visual chrome in the chat transcript. Tool calls render as a quiet header plus indented output instead of a bordered box, and sent messages render without padded background bubbles. Off by default. Changes apply immediately and persist to `config.json` as `compact_mode`.
450 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.450 - **show loaded resources at startup**: list the active Zutfile agent (for `zut run`), loaded `AGENTS.md` paths, extensions, and user-installed skills in compact sections above the transcript. Built-in skills are omitted. Off by default. Changes apply immediately and persist to `config.json` as `show_instructions_at_startup`.
451-- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then running subagents. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places live delegated-turn rows above or below the input; it defaults below the input and shows the profile or agent ID, current activity, and elapsed time since the last activity/heartbeat. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. Idle, detached, and terminal turns disappear from this area immediately. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields).451+- **TUI settings**: opens a sub-view for input layout in display order: status, working spinner, input, then subagent operations. **Input style** can be `plain` (default prompt line), `lines` (separator lines above and below the input), or `block` (a user-bubble-style input block). **Status position** places model, usage, and working-directory information above or below the input. **Working spinner position** places the busy spinner above or below the input. **Running subagent position** places trace-observed open-operation rows above or below the input; it defaults below the input and shows the profile or agent ID, operation, and elapsed operation duration. On a short terminal, omitted rows collapse into one count summary so the editor remains visible. A process lifecycle or heartbeat alone never creates a row. Changes apply immediately and persist to `config.json` as `tui_input_style`, `tui_status_position`, `tui_working_position`, and `tui_subagent_position` (`above_input` or `below_input` for the position fields).
452 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.452 - **reasoning level**: choose reasoning for supported models: off, minimum, low, medium, high, xhigh, or max. The `max` tier is opt-in and sent natively to GPT-5.6 and adaptive-thinking Claude models; unsupported backends clamp it to their highest accepted effort. The change is persisted to `config.json` and applied to the next model call. Use `/reasoning` to open this selector directly. The selector only shows distinct levels supported by the active model; models without reasoning support only offer `off`.
453 - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).453 - **color theme** — choose the built-in auto, inherited (from terminal), dark, or light theme, or any JSON theme discovered under `$ZUT_HOME/themes` or a loaded extension. Built-in and JSON themes follow terminal color capability: truecolor terminals receive RGB output, while RGB values fall back to xterm-256 elsewhere. Theme files can use xterm-256, ANSI, or RGB values for semantic UI colors and can also override syntax colors and spinner frames/messages. Changes apply immediately; if a selected theme file is deleted, zut resets to auto. See [docs/themes.md](docs/themes.md).
454 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.454 - **model shortcuts** — opens a sub-view with nine slots (`model 1` ... `model 9`). `enter` on a slot opens the same `/model` selector and binds the chosen provider/model to that slot; `backspace` clears a slot. Once assigned, press `Ctrl+1` ... `Ctrl+9` from the editor to switch the active model instantly (the same cross-provider swap `/model` performs, transcript and cost carried over). Assigning a shortcut does not change the current model. Shortcuts are skipped while a turn is running.
M docs/subagents.md +16 -4
@@ -135,9 +135,9 @@ Shared mode preserves the historical host working directory, so workers there mu
135135
136136 ## Lifecycle, results, and recovery
137137
138-Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, logical result references, and any required-work state, target turn, stable outcome error code, and parent-notification marker.
138+Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation.
139139
140-During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results.
140+During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the open operation, and its elapsed duration. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
141141
142142 `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.
143143
@@ -154,13 +154,25 @@ subagent://<id>/result
154154 subagent://<id>/patch
155155 ```
156156
157-`events.jsonl` is appended as the worker emits events, including partial `message.delta` output. The dashboard and `subagent://<id>/history` replay those deltas, so output received before a worker finishes remains recoverable rather than waiting for a final assistant message.
157+Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript.
158158
159159 A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.
160160
161161 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
162162
163-When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
163+When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use the shared trace projection and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, open operation and its start time when one is observed, a terminal fact or last observed event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
164+
165+## Local execution trace
166+
167+Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output that can contain credentials, secrets, personal data, or private source code. Enable it only when the local bundle can be protected and manually deleted afterwards; do not commit, upload, or otherwise expose trace data. Bundles request restrictive local permissions, but access control remains platform-dependent.\n\nExplicit runtime `TraceDir` and `TraceMode` configuration takes precedence over these environment variables. Environment mode values are case-insensitive (`normal` or `detailed`); an invalid value is ignored with a stderr warning. Detailed mode requires a non-empty trace directory, otherwise tracing remains disabled.
168+
169+Inspect a retained bundle with:
170+
171+```sh
172+zut debug trace <bundle>
173+```
174+
175+Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress.
164176
165177 Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
166178
@@ -135,9 +135,9 @@ Shared mode preserves the historical host working directory, so workers there mu
135 135
136 ## Lifecycle, results, and recovery136 ## Lifecycle, results, and recovery
137 137
138-Every child has independent process and turn state. A process may be `alive` while its turn is `idle`; a supervisor restart marks the process `detached` without claiming that its last turn failed. Durable manifests include the task, parent/root session identity, workspace mode, attempt, process/turn state, heartbeat timestamps, logical result references, and any required-work state, target turn, stable outcome error code, and parent-notification marker.138+Every child retains narrowly scoped internal process control and durable task/result metadata. These details coordinate startup, cancellation, and recovery; they are not a user-facing progress model. Diagnostic progress comes only from paired trace boundaries and their projection into open operations, terminal facts, or an explicitly insufficient observation.
139 139
140-During an active delegated turn, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), current activity, and elapsed time since the latest activity or heartbeat. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted workers collapse into one count summary so the editor remains visible. Rows disappear when the turn becomes idle, the process detaches, or the turn reaches a terminal state; use `/subagents` for durable history and results.140+During a trace-observed open operation, interactive mode shows compact rows beside the input area: a themed spinner, the named profile (or agent ID), the open operation, and its elapsed duration. Rows default below the input and can move above it through **running subagent position** in `/settings` → **tui settings**. On short terminals, omitted operations collapse into one count summary so the editor remains visible. A process lifecycle, heartbeat, or generic `working` label never creates a row; use `/subagents` for the current factual operation or last observed fact.
141 141
142 `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.142 `/subagents` opens on workers from the current host session. Press **Tab** to switch to the all-sessions history, use **Up/Down** to select a worker, and press **Right** or **l** to expand a bounded live snapshot of its user messages, assistant output, and tool activity (**Left** or **h** collapses it). The dashboard derives its row budget from the terminal height, keeping the selected worker visible on short terminals. Press **Enter** for the complete transcript.
143 143
@@ -154,13 +154,25 @@ subagent://<id>/result
154 subagent://<id>/patch154 subagent://<id>/patch
155 ```155 ```
156 156
157-`events.jsonl` is appended as the worker emits events, including partial `message.delta` output. The dashboard and `subagent://<id>/history` replay those deltas, so output received before a worker finishes remains recoverable rather than waiting for a final assistant message.157+Workers send protocol events to their parent supervisor. The supervisor maintains the live transcript and, when execution tracing is enabled, writes one ordered local trace for the parent/child tree. The trace is diagnostic data, not a second session transcript.
158 158
159 A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.159 A subagent timeout applies to each active delegated turn. When the deadline expires, the worker cancels that turn, writes a failed result with error code `deadline_exceeded`, preserves any output already received, and remains idle so an explicit follow-up can resume its session. Idle time does not consume the next turn's timeout, and each resumed turn receives a fresh deadline. Process shutdown still allows the configured grace period for the worker to write its session and result before forceful cancellation.
160 160
161 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.161 Use `/subagents resume-session <id>` to continue the existing session without replaying its original task. Use `/subagents restart-task <id>` only when intentionally starting the stored task again. Cancellation requests graceful shutdown first, then forcefully cancels after the configured grace period.
162 162
163-When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use in-process snapshots and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, a normalized lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), the underlying `process_state` and `turn_state`, `lifetime_turns` and `current_run_turns` counters, start/update/finish timestamps, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It omits full prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.163+When the relevant manager tool is permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. This includes explicit user-requested delegation and required-work recovery when auto-subagents orchestration is disabled. Call `subagent_status` with `{}` to list workers visible to the active supervisor session, or pass `{"agent_id":"<id-or-unique-prefix>"}` to query one worker. Queries use the shared trace projection and never wait for a worker turn or process to finish; repeated calls or state inspection are not completion signals. The JSON response contains the worker id, open operation and its start time when one is observed, a terminal fact or last observed event, `lifetime_turns` and `current_run_turns`, a bounded first-line task summary, required state/target/unmet/error-code metadata when applicable, and terminal-result metadata/reference when available. It deliberately omits generic lifecycle claims such as `working`, `alive`, `idle`, `process_state`, and `turn_state`, as well as prompts, transcripts, result output, provider settings, and filesystem paths. The bounded task summary can reflect user task text and must be treated as session data.
164+
165+## Local execution trace
166+
167+Set `ZUT_SUBAGENT_TRACE_DIR` to a private local directory before launching zut to retain an execution-trace bundle for each supervisor. Each bundle contains `manifest.json`, ordered `trace.jsonl`, and (only in detailed mode) private `payloads/` files. The default normal mode records redacted metadata. `ZUT_SUBAGENT_TRACE_MODE=detailed` retains unredacted prompts, tool arguments, and tool output that can contain credentials, secrets, personal data, or private source code. Enable it only when the local bundle can be protected and manually deleted afterwards; do not commit, upload, or otherwise expose trace data. Bundles request restrictive local permissions, but access control remains platform-dependent.\n\nExplicit runtime `TraceDir` and `TraceMode` configuration takes precedence over these environment variables. Environment mode values are case-insensitive (`normal` or `detailed`); an invalid value is ignored with a stderr warning. Detailed mode requires a non-empty trace directory, otherwise tracing remains disabled.
168+
169+Inspect a retained bundle with:
170+
171+```sh
172+zut debug trace <bundle>
173+```
174+
175+Start with the oldest open operation shown by the command. A provider request, tool invocation, or turn is useful evidence; a process that is merely alive without an open operation is reported as `no observable operation` with its last fact, not as progress.
164 176
165 Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.177 Use `subagent_stop` with an `agent_id` to request termination of a stuck worker through the normal graceful-then-forceful supervisor lifecycle. Its `stop_requested` result confirms that shutdown has started; wait for the host completion update before attempting a restart. Use `subagent_resume` with an `agent_id` and `prompt` to start a fresh max-turn budget while retaining the transcript/session/context, send the follow-up to an idle worker, queue it for the next available message turn when the worker is active, or restart a terminal worker from its retained session with that follow-up as the initial turn. Set `required:true` to make a new follow-up mandatory. A resume of failed, timed-out, or canceled required work is mandatory automatically, returns immediately, reports its new terminal outcome asynchronously, and advances the persisted target turn. An indeterminate outcome requires the explicit user reconciliation described above before a new target turn starts. A follow-up remains durably pending until its matching user message is flushed to the session and the delegated `turn.started` boundary is recorded; a worker-ready event alone does not acknowledge it. Automatic retries, model loops, and compaction remain within the current run budget; multiple active follow-ups are delivered in order. Only same-worker `turn.start` retries have stable in-memory command deduplication; that deduplication is lost when the worker restarts. Do not retry `turn.cancel`, `agent.shutdown`, or `agent.ping` after an ambiguous outcome. After a worker restart, explicitly reconcile durable results and side effects before retrying non-idempotent work. It never replays the original task. Optional and required completion use the normal auto-subagent update; required state is additionally supplied in the required-update context before the parent can finish. An unknown or ambiguous id is returned as a model-visible tool error; the existing `/subagents` dashboard and final-result notifications remain unchanged. An explicit `--tools` allowlist must include each manager tool that the model needs, and `subagent_status` must not be polled solely to wait for completion.
166 178
M packages/agent/cli.go +3 -0
@@ -793,6 +793,9 @@ func Run(rawArgs []string, version string) error {
793793 if handled, err := runZutfileCommand(rawArgs, version); handled {
794794 return err
795795 }
796+ if handled, err := runDebugCommand(rawArgs, os.Stdout); handled {
797+ return err
798+ }
796799 return runWithArgsRaw(rawArgs, version)
797800 }
798801
@@ -793,6 +793,9 @@ func Run(rawArgs []string, version string) error {
793 if handled, err := runZutfileCommand(rawArgs, version); handled {793 if handled, err := runZutfileCommand(rawArgs, version); handled {
794 return err794 return err
795 }795 }
796+ if handled, err := runDebugCommand(rawArgs, os.Stdout); handled {
797+ return err
798+ }
796 return runWithArgsRaw(rawArgs, version)799 return runWithArgsRaw(rawArgs, version)
797 }800 }
798 801
M packages/agent/modes/interactive.go +3 -1
@@ -2151,10 +2151,12 @@ func (i *Interactive) redraw() {
21512151 var allSubagentLines []string
21522152 if !dashboardActive {
21532153 now := i.clock()
2154+ activitySnapshots, traceViews := i.activeSubagentActivitySnapshots()
21542155 allSubagentLines = renderSubagentActivityLines(
21552156 i.cfg.Theme,
21562157 i.spin.FrameAt(now),
2157- i.activeSubagentActivitySnapshots(),
2158+ activitySnapshots,
2159+ traceViews,
21582160 mainCols,
21592161 now,
21602162 )
@@ -2151,10 +2151,12 @@ func (i *Interactive) redraw() {
2151 var allSubagentLines []string2151 var allSubagentLines []string
2152 if !dashboardActive {2152 if !dashboardActive {
2153 now := i.clock()2153 now := i.clock()
2154+ activitySnapshots, traceViews := i.activeSubagentActivitySnapshots()
2154 allSubagentLines = renderSubagentActivityLines(2155 allSubagentLines = renderSubagentActivityLines(
2155 i.cfg.Theme,2156 i.cfg.Theme,
2156 i.spin.FrameAt(now),2157 i.spin.FrameAt(now),
2157- i.activeSubagentActivitySnapshots(),2158+ activitySnapshots,
2159+ traceViews,
2158 mainCols,2160 mainCols,
2159 now,2161 now,
2160 )2162 )
M packages/agent/modes/subagent_indicator.go +28 -134
@@ -10,17 +10,17 @@ import (
1010 "github.com/mattn/go-runewidth"
1111 )
1212
13-// renderSubagentActivityLines returns one compact, input-adjacent row for each
14-// active delegated turn. Terminal and idle workers are intentionally omitted:
15-// the /subagents dashboard remains the durable history, while this surface is
16-// only a live "is it still moving?" signal.
17-func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string {
13+// renderSubagentActivityLines renders only trace-observed open operations.
14+// A living process, generic lifecycle state, or heartbeat never starts a
15+// spinner because none proves that an operation is progressing.
16+func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, views map[string]subagents.AgentTraceView, width int, now time.Time) []string {
1817 lines := make([]string, 0, len(snapshots))
1918 for _, snapshot := range snapshots {
20- if !subagentTurnIsActive(snapshot) {
19+ view := views[snapshot.ID]
20+ if len(view.OpenOperations) == 0 {
2121 continue
2222 }
23- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now)
23+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, view.OpenOperations[0], width, now)
2424 if line != "" {
2525 lines = append(lines, line)
2626 }
@@ -28,40 +28,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
2828 return lines
2929 }
3030
31-func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool {
32- switch snapshot.ProcessState {
33- case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive:
34- default:
35- // A detached worker has no confirmed live connection. Keep it in the
36- // dashboard, rather than presenting an endlessly animated row beside
37- // the input as if it were still making progress.
38- return false
39- }
40-
41- switch snapshot.TurnState {
42- case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled:
43- return false
44- }
45- switch snapshot.Status {
46- case subagents.StatusPending:
47- return true
48- case subagents.StatusRunning:
49- default:
50- return false
51- }
52-
53- switch snapshot.TurnState {
54- case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling:
55- return true
56- }
57-
58- // The worker enters TurnIdle while it waits for a follow-up prompt. Keep a
59- // brief "starting" phase visible, but remove the row as soon as it reports
60- // ordinary idle so an already-completed task never looks active.
61- return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle"
62-}
63-
64-func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string {
31+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, width int, now time.Time) string {
6532 name := sanitizeSubagentIndicatorText(snapshot.Subagent)
6633 if name == "" {
6734 name = sanitizeSubagentIndicatorText(snapshot.ID)
@@ -73,8 +40,8 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
7340 if spinnerGlyph == "" {
7441 spinnerGlyph = "."
7542 }
76- activity := compactSubagentActivity(snapshot)
77- age := formatSubagentActivityAge(snapshot, now)
43+ activity := operation.Label()
44+ age := formatSubagentActivityAge(operation.StartedAt, now)
7845 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
7946 if plain == "" {
8047 return ""
@@ -98,16 +65,9 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
9865 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
9966 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
10067 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])
101- case subagentActivityCompact:
102- prefix := spinnerGlyph + " "
103- suffix := " " + age
104- if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) {
105- name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix)
106- return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
107- th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age)
108- }
68+ default:
69+ return " " + th.FGColor(th.Muted, plain)
10970 }
110- return " " + th.FGColor(th.Muted, plain)
11171 }
11272
11373 type subagentActivityLayout uint8
@@ -119,73 +79,31 @@ const (
11979 )
12080
12181 func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {
122- spinnerGlyph = strings.TrimSpace(spinnerGlyph)
123- if spinnerGlyph == "" {
124- spinnerGlyph = "."
125- }
12682 if width <= 0 {
12783 return "", subagentActivityCompact
12884 }
129-
13085 full := spinnerGlyph + " " + name + " · " + activity + " · " + age
13186 if runewidth.StringWidth(full) <= width {
13287 return full, subagentActivityFull
13388 }
134-
13589 nameAge := spinnerGlyph + " " + name + " · " + age
13690 if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {
137- activity = truncateSubagentIndicatorText(activity, activityWidth)
138- return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull
91+ return spinnerGlyph + " " + name + " · " + truncateSubagentIndicatorText(activity, activityWidth) + " · " + age, subagentActivityFull
13992 }
14093 if runewidth.StringWidth(nameAge) <= width {
14194 return nameAge, subagentActivityNameAge
14295 }
143-
144- prefix := spinnerGlyph + " "
145- suffix := " " + age
146- nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix)
147- if nameWidth >= 1 {
148- return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact
149- }
150- if runewidth.StringWidth(age) <= width {
151- return age, subagentActivityCompact
152- }
153- return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact
96+ return truncateSubagentIndicatorText(nameAge, width), subagentActivityCompact
15497 }
15598
156-func compactSubagentActivity(snapshot subagents.AgentSnapshot) string {
157- activity := sanitizeSubagentIndicatorText(snapshot.Activity)
158- // Heartbeats report "idle" even while a delegated turn is still running.
159- // Keep the row meaningful in that interval instead of contradicting its
160- // spinner and turn state.
161- if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) {
162- return activity
163- }
164- switch snapshot.TurnState {
165- case subagents.TurnQueued:
166- return "queued"
167- case subagents.TurnCanceling:
168- return "cancelling"
169- default:
170- return "working"
171- }
172-}
173-
174-func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string {
175- last := snapshot.LastActivity
176- if last.IsZero() {
177- last = snapshot.UpdatedAt
178- }
179- if last.IsZero() {
180- last = snapshot.Started
181- }
182- if last.IsZero() {
99+func formatSubagentActivityAge(started, now time.Time) string {
100+ if started.IsZero() {
183101 return "-"
184102 }
185103 if now.IsZero() {
186104 now = time.Now()
187105 }
188- elapsed := now.Sub(last)
106+ elapsed := now.Sub(started)
189107 if elapsed < 0 {
190108 elapsed = 0
191109 }
@@ -196,11 +114,9 @@ func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time)
196114 case seconds < 60*60:
197115 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
198116 case seconds < 24*60*60:
199- minutes := (seconds % (60 * 60)) / 60
200- return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes)
117+ return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
201118 default:
202- hours := (seconds % (24 * 60 * 60)) / (60 * 60)
203- return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours)
119+ return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
204120 }
205121 }
206122
@@ -217,16 +133,8 @@ func truncateSubagentIndicatorText(s string, width int) string {
217133 return runewidth.Truncate(s, width, "...")
218134 }
219135
220-// sanitizeSubagentIndicatorText keeps worker-derived labels on one safe
221-// terminal row before they are measured or styled. A worker's profile, id, or
222-// tool activity is data, never terminal control output.
223-func sanitizeSubagentIndicatorText(s string) string {
224- return sanitizeSessionTreeText(s)
225-}
136+func sanitizeSubagentIndicatorText(s string) string { return sanitizeSessionTreeText(s) }
226137
227-// limitSubagentActivityLines reserves the input's terminal rows before adding
228-// activity rows. When the frame has room for only part of the live set, retain
229-// the earliest rows and make the omitted count explicit in one final row.
230138 func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {
231139 if maxRows <= 0 || len(lines) == 0 {
232140 return nil
@@ -242,20 +150,16 @@ func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int
242150 }
243151
244152 func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {
245- text := fmt.Sprintf(" … %d more active subagents", hidden)
153+ text := fmt.Sprintf(" … %d more open subagent operations", hidden)
246154 return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))
247155 }
248156
249-// activeSubagentActivitySnapshots builds the compact state needed by the
250-// input indicator without copying transcript buffers. It mirrors the
251-// supervisor's live-session filtering while deliberately omitting completed
252-// workers, which belong in the dashboard and completion notification instead.
253-func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot {
157+func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapshot, map[string]subagents.AgentTraceView) {
254158 supervisor := i.cfg.Supervisor
255159 if supervisor == nil {
256- return nil
160+ return nil, nil
257161 }
258-
162+ views := supervisor.TraceViews()
259163 activeSession := supervisor.ActiveSession()
260164 agents := supervisor.List()
261165 out := make([]subagents.AgentSnapshot, 0, len(agents))
@@ -263,20 +167,10 @@ func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapsho
263167 if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
264168 continue
265169 }
266- snapshot := subagents.AgentSnapshot{
267- ID: agent.ID,
268- Status: agent.Status(),
269- ProcessState: agent.ProcessState(),
270- TurnState: agent.TurnState(),
271- Activity: agent.Activity(),
272- Started: agent.Started,
273- UpdatedAt: agent.UpdatedAt(),
274- LastActivity: agent.LastActivity(),
275- Subagent: agent.Subagent,
276- }
277- if subagentTurnIsActive(snapshot) {
278- out = append(out, snapshot)
170+ if len(views[agent.ID].OpenOperations) == 0 {
171+ continue
279172 }
173+ out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})
280174 }
281- return out
175+ return out, views
282176 }
@@ -10,17 +10,17 @@ import (
10 "github.com/mattn/go-runewidth"10 "github.com/mattn/go-runewidth"
11 )11 )
12 12
13-// renderSubagentActivityLines returns one compact, input-adjacent row for each13+// renderSubagentActivityLines renders only trace-observed open operations.
14-// active delegated turn. Terminal and idle workers are intentionally omitted:14+// A living process, generic lifecycle state, or heartbeat never starts a
15-// the /subagents dashboard remains the durable history, while this surface is15+// spinner because none proves that an operation is progressing.
16-// only a live "is it still moving?" signal.16+func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, views map[string]subagents.AgentTraceView, width int, now time.Time) []string {
17-func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []subagents.AgentSnapshot, width int, now time.Time) []string {
18 lines := make([]string, 0, len(snapshots))17 lines := make([]string, 0, len(snapshots))
19 for _, snapshot := range snapshots {18 for _, snapshot := range snapshots {
20- if !subagentTurnIsActive(snapshot) {19+ view := views[snapshot.ID]
20+ if len(view.OpenOperations) == 0 {
21 continue21 continue
22 }22 }
23- line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, width, now)23+ line := renderSubagentActivityLine(th, spinnerGlyph, snapshot, view.OpenOperations[0], width, now)
24 if line != "" {24 if line != "" {
25 lines = append(lines, line)25 lines = append(lines, line)
26 }26 }
@@ -28,40 +28,7 @@ func renderSubagentActivityLines(th tui.Theme, spinnerGlyph string, snapshots []
28 return lines28 return lines
29 }29 }
30 30
31-func subagentTurnIsActive(snapshot subagents.AgentSnapshot) bool {31+func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, operation subagents.Operation, width int, now time.Time) string {
32- switch snapshot.ProcessState {
33- case subagents.ProcessPending, subagents.ProcessStarting, subagents.ProcessAlive:
34- default:
35- // A detached worker has no confirmed live connection. Keep it in the
36- // dashboard, rather than presenting an endlessly animated row beside
37- // the input as if it were still making progress.
38- return false
39- }
40-
41- switch snapshot.TurnState {
42- case subagents.TurnSucceeded, subagents.TurnFailed, subagents.TurnCanceled:
43- return false
44- }
45- switch snapshot.Status {
46- case subagents.StatusPending:
47- return true
48- case subagents.StatusRunning:
49- default:
50- return false
51- }
52-
53- switch snapshot.TurnState {
54- case subagents.TurnQueued, subagents.TurnRunning, subagents.TurnCanceling:
55- return true
56- }
57-
58- // The worker enters TurnIdle while it waits for a follow-up prompt. Keep a
59- // brief "starting" phase visible, but remove the row as soon as it reports
60- // ordinary idle so an already-completed task never looks active.
61- return strings.TrimSpace(snapshot.Activity) != "" && strings.TrimSpace(snapshot.Activity) != "idle"
62-}
63-
64-func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot subagents.AgentSnapshot, width int, now time.Time) string {
65 name := sanitizeSubagentIndicatorText(snapshot.Subagent)32 name := sanitizeSubagentIndicatorText(snapshot.Subagent)
66 if name == "" {33 if name == "" {
67 name = sanitizeSubagentIndicatorText(snapshot.ID)34 name = sanitizeSubagentIndicatorText(snapshot.ID)
@@ -73,8 +40,8 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
73 if spinnerGlyph == "" {40 if spinnerGlyph == "" {
74 spinnerGlyph = "."41 spinnerGlyph = "."
75 }42 }
76- activity := compactSubagentActivity(snapshot)43+ activity := operation.Label()
77- age := formatSubagentActivityAge(snapshot, now)44+ age := formatSubagentActivityAge(operation.StartedAt, now)
78 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)45 plain, layout := fitSubagentActivityLine(spinnerGlyph, name, activity, age, width-2)
79 if plain == "" {46 if plain == "" {
80 return ""47 return ""
@@ -98,16 +65,9 @@ func renderSubagentActivityLine(th tui.Theme, spinnerGlyph string, snapshot suba
98 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")65 name = strings.TrimPrefix(parts[0], spinnerGlyph+" ")
99 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +66 return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
100 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])67 th.FGColor(th.Assistant, name) + th.FGColor(th.Muted, " · "+parts[1])
101- case subagentActivityCompact:68+ default:
102- prefix := spinnerGlyph + " "69+ return " " + th.FGColor(th.Muted, plain)
103- suffix := " " + age
104- if strings.HasPrefix(plain, prefix) && strings.HasSuffix(plain, suffix) {
105- name = strings.TrimSuffix(strings.TrimPrefix(plain, prefix), suffix)
106- return " " + th.FGColor(th.Spinner, spinnerGlyph) + " " +
107- th.FGColor(th.Assistant, name) + " " + th.FGColor(th.Muted, age)
108- }
109 }70 }
110- return " " + th.FGColor(th.Muted, plain)
111 }71 }
112 72
113 type subagentActivityLayout uint873 type subagentActivityLayout uint8
@@ -119,73 +79,31 @@ const (
119 )79 )
120 80
121 func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {81 func fitSubagentActivityLine(spinnerGlyph, name, activity, age string, width int) (string, subagentActivityLayout) {
122- spinnerGlyph = strings.TrimSpace(spinnerGlyph)
123- if spinnerGlyph == "" {
124- spinnerGlyph = "."
125- }
126 if width <= 0 {82 if width <= 0 {
127 return "", subagentActivityCompact83 return "", subagentActivityCompact
128 }84 }
129-
130 full := spinnerGlyph + " " + name + " · " + activity + " · " + age85 full := spinnerGlyph + " " + name + " · " + activity + " · " + age
131 if runewidth.StringWidth(full) <= width {86 if runewidth.StringWidth(full) <= width {
132 return full, subagentActivityFull87 return full, subagentActivityFull
133 }88 }
134-
135 nameAge := spinnerGlyph + " " + name + " · " + age89 nameAge := spinnerGlyph + " " + name + " · " + age
136 if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {90 if activityWidth := width - runewidth.StringWidth(nameAge) - runewidth.StringWidth(" · "); activityWidth >= 4 {
137- activity = truncateSubagentIndicatorText(activity, activityWidth)91+ return spinnerGlyph + " " + name + " · " + truncateSubagentIndicatorText(activity, activityWidth) + " · " + age, subagentActivityFull
138- return spinnerGlyph + " " + name + " · " + activity + " · " + age, subagentActivityFull
139 }92 }
140 if runewidth.StringWidth(nameAge) <= width {93 if runewidth.StringWidth(nameAge) <= width {
141 return nameAge, subagentActivityNameAge94 return nameAge, subagentActivityNameAge
142 }95 }
143-96+ return truncateSubagentIndicatorText(nameAge, width), subagentActivityCompact
144- prefix := spinnerGlyph + " "
145- suffix := " " + age
146- nameWidth := width - runewidth.StringWidth(prefix) - runewidth.StringWidth(suffix)
147- if nameWidth >= 1 {
148- return prefix + truncateSubagentIndicatorText(name, nameWidth) + suffix, subagentActivityCompact
149- }
150- if runewidth.StringWidth(age) <= width {
151- return age, subagentActivityCompact
152- }
153- return truncateSubagentIndicatorText(spinnerGlyph, width), subagentActivityCompact
154 }97 }
155 98
156-func compactSubagentActivity(snapshot subagents.AgentSnapshot) string {99+func formatSubagentActivityAge(started, now time.Time) string {
157- activity := sanitizeSubagentIndicatorText(snapshot.Activity)100+ if started.IsZero() {
158- // Heartbeats report "idle" even while a delegated turn is still running.
159- // Keep the row meaningful in that interval instead of contradicting its
160- // spinner and turn state.
161- if activity != "" && (activity != "idle" || snapshot.TurnState != subagents.TurnRunning) {
162- return activity
163- }
164- switch snapshot.TurnState {
165- case subagents.TurnQueued:
166- return "queued"
167- case subagents.TurnCanceling:
168- return "cancelling"
169- default:
170- return "working"
171- }
172-}
173-
174-func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time) string {
175- last := snapshot.LastActivity
176- if last.IsZero() {
177- last = snapshot.UpdatedAt
178- }
179- if last.IsZero() {
180- last = snapshot.Started
181- }
182- if last.IsZero() {
183 return "-"101 return "-"
184 }102 }
185 if now.IsZero() {103 if now.IsZero() {
186 now = time.Now()104 now = time.Now()
187 }105 }
188- elapsed := now.Sub(last)106+ elapsed := now.Sub(started)
189 if elapsed < 0 {107 if elapsed < 0 {
190 elapsed = 0108 elapsed = 0
191 }109 }
@@ -196,11 +114,9 @@ func formatSubagentActivityAge(snapshot subagents.AgentSnapshot, now time.Time)
196 case seconds < 60*60:114 case seconds < 60*60:
197 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)115 return fmt.Sprintf("%dm%02ds", seconds/60, seconds%60)
198 case seconds < 24*60*60:116 case seconds < 24*60*60:
199- minutes := (seconds % (60 * 60)) / 60117+ return fmt.Sprintf("%dh%02dm", seconds/(60*60), (seconds%(60*60))/60)
200- return fmt.Sprintf("%dh%02dm", seconds/(60*60), minutes)
201 default:118 default:
202- hours := (seconds % (24 * 60 * 60)) / (60 * 60)119+ return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), (seconds%(24*60*60))/(60*60))
203- return fmt.Sprintf("%dd%02dh", seconds/(24*60*60), hours)
204 }120 }
205 }121 }
206 122
@@ -217,16 +133,8 @@ func truncateSubagentIndicatorText(s string, width int) string {
217 return runewidth.Truncate(s, width, "...")133 return runewidth.Truncate(s, width, "...")
218 }134 }
219 135
220-// sanitizeSubagentIndicatorText keeps worker-derived labels on one safe136+func sanitizeSubagentIndicatorText(s string) string { return sanitizeSessionTreeText(s) }
221-// terminal row before they are measured or styled. A worker's profile, id, or
222-// tool activity is data, never terminal control output.
223-func sanitizeSubagentIndicatorText(s string) string {
224- return sanitizeSessionTreeText(s)
225-}
226 137
227-// limitSubagentActivityLines reserves the input's terminal rows before adding
228-// activity rows. When the frame has room for only part of the live set, retain
229-// the earliest rows and make the omitted count explicit in one final row.
230 func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {138 func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int) []string {
231 if maxRows <= 0 || len(lines) == 0 {139 if maxRows <= 0 || len(lines) == 0 {
232 return nil140 return nil
@@ -242,20 +150,16 @@ func limitSubagentActivityLines(th tui.Theme, lines []string, maxRows, width int
242 }150 }
243 151
244 func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {152 func subagentActivityOverflowLine(th tui.Theme, hidden, width int) string {
245- text := fmt.Sprintf(" … %d more active subagents", hidden)153+ text := fmt.Sprintf(" … %d more open subagent operations", hidden)
246 return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))154 return th.FGColor(th.Muted, truncateSubagentIndicatorText(text, width))
247 }155 }
248 156
249-// activeSubagentActivitySnapshots builds the compact state needed by the157+func (i *Interactive) activeSubagentActivitySnapshots() ([]subagents.AgentSnapshot, map[string]subagents.AgentTraceView) {
250-// input indicator without copying transcript buffers. It mirrors the
251-// supervisor's live-session filtering while deliberately omitting completed
252-// workers, which belong in the dashboard and completion notification instead.
253-func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapshot {
254 supervisor := i.cfg.Supervisor158 supervisor := i.cfg.Supervisor
255 if supervisor == nil {159 if supervisor == nil {
256- return nil160+ return nil, nil
257 }161 }
258-162+ views := supervisor.TraceViews()
259 activeSession := supervisor.ActiveSession()163 activeSession := supervisor.ActiveSession()
260 agents := supervisor.List()164 agents := supervisor.List()
261 out := make([]subagents.AgentSnapshot, 0, len(agents))165 out := make([]subagents.AgentSnapshot, 0, len(agents))
@@ -263,20 +167,10 @@ func (i *Interactive) activeSubagentActivitySnapshots() []subagents.AgentSnapsho
263 if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {167 if activeSession != "" && agent.SessionID != "" && agent.SessionID != activeSession {
264 continue168 continue
265 }169 }
266- snapshot := subagents.AgentSnapshot{170+ if len(views[agent.ID].OpenOperations) == 0 {
267- ID: agent.ID,171+ continue
268- Status: agent.Status(),
269- ProcessState: agent.ProcessState(),
270- TurnState: agent.TurnState(),
271- Activity: agent.Activity(),
272- Started: agent.Started,
273- UpdatedAt: agent.UpdatedAt(),
274- LastActivity: agent.LastActivity(),
275- Subagent: agent.Subagent,
276- }
277- if subagentTurnIsActive(snapshot) {
278- out = append(out, snapshot)
279 }172 }
173+ out = append(out, subagents.AgentSnapshot{ID: agent.ID, Started: agent.Started, Subagent: agent.Subagent})
280 }174 }
281- return out175+ return out, views
282 }176 }
M packages/agent/modes/subagent_indicator_test.go +26 -328
@@ -1,101 +1,27 @@
11 package modes
22
33 import (
4- "context"
54 "strings"
6- "sync"
75 "testing"
86 "time"
9- "unicode"
107
11- "github.com/bnema/zut/packages/agent/extproto"
128 "github.com/bnema/zut/packages/agent/subagents"
139 "github.com/bnema/zut/packages/tui"
1410 "github.com/mattn/go-runewidth"
1511 )
1612
17-type sizedSubagentIndicatorTerminal struct {
18- *alertTestTerminal
19- cols int
20- rows int
21-}
22-
23-func (t *sizedSubagentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
24-
25-func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) {
13+func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
2614 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
27- lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{
28- {
29- ID: "design-123",
30- Subagent: "senior-engineer",
31- Status: subagents.StatusPending,
32- ProcessState: subagents.ProcessPending,
33- TurnState: subagents.TurnQueued,
34- Activity: "queued",
35- LastActivity: now.Add(-4 * time.Second),
36- },
37- {
38- ID: "tests-456",
39- Subagent: "",
40- Status: subagents.StatusRunning,
41- ProcessState: subagents.ProcessAlive,
42- TurnState: subagents.TurnRunning,
43- Activity: "tool: read",
44- LastActivity: now.Add(-72 * time.Second),
45- },
46- {
47- ID: "heartbeat-789",
48- Subagent: "implementer",
49- Status: subagents.StatusRunning,
50- ProcessState: subagents.ProcessAlive,
51- TurnState: subagents.TurnRunning,
52- Activity: "idle",
53- LastActivity: now.Add(-5 * time.Second),
54- },
55- {
56- ID: "idle-789",
57- Subagent: "reviewer",
58- Status: subagents.StatusRunning,
59- ProcessState: subagents.ProcessAlive,
60- TurnState: subagents.TurnIdle,
61- Activity: "idle",
62- LastActivity: now.Add(-time.Second),
63- },
64- {
65- ID: "settled-012",
66- Subagent: "implementer",
67- Status: subagents.StatusPending,
68- ProcessState: subagents.ProcessPending,
69- TurnState: subagents.TurnSucceeded,
70- Activity: "done",
71- LastActivity: now.Add(-time.Second),
72- },
73- {
74- ID: "done-345",
75- Subagent: "implementer",
76- Status: subagents.StatusDone,
77- ProcessState: subagents.ProcessExited,
78- TurnState: subagents.TurnSucceeded,
79- Activity: "done",
80- LastActivity: now.Add(-time.Second),
81- },
82- {
83- ID: "detached-678",
84- Subagent: "reviewer",
85- Status: subagents.StatusRunning,
86- ProcessState: subagents.ProcessDetached,
87- TurnState: subagents.TurnRunning,
88- Activity: "detached",
89- LastActivity: now.Add(-time.Second),
90- },
91- }, 80, now)
92-
93- got := plainActivityLines(lines)
94- want := []string{
95- " / senior-engineer · queued · 4s",
96- " / tests-456 · tool: read · 1m12s",
97- " / implementer · working · 5s",
15+ snapshots := []subagents.AgentSnapshot{
16+ {ID: "open", Subagent: "reviewer"},
17+ {ID: "alive", Subagent: "implementer", Activity: "working"},
18+ }
19+ views := map[string]subagents.AgentTraceView{
20+ "open": {AgentID: "open", OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}},
21+ "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}},
9822 }
23+ got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now))
24+ want := []string{" / reviewer · tool open · 1m12s"}
9925 if strings.Join(got, "\n") != strings.Join(want, "\n") {
10026 t.Fatalf("activity lines = %#v, want %#v", got, want)
10127 }
@@ -103,53 +29,26 @@ func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) {
10329
10430 func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
10531 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
106- const width = 27
107- lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{
108- ID: "review-123",
109- Subagent: "reviewer",
110- Status: subagents.StatusRunning,
111- ProcessState: subagents.ProcessAlive,
112- TurnState: subagents.TurnRunning,
113- Activity: "tool: read an exceptionally long filename",
114- LastActivity: now.Add(-72 * time.Second),
115- }}, width, now)
116-
32+ lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
33+ "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}},
34+ }, 27, now)
11735 if len(lines) != 1 {
11836 t.Fatalf("line count = %d, want 1", len(lines))
11937 }
12038 line := plainActivityLines(lines)[0]
121- if runewidth.StringWidth(line) > width {
122- t.Fatalf("line width = %d, want <= %d: %q", runewidth.StringWidth(line), width, line)
123- }
124- for _, want := range []string{"reviewer", "1m12s"} {
125- if !strings.Contains(line, want) {
126- t.Fatalf("narrow line %q is missing %q", line, want)
127- }
39+ if runewidth.StringWidth(line) > 27 || !strings.Contains(line, "reviewer") || !strings.Contains(line, "1m12s") {
40+ t.Fatalf("narrow line = %q", line)
12841 }
12942 }
13043
13144 func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) {
13245 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
133- lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{
134- ID: "review-123",
135- Subagent: "reviewer\x1b]52;c;ignored\a",
136- Status: subagents.StatusRunning,
137- ProcessState: subagents.ProcessAlive,
138- TurnState: subagents.TurnRunning,
139- Activity: "tool:\tread\x1b[2J\rfile",
140- LastActivity: now.Add(-time.Second),
141- }}, 80, now)
142- if len(lines) != 1 {
143- t.Fatalf("line count = %d, want 1", len(lines))
144- }
46+ lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer\x1b]52;c;ignored\a"}}, map[string]subagents.AgentTraceView{
47+ "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-time.Second)}}},
48+ }, 80, now)
14549 plain := plainActivityLines(lines)[0]
146- if strings.IndexFunc(plain, unicode.IsControl) >= 0 {
147- t.Fatalf("indicator retained a control character: %q", plain)
148- }
149- for _, want := range []string{"reviewer", "tool: read file"} {
150- if !strings.Contains(plain, want) {
151- t.Fatalf("sanitized indicator %q is missing %q", plain, want)
152- }
50+ if strings.ContainsRune(plain, '\x1b') || !strings.Contains(plain, "reviewer") {
51+ t.Fatalf("sanitized indicator = %q", plain)
15352 }
15453 }
15554
@@ -159,222 +58,21 @@ func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
15958 }
16059 }
16160
162-func TestFormatSubagentActivityAgeFallsBackToLifecycleTimes(t *testing.T) {
61+func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) {
16362 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
164- if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{UpdatedAt: now.Add(-7 * time.Second)}, now), "7s"; got != want {
165- t.Fatalf("updated-at age = %q, want %q", got, want)
166- }
167- if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{Started: now.Add(-8 * time.Second)}, now), "8s"; got != want {
168- t.Fatalf("started-at age = %q, want %q", got, want)
63+ if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want {
64+ t.Fatalf("age = %q, want %q", got, want)
16965 }
17066 }
17167
172-func TestLimitSubagentActivityLinesSummarizesOmittedWorkers(t *testing.T) {
173- lines := []string{"one", "two", "three", "four"}
174- got := plainActivityLines(limitSubagentActivityLines(tui.Dark, lines, 3, 80))
175- want := []string{"one", "two", " … 2 more active subagents"}
68+func TestLimitSubagentActivityLinesSummarizesOmittedOperations(t *testing.T) {
69+ got := plainActivityLines(limitSubagentActivityLines(tui.Dark, []string{"one", "two", "three", "four"}, 3, 80))
70+ want := []string{"one", "two", " … 2 more open subagent operations"}
17671 if strings.Join(got, "\n") != strings.Join(want, "\n") {
17772 t.Fatalf("limited lines = %#v, want %#v", got, want)
17873 }
17974 }
18075
181-func TestSubagentActivityRowsFitFixedRightBarFrame(t *testing.T) {
182- release := make(chan struct{})
183- supervisor := subagents.New(subagents.Config{
184- Root: t.TempDir(),
185- RepoRoot: t.TempDir(),
186- NewRunner: func(*subagents.Agent) subagents.Runner {
187- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
188- sink.Activity("thinking")
189- select {
190- case <-release:
191- return nil
192- case <-ctx.Done():
193- return ctx.Err()
194- }
195- })
196- },
197- })
198- defer func() {
199- close(release)
200- supervisor.StopAll()
201- }()
202- for n := 0; n < 7; n++ {
203- if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
204- Task: "inspect layout",
205- Subagent: "reviewer",
206- }); err != nil {
207- t.Fatal(err)
208- }
209- }
210-
211- term := &sizedSubagentIndicatorTerminal{
212- alertTestTerminal: &alertTestTerminal{},
213- cols: 80,
214- rows: 8,
215- }
216- interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
217- interactive.rend.Resize(80, 8)
218- interactive.SetWidget("test", "right", extproto.WidgetPositionRightBar, "status", nil)
219- interactive.redraw()
220-
221- output := stripANSIBytes(term.String())
222- if !strings.Contains(output, "▌") {
223- t.Fatalf("input was pushed out of the fixed frame: %q", output)
224- }
225- if !strings.Contains(output, "more active subagents") {
226- t.Fatalf("omitted subagents were not summarized: %q", output)
227- }
228- if strings.Contains(term.String(), "\x1b[-") {
229- t.Fatalf("renderer received a negative cursor row: %q", term.String())
230- }
231-}
232-
233-func TestSubagentActivityLinesFollowInputPosition(t *testing.T) {
234- started := make(chan struct{})
235- release := make(chan struct{})
236- var releaseOnce sync.Once
237- releaseRunner := func() { releaseOnce.Do(func() { close(release) }) }
238- supervisor := subagents.New(subagents.Config{
239- Root: t.TempDir(),
240- RepoRoot: t.TempDir(),
241- NewRunner: func(*subagents.Agent) subagents.Runner {
242- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
243- sink.Activity("thinking")
244- close(started)
245- select {
246- case <-release:
247- return nil
248- case <-ctx.Done():
249- return ctx.Err()
250- }
251- })
252- },
253- })
254- defer func() {
255- releaseRunner()
256- supervisor.StopAll()
257- }()
258- agent, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
259- Task: "inspect the input layout",
260- Subagent: "senior-engineer",
261- })
262- if err != nil {
263- t.Fatal(err)
264- }
265- select {
266- case <-started:
267- case <-time.After(time.Second):
268- t.Fatal("subagent runner did not start")
269- }
270-
271- assertPosition := func(t *testing.T, position string, wantBelow bool) {
272- t.Helper()
273- term := &alertTestTerminal{}
274- interactive := NewInteractive(InteractiveConfig{
275- Terminal: term,
276- Theme: tui.Dark,
277- Supervisor: supervisor,
278- TUISubagentPosition: position,
279- })
280- interactive.clock = func() time.Time { return time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) }
281- interactive.rend.Resize(80, 24)
282- interactive.redraw()
283-
284- output := stripANSIBytes(term.String())
285- inputAt := strings.LastIndex(output, "▌")
286- subagentAt := strings.LastIndex(output, "senior-engineer")
287- if inputAt < 0 || subagentAt < 0 {
288- t.Fatalf("rendered input/subagent missing from %q", output)
289- }
290- if gotBelow := subagentAt > inputAt; gotBelow != wantBelow {
291- t.Fatalf("subagent below input = %v, want %v: %q", gotBelow, wantBelow, output)
292- }
293- }
294-
295- t.Run("default below", func(t *testing.T) {
296- assertPosition(t, "", true)
297- })
298- t.Run("configured above", func(t *testing.T) {
299- assertPosition(t, tui.SubagentPositionAboveInput, false)
300- })
301- t.Run("dashboard editor suppresses inline animation", func(t *testing.T) {
302- term := &alertTestTerminal{}
303- interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
304- interactive.rend.Resize(80, 24)
305- interactive.subagentsDialog.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
306- interactive.subagentsDialog.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
307- if interactive.subagentsDialog.NeedsTickRefresh() {
308- t.Fatal("subagent spawn editor should suppress dashboard ticks")
309- }
310- interactive.redraw()
311- if interactive.subagentActivityActive {
312- t.Fatal("hidden inline indicators should not request animation ticks")
313- }
314- })
315-
316- releaseRunner()
317- agent.Wait()
318- term := &alertTestTerminal{}
319- interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
320- interactive.rend.Resize(80, 24)
321- interactive.redraw()
322- if output := stripANSIBytes(term.String()); strings.Contains(output, "senior-engineer") {
323- t.Fatalf("finished subagent indicator remained visible: %q", output)
324- }
325-}
326-
327-func TestSetSubagentSessionScopeRefreshesActivityFilter(t *testing.T) {
328- release := make(chan struct{})
329- supervisor := subagents.New(subagents.Config{
330- Root: t.TempDir(),
331- RepoRoot: t.TempDir(),
332- NewRunner: func(*subagents.Agent) subagents.Runner {
333- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
334- select {
335- case <-release:
336- return nil
337- case <-ctx.Done():
338- return ctx.Err()
339- }
340- })
341- },
342- })
343- defer func() {
344- close(release)
345- supervisor.StopAll()
346- }()
347- supervisor.SetActiveSession("session-a")
348- if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
349- Task: "inspect scope",
350- Subagent: "reviewer",
351- }); err != nil {
352- t.Fatal(err)
353- }
354-
355- interactive := NewInteractive(InteractiveConfig{Supervisor: supervisor})
356- if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
357- t.Fatalf("active rows in initial scope = %d, want 1", got)
358- }
359- interactive.SetSubagentSessionScope("session-b")
360- if got := supervisor.ActiveSession(); got != "session-b" {
361- t.Fatalf("active session = %q, want session-b", got)
362- }
363- select {
364- case <-interactive.dirty:
365- case <-time.After(time.Second):
366- t.Fatal("session scope change did not invalidate the interactive view")
367- }
368- if got := len(interactive.activeSubagentActivitySnapshots()); got != 0 {
369- t.Fatalf("active rows after scope change = %d, want 0", got)
370- }
371-
372- interactive.SetSubagentSessionScope("session-a")
373- if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
374- t.Fatalf("active rows after returning to scope = %d, want 1", got)
375- }
376-}
377-
37876 func plainActivityLines(lines []string) []string {
37977 plain := make([]string, len(lines))
38078 for idx, line := range lines {
@@ -1,101 +1,27 @@
1 package modes1 package modes
2 2
3 import (3 import (
4- "context"
5 "strings"4 "strings"
6- "sync"
7 "testing"5 "testing"
8 "time"6 "time"
9- "unicode"
10 7
11- "github.com/bnema/zut/packages/agent/extproto"
12 "github.com/bnema/zut/packages/agent/subagents"8 "github.com/bnema/zut/packages/agent/subagents"
13 "github.com/bnema/zut/packages/tui"9 "github.com/bnema/zut/packages/tui"
14 "github.com/mattn/go-runewidth"10 "github.com/mattn/go-runewidth"
15 )11 )
16 12
17-type sizedSubagentIndicatorTerminal struct {13+func TestRenderSubagentActivityLinesShowsOnlyTraceOpenOperations(t *testing.T) {
18- *alertTestTerminal
19- cols int
20- rows int
21-}
22-
23-func (t *sizedSubagentIndicatorTerminal) Size() (int, int) { return t.cols, t.rows }
24-
25-func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) {
26 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)14 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
27- lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{15+ snapshots := []subagents.AgentSnapshot{
28- {16+ {ID: "open", Subagent: "reviewer"},
29- ID: "design-123",17+ {ID: "alive", Subagent: "implementer", Activity: "working"},
30- Subagent: "senior-engineer",18+ }
31- Status: subagents.StatusPending,19+ views := map[string]subagents.AgentTraceView{
32- ProcessState: subagents.ProcessPending,20+ "open": {AgentID: "open", OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}},
33- TurnState: subagents.TurnQueued,21+ "alive": {AgentID: "alive", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(-time.Second)}},
34- Activity: "queued",
35- LastActivity: now.Add(-4 * time.Second),
36- },
37- {
38- ID: "tests-456",
39- Subagent: "",
40- Status: subagents.StatusRunning,
41- ProcessState: subagents.ProcessAlive,
42- TurnState: subagents.TurnRunning,
43- Activity: "tool: read",
44- LastActivity: now.Add(-72 * time.Second),
45- },
46- {
47- ID: "heartbeat-789",
48- Subagent: "implementer",
49- Status: subagents.StatusRunning,
50- ProcessState: subagents.ProcessAlive,
51- TurnState: subagents.TurnRunning,
52- Activity: "idle",
53- LastActivity: now.Add(-5 * time.Second),
54- },
55- {
56- ID: "idle-789",
57- Subagent: "reviewer",
58- Status: subagents.StatusRunning,
59- ProcessState: subagents.ProcessAlive,
60- TurnState: subagents.TurnIdle,
61- Activity: "idle",
62- LastActivity: now.Add(-time.Second),
63- },
64- {
65- ID: "settled-012",
66- Subagent: "implementer",
67- Status: subagents.StatusPending,
68- ProcessState: subagents.ProcessPending,
69- TurnState: subagents.TurnSucceeded,
70- Activity: "done",
71- LastActivity: now.Add(-time.Second),
72- },
73- {
74- ID: "done-345",
75- Subagent: "implementer",
76- Status: subagents.StatusDone,
77- ProcessState: subagents.ProcessExited,
78- TurnState: subagents.TurnSucceeded,
79- Activity: "done",
80- LastActivity: now.Add(-time.Second),
81- },
82- {
83- ID: "detached-678",
84- Subagent: "reviewer",
85- Status: subagents.StatusRunning,
86- ProcessState: subagents.ProcessDetached,
87- TurnState: subagents.TurnRunning,
88- Activity: "detached",
89- LastActivity: now.Add(-time.Second),
90- },
91- }, 80, now)
92-
93- got := plainActivityLines(lines)
94- want := []string{
95- " / senior-engineer · queued · 4s",
96- " / tests-456 · tool: read · 1m12s",
97- " / implementer · working · 5s",
98 }22 }
23+ got := plainActivityLines(renderSubagentActivityLines(tui.Dark, "/", snapshots, views, 80, now))
24+ want := []string{" / reviewer · tool open · 1m12s"}
99 if strings.Join(got, "\n") != strings.Join(want, "\n") {25 if strings.Join(got, "\n") != strings.Join(want, "\n") {
100 t.Fatalf("activity lines = %#v, want %#v", got, want)26 t.Fatalf("activity lines = %#v, want %#v", got, want)
101 }27 }
@@ -103,53 +29,26 @@ func TestRenderSubagentActivityLinesShowsOnlyActiveTurns(t *testing.T) {
103 29
104 func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {30 func TestRenderSubagentActivityLinesKeepsNameAndAgeOnNarrowTerminals(t *testing.T) {
105 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)31 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
106- const width = 2732+ lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer"}}, map[string]subagents.AgentTraceView{
107- lines := renderSubagentActivityLines(tui.Dark, "|", []subagents.AgentSnapshot{{33+ "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-72 * time.Second)}}},
108- ID: "review-123",34+ }, 27, now)
109- Subagent: "reviewer",
110- Status: subagents.StatusRunning,
111- ProcessState: subagents.ProcessAlive,
112- TurnState: subagents.TurnRunning,
113- Activity: "tool: read an exceptionally long filename",
114- LastActivity: now.Add(-72 * time.Second),
115- }}, width, now)
116-
117 if len(lines) != 1 {35 if len(lines) != 1 {
118 t.Fatalf("line count = %d, want 1", len(lines))36 t.Fatalf("line count = %d, want 1", len(lines))
119 }37 }
120 line := plainActivityLines(lines)[0]38 line := plainActivityLines(lines)[0]
121- if runewidth.StringWidth(line) > width {39+ if runewidth.StringWidth(line) > 27 || !strings.Contains(line, "reviewer") || !strings.Contains(line, "1m12s") {
122- t.Fatalf("line width = %d, want <= %d: %q", runewidth.StringWidth(line), width, line)40+ t.Fatalf("narrow line = %q", line)
123- }
124- for _, want := range []string{"reviewer", "1m12s"} {
125- if !strings.Contains(line, want) {
126- t.Fatalf("narrow line %q is missing %q", line, want)
127- }
128 }41 }
129 }42 }
130 43
131 func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) {44 func TestRenderSubagentActivityLinesSanitizesWorkerText(t *testing.T) {
132 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)45 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
133- lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{46+ lines := renderSubagentActivityLines(tui.Dark, "/", []subagents.AgentSnapshot{{ID: "review-123", Subagent: "reviewer\x1b]52;c;ignored\a"}}, map[string]subagents.AgentTraceView{
134- ID: "review-123",47+ "review-123": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now.Add(-time.Second)}}},
135- Subagent: "reviewer\x1b]52;c;ignored\a",48+ }, 80, now)
136- Status: subagents.StatusRunning,
137- ProcessState: subagents.ProcessAlive,
138- TurnState: subagents.TurnRunning,
139- Activity: "tool:\tread\x1b[2J\rfile",
140- LastActivity: now.Add(-time.Second),
141- }}, 80, now)
142- if len(lines) != 1 {
143- t.Fatalf("line count = %d, want 1", len(lines))
144- }
145 plain := plainActivityLines(lines)[0]49 plain := plainActivityLines(lines)[0]
146- if strings.IndexFunc(plain, unicode.IsControl) >= 0 {50+ if strings.ContainsRune(plain, '\x1b') || !strings.Contains(plain, "reviewer") {
147- t.Fatalf("indicator retained a control character: %q", plain)51+ t.Fatalf("sanitized indicator = %q", plain)
148- }
149- for _, want := range []string{"reviewer", "tool: read file"} {
150- if !strings.Contains(plain, want) {
151- t.Fatalf("sanitized indicator %q is missing %q", plain, want)
152- }
153 }52 }
154 }53 }
155 54
@@ -159,222 +58,21 @@ func TestTruncateSubagentIndicatorTextPreservesGraphemeClusters(t *testing.T) {
159 }58 }
160 }59 }
161 60
162-func TestFormatSubagentActivityAgeFallsBackToLifecycleTimes(t *testing.T) {61+func TestFormatSubagentActivityAgeUsesOperationStart(t *testing.T) {
163 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)62 now := time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC)
164- if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{UpdatedAt: now.Add(-7 * time.Second)}, now), "7s"; got != want {63+ if got, want := formatSubagentActivityAge(now.Add(-7*time.Second), now), "7s"; got != want {
165- t.Fatalf("updated-at age = %q, want %q", got, want)64+ t.Fatalf("age = %q, want %q", got, want)
166- }
167- if got, want := formatSubagentActivityAge(subagents.AgentSnapshot{Started: now.Add(-8 * time.Second)}, now), "8s"; got != want {
168- t.Fatalf("started-at age = %q, want %q", got, want)
169 }65 }
170 }66 }
171 67
172-func TestLimitSubagentActivityLinesSummarizesOmittedWorkers(t *testing.T) {68+func TestLimitSubagentActivityLinesSummarizesOmittedOperations(t *testing.T) {
173- lines := []string{"one", "two", "three", "four"}69+ got := plainActivityLines(limitSubagentActivityLines(tui.Dark, []string{"one", "two", "three", "four"}, 3, 80))
174- got := plainActivityLines(limitSubagentActivityLines(tui.Dark, lines, 3, 80))70+ want := []string{"one", "two", " … 2 more open subagent operations"}
175- want := []string{"one", "two", " … 2 more active subagents"}
176 if strings.Join(got, "\n") != strings.Join(want, "\n") {71 if strings.Join(got, "\n") != strings.Join(want, "\n") {
177 t.Fatalf("limited lines = %#v, want %#v", got, want)72 t.Fatalf("limited lines = %#v, want %#v", got, want)
178 }73 }
179 }74 }
180 75
181-func TestSubagentActivityRowsFitFixedRightBarFrame(t *testing.T) {
182- release := make(chan struct{})
183- supervisor := subagents.New(subagents.Config{
184- Root: t.TempDir(),
185- RepoRoot: t.TempDir(),
186- NewRunner: func(*subagents.Agent) subagents.Runner {
187- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
188- sink.Activity("thinking")
189- select {
190- case <-release:
191- return nil
192- case <-ctx.Done():
193- return ctx.Err()
194- }
195- })
196- },
197- })
198- defer func() {
199- close(release)
200- supervisor.StopAll()
201- }()
202- for n := 0; n < 7; n++ {
203- if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
204- Task: "inspect layout",
205- Subagent: "reviewer",
206- }); err != nil {
207- t.Fatal(err)
208- }
209- }
210-
211- term := &sizedSubagentIndicatorTerminal{
212- alertTestTerminal: &alertTestTerminal{},
213- cols: 80,
214- rows: 8,
215- }
216- interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
217- interactive.rend.Resize(80, 8)
218- interactive.SetWidget("test", "right", extproto.WidgetPositionRightBar, "status", nil)
219- interactive.redraw()
220-
221- output := stripANSIBytes(term.String())
222- if !strings.Contains(output, "▌") {
223- t.Fatalf("input was pushed out of the fixed frame: %q", output)
224- }
225- if !strings.Contains(output, "more active subagents") {
226- t.Fatalf("omitted subagents were not summarized: %q", output)
227- }
228- if strings.Contains(term.String(), "\x1b[-") {
229- t.Fatalf("renderer received a negative cursor row: %q", term.String())
230- }
231-}
232-
233-func TestSubagentActivityLinesFollowInputPosition(t *testing.T) {
234- started := make(chan struct{})
235- release := make(chan struct{})
236- var releaseOnce sync.Once
237- releaseRunner := func() { releaseOnce.Do(func() { close(release) }) }
238- supervisor := subagents.New(subagents.Config{
239- Root: t.TempDir(),
240- RepoRoot: t.TempDir(),
241- NewRunner: func(*subagents.Agent) subagents.Runner {
242- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
243- sink.Activity("thinking")
244- close(started)
245- select {
246- case <-release:
247- return nil
248- case <-ctx.Done():
249- return ctx.Err()
250- }
251- })
252- },
253- })
254- defer func() {
255- releaseRunner()
256- supervisor.StopAll()
257- }()
258- agent, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
259- Task: "inspect the input layout",
260- Subagent: "senior-engineer",
261- })
262- if err != nil {
263- t.Fatal(err)
264- }
265- select {
266- case <-started:
267- case <-time.After(time.Second):
268- t.Fatal("subagent runner did not start")
269- }
270-
271- assertPosition := func(t *testing.T, position string, wantBelow bool) {
272- t.Helper()
273- term := &alertTestTerminal{}
274- interactive := NewInteractive(InteractiveConfig{
275- Terminal: term,
276- Theme: tui.Dark,
277- Supervisor: supervisor,
278- TUISubagentPosition: position,
279- })
280- interactive.clock = func() time.Time { return time.Date(2026, time.August, 6, 12, 0, 0, 0, time.UTC) }
281- interactive.rend.Resize(80, 24)
282- interactive.redraw()
283-
284- output := stripANSIBytes(term.String())
285- inputAt := strings.LastIndex(output, "▌")
286- subagentAt := strings.LastIndex(output, "senior-engineer")
287- if inputAt < 0 || subagentAt < 0 {
288- t.Fatalf("rendered input/subagent missing from %q", output)
289- }
290- if gotBelow := subagentAt > inputAt; gotBelow != wantBelow {
291- t.Fatalf("subagent below input = %v, want %v: %q", gotBelow, wantBelow, output)
292- }
293- }
294-
295- t.Run("default below", func(t *testing.T) {
296- assertPosition(t, "", true)
297- })
298- t.Run("configured above", func(t *testing.T) {
299- assertPosition(t, tui.SubagentPositionAboveInput, false)
300- })
301- t.Run("dashboard editor suppresses inline animation", func(t *testing.T) {
302- term := &alertTestTerminal{}
303- interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
304- interactive.rend.Resize(80, 24)
305- interactive.subagentsDialog.Open(staticSnapshots(), nil, nil, func(string, string, string) error { return nil }, nil, nil, "")
306- interactive.subagentsDialog.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'n'})
307- if interactive.subagentsDialog.NeedsTickRefresh() {
308- t.Fatal("subagent spawn editor should suppress dashboard ticks")
309- }
310- interactive.redraw()
311- if interactive.subagentActivityActive {
312- t.Fatal("hidden inline indicators should not request animation ticks")
313- }
314- })
315-
316- releaseRunner()
317- agent.Wait()
318- term := &alertTestTerminal{}
319- interactive := NewInteractive(InteractiveConfig{Terminal: term, Theme: tui.Dark, Supervisor: supervisor})
320- interactive.rend.Resize(80, 24)
321- interactive.redraw()
322- if output := stripANSIBytes(term.String()); strings.Contains(output, "senior-engineer") {
323- t.Fatalf("finished subagent indicator remained visible: %q", output)
324- }
325-}
326-
327-func TestSetSubagentSessionScopeRefreshesActivityFilter(t *testing.T) {
328- release := make(chan struct{})
329- supervisor := subagents.New(subagents.Config{
330- Root: t.TempDir(),
331- RepoRoot: t.TempDir(),
332- NewRunner: func(*subagents.Agent) subagents.Runner {
333- return subagents.RunnerFunc(func(ctx context.Context, sink subagents.Sink) error {
334- select {
335- case <-release:
336- return nil
337- case <-ctx.Done():
338- return ctx.Err()
339- }
340- })
341- },
342- })
343- defer func() {
344- close(release)
345- supervisor.StopAll()
346- }()
347- supervisor.SetActiveSession("session-a")
348- if _, err := supervisor.SpawnReq(context.Background(), subagents.SpawnRequest{
349- Task: "inspect scope",
350- Subagent: "reviewer",
351- }); err != nil {
352- t.Fatal(err)
353- }
354-
355- interactive := NewInteractive(InteractiveConfig{Supervisor: supervisor})
356- if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
357- t.Fatalf("active rows in initial scope = %d, want 1", got)
358- }
359- interactive.SetSubagentSessionScope("session-b")
360- if got := supervisor.ActiveSession(); got != "session-b" {
361- t.Fatalf("active session = %q, want session-b", got)
362- }
363- select {
364- case <-interactive.dirty:
365- case <-time.After(time.Second):
366- t.Fatal("session scope change did not invalidate the interactive view")
367- }
368- if got := len(interactive.activeSubagentActivitySnapshots()); got != 0 {
369- t.Fatalf("active rows after scope change = %d, want 0", got)
370- }
371-
372- interactive.SetSubagentSessionScope("session-a")
373- if got := len(interactive.activeSubagentActivitySnapshots()); got != 1 {
374- t.Fatalf("active rows after returning to scope = %d, want 1", got)
375- }
376-}
377-
378 func plainActivityLines(lines []string) []string {76 func plainActivityLines(lines []string) []string {
379 plain := make([]string, len(lines))77 plain := make([]string, len(lines))
380 for idx, line := range lines {78 for idx, line := range lines {
M packages/agent/modes/subagents_dialog.go +51 -38
@@ -39,6 +39,7 @@ type subagentsDialog struct {
3939 lineInput bool
4040 snapshot func() []subagents.AgentSnapshot
4141 allSnapshots func() []subagents.AgentSnapshot
42+ traceViews func() map[string]subagents.AgentTraceView
4243 showAll bool
4344 expandedID string
4445 stop func(id string) error
@@ -139,6 +140,12 @@ func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) {
139140 d.allSnapshots = fn
140141 }
141142
143+// SetTraceViews supplies the shared factual execution projection. Lifecycle
144+// snapshots remain only for identity, transcript, and process-control wiring.
145+func (d *subagentsDialog) SetTraceViews(fn func() map[string]subagents.AgentTraceView) {
146+ d.traceViews = fn
147+}
148+
142149 // SetMaxRows bounds the complete dashboard before the host composes its
143150 // bottom-sticky dialog band. Four rows are needed for the frame, column
144151 // heading, and selected agent; a non-positive value leaves it unbounded.
@@ -337,7 +344,7 @@ func (d *subagentsDialog) transcriptEditorCursorRow(width, popupRows, editorRowO
337344 }
338345
339346 row += popupRows // @-file-suggest popup, if active
340- if agentIsBusy(a) {
347+ if a.Status == subagents.StatusRunning && d.send != nil && d.agentHasOpenOperation(a.ID) {
341348 row += 2 // blank + spinner row inserted above the editor
342349 }
343350 row++ // blank above editor (appendTranscriptEditor)
@@ -1208,7 +1215,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
12081215 }
12091216
12101217 // Column header for readability.
1211- header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")
1218+ header := fmt.Sprintf(" %-30s %-26s %-8s %s", "OPERATION", "ID", "AGE", "TASK")
12121219 out = append(out, th.FGColor(th.Muted, header))
12131220 out = append(out, d.renderVisibleRows(th, width)...)
12141221 out = append(out, frameRule(th, width))
@@ -1236,7 +1243,7 @@ func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string {
12361243 nextRow := d.cursor
12371244 for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {
12381245 r := d.rows[nextRow]
1239- row := formatSupervisorRow(r, width-2)
1246+ row := formatSupervisorRow(r, d.traceView(r.ID), width-2)
12401247 if nextRow == d.cursor {
12411248 out = append(out, th.PadHighlight(" "+row, width))
12421249 } else {
@@ -1297,7 +1304,7 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
12971304 frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),
12981305 " " + th.FGColor(th.Muted, "task: "+a.Task),
12991306 " " + th.FGColor(th.Muted, "dir: "+a.Dir),
1300- " " + th.FGColor(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),
1307+ " " + th.FGColor(th.Muted, "observation: "+d.traceSummary(a.ID)),
13011308 }
13021309 if a.Model != "" {
13031310 modelLine := "model: " + a.Model
@@ -1413,12 +1420,9 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
14131420 }
14141421 }
14151422
1416- // Busy-spinner row, /btw-style. Shown whenever the agent's
1417- // current activity isn't "idle" — so "thinking", "tool: foo",
1418- // "starting", etc. all animate. The spinner is stopped (and the
1419- // row hidden) once the agent reports idle, mirroring the main
1420- // chat's busy line.
1421- if agentIsBusy(a) {
1423+ // Busy-spinner row, /btw-style. It is shown only for a factual open
1424+ // trace operation, never for a lifecycle or heartbeat label.
1425+ if d.agentHasOpenOperation(a.ID) {
14221426 if d.transcriptSpin == nil {
14231427 d.transcriptSpin = newSpinner(th)
14241428 d.transcriptSpin.Start()
@@ -1428,7 +1432,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
14281432 out = append(out, "")
14291433 prefix := fmt.Sprintf("%s %s, %s",
14301434 th.FGColor(th.Assistant, d.transcriptSpin.Frame()),
1431- th.FGColor(th.Assistant, a.Activity),
1435+ th.FGColor(th.Assistant, d.traceSummary(a.ID)),
14321436 th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()),
14331437 )
14341438 out = append(out, " "+prefix)
@@ -1448,23 +1452,22 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
14481452 return out
14491453 }
14501454
1451-// agentIsBusy reports whether the agent is currently mid-turn and
1452-// should render a spinner. Conservative: only known idle markers
1453-// suppress the spinner so transient / unfamiliar activity strings
1454-// still animate. Detached / done / failed / killed never animate —
1455-// they aren't actively producing output.
1456-func agentIsBusy(a *subagents.AgentSnapshot) bool {
1457- if a == nil {
1458- return false
1455+func (d *subagentsDialog) agentHasOpenOperation(id string) bool {
1456+ return len(d.traceView(id).OpenOperations) != 0
1457+}
1458+
1459+func (d *subagentsDialog) traceSummary(id string) string {
1460+ view := d.traceView(id)
1461+ if view.Terminal != "" {
1462+ return view.Terminal
14591463 }
1460- if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending {
1461- return false
1464+ if len(view.OpenOperations) != 0 {
1465+ return view.OpenOperations[0].Label()
14621466 }
1463- switch strings.TrimSpace(a.Activity) {
1464- case "", "idle":
1465- return false
1467+ if view.LastEvent.Type != "" {
1468+ return "last event " + view.LastEvent.Type
14661469 }
1467- return true
1470+ return "no observable operation"
14681471 }
14691472
14701473 // renderSupervisorTranscriptBlocks converts the agent's flat transcript
@@ -1615,28 +1618,38 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri
16151618 return out
16161619 }
16171620
1621+// traceView retrieves the latest shared factual trace projection for id.
1622+func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView {
1623+ if d != nil && d.traceViews != nil {
1624+ return d.traceViews()[id]
1625+ }
1626+ return subagents.AgentTraceView{AgentID: id}
1627+}
1628+
16181629 // formatSupervisorRow is the one-line summary shown per agent.
16191630 //
1620-// Layout (fixed-width columns, then free-form activity):
1631+// Layout (fixed-width columns, then free-form task):
16211632 //
1622-// STATUS ID AGE ACTIVITY
1623-// ● run fix-login-12345 3m editing main.go
1624-// ✓ done write-tests-67890 1h done
1625-func formatSupervisorRow(r subagents.AgentSnapshot, maxWidth int) string {
1626- status := statusLabel(r.Status)
1627- if r.ProcessState != "" && r.TurnState != "" {
1628- status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18)
1633+// OPERATION ID AGE TASK
1634+// tool open 3m fix-login-12345 3m inspect main.go
1635+// completed write-tests-67890 1h add coverage
1636+func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
1637+ fact := "no observable operation"
1638+ if view.Terminal != "" {
1639+ fact = view.Terminal
1640+ } else if len(view.OpenOperations) != 0 {
1641+ operation := view.OpenOperations[0]
1642+ fact = operation.Label() + " " + formatAge(operation.StartedAt)
1643+ } else if view.LastEvent.Type != "" {
1644+ fact = "last " + view.LastEvent.Type + " " + formatAge(view.LastEvent.Timestamp)
16291645 }
16301646 age := formatAge(r.Started)
1631- left := fmt.Sprintf("%-18s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age)
1647+ left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age)
16321648 room := maxWidth - len([]rune(left))
16331649 if room < 10 {
16341650 room = 10
16351651 }
1636- act := strings.ReplaceAll(r.Activity, "\n", " ")
1637- if act == "" {
1638- act = r.Task
1639- }
1652+ act := strings.ReplaceAll(r.Task, "\n", " ")
16401653 if len([]rune(act)) > room {
16411654 act = string([]rune(act)[:room-3]) + "..."
16421655 }
@@ -39,6 +39,7 @@ type subagentsDialog struct {
39 lineInput bool39 lineInput bool
40 snapshot func() []subagents.AgentSnapshot40 snapshot func() []subagents.AgentSnapshot
41 allSnapshots func() []subagents.AgentSnapshot41 allSnapshots func() []subagents.AgentSnapshot
42+ traceViews func() map[string]subagents.AgentTraceView
42 showAll bool43 showAll bool
43 expandedID string44 expandedID string
44 stop func(id string) error45 stop func(id string) error
@@ -139,6 +140,12 @@ func (d *subagentsDialog) SetAllSnapshots(fn func() []subagents.AgentSnapshot) {
139 d.allSnapshots = fn140 d.allSnapshots = fn
140 }141 }
141 142
143+// SetTraceViews supplies the shared factual execution projection. Lifecycle
144+// snapshots remain only for identity, transcript, and process-control wiring.
145+func (d *subagentsDialog) SetTraceViews(fn func() map[string]subagents.AgentTraceView) {
146+ d.traceViews = fn
147+}
148+
142 // SetMaxRows bounds the complete dashboard before the host composes its149 // SetMaxRows bounds the complete dashboard before the host composes its
143 // bottom-sticky dialog band. Four rows are needed for the frame, column150 // bottom-sticky dialog band. Four rows are needed for the frame, column
144 // heading, and selected agent; a non-positive value leaves it unbounded.151 // heading, and selected agent; a non-positive value leaves it unbounded.
@@ -337,7 +344,7 @@ func (d *subagentsDialog) transcriptEditorCursorRow(width, popupRows, editorRowO
337 }344 }
338 345
339 row += popupRows // @-file-suggest popup, if active346 row += popupRows // @-file-suggest popup, if active
340- if agentIsBusy(a) {347+ if a.Status == subagents.StatusRunning && d.send != nil && d.agentHasOpenOperation(a.ID) {
341 row += 2 // blank + spinner row inserted above the editor348 row += 2 // blank + spinner row inserted above the editor
342 }349 }
343 row++ // blank above editor (appendTranscriptEditor)350 row++ // blank above editor (appendTranscriptEditor)
@@ -1208,7 +1215,7 @@ func (d *subagentsDialog) Render(th tui.Theme, width int) []string {
1208 }1215 }
1209 1216
1210 // Column header for readability.1217 // Column header for readability.
1211- header := fmt.Sprintf(" %-18s %-26s %-8s %s", "PROC/TURN STATUS", "ID", "AGE", "ACTIVITY")1218+ header := fmt.Sprintf(" %-30s %-26s %-8s %s", "OPERATION", "ID", "AGE", "TASK")
1212 out = append(out, th.FGColor(th.Muted, header))1219 out = append(out, th.FGColor(th.Muted, header))
1213 out = append(out, d.renderVisibleRows(th, width)...)1220 out = append(out, d.renderVisibleRows(th, width)...)
1214 out = append(out, frameRule(th, width))1221 out = append(out, frameRule(th, width))
@@ -1236,7 +1243,7 @@ func (d *subagentsDialog) renderVisibleRows(th tui.Theme, width int) []string {
1236 nextRow := d.cursor1243 nextRow := d.cursor
1237 for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {1244 for ; nextRow < len(d.rows) && len(out) < budget; nextRow++ {
1238 r := d.rows[nextRow]1245 r := d.rows[nextRow]
1239- row := formatSupervisorRow(r, width-2)1246+ row := formatSupervisorRow(r, d.traceView(r.ID), width-2)
1240 if nextRow == d.cursor {1247 if nextRow == d.cursor {
1241 out = append(out, th.PadHighlight(" "+row, width))1248 out = append(out, th.PadHighlight(" "+row, width))
1242 } else {1249 } else {
@@ -1297,7 +1304,7 @@ func (d *subagentsDialog) renderTranscript(th tui.Theme, width int) []string {
1297 frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),1304 frameHeader(th, "subagent: "+a.ID+" (type to send, esc back)", width),
1298 " " + th.FGColor(th.Muted, "task: "+a.Task),1305 " " + th.FGColor(th.Muted, "task: "+a.Task),
1299 " " + th.FGColor(th.Muted, "dir: "+a.Dir),1306 " " + th.FGColor(th.Muted, "dir: "+a.Dir),
1300- " " + th.FGColor(th.Muted, fmt.Sprintf("status: %s, %s", a.Status, a.Activity)),1307+ " " + th.FGColor(th.Muted, "observation: "+d.traceSummary(a.ID)),
1301 }1308 }
1302 if a.Model != "" {1309 if a.Model != "" {
1303 modelLine := "model: " + a.Model1310 modelLine := "model: " + a.Model
@@ -1413,12 +1420,9 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
1413 }1420 }
1414 }1421 }
1415 1422
1416- // Busy-spinner row, /btw-style. Shown whenever the agent's1423+ // Busy-spinner row, /btw-style. It is shown only for a factual open
1417- // current activity isn't "idle" — so "thinking", "tool: foo",1424+ // trace operation, never for a lifecycle or heartbeat label.
1418- // "starting", etc. all animate. The spinner is stopped (and the1425+ if d.agentHasOpenOperation(a.ID) {
1419- // row hidden) once the agent reports idle, mirroring the main
1420- // chat's busy line.
1421- if agentIsBusy(a) {
1422 if d.transcriptSpin == nil {1426 if d.transcriptSpin == nil {
1423 d.transcriptSpin = newSpinner(th)1427 d.transcriptSpin = newSpinner(th)
1424 d.transcriptSpin.Start()1428 d.transcriptSpin.Start()
@@ -1428,7 +1432,7 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
1428 out = append(out, "")1432 out = append(out, "")
1429 prefix := fmt.Sprintf("%s %s, %s",1433 prefix := fmt.Sprintf("%s %s, %s",
1430 th.FGColor(th.Assistant, d.transcriptSpin.Frame()),1434 th.FGColor(th.Assistant, d.transcriptSpin.Frame()),
1431- th.FGColor(th.Assistant, a.Activity),1435+ th.FGColor(th.Assistant, d.traceSummary(a.ID)),
1432 th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()),1436 th.FGColor(th.Muted, d.transcriptSpin.Elapsed().String()),
1433 )1437 )
1434 out = append(out, " "+prefix)1438 out = append(out, " "+prefix)
@@ -1448,23 +1452,22 @@ func (d *subagentsDialog) appendTranscriptEditor(out []string, th tui.Theme, wid
1448 return out1452 return out
1449 }1453 }
1450 1454
1451-// agentIsBusy reports whether the agent is currently mid-turn and1455+func (d *subagentsDialog) agentHasOpenOperation(id string) bool {
1452-// should render a spinner. Conservative: only known idle markers1456+ return len(d.traceView(id).OpenOperations) != 0
1453-// suppress the spinner so transient / unfamiliar activity strings1457+}
1454-// still animate. Detached / done / failed / killed never animate —1458+
1455-// they aren't actively producing output.1459+func (d *subagentsDialog) traceSummary(id string) string {
1456-func agentIsBusy(a *subagents.AgentSnapshot) bool {1460+ view := d.traceView(id)
1457- if a == nil {1461+ if view.Terminal != "" {
1458- return false1462+ return view.Terminal
1459 }1463 }
1460- if a.Status != subagents.StatusRunning && a.Status != subagents.StatusPending {1464+ if len(view.OpenOperations) != 0 {
1461- return false1465+ return view.OpenOperations[0].Label()
1462 }1466 }
1463- switch strings.TrimSpace(a.Activity) {1467+ if view.LastEvent.Type != "" {
1464- case "", "idle":1468+ return "last event " + view.LastEvent.Type
1465- return false
1466 }1469 }
1467- return true1470+ return "no observable operation"
1468 }1471 }
1469 1472
1470 // renderSupervisorTranscriptBlocks converts the agent's flat transcript1473 // renderSupervisorTranscriptBlocks converts the agent's flat transcript
@@ -1615,28 +1618,38 @@ func (d *subagentsDialog) renderPromptEditor(th tui.Theme, width int, out []stri
1615 return out1618 return out
1616 }1619 }
1617 1620
1621+// traceView retrieves the latest shared factual trace projection for id.
1622+func (d *subagentsDialog) traceView(id string) subagents.AgentTraceView {
1623+ if d != nil && d.traceViews != nil {
1624+ return d.traceViews()[id]
1625+ }
1626+ return subagents.AgentTraceView{AgentID: id}
1627+}
1628+
1618 // formatSupervisorRow is the one-line summary shown per agent.1629 // formatSupervisorRow is the one-line summary shown per agent.
1619 //1630 //
1620-// Layout (fixed-width columns, then free-form activity):1631+// Layout (fixed-width columns, then free-form task):
1621 //1632 //
1622-// STATUS ID AGE ACTIVITY1633+// OPERATION ID AGE TASK
1623-// ● run fix-login-12345 3m editing main.go1634+// tool open 3m fix-login-12345 3m inspect main.go
1624-// ✓ done write-tests-67890 1h done1635+// completed write-tests-67890 1h add coverage
1625-func formatSupervisorRow(r subagents.AgentSnapshot, maxWidth int) string {1636+func formatSupervisorRow(r subagents.AgentSnapshot, view subagents.AgentTraceView, maxWidth int) string {
1626- status := statusLabel(r.Status)1637+ fact := "no observable operation"
1627- if r.ProcessState != "" && r.TurnState != "" {1638+ if view.Terminal != "" {
1628- status = truncateLineSafe(status+" "+string(r.ProcessState)+"/"+string(r.TurnState), 18)1639+ fact = view.Terminal
1640+ } else if len(view.OpenOperations) != 0 {
1641+ operation := view.OpenOperations[0]
1642+ fact = operation.Label() + " " + formatAge(operation.StartedAt)
1643+ } else if view.LastEvent.Type != "" {
1644+ fact = "last " + view.LastEvent.Type + " " + formatAge(view.LastEvent.Timestamp)
1629 }1645 }
1630 age := formatAge(r.Started)1646 age := formatAge(r.Started)
1631- left := fmt.Sprintf("%-18s %-26s %-8s ", status, truncateLineSafe(r.ID, 26), age)1647+ left := fmt.Sprintf("%-30s %-26s %-8s ", truncateLineSafe(fact, 30), truncateLineSafe(r.ID, 26), age)
1632 room := maxWidth - len([]rune(left))1648 room := maxWidth - len([]rune(left))
1633 if room < 10 {1649 if room < 10 {
1634 room = 101650 room = 10
1635 }1651 }
1636- act := strings.ReplaceAll(r.Activity, "\n", " ")1652+ act := strings.ReplaceAll(r.Task, "\n", " ")
1637- if act == "" {
1638- act = r.Task
1639- }
1640 if len([]rune(act)) > room {1653 if len([]rune(act)) > room {
1641 act = string([]rune(act)[:room-3]) + "..."1654 act = string([]rune(act)[:room-3]) + "..."
1642 }1655 }
M packages/agent/modes/subagents_dialog_test.go +18 -11
@@ -40,13 +40,19 @@ func TestSubagentsDialogEmptyState(t *testing.T) {
4040 func TestSubagentsDialogRendersRows(t *testing.T) {
4141 now := time.Now()
4242 rows := []subagents.AgentSnapshot{
43- {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Activity: "editing file", Started: now},
44- {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Activity: "done", Started: now},
43+ {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Started: now},
44+ {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Started: now},
4545 }
4646 d := newSubagentsDialog()
47+ d.SetTraceViews(func() map[string]subagents.AgentTraceView {
48+ return map[string]subagents.AgentTraceView{
49+ "alpha-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now}}},
50+ "beta-2": {Terminal: "completed"},
51+ }
52+ })
4753 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
4854 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
49- for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} {
55+ for _, want := range []string{"alpha-1", "beta-2", "tool open", "completed", "OPERATION", "TASK"} {
5056 if !strings.Contains(out, want) {
5157 t.Fatalf("render missing %q:\n%s", want, out)
5258 }
@@ -108,7 +114,7 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
108114 // Esc should bring us back to the list view.
109115 d.HandleKey(tui.Key{Kind: tui.KeyEsc})
110116 out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
111- if !strings.Contains(out, "STATUS") {
117+ if !strings.Contains(out, "OPERATION") {
112118 t.Fatalf("did not return to list view:\n%s", out)
113119 }
114120 }
@@ -1299,6 +1305,8 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
12991305 }}
13001306 snap := func() []subagents.AgentSnapshot { return cur }
13011307 d := newSubagentsDialog()
1308+ views := map[string]subagents.AgentTraceView{"agent-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: time.Now()}}}}
1309+ d.SetTraceViews(func() map[string]subagents.AgentTraceView { return views })
13021310 d.Open(snap, nil, nil, nil,
13031311 func(string, string) error { return nil },
13041312 nil, "")
@@ -1306,22 +1314,21 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
13061314 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
13071315
13081316 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1309- if !strings.Contains(out, "thinking") {
1310- t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out)
1317+ if !strings.Contains(out, "tool open") {
1318+ t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out)
13111319 }
13121320 if d.transcriptSpin == nil {
13131321 t.Error("spinner not initialised while agent is busy")
13141322 }
13151323
1316- // Flip the activity to idle: spinner should vanish.
1317- cur[0].Activity = "idle"
1324+ // Close the operation: spinner should vanish.
1325+ views["agent-1"] = subagents.AgentTraceView{LastEvent: subagents.TraceEvent{Type: "tool.finished", Timestamp: time.Now()}}
13181326 out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
13191327 if d.transcriptSpin != nil {
13201328 t.Error("spinner stayed live after agent reported idle")
13211329 }
1322- // The bare word "thinking" must not appear anymore.
1323- if strings.Contains(out, "· thinking, ") {
1324- t.Errorf("busy line still rendered when agent is idle:\n%s", out)
1330+ if strings.Contains(out, "tool open, ") {
1331+ t.Errorf("busy line still rendered after operation finished:\n%s", out)
13251332 }
13261333 }
13271334
@@ -40,13 +40,19 @@ func TestSubagentsDialogEmptyState(t *testing.T) {
40 func TestSubagentsDialogRendersRows(t *testing.T) {40 func TestSubagentsDialogRendersRows(t *testing.T) {
41 now := time.Now()41 now := time.Now()
42 rows := []subagents.AgentSnapshot{42 rows := []subagents.AgentSnapshot{
43- {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Activity: "editing file", Started: now},43+ {ID: "alpha-1", Task: "fix login", Status: subagents.StatusRunning, Started: now},
44- {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Activity: "done", Started: now},44+ {ID: "beta-2", Task: "write tests", Status: subagents.StatusDone, Started: now},
45 }45 }
46 d := newSubagentsDialog()46 d := newSubagentsDialog()
47+ d.SetTraceViews(func() map[string]subagents.AgentTraceView {
48+ return map[string]subagents.AgentTraceView{
49+ "alpha-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: now}}},
50+ "beta-2": {Terminal: "completed"},
51+ }
52+ })
47 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")53 d.Open(staticSnapshots(rows...), nil, nil, nil, nil, nil, "")
48 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")54 out := strings.Join(d.Render(tui.Theme{}, 100), "\n")
49- for _, want := range []string{"alpha-1", "beta-2", "editing file", "done", "STATUS", "ACTIVITY"} {55+ for _, want := range []string{"alpha-1", "beta-2", "tool open", "completed", "OPERATION", "TASK"} {
50 if !strings.Contains(out, want) {56 if !strings.Contains(out, want) {
51 t.Fatalf("render missing %q:\n%s", want, out)57 t.Fatalf("render missing %q:\n%s", want, out)
52 }58 }
@@ -108,7 +114,7 @@ func TestSubagentsDialogEnterShowsTranscript(t *testing.T) {
108 // Esc should bring us back to the list view.114 // Esc should bring us back to the list view.
109 d.HandleKey(tui.Key{Kind: tui.KeyEsc})115 d.HandleKey(tui.Key{Kind: tui.KeyEsc})
110 out = strings.Join(d.Render(tui.Theme{}, 80), "\n")116 out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
111- if !strings.Contains(out, "STATUS") {117+ if !strings.Contains(out, "OPERATION") {
112 t.Fatalf("did not return to list view:\n%s", out)118 t.Fatalf("did not return to list view:\n%s", out)
113 }119 }
114 }120 }
@@ -1299,6 +1305,8 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1299 }}1305 }}
1300 snap := func() []subagents.AgentSnapshot { return cur }1306 snap := func() []subagents.AgentSnapshot { return cur }
1301 d := newSubagentsDialog()1307 d := newSubagentsDialog()
1308+ views := map[string]subagents.AgentTraceView{"agent-1": {OpenOperations: []subagents.Operation{{Type: "tool.started", StartedAt: time.Now()}}}}
1309+ d.SetTraceViews(func() map[string]subagents.AgentTraceView { return views })
1302 d.Open(snap, nil, nil, nil,1310 d.Open(snap, nil, nil, nil,
1303 func(string, string) error { return nil },1311 func(string, string) error { return nil },
1304 nil, "")1312 nil, "")
@@ -1306,22 +1314,21 @@ func TestSupervisorTranscriptBusySpinnerRenders(t *testing.T) {
1306 d.HandleKey(tui.Key{Kind: tui.KeyEnter})1314 d.HandleKey(tui.Key{Kind: tui.KeyEnter})
1307 1315
1308 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")1316 out := strings.Join(d.Render(tui.Theme{}, 80), "\n")
1309- if !strings.Contains(out, "thinking") {1317+ if !strings.Contains(out, "tool open") {
1310- t.Fatalf("busy spinner did not surface activity \"thinking\" near the editor:\n%s", out)1318+ t.Fatalf("busy spinner did not surface an open tool near the editor:\n%s", out)
1311 }1319 }
1312 if d.transcriptSpin == nil {1320 if d.transcriptSpin == nil {
1313 t.Error("spinner not initialised while agent is busy")1321 t.Error("spinner not initialised while agent is busy")
1314 }1322 }
1315 1323
1316- // Flip the activity to idle: spinner should vanish.1324+ // Close the operation: spinner should vanish.
1317- cur[0].Activity = "idle"1325+ views["agent-1"] = subagents.AgentTraceView{LastEvent: subagents.TraceEvent{Type: "tool.finished", Timestamp: time.Now()}}
1318 out = strings.Join(d.Render(tui.Theme{}, 80), "\n")1326 out = strings.Join(d.Render(tui.Theme{}, 80), "\n")
1319 if d.transcriptSpin != nil {1327 if d.transcriptSpin != nil {
1320 t.Error("spinner stayed live after agent reported idle")1328 t.Error("spinner stayed live after agent reported idle")
1321 }1329 }
1322- // The bare word "thinking" must not appear anymore.1330+ if strings.Contains(out, "tool open, ") {
1323- if strings.Contains(out, "· thinking, ") {1331+ t.Errorf("busy line still rendered after operation finished:\n%s", out)
1324- t.Errorf("busy line still rendered when agent is idle:\n%s", out)
1325 }1332 }
1326 }1333 }
1327 1334
M packages/agent/modes/subagents_slash.go +1 -0
@@ -78,6 +78,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
7878 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
7979 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
8080 i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions)
81+ i.subagentsDialog.SetTraceViews(i.cfg.Supervisor.TraceViews)
8182 i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
8283 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
8384 if i.cfg.LoggedInProviders != nil {
@@ -78,6 +78,7 @@ func (i *Interactive) runSubagents(ctx context.Context, args []string) {
78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())78 i.subagentsDialog.SetCompactMode(i.compactModeEnabled())
79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)79 i.subagentsDialog.SetLineInput(tui.NormalizeInputStyle(i.cfg.TUIInputStyle) == tui.InputStyleLines)
80 i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions)80 i.subagentsDialog.SetAllSnapshots(i.cfg.Supervisor.SnapshotAllSessions)
81+ i.subagentsDialog.SetTraceViews(i.cfg.Supervisor.TraceViews)
81 i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)82 i.subagentsDialog.SetLoadTranscript(i.cfg.Supervisor.LoadTranscript)
82 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)83 i.subagentsDialog.SetCurrentModel(i.cfg.Model, i.cfg.Provider)
83 if i.cfg.LoggedInProviders != nil {84 if i.cfg.LoggedInProviders != nil {
M packages/agent/subagent_runtime.go +31 -5
@@ -2,6 +2,9 @@ package agent
22
33 import (
44 "context"
5+ "errors"
6+ "fmt"
7+ "os"
58 "path/filepath"
69 "strings"
710 "sync"
@@ -67,10 +70,12 @@ type subagentRuntimeConfiguration struct {
6770 // assemble a subagentRuntime. The active selection callbacks are optional;
6871 // when omitted, the initial Provider/Model/Reasoning values are used.
6972 type subagentRuntimeConfig struct {
70- Context context.Context
71- Args Args
72- Root string
73- RepoRoot string
73+ Context context.Context
74+ Args Args
75+ Root string
76+ RepoRoot string
77+ TraceDir string
78+ TraceMode subagents.TraceMode
7479
7580 Provider string
7681 Model string
@@ -98,6 +103,23 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
98103 if cfg.Context == nil {
99104 cfg.Context = context.Background()
100105 }
106+ if cfg.TraceDir == "" {
107+ cfg.TraceDir = strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_DIR"))
108+ }
109+ if cfg.TraceMode == "" {
110+ switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_MODE"))) {
111+ case "", string(subagents.TraceModeNormal):
112+ cfg.TraceMode = subagents.TraceModeNormal
113+ case string(subagents.TraceModeDetailed):
114+ cfg.TraceMode = subagents.TraceModeDetailed
115+ default:
116+ fmt.Fprintf(os.Stderr, "zut: ignoring invalid ZUT_SUBAGENT_TRACE_MODE\n")
117+ cfg.TraceMode = subagents.TraceModeNormal
118+ }
119+ }
120+ if cfg.TraceDir == "" {
121+ cfg.TraceMode = subagents.TraceModeNormal
122+ }
101123 rt := &subagentRuntime{
102124 args: cfg.Args,
103125 root: cfg.Root,
@@ -159,6 +181,8 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
159181 Context: cfg.Context,
160182 Root: root,
161183 RepoRoot: cfg.RepoRoot,
184+ TraceDir: cfg.TraceDir,
185+ TraceMode: cfg.TraceMode,
162186 Provider: rt.currentProvider(),
163187 FastMode: cfg.FastMode,
164188 WebSearchPolicy: cfg.WebSearchPolicy,
@@ -449,7 +473,9 @@ func (rt *subagentRuntime) Close(ctx context.Context) error {
449473 if rt.closed {
450474 return nil
451475 }
452- if err := rt.supervisor.StopAllContext(ctx); err != nil {
476+ stopErr := rt.supervisor.StopAllContext(ctx)
477+ closeErr := rt.supervisor.CloseContext(ctx)
478+ if err := errors.Join(stopErr, closeErr); err != nil {
453479 return err
454480 }
455481 rt.closed = true
@@ -2,6 +2,9 @@ package agent
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "fmt"
7+ "os"
5 "path/filepath"8 "path/filepath"
6 "strings"9 "strings"
7 "sync"10 "sync"
@@ -67,10 +70,12 @@ type subagentRuntimeConfiguration struct {
67 // assemble a subagentRuntime. The active selection callbacks are optional;70 // assemble a subagentRuntime. The active selection callbacks are optional;
68 // when omitted, the initial Provider/Model/Reasoning values are used.71 // when omitted, the initial Provider/Model/Reasoning values are used.
69 type subagentRuntimeConfig struct {72 type subagentRuntimeConfig struct {
70- Context context.Context73+ Context context.Context
71- Args Args74+ Args Args
72- Root string75+ Root string
73- RepoRoot string76+ RepoRoot string
77+ TraceDir string
78+ TraceMode subagents.TraceMode
74 79
75 Provider string80 Provider string
76 Model string81 Model string
@@ -98,6 +103,23 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
98 if cfg.Context == nil {103 if cfg.Context == nil {
99 cfg.Context = context.Background()104 cfg.Context = context.Background()
100 }105 }
106+ if cfg.TraceDir == "" {
107+ cfg.TraceDir = strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_DIR"))
108+ }
109+ if cfg.TraceMode == "" {
110+ switch strings.ToLower(strings.TrimSpace(os.Getenv("ZUT_SUBAGENT_TRACE_MODE"))) {
111+ case "", string(subagents.TraceModeNormal):
112+ cfg.TraceMode = subagents.TraceModeNormal
113+ case string(subagents.TraceModeDetailed):
114+ cfg.TraceMode = subagents.TraceModeDetailed
115+ default:
116+ fmt.Fprintf(os.Stderr, "zut: ignoring invalid ZUT_SUBAGENT_TRACE_MODE\n")
117+ cfg.TraceMode = subagents.TraceModeNormal
118+ }
119+ }
120+ if cfg.TraceDir == "" {
121+ cfg.TraceMode = subagents.TraceModeNormal
122+ }
101 rt := &subagentRuntime{123 rt := &subagentRuntime{
102 args: cfg.Args,124 args: cfg.Args,
103 root: cfg.Root,125 root: cfg.Root,
@@ -159,6 +181,8 @@ func newSubagentRuntime(cfg subagentRuntimeConfig) *subagentRuntime {
159 Context: cfg.Context,181 Context: cfg.Context,
160 Root: root,182 Root: root,
161 RepoRoot: cfg.RepoRoot,183 RepoRoot: cfg.RepoRoot,
184+ TraceDir: cfg.TraceDir,
185+ TraceMode: cfg.TraceMode,
162 Provider: rt.currentProvider(),186 Provider: rt.currentProvider(),
163 FastMode: cfg.FastMode,187 FastMode: cfg.FastMode,
164 WebSearchPolicy: cfg.WebSearchPolicy,188 WebSearchPolicy: cfg.WebSearchPolicy,
@@ -449,7 +473,9 @@ func (rt *subagentRuntime) Close(ctx context.Context) error {
449 if rt.closed {473 if rt.closed {
450 return nil474 return nil
451 }475 }
452- if err := rt.supervisor.StopAllContext(ctx); err != nil {476+ stopErr := rt.supervisor.StopAllContext(ctx)
477+ closeErr := rt.supervisor.CloseContext(ctx)
478+ if err := errors.Join(stopErr, closeErr); err != nil {
453 return err479 return err
454 }480 }
455 rt.closed = true481 rt.closed = true
M packages/agent/subagent_worker.go +9 -96
@@ -27,10 +27,8 @@ import (
2727 // initial positional task (if any) is the first turn; subsequent
2828 // turns arrive through the inbox unix socket at args.SubagentWorker.
2929 //
30-// - Output: every emitted JSON line is also mirrored verbatim into
31-// events.jsonl (see ZUT_SUBAGENT_EVENT_LOG) so a separate zut
32-// process can /subagents open this agent and replay its full history
33-// even after the parent that spawned us is long gone.
30+// - Output: every emitted JSON line goes to the parent supervisor, which
31+// owns the optional execution trace. Workers never persist trace data.
3432 //
3533 // The runner in packages/agent/subagents/runner.go is the only caller in
3634 // production; tests use the stubchild binary under
@@ -185,20 +183,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
185183 }
186184 defer ln.Close()
187185
188- // Event log is owned by the supervisor's runner via stdout, but
189- // the daemon also writes a redundant copy here when the runner's
190- // pipe is closed (e.g. parent zut exited but the agent is still
191- // running headless). The env var is set by the runner; if it's
192- // empty we silently skip the second mirror.
193- var logMirror *subagents.EventLog
194- if path := os.Getenv("ZUT_SUBAGENT_EVENT_LOG"); path != "" {
195- logMirror, _ = subagents.OpenEventLog(path)
196- }
197- if logMirror != nil {
198- defer logMirror.Close()
199- }
200-
201- em := newSubagentEmitter(os.Stdout, logMirror)
186+ em := newSubagentEmitter(os.Stdout)
202187 em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID"))
203188 em.emit("agent.ready", map[string]any{
204189 "version": version,
@@ -209,30 +194,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
209194 "max_turns": args.SubagentMaxTurns,
210195 })
211196
212- // Heartbeats make a live-but-detached child distinguishable from a
213- // failed turn after supervisor reconnect. The parent still treats
214- // unknown event types as forward-compatible.
215- heartbeatDone := make(chan struct{})
216- defer close(heartbeatDone)
217- go func() {
218- interval := 10 * time.Second
219- if raw := os.Getenv("ZUT_SUBAGENT_HEARTBEAT_INTERVAL"); raw != "" {
220- if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 {
221- interval = parsed
222- }
223- }
224- ticker := time.NewTicker(interval)
225- defer ticker.Stop()
226- for {
227- select {
228- case <-heartbeatDone:
229- return
230- case <-ticker.C:
231- em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
232- }
233- }
234- }()
235-
236197 // Keep a per-turn cancel so the "cancel" inbox message can interrupt
237198 // an in-flight turn without tearing down the whole daemon.
238199 var (
@@ -240,7 +201,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
240201 cancelFn context.CancelFunc
241202 busyTurn bool
242203 budget = workerTurnBudget{
243- sequence: initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG")),
244204 lifetime: args.SubagentLifetimeTurns,
245205 current: args.SubagentRunTurns,
246206 }
@@ -521,29 +481,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
521481 }
522482 }
523483
524-// subagentEmitter serialises events to stdout and (optionally) to a
525-// durable log file. Concurrent goroutines call emit so we have to
526-// hold a mutex around the encoder.
484+// subagentEmitter serialises protocol events to stdout. The supervisor is
485+// the single trace writer; workers never persist execution events themselves.
527486 type subagentEmitter struct {
528487 mu sync.Mutex
529488 w *os.File
530- mirror *subagents.EventLog
531489 agentID string
532-
533- // orphan flips true the first time a stdout write fails (broken
534- // pipe — the supervisor died). Until then the mirror stays
535- // dormant: the supervisor is the canonical writer to events.jsonl
536- // (it parses our stdout and Append()s each event itself). Writing
537- // from both sides used to land every event in the log twice,
538- // which showed up as a fully-duplicated transcript the next time
539- // the agent was reloaded — the exact "why is everything doubled"
540- // bug. Once orphaned the mirror takes over so the events still
541- // land on disk for the next reload.
542- orphan bool
543490 }
544491
545-func newSubagentEmitter(w *os.File, mirror *subagents.EventLog) *subagentEmitter {
546- return &subagentEmitter{w: w, mirror: mirror}
492+func newSubagentEmitter(w *os.File) *subagentEmitter {
493+ return &subagentEmitter{w: w}
547494 }
548495
549496 func (e *subagentEmitter) setProtocolIdentity(agentID string) {
@@ -569,17 +516,8 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
569516 envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)
570517 line, err := subagents.MarshalJSONL(envelope)
571518
572- if !e.orphan && err == nil {
573- if _, werr := e.w.Write(line); werr != nil {
574- // Supervisor's stdout pipe is gone (parent zut exited but we
575- // kept running). Switch to mirror-only mode and preserve this
576- // event in the durable log.
577- e.orphan = true
578- }
579- }
580-
581- if e.orphan && e.mirror != nil {
582- _ = e.mirror.Append(subagents.NewEvent(wireType, payload))
519+ if err == nil {
520+ _, _ = e.w.Write(line)
583521 }
584522 }
585523
@@ -783,31 +721,6 @@ func errString(err error) string {
783721 return err.Error()
784722 }
785723
786-func initialTurnNumber(path string) int {
787- if strings.TrimSpace(path) == "" {
788- return 0
789- }
790- events, err := subagents.ReadEventLog(path)
791- if err != nil {
792- return 0
793- }
794- maxStep := 0
795- for _, ev := range events {
796- if !subagents.IsDelegatedTurnStart(ev) {
797- continue
798- }
799- if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep {
800- maxStep = int(step)
801- }
802- if strings.HasPrefix(ev.TurnID, "turn-") {
803- if n, parseErr := strconv.Atoi(strings.TrimPrefix(ev.TurnID, "turn-")); parseErr == nil && n > maxStep {
804- maxStep = n
805- }
806- }
807- }
808- return maxStep
809-}
810-
811724 func truncateForLog(s string, n int) string {
812725 if len(s) <= n {
813726 return s
@@ -27,10 +27,8 @@ import (
27 // initial positional task (if any) is the first turn; subsequent27 // initial positional task (if any) is the first turn; subsequent
28 // turns arrive through the inbox unix socket at args.SubagentWorker.28 // turns arrive through the inbox unix socket at args.SubagentWorker.
29 //29 //
30-// - Output: every emitted JSON line is also mirrored verbatim into30+// - Output: every emitted JSON line goes to the parent supervisor, which
31-// events.jsonl (see ZUT_SUBAGENT_EVENT_LOG) so a separate zut31+// owns the optional execution trace. Workers never persist trace data.
32-// process can /subagents open this agent and replay its full history
33-// even after the parent that spawned us is long gone.
34 //32 //
35 // The runner in packages/agent/subagents/runner.go is the only caller in33 // The runner in packages/agent/subagents/runner.go is the only caller in
36 // production; tests use the stubchild binary under34 // production; tests use the stubchild binary under
@@ -185,20 +183,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
185 }183 }
186 defer ln.Close()184 defer ln.Close()
187 185
188- // Event log is owned by the supervisor's runner via stdout, but186+ em := newSubagentEmitter(os.Stdout)
189- // the daemon also writes a redundant copy here when the runner's
190- // pipe is closed (e.g. parent zut exited but the agent is still
191- // running headless). The env var is set by the runner; if it's
192- // empty we silently skip the second mirror.
193- var logMirror *subagents.EventLog
194- if path := os.Getenv("ZUT_SUBAGENT_EVENT_LOG"); path != "" {
195- logMirror, _ = subagents.OpenEventLog(path)
196- }
197- if logMirror != nil {
198- defer logMirror.Close()
199- }
200-
201- em := newSubagentEmitter(os.Stdout, logMirror)
202 em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID"))187 em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID"))
203 em.emit("agent.ready", map[string]any{188 em.emit("agent.ready", map[string]any{
204 "version": version,189 "version": version,
@@ -209,30 +194,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
209 "max_turns": args.SubagentMaxTurns,194 "max_turns": args.SubagentMaxTurns,
210 })195 })
211 196
212- // Heartbeats make a live-but-detached child distinguishable from a
213- // failed turn after supervisor reconnect. The parent still treats
214- // unknown event types as forward-compatible.
215- heartbeatDone := make(chan struct{})
216- defer close(heartbeatDone)
217- go func() {
218- interval := 10 * time.Second
219- if raw := os.Getenv("ZUT_SUBAGENT_HEARTBEAT_INTERVAL"); raw != "" {
220- if parsed, parseErr := time.ParseDuration(raw); parseErr == nil && parsed > 0 {
221- interval = parsed
222- }
223- }
224- ticker := time.NewTicker(interval)
225- defer ticker.Stop()
226- for {
227- select {
228- case <-heartbeatDone:
229- return
230- case <-ticker.C:
231- em.emit("agent.heartbeat", map[string]any{"activity": "alive"})
232- }
233- }
234- }()
235-
236 // Keep a per-turn cancel so the "cancel" inbox message can interrupt197 // Keep a per-turn cancel so the "cancel" inbox message can interrupt
237 // an in-flight turn without tearing down the whole daemon.198 // an in-flight turn without tearing down the whole daemon.
238 var (199 var (
@@ -240,7 +201,6 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
240 cancelFn context.CancelFunc201 cancelFn context.CancelFunc
241 busyTurn bool202 busyTurn bool
242 budget = workerTurnBudget{203 budget = workerTurnBudget{
243- sequence: initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG")),
244 lifetime: args.SubagentLifetimeTurns,204 lifetime: args.SubagentLifetimeTurns,
245 current: args.SubagentRunTurns,205 current: args.SubagentRunTurns,
246 }206 }
@@ -521,29 +481,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
521 }481 }
522 }482 }
523 483
524-// subagentEmitter serialises events to stdout and (optionally) to a484+// subagentEmitter serialises protocol events to stdout. The supervisor is
525-// durable log file. Concurrent goroutines call emit so we have to485+// the single trace writer; workers never persist execution events themselves.
526-// hold a mutex around the encoder.
527 type subagentEmitter struct {486 type subagentEmitter struct {
528 mu sync.Mutex487 mu sync.Mutex
529 w *os.File488 w *os.File
530- mirror *subagents.EventLog
531 agentID string489 agentID string
532-
533- // orphan flips true the first time a stdout write fails (broken
534- // pipe — the supervisor died). Until then the mirror stays
535- // dormant: the supervisor is the canonical writer to events.jsonl
536- // (it parses our stdout and Append()s each event itself). Writing
537- // from both sides used to land every event in the log twice,
538- // which showed up as a fully-duplicated transcript the next time
539- // the agent was reloaded — the exact "why is everything doubled"
540- // bug. Once orphaned the mirror takes over so the events still
541- // land on disk for the next reload.
542- orphan bool
543 }490 }
544 491
545-func newSubagentEmitter(w *os.File, mirror *subagents.EventLog) *subagentEmitter {492+func newSubagentEmitter(w *os.File) *subagentEmitter {
546- return &subagentEmitter{w: w, mirror: mirror}493+ return &subagentEmitter{w: w}
547 }494 }
548 495
549 func (e *subagentEmitter) setProtocolIdentity(agentID string) {496 func (e *subagentEmitter) setProtocolIdentity(agentID string) {
@@ -569,17 +516,8 @@ func (e *subagentEmitter) emit(typ string, data map[string]any) {
569 envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)516 envelope := subagents.NewEventEnvelope(wireType, e.agentID, turnID, payload)
570 line, err := subagents.MarshalJSONL(envelope)517 line, err := subagents.MarshalJSONL(envelope)
571 518
572- if !e.orphan && err == nil {519+ if err == nil {
573- if _, werr := e.w.Write(line); werr != nil {520+ _, _ = e.w.Write(line)
574- // Supervisor's stdout pipe is gone (parent zut exited but we
575- // kept running). Switch to mirror-only mode and preserve this
576- // event in the durable log.
577- e.orphan = true
578- }
579- }
580-
581- if e.orphan && e.mirror != nil {
582- _ = e.mirror.Append(subagents.NewEvent(wireType, payload))
583 }521 }
584 }522 }
585 523
@@ -783,31 +721,6 @@ func errString(err error) string {
783 return err.Error()721 return err.Error()
784 }722 }
785 723
786-func initialTurnNumber(path string) int {
787- if strings.TrimSpace(path) == "" {
788- return 0
789- }
790- events, err := subagents.ReadEventLog(path)
791- if err != nil {
792- return 0
793- }
794- maxStep := 0
795- for _, ev := range events {
796- if !subagents.IsDelegatedTurnStart(ev) {
797- continue
798- }
799- if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep {
800- maxStep = int(step)
801- }
802- if strings.HasPrefix(ev.TurnID, "turn-") {
803- if n, parseErr := strconv.Atoi(strings.TrimPrefix(ev.TurnID, "turn-")); parseErr == nil && n > maxStep {
804- maxStep = n
805- }
806- }
807- }
808- return maxStep
809-}
810-
811 func truncateForLog(s string, n int) string {724 func truncateForLog(s string, n int) string {
812 if len(s) <= n {725 if len(s) <= n {
813 return s726 return s
M packages/agent/subagent_worker_test.go +2 -95
@@ -759,75 +759,6 @@ func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) {
759759 }
760760 }
761761
762-// TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the
763-// "everything is doubled after reopening a subagent agent" bug.
764-//
765-// Symptom: events.jsonl held two copies of every event because the
766-// child mirrored each event to disk AND the supervisor parsed the
767-// child's stdout and appended each event to disk too. On next zut
768-// launch the replay produced two transcript lines per real one.
769-//
770-// Fix invariant: while stdout writes succeed (i.e. the supervisor is
771-// alive on the other end of the pipe), the child's mirror writes
772-// NOTHING. Only when a stdout write fails (broken pipe → orphan)
773-// does the mirror take over so events still get persisted.
774-func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
775- // Real *os.File for the emitter's stdout-equivalent so the
776- // emitter's write() path (which expects *os.File) actually runs.
777- stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo")
778- stdoutFile, err := os.Create(stdoutPath)
779- if err != nil {
780- t.Fatalf("create stdout file: %v", err)
781- }
782- defer stdoutFile.Close()
783-
784- // Mirror writes go to a separate events.jsonl that we can read
785- // at the end to assert how many events the mirror emitted.
786- mirrorPath := filepath.Join(t.TempDir(), "events.jsonl")
787- mirror, err := subagents.OpenEventLog(mirrorPath)
788- if err != nil {
789- t.Fatalf("open mirror: %v", err)
790- }
791- defer mirror.Close()
792-
793- em := newSubagentEmitter(stdoutFile, mirror)
794-
795- // Healthy stdout: emit three events. Mirror must stay empty.
796- em.emit("turn_start", map[string]any{"step": 1})
797- em.emit("assistant_message", map[string]any{"text": "hi"})
798- em.emit("turn_end", map[string]any{"step": 1})
799-
800- got, err := subagents.ReadEventLog(mirrorPath)
801- if err != nil {
802- t.Fatalf("read mirror: %v", err)
803- }
804- if len(got) != 0 {
805- t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v",
806- len(got), got)
807- }
808-
809- // Simulate supervisor death: close stdout so the next Write
810- // returns EBADF / broken pipe. The emitter must flip into
811- // orphan mode and start writing through the mirror.
812- if err := stdoutFile.Close(); err != nil {
813- t.Fatalf("close stdout: %v", err)
814- }
815-
816- em.emit("assistant_message", map[string]any{"text": "after orphan"})
817- em.emit("turn_end", map[string]any{"step": 2})
818-
819- got, err = subagents.ReadEventLog(mirrorPath)
820- if err != nil {
821- t.Fatalf("read mirror post-orphan: %v", err)
822- }
823- if len(got) < 2 {
824- t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got))
825- }
826- if got[len(got)-1].Type != "turn_end" {
827- t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type)
828- }
829-}
830-
831762 // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the
832763 // large-result wire duplication bug.
833764 func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
@@ -838,7 +769,7 @@ func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
838769 }
839770 defer file.Close()
840771
841- em := newSubagentEmitter(file, nil)
772+ em := newSubagentEmitter(file)
842773 em.setProtocolIdentity("agent-1")
843774 em.emit("turn.result", map[string]any{
844775 "status": "succeeded",
@@ -887,7 +818,7 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
887818 }
888819 defer r.Close()
889820
890- em := newSubagentEmitter(w, nil)
821+ em := newSubagentEmitter(w)
891822 em.emit("turn_start", map[string]any{"step": 1})
892823 _ = w.Close()
893824
@@ -916,30 +847,6 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
916847 }
917848 }
918849
919-func TestInitialTurnNumberIgnoresNestedProviderTurns(t *testing.T) {
920- path := filepath.Join(t.TempDir(), "events.jsonl")
921- log, err := subagents.OpenEventLog(path)
922- if err != nil {
923- t.Fatal(err)
924- }
925- if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
926- "step": float64(1), "lifetime_turns": 1, "current_run_turns": 1,
927- })); err != nil {
928- t.Fatal(err)
929- }
930- if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
931- "step": float64(99), "nested_turn": true,
932- })); err != nil {
933- t.Fatal(err)
934- }
935- if err := log.Close(); err != nil {
936- t.Fatal(err)
937- }
938- if got := initialTurnNumber(path); got != 1 {
939- t.Fatalf("initial turn number = %d, want 1", got)
940- }
941-}
942-
943850 func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) {
944851 var budget workerTurnBudget
945852
@@ -759,75 +759,6 @@ func TestSubagentEventDataSanitizesContextOverflow(t *testing.T) {
759 }759 }
760 }760 }
761 761
762-// TestSupervisorEmitterMirrorDormantUntilStdoutBreaks regresses the
763-// "everything is doubled after reopening a subagent agent" bug.
764-//
765-// Symptom: events.jsonl held two copies of every event because the
766-// child mirrored each event to disk AND the supervisor parsed the
767-// child's stdout and appended each event to disk too. On next zut
768-// launch the replay produced two transcript lines per real one.
769-//
770-// Fix invariant: while stdout writes succeed (i.e. the supervisor is
771-// alive on the other end of the pipe), the child's mirror writes
772-// NOTHING. Only when a stdout write fails (broken pipe → orphan)
773-// does the mirror take over so events still get persisted.
774-func TestSupervisorEmitterMirrorDormantUntilStdoutBreaks(t *testing.T) {
775- // Real *os.File for the emitter's stdout-equivalent so the
776- // emitter's write() path (which expects *os.File) actually runs.
777- stdoutPath := filepath.Join(t.TempDir(), "stdout.fifo")
778- stdoutFile, err := os.Create(stdoutPath)
779- if err != nil {
780- t.Fatalf("create stdout file: %v", err)
781- }
782- defer stdoutFile.Close()
783-
784- // Mirror writes go to a separate events.jsonl that we can read
785- // at the end to assert how many events the mirror emitted.
786- mirrorPath := filepath.Join(t.TempDir(), "events.jsonl")
787- mirror, err := subagents.OpenEventLog(mirrorPath)
788- if err != nil {
789- t.Fatalf("open mirror: %v", err)
790- }
791- defer mirror.Close()
792-
793- em := newSubagentEmitter(stdoutFile, mirror)
794-
795- // Healthy stdout: emit three events. Mirror must stay empty.
796- em.emit("turn_start", map[string]any{"step": 1})
797- em.emit("assistant_message", map[string]any{"text": "hi"})
798- em.emit("turn_end", map[string]any{"step": 1})
799-
800- got, err := subagents.ReadEventLog(mirrorPath)
801- if err != nil {
802- t.Fatalf("read mirror: %v", err)
803- }
804- if len(got) != 0 {
805- t.Fatalf("mirror wrote %d events while supervisor was alive; want 0 (every event would otherwise double on the next reload)\n%+v",
806- len(got), got)
807- }
808-
809- // Simulate supervisor death: close stdout so the next Write
810- // returns EBADF / broken pipe. The emitter must flip into
811- // orphan mode and start writing through the mirror.
812- if err := stdoutFile.Close(); err != nil {
813- t.Fatalf("close stdout: %v", err)
814- }
815-
816- em.emit("assistant_message", map[string]any{"text": "after orphan"})
817- em.emit("turn_end", map[string]any{"step": 2})
818-
819- got, err = subagents.ReadEventLog(mirrorPath)
820- if err != nil {
821- t.Fatalf("read mirror post-orphan: %v", err)
822- }
823- if len(got) < 2 {
824- t.Fatalf("mirror failed to take over after stdout died: got %d events", len(got))
825- }
826- if got[len(got)-1].Type != "turn_end" {
827- t.Errorf("last mirrored event type = %q; want turn_end", got[len(got)-1].Type)
828- }
829-}
830-
831 // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the762 // TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire regresses the
832 // large-result wire duplication bug.763 // large-result wire duplication bug.
833 func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {764 func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
@@ -838,7 +769,7 @@ func TestSupervisorEmitterLargeResultIsNotDuplicatedOnWire(t *testing.T) {
838 }769 }
839 defer file.Close()770 defer file.Close()
840 771
841- em := newSubagentEmitter(file, nil)772+ em := newSubagentEmitter(file)
842 em.setProtocolIdentity("agent-1")773 em.setProtocolIdentity("agent-1")
843 em.emit("turn.result", map[string]any{774 em.emit("turn.result", map[string]any{
844 "status": "succeeded",775 "status": "succeeded",
@@ -887,7 +818,7 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
887 }818 }
888 defer r.Close()819 defer r.Close()
889 820
890- em := newSubagentEmitter(w, nil)821+ em := newSubagentEmitter(w)
891 em.emit("turn_start", map[string]any{"step": 1})822 em.emit("turn_start", map[string]any{"step": 1})
892 _ = w.Close()823 _ = w.Close()
893 824
@@ -916,30 +847,6 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
916 }847 }
917 }848 }
918 849
919-func TestInitialTurnNumberIgnoresNestedProviderTurns(t *testing.T) {
920- path := filepath.Join(t.TempDir(), "events.jsonl")
921- log, err := subagents.OpenEventLog(path)
922- if err != nil {
923- t.Fatal(err)
924- }
925- if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
926- "step": float64(1), "lifetime_turns": 1, "current_run_turns": 1,
927- })); err != nil {
928- t.Fatal(err)
929- }
930- if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
931- "step": float64(99), "nested_turn": true,
932- })); err != nil {
933- t.Fatal(err)
934- }
935- if err := log.Close(); err != nil {
936- t.Fatal(err)
937- }
938- if got := initialTurnNumber(path); got != 1 {
939- t.Fatalf("initial turn number = %d, want 1", got)
940- }
941-}
942-
943 func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) {850 func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) {
944 var budget workerTurnBudget851 var budget workerTurnBudget
945 852
M packages/agent/subagents/agent.go +11 -0
@@ -182,6 +182,7 @@ type Agent struct {
182182 workspaceCleanup func() error
183183 workspaceCapture func() (WorkspaceCapture, error)
184184 onTurnIdle func()
185+ trace *TraceWriter
185186
186187 // OnTurnEnd, if set, fires once per prompt-level turn_end event
187188 // emitted by the subagent daemon wrapper. Provider/tool-loop
@@ -218,6 +219,16 @@ type Agent struct {
218219 // doesn't speak the daemon protocol).
219220 func (a *Agent) Inbox() *Inbox { return a.inbox }
220221
222+func (a *Agent) recordTrace(event TraceEvent) {
223+ if a == nil || a.trace == nil {
224+ return
225+ }
226+ if event.AgentID == "" {
227+ event.AgentID = a.ID
228+ }
229+ a.trace.Record(event)
230+}
231+
221232 // FastModeOverridesHost reports whether this child was created with an
222233 // explicit fast-mode override while the host setting was disabled.
223234 func (a *Agent) FastModeOverridesHost() bool {
@@ -182,6 +182,7 @@ type Agent struct {
182 workspaceCleanup func() error182 workspaceCleanup func() error
183 workspaceCapture func() (WorkspaceCapture, error)183 workspaceCapture func() (WorkspaceCapture, error)
184 onTurnIdle func()184 onTurnIdle func()
185+ trace *TraceWriter
185 186
186 // OnTurnEnd, if set, fires once per prompt-level turn_end event187 // OnTurnEnd, if set, fires once per prompt-level turn_end event
187 // emitted by the subagent daemon wrapper. Provider/tool-loop188 // emitted by the subagent daemon wrapper. Provider/tool-loop
@@ -218,6 +219,16 @@ type Agent struct {
218 // doesn't speak the daemon protocol).219 // doesn't speak the daemon protocol).
219 func (a *Agent) Inbox() *Inbox { return a.inbox }220 func (a *Agent) Inbox() *Inbox { return a.inbox }
220 221
222+func (a *Agent) recordTrace(event TraceEvent) {
223+ if a == nil || a.trace == nil {
224+ return
225+ }
226+ if event.AgentID == "" {
227+ event.AgentID = a.ID
228+ }
229+ a.trace.Record(event)
230+}
231+
221 // FastModeOverridesHost reports whether this child was created with an232 // FastModeOverridesHost reports whether this child was created with an
222 // explicit fast-mode override while the host setting was disabled.233 // explicit fast-mode override while the host setting was disabled.
223 func (a *Agent) FastModeOverridesHost() bool {234 func (a *Agent) FastModeOverridesHost() bool {
M packages/agent/subagents/persist.go +1 -0
@@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
13251325 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
13261326 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
13271327 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
1328+ a.trace = f.trace
13281329 a.runner = f.cfg.NewRunner(a)
13291330 if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {
13301331 if a.cancel != nil {
@@ -1325,6 +1325,7 @@ func (f *Supervisor) resumeWithHook(ctx context.Context, id string, resuming boo
1325 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })1325 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
1326 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }1326 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
1327 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }1327 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
1328+ a.trace = f.trace
1328 a.runner = f.cfg.NewRunner(a)1329 a.runner = f.cfg.NewRunner(a)
1329 if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {1330 if err := writeAgentMeta(a.stateDirectory(f.cfg.Root), a); err != nil {
1330 if a.cancel != nil {1331 if a.cancel != nil {
A packages/agent/subagents/projection.go +117 -0
@@ -0,0 +1,117 @@
1+package subagents
2+
3+import (
4+ "sort"
5+ "strings"
6+ "time"
7+)
8+
9+// Operation is a currently open factual operation inferred from its start
10+// boundary. Completed operations are deliberately not retained in the public
11+// projection: their terminal trace fact remains available as LastEvent.
12+type Operation struct {
13+ Type string
14+ AgentID string
15+ TurnID string
16+ CallID string
17+ StartedAt time.Time
18+}
19+
20+// Open is always true because the projection exposes only unmatched starts.
21+func (o Operation) Open() bool { return true }
22+
23+// Duration measures this open operation at now; callers supply now to make
24+// rendering and tests deterministic.
25+func (o Operation) Duration(now time.Time) time.Duration {
26+ if now.Before(o.StartedAt) {
27+ return 0
28+ }
29+ return now.Sub(o.StartedAt)
30+}
31+
32+// Label returns the shared concise label for an open operation.
33+func (o Operation) Label() string { return strings.TrimSuffix(o.Type, ".started") + " open" }
34+
35+// AgentTraceView is the only user-facing execution state. It intentionally
36+// contains operations and terminal facts rather than lifecycle guesses such as
37+// working, idle, or alive.
38+type AgentTraceView struct {
39+ AgentID string
40+ LastEvent TraceEvent
41+ OpenOperations []Operation
42+ Terminal string
43+}
44+
45+// ProjectTrace reduces a chronologically ordered trace into per-agent facts.
46+// Unknown events are retained as LastEvent but never guessed into an operation.
47+func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
48+ views := make(map[string]AgentTraceView)
49+ open := make(map[string]map[string]Operation)
50+ for _, event := range events {
51+ if event.AgentID == "" {
52+ continue
53+ }
54+ view := views[event.AgentID]
55+ view.AgentID = event.AgentID
56+ view.LastEvent = event
57+ key, starts, ends, terminal := traceBoundary(event)
58+ if starts {
59+ if open[event.AgentID] == nil {
60+ open[event.AgentID] = make(map[string]Operation)
61+ }
62+ callID, _ := event.Data["call_id"].(string)
63+ open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, StartedAt: event.Timestamp}
64+ }
65+ if ends {
66+ delete(open[event.AgentID], key)
67+ }
68+ if terminal != "" {
69+ view.Terminal = terminal
70+ delete(open, event.AgentID)
71+ }
72+ views[event.AgentID] = view
73+ }
74+ for agentID, operations := range open {
75+ view := views[agentID]
76+ for _, operation := range operations {
77+ view.OpenOperations = append(view.OpenOperations, operation)
78+ }
79+ sort.Slice(view.OpenOperations, func(i, j int) bool {
80+ left, right := view.OpenOperations[i], view.OpenOperations[j]
81+ if !left.StartedAt.Equal(right.StartedAt) {
82+ return left.StartedAt.Before(right.StartedAt)
83+ }
84+ if left.Type != right.Type {
85+ return left.Type < right.Type
86+ }
87+ if left.TurnID != right.TurnID {
88+ return left.TurnID < right.TurnID
89+ }
90+ return left.CallID < right.CallID
91+ })
92+ views[agentID] = view
93+ }
94+ return views
95+}
96+
97+func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {
98+ callID, _ := event.Data["call_id"].(string)
99+ key = event.Type + ":" + event.TurnID + ":" + callID
100+ switch event.Type {
101+ case "turn.started":
102+ return "turn:" + event.TurnID, true, false, ""
103+ case "turn.finished", "turn.failed", "turn.cancelled":
104+ return "turn:" + event.TurnID, false, true, ""
105+ case "tool.started":
106+ return "tool:" + event.TurnID + ":" + callID, true, false, ""
107+ case "tool.finished", "tool.failed", "tool.cancelled":
108+ return "tool:" + event.TurnID + ":" + callID, false, true, ""
109+ case "agent.finished":
110+ return key, false, false, "completed"
111+ case "agent.failed":
112+ return key, false, false, "failed"
113+ case "agent.cancelled":
114+ return key, false, false, "cancelled"
115+ }
116+ return key, false, false, ""
117+}
@@ -0,0 +1,117 @@
1+package subagents
2+
3+import (
4+ "sort"
5+ "strings"
6+ "time"
7+)
8+
9+// Operation is a currently open factual operation inferred from its start
10+// boundary. Completed operations are deliberately not retained in the public
11+// projection: their terminal trace fact remains available as LastEvent.
12+type Operation struct {
13+ Type string
14+ AgentID string
15+ TurnID string
16+ CallID string
17+ StartedAt time.Time
18+}
19+
20+// Open is always true because the projection exposes only unmatched starts.
21+func (o Operation) Open() bool { return true }
22+
23+// Duration measures this open operation at now; callers supply now to make
24+// rendering and tests deterministic.
25+func (o Operation) Duration(now time.Time) time.Duration {
26+ if now.Before(o.StartedAt) {
27+ return 0
28+ }
29+ return now.Sub(o.StartedAt)
30+}
31+
32+// Label returns the shared concise label for an open operation.
33+func (o Operation) Label() string { return strings.TrimSuffix(o.Type, ".started") + " open" }
34+
35+// AgentTraceView is the only user-facing execution state. It intentionally
36+// contains operations and terminal facts rather than lifecycle guesses such as
37+// working, idle, or alive.
38+type AgentTraceView struct {
39+ AgentID string
40+ LastEvent TraceEvent
41+ OpenOperations []Operation
42+ Terminal string
43+}
44+
45+// ProjectTrace reduces a chronologically ordered trace into per-agent facts.
46+// Unknown events are retained as LastEvent but never guessed into an operation.
47+func ProjectTrace(events []TraceEvent) map[string]AgentTraceView {
48+ views := make(map[string]AgentTraceView)
49+ open := make(map[string]map[string]Operation)
50+ for _, event := range events {
51+ if event.AgentID == "" {
52+ continue
53+ }
54+ view := views[event.AgentID]
55+ view.AgentID = event.AgentID
56+ view.LastEvent = event
57+ key, starts, ends, terminal := traceBoundary(event)
58+ if starts {
59+ if open[event.AgentID] == nil {
60+ open[event.AgentID] = make(map[string]Operation)
61+ }
62+ callID, _ := event.Data["call_id"].(string)
63+ open[event.AgentID][key] = Operation{Type: event.Type, AgentID: event.AgentID, TurnID: event.TurnID, CallID: callID, StartedAt: event.Timestamp}
64+ }
65+ if ends {
66+ delete(open[event.AgentID], key)
67+ }
68+ if terminal != "" {
69+ view.Terminal = terminal
70+ delete(open, event.AgentID)
71+ }
72+ views[event.AgentID] = view
73+ }
74+ for agentID, operations := range open {
75+ view := views[agentID]
76+ for _, operation := range operations {
77+ view.OpenOperations = append(view.OpenOperations, operation)
78+ }
79+ sort.Slice(view.OpenOperations, func(i, j int) bool {
80+ left, right := view.OpenOperations[i], view.OpenOperations[j]
81+ if !left.StartedAt.Equal(right.StartedAt) {
82+ return left.StartedAt.Before(right.StartedAt)
83+ }
84+ if left.Type != right.Type {
85+ return left.Type < right.Type
86+ }
87+ if left.TurnID != right.TurnID {
88+ return left.TurnID < right.TurnID
89+ }
90+ return left.CallID < right.CallID
91+ })
92+ views[agentID] = view
93+ }
94+ return views
95+}
96+
97+func traceBoundary(event TraceEvent) (key string, starts, ends bool, terminal string) {
98+ callID, _ := event.Data["call_id"].(string)
99+ key = event.Type + ":" + event.TurnID + ":" + callID
100+ switch event.Type {
101+ case "turn.started":
102+ return "turn:" + event.TurnID, true, false, ""
103+ case "turn.finished", "turn.failed", "turn.cancelled":
104+ return "turn:" + event.TurnID, false, true, ""
105+ case "tool.started":
106+ return "tool:" + event.TurnID + ":" + callID, true, false, ""
107+ case "tool.finished", "tool.failed", "tool.cancelled":
108+ return "tool:" + event.TurnID + ":" + callID, false, true, ""
109+ case "agent.finished":
110+ return key, false, false, "completed"
111+ case "agent.failed":
112+ return key, false, false, "failed"
113+ case "agent.cancelled":
114+ return key, false, false, "cancelled"
115+ }
116+ return key, false, false, ""
117+}
A packages/agent/subagents/projection_test.go +67 -0
@@ -0,0 +1,67 @@
1+package subagents
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) {
9+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
10+ views := ProjectTrace([]TraceEvent{
11+ {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
12+ {Seq: 2, Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1"},
13+ })
14+ view := views["agent-1"]
15+ if view.Terminal != "" {
16+ t.Fatalf("terminal = %q, want empty", view.Terminal)
17+ }
18+ if len(view.OpenOperations) != 2 {
19+ t.Fatalf("open operations = %#v, want turn and tool", view.OpenOperations)
20+ }
21+ for _, operation := range view.OpenOperations {
22+ if !operation.Open() || operation.Duration(started.Add(time.Minute)) <= 0 {
23+ t.Fatalf("operation = %#v, want open operation with duration", operation)
24+ }
25+ }
26+}
27+
28+func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) {
29+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
30+ views := ProjectTrace([]TraceEvent{
31+ {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
32+ {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"},
33+ {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
34+ {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
35+ {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
36+ {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"},
37+ })
38+ view := views["agent-1"]
39+ if len(view.OpenOperations) != 0 {
40+ t.Fatalf("open operations = %#v, want none after terminal agent event", view.OpenOperations)
41+ }
42+ if view.Terminal != "completed" {
43+ t.Fatalf("terminal = %q, want completed", view.Terminal)
44+ }
45+}
46+
47+func TestProjectTraceClosesFailedToolByCallID(t *testing.T) {
48+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
49+ view := ProjectTrace([]TraceEvent{
50+ {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
51+ {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
52+ {Timestamp: started.Add(time.Second), Type: "tool.failed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
53+ })["agent-1"]
54+ if len(view.OpenOperations) != 1 || view.OpenOperations[0].CallID != "two" {
55+ t.Fatalf("open operations = %#v, want only call two", view.OpenOperations)
56+ }
57+}
58+
59+func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) {
60+ view := ProjectTrace([]TraceEvent{
61+ {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
62+ {Type: "agent.failed", AgentID: "agent-1"},
63+ })["agent-1"]
64+ if len(view.OpenOperations) != 0 || view.Terminal != "failed" {
65+ t.Fatalf("view = %#v, want failed terminal with no open operations", view)
66+ }
67+}
@@ -0,0 +1,67 @@
1+package subagents
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestProjectTraceReportsOpenOperationInsteadOfLifecycleState(t *testing.T) {
9+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
10+ views := ProjectTrace([]TraceEvent{
11+ {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
12+ {Seq: 2, Timestamp: started.Add(time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1"},
13+ })
14+ view := views["agent-1"]
15+ if view.Terminal != "" {
16+ t.Fatalf("terminal = %q, want empty", view.Terminal)
17+ }
18+ if len(view.OpenOperations) != 2 {
19+ t.Fatalf("open operations = %#v, want turn and tool", view.OpenOperations)
20+ }
21+ for _, operation := range view.OpenOperations {
22+ if !operation.Open() || operation.Duration(started.Add(time.Minute)) <= 0 {
23+ t.Fatalf("operation = %#v, want open operation with duration", operation)
24+ }
25+ }
26+}
27+
28+func TestProjectTraceClosesPairedOperationsAndKeepsTerminalFact(t *testing.T) {
29+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
30+ views := ProjectTrace([]TraceEvent{
31+ {Seq: 1, Timestamp: started, Type: "turn.started", AgentID: "agent-1", TurnID: "turn-1"},
32+ {Seq: 2, Timestamp: started.Add(time.Second), Type: "turn.finished", AgentID: "agent-1", TurnID: "turn-1"},
33+ {Seq: 3, Timestamp: started.Add(2 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
34+ {Seq: 4, Timestamp: started.Add(3 * time.Second), Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
35+ {Seq: 5, Timestamp: started.Add(4 * time.Second), Type: "tool.finished", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
36+ {Seq: 6, Timestamp: started.Add(5 * time.Second), Type: "agent.finished", AgentID: "agent-1"},
37+ })
38+ view := views["agent-1"]
39+ if len(view.OpenOperations) != 0 {
40+ t.Fatalf("open operations = %#v, want none after terminal agent event", view.OpenOperations)
41+ }
42+ if view.Terminal != "completed" {
43+ t.Fatalf("terminal = %q, want completed", view.Terminal)
44+ }
45+}
46+
47+func TestProjectTraceClosesFailedToolByCallID(t *testing.T) {
48+ started := time.Date(2026, 8, 12, 7, 0, 0, 0, time.UTC)
49+ view := ProjectTrace([]TraceEvent{
50+ {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
51+ {Timestamp: started, Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "two"}},
52+ {Timestamp: started.Add(time.Second), Type: "tool.failed", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
53+ })["agent-1"]
54+ if len(view.OpenOperations) != 1 || view.OpenOperations[0].CallID != "two" {
55+ t.Fatalf("open operations = %#v, want only call two", view.OpenOperations)
56+ }
57+}
58+
59+func TestProjectTraceFailedAgentClosesOpenOperations(t *testing.T) {
60+ view := ProjectTrace([]TraceEvent{
61+ {Type: "tool.started", AgentID: "agent-1", TurnID: "turn-1", Data: map[string]any{"call_id": "one"}},
62+ {Type: "agent.failed", AgentID: "agent-1"},
63+ })["agent-1"]
64+ if len(view.OpenOperations) != 0 || view.Terminal != "failed" {
65+ t.Fatalf("view = %#v, want failed terminal with no open operations", view)
66+ }
67+}
M packages/agent/subagents/runner.go +45 -49
@@ -12,7 +12,6 @@ import (
1212 "os/exec"
1313 "path/filepath"
1414 "strings"
15- "sync"
1615 "time"
1716 )
1817
@@ -299,15 +298,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
299298 }
300299
301300 inboxPath := r.agent.InboxPath
302- logPath := r.agent.EventLogPath
303- if logPath == "" {
304- return fmt.Errorf("subagent: agent missing event log path")
305- }
306- log, err := OpenEventLog(logPath)
307- if err != nil {
308- return err
309- }
310- defer log.Close()
311301
312302 args := r.Command
313303 if len(args) == 0 {
@@ -337,7 +327,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
337327 cmd.Dir = r.agent.Dir
338328 cmd.Env = append(workerEnvironment(r.agent.Provider),
339329 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,
340- "ZUT_SUBAGENT_EVENT_LOG="+logPath,
341330 )
342331 if r.agent.HeartbeatInterval > 0 {
343332 cmd.Env = append(cmd.Env, "ZUT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String())
@@ -388,28 +377,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
388377 defer close(runnerDone)
389378 go r.stopOnContextDone(ctx, cmd, runnerDone)
390379
391- var logErrMu sync.Mutex
392- var logErr error
393- var stopOnLogErr sync.Once
394- appendLog := func(ev Event) {
395- if err := log.Append(ev); err != nil {
396- logErrMu.Lock()
397- if logErr == nil {
398- logErr = err
399- }
400- logErrMu.Unlock()
401- // A durable event log is part of the runner's recovery contract.
402- // Stop the worker rather than letting it continue in a state the
403- // supervisor cannot reconstruct after restart.
404- stopOnLogErr.Do(func() { _ = killProcessGroup(cmd) })
405- }
406- }
407- firstLogErr := func() error {
408- logErrMu.Lock()
409- defer logErrMu.Unlock()
410- return logErr
411- }
412-
413380 // stdout: parsed as JSONL. Every well-formed event is appended
414381 // to the durable log AND forwarded to the in-memory sink so the
415382 // dashboard updates without having to tail the file. Malformed
@@ -431,7 +398,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
431398 trimmed := strings.TrimRight(string(line), "\r\n")
432399 if trimmed != "" && !truncated {
433400 if ev, ok := parseEventLine(trimmed); ok {
434- appendLog(ev)
401+ recordWorkerTrace(r.agent, ev)
435402 if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil {
436403 sink.Transcript("error: metadata persistence failed: " + persistErr.Error())
437404 }
@@ -445,12 +412,12 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
445412 notifyPromptTurnEnd(r.agent, ev)
446413 } else {
447414 sink.Transcript(trimmed)
448- appendLog(NewEvent("stdout", map[string]any{"text": trimmed}))
415+ r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed)}})
449416 }
450417 } else if trimmed != "" {
451418 trimmed += "\n...[line truncated]"
452419 sink.Transcript(trimmed)
453- appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true}))
420+ r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed), "truncated": true}})
454421 }
455422 }
456423 if err != nil {
@@ -481,7 +448,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
481448 txt += "\n...[line truncated]"
482449 }
483450 sink.Transcript("stderr: " + txt)
484- appendLog(NewEvent("stderr", map[string]any{"text": txt, "truncated": truncated}))
451+ r.agent.recordTrace(TraceEvent{Type: "worker.stderr", Data: map[string]any{"length": len(txt), "truncated": truncated}})
485452 }
486453 if err != nil {
487454 return
@@ -502,23 +469,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
502469 if errors.Is(ctxErr, context.DeadlineExceeded) {
503470 reason = "deadline"
504471 }
505- appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))
506- if logErr := firstLogErr(); logErr != nil {
507- return fmt.Errorf("subagent event log: %w", logErr)
508- }
472+ r.agent.recordTrace(TraceEvent{Type: "worker.stopped", Data: map[string]any{"reason": reason}})
509473 return ctxErr
510474 }
511475 if err != nil {
512- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
513- if logErr := firstLogErr(); logErr != nil {
514- return fmt.Errorf("subagent event log: %v; worker: %w", logErr, err)
515- }
476+ r.agent.recordTrace(TraceEvent{Type: "worker.failed", Data: map[string]any{"reason": "exit", "code": exit, "error_kind": "exit"}})
516477 return err
517478 }
518- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0}))
519- if logErr := firstLogErr(); logErr != nil {
520- return fmt.Errorf("subagent event log: %w", logErr)
521- }
479+ r.agent.recordTrace(TraceEvent{Type: "worker.finished", Data: map[string]any{"reason": "exit", "code": 0}})
522480 sink.Activity("done")
523481 return nil
524482 }
@@ -938,6 +896,44 @@ func isAssistantStreamBoundary(eventType string) bool {
938896 }
939897 }
940898
899+// recordWorkerTrace reduces the worker's protocol event to a factual trace
900+// observation. It deliberately records identifiers and bounded metadata, not
901+// worker payloads, which can contain prompts, tool arguments, or credentials.
902+func recordWorkerTrace(agent *Agent, ev Event) {
903+ if agent == nil {
904+ return
905+ }
906+ typeName := strings.ReplaceAll(ev.Type, "_", ".")
907+ traceType := "worker.event"
908+ switch ev.Type {
909+ case "turn_start", EventTurnStarted:
910+ traceType = "turn.started"
911+ case "turn_end", EventTurnResult:
912+ traceType = "turn.finished"
913+ case "turn_failed", EventTurnFailed:
914+ traceType = "turn.failed"
915+ case "tool_call", EventToolStarted:
916+ traceType = "tool.started"
917+ case "tool_result", EventToolFinished:
918+ traceType = "tool.finished"
919+ case "agent_ready", EventAgentReady:
920+ traceType = "agent.ready"
921+ case "agent_stopped":
922+ traceType = "agent.stopped"
923+ }
924+ data := map[string]any{"worker_event": typeName}
925+ if name, ok := ev.Data["name"].(string); ok && name != "" {
926+ data["name"] = name
927+ }
928+ if status, ok := ev.Data["status"].(string); ok && status != "" {
929+ data["status"] = status
930+ }
931+ if callID, ok := ev.Data["call_id"].(string); ok && callID != "" {
932+ data["call_id"] = callID
933+ }
934+ agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})
935+}
936+
941937 // applyEventToSink translates an Event into Sink updates. Only a
942938 // few event types are interpreted; the rest still land in the
943939 // durable log via the caller.
@@ -12,7 +12,6 @@ import (
12 "os/exec"12 "os/exec"
13 "path/filepath"13 "path/filepath"
14 "strings"14 "strings"
15- "sync"
16 "time"15 "time"
17 )16 )
18 17
@@ -299,15 +298,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
299 }298 }
300 299
301 inboxPath := r.agent.InboxPath300 inboxPath := r.agent.InboxPath
302- logPath := r.agent.EventLogPath
303- if logPath == "" {
304- return fmt.Errorf("subagent: agent missing event log path")
305- }
306- log, err := OpenEventLog(logPath)
307- if err != nil {
308- return err
309- }
310- defer log.Close()
311 301
312 args := r.Command302 args := r.Command
313 if len(args) == 0 {303 if len(args) == 0 {
@@ -337,7 +327,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
337 cmd.Dir = r.agent.Dir327 cmd.Dir = r.agent.Dir
338 cmd.Env = append(workerEnvironment(r.agent.Provider),328 cmd.Env = append(workerEnvironment(r.agent.Provider),
339 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,329 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,
340- "ZUT_SUBAGENT_EVENT_LOG="+logPath,
341 )330 )
342 if r.agent.HeartbeatInterval > 0 {331 if r.agent.HeartbeatInterval > 0 {
343 cmd.Env = append(cmd.Env, "ZUT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String())332 cmd.Env = append(cmd.Env, "ZUT_SUBAGENT_HEARTBEAT_INTERVAL="+r.agent.HeartbeatInterval.String())
@@ -388,28 +377,6 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
388 defer close(runnerDone)377 defer close(runnerDone)
389 go r.stopOnContextDone(ctx, cmd, runnerDone)378 go r.stopOnContextDone(ctx, cmd, runnerDone)
390 379
391- var logErrMu sync.Mutex
392- var logErr error
393- var stopOnLogErr sync.Once
394- appendLog := func(ev Event) {
395- if err := log.Append(ev); err != nil {
396- logErrMu.Lock()
397- if logErr == nil {
398- logErr = err
399- }
400- logErrMu.Unlock()
401- // A durable event log is part of the runner's recovery contract.
402- // Stop the worker rather than letting it continue in a state the
403- // supervisor cannot reconstruct after restart.
404- stopOnLogErr.Do(func() { _ = killProcessGroup(cmd) })
405- }
406- }
407- firstLogErr := func() error {
408- logErrMu.Lock()
409- defer logErrMu.Unlock()
410- return logErr
411- }
412-
413 // stdout: parsed as JSONL. Every well-formed event is appended380 // stdout: parsed as JSONL. Every well-formed event is appended
414 // to the durable log AND forwarded to the in-memory sink so the381 // to the durable log AND forwarded to the in-memory sink so the
415 // dashboard updates without having to tail the file. Malformed382 // dashboard updates without having to tail the file. Malformed
@@ -431,7 +398,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
431 trimmed := strings.TrimRight(string(line), "\r\n")398 trimmed := strings.TrimRight(string(line), "\r\n")
432 if trimmed != "" && !truncated {399 if trimmed != "" && !truncated {
433 if ev, ok := parseEventLine(trimmed); ok {400 if ev, ok := parseEventLine(trimmed); ok {
434- appendLog(ev)401+ recordWorkerTrace(r.agent, ev)
435 if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil {402 if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil {
436 sink.Transcript("error: metadata persistence failed: " + persistErr.Error())403 sink.Transcript("error: metadata persistence failed: " + persistErr.Error())
437 }404 }
@@ -445,12 +412,12 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
445 notifyPromptTurnEnd(r.agent, ev)412 notifyPromptTurnEnd(r.agent, ev)
446 } else {413 } else {
447 sink.Transcript(trimmed)414 sink.Transcript(trimmed)
448- appendLog(NewEvent("stdout", map[string]any{"text": trimmed}))415+ r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed)}})
449 }416 }
450 } else if trimmed != "" {417 } else if trimmed != "" {
451 trimmed += "\n...[line truncated]"418 trimmed += "\n...[line truncated]"
452 sink.Transcript(trimmed)419 sink.Transcript(trimmed)
453- appendLog(NewEvent("stdout", map[string]any{"text": trimmed, "truncated": true}))420+ r.agent.recordTrace(TraceEvent{Type: "worker.stdout", Data: map[string]any{"length": len(trimmed), "truncated": true}})
454 }421 }
455 }422 }
456 if err != nil {423 if err != nil {
@@ -481,7 +448,7 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
481 txt += "\n...[line truncated]"448 txt += "\n...[line truncated]"
482 }449 }
483 sink.Transcript("stderr: " + txt)450 sink.Transcript("stderr: " + txt)
484- appendLog(NewEvent("stderr", map[string]any{"text": txt, "truncated": truncated}))451+ r.agent.recordTrace(TraceEvent{Type: "worker.stderr", Data: map[string]any{"length": len(txt), "truncated": truncated}})
485 }452 }
486 if err != nil {453 if err != nil {
487 return454 return
@@ -502,23 +469,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
502 if errors.Is(ctxErr, context.DeadlineExceeded) {469 if errors.Is(ctxErr, context.DeadlineExceeded) {
503 reason = "deadline"470 reason = "deadline"
504 }471 }
505- appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))472+ r.agent.recordTrace(TraceEvent{Type: "worker.stopped", Data: map[string]any{"reason": reason}})
506- if logErr := firstLogErr(); logErr != nil {
507- return fmt.Errorf("subagent event log: %w", logErr)
508- }
509 return ctxErr473 return ctxErr
510 }474 }
511 if err != nil {475 if err != nil {
512- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))476+ r.agent.recordTrace(TraceEvent{Type: "worker.failed", Data: map[string]any{"reason": "exit", "code": exit, "error_kind": "exit"}})
513- if logErr := firstLogErr(); logErr != nil {
514- return fmt.Errorf("subagent event log: %v; worker: %w", logErr, err)
515- }
516 return err477 return err
517 }478 }
518- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": 0}))479+ r.agent.recordTrace(TraceEvent{Type: "worker.finished", Data: map[string]any{"reason": "exit", "code": 0}})
519- if logErr := firstLogErr(); logErr != nil {
520- return fmt.Errorf("subagent event log: %w", logErr)
521- }
522 sink.Activity("done")480 sink.Activity("done")
523 return nil481 return nil
524 }482 }
@@ -938,6 +896,44 @@ func isAssistantStreamBoundary(eventType string) bool {
938 }896 }
939 }897 }
940 898
899+// recordWorkerTrace reduces the worker's protocol event to a factual trace
900+// observation. It deliberately records identifiers and bounded metadata, not
901+// worker payloads, which can contain prompts, tool arguments, or credentials.
902+func recordWorkerTrace(agent *Agent, ev Event) {
903+ if agent == nil {
904+ return
905+ }
906+ typeName := strings.ReplaceAll(ev.Type, "_", ".")
907+ traceType := "worker.event"
908+ switch ev.Type {
909+ case "turn_start", EventTurnStarted:
910+ traceType = "turn.started"
911+ case "turn_end", EventTurnResult:
912+ traceType = "turn.finished"
913+ case "turn_failed", EventTurnFailed:
914+ traceType = "turn.failed"
915+ case "tool_call", EventToolStarted:
916+ traceType = "tool.started"
917+ case "tool_result", EventToolFinished:
918+ traceType = "tool.finished"
919+ case "agent_ready", EventAgentReady:
920+ traceType = "agent.ready"
921+ case "agent_stopped":
922+ traceType = "agent.stopped"
923+ }
924+ data := map[string]any{"worker_event": typeName}
925+ if name, ok := ev.Data["name"].(string); ok && name != "" {
926+ data["name"] = name
927+ }
928+ if status, ok := ev.Data["status"].(string); ok && status != "" {
929+ data["status"] = status
930+ }
931+ if callID, ok := ev.Data["call_id"].(string); ok && callID != "" {
932+ data["call_id"] = callID
933+ }
934+ agent.recordTrace(TraceEvent{Type: traceType, Timestamp: ev.Time, TurnID: ev.TurnID, Data: data})
935+}
936+
941 // applyEventToSink translates an Event into Sink updates. Only a937 // applyEventToSink translates an Event into Sink updates. Only a
942 // few event types are interpreted; the rest still land in the938 // few event types are interpreted; the rest still land in the
943 // durable log via the caller.939 // durable log via the caller.
M packages/agent/subagents/runner_e2e_test.go +6 -74
@@ -106,34 +106,10 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te
106106 t.Fatal("Run did not return after deadline expiry")
107107 }
108108
109- events, err := ReadEventLog(a.EventLogPath)
110- if err != nil {
111- t.Fatalf("ReadEventLog: %v", err)
112- }
113- var sawDelta, sawGracefulTurnEnd bool
114- for _, event := range events {
115- if event.Type == "message.delta" && event.Data["delta"] == "partial answer" {
116- sawDelta = true
117- }
118- if event.Type == "turn_end" && event.Data["stop"] == "cancelled" {
119- sawGracefulTurnEnd = true
120- }
121- }
122- if !sawDelta || !sawGracefulTurnEnd {
123- t.Fatalf("deadline did not preserve a graceful partial turn: %s", formatEvents(events))
124- }
125-
126109 snapshot := a.Snapshot()
127110 if snapshot.LastAssistant != "partial answer" {
128111 t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
129112 }
130- replayed := &Agent{}
131- for _, event := range events {
132- replayEventTranscript(replayed, event)
133- }
134- if snapshot := replayed.Snapshot(); snapshot.LastAssistant != "partial answer" {
135- t.Fatalf("replayed partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
136- }
137113 }
138114
139115 func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
@@ -234,23 +210,18 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
234210 t.Fatalf("Spawn: %v", err)
235211 }
236212
237- // Wait until the durable log has at least one assistant_message
238- // from the initial task. That confirms stdout→log→follower.
213+ // Wait for the supervisor-owned in-memory transcript. Worker protocol
214+ // stdout is no longer duplicated into a per-agent durable event log.
239215 waitFor := func(want string) {
240216 t.Helper()
241- deadline := time.Now().Add(5 * time.Second)
217+ deadline := time.Now().Add(runnerProcessTimeout)
242218 for time.Now().Before(deadline) {
243- evs, _ := ReadEventLog(a.EventLogPath)
244- for _, ev := range evs {
245- if strings.Contains(eventText(ev), want) {
246- return
247- }
219+ if strings.Contains(strings.Join(a.Transcript(), "\n"), want) {
220+ return
248221 }
249222 time.Sleep(20 * time.Millisecond)
250223 }
251- evs, _ := ReadEventLog(a.EventLogPath)
252- t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s",
253- want, len(evs), formatEvents(evs), dumpEventsVerbose(evs))
224+ t.Fatalf("timed out waiting for %q in transcript: %q", want, a.Transcript())
254225 }
255226 waitFor("echo: first task")
256227
@@ -291,45 +262,6 @@ func retrySend(f *Supervisor, id, msg string, timeout time.Duration) error {
291262 return lastErr
292263 }
293264
294-func eventText(ev Event) string {
295- if ev.Type != "assistant_message" && ev.Type != "user_message" {
296- return ""
297- }
298- content, _ := ev.Data["content"].([]any)
299- var sb strings.Builder
300- for _, c := range content {
301- m, _ := c.(map[string]any)
302- if t, _ := m["type"].(string); t == "text" {
303- if txt, _ := m["text"].(string); txt != "" {
304- sb.WriteString(txt)
305- sb.WriteByte('\n')
306- }
307- }
308- }
309- return sb.String()
310-}
311-
312-func dumpEventsVerbose(evs []Event) string {
313- var sb strings.Builder
314- for _, ev := range evs {
315- sb.WriteString(ev.Type)
316- sb.WriteString("\t")
317- for k, v := range ev.Data {
318- sb.WriteString(k)
319- sb.WriteString("=")
320- switch vv := v.(type) {
321- case string:
322- sb.WriteString(vv)
323- default:
324- sb.WriteString("<...>")
325- }
326- sb.WriteString(" ")
327- }
328- sb.WriteString("\n")
329- }
330- return sb.String()
331-}
332-
333265 func formatEvents(evs []Event) string {
334266 var sb strings.Builder
335267 for _, ev := range evs {
@@ -106,34 +106,10 @@ func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *te
106 t.Fatal("Run did not return after deadline expiry")106 t.Fatal("Run did not return after deadline expiry")
107 }107 }
108 108
109- events, err := ReadEventLog(a.EventLogPath)
110- if err != nil {
111- t.Fatalf("ReadEventLog: %v", err)
112- }
113- var sawDelta, sawGracefulTurnEnd bool
114- for _, event := range events {
115- if event.Type == "message.delta" && event.Data["delta"] == "partial answer" {
116- sawDelta = true
117- }
118- if event.Type == "turn_end" && event.Data["stop"] == "cancelled" {
119- sawGracefulTurnEnd = true
120- }
121- }
122- if !sawDelta || !sawGracefulTurnEnd {
123- t.Fatalf("deadline did not preserve a graceful partial turn: %s", formatEvents(events))
124- }
125-
126 snapshot := a.Snapshot()109 snapshot := a.Snapshot()
127 if snapshot.LastAssistant != "partial answer" {110 if snapshot.LastAssistant != "partial answer" {
128 t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer")111 t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
129 }112 }
130- replayed := &Agent{}
131- for _, event := range events {
132- replayEventTranscript(replayed, event)
133- }
134- if snapshot := replayed.Snapshot(); snapshot.LastAssistant != "partial answer" {
135- t.Fatalf("replayed partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
136- }
137 }113 }
138 114
139 func TestExecRunnerCancelsBlockedWorker(t *testing.T) {115 func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
@@ -234,23 +210,18 @@ func TestRunnerEndToEndWithStubChild(t *testing.T) {
234 t.Fatalf("Spawn: %v", err)210 t.Fatalf("Spawn: %v", err)
235 }211 }
236 212
237- // Wait until the durable log has at least one assistant_message213+ // Wait for the supervisor-owned in-memory transcript. Worker protocol
238- // from the initial task. That confirms stdout→log→follower.214+ // stdout is no longer duplicated into a per-agent durable event log.
239 waitFor := func(want string) {215 waitFor := func(want string) {
240 t.Helper()216 t.Helper()
241- deadline := time.Now().Add(5 * time.Second)217+ deadline := time.Now().Add(runnerProcessTimeout)
242 for time.Now().Before(deadline) {218 for time.Now().Before(deadline) {
243- evs, _ := ReadEventLog(a.EventLogPath)219+ if strings.Contains(strings.Join(a.Transcript(), "\n"), want) {
244- for _, ev := range evs {220+ return
245- if strings.Contains(eventText(ev), want) {
246- return
247- }
248 }221 }
249 time.Sleep(20 * time.Millisecond)222 time.Sleep(20 * time.Millisecond)
250 }223 }
251- evs, _ := ReadEventLog(a.EventLogPath)224+ t.Fatalf("timed out waiting for %q in transcript: %q", want, a.Transcript())
252- t.Fatalf("timed out waiting for %q in event log; got %d events:\n%s\n%s",
253- want, len(evs), formatEvents(evs), dumpEventsVerbose(evs))
254 }225 }
255 waitFor("echo: first task")226 waitFor("echo: first task")
256 227
@@ -291,45 +262,6 @@ func retrySend(f *Supervisor, id, msg string, timeout time.Duration) error {
291 return lastErr262 return lastErr
292 }263 }
293 264
294-func eventText(ev Event) string {
295- if ev.Type != "assistant_message" && ev.Type != "user_message" {
296- return ""
297- }
298- content, _ := ev.Data["content"].([]any)
299- var sb strings.Builder
300- for _, c := range content {
301- m, _ := c.(map[string]any)
302- if t, _ := m["type"].(string); t == "text" {
303- if txt, _ := m["text"].(string); txt != "" {
304- sb.WriteString(txt)
305- sb.WriteByte('\n')
306- }
307- }
308- }
309- return sb.String()
310-}
311-
312-func dumpEventsVerbose(evs []Event) string {
313- var sb strings.Builder
314- for _, ev := range evs {
315- sb.WriteString(ev.Type)
316- sb.WriteString("\t")
317- for k, v := range ev.Data {
318- sb.WriteString(k)
319- sb.WriteString("=")
320- switch vv := v.(type) {
321- case string:
322- sb.WriteString(vv)
323- default:
324- sb.WriteString("<...>")
325- }
326- sb.WriteString(" ")
327- }
328- sb.WriteString("\n")
329- }
330- return sb.String()
331-}
332-
333 func formatEvents(evs []Event) string {265 func formatEvents(evs []Event) string {
334 var sb strings.Builder266 var sb strings.Builder
335 for _, ev := range evs {267 for _, ev := range evs {
M packages/agent/subagents/supervisor.go +88 -0
@@ -21,6 +21,8 @@ import (
2121 "sync"
2222 "sync/atomic"
2323 "time"
24+
25+ "github.com/google/uuid"
2426 )
2527
2628 // Status is the high-level lifecycle state of an Agent.
@@ -85,6 +87,12 @@ type Config struct {
8587 // keeping command-backed credentials out of argv and the environment.
8688 ResolveCredential func(ctx context.Context, provider string) (Credential, error)
8789
90+ // TraceDir enables local execution tracing. When non-empty, the supervisor
91+ // creates one private bundle beneath this directory and is its sole writer.
92+ // An empty directory leaves tracing disabled.
93+ TraceDir string
94+ TraceMode TraceMode
95+
8896 // Now is a clock seam for tests; defaults to time.Now.
8997 Now func() time.Time
9098 }
@@ -134,6 +142,7 @@ type Supervisor struct {
134142 activeByParent map[string]int
135143 activeByBatch map[string]int
136144 batches map[string]*Batch
145+ trace *TraceWriter
137146
138147 // activeSession is the host session id the dashboard is
139148 // currently scoped to. When non-empty, SnapshotAll filters out
@@ -163,6 +172,17 @@ func New(cfg Config) *Supervisor {
163172 }
164173 }
165174 cfg.Policy.normalize()
175+ trace := NewMemoryTraceWriter()
176+ if cfg.TraceDir != "" {
177+ // Tracing is observational only: inability to create its optional local
178+ // bundle must not prevent the supervisor from running workers. The
179+ // in-memory trace remains the sole status projection in that case.
180+ if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil {
181+ trace = bundle
182+ } else {
183+ trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}})
184+ }
185+ }
166186 lifetimeParent := cfg.Context
167187 if lifetimeParent == nil {
168188 lifetimeParent = context.Background()
@@ -176,6 +196,7 @@ func New(cfg Config) *Supervisor {
176196 activeByParent: map[string]int{},
177197 activeByBatch: map[string]int{},
178198 batches: map[string]*Batch{},
199+ trace: trace,
179200 }
180201 }
181202
@@ -551,6 +572,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
551572 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
552573 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
553574 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
575+ a.trace = f.trace
554576 a.runner = f.cfg.NewRunner(a)
555577 if err := writeAgentMeta(stateDir, a); err != nil {
556578 if a.cancel != nil {
@@ -569,6 +591,9 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
569591 f.queue = append(f.queue, a)
570592 f.mu.Unlock()
571593 leaseOwned = false
594+ f.recordTrace(TraceEvent{Type: "agent.spawned", AgentID: a.ID, Data: map[string]any{
595+ "task_length": len(task), "workspace_mode": string(workspaceMode),
596+ }})
572597
573598 // Persisted metadata was written before queue admission, so a later
574599 // supervisor can reconstruct work that never reached a process.
@@ -582,6 +607,57 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
582607 // expected to have already trimmed and expanded the text.
583608 // CancelTurn requests cancellation of the current turn while keeping the
584609 // worker process alive for a later follow-up.
610+func traceErrorKind(err error) string {
611+ if err == nil {
612+ return ""
613+ }
614+ switch {
615+ case errors.Is(err, context.Canceled):
616+ return "canceled"
617+ case errors.Is(err, context.DeadlineExceeded):
618+ return "deadline_exceeded"
619+ default:
620+ return "error"
621+ }
622+}
623+
624+func (f *Supervisor) recordTrace(event TraceEvent) {
625+ if f == nil || f.trace == nil {
626+ return
627+ }
628+ f.trace.Record(event)
629+}
630+
631+// TraceDir returns the opt-in bundle directory, or an empty string when tracing
632+// is disabled.
633+func (f *Supervisor) TraceDir() string {
634+ if f == nil || f.trace == nil {
635+ return ""
636+ }
637+ return f.trace.Dir()
638+}
639+
640+// TraceViews projects the supervisor's factual trace into the diagnostic state
641+// for each observed agent. It intentionally does not expose lifecycle labels.
642+func (f *Supervisor) TraceViews() map[string]AgentTraceView {
643+ if f == nil || f.trace == nil {
644+ return map[string]AgentTraceView{}
645+ }
646+ return ProjectTrace(f.trace.Events())
647+}
648+
649+// Close flushes and closes the optional trace bundle. It does not stop agents;
650+// their lifetime remains controlled by the supervisor context and Stop.
651+func (f *Supervisor) Close() error { return f.CloseContext(context.Background()) }
652+
653+// CloseContext flushes and closes the optional trace bundle within ctx.
654+func (f *Supervisor) CloseContext(ctx context.Context) error {
655+ if f == nil || f.trace == nil {
656+ return nil
657+ }
658+ return f.trace.CloseContext(ctx)
659+}
660+
585661 func (f *Supervisor) CancelTurn(id string) error {
586662 a := f.Get(id)
587663 if a == nil {
@@ -591,6 +667,7 @@ func (f *Supervisor) CancelTurn(id string) error {
591667 return fmt.Errorf("subagents: agent %s has no inbox", a.ID)
592668 }
593669 a.setTurnState(TurnCanceling, a.CurrentTurnID())
670+ f.recordTrace(TraceEvent{Type: "turn.cancel.requested", AgentID: a.ID, TurnID: a.CurrentTurnID()})
594671 return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"}))
595672 }
596673
@@ -602,6 +679,7 @@ func (f *Supervisor) SendUserTurn(id, text string) error {
602679 if a.inbox == nil {
603680 return fmt.Errorf("subagents: agent %s has no inbox", a.ID)
604681 }
682+ f.recordTrace(TraceEvent{Type: "turn.requested", AgentID: a.ID, Data: map[string]any{"prompt_length": len(text)}})
605683 return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text}))
606684 }
607685
@@ -621,6 +699,7 @@ func (f *Supervisor) run(a *Agent) {
621699 err = context.Canceled
622700 }
623701 } else {
702+ f.recordTrace(TraceEvent{Type: "agent.started", AgentID: a.ID})
624703 a.setActivity("starting")
625704 if a.runner == nil {
626705 err = errors.New("subagents: agent has no runner")
@@ -659,6 +738,14 @@ func (f *Supervisor) run(a *Agent) {
659738 }
660739 finalStatus := a.status
661740 a.mu.Unlock()
741+ terminalType := "agent.finished"
742+ switch {
743+ case finalStatus == StatusKilled:
744+ terminalType = "agent.cancelled"
745+ case terminalErr != nil:
746+ terminalType = "agent.failed"
747+ }
748+ f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}})
662749
663750 switch finalStatus {
664751 case StatusKilled:
@@ -1026,6 +1113,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu
10261113 // StopAll cancels every running agent. Used on shutdown.
10271114 func (f *Supervisor) StopAll() {
10281115 _ = f.stopAll(context.Background())
1116+ _ = f.Close()
10291117 }
10301118
10311119 // StopAllContext is the context-aware shutdown variant. It returns the
@@ -21,6 +21,8 @@ import (
21 "sync"21 "sync"
22 "sync/atomic"22 "sync/atomic"
23 "time"23 "time"
24+
25+ "github.com/google/uuid"
24 )26 )
25 27
26 // Status is the high-level lifecycle state of an Agent.28 // Status is the high-level lifecycle state of an Agent.
@@ -85,6 +87,12 @@ type Config struct {
85 // keeping command-backed credentials out of argv and the environment.87 // keeping command-backed credentials out of argv and the environment.
86 ResolveCredential func(ctx context.Context, provider string) (Credential, error)88 ResolveCredential func(ctx context.Context, provider string) (Credential, error)
87 89
90+ // TraceDir enables local execution tracing. When non-empty, the supervisor
91+ // creates one private bundle beneath this directory and is its sole writer.
92+ // An empty directory leaves tracing disabled.
93+ TraceDir string
94+ TraceMode TraceMode
95+
88 // Now is a clock seam for tests; defaults to time.Now.96 // Now is a clock seam for tests; defaults to time.Now.
89 Now func() time.Time97 Now func() time.Time
90 }98 }
@@ -134,6 +142,7 @@ type Supervisor struct {
134 activeByParent map[string]int142 activeByParent map[string]int
135 activeByBatch map[string]int143 activeByBatch map[string]int
136 batches map[string]*Batch144 batches map[string]*Batch
145+ trace *TraceWriter
137 146
138 // activeSession is the host session id the dashboard is147 // activeSession is the host session id the dashboard is
139 // currently scoped to. When non-empty, SnapshotAll filters out148 // currently scoped to. When non-empty, SnapshotAll filters out
@@ -163,6 +172,17 @@ func New(cfg Config) *Supervisor {
163 }172 }
164 }173 }
165 cfg.Policy.normalize()174 cfg.Policy.normalize()
175+ trace := NewMemoryTraceWriter()
176+ if cfg.TraceDir != "" {
177+ // Tracing is observational only: inability to create its optional local
178+ // bundle must not prevent the supervisor from running workers. The
179+ // in-memory trace remains the sole status projection in that case.
180+ if bundle, err := NewTraceWriter(filepath.Join(cfg.TraceDir, uuid.NewString()), cfg.TraceMode); err == nil {
181+ trace = bundle
182+ } else {
183+ trace.Record(TraceEvent{Type: "trace.bundle.failed", Data: map[string]any{"error_kind": "create"}})
184+ }
185+ }
166 lifetimeParent := cfg.Context186 lifetimeParent := cfg.Context
167 if lifetimeParent == nil {187 if lifetimeParent == nil {
168 lifetimeParent = context.Background()188 lifetimeParent = context.Background()
@@ -176,6 +196,7 @@ func New(cfg Config) *Supervisor {
176 activeByParent: map[string]int{},196 activeByParent: map[string]int{},
177 activeByBatch: map[string]int{},197 activeByBatch: map[string]int{},
178 batches: map[string]*Batch{},198 batches: map[string]*Batch{},
199+ trace: trace,
179 }200 }
180 }201 }
181 202
@@ -551,6 +572,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
551 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })572 a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
552 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }573 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
553 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }574 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
575+ a.trace = f.trace
554 a.runner = f.cfg.NewRunner(a)576 a.runner = f.cfg.NewRunner(a)
555 if err := writeAgentMeta(stateDir, a); err != nil {577 if err := writeAgentMeta(stateDir, a); err != nil {
556 if a.cancel != nil {578 if a.cancel != nil {
@@ -569,6 +591,9 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
569 f.queue = append(f.queue, a)591 f.queue = append(f.queue, a)
570 f.mu.Unlock()592 f.mu.Unlock()
571 leaseOwned = false593 leaseOwned = false
594+ f.recordTrace(TraceEvent{Type: "agent.spawned", AgentID: a.ID, Data: map[string]any{
595+ "task_length": len(task), "workspace_mode": string(workspaceMode),
596+ }})
572 597
573 // Persisted metadata was written before queue admission, so a later598 // Persisted metadata was written before queue admission, so a later
574 // supervisor can reconstruct work that never reached a process.599 // supervisor can reconstruct work that never reached a process.
@@ -582,6 +607,57 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
582 // expected to have already trimmed and expanded the text.607 // expected to have already trimmed and expanded the text.
583 // CancelTurn requests cancellation of the current turn while keeping the608 // CancelTurn requests cancellation of the current turn while keeping the
584 // worker process alive for a later follow-up.609 // worker process alive for a later follow-up.
610+func traceErrorKind(err error) string {
611+ if err == nil {
612+ return ""
613+ }
614+ switch {
615+ case errors.Is(err, context.Canceled):
616+ return "canceled"
617+ case errors.Is(err, context.DeadlineExceeded):
618+ return "deadline_exceeded"
619+ default:
620+ return "error"
621+ }
622+}
623+
624+func (f *Supervisor) recordTrace(event TraceEvent) {
625+ if f == nil || f.trace == nil {
626+ return
627+ }
628+ f.trace.Record(event)
629+}
630+
631+// TraceDir returns the opt-in bundle directory, or an empty string when tracing
632+// is disabled.
633+func (f *Supervisor) TraceDir() string {
634+ if f == nil || f.trace == nil {
635+ return ""
636+ }
637+ return f.trace.Dir()
638+}
639+
640+// TraceViews projects the supervisor's factual trace into the diagnostic state
641+// for each observed agent. It intentionally does not expose lifecycle labels.
642+func (f *Supervisor) TraceViews() map[string]AgentTraceView {
643+ if f == nil || f.trace == nil {
644+ return map[string]AgentTraceView{}
645+ }
646+ return ProjectTrace(f.trace.Events())
647+}
648+
649+// Close flushes and closes the optional trace bundle. It does not stop agents;
650+// their lifetime remains controlled by the supervisor context and Stop.
651+func (f *Supervisor) Close() error { return f.CloseContext(context.Background()) }
652+
653+// CloseContext flushes and closes the optional trace bundle within ctx.
654+func (f *Supervisor) CloseContext(ctx context.Context) error {
655+ if f == nil || f.trace == nil {
656+ return nil
657+ }
658+ return f.trace.CloseContext(ctx)
659+}
660+
585 func (f *Supervisor) CancelTurn(id string) error {661 func (f *Supervisor) CancelTurn(id string) error {
586 a := f.Get(id)662 a := f.Get(id)
587 if a == nil {663 if a == nil {
@@ -591,6 +667,7 @@ func (f *Supervisor) CancelTurn(id string) error {
591 return fmt.Errorf("subagents: agent %s has no inbox", a.ID)667 return fmt.Errorf("subagents: agent %s has no inbox", a.ID)
592 }668 }
593 a.setTurnState(TurnCanceling, a.CurrentTurnID())669 a.setTurnState(TurnCanceling, a.CurrentTurnID())
670+ f.recordTrace(TraceEvent{Type: "turn.cancel.requested", AgentID: a.ID, TurnID: a.CurrentTurnID()})
594 return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"}))671 return a.inbox.SendCommand(NewCommand(CommandTurnCancel, a.ID, a.CurrentTurnID(), TurnCancelPayload{Reason: "user"}))
595 }672 }
596 673
@@ -602,6 +679,7 @@ func (f *Supervisor) SendUserTurn(id, text string) error {
602 if a.inbox == nil {679 if a.inbox == nil {
603 return fmt.Errorf("subagents: agent %s has no inbox", a.ID)680 return fmt.Errorf("subagents: agent %s has no inbox", a.ID)
604 }681 }
682+ f.recordTrace(TraceEvent{Type: "turn.requested", AgentID: a.ID, Data: map[string]any{"prompt_length": len(text)}})
605 return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text}))683 return a.inbox.SendCommand(NewCommand(CommandTurnStart, a.ID, a.CurrentTurnID(), TurnStartPayload{Prompt: text}))
606 }684 }
607 685
@@ -621,6 +699,7 @@ func (f *Supervisor) run(a *Agent) {
621 err = context.Canceled699 err = context.Canceled
622 }700 }
623 } else {701 } else {
702+ f.recordTrace(TraceEvent{Type: "agent.started", AgentID: a.ID})
624 a.setActivity("starting")703 a.setActivity("starting")
625 if a.runner == nil {704 if a.runner == nil {
626 err = errors.New("subagents: agent has no runner")705 err = errors.New("subagents: agent has no runner")
@@ -659,6 +738,14 @@ func (f *Supervisor) run(a *Agent) {
659 }738 }
660 finalStatus := a.status739 finalStatus := a.status
661 a.mu.Unlock()740 a.mu.Unlock()
741+ terminalType := "agent.finished"
742+ switch {
743+ case finalStatus == StatusKilled:
744+ terminalType = "agent.cancelled"
745+ case terminalErr != nil:
746+ terminalType = "agent.failed"
747+ }
748+ f.recordTrace(TraceEvent{Type: terminalType, AgentID: a.ID, Data: map[string]any{"status": string(finalStatus), "error": traceErrorKind(terminalErr)}})
662 749
663 switch finalStatus {750 switch finalStatus {
664 case StatusKilled:751 case StatusKilled:
@@ -1026,6 +1113,7 @@ func (f *Supervisor) stopAgents(ctx context.Context, agents []*Agent, origin Shu
1026 // StopAll cancels every running agent. Used on shutdown.1113 // StopAll cancels every running agent. Used on shutdown.
1027 func (f *Supervisor) StopAll() {1114 func (f *Supervisor) StopAll() {
1028 _ = f.stopAll(context.Background())1115 _ = f.stopAll(context.Background())
1116+ _ = f.Close()
1029 }1117 }
1030 1118
1031 // StopAllContext is the context-aware shutdown variant. It returns the1119 // StopAllContext is the context-aware shutdown variant. It returns the
A packages/agent/subagents/trace.go +635 -0
@@ -0,0 +1,635 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "reflect"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+// TraceMode controls how much data a trace keeps. Normal is the safe default:
17+// event metadata is retained, while payloads and values under sensitive keys
18+// are removed. Detailed is intended for explicitly enabled local debugging.
19+type TraceMode string
20+
21+const (
22+ TraceModeNormal TraceMode = "normal"
23+ TraceModeDetailed TraceMode = "detailed"
24+)
25+
26+// Normal and Detailed are short aliases useful to callers configuring a
27+// TraceWriter. Invalid modes are treated as Normal.
28+const (
29+ Normal = TraceModeNormal
30+ Detailed = TraceModeDetailed
31+)
32+
33+func (m TraceMode) valid() TraceMode {
34+ if m == TraceModeDetailed {
35+ return m
36+ }
37+ return TraceModeNormal
38+}
39+
40+// TraceEvent is one entry in trace.jsonl. Payload is never embedded in the
41+// JSONL stream: detailed traces store it as a separate file and normal traces
42+// discard it. Data is for small event metadata and is recursively redacted in
43+// normal mode when a key identifies a secret or payload.
44+type TraceEvent struct {
45+ Seq uint64 `json:"seq"`
46+ Timestamp time.Time `json:"timestamp"`
47+ Type string `json:"type"`
48+ AgentID string `json:"agent_id,omitempty"`
49+ TurnID string `json:"turn_id,omitempty"`
50+ Data map[string]any `json:"data,omitempty"`
51+ Payload any `json:"-"`
52+ PayloadFile string `json:"payload_file,omitempty"`
53+}
54+
55+// NewTraceEvent constructs an event with a UTC timestamp. A TraceWriter
56+// assigns Seq when the event is recorded; callers should not assign it.
57+func NewTraceEvent(typ string, data map[string]any) TraceEvent {
58+ return TraceEvent{
59+ Timestamp: time.Now().UTC(),
60+ Type: typ,
61+ Data: data,
62+ }
63+}
64+
65+// TraceManifest describes the files in a trace bundle.
66+type TraceManifest struct {
67+ Version int `json:"version"`
68+ Mode TraceMode `json:"mode"`
69+ CreatedAt time.Time `json:"created_at"`
70+ TraceFile string `json:"trace_file"`
71+ Payloads string `json:"payloads"`
72+}
73+
74+// TraceWriter writes a trace bundle. Record is deliberately asynchronous: it
75+// only snapshots the event and adds it to an in-memory queue. Disk failures
76+// are retained for Close, but never returned from Record or allowed to affect
77+// the agent that is being traced.
78+type TraceWriter struct {
79+ mu sync.Mutex
80+ cond *sync.Cond
81+ pending []traceRecord
82+ closed bool
83+ finished chan struct{}
84+ closeOnce sync.Once
85+ closeDone chan struct{}
86+
87+ dir string
88+ mode TraceMode
89+ traceFile *os.File
90+ payloadDir string
91+ memory bool
92+ sequence uint64
93+ dropped uint64
94+ events []TraceEvent
95+
96+ errorMu sync.Mutex
97+ err error
98+}
99+
100+type traceRecord struct {
101+ event TraceEvent
102+ payload []byte
103+ barrier chan struct{}
104+}
105+
106+const (
107+ traceQueueInitialCapacity = 64
108+ traceQueueLimit = 1024
109+ memoryTraceEventLimit = 4096
110+)
111+
112+// NewMemoryTraceWriter creates the non-persistent projection source used when
113+// bundle capture is disabled. It retains only normal-mode event metadata.
114+func NewMemoryTraceWriter() *TraceWriter {
115+ w := &TraceWriter{
116+ mode: TraceModeNormal,
117+ memory: true,
118+ pending: make([]traceRecord, 0, traceQueueInitialCapacity),
119+ finished: make(chan struct{}),
120+ closeDone: make(chan struct{}),
121+ events: make([]TraceEvent, 0, traceQueueInitialCapacity),
122+ }
123+ w.cond = sync.NewCond(&w.mu)
124+ go w.run()
125+ return w
126+}
127+
128+// NewTraceWriter creates a private trace bundle at dir. The directory and its
129+// files are created with restrictive permissions. If mode is omitted or
130+// invalid, TraceModeNormal is used.
131+func NewTraceWriter(dir string, modes ...TraceMode) (*TraceWriter, error) {
132+ mode := TraceModeNormal
133+ if len(modes) != 0 {
134+ mode = modes[0].valid()
135+ }
136+ if dir == "" {
137+ return nil, errors.New("trace writer: empty directory")
138+ }
139+ absolute, err := filepath.Abs(dir)
140+ if err != nil {
141+ return nil, fmt.Errorf("trace writer path: %w", err)
142+ }
143+ if err := os.MkdirAll(absolute, 0o700); err != nil {
144+ return nil, fmt.Errorf("trace writer directory: %w", err)
145+ }
146+ // MkdirAll does not tighten an existing directory's permissions.
147+ if err := os.Chmod(absolute, 0o700); err != nil {
148+ return nil, fmt.Errorf("trace writer directory permissions: %w", err)
149+ }
150+ if _, err := os.Stat(filepath.Join(absolute, "trace.jsonl")); err == nil {
151+ return nil, fmt.Errorf("trace writer: bundle already exists")
152+ } else if !errors.Is(err, os.ErrNotExist) {
153+ return nil, fmt.Errorf("trace writer trace file: %w", err)
154+ }
155+ payloadDir := filepath.Join(absolute, "payloads")
156+ if err := os.MkdirAll(payloadDir, 0o700); err != nil {
157+ return nil, fmt.Errorf("trace writer payload directory: %w", err)
158+ }
159+ if err := os.Chmod(payloadDir, 0o700); err != nil {
160+ return nil, fmt.Errorf("trace writer payload permissions: %w", err)
161+ }
162+
163+ traceFile, err := os.OpenFile(filepath.Join(absolute, "trace.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
164+ if err != nil {
165+ return nil, fmt.Errorf("trace writer trace file: %w", err)
166+ }
167+ if err := traceFile.Chmod(0o600); err != nil {
168+ _ = traceFile.Close()
169+ return nil, fmt.Errorf("trace writer trace permissions: %w", err)
170+ }
171+
172+ manifest := TraceManifest{
173+ Version: 1,
174+ Mode: mode,
175+ CreatedAt: time.Now().UTC(),
176+ TraceFile: "trace.jsonl",
177+ Payloads: "payloads",
178+ }
179+ if err := writeTraceManifest(filepath.Join(absolute, "manifest.json"), manifest); err != nil {
180+ _ = traceFile.Close()
181+ return nil, err
182+ }
183+
184+ w := &TraceWriter{
185+ dir: absolute,
186+ mode: mode,
187+ traceFile: traceFile,
188+ payloadDir: payloadDir,
189+ pending: make([]traceRecord, 0, traceQueueInitialCapacity),
190+ finished: make(chan struct{}),
191+ closeDone: make(chan struct{}),
192+ events: make([]TraceEvent, 0, traceQueueInitialCapacity),
193+ }
194+ w.cond = sync.NewCond(&w.mu)
195+ go w.run()
196+ return w, nil
197+}
198+
199+func writeTraceManifest(path string, manifest TraceManifest) error {
200+ data, err := json.MarshalIndent(manifest, "", " ")
201+ if err != nil {
202+ return fmt.Errorf("trace manifest encode: %w", err)
203+ }
204+ data = append(data, '\n')
205+ file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
206+ if err != nil {
207+ return fmt.Errorf("trace manifest open: %w", err)
208+ }
209+ defer file.Close()
210+ if err := file.Chmod(0o600); err != nil {
211+ return fmt.Errorf("trace manifest permissions: %w", err)
212+ }
213+ if _, err := file.Write(data); err != nil {
214+ return fmt.Errorf("trace manifest write: %w", err)
215+ }
216+ if err := file.Sync(); err != nil {
217+ return fmt.Errorf("trace manifest sync: %w", err)
218+ }
219+ return nil
220+}
221+
222+// Dir returns the trace bundle directory.
223+func (w *TraceWriter) Dir() string {
224+ if w == nil {
225+ return ""
226+ }
227+ return w.dir
228+}
229+
230+// Mode returns the effective mode of the bundle.
231+func (w *TraceWriter) Mode() TraceMode {
232+ if w == nil {
233+ return TraceModeNormal
234+ }
235+ return w.mode
236+}
237+
238+// Record snapshots and queues an event. It never returns an error. A nil
239+// writer is a no-op, which makes tracing safe to leave disabled by default.
240+func (w *TraceWriter) Record(event TraceEvent) {
241+ if w == nil {
242+ return
243+ }
244+ record, ok := w.prepare(event)
245+ if !ok {
246+ return
247+ }
248+ w.mu.Lock()
249+ defer w.mu.Unlock()
250+ if w.closed {
251+ return
252+ }
253+ w.sequence++
254+ record.event.Seq = w.sequence
255+ if len(w.pending) >= traceQueueLimit {
256+ for index, pending := range w.pending {
257+ if pending.barrier == nil {
258+ copy(w.pending[index:], w.pending[index+1:])
259+ w.pending[len(w.pending)-1] = traceRecord{}
260+ w.pending = w.pending[:len(w.pending)-1]
261+ w.dropped++
262+ w.setError(errors.New("trace event queue overflow"))
263+ break
264+ }
265+ }
266+ }
267+ w.pending = append(w.pending, record)
268+ w.cond.Signal()
269+}
270+
271+func (w *TraceWriter) prepare(event TraceEvent) (traceRecord, bool) {
272+ if event.Timestamp.IsZero() {
273+ event.Timestamp = time.Now().UTC()
274+ } else {
275+ event.Timestamp = event.Timestamp.UTC()
276+ }
277+ if w.mode == TraceModeNormal {
278+ event.Data = redactTraceMap(event.Data)
279+ event.Payload = nil
280+ return traceRecord{event: event}, true
281+ }
282+
283+ event.Data = cloneTraceMap(event.Data)
284+ record := traceRecord{event: event}
285+ if event.Payload != nil {
286+ payload, err := json.Marshal(event.Payload)
287+ if err != nil {
288+ // Keep the metadata event even if an optional detailed payload is
289+ // not JSON encodable.
290+ w.setError(fmt.Errorf("trace payload encode: %w", err))
291+ } else {
292+ record.payload = append(payload, '\n')
293+ }
294+ }
295+ record.event.Payload = nil
296+ return record, true
297+}
298+
299+func (w *TraceWriter) run() {
300+ defer close(w.finished)
301+ for {
302+ w.mu.Lock()
303+ for len(w.pending) == 0 && !w.closed {
304+ w.cond.Wait()
305+ }
306+ if len(w.pending) == 0 && w.closed {
307+ w.mu.Unlock()
308+ return
309+ }
310+ record := w.pending[0]
311+ w.pending[0] = traceRecord{}
312+ w.pending = w.pending[1:]
313+ w.mu.Unlock()
314+ w.write(record)
315+ }
316+}
317+
318+func (w *TraceWriter) write(record traceRecord) {
319+ if record.barrier != nil {
320+ defer close(record.barrier)
321+ return
322+ }
323+ event := record.event
324+ if w.memory {
325+ w.mu.Lock()
326+ w.appendEvent(event)
327+ w.mu.Unlock()
328+ return
329+ }
330+ if len(record.payload) != 0 {
331+ name := fmt.Sprintf("%020d.json", event.Seq)
332+ path := filepath.Join(w.payloadDir, name)
333+ file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
334+ if err != nil {
335+ w.setError(fmt.Errorf("trace payload file: %w", err))
336+ } else {
337+ if _, err := file.Write(record.payload); err != nil {
338+ w.setError(fmt.Errorf("trace payload write: %w", err))
339+ } else if err := file.Sync(); err != nil {
340+ w.setError(fmt.Errorf("trace payload sync: %w", err))
341+ }
342+ _ = file.Chmod(0o600)
343+ if err := file.Close(); err != nil {
344+ w.setError(fmt.Errorf("trace payload close: %w", err))
345+ }
346+ event.PayloadFile = filepath.ToSlash(filepath.Join("payloads", name))
347+ }
348+ }
349+
350+ line, err := json.Marshal(event)
351+ if err != nil {
352+ w.setError(fmt.Errorf("trace event encode: %w", err))
353+ return
354+ }
355+ line = append(line, '\n')
356+ if _, err := w.traceFile.Write(line); err != nil {
357+ w.setError(fmt.Errorf("trace event write: %w", err))
358+ return
359+ }
360+ // Sync every event so a completed Record queue can be made durable by
361+ // Close, and so a crash loses at most the event currently being written.
362+ if err := w.traceFile.Sync(); err != nil {
363+ w.setError(fmt.Errorf("trace event sync: %w", err))
364+ }
365+ w.mu.Lock()
366+ w.appendEvent(event)
367+ w.mu.Unlock()
368+}
369+
370+func (w *TraceWriter) appendEvent(event TraceEvent) {
371+ w.events = append(w.events, event)
372+ if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 {
373+ copy(w.events, w.events[overflow:])
374+ w.events = w.events[:memoryTraceEventLimit]
375+ }
376+}
377+
378+// Events returns a stable copy of events already written by this writer. It
379+// never waits for queued filesystem work, keeping status and UI reads cheap.
380+func (w *TraceWriter) Events() []TraceEvent {
381+ if w == nil {
382+ return nil
383+ }
384+ w.mu.Lock()
385+ defer w.mu.Unlock()
386+ return append([]TraceEvent(nil), w.events...)
387+}
388+
389+func (w *TraceWriter) setError(err error) {
390+ if err == nil {
391+ return
392+ }
393+ w.errorMu.Lock()
394+ if w.err == nil {
395+ w.err = err
396+ }
397+ w.errorMu.Unlock()
398+}
399+
400+// Flush waits until all events recorded before the call have been written.
401+func (w *TraceWriter) Flush() error { return w.FlushContext(context.Background()) }
402+
403+// FlushContext honors cancellation while waiting for the trace writer.
404+func (w *TraceWriter) FlushContext(ctx context.Context) error {
405+ if w == nil {
406+ return nil
407+ }
408+ if ctx == nil {
409+ ctx = context.Background()
410+ }
411+ w.mu.Lock()
412+ wait := w.finished
413+ if !w.closed {
414+ barrier := make(chan struct{})
415+ w.pending = append(w.pending, traceRecord{barrier: barrier})
416+ w.cond.Signal()
417+ wait = barrier
418+ }
419+ w.mu.Unlock()
420+ select {
421+ case <-wait:
422+ return w.recordingError()
423+ case <-ctx.Done():
424+ return fmt.Errorf("flush trace: %w", ctx.Err())
425+ }
426+}
427+
428+// Close drains the queue, syncs each event, and closes the bundle.
429+func (w *TraceWriter) Close() error { return w.CloseContext(context.Background()) }
430+
431+// CloseContext starts closing once and honors cancellation while waiting. A
432+// later caller can retry the wait without repeating file-close work.
433+func (w *TraceWriter) CloseContext(ctx context.Context) error {
434+ if w == nil {
435+ return nil
436+ }
437+ if ctx == nil {
438+ ctx = context.Background()
439+ }
440+ w.closeOnce.Do(func() {
441+ go func() {
442+ w.mu.Lock()
443+ w.closed = true
444+ w.cond.Broadcast()
445+ w.mu.Unlock()
446+ <-w.finished
447+ if !w.memory {
448+ if err := w.traceFile.Close(); err != nil {
449+ w.setError(fmt.Errorf("trace file close: %w", err))
450+ }
451+ }
452+ close(w.closeDone)
453+ }()
454+ })
455+ select {
456+ case <-w.closeDone:
457+ return w.recordingError()
458+ case <-ctx.Done():
459+ return fmt.Errorf("close trace: %w", ctx.Err())
460+ }
461+}
462+
463+func (w *TraceWriter) recordingError() error {
464+ w.errorMu.Lock()
465+ defer w.errorMu.Unlock()
466+ return w.err
467+}
468+
469+// TraceContext is the opt-in handle passed through agent code. Its zero value
470+// is a no-op, so callers do not need conditionals when tracing is disabled.
471+type TraceContext struct {
472+ writer *TraceWriter
473+}
474+
475+// NewTraceContext returns an enabled context for writer. A nil writer creates
476+// a no-op context. The writer's mode is authoritative.
477+func NewTraceContext(writer *TraceWriter) TraceContext {
478+ return TraceContext{writer: writer}
479+}
480+
481+// Context returns an enabled TraceContext for this writer.
482+func (w *TraceWriter) Context() TraceContext { return NewTraceContext(w) }
483+
484+// Enabled reports whether this context will record events.
485+func (t TraceContext) Enabled() bool { return t.writer != nil }
486+
487+// Mode returns the writer mode, or normal for a no-op context.
488+func (t TraceContext) Mode() TraceMode {
489+ if t.writer == nil {
490+ return TraceModeNormal
491+ }
492+ return t.writer.Mode()
493+}
494+
495+// Record records an event when tracing is enabled. The no-op path is safe for
496+// a zero TraceContext.
497+func (t TraceContext) Record(event TraceEvent) {
498+ if t.writer != nil {
499+ t.writer.Record(event)
500+ }
501+}
502+
503+// WithTraceContext stores a trace handle in a standard context.Context.
504+func WithTraceContext(ctx context.Context, trace TraceContext) context.Context {
505+ if ctx == nil {
506+ ctx = context.Background()
507+ }
508+ return context.WithValue(ctx, traceContextKey{}, trace)
509+}
510+
511+type traceContextKey struct{}
512+
513+// TraceContextFrom retrieves a trace handle, returning a no-op context when
514+// none was installed.
515+func TraceContextFrom(ctx context.Context) TraceContext {
516+ if ctx == nil {
517+ return TraceContext{}
518+ }
519+ trace, _ := ctx.Value(traceContextKey{}).(TraceContext)
520+ return trace
521+}
522+
523+// RecordTrace records through the context's trace handle and is a convenient
524+// best-effort call site for code that does not need to retain the handle.
525+func RecordTrace(ctx context.Context, event TraceEvent) {
526+ TraceContextFrom(ctx).Record(event)
527+}
528+
529+var sensitiveTraceKeys = map[string]struct{}{
530+ "api_key": {}, "apikey": {}, "authorization": {}, "auth": {},
531+ "certificate": {}, "cookie": {}, "credential": {}, "credentials": {},
532+ "password": {}, "private_key": {}, "secret": {}, "session": {},
533+ "token": {}, "access_token": {}, "refresh_token": {}, "payload": {},
534+ "prompt": {}, "input": {}, "output": {}, "content": {}, "message": {},
535+ "text": {}, "body": {}, "data": {}, "result": {}, "results": {},
536+ "request": {}, "response": {}, "arguments": {}, "args": {}, "query": {},
537+ "value": {}, "values": {}, "raw": {}, "stdout": {}, "stderr": {},
538+}
539+
540+func redactTraceMap(data map[string]any) map[string]any {
541+ if data == nil {
542+ return nil
543+ }
544+ out := make(map[string]any, len(data))
545+ for key, value := range data {
546+ if sensitiveTraceKey(normalizeTraceKey(key)) {
547+ out[key] = "[REDACTED]"
548+ continue
549+ }
550+ out[key] = redactTraceValue(value)
551+ }
552+ return out
553+}
554+
555+func sensitiveTraceKey(key string) bool {
556+ for fragment := range sensitiveTraceKeys {
557+ if strings.Contains(key, fragment) {
558+ return true
559+ }
560+ }
561+ return false
562+}
563+
564+func redactTraceValue(value any) any {
565+ if value == nil {
566+ return nil
567+ }
568+ switch value := value.(type) {
569+ case map[string]any:
570+ return redactTraceMap(value)
571+ case []any:
572+ out := make([]any, len(value))
573+ for i := range value {
574+ out[i] = redactTraceValue(value[i])
575+ }
576+ return out
577+ }
578+ v := reflect.ValueOf(value)
579+ if v.Kind() == reflect.Map && v.Type().Key().Kind() == reflect.String {
580+ out := make(map[string]any, v.Len())
581+ iter := v.MapRange()
582+ for iter.Next() {
583+ key := iter.Key().String()
584+ if sensitiveTraceKey(normalizeTraceKey(key)) {
585+ out[key] = "[REDACTED]"
586+ } else {
587+ out[key] = redactTraceValue(iter.Value().Interface())
588+ }
589+ }
590+ return out
591+ }
592+ if v.Kind() == reflect.Struct {
593+ encoded, err := json.Marshal(value)
594+ if err == nil {
595+ var data map[string]any
596+ if json.Unmarshal(encoded, &data) == nil {
597+ return redactTraceMap(data)
598+ }
599+ }
600+ }
601+ return value
602+}
603+
604+func cloneTraceMap(data map[string]any) map[string]any {
605+ if data == nil {
606+ return nil
607+ }
608+ out := make(map[string]any, len(data))
609+ for key, value := range data {
610+ out[key] = cloneTraceValue(value)
611+ }
612+ return out
613+}
614+
615+func cloneTraceValue(value any) any {
616+ switch value := value.(type) {
617+ case map[string]any:
618+ return cloneTraceMap(value)
619+ case []any:
620+ out := make([]any, len(value))
621+ for i := range value {
622+ out[i] = cloneTraceValue(value[i])
623+ }
624+ return out
625+ default:
626+ return value
627+ }
628+}
629+
630+func normalizeTraceKey(key string) string {
631+ key = strings.ToLower(strings.TrimSpace(key))
632+ key = strings.ReplaceAll(key, "-", "_")
633+ key = strings.ReplaceAll(key, " ", "_")
634+ return key
635+}
@@ -0,0 +1,635 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "os"
9+ "path/filepath"
10+ "reflect"
11+ "strings"
12+ "sync"
13+ "time"
14+)
15+
16+// TraceMode controls how much data a trace keeps. Normal is the safe default:
17+// event metadata is retained, while payloads and values under sensitive keys
18+// are removed. Detailed is intended for explicitly enabled local debugging.
19+type TraceMode string
20+
21+const (
22+ TraceModeNormal TraceMode = "normal"
23+ TraceModeDetailed TraceMode = "detailed"
24+)
25+
26+// Normal and Detailed are short aliases useful to callers configuring a
27+// TraceWriter. Invalid modes are treated as Normal.
28+const (
29+ Normal = TraceModeNormal
30+ Detailed = TraceModeDetailed
31+)
32+
33+func (m TraceMode) valid() TraceMode {
34+ if m == TraceModeDetailed {
35+ return m
36+ }
37+ return TraceModeNormal
38+}
39+
40+// TraceEvent is one entry in trace.jsonl. Payload is never embedded in the
41+// JSONL stream: detailed traces store it as a separate file and normal traces
42+// discard it. Data is for small event metadata and is recursively redacted in
43+// normal mode when a key identifies a secret or payload.
44+type TraceEvent struct {
45+ Seq uint64 `json:"seq"`
46+ Timestamp time.Time `json:"timestamp"`
47+ Type string `json:"type"`
48+ AgentID string `json:"agent_id,omitempty"`
49+ TurnID string `json:"turn_id,omitempty"`
50+ Data map[string]any `json:"data,omitempty"`
51+ Payload any `json:"-"`
52+ PayloadFile string `json:"payload_file,omitempty"`
53+}
54+
55+// NewTraceEvent constructs an event with a UTC timestamp. A TraceWriter
56+// assigns Seq when the event is recorded; callers should not assign it.
57+func NewTraceEvent(typ string, data map[string]any) TraceEvent {
58+ return TraceEvent{
59+ Timestamp: time.Now().UTC(),
60+ Type: typ,
61+ Data: data,
62+ }
63+}
64+
65+// TraceManifest describes the files in a trace bundle.
66+type TraceManifest struct {
67+ Version int `json:"version"`
68+ Mode TraceMode `json:"mode"`
69+ CreatedAt time.Time `json:"created_at"`
70+ TraceFile string `json:"trace_file"`
71+ Payloads string `json:"payloads"`
72+}
73+
74+// TraceWriter writes a trace bundle. Record is deliberately asynchronous: it
75+// only snapshots the event and adds it to an in-memory queue. Disk failures
76+// are retained for Close, but never returned from Record or allowed to affect
77+// the agent that is being traced.
78+type TraceWriter struct {
79+ mu sync.Mutex
80+ cond *sync.Cond
81+ pending []traceRecord
82+ closed bool
83+ finished chan struct{}
84+ closeOnce sync.Once
85+ closeDone chan struct{}
86+
87+ dir string
88+ mode TraceMode
89+ traceFile *os.File
90+ payloadDir string
91+ memory bool
92+ sequence uint64
93+ dropped uint64
94+ events []TraceEvent
95+
96+ errorMu sync.Mutex
97+ err error
98+}
99+
100+type traceRecord struct {
101+ event TraceEvent
102+ payload []byte
103+ barrier chan struct{}
104+}
105+
106+const (
107+ traceQueueInitialCapacity = 64
108+ traceQueueLimit = 1024
109+ memoryTraceEventLimit = 4096
110+)
111+
112+// NewMemoryTraceWriter creates the non-persistent projection source used when
113+// bundle capture is disabled. It retains only normal-mode event metadata.
114+func NewMemoryTraceWriter() *TraceWriter {
115+ w := &TraceWriter{
116+ mode: TraceModeNormal,
117+ memory: true,
118+ pending: make([]traceRecord, 0, traceQueueInitialCapacity),
119+ finished: make(chan struct{}),
120+ closeDone: make(chan struct{}),
121+ events: make([]TraceEvent, 0, traceQueueInitialCapacity),
122+ }
123+ w.cond = sync.NewCond(&w.mu)
124+ go w.run()
125+ return w
126+}
127+
128+// NewTraceWriter creates a private trace bundle at dir. The directory and its
129+// files are created with restrictive permissions. If mode is omitted or
130+// invalid, TraceModeNormal is used.
131+func NewTraceWriter(dir string, modes ...TraceMode) (*TraceWriter, error) {
132+ mode := TraceModeNormal
133+ if len(modes) != 0 {
134+ mode = modes[0].valid()
135+ }
136+ if dir == "" {
137+ return nil, errors.New("trace writer: empty directory")
138+ }
139+ absolute, err := filepath.Abs(dir)
140+ if err != nil {
141+ return nil, fmt.Errorf("trace writer path: %w", err)
142+ }
143+ if err := os.MkdirAll(absolute, 0o700); err != nil {
144+ return nil, fmt.Errorf("trace writer directory: %w", err)
145+ }
146+ // MkdirAll does not tighten an existing directory's permissions.
147+ if err := os.Chmod(absolute, 0o700); err != nil {
148+ return nil, fmt.Errorf("trace writer directory permissions: %w", err)
149+ }
150+ if _, err := os.Stat(filepath.Join(absolute, "trace.jsonl")); err == nil {
151+ return nil, fmt.Errorf("trace writer: bundle already exists")
152+ } else if !errors.Is(err, os.ErrNotExist) {
153+ return nil, fmt.Errorf("trace writer trace file: %w", err)
154+ }
155+ payloadDir := filepath.Join(absolute, "payloads")
156+ if err := os.MkdirAll(payloadDir, 0o700); err != nil {
157+ return nil, fmt.Errorf("trace writer payload directory: %w", err)
158+ }
159+ if err := os.Chmod(payloadDir, 0o700); err != nil {
160+ return nil, fmt.Errorf("trace writer payload permissions: %w", err)
161+ }
162+
163+ traceFile, err := os.OpenFile(filepath.Join(absolute, "trace.jsonl"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
164+ if err != nil {
165+ return nil, fmt.Errorf("trace writer trace file: %w", err)
166+ }
167+ if err := traceFile.Chmod(0o600); err != nil {
168+ _ = traceFile.Close()
169+ return nil, fmt.Errorf("trace writer trace permissions: %w", err)
170+ }
171+
172+ manifest := TraceManifest{
173+ Version: 1,
174+ Mode: mode,
175+ CreatedAt: time.Now().UTC(),
176+ TraceFile: "trace.jsonl",
177+ Payloads: "payloads",
178+ }
179+ if err := writeTraceManifest(filepath.Join(absolute, "manifest.json"), manifest); err != nil {
180+ _ = traceFile.Close()
181+ return nil, err
182+ }
183+
184+ w := &TraceWriter{
185+ dir: absolute,
186+ mode: mode,
187+ traceFile: traceFile,
188+ payloadDir: payloadDir,
189+ pending: make([]traceRecord, 0, traceQueueInitialCapacity),
190+ finished: make(chan struct{}),
191+ closeDone: make(chan struct{}),
192+ events: make([]TraceEvent, 0, traceQueueInitialCapacity),
193+ }
194+ w.cond = sync.NewCond(&w.mu)
195+ go w.run()
196+ return w, nil
197+}
198+
199+func writeTraceManifest(path string, manifest TraceManifest) error {
200+ data, err := json.MarshalIndent(manifest, "", " ")
201+ if err != nil {
202+ return fmt.Errorf("trace manifest encode: %w", err)
203+ }
204+ data = append(data, '\n')
205+ file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
206+ if err != nil {
207+ return fmt.Errorf("trace manifest open: %w", err)
208+ }
209+ defer file.Close()
210+ if err := file.Chmod(0o600); err != nil {
211+ return fmt.Errorf("trace manifest permissions: %w", err)
212+ }
213+ if _, err := file.Write(data); err != nil {
214+ return fmt.Errorf("trace manifest write: %w", err)
215+ }
216+ if err := file.Sync(); err != nil {
217+ return fmt.Errorf("trace manifest sync: %w", err)
218+ }
219+ return nil
220+}
221+
222+// Dir returns the trace bundle directory.
223+func (w *TraceWriter) Dir() string {
224+ if w == nil {
225+ return ""
226+ }
227+ return w.dir
228+}
229+
230+// Mode returns the effective mode of the bundle.
231+func (w *TraceWriter) Mode() TraceMode {
232+ if w == nil {
233+ return TraceModeNormal
234+ }
235+ return w.mode
236+}
237+
238+// Record snapshots and queues an event. It never returns an error. A nil
239+// writer is a no-op, which makes tracing safe to leave disabled by default.
240+func (w *TraceWriter) Record(event TraceEvent) {
241+ if w == nil {
242+ return
243+ }
244+ record, ok := w.prepare(event)
245+ if !ok {
246+ return
247+ }
248+ w.mu.Lock()
249+ defer w.mu.Unlock()
250+ if w.closed {
251+ return
252+ }
253+ w.sequence++
254+ record.event.Seq = w.sequence
255+ if len(w.pending) >= traceQueueLimit {
256+ for index, pending := range w.pending {
257+ if pending.barrier == nil {
258+ copy(w.pending[index:], w.pending[index+1:])
259+ w.pending[len(w.pending)-1] = traceRecord{}
260+ w.pending = w.pending[:len(w.pending)-1]
261+ w.dropped++
262+ w.setError(errors.New("trace event queue overflow"))
263+ break
264+ }
265+ }
266+ }
267+ w.pending = append(w.pending, record)
268+ w.cond.Signal()
269+}
270+
271+func (w *TraceWriter) prepare(event TraceEvent) (traceRecord, bool) {
272+ if event.Timestamp.IsZero() {
273+ event.Timestamp = time.Now().UTC()
274+ } else {
275+ event.Timestamp = event.Timestamp.UTC()
276+ }
277+ if w.mode == TraceModeNormal {
278+ event.Data = redactTraceMap(event.Data)
279+ event.Payload = nil
280+ return traceRecord{event: event}, true
281+ }
282+
283+ event.Data = cloneTraceMap(event.Data)
284+ record := traceRecord{event: event}
285+ if event.Payload != nil {
286+ payload, err := json.Marshal(event.Payload)
287+ if err != nil {
288+ // Keep the metadata event even if an optional detailed payload is
289+ // not JSON encodable.
290+ w.setError(fmt.Errorf("trace payload encode: %w", err))
291+ } else {
292+ record.payload = append(payload, '\n')
293+ }
294+ }
295+ record.event.Payload = nil
296+ return record, true
297+}
298+
299+func (w *TraceWriter) run() {
300+ defer close(w.finished)
301+ for {
302+ w.mu.Lock()
303+ for len(w.pending) == 0 && !w.closed {
304+ w.cond.Wait()
305+ }
306+ if len(w.pending) == 0 && w.closed {
307+ w.mu.Unlock()
308+ return
309+ }
310+ record := w.pending[0]
311+ w.pending[0] = traceRecord{}
312+ w.pending = w.pending[1:]
313+ w.mu.Unlock()
314+ w.write(record)
315+ }
316+}
317+
318+func (w *TraceWriter) write(record traceRecord) {
319+ if record.barrier != nil {
320+ defer close(record.barrier)
321+ return
322+ }
323+ event := record.event
324+ if w.memory {
325+ w.mu.Lock()
326+ w.appendEvent(event)
327+ w.mu.Unlock()
328+ return
329+ }
330+ if len(record.payload) != 0 {
331+ name := fmt.Sprintf("%020d.json", event.Seq)
332+ path := filepath.Join(w.payloadDir, name)
333+ file, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
334+ if err != nil {
335+ w.setError(fmt.Errorf("trace payload file: %w", err))
336+ } else {
337+ if _, err := file.Write(record.payload); err != nil {
338+ w.setError(fmt.Errorf("trace payload write: %w", err))
339+ } else if err := file.Sync(); err != nil {
340+ w.setError(fmt.Errorf("trace payload sync: %w", err))
341+ }
342+ _ = file.Chmod(0o600)
343+ if err := file.Close(); err != nil {
344+ w.setError(fmt.Errorf("trace payload close: %w", err))
345+ }
346+ event.PayloadFile = filepath.ToSlash(filepath.Join("payloads", name))
347+ }
348+ }
349+
350+ line, err := json.Marshal(event)
351+ if err != nil {
352+ w.setError(fmt.Errorf("trace event encode: %w", err))
353+ return
354+ }
355+ line = append(line, '\n')
356+ if _, err := w.traceFile.Write(line); err != nil {
357+ w.setError(fmt.Errorf("trace event write: %w", err))
358+ return
359+ }
360+ // Sync every event so a completed Record queue can be made durable by
361+ // Close, and so a crash loses at most the event currently being written.
362+ if err := w.traceFile.Sync(); err != nil {
363+ w.setError(fmt.Errorf("trace event sync: %w", err))
364+ }
365+ w.mu.Lock()
366+ w.appendEvent(event)
367+ w.mu.Unlock()
368+}
369+
370+func (w *TraceWriter) appendEvent(event TraceEvent) {
371+ w.events = append(w.events, event)
372+ if overflow := len(w.events) - memoryTraceEventLimit; overflow > 0 {
373+ copy(w.events, w.events[overflow:])
374+ w.events = w.events[:memoryTraceEventLimit]
375+ }
376+}
377+
378+// Events returns a stable copy of events already written by this writer. It
379+// never waits for queued filesystem work, keeping status and UI reads cheap.
380+func (w *TraceWriter) Events() []TraceEvent {
381+ if w == nil {
382+ return nil
383+ }
384+ w.mu.Lock()
385+ defer w.mu.Unlock()
386+ return append([]TraceEvent(nil), w.events...)
387+}
388+
389+func (w *TraceWriter) setError(err error) {
390+ if err == nil {
391+ return
392+ }
393+ w.errorMu.Lock()
394+ if w.err == nil {
395+ w.err = err
396+ }
397+ w.errorMu.Unlock()
398+}
399+
400+// Flush waits until all events recorded before the call have been written.
401+func (w *TraceWriter) Flush() error { return w.FlushContext(context.Background()) }
402+
403+// FlushContext honors cancellation while waiting for the trace writer.
404+func (w *TraceWriter) FlushContext(ctx context.Context) error {
405+ if w == nil {
406+ return nil
407+ }
408+ if ctx == nil {
409+ ctx = context.Background()
410+ }
411+ w.mu.Lock()
412+ wait := w.finished
413+ if !w.closed {
414+ barrier := make(chan struct{})
415+ w.pending = append(w.pending, traceRecord{barrier: barrier})
416+ w.cond.Signal()
417+ wait = barrier
418+ }
419+ w.mu.Unlock()
420+ select {
421+ case <-wait:
422+ return w.recordingError()
423+ case <-ctx.Done():
424+ return fmt.Errorf("flush trace: %w", ctx.Err())
425+ }
426+}
427+
428+// Close drains the queue, syncs each event, and closes the bundle.
429+func (w *TraceWriter) Close() error { return w.CloseContext(context.Background()) }
430+
431+// CloseContext starts closing once and honors cancellation while waiting. A
432+// later caller can retry the wait without repeating file-close work.
433+func (w *TraceWriter) CloseContext(ctx context.Context) error {
434+ if w == nil {
435+ return nil
436+ }
437+ if ctx == nil {
438+ ctx = context.Background()
439+ }
440+ w.closeOnce.Do(func() {
441+ go func() {
442+ w.mu.Lock()
443+ w.closed = true
444+ w.cond.Broadcast()
445+ w.mu.Unlock()
446+ <-w.finished
447+ if !w.memory {
448+ if err := w.traceFile.Close(); err != nil {
449+ w.setError(fmt.Errorf("trace file close: %w", err))
450+ }
451+ }
452+ close(w.closeDone)
453+ }()
454+ })
455+ select {
456+ case <-w.closeDone:
457+ return w.recordingError()
458+ case <-ctx.Done():
459+ return fmt.Errorf("close trace: %w", ctx.Err())
460+ }
461+}
462+
463+func (w *TraceWriter) recordingError() error {
464+ w.errorMu.Lock()
465+ defer w.errorMu.Unlock()
466+ return w.err
467+}
468+
469+// TraceContext is the opt-in handle passed through agent code. Its zero value
470+// is a no-op, so callers do not need conditionals when tracing is disabled.
471+type TraceContext struct {
472+ writer *TraceWriter
473+}
474+
475+// NewTraceContext returns an enabled context for writer. A nil writer creates
476+// a no-op context. The writer's mode is authoritative.
477+func NewTraceContext(writer *TraceWriter) TraceContext {
478+ return TraceContext{writer: writer}
479+}
480+
481+// Context returns an enabled TraceContext for this writer.
482+func (w *TraceWriter) Context() TraceContext { return NewTraceContext(w) }
483+
484+// Enabled reports whether this context will record events.
485+func (t TraceContext) Enabled() bool { return t.writer != nil }
486+
487+// Mode returns the writer mode, or normal for a no-op context.
488+func (t TraceContext) Mode() TraceMode {
489+ if t.writer == nil {
490+ return TraceModeNormal
491+ }
492+ return t.writer.Mode()
493+}
494+
495+// Record records an event when tracing is enabled. The no-op path is safe for
496+// a zero TraceContext.
497+func (t TraceContext) Record(event TraceEvent) {
498+ if t.writer != nil {
499+ t.writer.Record(event)
500+ }
501+}
502+
503+// WithTraceContext stores a trace handle in a standard context.Context.
504+func WithTraceContext(ctx context.Context, trace TraceContext) context.Context {
505+ if ctx == nil {
506+ ctx = context.Background()
507+ }
508+ return context.WithValue(ctx, traceContextKey{}, trace)
509+}
510+
511+type traceContextKey struct{}
512+
513+// TraceContextFrom retrieves a trace handle, returning a no-op context when
514+// none was installed.
515+func TraceContextFrom(ctx context.Context) TraceContext {
516+ if ctx == nil {
517+ return TraceContext{}
518+ }
519+ trace, _ := ctx.Value(traceContextKey{}).(TraceContext)
520+ return trace
521+}
522+
523+// RecordTrace records through the context's trace handle and is a convenient
524+// best-effort call site for code that does not need to retain the handle.
525+func RecordTrace(ctx context.Context, event TraceEvent) {
526+ TraceContextFrom(ctx).Record(event)
527+}
528+
529+var sensitiveTraceKeys = map[string]struct{}{
530+ "api_key": {}, "apikey": {}, "authorization": {}, "auth": {},
531+ "certificate": {}, "cookie": {}, "credential": {}, "credentials": {},
532+ "password": {}, "private_key": {}, "secret": {}, "session": {},
533+ "token": {}, "access_token": {}, "refresh_token": {}, "payload": {},
534+ "prompt": {}, "input": {}, "output": {}, "content": {}, "message": {},
535+ "text": {}, "body": {}, "data": {}, "result": {}, "results": {},
536+ "request": {}, "response": {}, "arguments": {}, "args": {}, "query": {},
537+ "value": {}, "values": {}, "raw": {}, "stdout": {}, "stderr": {},
538+}
539+
540+func redactTraceMap(data map[string]any) map[string]any {
541+ if data == nil {
542+ return nil
543+ }
544+ out := make(map[string]any, len(data))
545+ for key, value := range data {
546+ if sensitiveTraceKey(normalizeTraceKey(key)) {
547+ out[key] = "[REDACTED]"
548+ continue
549+ }
550+ out[key] = redactTraceValue(value)
551+ }
552+ return out
553+}
554+
555+func sensitiveTraceKey(key string) bool {
556+ for fragment := range sensitiveTraceKeys {
557+ if strings.Contains(key, fragment) {
558+ return true
559+ }
560+ }
561+ return false
562+}
563+
564+func redactTraceValue(value any) any {
565+ if value == nil {
566+ return nil
567+ }
568+ switch value := value.(type) {
569+ case map[string]any:
570+ return redactTraceMap(value)
571+ case []any:
572+ out := make([]any, len(value))
573+ for i := range value {
574+ out[i] = redactTraceValue(value[i])
575+ }
576+ return out
577+ }
578+ v := reflect.ValueOf(value)
579+ if v.Kind() == reflect.Map && v.Type().Key().Kind() == reflect.String {
580+ out := make(map[string]any, v.Len())
581+ iter := v.MapRange()
582+ for iter.Next() {
583+ key := iter.Key().String()
584+ if sensitiveTraceKey(normalizeTraceKey(key)) {
585+ out[key] = "[REDACTED]"
586+ } else {
587+ out[key] = redactTraceValue(iter.Value().Interface())
588+ }
589+ }
590+ return out
591+ }
592+ if v.Kind() == reflect.Struct {
593+ encoded, err := json.Marshal(value)
594+ if err == nil {
595+ var data map[string]any
596+ if json.Unmarshal(encoded, &data) == nil {
597+ return redactTraceMap(data)
598+ }
599+ }
600+ }
601+ return value
602+}
603+
604+func cloneTraceMap(data map[string]any) map[string]any {
605+ if data == nil {
606+ return nil
607+ }
608+ out := make(map[string]any, len(data))
609+ for key, value := range data {
610+ out[key] = cloneTraceValue(value)
611+ }
612+ return out
613+}
614+
615+func cloneTraceValue(value any) any {
616+ switch value := value.(type) {
617+ case map[string]any:
618+ return cloneTraceMap(value)
619+ case []any:
620+ out := make([]any, len(value))
621+ for i := range value {
622+ out[i] = cloneTraceValue(value[i])
623+ }
624+ return out
625+ default:
626+ return value
627+ }
628+}
629+
630+func normalizeTraceKey(key string) string {
631+ key = strings.ToLower(strings.TrimSpace(key))
632+ key = strings.ReplaceAll(key, "-", "_")
633+ key = strings.ReplaceAll(key, " ", "_")
634+ return key
635+}
A packages/agent/subagents/trace_read.go +88 -0
@@ -0,0 +1,88 @@
1+package subagents
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+)
13+
14+// ReadTrace reads a trace bundle using a background context.
15+func ReadTrace(bundle string) ([]TraceEvent, error) {
16+ return ReadTraceContext(context.Background(), bundle)
17+}
18+
19+// ReadTraceContext reads the manifest-selected JSONL trace stream. If a stream
20+// ends with malformed data, events decoded before it are returned with the
21+// error so callers can still diagnose the valid prefix.
22+func ReadTraceContext(ctx context.Context, bundle string) ([]TraceEvent, error) {
23+ if ctx == nil {
24+ ctx = context.Background()
25+ }
26+ manifestPath := filepath.Join(bundle, "manifest.json")
27+ data, err := os.ReadFile(manifestPath)
28+ if err != nil {
29+ return nil, fmt.Errorf("read trace manifest: %w", err)
30+ }
31+ var manifest TraceManifest
32+ if err := json.Unmarshal(data, &manifest); err != nil {
33+ return nil, fmt.Errorf("decode trace manifest: %w", err)
34+ }
35+ if manifest.Version != 1 {
36+ return nil, fmt.Errorf("unsupported trace manifest version %d", manifest.Version)
37+ }
38+ tracePath, err := traceBundlePath(bundle, manifest.TraceFile)
39+ if err != nil {
40+ return nil, err
41+ }
42+ file, err := os.Open(tracePath)
43+ if err != nil {
44+ return nil, fmt.Errorf("open trace: %w", err)
45+ }
46+ defer file.Close()
47+
48+ var events []TraceEvent
49+ scanner := bufio.NewScanner(file)
50+ buffer := make([]byte, 64*1024)
51+ scanner.Buffer(buffer, 4*1024*1024)
52+ for scanner.Scan() {
53+ if err := ctx.Err(); err != nil {
54+ return events, fmt.Errorf("read trace: %w", err)
55+ }
56+ var event TraceEvent
57+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
58+ return events, fmt.Errorf("decode trace event: %w", err)
59+ }
60+ events = append(events, event)
61+ }
62+ if err := scanner.Err(); err != nil {
63+ return events, fmt.Errorf("read trace: %w", err)
64+ }
65+ if err := ctx.Err(); err != nil {
66+ return events, fmt.Errorf("read trace: %w", err)
67+ }
68+ return events, nil
69+}
70+
71+func traceBundlePath(bundle, name string) (string, error) {
72+ if strings.TrimSpace(name) == "" || filepath.IsAbs(name) {
73+ return "", errors.New("invalid trace manifest path")
74+ }
75+ root, err := filepath.Abs(bundle)
76+ if err != nil {
77+ return "", fmt.Errorf("resolve trace bundle: %w", err)
78+ }
79+ path, err := filepath.Abs(filepath.Join(root, name))
80+ if err != nil {
81+ return "", fmt.Errorf("resolve trace path: %w", err)
82+ }
83+ rel, err := filepath.Rel(root, path)
84+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
85+ return "", errors.New("trace manifest path escapes bundle")
86+ }
87+ return path, nil
88+}
@@ -0,0 +1,88 @@
1+package subagents
2+
3+import (
4+ "bufio"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "os"
10+ "path/filepath"
11+ "strings"
12+)
13+
14+// ReadTrace reads a trace bundle using a background context.
15+func ReadTrace(bundle string) ([]TraceEvent, error) {
16+ return ReadTraceContext(context.Background(), bundle)
17+}
18+
19+// ReadTraceContext reads the manifest-selected JSONL trace stream. If a stream
20+// ends with malformed data, events decoded before it are returned with the
21+// error so callers can still diagnose the valid prefix.
22+func ReadTraceContext(ctx context.Context, bundle string) ([]TraceEvent, error) {
23+ if ctx == nil {
24+ ctx = context.Background()
25+ }
26+ manifestPath := filepath.Join(bundle, "manifest.json")
27+ data, err := os.ReadFile(manifestPath)
28+ if err != nil {
29+ return nil, fmt.Errorf("read trace manifest: %w", err)
30+ }
31+ var manifest TraceManifest
32+ if err := json.Unmarshal(data, &manifest); err != nil {
33+ return nil, fmt.Errorf("decode trace manifest: %w", err)
34+ }
35+ if manifest.Version != 1 {
36+ return nil, fmt.Errorf("unsupported trace manifest version %d", manifest.Version)
37+ }
38+ tracePath, err := traceBundlePath(bundle, manifest.TraceFile)
39+ if err != nil {
40+ return nil, err
41+ }
42+ file, err := os.Open(tracePath)
43+ if err != nil {
44+ return nil, fmt.Errorf("open trace: %w", err)
45+ }
46+ defer file.Close()
47+
48+ var events []TraceEvent
49+ scanner := bufio.NewScanner(file)
50+ buffer := make([]byte, 64*1024)
51+ scanner.Buffer(buffer, 4*1024*1024)
52+ for scanner.Scan() {
53+ if err := ctx.Err(); err != nil {
54+ return events, fmt.Errorf("read trace: %w", err)
55+ }
56+ var event TraceEvent
57+ if err := json.Unmarshal(scanner.Bytes(), &event); err != nil {
58+ return events, fmt.Errorf("decode trace event: %w", err)
59+ }
60+ events = append(events, event)
61+ }
62+ if err := scanner.Err(); err != nil {
63+ return events, fmt.Errorf("read trace: %w", err)
64+ }
65+ if err := ctx.Err(); err != nil {
66+ return events, fmt.Errorf("read trace: %w", err)
67+ }
68+ return events, nil
69+}
70+
71+func traceBundlePath(bundle, name string) (string, error) {
72+ if strings.TrimSpace(name) == "" || filepath.IsAbs(name) {
73+ return "", errors.New("invalid trace manifest path")
74+ }
75+ root, err := filepath.Abs(bundle)
76+ if err != nil {
77+ return "", fmt.Errorf("resolve trace bundle: %w", err)
78+ }
79+ path, err := filepath.Abs(filepath.Join(root, name))
80+ if err != nil {
81+ return "", fmt.Errorf("resolve trace path: %w", err)
82+ }
83+ rel, err := filepath.Rel(root, path)
84+ if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
85+ return "", errors.New("trace manifest path escapes bundle")
86+ }
87+ return path, nil
88+}
A packages/agent/subagents/trace_test.go +222 -0
@@ -0,0 +1,222 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+ "sync"
11+ "testing"
12+)
13+
14+func TestTraceContextZeroValueIsNoOp(t *testing.T) {
15+ var trace TraceContext
16+ if trace.Enabled() {
17+ t.Fatal("zero trace context is enabled")
18+ }
19+ trace.Record(NewTraceEvent("ignored", map[string]any{"secret": "not written"}))
20+ if got := TraceContextFrom(context.Background()); got.Enabled() {
21+ t.Fatal("context without trace is enabled")
22+ }
23+ ctx := WithTraceContext(context.Background(), trace)
24+ if TraceContextFrom(ctx).Enabled() {
25+ t.Fatal("stored no-op context is enabled")
26+ }
27+}
28+
29+func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) {
30+ writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeDetailed)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ writer.Record(TraceEvent{Type: "tool.result", Data: map[string]any{"name": "cat"}, Payload: map[string]any{"output": "sensitive result"}})
35+ if err := writer.Close(); err != nil {
36+ t.Fatal(err)
37+ }
38+
39+ for _, name := range []string{"manifest.json", "trace.jsonl", "payloads"} {
40+ if _, err := os.Stat(filepath.Join(writer.Dir(), name)); err != nil {
41+ t.Fatalf("bundle member %s: %v", name, err)
42+ }
43+ }
44+ assertPrivate(t, writer.Dir(), 0o700)
45+ assertPrivate(t, filepath.Join(writer.Dir(), "manifest.json"), 0o600)
46+ assertPrivate(t, filepath.Join(writer.Dir(), "trace.jsonl"), 0o600)
47+
48+ var manifest TraceManifest
49+ readJSONFile(t, filepath.Join(writer.Dir(), "manifest.json"), &manifest)
50+ if manifest.Mode != TraceModeDetailed || manifest.TraceFile != "trace.jsonl" || manifest.Payloads != "payloads" {
51+ t.Fatalf("manifest = %+v", manifest)
52+ }
53+ lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl"))
54+ if len(lines) != 1 {
55+ t.Fatalf("got %d trace lines, want 1", len(lines))
56+ }
57+ if strings.Contains(lines[0], "sensitive result") {
58+ t.Fatalf("trace event leaked detailed payload: %s", lines[0])
59+ }
60+ var event TraceEvent
61+ if err := json.Unmarshal([]byte(lines[0]), &event); err != nil {
62+ t.Fatal(err)
63+ }
64+ if event.Seq == 0 || event.PayloadFile == "" {
65+ t.Fatalf("event = %+v", event)
66+ }
67+ payloadPath := filepath.Join(writer.Dir(), filepath.FromSlash(event.PayloadFile))
68+ assertPrivate(t, payloadPath, 0o600)
69+ payload := string(mustRead(t, payloadPath))
70+ if !strings.Contains(payload, "sensitive result") {
71+ t.Fatalf("payload does not contain detailed value: %s", payload)
72+ }
73+}
74+
75+func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) {
76+ writer := NewMemoryTraceWriter()
77+ for index := 0; index < memoryTraceEventLimit+2; index++ {
78+ writer.Record(TraceEvent{Type: "event", Data: map[string]any{"index": index}})
79+ // Keep this retention test independent from the separate bounded-queue
80+ // contract: a slow Windows race run can otherwise intentionally drop
81+ // records before the in-memory limit is exercised.
82+ if (index+1)%traceQueueInitialCapacity == 0 {
83+ if err := writer.Flush(); err != nil {
84+ t.Fatal(err)
85+ }
86+ }
87+ }
88+ if err := writer.Flush(); err != nil {
89+ t.Fatal(err)
90+ }
91+ if got := writer.Dir(); got != "" {
92+ t.Fatalf("memory trace dir = %q, want empty", got)
93+ }
94+ events := writer.Events()
95+ if len(events) != memoryTraceEventLimit || events[len(events)-1].Seq != memoryTraceEventLimit+2 {
96+ t.Fatalf("memory events retained %d with final seq %d", len(events), events[len(events)-1].Seq)
97+ }
98+ if err := writer.Close(); err != nil {
99+ t.Fatal(err)
100+ }
101+}
102+
103+func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) {
104+ writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ secret := "super-secret-value"
109+ writer.Record(TraceEvent{
110+ Type: "request",
111+ Data: map[string]any{
112+ "name": "safe", "authorization": secret, "openai_api_key": secret,
113+ "nested": map[string]any{"token": secret, "count": 2, "headers": map[string]string{"x_api_key": secret}},
114+ },
115+ Payload: map[string]any{"content": secret},
116+ })
117+ if err := writer.Close(); err != nil {
118+ t.Fatal(err)
119+ }
120+ data := string(mustRead(t, filepath.Join(writer.Dir(), "trace.jsonl")))
121+ if strings.Contains(data, secret) || strings.Contains(data, "content") {
122+ t.Fatalf("normal trace leaked sensitive value: %s", data)
123+ }
124+ if !strings.Contains(data, "[REDACTED]") || !strings.Contains(data, "safe") {
125+ t.Fatalf("normal trace did not retain safe metadata/redaction: %s", data)
126+ }
127+ entries, err := os.ReadDir(filepath.Join(writer.Dir(), "payloads"))
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if len(entries) != 0 {
132+ t.Fatalf("normal trace wrote %d payload files", len(entries))
133+ }
134+}
135+
136+func TestTraceWriterConcurrentRecordHasOrderedGlobalSequences(t *testing.T) {
137+ writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"))
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ const workers = 8
142+ const perWorker = 25
143+ var group sync.WaitGroup
144+ for worker := 0; worker < workers; worker++ {
145+ group.Add(1)
146+ go func(worker int) {
147+ defer group.Done()
148+ for i := 0; i < perWorker; i++ {
149+ writer.Record(NewTraceEvent("event", map[string]any{"worker": worker, "index": i}))
150+ }
151+ }(worker)
152+ }
153+ group.Wait()
154+ if err := writer.Close(); err != nil {
155+ t.Fatal(err)
156+ }
157+ lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl"))
158+ if len(lines) != workers*perWorker {
159+ t.Fatalf("got %d lines, want %d", len(lines), workers*perWorker)
160+ }
161+ seqs := make([]uint64, 0, len(lines))
162+ for _, line := range lines {
163+ var event TraceEvent
164+ if err := json.Unmarshal([]byte(line), &event); err != nil {
165+ t.Fatal(err)
166+ }
167+ seqs = append(seqs, event.Seq)
168+ }
169+ if !sort.SliceIsSorted(seqs, func(i, j int) bool { return seqs[i] < seqs[j] }) {
170+ t.Fatalf("trace sequences are not ordered: %v", seqs)
171+ }
172+ for i := 1; i < len(seqs); i++ {
173+ if seqs[i] != seqs[i-1]+1 {
174+ t.Fatalf("sequence gap at %d: %d then %d", i, seqs[i-1], seqs[i])
175+ }
176+ }
177+}
178+
179+func assertPrivate(t *testing.T, path string, want os.FileMode) {
180+ t.Helper()
181+ if os.PathSeparator == '\\' {
182+ // Windows does not expose POSIX permission bits through os.FileMode.
183+ // The writer still requests restrictive ACL-compatible modes; assert the
184+ // portable property that the bundle member exists instead.
185+ if _, err := os.Stat(path); err != nil {
186+ t.Fatal(err)
187+ }
188+ return
189+ }
190+ info, err := os.Stat(path)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ if got := info.Mode().Perm(); got != want {
195+ t.Fatalf("%s mode = %o, want %o", path, got, want)
196+ }
197+}
198+
199+func readJSONFile(t *testing.T, path string, out any) {
200+ t.Helper()
201+ if err := json.Unmarshal(mustRead(t, path), out); err != nil {
202+ t.Fatal(err)
203+ }
204+}
205+
206+func readLines(t *testing.T, path string) []string {
207+ t.Helper()
208+ data := strings.TrimSpace(string(mustRead(t, path)))
209+ if data == "" {
210+ return nil
211+ }
212+ return strings.Split(data, "\n")
213+}
214+
215+func mustRead(t *testing.T, path string) []byte {
216+ t.Helper()
217+ data, err := os.ReadFile(path)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ return data
222+}
@@ -0,0 +1,222 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+ "strings"
10+ "sync"
11+ "testing"
12+)
13+
14+func TestTraceContextZeroValueIsNoOp(t *testing.T) {
15+ var trace TraceContext
16+ if trace.Enabled() {
17+ t.Fatal("zero trace context is enabled")
18+ }
19+ trace.Record(NewTraceEvent("ignored", map[string]any{"secret": "not written"}))
20+ if got := TraceContextFrom(context.Background()); got.Enabled() {
21+ t.Fatal("context without trace is enabled")
22+ }
23+ ctx := WithTraceContext(context.Background(), trace)
24+ if TraceContextFrom(ctx).Enabled() {
25+ t.Fatal("stored no-op context is enabled")
26+ }
27+}
28+
29+func TestTraceWriterCreatesPrivateBundleAndDetailedPayload(t *testing.T) {
30+ writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeDetailed)
31+ if err != nil {
32+ t.Fatal(err)
33+ }
34+ writer.Record(TraceEvent{Type: "tool.result", Data: map[string]any{"name": "cat"}, Payload: map[string]any{"output": "sensitive result"}})
35+ if err := writer.Close(); err != nil {
36+ t.Fatal(err)
37+ }
38+
39+ for _, name := range []string{"manifest.json", "trace.jsonl", "payloads"} {
40+ if _, err := os.Stat(filepath.Join(writer.Dir(), name)); err != nil {
41+ t.Fatalf("bundle member %s: %v", name, err)
42+ }
43+ }
44+ assertPrivate(t, writer.Dir(), 0o700)
45+ assertPrivate(t, filepath.Join(writer.Dir(), "manifest.json"), 0o600)
46+ assertPrivate(t, filepath.Join(writer.Dir(), "trace.jsonl"), 0o600)
47+
48+ var manifest TraceManifest
49+ readJSONFile(t, filepath.Join(writer.Dir(), "manifest.json"), &manifest)
50+ if manifest.Mode != TraceModeDetailed || manifest.TraceFile != "trace.jsonl" || manifest.Payloads != "payloads" {
51+ t.Fatalf("manifest = %+v", manifest)
52+ }
53+ lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl"))
54+ if len(lines) != 1 {
55+ t.Fatalf("got %d trace lines, want 1", len(lines))
56+ }
57+ if strings.Contains(lines[0], "sensitive result") {
58+ t.Fatalf("trace event leaked detailed payload: %s", lines[0])
59+ }
60+ var event TraceEvent
61+ if err := json.Unmarshal([]byte(lines[0]), &event); err != nil {
62+ t.Fatal(err)
63+ }
64+ if event.Seq == 0 || event.PayloadFile == "" {
65+ t.Fatalf("event = %+v", event)
66+ }
67+ payloadPath := filepath.Join(writer.Dir(), filepath.FromSlash(event.PayloadFile))
68+ assertPrivate(t, payloadPath, 0o600)
69+ payload := string(mustRead(t, payloadPath))
70+ if !strings.Contains(payload, "sensitive result") {
71+ t.Fatalf("payload does not contain detailed value: %s", payload)
72+ }
73+}
74+
75+func TestMemoryTraceWriterRetainsOrderedEventsWithoutBundle(t *testing.T) {
76+ writer := NewMemoryTraceWriter()
77+ for index := 0; index < memoryTraceEventLimit+2; index++ {
78+ writer.Record(TraceEvent{Type: "event", Data: map[string]any{"index": index}})
79+ // Keep this retention test independent from the separate bounded-queue
80+ // contract: a slow Windows race run can otherwise intentionally drop
81+ // records before the in-memory limit is exercised.
82+ if (index+1)%traceQueueInitialCapacity == 0 {
83+ if err := writer.Flush(); err != nil {
84+ t.Fatal(err)
85+ }
86+ }
87+ }
88+ if err := writer.Flush(); err != nil {
89+ t.Fatal(err)
90+ }
91+ if got := writer.Dir(); got != "" {
92+ t.Fatalf("memory trace dir = %q, want empty", got)
93+ }
94+ events := writer.Events()
95+ if len(events) != memoryTraceEventLimit || events[len(events)-1].Seq != memoryTraceEventLimit+2 {
96+ t.Fatalf("memory events retained %d with final seq %d", len(events), events[len(events)-1].Seq)
97+ }
98+ if err := writer.Close(); err != nil {
99+ t.Fatal(err)
100+ }
101+}
102+
103+func TestTraceWriterNormalRedactsSensitiveValues(t *testing.T) {
104+ writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"), TraceModeNormal)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ secret := "super-secret-value"
109+ writer.Record(TraceEvent{
110+ Type: "request",
111+ Data: map[string]any{
112+ "name": "safe", "authorization": secret, "openai_api_key": secret,
113+ "nested": map[string]any{"token": secret, "count": 2, "headers": map[string]string{"x_api_key": secret}},
114+ },
115+ Payload: map[string]any{"content": secret},
116+ })
117+ if err := writer.Close(); err != nil {
118+ t.Fatal(err)
119+ }
120+ data := string(mustRead(t, filepath.Join(writer.Dir(), "trace.jsonl")))
121+ if strings.Contains(data, secret) || strings.Contains(data, "content") {
122+ t.Fatalf("normal trace leaked sensitive value: %s", data)
123+ }
124+ if !strings.Contains(data, "[REDACTED]") || !strings.Contains(data, "safe") {
125+ t.Fatalf("normal trace did not retain safe metadata/redaction: %s", data)
126+ }
127+ entries, err := os.ReadDir(filepath.Join(writer.Dir(), "payloads"))
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ if len(entries) != 0 {
132+ t.Fatalf("normal trace wrote %d payload files", len(entries))
133+ }
134+}
135+
136+func TestTraceWriterConcurrentRecordHasOrderedGlobalSequences(t *testing.T) {
137+ writer, err := NewTraceWriter(filepath.Join(t.TempDir(), "trace"))
138+ if err != nil {
139+ t.Fatal(err)
140+ }
141+ const workers = 8
142+ const perWorker = 25
143+ var group sync.WaitGroup
144+ for worker := 0; worker < workers; worker++ {
145+ group.Add(1)
146+ go func(worker int) {
147+ defer group.Done()
148+ for i := 0; i < perWorker; i++ {
149+ writer.Record(NewTraceEvent("event", map[string]any{"worker": worker, "index": i}))
150+ }
151+ }(worker)
152+ }
153+ group.Wait()
154+ if err := writer.Close(); err != nil {
155+ t.Fatal(err)
156+ }
157+ lines := readLines(t, filepath.Join(writer.Dir(), "trace.jsonl"))
158+ if len(lines) != workers*perWorker {
159+ t.Fatalf("got %d lines, want %d", len(lines), workers*perWorker)
160+ }
161+ seqs := make([]uint64, 0, len(lines))
162+ for _, line := range lines {
163+ var event TraceEvent
164+ if err := json.Unmarshal([]byte(line), &event); err != nil {
165+ t.Fatal(err)
166+ }
167+ seqs = append(seqs, event.Seq)
168+ }
169+ if !sort.SliceIsSorted(seqs, func(i, j int) bool { return seqs[i] < seqs[j] }) {
170+ t.Fatalf("trace sequences are not ordered: %v", seqs)
171+ }
172+ for i := 1; i < len(seqs); i++ {
173+ if seqs[i] != seqs[i-1]+1 {
174+ t.Fatalf("sequence gap at %d: %d then %d", i, seqs[i-1], seqs[i])
175+ }
176+ }
177+}
178+
179+func assertPrivate(t *testing.T, path string, want os.FileMode) {
180+ t.Helper()
181+ if os.PathSeparator == '\\' {
182+ // Windows does not expose POSIX permission bits through os.FileMode.
183+ // The writer still requests restrictive ACL-compatible modes; assert the
184+ // portable property that the bundle member exists instead.
185+ if _, err := os.Stat(path); err != nil {
186+ t.Fatal(err)
187+ }
188+ return
189+ }
190+ info, err := os.Stat(path)
191+ if err != nil {
192+ t.Fatal(err)
193+ }
194+ if got := info.Mode().Perm(); got != want {
195+ t.Fatalf("%s mode = %o, want %o", path, got, want)
196+ }
197+}
198+
199+func readJSONFile(t *testing.T, path string, out any) {
200+ t.Helper()
201+ if err := json.Unmarshal(mustRead(t, path), out); err != nil {
202+ t.Fatal(err)
203+ }
204+}
205+
206+func readLines(t *testing.T, path string) []string {
207+ t.Helper()
208+ data := strings.TrimSpace(string(mustRead(t, path)))
209+ if data == "" {
210+ return nil
211+ }
212+ return strings.Split(data, "\n")
213+}
214+
215+func mustRead(t *testing.T, path string) []byte {
216+ t.Helper()
217+ data, err := os.ReadFile(path)
218+ if err != nil {
219+ t.Fatal(err)
220+ }
221+ return data
222+}
M packages/agent/tools/subagent_lifecycle_test.go +3 -4
@@ -66,15 +66,14 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) {
6666 var got struct {
6767 Action string `json:"action"`
6868 Agent struct {
69- ID string `json:"agent_id"`
70- State string `json:"state"`
69+ ID string `json:"agent_id"`
7170 } `json:"agent"`
7271 }
7372 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
7473 t.Fatalf("stop response JSON: %v", err)
7574 }
76- if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" {
77- t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID)
75+ if got.Action != "stop_requested" || got.Agent.ID != agent.ID {
76+ t.Fatalf("stop response = %+v, want stop_requested agent %s", got, agent.ID)
7877 }
7978 }
8079
@@ -66,15 +66,14 @@ func TestSubagentStopTerminatesStuckWorker(t *testing.T) {
66 var got struct {66 var got struct {
67 Action string `json:"action"`67 Action string `json:"action"`
68 Agent struct {68 Agent struct {
69- ID string `json:"agent_id"`69+ ID string `json:"agent_id"`
70- State string `json:"state"`
71 } `json:"agent"`70 } `json:"agent"`
72 }71 }
73 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {72 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
74 t.Fatalf("stop response JSON: %v", err)73 t.Fatalf("stop response JSON: %v", err)
75 }74 }
76- if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" {75+ if got.Action != "stop_requested" || got.Agent.ID != agent.ID {
77- t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID)76+ t.Fatalf("stop response = %+v, want stop_requested agent %s", got, agent.ID)
78 }77 }
79 }78 }
80 79
M packages/agent/tools/subagent_resume.go +1 -1
@@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
125125 if !ok {
126126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
127127 }
128- return renderSubagentAction("resumed", snapshot)
128+ return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
129129 }
@@ -125,5 +125,5 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
125 if !ok {125 if !ok {
126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)126 return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
127 }127 }
128- return renderSubagentAction("resumed", snapshot)128+ return renderSubagentAction("resumed", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
129 }129 }
M packages/agent/tools/subagent_status.go +28 -39
@@ -35,19 +35,27 @@ type subagentStatusResponse struct {
3535
3636 type subagentStatusEntry struct {
3737 ID string `json:"agent_id"`
38- State string `json:"state"`
39- ProcessState string `json:"process_state"`
40- TurnState string `json:"turn_state"`
38+ Operation *statusOperation `json:"operation,omitempty"`
39+ Terminal string `json:"terminal,omitempty"`
40+ LastEvent *statusLastEvent `json:"last_event,omitempty"`
41+ StartedAt time.Time `json:"started_at"`
4142 LifetimeTurns int `json:"lifetime_turns"`
4243 CurrentRunTurns int `json:"current_run_turns"`
43- StartedAt time.Time `json:"started_at"`
44- UpdatedAt time.Time `json:"updated_at"`
45- FinishedAt *time.Time `json:"finished_at,omitempty"`
4644 TaskSummary string `json:"task_summary,omitempty"`
4745 Requirement *statusRequirement `json:"requirement,omitempty"`
4846 Result *subagentStatusResult `json:"result,omitempty"`
4947 }
5048
49+type statusOperation struct {
50+ Type string `json:"type"`
51+ StartedAt time.Time `json:"started_at"`
52+}
53+
54+type statusLastEvent struct {
55+ Type string `json:"type"`
56+ At time.Time `json:"at"`
57+}
58+
5159 type statusRequirement struct {
5260 State string `json:"state"`
5361 TargetTurn int `json:"target_turn"`
@@ -102,15 +110,15 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
102110 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
103111 }
104112
105- // SnapshotAll is intentionally used for both list and single-worker
106- // queries. Besides being non-blocking, it applies the supervisor's active
107- // session visibility rules so an id lookup cannot bypass list scoping.
113+ // SnapshotAll provides identity, session scope, and durable result facts.
114+ // The execution indication itself comes solely from the trace projection.
108115 snapshots := t.Supervisor.SnapshotAll()
116+ views := t.Supervisor.TraceViews()
109117 id := strings.TrimSpace(args.AgentID)
110118 if id == "" {
111119 entries := make([]subagentStatusEntry, 0, len(snapshots))
112120 for _, snapshot := range snapshots {
113- entries = append(entries, publicSubagentStatus(snapshot))
121+ entries = append(entries, publicSubagentStatus(snapshot, views[snapshot.ID]))
114122 }
115123 return renderSubagentStatus(subagentStatusResponse{Agents: entries})
116124 }
@@ -119,7 +127,7 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
119127 if !ok {
120128 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
121129 }
122- entry := publicSubagentStatus(snapshot)
130+ entry := publicSubagentStatus(snapshot, views[snapshot.ID])
123131 return renderSubagentStatus(subagentStatusResponse{Agent: &entry})
124132 }
125133
@@ -140,21 +148,21 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string)
140148 return match, hits == 1
141149 }
142150
143-func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {
151+func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry {
144152 entry := subagentStatusEntry{
145153 ID: snapshot.ID,
146- State: publicSubagentState(snapshot),
147- ProcessState: string(snapshot.ProcessState),
148- TurnState: string(snapshot.TurnState),
154+ Terminal: view.Terminal,
155+ StartedAt: snapshot.Started,
149156 LifetimeTurns: snapshot.LifetimeTurns,
150157 CurrentRunTurns: snapshot.CurrentRunTurns,
151- StartedAt: snapshot.Started,
152- UpdatedAt: snapshot.UpdatedAt,
153158 TaskSummary: summarizeSubagentTask(snapshot.Task),
154159 }
155- if !snapshot.Finished.IsZero() {
156- finished := snapshot.Finished
157- entry.FinishedAt = &finished
160+ if len(view.OpenOperations) != 0 {
161+ operation := view.OpenOperations[0]
162+ entry.Operation = &statusOperation{Type: operation.Type, StartedAt: operation.StartedAt}
163+ }
164+ if view.LastEvent.Type != "" {
165+ entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp}
158166 }
159167 if snapshot.Requirement.Required {
160168 entry.Requirement = &statusRequirement{
@@ -174,25 +182,6 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry
174182 return entry
175183 }
176184
177-func publicSubagentState(snapshot subagents.AgentSnapshot) string {
178- switch snapshot.Status {
179- case subagents.StatusPending:
180- return "starting"
181- case subagents.StatusRunning:
182- return "running"
183- case subagents.StatusDone:
184- return "completed"
185- case subagents.StatusFailed:
186- return "failed"
187- case subagents.StatusKilled:
188- return "cancelled"
189- case subagents.StatusDetached:
190- return "detached"
191- default:
192- return "unknown"
193- }
194-}
195-
196185 func publicResultState(status subagents.TurnStatus) string {
197186 switch status {
198187 case subagents.ResultSucceeded:
@@ -35,19 +35,27 @@ type subagentStatusResponse struct {
35 35
36 type subagentStatusEntry struct {36 type subagentStatusEntry struct {
37 ID string `json:"agent_id"`37 ID string `json:"agent_id"`
38- State string `json:"state"`38+ Operation *statusOperation `json:"operation,omitempty"`
39- ProcessState string `json:"process_state"`39+ Terminal string `json:"terminal,omitempty"`
40- TurnState string `json:"turn_state"`40+ LastEvent *statusLastEvent `json:"last_event,omitempty"`
41+ StartedAt time.Time `json:"started_at"`
41 LifetimeTurns int `json:"lifetime_turns"`42 LifetimeTurns int `json:"lifetime_turns"`
42 CurrentRunTurns int `json:"current_run_turns"`43 CurrentRunTurns int `json:"current_run_turns"`
43- StartedAt time.Time `json:"started_at"`
44- UpdatedAt time.Time `json:"updated_at"`
45- FinishedAt *time.Time `json:"finished_at,omitempty"`
46 TaskSummary string `json:"task_summary,omitempty"`44 TaskSummary string `json:"task_summary,omitempty"`
47 Requirement *statusRequirement `json:"requirement,omitempty"`45 Requirement *statusRequirement `json:"requirement,omitempty"`
48 Result *subagentStatusResult `json:"result,omitempty"`46 Result *subagentStatusResult `json:"result,omitempty"`
49 }47 }
50 48
49+type statusOperation struct {
50+ Type string `json:"type"`
51+ StartedAt time.Time `json:"started_at"`
52+}
53+
54+type statusLastEvent struct {
55+ Type string `json:"type"`
56+ At time.Time `json:"at"`
57+}
58+
51 type statusRequirement struct {59 type statusRequirement struct {
52 State string `json:"state"`60 State string `json:"state"`
53 TargetTurn int `json:"target_turn"`61 TargetTurn int `json:"target_turn"`
@@ -102,15 +110,15 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
102 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)110 return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
103 }111 }
104 112
105- // SnapshotAll is intentionally used for both list and single-worker113+ // SnapshotAll provides identity, session scope, and durable result facts.
106- // queries. Besides being non-blocking, it applies the supervisor's active114+ // The execution indication itself comes solely from the trace projection.
107- // session visibility rules so an id lookup cannot bypass list scoping.
108 snapshots := t.Supervisor.SnapshotAll()115 snapshots := t.Supervisor.SnapshotAll()
116+ views := t.Supervisor.TraceViews()
109 id := strings.TrimSpace(args.AgentID)117 id := strings.TrimSpace(args.AgentID)
110 if id == "" {118 if id == "" {
111 entries := make([]subagentStatusEntry, 0, len(snapshots))119 entries := make([]subagentStatusEntry, 0, len(snapshots))
112 for _, snapshot := range snapshots {120 for _, snapshot := range snapshots {
113- entries = append(entries, publicSubagentStatus(snapshot))121+ entries = append(entries, publicSubagentStatus(snapshot, views[snapshot.ID]))
114 }122 }
115 return renderSubagentStatus(subagentStatusResponse{Agents: entries})123 return renderSubagentStatus(subagentStatusResponse{Agents: entries})
116 }124 }
@@ -119,7 +127,7 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
119 if !ok {127 if !ok {
120 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))128 return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
121 }129 }
122- entry := publicSubagentStatus(snapshot)130+ entry := publicSubagentStatus(snapshot, views[snapshot.ID])
123 return renderSubagentStatus(subagentStatusResponse{Agent: &entry})131 return renderSubagentStatus(subagentStatusResponse{Agent: &entry})
124 }132 }
125 133
@@ -140,21 +148,21 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string)
140 return match, hits == 1148 return match, hits == 1
141 }149 }
142 150
143-func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {151+func publicSubagentStatus(snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) subagentStatusEntry {
144 entry := subagentStatusEntry{152 entry := subagentStatusEntry{
145 ID: snapshot.ID,153 ID: snapshot.ID,
146- State: publicSubagentState(snapshot),154+ Terminal: view.Terminal,
147- ProcessState: string(snapshot.ProcessState),155+ StartedAt: snapshot.Started,
148- TurnState: string(snapshot.TurnState),
149 LifetimeTurns: snapshot.LifetimeTurns,156 LifetimeTurns: snapshot.LifetimeTurns,
150 CurrentRunTurns: snapshot.CurrentRunTurns,157 CurrentRunTurns: snapshot.CurrentRunTurns,
151- StartedAt: snapshot.Started,
152- UpdatedAt: snapshot.UpdatedAt,
153 TaskSummary: summarizeSubagentTask(snapshot.Task),158 TaskSummary: summarizeSubagentTask(snapshot.Task),
154 }159 }
155- if !snapshot.Finished.IsZero() {160+ if len(view.OpenOperations) != 0 {
156- finished := snapshot.Finished161+ operation := view.OpenOperations[0]
157- entry.FinishedAt = &finished162+ entry.Operation = &statusOperation{Type: operation.Type, StartedAt: operation.StartedAt}
163+ }
164+ if view.LastEvent.Type != "" {
165+ entry.LastEvent = &statusLastEvent{Type: view.LastEvent.Type, At: view.LastEvent.Timestamp}
158 }166 }
159 if snapshot.Requirement.Required {167 if snapshot.Requirement.Required {
160 entry.Requirement = &statusRequirement{168 entry.Requirement = &statusRequirement{
@@ -174,25 +182,6 @@ func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry
174 return entry182 return entry
175 }183 }
176 184
177-func publicSubagentState(snapshot subagents.AgentSnapshot) string {
178- switch snapshot.Status {
179- case subagents.StatusPending:
180- return "starting"
181- case subagents.StatusRunning:
182- return "running"
183- case subagents.StatusDone:
184- return "completed"
185- case subagents.StatusFailed:
186- return "failed"
187- case subagents.StatusKilled:
188- return "cancelled"
189- case subagents.StatusDetached:
190- return "detached"
191- default:
192- return "unknown"
193- }
194-}
195-
196 func publicResultState(status subagents.TurnStatus) string {185 func publicResultState(status subagents.TurnStatus) string {
197 switch status {186 switch status {
198 case subagents.ResultSucceeded:187 case subagents.ResultSucceeded:
M packages/agent/tools/subagent_status_test.go +56 -40
@@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
4444 ID: "counter-agent",
4545 LifetimeTurns: 7,
4646 CurrentRunTurns: 2,
47- })
47+ }, subagents.AgentTraceView{})
4848 if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {
4949 t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)
5050 }
@@ -119,13 +119,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
119119 }
120120
121121 var got struct {
122- Agents []struct {
123- ID string `json:"agent_id"`
124- State string `json:"state"`
125- StartedAt string `json:"started_at"`
126- UpdatedAt string `json:"updated_at"`
127- TaskSummary string `json:"task_summary"`
128- } `json:"agents"`
122+ Agents []json.RawMessage `json:"agents"`
129123 }
130124 text := textResult(res.Content)
131125 if err := json.Unmarshal([]byte(text), &got); err != nil {
@@ -134,17 +128,36 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
134128 if len(got.Agents) != 1 {
135129 t.Fatalf("listed agents = %d, want 1", len(got.Agents))
136130 }
137- if got.Agents[0].ID != agent.ID {
138- t.Fatalf("agent id = %q, want %q", got.Agents[0].ID, agent.ID)
131+ var entry map[string]json.RawMessage
132+ if err := json.Unmarshal(got.Agents[0], &entry); err != nil {
133+ t.Fatal(err)
139134 }
140- if got.Agents[0].State != "running" {
141- t.Fatalf("state = %q, want running", got.Agents[0].State)
135+ for _, forbiddenField := range []string{"state", "process_state", "turn_state", "updated_at", "finished_at"} {
136+ if _, present := entry[forbiddenField]; present {
137+ t.Fatalf("status response exposes removed lifecycle field %q", forbiddenField)
138+ }
142139 }
143- if got.Agents[0].StartedAt == "" || got.Agents[0].UpdatedAt == "" {
144- t.Fatalf("timestamps = started %q updated %q, want both present", got.Agents[0].StartedAt, got.Agents[0].UpdatedAt)
140+ var agentID, startedAt, taskSummary string
141+ if err := json.Unmarshal(entry["agent_id"], &agentID); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := json.Unmarshal(entry["started_at"], &startedAt); err != nil {
145+ t.Fatal(err)
146+ }
147+ if err := json.Unmarshal(entry["task_summary"], &taskSummary); err != nil {
148+ t.Fatal(err)
145149 }
146- if got.Agents[0].TaskSummary != "review architecture" {
147- t.Fatalf("task summary = %q, want first task line", got.Agents[0].TaskSummary)
150+ if agentID != agent.ID {
151+ t.Fatalf("agent id = %q, want %q", agentID, agent.ID)
152+ }
153+ if startedAt == "" {
154+ t.Fatal("started_at is empty")
155+ }
156+ if taskSummary != "review architecture" {
157+ t.Fatalf("task summary = %q, want first task line", taskSummary)
158+ }
159+ if _, present := entry["last_event"]; !present {
160+ t.Fatal("status response omits trace-backed last_event")
148161 }
149162 for _, forbidden := range []string{
150163 "PRIVATE_PROMPT_MARKER",
@@ -163,7 +176,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
163176 agent.Wait()
164177 }
165178
166-func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) {
179+func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) {
167180 root := t.TempDir()
168181 started := make(chan struct{}, 2)
169182 release := make(chan struct{})
@@ -203,14 +216,18 @@ func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) {
203216 }
204217 var got struct {
205218 Agent struct {
206- State string `json:"state"`
219+ ID string `json:"agent_id"`
220+ Operation json.RawMessage `json:"operation"`
207221 } `json:"agent"`
208222 }
209223 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
210224 t.Fatal(err)
211225 }
212- if got.Agent.State != "starting" {
213- t.Fatalf("queued state = %q, want starting", got.Agent.State)
226+ if got.Agent.ID != queued.ID {
227+ t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID)
228+ }
229+ if len(got.Agent.Operation) != 0 {
230+ t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation)
214231 }
215232
216233 close(release)
@@ -249,10 +266,8 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi
249266
250267 var got struct {
251268 Agent struct {
252- ID string `json:"agent_id"`
253- State string `json:"state"`
254- Finished string `json:"finished_at"`
255- Result *struct {
269+ ID string `json:"agent_id"`
270+ Result *struct {
256271 State string `json:"state"`
257272 Available bool `json:"available"`
258273 } `json:"result"`
@@ -265,12 +280,6 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi
265280 if got.Agent.ID != agent.ID {
266281 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
267282 }
268- if got.Agent.State != "completed" {
269- t.Fatalf("state = %q, want completed", got.Agent.State)
270- }
271- if got.Agent.Finished == "" {
272- t.Fatal("finished_at is empty for completed worker")
273- }
274283 if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" {
275284 t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result)
276285 }
@@ -319,15 +328,15 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
319328 }
320329 }
321330
322-func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) {
331+func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) {
323332 for _, tc := range []struct {
324- name string
325- runnerErr error
326- stop bool
327- wantState string
333+ name string
334+ runnerErr error
335+ stop bool
336+ wantTerminal string
328337 }{
329- {name: "failed", runnerErr: errors.New("runner failed"), wantState: "failed"},
330- {name: "cancelled", stop: true, wantState: "cancelled"},
338+ {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"},
339+ {name: "cancelled", stop: true, wantTerminal: "cancelled"},
331340 } {
332341 t.Run(tc.name, func(t *testing.T) {
333342 root := t.TempDir()
@@ -370,14 +379,21 @@ func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) {
370379 }
371380 var got struct {
372381 Agent struct {
373- State string `json:"state"`
382+ ID string `json:"agent_id"`
383+ Terminal string `json:"terminal"`
374384 } `json:"agent"`
375385 }
376386 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
377387 t.Fatal(err)
378388 }
379- if got.Agent.State != tc.wantState {
380- t.Fatalf("state = %q, want %q", got.Agent.State, tc.wantState)
389+ if got.Agent.ID != agent.ID {
390+ t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
391+ }
392+ if got.Agent.Terminal != tc.wantTerminal {
393+ t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal)
394+ }
395+ if tc.stop && got.Agent.Terminal == "completed" {
396+ t.Fatalf("cancelled worker reported completed terminal")
381397 }
382398 })
383399 }
@@ -44,7 +44,7 @@ func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
44 ID: "counter-agent",44 ID: "counter-agent",
45 LifetimeTurns: 7,45 LifetimeTurns: 7,
46 CurrentRunTurns: 2,46 CurrentRunTurns: 2,
47- })47+ }, subagents.AgentTraceView{})
48 if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {48 if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {
49 t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)49 t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)
50 }50 }
@@ -119,13 +119,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
119 }119 }
120 120
121 var got struct {121 var got struct {
122- Agents []struct {122+ Agents []json.RawMessage `json:"agents"`
123- ID string `json:"agent_id"`
124- State string `json:"state"`
125- StartedAt string `json:"started_at"`
126- UpdatedAt string `json:"updated_at"`
127- TaskSummary string `json:"task_summary"`
128- } `json:"agents"`
129 }123 }
130 text := textResult(res.Content)124 text := textResult(res.Content)
131 if err := json.Unmarshal([]byte(text), &got); err != nil {125 if err := json.Unmarshal([]byte(text), &got); err != nil {
@@ -134,17 +128,36 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
134 if len(got.Agents) != 1 {128 if len(got.Agents) != 1 {
135 t.Fatalf("listed agents = %d, want 1", len(got.Agents))129 t.Fatalf("listed agents = %d, want 1", len(got.Agents))
136 }130 }
137- if got.Agents[0].ID != agent.ID {131+ var entry map[string]json.RawMessage
138- t.Fatalf("agent id = %q, want %q", got.Agents[0].ID, agent.ID)132+ if err := json.Unmarshal(got.Agents[0], &entry); err != nil {
133+ t.Fatal(err)
139 }134 }
140- if got.Agents[0].State != "running" {135+ for _, forbiddenField := range []string{"state", "process_state", "turn_state", "updated_at", "finished_at"} {
141- t.Fatalf("state = %q, want running", got.Agents[0].State)136+ if _, present := entry[forbiddenField]; present {
137+ t.Fatalf("status response exposes removed lifecycle field %q", forbiddenField)
138+ }
142 }139 }
143- if got.Agents[0].StartedAt == "" || got.Agents[0].UpdatedAt == "" {140+ var agentID, startedAt, taskSummary string
144- t.Fatalf("timestamps = started %q updated %q, want both present", got.Agents[0].StartedAt, got.Agents[0].UpdatedAt)141+ if err := json.Unmarshal(entry["agent_id"], &agentID); err != nil {
142+ t.Fatal(err)
143+ }
144+ if err := json.Unmarshal(entry["started_at"], &startedAt); err != nil {
145+ t.Fatal(err)
146+ }
147+ if err := json.Unmarshal(entry["task_summary"], &taskSummary); err != nil {
148+ t.Fatal(err)
145 }149 }
146- if got.Agents[0].TaskSummary != "review architecture" {150+ if agentID != agent.ID {
147- t.Fatalf("task summary = %q, want first task line", got.Agents[0].TaskSummary)151+ t.Fatalf("agent id = %q, want %q", agentID, agent.ID)
152+ }
153+ if startedAt == "" {
154+ t.Fatal("started_at is empty")
155+ }
156+ if taskSummary != "review architecture" {
157+ t.Fatalf("task summary = %q, want first task line", taskSummary)
158+ }
159+ if _, present := entry["last_event"]; !present {
160+ t.Fatal("status response omits trace-backed last_event")
148 }161 }
149 for _, forbidden := range []string{162 for _, forbidden := range []string{
150 "PRIVATE_PROMPT_MARKER",163 "PRIVATE_PROMPT_MARKER",
@@ -163,7 +176,7 @@ func TestSubagentStatusListsLiveWorkerWithoutPrivateOutput(t *testing.T) {
163 agent.Wait()176 agent.Wait()
164 }177 }
165 178
166-func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) {179+func TestSubagentStatusReportsQueuedWorkerWithoutLifecycleState(t *testing.T) {
167 root := t.TempDir()180 root := t.TempDir()
168 started := make(chan struct{}, 2)181 started := make(chan struct{}, 2)
169 release := make(chan struct{})182 release := make(chan struct{})
@@ -203,14 +216,18 @@ func TestSubagentStatusReportsQueuedWorkerAsStarting(t *testing.T) {
203 }216 }
204 var got struct {217 var got struct {
205 Agent struct {218 Agent struct {
206- State string `json:"state"`219+ ID string `json:"agent_id"`
220+ Operation json.RawMessage `json:"operation"`
207 } `json:"agent"`221 } `json:"agent"`
208 }222 }
209 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {223 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
210 t.Fatal(err)224 t.Fatal(err)
211 }225 }
212- if got.Agent.State != "starting" {226+ if got.Agent.ID != queued.ID {
213- t.Fatalf("queued state = %q, want starting", got.Agent.State)227+ t.Fatalf("agent id = %q, want %q", got.Agent.ID, queued.ID)
228+ }
229+ if len(got.Agent.Operation) != 0 {
230+ t.Fatalf("queued operation = %s, want omitted", got.Agent.Operation)
214 }231 }
215 232
216 close(release)233 close(release)
@@ -249,10 +266,8 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi
249 266
250 var got struct {267 var got struct {
251 Agent struct {268 Agent struct {
252- ID string `json:"agent_id"`269+ ID string `json:"agent_id"`
253- State string `json:"state"`270+ Result *struct {
254- Finished string `json:"finished_at"`
255- Result *struct {
256 State string `json:"state"`271 State string `json:"state"`
257 Available bool `json:"available"`272 Available bool `json:"available"`
258 } `json:"result"`273 } `json:"result"`
@@ -265,12 +280,6 @@ func TestSubagentStatusQueriesOneWorkerAndReportsTerminalResultMetadata(t *testi
265 if got.Agent.ID != agent.ID {280 if got.Agent.ID != agent.ID {
266 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)281 t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
267 }282 }
268- if got.Agent.State != "completed" {
269- t.Fatalf("state = %q, want completed", got.Agent.State)
270- }
271- if got.Agent.Finished == "" {
272- t.Fatal("finished_at is empty for completed worker")
273- }
274 if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" {283 if got.Agent.Result == nil || !got.Agent.Result.Available || got.Agent.Result.State != "completed" {
275 t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result)284 t.Fatalf("result metadata = %#v, want available completed result", got.Agent.Result)
276 }285 }
@@ -319,15 +328,15 @@ func TestSubagentStatusRejectsUnknownWorker(t *testing.T) {
319 }328 }
320 }329 }
321 330
322-func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) {331+func TestSubagentStatusOmitsLifecycleStateForFailedAndCancelledWorkers(t *testing.T) {
323 for _, tc := range []struct {332 for _, tc := range []struct {
324- name string333+ name string
325- runnerErr error334+ runnerErr error
326- stop bool335+ stop bool
327- wantState string336+ wantTerminal string
328 }{337 }{
329- {name: "failed", runnerErr: errors.New("runner failed"), wantState: "failed"},338+ {name: "failed", runnerErr: errors.New("runner failed"), wantTerminal: "failed"},
330- {name: "cancelled", stop: true, wantState: "cancelled"},339+ {name: "cancelled", stop: true, wantTerminal: "cancelled"},
331 } {340 } {
332 t.Run(tc.name, func(t *testing.T) {341 t.Run(tc.name, func(t *testing.T) {
333 root := t.TempDir()342 root := t.TempDir()
@@ -370,14 +379,21 @@ func TestSubagentStatusMapsFailedAndCancelledWorkers(t *testing.T) {
370 }379 }
371 var got struct {380 var got struct {
372 Agent struct {381 Agent struct {
373- State string `json:"state"`382+ ID string `json:"agent_id"`
383+ Terminal string `json:"terminal"`
374 } `json:"agent"`384 } `json:"agent"`
375 }385 }
376 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {386 if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
377 t.Fatal(err)387 t.Fatal(err)
378 }388 }
379- if got.Agent.State != tc.wantState {389+ if got.Agent.ID != agent.ID {
380- t.Fatalf("state = %q, want %q", got.Agent.State, tc.wantState)390+ t.Fatalf("agent id = %q, want %q", got.Agent.ID, agent.ID)
391+ }
392+ if got.Agent.Terminal != tc.wantTerminal {
393+ t.Fatalf("terminal = %q, want %q", got.Agent.Terminal, tc.wantTerminal)
394+ }
395+ if tc.stop && got.Agent.Terminal == "completed" {
396+ t.Fatalf("cancelled worker reported completed terminal")
381 }397 }
382 })398 })
383 }399 }
M packages/agent/tools/subagent_stop.go +3 -3
@@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
9898 if !ok {
9999 return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)
100100 }
101- return renderSubagentAction("stop_requested", snapshot)
101+ return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
102102 }
103103
104104 func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
@@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
107107 (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)
108108 }
109109
110-func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) {
110+func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) {
111111 response := subagentActionResponse{
112112 Action: action,
113- Agent: publicSubagentStatus(snapshot),
113+ Agent: publicSubagentStatus(snapshot, view),
114114 }
115115 data, err := json.Marshal(response)
116116 if err != nil {
@@ -98,7 +98,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
98 if !ok {98 if !ok {
99 return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)99 return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)
100 }100 }
101- return renderSubagentAction("stop_requested", snapshot)101+ return renderSubagentAction("stop_requested", snapshot, t.Supervisor.TraceViews()[snapshot.ID])
102 }102 }
103 103
104 func subagentCanStop(snapshot subagents.AgentSnapshot) bool {104 func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
@@ -107,10 +107,10 @@ func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
107 (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)107 (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)
108 }108 }
109 109
110-func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) {110+func renderSubagentAction(action string, snapshot subagents.AgentSnapshot, view subagents.AgentTraceView) (core.ToolResult, error) {
111 response := subagentActionResponse{111 response := subagentActionResponse{
112 Action: action,112 Action: action,
113- Agent: publicSubagentStatus(snapshot),113+ Agent: publicSubagentStatus(snapshot, view),
114 }114 }
115 data, err := json.Marshal(response)115 data, err := json.Marshal(response)
116 if err != nil {116 if err != nil {
A packages/agent/tracecmd.go +58 -0
@@ -0,0 +1,58 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "io"
6+ "sort"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+)
12+
13+func runDebugCommand(rawArgs []string, stdout io.Writer) (bool, error) {
14+ if len(rawArgs) == 0 || rawArgs[0] != "debug" {
15+ return false, nil
16+ }
17+ if len(rawArgs) != 3 || rawArgs[1] != "trace" {
18+ return true, fmt.Errorf("usage: zut debug trace <bundle>")
19+ }
20+ events, readErr := subagents.ReadTrace(rawArgs[2])
21+ if err := renderTraceInspection(stdout, subagents.ProjectTrace(events), time.Now()); err != nil {
22+ return true, err
23+ }
24+ return true, readErr
25+}
26+
27+func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceView, now time.Time) error {
28+ ids := make([]string, 0, len(views))
29+ for id := range views {
30+ ids = append(ids, id)
31+ }
32+ sort.Strings(ids)
33+ for _, id := range ids {
34+ view := views[id]
35+ if view.Terminal != "" {
36+ if _, err := fmt.Fprintf(w, "%s %s\n", id, view.Terminal); err != nil {
37+ return err
38+ }
39+ continue
40+ }
41+ if len(view.OpenOperations) != 0 {
42+ operation := view.OpenOperations[0]
43+ if _, err := fmt.Fprintf(w, "%s %s open %s\n", id, strings.Replace(operation.Type, ".started", "", 1), operation.Duration(now).Round(time.Second)); err != nil {
44+ return err
45+ }
46+ continue
47+ }
48+ age := now.Sub(view.LastEvent.Timestamp).Round(time.Second)
49+ when := age.String() + " ago"
50+ if age < 0 {
51+ when = "in " + (-age).String()
52+ }
53+ if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil {
54+ return err
55+ }
56+ }
57+ return nil
58+}
@@ -0,0 +1,58 @@
1+package agent
2+
3+import (
4+ "fmt"
5+ "io"
6+ "sort"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+)
12+
13+func runDebugCommand(rawArgs []string, stdout io.Writer) (bool, error) {
14+ if len(rawArgs) == 0 || rawArgs[0] != "debug" {
15+ return false, nil
16+ }
17+ if len(rawArgs) != 3 || rawArgs[1] != "trace" {
18+ return true, fmt.Errorf("usage: zut debug trace <bundle>")
19+ }
20+ events, readErr := subagents.ReadTrace(rawArgs[2])
21+ if err := renderTraceInspection(stdout, subagents.ProjectTrace(events), time.Now()); err != nil {
22+ return true, err
23+ }
24+ return true, readErr
25+}
26+
27+func renderTraceInspection(w io.Writer, views map[string]subagents.AgentTraceView, now time.Time) error {
28+ ids := make([]string, 0, len(views))
29+ for id := range views {
30+ ids = append(ids, id)
31+ }
32+ sort.Strings(ids)
33+ for _, id := range ids {
34+ view := views[id]
35+ if view.Terminal != "" {
36+ if _, err := fmt.Fprintf(w, "%s %s\n", id, view.Terminal); err != nil {
37+ return err
38+ }
39+ continue
40+ }
41+ if len(view.OpenOperations) != 0 {
42+ operation := view.OpenOperations[0]
43+ if _, err := fmt.Fprintf(w, "%s %s open %s\n", id, strings.Replace(operation.Type, ".started", "", 1), operation.Duration(now).Round(time.Second)); err != nil {
44+ return err
45+ }
46+ continue
47+ }
48+ age := now.Sub(view.LastEvent.Timestamp).Round(time.Second)
49+ when := age.String() + " ago"
50+ if age < 0 {
51+ when = "in " + (-age).String()
52+ }
53+ if _, err := fmt.Fprintf(w, "%s no observable operation; last event %s %s\n", id, view.LastEvent.Type, when); err != nil {
54+ return err
55+ }
56+ }
57+ return nil
58+}
A packages/agent/tracecmd_test.go +37 -0
@@ -0,0 +1,37 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+)
10+
11+func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) {
12+ now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC)
13+ views := subagents.ProjectTrace([]subagents.TraceEvent{{
14+ Type: "tool.started", AgentID: "agent-a", TurnID: "turn-1", Timestamp: now.Add(-30 * time.Second),
15+ }})
16+ var out bytes.Buffer
17+ if err := renderTraceInspection(&out, views, now); err != nil {
18+ t.Fatal(err)
19+ }
20+ if got, want := out.String(), "agent-a tool open 30s\n"; got != want {
21+ t.Fatalf("inspection = %q, want %q", got, want)
22+ }
23+}
24+
25+func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) {
26+ now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC)
27+ views := map[string]subagents.AgentTraceView{
28+ "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}},
29+ }
30+ var out bytes.Buffer
31+ if err := renderTraceInspection(&out, views, now); err != nil {
32+ t.Fatal(err)
33+ }
34+ if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want {
35+ t.Fatalf("inspection = %q, want %q", got, want)
36+ }
37+}
@@ -0,0 +1,37 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/zut/packages/agent/subagents"
9+)
10+
11+func TestRenderTraceInspectionUsesFactualOperations(t *testing.T) {
12+ now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC)
13+ views := subagents.ProjectTrace([]subagents.TraceEvent{{
14+ Type: "tool.started", AgentID: "agent-a", TurnID: "turn-1", Timestamp: now.Add(-30 * time.Second),
15+ }})
16+ var out bytes.Buffer
17+ if err := renderTraceInspection(&out, views, now); err != nil {
18+ t.Fatal(err)
19+ }
20+ if got, want := out.String(), "agent-a tool open 30s\n"; got != want {
21+ t.Fatalf("inspection = %q, want %q", got, want)
22+ }
23+}
24+
25+func TestRenderTraceInspectionUsesFutureOrientedLastEventTime(t *testing.T) {
26+ now := time.Date(2026, 8, 12, 7, 0, 30, 0, time.UTC)
27+ views := map[string]subagents.AgentTraceView{
28+ "agent-a": {AgentID: "agent-a", LastEvent: subagents.TraceEvent{Type: "agent.ready", Timestamp: now.Add(time.Minute)}},
29+ }
30+ var out bytes.Buffer
31+ if err := renderTraceInspection(&out, views, now); err != nil {
32+ t.Fatal(err)
33+ }
34+ if got, want := out.String(), "agent-a no observable operation; last event agent.ready in 1m0s\n"; got != want {
35+ t.Fatalf("inspection = %q, want %q", got, want)
36+ }
37+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close