@getmarrow/install 0.1.36 → 0.1.37

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
@@ -74,9 +74,34 @@ npx -y @getmarrow/install@latest doctor
74
74
  npx -y @getmarrow/install@latest --repair
75
75
  ```
76
76
 
77
- `activate` reconciles Marrow-managed entries while retaining unrelated user hooks and configuration. Detection and notification are automatic; local changes remain explicit and subject to the operator's normal change policy.
77
+ `activate` reconciles Marrow-managed entries while retaining unrelated user hooks and configuration. After that explicit activation, the local controller can restore drifted Marrow-managed hooks and configuration. Package upgrades, owner policy, credentials, and unrelated local files remain explicit and subject to the operator's normal change policy.
78
78
 
79
- ## What's New in v0.1.36
79
+ ## Automatic Controller
80
+
81
+ On Linux, successful install, repair, and activation starts a loopback-only controller that survives individual agent sessions. It keeps the signed action-permit broker available, checks installer-managed hooks every five minutes, safely restores missing managed entries, and reports an exact fix when repair is not safe. The API key remains process-only; private controller state is owner-only and contains no Marrow credential.
82
+
83
+ ```bash
84
+ npx @getmarrow/install controller status
85
+ npx @getmarrow/install controller ensure
86
+ npx @getmarrow/install controller stop
87
+ ```
88
+
89
+ Persistent controller lifecycle is currently Linux-only. On macOS or Windows, activation still installs and verifies the supported hooks without starting or signaling a background process; run `npx @getmarrow/install sidecar` under an owner-managed service and pass `--no-controller`. The controller does not silently upgrade packages, change governance policy, rotate credentials, or modify unrelated project configuration.
90
+
91
+ ## What's New in v0.1.37
92
+
93
+ v0.1.37 adds the automatic local control lifecycle after explicit owner activation:
94
+
95
+ - a project-and-agent-scoped loopback controller survives individual agent sessions;
96
+ - installer-managed hooks are checked and safely restored without changing unrelated files;
97
+ - the governed runner automatically classifies consequential commands and requires fresh signed permits for protected actions;
98
+ - pre-action, execution, result, proof, and outcome receipts share stable correlation;
99
+ - integration coverage states exactly what is native, MCP-routed, governed-wrapper controlled, or adapter-required;
100
+ - in-session value messages use measured evidence only and report unavailable data instead of synthetic savings.
101
+
102
+ It preserves the update and permit controls introduced in v0.1.36.
103
+
104
+ ## Previous: v0.1.36
80
105
 
81
106
  v0.1.36 combines guided, operator-controlled client updates with a signed permit boundary for protected actions. Installer status, activation reports, and the Fleet Operator expose request-specific update advisories with exact update and verification commands while keeping local mutation explicit:
82
107
 
@@ -200,6 +225,7 @@ npx @getmarrow/install permit --agent deploy-agent --type deploy --action "deplo
200
225
  MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --agent deploy-agent --type deploy --action "deploy production"
201
226
  npx @getmarrow/install coverage --agent deploy-agent
202
227
  npx @getmarrow/install sidecar --agent deploy-agent
228
+ npx @getmarrow/install controller status
203
229
  npx @getmarrow/install status
204
230
  npx @getmarrow/install doctor
205
231
  npx @getmarrow/install --repair
@@ -225,6 +251,19 @@ The fleet view shows live agents, active workflows, agent disagreements and thei
225
251
 
226
252
  These are integration surfaces for one Marrow product, not separate products.
227
253
 
254
+ ## Exact Integration Coverage
255
+
256
+ Run `npx @getmarrow/install integrations --json` for the machine-readable matrix. The table below intentionally distinguishes full automatic interception from MCP-routed, wrapper-bounded, and adapter-required coverage.
257
+
258
+ | Harnesses | Pre-action | Result | Outcome closure | Proof enforcement | Safe repair |
259
+ | --- | --- | --- | --- | --- | --- |
260
+ | Claude Code | Automatic native hook | Automatic native hook | Correlated when determinable | Automatic for protected actions | Managed config after activation |
261
+ | Cursor, Composer, Cline, Windsurf | MCP-routed | MCP-routed | MCP-routed | Explicit or governed runner | Managed config after activation |
262
+ | Codex, OpenCode, Gemini, Grok, DeepSeek, Qwen, Kimi, MiniMax, GLM | Automatic inside governed runner | Automatic inside governed runner | Automatic when result is known | Automatic for protected actions | Managed config after activation |
263
+ | Hermes, OpenClaw, custom harnesses | Adapter required | Adapter required | Adapter required | Adapter required | Adapter owned |
264
+
265
+ For native hooks, a successful tool exit is not treated as a successful business outcome when proof is missing. MCP coverage includes only actions routed through that MCP client. Governed-runner coverage includes only commands launched through the runner. Event-contract integrations must emit the documented lifecycle themselves.
266
+
228
267
  ## Always-On Lifecycle
229
268
 
230
269
  Supported integrations capture a compact lifecycle without storing raw prompts, completions, command output, tool output, or credentials. Marrow recognizes prompt, goal, pre-action, tool/command result, verification evidence, workflow/session, subagent, handoff, proof-pack, and outcome events.
@@ -4,7 +4,7 @@ const installer = require('../src/installer');
4
4
  const governedRunner = require('../src/governed-runner');
5
5
 
6
6
  const argv = process.argv.slice(2);
7
- const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern', 'fleet', 'hermes', 'openclaw', 'integrations', 'permit', 'verify-permit', 'coverage', 'sidecar']);
7
+ const governedCommands = new Set(['run', 'gate', 'proof', 'status', 'govern', 'fleet', 'hermes', 'openclaw', 'integrations', 'permit', 'verify-permit', 'coverage', 'sidecar', 'controller']);
8
8
  const runCli = governedCommands.has(argv[0]) ? governedRunner.runCli : installer.runCli;
9
9
 
10
10
  runCli(argv).catch((error) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getmarrow/install",
3
- "version": "0.1.36",
3
+ "version": "0.1.37",
4
4
  "description": "Universal installer and governed runner for Marrow agent fleets.",
5
5
  "bin": {
6
6
  "marrow-install": "bin/marrow-install.js"
@@ -0,0 +1,449 @@
1
+ const fs = require('node:fs');
2
+ const crypto = require('node:crypto');
3
+ const os = require('node:os');
4
+ const path = require('node:path');
5
+ const { spawn } = require('node:child_process');
6
+
7
+ const START_TIMEOUT_MS = 5_000;
8
+ const STOP_TIMEOUT_MS = 3_000;
9
+ const HEALTH_TIMEOUT_MS = 1_000;
10
+ const MAX_STATE_BYTES = 8 * 1024;
11
+ const LIFECYCLE_LOCK_STALE_MS = 30_000;
12
+
13
+ function controllerIdentity(options = {}) {
14
+ const root = path.resolve(options.root || process.cwd());
15
+ const agentId = String(options.agentId || process.env.MARROW_FLEET_AGENT_ID || process.env.MARROW_AGENT_ID || 'agent').trim() || 'agent';
16
+ return crypto.createHash('sha256').update(`${root}\0${agentId}`).digest('hex').slice(0, 24);
17
+ }
18
+
19
+ function controllerDirectory(options = {}) {
20
+ return process.env.MARROW_SIDECAR_STATE_DIR
21
+ || path.join(os.homedir(), '.marrow', 'controllers', controllerIdentity(options));
22
+ }
23
+
24
+ function stateFile(options = {}) {
25
+ return path.join(controllerDirectory(options), 'active.json');
26
+ }
27
+
28
+ function lifecycleLockFile(options = {}) {
29
+ return path.join(controllerDirectory(options), 'lifecycle.lock');
30
+ }
31
+
32
+ function currentUid() {
33
+ return typeof process.getuid === 'function' ? process.getuid() : null;
34
+ }
35
+
36
+ function assertPrivatePath(filePath, kind) {
37
+ const stat = fs.lstatSync(filePath);
38
+ const uid = currentUid();
39
+ if (stat.isSymbolicLink()) throw new Error(`Controller ${kind} cannot be a symlink.`);
40
+ if (kind === 'directory' ? !stat.isDirectory() : !stat.isFile()) {
41
+ throw new Error(`Controller ${kind} has an invalid file type.`);
42
+ }
43
+ if (uid !== null && stat.uid !== uid) throw new Error(`Controller ${kind} must be owned by the current user.`);
44
+ const forbidden = kind === 'directory' ? 0o077 : 0o077;
45
+ if ((stat.mode & forbidden) !== 0) throw new Error(`Controller ${kind} permissions are too broad.`);
46
+ return stat;
47
+ }
48
+
49
+ function ensurePrivateDirectory(options = {}) {
50
+ const directory = path.resolve(controllerDirectory(options));
51
+ const parsed = path.parse(directory);
52
+ let current = parsed.root;
53
+ for (const segment of directory.slice(parsed.root.length).split(path.sep).filter(Boolean)) {
54
+ current = path.join(current, segment);
55
+ try {
56
+ fs.mkdirSync(current, { mode: 0o700 });
57
+ } catch (error) {
58
+ if (error?.code !== 'EEXIST') throw error;
59
+ }
60
+ const stat = fs.lstatSync(current);
61
+ if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(current) !== current) {
62
+ throw new Error('Controller state directory cannot contain symlinked path components.');
63
+ }
64
+ if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) {
65
+ throw new Error('Controller state directory cannot be nested under a non-sticky writable ancestor.');
66
+ }
67
+ }
68
+ fs.chmodSync(directory, 0o700);
69
+ assertPrivatePath(directory, 'directory');
70
+ return directory;
71
+ }
72
+
73
+ function readState(options = {}) {
74
+ const filePath = stateFile(options);
75
+ if (!fs.existsSync(filePath)) return null;
76
+ const stat = assertPrivatePath(filePath, 'state file');
77
+ if (stat.size > MAX_STATE_BYTES) throw new Error('Controller state file is oversized.');
78
+ const value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
79
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Controller state is invalid.');
80
+ if (!Number.isInteger(value.pid) || value.pid <= 1) throw new Error('Controller state PID is invalid.');
81
+ if (!Number.isInteger(value.port) || value.port < 1 || value.port > 65535) throw new Error('Controller state port is invalid.');
82
+ if (value.host !== '127.0.0.1') throw new Error('Controller state is not loopback-bound.');
83
+ if (typeof value.token !== 'string' || !/^[a-f0-9]{64}$/.test(value.token)) throw new Error('Controller state token is invalid.');
84
+ if (typeof value.instance_id !== 'string' || !/^sidecar-[0-9a-f-]{36}$/.test(value.instance_id)) throw new Error('Controller instance identity is invalid.');
85
+ if (!Number.isFinite(Date.parse(value.started_at))) throw new Error('Controller start timestamp is invalid.');
86
+ return value;
87
+ }
88
+
89
+ function pidAlive(pid) {
90
+ try {
91
+ process.kill(pid, 0);
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function controllerSupportedPlatform(platform = process.platform) {
99
+ return platform === 'linux';
100
+ }
101
+
102
+ function isExpectedControllerProcess(pid, platform = process.platform) {
103
+ if (!controllerSupportedPlatform(platform) || !Number.isInteger(pid) || pid <= 1) return false;
104
+ try {
105
+ const args = fs.readFileSync(`/proc/${pid}/cmdline`).toString('utf8').split('\0').filter(Boolean);
106
+ if (args.length < 3 || args[2] !== 'sidecar') return false;
107
+ return fs.realpathSync(args[1]) === fs.realpathSync(require.resolve('../bin/marrow-install.js'));
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ async function controllerStatus(options = {}) {
114
+ if (!controllerSupportedPlatform(options.platform)) {
115
+ return {
116
+ active: false,
117
+ state: 'unsupported_platform',
118
+ started_at: null,
119
+ instance_id: null,
120
+ exact_fix: 'Persistent controller lifecycle is currently supported on Linux. Use --no-controller and run npx @getmarrow/install sidecar under an owner-managed service.',
121
+ };
122
+ }
123
+ let state;
124
+ try {
125
+ state = readState(options);
126
+ } catch (error) {
127
+ return {
128
+ active: false,
129
+ state: 'invalid_state',
130
+ started_at: null,
131
+ instance_id: null,
132
+ exact_fix: 'Run npx @getmarrow/install controller stop, then npx @getmarrow/install controller ensure.',
133
+ error: error instanceof Error ? error.message : 'Controller state is invalid.',
134
+ };
135
+ }
136
+ if (!state) {
137
+ return {
138
+ active: false,
139
+ state: 'stopped',
140
+ started_at: null,
141
+ instance_id: null,
142
+ exact_fix: 'Run npx @getmarrow/install controller ensure.',
143
+ };
144
+ }
145
+ if (!pidAlive(state.pid)) {
146
+ return {
147
+ active: false,
148
+ state: 'stale',
149
+ started_at: state.started_at,
150
+ instance_id: state.instance_id,
151
+ exact_fix: 'Run npx @getmarrow/install controller ensure.',
152
+ };
153
+ }
154
+ if (!isExpectedControllerProcess(state.pid, options.platform)) {
155
+ return {
156
+ active: false,
157
+ state: 'identity_mismatch',
158
+ started_at: state.started_at,
159
+ instance_id: state.instance_id,
160
+ exact_fix: 'Inspect the recorded process and controller state before retrying. Marrow will not signal an unverified PID.',
161
+ };
162
+ }
163
+ const controller = new AbortController();
164
+ const timeout = setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
165
+ timeout.unref?.();
166
+ try {
167
+ const response = await fetch(`http://127.0.0.1:${state.port}/health`, {
168
+ headers: { Authorization: `Bearer ${state.token}` },
169
+ signal: controller.signal,
170
+ });
171
+ if (!response.ok) {
172
+ return {
173
+ active: false,
174
+ state: 'identity_mismatch',
175
+ started_at: state.started_at,
176
+ instance_id: state.instance_id,
177
+ exact_fix: 'Inspect the authenticated controller endpoint and private state before retrying. Marrow will not signal either process.',
178
+ };
179
+ }
180
+ let body;
181
+ try {
182
+ body = await response.json();
183
+ } catch {
184
+ return {
185
+ active: false,
186
+ state: 'identity_mismatch',
187
+ started_at: state.started_at,
188
+ instance_id: state.instance_id,
189
+ exact_fix: 'Inspect the authenticated controller endpoint and private state before retrying. Marrow will not signal either process.',
190
+ };
191
+ }
192
+ if (body?.ok !== true || body?.instance_id !== state.instance_id || body?.pid !== state.pid) {
193
+ return {
194
+ active: false,
195
+ state: 'identity_mismatch',
196
+ started_at: state.started_at,
197
+ instance_id: state.instance_id,
198
+ exact_fix: 'Inspect the authenticated controller endpoint and private state before retrying. Marrow will not signal either process.',
199
+ };
200
+ }
201
+ const maintenance = body.maintenance && typeof body.maintenance === 'object'
202
+ ? body.maintenance
203
+ : null;
204
+ return {
205
+ active: true,
206
+ state: 'active',
207
+ started_at: state.started_at,
208
+ instance_id: state.instance_id,
209
+ maintenance,
210
+ exact_fix: maintenance?.exact_fix || null,
211
+ };
212
+ } catch {
213
+ return {
214
+ active: false,
215
+ state: 'unreachable',
216
+ started_at: state.started_at,
217
+ instance_id: state.instance_id,
218
+ exact_fix: 'Run npx @getmarrow/install controller stop, then npx @getmarrow/install controller ensure.',
219
+ };
220
+ } finally {
221
+ clearTimeout(timeout);
222
+ }
223
+ }
224
+
225
+ function cleanControllerEnv(options) {
226
+ const keep = [
227
+ 'HOME', 'USER', 'LOGNAME', 'PATH', 'LANG', 'LC_ALL', 'TMPDIR', 'TEMP', 'TMP',
228
+ 'NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR',
229
+ ];
230
+ const env = {};
231
+ for (const name of keep) {
232
+ if (process.env[name]) env[name] = process.env[name];
233
+ }
234
+ env.MARROW_API_KEY = options.apiKey;
235
+ env.MARROW_BASE_URL = options.baseUrl;
236
+ env.MARROW_FLEET_AGENT_ID = options.agentId;
237
+ env.MARROW_AGENT_ID = options.agentId;
238
+ env.MARROW_CLIENT = options.client;
239
+ env.MARROW_GOVERN_PROFILE = options.profile;
240
+ env.MARROW_GOVERN_POLICY = options.policy;
241
+ env.MARROW_CONTROLLER_PROJECT_ROOT = path.resolve(options.root || process.cwd());
242
+ env.MARROW_CONTROLLER_MANAGED_MODE = options.mode || 'auto';
243
+ env.MARROW_SIDECAR_STATE_DIR = controllerDirectory(options);
244
+ return env;
245
+ }
246
+
247
+ async function waitFor(predicate, timeoutMs) {
248
+ const deadline = Date.now() + timeoutMs;
249
+ do {
250
+ const result = await predicate();
251
+ if (result) return result;
252
+ await new Promise((resolve) => setTimeout(resolve, 75));
253
+ } while (Date.now() < deadline);
254
+ return null;
255
+ }
256
+
257
+ function tryAcquireLifecycleLock(options = {}) {
258
+ const filePath = lifecycleLockFile(options);
259
+ const nonce = crypto.randomBytes(16).toString('hex');
260
+ let descriptor;
261
+ let created = false;
262
+ try {
263
+ descriptor = fs.openSync(filePath, 'wx', 0o600);
264
+ created = true;
265
+ fs.writeFileSync(descriptor, JSON.stringify({
266
+ pid: process.pid,
267
+ nonce,
268
+ created_at: new Date().toISOString(),
269
+ }) + '\n', 'utf8');
270
+ fs.fsyncSync(descriptor);
271
+ fs.chmodSync(filePath, 0o600);
272
+ return { descriptor, filePath, nonce };
273
+ } catch (error) {
274
+ if (descriptor !== undefined) {
275
+ try { fs.closeSync(descriptor); } catch {}
276
+ }
277
+ if (created) {
278
+ try { fs.unlinkSync(filePath); } catch {}
279
+ }
280
+ if (error?.code !== 'EEXIST') throw error;
281
+ const stat = assertPrivatePath(filePath, 'lifecycle lock');
282
+ if (stat.size > MAX_STATE_BYTES) throw new Error('Controller lifecycle lock is oversized.');
283
+ let value;
284
+ try {
285
+ value = JSON.parse(fs.readFileSync(filePath, 'utf8'));
286
+ } catch {
287
+ throw new Error('Controller lifecycle lock is invalid.');
288
+ }
289
+ const stale = Date.now() - stat.mtimeMs > LIFECYCLE_LOCK_STALE_MS;
290
+ if (stale && Number.isInteger(value?.pid) && !pidAlive(value.pid)) {
291
+ fs.unlinkSync(filePath);
292
+ return null;
293
+ }
294
+ return false;
295
+ }
296
+ }
297
+
298
+ async function acquireLifecycleLock(options = {}) {
299
+ const deadline = Date.now() + START_TIMEOUT_MS;
300
+ do {
301
+ const lock = tryAcquireLifecycleLock(options);
302
+ if (lock) return lock;
303
+ await new Promise((resolve) => setTimeout(resolve, 75));
304
+ } while (Date.now() < deadline);
305
+ throw new Error('Another Marrow controller lifecycle operation is still in progress.');
306
+ }
307
+
308
+ function releaseLifecycleLock(lock) {
309
+ if (!lock) return;
310
+ try { fs.closeSync(lock.descriptor); } catch {}
311
+ try {
312
+ const stat = assertPrivatePath(lock.filePath, 'lifecycle lock');
313
+ if (stat.size > MAX_STATE_BYTES) return;
314
+ const value = JSON.parse(fs.readFileSync(lock.filePath, 'utf8'));
315
+ if (value?.nonce === lock.nonce) fs.unlinkSync(lock.filePath);
316
+ } catch {}
317
+ }
318
+
319
+ function removeStaleState(options = {}) {
320
+ const filePath = stateFile(options);
321
+ if (!fs.existsSync(filePath)) return;
322
+ assertPrivatePath(filePath, 'state file');
323
+ fs.unlinkSync(filePath);
324
+ }
325
+
326
+ function removeInvalidState(options = {}) {
327
+ const filePath = stateFile(options);
328
+ if (!fs.existsSync(filePath)) return false;
329
+ assertPrivatePath(filePath, 'state file');
330
+ fs.unlinkSync(filePath);
331
+ return true;
332
+ }
333
+
334
+ async function stopRecordedController(state, options = {}) {
335
+ if (!pidAlive(state.pid)) return;
336
+ if (!isExpectedControllerProcess(state.pid, options.platform)) {
337
+ throw new Error('Refusing to terminate an unreachable process that cannot be verified as the Marrow controller.');
338
+ }
339
+ process.kill(state.pid, 'SIGTERM');
340
+ const stopped = await waitFor(async () => pidAlive(state.pid) ? null : true, STOP_TIMEOUT_MS);
341
+ if (!stopped) throw new Error('Marrow controller did not stop within the shutdown window.');
342
+ }
343
+
344
+ async function startGovernanceController(options) {
345
+ if (!controllerSupportedPlatform(options.platform)) {
346
+ throw new Error('Persistent controller lifecycle is currently supported on Linux. Use --no-controller and run the sidecar under an owner-managed service.');
347
+ }
348
+ if (!options.apiKey) throw new Error('MARROW_API_KEY is required to start the Marrow controller.');
349
+ const directory = ensurePrivateDirectory(options);
350
+ const lock = await acquireLifecycleLock(options);
351
+ try {
352
+ const current = await controllerStatus(options);
353
+ if (current.active) return { ...current, changed: false };
354
+ if (current.state === 'stale') removeStaleState(options);
355
+ if (current.state === 'unreachable') {
356
+ const prior = readState(options);
357
+ await stopRecordedController(prior, options);
358
+ removeStaleState(options);
359
+ }
360
+ if (current.state === 'identity_mismatch') {
361
+ throw new Error('Controller process identity is ambiguous; refusing automatic replacement.');
362
+ }
363
+ if (current.state === 'invalid_state') throw new Error(current.error || 'Controller state is invalid.');
364
+
365
+ const logPath = path.join(directory, 'controller.log');
366
+ if (fs.existsSync(logPath)) assertPrivatePath(logPath, 'log file');
367
+ const logFd = fs.openSync(logPath, 'a', 0o600);
368
+ fs.chmodSync(logPath, 0o600);
369
+ const binPath = require.resolve('../bin/marrow-install.js');
370
+ const args = [
371
+ binPath,
372
+ 'sidecar',
373
+ '--agent', options.agentId,
374
+ '--client', options.client,
375
+ '--profile', options.profile,
376
+ '--policy', options.policy,
377
+ ];
378
+ let child;
379
+ try {
380
+ child = spawn(process.execPath, args, {
381
+ detached: true,
382
+ env: cleanControllerEnv(options),
383
+ stdio: ['ignore', logFd, logFd],
384
+ });
385
+ child.unref();
386
+ } finally {
387
+ fs.closeSync(logFd);
388
+ }
389
+
390
+ const active = await waitFor(async () => {
391
+ const status = await controllerStatus(options);
392
+ return status.active ? status : null;
393
+ }, START_TIMEOUT_MS);
394
+ if (!active) {
395
+ if (child?.pid && pidAlive(child.pid)) {
396
+ try { process.kill(child.pid, 'SIGTERM'); } catch {}
397
+ }
398
+ throw new Error('Marrow controller did not become healthy within the startup window.');
399
+ }
400
+ return { ...active, changed: true };
401
+ } finally {
402
+ releaseLifecycleLock(lock);
403
+ }
404
+ }
405
+
406
+ async function stopGovernanceController(options = {}) {
407
+ if (!controllerSupportedPlatform(options.platform)) {
408
+ throw new Error('Persistent controller lifecycle is currently supported on Linux. No process was signaled.');
409
+ }
410
+ ensurePrivateDirectory(options);
411
+ const lock = await acquireLifecycleLock(options);
412
+ try {
413
+ let state;
414
+ try {
415
+ state = readState(options);
416
+ } catch {
417
+ const changed = removeInvalidState(options);
418
+ return { active: false, state: 'stopped', changed, exact_fix: null };
419
+ }
420
+ if (!state) return { active: false, state: 'stopped', changed: false, exact_fix: null };
421
+ const current = await controllerStatus(options);
422
+ if (current.state === 'identity_mismatch') {
423
+ throw new Error('Refusing to stop a controller whose PID is not bound to its authenticated endpoint.');
424
+ }
425
+ if (current.active || current.state === 'unreachable') {
426
+ await stopRecordedController(state, options);
427
+ }
428
+ removeStaleState(options);
429
+ return { active: false, state: 'stopped', changed: true, exact_fix: null };
430
+ } finally {
431
+ releaseLifecycleLock(lock);
432
+ }
433
+ }
434
+
435
+ async function ensureGovernanceController(options) {
436
+ const current = await controllerStatus(options);
437
+ return current.active ? { ...current, changed: false } : startGovernanceController(options);
438
+ }
439
+
440
+ module.exports = {
441
+ controllerDirectory,
442
+ controllerIdentity,
443
+ controllerSupportedPlatform,
444
+ controllerStatus,
445
+ ensureGovernanceController,
446
+ readState,
447
+ startGovernanceController,
448
+ stopGovernanceController,
449
+ };
@@ -29,6 +29,9 @@ function createPrivateDirectoryWithoutSymlinks(directory) {
29
29
  if (stat.isSymbolicLink() || !stat.isDirectory() || fs.realpathSync(current) !== current) {
30
30
  throw new Error('Sidecar state directory cannot contain symlinked path components.');
31
31
  }
32
+ if ((stat.mode & 0o022) !== 0 && (stat.mode & 0o1000) === 0) {
33
+ throw new Error('Sidecar state directory cannot be nested under a non-sticky writable ancestor.');
34
+ }
32
35
  }
33
36
  return resolved;
34
37
  }
@@ -90,12 +93,14 @@ function writePrivateJsonAtomic(filePath, value) {
90
93
  }
91
94
  }
92
95
 
93
- function unlinkPrivateStateFile(filePath) {
96
+ function unlinkPrivateStateFile(filePath, expectedInstanceId) {
94
97
  try {
95
98
  const stat = fs.lstatSync(filePath);
96
99
  const uid = currentUid();
97
100
  if (!stat.isSymbolicLink() && stat.isFile() && (uid === null || stat.uid === uid)) {
98
- fs.unlinkSync(filePath);
101
+ const raw = stat.size <= 8 * 1024 ? fs.readFileSync(filePath, 'utf8') : '';
102
+ const current = raw ? JSON.parse(raw) : null;
103
+ if (current?.instance_id === expectedInstanceId) fs.unlinkSync(filePath);
99
104
  }
100
105
  } catch {}
101
106
  }
@@ -131,6 +136,12 @@ async function startGovernanceSidecar(options, handlers) {
131
136
  const instanceId = `sidecar-${crypto.randomUUID()}`;
132
137
  const startedAt = new Date().toISOString();
133
138
  let latestCoverage = null;
139
+ let latestMaintenance = {
140
+ state: handlers.maintain ? 'pending' : 'unavailable',
141
+ checked_at: null,
142
+ repaired: [],
143
+ exact_fix: handlers.maintain ? null : 'Run npx @getmarrow/install --repair in the managed project.',
144
+ };
134
145
 
135
146
  const server = http.createServer(async (req, res) => {
136
147
  try {
@@ -142,7 +153,13 @@ async function startGovernanceSidecar(options, handlers) {
142
153
  }
143
154
  const url = new URL(req.url || '/', 'http://127.0.0.1');
144
155
  if (req.method === 'GET' && url.pathname === '/health') {
145
- return json(res, 200, { ok: true, instance_id: instanceId, started_at: startedAt });
156
+ return json(res, 200, {
157
+ ok: true,
158
+ instance_id: instanceId,
159
+ pid: process.pid,
160
+ started_at: startedAt,
161
+ maintenance: latestMaintenance,
162
+ });
146
163
  }
147
164
  if (req.method === 'GET' && url.pathname === '/coverage') {
148
165
  latestCoverage = await handlers.coverage();
@@ -184,6 +201,18 @@ async function startGovernanceSidecar(options, handlers) {
184
201
  }
185
202
 
186
203
  const heartbeat = async () => {
204
+ if (handlers.maintain) {
205
+ try {
206
+ latestMaintenance = await handlers.maintain();
207
+ } catch {
208
+ latestMaintenance = {
209
+ state: 'attention_required',
210
+ checked_at: new Date().toISOString(),
211
+ repaired: [],
212
+ exact_fix: 'Run npx @getmarrow/install --repair in the managed project.',
213
+ };
214
+ }
215
+ }
187
216
  try {
188
217
  latestCoverage = await handlers.heartbeat({ sidecarInstanceId: instanceId });
189
218
  } catch {
@@ -199,7 +228,7 @@ async function startGovernanceSidecar(options, handlers) {
199
228
  if (closed) return;
200
229
  closed = true;
201
230
  clearInterval(timer);
202
- unlinkPrivateStateFile(stateFile);
231
+ unlinkPrivateStateFile(stateFile, instanceId);
203
232
  process.off('SIGINT', close);
204
233
  process.off('SIGTERM', close);
205
234
  server.close();
@@ -14,6 +14,18 @@ const {
14
14
  verifyActionPermit,
15
15
  } = require('./enforcement-client');
16
16
  const { startGovernanceSidecar } = require('./governance-sidecar');
17
+ const {
18
+ controllerStatus,
19
+ ensureGovernanceController,
20
+ startGovernanceController,
21
+ stopGovernanceController,
22
+ } = require('./controller-manager');
23
+ const {
24
+ HARNESS_CAPABILITY_REGISTRY,
25
+ applyPlan,
26
+ buildPlan,
27
+ detectEnvironment,
28
+ } = require('./installer');
17
29
 
18
30
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
19
31
  const HIGH_RISK_TERMS = /\b(deploy|prod|production|publish|release|merge|migration|migrate|secret|token|key|cloudflare|wrangler|npm publish|gh pr merge|git push|terraform apply|kubectl apply|delete|destroy|drop)\b/i;
@@ -100,6 +112,8 @@ Commands:
100
112
  verify-permit Verify a permit before CI, deploy, publish, merge, migration, or credential access
101
113
  coverage Show enforcement, hook-health, closure, and bypass coverage
102
114
  sidecar Run the loopback-only Marrow governance sidecar
115
+ controller <ensure|start|status|stop>
116
+ Keep the loopback governance controller active across agent sessions
103
117
  govern Interactive setup TUI when run in a terminal; text panel in CI/non-TTY
104
118
  fleet Fleet operator TUI for live agents, workflows, gates, proof debt, and exact fixes
105
119
  integrations List Marrow-supported harness add-ons
@@ -431,6 +445,16 @@ function parseArgs(argv) {
431
445
  return { command, options };
432
446
  }
433
447
 
448
+ if (command === 'controller') {
449
+ const action = argv[1] && !argv[1].startsWith('--') ? argv[1] : 'status';
450
+ if (!['ensure', 'start', 'status', 'stop'].includes(action)) {
451
+ throw new Error('controller action must be ensure, start, status, or stop');
452
+ }
453
+ const parsed = parseBaseOptions(argv, action === 'status' && argv[1] !== 'status' ? 1 : 2);
454
+ if (parsed.options.help) return { command: 'help' };
455
+ return { command, action, options: parsed.options };
456
+ }
457
+
434
458
  if (command === 'status' || command === 'govern' || command === 'fleet' || command === 'hermes' || command === 'openclaw' || command === 'integrations' || command === 'coverage' || command === 'sidecar') {
435
459
  const parsed = parseBaseOptions(argv, 1);
436
460
  if (parsed.options.help) return { command: 'help' };
@@ -687,13 +711,18 @@ async function runGoverned(parsed) {
687
711
  let decision = null;
688
712
  let decisionId = '';
689
713
  let actionPermit = null;
690
- let permitEnforcementStarted = false;
691
714
  let permitVerified = false;
715
+ let degraded = false;
716
+ let protectedAction = risky;
692
717
  const surfaces = inferSurfaces(commandText || action);
693
718
 
694
719
  try {
695
720
  runtime = await preflightRuntime(options, action, type, commandText);
696
721
  decision = gateDecision(runtime);
722
+ protectedAction = risky
723
+ || decision?.required === true
724
+ || decision?.riskLevel === 'high'
725
+ || decision?.riskLevel === 'critical';
697
726
  printGate(decision, runtime);
698
727
  if (shouldBlock(decision, options)) {
699
728
  return {
@@ -710,38 +739,37 @@ async function runGoverned(parsed) {
710
739
  const target = options.target || commandText;
711
740
  const think = await createDecision(options, action, type, target, surfaces);
712
741
  decisionId = think.decision_id || think.id || think.decision?.id || '';
713
- permitEnforcementStarted = true;
714
- actionPermit = await issueActionPermit(requestJson, options, {
715
- action,
716
- type,
717
- target,
718
- surfaces,
719
- decisionId,
720
- gateReceiptId: decision?.receiptId || '',
721
- ownerApproval: options.ownerApproval,
722
- proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
723
- });
724
- if (!actionPermit?.permit || !actionPermit?.permit_id) {
725
- throw new Error('Marrow did not issue a valid action permit.');
742
+ if (protectedAction) {
743
+ actionPermit = await issueActionPermit(requestJson, options, {
744
+ action,
745
+ type,
746
+ target,
747
+ surfaces,
748
+ decisionId,
749
+ gateReceiptId: decision?.receiptId || '',
750
+ ownerApproval: options.ownerApproval,
751
+ proofRequirements: decision?.proofPack?.required_fields || decision?.proofPack?.missing || [],
752
+ });
753
+ if (!actionPermit?.permit || !actionPermit?.permit_id) {
754
+ throw new Error('Marrow did not issue a valid action permit.');
755
+ }
756
+ const verified = await verifyActionPermit(requestJson, options, {
757
+ action,
758
+ type,
759
+ target,
760
+ surfaces,
761
+ permit: actionPermit.permit,
762
+ });
763
+ if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
764
+ permitVerified = true;
726
765
  }
727
- const verified = await verifyActionPermit(requestJson, options, {
728
- action,
729
- type,
730
- target,
731
- surfaces,
732
- permit: actionPermit.permit,
733
- });
734
- if (verified?.verified !== true) throw new Error('Marrow action permit verification failed.');
735
- permitVerified = true;
736
766
  } catch (error) {
737
- const protectedAction = risky
738
- || decision?.required === true
739
- || decision?.riskLevel === 'high'
740
- || decision?.riskLevel === 'critical';
741
- const canDegrade = !permitEnforcementStarted
742
- && !protectedAction
767
+ const canDegrade = !protectedAction
743
768
  && (options.failOpen || !options.failClosed);
744
769
  if (canDegrade) {
770
+ actionPermit = null;
771
+ permitVerified = false;
772
+ degraded = true;
745
773
  process.stderr.write(`Marrow degraded: ${error.message}. Continuing because fail-open/non-risky policy allows it.\n`);
746
774
  } else {
747
775
  return {
@@ -757,7 +785,7 @@ async function runGoverned(parsed) {
757
785
  }
758
786
  }
759
787
 
760
- const childEnv = scopedExecutionEnv(actionPermit);
788
+ const childEnv = scopedExecutionEnv(permitVerified ? actionPermit : null);
761
789
  const child = await runChild(childCommand, childEnv);
762
790
  const success = child.exitCode === 0;
763
791
  const proof = defaultProof({ options, action, childCommand, exitCode: child.exitCode, success });
@@ -796,6 +824,7 @@ async function runGoverned(parsed) {
796
824
  action,
797
825
  type,
798
826
  risky,
827
+ degraded,
799
828
  decision,
800
829
  decision_id: decisionId,
801
830
  outcome_committed: Boolean(commit),
@@ -853,18 +882,54 @@ async function coverageOnly(parsed) {
853
882
 
854
883
  async function sidecarOnly(parsed) {
855
884
  const options = parsed.options;
885
+ const root = path.resolve(process.env.MARROW_CONTROLLER_PROJECT_ROOT || process.cwd());
886
+ const managedMode = process.env.MARROW_CONTROLLER_MANAGED_MODE || 'auto';
887
+ let lastMaintenanceAt = 0;
888
+ let lastMaintenance = null;
889
+ const maintain = async () => {
890
+ if (lastMaintenance && Date.now() - lastMaintenanceAt < 5 * 60_000) return lastMaintenance;
891
+ const detection = detectEnvironment(root, process.env);
892
+ const plan = buildPlan(detection, { mode: managedMode });
893
+ const changes = applyPlan(plan, { yes: true, dryRun: false, doctor: false });
894
+ const repaired = changes.filter((change) => change.applied).map((change) => change.label);
895
+ const remaining = changes.filter((change) => change.changed && !change.applied).map((change) => change.label);
896
+ lastMaintenanceAt = Date.now();
897
+ lastMaintenance = {
898
+ state: remaining.length > 0 ? 'attention_required' : repaired.length > 0 ? 'repaired' : 'clear',
899
+ checked_at: new Date(lastMaintenanceAt).toISOString(),
900
+ repaired,
901
+ exact_fix: remaining.length > 0 ? 'Run npx @getmarrow/install --repair in the managed project.' : null,
902
+ };
903
+ return lastMaintenance;
904
+ };
856
905
  const sidecar = await startGovernanceSidecar(options, {
857
906
  permit: (input) => issueActionPermit(requestJson, options, input),
858
907
  verify: (input) => verifyActionPermit(requestJson, options, input),
859
908
  close: (input) => closeActionPermit(requestJson, options, input),
860
909
  coverage: () => readEnforcementCoverage(requestJson, options),
861
910
  heartbeat: (input) => recordEnforcementHeartbeat(requestJson, options, input),
911
+ maintain,
862
912
  });
863
913
  process.stdout.write(`Marrow governance sidecar active on 127.0.0.1:${sidecar.port}. Press Ctrl+C to stop.\n`);
864
914
  await new Promise((resolve) => sidecar.server.once('close', resolve));
865
915
  return { ok: true };
866
916
  }
867
917
 
918
+ async function controllerOnly(parsed) {
919
+ const options = { ...parsed.options, root: process.cwd(), mode: 'auto' };
920
+ let result;
921
+ if (parsed.action === 'ensure') result = await ensureGovernanceController(options);
922
+ else if (parsed.action === 'start') result = await startGovernanceController(options);
923
+ else if (parsed.action === 'stop') result = await stopGovernanceController(options);
924
+ else result = await controllerStatus(options);
925
+ if (!options.json) {
926
+ process.stdout.write(`Marrow controller: ${result.active ? 'active' : result.state}.\n`);
927
+ if (result.started_at) process.stdout.write(`Started: ${result.started_at}\n`);
928
+ if (result.exact_fix) process.stdout.write(`Next: ${result.exact_fix}\n`);
929
+ }
930
+ return { ok: result.active || parsed.action === 'stop', controller: result };
931
+ }
932
+
868
933
  async function gateOnly(parsed) {
869
934
  const { options } = parsed;
870
935
  const action = redact(options.action);
@@ -1295,7 +1360,7 @@ function fleetPanel(snapshot) {
1295
1360
  const GENERIC_GOVERNED_COMMAND = 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent <agent-id> --profile production --policy warn -- <harness-command>';
1296
1361
 
1297
1362
  function localSupportedHarnesses() {
1298
- return [
1363
+ const harnesses = [
1299
1364
  { display_name: 'OpenAI Codex', client_label: 'codex', category: 'agent_harness', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent codex-prod -- codex' },
1300
1365
  { display_name: 'Claude Code', client_label: 'claude-code', category: 'agent_harness', support_level: 'native_mcp_or_sdk', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/mcp setup' },
1301
1366
  { display_name: 'Cursor', client_label: 'cursor', category: 'ide_agent', support_level: 'native_mcp_or_sdk', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/mcp setup' },
@@ -1316,6 +1381,63 @@ function localSupportedHarnesses() {
1316
1381
  { display_name: 'CI scripts and deploy runners', client_label: 'ci', category: 'ci_runner', support_level: 'governed_runner', install_command: 'MARROW_API_KEY=mrw_live_xxx npx @getmarrow/install run --agent ci-release --profile production --policy enforce -- <ci-or-deploy-command>' },
1317
1382
  { display_name: 'Custom shell/API harness', client_label: 'custom', category: 'custom_runner', support_level: 'event_contract', install_command: 'POST /v1/agent/integrations/events with harness, event_type, agent_id, and action' },
1318
1383
  ];
1384
+ return harnesses.map((harness) => {
1385
+ const capability = HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === harness.client_label)
1386
+ || HARNESS_CAPABILITY_REGISTRY.find((entry) => entry.client === 'custom');
1387
+ return { ...harness, capability_level: capability.capability_level };
1388
+ });
1389
+ }
1390
+
1391
+ function integrationCoverageMatrix() {
1392
+ return HARNESS_CAPABILITY_REGISTRY.map((entry) => {
1393
+ const native = entry.capability_level === 'native_hooks';
1394
+ const wrapper = entry.capability_level === 'governed_wrapper';
1395
+ const routed = entry.capability_level === 'mcp';
1396
+ const sdk = entry.capability_level === 'sdk_passive_runtime';
1397
+ return {
1398
+ harness: entry.client,
1399
+ capability_level: entry.capability_level,
1400
+ install_surface: entry.install_surface,
1401
+ pre_action: native
1402
+ ? 'automatic_native_hook'
1403
+ : wrapper
1404
+ ? 'automatic_in_governed_runner'
1405
+ : sdk
1406
+ ? 'automatic_in_sdk_runtime'
1407
+ : routed
1408
+ ? 'mcp_routed'
1409
+ : 'adapter_required',
1410
+ action_result: native
1411
+ ? 'automatic_native_hook'
1412
+ : wrapper
1413
+ ? 'automatic_in_governed_runner'
1414
+ : sdk
1415
+ ? 'automatic_in_sdk_runtime'
1416
+ : routed
1417
+ ? 'mcp_routed'
1418
+ : 'adapter_required',
1419
+ outcome_closure: wrapper || sdk
1420
+ ? 'automatic_when_result_is_known'
1421
+ : native
1422
+ ? 'correlated_when_determinable'
1423
+ : routed
1424
+ ? 'mcp_routed'
1425
+ : 'adapter_required',
1426
+ proof_enforcement: native || wrapper || sdk
1427
+ ? 'automatic_for_protected_actions'
1428
+ : routed
1429
+ ? 'explicit_or_governed_wrapper'
1430
+ : 'adapter_required',
1431
+ automatic_repair: native || routed || sdk || wrapper ? 'installer_managed_config_only' : 'adapter_owned',
1432
+ limitation: native
1433
+ ? 'A successful tool exit is not treated as a successful business outcome when the result cannot be proven.'
1434
+ : routed
1435
+ ? 'Only actions routed through the MCP client are visible automatically.'
1436
+ : wrapper
1437
+ ? 'Only commands launched through the governed wrapper receive full automatic coverage.'
1438
+ : 'The harness must emit the documented lifecycle event contract.',
1439
+ };
1440
+ });
1319
1441
  }
1320
1442
 
1321
1443
  function detectHarnesses(cwd = process.cwd()) {
@@ -1448,16 +1570,22 @@ async function integrationOnly(parsed, name) {
1448
1570
 
1449
1571
  async function integrationsOnly(parsed) {
1450
1572
  const local = {
1451
- integration_registry_version: 'local.broad-harness-support-v2',
1573
+ integration_registry_version: 'local.automatic-control-v3',
1452
1574
  first_class_addons: ['hermes', 'openclaw'].map((name) => localIntegrationManifest(name)),
1453
1575
  supported_harnesses: localSupportedHarnesses(),
1576
+ integration_coverage: integrationCoverageMatrix(),
1454
1577
  exact_next_action: 'Pick the harness your team already uses. Use the install_command shown here, or send compact events to /v1/agent/integrations/events.',
1455
1578
  };
1456
1579
  let registry = local;
1457
1580
  let source = 'local';
1458
1581
  if (parsed.options.apiKey) {
1459
1582
  try {
1460
- registry = await requestJson(parsed.options, 'GET', '/v1/agent/integrations');
1583
+ const remote = await requestJson(parsed.options, 'GET', '/v1/agent/integrations');
1584
+ registry = {
1585
+ ...local,
1586
+ ...remote,
1587
+ integration_coverage: local.integration_coverage,
1588
+ };
1461
1589
  source = 'api';
1462
1590
  } catch (error) {
1463
1591
  registry = { ...local, api_warning: error instanceof Error ? error.message : String(error) };
@@ -1465,6 +1593,7 @@ async function integrationsOnly(parsed) {
1465
1593
  }
1466
1594
  if (parsed.options.json) return { ok: true, source, registry };
1467
1595
  const harnesses = Array.isArray(registry.supported_harnesses) ? registry.supported_harnesses : [];
1596
+ const coverage = Array.isArray(registry.integration_coverage) ? registry.integration_coverage : [];
1468
1597
  process.stdout.write([
1469
1598
  'Marrow Harness Integrations',
1470
1599
  '',
@@ -1472,6 +1601,9 @@ async function integrationsOnly(parsed) {
1472
1601
  'Supported harnesses and model CLIs:',
1473
1602
  ...harnesses.map((harness) => ` - ${displayText(harness.display_name || harness.client_label || harness.integration || harness.title, 40)} [${displayText(harness.support_level || 'supported', 24)}] ${displayText(harness.install_command || harness.install_commands?.[0] || '', 120)}`),
1474
1603
  '',
1604
+ 'Automatic lifecycle coverage:',
1605
+ ...coverage.map((entry) => ` - ${displayText(entry.harness, 24)}: pre=${entry.pre_action}; result=${entry.action_result}; closure=${entry.outcome_closure}; proof=${entry.proof_enforcement}; repair=${entry.automatic_repair}`),
1606
+ '',
1475
1607
  'First-class add-on guides: hermes, openclaw',
1476
1608
  `Next: ${displayText(registry.exact_next_action, 140)}`,
1477
1609
  registry.api_warning ? `API warning: ${displayText(registry.api_warning, 120)}` : '',
@@ -2102,6 +2234,7 @@ async function runCli(argv) {
2102
2234
  else if (parsed.command === 'verify-permit') result = await verifyPermitOnly(parsed);
2103
2235
  else if (parsed.command === 'coverage') result = await coverageOnly(parsed);
2104
2236
  else if (parsed.command === 'sidecar') result = await sidecarOnly(parsed);
2237
+ else if (parsed.command === 'controller') result = await controllerOnly(parsed);
2105
2238
  else if (parsed.command === 'govern') {
2106
2239
  await runGovernInteractive(parsed.options);
2107
2240
  return;
@@ -2152,6 +2285,7 @@ module.exports = {
2152
2285
  verifyPermitOnly,
2153
2286
  coverageOnly,
2154
2287
  sidecarOnly,
2288
+ controllerOnly,
2155
2289
  actionBinding,
2156
2290
  gateOnly,
2157
2291
  proofOnly,
@@ -2168,6 +2302,7 @@ module.exports = {
2168
2302
  renderFleetTui,
2169
2303
  runFleetInteractive,
2170
2304
  localSupportedHarnesses,
2305
+ integrationCoverageMatrix,
2171
2306
  localIntegrationManifest,
2172
2307
  renderIntegrationPanel,
2173
2308
  integrationOnly,
package/src/installer.js CHANGED
@@ -3,13 +3,14 @@ const path = require('node:path');
3
3
  const os = require('node:os');
4
4
  const crypto = require('node:crypto');
5
5
  const { version: INSTALLER_ADAPTER_VERSION } = require('../package.json');
6
+ const { controllerStatus, controllerSupportedPlatform, ensureGovernanceController } = require('./controller-manager');
6
7
 
7
8
  const DEFAULT_BASE_URL = 'https://api.getmarrow.ai';
8
9
  const MARROW_BLOCK_START = '<!-- marrow:passive-start -->';
9
10
  const MARROW_BLOCK_END = '<!-- marrow:passive-end -->';
10
- const MCP_ADAPTER_VERSION = '3.9.52';
11
- const SDK_ADAPTER_VERSION = '3.7.51';
12
- const SDK_ADAPTER_INTEGRITY = 'sha512-0l4UOeJLZ8izIoci4TRy22svKm3xvoyyAbU16aB3vnqxvsrdv3TDaz2EqXpo0vz62IEMlx/F+IoiQ9t9xKzdFA==';
11
+ const MCP_ADAPTER_VERSION = '3.9.53';
12
+ const SDK_ADAPTER_VERSION = '3.7.52';
13
+ const SDK_ADAPTER_INTEGRITY = 'sha512-dMo5rMXP5sFTRsiRI+Oe3SOWgSsi8TTt9VrYL9gC71EQFN2UTtuH914CkYEpcS627sb02jXrUDiXxznX/2fWgA==';
13
14
  const SDK_ADAPTER_TARBALL = `https://registry.npmjs.org/@getmarrow/sdk/-/sdk-${SDK_ADAPTER_VERSION}.tgz`;
14
15
  const MCP_PACKAGE_SPEC = `@getmarrow/mcp@${MCP_ADAPTER_VERSION}`;
15
16
  const MCP_CONTEXT_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} context-hook`;
@@ -18,7 +19,7 @@ const MCP_ACTION_RESULT_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} hook`;
18
19
  const MCP_SESSION_END_HOOK_COMMAND = `npx -y ${MCP_PACKAGE_SPEC} session-hook`;
19
20
  const NATIVE_HOOK_MATCHER = 'Bash|Edit|Write|MultiEdit|mcp__(?!marrow_).*';
20
21
  const NATIVE_EXPECTED_HOOKS = ['prompt', 'pre_action', 'action_result', 'session_end'];
21
- const SOURCE_CLIENTS = new Set(['claude-code', 'cursor', 'composer', 'windsurf', 'openclaw', 'codex', 'gemini', 'grok', 'deepseek', 'qwen', 'kimi', 'minimax', 'cline', 'opencode', 'hermes', 'glm', 'custom', 'unknown']);
22
+ const SOURCE_CLIENTS = new Set(['claude-code', 'cursor', 'composer', 'windsurf', 'openclaw', 'codex', 'gemini', 'grok', 'deepseek', 'qwen', 'kimi', 'minimax', 'cline', 'opencode', 'hermes', 'glm', 'mcp', 'ci', 'custom', 'unknown']);
22
23
  const HARNESS_CAPABILITY_REGISTRY = Object.freeze([
23
24
  { client: 'claude-code', capability_level: 'native_hooks', automatic: ['prompt', 'pre_action', 'action_result', 'session_end'], install_surface: 'mcp' },
24
25
  { client: 'cursor', capability_level: 'mcp', automatic: ['mcp_tool_calls'], install_surface: 'mcp' },
@@ -36,6 +37,8 @@ const HARNESS_CAPABILITY_REGISTRY = Object.freeze([
36
37
  { client: 'kimi', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
37
38
  { client: 'minimax', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
38
39
  { client: 'glm', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
40
+ { client: 'mcp', capability_level: 'mcp', automatic: ['mcp_tool_calls'], install_surface: 'mcp' },
41
+ { client: 'ci', capability_level: 'governed_wrapper', automatic: ['pre_action', 'action_result', 'outcome_closure'], install_surface: 'runner' },
39
42
  { client: 'custom', capability_level: 'event_contract', automatic: [], install_surface: 'event_contract' },
40
43
  ]);
41
44
 
@@ -64,6 +67,9 @@ function sourceClient() {
64
67
  hermes: 'hermes',
65
68
  'hermes-agent': 'hermes',
66
69
  glm: 'glm',
70
+ mcp: 'mcp',
71
+ ci: 'ci',
72
+ 'github-actions': 'ci',
67
73
  };
68
74
  return aliases[raw] || (SOURCE_CLIENTS.has(raw) ? raw : 'custom');
69
75
  }
@@ -83,6 +89,7 @@ function parseArgs(argv) {
83
89
  selfTestExplicitlyDisabled: false,
84
90
  json: false,
85
91
  activate: false,
92
+ controller: true,
86
93
  };
87
94
 
88
95
  for (let i = 0; i < argv.length; i += 1) {
@@ -104,6 +111,7 @@ function parseArgs(argv) {
104
111
  options.selfTest = false;
105
112
  options.selfTestExplicitlyDisabled = true;
106
113
  }
114
+ else if (arg === '--no-controller') options.controller = false;
107
115
  else if (arg === '--self-test') options.selfTest = true;
108
116
  else if (arg === '--cwd') options.cwd = path.resolve(argv[++i] || options.cwd);
109
117
  else if (arg === '--mode') options.mode = argv[++i] || options.mode;
@@ -157,6 +165,7 @@ Options:
157
165
  --key <key> Marrow API key for self-test. Prefer MARROW_API_KEY because CLI args can appear in process listings.
158
166
  --base-url <url> Marrow API base URL
159
167
  --agent-id <id> Agent/fleet id for self-test headers
168
+ --no-controller Do not start the local background controller during install/repair
160
169
  --no-self-test Skip API smoke/self-test
161
170
  `;
162
171
  }
@@ -1263,8 +1272,13 @@ async function runSelfTest(options) {
1263
1272
  avoided_mistakes: performance.avoided_mistakes ?? performance.avoided_repeated_mistakes ?? 0,
1264
1273
  reused_winning_decisions: performance.reused_winning_decisions ?? 0,
1265
1274
  prevented_bad_actions: performance.prevented_bad_actions ?? 0,
1266
- estimated_tokens_saved: performance.token_time_saved_estimate?.estimated_tokens_saved ?? 0,
1267
- estimated_minutes_saved: performance.token_time_saved_estimate?.estimated_minutes_saved ?? 0,
1275
+ estimated_tokens_saved: tokenValueProof?.savings?.estimated_tokens_saved ?? null,
1276
+ estimated_minutes_saved: tokenValueProof?.savings?.estimated_minutes_saved ?? null,
1277
+ token_savings_available: Number(tokenValueProof?.observed?.model_calls || 0) > 0
1278
+ && Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0,
1279
+ token_savings_source: 'agent_model_usage_events',
1280
+ token_savings_method: tokenValueProof?.savings?.method || 'warming_up',
1281
+ token_savings_confidence: tokenValueProof?.savings?.confidence || 'none',
1268
1282
  reliability_score: performance.agent_reliability_score ?? null,
1269
1283
  } : null,
1270
1284
  };
@@ -1287,10 +1301,22 @@ function buildTokenValueProof(valueProof = {}) {
1287
1301
  return modelUsage;
1288
1302
  }
1289
1303
 
1304
+ function tokenValueProofLine(tokenValueProof) {
1305
+ const calls = Number(tokenValueProof?.observed?.model_calls || 0);
1306
+ const saved = Number(tokenValueProof?.savings?.estimated_tokens_saved || 0);
1307
+ if (calls > 0 && saved > 0) {
1308
+ const method = tokenValueProof.savings?.method || 'unspecified';
1309
+ const confidence = tokenValueProof.savings?.confidence || 'unknown';
1310
+ return `Marrow observed ${calls} model call${calls === 1 ? '' : 's'} and estimates ~${saved} tokens saved (${method}, ${confidence} confidence)`;
1311
+ }
1312
+ return tokenValueProof?.proof_line || null;
1313
+ }
1314
+
1290
1315
  function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {}, performance = {}, firstValue = {}, tokenValueProof = null) {
1291
1316
  if (firstValue && firstValue.ok !== false && firstValue.first_value) {
1292
1317
  const proof = Array.isArray(firstValue.first_value.proof) ? [...firstValue.first_value.proof] : [];
1293
- if (tokenValueProof?.proof_line && !proof.includes(tokenValueProof.proof_line)) proof.push(tokenValueProof.proof_line);
1318
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1319
+ if (tokenProofLine && !proof.includes(tokenProofLine)) proof.push(tokenProofLine);
1294
1320
  return {
1295
1321
  headline: firstValue.headline || firstValue.first_value.headline || 'Your agent is no longer starting from zero.',
1296
1322
  proof,
@@ -1315,7 +1341,7 @@ function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {
1315
1341
  'Closed the outcome successfully',
1316
1342
  'Runtime gate is ' + (firstValueSignal.active ? 'active' : 'installed'),
1317
1343
  runtimeLesson ? 'Future risky work now gets a pre-action brief' : 'Future risky work now gets checked before action',
1318
- tokenValueProof?.proof_line || 'Token usage proof is active and warming up after the first model call',
1344
+ tokenValueProofLine(tokenValueProof) || 'Token usage proof is active and warming up after the first model call',
1319
1345
  ],
1320
1346
  fleet_signal: hasFleetSignal
1321
1347
  ? 'Marrow already found signal: ' + proof.join('; ') + '.'
@@ -1334,9 +1360,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
1334
1360
  if (Number(proof.avoided_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes} avoided mistake(s)`);
1335
1361
  if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
1336
1362
  if (Number(proof.prevented_bad_actions || 0) > 0) proofBits.push(`${proof.prevented_bad_actions} prevented risky action(s)`);
1337
- if (Number(proof.estimated_tokens_saved || 0) > 0) proofBits.push(`~${proof.estimated_tokens_saved} tokens saved`);
1338
- if (Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0) proofBits.push(`~${tokenValueProof.savings.estimated_tokens_saved} measured model tokens saved`);
1339
- else if (tokenValueProof?.proof_line) proofBits.push(tokenValueProof.proof_line);
1363
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1364
+ if (tokenProofLine) proofBits.push(tokenProofLine);
1340
1365
  return {
1341
1366
  active: Boolean(firstValue.active),
1342
1367
  headline: `Marrow active: ${(capture.surfaces || ['decisions']).join(', ')} captured.`,
@@ -1362,10 +1387,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
1362
1387
  if (Number(proof.avoided_mistakes || proof.avoided_repeated_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes || proof.avoided_repeated_mistakes} avoided mistake(s)`);
1363
1388
  if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
1364
1389
  if (Number(proof.prevented_bad_actions || 0) > 0) proofBits.push(`${proof.prevented_bad_actions} prevented risky action(s)`);
1365
- const tokens = proof.token_time_saved_estimate?.estimated_tokens_saved || 0;
1366
- if (tokens > 0) proofBits.push(`~${tokens} tokens saved`);
1367
- if (Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0) proofBits.push(`~${tokenValueProof.savings.estimated_tokens_saved} measured model tokens saved`);
1368
- else if (tokenValueProof?.proof_line) proofBits.push(tokenValueProof.proof_line);
1390
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1391
+ if (tokenProofLine) proofBits.push(tokenProofLine);
1369
1392
 
1370
1393
  const firstLesson = runtime.before_you_act
1371
1394
  || runtime.before_you_act_injection?.message
@@ -1495,6 +1518,11 @@ function printReport(report) {
1495
1518
  if (report.sdkDependency.warning) process.stdout.write(`- warning: ${report.sdkDependency.warning}\n`);
1496
1519
  }
1497
1520
 
1521
+ process.stdout.write('\nAutomatic controller:\n');
1522
+ process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
1523
+ if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
1524
+ if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
1525
+
1498
1526
  if (report.writeMode === 'doctor') {
1499
1527
  process.stdout.write('\nDoctor:\n');
1500
1528
  process.stdout.write(`- Marrow active: ${report.doctor.active ? 'yes' : 'no'}\n`);
@@ -1573,6 +1601,43 @@ async function install(options) {
1573
1601
  }
1574
1602
  const changedConfig = changes.some((change) => change.applied) || configRepairs.some((repair) => repair.changed);
1575
1603
  const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
1604
+ const controllerPlatform = options.controllerPlatform || process.platform;
1605
+ let controller = await controllerStatus({
1606
+ root: detection.root,
1607
+ agentId: options.agentId,
1608
+ platform: controllerPlatform,
1609
+ });
1610
+ const shouldEnsureController = options.controller !== false
1611
+ && controllerSupportedPlatform(controllerPlatform)
1612
+ && Boolean(options.apiKey)
1613
+ && selfTestPassed
1614
+ && !options.dryRun
1615
+ && !options.doctor
1616
+ && (options.yes || options.activate || options.repair);
1617
+ if (shouldEnsureController) {
1618
+ try {
1619
+ controller = await ensureGovernanceController({
1620
+ apiKey: options.apiKey,
1621
+ baseUrl: options.baseUrl,
1622
+ agentId: options.agentId,
1623
+ client,
1624
+ root: detection.root,
1625
+ mode: plan.mode,
1626
+ profile: options.governanceMode || 'default',
1627
+ policy: options.governancePolicy || 'warn',
1628
+ platform: controllerPlatform,
1629
+ });
1630
+ } catch (error) {
1631
+ const message = error instanceof Error ? error.message : String(error);
1632
+ controller = {
1633
+ active: false,
1634
+ state: 'error',
1635
+ exact_fix: 'Run npx @getmarrow/install controller ensure after correcting the reported local controller error.',
1636
+ error: message,
1637
+ };
1638
+ if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
1639
+ }
1640
+ }
1576
1641
  const remediation = options.repair
1577
1642
  ? {
1578
1643
  attempted: true,
@@ -1614,16 +1679,17 @@ async function install(options) {
1614
1679
  missingEnv: options.apiKey ? [] : ['MARROW_API_KEY'],
1615
1680
  envHints,
1616
1681
  missingHooks: changes.filter((change) => change.changed).map((change) => change.label),
1617
- recommendedFix: configDiagnostics.npm_token.recommended_fix || selfTest.recommended_fix || (!options.apiKey
1682
+ recommendedFix: configDiagnostics.npm_token.recommended_fix || (!options.apiKey
1618
1683
  ? envHints.length
1619
1684
  ? `MARROW_API_KEY was found in a likely env file at ${envHints[0]}. Load that key from trusted secret storage, export only MARROW_API_KEY, then run npx @getmarrow/install --repair.`
1620
1685
  : 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
1621
- : null),
1686
+ : controller.exact_fix || selfTest.recommended_fix || null),
1622
1687
  },
1623
1688
  remediation,
1624
1689
  configDiagnostics,
1625
1690
  configRepairs,
1626
1691
  sdkDependency,
1692
+ controller,
1627
1693
  selfTest,
1628
1694
  warnings: options.keyFromArg
1629
1695
  ? ['Avoid --key in shared shells because command-line arguments can be visible in process listings. Prefer MARROW_API_KEY in your environment or secret manager.']