@ours.network/fleet 0.18.0-nightly.6 → 0.18.1

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.
Files changed (60) hide show
  1. package/README.md +81 -43
  2. package/dist/application/fleet-query-service.js +12 -0
  3. package/dist/application/role-creation-service.js +3 -1
  4. package/dist/application/types.d.ts +11 -0
  5. package/dist/briefing.js +9 -2
  6. package/dist/build-info.json +5 -5
  7. package/dist/cli.js +37 -7
  8. package/dist/config.d.ts +6 -3
  9. package/dist/config.js +28 -14
  10. package/dist/creation.d.ts +14 -15
  11. package/dist/creation.js +19 -13
  12. package/dist/docs.d.ts +1 -1
  13. package/dist/docs.js +92 -33
  14. package/dist/doctor.d.ts +1 -5
  15. package/dist/doctor.js +11 -18
  16. package/dist/fleet-proxy.d.ts +5 -0
  17. package/dist/harness/acp-agent.js +11 -6
  18. package/dist/harness/claude-code.js +200 -11
  19. package/dist/harness/codex.d.ts +4 -1
  20. package/dist/harness/codex.js +70 -12
  21. package/dist/harness/types.d.ts +54 -4
  22. package/dist/loops/manager.d.ts +30 -1
  23. package/dist/loops/manager.js +69 -6
  24. package/dist/loops/state.d.ts +18 -0
  25. package/dist/loops/state.js +4 -0
  26. package/dist/model-env.d.ts +71 -0
  27. package/dist/model-env.js +106 -0
  28. package/dist/monitor.js +1 -1
  29. package/dist/ops.js +1 -1
  30. package/dist/owner-channel/attachments.d.ts +2 -25
  31. package/dist/owner-channel/attachments.js +5 -61
  32. package/dist/owner-channel/channel.d.ts +30 -29
  33. package/dist/owner-channel/channel.js +291 -291
  34. package/dist/owner-channel/mcp.d.ts +24 -0
  35. package/dist/owner-channel/mcp.js +145 -0
  36. package/dist/owner-channel/notices.d.ts +7 -0
  37. package/dist/owner-channel/notices.js +9 -0
  38. package/dist/runner.d.ts +48 -0
  39. package/dist/runner.js +237 -85
  40. package/dist/session/acp.d.ts +104 -0
  41. package/dist/session/acp.js +213 -10
  42. package/dist/session/activity.d.ts +31 -0
  43. package/dist/session/activity.js +48 -0
  44. package/dist/session/conversation-normalizer.d.ts +6 -0
  45. package/dist/session/conversation-normalizer.js +153 -10
  46. package/dist/session/conversation-types.d.ts +23 -4
  47. package/dist/session/types.d.ts +35 -0
  48. package/dist/spawn.js +29 -17
  49. package/dist/supervisor/systemd.js +2 -29
  50. package/dist/watchdog/briefing.js +7 -0
  51. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  52. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  53. package/dist/web-app/index.html +1 -1
  54. package/dist/worklog.d.ts +7 -1
  55. package/dist/worklog.js +191 -39
  56. package/package.json +1 -3
  57. package/dist/owner-channel/message-recovery.d.ts +0 -25
  58. package/dist/owner-channel/message-recovery.js +0 -114
  59. package/dist/owner-channel/ours-client.d.ts +0 -148
  60. package/dist/owner-channel/ours-client.js +0 -231
@@ -6,8 +6,98 @@ import { registerAdapter } from './registry.js';
6
6
  import { replaceFileAtomically, withFileLock } from '../atomic-file.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
8
  import { bundledAcpAgent } from './acp-agent.js';
9
- const OPTION_KEYS = ['plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort'];
9
+ const OPTION_KEYS = [
10
+ 'plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort',
11
+ 'mcp_servers', 'mcp_servers_only',
12
+ ];
10
13
  const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
14
+ /** `.mcp.json` server types. Absent means stdio, as the file format has it. */
15
+ const MCP_SERVER_TYPES = ['stdio', 'http', 'sse'];
16
+ /** A role that names its own ACP command runs a process fleet did not choose. */
17
+ const customAcpCommand = (role) => role.session === 'acp' && role.session_options?.acp?.command != null;
18
+ /**
19
+ * Does this server set include the ours connector?
20
+ *
21
+ * Load-bearing, and the reason it is a check rather than a doc line:
22
+ * `mcp_servers_only` maps to `--strict-mcp-config`, which ignores EVERY other MCP
23
+ * configuration — project `.mcp.json`, user settings, and **plugins**. On a
24
+ * normal install the ours connector arrives as a plugin
25
+ * (`~/.claude/plugins/.../plugin.json` declares `ours`), so a role that turns
26
+ * strict mode on without re-declaring it loses `send_message` and `get_messages`
27
+ * and cannot report that it has: a mute agent looks exactly like a quiet one.
28
+ *
29
+ * Matched on the command line rather than the server's NAME, because the name is
30
+ * the operator's to choose and would make this trivially satisfiable by writing
31
+ * `ours:` above the wrong command.
32
+ */
33
+ const declaresOursConnector = (servers) => Object.values(servers).some(s => [s.command ?? '', ...(s.args ?? [])].some(part => /(^|[/\\])ours-mcp($|\s)|@ours\.network[/\\]mcp/.test(part)));
34
+ /** Shape-check `harness_options.mcp_servers` against `.mcp.json`'s own rules. */
35
+ function validateMcpServers(servers) {
36
+ if (servers == null)
37
+ return [];
38
+ const at = (k = '') => ({ path: `harness_options.mcp_servers${k}` });
39
+ if (typeof servers !== 'object' || Array.isArray(servers))
40
+ return [{ ...at(), message: 'must be a map of server name to server definition' }];
41
+ const entries = Object.entries(servers);
42
+ if (!entries.length)
43
+ return [{ ...at(), message: 'must declare at least one server, or be omitted' }];
44
+ const errors = [];
45
+ for (const [name, raw] of entries) {
46
+ const p = `.${name}`;
47
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
48
+ errors.push({ ...at(p), message: 'server name must be [A-Za-z0-9_-]' });
49
+ continue;
50
+ }
51
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
52
+ errors.push({ ...at(p), message: 'must be a map' });
53
+ continue;
54
+ }
55
+ const s = raw;
56
+ if (s.type != null && !MCP_SERVER_TYPES.includes(s.type))
57
+ errors.push({ ...at(`${p}.type`), message: `must be one of: ${MCP_SERVER_TYPES.join(', ')}` });
58
+ const remote = s.type === 'http' || s.type === 'sse';
59
+ if (remote) {
60
+ if (typeof s.url !== 'string' || !s.url.trim())
61
+ errors.push({ ...at(`${p}.url`), message: `must be a non-empty URL for a ${s.type} server` });
62
+ if (s.command != null)
63
+ errors.push({ ...at(`${p}.command`), message: `must not be set for a ${s.type} server` });
64
+ }
65
+ else {
66
+ if (typeof s.command !== 'string' || !s.command.trim())
67
+ errors.push({ ...at(`${p}.command`), message: 'must be a non-empty command for a stdio server' });
68
+ if (s.args != null && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string')))
69
+ errors.push({ ...at(`${p}.args`), message: 'must be an array of strings' });
70
+ if (s.url != null)
71
+ errors.push({ ...at(`${p}.url`), message: 'must not be set for a stdio server' });
72
+ }
73
+ for (const key of ['env', 'headers']) {
74
+ const v = s[key];
75
+ if (v == null)
76
+ continue;
77
+ if (typeof v !== 'object' || Array.isArray(v)
78
+ || Object.values(v).some(x => typeof x !== 'string'))
79
+ errors.push({ ...at(`${p}.${key}`), message: 'must be a map of string to string' });
80
+ }
81
+ }
82
+ return errors;
83
+ }
84
+ /** `harness_options.mcp_servers` in ACP's `session/new` array shape. */
85
+ function acpMcpServersFor(servers) {
86
+ if (!servers)
87
+ return [];
88
+ // `env` and `headers` are REQUIRED arrays in the protocol, so they are always
89
+ // sent — empty when the role declared none.
90
+ const pairs = (r) => Object.entries(r ?? {}).map(([name, value]) => ({ name, value }));
91
+ return Object.entries(servers).map(([name, s]) => {
92
+ if (s.type === 'http' || s.type === 'sse')
93
+ return { name, type: s.type, url: s.url, headers: pairs(s.headers) };
94
+ // Stdio carries NO `type` field: ACP's stdio variant is the one without it,
95
+ // and the bundled agent keys on exactly that (claude-agent-acp
96
+ // acp-agent.js:4058, `!("type" in server)`), so sending `type: 'stdio'`
97
+ // would drop the server on the floor.
98
+ return { name, command: s.command, args: s.args ?? [], env: pairs(s.env) };
99
+ });
100
+ }
11
101
  /** Claude Code's accepted --permission-mode values. */
12
102
  const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
13
103
  /**
@@ -127,11 +217,8 @@ export async function pretrust(dir, deps = {}) {
127
217
  * it must be the Monitor TOOL, not a background Bash task (which never wakes the
128
218
  * agent on output → an armed-looking but deaf monitor).
129
219
  */
130
- const shellQuote = (value) => `'${value.replace(/'/g, `'"'"'`)}'`;
131
- const watchCommand = (id) => 'ours api watch-notifications --input '
132
- + shellQuote(JSON.stringify({ identity: id, since: 'tip' })) + ' --json';
133
220
  const armMonitor = (id) => 'arm a **persistent Monitor** (the Monitor TOOL — NOT a background Bash command; a ' +
134
- `background Bash task never wakes you on output) running \`${watchCommand(id)}\` ` +
221
+ `background Bash task never wakes you on output) running \`ours-mcp watch "${id}"\` ` +
135
222
  'so inbound ours mail wakes you';
136
223
  export function makeClaudeCodeAdapter(exec = realExec) {
137
224
  return {
@@ -149,7 +236,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
149
236
  }],
150
237
  };
151
238
  },
152
- validateOptions(opts) {
239
+ validateOptions(opts, role) {
153
240
  if (opts == null)
154
241
  return [];
155
242
  if (typeof opts !== 'object' || Array.isArray(opts))
@@ -157,9 +244,44 @@ export function makeClaudeCodeAdapter(exec = realExec) {
157
244
  const errors = Object.keys(opts)
158
245
  .filter(k => !OPTION_KEYS.includes(k))
159
246
  .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
160
- const effort = opts.effort;
247
+ const o = opts;
248
+ const effort = o.effort;
161
249
  if (effort != null && !EFFORT_LEVELS.includes(effort))
162
250
  errors.push({ path: 'harness_options.effort', message: `must be one of: ${EFFORT_LEVELS.join(', ')}` });
251
+ if (o.mcp_servers_only != null && typeof o.mcp_servers_only !== 'boolean')
252
+ errors.push({ path: 'harness_options.mcp_servers_only', message: 'must be a boolean' });
253
+ errors.push(...validateMcpServers(o.mcp_servers));
254
+ if (o.mcp_servers_only === true && !o.mcp_servers)
255
+ errors.push({
256
+ path: 'harness_options.mcp_servers_only',
257
+ message: 'requires harness_options.mcp_servers; on its own it would leave the role with no MCP servers at all',
258
+ });
259
+ // The muteness gate. Only when the declared set is otherwise well-formed —
260
+ // a shape error already told the operator to look here.
261
+ if (o.mcp_servers_only === true && o.mcp_servers && errors.length === 0
262
+ && !declaresOursConnector(o.mcp_servers))
263
+ errors.push({
264
+ path: 'harness_options.mcp_servers',
265
+ message: 'mcp_servers_only ignores every other MCP configuration, INCLUDING plugins — and the ours '
266
+ + 'connector is normally a plugin, so this role would have no send_message or get_messages and no way '
267
+ + 'to report that. Declare it explicitly, e.g. ours: { command: ours-mcp, args: [proxy] }',
268
+ });
269
+ // Session-aware refusals. Both options reach an ACP session through the
270
+ // bundled agent's `_meta` vocabulary, so a role that launches a DIFFERENT
271
+ // ACP agent cannot be promised either one. Refuse rather than send it and
272
+ // hope: silently dropping the config is the defect being fixed here.
273
+ if (role && customAcpCommand(role)) {
274
+ for (const key of ['plugins', 'mcp_servers', 'mcp_servers_only']) {
275
+ if (o[key] == null)
276
+ continue;
277
+ errors.push({
278
+ path: `harness_options.${key}`,
279
+ message: 'cannot be honoured with session_options.acp.command: it is delivered through the bundled '
280
+ + 'Claude ACP agent\'s _meta vocabulary, which another agent has no reason to read. Drop the '
281
+ + 'custom ACP command, or drop this option',
282
+ });
283
+ }
284
+ }
163
285
  return errors;
164
286
  },
165
287
  async prepareSession(role, dirs) {
@@ -178,6 +300,15 @@ export function makeClaudeCodeAdapter(exec = realExec) {
178
300
  CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(autocompactPct(role)),
179
301
  MEMPALACE_HOOKS_AUTO_SAVE: 'false',
180
302
  MEMPALACE_MIDSESSION_AUTOSAVE: o.mem_palace_midsession_autosave ? 'true' : 'false',
303
+ // The role's identity, for the ours connector to bind at startup instead of
304
+ // the briefing telling the MODEL to call choose_identity. Both launches
305
+ // return `prep.env`, so this one line covers tmux and ACP alike.
306
+ //
307
+ // The bind the connector performs is PLAIN and fail-closed: it can never
308
+ // evict a live session, and a role whose identity does not exist yet simply
309
+ // boots unbound and falls through to the briefing's create-if-missing step.
310
+ // Nothing here may ever grow a force flag.
311
+ OURS_BIND_IDENTITY: role.identity,
181
312
  };
182
313
  if (!memPalace)
183
314
  env.MEMPALACE_DISABLED = 'true';
@@ -188,12 +319,29 @@ export function makeClaudeCodeAdapter(exec = realExec) {
188
319
  if (role.isolation)
189
320
  mkdirSync(harnessRuntimeDir(dirs.stateDir, 'claude'), { recursive: true });
190
321
  const argv = [];
322
+ let settingsOverlay;
191
323
  if (Object.keys(enabledPlugins).length) {
192
- const overlay = join(dirs.stateDir, '.settings-overlay.json');
193
- writeFileSync(overlay, JSON.stringify({ enabledPlugins }, null, 2));
194
- argv.push('--settings', overlay);
324
+ settingsOverlay = join(dirs.stateDir, '.settings-overlay.json');
325
+ writeFileSync(settingsOverlay, JSON.stringify({ enabledPlugins }, null, 2));
326
+ argv.push('--settings', settingsOverlay);
195
327
  }
196
- return { argv, env };
328
+ // `harness_options.mcp_servers` the tmux delivery. `--mcp-config` ADDS the
329
+ // file's servers; `--strict-mcp-config` is what makes the set exclusive, and
330
+ // it is opt-in per role because it drops everything else the user has,
331
+ // plugins included (see `declaresOursConnector`).
332
+ let mcpConfigFile;
333
+ if (o.mcp_servers) {
334
+ mcpConfigFile = join(dirs.stateDir, '.mcp-config.json');
335
+ writeFileSync(mcpConfigFile, JSON.stringify({ mcpServers: o.mcp_servers }, null, 2), { mode: 0o600 });
336
+ argv.push('--mcp-config', mcpConfigFile);
337
+ if (o.mcp_servers_only === true)
338
+ argv.push('--strict-mcp-config');
339
+ }
340
+ return {
341
+ argv, env,
342
+ ...(settingsOverlay ? { settingsOverlay } : {}),
343
+ ...(mcpConfigFile ? { mcpConfigFile } : {}),
344
+ };
197
345
  },
198
346
  buildLaunch(role, mode, s, prep) {
199
347
  const stateDir = roleStateDir(role);
@@ -223,6 +371,46 @@ export function makeClaudeCodeAdapter(exec = realExec) {
223
371
  acpPermissionModeId(role) {
224
372
  return permissionMode(role);
225
373
  },
374
+ /**
375
+ * Deliver, over ACP, the two things the tmux launch delivers as flags.
376
+ *
377
+ * `buildAcpLaunch` builds its own argv and cannot carry `prep.argv`: the
378
+ * process it launches is the ACP agent, not `claude`, and it takes none of
379
+ * claude's flags. That is why `harness_options.plugins` did nothing at all on
380
+ * an ACP role — the overlay was written and then dropped, and the mem-palace
381
+ * toggle rode `prep.env` and survived, so the failure was silent AND
382
+ * selective.
383
+ *
384
+ * `_meta.claudeCode.options` is the bundled agent's own passthrough into the
385
+ * Claude Agent SDK (@agentclientprotocol/claude-agent-acp, acp-agent.js:4092
386
+ * → the `options` object at :4144). `settings` takes the same overlay path
387
+ * `--settings` takes; `strictMcpConfig` is the SDK's spelling of
388
+ * `--strict-mcp-config`. Both are spread BEFORE the fields the agent forces,
389
+ * so neither is overwritten.
390
+ *
391
+ * ⚠ RETURNS NOTHING FOR A ROLE THAT NAMES ITS OWN ACP COMMAND. That process
392
+ * is not the bundled agent and has no reason to read this vocabulary; sending
393
+ * it anyway would be the silent drop again, one level down. `validateOptions`
394
+ * refuses those roles instead.
395
+ */
396
+ acpSessionMeta(role, prep) {
397
+ if (customAcpCommand(role))
398
+ return undefined;
399
+ const options = {};
400
+ if (prep.settingsOverlay)
401
+ options.settings = prep.settingsOverlay;
402
+ if (role.harness_options?.mcp_servers_only === true)
403
+ options.strictMcpConfig = true;
404
+ return Object.keys(options).length ? { claudeCode: { options } } : undefined;
405
+ },
406
+ /**
407
+ * The declared servers, in ACP's array shape. Sent on `session/new` and on
408
+ * resume/load, because the SDK builds its server set once per session and a
409
+ * resumed session that dropped them would quietly lose its tools.
410
+ */
411
+ acpMcpServers(role) {
412
+ return acpMcpServersFor(role.harness_options?.mcp_servers);
413
+ },
226
414
  isolationPaths(_role, _dirs) {
227
415
  const claudeHome = join(home(), '.claude');
228
416
  return {
@@ -285,6 +473,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
285
473
  currentIdentityTool: 'current_identity',
286
474
  sendTool: 'send_message',
287
475
  getMessagesTool: 'get_messages',
476
+ watchCommand: id => `ours-mcp watch "${id}"`,
288
477
  monitorInstruction: id => {
289
478
  const m = armMonitor(id);
290
479
  return `${m.charAt(0).toUpperCase()}${m.slice(1)}.`;
@@ -1,10 +1,13 @@
1
1
  import { type Exec } from '../exec.js';
2
- import type { HarnessAdapter, UnattendedCapability } from './types.js';
2
+ import type { AcpLaunch, HarnessAdapter, UnattendedCapability } from './types.js';
3
+ import { type AcpAgentResolution } from './acp-agent.js';
3
4
  /**
4
5
  * What an unattended role can actually do under Codex's native settings.
5
6
  * `on-request` and `untrusted` stop to ask, and with no console attached that
6
7
  * request is refused rather than answered — so the role can only read.
7
8
  */
8
9
  export declare function codexCapabilities(approval: string, sandbox: string): UnattendedCapability[];
10
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
11
+ export declare function codexAcpLaunchForResolution(resolution: AcpAgentResolution): Pick<AcpLaunch, 'argv' | 'permissionMetadataSource'>;
9
12
  export declare function makeCodexAdapter(exec?: Exec): HarnessAdapter;
10
13
  export declare const codexAdapter: HarnessAdapter;
@@ -5,7 +5,7 @@ import { agentDir, home } from '../paths.js';
5
5
  import { realExec } from '../exec.js';
6
6
  import { registerAdapter } from './registry.js';
7
7
  import { harnessRuntimeDir } from '../isolation/policy.js';
8
- import { bundledAcpAgent, resolveBundledAcpAgent } from './acp-agent.js';
8
+ import { resolveBundledAcpAgent, } from './acp-agent.js';
9
9
  const OPTION_KEYS = [
10
10
  'launcher', 'sandbox', 'approval', 'permission_mode', 'search', 'profile', 'config', 'add_dirs',
11
11
  'monitor',
@@ -51,9 +51,7 @@ function sandboxMode(role) {
51
51
  throw new Error(`invalid harness_options.sandbox "${s}"; allowed: ${SANDBOX_MODES.join(', ')}`);
52
52
  return s;
53
53
  }
54
- /** codex-acp exposes the same sandbox postures as named ACP agent modes. */
55
- function acpAgentMode(role) {
56
- const sandbox = sandboxMode(role);
54
+ function modeForSandbox(sandbox) {
57
55
  if (sandbox === 'read-only')
58
56
  return 'read-only';
59
57
  if (sandbox === 'workspace-write')
@@ -62,6 +60,26 @@ function acpAgentMode(role) {
62
60
  return 'agent-full-access';
63
61
  return undefined;
64
62
  }
63
+ /**
64
+ * Resolve the coupled Codex ACP mode.
65
+ *
66
+ * The portable approval contract owns the default mode selection: `allow`
67
+ * means the adapter's fully non-interactive yolo preset and `auto` means its
68
+ * ordinary agent preset. This intentionally means that Codex ACP cannot retain
69
+ * an independent neutral filesystem posture for those two modes. An explicit
70
+ * native sandbox remains authoritative and selects its corresponding preset.
71
+ */
72
+ function acpAgentMode(role) {
73
+ const explicitSandbox = role.harness_options?.sandbox;
74
+ if (explicitSandbox != null)
75
+ return modeForSandbox(sandboxMode(role));
76
+ if (role.permissions?.approval === 'allow')
77
+ return 'agent-full-access';
78
+ if (role.permissions?.approval === 'auto')
79
+ return 'agent';
80
+ const sandbox = sandboxMode(role);
81
+ return modeForSandbox(sandbox);
82
+ }
65
83
  function acpModePermissions(mode) {
66
84
  if (mode === 'read-only')
67
85
  return { approval: 'on-request', sandbox: 'read-only' };
@@ -69,6 +87,10 @@ function acpModePermissions(mode) {
69
87
  return { approval: 'never', sandbox: 'danger-full-access' };
70
88
  return { approval: 'on-request', sandbox: 'workspace-write' };
71
89
  }
90
+ /** The sandbox Codex will actually receive from the selected coupled ACP mode. */
91
+ function acpRuntimeSandbox(role) {
92
+ return acpModePermissions(acpAgentMode(role)).sandbox;
93
+ }
72
94
  function fleetModeForApproval(nativeMode) {
73
95
  if (nativeMode === 'never')
74
96
  return 'allow';
@@ -102,6 +124,18 @@ function launcherMode(role) {
102
124
  function bundledCodexAcp() {
103
125
  return resolveBundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
104
126
  }
127
+ /** Bind launch argv and metadata provenance to one already-completed resolution. */
128
+ export function codexAcpLaunchForResolution(resolution) {
129
+ const permissionMetadataSource = resolution.bundled
130
+ && resolution.version === BUNDLED_CODEX_ACP_VERSION
131
+ && resolution.manifestPath !== undefined
132
+ ? 'codex-acp'
133
+ : undefined;
134
+ return {
135
+ argv: [...resolution.argv],
136
+ ...(permissionMetadataSource ? { permissionMetadataSource } : {}),
137
+ };
138
+ }
105
139
  function canOverrideBundledAcpApproval() {
106
140
  const resolution = bundledCodexAcp();
107
141
  return resolution.bundled && resolution.version === BUNDLED_CODEX_ACP_VERSION
@@ -147,7 +181,7 @@ function codexAcpEnvironment(role, dirs) {
147
181
  return {
148
182
  CODEX_PATH: command,
149
183
  [CODEX_PROXY_APPROVAL_ENV]: approvalPolicy(role) ?? 'on-request',
150
- [CODEX_PROXY_SANDBOX_ENV]: sandboxMode(role) ?? 'workspace-write',
184
+ [CODEX_PROXY_SANDBOX_ENV]: acpRuntimeSandbox(role),
151
185
  [CODEX_PROXY_MANIFEST_ENV]: resolution.manifestPath,
152
186
  ...(process.env.CODEX_PATH ? { [CODEX_PROXY_REAL_PATH_ENV]: process.env.CODEX_PATH } : {}),
153
187
  };
@@ -285,7 +319,16 @@ export function makeCodexAdapter(exec = realExec) {
285
319
  if (requested === 'ours-codex' && !hasOursCodex)
286
320
  throw new Error('harness_options.launcher is ours-codex, but ours-codex is not on PATH; install @ours.network/codex or use launcher: auto');
287
321
  const command = requested === 'codex' ? 'codex' : hasOursCodex ? 'ours-codex' : 'codex';
288
- return { argv: [], env: codexAcpEnvironment(role, dirs), command };
322
+ return {
323
+ argv: [],
324
+ // OURS_BIND_IDENTITY is the connector's startup bind seed — see the note in
325
+ // claude-code.ts's prepareSession. It belongs on EVERY harness that runs a
326
+ // role with an ours identity, not just claude-code: a seed that works on one
327
+ // harness and silently does nothing on the other is the same class of defect
328
+ // as a config key that only works on one session type.
329
+ env: { OURS_BIND_IDENTITY: role.identity, ...codexAcpEnvironment(role, dirs) },
330
+ command,
331
+ };
289
332
  },
290
333
  buildLaunch(role, mode, _s, prep) {
291
334
  const stateDir = roleStateDir(role);
@@ -299,17 +342,30 @@ export function makeCodexAdapter(exec = realExec) {
299
342
  },
300
343
  buildAcpLaunch(role, prep) {
301
344
  const configured = role.session_options?.acp?.command;
345
+ // Resolve once: both argv and permission-metadata provenance must describe
346
+ // the same artifact. A bare PATH fallback is launchable for compatibility,
347
+ // but is never authenticated for protected-MCP auto-approval.
348
+ const resolved = configured == null
349
+ ? codexAcpLaunchForResolution(bundledCodexAcp())
350
+ : undefined;
302
351
  const argv = Array.isArray(configured)
303
352
  ? [...configured]
304
353
  : typeof configured === 'string'
305
354
  ? ['sh', '-c', configured]
306
- : bundledAcpAgent(CODEX_ACP_PACKAGE, 'codex-acp', 'codex-acp');
355
+ : resolved.argv;
307
356
  const initialMode = acpAgentMode(role);
308
357
  return {
309
358
  argv,
310
359
  env: initialMode ? { ...prep.env, INITIAL_AGENT_MODE: initialMode } : prep.env,
360
+ ...(resolved?.permissionMetadataSource
361
+ ? { permissionMetadataSource: resolved.permissionMetadataSource } : {}),
311
362
  };
312
363
  },
364
+ // INITIAL_AGENT_MODE covers session/new in codex-acp; session/set_mode
365
+ // keeps resumed/loaded sessions and live status on the identical mode.
366
+ acpPermissionModeId(role) {
367
+ return acpAgentMode(role);
368
+ },
313
369
  isolationPaths(role, _dirs) {
314
370
  const codexHome = join(home(), '.codex');
315
371
  const profile = role.harness_options?.profile;
@@ -362,7 +418,9 @@ export function makeCodexAdapter(exec = realExec) {
362
418
  const mode = acpAgentMode(role) ?? 'agent';
363
419
  const configured = role.session_options?.acp?.command;
364
420
  const overrideAvailable = configured == null && canOverrideBundledAcpApproval();
365
- const actual = overrideAvailable ? { approval, sandbox } : acpModePermissions(mode);
421
+ const actual = overrideAvailable
422
+ ? { approval, sandbox: acpRuntimeSandbox(role) }
423
+ : acpModePermissions(mode);
366
424
  const exact = actual.approval === approval && actual.sandbox === sandbox;
367
425
  return {
368
426
  ...translated,
@@ -372,10 +430,9 @@ export function makeCodexAdapter(exec = realExec) {
372
430
  ? `custom ACP command cannot be verified against approval=${approval} sandbox=${sandbox}; `
373
431
  + `its '${mode}' mode is conservatively treated as approval=${actual.approval} `
374
432
  + `sandbox=${actual.sandbox}`
375
- : `codex-acp mode '${mode}' actually uses approval=${actual.approval} `
376
- + `sandbox=${actual.sandbox}, and the bundled ${BUNDLED_CODEX_ACP_VERSION} `
377
- + `app-server override is unavailable; this does not exactly represent `
378
- + `approval=${approval} sandbox=${sandbox}`],
433
+ : `Codex ACP mode '${mode}' couples approval and filesystem as `
434
+ + `approval=${actual.approval} sandbox=${actual.sandbox}; this does not exactly `
435
+ + `represent approval=${approval} sandbox=${sandbox}`],
379
436
  capabilities: codexCapabilities(actual.approval, actual.sandbox),
380
437
  };
381
438
  }
@@ -411,6 +468,7 @@ export function makeCodexAdapter(exec = realExec) {
411
468
  currentIdentityTool: 'current_identity',
412
469
  sendTool: 'send_message',
413
470
  getMessagesTool: 'get_messages',
471
+ watchCommand: id => `ours-mcp watch "${id}"`,
414
472
  monitorInstruction: (id, configuredRole) => {
415
473
  const consented = configuredRole?.harness_options?.monitor === true;
416
474
  const consent = consented
@@ -1,3 +1,4 @@
1
+ import type { McpServer } from '@agentclientprotocol/sdk';
1
2
  import type { CommonPermissions, FleetPermissionMode, ResolvedRole } from '../config.js';
2
3
  export interface PrereqCheck {
3
4
  name: string;
@@ -21,14 +22,39 @@ export interface SessionPrep {
21
22
  env: Record<string, string>;
22
23
  /** Optional launcher selected after runtime prerequisite probing. */
23
24
  command?: string;
25
+ /**
26
+ * The settings overlay prepareSession wrote, if it wrote one.
27
+ *
28
+ * The tmux launch delivers this as `--settings <path>` in `argv`; an ACP agent
29
+ * takes no flags, so it needs the PATH rather than the flag. Recorded here so
30
+ * the two deliveries read one value instead of each re-deriving the filename.
31
+ */
32
+ settingsOverlay?: string;
33
+ /**
34
+ * The MCP config file prepareSession wrote for `harness_options.mcp_servers`,
35
+ * if the role declared any. Same reason as `settingsOverlay`: the tmux launch
36
+ * passes the file, the ACP launch has to send the servers themselves.
37
+ */
38
+ mcpConfigFile?: string;
24
39
  }
40
+ /**
41
+ * One MCP server as ACP's `session/new` declares it.
42
+ *
43
+ * ⚠ THE PROTOCOL'S OWN TYPE, DELIBERATELY NOT A LOCAL RESTATEMENT. `mcpServers`
44
+ * goes onto the wire unchanged, so a hand-written near-copy would compile while
45
+ * being subtly wrong — `env` and `headers` are REQUIRED arrays, and the stdio
46
+ * variant is the one with no `type` field at all. Aliasing it also keeps
47
+ * `session/new`'s response type inferable, which a structural stand-in silently
48
+ * broke (every field of the result degraded to `unknown`).
49
+ */
50
+ export type AcpMcpServer = McpServer;
25
51
  export interface Launch {
26
52
  argv: string[];
27
53
  env: Record<string, string>;
28
54
  }
29
- export interface AcpLaunch {
30
- argv: string[];
31
- env: Record<string, string>;
55
+ export interface AcpLaunch extends Launch {
56
+ /** Metadata vocabulary authenticated by the exact ACP artifact in argv. */
57
+ permissionMetadataSource?: 'codex-acp';
32
58
  }
33
59
  /**
34
60
  * The result of expressing neutral `permissions:` in a harness's own terms.
@@ -67,6 +93,7 @@ export interface BriefingVocab {
67
93
  currentIdentityTool: string;
68
94
  sendTool: string;
69
95
  getMessagesTool: string;
96
+ watchCommand(identity: string): string;
70
97
  monitorInstruction(identity: string, role?: ResolvedRole): string;
71
98
  /** Wake-source wording for a role whose monitor is supervisor-owned (monitor.mode=fleet). */
72
99
  supervisedWakeNote(identity: string, role?: ResolvedRole): string;
@@ -95,7 +122,13 @@ export interface HarnessAdapter {
95
122
  id: string;
96
123
  supportsResume: boolean;
97
124
  checkPrereqs(): Promise<PrereqReport>;
98
- validateOptions(opts: unknown): ValidationError[];
125
+ /**
126
+ * `role` is the SESSION-AWARE half: some harness options can only be honoured
127
+ * on some session types, and an option that is silently dropped is worse than
128
+ * one that is refused. Optional so an adapter that has nothing session-specific
129
+ * to say keeps its one-argument implementation.
130
+ */
131
+ validateOptions(opts: unknown, role?: ResolvedRole): ValidationError[];
99
132
  prepareSession(role: ResolvedRole, dirs: RoleDirs): Promise<SessionPrep>;
100
133
  buildLaunch(role: ResolvedRole, mode: 'fresh' | 'resume', s: SessionState, prep: SessionPrep): Launch;
101
134
  buildAcpLaunch?(role: ResolvedRole, prep: SessionPrep): AcpLaunch;
@@ -105,6 +138,23 @@ export interface HarnessAdapter {
105
138
  * agent's default. Omit for a harness whose ACP agent has no modes.
106
139
  */
107
140
  acpPermissionModeId?(role: ResolvedRole): string | undefined;
141
+ /**
142
+ * The MCP servers this role declares, for the `mcpServers` array of ACP's
143
+ * `session/new` / `resume` / `load`. Empty (or omitted) leaves the agent's own
144
+ * configuration alone, which is what fleet has always sent.
145
+ */
146
+ acpMcpServers?(role: ResolvedRole): AcpMcpServer[];
147
+ /**
148
+ * Agent-specific `_meta` for `session/new` — how a capability the CLI takes as
149
+ * a flag reaches an ACP agent that accepts no flags.
150
+ *
151
+ * ⚠ THIS IS A PER-AGENT VOCABULARY, NOT PROTOCOL. `_meta` is free-form in ACP,
152
+ * so what an adapter puts here is only honoured by the agent it was written
153
+ * for. An adapter must therefore return nothing for an ACP command it did not
154
+ * choose, and the options that depend on it must be refused at validation for
155
+ * such a role rather than sent and silently ignored.
156
+ */
157
+ acpSessionMeta?(role: ResolvedRole, prep: SessionPrep): Record<string, unknown> | undefined;
108
158
  /** Effective portable policy and harness-native approval mode after native overrides win. */
109
159
  effectivePermissionMode?(role: ResolvedRole): {
110
160
  fleetMode: FleetPermissionMode;
@@ -68,8 +68,32 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
68
68
  private armAbandon;
69
69
  private finish;
70
70
  private advance;
71
+ /**
72
+ * Coalesce a backlog into one skip. The counters alone say how many
73
+ * occurrences were lost but never when or for how long, so the window is
74
+ * recorded too and carried on the state until a run is actually told about it
75
+ * — a dropped pass has to stay visible to the next one, not just to whoever
76
+ * was reading the log at the time.
77
+ */
71
78
  private skipMissed;
72
- private skipRestartMisses;
79
+ /**
80
+ * Restart is not, by itself, a reason to lose an occurrence a running manager
81
+ * would still have run. `poll` tolerates lateness up to one full interval and
82
+ * runs the tick late; this path used to drop anything already due however
83
+ * recently, so a role restarted seconds after its own tick came due lost it
84
+ * outright. For an oversight role that is precisely the pass which would have
85
+ * recorded why it restarted, so the failure erased its own witness.
86
+ *
87
+ * The tolerance is the only thing shared with `poll`. A backlog at least one
88
+ * interval deep is still coalesced into a single skip and never replayed —
89
+ * after a long outage exactly one occurrence survives, and `schedule` then
90
+ * arms it through the ordinary path rather than firing a burst here.
91
+ *
92
+ * Running the survivor late cannot outpace the configured cadence: `advance`
93
+ * moves the cursor by exactly one `intervalMs` per occurrence from the nominal
94
+ * time, so a loop that keeps restarting still runs at most once per interval.
95
+ */
96
+ private skipRestartBacklog;
73
97
  /**
74
98
  * A run the store could not record is dropped, not retried: the cursor has
75
99
  * already moved, so this can never become a busy loop, and the outage is
@@ -86,5 +110,10 @@ export declare class ScheduledLoopManager implements ScheduledLoopManagerHandle
86
110
  * until the process was restarted.
87
111
  */
88
112
  private recover;
113
+ /**
114
+ * The envelope is the only channel a scheduled pass has for learning about
115
+ * the passes that did not happen. A gap stated here is what lets an oversight
116
+ * role report its own outage instead of resuming as if nothing was missed.
117
+ */
89
118
  private envelope;
90
119
  }