mixdog 0.9.86 → 0.9.87

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.
Files changed (38) hide show
  1. package/package.json +2 -2
  2. package/src/runtime/agent/orchestrator/agent-trace-format.mjs +4 -2
  3. package/src/runtime/agent/orchestrator/providers/oauth-usage.mjs +125 -16
  4. package/src/runtime/agent/orchestrator/session/manager.mjs +3 -0
  5. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +9 -2
  6. package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +8 -0
  7. package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +4 -3
  8. package/src/runtime/agent/orchestrator/tools/token-manifest.json +23 -2
  9. package/src/runtime/memory/lib/memory-action-handlers.mjs +4 -1
  10. package/src/runtime/memory/tool-defs.mjs +19 -3
  11. package/src/session-runtime/lifecycle-api.mjs +15 -0
  12. package/src/session-runtime/provider-auth-api.mjs +8 -0
  13. package/src/session-runtime/provider-usage.mjs +30 -5
  14. package/src/session-runtime/runtime-core.mjs +267 -481
  15. package/src/session-runtime/session-lifecycle.mjs +378 -0
  16. package/src/standalone/agent-shard/shard-pool.mjs +6 -5
  17. package/src/standalone/agent-tool/job-views.mjs +329 -0
  18. package/src/standalone/agent-tool/spawn-flow.mjs +629 -0
  19. package/src/standalone/agent-tool/tag-registry.mjs +340 -0
  20. package/src/standalone/agent-tool.mjs +100 -1151
  21. package/src/standalone/usage-dashboard.mjs +23 -1
  22. package/src/tui/App.jsx +362 -2576
  23. package/src/tui/app/app-view.jsx +504 -0
  24. package/src/tui/app/create-app-pickers.mjs +313 -0
  25. package/src/tui/app/prompt-submit.mjs +501 -0
  26. package/src/tui/app/shell-layout.mjs +563 -0
  27. package/src/tui/app/usage-context-panels.mjs +288 -0
  28. package/src/tui/app/use-copy-selection.mjs +56 -0
  29. package/src/tui/app/use-global-key-input.mjs +151 -0
  30. package/src/tui/app/use-pasted-buffers.mjs +115 -0
  31. package/src/tui/app/use-prompt-draft-flow.mjs +167 -0
  32. package/src/tui/app/use-prompt-hint.mjs +58 -0
  33. package/src/tui/app/use-prompt-queue-history.mjs +100 -0
  34. package/src/tui/app/use-terminal-chrome.mjs +72 -0
  35. package/src/tui/app/use-transcript-activity.mjs +131 -0
  36. package/src/tui/app/use-welcome-prompt-hint.mjs +96 -0
  37. package/src/tui/dist/index.mjs +9802 -9024
  38. package/src/tui/engine/session-api-ext.mjs +23 -0
@@ -49,6 +49,9 @@ import {
49
49
  import { abnormalEmptyFinishError, renderResult } from './agent-tool/render.mjs';
50
50
  import { createProviderInit } from './agent-tool/provider-init.mjs';
51
51
  import { createNotify } from './agent-tool/notify.mjs';
52
+ import { createTagRegistry } from './agent-tool/tag-registry.mjs';
53
+ import { createJobViews } from './agent-tool/job-views.mjs';
54
+ import { createSpawnFlow } from './agent-tool/spawn-flow.mjs';
52
55
  import { resolveAgentSpawnPreset } from './agent-tool/spawn-preset.mjs';
53
56
  import {
54
57
  TAG_TOMBSTONE_TTL_MS,
@@ -116,1162 +119,108 @@ export function createStandaloneAgent({
116
119
  },
117
120
  };
118
121
  }
119
- const tags = new Map();
120
- const tagAgents = new Map();
121
- const tagCwds = new Map();
122
- const reapTimers = new Map();
123
- // Worker-index persistence (row store, parse cache, batched atomic writer,
124
- // tag projection) lives in agent-tool/worker-index.mjs; the tag maps are
125
- // shared by reference so both sides see the same live state.
126
- const {
127
- readAllTagTombstones,
128
- readTagTombstones,
129
- readWorkerRows,
130
- writeWorkerRows,
131
- flushWorkerIndexMutations,
132
- upsertWorkerSession,
133
- upsertWorkerSessionDeferred,
134
- removeWorkerRow,
135
- refreshTagsFromIndex,
136
- } = createWorkerIndex({ dataDir, cfgMod, mgr, tags, tagAgents, tagCwds });
137
-
138
- function wantsSessionScan(args = {}) {
139
- return args.recover === true || args.scanSessions === true || args.scan_sessions === true;
140
- }
141
-
142
- function resolveTag(target, context = {}, options = {}) {
143
- const scanSessions = options.scanSessions === true;
144
- const excludeTerminalTraces = options.excludeTerminalTraces === true;
145
- refreshTagsFromSessions({ scanSessions, context });
146
- const value = clean(target);
147
- if (!value) return null;
148
- if (value.startsWith('sess_')) {
149
- const session = getLiveSession(value);
150
- if (session && sessionMatchesContext(session, context)) return value;
151
- const row = readWorkerRows(context).find((item) => item.sessionId === value);
152
- return row ? value : null;
153
- }
154
- const matches = agentSessionEntries({ scanSessions, context, excludeTerminalTraces })
155
- .filter((entry) => entry.tag === value);
156
- if (matches.length === 1) return matches[0].session.id;
157
- if (matches.length > 1) {
158
- throw new Error(`agent: tag "${value}" is ambiguous across terminals; use sessionId`);
159
- }
160
- const sessionId = tags.get(value) || null;
161
- const session = getLiveSession(sessionId);
162
- return session && sessionMatchesContext(session, context) ? sessionId : null;
163
- }
164
-
165
- function getLiveSession(sessionId) {
166
- if (!sessionId) return null;
167
- const session = mgr.getSession(sessionId);
168
- return session && session.closed !== true ? session : null;
169
- }
170
-
171
- function tagForSession(sessionId) {
172
- const session = getLiveSession(sessionId);
173
- const persistedTag = agentTagOf(session);
174
- if (persistedTag) return persistedTag;
175
- for (const [tag, sid] of tags.entries()) {
176
- if (sid === sessionId) return tag;
177
- }
178
- return null;
179
- }
180
-
181
- function agentSessionEntries({ scanSessions = false, context = {}, excludeTerminalTraces = false } = {}) {
182
- const rows = [];
183
- const seen = new Set();
184
- const add = (session, fallbackTag = '') => {
185
- const tag = agentTagOf(session) || clean(fallbackTag);
186
- if (!tag || !session?.id || session.closed === true) return;
187
- if (!sessionMatchesContext(session, context)) return;
188
- if (seen.has(session.id)) return;
189
- seen.add(session.id);
190
- rows.push({ tag, session });
191
- };
192
- const addIndexRow = (row) => {
193
- const tag = clean(row?.tag);
194
- const sessionId = clean(row?.sessionId);
195
- if (!tag || !sessionId || !rowMatchesContext(row, context)) return;
196
- if (seen.has(sessionId)) return;
197
- // Collision/resolution enumeration only: a row that is in a terminal
198
- // (or idle-but-finished) state AND has no live session behind it is a
199
- // lingering trace kept for the reap grace window. excludeTerminalTraces drops those
200
- // rows so live-session reuse/spawn resolution can proceed; list/status
201
- // keep excludeTerminalTraces=false so finished workers still appear.
202
- if (excludeTerminalTraces
203
- && isTerminalWorkerStatus(row.status || row.stage)
204
- && !getLiveSession(sessionId)) {
205
- return;
206
- }
207
- seen.add(sessionId);
208
- rows.push({ tag, session: workerRowToSession(row), indexRow: row });
209
- };
210
- for (const row of readWorkerRows(context)) addIndexRow(row);
211
- if (scanSessions) {
212
- for (const session of mgr.listSessions({ includeClosed: false }) || []) {
213
- const tag = agentTagOf(session);
214
- add(session, tag);
215
- if (tag) upsertWorkerSessionDeferred(session, tag);
216
- }
217
- }
218
- for (const [tag, sessionId] of tags.entries()) {
219
- add(getLiveSession(sessionId), tag);
220
- }
221
- return rows;
222
- }
223
-
224
- function nextTag(agent, context = {}) {
225
- refreshTagsFromSessions({ context });
226
- // Auto tags are agent + a per-agent local index with NO hyphen
227
- // ("worker3", "heavy-worker7", or "agent1" when the agent is unset). The
228
- // index is the max existing `^agent(\d+)$` + 1, escaping the agent so a
229
- // hyphenated agent ("heavy-worker") is matched literally. Keep incrementing
230
- // on any live collision.
231
- const base = clean(agent) || 'agent';
232
- const escaped = base.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
233
- const re = new RegExp(`^${escaped}(\\d+)$`);
234
- let maxN = 0;
235
- for (const existing of tags.keys()) {
236
- const match = re.exec(existing);
237
- if (!match) continue;
238
- const n = Number(match[1]);
239
- if (Number.isFinite(n) && n > maxN) maxN = n;
240
- }
241
- let n = maxN + 1;
242
- let tag = `${base}${n}`;
243
- while (resolveTag(tag, context)) tag = `${base}${++n}`;
244
- return tag;
245
- }
246
-
247
- function refreshTagsFromSessions({ scanSessions = false, context = {} } = {}) {
248
- transitionStaleNonterminalRows(context);
249
- const indexedRows = refreshTagsFromIndex(context);
250
- const indexedKeys = new Set(indexedRows.map((row) => `${row.tag}\0${row.sessionId}`));
251
- for (const [tag, sessionId] of [...tags.entries()]) {
252
- if (indexedKeys.has(`${tag}\0${sessionId}`)) continue;
253
- const session = getLiveSession(sessionId);
254
- if (!session || session.closed) {
255
- tags.delete(tag);
256
- tagAgents.delete(tag);
257
- tagCwds.delete(tag);
258
- }
259
- }
260
- if (!scanSessions) return;
261
- for (const session of mgr.listSessions({ includeClosed: false }) || []) {
262
- const tag = agentTagOf(session);
263
- if (!tag || tags.has(tag)) continue;
264
- if (!sessionMatchesContext(session, context)) continue;
265
- tags.set(tag, session.id);
266
- if (session.agent) tagAgents.set(tag, session.agent);
267
- if (session.cwd) tagCwds.set(tag, session.cwd);
268
- upsertWorkerSessionDeferred(session, tag);
269
- }
270
- }
271
-
272
- function bindTag(tag, session, extra = {}) {
273
- if (!tag || !session?.id) return;
274
- tags.set(tag, session.id);
275
- if (session.agent) tagAgents.set(tag, session.agent);
276
- if (session.cwd) tagCwds.set(tag, session.cwd);
277
- upsertWorkerSessionDeferred(session, tag, extra);
278
- }
279
-
280
- function forgetTag(tag) {
281
- if (!tag) return;
282
- const sessionId = tags.get(tag) || '';
283
- tags.delete(tag);
284
- tagAgents.delete(tag);
285
- tagCwds.delete(tag);
286
- removeWorkerRow({ tag, sessionId });
287
- }
288
-
289
- function forgetTerminalSession(tag, sessionId) {
290
- const value = clean(tag);
291
- const id = clean(sessionId);
292
- if (value && id && tags.get(value) === id) {
293
- tags.delete(value);
294
- tagAgents.delete(value);
295
- tagCwds.delete(value);
296
- }
297
- if (id) removeWorkerRow({ sessionId: id });
298
- }
299
-
300
- function tombstoneTerminalSession(tag, sessionId, session = null) {
301
- const value = clean(tag);
302
- const id = clean(sessionId);
303
- if (!value || !id) {
304
- forgetTerminalSession(value, id);
305
- return;
306
- }
307
- if (tags.get(value) === id) {
308
- tags.delete(value);
309
- tagAgents.delete(value);
310
- tagCwds.delete(value);
311
- }
312
- const tombstone = {
313
- tag: value,
314
- agent: clean(session?.agent) || null,
315
- cwd: clean(session?.cwd) || null,
316
- clientHostPid: positiveInt(session?.clientHostPid),
317
- reapedAt: new Date().toISOString(),
318
- };
319
- flushWorkerIndexMutations();
320
- writeWorkerRows((byKey, tombstonesByKey, priorityTombstoneKeys) => {
321
- for (const [key, row] of [...byKey.entries()]) {
322
- if (clean(row.sessionId) === id) byKey.delete(key);
323
- }
324
- const tombstoneKey = tagTombstoneKey(tombstone);
325
- tombstonesByKey.set(tombstoneKey, tombstone);
326
- priorityTombstoneKeys.add(tombstoneKey);
122
+ // Tag maps + resolve/bind/reap lifecycle: agent-tool/tag-registry.mjs.
123
+ const {
124
+ tags,
125
+ tagAgents,
126
+ tagCwds,
127
+ reapTimers,
128
+ wantsSessionScan,
129
+ resolveTag,
130
+ getLiveSession,
131
+ tagForSession,
132
+ agentSessionEntries,
133
+ nextTag,
134
+ refreshTagsFromSessions,
135
+ bindTag,
136
+ forgetTag,
137
+ forgetTerminalSession,
138
+ tombstoneTerminalSession,
139
+ tagTombstoneForTag,
140
+ consumeTagTombstone,
141
+ cancelReap,
142
+ scheduleReap,
143
+ transitionStaleNonterminalRows,
144
+ readAllTagTombstones,
145
+ readTagTombstones,
146
+ readWorkerRows,
147
+ writeWorkerRows,
148
+ flushWorkerIndexMutations,
149
+ upsertWorkerSession,
150
+ upsertWorkerSessionDeferred,
151
+ removeWorkerRow,
152
+ refreshTagsFromIndex,
153
+ } = createTagRegistry({
154
+ dataDir,
155
+ cfgMod,
156
+ mgr,
157
+ emitSubagentEvent,
327
158
  });
328
- }
329
-
330
- function tagTombstoneForTag(tag, context = {}) {
331
- const value = clean(tag);
332
- if (!value || value.startsWith('sess_')) return null;
333
- return readTagTombstones(context).find((row) => row.tag === value) || null;
334
- }
335
-
336
- function consumeTagTombstone(tombstone) {
337
- if (!tombstone?.tag) return false;
338
- const key = tagTombstoneKey(tombstone);
339
- flushWorkerIndexMutations();
340
- writeWorkerRows((_byKey, tombstonesByKey) => tombstonesByKey.delete(key));
341
- return true;
342
- }
343
159
 
344
- function cancelReap(sessionId) {
345
- const handle = reapTimers.get(sessionId);
346
- if (!handle) return false;
347
- clearTimeout(handle);
348
- reapTimers.delete(sessionId);
349
- return true;
350
- }
351
-
352
- function scheduleReap(sessionId, provider = null) {
353
- if (!sessionId) return;
354
- cancelReap(sessionId);
355
- const reapProvider = provider || getLiveSession(sessionId)?.provider || null;
356
- const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), reapProvider);
357
- if (reapMs == null) return;
358
- const handle = setTimeout(() => {
359
- reapTimers.delete(sessionId);
360
- const session = getLiveSession(sessionId);
361
- try { mgr.hideSessionFromList?.(sessionId); } catch {}
362
- const tag = tagForSession(sessionId);
363
- tombstoneTerminalSession(tag, sessionId, session);
364
- clearAgentStatuslineRoute(sessionId);
365
- try { mgr.closeSession(sessionId, 'terminal-reap'); } catch {}
366
- }, reapMs);
367
- handle.unref?.();
368
- reapTimers.set(sessionId, handle);
369
- }
370
-
371
- function transitionStaleNonterminalRows(context = {}) {
372
- const staleRows = readWorkerRows(context).filter((row) => {
373
- if (isTerminalWorkerStatus(row.status || row.stage)) return false;
374
- if (getLiveSession(clean(row.sessionId))) return false;
375
- const rowTime = workerRowTime(row);
376
- const reapMs = resolveAgentTerminalReapMs(cfgMod.loadConfig(), row.provider);
377
- // A row with no timestamp has no usable heartbeat at all. Explicitly
378
- // disabled terminal reaping still gets the tombstone TTL as a finite
379
- // stale-heartbeat bound, so malformed/running index rows cannot block a
380
- // tag forever.
381
- return rowTime <= 0 || Date.now() - rowTime >= (reapMs ?? TAG_TOMBSTONE_TTL_MS);
160
+ // Job/session views (list/getJob/render/spawn-meta): agent-tool/job-views.mjs.
161
+ const {
162
+ isSessionBusy,
163
+ ensureProvider,
164
+ list,
165
+ sessionProgressExtras,
166
+ jobWorkerSnapshot,
167
+ listJobs,
168
+ getJob,
169
+ workerFallbackJob,
170
+ getJobOrWorker,
171
+ renderJob,
172
+ preparedSpawnMeta,
173
+ pendingSpawnMeta,
174
+ mergeJobMeta,
175
+ } = createJobViews({
176
+ mgr,
177
+ getLiveSession,
178
+ reg,
179
+ DEFAULT_SPAWN_PREP_TIMEOUT_MS,
180
+ refreshTagsFromSessions,
181
+ agentSessionEntries,
182
+ tags,
183
+ cfgMod,
382
184
  });
383
- if (staleRows.length === 0) return false;
384
- flushWorkerIndexMutations();
385
- const nowIso = new Date().toISOString();
386
- writeWorkerRows((byKey, tombstonesByKey, priorityTombstoneKeys) => {
387
- for (const row of staleRows) {
388
- const sessionId = clean(row.sessionId);
389
- for (const [key, candidate] of [...byKey.entries()]) {
390
- if (clean(candidate.sessionId) === sessionId) byKey.delete(key);
391
- }
392
- const tombstone = {
393
- tag: clean(row.tag),
394
- agent: clean(row.agent) || null,
395
- cwd: clean(row.cwd) || null,
396
- clientHostPid: positiveInt(row.clientHostPid),
397
- reapedAt: nowIso,
398
- };
399
- const tombstoneKey = tagTombstoneKey(tombstone);
400
- tombstonesByKey.set(tombstoneKey, tombstone);
401
- priorityTombstoneKeys.add(tombstoneKey);
402
- if (tags.get(tombstone.tag) === sessionId) {
403
- tags.delete(tombstone.tag);
404
- tagAgents.delete(tombstone.tag);
405
- tagCwds.delete(tombstone.tag);
406
- }
407
- }
408
- });
409
- return true;
410
- }
411
185
 
412
- function isSessionBusy(sessionId) {
413
- const runtime = mgr.getSessionRuntime?.(sessionId);
414
- if (runtime?.controller?.signal && !runtime.controller.signal.aborted) return true;
415
- if (runtime?.stage) return ACTIVE_STAGES.has(runtime.stage);
416
- const session = getLiveSession(sessionId);
417
- return ACTIVE_STAGES.has(session?.status || '');
418
- }
419
-
420
- // Provider init de-dup lives in ./agent-tool/provider-init.mjs; the factory
421
- // keeps its per-provider chain/ready state private per agent instance. The
422
- // chain-gate defaults to the spawn-prep cap (see provider-init.mjs comments).
423
- const { ensureProvider } = createProviderInit(reg, DEFAULT_SPAWN_PREP_TIMEOUT_MS);
424
-
425
- function list({ scanSessions = false, context = {} } = {}) {
426
- refreshTagsFromSessions({ scanSessions, context });
427
- const now = Date.now();
428
- const rows = [];
429
- for (const { tag, session } of agentSessionEntries({ scanSessions, context })) {
430
- const sessionId = session.id;
431
- const runtime = mgr.getSessionRuntime?.(sessionId);
432
- const status = session.closed === true ? 'closed' : (session.status || 'idle');
433
- const stage = session.stage || (status === 'idle' || status === 'error' || status === 'closed'
434
- ? status
435
- : (runtime?.stage || status));
436
- const progress = sessionProgressExtras(sessionId, session.agent || null, now);
437
- rows.push({
438
- tag,
439
- sessionId,
440
- agent: session.agent || null,
441
- provider: session.provider,
442
- model: session.model,
443
- preset: session.presetName || null,
444
- effort: session.effort || null,
445
- fast: session.fast === true,
446
- status,
447
- stage,
448
- ...progress,
449
- createdAt: session.createdAt || null,
450
- updatedAt: session.updatedAt || null,
451
- lastUsedAt: session.lastUsedAt || null,
452
- clientHostPid: session.clientHostPid || null,
453
- lastStreamDeltaAt: runtime?.lastStreamDeltaAt ? new Date(runtime.lastStreamDeltaAt).toISOString() : null,
454
- staleSeconds: runtime?.lastStreamDeltaAt ? Math.floor((now - runtime.lastStreamDeltaAt) / 1000) : null,
455
- windowTokens: Number(session.lastContextTokens ?? session.lastInputTokens) || 0,
456
- windowCap: Number(session.contextWindow) || null,
457
- permission: session.permission || null,
458
- toolPermission: session.toolPermission || null,
459
- messages: Array.isArray(session.messages) ? session.messages.length : Math.max(0, Number(session.messageCount || 0)),
460
- tools: Array.isArray(session.tools) ? session.tools.length : Math.max(0, Number(session.toolCount || 0)),
461
- });
462
- }
463
- return rows;
464
- }
465
-
466
- function sessionProgressExtras(sessionId, role, now = Date.now(), taskStatus = null) {
467
- if (!sessionId) return {};
468
- const session = mgr.getSession(sessionId);
469
- const runtime = mgr.getSessionRuntime?.(sessionId) || null;
470
- const snapshot = typeof mgr.getSessionProgressSnapshot === 'function'
471
- ? mgr.getSessionProgressSnapshot(sessionId)
472
- : null;
473
- const policy = role ? resolveAgentWatchdogPolicy(role) : null;
474
- const queuedFollowups = typeof mgr.getSessionPendingMessageDepth === 'function'
475
- ? mgr.getSessionPendingMessageDepth(sessionId)
476
- : null;
477
- return buildAgentTaskProgressFields({
478
- now,
479
- sessionStatus: session?.status || null,
480
- runtimeStage: runtime?.stage || snapshot?.stage || session?.status || null,
481
- snapshot,
482
- runtime,
483
- policy,
484
- queuedFollowups,
485
- taskStatus,
486
- lastToolCall: runtime?.lastToolCall || null,
487
- });
488
- }
489
-
490
- function jobWorkerSnapshot(sessionId) {
491
- if (!sessionId) return null;
492
- const session = mgr.getSession(sessionId);
493
- if (!session) return null;
494
- const runtime = mgr.getSessionRuntime?.(sessionId);
495
- const status = session.closed === true ? 'closed' : (session.status || 'idle');
496
- const progress = sessionProgressExtras(sessionId, session.agent || null);
497
- return {
498
- workerStatus: status,
499
- stage: progress.worker_stage || runtime?.stage || status,
500
- clientHostPid: session.clientHostPid || null,
501
- lastStreamDeltaAt: runtime?.lastStreamDeltaAt ? new Date(runtime.lastStreamDeltaAt).toISOString() : null,
502
- ...progress,
503
- };
504
- }
505
-
506
- function listJobs(context = {}) {
507
- const wantedPid = terminalPidForContext(context);
508
- const rows = listBackgroundTasks({ surface: 'agent', context }).map((task) => ({
509
- task_id: task.task_id,
510
- type: task.operation,
511
- status: task.status,
512
- tag: task.tag || null,
513
- sessionId: task.sessionId || null,
514
- agent: task.agent || null,
515
- preset: task.preset || null,
516
- provider: task.provider || null,
517
- model: task.model || null,
518
- effort: task.effort || null,
519
- fast: task.fast === true || task.fast === false ? task.fast : null,
520
- maxLoopIterations: task.maxLoopIterations || null,
521
- startedAt: task.startedAt,
522
- finishedAt: task.finishedAt || null,
523
- error: task.error || null,
524
- ...jobWorkerSnapshot(task.sessionId),
525
- ...sessionProgressExtras(task.sessionId, task.agent || null, Date.now(), task.status),
526
- }));
527
- return wantedPid
528
- ? rows.filter((row) => {
529
- const pid = positiveInt(row.clientHostPid);
530
- // Spawn-prep rows have no worker session (and therefore no pid) yet;
531
- // they already passed taskMatchesScope, so the queued spawn stays
532
- // visible instead of vanishing until a pool slot frees up (user bug:
533
- // "spawned 5, only 4 listed").
534
- return pid ? pid === wantedPid : true;
535
- })
536
- : rows;
537
- }
538
-
539
- function getJob(args, context = {}) {
540
- const taskId = taskIdFromArgs(args);
541
- if (taskId) {
542
- const task = getBackgroundTask(taskId, { surface: 'agent', context });
543
- if (!task) throw new Error(`agent read/status: task "${taskId}" not found`);
544
- return task;
545
- }
546
- // Fall back to tag/sessionId resolution, same precedence as close()
547
- // (agent-tool.mjs close()): clean(args.tag || args.sessionId || ...).
548
- const target = clean(args.tag || args.sessionId);
549
- if (!target) throw new Error('agent read/status: task_id, tag, or sessionId is required');
550
- const candidates = listBackgroundTasks({ surface: 'agent', context })
551
- .filter(Boolean)
552
- .filter((row) => row.tag === target || row.sessionId === target);
553
- if (!candidates.length) throw new Error(`agent read/status: no task found for tag/sessionId "${target}"`);
554
- // Prefer most recent when multiple tasks match the same tag.
555
- candidates.sort((a, b) => new Date(b.startedAt || 0) - new Date(a.startedAt || 0));
556
- const task = getBackgroundTask(candidates[0].task_id, { surface: 'agent', context });
557
- if (!task) throw new Error(`agent read/status: task "${candidates[0].task_id}" not found`);
558
- return task;
559
- }
560
-
561
- // Background tasks are process-memory only; a worker SESSION can outlive
562
- // its spawn task (task pruned, engine restarted). read/status by tag then
563
- // used to throw "no task found" even though the worker exists. Fall back to
564
- // a synthetic job view built from the worker session, with the last
565
- // assistant message as the readable result.
566
- function workerFallbackJob(target, context = {}) {
567
- refreshTagsFromSessions({ scanSessions: true, context });
568
- const sessionId = tags.get(target) || (mgr.getSession(target) ? target : null);
569
- if (!sessionId) return null;
570
- const session = mgr.getSession(sessionId);
571
- if (!session) return null;
572
- const status = session.closed === true ? 'closed' : (session.status || 'idle');
573
- const msgs = Array.isArray(session.messages) ? session.messages : [];
574
- const lastAssistant = [...msgs].reverse().find((m) => m?.role === 'assistant'
575
- && (typeof m.content === 'string' ? m.content.trim() : m.content));
576
- const resultText = lastAssistant
577
- ? (typeof lastAssistant.content === 'string' ? lastAssistant.content : JSON.stringify(lastAssistant.content))
578
- : '(worker session has no assistant output yet)';
579
- return {
580
- taskId: null,
581
- operation: 'worker',
582
- status,
583
- startedAt: session.createdAt || null,
584
- finishedAt: null,
585
- error: null,
586
- result: resultText,
587
- meta: {
588
- tag: tags.has(target) ? target : (session.tag || null),
589
- sessionId,
590
- agent: session.agent || null,
591
- preset: session.presetName || null,
592
- provider: session.provider || null,
593
- model: session.model || null,
594
- effort: session.effort || null,
595
- fast: session.fast === true,
596
- maxLoopIterations: null,
597
- },
598
- };
599
- }
600
-
601
- function getJobOrWorker(args, context = {}) {
602
- try {
603
- return getJob(args, context);
604
- } catch (error) {
605
- const target = clean(args.tag || args.sessionId);
606
- const fallback = target ? workerFallbackJob(target, context) : null;
607
- if (fallback) return fallback;
608
- throw error;
609
- }
610
- }
611
-
612
- function renderJob(job, includeResult = false) {
613
- const meta = job.meta || {};
614
- let progress = sessionProgressExtras(meta.sessionId, meta.agent || null, Date.now(), job.status);
615
- // Spawn is deferred: before the worker session exists, sessionProgressExtras
616
- // returns {} and the status card would show only "status: running" with no
617
- // stage/progress. Fill a minimal stage so the caller can tell the job is
618
- // still spinning up rather than silently stalled.
619
- if (!meta.sessionId && (!progress || Object.keys(progress).length === 0)) {
620
- const spawning = job.status === 'running';
621
- progress = {
622
- worker_stage: spawning ? 'spawning' : (job.status || 'unknown'),
623
- last_progress: spawning ? 'spawning worker session' : (job.status || 'unknown'),
624
- diagnostic: spawning ? 'worker session not started yet' : (job.status || 'unknown'),
625
- };
626
- }
627
- return {
628
- task_id: job.taskId,
629
- type: job.operation,
630
- status: job.status,
631
- tag: meta.tag || null,
632
- sessionId: meta.sessionId || null,
633
- agent: meta.agent || null,
634
- ...(meta.respawned === true ? { respawned: true, note: 'previous session reaped — fresh session, no prior context; re-supply anchors if needed' } : {}),
635
- preset: meta.preset || null,
636
- provider: meta.provider || null,
637
- model: meta.model || null,
638
- effort: meta.effort || null,
639
- fast: meta.fast === true || meta.fast === false ? meta.fast : null,
640
- maxLoopIterations: meta.maxLoopIterations || null,
641
- startedAt: job.startedAt,
642
- finishedAt: job.finishedAt || null,
643
- error: job.error || null,
644
- ...jobWorkerSnapshot(meta.sessionId),
645
- ...progress,
646
- ...(includeResult && job.result !== undefined ? { result: job.result } : {}),
647
- };
648
- }
649
-
650
- function preparedSpawnMeta(prepared, extras = {}) {
651
- return sanitizeTaskMeta({
652
- ...(extras || {}),
653
- tag: prepared.tag,
654
- sessionId: prepared.session.id,
655
- agent: prepared.agent,
656
- preset: presetKey(prepared.preset) || prepared.presetName,
657
- provider: prepared.preset.provider,
658
- model: prepared.preset.model,
659
- effort: prepared.preset.effort || null,
660
- fast: prepared.preset.fast === true,
661
- maxLoopIterations: prepared.maxLoopIterations || null,
662
- });
663
- }
664
-
665
- function pendingSpawnMeta(args = {}, extras = {}) {
666
- const agent = normalizeAgentName(args.agent);
667
- // Best-effort resolve the default preset so the pending "Spawn …" card can
668
- // already show the model (e.g. "Spawn Heavy Worker (Opus 4.8)") even when
669
- // the caller did not pass an explicit provider/model. Never throw: fall back
670
- // to whatever raw args carry.
671
- let resolved = null;
672
- if (!clean(args.model) || !clean(args.provider)) {
673
- try { resolved = resolveAgentSpawnPreset(cfgMod.loadConfig(), args)?.preset || null; }
674
- catch { resolved = null; }
675
- }
676
- return sanitizeTaskMeta({
677
- ...(extras || {}),
678
- tag: clean(args.tag) || null,
679
- sessionId: null,
680
- agent: agent || null,
681
- preset: clean(args.preset) || presetKey(resolved) || null,
682
- provider: clean(args.provider) || clean(resolved?.provider) || null,
683
- model: clean(args.model) || clean(resolved?.model) || null,
684
- effort: clean(args.effort) || clean(resolved?.effort) || null,
685
- fast: args.fast === true ? true : (resolved?.fast === true ? true : null),
686
- });
687
- }
688
-
689
- function mergeJobMeta(job, meta = {}) {
690
- if (!job || !meta || typeof meta !== 'object') return;
691
- const next = sanitizeTaskMeta({ ...(job.meta || {}), ...meta });
692
- job.meta = next;
693
- if (job.input && typeof job.input === 'object') {
694
- job.input = {
695
- ...job.input,
696
- tag: next.tag || job.input.tag || null,
697
- sessionId: next.sessionId || job.input.sessionId || null,
698
- agent: next.agent || job.input.agent || null,
699
- };
700
- }
701
- job.label = next.tag || next.sessionId || job.label;
702
- }
703
-
704
- function closePreparedSpawn(prepared, reason = 'agent-task-cancel') {
705
- if (!prepared?.session?.id) return;
706
- try { mgr.closeSession(prepared.session.id, reason); } catch {}
707
- try { clearAgentStatuslineRoute(prepared.session.id); } catch {}
708
- forgetTerminalSession(prepared.tag, prepared.session.id);
709
- }
710
-
711
- // Owner/worker completion notification lives in ./agent-tool/notify.mjs; the
712
- // factory captures mgr and registers the canonical completion fallback.
713
- const { workerNotifyFn, notifyOwnerAgentCompletionEarly } = createNotify(mgr);
714
-
715
- function startJob(type, meta, run, notifyContext = null) {
716
- const clientHostPid = terminalPidForContext(notifyContext);
717
- const jobMeta = sanitizeTaskMeta({
718
- ...(meta || {}),
719
- ...(clientHostPid ? { clientHostPid } : {}),
186
+ // Spawn/job lifecycle (startJob/watchdogs/prepare/run): agent-tool/spawn-flow.mjs.
187
+ const {
188
+ closePreparedSpawn,
189
+ workerNotifyFn,
190
+ notifyOwnerAgentCompletionEarly,
191
+ startJob,
192
+ startDeferredSpawnJob,
193
+ progressWatchdogs,
194
+ startProgressIdleWatchdog,
195
+ prepareSpawn,
196
+ tryShardEnqueue,
197
+ runShardSpawn,
198
+ prepareSpawnInProcess,
199
+ runSpawn,
200
+ } = createSpawnFlow({
201
+ mgr,
202
+ forgetTerminalSession,
203
+ pendingSpawnMeta,
204
+ DEFAULT_SPAWN_PREP_TIMEOUT_MS,
205
+ mergeJobMeta,
206
+ preparedSpawnMeta,
207
+ upsertWorkerSessionDeferred,
208
+ refreshTagsFromSessions,
209
+ readWorkerRows,
210
+ defaultCwd,
211
+ nextTag,
212
+ cancelReap,
213
+ bindTag,
214
+ emitSubagentEvent,
215
+ scheduleReap,
216
+ cfgMod,
217
+ dataDir,
218
+ STANDALONE_SOURCE_ROOT,
219
+ ensureProvider,
220
+ resolveTag,
221
+ wantsSessionScan,
222
+ createTurnReviewCollector,
720
223
  });
721
- let task;
722
- const admissionController = new AbortController();
723
- task = startBackgroundTask({
724
- surface: 'agent',
725
- operation: type,
726
- label: jobMeta?.tag || jobMeta?.sessionId || type,
727
- input: { type, tag: jobMeta?.tag || null, sessionId: jobMeta?.sessionId || null, agent: jobMeta?.agent || null },
728
- context: notifyContext,
729
- meta: jobMeta,
730
- resultType: 'agent_task_result',
731
- renderResult: (result) => renderResult(result),
732
- cancel: () => {
733
- try { admissionController.abort(new Error('agent task cancelled before resource admission')); } catch {}
734
- const currentMeta = task?.meta || jobMeta;
735
- if (currentMeta?.sessionId) {
736
- try { mgr.closeSession(currentMeta.sessionId, 'agent-task-cancel'); } catch {}
737
- }
738
- // Sharded spawn: the live session exists only in the shard child —
739
- // forward the cancel by sessionId (known after the `spawned` progress
740
- // event) or tag.
741
- if (agentShardEnabled() && (currentMeta?.tag || currentMeta?.sessionId)) {
742
- try {
743
- void shardRequest('cancel', {
744
- tag: currentMeta?.tag || null,
745
- sessionId: currentMeta?.sessionId || null,
746
- }).catch(() => {});
747
- } catch { /* best-effort */ }
748
- }
749
- },
750
- run: async () => {
751
- const lease = await resourceAdmission.acquire('agent', {
752
- signal: admissionController.signal,
753
- label: jobMeta?.tag || type,
754
- });
755
- try {
756
- // Yield one macrotask before doing agent work. startBackgroundTask uses
757
- // a Promise microtask, which otherwise begins CPU-heavy spawn prep
758
- // before the TUI receives/render the "running" result.
759
- await new Promise((resolve) => setImmediate(resolve));
760
- if (task?.status === 'cancelled') return null;
761
- return await resourceAdmission.runWithLease(lease, () => run(task));
762
- } finally {
763
- await lease.release();
764
- }
765
- },
766
- });
767
- return task;
768
- }
769
-
770
- function startDeferredSpawnJob(args, callerCwd, context, notifyContext, extras = {}) {
771
- return startJob('spawn', pendingSpawnMeta(args, extras), async (job) => {
772
- if (job?.status === 'cancelled') return null;
773
- // P1 agent sharding (MIXDOG_AGENT_SHARD=1, default off): run the worker
774
- // session in a shard child process instead of this event loop. Prompt
775
- // resolution stays here (cheap); prep + the full agent loop run in the
776
- // shard. Tag/index/watchdog parity lands in P2 (docs/agent-shard-design.md).
777
- if (agentShardEnabled()) {
778
- return await runShardSpawn(args, callerCwd, context, job);
779
- }
780
- // prepareSpawn (ensureProvider/prepareAgentSession) runs before runSpawn
781
- // installs its progress watchdog, so guard prep with an internal env-
782
- // backed cap rather than exposing per-call timeout knobs on the agent
783
- // tool surface.
784
- const prepDeadlineMs = nonNegativeInt(args.spawnPrepTimeoutMs ?? args.prepTimeoutMs)
785
- ?? DEFAULT_SPAWN_PREP_TIMEOUT_MS;
786
- let prepared;
787
- const prepState = { timedOut: false };
788
- if (prepDeadlineMs > 0) {
789
- let prepTimer = null;
790
- let timedOut = false;
791
- // If prep wins the race we use its result. If the timeout wins, the
792
- // prepareSpawn promise may still resolve later with a fully-built
793
- // session/tag/route — attach a cleanup so the late-arriving prepared is
794
- // torn down, otherwise the orphaned tag would collide on re-spawn.
795
- const prepPromise = prepareSpawn(args, callerCwd, context, prepState);
796
- prepPromise.then((late) => {
797
- if (timedOut) closePreparedSpawn(late, 'agent-spawn-prep-timeout');
798
- }, () => {});
799
- const timeout = new Promise((_resolve, reject) => {
800
- prepTimer = setTimeout(() => {
801
- timedOut = true;
802
- prepState.timedOut = true;
803
- reject(new Error(`agent spawn prep timed out (${prepDeadlineMs}ms) before model request`));
804
- }, prepDeadlineMs);
805
- prepTimer.unref?.();
806
- });
807
- try {
808
- prepared = await Promise.race([prepPromise, timeout]);
809
- } finally {
810
- if (prepTimer) clearTimeout(prepTimer);
811
- }
812
- } else {
813
- prepared = await prepareSpawn(args, callerCwd, context, prepState);
814
- }
815
- mergeJobMeta(job, preparedSpawnMeta(prepared, extras));
816
- upsertWorkerSessionDeferred(prepared.session, prepared.tag, {
817
- ...preparedSpawnMeta(prepared, extras),
818
- status: 'running',
819
- stage: 'running',
820
- task_id: job.taskId,
821
- startedAt: job.startedAt,
822
- });
823
- if (job?.status === 'cancelled') {
824
- closePreparedSpawn(prepared);
825
- return null;
826
- }
827
- return await runSpawn(prepared, notifyContext, job);
828
- }, notifyContext);
829
- }
830
-
831
- // Idle-progress watchdogs (shared sweep timer) live in
832
- // agent-watchdog-registry.mjs.
833
- const progressWatchdogs = createProgressWatchdogRegistry({ mgr });
834
- const startProgressIdleWatchdog = (sessionId, watchdogPolicy, agent = null) =>
835
- progressWatchdogs.start(sessionId, watchdogPolicy, agent);
836
-
837
- async function prepareSpawn(args, callerCwd = null, context = {}, prepState = null) {
838
- // (sharded spawns never reach here — see startDeferredSpawnJob)
839
- refreshTagsFromSessions({ context });
840
- return prepareSpawnInProcess(args, callerCwd, context, prepState);
841
- }
842
-
843
- // Tag-reuse routing for sharded workers (P2): a same-tag spawn/send while
844
- // the shard runs the session mirrors the in-process busy-session enqueue.
845
- // Discriminator: a non-terminal worker-index row whose session has no live
846
- // runtime in THIS process. (mgr.getSession must not be used here — it
847
- // falls back to the on-disk session file the shard child persists, which
848
- // makes a shard-owned session look lead-live.) Returns the rendered queued
849
- // result, or null to fall through to the normal path (unknown tag,
850
- // terminal row, lead-owned runtime, or the shard no longer owns the
851
- // session — e.g. after a shard crash, where respawn is the correct
852
- // recovery).
853
- async function tryShardEnqueue(target, args, context, extras = {}) {
854
- if (!agentShardEnabled()) return null;
855
- const value = clean(target);
856
- if (!value) return null;
857
- const row = readWorkerRows(context).find((r) => (
858
- (clean(r.tag) === value || clean(r.sessionId) === value)
859
- && rowMatchesContext(r, context)
860
- && !isTerminalWorkerStatus(r.status || r.stage)
861
- ));
862
- if (!row) return null;
863
- const rowSessionId = clean(row.sessionId);
864
- const runtime = rowSessionId ? mgr.getSessionRuntime?.(rowSessionId) : null;
865
- if (runtime?.controller?.signal && !runtime.controller.signal.aborted) return null;
866
- const prompt = await resolvePrompt(args, clean(row.cwd) || defaultCwd || process.cwd());
867
- if (!clean(prompt)) return null;
868
- let queued = null;
869
- try {
870
- queued = await shardRequest('enqueue', {
871
- tag: clean(row.tag) || null,
872
- sessionId: rowSessionId || null,
873
- prompt,
874
- });
875
- } catch { return null; }
876
- if (queued?.queued !== true) return null;
877
- return renderResult({
878
- queued: true,
879
- ...extras,
880
- tag: clean(row.tag) || null,
881
- sessionId: rowSessionId || null,
882
- agent: clean(row.agent) || null,
883
- queueDepth: queued.queueDepth || 1,
884
- });
885
- }
886
-
887
- async function runShardSpawn(args, callerCwd, context, job) {
888
- const agent = normalizeAgentName(args.agent);
889
- if (!agent) throw new Error('agent spawn: agent is required');
890
- const tag = clean(args.tag) || nextTag(agent, context);
891
- const baseCwd = resolve(callerCwd || defaultCwd || process.cwd());
892
- const workerCwd = clean(args.cwd) ? resolve(baseCwd, args.cwd) : baseCwd;
893
- const prompt = withCwdHeader(await resolvePrompt(args, workerCwd), workerCwd);
894
- const clientHostPid = terminalPidForContext(context) || null;
895
- // P2 progress parity: the shard reports the bound session the moment it
896
- // exists (`spawned`), so the lead mirrors its in-process spawn
897
- // bookkeeping — tag maps, worker-index row, statusline route, job meta,
898
- // SubagentStart — while the worker loop runs in the child.
899
- let spawned = null;
900
- const onSpawned = (payload = {}) => {
901
- if (spawned || !payload.sessionId) return;
902
- spawned = payload;
903
- const sessionLike = {
904
- id: payload.sessionId,
905
- agent,
906
- cwd: payload.cwd || workerCwd,
907
- provider: payload.provider || null,
908
- model: payload.model || null,
909
- presetName: payload.presetName || null,
910
- effort: payload.effort || null,
911
- fast: payload.fast === true,
912
- clientHostPid,
913
- status: 'running',
914
- };
915
- cancelReap(payload.sessionId);
916
- writeAgentStatuslineRoute(payload.sessionId, {
917
- provider: payload.provider || null,
918
- model: payload.model || null,
919
- effort: payload.effort || null,
920
- fast: payload.fast === true,
921
- });
922
- bindTag(tag, sessionLike, {
923
- agent,
924
- preset: payload.presetName || null,
925
- provider: payload.provider || null,
926
- model: payload.model || null,
927
- effort: payload.effort || null,
928
- fast: payload.fast === true,
929
- status: 'running',
930
- stage: 'running',
931
- });
932
- if (job) {
933
- mergeJobMeta(job, {
934
- tag,
935
- sessionId: payload.sessionId,
936
- agent,
937
- preset: payload.presetName || null,
938
- provider: payload.provider || null,
939
- model: payload.model || null,
940
- effort: payload.effort || null,
941
- fast: payload.fast === true,
942
- });
943
- }
944
- emitSubagentEvent('start', agent, { session_id: payload.sessionId, tag });
945
- // Cancel raced the shard prep: the task is already terminal on the lead
946
- // but the child just started its loop — abort it now that the sessionId
947
- // is known, instead of letting it burn tokens to completion.
948
- if (job?.status === 'cancelled') {
949
- try { void shardRequest('cancel', { tag, sessionId: payload.sessionId }).catch(() => {}); } catch { /* best-effort */ }
950
- }
951
- };
952
- let finalStatus = 'error';
953
- try {
954
- const response = await shardRequest('spawn', {
955
- args: {
956
- agent,
957
- provider: args.provider,
958
- model: args.model,
959
- effort: args.effort,
960
- fast: args.fast,
961
- preset: args.preset,
962
- },
963
- tag,
964
- cwd: workerCwd,
965
- prompt,
966
- context: args.context || null,
967
- parentSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
968
- ownerSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
969
- clientHostPid,
970
- }, {
971
- onProgress: (event, payload) => { if (event === 'spawned') onSpawned(payload); },
972
- });
973
- // Same abnormal-empty promotion as the in-process completionValue; a
974
- // watchdog partial handoff (stallAbort) is a valid completion and is
975
- // exempt, matching runSpawn's catch path.
976
- const abnormalError = response?.stallAbort === true
977
- ? null
978
- : abnormalEmptyFinishError(response, agent);
979
- const value = {
980
- tag,
981
- sessionId: response.sessionId,
982
- agent,
983
- preset: response.presetName || null,
984
- provider: response.provider || null,
985
- model: response.model || null,
986
- effort: response.effort || null,
987
- fast: response.fast === true,
988
- content: response.content || '',
989
- ...(response.stallAbort === true ? { stallAbort: true } : {}),
990
- ...(abnormalError ? { error: abnormalError } : {}),
991
- };
992
- if (job) job._terminalResultValue = value;
993
- if (value.error) throw new Error(value.error);
994
- finalStatus = 'idle';
995
- return value;
996
- } finally {
997
- // Terminal bookkeeping parity with runSpawn's finally: worker-index row
998
- // transitions to idle/error, the terminal-reap timer starts (tombstone +
999
- // statusline cleanup), and SubagentStop fires. Only after `spawned` —
1000
- // a prep failure inside the shard bound nothing on the lead.
1001
- if (spawned?.sessionId) {
1002
- upsertWorkerSessionDeferred({
1003
- id: spawned.sessionId,
1004
- agent,
1005
- cwd: spawned.cwd || workerCwd,
1006
- provider: spawned.provider || null,
1007
- model: spawned.model || null,
1008
- presetName: spawned.presetName || null,
1009
- effort: spawned.effort || null,
1010
- fast: spawned.fast === true,
1011
- clientHostPid,
1012
- status: finalStatus,
1013
- }, tag, {
1014
- agent,
1015
- preset: spawned.presetName || null,
1016
- provider: spawned.provider || null,
1017
- model: spawned.model || null,
1018
- effort: spawned.effort || null,
1019
- fast: spawned.fast === true,
1020
- status: finalStatus,
1021
- stage: finalStatus,
1022
- finishedAt: new Date().toISOString(),
1023
- });
1024
- scheduleReap(spawned.sessionId, spawned.provider || null);
1025
- emitSubagentEvent('stop', agent, { session_id: spawned.sessionId, tag, status: finalStatus });
1026
- }
1027
- }
1028
- }
1029
-
1030
- async function prepareSpawnInProcess(args, callerCwd = null, context = {}, prepState = null) {
1031
- const config = cfgMod.loadConfig();
1032
- const agent = normalizeAgentName(args.agent);
1033
- if (!agent) throw new Error('agent spawn: agent is required');
1034
- const agentPermission = readAgentFrontmatterPermission(agent, dataDir, STANDALONE_SOURCE_ROOT);
1035
- const agentPerm = normalizeAgentPermission(agentPermission) || null;
1036
- const { presetName, preset } = resolveAgentSpawnPreset(config, args);
1037
- await ensureProvider(config, preset.provider);
1038
- if (prepState?.timedOut) {
1039
- throw new Error('agent spawn prep timed out before session bind');
1040
- }
1041
-
1042
- const tag = clean(args.tag) || nextTag(agent, context);
1043
- // Any resolved same-tag binding in this terminal (live or lingering trace)
1044
- // blocks a fresh spawn. execute() routes live reuse before prepareSpawn.
1045
- if (resolveTag(tag, context, { scanSessions: wantsSessionScan(args) })) {
1046
- throw new Error(`agent spawn: tag "${tag}" already exists`);
1047
- }
1048
- const baseCwd = resolve(callerCwd || defaultCwd || process.cwd());
1049
- const workerCwd = clean(args.cwd) ? resolve(baseCwd, args.cwd) : baseCwd;
1050
- const prompt = withCwdHeader(await resolvePrompt(args, workerCwd), workerCwd);
1051
- if (prepState?.timedOut) {
1052
- throw new Error('agent spawn prep timed out before session bind');
1053
- }
1054
- const runtimeSpec = cfgMod.resolveRuntimeSpec(preset, { lane: 'agent', agentId: tag });
1055
- const maxLoopIterations = positiveInt(args.maxLoopIterations) || null;
1056
- const watchdogPolicy = resolveAgentWatchdogPolicy(agent);
1057
- const { session, effectiveCwd } = prepareAgentSession({
1058
- agent,
1059
- presetName,
1060
- preset,
1061
- runtimeSpec,
1062
- owner: AGENT_OWNER,
1063
- cwd: workerCwd,
1064
- sourceType: 'cli',
1065
- sourceName: agent,
1066
- parentSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
1067
- ownerSessionId: clean(context?.callerSessionId || context?.sessionId) || null,
1068
- clientHostPid: terminalPidForContext(context) || null,
1069
- agentTag: tag,
1070
- taskType: clean(args.taskType) || clean(args.typeHint) || undefined,
1071
- maxLoopIterations: maxLoopIterations || undefined,
1072
- permission: agentPerm || undefined,
1073
- cacheKeyOverride: args.cacheKey || undefined,
1074
- });
1075
- // Lead sessions write a gateway-session route when created; agent sessions
1076
- // are built through prepareAgentSession(), so mirror that registration here
1077
- // or the vendored L1/L2 statusline cannot resolve the agent route/model.
1078
- writeAgentStatuslineRoute(session.id, preset);
1079
- bindTag(tag, session, {
1080
- agent,
1081
- preset: presetKey(preset) || presetName,
1082
- provider: preset.provider,
1083
- model: preset.model,
1084
- effort: preset.effort || null,
1085
- fast: preset.fast === true,
1086
- status: 'idle',
1087
- stage: 'idle',
1088
- });
1089
- cancelReap(session.id);
1090
- return {
1091
- args,
1092
- tag,
1093
- session,
1094
- agent,
1095
- preset,
1096
- presetName,
1097
- workerCwd: effectiveCwd || workerCwd,
1098
- prompt,
1099
- maxLoopIterations,
1100
- watchdogPolicy,
1101
- };
1102
- }
1103
-
1104
- async function runSpawn(prepared, notifyContext = null, job = null) {
1105
- const { args, tag, session, agent, preset, presetName, workerCwd, prompt, watchdogPolicy } = prepared;
1106
- const watchdog = startProgressIdleWatchdog(session.id, watchdogPolicy, agent);
1107
- const turnReview = createTurnReviewCollector(session, tag, agent, notifyContext || {});
1108
- let finalStatus = 'idle';
1109
- // SubagentStart: a worker session is about to run its first turn.
1110
- emitSubagentEvent('start', agent, { session_id: session.id, tag });
1111
- upsertWorkerSessionDeferred(session, tag, {
1112
- agent,
1113
- preset: presetKey(preset) || presetName,
1114
- provider: preset.provider,
1115
- model: preset.model,
1116
- effort: preset.effort || null,
1117
- fast: preset.fast === true,
1118
- status: 'running',
1119
- stage: 'running',
1120
- });
1121
- let handoffMsgStart = 0;
1122
- try {
1123
- const completionValue = (result) => {
1124
- // Promote an abnormal finish (iteration cap, truncation, or a public
1125
- // agent's empty terminal turn) to an explicit error, so the Lead
1126
- // receives it as a failure with an accurate reason instead of a silent
1127
- // `completed` empty result. Keyed off loop.mjs terminationReason;
1128
- // hidden agents finishing normally-empty are left untagged (benign).
1129
- const abnormalError = abnormalEmptyFinishError(result, agent);
1130
- return {
1131
- tag,
1132
- sessionId: session.id,
1133
- agent,
1134
- preset: presetKey(preset) || presetName,
1135
- provider: preset.provider,
1136
- model: preset.model,
1137
- effort: preset.effort || null,
1138
- fast: preset.fast === true,
1139
- content: result?.content || '',
1140
- ...(abnormalError ? { error: abnormalError } : {}),
1141
- };
1142
- };
1143
- handoffMsgStart = resolveHandoffMessageStartIndex(mgr.getSession(session.id));
1144
- const result = await mgr.askSession(session.id, prompt, args.context || null, null, workerCwd, null, {
1145
- notifyFn: workerNotifyFn(session.id, notifyContext || {}),
1146
- onToolResult: (message) => turnReview.onToolResult(message),
1147
- ...(job ? {
1148
- onTerminalResult: (terminalResult) => {
1149
- turnReview.complete();
1150
- const value = completionValue(terminalResult);
1151
- if (job) job._terminalResultValue = value;
1152
- notifyOwnerAgentCompletionEarly(job, value, notifyContext || {});
1153
- // Mark the task terminal the moment the worker produces its final
1154
- // result, so a hung/slow post-result session save cannot strand the
1155
- // task (and the status card) in `running`. Idempotent; the finally
1156
- // reconcile remains a backup for the error/no-terminal-result path.
1157
- if (job?.taskId) {
1158
- try {
1159
- // An empty/abnormal finish is a failure, not a completion:
1160
- // reconcile as `failed` with the accurate error so the Lead
1161
- // card renders `error: …` instead of a header-only empty card.
1162
- reconcileBackgroundTask(job.taskId, value.error
1163
- ? {
1164
- status: 'failed',
1165
- result: value,
1166
- error: value.error,
1167
- terminalReason: 'agent-empty-final',
1168
- }
1169
- : {
1170
- status: 'completed',
1171
- result: value,
1172
- terminalReason: 'agent-terminal-result',
1173
- });
1174
- } catch {}
1175
- }
1176
- },
1177
- } : {}),
1178
- });
1179
- // The early preview no longer promises body suppression, so the canonical
1180
- // notifyTaskCompletion is left to fire exactly once with output via the
1181
- // resolve/reconcile/finally path.
1182
- const finalValue = completionValue(result);
1183
- // Non-job return path (or job path where the terminal-result reconcile
1184
- // already ran): if the finish was abnormal-empty, surface it as a thrown
1185
- // error so finalStatus becomes 'error' and the caller's error path (and
1186
- // the finally reconcile below, as `failed`) render the accurate reason.
1187
- if (finalValue.error) {
1188
- finalStatus = 'error';
1189
- if (job) job._terminalResultValue = finalValue;
1190
- throw new Error(finalValue.error);
1191
- }
1192
- return finalValue;
1193
- } catch (error) {
1194
- const partial = watchdogPartialHandoffFromError(error, mgr.getSession(session.id), handoffMsgStart);
1195
- if (partial) {
1196
- finalStatus = 'idle';
1197
- const value = {
1198
- tag,
1199
- sessionId: session.id,
1200
- agent,
1201
- preset: presetKey(preset) || presetName,
1202
- provider: preset.provider,
1203
- model: preset.model,
1204
- effort: preset.effort || null,
1205
- fast: preset.fast === true,
1206
- content: partial,
1207
- stallAbort: true,
1208
- };
1209
- if (job) job._terminalResultValue = value;
1210
- if (job?.taskId) {
1211
- try {
1212
- reconcileBackgroundTask(job.taskId, {
1213
- status: 'completed',
1214
- result: value,
1215
- terminalReason: 'agent-watchdog-partial',
1216
- });
1217
- } catch {}
1218
- }
1219
- return value;
1220
- }
1221
- finalStatus = 'error';
1222
- // Part C: a mid-stream stall (StreamStalledError / ESTREAMSTALL) throws
1223
- // here WITHOUT a terminal result, so the finally reconcile below (gated on
1224
- // _terminalResultValue) would be skipped and only the outer task-reject
1225
- // path would notify. Belt-and-suspenders: reconcile this job to `failed`
1226
- // now so the owner (Lead) always gets a failure notification instead of a
1227
- // task stranded in `running`. Idempotent — completeBackgroundTask no-ops
1228
- // once terminal, so the outer reject path can't double-notify.
1229
- if (job?.taskId && job._terminalResultValue === undefined) {
1230
- try {
1231
- reconcileBackgroundTask(job.taskId, {
1232
- status: 'failed',
1233
- error,
1234
- terminalReason: 'agent-stream-stalled',
1235
- });
1236
- } catch {}
1237
- }
1238
- throw error;
1239
- } finally {
1240
- turnReview.complete();
1241
- watchdog?.stop?.();
1242
- upsertWorkerSessionDeferred(session, tag, {
1243
- agent,
1244
- preset: presetKey(preset) || presetName,
1245
- provider: preset.provider,
1246
- model: preset.model,
1247
- effort: preset.effort || null,
1248
- fast: preset.fast === true,
1249
- status: finalStatus,
1250
- stage: finalStatus,
1251
- finishedAt: new Date().toISOString(),
1252
- });
1253
- // Safety net: if a post-result step (session save) hung or threw after the
1254
- // worker already produced a terminal result, the task could otherwise be
1255
- // stranded in `running`. Reconcile it to a terminal state using the
1256
- // captured result so the owner gets a completion notification + the
1257
- // statusline clears. Idempotent once the task is already terminal.
1258
- if (job && job._terminalResultValue !== undefined) {
1259
- try {
1260
- reconcileBackgroundTask(job.taskId, {
1261
- status: finalStatus === 'error' ? 'failed' : 'completed',
1262
- result: job._terminalResultValue,
1263
- ...(finalStatus === 'error' && job._terminalResultValue?.error
1264
- ? { error: job._terminalResultValue.error }
1265
- : {}),
1266
- terminalReason: 'agent-finally-reconcile',
1267
- });
1268
- } catch {}
1269
- }
1270
- scheduleReap(session.id);
1271
- // SubagentStop: worker finished (terminal), regardless of outcome.
1272
- emitSubagentEvent('stop', agent, { session_id: session.id, tag, status: finalStatus });
1273
- }
1274
- }
1275
224
 
1276
225
  async function spawn(args) {
1277
226
  return await runSpawn(await prepareSpawn(args));