@ours.network/fleet 0.11.1 → 0.13.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 (43) hide show
  1. package/README.md +225 -0
  2. package/dist/briefing.js +25 -0
  3. package/dist/cli.js +408 -1
  4. package/dist/config.d.ts +31 -1
  5. package/dist/config.js +123 -2
  6. package/dist/docs.d.ts +1 -1
  7. package/dist/docs.js +143 -0
  8. package/dist/duration.js +7 -3
  9. package/dist/index.d.ts +2 -1
  10. package/dist/index.js +1 -0
  11. package/dist/loops/config.d.ts +30 -0
  12. package/dist/loops/config.js +135 -0
  13. package/dist/loops/manager.d.ts +48 -0
  14. package/dist/loops/manager.js +237 -0
  15. package/dist/loops/state.d.ts +54 -0
  16. package/dist/loops/state.js +148 -0
  17. package/dist/monitor.js +26 -2
  18. package/dist/owner-channel/attachments.d.ts +74 -0
  19. package/dist/owner-channel/attachments.js +378 -0
  20. package/dist/owner-channel/channel.d.ts +167 -0
  21. package/dist/owner-channel/channel.js +874 -0
  22. package/dist/owner-channel/mcp.d.ts +24 -0
  23. package/dist/owner-channel/mcp.js +123 -0
  24. package/dist/owner-channel/notices.d.ts +21 -0
  25. package/dist/owner-channel/notices.js +66 -0
  26. package/dist/owner-channel/state.d.ts +44 -0
  27. package/dist/owner-channel/state.js +184 -0
  28. package/dist/owner-channel/tasks.d.ts +62 -0
  29. package/dist/owner-channel/tasks.js +246 -0
  30. package/dist/resolved-plan.js +12 -0
  31. package/dist/runner.d.ts +3 -0
  32. package/dist/runner.js +112 -5
  33. package/dist/session/acp.d.ts +4 -2
  34. package/dist/session/acp.js +82 -25
  35. package/dist/session/arbiter.d.ts +42 -0
  36. package/dist/session/arbiter.js +72 -0
  37. package/dist/session/control.d.ts +12 -1
  38. package/dist/session/control.js +56 -3
  39. package/dist/session/types.d.ts +28 -2
  40. package/dist/session/types.js +5 -2
  41. package/dist/spawn.js +7 -3
  42. package/dist/supervisor/systemd.js +12 -2
  43. package/package.json +1 -1
package/dist/config.js CHANGED
@@ -5,11 +5,21 @@ import { parseFleetDocument, } from './config-yaml.js';
5
5
  import { harnessRuntimeDir, resolveIsolation, validateIsolationConfig, } from './isolation/policy.js';
6
6
  import { getAdapter } from './harness/registry.js';
7
7
  import { resolveWatchdogs } from './watchdog/config.js';
8
+ import { resolveLoops } from './loops/config.js';
8
9
  /** The 8 content-free event types the ours daemon appends to notifications.log. */
9
10
  export const NOTIFY_EVENT_TYPES = [
10
11
  'message_received', 'file_received', 'sibling_contact_added', 'local_contact_request',
11
12
  'pending_message', 'contact_restored', 'inbound_error', 'state_import_failed',
12
13
  ];
14
+ export const DEFAULT_OWNER_ATTACHMENT_MIME = [
15
+ 'application/pdf', 'application/json', 'text/plain',
16
+ 'image/png', 'image/jpeg', 'image/gif', 'image/webp',
17
+ 'audio/ogg', 'audio/mpeg', 'audio/wav', 'audio/x-wav', 'audio/mp4', 'audio/webm',
18
+ 'application/msword', 'application/vnd.ms-excel', 'application/vnd.ms-powerpoint',
19
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
20
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
21
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
22
+ ];
13
23
  /** Default wake sources when a role does not list its own (design §2). */
14
24
  export const DEFAULT_WAKE_SOURCES = ['message_received', 'file_received', 'local_contact_request', 'pending_message'];
15
25
  const MONITOR_KEYS = [
@@ -106,7 +116,7 @@ export const ROLE_NAME_RE = /^[A-Za-z0-9_-]+$/;
106
116
  const ROLE_KEYS = [
107
117
  'harness', 'session', 'session_options', 'permissions', 'identity', 'cwd', 'coordinator', 'mission', 'persona', 'bio',
108
118
  'briefing_file', 'model', 'model_chain', 'max_tokens', 'autocompact_pct', 'env', 'oversee', 'harness_options',
109
- 'isolation', 'monitor', 'worklog', 'auth_proxy',
119
+ 'isolation', 'monitor', 'owner_channel', 'worklog', 'auth_proxy',
110
120
  ];
111
121
  function deepSub(v, vars) {
112
122
  if (typeof v === 'string')
@@ -186,6 +196,7 @@ export function loadConfig(configPath, options = {}) {
186
196
  throw new ConfigError(`${file}: role '${name}' ${problems.join('; ')}`);
187
197
  }
188
198
  const monitor = resolveMonitorConfig(defaults.monitor, r.monitor, { base, file, name });
199
+ const ownerChannel = resolveOwnerChannelConfig(defaults.owner_channel, r.owner_channel, session, file, name);
189
200
  const worklog = resolveWorklogPolicy(defaults.worklog, r.worklog, file, name);
190
201
  const authProxy = resolveAuthProxy(defaults.auth_proxy, r.auth_proxy, file, name);
191
202
  const harness = r.harness ?? defaults.harness ?? 'claude-code';
@@ -218,9 +229,11 @@ export function loadConfig(configPath, options = {}) {
218
229
  harness_options: harnessOptions,
219
230
  isolation,
220
231
  monitor,
232
+ owner_channel: ownerChannel,
221
233
  worklog,
222
234
  auth_proxy: authProxy,
223
235
  env: Object.keys(env).length ? env : undefined,
236
+ loops: [],
224
237
  });
225
238
  // Forbidden-path enforcement (5.2): a mount that would breach the policy
226
239
  // is a configuration error, caught by `config` rather than at launch.
@@ -235,8 +248,116 @@ export function loadConfig(configPath, options = {}) {
235
248
  }
236
249
  }
237
250
  }
251
+ validateOwnerChannelIdentities(roles);
238
252
  const watchdogs = resolveWatchdogs(baseDoc, base, roles, vars, defaults);
239
- return { roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs };
253
+ const resolvedLoops = resolveLoops(baseDoc.loops, base, roles, vars);
254
+ for (const role of roles)
255
+ role.loops = resolvedLoops.byRole.get(role.name) ?? [];
256
+ return {
257
+ roles, vars, defaults, files, startStaggerMs, diagnostics, watchdogs,
258
+ loops: resolvedLoops.loops,
259
+ };
260
+ }
261
+ export function resolveOwnerChannelConfig(defaults, role, session, file = 'config', name = 'role') {
262
+ if (defaults === undefined && role === undefined)
263
+ return undefined;
264
+ if (defaults !== undefined && !isPlainObject(defaults))
265
+ throw new ConfigError(`${file}: defaults.owner_channel must be a map`);
266
+ if (role !== undefined && !isPlainObject(role))
267
+ throw new ConfigError(`${file}: role '${name}' owner_channel must be a map`);
268
+ const defaultInput = (defaults ?? {});
269
+ const merged = {
270
+ ...defaultInput,
271
+ ...(role ?? {}),
272
+ };
273
+ const allowed = ['identity', 'owners', 'interrupt', 'progress_interval_ms', 'attachments'];
274
+ const bad = Object.keys(merged).filter(key => !allowed.includes(key));
275
+ if (bad.length)
276
+ throw new ConfigError(`${file}: role '${name}' owner_channel: unknown key(s) ${bad.join(', ')}`);
277
+ if (typeof merged.identity !== 'string' || !merged.identity.trim())
278
+ throw new ConfigError(`${file}: role '${name}' owner_channel.identity must be a non-blank string`);
279
+ if (!Array.isArray(merged.owners) || merged.owners.length === 0
280
+ || merged.owners.some(owner => typeof owner !== 'string' || !owner.trim()))
281
+ throw new ConfigError(`${file}: role '${name}' owner_channel.owners must be a non-empty list of contact IDs`);
282
+ const owners = merged.owners.map(owner => owner.trim());
283
+ if (new Set(owners).size !== owners.length)
284
+ throw new ConfigError(`${file}: role '${name}' owner_channel.owners must not contain duplicates`);
285
+ if (merged.interrupt !== undefined && typeof merged.interrupt !== 'boolean')
286
+ throw new ConfigError(`${file}: role '${name}' owner_channel.interrupt must be true or false`);
287
+ if (merged.progress_interval_ms !== undefined
288
+ && (typeof merged.progress_interval_ms !== 'number'
289
+ || !Number.isFinite(merged.progress_interval_ms) || merged.progress_interval_ms < 0))
290
+ throw new ConfigError(`${file}: role '${name}' owner_channel.progress_interval_ms must be a non-negative number`);
291
+ if (defaultInput.attachments !== undefined && !isPlainObject(defaultInput.attachments))
292
+ throw new ConfigError(`${file}: defaults.owner_channel.attachments must be a map`);
293
+ if (role?.attachments !== undefined && !isPlainObject(role.attachments))
294
+ throw new ConfigError(`${file}: role '${name}' owner_channel.attachments must be a map`);
295
+ const attachments = {
296
+ ...(defaultInput.attachments ?? {}), ...(role?.attachments ?? {}),
297
+ };
298
+ const attachmentKeys = [
299
+ 'enabled', 'max_files_per_request', 'max_file_bytes', 'max_request_bytes',
300
+ 'retention_ms', 'allowed_mime',
301
+ ];
302
+ const badAttachmentKeys = Object.keys(attachments)
303
+ .filter(key => !attachmentKeys.includes(key));
304
+ if (badAttachmentKeys.length)
305
+ throw new ConfigError(`${file}: role '${name}' owner_channel.attachments: unknown key(s) ${badAttachmentKeys.join(', ')}`);
306
+ if (attachments.enabled !== undefined && typeof attachments.enabled !== 'boolean')
307
+ throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.enabled must be true or false`);
308
+ const boundedInteger = (key, min, max) => {
309
+ const value = attachments[key];
310
+ if (value !== undefined && (typeof value !== 'number' || !Number.isSafeInteger(value)
311
+ || value < min || value > max))
312
+ throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.${key} must be an integer from ${min} to ${max}`);
313
+ };
314
+ boundedInteger('max_files_per_request', 1, 32);
315
+ boundedInteger('max_file_bytes', 1, 100 * 1024 * 1024);
316
+ boundedInteger('max_request_bytes', 1, 256 * 1024 * 1024);
317
+ boundedInteger('retention_ms', 60_000, 30 * 24 * 60 * 60 * 1_000);
318
+ const maxFileBytes = attachments.max_file_bytes ?? 10 * 1024 * 1024;
319
+ const maxRequestBytes = attachments.max_request_bytes ?? 20 * 1024 * 1024;
320
+ if (maxRequestBytes < maxFileBytes)
321
+ throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.max_request_bytes must be at least max_file_bytes`);
322
+ const allowedMime = attachments.allowed_mime ?? [...DEFAULT_OWNER_ATTACHMENT_MIME];
323
+ if (!Array.isArray(allowedMime) || allowedMime.length < 1 || allowedMime.length > 64
324
+ || allowedMime.some(mime => typeof mime !== 'string'
325
+ || !/^[a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*$/.test(mime)))
326
+ throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.allowed_mime must contain 1-64 lowercase MIME types`);
327
+ if (new Set(allowedMime).size !== allowedMime.length)
328
+ throw new ConfigError(`${file}: role '${name}' owner_channel.attachments.allowed_mime must not contain duplicates`);
329
+ if (session !== 'acp')
330
+ throw new ConfigError(`${file}: role '${name}' owner_channel requires session: acp for correlated final replies`);
331
+ return {
332
+ identity: merged.identity.trim(),
333
+ owners,
334
+ interrupt: merged.interrupt ?? false,
335
+ progress_interval_ms: merged.progress_interval_ms ?? 30_000,
336
+ attachments: {
337
+ enabled: attachments.enabled ?? true,
338
+ max_files_per_request: attachments.max_files_per_request ?? 4,
339
+ max_file_bytes: maxFileBytes,
340
+ max_request_bytes: maxRequestBytes,
341
+ retention_ms: attachments.retention_ms ?? 24 * 60 * 60 * 1_000,
342
+ allowed_mime: [...allowedMime],
343
+ },
344
+ };
345
+ }
346
+ function validateOwnerChannelIdentities(roles) {
347
+ const roleIdentities = new Map(roles.map(role => [role.identity, role.name]));
348
+ const channels = new Map();
349
+ for (const role of roles) {
350
+ const identity = role.owner_channel?.identity;
351
+ if (!identity)
352
+ continue;
353
+ const roleOwner = roleIdentities.get(identity);
354
+ if (roleOwner)
355
+ throw new ConfigError(`${role.sourceFile}: role '${role.name}' owner_channel.identity '${identity}' conflicts with role '${roleOwner}' identity`);
356
+ const channelOwner = channels.get(identity);
357
+ if (channelOwner)
358
+ throw new ConfigError(`${role.sourceFile}: owner_channel.identity '${identity}' is shared by roles '${channelOwner}' and '${role.name}'`);
359
+ channels.set(identity, role.name);
360
+ }
240
361
  }
241
362
  export function resolveModelChain(model, chain, file = 'config', name = 'role') {
242
363
  if (chain === undefined)
package/dist/docs.d.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * Keep this concise enough to place directly in an agent context. Unlike
5
5
  * Commander's per-command help, this describes how the pieces compose.
6
6
  */
7
- export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
7
+ export declare const AI_DOCS = "# ours-fleet reference\n\nours-fleet runs persistent or temporary, identity-bound AI roles. A role selects\na harness independently from its session backend:\n\n- harness: `claude-code` or `codex`\n- session: `tmux` (default) or `acp`\n- lifetime: permanent (supervised, restartable) or `spawn --temp`\n\n## Discover and validate\n\n```sh\nours-fleet docs # this complete reference (`man` is an alias)\nours-fleet help <command> # exact flags for one command\nours-fleet config [-c FILE] # validate and print the merged plan; no changes\nours-fleet doctor [-c FILE] [--harness codex|claude-code]\n```\n\nDefault configuration is `~/fleet.yaml` plus sorted `~/fleet.d/*.yaml` role\ndrop-ins. An explicit `-c FILE` replaces `~/fleet.yaml`; fleet.d still adds\nroles. Validate with `config` and `doctor` before starting or restarting.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init\nours-fleet up|down [Name...]\nours-fleet restart [Name...] # preserve/resume harness context\nours-fleet force-restart [Name...] # fresh context; briefing is reloaded\nours-fleet ls\nours-fleet status|peek|attach|logs Name\nours-fleet logs -f Name\nours-fleet send Name \"prompt\"\nours-fleet send Name --key Enter # tmux only\nours-fleet rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` work with tmux and ACP. ACP attachment\nalso accepts `/permit <permission-id> <option-id>`, `/interrupt`, and\n`/detach`. Raw `--key` input is tmux-only.\n\n## Local web console\n\nThe npm package includes the web console; installed users do not clone the repo\nor run `npm run build`:\n\n```sh\nnpm i -g @ours.network/fleet\nours-fleet init\nours-fleet doctor\nours-fleet web # install/update service, start, pair browser\n```\n\nThe normal command uses stable `http://127.0.0.1:49271/`, installs an\nowner-level systemd user service (Linux) or LaunchAgent (macOS), and opens a\nfive-minute one-use pairing link in the local browser. After pairing, bookmark\nthe plain URL or install the PWA. To pair a new, signed-out, or revoked browser,\nrun `ours-fleet web open`.\n\n```sh\nours-fleet web status\nours-fleet web start|stop|restart\nours-fleet web open\nours-fleet web revoke-all # revoke every browser and active session\nours-fleet web uninstall\nours-fleet web serve --port 0 --no-open # isolated foreground/testing mode\n```\n\nThe console is intentionally IPv4-loopback-only. It has no LAN/Internet host,\nproxy, TLS, or remote-access mode. Do not expose port 49271 through a reverse\nproxy. Browser credentials are HttpOnly/SameSite, and `revoke-all` invalidates\nall trusted devices. Role creation offers harness-scoped known-model choices\nwhile still accepting a typed model ID; blank explicitly uses the selected\nharness's own default.\n\n## Spawn\n\n```sh\nours-fleet spawn [--temp] Name \\\n --harness codex|claude-code --session tmux|acp \\\n --mission \"one line\" --cwd /absolute/path --identity Identity \\\n --coordinator Coordinator --model MODEL \\\n --approval ask|allow|deny \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait \\\n --bio-file /path/bio.md --persona-file /path/persona.md\n```\n\nPermanent spawn writes `~/fleet.d/Name.yaml` and starts a supervised role.\n`--temp` writes ephemeral state, starts a detached supervisor, and removes the\nrole after exit/reboot. Both lifetimes support `--session acp`.\n\nCodex-specific spawn flags: `--sandbox`, `--permission-mode`, `--launcher`,\n`--profile`, `--search`, repeatable `--codex-config key=value`, repeatable\n`--add-dir`, and legacy `--monitor` (consent for the native Codex monitor,\nnot the `monitor.mode` wake-owner selector). Run `ours-fleet help spawn` for\nexact values.\n\n## fleet.yaml\n\n```yaml\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n harness: codex\n session: acp\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\nroles:\n Coordinator:\n harness: codex\n session: acp\n identity: Coordinator\n cwd: ${work_root}/project\n mission: Coordinate work and delegate implementation.\n model: gpt-model-id\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n session_options: # advanced overrides; normally omit\n # acp:\n # command: [/custom/codex-acp, --flag]\n tmux:\n boot_grace_ms: 10000\n monitor:\n mode: fleet # fleet supervisor | native harness monitor\n interrupt: false # true cancels active work before every configured wake\n wake_sources: [message_received, file_received, local_contact_request, pending_message]\n batch_ms: 2000\n inject: notification\n turn_fail_threshold: 3\n harness_options:\n launcher: auto\n sandbox: workspace-write\n approval: on-request\n search: false\n profile: fleet\n add_dirs: [/data/shared]\n config:\n model_reasoning_effort: high\n bio: Public role card and when peers should engage it.\n persona: Local operating contract, boundaries, and escalation policy.\n briefing_file: /absolute/custom-briefing.md\n coordinator: AnotherCoordinator\n env:\n KEY: value\n oversee:\n - { role: Worker, interval: 5m }\nwatchdogs:\n nightwatch: # [A-Za-z0-9_-], must not collide with a role name\n coordinator: FleetCoordinator # required \u2014 where alerts go\n # everything below is optional\n enabled: true # default true; false = configured but never scheduled\n interval: 10m # default 10m; 30s | 10m | 2h, minimum 1m\n watch: [Alice, CodexReviewer] # explicit lists are exact; omit for configured + live temp roles\n harness: claude-code # default: defaults.harness\n model: claude-fable-5 # default: same resolution rule roles use (resolveRoleModel)\n session: acp # default: defaults.session\n identity: Watchdog-nightwatch # default: Watchdog-<name>\n timeout: 5m # default 5m; a run past this is killed and recorded as error\n keep_reports: 50 # default 50 reports retained per watchdog\n alert_cooldown: 60m # default 60m before the same finding alerts again\n prompt_file: /abs/extra.md # optional extra focus, APPENDED to the fixed contract\n isolation: # optional; omitted means no OS sandbox, like an ordinary role\n backend: bubblewrap # when present, the ordinary role isolation schema applies\n network: broker\n fs: { read: [/opt/watch-data] }\n```\n\nA watchdog observes and reports; it never restarts, stops, spawns, or removes a\nrole, answers a pending permission, edits a workspace, or approves anything on\nthe owner's behalf. `watchdogs:` may appear only in the base config\n(`~/fleet.yaml` or `-c FILE`), not in `~/fleet.d/*.yaml` drop-ins.\nWatchdogs are not isolated by default. An explicit watchdog `isolation:` block\nuses the same policy schema as a role and is applied unchanged; declare every\nextra filesystem access required by a custom prompt there.\nWhen `watch:` is omitted, each run watches the configured roles plus temporary\nfleet roles that are live when the run starts. An explicit `watch:` list is\nnever augmented.\n\nRole values override defaults. `${name}` substitutes entries from `vars`.\nOther role fields include `max_tokens`, `autocompact_pct`, and `isolation`.\nUse README.md for the complete isolation policy and resource-cap schema.\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|allow|deny`: whether actions may request or receive approval\n- `filesystem: read-only|workspace|unrestricted`: filesystem intent\n- `unattended: deny|wait`: what ACP does when no console can answer a request\n\nThe backend translates this common intent. Harness-native settings in\n`harness_options` take precedence where supplied. Do not choose\n`allow`/`unrestricted`, Codex `never`/`danger-full-access`, or Claude\n`bypassPermissions` without explicit authorization.\n\n### Creation-time isolation\n\n`ours-fleet spawn --isolation-file <path>` supplies a role's sandbox policy at\ncreation, so the FIRST launch is already confined \u2014 a role that only gains\n`isolation:` on a later `up` ran unsandboxed until then.\n\nThe file holds exactly the `isolation:` mapping documented above and nothing\nelse \u2014 the same schema, validated by the same code, so a policy written here\ncannot mean something different from the identical block in fleet.yaml:\n\n```yaml\nnetwork: deny\nfs:\n read: [/opt/reference]\nresources:\n mem: 2G\n```\n\nInvalid files are rejected before anything is created: no config, no state\ndirectory, no identity reservation. Works for both permanent and `--temp` roles.\n\n### Never-prompt failure\n\nThe failure this section exists to prevent leaves no error message anywhere.\n\nAn unattended role has no console. When the harness needs a permission decision\nthere is nobody to ask, so the request is refused INSIDE the harness \u2014 no\nprompt, no error, no log line. The agent simply does less than its briefing told\nit to, reports success, and nothing distinguishes that from having done the\nwork. Two settings produce it:\n\n1. a permission mode that suppresses the prompt without granting the action\n (Claude `dontAsk`, which is why neutral `allow` maps to\n `bypassPermissions` instead); and\n2. `unattended: deny`, which refuses every request that reaches it.\n\n**Automatic decisions are now recorded.** Every permission request decided\nwithout a human emits a completed event into\n`~/.ours-fleet/agents/<Name>/.session-events.jsonl` carrying the decision,\nwhether policy or a person made it, the policy that produced it\n(`permissions.unattended=deny` vs `permissions.approval=deny`/`=allow`),\nthe reason, and the option selected. `ours-fleet peek` and `attach` render\nthem. Automatic denial asks for a one-shot rejection, never a standing one, so a\nsingle unattended refusal cannot disable a tool for the rest of the session.\n\nA role that can auto-deny logs one line at startup saying so.\n\nTo detect an under-permissioned role BEFORE it runs, use the capability floor\nbelow: `ours-fleet doctor` fails such a role rather than letting it discover\nthe problem silently at work.\n\n### The unattended capability floor\n\nAn unattended role has no console, so a permission request cannot be answered \u2014\nit is refused, silently, inside the harness. The agent then does less than it\nwas told to and reports no error. To make that visible before launch,\n`ours-fleet config` and `ours-fleet doctor` resolve each role's neutral\npermissions through its harness and check the result against a fixed floor:\n\n- `read-state` \u2014 read its briefing, ROUTINES.md, and WORKLOG.md\n- `write-state` \u2014 append its WORKLOG and its own state files\n- `messaging` \u2014 bind its identity, send and receive ours mail\n- `monitor` \u2014 arm and observe its mail monitor\n- `workspace-edit` \u2014 edit and test files in its working directory\n- `status-commands` \u2014 run the inspection commands its briefing prescribes\n\n`doctor` reports this per role as `unattended floor: <Role>`. A role with\n`unattended: deny` that cannot meet the floor FAILS doctor, because it will\ndeny those requests with nobody to see it; with `unattended: wait` it warns,\nbecause a human can still attach and answer.\n\nSecurity meaning: `approval: allow` maps to Claude's `bypassPermissions`,\nwhich genuinely permits the actions the role was authorized to take \u2014\n`dontAsk` only suppresses the prompt while still refusing the action. Nothing\nother than an explicit `allow` is elevated: `ask` stays on Claude's default\nmode and `deny` maps to `plan`. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nSee also: `spawn --approval/--filesystem/--unattended` set this intent at\ncreation, and `ours-fleet config` prints each role's neutral settings, their\nnative translation, and any warning \u2014 the same text `doctor` reports.\n\nClaude `harness_options`: `permission_mode` (default, acceptEdits, plan,\ndontAsk, bypassPermissions), `plugins`, `mem_palace`, and\n`mem_palace_midsession_autosave`.\n\nCodex `harness_options`: `launcher` (auto, ours-codex, codex), `sandbox`\n(read-only, workspace-write, danger-full-access), `approval` or\n`permission_mode` (untrusted, on-request, never), `profile`, `search`,\n`config`, `add_dirs`, and `monitor`.\n\n## ACP adapters\n\nThe maintained `@agentclientprotocol/codex-acp` and\n`@agentclientprotocol/claude-agent-acp` runtimes are bundled automatically as\noptional ours-fleet dependencies. The supervisor resolves their executable\nentrypoints internally, so default ACP roles do not depend on global PATH.\nThe maintained Claude adapter requires Node 22; tmux and Codex ACP continue to\nwork on the ours-fleet core minimum of Node 20.\n\nOverride an adapter only when necessary with `session_options.acp.command`\n(string or argv list). If optional dependencies were deliberately omitted,\nours-fleet falls back to a compatible globally installed `codex-acp` or\n`claude-agent-acp`. `ours-fleet doctor -c FILE` verifies the resolved adapter.\n\n## Reliable mail wake\n\n`monitor.mode` selects exactly one wake owner:\n\n- `fleet` (default): the ours-fleet supervisor consumes body-free daemon\n events and advances its durable cursor only after delivery is accepted. ACP\n uses live steering when supported and falls back to structured\n `session/prompt`; tmux uses verified console injection.\n- `native`: ours-fleet starts no supervisor monitor; the generated briefing\n instructs Claude Code or Codex to arm its harness-native wake mechanism.\n\nSet `monitor.interrupt: true` in fleet mode to cancel active work before every\nconfigured wake. The policy is content-blind because the supervisor cannot\ninspect encrypted message bodies. Message bodies are released only when the\nrole calls the ours `get_messages` tool.\n\nThe default is `false`. For a temporary role whose mission intentionally arrives\nafter its readiness announcement, set `mode: fleet` and `interrupt: true`\nexplicitly. The readiness announcement does not change the transport: the\nmission remains ordinary ours mail, fleet injects only the body-free wake, and\nthe role calls `get_messages` before acting. Every later configured wake uses\nthe same interruption policy.\n\nLegacy `monitor.enabled: true|false` remains accepted as an alias for\n`mode: fleet|native`; use `mode` in new configuration. Codex's separate\n`harness_options.monitor: true` is native-monitor consent, not monitor-owner\nselection.\nInspect `ours-fleet status Name`, `peek Name`, role logs, and\n`~/.ours-fleet/agents/Name/.monitor-status` when diagnosing delivery.\n\n## Trusted owner channel\n\nAn ACP role may declare a separate, existing ours identity which fleet \u2014 never\nthe agent \u2014 binds:\n\n```yaml\nowner_channel:\n identity: Coordinator Owner Channel\n owners: [authenticated-owner-contact-cid]\n interrupt: false\n progress_interval_ms: 30000\n attachments:\n enabled: true\n max_files_per_request: 4\n max_file_bytes: 10485760\n max_request_bytes: 20971520\n retention_ms: 86400000\n allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]\n```\n\nThis does not replace the role identity. Normal identity mail remains untrusted\npeer input: the agent reads it through `get_messages` and replies through\n`send_message`. Only mail arriving on the dedicated channel from a CID in\n`owners` is injected as a direct `[fleet-owner]` prompt. Fleet itself sends\naccepted/queued/progress/interrupted/failure notices and routes the ACP turn's\nfinal assistant text back to the authenticated sender with its source wire ID.\nFor file replies, fleet injects a request-specific outbox path into the owner\nprompt. The agent copies completed artifacts there; fleet sends every regular\nfile from the channel identity with the same source wire ID and removes the\ntemporary outbox only after successful delivery. The agent never chooses a\nrecipient or calls ours `send_file` for an owner-channel response.\nExact `/status` and `/interrupt` commands bypass the model.\n\nOwner documents, images, and voice messages use the same authenticated sender\nand source-wire boundary. Fleet inspects body-free metadata first and rejects\ndisabled, over-count, over-size, or disallowed-MIME requests before selective\nretrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text\nand files from the same sender become one ordered request; a file-only wake also\nstarts a turn. Retrieved bytes must match their structured size and SHA-256,\ntheir content signature must match the declared MIME, and symlinks or non-regular\npaths fail closed. Sanitized copies live only in a mode-0700 request directory as\nmode-0600 files and are removed after completion or bounded stale retention.\n\nVoice prompts include a bounded transcript only when ours-mcp reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\nfallback. Run `ours-mcp voice-status --json` to inspect the host configuration.\nA mode-0600 crash journal contains only authenticated CID and wire routing data;\nit never stores captions, filenames, paths, transcript text, or bytes. Journaled\npost-retrieval files resume selectively through `save_file`; corrupt state\ndisables attachment admission rather than weakening provenance checks.\n\nThe channel identity must be unique and must not be a role identity. The bridge\npersists bounded wire IDs only, never message/reply plaintext, and requeues input\nbefore starting its turn for at-least-once crash recovery. It currently requires\n`session: acp`: tmux has no structured, turn-correlated final answer, and pane\nscraping cannot provide the same reliable reply guarantee.\n\n### Live contact and owner administration\n\nThe supervisor which is already running the ACP role remains the sole binder of\n`owner_channel.identity`. The CLI reaches that exact live `OwnerChannel`\nthrough the role's token-authenticated, mode-0600 Unix control socket; it never\nstarts another ours client and never force-binds. An active owner turn uses the\nsame control plane for explicit bounded updates:\n\n```sh\nours-fleet owner-channel contact list <Role>\nours-fleet owner-channel contact invite <Role> [--name <label>]\nours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]\nours-fleet owner-channel owner list <Role>\nours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>\nours-fleet owner-channel update <Role> <request-id> --phase <working|approval|blocked> --message-stdin\nours-fleet owner-channel task open <Role> <active-request-id>\nours-fleet owner-channel task report <Role> <task-id> --phase <progress|done|blocked> --message-stdin\n```\n\nContact establishment and owner authorization are separate security steps.\n`contact add` never authorizes: invite redemption is pending until the peer\nverifies it. Once `contact list` reports the established contact, authorize\nits exact immutable CID explicitly. Invite creation emits invite material only\non stdout; acceptance reads it from a file or stdin, not argv.\n\nConfigured `owners` remain the baseline. Live authorizations/revocations are\nan immediately effective, restart-persistent overlay. `owner list` labels\nbaseline versus dynamic entries and effective status. The atomic mode-0600 file\ncontains bounded CIDs and audit actions only. Corruption disables all effective\nowners and refuses mutation rather than resurrecting authority; revoking the\nlast effective owner is always refused.\n\nA missing/stopped role, tmux session, role without `owner_channel`, unavailable\nMCP client, or a role entering shutdown returns an actionable error with no\nside effects. Management uses no network listener and never logs or persists\ninvite material.\n\nFor work delegated beyond the current owner turn, call `task open` while its\nauthenticated request ID is still active. It emits no message and returns a\nrandom opaque task ID whose mode-0600 durable record contains only the exact\noriginating CID/wire route, expiry, counters, delivery state, and body hashes.\nFinalize the ACP turn normally and idle; never keep it open or poll. After fleet\nmail wakes the coordinator, verify the specialist result and call `task report`.\nFleet rechecks authorization and sends the proactive follow-up only to the\nstored origin, correlated to the original wire. There is no recipient argument.\n`done` and `blocked` are terminal after successful delivery; `progress`\nkeeps the task open.\n\nTasks expire after seven days and are limited to 32 per role, eight per owner,\n20 reports each, and one report per five seconds. Reports use the same one-line\n280-character/1024-byte secret/reasoning/log rejection as active updates. The\nstate contains no body plaintext and corruption fails closed. Fleet persists a\npre-send marker; an ambiguous transport result becomes `uncertain` and is not\nretried or reordered, preventing duplicate delivery when the transport cannot\nprove whether the first send succeeded.\n\nThe full request lifecycle is ordered on the original authenticated source wire:\nimmediate receipt; optional allowlisted periodic activity; zero or more explicit\nagent-authored updates; an optional `\uD83D\uDD10` approval or `\uD83D\uDEA7` blocked update; one\nfinal ACP response or sanitized terminal outcome; then successful-turn files from\nthe request outbox. Updates use the request ID injected into the owner prompt,\nnever choose a recipient, and never create another channel binding.\n\nAuthored update bodies come from stdin rather than argv and must be a single\nplain-text sentence (280 characters/1024 bytes maximum). Fleet rejects empty,\noversized, control-character, reasoning, secret-like, log/tool-output, duplicate,\nunknown, late, and over-rate content. Distinct updates are limited to 20 per\nrequest and one every five seconds. The audit line retains only a hashed request\nprefix, phase, character count, sequence, and result\u2014not the body. The final ACP\ncontract remains exactly one response, ordered after every accepted update.\n\nFor a mobile owner, establish the contact first, wait for peer verification,\nauthorize its exact CID, and revoke that same CID when access ends. The bounded\nmode-0600 CID overlay survives supervisor restart and remains fail-closed on\ncorruption. Update bodies remain memory-only. After a crash/restart, unfinished\ndeferred owner input follows the existing at-least-once replay path; the restarted\nsupervisor remains the sole binder.\n\n## Stable config and YAML migration\n\n`ours-fleet config --json` emits schemaVersion 1 resolved plans. Environment\nvalues and mission/persona/bio bodies are withheld; environment keys are sorted\nand values are marked redacted. Additive fields may appear in schema 1, while a\nremoval or semantic reuse requires a new schema version.\n\nYAML parsing always rejects duplicate keys. The current default\n`--yaml-mode compat` warns with file/line/column for anchors, aliases, explicit\ntags, non-scalar keys, and multiple documents. Use `--yaml-mode strict` in CI\nnow; strict becomes the next-major default and compat is the temporary migration\nescape hatch.\n\n## Bounded worklogs, auth proxy, and model recovery\n\nAn optional `worklog: { max_kb, keep_tail_kb, max_archives }` policy rotates a\nstable snapshot at fleet-owned lifecycle points. Concurrent changes defer\nrotation. Archives remain beside WORKLOG.md with the same sensitive-state\nboundary; retention deletes only recognized fleet archive names.\n\n`auth_proxy: { kind: anthropic, base_url, required, health_url }` is Claude-only\nand loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects\ncredential env keys. The privileged reference companion is\n`contrib/anthropic-auth-proxy.mjs`; deploy it separately as a dedicated account\nwith a 0600 token file and per-role listener access. Fleet never installs it or\nreads its credential.\n\n`model_chain` is an ordered authorization list and its first entry must equal\n`model`. Only sustained high-confidence entitlement/quota 429 evidence advances\none entry. Transient 429, overload, auth, policy, and unknown errors never\ndown-shift. Runtime state is atomic in .model-recovery.json; exhaustion is\nfail-closed and held down. Change the declared chain/model and restart to\nreconcile explicitly; no chain preserves detection-only behavior.\n";
8
8
  /**
9
9
  * What every shipped spawn-skill variant must say, and must not say (7.1).
10
10
  *
package/dist/docs.js CHANGED
@@ -332,6 +332,13 @@ configured wake. The policy is content-blind because the supervisor cannot
332
332
  inspect encrypted message bodies. Message bodies are released only when the
333
333
  role calls the ours \`get_messages\` tool.
334
334
 
335
+ The default is \`false\`. For a temporary role whose mission intentionally arrives
336
+ after its readiness announcement, set \`mode: fleet\` and \`interrupt: true\`
337
+ explicitly. The readiness announcement does not change the transport: the
338
+ mission remains ordinary ours mail, fleet injects only the body-free wake, and
339
+ the role calls \`get_messages\` before acting. Every later configured wake uses
340
+ the same interruption policy.
341
+
335
342
  Legacy \`monitor.enabled: true|false\` remains accepted as an alias for
336
343
  \`mode: fleet|native\`; use \`mode\` in new configuration. Codex's separate
337
344
  \`harness_options.monitor: true\` is native-monitor consent, not monitor-owner
@@ -339,6 +346,142 @@ selection.
339
346
  Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
340
347
  \`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
341
348
 
349
+ ## Trusted owner channel
350
+
351
+ An ACP role may declare a separate, existing ours identity which fleet — never
352
+ the agent — binds:
353
+
354
+ \`\`\`yaml
355
+ owner_channel:
356
+ identity: Coordinator Owner Channel
357
+ owners: [authenticated-owner-contact-cid]
358
+ interrupt: false
359
+ progress_interval_ms: 30000
360
+ attachments:
361
+ enabled: true
362
+ max_files_per_request: 4
363
+ max_file_bytes: 10485760
364
+ max_request_bytes: 20971520
365
+ retention_ms: 86400000
366
+ allowed_mime: [application/pdf, text/plain, image/png, audio/ogg]
367
+ \`\`\`
368
+
369
+ This does not replace the role identity. Normal identity mail remains untrusted
370
+ peer input: the agent reads it through \`get_messages\` and replies through
371
+ \`send_message\`. Only mail arriving on the dedicated channel from a CID in
372
+ \`owners\` is injected as a direct \`[fleet-owner]\` prompt. Fleet itself sends
373
+ accepted/queued/progress/interrupted/failure notices and routes the ACP turn's
374
+ final assistant text back to the authenticated sender with its source wire ID.
375
+ For file replies, fleet injects a request-specific outbox path into the owner
376
+ prompt. The agent copies completed artifacts there; fleet sends every regular
377
+ file from the channel identity with the same source wire ID and removes the
378
+ temporary outbox only after successful delivery. The agent never chooses a
379
+ recipient or calls ours \`send_file\` for an owner-channel response.
380
+ Exact \`/status\` and \`/interrupt\` commands bypass the model.
381
+
382
+ Owner documents, images, and voice messages use the same authenticated sender
383
+ and source-wire boundary. Fleet inspects body-free metadata first and rejects
384
+ disabled, over-count, over-size, or disallowed-MIME requests before selective
385
+ retrieval. Unauthorized CIDs are never retrieved or answered. Reply-linked text
386
+ and files from the same sender become one ordered request; a file-only wake also
387
+ starts a turn. Retrieved bytes must match their structured size and SHA-256,
388
+ their content signature must match the declared MIME, and symlinks or non-regular
389
+ paths fail closed. Sanitized copies live only in a mode-0700 request directory as
390
+ mode-0600 files and are removed after completion or bounded stale retention.
391
+
392
+ Voice prompts include a bounded transcript only when ours-mcp reports success.
393
+ Failure or unavailability is explicit and preserves the private audio path as the
394
+ fallback. Run \`ours-mcp voice-status --json\` to inspect the host configuration.
395
+ A mode-0600 crash journal contains only authenticated CID and wire routing data;
396
+ it never stores captions, filenames, paths, transcript text, or bytes. Journaled
397
+ post-retrieval files resume selectively through \`save_file\`; corrupt state
398
+ disables attachment admission rather than weakening provenance checks.
399
+
400
+ The channel identity must be unique and must not be a role identity. The bridge
401
+ persists bounded wire IDs only, never message/reply plaintext, and requeues input
402
+ before starting its turn for at-least-once crash recovery. It currently requires
403
+ \`session: acp\`: tmux has no structured, turn-correlated final answer, and pane
404
+ scraping cannot provide the same reliable reply guarantee.
405
+
406
+ ### Live contact and owner administration
407
+
408
+ The supervisor which is already running the ACP role remains the sole binder of
409
+ \`owner_channel.identity\`. The CLI reaches that exact live \`OwnerChannel\`
410
+ through the role's token-authenticated, mode-0600 Unix control socket; it never
411
+ starts another ours client and never force-binds. An active owner turn uses the
412
+ same control plane for explicit bounded updates:
413
+
414
+ \`\`\`sh
415
+ ours-fleet owner-channel contact list <Role>
416
+ ours-fleet owner-channel contact invite <Role> [--name <label>]
417
+ ours-fleet owner-channel contact add <Role> (--invite-file <path> | --invite-stdin) [--name <label>]
418
+ ours-fleet owner-channel owner list <Role>
419
+ ours-fleet owner-channel owner authorize <Role> <exact-64-hex-contact-cid>
420
+ ours-fleet owner-channel owner revoke <Role> <exact-64-hex-contact-cid>
421
+ ours-fleet owner-channel update <Role> <request-id> --phase <working|approval|blocked> --message-stdin
422
+ ours-fleet owner-channel task open <Role> <active-request-id>
423
+ ours-fleet owner-channel task report <Role> <task-id> --phase <progress|done|blocked> --message-stdin
424
+ \`\`\`
425
+
426
+ Contact establishment and owner authorization are separate security steps.
427
+ \`contact add\` never authorizes: invite redemption is pending until the peer
428
+ verifies it. Once \`contact list\` reports the established contact, authorize
429
+ its exact immutable CID explicitly. Invite creation emits invite material only
430
+ on stdout; acceptance reads it from a file or stdin, not argv.
431
+
432
+ Configured \`owners\` remain the baseline. Live authorizations/revocations are
433
+ an immediately effective, restart-persistent overlay. \`owner list\` labels
434
+ baseline versus dynamic entries and effective status. The atomic mode-0600 file
435
+ contains bounded CIDs and audit actions only. Corruption disables all effective
436
+ owners and refuses mutation rather than resurrecting authority; revoking the
437
+ last effective owner is always refused.
438
+
439
+ A missing/stopped role, tmux session, role without \`owner_channel\`, unavailable
440
+ MCP client, or a role entering shutdown returns an actionable error with no
441
+ side effects. Management uses no network listener and never logs or persists
442
+ invite material.
443
+
444
+ For work delegated beyond the current owner turn, call \`task open\` while its
445
+ authenticated request ID is still active. It emits no message and returns a
446
+ random opaque task ID whose mode-0600 durable record contains only the exact
447
+ originating CID/wire route, expiry, counters, delivery state, and body hashes.
448
+ Finalize the ACP turn normally and idle; never keep it open or poll. After fleet
449
+ mail wakes the coordinator, verify the specialist result and call \`task report\`.
450
+ Fleet rechecks authorization and sends the proactive follow-up only to the
451
+ stored origin, correlated to the original wire. There is no recipient argument.
452
+ \`done\` and \`blocked\` are terminal after successful delivery; \`progress\`
453
+ keeps the task open.
454
+
455
+ Tasks expire after seven days and are limited to 32 per role, eight per owner,
456
+ 20 reports each, and one report per five seconds. Reports use the same one-line
457
+ 280-character/1024-byte secret/reasoning/log rejection as active updates. The
458
+ state contains no body plaintext and corruption fails closed. Fleet persists a
459
+ pre-send marker; an ambiguous transport result becomes \`uncertain\` and is not
460
+ retried or reordered, preventing duplicate delivery when the transport cannot
461
+ prove whether the first send succeeded.
462
+
463
+ The full request lifecycle is ordered on the original authenticated source wire:
464
+ immediate receipt; optional allowlisted periodic activity; zero or more explicit
465
+ agent-authored updates; an optional \`🔐\` approval or \`🚧\` blocked update; one
466
+ final ACP response or sanitized terminal outcome; then successful-turn files from
467
+ the request outbox. Updates use the request ID injected into the owner prompt,
468
+ never choose a recipient, and never create another channel binding.
469
+
470
+ Authored update bodies come from stdin rather than argv and must be a single
471
+ plain-text sentence (280 characters/1024 bytes maximum). Fleet rejects empty,
472
+ oversized, control-character, reasoning, secret-like, log/tool-output, duplicate,
473
+ unknown, late, and over-rate content. Distinct updates are limited to 20 per
474
+ request and one every five seconds. The audit line retains only a hashed request
475
+ prefix, phase, character count, sequence, and result—not the body. The final ACP
476
+ contract remains exactly one response, ordered after every accepted update.
477
+
478
+ For a mobile owner, establish the contact first, wait for peer verification,
479
+ authorize its exact CID, and revoke that same CID when access ends. The bounded
480
+ mode-0600 CID overlay survives supervisor restart and remains fail-closed on
481
+ corruption. Update bodies remain memory-only. After a crash/restart, unfinished
482
+ deferred owner input follows the existing at-least-once replay path; the restarted
483
+ supervisor remains the sole binder.
484
+
342
485
  ## Stable config and YAML migration
343
486
 
344
487
  \`ours-fleet config --json\` emits schemaVersion 1 resolved plans. Environment
package/dist/duration.js CHANGED
@@ -1,17 +1,21 @@
1
1
  /** Parse `30s | 10m | 2h` duration strings to milliseconds (spec §2). */
2
- const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000 };
3
- const RE = /^(\d+)([smh])$/;
2
+ const UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
3
+ const RE = /^(\d+)([smhd])$/;
4
4
  export function parseDuration(text, opts = {}) {
5
5
  const label = opts.name ?? 'duration';
6
6
  const m = RE.exec(text);
7
7
  if (!m)
8
- throw new Error(`${label}: invalid duration '${text}' (expected e.g. 30s, 10m, 2h)`);
8
+ throw new Error(`${label}: invalid duration '${text}' (expected e.g. 30s, 10m, 2h, 1d)`);
9
9
  const ms = Number(m[1]) * UNIT_MS[m[2]];
10
+ if (!Number.isSafeInteger(ms))
11
+ throw new Error(`${label}: '${text}' is too large`);
10
12
  if (opts.minMs !== undefined && ms < opts.minMs)
11
13
  throw new Error(`${label}: '${text}' is below the minimum ${formatDuration(opts.minMs)}`);
12
14
  return ms;
13
15
  }
14
16
  export function formatDuration(ms) {
17
+ if (ms % 86_400_000 === 0 && ms >= 86_400_000)
18
+ return `${ms / 86_400_000}d`;
15
19
  if (ms % 3_600_000 === 0 && ms >= 3_600_000)
16
20
  return `${ms / 3_600_000}h`;
17
21
  if (ms % 60_000 === 0 && ms >= 60_000)
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
- export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, SessionOptions, } from './config.js';
2
+ export type { FleetConfig, ResolvedRole, RoleConfig, OverseeEntry, SessionBackendId, CommonPermissions, SessionOptions, OwnerChannelConfig, } from './config.js';
3
3
  export type { HarnessAdapter, BriefingVocab, ExitPolicy, PrereqReport, PrereqCheck, SessionPrep, SessionState, Launch, AcpLaunch, PermissionTranslation, RoleDirs, ValidationError, } from './harness/types.js';
4
4
  export type { SessionHandle, SessionSnapshot, SessionEvent, TurnResult, } from './session/types.js';
5
5
  export { AcpSession } from './session/acp.js';
6
+ export { OwnerChannel } from './owner-channel/channel.js';
6
7
  export { TmuxSession } from './session/tmux.js';
7
8
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
8
9
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { loadConfig, findRole, resolvePermissions, ConfigError } from './config.js';
2
2
  export { AcpSession } from './session/acp.js';
3
+ export { OwnerChannel } from './owner-channel/channel.js';
3
4
  export { TmuxSession } from './session/tmux.js';
4
5
  export { registerAdapter, getAdapter, knownAdapters } from './harness/registry.js';
5
6
  export { claudeCodeAdapter, makeClaudeCodeAdapter } from './harness/claude-code.js';
@@ -0,0 +1,30 @@
1
+ import type { ResolvedRole } from '../config.js';
2
+ export interface LoopConfig {
3
+ roles?: string[];
4
+ interval?: string;
5
+ prompt?: string;
6
+ enabled?: boolean;
7
+ initial_delay?: string;
8
+ jitter?: string;
9
+ }
10
+ export interface ResolvedLoop {
11
+ name: string;
12
+ selectors: string[];
13
+ roleNames: string[];
14
+ intervalMs: number;
15
+ prompt: string;
16
+ promptBytes: number;
17
+ promptHash: string;
18
+ enabled: boolean;
19
+ initialDelayMs: number;
20
+ jitterMs: number;
21
+ sourceFile: string;
22
+ }
23
+ export interface ResolvedRoleLoop extends Omit<ResolvedLoop, 'selectors' | 'roleNames'> {
24
+ role: string;
25
+ definitionHash: string;
26
+ }
27
+ export declare function resolveLoops(block: unknown, baseFile: string, roles: ResolvedRole[], vars: Record<string, string>): {
28
+ loops: ResolvedLoop[];
29
+ byRole: Map<string, ResolvedRoleLoop[]>;
30
+ };
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstatSync } from 'node:fs';
3
+ import { ConfigError, ROLE_NAME_RE } from '../config.js';
4
+ import { parseDuration } from '../duration.js';
5
+ const LOOP_KEYS = ['roles', 'interval', 'prompt', 'enabled', 'initial_delay', 'jitter'];
6
+ const MIN_INTERVAL_MS = 60_000;
7
+ const MAX_DURATION_MS = 30 * 24 * 60 * 60 * 1_000;
8
+ const MAX_JITTER_MS = 60 * 60 * 1_000;
9
+ const MAX_PROMPT_BYTES = 16 * 1024;
10
+ const MAX_PROMPT_SCALARS = 12_000;
11
+ function substitute(value, vars) {
12
+ if (typeof value === 'string')
13
+ return value.replace(/\$\{(\w+)\}/g, (match, key) => key in vars ? String(vars[key]) : match);
14
+ if (Array.isArray(value))
15
+ return value.map(item => substitute(item, vars));
16
+ if (value && typeof value === 'object')
17
+ return Object.fromEntries(Object.entries(value)
18
+ .map(([key, item]) => [key, substitute(item, vars)]));
19
+ return value;
20
+ }
21
+ function duration(raw, where, minMs) {
22
+ if (typeof raw !== 'string')
23
+ throw new ConfigError(`${where} must be a duration string`);
24
+ let value;
25
+ try {
26
+ value = parseDuration(raw, { name: where, minMs });
27
+ }
28
+ catch (error) {
29
+ throw new ConfigError(error.message);
30
+ }
31
+ if (!Number.isSafeInteger(value) || value > MAX_DURATION_MS)
32
+ throw new ConfigError(`${where} must not exceed 30d`);
33
+ return value;
34
+ }
35
+ function normalizePrompt(raw, where) {
36
+ if (typeof raw !== 'string')
37
+ throw new ConfigError(`${where} must be text`);
38
+ const prompt = raw.replace(/\r\n?/g, '\n').normalize('NFC');
39
+ if (!prompt.trim())
40
+ throw new ConfigError(`${where} must be non-blank text`);
41
+ if (prompt.includes('\0'))
42
+ throw new ConfigError(`${where} must not contain NUL`);
43
+ if (Buffer.byteLength(prompt) > MAX_PROMPT_BYTES || Array.from(prompt).length > MAX_PROMPT_SCALARS)
44
+ throw new ConfigError(`${where} exceeds 16384 bytes or 12000 Unicode scalars`);
45
+ return prompt;
46
+ }
47
+ function digest(value) {
48
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
49
+ }
50
+ export function resolveLoops(block, baseFile, roles, vars) {
51
+ const byRole = new Map(roles.map(role => [role.name, []]));
52
+ if (block === undefined || block === null)
53
+ return { loops: [], byRole };
54
+ if (!block || typeof block !== 'object' || Array.isArray(block))
55
+ throw new ConfigError(`${baseFile}: loops must be a map`);
56
+ const roleByName = new Map(roles.map(role => [role.name, role]));
57
+ const out = [];
58
+ for (const [name, raw] of Object.entries(block)) {
59
+ const where = `${baseFile}: loop '${name}'`;
60
+ if (!ROLE_NAME_RE.test(name))
61
+ throw new ConfigError(`${baseFile}: invalid loop name '${name}' (allowed: [A-Za-z0-9_-])`);
62
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
63
+ throw new ConfigError(`${where} must be a map`);
64
+ const value = substitute(raw, vars);
65
+ const bad = Object.keys(value).filter(key => !LOOP_KEYS.includes(key));
66
+ if (bad.length)
67
+ throw new ConfigError(`${where} has unknown key(s) ${bad.join(', ')}; allowed: ${LOOP_KEYS.join(', ')}`);
68
+ if (!Array.isArray(value.roles) || !value.roles.length
69
+ || value.roles.some(role => typeof role !== 'string' || !role))
70
+ throw new ConfigError(`${where}: roles must be a non-empty list of role names`);
71
+ if (new Set(value.roles).size !== value.roles.length)
72
+ throw new ConfigError(`${where}: roles must not contain duplicates`);
73
+ if (value.roles.includes('*') && value.roles.length !== 1)
74
+ throw new ConfigError(`${where}: '*' must be the sole role selector`);
75
+ const selected = value.roles[0] === '*' ? roles : value.roles.map(roleName => {
76
+ const role = roleByName.get(roleName);
77
+ if (!role)
78
+ throw new ConfigError(`${where}: roles names missing role '${roleName}'`);
79
+ return role;
80
+ });
81
+ if (value.enabled !== undefined && typeof value.enabled !== 'boolean')
82
+ throw new ConfigError(`${where}: enabled must be true or false`);
83
+ const enabled = value.enabled ?? true;
84
+ const intervalMs = duration(value.interval, `${where}: interval`, MIN_INTERVAL_MS);
85
+ const initialDelayMs = value.initial_delay === undefined
86
+ ? intervalMs : duration(value.initial_delay, `${where}: initial_delay`, 0);
87
+ const jitterMs = value.jitter === undefined ? 0 : duration(value.jitter, `${where}: jitter`, 0);
88
+ if (jitterMs >= intervalMs || jitterMs > MAX_JITTER_MS)
89
+ throw new ConfigError(`${where}: jitter must be less than interval and no more than 1h`);
90
+ const prompt = normalizePrompt(value.prompt, `${where}: prompt`);
91
+ if (enabled)
92
+ for (const role of selected) {
93
+ if (role.session !== 'acp')
94
+ throw new ConfigError(`${where} selects role '${role.name}' with session '${role.session}'; scheduled loops require session: acp`);
95
+ }
96
+ const promptHash = digest(prompt);
97
+ const resolved = {
98
+ name, selectors: [...value.roles], roleNames: selected.map(role => role.name), intervalMs,
99
+ prompt, promptBytes: Buffer.byteLength(prompt), promptHash, enabled,
100
+ initialDelayMs, jitterMs, sourceFile: baseFile,
101
+ };
102
+ out.push(resolved);
103
+ for (const role of selected) {
104
+ const definitionHash = digest({
105
+ role: role.name, name, intervalMs, initialDelayMs, jitterMs, enabled,
106
+ });
107
+ byRole.get(role.name).push({
108
+ name, role: role.name, intervalMs, prompt, promptBytes: resolved.promptBytes,
109
+ promptHash, enabled, initialDelayMs, jitterMs, sourceFile: baseFile, definitionHash,
110
+ });
111
+ }
112
+ }
113
+ for (const values of byRole.values())
114
+ values.sort((a, b) => a.name.localeCompare(b.name));
115
+ out.sort((a, b) => a.name.localeCompare(b.name));
116
+ if (out.some(loop => loop.enabled))
117
+ assertSafeLoopConfig(baseFile);
118
+ return { loops: out, byRole };
119
+ }
120
+ function assertSafeLoopConfig(path) {
121
+ let stat;
122
+ try {
123
+ stat = lstatSync(path);
124
+ }
125
+ catch (error) {
126
+ throw new ConfigError(`${path}: cannot inspect scheduled-loop config permissions: ${error.message}`);
127
+ }
128
+ if (!stat.isFile() || stat.isSymbolicLink())
129
+ throw new ConfigError(`${path}: scheduled-loop config must be a regular non-symlink file`);
130
+ if ((stat.mode & 0o022) !== 0)
131
+ throw new ConfigError(`${path}: scheduled-loop config is group/world writable; refusing loop delivery`);
132
+ const uid = process.getuid?.();
133
+ if (uid !== undefined && stat.uid !== uid)
134
+ throw new ConfigError(`${path}: scheduled-loop config is not owned by the current user`);
135
+ }