@heretyc/subagent-mcp 2.12.5-beta.0 → 2.12.5-beta.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/dist/drivers.js +68 -22
- package/dist/index.js +29 -18
- package/dist/pending-permissions.js +8 -1
- package/dist/status-helpers.js +15 -0
- package/package.json +1 -1
package/dist/drivers.js
CHANGED
|
@@ -831,33 +831,52 @@ export class ClaudeSdkDriver {
|
|
|
831
831
|
allowDangerouslySkipPermissions: isYolo,
|
|
832
832
|
settingSources: [],
|
|
833
833
|
tools: { type: "preset", preset: "claude_code" },
|
|
834
|
+
// Shared gate: the SINGLE source of truth for every permission decision.
|
|
835
|
+
// `harnessChannel` names the SDK surface that invoked it so parked prompts
|
|
836
|
+
// are attributable. canUseTool and the PreToolUse hook both route here.
|
|
834
837
|
canUseTool: async (request) => {
|
|
835
|
-
|
|
836
|
-
const engineResult = verdict(op, permissionSnapshot.rules);
|
|
837
|
-
if (isYolo && isBypassImmuneClaudeAsk(request, op)) {
|
|
838
|
-
return denyClaudePermission("bypass-immune Claude safety prompt auto-denied under yolo");
|
|
839
|
-
}
|
|
840
|
-
const decision = applyPermissionCeiling(engineResult.verdict, permissionSnapshot.ceiling);
|
|
841
|
-
if (decision !== "ask") {
|
|
842
|
-
return permissionDecisionToClaude(decision, engineResult.reason);
|
|
843
|
-
}
|
|
844
|
-
const pendingDecision = await requestPendingPermission({
|
|
845
|
-
agentId: options.agentId,
|
|
846
|
-
harnessChannel: "claude-canUseTool",
|
|
847
|
-
toolNameOrMethod: op.tool,
|
|
848
|
-
action: claudeToolInput(request),
|
|
849
|
-
permissionCeiling: permissionSnapshot.ceiling,
|
|
850
|
-
escalation: permissionSnapshot.escalation,
|
|
851
|
-
irreversible: engineResult.irreversible,
|
|
852
|
-
reason: engineResult.reason,
|
|
853
|
-
suggestions: [],
|
|
854
|
-
correlationId: claudeCorrelationId(request),
|
|
855
|
-
});
|
|
856
|
-
return permissionDecisionToClaude(pendingDecision.verdict, pendingDecision.reason);
|
|
838
|
+
return this.gateRequest(request, options, permissionSnapshot, isYolo, "claude-canUseTool");
|
|
857
839
|
},
|
|
858
840
|
maxTurns: 50,
|
|
859
841
|
includePartialMessages: true,
|
|
860
842
|
};
|
|
843
|
+
// PreToolUse hook: with permissionMode:"default" + settingSources:[] the SDK
|
|
844
|
+
// auto-approves Bash (and any tool it treats as pre-approved) WITHOUT calling
|
|
845
|
+
// canUseTool, so those calls would bypass the engine gate entirely. Register a
|
|
846
|
+
// PreToolUse hook (no matcher => every tool) that routes EVERY tool call
|
|
847
|
+
// through the same gate: allow -> continue, deny -> block with reason, ask ->
|
|
848
|
+
// park via requestPendingPermission and resolve on respond/timeout. The hook
|
|
849
|
+
// returns a concrete allow/deny, so canUseTool never double-fires for a tool
|
|
850
|
+
// the hook already decided (it remains as fallback for anything the hook does
|
|
851
|
+
// not cover). The yolo path stays hook-free — no gating there by design.
|
|
852
|
+
if (!isYolo) {
|
|
853
|
+
sdkOptions.hooks = {
|
|
854
|
+
PreToolUse: [
|
|
855
|
+
{
|
|
856
|
+
hooks: [
|
|
857
|
+
async (input) => {
|
|
858
|
+
const request = {
|
|
859
|
+
tool_name: input.tool_name,
|
|
860
|
+
tool_input: input.tool_input,
|
|
861
|
+
tool_use_id: input.tool_use_id,
|
|
862
|
+
};
|
|
863
|
+
const result = await this.gateRequest(request, options, permissionSnapshot, isYolo, "claude-pretooluse-hook");
|
|
864
|
+
const allow = result.behavior === "allow";
|
|
865
|
+
return {
|
|
866
|
+
hookSpecificOutput: {
|
|
867
|
+
hookEventName: "PreToolUse",
|
|
868
|
+
permissionDecision: allow ? "allow" : "deny",
|
|
869
|
+
permissionDecisionReason: allow
|
|
870
|
+
? ""
|
|
871
|
+
: result.message,
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
},
|
|
875
|
+
],
|
|
876
|
+
},
|
|
877
|
+
],
|
|
878
|
+
};
|
|
879
|
+
}
|
|
861
880
|
if (options.effort !== "none" && options.effort !== "ultracode") {
|
|
862
881
|
sdkOptions.effort = options.effort;
|
|
863
882
|
}
|
|
@@ -867,6 +886,33 @@ export class ClaudeSdkDriver {
|
|
|
867
886
|
this.queryHandle = query;
|
|
868
887
|
void this.pump(query);
|
|
869
888
|
}
|
|
889
|
+
// Single permission gate shared by canUseTool and the PreToolUse hook. Returns
|
|
890
|
+
// allow/deny; parks (early-returns to the parent, resolves on respond/timeout)
|
|
891
|
+
// when the engine verdict is "ask".
|
|
892
|
+
async gateRequest(request, options, permissionSnapshot, isYolo, harnessChannel) {
|
|
893
|
+
const op = permissionOpFromClaudeRequest(request, options.cwd);
|
|
894
|
+
const engineResult = verdict(op, permissionSnapshot.rules);
|
|
895
|
+
if (isYolo && isBypassImmuneClaudeAsk(request, op)) {
|
|
896
|
+
return denyClaudePermission("bypass-immune Claude safety prompt auto-denied under yolo");
|
|
897
|
+
}
|
|
898
|
+
const decision = applyPermissionCeiling(engineResult.verdict, permissionSnapshot.ceiling);
|
|
899
|
+
if (decision !== "ask") {
|
|
900
|
+
return permissionDecisionToClaude(decision, engineResult.reason);
|
|
901
|
+
}
|
|
902
|
+
const pendingDecision = await requestPendingPermission({
|
|
903
|
+
agentId: options.agentId,
|
|
904
|
+
harnessChannel,
|
|
905
|
+
toolNameOrMethod: op.tool,
|
|
906
|
+
action: claudeToolInput(request),
|
|
907
|
+
permissionCeiling: permissionSnapshot.ceiling,
|
|
908
|
+
escalation: permissionSnapshot.escalation,
|
|
909
|
+
irreversible: engineResult.irreversible,
|
|
910
|
+
reason: engineResult.reason,
|
|
911
|
+
suggestions: [],
|
|
912
|
+
correlationId: claudeCorrelationId(request),
|
|
913
|
+
});
|
|
914
|
+
return permissionDecisionToClaude(pendingDecision.verdict, pendingDecision.reason);
|
|
915
|
+
}
|
|
870
916
|
start(message) {
|
|
871
917
|
return this.send(message);
|
|
872
918
|
}
|
package/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ import { buildCommand } from "./effort.js";
|
|
|
12
12
|
import { createProviderDriver } from "./drivers.js";
|
|
13
13
|
import { resolveExeFor } from "./platform.js";
|
|
14
14
|
import { formatLocalIso, selectUnreported, selectUnreportedPermissionRequested, } from "./wait-helpers.js";
|
|
15
|
-
import { computeStatusTransition, buildLivenessFields, } from "./status-helpers.js";
|
|
15
|
+
import { computeStatusTransition, buildLivenessFields, reconcilePermissionStatus, } from "./status-helpers.js";
|
|
16
16
|
import { escapeUntrustedTags, envelopeUntrustedOutput, extractFinalTurn, } from "./output-helpers.js";
|
|
17
17
|
import { consumeStreamChunk, flushStream, isTurnCompletedLine, retainLastN, } from "./stream-helpers.js";
|
|
18
18
|
import { loadRoutingTable, buildCandidates, validatePresence, TASK_CATEGORIES, AUTO_HINT, SPLIT_HINT, } from "./routing.js";
|
|
@@ -69,23 +69,29 @@ function pendingPermissionSummary(record, now = Date.now()) {
|
|
|
69
69
|
age_seconds: Math.floor((now - record.requested_at) / 1000),
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
72
|
+
// Reconcile one agent's live status against its current pending-permission
|
|
73
|
+
// depth. Used by the live queue listener AND at registration time (agents.set)
|
|
74
|
+
// to recover a park whose queue event fired before the agent was registered —
|
|
75
|
+
// the Codex approval race, where approvals arrive during driver.start() inside
|
|
76
|
+
// the spawn-grace window, ahead of agents.set. Idempotent and no-op unless the
|
|
77
|
+
// pure rule says the status must change.
|
|
78
|
+
function reconcileAgentPermissionStatus(agent) {
|
|
79
|
+
if (agent.exitCode !== null)
|
|
75
80
|
return;
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
}
|
|
83
|
-
else if (agent.status === "permission_requested") {
|
|
84
|
-
agent.status = "processing";
|
|
85
|
-
agent.waitReported = false;
|
|
81
|
+
const { status, changed } = reconcilePermissionStatus(agent.status, pendingPermissionManager.pendingCount(agent.id));
|
|
82
|
+
if (!changed)
|
|
83
|
+
return;
|
|
84
|
+
agent.status = status;
|
|
85
|
+
agent.waitReported = false;
|
|
86
|
+
if (status === "processing")
|
|
86
87
|
agent.lastActivity = Date.now();
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
updateSlotMetadata(agent);
|
|
89
|
+
}
|
|
90
|
+
pendingPermissionManager.onAgentQueueChange((agentId) => {
|
|
91
|
+
const agent = agents.get(agentId);
|
|
92
|
+
if (!agent)
|
|
93
|
+
return;
|
|
94
|
+
reconcileAgentPermissionStatus(agent);
|
|
89
95
|
});
|
|
90
96
|
// Post-spawn grace window (ms). A provider driver that exits within this window
|
|
91
97
|
// after a successful driver start never launched (not logged in, expired auth, instant
|
|
@@ -510,7 +516,7 @@ const ORCHESTRATION_INSTRUCTIONS = "subagent-mcp - CANONICAL OPERATING MODEL (fu
|
|
|
510
516
|
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.";
|
|
511
517
|
const server = new McpServer({
|
|
512
518
|
name: "subagent-mcp",
|
|
513
|
-
version: "2.12.5-beta.
|
|
519
|
+
version: "2.12.5-beta.1",
|
|
514
520
|
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.",
|
|
515
521
|
}, {
|
|
516
522
|
instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
|
|
@@ -580,6 +586,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapsho
|
|
|
580
586
|
effort: candidate.effort,
|
|
581
587
|
ucSettingsPath: buildResult.ucSettingsPath,
|
|
582
588
|
ucSettingsDir: buildResult.ucSettingsDir,
|
|
589
|
+
agentId,
|
|
583
590
|
});
|
|
584
591
|
}
|
|
585
592
|
catch (error) {
|
|
@@ -848,6 +855,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapsho
|
|
|
848
855
|
const startedBeforeStartError = await readStartedBoundary();
|
|
849
856
|
if (startedBeforeStartError) {
|
|
850
857
|
agents.set(agentId, agentState);
|
|
858
|
+
reconcileAgentPermissionStatus(agentState);
|
|
851
859
|
return { agentId };
|
|
852
860
|
}
|
|
853
861
|
// Lived past the grace window but the startup write failed: the child is
|
|
@@ -865,6 +873,7 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, permissionSnapsho
|
|
|
865
873
|
}
|
|
866
874
|
}
|
|
867
875
|
agents.set(agentId, agentState);
|
|
876
|
+
reconcileAgentPermissionStatus(agentState);
|
|
868
877
|
return { agentId };
|
|
869
878
|
}
|
|
870
879
|
// Order-sensitive (provider, model, effort) list equality. Detects whether the
|
|
@@ -1147,7 +1156,9 @@ server.tool("poll_agent", "Get an agent's current status and output. `processing
|
|
|
1147
1156
|
...liveness,
|
|
1148
1157
|
...(pendingPermissionManager.pendingCount(agent.id) > 0
|
|
1149
1158
|
? {
|
|
1150
|
-
pending_permissions: pendingPermissionManager
|
|
1159
|
+
pending_permissions: pendingPermissionManager
|
|
1160
|
+
.pendingForAgent(agent.id)
|
|
1161
|
+
.map((p) => pendingPermissionSummary(p, now)),
|
|
1151
1162
|
}
|
|
1152
1163
|
: {}),
|
|
1153
1164
|
...(isStalePermissive(agent)
|
|
@@ -2,7 +2,14 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
const PARK_TIMEOUT_MS = 5 * 60 * 1000;
|
|
3
3
|
const PER_AGENT_FIFO_CAP = 16;
|
|
4
4
|
function publicRecord(record) {
|
|
5
|
-
|
|
5
|
+
// record may be a StoredPendingPermission at runtime, which carries a live
|
|
6
|
+
// NodeJS.Timeout and a resolve closure. Both are non-serializable (the timer
|
|
7
|
+
// is circular via TimersList), so strip them — a leaked timer crashes any
|
|
8
|
+
// JSON.stringify of a public record (e.g. poll_agent's pending_permissions).
|
|
9
|
+
const { timer, resolve, ...clean } = record;
|
|
10
|
+
void timer;
|
|
11
|
+
void resolve;
|
|
12
|
+
return clean;
|
|
6
13
|
}
|
|
7
14
|
function shouldEscalateToHuman(input) {
|
|
8
15
|
return (input.permission_ceiling === "auto" &&
|
package/dist/status-helpers.js
CHANGED
|
@@ -45,6 +45,21 @@ export function computeStatusTransition(input) {
|
|
|
45
45
|
}
|
|
46
46
|
return { status, exitedAt: input.exitedAt };
|
|
47
47
|
}
|
|
48
|
+
// Pure reconciliation of an agent's live status against its pending-permission
|
|
49
|
+
// queue depth. Mirrors the onAgentQueueChange listener so registration-time
|
|
50
|
+
// reconciliation and the live listener share one rule. A park that fires BEFORE
|
|
51
|
+
// the agent is registered (the Codex approval race: approvals arrive during
|
|
52
|
+
// driver.start(), inside the spawn-grace window, before agents.set) loses its
|
|
53
|
+
// queue event; re-running this after registration recovers the flip.
|
|
54
|
+
export function reconcilePermissionStatus(status, pendingCount) {
|
|
55
|
+
if (pendingCount > 0 && (status === "processing" || status === "stalled")) {
|
|
56
|
+
return { status: "permission_requested", changed: true };
|
|
57
|
+
}
|
|
58
|
+
if (pendingCount === 0 && status === "permission_requested") {
|
|
59
|
+
return { status: "processing", changed: true };
|
|
60
|
+
}
|
|
61
|
+
return { status, changed: false };
|
|
62
|
+
}
|
|
48
63
|
// Pure formatter for the per-agent liveness fields shared by poll_agent and
|
|
49
64
|
// list_agents. `hint` is present ONLY when status === "stalled" AND the caller
|
|
50
65
|
// opts in (poll_agent does; list_agents omits it to stay token-efficient).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@heretyc/subagent-mcp",
|
|
3
|
-
"version": "2.12.5-beta.
|
|
3
|
+
"version": "2.12.5-beta.1",
|
|
4
4
|
"description": "MCP server that launches and manages always-interactive Claude Code and Codex sub-agent sessions (no direct Anthropic/OpenAI API).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|