@heretyc/subagent-mcp 2.12.5-beta.0 → 2.12.5-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/drivers.js CHANGED
@@ -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
- const op = permissionOpFromClaudeRequest(request, options.cwd);
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
- pendingPermissionManager.onAgentQueueChange((agentId, pendingCount) => {
73
- const agent = agents.get(agentId);
74
- if (!agent || agent.exitCode !== null)
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
- if (pendingCount > 0) {
77
- if (agent.status === "processing" || agent.status === "stalled") {
78
- agent.status = "permission_requested";
79
- agent.waitReported = false;
80
- updateSlotMetadata(agent);
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
- updateSlotMetadata(agent);
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.0",
519
+ version: "2.12.5-beta.2",
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.pendingForAgent(agent.id),
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
- return { ...record };
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" &&
@@ -58,9 +58,15 @@ export function verdict(op, rules = {}) {
58
58
  export function applyPermissionCeiling(engineVote, ceiling) {
59
59
  if (ceiling === "yolo")
60
60
  return "allow";
61
- if (ceiling === "manual")
62
- return minVerdict(engineVote, "ask");
63
- return minVerdict(engineVote, "allow");
61
+ // The ceilings are NOT points on a permissiveness total order (the J2-9
62
+ // "total-order-over-modes" mistake). Neither `auto` nor `manual` may demote
63
+ // the engine's allow (SAFE) or deny (DANGER) decisions: SAFE always
64
+ // auto-allows, DANGER always auto-denies, and only NEUTRAL residue ("ask")
65
+ // parks. The auto/manual difference is solely *who* answers an "ask" —
66
+ // `auto` escalates only irreversible residue to a human while `manual` routes
67
+ // all residue to a human — which is decided downstream (see pending-permissions
68
+ // `escalate_to_human`), never by mapping SAFE ops to "ask" here.
69
+ return engineVote;
64
70
  }
65
71
  export function classifyPermissionOp(op) {
66
72
  const command = commandText(op);
@@ -247,7 +253,3 @@ function wildcardMatch(pattern, value) {
247
253
  const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[\\s\\S]*");
248
254
  return new RegExp(`^${escaped}$`, "i").test(value);
249
255
  }
250
- function minVerdict(a, b) {
251
- const rank = { deny: 0, ask: 1, allow: 2 };
252
- return rank[a] <= rank[b] ? a : b;
253
- }
@@ -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.0",
3
+ "version": "2.12.5-beta.2",
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",