detached 2d66a41

merge: integrate origin/main

commit
2d66a4142c96a48ea51796bcf0340ed5adac818c
author
brice · · 2026-08-08 09:40 UTC
committer
brice · · 2026-08-08 09:40 UTC
merge of
4c138a0 + 06b32e8 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
46 files · +3047 -528
M README.md +2 -2
@@ -390,7 +390,7 @@ Background subagents that run alongside your main session. Each one is a separat
390390
391391 **`/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.
392392
393-**Auto-subagents.** With `/settings` -> auto-subagents on, the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only completion signal. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle from `/settings`.
393+**Auto-subagents.** With `/settings` -> auto-subagents on, the interactive primary agent enters strict orchestrator mode and gets whichever of the canonical `subagent_spawn` and `subagent_status` tools are permitted by launch-time policy. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. It must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches itself. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize results. Give workers self-contained tasks: shared-worktree workers edit the same working directory and need coordination, while `isolation:"worktree"` is for parallel coding in separate trees; a worktree patch must be integrated by a worker rather than applied by the primary agent. Child workers cannot recursively spawn more sub-agents in v1. The `subagent_status` tool accepts an optional `agent_id`: omit it to list visible workers, or pass a full id / unique prefix to query one without waiting for completion. It returns privacy-safe JSON metadata with the worker id, lifecycle state (`starting`, `running`, `completed`, `failed`, `cancelled`, or `detached`), underlying process/turn state, timestamps, a bounded first-line task summary, and terminal-result metadata when available; it does not return prompts, transcripts, result output, credentials, provider settings, or filesystem paths. The spawn tool returns the child id and initial state immediately; completion is a durable `turn.result` with a `subagent://<id>/result` reference. It accepts named profiles, timeout, `max_turns`, and shared/worktree isolation. Omit `max_turns` to use the supervisor policy; the default ceiling is 3 and `subagents.max_turns` changes it. Global concurrency limits apply equally to slash commands, tool calls, and batches. Completion is host-event-driven: wait for the host-injected `[auto-subagents update]`, the only completion signal. Never use `bash sleep`, `watch`, `tail -f`, polling loops, repeated `subagent_status`, or dashboard/metadata/event-log/file checks solely to wait; work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests remain allowed. Off by default; toggle from `/settings`. When off, the permitted canonical subagent tools remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise performs work itself without the strict orchestrator contract or profile manifest.
394394
395395 ### Named subagent profiles
396396
@@ -404,7 +404,7 @@ Opens a dialog with every persistent setting. `up`/`down` to navigate, `enter` o
404404 - **render images when supported** — draw screenshots / `read`-returned images inline using the terminal's image protocol, or fall back to a text placeholder. Auto-detected from `TERM_PROGRAM`; the toggle overrides the detection. The row is greyed out and forced off on terminals that don't speak any image protocol.
405405 - **terminal alerts** — emit a terminal bell when the main session stops with work that may need attention, or when an extension raises a structured alert. Enabled by default; changes apply immediately and persist as `terminal_alerts_enabled`. Terminal emulators may render the bell audibly, visually, or not at all.
406406 - **AI terminal titles** — after the first real prompt of a fresh interactive session, make one small hidden request to the active model and set the terminal title to `zut: <title>`. The title is limited to 40 Unicode characters, persisted with the session, restored on resume, and never added to the conversation. Enabled by default; disable it to avoid the extra model request. The toggle applies immediately, but title generation still waits for the first real prompt and never starts from startup context, resumed history, or slash commands. Persists as `terminal_title_enabled`.
407-- **auto-subagents** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven: `[auto-subagents update]` completion updates are the only signal; do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
407+- **auto-subagents** — enable strict primary-agent orchestrator mode: delegate implementation, debugging/testing, and code-review work to named or general workers; the primary agent coordinates but does not make direct implementation changes. It can spawn background sub-agents via `subagent_spawn` and query live state via `subagent_status` when those tools are permitted. If delegation is unavailable because of launch-time restrictions, the contract remains active, profile metadata is withheld, and the primary reports the limitation rather than implementing directly. Off by default. When off, the permitted canonical subagent tools remain available for user-requested delegation or when an active skill workflow requires it; the primary otherwise works directly without the strict orchestrator contract or profile manifest. The tools accept named profiles, reasoning, timeout, turn limits, and shared/worktree isolation where applicable; lifecycle and result references are persisted. Completion is host-event-driven: `[auto-subagents update]` completion updates are the only signal; do not poll or inspect dashboards, metadata, event logs, or files solely to wait. See `/subagents` and [docs/subagents.md](docs/subagents.md) for details.
408408 - **Ponytail coding mode** — include compact engineering guidance in each resolved system prompt; the guidance tells the model to apply it to coding, debugging, and review work rather than ordinary conversation. It favors understanding the real flow, small validated changes, reuse, and preserving safety checks. Enabled by default; changes apply to the next model call and persist as `ponytail_enabled` in `$ZUT_HOME/config.json`. It is included by interactive, print, stream, JSON, RPC, subagent, bot, SDK, and Zutfile agents.
409409 - **fast mode** — request the provider's fast tier where supported. It currently uses OpenAI's Fast service tier for OpenAI, OpenAI Responses, and OpenAI Codex models. Off by default; toggle it with `/fast` or `/settings`; changes apply on the next model call and persist as `fast_mode`. Other providers currently return an unsupported-provider error. Fast mode may cost more and depends on the selected model/account.
410410 - **lsp in main session** — enable the built-in `lsp` tool and code diagnostics for the main agent. Enabled by default; changes persist as `lsp_enabled`.
M docs/subagents.md +10 -4
@@ -54,7 +54,9 @@ Other frontmatter from another agent host is ignored when zut does not have an e
5454
5555 ## Selecting a profile
5656
57-When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results. This is a prompt-level behavior contract; the normal tool registry is not narrowed by this setting.
57+When **auto-subagents** is enabled in `/settings`, the interactive primary agent runs in strict orchestrator mode. It must delegate all implementation, debugging/testing, and code-review work to an appropriately named profile, or to a clearly described general worker when no profile fits. The primary agent must not write or edit code, make direct implementation tool calls, inspect or review code, or apply worker patches. It may decompose requests, select and spawn workers, check status, coordinate follow-ups, and synthesize their results.
58+
59+When auto-subagents is disabled, the canonical subagent tools remain available subject to launch-time policy. The primary may use them when the user requests delegation or an active skill workflow requires delegation. It continues to perform work itself otherwise and does not receive the profile manifest or strict orchestrator contract. The setting changes prompting, not tool availability.
5860
5961 When `subagent_spawn` is available, the orchestrator receives a compact `[subagents_list]` section in its system prompt and should select the profile whose description best matches each worker task. If launch-time policy withholds `subagent_spawn`, strict orchestrator guidance remains active, profile metadata is omitted, and the primary must report that delegation is unavailable rather than implementing directly. If no profile fits when spawning is available, omit `agent` to use a general worker:
6062
@@ -123,12 +125,16 @@ subagent://<id>/result
123125 subagent://<id>/patch
124126 ```
125127
128+`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.
129+
130+When a subagent deadline expires, the supervisor requests worker shutdown and allows the configured grace period for the worker to cancel its turn and write its session and result. The terminal result remains a failure with error code `deadline_exceeded`, preserves any output already received, and directs the caller to its history. If the worker does not stop during that grace period, the supervisor forcefully cancels it to preserve the timeout limit.
131+
126132 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.
127133
128-When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. 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`, start/update/finish timestamps, a bounded first-line task summary, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.
134+When auto-subagents is enabled and permitted by launch-time policy, the model can manage workers with `subagent_status`, `subagent_stop`, and `subagent_resume`. 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, and terminal-result metadata/reference when available. It intentionally omits prompts, transcripts, result output, credentials, provider settings, and filesystem paths.
129135
130-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 send a new follow-up to an idle worker or to restart a terminal worker from its retained session with that follow-up as the initial turn. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. 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.
136+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. 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. Commands have stable identities so cancellation or an ambiguous socket-write outcome can be retried without replaying an accepted command. It never replays the original task, and its completion is delivered to the manager through the normal auto-subagent update. 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.
131137
132138 ## Resource policy
133139
134-The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
140+The persisted `subagents` config object supports `max_concurrent`, `max_concurrent_per_parent`, `queue_timeout`, `default_timeout`, `max_turns`, output caps, allowed tools/roots, heartbeat and idle timeouts. `max_turns` bounds one active worker run; lifetime and current-run turn counters remain persisted and visible in worker status. An explicit `subagent_resume` starts a new run with a fresh budget without replaying the original task or losing session context. Previously saved `max_total_spawned` values are accepted but ignored. A missing or non-positive configured `max_turns` uses the default ceiling of 3. The `max_turns` field in `subagent_spawn` is optional: omit it to use the policy ceiling, or provide a value from 1 through the configured maximum. Concurrency limits apply to slash commands, `subagent_spawn`, and batch operations. A child cannot create descendants in v1. Per-agent timeouts are retained across reload/resume. Packaged `zut run` agents keep their declared capability ceiling by disabling subagent delegation; profiles are not a substitute for that permission boundary. A primary whose launch-time policy disables delegation still receives the strict orchestrator contract, but does not receive actionable profile metadata or a replacement implementation path.
M packages/agent/args.go +26 -1
@@ -139,8 +139,13 @@ type Args struct {
139139 // --subagent-worker.
140140 SubagentWorker string
141141
142- // SubagentMaxTurns limits prompt-level turns in worker mode.
142+ // SubagentMaxTurns limits message turns in one worker run.
143143 SubagentMaxTurns int
144+ // SubagentLifetimeTurns and SubagentRunTurns are supervisor-provided
145+ // counters used to resume a worker without losing observability or its
146+ // current-run budget. They are internal worker flags, not user policy.
147+ SubagentLifetimeTurns int
148+ SubagentRunTurns int
144149
145150 // Subagent selects a named markdown profile for a subagent child.
146151 // It is intentionally an internal child flag; the parent subagent tool
@@ -371,6 +376,26 @@ func ParseArgs(in []string) (Args, error) {
371376 return a, fmt.Errorf("--max-turns must be a positive integer")
372377 }
373378 a.SubagentMaxTurns = n
379+ case "--subagent-lifetime-turns":
380+ v, err := want(&i, arg)
381+ if err != nil {
382+ return a, err
383+ }
384+ var n int
385+ if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
386+ return a, fmt.Errorf("--subagent-lifetime-turns must be a non-negative integer")
387+ }
388+ a.SubagentLifetimeTurns = n
389+ case "--subagent-run-turns":
390+ v, err := want(&i, arg)
391+ if err != nil {
392+ return a, err
393+ }
394+ var n int
395+ if _, err := fmt.Sscanf(v, "%d", &n); err != nil || n < 0 {
396+ return a, fmt.Errorf("--subagent-run-turns must be a non-negative integer")
397+ }
398+ a.SubagentRunTurns = n
374399 default:
375400 if strings.HasPrefix(arg, "-") && arg != "-" {
376401 return a, fmt.Errorf("unknown flag %q", arg)
M packages/agent/build.go +2 -0
@@ -870,6 +870,8 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
870870 autoSubagentsStopToolAllowed(args),
871871 autoSubagentsResumeToolAllowed(args),
872872 ))
873+ } else if selectedProfile == nil && interactiveMode && autoSubagentsAnyToolAllowed(args) {
874+ append_ = append(append_, OnDemandSubagentsSystemAddendum)
873875 }
874876 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
875877 append_ = append(append_, selectedProfile.SystemPrompt)
M packages/agent/build_test.go +8 -0
@@ -501,6 +501,14 @@ func TestResolveOmitsAutoSubagentsOrchestratorContractWhenDisabled(t *testing.T)
501501 t.Fatalf("disabled system prompt contains auto-subagents contract %q:\n%s", unwanted, r.SystemPrompt)
502502 }
503503 }
504+ for _, required := range []string{
505+ "user asks you to delegate",
506+ "active skill workflow requires delegation",
507+ } {
508+ if !strings.Contains(r.SystemPrompt, required) {
509+ t.Fatalf("disabled system prompt omits permitted delegation path %q:\n%s", required, r.SystemPrompt)
510+ }
511+ }
504512 }
505513
506514 func TestResolveAppliesSelectedSubagentProfile(t *testing.T) {
M packages/agent/cli.go +32 -37
@@ -1496,23 +1496,17 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
14961496 return findSubagentProfile(r.CWD, name)
14971497 }
14981498
1499- // Inject the auto-subagent tools only when /settings -> auto-subagents
1500- // is currently enabled. Registering them unconditionally leaves the model
1501- // trying to call them (and getting a polite error) even when the user has
1502- // switched the feature off. The /settings toggle live-mutates the running
1503- // agent's registry separately so flipping the flag mid-session takes effect
1504- // on the next turn.
1499+ // Register the canonical subagent tools whenever launch-time policy permits
1500+ // them. The auto-subagents setting controls strict orchestration guidance,
1501+ // not explicit user-requested delegation.
15051502 injectSubagentTools := func(reg core.Registry) core.Registry {
15061503 if reg == nil || subagentsMgr == nil || !autoSubagentsAnyToolAllowed(args) {
15071504 return reg
15081505 }
1509- if !AutoSubagentsEnabled() {
1510- return reg
1511- }
15121506 if autoSubagentsToolAllowed(args) {
15131507 canonical := &tools.SubagentSpawnTool{
15141508 Supervisor: subagentsMgr,
1515- Enabled: AutoSubagentsEnabled,
1509+ Enabled: func() bool { return true },
15161510 DefaultModel: func() string {
15171511 persistMu.Lock()
15181512 defer persistMu.Unlock()
@@ -1532,14 +1526,14 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
15321526 if autoSubagentsStatusToolAllowed(args) {
15331527 statusTool := &tools.SubagentStatusTool{
15341528 Supervisor: subagentsMgr,
1535- Enabled: AutoSubagentsEnabled,
1529+ Enabled: func() bool { return true },
15361530 }
15371531 reg[statusTool.Name()] = statusTool
15381532 }
15391533 if autoSubagentsStopToolAllowed(args) {
15401534 stopTool := &tools.SubagentStopTool{
15411535 Supervisor: subagentsMgr,
1542- Enabled: AutoSubagentsEnabled,
1536+ Enabled: func() bool { return true },
15431537 OnStopRequested: onStopRequestedSupervisor,
15441538 }
15451539 reg[stopTool.Name()] = stopTool
@@ -1547,7 +1541,7 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
15471541 if autoSubagentsResumeToolAllowed(args) {
15481542 resumeTool := &tools.SubagentResumeTool{
15491543 Supervisor: subagentsMgr,
1550- Enabled: AutoSubagentsEnabled,
1544+ Enabled: func() bool { return true },
15511545 OnResumed: onResumedSupervisor,
15521546 }
15531547 reg[resumeTool.Name()] = resumeTool
@@ -2341,30 +2335,31 @@ func runInteractive(ctx context.Context, args Args, version string) (runErr erro
23412335 autoSubagentsStopToolAllowedForSession,
23422336 autoSubagentsResumeToolAllowedForSession,
23432337 ),
2344- SubagentsSystemAddendum: subagentsAddendum,
2345- SettingsStore: configSettingsStore{},
2346- Model: r.Model,
2347- Provider: r.Provider,
2348- AuthMethod: r.AuthMethod,
2349- BaseURL: r.BaseURL,
2350- Reasoning: r.Reasoning,
2351- SystemPrompt: r.SystemPrompt,
2352- Tools: r.ToolRegistry,
2353- MaxSteps: r.MaxSteps,
2354- CWD: r.CWD,
2355- StartupAgentName: args.AgentName,
2356- StartupContextPaths: instructionContextPaths(r.ContextFiles),
2357- StartupExtensionNames: startupExtensionNames(extMgr.All()),
2358- StartupExtensionErrors: startupExtensionErrors,
2359- StartupSkillNames: startupSkillNames(startupSkills),
2360- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2361- ZutHome: ZutHome(),
2362- SessionsRoot: agentSessionsRoot(ZutHome(), args),
2363- Version: version,
2364- UpdateInfoChan: updateCh,
2365- Sandbox: sharedSandbox,
2366- Agent: ag,
2367- InitialCompactHandoff: currentCompactHandoff(),
2338+ OnDemandSubagentsSystemAddendum: OnDemandSubagentsSystemAddendum,
2339+ SubagentsSystemAddendum: subagentsAddendum,
2340+ SettingsStore: configSettingsStore{},
2341+ Model: r.Model,
2342+ Provider: r.Provider,
2343+ AuthMethod: r.AuthMethod,
2344+ BaseURL: r.BaseURL,
2345+ Reasoning: r.Reasoning,
2346+ SystemPrompt: r.SystemPrompt,
2347+ Tools: r.ToolRegistry,
2348+ MaxSteps: r.MaxSteps,
2349+ CWD: r.CWD,
2350+ StartupAgentName: args.AgentName,
2351+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2352+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2353+ StartupExtensionErrors: startupExtensionErrors,
2354+ StartupSkillNames: startupSkillNames(startupSkills),
2355+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2356+ ZutHome: ZutHome(),
2357+ SessionsRoot: agentSessionsRoot(ZutHome(), args),
2358+ Version: version,
2359+ UpdateInfoChan: updateCh,
2360+ Sandbox: sharedSandbox,
2361+ Agent: ag,
2362+ InitialCompactHandoff: currentCompactHandoff(),
23682363 InitialSessionTitle: func() string {
23692364 if sess == nil || sessionTitlePending {
23702365 return ""
M packages/agent/modes/auto_subagents_policy_test.go +28 -2
@@ -155,7 +155,7 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
155155 dirty: make(chan struct{}, 1),
156156 }
157157
158- iv.applyAutoSubagentsTool(true)
158+ iv.applyAutoSubagentsTool()
159159 if _, ok := iv.agent.Tools["subagent_spawn"]; ok {
160160 t.Fatal("subagent_spawn registered despite launch-time policy")
161161 }
@@ -220,7 +220,7 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
220220 dirty: make(chan struct{}, 1),
221221 }
222222
223- iv.applyAutoSubagentsTool(true)
223+ iv.applyAutoSubagentsTool()
224224 _, gotSpawn := iv.agent.Tools["subagent_spawn"]
225225 _, gotStatus := iv.agent.Tools["subagent_status"]
226226 _, gotStop := iv.agent.Tools["subagent_stop"]
@@ -232,6 +232,32 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
232232 }
233233 }
234234
235+func TestAutoSubagentsDisabledKeepsToolsForExplicitUserRequests(t *testing.T) {
236+ enabled := false
237+ allowed := true
238+ supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
239+ t.Cleanup(supervisor.StopAll)
240+ iv := NewInteractive(InteractiveConfig{
241+ Agent: &core.Agent{Tools: core.Registry{}},
242+ AutoSubagentsEnabled: &enabled,
243+ AutoSubagentsToolAllowed: &allowed,
244+ AutoSubagentsSystemAddendum: "primary-agent orchestrator",
245+ Supervisor: supervisor,
246+ })
247+
248+ spawn, ok := iv.agent.ToolsSnapshot()["subagent_spawn"].(*tools.SubagentSpawnTool)
249+ if !ok {
250+ t.Fatal("subagent_spawn is unavailable when automatic subagents are disabled")
251+ }
252+ if spawn.Enabled == nil || !spawn.Enabled() {
253+ t.Fatal("subagent_spawn is disabled when a user explicitly requests delegation")
254+ }
255+ system, _ := iv.agent.PromptConfig()
256+ if strings.Contains(system, "primary-agent orchestrator") {
257+ t.Fatalf("disabled automatic subagents retained the orchestrator contract: %q", system)
258+ }
259+}
260+
235261 type serializedAutoSubagentsClient struct {
236262 started chan struct{}
237263 request chan provider.Request
M packages/agent/modes/auto_subagents_test.go +11 -4
@@ -13,13 +13,17 @@ import (
1313 "github.com/bnema/zut/packages/provider"
1414 )
1515
16+const autoSubagentsTestTimeout = 10 * time.Second
17+
1618 func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t *testing.T) {
19+ const onDemand = "delegate only on an explicit user request"
1720 iv := &Interactive{
1821 agent: &core.Agent{System: "base system"},
1922 cfg: InteractiveConfig{
20- AutoSubagentsSystemAddendum: "auto-subagents guidance",
21- SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
22- Supervisor: &subagents.Supervisor{},
23+ AutoSubagentsSystemAddendum: "auto-subagents guidance",
24+ OnDemandSubagentsSystemAddendum: onDemand,
25+ SubagentsSystemAddendum: "[subagents_list]\n- reviewer\n[/subagents_list]",
26+ Supervisor: &subagents.Supervisor{},
2327 },
2428 }
2529
@@ -37,6 +41,9 @@ func TestAutoSubagentsSystemPromptTogglesProfileManifestWithDelegationGuidance(t
3741 if strings.Contains(iv.agent.System, "[subagents_list]") || strings.Contains(iv.agent.System, "auto-subagents guidance") {
3842 t.Fatalf("disabled system prompt retained subagent blocks: %q", iv.agent.System)
3943 }
44+ if !strings.Contains(iv.agent.System, onDemand) {
45+ t.Fatalf("disabled system prompt omitted on-demand delegation guidance: %q", iv.agent.System)
46+ }
4047 }
4148
4249 func TestAutoSubagentsTogglePreservesMatchingBasePromptBlocks(t *testing.T) {
@@ -327,7 +334,7 @@ func newQueuedAutoSubagentsInteractive() *Interactive {
327334
328335 func waitForQueuedPrompt(t *testing.T, iv *Interactive) string {
329336 t.Helper()
330- deadline := time.NewTimer(time.Second)
337+ deadline := time.NewTimer(autoSubagentsTestTimeout)
331338 defer deadline.Stop()
332339 ticker := time.NewTicker(time.Millisecond)
333340 defer ticker.Stop()
M packages/agent/modes/interactive.go +67 -85
@@ -179,12 +179,15 @@ type InteractiveConfig struct {
179179 // ExtensionThemes returns themes bundled with loaded extensions.
180180 ExtensionThemes func() []tui.ThemeOption
181181
182- // AutoSubagentsSystemAddendum is the system-prompt block that gets
183- // appended/stripped when the user toggles auto-subagents at runtime.
184- // Plumbed in from the cli so this package doesn't have to import
185- // agent (cycle).
182+ // AutoSubagentsSystemAddendum is the strict orchestrator block that gets
183+ // appended when the user enables auto-subagents at runtime. Plumbed in from
184+ // the cli so this package doesn't have to import agent (cycle).
186185 AutoSubagentsSystemAddendum string
187186
187+ // OnDemandSubagentsSystemAddendum limits delegation to explicit user
188+ // requests while auto-subagents is disabled.
189+ OnDemandSubagentsSystemAddendum string
190+
188191 // SubagentsSystemAddendum is the metadata-only [subagents_list]
189192 // block that is added or removed together with auto-subagents.
190193 SubagentsSystemAddendum string
@@ -974,9 +977,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
974977 baseURL, _, err := cfg.LlamaCPPConfig()
975978 i.llamaConfigured = err == nil && baseURL != ""
976979 }
977- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
978- i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg)
979- }
980+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(cfg, cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled)
980981 if cfg.Agent != nil {
981982 i.agent = cfg.Agent
982983 i.view.Messages = filterHiddenTranscriptMessages(cfg.Agent.Messages())
@@ -1003,9 +1004,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
10031004 i.titleRealPromptSeen = i.sessionTitle != "" || hasRealUserPrompt(i.view.Messages)
10041005 i.titleGenerationStarted = i.titleRealPromptSeen
10051006 }
1006- if cfg.AutoSubagentsEnabled != nil && *cfg.AutoSubagentsEnabled {
1007- i.applyAutoSubagentsTool(true)
1008- }
1007+ i.applyAutoSubagentsTool()
10091008 return i
10101009 }
10111010
@@ -1343,10 +1342,9 @@ func (i *Interactive) requestRendererTheme(theme tui.Theme) {
13431342
13441343 func (i *Interactive) invalidate() {
13451344 i.renderRevision.Add(1)
1346- if scheduler := i.renderScheduler.Load(); scheduler != nil {
1347- scheduler.request(false, false)
1348- return
1349- }
1345+ // Keep ordinary state changes on the main-loop throttle. The scheduler
1346+ // owns terminal output, but scheduling it here would bypass
1347+ // redrawMinInterval and paint every intermediate tool-event frame.
13501348 select {
13511349 case i.dirty <- struct{}{}:
13521350 default:
@@ -2224,11 +2222,6 @@ func (i *Interactive) redraw() {
22242222 i.subagentActivityActive = !dashboardActive && len(subagentLines) > 0
22252223
22262224 chatRows := rows - len(bottom)
2227- if rightBarActive {
2228- // DrawRightBar keeps the renderer-owned bottom margin inside the
2229- // fixed frame, so reserve that row before selecting visible chat.
2230- chatRows--
2231- }
22322225 if chatRows < 1 {
22332226 chatRows = 1
22342227 }
@@ -4018,11 +4011,7 @@ func (i *Interactive) applyChangedCWD(ag *core.Agent, provider, model, cwd strin
40184011 i.agent = ag
40194012 i.cfg.CWD = cwd
40204013 i.cfg.SubagentsSystemAddendum = subagentsAddendum
4021- if i.autoSubagentsEnabledLocked() {
4022- i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg)
4023- } else {
4024- i.managedAutoSubagentsAddenda = nil
4025- }
4014+ i.managedAutoSubagentsAddenda = autoSubagentsAddenda(i.cfg, i.autoSubagentsEnabledLocked())
40264015 i.cfg.StartupContextPaths = append([]string(nil), startupContextPaths...)
40274016 i.view.StartupContextPaths = nil
40284017 i.view.InvalidateRenderCache()
@@ -5244,14 +5233,12 @@ func (i *Interactive) applySettingToggle(key string, value bool) {
52445233 return
52455234 }
52465235 }
5247- // Add/remove the auto-subagent tools on the live agent so the
5248- // model's tools[] list reflects the toggle on the next turn.
5249- // Without this the tools stay advertised after a disable and
5250- // the model keeps trying to call them.
5251- i.applyAutoSubagentsTool(value)
5252- // Also swap the system-prompt addendum in/out so the model
5253- // knows to use the tools proactively (or stops referencing them
5254- // after a disable).
5236+ // The subagent tools remain available for explicit user requests.
5237+ // Swap only the system-prompt guidance between strict orchestration and
5238+ // on-demand delegation.
5239+ i.applyAutoSubagentsTool()
5240+ // Also swap the system-prompt addendum so the model knows whether it
5241+ // should orchestrate proactively or delegate only on user request.
52555242 i.applyAutoSubagentsSystemPrompt(value)
52565243 i.mu.Lock()
52575244 i.statusOK = "auto-subagents " + onOff(value)
@@ -6909,7 +6896,7 @@ func (i *Interactive) swapModelUnserialized(prov, model string, builder func(str
69096896 // dynamically-registered tools need to be reattached. The apply
69106897 // helpers are no-ops when their feature is inactive, so the
69116898 // cross-provider path still works on a vanilla setup.
6912- i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6899+ i.applyAutoSubagentsTool()
69136900 i.applyTelegramTools(i.telegramBridge != nil)
69146901 if i.cfg.PersistModel != nil {
69156902 i.cfg.PersistModel(p, md)
@@ -6952,7 +6939,7 @@ func (i *Interactive) handleAuthEvent(ev auth.Event) {
69526939 if oldAgent != nil {
69536940 _ = tools.CloseLSPManagers(oldAgent.ToolsSnapshot())
69546941 }
6955- i.applyAutoSubagentsTool(i.autoSubagentsEnabled())
6942+ i.applyAutoSubagentsTool()
69566943 i.applyTelegramTools(i.telegramBridge != nil)
69576944 // Authentication can change the provider used by the live
69586945 // agent. Persist it on the active session just like /model.
@@ -8851,8 +8838,18 @@ func truncateForSummary(s string, n int) string {
88518838 }
88528839
88538840 // autoSubagentsAddenda returns the prompt blocks owned by the auto-subagents
8854-// toggle, in the same order Resolve appends them to a new agent.
8855-func autoSubagentsAddenda(cfg InteractiveConfig) []string {
8841+// setting, in the same order Resolve appends them to a new agent.
8842+func autoSubagentsAddenda(cfg InteractiveConfig, orchestrating bool) []string {
8843+ if !orchestrating {
8844+ if cfg.Supervisor == nil || !autoSubagentsAnyToolAllowedConfig(cfg) {
8845+ return nil
8846+ }
8847+ if addendum := strings.TrimSpace(cfg.OnDemandSubagentsSystemAddendum); addendum != "" {
8848+ return []string{addendum}
8849+ }
8850+ return nil
8851+ }
8852+
88568853 addenda := make([]string, 0, 2)
88578854 if cfg.Supervisor != nil && autoSubagentsToolAllowedConfig(cfg) {
88588855 if addendum := strings.TrimSpace(cfg.SubagentsSystemAddendum); addendum != "" {
@@ -8865,15 +8862,6 @@ func autoSubagentsAddenda(cfg InteractiveConfig) []string {
88658862 return addenda
88668863 }
88678864
8868-func containsAutoSubagentsAddendum(addenda []string, want string) bool {
8869- for _, addendum := range addenda {
8870- if addendum == want {
8871- return true
8872- }
8873- }
8874- return false
8875-}
8876-
88778865 // removeLastAutoSubagentsAddendum removes one occurrence of a block known to
88788866 // have been appended by auto-subagents. Resolve appends owned blocks at the
88798867 // end, so removing the last occurrence preserves an identical base block.
@@ -8885,11 +8873,10 @@ func removeLastAutoSubagentsAddendum(system, addendum string) (string, bool) {
88858873 return system[:idx] + system[idx+len(addendum):], true
88868874 }
88878875
8888-// applyAutoSubagentsSystemPrompt appends (active=true) or strips
8889-// (active=false) the auto-subagents prompt blocks on the running agent.
8890-// The profile manifest and delegation guidance are managed together so
8891-// toggling auto-subagents never leaves the model with names but no tool.
8892-func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
8876+// applyAutoSubagentsSystemPrompt swaps the prompt blocks owned by the
8877+// auto-subagents setting on the running agent. The profile manifest belongs
8878+// only to strict orchestration; disabled mode keeps on-demand guidance.
8879+func (i *Interactive) applyAutoSubagentsSystemPrompt(orchestrating bool) {
88938880 // i.mu is sufficient here: this path updates the existing agent's system
88948881 // prompt and managed addenda in place; it does not replace the agent or
88958882 // its tool registry, so agentMu is not required.
@@ -8898,22 +8885,6 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
88988885 if i.agent == nil {
88998886 return
89008887 }
8901- addenda := autoSubagentsAddenda(i.cfg)
8902- if active {
8903- sys, _ := i.agent.PromptConfig()
8904- for _, addendum := range addenda {
8905- if containsAutoSubagentsAddendum(i.managedAutoSubagentsAddenda, addendum) {
8906- continue
8907- }
8908- if sys != "" && !strings.HasSuffix(sys, "\n\n") {
8909- sys += "\n\n"
8910- }
8911- sys += addendum
8912- i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
8913- }
8914- i.agent.SetSystemPrompt(sys)
8915- return
8916- }
89178888
89188889 sys, _ := i.agent.PromptConfig()
89198890 changed := false
@@ -8923,16 +8894,33 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
89238894 changed = changed || removed
89248895 }
89258896 i.managedAutoSubagentsAddenda = nil
8926- if changed {
8927- i.agent.SetSystemPrompt(strings.TrimRight(sys, "\n") + "\n")
8897+
8898+ addenda := autoSubagentsAddenda(i.cfg, orchestrating)
8899+ if changed && len(addenda) > 0 {
8900+ sys = strings.TrimRight(sys, "\n")
89288901 }
8902+ for _, addendum := range addenda {
8903+ if sys != "" {
8904+ sys += "\n\n"
8905+ }
8906+ sys += addendum
8907+ i.managedAutoSubagentsAddenda = append(i.managedAutoSubagentsAddenda, addendum)
8908+ changed = true
8909+ }
8910+ if !changed {
8911+ return
8912+ }
8913+ if len(addenda) == 0 {
8914+ sys = strings.TrimRight(sys, "\n") + "\n"
8915+ }
8916+ i.agent.SetSystemPrompt(sys)
89298917 }
89308918
8931-// applyAutoSubagentsTool registers (active=true) or removes (active=false)
8932-// the auto-subagent tools on the running agent so the model only sees them
8933-// when /settings -> auto-subagents is enabled. Mirrors applyTelegramTools'
8934-// snapshot+mutate pattern so extension tools and /reload-ext additions
8935-// survive a toggle.
8919+// applyAutoSubagentsTool registers the canonical subagent tools whenever
8920+// launch-time policy permits them. Auto-subagents controls prompt behavior,
8921+// while the tools remain available for explicit user-requested delegation.
8922+// Mirrors applyTelegramTools' snapshot+mutate pattern so extension tools and
8923+// /reload-ext additions survive a settings change.
89368924 func (i *Interactive) autoSubagentsAvailable() bool {
89378925 return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
89388926 }
@@ -8998,17 +8986,11 @@ func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
89988986 return autoSubagentsResumeToolAllowedConfig(i.cfg)
89998987 }
90008988
9001-func (i *Interactive) autoSubagentsEnabled() bool {
9002- i.mu.Lock()
9003- defer i.mu.Unlock()
9004- return i.autoSubagentsEnabledLocked()
9005-}
9006-
90078989 func (i *Interactive) autoSubagentsEnabledLocked() bool {
90088990 return i.cfg.AutoSubagentsEnabled != nil && *i.cfg.AutoSubagentsEnabled && i.autoSubagentsAvailable()
90098991 }
90108992
9011-func (i *Interactive) applyAutoSubagentsTool(active bool) {
8993+func (i *Interactive) applyAutoSubagentsTool() {
90128994 i.agentMu.Lock()
90138995 defer i.agentMu.Unlock()
90148996 i.mu.Lock()
@@ -9024,11 +9006,11 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
90249006 }
90259007 next[name] = t
90269008 }
9027- if active && i.autoSubagentsAvailable() {
9009+ if i.autoSubagentsAvailable() {
90289010 if i.autoSubagentsToolAllowed() {
90299011 canonical := &tools.SubagentSpawnTool{
90309012 Supervisor: i.cfg.Supervisor,
9031- Enabled: func() bool { return i.autoSubagentsEnabled() },
9013+ Enabled: func() bool { return true },
90329014 DefaultModel: func() string { return i.cfg.Model },
90339015 DefaultProvider: func() string { return i.cfg.Provider },
90349016 DefaultReasoning: func() string { return i.cfg.Reasoning },
@@ -9040,14 +9022,14 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
90409022 if i.autoSubagentsStatusToolAllowed() {
90419023 statusTool := &tools.SubagentStatusTool{
90429024 Supervisor: i.cfg.Supervisor,
9043- Enabled: func() bool { return i.autoSubagentsEnabled() },
9025+ Enabled: func() bool { return true },
90449026 }
90459027 next[statusTool.Name()] = statusTool
90469028 }
90479029 if i.autoSubagentsStopToolAllowed() {
90489030 stopTool := &tools.SubagentStopTool{
90499031 Supervisor: i.cfg.Supervisor,
9050- Enabled: func() bool { return i.autoSubagentsEnabled() },
9032+ Enabled: func() bool { return true },
90519033 OnStopRequested: i.TrackStoppedSubagentWorker,
90529034 }
90539035 next[stopTool.Name()] = stopTool
@@ -9055,7 +9037,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
90559037 if i.autoSubagentsResumeToolAllowed() {
90569038 resumeTool := &tools.SubagentResumeTool{
90579039 Supervisor: i.cfg.Supervisor,
9058- Enabled: func() bool { return i.autoSubagentsEnabled() },
9040+ Enabled: func() bool { return true },
90599041 OnResumed: i.TrackResumedSubagentWorker,
90609042 }
90619043 next[resumeTool.Name()] = resumeTool
M packages/agent/modes/interactive_render_test.go +28 -0
@@ -65,6 +65,34 @@ func TestLatestFrameSchedulerKeepsNewestRequest(t *testing.T) {
6565 }
6666 }
6767
68+func TestToolEventBurstUsesThrottledInvalidationPath(t *testing.T) {
69+ scheduler := newLatestFrameScheduler()
70+ i := &Interactive{
71+ dirty: make(chan struct{}, 1),
72+ toolCalls: make(map[string]*tui.ToolCallView),
73+ toolGate: make(map[string]int),
74+ }
75+ i.renderScheduler.Store(scheduler)
76+
77+ i.handleEventForPresentation(core.EvToolUseStart{ID: "call", Name: "bash"})
78+ i.handleEventForPresentation(core.EvToolUseArgs{ID: "call", Delta: `{"command":"pwd"}`})
79+ i.handleEventForPresentation(core.EvToolUseEnd{ID: "call"})
80+
81+ if got := len(scheduler.wake); got != 0 {
82+ t.Fatalf("tool events bypassed the main-loop redraw throttle: %d direct request", got)
83+ }
84+ select {
85+ case <-i.dirty:
86+ default:
87+ t.Fatal("tool events did not wake the throttled redraw path")
88+ }
89+ select {
90+ case <-i.dirty:
91+ t.Fatal("tool event burst queued more than one throttled redraw")
92+ default:
93+ }
94+}
95+
6896 func TestStableChatCacheTracksViewInvalidation(t *testing.T) {
6997 i := &Interactive{
7098 view: &tui.View{
M packages/agent/modes/session_dialog_test.go +8 -6
@@ -13,6 +13,8 @@ import (
1313 "github.com/bnema/zut/packages/tui"
1414 )
1515
16+const sessionDialogTestTimeout = 10 * time.Second
17+
1618 func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
1719 row := formatSessionRowPlain(core.SessionSummary{
1820 Provider: "test\x1b]0;bad\a",
@@ -149,12 +151,12 @@ func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
149151 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
150152 }
151153 d.ApplyLoad(event)
152- case <-time.After(time.Second):
154+ case <-time.After(sessionDialogTestTimeout):
153155 t.Fatal("timed out waiting for sessionLoadStarted")
154156 }
155157 select {
156158 case <-entered:
157- case <-time.After(time.Second):
159+ case <-time.After(sessionDialogTestTimeout):
158160 t.Fatal("timed out waiting for the session worker")
159161 }
160162
@@ -173,7 +175,7 @@ func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
173175 t.Fatal("canceled load emitted sessionLoadFinished")
174176 }
175177 d.ApplyLoad(event)
176- case <-time.After(time.Second):
178+ case <-time.After(sessionDialogTestTimeout):
177179 t.Fatal("timed out waiting for canceled session load to close")
178180 }
179181 }
@@ -221,12 +223,12 @@ func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T)
221223 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
222224 }
223225 d.ApplyLoad(event)
224- case <-time.After(time.Second):
226+ case <-time.After(sessionDialogTestTimeout):
225227 t.Fatal("timed out waiting for the first load to start")
226228 }
227229 select {
228230 case <-entered:
229- case <-time.After(time.Second):
231+ case <-time.After(sessionDialogTestTimeout):
230232 t.Fatal("timed out waiting for the first worker")
231233 }
232234
@@ -253,7 +255,7 @@ func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T)
253255 t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
254256 }
255257 d.ApplyLoad(event)
256- case <-time.After(time.Second):
258+ case <-time.After(sessionDialogTestTimeout):
257259 t.Fatal("timed out waiting for third load after releasing the first")
258260 }
259261 cancelThird()
M packages/agent/modes/subagents_slash_test.go +13 -13
@@ -290,20 +290,20 @@ func TestRunSupervisorWaitReportsCompletionBeforeCancellation(t *testing.T) {
290290 if err != nil {
291291 t.Fatal(err)
292292 }
293+ watcherDone := make(chan struct{})
294+ iv.subagentsWaitWatcherDone = func() { close(watcherDone) }
293295 iv.runSubagents(context.Background(), []string{"wait", a.ID})
294- deadline := time.After(time.Second)
295- for {
296- iv.mu.Lock()
297- status := iv.statusOK
298- iv.mu.Unlock()
299- if status == "completed "+a.ID {
300- return
301- }
302- select {
303- case <-deadline:
304- t.Fatalf("wait did not report completion; status = %q", status)
305- case <-time.After(time.Millisecond):
306- }
296+ select {
297+ case <-watcherDone:
298+ case <-time.After(5 * time.Second):
299+ t.Fatal("timed out waiting for wait watcher to report completion")
300+ }
301+
302+ iv.mu.Lock()
303+ status := iv.statusOK
304+ iv.mu.Unlock()
305+ if status != "completed "+a.ID {
306+ t.Fatalf("wait completion status = %q; want %q", status, "completed "+a.ID)
307307 }
308308 }
309309
M packages/agent/settings_store.go +8 -5
@@ -252,10 +252,9 @@ func (configSettingsStore) SetTheme(name string) error {
252252 return SaveConfig(cfg)
253253 }
254254
255-// AutoSubagentsEnabled reads the current auto-subagents flag from config.
256-// The build/CLI startup path uses it when initially injecting
257-// the auto-subagent tools into the tool registry; interactive mode mirrors
258-// the setting into the live tool registry when its settings toggle changes.
255+// AutoSubagentsEnabled reads whether the interactive primary agent runs in
256+// strict orchestrator mode. The canonical subagent tools remain available for
257+// user-requested delegation and skill-mandated workflows when disabled.
259258 func AutoSubagentsEnabled() bool {
260259 cfg, err := LoadConfig()
261260 if err != nil {
@@ -266,7 +265,7 @@ func AutoSubagentsEnabled() bool {
266265
267266 // AutoSubagentsSystemAddendum is appended to the system prompt when
268267 // auto-subagents is enabled. It makes the interactive primary agent an
269-// orchestrator while preserving the normal prompt and tools when disabled.
268+// orchestrator.
270269 const AutoSubagentsSystemAddendum = `Auto-subagents are enabled. You are the primary-agent orchestrator, not an implementer.
271270
272271 Delegate all implementation, debugging/testing, and code-review work to an appropriately named subagent profile, or to a clearly described general worker when no profile fits. Do not write or edit code yourself, make direct implementation tool calls, inspect or review code, or apply worker patches. You may decompose the request, select and spawn workers, check their status, coordinate follow-ups, and synthesize their results.
@@ -277,6 +276,10 @@ Completion is host-event-driven. After spawning a worker, never use "bash sleep"
277276
278277 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat the [auto-subagents update] message as observed worker state, not as a new user request.`
279278
279+// OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
280+// available without enabling proactive primary-agent orchestration.
281+const OnDemandSubagentsSystemAddendum = `Subagent tools are available when the user asks you to delegate or an active skill workflow requires delegation. Use them only in those cases. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
282+
280283 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
281284 // orchestrator contract still applies when launch-time policy withholds the
282285 // spawn tool.
M packages/agent/subagent_worker.go +161 -48
@@ -36,6 +36,56 @@ import (
3636 // production; tests use the stubchild binary under
3737 // packages/agent/subagents/testdata/cmd/stubchild instead of the real model
3838 // loop.
39+// workerTurnBudget is deliberately independent of the model loop: one
40+// admitted message turn consumes one current-run and lifetime turn, while
41+// retries, compaction, and provider/tool loops remain inside that turn.
42+type workerTurnBudget struct {
43+ sequence int
44+ lifetime int
45+ current int
46+}
47+
48+type sessionPersistenceState struct {
49+ mu sync.Mutex
50+ errValue error
51+}
52+
53+func (s *sessionPersistenceState) record(err error) {
54+ if err == nil {
55+ return
56+ }
57+ s.mu.Lock()
58+ if s.errValue == nil {
59+ s.errValue = err
60+ }
61+ s.mu.Unlock()
62+}
63+
64+func (s *sessionPersistenceState) failed() bool {
65+ s.mu.Lock()
66+ defer s.mu.Unlock()
67+ return s.errValue != nil
68+}
69+
70+func (s *sessionPersistenceState) err() error {
71+ s.mu.Lock()
72+ defer s.mu.Unlock()
73+ return s.errValue
74+}
75+
76+func (b *workerTurnBudget) start(maxTurns int, newRun bool) (step, lifetime, current int, admitted bool) {
77+ if newRun {
78+ b.current = 0
79+ }
80+ b.sequence++
81+ if maxTurns > 0 && b.current >= maxTurns {
82+ return b.sequence, b.lifetime, b.current, false
83+ }
84+ b.lifetime++
85+ b.current++
86+ return b.sequence, b.lifetime, b.current, true
87+}
88+
3989 func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runErr error) {
4090 if args.SubagentWorker == "" {
4191 return fmt.Errorf("--subagent-worker requires a socket path")
@@ -78,6 +128,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
78128 if err != nil {
79129 return err
80130 }
131+ var sessionPersistence sessionPersistenceState
81132 if sess != nil {
82133 var providerName, model string
83134 sess, ag, providerName, model, err = applyInitialSessionResume(ctx, args, r, extMgr, sess, ag)
@@ -86,6 +137,23 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
86137 }
87138 r.Provider, r.Model = providerName, model
88139 ag.OnToolResult = func(_ string, result core.ToolResult) { persistExtensionToolResult(extMgr, sess, result) }
140+ // A prompt is acknowledged by the supervisor only after this callback
141+ // has appended and synced the matching user message. This closes the
142+ // crash window between accepting a follow-up and writing session.json.
143+ ag.OnMessageAppended = func(message provider.Message) {
144+ if err := sess.AppendMessage(message); err != nil {
145+ sessionPersistence.record(err)
146+ return
147+ }
148+ if err := sess.Sync(); err != nil {
149+ sessionPersistence.record(err)
150+ }
151+ }
152+ ag.OnUsage = func(usage provider.Usage) {
153+ if err := sess.AppendUsage(usage, usage); err != nil {
154+ sessionPersistence.record(err)
155+ }
156+ }
89157 defer func() { runErr = joinSessionCloseError(runErr, sess) }()
90158 }
91159 announceSession(extMgr, sess)
@@ -122,9 +190,12 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
122190 em := newSubagentEmitter(os.Stdout, logMirror)
123191 em.setProtocolIdentity(os.Getenv("ZUT_SUBAGENT_AGENT_ID"))
124192 em.emit("agent.ready", map[string]any{
125- "version": version,
126- "cwd": r.CWD,
127- "model": r.Model,
193+ "version": version,
194+ "cwd": r.CWD,
195+ "model": r.Model,
196+ "lifetime_turns": args.SubagentLifetimeTurns,
197+ "current_run_turns": args.SubagentRunTurns,
198+ "max_turns": args.SubagentMaxTurns,
128199 })
129200
130201 // Heartbeats make a live-but-detached child distinguishable from a
@@ -154,63 +225,82 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
154225 // Keep a per-turn cancel so the "cancel" inbox message can interrupt
155226 // an in-flight turn without tearing down the whole daemon.
156227 var (
157- mu sync.Mutex
158- cancelFn context.CancelFunc
159- busyTurn bool
160- turnNo = initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG"))
228+ mu sync.Mutex
229+ cancelFn context.CancelFunc
230+ busyTurn bool
231+ budget = workerTurnBudget{
232+ sequence: initialTurnNumber(os.Getenv("ZUT_SUBAGENT_EVENT_LOG")),
233+ lifetime: args.SubagentLifetimeTurns,
234+ current: args.SubagentRunTurns,
235+ }
161236 turnWG sync.WaitGroup
162237 closing bool
238+ turnPending bool
239+ seenCommands = make(map[string]struct{})
163240 shutdown = make(chan struct{})
164241 shutdownOnce sync.Once
165242 )
166243 maxOutputBytes, maxOutputLines := workerOutputLimits()
167244
168- runOne := func(prompt string) {
245+ runOne := func(prompt string, newRun bool, commandID string) {
169246 mu.Lock()
170247 // launchTurn admits the goroutine to turnWG before it starts so
171248 // shutdown can join it. A shutdown can win the scheduling race
172249 // after that admission but before this goroutine acquires mu; in
173250 // that case do not start a new Prompt after waitForActiveTurn has
174251 // declared the worker closing.
175- if closing {
252+ if closing || busyTurn {
176253 mu.Unlock()
254+ em.emit("error", map[string]any{"code": "turn_rejected", "reason": "busy", "command_id": commandID, "message": "worker is busy"})
177255 return
178256 }
179- if args.SubagentMaxTurns > 0 && turnNo >= args.SubagentMaxTurns {
180- // Consume a unique rejected attempt so repeated prompts after
181- // the limit cannot reuse the same turn id in the event log.
182- turnNo++
183- step := turnNo
257+ busyTurn = true
258+ turnPending = false
259+ step, lifetime, currentRun, admitted := budget.start(args.SubagentMaxTurns, newRun)
260+ if !admitted {
261+ busyTurn = false
184262 mu.Unlock()
185263 turnID := fmt.Sprintf("turn-%d", step)
186- errPayload := map[string]any{"code": "max_turns", "message": "maximum subagent turns reached"}
264+ errPayload := map[string]any{"code": "turn_rejected", "reason": "max_turns", "command_id": commandID, "message": "maximum subagent turns reached"}
265+ em.emit("error", errPayload)
187266 em.emit("turn.result", map[string]any{"status": "failed", "turn_id": turnID, "error": errPayload})
188267 em.emit("turn.failed", map[string]any{"turn_id": turnID, "error": errPayload})
189268 em.emit("turn_end", map[string]any{"step": step, "turn_id": turnID, "error": errPayload["message"]})
190- em.emit("agent.idle", map[string]any{"turn_id": turnID})
191- return
192- }
193- if busyTurn {
194- // Drop concurrent turns rather than queuing. The
195- // supervisor protocol assumes one outstanding turn per
196- // agent; if a user really wants to interrupt and start
197- // another, they should send "cancel" first.
198- mu.Unlock()
199- em.emit("error", map[string]any{"message": "agent busy; send 'cancel' first"})
269+ // Max-turn rejection ends the current run, but the worker remains
270+ // alive and can accept a queued follow-up as a fresh run.
271+ em.emit("agent.idle", map[string]any{
272+ "turn_id": turnID,
273+ "lifetime_turns": lifetime,
274+ "current_run_turns": currentRun,
275+ })
200276 return
201277 }
202- busyTurn = true
203- turnNo++
204- step := turnNo
278+ turnID := fmt.Sprintf("turn-%d", step)
205279 c, cancel := context.WithCancel(ctx)
206280 cancelFn = cancel
207281 mu.Unlock()
208282
209- turnID := fmt.Sprintf("turn-%d", step)
210- em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID})
211-
283+ turnStarted := false
212284 sink := func(ev core.AgentEvent) {
213- em.emit(ev.Type(), subagentEventData(ev))
285+ data := subagentEventData(ev)
286+ if ev.Type() == "user_message" && !turnStarted {
287+ // Prompt invokes OnMessageAppended before delivering this sink
288+ // event, so the session row is durable before the lifecycle
289+ // acknowledgement is written.
290+ em.emit(ev.Type(), data)
291+ if !sessionPersistence.failed() {
292+ em.emit("turn.started", map[string]any{"step": step, "turn_id": turnID, "command_id": commandID, "lifetime_turns": lifetime, "current_run_turns": currentRun})
293+ turnStarted = true
294+ }
295+ return
296+ }
297+ if ev.Type() == "turn_start" {
298+ // A provider/model-loop turn is nested inside the one delegated
299+ // message turn admitted above. Mark it on the wire so the
300+ // supervisor cannot mistake it for a new worker turn.
301+ data["nested_turn"] = true
302+ }
303+ em.emit(ev.Type(), data)
214304 }
215305
216306 baseline := workerTranscriptState{
@@ -232,13 +322,21 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
232322 AutoCompactThreshold: cfg.AutoCompactThreshold,
233323 })
234324 output := finalAssistantText(ag.Messages()[recovery.OutputStart:])
235- if persistErr := persistWorkerTranscript(c, ag, sess, recovery.OutputStart, recovery.Compacted, baseline); persistErr != nil {
236- if err != nil {
237- err = errors.Join(err, persistErr)
238- } else {
239- err = persistErr
325+ // Message and usage callbacks persist ordinary turns incrementally. A
326+ // compacted turn still needs one atomic replacement checkpoint so the
327+ // durable transcript cannot mix generations.
328+ if recovery.Compacted || ag.OnMessageAppended == nil {
329+ if persistErr := persistWorkerTranscript(c, ag, sess, recovery.OutputStart, recovery.Compacted, baseline); persistErr != nil {
330+ if err != nil {
331+ err = errors.Join(err, persistErr)
332+ } else {
333+ err = persistErr
334+ }
240335 }
241336 }
337+ if sessionErr := sessionPersistence.err(); err == nil && sessionErr != nil {
338+ err = sessionErr
339+ }
242340
243341 resultStatus := "succeeded"
244342 if err != nil {
@@ -273,24 +371,26 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
273371 // The supervisor treats agent.idle as permission to start a follow-up.
274372 // Publish it only after the busy gate has opened, otherwise a manager
275373 // can send a turn that the inbox loop drops as still busy.
276- em.emit("agent.idle", map[string]any{"turn_id": turnID})
374+ em.emit("agent.idle", map[string]any{"turn_id": turnID, "lifetime_turns": lifetime, "current_run_turns": currentRun})
277375 }
278376
279- launchTurn := func(prompt string) {
377+ launchTurn := func(prompt string, newRun bool, commandID string) bool {
280378 mu.Lock()
281- if closing {
379+ if closing || busyTurn || turnPending {
282380 mu.Unlock()
283- return
381+ em.emit("error", map[string]any{"code": "turn_rejected", "reason": "busy", "command_id": commandID, "message": "worker is busy"})
382+ return false
284383 }
285- // Register the goroutine before launching it. Shutdown can then
286- // close the gate and wait for every turn that was admitted, even
287- // if runOne has not acquired mu and set busyTurn yet.
384+ // Reserve the worker before launching the goroutine. A second command
385+ // cannot be accepted in the gap before runOne acquires mu.
386+ turnPending = true
288387 turnWG.Add(1)
289388 mu.Unlock()
290389 go func() {
291390 defer turnWG.Done()
292- runOne(prompt)
391+ runOne(prompt, newRun, commandID)
293392 }()
393+ return true
294394 }
295395
296396 waitForActiveTurn := func(cancel bool) {
@@ -315,7 +415,7 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
315415 // "starts working" the moment it boots, matching what users
316416 // expect from `/subagents new <task>`.
317417 if args.Prompt != "" {
318- launchTurn(args.Prompt)
418+ launchTurn(args.Prompt, false, "")
319419 }
320420
321421 // Inbox loop: one supervisor message at a time. We don't spawn
@@ -361,10 +461,20 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) (runE
361461 case subagents.CommandTurnStart:
362462 var payload subagents.TurnStartPayload
363463 if err := command.DecodePayload(&payload); err != nil {
364- em.emit("error", map[string]any{"message": "invalid turn.start payload"})
464+ em.emit("error", map[string]any{"code": "turn_rejected", "reason": "invalid_payload", "command_id": command.MessageID, "message": "invalid turn.start payload"})
365465 continue
366466 }
367- launchTurn(payload.Prompt)
467+ mu.Lock()
468+ _, duplicate := seenCommands[command.MessageID]
469+ mu.Unlock()
470+ if duplicate && command.MessageID != "" {
471+ continue
472+ }
473+ if launchTurn(payload.Prompt, payload.NewRun, command.MessageID) && command.MessageID != "" {
474+ mu.Lock()
475+ seenCommands[command.MessageID] = struct{}{}
476+ mu.Unlock()
477+ }
368478 default:
369479 // Unknown commands are rejected rather than interpreted as
370480 // child-originated control requests.
@@ -625,6 +735,9 @@ func initialTurnNumber(path string) int {
625735 }
626736 maxStep := 0
627737 for _, ev := range events {
738+ if !subagents.IsDelegatedTurnStart(ev) {
739+ continue
740+ }
628741 if step, ok := ev.Data["step"].(float64); ok && int(step) > maxStep {
629742 maxStep = int(step)
630743 }
M packages/agent/subagent_worker_test.go +46 -0
@@ -857,3 +857,49 @@ func TestSupervisorEmitterStdoutShapeMatchesSupervisorParser(t *testing.T) {
857857 t.Errorf("payload step field missing or wrong: %v", object["payload"])
858858 }
859859 }
860+
861+func TestInitialTurnNumberIgnoresNestedProviderTurns(t *testing.T) {
862+ path := filepath.Join(t.TempDir(), "events.jsonl")
863+ log, err := subagents.OpenEventLog(path)
864+ if err != nil {
865+ t.Fatal(err)
866+ }
867+ if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
868+ "step": float64(1), "lifetime_turns": 1, "current_run_turns": 1,
869+ })); err != nil {
870+ t.Fatal(err)
871+ }
872+ if err := log.Append(subagents.NewEvent(subagents.EventTurnStarted, map[string]any{
873+ "step": float64(99), "nested_turn": true,
874+ })); err != nil {
875+ t.Fatal(err)
876+ }
877+ if err := log.Close(); err != nil {
878+ t.Fatal(err)
879+ }
880+ if got := initialTurnNumber(path); got != 1 {
881+ t.Fatalf("initial turn number = %d, want 1", got)
882+ }
883+}
884+
885+func TestWorkerTurnBudgetFreshAllowanceOnlyForExplicitRun(t *testing.T) {
886+ var budget workerTurnBudget
887+
888+ if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 1 || current != 1 {
889+ t.Fatalf("first turn = (%d, %d, %v), want (1, 1, true)", lifetime, current, admitted)
890+ }
891+ // Provider retries, model loops, and compaction stay inside the admitted
892+ // turn and therefore do not call start or consume another allowance.
893+ if budget.lifetime != 1 || budget.current != 1 {
894+ t.Fatalf("nested work changed budget to (lifetime=%d, current=%d)", budget.lifetime, budget.current)
895+ }
896+ if _, lifetime, current, admitted := budget.start(2, false); !admitted || lifetime != 2 || current != 2 {
897+ t.Fatalf("second turn = (%d, %d, %v), want (2, 2, true)", lifetime, current, admitted)
898+ }
899+ if _, lifetime, current, admitted := budget.start(2, false); admitted || lifetime != 2 || current != 2 {
900+ t.Fatalf("exhausted turn = (%d, %d, %v), want (2, 2, false)", lifetime, current, admitted)
901+ }
902+ if _, lifetime, current, admitted := budget.start(2, true); !admitted || lifetime != 3 || current != 1 {
903+ t.Fatalf("explicit resumed turn = (%d, %d, %v), want (3, 1, true)", lifetime, current, admitted)
904+ }
905+}
A packages/agent/subagents/ack_test.go +65 -0
@@ -0,0 +1,65 @@
1+package subagents
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestResumePromptAcknowledgementRequiresDurableUserMessageAndTurnStart(t *testing.T) {
9+ acceptedAt := time.Now().Add(-time.Second)
10+ prompt := "follow-up prompt"
11+ user := NewEvent("user_message", map[string]any{
12+ "content": []any{map[string]any{"type": "text", "text": prompt}},
13+ })
14+ started := NewEvent(EventTurnStarted, map[string]any{
15+ "lifetime_turns": 2,
16+ "current_run_turns": 1,
17+ })
18+
19+ if resumePromptAcknowledged([]Event{started}, prompt, acceptedAt) {
20+ t.Fatal("turn.started alone acknowledged a pending prompt")
21+ }
22+ if resumePromptAcknowledged([]Event{user}, prompt, acceptedAt) {
23+ t.Fatal("crash point after durable user message acknowledged before turn.started")
24+ }
25+ if !resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
26+ t.Fatal("matching durable user message followed by delegated turn.started was not acknowledged")
27+ }
28+}
29+
30+func TestResumePromptAcknowledgementIgnoresNestedTurnStart(t *testing.T) {
31+ acceptedAt := time.Now().Add(-time.Second)
32+ prompt := "follow-up prompt"
33+ user := NewEvent("user_message", map[string]any{
34+ "content": []any{map[string]any{"type": "text", "text": prompt}},
35+ })
36+ nested := NewEvent(EventTurnStarted, map[string]any{"nested_turn": true})
37+ if resumePromptAcknowledged([]Event{user, nested}, prompt, acceptedAt) {
38+ t.Fatal("nested turn.started acknowledged a pending delegated prompt")
39+ }
40+}
41+
42+func TestResumePromptAcknowledgementIgnoresStaleMatchingUserMessage(t *testing.T) {
43+ acceptedAt := time.Now()
44+ prompt := "follow-up prompt"
45+ user := NewEvent("user_message", map[string]any{
46+ "content": []any{map[string]any{"type": "text", "text": prompt}},
47+ })
48+ user.Time = acceptedAt.Add(-time.Millisecond)
49+ started := NewEvent(EventTurnStarted, map[string]any{"lifetime_turns": 2, "current_run_turns": 1})
50+ if resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
51+ t.Fatal("stale matching user message acknowledged a pending prompt")
52+ }
53+}
54+
55+func TestResumePromptAcknowledgementIgnoresAmbiguousUserMessage(t *testing.T) {
56+ acceptedAt := time.Now().Add(-time.Second)
57+ prompt := "follow-up prompt"
58+ user := NewEvent("user_message", map[string]any{
59+ "content": []any{map[string]any{"type": "text", "text": "older prompt"}},
60+ })
61+ started := NewEvent(EventTurnStarted, map[string]any{"lifetime_turns": 2, "current_run_turns": 1})
62+ if resumePromptAcknowledged([]Event{user, started}, prompt, acceptedAt) {
63+ t.Fatal("ambiguous user message acknowledged a pending prompt")
64+ }
65+}
M packages/agent/subagents/agent.go +377 -43
@@ -7,6 +7,8 @@ import (
77 "strings"
88 "sync"
99 "time"
10+
11+ "github.com/google/uuid"
1012 )
1113
1214 // Agent is one supervised task. Public fields are immutable after
@@ -17,6 +19,18 @@ type turnEndNotice struct {
1719 errMsg string
1820 }
1921
22+type queuedResumePrompt struct {
23+ Prompt string `json:"prompt"`
24+ AcceptedAt time.Time `json:"accepted_at"`
25+ CommandID string `json:"command_id,omitempty"`
26+}
27+
28+type resumePromptSnapshot struct {
29+ Prompt string
30+ AcceptedAt time.Time
31+ CommandID string
32+}
33+
2034 type Agent struct {
2135 ID string
2236 Task string
@@ -36,6 +50,14 @@ type Agent struct {
3650 WorkspaceBase string
3751 WorkspaceCapture CaptureMode
3852 MaxTurns int
53+
54+ // LifetimeTurns counts every accepted message turn for this worker across
55+ // all explicit runs. CurrentRunTurns resets only when an explicit
56+ // subagent_resume follow-up starts a new run; retries, model loops,
57+ // compaction, and cancellation do not reset it.
58+ LifetimeTurns int
59+ CurrentRunTurns int
60+
3961 // Timeout is the effective per-agent lifetime, persisted so a resumed
4062 // worker keeps the timeout selected at its spawn boundary.
4163 Timeout time.Duration
@@ -80,12 +102,15 @@ type Agent struct {
80102 // Not persisted — every Resume sets it explicitly.
81103 Resuming bool
82104
83- // resumePromptText is an optional manager follow-up passed to a resumed
84- // child as its initial prompt. It remains in durable metadata until the
85- // child reports turn.started, so a host exit while the worker is queued
86- // cannot lose it. Access it through ResumePromptInfo.
87- resumePromptText string
88- resumePromptAt time.Time
105+ // resumePromptText is the manager follow-up currently handed to the
106+ // child. It remains in durable metadata until the child reports
107+ // turn.started, so a host exit while the worker is queued cannot lose it.
108+ // Additional follow-ups accepted while the child is busy stay in
109+ // resumePromptQueue until the next agent idle turn.
110+ resumePromptText string
111+ resumePromptAt time.Time
112+ resumePromptID string
113+ resumePromptQueue []queuedResumePrompt
89114
90115 // InboxPath is the unix socket the child agent listens on for
91116 // follow-up prompts and control messages. The supervisor opens
@@ -116,16 +141,20 @@ type Agent struct {
116141 // Supervisor.Spawn so callers do not need to manage the socket directly.
117142 inbox *Inbox
118143
119- mu sync.Mutex
120- status Status
121- activity string
122- transcript []string
123- lastAssistant string
124- finished time.Time
125- lastErr error
126- transcriptLoaded bool
127- legacyEventState bool
128- transcriptMu sync.Mutex
144+ mu sync.Mutex
145+ status Status
146+ activity string
147+ transcript []string
148+ lastAssistant string
149+ streamingAssistantText string
150+ streamingAssistantTruncated bool
151+ streamingAssistantStart int
152+ streamingAssistantLines int
153+ finished time.Time
154+ lastErr error
155+ transcriptLoaded bool
156+ legacyEventState bool
157+ transcriptMu sync.Mutex
129158
130159 lifecycleMu sync.Mutex
131160 processState ProcessState
@@ -142,10 +171,12 @@ type Agent struct {
142171 outputTruncated bool
143172 maxOutputBytes int
144173 maxOutputLines int
145- persistFn func(*Agent)
174+ persistFn func(*Agent) error
175+ persistMu sync.Mutex
146176 stateDir string
147177 workspaceCleanup func() error
148178 workspaceCapture func() (WorkspaceCapture, error)
179+ onTurnIdle func()
149180
150181 // OnTurnEnd, if set, fires once per prompt-level turn_end event
151182 // emitted by the subagent daemon wrapper. Provider/tool-loop
@@ -230,6 +261,66 @@ func (a *Agent) AttemptValue() int {
230261 return a.Attempt
231262 }
232263
264+// LifetimeTurnsValue returns the number of accepted message turns across all
265+// explicit runs of this worker.
266+func (a *Agent) LifetimeTurnsValue() int {
267+ a.lifecycleMu.Lock()
268+ defer a.lifecycleMu.Unlock()
269+ return a.LifetimeTurns
270+}
271+
272+// CurrentRunTurnsValue returns the number of accepted message turns in the
273+// current explicit run.
274+func (a *Agent) CurrentRunTurnsValue() int {
275+ a.lifecycleMu.Lock()
276+ defer a.lifecycleMu.Unlock()
277+ return a.CurrentRunTurns
278+}
279+
280+func (a *Agent) setTurnCounts(lifetime, currentRun int) {
281+ if lifetime < 0 {
282+ lifetime = 0
283+ }
284+ if currentRun < 0 {
285+ currentRun = 0
286+ }
287+ a.lifecycleMu.Lock()
288+ a.LifetimeTurns = lifetime
289+ a.CurrentRunTurns = currentRun
290+ a.updatedAt = time.Now()
291+ a.lifecycleMu.Unlock()
292+}
293+
294+func (a *Agent) incrementTurnCounts() (lifetime, currentRun int) {
295+ a.lifecycleMu.Lock()
296+ a.LifetimeTurns++
297+ a.CurrentRunTurns++
298+ a.updatedAt = time.Now()
299+ lifetime = a.LifetimeTurns
300+ currentRun = a.CurrentRunTurns
301+ a.lifecycleMu.Unlock()
302+ return lifetime, currentRun
303+}
304+
305+func (a *Agent) resetCurrentRunTurns() (previous int) {
306+ a.lifecycleMu.Lock()
307+ previous = a.CurrentRunTurns
308+ a.CurrentRunTurns = 0
309+ a.updatedAt = time.Now()
310+ a.lifecycleMu.Unlock()
311+ return previous
312+}
313+
314+func (a *Agent) setCurrentRunTurns(currentRun int) {
315+ if currentRun < 0 {
316+ currentRun = 0
317+ }
318+ a.lifecycleMu.Lock()
319+ a.CurrentRunTurns = currentRun
320+ a.updatedAt = time.Now()
321+ a.lifecycleMu.Unlock()
322+}
323+
233324 // ProcessPIDValue returns the current worker pid, when known.
234325 func (a *Agent) ProcessPIDValue() int {
235326 a.lifecycleMu.Lock()
@@ -400,13 +491,45 @@ func (a *Agent) ResumePromptInfo() (string, time.Time) {
400491 return a.resumePromptText, a.resumePromptAt
401492 }
402493
403-func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) (string, time.Time) {
494+func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) resumePromptSnapshot {
404495 a.mu.Lock()
405496 defer a.mu.Unlock()
406- previousPrompt, previousAcceptedAt := a.resumePromptText, a.resumePromptAt
497+ previous := resumePromptSnapshot{
498+ Prompt: a.resumePromptText,
499+ AcceptedAt: a.resumePromptAt,
500+ CommandID: a.resumePromptID,
501+ }
407502 a.resumePromptText = prompt
408503 a.resumePromptAt = acceptedAt
409- return previousPrompt, previousAcceptedAt
504+ a.resumePromptID = uuid.NewString()
505+ return previous
506+}
507+
508+func (a *Agent) restoreResumePrompt(previous resumePromptSnapshot) {
509+ a.mu.Lock()
510+ defer a.mu.Unlock()
511+ a.resumePromptText = previous.Prompt
512+ a.resumePromptAt = previous.AcceptedAt
513+ a.resumePromptID = previous.CommandID
514+ if a.resumePromptText != "" && a.resumePromptID == "" {
515+ a.resumePromptID = uuid.NewString()
516+ }
517+}
518+
519+func (a *Agent) resumePromptState() resumePromptSnapshot {
520+ a.mu.Lock()
521+ defer a.mu.Unlock()
522+ return resumePromptSnapshot{
523+ Prompt: a.resumePromptText,
524+ AcceptedAt: a.resumePromptAt,
525+ CommandID: a.resumePromptID,
526+ }
527+}
528+
529+func (a *Agent) resumePromptCommandID() string {
530+ a.mu.Lock()
531+ defer a.mu.Unlock()
532+ return a.resumePromptID
410533 }
411534
412535 func (a *Agent) clearResumePrompt() bool {
@@ -417,9 +540,110 @@ func (a *Agent) clearResumePrompt() bool {
417540 }
418541 a.resumePromptText = ""
419542 a.resumePromptAt = time.Time{}
543+ a.resumePromptID = ""
544+ return true
545+}
546+
547+func (a *Agent) queueResumePrompt(prompt string, acceptedAt time.Time) string {
548+ commandID := uuid.NewString()
549+ a.mu.Lock()
550+ a.resumePromptQueue = append(a.resumePromptQueue, queuedResumePrompt{Prompt: prompt, AcceptedAt: acceptedAt, CommandID: commandID})
551+ a.mu.Unlock()
552+ return commandID
553+}
554+
555+func (a *Agent) removeQueuedResumePrompt(commandID string) bool {
556+ if commandID == "" {
557+ return false
558+ }
559+ a.mu.Lock()
560+ defer a.mu.Unlock()
561+ for i := len(a.resumePromptQueue) - 1; i >= 0; i-- {
562+ if a.resumePromptQueue[i].CommandID == commandID {
563+ a.resumePromptQueue = append(a.resumePromptQueue[:i], a.resumePromptQueue[i+1:]...)
564+ return true
565+ }
566+ }
567+ return false
568+}
569+
570+func (a *Agent) resumePromptQueueSnapshot() []queuedResumePrompt {
571+ a.mu.Lock()
572+ defer a.mu.Unlock()
573+ return append([]queuedResumePrompt(nil), a.resumePromptQueue...)
574+}
575+
576+func (a *Agent) hasQueuedResumePrompt() bool {
577+ a.mu.Lock()
578+ defer a.mu.Unlock()
579+ return len(a.resumePromptQueue) != 0
580+}
581+
582+// startQueuedResumePrompt moves the oldest active follow-up into the
583+// in-flight prompt slot. The caller must persist the returned state before
584+// delivering the command so a host exit cannot lose the prompt.
585+func (a *Agent) startQueuedResumePrompt() (string, time.Time, string, bool) {
586+ a.mu.Lock()
587+ defer a.mu.Unlock()
588+ if a.resumePromptText != "" || len(a.resumePromptQueue) == 0 {
589+ return "", time.Time{}, "", false
590+ }
591+ queued := a.resumePromptQueue[0]
592+ a.resumePromptQueue = a.resumePromptQueue[1:]
593+ a.resumePromptText = queued.Prompt
594+ a.resumePromptAt = queued.AcceptedAt
595+ a.resumePromptID = queued.CommandID
596+ if a.resumePromptID == "" {
597+ a.resumePromptID = uuid.NewString()
598+ }
599+ return queued.Prompt, queued.AcceptedAt, a.resumePromptID, true
600+}
601+
602+func (a *Agent) restoreResumePromptToQueue(prompt string, acceptedAt time.Time) bool {
603+ a.mu.Lock()
604+ defer a.mu.Unlock()
605+ if a.resumePromptText != prompt || !a.resumePromptAt.Equal(acceptedAt) {
606+ return false
607+ }
608+ commandID := a.resumePromptID
609+ if commandID == "" {
610+ commandID = uuid.NewString()
611+ }
612+ a.resumePromptText = ""
613+ a.resumePromptAt = time.Time{}
614+ a.resumePromptID = ""
615+ a.resumePromptQueue = append([]queuedResumePrompt{{Prompt: prompt, AcceptedAt: acceptedAt, CommandID: commandID}}, a.resumePromptQueue...)
420616 return true
421617 }
422618
619+func (a *Agent) rejectActiveResumePrompt() bool {
620+ a.mu.Lock()
621+ defer a.mu.Unlock()
622+ if a.resumePromptText == "" {
623+ return false
624+ }
625+ a.resumePromptQueue = append([]queuedResumePrompt{{Prompt: a.resumePromptText, AcceptedAt: a.resumePromptAt, CommandID: a.resumePromptID}}, a.resumePromptQueue...)
626+ a.resumePromptText = ""
627+ a.resumePromptAt = time.Time{}
628+ a.resumePromptID = ""
629+ return true
630+}
631+
632+func (a *Agent) setOnTurnIdle(fn func()) {
633+ a.mu.Lock()
634+ a.onTurnIdle = fn
635+ a.mu.Unlock()
636+}
637+
638+func (a *Agent) notifyTurnIdle() {
639+ a.mu.Lock()
640+ fn := a.onTurnIdle
641+ a.mu.Unlock()
642+ if fn != nil {
643+ fn()
644+ }
645+}
646+
423647 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
424648 a.mu.Lock()
425649 a.OnTurnEnd = fn
@@ -433,6 +657,20 @@ func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
433657 }
434658 }
435659
660+func (a *Agent) recordPersistenceError(err error) {
661+ if a == nil || err == nil {
662+ return
663+ }
664+ a.mu.Lock()
665+ if a.lastErr == nil {
666+ a.lastErr = fmt.Errorf("subagent metadata persistence: %w", err)
667+ }
668+ if a.status != StatusDone && a.status != StatusFailed && a.status != StatusKilled {
669+ a.activity = "metadata error: " + truncate(err.Error(), 120)
670+ }
671+ a.mu.Unlock()
672+}
673+
436674 func (a *Agent) setActivity(msg string) {
437675 a.mu.Lock()
438676 a.activity = strings.TrimSpace(msg)
@@ -441,25 +679,114 @@ func (a *Agent) setActivity(msg string) {
441679 }
442680
443681 func (a *Agent) appendTranscript(chunk string) {
444- a.appendTranscriptLocked(chunk, "", false)
682+ a.appendTranscriptChunk(chunk, "", false)
445683 }
446684
447685 func (a *Agent) appendUserMessage(text string) {
448- a.appendTranscriptLocked(text, "user: ", false)
686+ a.appendTranscriptChunk(text, "user: ", false)
449687 }
450688
451689 func (a *Agent) appendAssistantMessage(text string) {
452- a.appendTranscriptLocked(text, "", true)
690+ if text == "" {
691+ return
692+ }
693+ a.mu.Lock()
694+ if a.streamingAssistantText != "" {
695+ a.replaceStreamingAssistantLocked(text)
696+ a.clearStreamingAssistantLocked()
697+ } else {
698+ a.appendTranscriptLinesLocked(text, "", true)
699+ a.boundTranscriptLocked()
700+ }
701+ a.mu.Unlock()
702+ a.markActivity(time.Now())
703+}
704+
705+// appendAssistantDelta exposes an in-progress assistant response in the
706+// dashboard without duplicating it when the final assistant_message arrives.
707+// The JSONL event log remains the durable source; this maintains its live and
708+// replayed in-memory projection.
709+func (a *Agent) appendAssistantDelta(text string) {
710+ if text == "" {
711+ return
712+ }
713+ a.mu.Lock()
714+ if !a.streamingAssistantTruncated {
715+ value := a.streamingAssistantText + text
716+ byteCap, lineCap := a.outputLimits()
717+ a.streamingAssistantText = boundInlineText(value, byteCap, lineCap)
718+ a.streamingAssistantTruncated = a.streamingAssistantText != value
719+ }
720+ a.replaceStreamingAssistantLocked(a.streamingAssistantText)
721+ a.mu.Unlock()
722+ a.markActivity(time.Now())
723+}
724+
725+func (a *Agent) appendTranscriptChunk(chunk, linePrefix string, assistant bool) {
726+ if strings.TrimRight(chunk, "\n") == "" {
727+ return
728+ }
729+ a.mu.Lock()
730+ a.appendTranscriptLinesLocked(chunk, linePrefix, assistant)
731+ a.boundTranscriptLocked()
732+ a.mu.Unlock()
733+ a.markActivity(time.Now())
453734 }
454735
455-func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool) {
736+func (a *Agent) resetStreamingAssistant() {
737+ a.mu.Lock()
738+ a.clearStreamingAssistantLocked()
739+ a.mu.Unlock()
740+}
741+
742+func (a *Agent) replaceStreamingAssistantLocked(text string) {
743+ a.removeStreamingAssistantLocked()
744+ a.streamingAssistantStart = len(a.transcript)
745+ a.streamingAssistantLines = a.appendTranscriptLinesLocked(text, "", true)
746+ a.boundTranscriptLocked()
747+}
748+
749+func (a *Agent) removeStreamingAssistantLocked() {
750+ if a.streamingAssistantLines == 0 {
751+ return
752+ }
753+ start := a.streamingAssistantStart
754+ if start < 0 {
755+ start = 0
756+ }
757+ if start > len(a.transcript) {
758+ start = len(a.transcript)
759+ }
760+ end := start + a.streamingAssistantLines
761+ if end > len(a.transcript) {
762+ end = len(a.transcript)
763+ }
764+ for _, line := range a.transcript[start:end] {
765+ a.outputBytes -= len(line) + 1
766+ a.outputLines--
767+ }
768+ a.transcript = append(a.transcript[:start], a.transcript[end:]...)
769+ a.streamingAssistantLines = 0
770+ if a.outputLines == 0 {
771+ a.outputBytes = 0
772+ }
773+}
774+
775+func (a *Agent) clearStreamingAssistantLocked() {
776+ a.streamingAssistantText = ""
777+ a.streamingAssistantTruncated = false
778+ a.streamingAssistantStart = 0
779+ a.streamingAssistantLines = 0
780+}
781+
782+func (a *Agent) appendTranscriptLinesLocked(chunk, linePrefix string, assistant bool) int {
456783 message := chunk
457784 chunk = strings.TrimRight(chunk, "\n")
458785 if chunk == "" {
459- return
786+ return 0
460787 }
461- a.mu.Lock()
462- for _, line := range strings.Split(chunk, "\n") {
788+ lines := strings.Split(chunk, "\n")
789+ for _, line := range lines {
463790 line = linePrefix + line
464791 a.transcript = append(a.transcript, line)
465792 a.outputBytes += len(line) + 1
@@ -468,35 +795,42 @@ func (a *Agent) appendTranscriptLocked(chunk, linePrefix string, assistant bool)
468795 if assistant {
469796 a.lastAssistant = boundInlineText(message, a.maxOutputBytes, a.maxOutputLines)
470797 }
471- // Bound the inline dashboard copy. The durable event log and session
472- // remain available through the logical history reference.
473- lineCap := a.maxOutputLines
474- if lineCap <= 0 {
475- lineCap = 2_000
476- }
477- byteCap := a.maxOutputBytes
798+ return len(lines)
799+}
800+
801+func (a *Agent) outputLimits() (byteCap, lineCap int) {
802+ byteCap = a.maxOutputBytes
478803 if byteCap <= 0 {
479804 byteCap = 500_000
480805 }
481- for a.outputLines > lineCap {
482- line := a.transcript[0]
483- a.transcript = a.transcript[1:]
484- a.outputBytes -= len(line) + 1
485- a.outputLines--
486- a.outputTruncated = true
806+ lineCap = a.maxOutputLines
807+ if lineCap <= 0 {
808+ lineCap = 2_000
487809 }
488- for a.outputLines > 0 && a.outputBytes > byteCap {
810+ return byteCap, lineCap
811+}
812+
813+func (a *Agent) boundTranscriptLocked() {
814+ // Bound the inline dashboard copy. The durable event log and session
815+ // remain available through the logical history reference.
816+ byteCap, lineCap := a.outputLimits()
817+ for a.outputLines > lineCap || (a.outputLines > 0 && a.outputBytes > byteCap) {
489818 line := a.transcript[0]
490819 a.transcript = a.transcript[1:]
491820 a.outputBytes -= len(line) + 1
492821 a.outputLines--
822+ if a.streamingAssistantLines > 0 {
823+ if a.streamingAssistantStart > 0 {
824+ a.streamingAssistantStart--
825+ } else {
826+ a.streamingAssistantLines--
827+ }
828+ }
493829 a.outputTruncated = true
494830 }
495831 if a.outputLines == 0 {
496832 a.outputBytes = 0
497833 }
498- a.mu.Unlock()
499- a.markActivity(time.Now())
500834 }
501835
502836 // newAgentID returns a short, mostly-collision-free identifier of the
M packages/agent/subagents/agent_test.go +31 -1
@@ -1,6 +1,9 @@
11 package subagents
22
3-import "testing"
3+import (
4+ "sync"
5+ "testing"
6+)
47
58 func TestAgentAttemptValueAndTranscriptAccounting(t *testing.T) {
69 a := &Agent{maxOutputBytes: 6, maxOutputLines: 10}
@@ -47,3 +50,30 @@ func TestAgentTranscriptLineCapAccounting(t *testing.T) {
4750 t.Fatalf("line-cap accounting = (%d bytes, %d lines), want (10, 2)", a.outputBytes, a.outputLines)
4851 }
4952 }
53+
54+func TestAgentSnapshotConcurrentTranscriptTruncation(t *testing.T) {
55+ a := &Agent{maxOutputBytes: 8, maxOutputLines: 1}
56+ start := make(chan struct{})
57+ var wg sync.WaitGroup
58+ wg.Add(2)
59+ go func() {
60+ defer wg.Done()
61+ <-start
62+ for range 10_000 {
63+ a.appendTranscript("overflow")
64+ }
65+ }()
66+ go func() {
67+ defer wg.Done()
68+ <-start
69+ for range 10_000 {
70+ _ = a.Snapshot()
71+ }
72+ }()
73+ close(start)
74+ wg.Wait()
75+
76+ if !a.Snapshot().OutputTruncated {
77+ t.Fatal("snapshot did not report transcript truncation")
78+ }
79+}
M packages/agent/subagents/inbox.go +33 -12
@@ -112,34 +112,49 @@ func (b *Inbox) sendBytesContext(ctx context.Context, data []byte) error {
112112 }
113113 defer conn.SetWriteDeadline(time.Time{})
114114 }
115- if _, err := conn.Write(data); err != nil {
116- // Drop the connection so the next call redials. The previous error is
117- // more informative than the redial's would be, so surface this one.
115+ n, writeErr := conn.Write(data)
116+ if writeErr != nil {
117+ // Drop the connection so the next call redials. Once any bytes have
118+ // crossed the socket boundary, the outcome is ambiguous; returning the
119+ // caller's context error would invite a duplicate command with a new
120+ // identity.
118121 stopCancel()
119- b.mu.Lock()
120- if b.conn == conn {
121- _ = b.conn.Close()
122- b.conn = nil
122+ b.dropConnection(conn)
123+ if n > 0 {
124+ return fmt.Errorf("%w: wrote %d of %d bytes: %v", ErrDeliveryUnknown, n, len(data), writeErr)
123125 }
124- b.mu.Unlock()
125126 if ctxErr := ctx.Err(); ctxErr != nil {
126127 return ctxErr
127128 }
128- return err
129+ return writeErr
130+ }
131+ if n != len(data) {
132+ stopCancel()
133+ b.dropConnection(conn)
134+ return fmt.Errorf("%w: wrote %d of %d bytes", ErrDeliveryUnknown, n, len(data))
129135 }
130136 if !stopCancel() {
131- // Cancellation closed this connection after the write completed. Drop it
132- // so the next command redials instead of reusing a closed stream.
137+ // Cancellation closed this connection after the complete command was
138+ // written. The worker can parse the newline-delimited command, so the
139+ // delivery succeeded even though the context is now canceled.
133140 b.mu.Lock()
134141 if b.conn == conn {
135142 b.conn = nil
136143 }
137144 b.mu.Unlock()
138- return ctx.Err()
139145 }
140146 return nil
141147 }
142148
149+func (b *Inbox) dropConnection(conn net.Conn) {
150+ b.mu.Lock()
151+ defer b.mu.Unlock()
152+ if b.conn == conn {
153+ _ = b.conn.Close()
154+ b.conn = nil
155+ }
156+}
157+
143158 func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) {
144159 b.mu.Lock()
145160 if b.send == nil {
@@ -179,6 +194,12 @@ func (b *Inbox) Close() error {
179194 // the TUI surfaces it as "agent <id> not listening yet".
180195 var ErrNotReady = errors.New("subagent: agent inbox not ready")
181196
197+// ErrDeliveryUnknown means a command write made progress but did not finish.
198+// Callers must not replace the command with a fresh identity: the worker may
199+// have received enough bytes to parse it, so retries must reuse the same
200+// message id and be handled idempotently.
201+var ErrDeliveryUnknown = errors.New("subagent: command delivery unknown")
202+
182203 // dialUnixContext retries a Unix-socket connection briefly so the happy path
183204 // "child just started, supervisor sends right away" works without forcing the
184205 // caller to poll. Errors collapse onto ErrNotReady whenever the failure means
M packages/agent/subagents/inbox_test.go +60 -0
@@ -127,6 +127,32 @@ func (c *blockingWriteConn) Close() error {
127127 return c.Conn.Close()
128128 }
129129
130+type cancelAfterWriteConn struct {
131+ net.Conn
132+ cancel context.CancelFunc
133+}
134+
135+func (c *cancelAfterWriteConn) Write(data []byte) (int, error) {
136+ c.cancel()
137+ return len(data), nil
138+}
139+
140+type partialWriteConn struct {
141+ net.Conn
142+ withError bool
143+}
144+
145+func (c *partialWriteConn) Write(data []byte) (int, error) {
146+ written := len(data) / 2
147+ if written == 0 {
148+ written = 1
149+ }
150+ if c.withError {
151+ return written, errors.New("partial write")
152+ }
153+ return written, nil
154+}
155+
130156 type notifyingContext struct {
131157 context.Context
132158 doneCalled chan struct{}
@@ -138,6 +164,40 @@ func (c *notifyingContext) Done() <-chan struct{} {
138164 return c.Context.Done()
139165 }
140166
167+func TestInboxSendContextTreatsCompleteWriteBeforeCancellationAsDelivered(t *testing.T) {
168+ local, remote := net.Pipe()
169+ defer remote.Close()
170+ ctx, cancel := context.WithCancel(context.Background())
171+ inbox := NewInbox("unused")
172+ inbox.conn = &cancelAfterWriteConn{Conn: local, cancel: cancel}
173+ defer inbox.Close()
174+
175+ if err := inbox.sendBytesContext(ctx, []byte("follow-up\n")); err != nil {
176+ t.Fatalf("complete write after cancellation = %v, want success", err)
177+ }
178+}
179+
180+func TestInboxSendContextTreatsPartialWritesAsUnknownDelivery(t *testing.T) {
181+ for _, withError := range []bool{false, true} {
182+ name := "short write"
183+ if withError {
184+ name = "partial write error"
185+ }
186+ t.Run(name, func(t *testing.T) {
187+ local, remote := net.Pipe()
188+ defer remote.Close()
189+ inbox := NewInbox("unused")
190+ inbox.conn = &partialWriteConn{Conn: local, withError: withError}
191+ defer inbox.Close()
192+
193+ err := inbox.sendBytesContext(context.Background(), []byte("follow-up\n"))
194+ if !errors.Is(err, ErrDeliveryUnknown) {
195+ t.Fatalf("partial write error = %v, want ErrDeliveryUnknown", err)
196+ }
197+ })
198+ }
199+}
200+
141201 func TestInboxSendContextCancelsBlockedWrite(t *testing.T) {
142202 local, remote := net.Pipe()
143203 defer remote.Close()
M packages/agent/subagents/persist.go +324 -82
@@ -22,6 +22,8 @@ import (
2222 "sort"
2323 "strings"
2424 "time"
25+
26+ "github.com/google/uuid"
2527 )
2628
2729 // agentMeta is the durable identity record for one agent. Only fields
@@ -54,6 +56,8 @@ type agentMeta struct {
5456 WorkspaceBase string `json:"workspace_base,omitempty"`
5557 WorkspaceCapture CaptureMode `json:"workspace_capture,omitempty"`
5658 MaxTurns int `json:"max_turns,omitempty"`
59+ LifetimeTurns int `json:"lifetime_turns,omitempty"`
60+ CurrentRunTurns int `json:"current_run_turns,omitempty"`
5761 Timeout time.Duration `json:"timeout,omitempty"`
5862 Tools []string `json:"tools,omitempty"`
5963 WebSearchPolicy WebSearchPolicy `json:"web_search_policy,omitempty"`
@@ -77,8 +81,10 @@ type agentMeta struct {
7781 // ResumePrompt is a follow-up accepted by subagent_resume but not yet
7882 // acknowledged by the child with a turn.started event. It is durable so a
7983 // host exit while the worker is queued does not lose the manager signal.
80- ResumePrompt string `json:"resume_prompt,omitempty"`
81- ResumePromptAt time.Time `json:"resume_prompt_at,omitempty"`
84+ ResumePrompt string `json:"resume_prompt,omitempty"`
85+ ResumePromptAt time.Time `json:"resume_prompt_at,omitempty"`
86+ ResumePromptID string `json:"resume_prompt_id,omitempty"`
87+ ResumeQueue []queuedResumePrompt `json:"resume_queue,omitempty"`
8288
8389 // SessionID, when non-empty, scopes the agent to a particular
8490 // host zut session: the dashboard only shows agents whose
@@ -96,6 +102,12 @@ func metaPath(stateDir string) string { return filepath.Join(stateDir, "meta.jso
96102 // write is atomic (tmp + rename) so a crash mid-write can't leave a
97103 // half-parsable file that fails Reload.
98104 func writeAgentMeta(stateDir string, a *Agent) error {
105+ if a == nil {
106+ return errors.New("subagent meta: nil agent")
107+ }
108+ a.persistMu.Lock()
109+ defer a.persistMu.Unlock()
110+
99111 fastMode := a.FastMode
100112 webSearchPolicy := childWebSearchPolicy(a.WebSearchPolicy, a.Subagent, a.Tools)
101113 a.mu.Lock()
@@ -104,6 +116,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
104116 finished := a.finished
105117 resumePrompt := a.resumePromptText
106118 resumePromptAt := a.resumePromptAt
119+ resumePromptID := a.resumePromptID
120+ resumeQueue := append([]queuedResumePrompt(nil), a.resumePromptQueue...)
107121 errText := ""
108122 if a.lastErr != nil {
109123 errText = a.lastErr.Error()
@@ -113,6 +127,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
113127 processState := a.processState
114128 turnState := a.turnState
115129 currentTurnID := a.currentTurnID
130+ lifetimeTurns := a.LifetimeTurns
131+ currentRunTurns := a.CurrentRunTurns
116132 attempt := a.Attempt
117133 processPID := a.ProcessPID
118134 updatedAt := a.updatedAt
@@ -143,6 +159,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
143159 WorkspaceBase: a.WorkspaceBase,
144160 WorkspaceCapture: a.WorkspaceCapture,
145161 MaxTurns: a.MaxTurns,
162+ LifetimeTurns: lifetimeTurns,
163+ CurrentRunTurns: currentRunTurns,
146164 Timeout: a.Timeout,
147165 Tools: append([]string(nil), a.Tools...),
148166 WebSearchPolicy: webSearchPolicy,
@@ -165,6 +183,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
165183 SessionPath: a.SessionPath,
166184 ResumePrompt: resumePrompt,
167185 ResumePromptAt: resumePromptAt,
186+ ResumePromptID: resumePromptID,
187+ ResumeQueue: resumeQueue,
168188 SessionID: a.SessionID,
169189 }
170190 b, err := json.MarshalIndent(m, "", " ")
@@ -175,14 +195,50 @@ func writeAgentMeta(stateDir string, a *Agent) error {
175195 return fmt.Errorf("subagents meta dir: %w", err)
176196 }
177197 final := metaPath(stateDir)
178- tmp := final + ".tmp"
179- if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
198+ // Use a unique temporary file while holding the per-agent persistence
199+ // lock. A fixed meta.json.tmp is vulnerable to a concurrent writer (or a
200+ // stale file after a crash) replacing another writer's snapshot.
201+ tmpFile, err := os.CreateTemp(stateDir, ".meta.json.tmp-*")
202+ if err != nil {
203+ return fmt.Errorf("subagents meta temp: %w", err)
204+ }
205+ tmp := tmpFile.Name()
206+ removeTemp := true
207+ defer func() {
208+ _ = tmpFile.Close()
209+ if removeTemp {
210+ _ = os.Remove(tmp)
211+ }
212+ }()
213+ if err := tmpFile.Chmod(0o600); err != nil {
214+ return fmt.Errorf("subagents meta permissions: %w", err)
215+ }
216+ if _, err := tmpFile.Write(append(b, '\n')); err != nil {
180217 return fmt.Errorf("subagents meta write: %w", err)
181218 }
219+ if err := tmpFile.Sync(); err != nil {
220+ return fmt.Errorf("subagents meta sync: %w", err)
221+ }
222+ if err := tmpFile.Close(); err != nil {
223+ return fmt.Errorf("subagents meta close: %w", err)
224+ }
182225 if err := os.Rename(tmp, final); err != nil {
183- _ = os.Remove(tmp)
184226 return fmt.Errorf("subagent meta rename: %w", err)
185227 }
228+ removeTemp = false
229+ // Sync the containing directory so the rename itself survives a host
230+ // crash, not just the bytes in the temporary file.
231+ dir, err := os.Open(stateDir)
232+ if err != nil {
233+ return fmt.Errorf("subagents meta directory open: %w", err)
234+ }
235+ if err := dir.Sync(); err != nil {
236+ _ = dir.Close()
237+ return fmt.Errorf("subagents meta directory sync: %w", err)
238+ }
239+ if err := dir.Close(); err != nil {
240+ return fmt.Errorf("subagents meta directory close: %w", err)
241+ }
186242 return nil
187243 }
188244
@@ -420,57 +476,61 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
420476 agentStateDir = filepath.Dir(m.EventLogPath)
421477 }
422478 a := &Agent{
423- ID: m.ID,
424- Task: m.Task,
425- OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
426- Dir: dir,
427- RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
428- Started: m.Started,
429- ParentID: m.ParentID,
430- BatchID: m.BatchID,
431- RootSessionID: m.RootSessionID,
432- Model: m.Model,
433- Provider: m.Provider,
434- BaseURL: m.BaseURL,
435- InsecureTLS: m.InsecureTLS,
436- Reasoning: m.Reasoning,
437- FastMode: fastMode,
438- Subagent: m.Subagent,
439- WorkspaceMode: m.WorkspaceMode,
440- WorkspacePath: m.WorkspacePath,
441- WorkspaceBase: m.WorkspaceBase,
442- WorkspaceCapture: m.WorkspaceCapture,
443- MaxTurns: m.MaxTurns,
444- Timeout: m.Timeout,
445- Tools: append([]string(nil), m.Tools...),
446- WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),
447- Attempt: m.Attempt,
448- ProcessPID: m.ProcessPID,
449- InboxPath: m.InboxPath,
450- EventLogPath: m.EventLogPath,
451- SessionPath: m.SessionPath,
452- resumePromptText: m.ResumePrompt,
453- resumePromptAt: m.ResumePromptAt,
454- SessionID: m.SessionID,
455- inbox: NewInbox(m.InboxPath),
456- status: status,
457- activity: activity,
458- finished: m.Finished,
459- lastErr: lastErr,
460- legacyEventState: legacyEventState,
461- processState: processState,
462- turnState: turnState,
463- currentTurnID: m.CurrentTurnID,
464- updatedAt: m.UpdatedAt,
465- lastActivity: lastActivity,
466- stateDir: agentStateDir,
467- resultRef: m.ResultRef,
468- patchRef: m.PatchRef,
469- changedFiles: append([]string(nil), m.ChangedFiles...),
470- maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
471- maxOutputLines: f.cfg.Policy.MaxOutputLines,
472- done: make(chan struct{}),
473- turnResults: make(chan *TurnResult, 16),
479+ ID: m.ID,
480+ Task: m.Task,
481+ OriginalTask: firstNonEmpty(m.OriginalTask, m.Task),
482+ Dir: dir,
483+ RepositoryRoot: firstNonEmpty(m.RepositoryRoot, f.cfg.RepoRoot),
484+ Started: m.Started,
485+ ParentID: m.ParentID,
486+ BatchID: m.BatchID,
487+ RootSessionID: m.RootSessionID,
488+ Model: m.Model,
489+ Provider: m.Provider,
490+ BaseURL: m.BaseURL,
491+ InsecureTLS: m.InsecureTLS,
492+ Reasoning: m.Reasoning,
493+ FastMode: fastMode,
494+ Subagent: m.Subagent,
495+ WorkspaceMode: m.WorkspaceMode,
496+ WorkspacePath: m.WorkspacePath,
497+ WorkspaceBase: m.WorkspaceBase,
498+ WorkspaceCapture: m.WorkspaceCapture,
499+ MaxTurns: m.MaxTurns,
500+ LifetimeTurns: m.LifetimeTurns,
501+ CurrentRunTurns: m.CurrentRunTurns,
502+ Timeout: m.Timeout,
503+ Tools: append([]string(nil), m.Tools...),
504+ WebSearchPolicy: childWebSearchPolicy(m.WebSearchPolicy, m.Subagent, m.Tools),
505+ Attempt: m.Attempt,
506+ ProcessPID: m.ProcessPID,
507+ InboxPath: m.InboxPath,
508+ EventLogPath: m.EventLogPath,
509+ SessionPath: m.SessionPath,
510+ resumePromptText: m.ResumePrompt,
511+ resumePromptAt: m.ResumePromptAt,
512+ resumePromptID: m.ResumePromptID,
513+ resumePromptQueue: append([]queuedResumePrompt(nil), m.ResumeQueue...),
514+ SessionID: m.SessionID,
515+ inbox: NewInbox(m.InboxPath),
516+ status: status,
517+ activity: activity,
518+ finished: m.Finished,
519+ lastErr: lastErr,
520+ legacyEventState: legacyEventState,
521+ processState: processState,
522+ turnState: turnState,
523+ currentTurnID: m.CurrentTurnID,
524+ updatedAt: m.UpdatedAt,
525+ lastActivity: lastActivity,
526+ stateDir: agentStateDir,
527+ resultRef: m.ResultRef,
528+ patchRef: m.PatchRef,
529+ changedFiles: append([]string(nil), m.ChangedFiles...),
530+ maxOutputBytes: f.cfg.Policy.MaxOutputBytes,
531+ maxOutputLines: f.cfg.Policy.MaxOutputLines,
532+ done: make(chan struct{}),
533+ turnResults: make(chan *TurnResult, 16),
474534 }
475535 if a.updatedAt.IsZero() {
476536 a.updatedAt = lastActivity
@@ -542,7 +602,7 @@ func (a *Agent) loadTranscript() error {
542602 if err != nil {
543603 return fmt.Errorf("read subagent transcript: %w", err)
544604 }
545- if prompt, acceptedAt := a.ResumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, acceptedAt) {
605+ if prompt, acceptedAt := a.ResumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, prompt, acceptedAt) {
546606 a.clearResumePrompt()
547607 }
548608 a.mu.Lock()
@@ -583,12 +643,23 @@ func (a *Agent) loadTranscript() error {
583643 // Status precedence: explicit lifecycle events (agent_stopped) win
584644 // over inferred ones (assistant_message → idle). If the log contains
585645 // no terminator we keep status=StatusDetached so the user can resume.
586-func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {
587- if acceptedAt.IsZero() {
646+func resumePromptAcknowledged(events []Event, prompt string, acceptedAt time.Time) bool {
647+ if prompt == "" || acceptedAt.IsZero() {
588648 return false
589649 }
650+ userDurable := false
590651 for _, ev := range events {
591- if (ev.Type == EventTurnStarted || ev.Type == "turn_start") && !ev.Time.Before(acceptedAt) {
652+ if ev.Time.Before(acceptedAt) {
653+ continue
654+ }
655+ if ev.Type == "user_message" && userMessageText(ev) == prompt {
656+ // The worker emits this event only after the matching session
657+ // message has been appended and flushed. A crash here leaves the
658+ // prompt pending, which is intentionally safe to replay.
659+ userDurable = true
660+ continue
661+ }
662+ if userDurable && isDelegatedTurnStart(ev) {
592663 return true
593664 }
594665 }
@@ -598,15 +669,13 @@ func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {
598669 func replayEventsIntoAgent(a *Agent, evs []Event) {
599670 terminal := false
600671 for _, ev := range evs {
672+ replayEventTranscript(a, ev)
601673 switch ev.Type {
602- case EventTurnStarted, "turn_start":
603- turnID := ev.TurnID
604- if turnID == "" {
605- if step, ok := ev.Data["step"].(float64); ok {
606- turnID = fmt.Sprintf("turn-%d", int(step))
607- }
674+ case EventTurnStarted:
675+ if !isDelegatedTurnStart(ev) {
676+ continue
608677 }
609- a.setTurnState(TurnRunning, turnID)
678+ a.setTurnState(TurnRunning, eventTurnID(ev))
610679 case EventTurnResult, "turn_result":
611680 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
612681 a.setResult(result)
@@ -627,8 +696,6 @@ func replayEventsIntoAgent(a *Agent, evs []Event) {
627696 a.status = StatusDone
628697 a.activity = "done (offline)"
629698 a.mu.Unlock()
630- case "assistant_message", "user_message", "stdout", "stderr", "error":
631- replayEventTranscript(a, ev)
632699 case "agent_stopped":
633700 terminal = true
634701 reason, _ := ev.Data["reason"].(string)
@@ -677,7 +744,14 @@ func replayTranscriptIntoAgent(a *Agent, evs []Event) {
677744 }
678745
679746 func replayEventTranscript(a *Agent, ev Event) {
747+ if isAssistantStreamBoundary(ev.Type) {
748+ a.resetStreamingAssistant()
749+ }
680750 switch ev.Type {
751+ case "message.delta":
752+ if delta, _ := ev.Data["delta"].(string); delta != "" {
753+ a.appendAssistantDelta(delta)
754+ }
681755 case "assistant_message", "user_message":
682756 var text []string
683757 if c, ok := ev.Data["content"].([]any); ok {
@@ -735,9 +809,16 @@ func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, erro
735809
736810 // ResumeWithPrompt continues an agent with a manager follow-up while retaining
737811 // its existing session. An idle live worker receives the turn through its
738-// inbox; a terminal worker is restarted with the follow-up as its initial
739-// prompt, avoiding a race with inbox listener startup.
812+// inbox; an active live worker queues the follow-up for the next idle turn; a
813+// terminal worker is restarted with the follow-up as its initial prompt,
814+// avoiding a race with inbox listener startup.
740815 func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
816+ if ctx == nil {
817+ ctx = context.Background()
818+ }
819+ if err := ctx.Err(); err != nil {
820+ return nil, err
821+ }
741822 if strings.TrimSpace(prompt) == "" {
742823 return nil, fmt.Errorf("subagent: follow-up prompt is required")
743824 }
@@ -750,34 +831,82 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
750831 }
751832 status := existing.Status()
752833 if status == StatusRunning {
753- if existing.ProcessState() != ProcessAlive || existing.TurnState() != TurnIdle {
834+ if existing.ProcessState() != ProcessAlive {
754835 f.operationMu.Unlock()
755- return nil, fmt.Errorf("subagent: agent %s is still working; wait for it to become idle before sending a follow-up", existing.ID)
836+ return nil, fmt.Errorf("subagent: agent %s is still working; wait for its worker to become available before sending a follow-up", existing.ID)
756837 }
757838 if existing.inbox == nil {
758839 f.operationMu.Unlock()
759840 return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID)
760841 }
842+ if err := ctx.Err(); err != nil {
843+ f.operationMu.Unlock()
844+ return nil, err
845+ }
846+ if existing.TurnState() != TurnRunning && existing.hasQueuedResumePrompt() {
847+ f.operationMu.Unlock()
848+ return nil, fmt.Errorf("subagent: agent %s already has a follow-up pending delivery", existing.ID)
849+ }
850+ if existing.TurnState() == TurnRunning {
851+ // Active turns cannot accept a command through the worker inbox yet.
852+ // Keep the prompt durable and let the idle transition deliver it in
853+ // order after the current message turn finishes.
854+ acceptedAt := f.cfg.Now()
855+ commandID := existing.queueResumePrompt(prompt, acceptedAt)
856+ if err := f.persistAgent(existing); err != nil {
857+ existing.removeQueuedResumePrompt(commandID)
858+ f.operationMu.Unlock()
859+ return nil, fmt.Errorf("subagent: persist queued follow-up for %s: %w", existing.ID, err)
860+ }
861+ f.operationMu.Unlock()
862+ return existing, nil
863+ }
864+ if existing.TurnState() != TurnIdle {
865+ f.operationMu.Unlock()
866+ return nil, fmt.Errorf("subagent: agent %s is still working; wait for it to become idle before sending a follow-up", existing.ID)
867+ }
761868 turnID := existing.CurrentTurnID()
869+ previousRunTurns := existing.resetCurrentRunTurns()
762870 // Reserve the worker before writing to its inbox. A second manager call
763871 // must not enqueue another turn in the small interval before the worker
764872 // reports its turn.start event. Persist the prompt first so a host exit
765873 // before that event can resume and redeliver it.
766- previousPrompt, previousPromptAt := existing.setResumePrompt(prompt, f.cfg.Now())
874+ previousPrompt := existing.setResumePrompt(prompt, f.cfg.Now())
767875 existing.setTurnState(TurnQueued, turnID)
768876 inbox := existing.inbox
769- command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt})
770- f.persistAgent(existing)
877+ command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt, NewRun: true})
878+ command.MessageID = existing.resumePromptCommandID()
879+ if err := f.persistAgent(existing); err != nil {
880+ existing.setTurnState(TurnIdle, turnID)
881+ existing.setCurrentRunTurns(previousRunTurns)
882+ existing.restoreResumePrompt(previousPrompt)
883+ f.operationMu.Unlock()
884+ return nil, fmt.Errorf("subagent: persist follow-up for %s: %w", existing.ID, err)
885+ }
771886 f.operationMu.Unlock()
772887
773888 if err := inbox.SendCommandContext(ctx, command); err != nil {
889+ retryUnknown := false
774890 f.operationMu.Lock()
775891 if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID {
776892 existing.setTurnState(TurnIdle, turnID)
777- existing.setResumePrompt(previousPrompt, previousPromptAt)
778- f.persistAgent(existing)
893+ existing.setCurrentRunTurns(previousRunTurns)
894+ if errors.Is(err, ErrDeliveryUnknown) {
895+ existing.rejectActiveResumePrompt()
896+ retryUnknown = true
897+ } else {
898+ existing.restoreResumePrompt(previousPrompt)
899+ }
900+ if persistErr := f.persistAgent(existing); persistErr != nil {
901+ err = fmt.Errorf("%w; persist recovery: %v", err, persistErr)
902+ }
779903 }
780904 f.operationMu.Unlock()
905+ if retryUnknown {
906+ // The command keeps its durable identity. A retry is safe whether
907+ // the worker parsed the first write or only received a prefix.
908+ f.dispatchQueuedResume(ctx, existing)
909+ }
781910 return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err)
782911 }
783912 return existing, nil
@@ -787,6 +916,77 @@ func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*
787916 return f.resume(ctx, id, true, prompt)
788917 }
789918
919+// dispatchQueuedResumeWithTimeout keeps the idle callback bounded. Unlike a
920+// manager request, an idle transition has no caller context to propagate.
921+func (f *Supervisor) dispatchQueuedResumeWithTimeout(a *Agent) {
922+ timeout := f.cfg.Policy.ReconnectTimeout
923+ if timeout <= 0 {
924+ timeout = 5 * time.Second
925+ }
926+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
927+ defer cancel()
928+ f.dispatchQueuedResume(ctx, a)
929+}
930+
931+// dispatchQueuedResume delivers the oldest follow-up accepted during an
932+// active turn after the worker reports that its inbox can accept a new turn.
933+// The operation lock serializes this transition with manager resume calls and
934+// the agent's durable prompt state prevents a host exit from losing it.
935+func (f *Supervisor) dispatchQueuedResume(ctx context.Context, a *Agent) {
936+ if ctx == nil {
937+ ctx = context.Background()
938+ }
939+ if a == nil {
940+ return
941+ }
942+ f.operationMu.Lock()
943+ if a.Status() != StatusRunning || a.ProcessState() != ProcessAlive || a.TurnState() != TurnIdle || a.inbox == nil {
944+ f.operationMu.Unlock()
945+ return
946+ }
947+ prompt, acceptedAt, commandID, ok := a.startQueuedResumePrompt()
948+ if !ok {
949+ f.operationMu.Unlock()
950+ return
951+ }
952+ turnID := a.CurrentTurnID()
953+ previousRunTurns := a.resetCurrentRunTurns()
954+ a.setTurnState(TurnQueued, turnID)
955+ inbox := a.inbox
956+ command := NewCommand(CommandTurnStart, a.ID, turnID, TurnStartPayload{Prompt: prompt, NewRun: true})
957+ command.MessageID = commandID
958+ if err := f.persistAgent(a); err != nil {
959+ a.restoreResumePromptToQueue(prompt, acceptedAt)
960+ a.setCurrentRunTurns(previousRunTurns)
961+ a.setTurnState(TurnIdle, turnID)
962+ f.operationMu.Unlock()
963+ return
964+ }
965+ f.operationMu.Unlock()
966+
967+ if err := inbox.SendCommandContext(ctx, command); err == nil {
968+ return
969+ }
970+
971+ // A failed delivery must put the prompt back at the head of the queue.
972+ // Keep the worker idle so a later retry, or an explicit resume, can send
973+ // it without skipping any subsequent follow-ups.
974+ f.operationMu.Lock()
975+ if a.Status() == StatusRunning && a.ProcessState() == ProcessAlive && a.TurnState() == TurnQueued && a.CurrentTurnID() == turnID {
976+ if a.restoreResumePromptToQueue(prompt, acceptedAt) {
977+ a.setCurrentRunTurns(previousRunTurns)
978+ a.setTurnState(TurnIdle, turnID)
979+ if persistErr := f.persistAgent(a); persistErr != nil {
980+ // persistAgent records the failure on the agent. Keep the explicit
981+ // check here because this recovery write is the only durable copy
982+ // of the restored queue head.
983+ a.recordPersistenceError(persistErr)
984+ }
985+ }
986+ }
987+ f.operationMu.Unlock()
988+}
989+
790990 // RestartTask intentionally runs the stored original task again in a fresh
791991 // worker attempt. Callers should use ResumeSession for normal recovery.
792992 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
@@ -852,10 +1052,45 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
8521052 }
8531053 now := f.cfg.Now()
8541054 fastMode := existing.FastMode
855- pendingResumePrompt, pendingResumePromptAt := existing.ResumePromptInfo()
1055+ lifetimeTurns := existing.LifetimeTurnsValue()
1056+ currentRunTurns := existing.CurrentRunTurnsValue()
1057+ pendingResume := existing.resumePromptState()
1058+ pendingResumePrompt, pendingResumePromptAt, pendingResumePromptID := pendingResume.Prompt, pendingResume.AcceptedAt, pendingResume.CommandID
1059+ pendingResumeQueue := existing.resumePromptQueueSnapshot()
1060+ if pendingResumePrompt != "" && pendingResumePromptID == "" {
1061+ pendingResumePromptID = uuid.NewString()
1062+ }
8561063 if resumePrompt != "" {
857- pendingResumePrompt = resumePrompt
858- pendingResumePromptAt = now
1064+ currentRunTurns = 0
1065+ if pendingResumePrompt == "" {
1066+ pendingResumePrompt = resumePrompt
1067+ pendingResumePromptAt = now
1068+ pendingResumePromptID = uuid.NewString()
1069+ } else {
1070+ pendingResumeQueue = append(pendingResumeQueue, queuedResumePrompt{Prompt: resumePrompt, AcceptedAt: now, CommandID: uuid.NewString()})
1071+ }
1072+ }
1073+ if resuming && resumePrompt == "" && pendingResumePrompt == "" && len(pendingResumeQueue) > 0 {
1074+ // A host can exit after accepting an active-worker follow-up but before
1075+ // dispatching it. Make the first durable queued prompt the resumed
1076+ // worker's initial turn rather than leaving the queue stranded.
1077+ queued := pendingResumeQueue[0]
1078+ pendingResumePrompt = queued.Prompt
1079+ pendingResumePromptAt = queued.AcceptedAt
1080+ pendingResumePromptID = queued.CommandID
1081+ if pendingResumePromptID == "" {
1082+ pendingResumePromptID = uuid.NewString()
1083+ }
1084+ pendingResumeQueue = pendingResumeQueue[1:]
1085+ }
1086+ if resuming && pendingResumePrompt != "" {
1087+ // A retained follow-up is an explicit new run, including a queued
1088+ // follow-up recovered after a host restart. Preserve lifetime turns but
1089+ // never carry the previous run's allowance into this one.
1090+ currentRunTurns = 0
1091+ }
1092+ if !resuming {
1093+ currentRunTurns = 0
8591094 }
8601095 m := agentMeta{
8611096 ID: existing.ID, Task: existing.Task,
@@ -868,7 +1103,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
8681103 Subagent: existing.Subagent,
8691104 WorkspaceMode: existing.WorkspaceMode, WorkspacePath: existing.WorkspacePath,
8701105 WorkspaceBase: existing.WorkspaceBase, WorkspaceCapture: existing.WorkspaceCapture,
871- MaxTurns: existing.MaxTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
1106+ MaxTurns: existing.MaxTurns, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Timeout: existing.Timeout, Tools: append([]string(nil), existing.Tools...),
8721107 WebSearchPolicy: childWebSearchPolicy(existing.WebSearchPolicy, existing.Subagent, existing.Tools),
8731108 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
8741109 SessionID: existing.SessionID,
@@ -876,6 +1111,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
8761111 SessionPath: existing.SessionPath,
8771112 ResumePrompt: pendingResumePrompt,
8781113 ResumePromptAt: pendingResumePromptAt,
1114+ ResumePromptID: pendingResumePromptID,
1115+ ResumeQueue: pendingResumeQueue,
8791116 }
8801117
8811118 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
@@ -954,6 +1191,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
9541191 WorkspaceBase: m.WorkspaceBase,
9551192 WorkspaceCapture: m.WorkspaceCapture,
9561193 MaxTurns: maxTurns,
1194+ LifetimeTurns: m.LifetimeTurns,
1195+ CurrentRunTurns: m.CurrentRunTurns,
9571196 Timeout: timeout,
9581197 Attempt: m.Attempt,
9591198 HeartbeatInterval: f.cfg.Policy.HeartbeatInterval,
@@ -965,6 +1204,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
9651204 Resuming: resuming,
9661205 resumePromptText: pendingResumePrompt,
9671206 resumePromptAt: pendingResumePromptAt,
1207+ resumePromptID: m.ResumePromptID,
1208+ resumePromptQueue: append([]queuedResumePrompt(nil), pendingResumeQueue...),
9681209 inbox: NewInbox(m.InboxPath),
9691210 status: StatusPending,
9701211 activity: "resuming",
@@ -989,6 +1230,7 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resum
9891230 }
9901231 a.ctx, a.cancel = runCtx, cancel
9911232 a.persistFn = f.persistAgent
1233+ a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
9921234 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
9931235 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
9941236 a.runner = f.cfg.NewRunner(a)
M packages/agent/subagents/persist_test.go +32 -1
@@ -14,6 +14,34 @@ import (
1414 "time"
1515 )
1616
17+func TestWriteAgentMetaSerializesConcurrentSnapshots(t *testing.T) {
18+ stateDir := t.TempDir()
19+ a := &Agent{ID: "concurrent-meta", Task: "retain queued prompts", stateDir: stateDir}
20+ var wg sync.WaitGroup
21+ for i := 0; i < 32; i++ {
22+ wg.Add(1)
23+ go func(i int) {
24+ defer wg.Done()
25+ a.setTurnCounts(i, i/2)
26+ a.queueResumePrompt("prompt", time.Now())
27+ if err := writeAgentMeta(stateDir, a); err != nil {
28+ t.Errorf("write metadata: %v", err)
29+ }
30+ }(i)
31+ }
32+ wg.Wait()
33+ if _, err := readAgentMeta(stateDir); err != nil {
34+ t.Fatalf("final metadata is not a complete JSON snapshot: %v", err)
35+ }
36+ matches, err := filepath.Glob(filepath.Join(stateDir, ".meta.json.tmp-*"))
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ if len(matches) != 0 {
41+ t.Fatalf("temporary metadata files remain: %v", matches)
42+ }
43+}
44+
1745 // TestSpawnWritesMetaJSON asserts the durability contract: every
1846 // successful Spawn leaves a meta.json on disk with the agent's
1947 // identity bits. Without this, Reload on the next zut launch can't
@@ -662,7 +690,7 @@ func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) {
662690 EventLogPath: filepath.Join(stateDir, "events.jsonl"),
663691 SessionPath: filepath.Join(stateDir, "session.json"),
664692 ResumePrompt: "I applied your review. What do you think now?",
665- ResumePromptAt: time.Now(),
693+ ResumePromptAt: time.Now().Add(-time.Second),
666694 }
667695 data, err := json.Marshal(m)
668696 if err != nil {
@@ -675,6 +703,9 @@ func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) {
675703 if err != nil {
676704 t.Fatal(err)
677705 }
706+ if err := log.Append(NewEvent("user_message", map[string]any{"content": []any{map[string]any{"type": "text", "text": m.ResumePrompt}}})); err != nil {
707+ t.Fatal(err)
708+ }
678709 if err := log.Append(NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})); err != nil {
679710 t.Fatal(err)
680711 }
M packages/agent/subagents/protocol.go +4 -0
@@ -579,6 +579,10 @@ type AgentHeartbeatPayload struct {
579579 }
580580 type TurnStartPayload struct {
581581 Prompt string `json:"prompt"`
582+ // NewRun starts a fresh max_turns budget while retaining the worker's
583+ // existing session and transcript. It is set only for explicit
584+ // subagent_resume follow-ups.
585+ NewRun bool `json:"new_run,omitempty"`
582586 }
583587 type TurnCancelPayload struct {
584588 Reason string `json:"reason,omitempty"`
M packages/agent/subagents/result.go +26 -4
@@ -443,6 +443,9 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
443443 return
444444 }
445445 attempt := a.AttemptValue()
446+ a.mu.Lock()
447+ partialOutput := a.lastAssistant
448+ a.mu.Unlock()
446449 result := a.Result()
447450 if result != nil {
448451 if err := validateTurnResultAgent(result, a.ID); err != nil {
@@ -468,14 +471,20 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
468471 result = nil
469472 }
470473 }
474+ if result != nil && result.Output == "" && partialOutput != "" &&
475+ (result.Status == ResultFailed || result.Status == ResultCanceled || status == StatusFailed || status == StatusKilled) {
476+ // A canceled or failed worker turn may emit its terminal result before
477+ // its provider turns accumulated text into a final assistant message.
478+ // The event stream has already projected that text onto the Agent, so
479+ // retain it in the durable result rather than returning an empty failure.
480+ result.Output = partialOutput
481+ }
471482 if result == nil {
472483 turnID := a.CurrentTurnID()
473484 if turnID == "" {
474485 turnID = fmt.Sprintf("turn-%d", attempt)
475486 }
476- a.mu.Lock()
477- output := a.lastAssistant
478- a.mu.Unlock()
487+ output := partialOutput
479488 resultStatus := ResultSucceeded
480489 switch {
481490 case status == StatusFailed && errors.Is(runErr, context.Canceled):
@@ -512,7 +521,20 @@ func (f *Supervisor) ensureResult(a *Agent, status Status, runErr error) {
512521 } else {
513522 result.Status = ResultFailed
514523 }
515- if result.Error == nil {
524+ if errors.Is(runErr, context.DeadlineExceeded) {
525+ // The parent deadline is authoritative for terminal categorization, so
526+ // it intentionally overrides any child result error, including
527+ // context_limit. The worker was first asked to stop cleanly, and any
528+ // streamed output remains in this result and HistoryRef(agentID).
529+ message := "subagent deadline exceeded; inspect history for received output"
530+ if result.Output != "" {
531+ message = "subagent deadline exceeded; partial output is preserved in the result and history"
532+ }
533+ result.Error = &ResultError{
534+ Code: "deadline_exceeded",
535+ Message: message,
536+ }
537+ } else if result.Error == nil {
516538 result.Error = &ResultError{Code: "runner_failed", Message: truncate(runErr.Error(), 500)}
517539 }
518540 }
M packages/agent/subagents/result_test.go +52 -10
@@ -269,23 +269,35 @@ func checkCaptureFailureResult(t *testing.T, agent *Agent, stateDir string) {
269269
270270 func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
271271 tests := []struct {
272- name string
273- status Status
274- runErr error
275- wantState TurnStatus
272+ name string
273+ status Status
274+ runErr error
275+ childCode string
276+ wantState TurnStatus
277+ wantCode string
278+ wantOutput string
276279 }{
277- {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed},
278- {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled},
279- {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled},
280+ {name: "runner failure", status: StatusFailed, runErr: errors.New("runner failed"), wantState: ResultFailed, wantCode: "runner_failed"},
281+ {name: "runner cancellation", status: StatusFailed, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
282+ {name: "deadline preserves recovery guidance", status: StatusFailed, runErr: context.DeadlineExceeded, wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
283+ {name: "deadline overrides child error", status: StatusFailed, runErr: context.DeadlineExceeded, childCode: "context_limit", wantState: ResultFailed, wantCode: "deadline_exceeded", wantOutput: "partial answer"},
284+ {name: "killed", status: StatusKilled, runErr: context.Canceled, wantState: ResultCanceled, wantCode: "runner_failed"},
280285 }
281286 for _, tt := range tests {
282287 t.Run(tt.name, func(t *testing.T) {
283288 stateDir := t.TempDir()
289+ childStatus := ResultSucceeded
290+ var childError *ResultError
291+ if tt.childCode != "" {
292+ childStatus = ResultFailed
293+ childError = &ResultError{Code: tt.childCode, Message: "child error"}
294+ }
284295 agent := &Agent{
285- ID: "agent-1",
286- stateDir: stateDir,
296+ ID: "agent-1",
297+ stateDir: stateDir,
298+ lastAssistant: tt.wantOutput,
287299 result: &TurnResult{
288- AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
300+ AgentID: "agent-1", TurnID: "turn-1", Status: childStatus, Error: childError,
289301 },
290302 }
291303 supervisor := &Supervisor{}
@@ -297,6 +309,12 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
297309 if result.Error == nil {
298310 t.Fatal("runner error was not recorded")
299311 }
312+ if result.Error.Code != tt.wantCode {
313+ t.Fatalf("result error code = %q, want %q", result.Error.Code, tt.wantCode)
314+ }
315+ if result.Output != tt.wantOutput {
316+ t.Fatalf("result output = %q, want %q", result.Output, tt.wantOutput)
317+ }
300318 durable, err := readTurnResult(stateDir)
301319 if err != nil {
302320 t.Fatal(err)
@@ -304,6 +322,30 @@ func TestEnsureResultReconcilesChildStatusWithRunnerError(t *testing.T) {
304322 if durable.Status != tt.wantState {
305323 t.Fatalf("durable result status = %q, want %q", durable.Status, tt.wantState)
306324 }
325+ if durable.Output != tt.wantOutput {
326+ t.Fatalf("durable result output = %q, want %q", durable.Output, tt.wantOutput)
327+ }
328+ if durable.Error == nil || durable.Error.Code != tt.wantCode {
329+ t.Fatalf("durable result error = %#v, want code %q", durable.Error, tt.wantCode)
330+ }
307331 })
308332 }
309333 }
334+
335+func TestEnsureResultDoesNotBackfillSuccessfulEmptyOutput(t *testing.T) {
336+ stateDir := t.TempDir()
337+ agent := &Agent{
338+ ID: "agent-1",
339+ stateDir: stateDir,
340+ lastAssistant: "streamed text",
341+ result: &TurnResult{
342+ AgentID: "agent-1", TurnID: "turn-1", Status: ResultSucceeded,
343+ },
344+ }
345+
346+ (&Supervisor{}).ensureResult(agent, StatusDone, nil)
347+
348+ if result := agent.Result(); result == nil || result.Output != "" {
349+ t.Fatalf("successful result = %#v, want intentionally empty output", result)
350+ }
351+}
M packages/agent/subagents/resume_prompt_test.go +149 -0
@@ -2,12 +2,151 @@ package subagents
22
33 import (
44 "context"
5+ "errors"
56 "path/filepath"
67 "runtime"
78 "testing"
89 "time"
910 )
1011
12+func TestResumeWithPromptQueuesFollowUpsForActiveWorker(t *testing.T) {
13+ if runtime.GOOS == "windows" {
14+ t.Skip("subagent inbox transport uses Unix-domain sockets")
15+ }
16+
17+ root := shortSocketDir(t)
18+ ready := make(chan struct{}, 1)
19+ commands := make(chan Envelope, 2)
20+ f := New(Config{
21+ Root: root, RepoRoot: root,
22+ NewRunner: func(a *Agent) Runner {
23+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
24+ listener, err := Listen(a.InboxPath)
25+ if err != nil {
26+ return err
27+ }
28+ defer listener.Close()
29+ a.setProcessState(ProcessAlive)
30+ a.setTurnState(TurnRunning, "turn-1")
31+ ready <- struct{}{}
32+ for {
33+ select {
34+ case <-ctx.Done():
35+ return ctx.Err()
36+ case line, ok := <-listener.Lines():
37+ if !ok {
38+ return nil
39+ }
40+ command, err := ParseCommand(line)
41+ if err != nil {
42+ return err
43+ }
44+ if command.Type == CommandAgentShutdown {
45+ return nil
46+ }
47+ commands <- command
48+ }
49+ }
50+ })
51+ },
52+ })
53+ defer f.StopAll()
54+
55+ a, err := f.Spawn(context.Background(), "review the implementation")
56+ if err != nil {
57+ t.Fatal(err)
58+ }
59+ select {
60+ case <-ready:
61+ case <-time.After(time.Second):
62+ t.Fatal("active worker did not open its inbox")
63+ }
64+ canceled, cancel := context.WithCancel(context.Background())
65+ cancel()
66+ if _, err := f.ResumeWithPrompt(canceled, a.ID, "must not be accepted"); !errors.Is(err, context.Canceled) {
67+ t.Fatalf("canceled active follow-up error = %v, want context canceled", err)
68+ }
69+
70+ const (
71+ firstFollowUp = "Check the parser behavior."
72+ secondFollowUp = "Then check the error handling."
73+ )
74+ if _, err := f.ResumeWithPrompt(context.Background(), a.ID, firstFollowUp); err != nil {
75+ t.Fatalf("queue first active follow-up: %v", err)
76+ }
77+ if _, err := f.ResumeWithPrompt(context.Background(), a.ID, secondFollowUp); err != nil {
78+ t.Fatalf("queue second active follow-up: %v", err)
79+ }
80+ select {
81+ case command := <-commands:
82+ t.Fatalf("active follow-up was delivered before idle: %v", command.Type)
83+ default:
84+ }
85+
86+ persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ if persisted.ResumePrompt != "" || len(persisted.ResumeQueue) != 2 {
91+ t.Fatalf("active follow-ups persisted as prompt %q queue %d, want empty prompt and queue 2", persisted.ResumePrompt, len(persisted.ResumeQueue))
92+ }
93+ if firstID, secondID := persisted.ResumeQueue[0].CommandID, persisted.ResumeQueue[1].CommandID; firstID == "" || secondID == "" || firstID == secondID {
94+ t.Fatalf("queued follow-up command IDs = %q, %q; want distinct non-empty IDs", firstID, secondID)
95+ }
96+
97+ if err := updateAgentFromEvent(a, NewEvent(EventAgentIdle, map[string]any{"turn_id": "turn-1"})); err != nil {
98+ t.Fatalf("apply first idle event: %v", err)
99+ }
100+ select {
101+ case command := <-commands:
102+ var payload TurnStartPayload
103+ if command.Type != CommandTurnStart || command.DecodePayload(&payload) != nil || payload.Prompt != firstFollowUp {
104+ t.Fatalf("first idle delivery = type %q prompt %q, want turn start %q", command.Type, payload.Prompt, firstFollowUp)
105+ }
106+ if !payload.NewRun {
107+ t.Fatal("first queued follow-up did not request a fresh run budget")
108+ }
109+ case <-time.After(time.Second):
110+ t.Fatal("first active follow-up was not delivered at idle")
111+ }
112+ if got := a.TurnState(); got != TurnQueued {
113+ t.Fatalf("turn state after first queued delivery = %s, want %s", got, TurnQueued)
114+ }
115+
116+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
117+ "turn_id": "turn-2", "step": float64(2), "lifetime_turns": 2, "current_run_turns": 1,
118+ })); err != nil {
119+ t.Fatalf("apply second turn started event: %v", err)
120+ }
121+ if err := updateAgentFromEvent(a, NewEvent(EventAgentIdle, map[string]any{"turn_id": "turn-2"})); err != nil {
122+ t.Fatalf("apply second idle event: %v", err)
123+ }
124+ select {
125+ case command := <-commands:
126+ var payload TurnStartPayload
127+ if command.Type != CommandTurnStart || command.DecodePayload(&payload) != nil || payload.Prompt != secondFollowUp {
128+ t.Fatalf("second idle delivery = type %q prompt %q, want turn start %q", command.Type, payload.Prompt, secondFollowUp)
129+ }
130+ case <-time.After(time.Second):
131+ t.Fatal("second active follow-up was not delivered at the next idle turn")
132+ }
133+
134+ persisted, err = readAgentMeta(filepath.Join(root, "agents", a.ID))
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if persisted.ResumePrompt != secondFollowUp || len(persisted.ResumeQueue) != 0 {
139+ t.Fatalf("second follow-up persistence = prompt %q queue %d, want prompt %q and empty queue", persisted.ResumePrompt, len(persisted.ResumeQueue), secondFollowUp)
140+ }
141+ if persisted.ResumePromptID == "" {
142+ t.Fatal("promoted follow-up lost its command ID")
143+ }
144+ if err := f.Stop(a.ID); err != nil {
145+ t.Fatal(err)
146+ }
147+ a.Wait()
148+}
149+
11150 func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
12151 if runtime.GOOS == "windows" {
13152 t.Skip("subagent inbox transport uses Unix-domain sockets")
@@ -60,6 +199,7 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
60199 case <-time.After(time.Second):
61200 t.Fatal("idle worker did not open its inbox")
62201 }
202+ a.setTurnCounts(4, 2)
63203
64204 const followUp = "I applied your review. What do you think now?"
65205 continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp)
@@ -72,6 +212,12 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
72212 if got := a.TurnState(); got != TurnQueued {
73213 t.Fatalf("turn state after sending follow-up = %s, want %s", got, TurnQueued)
74214 }
215+ if got := a.CurrentRunTurnsValue(); got != 0 {
216+ t.Fatalf("current-run turns after explicit resume = %d, want 0 before turn starts", got)
217+ }
218+ if got := a.LifetimeTurnsValue(); got != 4 {
219+ t.Fatalf("lifetime turns after explicit resume = %d, want 4", got)
220+ }
75221 if _, err := f.ResumeWithPrompt(context.Background(), a.ID, "duplicate follow-up"); err == nil {
76222 t.Fatal("second follow-up succeeded while the first was queued")
77223 }
@@ -87,6 +233,9 @@ func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
87233 if payload.Prompt != followUp {
88234 t.Fatalf("follow-up prompt = %q, want %q", payload.Prompt, followUp)
89235 }
236+ if !payload.NewRun {
237+ t.Fatal("idle follow-up did not request a fresh run budget")
238+ }
90239 case <-time.After(time.Second):
91240 t.Fatal("idle worker did not receive the follow-up")
92241 }
M packages/agent/subagents/runner.go +246 -18
@@ -5,7 +5,9 @@ import (
55 "bytes"
66 "context"
77 "encoding/json"
8+ "errors"
89 "fmt"
10+ "math"
911 "os"
1012 "os/exec"
1113 "path/filepath"
@@ -52,6 +54,11 @@ type execRunner struct {
5254 // would litter the user's repo — every agent's Dir points
5355 // at it directly.
5456 SessionPath string
57+
58+ // GracePeriod is the time a deadline-expired worker gets to handle an
59+ // inbox shutdown and write its session/result before the process group is
60+ // force-killed. Zero uses the standard ten-second cancellation grace.
61+ GracePeriod time.Duration
5562 }
5663
5764 // subagentWorkerArgsOpts captures every dynamic input to subagentWorkerArgs.
@@ -73,6 +80,9 @@ type subagentWorkerArgsOpts struct {
7380 FastModeSet bool
7481 Subagent string
7582 MaxTurns int
83+ LifetimeTurns int
84+ RunTurns int
85+ CountersSet bool
7686 Tools []string
7787 WebSearchPolicy WebSearchPolicy
7888 }
@@ -108,6 +118,9 @@ func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []str
108118 FastModeSet: true,
109119 Subagent: a.Subagent,
110120 MaxTurns: a.MaxTurns,
121+ LifetimeTurns: a.LifetimeTurnsValue(),
122+ RunTurns: a.CurrentRunTurnsValue(),
123+ CountersSet: true,
111124 Tools: a.Tools,
112125 WebSearchPolicy: a.WebSearchPolicy,
113126 })
@@ -151,6 +164,12 @@ func subagentWorkerArgs(opts subagentWorkerArgsOpts) []string {
151164 if opts.MaxTurns > 0 {
152165 args = append(args, "--max-turns", fmt.Sprint(opts.MaxTurns))
153166 }
167+ if opts.CountersSet {
168+ args = append(args,
169+ "--subagent-lifetime-turns", fmt.Sprint(opts.LifetimeTurns),
170+ "--subagent-run-turns", fmt.Sprint(opts.RunTurns),
171+ )
172+ }
154173 webSearchPolicy := childWebSearchPolicy(opts.WebSearchPolicy, opts.Subagent, opts.Tools)
155174 // Always propagate the final capability decision, including deny, so a
156175 // child with no ordinary --tools list cannot re-enable web search from its
@@ -302,7 +321,14 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
302321 args = defaultChildArgs(exe, r.agent, sessionPath, inboxPath)
303322 }
304323
305- cmd := exec.CommandContext(ctx, args[0], args[1:]...)
324+ // Do not bind the process directly to ctx: CommandContext sends an
325+ // immediate SIGKILL at the deadline, which prevents the worker from
326+ // handling cancellation and persisting the session it has built so far.
327+ // A deadline watcher below requests the worker's graceful inbox shutdown
328+ // first, then force-stops it only after the bounded grace period. Use a
329+ // non-canceling CommandContext so configureWorkerProcess can retain its
330+ // Cmd.Cancel fallback without tying it to the worker lifetime context.
331+ cmd := exec.CommandContext(context.Background(), args[0], args[1:]...)
306332 cmd.Dir = r.agent.Dir
307333 cmd.Env = append(workerEnvironment(r.agent.Provider),
308334 "ZUT_SUBAGENT_AGENT_ID="+r.agent.ID,
@@ -353,6 +379,10 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
353379 r.agent.persistFn(r.agent)
354380 }
355381
382+ runnerDone := make(chan struct{})
383+ defer close(runnerDone)
384+ go r.stopOnContextDone(ctx, cmd, runnerDone)
385+
356386 var logErrMu sync.Mutex
357387 var logErr error
358388 var stopOnLogErr sync.Once
@@ -397,7 +427,9 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
397427 if trimmed != "" && !truncated {
398428 if ev, ok := parseEventLine(trimmed); ok {
399429 appendLog(ev)
400- updateAgentFromEvent(r.agent, ev)
430+ if persistErr := updateAgentFromEvent(r.agent, ev); persistErr != nil {
431+ sink.Transcript("error: metadata persistence failed: " + persistErr.Error())
432+ }
401433 applyEventToSink(ev, sink)
402434 // Fan prompt-level task completions up to any
403435 // subscriber on the supervised Agent. The child
@@ -460,12 +492,16 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
460492 if ee, ok := err.(*exec.ExitError); ok {
461493 exit = ee.ExitCode()
462494 }
463- if err != nil && ctx.Err() != nil {
464- appendLog(NewEvent("agent_stopped", map[string]any{"reason": "cancelled"}))
495+ if ctxErr := ctx.Err(); ctxErr != nil {
496+ reason := "cancelled"
497+ if errors.Is(ctxErr, context.DeadlineExceeded) {
498+ reason = "deadline"
499+ }
500+ appendLog(NewEvent("agent_stopped", map[string]any{"reason": reason}))
465501 if logErr := firstLogErr(); logErr != nil {
466502 return fmt.Errorf("subagent event log: %w", logErr)
467503 }
468- return ctx.Err()
504+ return ctxErr
469505 }
470506 if err != nil {
471507 appendLog(NewEvent("agent_stopped", map[string]any{"reason": "exit", "code": exit, "error": err.Error()}))
@@ -482,6 +518,48 @@ func (r *execRunner) Run(ctx context.Context, sink Sink) error {
482518 return nil
483519 }
484520
521+// stopOnContextDone stops a worker when its run context ends. A deadline gets
522+// one bounded chance to process an inbox shutdown and persist its session;
523+// explicit cancellation force-stops immediately so Run cannot remain blocked.
524+func (r *execRunner) stopOnContextDone(ctx context.Context, cmd *exec.Cmd, runnerDone <-chan struct{}) {
525+ select {
526+ case <-runnerDone:
527+ return
528+ case <-ctx.Done():
529+ }
530+ if !errors.Is(ctx.Err(), context.DeadlineExceeded) {
531+ if cmd.Cancel != nil {
532+ _ = cmd.Cancel()
533+ return
534+ }
535+ _ = killProcessGroup(cmd)
536+ return
537+ }
538+
539+ grace := r.GracePeriod
540+ if grace <= 0 {
541+ grace = 10 * time.Second
542+ }
543+ shutdownCtx, cancel := context.WithTimeout(context.Background(), grace)
544+ defer cancel()
545+ if r.agent != nil && r.agent.inbox != nil {
546+ _ = r.agent.inbox.SendCommandContext(shutdownCtx, NewCommand(
547+ CommandAgentShutdown, r.agent.ID, r.agent.CurrentTurnID(), AgentShutdownPayload{},
548+ ))
549+ }
550+
551+ select {
552+ case <-runnerDone:
553+ return
554+ case <-shutdownCtx.Done():
555+ }
556+ if cmd.Cancel != nil {
557+ _ = cmd.Cancel()
558+ return
559+ }
560+ _ = killProcessGroup(cmd)
561+}
562+
485563 func workerEnvironment(provider string) []string {
486564 // Provider credentials are resolved by the supervisor and transferred
487565 // over the child's stdin. Do not inherit API-key/token variables into the
@@ -594,9 +672,71 @@ func parseEventLine(line string) (Event, bool) {
594672 }, true
595673 }
596674
597-func updateAgentFromEvent(a *Agent, ev Event) {
675+func eventMatchesPendingResume(a *Agent, ev Event) bool {
598676 if a == nil {
599- return
677+ return false
678+ }
679+ commandID, _ := ev.Data["command_id"].(string)
680+ if commandID == "" {
681+ // Protocol-v1 turn.started events predate command identities, but a
682+ // commandless rejection cannot be safely associated with the current
683+ // pending prompt and must not consume it.
684+ code, _ := ev.Data["code"].(string)
685+ return code != "turn_rejected"
686+ }
687+ expected := a.resumePromptCommandID()
688+ return expected == "" || expected == commandID
689+}
690+
691+func eventCounter(data map[string]any, key string) (int, bool) {
692+ value, ok := data[key]
693+ if !ok {
694+ return 0, false
695+ }
696+ maxInt := int(^uint(0) >> 1)
697+ valid := func(value int64) (int, bool) {
698+ if value < 0 || value > int64(maxInt) {
699+ return 0, false
700+ }
701+ return int(value), true
702+ }
703+ switch value := value.(type) {
704+ case int:
705+ if value < 0 {
706+ return 0, false
707+ }
708+ return value, true
709+ case int64:
710+ return valid(value)
711+ case uint:
712+ if uint64(value) > uint64(maxInt) {
713+ return 0, false
714+ }
715+ return int(value), true
716+ case uint64:
717+ if value > uint64(maxInt) {
718+ return 0, false
719+ }
720+ return int(value), true
721+ case float64:
722+ if value < 0 || math.Trunc(value) != value || value >= float64(maxInt)+1 {
723+ return 0, false
724+ }
725+ return int(value), true
726+ case json.Number:
727+ parsed, err := value.Int64()
728+ if err != nil {
729+ return 0, false
730+ }
731+ return valid(parsed)
732+ default:
733+ return 0, false
734+ }
735+}
736+
737+func updateAgentFromEvent(a *Agent, ev Event) error {
738+ if a == nil {
739+ return nil
600740 }
601741 now := ev.Time
602742 if now.IsZero() {
@@ -604,25 +744,56 @@ func updateAgentFromEvent(a *Agent, ev Event) {
604744 }
605745 a.markActivity(now)
606746 persist := false
747+ notifyIdle := false
607748 switch ev.Type {
608749 case EventAgentReady, "agent_ready":
609750 a.setProcessState(ProcessAlive)
610- a.setTurnState(TurnIdle, ev.TurnID)
751+ if lifetime, ok := eventCounter(ev.Data, "lifetime_turns"); ok {
752+ if currentRun, currentOK := eventCounter(ev.Data, "current_run_turns"); currentOK {
753+ a.setTurnCounts(lifetime, currentRun)
754+ }
755+ }
756+ // A resumed worker may have a durable initial follow-up in its argv.
757+ // Keep it queued until the worker emits its own turn.started event;
758+ // treating readiness as idle here could dispatch another queued prompt
759+ // concurrently with that initial turn.
760+ if prompt, _ := a.ResumePromptInfo(); prompt == "" && a.TurnState() != TurnQueued {
761+ a.setTurnState(TurnIdle, ev.TurnID)
762+ notifyIdle = true
763+ }
611764 persist = true
612765 case EventAgentHeartbeat, "agent_heartbeat":
613766 a.setProcessState(ProcessAlive)
614767 persist = true
615- case EventTurnStarted, "turn_start":
616- turnID := ev.TurnID
617- if turnID == "" {
618- if step, ok := ev.Data["step"].(float64); ok {
619- turnID = fmt.Sprintf("turn-%d", int(step))
620- }
768+ case EventTurnStarted:
769+ if !isDelegatedTurnStart(ev) {
770+ // Provider/model-loop turn starts are nested inside one worker
771+ // message turn and are activity only.
772+ a.setProcessState(ProcessAlive)
773+ break
621774 }
775+ if !eventMatchesPendingResume(a, ev) {
776+ break
777+ }
778+ turnID := eventTurnID(ev)
622779 a.setProcessState(ProcessAlive)
780+ if lifetime, ok := eventCounter(ev.Data, "lifetime_turns"); ok {
781+ if currentRun, currentOK := eventCounter(ev.Data, "current_run_turns"); currentOK {
782+ a.setTurnCounts(lifetime, currentRun)
783+ } else {
784+ a.incrementTurnCounts()
785+ }
786+ } else {
787+ a.incrementTurnCounts()
788+ }
623789 a.setTurnState(TurnRunning, turnID)
624790 a.clearResumePrompt()
625791 persist = true
792+ case "turn_start":
793+ // Provider/model-loop turn starts are nested inside one worker message
794+ // turn. They are activity only and must not consume or reset the
795+ // worker's lifetime or current-run budget.
796+ a.setProcessState(ProcessAlive)
626797 case EventTurnResult, "turn_result":
627798 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
628799 a.setResult(result)
@@ -653,17 +824,36 @@ func updateAgentFromEvent(a *Agent, ev Event) {
653824 a.setTurnState(TurnFailed, ev.TurnID)
654825 persist = true
655826 case EventAgentIdle, "agent_idle":
827+ if lifetime, ok := eventCounter(ev.Data, "lifetime_turns"); ok {
828+ if currentRun, currentOK := eventCounter(ev.Data, "current_run_turns"); currentOK {
829+ a.setTurnCounts(lifetime, currentRun)
830+ }
831+ }
656832 a.setTurnState(TurnIdle, ev.TurnID)
833+ notifyIdle = true
657834 persist = true
658835 case EventAgentExited, "agent_exited", "agent_stopped":
659836 a.setProcessState(ProcessExited)
660837 persist = true
838+ case "error":
839+ if code, _ := ev.Data["code"].(string); code == "turn_rejected" && eventMatchesPendingResume(a, ev) {
840+ if a.rejectActiveResumePrompt() {
841+ reason, _ := ev.Data["reason"].(string)
842+ if reason == "max_turns" {
843+ a.setTurnState(TurnFailed, ev.TurnID)
844+ } else {
845+ a.setTurnState(TurnIdle, ev.TurnID)
846+ notifyIdle = true
847+ }
848+ persist = true
849+ }
850+ }
661851 case "turn_end":
662852 // Provider/tool-loop turn_end events (for example stop=tool_use)
663853 // do not carry the daemon's prompt step and are not terminal for
664854 // the delegated task. They remain in the event log, but must not
665855 // overwrite the delegated turn state or trigger persistence.
666- if _, ok := ev.Data["step"].(float64); !ok {
856+ if !isDelegatedTurnEnd(ev) {
667857 break
668858 }
669859 if message, _ := ev.Data["error"].(string); message != "" {
@@ -674,8 +864,15 @@ func updateAgentFromEvent(a *Agent, ev Event) {
674864 persist = true
675865 }
676866 if persist && a.persistFn != nil {
677- a.persistFn(a)
867+ if err := a.persistFn(a); err != nil {
868+ a.recordPersistenceError(err)
869+ return err
870+ }
871+ }
872+ if notifyIdle {
873+ a.notifyTurnIdle()
678874 }
875+ return nil
679876 }
680877
681878 // notifyPromptTurnEnd calls Agent.OnTurnEnd only for the subagent
@@ -686,7 +883,10 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
686883 if a == nil || ev.Type != "turn_end" {
687884 return
688885 }
689- step, ok := ev.Data["step"].(float64)
886+ if !isDelegatedTurnEnd(ev) {
887+ return
888+ }
889+ step, ok := eventCounter(ev.Data, "step")
690890 if !ok {
691891 return
692892 }
@@ -700,7 +900,16 @@ func notifyPromptTurnEnd(a *Agent, ev Event) {
700900 return
701901 }
702902 a.mu.Unlock()
703- go fn(int(step), errMsg)
903+ go fn(step, errMsg)
904+}
905+
906+func isAssistantStreamBoundary(eventType string) bool {
907+ switch eventType {
908+ case EventTurnStarted, "turn_start", EventTurnResult, "turn_result", EventTurnFailed, "turn_failed", "turn_end":
909+ return true
910+ default:
911+ return false
912+ }
704913 }
705914
706915 // applyEventToSink translates an Event into Sink updates. Only a
@@ -711,6 +920,17 @@ func applyEventToSink(ev Event, sink Sink) {
711920 userMessage(string)
712921 assistantMessage(string)
713922 }
923+ type streamingSink interface {
924+ assistantDelta(string)
925+ }
926+ type streamResetSink interface {
927+ resetStreamingAssistant()
928+ }
929+ if isAssistantStreamBoundary(ev.Type) {
930+ if streaming, ok := sink.(streamResetSink); ok {
931+ streaming.resetStreamingAssistant()
932+ }
933+ }
714934 appendMessage := func(text string, assistant bool) {
715935 if text == "" {
716936 return
@@ -731,6 +951,14 @@ func applyEventToSink(ev Event, sink Sink) {
731951 }
732952
733953 switch ev.Type {
954+ case "message.delta":
955+ if delta, _ := ev.Data["delta"].(string); delta != "" {
956+ if streaming, ok := sink.(streamingSink); ok {
957+ streaming.assistantDelta(delta)
958+ } else {
959+ sink.Transcript(delta)
960+ }
961+ }
734962 case "assistant_message", "user_message":
735963 var text []string
736964 if c, ok := ev.Data["content"].([]any); ok {
M packages/agent/subagents/runner_e2e_test.go +181 -19
@@ -12,25 +12,187 @@ import (
1212 "time"
1313 )
1414
15-// TestRunnerEndToEndWithStubChild is the integration test for the
16-// new daemon-mode runner. It compiles the stubchild binary in
17-// testdata/cmd/stubchild, points an execRunner at it, and drives
18-// a Supervisor through one Spawn + one SendUserTurn + Stop cycle.
19-//
20-// What this proves:
21-//
22-// - The default argv shape (subagentWorkerArgs) is one the child can
23-// actually parse — locks the shape against silent breakage.
24-// - The stdout JSONL parser ingests events and writes them to the
25-// durable log.
26-// - applyEventToSink turns events into Activity / Transcript
27-// updates the dashboard reads.
28-// - The supervisor inbox dials the child's socket and a follow-up
29-// line round-trips back as a user_message event.
30-// - Stop closes the inbox AND cancels the child's context so the
31-// stub exits cleanly.
32-//
33-// The test is skipped on platforms without unix sockets (Windows).
15+type notifyingAgentSink struct {
16+ agentSink
17+ deltas chan<- struct{}
18+}
19+
20+func (s notifyingAgentSink) assistantDelta(text string) {
21+ s.agentSink.assistantDelta(text)
22+ select {
23+ case s.deltas <- struct{}{}:
24+ default:
25+ }
26+}
27+
28+const runnerProcessTimeout = 10 * time.Second
29+
30+type controlledDeadlineContext struct{ done chan struct{} }
31+
32+func (c *controlledDeadlineContext) Deadline() (time.Time, bool) {
33+ return time.Now().Add(time.Hour), true
34+}
35+func (c *controlledDeadlineContext) Done() <-chan struct{} { return c.done }
36+func (c *controlledDeadlineContext) Err() error {
37+ select {
38+ case <-c.done:
39+ return context.DeadlineExceeded
40+ default:
41+ return nil
42+ }
43+}
44+func (c *controlledDeadlineContext) Value(any) any { return nil }
45+func (c *controlledDeadlineContext) expire() {
46+ select {
47+ case <-c.done:
48+ default:
49+ close(c.done)
50+ }
51+}
52+
53+// TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput
54+// verifies that deadline expiry shuts a worker down through its inbox rather
55+// than killing it before its terminal events can be recorded.
56+func TestExecRunnerDeadlineGracefullyStopsWorkerAndPreservesStreamedOutput(t *testing.T) {
57+ if runtime.GOOS == "windows" {
58+ t.Skip("unix sockets not supported")
59+ }
60+ if testing.Short() {
61+ t.Skip("skip end-to-end runner test in -short mode")
62+ }
63+
64+ exe := buildStubChild(t)
65+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
66+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
67+
68+ root := t.TempDir()
69+ inboxPath, err := inboxSocketPath(root, "deadline-test")
70+ if err != nil {
71+ t.Fatalf("inboxSocketPath: %v", err)
72+ }
73+ a := &Agent{
74+ ID: "deadline-test",
75+ Task: "long-running task",
76+ Dir: root,
77+ SessionPath: filepath.Join(root, "session.jsonl"),
78+ InboxPath: inboxPath,
79+ EventLogPath: filepath.Join(root, "events.jsonl"),
80+ }
81+ a.inbox = NewInbox(a.InboxPath)
82+ t.Cleanup(func() { _ = a.inbox.Close() })
83+
84+ r := &execRunner{
85+ agent: a,
86+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
87+ GracePeriod: time.Second,
88+ }
89+ ctx := &controlledDeadlineContext{done: make(chan struct{})}
90+ defer ctx.expire()
91+ deltas := make(chan struct{}, 1)
92+ runDone := make(chan error, 1)
93+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
94+ select {
95+ case <-deltas:
96+ case <-time.After(runnerProcessTimeout):
97+ t.Fatal("worker did not emit its initial delta")
98+ }
99+ ctx.expire()
100+ select {
101+ case err := <-runDone:
102+ if !errors.Is(err, context.DeadlineExceeded) {
103+ t.Fatalf("Run error = %v, want context deadline exceeded", err)
104+ }
105+ case <-time.After(runnerProcessTimeout):
106+ t.Fatal("Run did not return after deadline expiry")
107+ }
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()
127+ if snapshot.LastAssistant != "partial answer" {
128+ t.Fatalf("live partial output = %q, want %q", snapshot.LastAssistant, "partial answer")
129+ }
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+}
138+
139+func TestExecRunnerCancelsBlockedWorker(t *testing.T) {
140+ if runtime.GOOS == "windows" {
141+ t.Skip("unix sockets not supported")
142+ }
143+ if testing.Short() {
144+ t.Skip("skip end-to-end runner test in -short mode")
145+ }
146+
147+ exe := buildStubChild(t)
148+ t.Setenv("ZUT_STUB_PROTOCOL", "1")
149+ t.Setenv("ZUT_STUB_BLOCK_INITIAL", "1")
150+
151+ root := t.TempDir()
152+ inboxPath, err := inboxSocketPath(root, "cancel-test")
153+ if err != nil {
154+ t.Fatalf("inboxSocketPath: %v", err)
155+ }
156+ a := &Agent{
157+ ID: "cancel-test",
158+ Task: "blocked task",
159+ Dir: root,
160+ SessionPath: filepath.Join(root, "session.jsonl"),
161+ InboxPath: inboxPath,
162+ EventLogPath: filepath.Join(root, "events.jsonl"),
163+ }
164+ a.inbox = NewInbox(a.InboxPath)
165+ t.Cleanup(func() { _ = a.inbox.Close() })
166+
167+ r := &execRunner{
168+ agent: a,
169+ Command: subagentWorkerArgs(subagentWorkerArgsOpts{Exe: exe, Dir: a.Dir, SessionPath: a.SessionPath, InboxPath: a.InboxPath, Task: a.Task}),
170+ }
171+ ctx, cancel := context.WithCancel(context.Background())
172+ defer cancel()
173+ deltas := make(chan struct{}, 1)
174+ runDone := make(chan error, 1)
175+ go func() { runDone <- r.Run(ctx, notifyingAgentSink{agentSink: agentSink{a: a}, deltas: deltas}) }()
176+
177+ select {
178+ case <-deltas:
179+ case <-time.After(runnerProcessTimeout):
180+ t.Fatal("worker did not emit its initial delta")
181+ }
182+ cancel()
183+ select {
184+ case err := <-runDone:
185+ if !errors.Is(err, context.Canceled) {
186+ t.Fatalf("Run error = %v, want context canceled", err)
187+ }
188+ case <-time.After(runnerProcessTimeout):
189+ t.Fatal("Run did not return after context cancellation")
190+ }
191+}
192+
193+// TestRunnerEndToEndWithStubChild drives the daemon-mode runner through an
194+// initial turn, a follow-up, and a graceful stop using the stub child. It is
195+// skipped on platforms without Unix sockets.
34196 func TestRunnerEndToEndWithStubChild(t *testing.T) {
35197 if runtime.GOOS == "windows" {
36198 t.Skip("unix sockets not supported")
M packages/agent/subagents/runner_test.go +268 -2
@@ -58,6 +58,106 @@ func TestCredentialStdinHelperProcess(t *testing.T) {
5858 os.Exit(0)
5959 }
6060
61+// TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage verifies
62+// that the durable event stream has the same visible result while live and
63+// after history replay.
64+func TestMessageDeltaStreamsAndReplaysWithoutDuplicatingFinalMessage(t *testing.T) {
65+ events := []Event{
66+ NewEvent("message.delta", map[string]any{"delta": "partial "}),
67+ NewEvent("message.delta", map[string]any{"delta": "answer"}),
68+ NewEvent("assistant_message", map[string]any{"content": []any{
69+ map[string]any{"type": "text", "text": "partial answer"},
70+ }}),
71+ }
72+
73+ live := &Agent{transcriptLoaded: true}
74+ for _, event := range events {
75+ applyEventToSink(event, agentSink{a: live})
76+ }
77+ if got := strings.Join(live.Transcript(), "\n"); got != "partial answer" {
78+ t.Fatalf("live transcript = %q, want final assistant message once", got)
79+ }
80+ if got := live.Snapshot().LastAssistant; got != "partial answer" {
81+ t.Fatalf("live assistant output = %q, want %q", got, "partial answer")
82+ }
83+
84+ replayed := &Agent{transcriptLoaded: true}
85+ for _, event := range events {
86+ replayEventTranscript(replayed, event)
87+ }
88+ if got := strings.Join(replayed.Transcript(), "\n"); got != "partial answer" {
89+ t.Fatalf("replayed transcript = %q, want final assistant message once", got)
90+ }
91+}
92+
93+func TestMessageDeltaBoundsLiveProjection(t *testing.T) {
94+ a := &Agent{maxOutputBytes: 32, maxOutputLines: 100, transcriptLoaded: true}
95+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: a})
96+ applyEventToSink(NewEvent("message.delta", map[string]any{"delta": "discarded"}), agentSink{a: a})
97+
98+ a.mu.Lock()
99+ streamed := a.streamingAssistantText
100+ truncated := a.streamingAssistantTruncated
101+ a.mu.Unlock()
102+ if !truncated {
103+ t.Fatal("streamed output was not marked truncated")
104+ }
105+ if got := len([]byte(streamed)); got > 32 {
106+ t.Fatalf("streamed output length = %d, want at most 32", got)
107+ }
108+ snapshot := a.Snapshot()
109+ if got := snapshot.LastAssistant; got != streamed {
110+ t.Fatalf("live assistant output = %q, want bounded stream %q", got, streamed)
111+ }
112+ if !snapshot.OutputTruncated {
113+ t.Fatal("snapshot did not expose truncated streamed output")
114+ }
115+
116+ events := []Event{
117+ NewEvent(EventMessageDelta, map[string]any{"delta": "first partial"}),
118+ NewEvent("turn_end", map[string]any{"stop": "cancelled"}),
119+ NewEvent(EventMessageDelta, map[string]any{"delta": "second partial"}),
120+ }
121+ want := "first partial\nsecond partial"
122+ assertProjection := func(t *testing.T, agent *Agent) {
123+ t.Helper()
124+ if got := strings.Join(agent.Transcript(), "\n"); got != want {
125+ t.Fatalf("transcript = %q, want %q", got, want)
126+ }
127+ agent.mu.Lock()
128+ truncated := agent.streamingAssistantTruncated
129+ agent.mu.Unlock()
130+ if truncated {
131+ t.Fatal("fresh second-turn stream was incorrectly truncated")
132+ }
133+ }
134+
135+ live := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
136+ for _, event := range events {
137+ applyEventToSink(event, agentSink{a: live})
138+ }
139+ assertProjection(t, live)
140+
141+ replayed := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
142+ replayTranscriptIntoAgent(replayed, events)
143+ assertProjection(t, replayed)
144+
145+ legacy := &Agent{maxOutputBytes: 100, maxOutputLines: 100, transcriptLoaded: true}
146+ replayEventsIntoAgent(legacy, events)
147+ assertProjection(t, legacy)
148+
149+ truncatedTurn := &Agent{maxOutputBytes: 16, maxOutputLines: 100, transcriptLoaded: true}
150+ applyEventToSink(NewEvent(EventMessageDelta, map[string]any{"delta": strings.Repeat("x", 64)}), agentSink{a: truncatedTurn})
151+ applyEventToSink(NewEvent("turn_end", map[string]any{"stop": "cancelled"}), agentSink{a: truncatedTurn})
152+ truncatedTurn.mu.Lock()
153+ streamText := truncatedTurn.streamingAssistantText
154+ streamTruncated := truncatedTurn.streamingAssistantTruncated
155+ truncatedTurn.mu.Unlock()
156+ if streamText != "" || streamTruncated {
157+ t.Fatalf("stream state after turn boundary = (%q, %v), want empty and not truncated", streamText, streamTruncated)
158+ }
159+}
160+
61161 func TestWorkerEnvironmentRedactsProviderSecrets(t *testing.T) {
62162 t.Setenv("OPENAI_API_KEY", "openai-secret")
63163 t.Setenv("CUSTOM_PROVIDER_API_KEY", "custom-secret")
@@ -383,11 +483,69 @@ func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
383483 }
384484 }
385485
486+func TestReadyDoesNotMarkInitialDelegatedTurnIdle(t *testing.T) {
487+ a := &Agent{Task: "initial delegated task"}
488+ a.setTurnState(TurnQueued, "")
489+
490+ if err := updateAgentFromEvent(a, NewEvent(EventAgentReady, map[string]any{"lifetime_turns": 0, "current_run_turns": 0})); err != nil {
491+ t.Fatal(err)
492+ }
493+ if got := a.TurnState(); got != TurnQueued {
494+ t.Fatalf("ready changed turn state to %s, want %s", got, TurnQueued)
495+ }
496+ if err := updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
497+ "turn_id": "turn-1",
498+ "lifetime_turns": 1,
499+ "current_run_turns": 1,
500+ })); err != nil {
501+ t.Fatal(err)
502+ }
503+ if got := a.TurnState(); got != TurnRunning {
504+ t.Fatalf("turn.started changed turn state to %s, want %s", got, TurnRunning)
505+ }
506+}
507+
508+func TestUpdateAgentFromEventIgnoresStaleRejectedFollowUp(t *testing.T) {
509+ a := &Agent{}
510+ a.setResumePrompt("current prompt", time.Now())
511+ if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
512+ "code": "turn_rejected", "reason": "busy", "command_id": "stale-command",
513+ })); err != nil {
514+ t.Fatal(err)
515+ }
516+ if prompt, _ := a.ResumePromptInfo(); prompt != "current prompt" {
517+ t.Fatalf("stale rejection changed active prompt to %q", prompt)
518+ }
519+}
520+
521+func TestUpdateAgentFromEventRecoversRejectedFollowUp(t *testing.T) {
522+ a := &Agent{}
523+ a.setResumePrompt("retry me", time.Now())
524+ a.setTurnState(TurnQueued, "turn-1")
525+ if err := updateAgentFromEvent(a, NewEvent("error", map[string]any{
526+ "code": "turn_rejected",
527+ "reason": "busy",
528+ "command_id": a.resumePromptCommandID(),
529+ })); err != nil {
530+ t.Fatal(err)
531+ }
532+ if got := a.TurnState(); got != TurnIdle {
533+ t.Fatalf("rejected turn state = %s, want %s", got, TurnIdle)
534+ }
535+ if prompt, _ := a.ResumePromptInfo(); prompt != "" {
536+ t.Fatalf("rejected prompt remained active: %q", prompt)
537+ }
538+ queued := a.resumePromptQueueSnapshot()
539+ if len(queued) != 1 || queued[0].Prompt != "retry me" {
540+ t.Fatalf("rejected prompt queue = %+v, want retry me", queued)
541+ }
542+}
543+
386544 func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
387545 a := &Agent{}
388546 a.setTurnState(TurnRunning, "turn-1")
389547 persisted := 0
390- a.persistFn = func(*Agent) { persisted++ }
548+ a.persistFn = func(*Agent) error { persisted++; return nil }
391549
392550 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"stop": "tool_use"}))
393551
@@ -405,7 +563,7 @@ func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
405563 func TestUpdateAgentFromEventPersistsTerminalTurnEndWithStep(t *testing.T) {
406564 a := &Agent{}
407565 persisted := 0
408- a.persistFn = func(*Agent) { persisted++ }
566+ a.persistFn = func(*Agent) error { persisted++; return nil }
409567
410568 updateAgentFromEvent(a, NewEvent("turn_end", map[string]any{"step": float64(2), "error": "boom"}))
411569
@@ -481,6 +639,114 @@ func TestSetOnTurnEndReplaysPendingNoticesInArrivalOrder(t *testing.T) {
481639 }
482640 }
483641
642+func TestUpdateAgentFromEventDoesNotCountProviderTurnStarts(t *testing.T) {
643+ a := &Agent{}
644+ a.setTurnCounts(4, 2)
645+ a.setTurnState(TurnRunning, "turn-1")
646+ persisted := 0
647+ a.persistFn = func(*Agent) error { persisted++; return nil }
648+
649+ updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{"step": float64(7), "nested_turn": true}))
650+
651+ if got := a.LifetimeTurnsValue(); got != 4 {
652+ t.Fatalf("lifetime turns = %d, want 4", got)
653+ }
654+ if got := a.CurrentRunTurnsValue(); got != 2 {
655+ t.Fatalf("current-run turns = %d, want 2", got)
656+ }
657+ if got := a.TurnState(); got != TurnRunning {
658+ t.Fatalf("turn state = %s, want %s", got, TurnRunning)
659+ }
660+ if persisted != 0 {
661+ t.Fatalf("provider turn start persisted %d times, want 0", persisted)
662+ }
663+}
664+
665+func TestUpdateAgentFromEventPersistsCanonicalTurnCounters(t *testing.T) {
666+ stateDir := t.TempDir()
667+ a := &Agent{ID: "counter-agent", stateDir: stateDir}
668+ a.persistFn = func(agent *Agent) error {
669+ if err := writeAgentMeta(stateDir, agent); err != nil {
670+ t.Errorf("write agent metadata: %v", err)
671+ return err
672+ }
673+ return nil
674+ }
675+
676+ updateAgentFromEvent(a, NewEvent(EventTurnStarted, map[string]any{
677+ "turn_id": "turn-3",
678+ "lifetime_turns": 3,
679+ "current_run_turns": 1,
680+ }))
681+
682+ meta, err := readAgentMeta(stateDir)
683+ if err != nil {
684+ t.Fatal(err)
685+ }
686+ if meta.LifetimeTurns != 3 || meta.CurrentRunTurns != 1 {
687+ t.Fatalf("persisted counters = (%d, %d), want (3, 1)", meta.LifetimeTurns, meta.CurrentRunTurns)
688+ }
689+ if got := a.Snapshot(); got.LifetimeTurns != 3 || got.CurrentRunTurns != 1 {
690+ t.Fatalf("snapshot counters = (%d, %d), want (3, 1)", got.LifetimeTurns, got.CurrentRunTurns)
691+ }
692+}
693+
694+func TestRecordPersistenceErrorPreservesFirstFailure(t *testing.T) {
695+ a := &Agent{}
696+ a.recordPersistenceError(fmt.Errorf("first failure"))
697+ a.recordPersistenceError(fmt.Errorf("later failure"))
698+ if got := a.Snapshot().Err; !strings.Contains(got, "first failure") || strings.Contains(got, "later failure") {
699+ t.Fatalf("recorded persistence error = %q, want only first failure", got)
700+ }
701+}
702+
703+func TestRestoreResumePromptPreservesCommandIdentity(t *testing.T) {
704+ a := &Agent{}
705+ a.setResumePrompt("old prompt", time.Now())
706+ oldID := a.resumePromptCommandID()
707+ previous := a.setResumePrompt("new prompt", time.Now())
708+ a.restoreResumePrompt(previous)
709+ if got := a.resumePromptCommandID(); got != oldID {
710+ t.Fatalf("restored command ID = %q, want %q", got, oldID)
711+ }
712+}
713+
714+func TestEventMatchesPendingResumeRejectsIdentityLessRejection(t *testing.T) {
715+ a := &Agent{resumePromptID: "command-1"}
716+ if eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected"}}) {
717+ t.Fatal("identity-less turn rejection matched a pending prompt")
718+ }
719+ if !eventMatchesPendingResume(a, Event{Type: EventTurnStarted, Data: map[string]any{}}) {
720+ t.Fatal("identity-less legacy turn.started did not match a pending prompt")
721+ }
722+ if !eventMatchesPendingResume(a, Event{Type: "error", Data: map[string]any{"code": "turn_rejected", "command_id": "command-1"}}) {
723+ t.Fatal("matching turn rejection did not match a pending prompt")
724+ }
725+}
726+
727+func TestEventCounterRejectsNegativeFractionalAndOverflowValues(t *testing.T) {
728+ cases := []struct {
729+ name string
730+ value any
731+ ok bool
732+ }{
733+ {name: "valid integer", value: float64(3), ok: true},
734+ {name: "negative integer", value: int64(-1)},
735+ {name: "fractional float", value: 1.5},
736+ {name: "negative float", value: -1.0},
737+ {name: "fractional json number", value: json.Number("1.5")},
738+ {name: "overflow float", value: float64(^uint(0) >> 1)},
739+ }
740+ for _, tc := range cases {
741+ t.Run(tc.name, func(t *testing.T) {
742+ got, ok := eventCounter(map[string]any{"counter": tc.value}, "counter")
743+ if ok != tc.ok {
744+ t.Fatalf("eventCounter ok = %t, want %t (value %v, got %d)", ok, tc.ok, tc.value, got)
745+ }
746+ })
747+ }
748+}
749+
484750 func indexOf(xs []string, want string) int {
485751 for i, x := range xs {
486752 if x == want {
M packages/agent/subagents/scheduler.go +17 -6
@@ -2,6 +2,7 @@ package subagents
22
33 import (
44 "context"
5+ "errors"
56 "fmt"
67 "os"
78 "path/filepath"
@@ -184,7 +185,13 @@ func (f *Supervisor) schedule() {
184185 a.activity = "starting"
185186 a.mu.Unlock()
186187 a.setProcessState(ProcessStarting)
187- a.setTurnState(TurnIdle, "")
188+ prompt, _ := a.ResumePromptInfo()
189+ initialTurn := (!a.Resuming && strings.TrimSpace(a.Task) != "") || prompt != ""
190+ if initialTurn {
191+ a.setTurnState(TurnQueued, "")
192+ } else {
193+ a.setTurnState(TurnIdle, "")
194+ }
188195 a.incrementAttempt()
189196
190197 f.active++
@@ -374,13 +381,17 @@ func (f *Supervisor) releaseCapacity(a *Agent) {
374381 f.schedule()
375382 }
376383
377-func (f *Supervisor) persistAgent(a *Agent) {
384+func (f *Supervisor) persistAgent(a *Agent) error {
378385 if a == nil {
379- return
386+ return errors.New("subagents: nil agent")
380387 }
381388 stateDir := a.stateDirectory(f.cfg.Root)
382- if _, err := os.Stat(stateDir); err != nil {
383- return
389+ if err := writeAgentMeta(stateDir, a); err != nil {
390+ // Lifecycle callbacks cannot all return an error to their caller. Keep
391+ // the failure visible in the agent snapshot instead of silently
392+ // allowing a queued prompt or counter to disappear on restart.
393+ a.recordPersistenceError(err)
394+ return err
384395 }
385- _ = writeAgentMeta(stateDir, a)
396+ return nil
386397 }
M packages/agent/subagents/supervisor.go +14 -3
@@ -155,7 +155,11 @@ func New(cfg Config) *Supervisor {
155155 }
156156 if cfg.NewRunner == nil {
157157 cfg.NewRunner = func(a *Agent) Runner {
158- return &execRunner{agent: a, resolveCredential: cfg.ResolveCredential}
158+ return &execRunner{
159+ agent: a,
160+ resolveCredential: cfg.ResolveCredential,
161+ GracePeriod: cfg.Policy.CancelGracePeriod,
162+ }
159163 }
160164 }
161165 cfg.Policy.normalize()
@@ -543,6 +547,7 @@ func (f *Supervisor) SpawnReq(ctx context.Context, req SpawnRequest) (*Agent, er
543547 }
544548 a.ctx, a.cancel = runCtx, cancel
545549 a.persistFn = f.persistAgent
550+ a.setOnTurnIdle(func() { f.dispatchQueuedResumeWithTimeout(a) })
546551 a.workspaceCleanup = func() error { return workspace.Cleanup(context.Background()) }
547552 a.workspaceCapture = func() (WorkspaceCapture, error) { return workspace.Capture(context.Background()) }
548553 a.runner = f.cfg.NewRunner(a)
@@ -1075,6 +1080,8 @@ type AgentSnapshot struct {
10751080 ProcessState ProcessState
10761081 TurnState TurnState
10771082 CurrentTurnID string
1083+ LifetimeTurns int
1084+ CurrentRunTurns int
10781085 Attempt int
10791086 Activity string
10801087 Started time.Time
@@ -1129,6 +1136,7 @@ func (a *Agent) Snapshot() AgentSnapshot {
11291136 status := a.status
11301137 activity := a.activity
11311138 lastAssistant := a.lastAssistant
1139+ outputTruncated := a.outputTruncated || a.streamingAssistantTruncated
11321140 finished := a.finished
11331141 a.mu.Unlock()
11341142
@@ -1136,6 +1144,8 @@ func (a *Agent) Snapshot() AgentSnapshot {
11361144 processState := a.processState
11371145 turnState := a.turnState
11381146 currentTurnID := a.currentTurnID
1147+ lifetimeTurns := a.LifetimeTurns
1148+ currentRunTurns := a.CurrentRunTurns
11391149 attempt := a.Attempt
11401150 lastActivity := a.lastActivity
11411151 updatedAt := a.updatedAt
@@ -1143,12 +1153,11 @@ func (a *Agent) Snapshot() AgentSnapshot {
11431153 resultRef := a.resultRef
11441154 patchRef := a.patchRef
11451155 changedFiles := append([]string(nil), a.changedFiles...)
1146- outputTruncated := a.outputTruncated
11471156 a.lifecycleMu.Unlock()
11481157 return AgentSnapshot{
11491158 ID: a.ID, Task: a.Task, Dir: a.Dir,
11501159 Status: status, ProcessState: processState, TurnState: turnState,
1151- CurrentTurnID: currentTurnID, Attempt: attempt, Activity: activity,
1160+ CurrentTurnID: currentTurnID, LifetimeTurns: lifetimeTurns, CurrentRunTurns: currentRunTurns, Attempt: attempt, Activity: activity,
11521161 Started: a.Started, Finished: finished, CreatedAt: a.Started, UpdatedAt: updatedAt, LastActivity: lastActivity,
11531162 Err: errStr, Tail: tail, Lines: lines,
11541163 LastAssistant: lastAssistant, Result: result, ResultRef: resultRef,
@@ -1208,6 +1217,8 @@ func (s agentSink) Transcript(chunk string) {
12081217 }
12091218 func (s agentSink) userMessage(text string) { s.a.appendUserMessage(text) }
12101219 func (s agentSink) assistantMessage(text string) { s.a.appendAssistantMessage(text) }
1220+func (s agentSink) assistantDelta(text string) { s.a.appendAssistantDelta(text) }
1221+func (s agentSink) resetStreamingAssistant() { s.a.resetStreamingAssistant() }
12111222
12121223 func truncate(s string, n int) string {
12131224 runes := []rune(s)
M packages/agent/subagents/testdata/cmd/stubchild/main.go +20 -1
@@ -72,8 +72,20 @@ func main() {
7272
7373 // Initial task lives in the positional. Process it as the first
7474 // user turn so the supervisor's "initial task" path is exercised.
75+ initialTurnOpen := false
7576 if task := flag.Arg(0); task != "" {
76- runTurn(emit, task, 0)
77+ if os.Getenv("ZUT_STUB_BLOCK_INITIAL") == "1" {
78+ initialTurnOpen = true
79+ emit("turn_start", map[string]any{"step": 0})
80+ emit("user_message", map[string]any{
81+ "content": []any{
82+ map[string]any{"type": "text", "text": task},
83+ },
84+ })
85+ emit("message.delta", map[string]any{"delta": "partial answer"})
86+ } else {
87+ runTurn(emit, task, 0)
88+ }
7789 }
7890
7991 turn := 1
@@ -91,10 +103,17 @@ func main() {
91103 if parseErr == nil {
92104 switch command.Type {
93105 case subagents.CommandAgentShutdown:
106+ if initialTurnOpen {
107+ emit("turn_end", map[string]any{"stop": "cancelled"})
108+ initialTurnOpen = false
109+ }
94110 emit("agent_stopped", map[string]any{"reason": "shutdown"})
95111 _ = c.Close()
96112 return
97113 case subagents.CommandTurnCancel:
114+ if initialTurnOpen {
115+ initialTurnOpen = false
116+ }
98117 emit("turn_end", map[string]any{"stop": "cancelled"})
99118 case subagents.CommandTurnStart:
100119 var payload subagents.TurnStartPayload
A packages/agent/subagents/turn_classification.go +93 -0
@@ -0,0 +1,93 @@
1+package subagents
2+
3+import "fmt"
4+
5+// turnEventClass identifies the boundary that owns a turn event. The worker
6+// daemon's turn.started event is the delegated message boundary; provider
7+// turn_start events are nested model-loop activity. New workers carry stable
8+// lifetime/current counters on the delegated boundary and nested_turn on the
9+// provider boundary. The fallbacks preserve protocol-v1 logs, which used
10+// turn.started without counters for delegated turns and turn_start for nested
11+// turns.
12+type turnEventClass uint8
13+
14+const (
15+ turnEventUnknown turnEventClass = iota
16+ turnEventDelegated
17+ turnEventNested
18+)
19+
20+func classifyTurnEvent(ev Event) turnEventClass {
21+ if nested, ok := ev.Data["nested_turn"].(bool); ok && nested {
22+ return turnEventNested
23+ }
24+ switch ev.Type {
25+ case "turn_start":
26+ return turnEventNested
27+ case EventTurnStarted:
28+ // v1 turn.started without counters is the delegated boundary. A
29+ // current worker marks nested canonical events explicitly above.
30+ return turnEventDelegated
31+ case "turn_end":
32+ if _, ok := eventCounter(ev.Data, "step"); ok {
33+ return turnEventDelegated
34+ }
35+ return turnEventNested
36+ }
37+ return turnEventUnknown
38+}
39+
40+func isDelegatedTurnStart(ev Event) bool {
41+ return ev.Type == EventTurnStarted && classifyTurnEvent(ev) == turnEventDelegated
42+}
43+
44+// IsDelegatedTurnStart is shared with the worker's replay bootstrap so live,
45+// replayed, and protocol-v1 event handling use the same boundary rules.
46+func IsDelegatedTurnStart(ev Event) bool {
47+ return isDelegatedTurnStart(ev)
48+}
49+
50+func isDelegatedTurnEnd(ev Event) bool {
51+ return ev.Type == "turn_end" && classifyTurnEvent(ev) == turnEventDelegated
52+}
53+
54+func eventTurnID(ev Event) string {
55+ if ev.TurnID != "" {
56+ return ev.TurnID
57+ }
58+ if step, ok := eventCounter(ev.Data, "step"); ok {
59+ return fmt.Sprintf("turn-%d", step)
60+ }
61+ return ""
62+}
63+
64+func userMessageText(ev Event) string {
65+ if ev.Type != "user_message" {
66+ return ""
67+ }
68+ content := ev.Data["content"]
69+ var text string
70+ appendBlock := func(fields map[string]any) {
71+ if typ, _ := fields["type"].(string); typ == "text" {
72+ if value, _ := fields["text"].(string); value != "" {
73+ if text != "" {
74+ text += "\n"
75+ }
76+ text += value
77+ }
78+ }
79+ }
80+ switch blocks := content.(type) {
81+ case []any:
82+ for _, block := range blocks {
83+ if fields, ok := block.(map[string]any); ok {
84+ appendBlock(fields)
85+ }
86+ }
87+ case []map[string]any:
88+ for _, fields := range blocks {
89+ appendBlock(fields)
90+ }
91+ }
92+ return text
93+}
A packages/agent/subagents/turn_classification_test.go +63 -0
@@ -0,0 +1,63 @@
1+package subagents
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestTurnClassificationUsesStableBoundaryFieldsAcrossProtocolVersions(t *testing.T) {
9+ cases := []struct {
10+ name string
11+ event Event
12+ class turnEventClass
13+ delegated bool
14+ }{
15+ {
16+ name: "current delegated",
17+ event: NewEvent(EventTurnStarted, map[string]any{
18+ "lifetime_turns": 4,
19+ "current_run_turns": 2,
20+ }),
21+ class: turnEventDelegated,
22+ delegated: true,
23+ },
24+ {
25+ name: "current nested",
26+ event: NewEvent(EventTurnStarted, map[string]any{
27+ "nested_turn": true,
28+ "step": 3,
29+ }),
30+ class: turnEventNested,
31+ },
32+ {
33+ name: "protocol v1 delegated fallback",
34+ event: Event{Type: EventTurnStarted, Version: 1, Time: time.Now()},
35+ class: turnEventDelegated,
36+ delegated: true,
37+ },
38+ {
39+ name: "protocol v1 nested turn_start",
40+ event: Event{Type: "turn_start", Version: 1, Time: time.Now()},
41+ class: turnEventNested,
42+ },
43+ }
44+ for _, tc := range cases {
45+ t.Run(tc.name, func(t *testing.T) {
46+ if got := classifyTurnEvent(tc.event); got != tc.class {
47+ t.Fatalf("classifyTurnEvent = %d, want %d", got, tc.class)
48+ }
49+ if got := isDelegatedTurnStart(tc.event); got != tc.delegated {
50+ t.Fatalf("isDelegatedTurnStart = %v, want %v", got, tc.delegated)
51+ }
52+ })
53+ }
54+}
55+
56+func TestTurnClassificationKeepsNestedAndDelegatedEndsSeparate(t *testing.T) {
57+ if isDelegatedTurnEnd(NewEvent("turn_end", map[string]any{"stop": "tool_use"})) {
58+ t.Fatal("provider turn_end was classified as delegated")
59+ }
60+ if !isDelegatedTurnEnd(NewEvent("turn_end", map[string]any{"step": 2})) {
61+ t.Fatal("worker turn_end was not classified as delegated")
62+ }
63+}
M packages/agent/tools/create_worktree.go +9 -7
@@ -19,11 +19,12 @@ import (
1919 )
2020
2121 const (
22- worktreesIgnoreEntry = "/.worktrees/"
23- defaultWorktreeRoot = ".worktrees"
24- worktreeConfigKey = "zut.worktrees.path"
25- maxCreateWorktreeGitOutput = 64 * 1024
26- maxCreateWorktreeIgnoreBytes = 1 << 20
22+ worktreesIgnoreEntry = "/.worktrees/"
23+ worktreesUnanchoredIgnoreEntry = ".worktrees/"
24+ defaultWorktreeRoot = ".worktrees"
25+ worktreeConfigKey = "zut.worktrees.path"
26+ maxCreateWorktreeGitOutput = 64 * 1024
27+ maxCreateWorktreeIgnoreBytes = 1 << 20
2728 )
2829
2930 var createWorktreeOperationMu sync.Mutex
@@ -358,7 +359,7 @@ func (t *CreateWorktreeTool) plan(ctx context.Context, raw json.RawMessage) (cre
358359 if err != nil {
359360 return createWorktreePlan{}, fmt.Errorf("create_worktree: read .gitignore: %w", err)
360361 }
361- ignoreAction = "already contains " + worktreesIgnoreEntry
362+ ignoreAction = "already ignores " + defaultWorktreeRoot + "/"
362363 if ignore.changed {
363364 ignoreAction = "add " + worktreesIgnoreEntry
364365 }
@@ -818,7 +819,8 @@ func newWorktreesIgnoreUpdate(path string) (worktreesIgnoreUpdate, error) {
818819 return worktreesIgnoreUpdate{}, err
819820 }
820821 for _, line := range strings.Split(string(contents), "\n") {
821- if strings.TrimSuffix(line, "\r") == worktreesIgnoreEntry {
822+ switch strings.TrimSuffix(line, "\r") {
823+ case worktreesIgnoreEntry, worktreesUnanchoredIgnoreEntry:
822824 return worktreesIgnoreUpdate{path: path, before: contents, existed: existed, mode: mode}, nil
823825 }
824826 }
M packages/agent/tools/create_worktree_test.go +70 -0
@@ -431,6 +431,76 @@ func TestExecuteCreateWorktreePlanRollsBackBootstrapConfigOnIgnoreFailure(t *tes
431431 }
432432 }
433433
434+func TestCreateWorktreeAcceptsViableGitignoreEntries(t *testing.T) {
435+ if _, err := exec.LookPath("git"); err != nil {
436+ t.Skip("git is required")
437+ }
438+ for _, test := range []struct {
439+ name string
440+ entry string
441+ }{
442+ {name: "root-anchored", entry: worktreesIgnoreEntry},
443+ {name: "unanchored", entry: worktreesUnanchoredIgnoreEntry},
444+ } {
445+ t.Run(test.name, func(t *testing.T) {
446+ repo := initWorktreeTestRepo(t)
447+ ignorePath := filepath.Join(repo, ".gitignore")
448+ before := []byte(test.entry + "\n")
449+ if err := os.WriteFile(ignorePath, before, 0o644); err != nil {
450+ t.Fatal(err)
451+ }
452+ tool := newCreateWorktreeTestTool(repo)
453+ args := mustJSON(t, map[string]string{
454+ "branch": "feature/" + test.name + "-ignore",
455+ "bootstrap_root": defaultWorktreeRoot,
456+ })
457+
458+ result, err := tool.Execute(context.Background(), args, nil)
459+ if err != nil {
460+ t.Fatal(err)
461+ }
462+ text := result.Content[0].(provider.TextBlock).Text
463+ if !strings.Contains(text, ".gitignore: already ignores .worktrees/") {
464+ t.Fatalf("result did not recognize existing ignore entry:\n%s", text)
465+ }
466+ ignore, err := os.ReadFile(ignorePath)
467+ if err != nil {
468+ t.Fatal(err)
469+ }
470+ if string(ignore) != string(before) {
471+ t.Fatalf(".gitignore changed from %q to %q", before, ignore)
472+ }
473+ })
474+ }
475+}
476+
477+func TestNewWorktreesIgnoreUpdateRejectsLookalikeEntries(t *testing.T) {
478+ for _, entry := range []string{
479+ "!.worktrees/\n",
480+ "nested/.worktrees/\n",
481+ "#.worktrees/\n",
482+ ".worktrees-other/\n",
483+ } {
484+ t.Run(strings.TrimSpace(entry), func(t *testing.T) {
485+ ignorePath := filepath.Join(t.TempDir(), ".gitignore")
486+ if err := os.WriteFile(ignorePath, []byte(entry), 0o644); err != nil {
487+ t.Fatal(err)
488+ }
489+
490+ update, err := newWorktreesIgnoreUpdate(ignorePath)
491+ if err != nil {
492+ t.Fatal(err)
493+ }
494+ if !update.changed {
495+ t.Fatalf("entry %q was treated as ignoring the root .worktrees directory", strings.TrimSpace(entry))
496+ }
497+ if got, want := string(update.after), entry+worktreesIgnoreEntry+"\n"; got != want {
498+ t.Fatalf("updated .gitignore = %q, want %q", got, want)
499+ }
500+ })
501+ }
502+}
503+
434504 func TestCreateWorktreePreservesGitignoreLineEndings(t *testing.T) {
435505 if _, err := exec.LookPath("git"); err != nil {
436506 t.Skip("git is required")
M packages/agent/tools/subagent_resume.go +8 -6
@@ -10,10 +10,12 @@ import (
1010 "github.com/bnema/zut/packages/core"
1111 )
1212
13-// SubagentResumeTool gives a completed or stopped sub-agent a follow-up turn
14-// while preserving its existing session context. For an idle live worker, the
15-// follow-up is delivered directly. For a terminal worker, the supervisor
16-// restarts the retained session with the follow-up as its initial turn.
13+// SubagentResumeTool gives a sub-agent a follow-up turn while preserving its
14+// existing session context. Every explicit follow-up starts a fresh max_turns
15+// budget. For an idle live worker, the follow-up is delivered directly; for an
16+// active live worker, it is queued for the next available message turn; for a
17+// terminal worker, the supervisor restarts the retained session with the
18+// follow-up as its initial turn.
1719 type SubagentResumeTool struct {
1820 Supervisor *subagents.Supervisor
1921 Enabled func() bool
@@ -48,7 +50,7 @@ const subagentResumeSchema = `{
4850 func (t *SubagentResumeTool) Name() string { return "subagent_resume" }
4951
5052 func (t *SubagentResumeTool) Description() string {
51- return "Send a follow-up to an idle sub-agent, or restart a stopped one with its prior session context preserved."
53+ return "Continue a sub-agent with a fresh turn budget and its retained session; deliver immediately when idle, queue while active, or restart a stopped worker."
5254 }
5355
5456 func (t *SubagentResumeTool) Schema() json.RawMessage {
@@ -68,7 +70,7 @@ func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _
6870 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
6971 }
7072 if t.Enabled == nil || !t.Enabled() {
71- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.")
73+ return protocolToolError(prefix + ": subagent management is unavailable in this mode")
7274 }
7375
7476 var args subagentResumeArgs
M packages/agent/tools/subagent_spawn.go +8 -10
@@ -18,18 +18,16 @@ import (
1818 // turn continues uninterrupted. The user can monitor / chat with the
1919 // spawned agent via /subagents.
2020 //
21-// Gated by the auto_subagents_enabled config flag at call time so a user
22-// can flip it off mid-session and the next call refuses cleanly
23-// without re-registering the tool.
21+// Available when the host's launch-time tool policy permits delegation. The
22+// primary-agent prompt decides whether delegation is proactive or only on an
23+// explicit user request.
2424 type SubagentSpawnTool struct {
25- // Supervisor is the supervisor used to spawn agents. Nil means
26- // "auto-subagents not available in this mode" and the tool always
27- // errors.
25+ // Supervisor is the supervisor used to spawn agents. Nil means subagents
26+ // are unavailable in this mode and the tool always errors.
2827 Supervisor *subagents.Supervisor
2928
30- // Enabled reads the live config flag. Lets users toggle from
31- // /settings without rebuilding the agent. When nil, the tool
32- // is treated as disabled.
29+ // Enabled reports whether the host currently exposes this tool. When nil,
30+ // the tool is treated as disabled.
3331 Enabled func() bool
3432
3533 // DefaultModel and DefaultProvider return the host agent's resolved
@@ -131,7 +129,7 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, pr
131129 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
132130 }
133131 if t.Enabled == nil || !t.Enabled() {
134- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before delegating sub-tasks.")
132+ return protocolToolError(prefix + ": subagent delegation is unavailable in this mode")
135133 }
136134 var a subagentSpawnArgs
137135 if err := json.Unmarshal(raw, &a); err != nil {
M packages/agent/tools/subagent_status.go +21 -17
@@ -34,15 +34,17 @@ type subagentStatusResponse struct {
3434 }
3535
3636 type subagentStatusEntry struct {
37- ID string `json:"agent_id"`
38- State string `json:"state"`
39- ProcessState string `json:"process_state"`
40- TurnState string `json:"turn_state"`
41- StartedAt time.Time `json:"started_at"`
42- UpdatedAt time.Time `json:"updated_at"`
43- FinishedAt *time.Time `json:"finished_at,omitempty"`
44- TaskSummary string `json:"task_summary,omitempty"`
45- Result *subagentStatusResult `json:"result,omitempty"`
37+ ID string `json:"agent_id"`
38+ State string `json:"state"`
39+ ProcessState string `json:"process_state"`
40+ TurnState string `json:"turn_state"`
41+ LifetimeTurns int `json:"lifetime_turns"`
42+ 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"`
47+ Result *subagentStatusResult `json:"result,omitempty"`
4648 }
4749
4850 type subagentStatusResult struct {
@@ -84,7 +86,7 @@ func (t *SubagentStatusTool) Execute(ctx context.Context, raw json.RawMessage, _
8486 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
8587 }
8688 if t.Enabled == nil || !t.Enabled() {
87- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before querying workers.")
89+ return protocolToolError(prefix + ": subagent status is unavailable in this mode")
8890 }
8991
9092 var args subagentStatusArgs
@@ -132,13 +134,15 @@ func findSubagentStatusSnapshot(snapshots []subagents.AgentSnapshot, id string)
132134
133135 func publicSubagentStatus(snapshot subagents.AgentSnapshot) subagentStatusEntry {
134136 entry := subagentStatusEntry{
135- ID: snapshot.ID,
136- State: publicSubagentState(snapshot),
137- ProcessState: string(snapshot.ProcessState),
138- TurnState: string(snapshot.TurnState),
139- StartedAt: snapshot.Started,
140- UpdatedAt: snapshot.UpdatedAt,
141- TaskSummary: summarizeSubagentTask(snapshot.Task),
137+ ID: snapshot.ID,
138+ State: publicSubagentState(snapshot),
139+ ProcessState: string(snapshot.ProcessState),
140+ TurnState: string(snapshot.TurnState),
141+ LifetimeTurns: snapshot.LifetimeTurns,
142+ CurrentRunTurns: snapshot.CurrentRunTurns,
143+ StartedAt: snapshot.Started,
144+ UpdatedAt: snapshot.UpdatedAt,
145+ TaskSummary: summarizeSubagentTask(snapshot.Task),
142146 }
143147 if !snapshot.Finished.IsZero() {
144148 finished := snapshot.Finished
M packages/agent/tools/subagent_status_test.go +22 -0
@@ -39,6 +39,28 @@ func (r statusTestRunner) Run(ctx context.Context, sink subagents.Sink) error {
3939 }
4040 }
4141
42+func TestSubagentStatusIncludesTurnCounters(t *testing.T) {
43+ entry := publicSubagentStatus(subagents.AgentSnapshot{
44+ ID: "counter-agent",
45+ LifetimeTurns: 7,
46+ CurrentRunTurns: 2,
47+ })
48+ if entry.LifetimeTurns != 7 || entry.CurrentRunTurns != 2 {
49+ t.Fatalf("status counters = (%d, %d), want (7, 2)", entry.LifetimeTurns, entry.CurrentRunTurns)
50+ }
51+ encoded, err := json.Marshal(entry)
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ var fields map[string]any
56+ if err := json.Unmarshal(encoded, &fields); err != nil {
57+ t.Fatal(err)
58+ }
59+ if fields["lifetime_turns"] != float64(7) || fields["current_run_turns"] != float64(2) {
60+ t.Fatalf("encoded status counters = %#v", fields)
61+ }
62+}
63+
4264 func TestSubagentStatusSchemaHasOptionalAgentID(t *testing.T) {
4365 var schema struct {
4466 Type string `json:"type"`
M packages/agent/tools/subagent_stop.go +1 -1
@@ -67,7 +67,7 @@ func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ f
6767 return protocolToolError(prefix + ": subagent supervisor not available in this mode")
6868 }
6969 if t.Enabled == nil || !t.Enabled() {
70- return protocolToolError(prefix + ": auto-subagents is disabled. Ask the user to enable it from /settings before managing workers.")
70+ return protocolToolError(prefix + ": subagent management is unavailable in this mode")
7171 }
7272
7373 var args subagentStopArgs
M packages/core/session.go +13 -0
@@ -1187,6 +1187,19 @@ func (s *Session) AppendMessage(m provider.Message) error {
11871187 return nil
11881188 }
11891189
1190+// Sync flushes the session file's bytes through the filesystem. Interactive
1191+// hosts use this at acknowledgement boundaries where a crash must not make a
1192+// prompt look accepted before its user message is recoverable.
1193+func (s *Session) Sync() error {
1194+ if s == nil || s.writer == nil {
1195+ return nil
1196+ }
1197+ if err := s.buf.Flush(); err != nil {
1198+ return err
1199+ }
1200+ return s.writer.Sync()
1201+}
1202+
11901203 // AppendCompaction writes a checkpoint that replaces all earlier
11911204 // transcript rows when the session is resumed. The old rows remain in
11921205 // the JSONL file for audit/export, while loaders use the latest
M packages/tui/editor.go +15 -0
@@ -912,6 +912,21 @@ func stripANSI(s string) string {
912912 }
913913 continue
914914 }
915+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
916+ i += 2
917+ for i < len(runes) {
918+ if runes[i] == 0x07 {
919+ i++
920+ break
921+ }
922+ if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == '\\' {
923+ i += 2
924+ break
925+ }
926+ i++
927+ }
928+ continue
929+ }
915930 out = append(out, runes[i])
916931 i++
917932 }
M packages/tui/render.go +131 -30
@@ -42,16 +42,24 @@ type Renderer struct {
4242 logLines []string
4343 // Raw logical rows let DrawLog reject an unchanged frame before
4444 // truncating and repainting every transcript row.
45- logRawChat []string
46- logRawBottom []string
47- logRawCols int
48- logRawRows int
49- logRawBackground string
50- logRawHasThemeBG bool
51- logViewportTop int
52- logHardwareRow int
53- logInit bool
54- logNeedsFullRepaint bool
45+ logRawChat []string
46+ logRawBottom []string
47+ logRawCols int
48+ logRawRows int
49+ logRawContentCols int
50+ logRawBackground string
51+ logRawHasThemeBG bool
52+ logViewportTop int
53+ logHardwareRow int
54+ logInit bool
55+
56+ // The right bar is a viewport overlay on top of the main-screen flow
57+ // renderer. Keeping its cache separate prevents persistent chrome from
58+ // replacing the transcript's logical buffer and native scrollback.
59+ rightBarActive bool
60+ rightBarLines []string
61+ rightBarMainCols int
62+ rightBarDimmed bool
5563
5664 // keepScrollback is true when we must NOT emit \x1b[3J
5765 // (erase-in-display 3, "clear scrollback").
@@ -130,6 +138,9 @@ func (r *Renderer) Resize(cols, rows int) {
130138 r.logViewportTop = 0
131139 r.logHardwareRow = 0
132140 r.logInit = false
141+ r.rightBarActive = false
142+ r.rightBarLines = nil
143+ r.rightBarMainCols = 0
133144 if r.out != nil {
134145 if r.keepScrollback {
135146 // A resize is a discrete user action, like Ctrl+L: the
@@ -166,6 +177,9 @@ func (r *Renderer) Clear() {
166177 r.logViewportTop = 0
167178 r.logHardwareRow = 0
168179 r.logInit = false
180+ r.rightBarActive = false
181+ r.rightBarLines = nil
182+ r.rightBarMainCols = 0
169183 if r.keepScrollback {
170184 // On VS Code's xterm.js the transcript is taller than the
171185 // viewport, so an in-place clear (home + erase-to-end) only
@@ -264,6 +278,7 @@ func truncateToWidth(s string, cols int) string {
264278 var out strings.Builder
265279 out.Grow(len(s))
266280 seen := 0
281+ osc8Active := false
267282 runes := []rune(s)
268283 for i := 0; i < len(runes); {
269284 r := runes[i]
@@ -282,8 +297,41 @@ func truncateToWidth(s string, cols int) string {
282297 }
283298 continue
284299 }
300+ // OSC string escape (BEL or ST terminated): zero-width and atomic.
301+ if r == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
302+ start := i
303+ i += 2
304+ for i < len(runes) {
305+ c := runes[i]
306+ i++
307+ if c == 0x07 {
308+ break
309+ }
310+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
311+ i++
312+ break
313+ }
314+ }
315+ osc8Closer := start+5 < i && runes[start+2] == '8' && runes[start+3] == ';' && runes[start+4] == ';' &&
316+ ((runes[start+5] == 0x07 && i == start+6) ||
317+ (start+6 < i && runes[start+5] == 0x1b && runes[start+6] == '\\' && i == start+7))
318+ if seen < cols || osc8Closer {
319+ for _, c := range runes[start:i] {
320+ out.WriteRune(c)
321+ }
322+ if runes[start+2] == '8' {
323+ osc8Active = !osc8Closer
324+ }
325+ }
326+ continue
327+ }
285328 rw := runewidthRune(r)
286329 if seen+rw > cols {
330+ if osc8Active {
331+ // The remaining text is omitted, so close a hyperlink
332+ // whose opener was already emitted before the boundary.
333+ out.WriteString("\x1b]8;;\x07")
334+ }
287335 // Flush any trailing ANSI escapes (resets, erase-to-EOL)
288336 // so background colors and cleanup sequences survive.
289337 for i < len(runes) {
@@ -299,6 +347,31 @@ func truncateToWidth(s string, cols int) string {
299347 break
300348 }
301349 }
350+ } else if runes[i] == 0x1b && i+1 < len(runes) && runes[i+1] == ']' {
351+ // Do not append a new OSC sequence after the visible
352+ // boundary: an OSC 8 opener would leak hyperlink state
353+ // into the next terminal row. A matching OSC 8 closer is
354+ // retained so a link opened before the boundary is closed.
355+ keep := i+5 < len(runes) && runes[i+2] == '8' && runes[i+3] == ';' && runes[i+4] == ';' &&
356+ (runes[i+5] == 0x07 || (i+6 < len(runes) && runes[i+5] == 0x1b && runes[i+6] == '\\'))
357+ start := i
358+ i += 2
359+ for i < len(runes) {
360+ c := runes[i]
361+ i++
362+ if c == 0x07 {
363+ break
364+ }
365+ if c == 0x1b && i < len(runes) && runes[i] == '\\' {
366+ i++
367+ break
368+ }
369+ }
370+ if keep {
371+ for _, c := range runes[start:i] {
372+ out.WriteRune(c)
373+ }
374+ }
302375 } else {
303376 break
304377 }
@@ -432,26 +505,34 @@ func (r *Renderer) Draw(lines []string, cursorRow, cursorCol int) {
432505 //
433506 // cursorBottomRow/cursorCol are offsets into bottom, not the full frame.
434507 func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int) {
508+ if r.rightBarActive {
509+ r.clearRightBarOverlay()
510+ }
511+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, r.cols)
512+}
513+
514+// drawLog is DrawLog's owning flow implementation. contentCols may reserve
515+// terminal columns for a separately composited viewport overlay.
516+func (r *Renderer) drawLog(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
435517 if r.cols == 0 || r.rows == 0 {
436- return
518+ return false
519+ }
520+ if contentCols <= 0 || contentCols > r.cols {
521+ contentCols = r.cols
437522 }
438523 if len(bottom) == 0 {
439524 bottom = []string{""}
440525 }
441- if r.logInit && len(r.logLines) > 0 &&
442- cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
443- r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawBackground == r.backgroundStyle &&
444- r.logRawHasThemeBG == (r.theme.Background != nil) &&
445- sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom) {
446- return
526+ if r.logRawFrameUnchanged(chat, bottom, cursorBottomRow, cursorCol, contentCols) {
527+ return false
447528 }
448529 chatFrame := make([]string, len(chat))
449530 for i, line := range chat {
450- chatFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
531+ chatFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
451532 }
452533 bottomFrame := make([]string, len(bottom))
453534 for i, line := range bottom {
454- bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, r.cols), r.cols, r.backgroundStyle)
535+ bottomFrame[i] = paintBackgroundRow(truncateToWidth(line, contentCols), contentCols, r.backgroundStyle)
455536 }
456537
457538 // Always reserve one real row below the editor/status band. This is
@@ -463,7 +544,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
463544 lines = append(lines, chatFrame...)
464545 lines = append(lines, bottomFrame...)
465546 for range bottomMarginRows {
466- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
547+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
467548 }
468549 // In main-screen flow mode zut normally emits only its logical
469550 // content rows and leaves the rest of the terminal viewport alone.
@@ -473,7 +554,7 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
473554 // default transparent case.
474555 if r.theme.Background != nil {
475556 for len(lines) < r.rows {
476- lines = append(lines, paintBackgroundRow("", r.cols, r.backgroundStyle))
557+ lines = append(lines, paintBackgroundRow("", contentCols, r.backgroundStyle))
477558 }
478559 }
479560 if len(lines) == 0 {
@@ -495,8 +576,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
495576 // that never blinks, because we keep "showing" it before the
496577 // terminal can blink it off. Bailing out here lets the OS run
497578 // its blink cycle.
498- if r.logInit && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
499- return
579+ if r.logInit && r.logRawContentCols == contentCols &&
580+ cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol && sameLines(lines, r.logLines) {
581+ return false
500582 }
501583
502584 var w strings.Builder
@@ -590,8 +672,15 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
590672 }
591673 moveToLogicalRow(cursorTargetRow)
592674 w.WriteString("\r")
593- if cursorCol > 0 {
594- w.WriteString("\x1b[" + itoa(cursorCol) + "C")
675+ physicalCursorCol := cursorCol
676+ if physicalCursorCol < 0 {
677+ physicalCursorCol = 0
678+ }
679+ if physicalCursorCol >= contentCols {
680+ physicalCursorCol = contentCols - 1
681+ }
682+ if physicalCursorCol > 0 {
683+ w.WriteString("\x1b[" + itoa(physicalCursorCol) + "C")
595684 }
596685 w.WriteString(SeqShowCursor)
597686 }
@@ -605,12 +694,10 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
605694 wasInitialized := r.logInit
606695 full := !wasInitialized || len(r.logLines) == 0
607696 if full {
608- // Returning from the fixed right-bar frame needs a full viewport
609- // repaint, but it must not discard terminal scrollback merely because
610- // the renderer's flow cache is intentionally empty.
611- writeFull(true, !wasInitialized && !r.logNeedsFullRepaint)
697+ // A cache invalidation may require a full viewport repaint after flow
698+ // has started, but only the initial paint may discard old scrollback.
699+ writeFull(true, !wasInitialized)
612700 r.logInit = true
613- r.logNeedsFullRepaint = false
614701 } else {
615702 firstChanged := -1
616703 lastChanged := -1
@@ -827,10 +914,24 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
827914 r.logRawBottom = append(r.logRawBottom[:0], bottom...)
828915 r.logRawCols = r.cols
829916 r.logRawRows = r.rows
917+ r.logRawContentCols = contentCols
830918 r.logRawBackground = r.backgroundStyle
831919 r.logRawHasThemeBG = r.theme.Background != nil
832920 r.cursorRow = cursorBottomRow
833921 r.cursorCol = cursorCol
922+ return true
923+}
924+
925+func (r *Renderer) logRawFrameUnchanged(chat, bottom []string, cursorBottomRow, cursorCol, contentCols int) bool {
926+ return cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol &&
927+ r.logRawContentUnchanged(chat, bottom, contentCols)
928+}
929+
930+func (r *Renderer) logRawContentUnchanged(chat, bottom []string, contentCols int) bool {
931+ return r.logInit && len(r.logLines) > 0 &&
932+ r.logRawCols == r.cols && r.logRawRows == r.rows && r.logRawContentCols == contentCols &&
933+ r.logRawBackground == r.backgroundStyle && r.logRawHasThemeBG == (r.theme.Background != nil) &&
934+ sameLines(chat, r.logRawChat) && sameLines(bottom, r.logRawBottom)
834935 }
835936
836937 // sameLines reports whether two []string have the exact same
M packages/tui/rightbar.go +92 -42
@@ -1,6 +1,7 @@
11 package tui
22
33 import (
4+ "io"
45 "sort"
56 "strings"
67 )
@@ -372,12 +373,9 @@ func joinRightBar(th Theme, main, rightBar string, mainWidth, rightBarWidth int,
372373 return main + separator + rightBar
373374 }
374375
375-// DrawRightBar renders an already-laid-out visible chat slice and sticky
376-// bottom block in a fixed frame with a persistent right bar. The normal
377-// DrawLog path deliberately uses terminal scrollback; a persistent side rail
378-// needs a fixed viewport so updates can repaint its columns without leaving
379-// stale rows behind. Interactive mode still owns chat scrolling and passes
380-// the selected visible slice here.
376+// DrawRightBar renders the complete transcript through DrawLog, then
377+// composites the persistent rail over the visible viewport. The flow cache
378+// remains authoritative so appended chat still enters native scrollback.
381379 func (r *Renderer) DrawRightBar(chat, bottom, rightBar []string, cursorBottomRow, cursorCol int) {
382380 r.drawRightBar(chat, bottom, rightBar, cursorBottomRow, cursorCol, false)
383381 }
@@ -394,50 +392,102 @@ func (r *Renderer) drawRightBar(chat, bottom, rightBar []string, cursorBottomRow
394392 }
395393 mainWidth, rightBarWidth, ok := RightBarColumns(r.cols)
396394 if !ok {
395+ r.DrawLog(chat, bottom, cursorBottomRow, cursorCol)
397396 return
398397 }
398+
399399 if len(bottom) == 0 {
400400 bottom = []string{""}
401401 }
402- if len(rightBar) < r.rows {
403- padded := make([]string, r.rows)
404- copy(padded, rightBar)
405- rightBar = padded
406- } else if len(rightBar) > r.rows {
407- rightBar = rightBar[:r.rows]
408- }
409402
410- main := make([]string, 0, len(chat)+len(bottom)+1)
411- main = append(main, chat...)
412- main = append(main, bottom...)
413- main = append(main, "") // mirror DrawLog's renderer-owned bottom margin
414- cursorRow := len(chat) + cursorBottomRow
415- if len(main) > r.rows {
416- trim := len(main) - r.rows
417- main = main[trim:]
418- cursorRow -= trim
419- }
420- if len(main) < r.rows {
421- padding := make([]string, r.rows-len(main))
422- main = append(padding, main...)
423- cursorRow += len(padding)
403+ // Keep DrawLog's final blank margin unobstructed, matching the normal flow
404+ // renderer and the previous right-bar layout.
405+ frameRows := max(0, r.rows-1)
406+ barLines := make([]string, frameRows)
407+ copy(barLines, rightBar)
408+
409+ barChanged := !r.rightBarActive || r.rightBarMainCols != mainWidth ||
410+ r.rightBarDimmed != dimSeparator || !sameLines(barLines, r.rightBarLines)
411+ contentUnchanged := r.logRawContentUnchanged(chat, bottom, mainWidth)
412+ flowUnchanged := contentUnchanged && cursorBottomRow == r.cursorRow && cursorCol == r.cursorCol
413+ if flowUnchanged && !barChanged {
414+ return
415+ }
416+ if !contentUnchanged && r.rightBarActive {
417+ // Remove viewport chrome before DrawLog can naturally scroll a row into
418+ // history. Otherwise every appended transcript row captures a stale
419+ // copy of the rail in native terminal scrollback.
420+ r.clearRightBarOverlay()
424421 }
422+ r.drawLog(chat, bottom, cursorBottomRow, cursorCol, mainWidth)
423+ if contentUnchanged && !barChanged {
424+ // Cursor-only updates do not disturb the composited rail.
425+ return
426+ }
427+ r.writeRightBarOverlay(barLines, mainWidth, rightBarWidth, dimSeparator)
428+ r.rightBarActive = true
429+ r.rightBarLines = append(r.rightBarLines[:0], barLines...)
430+ r.rightBarMainCols = mainWidth
431+ r.rightBarDimmed = dimSeparator
432+}
425433
426- frame := make([]string, r.rows)
427- for row := range frame {
428- frame[row] = joinRightBar(r.theme, main[row], rightBar[row], mainWidth, rightBarWidth, dimSeparator)
434+// clearRightBarOverlay removes viewport chrome before DrawLog expands back to
435+// the terminal width. Restoring the tracked flow cursor keeps DrawLog's
436+// hardware-row model valid across the intermediate absolute writes.
437+func (r *Renderer) clearRightBarOverlay() {
438+ if !r.rightBarActive {
439+ return
429440 }
441+ remaining := r.cols - r.rightBarMainCols
442+ if remaining > 0 {
443+ blank := strings.Repeat(" ", remaining)
444+ var w strings.Builder
445+ w.WriteString(SeqSynchronizedOn)
446+ w.WriteString(SeqHideCursor)
447+ for row := 0; row < max(0, r.rows-1); row++ {
448+ w.WriteString(MoveTo(row+1, r.rightBarMainCols+1))
449+ w.WriteString("\x1b[0m")
450+ w.WriteString(blank)
451+ }
452+ r.appendTrackedLogCursor(&w, r.rightBarMainCols)
453+ w.WriteString(SeqSynchronizedOff)
454+ _, _ = io.WriteString(r.out, w.String())
455+ }
456+ r.rightBarActive = false
457+ r.rightBarLines = nil
458+ r.rightBarMainCols = 0
459+ r.rightBarDimmed = false
460+}
461+
462+func (r *Renderer) writeRightBarOverlay(rightBar []string, mainWidth, rightBarWidth int, dimSeparator bool) {
463+ var w strings.Builder
464+ w.WriteString(SeqSynchronizedOn)
465+ w.WriteString(SeqHideCursor)
466+ for row, line := range rightBar {
467+ w.WriteString(MoveTo(row+1, mainWidth+1))
468+ w.WriteString("\x1b[0m")
469+ w.WriteString(joinRightBar(r.theme, "", line, 0, rightBarWidth, dimSeparator))
470+ }
471+ r.appendTrackedLogCursor(&w, mainWidth)
472+ w.WriteString(SeqSynchronizedOff)
473+ _, _ = io.WriteString(r.out, w.String())
474+}
430475
431- // Draw() owns the fixed-frame cache. Reset the flow-mode cache so a
432- // later fallback to DrawLog starts with a full repaint instead of trying
433- // to diff against logical rows from before the rail was enabled. Mark the
434- // transition separately so that repaint does not clear scrollback.
435- r.logChat = nil
436- r.logBottom = nil
437- r.logLines = nil
438- r.logViewportTop = 0
439- r.logHardwareRow = 0
440- r.logInit = false
441- r.logNeedsFullRepaint = true
442- r.Draw(frame, cursorRow, cursorCol)
476+func (r *Renderer) appendTrackedLogCursor(w *strings.Builder, contentCols int) {
477+ if !r.logInit || r.cursorRow < 0 {
478+ return
479+ }
480+ row := len(r.logChat) + r.cursorRow - r.logViewportTop
481+ if row < 0 || row >= r.rows {
482+ return
483+ }
484+ cursorCol := r.cursorCol
485+ if cursorCol < 0 {
486+ cursorCol = 0
487+ }
488+ if cursorCol >= contentCols {
489+ cursorCol = contentCols - 1
490+ }
491+ w.WriteString(MoveTo(row+1, cursorCol+1))
492+ w.WriteString(SeqShowCursor)
443493 }
M packages/tui/rightbar_test.go +152 -6
@@ -209,6 +209,141 @@ func TestJoinRightBarPreservesCellBudgetForUnicode(t *testing.T) {
209209 }
210210 }
211211
212+func TestRightBarTruncationPreservesOSCSequences(t *testing.T) {
213+ for _, tc := range []struct {
214+ opener string
215+ closer string
216+ }{
217+ {"\x1b]8;;https://example.test\x07", "\x1b]8;;\x07"},
218+ {"\x1b]8;;https://example.test\x1b\\", "\x1b]8;;\x1b\\"},
219+ } {
220+ got := JoinRightBar(Dark, tc.opener+"label-more", "", 5, 1)
221+ if !strings.HasPrefix(got, tc.opener+"label") {
222+ t.Fatalf("truncated right-bar row split OSC escape %q: %q", tc.opener, got)
223+ }
224+ got = truncateToWidth("12345"+tc.opener+"tail", 5)
225+ if got != "12345" {
226+ t.Fatalf("truncated right-bar row leaked trailing OSC escape %q: %q", tc.opener, got)
227+ }
228+ got = truncateToWidth(tc.opener+"12345x"+tc.closer, 5)
229+ if !strings.HasSuffix(got, "\x1b]8;;\x07") && !strings.HasSuffix(got, tc.closer) {
230+ t.Fatalf("truncated right-bar row left OSC 8 hyperlink open: %q", got)
231+ }
232+ }
233+}
234+
235+func TestRendererDrawRightBarKeepsFlowScrollback(t *testing.T) {
236+ t.Setenv("TERM_PROGRAM", "")
237+
238+ var out bytes.Buffer
239+ r := NewRenderer(&out)
240+ r.SetTheme(Dark)
241+ r.Resize(80, 5)
242+ chat := []string{"one", "two", "three", "four", "five", "six"}
243+ r.DrawRightBar(
244+ chat,
245+ []string{"input"},
246+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
247+ 0,
248+ 0,
249+ )
250+ if !r.logInit {
251+ t.Fatal("right-bar draw abandoned the flow renderer")
252+ }
253+ if got := len(r.logChat); got != len(chat) {
254+ t.Fatalf("cached chat rows = %d, want complete transcript length %d", got, len(chat))
255+ }
256+
257+ out.Reset()
258+ chat = append(chat, "seven")
259+ r.DrawRightBar(
260+ chat,
261+ []string{"input"},
262+ RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Title: "Plan", Lines: []string{"task"}}}, 26, 5),
263+ 0,
264+ 0,
265+ )
266+ got := out.String()
267+ if strings.Contains(got, SeqClearScreenNoHome) || strings.Contains(got, SeqClearScrollback) {
268+ t.Fatalf("right-bar append cleared retained scrollback: %q", got)
269+ }
270+ if !strings.Contains(stripANSI(got), "seven") {
271+ t.Fatalf("right-bar append omitted new transcript row: %q", got)
272+ }
273+ mainWidth, _, _ := RightBarColumns(80)
274+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
275+ if clearAt, appendAt := strings.Index(got, clearedRail), strings.Index(got, "seven"); clearAt < 0 || clearAt > appendAt {
276+ t.Fatalf("right bar was not removed before transcript append entered scrollback: %q", got)
277+ }
278+ if r.logViewportTop == 0 {
279+ t.Fatal("test did not exercise transcript rows above the viewport")
280+ }
281+}
282+
283+func TestRendererDrawRightBarIdleNoOpEmitsNothing(t *testing.T) {
284+ t.Setenv("TERM_PROGRAM", "")
285+ var out bytes.Buffer
286+ r := NewRenderer(&out)
287+ r.SetTheme(Dark)
288+ r.Resize(80, 5)
289+ chat := []string{"chat"}
290+ bottom := []string{"input"}
291+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
292+ r.DrawRightBar(chat, bottom, rail, 0, 0)
293+ out.Reset()
294+
295+ r.DrawRightBar(chat, bottom, rail, 0, 0)
296+ if out.Len() != 0 {
297+ t.Fatalf("idle right-bar redraw emitted %d bytes: %q", out.Len(), out.String())
298+ }
299+}
300+
301+func TestRendererDrawRightBarCursorMoveDoesNotRepaintRail(t *testing.T) {
302+ t.Setenv("TERM_PROGRAM", "")
303+ var out bytes.Buffer
304+ r := NewRenderer(&out)
305+ r.SetTheme(Dark)
306+ r.Resize(80, 5)
307+ chat := []string{"chat"}
308+ bottom := []string{"input"}
309+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
310+ r.DrawRightBar(chat, bottom, rail, 0, 0)
311+ out.Reset()
312+
313+ r.DrawRightBar(chat, bottom, rail, 0, 1)
314+ if out.Len() == 0 {
315+ t.Fatal("cursor-only right-bar update emitted nothing")
316+ }
317+ if !strings.Contains(out.String(), "\r\x1b[1C") {
318+ t.Fatalf("cursor-only update did not move the cursor to column 1: %q", out.String())
319+ }
320+ if strings.Contains(stripANSI(out.String()), "task") {
321+ t.Fatalf("cursor-only update repainted the entire rail: %q", out.String())
322+ }
323+}
324+
325+func TestRendererDrawRightBarClampsCursorToMainContent(t *testing.T) {
326+ t.Setenv("TERM_PROGRAM", "")
327+ var out bytes.Buffer
328+ r := NewRenderer(&out)
329+ r.SetTheme(Dark)
330+ r.Resize(80, 5)
331+ rail := RenderRightBar(Dark, []RightBarWidget{{Extension: "plan", Lines: []string{"task"}}}, 26, 5)
332+ r.DrawRightBar([]string{"chat"}, []string{"input"}, rail, 0, 100)
333+
334+ mainWidth, _, ok := RightBarColumns(80)
335+ if !ok {
336+ t.Fatal("right bar should fit")
337+ }
338+ got := out.String()
339+ if strings.Contains(got, MoveTo(2, 101)) {
340+ t.Fatalf("cursor escaped the main content area: %q", got)
341+ }
342+ if !strings.Contains(got, MoveTo(2, mainWidth)) {
343+ t.Fatalf("cursor was not clamped to the main content edge: %q", got)
344+ }
345+}
346+
212347 func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
213348 t.Setenv("TERM_PROGRAM", "")
214349
@@ -226,10 +361,21 @@ func TestRendererDrawRightBarToDrawLogKeepsScrollback(t *testing.T) {
226361
227362 out.Reset()
228363 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
229- if !strings.Contains(stripANSI(out.String()), "input") {
230- t.Fatalf("right-bar fallback did not repaint flow content: %q", out.String())
364+ if r.rightBarActive {
365+ t.Fatal("right-bar fallback left the viewport overlay active")
366+ }
367+ if r.logRawContentCols != 80 {
368+ t.Fatalf("flow content width = %d, want full terminal width 80", r.logRawContentCols)
231369 }
232- if strings.Contains(out.String(), SeqClearScrollback) {
370+ mainWidth, _, ok := RightBarColumns(80)
371+ if !ok {
372+ t.Fatal("right bar should fit before falling back to DrawLog")
373+ }
374+ clearedRail := MoveTo(1, mainWidth+1) + "\x1b[0m" + strings.Repeat(" ", 80-mainWidth)
375+ if !strings.Contains(out.String(), clearedRail) {
376+ t.Fatalf("right-bar fallback did not clear the old rail: %q", out.String())
377+ }
378+ if strings.Contains(out.String(), SeqClearScreenNoHome) || strings.Contains(out.String(), SeqClearScrollback) {
233379 t.Fatalf("right-bar fallback cleared terminal scrollback: %q", out.String())
234380 }
235381 }
@@ -261,14 +407,14 @@ func TestRendererDrawRightBarUpdatesClearsAndResizes(t *testing.T) {
261407 if !strings.Contains(stripANSI(out.String()), "Updated") {
262408 t.Fatalf("right-bar update did not repaint changed content: %q", out.String())
263409 }
264- if r.logInit {
265- t.Fatal("right-bar mode left flow renderer initialized")
410+ if !r.logInit {
411+ t.Fatal("right-bar update abandoned the flow renderer")
266412 }
267413
268414 out.Reset()
269415 r.DrawLog([]string{"chat"}, []string{"input"}, 0, 0)
270416 if !r.logInit {
271- t.Fatal("flow renderer was not reinitialized after right-bar clearing")
417+ t.Fatal("flow renderer was not retained after right-bar clearing")
272418 }
273419
274420 out.Reset()

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 477

↑↓ moveenter openesc close