detached 073fe61

feat(subagents): support bounded spawn waits

commit
073fe61296d8acc295c0685b55a8751dfcdfade8
author
brice · · 2026-08-18 14:02 UTC
parent
cabc432
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +213 -68
M docs/subagents.md +8 -5
@@ -99,15 +99,18 @@ explicit successful follow-up. A host restart never retries it automatically.
9999 The model-facing tools retain their logical names:
100100
101101 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
102- `reasoning`, `fast_mode`, `required`, and `isolation` (`shared` or
103- `worktree`). It returns a logical `subagent://<id>` reference.
102+ `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
103+ `worktree`). `wait` is an explicit whole-second value from 1 through 300;
104+ when omitted, spawning returns immediately. A timed-out wait leaves the
105+ child running. It returns a logical `subagent://<id>` reference.
104106 - `subagent_status` returns bounded state for one child or the current set.
105107 - `subagent_stop` stops one live child.
106108 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
107109
108-These calls are asynchronous. Completion arrives through the host’s typed
109-completion update, not process polling. Do not use sleep loops, repeated
110-status calls, journal files, or terminal UI inspection as a completion signal.
110+These calls are asynchronous unless `subagent_spawn` receives an explicit
111+`wait` value. Otherwise completion arrives through the host’s typed completion
112+update, not process polling. Do not use sleep loops, repeated status calls,
113+journal files, or terminal UI inspection as a completion signal.
111114 Successful completions include the final visible assistant summary, capped at
112115 256 KiB; open the child session for the complete durable transcript.
113116
@@ -99,15 +99,18 @@ explicit successful follow-up. A host restart never retries it automatically.
99 The model-facing tools retain their logical names:99 The model-facing tools retain their logical names:
100 100
101 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,101 - `subagent_spawn` accepts `task`, optional `agent`, `model` and `provider`,
102- `reasoning`, `fast_mode`, `required`, and `isolation` (`shared` or102+ `reasoning`, `fast_mode`, `required`, `wait`, and `isolation` (`shared` or
103- `worktree`). It returns a logical `subagent://<id>` reference.103+ `worktree`). `wait` is an explicit whole-second value from 1 through 300;
104+ when omitted, spawning returns immediately. A timed-out wait leaves the
105+ child running. It returns a logical `subagent://<id>` reference.
104 - `subagent_status` returns bounded state for one child or the current set.106 - `subagent_status` returns bounded state for one child or the current set.
105 - `subagent_stop` stops one live child.107 - `subagent_stop` stops one live child.
106 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.108 - `subagent_resume` accepts an explicit follow-up prompt for an existing child.
107 109
108-These calls are asynchronous. Completion arrives through the host’s typed110+These calls are asynchronous unless `subagent_spawn` receives an explicit
109-completion update, not process polling. Do not use sleep loops, repeated111+`wait` value. Otherwise completion arrives through the host’s typed completion
110-status calls, journal files, or terminal UI inspection as a completion signal.112+update, not process polling. Do not use sleep loops, repeated status calls,
113+journal files, or terminal UI inspection as a completion signal.
111 Successful completions include the final visible assistant summary, capped at114 Successful completions include the final visible assistant summary, capped at
112 256 KiB; open the child session for the complete durable transcript.115 256 KiB; open the child session for the complete durable transcript.
113 116
M packages/agent/settings_store.go +3 -3
@@ -258,15 +258,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro
258258
259259 Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.
260260
261-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.
261+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.
262262
263-Completion is host-event-driven for every resident child. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.
263+Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update.
264264
265265 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`
266266
267267 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
268268 // available without enabling proactive primary-agent orchestration.
269-const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
269+const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; a timed-out wait leaves the child running. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
270270
271271 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
272272 // orchestrator contract still applies when launch-time policy withholds the
@@ -258,15 +258,15 @@ Delegate all implementation, debugging/testing, and code-review work to an appro
258 258
259 Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.259 Give every worker a self-contained task because workers start without this conversation's context. Shared-worktree workers edit the same working directory, so coordinate dependent work and avoid conflicting edits. Use isolation:"worktree" for parallel coding when workers need separate trees; its changes are returned as a patch for a worker to integrate, not for you to apply. Do not invent feature scope beyond the user's request. Child workers cannot recursively spawn more sub-agents in v1.
260 260
261-Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous: manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.261+Use required:true whenever your answer or a declared workflow depends on that worker, especially for required validation or finalization. Required workers remain asynchronous unless you explicitly set subagent_spawn's wait field: without it, manager calls return immediately, the parent stays free to coordinate or perform independent work, and the host delivers terminal outcomes through completion updates. Failed, timed-out, or canceled required work remains unmet: use an enabled manager follow-up action to retry it and do not claim completion. An indeterminate outcome after host restart must not be retried automatically; the user must inspect durable results and side effects, then explicitly resume, restart, or remove the worker. Only the user can waive required work by explicitly removing the terminal worker with /subagents rm. Keep required:false only for independent work that may safely finish later.
262 262
263-Completion is host-event-driven for every resident child. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update. Completion updates are the only completion signal. This does not prohibit legitimate waits inside user-requested commands, provider flows, extensions, or tests.263+Completion is host-event-driven unless you explicitly set subagent_spawn's wait field to a whole number of seconds (1–300) for that initial task. Omitting wait always returns immediately. Never use "bash sleep", "watch", "tail -f", polling loops, repeated "subagent_status", or dashboard, metadata, or file checks solely to wait. Those are not completion signals. Continue unrelated independent tasks; otherwise end or yield your turn until the host injects a completion update.
264 264
265 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`265 When workers finish, use the host update's agent ID, status, task, optional error, and final response or tail to coordinate any follow-up and summarize the outcome. Treat [auto-subagents update] and [required-subagents update] messages as observed worker state, not as new user requests.`
266 266
267 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools267 // OnDemandSubagentsSystemAddendum keeps the canonical subagent tools
268 // available without enabling proactive primary-agent orchestration.268 // available without enabling proactive primary-agent orchestration.
269-const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Every manager call returns immediately and every result arrives through a host completion update, so continue unrelated work rather than polling. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`269+const OnDemandSubagentsSystemAddendum = `Subagent tools are available only when the user asks you to delegate and the relevant launch-time tool permission allows it, or an active skill workflow requires delegation. Use them only in those cases. Mark delegated work required:true when the user's request or workflow requires its outcome before completion. Omit subagent_spawn's wait field to return immediately and receive completion through a host update. Set wait only to an explicit 1–300 second value when you need to wait for that initial task; a timed-out wait leaves the child running. Failed, timed-out, or canceled required work remains unmet and must be retried; after host restart, an indeterminate outcome requires user-directed reconciliation of durable results and side effects before non-idempotent work is retried. Do not produce a terminal answer until required work is satisfied. Only the user may waive terminal required work by removing the worker with /subagents rm. Do not delegate proactively or switch into an orchestrator role; otherwise perform the work yourself.`
270 270
271 // AutoSubagentsDelegationUnavailableAddendum explains why the strict271 // AutoSubagentsDelegationUnavailableAddendum explains why the strict
272 // orchestrator contract still applies when launch-time policy withholds the272 // orchestrator contract still applies when launch-time policy withholds the
M packages/agent/subagents/resident_manager.go +96 -55
@@ -21,26 +21,27 @@ import (
2121 type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error)
2222
2323 type ResidentManager struct {
24- root string
25- factory ResidentFactory
26- prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error)
27- mu sync.Mutex
28- lifecycleMu sync.Mutex
29- closed bool
30- children map[string]*ResidentChild
31- recovered map[string]ResidentSnapshot
32- recoveredSpec map[string]ResidentChildSpec
33- pending map[string]struct{}
34- activeChildren map[string]struct{}
35- scheduler *ResidentScheduler
36- queueTimeout time.Duration
37- allowedRoots []string
38- dispatchMu sync.Mutex
39- onCompletion func(ResidentCompletion)
40- onAccepted func(ResidentChildSpec, string, string)
41- onUpdate func(string)
42- onHistoryUpdate func(string)
43- onActivity func(bool)
24+ root string
25+ factory ResidentFactory
26+ prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error)
27+ mu sync.Mutex
28+ lifecycleMu sync.Mutex
29+ closed bool
30+ children map[string]*ResidentChild
31+ recovered map[string]ResidentSnapshot
32+ recoveredSpec map[string]ResidentChildSpec
33+ pending map[string]struct{}
34+ activeChildren map[string]struct{}
35+ scheduler *ResidentScheduler
36+ queueTimeout time.Duration
37+ allowedRoots []string
38+ dispatchMu sync.Mutex
39+ onCompletion func(ResidentCompletion)
40+ completionWaiters map[string][]chan ResidentCompletion
41+ onAccepted func(ResidentChildSpec, string, string)
42+ onUpdate func(string)
43+ onHistoryUpdate func(string)
44+ onActivity func(bool)
4445 }
4546
4647 func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion)) {
@@ -52,6 +53,64 @@ func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion
5253 m.mu.Unlock()
5354 }
5455
56+// WatchCompletion registers for one accepted resident turn. The caller must
57+// register before making the turn visible, then call the returned cancel
58+// function if it stops waiting before completion.
59+func (m *ResidentManager) WatchCompletion(childID, turnID string) (<-chan ResidentCompletion, func()) {
60+ result := make(chan ResidentCompletion, 1)
61+ key := completionKey(childID, turnID)
62+ if m == nil || key == "" {
63+ close(result)
64+ return result, func() {}
65+ }
66+ m.mu.Lock()
67+ if m.closed {
68+ m.mu.Unlock()
69+ close(result)
70+ return result, func() {}
71+ }
72+ if m.completionWaiters == nil {
73+ m.completionWaiters = make(map[string][]chan ResidentCompletion)
74+ }
75+ m.completionWaiters[key] = append(m.completionWaiters[key], result)
76+ m.mu.Unlock()
77+ return result, func() {
78+ m.mu.Lock()
79+ waiters := m.completionWaiters[key]
80+ for i, waiter := range waiters {
81+ if waiter == result {
82+ waiters = append(waiters[:i], waiters[i+1:]...)
83+ break
84+ }
85+ }
86+ if len(waiters) == 0 {
87+ delete(m.completionWaiters, key)
88+ } else {
89+ m.completionWaiters[key] = waiters
90+ }
91+ m.mu.Unlock()
92+ }
93+}
94+
95+func (m *ResidentManager) reportCompletion(completion ResidentCompletion) {
96+ if m == nil {
97+ return
98+ }
99+ key := completionKey(completion.ChildID, completion.TurnID)
100+ m.mu.Lock()
101+ waiters := m.completionWaiters[key]
102+ delete(m.completionWaiters, key)
103+ observer := m.onCompletion
104+ m.mu.Unlock()
105+ for _, waiter := range waiters {
106+ waiter <- completion
107+ close(waiter)
108+ }
109+ if observer != nil {
110+ observer(completion)
111+ }
112+}
113+
55114 // SetAcceptedObserver receives an accepted turn's ID and prompt only after
56115 // the authoritative child.accepted record has synchronized and before it can
57116 // be scheduled.
@@ -202,17 +261,18 @@ func newResidentManager(root string, policy SubagentPolicy, prepare func(context
202261 prepare = PrepareWorkspace
203262 }
204263 return &ResidentManager{
205- root: root,
206- factory: factory,
207- prepareWorkspace: prepare,
208- children: make(map[string]*ResidentChild),
209- recovered: make(map[string]ResidentSnapshot),
210- recoveredSpec: make(map[string]ResidentChildSpec),
211- pending: make(map[string]struct{}),
212- activeChildren: make(map[string]struct{}),
213- scheduler: NewResidentScheduler(policy.MaxConcurrent),
214- queueTimeout: policy.QueueTimeout,
215- allowedRoots: append([]string(nil), policy.AllowedRoots...),
264+ root: root,
265+ factory: factory,
266+ prepareWorkspace: prepare,
267+ children: make(map[string]*ResidentChild),
268+ recovered: make(map[string]ResidentSnapshot),
269+ recoveredSpec: make(map[string]ResidentChildSpec),
270+ pending: make(map[string]struct{}),
271+ activeChildren: make(map[string]struct{}),
272+ completionWaiters: make(map[string][]chan ResidentCompletion),
273+ scheduler: NewResidentScheduler(policy.MaxConcurrent),
274+ queueTimeout: policy.QueueTimeout,
275+ allowedRoots: append([]string(nil), policy.AllowedRoots...),
216276 }
217277 }
218278
@@ -288,18 +348,10 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas
288348 if err != nil {
289349 _ = journal.RecordFailure(spec)
290350 _ = journal.Close()
291- m.mu.Lock()
292- completion := m.onCompletion
293- m.mu.Unlock()
294- if completion != nil {
295- completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
296- }
351+ m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
297352 return nil, err
298353 }
299- m.mu.Lock()
300- observer := m.onCompletion
301- m.mu.Unlock()
302- child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer)
354+ child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion)
303355 child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) })
304356 child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) })
305357 m.mu.Lock()
@@ -315,14 +367,11 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas
315367 if m.children[spec.ID] == child {
316368 delete(m.children, spec.ID)
317369 }
318- completion := m.onCompletion
319370 m.mu.Unlock()
320371 _ = child.Close(context.Background())
321372 if !childAccepted {
322373 _ = journal.RecordTurnInterrupted(spec, spec.InitialTurnID)
323- if completion != nil {
324- completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
325- }
374+ m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
326375 }
327376 return nil, err
328377 }
@@ -469,10 +518,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
469518 _ = journal.Close()
470519 return fmt.Errorf("resident manager rebuild child: %w", err)
471520 }
472- m.mu.Lock()
473- observer := m.onCompletion
474- m.mu.Unlock()
475- child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer)
521+ child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion)
476522 child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) })
477523 child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) })
478524 m.mu.Lock()
@@ -506,12 +552,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
506552 if childAccepted, err := child.resumeAccepted(ctx, turnID, prompt); err != nil {
507553 if !childAccepted {
508554 _ = child.journal.RecordTurnInterrupted(child.spec, turnID)
509- m.mu.Lock()
510- completion := m.onCompletion
511- m.mu.Unlock()
512- if completion != nil {
513- completion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err})
514- }
555+ m.reportCompletion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err})
515556 }
516557 return err
517558 }
@@ -21,26 +21,27 @@ import (
21 type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error)21 type ResidentFactory func(ResidentChildSpec, *ResidentJournal) (ResidentTurnRunner, error)
22 22
23 type ResidentManager struct {23 type ResidentManager struct {
24- root string24+ root string
25- factory ResidentFactory25+ factory ResidentFactory
26- prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error)26+ prepareWorkspace func(context.Context, WorkspaceRequest) (WorkspaceHandle, error)
27- mu sync.Mutex27+ mu sync.Mutex
28- lifecycleMu sync.Mutex28+ lifecycleMu sync.Mutex
29- closed bool29+ closed bool
30- children map[string]*ResidentChild30+ children map[string]*ResidentChild
31- recovered map[string]ResidentSnapshot31+ recovered map[string]ResidentSnapshot
32- recoveredSpec map[string]ResidentChildSpec32+ recoveredSpec map[string]ResidentChildSpec
33- pending map[string]struct{}33+ pending map[string]struct{}
34- activeChildren map[string]struct{}34+ activeChildren map[string]struct{}
35- scheduler *ResidentScheduler35+ scheduler *ResidentScheduler
36- queueTimeout time.Duration36+ queueTimeout time.Duration
37- allowedRoots []string37+ allowedRoots []string
38- dispatchMu sync.Mutex38+ dispatchMu sync.Mutex
39- onCompletion func(ResidentCompletion)39+ onCompletion func(ResidentCompletion)
40- onAccepted func(ResidentChildSpec, string, string)40+ completionWaiters map[string][]chan ResidentCompletion
41- onUpdate func(string)41+ onAccepted func(ResidentChildSpec, string, string)
42- onHistoryUpdate func(string)42+ onUpdate func(string)
43- onActivity func(bool)43+ onHistoryUpdate func(string)
44+ onActivity func(bool)
44 }45 }
45 46
46 func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion)) {47 func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion)) {
@@ -52,6 +53,64 @@ func (m *ResidentManager) SetCompletionObserver(observer func(ResidentCompletion
52 m.mu.Unlock()53 m.mu.Unlock()
53 }54 }
54 55
56+// WatchCompletion registers for one accepted resident turn. The caller must
57+// register before making the turn visible, then call the returned cancel
58+// function if it stops waiting before completion.
59+func (m *ResidentManager) WatchCompletion(childID, turnID string) (<-chan ResidentCompletion, func()) {
60+ result := make(chan ResidentCompletion, 1)
61+ key := completionKey(childID, turnID)
62+ if m == nil || key == "" {
63+ close(result)
64+ return result, func() {}
65+ }
66+ m.mu.Lock()
67+ if m.closed {
68+ m.mu.Unlock()
69+ close(result)
70+ return result, func() {}
71+ }
72+ if m.completionWaiters == nil {
73+ m.completionWaiters = make(map[string][]chan ResidentCompletion)
74+ }
75+ m.completionWaiters[key] = append(m.completionWaiters[key], result)
76+ m.mu.Unlock()
77+ return result, func() {
78+ m.mu.Lock()
79+ waiters := m.completionWaiters[key]
80+ for i, waiter := range waiters {
81+ if waiter == result {
82+ waiters = append(waiters[:i], waiters[i+1:]...)
83+ break
84+ }
85+ }
86+ if len(waiters) == 0 {
87+ delete(m.completionWaiters, key)
88+ } else {
89+ m.completionWaiters[key] = waiters
90+ }
91+ m.mu.Unlock()
92+ }
93+}
94+
95+func (m *ResidentManager) reportCompletion(completion ResidentCompletion) {
96+ if m == nil {
97+ return
98+ }
99+ key := completionKey(completion.ChildID, completion.TurnID)
100+ m.mu.Lock()
101+ waiters := m.completionWaiters[key]
102+ delete(m.completionWaiters, key)
103+ observer := m.onCompletion
104+ m.mu.Unlock()
105+ for _, waiter := range waiters {
106+ waiter <- completion
107+ close(waiter)
108+ }
109+ if observer != nil {
110+ observer(completion)
111+ }
112+}
113+
55 // SetAcceptedObserver receives an accepted turn's ID and prompt only after114 // SetAcceptedObserver receives an accepted turn's ID and prompt only after
56 // the authoritative child.accepted record has synchronized and before it can115 // the authoritative child.accepted record has synchronized and before it can
57 // be scheduled.116 // be scheduled.
@@ -202,17 +261,18 @@ func newResidentManager(root string, policy SubagentPolicy, prepare func(context
202 prepare = PrepareWorkspace261 prepare = PrepareWorkspace
203 }262 }
204 return &ResidentManager{263 return &ResidentManager{
205- root: root,264+ root: root,
206- factory: factory,265+ factory: factory,
207- prepareWorkspace: prepare,266+ prepareWorkspace: prepare,
208- children: make(map[string]*ResidentChild),267+ children: make(map[string]*ResidentChild),
209- recovered: make(map[string]ResidentSnapshot),268+ recovered: make(map[string]ResidentSnapshot),
210- recoveredSpec: make(map[string]ResidentChildSpec),269+ recoveredSpec: make(map[string]ResidentChildSpec),
211- pending: make(map[string]struct{}),270+ pending: make(map[string]struct{}),
212- activeChildren: make(map[string]struct{}),271+ activeChildren: make(map[string]struct{}),
213- scheduler: NewResidentScheduler(policy.MaxConcurrent),272+ completionWaiters: make(map[string][]chan ResidentCompletion),
214- queueTimeout: policy.QueueTimeout,273+ scheduler: NewResidentScheduler(policy.MaxConcurrent),
215- allowedRoots: append([]string(nil), policy.AllowedRoots...),274+ queueTimeout: policy.QueueTimeout,
275+ allowedRoots: append([]string(nil), policy.AllowedRoots...),
216 }276 }
217 }277 }
218 278
@@ -288,18 +348,10 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas
288 if err != nil {348 if err != nil {
289 _ = journal.RecordFailure(spec)349 _ = journal.RecordFailure(spec)
290 _ = journal.Close()350 _ = journal.Close()
291- m.mu.Lock()351+ m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
292- completion := m.onCompletion
293- m.mu.Unlock()
294- if completion != nil {
295- completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
296- }
297 return nil, err352 return nil, err
298 }353 }
299- m.mu.Lock()354+ child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion)
300- observer := m.onCompletion
301- m.mu.Unlock()
302- child := newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer)
303 child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) })355 child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) })
304 child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) })356 child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) })
305 m.mu.Lock()357 m.mu.Lock()
@@ -315,14 +367,11 @@ func (m *ResidentManager) Spawn(ctx context.Context, spec ResidentChildSpec, tas
315 if m.children[spec.ID] == child {367 if m.children[spec.ID] == child {
316 delete(m.children, spec.ID)368 delete(m.children, spec.ID)
317 }369 }
318- completion := m.onCompletion
319 m.mu.Unlock()370 m.mu.Unlock()
320 _ = child.Close(context.Background())371 _ = child.Close(context.Background())
321 if !childAccepted {372 if !childAccepted {
322 _ = journal.RecordTurnInterrupted(spec, spec.InitialTurnID)373 _ = journal.RecordTurnInterrupted(spec, spec.InitialTurnID)
323- if completion != nil {374+ m.reportCompletion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
324- completion(ResidentCompletion{ChildID: spec.ID, TurnID: spec.InitialTurnID, Task: task, Err: err})
325- }
326 }375 }
327 return nil, err376 return nil, err
328 }377 }
@@ -469,10 +518,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
469 _ = journal.Close()518 _ = journal.Close()
470 return fmt.Errorf("resident manager rebuild child: %w", err)519 return fmt.Errorf("resident manager rebuild child: %w", err)
471 }520 }
472- m.mu.Lock()521+ child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), m.reportCompletion)
473- observer := m.onCompletion
474- m.mu.Unlock()
475- child = newJournaledResidentChildWithWorkspace(spec, journal, workspace, m.scheduledRunner(spec.ID, runner), observer)
476 child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) })522 child.setUpdateObserver(func(historyChanged bool) { m.notifyUpdate(spec.ID, historyChanged) })
477 child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) })523 child.setStateObserver(func(state ResidentState) { m.notifyActivity(spec.ID, state) })
478 m.mu.Lock()524 m.mu.Lock()
@@ -506,12 +552,7 @@ func (m *ResidentManager) Resume(ctx context.Context, childID, prompt string) er
506 if childAccepted, err := child.resumeAccepted(ctx, turnID, prompt); err != nil {552 if childAccepted, err := child.resumeAccepted(ctx, turnID, prompt); err != nil {
507 if !childAccepted {553 if !childAccepted {
508 _ = child.journal.RecordTurnInterrupted(child.spec, turnID)554 _ = child.journal.RecordTurnInterrupted(child.spec, turnID)
509- m.mu.Lock()555+ m.reportCompletion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err})
510- completion := m.onCompletion
511- m.mu.Unlock()
512- if completion != nil {
513- completion(ResidentCompletion{ChildID: child.spec.ID, TurnID: turnID, Task: prompt, Err: err})
514- }
515 }556 }
516 return err557 return err
517 }558 }
M packages/agent/tools/resident_tools_test.go +38 -0
@@ -8,6 +8,7 @@ import (
88
99 "github.com/bnema/zut/packages/agent/subagents"
1010 "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
1112 )
1213
1314 func TestResidentToolsUseManagerOnly(t *testing.T) {
@@ -52,6 +53,29 @@ func TestResidentToolsUseManagerOnly(t *testing.T) {
5253 }
5354 }
5455
56+func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
57+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
58+ return func(context.Context, string) error { return nil }, nil
59+ })
60+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
61+ spawn := &SubagentSpawnTool{
62+ ResidentManager: manager,
63+ Enabled: func() bool { return true },
64+ DefaultProvider: func() string { return "openai" },
65+ DefaultModel: func() string { return "gpt-5" },
66+ BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
67+ return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil
68+ },
69+ }
70+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil)
71+ if err != nil || result.IsError {
72+ t.Fatalf("Execute = (%#v, %v)", result, err)
73+ }
74+ if got := toolResultText(t, result); !strings.Contains(got, "state: completed") {
75+ t.Fatalf("wait result = %q, want completed state", got)
76+ }
77+}
78+
5579 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
5680 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
5781 return func(context.Context, string) error { return nil }, nil
@@ -70,6 +94,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
7094 `{"task":"x","unexpected":true}`,
7195 `{"task":"x"}{}`,
7296 `{"task":"x","model":"gpt-5"}`,
97+ `{"task":"x","wait":0}`,
98+ `{"task":"x","wait":301}`,
7399 `{"task":"x","isolation":"outside"}`,
74100 } {
75101 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
@@ -130,6 +156,18 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) {
130156 }
131157 }
132158
159+func toolResultText(t *testing.T, result core.ToolResult) string {
160+ t.Helper()
161+ if len(result.Content) != 1 {
162+ t.Fatalf("content = %#v, want one text block", result.Content)
163+ }
164+ block, ok := result.Content[0].(provider.TextBlock)
165+ if !ok {
166+ t.Fatalf("content = %#v, want text block", result.Content)
167+ }
168+ return block.Text
169+}
170+
133171 func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
134172 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
135173 if ok {
@@ -8,6 +8,7 @@ import (
8 8
9 "github.com/bnema/zut/packages/agent/subagents"9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/core"10 "github.com/bnema/zut/packages/core"
11+ "github.com/bnema/zut/packages/provider"
11 )12 )
12 13
13 func TestResidentToolsUseManagerOnly(t *testing.T) {14 func TestResidentToolsUseManagerOnly(t *testing.T) {
@@ -52,6 +53,29 @@ func TestResidentToolsUseManagerOnly(t *testing.T) {
52 }53 }
53 }54 }
54 55
56+func TestResidentSpawnWaitReturnsInitialCompletion(t *testing.T) {
57+ manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
58+ return func(context.Context, string) error { return nil }, nil
59+ })
60+ t.Cleanup(func() { _ = manager.Close(context.Background()) })
61+ spawn := &SubagentSpawnTool{
62+ ResidentManager: manager,
63+ Enabled: func() bool { return true },
64+ DefaultProvider: func() string { return "openai" },
65+ DefaultModel: func() string { return "gpt-5" },
66+ BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
67+ return subagents.ResidentChildSpec{ID: "wait-for-completion", SessionID: "child-session", Provider: request.Provider, Model: request.Model}, nil
68+ },
69+ }
70+ result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"finish now","wait":1}`), nil)
71+ if err != nil || result.IsError {
72+ t.Fatalf("Execute = (%#v, %v)", result, err)
73+ }
74+ if got := toolResultText(t, result); !strings.Contains(got, "state: completed") {
75+ t.Fatalf("wait result = %q, want completed state", got)
76+ }
77+}
78+
55 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {79 func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
56 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {80 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
57 return func(context.Context, string) error { return nil }, nil81 return func(context.Context, string) error { return nil }, nil
@@ -70,6 +94,8 @@ func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
70 `{"task":"x","unexpected":true}`,94 `{"task":"x","unexpected":true}`,
71 `{"task":"x"}{}`,95 `{"task":"x"}{}`,
72 `{"task":"x","model":"gpt-5"}`,96 `{"task":"x","model":"gpt-5"}`,
97+ `{"task":"x","wait":0}`,
98+ `{"task":"x","wait":301}`,
73 `{"task":"x","isolation":"outside"}`,99 `{"task":"x","isolation":"outside"}`,
74 } {100 } {
75 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)101 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
@@ -130,6 +156,18 @@ func TestResidentToolsRejectUnknownFields(t *testing.T) {
130 }156 }
131 }157 }
132 158
159+func toolResultText(t *testing.T, result core.ToolResult) string {
160+ t.Helper()
161+ if len(result.Content) != 1 {
162+ t.Fatalf("content = %#v, want one text block", result.Content)
163+ }
164+ block, ok := result.Content[0].(provider.TextBlock)
165+ if !ok {
166+ t.Fatalf("content = %#v, want text block", result.Content)
167+ }
168+ return block.Text
169+}
170+
133 func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {171 func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
134 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")172 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
135 if ok {173 if ok {
M packages/agent/tools/subagent_spawn.go +68 -5
@@ -7,10 +7,12 @@ import (
77 "fmt"
88 "io"
99 "strings"
10+ "time"
1011
1112 "github.com/bnema/zut/packages/agent/subagents"
1213 "github.com/bnema/zut/packages/core"
1314 "github.com/bnema/zut/packages/provider"
15+ "github.com/google/uuid"
1416 )
1517
1618 // SubagentSpawnTool lets the main agent delegate work through the resident
@@ -25,6 +27,8 @@ const (
2527 SubagentStatusToolName = "subagent_status"
2628 SubagentStopToolName = "subagent_stop"
2729 SubagentResumeToolName = "subagent_resume"
30+
31+ maxSubagentWaitSeconds = 5 * 60
2832 )
2933
3034 type SubagentSpawnTool struct {
@@ -69,6 +73,7 @@ type subagentSpawnArgs struct {
6973 Reasoning string `json:"reasoning,omitempty"`
7074 FastMode *bool `json:"fast_mode,omitempty"`
7175 Required bool `json:"required,omitempty"`
76+ Wait *int `json:"wait,omitempty"`
7277 Isolation string `json:"isolation,omitempty"`
7378 }
7479
@@ -104,6 +109,12 @@ const subagentSpawnSchemaTemplate = `{
104109 "type": "boolean",
105110 "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."
106111 },
112+ "wait": {
113+ "type": "integer",
114+ "minimum": 1,
115+ "maximum": 300,
116+ "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires."
117+ },
107118 "isolation": {
108119 "type": "string",
109120 "enum": ["shared", "worktree"],
@@ -115,7 +126,7 @@ const subagentSpawnSchemaTemplate = `{
115126
116127 func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
117128 func (t *SubagentSpawnTool) Description() string {
118- return "Delegate work to a resident sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."
129+ return "Delegate work to a resident sub-agent. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn."
119130 }
120131 func (t *SubagentSpawnTool) Schema() json.RawMessage {
121132 return json.RawMessage(subagentSpawnSchemaTemplate)
@@ -137,6 +148,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
137148 if task == "" {
138149 return protocolToolError(prefix + ": task is required")
139150 }
151+ if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
152+ return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
153+ }
140154
141155 workspaceMode := subagents.WorkspaceShared
142156 if value := strings.TrimSpace(a.Isolation); value != "" {
@@ -217,11 +231,44 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
217231 if err != nil {
218232 return protocolToolError(prefix + ": " + err.Error())
219233 }
220- if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
234+ var completion subagents.ResidentCompletion
235+ waitTimedOut := false
236+ if a.Wait != nil {
237+ spec.InitialTurnID = uuid.NewString()
238+ completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
239+ defer cancelWait()
240+ if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
241+ return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
242+ }
243+ waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)
244+ defer cancel()
245+ select {
246+ case result, ok := <-completionResult:
247+ if !ok {
248+ return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
249+ }
250+ completion = result
251+ case <-waitCtx.Done():
252+ if ctx.Err() != nil {
253+ return core.ToolResult{}, ctx.Err()
254+ }
255+ waitTimedOut = true
256+ }
257+ } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
221258 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
222259 }
260+ state := "queued"
261+ if a.Wait != nil {
262+ if waitTimedOut {
263+ state = "running"
264+ } else if completion.Err != nil {
265+ state = "failed"
266+ } else {
267+ state = "completed"
268+ }
269+ }
223270 var sb strings.Builder
224- fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: queued\nworkspace: %s\ntask: %s\n", spec.ID, spec.WorkspaceMode, truncateTask(task, 200))
271+ fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
225272 if spec.Profile != "" {
226273 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
227274 }
@@ -230,9 +277,25 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
230277 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
231278 }
232279 if spec.Required {
233- sb.WriteString("required: pending\n")
280+ fmt.Fprintf(&sb, "required: %s\n", state)
281+ }
282+ if a.Wait != nil {
283+ if waitTimedOut {
284+ fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
285+ sb.WriteString("\nThe sub-agent is still running in the background. Completion is host-event-driven through [auto-subagents update].")
286+ } else if completion.Err != nil {
287+ fmt.Fprintf(&sb, "error: %s\n", completion.Err)
288+ if completion.Summary != "" {
289+ fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
290+ }
291+ } else {
292+ if completion.Summary != "" {
293+ fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
294+ }
295+ }
296+ } else {
297+ sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].")
234298 }
235- sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].")
236299 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
237300 }
238301
@@ -7,10 +7,12 @@ import (
7 "fmt"7 "fmt"
8 "io"8 "io"
9 "strings"9 "strings"
10+ "time"
10 11
11 "github.com/bnema/zut/packages/agent/subagents"12 "github.com/bnema/zut/packages/agent/subagents"
12 "github.com/bnema/zut/packages/core"13 "github.com/bnema/zut/packages/core"
13 "github.com/bnema/zut/packages/provider"14 "github.com/bnema/zut/packages/provider"
15+ "github.com/google/uuid"
14 )16 )
15 17
16 // SubagentSpawnTool lets the main agent delegate work through the resident18 // SubagentSpawnTool lets the main agent delegate work through the resident
@@ -25,6 +27,8 @@ const (
25 SubagentStatusToolName = "subagent_status"27 SubagentStatusToolName = "subagent_status"
26 SubagentStopToolName = "subagent_stop"28 SubagentStopToolName = "subagent_stop"
27 SubagentResumeToolName = "subagent_resume"29 SubagentResumeToolName = "subagent_resume"
30+
31+ maxSubagentWaitSeconds = 5 * 60
28 )32 )
29 33
30 type SubagentSpawnTool struct {34 type SubagentSpawnTool struct {
@@ -69,6 +73,7 @@ type subagentSpawnArgs struct {
69 Reasoning string `json:"reasoning,omitempty"`73 Reasoning string `json:"reasoning,omitempty"`
70 FastMode *bool `json:"fast_mode,omitempty"`74 FastMode *bool `json:"fast_mode,omitempty"`
71 Required bool `json:"required,omitempty"`75 Required bool `json:"required,omitempty"`
76+ Wait *int `json:"wait,omitempty"`
72 Isolation string `json:"isolation,omitempty"`77 Isolation string `json:"isolation,omitempty"`
73 }78 }
74 79
@@ -104,6 +109,12 @@ const subagentSpawnSchemaTemplate = `{
104 "type": "boolean",109 "type": "boolean",
105 "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."110 "description": "Set true when the parent must receive this delegated result before it can finish. The worker remains asynchronous and reports through a host completion update. Failed, timed-out, or canceled work remains unmet until a successful retry or explicit user removal. An outcome unobserved across host restart requires explicit user reconciliation."
106 },111 },
112+ "wait": {
113+ "type": "integer",
114+ "minimum": 1,
115+ "maximum": 300,
116+ "description": "Optional explicit number of seconds to wait for this sub-agent's initial task to finish. Omit to return immediately. The sub-agent continues in the background if this wait expires."
117+ },
107 "isolation": {118 "isolation": {
108 "type": "string",119 "type": "string",
109 "enum": ["shared", "worktree"],120 "enum": ["shared", "worktree"],
@@ -115,7 +126,7 @@ const subagentSpawnSchemaTemplate = `{
115 126
116 func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }127 func (t *SubagentSpawnTool) Name() string { return SubagentSpawnToolName }
117 func (t *SubagentSpawnTool) Description() string {128 func (t *SubagentSpawnTool) Description() string {
118- return "Delegate work to a resident sub-agent. Every spawn returns immediately and completion is host-event-driven through [auto-subagents update]. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn. Legitimate waits inside user-requested commands, provider flows, extensions, or tests are allowed."129+ return "Delegate work to a resident sub-agent. Omit wait to return immediately and receive completion through [auto-subagents update]; set wait to an explicit 1–300 second value only when this turn should wait for the initial task. Set required=true when the outcome is mandatory before the parent's terminal response; failures remain recoverable through subagent_resume. Never use bash sleep, watch, tail -f, polling loops, repeated subagent_status, dashboard, metadata, or file checks solely to wait. Work on unrelated independent tasks or end/yield your turn."
119 }130 }
120 func (t *SubagentSpawnTool) Schema() json.RawMessage {131 func (t *SubagentSpawnTool) Schema() json.RawMessage {
121 return json.RawMessage(subagentSpawnSchemaTemplate)132 return json.RawMessage(subagentSpawnSchemaTemplate)
@@ -137,6 +148,9 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
137 if task == "" {148 if task == "" {
138 return protocolToolError(prefix + ": task is required")149 return protocolToolError(prefix + ": task is required")
139 }150 }
151+ if a.Wait != nil && (*a.Wait < 1 || *a.Wait > maxSubagentWaitSeconds) {
152+ return protocolToolError(fmt.Sprintf("%s: wait must be between 1 and %d seconds", prefix, maxSubagentWaitSeconds))
153+ }
140 154
141 workspaceMode := subagents.WorkspaceShared155 workspaceMode := subagents.WorkspaceShared
142 if value := strings.TrimSpace(a.Isolation); value != "" {156 if value := strings.TrimSpace(a.Isolation); value != "" {
@@ -217,11 +231,44 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
217 if err != nil {231 if err != nil {
218 return protocolToolError(prefix + ": " + err.Error())232 return protocolToolError(prefix + ": " + err.Error())
219 }233 }
220- if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {234+ var completion subagents.ResidentCompletion
235+ waitTimedOut := false
236+ if a.Wait != nil {
237+ spec.InitialTurnID = uuid.NewString()
238+ completionResult, cancelWait := t.ResidentManager.WatchCompletion(spec.ID, spec.InitialTurnID)
239+ defer cancelWait()
240+ if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
241+ return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
242+ }
243+ waitCtx, cancel := context.WithTimeout(ctx, time.Duration(*a.Wait)*time.Second)
244+ defer cancel()
245+ select {
246+ case result, ok := <-completionResult:
247+ if !ok {
248+ return core.ToolResult{}, fmt.Errorf("%s: completion wait ended unexpectedly", prefix)
249+ }
250+ completion = result
251+ case <-waitCtx.Done():
252+ if ctx.Err() != nil {
253+ return core.ToolResult{}, ctx.Err()
254+ }
255+ waitTimedOut = true
256+ }
257+ } else if _, err := t.ResidentManager.Spawn(ctx, spec, task); err != nil {
221 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)258 return core.ToolResult{}, fmt.Errorf("%s: %w", prefix, err)
222 }259 }
260+ state := "queued"
261+ if a.Wait != nil {
262+ if waitTimedOut {
263+ state = "running"
264+ } else if completion.Err != nil {
265+ state = "failed"
266+ } else {
267+ state = "completed"
268+ }
269+ }
223 var sb strings.Builder270 var sb strings.Builder
224- fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: queued\nworkspace: %s\ntask: %s\n", spec.ID, spec.WorkspaceMode, truncateTask(task, 200))271+ fmt.Fprintf(&sb, "spawned sub-agent %s\nstate: %s\nworkspace: %s\ntask: %s\n", spec.ID, state, spec.WorkspaceMode, truncateTask(task, 200))
225 if spec.Profile != "" {272 if spec.Profile != "" {
226 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)273 fmt.Fprintf(&sb, "agent: %s\n", spec.Profile)
227 }274 }
@@ -230,9 +277,25 @@ func (t *SubagentSpawnTool) Execute(ctx context.Context, raw json.RawMessage, _
230 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)277 fmt.Fprintf(&sb, "reasoning: %s\n", spec.Reasoning)
231 }278 }
232 if spec.Required {279 if spec.Required {
233- sb.WriteString("required: pending\n")280+ fmt.Fprintf(&sb, "required: %s\n", state)
281+ }
282+ if a.Wait != nil {
283+ if waitTimedOut {
284+ fmt.Fprintf(&sb, "wait: timed out after %d seconds\n", *a.Wait)
285+ sb.WriteString("\nThe sub-agent is still running in the background. Completion is host-event-driven through [auto-subagents update].")
286+ } else if completion.Err != nil {
287+ fmt.Fprintf(&sb, "error: %s\n", completion.Err)
288+ if completion.Summary != "" {
289+ fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
290+ }
291+ } else {
292+ if completion.Summary != "" {
293+ fmt.Fprintf(&sb, "final: %s\n", completion.Summary)
294+ }
295+ }
296+ } else {
297+ sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].")
234 }298 }
235- sb.WriteString("\nThe sub-agent is running in the background. Completion is host-event-driven through [auto-subagents update].")
236 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil299 return core.ToolResult{Content: []provider.Content{provider.TextBlock{Text: sb.String()}}}, nil
237 }300 }
238 301

Keyboard shortcuts

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

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

Branches 1

Tags 477

↑↓ moveenter openesc close