@dreb/coding-agent 2.34.2 → 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.
- package/CHANGELOG.md +10 -0
- package/README.md +1 -1
- package/dist/cli/args.d.ts +3 -0
- package/dist/cli/args.d.ts.map +1 -1
- package/dist/cli/args.js +3 -0
- package/dist/cli/args.js.map +1 -1
- package/dist/core/agent-session.d.ts +30 -2
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +172 -130
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/buddy/buddy-controller.d.ts +35 -1
- package/dist/core/buddy/buddy-controller.d.ts.map +1 -1
- package/dist/core/buddy/buddy-controller.js +65 -4
- package/dist/core/buddy/buddy-controller.js.map +1 -1
- package/dist/core/buddy/buddy-manager.d.ts +6 -0
- package/dist/core/buddy/buddy-manager.d.ts.map +1 -1
- package/dist/core/buddy/buddy-manager.js +25 -2
- package/dist/core/buddy/buddy-manager.js.map +1 -1
- package/dist/core/export-html/template.js +1 -1
- package/dist/core/footer-data-provider.d.ts.map +1 -1
- package/dist/core/footer-data-provider.js +5 -102
- package/dist/core/footer-data-provider.js.map +1 -1
- package/dist/core/git-branch.d.ts +19 -0
- package/dist/core/git-branch.d.ts.map +1 -0
- package/dist/core/git-branch.js +109 -0
- package/dist/core/git-branch.js.map +1 -0
- package/dist/core/sdk.d.ts.map +1 -1
- package/dist/core/sdk.js +1 -1
- package/dist/core/sdk.js.map +1 -1
- package/dist/core/session-manager.d.ts +21 -0
- package/dist/core/session-manager.d.ts.map +1 -1
- package/dist/core/session-manager.js +117 -42
- package/dist/core/session-manager.js.map +1 -1
- package/dist/core/settings-manager.d.ts +6 -0
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +8 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/{modes/interactive → core}/tab-title.d.ts +12 -3
- package/dist/core/tab-title.d.ts.map +1 -0
- package/dist/{modes/interactive → core}/tab-title.js +58 -16
- package/dist/core/tab-title.js.map +1 -0
- package/dist/core/tools/search.d.ts.map +1 -1
- package/dist/core/tools/search.js +4 -1
- package/dist/core/tools/search.js.map +1 -1
- package/dist/core/tools/subagent.d.ts +63 -2
- package/dist/core/tools/subagent.d.ts.map +1 -1
- package/dist/core/tools/subagent.js +459 -73
- package/dist/core/tools/subagent.js.map +1 -1
- package/dist/core/tools/web.d.ts +1 -1
- package/dist/core/tools/web.d.ts.map +1 -1
- package/dist/core/tools/web.js +60 -16
- package/dist/core/tools/web.js.map +1 -1
- package/dist/main.d.ts +10 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +54 -4
- package/dist/main.js.map +1 -1
- package/dist/modes/index.d.ts +2 -2
- package/dist/modes/index.d.ts.map +1 -1
- package/dist/modes/index.js +1 -1
- package/dist/modes/index.js.map +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts +1 -1
- package/dist/modes/interactive/components/session-selector.d.ts.map +1 -1
- package/dist/modes/interactive/components/session-selector.js +4 -40
- package/dist/modes/interactive/components/session-selector.js.map +1 -1
- package/dist/modes/interactive/interactive-mode.d.ts +1 -0
- package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
- package/dist/modes/interactive/interactive-mode.js +20 -3
- package/dist/modes/interactive/interactive-mode.js.map +1 -1
- package/dist/modes/rpc/index.d.ts +2 -2
- package/dist/modes/rpc/index.d.ts.map +1 -1
- package/dist/modes/rpc/index.js.map +1 -1
- package/dist/modes/rpc/rpc-client.d.ts +114 -6
- package/dist/modes/rpc/rpc-client.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-client.js +140 -2
- package/dist/modes/rpc/rpc-client.js.map +1 -1
- package/dist/modes/rpc/rpc-mode.d.ts +97 -0
- package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-mode.js +656 -27
- package/dist/modes/rpc/rpc-mode.js.map +1 -1
- package/dist/modes/rpc/rpc-types.d.ts +312 -1
- package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
- package/dist/modes/rpc/rpc-types.js.map +1 -1
- package/docs/buddy.md +9 -1
- package/docs/dashboard.md +152 -0
- package/docs/development.md +8 -5
- package/docs/rpc.md +652 -5
- package/examples/sdk/12-full-control.ts +1 -1
- package/package.json +1 -1
- package/dist/modes/interactive/tab-title.d.ts.map +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
|
390
|
-
*
|
|
391
|
-
*
|
|
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
|
|
468
|
+
export function discoverSessionFiles(sessionDir, agentName) {
|
|
394
469
|
try {
|
|
395
470
|
if (!existsSync(sessionDir))
|
|
396
|
-
return
|
|
397
|
-
const
|
|
398
|
-
if (
|
|
399
|
-
|
|
400
|
-
|
|
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
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
|
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");
|