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

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 (67) hide show
  1. package/README.md +111 -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 +7 -6
  7. package/dist/capabilities.d.ts +3 -1
  8. package/dist/capabilities.js +3 -0
  9. package/dist/cli.js +83 -7
  10. package/dist/config.d.ts +11 -3
  11. package/dist/config.js +40 -15
  12. package/dist/creation.d.ts +14 -15
  13. package/dist/creation.js +19 -13
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +117 -35
  16. package/dist/doctor.d.ts +1 -5
  17. package/dist/doctor.js +11 -18
  18. package/dist/fleet-proxy.d.ts +5 -0
  19. package/dist/harness/acp-agent.js +11 -6
  20. package/dist/harness/claude-code.js +204 -11
  21. package/dist/harness/codex.d.ts +4 -1
  22. package/dist/harness/codex.js +74 -12
  23. package/dist/harness/types.d.ts +54 -4
  24. package/dist/harness-plugins.d.ts +48 -0
  25. package/dist/harness-plugins.js +309 -0
  26. package/dist/index.d.ts +2 -0
  27. package/dist/index.js +1 -0
  28. package/dist/loops/manager.d.ts +30 -1
  29. package/dist/loops/manager.js +69 -6
  30. package/dist/loops/state.d.ts +18 -0
  31. package/dist/loops/state.js +4 -0
  32. package/dist/model-env.d.ts +71 -0
  33. package/dist/model-env.js +106 -0
  34. package/dist/monitor.js +1 -1
  35. package/dist/ops.js +1 -1
  36. package/dist/owner-channel/attachments.d.ts +2 -25
  37. package/dist/owner-channel/attachments.js +5 -61
  38. package/dist/owner-channel/channel.d.ts +30 -29
  39. package/dist/owner-channel/channel.js +291 -291
  40. package/dist/owner-channel/mcp.d.ts +24 -0
  41. package/dist/owner-channel/mcp.js +145 -0
  42. package/dist/owner-channel/notices.d.ts +7 -0
  43. package/dist/owner-channel/notices.js +9 -0
  44. package/dist/resolved-plan.js +1 -0
  45. package/dist/runner.d.ts +48 -0
  46. package/dist/runner.js +237 -85
  47. package/dist/session/acp.d.ts +104 -0
  48. package/dist/session/acp.js +213 -10
  49. package/dist/session/activity.d.ts +31 -0
  50. package/dist/session/activity.js +48 -0
  51. package/dist/session/conversation-normalizer.d.ts +6 -0
  52. package/dist/session/conversation-normalizer.js +153 -10
  53. package/dist/session/conversation-types.d.ts +23 -4
  54. package/dist/session/types.d.ts +35 -0
  55. package/dist/spawn.js +29 -17
  56. package/dist/supervisor/systemd.js +2 -29
  57. package/dist/watchdog/briefing.js +7 -0
  58. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  59. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  60. package/dist/web-app/index.html +1 -1
  61. package/dist/worklog.d.ts +7 -1
  62. package/dist/worklog.js +191 -39
  63. package/package.json +1 -3
  64. package/dist/owner-channel/message-recovery.d.ts +0 -25
  65. package/dist/owner-channel/message-recovery.js +0 -114
  66. package/dist/owner-channel/ours-client.d.ts +0 -148
  67. package/dist/owner-channel/ours-client.js +0 -231
package/dist/docs.js CHANGED
@@ -27,11 +27,34 @@ Default configuration is \`~/fleet.yaml\` plus sorted \`~/fleet.d/*.yaml\` role
27
27
  drop-ins. An explicit \`-c FILE\` replaces \`~/fleet.yaml\`; fleet.d still adds
28
28
  roles. Validate with \`config\` and \`doctor\` before starting or restarting.
29
29
 
30
+ ## Deterministic harness plugins
31
+
32
+ Harness plugin channels are configured independently and default to \`stable\`:
33
+
34
+ \`\`\`yaml
35
+ harnesses:
36
+ codex: { plugin_channel: nightly }
37
+ claude-code: { plugin_channel: stable }
38
+ \`\`\`
39
+
40
+ \`ours-fleet plugins install [codex|claude-code...]\` resolves an npm dist-tag
41
+ once only when no same-channel lock exists. \`plugins update\` explicitly resolves
42
+ again. Both persist the exact semver under
43
+ \`~/.ours-fleet/harness-plugins/<harness>/plugin-lock.json\`, generate a local
44
+ marketplace whose npm source contains that exact version, and install from that
45
+ marketplace. \`plugins status\` reports the configured channel, exact lock, and
46
+ channel mismatches. Stable resolves \`latest\` once and pins it; nightly resolves
47
+ \`nightly\` once and requires a \`-nightly.N\` prerelease. Starts, restarts, and
48
+ reconciliation only re-materialize the marketplace from the lock: they never
49
+ query a dist-tag, run an installer, or advance a version.
50
+ An explicit nightly role refuses to launch until that harness has a nightly lock;
51
+ a configured/locked channel mismatch also fails with the exact update command.
52
+
30
53
  The CLI never writes the base file: \`spawn\` writes \`~/fleet.d/Name.yaml\`. The
31
54
  web console does write it, as a whole document — its setup wizard and
32
55
  configuration editor may create, change or remove any top-level block, including
33
- \`vars:\`, \`defaults:\`, \`roles:\`, \`watchdogs:\` and \`loops:\`. Only the base
34
- file may hold \`defaults:\`, \`watchdogs:\` and \`loops:\`; a fleet.d drop-in may
56
+ \`vars:\`, \`defaults:\`, \`harnesses:\`, \`roles:\`, \`watchdogs:\` and \`loops:\`. Only the base
57
+ file may hold \`defaults:\`, \`harnesses:\`, \`watchdogs:\` and \`loops:\`; a fleet.d drop-in may
35
58
  declare \`roles:\` and nothing else. Unrecognised top-level keys are round-tripped
36
59
  untouched. Console edits are applied as surgical splices against the file's exact
37
60
  bytes, so an unchanged save is byte-identical and lines outside the edit keep their
@@ -270,6 +293,9 @@ roles:
270
293
  add_dirs: [/data/shared]
271
294
  config:
272
295
  model_reasoning_effort: high
296
+ mcp_servers: # claude-code: per-role MCP servers, additive by default
297
+ ours: { command: ours-mcp, args: [proxy] }
298
+ mcp_servers_only: false # true = ONLY these; drops user/project/plugin servers
273
299
  bio: Public role card and when peers should engage it.
274
300
  persona: Local operating contract, boundaries, and escalation policy.
275
301
  briefing_file: /absolute/custom-briefing.md
@@ -314,6 +340,11 @@ Role values override defaults. \`\${name}\` substitutes entries from \`vars\`.
314
340
  Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`.
315
341
  Use README.md for the complete isolation policy and resource-cap schema.
316
342
 
343
+ Supervised roles connect to the operator-configured ours daemon; they do not own its
344
+ lifecycle. Fleet forces \`OURS_AUTOSTART=0\` in tmux and ACP child processes after role
345
+ environment overlays. Start the shared daemon only through an explicit operator or
346
+ installer/setup flow.
347
+
317
348
  ## Permissions
318
349
 
319
350
  Prefer the harness-neutral \`permissions\` block:
@@ -399,10 +430,14 @@ permissions through its harness and check the result against a fixed floor:
399
430
  deny those requests with nobody to see it; with \`unattended: wait\` it warns,
400
431
  because a human can still attach and answer.
401
432
 
402
- Security meaning: \`ask\` maps to Codex \`untrusted\` and Claude \`default\`;
403
- \`auto\` maps to Codex \`on-request\` and Claude \`acceptEdits\`; and
404
- \`approval: allow\` maps to Codex \`never\` and Claude \`bypassPermissions\`,
405
- which genuinely permits the actions the role was authorized to take —
433
+ Security meaning: \`ask\` maps to Codex \`untrusted\` and Claude \`default\`.
434
+ \`auto\` selects Codex ACP \`agent\` (\`on-request\` + \`workspace-write\`) and
435
+ Claude \`acceptEdits\`. \`approval: allow\` selects Codex ACP's fully
436
+ non-interactive yolo mode, reported as \`agent-full-access\` (\`never\` +
437
+ \`danger-full-access\`), and Claude \`bypassPermissions\`. Codex tmux retains
438
+ independent approval and sandbox flags: \`auto\` is \`on-request\`, \`allow\`
439
+ is \`never\`, and \`filesystem\` still selects the sandbox. These modes genuinely
440
+ permit the actions the role was authorized to take —
406
441
  \`dontAsk\` only suppresses the prompt while still refusing the action. Nothing
407
442
  other than an explicit \`allow\` becomes non-interactive. Legacy \`deny\` keeps
408
443
  its conservative Codex \`on-request\` / Claude \`plan\` translation. \`allow\` is therefore a real grant and
@@ -413,20 +448,45 @@ ACP carries agent-advertised session mode IDs and \`session/set_mode\`, but thos
413
448
  IDs are agent-specific and ACP defines no portable permission-policy capability.
414
449
  Fleet therefore uses the ACP primitive where an adapter exposes a matching mode
415
450
  and otherwise performs the harness translation above. The bundled Codex ACP
416
- adapter couples approval and sandboxing in its advertised mode IDs, so fleet
417
- keeps the selected sandbox preset and enforces the independently translated
418
- approval policy on the app-server turn request. For example, \`allow\` plus
419
- \`workspace\` is really \`approval=never sandbox=workspace-write\`; it is never
420
- widened to \`danger-full-access\`. The live session reports both its effective
421
- normalized mode and the ACP sandbox-preset ID.
451
+ adapter couples approval and sandboxing in its advertised mode IDs. Neutral
452
+ \`allow\` therefore selects \`agent-full-access\` and widens \`filesystem:
453
+ workspace\` or \`read-only\` to \`danger-full-access\`; neutral \`auto\` selects
454
+ \`agent\` and \`workspace-write\` even when the neutral filesystem value differs.
455
+ An explicit \`harness_options.sandbox\` selects its corresponding ACP preset and
456
+ still wins, as does an explicit native approval override. \`config\` and
457
+ \`doctor\` report a coupled-mode mismatch as approximate. Use per-role
458
+ \`isolation:\` as the outer boundary for an \`allow\` ACP role. The live session
459
+ reports both its effective normalized mode and the exact native mode selected.
422
460
 
423
461
  See also: \`spawn --approval/--filesystem/--unattended\` set this intent at
424
462
  creation, and \`ours-fleet config\` prints each role's neutral settings, their
425
463
  native translation, and any warning — the same text \`doctor\` reports.
426
464
 
427
465
  Claude \`harness_options\`: \`permission_mode\` (default, acceptEdits, plan,
428
- dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`, and
429
- \`mem_palace_midsession_autosave\`.
466
+ dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`,
467
+ \`mem_palace_midsession_autosave\`, \`mcp_servers\` and \`mcp_servers_only\`.
468
+
469
+ \`mcp_servers\` declares MCP servers for the role, in \`.mcp.json\`'s own shape
470
+ (a map of name to \`{ command, args, env }\`, or \`{ type: http|sse, url,
471
+ headers }\`). By default they are ADDED to whatever the OS user running the role
472
+ already has configured, on both session types: tmux passes \`--mcp-config\`, and
473
+ ACP sends them in \`session/new\`.
474
+
475
+ \`mcp_servers_only: true\` makes the declared set EXCLUSIVE — \`--strict-mcp-config\`
476
+ on tmux, \`strictMcpConfig\` on ACP. It is all-or-nothing and it ignores every
477
+ other MCP configuration: project \`.mcp.json\`, user settings, and **plugins**.
478
+ The ours connector is normally installed as a plugin, so a strict role that does
479
+ not re-declare it has no \`send_message\` and no \`get_messages\` — it cannot even
480
+ report that it has gone mute. Fleet therefore refuses a strict role whose
481
+ \`mcp_servers\` does not name the connector; declare it explicitly, e.g.
482
+ \`ours: { command: ours-mcp, args: [proxy] }\`.
483
+
484
+ Both options, and \`plugins\`, reach an ACP session through the bundled Claude ACP
485
+ agent's \`_meta\` vocabulary. A role that sets \`session_options.acp.command\` runs
486
+ an agent fleet did not choose and cannot be promised them, so that combination is
487
+ refused at validation rather than accepted and dropped. This narrows a role's
488
+ tool surface; it does not stop the harness deferring tool schemas, which is the
489
+ harness's own decision.
430
490
 
431
491
  Codex \`harness_options\`: \`launcher\` (auto, ours-codex, codex), \`sandbox\`
432
492
  (read-only, workspace-write, danger-full-access), \`approval\` or
@@ -514,12 +574,11 @@ owner of that authenticated source wire instead of the latest conversation.
514
574
  Every other CID is rejected and warned about without reflecting its body. Fleet sends
515
575
  accepted/queued/progress/interrupted/failure notices and routes the ACP turn's
516
576
  final assistant text back to the authenticated sender with its source wire ID.
517
- For file replies, fleet injects a request-specific outbox path into the owner
518
- prompt. The agent copies completed artifacts there; fleet sends every regular
519
- file from the channel identity with the same source wire ID and removes the
520
- temporary outbox only after successful delivery. For proactive or in-turn agent
521
- attachments, the agent calls ours \`send_file\` to the channel identity and may
522
- pair it with a reply-linked caption; fleet, not the agent, chooses the owner.
577
+ For file replies of every kind — a response artifact, a proactive note, or an
578
+ in-turn attachment — the agent calls ours \`send_file\` to the channel identity
579
+ and may pair it with a reply-linked caption; fleet, not the agent, chooses the
580
+ owner. That is the only delivery route an agent is given: a tool call either
581
+ delivers or reports an error, where a file written to disk does neither.
523
582
  Owner messages whose trimmed text starts with \`/\` are deterministic
524
583
  supervisor commands and never enter the model: \`/help\` (alias \`/commands\`),
525
584
  \`/status\`, \`/comments [status|on|off]\`, \`/interrupt\`, \`/clear\`,
@@ -552,18 +611,13 @@ their content signature must match the declared MIME, and symlinks or non-regula
552
611
  paths fail closed. Sanitized copies live only in a mode-0700 request directory as
553
612
  mode-0600 files and are removed after completion or bounded stale retention.
554
613
 
555
- Voice prompts include a bounded transcript only when typed daemon metadata reports success.
614
+ Voice prompts include a bounded transcript only when ours-mcp reports success.
556
615
  Failure or unavailability is explicit and preserves the private audio path as the
557
- input for direct review. Run \`ours config show --json\` and inspect \`sttConfigured\` without
558
- revealing provider credentials.
559
- A mode-0600 message claim journal stores only wire ID, persistent-history
560
- sequence, and claim time. Fleet journals the exact body-free oldest-first slice
561
- before calling \`getMessages\` with that slice length, rejects a returned set
562
- mismatch, and loads a crash-recovered body only through \`getHistoryItem\`.
563
- The attachment crash journal contains only authenticated CID and wire routing
564
- data; it never stores captions, filenames, paths, transcript text, or bytes.
565
- Journaled read files resume through \`getFileInfo\` and \`fetchFile\`. A claimed
566
- agent caption is loaded from history and rejoined before the group is admitted. Fleet
616
+ fallback. Run \`ours-mcp voice-status --json\` to inspect the host configuration.
617
+ A mode-0600 crash journal contains only authenticated CID and wire routing data;
618
+ it never stores captions, filenames, paths, transcript text, or bytes. Journaled
619
+ post-retrieval files resume selectively through \`save_file\`. A deferred agent
620
+ caption is replayed with its processed files before the group is admitted. Fleet
567
621
  resolves one authenticated owner route before retrieving bytes, admits every file
568
622
  before emitting the caption or any file, and sends every part to that same route.
569
623
  Unknown correlated routes remain queued without retrieval and receive one bounded
@@ -666,10 +720,38 @@ escape hatch.
666
720
 
667
721
  ## Bounded worklogs, auth proxy, and model recovery
668
722
 
669
- An optional \`worklog: { max_kb, keep_tail_kb, max_archives }\` policy rotates a
670
- stable snapshot at fleet-owned lifecycle points. Concurrent changes defer
671
- rotation. Archives remain beside WORKLOG.md with the same sensitive-state
672
- boundary; retention deletes only recognized fleet archive names.
723
+ WORKLOG rotation is enabled by default with
724
+ \`worklog: { max_kb: 1024, keep_tail_kb: 256, max_archives: 12 }\`. Maps may
725
+ override individual values; \`worklog: false\` on a role or in defaults opts out.
726
+ Fleet rotates only at that role's launch/resume lifecycle boundary. Concurrent
727
+ changes defer rotation. The active file keeps a bounded UTF-8 tail and advances
728
+ to a line boundary when a complete line fits. If one logical line alone exceeds
729
+ the budget, its newest suffix remains and the rotation manifest records the
730
+ mid-line start and omitted byte count. The complete prior inode receives a
731
+ collision-safe UTC archive name, and
732
+ \`.worklog-rotation.json\` records restart provenance. \`max_archives\` bounds
733
+ recent archives beside WORKLOG.md; older complete archives move to
734
+ \`WORKLOG.archives/\` without deletion. All archives share the role's sensitive
735
+ state boundary. Fleet refuses a symlinked/non-regular live log or a symlinked
736
+ cold-archive boundary before replacing the live path and best-effort removes a
737
+ duplicate publication left by a detected failure while the original inode is
738
+ still available. The manifest records SHA-256 digests for the archive and live
739
+ bytes observed when it is written. These checks address ordinary path hazards,
740
+ not intentional path mutation by a malicious concurrent process with the same
741
+ Unix authority; that is outside the threat model and requires OS-level isolation.
742
+
743
+ ACP tool diffs are bounded before entering web conversation events. Existing
744
+ small before/after diffs are unchanged. Oversized whole-file snapshots are
745
+ reduced to the actual changed region plus path, operation, original byte counts,
746
+ digest, and omission metadata. Each retained side is a newest-content UTF-8 tail
747
+ of at most 64 KiB, advanced to a line boundary when a complete line fits. An
748
+ overlong single line keeps its newest suffix and explicitly records a mid-line
749
+ start. Paths retain at most a 4 KiB suffix with byte count, digest, and omitted
750
+ prefix metadata; the complete normalized update is capped at 320 KiB. A large
751
+ append therefore retains current appended content, not the historical prefix.
752
+ The live web-console transcript includes only the current runner generation and
753
+ excludes adapter session/load replay. Replayed events remain durable with
754
+ agent_replay provenance for diagnosis and recovery.
673
755
 
674
756
  \`auth_proxy: { kind: anthropic, base_url, required, health_url }\` is Claude-only
675
757
  and loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects
package/dist/doctor.d.ts CHANGED
@@ -1,10 +1,7 @@
1
- import { type AttachOursClientOptions, type OursClient } from '@ours.network/sdk/client';
2
1
  import { type Exec } from './exec.js';
3
2
  import type { YamlMode } from './config-yaml.js';
4
3
  import { type FetchLike } from './monitor.js';
5
4
  import type { PrereqReport } from './harness/types.js';
6
- type DoctorDaemonClient = Pick<OursClient, 'version'>;
7
- type AttachDoctorDaemon = (options: AttachOursClientOptions) => Promise<DoctorDaemonClient>;
8
5
  /** Host-level + per-harness prerequisite report with actionable messages. */
9
6
  export declare function doctor(opts?: {
10
7
  harness?: string;
@@ -15,5 +12,4 @@ export declare function doctor(opts?: {
15
12
  path?: string;
16
13
  argv1?: string;
17
14
  };
18
- }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike, attachDaemon?: AttachDoctorDaemon): Promise<PrereqReport>;
19
- export {};
15
+ }, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike): Promise<PrereqReport>;
package/dist/doctor.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import { userInfo } from 'node:os';
2
2
  import { existsSync, readFileSync } from 'node:fs';
3
- import { attachOursClient, } from '@ours.network/sdk/client';
4
3
  import { realExec } from './exec.js';
5
4
  import { isolationContextFor, loadConfig } from './config.js';
6
5
  import { getAdapter, productionAdapters } from './harness/registry.js';
@@ -77,7 +76,7 @@ function installChecks(scan) {
77
76
  ];
78
77
  }
79
78
  /** Host-level + per-harness prerequisite report with actionable messages. */
80
- export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i), attachDaemon = attachOursClient) {
79
+ export async function doctor(opts = {}, exec = realExec, platform = process.platform, fetchImpl = (u, i) => globalThis.fetch(u, i)) {
81
80
  const checks = [];
82
81
  // First: which artifact is producing this report. Everything below is only as
83
82
  // trustworthy as the answer, and a second install with the same semver and
@@ -113,22 +112,16 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
113
112
  detail: tmux.code === 0 ? tmux.stdout.trim() : 'not found — apt install tmux / brew install tmux',
114
113
  });
115
114
  }
116
- try {
117
- const client = await attachDaemon({
118
- env: process.env,
119
- leaseToken: `ours-fleet-doctor-${process.pid}`,
120
- clientPid: process.pid,
121
- });
122
- const info = await client.version();
123
- if (info.name !== 'ours' || typeof info.version !== 'string')
124
- throw new Error('the selected endpoint did not return a valid ours daemon identity');
125
- checks.push({ name: 'ours daemon', ok: true, detail: `running (${info.version})` });
126
- }
127
- catch (error) {
115
+ const mcp = await exec('ours-mcp', ['--version']);
116
+ checks.push({
117
+ name: 'ours-mcp', ok: mcp.code === 0,
118
+ detail: mcp.code === 0 ? mcp.stdout.trim() : 'not found — npm i -g @ours.network/mcp',
119
+ });
120
+ if (mcp.code === 0) {
121
+ const st = await exec('ours-mcp', ['status']);
128
122
  checks.push({
129
- name: 'ours daemon', ok: false,
130
- detail: `not reachable through the SDK — start it with: ours daemon start `
131
- + `[${error?.message ?? String(error)}]`,
123
+ name: 'ours-mcp daemon', ok: st.code === 0,
124
+ detail: st.code === 0 ? 'running' : 'not running — start it with: ours-mcp start',
132
125
  });
133
126
  }
134
127
  if (platform === 'linux') {
@@ -295,7 +288,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
295
288
  }
296
289
  catch (e) {
297
290
  detail = `unreachable on :${endpoint.port} — monitored roles run degraded until it is up ` +
298
- `(start it: ours daemon start) [${e?.message ?? e}]`;
291
+ `(start it: ours-mcp start) [${e?.message ?? e}]`;
299
292
  }
300
293
  checks.push({ name: checkName, ok, detail });
301
294
  }
@@ -12,6 +12,11 @@ export interface ManagedFleetSpawnResult {
12
12
  session: 'tmux' | 'acp';
13
13
  model?: string;
14
14
  monitor: Pick<MonitorConfig, 'mode' | 'interrupt'>;
15
+ /** Adapter-resolved portable policy and exact native runtime mode. */
16
+ permissionMode?: {
17
+ fleetMode: 'ask' | 'auto' | 'allow';
18
+ nativeMode: string;
19
+ };
15
20
  inherited: string[];
16
21
  creationActionId: string;
17
22
  }
@@ -1,18 +1,23 @@
1
1
  import { createRequire } from 'node:module';
2
- import { existsSync, readFileSync } from 'node:fs';
3
- import { dirname, resolve } from 'node:path';
2
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
3
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
4
4
  const require = createRequire(import.meta.url);
5
5
  export function resolveBundledAcpAgent(packageName, binName, fallbackCommand) {
6
6
  try {
7
7
  const manifestPath = require.resolve(`${packageName}/package.json`);
8
8
  const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
9
- const relative = typeof manifest.bin === 'string'
9
+ const declaredEntrypoint = typeof manifest.bin === 'string'
10
10
  ? manifest.bin
11
11
  : manifest.bin?.[binName];
12
- if (!relative)
12
+ if (!declaredEntrypoint)
13
13
  return { argv: [fallbackCommand], bundled: false };
14
- const entrypoint = resolve(dirname(manifestPath), relative);
15
- if (!existsSync(entrypoint))
14
+ const packageRoot = realpathSync(dirname(manifestPath));
15
+ const entrypoint = realpathSync(resolve(packageRoot, declaredEntrypoint));
16
+ const entrypointFromRoot = relative(packageRoot, entrypoint);
17
+ if (entrypointFromRoot === '..'
18
+ || entrypointFromRoot.startsWith(`..${sep}`)
19
+ || isAbsolute(entrypointFromRoot)
20
+ || !statSync(entrypoint).isFile())
16
21
  return { argv: [fallbackCommand], bundled: false };
17
22
  return {
18
23
  argv: [process.execPath, entrypoint], bundled: true, manifestPath,
@@ -6,8 +6,99 @@ 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
+ import { restoreLockedHarnessMarketplace } from '../harness-plugins.js';
10
+ const OPTION_KEYS = [
11
+ 'plugins', 'mem_palace', 'mem_palace_midsession_autosave', 'permission_mode', 'effort',
12
+ 'mcp_servers', 'mcp_servers_only',
13
+ ];
10
14
  const EFFORT_LEVELS = ['low', 'medium', 'high', 'xhigh', 'max'];
15
+ /** `.mcp.json` server types. Absent means stdio, as the file format has it. */
16
+ const MCP_SERVER_TYPES = ['stdio', 'http', 'sse'];
17
+ /** A role that names its own ACP command runs a process fleet did not choose. */
18
+ const customAcpCommand = (role) => role.session === 'acp' && role.session_options?.acp?.command != null;
19
+ /**
20
+ * Does this server set include the ours connector?
21
+ *
22
+ * Load-bearing, and the reason it is a check rather than a doc line:
23
+ * `mcp_servers_only` maps to `--strict-mcp-config`, which ignores EVERY other MCP
24
+ * configuration — project `.mcp.json`, user settings, and **plugins**. On a
25
+ * normal install the ours connector arrives as a plugin
26
+ * (`~/.claude/plugins/.../plugin.json` declares `ours`), so a role that turns
27
+ * strict mode on without re-declaring it loses `send_message` and `get_messages`
28
+ * and cannot report that it has: a mute agent looks exactly like a quiet one.
29
+ *
30
+ * Matched on the command line rather than the server's NAME, because the name is
31
+ * the operator's to choose and would make this trivially satisfiable by writing
32
+ * `ours:` above the wrong command.
33
+ */
34
+ const declaresOursConnector = (servers) => Object.values(servers).some(s => [s.command ?? '', ...(s.args ?? [])].some(part => /(^|[/\\])ours-mcp($|\s)|@ours\.network[/\\]mcp/.test(part)));
35
+ /** Shape-check `harness_options.mcp_servers` against `.mcp.json`'s own rules. */
36
+ function validateMcpServers(servers) {
37
+ if (servers == null)
38
+ return [];
39
+ const at = (k = '') => ({ path: `harness_options.mcp_servers${k}` });
40
+ if (typeof servers !== 'object' || Array.isArray(servers))
41
+ return [{ ...at(), message: 'must be a map of server name to server definition' }];
42
+ const entries = Object.entries(servers);
43
+ if (!entries.length)
44
+ return [{ ...at(), message: 'must declare at least one server, or be omitted' }];
45
+ const errors = [];
46
+ for (const [name, raw] of entries) {
47
+ const p = `.${name}`;
48
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
49
+ errors.push({ ...at(p), message: 'server name must be [A-Za-z0-9_-]' });
50
+ continue;
51
+ }
52
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) {
53
+ errors.push({ ...at(p), message: 'must be a map' });
54
+ continue;
55
+ }
56
+ const s = raw;
57
+ if (s.type != null && !MCP_SERVER_TYPES.includes(s.type))
58
+ errors.push({ ...at(`${p}.type`), message: `must be one of: ${MCP_SERVER_TYPES.join(', ')}` });
59
+ const remote = s.type === 'http' || s.type === 'sse';
60
+ if (remote) {
61
+ if (typeof s.url !== 'string' || !s.url.trim())
62
+ errors.push({ ...at(`${p}.url`), message: `must be a non-empty URL for a ${s.type} server` });
63
+ if (s.command != null)
64
+ errors.push({ ...at(`${p}.command`), message: `must not be set for a ${s.type} server` });
65
+ }
66
+ else {
67
+ if (typeof s.command !== 'string' || !s.command.trim())
68
+ errors.push({ ...at(`${p}.command`), message: 'must be a non-empty command for a stdio server' });
69
+ if (s.args != null && (!Array.isArray(s.args) || s.args.some(a => typeof a !== 'string')))
70
+ errors.push({ ...at(`${p}.args`), message: 'must be an array of strings' });
71
+ if (s.url != null)
72
+ errors.push({ ...at(`${p}.url`), message: 'must not be set for a stdio server' });
73
+ }
74
+ for (const key of ['env', 'headers']) {
75
+ const v = s[key];
76
+ if (v == null)
77
+ continue;
78
+ if (typeof v !== 'object' || Array.isArray(v)
79
+ || Object.values(v).some(x => typeof x !== 'string'))
80
+ errors.push({ ...at(`${p}.${key}`), message: 'must be a map of string to string' });
81
+ }
82
+ }
83
+ return errors;
84
+ }
85
+ /** `harness_options.mcp_servers` in ACP's `session/new` array shape. */
86
+ function acpMcpServersFor(servers) {
87
+ if (!servers)
88
+ return [];
89
+ // `env` and `headers` are REQUIRED arrays in the protocol, so they are always
90
+ // sent — empty when the role declared none.
91
+ const pairs = (r) => Object.entries(r ?? {}).map(([name, value]) => ({ name, value }));
92
+ return Object.entries(servers).map(([name, s]) => {
93
+ if (s.type === 'http' || s.type === 'sse')
94
+ return { name, type: s.type, url: s.url, headers: pairs(s.headers) };
95
+ // Stdio carries NO `type` field: ACP's stdio variant is the one without it,
96
+ // and the bundled agent keys on exactly that (claude-agent-acp
97
+ // acp-agent.js:4058, `!("type" in server)`), so sending `type: 'stdio'`
98
+ // would drop the server on the floor.
99
+ return { name, command: s.command, args: s.args ?? [], env: pairs(s.env) };
100
+ });
101
+ }
11
102
  /** Claude Code's accepted --permission-mode values. */
12
103
  const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'dontAsk', 'bypassPermissions'];
13
104
  /**
@@ -127,11 +218,8 @@ export async function pretrust(dir, deps = {}) {
127
218
  * it must be the Monitor TOOL, not a background Bash task (which never wakes the
128
219
  * agent on output → an armed-looking but deaf monitor).
129
220
  */
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
221
  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)}\` ` +
222
+ `background Bash task never wakes you on output) running \`ours-mcp watch "${id}"\` ` +
135
223
  'so inbound ours mail wakes you';
136
224
  export function makeClaudeCodeAdapter(exec = realExec) {
137
225
  return {
@@ -149,7 +237,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
149
237
  }],
150
238
  };
151
239
  },
152
- validateOptions(opts) {
240
+ validateOptions(opts, role) {
153
241
  if (opts == null)
154
242
  return [];
155
243
  if (typeof opts !== 'object' || Array.isArray(opts))
@@ -157,12 +245,50 @@ export function makeClaudeCodeAdapter(exec = realExec) {
157
245
  const errors = Object.keys(opts)
158
246
  .filter(k => !OPTION_KEYS.includes(k))
159
247
  .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
160
- const effort = opts.effort;
248
+ const o = opts;
249
+ const effort = o.effort;
161
250
  if (effort != null && !EFFORT_LEVELS.includes(effort))
162
251
  errors.push({ path: 'harness_options.effort', message: `must be one of: ${EFFORT_LEVELS.join(', ')}` });
252
+ if (o.mcp_servers_only != null && typeof o.mcp_servers_only !== 'boolean')
253
+ errors.push({ path: 'harness_options.mcp_servers_only', message: 'must be a boolean' });
254
+ errors.push(...validateMcpServers(o.mcp_servers));
255
+ if (o.mcp_servers_only === true && !o.mcp_servers)
256
+ errors.push({
257
+ path: 'harness_options.mcp_servers_only',
258
+ message: 'requires harness_options.mcp_servers; on its own it would leave the role with no MCP servers at all',
259
+ });
260
+ // The muteness gate. Only when the declared set is otherwise well-formed —
261
+ // a shape error already told the operator to look here.
262
+ if (o.mcp_servers_only === true && o.mcp_servers && errors.length === 0
263
+ && !declaresOursConnector(o.mcp_servers))
264
+ errors.push({
265
+ path: 'harness_options.mcp_servers',
266
+ message: 'mcp_servers_only ignores every other MCP configuration, INCLUDING plugins — and the ours '
267
+ + 'connector is normally a plugin, so this role would have no send_message or get_messages and no way '
268
+ + 'to report that. Declare it explicitly, e.g. ours: { command: ours-mcp, args: [proxy] }',
269
+ });
270
+ // Session-aware refusals. Both options reach an ACP session through the
271
+ // bundled agent's `_meta` vocabulary, so a role that launches a DIFFERENT
272
+ // ACP agent cannot be promised either one. Refuse rather than send it and
273
+ // hope: silently dropping the config is the defect being fixed here.
274
+ if (role && customAcpCommand(role)) {
275
+ for (const key of ['plugins', 'mcp_servers', 'mcp_servers_only']) {
276
+ if (o[key] == null)
277
+ continue;
278
+ errors.push({
279
+ path: `harness_options.${key}`,
280
+ message: 'cannot be honoured with session_options.acp.command: it is delivered through the bundled '
281
+ + 'Claude ACP agent\'s _meta vocabulary, which another agent has no reason to read. Drop the '
282
+ + 'custom ACP command, or drop this option',
283
+ });
284
+ }
285
+ }
163
286
  return errors;
164
287
  },
165
288
  async prepareSession(role, dirs) {
289
+ // Re-materialize only from the persisted exact lock. Ordinary launches
290
+ // never resolve npm tags or run an installer.
291
+ restoreLockedHarnessMarketplace('claude-code', role.harnessPluginChannel);
166
292
  // Pre-trust stays a HOST-side step: inside the sandbox ~/.claude.json is
167
293
  // read-only, and it is the fleet's job to trust the role's dirs, not the
168
294
  // agent's (5.1, 6.1).
@@ -178,6 +304,15 @@ export function makeClaudeCodeAdapter(exec = realExec) {
178
304
  CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(autocompactPct(role)),
179
305
  MEMPALACE_HOOKS_AUTO_SAVE: 'false',
180
306
  MEMPALACE_MIDSESSION_AUTOSAVE: o.mem_palace_midsession_autosave ? 'true' : 'false',
307
+ // The role's identity, for the ours connector to bind at startup instead of
308
+ // the briefing telling the MODEL to call choose_identity. Both launches
309
+ // return `prep.env`, so this one line covers tmux and ACP alike.
310
+ //
311
+ // The bind the connector performs is PLAIN and fail-closed: it can never
312
+ // evict a live session, and a role whose identity does not exist yet simply
313
+ // boots unbound and falls through to the briefing's create-if-missing step.
314
+ // Nothing here may ever grow a force flag.
315
+ OURS_BIND_IDENTITY: role.identity,
181
316
  };
182
317
  if (!memPalace)
183
318
  env.MEMPALACE_DISABLED = 'true';
@@ -188,12 +323,29 @@ export function makeClaudeCodeAdapter(exec = realExec) {
188
323
  if (role.isolation)
189
324
  mkdirSync(harnessRuntimeDir(dirs.stateDir, 'claude'), { recursive: true });
190
325
  const argv = [];
326
+ let settingsOverlay;
191
327
  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);
328
+ settingsOverlay = join(dirs.stateDir, '.settings-overlay.json');
329
+ writeFileSync(settingsOverlay, JSON.stringify({ enabledPlugins }, null, 2));
330
+ argv.push('--settings', settingsOverlay);
195
331
  }
196
- return { argv, env };
332
+ // `harness_options.mcp_servers` the tmux delivery. `--mcp-config` ADDS the
333
+ // file's servers; `--strict-mcp-config` is what makes the set exclusive, and
334
+ // it is opt-in per role because it drops everything else the user has,
335
+ // plugins included (see `declaresOursConnector`).
336
+ let mcpConfigFile;
337
+ if (o.mcp_servers) {
338
+ mcpConfigFile = join(dirs.stateDir, '.mcp-config.json');
339
+ writeFileSync(mcpConfigFile, JSON.stringify({ mcpServers: o.mcp_servers }, null, 2), { mode: 0o600 });
340
+ argv.push('--mcp-config', mcpConfigFile);
341
+ if (o.mcp_servers_only === true)
342
+ argv.push('--strict-mcp-config');
343
+ }
344
+ return {
345
+ argv, env,
346
+ ...(settingsOverlay ? { settingsOverlay } : {}),
347
+ ...(mcpConfigFile ? { mcpConfigFile } : {}),
348
+ };
197
349
  },
198
350
  buildLaunch(role, mode, s, prep) {
199
351
  const stateDir = roleStateDir(role);
@@ -223,6 +375,46 @@ export function makeClaudeCodeAdapter(exec = realExec) {
223
375
  acpPermissionModeId(role) {
224
376
  return permissionMode(role);
225
377
  },
378
+ /**
379
+ * Deliver, over ACP, the two things the tmux launch delivers as flags.
380
+ *
381
+ * `buildAcpLaunch` builds its own argv and cannot carry `prep.argv`: the
382
+ * process it launches is the ACP agent, not `claude`, and it takes none of
383
+ * claude's flags. That is why `harness_options.plugins` did nothing at all on
384
+ * an ACP role — the overlay was written and then dropped, and the mem-palace
385
+ * toggle rode `prep.env` and survived, so the failure was silent AND
386
+ * selective.
387
+ *
388
+ * `_meta.claudeCode.options` is the bundled agent's own passthrough into the
389
+ * Claude Agent SDK (@agentclientprotocol/claude-agent-acp, acp-agent.js:4092
390
+ * → the `options` object at :4144). `settings` takes the same overlay path
391
+ * `--settings` takes; `strictMcpConfig` is the SDK's spelling of
392
+ * `--strict-mcp-config`. Both are spread BEFORE the fields the agent forces,
393
+ * so neither is overwritten.
394
+ *
395
+ * ⚠ RETURNS NOTHING FOR A ROLE THAT NAMES ITS OWN ACP COMMAND. That process
396
+ * is not the bundled agent and has no reason to read this vocabulary; sending
397
+ * it anyway would be the silent drop again, one level down. `validateOptions`
398
+ * refuses those roles instead.
399
+ */
400
+ acpSessionMeta(role, prep) {
401
+ if (customAcpCommand(role))
402
+ return undefined;
403
+ const options = {};
404
+ if (prep.settingsOverlay)
405
+ options.settings = prep.settingsOverlay;
406
+ if (role.harness_options?.mcp_servers_only === true)
407
+ options.strictMcpConfig = true;
408
+ return Object.keys(options).length ? { claudeCode: { options } } : undefined;
409
+ },
410
+ /**
411
+ * The declared servers, in ACP's array shape. Sent on `session/new` and on
412
+ * resume/load, because the SDK builds its server set once per session and a
413
+ * resumed session that dropped them would quietly lose its tools.
414
+ */
415
+ acpMcpServers(role) {
416
+ return acpMcpServersFor(role.harness_options?.mcp_servers);
417
+ },
226
418
  isolationPaths(_role, _dirs) {
227
419
  const claudeHome = join(home(), '.claude');
228
420
  return {
@@ -285,6 +477,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
285
477
  currentIdentityTool: 'current_identity',
286
478
  sendTool: 'send_message',
287
479
  getMessagesTool: 'get_messages',
480
+ watchCommand: id => `ours-mcp watch "${id}"`,
288
481
  monitorInstruction: id => {
289
482
  const m = armMonitor(id);
290
483
  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;