@heretyc/subagent-mcp 2.8.9 → 2.9.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/index.js CHANGED
@@ -139,11 +139,11 @@ reconcileInterval.unref();
139
139
  // test/mirror-fragments.test.mjs while ORCHESTRATION_INSTRUCTIONS below stays
140
140
  // compressed under MCP metadata limits:
141
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.";
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), subagent-mcp, and the /workflows tool. 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 remain enabled; 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
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.";
144
144
  const server = new McpServer({
145
145
  name: "subagent-mcp",
146
- version: "2.8.9",
146
+ version: "2.9.2",
147
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.",
148
148
  }, {
149
149
  instructions: process.env.SUBAGENT_MCP_SUBAGENT === "1"
@@ -221,10 +221,12 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
221
221
  }
222
222
  const childProcess = driver.process;
223
223
  let definitelyStarted = false;
224
+ let startupRejection = null;
224
225
  const definitelyStartedProbe = driver.definitelyStarted.then(() => {
225
226
  definitelyStarted = true;
226
227
  return true;
227
- }, () => {
228
+ }, (e) => {
229
+ startupRejection = e instanceof Error ? e : new Error(String(e));
228
230
  return false;
229
231
  });
230
232
  const readStartedBoundary = async () => {
@@ -452,7 +454,9 @@ async function tryLaunchCandidate(candidate, prompt, agentCwd, routingTier, rule
452
454
  const reason = `process exited (code ${earlyExit.code ?? earlyExit.signal}) within ${SPAWN_GRACE_MS}ms of spawn${tail ? `: ${tail}` : ""}`;
453
455
  return {
454
456
  reason,
455
- failure_type: classifyFailureReason(reason, agentState.stderr),
457
+ failure_type: startupRejection
458
+ ? failureTypeForError(startupRejection, agentState.stderr)
459
+ : classifyFailureReason(reason, agentState.stderr),
456
460
  };
457
461
  }
458
462
  }
@@ -1000,24 +1004,52 @@ server.tool("wait", "Blocks until one or more sub-agents reach a reportable stat
1000
1004
  };
1001
1005
  });
1002
1006
  // Tool 7: orchestration-mode
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).", {
1007
+ server.tool("orchestration-mode", "Toggle or query per-project ORCHESTRATION MODE. `enabled`: true = ON, false = OFF for THIS session only, 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 remain enabled (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 default-ON orchestration mode for LONG-HORIZON work that would fill the context window if run to completion inline; when ON, act as a delegate-ONLY orchestrator: delegate every step; inline-by-right does not exist; a non-delegable atomic step requires a one-time user-approved exception via the structured-question tool (state which + why). PERSISTENCE: orchestration is DEFAULT ON; a permitted disable applies to THIS session only, resumes ON next new session (or after the 2h backstop), and cannot be re-enabled mid-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).", {
1004
1008
  enabled: z.boolean().optional(),
1005
1009
  }, async (params) => {
1006
1010
  const cwd = process.cwd();
1011
+ const key = orchestrationMarker.readCurrentSession(cwd);
1007
1012
  if (params.enabled === true) {
1008
- orchestrationMarker.enable(cwd);
1013
+ if (!orchestrationMarker.isActive(cwd, key)) {
1014
+ return errorResult("orchestration already disabled for this session, cannot re-enable mid-session; resumes ON automatically next new session (or after the 2h backstop).");
1015
+ }
1016
+ return {
1017
+ content: [
1018
+ {
1019
+ type: "text",
1020
+ text: JSON.stringify({
1021
+ orchestration_mode: "ON",
1022
+ message: "orchestration is ON by default.",
1023
+ }),
1024
+ },
1025
+ ],
1026
+ };
1009
1027
  }
1010
1028
  else if (params.enabled === false) {
1011
- orchestrationMarker.disable(cwd);
1029
+ if (key)
1030
+ orchestrationMarker.writeDisable(key);
1031
+ else
1032
+ orchestrationMarker.writeDisableCwd(cwd);
1033
+ return {
1034
+ content: [
1035
+ {
1036
+ type: "text",
1037
+ text: JSON.stringify({
1038
+ orchestration_mode: "disabled-this-session",
1039
+ message: "orchestration disabled for THIS session only; the next new session resumes ON automatically (or after the 2h backstop); no mid-session re-enable.",
1040
+ }),
1041
+ },
1042
+ ],
1043
+ };
1012
1044
  }
1013
1045
  // enabled === undefined -> query only; no marker mutation.
1046
+ const active = orchestrationMarker.isActive(cwd, key);
1014
1047
  return {
1015
1048
  content: [
1016
1049
  {
1017
1050
  type: "text",
1018
1051
  text: JSON.stringify({
1019
- orchestration_mode: orchestrationMarker.isActive(cwd),
1020
- marker_path: orchestrationMarker.markerPath(cwd),
1052
+ orchestration_mode: active ? "ON" : "disabled-this-session",
1021
1053
  }),
1022
1054
  },
1023
1055
  ],
@@ -1065,6 +1097,7 @@ if (isMain) {
1065
1097
  " init, --init [flags]",
1066
1098
  " upsert project instruction-file invariant blocks",
1067
1099
  " flags: --dry-run --remove --force --root <dir> --files <csv> --copilot --cursor",
1100
+ " --global upsert into ~/.claude/CLAUDE.md, ~/.codex/AGENTS.md, ~/.gemini/GEMINI.md",
1068
1101
  " doctor check install and wiring health",
1069
1102
  " update, --update update to the latest release (npm install -g)",
1070
1103
  " version, --version, -v",
@@ -1179,11 +1212,14 @@ if (isMain) {
1179
1212
  process.exit(1);
1180
1213
  }
1181
1214
  // ORCHESTRATION MODE PERSISTS across restarts/sessions: the server does NOT
1182
- // clear the marker on startup. DEFAULT OFF now means ABSENCE of a marker — a
1183
- // project never enabled stays OFF; a project explicitly enabled persists ON
1184
- // until disabled with explicit user permission. On a new session the bundled
1185
- // hook detects the carried-over marker and prompts the user to confirm.
1186
- // (orchestrationMarker.disable is still used by the tool's enabled:false.)
1215
+ // clear the marker on startup. DEFAULT ON now means ABSENCE of a disable
1216
+ // record a project stays ON with no marker write needed; OFF is only a
1217
+ // per-session disable record that holds while it is active, cleared with
1218
+ // explicit user permission. On a new session a carried-over legacy ON marker
1219
+ // (if any) triggers a one-time prompt asking whether to remain enabled; under
1220
+ // default-ON this rarely fires.
1221
+ // (the tool's enabled:false writes a disable record via writeDisable /
1222
+ // writeDisableCwd; it does not call disable().)
1187
1223
  startLivenessHeartbeat();
1188
1224
  const transport = new StdioServerTransport();
1189
1225
  await server.connect(transport);
package/dist/init.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, } from "node:fs";
2
+ import * as os from "node:os";
2
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
3
4
  const MIGRATE_RE = /<!-- subagent-mcp:(?:managed:)?begin\b[^>]*-->[\s\S]*?<!-- subagent-mcp:(?:managed:)?end -->/;
4
5
  const OWNER_CAP = 8;
@@ -16,7 +17,7 @@ export const INIT_BLOCK = [
16
17
  "",
17
18
  "PRECEDENCE (co-supreme top tier): <subagent-mcp> hook tags AND repo/system safety-scope rules are BOTH supreme and EQUAL — neither outranks the other. If they genuinely conflict, STOP and escalate to the user via the structured-question tool; do not silently pick one or average them. FORBIDDEN: resolving such a conflict yourself. Hook tags otherwise outrank ordinary user requests.",
18
19
  "",
19
- 'ORCHESTRATION ON — you are the ORCHESTRATOR. Allowed tools: ONLY the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex) and subagent-mcp. NO direct reads or writes of any kind. "Inline-by-right" does not exist. Every step runs in a sub-agent. If one atomic step truly cannot run in a sub-agent, ASK the user via the structured-question tool for a one-time exception for that single step, perform only that step, then resume delegating. SOLE CHANNEL: while subagent-mcp is connected, every sub-agent launch goes through `launch_agent`; never use harness-native sub-agent tools or shell-spawned agents.',
20
+ 'ORCHESTRATION ON — you are the ORCHESTRATOR. Allowed tools: ONLY the structured-question tool (AskUserQuestion on Claude / request-user-input on Codex), subagent-mcp, and the /workflows tool. NO direct reads or writes of any kind. "Inline-by-right" does not exist. Every step runs in a sub-agent. If one atomic step truly cannot run in a sub-agent, ASK the user via the structured-question tool for a one-time exception for that single step, perform only that step, then resume delegating. SOLE CHANNEL: while subagent-mcp is connected, every sub-agent launch goes through `launch_agent`; never use harness-native sub-agent tools or shell-spawned agents.',
20
21
  "",
21
22
  "ORCHESTRATOR WORKTREE SETUP: launch sub-agents in the main checkout cwd (they no longer self-isolate into per-agent worktrees); SERIALIZE any sub-agents that write the SAME files — never run concurrent writers over overlapping paths (no cwd-level lock exists).",
22
23
  "",
@@ -28,7 +29,7 @@ export const INIT_BLOCK = [
28
29
  "",
29
30
  'NO-HOOK / UNKNOWN STATE: if no harness-hook injection bearing a <subagent-mcp state="..."> tag is present this session (e.g. Gemini, desktop apps, or any host that fires no hook), the state is UNKNOWN — represented by the ABSENCE of any tag, never by a tag value. Emit this UNKNOWN-STATE WARNING to the user: "subagent-mcp: no hook injection detected — orchestration state unknown; defaulting to ON". Briefly explain WHY: no hook injection detected — cannot verify orchestration state; defaulting to ON to prevent uncontrolled inline execution. Then DEFAULT TO ORCHESTRATION ON (fail-safe). One-time opt-out: if you are not currently running an orchestration workflow, you may explicitly opt out of ON for this session by saying so now; this opt-out does not persist and is not recorded. The sub-agent first-line exemption is the ONLY automatic suppressor of this default.',
30
31
  "",
31
- "DISABLE: never on your own initiative; you MAY propose OFF on task-fit mismatch via the structured-question tool, and only explicit user approval may call orchestration-mode enabled:false.",
32
+ "DISABLE: never on your own initiative; you MAY propose OFF on task-fit mismatch via the structured-question tool, and only explicit user approval may set enabled:false — per-session only; the next new session resumes ON; no mid-session re-enable.",
32
33
  "<!-- subagent-mcp:managed:end -->",
33
34
  ].join("\n");
34
35
  function detectEol(s) {
@@ -145,16 +146,18 @@ export function upsertInitBlock(file, opts = {}) {
145
146
  }
146
147
  return { file, status, changed };
147
148
  }
148
- function parseArgs(args) {
149
+ export function parseArgs(args) {
149
150
  const parsed = {
150
151
  dryRun: false,
151
152
  remove: false,
152
153
  force: false,
153
154
  copilot: false,
154
155
  cursor: false,
156
+ global: false,
155
157
  root: process.cwd(),
156
158
  files: null,
157
159
  };
160
+ let rootProvided = false;
158
161
  const readValue = (args, i, flag) => {
159
162
  const value = args[i + 1];
160
163
  if (value === undefined || value === "" || value.startsWith("--")) {
@@ -174,8 +177,11 @@ function parseArgs(args) {
174
177
  parsed.copilot = true;
175
178
  else if (a === "--cursor")
176
179
  parsed.cursor = true;
180
+ else if (a === "--global")
181
+ parsed.global = true;
177
182
  else if (a === "--root") {
178
183
  parsed.root = readValue(args, i, a);
184
+ rootProvided = true;
179
185
  i++;
180
186
  }
181
187
  else if (a === "--files") {
@@ -187,6 +193,9 @@ function parseArgs(args) {
187
193
  }
188
194
  if (!parsed.root)
189
195
  throw new Error("--root requires a directory");
196
+ if (parsed.global && (rootProvided || parsed.files || parsed.copilot || parsed.cursor)) {
197
+ throw new Error("--global cannot be combined with --root/--files/--copilot/--cursor");
198
+ }
190
199
  return parsed;
191
200
  }
192
201
  function isSelfRepo(root) {
@@ -198,6 +207,13 @@ function isSelfRepo(root) {
198
207
  return false;
199
208
  }
200
209
  }
210
+ export function globalTargetFiles(home = os.homedir()) {
211
+ return [
212
+ join(home, ".claude", "CLAUDE.md"),
213
+ join(home, ".codex", "AGENTS.md"),
214
+ join(home, ".gemini", "GEMINI.md"),
215
+ ];
216
+ }
201
217
  function targetFiles(root, opts) {
202
218
  const resolveTarget = (f) => {
203
219
  const target = isAbsolute(f) ? resolve(f) : resolve(root, f);
@@ -225,19 +241,24 @@ export async function runInit(args = process.argv.slice(3)) {
225
241
  console.error(e instanceof Error ? e.message : String(e));
226
242
  return 1;
227
243
  }
228
- const root = resolve(opts.root);
229
- if (isSelfRepo(root) && !opts.force) {
230
- console.error("Refusing to run init inside the subagent-mcp source repo without --force.");
231
- console.error("This repo keeps CLAUDE.md/GEMINI.md as thin redirects; use --root for a consumer repo.");
232
- return 1;
233
- }
234
244
  let files;
235
- try {
236
- files = targetFiles(root, opts);
245
+ if (opts.global) {
246
+ files = globalTargetFiles();
237
247
  }
238
- catch (e) {
239
- console.error(e instanceof Error ? e.message : String(e));
240
- return 1;
248
+ else {
249
+ const root = resolve(opts.root);
250
+ if (isSelfRepo(root) && !opts.force) {
251
+ console.error("Refusing to run init inside the subagent-mcp source repo without --force.");
252
+ console.error("This repo keeps CLAUDE.md/GEMINI.md as thin redirects; use --root for a consumer repo.");
253
+ return 1;
254
+ }
255
+ try {
256
+ files = targetFiles(root, opts);
257
+ }
258
+ catch (e) {
259
+ console.error(e instanceof Error ? e.message : String(e));
260
+ return 1;
261
+ }
241
262
  }
242
263
  const issues = [];
243
264
  const results = [];
@@ -234,7 +234,9 @@ export function runHook(payload, env, adapter) {
234
234
  }
235
235
  const cwd = payload.cwd || process.cwd();
236
236
  const current = sessionKey(payload);
237
- if (!marker.isActive(cwd)) {
237
+ if (current)
238
+ marker.writeCurrentSession(cwd, current);
239
+ if (!marker.isActive(cwd, current)) {
238
240
  // OFF: no claim machinery — just the per-prompt reminder cadence.
239
241
  const r = reminder.advance(cwd, current);
240
242
  return cadenceEmit(env, adapter, adapter.reminderOffFile, adapter.shortOffFile, r.count, r.persisted);
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from
3
3
  import { tmpdir } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
5
  const markerDir = join(tmpdir(), "subagent-mcp");
6
+ export const ORCH_DISABLE_TTL_MS = 2 * 60 * 60 * 1000; // 2h GC backstop ONLY (independent of model-mode WINDOW_MS)
6
7
  /**
7
8
  * Shared per-project state dir for ALL hook state files (marker + reminder
8
9
  * counter). Exported so sibling state modules key off the SAME location — a
@@ -33,15 +34,27 @@ export function normalizeCwd(cwd) {
33
34
  }
34
35
  return p;
35
36
  }
36
- export function cwdHash(cwd) {
37
+ function hashKey(key) {
37
38
  return createHash("sha256")
38
- .update(normalizeCwd(cwd), "utf8")
39
+ .update(key, "utf8")
39
40
  .digest("hex")
40
41
  .slice(0, 16);
41
42
  }
43
+ export function cwdHash(cwd) {
44
+ return hashKey(normalizeCwd(cwd));
45
+ }
42
46
  export function markerPath(cwd) {
43
47
  return join(markerDir, "orch-" + cwdHash(cwd) + ".flag");
44
48
  }
49
+ export function disablePath(sessionKey) {
50
+ return join(stateDir, `orch-disable-${hashKey(sessionKey)}.json`);
51
+ }
52
+ function cwdDisablePath(cwd) {
53
+ return join(stateDir, `orch-disable-${cwdHash(cwd)}.json`);
54
+ }
55
+ export function sessionPointerPath(cwd) {
56
+ return join(stateDir, `orch-session-${cwdHash(cwd)}.json`);
57
+ }
45
58
  /**
46
59
  * Enable orchestration for cwd. ALWAYS overwrites — re-enabling re-baselines by
47
60
  * clearing owner_session/baseline_turn back to null so the next hook turn
@@ -62,44 +75,105 @@ export function enable(cwd) {
62
75
  carryover_ack: false,
63
76
  };
64
77
  writeFileSync(markerPath(cwd), JSON.stringify(state), { encoding: "utf8", mode: 0o600 });
78
+ try {
79
+ unlinkSync(cwdDisablePath(cwd));
80
+ }
81
+ catch (e) {
82
+ if (e?.code !== "ENOENT") {
83
+ // Fail-safe: enable still succeeds if stale disable cleanup fails.
84
+ }
85
+ }
65
86
  }
66
87
  catch {
67
88
  // Fail-safe: never throw to the caller.
68
89
  }
69
90
  }
70
91
  /**
71
- * Disable orchestration for cwd by removing the marker.
72
- *
73
- * No existsSync() guard: that only opens a TOCTOU window where a concurrent
74
- * clearForCwd/disable for the same cwd removes the file between the check and
75
- * the unlink. We just unlink and swallow ENOENT (already-gone is success).
76
- *
77
- * KNOWN LIMITATION: the marker is keyed by cwd, NOT by session. Two CLI
78
- * sessions in the same project share one marker, so their enable/disable
79
- * interleave and the last writer wins. Per-session isolation would require
80
- * keying the marker by cwd+session_id; not done here because LOCKED DECISION 1
81
- * keys the marker by working directory alone. (The hook tracks the owning
82
- * session via owner_session so a carried-over marker is re-claimed, but the
83
- * marker file itself is still shared per cwd.)
92
+ * Disable orchestration for cwd using cwd-keyed shared fallback state. The
93
+ * hook's session-keyed path uses writeDisable(sessionKey) instead.
84
94
  */
85
95
  export function disable(cwd) {
86
96
  try {
87
- unlinkSync(markerPath(cwd));
97
+ mkdirSync(markerDir, { recursive: true, mode: 0o700 });
98
+ writeFileSync(cwdDisablePath(cwd), JSON.stringify({ disabled_at: Date.now() }), {
99
+ encoding: "utf8",
100
+ mode: 0o600,
101
+ });
88
102
  }
89
- catch (e) {
90
- if (e?.code !== "ENOENT") {
91
- // Any non-ENOENT failure is still swallowed (fail-safe); ENOENT means the
92
- // marker was already gone, which is the desired end state anyway.
93
- }
103
+ catch {
104
+ // Fail-safe: never throw to the caller.
105
+ }
106
+ }
107
+ export function writeDisable(sessionKey) {
108
+ try {
109
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
110
+ writeFileSync(disablePath(sessionKey), JSON.stringify({ disabled_at: Date.now() }), {
111
+ encoding: "utf8",
112
+ mode: 0o600,
113
+ });
114
+ }
115
+ catch {
116
+ // Fail-safe: never throw to the caller.
94
117
  }
95
118
  }
96
- export function isActive(cwd) {
119
+ export function writeDisableCwd(cwd) {
97
120
  try {
98
- return existsSync(markerPath(cwd));
121
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
122
+ writeFileSync(cwdDisablePath(cwd), JSON.stringify({ disabled_at: Date.now() }), {
123
+ encoding: "utf8",
124
+ mode: 0o600,
125
+ });
99
126
  }
100
127
  catch {
128
+ // Fail-safe: never throw to the caller.
129
+ }
130
+ }
131
+ export function writeCurrentSession(cwd, sessionKey) {
132
+ try {
133
+ mkdirSync(stateDir, { recursive: true, mode: 0o700 });
134
+ writeFileSync(sessionPointerPath(cwd), JSON.stringify({ session_key: sessionKey }), {
135
+ encoding: "utf8",
136
+ mode: 0o600,
137
+ });
138
+ }
139
+ catch {
140
+ // Fail-safe: never throw to the caller.
141
+ }
142
+ }
143
+ export function readCurrentSession(cwd) {
144
+ try {
145
+ const raw = readFileSync(sessionPointerPath(cwd), "utf8");
146
+ const parsed = JSON.parse(raw);
147
+ return typeof parsed.session_key === "string" ? parsed.session_key : undefined;
148
+ }
149
+ catch {
150
+ return undefined;
151
+ }
152
+ }
153
+ function isDisableActive(path, now) {
154
+ if (!existsSync(path)) {
101
155
  return false;
102
156
  }
157
+ const raw = readFileSync(path, "utf8");
158
+ const parsed = JSON.parse(raw);
159
+ if (typeof parsed.disabled_at !== "number") {
160
+ return false;
161
+ }
162
+ if (now - parsed.disabled_at <= ORCH_DISABLE_TTL_MS) {
163
+ return true;
164
+ }
165
+ // Lazy GC side-effect: the disable has expired, so remove it.
166
+ unlinkSync(path);
167
+ return false;
168
+ }
169
+ export function isActive(cwd, sessionKey) {
170
+ try {
171
+ const path = sessionKey === undefined ? cwdDisablePath(cwd) : disablePath(sessionKey);
172
+ return !isDisableActive(path, Date.now());
173
+ }
174
+ catch {
175
+ return true;
176
+ }
103
177
  }
104
178
  export function readMarker(cwd) {
105
179
  try {
@@ -136,9 +210,8 @@ export function writeMarker(cwd, obj) {
136
210
  }
137
211
  }
138
212
  /**
139
- * Marker removal alias, identical to disable. RETAINED for callers that clear a
140
- * marker explicitly (e.g. the tool's enabled:false path). NOTE: the server no
141
- * longer calls this on startup — orchestration mode now PERSISTS across sessions.
213
+ * Cwd-keyed disable alias, identical to disable. RETAINED for callers that
214
+ * clear legacy marker state explicitly (e.g. the tool's enabled:false path).
142
215
  */
143
216
  export function clearForCwd(cwd) {
144
217
  disable(cwd);