@dreb/coding-agent 2.34.3 → 2.35.0

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 (80) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +1 -1
  3. package/dist/cli/args.d.ts +3 -0
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +3 -0
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +30 -2
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +172 -130
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/export-html/template.js +1 -1
  12. package/dist/core/footer-data-provider.d.ts.map +1 -1
  13. package/dist/core/footer-data-provider.js +5 -102
  14. package/dist/core/footer-data-provider.js.map +1 -1
  15. package/dist/core/git-branch.d.ts +19 -0
  16. package/dist/core/git-branch.d.ts.map +1 -0
  17. package/dist/core/git-branch.js +109 -0
  18. package/dist/core/git-branch.js.map +1 -0
  19. package/dist/core/sdk.d.ts.map +1 -1
  20. package/dist/core/sdk.js +1 -1
  21. package/dist/core/sdk.js.map +1 -1
  22. package/dist/core/session-manager.d.ts +21 -0
  23. package/dist/core/session-manager.d.ts.map +1 -1
  24. package/dist/core/session-manager.js +117 -42
  25. package/dist/core/session-manager.js.map +1 -1
  26. package/dist/core/settings-manager.d.ts +6 -0
  27. package/dist/core/settings-manager.d.ts.map +1 -1
  28. package/dist/core/settings-manager.js +8 -0
  29. package/dist/core/settings-manager.js.map +1 -1
  30. package/dist/{modes/interactive → core}/tab-title.d.ts +12 -3
  31. package/dist/core/tab-title.d.ts.map +1 -0
  32. package/dist/{modes/interactive → core}/tab-title.js +58 -16
  33. package/dist/core/tab-title.js.map +1 -0
  34. package/dist/core/tools/search.d.ts.map +1 -1
  35. package/dist/core/tools/search.js +4 -1
  36. package/dist/core/tools/search.js.map +1 -1
  37. package/dist/core/tools/subagent.d.ts +63 -2
  38. package/dist/core/tools/subagent.d.ts.map +1 -1
  39. package/dist/core/tools/subagent.js +459 -73
  40. package/dist/core/tools/subagent.js.map +1 -1
  41. package/dist/core/tools/web.d.ts +1 -1
  42. package/dist/core/tools/web.d.ts.map +1 -1
  43. package/dist/core/tools/web.js +60 -16
  44. package/dist/core/tools/web.js.map +1 -1
  45. package/dist/main.d.ts +10 -0
  46. package/dist/main.d.ts.map +1 -1
  47. package/dist/main.js +54 -4
  48. package/dist/main.js.map +1 -1
  49. package/dist/modes/index.d.ts +2 -2
  50. package/dist/modes/index.d.ts.map +1 -1
  51. package/dist/modes/index.js +1 -1
  52. package/dist/modes/index.js.map +1 -1
  53. package/dist/modes/interactive/components/session-selector.d.ts +1 -1
  54. package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
  55. package/dist/modes/interactive/components/session-selector.js +4 -40
  56. package/dist/modes/interactive/components/session-selector.js.map +1 -1
  57. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  58. package/dist/modes/interactive/interactive-mode.js +2 -2
  59. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  60. package/dist/modes/rpc/index.d.ts +2 -2
  61. package/dist/modes/rpc/index.d.ts.map +1 -1
  62. package/dist/modes/rpc/index.js.map +1 -1
  63. package/dist/modes/rpc/rpc-client.d.ts +114 -6
  64. package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
  65. package/dist/modes/rpc/rpc-client.js +140 -2
  66. package/dist/modes/rpc/rpc-client.js.map +1 -1
  67. package/dist/modes/rpc/rpc-mode.d.ts +97 -0
  68. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  69. package/dist/modes/rpc/rpc-mode.js +656 -27
  70. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  71. package/dist/modes/rpc/rpc-types.d.ts +312 -1
  72. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  73. package/dist/modes/rpc/rpc-types.js.map +1 -1
  74. package/docs/dashboard.md +152 -0
  75. package/docs/development.md +8 -5
  76. package/docs/rpc.md +652 -5
  77. package/examples/sdk/12-full-control.ts +1 -1
  78. package/package.json +1 -1
  79. package/dist/modes/interactive/tab-title.d.ts.map +0 -1
  80. package/dist/modes/interactive/tab-title.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  import { spawn } from "node:child_process";
2
2
  import { randomBytes } from "node:crypto";
3
- import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
3
+ import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
- import { join, resolve } from "node:path";
5
+ import { basename, join, resolve } from "node:path";
6
6
  import { completeSimple } from "@dreb/ai";
7
7
  import { Text } from "@dreb/tui";
8
8
  import { Type } from "@sinclair/typebox";
@@ -143,7 +143,53 @@ export function filterSubagentTools(tools) {
143
143
  function findDrebBinary() {
144
144
  return DREB_SCRIPT;
145
145
  }
146
- async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentProvider, sessionDir, parentSessionFile) {
146
+ /**
147
+ * Handle one line of a subagent child's JSONL stdout. Parses the line and
148
+ * dispatches to the sinks: full-event relay, assistant-message collection,
149
+ * tool progress, resolved model, and non-JSON passthrough.
150
+ */
151
+ export function handleChildJsonlLine(line, sinks) {
152
+ if (!line.trim())
153
+ return;
154
+ // Separate JSON.parse from event handling so only parse failures
155
+ // are caught as non-JSON lines — errors in handling propagate normally
156
+ let event;
157
+ try {
158
+ event = JSON.parse(line);
159
+ }
160
+ catch {
161
+ // Capture non-JSON lines — on failure these often contain the real error
162
+ // (e.g. startup errors printed before JSONL mode begins)
163
+ sinks.onPlainLine(line.trim());
164
+ if (line.trim().startsWith("{")) {
165
+ log.warn(`[subagent] Failed to parse JSONL event: ${line.slice(0, 200)}`);
166
+ }
167
+ return;
168
+ }
169
+ if (event === null || typeof event !== "object") {
170
+ // Valid JSON but not an event object (e.g. a bare string or number) — treat
171
+ // like a plain line so diagnostic content is preserved.
172
+ sinks.onPlainLine(line.trim());
173
+ return;
174
+ }
175
+ if (typeof event.type === "string") {
176
+ sinks.onEvent?.(event);
177
+ }
178
+ if (event.type === "agent_start" && event.model) {
179
+ sinks.onModel(event.model.id);
180
+ }
181
+ if (event.type === "message_end" && event.message?.role === "assistant") {
182
+ sinks.onAssistantMessage(event.message);
183
+ }
184
+ if (event.type === "tool_execution_start" && sinks.onProgress) {
185
+ sinks.toolNameRef.current = event.toolName || "";
186
+ sinks.onProgress(`Using ${sinks.toolNameRef.current}...`);
187
+ }
188
+ if (event.type === "tool_execution_end" && sinks.onProgress) {
189
+ sinks.onProgress(`${sinks.toolNameRef.current} done`);
190
+ }
191
+ }
192
+ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentProvider, sessionDir, parentSessionFile, onChildEvent) {
147
193
  const drebBin = findDrebBinary();
148
194
  log.debug(`[subagent] spawn: agent=${agentConfig.name} cwd=${cwd}`);
149
195
  // Validate cwd exists — spawn() throws a misleading ENOENT blaming the
@@ -224,7 +270,7 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
224
270
  let stderrSize = 0;
225
271
  const MAX_STDERR_BYTES = 8192;
226
272
  const plainStdoutLines = [];
227
- let lastToolName = "";
273
+ const toolNameRef = { current: "" };
228
274
  let resolvedModel;
229
275
  // Drain stderr concurrently to avoid pipe deadlock (capped to prevent OOM from verbose subagents)
230
276
  proc.stderr?.on("data", (chunk) => {
@@ -243,36 +289,16 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
243
289
  log.warn(`[subagent] stdout stream error (agent=${agentConfig.name}): ${err.message}`);
244
290
  });
245
291
  attachJsonlLineReader(proc.stdout, (line) => {
246
- if (!line.trim())
247
- return;
248
- // Separate JSON.parse from event handling so only parse failures
249
- // are caught as non-JSON lines — errors in handling propagate normally
250
- let event;
251
- try {
252
- event = JSON.parse(line);
253
- }
254
- catch {
255
- // Capture non-JSON lines — on failure these often contain the real error
256
- // (e.g. startup errors printed before JSONL mode begins)
257
- plainStdoutLines.push(line.trim());
258
- if (line.trim().startsWith("{")) {
259
- log.warn(`[subagent] Failed to parse JSONL event: ${line.slice(0, 200)}`);
260
- }
261
- return;
262
- }
263
- if (event.type === "agent_start" && event.model) {
264
- resolvedModel = event.model.id;
265
- }
266
- if (event.type === "message_end" && event.message?.role === "assistant") {
267
- collectedMessages.push(event.message);
268
- }
269
- if (event.type === "tool_execution_start" && onProgress) {
270
- lastToolName = event.toolName || "";
271
- onProgress(`Using ${lastToolName}...`);
272
- }
273
- if (event.type === "tool_execution_end" && onProgress) {
274
- onProgress(`${lastToolName} done`);
275
- }
292
+ handleChildJsonlLine(line, {
293
+ onEvent: onChildEvent,
294
+ onAssistantMessage: (message) => collectedMessages.push(message),
295
+ onProgress,
296
+ onModel: (modelId) => {
297
+ resolvedModel = modelId;
298
+ },
299
+ onPlainLine: (plain) => plainStdoutLines.push(plain),
300
+ toolNameRef,
301
+ });
276
302
  });
277
303
  }
278
304
  // Handle abort signal (guard kill() against ESRCH race if process already exited)
@@ -382,44 +408,108 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
382
408
  });
383
409
  });
384
410
  }
385
- // ---------------------------------------------------------------------------
386
- // Session file discovery and cleanup
387
- // ---------------------------------------------------------------------------
411
+ const STEP_SESSION_DIR_RE = /^step-(\d+)$/;
412
+ function findNewestJsonlFileInDir(dir) {
413
+ let best;
414
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
415
+ if (!entry.name.endsWith(".jsonl"))
416
+ continue;
417
+ if (entry.isDirectory())
418
+ continue;
419
+ const fullPath = join(dir, entry.name);
420
+ try {
421
+ const mtime = statSync(fullPath).mtime.getTime();
422
+ if (!best || mtime > best.mtime)
423
+ best = { path: fullPath, mtime };
424
+ }
425
+ catch (err) {
426
+ if (isExpectedFilesystemError(err))
427
+ continue;
428
+ throw err;
429
+ }
430
+ }
431
+ return best;
432
+ }
433
+ function discoverStepSessionFileCandidates(sessionDir) {
434
+ const steps = [];
435
+ for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
436
+ if (!entry.isDirectory())
437
+ continue;
438
+ const match = STEP_SESSION_DIR_RE.exec(entry.name);
439
+ if (!match)
440
+ continue;
441
+ steps.push({ name: entry.name, index: Number(match[1]) });
442
+ }
443
+ steps.sort((a, b) => a.index - b.index || a.name.localeCompare(b.name));
444
+ const files = [];
445
+ for (const step of steps) {
446
+ try {
447
+ const candidate = findNewestJsonlFileInDir(join(sessionDir, step.name));
448
+ if (candidate)
449
+ files.push(candidate);
450
+ }
451
+ catch (err) {
452
+ if (isExpectedFilesystemError(err))
453
+ continue;
454
+ throw err;
455
+ }
456
+ }
457
+ return files;
458
+ }
388
459
  /**
389
- * Find the most recently modified .jsonl file in a session directory.
390
- * Returns the full path, or undefined if no session file was written
391
- * (e.g., subagent was killed before the first assistant message).
460
+ * Find session JSONL files for a subagent session directory.
461
+ *
462
+ * Normal subagents write one .jsonl directly under sessionDir, so this returns
463
+ * the most recently modified flat file. Chain-mode subagents register the chain
464
+ * root as sessionDir but write each step under step-N/, so when no flat file is
465
+ * present this recurses one level into step-* directories and returns one file
466
+ * per step in numeric step order.
392
467
  */
393
- export function discoverSessionFile(sessionDir, agentName) {
468
+ export function discoverSessionFiles(sessionDir, agentName) {
394
469
  try {
395
470
  if (!existsSync(sessionDir))
396
- return undefined;
397
- const files = readdirSync(sessionDir).filter((f) => f.endsWith(".jsonl"));
398
- if (files.length === 0)
399
- return undefined;
400
- // Pick the most recently modified file (typically there's only one per subagent dir)
401
- let best;
402
- for (const f of files) {
403
- try {
404
- const fullPath = join(sessionDir, f);
405
- const mtime = statSync(fullPath).mtime.getTime();
406
- if (!best || mtime > best.mtime) {
407
- best = { path: fullPath, mtime };
408
- }
409
- }
410
- catch {
411
- // File disappeared or is a bad symlink — skip it, keep any valid candidate
412
- }
471
+ return [];
472
+ const flatFile = findNewestJsonlFileInDir(sessionDir);
473
+ if (flatFile) {
474
+ log.debug(`[subagent] session file: ${flatFile.path} (agent=${agentName})`);
475
+ return [flatFile.path];
413
476
  }
414
- if (best) {
415
- log.debug(`[subagent] session file: ${best.path} (agent=${agentName})`);
416
- return best.path;
477
+ const stepFiles = discoverStepSessionFileCandidates(sessionDir);
478
+ if (stepFiles.length > 0) {
479
+ log.debug(`[subagent] chain session files: ${stepFiles.map((file) => file.path).join(", ")} (agent=${agentName})`);
480
+ return stepFiles.map((file) => file.path);
417
481
  }
418
482
  }
419
483
  catch (err) {
484
+ if (!isExpectedFilesystemError(err))
485
+ throw err;
420
486
  log.warn(`[subagent] failed to discover session file (agent=${agentName}): ${err instanceof Error ? err.message : String(err)}`);
421
487
  }
422
- return undefined;
488
+ return [];
489
+ }
490
+ /**
491
+ * Find the most recently modified .jsonl file in a session directory.
492
+ * Returns the full path, or undefined if no session file was written
493
+ * (e.g., subagent was killed before the first assistant message).
494
+ */
495
+ export function discoverSessionFile(sessionDir, agentName) {
496
+ const files = discoverSessionFiles(sessionDir, agentName);
497
+ if (files.length <= 1)
498
+ return files[0];
499
+ let best;
500
+ for (const file of files) {
501
+ try {
502
+ const candidate = { path: file, mtime: statSync(file).mtime.getTime() };
503
+ if (!best || candidate.mtime > best.mtime)
504
+ best = candidate;
505
+ }
506
+ catch (err) {
507
+ if (isExpectedFilesystemError(err))
508
+ continue;
509
+ throw err;
510
+ }
511
+ }
512
+ return best?.path;
423
513
  }
424
514
  // ---------------------------------------------------------------------------
425
515
  // Execution modes
@@ -714,7 +804,7 @@ function clampCwd(defaultCwd, itemCwd) {
714
804
  }
715
805
  return { ok: true, cwd: resolved };
716
806
  }
717
- export async function executeSingle(agents, agentName, task, cwd, signal, onProgress, modelOverride, parentProvider, registry, sessionDir, parentModel, agentModels, parentSessionFile) {
807
+ export async function executeSingle(agents, agentName, task, cwd, signal, onProgress, modelOverride, parentProvider, registry, sessionDir, parentModel, agentModels, parentSessionFile, onChildEvent) {
718
808
  const name = agentName || DEFAULT_AGENT;
719
809
  const config = agents.get(name);
720
810
  if (!config) {
@@ -773,14 +863,14 @@ export async function executeSingle(agents, agentName, task, cwd, signal, onProg
773
863
  }
774
864
  const usedModel = effectiveConfig.model?.toString();
775
865
  onProgress?.(`Running ${name} agent${usedModel ? ` (${usedModel})` : ""}...`);
776
- const result = await spawnSubagent(effectiveConfig, task, cwd, signal, onProgress, resolvedProvider, sessionDir, parentSessionFile);
866
+ const result = await spawnSubagent(effectiveConfig, task, cwd, signal, onProgress, resolvedProvider, sessionDir, parentSessionFile, onChildEvent);
777
867
  result.output = prependModelFallbackSummary(result.output, skippedModels, result.model ?? usedModel);
778
868
  if (warning) {
779
869
  result.output = `[WARNING: ${warning}]\n\n${result.output}`;
780
870
  }
781
871
  return result;
782
872
  }
783
- async function executeChain(agents, chain, defaultCwd, signal, onProgress, parentProvider, registry, sessionBaseDir, defaultAgent, defaultModel, parentModel, getAgentModelsForAgentFn, parentSessionFile) {
873
+ async function executeChain(agents, chain, defaultCwd, signal, onProgress, parentProvider, registry, sessionBaseDir, defaultAgent, defaultModel, parentModel, getAgentModelsForAgentFn, parentSessionFile, onChildEvent) {
784
874
  const results = [];
785
875
  let previousOutput = "";
786
876
  for (let i = 0; i < chain.length; i++) {
@@ -817,7 +907,7 @@ async function executeChain(agents, chain, defaultCwd, signal, onProgress, paren
817
907
  const stepSessionDir = sessionBaseDir ? join(sessionBaseDir, `step-${i + 1}`) : undefined;
818
908
  const stepAgentName = step.agent || defaultAgent || DEFAULT_AGENT;
819
909
  const stepMach6Models = getAgentModelsForAgentFn?.(stepAgentName);
820
- const result = await executeSingle(agents, step.agent || defaultAgent, task, cwdResult.cwd, signal, onProgress, step.model || defaultModel, parentProvider, registry, stepSessionDir, parentModel, stepMach6Models, parentSessionFile);
910
+ const result = await executeSingle(agents, step.agent || defaultAgent, task, cwdResult.cwd, signal, onProgress, step.model || defaultModel, parentProvider, registry, stepSessionDir, parentModel, stepMach6Models, parentSessionFile, onChildEvent);
821
911
  results.push(result);
822
912
  if (result.exitCode !== 0) {
823
913
  break; // stop chain on error
@@ -834,6 +924,283 @@ function generateAgentId() {
834
924
  }
835
925
  const backgroundAgentRegistry = new Map();
836
926
  const backgroundAbortControllers = new Map();
927
+ const REHYDRATED_AGENT_ID_PREFIX = "rehydrated-";
928
+ const HEADER_READ_CHUNK_BYTES = 8192;
929
+ const MAX_HEADER_READ_BYTES = 256 * 1024;
930
+ const METADATA_READ_BYTES = 256 * 1024;
931
+ const TAIL_READ_BYTES = 64 * 1024;
932
+ function isRecord(value) {
933
+ return typeof value === "object" && value !== null;
934
+ }
935
+ function isExpectedFilesystemError(err) {
936
+ return typeof err?.code === "string";
937
+ }
938
+ function parseJsonlLine(line) {
939
+ try {
940
+ const parsed = JSON.parse(line);
941
+ return isRecord(parsed) ? parsed : undefined;
942
+ }
943
+ catch (err) {
944
+ if (err instanceof SyntaxError)
945
+ return undefined;
946
+ throw err;
947
+ }
948
+ }
949
+ function readFirstNonEmptyLine(filePath) {
950
+ let fd;
951
+ try {
952
+ fd = openSync(filePath, "r");
953
+ const chunk = Buffer.alloc(HEADER_READ_CHUNK_BYTES);
954
+ let buffered = "";
955
+ let bytesReadTotal = 0;
956
+ while (bytesReadTotal < MAX_HEADER_READ_BYTES) {
957
+ const bytesRead = readSync(fd, chunk, 0, Math.min(chunk.length, MAX_HEADER_READ_BYTES - bytesReadTotal), null);
958
+ if (bytesRead === 0)
959
+ break;
960
+ bytesReadTotal += bytesRead;
961
+ buffered += chunk.toString("utf8", 0, bytesRead);
962
+ const lines = buffered.split(/\r?\n/);
963
+ const completeLines = buffered.endsWith("\n") || buffered.endsWith("\r") ? lines : lines.slice(0, -1);
964
+ for (const line of completeLines) {
965
+ if (line.trim())
966
+ return line;
967
+ }
968
+ buffered = lines.at(-1) ?? "";
969
+ }
970
+ return buffered.trim() ? buffered : undefined;
971
+ }
972
+ catch (err) {
973
+ if (isExpectedFilesystemError(err))
974
+ return undefined;
975
+ throw err;
976
+ }
977
+ finally {
978
+ if (fd !== undefined) {
979
+ try {
980
+ closeSync(fd);
981
+ }
982
+ catch {
983
+ // Best-effort observability: close errors are filesystem cleanup noise.
984
+ }
985
+ }
986
+ }
987
+ }
988
+ function readFileSlice(filePath, start, length) {
989
+ let fd;
990
+ try {
991
+ fd = openSync(filePath, "r");
992
+ const buffer = Buffer.alloc(length);
993
+ const bytesRead = readSync(fd, buffer, 0, length, start);
994
+ return buffer.toString("utf8", 0, bytesRead);
995
+ }
996
+ catch (err) {
997
+ if (isExpectedFilesystemError(err))
998
+ return undefined;
999
+ throw err;
1000
+ }
1001
+ finally {
1002
+ if (fd !== undefined) {
1003
+ try {
1004
+ closeSync(fd);
1005
+ }
1006
+ catch {
1007
+ // Best-effort observability: close errors are filesystem cleanup noise.
1008
+ }
1009
+ }
1010
+ }
1011
+ }
1012
+ function readFileStart(filePath, maxBytes) {
1013
+ let size = maxBytes;
1014
+ try {
1015
+ size = Math.min(statSync(filePath).size, maxBytes);
1016
+ }
1017
+ catch (err) {
1018
+ if (isExpectedFilesystemError(err))
1019
+ return undefined;
1020
+ throw err;
1021
+ }
1022
+ return readFileSlice(filePath, 0, size);
1023
+ }
1024
+ function readFileTail(filePath, maxBytes) {
1025
+ let start = 0;
1026
+ let length = maxBytes;
1027
+ try {
1028
+ const size = statSync(filePath).size;
1029
+ start = Math.max(0, size - maxBytes);
1030
+ length = size - start;
1031
+ }
1032
+ catch (err) {
1033
+ if (isExpectedFilesystemError(err))
1034
+ return undefined;
1035
+ throw err;
1036
+ }
1037
+ return readFileSlice(filePath, start, length);
1038
+ }
1039
+ function parseSessionHeader(sessionFile) {
1040
+ const headerLine = readFirstNonEmptyLine(sessionFile);
1041
+ if (!headerLine)
1042
+ return undefined;
1043
+ return parseJsonlLine(headerLine);
1044
+ }
1045
+ function comparablePath(pathValue) {
1046
+ const resolved = resolve(pathValue);
1047
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
1048
+ }
1049
+ function parentSessionMatches(recordedParentSession, parentSessionFile) {
1050
+ if (recordedParentSession === parentSessionFile)
1051
+ return true;
1052
+ if (comparablePath(recordedParentSession) === comparablePath(parentSessionFile))
1053
+ return true;
1054
+ // Older/foreign invocations may disagree about absolute vs relative roots, but
1055
+ // dreb session filenames include a timestamp and UUID. A basename match is a
1056
+ // safe final fallback for recovering observability across process boundaries.
1057
+ return basename(recordedParentSession) === basename(parentSessionFile);
1058
+ }
1059
+ function extractTextFromContent(content) {
1060
+ if (typeof content === "string")
1061
+ return content.trim() || undefined;
1062
+ if (!Array.isArray(content))
1063
+ return undefined;
1064
+ const parts = [];
1065
+ for (const item of content) {
1066
+ if (!isRecord(item))
1067
+ continue;
1068
+ if (item.type === "text" && typeof item.text === "string" && item.text.trim()) {
1069
+ parts.push(item.text.trim());
1070
+ }
1071
+ }
1072
+ const text = parts.join("\n").trim();
1073
+ return text || undefined;
1074
+ }
1075
+ function truncateTaskSummary(text) {
1076
+ const normalized = text.replace(/\s+/g, " ").trim();
1077
+ return normalized.length > 120 ? `${normalized.slice(0, 117)}...` : normalized;
1078
+ }
1079
+ function findFirstUserMessageSummary(sessionFile) {
1080
+ const head = readFileStart(sessionFile, METADATA_READ_BYTES);
1081
+ if (!head)
1082
+ return undefined;
1083
+ for (const line of head.split(/\r?\n/)) {
1084
+ if (!line.trim())
1085
+ continue;
1086
+ const entry = parseJsonlLine(line);
1087
+ if (entry?.type !== "message" || !isRecord(entry.message))
1088
+ continue;
1089
+ if (entry.message.role !== "user")
1090
+ continue;
1091
+ const text = extractTextFromContent(entry.message.content);
1092
+ if (text)
1093
+ return truncateTaskSummary(text);
1094
+ }
1095
+ return undefined;
1096
+ }
1097
+ function inferCompletedSessionStatus(sessionFile) {
1098
+ const tail = readFileTail(sessionFile, TAIL_READ_BYTES);
1099
+ if (!tail)
1100
+ return "completed";
1101
+ const lines = tail.split(/\r?\n/).reverse();
1102
+ for (const line of lines) {
1103
+ if (!line.trim())
1104
+ continue;
1105
+ const entry = parseJsonlLine(line);
1106
+ if (entry?.type !== "message" || !isRecord(entry.message))
1107
+ continue;
1108
+ if (entry.message.role !== "assistant")
1109
+ continue;
1110
+ const stopReason = entry.message.stopReason;
1111
+ return stopReason === "error" || stopReason === "aborted" ? "failed" : "completed";
1112
+ }
1113
+ return "completed";
1114
+ }
1115
+ function parseStartedAt(header, sessionFile) {
1116
+ if (typeof header.timestamp === "string") {
1117
+ const timestamp = Date.parse(header.timestamp);
1118
+ if (Number.isFinite(timestamp))
1119
+ return timestamp;
1120
+ }
1121
+ try {
1122
+ return statSync(sessionFile).mtime.getTime();
1123
+ }
1124
+ catch (err) {
1125
+ if (isExpectedFilesystemError(err))
1126
+ return Date.now();
1127
+ throw err;
1128
+ }
1129
+ }
1130
+ function hasRegisteredSession(sessionDir, sessionFile) {
1131
+ const comparableSessionDir = comparablePath(sessionDir);
1132
+ const comparableSessionFile = comparablePath(sessionFile);
1133
+ for (const agent of backgroundAgentRegistry.values()) {
1134
+ if (agent.sessionDir && comparablePath(agent.sessionDir) === comparableSessionDir)
1135
+ return true;
1136
+ if (agent.sessionFile && comparablePath(agent.sessionFile) === comparableSessionFile)
1137
+ return true;
1138
+ }
1139
+ return false;
1140
+ }
1141
+ /**
1142
+ * Best-effort recovery for completed background subagents after a dashboard/RPC
1143
+ * process resumes an existing parent session. Live background-agent state is an
1144
+ * in-memory registry, while child sessions are durable JSONL files under the
1145
+ * subagent sessions directory.
1146
+ *
1147
+ * Returns the number of newly registered agents. Expected filesystem and JSONL
1148
+ * parse failures are skipped; unexpected programming errors are allowed to throw.
1149
+ */
1150
+ export function rehydrateBackgroundAgentsFromDisk(parentSessionFile, subagentSessionsBase = getSubagentSessionsDir()) {
1151
+ if (!parentSessionFile)
1152
+ return 0;
1153
+ let entries;
1154
+ try {
1155
+ entries = readdirSync(subagentSessionsBase, { withFileTypes: true });
1156
+ }
1157
+ catch (err) {
1158
+ if (isExpectedFilesystemError(err))
1159
+ return 0;
1160
+ throw err;
1161
+ }
1162
+ let registered = 0;
1163
+ for (const entry of entries) {
1164
+ if (!entry.isDirectory())
1165
+ continue;
1166
+ const sessionDir = join(subagentSessionsBase, entry.name);
1167
+ const sessionFiles = discoverSessionFiles(sessionDir, entry.name);
1168
+ if (sessionFiles.length === 0)
1169
+ continue;
1170
+ let sessionFile;
1171
+ let header;
1172
+ for (const candidateFile of sessionFiles) {
1173
+ const candidateHeader = parseSessionHeader(candidateFile);
1174
+ if (candidateHeader?.type !== "session" || typeof candidateHeader.parentSession !== "string")
1175
+ continue;
1176
+ if (!parentSessionMatches(candidateHeader.parentSession, parentSessionFile))
1177
+ continue;
1178
+ sessionFile = candidateFile;
1179
+ header = candidateHeader;
1180
+ break;
1181
+ }
1182
+ if (!sessionFile || !header)
1183
+ continue;
1184
+ const agentId = `${REHYDRATED_AGENT_ID_PREFIX}${entry.name}`;
1185
+ if (backgroundAgentRegistry.has(agentId) || hasRegisteredSession(sessionDir, sessionFile))
1186
+ continue;
1187
+ const statusFile = sessionFiles[sessionFiles.length - 1] ?? sessionFile;
1188
+ const agentType = typeof header.agentType === "string" && header.agentType.trim() ? header.agentType : "agent";
1189
+ const taskSummary = findFirstUserMessageSummary(sessionFile) ?? `${agentType} (${entry.name})`;
1190
+ backgroundAgentRegistry.set(agentId, {
1191
+ agentId,
1192
+ agentType,
1193
+ taskSummary,
1194
+ startedAt: parseStartedAt(header, sessionFile),
1195
+ status: inferCompletedSessionStatus(statusFile),
1196
+ sessionDir,
1197
+ sessionFile,
1198
+ cwd: typeof header.cwd === "string" ? header.cwd : undefined,
1199
+ });
1200
+ registered++;
1201
+ }
1202
+ return registered;
1203
+ }
837
1204
  /** Get a snapshot of all tracked background agents (running and recently completed). Returns readonly clones. */
838
1205
  export function getBackgroundAgents() {
839
1206
  return [...backgroundAgentRegistry.values()].map((a) => ({ ...a }));
@@ -980,6 +1347,7 @@ export function formatSingleResult(result) {
980
1347
  export function createSubagentToolDefinition(cwd, options) {
981
1348
  const onBackgroundStart = options?.onBackgroundStart;
982
1349
  const onBackgroundComplete = options?.onBackgroundComplete;
1350
+ const onBackgroundEvent = options?.onBackgroundEvent;
983
1351
  const getParentProvider = options?.parentProvider ?? (() => undefined);
984
1352
  const getParentModel = options?.parentModel ?? (() => undefined);
985
1353
  const getParentSessionFile = options?.parentSessionFile ?? (() => undefined);
@@ -1058,9 +1426,11 @@ export function createSubagentToolDefinition(cwd, options) {
1058
1426
  * Shared lifecycle for all background launches: generates agent ID,
1059
1427
  * sets up registry/abort/notification, gates on the concurrency
1060
1428
  * semaphore, and handles errors. The caller provides the actual
1061
- * work via `runFn(signal)` which must return a SubagentResult.
1429
+ * work via `runFn(signal, onChildEvent)` which must return a
1430
+ * SubagentResult; `onChildEvent` (when defined) must be forwarded to
1431
+ * the spawn so the child's JSONL events reach the relay.
1062
1432
  */
1063
- const launchBackgroundLifecycle = (agentName, taskSummary, runFn) => {
1433
+ const launchBackgroundLifecycle = (agentName, taskSummary, sessionDir, agentCwd, runFn) => {
1064
1434
  const agentId = generateAgentId();
1065
1435
  const bgAbort = new AbortController();
1066
1436
  backgroundAgentRegistry.set(agentId, {
@@ -1069,9 +1439,23 @@ export function createSubagentToolDefinition(cwd, options) {
1069
1439
  taskSummary,
1070
1440
  startedAt: Date.now(),
1071
1441
  status: "running",
1442
+ sessionDir,
1443
+ cwd: agentCwd,
1072
1444
  });
1073
1445
  backgroundAbortControllers.set(agentId, bgAbort);
1074
- onBackgroundStart?.(agentId, agentName, taskSummary);
1446
+ onBackgroundStart?.(agentId, agentName, taskSummary, sessionDir);
1447
+ // Relay child JSONL events tagged with this agent's ID. Guarded so a
1448
+ // throwing relay listener can never kill the stdout reader.
1449
+ const onChildEvent = onBackgroundEvent
1450
+ ? (event) => {
1451
+ try {
1452
+ onBackgroundEvent(agentId, event);
1453
+ }
1454
+ catch (err) {
1455
+ log.warn(`[subagent] onBackgroundEvent threw for agent ${agentId}: ${err instanceof Error ? err.message : String(err)}`);
1456
+ }
1457
+ }
1458
+ : undefined;
1075
1459
  const bgSignal = bgAbort.signal;
1076
1460
  const safeNotify = (result) => {
1077
1461
  try {
@@ -1084,10 +1468,12 @@ export function createSubagentToolDefinition(cwd, options) {
1084
1468
  const run = async () => {
1085
1469
  await bgAcquire();
1086
1470
  try {
1087
- const result = await runFn(bgSignal);
1471
+ const result = await runFn(bgSignal, onChildEvent);
1088
1472
  const entry = backgroundAgentRegistry.get(agentId);
1089
1473
  if (entry && !bgSignal.aborted)
1090
1474
  entry.status = result.exitCode === 0 ? "completed" : "failed";
1475
+ if (entry && result.sessionFile)
1476
+ entry.sessionFile = result.sessionFile;
1091
1477
  backgroundAbortControllers.delete(agentId);
1092
1478
  safeNotify(result);
1093
1479
  }
@@ -1139,7 +1525,7 @@ export function createSubagentToolDefinition(cwd, options) {
1139
1525
  const sessionId = generateAgentId();
1140
1526
  const sessionDir = join(subagentSessionsBase, sessionId);
1141
1527
  const agentModels = getAgentModelsForAgent?.(agentName || DEFAULT_AGENT);
1142
- return launchBackgroundLifecycle(agentName, taskLabel, (signal) => executeSingle(agents, agentName === DEFAULT_AGENT ? undefined : agentName, task, resolvedCwd, signal, undefined, modelOverride, getParentProvider(), modelRegistry, sessionDir, getParentModel(), agentModels, getParentSessionFile()));
1528
+ return launchBackgroundLifecycle(agentName, taskLabel, sessionDir, resolvedCwd, (signal, onChildEvent) => executeSingle(agents, agentName === DEFAULT_AGENT ? undefined : agentName, task, resolvedCwd, signal, undefined, modelOverride, getParentProvider(), modelRegistry, sessionDir, getParentModel(), agentModels, getParentSessionFile(), onChildEvent));
1143
1529
  };
1144
1530
  if (params.task) {
1145
1531
  // Single background task
@@ -1204,8 +1590,8 @@ export function createSubagentToolDefinition(cwd, options) {
1204
1590
  const taskSummary = `${params.chain.length}-step chain`;
1205
1591
  const chainSteps = params.chain;
1206
1592
  const chainSessionDir = join(subagentSessionsBase, `chain-${generateAgentId()}`);
1207
- const agentId = launchBackgroundLifecycle(agentName, taskSummary, async (signal) => {
1208
- const results = await executeChain(agents, chainSteps, cwd, signal, undefined, getParentProvider(), modelRegistry, chainSessionDir, params.agent, params.model, getParentModel(), getAgentModelsForAgent, getParentSessionFile());
1593
+ const agentId = launchBackgroundLifecycle(agentName, taskSummary, chainSessionDir, cwd, async (signal, onChildEvent) => {
1594
+ const results = await executeChain(agents, chainSteps, cwd, signal, undefined, getParentProvider(), modelRegistry, chainSessionDir, params.agent, params.model, getParentModel(), getAgentModelsForAgent, getParentSessionFile(), onChildEvent);
1209
1595
  const resultText = results
1210
1596
  .map((r, i) => `### Step ${i + 1}\n${formatSingleResult(r)}`)
1211
1597
  .join("\n\n---\n\n");