@getmarrow/install 0.1.69 → 0.1.70

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/README.md CHANGED
@@ -103,15 +103,23 @@ Persistent controller lifecycle is currently Linux-only. On macOS or Windows, ac
103
103
 
104
104
  The controller is not a boot service. After a host restart, or any exit that skips its shutdown handler, doctor reports it as `stale` until the next install, update, or `controller ensure`. Local control can be inspected and changed with `npx @getmarrow/install control status|disable --yes|enable`. While local control is disabled, the controller is not started, and doctor reports a stopped or stale controller as not required instead of recommending `controller ensure`; missing state means enabled, and an explicit owner disable is preserved. Unsafe controller state or an unverified or unresponsive controller process keeps its exact fix.
105
105
 
106
- ## What's New in v0.1.69
106
+ ## Key handling at install
107
107
 
108
- v0.1.69 installs MCP `3.9.101`. In short (the full entry is in the [CHANGELOG](https://github.com/getmarrow/marrow-install/blob/master/CHANGELOG.md)):
108
+ These behaviors shipped in v0.1.69 and still apply.
109
109
 
110
- - **No key, no half install.** With no API key, `activate` and every `--yes` run now stop, write nothing and print how to get a key. Before, they wrote hooks that blocked every commit.
111
- - **A blank key no longer hides a saved one.** An empty `MARROW_API_KEY` value no longer hides a valid key saved in `~/.marrow/env`.
110
+ - **No key, no half install.** With no API key, `activate` and every `--yes` run stop, write nothing and print how to get a key.
111
+ - **A blank key does not hide a saved one.** An empty `MARROW_API_KEY` value does not hide a valid key saved in `~/.marrow/env`.
112
112
  - **`MARROW_KEY` works on its own.** It is used when `MARROW_API_KEY` is absent.
113
- - **Key-file steps.** They now include `chmod 700 ~/.marrow` as well as `chmod 600 ~/.marrow/env`. See [Get started](#get-started).
114
- - **MCP 3.9.101.** The installer pins MCP `3.9.101`.
113
+ - **Key-file steps.** They include `chmod 700 ~/.marrow` as well as `chmod 600 ~/.marrow/env`. See [Get started](#get-started).
114
+
115
+ ## What's New in v0.1.70
116
+
117
+ v0.1.70 installs MCP `3.9.102`. In short (the full entry is in the [CHANGELOG](https://github.com/getmarrow/marrow-install/blob/master/CHANGELOG.md)):
118
+
119
+ - **Own-tool names are exact.** Hook matchers and the Cursor guard skip the gate only for Marrow's registered tools on server `marrow`, by exact name. Look-alike servers or names go through the gate. Re-run `update` to refresh hooks written with the old matcher. See [Hooks the installer writes](#hooks-the-installer-writes).
120
+ - **Hooks run only the verified local MCP runtime.** They no longer start `npx`; when the runtime is missing, they heal it from the pinned, integrity-checked package, and hold if they cannot. When npm or the network is down, pre-action hooks warn and let the action run; an integrity mismatch or any other failure holds. See [Local MCP runtime](#local-mcp-runtime).
121
+ - **Fallback npm install protects your npm login.** It sends npm auth only when the network settings come from your own npm config, and installs exactly the pinned MCP and SDK.
122
+ - **MCP 3.9.102.** A cloned project's `.env` can no longer point the MCP server at another account or server.
115
123
 
116
124
  Earlier release notes are in the [CHANGELOG](https://github.com/getmarrow/marrow-install/blob/master/CHANGELOG.md).
117
125
 
@@ -267,12 +275,12 @@ Never connect an agent to the email inbox that receives Marrow approval mail. An
267
275
 
268
276
  ### Hooks the installer writes
269
277
 
270
- These hooks are written with MCP `3.9.99` and later (`MCP_HOST_APPROVAL_HOOKS_SINCE` in `src/pins.js`). v0.1.69 installs MCP `3.9.101`, so `install` and `update` write them; with an older MCP the earlier layout stays. `update` also uses a registry-verified MCP at or above that version.
278
+ These hooks are written with MCP `3.9.99` and later (`MCP_HOST_APPROVAL_HOOKS_SINCE` in `src/pins.js`). v0.1.70 installs MCP `3.9.102`, so `install` and `update` write them; with an older MCP the earlier layout stays. `update` also uses a registry-verified MCP at or above that version.
271
279
 
272
280
  | Host | Hooks for approvals | How the operator answers |
273
281
  | --- | --- | --- |
274
282
  | Claude Code | `PermissionRequest` → `claude-permission-request-hook` and `PostToolBatch` → `claude-hook`, both `async: true`, next to the existing hooks; the same entries `marrow-mcp setup` writes | In Claude Code's own permission dialog. The pass-through marker never answers or delays the dialog; a permission rejection is recorded as a decline, an interruption never is |
275
- | Cursor | `beforeShellExecution` → `cursor-pre-action-hook` and `beforeMCPExecution` → the same entrypoint through a small guard, both `failClosed: true` with a 15-second timeout; `afterShellExecution` and `afterMCPExecution` → `cursor-hook`; `sessionStart` → `cursor-session-hook`; `beforeSubmitPrompt` → `cursor-context-hook`. Shell calls leave the `preToolUse` matcher. MCP calls stay in it, because cloud agents never run `beforeMCPExecution`: there `preToolUse` holds them, and in a local interactive session it lets `beforeMCPExecution` ask. The guard answers Marrow's own `marrow_*` tools at once, as the old matcher exempted them | Cursor's own prompt for shell and MCP calls in a local interactive session; the typed reply the hook shows the user (`marrow approve CODE`) for its other tools there. Background and cloud agents hold quietly |
283
+ | Cursor | `beforeShellExecution` → `cursor-pre-action-hook` and `beforeMCPExecution` → the same entrypoint through a small guard, both `failClosed: true` with a 15-second timeout; `afterShellExecution` and `afterMCPExecution` → `cursor-hook`; `sessionStart` → `cursor-session-hook`; `beforeSubmitPrompt` → `cursor-context-hook`. Shell calls leave the `preToolUse` matcher. MCP calls stay in it, because cloud agents never run `beforeMCPExecution`: there `preToolUse` holds them, and in a local interactive session it lets `beforeMCPExecution` ask. The guard answers at once only for Marrow's own tools: server exactly `marrow` and one of the 58 registered names (`src/marrow-own-tools.js`). Only the Claude Code matcher skips them, by the exact name `mcp__marrow__<name>`; Cursor (`MCP:<tool>`) and Gemini (`mcp_<server>_<tool>`) names do not prove the server, so every MCP call there reaches the hook, as does an unregistered `marrow_*` name, a case variant or a look-alike server | Cursor's own prompt for shell and MCP calls in a local interactive session; the typed reply the hook shows the user (`marrow approve CODE`) for its other tools there. Background and cloud agents hold quietly |
276
284
  | Gemini CLI | `BeforeAgent` → `gemini-context-hook`. The `BeforeTool` guard passes a denial that carries a code for the user only (`systemMessage`); any other output, a crash, or no answer within 4.5 seconds still blocks the call | The typed reply, in a local interactive session (`gemini`); `gemini -p` holds quietly |
277
285
  | Codex | `UserPromptSubmit` → `codex-context-hook` (unchanged). Codex is never configured to "ask", because Codex lets an asked call run | The typed reply, in a local interactive session; `codex exec` holds quietly |
278
286
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/install",
3
- "version": "0.1.69",
3
+ "version": "0.1.70",
4
4
  "description": "Universal installer and governed runner for Marrow agent fleets.",
5
5
  "bin": {
6
6
  "marrow-install": "bin/marrow-install.js"
@@ -3089,9 +3089,9 @@ async function sidecarOnly(parsed) {
3089
3089
  const detection = detectEnvironment(root, process.env);
3090
3090
  // Maintenance re-applies the controller's own configured agent id and base URL. A different
3091
3091
  // value found in managed MCP config is replaced unless the owner allowlisted it, and the
3092
- // divergence is reported. It never edits the owner's Hermes config. Hooks stay on the
3093
- // verified local MCP runtime when there is one (checked, never installed, here).
3094
- const mcpRuntime = maintenanceMcpRuntime(detection, { mcpLocalRuntime: process.env.MARROW_LOCAL_RUNTIME !== '0' });
3092
+ // divergence is reported. It never edits the owner's Hermes config. Hooks are written in
3093
+ // the local MCP runtime form (never npx); the copy is checked, never installed, here.
3094
+ const mcpRuntime = maintenanceMcpRuntime(detection);
3095
3095
  const plan = buildPlan(detection, {
3096
3096
  mode: managedMode,
3097
3097
  agentId: options.agentId,
package/src/installer.js CHANGED
@@ -20,11 +20,18 @@ const {
20
20
  planHermesMcpConfig,
21
21
  } = require('./hermes-config');
22
22
  const { ensureOwnerApiKey, readOwnerApiKey } = require('./owner-env');
23
+ const { buildOwnToolMatchers, cursorOwnToolGuardCondition } = require('./marrow-own-tools');
23
24
  const {
24
25
  delocalizeHookCommand,
26
+ delocalizeHookScriptText,
25
27
  ensureMcpRuntime,
28
+ hookTextUsesNpx,
29
+ localizeHookScriptText,
26
30
  localizeHookSettingsText,
27
31
  mapHookCommands,
32
+ mcpServerLaunch,
33
+ mcpServerLaunchVersion,
34
+ mcpServerUsesNpx,
28
35
  removeMcpRuntime,
29
36
  verifyMcpRuntime,
30
37
  } = require('./mcp-runtime');
@@ -231,7 +238,8 @@ function adapterProvenanceForMcpTarget(target) {
231
238
 
232
239
  function retargetMcpPackageSpec(value, version) {
233
240
  const target = compatibleMcpTargetVersion(version) ? version : MCP_ADAPTER_VERSION;
234
- return String(value).split(MCP_PACKAGE_SPEC).join(`@getmarrow/mcp@${target}`);
241
+ return String(value).split(MCP_PACKAGE_SPEC).join(`@getmarrow/mcp@${target}`)
242
+ .split(`/.marrow/runtime/mcp/${MCP_ADAPTER_VERSION}/`).join(`/.marrow/runtime/mcp/${target}/`);
235
243
  }
236
244
 
237
245
  function retargetMcpDowngradeRecommendation(value, targetVersion) {
@@ -280,8 +288,8 @@ const CURSOR_ACTION_RESULT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC}
280
288
  const CURSOR_SESSION_END_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-session-hook`;
281
289
  const CURSOR_CONTEXT_HOOK_COMMAND = `npx -y --package=${MCP_PACKAGE_SPEC} marrow-mcp cursor-context-hook`;
282
290
  // 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
291
+ // Marrow's own tools (server exactly "marrow", tool one of the registered names in
292
+ // marrow-own-tools.js; this event names the server) without starting npx. Every other call goes to the MCP entrypoint with its input
285
293
  // unchanged; a crash, a non-zero exit or output that is not a JSON object blocks (exit 2).
286
294
  const CURSOR_MCP_GUARD_FAILURE = 'Marrow governance adapter was unavailable; this MCP call is blocked.';
287
295
  const CURSOR_MCP_PRE_ACTION_GUARD_SOURCE = [
@@ -291,7 +299,7 @@ const CURSOR_MCP_PRE_ACTION_GUARD_SOURCE = [
291
299
  'process.stdin.on("error",fail);',
292
300
  'process.stdin.on("data",c=>{size+=c.length;if(size>16777216){over=true;return;}chunks.push(c);});',
293
301
  '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;}',
302
+ `if(ev&&typeof ev==="object"&&${cursorOwnToolGuardCondition()}){done=true;process.stdout.write("{\\"permission\\":\\"allow\\"}\\n");return;}`,
295
303
  'let child;try{',
296
304
  `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
305
  '}catch{fail();return;}',
@@ -387,21 +395,22 @@ const GROK_PRE_ACTION_GUARD_SOURCE = [
387
395
  'child.stdin.end(Buffer.concat(input));}catch{fail();}});',
388
396
  ].join('');
389
397
  const GROK_PRE_ACTION_HOOK_COMMAND = `node -e '${GROK_PRE_ACTION_GUARD_SOURCE}'`;
390
- const NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|Read|Glob|Grep|Search|WebSearch|Task|functions\\.(?!mcp__marrow__marrow_).*|mcp__(?!marrow__marrow_).*';
391
- const CODEX_NATIVE_HOOK_MATCHER = 'Bash|apply_patch|Edit|Write|MultiEdit|mcp__(?!marrow__marrow_).*|functions\\.(?!marrow_).*';
392
- const CURSOR_NATIVE_HOOK_MATCHER = 'Shell|Write|Delete|Task|Read|Glob|Grep|Search|WebSearch|List|MCP:(?!marrow(?:_.*|:marrow_.*)$).*';
398
+ const OWN_TOOL_MATCHERS = buildOwnToolMatchers();
399
+ const NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.claude;
400
+ const CODEX_NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.codex;
401
+ const CURSOR_NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.cursor;
393
402
  // Host approvals: Cursor enforces an "ask" only on beforeShellExecution and beforeMCPExecution,
394
403
  // never on preToolUse. Shell calls move to beforeShellExecution (it runs in cloud agents too).
395
404
  // MCP calls stay in preToolUse as well, because cloud agents never run beforeMCPExecution: the
396
405
  // MCP hook defers there to beforeMCPExecution only in a local interactive session (sessionStart
397
406
  // 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_.*)$).*';
407
+ const CURSOR_PRE_TOOL_USE_HOST_APPROVAL_MATCHER = OWN_TOOL_MATCHERS.cursorPreToolUseHostApproval;
399
408
  // Cursor's gating hooks are failClosed: a cold npx start on a slow network must not block a
400
409
  // call, so they get room beyond the MCP hook's own 4-second budget.
401
410
  const CURSOR_GATE_HOOK_TIMEOUT_SECONDS = 15;
402
411
  const CURSOR_EXECUTION_PRE_EVENTS = Object.freeze(['beforeShellExecution', 'beforeMCPExecution']);
403
412
  const CURSOR_EXECUTION_POST_EVENTS = Object.freeze(['afterShellExecution', 'afterMCPExecution']);
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})$';
413
+ const GEMINI_NATIVE_HOOK_MATCHER = OWN_TOOL_MATCHERS.gemini;
405
414
  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';
406
415
  const CODEX_HOOK_TIMEOUT_SECONDS = 5;
407
416
  const CODEX_SESSION_TIMEOUT_SECONDS = 3;
@@ -644,6 +653,9 @@ function isMcpProcessCommand(command) {
644
653
  const executable = path.basename(args[0]);
645
654
  if (new Set(['bash', 'bwrap', 'dash', 'fish', 'sh', 'zsh']).has(executable)) return false;
646
655
  if (executable === 'marrow-mcp') return true;
656
+ // The verified local runtime: node on the inline loader with the pinned package spec.
657
+ if (executable === 'node' && args[1] === '-e' && args.includes('marrow-mcp')
658
+ && args.some((arg) => /^--package=@getmarrow\/mcp@\d+\.\d+\.\d+$/.test(arg))) return true;
647
659
 
648
660
  if (executable === 'node'
649
661
  && args[1]
@@ -862,9 +874,11 @@ function parseArgs(argv, env = process.env) {
862
874
  json: false,
863
875
  activate: false,
864
876
  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',
877
+ // Hooks start only the verified local copy of the pinned MCP (installed on write runs); they
878
+ // never start npx, which resolves the package from the project directory first. The old
879
+ // opt-out (MARROW_LOCAL_RUNTIME=0, --no-local-runtime) is accepted and ignored.
880
+ mcpLocalRuntime: true,
881
+ ...(env.MARROW_LOCAL_RUNTIME === '0' ? { localRuntimeSwitchIgnored: true } : {}),
868
882
  };
869
883
  let explicitOperation = false;
870
884
 
@@ -905,6 +919,9 @@ function parseArgs(argv, env = process.env) {
905
919
  explicitOperation = true;
906
920
  options.uninstall = true;
907
921
  }
922
+ else if (arg === '--all') {
923
+ options.all = true;
924
+ }
908
925
  else if (arg === '--doctor' || arg === 'doctor' || arg === 'check') {
909
926
  explicitOperation = true;
910
927
  options.doctor = true;
@@ -917,7 +934,7 @@ function parseArgs(argv, env = process.env) {
917
934
  options.selfTestExplicitlyDisabled = true;
918
935
  }
919
936
  else if (arg === '--no-controller') options.controller = false;
920
- else if (arg === '--no-local-runtime') options.mcpLocalRuntime = false;
937
+ else if (arg === '--no-local-runtime') options.localRuntimeSwitchIgnored = true;
921
938
  else if (arg === '--self-test') options.selfTest = true;
922
939
  else if (arg === '--cwd') options.cwd = path.resolve(optionValue() || options.cwd);
923
940
  else if (arg === '--mode') {
@@ -954,6 +971,9 @@ function parseArgs(argv, env = process.env) {
954
971
  }
955
972
  }
956
973
 
974
+ if (options.all && !options.uninstall) {
975
+ throw new Error('--all is only valid with uninstall');
976
+ }
957
977
  if (options.uninstall && (options.activate || options.repair || options.doctor)) {
958
978
  throw new Error('uninstall cannot be combined with activate, update, repair, or doctor');
959
979
  }
@@ -989,7 +1009,7 @@ function usage() {
989
1009
  npx @getmarrow/install doctor --self-test
990
1010
  npx @getmarrow/install --mcp --yes
991
1011
  npx @getmarrow/install --sdk --yes
992
- npx @getmarrow/install uninstall [--yes]
1012
+ npx @getmarrow/install uninstall [--yes] [--all]
993
1013
 
994
1014
  Options:
995
1015
  (no command) Detect, install, self-test, and start the supported persistent controller
@@ -1004,12 +1024,12 @@ Options:
1004
1024
  --base-url <url> Marrow API base URL
1005
1025
  --agent-id <id> Agent/fleet id for self-test headers
1006
1026
  --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
1027
+ --no-local-runtime Ignored: hooks always start the verified local MCP copy in ~/.marrow/runtime, never npx
1008
1028
  --no-self-test Skip API smoke/self-test
1009
1029
  --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.
1030
+ uninstall Preview removing only Marrow's own hooks, MCP server entries and instructions
1031
+ from this project; add --yes to remove them. Your own hooks and settings are
1032
+ kept, and so is the local MCP runtime other projects share; --all removes it.
1013
1033
 
1014
1034
  Get a key at https://getmarrow.ai/signup. Put it in the owner-only ~/.marrow/env as
1015
1035
  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
@@ -2190,19 +2210,25 @@ function clineHookContract(detection) {
2190
2210
  stage: 'pre_action',
2191
2211
  path: detection.paths.clinePreToolUseHook,
2192
2212
  label: 'Cline PreToolUse native hook',
2193
- content: clinePreToolUseHookSource(),
2213
+ content: localizeHookScriptText(clinePreToolUseHookSource(), MCP_ADAPTER_VERSION),
2214
+ source: clinePreToolUseHookSource(),
2215
+ legacyContent: clinePreToolUseHookSource(),
2194
2216
  },
2195
2217
  {
2196
2218
  stage: 'action_result',
2197
2219
  path: detection.paths.clinePostToolUseHook,
2198
2220
  label: 'Cline PostToolUse native hook',
2199
- content: clineTelemetryHookSource('cline-hook'),
2221
+ content: localizeHookScriptText(clineTelemetryHookSource('cline-hook'), MCP_ADAPTER_VERSION),
2222
+ source: clineTelemetryHookSource('cline-hook'),
2223
+ legacyContent: clineTelemetryHookSource('cline-hook'),
2200
2224
  },
2201
2225
  {
2202
2226
  stage: 'cancel_closeout',
2203
2227
  path: detection.paths.clineTaskCancelHook,
2204
2228
  label: 'Cline TaskCancel native hook',
2205
- content: clineTelemetryHookSource('cline-session-hook'),
2229
+ content: localizeHookScriptText(clineTelemetryHookSource('cline-session-hook'), MCP_ADAPTER_VERSION),
2230
+ source: clineTelemetryHookSource('cline-session-hook'),
2231
+ legacyContent: clineTelemetryHookSource('cline-session-hook'),
2206
2232
  },
2207
2233
  ];
2208
2234
  }
@@ -2348,9 +2374,7 @@ function activationProfile(detection, plan, changes, client) {
2348
2374
  const mcpConfigs = [detection.paths.mcpJson, detection.paths.cursorMcp]
2349
2375
  .map((filePath) => safeJsonObject(filePath));
2350
2376
  if (capabilityLevel === 'mcp' && mcpConfigs.some((config) => (
2351
- config?.mcpServers?.marrow?.command === 'npx'
2352
- && Array.isArray(config.mcpServers.marrow.args)
2353
- && config.mcpServers.marrow.args.join(' ') === `-y --package=@getmarrow/mcp@${mcpTargetVersion} marrow-mcp`
2377
+ mcpServerLaunchVersion(config?.mcpServers?.marrow) === mcpTargetVersion
2354
2378
  ))) observedHooks.push('mcp_tool_calls');
2355
2379
  const fingerprintMaterial = changes
2356
2380
  .filter((change) => change.applied || change.already_present)
@@ -2522,9 +2546,15 @@ function upsertMcpServerConfig(filePath, options = {}) {
2522
2546
  const env = { MARROW_BASE_URL: baseUrl };
2523
2547
  if (keptAgentId) env.MARROW_FLEET_AGENT_ID = keptAgentId;
2524
2548
  if (configuredProfile !== 'unset') env.MARROW_TOOL_PROFILE = configuredProfile;
2549
+ // The server starts the verified local runtime (it installs the pinned copy itself when it is
2550
+ // missing), never npx from the project directory: a copy planted there would otherwise become
2551
+ // the Marrow MCP server, whose own tools the hook matchers leave ungated.
2552
+ const serverVersion = compatibleMcpTargetVersion(options.mcpVersion) ? options.mcpVersion : MCP_ADAPTER_VERSION;
2525
2553
  servers.marrow = {
2526
- command: 'npx',
2527
- args: ['-y', `--package=${MCP_PACKAGE_SPEC}`, 'marrow-mcp'],
2554
+ ...mcpServerLaunch(serverVersion, {
2555
+ platform: options.hookPlatform || process.platform,
2556
+ ...(options.mcpIntegrity && serverVersion === options.mcpVersion ? { integrity: options.mcpIntegrity } : {}),
2557
+ }),
2528
2558
  env,
2529
2559
  };
2530
2560
  config.mcpServers = servers;
@@ -2827,15 +2857,25 @@ function buildPlan(detection, options) {
2827
2857
  write.identityDivergence = [];
2828
2858
  return retarget(upsertMcpServerConfig(target, {
2829
2859
  ...mcpConfigOptions,
2860
+ mcpVersion: mcpTargetVersion,
2861
+ mcpIntegrity: mcpTargetIntegrity,
2862
+ hookPlatform,
2830
2863
  onDivergence: (divergence) => write.identityDivergence.push(divergence),
2831
2864
  }));
2832
2865
  };
2833
2866
  return write;
2834
2867
  };
2835
2868
  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);
2869
+ // Hooks always start the verified local MCP runtime for this exact version, never npx: when
2870
+ // that copy is missing or changed, a pre-action hook holds the call until a repair installs it.
2871
+ const hookPlatform = options.hookPlatform || process.platform;
2872
+ const mcpTargetIntegrity = executableMcpTarget(options).integrity;
2873
+ // A Marrow command already in the file with the same configuration is kept byte for byte.
2874
+ const localize = (text, filePath) => localizeHookSettingsText(text, mcpTargetVersion, {
2875
+ platform: hookPlatform,
2876
+ integrity: mcpTargetIntegrity,
2877
+ previousText: filePath ? safeRead(filePath) : null,
2878
+ });
2839
2879
  const retarget = (value) => retargetMcpPackageSpec(value, mcpTargetVersion);
2840
2880
  // The host-approval hook layout only for an MCP that answers those hooks.
2841
2881
  const hostApprovals = hostApprovalHooksSupported(mcpTargetVersion);
@@ -2866,7 +2906,7 @@ function buildPlan(detection, options) {
2866
2906
  type: 'json-transform',
2867
2907
  path: detection.paths.claudeSettings,
2868
2908
  label: 'Claude Code MCP passive hooks',
2869
- transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals }))),
2909
+ transform: (filePath) => localize(retarget(upsertClaudeHooks(filePath, { hostApprovals })), filePath),
2870
2910
  });
2871
2911
  }
2872
2912
  if (detection.codex) {
@@ -2874,7 +2914,7 @@ function buildPlan(detection, options) {
2874
2914
  type: 'json-transform',
2875
2915
  path: detection.paths.codexHooks,
2876
2916
  label: 'Codex native hooks',
2877
- transform: (filePath) => localize(retarget(upsertCodexHooks(filePath))),
2917
+ transform: (filePath) => localize(retarget(upsertCodexHooks(filePath)), filePath),
2878
2918
  });
2879
2919
  }
2880
2920
  if (detection.cline) {
@@ -2883,7 +2923,9 @@ function buildPlan(detection, options) {
2883
2923
  type: 'owned-executable',
2884
2924
  path: hook.path,
2885
2925
  label: hook.label,
2886
- content: retarget(hook.content),
2926
+ content: localizeHookScriptText(retarget(hook.source), mcpTargetVersion, { integrity: mcpTargetIntegrity }),
2927
+ // The npx script 0.1.68 and earlier wrote is Marrow's too, and is rewritten.
2928
+ legacyContent: retarget(hook.legacyContent),
2887
2929
  mode: 0o755,
2888
2930
  conflict_fix: 'Move or remove the existing owner-managed Cline hook after owner review, then run npx @getmarrow/install --repair.',
2889
2931
  });
@@ -2894,7 +2936,7 @@ function buildPlan(detection, options) {
2894
2936
  type: 'json-transform',
2895
2937
  path: detection.paths.windsurfHooks,
2896
2938
  label: 'Windsurf native hooks',
2897
- transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath))),
2939
+ transform: (filePath) => localize(retarget(upsertWindsurfHooks(filePath)), filePath),
2898
2940
  });
2899
2941
  }
2900
2942
  if (detection.gemini) {
@@ -2902,7 +2944,7 @@ function buildPlan(detection, options) {
2902
2944
  type: 'json-transform',
2903
2945
  path: detection.paths.geminiSettings,
2904
2946
  label: 'Gemini CLI native hooks',
2905
- transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals }))),
2947
+ transform: (filePath) => localize(retarget(upsertGeminiHooks(filePath, { hostApprovals })), filePath),
2906
2948
  });
2907
2949
  }
2908
2950
  if (detection.grok) {
@@ -2913,7 +2955,7 @@ function buildPlan(detection, options) {
2913
2955
  label: 'Grok native hooks',
2914
2956
  isManaged: managedGrokHooksFile,
2915
2957
  conflict_fix: 'Preserve or move the unmanaged owner Grok hook file after owner review, then rerun npx @getmarrow/install update.',
2916
- transform: (filePath) => localize(retarget(upsertGrokHooks(filePath))),
2958
+ transform: (filePath) => localize(retarget(upsertGrokHooks(filePath)), filePath),
2917
2959
  });
2918
2960
  }
2919
2961
  writes.push(mcpConfigWrite(detection.paths.mcpJson, 'Project MCP server config'));
@@ -2937,7 +2979,7 @@ function buildPlan(detection, options) {
2937
2979
  type: 'json-transform',
2938
2980
  path: detection.paths.cursorHooks,
2939
2981
  label: 'Cursor native hooks',
2940
- transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals }))),
2982
+ transform: (filePath) => localize(retarget(upsertCursorHooks(filePath, { hostApprovals })), filePath),
2941
2983
  });
2942
2984
  writes.push(mcpConfigWrite(detection.paths.cursorMcp, 'Cursor MCP server config'));
2943
2985
  }
@@ -3082,7 +3124,7 @@ function applyPlan(plan, options) {
3082
3124
  } else if (write.type === 'owned-executable') {
3083
3125
  // A file Marrow wrote for another pinned MCP version is still Marrow's and moves to this
3084
3126
  // pin; any other difference is the owner's edit and is never overwritten.
3085
- if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content])) {
3127
+ if (fileExists && before !== write.content && !sameMarrowFile(before, [write.content, write.legacyContent].filter(Boolean))) {
3086
3128
  after = before;
3087
3129
  hookConflict = true;
3088
3130
  } else {
@@ -3238,8 +3280,8 @@ function withoutMarrowMcpServer(config) {
3238
3280
  const entry = servers && typeof servers === 'object' && !Array.isArray(servers) ? servers.marrow : null;
3239
3281
  if (!entry) return { config, removed: 0, kept: 0, custom: false };
3240
3282
  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));
3283
+ const ours = Boolean(mcpServerLaunchVersion(entry)) || (entry.command === 'npx' && args.includes('marrow-mcp')
3284
+ && args.some((arg) => /^--package=@getmarrow\/mcp(?:@[^\s]+)?$/.test(arg)));
3243
3285
  if (!ours) return { config, removed: 0, kept: 1, custom: true };
3244
3286
  const nextServers = { ...servers };
3245
3287
  delete nextServers.marrow;
@@ -3260,7 +3302,8 @@ function withoutMarrowInstructionBlock(content) {
3260
3302
 
3261
3303
  // Marrow-written files compare equal once the pinned MCP version is normalized.
3262
3304
  function sameMarrowFile(actual, candidates) {
3263
- const normalize = (value) => String(value).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>');
3305
+ const normalize = (value) => delocalizeHookScriptText(String(value)).replace(/@getmarrow\/mcp@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g, '@getmarrow/mcp@<pinned>')
3306
+ .replace(/\/\.marrow\/runtime\/mcp\/\d+\.\d+\.\d+\//g, '/.marrow/runtime/mcp/<pinned>/');
3264
3307
  const normalized = normalize(actual);
3265
3308
  return candidates.some((candidate) => normalize(candidate) === normalized);
3266
3309
  }
@@ -3289,7 +3332,7 @@ function buildUninstallPlan(detection) {
3289
3332
  const instructionVariants = [false, true].map((hostApprovals) => passiveInstructions({ hostApprovals }).replace(/<!--[^>]+-->/g, '').trim() + '\n');
3290
3333
  steps.push({ kind: 'owned-file', path: detection.paths.cursorRules, label: 'Cursor Marrow rule', candidates: instructionVariants });
3291
3334
  for (const hook of clineHookContract(detection)) {
3292
- steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content] });
3335
+ steps.push({ kind: 'owned-file', path: hook.path, label: hook.label, candidates: [hook.content, hook.legacyContent] });
3293
3336
  }
3294
3337
  return steps;
3295
3338
  }
@@ -3406,8 +3449,12 @@ async function uninstall(options = {}) {
3406
3449
  hermesEntry = false;
3407
3450
  }
3408
3451
  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 });
3452
+ // The local MCP runtime (~/.marrow/runtime) is shared by every project on this machine, so a
3453
+ // project uninstall keeps it. `uninstall --all` removes it too; hooks left in other projects
3454
+ // then install it again on their next start (or hold when they cannot).
3455
+ const runtime = options.all === true
3456
+ ? removeMcpRuntime(options.home || detection.home, { dryRun: !apply })
3457
+ : { removed: false, would_remove: false, kept: true, path: path.join(options.home || detection.home, '.marrow', 'runtime') };
3411
3458
  const removedEntries = changes.reduce((sum, change) => sum + change.removed_entries, 0);
3412
3459
  return {
3413
3460
  uninstall: {
@@ -3418,6 +3465,7 @@ async function uninstall(options = {}) {
3418
3465
  changes,
3419
3466
  kept,
3420
3467
  runtime: runtime.removed || runtime.would_remove ? runtime : null,
3468
+ runtime_kept: runtime.kept === true && fs.existsSync(runtime.path) ? runtime.path : null,
3421
3469
  removed_entries: removedEntries,
3422
3470
  next_step: apply
3423
3471
  ? 'Restart the agent hosts so they stop loading the removed hooks and MCP server.'
@@ -3440,7 +3488,8 @@ function printUninstallReport(report, sink = (text) => process.stdout.write(text
3440
3488
  else if (change.action === 'kept' || change.action === 'skipped') sink(`- ${where(change.path)}: left unchanged (${change.reason}).\n`);
3441
3489
  }
3442
3490
  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`);
3491
+ if (result.runtime) sink(`- ${result.runtime.path}: ${result.applied ? 'removed' : 'would remove'} Marrow's local MCP runtime (Marrow hooks left in other projects install it again on their next start).\n`);
3492
+ else if (result.runtime_kept) sink(`- ${result.runtime_kept}: kept; other projects on this machine may use Marrow's local MCP runtime. Add --all to remove it too.\n`);
3444
3493
  if (result.controller?.stopped) sink(`Stopped ${result.controller.stopped} Marrow controller${result.controller.stopped === 1 ? '' : 's'} for this project.\n`);
3445
3494
  sink(`${result.next_step}\n`);
3446
3495
  }
@@ -4144,7 +4193,10 @@ function printReport(report, sink) {
4144
4193
  if (report.mcp_runtime) {
4145
4194
  const runtime = report.mcp_runtime;
4146
4195
  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`);
4196
+ out(`- hooks start: ${runtime.hooks_start === 'local_runtime' ? `the verified local copy of @getmarrow/mcp@${runtime.version} (${runtime.path})` : 'no verified local copy yet: Marrow hooks install the pinned copy on their next start; if npm or the registry cannot be reached they warn and let the action run, and any other failure holds it'}\n`);
4197
+ for (const filePath of runtime.legacy_npx_hooks || []) out(`- needs repair: ${filePath} still starts @getmarrow/mcp through npx\n`);
4198
+ if (runtime.needs_repair) out('- repair: run npx -y @getmarrow/install@latest --repair (from a terminal outside the project, with --cwd <project folder>)\n');
4199
+ if (runtime.local_runtime_switch === 'ignored') out('- note: MARROW_LOCAL_RUNTIME=0 / --no-local-runtime are ignored; hooks never start npx\n');
4148
4200
  out(`- state: ${runtime.state}${runtime.reason ? ` (${runtime.reason})` : ''}\n`);
4149
4201
  if (runtime.removed_versions?.length) out(`- removed older versions: ${runtime.removed_versions.join(', ')}\n`);
4150
4202
  }
@@ -4410,11 +4462,9 @@ function hermesWiringReport(detection, changes, options, planMode) {
4410
4462
  }
4411
4463
 
4412
4464
  // 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.
4465
+ // command, otherwise only an existing verified copy is used. Hooks start nothing else.
4415
4466
  function resolveMcpRuntime(detection, options, mcpTarget) {
4416
4467
  const home = options.home || detection.home;
4417
- if (options.mcpLocalRuntime === false) return { state: 'disabled' };
4418
4468
  const version = mcpTarget.version;
4419
4469
  const integrity = mcpTarget.integrity || (version === MCP_ADAPTER_VERSION ? MCP_ADAPTER_INTEGRITY : null);
4420
4470
  if (!integrity) return { state: 'skipped', reason: 'no_verified_integrity', version };
@@ -4425,6 +4475,7 @@ function resolveMcpRuntime(detection, options, mcpTarget) {
4425
4475
  version,
4426
4476
  integrity,
4427
4477
  sdk: { version: SDK_ADAPTER_VERSION, integrity: SDK_ADAPTER_INTEGRITY },
4478
+ ...(options.hookPlatform ? { platform: options.hookPlatform } : {}),
4428
4479
  ...(typeof options.mcpRuntimeInstall === 'function' ? { npmInstall: options.mcpRuntimeInstall } : {}),
4429
4480
  ...(options.mcpRuntimeNode ? { nodePath: options.mcpRuntimeNode } : {}),
4430
4481
  // One line on stderr before npm runs, so the wait is never silent (stdout stays clean
@@ -4432,36 +4483,43 @@ function resolveMcpRuntime(detection, options, mcpTarget) {
4432
4483
  onProgress: typeof options.mcpRuntimeProgress === 'function' ? options.mcpRuntimeProgress : (text) => process.stderr.write(text),
4433
4484
  });
4434
4485
  }
4435
- return verifyMcpRuntime(home, version, integrity) || { state: 'absent', version };
4486
+ return verifyMcpRuntime(home, version, integrity, options.hookPlatform ? { platform: options.hookPlatform } : {}) || { state: 'absent', version };
4436
4487
  }
4437
4488
 
4438
- // Whether this project's managed hooks start the local runtime now, as its install chose.
4439
- function projectHooksUseLocalRuntime(detection) {
4489
+ // Managed hook files that still start @getmarrow/mcp through npx (written by 0.1.68 or earlier,
4490
+ // or by hand): each needs a repair, which rewrites it in the local runtime form.
4491
+ function legacyNpxHookFiles(detection) {
4440
4492
  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/'));
4493
+ return [paths.claudeSettings, paths.codexHooks, paths.cursorHooks, paths.windsurfHooks, paths.geminiSettings,
4494
+ paths.grokHooks, paths.clinePreToolUseHook, paths.clinePostToolUseHook, paths.clineTaskCancelHook]
4495
+ .filter((filePath, index, all) => filePath && all.indexOf(filePath) === index && hookTextUsesNpx(safeRead(filePath)))
4496
+ // The Marrow MCP server entry too (F-I1): npx there would start a copy planted in the project.
4497
+ .concat([paths.mcpJson, paths.cursorMcp].filter((filePath, index, all) => filePath && all.indexOf(filePath) === index
4498
+ && mcpServerUsesNpx(safeJsonObject(filePath))));
4443
4499
  }
4444
4500
 
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.
4501
+ // The controller's maintenance rewrites managed hooks for the pinned MCP, always in the local
4502
+ // runtime form; it only verifies the copy (maintenance never installs one).
4450
4503
  function maintenanceMcpRuntime(detection, options = {}) {
4451
- if (options.mcpLocalRuntime === false || !projectHooksUseLocalRuntime(detection)) return null;
4452
4504
  const runtime = resolveMcpRuntime(detection, { ...options, maintenance: true, yes: false }, executableMcpTarget(options));
4453
4505
  return runtime.version && ['present', 'installed', 'verified'].includes(runtime.state) ? runtime : null;
4454
4506
  }
4455
4507
 
4456
- function mcpRuntimeReport(runtime) {
4508
+ function mcpRuntimeReport(runtime, { legacyNpxHooks = [], switchIgnored = false } = {}) {
4457
4509
  if (!runtime) return null;
4510
+ const verified = Boolean(runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state));
4458
4511
  return {
4459
4512
  state: runtime.state,
4460
4513
  version: runtime.version || null,
4461
4514
  path: runtime.directory || null,
4462
4515
  ...(runtime.reason ? { reason: runtime.reason } : {}),
4463
4516
  ...(runtime.removed_versions?.length ? { removed_versions: runtime.removed_versions } : {}),
4464
- hooks_start: runtime.directory && ['present', 'installed', 'verified'].includes(runtime.state) ? 'local_runtime' : 'npx',
4517
+ // Hooks never start npx: without a verified copy they install the pinned one themselves on
4518
+ // their next start, and pre-action hooks hold the call only when that fails.
4519
+ hooks_start: verified ? 'local_runtime' : 'self_heal_on_next_start',
4520
+ ...(legacyNpxHooks.length ? { legacy_npx_hooks: legacyNpxHooks } : {}),
4521
+ needs_repair: !verified || legacyNpxHooks.length > 0,
4522
+ ...(switchIgnored ? { local_runtime_switch: 'ignored' } : {}),
4465
4523
  };
4466
4524
  }
4467
4525
 
@@ -4513,7 +4571,7 @@ async function install(options) {
4513
4571
  options.mcpTargetVersion = mcpTarget.version;
4514
4572
  // A maintenance pass keeps the project's hooks in the form its install chose.
4515
4573
  const mcpRuntime = options.maintenance === true
4516
- ? maintenanceMcpRuntime(detection, options) || { state: options.mcpLocalRuntime === false ? 'disabled' : 'absent', version: mcpTarget.version }
4574
+ ? maintenanceMcpRuntime(detection, options) || { state: 'absent', version: mcpTarget.version }
4517
4575
  : resolveMcpRuntime(detection, options, mcpTarget);
4518
4576
  options.mcpRuntime = mcpRuntime.version && ['present', 'installed', 'verified'].includes(mcpRuntime.state) ? mcpRuntime : null;
4519
4577
  const plan = buildPlan(detection, options);
@@ -4775,7 +4833,7 @@ async function install(options) {
4775
4833
  harnessReload,
4776
4834
  firstCapture: firstCapturePath(detection),
4777
4835
  hermes,
4778
- mcp_runtime: mcpRuntimeReport(mcpRuntime),
4836
+ mcp_runtime: mcpRuntimeReport(mcpRuntime, { legacyNpxHooks: legacyNpxHookFiles(detection), switchIgnored: options.localRuntimeSwitchIgnored === true }),
4779
4837
  changes,
4780
4838
  doctor: {
4781
4839
  active: Boolean(!selfTest.skipped && selfTest.active),