@heretyc/subagent-mcp 2.10.4 → 2.12.2

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/dist/drivers.js CHANGED
@@ -108,6 +108,12 @@ class AsyncInputQueue {
108
108
  for (const taker of this.takers.splice(0))
109
109
  taker({ value: undefined, done: true });
110
110
  }
111
+ // True when the consumer (SDK query loop) is blocked awaiting input and nothing
112
+ // is buffered — i.e. the model turn ended and it is idle. A watchdog uses this
113
+ // to decide whether a resume turn is warranted.
114
+ get isAwaitingInput() {
115
+ return !this.closed && this.items.length === 0 && this.takers.length > 0;
116
+ }
111
117
  [Symbol.asyncIterator]() {
112
118
  return {
113
119
  next: () => {
@@ -147,6 +153,48 @@ function userMessage(text) {
147
153
  function textInput(text) {
148
154
  return { type: "text", text, text_elements: [] };
149
155
  }
156
+ // Server->client RPC methods that block on a client answer (elicitation /
157
+ // approval). Matched case-insensitively so provider-namespaced variants
158
+ // (e.g. "codex/requestUserInput", "session/requestApproval") are covered.
159
+ function isElicitationMethod(method) {
160
+ return /requestuserinput|elicit|approv/i.test(method);
161
+ }
162
+ // Shape the `result` payload for an elicitation reply. When the RPC offered a
163
+ // discrete option set, select only an exact case-insensitive label match and
164
+ // echo its identifier; otherwise pass the answer through as text.
165
+ function buildElicitationResult(options, answer) {
166
+ if (options && options.length > 0) {
167
+ const norm = answer.trim().toLowerCase();
168
+ const chosen = options.find((opt) => {
169
+ const label = optionLabel(opt);
170
+ return label !== null && norm === label.trim().toLowerCase();
171
+ });
172
+ if (chosen) {
173
+ const id = optionId(chosen);
174
+ return id !== null ? { optionId: id } : { option: chosen };
175
+ }
176
+ }
177
+ return { text: answer };
178
+ }
179
+ function optionLabel(opt) {
180
+ for (const key of ["label", "name", "title", "text", "value", "id"]) {
181
+ const v = opt[key];
182
+ if (typeof v === "string" && v.length > 0)
183
+ return v;
184
+ }
185
+ return null;
186
+ }
187
+ function optionId(opt) {
188
+ for (const key of ["id", "value", "optionId", "label", "name"]) {
189
+ const v = opt[key];
190
+ if (typeof v === "string" || typeof v === "number")
191
+ return v;
192
+ }
193
+ return null;
194
+ }
195
+ function isJsonRpcId(id) {
196
+ return typeof id === "string" || typeof id === "number";
197
+ }
150
198
  export class MockJsonlDriver {
151
199
  child;
152
200
  provider;
@@ -254,6 +302,10 @@ export class CodexAppServerDriver {
254
302
  initialized = false;
255
303
  turnInFlight = false;
256
304
  maxQueueDepth = 32;
305
+ // An inbound server->client RPC (elicitation, e.g. requestUserInput / approval)
306
+ // awaiting a reply. While set, the next send() answers this request instead of
307
+ // enqueuing a new turn — otherwise the in-flight question turn wedges the queue.
308
+ pendingServerRequest = null;
257
309
  constructor(child, options) {
258
310
  this.child = child;
259
311
  this.options = options;
@@ -301,6 +353,18 @@ export class CodexAppServerDriver {
301
353
  if (!this.initialized || !this.threadId) {
302
354
  return Promise.reject(new Error("codex app-server thread is not initialized"));
303
355
  }
356
+ if (this.pendingServerRequest) {
357
+ // The message is the user's answer to a parked server-side elicitation.
358
+ // Reply on the same JSON-RPC id (mirroring the { id, result } envelope our
359
+ // own request() responses arrive in) instead of enqueuing a fresh turn.
360
+ const req = this.pendingServerRequest;
361
+ this.pendingServerRequest = null;
362
+ return writeLine(this.child.stdin, {
363
+ jsonrpc: "2.0",
364
+ id: req.id,
365
+ result: buildElicitationResult(req.options, message),
366
+ });
367
+ }
304
368
  if (this.queuedTurns.length >= this.maxQueueDepth) {
305
369
  return Promise.reject(new Error(`provider input queue is full (${this.maxQueueDepth})`));
306
370
  }
@@ -315,6 +379,7 @@ export class CodexAppServerDriver {
315
379
  this.process.kill("SIGKILL");
316
380
  this.rejectPending(new Error("codex app-server driver was killed"));
317
381
  this.queuedTurns.length = 0;
382
+ this.pendingServerRequest = null;
318
383
  }
319
384
  async drainQueuedTurns() {
320
385
  if (this.drainActive)
@@ -406,6 +471,16 @@ export class CodexAppServerDriver {
406
471
  pending.resolve(message);
407
472
  }
408
473
  }
474
+ else if (isJsonRpcId(message.id) &&
475
+ typeof message.method === "string" &&
476
+ isElicitationMethod(message.method)) {
477
+ // Inbound server->client RPC (elicitation) that is NOT a reply to one of
478
+ // our requests. Park it so the next send() answers it instead of queuing a
479
+ // new turn; leaving it unanswered wedges the in-flight question turn.
480
+ const params = (message.params ?? {});
481
+ const options = Array.isArray(params.options) ? params.options : undefined;
482
+ this.pendingServerRequest = { id: message.id, method: message.method, options };
483
+ }
409
484
  if (message.method === "turn/started" && message.params && typeof message.params === "object") {
410
485
  const turn = (message.params.turn ?? {});
411
486
  if (typeof turn.id === "string")
@@ -448,6 +523,10 @@ export class ClaudeSdkDriver {
448
523
  queue = Promise.resolve();
449
524
  queryHandle = null;
450
525
  closedFlag = false;
526
+ // Debounce for notifyTaskComplete(): set when a resume turn is pushed, cleared
527
+ // as soon as the model streams any message (see pump). Prevents a stalled agent
528
+ // from being resumed more than once per idle cycle.
529
+ resumePending = false;
451
530
  constructor(queryFn) {
452
531
  this.queryFn = queryFn;
453
532
  }
@@ -490,6 +569,30 @@ export class ClaudeSdkDriver {
490
569
  this.queue = next.catch(() => { });
491
570
  return next;
492
571
  }
572
+ // Wake the SDK query loop after the agent's own background task finishes. The
573
+ // loop parks on `await next input` once the model turn ends; nothing else
574
+ // pushes a resume, so a sub-agent that spawned a bg task would stall forever.
575
+ // Pushes a synthetic user turn through the SAME input queue send() uses.
576
+ //
577
+ // WIRING: the notification source (task_notification for THIS agent's bg task)
578
+ // is not visible inside drivers.ts. The owning server/monitor must call
579
+ // driver.notifyTaskComplete() when it observes this agent's background
580
+ // task_notification while the agent is `stalled` with an empty input queue
581
+ // (see AsyncInputQueue.isAwaitingInput). Guarded so repeat calls are no-ops
582
+ // until the model next produces output.
583
+ notifyTaskComplete(text) {
584
+ if (this.closed || this.resumePending)
585
+ return Promise.resolve();
586
+ this.resumePending = true;
587
+ const resumeText = text ?? "Your background task has completed. Resume and continue where you left off.";
588
+ const next = this.queue.then(() => {
589
+ if (this.closed)
590
+ return;
591
+ return this.input.push(userMessage(resumeText));
592
+ });
593
+ this.queue = next.catch(() => { });
594
+ return next;
595
+ }
493
596
  kill() {
494
597
  if (this.closedFlag)
495
598
  return;
@@ -503,6 +606,9 @@ export class ClaudeSdkDriver {
503
606
  let started = false;
504
607
  try {
505
608
  for await (const message of query) {
609
+ // The model is producing output again: clear the resume debounce so a
610
+ // future bg-task completion can wake it once more.
611
+ this.resumePending = false;
506
612
  const text = claudeMessageText(message);
507
613
  if (!started && text && isClaudeSessionLimit(text)) {
508
614
  // Launch-time failover only applies before startup resolves/spawn grace ends;
package/dist/effort.js CHANGED
@@ -6,6 +6,8 @@ export function mapModel(provider, model) {
6
6
  if (provider === "claude") {
7
7
  if (model === "opus" || model === "opus-4-8")
8
8
  return "claude-opus-4-8";
9
+ if (model === "fable")
10
+ return "claude-fable-5";
9
11
  return model; // haiku, sonnet as-is
10
12
  }
11
13
  else {
@@ -26,7 +28,7 @@ export function resolveEffort(provider, model, effort) {
26
28
  if (provider === "claude" && model === "haiku") {
27
29
  return { kind: "none" };
28
30
  }
29
- if (provider === "claude" && ["sonnet", "opus", "opus-4-8"].includes(model)) {
31
+ if (provider === "claude" && ["sonnet", "opus", "opus-4-8", "fable"].includes(model)) {
30
32
  if (["medium", "high", "xhigh", "max"].includes(effort)) {
31
33
  return { kind: "flag", value: effort };
32
34
  }
@@ -1,29 +1,34 @@
1
- // subagent-mcp — Global Concurrent Subagent Cap
2
- // ------------------------------------------------------------------
3
- // SOLE source of truth for the machine-wide limit on how many subagents
4
- // may be ALIVE AT ONCE across EVERY session, process, and user on this
5
- // machine. There is NO environment-variable override.
6
- //
7
- // The whole recursive descendant tree counts toward this ONE number: a
8
- // subagent that itself launches subagents adds to the same machine-wide
9
- // total, and OTHER active agentic sessions count too.
10
- //
11
- // RE-READ on every launch_agent call — edits take effect immediately, no
12
- // server restart required.
13
- //
14
- // Value rules (forcibly applied to the number below):
15
- // - missing / unset / non-integer / 0 / negative -> reset to default 20
16
- // - 1 through 9 -> forced UP to minimum 10
17
- // - 10 or greater -> used as-is
18
- //
1
+ // subagent-mcp — Global Concurrent Subagent Cap
2
+ // ------------------------------------------------------------------
3
+ // SOLE source of truth for the machine-wide limit on how many subagents
4
+ // may be ALIVE AT ONCE across EVERY session, process, and user on this
5
+ // machine. There is NO environment-variable override for the cap.
6
+ //
7
+ // The whole recursive descendant tree counts toward this ONE number: a
8
+ // subagent that itself launches subagents adds to the same machine-wide
9
+ // total, and OTHER active agentic sessions count too.
10
+ //
11
+ // RE-READ on every launch_agent call — edits take effect immediately, no
12
+ // server restart required.
13
+ //
14
+ // Value rules (forcibly applied to the number below):
15
+ // - missing / unset / non-integer / 0 / negative -> reset to default 20
16
+ // - 1 through 9 -> forced UP to minimum 10
17
+ // - 10 or greater -> used as-is
18
+ //
19
19
  // Zombie culling is always enabled. There is no config knob. Before cap
20
20
  // rejection, launch/tool/hook paths refresh live owned slots, preserve stale
21
21
  // slots whose owner server is still alive, and cull stale slots whose owner is
22
22
  // gone. Managed stale slots terminate the child process tree, then force-kill
23
23
  // after 20s when needed; unmanaged stale slots are only unlinked.
24
- //
25
- // When the cap is reached after culling, launch_agent is REJECTED (never
26
- // queued). Free a slot with list_agents + kill_agent, then retry.
27
- {
28
- "globalConcurrentSubagents": 20
29
- }
24
+ //
25
+ // When the cap is reached after culling, launch_agent is REJECTED (never
26
+ // queued). Free a slot with list_agents + kill_agent, then retry.
27
+ //
28
+ // checkForUpdates controls the silent npmjs update check started when the MCP
29
+ // server connects. Default true. Set to false to skip the registry fetch and
30
+ // suppress hook notices. SUBAGENT_UPDATE_CHECK=0 or false also disables it.
31
+ {
32
+ "globalConcurrentSubagents": 20,
33
+ "checkForUpdates": true
34
+ }
@@ -1,6 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
- import { claimAndEmit, appendHookZombieReport, classifyClaim, countJsonlType, cullHookZombies, runHook, sessionKey, } from "../orchestration/hook-core.js";
3
+ import { claimAndEmit, classifyClaim, countJsonlType, cullHookZombies, runHook, sessionKey, } from "../orchestration/hook-core.js";
4
4
  import * as marker from "../orchestration/marker.js";
5
5
  /**
6
6
  * Codex CLI hook entry. Branches on payload.hook_event_name:
@@ -80,13 +80,13 @@ export const codexAdapter = {
80
80
  export function runCodexHook(payload, env, adapter = codexAdapter) {
81
81
  try {
82
82
  if (payload.hook_event_name === "SessionStart") {
83
- const zombieRecords = cullHookZombies();
83
+ cullHookZombies();
84
84
  if (adapter.isSubagent(payload, env)) {
85
- return appendHookZombieReport("", zombieRecords);
85
+ return "";
86
86
  }
87
87
  const cwd = payload.cwd || process.cwd();
88
88
  if (!marker.isActive(cwd)) {
89
- return appendHookZombieReport("", zombieRecords);
89
+ return "";
90
90
  }
91
91
  const current = sessionKey(payload);
92
92
  const turn = adapter.currentTurn(payload.transcript_path);
@@ -96,7 +96,7 @@ export function runCodexHook(payload, env, adapter = codexAdapter) {
96
96
  // semantics — FULL + ON reminder, ack-latched CARRYOVER prepend, counter
97
97
  // re-baseline). SessionStart claims even on SAME-SESSION (resume) so
98
98
  // turn 0 is always covered.
99
- return appendHookZombieReport(claimAndEmit(cwd, current, turn, m, kind, env, adapter), zombieRecords);
99
+ return claimAndEmit(cwd, current, turn, m, kind, env, adapter);
100
100
  }
101
101
  // UserPromptSubmit (and any other event) -> normal cadence.
102
102
  return runHook(payload, env, adapter);
package/dist/index.js CHANGED
@@ -22,11 +22,11 @@ import { CONFIG_FILENAME, NONBLOCKING_CULL_DEPS, ZOMBIE_FORCE_GRACE_MS, ZOMBIE_L
22
22
  import * as orchestrationMarker from "./orchestration/marker.js";
23
23
  import * as modelMode from "./orchestration/model-mode.js";
24
24
  import { startLivenessHeartbeat } from "./orchestration/liveness.js";
25
+ import { checkForNpmUpdate } from "./orchestration/update-check.js";
25
26
  import { ensureParentMarker } from "./launch-prompt.js";
26
27
  const agents = new Map();
27
- const MAX_CLAUDE = 5;
28
- const MAX_CODEX = 5;
29
28
  const deadlockWindow = createDeadlockWindow();
29
+ const STDOUT_RING_BYTES = 2 * 1024 * 1024;
30
30
  // Advanced-ruleset gate: per-process latch with exactly the deadlock-window
31
31
  // scoping. The env-check runs lazily at the FIRST launch_agent call; success
32
32
  // latches enabled/disabled for the process lifetime, failure never latches.
@@ -264,7 +264,7 @@ function withMaintenance(handler, options = {}) {
264
264
  return async (params) => {
265
265
  const zombieRecords = runToolMaintenance();
266
266
  const result = await handler(params, zombieRecords);
267
- if (options.omitZombieReport)
267
+ if (!options.includeZombieReport)
268
268
  return result;
269
269
  if (result && typeof result === "object" && "content" in result) {
270
270
  return withZombieReport(result, zombieRecords);
@@ -290,12 +290,96 @@ function failureTypeForError(error, stderr) {
290
290
  : classifyFailureReason(error.message, stderr);
291
291
  }
292
292
  const isWindows = process.platform === "win32";
293
- let _npmPrefix = null;
293
+ // Resolved lazily and memoized on first use so that plain CLI invocations never
294
+ // spawn `npm prefix -g` at import time. The server warms this once during
295
+ // startup (see main()) so a live, long-lived server never stalls mid-request.
296
+ let npmPrefixCache;
294
297
  function getNpmPrefix() {
295
- if (!_npmPrefix) {
296
- _npmPrefix = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
298
+ if (npmPrefixCache === undefined) {
299
+ npmPrefixCache = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
300
+ }
301
+ return npmPrefixCache;
302
+ }
303
+ const CLAUDE_BACKGROUND_RESUME_TEXT = "Your background task has completed. Resume and continue where you left off.";
304
+ export function isClaudeBackgroundWakeLine(line) {
305
+ const trimmed = line.trim();
306
+ if (!trimmed)
307
+ return false;
308
+ try {
309
+ const evt = JSON.parse(trimmed);
310
+ if (evt.type === "result")
311
+ return false;
312
+ const method = typeof evt.method === "string" ? evt.method : "";
313
+ const type = typeof evt.type === "string" ? evt.type : "";
314
+ const name = typeof evt.name === "string" ? evt.name : "";
315
+ const marker = `${method} ${type} ${name}`.toLowerCase();
316
+ if (marker.includes("task_notification") || marker.includes("task-notification"))
317
+ return true;
318
+ if (marker.includes("background") && marker.includes("complete"))
319
+ return true;
320
+ if (marker.includes("taskoutput") && marker.includes("complete"))
321
+ return true;
322
+ // No concrete Claude bg-task-complete JSONL fixture exists in-repo. As a
323
+ // backstop, any non-result JSONL activity after a finished turn means the
324
+ // still-live SDK stream observed something new and needs a nudge.
325
+ return true;
326
+ }
327
+ catch {
328
+ return false;
329
+ }
330
+ }
331
+ function noteBackgroundResumeSignal(agent, at) {
332
+ if (agent.provider !== "claude" || agent.driver.closed)
333
+ return;
334
+ agent.bgTaskResumeObservedAt = Math.max(agent.bgTaskResumeObservedAt ?? 0, at);
335
+ }
336
+ export async function maybeResumeAfterBackgroundTask(agent, now = Date.now()) {
337
+ if (agent.provider !== "claude")
338
+ return false;
339
+ if (agent.driver.closed)
340
+ return false;
341
+ if (agent.bgTaskResumeInFlight)
342
+ return false;
343
+ const observedAt = agent.bgTaskResumeObservedAt ?? 0;
344
+ if (observedAt === 0 || observedAt <= (agent.bgTaskResumeSentAt ?? 0))
345
+ return false;
346
+ agent.bgTaskResumeInFlight = true;
347
+ try {
348
+ if (typeof agent.driver.notifyTaskComplete === "function") {
349
+ await agent.driver.notifyTaskComplete(CLAUDE_BACKGROUND_RESUME_TEXT);
350
+ }
351
+ else {
352
+ await agent.driver.send(CLAUDE_BACKGROUND_RESUME_TEXT);
353
+ }
354
+ agent.bgTaskResumeSentAt = observedAt;
355
+ agent.status = "processing";
356
+ agent.lastExitCode = agent.exitCode;
357
+ agent.lastExitedAt = agent.exitedAt;
358
+ agent.exitCode = null;
359
+ agent.exitedAt = null;
360
+ agent.waitReported = false;
361
+ agent.turnCompleted = false;
362
+ agent.lastActivity = now;
363
+ return true;
364
+ }
365
+ finally {
366
+ agent.bgTaskResumeInFlight = false;
367
+ }
368
+ }
369
+ function handleCompletedStdoutLines(agent, lines, at) {
370
+ let turnWasComplete = agent.turnCompleted === true;
371
+ for (const line of lines) {
372
+ if (turnWasComplete && isClaudeBackgroundWakeLine(line)) {
373
+ noteBackgroundResumeSignal(agent, at);
374
+ }
375
+ if (isTurnCompletedLine(agent.provider, line)) {
376
+ turnWasComplete = true;
377
+ agent.turnCompleted = true;
378
+ agent.status = "finished";
379
+ if (agent.exitedAt === null)
380
+ agent.exitedAt = at;
381
+ }
297
382
  }
298
- return _npmPrefix;
299
383
  }
300
384
  function resolveExe(provider) {
301
385
  return resolveExeFor(provider, process.platform, { existsSync, npmPrefix: getNpmPrefix });
@@ -311,17 +395,6 @@ function cleanupUcSettings(agentState) {
311
395
  agentState.ucSettingsPath = undefined;
312
396
  }
313
397
  }
314
- // Concurrency cap accounting: only `processing` agents count against a
315
- // provider's cap. `stalled` agents (live but quiet past the heartbeat window) do
316
- // NOT count, freeing a slot while they idle.
317
- function countProcessing(provider) {
318
- let count = 0;
319
- for (const a of agents.values()) {
320
- if (a.provider === provider && a.status === "processing")
321
- count++;
322
- }
323
- return count;
324
- }
325
398
  // Synchronously reconcile a single agent's status against the pure transition
326
399
  // helper. Folds the live process exitCode into AgentState first so an already-
327
400
  // exited process is reported as completed/failed immediately (no monitor lag).
@@ -347,6 +420,12 @@ const reconcileInterval = setInterval(() => {
347
420
  for (const agent of agents.values()) {
348
421
  reconcileAgent(agent, now);
349
422
  refreshLiveSlotMetadata(agent, now);
423
+ if (agent.bgTaskResumeObservedAt && agent.bgTaskResumeObservedAt > (agent.bgTaskResumeSentAt ?? 0)) {
424
+ void maybeResumeAfterBackgroundTask(agent, now).then((resumed) => {
425
+ if (resumed)
426
+ updateSlotMetadata(agent);
427
+ });
428
+ }
350
429
  }
351
430
  }, 10000);
352
431
  reconcileInterval.unref();
@@ -363,7 +442,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
363
442
  const SUBAGENT_INSTRUCTIONS = "SUB-AGENT SESSION: you are a child process launched by subagent-mcp. Follow the parent prompt. Do not treat yourself as the orchestrator, do not re-trigger orchestration carryover, and do not launch further sub-agents unless the parent prompt explicitly assigns that.\n\nMODEL SELECTION MODE (parallel to orchestration-mode, set via the model-selection-mode tool). DEFAULT is \"smart\" and is used whenever unset: in smart, launch_agent REJECTS any call supplying provider/model/effort selectors and the server auto-picks the best model. \"user-approved-overrides\" opens a 30-MINUTE window where selectors are HONORED, enforced LAZILY (the mode reverts to smart on the next launch_agent call after 30 minutes) and re-enabling does NOT extend an active window. HONOR-BASED: you MUST NOT set \"user-approved-overrides\" without explicit interactive USER authorization via the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex); never enable it on your own initiative.";
364
443
  const server = new McpServer({
365
444
  name: "subagent-mcp",
366
- version: "2.10.4",
445
+ version: "2.12.2",
367
446
  description: "Launches always-interactive local Claude and Codex sub-agent sessions and is the orchestrator's sole launch channel. Claude runs via the Claude Agent SDK over the local Claude Code executable; Codex via `codex app-server` over stdio. The server never calls Anthropic or OpenAI HTTP APIs directly.",
368
447
  }, {
369
448
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -394,13 +473,6 @@ function cleanupUcSettingsPath(ucSettingsPath) {
394
473
  // process. Any launch-time failure cleans up and is reported so the attempt loop
395
474
  // silently advances to the next candidate.
396
475
  async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetInfo) {
397
- // Concurrency cap for this provider.
398
- const running = countProcessing(candidate.provider);
399
- const max = candidate.provider === "claude" ? MAX_CLAUDE : MAX_CODEX;
400
- if (running >= max) {
401
- const reason = `Maximum ${max} concurrent ${candidate.provider} agents already running. Current: ${running}`;
402
- return { reason, failure_type: "permanent" };
403
- }
404
476
  // Build the command. haiku ignores effort; pass "high" placeholder for the
405
477
  // "none" sentinel (buildCommand drops it for haiku anyway).
406
478
  const effortForBuild = candidate.effort === "none" ? "high" : candidate.effort;
@@ -499,6 +571,8 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
499
571
  stderr: "",
500
572
  exitCode: null,
501
573
  exitedAt: null,
574
+ lastExitCode: null,
575
+ lastExitedAt: null,
502
576
  // Launch time is the initial heartbeat. Only PARSED VISIBLE provider stream
503
577
  // items refresh lastActivity afterwards (see the stdout handler); raw
504
578
  // stdout/stderr chunks do NOT, so `stalled` means exactly "no visible
@@ -529,6 +603,9 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
529
603
  // Accumulate all complete lines into stored stdout.
530
604
  for (const line of lines) {
531
605
  agentState.stdout += line + "\n";
606
+ if (agentState.stdout.length > STDOUT_RING_BYTES) {
607
+ agentState.stdout = agentState.stdout.slice(-STDOUT_RING_BYTES);
608
+ }
532
609
  }
533
610
  if (items.length > 0) {
534
611
  // Heartbeat refreshes only on parsed visible provider stream items,
@@ -541,11 +618,12 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
541
618
  // logical interactive session can remain available for later messages.
542
619
  // Scan COMPLETE lines only so a marker split across chunks is matched
543
620
  // once fully assembled (never on a partial fragment).
544
- if (lines.some((l) => isTurnCompletedLine(agentState.provider, l))) {
545
- agentState.turnCompleted = true;
546
- agentState.status = "finished";
547
- if (agentState.exitedAt === null)
548
- agentState.exitedAt = at;
621
+ handleCompletedStdoutLines(agentState, lines, at);
622
+ if (agentState.bgTaskResumeObservedAt && agentState.bgTaskResumeObservedAt > (agentState.bgTaskResumeSentAt ?? 0)) {
623
+ void maybeResumeAfterBackgroundTask(agentState, at).then((resumed) => {
624
+ if (resumed)
625
+ updateSlotMetadata(agentState);
626
+ });
549
627
  }
550
628
  });
551
629
  }
@@ -565,15 +643,13 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
565
643
  agentState.streamBuf = "";
566
644
  for (const line of lines) {
567
645
  agentState.stdout += line + "\n";
646
+ if (agentState.stdout.length > STDOUT_RING_BYTES) {
647
+ agentState.stdout = agentState.stdout.slice(-STDOUT_RING_BYTES);
648
+ }
568
649
  }
569
650
  // A completion marker may arrive only in this final flush (no trailing
570
651
  // newline) — the grace window's success exception needs it.
571
- if (lines.some((l) => isTurnCompletedLine(agentState.provider, l))) {
572
- agentState.turnCompleted = true;
573
- agentState.status = "finished";
574
- if (agentState.exitedAt === null)
575
- agentState.exitedAt = at;
576
- }
652
+ handleCompletedStdoutLines(agentState, lines, at);
577
653
  if (items.length > 0) {
578
654
  agentState.lastActivity = at;
579
655
  agentState.visibleStream = retainLastN(agentState.visibleStream, items.map((it) => ({ ...it, at })), 3);
@@ -725,7 +801,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
725
801
  task_category: z.enum(TASK_CATEGORIES).describe(TASK_CATEGORY_GLOSS),
726
802
  prompt: z.string().min(1),
727
803
  provider: z.enum(["claude", "codex"]).optional(),
728
- model: z.enum(["haiku", "sonnet", "opus", "opus-4-8", "gpt-5.5"]).optional(),
804
+ model: z.enum(["haiku", "sonnet", "opus", "opus-4-8", "fable", "gpt-5.5"]).optional(),
729
805
  effort: z.enum(["medium", "high", "xhigh", "max", "ultracode"]).optional(),
730
806
  cwd: z.string().optional(),
731
807
  deadlock: z.boolean().optional().describe("MANDATE: ALWAYS set deadlock=true when, and ONLY when, 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory — the 3rd attempt onward. Re-wording the prompt does NOT make it a different task; splitting a failed task does NOT reset attempts for its unchanged parts; re-launching for the same deliverable means the prior attempt COUNTS as failed/unsatisfactory ('partial progress' is not an exemption). NEVER set it on a 1st or 2nd attempt, NEVER for a different task, NEVER speculatively. Auto mode only: cannot be combined with provider/model/effort — from the 3rd attempt deadlock outranks any capability override, so drop those params. Passing false is identical to omitting it."),
@@ -933,7 +1009,7 @@ server.tool("launch_agent", "Spawn a sub-agent session. CONTRACT: every `prompt`
933
1009
  .map((s, i) => ` ${i + 1}. ${s.model}@${s.effort} (${s.provider}) [${s.failure_type}]: ${s.reason}`)
934
1010
  .join("\n");
935
1011
  return errorResult(`Error: all ${skipped.length} candidate launches failed for task_category ${task_category}:\n${lines}\n${SPLIT_HINT}\n${AUTO_HINT}`);
936
- }, { omitZombieReport: true }) // ponytail: launch_agent still reaps, but callers do not receive zombie_report.
1012
+ }) // ponytail: launch_agent still reaps, but callers do not receive zombie_report.
937
1013
  );
938
1014
  // Tool 2: poll_agent
939
1015
  server.tool("poll_agent", "Get an agent's current status and output. `processing` = ALIVE with visible provider activity in the last 10 min; `stalled` = ALIVE but no visible provider stream item for 10 min (thinking, or awaiting a temp-file handoff) — NOT dead, so prefer `wait`/re-poll over killing. Always returns `alive` + `idle_seconds`, plus `recent_stream` (last 3 timestamped visible stream items) and a `hint` while stalled. `verbose: true` also returns `final_output`, the agent's final assistant turn from its captured stdout.", {
@@ -973,13 +1049,10 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
973
1049
  model: agent.model,
974
1050
  status: agent.status,
975
1051
  exit_code: agent.exitCode,
976
- stdout_tail: stdoutTail,
977
- stderr_tail: stderrTail,
978
1052
  started_at: agent.startedAt,
979
1053
  last_activity: agent.lastActivity,
980
1054
  cwd: agent.cwd,
981
1055
  ...liveness,
982
- ...(agent.routingTier !== undefined ? { routing_tier: agent.routingTier } : {}),
983
1056
  ...(agent.rulesetApplied
984
1057
  ? {
985
1058
  ruleset_applied: true,
@@ -998,13 +1071,17 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
998
1071
  at: it.at !== undefined ? formatLocalIso(it.at) : null,
999
1072
  })),
1000
1073
  ...(params.verbose
1001
- ? { final_output: extractFinalTurn(agent.provider, agent.stdout) }
1074
+ ? {
1075
+ stdout_tail: stdoutTail,
1076
+ stderr_tail: stderrTail,
1077
+ final_output: extractFinalTurn(agent.provider, agent.stdout),
1078
+ }
1002
1079
  : {}),
1003
1080
  }),
1004
1081
  },
1005
1082
  ],
1006
1083
  };
1007
- }));
1084
+ }, { includeZombieReport: true }));
1008
1085
  // Tool 3: kill_agent
1009
1086
  server.tool("kill_agent", "Terminate a live agent/session (status `processing`, `stalled`, or turn-finished but still interactive) by immediately force-killing its managed driver. No-op for already-terminal closed agents.", {
1010
1087
  agent_id: z.string(),
@@ -1119,6 +1196,8 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
1119
1196
  await agent.driver.send(params.message);
1120
1197
  const now = Date.now();
1121
1198
  agent.status = "processing";
1199
+ agent.lastExitCode = agent.exitCode;
1200
+ agent.lastExitedAt = agent.exitedAt;
1122
1201
  agent.exitCode = null;
1123
1202
  agent.exitedAt = null;
1124
1203
  agent.waitReported = false;
@@ -1179,7 +1258,7 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
1179
1258
  },
1180
1259
  ],
1181
1260
  };
1182
- }));
1261
+ }, { includeZombieReport: true }));
1183
1262
  // Tool 6: wait
1184
1263
  server.tool("wait", "Blocks until one or more sub-agents reach a reportable state (turn-finished, errored, stopped, or zombie_killed), returning exit code when known + local-time timestamp; or returns the live-job list after a 15-minute timeout. This is how you learn an agent finished — do NOT poll-loop. A `finished` agent with null exit_code is still alive and accepts `send_message`; a `stalled` agent is still ALIVE and does NOT end the wait. `verbose: true` adds each finished agent's `final_output`.", {
1185
1264
  verbose: z.boolean().optional().default(false),
@@ -1218,7 +1297,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1218
1297
  a.waitReported = true;
1219
1298
  const payload = { finished: unreported.map(buildFinishedEntry) };
1220
1299
  return {
1221
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1300
+ content: [{ type: "text", text: JSON.stringify(payload) }],
1222
1301
  };
1223
1302
  }
1224
1303
  // Step 2: nothing alive and nothing unreported (includes stopped-but-not-yet-closed).
@@ -1233,7 +1312,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1233
1312
  message: "No agents are running or waiting to finish.",
1234
1313
  };
1235
1314
  return {
1236
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1315
+ content: [{ type: "text", text: JSON.stringify(payload) }],
1237
1316
  };
1238
1317
  }
1239
1318
  // Step 3: block-poll until a terminal agent appears or deadline passes
@@ -1246,7 +1325,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1246
1325
  a.waitReported = true;
1247
1326
  const payload = { finished: unreported.map(buildFinishedEntry) };
1248
1327
  return {
1249
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1328
+ content: [{ type: "text", text: JSON.stringify(payload) }],
1250
1329
  };
1251
1330
  }
1252
1331
  }
@@ -1260,7 +1339,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1260
1339
  hint: "15 minutes elapsed with no agent finishing. Call wait again to block for another 15 minutes or until the next agent finishes.",
1261
1340
  };
1262
1341
  return {
1263
- content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
1342
+ content: [{ type: "text", text: JSON.stringify(payload) }],
1264
1343
  };
1265
1344
  }));
1266
1345
  // Tool 7: orchestration-mode
@@ -1519,6 +1598,8 @@ if (isMain) {
1519
1598
  // default-ON this rarely fires.
1520
1599
  // (the tool's enabled:false writes a disable record via writeDisable /
1521
1600
  // writeDisableCwd; it does not call disable().)
1601
+ getNpmPrefix();
1602
+ void checkForNpmUpdate().catch(() => { });
1522
1603
  startLivenessHeartbeat();
1523
1604
  const transport = new StdioServerTransport();
1524
1605
  await server.connect(transport);