@yeaft/webchat-agent 0.1.951 → 0.1.953

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,32 +3,59 @@
3
3
  *
4
4
  * Lifecycle (per agent record in the global registry from `tools/agent.js`):
5
5
  *
6
- * created → running idle (mission turn finished, awaiting parent feedback)
7
- *
8
- * failed running again (on SendMessage)
9
- * ↘ ↘
10
- * closed completed (terminal — set by CloseAgent or last end_turn)
6
+ * created → running idle (mission turn finished, awaiting parent feedback)
7
+ * ↘
8
+ * completed (terminal — budget cutoff with partial output)
9
+ * → failed (terminal — adapter/stream error)
10
+ *closed (terminal — CloseAgent or clean finally{} drain)
11
+ * → abandoned (terminal — idle watchdog tripped)
11
12
  *
12
13
  * Each sub-agent owns:
13
14
  * - its own Engine instance (shares parent adapter/trace/config/stores)
14
- * - its own ToolRegistry: parent's minus [Agent, SendMessage, WaitAgent,
15
- * CloseAgent, ListAgents, RouteForward, AskUser]
15
+ * - its own ToolRegistry: parent's minus the orchestration tools
16
16
  * - its own messages buffer (`agent.engineMessages`) so a turn can resume
17
- * after SendMessage
17
+ * after PromptAgent
18
+ * - a durable output log at ~/.yeaft/sub-agents/<agentId>.log mirroring
19
+ * every onEvent (see output-log.js)
20
+ * - a liveness snapshot (toolUseCount, tokenCount, lastEventAt, …) the
21
+ * parent reads through WaitAgent / ListAgents
18
22
  *
19
23
  * The runner is fire-and-forget: `startSubAgent(agent, deps)` schedules a
20
24
  * microtask that drives the loop and returns immediately. Parents observe
21
- * via `WaitAgent` (polls status) or via `deps.onEvent(agentId, evt)`
22
- * which is invoked for every sub-engine event for live UI streaming.
25
+ * via `WaitAgent` (which now returns a structured envelope with status,
26
+ * liveness, mid-stream preview, outputFile path) or via `deps.onEvent` for
27
+ * live UI streaming.
23
28
  *
24
- * Errors are caught; the agent is marked `failed` with `error` set, and
25
- * resolved through `WaitAgent` per the option-A protocol (parent decides
26
- * how to react).
29
+ * Terminal transitions ALWAYS:
30
+ * 1. enqueue a sub-agent notification (see notifications.js) so the
31
+ * parent engine surfaces it on the next user turn even if the
32
+ * parent forgot to call WaitAgent;
33
+ * 2. close the output log file;
34
+ * 3. emit a `sub_agent_status` event with the terminal status;
35
+ * 4. release the subEngine reference for GC.
27
36
  */
28
37
 
29
38
  import { Engine } from '../engine.js';
30
39
  import { ToolRegistry } from '../tools/registry.js';
31
40
  import { buildSpawnedPreamble } from './spawned-prompt.js';
41
+ import { STATUS, isTerminalAgentStatus } from './status.js';
42
+ import { createOutputLog } from './output-log.js';
43
+ import { makeLiveness, bumpLivenessFromEvent } from './liveness.js';
44
+ import { enqueueTerminalNotification } from './notifications.js';
45
+ // NOTE: tickAgent lives in `../tools/agent.js`, which itself imports this
46
+ // module (startSubAgent). To avoid the ES-module circular-import gotcha
47
+ // where one side sees an undefined export at module-init time, we import
48
+ // tickAgent dynamically inside the driver at first use. The cost is one
49
+ // `await import(...)` per sub-agent lifetime — negligible — and the
50
+ // benefit is that either module can be loaded first without ordering
51
+ // hazards.
52
+ let _tickAgent = null;
53
+ async function loadTickAgent() {
54
+ if (_tickAgent) return _tickAgent;
55
+ const mod = await import('../tools/agent.js');
56
+ _tickAgent = mod.tickAgent;
57
+ return _tickAgent;
58
+ }
32
59
 
33
60
  const RESTRICTED_TOOLS = new Set([
34
61
  'SpawnAgent',
@@ -42,6 +69,12 @@ const RESTRICTED_TOOLS = new Set([
42
69
  'AskUser',
43
70
  ]);
44
71
 
72
+ /** How long an idle sub-agent may wait for a follow-up before the watchdog reaps it. */
73
+ const IDLE_ABANDON_MS = 5 * 60 * 1000; // 5 minutes
74
+
75
+ /** Cap on agent.lastResult (mid-stream preview) — keeps memory bounded. */
76
+ const LAST_RESULT_MAX_CHARS = 8 * 1024;
77
+
45
78
  /**
46
79
  * Build a child ToolRegistry by copying every tool from the parent
47
80
  * registry except those in RESTRICTED_TOOLS.
@@ -71,7 +104,8 @@ export function isRestrictedToolName(name) {
71
104
 
72
105
  /**
73
106
  * Fire-and-forget: kick off the sub-agent loop. Mutates `agent` in place
74
- * (status, result, error, engineMessages, abortController).
107
+ * (status, result, error, engineMessages, abortController, outputLog,
108
+ * liveness).
75
109
  *
76
110
  * @param {object} agent — record from getAgentRegistry()
77
111
  * @param {{
@@ -87,10 +121,14 @@ export function isRestrictedToolName(name) {
87
121
  * yeaftDir?: string,
88
122
  * parentName?: string,
89
123
  * parentVpId?: string,
124
+ * parentSessionId?: string|null,
125
+ * parentThreadId?: string|null,
90
126
  * parentVpPersona?: object,
91
127
  * toolStats?: object,
92
128
  * onEvent?: (agentId: string, evt: object) => void,
93
129
  * language?: 'en'|'zh',
130
+ * subAgentLogDir?: string,
131
+ * idleAbandonMs?: number,
94
132
  * }} deps
95
133
  */
96
134
  export function startSubAgent(agent, deps = {}) {
@@ -115,21 +153,20 @@ export function startSubAgent(agent, deps = {}) {
115
153
  skillManager: deps.skillManager || null,
116
154
  mcpManager: deps.mcpManager || null,
117
155
  yeaftDir: deps.yeaftDir || null,
118
- // Share the session-shared ToolUsageStats so sub-agent tool calls
119
- // land in the same on-disk snapshot the parent records into. Sub-
120
- // agents are often the heaviest tool users — leaving them out
121
- // skewed `yeaft_fetch_tool_stats` output.
122
156
  toolStats: deps.toolStats || null,
123
157
  });
124
158
 
125
159
  agent.subEngine = subEngine;
126
160
  agent.engineMessages = agent.engineMessages || [];
161
+ agent.liveness = agent.liveness || makeLiveness();
162
+ agent.parentVpId = deps.parentVpId || null;
163
+ agent.parentSessionId = deps.parentSessionId || null;
164
+ agent.parentThreadId = deps.parentThreadId || 'main';
165
+ agent.outputLog = createOutputLog(agent.id, deps.subAgentLogDir);
166
+ agent.outputFile = agent.outputLog.path;
167
+ agent.outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '' });
127
168
 
128
- // Compose the system-prompt-overlay we want injected. We piggyback on
129
- // the existing `vpPersona` parameter that #buildSystemPrompt threads
130
- // through to buildWorkerPrompt — appending our spawned-preamble at the
131
- // end of the persona block guarantees it lands inside Layer A and is
132
- // subject to the same persona caching guarantees.
169
+ // Compose the system-prompt-overlay we want injected.
133
170
  const preamble = buildSpawnedPreamble({
134
171
  parentName: deps.parentName || 'parent',
135
172
  parentVpId: deps.parentVpId || null,
@@ -142,16 +179,10 @@ export function startSubAgent(agent, deps = {}) {
142
179
  deps.parentVpPersona && typeof deps.parentVpPersona === 'object'
143
180
  ? { ...deps.parentVpPersona }
144
181
  : {};
145
- // Append the preamble onto whatever the parent persona body looked
146
- // like. If the parent had no persona, we still hand the LLM a clean
147
- // sub-agent identity block so it knows the scope.
148
182
  baseVpPersona.persona =
149
183
  [(baseVpPersona.persona || '').trim(), preamble.trim()]
150
184
  .filter(Boolean)
151
185
  .join('\n\n');
152
- // renderVpPersona requires a displayName to emit the persona block.
153
- // If the parent did not provide one, synthesize one from agent name
154
- // so the spawned-preamble actually surfaces in the system prompt.
155
186
  if (!baseVpPersona.displayName || !String(baseVpPersona.displayName).trim()) {
156
187
  baseVpPersona.displayName = `${deps.parentName || 'Parent'}/${agent.name || 'sub-agent'}`;
157
188
  }
@@ -164,15 +195,18 @@ export function startSubAgent(agent, deps = {}) {
164
195
  agent.subVpPersona = baseVpPersona;
165
196
 
166
197
  // Background driver — pumps queued user messages through engine.query
167
- // turn by turn until status flips to closed/completed/failed.
198
+ // turn by turn until the agent reaches a terminal state.
168
199
  driveSubAgent(agent, subEngine, baseVpPersona, deps).catch((err) => {
169
- if (agent.status === 'closed' || agent.status === 'completed') return;
170
- agent.status = 'failed';
171
- agent.error = err && err.message ? err.message : String(err);
172
- agent.diagnostics.push({ type: 'driver_error', error: agent.error, at: Date.now() });
173
- if (typeof deps.onEvent === 'function') {
174
- try { deps.onEvent(agent.id, { type: 'sub_agent_status', agentId: agent.id, status: 'failed', error: agent.error }); } catch { /* ignore */ }
175
- }
200
+ // The driver normally handles its own failures (stream try/catch +
201
+ // terminal transition). This .catch covers genuinely unexpected
202
+ // throws between turns (e.g. inside dequeueNextUserPrompt) so we
203
+ // never leave a zombie record without a terminal status.
204
+ if (isTerminalAgentStatus(agent.status)) return;
205
+ transitionTerminal(agent, STATUS.FAILED, {
206
+ error: err && err.message ? err.message : String(err),
207
+ diagnostic: 'driver_error',
208
+ deps,
209
+ });
176
210
  });
177
211
  }
178
212
 
@@ -181,152 +215,304 @@ export function startSubAgent(agent, deps = {}) {
181
215
  * 1. Pull the next pending user message from the queue (or, on first
182
216
  * turn, the mission itself).
183
217
  * 2. Run engine.query, forwarding every event to deps.onEvent (tagged
184
- * with agentId).
185
- * 3. Capture the final assistant text → agent.lastResult, mark idle.
186
- * 4. Wait for either a new SendMessage (status=='running' again) OR
187
- * CloseAgent (status=='closed') OR the mission to be marked
188
- * completed by parent.
218
+ * with agentId) AND mirroring to the output log AND updating
219
+ * liveness + lastResult.
220
+ * 3. Stash the final assistant text on agent.result, tickAgent for
221
+ * budget enforcement, mark idle.
222
+ * 4. Wait for either a new PromptAgent (status flips to running) OR
223
+ * CloseAgent (status=='closed') OR the idle watchdog firing
224
+ * (status=='abandoned').
189
225
  */
190
226
  async function driveSubAgent(agent, subEngine, vpPersona, deps) {
191
227
  const onEvent = typeof deps.onEvent === 'function' ? deps.onEvent : null;
192
- // Sub-agent events are forwarded with agentId/agentName stamped on top of
193
- // the raw engine event. (PR-4 parent-feature inheritance was removed
194
- // 2026-05-13 along with the rest of the Feature system.)
228
+ const idleAbandonMs = typeof deps.idleAbandonMs === 'number' && deps.idleAbandonMs > 0
229
+ ? deps.idleAbandonMs : IDLE_ABANDON_MS;
230
+
195
231
  const wrapEvt = (evt) => ({ ...evt, agentId: agent.id, agentName: agent.name });
196
232
 
197
- // Helper: append a user message and either start or resume.
233
+ const emit = (evt) => {
234
+ const wrapped = wrapEvt(evt);
235
+ try { agent.outputLog?.write(wrapped); } catch { /* ignore log failures */ }
236
+ if (onEvent) {
237
+ try { onEvent(agent.id, wrapped); } catch { /* ignore listener errors */ }
238
+ }
239
+ };
240
+
198
241
  const dequeueNextUserPrompt = () => {
199
242
  if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
200
243
  return agent.pendingPrompts.shift() || null;
201
244
  };
202
245
 
203
- // Seed: mission becomes the first user prompt.
204
- if (!agent.pendingPrompts) agent.pendingPrompts = [];
205
- if (agent.mission && !agent.__missionSeeded) {
206
- agent.pendingPrompts.push(agent.mission);
207
- agent.__missionSeeded = true;
208
- }
246
+ try {
247
+ // Seed: mission becomes the first user prompt.
248
+ if (!agent.pendingPrompts) agent.pendingPrompts = [];
249
+ if (agent.mission && !agent.__missionSeeded) {
250
+ agent.pendingPrompts.push(agent.mission);
251
+ agent.__missionSeeded = true;
252
+ }
209
253
 
210
- agent.status = 'running';
211
- if (onEvent) {
212
- try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'running' })); } catch { /* ignore */ }
213
- }
254
+ agent.status = STATUS.RUNNING;
255
+ emit({ type: 'sub_agent_status', status: STATUS.RUNNING });
214
256
 
215
- while (agent.status !== 'closed' && agent.status !== 'completed' && agent.status !== 'failed') {
216
- const prompt = dequeueNextUserPrompt();
217
- if (!prompt) {
218
- // Nothing to do — go idle and wait for SendMessage / CloseAgent.
219
- agent.status = 'idle';
220
- if (onEvent) {
221
- try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'idle' })); } catch { /* ignore */ }
222
- }
223
- await waitUntilResumed(agent);
224
- // Either we have a new prompt now (back to running) or status is closed.
225
- if (agent.status === 'closed') break;
226
- agent.status = 'running';
227
- continue;
228
- }
257
+ while (!isTerminalAgentStatus(agent.status)) {
258
+ const prompt = dequeueNextUserPrompt();
259
+ if (!prompt) {
260
+ // No queued work — go idle and wait for PromptAgent / CloseAgent /
261
+ // watchdog.
262
+ agent.status = STATUS.IDLE;
263
+ agent.idleSince = Date.now();
264
+ emit({ type: 'sub_agent_status', status: STATUS.IDLE });
229
265
 
230
- let assistantText = '';
231
- let endedNormally = false;
232
- let streamError = null;
233
- try {
234
- const stream = subEngine.query({
235
- prompt,
236
- messages: agent.engineMessages,
237
- signal: agent.abortController?.signal,
238
- scenario: 'chat',
239
- vpPersona,
240
- });
241
- for await (const evt of stream) {
242
- // Forward every sub-engine event to the parent observer with
243
- // the agent identity attached. Frontend renders these inside
244
- // the sub-agent's collapsed card.
245
- if (onEvent) {
246
- try { onEvent(agent.id, wrapEvt(evt)); } catch { /* ignore listener errors */ }
247
- }
248
- if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
249
- assistantText += evt.text;
266
+ const reason = await waitUntilResumed(agent, idleAbandonMs);
267
+ if (reason === 'abandoned') {
268
+ transitionTerminal(agent, STATUS.ABANDONED, {
269
+ error: `idle for more than ${idleAbandonMs}ms with no follow-up`,
270
+ diagnostic: 'idle_watchdog',
271
+ deps,
272
+ });
273
+ break;
250
274
  }
251
- if (evt && evt.type === 'error' && evt.error) {
252
- streamError = evt.error.message || String(evt.error);
253
- }
254
- if (evt && evt.type === 'stop') {
255
- if (evt.stopReason === 'end_turn' || evt.stopReason === 'stop_sequence') {
256
- endedNormally = true;
275
+ if (isTerminalAgentStatus(agent.status)) break;
276
+ agent.idleSince = null;
277
+ agent.status = STATUS.RUNNING;
278
+ emit({ type: 'sub_agent_status', status: STATUS.RUNNING });
279
+ continue;
280
+ }
281
+
282
+ let assistantText = '';
283
+ let endedNormally = false;
284
+ let streamError = null;
285
+ const turnTokenStart = agent.liveness?.tokenCount || 0;
286
+ let turnUsageTokens = 0;
287
+ try {
288
+ const stream = subEngine.query({
289
+ prompt,
290
+ messages: agent.engineMessages,
291
+ signal: agent.abortController?.signal,
292
+ scenario: 'chat',
293
+ vpPersona,
294
+ });
295
+ for await (const evt of stream) {
296
+ // Liveness — update first so even listener throws don't lose
297
+ // the bump.
298
+ bumpLivenessFromEvent(agent.liveness, evt);
299
+
300
+ // Mirror to log + UI sink.
301
+ if (agent.outputLog) {
302
+ try { agent.outputLog.write(wrapEvt(evt)); } catch { /* ignore */ }
303
+ }
304
+ if (onEvent) {
305
+ try { onEvent(agent.id, wrapEvt(evt)); } catch { /* ignore listener errors */ }
306
+ }
307
+
308
+ if (evt && evt.type === 'text_delta' && typeof evt.text === 'string') {
309
+ assistantText += evt.text;
310
+ // Mid-stream visibility: keep lastResult fresh so a parent
311
+ // calling WaitAgent during a long generation sees what the
312
+ // child is currently saying, not stale text from the prior
313
+ // turn.
314
+ agent.lastResult = capTail(assistantText, LAST_RESULT_MAX_CHARS);
315
+ }
316
+ if (evt && evt.type === 'usage') {
317
+ turnUsageTokens += (evt.inputTokens || 0) + (evt.outputTokens || 0);
318
+ }
319
+ if (evt && evt.type === 'error' && evt.error) {
320
+ streamError = evt.error.message || String(evt.error);
321
+ }
322
+ if (evt && evt.type === 'stop') {
323
+ if (evt.stopReason === 'end_turn' || evt.stopReason === 'stop_sequence') {
324
+ endedNormally = true;
325
+ }
257
326
  }
258
327
  }
328
+ } catch (err) {
329
+ transitionTerminal(agent, STATUS.FAILED, {
330
+ error: err && err.message ? err.message : String(err),
331
+ diagnostic: 'query_error',
332
+ deps,
333
+ });
334
+ return;
259
335
  }
260
- } catch (err) {
261
- agent.status = 'failed';
262
- agent.error = err && err.message ? err.message : String(err);
263
- agent.diagnostics.push({ type: 'query_error', error: agent.error, at: Date.now() });
264
- if (onEvent) {
265
- try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: agent.error })); } catch { /* ignore */ }
336
+
337
+ if (streamError) {
338
+ transitionTerminal(agent, STATUS.FAILED, {
339
+ error: streamError,
340
+ diagnostic: 'stream_error',
341
+ deps,
342
+ });
343
+ return;
266
344
  }
267
- return;
268
- }
269
345
 
270
- if (streamError) {
271
- // Engine surfaced an error event (e.g. adapter failure) instead of
272
- // throwing — treat the same as a thrown error.
273
- agent.status = 'failed';
274
- agent.error = streamError;
275
- agent.diagnostics.push({ type: 'stream_error', error: streamError, at: Date.now() });
276
- if (onEvent) {
277
- try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: streamError })); } catch { /* ignore */ }
346
+ if (isTerminalAgentStatus(agent.status)) {
347
+ return;
278
348
  }
279
- return;
280
- }
281
349
 
282
- // Persist the turn into the local message buffer so subsequent
283
- // SendMessage continuations see context.
284
- agent.engineMessages.push({ role: 'user', content: prompt });
285
- if (assistantText) {
286
- agent.engineMessages.push({ role: 'assistant', content: assistantText });
287
- }
288
- agent.lastResult = assistantText;
289
- agent.usage.turns = (agent.usage.turns || 0) + 1;
290
-
291
- if (!endedNormally) {
292
- // Adapter aborted/errored without end_turn — mark failed.
293
- agent.status = 'failed';
294
- agent.error = agent.error || 'sub-agent stream ended without end_turn';
295
- if (onEvent) {
296
- try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_status', status: 'failed', error: agent.error })); } catch { /* ignore */ }
350
+ // Persist the turn into the local message buffer so subsequent
351
+ // PromptAgent continuations see context.
352
+ agent.engineMessages.push({ role: 'user', content: prompt });
353
+ if (assistantText) {
354
+ agent.engineMessages.push({ role: 'assistant', content: assistantText });
297
355
  }
298
- return;
299
- }
356
+ agent.lastResult = capTail(assistantText, LAST_RESULT_MAX_CHARS);
357
+ agent.result = assistantText;
358
+ // NB: agent.usage.turns is incremented by tickAgent below — do NOT
359
+ // bump it here too or every turn would double-count and trip
360
+ // max_turns budgets at half the configured limit.
300
361
 
301
- // Turn complete. Stash the result for WaitAgent and emit a turn-end
302
- // event for the UI. Loop re-enters: if more pendingPrompts queued
303
- // by SendMessage, run the next; else go idle.
304
- agent.result = assistantText;
305
- if (onEvent) {
306
- try { onEvent(agent.id, wrapEvt({ type: 'sub_agent_turn_end', content: assistantText })); } catch { /* ignore */ }
362
+ if (!endedNormally) {
363
+ transitionTerminal(agent, STATUS.FAILED, {
364
+ error: agent.error || 'sub-agent stream ended without end_turn',
365
+ diagnostic: 'no_end_turn',
366
+ deps,
367
+ });
368
+ return;
369
+ }
370
+
371
+ // Budget enforcement: tickAgent will flip the agent to 'completed'
372
+ // with a budget_exceeded envelope if any explicit budget bound was
373
+ // tripped. The driver respects that and exits cleanly. We
374
+ // dynamically import to avoid the agent.js↔runner.js cycle.
375
+ let tickResult = null;
376
+ try {
377
+ const tickAgent = await loadTickAgent();
378
+ if (typeof tickAgent === 'function') {
379
+ const textTokenDelta = Math.max(0, (agent.liveness?.tokenCount || 0) - turnTokenStart);
380
+ const tokenDelta = turnUsageTokens > 0 ? turnUsageTokens : textTokenDelta;
381
+ tickResult = tickAgent(agent.id, {
382
+ turns: 1,
383
+ tokens: tokenDelta,
384
+ partial_output: assistantText,
385
+ });
386
+ }
387
+ } catch { /* budget enforcement is best-effort */ }
388
+ if (tickResult) {
389
+ // tickAgent already flipped status to 'completed' and aborted
390
+ // the signal. Still want a terminal-status event + notification.
391
+ finalizeTerminal(agent, STATUS.COMPLETED, { error: null, deps });
392
+ return;
393
+ }
394
+
395
+ // Turn complete cleanly. Stash the result for WaitAgent and emit
396
+ // a turn-end event for the UI. Loop re-enters: if more
397
+ // pendingPrompts queued by PromptAgent, run the next; else idle.
398
+ emit({ type: 'sub_agent_turn_end', content: assistantText });
307
399
  }
400
+ } finally {
401
+ // Always clean up driver-owned resources. We intentionally do NOT
402
+ // unset agent.result / agent.lastResult / agent.liveness / agent.
403
+ // outputFile — those are observable by the parent after termination.
404
+ try { agent.outputLog?.close(); } catch { /* ignore */ }
405
+ agent.subEngine = null;
406
+ agent.__driverStarted = false;
407
+ agent.idleSince = null;
408
+ }
409
+ }
410
+
411
+ /**
412
+ * Flip an agent to a terminal status, emit the matching status event,
413
+ * mirror to the log, and enqueue a re-entry notification for the
414
+ * parent. Idempotent — if status is already terminal we no-op.
415
+ *
416
+ * @param {object} agent
417
+ * @param {string} status
418
+ * @param {{ error?: string|null, diagnostic?: string, deps?: object }} opts
419
+ */
420
+ function transitionTerminal(agent, status, opts = {}) {
421
+ if (isTerminalAgentStatus(agent.status)) return;
422
+ agent.status = status;
423
+ if (opts.error) agent.error = opts.error;
424
+ agent.diagnostics = agent.diagnostics || [];
425
+ agent.diagnostics.push({ type: opts.diagnostic || `transition_${status}`, error: opts.error || null, at: Date.now() });
426
+ finalizeTerminal(agent, status, { error: opts.error || null, deps: opts.deps });
427
+ }
428
+
429
+ /**
430
+ * Emit the terminal status event, write it to the log, enqueue a
431
+ * notification for the parent. Split out from transitionTerminal so
432
+ * tickAgent's external status flip (it sets 'completed' itself) can
433
+ * still go through the same notification path.
434
+ */
435
+ function finalizeTerminal(agent, status, { error, deps } = {}) {
436
+ // Mark notified-once to avoid double notifications if both tickAgent
437
+ // and the driver loop converge on the same terminal transition.
438
+ if (agent.__terminalNotified) return;
439
+ agent.__terminalNotified = true;
440
+
441
+ const evt = {
442
+ type: 'sub_agent_status',
443
+ agentId: agent.id,
444
+ agentName: agent.name,
445
+ status,
446
+ error: error || agent.error || null,
447
+ };
448
+ try { agent.outputLog?.write(evt); } catch { /* ignore */ }
449
+ if (deps && typeof deps.onEvent === 'function') {
450
+ try { deps.onEvent(agent.id, evt); } catch { /* ignore */ }
308
451
  }
452
+
453
+ // Push the re-entry notification so the parent learns about this
454
+ // even if it forgot to call WaitAgent.
455
+ try {
456
+ const budgetResult = agent.result && typeof agent.result === 'object'
457
+ && agent.result.status === 'budget_exceeded'
458
+ ? agent.result
459
+ : null;
460
+ enqueueTerminalNotification({
461
+ agentId: agent.id,
462
+ agentName: agent.name,
463
+ status,
464
+ result: budgetResult
465
+ ? (budgetResult.partial_output || '')
466
+ : (typeof agent.result === 'string' ? agent.result : (agent.lastResult || '')),
467
+ error: error || agent.error || null,
468
+ outputFile: agent.outputFile || null,
469
+ turns: agent.usage?.turns || 0,
470
+ parentVpId: agent.parentVpId || null,
471
+ parentSessionId: agent.parentSessionId || null,
472
+ parentThreadId: agent.parentThreadId || 'main',
473
+ budgetExceeded: !!budgetResult,
474
+ budgetReason: budgetResult?.reason || null,
475
+ budgetUsage: budgetResult?.usage || null,
476
+ });
477
+ } catch { /* never let the notification queue throw kill the driver */ }
309
478
  }
310
479
 
311
480
  /**
312
- * Resume signal — resolves when:
313
- * - a new prompt was pushed onto agent.pendingPrompts (SendMessage), OR
314
- * - the agent was closed (CloseAgent / abort)
481
+ * Resume signal — resolves with a string reason:
482
+ * - 'prompt' : pendingPrompts non-empty (PromptAgent fired)
483
+ * - 'terminal' : agent status flipped to terminal externally
484
+ * - 'abandoned' : idle timer expired
315
485
  *
316
- * This is a tight poll because sub-agent I/O is interactive and there
317
- * are at most a handful of these alive in a session.
486
+ * @param {object} agent
487
+ * @param {number} idleAbandonMs
488
+ * @returns {Promise<'prompt'|'terminal'|'abandoned'>}
318
489
  */
319
- function waitUntilResumed(agent) {
490
+ function waitUntilResumed(agent, idleAbandonMs) {
320
491
  return new Promise((resolve) => {
492
+ const start = Date.now();
321
493
  const tick = () => {
322
- if (agent.status === 'closed' || agent.status === 'completed' || agent.status === 'failed') {
323
- return resolve();
494
+ if (isTerminalAgentStatus(agent.status)) {
495
+ return resolve('terminal');
324
496
  }
325
497
  if (Array.isArray(agent.pendingPrompts) && agent.pendingPrompts.length > 0) {
326
- return resolve();
498
+ return resolve('prompt');
499
+ }
500
+ if (idleAbandonMs > 0 && Date.now() - start >= idleAbandonMs) {
501
+ return resolve('abandoned');
327
502
  }
328
503
  setTimeout(tick, 50);
329
504
  };
330
505
  tick();
331
506
  });
332
507
  }
508
+
509
+ /**
510
+ * Cap a tailing string to N chars while keeping the most recent content.
511
+ * Used for agent.lastResult so a runaway model can't OOM the registry.
512
+ */
513
+ function capTail(text, maxChars) {
514
+ if (typeof text !== 'string' || text.length <= maxChars) return text;
515
+ return '…' + text.slice(text.length - maxChars);
516
+ }
517
+
518
+ export const _internals = { IDLE_ABANDON_MS, LAST_RESULT_MAX_CHARS };