@getmarrow/install 0.1.67 → 0.1.68

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/src/installer.js CHANGED
@@ -8,6 +8,7 @@ const {
8
8
  controllerStatus,
9
9
  controllerSupportedPlatform,
10
10
  ensureCurrentGovernanceController,
11
+ stopProjectControllers,
11
12
  } = require('./controller-manager');
12
13
  const { firstCapturePath, harnessReloadPlan } = require('./first-hour');
13
14
  const { evidence: localControlEvidence } = require('./control-state');
@@ -19,15 +20,26 @@ const {
19
20
  planHermesMcpConfig,
20
21
  } = require('./hermes-config');
21
22
  const { ensureOwnerApiKey, readOwnerApiKey } = require('./owner-env');
23
+ const {
24
+ delocalizeHookCommand,
25
+ ensureMcpRuntime,
26
+ localizeHookSettingsText,
27
+ mapHookCommands,
28
+ removeMcpRuntime,
29
+ verifyMcpRuntime,
30
+ } = require('./mcp-runtime');
31
+ const {
32
+ MCP_ADAPTER_VERSION,
33
+ MCP_ADAPTER_SOURCE_SHA,
34
+ MCP_ADAPTER_INTEGRITY,
35
+ MCP_HOST_APPROVAL_HOOKS_SINCE,
36
+ SDK_ADAPTER_VERSION,
37
+ SDK_ADAPTER_INTEGRITY,
38
+ } = require('./pins');
22
39
 
23
40
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
24
41
  const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
25
42
  const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
26
- const MCP_ADAPTER_VERSION = '3.9.98';
27
- const MCP_ADAPTER_SOURCE_SHA = 'e40d3cb40479456fd937bce0b9488eb0c3f10863';
28
- const MCP_ADAPTER_INTEGRITY = 'sha512-AmDT3afwdm7+Dc555zDs+yGIG4RyC/YbaQm+9O1mThlC6g/9EujTr7y7UvRMEtYDvVWAAaG6CFM7/u/ytjKhwQ==';
29
- const SDK_ADAPTER_VERSION = '3.7.64';
30
- const SDK_ADAPTER_INTEGRITY = 'sha512-8qJj/8ouHEz1NnZkmujtFxUm/fWldqR/rHv62/sqabaxT0H90xCCHxodLOkV0SVxDscAtnahioZakqkeGNUwyA==';
31
43
  const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
32
44
  const MCP_PACKAGE_SPEC = `@getmarrow/mcp@${MCP_ADAPTER_VERSION}`;
33
45
  const ADAPTER_PROVENANCE = Object.freeze({
@@ -97,6 +109,12 @@ function compatibleMcpTargetVersion(value) {
97
109
  && compareMcpVersions(value, MCP_ADAPTER_VERSION) >= 0);
98
110
  }
99
111
 
112
+ // Whether an MCP version answers the host-approval hooks (see MCP_HOST_APPROVAL_HOOKS_SINCE).
113
+ function hostApprovalHooksSupported(version = MCP_ADAPTER_VERSION) {
114
+ const comparison = compareMcpVersions(version, MCP_HOST_APPROVAL_HOOKS_SINCE);
115
+ return comparison !== null && comparison >= 0;
116
+ }
117
+
100
118
  function mcpVersionsInText(value) {
101
119
  return [...String(value || '').matchAll(/@getmarrow\/mcp@(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/g)]
102
120
  .map((match) => match[1]);
@@ -248,6 +266,11 @@ const MCP_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mc
248
266
  const MCP_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-pre-action-hook`;
249
267
  const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-hook`;
250
268
  const MCP_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-session-hook`;
269
+ // Host approvals: a pass-through PermissionRequest hook (installed async) that only notes that
270
+ // Claude Code is about to show its own permission dialog for a held call, and PostToolBatch
271
+ // (async, the result entrypoint) that settles a declined dialog. Same spelling and fields as
272
+ // `marrow-mcp setup` writes, so neither writer rewrites the other.
273
+ const MCP_PERMISSION_REQUEST_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp claude-permission-request-hook`;
251
274
  const CODEX_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-context-hook`;
252
275
  const CODEX_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-pre-action-hook`;
253
276
  const CODEX_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp codex-hook`;
@@ -255,6 +278,29 @@ const CODEX_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} mar
255
278
  const CURSOR_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-pre-action-hook`;
256
279
  const CURSOR_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-hook`;
257
280
  const CURSOR_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-session-hook`;
281
+ const CURSOR_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-context-hook`;
282
+ // Cursor's beforeMCPExecution takes no matcher, so this guard answers allow at once for
283
+ // Marrow's own tools (server "marrow", tools marrow_*), as the old preToolUse matcher exempted
284
+ // them, without starting npx. Every other call goes to the MCP entrypoint with its input
285
+ // unchanged; a crash, a non-zero exit or output that is not a JSON object blocks (exit 2).
286
+ const CURSOR_MCP_GUARD_FAILURE = 'Marrow governance adapter was unavailable; this MCP call is blocked.';
287
+ const CURSOR_MCP_PRE_ACTION_GUARD_SOURCE = [
288
+ 'const {spawn}=require("node:child_process");',
289
+ 'const chunks=[];let size=0,over=false,done=false;',
290
+ `const fail=()=>{if(done)return;done=true;process.exitCode=2;process.stderr.write(${JSON.stringify(`${CURSOR_MCP_GUARD_FAILURE}\n`)},()=>process.exit(2));};`,
291
+ 'process.stdin.on("error",fail);',
292
+ 'process.stdin.on("data",c=>{size+=c.length;if(size>16777216){over=true;return;}chunks.push(c);});',
293
+ 'process.stdin.on("end",()=>{if(over){fail();return;}const raw=Buffer.concat(chunks);let ev=null;try{ev=JSON.parse(raw.toString("utf8"));}catch{ev=null;}',
294
+ 'if(ev&&typeof ev==="object"&&ev.mcp_server_name==="marrow"&&typeof ev.tool_name==="string"&&/^marrow_[a-z0-9_]{1,64}$/.test(ev.tool_name)){done=true;process.stdout.write("{\\"permission\\":\\"allow\\"}\\n");return;}',
295
+ 'let child;try{',
296
+ `child=spawn(process.platform==="win32"?"npx.cmd":"npx",${JSON.stringify(['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp', 'cursor-pre-action-hook'])},{stdio:["pipe","pipe","ignore"]});`,
297
+ '}catch{fail();return;}',
298
+ 'let out="",bytes=0;child.stdout.on("data",c=>{bytes+=c.length;if(bytes<=65536)out+=c.toString("utf8");});',
299
+ 'child.on("error",fail);child.stdin.on("error",()=>{});',
300
+ 'child.on("close",code=>{if(done)return;let p=null;try{p=JSON.parse(out);}catch{p=null;}if(code!==0||bytes>65536||!p||typeof p!=="object"||Array.isArray(p)){fail();return;}done=true;process.stdout.write(JSON.stringify(p)+"\\n");});',
301
+ 'child.stdin.end(raw);});',
302
+ ].join('');
303
+ const CURSOR_MCP_PRE_ACTION_GUARD_COMMAND = `node -e '${CURSOR_MCP_PRE_ACTION_GUARD_SOURCE}'`;
258
304
  const CLINE_PRE_ACTION_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cline-pre-action-hook`;
259
305
  const CLINE_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cline-hook`;
260
306
  const CLINE_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cline-session-hook`;
@@ -271,6 +317,53 @@ const GEMINI_SESSION_END_ENTRYPOINT = `npx -y --package=${MCP_PACKAGE_SPEC} marr
271
317
  const GEMINI_FIXED_DENIAL = 'Marrow blocked this action because required governance approval or proof is unavailable.';
272
318
  const GEMINI_LAUNCH_FAILURE = 'Marrow governance adapter was unavailable; this action is blocked.';
273
319
  const GEMINI_PRE_ACTION_HOOK_COMMAND = `sh -c 'output="$(${GEMINI_PRE_ACTION_ENTRYPOINT} 2>/dev/null)" && { case "$output" in "{\\"decision\\":\\"allow\\"}"|"{\\"decision\\":\\"deny\\",\\"reason\\":\\"${GEMINI_FIXED_DENIAL}\\"}") printf "%s\\n" "$output"; exit 0 ;; esac; }; printf "%s\\n" "${GEMINI_LAUNCH_FAILURE}" >&2; exit 2'`;
320
+ // Host approvals: the BeforeTool guard also passes the hold denial that carries a typed-reply
321
+ // code for the user only (`systemMessage`), so it checks the JSON shape instead of two fixed
322
+ // strings. Anything else (a crash, a timeout before Gemini's own, invalid or oversized output)
323
+ // still blocks with the fixed launch-failure text. stdin goes straight to the MCP entrypoint.
324
+ const GEMINI_HOOK_GUARD_TIMEOUT_MS = 4500;
325
+ const GEMINI_PRE_ACTION_GUARD_SOURCE = [
326
+ 'const {spawn}=require("node:child_process");',
327
+ 'const valid=value=>{try{const p=JSON.parse(value);if(!p||typeof p!=="object"||Array.isArray(p)||JSON.stringify(p)!==value)return false;const keys=Object.keys(p);',
328
+ 'if(p.decision==="allow")return keys.length===1;',
329
+ 'if(p.decision!=="deny"||typeof p.reason!=="string"||p.reason.length<1||p.reason.length>500)return false;',
330
+ 'if(keys.length===2)return keys.includes("reason");',
331
+ 'return keys.length===3&&keys.includes("systemMessage")&&typeof p.systemMessage==="string"&&p.systemMessage.length>0&&p.systemMessage.length<=600;}catch{return false;}};',
332
+ 'let child=null,timer=null,done=false,output="",bytes=0;',
333
+ // Exits at once: a grandchild still holding the pipe must not keep the hook alive until the
334
+ // host's own timeout, which Gemini treats as a non-blocking failure.
335
+ `const fail=()=>{if(done)return;done=true;if(timer)clearTimeout(timer);try{if(child){if(child.stdout)child.stdout.destroy();if(!child.killed)child.kill("SIGKILL");}}catch{}process.exitCode=2;process.stderr.write(${JSON.stringify(`${GEMINI_LAUNCH_FAILURE}\n`)},()=>process.exit(2));};`,
336
+ 'try{',
337
+ `child=spawn(process.platform==="win32"?"npx.cmd":"npx",${JSON.stringify(['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp', 'gemini-pre-action-hook'])},{stdio:["inherit","pipe","ignore"]});`,
338
+ `timer=setTimeout(fail,${GEMINI_HOOK_GUARD_TIMEOUT_MS});`,
339
+ 'child.stdout.on("data",chunk=>{if(done)return;bytes+=chunk.length;if(bytes>4096){fail();return;}output+=chunk.toString("utf8");});',
340
+ 'child.on("error",fail);',
341
+ 'child.on("close",code=>{if(done)return;const out=output.replace(/\\n+$/,"");if(code!==0||!valid(out)){fail();return;}done=true;clearTimeout(timer);process.stdout.write(out+"\\n");});',
342
+ '}catch{fail();}',
343
+ ].join('');
344
+ const GEMINI_PRE_ACTION_GUARD_COMMAND = `node -e '${GEMINI_PRE_ACTION_GUARD_SOURCE}'`;
345
+ // BeforeAgent records a typed reply ("marrow approve CODE") in a local interactive session. It
346
+ // never blocks the prompt: only a confirmation for the user (`systemMessage`) and a retry note
347
+ // for the agent pass; any failure prints `{}` and the prompt continues. A held action stays
348
+ // denied at BeforeTool until Marrow has the answer, so this hook cannot let one run.
349
+ const GEMINI_CONTEXT_GUARD_SOURCE = [
350
+ 'const {spawn}=require("node:child_process");',
351
+ 'const text=(v)=>typeof v==="string"&&v.length<=1000;',
352
+ 'const valid=value=>{try{const p=JSON.parse(value);if(!p||typeof p!=="object"||Array.isArray(p)||JSON.stringify(p)!==value)return false;',
353
+ 'for(const key of Object.keys(p)){if(key==="systemMessage"){if(!text(p.systemMessage))return false;}',
354
+ 'else if(key==="hookSpecificOutput"){const h=p.hookSpecificOutput;if(!h||typeof h!=="object"||Array.isArray(h)||Object.keys(h).sort().join()!=="additionalContext,hookEventName"||h.hookEventName!=="BeforeAgent"||!text(h.additionalContext))return false;}',
355
+ 'else return false;}return true;}catch{return false;}};',
356
+ 'let child=null,timer=null,done=false,output="",bytes=0;',
357
+ 'const neutral=()=>{if(done)return;done=true;if(timer)clearTimeout(timer);try{if(child){if(child.stdout)child.stdout.destroy();if(!child.killed)child.kill("SIGKILL");}}catch{}process.exitCode=0;process.stdout.write("{}\\n",()=>process.exit(0));};',
358
+ 'try{',
359
+ `child=spawn(process.platform==="win32"?"npx.cmd":"npx",${JSON.stringify(['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp', 'gemini-context-hook'])},{stdio:["inherit","pipe","ignore"]});`,
360
+ `timer=setTimeout(neutral,${GEMINI_HOOK_GUARD_TIMEOUT_MS});`,
361
+ 'child.stdout.on("data",chunk=>{if(done)return;bytes+=chunk.length;if(bytes>4096){neutral();return;}output+=chunk.toString("utf8");});',
362
+ 'child.on("error",neutral);',
363
+ 'child.on("close",code=>{if(done)return;const out=output.replace(/\\n+$/,"");if(code!==0||!valid(out)){neutral();return;}done=true;clearTimeout(timer);process.stdout.write(out+"\\n");});',
364
+ '}catch{neutral();}',
365
+ ].join('');
366
+ const GEMINI_CONTEXT_HOOK_COMMAND = `node -e '${GEMINI_CONTEXT_GUARD_SOURCE}'`;
274
367
  const GEMINI_ACTION_RESULT_HOOK_COMMAND = `sh -c '${GEMINI_ACTION_RESULT_ENTRYPOINT} >/dev/null 2>&1 || :; printf "%s\\n" "{}"; exit 0'`;
275
368
  const GEMINI_SESSION_END_HOOK_COMMAND = `sh -c '${GEMINI_SESSION_END_ENTRYPOINT} >/dev/null 2>&1 || :; printf "%s\\n" "{}"; exit 0'`;
276
369
  const GROK_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp grok-context-hook`;
@@ -297,6 +390,17 @@ const GROK_PRE_ACTION_HOOK_COMMAND = `node -e '${GROK_PRE_ACTION_GUARD_SOURCE}'`
297
390
  const NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|Read|Glob|Grep|Search|WebSearch|Task|functions\\.(?!mcp__marrow__marrow_).*|mcp__(?!marrow__marrow_).*';
298
391
  const CODEX_NATIVE_HOOK_MATCHER = 'Bash|apply_patch|Edit|Write|MultiEdit|mcp__(?!marrow__marrow_).*|functions\\.(?!marrow_).*';
299
392
  const CURSOR_NATIVE_HOOK_MATCHER = 'Shell|Write|Delete|Task|Read|Glob|Grep|Search|WebSearch|List|MCP:(?!marrow(?:_.*|:marrow_.*)$).*';
393
+ // Host approvals: Cursor enforces an "ask" only on beforeShellExecution and beforeMCPExecution,
394
+ // never on preToolUse. Shell calls move to beforeShellExecution (it runs in cloud agents too).
395
+ // MCP calls stay in preToolUse as well, because cloud agents never run beforeMCPExecution: the
396
+ // MCP hook defers there to beforeMCPExecution only in a local interactive session (sessionStart
397
+ // evidence) and holds in cloud and background agents. The result hooks keep the full matcher.
398
+ const CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER = 'Write|Delete|Task|Read|Glob|Grep|Search|WebSearch|List|MCP:(?!marrow(?:_.*|:marrow_.*)$).*';
399
+ // Cursor's gating hooks are failClosed: a cold npx start on a slow network must not block a
400
+ // call, so they get room beyond the MCP hook's own 4-second budget.
401
+ const CURSOR_GATE_HOOK_TIMEOUT_SECONDS = 15;
402
+ const CURSOR_EXECUTION_PRE_EVENTS = Object.freeze(['beforeShellExecution', 'beforeMCPExecution']);
403
+ const CURSOR_EXECUTION_POST_EVENTS = Object.freeze(['afterShellExecution', 'afterMCPExecution']);
300
404
  const GEMINI_NATIVE_HOOK_MATCHER = '^(?:run_shell_command|write_file|replace|edit_file|delete_file|read_file|read_many_files|glob|grep_search|list_directory|get_file_info|web_search|google_web_search|mcp_(?!marrow_marrow_)[A-Za-z0-9_]{1,192})$';
301
405
  const GROK_NATIVE_HOOK_MATCHER = 'run_terminal_command|search_replace|write|spawn_subagent|use_tool|workflow|image_gen|image_edit|image_to_video|reference_to_video';
302
406
  const CODEX_HOOK_TIMEOUT_SECONDS = 5;
@@ -723,6 +827,18 @@ function sourceClient() {
723
827
  return aliases[raw] || (SOURCE_CLIENTS.has(raw) ? raw : 'custom');
724
828
  }
725
829
 
830
+ // What an error may print of an argument it does not know: an option's name (the text before
831
+ // `=`, never its value) when it looks like one (--lower-case-name, or one letter after one dash),
832
+ // or a short plain word. Anything else may be a pasted key (-pPASSWORD) and is not shown.
833
+ function argumentLabel(arg) {
834
+ const text = String(arg ?? '');
835
+ if (text.startsWith('-')) {
836
+ const name = text.split('=')[0];
837
+ return /^(?:--[a-z][a-z0-9-]{0,40}|-[A-Za-z])$/.test(name) ? name : '(an option that is not known; not shown)';
838
+ }
839
+ return /^[a-z][a-z-]{0,23}$/.test(text) ? text : '(a value that is not an option; not shown)';
840
+ }
841
+
726
842
  function parseArgs(argv, env = process.env) {
727
843
  const options = {
728
844
  cwd: process.cwd(),
@@ -742,6 +858,9 @@ function parseArgs(argv, env = process.env) {
742
858
  json: false,
743
859
  activate: false,
744
860
  controller: true,
861
+ // Hooks start a verified local copy of the pinned MCP (installed on write runs) instead of
862
+ // npx. MARROW_LOCAL_RUNTIME=0 or --no-local-runtime keeps them on npx.
863
+ mcpLocalRuntime: env.MARROW_LOCAL_RUNTIME !== '0',
745
864
  };
746
865
  let explicitOperation = false;
747
866
 
@@ -767,6 +886,10 @@ function parseArgs(argv, env = process.env) {
767
886
  explicitOperation = true;
768
887
  options.dryRun = true;
769
888
  }
889
+ else if (arg === 'uninstall' || arg === '--uninstall') {
890
+ explicitOperation = true;
891
+ options.uninstall = true;
892
+ }
770
893
  else if (arg === '--doctor' || arg === 'doctor' || arg === 'check') {
771
894
  explicitOperation = true;
772
895
  options.doctor = true;
@@ -779,6 +902,7 @@ function parseArgs(argv, env = process.env) {
779
902
  options.selfTestExplicitlyDisabled = true;
780
903
  }
781
904
  else if (arg === '--no-controller') options.controller = false;
905
+ else if (arg === '--no-local-runtime') options.mcpLocalRuntime = false;
782
906
  else if (arg === '--self-test') options.selfTest = true;
783
907
  else if (arg === '--cwd') options.cwd = path.resolve(argv[++i] || options.cwd);
784
908
  else if (arg === '--mode') {
@@ -810,10 +934,13 @@ function parseArgs(argv, env = process.env) {
810
934
  else if (arg === '--help' || arg === '-h') {
811
935
  options.help = true;
812
936
  } else {
813
- throw new Error(`Unknown argument: ${arg}`);
937
+ throw new Error(`Unknown argument: ${argumentLabel(arg)}`);
814
938
  }
815
939
  }
816
940
 
941
+ if (options.uninstall && (options.activate || options.repair || options.doctor)) {
942
+ throw new Error('uninstall cannot be combined with activate, update, repair, or doctor');
943
+ }
817
944
  if (!['auto', 'mcp', 'sdk', 'both', 'md'].includes(options.mode)) {
818
945
  throw new Error('--mode must be one of auto, mcp, sdk, both, md');
819
946
  }
@@ -845,6 +972,7 @@ function usage() {
845
972
  npx @getmarrow/install doctor
846
973
  npx @getmarrow/install --mcp --yes
847
974
  npx @getmarrow/install --sdk --yes
975
+ npx @getmarrow/install uninstall [--yes]
848
976
 
849
977
  Options:
850
978
  (no command) Detect, install, self-test, and start the supported persistent controller
@@ -859,8 +987,12 @@ Options:
859
987
  --base-url <url> Marrow API base URL
860
988
  --agent-id <id> Agent/fleet id for self-test headers
861
989
  --no-controller Do not start the local background controller during install/repair
990
+ --no-local-runtime Keep hooks on npx instead of the verified local MCP copy in ~/.marrow/runtime
862
991
  --no-self-test Skip API smoke/self-test
863
992
  --verbose Print the full report instead of the one-line summary and log file
993
+ uninstall Preview removing only Marrow's own hooks, MCP server entries, instructions and
994
+ the local MCP runtime; add --yes to remove them. Your own hooks and settings
995
+ are kept.
864
996
 
865
997
  The API key comes from MARROW_API_KEY, or from the owner-only ~/.marrow/env when unset.
866
998
 
@@ -878,13 +1010,14 @@ function stableAgentId(root, client = sourceClient()) {
878
1010
  function detectedClient(detection) {
879
1011
  if (sourceClient() !== 'custom') return sourceClient();
880
1012
  if (detection.openclaw) return 'openclaw';
881
- if (detection.claudeCode) return 'claude-code';
1013
+ if (detection.claudeCodeProject ?? detection.claudeCode) return 'claude-code';
882
1014
  if (detection.cursor) return 'cursor';
883
1015
  if (detection.cline) return 'cline';
884
1016
  if (detection.windsurf) return 'windsurf';
885
1017
  if (detection.gemini) return 'gemini';
886
1018
  if (detection.codexProject) return 'codex';
887
1019
  // Home-level harnesses rank below project markers; a shared AGENTS.md is a weaker signal.
1020
+ if (detection.claudeCode) return 'claude-code';
888
1021
  if (detection.hermes) return 'hermes';
889
1022
  if (detection.codex) return 'codex';
890
1023
  return 'custom';
@@ -1037,6 +1170,14 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
1037
1170
  };
1038
1171
  const homeHarnesses = detectHomeHarnesses(home, env);
1039
1172
  const codexProject = codexDirectoryIsOwnerConfigured(path.join(root, '.codex'));
1173
+ // Claude Code is detected from the project (CLAUDE.md, .claude/settings.json) or, for a repo
1174
+ // that has neither yet, from the user's installation: ~/.claude/, ~/.claude.json or `claude`
1175
+ // on PATH. Either way the project gets .claude/settings.json.
1176
+ const claudeCodeProject = exists(paths.claudeSettings) || exists(paths.claudeMd);
1177
+ const claudeCodeSource = claudeCodeProject ? 'project'
1178
+ : exists(path.join(home, '.claude')) || exists(path.join(home, '.claude.json')) ? 'home'
1179
+ : executableOnPath('claude', env) ? 'path'
1180
+ : null;
1040
1181
 
1041
1182
  return {
1042
1183
  root,
@@ -1044,7 +1185,9 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
1044
1185
  paths,
1045
1186
  node: exists(paths.packageJson),
1046
1187
  python: exists(paths.pyproject) || exists(paths.requirements) || exists(paths.setupPy),
1047
- claudeCode: exists(paths.claudeSettings) || exists(paths.claudeMd),
1188
+ claudeCode: Boolean(claudeCodeSource),
1189
+ claudeCodeProject,
1190
+ claudeCodeSource,
1048
1191
  cursor: exists(path.join(root, '.cursor')),
1049
1192
  cline: exists(path.join(root, '.clinerules')),
1050
1193
  windsurf: exists(path.join(root, '.windsurf')),
@@ -1123,7 +1266,12 @@ function findLikelyEnvFiles(detection, env = process.env) {
1123
1266
  });
1124
1267
  }
1125
1268
 
1126
- function passiveInstructions() {
1269
+ function passiveInstructions(options = {}) {
1270
+ const hostApprovals = options.hostApprovals === true;
1271
+ const geminiHooks = hostApprovals ? 'BeforeTool, AfterTool, AfterAgent, and BeforeAgent' : 'BeforeTool, AfterTool, and AfterAgent';
1272
+ const approvals = hostApprovals
1273
+ ? '\n- When Marrow holds an action for approval, the operator approves it in the host\'s own permission prompt, or with the typed reply the hook shows them. With nobody at the prompt, the action waits quietly: do not run it, carry on with other work, and tell the person it is waiting. Marrow sends the account owner a one-tap link only for actions the owner approves personally, to reverse the owner\'s own decline when asked, or when the owner turned on unattended pings. Never ask anyone to log in to a dashboard to approve, and never report, write, or claim an approval yourself. Retry the held action only after the approval or when the operator asks.'
1274
+ : '';
1127
1275
  return `${MARROW_BLOCK_START}
1128
1276
  ## Marrow Passive Agent Memory
1129
1277
 
@@ -1137,10 +1285,10 @@ Marrow should run passively after install:
1137
1285
  - Before risky work, use Marrow's decision brief or passive prompt hook.
1138
1286
  - After activate writes MCP or hooks, restart this harness, then run \`npx @getmarrow/install@latest doctor --self-test\`. Do not claim Marrow is live in a process that has not reloaded.
1139
1287
  - First turn: tell the owner the gate is live and empty savings are honest. The next deploy, merge, or publish must go through Marrow.
1140
- - Cursor and Composer use configured native hooks after restart and \`/hooks\` trust review. Cline uses its configured PreToolUse, PostToolUse, and TaskCancel hooks only after Enable Hooks, restart, executable trust, and workspace trust; TaskComplete is not claimed as current coverage. Windsurf uses configured native pre-action, success-result, and response-closeout hooks only after restart, trust review, and leaving Restricted Mode. Gemini CLI uses configured BeforeTool, AfterTool, and AfterAgent hooks only after restart and project fingerprint review and approval in \`/hooks panel\`; explicit user disablement is preserved. Grok uses global native PreToolUse, PostToolUse/PostToolUseFailure, and one nonblocking Stop closeout only after restart and \`/hooks\` inspection; Grok hooks remain user-toggleable. MCP tools remain on demand. Codex uses configured native hooks after restart and \`/hooks\` trust review. The governed wrapper remains an explicit bounded fallback.
1288
+ - Cursor and Composer use configured native hooks after restart and \`/hooks\` trust review. Cline uses its configured PreToolUse, PostToolUse, and TaskCancel hooks only after Enable Hooks, restart, executable trust, and workspace trust; TaskComplete is not claimed as current coverage. Windsurf uses configured native pre-action, success-result, and response-closeout hooks only after restart, trust review, and leaving Restricted Mode. Gemini CLI uses configured ${geminiHooks} hooks only after restart and project fingerprint review and approval in \`/hooks panel\`; explicit user disablement is preserved. Grok uses global native PreToolUse, PostToolUse/PostToolUseFailure, and one nonblocking Stop closeout only after restart and \`/hooks\` inspection; Grok hooks remain user-toggleable. MCP tools remain on demand. Codex uses configured native hooks after restart and \`/hooks\` trust review. The governed wrapper remains an explicit bounded fallback.
1141
1289
  - Before the session ends, close open work with session-end auto-commit or \`marrow_commit\`. Record model usage only when the host response includes counts.
1142
1290
  - After meaningful work, record the outcome so future agents learn from it.
1143
- - After Marrow blocks, warns, or requires review, use the decision trace receipt to tell the operator what changed and which recorded workflow or proof is required. Stay quiet for routine low-risk work.
1291
+ - After Marrow blocks, warns, or requires review, use the decision trace receipt to tell the operator what changed and which recorded workflow or proof is required. Stay quiet for routine low-risk work.${approvals}
1144
1292
  - Check health with \`marrow_agent_status\` or \`GET /v1/agent/status\`.
1145
1293
  - When status/runtime returns a \`client_update\` notice, tell the operator and use its exact update and verification commands only when local change policy permits.
1146
1294
 
@@ -1208,6 +1356,9 @@ MARROW_PASSIVE_TOKEN_USAGE=true
1208
1356
  `;
1209
1357
  }
1210
1358
 
1359
+ // Hook files are read with Marrow's hook commands in their canonical (npx) form, whether they
1360
+ // start through npx or the local runtime, so every check and merge sees one spelling; writes
1361
+ // switch them to the local runtime again when it is verified.
1211
1362
  function parseJsonObject(filePath) {
1212
1363
  const raw = safeRead(filePath).trim();
1213
1364
  if (!raw) return {};
@@ -1215,7 +1366,7 @@ function parseJsonObject(filePath) {
1215
1366
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1216
1367
  throw new Error(`Expected JSON object in ${filePath}`);
1217
1368
  }
1218
- return parsed;
1369
+ return mapHookCommands(parsed, delocalizeHookCommand);
1219
1370
  }
1220
1371
 
1221
1372
  function canonicalJsonValue(value) {
@@ -1293,7 +1444,7 @@ function exactHookDescriptors(settings, eventName, command, matcher) {
1293
1444
  function marrowHookSubcommand(command) {
1294
1445
  if (typeof command !== 'string') return null;
1295
1446
  const match = command.trim().match(
1296
- /^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|cline|codex|cursor|gemini|grok|windsurf)-)?(context-hook|pre-action-hook|hook|session-hook)$/,
1447
+ /^npx\s+(?:-y\s+)?(?:--package=@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp|@getmarrow\/mcp(?:@[^\s]+)?)\s+(?:(?:claude|cline|codex|cursor|gemini|grok|windsurf)-)?(context-hook|pre-action-hook|hook|session-hook|permission-request-hook)$/,
1297
1448
  );
1298
1449
  return match?.[1] || null;
1299
1450
  }
@@ -1367,11 +1518,33 @@ function safeJsonObject(filePath) {
1367
1518
  }
1368
1519
  }
1369
1520
 
1370
- function claudeNativeHookFingerprint(settings) {
1521
+ // The host-approval hooks Claude Code needs besides the core five (see upsertClaudeHooks).
1522
+ function claudeHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
1523
+ return {
1524
+ permission_request: exactHookConfigured(settings, 'PermissionRequest', targetCommand(MCP_PERMISSION_REQUEST_HOOK_COMMAND), NATIVE_HOOK_MATCHER),
1525
+ post_tool_batch: exactHookConfigured(settings, 'PostToolBatch', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND)),
1526
+ };
1527
+ }
1528
+
1529
+ function claudeNativeHookFingerprint(settings, options = {}) {
1530
+ const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
1371
1531
  const contract = {
1372
1532
  schema: 'marrow-claude-native-hooks.v3',
1373
1533
  adapter_version: MCP_ADAPTER_VERSION,
1374
1534
  expected_hooks: NATIVE_EXPECTED_HOOKS,
1535
+ ...(hostApprovals ? {
1536
+ host_approvals: {
1537
+ configured: claudeHostApprovalHooksConfigured(settings),
1538
+ descriptors: {
1539
+ permission_request: exactHookDescriptors(settings, 'PermissionRequest', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
1540
+ post_tool_batch: exactHookDescriptors(settings, 'PostToolBatch', MCP_ACTION_RESULT_HOOK_COMMAND),
1541
+ },
1542
+ active_marrow_handlers: {
1543
+ permission_request: marrowHookDescriptors(settings, 'PermissionRequest'),
1544
+ post_tool_batch: marrowHookDescriptors(settings, 'PostToolBatch'),
1545
+ },
1546
+ },
1547
+ } : {}),
1375
1548
  configured: {
1376
1549
  prompt: exactHookConfigured(settings, 'UserPromptSubmit', MCP_CONTEXT_HOOK_COMMAND),
1377
1550
  pre_action: exactHookConfigured(settings, 'PreToolUse', MCP_PRE_ACTION_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
@@ -1397,7 +1570,7 @@ function claudeNativeHookFingerprint(settings) {
1397
1570
  return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
1398
1571
  }
1399
1572
 
1400
- function upsertClaudeHooks(settingsPath) {
1573
+ function upsertClaudeHooks(settingsPath, options = {}) {
1401
1574
  const settings = parseJsonObject(settingsPath);
1402
1575
  const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
1403
1576
  ? settings.hooks
@@ -1426,6 +1599,18 @@ function upsertClaudeHooks(settingsPath) {
1426
1599
  UserPromptSubmit: userPromptSubmit,
1427
1600
  Stop: stop,
1428
1601
  };
1602
+ if (options.hostApprovals) {
1603
+ // Both async, exactly as `marrow-mcp setup` writes them: the marker never delays or answers
1604
+ // the dialog, and PostToolBatch settles a declined dialog in the background.
1605
+ settings.hooks.PermissionRequest = reconcileMarrowCommandHook(
1606
+ settings, 'PermissionRequest', 'permission-request-hook', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER,
1607
+ { async: true },
1608
+ );
1609
+ settings.hooks.PostToolBatch = reconcileMarrowCommandHook(
1610
+ settings, 'PostToolBatch', 'hook', MCP_ACTION_RESULT_HOOK_COMMAND, undefined,
1611
+ { async: true },
1612
+ );
1613
+ }
1429
1614
 
1430
1615
  return JSON.stringify(settings, null, 2) + '\n';
1431
1616
  }
@@ -1484,11 +1669,20 @@ function upsertCodexHooks(hooksPath) {
1484
1669
  return JSON.stringify(settings, null, 2) + '\n';
1485
1670
  }
1486
1671
 
1672
+ // A Marrow Cursor entry: the npx entrypoint, or the node guard that names it as JSON items.
1673
+ function cursorMarrowHookSubcommand(command) {
1674
+ const direct = marrowHookSubcommand(command);
1675
+ if (direct) return direct;
1676
+ if (typeof command !== 'string') return null;
1677
+ const guarded = command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']cursor-(pre-action-hook|context-hook|hook|session-hook)["']/);
1678
+ return guarded?.[1] || null;
1679
+ }
1680
+
1487
1681
  function reconcileCursorHook(settings, eventName, subcommand, canonical) {
1488
1682
  const original = Array.isArray(settings?.hooks?.[eventName]) ? settings.hooks[eventName] : [];
1489
1683
  const retained = original.filter((entry) => !(
1490
1684
  entry && typeof entry === 'object' && !Array.isArray(entry)
1491
- && marrowHookSubcommand(entry.command)
1685
+ && cursorMarrowHookSubcommand(entry.command)
1492
1686
  ));
1493
1687
  return [...retained, canonical];
1494
1688
  }
@@ -1506,7 +1700,7 @@ function cursorHookDescriptors(settings, eventName) {
1506
1700
  const entries = settings?.hooks?.[eventName];
1507
1701
  if (!Array.isArray(entries)) return [];
1508
1702
  return entries.flatMap((entry) => {
1509
- if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !marrowHookSubcommand(entry.command)) return [];
1703
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !cursorMarrowHookSubcommand(entry.command)) return [];
1510
1704
  return [{
1511
1705
  matcher: typeof entry.matcher === 'string' ? entry.matcher : null,
1512
1706
  command: String(entry.command).trim(),
@@ -1517,14 +1711,43 @@ function cursorHookDescriptors(settings, eventName) {
1517
1711
  });
1518
1712
  }
1519
1713
 
1520
- function cursorNativeHookFingerprint(settings) {
1714
+ // The Cursor hooks that host approvals add (see upsertCursorHooks), as configured checks.
1715
+ function cursorHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
1716
+ const pre = targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND);
1717
+ const post = targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND);
1718
+ const gate = { timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false };
1719
+ return {
1720
+ pre_tool_use: exactCursorHookConfigured(settings, 'preToolUse', pre, CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER, gate),
1721
+ beforeShellExecution: exactCursorHookConfigured(settings, 'beforeShellExecution', pre, undefined, gate),
1722
+ beforeMCPExecution: exactCursorHookConfigured(settings, 'beforeMCPExecution', targetCommand(CURSOR_MCP_PRE_ACTION_GUARD_COMMAND), undefined, gate),
1723
+ ...Object.fromEntries(CURSOR_EXECUTION_POST_EVENTS.map((event) => [event, exactCursorHookConfigured(settings, event, post, undefined, {
1724
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1725
+ })])),
1726
+ sessionStart: exactCursorHookConfigured(settings, 'sessionStart', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
1727
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1728
+ }),
1729
+ beforeSubmitPrompt: exactCursorHookConfigured(settings, 'beforeSubmitPrompt', targetCommand(CURSOR_CONTEXT_HOOK_COMMAND), undefined, {
1730
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1731
+ }),
1732
+ };
1733
+ }
1734
+
1735
+ function cursorNativeHookFingerprint(settings, options = {}) {
1736
+ const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
1521
1737
  const contract = {
1522
1738
  schema: 'marrow-cursor-native-hooks.v1',
1523
1739
  adapter_version: MCP_ADAPTER_VERSION,
1524
1740
  expected_hooks: ['pre_action', 'action_result', 'outcome_closure'],
1741
+ ...(hostApprovals ? {
1742
+ host_approvals: {
1743
+ configured: cursorHostApprovalHooksConfigured(settings),
1744
+ descriptors: Object.fromEntries([...CURSOR_EXECUTION_PRE_EVENTS, ...CURSOR_EXECUTION_POST_EVENTS, 'sessionStart', 'beforeSubmitPrompt']
1745
+ .map((event) => [event, cursorHookDescriptors(settings, event)])),
1746
+ },
1747
+ } : {}),
1525
1748
  configured: {
1526
- pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
1527
- timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1749
+ pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
1750
+ timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
1528
1751
  failClosed: true,
1529
1752
  async: false,
1530
1753
  }),
@@ -1548,18 +1771,19 @@ function cursorNativeHookFingerprint(settings) {
1548
1771
  return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
1549
1772
  }
1550
1773
 
1551
- function upsertCursorHooks(hooksPath) {
1774
+ function upsertCursorHooks(hooksPath, options = {}) {
1552
1775
  const settings = parseJsonObject(hooksPath);
1553
1776
  const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
1554
1777
  ? settings.hooks
1555
1778
  : {};
1556
1779
  settings.version = 1;
1780
+ const hostApprovals = options.hostApprovals === true;
1557
1781
  settings.hooks = {
1558
1782
  ...hooks,
1559
1783
  preToolUse: reconcileCursorHook(settings, 'preToolUse', 'pre-action-hook', {
1560
1784
  command: CURSOR_PRE_ACTION_HOOK_COMMAND,
1561
- matcher: CURSOR_NATIVE_HOOK_MATCHER,
1562
- timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1785
+ matcher: hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER,
1786
+ timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
1563
1787
  failClosed: true,
1564
1788
  async: false,
1565
1789
  }),
@@ -1578,6 +1802,36 @@ function upsertCursorHooks(hooksPath) {
1578
1802
  timeout: CODEX_SESSION_TIMEOUT_SECONDS,
1579
1803
  }),
1580
1804
  };
1805
+ if (hostApprovals) {
1806
+ // Cursor enforces "ask" only here. failClosed: a crash or timeout blocks the call instead
1807
+ // of letting it through (Cursor fails open by default).
1808
+ for (const eventName of CURSOR_EXECUTION_PRE_EVENTS) {
1809
+ settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'pre-action-hook', {
1810
+ command: eventName === 'beforeMCPExecution' ? CURSOR_MCP_PRE_ACTION_GUARD_COMMAND : CURSOR_PRE_ACTION_HOOK_COMMAND,
1811
+ timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS,
1812
+ failClosed: true,
1813
+ async: false,
1814
+ });
1815
+ }
1816
+ for (const eventName of CURSOR_EXECUTION_POST_EVENTS) {
1817
+ settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'hook', {
1818
+ command: CURSOR_ACTION_RESULT_HOOK_COMMAND,
1819
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1820
+ });
1821
+ }
1822
+ // sessionStart tells the hooks whether the session is local and interactive (not a
1823
+ // background agent); without it Cursor holds are denied instead of asked.
1824
+ settings.hooks.sessionStart = reconcileCursorHook(settings, 'sessionStart', 'session-hook', {
1825
+ command: CURSOR_SESSION_END_HOOK_COMMAND,
1826
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1827
+ });
1828
+ // The typed reply ("marrow approve CODE") in a local interactive session. Never failClosed:
1829
+ // a failure must not block the operator's own prompts.
1830
+ settings.hooks.beforeSubmitPrompt = reconcileCursorHook(settings, 'beforeSubmitPrompt', 'context-hook', {
1831
+ command: CURSOR_CONTEXT_HOOK_COMMAND,
1832
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1833
+ });
1834
+ }
1581
1835
  return JSON.stringify(settings, null, 2) + '\n';
1582
1836
  }
1583
1837
 
@@ -1647,7 +1901,9 @@ function upsertWindsurfHooks(hooksPath) {
1647
1901
 
1648
1902
  function geminiMarrowHookEntrypoint(command) {
1649
1903
  if (typeof command !== 'string') return null;
1650
- const match = command.match(/@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp\s+gemini-(pre-action-hook|hook|session-hook)(?:\s|['"]|$)/);
1904
+ const match = command.match(/@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp\s+gemini-(pre-action-hook|context-hook|hook|session-hook)(?:\s|['"]|$)/)
1905
+ // The node guards name the entrypoint as JSON array items.
1906
+ || command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']gemini-(pre-action-hook|context-hook|hook|session-hook)["']/);
1651
1907
  return match?.[1] || null;
1652
1908
  }
1653
1909
 
@@ -1689,7 +1945,14 @@ function geminiHooksExplicitlyDisabled(settings) {
1689
1945
  return settings?.hooksConfig?.enabled === false;
1690
1946
  }
1691
1947
 
1692
- function geminiNativeHookFingerprint(settings) {
1948
+ // The Gemini BeforeTool command: the JSON-checking guard with host approvals, otherwise the
1949
+ // earlier fixed-string wrapper.
1950
+ function geminiPreActionCommand(hostApprovals) {
1951
+ return hostApprovals ? GEMINI_PRE_ACTION_GUARD_COMMAND : GEMINI_PRE_ACTION_HOOK_COMMAND;
1952
+ }
1953
+
1954
+ function geminiNativeHookFingerprint(settings, options = {}) {
1955
+ const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
1693
1956
  return crypto.createHash('sha256').update(JSON.stringify({
1694
1957
  schema: 'marrow-gemini-native-hooks.v1',
1695
1958
  adapter_version: MCP_ADAPTER_VERSION,
@@ -1698,7 +1961,7 @@ function geminiNativeHookFingerprint(settings) {
1698
1961
  explicitly_disabled: geminiHooksExplicitlyDisabled(settings),
1699
1962
  configured: {
1700
1963
  pre_action: exactGeminiHookConfigured(
1701
- settings, 'BeforeTool', 'marrow-before-tool', GEMINI_PRE_ACTION_HOOK_COMMAND,
1964
+ settings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
1702
1965
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
1703
1966
  ),
1704
1967
  action_result: exactGeminiHookConfigured(
@@ -1709,6 +1972,12 @@ function geminiNativeHookFingerprint(settings) {
1709
1972
  settings, 'AfterAgent', 'marrow-after-agent', GEMINI_SESSION_END_HOOK_COMMAND,
1710
1973
  undefined, GEMINI_CLOSEOUT_TIMEOUT_MS,
1711
1974
  ),
1975
+ ...(hostApprovals ? {
1976
+ typed_reply: exactGeminiHookConfigured(
1977
+ settings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
1978
+ undefined, GEMINI_HOOK_TIMEOUT_MS,
1979
+ ),
1980
+ } : {}),
1712
1981
  },
1713
1982
  session_end_claimed: false,
1714
1983
  })).digest('hex');
@@ -1825,11 +2094,12 @@ function managedGrokHooksFile(filePath) {
1825
2094
  }
1826
2095
  }
1827
2096
 
1828
- function upsertGeminiHooks(settingsPath) {
2097
+ function upsertGeminiHooks(settingsPath, options = {}) {
1829
2098
  const settings = parseJsonObject(settingsPath);
1830
2099
  const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
1831
2100
  ? settings.hooks
1832
2101
  : {};
2102
+ const hostApprovals = options.hostApprovals === true;
1833
2103
  settings.hooks = {
1834
2104
  ...hooks,
1835
2105
  BeforeTool: reconcileGeminiHook(settings, 'BeforeTool', {
@@ -1837,7 +2107,7 @@ function upsertGeminiHooks(settingsPath) {
1837
2107
  hooks: [{
1838
2108
  name: 'marrow-before-tool',
1839
2109
  type: 'command',
1840
- command: GEMINI_PRE_ACTION_HOOK_COMMAND,
2110
+ command: geminiPreActionCommand(hostApprovals),
1841
2111
  timeout: GEMINI_HOOK_TIMEOUT_MS,
1842
2112
  }],
1843
2113
  }),
@@ -1859,6 +2129,17 @@ function upsertGeminiHooks(settingsPath) {
1859
2129
  }],
1860
2130
  }),
1861
2131
  };
2132
+ if (hostApprovals) {
2133
+ // The typed reply in a local interactive session (`gemini`, never `gemini -p`).
2134
+ settings.hooks.BeforeAgent = reconcileGeminiHook(settings, 'BeforeAgent', {
2135
+ hooks: [{
2136
+ name: 'marrow-before-agent',
2137
+ type: 'command',
2138
+ command: GEMINI_CONTEXT_HOOK_COMMAND,
2139
+ timeout: GEMINI_HOOK_TIMEOUT_MS,
2140
+ }],
2141
+ });
2142
+ }
1862
2143
  return JSON.stringify(settings, null, 2) + '\n';
1863
2144
  }
1864
2145
 
@@ -1935,7 +2216,7 @@ function clineNativeHookFingerprint(detection) {
1935
2216
  function activationProfile(detection, plan, changes, client) {
1936
2217
  const registry = HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === client)
1937
2218
  || HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === 'custom');
1938
- const sdkDependency = inspectSdkDependency(detection);
2219
+ const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
1939
2220
  const capabilityLevel = client === 'custom' && (plan.mode === 'sdk' || plan.mode === 'both')
1940
2221
  ? 'sdk_passive_runtime'
1941
2222
  : registry.capability_level;
@@ -1946,6 +2227,7 @@ function activationProfile(detection, plan, changes, client) {
1946
2227
  ? plan.mcp_target_version
1947
2228
  : MCP_ADAPTER_VERSION;
1948
2229
  const targetCommand = (command) => retargetMcpPackageSpec(command, mcpTargetVersion);
2230
+ const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
1949
2231
  const adapterVersion = capabilityLevel === 'native_hooks' || capabilityLevel === 'mcp'
1950
2232
  ? mcpTargetVersion
1951
2233
  : capabilityLevel === 'sdk_passive_runtime'
@@ -1964,14 +2246,18 @@ function activationProfile(detection, plan, changes, client) {
1964
2246
  if (exactHookConfigured(codexSettings, 'PostToolUse', targetCommand(CODEX_ACTION_RESULT_HOOK_COMMAND), CODEX_NATIVE_HOOK_MATCHER)) observedHooks.push('action_result');
1965
2247
  if (exactHookConfigured(codexSettings, 'SessionEnd', targetCommand(CODEX_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
1966
2248
  } else if (client === 'cursor' || client === 'composer') {
1967
- if (exactCursorHookConfigured(cursorSettings, 'preToolUse', targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
1968
- timeout: CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
1969
- })) observedHooks.push('pre_action');
2249
+ const approvalHooks = hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings, targetCommand) : null;
2250
+ if ((hostApprovals
2251
+ ? approvalHooks.pre_tool_use && approvalHooks.beforeShellExecution && approvalHooks.beforeMCPExecution
2252
+ && approvalHooks.sessionStart && approvalHooks.beforeSubmitPrompt
2253
+ : exactCursorHookConfigured(cursorSettings, 'preToolUse', targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
2254
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
2255
+ }))) observedHooks.push('pre_action');
1970
2256
  if (exactCursorHookConfigured(cursorSettings, 'postToolUse', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
1971
2257
  timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1972
2258
  }) && exactCursorHookConfigured(cursorSettings, 'postToolUseFailure', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
1973
2259
  timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1974
- })) observedHooks.push('action_result');
2260
+ }) && (!hostApprovals || (approvalHooks.afterShellExecution && approvalHooks.afterMCPExecution))) observedHooks.push('action_result');
1975
2261
  if (exactCursorHookConfigured(cursorSettings, 'stop', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
1976
2262
  timeout: CODEX_SESSION_TIMEOUT_SECONDS,
1977
2263
  })) observedHooks.push('outcome_closure');
@@ -1991,9 +2277,12 @@ function activationProfile(detection, plan, changes, client) {
1991
2277
  )) observedHooks.push('response_closeout');
1992
2278
  } else if (client === 'gemini' && !geminiHooksExplicitlyDisabled(geminiSettings)) {
1993
2279
  if (exactGeminiHookConfigured(
1994
- geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(GEMINI_PRE_ACTION_HOOK_COMMAND),
2280
+ geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(geminiPreActionCommand(hostApprovals)),
1995
2281
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
1996
- )) observedHooks.push('pre_action');
2282
+ ) && (!hostApprovals || exactGeminiHookConfigured(
2283
+ geminiSettings, 'BeforeAgent', 'marrow-before-agent', targetCommand(GEMINI_CONTEXT_HOOK_COMMAND),
2284
+ undefined, GEMINI_HOOK_TIMEOUT_MS,
2285
+ ))) observedHooks.push('pre_action');
1997
2286
  if (exactGeminiHookConfigured(
1998
2287
  geminiSettings, 'AfterTool', 'marrow-after-tool', targetCommand(GEMINI_ACTION_RESULT_HOOK_COMMAND),
1999
2288
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
@@ -2017,11 +2306,14 @@ function activationProfile(detection, plan, changes, client) {
2017
2306
  } else {
2018
2307
  if (capabilityLevel === 'native_hooks'
2019
2308
  && exactHookConfigured(claudeSettings, 'UserPromptSubmit', targetCommand(MCP_CONTEXT_HOOK_COMMAND))) observedHooks.push('prompt');
2309
+ const claudeApprovalHooks = hostApprovals ? claudeHostApprovalHooksConfigured(claudeSettings, targetCommand) : null;
2020
2310
  if (capabilityLevel === 'native_hooks'
2021
- && exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)) observedHooks.push('pre_action');
2311
+ && exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
2312
+ && (!hostApprovals || claudeApprovalHooks.permission_request)) observedHooks.push('pre_action');
2022
2313
  if (capabilityLevel === 'native_hooks'
2023
2314
  && exactHookConfigured(claudeSettings, 'PostToolUse', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
2024
- && exactHookConfigured(claudeSettings, 'PostToolUseFailure', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)) observedHooks.push('action_result');
2315
+ && exactHookConfigured(claudeSettings, 'PostToolUseFailure', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
2316
+ && (!hostApprovals || claudeApprovalHooks.post_tool_batch)) observedHooks.push('action_result');
2025
2317
  if (capabilityLevel === 'native_hooks'
2026
2318
  && exactHookConfigured(claudeSettings, 'Stop', targetCommand(MCP_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
2027
2319
  }
@@ -2048,12 +2340,12 @@ function activationProfile(detection, plan, changes, client) {
2048
2340
  .join('|');
2049
2341
  const configFingerprint = capabilityLevel === 'native_hooks'
2050
2342
  ? client === 'codex' ? codexNativeHookFingerprint(codexSettings)
2051
- : client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings)
2343
+ : client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings, { hostApprovals })
2052
2344
  : client === 'cline' ? clineNativeHookFingerprint(detection)
2053
2345
  : client === 'windsurf' ? windsurfNativeHookFingerprint(windsurfSettings)
2054
- : client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings)
2346
+ : client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings, { hostApprovals })
2055
2347
  : client === 'grok' ? grokNativeHookFingerprint(grokSettings)
2056
- : claudeNativeHookFingerprint(claudeSettings)
2348
+ : claudeNativeHookFingerprint(claudeSettings, { hostApprovals })
2057
2349
  : crypto.createHash('sha256')
2058
2350
  .update(`${client}:${capabilityLevel}:${expectedHooks.join(',')}:${fingerprintMaterial}`)
2059
2351
  .digest('hex');
@@ -2220,8 +2512,9 @@ function upsertMcpServerConfig(filePath, options = {}) {
2220
2512
  return JSON.stringify(config, null, 2) + '\n';
2221
2513
  }
2222
2514
 
2223
- function inspectSdkDependency(detection) {
2224
- if (!detection.node) {
2515
+ function inspectSdkDependency(detection, { sdkMode = false } = {}) {
2516
+ // No SDK advice unless this project uses the SDK or the owner asked for the SDK runtime.
2517
+ if (!sdkMode && !sdkUseDetected(detection)) {
2225
2518
  return { required: false, present: false, install_command: null };
2226
2519
  }
2227
2520
 
@@ -2346,11 +2639,14 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
2346
2639
  && exactHookConfigured(codexSettings, 'PreToolUse', CODEX_PRE_ACTION_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
2347
2640
  && exactHookConfigured(codexSettings, 'PostToolUse', CODEX_ACTION_RESULT_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
2348
2641
  && exactHookConfigured(codexSettings, 'SessionEnd', CODEX_SESSION_END_HOOK_COMMAND));
2642
+ const hostApprovals = hostApprovalHooksSupported();
2349
2643
  const cursorSettings = ['cursor', 'composer'].includes(entry.client) ? safeJsonObject(detection.paths.cursorHooks) : null;
2644
+ const cursorApprovalHooks = cursorSettings && hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings) : null;
2350
2645
  const cursorConfigured = Boolean(cursorSettings
2351
- && exactCursorHookConfigured(cursorSettings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
2352
- timeout: CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
2646
+ && exactCursorHookConfigured(cursorSettings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
2647
+ timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
2353
2648
  })
2649
+ && (!cursorApprovalHooks || Object.values(cursorApprovalHooks).every(Boolean))
2354
2650
  && exactCursorHookConfigured(cursorSettings, 'postToolUse', CURSOR_ACTION_RESULT_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
2355
2651
  timeout: CODEX_HOOK_TIMEOUT_SECONDS,
2356
2652
  })
@@ -2377,9 +2673,13 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
2377
2673
  const geminiConfigured = Boolean(geminiSettings
2378
2674
  && !geminiHooksExplicitlyDisabled(geminiSettings)
2379
2675
  && exactGeminiHookConfigured(
2380
- geminiSettings, 'BeforeTool', 'marrow-before-tool', GEMINI_PRE_ACTION_HOOK_COMMAND,
2676
+ geminiSettings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
2381
2677
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
2382
2678
  )
2679
+ && (!hostApprovals || exactGeminiHookConfigured(
2680
+ geminiSettings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
2681
+ undefined, GEMINI_HOOK_TIMEOUT_MS,
2682
+ ))
2383
2683
  && exactGeminiHookConfigured(
2384
2684
  geminiSettings, 'AfterTool', 'marrow-after-tool', GEMINI_ACTION_RESULT_HOOK_COMMAND,
2385
2685
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
@@ -2462,6 +2762,32 @@ function derivedAgentIdsFor(root) {
2462
2762
  return [...SOURCE_CLIENTS].map((candidate) => stableAgentId(root, candidate));
2463
2763
  }
2464
2764
 
2765
+ // SDK use in this project: the SDK is a declared or installed dependency, or an earlier install
2766
+ // already wrote Marrow's passive runtime here. Without it, no SDK file or SDK advice is written.
2767
+ function sdkUseDetected(detection) {
2768
+ if (exists(detection.paths.passiveRuntime)) return true;
2769
+ if (!detection.node) return false;
2770
+ if (findUp(detection.root, [path.join('node_modules', '@getmarrow', 'sdk', 'package.json')])) return true;
2771
+ const raw = safeRead(detection.paths.packageJson);
2772
+ if (!raw) return false;
2773
+ // A package.json Marrow cannot parse is checked by hand (the SDK check says so).
2774
+ try {
2775
+ JSON.parse(raw);
2776
+ } catch {
2777
+ return /@getmarrow\/sdk/.test(raw);
2778
+ }
2779
+ return /"@getmarrow\/sdk"/.test(raw) || /@getmarrow\/sdk@/.test(raw);
2780
+ }
2781
+
2782
+ // Hosts that read AGENTS.md. Claude Code reads CLAUDE.md and Cursor gets its own rule file, so
2783
+ // a project with only those gets no AGENTS.md unless it already holds Marrow's block.
2784
+ const AGENTS_MD_READERS = Object.freeze(['codex', 'windsurf', 'gemini', 'cline', 'grok', 'hermes', 'openclaw']);
2785
+
2786
+ function agentsMdWanted(detection) {
2787
+ if (AGENTS_MD_READERS.some((name) => detection[name])) return true;
2788
+ return Boolean(marrowManagedBlockInText(safeRead(detection.paths.agentsMd)));
2789
+ }
2790
+
2465
2791
  function buildPlan(detection, options) {
2466
2792
  const client = options.client || detectedClient(detection);
2467
2793
  // Server-facing configuration carries only a configured agent id. Without one, the MCP
@@ -2488,9 +2814,14 @@ function buildPlan(detection, options) {
2488
2814
  return write;
2489
2815
  };
2490
2816
  const mcpTargetVersion = executableMcpTarget(options).version;
2817
+ // Hooks start the verified local MCP runtime for this exact version when there is one.
2818
+ const runtimeVersion = options.mcpRuntime && options.mcpRuntime.version === mcpTargetVersion ? mcpTargetVersion : null;
2819
+ const localize = (text) => (runtimeVersion ? localizeHookSettingsText(text, runtimeVersion) : text);
2491
2820
  const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
2821
+ // The host-approval hook layout only for an MCP that answers those hooks.
2822
+ const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
2492
2823
  const mode = options.mode === 'auto'
2493
- ? detection.node ? 'both' : 'mcp'
2824
+ ? sdkUseDetected(detection) ? 'both' : 'mcp'
2494
2825
  : options.mode;
2495
2826
  const writes = [];
2496
2827
 
@@ -2516,7 +2847,7 @@ function buildPlan(detection, options) {
2516
2847
  type: 'json-transform',
2517
2848
  path: detection.paths.claudeSettings,
2518
2849
  label: 'Claude Code MCP passive hooks',
2519
- transform: (filePath) => retarget(upsertClaudeHooks(filePath)),
2850
+ transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals }))),
2520
2851
  });
2521
2852
  }
2522
2853
  if (detection.codex) {
@@ -2524,7 +2855,7 @@ function buildPlan(detection, options) {
2524
2855
  type: 'json-transform',
2525
2856
  path: detection.paths.codexHooks,
2526
2857
  label: 'Codex native hooks',
2527
- transform: (filePath) => retarget(upsertCodexHooks(filePath)),
2858
+ transform: (filePath) => localize(retarget(upsertCodexHooks(filePath))),
2528
2859
  });
2529
2860
  }
2530
2861
  if (detection.cline) {
@@ -2544,7 +2875,7 @@ function buildPlan(detection, options) {
2544
2875
  type: 'json-transform',
2545
2876
  path: detection.paths.windsurfHooks,
2546
2877
  label: 'Windsurf native hooks',
2547
- transform: (filePath) => retarget(upsertWindsurfHooks(filePath)),
2878
+ transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath))),
2548
2879
  });
2549
2880
  }
2550
2881
  if (detection.gemini) {
@@ -2552,7 +2883,7 @@ function buildPlan(detection, options) {
2552
2883
  type: 'json-transform',
2553
2884
  path: detection.paths.geminiSettings,
2554
2885
  label: 'Gemini CLI native hooks',
2555
- transform: (filePath) => retarget(upsertGeminiHooks(filePath)),
2886
+ transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals }))),
2556
2887
  });
2557
2888
  }
2558
2889
  if (detection.grok) {
@@ -2563,7 +2894,7 @@ function buildPlan(detection, options) {
2563
2894
  label: 'Grok native hooks',
2564
2895
  isManaged: managedGrokHooksFile,
2565
2896
  conflict_fix: 'Preserve or move the unmanaged owner Grok hook file after owner review, then rerun npx @getmarrow/install update.',
2566
- transform: (filePath) => retarget(upsertGrokHooks(filePath)),
2897
+ transform: (filePath) => localize(retarget(upsertGrokHooks(filePath))),
2567
2898
  });
2568
2899
  }
2569
2900
  writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
@@ -2587,18 +2918,20 @@ function buildPlan(detection, options) {
2587
2918
  type: 'json-transform',
2588
2919
  path: detection.paths.cursorHooks,
2589
2920
  label: 'Cursor native hooks',
2590
- transform: (filePath) => retarget(upsertCursorHooks(filePath)),
2921
+ transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals }))),
2591
2922
  });
2592
2923
  writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
2593
2924
  }
2594
2925
  }
2595
2926
 
2596
- if (mode === 'md' || mode === 'both' || mode === 'mcp') {
2927
+ // AGENTS.md only for a host that reads it, an explicit --md or --both, or a file that already
2928
+ // carries Marrow's block (so its pins stay current).
2929
+ if (mode === 'md' || (options.mode === 'both') || ((mode === 'both' || mode === 'mcp') && agentsMdWanted(detection))) {
2597
2930
  writes.push({
2598
2931
  type: 'md-block',
2599
2932
  path: detection.paths.agentsMd,
2600
2933
  label: 'Agent instructions',
2601
- block: retarget(passiveInstructions()),
2934
+ block: retarget(passiveInstructions({ hostApprovals })),
2602
2935
  });
2603
2936
  }
2604
2937
 
@@ -2607,11 +2940,11 @@ function buildPlan(detection, options) {
2607
2940
  type: 'file',
2608
2941
  path: detection.paths.cursorRules,
2609
2942
  label: 'Cursor Marrow rule',
2610
- content: retarget(passiveInstructions()).replace(/<!--[^>]+-->/g, '').trim() + '\n',
2943
+ content: retarget(passiveInstructions({ hostApprovals })).replace(/<!--[^>]+-->/g, '').trim() + '\n',
2611
2944
  });
2612
2945
  }
2613
2946
 
2614
- return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion };
2947
+ return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion, host_approval_hooks: hostApprovals };
2615
2948
  }
2616
2949
 
2617
2950
  function assertContainedManagedTarget(root, targetPath) {
@@ -2728,7 +3061,9 @@ function applyPlan(plan, options) {
2728
3061
  undo = planned.action === 'update' ? planned.undo || [] : null;
2729
3062
  }
2730
3063
  } else if (write.type === 'owned-executable') {
2731
- if (fileExists && before !== write.content) {
3064
+ // A file Marrow wrote for another pinned MCP version is still Marrow's and moves to this
3065
+ // pin; any other difference is the owner's edit and is never overwritten.
3066
+ if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content])) {
2732
3067
  after = before;
2733
3068
  hookConflict = true;
2734
3069
  } else {
@@ -2803,6 +3138,294 @@ function applyPlan(plan, options) {
2803
3138
  return changes;
2804
3139
  }
2805
3140
 
3141
+ // ---------------------------------------------------------------------------
3142
+ // Uninstall: removes only what Marrow wrote. Every other hook, server, setting and file is kept.
3143
+ // Files are read in-process and never printed; the report names paths and counts only.
3144
+ // ---------------------------------------------------------------------------
3145
+
3146
+ // Hook groups of the Claude Code, Codex, Grok and Gemini shape:
3147
+ // hooks[event] = [{ matcher?, hooks: [handler, ...] }].
3148
+ function withoutMarrowGroupedHandlers(settings, isMarrowHandler) {
3149
+ const hooks = settings?.hooks;
3150
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
3151
+ let removed = 0;
3152
+ let kept = 0;
3153
+ const next = {};
3154
+ for (const [eventName, entries] of Object.entries(hooks)) {
3155
+ if (!Array.isArray(entries)) {
3156
+ next[eventName] = entries;
3157
+ continue;
3158
+ }
3159
+ const retained = [];
3160
+ let touched = false;
3161
+ for (const entry of entries) {
3162
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !Array.isArray(entry.hooks)) {
3163
+ retained.push(entry);
3164
+ kept += 1;
3165
+ continue;
3166
+ }
3167
+ const handlers = entry.hooks.filter((handler) => !isMarrowHandler(handler));
3168
+ kept += handlers.length;
3169
+ if (handlers.length === entry.hooks.length) {
3170
+ retained.push(entry);
3171
+ continue;
3172
+ }
3173
+ touched = true;
3174
+ removed += entry.hooks.length - handlers.length;
3175
+ if (handlers.length > 0) retained.push({ ...entry, hooks: handlers });
3176
+ }
3177
+ // An event that held only Marrow's entries goes away with them.
3178
+ if (touched && retained.length === 0) continue;
3179
+ next[eventName] = retained;
3180
+ }
3181
+ const result = { ...settings, hooks: next };
3182
+ if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
3183
+ return { settings: result, removed, kept };
3184
+ }
3185
+
3186
+ // Flat hook lists of the Cursor and Windsurf shape: hooks[event] = [{ command, ... }].
3187
+ function withoutMarrowFlatEntries(settings, isMarrowEntry) {
3188
+ const hooks = settings?.hooks;
3189
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
3190
+ let removed = 0;
3191
+ let kept = 0;
3192
+ const next = {};
3193
+ for (const [eventName, entries] of Object.entries(hooks)) {
3194
+ if (!Array.isArray(entries)) {
3195
+ next[eventName] = entries;
3196
+ continue;
3197
+ }
3198
+ const retained = entries.filter((entry) => !(entry && typeof entry === 'object' && !Array.isArray(entry) && isMarrowEntry(entry)));
3199
+ kept += retained.length;
3200
+ removed += entries.length - retained.length;
3201
+ if (retained.length === 0 && entries.length > 0) continue;
3202
+ next[eventName] = retained;
3203
+ }
3204
+ const result = { ...settings, hooks: next };
3205
+ if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
3206
+ return { settings: result, removed, kept };
3207
+ }
3208
+
3209
+ const isMarrowCommandHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
3210
+ && handler.type === 'command' && marrowHookSubcommand(handler.command));
3211
+ const isMarrowGeminiHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
3212
+ && (String(handler.name || '').startsWith('marrow-') || geminiMarrowHookEntrypoint(handler.command)));
3213
+ const isMarrowGrokHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
3214
+ && handler.type === 'command' && grokMarrowHookSubcommand(handler.command));
3215
+
3216
+ // The MCP server entry only when Marrow wrote it: npx running the published package.
3217
+ function withoutMarrowMcpServer(config) {
3218
+ const servers = config?.mcpServers;
3219
+ const entry = servers && typeof servers === 'object' && !Array.isArray(servers) ? servers.marrow : null;
3220
+ if (!entry) return { config, removed: 0, kept: 0, custom: false };
3221
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
3222
+ const ours = entry.command === 'npx' && args.includes('marrow-mcp')
3223
+ && args.some((arg) => /^--package=@getmarrow\/mcp(?:@[^\s]+)?$/.test(arg));
3224
+ if (!ours) return { config, removed: 0, kept: 1, custom: true };
3225
+ const nextServers = { ...servers };
3226
+ delete nextServers.marrow;
3227
+ const result = { ...config, mcpServers: nextServers };
3228
+ if (Object.keys(nextServers).length === 0) delete result.mcpServers;
3229
+ return { config: result, removed: 1, kept: Object.keys(nextServers).length, custom: false };
3230
+ }
3231
+
3232
+ function withoutMarrowInstructionBlock(content) {
3233
+ const block = marrowManagedBlockInText(content);
3234
+ if (!block) return { content, removed: 0 };
3235
+ const start = content.indexOf(block);
3236
+ const before = content.slice(0, start);
3237
+ const after = content.slice(start + block.length).replace(/^\n/, '');
3238
+ const head = before.trim() ? before.replace(/\n*$/, after.trim() ? '\n\n' : '\n') : '';
3239
+ return { content: `${head}${after}`, removed: 1 };
3240
+ }
3241
+
3242
+ // Marrow-written files compare equal once the pinned MCP version is normalized.
3243
+ function sameMarrowFile(actual, candidates) {
3244
+ const normalize = (value) => String(value).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>');
3245
+ const normalized = normalize(actual);
3246
+ return candidates.some((candidate) => normalize(candidate) === normalized);
3247
+ }
3248
+
3249
+ function buildUninstallPlan(detection) {
3250
+ const steps = [];
3251
+ const json = (filePath, label, transform, extra = {}) => steps.push({ kind: 'json', path: filePath, label, transform, ...extra });
3252
+ const mcpServer = (value) => {
3253
+ const result = withoutMarrowMcpServer(value);
3254
+ return { settings: result.config, removed: result.removed, kept: result.kept, custom: result.custom };
3255
+ };
3256
+ json(detection.paths.claudeSettings, 'Claude Code hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
3257
+ json(detection.paths.codexHooks, 'Codex hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
3258
+ json(detection.paths.cursorHooks, 'Cursor hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(cursorMarrowHookSubcommand(entry.command))));
3259
+ json(detection.paths.windsurfHooks, 'Windsurf hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(windsurfMarrowHookEntrypoint(entry.command))));
3260
+ json(detection.paths.geminiSettings, 'Gemini CLI hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGeminiHandler));
3261
+ json(detection.paths.grokHooks, 'Grok hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGrokHandler), {
3262
+ root: detection.home,
3263
+ grok: true,
3264
+ // Marrow's own global file: deleted when nothing but Marrow's hooks was in it.
3265
+ removeWhenEmpty: true,
3266
+ });
3267
+ json(detection.paths.mcpJson, 'Project MCP server config', mcpServer);
3268
+ json(detection.paths.cursorMcp, 'Cursor MCP server config', mcpServer);
3269
+ steps.push({ kind: 'md-block', path: detection.paths.agentsMd, label: 'Agent instructions' });
3270
+ const instructionVariants = [false, true].map((hostApprovals) => passiveInstructions({ hostApprovals }).replace(/<!--[^>]+-->/g, '').trim() + '\n');
3271
+ steps.push({ kind: 'owned-file', path: detection.paths.cursorRules, label: 'Cursor Marrow rule', candidates: instructionVariants });
3272
+ for (const hook of clineHookContract(detection)) {
3273
+ steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content] });
3274
+ }
3275
+ return steps;
3276
+ }
3277
+
3278
+ function uninstallFileState(filePath) {
3279
+ try {
3280
+ const stat = fs.lstatSync(filePath);
3281
+ if (stat.isSymbolicLink() || !stat.isFile()) return 'unsafe';
3282
+ if (stat.size > 4 * 1024 * 1024) return 'too_large';
3283
+ return 'file';
3284
+ } catch {
3285
+ return 'missing';
3286
+ }
3287
+ }
3288
+
3289
+ async function uninstall(options = {}) {
3290
+ const home = options.home || process.env.HOME || process.env.USERPROFILE || os.homedir();
3291
+ const detection = detectEnvironment(options.cwd || process.cwd(), { ...process.env, HOME: home, USERPROFILE: home });
3292
+ const apply = Boolean(options.yes && !options.dryRun);
3293
+ const root = path.resolve(detection.root);
3294
+ const changes = [];
3295
+ let controller = null;
3296
+ if (apply && options.controller !== false) {
3297
+ // A running controller restores missing managed hooks every five minutes; stop it first.
3298
+ try {
3299
+ const stopped = await stopProjectControllers({ root, client: detectedClient(detection), mode: 'auto' });
3300
+ controller = { stopped: stopped.stopped || 0, changed: Boolean(stopped.changed) };
3301
+ } catch {
3302
+ controller = { stopped: 0, changed: false, error: 'controller_stop_failed' };
3303
+ }
3304
+ }
3305
+ for (const step of buildUninstallPlan(detection)) {
3306
+ const state = uninstallFileState(step.path);
3307
+ if (state === 'missing') continue;
3308
+ const change = { path: step.path, label: step.label, removed_entries: 0, kept_entries: 0, action: 'unchanged', applied: false };
3309
+ if (state !== 'file') {
3310
+ changes.push({ ...change, action: 'skipped', reason: state === 'unsafe' ? 'not a regular file' : 'larger than 4 MB' });
3311
+ continue;
3312
+ }
3313
+ try {
3314
+ const writeRoot = step.root ? path.resolve(step.root) : root;
3315
+ if (step.grok) {
3316
+ if (!managedGrokHooksFile(step.path)) {
3317
+ changes.push({ ...change, action: 'skipped', reason: 'not managed by Marrow' });
3318
+ continue;
3319
+ }
3320
+ assertSafeGrokHookTarget(writeRoot, step.path);
3321
+ }
3322
+ assertContainedManagedTarget(writeRoot, step.path);
3323
+ const before = fs.readFileSync(step.path, 'utf8');
3324
+ if (step.kind === 'owned-file') {
3325
+ if (!sameMarrowFile(before, step.candidates)) {
3326
+ if (MARROW_MANAGED_TEXT_RE.test(before)) changes.push({ ...change, action: 'kept', reason: 'edited after Marrow wrote it' });
3327
+ continue;
3328
+ }
3329
+ change.removed_entries = 1;
3330
+ change.action = 'delete_file';
3331
+ if (apply) fs.unlinkSync(step.path);
3332
+ } else if (step.kind === 'md-block') {
3333
+ const result = withoutMarrowInstructionBlock(before);
3334
+ if (!result.removed) continue;
3335
+ change.removed_entries = 1;
3336
+ change.action = result.content.trim() ? 'update' : 'delete_file';
3337
+ if (change.action === 'delete_file') change.note = 'it held only the Marrow block';
3338
+ else change.note = 'block_only';
3339
+ if (apply) {
3340
+ if (change.action === 'delete_file') fs.unlinkSync(step.path);
3341
+ else atomicWriteManagedFile(writeRoot, step.path, result.content);
3342
+ }
3343
+ } else {
3344
+ let parsed;
3345
+ try {
3346
+ // Marrow's hook commands in their canonical form, whether they start through npx or
3347
+ // the local runtime, so both are recognized and removed.
3348
+ parsed = before.trim() ? mapHookCommands(JSON.parse(before), delocalizeHookCommand) : {};
3349
+ } catch {
3350
+ changes.push({ ...change, action: 'skipped', reason: 'invalid JSON' });
3351
+ continue;
3352
+ }
3353
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
3354
+ changes.push({ ...change, action: 'skipped', reason: 'root is not a JSON object' });
3355
+ continue;
3356
+ }
3357
+ const result = step.transform(parsed);
3358
+ change.kept_entries = result.kept;
3359
+ if (result.custom) {
3360
+ changes.push({ ...change, action: 'kept', reason: 'custom marrow server entry' });
3361
+ continue;
3362
+ }
3363
+ if (!result.removed) continue;
3364
+ change.removed_entries = result.removed;
3365
+ const emptied = step.removeWhenEmpty && result.kept === 0
3366
+ && Object.keys(result.settings).every((key) => key === 'hooks');
3367
+ change.action = emptied ? 'delete_file' : 'update';
3368
+ if (apply) {
3369
+ if (emptied) fs.unlinkSync(step.path);
3370
+ else atomicWriteManagedFile(writeRoot, step.path, JSON.stringify(result.settings, null, 2) + '\n');
3371
+ }
3372
+ }
3373
+ change.applied = apply;
3374
+ changes.push(change);
3375
+ } catch (error) {
3376
+ changes.push({ ...change, action: 'skipped', reason: error instanceof Error ? error.message.slice(0, 200) : 'unsafe target' });
3377
+ }
3378
+ }
3379
+ const kept = [];
3380
+ if (exists(detection.paths.passiveRuntime)) kept.push({ path: detection.paths.passiveRuntime, reason: 'your start command may import it; remove that import first, then delete the file' });
3381
+ if (exists(detection.paths.passiveEnv)) kept.push({ path: detection.paths.passiveEnv, reason: 'it may hold your own edits; delete it when no longer needed' });
3382
+ let hermesEntry = false;
3383
+ try {
3384
+ hermesEntry = Boolean(detection.hermesConfig)
3385
+ && /(?:^|\n)mcp_servers:[\s\S]*\n[ \t]+marrow:/.test(fs.readFileSync(detection.paths.hermesConfig, 'utf8'));
3386
+ } catch {
3387
+ hermesEntry = false;
3388
+ }
3389
+ if (hermesEntry) kept.push({ path: detection.paths.hermesConfig, reason: 'remove mcp_servers.marrow by hand; uninstall never rewrites the Hermes config, which holds other servers\' settings' });
3390
+ // The local MCP runtime (~/.marrow/runtime) goes too; hooks elsewhere then start through npx.
3391
+ const runtime = removeMcpRuntime(options.home || detection.home, { dryRun: !apply });
3392
+ const removedEntries = changes.reduce((sum, change) => sum + change.removed_entries, 0);
3393
+ return {
3394
+ uninstall: {
3395
+ applied: apply,
3396
+ dry_run: !apply,
3397
+ root,
3398
+ controller,
3399
+ changes,
3400
+ kept,
3401
+ runtime: runtime.removed || runtime.would_remove ? runtime : null,
3402
+ removed_entries: removedEntries,
3403
+ next_step: apply
3404
+ ? 'Restart the agent hosts so they stop loading the removed hooks and MCP server.'
3405
+ : removedEntries > 0 || runtime.would_remove
3406
+ ? 'Nothing was changed. Run npx @getmarrow/install uninstall --yes to remove these Marrow entries.'
3407
+ : 'Nothing to remove.',
3408
+ },
3409
+ };
3410
+ }
3411
+
3412
+ function printUninstallReport(report, sink = (text) => process.stdout.write(text)) {
3413
+ const result = report.uninstall;
3414
+ const where = (filePath) => (filePath.startsWith(`${result.root}${path.sep}`) ? path.relative(result.root, filePath) : filePath);
3415
+ const entries = (count) => `${count} Marrow entr${count === 1 ? 'y' : 'ies'}`;
3416
+ sink(`Marrow uninstall${result.applied ? '' : ' (preview)'}: ${entries(result.removed_entries)} ${result.applied ? 'removed' : 'to remove'}.\n`);
3417
+ for (const change of result.changes) {
3418
+ if (change.action === 'update' && change.note) sink(`- ${where(change.path)}: ${result.applied ? 'removed' : 'would remove'} the Marrow block; your text is kept.\n`);
3419
+ else if (change.action === 'update') sink(`- ${where(change.path)}: ${result.applied ? 'removed' : 'would remove'} ${entries(change.removed_entries)}; kept ${change.kept_entries} of yours.\n`);
3420
+ else if (change.action === 'delete_file') sink(`- ${where(change.path)}: ${result.applied ? 'deleted' : 'would delete'} (${change.note || 'Marrow\'s own file'}).\n`);
3421
+ else if (change.action === 'kept' || change.action === 'skipped') sink(`- ${where(change.path)}: left unchanged (${change.reason}).\n`);
3422
+ }
3423
+ for (const entry of result.kept) sink(`- ${where(entry.path)}: left in place; ${entry.reason}.\n`);
3424
+ if (result.runtime) sink(`- ${result.runtime.path}: ${result.applied ? 'removed' : 'would remove'} Marrow's local MCP runtime (other projects' hooks start through npx until the next install there).\n`);
3425
+ if (result.controller?.stopped) sink(`Stopped ${result.controller.stopped} Marrow controller${result.controller.stopped === 1 ? '' : 's'} for this project.\n`);
3426
+ sink(`${result.next_step}\n`);
3427
+ }
3428
+
2806
3429
  const SELF_TEST_READ_TIMEOUT_MS = 15_000;
2807
3430
 
2808
3431
  async function requestJson(url, options) {
@@ -2976,7 +3599,15 @@ function runtimeGateVerified(runtime) {
2976
3599
  return ['allow', 'warn', 'review_required', 'block'].includes(decision);
2977
3600
  }
2978
3601
 
2979
- const ACTIVATION_UNBOUND_KEY_FIX = 'This API key is not bound to one agent, so Marrow cannot confirm activation for a specific agent. Bind the key to one agent in the Marrow dashboard, or register the agent with POST /v1/agents and set MARROW_AGENT_ID to its id, then run npx -y @getmarrow/install@latest doctor --self-test.';
3602
+ // When Marrow refuses this machine's agent identity, the one command that fixes it. A key that
3603
+ // is simply not bound to one agent needs nothing: Marrow resolves the agent on each call.
3604
+ const IDENTITY_REFUSED_RE = /AGENT_NOT_REGISTERED|wrong_agent_id|MARROW_AGENT_SCOPE_MISMATCH|Agent-bound key cannot access another agent/i;
3605
+ function identityRefusalFix(message, configuredAgentId = '') {
3606
+ if (!IDENTITY_REFUSED_RE.test(String(message || ''))) return null;
3607
+ return configuredAgentId
3608
+ ? 'unset MARROW_AGENT_ID MARROW_FLEET_AGENT_ID && npx -y @getmarrow/install@latest'
3609
+ : 'MARROW_API_KEY=your_agent_key npx -y @getmarrow/install@latest (replace your_agent_key with a key for one agent from your Marrow account)';
3610
+ }
2980
3611
 
2981
3612
  // The agent Marrow resolved for this key: its single bound agent or the Free plan seat. An
2982
3613
  // unbound key has no single agent, and a locally derived id is never substituted for it.
@@ -3224,7 +3855,11 @@ async function runSelfTest(options) {
3224
3855
  method: 'POST',
3225
3856
  headers,
3226
3857
  body: JSON.stringify({
3227
- event_id: `activation-${activationConfigFingerprint.slice(0, 32)}`,
3858
+ // One registration per agent: two agents with the same hook configuration (a second
3859
+ // agent installed on the same project) are two events, so the second is not refused
3860
+ // as a conflicting reuse of the first agent's event id. Reruns by one agent repeat
3861
+ // the same id.
3862
+ event_id: `activation-${crypto.createHash('sha256').update(`${activationConfigFingerprint}\0${activationAgentId}`).digest('hex').slice(0, 32)}`,
3228
3863
  event_type: 'activation_profile_registered',
3229
3864
  harness: options.activation.harness,
3230
3865
  agent_id: activationAgentId,
@@ -3291,7 +3926,8 @@ async function runSelfTest(options) {
3291
3926
  bound_agent_ids: serverIdentity.bound_agent_ids,
3292
3927
  activation_verified: activationVerified,
3293
3928
  activation_identity_unresolved: activationIdentityUnresolved,
3294
- activation_exact_fix: activationIdentityUnresolved ? ACTIVATION_UNBOUND_KEY_FIX : null,
3929
+ // An unbound key is fine: Marrow resolves the agent on each call, so there is nothing to fix.
3930
+ activation_exact_fix: null,
3295
3931
  activation_scope: options.activation ? 'server_self_test_only' : null,
3296
3932
  coverage_verified: false,
3297
3933
  passive_live: false,
@@ -3486,6 +4122,16 @@ function printReport(report, sink) {
3486
4122
  }
3487
4123
  }
3488
4124
 
4125
+ if (report.mcp_runtime) {
4126
+ const runtime = report.mcp_runtime;
4127
+ out('\nMCP local runtime:\n');
4128
+ out(`- hooks start: ${runtime.hooks_start === 'local_runtime' ? `the verified local copy of @getmarrow/mcp@${runtime.version} (${runtime.path})` : 'through npx'}\n`);
4129
+ out(`- state: ${runtime.state}${runtime.reason ? ` (${runtime.reason})` : ''}\n`);
4130
+ if (runtime.removed_versions?.length) out(`- removed older versions: ${runtime.removed_versions.join(', ')}\n`);
4131
+ }
4132
+ if (report.owner_key_storage) {
4133
+ out(`\nAPI key for hosts opened outside this terminal: ${report.owner_key_storage.state}${report.owner_key_storage.path ? ` (${report.owner_key_storage.path})` : ''}\n`);
4134
+ }
3489
4135
  out('\nLocal session loop guard:\n');
3490
4136
  out(`- configured: ${report.loop_guard_configured ? 'yes' : 'no'}\n`);
3491
4137
  out(`- isolated self-test passed: ${report.loop_guard_self_tested ? 'yes' : 'no'}\n`);
@@ -3744,6 +4390,62 @@ function hermesWiringReport(detection, changes, options, planMode) {
3744
4390
  };
3745
4391
  }
3746
4392
 
4393
+ // The local MCP runtime for the target version: installed (or kept) on a write run of the
4394
+ // command, otherwise only an existing verified copy is used. `mcpLocalRuntime: false` (or
4395
+ // MARROW_LOCAL_RUNTIME=0, --no-local-runtime) leaves every hook of this project on npx.
4396
+ function resolveMcpRuntime(detection, options, mcpTarget) {
4397
+ const home = options.home || detection.home;
4398
+ if (options.mcpLocalRuntime === false) return { state: 'disabled' };
4399
+ const version = mcpTarget.version;
4400
+ const integrity = mcpTarget.integrity || (version === MCP_ADAPTER_VERSION ? MCP_ADAPTER_INTEGRITY : null);
4401
+ if (!integrity) return { state: 'skipped', reason: 'no_verified_integrity', version };
4402
+ const install = options.mcpLocalRuntime === true && options.yes && !options.dryRun && !options.doctor && options.maintenance !== true;
4403
+ if (install) {
4404
+ return ensureMcpRuntime({
4405
+ home,
4406
+ version,
4407
+ integrity,
4408
+ sdk: { version: SDK_ADAPTER_VERSION, integrity: SDK_ADAPTER_INTEGRITY },
4409
+ ...(typeof options.mcpRuntimeInstall === 'function' ? { npmInstall: options.mcpRuntimeInstall } : {}),
4410
+ ...(options.mcpRuntimeNode ? { nodePath: options.mcpRuntimeNode } : {}),
4411
+ // One line on stderr before npm runs, so the wait is never silent (stdout stays clean
4412
+ // for --json).
4413
+ onProgress: typeof options.mcpRuntimeProgress === 'function' ? options.mcpRuntimeProgress : (text) => process.stderr.write(text),
4414
+ });
4415
+ }
4416
+ return verifyMcpRuntime(home, version, integrity) || { state: 'absent', version };
4417
+ }
4418
+
4419
+ // Whether this project's managed hooks start the local runtime now, as its install chose.
4420
+ function projectHooksUseLocalRuntime(detection) {
4421
+ const paths = detection.paths || {};
4422
+ return [paths.claudeSettings, paths.codexHooks, paths.cursorHooks, paths.windsurfHooks, paths.geminiSettings]
4423
+ .some((filePath) => filePath && safeRead(filePath).includes('/.marrow/runtime/mcp/'));
4424
+ }
4425
+
4426
+ // The controller's maintenance rewrites managed hooks for the pinned MCP and keeps each project
4427
+ // in the form its install chose: on the verified local runtime for that version when the
4428
+ // project's hooks start it (verify only: maintenance never installs), on npx otherwise. A
4429
+ // project installed with --no-local-runtime stays on npx even when another project's install
4430
+ // put a runtime on this machine.
4431
+ function maintenanceMcpRuntime(detection, options = {}) {
4432
+ if (options.mcpLocalRuntime === false || !projectHooksUseLocalRuntime(detection)) return null;
4433
+ const runtime = resolveMcpRuntime(detection, { ...options, maintenance: true, yes: false }, executableMcpTarget(options));
4434
+ return runtime.version && ['present', 'installed', 'verified'].includes(runtime.state) ? runtime : null;
4435
+ }
4436
+
4437
+ function mcpRuntimeReport(runtime) {
4438
+ if (!runtime) return null;
4439
+ return {
4440
+ state: runtime.state,
4441
+ version: runtime.version || null,
4442
+ path: runtime.directory || null,
4443
+ ...(runtime.reason ? { reason: runtime.reason } : {}),
4444
+ ...(runtime.removed_versions?.length ? { removed_versions: runtime.removed_versions } : {}),
4445
+ hooks_start: runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state) ? 'local_runtime' : 'npx',
4446
+ };
4447
+ }
4448
+
3747
4449
  function localControllerAgentId(root, client, configuredAgentId = '') {
3748
4450
  return String(configuredAgentId || '').trim() || stableAgentId(root, client);
3749
4451
  }
@@ -3790,6 +4492,11 @@ async function install(options) {
3790
4492
  });
3791
4493
  options.mcpTarget = mcpTarget;
3792
4494
  options.mcpTargetVersion = mcpTarget.version;
4495
+ // A maintenance pass keeps the project's hooks in the form its install chose.
4496
+ const mcpRuntime = options.maintenance === true
4497
+ ? maintenanceMcpRuntime(detection, options) || { state: options.mcpLocalRuntime === false ? 'disabled' : 'absent', version: mcpTarget.version }
4498
+ : resolveMcpRuntime(detection, options, mcpTarget);
4499
+ options.mcpRuntime = mcpRuntime.version && ['present', 'installed', 'verified'].includes(mcpRuntime.state) ? mcpRuntime : null;
3793
4500
  const plan = buildPlan(detection, options);
3794
4501
  const writeMode = options.doctor ? 'doctor' : options.dryRun ? 'dry-run' : options.repair ? 'repair' : options.yes ? 'write' : 'dry-run';
3795
4502
  const changes = applyPlan(plan, options);
@@ -3839,7 +4546,7 @@ async function install(options) {
3839
4546
  intervention_verified: false,
3840
4547
  closure_verified: false,
3841
4548
  } : null;
3842
- const sdkDependency = inspectSdkDependency(detection);
4549
+ const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
3843
4550
  const envHints = options.apiKey ? [] : findLikelyEnvFiles(detection);
3844
4551
  const mcpProcesses = inspectMcpProcesses({
3845
4552
  commands: options.processCommands,
@@ -4049,6 +4756,7 @@ async function install(options) {
4049
4756
  harnessReload,
4050
4757
  firstCapture: firstCapturePath(detection),
4051
4758
  hermes,
4759
+ mcp_runtime: mcpRuntimeReport(mcpRuntime),
4052
4760
  changes,
4053
4761
  doctor: {
4054
4762
  active: Boolean(!selfTest.skipped && selfTest.active),
@@ -4099,11 +4807,8 @@ async function install(options) {
4099
4807
  };
4100
4808
  }
4101
4809
 
4102
- function activationFailureFix(message) {
4103
- if (/Agent-bound key cannot access another agent|MARROW_AGENT_SCOPE_MISMATCH/i.test(message)) {
4104
- return `unset MARROW_AGENT_ID and MARROW_FLEET_AGENT_ID so Marrow uses the key's own agent, then rerun ${INSTALLER_UPDATE_COMMAND}`;
4105
- }
4106
- return INSTALLER_DOCTOR_COMMAND;
4810
+ function activationFailureFix(message, configuredAgentId = '') {
4811
+ return identityRefusalFix(message, configuredAgentId) || INSTALLER_DOCTOR_COMMAND;
4107
4812
  }
4108
4813
 
4109
4814
  function installCommandFor(options) {
@@ -4112,23 +4817,31 @@ function installCommandFor(options) {
4112
4817
 
4113
4818
  // One summary for the one-command paths (first install and update). The full report goes to a
4114
4819
  // private log file. Nothing here prints the API key.
4820
+ // One verdict for the self-test, used by the summary and by key storage: it passed only when it
4821
+ // ran, raised no error, found the account active and Marrow committed the closure.
4822
+ function selfTestVerdict(selfTest = {}) {
4823
+ const untrusted = !selfTest.skipped && !selfTest.error && Boolean(selfTest.active) && selfTest.decision_committed !== true;
4824
+ const failed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
4825
+ return { untrusted, failed, passed: !selfTest.skipped && !failed };
4826
+ }
4827
+
4115
4828
  function installSummaryLines(report, options) {
4116
4829
  const selfTest = report.selfTest || {};
4117
4830
  const activation = report.activation || {};
4118
4831
  const lines = [];
4119
- const untrusted = !selfTest.skipped && !selfTest.error && selfTest.active && selfTest.decision_committed !== true;
4120
- const selfTestFailed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
4832
+ const { untrusted, failed: selfTestFailed } = selfTestVerdict(selfTest);
4121
4833
  if (selfTest.skipped) {
4122
4834
  lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: configuration updated; self-test skipped (${selfTest.reason}).${selfTest.exact_fix ? ` Fix: ${selfTest.exact_fix}` : ''}`);
4123
4835
  } else if (untrusted) {
4124
4836
  lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test decision ${selfTest.decision_id} was recorded without trusted closure (committed was not true). Fix: ${INSTALLER_DOCTOR_COMMAND}`);
4125
4837
  } else if (selfTestFailed) {
4126
- const fix = report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
4838
+ const fix = identityRefusalFix(selfTest.error, activation.agent_id_source === 'configured' ? activation.agent_id : '')
4839
+ || report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
4127
4840
  lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test failed: ${selfTest.error || 'Marrow reported this account as inactive'}. Fix: ${fix}`);
4128
4841
  } else {
4129
4842
  const agent = activation.agent_id
4130
4843
  ? `; agent ${activation.agent_id} (${activation.agent_id_source === 'configured' ? 'configured' : 'resolved by Marrow'})`
4131
- : '; this key is not bound to one agent';
4844
+ : '';
4132
4845
  lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: healthy. Self-test decision ${selfTest.decision_id} committed${agent}.`);
4133
4846
  }
4134
4847
  if (activation.exact_fix) lines.push(`Activation: ${activation.exact_fix}`);
@@ -4155,6 +4868,14 @@ function installSummaryLines(report, options) {
4155
4868
  } else if (hermes.state === 'refused' || hermes.state === 'config_not_found' || hermes.state === 'preserved_unverified_ahead') {
4156
4869
  lines.push(`Hermes: ${hermes.config_path || 'config.yaml'} was not changed. The exact block to add is in the full report.`);
4157
4870
  }
4871
+ const ownerKey = report.owner_key_storage || {};
4872
+ if (ownerKey.state === 'written') {
4873
+ lines.push(`Saved your API key in ${ownerKey.path} (only you can read it), so Claude Code finds it when opened from the desktop app, an IDE or a new terminal.`);
4874
+ } else if (ownerKey.state === 'different_key_present') {
4875
+ lines.push(`Note: ${ownerKey.path} holds a different Marrow key, and Claude Code uses that one when opened outside this terminal. To use this key there, remove that key from the file and run npx @getmarrow/install again.`);
4876
+ } else if (ownerKey.state && !['present', 'no_key'].includes(ownerKey.state)) {
4877
+ lines.push(`Note: your API key was not saved for Claude Code opened outside this terminal (${ownerKey.path || '~/.marrow/env'} is not a private owner-only file).`);
4878
+ }
4158
4879
  if (hermes.owner_key_storage?.state === 'written') {
4159
4880
  lines.push('Stored your API key in ~/.marrow/env (mode 600) so the Hermes MCP server can read it; Hermes passes no other environment to MCP servers.');
4160
4881
  } else if (hermes.owner_key_storage?.state === 'different_key_present') {
@@ -4190,6 +4911,12 @@ async function runCli(argv) {
4190
4911
  process.stdout.write(usage());
4191
4912
  return;
4192
4913
  }
4914
+ if (options.uninstall) {
4915
+ const report = await uninstall(options);
4916
+ if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
4917
+ else printUninstallReport(report);
4918
+ return;
4919
+ }
4193
4920
  const stored = readOwnerApiKey(options.home);
4194
4921
  const keyInfo = { source: options.apiKey ? 'environment' : null, path: null, owner_files_disagree: stored.conflict };
4195
4922
  if (!options.apiKey && stored.apiKey) {
@@ -4207,7 +4934,7 @@ async function runCli(argv) {
4207
4934
  USERPROFILE: options.home || options.cwd,
4208
4935
  }));
4209
4936
  }
4210
- process.stdout.write(`Marrow needs your API key. Run: MARROW_API_KEY=<your key> ${installCommandFor(options)} (create a key at https://getmarrow.ai)\n`);
4937
+ process.stdout.write(`Marrow needs your API key. Run: MARROW_API_KEY=your_key ${installCommandFor(options)} (replace your_key with your key; create one at https://getmarrow.ai)\n`);
4211
4938
  process.exitCode = 2;
4212
4939
  return;
4213
4940
  }
@@ -4217,10 +4944,23 @@ async function runCli(argv) {
4217
4944
  } catch (error) {
4218
4945
  const message = error instanceof Error ? error.message : String(error);
4219
4946
  if (!oneCommand || options.json || !/^Marrow activation failed/.test(message)) throw error;
4220
- process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message)}\n`);
4947
+ process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message, String(options.agentId || '').trim())}\n`);
4221
4948
  process.exitCode = 1;
4222
4949
  return;
4223
4950
  }
4951
+ // Claude Code opened from the desktop app, an IDE or a new terminal has no MARROW_API_KEY.
4952
+ // The MCP server and hooks read ~/.marrow/env, so a first install stores a key that came
4953
+ // from the environment there (owner-only), once the self-test passed by the same verdict the
4954
+ // summary prints (never on an untrusted closure), with --json and --verbose too.
4955
+ const keyWorked = report.selfTest ? selfTestVerdict(report.selfTest).passed : false;
4956
+ if (options.activate && !options.update && keyInfo.source === 'environment' && !options.keyFromArg && keyWorked) {
4957
+ try {
4958
+ const stored = ensureOwnerApiKey(options.home, options.apiKey);
4959
+ report.owner_key_storage = { state: stored.state, path: stored.path };
4960
+ } catch {
4961
+ report.owner_key_storage = { state: 'not_written', path: null };
4962
+ }
4963
+ }
4224
4964
  if (options.json) {
4225
4965
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
4226
4966
  return;
@@ -4246,6 +4986,8 @@ async function runCli(argv) {
4246
4986
  }
4247
4987
 
4248
4988
  module.exports = {
4989
+ argumentLabel,
4990
+ maintenanceMcpRuntime,
4249
4991
  parseArgs,
4250
4992
  detectEnvironment,
4251
4993
  buildPlan,
@@ -4267,6 +5009,13 @@ module.exports = {
4267
5009
  activationProfile,
4268
5010
  claudeNativeHookFingerprint,
4269
5011
  codexNativeHookFingerprint,
5012
+ hostApprovalHooksSupported,
5013
+ uninstall,
5014
+ printUninstallReport,
5015
+ CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER,
5016
+ GEMINI_PRE_ACTION_GUARD_COMMAND,
5017
+ GEMINI_CONTEXT_HOOK_COMMAND,
5018
+ MCP_PERMISSION_REQUEST_HOOK_COMMAND,
4270
5019
  cursorNativeHookFingerprint,
4271
5020
  clineNativeHookFingerprint,
4272
5021
  windsurfNativeHookFingerprint,