@getmarrow/install 0.1.67 → 0.1.69

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,21 @@ 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
+
842
+ // Operation words parseArgs accepts. A --key value equal to one of these is a missing key.
843
+ const SUBCOMMAND_WORDS = new Set(['activate', 'update', 'repair', 'uninstall', 'doctor', 'check']);
844
+
726
845
  function parseArgs(argv, env = process.env) {
727
846
  const options = {
728
847
  cwd: process.cwd(),
@@ -732,7 +851,8 @@ function parseArgs(argv, env = process.env) {
732
851
  doctor: false,
733
852
  repair: false,
734
853
  mode: 'auto',
735
- apiKey: env.MARROW_API_KEY || '',
854
+ // MARROW_KEY is accepted when MARROW_API_KEY is absent or blank, as the runner and owner files do.
855
+ apiKey: String(env.MARROW_API_KEY || '').trim() || String(env.MARROW_KEY || '').trim(),
736
856
  baseUrl: env.MARROW_BASE_URL || DEFAULT_BASE_URL,
737
857
  agentId: env.MARROW_FLEET_AGENT_ID || env.MARROW_AGENT_ID || '',
738
858
  toolProfile: resolveToolProfile(env.MARROW_TOOL_PROFILE),
@@ -742,10 +862,24 @@ function parseArgs(argv, env = process.env) {
742
862
  json: false,
743
863
  activate: false,
744
864
  controller: true,
865
+ // Hooks start a verified local copy of the pinned MCP (installed on write runs) instead of
866
+ // npx. MARROW_LOCAL_RUNTIME=0 or --no-local-runtime keeps them on npx.
867
+ mcpLocalRuntime: env.MARROW_LOCAL_RUNTIME !== '0',
745
868
  };
746
869
  let explicitOperation = false;
747
870
 
748
- for (let i = 0; i < argv.length; i += 1) {
871
+ // Value of the option at argv[i]. Only consumes the next argument when it exists and is not an
872
+ // option itself, so a missing value never swallows a following flag.
873
+ let i = 0;
874
+ const optionValue = (refuseWords = false) => {
875
+ const next = argv[i + 1];
876
+ if (next === undefined || String(next).startsWith('-')) return '';
877
+ // `--key $UNSET activate` must not take the subcommand word as the key.
878
+ if (refuseWords && SUBCOMMAND_WORDS.has(String(next))) return '';
879
+ i += 1;
880
+ return next;
881
+ };
882
+ for (; i < argv.length; i += 1) {
749
883
  const arg = argv[i];
750
884
  if (arg === 'activate' || arg === '--activate') {
751
885
  explicitOperation = true;
@@ -767,6 +901,10 @@ function parseArgs(argv, env = process.env) {
767
901
  explicitOperation = true;
768
902
  options.dryRun = true;
769
903
  }
904
+ else if (arg === 'uninstall' || arg === '--uninstall') {
905
+ explicitOperation = true;
906
+ options.uninstall = true;
907
+ }
770
908
  else if (arg === '--doctor' || arg === 'doctor' || arg === 'check') {
771
909
  explicitOperation = true;
772
910
  options.doctor = true;
@@ -779,18 +917,20 @@ function parseArgs(argv, env = process.env) {
779
917
  options.selfTestExplicitlyDisabled = true;
780
918
  }
781
919
  else if (arg === '--no-controller') options.controller = false;
920
+ else if (arg === '--no-local-runtime') options.mcpLocalRuntime = false;
782
921
  else if (arg === '--self-test') options.selfTest = true;
783
- else if (arg === '--cwd') options.cwd = path.resolve(argv[++i] || options.cwd);
922
+ else if (arg === '--cwd') options.cwd = path.resolve(optionValue() || options.cwd);
784
923
  else if (arg === '--mode') {
785
924
  explicitOperation = true;
786
- options.mode = argv[++i] || options.mode;
925
+ options.mode = optionValue() || options.mode;
787
926
  }
788
927
  else if (arg === '--key') {
789
- options.apiKey = argv[++i] || options.apiKey;
928
+ // A missing value (end of argv, or another option next) is a missing key, never a key.
929
+ options.apiKey = optionValue(true) || options.apiKey;
790
930
  options.keyFromArg = true;
791
931
  }
792
- else if (arg === '--base-url') options.baseUrl = argv[++i] || options.baseUrl;
793
- else if (arg === '--agent-id') options.agentId = argv[++i] || options.agentId;
932
+ else if (arg === '--base-url') options.baseUrl = optionValue() || options.baseUrl;
933
+ else if (arg === '--agent-id') options.agentId = optionValue() || options.agentId;
794
934
  else if (arg === '--mcp') {
795
935
  explicitOperation = true;
796
936
  options.mode = 'mcp';
@@ -810,10 +950,13 @@ function parseArgs(argv, env = process.env) {
810
950
  else if (arg === '--help' || arg === '-h') {
811
951
  options.help = true;
812
952
  } else {
813
- throw new Error(`Unknown argument: ${arg}`);
953
+ throw new Error(`Unknown argument: ${argumentLabel(arg)}`);
814
954
  }
815
955
  }
816
956
 
957
+ if (options.uninstall && (options.activate || options.repair || options.doctor)) {
958
+ throw new Error('uninstall cannot be combined with activate, update, repair, or doctor');
959
+ }
817
960
  if (!['auto', 'mcp', 'sdk', 'both', 'md'].includes(options.mode)) {
818
961
  throw new Error('--mode must be one of auto, mcp, sdk, both, md');
819
962
  }
@@ -843,26 +986,34 @@ function usage() {
843
986
  npx @getmarrow/install --repair
844
987
  npx @getmarrow/install update
845
988
  npx @getmarrow/install doctor
989
+ npx @getmarrow/install doctor --self-test
846
990
  npx @getmarrow/install --mcp --yes
847
991
  npx @getmarrow/install --sdk --yes
992
+ npx @getmarrow/install uninstall [--yes]
848
993
 
849
994
  Options:
850
995
  (no command) Detect, install, self-test, and start the supported persistent controller
851
996
  activate Detect, install, self-test, and return a server-confirmed activation receipt
852
997
  --dry-run Print planned changes without writing
853
998
  --doctor Check install health without writing
999
+ doctor --self-test Same as doctor, which already runs the server self-test: run it after restarting the harness
854
1000
  --repair Write missing hooks/config, then run self-test and status check
855
1001
  update Same as --repair: refresh exact MCP/SDK/install package pins after owner approval
856
- --yes, -y Write detected config files
1002
+ --yes, -y Write detected config files (needs your API key; stops before writing without one)
857
1003
  --mode <mode> auto, mcp, sdk, both, or md
858
- --key <key> Marrow API key for self-test. Prefer MARROW_API_KEY because CLI args can appear in process listings.
859
1004
  --base-url <url> Marrow API base URL
860
1005
  --agent-id <id> Agent/fleet id for self-test headers
861
1006
  --no-controller Do not start the local background controller during install/repair
1007
+ --no-local-runtime Keep hooks on npx instead of the verified local MCP copy in ~/.marrow/runtime
862
1008
  --no-self-test Skip API smoke/self-test
863
1009
  --verbose Print the full report instead of the one-line summary and log file
1010
+ uninstall Preview removing only Marrow's own hooks, MCP server entries, instructions and
1011
+ the local MCP runtime; add --yes to remove them. Your own hooks and settings
1012
+ are kept.
864
1013
 
865
- The API key comes from MARROW_API_KEY, or from the owner-only ~/.marrow/env when unset.
1014
+ Get a key at https://getmarrow.ai/signup. Put it in the owner-only ~/.marrow/env as
1015
+ MARROW_API_KEY=your_key, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env, or export MARROW_API_KEY. Keep it off the command line: command-line
1016
+ arguments land in shell history and process listings.
866
1017
 
867
1018
  Environment:
868
1019
  MARROW_TOOL_PROFILE Leave unset for primary (17 tools), or explicitly set primary, core, or full.
@@ -878,13 +1029,14 @@ function stableAgentId(root, client = sourceClient()) {
878
1029
  function detectedClient(detection) {
879
1030
  if (sourceClient() !== 'custom') return sourceClient();
880
1031
  if (detection.openclaw) return 'openclaw';
881
- if (detection.claudeCode) return 'claude-code';
1032
+ if (detection.claudeCodeProject ?? detection.claudeCode) return 'claude-code';
882
1033
  if (detection.cursor) return 'cursor';
883
1034
  if (detection.cline) return 'cline';
884
1035
  if (detection.windsurf) return 'windsurf';
885
1036
  if (detection.gemini) return 'gemini';
886
1037
  if (detection.codexProject) return 'codex';
887
1038
  // Home-level harnesses rank below project markers; a shared AGENTS.md is a weaker signal.
1039
+ if (detection.claudeCode) return 'claude-code';
888
1040
  if (detection.hermes) return 'hermes';
889
1041
  if (detection.codex) return 'codex';
890
1042
  return 'custom';
@@ -1037,6 +1189,14 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
1037
1189
  };
1038
1190
  const homeHarnesses = detectHomeHarnesses(home, env);
1039
1191
  const codexProject = codexDirectoryIsOwnerConfigured(path.join(root, '.codex'));
1192
+ // Claude Code is detected from the project (CLAUDE.md, .claude/settings.json) or, for a repo
1193
+ // that has neither yet, from the user's installation: ~/.claude/, ~/.claude.json or `claude`
1194
+ // on PATH. Either way the project gets .claude/settings.json.
1195
+ const claudeCodeProject = exists(paths.claudeSettings) || exists(paths.claudeMd);
1196
+ const claudeCodeSource = claudeCodeProject ? 'project'
1197
+ : exists(path.join(home, '.claude')) || exists(path.join(home, '.claude.json')) ? 'home'
1198
+ : executableOnPath('claude', env) ? 'path'
1199
+ : null;
1040
1200
 
1041
1201
  return {
1042
1202
  root,
@@ -1044,7 +1204,9 @@ function detectEnvironment(cwd = process.cwd(), env = process.env) {
1044
1204
  paths,
1045
1205
  node: exists(paths.packageJson),
1046
1206
  python: exists(paths.pyproject) || exists(paths.requirements) || exists(paths.setupPy),
1047
- claudeCode: exists(paths.claudeSettings) || exists(paths.claudeMd),
1207
+ claudeCode: Boolean(claudeCodeSource),
1208
+ claudeCodeProject,
1209
+ claudeCodeSource,
1048
1210
  cursor: exists(path.join(root, '.cursor')),
1049
1211
  cline: exists(path.join(root, '.clinerules')),
1050
1212
  windsurf: exists(path.join(root, '.windsurf')),
@@ -1123,7 +1285,12 @@ function findLikelyEnvFiles(detection, env = process.env) {
1123
1285
  });
1124
1286
  }
1125
1287
 
1126
- function passiveInstructions() {
1288
+ function passiveInstructions(options = {}) {
1289
+ const hostApprovals = options.hostApprovals === true;
1290
+ const geminiHooks = hostApprovals ? 'BeforeTool, AfterTool, AfterAgent, and BeforeAgent' : 'BeforeTool, AfterTool, and AfterAgent';
1291
+ const approvals = hostApprovals
1292
+ ? '\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.'
1293
+ : '';
1127
1294
  return `${MARROW_BLOCK_START}
1128
1295
  ## Marrow Passive Agent Memory
1129
1296
 
@@ -1137,10 +1304,10 @@ Marrow should run passively after install:
1137
1304
  - Before risky work, use Marrow's decision brief or passive prompt hook.
1138
1305
  - 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
1306
  - 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.
1307
+ - 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
1308
  - 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
1309
  - 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.
1310
+ - 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
1311
  - Check health with \`marrow_agent_status\` or \`GET /v1/agent/status\`.
1145
1312
  - 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
1313
 
@@ -1208,6 +1375,9 @@ MARROW_PASSIVE_TOKEN_USAGE=true
1208
1375
  `;
1209
1376
  }
1210
1377
 
1378
+ // Hook files are read with Marrow's hook commands in their canonical (npx) form, whether they
1379
+ // start through npx or the local runtime, so every check and merge sees one spelling; writes
1380
+ // switch them to the local runtime again when it is verified.
1211
1381
  function parseJsonObject(filePath) {
1212
1382
  const raw = safeRead(filePath).trim();
1213
1383
  if (!raw) return {};
@@ -1215,7 +1385,7 @@ function parseJsonObject(filePath) {
1215
1385
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
1216
1386
  throw new Error(`Expected JSON object in ${filePath}`);
1217
1387
  }
1218
- return parsed;
1388
+ return mapHookCommands(parsed, delocalizeHookCommand);
1219
1389
  }
1220
1390
 
1221
1391
  function canonicalJsonValue(value) {
@@ -1293,7 +1463,7 @@ function exactHookDescriptors(settings, eventName, command, matcher) {
1293
1463
  function marrowHookSubcommand(command) {
1294
1464
  if (typeof command !== 'string') return null;
1295
1465
  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)$/,
1466
+ /^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
1467
  );
1298
1468
  return match?.[1] || null;
1299
1469
  }
@@ -1367,11 +1537,33 @@ function safeJsonObject(filePath) {
1367
1537
  }
1368
1538
  }
1369
1539
 
1370
- function claudeNativeHookFingerprint(settings) {
1540
+ // The host-approval hooks Claude Code needs besides the core five (see upsertClaudeHooks).
1541
+ function claudeHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
1542
+ return {
1543
+ permission_request: exactHookConfigured(settings, 'PermissionRequest', targetCommand(MCP_PERMISSION_REQUEST_HOOK_COMMAND), NATIVE_HOOK_MATCHER),
1544
+ post_tool_batch: exactHookConfigured(settings, 'PostToolBatch', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND)),
1545
+ };
1546
+ }
1547
+
1548
+ function claudeNativeHookFingerprint(settings, options = {}) {
1549
+ const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
1371
1550
  const contract = {
1372
1551
  schema: 'marrow-claude-native-hooks.v3',
1373
1552
  adapter_version: MCP_ADAPTER_VERSION,
1374
1553
  expected_hooks: NATIVE_EXPECTED_HOOKS,
1554
+ ...(hostApprovals ? {
1555
+ host_approvals: {
1556
+ configured: claudeHostApprovalHooksConfigured(settings),
1557
+ descriptors: {
1558
+ permission_request: exactHookDescriptors(settings, 'PermissionRequest', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
1559
+ post_tool_batch: exactHookDescriptors(settings, 'PostToolBatch', MCP_ACTION_RESULT_HOOK_COMMAND),
1560
+ },
1561
+ active_marrow_handlers: {
1562
+ permission_request: marrowHookDescriptors(settings, 'PermissionRequest'),
1563
+ post_tool_batch: marrowHookDescriptors(settings, 'PostToolBatch'),
1564
+ },
1565
+ },
1566
+ } : {}),
1375
1567
  configured: {
1376
1568
  prompt: exactHookConfigured(settings, 'UserPromptSubmit', MCP_CONTEXT_HOOK_COMMAND),
1377
1569
  pre_action: exactHookConfigured(settings, 'PreToolUse', MCP_PRE_ACTION_HOOK_COMMAND, NATIVE_HOOK_MATCHER),
@@ -1397,7 +1589,7 @@ function claudeNativeHookFingerprint(settings) {
1397
1589
  return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
1398
1590
  }
1399
1591
 
1400
- function upsertClaudeHooks(settingsPath) {
1592
+ function upsertClaudeHooks(settingsPath, options = {}) {
1401
1593
  const settings = parseJsonObject(settingsPath);
1402
1594
  const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
1403
1595
  ? settings.hooks
@@ -1426,6 +1618,18 @@ function upsertClaudeHooks(settingsPath) {
1426
1618
  UserPromptSubmit: userPromptSubmit,
1427
1619
  Stop: stop,
1428
1620
  };
1621
+ if (options.hostApprovals) {
1622
+ // Both async, exactly as `marrow-mcp setup` writes them: the marker never delays or answers
1623
+ // the dialog, and PostToolBatch settles a declined dialog in the background.
1624
+ settings.hooks.PermissionRequest = reconcileMarrowCommandHook(
1625
+ settings, 'PermissionRequest', 'permission-request-hook', MCP_PERMISSION_REQUEST_HOOK_COMMAND, NATIVE_HOOK_MATCHER,
1626
+ { async: true },
1627
+ );
1628
+ settings.hooks.PostToolBatch = reconcileMarrowCommandHook(
1629
+ settings, 'PostToolBatch', 'hook', MCP_ACTION_RESULT_HOOK_COMMAND, undefined,
1630
+ { async: true },
1631
+ );
1632
+ }
1429
1633
 
1430
1634
  return JSON.stringify(settings, null, 2) + '\n';
1431
1635
  }
@@ -1484,11 +1688,20 @@ function upsertCodexHooks(hooksPath) {
1484
1688
  return JSON.stringify(settings, null, 2) + '\n';
1485
1689
  }
1486
1690
 
1691
+ // A Marrow Cursor entry: the npx entrypoint, or the node guard that names it as JSON items.
1692
+ function cursorMarrowHookSubcommand(command) {
1693
+ const direct = marrowHookSubcommand(command);
1694
+ if (direct) return direct;
1695
+ if (typeof command !== 'string') return null;
1696
+ const guarded = command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']cursor-(pre-action-hook|context-hook|hook|session-hook)["']/);
1697
+ return guarded?.[1] || null;
1698
+ }
1699
+
1487
1700
  function reconcileCursorHook(settings, eventName, subcommand, canonical) {
1488
1701
  const original = Array.isArray(settings?.hooks?.[eventName]) ? settings.hooks[eventName] : [];
1489
1702
  const retained = original.filter((entry) => !(
1490
1703
  entry && typeof entry === 'object' && !Array.isArray(entry)
1491
- && marrowHookSubcommand(entry.command)
1704
+ && cursorMarrowHookSubcommand(entry.command)
1492
1705
  ));
1493
1706
  return [...retained, canonical];
1494
1707
  }
@@ -1506,7 +1719,7 @@ function cursorHookDescriptors(settings, eventName) {
1506
1719
  const entries = settings?.hooks?.[eventName];
1507
1720
  if (!Array.isArray(entries)) return [];
1508
1721
  return entries.flatMap((entry) => {
1509
- if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !marrowHookSubcommand(entry.command)) return [];
1722
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !cursorMarrowHookSubcommand(entry.command)) return [];
1510
1723
  return [{
1511
1724
  matcher: typeof entry.matcher === 'string' ? entry.matcher : null,
1512
1725
  command: String(entry.command).trim(),
@@ -1517,14 +1730,43 @@ function cursorHookDescriptors(settings, eventName) {
1517
1730
  });
1518
1731
  }
1519
1732
 
1520
- function cursorNativeHookFingerprint(settings) {
1733
+ // The Cursor hooks that host approvals add (see upsertCursorHooks), as configured checks.
1734
+ function cursorHostApprovalHooksConfigured(settings, targetCommand = (command) => command) {
1735
+ const pre = targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND);
1736
+ const post = targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND);
1737
+ const gate = { timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false };
1738
+ return {
1739
+ pre_tool_use: exactCursorHookConfigured(settings, 'preToolUse', pre, CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER, gate),
1740
+ beforeShellExecution: exactCursorHookConfigured(settings, 'beforeShellExecution', pre, undefined, gate),
1741
+ beforeMCPExecution: exactCursorHookConfigured(settings, 'beforeMCPExecution', targetCommand(CURSOR_MCP_PRE_ACTION_GUARD_COMMAND), undefined, gate),
1742
+ ...Object.fromEntries(CURSOR_EXECUTION_POST_EVENTS.map((event) => [event, exactCursorHookConfigured(settings, event, post, undefined, {
1743
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1744
+ })])),
1745
+ sessionStart: exactCursorHookConfigured(settings, 'sessionStart', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
1746
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1747
+ }),
1748
+ beforeSubmitPrompt: exactCursorHookConfigured(settings, 'beforeSubmitPrompt', targetCommand(CURSOR_CONTEXT_HOOK_COMMAND), undefined, {
1749
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1750
+ }),
1751
+ };
1752
+ }
1753
+
1754
+ function cursorNativeHookFingerprint(settings, options = {}) {
1755
+ const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
1521
1756
  const contract = {
1522
1757
  schema: 'marrow-cursor-native-hooks.v1',
1523
1758
  adapter_version: MCP_ADAPTER_VERSION,
1524
1759
  expected_hooks: ['pre_action', 'action_result', 'outcome_closure'],
1760
+ ...(hostApprovals ? {
1761
+ host_approvals: {
1762
+ configured: cursorHostApprovalHooksConfigured(settings),
1763
+ descriptors: Object.fromEntries([...CURSOR_EXECUTION_PRE_EVENTS, ...CURSOR_EXECUTION_POST_EVENTS, 'sessionStart', 'beforeSubmitPrompt']
1764
+ .map((event) => [event, cursorHookDescriptors(settings, event)])),
1765
+ },
1766
+ } : {}),
1525
1767
  configured: {
1526
- pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
1527
- timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1768
+ pre_action: exactCursorHookConfigured(settings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
1769
+ timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
1528
1770
  failClosed: true,
1529
1771
  async: false,
1530
1772
  }),
@@ -1548,18 +1790,19 @@ function cursorNativeHookFingerprint(settings) {
1548
1790
  return crypto.createHash('sha256').update(JSON.stringify(contract)).digest('hex');
1549
1791
  }
1550
1792
 
1551
- function upsertCursorHooks(hooksPath) {
1793
+ function upsertCursorHooks(hooksPath, options = {}) {
1552
1794
  const settings = parseJsonObject(hooksPath);
1553
1795
  const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
1554
1796
  ? settings.hooks
1555
1797
  : {};
1556
1798
  settings.version = 1;
1799
+ const hostApprovals = options.hostApprovals === true;
1557
1800
  settings.hooks = {
1558
1801
  ...hooks,
1559
1802
  preToolUse: reconcileCursorHook(settings, 'preToolUse', 'pre-action-hook', {
1560
1803
  command: CURSOR_PRE_ACTION_HOOK_COMMAND,
1561
- matcher: CURSOR_NATIVE_HOOK_MATCHER,
1562
- timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1804
+ matcher: hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER,
1805
+ timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS,
1563
1806
  failClosed: true,
1564
1807
  async: false,
1565
1808
  }),
@@ -1578,6 +1821,36 @@ function upsertCursorHooks(hooksPath) {
1578
1821
  timeout: CODEX_SESSION_TIMEOUT_SECONDS,
1579
1822
  }),
1580
1823
  };
1824
+ if (hostApprovals) {
1825
+ // Cursor enforces "ask" only here. failClosed: a crash or timeout blocks the call instead
1826
+ // of letting it through (Cursor fails open by default).
1827
+ for (const eventName of CURSOR_EXECUTION_PRE_EVENTS) {
1828
+ settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'pre-action-hook', {
1829
+ command: eventName === 'beforeMCPExecution' ? CURSOR_MCP_PRE_ACTION_GUARD_COMMAND : CURSOR_PRE_ACTION_HOOK_COMMAND,
1830
+ timeout: CURSOR_GATE_HOOK_TIMEOUT_SECONDS,
1831
+ failClosed: true,
1832
+ async: false,
1833
+ });
1834
+ }
1835
+ for (const eventName of CURSOR_EXECUTION_POST_EVENTS) {
1836
+ settings.hooks[eventName] = reconcileCursorHook(settings, eventName, 'hook', {
1837
+ command: CURSOR_ACTION_RESULT_HOOK_COMMAND,
1838
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1839
+ });
1840
+ }
1841
+ // sessionStart tells the hooks whether the session is local and interactive (not a
1842
+ // background agent); without it Cursor holds are denied instead of asked.
1843
+ settings.hooks.sessionStart = reconcileCursorHook(settings, 'sessionStart', 'session-hook', {
1844
+ command: CURSOR_SESSION_END_HOOK_COMMAND,
1845
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1846
+ });
1847
+ // The typed reply ("marrow approve CODE") in a local interactive session. Never failClosed:
1848
+ // a failure must not block the operator's own prompts.
1849
+ settings.hooks.beforeSubmitPrompt = reconcileCursorHook(settings, 'beforeSubmitPrompt', 'context-hook', {
1850
+ command: CURSOR_CONTEXT_HOOK_COMMAND,
1851
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1852
+ });
1853
+ }
1581
1854
  return JSON.stringify(settings, null, 2) + '\n';
1582
1855
  }
1583
1856
 
@@ -1647,7 +1920,9 @@ function upsertWindsurfHooks(hooksPath) {
1647
1920
 
1648
1921
  function geminiMarrowHookEntrypoint(command) {
1649
1922
  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|['"]|$)/);
1923
+ const match = command.match(/@getmarrow\/mcp(?:@[^\s]+)?\s+marrow-mcp\s+gemini-(pre-action-hook|context-hook|hook|session-hook)(?:\s|['"]|$)/)
1924
+ // The node guards name the entrypoint as JSON array items.
1925
+ || command.match(/@getmarrow\/mcp@[^\s"',]+["'],["']marrow-mcp["'],["']gemini-(pre-action-hook|context-hook|hook|session-hook)["']/);
1651
1926
  return match?.[1] || null;
1652
1927
  }
1653
1928
 
@@ -1689,7 +1964,14 @@ function geminiHooksExplicitlyDisabled(settings) {
1689
1964
  return settings?.hooksConfig?.enabled === false;
1690
1965
  }
1691
1966
 
1692
- function geminiNativeHookFingerprint(settings) {
1967
+ // The Gemini BeforeTool command: the JSON-checking guard with host approvals, otherwise the
1968
+ // earlier fixed-string wrapper.
1969
+ function geminiPreActionCommand(hostApprovals) {
1970
+ return hostApprovals ? GEMINI_PRE_ACTION_GUARD_COMMAND : GEMINI_PRE_ACTION_HOOK_COMMAND;
1971
+ }
1972
+
1973
+ function geminiNativeHookFingerprint(settings, options = {}) {
1974
+ const hostApprovals = options.hostApprovals ?? hostApprovalHooksSupported();
1693
1975
  return crypto.createHash('sha256').update(JSON.stringify({
1694
1976
  schema: 'marrow-gemini-native-hooks.v1',
1695
1977
  adapter_version: MCP_ADAPTER_VERSION,
@@ -1698,7 +1980,7 @@ function geminiNativeHookFingerprint(settings) {
1698
1980
  explicitly_disabled: geminiHooksExplicitlyDisabled(settings),
1699
1981
  configured: {
1700
1982
  pre_action: exactGeminiHookConfigured(
1701
- settings, 'BeforeTool', 'marrow-before-tool', GEMINI_PRE_ACTION_HOOK_COMMAND,
1983
+ settings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
1702
1984
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
1703
1985
  ),
1704
1986
  action_result: exactGeminiHookConfigured(
@@ -1709,6 +1991,12 @@ function geminiNativeHookFingerprint(settings) {
1709
1991
  settings, 'AfterAgent', 'marrow-after-agent', GEMINI_SESSION_END_HOOK_COMMAND,
1710
1992
  undefined, GEMINI_CLOSEOUT_TIMEOUT_MS,
1711
1993
  ),
1994
+ ...(hostApprovals ? {
1995
+ typed_reply: exactGeminiHookConfigured(
1996
+ settings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
1997
+ undefined, GEMINI_HOOK_TIMEOUT_MS,
1998
+ ),
1999
+ } : {}),
1712
2000
  },
1713
2001
  session_end_claimed: false,
1714
2002
  })).digest('hex');
@@ -1825,11 +2113,12 @@ function managedGrokHooksFile(filePath) {
1825
2113
  }
1826
2114
  }
1827
2115
 
1828
- function upsertGeminiHooks(settingsPath) {
2116
+ function upsertGeminiHooks(settingsPath, options = {}) {
1829
2117
  const settings = parseJsonObject(settingsPath);
1830
2118
  const hooks = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
1831
2119
  ? settings.hooks
1832
2120
  : {};
2121
+ const hostApprovals = options.hostApprovals === true;
1833
2122
  settings.hooks = {
1834
2123
  ...hooks,
1835
2124
  BeforeTool: reconcileGeminiHook(settings, 'BeforeTool', {
@@ -1837,7 +2126,7 @@ function upsertGeminiHooks(settingsPath) {
1837
2126
  hooks: [{
1838
2127
  name: 'marrow-before-tool',
1839
2128
  type: 'command',
1840
- command: GEMINI_PRE_ACTION_HOOK_COMMAND,
2129
+ command: geminiPreActionCommand(hostApprovals),
1841
2130
  timeout: GEMINI_HOOK_TIMEOUT_MS,
1842
2131
  }],
1843
2132
  }),
@@ -1859,6 +2148,17 @@ function upsertGeminiHooks(settingsPath) {
1859
2148
  }],
1860
2149
  }),
1861
2150
  };
2151
+ if (hostApprovals) {
2152
+ // The typed reply in a local interactive session (`gemini`, never `gemini -p`).
2153
+ settings.hooks.BeforeAgent = reconcileGeminiHook(settings, 'BeforeAgent', {
2154
+ hooks: [{
2155
+ name: 'marrow-before-agent',
2156
+ type: 'command',
2157
+ command: GEMINI_CONTEXT_HOOK_COMMAND,
2158
+ timeout: GEMINI_HOOK_TIMEOUT_MS,
2159
+ }],
2160
+ });
2161
+ }
1862
2162
  return JSON.stringify(settings, null, 2) + '\n';
1863
2163
  }
1864
2164
 
@@ -1935,7 +2235,7 @@ function clineNativeHookFingerprint(detection) {
1935
2235
  function activationProfile(detection, plan, changes, client) {
1936
2236
  const registry = HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === client)
1937
2237
  || HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === 'custom');
1938
- const sdkDependency = inspectSdkDependency(detection);
2238
+ const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
1939
2239
  const capabilityLevel = client === 'custom' && (plan.mode === 'sdk' || plan.mode === 'both')
1940
2240
  ? 'sdk_passive_runtime'
1941
2241
  : registry.capability_level;
@@ -1946,6 +2246,7 @@ function activationProfile(detection, plan, changes, client) {
1946
2246
  ? plan.mcp_target_version
1947
2247
  : MCP_ADAPTER_VERSION;
1948
2248
  const targetCommand = (command) => retargetMcpPackageSpec(command, mcpTargetVersion);
2249
+ const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
1949
2250
  const adapterVersion = capabilityLevel === 'native_hooks' || capabilityLevel === 'mcp'
1950
2251
  ? mcpTargetVersion
1951
2252
  : capabilityLevel === 'sdk_passive_runtime'
@@ -1964,14 +2265,18 @@ function activationProfile(detection, plan, changes, client) {
1964
2265
  if (exactHookConfigured(codexSettings, 'PostToolUse', targetCommand(CODEX_ACTION_RESULT_HOOK_COMMAND), CODEX_NATIVE_HOOK_MATCHER)) observedHooks.push('action_result');
1965
2266
  if (exactHookConfigured(codexSettings, 'SessionEnd', targetCommand(CODEX_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
1966
2267
  } 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');
2268
+ const approvalHooks = hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings, targetCommand) : null;
2269
+ if ((hostApprovals
2270
+ ? approvalHooks.pre_tool_use && approvalHooks.beforeShellExecution && approvalHooks.beforeMCPExecution
2271
+ && approvalHooks.sessionStart && approvalHooks.beforeSubmitPrompt
2272
+ : exactCursorHookConfigured(cursorSettings, 'preToolUse', targetCommand(CURSOR_PRE_ACTION_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
2273
+ timeout: CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
2274
+ }))) observedHooks.push('pre_action');
1970
2275
  if (exactCursorHookConfigured(cursorSettings, 'postToolUse', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
1971
2276
  timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1972
2277
  }) && exactCursorHookConfigured(cursorSettings, 'postToolUseFailure', targetCommand(CURSOR_ACTION_RESULT_HOOK_COMMAND), CURSOR_NATIVE_HOOK_MATCHER, {
1973
2278
  timeout: CODEX_HOOK_TIMEOUT_SECONDS,
1974
- })) observedHooks.push('action_result');
2279
+ }) && (!hostApprovals || (approvalHooks.afterShellExecution && approvalHooks.afterMCPExecution))) observedHooks.push('action_result');
1975
2280
  if (exactCursorHookConfigured(cursorSettings, 'stop', targetCommand(CURSOR_SESSION_END_HOOK_COMMAND), undefined, {
1976
2281
  timeout: CODEX_SESSION_TIMEOUT_SECONDS,
1977
2282
  })) observedHooks.push('outcome_closure');
@@ -1991,9 +2296,12 @@ function activationProfile(detection, plan, changes, client) {
1991
2296
  )) observedHooks.push('response_closeout');
1992
2297
  } else if (client === 'gemini' && !geminiHooksExplicitlyDisabled(geminiSettings)) {
1993
2298
  if (exactGeminiHookConfigured(
1994
- geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(GEMINI_PRE_ACTION_HOOK_COMMAND),
2299
+ geminiSettings, 'BeforeTool', 'marrow-before-tool', targetCommand(geminiPreActionCommand(hostApprovals)),
1995
2300
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
1996
- )) observedHooks.push('pre_action');
2301
+ ) && (!hostApprovals || exactGeminiHookConfigured(
2302
+ geminiSettings, 'BeforeAgent', 'marrow-before-agent', targetCommand(GEMINI_CONTEXT_HOOK_COMMAND),
2303
+ undefined, GEMINI_HOOK_TIMEOUT_MS,
2304
+ ))) observedHooks.push('pre_action');
1997
2305
  if (exactGeminiHookConfigured(
1998
2306
  geminiSettings, 'AfterTool', 'marrow-after-tool', targetCommand(GEMINI_ACTION_RESULT_HOOK_COMMAND),
1999
2307
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
@@ -2017,11 +2325,14 @@ function activationProfile(detection, plan, changes, client) {
2017
2325
  } else {
2018
2326
  if (capabilityLevel === 'native_hooks'
2019
2327
  && exactHookConfigured(claudeSettings, 'UserPromptSubmit', targetCommand(MCP_CONTEXT_HOOK_COMMAND))) observedHooks.push('prompt');
2328
+ const claudeApprovalHooks = hostApprovals ? claudeHostApprovalHooksConfigured(claudeSettings, targetCommand) : null;
2020
2329
  if (capabilityLevel === 'native_hooks'
2021
- && exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)) observedHooks.push('pre_action');
2330
+ && exactHookConfigured(claudeSettings, 'PreToolUse', targetCommand(MCP_PRE_ACTION_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
2331
+ && (!hostApprovals || claudeApprovalHooks.permission_request)) observedHooks.push('pre_action');
2022
2332
  if (capabilityLevel === 'native_hooks'
2023
2333
  && 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');
2334
+ && exactHookConfigured(claudeSettings, 'PostToolUseFailure', targetCommand(MCP_ACTION_RESULT_HOOK_COMMAND), NATIVE_HOOK_MATCHER)
2335
+ && (!hostApprovals || claudeApprovalHooks.post_tool_batch)) observedHooks.push('action_result');
2025
2336
  if (capabilityLevel === 'native_hooks'
2026
2337
  && exactHookConfigured(claudeSettings, 'Stop', targetCommand(MCP_SESSION_END_HOOK_COMMAND))) observedHooks.push('session_end');
2027
2338
  }
@@ -2048,12 +2359,12 @@ function activationProfile(detection, plan, changes, client) {
2048
2359
  .join('|');
2049
2360
  const configFingerprint = capabilityLevel === 'native_hooks'
2050
2361
  ? client === 'codex' ? codexNativeHookFingerprint(codexSettings)
2051
- : client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings)
2362
+ : client === 'cursor' || client === 'composer' ? cursorNativeHookFingerprint(cursorSettings, { hostApprovals })
2052
2363
  : client === 'cline' ? clineNativeHookFingerprint(detection)
2053
2364
  : client === 'windsurf' ? windsurfNativeHookFingerprint(windsurfSettings)
2054
- : client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings)
2365
+ : client === 'gemini' ? geminiNativeHookFingerprint(geminiSettings, { hostApprovals })
2055
2366
  : client === 'grok' ? grokNativeHookFingerprint(grokSettings)
2056
- : claudeNativeHookFingerprint(claudeSettings)
2367
+ : claudeNativeHookFingerprint(claudeSettings, { hostApprovals })
2057
2368
  : crypto.createHash('sha256')
2058
2369
  .update(`${client}:${capabilityLevel}:${expectedHooks.join(',')}:${fingerprintMaterial}`)
2059
2370
  .digest('hex');
@@ -2220,8 +2531,9 @@ function upsertMcpServerConfig(filePath, options = {}) {
2220
2531
  return JSON.stringify(config, null, 2) + '\n';
2221
2532
  }
2222
2533
 
2223
- function inspectSdkDependency(detection) {
2224
- if (!detection.node) {
2534
+ function inspectSdkDependency(detection, { sdkMode = false } = {}) {
2535
+ // No SDK advice unless this project uses the SDK or the owner asked for the SDK runtime.
2536
+ if (!sdkMode && !sdkUseDetected(detection)) {
2225
2537
  return { required: false, present: false, install_command: null };
2226
2538
  }
2227
2539
 
@@ -2346,11 +2658,14 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
2346
2658
  && exactHookConfigured(codexSettings, 'PreToolUse', CODEX_PRE_ACTION_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
2347
2659
  && exactHookConfigured(codexSettings, 'PostToolUse', CODEX_ACTION_RESULT_HOOK_COMMAND, CODEX_NATIVE_HOOK_MATCHER)
2348
2660
  && exactHookConfigured(codexSettings, 'SessionEnd', CODEX_SESSION_END_HOOK_COMMAND));
2661
+ const hostApprovals = hostApprovalHooksSupported();
2349
2662
  const cursorSettings = ['cursor', 'composer'].includes(entry.client) ? safeJsonObject(detection.paths.cursorHooks) : null;
2663
+ const cursorApprovalHooks = cursorSettings && hostApprovals ? cursorHostApprovalHooksConfigured(cursorSettings) : null;
2350
2664
  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,
2665
+ && exactCursorHookConfigured(cursorSettings, 'preToolUse', CURSOR_PRE_ACTION_HOOK_COMMAND, hostApprovals ? CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER : CURSOR_NATIVE_HOOK_MATCHER, {
2666
+ timeout: hostApprovals ? CURSOR_GATE_HOOK_TIMEOUT_SECONDS : CODEX_HOOK_TIMEOUT_SECONDS, failClosed: true, async: false,
2353
2667
  })
2668
+ && (!cursorApprovalHooks || Object.values(cursorApprovalHooks).every(Boolean))
2354
2669
  && exactCursorHookConfigured(cursorSettings, 'postToolUse', CURSOR_ACTION_RESULT_HOOK_COMMAND, CURSOR_NATIVE_HOOK_MATCHER, {
2355
2670
  timeout: CODEX_HOOK_TIMEOUT_SECONDS,
2356
2671
  })
@@ -2377,9 +2692,13 @@ function defaultHarnessInstallMatrix(detection = detectEnvironment(process.cwd()
2377
2692
  const geminiConfigured = Boolean(geminiSettings
2378
2693
  && !geminiHooksExplicitlyDisabled(geminiSettings)
2379
2694
  && exactGeminiHookConfigured(
2380
- geminiSettings, 'BeforeTool', 'marrow-before-tool', GEMINI_PRE_ACTION_HOOK_COMMAND,
2695
+ geminiSettings, 'BeforeTool', 'marrow-before-tool', geminiPreActionCommand(hostApprovals),
2381
2696
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
2382
2697
  )
2698
+ && (!hostApprovals || exactGeminiHookConfigured(
2699
+ geminiSettings, 'BeforeAgent', 'marrow-before-agent', GEMINI_CONTEXT_HOOK_COMMAND,
2700
+ undefined, GEMINI_HOOK_TIMEOUT_MS,
2701
+ ))
2383
2702
  && exactGeminiHookConfigured(
2384
2703
  geminiSettings, 'AfterTool', 'marrow-after-tool', GEMINI_ACTION_RESULT_HOOK_COMMAND,
2385
2704
  GEMINI_NATIVE_HOOK_MATCHER, GEMINI_HOOK_TIMEOUT_MS,
@@ -2462,6 +2781,32 @@ function derivedAgentIdsFor(root) {
2462
2781
  return [...SOURCE_CLIENTS].map((candidate) => stableAgentId(root, candidate));
2463
2782
  }
2464
2783
 
2784
+ // SDK use in this project: the SDK is a declared or installed dependency, or an earlier install
2785
+ // already wrote Marrow's passive runtime here. Without it, no SDK file or SDK advice is written.
2786
+ function sdkUseDetected(detection) {
2787
+ if (exists(detection.paths.passiveRuntime)) return true;
2788
+ if (!detection.node) return false;
2789
+ if (findUp(detection.root, [path.join('node_modules', '@getmarrow', 'sdk', 'package.json')])) return true;
2790
+ const raw = safeRead(detection.paths.packageJson);
2791
+ if (!raw) return false;
2792
+ // A package.json Marrow cannot parse is checked by hand (the SDK check says so).
2793
+ try {
2794
+ JSON.parse(raw);
2795
+ } catch {
2796
+ return /@getmarrow\/sdk/.test(raw);
2797
+ }
2798
+ return /"@getmarrow\/sdk"/.test(raw) || /@getmarrow\/sdk@/.test(raw);
2799
+ }
2800
+
2801
+ // Hosts that read AGENTS.md. Claude Code reads CLAUDE.md and Cursor gets its own rule file, so
2802
+ // a project with only those gets no AGENTS.md unless it already holds Marrow's block.
2803
+ const AGENTS_MD_READERS = Object.freeze(['codex', 'windsurf', 'gemini', 'cline', 'grok', 'hermes', 'openclaw']);
2804
+
2805
+ function agentsMdWanted(detection) {
2806
+ if (AGENTS_MD_READERS.some((name) => detection[name])) return true;
2807
+ return Boolean(marrowManagedBlockInText(safeRead(detection.paths.agentsMd)));
2808
+ }
2809
+
2465
2810
  function buildPlan(detection, options) {
2466
2811
  const client = options.client || detectedClient(detection);
2467
2812
  // Server-facing configuration carries only a configured agent id. Without one, the MCP
@@ -2488,9 +2833,14 @@ function buildPlan(detection, options) {
2488
2833
  return write;
2489
2834
  };
2490
2835
  const mcpTargetVersion = executableMcpTarget(options).version;
2836
+ // Hooks start the verified local MCP runtime for this exact version when there is one.
2837
+ const runtimeVersion = options.mcpRuntime && options.mcpRuntime.version === mcpTargetVersion ? mcpTargetVersion : null;
2838
+ const localize = (text) => (runtimeVersion ? localizeHookSettingsText(text, runtimeVersion) : text);
2491
2839
  const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
2840
+ // The host-approval hook layout only for an MCP that answers those hooks.
2841
+ const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
2492
2842
  const mode = options.mode === 'auto'
2493
- ? detection.node ? 'both' : 'mcp'
2843
+ ? sdkUseDetected(detection) ? 'both' : 'mcp'
2494
2844
  : options.mode;
2495
2845
  const writes = [];
2496
2846
 
@@ -2516,7 +2866,7 @@ function buildPlan(detection, options) {
2516
2866
  type: 'json-transform',
2517
2867
  path: detection.paths.claudeSettings,
2518
2868
  label: 'Claude Code MCP passive hooks',
2519
- transform: (filePath) => retarget(upsertClaudeHooks(filePath)),
2869
+ transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals }))),
2520
2870
  });
2521
2871
  }
2522
2872
  if (detection.codex) {
@@ -2524,7 +2874,7 @@ function buildPlan(detection, options) {
2524
2874
  type: 'json-transform',
2525
2875
  path: detection.paths.codexHooks,
2526
2876
  label: 'Codex native hooks',
2527
- transform: (filePath) => retarget(upsertCodexHooks(filePath)),
2877
+ transform: (filePath) => localize(retarget(upsertCodexHooks(filePath))),
2528
2878
  });
2529
2879
  }
2530
2880
  if (detection.cline) {
@@ -2544,7 +2894,7 @@ function buildPlan(detection, options) {
2544
2894
  type: 'json-transform',
2545
2895
  path: detection.paths.windsurfHooks,
2546
2896
  label: 'Windsurf native hooks',
2547
- transform: (filePath) => retarget(upsertWindsurfHooks(filePath)),
2897
+ transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath))),
2548
2898
  });
2549
2899
  }
2550
2900
  if (detection.gemini) {
@@ -2552,7 +2902,7 @@ function buildPlan(detection, options) {
2552
2902
  type: 'json-transform',
2553
2903
  path: detection.paths.geminiSettings,
2554
2904
  label: 'Gemini CLI native hooks',
2555
- transform: (filePath) => retarget(upsertGeminiHooks(filePath)),
2905
+ transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals }))),
2556
2906
  });
2557
2907
  }
2558
2908
  if (detection.grok) {
@@ -2563,7 +2913,7 @@ function buildPlan(detection, options) {
2563
2913
  label: 'Grok native hooks',
2564
2914
  isManaged: managedGrokHooksFile,
2565
2915
  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)),
2916
+ transform: (filePath) => localize(retarget(upsertGrokHooks(filePath))),
2567
2917
  });
2568
2918
  }
2569
2919
  writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
@@ -2587,18 +2937,20 @@ function buildPlan(detection, options) {
2587
2937
  type: 'json-transform',
2588
2938
  path: detection.paths.cursorHooks,
2589
2939
  label: 'Cursor native hooks',
2590
- transform: (filePath) => retarget(upsertCursorHooks(filePath)),
2940
+ transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals }))),
2591
2941
  });
2592
2942
  writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
2593
2943
  }
2594
2944
  }
2595
2945
 
2596
- if (mode === 'md' || mode === 'both' || mode === 'mcp') {
2946
+ // AGENTS.md only for a host that reads it, an explicit --md or --both, or a file that already
2947
+ // carries Marrow's block (so its pins stay current).
2948
+ if (mode === 'md' || (options.mode === 'both') || ((mode === 'both' || mode === 'mcp') && agentsMdWanted(detection))) {
2597
2949
  writes.push({
2598
2950
  type: 'md-block',
2599
2951
  path: detection.paths.agentsMd,
2600
2952
  label: 'Agent instructions',
2601
- block: retarget(passiveInstructions()),
2953
+ block: retarget(passiveInstructions({ hostApprovals })),
2602
2954
  });
2603
2955
  }
2604
2956
 
@@ -2607,11 +2959,11 @@ function buildPlan(detection, options) {
2607
2959
  type: 'file',
2608
2960
  path: detection.paths.cursorRules,
2609
2961
  label: 'Cursor Marrow rule',
2610
- content: retarget(passiveInstructions()).replace(/<!--[^>]+-->/g, '').trim() + '\n',
2962
+ content: retarget(passiveInstructions({ hostApprovals })).replace(/<!--[^>]+-->/g, '').trim() + '\n',
2611
2963
  });
2612
2964
  }
2613
2965
 
2614
- return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion };
2966
+ return { mode, root: detection.root, writes, mcp_target_version: mcpTargetVersion, host_approval_hooks: hostApprovals };
2615
2967
  }
2616
2968
 
2617
2969
  function assertContainedManagedTarget(root, targetPath) {
@@ -2728,7 +3080,9 @@ function applyPlan(plan, options) {
2728
3080
  undo = planned.action === 'update' ? planned.undo || [] : null;
2729
3081
  }
2730
3082
  } else if (write.type === 'owned-executable') {
2731
- if (fileExists && before !== write.content) {
3083
+ // A file Marrow wrote for another pinned MCP version is still Marrow's and moves to this
3084
+ // pin; any other difference is the owner's edit and is never overwritten.
3085
+ if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content])) {
2732
3086
  after = before;
2733
3087
  hookConflict = true;
2734
3088
  } else {
@@ -2803,6 +3157,294 @@ function applyPlan(plan, options) {
2803
3157
  return changes;
2804
3158
  }
2805
3159
 
3160
+ // ---------------------------------------------------------------------------
3161
+ // Uninstall: removes only what Marrow wrote. Every other hook, server, setting and file is kept.
3162
+ // Files are read in-process and never printed; the report names paths and counts only.
3163
+ // ---------------------------------------------------------------------------
3164
+
3165
+ // Hook groups of the Claude Code, Codex, Grok and Gemini shape:
3166
+ // hooks[event] = [{ matcher?, hooks: [handler, ...] }].
3167
+ function withoutMarrowGroupedHandlers(settings, isMarrowHandler) {
3168
+ const hooks = settings?.hooks;
3169
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
3170
+ let removed = 0;
3171
+ let kept = 0;
3172
+ const next = {};
3173
+ for (const [eventName, entries] of Object.entries(hooks)) {
3174
+ if (!Array.isArray(entries)) {
3175
+ next[eventName] = entries;
3176
+ continue;
3177
+ }
3178
+ const retained = [];
3179
+ let touched = false;
3180
+ for (const entry of entries) {
3181
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry) || !Array.isArray(entry.hooks)) {
3182
+ retained.push(entry);
3183
+ kept += 1;
3184
+ continue;
3185
+ }
3186
+ const handlers = entry.hooks.filter((handler) => !isMarrowHandler(handler));
3187
+ kept += handlers.length;
3188
+ if (handlers.length === entry.hooks.length) {
3189
+ retained.push(entry);
3190
+ continue;
3191
+ }
3192
+ touched = true;
3193
+ removed += entry.hooks.length - handlers.length;
3194
+ if (handlers.length > 0) retained.push({ ...entry, hooks: handlers });
3195
+ }
3196
+ // An event that held only Marrow's entries goes away with them.
3197
+ if (touched && retained.length === 0) continue;
3198
+ next[eventName] = retained;
3199
+ }
3200
+ const result = { ...settings, hooks: next };
3201
+ if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
3202
+ return { settings: result, removed, kept };
3203
+ }
3204
+
3205
+ // Flat hook lists of the Cursor and Windsurf shape: hooks[event] = [{ command, ... }].
3206
+ function withoutMarrowFlatEntries(settings, isMarrowEntry) {
3207
+ const hooks = settings?.hooks;
3208
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return { settings, removed: 0, kept: 0 };
3209
+ let removed = 0;
3210
+ let kept = 0;
3211
+ const next = {};
3212
+ for (const [eventName, entries] of Object.entries(hooks)) {
3213
+ if (!Array.isArray(entries)) {
3214
+ next[eventName] = entries;
3215
+ continue;
3216
+ }
3217
+ const retained = entries.filter((entry) => !(entry && typeof entry === 'object' && !Array.isArray(entry) && isMarrowEntry(entry)));
3218
+ kept += retained.length;
3219
+ removed += entries.length - retained.length;
3220
+ if (retained.length === 0 && entries.length > 0) continue;
3221
+ next[eventName] = retained;
3222
+ }
3223
+ const result = { ...settings, hooks: next };
3224
+ if (removed > 0 && Object.keys(next).length === 0) delete result.hooks;
3225
+ return { settings: result, removed, kept };
3226
+ }
3227
+
3228
+ const isMarrowCommandHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
3229
+ && handler.type === 'command' && marrowHookSubcommand(handler.command));
3230
+ const isMarrowGeminiHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
3231
+ && (String(handler.name || '').startsWith('marrow-') || geminiMarrowHookEntrypoint(handler.command)));
3232
+ const isMarrowGrokHandler = (handler) => Boolean(handler && typeof handler === 'object' && !Array.isArray(handler)
3233
+ && handler.type === 'command' && grokMarrowHookSubcommand(handler.command));
3234
+
3235
+ // The MCP server entry only when Marrow wrote it: npx running the published package.
3236
+ function withoutMarrowMcpServer(config) {
3237
+ const servers = config?.mcpServers;
3238
+ const entry = servers && typeof servers === 'object' && !Array.isArray(servers) ? servers.marrow : null;
3239
+ if (!entry) return { config, removed: 0, kept: 0, custom: false };
3240
+ const args = Array.isArray(entry.args) ? entry.args.map(String) : [];
3241
+ const ours = entry.command === 'npx' && args.includes('marrow-mcp')
3242
+ && args.some((arg) => /^--package=@getmarrow\/mcp(?:@[^\s]+)?$/.test(arg));
3243
+ if (!ours) return { config, removed: 0, kept: 1, custom: true };
3244
+ const nextServers = { ...servers };
3245
+ delete nextServers.marrow;
3246
+ const result = { ...config, mcpServers: nextServers };
3247
+ if (Object.keys(nextServers).length === 0) delete result.mcpServers;
3248
+ return { config: result, removed: 1, kept: Object.keys(nextServers).length, custom: false };
3249
+ }
3250
+
3251
+ function withoutMarrowInstructionBlock(content) {
3252
+ const block = marrowManagedBlockInText(content);
3253
+ if (!block) return { content, removed: 0 };
3254
+ const start = content.indexOf(block);
3255
+ const before = content.slice(0, start);
3256
+ const after = content.slice(start + block.length).replace(/^\n/, '');
3257
+ const head = before.trim() ? before.replace(/\n*$/, after.trim() ? '\n\n' : '\n') : '';
3258
+ return { content: `${head}${after}`, removed: 1 };
3259
+ }
3260
+
3261
+ // Marrow-written files compare equal once the pinned MCP version is normalized.
3262
+ function sameMarrowFile(actual, candidates) {
3263
+ const normalize = (value) => String(value).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>');
3264
+ const normalized = normalize(actual);
3265
+ return candidates.some((candidate) => normalize(candidate) === normalized);
3266
+ }
3267
+
3268
+ function buildUninstallPlan(detection) {
3269
+ const steps = [];
3270
+ const json = (filePath, label, transform, extra = {}) => steps.push({ kind: 'json', path: filePath, label, transform, ...extra });
3271
+ const mcpServer = (value) => {
3272
+ const result = withoutMarrowMcpServer(value);
3273
+ return { settings: result.config, removed: result.removed, kept: result.kept, custom: result.custom };
3274
+ };
3275
+ json(detection.paths.claudeSettings, 'Claude Code hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
3276
+ json(detection.paths.codexHooks, 'Codex hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowCommandHandler));
3277
+ json(detection.paths.cursorHooks, 'Cursor hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(cursorMarrowHookSubcommand(entry.command))));
3278
+ json(detection.paths.windsurfHooks, 'Windsurf hooks', (value) => withoutMarrowFlatEntries(value, (entry) => Boolean(windsurfMarrowHookEntrypoint(entry.command))));
3279
+ json(detection.paths.geminiSettings, 'Gemini CLI hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGeminiHandler));
3280
+ json(detection.paths.grokHooks, 'Grok hooks', (value) => withoutMarrowGroupedHandlers(value, isMarrowGrokHandler), {
3281
+ root: detection.home,
3282
+ grok: true,
3283
+ // Marrow's own global file: deleted when nothing but Marrow's hooks was in it.
3284
+ removeWhenEmpty: true,
3285
+ });
3286
+ json(detection.paths.mcpJson, 'Project MCP server config', mcpServer);
3287
+ json(detection.paths.cursorMcp, 'Cursor MCP server config', mcpServer);
3288
+ steps.push({ kind: 'md-block', path: detection.paths.agentsMd, label: 'Agent instructions' });
3289
+ const instructionVariants = [false, true].map((hostApprovals) => passiveInstructions({ hostApprovals }).replace(/<!--[^>]+-->/g, '').trim() + '\n');
3290
+ steps.push({ kind: 'owned-file', path: detection.paths.cursorRules, label: 'Cursor Marrow rule', candidates: instructionVariants });
3291
+ for (const hook of clineHookContract(detection)) {
3292
+ steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content] });
3293
+ }
3294
+ return steps;
3295
+ }
3296
+
3297
+ function uninstallFileState(filePath) {
3298
+ try {
3299
+ const stat = fs.lstatSync(filePath);
3300
+ if (stat.isSymbolicLink() || !stat.isFile()) return 'unsafe';
3301
+ if (stat.size > 4 * 1024 * 1024) return 'too_large';
3302
+ return 'file';
3303
+ } catch {
3304
+ return 'missing';
3305
+ }
3306
+ }
3307
+
3308
+ async function uninstall(options = {}) {
3309
+ const home = options.home || process.env.HOME || process.env.USERPROFILE || os.homedir();
3310
+ const detection = detectEnvironment(options.cwd || process.cwd(), { ...process.env, HOME: home, USERPROFILE: home });
3311
+ const apply = Boolean(options.yes && !options.dryRun);
3312
+ const root = path.resolve(detection.root);
3313
+ const changes = [];
3314
+ let controller = null;
3315
+ if (apply && options.controller !== false) {
3316
+ // A running controller restores missing managed hooks every five minutes; stop it first.
3317
+ try {
3318
+ const stopped = await stopProjectControllers({ root, client: detectedClient(detection), mode: 'auto' });
3319
+ controller = { stopped: stopped.stopped || 0, changed: Boolean(stopped.changed) };
3320
+ } catch {
3321
+ controller = { stopped: 0, changed: false, error: 'controller_stop_failed' };
3322
+ }
3323
+ }
3324
+ for (const step of buildUninstallPlan(detection)) {
3325
+ const state = uninstallFileState(step.path);
3326
+ if (state === 'missing') continue;
3327
+ const change = { path: step.path, label: step.label, removed_entries: 0, kept_entries: 0, action: 'unchanged', applied: false };
3328
+ if (state !== 'file') {
3329
+ changes.push({ ...change, action: 'skipped', reason: state === 'unsafe' ? 'not a regular file' : 'larger than 4 MB' });
3330
+ continue;
3331
+ }
3332
+ try {
3333
+ const writeRoot = step.root ? path.resolve(step.root) : root;
3334
+ if (step.grok) {
3335
+ if (!managedGrokHooksFile(step.path)) {
3336
+ changes.push({ ...change, action: 'skipped', reason: 'not managed by Marrow' });
3337
+ continue;
3338
+ }
3339
+ assertSafeGrokHookTarget(writeRoot, step.path);
3340
+ }
3341
+ assertContainedManagedTarget(writeRoot, step.path);
3342
+ const before = fs.readFileSync(step.path, 'utf8');
3343
+ if (step.kind === 'owned-file') {
3344
+ if (!sameMarrowFile(before, step.candidates)) {
3345
+ if (MARROW_MANAGED_TEXT_RE.test(before)) changes.push({ ...change, action: 'kept', reason: 'edited after Marrow wrote it' });
3346
+ continue;
3347
+ }
3348
+ change.removed_entries = 1;
3349
+ change.action = 'delete_file';
3350
+ if (apply) fs.unlinkSync(step.path);
3351
+ } else if (step.kind === 'md-block') {
3352
+ const result = withoutMarrowInstructionBlock(before);
3353
+ if (!result.removed) continue;
3354
+ change.removed_entries = 1;
3355
+ change.action = result.content.trim() ? 'update' : 'delete_file';
3356
+ if (change.action === 'delete_file') change.note = 'it held only the Marrow block';
3357
+ else change.note = 'block_only';
3358
+ if (apply) {
3359
+ if (change.action === 'delete_file') fs.unlinkSync(step.path);
3360
+ else atomicWriteManagedFile(writeRoot, step.path, result.content);
3361
+ }
3362
+ } else {
3363
+ let parsed;
3364
+ try {
3365
+ // Marrow's hook commands in their canonical form, whether they start through npx or
3366
+ // the local runtime, so both are recognized and removed.
3367
+ parsed = before.trim() ? mapHookCommands(JSON.parse(before), delocalizeHookCommand) : {};
3368
+ } catch {
3369
+ changes.push({ ...change, action: 'skipped', reason: 'invalid JSON' });
3370
+ continue;
3371
+ }
3372
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
3373
+ changes.push({ ...change, action: 'skipped', reason: 'root is not a JSON object' });
3374
+ continue;
3375
+ }
3376
+ const result = step.transform(parsed);
3377
+ change.kept_entries = result.kept;
3378
+ if (result.custom) {
3379
+ changes.push({ ...change, action: 'kept', reason: 'custom marrow server entry' });
3380
+ continue;
3381
+ }
3382
+ if (!result.removed) continue;
3383
+ change.removed_entries = result.removed;
3384
+ const emptied = step.removeWhenEmpty && result.kept === 0
3385
+ && Object.keys(result.settings).every((key) => key === 'hooks');
3386
+ change.action = emptied ? 'delete_file' : 'update';
3387
+ if (apply) {
3388
+ if (emptied) fs.unlinkSync(step.path);
3389
+ else atomicWriteManagedFile(writeRoot, step.path, JSON.stringify(result.settings, null, 2) + '\n');
3390
+ }
3391
+ }
3392
+ change.applied = apply;
3393
+ changes.push(change);
3394
+ } catch (error) {
3395
+ changes.push({ ...change, action: 'skipped', reason: error instanceof Error ? error.message.slice(0, 200) : 'unsafe target' });
3396
+ }
3397
+ }
3398
+ const kept = [];
3399
+ 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' });
3400
+ if (exists(detection.paths.passiveEnv)) kept.push({ path: detection.paths.passiveEnv, reason: 'it may hold your own edits; delete it when no longer needed' });
3401
+ let hermesEntry = false;
3402
+ try {
3403
+ hermesEntry = Boolean(detection.hermesConfig)
3404
+ && /(?:^|\n)mcp_servers:[\s\S]*\n[ \t]+marrow:/.test(fs.readFileSync(detection.paths.hermesConfig, 'utf8'));
3405
+ } catch {
3406
+ hermesEntry = false;
3407
+ }
3408
+ 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' });
3409
+ // The local MCP runtime (~/.marrow/runtime) goes too; hooks elsewhere then start through npx.
3410
+ const runtime = removeMcpRuntime(options.home || detection.home, { dryRun: !apply });
3411
+ const removedEntries = changes.reduce((sum, change) => sum + change.removed_entries, 0);
3412
+ return {
3413
+ uninstall: {
3414
+ applied: apply,
3415
+ dry_run: !apply,
3416
+ root,
3417
+ controller,
3418
+ changes,
3419
+ kept,
3420
+ runtime: runtime.removed || runtime.would_remove ? runtime : null,
3421
+ removed_entries: removedEntries,
3422
+ next_step: apply
3423
+ ? 'Restart the agent hosts so they stop loading the removed hooks and MCP server.'
3424
+ : removedEntries > 0 || runtime.would_remove
3425
+ ? 'Nothing was changed. Run npx @getmarrow/install uninstall --yes to remove these Marrow entries.'
3426
+ : 'Nothing to remove.',
3427
+ },
3428
+ };
3429
+ }
3430
+
3431
+ function printUninstallReport(report, sink = (text) => process.stdout.write(text)) {
3432
+ const result = report.uninstall;
3433
+ const where = (filePath) => (filePath.startsWith(`${result.root}${path.sep}`) ? path.relative(result.root, filePath) : filePath);
3434
+ const entries = (count) => `${count} Marrow entr${count === 1 ? 'y' : 'ies'}`;
3435
+ sink(`Marrow uninstall${result.applied ? '' : ' (preview)'}: ${entries(result.removed_entries)} ${result.applied ? 'removed' : 'to remove'}.\n`);
3436
+ for (const change of result.changes) {
3437
+ if (change.action === 'update' && change.note) sink(`- ${where(change.path)}: ${result.applied ? 'removed' : 'would remove'} the Marrow block; your text is kept.\n`);
3438
+ 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`);
3439
+ else if (change.action === 'delete_file') sink(`- ${where(change.path)}: ${result.applied ? 'deleted' : 'would delete'} (${change.note || 'Marrow\'s own file'}).\n`);
3440
+ else if (change.action === 'kept' || change.action === 'skipped') sink(`- ${where(change.path)}: left unchanged (${change.reason}).\n`);
3441
+ }
3442
+ for (const entry of result.kept) sink(`- ${where(entry.path)}: left in place; ${entry.reason}.\n`);
3443
+ 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`);
3444
+ if (result.controller?.stopped) sink(`Stopped ${result.controller.stopped} Marrow controller${result.controller.stopped === 1 ? '' : 's'} for this project.\n`);
3445
+ sink(`${result.next_step}\n`);
3446
+ }
3447
+
2806
3448
  const SELF_TEST_READ_TIMEOUT_MS = 15_000;
2807
3449
 
2808
3450
  async function requestJson(url, options) {
@@ -2976,7 +3618,15 @@ function runtimeGateVerified(runtime) {
2976
3618
  return ['allow', 'warn', 'review_required', 'block'].includes(decision);
2977
3619
  }
2978
3620
 
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.';
3621
+ // When Marrow refuses this machine's agent identity, the one command that fixes it. A key that
3622
+ // is simply not bound to one agent needs nothing: Marrow resolves the agent on each call.
3623
+ const IDENTITY_REFUSED_RE = /AGENT_NOT_REGISTERED|wrong_agent_id|MARROW_AGENT_SCOPE_MISMATCH|Agent-bound key cannot access another agent/i;
3624
+ function identityRefusalFix(message, configuredAgentId = '') {
3625
+ if (!IDENTITY_REFUSED_RE.test(String(message || ''))) return null;
3626
+ return configuredAgentId
3627
+ ? 'unset MARROW_AGENT_ID MARROW_FLEET_AGENT_ID && npx -y @getmarrow/install@latest activate'
3628
+ : 'npx -y @getmarrow/install@latest activate (with a key for one agent from your Marrow account saved in ~/.marrow/env, or exported as MARROW_API_KEY)';
3629
+ }
2980
3630
 
2981
3631
  // The agent Marrow resolved for this key: its single bound agent or the Free plan seat. An
2982
3632
  // unbound key has no single agent, and a locally derived id is never substituted for it.
@@ -3038,7 +3688,7 @@ async function runSelfTest(options) {
3038
3688
  return {
3039
3689
  skipped: true,
3040
3690
  reason: 'missing MARROW_API_KEY',
3041
- exact_fix: 'export MARROW_API_KEY=mrw_live_... && npx @getmarrow/install --repair',
3691
+ exact_fix: 'Create a key at https://getmarrow.ai/signup, save it as MARROW_API_KEY=your_key in the owner-only ~/.marrow/env, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env, then run npx -y @getmarrow/install@latest activate',
3042
3692
  mcp_tool_profile: initialProfile,
3043
3693
  };
3044
3694
  }
@@ -3224,7 +3874,11 @@ async function runSelfTest(options) {
3224
3874
  method: 'POST',
3225
3875
  headers,
3226
3876
  body: JSON.stringify({
3227
- event_id: `activation-${activationConfigFingerprint.slice(0, 32)}`,
3877
+ // One registration per agent: two agents with the same hook configuration (a second
3878
+ // agent installed on the same project) are two events, so the second is not refused
3879
+ // as a conflicting reuse of the first agent's event id. Reruns by one agent repeat
3880
+ // the same id.
3881
+ event_id: `activation-${crypto.createHash('sha256').update(`${activationConfigFingerprint}\0${activationAgentId}`).digest('hex').slice(0, 32)}`,
3228
3882
  event_type: 'activation_profile_registered',
3229
3883
  harness: options.activation.harness,
3230
3884
  agent_id: activationAgentId,
@@ -3291,7 +3945,8 @@ async function runSelfTest(options) {
3291
3945
  bound_agent_ids: serverIdentity.bound_agent_ids,
3292
3946
  activation_verified: activationVerified,
3293
3947
  activation_identity_unresolved: activationIdentityUnresolved,
3294
- activation_exact_fix: activationIdentityUnresolved ? ACTIVATION_UNBOUND_KEY_FIX : null,
3948
+ // An unbound key is fine: Marrow resolves the agent on each call, so there is nothing to fix.
3949
+ activation_exact_fix: null,
3295
3950
  activation_scope: options.activation ? 'server_self_test_only' : null,
3296
3951
  coverage_verified: false,
3297
3952
  passive_live: false,
@@ -3486,6 +4141,16 @@ function printReport(report, sink) {
3486
4141
  }
3487
4142
  }
3488
4143
 
4144
+ if (report.mcp_runtime) {
4145
+ const runtime = report.mcp_runtime;
4146
+ out('\nMCP local runtime:\n');
4147
+ out(`- hooks start: ${runtime.hooks_start === 'local_runtime' ? `the verified local copy of @getmarrow/mcp@${runtime.version} (${runtime.path})` : 'through npx'}\n`);
4148
+ out(`- state: ${runtime.state}${runtime.reason ? ` (${runtime.reason})` : ''}\n`);
4149
+ if (runtime.removed_versions?.length) out(`- removed older versions: ${runtime.removed_versions.join(', ')}\n`);
4150
+ }
4151
+ if (report.owner_key_storage) {
4152
+ 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`);
4153
+ }
3489
4154
  out('\nLocal session loop guard:\n');
3490
4155
  out(`- configured: ${report.loop_guard_configured ? 'yes' : 'no'}\n`);
3491
4156
  out(`- isolated self-test passed: ${report.loop_guard_self_tested ? 'yes' : 'no'}\n`);
@@ -3744,6 +4409,62 @@ function hermesWiringReport(detection, changes, options, planMode) {
3744
4409
  };
3745
4410
  }
3746
4411
 
4412
+ // The local MCP runtime for the target version: installed (or kept) on a write run of the
4413
+ // command, otherwise only an existing verified copy is used. `mcpLocalRuntime: false` (or
4414
+ // MARROW_LOCAL_RUNTIME=0, --no-local-runtime) leaves every hook of this project on npx.
4415
+ function resolveMcpRuntime(detection, options, mcpTarget) {
4416
+ const home = options.home || detection.home;
4417
+ if (options.mcpLocalRuntime === false) return { state: 'disabled' };
4418
+ const version = mcpTarget.version;
4419
+ const integrity = mcpTarget.integrity || (version === MCP_ADAPTER_VERSION ? MCP_ADAPTER_INTEGRITY : null);
4420
+ if (!integrity) return { state: 'skipped', reason: 'no_verified_integrity', version };
4421
+ const install = options.mcpLocalRuntime === true && options.yes && !options.dryRun && !options.doctor && options.maintenance !== true;
4422
+ if (install) {
4423
+ return ensureMcpRuntime({
4424
+ home,
4425
+ version,
4426
+ integrity,
4427
+ sdk: { version: SDK_ADAPTER_VERSION, integrity: SDK_ADAPTER_INTEGRITY },
4428
+ ...(typeof options.mcpRuntimeInstall === 'function' ? { npmInstall: options.mcpRuntimeInstall } : {}),
4429
+ ...(options.mcpRuntimeNode ? { nodePath: options.mcpRuntimeNode } : {}),
4430
+ // One line on stderr before npm runs, so the wait is never silent (stdout stays clean
4431
+ // for --json).
4432
+ onProgress: typeof options.mcpRuntimeProgress === 'function' ? options.mcpRuntimeProgress : (text) => process.stderr.write(text),
4433
+ });
4434
+ }
4435
+ return verifyMcpRuntime(home, version, integrity) || { state: 'absent', version };
4436
+ }
4437
+
4438
+ // Whether this project's managed hooks start the local runtime now, as its install chose.
4439
+ function projectHooksUseLocalRuntime(detection) {
4440
+ const paths = detection.paths || {};
4441
+ return [paths.claudeSettings, paths.codexHooks, paths.cursorHooks, paths.windsurfHooks, paths.geminiSettings]
4442
+ .some((filePath) => filePath && safeRead(filePath).includes('/.marrow/runtime/mcp/'));
4443
+ }
4444
+
4445
+ // The controller's maintenance rewrites managed hooks for the pinned MCP and keeps each project
4446
+ // in the form its install chose: on the verified local runtime for that version when the
4447
+ // project's hooks start it (verify only: maintenance never installs), on npx otherwise. A
4448
+ // project installed with --no-local-runtime stays on npx even when another project's install
4449
+ // put a runtime on this machine.
4450
+ function maintenanceMcpRuntime(detection, options = {}) {
4451
+ if (options.mcpLocalRuntime === false || !projectHooksUseLocalRuntime(detection)) return null;
4452
+ const runtime = resolveMcpRuntime(detection, { ...options, maintenance: true, yes: false }, executableMcpTarget(options));
4453
+ return runtime.version && ['present', 'installed', 'verified'].includes(runtime.state) ? runtime : null;
4454
+ }
4455
+
4456
+ function mcpRuntimeReport(runtime) {
4457
+ if (!runtime) return null;
4458
+ return {
4459
+ state: runtime.state,
4460
+ version: runtime.version || null,
4461
+ path: runtime.directory || null,
4462
+ ...(runtime.reason ? { reason: runtime.reason } : {}),
4463
+ ...(runtime.removed_versions?.length ? { removed_versions: runtime.removed_versions } : {}),
4464
+ hooks_start: runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state) ? 'local_runtime' : 'npx',
4465
+ };
4466
+ }
4467
+
3747
4468
  function localControllerAgentId(root, client, configuredAgentId = '') {
3748
4469
  return String(configuredAgentId || '').trim() || stableAgentId(root, client);
3749
4470
  }
@@ -3790,6 +4511,11 @@ async function install(options) {
3790
4511
  });
3791
4512
  options.mcpTarget = mcpTarget;
3792
4513
  options.mcpTargetVersion = mcpTarget.version;
4514
+ // A maintenance pass keeps the project's hooks in the form its install chose.
4515
+ const mcpRuntime = options.maintenance === true
4516
+ ? maintenanceMcpRuntime(detection, options) || { state: options.mcpLocalRuntime === false ? 'disabled' : 'absent', version: mcpTarget.version }
4517
+ : resolveMcpRuntime(detection, options, mcpTarget);
4518
+ options.mcpRuntime = mcpRuntime.version && ['present', 'installed', 'verified'].includes(mcpRuntime.state) ? mcpRuntime : null;
3793
4519
  const plan = buildPlan(detection, options);
3794
4520
  const writeMode = options.doctor ? 'doctor' : options.dryRun ? 'dry-run' : options.repair ? 'repair' : options.yes ? 'write' : 'dry-run';
3795
4521
  const changes = applyPlan(plan, options);
@@ -3839,7 +4565,7 @@ async function install(options) {
3839
4565
  intervention_verified: false,
3840
4566
  closure_verified: false,
3841
4567
  } : null;
3842
- const sdkDependency = inspectSdkDependency(detection);
4568
+ const sdkDependency = inspectSdkDependency(detection, { sdkMode: plan.mode === 'sdk' || plan.mode === 'both' });
3843
4569
  const envHints = options.apiKey ? [] : findLikelyEnvFiles(detection);
3844
4570
  const mcpProcesses = inspectMcpProcesses({
3845
4571
  commands: options.processCommands,
@@ -4049,6 +4775,7 @@ async function install(options) {
4049
4775
  harnessReload,
4050
4776
  firstCapture: firstCapturePath(detection),
4051
4777
  hermes,
4778
+ mcp_runtime: mcpRuntimeReport(mcpRuntime),
4052
4779
  changes,
4053
4780
  doctor: {
4054
4781
  active: Boolean(!selfTest.skipped && selfTest.active),
@@ -4099,36 +4826,48 @@ async function install(options) {
4099
4826
  };
4100
4827
  }
4101
4828
 
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;
4829
+ function activationFailureFix(message, configuredAgentId = '') {
4830
+ return identityRefusalFix(message, configuredAgentId) || INSTALLER_DOCTOR_COMMAND;
4107
4831
  }
4108
4832
 
4109
4833
  function installCommandFor(options) {
4110
- return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest';
4834
+ return options.update ? INSTALLER_UPDATE_COMMAND : 'npx -y @getmarrow/install@latest activate';
4835
+ }
4836
+
4837
+ // Static text: it never echoes a key, a command line or a path. The stored-file form comes first
4838
+ // because a key typed on a command line lands in shell history.
4839
+ function missingKeyMessage(options) {
4840
+ const command = installCommandFor(options);
4841
+ return `Marrow needs your API key. Nothing was written. Create a key at https://getmarrow.ai/signup, save it as MARROW_API_KEY=your_key in the owner-only file ~/.marrow/env, then run chmod 700 ~/.marrow && chmod 600 ~/.marrow/env, then run: ${command} (fallback: MARROW_API_KEY=your_key ${command})`;
4111
4842
  }
4112
4843
 
4113
4844
  // One summary for the one-command paths (first install and update). The full report goes to a
4114
4845
  // private log file. Nothing here prints the API key.
4846
+ // One verdict for the self-test, used by the summary and by key storage: it passed only when it
4847
+ // ran, raised no error, found the account active and Marrow committed the closure.
4848
+ function selfTestVerdict(selfTest = {}) {
4849
+ const untrusted = !selfTest.skipped && !selfTest.error && Boolean(selfTest.active) && selfTest.decision_committed !== true;
4850
+ const failed = !selfTest.skipped && (Boolean(selfTest.error) || !selfTest.active || untrusted);
4851
+ return { untrusted, failed, passed: !selfTest.skipped && !failed };
4852
+ }
4853
+
4115
4854
  function installSummaryLines(report, options) {
4116
4855
  const selfTest = report.selfTest || {};
4117
4856
  const activation = report.activation || {};
4118
4857
  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);
4858
+ const { untrusted, failed: selfTestFailed } = selfTestVerdict(selfTest);
4121
4859
  if (selfTest.skipped) {
4122
4860
  lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: configuration updated; self-test skipped (${selfTest.reason}).${selfTest.exact_fix ? ` Fix: ${selfTest.exact_fix}` : ''}`);
4123
4861
  } else if (untrusted) {
4124
4862
  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
4863
  } else if (selfTestFailed) {
4126
- const fix = report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
4864
+ const fix = identityRefusalFix(selfTest.error, activation.agent_id_source === 'configured' ? activation.agent_id : '')
4865
+ || report.doctor?.recommendedFix || INSTALLER_DOCTOR_COMMAND;
4127
4866
  lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: self-test failed: ${selfTest.error || 'Marrow reported this account as inactive'}. Fix: ${fix}`);
4128
4867
  } else {
4129
4868
  const agent = activation.agent_id
4130
4869
  ? `; agent ${activation.agent_id} (${activation.agent_id_source === 'configured' ? 'configured' : 'resolved by Marrow'})`
4131
- : '; this key is not bound to one agent';
4870
+ : '';
4132
4871
  lines.push(`Marrow ${INSTALLER_ADAPTER_VERSION}: healthy. Self-test decision ${selfTest.decision_id} committed${agent}.`);
4133
4872
  }
4134
4873
  if (activation.exact_fix) lines.push(`Activation: ${activation.exact_fix}`);
@@ -4155,6 +4894,14 @@ function installSummaryLines(report, options) {
4155
4894
  } else if (hermes.state === 'refused' || hermes.state === 'config_not_found' || hermes.state === 'preserved_unverified_ahead') {
4156
4895
  lines.push(`Hermes: ${hermes.config_path || 'config.yaml'} was not changed. The exact block to add is in the full report.`);
4157
4896
  }
4897
+ const ownerKey = report.owner_key_storage || {};
4898
+ if (ownerKey.state === 'written') {
4899
+ 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.`);
4900
+ } else if (ownerKey.state === 'different_key_present') {
4901
+ 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.`);
4902
+ } else if (ownerKey.state && !['present', 'no_key'].includes(ownerKey.state)) {
4903
+ 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).`);
4904
+ }
4158
4905
  if (hermes.owner_key_storage?.state === 'written') {
4159
4906
  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
4907
  } else if (hermes.owner_key_storage?.state === 'different_key_present') {
@@ -4190,7 +4937,16 @@ async function runCli(argv) {
4190
4937
  process.stdout.write(usage());
4191
4938
  return;
4192
4939
  }
4940
+ if (options.uninstall) {
4941
+ const report = await uninstall(options);
4942
+ if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
4943
+ else printUninstallReport(report);
4944
+ return;
4945
+ }
4946
+ // An empty or whitespace-only key from any source (environment, --key, owner file) is missing.
4947
+ options.apiKey = String(options.apiKey || '').trim();
4193
4948
  const stored = readOwnerApiKey(options.home);
4949
+ stored.apiKey = String(stored.apiKey || '').trim();
4194
4950
  const keyInfo = { source: options.apiKey ? 'environment' : null, path: null, owner_files_disagree: stored.conflict };
4195
4951
  if (!options.apiKey && stored.apiKey) {
4196
4952
  options.apiKey = stored.apiKey;
@@ -4198,7 +4954,11 @@ async function runCli(argv) {
4198
4954
  keyInfo.path = stored.source;
4199
4955
  }
4200
4956
  const oneCommand = Boolean((options.activate || options.update) && !options.dryRun && !options.doctor);
4201
- if (oneCommand && !options.apiKey) {
4957
+ // Any write run without a key stops here, before anything is written: hooks installed with no
4958
+ // key would deny protected actions on the next harness start. Previews, doctor and uninstall
4959
+ // are not write runs.
4960
+ const writeRun = Boolean(options.yes && !options.dryRun && !options.doctor);
4961
+ if ((oneCommand || writeRun) && !options.apiKey) {
4202
4962
  // An update aimed at an unmanaged directory is refused first; it needs no key to answer.
4203
4963
  if (options.update) {
4204
4964
  assertUpdateTargetsManagedRoot(detectEnvironment(options.cwd, {
@@ -4207,7 +4967,7 @@ async function runCli(argv) {
4207
4967
  USERPROFILE: options.home || options.cwd,
4208
4968
  }));
4209
4969
  }
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`);
4970
+ process.stdout.write(`${missingKeyMessage(options)}\n`);
4211
4971
  process.exitCode = 2;
4212
4972
  return;
4213
4973
  }
@@ -4217,10 +4977,23 @@ async function runCli(argv) {
4217
4977
  } catch (error) {
4218
4978
  const message = error instanceof Error ? error.message : String(error);
4219
4979
  if (!oneCommand || options.json || !/^Marrow activation failed/.test(message)) throw error;
4220
- process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message)}\n`);
4980
+ process.stderr.write(`Marrow ${INSTALLER_ADAPTER_VERSION}: ${message}. Fix: ${activationFailureFix(message, String(options.agentId || '').trim())}\n`);
4221
4981
  process.exitCode = 1;
4222
4982
  return;
4223
4983
  }
4984
+ // Claude Code opened from the desktop app, an IDE or a new terminal has no MARROW_API_KEY.
4985
+ // The MCP server and hooks read ~/.marrow/env, so a first install stores a key that came
4986
+ // from the environment there (owner-only), once the self-test passed by the same verdict the
4987
+ // summary prints (never on an untrusted closure), with --json and --verbose too.
4988
+ const keyWorked = report.selfTest ? selfTestVerdict(report.selfTest).passed : false;
4989
+ if (options.activate && !options.update && keyInfo.source === 'environment' && !options.keyFromArg && keyWorked) {
4990
+ try {
4991
+ const stored = ensureOwnerApiKey(options.home, options.apiKey);
4992
+ report.owner_key_storage = { state: stored.state, path: stored.path };
4993
+ } catch {
4994
+ report.owner_key_storage = { state: 'not_written', path: null };
4995
+ }
4996
+ }
4224
4997
  if (options.json) {
4225
4998
  process.stdout.write(JSON.stringify(report, null, 2) + '\n');
4226
4999
  return;
@@ -4246,6 +5019,8 @@ async function runCli(argv) {
4246
5019
  }
4247
5020
 
4248
5021
  module.exports = {
5022
+ argumentLabel,
5023
+ maintenanceMcpRuntime,
4249
5024
  parseArgs,
4250
5025
  detectEnvironment,
4251
5026
  buildPlan,
@@ -4267,6 +5042,13 @@ module.exports = {
4267
5042
  activationProfile,
4268
5043
  claudeNativeHookFingerprint,
4269
5044
  codexNativeHookFingerprint,
5045
+ hostApprovalHooksSupported,
5046
+ uninstall,
5047
+ printUninstallReport,
5048
+ CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER,
5049
+ GEMINI_PRE_ACTION_GUARD_COMMAND,
5050
+ GEMINI_CONTEXT_HOOK_COMMAND,
5051
+ MCP_PERMISSION_REQUEST_HOOK_COMMAND,
4270
5052
  cursorNativeHookFingerprint,
4271
5053
  clineNativeHookFingerprint,
4272
5054
  windsurfNativeHookFingerprint,