@heretyc/subagent-mcp 2.10.3 → 2.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +158 -187
- package/directives/carryover-codex.md +4 -2
- package/directives/reminder-on.md +1 -1
- package/directives/short-on.md +1 -1
- package/dist/advanced-ruleset.py +67 -67
- package/dist/concurrency.js +12 -6
- package/dist/config-scaffold.js +1 -1
- package/dist/drivers.js +106 -0
- package/dist/global-concurrency.jsonc +29 -28
- package/dist/index.js +171 -56
- package/dist/orchestration/hook-core.js +16 -6
- package/dist/routing-table.json +3561 -3561
- package/dist/ruleset-scaffold.js +1 -1
- package/dist/setup.js +2 -0
- package/dist/zombie.js +35 -5
- package/package.json +69 -69
- package/scripts/postinstall.mjs +102 -102
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;
|
|
@@ -1,28 +1,29 @@
|
|
|
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
|
-
//
|
|
19
|
-
// Zombie culling is always enabled. There is no config knob. Before cap
|
|
20
|
-
// rejection, launch/tool/hook paths
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
//
|
|
19
|
+
// Zombie culling is always enabled. There is no config knob. Before cap
|
|
20
|
+
// rejection, launch/tool/hook paths refresh live owned slots, preserve stale
|
|
21
|
+
// slots whose owner server is still alive, and cull stale slots whose owner is
|
|
22
|
+
// gone. Managed stale slots terminate the child process tree, then force-kill
|
|
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
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -24,9 +24,8 @@ import * as modelMode from "./orchestration/model-mode.js";
|
|
|
24
24
|
import { startLivenessHeartbeat } from "./orchestration/liveness.js";
|
|
25
25
|
import { ensureParentMarker } from "./launch-prompt.js";
|
|
26
26
|
const agents = new Map();
|
|
27
|
-
const MAX_CLAUDE = 5;
|
|
28
|
-
const MAX_CODEX = 5;
|
|
29
27
|
const deadlockWindow = createDeadlockWindow();
|
|
28
|
+
const STDOUT_RING_BYTES = 2 * 1024 * 1024;
|
|
30
29
|
// Advanced-ruleset gate: per-process latch with exactly the deadlock-window
|
|
31
30
|
// scoping. The env-check runs lazily at the FIRST launch_agent call; success
|
|
32
31
|
// latches enabled/disabled for the process lifetime, failure never latches.
|
|
@@ -91,9 +90,31 @@ function withZombieReport(result, records) {
|
|
|
91
90
|
result.content[0].text = `${text}\n${report}`;
|
|
92
91
|
return result;
|
|
93
92
|
}
|
|
94
|
-
function
|
|
93
|
+
function slotHeartbeatIntervalMs() {
|
|
94
|
+
return Math.max(1000, Math.min(30_000, Math.floor(zombieLiveIdleMs() / 3)));
|
|
95
|
+
}
|
|
96
|
+
function isLiveAgent(agent) {
|
|
97
|
+
return agent.status === "processing" || agent.status === "stalled";
|
|
98
|
+
}
|
|
99
|
+
function isSameOwnerSlot(agent, slotMeta) {
|
|
100
|
+
if (!slotMeta)
|
|
101
|
+
return true;
|
|
102
|
+
if (slotMeta.agent_id !== agent.id)
|
|
103
|
+
return false;
|
|
104
|
+
return slotMeta.server_pid === null || slotMeta.server_pid === process.pid;
|
|
105
|
+
}
|
|
106
|
+
function adoptNewerSlotActivity(agent, slotMeta) {
|
|
107
|
+
if (!slotMeta?.last_activity_ms || !isSameOwnerSlot(agent, slotMeta))
|
|
108
|
+
return;
|
|
109
|
+
if (slotMeta.last_activity_ms > agent.slotLastActivity) {
|
|
110
|
+
agent.slotLastActivity = slotMeta.last_activity_ms;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
function updateSlotMetadata(agent, slotActivityMs) {
|
|
95
114
|
if (!agent.slotPath)
|
|
96
115
|
return;
|
|
116
|
+
const lastActivityMs = Math.max(agent.slotLastActivity, agent.lastActivity, slotActivityMs ?? 0);
|
|
117
|
+
agent.slotLastActivity = lastActivityMs;
|
|
97
118
|
writeSlotMetadata(agent.slotPath, {
|
|
98
119
|
agent_id: agent.id,
|
|
99
120
|
server_pid: process.pid,
|
|
@@ -101,10 +122,25 @@ function updateSlotMetadata(agent) {
|
|
|
101
122
|
cwd: agent.cwd,
|
|
102
123
|
started_at: new Date(agent.startedAt).toISOString(),
|
|
103
124
|
started_at_ms: agent.startedAt,
|
|
104
|
-
last_activity_ms:
|
|
125
|
+
last_activity_ms: lastActivityMs,
|
|
105
126
|
status: agent.status,
|
|
106
127
|
});
|
|
107
128
|
}
|
|
129
|
+
function refreshLiveSlotMetadata(agent, now) {
|
|
130
|
+
if (!agent.slotPath || !isLiveAgent(agent))
|
|
131
|
+
return;
|
|
132
|
+
const slotMeta = readSlotMetadata(agent.slotPath);
|
|
133
|
+
if (!isSameOwnerSlot(agent, slotMeta))
|
|
134
|
+
return;
|
|
135
|
+
adoptNewerSlotActivity(agent, slotMeta);
|
|
136
|
+
const diskActivityMs = slotMeta?.last_activity_ms ?? null;
|
|
137
|
+
const shouldRefresh = slotMeta === null ||
|
|
138
|
+
diskActivityMs === null ||
|
|
139
|
+
diskActivityMs < agent.slotLastActivity ||
|
|
140
|
+
now - agent.slotLastActivity >= slotHeartbeatIntervalMs();
|
|
141
|
+
if (shouldRefresh)
|
|
142
|
+
updateSlotMetadata(agent, now);
|
|
143
|
+
}
|
|
108
144
|
function spawnProcessTreeKill(pid, force) {
|
|
109
145
|
const commands = buildProcessTreeKillCommands(pid);
|
|
110
146
|
const command = force ? commands.force : commands.graceful;
|
|
@@ -202,17 +238,13 @@ function runToolMaintenance() {
|
|
|
202
238
|
if (agent.status === "zombie_killed")
|
|
203
239
|
continue;
|
|
204
240
|
reconcileAgent(agent, now);
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
const live = agent.status === "processing" || agent.status === "stalled";
|
|
210
|
-
if (live && now - agent.lastActivity > zombieLiveIdleMs()) {
|
|
211
|
-
const record = markZombieKilled(agent, "stale_live", now);
|
|
212
|
-
records.push(record);
|
|
213
|
-
applied.add(agent.id);
|
|
241
|
+
const live = isLiveAgent(agent);
|
|
242
|
+
if (live) {
|
|
243
|
+
refreshLiveSlotMetadata(agent, now);
|
|
214
244
|
continue;
|
|
215
245
|
}
|
|
246
|
+
const slotMeta = agent.slotPath ? readSlotMetadata(agent.slotPath) : null;
|
|
247
|
+
adoptNewerSlotActivity(agent, slotMeta);
|
|
216
248
|
const terminalButAlive = (agent.status === "finished" || agent.status === "errored" || agent.status === "stopped") &&
|
|
217
249
|
!agent.driver.closed &&
|
|
218
250
|
agent.exitedAt !== null &&
|
|
@@ -257,12 +289,96 @@ function failureTypeForError(error, stderr) {
|
|
|
257
289
|
: classifyFailureReason(error.message, stderr);
|
|
258
290
|
}
|
|
259
291
|
const isWindows = process.platform === "win32";
|
|
260
|
-
|
|
292
|
+
// Resolved lazily and memoized on first use so that plain CLI invocations never
|
|
293
|
+
// spawn `npm prefix -g` at import time. The server warms this once during
|
|
294
|
+
// startup (see main()) so a live, long-lived server never stalls mid-request.
|
|
295
|
+
let npmPrefixCache;
|
|
261
296
|
function getNpmPrefix() {
|
|
262
|
-
if (
|
|
263
|
-
|
|
297
|
+
if (npmPrefixCache === undefined) {
|
|
298
|
+
npmPrefixCache = execSync("npm prefix -g", { encoding: "utf-8" }).trim();
|
|
299
|
+
}
|
|
300
|
+
return npmPrefixCache;
|
|
301
|
+
}
|
|
302
|
+
const CLAUDE_BACKGROUND_RESUME_TEXT = "Your background task has completed. Resume and continue where you left off.";
|
|
303
|
+
export function isClaudeBackgroundWakeLine(line) {
|
|
304
|
+
const trimmed = line.trim();
|
|
305
|
+
if (!trimmed)
|
|
306
|
+
return false;
|
|
307
|
+
try {
|
|
308
|
+
const evt = JSON.parse(trimmed);
|
|
309
|
+
if (evt.type === "result")
|
|
310
|
+
return false;
|
|
311
|
+
const method = typeof evt.method === "string" ? evt.method : "";
|
|
312
|
+
const type = typeof evt.type === "string" ? evt.type : "";
|
|
313
|
+
const name = typeof evt.name === "string" ? evt.name : "";
|
|
314
|
+
const marker = `${method} ${type} ${name}`.toLowerCase();
|
|
315
|
+
if (marker.includes("task_notification") || marker.includes("task-notification"))
|
|
316
|
+
return true;
|
|
317
|
+
if (marker.includes("background") && marker.includes("complete"))
|
|
318
|
+
return true;
|
|
319
|
+
if (marker.includes("taskoutput") && marker.includes("complete"))
|
|
320
|
+
return true;
|
|
321
|
+
// No concrete Claude bg-task-complete JSONL fixture exists in-repo. As a
|
|
322
|
+
// backstop, any non-result JSONL activity after a finished turn means the
|
|
323
|
+
// still-live SDK stream observed something new and needs a nudge.
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
function noteBackgroundResumeSignal(agent, at) {
|
|
331
|
+
if (agent.provider !== "claude" || agent.driver.closed)
|
|
332
|
+
return;
|
|
333
|
+
agent.bgTaskResumeObservedAt = Math.max(agent.bgTaskResumeObservedAt ?? 0, at);
|
|
334
|
+
}
|
|
335
|
+
export async function maybeResumeAfterBackgroundTask(agent, now = Date.now()) {
|
|
336
|
+
if (agent.provider !== "claude")
|
|
337
|
+
return false;
|
|
338
|
+
if (agent.driver.closed)
|
|
339
|
+
return false;
|
|
340
|
+
if (agent.bgTaskResumeInFlight)
|
|
341
|
+
return false;
|
|
342
|
+
const observedAt = agent.bgTaskResumeObservedAt ?? 0;
|
|
343
|
+
if (observedAt === 0 || observedAt <= (agent.bgTaskResumeSentAt ?? 0))
|
|
344
|
+
return false;
|
|
345
|
+
agent.bgTaskResumeInFlight = true;
|
|
346
|
+
try {
|
|
347
|
+
if (typeof agent.driver.notifyTaskComplete === "function") {
|
|
348
|
+
await agent.driver.notifyTaskComplete(CLAUDE_BACKGROUND_RESUME_TEXT);
|
|
349
|
+
}
|
|
350
|
+
else {
|
|
351
|
+
await agent.driver.send(CLAUDE_BACKGROUND_RESUME_TEXT);
|
|
352
|
+
}
|
|
353
|
+
agent.bgTaskResumeSentAt = observedAt;
|
|
354
|
+
agent.status = "processing";
|
|
355
|
+
agent.lastExitCode = agent.exitCode;
|
|
356
|
+
agent.lastExitedAt = agent.exitedAt;
|
|
357
|
+
agent.exitCode = null;
|
|
358
|
+
agent.exitedAt = null;
|
|
359
|
+
agent.waitReported = false;
|
|
360
|
+
agent.turnCompleted = false;
|
|
361
|
+
agent.lastActivity = now;
|
|
362
|
+
return true;
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
agent.bgTaskResumeInFlight = false;
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
function handleCompletedStdoutLines(agent, lines, at) {
|
|
369
|
+
let turnWasComplete = agent.turnCompleted === true;
|
|
370
|
+
for (const line of lines) {
|
|
371
|
+
if (turnWasComplete && isClaudeBackgroundWakeLine(line)) {
|
|
372
|
+
noteBackgroundResumeSignal(agent, at);
|
|
373
|
+
}
|
|
374
|
+
if (isTurnCompletedLine(agent.provider, line)) {
|
|
375
|
+
turnWasComplete = true;
|
|
376
|
+
agent.turnCompleted = true;
|
|
377
|
+
agent.status = "finished";
|
|
378
|
+
if (agent.exitedAt === null)
|
|
379
|
+
agent.exitedAt = at;
|
|
380
|
+
}
|
|
264
381
|
}
|
|
265
|
-
return _npmPrefix;
|
|
266
382
|
}
|
|
267
383
|
function resolveExe(provider) {
|
|
268
384
|
return resolveExeFor(provider, process.platform, { existsSync, npmPrefix: getNpmPrefix });
|
|
@@ -278,17 +394,6 @@ function cleanupUcSettings(agentState) {
|
|
|
278
394
|
agentState.ucSettingsPath = undefined;
|
|
279
395
|
}
|
|
280
396
|
}
|
|
281
|
-
// Concurrency cap accounting: only `processing` agents count against a
|
|
282
|
-
// provider's cap. `stalled` agents (live but quiet past the heartbeat window) do
|
|
283
|
-
// NOT count, freeing a slot while they idle.
|
|
284
|
-
function countProcessing(provider) {
|
|
285
|
-
let count = 0;
|
|
286
|
-
for (const a of agents.values()) {
|
|
287
|
-
if (a.provider === provider && a.status === "processing")
|
|
288
|
-
count++;
|
|
289
|
-
}
|
|
290
|
-
return count;
|
|
291
|
-
}
|
|
292
397
|
// Synchronously reconcile a single agent's status against the pure transition
|
|
293
398
|
// helper. Folds the live process exitCode into AgentState first so an already-
|
|
294
399
|
// exited process is reported as completed/failed immediately (no monitor lag).
|
|
@@ -313,6 +418,13 @@ const reconcileInterval = setInterval(() => {
|
|
|
313
418
|
const now = Date.now();
|
|
314
419
|
for (const agent of agents.values()) {
|
|
315
420
|
reconcileAgent(agent, now);
|
|
421
|
+
refreshLiveSlotMetadata(agent, now);
|
|
422
|
+
if (agent.bgTaskResumeObservedAt && agent.bgTaskResumeObservedAt > (agent.bgTaskResumeSentAt ?? 0)) {
|
|
423
|
+
void maybeResumeAfterBackgroundTask(agent, now).then((resumed) => {
|
|
424
|
+
if (resumed)
|
|
425
|
+
updateSlotMetadata(agent);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
316
428
|
}
|
|
317
429
|
}, 10000);
|
|
318
430
|
reconcileInterval.unref();
|
|
@@ -329,7 +441,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
|
|
|
329
441
|
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.";
|
|
330
442
|
const server = new McpServer({
|
|
331
443
|
name: "subagent-mcp",
|
|
332
|
-
version: "2.
|
|
444
|
+
version: "2.12.1",
|
|
333
445
|
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.",
|
|
334
446
|
}, {
|
|
335
447
|
instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
|
|
@@ -360,13 +472,6 @@ function cleanupUcSettingsPath(ucSettingsPath) {
|
|
|
360
472
|
// process. Any launch-time failure cleans up and is reported so the attempt loop
|
|
361
473
|
// silently advances to the next candidate.
|
|
362
474
|
async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rulesetInfo) {
|
|
363
|
-
// Concurrency cap for this provider.
|
|
364
|
-
const running = countProcessing(candidate.provider);
|
|
365
|
-
const max = candidate.provider === "claude" ? MAX_CLAUDE : MAX_CODEX;
|
|
366
|
-
if (running >= max) {
|
|
367
|
-
const reason = `Maximum ${max} concurrent ${candidate.provider} agents already running. Current: ${running}`;
|
|
368
|
-
return { reason, failure_type: "permanent" };
|
|
369
|
-
}
|
|
370
475
|
// Build the command. haiku ignores effort; pass "high" placeholder for the
|
|
371
476
|
// "none" sentinel (buildCommand drops it for haiku anyway).
|
|
372
477
|
const effortForBuild = candidate.effort === "none" ? "high" : candidate.effort;
|
|
@@ -465,6 +570,8 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
465
570
|
stderr: "",
|
|
466
571
|
exitCode: null,
|
|
467
572
|
exitedAt: null,
|
|
573
|
+
lastExitCode: null,
|
|
574
|
+
lastExitedAt: null,
|
|
468
575
|
// Launch time is the initial heartbeat. Only PARSED VISIBLE provider stream
|
|
469
576
|
// items refresh lastActivity afterwards (see the stdout handler); raw
|
|
470
577
|
// stdout/stderr chunks do NOT, so `stalled` means exactly "no visible
|
|
@@ -476,6 +583,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
476
583
|
waitReported: false,
|
|
477
584
|
visibleStream: [],
|
|
478
585
|
streamBuf: "",
|
|
586
|
+
slotLastActivity: now,
|
|
479
587
|
};
|
|
480
588
|
childProcess.on("error", (err) => {
|
|
481
589
|
// Captured into the stderr tail for debugging. Not a visible provider stream
|
|
@@ -494,6 +602,9 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
494
602
|
// Accumulate all complete lines into stored stdout.
|
|
495
603
|
for (const line of lines) {
|
|
496
604
|
agentState.stdout += line + "\n";
|
|
605
|
+
if (agentState.stdout.length > STDOUT_RING_BYTES) {
|
|
606
|
+
agentState.stdout = agentState.stdout.slice(-STDOUT_RING_BYTES);
|
|
607
|
+
}
|
|
497
608
|
}
|
|
498
609
|
if (items.length > 0) {
|
|
499
610
|
// Heartbeat refreshes only on parsed visible provider stream items,
|
|
@@ -506,11 +617,12 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
506
617
|
// logical interactive session can remain available for later messages.
|
|
507
618
|
// Scan COMPLETE lines only so a marker split across chunks is matched
|
|
508
619
|
// once fully assembled (never on a partial fragment).
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
agentState.
|
|
512
|
-
|
|
513
|
-
|
|
620
|
+
handleCompletedStdoutLines(agentState, lines, at);
|
|
621
|
+
if (agentState.bgTaskResumeObservedAt && agentState.bgTaskResumeObservedAt > (agentState.bgTaskResumeSentAt ?? 0)) {
|
|
622
|
+
void maybeResumeAfterBackgroundTask(agentState, at).then((resumed) => {
|
|
623
|
+
if (resumed)
|
|
624
|
+
updateSlotMetadata(agentState);
|
|
625
|
+
});
|
|
514
626
|
}
|
|
515
627
|
});
|
|
516
628
|
}
|
|
@@ -530,15 +642,13 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
|
|
|
530
642
|
agentState.streamBuf = "";
|
|
531
643
|
for (const line of lines) {
|
|
532
644
|
agentState.stdout += line + "\n";
|
|
645
|
+
if (agentState.stdout.length > STDOUT_RING_BYTES) {
|
|
646
|
+
agentState.stdout = agentState.stdout.slice(-STDOUT_RING_BYTES);
|
|
647
|
+
}
|
|
533
648
|
}
|
|
534
649
|
// A completion marker may arrive only in this final flush (no trailing
|
|
535
650
|
// newline) — the grace window's success exception needs it.
|
|
536
|
-
|
|
537
|
-
agentState.turnCompleted = true;
|
|
538
|
-
agentState.status = "finished";
|
|
539
|
-
if (agentState.exitedAt === null)
|
|
540
|
-
agentState.exitedAt = at;
|
|
541
|
-
}
|
|
651
|
+
handleCompletedStdoutLines(agentState, lines, at);
|
|
542
652
|
if (items.length > 0) {
|
|
543
653
|
agentState.lastActivity = at;
|
|
544
654
|
agentState.visibleStream = retainLastN(agentState.visibleStream, items.map((it) => ({ ...it, at })), 3);
|
|
@@ -938,13 +1048,10 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
938
1048
|
model: agent.model,
|
|
939
1049
|
status: agent.status,
|
|
940
1050
|
exit_code: agent.exitCode,
|
|
941
|
-
stdout_tail: stdoutTail,
|
|
942
|
-
stderr_tail: stderrTail,
|
|
943
1051
|
started_at: agent.startedAt,
|
|
944
1052
|
last_activity: agent.lastActivity,
|
|
945
1053
|
cwd: agent.cwd,
|
|
946
1054
|
...liveness,
|
|
947
|
-
...(agent.routingTier !== undefined ? { routing_tier: agent.routingTier } : {}),
|
|
948
1055
|
...(agent.rulesetApplied
|
|
949
1056
|
? {
|
|
950
1057
|
ruleset_applied: true,
|
|
@@ -963,7 +1070,11 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
963
1070
|
at: it.at !== undefined ? formatLocalIso(it.at) : null,
|
|
964
1071
|
})),
|
|
965
1072
|
...(params.verbose
|
|
966
|
-
? {
|
|
1073
|
+
? {
|
|
1074
|
+
stdout_tail: stdoutTail,
|
|
1075
|
+
stderr_tail: stderrTail,
|
|
1076
|
+
final_output: extractFinalTurn(agent.provider, agent.stdout),
|
|
1077
|
+
}
|
|
967
1078
|
: {}),
|
|
968
1079
|
}),
|
|
969
1080
|
},
|
|
@@ -1084,6 +1195,8 @@ server.tool("send_message", "Enqueue a user message for an open interactive agen
|
|
|
1084
1195
|
await agent.driver.send(params.message);
|
|
1085
1196
|
const now = Date.now();
|
|
1086
1197
|
agent.status = "processing";
|
|
1198
|
+
agent.lastExitCode = agent.exitCode;
|
|
1199
|
+
agent.lastExitedAt = agent.exitedAt;
|
|
1087
1200
|
agent.exitCode = null;
|
|
1088
1201
|
agent.exitedAt = null;
|
|
1089
1202
|
agent.waitReported = false;
|
|
@@ -1148,7 +1261,7 @@ server.tool("list_agents", "List all agents with token-efficient core metrics (s
|
|
|
1148
1261
|
// Tool 6: wait
|
|
1149
1262
|
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`.", {
|
|
1150
1263
|
verbose: z.boolean().optional().default(false),
|
|
1151
|
-
}, withMaintenance(async (params) => {
|
|
1264
|
+
}, withMaintenance(async (params, zombieRecords) => {
|
|
1152
1265
|
const { verbose } = params;
|
|
1153
1266
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1154
1267
|
const TIMEOUT_MS = 15 * 60 * 1000;
|
|
@@ -1183,7 +1296,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1183
1296
|
a.waitReported = true;
|
|
1184
1297
|
const payload = { finished: unreported.map(buildFinishedEntry) };
|
|
1185
1298
|
return {
|
|
1186
|
-
content: [{ type: "text", text: JSON.stringify(payload
|
|
1299
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1187
1300
|
};
|
|
1188
1301
|
}
|
|
1189
1302
|
// Step 2: nothing alive and nothing unreported (includes stopped-but-not-yet-closed).
|
|
@@ -1198,19 +1311,20 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1198
1311
|
message: "No agents are running or waiting to finish.",
|
|
1199
1312
|
};
|
|
1200
1313
|
return {
|
|
1201
|
-
content: [{ type: "text", text: JSON.stringify(payload
|
|
1314
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1202
1315
|
};
|
|
1203
1316
|
}
|
|
1204
1317
|
// Step 3: block-poll until a terminal agent appears or deadline passes
|
|
1205
1318
|
while (Date.now() < deadline) {
|
|
1206
1319
|
await sleep(250);
|
|
1207
1320
|
unreported = selectUnreported(Array.from(agents.values()));
|
|
1321
|
+
zombieRecords.push(...runToolMaintenance());
|
|
1208
1322
|
if (unreported.length > 0) {
|
|
1209
1323
|
for (const a of unreported)
|
|
1210
1324
|
a.waitReported = true;
|
|
1211
1325
|
const payload = { finished: unreported.map(buildFinishedEntry) };
|
|
1212
1326
|
return {
|
|
1213
|
-
content: [{ type: "text", text: JSON.stringify(payload
|
|
1327
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1214
1328
|
};
|
|
1215
1329
|
}
|
|
1216
1330
|
}
|
|
@@ -1224,7 +1338,7 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
|
|
|
1224
1338
|
hint: "15 minutes elapsed with no agent finishing. Call wait again to block for another 15 minutes or until the next agent finishes.",
|
|
1225
1339
|
};
|
|
1226
1340
|
return {
|
|
1227
|
-
content: [{ type: "text", text: JSON.stringify(payload
|
|
1341
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
1228
1342
|
};
|
|
1229
1343
|
}));
|
|
1230
1344
|
// Tool 7: orchestration-mode
|
|
@@ -1483,6 +1597,7 @@ if (isMain) {
|
|
|
1483
1597
|
// default-ON this rarely fires.
|
|
1484
1598
|
// (the tool's enabled:false writes a disable record via writeDisable /
|
|
1485
1599
|
// writeDisableCwd; it does not call disable().)
|
|
1600
|
+
getNpmPrefix();
|
|
1486
1601
|
startLivenessHeartbeat();
|
|
1487
1602
|
const transport = new StdioServerTransport();
|
|
1488
1603
|
await server.connect(transport);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
-
import { closeSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
|
|
2
|
+
import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { dirname, join } from "node:path";
|
|
4
|
+
import { dirname, isAbsolute, join } from "node:path";
|
|
5
5
|
import * as marker from "./marker.js";
|
|
6
6
|
import * as reminder from "./reminder.js";
|
|
7
7
|
import { cullStaleSlots, slotDir, ZOMBIE_FORCE_GRACE_MS, } from "../concurrency.js";
|
|
@@ -37,9 +37,18 @@ export const REMINDER_PERIOD = 5;
|
|
|
37
37
|
* === <repoRoot>/directives.
|
|
38
38
|
*/
|
|
39
39
|
export function resolveDirectivesDir(env) {
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
const rootEnvName = env.CLAUDE_PLUGIN_ROOT !== undefined
|
|
41
|
+
? "CLAUDE_PLUGIN_ROOT"
|
|
42
|
+
: env.PLUGIN_ROOT !== undefined
|
|
43
|
+
? "PLUGIN_ROOT"
|
|
44
|
+
: undefined;
|
|
45
|
+
if (rootEnvName) {
|
|
46
|
+
const root = env[rootEnvName] ?? "";
|
|
47
|
+
const directivesDir = join(root, "directives");
|
|
48
|
+
if (!isAbsolute(root) || !existsSync(directivesDir)) {
|
|
49
|
+
throw new Error(`${rootEnvName} must be an absolute path with an existing directives directory: ${root}`);
|
|
50
|
+
}
|
|
51
|
+
return directivesDir;
|
|
43
52
|
}
|
|
44
53
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
45
54
|
// Compiled location is dist/orchestration/hook-core.js, so ../../directives
|
|
@@ -50,8 +59,9 @@ export function resolveDirectivesDir(env) {
|
|
|
50
59
|
}
|
|
51
60
|
/** Read a directive asset by filename. On ANY failure return '' (fail-safe). */
|
|
52
61
|
export function readDirective(env, fileName) {
|
|
62
|
+
const directivesDir = resolveDirectivesDir(env);
|
|
53
63
|
try {
|
|
54
|
-
return readFileSync(join(
|
|
64
|
+
return readFileSync(join(directivesDir, fileName), "utf8");
|
|
55
65
|
}
|
|
56
66
|
catch {
|
|
57
67
|
return "";
|