@heretyc/subagent-mcp 2.8.6 → 2.8.8

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
@@ -2,6 +2,13 @@ import { spawn } from "node:child_process";
2
2
  import { EventEmitter } from "node:events";
3
3
  import { PassThrough } from "node:stream";
4
4
  import { mapModel } from "./effort.js";
5
+ export class ProviderTransientError extends Error {
6
+ isTransient = true;
7
+ constructor(message) {
8
+ super(message);
9
+ this.name = "ProviderTransientError";
10
+ }
11
+ }
5
12
  class LogicalProcess extends EventEmitter {
6
13
  pid;
7
14
  stdout = new PassThrough();
@@ -120,10 +127,18 @@ function userMessage(text) {
120
127
  function textInput(text) {
121
128
  return { type: "text", text, text_elements: [] };
122
129
  }
123
- class MockJsonlDriver {
130
+ export class MockJsonlDriver {
124
131
  child;
125
132
  provider;
133
+ static transientPreStartHook = null;
134
+ static postStartErrorHook = null;
126
135
  process;
136
+ _definitelyStartedResolve;
137
+ _definitelyStartedReject;
138
+ definitelyStarted = new Promise((res, rej) => {
139
+ this._definitelyStartedResolve = res;
140
+ this._definitelyStartedReject = rej;
141
+ });
127
142
  queue = Promise.resolve();
128
143
  turn = 0;
129
144
  constructor(child, provider) {
@@ -136,7 +151,10 @@ class MockJsonlDriver {
136
151
  if (!this.process.killed)
137
152
  this.process.close(code, signal);
138
153
  });
139
- child.stdout?.on("data", (chunk) => this.process.stdout.write(chunk));
154
+ child.stdout?.on("data", (chunk) => {
155
+ this._definitelyStartedResolve();
156
+ this.process.stdout.write(chunk);
157
+ });
140
158
  child.stderr?.on("data", (chunk) => this.process.stderr.write(chunk));
141
159
  // Swallow stdin pipe errors (e.g. EPIPE writing to an exited child): the
142
160
  // writeLine() callback already surfaces the failure; without this listener
@@ -147,6 +165,20 @@ class MockJsonlDriver {
147
165
  return this.process.killed || this.child.killed || this.child.exitCode !== null;
148
166
  }
149
167
  start(message) {
168
+ if (MockJsonlDriver.transientPreStartHook) {
169
+ MockJsonlDriver.transientPreStartHook(this.provider);
170
+ const err = new ProviderTransientError(`mock transient pre-start failure (${this.provider})`);
171
+ this._definitelyStartedReject(err);
172
+ return Promise.reject(err);
173
+ }
174
+ if (MockJsonlDriver.postStartErrorHook) {
175
+ this._definitelyStartedResolve();
176
+ MockJsonlDriver.postStartErrorHook(this.provider);
177
+ setTimeout(() => {
178
+ this.process.stderr.write(`mock post-start error (${this.provider})\n`);
179
+ this.process.close(1);
180
+ }, 0);
181
+ }
150
182
  return this.send(message);
151
183
  }
152
184
  send(message) {
@@ -175,6 +207,12 @@ export class CodexAppServerDriver {
175
207
  child;
176
208
  options;
177
209
  process;
210
+ _definitelyStartedResolve;
211
+ _definitelyStartedReject;
212
+ definitelyStarted = new Promise((res, rej) => {
213
+ this._definitelyStartedResolve = res;
214
+ this._definitelyStartedReject = rej;
215
+ });
178
216
  pending = new Map();
179
217
  queuedTurns = [];
180
218
  nextId = 1;
@@ -341,6 +379,7 @@ export class CodexAppServerDriver {
341
379
  const turn = (message.params.turn ?? {});
342
380
  if (typeof turn.id === "string")
343
381
  this.activeTurnId = turn.id;
382
+ this._definitelyStartedResolve();
344
383
  }
345
384
  if (message.method === "turn/completed") {
346
385
  this.activeTurnId = null;
@@ -349,6 +388,9 @@ export class CodexAppServerDriver {
349
388
  }
350
389
  }
351
390
  fail(error) {
391
+ const msg = error.message;
392
+ const transient = /\b429\b|\b5\d{2}\b|quota|rate.?limit|timeout|ECONNRESET|ETIMEDOUT|ECONNREFUSED|too many requests|service unavailable|server error|overloaded/i.test(msg);
393
+ this._definitelyStartedReject(transient ? new ProviderTransientError(msg) : error);
352
394
  this.process.fail(error);
353
395
  this.rejectPending(error);
354
396
  }
@@ -364,6 +406,12 @@ export class CodexAppServerDriver {
364
406
  export class ClaudeSdkDriver {
365
407
  queryFn;
366
408
  process = new LogicalProcess(undefined, true);
409
+ _definitelyStartedResolve;
410
+ _definitelyStartedReject;
411
+ definitelyStarted = new Promise((res, rej) => {
412
+ this._definitelyStartedResolve = res;
413
+ this._definitelyStartedReject = rej;
414
+ });
367
415
  input = new AsyncInputQueue();
368
416
  abortController = new AbortController();
369
417
  queue = Promise.resolve();
@@ -421,8 +469,13 @@ export class ClaudeSdkDriver {
421
469
  this.process.kill("SIGKILL");
422
470
  }
423
471
  async pump(query) {
472
+ let started = false;
424
473
  try {
425
474
  for await (const message of query) {
475
+ if (!started) {
476
+ started = true;
477
+ this._definitelyStartedResolve();
478
+ }
426
479
  this.process.stdout.write(`${JSON.stringify(message)}\n`);
427
480
  }
428
481
  this.closedFlag = true;
@@ -430,8 +483,11 @@ export class ClaudeSdkDriver {
430
483
  }
431
484
  catch (error) {
432
485
  if (!this.process.killed) {
486
+ const msg = error instanceof Error ? error.message : String(error);
487
+ const transient = /\b429\b|\b5\d{2}\b|quota|rate.?limit|timeout|ECONNRESET|ETIMEDOUT|ECONNREFUSED|too many requests|service unavailable|server error|overloaded/i.test(msg);
488
+ this._definitelyStartedReject(transient ? new ProviderTransientError(msg) : new Error(msg));
433
489
  this.closedFlag = true;
434
- this.process.stderr.write(error instanceof Error ? error.message : String(error));
490
+ this.process.stderr.write(msg);
435
491
  this.process.close(1);
436
492
  }
437
493
  }
package/dist/effort.js CHANGED
@@ -14,6 +14,9 @@ export function mapModel(provider, model) {
14
14
  }
15
15
  export function resolveEffort(provider, model, effort) {
16
16
  const isOpus48 = provider === "claude" && (model === "opus" || model === "opus-4-8");
17
+ if (effort === "low") {
18
+ throw new Error(`low effort is not supported. Valid efforts: medium, high, xhigh, max, ultracode.`);
19
+ }
17
20
  if (effort === "ultracode") {
18
21
  if (!isOpus48) {
19
22
  throw new Error(`ultracode effort is only available on Opus 4.8+ (got ${provider}/${model}). Use xhigh for other models.`);
@@ -24,15 +27,15 @@ export function resolveEffort(provider, model, effort) {
24
27
  return { kind: "none" };
25
28
  }
26
29
  if (provider === "claude" && ["sonnet", "opus", "opus-4-8"].includes(model)) {
27
- if (["low", "medium", "high", "xhigh", "max"].includes(effort)) {
30
+ if (["medium", "high", "xhigh", "max"].includes(effort)) {
28
31
  return { kind: "flag", value: effort };
29
32
  }
30
33
  }
31
34
  if (provider === "codex") {
32
35
  if (effort === "max") {
33
- throw new Error(`max effort is not valid for gpt-5.5 (Codex). Valid: low, medium, high, xhigh.`);
36
+ throw new Error(`max effort is not valid for gpt-5.5 (Codex). Valid: medium, high, xhigh.`);
34
37
  }
35
- if (["low", "medium", "high", "xhigh"].includes(effort)) {
38
+ if (["medium", "high", "xhigh"].includes(effort)) {
36
39
  return { kind: "flag", value: effort };
37
40
  }
38
41
  }
@@ -1,7 +1,6 @@
1
1
  import { realpathSync } from "node:fs";
2
2
  import { pathToFileURL } from "node:url";
3
3
  import { countJsonlType, runHook, } from "../orchestration/hook-core.js";
4
- import { resetToolCount } from "../orchestration/pretool.js";
5
4
  /**
6
5
  * Claude Code UserPromptSubmit hook entry. Reads the JSON payload from stdin,
7
6
  * runs the provider-agnostic core with the Claude adapter, and writes the
@@ -51,9 +50,6 @@ export const claudeAdapter = {
51
50
  };
52
51
  export function runClaudeHook(payload, env, adapter = claudeAdapter) {
53
52
  try {
54
- if (!adapter.isSubagent(payload, env)) {
55
- resetToolCount(payload);
56
- }
57
53
  return runHook(payload, env, adapter);
58
54
  }
59
55
  catch {
package/dist/index.js CHANGED
@@ -19,7 +19,9 @@ import { loadRoutingTable, buildCandidates, validatePresence, TASK_CATEGORIES, A
19
19
  import { createDeadlockWindow } from "./deadlock.js";
20
20
  import { createRulesetGate, RULESET_HARD_FAIL_MSG, } from "./ruleset.js";
21
21
  import * as orchestrationMarker from "./orchestration/marker.js";
22
+ import * as modelMode from "./orchestration/model-mode.js";
22
23
  import { startLivenessHeartbeat } from "./orchestration/liveness.js";
24
+ import { ensureParentMarker } from "./launch-prompt.js";
23
25
  const agents = new Map();
24
26
  const MAX_CLAUDE = 5;
25
27
  const MAX_CODEX = 5;
@@ -51,6 +53,23 @@ const TASK_CATEGORY_GLOSS = "REQUIRED. Task shape -> routing category (server pi
51
53
  function errorResult(text) {
52
54
  return { content: [{ type: "text", text }], isError: true };
53
55
  }
56
+ export function classifyFailureReason(reason, stderr) {
57
+ const text = `${reason}\n${stderr}`;
58
+ return /\b429\b|\b5\d{2}\b|quota|usage.?cap|rate.?limit|timeout|connection.?reset|ECONNRESET|ETIMEDOUT|ECONNREFUSED|too many requests|service unavailable|server error|overloaded/i.test(text)
59
+ ? "transient_provider"
60
+ : "permanent";
61
+ }
62
+ function buildFailoverNote(skipped, winner) {
63
+ const top = skipped[0];
64
+ const topLabel = `${top.model}@${top.effort} (${top.provider})`;
65
+ const winnerLabel = `${winner.model}@${winner.effort} (${winner.provider})`;
66
+ return `Rank-1 candidate ${topLabel} failed with ${top.failure_type === "transient_provider" ? "a transient provider error" : "a permanent error"}; auto-selected ${winnerLabel}.`;
67
+ }
68
+ function failureTypeForError(error, stderr) {
69
+ return error.isTransient
70
+ ? "transient_provider"
71
+ : classifyFailureReason(error.message, stderr);
72
+ }
54
73
  const isWindows = process.platform === "win32";
55
74
  let _npmPrefix = null;
56
75
  function getNpmPrefix() {
@@ -116,11 +135,15 @@ reconcileInterval.unref();
116
135
  // initialize (per the MCP spec the initialize result has an `instructions`
117
136
  // field) rather than re-injecting it on every turn. The bundled per-turn hook
118
137
  // injects only a small compact reminder; this is the durable, full explanation.
119
- const ORCHESTRATION_INSTRUCTIONS = "BINDING IN BOTH MODES - SOLE CHANNEL: while this server is connected, EVERY sub-agent launch goes through launch_agent; harness Task/Agent tools AND any other spawn path (shell claude -p/codex exec, skills) FORBIDDEN. 5-CALL RULE: >5 tool calls needed for the request (all turns, no reset), or >5 already spent? If yes you MUST use orchestration mode + this server; OFF -> ask the user before enabling, a decline authorizes inline for that request. ORCHESTRATION MODE (orchestration-mode tool). WHAT: per-project toggle for LONG-HORIZON work that would fill the context window inline. OPERATING MODEL: when ON you are a delegate-default orchestrator: decompose, DELEGATE/offload; EVERY reply starts: route: delegate|inline - <reason>. MUST DELEGATE/OFFLOAD: pure compute; any payload >50KB or >200 lines via temp scratch-file path handoff (%TEMP% / /tmp); mixed = SPLIT (one MCP-bound step != whole-task inline); keep the orchestrator context lean. INLINE BY RIGHT - the ONLY exemption: steps bound to main-session-only capability (non-inheritable MCP tools, interactive/consent tools, verify loops = re-run existing checks); state which and why. CONFLICT ORDER: safety-scope > user instruction this turn > 5-CALL RULE/delegate-default. User tool-pin re-partitions work; never suspends mode. PERSISTENCE: per-project marker; survives restarts/sessions until disabled with user permission. CARRYOVER: inherited-ON gets a notice ONCE per marker; you MUST tell the user it auto-activated, ask keep ON?, advise fit. DISABLE: never on own initiative. You MAY propose it on task-fit mismatch (bounded/interactive/MCP-bound): explain WHAT+WHY, ask via AskUserQuestion (Claude) / request-user-input (Codex); only explicit approval may call orchestration-mode enabled:false. Declined -> continue under inline-by-right; ask once per topic, never re-nag. Per-turn injection: CLI hosts with bundled hook only; desktop hosts toggle the marker but inject nothing.";
120
- 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.";
138
+ // Canonical A2 mirror fragment retained byte-identical for
139
+ // test/mirror-fragments.test.mjs while ORCHESTRATION_INSTRUCTIONS below stays
140
+ // compressed under MCP metadata limits:
141
+ // READ-ESCALATION LADDER (the orchestrator's only read channels, in order): (1) subagent-mcp `poll_agent` TAIL; (2) if the tail is insufficient, dispatch ONE sub-agent to return a single summary of <=100 lines, trusted as-is (no separate verification step); (3) anything larger: the USER reads the document directly. No reads or writes occur outside these channels. An empty or stalled tail means the agent is ALIVE, not dead — do NOT busy-loop poll_agent; learn completion via `wait`. Large inter-agent data: the orchestrator assigns scratch-file paths (%TEMP% on Windows, /tmp on POSIX) in prompts; the producing sub-agent writes, the consuming sub-agent reads; the orchestrator NEVER reads those files.
142
+ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (full spec: docs/spec/dev-loop/orchestration-directive-architecture.md).\n\nPRECEDENCE. The latest <subagent-mcp state=\"...\"> hook tag and repo/system safety rules are co-supreme; genuine conflict => STOP and ask the user. Only the hook state changes ON/OFF.\n\nSOLE CHANNEL. Every sub-agent launch uses launch_agent; never harness Task/Agent or shell-spawned agents.\n\nORCHESTRATION ON. You are a delegate-ONLY orchestrator. Use only the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex) and subagent-mcp. No direct reads/writes; inline-by-right does not exist. Non-delegable atomic step: ask for a one-time exception, do only that step, then resume delegating.\n\nREAD LADDER. poll_agent tail -> one <=100-line summarizer sub-agent, trusted as-is -> else the USER reads it. Large handoffs use scratch-file paths; producer writes, consumer reads; orchestrator never reads those files. Empty/stalled tail means ALIVE; use wait.\n\nORCHESTRATION OFF. If total context footprint since last upgrade ask exceeds 200 lines, after that turn STOP and ask whether to switch ON; reset count only when you ask.\n\nDROPOUT WHILE ON: HALT and ask; stay halted until restored. SUB-AGENT EXEMPTION: a prompt whose literal FIRST LINE begins \"<this is a request from a parent process>\" skips this regime.\n\nMODEL SELECTION MODE. Default smart rejects provider/model/effort selectors; launch_agent auto-picks. user-approved-overrides lasts 30 minutes, expires lazily on launch_agent, and must be enabled only after explicit user authorization via AskUserQuestion/request-user-input.";
143
+ 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.";
121
144
  const server = new McpServer({
122
145
  name: "subagent-mcp",
123
- version: "2.8.6",
146
+ version: "2.8.8",
124
147
  description: "Launches always-interactive local Claude and Codex sub-agent sessions. Claude uses the Claude Agent SDK over the local Claude Code executable; Codex uses `codex app-server` over stdio. The server does not call Anthropic or OpenAI HTTP APIs directly.",
125
148
  }, {
126
149
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -155,9 +178,8 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
155
178
  const running = countProcessing(candidate.provider);
156
179
  const max = candidate.provider === "claude" ? MAX_CLAUDE : MAX_CODEX;
157
180
  if (running >= max) {
158
- return {
159
- reason: `Maximum ${max} concurrent ${candidate.provider} agents already running. Current: ${running}`,
160
- };
181
+ const reason = `Maximum ${max} concurrent ${candidate.provider} agents already running. Current: ${running}`;
182
+ return { reason, failure_type: "permanent" };
161
183
  }
162
184
  // Build the command. haiku ignores effort; pass "high" placeholder for the
163
185
  // "none" sentinel (buildCommand drops it for haiku anyway).
@@ -169,13 +191,14 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
169
191
  cmd = resolveExe(candidate.provider);
170
192
  }
171
193
  catch (e) {
172
- return { reason: e instanceof Error ? e.message : String(e) };
194
+ const reason = e instanceof Error ? e.message : String(e);
195
+ return { reason, failure_type: "permanent" };
173
196
  }
174
197
  // Fast-fail absolute paths only. Bare names intentionally rely on PATH; spawn
175
198
  // below resolves them and reports ENOENT/EACCES through the same failure path.
176
199
  if (isAbsolute(cmd) && !existsSync(cmd)) {
177
200
  cleanupUcSettingsPath(buildResult.ucSettingsPath);
178
- return { reason: `CLI executable not found: ${cmd}` };
201
+ return { reason: `CLI executable not found: ${cmd}`, failure_type: "permanent" };
179
202
  }
180
203
  let driver;
181
204
  try {
@@ -193,9 +216,24 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
193
216
  catch (error) {
194
217
  // Synchronous spawn throw (rare) — clean up and report as a launch failure.
195
218
  cleanupUcSettingsPath(buildResult.ucSettingsPath);
196
- return { reason: error instanceof Error ? error.message : String(error) };
219
+ const reason = error instanceof Error ? error.message : String(error);
220
+ return { reason, failure_type: classifyFailureReason(reason, "") };
197
221
  }
198
222
  const childProcess = driver.process;
223
+ let definitelyStarted = false;
224
+ const definitelyStartedProbe = driver.definitelyStarted.then(() => {
225
+ definitelyStarted = true;
226
+ return true;
227
+ }, () => {
228
+ return false;
229
+ });
230
+ const readStartedBoundary = async () => {
231
+ await Promise.race([
232
+ definitelyStartedProbe,
233
+ new Promise((resolve) => setTimeout(resolve, 0)),
234
+ ]);
235
+ return definitelyStarted;
236
+ };
199
237
  // Await the one-shot spawn/error race. The 'error' handler is attached BEFORE
200
238
  // we await so an async ENOENT cannot escape as an unhandled event.
201
239
  try {
@@ -216,7 +254,8 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
216
254
  }
217
255
  catch { }
218
256
  cleanupUcSettingsPath(buildResult.ucSettingsPath);
219
- return { reason: err instanceof Error ? err.message : String(err) };
257
+ const reason = err instanceof Error ? err.message : String(err);
258
+ return { reason, failure_type: classifyFailureReason(reason, "") };
220
259
  }
221
260
  // Spawn succeeded. Register the agent exactly as before. Keep a persistent
222
261
  // 'error' handler so a LATE spawn error never crashes the process; fold it
@@ -366,6 +405,15 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
366
405
  }
367
406
  catch (err) {
368
407
  startError = err instanceof Error ? err : new Error(String(err));
408
+ await readStartedBoundary();
409
+ if (!definitelyStarted) {
410
+ const reason = startError.message;
411
+ cleanupUcSettings(agentState);
412
+ return {
413
+ reason,
414
+ failure_type: failureTypeForError(startError, agentState.stderr),
415
+ };
416
+ }
369
417
  }
370
418
  // Post-spawn grace window: a 'spawn' win alone is NOT success — a provider
371
419
  // driver that starts then dies immediately must advance the attempt loop, not
@@ -398,14 +446,22 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
398
446
  // turn.completed fast completion must never be misread as a launch
399
447
  // failure and the task silently re-executed on the next candidate.
400
448
  await closedAfterFlush;
401
- if (!agentState.turnCompleted) {
449
+ const startedBeforeExit = await readStartedBoundary();
450
+ if (!agentState.turnCompleted && !startedBeforeExit) {
402
451
  const tail = agentState.stderr.trim().split("\n").slice(-1)[0] ?? "";
452
+ const reason = `process exited (code ${earlyExit.code ?? earlyExit.signal}) within ${SPAWN_GRACE_MS}ms of spawn${tail ? `: ${tail}` : ""}`;
403
453
  return {
404
- reason: `process exited (code ${earlyExit.code ?? earlyExit.signal}) within ${SPAWN_GRACE_MS}ms of spawn${tail ? `: ${tail}` : ""}`,
454
+ reason,
455
+ failure_type: classifyFailureReason(reason, agentState.stderr),
405
456
  };
406
457
  }
407
458
  }
408
459
  else if (startError && !agentState.turnCompleted) {
460
+ const startedBeforeStartError = await readStartedBoundary();
461
+ if (startedBeforeStartError) {
462
+ agents.set(agentId, agentState);
463
+ return { agentId };
464
+ }
409
465
  // Lived past the grace window but the startup write failed: the child is
410
466
  // not accepting input, so advance the loop rather than register an agent
411
467
  // that never received its prompt.
@@ -414,7 +470,10 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
414
470
  }
415
471
  catch { }
416
472
  cleanupUcSettings(agentState);
417
- return { reason: startError.message };
473
+ return {
474
+ reason: startError.message,
475
+ failure_type: failureTypeForError(startError, agentState.stderr),
476
+ };
418
477
  }
419
478
  }
420
479
  agents.set(agentId, agentState);
@@ -429,16 +488,20 @@ function sameTriples(a, b) {
429
488
  return a.every((c, i) => c.provider === b[i].provider && c.model === b[i].model && c.effort === b[i].effort);
430
489
  }
431
490
  // Tool 1: launch_agent
432
- server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory first attempt unless an override is licensed below): pass only `prompt` + `task_category` and NO overrides; the server picks the best provider/model/effort for that category from its routing table, launches the top candidate, and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are overrides — licensed on 1st/2nd attempts ONLY when the task verifiably requires a specific capability; STATE that capability when overriding; if you pass `model` you must also pass `provider`, and if you pass `effort` you must pass both `provider` and `model`. SOLE CHANNEL: while this server is connected this tool is the ONLY sanctioned way to spawn sub-agents, in BOTH orchestration states — harness-native Task/Agent tools are FORBIDDEN for sub-agent launches. PROMPT RULE: the FIRST line of every `prompt` MUST be \"<this is a request from a parent process>\" (sub-agent self-identification). Unsure which task_category fits? Don't submit one amorphous task — SPLIT into atomic steps that each map to a single category, one agent per step. ultracode effort is Opus 4.8+ only. Claude uses a Claude Agent SDK logical session over the local Claude executable; Codex uses a `codex app-server` child. Children run with env SUBAGENT_MCP_SUBAGENT=1 so the orchestration hooks skip them (they are not orchestrators and don't re-trigger carryover). Launch returns status `processing` (alive); a later `stalled` is alive-but-quiet (thinking or awaiting a temp-file handoff), NOT dead — wait or re-poll, don't kill (see poll_agent). DEADLOCK RULE: you MUST ALWAYS set `deadlock=true` when 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory (the 3rd attempt onward; re-wording or re-splitting the prompt does NOT make it a different task), and NEVER otherwise — from the 3rd attempt deadlock outranks any capability override: drop provider/model/effort.", {
491
+ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory first attempt unless an override is licensed below): pass only `prompt` + `task_category` and NO overrides; the server picks the best provider/model/effort for that category from its routing table, launches the top candidate, and silently falls back to the next-best on launch failure. `provider`/`model`/`effort` are overrides — licensed on 1st/2nd attempts ONLY when the task verifiably requires a specific capability; STATE that capability when overriding; if you pass `model` you must also pass `provider`, and if you pass `effort` you must pass both `provider` and `model`. SOLE CHANNEL: while this server is connected this tool is the ONLY sanctioned way to spawn sub-agents, in BOTH orchestration states — harness-native Task/Agent tools are FORBIDDEN for sub-agent launches. PROMPT RULE: every sub-agent `prompt`'s first line is the self-identification marker \"<this is a request from a parent process>\"; the server now UPSERTS this marker as the true first line automatically (idempotent — it is never duplicated and your prompt body is never mutated), so you need not add it yourself. Unsure which task_category fits? Don't submit one amorphous task — SPLIT into atomic steps that each map to a single category, one agent per step. ultracode effort is Opus 4.8+ only. Claude uses a Claude Agent SDK logical session over the local Claude executable; Codex uses a `codex app-server` child. Children run with env SUBAGENT_MCP_SUBAGENT=1 so the orchestration hooks skip them (they are not orchestrators and don't re-trigger carryover). Launch returns status `processing` (alive); a later `stalled` is alive-but-quiet (thinking or awaiting a temp-file handoff), NOT dead — wait or re-poll, don't kill (see poll_agent). DEADLOCK RULE: you MUST ALWAYS set `deadlock=true` when 2 launch attempts for the SAME atomic task have already failed or been unsatisfactory (the 3rd attempt onward; re-wording or re-splitting the prompt does NOT make it a different task), and NEVER otherwise — from the 3rd attempt deadlock outranks any capability override: drop provider/model/effort.", {
433
492
  task_category: z.enum(TASK_CATEGORIES).describe(TASK_CATEGORY_GLOSS),
434
493
  prompt: z.string().min(1),
435
494
  provider: z.enum(["claude", "codex"]).optional(),
436
495
  model: z.enum(["haiku", "sonnet", "opus", "opus-4-8", "gpt-5.5"]).optional(),
437
- effort: z.enum(["low", "medium", "high", "xhigh", "max", "ultracode"]).optional(),
496
+ effort: z.enum(["medium", "high", "xhigh", "max", "ultracode"]).optional(),
438
497
  cwd: z.string().optional(),
439
498
  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."),
440
499
  }, async (params) => {
441
- const { task_category, provider, model, effort, prompt, deadlock } = params;
500
+ const { task_category, provider, model, effort, deadlock } = params;
501
+ // D19/D20/S8: server silently upserts the parent-process marker as the TRUE
502
+ // first line of every sub-agent prompt (idempotent; never duplicates; never
503
+ // mutates the body). This is what makes the child first-line exemption fire.
504
+ const prompt = ensureParentMarker(params.prompt);
442
505
  const agentCwd = params.cwd || process.cwd();
443
506
  // 1-5. Param-presence validation (zod already constrains task_category, but
444
507
  // hard-validate so the spec error text — valid list + hints, and the
@@ -448,6 +511,10 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
448
511
  if (presenceError) {
449
512
  return errorResult(presenceError);
450
513
  }
514
+ const modelGate = modelMode.gateLaunch(agentCwd, { provider, model, effort });
515
+ if (!modelGate.allowed) {
516
+ return errorResult(modelGate.message ?? modelMode.SELECTOR_REJECTION_MESSAGE);
517
+ }
451
518
  // 6. Build the candidate list per mode.
452
519
  const overrides = { provider, model, effort };
453
520
  const isExplicit = !!(provider && model && effort);
@@ -537,6 +604,17 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
537
604
  if (branch === "performance") {
538
605
  deadlockWindow.consume();
539
606
  }
607
+ if (skipped.length > 0) {
608
+ const registeredAgent = agents.get(outcome.agentId);
609
+ if (registeredAgent) {
610
+ registeredAgent.failoverFrom = skipped.map((s) => ({
611
+ provider: s.provider,
612
+ model: s.model,
613
+ effort: s.effort,
614
+ failure_type: s.failure_type,
615
+ }));
616
+ }
617
+ }
540
618
  return {
541
619
  content: [
542
620
  {
@@ -554,6 +632,18 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
554
632
  ruleset_original_selection: rulesetOriginalSelection,
555
633
  }
556
634
  : {}),
635
+ ...(skipped.length > 0
636
+ ? {
637
+ failover_occurred: true,
638
+ failover_from: skipped.map((s) => ({
639
+ provider: s.provider,
640
+ model: s.model,
641
+ effort: s.effort,
642
+ failure_type: s.failure_type,
643
+ })),
644
+ failover_note: buildFailoverNote(skipped, candidate),
645
+ }
646
+ : {}),
557
647
  }),
558
648
  },
559
649
  ],
@@ -564,17 +654,24 @@ server.tool("launch_agent", "Spawn a sub-agent session. AUTO MODE (mandatory fir
564
654
  effort: candidate.effort,
565
655
  provider: candidate.provider,
566
656
  reason: outcome.reason,
657
+ failure_type: outcome.failure_type,
567
658
  });
659
+ if (!pureAuto) {
660
+ break;
661
+ }
568
662
  }
569
- // 7. All candidates failed. A ruleset-modified explicit launch may have
570
- // attempted N≠1 candidates only the numbered ALL_FAILED shape can
571
- // report that, so the explicit shape is reserved for unmodified launches.
572
- if (isExplicit && !rulesetApplied) {
663
+ // 7. All candidates failed. Override selector modes are single-attempt hard
664
+ // failures; pure auto mode reports the attempted candidates.
665
+ if (!pureAuto) {
573
666
  const f = skipped[0];
574
- return errorResult(`Error: explicit launch ${f.model}@${f.effort} (${f.provider}) failed: ${f.reason}.\n${AUTO_HINT}`);
667
+ const transientNote = f.failure_type === "transient_provider"
668
+ ? `\nNote: this failure appears transient (quota/rate-limit/network). Switch to auto mode (omit provider/model/effort) for automatic silent failover to the next-best provider.`
669
+ : "";
670
+ const label = isExplicit ? "explicit" : "override";
671
+ return errorResult(`Error: ${label} launch ${f.model}@${f.effort} (${f.provider}) failed: ${f.reason}.${transientNote}\n${AUTO_HINT}`);
575
672
  }
576
673
  const lines = skipped
577
- .map((s, i) => ` ${i + 1}. ${s.model}@${s.effort} (${s.provider}): ${s.reason}`)
674
+ .map((s, i) => ` ${i + 1}. ${s.model}@${s.effort} (${s.provider}) [${s.failure_type}]: ${s.reason}`)
578
675
  .join("\n");
579
676
  return errorResult(`Error: all ${skipped.length} candidate launches failed for task_category ${task_category}:\n${lines}\n${SPLIT_HINT}\n${AUTO_HINT}`);
580
677
  });
@@ -629,6 +726,12 @@ server.tool("poll_agent", "Get an agent's current status and output. Status `pro
629
726
  ruleset_original_selection: agent.rulesetOriginalSelection,
630
727
  }
631
728
  : {}),
729
+ ...(agent.failoverFrom && agent.failoverFrom.length > 0
730
+ ? {
731
+ failover_occurred: true,
732
+ failover_from: agent.failoverFrom,
733
+ }
734
+ : {}),
632
735
  recent_stream: agent.visibleStream.map((it) => ({
633
736
  type: it.type,
634
737
  text: it.text,
@@ -897,7 +1000,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
897
1000
  };
898
1001
  });
899
1002
  // Tool 7: orchestration-mode
900
- server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF, omit = query current state. SOLE CHANNEL: the subagent MCP is the ONLY sanctioned channel for launching sub-agents whether this mode is ON or OFF, and the 5-CALL RULE (>5 tool calls needed for the request all turns, no reset or >5 already spent ON: delegate via the subagent MCP; OFF: ask the user before enabling) binds in BOTH states toggling OFF lifts neither obligation. The FULL operating model + governance is carried in this server's MCP `instructions` (read once at initialize) — this is the operational summary only; do not act on the mode without that detail. WHAT: a per-project toggle for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as an orchestrator with delegate-default, but steps bound to main-session-only capability stay INLINE BY RIGHT (state which + why). PERSISTENCE: a per-project marker keyed by cwd; absence of the marker = OFF = no injection; once ON it persists across restarts/sessions until a permitted disable (it does NOT reset on a new session). CARRYOVER: if ON was inherited from a PRIOR session (provenance = carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker, never per turn) — you MUST then notify the user it auto-activated and confirm whether to keep it ON. DISABLE: never on your own initiative; you MAY PROPOSE turning it OFF on task-fit mismatch, but only EXPLICIT user permission (AskUserQuestion on Claude, request-user-input on Codex) may set enabled:false. Per-turn injection fires only in CLI hosts that load the bundled hook; desktop hosts toggle the marker but inject nothing (documented degradation).", {
1003
+ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF, omit = query current state. SOLE CHANNEL: the subagent MCP is the ONLY sanctioned channel for launching sub-agents whether this mode is ON or OFF — toggling OFF does not lift that obligation. When OFF, run the per-turn upgrade check: a long-horizon task = any whose TOTAL context footprint (input read + output produced) exceeds 200 lines, measured CUMULATIVELY since your last upgrade ask; after EVERY user turn, if it qualifies, STOP and ask the user whether to switch ON (ask every qualifying turn; a decline does not latch; reset the count only when you actually ask). The FULL operating model + governance is carried in this server's MCP `instructions` (read once at initialize) — this is the operational summary only; do not act on the mode without that detail. WHAT: a per-project toggle for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as an orchestrator with delegate-default, but steps bound to main-session-only capability stay INLINE BY RIGHT (state which + why). PERSISTENCE: a per-project marker keyed by cwd; absence of the marker = OFF = no injection; once ON it persists across restarts/sessions until a permitted disable (it does NOT reset on a new session). CARRYOVER: if ON was inherited from a PRIOR session (provenance = carried-over, not user-enabled this session), the bundled hook prepends a ONE-TIME notice (once per marker, never per turn) — you MUST then notify the user it auto-activated and confirm whether to keep it ON. DISABLE: never on your own initiative; you MAY PROPOSE turning it OFF on task-fit mismatch, but only EXPLICIT user permission (AskUserQuestion on Claude, request-user-input on Codex) may set enabled:false. Per-turn injection fires only in CLI hosts that load the bundled hook; desktop hosts toggle the marker but inject nothing (documented degradation).", {
901
1004
  enabled: z.boolean().optional(),
902
1005
  }, async (params) => {
903
1006
  const cwd = process.cwd();
@@ -920,6 +1023,28 @@ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MOD
920
1023
  ],
921
1024
  };
922
1025
  });
1026
+ // Tool 8: model-selection-mode
1027
+ server.tool("model-selection-mode", "Set or query per-project MODEL SELECTION MODE, which gates launch_agent's `provider`/`model`/`effort` selectors. `mode`: \"smart\" or \"user-approved-overrides\"; omit to query current state. \"smart\" is the DEFAULT and is used whenever the mode is unset — in smart, launch_agent REJECTS any call that supplies provider/model/effort and the server auto-picks the best model for the task_category. \"user-approved-overrides\" opens a 30-MINUTE window during which selectors are HONORED; the window is enforced LAZILY — the mode reverts to smart on the next launch_agent call after the 30 minutes elapse — and re-enabling does NOT extend an already-active window. HONOR-BASED, parallel to orchestration-mode: you MUST NOT set this to \"user-approved-overrides\" without explicit interactive USER authorization obtained via the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex; a plain yes/no exchange if neither exists). This tool CANNOT verify that authorization — never enable it on your own initiative. PERSISTENCE: per-project state keyed by cwd; both the mode and the override-window enable-timestamp persist across MCP server restarts (the remaining window is restored, not reset).", {
1028
+ mode: z.enum(["smart", "user-approved-overrides"]).optional(),
1029
+ }, async (params) => {
1030
+ const cwd = process.cwd();
1031
+ if (params.mode)
1032
+ modelMode.setMode(cwd, params.mode);
1033
+ const r = modelMode.resolveMode(cwd);
1034
+ return {
1035
+ content: [
1036
+ {
1037
+ type: "text",
1038
+ text: JSON.stringify({
1039
+ model_selection_mode: r.mode,
1040
+ enabled_at: r.enabled_at,
1041
+ window_remaining_ms: r.window_remaining_ms,
1042
+ marker_path: modelMode.modelModePath(cwd),
1043
+ }, null, 2),
1044
+ },
1045
+ ],
1046
+ };
1047
+ });
923
1048
  // Connect the stdio transport only when run as the entry point (the bin), NOT
924
1049
  // when this module is imported (e.g. test/handler-validation.test.mjs importing
925
1050
  // the exported validatePresence). Connecting on import would block the test on