@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/README.md +136 -136
- package/directives/carryover-claude.md +6 -9
- package/directives/carryover-codex.md +6 -9
- package/directives/orchestration-claude.md +9 -16
- package/directives/orchestration-codex.md +9 -16
- package/directives/reminder-off-claude.md +5 -5
- package/directives/reminder-off-codex.md +5 -5
- package/directives/reminder-on.md +10 -4
- package/directives/short-off.md +1 -1
- package/directives/short-on.md +1 -1
- package/dist/advanced-ruleset.py +67 -67
- package/dist/drivers.js +59 -3
- package/dist/effort.js +6 -3
- package/dist/hooks/orchestration-claude.js +0 -4
- package/dist/index.js +148 -23
- package/dist/init.js +63 -28
- package/dist/launch-prompt.js +29 -0
- package/dist/orchestration/hook-core.js +1 -1
- package/dist/orchestration/model-mode.js +132 -0
- package/dist/orchestration/pretool.js +10 -66
- package/dist/routing-table.json +3561 -4149
- package/dist/routing.js +1 -1
- package/dist/ruleset-scaffold.js +1 -1
- package/package.json +53 -53
- package/scripts/postinstall.mjs +102 -102
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) =>
|
|
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(
|
|
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 (["
|
|
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:
|
|
36
|
+
throw new Error(`max effort is not valid for gpt-5.5 (Codex). Valid: medium, high, xhigh.`);
|
|
34
37
|
}
|
|
35
|
-
if (["
|
|
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
|
-
|
|
120
|
-
|
|
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.
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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:
|
|
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(["
|
|
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,
|
|
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.
|
|
570
|
-
//
|
|
571
|
-
|
|
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
|
-
|
|
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,
|
|
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
|