@dreb/coding-agent 2.56.0 → 2.57.1

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.
@@ -8,7 +8,7 @@ import { Text } from "@dreb/tui";
8
8
  import { Type } from "@sinclair/typebox";
9
9
  import { CONFIG_DIR_NAME, getPackageDir, getSubagentSessionsDir } from "../../config.js";
10
10
  import { keyHint } from "../../modes/interactive/components/keybinding-hints.js";
11
- import { attachJsonlLineReader } from "../../modes/rpc/jsonl.js";
11
+ import { attachJsonlLineReader, serializeJsonLine } from "../../modes/rpc/jsonl.js";
12
12
  import { log } from "../logger.js";
13
13
  import { resolveCliModel } from "../model-resolver.js";
14
14
  import { resolveEffectiveThinkingLevel, thinkingLevelToReasoning, validateThinkingLevelForModel } from "../thinking.js";
@@ -226,7 +226,7 @@ export function handleChildJsonlLine(line, sinks) {
226
226
  sinks.onProgress(`${sinks.toolNameRef.current} done`);
227
227
  }
228
228
  }
229
- async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentProvider, sessionDir, parentSessionFile, onChildEvent, thinkingOverride) {
229
+ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentProvider, sessionDir, parentSessionFile, onChildEvent, thinkingOverride, onControlAvailable) {
230
230
  const drebBin = findDrebBinary();
231
231
  log.debug(`[subagent] spawn: agent=${agentConfig.name} cwd=${cwd}`);
232
232
  // Validate cwd exists — spawn() throws a misleading ENOENT blaming the
@@ -276,6 +276,12 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
276
276
  args.push("--parent-session", parentSessionFile);
277
277
  }
278
278
  args.push("-p", task);
279
+ if (onControlAvailable) {
280
+ const rpcModeIndex = args.indexOf("json");
281
+ if (rpcModeIndex !== -1)
282
+ args[rpcModeIndex] = "rpc";
283
+ args.splice(args.length - 2, 2);
284
+ }
279
285
  // Early abort check — if the signal is already aborted (e.g. queued task whose
280
286
  // AbortController was aborted while waiting on bgAcquire), bail out before
281
287
  // spawning a child process that can never be killed. addEventListener("abort")
@@ -295,7 +301,7 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
295
301
  try {
296
302
  proc = spawn(NODE_EXEC, [drebBin, ...args], {
297
303
  cwd,
298
- stdio: ["ignore", "pipe", "pipe"],
304
+ stdio: [onControlAvailable ? "pipe" : "ignore", "pipe", "pipe"],
299
305
  env: { ...process.env },
300
306
  });
301
307
  }
@@ -313,6 +319,56 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
313
319
  const toolNameRef = { current: "" };
314
320
  let resolvedModel;
315
321
  let resolvedThinking;
322
+ let rpcRequestId = 0;
323
+ let rpcCompleted = false;
324
+ let controlError;
325
+ const pendingRpc = new Map();
326
+ const sendRpc = (command) => {
327
+ if (!proc.stdin?.writable)
328
+ return Promise.reject(new Error("Subagent control channel is unavailable."));
329
+ const id = `subagent-${++rpcRequestId}`;
330
+ proc.stdin.write(serializeJsonLine({ ...command, id }));
331
+ return new Promise((resolve, reject) => pendingRpc.set(id, { resolve, reject }));
332
+ };
333
+ const controlClient = onControlAvailable
334
+ ? {
335
+ steer: async (message) => {
336
+ await sendRpc({ type: "steer", message });
337
+ },
338
+ getPendingMessages: async () => sendRpc({ type: "get_pending_messages" }),
339
+ getState: async () => sendRpc({ type: "get_state" }),
340
+ }
341
+ : undefined;
342
+ // Terminally fail a controlled child: remember the first failure, reject all
343
+ // in-flight control requests, and stop the process (with the same SIGKILL
344
+ // backstop as the abort path) so the close handler settles instead of the
345
+ // child idling in RPC mode forever.
346
+ const failControlledChild = (err) => {
347
+ if (!controlError)
348
+ controlError = err;
349
+ for (const request of pendingRpc.values())
350
+ request.reject(err);
351
+ pendingRpc.clear();
352
+ try {
353
+ proc.kill("SIGTERM");
354
+ }
355
+ catch {
356
+ /* process already exited */
357
+ }
358
+ killTimer ??= setTimeout(() => {
359
+ try {
360
+ if (!proc.killed)
361
+ proc.kill("SIGKILL");
362
+ }
363
+ catch {
364
+ /* process already exited */
365
+ }
366
+ }, 5000);
367
+ };
368
+ proc.stdin?.on("error", (err) => {
369
+ log.warn(`[subagent] stdin stream error (agent=${agentConfig.name}): ${err.message}`);
370
+ failControlledChild(err);
371
+ });
316
372
  // Drain stderr concurrently to avoid pipe deadlock (capped to prevent OOM from verbose subagents)
317
373
  proc.stderr?.on("data", (chunk) => {
318
374
  if (stderrSize < MAX_STDERR_BYTES) {
@@ -330,6 +386,36 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
330
386
  log.warn(`[subagent] stdout stream error (agent=${agentConfig.name}): ${err.message}`);
331
387
  });
332
388
  attachJsonlLineReader(proc.stdout, (line) => {
389
+ if (onControlAvailable) {
390
+ try {
391
+ const message = JSON.parse(line);
392
+ if (message?.type === "agent_end" && !rpcCompleted) {
393
+ rpcCompleted = true;
394
+ setImmediate(() => proc.kill("SIGTERM"));
395
+ }
396
+ if (message?.type === "response" && typeof message.id === "string") {
397
+ const pending = pendingRpc.get(message.id);
398
+ if (pending) {
399
+ pendingRpc.delete(message.id);
400
+ if (message.success)
401
+ pending.resolve(message.data);
402
+ else
403
+ pending.reject(new Error(message.error ?? "Subagent RPC command failed."));
404
+ }
405
+ else if (message.command === "prompt" && message.success === false && !rpcCompleted) {
406
+ // Late failure of the initial prompt: the child acknowledged the
407
+ // command synchronously, then failed before the agent loop started
408
+ // (e.g. model/API-key validation, or the task was consumed without
409
+ // starting the loop), so no agent_end will ever settle this process.
410
+ failControlledChild(new Error(typeof message.error === "string" ? message.error : "Subagent prompt failed."));
411
+ }
412
+ return;
413
+ }
414
+ }
415
+ catch {
416
+ // The normal line handler preserves non-JSON diagnostics.
417
+ }
418
+ }
333
419
  handleChildJsonlLine(line, {
334
420
  onEvent: onChildEvent,
335
421
  onAssistantMessage: (message) => collectedMessages.push(message),
@@ -345,6 +431,16 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
345
431
  });
346
432
  });
347
433
  }
434
+ if (onControlAvailable && controlClient) {
435
+ onControlAvailable(controlClient);
436
+ void sendRpc({ type: "prompt", message: task }).catch((err) => {
437
+ const error = err instanceof Error ? err : new Error(String(err));
438
+ log.warn(`[subagent] initial RPC prompt failed (agent=${agentConfig.name}): ${error.message}`);
439
+ // A rejected initial prompt means the agent loop never starts and the
440
+ // child would idle in RPC mode forever — terminate it and fail loudly.
441
+ failControlledChild(error);
442
+ });
443
+ }
348
444
  // Handle abort signal (guard kill() against ESRCH race if process already exited)
349
445
  const onAbort = () => {
350
446
  try {
@@ -368,6 +464,10 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
368
464
  if (settled)
369
465
  return;
370
466
  settled = true;
467
+ onControlAvailable?.(undefined);
468
+ for (const request of pendingRpc.values())
469
+ request.reject(err);
470
+ pendingRpc.clear();
371
471
  if (killTimer)
372
472
  clearTimeout(killTimer);
373
473
  signal?.removeEventListener("abort", onAbort);
@@ -377,10 +477,14 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
377
477
  if (settled)
378
478
  return;
379
479
  settled = true;
480
+ onControlAvailable?.(undefined);
481
+ for (const request of pendingRpc.values())
482
+ request.reject(new Error("Subagent process exited."));
483
+ pendingRpc.clear();
380
484
  if (killTimer)
381
485
  clearTimeout(killTimer);
382
486
  signal?.removeEventListener("abort", onAbort);
383
- const exitCode = code ?? 1;
487
+ const exitCode = rpcCompleted ? 0 : (code ?? 1);
384
488
  const stderr = stderrChunks.join("");
385
489
  log.debug(`[subagent] close: agent=${agentConfig.name} exit=${exitCode} messages=${collectedMessages.length}${exitCode !== 0 ? ` stderr=${stderr.slice(0, 200)} stdout=${plainStdoutLines.join("|").slice(0, 200)}` : ""}`);
386
490
  // Extract final text output from collected assistant messages
@@ -407,7 +511,10 @@ async function spawnSubagent(agentConfig, task, cwd, signal, onProgress, parentP
407
511
  const stderrTrimmed = stderr.trim();
408
512
  const plainOutput = plainStdoutLines.join("\n").trim();
409
513
  errorMessage =
410
- stderrTrimmed.slice(0, 500) || plainOutput.slice(0, 500) || `Subagent exited with code ${exitCode}`;
514
+ controlError?.message.slice(0, 500) ||
515
+ stderrTrimmed.slice(0, 500) ||
516
+ plainOutput.slice(0, 500) ||
517
+ `Subagent exited with code ${exitCode}`;
411
518
  }
412
519
  else if (output.trim() === "") {
413
520
  // Clean exit but no output — surface why instead of returning a silent empty result.
@@ -853,7 +960,7 @@ function clampCwd(defaultCwd, itemCwd) {
853
960
  }
854
961
  return { ok: true, cwd: resolved };
855
962
  }
856
- export async function executeSingle(agents, agentName, task, cwd, signal, onProgress, modelOverride, parentProvider, registry, sessionDir, parentModel, agentModels, parentSessionFile, onChildEvent, thinkingOverride, arbitration) {
963
+ export async function executeSingle(agents, agentName, task, cwd, signal, onProgress, modelOverride, parentProvider, registry, sessionDir, parentModel, agentModels, parentSessionFile, onChildEvent, thinkingOverride, arbitration, onControlAvailable) {
857
964
  let name = agentName || DEFAULT_AGENT;
858
965
  let config = agents.get(name);
859
966
  if (!config) {
@@ -1086,7 +1193,7 @@ export async function executeSingle(agents, agentName, task, cwd, signal, onProg
1086
1193
  }
1087
1194
  const usedModel = effectiveConfig.model?.toString();
1088
1195
  onProgress?.(`Running ${name} agent${usedModel ? ` (${usedModel})` : ""}...`);
1089
- const result = await spawnSubagent(effectiveConfig, task, cwd, signal, onProgress, resolvedProvider, sessionDir, parentSessionFile, onChildEvent, finalThinking);
1196
+ const result = await spawnSubagent(effectiveConfig, task, cwd, signal, onProgress, resolvedProvider, sessionDir, parentSessionFile, onChildEvent, finalThinking, onControlAvailable);
1090
1197
  const finalSelectedModel = result.model ?? (usedModel ? canonicalModelRef(resolvedProvider, usedModel) : undefined);
1091
1198
  result.output = prependModelFallbackSummary(result.output, skippedModels, arbitrationEnabled ? proposalSelectedModel : finalSelectedModel, arbitrationEnabled ? finalSelectedModel : undefined);
1092
1199
  if (warning) {
@@ -1095,7 +1202,7 @@ export async function executeSingle(agents, agentName, task, cwd, signal, onProg
1095
1202
  }
1096
1203
  return result;
1097
1204
  }
1098
- async function executeChain(agents, chain, defaultCwd, signal, onProgress, parentProvider, registry, sessionBaseDir, defaultAgent, defaultModel, defaultThinking, parentModel, getAgentModelsForAgentFn, parentSessionFile, onChildEvent, arbitration) {
1205
+ async function executeChain(agents, chain, defaultCwd, signal, onProgress, parentProvider, registry, sessionBaseDir, defaultAgent, defaultModel, defaultThinking, parentModel, getAgentModelsForAgentFn, parentSessionFile, onChildEvent, arbitration, onControlAvailable) {
1099
1206
  const results = [];
1100
1207
  let previousOutput = "";
1101
1208
  for (let i = 0; i < chain.length; i++) {
@@ -1132,7 +1239,7 @@ async function executeChain(agents, chain, defaultCwd, signal, onProgress, paren
1132
1239
  const stepSessionDir = sessionBaseDir ? join(sessionBaseDir, `step-${i + 1}`) : undefined;
1133
1240
  const stepAgentName = step.agent || defaultAgent || DEFAULT_AGENT;
1134
1241
  const stepMach6Models = getAgentModelsForAgentFn?.(stepAgentName);
1135
- const result = await executeSingle(agents, step.agent || defaultAgent, task, cwdResult.cwd, signal, onProgress, step.model || defaultModel, parentProvider, registry, stepSessionDir, parentModel, stepMach6Models, parentSessionFile, onChildEvent, resolveSubagentThinkingOverride(step.thinking, defaultThinking), arbitration ? { ...arbitration, step: i + 1 } : undefined);
1242
+ const result = await executeSingle(agents, step.agent || defaultAgent, task, cwdResult.cwd, signal, onProgress, step.model || defaultModel, parentProvider, registry, stepSessionDir, parentModel, stepMach6Models, parentSessionFile, onChildEvent, resolveSubagentThinkingOverride(step.thinking, defaultThinking), arbitration ? { ...arbitration, step: i + 1 } : undefined, onControlAvailable);
1136
1243
  results.push(result);
1137
1244
  if (result.exitCode !== 0) {
1138
1245
  break; // stop chain on error
@@ -1149,6 +1256,7 @@ function generateAgentId() {
1149
1256
  }
1150
1257
  const backgroundAgentRegistry = new Map();
1151
1258
  const backgroundAbortControllers = new Map();
1259
+ const backgroundControlClients = new Map();
1152
1260
  const REHYDRATED_AGENT_ID_PREFIX = "rehydrated-";
1153
1261
  const HEADER_READ_CHUNK_BYTES = 8192;
1154
1262
  const MAX_HEADER_READ_BYTES = 256 * 1024;
@@ -1440,6 +1548,27 @@ export function getBackgroundAgents() {
1440
1548
  export function getRunningBackgroundAgents() {
1441
1549
  return [...backgroundAgentRegistry.values()].filter((a) => a.status === "running").map(cloneBackgroundAgentInfo);
1442
1550
  }
1551
+ function getBackgroundControlClient(agentId) {
1552
+ const info = backgroundAgentRegistry.get(agentId);
1553
+ if (!info)
1554
+ throw new Error(`Unknown background agent "${agentId}".`);
1555
+ if (info.status !== "running")
1556
+ throw new Error(`Background agent "${agentId}" is no longer running.`);
1557
+ const client = backgroundControlClients.get(agentId);
1558
+ if (!client)
1559
+ throw new Error(`Background agent "${agentId}" has not started a controllable child yet.`);
1560
+ return client;
1561
+ }
1562
+ /** Queue the user's message unchanged in the selected live child session. */
1563
+ export async function steerBackgroundAgent(agentId, message) {
1564
+ await getBackgroundControlClient(agentId).steer(message);
1565
+ }
1566
+ /** Read pending steering messages and the effective delivery mode from the selected live child. */
1567
+ export async function getBackgroundAgentPendingSteering(agentId) {
1568
+ const client = getBackgroundControlClient(agentId);
1569
+ const [state, pending] = await Promise.all([client.getState(), client.getPendingMessages()]);
1570
+ return { steeringMode: state.steeringMode, pending };
1571
+ }
1443
1572
  /** Abort all running background agents. */
1444
1573
  export function abortBackgroundAgents() {
1445
1574
  for (const [id, controller] of backgroundAbortControllers) {
@@ -1450,6 +1579,7 @@ export function abortBackgroundAgents() {
1450
1579
  }
1451
1580
  }
1452
1581
  backgroundAbortControllers.clear();
1582
+ backgroundControlClients.clear();
1453
1583
  }
1454
1584
  /** Remove completed/failed entries older than the given age (ms). Default: 5 minutes. */
1455
1585
  export function pruneBackgroundAgents(maxAgeMs = 5 * 60 * 1000) {
@@ -1458,6 +1588,7 @@ export function pruneBackgroundAgents(maxAgeMs = 5 * 60 * 1000) {
1458
1588
  if (info.status !== "running" && now - info.startedAt > maxAgeMs) {
1459
1589
  backgroundAgentRegistry.delete(id);
1460
1590
  backgroundAbortControllers.delete(id);
1591
+ backgroundControlClients.delete(id);
1461
1592
  }
1462
1593
  }
1463
1594
  }
@@ -1732,6 +1863,12 @@ export function createSubagentToolDefinition(cwd, options) {
1732
1863
  onArbitration?.({ type: "subagent_arbitration", agentId, ...record });
1733
1864
  };
1734
1865
  const bgSignal = bgAbort.signal;
1866
+ const onControlAvailable = (client) => {
1867
+ if (client)
1868
+ backgroundControlClients.set(agentId, client);
1869
+ else
1870
+ backgroundControlClients.delete(agentId);
1871
+ };
1735
1872
  const safeNotify = (result) => {
1736
1873
  try {
1737
1874
  onBackgroundComplete(agentId, result, bgSignal.aborted);
@@ -1743,7 +1880,7 @@ export function createSubagentToolDefinition(cwd, options) {
1743
1880
  const run = async () => {
1744
1881
  await bgAcquire();
1745
1882
  try {
1746
- const result = await runFn(bgSignal, onChildEvent, onArbitrationRecord);
1883
+ const result = await runFn(bgSignal, onChildEvent, onArbitrationRecord, onControlAvailable);
1747
1884
  const entry = backgroundAgentRegistry.get(agentId);
1748
1885
  if (entry && !bgSignal.aborted)
1749
1886
  entry.status = result.exitCode === 0 ? "completed" : "failed";
@@ -1767,6 +1904,7 @@ export function createSubagentToolDefinition(cwd, options) {
1767
1904
  });
1768
1905
  }
1769
1906
  finally {
1907
+ backgroundControlClients.delete(agentId);
1770
1908
  bgRelease();
1771
1909
  }
1772
1910
  };
@@ -1800,14 +1938,14 @@ export function createSubagentToolDefinition(cwd, options) {
1800
1938
  const sessionId = generateAgentId();
1801
1939
  const sessionDir = join(subagentSessionsBase, sessionId);
1802
1940
  const agentModels = getAgentModelsForAgent?.(agentName || DEFAULT_AGENT);
1803
- return launchBackgroundLifecycle(agentName, taskLabel, sessionDir, resolvedCwd, (signal, onChildEvent, onArbitrationRecord) => executeSingle(agents, agentName === DEFAULT_AGENT ? undefined : agentName, task, resolvedCwd, signal, undefined, modelOverride, getParentProvider(), modelRegistry, sessionDir, getParentModel(), agentModels, getParentSessionFile(), onChildEvent, thinkingOverride, arbitrate
1941
+ return launchBackgroundLifecycle(agentName, taskLabel, sessionDir, resolvedCwd, (signal, onChildEvent, onArbitrationRecord, onControlAvailable) => executeSingle(agents, agentName === DEFAULT_AGENT ? undefined : agentName, task, resolvedCwd, signal, undefined, modelOverride, getParentProvider(), modelRegistry, sessionDir, getParentModel(), agentModels, getParentSessionFile(), onChildEvent, thinkingOverride, arbitrate
1804
1942
  ? {
1805
1943
  arbitrate,
1806
1944
  onRecord: onArbitrationRecord,
1807
1945
  defaultThinkingLevel: getDefaultThinkingLevel?.(),
1808
1946
  getAgentModelsForAgent,
1809
1947
  }
1810
- : undefined));
1948
+ : undefined, onControlAvailable));
1811
1949
  };
1812
1950
  if (params.task) {
1813
1951
  // Single background task
@@ -1872,7 +2010,7 @@ export function createSubagentToolDefinition(cwd, options) {
1872
2010
  const taskSummary = `${params.chain.length}-step chain`;
1873
2011
  const chainSteps = params.chain;
1874
2012
  const chainSessionDir = join(subagentSessionsBase, `chain-${generateAgentId()}`);
1875
- const agentId = launchBackgroundLifecycle(agentName, taskSummary, chainSessionDir, cwd, async (signal, onChildEvent, onArbitrationRecord) => {
2013
+ const agentId = launchBackgroundLifecycle(agentName, taskSummary, chainSessionDir, cwd, async (signal, onChildEvent, onArbitrationRecord, onControlAvailable) => {
1876
2014
  const results = await executeChain(agents, chainSteps, cwd, signal, undefined, getParentProvider(), modelRegistry, chainSessionDir, params.agent, params.model, params.thinking, getParentModel(), getAgentModelsForAgent, getParentSessionFile(), onChildEvent, arbitrate
1877
2015
  ? {
1878
2016
  arbitrate,
@@ -1880,7 +2018,7 @@ export function createSubagentToolDefinition(cwd, options) {
1880
2018
  defaultThinkingLevel: getDefaultThinkingLevel?.(),
1881
2019
  getAgentModelsForAgent,
1882
2020
  }
1883
- : undefined);
2021
+ : undefined, onControlAvailable);
1884
2022
  const resultText = results
1885
2023
  .map((r, i) => `### Step ${i + 1}\n${formatSingleResult(r)}`)
1886
2024
  .join("\n\n---\n\n");