@yeaft/webchat-agent 0.1.952 → 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.
@@ -0,0 +1,81 @@
1
+ /**
2
+ * status.js — Centralized status vocabulary for the sub-agent subsystem.
3
+ *
4
+ * The same string set used to be sprinkled across tools/agent.js,
5
+ * tools/wait-agent.js, tools/send-message.js, tools/close-agent.js,
6
+ * tools/list-agents.js and sub-agent/runner.js. Every time we added a
7
+ * status (e.g. 'abandoned' for the idle watchdog) we had to chase 5+
8
+ * files and update string lists. This module is the single source of
9
+ * truth.
10
+ *
11
+ * Status lifecycle:
12
+ *
13
+ * created → running ↔ idle (after each turn ends without queued prompt)
14
+ * ↓ ↓
15
+ * failed completed
16
+ * ↓ ↓
17
+ * closed abandoned (idle too long; reaped by watchdog)
18
+ *
19
+ * - 'created' : registry record exists but the driver hasn't taken a
20
+ * step yet. Transient — flips to 'running' on first tick.
21
+ * - 'running' : the sub-engine is processing a prompt.
22
+ * - 'idle' : the previous turn ended cleanly and the queue is empty.
23
+ * The driver is parked in waitUntilResumed().
24
+ * - 'completed' : terminal success — typically set by tickAgent() when a
25
+ * budget cutoff is hit cleanly with a partial_output.
26
+ * - 'failed' : terminal — driver/adapter/stream raised; agent.error set.
27
+ * - 'closed' : terminal — CloseAgent called (or driver finally{} reaped
28
+ * a cleanly-finishing agent).
29
+ * - 'abandoned' : terminal — idle watchdog tripped (no prompt arrived in
30
+ * IDLE_ABANDON_MS). Distinct from 'closed' so the parent
31
+ * can tell "the parent forgot about me" from "the parent
32
+ * deliberately wrapped me up".
33
+ */
34
+
35
+ export const STATUS = Object.freeze({
36
+ CREATED: 'created',
37
+ RUNNING: 'running',
38
+ IDLE: 'idle',
39
+ COMPLETED: 'completed',
40
+ FAILED: 'failed',
41
+ CLOSED: 'closed',
42
+ ABANDONED: 'abandoned',
43
+ });
44
+
45
+ const TERMINAL = new Set([STATUS.COMPLETED, STATUS.FAILED, STATUS.CLOSED, STATUS.ABANDONED]);
46
+ const INTERACTIVE = new Set([STATUS.CREATED, STATUS.RUNNING, STATUS.IDLE]);
47
+
48
+ /** Is this status a permanent terminal state? */
49
+ export function isTerminalAgentStatus(status) {
50
+ return TERMINAL.has(status);
51
+ }
52
+
53
+ /** Is the agent still alive and interactable (not terminal)? */
54
+ export function isInteractiveAgentStatus(status) {
55
+ return INTERACTIVE.has(status);
56
+ }
57
+
58
+ /** Can PromptAgent successfully queue a prompt at this status? */
59
+ export function isPromptableAgentStatus(status) {
60
+ // Even 'created' is promptable — the mission is the implicit first prompt
61
+ // and a SendMessage racing the driver simply gets dequeued after the
62
+ // mission.
63
+ return status === STATUS.CREATED || status === STATUS.RUNNING || status === STATUS.IDLE;
64
+ }
65
+
66
+ /**
67
+ * Build the canonical human-facing label for a status. Used by UI nudges
68
+ * and `next_steps` strings so we don't drift between modules.
69
+ */
70
+ export function describeAgentStatus(status) {
71
+ switch (status) {
72
+ case STATUS.CREATED: return 'just spawned';
73
+ case STATUS.RUNNING: return 'running a turn';
74
+ case STATUS.IDLE: return 'idle (turn ended, queue empty)';
75
+ case STATUS.COMPLETED: return 'completed (terminal)';
76
+ case STATUS.FAILED: return 'failed (terminal)';
77
+ case STATUS.CLOSED: return 'closed (terminal)';
78
+ case STATUS.ABANDONED: return 'abandoned by idle watchdog (terminal)';
79
+ default: return String(status || 'unknown');
80
+ }
81
+ }
@@ -24,6 +24,8 @@ import { defineTool } from './types.js';
24
24
  import { randomUUID } from 'crypto';
25
25
  import { getPersona, listPersonaIds } from '../personas.js';
26
26
  import { startSubAgent } from '../sub-agent/runner.js';
27
+ import { STATUS, isTerminalAgentStatus } from '../sub-agent/status.js';
28
+ import { makeLiveness } from '../sub-agent/liveness.js';
27
29
 
28
30
  /** In-memory sub-agent registry. */
29
31
  const agents = new Map();
@@ -33,6 +35,44 @@ export function getAgentRegistry() {
33
35
  return agents;
34
36
  }
35
37
 
38
+ const MAIN_THREAD_ID = 'main';
39
+
40
+ function cleanString(value) {
41
+ return (typeof value === 'string' && value.trim()) ? value.trim() : null;
42
+ }
43
+
44
+ export function getCallerAgentScope(ctx = {}) {
45
+ const deps = ctx?.parentEngineDeps || {};
46
+ return {
47
+ sessionId: cleanString(deps.parentSessionId ?? ctx?.sessionId),
48
+ parentVpId: cleanString(deps.parentVpId ?? ctx?.senderVpId),
49
+ parentThreadId: cleanString(deps.parentThreadId ?? ctx?.threadId),
50
+ };
51
+ }
52
+
53
+ export function agentBelongsToCaller(agent, ctx = {}) {
54
+ if (!agent) return false;
55
+ const scope = getCallerAgentScope(ctx);
56
+ return agentBelongsToScope(agent, scope);
57
+ }
58
+
59
+ export function agentBelongsToScope(agent, scope = {}) {
60
+ if (!agent) return false;
61
+ const agentSessionId = cleanString(agent.parentSessionId);
62
+ const scopeSessionId = cleanString(scope.sessionId);
63
+ const agentVpId = cleanString(agent.parentVpId);
64
+ const scopeVpId = cleanString(scope.parentVpId);
65
+ const agentThreadId = cleanString(agent.parentThreadId);
66
+ const scopeThreadId = cleanString(scope.parentThreadId);
67
+ if (!scopeSessionId && !scopeVpId && !scopeThreadId) {
68
+ return !agentSessionId && !agentVpId;
69
+ }
70
+ if ((agentSessionId || scopeSessionId) && agentSessionId !== scopeSessionId) return false;
71
+ if ((agentVpId || scopeVpId) && agentVpId !== scopeVpId) return false;
72
+ if (!agentThreadId && !scopeThreadId) return true;
73
+ return (agentThreadId || MAIN_THREAD_ID) === (scopeThreadId || MAIN_THREAD_ID);
74
+ }
75
+
36
76
  /** Reset registry (for tests). */
37
77
  export function _resetAgentRegistry() {
38
78
  agents.clear();
@@ -138,7 +178,7 @@ export function budgetExceededResult(agent, reason) {
138
178
  export function tickAgent(agentId, delta = {}, now = Date.now()) {
139
179
  const agent = agents.get(agentId);
140
180
  if (!agent) return null;
141
- if (agent.status === 'completed' || agent.status === 'closed') return null;
181
+ if (isTerminalAgentStatus(agent.status)) return null;
142
182
 
143
183
  if (typeof delta.tokens === 'number' && delta.tokens > 0) {
144
184
  agent.usage.tokens += delta.tokens;
@@ -155,7 +195,7 @@ export function tickAgent(agentId, delta = {}, now = Date.now()) {
155
195
 
156
196
  const envelope = budgetExceededResult(agent, check.reason);
157
197
  agent.result = envelope;
158
- agent.status = 'completed';
198
+ agent.status = STATUS.COMPLETED;
159
199
  agent.diagnostics.push({
160
200
  type: 'budget_exceeded',
161
201
  limit: check.limit,
@@ -262,10 +302,15 @@ you just kicked off.`,
262
302
  }
263
303
  const spec = validation.spec;
264
304
  const { name, cwd } = input;
305
+ const callerScope = getCallerAgentScope(ctx);
265
306
 
266
- // Check for name collision
307
+ // Check for name collision — any non-terminal agent with the same
308
+ // name blocks the new spawn. Terminal agents (closed/failed/abandoned/
309
+ // completed) free the name up for reuse. Scope this to the caller's
310
+ // Session/VP/thread so independent sessions can reuse natural names.
267
311
  for (const [, agent] of agents) {
268
- if (agent.name === name && agent.status !== 'closed') {
312
+ if (agent.name === name && !isTerminalAgentStatus(agent.status)
313
+ && agentBelongsToScope(agent, callerScope)) {
269
314
  return JSON.stringify({
270
315
  next_steps: ERROR_NEXT_STEPS,
271
316
  error: `Agent "${name}" already exists. Close it first or use a different name.`,
@@ -287,28 +332,42 @@ you just kicked off.`,
287
332
  personaData: persona || null,
288
333
  budget: spec.budget,
289
334
  cwd: cwd || ctx?.cwd || process.cwd(),
290
- status: 'created',
335
+ status: STATUS.CREATED,
291
336
  messages: [],
292
337
  result: null,
338
+ lastResult: '',
293
339
  partial_output: '',
294
340
  diagnostics: [],
295
341
  usage: { tokens: 0, turns: 0, startedAt: now },
296
342
  createdAt: now,
297
343
  trace: [],
344
+ // Liveness counters — kept fresh by the runner from each sub-engine
345
+ // event so WaitAgent/ListAgents can show real-time progress.
346
+ liveness: makeLiveness(),
347
+ // Path to the durable JSONL event log. Populated by the runner
348
+ // when startSubAgent attaches createOutputLog(); we initialize to
349
+ // null so the field always exists on the record shape.
350
+ outputFile: null,
351
+ // ParentVpId is mirrored from deps when the driver starts so the
352
+ // notification queue can bucket by parent Session/VP/thread.
353
+ parentVpId: callerScope.parentVpId,
354
+ parentSessionId: callerScope.sessionId,
355
+ parentThreadId: callerScope.parentThreadId,
298
356
  abortController: new AbortController(),
299
357
  };
300
358
 
301
359
  agents.set(agentId, agent);
302
360
 
303
- // PR-M1: actually spawn the sub-agent driver. This is fire-and-forget;
304
- // it returns immediately. The parent observes via WaitAgent (poll) or
305
- // via the engine's sub-agent event sink (live UI streaming).
361
+ // Actually spawn the sub-agent driver. This is fire-and-forget;
362
+ // it returns immediately. The parent observes via WaitAgent (which
363
+ // surfaces status + liveness + outputFile) or via the engine's
364
+ // sub-agent event sink (live UI streaming).
306
365
  const deps = ctx?.parentEngineDeps;
307
366
  if (deps && deps.adapter) {
308
367
  try {
309
368
  startSubAgent(agent, deps);
310
369
  } catch (err) {
311
- agent.status = 'failed';
370
+ agent.status = STATUS.FAILED;
312
371
  agent.error = err && err.message ? err.message : String(err);
313
372
  agent.diagnostics.push({ type: 'spawn_error', error: agent.error, at: Date.now() });
314
373
  return JSON.stringify({
@@ -333,7 +392,8 @@ you just kicked off.`,
333
392
  persona: spec.persona || null,
334
393
  budget: spec.budget || null,
335
394
  status: agent.status,
336
- message: `Sub-agent "${name}" spawned (${agentId}). Use WaitAgent to collect its first turn output, PromptAgent to give it more work, CloseAgent to finish.`,
395
+ outputFile: agent.outputFile || null,
396
+ message: `Sub-agent "${name}" spawned (${agentId}). Use WaitAgent to collect its first turn output, PromptAgent to give it more work, CloseAgent to finish. Read \`outputFile\` for the durable event log at any time.`,
337
397
  });
338
398
  },
339
399
  });
@@ -1,9 +1,17 @@
1
1
  /**
2
2
  * close-agent.js — Close a sub-agent and clean up.
3
+ *
4
+ * Marks status='closed' (terminal), aborts the abort controller so any
5
+ * in-flight engine.query stops promptly, and drains any pending re-entry
6
+ * notification for the agent so the engine doesn't redeliver it on the
7
+ * next user turn.
3
8
  */
4
9
 
5
10
  import { defineTool } from './types.js';
6
- import { getAgentRegistry } from './agent.js';
11
+ import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
12
+ import { isTerminalAgentStatus, STATUS } from '../sub-agent/status.js';
13
+ import { consumeNotificationForAgent, enqueueTerminalNotification } from '../sub-agent/notifications.js';
14
+ import { snapshotLiveness } from '../sub-agent/liveness.js';
7
15
 
8
16
  export default defineTool({
9
17
  name: 'CloseAgent',
@@ -33,8 +41,6 @@ Do NOT end your turn silently right after CloseAgent.`,
33
41
  isConcurrencySafe: () => false,
34
42
  isReadOnly: () => false,
35
43
  async execute(input, ctx) {
36
- // NB: next_steps is the FIRST envelope field — the registry's 1 KiB
37
- // tail-truncation would eat it if it lived at the end.
38
44
  const ERROR_NEXT_STEPS =
39
45
  'That call failed — see `error`. Either correct the arguments and ' +
40
46
  'retry, or tell the user what went wrong. Do NOT end your turn ' +
@@ -49,21 +55,58 @@ Do NOT end your turn silently right after CloseAgent.`,
49
55
  if (!agent) {
50
56
  return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
51
57
  }
58
+ if (!agentBelongsToCaller(agent, ctx)) {
59
+ return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
60
+ }
52
61
 
53
62
  if (result) {
54
63
  agent.result = result;
55
64
  }
56
65
 
57
- // PR-M1: abort any in-flight engine.query so the driver loop exits
58
- // promptly. This is cooperative — if the driver is mid-stream the
59
- // adapter receives the signal; if it's idle, status flip ends the
60
- // wait loop on the next 50ms tick.
66
+ // Abort any in-flight engine.query so the driver loop exits promptly.
67
+ // Cooperative — if the driver is mid-stream the adapter receives the
68
+ // signal; if it's idle, status flip ends the wait loop on the next tick.
61
69
  if (agent.abortController && !agent.abortController.signal.aborted) {
62
70
  try { agent.abortController.abort('closed'); } catch { /* ignore */ }
63
71
  }
64
72
 
65
- const finalResult = agent.result || agent.lastResult || '';
66
- agent.status = 'closed';
73
+ const finalResult = (typeof agent.result === 'string' && agent.result)
74
+ ? agent.result
75
+ : (agent.lastResult || '');
76
+
77
+ // If the agent had already gone terminal (e.g. failed) before we got
78
+ // here, preserve that status; otherwise mark closed. Either way drain
79
+ // any pending re-entry notification — the parent is explicitly
80
+ // wrapping up so it doesn't need another nudge.
81
+ const wasTerminal = isTerminalAgentStatus(agent.status);
82
+ if (!wasTerminal) {
83
+ agent.status = STATUS.CLOSED;
84
+ // The driver may not yet have observed the abort / status flip; push
85
+ // a notification so the queue stays consistent (idempotent inside
86
+ // the notifications module). The driver's own finalizeTerminal()
87
+ // would also try to enqueue but the __terminalNotified guard makes
88
+ // that a no-op.
89
+ try {
90
+ enqueueTerminalNotification({
91
+ agentId: agent.id,
92
+ agentName: agent.name,
93
+ status: STATUS.CLOSED,
94
+ result: finalResult,
95
+ error: agent.error || null,
96
+ outputFile: agent.outputFile || null,
97
+ turns: agent.usage?.turns || 0,
98
+ parentVpId: agent.parentVpId || null,
99
+ parentSessionId: agent.parentSessionId || null,
100
+ parentThreadId: agent.parentThreadId || 'main',
101
+ });
102
+ } catch { /* never block close on notification queue */ }
103
+ agent.__terminalNotified = true;
104
+ }
105
+
106
+ // The parent is acknowledging the agent right now via this tool
107
+ // call; drop the queued notification so the engine doesn't
108
+ // double-deliver on its next user turn.
109
+ consumeNotificationForAgent(agent.id);
67
110
 
68
111
  return JSON.stringify({
69
112
  next_steps:
@@ -73,7 +116,10 @@ Do NOT end your turn silently right after CloseAgent.`,
73
116
  success: true,
74
117
  agentId: agent_id,
75
118
  name: agent.name,
119
+ status: agent.status,
76
120
  result: finalResult,
121
+ outputFile: agent.outputFile || null,
122
+ liveness: snapshotLiveness(agent.liveness),
77
123
  messages: agent.messages.length,
78
124
  turns: agent.usage?.turns || 0,
79
125
  message: `Agent "${agent.name}" closed`,
@@ -1,41 +1,68 @@
1
1
  /**
2
- * list-agents.js — List all active sub-agents.
2
+ * list-agents.js — List all active (and optionally terminal) sub-agents.
3
+ *
4
+ * Returns: { agents: [{ id, name, status, task, outputFile, liveness,
5
+ * lastEventAt, msSinceLastEvent, error, hasResult, createdAt }, …] }.
6
+ *
7
+ * The default filter drops `closed` agents to stay tidy; pass
8
+ * include_closed=true (or include_terminal=true) to see them all. The
9
+ * include_closed alias is kept for backward-compat with the old shape.
3
10
  */
4
11
 
5
12
  import { defineTool } from './types.js';
6
- import { getAgentRegistry } from './agent.js';
13
+ import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
14
+ import { isTerminalAgentStatus } from '../sub-agent/status.js';
15
+ import { snapshotLiveness } from '../sub-agent/liveness.js';
7
16
 
8
17
  export default defineTool({
9
18
  name: 'ListAgents',
10
19
  description: `List all sub-agents and their current status.
11
20
 
12
- Shows agent IDs, names, tasks, status (created/active/completed/closed),
13
- and message counts. Use to monitor parallel task progress.`,
21
+ Returns id, name, status, mission/task summary, durable outputFile path,
22
+ liveness counters (toolUseCount, tokenCount, msSinceLastEvent, recentTools)
23
+ and message count for each agent. Use to monitor parallel work in flight,
24
+ and Read \`outputFile\` for any single agent if you need its full timeline.
25
+
26
+ By default only non-closed agents are returned. Pass include_closed=true
27
+ to also list closed/failed/abandoned/completed agents.`,
14
28
  parameters: {
15
29
  type: 'object',
16
30
  properties: {
17
31
  include_closed: {
18
32
  type: 'boolean',
19
- description: 'Include closed agents in the list (default: false)',
33
+ description: 'Include closed/failed/abandoned/completed agents in the list (default: false)',
34
+ },
35
+ include_terminal: {
36
+ type: 'boolean',
37
+ description: 'Alias for include_closed — include all terminal-status agents in the list',
20
38
  },
21
39
  },
22
40
  },
23
41
  isConcurrencySafe: () => true,
24
42
  isReadOnly: () => true,
25
43
  async execute(input, ctx) {
26
- const { include_closed = false } = input;
44
+ const includeTerminal = Boolean(input?.include_closed || input?.include_terminal);
27
45
  const agents = getAgentRegistry();
46
+ const now = Date.now();
28
47
 
29
48
  const agentList = [];
30
49
  for (const [id, agent] of agents) {
31
- if (!include_closed && agent.status === 'closed') continue;
50
+ if (!agentBelongsToCaller(agent, ctx)) continue;
51
+ if (!includeTerminal && isTerminalAgentStatus(agent.status)) continue;
52
+ const liveness = snapshotLiveness(agent.liveness, now);
32
53
  agentList.push({
33
54
  id,
34
55
  name: agent.name,
35
56
  status: agent.status,
36
- task: agent.task?.slice(0, 200),
37
- messages: agent.messages.length,
38
- hasResult: !!agent.result,
57
+ task: typeof agent.task === 'string' ? agent.task.slice(0, 200) : null,
58
+ outputFile: agent.outputFile || null,
59
+ liveness,
60
+ lastEventAt: liveness.lastEventAt,
61
+ msSinceLastEvent: liveness.msSinceLastEvent,
62
+ error: agent.error || null,
63
+ hasResult: Boolean(agent.result || agent.lastResult),
64
+ messages: Array.isArray(agent.messages) ? agent.messages.length : 0,
65
+ turns: agent.usage?.turns || 0,
39
66
  createdAt: agent.createdAt,
40
67
  });
41
68
  }
@@ -43,7 +70,9 @@ and message counts. Use to monitor parallel task progress.`,
43
70
  if (agentList.length === 0) {
44
71
  return JSON.stringify({
45
72
  agents: [],
46
- message: 'No active sub-agents',
73
+ message: includeTerminal
74
+ ? 'No sub-agents in the registry'
75
+ : 'No active sub-agents (pass include_closed=true to see terminal ones)',
47
76
  });
48
77
  }
49
78
 
@@ -6,7 +6,8 @@
6
6
  */
7
7
 
8
8
  import { defineTool } from './types.js';
9
- import { getAgentRegistry } from './agent.js';
9
+ import { agentBelongsToCaller, getAgentRegistry } from './agent.js';
10
+ import { isTerminalAgentStatus, isPromptableAgentStatus, STATUS, describeAgentStatus } from '../sub-agent/status.js';
10
11
 
11
12
  export default defineTool({
12
13
  name: 'PromptAgent',
@@ -21,7 +22,10 @@ returns you almost always want to call WaitAgent next to collect the reply.
21
22
  Do NOT end your turn after PromptAgent without either (a) calling WaitAgent,
22
23
  (b) explaining to the user what you just asked the sub-agent, or (c) calling
23
24
  CloseAgent. The orchestration loop is
24
- SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user.`,
25
+ SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to user.
26
+
27
+ PromptAgent is rejected if the sub-agent is in a terminal state
28
+ (completed/failed/closed/abandoned). Use SpawnAgent to start a fresh one.`,
25
29
  parameters: {
26
30
  type: 'object',
27
31
  properties: {
@@ -56,17 +60,38 @@ SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to us
56
60
  if (!agent) {
57
61
  return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
58
62
  }
63
+ if (!agentBelongsToCaller(agent, ctx)) {
64
+ return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent not found: ${agent_id}` });
65
+ }
59
66
 
60
- if (agent.status === 'closed') {
61
- return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent "${agent.name}" is closed` });
67
+ if (isTerminalAgentStatus(agent.status)) {
68
+ // Build a specific error message per status. Telling the model
69
+ // "agent is closed" vs "agent failed: <error>" vs "agent was
70
+ // abandoned (idle too long)" gives it a clear next action.
71
+ const desc = describeAgentStatus(agent.status);
72
+ const detail = agent.status === STATUS.FAILED && agent.error
73
+ ? `: ${agent.error}`
74
+ : '';
75
+ return JSON.stringify({
76
+ next_steps: ERROR_NEXT_STEPS,
77
+ error: `Agent "${agent.name}" is ${desc}${detail}. Spawn a new agent if you need more work.`,
78
+ agentId: agent_id,
79
+ status: agent.status,
80
+ });
62
81
  }
63
- if (agent.status === 'failed') {
64
- return JSON.stringify({ next_steps: ERROR_NEXT_STEPS, error: `Agent "${agent.name}" has failed: ${agent.error || 'unknown error'}` });
82
+
83
+ if (!isPromptableAgentStatus(agent.status)) {
84
+ // Defensive — covers any future status that isn't terminal but
85
+ // also isn't ready for prompts.
86
+ return JSON.stringify({
87
+ next_steps: ERROR_NEXT_STEPS,
88
+ error: `Agent "${agent.name}" is in status "${agent.status}", which does not accept new prompts.`,
89
+ agentId: agent_id,
90
+ });
65
91
  }
66
92
 
67
- // PR-M1: queue as a pending prompt the driver will pull. This wakes
68
- // the driver out of its idle wait and starts a new turn. The 'active'
69
- // status alias kept for backward-compat with code that polls for it.
93
+ // Queue as a pending prompt the driver will pull. This wakes the
94
+ // driver out of its idle wait and starts a new turn.
70
95
  if (!Array.isArray(agent.pendingPrompts)) agent.pendingPrompts = [];
71
96
  agent.pendingPrompts.push(message);
72
97
  agent.messages.push({
@@ -74,8 +99,8 @@ SpawnAgent → (PromptAgent ↔ WaitAgent)+ → CloseAgent → final reply to us
74
99
  content: message,
75
100
  timestamp: Date.now(),
76
101
  });
77
- if (agent.status === 'idle' || agent.status === 'created') {
78
- agent.status = 'running';
102
+ if (agent.status === STATUS.IDLE || agent.status === STATUS.CREATED) {
103
+ agent.status = STATUS.RUNNING;
79
104
  }
80
105
 
81
106
  return JSON.stringify({