@getmarrow/install 0.1.36 → 0.1.38

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,44 @@ 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.38
92
+
93
+ v0.1.38 makes a meaningful Marrow intervention visible without adding manual work to routine agent sessions:
94
+
95
+ - generated instructions tell agents to retrieve and relay one factual intervention receipt after Marrow changes consequential work;
96
+ - Fleet Reports surface the same receipt for owners, including the required workflow, proof status, permit follow-through, and recorded outcome;
97
+ - agents remain quiet for routine low-risk work, and receipts exclude raw context, raw outcomes, proof values, credentials, and cross-tenant data.
98
+
99
+ It preserves the automatic local control lifecycle introduced in v0.1.37.
100
+
101
+ ## Previous: v0.1.37
102
+
103
+ v0.1.37 adds the automatic local control lifecycle after explicit owner activation:
104
+
105
+ - a project-and-agent-scoped loopback controller survives individual agent sessions;
106
+ - installer-managed hooks are checked and safely restored without changing unrelated files;
107
+ - the governed runner automatically classifies consequential commands and requires fresh signed permits for protected actions;
108
+ - pre-action, execution, result, proof, and outcome receipts share stable correlation;
109
+ - integration coverage states exactly what is native, MCP-routed, governed-wrapper controlled, or adapter-required;
110
+ - in-session value messages use measured evidence only and report unavailable data instead of synthetic savings.
111
+
112
+ It preserves the update and permit controls introduced in v0.1.36.
113
+
114
+ ## Previous: v0.1.36
80
115
 
81
116
  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
117
 
@@ -200,6 +235,7 @@ npx @getmarrow/install permit --agent deploy-agent --type deploy --action "deplo
200
235
  MARROW_ACTION_PERMIT=... npx @getmarrow/install verify-permit --agent deploy-agent --type deploy --action "deploy production"
201
236
  npx @getmarrow/install coverage --agent deploy-agent
202
237
  npx @getmarrow/install sidecar --agent deploy-agent
238
+ npx @getmarrow/install controller status
203
239
  npx @getmarrow/install status
204
240
  npx @getmarrow/install doctor
205
241
  npx @getmarrow/install --repair
@@ -225,13 +261,26 @@ The fleet view shows live agents, active workflows, agent disagreements and thei
225
261
 
226
262
  These are integration surfaces for one Marrow product, not separate products.
227
263
 
264
+ ## Exact Integration Coverage
265
+
266
+ 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.
267
+
268
+ | Harnesses | Pre-action | Result | Outcome closure | Proof enforcement | Safe repair |
269
+ | --- | --- | --- | --- | --- | --- |
270
+ | Claude Code | Automatic native hook | Automatic native hook | Correlated when determinable | Automatic for protected actions | Managed config after activation |
271
+ | Cursor, Composer, Cline, Windsurf | MCP-routed | MCP-routed | MCP-routed | Explicit or governed runner | Managed config after activation |
272
+ | 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 |
273
+ | Hermes, OpenClaw, custom harnesses | Adapter required | Adapter required | Adapter required | Adapter required | Adapter owned |
274
+
275
+ 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.
276
+
228
277
  ## Always-On Lifecycle
229
278
 
230
279
  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.
231
280
 
232
281
  Meaningful work opens an outcome-closure item. A tool exit or workflow completion does not silently count as a successful business outcome; an explicit outcome receipt closes it. Transient delivery failures are held in an owner-only local spool and retried with the same event ID so retries do not create duplicate lifecycle records.
233
282
 
234
- Owners can inspect pending outcomes in Fleet Operations. Agents can retrieve a tenant-scoped causal trace for a decision to see the prior failure, lesson, gate, proof, workflow, and outcome path that changed the action.
283
+ Owners can inspect pending outcomes in Fleet Operations and recent intervention receipts in Reports. Agents can retrieve the same tenant-scoped receipt through the decision trace to explain what Marrow blocked, warned about, or held for review; the required workflow; proof status; permit follow-through; and the recorded outcome. The receipt excludes raw context, raw outcomes, proof values, credentials, and other tenants' data. Agents should relay one factual receipt summary after a meaningful intervention and stay quiet for routine low-risk work.
235
284
 
236
285
  ## Passive Token and Value Proof
237
286
 
@@ -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.38",
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.54';
12
+ const SDK_ADAPTER_VERSION = '3.7.53';
13
+ const SDK_ADAPTER_INTEGRITY = 'sha512-CKPVR9gf24mNvZIcFvKhHGZSsUhxa0uDg/bN4dktLlttB/EU/p6CEVWmGqZmxDTnfp4I+qqdJUpzuQSnkNhaIA==';
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
  }
@@ -435,6 +444,7 @@ Marrow should run passively after install:
435
444
  - Keep passive token/model usage proof enabled so Marrow can show token, cost, latency, and workflow savings after real work completes.
436
445
  - Before risky work, use Marrow's decision brief or passive prompt hook.
437
446
  - After meaningful work, record the outcome so future agents learn from it.
447
+ - After Marrow blocks, warns, or requires review, use the decision trace receipt to tell the operator what changed and which recorded workflow or proof is required. Stay quiet for routine low-risk work.
438
448
  - Check health with \`marrow_agent_status\` or \`GET /v1/agent/status\`.
439
449
  - When status/runtime returns a \`client_update\` notice, tell the operator and use its exact update and verification commands only when local change policy permits.
440
450
 
@@ -1263,8 +1273,13 @@ async function runSelfTest(options) {
1263
1273
  avoided_mistakes: performance.avoided_mistakes ?? performance.avoided_repeated_mistakes ?? 0,
1264
1274
  reused_winning_decisions: performance.reused_winning_decisions ?? 0,
1265
1275
  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,
1276
+ estimated_tokens_saved: tokenValueProof?.savings?.estimated_tokens_saved ?? null,
1277
+ estimated_minutes_saved: tokenValueProof?.savings?.estimated_minutes_saved ?? null,
1278
+ token_savings_available: Number(tokenValueProof?.observed?.model_calls || 0) > 0
1279
+ && Number(tokenValueProof?.savings?.estimated_tokens_saved || 0) > 0,
1280
+ token_savings_source: 'agent_model_usage_events',
1281
+ token_savings_method: tokenValueProof?.savings?.method || 'warming_up',
1282
+ token_savings_confidence: tokenValueProof?.savings?.confidence || 'none',
1268
1283
  reliability_score: performance.agent_reliability_score ?? null,
1269
1284
  } : null,
1270
1285
  };
@@ -1287,10 +1302,22 @@ function buildTokenValueProof(valueProof = {}) {
1287
1302
  return modelUsage;
1288
1303
  }
1289
1304
 
1305
+ function tokenValueProofLine(tokenValueProof) {
1306
+ const calls = Number(tokenValueProof?.observed?.model_calls || 0);
1307
+ const saved = Number(tokenValueProof?.savings?.estimated_tokens_saved || 0);
1308
+ if (calls > 0 && saved > 0) {
1309
+ const method = tokenValueProof.savings?.method || 'unspecified';
1310
+ const confidence = tokenValueProof.savings?.confidence || 'unknown';
1311
+ return `Marrow observed ${calls} model call${calls === 1 ? '' : 's'} and estimates ~${saved} tokens saved (${method}, ${confidence} confidence)`;
1312
+ }
1313
+ return tokenValueProof?.proof_line || null;
1314
+ }
1315
+
1290
1316
  function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {}, performance = {}, firstValue = {}, tokenValueProof = null) {
1291
1317
  if (firstValue && firstValue.ok !== false && firstValue.first_value) {
1292
1318
  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);
1319
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1320
+ if (tokenProofLine && !proof.includes(tokenProofLine)) proof.push(tokenProofLine);
1294
1321
  return {
1295
1322
  headline: firstValue.headline || firstValue.first_value.headline || 'Your agent is no longer starting from zero.',
1296
1323
  proof,
@@ -1315,7 +1342,7 @@ function buildInstallValueMoment(firstValueSignal = {}, status = {}, runtime = {
1315
1342
  'Closed the outcome successfully',
1316
1343
  'Runtime gate is ' + (firstValueSignal.active ? 'active' : 'installed'),
1317
1344
  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',
1345
+ tokenValueProofLine(tokenValueProof) || 'Token usage proof is active and warming up after the first model call',
1319
1346
  ],
1320
1347
  fleet_signal: hasFleetSignal
1321
1348
  ? 'Marrow already found signal: ' + proof.join('; ') + '.'
@@ -1334,9 +1361,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
1334
1361
  if (Number(proof.avoided_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes} avoided mistake(s)`);
1335
1362
  if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
1336
1363
  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);
1364
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1365
+ if (tokenProofLine) proofBits.push(tokenProofLine);
1340
1366
  return {
1341
1367
  active: Boolean(firstValue.active),
1342
1368
  headline: `Marrow active: ${(capture.surfaces || ['decisions']).join(', ')} captured.`,
@@ -1362,10 +1388,8 @@ function buildFirstValueSignal(status, runtime, performance, firstValue = {}, to
1362
1388
  if (Number(proof.avoided_mistakes || proof.avoided_repeated_mistakes || 0) > 0) proofBits.push(`${proof.avoided_mistakes || proof.avoided_repeated_mistakes} avoided mistake(s)`);
1363
1389
  if (Number(proof.reused_winning_decisions || 0) > 0) proofBits.push(`${proof.reused_winning_decisions} reused winning decision(s)`);
1364
1390
  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);
1391
+ const tokenProofLine = tokenValueProofLine(tokenValueProof);
1392
+ if (tokenProofLine) proofBits.push(tokenProofLine);
1369
1393
 
1370
1394
  const firstLesson = runtime.before_you_act
1371
1395
  || runtime.before_you_act_injection?.message
@@ -1495,6 +1519,11 @@ function printReport(report) {
1495
1519
  if (report.sdkDependency.warning) process.stdout.write(`- warning: ${report.sdkDependency.warning}\n`);
1496
1520
  }
1497
1521
 
1522
+ process.stdout.write('\nAutomatic controller:\n');
1523
+ process.stdout.write(`- state: ${report.controller?.active ? 'active' : report.controller?.state || 'unavailable'}\n`);
1524
+ if (report.controller?.started_at) process.stdout.write(`- started: ${report.controller.started_at}\n`);
1525
+ if (report.controller?.exact_fix) process.stdout.write(`- exact fix: ${report.controller.exact_fix}\n`);
1526
+
1498
1527
  if (report.writeMode === 'doctor') {
1499
1528
  process.stdout.write('\nDoctor:\n');
1500
1529
  process.stdout.write(`- Marrow active: ${report.doctor.active ? 'yes' : 'no'}\n`);
@@ -1573,6 +1602,43 @@ async function install(options) {
1573
1602
  }
1574
1603
  const changedConfig = changes.some((change) => change.applied) || configRepairs.some((repair) => repair.changed);
1575
1604
  const selfTestPassed = Boolean(!selfTest.skipped && selfTest.active && !selfTest.error);
1605
+ const controllerPlatform = options.controllerPlatform || process.platform;
1606
+ let controller = await controllerStatus({
1607
+ root: detection.root,
1608
+ agentId: options.agentId,
1609
+ platform: controllerPlatform,
1610
+ });
1611
+ const shouldEnsureController = options.controller !== false
1612
+ && controllerSupportedPlatform(controllerPlatform)
1613
+ && Boolean(options.apiKey)
1614
+ && selfTestPassed
1615
+ && !options.dryRun
1616
+ && !options.doctor
1617
+ && (options.yes || options.activate || options.repair);
1618
+ if (shouldEnsureController) {
1619
+ try {
1620
+ controller = await ensureGovernanceController({
1621
+ apiKey: options.apiKey,
1622
+ baseUrl: options.baseUrl,
1623
+ agentId: options.agentId,
1624
+ client,
1625
+ root: detection.root,
1626
+ mode: plan.mode,
1627
+ profile: options.governanceMode || 'default',
1628
+ policy: options.governancePolicy || 'warn',
1629
+ platform: controllerPlatform,
1630
+ });
1631
+ } catch (error) {
1632
+ const message = error instanceof Error ? error.message : String(error);
1633
+ controller = {
1634
+ active: false,
1635
+ state: 'error',
1636
+ exact_fix: 'Run npx @getmarrow/install controller ensure after correcting the reported local controller error.',
1637
+ error: message,
1638
+ };
1639
+ if (options.activate) throw new Error(`Marrow activation failed: local controller did not start: ${message}`);
1640
+ }
1641
+ }
1576
1642
  const remediation = options.repair
1577
1643
  ? {
1578
1644
  attempted: true,
@@ -1614,16 +1680,17 @@ async function install(options) {
1614
1680
  missingEnv: options.apiKey ? [] : ['MARROW_API_KEY'],
1615
1681
  envHints,
1616
1682
  missingHooks: changes.filter((change) => change.changed).map((change) => change.label),
1617
- recommendedFix: configDiagnostics.npm_token.recommended_fix || selfTest.recommended_fix || (!options.apiKey
1683
+ recommendedFix: configDiagnostics.npm_token.recommended_fix || (!options.apiKey
1618
1684
  ? envHints.length
1619
1685
  ? `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
1686
  : 'Set MARROW_API_KEY, then run npx @getmarrow/install --repair.'
1621
- : null),
1687
+ : controller.exact_fix || selfTest.recommended_fix || null),
1622
1688
  },
1623
1689
  remediation,
1624
1690
  configDiagnostics,
1625
1691
  configRepairs,
1626
1692
  sdkDependency,
1693
+ controller,
1627
1694
  selfTest,
1628
1695
  warnings: options.keyFromArg
1629
1696
  ? ['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.']