detached e1eefc2

Merge pull request #69 from bnema/feat/subagent-lifecycle-actions

feat(subagents): add manager lifecycle actions
commit
e1eefc2e87bf67bdbc444aa5ea73720e49d9661e
author
bnema · · 2026-08-07 14:08 UTC
committer
GitHub · · 2026-08-07 14:08 UTC
merge of
8a8e2b3 + 8f2fcb4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.11.0
23 files · +1721 -109
M docs/subagents.md +3 -1
@@ -125,7 +125,9 @@ subagent://<id>/patch
125125
126126 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.
127127
128-When auto-subagents is enabled and permitted by launch-time policy, the model also receives the read-only `subagent_status` tool. Call it 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. 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 `subagent_status` when the status tool is needed, but it must not be polled solely to wait for completion.
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.
129+
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.
129131
130132 ## Resource policy
131133
@@ -125,7 +125,9 @@ subagent://<id>/patch
125 125
126 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.126 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.
127 127
128-When auto-subagents is enabled and permitted by launch-time policy, the model also receives the read-only `subagent_status` tool. Call it 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. 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 `subagent_status` when the status tool is needed, but it must not be polled solely to wait for completion.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.
129+
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.
129 131
130 ## Resource policy132 ## Resource policy
131 133
M packages/agent/build.go +20 -2
@@ -858,7 +858,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
858858 append_ = append(append_, subagentsAddendum)
859859 }
860860 }
861- append_ = append(append_, AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowed(args)))
861+ append_ = append(append_, AutoSubagentsSystemAddendumFor(
862+ autoSubagentsToolAllowed(args),
863+ autoSubagentsStopToolAllowed(args),
864+ autoSubagentsResumeToolAllowed(args),
865+ ))
862866 }
863867 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
864868 append_ = append(append_, selectedProfile.SystemPrompt)
@@ -1317,14 +1321,28 @@ func autoSubagentsStatusToolAllowed(args Args) bool {
13171321 return autoSubagentsToolAllowedFor(args, "subagent_status")
13181322 }
13191323
1324+func autoSubagentsStopToolAllowed(args Args) bool {
1325+ return autoSubagentsToolAllowedFor(args, "subagent_stop")
1326+}
1327+
1328+func autoSubagentsResumeToolAllowed(args Args) bool {
1329+ return autoSubagentsToolAllowedFor(args, "subagent_resume")
1330+}
1331+
13201332 func autoSubagentsAnyToolAllowed(args Args) bool {
1321- return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args)
1333+ return autoSubagentsToolAllowed(args) ||
1334+ autoSubagentsStatusToolAllowed(args) ||
1335+ autoSubagentsStopToolAllowed(args) ||
1336+ autoSubagentsResumeToolAllowed(args)
13221337 }
13231338
13241339 func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
13251340 if args.NoTools || args.PermissionSet != nil {
13261341 return false
13271342 }
1343+ if args.ToolsSet && len(args.Tools) == 0 {
1344+ return false
1345+ }
13281346 if len(args.Tools) == 0 {
13291347 return true
13301348 }
@@ -858,7 +858,11 @@ func Resolve(args Args, requireCred bool) (Resolved, error) {
858 append_ = append(append_, subagentsAddendum)858 append_ = append(append_, subagentsAddendum)
859 }859 }
860 }860 }
861- append_ = append(append_, AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowed(args)))861+ append_ = append(append_, AutoSubagentsSystemAddendumFor(
862+ autoSubagentsToolAllowed(args),
863+ autoSubagentsStopToolAllowed(args),
864+ autoSubagentsResumeToolAllowed(args),
865+ ))
862 }866 }
863 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {867 if selectedProfile != nil && selectedProfile.SystemPromptMode != "replace" && selectedProfile.SystemPrompt != "" {
864 append_ = append(append_, selectedProfile.SystemPrompt)868 append_ = append(append_, selectedProfile.SystemPrompt)
@@ -1317,14 +1321,28 @@ func autoSubagentsStatusToolAllowed(args Args) bool {
1317 return autoSubagentsToolAllowedFor(args, "subagent_status")1321 return autoSubagentsToolAllowedFor(args, "subagent_status")
1318 }1322 }
1319 1323
1324+func autoSubagentsStopToolAllowed(args Args) bool {
1325+ return autoSubagentsToolAllowedFor(args, "subagent_stop")
1326+}
1327+
1328+func autoSubagentsResumeToolAllowed(args Args) bool {
1329+ return autoSubagentsToolAllowedFor(args, "subagent_resume")
1330+}
1331+
1320 func autoSubagentsAnyToolAllowed(args Args) bool {1332 func autoSubagentsAnyToolAllowed(args Args) bool {
1321- return autoSubagentsToolAllowed(args) || autoSubagentsStatusToolAllowed(args)1333+ return autoSubagentsToolAllowed(args) ||
1334+ autoSubagentsStatusToolAllowed(args) ||
1335+ autoSubagentsStopToolAllowed(args) ||
1336+ autoSubagentsResumeToolAllowed(args)
1322 }1337 }
1323 1338
1324 func autoSubagentsToolAllowedFor(args Args, toolName string) bool {1339 func autoSubagentsToolAllowedFor(args Args, toolName string) bool {
1325 if args.NoTools || args.PermissionSet != nil {1340 if args.NoTools || args.PermissionSet != nil {
1326 return false1341 return false
1327 }1342 }
1343+ if args.ToolsSet && len(args.Tools) == 0 {
1344+ return false
1345+ }
1328 if len(args.Tools) == 0 {1346 if len(args.Tools) == 0 {
1329 return true1347 return true
1330 }1348 }
M packages/agent/build_test.go +14 -2
@@ -233,18 +233,24 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
233233 for _, tc := range []struct {
234234 name string
235235 tools []string
236+ toolsSet bool
236237 wantSpawn bool
237238 wantStatus bool
239+ wantStop bool
240+ wantResume bool
238241 wantAny bool
239242 }{
240- {name: "default", wantSpawn: true, wantStatus: true, wantAny: true},
243+ {name: "default", wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true, wantAny: true},
244+ {name: "explicit empty", toolsSet: true},
241245 {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
242246 {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
247+ {name: "stop", tools: []string{"subagent_stop"}, wantStop: true, wantAny: true},
248+ {name: "resume", tools: []string{"subagent_resume"}, wantResume: true, wantAny: true},
243249 {name: "other", tools: []string{"read"}},
244250 {name: "no tools", wantAny: false, tools: nil},
245251 } {
246252 t.Run(tc.name, func(t *testing.T) {
247- args := Args{Tools: tc.tools}
253+ args := Args{Tools: tc.tools, ToolsSet: tc.toolsSet}
248254 if tc.name == "no tools" {
249255 args.NoTools = true
250256 }
@@ -254,6 +260,12 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
254260 if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
255261 t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
256262 }
263+ if got := autoSubagentsStopToolAllowed(args); got != tc.wantStop {
264+ t.Fatalf("stop allowed = %v, want %v", got, tc.wantStop)
265+ }
266+ if got := autoSubagentsResumeToolAllowed(args); got != tc.wantResume {
267+ t.Fatalf("resume allowed = %v, want %v", got, tc.wantResume)
268+ }
257269 if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
258270 t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
259271 }
@@ -233,18 +233,24 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
233 for _, tc := range []struct {233 for _, tc := range []struct {
234 name string234 name string
235 tools []string235 tools []string
236+ toolsSet bool
236 wantSpawn bool237 wantSpawn bool
237 wantStatus bool238 wantStatus bool
239+ wantStop bool
240+ wantResume bool
238 wantAny bool241 wantAny bool
239 }{242 }{
240- {name: "default", wantSpawn: true, wantStatus: true, wantAny: true},243+ {name: "default", wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true, wantAny: true},
244+ {name: "explicit empty", toolsSet: true},
241 {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},245 {name: "spawn", tools: []string{"subagent_spawn"}, wantSpawn: true, wantAny: true},
242 {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},246 {name: "status", tools: []string{"subagent_status"}, wantStatus: true, wantAny: true},
247+ {name: "stop", tools: []string{"subagent_stop"}, wantStop: true, wantAny: true},
248+ {name: "resume", tools: []string{"subagent_resume"}, wantResume: true, wantAny: true},
243 {name: "other", tools: []string{"read"}},249 {name: "other", tools: []string{"read"}},
244 {name: "no tools", wantAny: false, tools: nil},250 {name: "no tools", wantAny: false, tools: nil},
245 } {251 } {
246 t.Run(tc.name, func(t *testing.T) {252 t.Run(tc.name, func(t *testing.T) {
247- args := Args{Tools: tc.tools}253+ args := Args{Tools: tc.tools, ToolsSet: tc.toolsSet}
248 if tc.name == "no tools" {254 if tc.name == "no tools" {
249 args.NoTools = true255 args.NoTools = true
250 }256 }
@@ -254,6 +260,12 @@ func TestAutoSubagentsToolPoliciesTrackEachTool(t *testing.T) {
254 if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {260 if got := autoSubagentsStatusToolAllowed(args); got != tc.wantStatus {
255 t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)261 t.Fatalf("status allowed = %v, want %v", got, tc.wantStatus)
256 }262 }
263+ if got := autoSubagentsStopToolAllowed(args); got != tc.wantStop {
264+ t.Fatalf("stop allowed = %v, want %v", got, tc.wantStop)
265+ }
266+ if got := autoSubagentsResumeToolAllowed(args); got != tc.wantResume {
267+ t.Fatalf("resume allowed = %v, want %v", got, tc.wantResume)
268+ }
257 if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {269 if got := autoSubagentsAnyToolAllowed(args); got != tc.wantAny {
258 t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)270 t.Fatalf("any allowed = %v, want %v", got, tc.wantAny)
259 }271 }
M packages/agent/cli.go +59 -25
@@ -1454,6 +1454,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14541454 iv.TrackSubagentWorker(a, task)
14551455 }
14561456 }
1457+ onResumedSupervisor := func(a *subagents.Agent, prompt string) {
1458+ if iv != nil {
1459+ iv.TrackResumedSubagentWorker(a, prompt)
1460+ }
1461+ }
1462+ onStopRequestedSupervisor := func(a *subagents.Agent) {
1463+ if iv != nil {
1464+ iv.TrackStoppedSubagentWorker(a)
1465+ }
1466+ }
14571467 resolveSubagent := func(name string) (*subagents.Profile, error) {
14581468 return findSubagentProfile(r.CWD, name)
14591469 }
@@ -1498,6 +1508,22 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14981508 }
14991509 reg[statusTool.Name()] = statusTool
15001510 }
1511+ if autoSubagentsStopToolAllowed(args) {
1512+ stopTool := &tools.SubagentStopTool{
1513+ Supervisor: subagentsMgr,
1514+ Enabled: AutoSubagentsEnabled,
1515+ OnStopRequested: onStopRequestedSupervisor,
1516+ }
1517+ reg[stopTool.Name()] = stopTool
1518+ }
1519+ if autoSubagentsResumeToolAllowed(args) {
1520+ resumeTool := &tools.SubagentResumeTool{
1521+ Supervisor: subagentsMgr,
1522+ Enabled: AutoSubagentsEnabled,
1523+ OnResumed: onResumedSupervisor,
1524+ }
1525+ reg[resumeTool.Name()] = resumeTool
1526+ }
15011527 return reg
15021528 }
15031529 prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
@@ -2216,6 +2242,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
22162242 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
22172243 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
22182244 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2245+ autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2246+ autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
22192247 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
22202248 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
22212249 setWebSearchAvailable := func(available bool) {
@@ -2243,6 +2271,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
22432271 WebSearchInvocationOverride: webSearchInvocationOverride,
22442272 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
22452273 AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2274+ AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2275+ AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
22462276 FastMode: &fastMode,
22472277 LSPEnabled: initialCfg.LSPEnabled,
22482278 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
@@ -2260,31 +2290,35 @@ func runInteractive(ctx context.Context, args Args, version string) error {
22602290 FlatTools: initialCfg.FlatToolRender(),
22612291 CompactUser: initialCfg.CompactUserInput(),
22622292 ExtensionThemes: extMgr.ThemeOptions,
2263- AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowedForSession),
2264- SubagentsSystemAddendum: subagentsAddendum,
2265- SettingsStore: configSettingsStore{},
2266- Model: r.Model,
2267- Provider: r.Provider,
2268- AuthMethod: r.AuthMethod,
2269- BaseURL: r.BaseURL,
2270- Reasoning: r.Reasoning,
2271- SystemPrompt: r.SystemPrompt,
2272- Tools: r.ToolRegistry,
2273- MaxSteps: r.MaxSteps,
2274- CWD: r.CWD,
2275- StartupAgentName: args.AgentName,
2276- StartupContextPaths: instructionContextPaths(r.ContextFiles),
2277- StartupExtensionNames: startupExtensionNames(extMgr.All()),
2278- StartupExtensionErrors: startupExtensionErrors,
2279- StartupSkillNames: startupSkillNames(startupSkills),
2280- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2281- ZutHome: ZutHome(),
2282- SessionsRoot: agentSessionsRoot(ZutHome(), args),
2283- Version: version,
2284- UpdateInfoChan: updateCh,
2285- Sandbox: sharedSandbox,
2286- Agent: ag,
2287- InitialCompactHandoff: currentCompactHandoff(),
2293+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(
2294+ autoSubagentsToolAllowedForSession,
2295+ autoSubagentsStopToolAllowedForSession,
2296+ autoSubagentsResumeToolAllowedForSession,
2297+ ),
2298+ SubagentsSystemAddendum: subagentsAddendum,
2299+ SettingsStore: configSettingsStore{},
2300+ Model: r.Model,
2301+ Provider: r.Provider,
2302+ AuthMethod: r.AuthMethod,
2303+ BaseURL: r.BaseURL,
2304+ Reasoning: r.Reasoning,
2305+ SystemPrompt: r.SystemPrompt,
2306+ Tools: r.ToolRegistry,
2307+ MaxSteps: r.MaxSteps,
2308+ CWD: r.CWD,
2309+ StartupAgentName: args.AgentName,
2310+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2311+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2312+ StartupExtensionErrors: startupExtensionErrors,
2313+ StartupSkillNames: startupSkillNames(startupSkills),
2314+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2315+ ZutHome: ZutHome(),
2316+ SessionsRoot: agentSessionsRoot(ZutHome(), args),
2317+ Version: version,
2318+ UpdateInfoChan: updateCh,
2319+ Sandbox: sharedSandbox,
2320+ Agent: ag,
2321+ InitialCompactHandoff: currentCompactHandoff(),
22882322 InitialSessionTitle: func() string {
22892323 if sess == nil || sessionTitlePending {
22902324 return ""
@@ -1454,6 +1454,16 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1454 iv.TrackSubagentWorker(a, task)1454 iv.TrackSubagentWorker(a, task)
1455 }1455 }
1456 }1456 }
1457+ onResumedSupervisor := func(a *subagents.Agent, prompt string) {
1458+ if iv != nil {
1459+ iv.TrackResumedSubagentWorker(a, prompt)
1460+ }
1461+ }
1462+ onStopRequestedSupervisor := func(a *subagents.Agent) {
1463+ if iv != nil {
1464+ iv.TrackStoppedSubagentWorker(a)
1465+ }
1466+ }
1457 resolveSubagent := func(name string) (*subagents.Profile, error) {1467 resolveSubagent := func(name string) (*subagents.Profile, error) {
1458 return findSubagentProfile(r.CWD, name)1468 return findSubagentProfile(r.CWD, name)
1459 }1469 }
@@ -1498,6 +1508,22 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1498 }1508 }
1499 reg[statusTool.Name()] = statusTool1509 reg[statusTool.Name()] = statusTool
1500 }1510 }
1511+ if autoSubagentsStopToolAllowed(args) {
1512+ stopTool := &tools.SubagentStopTool{
1513+ Supervisor: subagentsMgr,
1514+ Enabled: AutoSubagentsEnabled,
1515+ OnStopRequested: onStopRequestedSupervisor,
1516+ }
1517+ reg[stopTool.Name()] = stopTool
1518+ }
1519+ if autoSubagentsResumeToolAllowed(args) {
1520+ resumeTool := &tools.SubagentResumeTool{
1521+ Supervisor: subagentsMgr,
1522+ Enabled: AutoSubagentsEnabled,
1523+ OnResumed: onResumedSupervisor,
1524+ }
1525+ reg[resumeTool.Name()] = resumeTool
1526+ }
1501 return reg1527 return reg
1502 }1528 }
1503 prepareInteractiveRegistry := func(reg core.Registry) core.Registry {1529 prepareInteractiveRegistry := func(reg core.Registry) core.Registry {
@@ -2216,6 +2242,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2216 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)2242 subagentsAddendum := subagents.SystemPromptAddendum(discoveredSubagents)
2217 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)2243 autoSubagentsToolAllowedForSession := autoSubagentsToolAllowed(args)
2218 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)2244 autoSubagentsStatusToolAllowedForSession := autoSubagentsStatusToolAllowed(args)
2245+ autoSubagentsStopToolAllowedForSession := autoSubagentsStopToolAllowed(args)
2246+ autoSubagentsResumeToolAllowedForSession := autoSubagentsResumeToolAllowed(args)
2219 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)2247 webSearchToolAllowedForInvocation := webSearchToolAllowedForSession(args)
2220 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)2248 webSearchInvocationOverride := webSearchExplicitlyEnabledForSession(args)
2221 setWebSearchAvailable := func(available bool) {2249 setWebSearchAvailable := func(available bool) {
@@ -2243,6 +2271,8 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2243 WebSearchInvocationOverride: webSearchInvocationOverride,2271 WebSearchInvocationOverride: webSearchInvocationOverride,
2244 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,2272 AutoSubagentsToolAllowed: &autoSubagentsToolAllowedForSession,
2245 AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,2273 AutoSubagentsStatusToolAllowed: &autoSubagentsStatusToolAllowedForSession,
2274+ AutoSubagentsStopToolAllowed: &autoSubagentsStopToolAllowedForSession,
2275+ AutoSubagentsResumeToolAllowed: &autoSubagentsResumeToolAllowedForSession,
2246 FastMode: &fastMode,2276 FastMode: &fastMode,
2247 LSPEnabled: initialCfg.LSPEnabled,2277 LSPEnabled: initialCfg.LSPEnabled,
2248 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,2278 SubagentLSPEnabled: initialCfg.SubagentLSPEnabled,
@@ -2260,31 +2290,35 @@ func runInteractive(ctx context.Context, args Args, version string) error {
2260 FlatTools: initialCfg.FlatToolRender(),2290 FlatTools: initialCfg.FlatToolRender(),
2261 CompactUser: initialCfg.CompactUserInput(),2291 CompactUser: initialCfg.CompactUserInput(),
2262 ExtensionThemes: extMgr.ThemeOptions,2292 ExtensionThemes: extMgr.ThemeOptions,
2263- AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(autoSubagentsToolAllowedForSession),2293+ AutoSubagentsSystemAddendum: AutoSubagentsSystemAddendumFor(
2264- SubagentsSystemAddendum: subagentsAddendum,2294+ autoSubagentsToolAllowedForSession,
2265- SettingsStore: configSettingsStore{},2295+ autoSubagentsStopToolAllowedForSession,
2266- Model: r.Model,2296+ autoSubagentsResumeToolAllowedForSession,
2267- Provider: r.Provider,2297+ ),
2268- AuthMethod: r.AuthMethod,2298+ SubagentsSystemAddendum: subagentsAddendum,
2269- BaseURL: r.BaseURL,2299+ SettingsStore: configSettingsStore{},
2270- Reasoning: r.Reasoning,2300+ Model: r.Model,
2271- SystemPrompt: r.SystemPrompt,2301+ Provider: r.Provider,
2272- Tools: r.ToolRegistry,2302+ AuthMethod: r.AuthMethod,
2273- MaxSteps: r.MaxSteps,2303+ BaseURL: r.BaseURL,
2274- CWD: r.CWD,2304+ Reasoning: r.Reasoning,
2275- StartupAgentName: args.AgentName,2305+ SystemPrompt: r.SystemPrompt,
2276- StartupContextPaths: instructionContextPaths(r.ContextFiles),2306+ Tools: r.ToolRegistry,
2277- StartupExtensionNames: startupExtensionNames(extMgr.All()),2307+ MaxSteps: r.MaxSteps,
2278- StartupExtensionErrors: startupExtensionErrors,2308+ CWD: r.CWD,
2279- StartupSkillNames: startupSkillNames(startupSkills),2309+ StartupAgentName: args.AgentName,
2280- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,2310+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
2281- ZutHome: ZutHome(),2311+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
2282- SessionsRoot: agentSessionsRoot(ZutHome(), args),2312+ StartupExtensionErrors: startupExtensionErrors,
2283- Version: version,2313+ StartupSkillNames: startupSkillNames(startupSkills),
2284- UpdateInfoChan: updateCh,2314+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
2285- Sandbox: sharedSandbox,2315+ ZutHome: ZutHome(),
2286- Agent: ag,2316+ SessionsRoot: agentSessionsRoot(ZutHome(), args),
2287- InitialCompactHandoff: currentCompactHandoff(),2317+ Version: version,
2318+ UpdateInfoChan: updateCh,
2319+ Sandbox: sharedSandbox,
2320+ Agent: ag,
2321+ InitialCompactHandoff: currentCompactHandoff(),
2288 InitialSessionTitle: func() string {2322 InitialSessionTitle: func() string {
2289 if sess == nil || sessionTitlePending {2323 if sess == nil || sessionTitlePending {
2290 return ""2324 return ""
M packages/agent/modes/auto_subagents_policy_test.go +22 -4
@@ -102,7 +102,7 @@ func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
102102 name: "launch policy excludes tool",
103103 supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
104104 allowed: &falseValue,
105- wantHint: "launch-time tool policy excludes subagent_spawn",
105+ wantHint: "launch-time tool policy excludes subagent manager tools",
106106 },
107107 }
108108 for _, tt := range tests {
@@ -162,6 +162,12 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
162162 if _, ok := iv.agent.Tools["subagent_status"]; ok {
163163 t.Fatal("subagent_status registered despite launch-time policy")
164164 }
165+ if _, ok := iv.agent.Tools["subagent_stop"]; ok {
166+ t.Fatal("subagent_stop registered despite launch-time policy")
167+ }
168+ if _, ok := iv.agent.Tools["subagent_resume"]; ok {
169+ t.Fatal("subagent_resume registered despite launch-time policy")
170+ }
165171 iv.applySettingToggle("auto_subagents_enabled", true)
166172 iv.mu.Lock()
167173 statusErr := iv.statusErr
@@ -180,17 +186,25 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
180186 name string
181187 spawnAllowed bool
182188 statusAllowed bool
189+ stopAllowed bool
190+ resumeAllowed bool
183191 wantSpawn bool
184192 wantStatus bool
193+ wantStop bool
194+ wantResume bool
185195 }{
186196 {name: "status only", statusAllowed: true, wantStatus: true},
187197 {name: "spawn only", spawnAllowed: true, wantSpawn: true},
188- {name: "both", spawnAllowed: true, statusAllowed: true, wantSpawn: true, wantStatus: true},
198+ {name: "stop only", stopAllowed: true, wantStop: true},
199+ {name: "resume only", resumeAllowed: true, wantResume: true},
200+ {name: "all", spawnAllowed: true, statusAllowed: true, stopAllowed: true, resumeAllowed: true, wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true},
189201 } {
190202 t.Run(tc.name, func(t *testing.T) {
191203 enabled := true
192204 spawnAllowed := tc.spawnAllowed
193205 statusAllowed := tc.statusAllowed
206+ stopAllowed := tc.stopAllowed
207+ resumeAllowed := tc.resumeAllowed
194208 supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
195209 t.Cleanup(supervisor.StopAll)
196210 iv := &Interactive{
@@ -199,6 +213,8 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
199213 AutoSubagentsEnabled: &enabled,
200214 AutoSubagentsToolAllowed: &spawnAllowed,
201215 AutoSubagentsStatusToolAllowed: &statusAllowed,
216+ AutoSubagentsStopToolAllowed: &stopAllowed,
217+ AutoSubagentsResumeToolAllowed: &resumeAllowed,
202218 Supervisor: supervisor,
203219 },
204220 dirty: make(chan struct{}, 1),
@@ -207,8 +223,10 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
207223 iv.applyAutoSubagentsTool(true)
208224 _, gotSpawn := iv.agent.Tools["subagent_spawn"]
209225 _, gotStatus := iv.agent.Tools["subagent_status"]
210- if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus {
211- t.Fatalf("registered tools spawn=%v status=%v, want spawn=%v status=%v", gotSpawn, gotStatus, tc.wantSpawn, tc.wantStatus)
226+ _, gotStop := iv.agent.Tools["subagent_stop"]
227+ _, gotResume := iv.agent.Tools["subagent_resume"]
228+ if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus || gotStop != tc.wantStop || gotResume != tc.wantResume {
229+ t.Fatalf("registered tools spawn=%v status=%v stop=%v resume=%v, want spawn=%v status=%v stop=%v resume=%v", gotSpawn, gotStatus, gotStop, gotResume, tc.wantSpawn, tc.wantStatus, tc.wantStop, tc.wantResume)
212230 }
213231 })
214232 }
@@ -102,7 +102,7 @@ func TestAutoSubagentsSettingsDisableUnavailablePolicy(t *testing.T) {
102 name: "launch policy excludes tool",102 name: "launch policy excludes tool",
103 supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),103 supervisor: subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()}),
104 allowed: &falseValue,104 allowed: &falseValue,
105- wantHint: "launch-time tool policy excludes subagent_spawn",105+ wantHint: "launch-time tool policy excludes subagent manager tools",
106 },106 },
107 }107 }
108 for _, tt := range tests {108 for _, tt := range tests {
@@ -162,6 +162,12 @@ func TestAutoSubagentsToolRegistrationHonorsLaunchPolicy(t *testing.T) {
162 if _, ok := iv.agent.Tools["subagent_status"]; ok {162 if _, ok := iv.agent.Tools["subagent_status"]; ok {
163 t.Fatal("subagent_status registered despite launch-time policy")163 t.Fatal("subagent_status registered despite launch-time policy")
164 }164 }
165+ if _, ok := iv.agent.Tools["subagent_stop"]; ok {
166+ t.Fatal("subagent_stop registered despite launch-time policy")
167+ }
168+ if _, ok := iv.agent.Tools["subagent_resume"]; ok {
169+ t.Fatal("subagent_resume registered despite launch-time policy")
170+ }
165 iv.applySettingToggle("auto_subagents_enabled", true)171 iv.applySettingToggle("auto_subagents_enabled", true)
166 iv.mu.Lock()172 iv.mu.Lock()
167 statusErr := iv.statusErr173 statusErr := iv.statusErr
@@ -180,17 +186,25 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
180 name string186 name string
181 spawnAllowed bool187 spawnAllowed bool
182 statusAllowed bool188 statusAllowed bool
189+ stopAllowed bool
190+ resumeAllowed bool
183 wantSpawn bool191 wantSpawn bool
184 wantStatus bool192 wantStatus bool
193+ wantStop bool
194+ wantResume bool
185 }{195 }{
186 {name: "status only", statusAllowed: true, wantStatus: true},196 {name: "status only", statusAllowed: true, wantStatus: true},
187 {name: "spawn only", spawnAllowed: true, wantSpawn: true},197 {name: "spawn only", spawnAllowed: true, wantSpawn: true},
188- {name: "both", spawnAllowed: true, statusAllowed: true, wantSpawn: true, wantStatus: true},198+ {name: "stop only", stopAllowed: true, wantStop: true},
199+ {name: "resume only", resumeAllowed: true, wantResume: true},
200+ {name: "all", spawnAllowed: true, statusAllowed: true, stopAllowed: true, resumeAllowed: true, wantSpawn: true, wantStatus: true, wantStop: true, wantResume: true},
189 } {201 } {
190 t.Run(tc.name, func(t *testing.T) {202 t.Run(tc.name, func(t *testing.T) {
191 enabled := true203 enabled := true
192 spawnAllowed := tc.spawnAllowed204 spawnAllowed := tc.spawnAllowed
193 statusAllowed := tc.statusAllowed205 statusAllowed := tc.statusAllowed
206+ stopAllowed := tc.stopAllowed
207+ resumeAllowed := tc.resumeAllowed
194 supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})208 supervisor := subagents.New(subagents.Config{Root: t.TempDir(), RepoRoot: t.TempDir()})
195 t.Cleanup(supervisor.StopAll)209 t.Cleanup(supervisor.StopAll)
196 iv := &Interactive{210 iv := &Interactive{
@@ -199,6 +213,8 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
199 AutoSubagentsEnabled: &enabled,213 AutoSubagentsEnabled: &enabled,
200 AutoSubagentsToolAllowed: &spawnAllowed,214 AutoSubagentsToolAllowed: &spawnAllowed,
201 AutoSubagentsStatusToolAllowed: &statusAllowed,215 AutoSubagentsStatusToolAllowed: &statusAllowed,
216+ AutoSubagentsStopToolAllowed: &stopAllowed,
217+ AutoSubagentsResumeToolAllowed: &resumeAllowed,
202 Supervisor: supervisor,218 Supervisor: supervisor,
203 },219 },
204 dirty: make(chan struct{}, 1),220 dirty: make(chan struct{}, 1),
@@ -207,8 +223,10 @@ func TestAutoSubagentsToolRegistrationHonorsSeparateLaunchPolicies(t *testing.T)
207 iv.applyAutoSubagentsTool(true)223 iv.applyAutoSubagentsTool(true)
208 _, gotSpawn := iv.agent.Tools["subagent_spawn"]224 _, gotSpawn := iv.agent.Tools["subagent_spawn"]
209 _, gotStatus := iv.agent.Tools["subagent_status"]225 _, gotStatus := iv.agent.Tools["subagent_status"]
210- if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus {226+ _, gotStop := iv.agent.Tools["subagent_stop"]
211- t.Fatalf("registered tools spawn=%v status=%v, want spawn=%v status=%v", gotSpawn, gotStatus, tc.wantSpawn, tc.wantStatus)227+ _, gotResume := iv.agent.Tools["subagent_resume"]
228+ if gotSpawn != tc.wantSpawn || gotStatus != tc.wantStatus || gotStop != tc.wantStop || gotResume != tc.wantResume {
229+ t.Fatalf("registered tools spawn=%v status=%v stop=%v resume=%v, want spawn=%v status=%v stop=%v resume=%v", gotSpawn, gotStatus, gotStop, gotResume, tc.wantSpawn, tc.wantStatus, tc.wantStop, tc.wantResume)
212 }230 }
213 })231 })
214 }232 }
M packages/agent/modes/auto_subagents_test.go +78 -0
@@ -114,6 +114,84 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
114114 }
115115 }
116116
117+func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
118+ started := make(chan struct{})
119+ mgr := subagents.New(subagents.Config{
120+ Root: t.TempDir(),
121+ RepoRoot: t.TempDir(),
122+ NewRunner: func(*subagents.Agent) subagents.Runner {
123+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
124+ close(started)
125+ <-ctx.Done()
126+ return ctx.Err()
127+ })
128+ },
129+ })
130+ a, err := mgr.Spawn(context.Background(), "investigate the stuck worker")
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ <-started
135+ t.Cleanup(func() {
136+ _ = mgr.Stop(a.ID)
137+ a.Wait()
138+ })
139+
140+ iv := newQueuedAutoSubagentsInteractive()
141+ iv.TrackStoppedSubagentWorker(a)
142+ if err := mgr.Stop(a.ID); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ update := waitForQueuedPrompt(t, iv)
147+ if !strings.Contains(update, "task: investigate the stuck worker") {
148+ t.Fatalf("stop update missing task: %q", update)
149+ }
150+ if !strings.Contains(update, "status: killed") {
151+ t.Fatalf("stop update missing killed status: %q", update)
152+ }
153+}
154+
155+func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
156+ started := make(chan struct{})
157+ mgr := subagents.New(subagents.Config{
158+ Root: t.TempDir(),
159+ RepoRoot: t.TempDir(),
160+ NewRunner: func(*subagents.Agent) subagents.Runner {
161+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
162+ close(started)
163+ <-ctx.Done()
164+ return ctx.Err()
165+ })
166+ },
167+ })
168+ a, err := mgr.Spawn(context.Background(), "review the implementation")
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ <-started
173+ t.Cleanup(func() {
174+ _ = mgr.Stop(a.ID)
175+ a.Wait()
176+ })
177+
178+ iv := newQueuedAutoSubagentsInteractive()
179+ const followUp = "I applied your review. What do you think now?"
180+ iv.TrackResumedSubagentWorker(a, followUp)
181+ if a.OnTurnEnd == nil {
182+ t.Fatal("resumed worker has no completion watcher")
183+ }
184+ a.OnTurnEnd(2, "")
185+
186+ update := waitForQueuedPrompt(t, iv)
187+ if !strings.Contains(update, "task: "+followUp) {
188+ t.Fatalf("follow-up update uses the original task instead of the new prompt: %q", update)
189+ }
190+ if !strings.Contains(update, "status: completed") {
191+ t.Fatalf("follow-up update missing completed status: %q", update)
192+ }
193+}
194+
117195 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
118196 started := make(chan struct{})
119197 mgr := subagents.New(subagents.Config{
@@ -114,6 +114,84 @@ func TestTrackSubagentWorkerReportsStartupFailure(t *testing.T) {
114 }114 }
115 }115 }
116 116
117+func TestTrackStoppedSubagentWorkerDeliversTerminalUpdate(t *testing.T) {
118+ started := make(chan struct{})
119+ mgr := subagents.New(subagents.Config{
120+ Root: t.TempDir(),
121+ RepoRoot: t.TempDir(),
122+ NewRunner: func(*subagents.Agent) subagents.Runner {
123+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
124+ close(started)
125+ <-ctx.Done()
126+ return ctx.Err()
127+ })
128+ },
129+ })
130+ a, err := mgr.Spawn(context.Background(), "investigate the stuck worker")
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ <-started
135+ t.Cleanup(func() {
136+ _ = mgr.Stop(a.ID)
137+ a.Wait()
138+ })
139+
140+ iv := newQueuedAutoSubagentsInteractive()
141+ iv.TrackStoppedSubagentWorker(a)
142+ if err := mgr.Stop(a.ID); err != nil {
143+ t.Fatal(err)
144+ }
145+
146+ update := waitForQueuedPrompt(t, iv)
147+ if !strings.Contains(update, "task: investigate the stuck worker") {
148+ t.Fatalf("stop update missing task: %q", update)
149+ }
150+ if !strings.Contains(update, "status: killed") {
151+ t.Fatalf("stop update missing killed status: %q", update)
152+ }
153+}
154+
155+func TestTrackResumedSubagentWorkerDeliversFollowUp(t *testing.T) {
156+ started := make(chan struct{})
157+ mgr := subagents.New(subagents.Config{
158+ Root: t.TempDir(),
159+ RepoRoot: t.TempDir(),
160+ NewRunner: func(*subagents.Agent) subagents.Runner {
161+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
162+ close(started)
163+ <-ctx.Done()
164+ return ctx.Err()
165+ })
166+ },
167+ })
168+ a, err := mgr.Spawn(context.Background(), "review the implementation")
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ <-started
173+ t.Cleanup(func() {
174+ _ = mgr.Stop(a.ID)
175+ a.Wait()
176+ })
177+
178+ iv := newQueuedAutoSubagentsInteractive()
179+ const followUp = "I applied your review. What do you think now?"
180+ iv.TrackResumedSubagentWorker(a, followUp)
181+ if a.OnTurnEnd == nil {
182+ t.Fatal("resumed worker has no completion watcher")
183+ }
184+ a.OnTurnEnd(2, "")
185+
186+ update := waitForQueuedPrompt(t, iv)
187+ if !strings.Contains(update, "task: "+followUp) {
188+ t.Fatalf("follow-up update uses the original task instead of the new prompt: %q", update)
189+ }
190+ if !strings.Contains(update, "status: completed") {
191+ t.Fatalf("follow-up update missing completed status: %q", update)
192+ }
193+}
194+
117 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {195 func TestCompleteSupervisorWatchReportsTurnOutcomeOnce(t *testing.T) {
118 started := make(chan struct{})196 started := make(chan struct{})
119 mgr := subagents.New(subagents.Config{197 mgr := subagents.New(subagents.Config{
M packages/agent/modes/interactive.go +109 -24
@@ -101,6 +101,12 @@ type InteractiveConfig struct {
101101 // the launch-time --no-tools/--tools policy excludes status queries.
102102 AutoSubagentsStatusToolAllowed *bool
103103
104+ // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil
105+ // for normal sessions and false when the launch-time tool policy excludes
106+ // manager lifecycle actions.
107+ AutoSubagentsStopToolAllowed *bool
108+ AutoSubagentsResumeToolAllowed *bool
109+
104110 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
105111 // nil/missing means disabled. Unsupported providers reject attempts
106112 // to enable it and reject requests when it remains enabled.
@@ -8553,32 +8559,59 @@ func (a telegramSenderAdapter) Active() bool {
85538559 // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
85548560 // delegated turn's outcome separately from the long-lived daemon status.
85558561 type subagentWatchEntry struct {
8556- agent *subagents.Agent
8557- task string
8558- done bool
8559- outcome string
8560- err string
8562+ agent *subagents.Agent
8563+ task string
8564+ followUp bool
8565+ done bool
8566+ outcome string
8567+ err string
85618568 }
85628569
85638570 // TrackSubagentWorker is the exported entry point used by the cli to
85648571 // hand a freshly-spawned auto-subagents agent off to the watcher.
85658572 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
8566- i.trackSubagentWorker(a, task)
8573+ i.trackSubagentWorker(a, task, false)
85678574 }
85688575
8569-// trackSubagentWorker records a freshly-spawned auto-subagents agent and
8570-// subscribes to both ways its delegated task can finish. Successful
8571-// long-lived agents report a prompt-level turn_end and keep listening;
8572-// startup failures and unexpected daemon exits only unblock Agent.Wait.
8573-// Whichever signal arrives first completes the entry exactly once.
8574-//
8575-// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-
8576-// subagent is enabled, so this is a no-op when the feature is off.
8577-func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
8576+// TrackResumedSubagentWorker watches a resumed follow-up independently of the
8577+// worker's original task. A long-lived daemon can complete several manager
8578+// turns, each of which must produce its own automatic delivery.
8579+func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {
8580+ i.trackSubagentWorker(a, prompt, true)
8581+}
8582+
8583+// TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8584+// task watcher already owns the worker, that watcher reports the terminal
8585+// outcome; otherwise this creates a single exit-only watcher, including for a
8586+// worker restored by Reload.
8587+func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
8588+ if i == nil || a == nil {
8589+ return
8590+ }
8591+ i.subagentWatchMu.Lock()
8592+ for _, existing := range i.subagentWatch {
8593+ if existing.agent.ID == a.ID && !existing.done {
8594+ i.subagentWatchMu.Unlock()
8595+ return
8596+ }
8597+ }
8598+ entry := &subagentWatchEntry{agent: a, task: a.Task}
8599+ i.subagentWatch = append(i.subagentWatch, entry)
8600+ i.subagentWatchMu.Unlock()
8601+ i.invalidate()
8602+ i.watchSubagentExit(entry)
8603+}
8604+
8605+// trackSubagentWorker records an auto-subagents turn and subscribes to both
8606+// ways it can finish. Successful long-lived agents report a prompt-level
8607+// turn_end and keep listening; startup failures and unexpected daemon exits
8608+// only unblock Agent.Wait. Whichever signal arrives first completes the entry
8609+// exactly once.
8610+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) {
85788611 if i == nil || a == nil {
85798612 return
85808613 }
8581- entry := &subagentWatchEntry{agent: a, task: task}
8614+ entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp}
85828615 i.subagentWatchMu.Lock()
85838616 i.subagentWatch = append(i.subagentWatch, entry)
85848617 i.subagentWatchMu.Unlock()
@@ -8591,9 +8624,13 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
85918624 }
85928625 i.completeSupervisorWatchEntry(entry, outcome, errMsg)
85938626 })
8627+ i.watchSubagentExit(entry)
8628+}
8629+
8630+func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) {
85948631 go func() {
8595- a.Wait()
8596- snap := a.Snapshot()
8632+ entry.agent.Wait()
8633+ snap := entry.agent.Snapshot()
85978634 outcome := string(snap.Status)
85988635 if snap.Status == subagents.StatusDone {
85998636 outcome = "completed"
@@ -8646,7 +8683,10 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
86468683 if status == "" {
86478684 status = string(snap.Status)
86488685 }
8649- task := snap.Task
8686+ task := e.task
8687+ if task == "" || !e.followUp {
8688+ task = snap.Task
8689+ }
86508690 if task == "" {
86518691 task = e.task
86528692 }
@@ -8762,7 +8802,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
87628802 // snapshot+mutate pattern so extension tools and /reload-ext additions
87638803 // survive a toggle.
87648804 func (i *Interactive) autoSubagentsAvailable() bool {
8765- return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())
8805+ return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
87668806 }
87678807
87688808 func (i *Interactive) autoSubagentsUnavailableHint() string {
@@ -8770,8 +8810,8 @@ func (i *Interactive) autoSubagentsUnavailableHint() string {
87708810 if i.cfg.Supervisor == nil {
87718811 hints = append(hints, "subagent supervisor not available in this mode")
87728812 }
8773- if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {
8774- hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")
8813+ if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
8814+ hints = append(hints, "launch-time tool policy excludes subagent manager tools")
87758815 }
87768816 return strings.Join(hints, "; ")
87778817 }
@@ -8789,6 +8829,27 @@ func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
87898829 return autoSubagentsToolAllowedConfig(cfg)
87908830 }
87918831
8832+func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
8833+ if cfg.AutoSubagentsStopToolAllowed != nil {
8834+ return *cfg.AutoSubagentsStopToolAllowed
8835+ }
8836+ return autoSubagentsToolAllowedConfig(cfg)
8837+}
8838+
8839+func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
8840+ if cfg.AutoSubagentsResumeToolAllowed != nil {
8841+ return *cfg.AutoSubagentsResumeToolAllowed
8842+ }
8843+ return autoSubagentsToolAllowedConfig(cfg)
8844+}
8845+
8846+func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
8847+ return autoSubagentsToolAllowedConfig(cfg) ||
8848+ autoSubagentsStatusToolAllowedConfig(cfg) ||
8849+ autoSubagentsStopToolAllowedConfig(cfg) ||
8850+ autoSubagentsResumeToolAllowedConfig(cfg)
8851+}
8852+
87928853 func (i *Interactive) autoSubagentsToolAllowed() bool {
87938854 return autoSubagentsToolAllowedConfig(i.cfg)
87948855 }
@@ -8797,6 +8858,14 @@ func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
87978858 return autoSubagentsStatusToolAllowedConfig(i.cfg)
87988859 }
87998860
8861+func (i *Interactive) autoSubagentsStopToolAllowed() bool {
8862+ return autoSubagentsStopToolAllowedConfig(i.cfg)
8863+}
8864+
8865+func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
8866+ return autoSubagentsResumeToolAllowedConfig(i.cfg)
8867+}
8868+
88008869 func (i *Interactive) autoSubagentsEnabled() bool {
88018870 i.mu.Lock()
88028871 defer i.mu.Unlock()
@@ -8818,7 +8887,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
88188887 current := i.agent.ToolsSnapshot()
88198888 next := core.Registry{}
88208889 for name, t := range current {
8821- if name == "subagent_spawn" || name == "subagent_status" {
8890+ if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
88228891 continue
88238892 }
88248893 next[name] = t
@@ -8832,7 +8901,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
88328901 DefaultProvider: func() string { return i.cfg.Provider },
88338902 DefaultReasoning: func() string { return i.cfg.Reasoning },
88348903 ResolveSubagent: i.cfg.ResolveSubagent,
8835- OnSpawned: i.trackSubagentWorker,
8904+ OnSpawned: i.TrackSubagentWorker,
88368905 }
88378906 next[canonical.Name()] = canonical
88388907 }
@@ -8843,6 +8912,22 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
88438912 }
88448913 next[statusTool.Name()] = statusTool
88458914 }
8915+ if i.autoSubagentsStopToolAllowed() {
8916+ stopTool := &tools.SubagentStopTool{
8917+ Supervisor: i.cfg.Supervisor,
8918+ Enabled: func() bool { return i.autoSubagentsEnabled() },
8919+ OnStopRequested: i.TrackStoppedSubagentWorker,
8920+ }
8921+ next[stopTool.Name()] = stopTool
8922+ }
8923+ if i.autoSubagentsResumeToolAllowed() {
8924+ resumeTool := &tools.SubagentResumeTool{
8925+ Supervisor: i.cfg.Supervisor,
8926+ Enabled: func() bool { return i.autoSubagentsEnabled() },
8927+ OnResumed: i.TrackResumedSubagentWorker,
8928+ }
8929+ next[resumeTool.Name()] = resumeTool
8930+ }
88468931 }
88478932 i.agent.SetTools(next)
88488933 }
@@ -101,6 +101,12 @@ type InteractiveConfig struct {
101 // the launch-time --no-tools/--tools policy excludes status queries.101 // the launch-time --no-tools/--tools policy excludes status queries.
102 AutoSubagentsStatusToolAllowed *bool102 AutoSubagentsStatusToolAllowed *bool
103 103
104+ // AutoSubagentsStopToolAllowed and AutoSubagentsResumeToolAllowed are nil
105+ // for normal sessions and false when the launch-time tool policy excludes
106+ // manager lifecycle actions.
107+ AutoSubagentsStopToolAllowed *bool
108+ AutoSubagentsResumeToolAllowed *bool
109+
104 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.110 // FastMode mirrors the persisted OpenAI fast-mode flag at startup.
105 // nil/missing means disabled. Unsupported providers reject attempts111 // nil/missing means disabled. Unsupported providers reject attempts
106 // to enable it and reject requests when it remains enabled.112 // to enable it and reject requests when it remains enabled.
@@ -8553,32 +8559,59 @@ func (a telegramSenderAdapter) Active() bool {
8553 // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the8559 // subagentWatchEntry is one tracked auto-subagents sub-agent. It records the
8554 // delegated turn's outcome separately from the long-lived daemon status.8560 // delegated turn's outcome separately from the long-lived daemon status.
8555 type subagentWatchEntry struct {8561 type subagentWatchEntry struct {
8556- agent *subagents.Agent8562+ agent *subagents.Agent
8557- task string8563+ task string
8558- done bool8564+ followUp bool
8559- outcome string8565+ done bool
8560- err string8566+ outcome string
8567+ err string
8561 }8568 }
8562 8569
8563 // TrackSubagentWorker is the exported entry point used by the cli to8570 // TrackSubagentWorker is the exported entry point used by the cli to
8564 // hand a freshly-spawned auto-subagents agent off to the watcher.8571 // hand a freshly-spawned auto-subagents agent off to the watcher.
8565 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {8572 func (i *Interactive) TrackSubagentWorker(a *subagents.Agent, task string) {
8566- i.trackSubagentWorker(a, task)8573+ i.trackSubagentWorker(a, task, false)
8567 }8574 }
8568 8575
8569-// trackSubagentWorker records a freshly-spawned auto-subagents agent and8576+// TrackResumedSubagentWorker watches a resumed follow-up independently of the
8570-// subscribes to both ways its delegated task can finish. Successful8577+// worker's original task. A long-lived daemon can complete several manager
8571-// long-lived agents report a prompt-level turn_end and keep listening;8578+// turns, each of which must produce its own automatic delivery.
8572-// startup failures and unexpected daemon exits only unblock Agent.Wait.8579+func (i *Interactive) TrackResumedSubagentWorker(a *subagents.Agent, prompt string) {
8573-// Whichever signal arrives first completes the entry exactly once.8580+ i.trackSubagentWorker(a, prompt, true)
8574-//8581+}
8575-// Wired in from cli.go via SubagentSpawnTool.OnSpawned only when auto-8582+
8576-// subagent is enabled, so this is a no-op when the feature is off.8583+// TrackStoppedSubagentWorker watches a requested worker shutdown. If an active
8577-func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {8584+// task watcher already owns the worker, that watcher reports the terminal
8585+// outcome; otherwise this creates a single exit-only watcher, including for a
8586+// worker restored by Reload.
8587+func (i *Interactive) TrackStoppedSubagentWorker(a *subagents.Agent) {
8588+ if i == nil || a == nil {
8589+ return
8590+ }
8591+ i.subagentWatchMu.Lock()
8592+ for _, existing := range i.subagentWatch {
8593+ if existing.agent.ID == a.ID && !existing.done {
8594+ i.subagentWatchMu.Unlock()
8595+ return
8596+ }
8597+ }
8598+ entry := &subagentWatchEntry{agent: a, task: a.Task}
8599+ i.subagentWatch = append(i.subagentWatch, entry)
8600+ i.subagentWatchMu.Unlock()
8601+ i.invalidate()
8602+ i.watchSubagentExit(entry)
8603+}
8604+
8605+// trackSubagentWorker records an auto-subagents turn and subscribes to both
8606+// ways it can finish. Successful long-lived agents report a prompt-level
8607+// turn_end and keep listening; startup failures and unexpected daemon exits
8608+// only unblock Agent.Wait. Whichever signal arrives first completes the entry
8609+// exactly once.
8610+func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string, followUp bool) {
8578 if i == nil || a == nil {8611 if i == nil || a == nil {
8579 return8612 return
8580 }8613 }
8581- entry := &subagentWatchEntry{agent: a, task: task}8614+ entry := &subagentWatchEntry{agent: a, task: task, followUp: followUp}
8582 i.subagentWatchMu.Lock()8615 i.subagentWatchMu.Lock()
8583 i.subagentWatch = append(i.subagentWatch, entry)8616 i.subagentWatch = append(i.subagentWatch, entry)
8584 i.subagentWatchMu.Unlock()8617 i.subagentWatchMu.Unlock()
@@ -8591,9 +8624,13 @@ func (i *Interactive) trackSubagentWorker(a *subagents.Agent, task string) {
8591 }8624 }
8592 i.completeSupervisorWatchEntry(entry, outcome, errMsg)8625 i.completeSupervisorWatchEntry(entry, outcome, errMsg)
8593 })8626 })
8627+ i.watchSubagentExit(entry)
8628+}
8629+
8630+func (i *Interactive) watchSubagentExit(entry *subagentWatchEntry) {
8594 go func() {8631 go func() {
8595- a.Wait()8632+ entry.agent.Wait()
8596- snap := a.Snapshot()8633+ snap := entry.agent.Snapshot()
8597 outcome := string(snap.Status)8634 outcome := string(snap.Status)
8598 if snap.Status == subagents.StatusDone {8635 if snap.Status == subagents.StatusDone {
8599 outcome = "completed"8636 outcome = "completed"
@@ -8646,7 +8683,10 @@ func (i *Interactive) flushSupervisorSummary(batch []*subagentWatchEntry) {
8646 if status == "" {8683 if status == "" {
8647 status = string(snap.Status)8684 status = string(snap.Status)
8648 }8685 }
8649- task := snap.Task8686+ task := e.task
8687+ if task == "" || !e.followUp {
8688+ task = snap.Task
8689+ }
8650 if task == "" {8690 if task == "" {
8651 task = e.task8691 task = e.task
8652 }8692 }
@@ -8762,7 +8802,7 @@ func (i *Interactive) applyAutoSubagentsSystemPrompt(active bool) {
8762 // snapshot+mutate pattern so extension tools and /reload-ext additions8802 // snapshot+mutate pattern so extension tools and /reload-ext additions
8763 // survive a toggle.8803 // survive a toggle.
8764 func (i *Interactive) autoSubagentsAvailable() bool {8804 func (i *Interactive) autoSubagentsAvailable() bool {
8765- return i.cfg.Supervisor != nil && (i.autoSubagentsToolAllowed() || i.autoSubagentsStatusToolAllowed())8805+ return i.cfg.Supervisor != nil && autoSubagentsAnyToolAllowedConfig(i.cfg)
8766 }8806 }
8767 8807
8768 func (i *Interactive) autoSubagentsUnavailableHint() string {8808 func (i *Interactive) autoSubagentsUnavailableHint() string {
@@ -8770,8 +8810,8 @@ func (i *Interactive) autoSubagentsUnavailableHint() string {
8770 if i.cfg.Supervisor == nil {8810 if i.cfg.Supervisor == nil {
8771 hints = append(hints, "subagent supervisor not available in this mode")8811 hints = append(hints, "subagent supervisor not available in this mode")
8772 }8812 }
8773- if !i.autoSubagentsToolAllowed() && !i.autoSubagentsStatusToolAllowed() {8813+ if !autoSubagentsAnyToolAllowedConfig(i.cfg) {
8774- hints = append(hints, "launch-time tool policy excludes subagent_spawn and subagent_status")8814+ hints = append(hints, "launch-time tool policy excludes subagent manager tools")
8775 }8815 }
8776 return strings.Join(hints, "; ")8816 return strings.Join(hints, "; ")
8777 }8817 }
@@ -8789,6 +8829,27 @@ func autoSubagentsStatusToolAllowedConfig(cfg InteractiveConfig) bool {
8789 return autoSubagentsToolAllowedConfig(cfg)8829 return autoSubagentsToolAllowedConfig(cfg)
8790 }8830 }
8791 8831
8832+func autoSubagentsStopToolAllowedConfig(cfg InteractiveConfig) bool {
8833+ if cfg.AutoSubagentsStopToolAllowed != nil {
8834+ return *cfg.AutoSubagentsStopToolAllowed
8835+ }
8836+ return autoSubagentsToolAllowedConfig(cfg)
8837+}
8838+
8839+func autoSubagentsResumeToolAllowedConfig(cfg InteractiveConfig) bool {
8840+ if cfg.AutoSubagentsResumeToolAllowed != nil {
8841+ return *cfg.AutoSubagentsResumeToolAllowed
8842+ }
8843+ return autoSubagentsToolAllowedConfig(cfg)
8844+}
8845+
8846+func autoSubagentsAnyToolAllowedConfig(cfg InteractiveConfig) bool {
8847+ return autoSubagentsToolAllowedConfig(cfg) ||
8848+ autoSubagentsStatusToolAllowedConfig(cfg) ||
8849+ autoSubagentsStopToolAllowedConfig(cfg) ||
8850+ autoSubagentsResumeToolAllowedConfig(cfg)
8851+}
8852+
8792 func (i *Interactive) autoSubagentsToolAllowed() bool {8853 func (i *Interactive) autoSubagentsToolAllowed() bool {
8793 return autoSubagentsToolAllowedConfig(i.cfg)8854 return autoSubagentsToolAllowedConfig(i.cfg)
8794 }8855 }
@@ -8797,6 +8858,14 @@ func (i *Interactive) autoSubagentsStatusToolAllowed() bool {
8797 return autoSubagentsStatusToolAllowedConfig(i.cfg)8858 return autoSubagentsStatusToolAllowedConfig(i.cfg)
8798 }8859 }
8799 8860
8861+func (i *Interactive) autoSubagentsStopToolAllowed() bool {
8862+ return autoSubagentsStopToolAllowedConfig(i.cfg)
8863+}
8864+
8865+func (i *Interactive) autoSubagentsResumeToolAllowed() bool {
8866+ return autoSubagentsResumeToolAllowedConfig(i.cfg)
8867+}
8868+
8800 func (i *Interactive) autoSubagentsEnabled() bool {8869 func (i *Interactive) autoSubagentsEnabled() bool {
8801 i.mu.Lock()8870 i.mu.Lock()
8802 defer i.mu.Unlock()8871 defer i.mu.Unlock()
@@ -8818,7 +8887,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
8818 current := i.agent.ToolsSnapshot()8887 current := i.agent.ToolsSnapshot()
8819 next := core.Registry{}8888 next := core.Registry{}
8820 for name, t := range current {8889 for name, t := range current {
8821- if name == "subagent_spawn" || name == "subagent_status" {8890+ if name == "subagent_spawn" || name == "subagent_status" || name == "subagent_stop" || name == "subagent_resume" {
8822 continue8891 continue
8823 }8892 }
8824 next[name] = t8893 next[name] = t
@@ -8832,7 +8901,7 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
8832 DefaultProvider: func() string { return i.cfg.Provider },8901 DefaultProvider: func() string { return i.cfg.Provider },
8833 DefaultReasoning: func() string { return i.cfg.Reasoning },8902 DefaultReasoning: func() string { return i.cfg.Reasoning },
8834 ResolveSubagent: i.cfg.ResolveSubagent,8903 ResolveSubagent: i.cfg.ResolveSubagent,
8835- OnSpawned: i.trackSubagentWorker,8904+ OnSpawned: i.TrackSubagentWorker,
8836 }8905 }
8837 next[canonical.Name()] = canonical8906 next[canonical.Name()] = canonical
8838 }8907 }
@@ -8843,6 +8912,22 @@ func (i *Interactive) applyAutoSubagentsTool(active bool) {
8843 }8912 }
8844 next[statusTool.Name()] = statusTool8913 next[statusTool.Name()] = statusTool
8845 }8914 }
8915+ if i.autoSubagentsStopToolAllowed() {
8916+ stopTool := &tools.SubagentStopTool{
8917+ Supervisor: i.cfg.Supervisor,
8918+ Enabled: func() bool { return i.autoSubagentsEnabled() },
8919+ OnStopRequested: i.TrackStoppedSubagentWorker,
8920+ }
8921+ next[stopTool.Name()] = stopTool
8922+ }
8923+ if i.autoSubagentsResumeToolAllowed() {
8924+ resumeTool := &tools.SubagentResumeTool{
8925+ Supervisor: i.cfg.Supervisor,
8926+ Enabled: func() bool { return i.autoSubagentsEnabled() },
8927+ OnResumed: i.TrackResumedSubagentWorker,
8928+ }
8929+ next[resumeTool.Name()] = resumeTool
8930+ }
8846 }8931 }
8847 i.agent.SetTools(next)8932 i.agent.SetTools(next)
8848 }8933 }
M packages/agent/settings_store.go +20 -6
@@ -282,11 +282,25 @@ When workers finish, use the host update's agent ID, status, task, optional erro
282282 // spawn tool.
283283 const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.`
284284
285-// AutoSubagentsSystemAddendumFor returns the strict contract with explicit
286-// limitation guidance when the spawn tool is not available.
287-func AutoSubagentsSystemAddendumFor(spawnToolAllowed bool) string {
288- if spawnToolAllowed {
289- return AutoSubagentsSystemAddendum
285+// AutoSubagentsSystemAddendumFor returns the strict contract with lifecycle
286+// guidance only for manager actions exposed by the launch-time tool policy.
287+// It adds explicit limitation guidance when the spawn tool is not available.
288+func AutoSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {
289+ addendum := AutoSubagentsSystemAddendum
290+ switch {
291+ case stopToolAllowed && resumeToolAllowed:
292+ addendum += "\n\nManager lifecycle actions are available: use subagent_stop to request termination of a stuck worker, and subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
293+ case stopToolAllowed:
294+ addendum += "\n\nA manager lifecycle action is available: use subagent_stop to request termination of a stuck worker."
295+ case resumeToolAllowed:
296+ addendum += "\n\nA manager lifecycle action is available: use subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
297+ }
298+ if !spawnToolAllowed {
299+ if stopToolAllowed || resumeToolAllowed {
300+ addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers. Do not implement, debug, test, or review directly."
301+ } else {
302+ addendum += "\n\n" + AutoSubagentsDelegationUnavailableAddendum
303+ }
290304 }
291- return AutoSubagentsSystemAddendum + "\n\n" + AutoSubagentsDelegationUnavailableAddendum
305+ return addendum
292306 }
@@ -282,11 +282,25 @@ When workers finish, use the host update's agent ID, status, task, optional erro
282 // spawn tool.282 // spawn tool.
283 const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.`283 const AutoSubagentsDelegationUnavailableAddendum = `Delegation is unavailable in this session because the launch-time tool policy does not expose subagent_spawn. Remain the primary-agent orchestrator: report this limitation to the user rather than implementing, debugging/testing, or reviewing directly, and end or yield your turn until delegation is available. Do not treat subagent_status or other tools as a substitute for subagent_spawn.`
284 284
285-// AutoSubagentsSystemAddendumFor returns the strict contract with explicit285+// AutoSubagentsSystemAddendumFor returns the strict contract with lifecycle
286-// limitation guidance when the spawn tool is not available.286+// guidance only for manager actions exposed by the launch-time tool policy.
287-func AutoSubagentsSystemAddendumFor(spawnToolAllowed bool) string {287+// It adds explicit limitation guidance when the spawn tool is not available.
288- if spawnToolAllowed {288+func AutoSubagentsSystemAddendumFor(spawnToolAllowed, stopToolAllowed, resumeToolAllowed bool) string {
289- return AutoSubagentsSystemAddendum289+ addendum := AutoSubagentsSystemAddendum
290+ switch {
291+ case stopToolAllowed && resumeToolAllowed:
292+ addendum += "\n\nManager lifecycle actions are available: use subagent_stop to request termination of a stuck worker, and subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
293+ case stopToolAllowed:
294+ addendum += "\n\nA manager lifecycle action is available: use subagent_stop to request termination of a stuck worker."
295+ case resumeToolAllowed:
296+ addendum += "\n\nA manager lifecycle action is available: use subagent_resume with an agent id and follow-up prompt to continue an idle worker or restart a stopped worker with its existing session context."
297+ }
298+ if !spawnToolAllowed {
299+ if stopToolAllowed || resumeToolAllowed {
300+ addendum += "\n\nSpawning new workers is unavailable in this session. Use only the enabled manager lifecycle actions for existing workers. Do not implement, debug, test, or review directly."
301+ } else {
302+ addendum += "\n\n" + AutoSubagentsDelegationUnavailableAddendum
303+ }
290 }304 }
291- return AutoSubagentsSystemAddendum + "\n\n" + AutoSubagentsDelegationUnavailableAddendum305+ return addendum
292 }306 }
M packages/agent/settings_store_test.go +41 -1
@@ -1,6 +1,46 @@
11 package agent
22
3-import "testing"
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestAutoSubagentsSystemAddendumForListsOnlyEnabledLifecycleTools(t *testing.T) {
9+ for _, tc := range []struct {
10+ name string
11+ spawn bool
12+ stop bool
13+ resume bool
14+ want []string
15+ unwanted []string
16+ wantUnavailable bool
17+ }{
18+ {name: "none", spawn: true, unwanted: []string{"subagent_stop", "subagent_resume"}},
19+ {name: "fully unavailable", unwanted: []string{"subagent_stop", "subagent_resume"}, wantUnavailable: true},
20+ {name: "stop", spawn: true, stop: true, want: []string{"subagent_stop"}, unwanted: []string{"subagent_resume"}},
21+ {name: "resume", spawn: true, resume: true, want: []string{"subagent_resume"}, unwanted: []string{"subagent_stop"}},
22+ {name: "both", spawn: true, stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume"}},
23+ {name: "stop without spawn", stop: true, want: []string{"subagent_stop", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_resume"}},
24+ {name: "resume without spawn", resume: true, want: []string{"subagent_resume", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_stop"}},
25+ } {
26+ t.Run(tc.name, func(t *testing.T) {
27+ got := AutoSubagentsSystemAddendumFor(tc.spawn, tc.stop, tc.resume)
28+ for _, want := range tc.want {
29+ if !strings.Contains(got, want) {
30+ t.Fatalf("addendum missing enabled lifecycle tool %q:\n%s", want, got)
31+ }
32+ }
33+ for _, unwanted := range tc.unwanted {
34+ if strings.Contains(got, unwanted) {
35+ t.Fatalf("addendum mentions unavailable lifecycle tool %q:\n%s", unwanted, got)
36+ }
37+ }
38+ if gotUnavailable := strings.Contains(got, AutoSubagentsDelegationUnavailableAddendum); gotUnavailable != tc.wantUnavailable {
39+ t.Fatalf("delegation-unavailable guidance present = %t, want %t:\n%s", gotUnavailable, tc.wantUnavailable, got)
40+ }
41+ })
42+ }
43+}
444
545 func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
646 t.Setenv("ZUT_HOME", t.TempDir())
@@ -1,6 +1,46 @@
1 package agent1 package agent
2 2
3-import "testing"3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestAutoSubagentsSystemAddendumForListsOnlyEnabledLifecycleTools(t *testing.T) {
9+ for _, tc := range []struct {
10+ name string
11+ spawn bool
12+ stop bool
13+ resume bool
14+ want []string
15+ unwanted []string
16+ wantUnavailable bool
17+ }{
18+ {name: "none", spawn: true, unwanted: []string{"subagent_stop", "subagent_resume"}},
19+ {name: "fully unavailable", unwanted: []string{"subagent_stop", "subagent_resume"}, wantUnavailable: true},
20+ {name: "stop", spawn: true, stop: true, want: []string{"subagent_stop"}, unwanted: []string{"subagent_resume"}},
21+ {name: "resume", spawn: true, resume: true, want: []string{"subagent_resume"}, unwanted: []string{"subagent_stop"}},
22+ {name: "both", spawn: true, stop: true, resume: true, want: []string{"subagent_stop", "subagent_resume"}},
23+ {name: "stop without spawn", stop: true, want: []string{"subagent_stop", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_resume"}},
24+ {name: "resume without spawn", resume: true, want: []string{"subagent_resume", "Spawning new workers is unavailable"}, unwanted: []string{"subagent_stop"}},
25+ } {
26+ t.Run(tc.name, func(t *testing.T) {
27+ got := AutoSubagentsSystemAddendumFor(tc.spawn, tc.stop, tc.resume)
28+ for _, want := range tc.want {
29+ if !strings.Contains(got, want) {
30+ t.Fatalf("addendum missing enabled lifecycle tool %q:\n%s", want, got)
31+ }
32+ }
33+ for _, unwanted := range tc.unwanted {
34+ if strings.Contains(got, unwanted) {
35+ t.Fatalf("addendum mentions unavailable lifecycle tool %q:\n%s", unwanted, got)
36+ }
37+ }
38+ if gotUnavailable := strings.Contains(got, AutoSubagentsDelegationUnavailableAddendum); gotUnavailable != tc.wantUnavailable {
39+ t.Fatalf("delegation-unavailable guidance present = %t, want %t:\n%s", gotUnavailable, tc.wantUnavailable, got)
40+ }
41+ })
42+ }
43+}
4 44
5 func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {45 func TestConfigSettingsStorePersistsShowInstructionsAtStartup(t *testing.T) {
6 t.Setenv("ZUT_HOME", t.TempDir())46 t.Setenv("ZUT_HOME", t.TempDir())
M packages/agent/subagent_worker.go +4 -1
@@ -236,13 +236,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
236236 "turn_id": turnID,
237237 "error": turnErrorMessage(err),
238238 })
239- em.emit("agent.idle", map[string]any{"turn_id": turnID})
240239
241240 cancel()
242241 mu.Lock()
243242 busyTurn = false
244243 cancelFn = nil
245244 mu.Unlock()
245+ // The supervisor treats agent.idle as permission to start a follow-up.
246+ // Publish it only after the busy gate has opened, otherwise a manager
247+ // can send a turn that the inbox loop drops as still busy.
248+ em.emit("agent.idle", map[string]any{"turn_id": turnID})
246249 }
247250
248251 launchTurn := func(prompt string) {
@@ -236,13 +236,16 @@ func runSubagentWorkerMode(ctx context.Context, args Args, version string) error
236 "turn_id": turnID,236 "turn_id": turnID,
237 "error": turnErrorMessage(err),237 "error": turnErrorMessage(err),
238 })238 })
239- em.emit("agent.idle", map[string]any{"turn_id": turnID})
240 239
241 cancel()240 cancel()
242 mu.Lock()241 mu.Lock()
243 busyTurn = false242 busyTurn = false
244 cancelFn = nil243 cancelFn = nil
245 mu.Unlock()244 mu.Unlock()
245+ // The supervisor treats agent.idle as permission to start a follow-up.
246+ // Publish it only after the busy gate has opened, otherwise a manager
247+ // can send a turn that the inbox loop drops as still busy.
248+ em.emit("agent.idle", map[string]any{"turn_id": turnID})
246 }249 }
247 250
248 launchTurn := func(prompt string) {251 launchTurn := func(prompt string) {
M packages/agent/subagents/agent.go +42 -4
@@ -76,12 +76,17 @@ type Agent struct {
7676 // rather than Spawn. The runner consults it to decide whether
7777 // to pass the original Task as a positional argv to the child:
7878 // on first Spawn we want the child to run the task immediately;
79- // on Resume the task was already run last time and replaying it
80- // would produce a second turn that collides ("agent busy; send
81- // 'cancel' first") with whatever the user types next. Not
82- // persisted — every Resume sets it explicitly.
79+ // on Resume the task was already run last time and must not replay.
80+ // Not persisted — every Resume sets it explicitly.
8381 Resuming bool
8482
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
89+
8590 // InboxPath is the unix socket the child agent listens on for
8691 // follow-up prompts and control messages. The supervisor opens
8792 // an Inbox at this path; the child opens a Listener.
@@ -382,6 +387,39 @@ func (a *Agent) stateDirectory(defaultRoot string) string {
382387 // from the runner when the child daemon emits a prompt-level
383388 // turn_end event with a step field. Safe to call from any goroutine:
384389 // the runner reads the callback under the same mutex.
390+func (a *Agent) resumePrompt() string {
391+ prompt, _ := a.ResumePromptInfo()
392+ return prompt
393+}
394+
395+// ResumePromptInfo returns the queued manager follow-up and its acceptance
396+// time. Both values are read as one synchronized snapshot.
397+func (a *Agent) ResumePromptInfo() (string, time.Time) {
398+ a.mu.Lock()
399+ defer a.mu.Unlock()
400+ return a.resumePromptText, a.resumePromptAt
401+}
402+
403+func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) (string, time.Time) {
404+ a.mu.Lock()
405+ defer a.mu.Unlock()
406+ previousPrompt, previousAcceptedAt := a.resumePromptText, a.resumePromptAt
407+ a.resumePromptText = prompt
408+ a.resumePromptAt = acceptedAt
409+ return previousPrompt, previousAcceptedAt
410+}
411+
412+func (a *Agent) clearResumePrompt() bool {
413+ a.mu.Lock()
414+ defer a.mu.Unlock()
415+ if a.resumePromptText == "" {
416+ return false
417+ }
418+ a.resumePromptText = ""
419+ a.resumePromptAt = time.Time{}
420+ return true
421+}
422+
385423 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
386424 a.mu.Lock()
387425 a.OnTurnEnd = fn
@@ -76,12 +76,17 @@ type Agent struct {
76 // rather than Spawn. The runner consults it to decide whether76 // rather than Spawn. The runner consults it to decide whether
77 // to pass the original Task as a positional argv to the child:77 // to pass the original Task as a positional argv to the child:
78 // on first Spawn we want the child to run the task immediately;78 // on first Spawn we want the child to run the task immediately;
79- // on Resume the task was already run last time and replaying it79+ // on Resume the task was already run last time and must not replay.
80- // would produce a second turn that collides ("agent busy; send80+ // Not persisted — every Resume sets it explicitly.
81- // 'cancel' first") with whatever the user types next. Not
82- // persisted — every Resume sets it explicitly.
83 Resuming bool81 Resuming bool
84 82
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
89+
85 // InboxPath is the unix socket the child agent listens on for90 // InboxPath is the unix socket the child agent listens on for
86 // follow-up prompts and control messages. The supervisor opens91 // follow-up prompts and control messages. The supervisor opens
87 // an Inbox at this path; the child opens a Listener.92 // an Inbox at this path; the child opens a Listener.
@@ -382,6 +387,39 @@ func (a *Agent) stateDirectory(defaultRoot string) string {
382 // from the runner when the child daemon emits a prompt-level387 // from the runner when the child daemon emits a prompt-level
383 // turn_end event with a step field. Safe to call from any goroutine:388 // turn_end event with a step field. Safe to call from any goroutine:
384 // the runner reads the callback under the same mutex.389 // the runner reads the callback under the same mutex.
390+func (a *Agent) resumePrompt() string {
391+ prompt, _ := a.ResumePromptInfo()
392+ return prompt
393+}
394+
395+// ResumePromptInfo returns the queued manager follow-up and its acceptance
396+// time. Both values are read as one synchronized snapshot.
397+func (a *Agent) ResumePromptInfo() (string, time.Time) {
398+ a.mu.Lock()
399+ defer a.mu.Unlock()
400+ return a.resumePromptText, a.resumePromptAt
401+}
402+
403+func (a *Agent) setResumePrompt(prompt string, acceptedAt time.Time) (string, time.Time) {
404+ a.mu.Lock()
405+ defer a.mu.Unlock()
406+ previousPrompt, previousAcceptedAt := a.resumePromptText, a.resumePromptAt
407+ a.resumePromptText = prompt
408+ a.resumePromptAt = acceptedAt
409+ return previousPrompt, previousAcceptedAt
410+}
411+
412+func (a *Agent) clearResumePrompt() bool {
413+ a.mu.Lock()
414+ defer a.mu.Unlock()
415+ if a.resumePromptText == "" {
416+ return false
417+ }
418+ a.resumePromptText = ""
419+ a.resumePromptAt = time.Time{}
420+ return true
421+}
422+
385 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {423 func (a *Agent) SetOnTurnEnd(fn func(step int, errMsg string)) {
386 a.mu.Lock()424 a.mu.Lock()
387 a.OnTurnEnd = fn425 a.OnTurnEnd = fn
M packages/agent/subagents/inbox.go +126 -23
@@ -2,6 +2,7 @@ package subagents
22
33 import (
44 "bufio"
5+ "context"
56 "errors"
67 "fmt"
78 "net"
@@ -32,6 +33,10 @@ type Inbox struct {
3233
3334 mu sync.Mutex
3435 conn net.Conn // lazily dialed; one persistent connection per agent
36+
37+ // send is a one-slot semaphore that serializes JSONL writes without making
38+ // a caller wait behind another blocked write after its context is canceled.
39+ send chan struct{}
3540 }
3641
3742 // NewInbox returns a handle that will dial the socket at path on
@@ -40,7 +45,11 @@ type Inbox struct {
4045 // parent build the Inbox before the child has booted; sends that
4146 // happen before the child is ready get a clear "not yet" error
4247 // the caller can retry or surface.
43-func NewInbox(path string) *Inbox { return &Inbox{path: path} }
48+func NewInbox(path string) *Inbox {
49+ inbox := &Inbox{path: path, send: make(chan struct{}, 1)}
50+ inbox.send <- struct{}{}
51+ return inbox
52+}
4453
4554 // Path returns the absolute socket path. Used by the runner to
4655 // pass the path to the child as a flag.
@@ -48,34 +57,109 @@ func (b *Inbox) Path() string { return b.path }
4857
4958 // SendCommand sends a versioned JSONL command.
5059 func (b *Inbox) SendCommand(command Envelope) error {
60+ return b.SendCommandContext(context.Background(), command)
61+}
62+
63+// SendCommandContext sends a command while honoring cancellation during both
64+// connection setup and a blocked write. Cancellation closes the persistent
65+// connection so a later command redials instead of reusing a half-written
66+// stream.
67+func (b *Inbox) SendCommandContext(ctx context.Context, command Envelope) error {
5168 line, err := MarshalJSONL(command)
5269 if err != nil {
5370 return err
5471 }
55- return b.sendBytes(line)
72+ return b.sendBytesContext(ctx, line)
5673 }
5774
5875 func (b *Inbox) sendBytes(data []byte) error {
76+ return b.sendBytesContext(context.Background(), data)
77+}
78+
79+func (b *Inbox) sendBytesContext(ctx context.Context, data []byte) error {
80+ if ctx == nil {
81+ ctx = context.Background()
82+ }
83+ if err := ctx.Err(); err != nil {
84+ return err
85+ }
86+ send, err := b.acquireSend(ctx)
87+ if err != nil {
88+ return err
89+ }
90+ defer releaseSend(send)
91+ if err := ctx.Err(); err != nil {
92+ return err
93+ }
94+
5995 b.mu.Lock()
60- defer b.mu.Unlock()
6196 if b.conn == nil {
62- c, err := dialUnix(b.path, 200*time.Millisecond)
97+ c, err := dialUnixContext(ctx, b.path, 200*time.Millisecond)
6398 if err != nil {
99+ b.mu.Unlock()
64100 return err
65101 }
66102 b.conn = c
67103 }
68- if _, err := b.conn.Write(data); err != nil {
69- // Drop the connection so the next call redials. The
70- // previous error is more informative than the redial's
71- // would be, so surface this one.
72- _ = b.conn.Close()
73- b.conn = nil
104+ conn := b.conn
105+ b.mu.Unlock()
106+
107+ stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() })
108+ if deadline, ok := ctx.Deadline(); ok {
109+ if err := conn.SetWriteDeadline(deadline); err != nil {
110+ stopCancel()
111+ return err
112+ }
113+ defer conn.SetWriteDeadline(time.Time{})
114+ }
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.
118+ stopCancel()
119+ b.mu.Lock()
120+ if b.conn == conn {
121+ _ = b.conn.Close()
122+ b.conn = nil
123+ }
124+ b.mu.Unlock()
125+ if ctxErr := ctx.Err(); ctxErr != nil {
126+ return ctxErr
127+ }
74128 return err
75129 }
130+ 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.
133+ b.mu.Lock()
134+ if b.conn == conn {
135+ b.conn = nil
136+ }
137+ b.mu.Unlock()
138+ return ctx.Err()
139+ }
76140 return nil
77141 }
78142
143+func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) {
144+ b.mu.Lock()
145+ if b.send == nil {
146+ b.send = make(chan struct{}, 1)
147+ b.send <- struct{}{}
148+ }
149+ send := b.send
150+ b.mu.Unlock()
151+ select {
152+ case <-ctx.Done():
153+ return nil, ctx.Err()
154+ case <-send:
155+ return send, nil
156+ }
157+}
158+
159+func releaseSend(send chan struct{}) {
160+ send <- struct{}{}
161+}
162+
79163 // Close drops any persistent connection. Safe to call repeatedly.
80164 // Does not unlink the socket file — that's the child's job
81165 // (Listener.Close).
@@ -95,29 +179,48 @@ func (b *Inbox) Close() error {
95179 // the TUI surfaces it as "agent <id> not listening yet".
96180 var ErrNotReady = errors.New("subagent: agent inbox not ready")
97181
98-// dialUnix wraps net.DialTimeout with a small retry window so the
99-// happy path "child just started, supervisor sends right away"
100-// works without forcing the caller to poll.
101-//
102-// Errors collapse onto ErrNotReady whenever the failure means "no
103-// one is listening": either the socket file doesn't exist (child
104-// hasn't booted) or it does exist but the dial was refused (the
105-// previous child exited and left a stale inode). Both are
106-// recoverable by Supervisor.Resume, and both should surface as the same
107-// user-facing hint rather than as a path-leaking error string.
108-func dialUnix(path string, timeout time.Duration) (net.Conn, error) {
182+// dialUnixContext retries a Unix-socket connection briefly so the happy path
183+// "child just started, supervisor sends right away" works without forcing the
184+// caller to poll. Errors collapse onto ErrNotReady whenever the failure means
185+// no worker is listening, without exposing the socket path to the caller.
186+func dialUnixContext(ctx context.Context, path string, timeout time.Duration) (net.Conn, error) {
187+ if ctx == nil {
188+ ctx = context.Background()
189+ }
109190 deadline := time.Now().Add(timeout)
110191 var lastErr error
111192 for {
112- c, err := net.DialTimeout("unix", path, 50*time.Millisecond)
193+ if err := ctx.Err(); err != nil {
194+ return nil, err
195+ }
196+ attemptTimeout := 50 * time.Millisecond
197+ if remaining := time.Until(deadline); remaining < attemptTimeout {
198+ attemptTimeout = remaining
199+ }
200+ if attemptTimeout <= 0 {
201+ break
202+ }
203+ c, err := (&net.Dialer{Timeout: attemptTimeout}).DialContext(ctx, "unix", path)
113204 if err == nil {
114205 return c, nil
115206 }
207+ if ctxErr := ctx.Err(); ctxErr != nil {
208+ return nil, ctxErr
209+ }
116210 lastErr = err
117211 if time.Now().After(deadline) {
118212 break
119213 }
120- time.Sleep(20 * time.Millisecond)
214+ wait := time.NewTimer(20 * time.Millisecond)
215+ select {
216+ case <-ctx.Done():
217+ wait.Stop()
218+ return nil, ctx.Err()
219+ case <-wait.C:
220+ }
221+ }
222+ if lastErr == nil {
223+ return nil, ErrNotReady
121224 }
122225 if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
123226 return nil, ErrNotReady
@@ -2,6 +2,7 @@ package subagents
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5+ "context"
5 "errors"6 "errors"
6 "fmt"7 "fmt"
7 "net"8 "net"
@@ -32,6 +33,10 @@ type Inbox struct {
32 33
33 mu sync.Mutex34 mu sync.Mutex
34 conn net.Conn // lazily dialed; one persistent connection per agent35 conn net.Conn // lazily dialed; one persistent connection per agent
36+
37+ // send is a one-slot semaphore that serializes JSONL writes without making
38+ // a caller wait behind another blocked write after its context is canceled.
39+ send chan struct{}
35 }40 }
36 41
37 // NewInbox returns a handle that will dial the socket at path on42 // NewInbox returns a handle that will dial the socket at path on
@@ -40,7 +45,11 @@ type Inbox struct {
40 // parent build the Inbox before the child has booted; sends that45 // parent build the Inbox before the child has booted; sends that
41 // happen before the child is ready get a clear "not yet" error46 // happen before the child is ready get a clear "not yet" error
42 // the caller can retry or surface.47 // the caller can retry or surface.
43-func NewInbox(path string) *Inbox { return &Inbox{path: path} }48+func NewInbox(path string) *Inbox {
49+ inbox := &Inbox{path: path, send: make(chan struct{}, 1)}
50+ inbox.send <- struct{}{}
51+ return inbox
52+}
44 53
45 // Path returns the absolute socket path. Used by the runner to54 // Path returns the absolute socket path. Used by the runner to
46 // pass the path to the child as a flag.55 // pass the path to the child as a flag.
@@ -48,34 +57,109 @@ func (b *Inbox) Path() string { return b.path }
48 57
49 // SendCommand sends a versioned JSONL command.58 // SendCommand sends a versioned JSONL command.
50 func (b *Inbox) SendCommand(command Envelope) error {59 func (b *Inbox) SendCommand(command Envelope) error {
60+ return b.SendCommandContext(context.Background(), command)
61+}
62+
63+// SendCommandContext sends a command while honoring cancellation during both
64+// connection setup and a blocked write. Cancellation closes the persistent
65+// connection so a later command redials instead of reusing a half-written
66+// stream.
67+func (b *Inbox) SendCommandContext(ctx context.Context, command Envelope) error {
51 line, err := MarshalJSONL(command)68 line, err := MarshalJSONL(command)
52 if err != nil {69 if err != nil {
53 return err70 return err
54 }71 }
55- return b.sendBytes(line)72+ return b.sendBytesContext(ctx, line)
56 }73 }
57 74
58 func (b *Inbox) sendBytes(data []byte) error {75 func (b *Inbox) sendBytes(data []byte) error {
76+ return b.sendBytesContext(context.Background(), data)
77+}
78+
79+func (b *Inbox) sendBytesContext(ctx context.Context, data []byte) error {
80+ if ctx == nil {
81+ ctx = context.Background()
82+ }
83+ if err := ctx.Err(); err != nil {
84+ return err
85+ }
86+ send, err := b.acquireSend(ctx)
87+ if err != nil {
88+ return err
89+ }
90+ defer releaseSend(send)
91+ if err := ctx.Err(); err != nil {
92+ return err
93+ }
94+
59 b.mu.Lock()95 b.mu.Lock()
60- defer b.mu.Unlock()
61 if b.conn == nil {96 if b.conn == nil {
62- c, err := dialUnix(b.path, 200*time.Millisecond)97+ c, err := dialUnixContext(ctx, b.path, 200*time.Millisecond)
63 if err != nil {98 if err != nil {
99+ b.mu.Unlock()
64 return err100 return err
65 }101 }
66 b.conn = c102 b.conn = c
67 }103 }
68- if _, err := b.conn.Write(data); err != nil {104+ conn := b.conn
69- // Drop the connection so the next call redials. The105+ b.mu.Unlock()
70- // previous error is more informative than the redial's106+
71- // would be, so surface this one.107+ stopCancel := context.AfterFunc(ctx, func() { _ = conn.Close() })
72- _ = b.conn.Close()108+ if deadline, ok := ctx.Deadline(); ok {
73- b.conn = nil109+ if err := conn.SetWriteDeadline(deadline); err != nil {
110+ stopCancel()
111+ return err
112+ }
113+ defer conn.SetWriteDeadline(time.Time{})
114+ }
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.
118+ stopCancel()
119+ b.mu.Lock()
120+ if b.conn == conn {
121+ _ = b.conn.Close()
122+ b.conn = nil
123+ }
124+ b.mu.Unlock()
125+ if ctxErr := ctx.Err(); ctxErr != nil {
126+ return ctxErr
127+ }
74 return err128 return err
75 }129 }
130+ 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.
133+ b.mu.Lock()
134+ if b.conn == conn {
135+ b.conn = nil
136+ }
137+ b.mu.Unlock()
138+ return ctx.Err()
139+ }
76 return nil140 return nil
77 }141 }
78 142
143+func (b *Inbox) acquireSend(ctx context.Context) (chan struct{}, error) {
144+ b.mu.Lock()
145+ if b.send == nil {
146+ b.send = make(chan struct{}, 1)
147+ b.send <- struct{}{}
148+ }
149+ send := b.send
150+ b.mu.Unlock()
151+ select {
152+ case <-ctx.Done():
153+ return nil, ctx.Err()
154+ case <-send:
155+ return send, nil
156+ }
157+}
158+
159+func releaseSend(send chan struct{}) {
160+ send <- struct{}{}
161+}
162+
79 // Close drops any persistent connection. Safe to call repeatedly.163 // Close drops any persistent connection. Safe to call repeatedly.
80 // Does not unlink the socket file — that's the child's job164 // Does not unlink the socket file — that's the child's job
81 // (Listener.Close).165 // (Listener.Close).
@@ -95,29 +179,48 @@ func (b *Inbox) Close() error {
95 // the TUI surfaces it as "agent <id> not listening yet".179 // the TUI surfaces it as "agent <id> not listening yet".
96 var ErrNotReady = errors.New("subagent: agent inbox not ready")180 var ErrNotReady = errors.New("subagent: agent inbox not ready")
97 181
98-// dialUnix wraps net.DialTimeout with a small retry window so the182+// dialUnixContext retries a Unix-socket connection briefly so the happy path
99-// happy path "child just started, supervisor sends right away"183+// "child just started, supervisor sends right away" works without forcing the
100-// works without forcing the caller to poll.184+// caller to poll. Errors collapse onto ErrNotReady whenever the failure means
101-//185+// no worker is listening, without exposing the socket path to the caller.
102-// Errors collapse onto ErrNotReady whenever the failure means "no186+func dialUnixContext(ctx context.Context, path string, timeout time.Duration) (net.Conn, error) {
103-// one is listening": either the socket file doesn't exist (child187+ if ctx == nil {
104-// hasn't booted) or it does exist but the dial was refused (the188+ ctx = context.Background()
105-// previous child exited and left a stale inode). Both are189+ }
106-// recoverable by Supervisor.Resume, and both should surface as the same
107-// user-facing hint rather than as a path-leaking error string.
108-func dialUnix(path string, timeout time.Duration) (net.Conn, error) {
109 deadline := time.Now().Add(timeout)190 deadline := time.Now().Add(timeout)
110 var lastErr error191 var lastErr error
111 for {192 for {
112- c, err := net.DialTimeout("unix", path, 50*time.Millisecond)193+ if err := ctx.Err(); err != nil {
194+ return nil, err
195+ }
196+ attemptTimeout := 50 * time.Millisecond
197+ if remaining := time.Until(deadline); remaining < attemptTimeout {
198+ attemptTimeout = remaining
199+ }
200+ if attemptTimeout <= 0 {
201+ break
202+ }
203+ c, err := (&net.Dialer{Timeout: attemptTimeout}).DialContext(ctx, "unix", path)
113 if err == nil {204 if err == nil {
114 return c, nil205 return c, nil
115 }206 }
207+ if ctxErr := ctx.Err(); ctxErr != nil {
208+ return nil, ctxErr
209+ }
116 lastErr = err210 lastErr = err
117 if time.Now().After(deadline) {211 if time.Now().After(deadline) {
118 break212 break
119 }213 }
120- time.Sleep(20 * time.Millisecond)214+ wait := time.NewTimer(20 * time.Millisecond)
215+ select {
216+ case <-ctx.Done():
217+ wait.Stop()
218+ return nil, ctx.Err()
219+ case <-wait.C:
220+ }
221+ }
222+ if lastErr == nil {
223+ return nil, ErrNotReady
121 }224 }
122 if _, statErr := os.Stat(path); os.IsNotExist(statErr) {225 if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
123 return nil, ErrNotReady226 return nil, ErrNotReady
M packages/agent/subagents/inbox_test.go +123 -0
@@ -1,8 +1,10 @@
11 package subagents
22
33 import (
4+ "context"
45 "errors"
56 "fmt"
7+ "net"
68 "os"
79 "path/filepath"
810 "runtime"
@@ -93,6 +95,127 @@ func TestInboxNotReady(t *testing.T) {
9395 }
9496 }
9597
98+func TestDialUnixContextNonPositiveTimeoutIsNotReady(t *testing.T) {
99+ path := filepath.Join(t.TempDir(), "existing")
100+ if err := os.WriteFile(path, nil, 0o600); err != nil {
101+ t.Fatal(err)
102+ }
103+ for _, timeout := range []time.Duration{0, -time.Millisecond} {
104+ _, err := dialUnixContext(context.Background(), path, timeout)
105+ if !errors.Is(err, ErrNotReady) {
106+ t.Fatalf("dial timeout %s error = %v, want ErrNotReady", timeout, err)
107+ }
108+ }
109+}
110+
111+type blockingWriteConn struct {
112+ net.Conn
113+ started chan struct{}
114+ release chan struct{}
115+ startOnce sync.Once
116+ closeOnce sync.Once
117+}
118+
119+func (c *blockingWriteConn) Write(data []byte) (int, error) {
120+ c.startOnce.Do(func() { close(c.started) })
121+ <-c.release
122+ return c.Conn.Write(data)
123+}
124+
125+func (c *blockingWriteConn) Close() error {
126+ c.closeOnce.Do(func() { close(c.release) })
127+ return c.Conn.Close()
128+}
129+
130+type notifyingContext struct {
131+ context.Context
132+ doneCalled chan struct{}
133+ once sync.Once
134+}
135+
136+func (c *notifyingContext) Done() <-chan struct{} {
137+ c.once.Do(func() { close(c.doneCalled) })
138+ return c.Context.Done()
139+}
140+
141+func TestInboxSendContextCancelsBlockedWrite(t *testing.T) {
142+ local, remote := net.Pipe()
143+ defer remote.Close()
144+ conn := &blockingWriteConn{
145+ Conn: local,
146+ started: make(chan struct{}),
147+ release: make(chan struct{}),
148+ }
149+ inbox := NewInbox("unused")
150+ inbox.conn = conn
151+ defer inbox.Close()
152+
153+ ctx, cancel := context.WithCancel(context.Background())
154+ sent := make(chan error, 1)
155+ go func() { sent <- inbox.sendBytesContext(ctx, []byte("follow-up\n")) }()
156+ select {
157+ case <-conn.started:
158+ case <-time.After(time.Second):
159+ t.Fatal("write did not start")
160+ }
161+ cancel()
162+ select {
163+ case err := <-sent:
164+ if !errors.Is(err, context.Canceled) {
165+ t.Fatalf("blocked write error = %v, want context canceled", err)
166+ }
167+ case <-time.After(time.Second):
168+ t.Fatal("blocked write did not honor context cancellation")
169+ }
170+}
171+
172+func TestInboxSendContextCancelsWhileAnotherWriteIsBlocked(t *testing.T) {
173+ local, remote := net.Pipe()
174+ defer remote.Close()
175+ conn := &blockingWriteConn{
176+ Conn: local,
177+ started: make(chan struct{}),
178+ release: make(chan struct{}),
179+ }
180+ inbox := NewInbox("unused")
181+ inbox.conn = conn
182+ defer inbox.Close()
183+
184+ first := make(chan error, 1)
185+ go func() { first <- inbox.sendBytesContext(context.Background(), []byte("first\n")) }()
186+ select {
187+ case <-conn.started:
188+ case <-time.After(time.Second):
189+ t.Fatal("first write did not start")
190+ }
191+
192+ base, cancel := context.WithCancel(context.Background())
193+ secondCtx := &notifyingContext{Context: base, doneCalled: make(chan struct{})}
194+ second := make(chan error, 1)
195+ go func() { second <- inbox.sendBytesContext(secondCtx, []byte("second\n")) }()
196+ select {
197+ case <-secondCtx.doneCalled:
198+ case <-time.After(time.Second):
199+ t.Fatal("second write did not wait for the send semaphore")
200+ }
201+ cancel()
202+ select {
203+ case err := <-second:
204+ if !errors.Is(err, context.Canceled) {
205+ t.Fatalf("waiting write error = %v, want context canceled", err)
206+ }
207+ case <-time.After(time.Second):
208+ t.Fatal("waiting write did not honor context cancellation")
209+ }
210+
211+ _ = conn.Close()
212+ select {
213+ case <-first:
214+ case <-time.After(time.Second):
215+ t.Fatal("first write did not unblock")
216+ }
217+}
218+
96219 // TestIsNoListenerErrMatchesECONNREFUSED documents the contract
97220 // that the user's screenshot regression depends on: any dial error
98221 // whose root cause is "the kernel refused the connect because nobody
@@ -1,8 +1,10 @@
1 package subagents1 package subagents
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
7+ "net"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
8 "runtime"10 "runtime"
@@ -93,6 +95,127 @@ func TestInboxNotReady(t *testing.T) {
93 }95 }
94 }96 }
95 97
98+func TestDialUnixContextNonPositiveTimeoutIsNotReady(t *testing.T) {
99+ path := filepath.Join(t.TempDir(), "existing")
100+ if err := os.WriteFile(path, nil, 0o600); err != nil {
101+ t.Fatal(err)
102+ }
103+ for _, timeout := range []time.Duration{0, -time.Millisecond} {
104+ _, err := dialUnixContext(context.Background(), path, timeout)
105+ if !errors.Is(err, ErrNotReady) {
106+ t.Fatalf("dial timeout %s error = %v, want ErrNotReady", timeout, err)
107+ }
108+ }
109+}
110+
111+type blockingWriteConn struct {
112+ net.Conn
113+ started chan struct{}
114+ release chan struct{}
115+ startOnce sync.Once
116+ closeOnce sync.Once
117+}
118+
119+func (c *blockingWriteConn) Write(data []byte) (int, error) {
120+ c.startOnce.Do(func() { close(c.started) })
121+ <-c.release
122+ return c.Conn.Write(data)
123+}
124+
125+func (c *blockingWriteConn) Close() error {
126+ c.closeOnce.Do(func() { close(c.release) })
127+ return c.Conn.Close()
128+}
129+
130+type notifyingContext struct {
131+ context.Context
132+ doneCalled chan struct{}
133+ once sync.Once
134+}
135+
136+func (c *notifyingContext) Done() <-chan struct{} {
137+ c.once.Do(func() { close(c.doneCalled) })
138+ return c.Context.Done()
139+}
140+
141+func TestInboxSendContextCancelsBlockedWrite(t *testing.T) {
142+ local, remote := net.Pipe()
143+ defer remote.Close()
144+ conn := &blockingWriteConn{
145+ Conn: local,
146+ started: make(chan struct{}),
147+ release: make(chan struct{}),
148+ }
149+ inbox := NewInbox("unused")
150+ inbox.conn = conn
151+ defer inbox.Close()
152+
153+ ctx, cancel := context.WithCancel(context.Background())
154+ sent := make(chan error, 1)
155+ go func() { sent <- inbox.sendBytesContext(ctx, []byte("follow-up\n")) }()
156+ select {
157+ case <-conn.started:
158+ case <-time.After(time.Second):
159+ t.Fatal("write did not start")
160+ }
161+ cancel()
162+ select {
163+ case err := <-sent:
164+ if !errors.Is(err, context.Canceled) {
165+ t.Fatalf("blocked write error = %v, want context canceled", err)
166+ }
167+ case <-time.After(time.Second):
168+ t.Fatal("blocked write did not honor context cancellation")
169+ }
170+}
171+
172+func TestInboxSendContextCancelsWhileAnotherWriteIsBlocked(t *testing.T) {
173+ local, remote := net.Pipe()
174+ defer remote.Close()
175+ conn := &blockingWriteConn{
176+ Conn: local,
177+ started: make(chan struct{}),
178+ release: make(chan struct{}),
179+ }
180+ inbox := NewInbox("unused")
181+ inbox.conn = conn
182+ defer inbox.Close()
183+
184+ first := make(chan error, 1)
185+ go func() { first <- inbox.sendBytesContext(context.Background(), []byte("first\n")) }()
186+ select {
187+ case <-conn.started:
188+ case <-time.After(time.Second):
189+ t.Fatal("first write did not start")
190+ }
191+
192+ base, cancel := context.WithCancel(context.Background())
193+ secondCtx := &notifyingContext{Context: base, doneCalled: make(chan struct{})}
194+ second := make(chan error, 1)
195+ go func() { second <- inbox.sendBytesContext(secondCtx, []byte("second\n")) }()
196+ select {
197+ case <-secondCtx.doneCalled:
198+ case <-time.After(time.Second):
199+ t.Fatal("second write did not wait for the send semaphore")
200+ }
201+ cancel()
202+ select {
203+ case err := <-second:
204+ if !errors.Is(err, context.Canceled) {
205+ t.Fatalf("waiting write error = %v, want context canceled", err)
206+ }
207+ case <-time.After(time.Second):
208+ t.Fatal("waiting write did not honor context cancellation")
209+ }
210+
211+ _ = conn.Close()
212+ select {
213+ case <-first:
214+ case <-time.After(time.Second):
215+ t.Fatal("first write did not unblock")
216+ }
217+}
218+
96 // TestIsNoListenerErrMatchesECONNREFUSED documents the contract219 // TestIsNoListenerErrMatchesECONNREFUSED documents the contract
97 // that the user's screenshot regression depends on: any dial error220 // that the user's screenshot regression depends on: any dial error
98 // whose root cause is "the kernel refused the connect because nobody221 // whose root cause is "the kernel refused the connect because nobody
M packages/agent/subagents/persist.go +103 -6
@@ -74,6 +74,11 @@ type agentMeta struct {
7474 InboxPath string `json:"inbox_path"`
7575 EventLogPath string `json:"event_log_path"`
7676 SessionPath string `json:"session_path"`
77+ // ResumePrompt is a follow-up accepted by subagent_resume but not yet
78+ // acknowledged by the child with a turn.started event. It is durable so a
79+ // 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"`
7782
7883 // SessionID, when non-empty, scopes the agent to a particular
7984 // host zut session: the dashboard only shows agents whose
@@ -97,6 +102,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
97102 status := a.status
98103 activity := a.activity
99104 finished := a.finished
105+ resumePrompt := a.resumePromptText
106+ resumePromptAt := a.resumePromptAt
100107 errText := ""
101108 if a.lastErr != nil {
102109 errText = a.lastErr.Error()
@@ -156,6 +163,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
156163 InboxPath: a.InboxPath,
157164 EventLogPath: a.EventLogPath,
158165 SessionPath: a.SessionPath,
166+ ResumePrompt: resumePrompt,
167+ ResumePromptAt: resumePromptAt,
159168 SessionID: a.SessionID,
160169 }
161170 b, err := json.MarshalIndent(m, "", " ")
@@ -440,6 +449,8 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
440449 InboxPath: m.InboxPath,
441450 EventLogPath: m.EventLogPath,
442451 SessionPath: m.SessionPath,
452+ resumePromptText: m.ResumePrompt,
453+ resumePromptAt: m.ResumePromptAt,
443454 SessionID: m.SessionID,
444455 inbox: NewInbox(m.InboxPath),
445456 status: status,
@@ -531,6 +542,9 @@ func (a *Agent) loadTranscript() error {
531542 if err != nil {
532543 return fmt.Errorf("read subagent transcript: %w", err)
533544 }
545+ if prompt, acceptedAt := a.ResumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, acceptedAt) {
546+ a.clearResumePrompt()
547+ }
534548 a.mu.Lock()
535549 if a.transcriptLoaded {
536550 a.mu.Unlock()
@@ -569,6 +583,18 @@ func (a *Agent) loadTranscript() error {
569583 // Status precedence: explicit lifecycle events (agent_stopped) win
570584 // over inferred ones (assistant_message → idle). If the log contains
571585 // no terminator we keep status=StatusDetached so the user can resume.
586+func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {
587+ if acceptedAt.IsZero() {
588+ return false
589+ }
590+ for _, ev := range events {
591+ if (ev.Type == EventTurnStarted || ev.Type == "turn_start") && !ev.Time.Before(acceptedAt) {
592+ return true
593+ }
594+ }
595+ return false
596+}
597+
572598 func replayEventsIntoAgent(a *Agent, evs []Event) {
573599 terminal := false
574600 for _, ev := range evs {
@@ -698,22 +724,76 @@ func replayEventTranscript(a *Agent, ev Event) {
698724 // Killed). Resuming a still-running agent returns an error so two
699725 // runners don't race for the same session.
700726 func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {
701- return f.resume(ctx, id, true)
727+ return f.resume(ctx, id, true, "")
702728 }
703729
704730 // ResumeSession continues the existing child session without replaying the
705731 // original task. It is the explicit canonical spelling for Resume.
706732 func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {
707- return f.resume(ctx, id, true)
733+ return f.resume(ctx, id, true, "")
734+}
735+
736+// ResumeWithPrompt continues an agent with a manager follow-up while retaining
737+// 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.
740+func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
741+ if strings.TrimSpace(prompt) == "" {
742+ return nil, fmt.Errorf("subagent: follow-up prompt is required")
743+ }
744+
745+ f.operationMu.Lock()
746+ existing := f.Get(id)
747+ if existing == nil {
748+ f.operationMu.Unlock()
749+ return nil, fmt.Errorf("subagent: no such agent %q", id)
750+ }
751+ status := existing.Status()
752+ if status == StatusRunning {
753+ if existing.ProcessState() != ProcessAlive || existing.TurnState() != TurnIdle {
754+ 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)
756+ }
757+ if existing.inbox == nil {
758+ f.operationMu.Unlock()
759+ return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID)
760+ }
761+ turnID := existing.CurrentTurnID()
762+ // Reserve the worker before writing to its inbox. A second manager call
763+ // must not enqueue another turn in the small interval before the worker
764+ // reports its turn.start event. Persist the prompt first so a host exit
765+ // before that event can resume and redeliver it.
766+ previousPrompt, previousPromptAt := existing.setResumePrompt(prompt, f.cfg.Now())
767+ existing.setTurnState(TurnQueued, turnID)
768+ inbox := existing.inbox
769+ command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt})
770+ f.persistAgent(existing)
771+ f.operationMu.Unlock()
772+
773+ if err := inbox.SendCommandContext(ctx, command); err != nil {
774+ f.operationMu.Lock()
775+ if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID {
776+ existing.setTurnState(TurnIdle, turnID)
777+ existing.setResumePrompt(previousPrompt, previousPromptAt)
778+ f.persistAgent(existing)
779+ }
780+ f.operationMu.Unlock()
781+ return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err)
782+ }
783+ return existing, nil
784+ }
785+ f.operationMu.Unlock()
786+
787+ return f.resume(ctx, id, true, prompt)
708788 }
709789
710790 // RestartTask intentionally runs the stored original task again in a fresh
711791 // worker attempt. Callers should use ResumeSession for normal recovery.
712792 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
713- return f.resume(ctx, id, false)
793+ return f.resume(ctx, id, false, "")
714794 }
715795
716-func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Agent, error) {
796+func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {
717797 f.operationMu.Lock()
718798 defer f.operationMu.Unlock()
719799 if ctx == nil {
@@ -741,6 +821,14 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
741821 if inboxLive(existing.InboxPath) {
742822 return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID)
743823 }
824+ // A fresh Reload does not hydrate its event log. Before replaying a pending
825+ // follow-up, hydrate it to see whether the child already acknowledged the
826+ // turn before the previous host exited.
827+ if resuming && existing.resumePrompt() != "" {
828+ if err := existing.loadTranscript(); err != nil {
829+ return nil, fmt.Errorf("subagent: replay pending follow-up: %w", err)
830+ }
831+ }
744832 stateDir := existing.stateDirectory(f.cfg.Root)
745833 lease, err := acquireAgentLease(stateDir)
746834 if err != nil {
@@ -762,7 +850,13 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
762850 if err != nil {
763851 return nil, fmt.Errorf("subagent inbox path: %w", err)
764852 }
853+ now := f.cfg.Now()
765854 fastMode := existing.FastMode
855+ pendingResumePrompt, pendingResumePromptAt := existing.ResumePromptInfo()
856+ if resumePrompt != "" {
857+ pendingResumePrompt = resumePrompt
858+ pendingResumePromptAt = now
859+ }
766860 m := agentMeta{
767861 ID: existing.ID, Task: existing.Task,
768862 OriginalTask: existing.OriginalTask,
@@ -779,7 +873,9 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
779873 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
780874 SessionID: existing.SessionID,
781875 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
782- SessionPath: existing.SessionPath,
876+ SessionPath: existing.SessionPath,
877+ ResumePrompt: pendingResumePrompt,
878+ ResumePromptAt: pendingResumePromptAt,
783879 }
784880
785881 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
@@ -805,7 +901,6 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
805901 if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {
806902 return nil, err
807903 }
808- now := f.cfg.Now()
809904 maxTurns := m.MaxTurns
810905 if maxTurns <= 0 {
811906 maxTurns = f.cfg.Policy.MaxTurns
@@ -868,6 +963,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
868963 EventLogPath: m.EventLogPath,
869964 SessionPath: m.SessionPath,
870965 Resuming: resuming,
966+ resumePromptText: pendingResumePrompt,
967+ resumePromptAt: pendingResumePromptAt,
871968 inbox: NewInbox(m.InboxPath),
872969 status: StatusPending,
873970 activity: "resuming",
@@ -74,6 +74,11 @@ type agentMeta struct {
74 InboxPath string `json:"inbox_path"`74 InboxPath string `json:"inbox_path"`
75 EventLogPath string `json:"event_log_path"`75 EventLogPath string `json:"event_log_path"`
76 SessionPath string `json:"session_path"`76 SessionPath string `json:"session_path"`
77+ // ResumePrompt is a follow-up accepted by subagent_resume but not yet
78+ // acknowledged by the child with a turn.started event. It is durable so a
79+ // 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"`
77 82
78 // SessionID, when non-empty, scopes the agent to a particular83 // SessionID, when non-empty, scopes the agent to a particular
79 // host zut session: the dashboard only shows agents whose84 // host zut session: the dashboard only shows agents whose
@@ -97,6 +102,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
97 status := a.status102 status := a.status
98 activity := a.activity103 activity := a.activity
99 finished := a.finished104 finished := a.finished
105+ resumePrompt := a.resumePromptText
106+ resumePromptAt := a.resumePromptAt
100 errText := ""107 errText := ""
101 if a.lastErr != nil {108 if a.lastErr != nil {
102 errText = a.lastErr.Error()109 errText = a.lastErr.Error()
@@ -156,6 +163,8 @@ func writeAgentMeta(stateDir string, a *Agent) error {
156 InboxPath: a.InboxPath,163 InboxPath: a.InboxPath,
157 EventLogPath: a.EventLogPath,164 EventLogPath: a.EventLogPath,
158 SessionPath: a.SessionPath,165 SessionPath: a.SessionPath,
166+ ResumePrompt: resumePrompt,
167+ ResumePromptAt: resumePromptAt,
159 SessionID: a.SessionID,168 SessionID: a.SessionID,
160 }169 }
161 b, err := json.MarshalIndent(m, "", " ")170 b, err := json.MarshalIndent(m, "", " ")
@@ -440,6 +449,8 @@ func (f *Supervisor) buildDetachedAgent(m agentMeta) *Agent {
440 InboxPath: m.InboxPath,449 InboxPath: m.InboxPath,
441 EventLogPath: m.EventLogPath,450 EventLogPath: m.EventLogPath,
442 SessionPath: m.SessionPath,451 SessionPath: m.SessionPath,
452+ resumePromptText: m.ResumePrompt,
453+ resumePromptAt: m.ResumePromptAt,
443 SessionID: m.SessionID,454 SessionID: m.SessionID,
444 inbox: NewInbox(m.InboxPath),455 inbox: NewInbox(m.InboxPath),
445 status: status,456 status: status,
@@ -531,6 +542,9 @@ func (a *Agent) loadTranscript() error {
531 if err != nil {542 if err != nil {
532 return fmt.Errorf("read subagent transcript: %w", err)543 return fmt.Errorf("read subagent transcript: %w", err)
533 }544 }
545+ if prompt, acceptedAt := a.ResumePromptInfo(); prompt != "" && resumePromptAcknowledged(events, acceptedAt) {
546+ a.clearResumePrompt()
547+ }
534 a.mu.Lock()548 a.mu.Lock()
535 if a.transcriptLoaded {549 if a.transcriptLoaded {
536 a.mu.Unlock()550 a.mu.Unlock()
@@ -569,6 +583,18 @@ func (a *Agent) loadTranscript() error {
569 // Status precedence: explicit lifecycle events (agent_stopped) win583 // Status precedence: explicit lifecycle events (agent_stopped) win
570 // over inferred ones (assistant_message → idle). If the log contains584 // over inferred ones (assistant_message → idle). If the log contains
571 // no terminator we keep status=StatusDetached so the user can resume.585 // no terminator we keep status=StatusDetached so the user can resume.
586+func resumePromptAcknowledged(events []Event, acceptedAt time.Time) bool {
587+ if acceptedAt.IsZero() {
588+ return false
589+ }
590+ for _, ev := range events {
591+ if (ev.Type == EventTurnStarted || ev.Type == "turn_start") && !ev.Time.Before(acceptedAt) {
592+ return true
593+ }
594+ }
595+ return false
596+}
597+
572 func replayEventsIntoAgent(a *Agent, evs []Event) {598 func replayEventsIntoAgent(a *Agent, evs []Event) {
573 terminal := false599 terminal := false
574 for _, ev := range evs {600 for _, ev := range evs {
@@ -698,22 +724,76 @@ func replayEventTranscript(a *Agent, ev Event) {
698 // Killed). Resuming a still-running agent returns an error so two724 // Killed). Resuming a still-running agent returns an error so two
699 // runners don't race for the same session.725 // runners don't race for the same session.
700 func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {726 func (f *Supervisor) Resume(ctx context.Context, id string) (*Agent, error) {
701- return f.resume(ctx, id, true)727+ return f.resume(ctx, id, true, "")
702 }728 }
703 729
704 // ResumeSession continues the existing child session without replaying the730 // ResumeSession continues the existing child session without replaying the
705 // original task. It is the explicit canonical spelling for Resume.731 // original task. It is the explicit canonical spelling for Resume.
706 func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {732 func (f *Supervisor) ResumeSession(ctx context.Context, id string) (*Agent, error) {
707- return f.resume(ctx, id, true)733+ return f.resume(ctx, id, true, "")
734+}
735+
736+// ResumeWithPrompt continues an agent with a manager follow-up while retaining
737+// 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.
740+func (f *Supervisor) ResumeWithPrompt(ctx context.Context, id, prompt string) (*Agent, error) {
741+ if strings.TrimSpace(prompt) == "" {
742+ return nil, fmt.Errorf("subagent: follow-up prompt is required")
743+ }
744+
745+ f.operationMu.Lock()
746+ existing := f.Get(id)
747+ if existing == nil {
748+ f.operationMu.Unlock()
749+ return nil, fmt.Errorf("subagent: no such agent %q", id)
750+ }
751+ status := existing.Status()
752+ if status == StatusRunning {
753+ if existing.ProcessState() != ProcessAlive || existing.TurnState() != TurnIdle {
754+ 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)
756+ }
757+ if existing.inbox == nil {
758+ f.operationMu.Unlock()
759+ return nil, fmt.Errorf("subagent: agent %s has no inbox", existing.ID)
760+ }
761+ turnID := existing.CurrentTurnID()
762+ // Reserve the worker before writing to its inbox. A second manager call
763+ // must not enqueue another turn in the small interval before the worker
764+ // reports its turn.start event. Persist the prompt first so a host exit
765+ // before that event can resume and redeliver it.
766+ previousPrompt, previousPromptAt := existing.setResumePrompt(prompt, f.cfg.Now())
767+ existing.setTurnState(TurnQueued, turnID)
768+ inbox := existing.inbox
769+ command := NewCommand(CommandTurnStart, existing.ID, turnID, TurnStartPayload{Prompt: prompt})
770+ f.persistAgent(existing)
771+ f.operationMu.Unlock()
772+
773+ if err := inbox.SendCommandContext(ctx, command); err != nil {
774+ f.operationMu.Lock()
775+ if existing.Status() == StatusRunning && existing.TurnState() == TurnQueued && existing.CurrentTurnID() == turnID {
776+ existing.setTurnState(TurnIdle, turnID)
777+ existing.setResumePrompt(previousPrompt, previousPromptAt)
778+ f.persistAgent(existing)
779+ }
780+ f.operationMu.Unlock()
781+ return nil, fmt.Errorf("subagent: send follow-up to %s: %w", existing.ID, err)
782+ }
783+ return existing, nil
784+ }
785+ f.operationMu.Unlock()
786+
787+ return f.resume(ctx, id, true, prompt)
708 }788 }
709 789
710 // RestartTask intentionally runs the stored original task again in a fresh790 // RestartTask intentionally runs the stored original task again in a fresh
711 // worker attempt. Callers should use ResumeSession for normal recovery.791 // worker attempt. Callers should use ResumeSession for normal recovery.
712 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {792 func (f *Supervisor) RestartTask(ctx context.Context, id string) (*Agent, error) {
713- return f.resume(ctx, id, false)793+ return f.resume(ctx, id, false, "")
714 }794 }
715 795
716-func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Agent, error) {796+func (f *Supervisor) resume(ctx context.Context, id string, resuming bool, resumePrompt string) (*Agent, error) {
717 f.operationMu.Lock()797 f.operationMu.Lock()
718 defer f.operationMu.Unlock()798 defer f.operationMu.Unlock()
719 if ctx == nil {799 if ctx == nil {
@@ -741,6 +821,14 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
741 if inboxLive(existing.InboxPath) {821 if inboxLive(existing.InboxPath) {
742 return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID)822 return nil, fmt.Errorf("subagent: agent %s still has a live worker; send shutdown or stop it first", existing.ID)
743 }823 }
824+ // A fresh Reload does not hydrate its event log. Before replaying a pending
825+ // follow-up, hydrate it to see whether the child already acknowledged the
826+ // turn before the previous host exited.
827+ if resuming && existing.resumePrompt() != "" {
828+ if err := existing.loadTranscript(); err != nil {
829+ return nil, fmt.Errorf("subagent: replay pending follow-up: %w", err)
830+ }
831+ }
744 stateDir := existing.stateDirectory(f.cfg.Root)832 stateDir := existing.stateDirectory(f.cfg.Root)
745 lease, err := acquireAgentLease(stateDir)833 lease, err := acquireAgentLease(stateDir)
746 if err != nil {834 if err != nil {
@@ -762,7 +850,13 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
762 if err != nil {850 if err != nil {
763 return nil, fmt.Errorf("subagent inbox path: %w", err)851 return nil, fmt.Errorf("subagent inbox path: %w", err)
764 }852 }
853+ now := f.cfg.Now()
765 fastMode := existing.FastMode854 fastMode := existing.FastMode
855+ pendingResumePrompt, pendingResumePromptAt := existing.ResumePromptInfo()
856+ if resumePrompt != "" {
857+ pendingResumePrompt = resumePrompt
858+ pendingResumePromptAt = now
859+ }
766 m := agentMeta{860 m := agentMeta{
767 ID: existing.ID, Task: existing.Task,861 ID: existing.ID, Task: existing.Task,
768 OriginalTask: existing.OriginalTask,862 OriginalTask: existing.OriginalTask,
@@ -779,7 +873,9 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
779 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,873 CurrentTurnID: existingSnapshot.CurrentTurnID, Attempt: existingSnapshot.Attempt,
780 SessionID: existing.SessionID,874 SessionID: existing.SessionID,
781 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,875 InboxPath: inboxPath, EventLogPath: existing.EventLogPath,
782- SessionPath: existing.SessionPath,876+ SessionPath: existing.SessionPath,
877+ ResumePrompt: pendingResumePrompt,
878+ ResumePromptAt: pendingResumePromptAt,
783 }879 }
784 880
785 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {881 if len(m.Tools) == 0 && len(f.cfg.Policy.AllowedTools) > 0 {
@@ -805,7 +901,6 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
805 if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {901 if err := validateWorkspaceRoot(repositoryRoot, workspaceMode, allowedRoots); err != nil {
806 return nil, err902 return nil, err
807 }903 }
808- now := f.cfg.Now()
809 maxTurns := m.MaxTurns904 maxTurns := m.MaxTurns
810 if maxTurns <= 0 {905 if maxTurns <= 0 {
811 maxTurns = f.cfg.Policy.MaxTurns906 maxTurns = f.cfg.Policy.MaxTurns
@@ -868,6 +963,8 @@ func (f *Supervisor) resume(ctx context.Context, id string, resuming bool) (*Age
868 EventLogPath: m.EventLogPath,963 EventLogPath: m.EventLogPath,
869 SessionPath: m.SessionPath,964 SessionPath: m.SessionPath,
870 Resuming: resuming,965 Resuming: resuming,
966+ resumePromptText: pendingResumePrompt,
967+ resumePromptAt: pendingResumePromptAt,
871 inbox: NewInbox(m.InboxPath),968 inbox: NewInbox(m.InboxPath),
872 status: StatusPending,969 status: StatusPending,
873 activity: "resuming",970 activity: "resuming",
M packages/agent/subagents/persist_test.go +276 -0
@@ -513,6 +513,69 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
513513 }
514514 }
515515
516+func TestRestartTaskRetainsQueuedResumePrompt(t *testing.T) {
517+ root := t.TempDir()
518+ started := make(chan *Agent, 2)
519+ f := New(Config{
520+ Root: root, RepoRoot: root,
521+ NewRunner: func(a *Agent) Runner {
522+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
523+ started <- a
524+ <-ctx.Done()
525+ return ctx.Err()
526+ })
527+ },
528+ })
529+ t.Cleanup(f.StopAll)
530+
531+ existing, err := f.Spawn(context.Background(), "review the implementation")
532+ if err != nil {
533+ t.Fatal(err)
534+ }
535+ select {
536+ case got := <-started:
537+ if got != existing {
538+ t.Fatalf("initial runner agent = %p, want %p", got, existing)
539+ }
540+ case <-time.After(time.Second):
541+ t.Fatal("initial runner did not start")
542+ }
543+
544+ const followUp = "I applied your review. What do you think now?"
545+ acceptedAt := time.Now().Add(-time.Minute)
546+ existing.setResumePrompt(followUp, acceptedAt)
547+ f.persistAgent(existing)
548+ if err := f.Stop(existing.ID); err != nil {
549+ t.Fatal(err)
550+ }
551+ existing.Wait()
552+
553+ restarted, err := f.RestartTask(context.Background(), existing.ID)
554+ if err != nil {
555+ t.Fatal(err)
556+ }
557+ select {
558+ case got := <-started:
559+ if got != restarted {
560+ t.Fatalf("restarted runner agent = %p, want %p", got, restarted)
561+ }
562+ resumePrompt, resumePromptAt := got.ResumePromptInfo()
563+ if got.Resuming || resumePrompt != followUp || !resumePromptAt.Equal(acceptedAt) {
564+ t.Fatalf("restarted lifecycle = resuming %t prompt %q accepted %s, want false, prompt %q, and accepted %s", got.Resuming, resumePrompt, resumePromptAt, followUp, acceptedAt)
565+ }
566+ case <-time.After(time.Second):
567+ t.Fatal("restarted runner did not start")
568+ }
569+
570+ persisted, err := readAgentMeta(filepath.Join(root, "agents", existing.ID))
571+ if err != nil {
572+ t.Fatal(err)
573+ }
574+ if persisted.ResumePrompt != followUp || !persisted.ResumePromptAt.Equal(acceptedAt) {
575+ t.Fatalf("persisted restart prompt = %q accepted %s, want prompt %q and accepted %s", persisted.ResumePrompt, persisted.ResumePromptAt, followUp, acceptedAt)
576+ }
577+}
578+
516579 func TestResumeRecalculatesInvalidStateInboxPath(t *testing.T) {
517580 if runtime.GOOS == "windows" {
518581 t.Skip("subagent inbox runtime relocation applies to Unix sockets")
@@ -583,6 +646,219 @@ func TestResumeSetsResumingFlag(t *testing.T) {
583646 }
584647 }
585648
649+func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) {
650+ root := t.TempDir()
651+ stateDir := filepath.Join(root, "agents", "acknowledged-follow-up")
652+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
653+ t.Fatal(err)
654+ }
655+ m := agentMeta{
656+ ID: "acknowledged-follow-up",
657+ Task: "review the implementation",
658+ Dir: root,
659+ Started: time.Now().Add(-time.Minute),
660+ Status: StatusPending,
661+ InboxPath: filepath.Join(stateDir, "in.sock"),
662+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
663+ SessionPath: filepath.Join(stateDir, "session.json"),
664+ ResumePrompt: "I applied your review. What do you think now?",
665+ ResumePromptAt: time.Now(),
666+ }
667+ data, err := json.Marshal(m)
668+ if err != nil {
669+ t.Fatal(err)
670+ }
671+ if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil {
672+ t.Fatal(err)
673+ }
674+ log, err := OpenEventLog(m.EventLogPath)
675+ if err != nil {
676+ t.Fatal(err)
677+ }
678+ if err := log.Append(NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})); err != nil {
679+ t.Fatal(err)
680+ }
681+ if err := log.Close(); err != nil {
682+ t.Fatal(err)
683+ }
684+
685+ started := make(chan *Agent, 1)
686+ f := New(Config{
687+ Root: root, RepoRoot: root,
688+ NewRunner: func(a *Agent) Runner {
689+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
690+ started <- a
691+ <-ctx.Done()
692+ return ctx.Err()
693+ })
694+ },
695+ })
696+ t.Cleanup(f.StopAll)
697+ if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 {
698+ t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs)
699+ }
700+ resumed, err := f.ResumeSession(context.Background(), m.ID)
701+ if err != nil {
702+ t.Fatal(err)
703+ }
704+ select {
705+ case got := <-started:
706+ if got != resumed || got.resumePrompt() != "" {
707+ t.Fatalf("acknowledged follow-up was replayed as %q", got.resumePrompt())
708+ }
709+ case <-time.After(time.Second):
710+ t.Fatal("resumed runner did not start")
711+ }
712+}
713+
714+func TestReloadRetainsQueuedResumePromptUntilTurnStarts(t *testing.T) {
715+ root := t.TempDir()
716+ const (
717+ id = "queued-follow-up"
718+ original = "review the implementation"
719+ followUp = "I applied your review. What do you think now?"
720+ )
721+ stateDir := filepath.Join(root, "agents", id)
722+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
723+ t.Fatal(err)
724+ }
725+ m := agentMeta{
726+ ID: id,
727+ Task: original,
728+ OriginalTask: original,
729+ Dir: root,
730+ Started: time.Now().Add(-time.Minute),
731+ Status: StatusPending,
732+ ProcessState: ProcessPending,
733+ TurnState: TurnQueued,
734+ InboxPath: filepath.Join(stateDir, "in.sock"),
735+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
736+ SessionPath: filepath.Join(stateDir, "session.json"),
737+ ResumePrompt: followUp,
738+ ResumePromptAt: time.Now(),
739+ }
740+ data, err := json.Marshal(m)
741+ if err != nil {
742+ t.Fatal(err)
743+ }
744+ if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil {
745+ t.Fatal(err)
746+ }
747+ if err := os.WriteFile(m.EventLogPath, nil, 0o600); err != nil {
748+ t.Fatal(err)
749+ }
750+
751+ started := make(chan *Agent, 1)
752+ f := New(Config{
753+ Root: root, RepoRoot: root,
754+ NewRunner: func(a *Agent) Runner {
755+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
756+ started <- a
757+ <-ctx.Done()
758+ return ctx.Err()
759+ })
760+ },
761+ })
762+ t.Cleanup(f.StopAll)
763+ if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 {
764+ t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs)
765+ }
766+ loaded := f.Get(id)
767+ if loaded == nil {
768+ t.Fatal("reloaded agent missing")
769+ }
770+ if got := loaded.resumePrompt(); got != followUp {
771+ t.Fatalf("reloaded pending prompt = %q, want %q", got, followUp)
772+ }
773+
774+ resumed, err := f.ResumeSession(context.Background(), id)
775+ if err != nil {
776+ t.Fatal(err)
777+ }
778+ select {
779+ case got := <-started:
780+ if got != resumed || got.resumePrompt() != followUp {
781+ t.Fatalf("resumed runner = %p prompt %q, want %p prompt %q", got, got.resumePrompt(), resumed, followUp)
782+ }
783+ case <-time.After(time.Second):
784+ t.Fatal("resumed runner did not start")
785+ }
786+
787+ updateAgentFromEvent(resumed, NewEvent(EventTurnStarted, map[string]any{"step": float64(1)}))
788+ persisted, err := readAgentMeta(stateDir)
789+ if err != nil {
790+ t.Fatal(err)
791+ }
792+ if resumed.resumePrompt() != "" || persisted.ResumePrompt != "" {
793+ t.Fatalf("pending prompt not cleared after turn start: agent %q meta %q", resumed.resumePrompt(), persisted.ResumePrompt)
794+ }
795+}
796+
797+func TestResumeWithPromptRetainsSessionAndStartsFollowUp(t *testing.T) {
798+ root := t.TempDir()
799+ started := make(chan *Agent, 2)
800+ f := New(Config{
801+ Root: root, RepoRoot: root,
802+ NewRunner: func(a *Agent) Runner {
803+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
804+ started <- a
805+ <-ctx.Done()
806+ return ctx.Err()
807+ })
808+ },
809+ })
810+ defer f.StopAll()
811+
812+ first, err := f.Spawn(context.Background(), "review the patch")
813+ if err != nil {
814+ t.Fatal(err)
815+ }
816+ select {
817+ case got := <-started:
818+ if got != first {
819+ t.Fatalf("initial runner agent = %p, want %p", got, first)
820+ }
821+ case <-time.After(time.Second):
822+ t.Fatal("initial runner did not start")
823+ }
824+ if err := f.Stop(first.ID); err != nil {
825+ t.Fatal(err)
826+ }
827+ first.Wait()
828+
829+ const followUp = "I applied your feedback. Please review it again."
830+ resumed, err := f.ResumeWithPrompt(context.Background(), first.ID, followUp)
831+ if err != nil {
832+ t.Fatalf("resume with prompt: %v", err)
833+ }
834+ if resumed.ID != first.ID || resumed.SessionPath != first.SessionPath {
835+ t.Fatalf("resumed agent = id %q session %q, want id %q session %q", resumed.ID, resumed.SessionPath, first.ID, first.SessionPath)
836+ }
837+ resumePrompt, resumePromptAt := resumed.ResumePromptInfo()
838+ if !resumed.Resuming || resumePrompt != followUp || resumePromptAt.IsZero() {
839+ t.Fatalf("resumed lifecycle = resuming %t prompt %q accepted %s, want true, prompt %q, and timestamp", resumed.Resuming, resumePrompt, resumePromptAt, followUp)
840+ }
841+ persisted, err := readAgentMeta(filepath.Join(root, "agents", first.ID))
842+ if err != nil {
843+ t.Fatal(err)
844+ }
845+ if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() {
846+ t.Fatalf("persisted follow-up = prompt %q accepted %s, want prompt %q and timestamp", persisted.ResumePrompt, persisted.ResumePromptAt, followUp)
847+ }
848+ select {
849+ case got := <-started:
850+ if got != resumed {
851+ t.Fatalf("follow-up runner agent = %p, want %p", got, resumed)
852+ }
853+ resumePrompt, _ := got.ResumePromptInfo()
854+ if resumePrompt != followUp {
855+ t.Fatalf("runner follow-up = %q, want %q", resumePrompt, followUp)
856+ }
857+ case <-time.After(time.Second):
858+ t.Fatal("resumed runner did not start")
859+ }
860+}
861+
586862 // TestResumeRejectsRunningAgent prevents the user from double-running
587863 // an agent: two runners on the same session.json would race.
588864 func TestResumeRejectsRunningAgent(t *testing.T) {
@@ -513,6 +513,69 @@ func TestResumeRestartsRunnerOnSameSession(t *testing.T) {
513 }513 }
514 }514 }
515 515
516+func TestRestartTaskRetainsQueuedResumePrompt(t *testing.T) {
517+ root := t.TempDir()
518+ started := make(chan *Agent, 2)
519+ f := New(Config{
520+ Root: root, RepoRoot: root,
521+ NewRunner: func(a *Agent) Runner {
522+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
523+ started <- a
524+ <-ctx.Done()
525+ return ctx.Err()
526+ })
527+ },
528+ })
529+ t.Cleanup(f.StopAll)
530+
531+ existing, err := f.Spawn(context.Background(), "review the implementation")
532+ if err != nil {
533+ t.Fatal(err)
534+ }
535+ select {
536+ case got := <-started:
537+ if got != existing {
538+ t.Fatalf("initial runner agent = %p, want %p", got, existing)
539+ }
540+ case <-time.After(time.Second):
541+ t.Fatal("initial runner did not start")
542+ }
543+
544+ const followUp = "I applied your review. What do you think now?"
545+ acceptedAt := time.Now().Add(-time.Minute)
546+ existing.setResumePrompt(followUp, acceptedAt)
547+ f.persistAgent(existing)
548+ if err := f.Stop(existing.ID); err != nil {
549+ t.Fatal(err)
550+ }
551+ existing.Wait()
552+
553+ restarted, err := f.RestartTask(context.Background(), existing.ID)
554+ if err != nil {
555+ t.Fatal(err)
556+ }
557+ select {
558+ case got := <-started:
559+ if got != restarted {
560+ t.Fatalf("restarted runner agent = %p, want %p", got, restarted)
561+ }
562+ resumePrompt, resumePromptAt := got.ResumePromptInfo()
563+ if got.Resuming || resumePrompt != followUp || !resumePromptAt.Equal(acceptedAt) {
564+ t.Fatalf("restarted lifecycle = resuming %t prompt %q accepted %s, want false, prompt %q, and accepted %s", got.Resuming, resumePrompt, resumePromptAt, followUp, acceptedAt)
565+ }
566+ case <-time.After(time.Second):
567+ t.Fatal("restarted runner did not start")
568+ }
569+
570+ persisted, err := readAgentMeta(filepath.Join(root, "agents", existing.ID))
571+ if err != nil {
572+ t.Fatal(err)
573+ }
574+ if persisted.ResumePrompt != followUp || !persisted.ResumePromptAt.Equal(acceptedAt) {
575+ t.Fatalf("persisted restart prompt = %q accepted %s, want prompt %q and accepted %s", persisted.ResumePrompt, persisted.ResumePromptAt, followUp, acceptedAt)
576+ }
577+}
578+
516 func TestResumeRecalculatesInvalidStateInboxPath(t *testing.T) {579 func TestResumeRecalculatesInvalidStateInboxPath(t *testing.T) {
517 if runtime.GOOS == "windows" {580 if runtime.GOOS == "windows" {
518 t.Skip("subagent inbox runtime relocation applies to Unix sockets")581 t.Skip("subagent inbox runtime relocation applies to Unix sockets")
@@ -583,6 +646,219 @@ func TestResumeSetsResumingFlag(t *testing.T) {
583 }646 }
584 }647 }
585 648
649+func TestReloadClearsResumePromptAcknowledgedInEventLog(t *testing.T) {
650+ root := t.TempDir()
651+ stateDir := filepath.Join(root, "agents", "acknowledged-follow-up")
652+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
653+ t.Fatal(err)
654+ }
655+ m := agentMeta{
656+ ID: "acknowledged-follow-up",
657+ Task: "review the implementation",
658+ Dir: root,
659+ Started: time.Now().Add(-time.Minute),
660+ Status: StatusPending,
661+ InboxPath: filepath.Join(stateDir, "in.sock"),
662+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
663+ SessionPath: filepath.Join(stateDir, "session.json"),
664+ ResumePrompt: "I applied your review. What do you think now?",
665+ ResumePromptAt: time.Now(),
666+ }
667+ data, err := json.Marshal(m)
668+ if err != nil {
669+ t.Fatal(err)
670+ }
671+ if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil {
672+ t.Fatal(err)
673+ }
674+ log, err := OpenEventLog(m.EventLogPath)
675+ if err != nil {
676+ t.Fatal(err)
677+ }
678+ if err := log.Append(NewEvent(EventTurnStarted, map[string]any{"step": float64(1)})); err != nil {
679+ t.Fatal(err)
680+ }
681+ if err := log.Close(); err != nil {
682+ t.Fatal(err)
683+ }
684+
685+ started := make(chan *Agent, 1)
686+ f := New(Config{
687+ Root: root, RepoRoot: root,
688+ NewRunner: func(a *Agent) Runner {
689+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
690+ started <- a
691+ <-ctx.Done()
692+ return ctx.Err()
693+ })
694+ },
695+ })
696+ t.Cleanup(f.StopAll)
697+ if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 {
698+ t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs)
699+ }
700+ resumed, err := f.ResumeSession(context.Background(), m.ID)
701+ if err != nil {
702+ t.Fatal(err)
703+ }
704+ select {
705+ case got := <-started:
706+ if got != resumed || got.resumePrompt() != "" {
707+ t.Fatalf("acknowledged follow-up was replayed as %q", got.resumePrompt())
708+ }
709+ case <-time.After(time.Second):
710+ t.Fatal("resumed runner did not start")
711+ }
712+}
713+
714+func TestReloadRetainsQueuedResumePromptUntilTurnStarts(t *testing.T) {
715+ root := t.TempDir()
716+ const (
717+ id = "queued-follow-up"
718+ original = "review the implementation"
719+ followUp = "I applied your review. What do you think now?"
720+ )
721+ stateDir := filepath.Join(root, "agents", id)
722+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
723+ t.Fatal(err)
724+ }
725+ m := agentMeta{
726+ ID: id,
727+ Task: original,
728+ OriginalTask: original,
729+ Dir: root,
730+ Started: time.Now().Add(-time.Minute),
731+ Status: StatusPending,
732+ ProcessState: ProcessPending,
733+ TurnState: TurnQueued,
734+ InboxPath: filepath.Join(stateDir, "in.sock"),
735+ EventLogPath: filepath.Join(stateDir, "events.jsonl"),
736+ SessionPath: filepath.Join(stateDir, "session.json"),
737+ ResumePrompt: followUp,
738+ ResumePromptAt: time.Now(),
739+ }
740+ data, err := json.Marshal(m)
741+ if err != nil {
742+ t.Fatal(err)
743+ }
744+ if err := os.WriteFile(metaPath(stateDir), data, 0o600); err != nil {
745+ t.Fatal(err)
746+ }
747+ if err := os.WriteFile(m.EventLogPath, nil, 0o600); err != nil {
748+ t.Fatal(err)
749+ }
750+
751+ started := make(chan *Agent, 1)
752+ f := New(Config{
753+ Root: root, RepoRoot: root,
754+ NewRunner: func(a *Agent) Runner {
755+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
756+ started <- a
757+ <-ctx.Done()
758+ return ctx.Err()
759+ })
760+ },
761+ })
762+ t.Cleanup(f.StopAll)
763+ if loaded, errs := f.Reload(); loaded != 1 || len(errs) != 0 {
764+ t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs)
765+ }
766+ loaded := f.Get(id)
767+ if loaded == nil {
768+ t.Fatal("reloaded agent missing")
769+ }
770+ if got := loaded.resumePrompt(); got != followUp {
771+ t.Fatalf("reloaded pending prompt = %q, want %q", got, followUp)
772+ }
773+
774+ resumed, err := f.ResumeSession(context.Background(), id)
775+ if err != nil {
776+ t.Fatal(err)
777+ }
778+ select {
779+ case got := <-started:
780+ if got != resumed || got.resumePrompt() != followUp {
781+ t.Fatalf("resumed runner = %p prompt %q, want %p prompt %q", got, got.resumePrompt(), resumed, followUp)
782+ }
783+ case <-time.After(time.Second):
784+ t.Fatal("resumed runner did not start")
785+ }
786+
787+ updateAgentFromEvent(resumed, NewEvent(EventTurnStarted, map[string]any{"step": float64(1)}))
788+ persisted, err := readAgentMeta(stateDir)
789+ if err != nil {
790+ t.Fatal(err)
791+ }
792+ if resumed.resumePrompt() != "" || persisted.ResumePrompt != "" {
793+ t.Fatalf("pending prompt not cleared after turn start: agent %q meta %q", resumed.resumePrompt(), persisted.ResumePrompt)
794+ }
795+}
796+
797+func TestResumeWithPromptRetainsSessionAndStartsFollowUp(t *testing.T) {
798+ root := t.TempDir()
799+ started := make(chan *Agent, 2)
800+ f := New(Config{
801+ Root: root, RepoRoot: root,
802+ NewRunner: func(a *Agent) Runner {
803+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
804+ started <- a
805+ <-ctx.Done()
806+ return ctx.Err()
807+ })
808+ },
809+ })
810+ defer f.StopAll()
811+
812+ first, err := f.Spawn(context.Background(), "review the patch")
813+ if err != nil {
814+ t.Fatal(err)
815+ }
816+ select {
817+ case got := <-started:
818+ if got != first {
819+ t.Fatalf("initial runner agent = %p, want %p", got, first)
820+ }
821+ case <-time.After(time.Second):
822+ t.Fatal("initial runner did not start")
823+ }
824+ if err := f.Stop(first.ID); err != nil {
825+ t.Fatal(err)
826+ }
827+ first.Wait()
828+
829+ const followUp = "I applied your feedback. Please review it again."
830+ resumed, err := f.ResumeWithPrompt(context.Background(), first.ID, followUp)
831+ if err != nil {
832+ t.Fatalf("resume with prompt: %v", err)
833+ }
834+ if resumed.ID != first.ID || resumed.SessionPath != first.SessionPath {
835+ t.Fatalf("resumed agent = id %q session %q, want id %q session %q", resumed.ID, resumed.SessionPath, first.ID, first.SessionPath)
836+ }
837+ resumePrompt, resumePromptAt := resumed.ResumePromptInfo()
838+ if !resumed.Resuming || resumePrompt != followUp || resumePromptAt.IsZero() {
839+ t.Fatalf("resumed lifecycle = resuming %t prompt %q accepted %s, want true, prompt %q, and timestamp", resumed.Resuming, resumePrompt, resumePromptAt, followUp)
840+ }
841+ persisted, err := readAgentMeta(filepath.Join(root, "agents", first.ID))
842+ if err != nil {
843+ t.Fatal(err)
844+ }
845+ if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() {
846+ t.Fatalf("persisted follow-up = prompt %q accepted %s, want prompt %q and timestamp", persisted.ResumePrompt, persisted.ResumePromptAt, followUp)
847+ }
848+ select {
849+ case got := <-started:
850+ if got != resumed {
851+ t.Fatalf("follow-up runner agent = %p, want %p", got, resumed)
852+ }
853+ resumePrompt, _ := got.ResumePromptInfo()
854+ if resumePrompt != followUp {
855+ t.Fatalf("runner follow-up = %q, want %q", resumePrompt, followUp)
856+ }
857+ case <-time.After(time.Second):
858+ t.Fatal("resumed runner did not start")
859+ }
860+}
861+
586 // TestResumeRejectsRunningAgent prevents the user from double-running862 // TestResumeRejectsRunningAgent prevents the user from double-running
587 // an agent: two runners on the same session.json would race.863 // an agent: two runners on the same session.json would race.
588 func TestResumeRejectsRunningAgent(t *testing.T) {864 func TestResumeRejectsRunningAgent(t *testing.T) {
A packages/agent/subagents/resume_prompt_test.go +133 -0
@@ -0,0 +1,133 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+ "time"
9+)
10+
11+func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
12+ if runtime.GOOS == "windows" {
13+ t.Skip("subagent inbox transport uses Unix-domain sockets")
14+ }
15+
16+ root := shortSocketDir(t)
17+ ready := make(chan struct{}, 1)
18+ commands := make(chan Envelope, 2)
19+ f := New(Config{
20+ Root: root, RepoRoot: root,
21+ NewRunner: func(a *Agent) Runner {
22+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
23+ listener, err := Listen(a.InboxPath)
24+ if err != nil {
25+ return err
26+ }
27+ defer listener.Close()
28+ a.setProcessState(ProcessAlive)
29+ a.setTurnState(TurnIdle, "")
30+ ready <- struct{}{}
31+ for {
32+ select {
33+ case <-ctx.Done():
34+ return ctx.Err()
35+ case line, ok := <-listener.Lines():
36+ if !ok {
37+ return nil
38+ }
39+ command, err := ParseCommand(line)
40+ if err != nil {
41+ return err
42+ }
43+ if command.Type == CommandAgentShutdown {
44+ return nil
45+ }
46+ commands <- command
47+ }
48+ }
49+ })
50+ },
51+ })
52+ defer f.StopAll()
53+
54+ a, err := f.Spawn(context.Background(), "review the implementation")
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ select {
59+ case <-ready:
60+ case <-time.After(time.Second):
61+ t.Fatal("idle worker did not open its inbox")
62+ }
63+
64+ const followUp = "I applied your review. What do you think now?"
65+ continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp)
66+ if err != nil {
67+ t.Fatalf("continue idle worker: %v", err)
68+ }
69+ if continued != a {
70+ t.Fatalf("continued agent = %p, want existing worker %p", continued, a)
71+ }
72+ if got := a.TurnState(); got != TurnQueued {
73+ t.Fatalf("turn state after sending follow-up = %s, want %s", got, TurnQueued)
74+ }
75+ if _, err := f.ResumeWithPrompt(context.Background(), a.ID, "duplicate follow-up"); err == nil {
76+ t.Fatal("second follow-up succeeded while the first was queued")
77+ }
78+ select {
79+ case command := <-commands:
80+ if command.Type != CommandTurnStart {
81+ t.Fatalf("command type = %q, want %q", command.Type, CommandTurnStart)
82+ }
83+ var payload TurnStartPayload
84+ if err := command.DecodePayload(&payload); err != nil {
85+ t.Fatal(err)
86+ }
87+ if payload.Prompt != followUp {
88+ t.Fatalf("follow-up prompt = %q, want %q", payload.Prompt, followUp)
89+ }
90+ case <-time.After(time.Second):
91+ t.Fatal("idle worker did not receive the follow-up")
92+ }
93+
94+ persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() {
99+ t.Fatalf("queued follow-up was not persisted: prompt %q accepted %s", persisted.ResumePrompt, persisted.ResumePromptAt)
100+ }
101+ if err := f.Stop(a.ID); err != nil {
102+ t.Fatal(err)
103+ }
104+ a.Wait()
105+
106+ restarted := make(chan *Agent, 1)
107+ f2 := New(Config{
108+ Root: root, RepoRoot: root,
109+ NewRunner: func(agent *Agent) Runner {
110+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
111+ restarted <- agent
112+ <-ctx.Done()
113+ return ctx.Err()
114+ })
115+ },
116+ })
117+ defer f2.StopAll()
118+ if loaded, errs := f2.Reload(); loaded != 1 || len(errs) != 0 {
119+ t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs)
120+ }
121+ resumed, err := f2.ResumeSession(context.Background(), a.ID)
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ select {
126+ case got := <-restarted:
127+ if got != resumed || got.resumePrompt() != followUp {
128+ t.Fatalf("unacknowledged follow-up was not replayed: agent %p prompt %q", got, got.resumePrompt())
129+ }
130+ case <-time.After(time.Second):
131+ t.Fatal("restarted worker did not receive the follow-up")
132+ }
133+}
@@ -0,0 +1,133 @@
1+package subagents
2+
3+import (
4+ "context"
5+ "path/filepath"
6+ "runtime"
7+ "testing"
8+ "time"
9+)
10+
11+func TestResumeWithPromptDeliversFollowUpToIdleWorker(t *testing.T) {
12+ if runtime.GOOS == "windows" {
13+ t.Skip("subagent inbox transport uses Unix-domain sockets")
14+ }
15+
16+ root := shortSocketDir(t)
17+ ready := make(chan struct{}, 1)
18+ commands := make(chan Envelope, 2)
19+ f := New(Config{
20+ Root: root, RepoRoot: root,
21+ NewRunner: func(a *Agent) Runner {
22+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
23+ listener, err := Listen(a.InboxPath)
24+ if err != nil {
25+ return err
26+ }
27+ defer listener.Close()
28+ a.setProcessState(ProcessAlive)
29+ a.setTurnState(TurnIdle, "")
30+ ready <- struct{}{}
31+ for {
32+ select {
33+ case <-ctx.Done():
34+ return ctx.Err()
35+ case line, ok := <-listener.Lines():
36+ if !ok {
37+ return nil
38+ }
39+ command, err := ParseCommand(line)
40+ if err != nil {
41+ return err
42+ }
43+ if command.Type == CommandAgentShutdown {
44+ return nil
45+ }
46+ commands <- command
47+ }
48+ }
49+ })
50+ },
51+ })
52+ defer f.StopAll()
53+
54+ a, err := f.Spawn(context.Background(), "review the implementation")
55+ if err != nil {
56+ t.Fatal(err)
57+ }
58+ select {
59+ case <-ready:
60+ case <-time.After(time.Second):
61+ t.Fatal("idle worker did not open its inbox")
62+ }
63+
64+ const followUp = "I applied your review. What do you think now?"
65+ continued, err := f.ResumeWithPrompt(context.Background(), a.ID, followUp)
66+ if err != nil {
67+ t.Fatalf("continue idle worker: %v", err)
68+ }
69+ if continued != a {
70+ t.Fatalf("continued agent = %p, want existing worker %p", continued, a)
71+ }
72+ if got := a.TurnState(); got != TurnQueued {
73+ t.Fatalf("turn state after sending follow-up = %s, want %s", got, TurnQueued)
74+ }
75+ if _, err := f.ResumeWithPrompt(context.Background(), a.ID, "duplicate follow-up"); err == nil {
76+ t.Fatal("second follow-up succeeded while the first was queued")
77+ }
78+ select {
79+ case command := <-commands:
80+ if command.Type != CommandTurnStart {
81+ t.Fatalf("command type = %q, want %q", command.Type, CommandTurnStart)
82+ }
83+ var payload TurnStartPayload
84+ if err := command.DecodePayload(&payload); err != nil {
85+ t.Fatal(err)
86+ }
87+ if payload.Prompt != followUp {
88+ t.Fatalf("follow-up prompt = %q, want %q", payload.Prompt, followUp)
89+ }
90+ case <-time.After(time.Second):
91+ t.Fatal("idle worker did not receive the follow-up")
92+ }
93+
94+ persisted, err := readAgentMeta(filepath.Join(root, "agents", a.ID))
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if persisted.ResumePrompt != followUp || persisted.ResumePromptAt.IsZero() {
99+ t.Fatalf("queued follow-up was not persisted: prompt %q accepted %s", persisted.ResumePrompt, persisted.ResumePromptAt)
100+ }
101+ if err := f.Stop(a.ID); err != nil {
102+ t.Fatal(err)
103+ }
104+ a.Wait()
105+
106+ restarted := make(chan *Agent, 1)
107+ f2 := New(Config{
108+ Root: root, RepoRoot: root,
109+ NewRunner: func(agent *Agent) Runner {
110+ return RunnerFunc(func(ctx context.Context, _ Sink) error {
111+ restarted <- agent
112+ <-ctx.Done()
113+ return ctx.Err()
114+ })
115+ },
116+ })
117+ defer f2.StopAll()
118+ if loaded, errs := f2.Reload(); loaded != 1 || len(errs) != 0 {
119+ t.Fatalf("reload = (%d, %v), want (1, no errors)", loaded, errs)
120+ }
121+ resumed, err := f2.ResumeSession(context.Background(), a.ID)
122+ if err != nil {
123+ t.Fatal(err)
124+ }
125+ select {
126+ case got := <-restarted:
127+ if got != resumed || got.resumePrompt() != followUp {
128+ t.Fatalf("unacknowledged follow-up was not replayed: agent %p prompt %q", got, got.resumePrompt())
129+ }
130+ case <-time.After(time.Second):
131+ t.Fatal("restarted worker did not receive the follow-up")
132+ }
133+}
M packages/agent/subagents/runner.go +6 -8
@@ -84,17 +84,14 @@ type subagentWorkerArgsOpts struct {
8484 // side effects.
8585 //
8686 // On Spawn (Resuming==false) we pass the task so the child's first
87-// turn runs immediately. On Resume (Resuming==true) we omit it: the
88-// child reopens the existing session file, loads the prior
89-// conversation, and just waits on the inbox for the next prompt.
90-// Re-firing the task on every resume produces a duplicate turn that
91-// collides with whatever the user types next, surfacing the
92-// "agent busy; send 'cancel' first" error between two assistant
93-// messages — which is exactly the bug this helper fixes.
87+// turn runs immediately. On Resume (Resuming==true) we omit the original task
88+// so the child reopens the existing session file without replaying it. A
89+// caller can instead supply ResumePrompt to begin exactly one new follow-up
90+// turn without racing the inbox listener startup.
9491 func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string {
9592 task := a.Task
9693 if a.Resuming {
97- task = ""
94+ task = a.resumePrompt()
9895 }
9996 return subagentWorkerArgs(subagentWorkerArgsOpts{
10097 Exe: exe,
@@ -624,6 +621,7 @@ func updateAgentFromEvent(a *Agent, ev Event) {
624621 }
625622 a.setProcessState(ProcessAlive)
626623 a.setTurnState(TurnRunning, turnID)
624+ a.clearResumePrompt()
627625 persist = true
628626 case EventTurnResult, "turn_result":
629627 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
@@ -84,17 +84,14 @@ type subagentWorkerArgsOpts struct {
84 // side effects.84 // side effects.
85 //85 //
86 // On Spawn (Resuming==false) we pass the task so the child's first86 // On Spawn (Resuming==false) we pass the task so the child's first
87-// turn runs immediately. On Resume (Resuming==true) we omit it: the87+// turn runs immediately. On Resume (Resuming==true) we omit the original task
88-// child reopens the existing session file, loads the prior88+// so the child reopens the existing session file without replaying it. A
89-// conversation, and just waits on the inbox for the next prompt.89+// caller can instead supply ResumePrompt to begin exactly one new follow-up
90-// Re-firing the task on every resume produces a duplicate turn that90+// turn without racing the inbox listener startup.
91-// collides with whatever the user types next, surfacing the
92-// "agent busy; send 'cancel' first" error between two assistant
93-// messages — which is exactly the bug this helper fixes.
94 func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string {91 func defaultChildArgs(exe string, a *Agent, sessionPath, inboxPath string) []string {
95 task := a.Task92 task := a.Task
96 if a.Resuming {93 if a.Resuming {
97- task = ""94+ task = a.resumePrompt()
98 }95 }
99 return subagentWorkerArgs(subagentWorkerArgsOpts{96 return subagentWorkerArgs(subagentWorkerArgsOpts{
100 Exe: exe,97 Exe: exe,
@@ -624,6 +621,7 @@ func updateAgentFromEvent(a *Agent, ev Event) {
624 }621 }
625 a.setProcessState(ProcessAlive)622 a.setProcessState(ProcessAlive)
626 a.setTurnState(TurnRunning, turnID)623 a.setTurnState(TurnRunning, turnID)
624+ a.clearResumePrompt()
627 persist = true625 persist = true
628 case EventTurnResult, "turn_result":626 case EventTurnResult, "turn_result":
629 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {627 if result, err := decodeTurnResultEvent(ev, a.ID, a.maxOutputBytes, a.maxOutputLines); err == nil {
M packages/agent/subagents/runner_test.go +19 -0
@@ -364,6 +364,25 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
364364 }
365365 }
366366
367+func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
368+ a := &Agent{
369+ Dir: "/wt",
370+ Task: "review the change",
371+ Resuming: true,
372+ }
373+ const followUp = "I applied your feedback. Please review it again."
374+ a.setResumePrompt(followUp, time.Now())
375+ args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
376+ if got := args[len(args)-1]; got != followUp {
377+ t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, followUp, args)
378+ }
379+ for _, v := range args[:len(args)-1] {
380+ if v == a.Task {
381+ t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args)
382+ }
383+ }
384+}
385+
367386 func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
368387 a := &Agent{}
369388 a.setTurnState(TurnRunning, "turn-1")
@@ -364,6 +364,25 @@ func TestDefaultChildArgsResumeOmitsTask(t *testing.T) {
364 }364 }
365 }365 }
366 366
367+func TestDefaultChildArgsResumeUsesFollowUpPrompt(t *testing.T) {
368+ a := &Agent{
369+ Dir: "/wt",
370+ Task: "review the change",
371+ Resuming: true,
372+ }
373+ const followUp = "I applied your feedback. Please review it again."
374+ a.setResumePrompt(followUp, time.Now())
375+ args := defaultChildArgs("/zut", a, "/s.json", "/in.sock")
376+ if got := args[len(args)-1]; got != followUp {
377+ t.Fatalf("resume argv last = %q, want follow-up prompt %q\n%v", got, followUp, args)
378+ }
379+ for _, v := range args[:len(args)-1] {
380+ if v == a.Task {
381+ t.Fatalf("resume argv contains original task; it would duplicate the first turn\n%v", args)
382+ }
383+ }
384+}
385+
367 func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {386 func TestUpdateAgentFromEventIgnoresProviderToolLoopTurnEnd(t *testing.T) {
368 a := &Agent{}387 a := &Agent{}
369 a.setTurnState(TurnRunning, "turn-1")388 a.setTurnState(TurnRunning, "turn-1")
M packages/agent/subagents/supervisor.go +4 -2
@@ -865,7 +865,7 @@ func (f *Supervisor) stop(ctx context.Context, id string) error {
865865 }
866866 graceful := false
867867 if a.inbox != nil {
868- err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
868+ err := a.inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
869869 if err == nil {
870870 graceful = true
871871 }
@@ -918,7 +918,7 @@ func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, in
918918 if err := ctx.Err(); err != nil {
919919 return err
920920 }
921- if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil {
921+ if err := inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil {
922922 if ctxErr := ctx.Err(); ctxErr != nil {
923923 return ctxErr
924924 }
@@ -966,7 +966,9 @@ func (f *Supervisor) markDetachedKilled(a *Agent) {
966966 a.setProcessState(ProcessKilled)
967967 a.setTurnState(TurnCanceled, a.CurrentTurnID())
968968 a.setProcessPID(0)
969+ f.ensureResult(a, StatusKilled, context.Canceled)
969970 f.persistAgent(a)
971+ a.closeDone()
970972 }
971973
972974 // StopAll cancels every running agent. Used on shutdown.
@@ -865,7 +865,7 @@ func (f *Supervisor) stop(ctx context.Context, id string) error {
865 }865 }
866 graceful := false866 graceful := false
867 if a.inbox != nil {867 if a.inbox != nil {
868- err := a.inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))868+ err := a.inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{}))
869 if err == nil {869 if err == nil {
870 graceful = true870 graceful = true
871 }871 }
@@ -918,7 +918,7 @@ func (f *Supervisor) stopDetached(ctx context.Context, a *Agent, path string, in
918 if err := ctx.Err(); err != nil {918 if err := ctx.Err(); err != nil {
919 return err919 return err
920 }920 }
921- if err := inbox.SendCommand(NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil {921+ if err := inbox.SendCommandContext(ctx, NewCommand(CommandAgentShutdown, a.ID, a.CurrentTurnID(), AgentShutdownPayload{})); err != nil {
922 if ctxErr := ctx.Err(); ctxErr != nil {922 if ctxErr := ctx.Err(); ctxErr != nil {
923 return ctxErr923 return ctxErr
924 }924 }
@@ -966,7 +966,9 @@ func (f *Supervisor) markDetachedKilled(a *Agent) {
966 a.setProcessState(ProcessKilled)966 a.setProcessState(ProcessKilled)
967 a.setTurnState(TurnCanceled, a.CurrentTurnID())967 a.setTurnState(TurnCanceled, a.CurrentTurnID())
968 a.setProcessPID(0)968 a.setProcessPID(0)
969+ f.ensureResult(a, StatusKilled, context.Canceled)
969 f.persistAgent(a)970 f.persistAgent(a)
971+ a.closeDone()
970 }972 }
971 973
972 // StopAll cancels every running agent. Used on shutdown.974 // StopAll cancels every running agent. Used on shutdown.
M packages/agent/subagents/supervisor_test.go +122 -0
@@ -3,6 +3,8 @@ package subagents
33 import (
44 "context"
55 "errors"
6+ "net"
7+ "os"
68 "path/filepath"
79 "runtime"
810 "strings"
@@ -286,6 +288,126 @@ func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T)
286288 }
287289 }
288290
291+func TestStopDetachedWorkerClosesWaiters(t *testing.T) {
292+ if runtime.GOOS == "windows" {
293+ t.Skip("subagent inbox transport uses Unix-domain sockets")
294+ }
295+ path := filepath.Join(shortSocketDir(t), "agent.sock")
296+ listener, err := Listen(path)
297+ if err != nil {
298+ t.Fatal(err)
299+ }
300+ t.Cleanup(func() { _ = listener.Close() })
301+
302+ root := t.TempDir()
303+ f := New(Config{Root: root, RepoRoot: root})
304+ stateDir := f.agentStateDir("detached-agent")
305+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
306+ t.Fatal(err)
307+ }
308+ a := &Agent{
309+ ID: "detached-agent",
310+ stateDir: stateDir,
311+ InboxPath: path,
312+ inbox: NewInbox(path),
313+ status: StatusDetached,
314+ done: make(chan struct{}),
315+ }
316+ defer a.inbox.Close()
317+ f.mu.Lock()
318+ f.agents[a.ID] = a
319+ f.order = append(f.order, a.ID)
320+ f.mu.Unlock()
321+
322+ stopped := make(chan error, 1)
323+ go func() { stopped <- f.StopContext(context.Background(), a.ID) }()
324+ select {
325+ case msg := <-listener.Lines():
326+ if msg == "" {
327+ t.Fatal("shutdown command was empty")
328+ }
329+ case <-time.After(time.Second):
330+ t.Fatal("detached worker did not receive shutdown")
331+ }
332+ if err := listener.Close(); err != nil {
333+ t.Fatal(err)
334+ }
335+ select {
336+ case err := <-stopped:
337+ if err != nil {
338+ t.Fatalf("stop detached worker: %v", err)
339+ }
340+ case <-time.After(time.Second):
341+ t.Fatal("detached worker stop did not finish")
342+ }
343+ select {
344+ case <-a.done:
345+ case <-time.After(time.Second):
346+ t.Fatal("detached worker stop did not close waiters")
347+ }
348+ result, err := readTurnResult(stateDir)
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ if result.AgentID != a.ID || result.Status != ResultCanceled {
353+ t.Fatalf("detached worker result = agent %q status %q, want agent %q status %q", result.AgentID, result.Status, a.ID, ResultCanceled)
354+ }
355+}
356+
357+func TestStopDetachedWorkerHonorsCanceledShutdownSend(t *testing.T) {
358+ if runtime.GOOS == "windows" {
359+ t.Skip("subagent inbox transport uses Unix-domain sockets")
360+ }
361+ path := filepath.Join(shortSocketDir(t), "agent.sock")
362+ listener, err := Listen(path)
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ defer listener.Close()
367+
368+ local, remote := net.Pipe()
369+ defer remote.Close()
370+ conn := &blockingWriteConn{
371+ Conn: local,
372+ started: make(chan struct{}),
373+ release: make(chan struct{}),
374+ }
375+ inbox := NewInbox(path)
376+ inbox.conn = conn
377+ defer inbox.Close()
378+
379+ f := New(Config{Root: t.TempDir()})
380+ a := &Agent{
381+ ID: "detached-cancel",
382+ InboxPath: path,
383+ inbox: inbox,
384+ status: StatusDetached,
385+ done: make(chan struct{}),
386+ }
387+ f.mu.Lock()
388+ f.agents[a.ID] = a
389+ f.order = append(f.order, a.ID)
390+ f.mu.Unlock()
391+
392+ ctx, cancel := context.WithCancel(context.Background())
393+ stopped := make(chan error, 1)
394+ go func() { stopped <- f.StopContext(ctx, a.ID) }()
395+ select {
396+ case <-conn.started:
397+ case <-time.After(time.Second):
398+ t.Fatal("detached worker shutdown send did not start")
399+ }
400+ cancel()
401+ select {
402+ case err := <-stopped:
403+ if !errors.Is(err, context.Canceled) {
404+ t.Fatalf("stop detached worker error = %v, want context canceled", err)
405+ }
406+ case <-time.After(time.Second):
407+ t.Fatal("detached worker shutdown send did not honor cancellation")
408+ }
409+}
410+
289411 func TestStopAfterDoneIsNoop(t *testing.T) {
290412 f := newTestSupervisor(t, func(a *Agent) Runner {
291413 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
@@ -3,6 +3,8 @@ package subagents
3 import (3 import (
4 "context"4 "context"
5 "errors"5 "errors"
6+ "net"
7+ "os"
6 "path/filepath"8 "path/filepath"
7 "runtime"9 "runtime"
8 "strings"10 "strings"
@@ -286,6 +288,126 @@ func TestStopContextCancelsDetachedWaitWithoutHoldingOperationLock(t *testing.T)
286 }288 }
287 }289 }
288 290
291+func TestStopDetachedWorkerClosesWaiters(t *testing.T) {
292+ if runtime.GOOS == "windows" {
293+ t.Skip("subagent inbox transport uses Unix-domain sockets")
294+ }
295+ path := filepath.Join(shortSocketDir(t), "agent.sock")
296+ listener, err := Listen(path)
297+ if err != nil {
298+ t.Fatal(err)
299+ }
300+ t.Cleanup(func() { _ = listener.Close() })
301+
302+ root := t.TempDir()
303+ f := New(Config{Root: root, RepoRoot: root})
304+ stateDir := f.agentStateDir("detached-agent")
305+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
306+ t.Fatal(err)
307+ }
308+ a := &Agent{
309+ ID: "detached-agent",
310+ stateDir: stateDir,
311+ InboxPath: path,
312+ inbox: NewInbox(path),
313+ status: StatusDetached,
314+ done: make(chan struct{}),
315+ }
316+ defer a.inbox.Close()
317+ f.mu.Lock()
318+ f.agents[a.ID] = a
319+ f.order = append(f.order, a.ID)
320+ f.mu.Unlock()
321+
322+ stopped := make(chan error, 1)
323+ go func() { stopped <- f.StopContext(context.Background(), a.ID) }()
324+ select {
325+ case msg := <-listener.Lines():
326+ if msg == "" {
327+ t.Fatal("shutdown command was empty")
328+ }
329+ case <-time.After(time.Second):
330+ t.Fatal("detached worker did not receive shutdown")
331+ }
332+ if err := listener.Close(); err != nil {
333+ t.Fatal(err)
334+ }
335+ select {
336+ case err := <-stopped:
337+ if err != nil {
338+ t.Fatalf("stop detached worker: %v", err)
339+ }
340+ case <-time.After(time.Second):
341+ t.Fatal("detached worker stop did not finish")
342+ }
343+ select {
344+ case <-a.done:
345+ case <-time.After(time.Second):
346+ t.Fatal("detached worker stop did not close waiters")
347+ }
348+ result, err := readTurnResult(stateDir)
349+ if err != nil {
350+ t.Fatal(err)
351+ }
352+ if result.AgentID != a.ID || result.Status != ResultCanceled {
353+ t.Fatalf("detached worker result = agent %q status %q, want agent %q status %q", result.AgentID, result.Status, a.ID, ResultCanceled)
354+ }
355+}
356+
357+func TestStopDetachedWorkerHonorsCanceledShutdownSend(t *testing.T) {
358+ if runtime.GOOS == "windows" {
359+ t.Skip("subagent inbox transport uses Unix-domain sockets")
360+ }
361+ path := filepath.Join(shortSocketDir(t), "agent.sock")
362+ listener, err := Listen(path)
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ defer listener.Close()
367+
368+ local, remote := net.Pipe()
369+ defer remote.Close()
370+ conn := &blockingWriteConn{
371+ Conn: local,
372+ started: make(chan struct{}),
373+ release: make(chan struct{}),
374+ }
375+ inbox := NewInbox(path)
376+ inbox.conn = conn
377+ defer inbox.Close()
378+
379+ f := New(Config{Root: t.TempDir()})
380+ a := &Agent{
381+ ID: "detached-cancel",
382+ InboxPath: path,
383+ inbox: inbox,
384+ status: StatusDetached,
385+ done: make(chan struct{}),
386+ }
387+ f.mu.Lock()
388+ f.agents[a.ID] = a
389+ f.order = append(f.order, a.ID)
390+ f.mu.Unlock()
391+
392+ ctx, cancel := context.WithCancel(context.Background())
393+ stopped := make(chan error, 1)
394+ go func() { stopped <- f.StopContext(ctx, a.ID) }()
395+ select {
396+ case <-conn.started:
397+ case <-time.After(time.Second):
398+ t.Fatal("detached worker shutdown send did not start")
399+ }
400+ cancel()
401+ select {
402+ case err := <-stopped:
403+ if !errors.Is(err, context.Canceled) {
404+ t.Fatalf("stop detached worker error = %v, want context canceled", err)
405+ }
406+ case <-time.After(time.Second):
407+ t.Fatal("detached worker shutdown send did not honor cancellation")
408+ }
409+}
410+
289 func TestStopAfterDoneIsNoop(t *testing.T) {411 func TestStopAfterDoneIsNoop(t *testing.T) {
290 f := newTestSupervisor(t, func(a *Agent) Runner {412 f := newTestSupervisor(t, func(a *Agent) Runner {
291 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })413 return RunnerFunc(func(ctx context.Context, sink Sink) error { return nil })
A packages/agent/tools/subagent_lifecycle_test.go +172 -0
@@ -0,0 +1,172 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+)
12+
13+func TestSubagentStopTerminatesStuckWorker(t *testing.T) {
14+ root := t.TempDir()
15+ started := make(chan struct{}, 1)
16+ manager := subagents.New(subagents.Config{
17+ Root: filepath.Join(root, "subagents"),
18+ RepoRoot: root,
19+ NewRunner: func(*subagents.Agent) subagents.Runner {
20+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
21+ started <- struct{}{}
22+ <-ctx.Done()
23+ return ctx.Err()
24+ })
25+ },
26+ })
27+ t.Cleanup(manager.StopAll)
28+
29+ agent, err := manager.Spawn(context.Background(), "investigate a stuck operation")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ select {
34+ case <-started:
35+ case <-time.After(time.Second):
36+ t.Fatal("subagent did not start")
37+ }
38+
39+ var tracked *subagents.Agent
40+ tool := &SubagentStopTool{
41+ Supervisor: manager,
42+ Enabled: func() bool { return true },
43+ OnStopRequested: func(got *subagents.Agent) {
44+ tracked = got
45+ },
46+ }
47+ args, err := json.Marshal(subagentStopArgs{AgentID: agent.ID})
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ res, err := tool.Execute(context.Background(), args, nil)
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if res.IsError {
56+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
57+ }
58+ if tracked != agent {
59+ t.Fatalf("stop callback agent = %p, want %p", tracked, agent)
60+ }
61+ agent.Wait()
62+ if got := agent.Status(); got != subagents.StatusKilled {
63+ t.Fatalf("agent status = %s, want %s", got, subagents.StatusKilled)
64+ }
65+
66+ var got struct {
67+ Action string `json:"action"`
68+ Agent struct {
69+ ID string `json:"agent_id"`
70+ State string `json:"state"`
71+ } `json:"agent"`
72+ }
73+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
74+ t.Fatalf("stop response JSON: %v", err)
75+ }
76+ if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" {
77+ t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID)
78+ }
79+}
80+
81+func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
82+ root := t.TempDir()
83+ started := make(chan *subagents.Agent, 2)
84+ manager := subagents.New(subagents.Config{
85+ Root: filepath.Join(root, "subagents"),
86+ RepoRoot: root,
87+ NewRunner: func(agent *subagents.Agent) subagents.Runner {
88+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
89+ started <- agent
90+ <-ctx.Done()
91+ return ctx.Err()
92+ })
93+ },
94+ })
95+ t.Cleanup(manager.StopAll)
96+
97+ first, err := manager.Spawn(context.Background(), "review the implementation")
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ select {
102+ case got := <-started:
103+ if got != first {
104+ t.Fatalf("first runner agent = %p, want %p", got, first)
105+ }
106+ case <-time.After(time.Second):
107+ t.Fatal("initial subagent did not start")
108+ }
109+ if err := manager.Stop(first.ID); err != nil {
110+ t.Fatal(err)
111+ }
112+ first.Wait()
113+
114+ const prompt = "I applied your review. What do you think now?"
115+ var tracked *subagents.Agent
116+ var trackedPrompt string
117+ tool := &SubagentResumeTool{
118+ Supervisor: manager,
119+ Enabled: func() bool { return true },
120+ OnResumed: func(agent *subagents.Agent, gotPrompt string) {
121+ tracked = agent
122+ trackedPrompt = gotPrompt
123+ },
124+ }
125+ args, err := json.Marshal(subagentResumeArgs{AgentID: first.ID, Prompt: prompt})
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ res, err := tool.Execute(context.Background(), args, nil)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ if res.IsError {
134+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
135+ }
136+ trackedID := ""
137+ if tracked != nil {
138+ trackedID = tracked.ID
139+ }
140+ if trackedID != first.ID || trackedPrompt != prompt {
141+ t.Fatalf("resume callback = agent %q prompt %q, want agent %s prompt %q", trackedID, trackedPrompt, first.ID, prompt)
142+ }
143+
144+ select {
145+ case resumed := <-started:
146+ if resumed.ID != first.ID {
147+ t.Fatalf("resumed id = %q, want %q", resumed.ID, first.ID)
148+ }
149+ if resumed.SessionPath != first.SessionPath {
150+ t.Fatalf("resumed session = %q, want %q", resumed.SessionPath, first.SessionPath)
151+ }
152+ resumePrompt, _ := resumed.ResumePromptInfo()
153+ if !resumed.Resuming || resumePrompt != prompt {
154+ t.Fatalf("resumed lifecycle = resuming %t prompt %q, want true and %q", resumed.Resuming, resumePrompt, prompt)
155+ }
156+ case <-time.After(time.Second):
157+ t.Fatal("resumed subagent did not start")
158+ }
159+
160+ var got struct {
161+ Action string `json:"action"`
162+ Agent struct {
163+ ID string `json:"agent_id"`
164+ } `json:"agent"`
165+ }
166+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
167+ t.Fatalf("resume response JSON: %v", err)
168+ }
169+ if got.Action != "resumed" || got.Agent.ID != first.ID {
170+ t.Fatalf("resume response = %+v, want resumed agent %s", got, first.ID)
171+ }
172+}
@@ -0,0 +1,172 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/zut/packages/agent/subagents"
11+)
12+
13+func TestSubagentStopTerminatesStuckWorker(t *testing.T) {
14+ root := t.TempDir()
15+ started := make(chan struct{}, 1)
16+ manager := subagents.New(subagents.Config{
17+ Root: filepath.Join(root, "subagents"),
18+ RepoRoot: root,
19+ NewRunner: func(*subagents.Agent) subagents.Runner {
20+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
21+ started <- struct{}{}
22+ <-ctx.Done()
23+ return ctx.Err()
24+ })
25+ },
26+ })
27+ t.Cleanup(manager.StopAll)
28+
29+ agent, err := manager.Spawn(context.Background(), "investigate a stuck operation")
30+ if err != nil {
31+ t.Fatal(err)
32+ }
33+ select {
34+ case <-started:
35+ case <-time.After(time.Second):
36+ t.Fatal("subagent did not start")
37+ }
38+
39+ var tracked *subagents.Agent
40+ tool := &SubagentStopTool{
41+ Supervisor: manager,
42+ Enabled: func() bool { return true },
43+ OnStopRequested: func(got *subagents.Agent) {
44+ tracked = got
45+ },
46+ }
47+ args, err := json.Marshal(subagentStopArgs{AgentID: agent.ID})
48+ if err != nil {
49+ t.Fatal(err)
50+ }
51+ res, err := tool.Execute(context.Background(), args, nil)
52+ if err != nil {
53+ t.Fatal(err)
54+ }
55+ if res.IsError {
56+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
57+ }
58+ if tracked != agent {
59+ t.Fatalf("stop callback agent = %p, want %p", tracked, agent)
60+ }
61+ agent.Wait()
62+ if got := agent.Status(); got != subagents.StatusKilled {
63+ t.Fatalf("agent status = %s, want %s", got, subagents.StatusKilled)
64+ }
65+
66+ var got struct {
67+ Action string `json:"action"`
68+ Agent struct {
69+ ID string `json:"agent_id"`
70+ State string `json:"state"`
71+ } `json:"agent"`
72+ }
73+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
74+ t.Fatalf("stop response JSON: %v", err)
75+ }
76+ if got.Action != "stop_requested" || got.Agent.ID != agent.ID || got.Agent.State != "cancelled" {
77+ t.Fatalf("stop response = %+v, want stop_requested cancelled agent %s", got, agent.ID)
78+ }
79+}
80+
81+func TestSubagentResumeRestartsSessionWithFollowUp(t *testing.T) {
82+ root := t.TempDir()
83+ started := make(chan *subagents.Agent, 2)
84+ manager := subagents.New(subagents.Config{
85+ Root: filepath.Join(root, "subagents"),
86+ RepoRoot: root,
87+ NewRunner: func(agent *subagents.Agent) subagents.Runner {
88+ return subagents.RunnerFunc(func(ctx context.Context, _ subagents.Sink) error {
89+ started <- agent
90+ <-ctx.Done()
91+ return ctx.Err()
92+ })
93+ },
94+ })
95+ t.Cleanup(manager.StopAll)
96+
97+ first, err := manager.Spawn(context.Background(), "review the implementation")
98+ if err != nil {
99+ t.Fatal(err)
100+ }
101+ select {
102+ case got := <-started:
103+ if got != first {
104+ t.Fatalf("first runner agent = %p, want %p", got, first)
105+ }
106+ case <-time.After(time.Second):
107+ t.Fatal("initial subagent did not start")
108+ }
109+ if err := manager.Stop(first.ID); err != nil {
110+ t.Fatal(err)
111+ }
112+ first.Wait()
113+
114+ const prompt = "I applied your review. What do you think now?"
115+ var tracked *subagents.Agent
116+ var trackedPrompt string
117+ tool := &SubagentResumeTool{
118+ Supervisor: manager,
119+ Enabled: func() bool { return true },
120+ OnResumed: func(agent *subagents.Agent, gotPrompt string) {
121+ tracked = agent
122+ trackedPrompt = gotPrompt
123+ },
124+ }
125+ args, err := json.Marshal(subagentResumeArgs{AgentID: first.ID, Prompt: prompt})
126+ if err != nil {
127+ t.Fatal(err)
128+ }
129+ res, err := tool.Execute(context.Background(), args, nil)
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+ if res.IsError {
134+ t.Fatalf("unexpected tool error: %s", textResult(res.Content))
135+ }
136+ trackedID := ""
137+ if tracked != nil {
138+ trackedID = tracked.ID
139+ }
140+ if trackedID != first.ID || trackedPrompt != prompt {
141+ t.Fatalf("resume callback = agent %q prompt %q, want agent %s prompt %q", trackedID, trackedPrompt, first.ID, prompt)
142+ }
143+
144+ select {
145+ case resumed := <-started:
146+ if resumed.ID != first.ID {
147+ t.Fatalf("resumed id = %q, want %q", resumed.ID, first.ID)
148+ }
149+ if resumed.SessionPath != first.SessionPath {
150+ t.Fatalf("resumed session = %q, want %q", resumed.SessionPath, first.SessionPath)
151+ }
152+ resumePrompt, _ := resumed.ResumePromptInfo()
153+ if !resumed.Resuming || resumePrompt != prompt {
154+ t.Fatalf("resumed lifecycle = resuming %t prompt %q, want true and %q", resumed.Resuming, resumePrompt, prompt)
155+ }
156+ case <-time.After(time.Second):
157+ t.Fatal("resumed subagent did not start")
158+ }
159+
160+ var got struct {
161+ Action string `json:"action"`
162+ Agent struct {
163+ ID string `json:"agent_id"`
164+ } `json:"agent"`
165+ }
166+ if err := json.Unmarshal([]byte(textResult(res.Content)), &got); err != nil {
167+ t.Fatalf("resume response JSON: %v", err)
168+ }
169+ if got.Action != "resumed" || got.Agent.ID != first.ID {
170+ t.Fatalf("resume response = %+v, want resumed agent %s", got, first.ID)
171+ }
172+}
A packages/agent/tools/subagent_resume.go +102 -0
@@ -0,0 +1,102 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/core"
11+)
12+
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.
17+type SubagentResumeTool struct {
18+ Supervisor *subagents.Supervisor
19+ Enabled func() bool
20+
21+ // OnResumed receives the worker and follow-up after a successful resume.
22+ // It runs synchronously, so callbacks must only register non-blocking
23+ // tracking work. The interactive host uses it to watch this new delegated
24+ // turn and deliver its outcome back to the manager automatically.
25+ OnResumed func(agent *subagents.Agent, prompt string)
26+}
27+
28+type subagentResumeArgs struct {
29+ AgentID string `json:"agent_id"`
30+ Prompt string `json:"prompt"`
31+}
32+
33+const subagentResumeSchema = `{
34+ "type": "object",
35+ "properties": {
36+ "agent_id": {
37+ "type": "string",
38+ "description": "Worker id or unique id prefix for the sub-agent to continue."
39+ },
40+ "prompt": {
41+ "type": "string",
42+ "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."
43+ }
44+ },
45+ "required": ["agent_id", "prompt"]
46+}`
47+
48+func (t *SubagentResumeTool) Name() string { return "subagent_resume" }
49+
50+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."
52+}
53+
54+func (t *SubagentResumeTool) Schema() json.RawMessage {
55+ return json.RawMessage(subagentResumeSchema)
56+}
57+
58+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
59+ if ctx != nil {
60+ select {
61+ case <-ctx.Done():
62+ return core.ToolResult{}, ctx.Err()
63+ default:
64+ }
65+ }
66+ prefix := t.Name()
67+ if t.Supervisor == nil {
68+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
69+ }
70+ 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.")
72+ }
73+
74+ var args subagentResumeArgs
75+ if err := json.Unmarshal(raw, &args); err != nil {
76+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
77+ }
78+ id := strings.TrimSpace(args.AgentID)
79+ if id == "" {
80+ return protocolToolError(prefix + ": agent_id is required")
81+ }
82+ if strings.TrimSpace(args.Prompt) == "" {
83+ return protocolToolError(prefix + ": prompt is required")
84+ }
85+ snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id)
86+ if !ok {
87+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
88+ }
89+ resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt)
90+ if err != nil {
91+ return protocolToolError(prefix + ": " + err.Error())
92+ }
93+ if t.OnResumed != nil {
94+ t.OnResumed(resumed, args.Prompt)
95+ }
96+
97+ snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
98+ if !ok {
99+ return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
100+ }
101+ return renderSubagentAction("resumed", snapshot)
102+}
@@ -0,0 +1,102 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/core"
11+)
12+
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.
17+type SubagentResumeTool struct {
18+ Supervisor *subagents.Supervisor
19+ Enabled func() bool
20+
21+ // OnResumed receives the worker and follow-up after a successful resume.
22+ // It runs synchronously, so callbacks must only register non-blocking
23+ // tracking work. The interactive host uses it to watch this new delegated
24+ // turn and deliver its outcome back to the manager automatically.
25+ OnResumed func(agent *subagents.Agent, prompt string)
26+}
27+
28+type subagentResumeArgs struct {
29+ AgentID string `json:"agent_id"`
30+ Prompt string `json:"prompt"`
31+}
32+
33+const subagentResumeSchema = `{
34+ "type": "object",
35+ "properties": {
36+ "agent_id": {
37+ "type": "string",
38+ "description": "Worker id or unique id prefix for the sub-agent to continue."
39+ },
40+ "prompt": {
41+ "type": "string",
42+ "description": "New manager follow-up for the sub-agent. Its earlier task and conversation remain available in the retained session."
43+ }
44+ },
45+ "required": ["agent_id", "prompt"]
46+}`
47+
48+func (t *SubagentResumeTool) Name() string { return "subagent_resume" }
49+
50+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."
52+}
53+
54+func (t *SubagentResumeTool) Schema() json.RawMessage {
55+ return json.RawMessage(subagentResumeSchema)
56+}
57+
58+func (t *SubagentResumeTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
59+ if ctx != nil {
60+ select {
61+ case <-ctx.Done():
62+ return core.ToolResult{}, ctx.Err()
63+ default:
64+ }
65+ }
66+ prefix := t.Name()
67+ if t.Supervisor == nil {
68+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
69+ }
70+ 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.")
72+ }
73+
74+ var args subagentResumeArgs
75+ if err := json.Unmarshal(raw, &args); err != nil {
76+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
77+ }
78+ id := strings.TrimSpace(args.AgentID)
79+ if id == "" {
80+ return protocolToolError(prefix + ": agent_id is required")
81+ }
82+ if strings.TrimSpace(args.Prompt) == "" {
83+ return protocolToolError(prefix + ": prompt is required")
84+ }
85+ snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id)
86+ if !ok {
87+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
88+ }
89+ resumed, err := t.Supervisor.ResumeWithPrompt(ctx, snapshot.ID, args.Prompt)
90+ if err != nil {
91+ return protocolToolError(prefix + ": " + err.Error())
92+ }
93+ if t.OnResumed != nil {
94+ t.OnResumed(resumed, args.Prompt)
95+ }
96+
97+ snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
98+ if !ok {
99+ return core.ToolResult{}, fmt.Errorf("%s: resumed agent disappeared from supervisor", prefix)
100+ }
101+ return renderSubagentAction("resumed", snapshot)
102+}
A packages/agent/tools/subagent_stop.go +123 -0
@@ -0,0 +1,123 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+// SubagentStopTool lets the manager request termination of a stuck background
15+// sub-agent. It uses the supervisor's graceful shutdown and force-stop
16+// lifecycle rather than directly manipulating the worker process.
17+type SubagentStopTool struct {
18+ Supervisor *subagents.Supervisor
19+ Enabled func() bool
20+
21+ // OnStopRequested registers non-blocking completion tracking before the
22+ // shutdown request is sent. The interactive host uses it to deliver the
23+ // terminal result even for workers restored by Reload.
24+ OnStopRequested func(agent *subagents.Agent)
25+}
26+
27+type subagentStopArgs struct {
28+ AgentID string `json:"agent_id"`
29+}
30+
31+type subagentActionResponse struct {
32+ Action string `json:"action"`
33+ Agent subagentStatusEntry `json:"agent"`
34+}
35+
36+const subagentStopSchema = `{
37+ "type": "object",
38+ "properties": {
39+ "agent_id": {
40+ "type": "string",
41+ "description": "Worker id or unique id prefix for the stuck background sub-agent to terminate."
42+ }
43+ },
44+ "required": ["agent_id"]
45+}`
46+
47+func (t *SubagentStopTool) Name() string { return "subagent_stop" }
48+
49+func (t *SubagentStopTool) Description() string {
50+ return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."
51+}
52+
53+func (t *SubagentStopTool) Schema() json.RawMessage {
54+ return json.RawMessage(subagentStopSchema)
55+}
56+
57+func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
58+ if ctx != nil {
59+ select {
60+ case <-ctx.Done():
61+ return core.ToolResult{}, ctx.Err()
62+ default:
63+ }
64+ }
65+ prefix := t.Name()
66+ if t.Supervisor == nil {
67+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
68+ }
69+ 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.")
71+ }
72+
73+ var args subagentStopArgs
74+ if err := json.Unmarshal(raw, &args); err != nil {
75+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
76+ }
77+ id := strings.TrimSpace(args.AgentID)
78+ if id == "" {
79+ return protocolToolError(prefix + ": agent_id is required")
80+ }
81+ snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id)
82+ if !ok {
83+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
84+ }
85+ if !subagentCanStop(snapshot) {
86+ return protocolToolError(fmt.Sprintf("%s: agent %s has no live worker to stop", prefix, snapshot.ID))
87+ }
88+ if t.OnStopRequested != nil {
89+ if agent := t.Supervisor.Get(snapshot.ID); agent != nil {
90+ t.OnStopRequested(agent)
91+ }
92+ }
93+ if err := t.Supervisor.StopContext(ctx, snapshot.ID); err != nil {
94+ return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
95+ }
96+
97+ snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
98+ if !ok {
99+ return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)
100+ }
101+ return renderSubagentAction("stop_requested", snapshot)
102+}
103+
104+func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
105+ return snapshot.Status == subagents.StatusPending ||
106+ snapshot.Status == subagents.StatusRunning ||
107+ (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)
108+}
109+
110+func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) {
111+ response := subagentActionResponse{
112+ Action: action,
113+ Agent: publicSubagentStatus(snapshot),
114+ }
115+ data, err := json.Marshal(response)
116+ if err != nil {
117+ return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)
118+ }
119+ return core.ToolResult{
120+ Content: []provider.Content{provider.TextBlock{Text: string(data)}},
121+ Details: response,
122+ }, nil
123+}
@@ -0,0 +1,123 @@
1+package tools
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+
9+ "github.com/bnema/zut/packages/agent/subagents"
10+ "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
12+)
13+
14+// SubagentStopTool lets the manager request termination of a stuck background
15+// sub-agent. It uses the supervisor's graceful shutdown and force-stop
16+// lifecycle rather than directly manipulating the worker process.
17+type SubagentStopTool struct {
18+ Supervisor *subagents.Supervisor
19+ Enabled func() bool
20+
21+ // OnStopRequested registers non-blocking completion tracking before the
22+ // shutdown request is sent. The interactive host uses it to deliver the
23+ // terminal result even for workers restored by Reload.
24+ OnStopRequested func(agent *subagents.Agent)
25+}
26+
27+type subagentStopArgs struct {
28+ AgentID string `json:"agent_id"`
29+}
30+
31+type subagentActionResponse struct {
32+ Action string `json:"action"`
33+ Agent subagentStatusEntry `json:"agent"`
34+}
35+
36+const subagentStopSchema = `{
37+ "type": "object",
38+ "properties": {
39+ "agent_id": {
40+ "type": "string",
41+ "description": "Worker id or unique id prefix for the stuck background sub-agent to terminate."
42+ }
43+ },
44+ "required": ["agent_id"]
45+}`
46+
47+func (t *SubagentStopTool) Name() string { return "subagent_stop" }
48+
49+func (t *SubagentStopTool) Description() string {
50+ return "Request termination of a stuck background sub-agent. It gracefully requests shutdown, then cancels or force-stops the worker when necessary."
51+}
52+
53+func (t *SubagentStopTool) Schema() json.RawMessage {
54+ return json.RawMessage(subagentStopSchema)
55+}
56+
57+func (t *SubagentStopTool) Execute(ctx context.Context, raw json.RawMessage, _ func(string)) (core.ToolResult, error) {
58+ if ctx != nil {
59+ select {
60+ case <-ctx.Done():
61+ return core.ToolResult{}, ctx.Err()
62+ default:
63+ }
64+ }
65+ prefix := t.Name()
66+ if t.Supervisor == nil {
67+ return protocolToolError(prefix + ": subagent supervisor not available in this mode")
68+ }
69+ 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.")
71+ }
72+
73+ var args subagentStopArgs
74+ if err := json.Unmarshal(raw, &args); err != nil {
75+ return core.ToolResult{}, fmt.Errorf("invalid args: %w", err)
76+ }
77+ id := strings.TrimSpace(args.AgentID)
78+ if id == "" {
79+ return protocolToolError(prefix + ": agent_id is required")
80+ }
81+ snapshot, ok := findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), id)
82+ if !ok {
83+ return protocolToolError(fmt.Sprintf("%s: no such agent %q", prefix, id))
84+ }
85+ if !subagentCanStop(snapshot) {
86+ return protocolToolError(fmt.Sprintf("%s: agent %s has no live worker to stop", prefix, snapshot.ID))
87+ }
88+ if t.OnStopRequested != nil {
89+ if agent := t.Supervisor.Get(snapshot.ID); agent != nil {
90+ t.OnStopRequested(agent)
91+ }
92+ }
93+ if err := t.Supervisor.StopContext(ctx, snapshot.ID); err != nil {
94+ return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
95+ }
96+
97+ snapshot, ok = findSubagentStatusSnapshot(t.Supervisor.SnapshotAll(), snapshot.ID)
98+ if !ok {
99+ return core.ToolResult{}, fmt.Errorf("%s: terminated agent disappeared from supervisor", prefix)
100+ }
101+ return renderSubagentAction("stop_requested", snapshot)
102+}
103+
104+func subagentCanStop(snapshot subagents.AgentSnapshot) bool {
105+ return snapshot.Status == subagents.StatusPending ||
106+ snapshot.Status == subagents.StatusRunning ||
107+ (snapshot.Status == subagents.StatusDetached && snapshot.ProcessState == subagents.ProcessAlive)
108+}
109+
110+func renderSubagentAction(action string, snapshot subagents.AgentSnapshot) (core.ToolResult, error) {
111+ response := subagentActionResponse{
112+ Action: action,
113+ Agent: publicSubagentStatus(snapshot),
114+ }
115+ data, err := json.Marshal(response)
116+ if err != nil {
117+ return core.ToolResult{}, fmt.Errorf("subagent action: encode response: %w", err)
118+ }
119+ return core.ToolResult{
120+ Content: []provider.Content{provider.TextBlock{Text: string(data)}},
121+ Details: response,
122+ }, nil
123+}

Keyboard shortcuts

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

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

Branches 1

Tags 476

↑↓ moveenter openesc close