@ours.network/fleet 0.19.0-nightly.8 → 1.0.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 (116) hide show
  1. package/README.md +96 -11
  2. package/dist/application/fleet-query-service.d.ts +1 -1
  3. package/dist/application/fleet-query-service.js +12 -0
  4. package/dist/application/role-creation-service.js +3 -1
  5. package/dist/application/types.d.ts +11 -0
  6. package/dist/briefing.d.ts +1 -1
  7. package/dist/briefing.js +103 -10
  8. package/dist/build-info.json +5 -5
  9. package/dist/cli.js +41 -11
  10. package/dist/config.d.ts +21 -7
  11. package/dist/config.js +32 -18
  12. package/dist/creation.d.ts +1 -1
  13. package/dist/docs.d.ts +2 -2
  14. package/dist/docs.js +145 -24
  15. package/dist/doctor.js +10 -10
  16. package/dist/duration.js +1 -1
  17. package/dist/fleet-proxy.d.ts +5 -0
  18. package/dist/harness/acp-agent.js +11 -6
  19. package/dist/harness/claude-code.js +201 -8
  20. package/dist/harness/codex.d.ts +4 -1
  21. package/dist/harness/codex.js +72 -13
  22. package/dist/harness/types.d.ts +57 -6
  23. package/dist/isolation/bubblewrap.d.ts +4 -4
  24. package/dist/isolation/bubblewrap.js +4 -4
  25. package/dist/isolation/policy.d.ts +1 -1
  26. package/dist/isolation/policy.js +2 -2
  27. package/dist/isolation/registry.d.ts +3 -3
  28. package/dist/isolation/registry.js +5 -5
  29. package/dist/isolation/resources.d.ts +1 -1
  30. package/dist/isolation/resources.js +1 -1
  31. package/dist/isolation/types.d.ts +3 -3
  32. package/dist/loops/manager.d.ts +30 -1
  33. package/dist/loops/manager.js +69 -6
  34. package/dist/loops/state.d.ts +18 -0
  35. package/dist/loops/state.js +4 -0
  36. package/dist/model-env.d.ts +71 -0
  37. package/dist/model-env.js +106 -0
  38. package/dist/monitor.d.ts +2 -2
  39. package/dist/monitor.js +4 -4
  40. package/dist/ops.d.ts +2 -2
  41. package/dist/ops.js +9 -9
  42. package/dist/owner-channel/channel.d.ts +17 -0
  43. package/dist/owner-channel/channel.js +104 -23
  44. package/dist/owner-channel/commands.d.ts +9 -0
  45. package/dist/owner-channel/commands.js +226 -99
  46. package/dist/owner-channel/notices.d.ts +7 -0
  47. package/dist/owner-channel/notices.js +9 -0
  48. package/dist/rooms-tasks/cli.js +733 -221
  49. package/dist/rooms-tasks/close.d.ts +35 -0
  50. package/dist/rooms-tasks/close.js +182 -0
  51. package/dist/rooms-tasks/config.js +8 -5
  52. package/dist/rooms-tasks/cowork-adapter.d.ts +22 -0
  53. package/dist/rooms-tasks/cowork-adapter.js +116 -0
  54. package/dist/rooms-tasks/external-worker.d.ts +2 -0
  55. package/dist/rooms-tasks/external-worker.js +40 -0
  56. package/dist/rooms-tasks/index.d.ts +2 -0
  57. package/dist/rooms-tasks/index.js +2 -0
  58. package/dist/rooms-tasks/markdown.d.ts +49 -0
  59. package/dist/rooms-tasks/markdown.js +206 -0
  60. package/dist/rooms-tasks/member-startup.d.ts +51 -0
  61. package/dist/rooms-tasks/member-startup.js +151 -0
  62. package/dist/rooms-tasks/provision.d.ts +8 -0
  63. package/dist/rooms-tasks/provision.js +363 -97
  64. package/dist/rooms-tasks/room-state.d.ts +13 -1
  65. package/dist/rooms-tasks/room-state.js +149 -1
  66. package/dist/rooms-tasks/task-state.d.ts +13 -2
  67. package/dist/rooms-tasks/task-state.js +131 -3
  68. package/dist/rooms-tasks/terminal.d.ts +23 -0
  69. package/dist/rooms-tasks/terminal.js +56 -0
  70. package/dist/rooms-tasks/types.d.ts +109 -4
  71. package/dist/rooms-tasks/types.js +1 -1
  72. package/dist/runner.d.ts +48 -0
  73. package/dist/runner.js +252 -100
  74. package/dist/session/acp.d.ts +106 -2
  75. package/dist/session/acp.js +217 -14
  76. package/dist/session/activity.d.ts +31 -0
  77. package/dist/session/activity.js +48 -0
  78. package/dist/session/control.d.ts +1 -1
  79. package/dist/session/conversation-normalizer.d.ts +7 -1
  80. package/dist/session/conversation-normalizer.js +154 -11
  81. package/dist/session/conversation-store.js +1 -1
  82. package/dist/session/conversation-types.d.ts +25 -6
  83. package/dist/session/types.d.ts +35 -0
  84. package/dist/spawn.d.ts +4 -2
  85. package/dist/spawn.js +39 -26
  86. package/dist/supervisor/launchd.d.ts +2 -2
  87. package/dist/supervisor/launchd.js +4 -4
  88. package/dist/supervisor/systemd.js +4 -4
  89. package/dist/supervisor/types.d.ts +1 -1
  90. package/dist/temp-lifecycle.d.ts +14 -2
  91. package/dist/temp-lifecycle.js +90 -0
  92. package/dist/watchdog/alerts.d.ts +2 -2
  93. package/dist/watchdog/alerts.js +2 -2
  94. package/dist/watchdog/briefing.d.ts +5 -5
  95. package/dist/watchdog/briefing.js +13 -6
  96. package/dist/watchdog/config.js +1 -1
  97. package/dist/watchdog/query.d.ts +2 -2
  98. package/dist/watchdog/query.js +2 -2
  99. package/dist/watchdog/report.js +2 -2
  100. package/dist/watchdog/run.d.ts +2 -2
  101. package/dist/watchdog/run.js +6 -6
  102. package/dist/watchdog/scheduler.d.ts +7 -7
  103. package/dist/watchdog/scheduler.js +10 -10
  104. package/dist/watchdog/service.d.ts +4 -4
  105. package/dist/watchdog/service.js +5 -6
  106. package/dist/watchdog/store.d.ts +4 -4
  107. package/dist/watchdog/store.js +4 -4
  108. package/dist/web/runtime.js +1 -1
  109. package/dist/web/server.js +1 -1
  110. package/dist/web/topology-promote.js +1 -1
  111. package/dist/web-app/assets/{TerminalView-BAVk1Bot.js → TerminalView-C_G1ID2P.js} +1 -1
  112. package/dist/web-app/assets/{index-C3S-xFRU.js → index-BCBK78hw.js} +5 -5
  113. package/dist/web-app/index.html +1 -1
  114. package/dist/worklog.d.ts +7 -1
  115. package/dist/worklog.js +191 -39
  116. package/package.json +1 -1
package/dist/docs.js CHANGED
@@ -271,6 +271,9 @@ roles:
271
271
  add_dirs: [/data/shared]
272
272
  config:
273
273
  model_reasoning_effort: high
274
+ mcp_servers: # claude-code: per-role MCP servers, additive by default
275
+ ours: { command: ours-mcp, args: [proxy] }
276
+ mcp_servers_only: false # true = ONLY these; drops user/project/plugin servers
274
277
  bio: Public role card and when peers should engage it.
275
278
  persona: Local operating contract, boundaries, and escalation policy.
276
279
  briefing_file: /absolute/custom-briefing.md
@@ -315,6 +318,68 @@ Role values override defaults. \`\${name}\` substitutes entries from \`vars\`.
315
318
  Other role fields include \`max_tokens\`, \`autocompact_pct\`, and \`isolation\`.
316
319
  Use README.md for the complete isolation policy and resource-cap schema.
317
320
 
321
+ Supervised roles connect to the operator-configured ours daemon; they do not own its
322
+ lifecycle. Fleet forces \`OURS_AUTOSTART=0\` in tmux and ACP child processes after role
323
+ environment overlays. Start the shared daemon only through an explicit operator or
324
+ installer/setup flow.
325
+
326
+ ## Rooms and tasks
327
+
328
+ Rooms always use \`ours-cowork\`; there is no room-provider selector. Configure
329
+ the cowork daemon connection and room owner directly:
330
+
331
+ \`\`\`yaml
332
+ rooms:
333
+ cowork:
334
+ config: /home/me/.ours-cowork/config.json
335
+ owner:
336
+ expected_cid: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
337
+ public_invite_file: /home/me/.ours-fleet/owner-room-invite.txt
338
+ defaults:
339
+ template: team
340
+ attach_owner: true
341
+ close_when_task_done: true
342
+ tasks:
343
+ default_room_template: team
344
+ create_mode: start
345
+ close_room_on_done: true
346
+ \`\`\`
347
+
348
+ Human task and room results use the same compact Markdown presentation in the
349
+ CLI and authenticated owner channel: a short heading, icon-plus-word status,
350
+ code-formatted identifiers, bounded summaries, and actionable recovery or error
351
+ steps. Untrusted prose is context-escaped and control characters are neutralized;
352
+ Messenger-bound results are capped at 3,500 Unicode code points and 12,000 UTF-8
353
+ bytes with structural omission notices. \`--json\` bypasses this presentation layer
354
+ and retains the versioned machine schema and serialization order.
355
+
356
+ Older prerelease files with the exact legacy \`provider: cowork\` key under
357
+ \`rooms:\` still load, but the key is ignored and omitted from resolved
358
+ configuration. Remove it when editing the file. Any other legacy value is an
359
+ error. The optional \`rooms.owner.provider\` setting is separate and defaults to
360
+ \`messenger-server\`.
361
+
362
+ Finish and Delete are distinct terminal task actions:
363
+
364
+ \`ours-fleet task finish <id>\` moves an active or review task to \`done\` and
365
+ deletes its associated Cowork room after retiring its members. The room then
366
+ disappears from normal Fleet and Cowork views; its brief, messages, repository
367
+ references, and attachments are not retained as an inspectable archive.
368
+ The prerelease configuration names \`tasks.close_room_on_done\` and
369
+ \`rooms.defaults.close_when_task_done\` are retained for compatibility, but
370
+ \`true\` now means this close-then-delete behavior.
371
+
372
+ \`ours-fleet room delete <id> <id>\` is the canonical destructive room command.
373
+ \`room close <id> <id>\` remains a deprecated alias with identical deletion
374
+ semantics. Older prerelease \`closed\` room records are deleted directly the next
375
+ time \`room list\` reconciles Fleet with Cowork.
376
+
377
+ \`ours-fleet task delete <id> <id>\` removes only a \`done\` task's Fleet backlog
378
+ record. The exact task ID is required twice for confirmation. Delete rejects every
379
+ other task state, never changes any room state, and reports an already-missing
380
+ task as an idempotent no-op. The Owner-channel equivalent is
381
+ \`/task delete <id> <id>\`.
382
+
318
383
  ## Permissions
319
384
 
320
385
  Prefer the harness-neutral \`permissions\` block:
@@ -400,10 +465,14 @@ permissions through its harness and check the result against a fixed floor:
400
465
  deny those requests with nobody to see it; with \`unattended: wait\` it warns,
401
466
  because a human can still attach and answer.
402
467
 
403
- Security meaning: \`ask\` maps to Codex \`untrusted\` and Claude \`default\`;
404
- \`auto\` maps to Codex \`on-request\` and Claude \`acceptEdits\`; and
405
- \`approval: allow\` maps to Codex \`never\` and Claude \`bypassPermissions\`,
406
- which genuinely permits the actions the role was authorized to take —
468
+ Security meaning: \`ask\` maps to Codex \`untrusted\` and Claude \`default\`.
469
+ \`auto\` selects Codex ACP \`agent\` (\`on-request\` + \`workspace-write\`) and
470
+ Claude \`acceptEdits\`. \`approval: allow\` selects Codex ACP's fully
471
+ non-interactive yolo mode, reported as \`agent-full-access\` (\`never\` +
472
+ \`danger-full-access\`), and Claude \`bypassPermissions\`. Codex tmux retains
473
+ independent approval and sandbox flags: \`auto\` is \`on-request\`, \`allow\`
474
+ is \`never\`, and \`filesystem\` still selects the sandbox. These modes genuinely
475
+ permit the actions the role was authorized to take —
407
476
  \`dontAsk\` only suppresses the prompt while still refusing the action. Nothing
408
477
  other than an explicit \`allow\` becomes non-interactive. Legacy \`deny\` keeps
409
478
  its conservative Codex \`on-request\` / Claude \`plan\` translation. \`allow\` is therefore a real grant and
@@ -414,20 +483,45 @@ ACP carries agent-advertised session mode IDs and \`session/set_mode\`, but thos
414
483
  IDs are agent-specific and ACP defines no portable permission-policy capability.
415
484
  Fleet therefore uses the ACP primitive where an adapter exposes a matching mode
416
485
  and otherwise performs the harness translation above. The bundled Codex ACP
417
- adapter couples approval and sandboxing in its advertised mode IDs, so fleet
418
- keeps the selected sandbox preset and enforces the independently translated
419
- approval policy on the app-server turn request. For example, \`allow\` plus
420
- \`workspace\` is really \`approval=never sandbox=workspace-write\`; it is never
421
- widened to \`danger-full-access\`. The live session reports both its effective
422
- normalized mode and the ACP sandbox-preset ID.
486
+ adapter couples approval and sandboxing in its advertised mode IDs. Neutral
487
+ \`allow\` therefore selects \`agent-full-access\` and widens \`filesystem:
488
+ workspace\` or \`read-only\` to \`danger-full-access\`; neutral \`auto\` selects
489
+ \`agent\` and \`workspace-write\` even when the neutral filesystem value differs.
490
+ An explicit \`harness_options.sandbox\` selects its corresponding ACP preset and
491
+ still wins, as does an explicit native approval override. \`config\` and
492
+ \`doctor\` report a coupled-mode mismatch as approximate. Use per-role
493
+ \`isolation:\` as the outer boundary for an \`allow\` ACP role. The live session
494
+ reports both its effective normalized mode and the exact native mode selected.
423
495
 
424
496
  See also: \`spawn --approval/--filesystem/--unattended\` set this intent at
425
497
  creation, and \`ours-fleet config\` prints each role's neutral settings, their
426
498
  native translation, and any warning — the same text \`doctor\` reports.
427
499
 
428
500
  Claude \`harness_options\`: \`permission_mode\` (default, acceptEdits, plan,
429
- dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`, and
430
- \`mem_palace_midsession_autosave\`.
501
+ dontAsk, bypassPermissions), \`plugins\`, \`mem_palace\`,
502
+ \`mem_palace_midsession_autosave\`, \`mcp_servers\` and \`mcp_servers_only\`.
503
+
504
+ \`mcp_servers\` declares MCP servers for the role, in \`.mcp.json\`'s own shape
505
+ (a map of name to \`{ command, args, env }\`, or \`{ type: http|sse, url,
506
+ headers }\`). By default they are ADDED to whatever the OS user running the role
507
+ already has configured, on both session types: tmux passes \`--mcp-config\`, and
508
+ ACP sends them in \`session/new\`.
509
+
510
+ \`mcp_servers_only: true\` makes the declared set EXCLUSIVE — \`--strict-mcp-config\`
511
+ on tmux, \`strictMcpConfig\` on ACP. It is all-or-nothing and it ignores every
512
+ other MCP configuration: project \`.mcp.json\`, user settings, and **plugins**.
513
+ The ours connector is normally installed as a plugin, so a strict role that does
514
+ not re-declare it has no \`send_message\` and no \`get_messages\` — it cannot even
515
+ report that it has gone mute. Fleet therefore refuses a strict role whose
516
+ \`mcp_servers\` does not name the connector; declare it explicitly, e.g.
517
+ \`ours: { command: ours-mcp, args: [proxy] }\`.
518
+
519
+ Both options, and \`plugins\`, reach an ACP session through the bundled Claude ACP
520
+ agent's \`_meta\` vocabulary. A role that sets \`session_options.acp.command\` runs
521
+ an agent fleet did not choose and cannot be promised them, so that combination is
522
+ refused at validation rather than accepted and dropped. This narrows a role's
523
+ tool surface; it does not stop the harness deferring tool schemas, which is the
524
+ harness's own decision.
431
525
 
432
526
  Codex \`harness_options\`: \`launcher\` (auto, ours-codex, codex), \`sandbox\`
433
527
  (read-only, workspace-write, danger-full-access), \`approval\` or
@@ -522,12 +616,11 @@ owner of that authenticated source wire instead of the latest conversation.
522
616
  Every other CID is rejected and warned about without reflecting its body. Fleet sends
523
617
  accepted/queued/progress/interrupted/failure notices and routes the ACP turn's
524
618
  final assistant text back to the authenticated sender with its source wire ID.
525
- For file replies, fleet injects a request-specific outbox path into the owner
526
- prompt. The agent copies completed artifacts there; fleet sends every regular
527
- file from the channel identity with the same source wire ID and removes the
528
- temporary outbox only after successful delivery. For proactive or in-turn agent
529
- attachments, the agent calls ours \`send_file\` to the channel identity and may
530
- pair it with a reply-linked caption; fleet, not the agent, chooses the owner.
619
+ For file replies of every kind — a response artifact, a proactive note, or an
620
+ in-turn attachment — the agent calls ours \`send_file\` to the channel identity
621
+ and may pair it with a reply-linked caption; fleet, not the agent, chooses the
622
+ owner. That is the only delivery route an agent is given: a tool call either
623
+ delivers or reports an error, where a file written to disk does neither.
531
624
  Owner messages whose trimmed text starts with \`/\` are deterministic
532
625
  supervisor commands and never enter the model: \`/help\` (alias \`/commands\`),
533
626
  \`/status\`, \`/comments [status|on|off]\`, \`/interrupt\`, \`/clear\`,
@@ -674,10 +767,38 @@ escape hatch.
674
767
 
675
768
  ## Bounded worklogs, auth proxy, and model recovery
676
769
 
677
- An optional \`worklog: { max_kb, keep_tail_kb, max_archives }\` policy rotates a
678
- stable snapshot at fleet-owned lifecycle points. Concurrent changes defer
679
- rotation. Archives remain beside WORKLOG.md with the same sensitive-state
680
- boundary; retention deletes only recognized fleet archive names.
770
+ WORKLOG rotation is enabled by default with
771
+ \`worklog: { max_kb: 1024, keep_tail_kb: 256, max_archives: 12 }\`. Maps may
772
+ override individual values; \`worklog: false\` on a role or in defaults opts out.
773
+ Fleet rotates only at that role's launch/resume lifecycle boundary. Concurrent
774
+ changes defer rotation. The active file keeps a bounded UTF-8 tail and advances
775
+ to a line boundary when a complete line fits. If one logical line alone exceeds
776
+ the budget, its newest suffix remains and the rotation manifest records the
777
+ mid-line start and omitted byte count. The complete prior inode receives a
778
+ collision-safe UTC archive name, and
779
+ \`.worklog-rotation.json\` records restart provenance. \`max_archives\` bounds
780
+ recent archives beside WORKLOG.md; older complete archives move to
781
+ \`WORKLOG.archives/\` without deletion. All archives share the role's sensitive
782
+ state boundary. Fleet refuses a symlinked/non-regular live log or a symlinked
783
+ cold-archive boundary before replacing the live path and best-effort removes a
784
+ duplicate publication left by a detected failure while the original inode is
785
+ still available. The manifest records SHA-256 digests for the archive and live
786
+ bytes observed when it is written. These checks address ordinary path hazards,
787
+ not intentional path mutation by a malicious concurrent process with the same
788
+ Unix authority; that is outside the threat model and requires OS-level isolation.
789
+
790
+ ACP tool diffs are bounded before entering web conversation events. Existing
791
+ small before/after diffs are unchanged. Oversized whole-file snapshots are
792
+ reduced to the actual changed region plus path, operation, original byte counts,
793
+ digest, and omission metadata. Each retained side is a newest-content UTF-8 tail
794
+ of at most 64 KiB, advanced to a line boundary when a complete line fits. An
795
+ overlong single line keeps its newest suffix and explicitly records a mid-line
796
+ start. Paths retain at most a 4 KiB suffix with byte count, digest, and omitted
797
+ prefix metadata; the complete normalized update is capped at 320 KiB. A large
798
+ append therefore retains current appended content, not the historical prefix.
799
+ The live web-console transcript includes only the current runner generation and
800
+ excludes adapter session/load replay. Replayed events remain durable with
801
+ agent_replay provenance for diagnosis and recovery.
681
802
 
682
803
  \`auth_proxy: { kind: anthropic, base_url, required, health_url }\` is Claude-only
683
804
  and loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects
@@ -694,7 +815,7 @@ fail-closed and held down. Change the declared chain/model and restart to
694
815
  reconcile explicitly; no chain preserves detection-only behavior.
695
816
  `;
696
817
  /**
697
- * What every shipped spawn-skill variant must say, and must not say (7.1).
818
+ * What every shipped spawn-skill variant must say, and must not say.
698
819
  *
699
820
  * The skills are separate markdown files in two published plugins, written for
700
821
  * two different harnesses, so they cannot literally be one file. This is the
@@ -724,7 +845,7 @@ export const SPAWN_SKILL_CONTRACT = {
724
845
  // The only intent that clears the floor, and the honest alternative.
725
846
  '--approval allow',
726
847
  '--unattended wait',
727
- // Creation-time isolation (6.3) — the one new operator input this release adds.
848
+ // Creation-time isolation — the one new operator input this release adds.
728
849
  '--isolation-file',
729
850
  ],
730
851
  /**
package/dist/doctor.js CHANGED
@@ -106,7 +106,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
106
106
  ok: true,
107
107
  detail: `warning: ${diagnostic.message}`,
108
108
  });
109
- // ── Rooms/tasks checks (§5.3) ────────────────────────────────────────
109
+ // ── Rooms/tasks checks ────────────────────────────────────────
110
110
  if (loaded.ok && loaded.rooms) {
111
111
  const rooms = loaded.rooms;
112
112
  // Cowork socket reachability
@@ -183,7 +183,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
183
183
  : `cowork config not found: ${rooms.cowork.config}`,
184
184
  });
185
185
  }
186
- // Hard prerelease capability check
186
+ // Hard room-management capability check
187
187
  try {
188
188
  const { createCoworkAdapter } = await import('./rooms-tasks/cowork-adapter.js');
189
189
  const adapter = createCoworkAdapter({ configPath: coworkConfig });
@@ -199,7 +199,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
199
199
  checks.push({
200
200
  name: 'rooms: capability', ok: false,
201
201
  detail: isProto
202
- ? `cowork does not support room management protocol — upgrade ours-cowork to prerelease`
202
+ ? `cowork does not support room management protocol — upgrade ours-cowork to a version that supports the room management protocol`
203
203
  : `room management check failed: ${msg}`,
204
204
  });
205
205
  }
@@ -271,7 +271,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
271
271
  : `no XDG_RUNTIME_DIR and /run/user/<uid> missing — systemctl --user cannot reach the user manager; enable linger: sudo loginctl enable-linger ${user}`,
272
272
  });
273
273
  }
274
- // Per-role permission translation (2.3). Rendered from the same analysis the
274
+ // Per-role permission translation. Rendered from the same analysis the
275
275
  // `config` command prints, so the two commands cannot disagree.
276
276
  for (const analysis of analyzeFleetPermissions(roles)) {
277
277
  if (!analysis.supported) {
@@ -290,13 +290,13 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
290
290
  ? `${summary} — ${analysis.warnings.join('; ')}`
291
291
  : `${summary} (exact)`,
292
292
  });
293
- // A role that states its permission intent twice, in two disagreeing places
294
- // (2.4). Quiet when there is a single source of intent.
293
+ // Report a role that states its permission intent twice in disagreeing places.
294
+ // Stay quiet when there is a single source of intent.
295
295
  for (const conflict of analysis.conflicts ?? [])
296
296
  checks.push({
297
297
  name: `permission conflict: ${analysis.role}`, ok: true, detail: conflict.warning,
298
298
  });
299
- // The floor is checked BEFORE start (2.1): an under-permissioned unattended
299
+ // The floor is checked BEFORE start: an under-permissioned unattended
300
300
  // role never reports its own failure, because the denial happens inside the
301
301
  // harness with nobody attached to see it.
302
302
  const floor = analysis.floor;
@@ -342,7 +342,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
342
342
  });
343
343
  }
344
344
  // Isolation reporting (AC-9). Backend availability is advisory — isolation is
345
- // opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
345
+ // opt-in per role, so a missing bwrap must not fail doctor for fleets that
346
346
  // don't use it. Only a role that DECLARES isolation and cannot get it under
347
347
  // `strict` is a hard failure.
348
348
  const bw = await makeBubblewrapBackend(exec).available();
@@ -355,7 +355,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
355
355
  if (platform === 'linux')
356
356
  checks.push({ name: 'isolation: cgroup delegation', ok: true, detail: cgroupDelegationDetail() });
357
357
  for (const r of roles.filter(r => r.isolation)) {
358
- // A refused mount is a launch-blocking policy error (5.2), not a warning.
358
+ // A refused mount is a launch-blocking policy error, not a warning.
359
359
  let policy;
360
360
  try {
361
361
  policy = resolveIsolation(r.isolation, isolationContextFor(r));
@@ -385,7 +385,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
385
385
  detail = `backend=${policy.backend} (not yet implemented)`;
386
386
  checks.push({ name: `isolation: ${r.name}`, ok, detail });
387
387
  }
388
- // Monitor daemon-API reachability (design §5): only when a role is supervised.
388
+ // Probe monitor daemon-API reachability only when a role is supervised.
389
389
  // /state-dir is unauthenticated (liveness); /identities exercises the token so a
390
390
  // shared-mode misconfig (401) surfaces here rather than as a silent deaf monitor.
391
391
  const monitorProfiles = resolveMonitorProfiles(roles);
package/dist/duration.js CHANGED
@@ -1,4 +1,4 @@
1
- /** Parse `30s | 10m | 2h` duration strings to milliseconds (spec §2). */
1
+ /** Parse `30s | 10m | 2h` duration strings to milliseconds. */
2
2
  const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
3
3
  const RE = /^(\d+)([smhd])$/;
4
4
  export function parseDuration(text, opts = {}) {
@@ -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,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
  /**
@@ -149,7 +239,7 @@ export function makeClaudeCodeAdapter(exec = realExec) {
149
239
  }],
150
240
  };
151
241
  },
152
- validateOptions(opts) {
242
+ validateOptions(opts, role) {
153
243
  if (opts == null)
154
244
  return [];
155
245
  if (typeof opts !== 'object' || Array.isArray(opts))
@@ -157,9 +247,44 @@ export function makeClaudeCodeAdapter(exec = realExec) {
157
247
  const errors = Object.keys(opts)
158
248
  .filter(k => !OPTION_KEYS.includes(k))
159
249
  .map(k => ({ path: `harness_options.${k}`, message: `unknown option; allowed: ${OPTION_KEYS.join(', ')}` }));
160
- const effort = opts.effort;
250
+ const o = opts;
251
+ const effort = o.effort;
161
252
  if (effort != null && !EFFORT_LEVELS.includes(effort))
162
253
  errors.push({ path: 'harness_options.effort', message: `must be one of: ${EFFORT_LEVELS.join(', ')}` });
254
+ if (o.mcp_servers_only != null && typeof o.mcp_servers_only !== 'boolean')
255
+ errors.push({ path: 'harness_options.mcp_servers_only', message: 'must be a boolean' });
256
+ errors.push(...validateMcpServers(o.mcp_servers));
257
+ if (o.mcp_servers_only === true && !o.mcp_servers)
258
+ errors.push({
259
+ path: 'harness_options.mcp_servers_only',
260
+ message: 'requires harness_options.mcp_servers; on its own it would leave the role with no MCP servers at all',
261
+ });
262
+ // The muteness gate. Only when the declared set is otherwise well-formed —
263
+ // a shape error already told the operator to look here.
264
+ if (o.mcp_servers_only === true && o.mcp_servers && errors.length === 0
265
+ && !declaresOursConnector(o.mcp_servers))
266
+ errors.push({
267
+ path: 'harness_options.mcp_servers',
268
+ message: 'mcp_servers_only ignores every other MCP configuration, INCLUDING plugins — and the ours '
269
+ + 'connector is normally a plugin, so this role would have no send_message or get_messages and no way '
270
+ + 'to report that. Declare it explicitly, e.g. ours: { command: ours-mcp, args: [proxy] }',
271
+ });
272
+ // Session-aware refusals. Both options reach an ACP session through the
273
+ // bundled agent's `_meta` vocabulary, so a role that launches a DIFFERENT
274
+ // ACP agent cannot be promised either one. Refuse rather than send it and
275
+ // hope: silently dropping the config is the defect being fixed here.
276
+ if (role && customAcpCommand(role)) {
277
+ for (const key of ['plugins', 'mcp_servers', 'mcp_servers_only']) {
278
+ if (o[key] == null)
279
+ continue;
280
+ errors.push({
281
+ path: `harness_options.${key}`,
282
+ message: 'cannot be honoured with session_options.acp.command: it is delivered through the bundled '
283
+ + 'Claude ACP agent\'s _meta vocabulary, which another agent has no reason to read. Drop the '
284
+ + 'custom ACP command, or drop this option',
285
+ });
286
+ }
287
+ }
163
288
  return errors;
164
289
  },
165
290
  async prepareSession(role, dirs) {
@@ -178,22 +303,48 @@ export function makeClaudeCodeAdapter(exec = realExec) {
178
303
  CLAUDE_AUTOCOMPACT_PCT_OVERRIDE: String(autocompactPct(role)),
179
304
  MEMPALACE_HOOKS_AUTO_SAVE: 'false',
180
305
  MEMPALACE_MIDSESSION_AUTOSAVE: o.mem_palace_midsession_autosave ? 'true' : 'false',
306
+ // The role's identity, for the ours connector to bind at startup instead of
307
+ // the briefing telling the MODEL to call choose_identity. Both launches
308
+ // return `prep.env`, so this one line covers tmux and ACP alike.
309
+ //
310
+ // The bind the connector performs is PLAIN and fail-closed: it can never
311
+ // evict a live session, and a role whose identity does not exist yet simply
312
+ // boots unbound and falls through to the briefing's create-if-missing step.
313
+ // Nothing here may ever grow a force flag.
314
+ OURS_BIND_IDENTITY: role.identity,
181
315
  };
182
316
  if (!memPalace)
183
317
  env.MEMPALACE_DISABLED = 'true';
184
- // Per-role harness runtime home (5.1). Created before sandbox entry so the
318
+ // Per-role harness runtime home. Created before sandbox entry so the
185
319
  // bind has something to mount; harmless for un-isolated roles.
186
320
  // Only a role that declares `isolation:` gets a sandbox, and only a
187
321
  // sandbox needs this directory to exist before entry.
188
322
  if (role.isolation)
189
323
  mkdirSync(harnessRuntimeDir(dirs.stateDir, 'claude'), { recursive: true });
190
324
  const argv = [];
325
+ let settingsOverlay;
191
326
  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);
327
+ settingsOverlay = join(dirs.stateDir, '.settings-overlay.json');
328
+ writeFileSync(settingsOverlay, JSON.stringify({ enabledPlugins }, null, 2));
329
+ argv.push('--settings', settingsOverlay);
195
330
  }
196
- return { argv, env };
331
+ // `harness_options.mcp_servers` the tmux delivery. `--mcp-config` ADDS the
332
+ // file's servers; `--strict-mcp-config` is what makes the set exclusive, and
333
+ // it is opt-in per role because it drops everything else the user has,
334
+ // plugins included (see `declaresOursConnector`).
335
+ let mcpConfigFile;
336
+ if (o.mcp_servers) {
337
+ mcpConfigFile = join(dirs.stateDir, '.mcp-config.json');
338
+ writeFileSync(mcpConfigFile, JSON.stringify({ mcpServers: o.mcp_servers }, null, 2), { mode: 0o600 });
339
+ argv.push('--mcp-config', mcpConfigFile);
340
+ if (o.mcp_servers_only === true)
341
+ argv.push('--strict-mcp-config');
342
+ }
343
+ return {
344
+ argv, env,
345
+ ...(settingsOverlay ? { settingsOverlay } : {}),
346
+ ...(mcpConfigFile ? { mcpConfigFile } : {}),
347
+ };
197
348
  },
198
349
  buildLaunch(role, mode, s, prep) {
199
350
  const stateDir = roleStateDir(role);
@@ -223,6 +374,46 @@ export function makeClaudeCodeAdapter(exec = realExec) {
223
374
  acpPermissionModeId(role) {
224
375
  return permissionMode(role);
225
376
  },
377
+ /**
378
+ * Deliver, over ACP, the two things the tmux launch delivers as flags.
379
+ *
380
+ * `buildAcpLaunch` builds its own argv and cannot carry `prep.argv`: the
381
+ * process it launches is the ACP agent, not `claude`, and it takes none of
382
+ * claude's flags. That is why `harness_options.plugins` did nothing at all on
383
+ * an ACP role — the overlay was written and then dropped, and the mem-palace
384
+ * toggle rode `prep.env` and survived, so the failure was silent AND
385
+ * selective.
386
+ *
387
+ * `_meta.claudeCode.options` is the bundled agent's own passthrough into the
388
+ * Claude Agent SDK (@agentclientprotocol/claude-agent-acp, acp-agent.js:4092
389
+ * → the `options` object at :4144). `settings` takes the same overlay path
390
+ * `--settings` takes; `strictMcpConfig` is the SDK's spelling of
391
+ * `--strict-mcp-config`. Both are spread BEFORE the fields the agent forces,
392
+ * so neither is overwritten.
393
+ *
394
+ * ⚠ RETURNS NOTHING FOR A ROLE THAT NAMES ITS OWN ACP COMMAND. That process
395
+ * is not the bundled agent and has no reason to read this vocabulary; sending
396
+ * it anyway would be the silent drop again, one level down. `validateOptions`
397
+ * refuses those roles instead.
398
+ */
399
+ acpSessionMeta(role, prep) {
400
+ if (customAcpCommand(role))
401
+ return undefined;
402
+ const options = {};
403
+ if (prep.settingsOverlay)
404
+ options.settings = prep.settingsOverlay;
405
+ if (role.harness_options?.mcp_servers_only === true)
406
+ options.strictMcpConfig = true;
407
+ return Object.keys(options).length ? { claudeCode: { options } } : undefined;
408
+ },
409
+ /**
410
+ * The declared servers, in ACP's array shape. Sent on `session/new` and on
411
+ * resume/load, because the SDK builds its server set once per session and a
412
+ * resumed session that dropped them would quietly lose its tools.
413
+ */
414
+ acpMcpServers(role) {
415
+ return acpMcpServersFor(role.harness_options?.mcp_servers);
416
+ },
226
417
  isolationPaths(_role, _dirs) {
227
418
  const claudeHome = join(home(), '.claude');
228
419
  return {
@@ -285,6 +476,8 @@ export function makeClaudeCodeAdapter(exec = realExec) {
285
476
  currentIdentityTool: 'current_identity',
286
477
  sendTool: 'send_message',
287
478
  getMessagesTool: 'get_messages',
479
+ listHistoryTool: 'list_history',
480
+ getHistoryItemTool: 'get_history_item',
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;