@ours.network/fleet 0.10.1 → 0.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +49 -0
- package/contrib/anthropic-auth-proxy.mjs +85 -0
- package/dist/briefing.js +5 -0
- package/dist/cli.js +63 -4
- package/dist/config-yaml.d.ts +20 -0
- package/dist/config-yaml.js +76 -0
- package/dist/config.d.ts +25 -1
- package/dist/config.js +115 -9
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +34 -0
- package/dist/doctor.d.ts +2 -0
- package/dist/doctor.js +42 -4
- package/dist/harness/claude-code.js +11 -0
- package/dist/harness/codex.js +12 -0
- package/dist/harness/types.d.ts +2 -0
- package/dist/model-recovery.d.ts +42 -0
- package/dist/model-recovery.js +120 -0
- package/dist/monitor.d.ts +3 -0
- package/dist/monitor.js +13 -7
- package/dist/permissions.d.ts +3 -1
- package/dist/permissions.js +80 -6
- package/dist/resolved-plan.d.ts +5 -0
- package/dist/resolved-plan.js +66 -0
- package/dist/runner.d.ts +1 -0
- package/dist/runner.js +84 -2
- package/dist/spawn.d.ts +19 -1
- package/dist/spawn.js +91 -3
- package/dist/worklog.d.ts +25 -0
- package/dist/worklog.js +99 -0
- package/package.json +2 -1
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\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## 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 }\n```\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";
|
|
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\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## 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 }\n```\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";
|
|
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
|
@@ -272,6 +272,40 @@ Legacy \`monitor.enabled: true|false\` remains accepted as an alias for
|
|
|
272
272
|
selection.
|
|
273
273
|
Inspect \`ours-fleet status Name\`, \`peek Name\`, role logs, and
|
|
274
274
|
\`~/.ours-fleet/agents/Name/.monitor-status\` when diagnosing delivery.
|
|
275
|
+
|
|
276
|
+
## Stable config and YAML migration
|
|
277
|
+
|
|
278
|
+
\`ours-fleet config --json\` emits schemaVersion 1 resolved plans. Environment
|
|
279
|
+
values and mission/persona/bio bodies are withheld; environment keys are sorted
|
|
280
|
+
and values are marked redacted. Additive fields may appear in schema 1, while a
|
|
281
|
+
removal or semantic reuse requires a new schema version.
|
|
282
|
+
|
|
283
|
+
YAML parsing always rejects duplicate keys. The current default
|
|
284
|
+
\`--yaml-mode compat\` warns with file/line/column for anchors, aliases, explicit
|
|
285
|
+
tags, non-scalar keys, and multiple documents. Use \`--yaml-mode strict\` in CI
|
|
286
|
+
now; strict becomes the next-major default and compat is the temporary migration
|
|
287
|
+
escape hatch.
|
|
288
|
+
|
|
289
|
+
## Bounded worklogs, auth proxy, and model recovery
|
|
290
|
+
|
|
291
|
+
An optional \`worklog: { max_kb, keep_tail_kb, max_archives }\` policy rotates a
|
|
292
|
+
stable snapshot at fleet-owned lifecycle points. Concurrent changes defer
|
|
293
|
+
rotation. Archives remain beside WORKLOG.md with the same sensitive-state
|
|
294
|
+
boundary; retention deletes only recognized fleet archive names.
|
|
295
|
+
|
|
296
|
+
\`auth_proxy: { kind: anthropic, base_url, required, health_url }\` is Claude-only
|
|
297
|
+
and loopback-only. Fleet injects only ANTHROPIC_BASE_URL and doctor rejects
|
|
298
|
+
credential env keys. The privileged reference companion is
|
|
299
|
+
\`contrib/anthropic-auth-proxy.mjs\`; deploy it separately as a dedicated account
|
|
300
|
+
with a 0600 token file and per-role listener access. Fleet never installs it or
|
|
301
|
+
reads its credential.
|
|
302
|
+
|
|
303
|
+
\`model_chain\` is an ordered authorization list and its first entry must equal
|
|
304
|
+
\`model\`. Only sustained high-confidence entitlement/quota 429 evidence advances
|
|
305
|
+
one entry. Transient 429, overload, auth, policy, and unknown errors never
|
|
306
|
+
down-shift. Runtime state is atomic in .model-recovery.json; exhaustion is
|
|
307
|
+
fail-closed and held down. Change the declared chain/model and restart to
|
|
308
|
+
reconcile explicitly; no chain preserves detection-only behavior.
|
|
275
309
|
`;
|
|
276
310
|
/**
|
|
277
311
|
* What every shipped spawn-skill variant must say, and must not say (7.1).
|
package/dist/doctor.d.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { type Exec } from './exec.js';
|
|
2
|
+
import type { YamlMode } from './config-yaml.js';
|
|
2
3
|
import { type FetchLike } from './monitor.js';
|
|
3
4
|
import type { PrereqReport } from './harness/types.js';
|
|
4
5
|
/** Host-level + per-harness prerequisite report with actionable messages. */
|
|
5
6
|
export declare function doctor(opts?: {
|
|
6
7
|
harness?: string;
|
|
7
8
|
configPath?: string;
|
|
9
|
+
yamlMode?: YamlMode;
|
|
8
10
|
}, exec?: Exec, platform?: NodeJS.Platform, fetchImpl?: FetchLike): Promise<PrereqReport>;
|
package/dist/doctor.js
CHANGED
|
@@ -48,7 +48,7 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
48
48
|
// The configuration is a checked prerequisite in its own right. A config the
|
|
49
49
|
// `config` command rejects must fail here too, with the same cause — while the
|
|
50
50
|
// host checks below still run, because they are what the operator needs next.
|
|
51
|
-
const loaded = loadConfigResult(opts.configPath);
|
|
51
|
+
const loaded = loadConfigResult(opts.configPath, opts.yamlMode);
|
|
52
52
|
const roles = loaded.ok ? loaded.roles : [];
|
|
53
53
|
checks.push(loaded.ok
|
|
54
54
|
? { name: 'config', ok: true, detail: loaded.files.join(' + ') || '(none — no fleet.yaml or fleet.d)' }
|
|
@@ -56,6 +56,13 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
56
56
|
checks.push(loaded.ok
|
|
57
57
|
? { name: 'roles', ok: true, detail: `${roles.length} configured` }
|
|
58
58
|
: { name: 'roles', ok: false, detail: 'unknown — the configuration did not load' });
|
|
59
|
+
if (loaded.ok)
|
|
60
|
+
for (const diagnostic of loaded.diagnostics)
|
|
61
|
+
checks.push({
|
|
62
|
+
name: `yaml: ${diagnostic.kind}`,
|
|
63
|
+
ok: true,
|
|
64
|
+
detail: `warning: ${diagnostic.message}`,
|
|
65
|
+
});
|
|
59
66
|
if (roles.length === 0 || roles.some(role => (role.session ?? 'tmux') === 'tmux')) {
|
|
60
67
|
const tmux = await exec('tmux', ['-V']);
|
|
61
68
|
checks.push({
|
|
@@ -134,6 +141,37 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
134
141
|
+ `grants only ${analysis.capabilities.join(', ') || '(nothing)'}`,
|
|
135
142
|
});
|
|
136
143
|
}
|
|
144
|
+
for (const role of roles.filter(candidate => candidate.auth_proxy !== undefined)) {
|
|
145
|
+
const proxy = role.auth_proxy;
|
|
146
|
+
const credentialKeys = Object.keys(role.env ?? {}).filter(key => /^(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|AUTHORIZATION)$/i.test(key)
|
|
147
|
+
|| /authorization/i.test(key));
|
|
148
|
+
checks.push({
|
|
149
|
+
name: `auth proxy secrets: ${role.name}`,
|
|
150
|
+
ok: credentialKeys.length === 0,
|
|
151
|
+
detail: credentialKeys.length
|
|
152
|
+
? `proxy-enabled role also exposes credential env key(s): ${credentialKeys.join(', ')}`
|
|
153
|
+
: 'credential-free role environment',
|
|
154
|
+
});
|
|
155
|
+
let ok = false;
|
|
156
|
+
let detail;
|
|
157
|
+
try {
|
|
158
|
+
const response = await fetchImpl(proxy.health_url, {
|
|
159
|
+
signal: AbortSignal.timeout(2_000),
|
|
160
|
+
});
|
|
161
|
+
ok = response.ok;
|
|
162
|
+
detail = response.ok
|
|
163
|
+
? `reachable (${proxy.health_url})`
|
|
164
|
+
: `HTTP ${response.status} from ${proxy.health_url}`;
|
|
165
|
+
}
|
|
166
|
+
catch (e) {
|
|
167
|
+
detail = `unreachable (${proxy.health_url}): ${e.message}`;
|
|
168
|
+
}
|
|
169
|
+
checks.push({
|
|
170
|
+
name: `auth proxy: ${role.name}`,
|
|
171
|
+
ok: ok || !proxy.required,
|
|
172
|
+
detail: `${detail}${proxy.required ? ' [required]' : ' [optional]'}`,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
137
175
|
// Isolation reporting (AC-9). Backend availability is advisory — isolation is
|
|
138
176
|
// opt-in per role (OQ-1), so a missing bwrap must not fail doctor for fleets that
|
|
139
177
|
// don't use it. Only a role that DECLARES isolation and cannot get it under
|
|
@@ -272,10 +310,10 @@ export async function doctor(opts = {}, exec = realExec, platform = process.plat
|
|
|
272
310
|
}
|
|
273
311
|
return { ok: checks.every(c => c.ok), checks };
|
|
274
312
|
}
|
|
275
|
-
function loadConfigResult(configPath) {
|
|
313
|
+
function loadConfigResult(configPath, yamlMode) {
|
|
276
314
|
try {
|
|
277
|
-
const cfg = loadConfig(configPath);
|
|
278
|
-
return { ok: true, roles: cfg.roles, files: cfg.files };
|
|
315
|
+
const cfg = loadConfig(configPath, { yamlMode });
|
|
316
|
+
return { ok: true, roles: cfg.roles, files: cfg.files, diagnostics: cfg.diagnostics };
|
|
279
317
|
}
|
|
280
318
|
catch (e) {
|
|
281
319
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
@@ -240,6 +240,17 @@ export function makeClaudeCodeAdapter(exec = realExec) {
|
|
|
240
240
|
capabilities: claudeCapabilities(native, permissions.filesystem),
|
|
241
241
|
};
|
|
242
242
|
},
|
|
243
|
+
effectivePermissions(role) {
|
|
244
|
+
const translated = this.translatePermissions(role.permissions);
|
|
245
|
+
if (!translated.supported)
|
|
246
|
+
return translated;
|
|
247
|
+
const native = permissionMode(role) ?? 'default';
|
|
248
|
+
return {
|
|
249
|
+
...translated,
|
|
250
|
+
native: { permission_mode: native },
|
|
251
|
+
capabilities: claudeCapabilities(native, role.permissions.filesystem),
|
|
252
|
+
};
|
|
253
|
+
},
|
|
243
254
|
vocabulary: {
|
|
244
255
|
bindTool: 'choose_identity',
|
|
245
256
|
createTool: 'create_identity',
|
package/dist/harness/codex.js
CHANGED
|
@@ -258,6 +258,18 @@ export function makeCodexAdapter(exec = realExec) {
|
|
|
258
258
|
capabilities: codexCapabilities(approval, sandbox),
|
|
259
259
|
};
|
|
260
260
|
},
|
|
261
|
+
effectivePermissions(role) {
|
|
262
|
+
const translated = this.translatePermissions(role.permissions);
|
|
263
|
+
if (!translated.supported)
|
|
264
|
+
return translated;
|
|
265
|
+
const approval = approvalPolicy(role) ?? 'on-request';
|
|
266
|
+
const sandbox = sandboxMode(role) ?? 'workspace-write';
|
|
267
|
+
return {
|
|
268
|
+
...translated,
|
|
269
|
+
native: { approval, sandbox },
|
|
270
|
+
capabilities: codexCapabilities(approval, sandbox),
|
|
271
|
+
};
|
|
272
|
+
},
|
|
261
273
|
vocabulary: {
|
|
262
274
|
bindTool: 'choose_identity',
|
|
263
275
|
createTool: 'create_identity',
|
package/dist/harness/types.d.ts
CHANGED
|
@@ -103,6 +103,8 @@ export interface HarnessAdapter {
|
|
|
103
103
|
* explicitly declare that it cannot. Enforced at registration.
|
|
104
104
|
*/
|
|
105
105
|
translatePermissions(permissions: CommonPermissions): PermissionTranslation;
|
|
106
|
+
/** Permission result after native harness_options precedence is applied. */
|
|
107
|
+
effectivePermissions?(role: ResolvedRole): PermissionTranslation;
|
|
106
108
|
/**
|
|
107
109
|
* The permission settings this role states NATIVELY in `harness_options`,
|
|
108
110
|
* keyed the same way `translatePermissions().native` is, so the two can be
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ResolvedRole } from './config.js';
|
|
2
|
+
export type FailureClass = 'model-entitlement-or-quota' | 'transient-rate-limit' | 'overload' | 'authentication' | 'policy' | 'unknown-api-error';
|
|
3
|
+
export interface FailureEvidence {
|
|
4
|
+
class: FailureClass;
|
|
5
|
+
httpStatus?: number;
|
|
6
|
+
model?: string;
|
|
7
|
+
confidence: 'low' | 'medium' | 'high';
|
|
8
|
+
source: 'claude-pane' | 'acp' | 'auth-proxy';
|
|
9
|
+
observedAt: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ModelRecoveryLedger {
|
|
12
|
+
schemaVersion: 1;
|
|
13
|
+
chainFingerprint: string;
|
|
14
|
+
declaredModel: string;
|
|
15
|
+
effectiveModel: string;
|
|
16
|
+
index: number;
|
|
17
|
+
evidenceCount: number;
|
|
18
|
+
evidenceSource?: FailureEvidence['source'];
|
|
19
|
+
lastEvidenceAt?: string;
|
|
20
|
+
transitionedAt?: string;
|
|
21
|
+
heldDown: boolean;
|
|
22
|
+
reconciledAt: string;
|
|
23
|
+
}
|
|
24
|
+
export type RecoveryAction = {
|
|
25
|
+
kind: 'none';
|
|
26
|
+
ledger: ModelRecoveryLedger;
|
|
27
|
+
} | {
|
|
28
|
+
kind: 'advance';
|
|
29
|
+
from: string;
|
|
30
|
+
to: string;
|
|
31
|
+
ledger: ModelRecoveryLedger;
|
|
32
|
+
} | {
|
|
33
|
+
kind: 'hold';
|
|
34
|
+
model: string;
|
|
35
|
+
ledger: ModelRecoveryLedger;
|
|
36
|
+
};
|
|
37
|
+
export declare function classifyFailureText(text: string, source?: FailureEvidence['source'], observedAt?: string): FailureEvidence | undefined;
|
|
38
|
+
export declare function reconcileModelRecovery(dir: string, role: ResolvedRole, now?: string): ModelRecoveryLedger | undefined;
|
|
39
|
+
export declare function recordModelFailure(dir: string, role: ResolvedRole, evidence: FailureEvidence, threshold?: number): RecoveryAction;
|
|
40
|
+
export declare function effectiveModelForRole(dir: string, role: ResolvedRole): string | undefined;
|
|
41
|
+
export declare function modelRecoveryHeld(dir: string): boolean;
|
|
42
|
+
export declare function resetModelRecovery(dir: string, role: ResolvedRole): ModelRecoveryLedger | undefined;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { replaceFileAtomically } from './atomic-file.js';
|
|
5
|
+
const fingerprint = (chain) => createHash('sha256').update(JSON.stringify(chain)).digest('hex');
|
|
6
|
+
const pathOf = (dir) => join(dir, '.model-recovery.json');
|
|
7
|
+
export function classifyFailureText(text, source = 'claude-pane', observedAt = new Date().toISOString()) {
|
|
8
|
+
const tail = text.split('\n').slice(-30).join('\n');
|
|
9
|
+
const status = tail.match(/\b(?:HTTP\s*)?(401|403|429|529)\b/i)?.[1];
|
|
10
|
+
const httpStatus = status ? Number(status) : undefined;
|
|
11
|
+
const base = { httpStatus, source, observedAt };
|
|
12
|
+
if (httpStatus === 529 || /\boverload(?:ed)?\b/i.test(tail))
|
|
13
|
+
return { ...base, class: 'overload', confidence: 'high' };
|
|
14
|
+
if (httpStatus === 401 || httpStatus === 403 || /\binvalid (?:api )?key\b/i.test(tail))
|
|
15
|
+
return { ...base, class: 'authentication', confidence: 'high' };
|
|
16
|
+
if (/\bpolicy|usage policy|content policy\b/i.test(tail))
|
|
17
|
+
return { ...base, class: 'policy', confidence: 'high' };
|
|
18
|
+
if (httpStatus === 429) {
|
|
19
|
+
const entitlement = /\b(model|subscription|plan)\b.{0,80}\b(not (?:available|included|enabled|covered)|unsupported|access)\b/i.test(tail)
|
|
20
|
+
|| /\b(quota|usage limit|billing limit)\b.{0,80}\b(exceeded|reached|unavailable)\b/i.test(tail);
|
|
21
|
+
return {
|
|
22
|
+
...base,
|
|
23
|
+
class: entitlement ? 'model-entitlement-or-quota' : 'transient-rate-limit',
|
|
24
|
+
confidence: entitlement ? 'high' : 'medium',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (/\bAPI Error\b/i.test(tail))
|
|
28
|
+
return { ...base, class: 'unknown-api-error', confidence: 'low' };
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
export function reconcileModelRecovery(dir, role, now = new Date().toISOString()) {
|
|
32
|
+
const chain = role.model_chain;
|
|
33
|
+
if (!chain)
|
|
34
|
+
return undefined;
|
|
35
|
+
const fp = fingerprint(chain);
|
|
36
|
+
let existing;
|
|
37
|
+
try {
|
|
38
|
+
existing = JSON.parse(readFileSync(pathOf(dir), 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
existing = undefined;
|
|
42
|
+
}
|
|
43
|
+
if (existing?.schemaVersion === 1 && existing.chainFingerprint === fp
|
|
44
|
+
&& chain[existing.index] === existing.effectiveModel)
|
|
45
|
+
return existing;
|
|
46
|
+
const ledger = {
|
|
47
|
+
schemaVersion: 1,
|
|
48
|
+
chainFingerprint: fp,
|
|
49
|
+
declaredModel: chain[0],
|
|
50
|
+
effectiveModel: chain[0],
|
|
51
|
+
index: 0,
|
|
52
|
+
evidenceCount: 0,
|
|
53
|
+
heldDown: false,
|
|
54
|
+
reconciledAt: now,
|
|
55
|
+
};
|
|
56
|
+
writeModelRecovery(dir, ledger);
|
|
57
|
+
return ledger;
|
|
58
|
+
}
|
|
59
|
+
export function recordModelFailure(dir, role, evidence, threshold = 3) {
|
|
60
|
+
const ledger = reconcileModelRecovery(dir, role, evidence.observedAt);
|
|
61
|
+
if (!ledger)
|
|
62
|
+
throw new Error('model recovery requires model_chain');
|
|
63
|
+
if (ledger.heldDown || evidence.class !== 'model-entitlement-or-quota'
|
|
64
|
+
|| evidence.confidence !== 'high'
|
|
65
|
+
|| (evidence.model !== undefined && evidence.model !== ledger.effectiveModel))
|
|
66
|
+
return { kind: 'none', ledger };
|
|
67
|
+
const next = {
|
|
68
|
+
...ledger,
|
|
69
|
+
evidenceCount: ledger.evidenceCount + 1,
|
|
70
|
+
evidenceSource: evidence.source,
|
|
71
|
+
lastEvidenceAt: evidence.observedAt,
|
|
72
|
+
};
|
|
73
|
+
if (next.evidenceCount < threshold) {
|
|
74
|
+
writeModelRecovery(dir, next);
|
|
75
|
+
return { kind: 'none', ledger: next };
|
|
76
|
+
}
|
|
77
|
+
if (next.index + 1 >= role.model_chain.length) {
|
|
78
|
+
const held = { ...next, heldDown: true, transitionedAt: evidence.observedAt };
|
|
79
|
+
writeModelRecovery(dir, held);
|
|
80
|
+
writeStatus(dir, held);
|
|
81
|
+
return { kind: 'hold', model: held.effectiveModel, ledger: held };
|
|
82
|
+
}
|
|
83
|
+
const advanced = {
|
|
84
|
+
...next,
|
|
85
|
+
index: next.index + 1,
|
|
86
|
+
effectiveModel: role.model_chain[next.index + 1],
|
|
87
|
+
evidenceCount: 0,
|
|
88
|
+
transitionedAt: evidence.observedAt,
|
|
89
|
+
};
|
|
90
|
+
writeModelRecovery(dir, advanced);
|
|
91
|
+
writeStatus(dir, advanced);
|
|
92
|
+
return { kind: 'advance', from: ledger.effectiveModel, to: advanced.effectiveModel, ledger: advanced };
|
|
93
|
+
}
|
|
94
|
+
export function effectiveModelForRole(dir, role) {
|
|
95
|
+
return reconcileModelRecovery(dir, role)?.effectiveModel ?? role.model;
|
|
96
|
+
}
|
|
97
|
+
export function modelRecoveryHeld(dir) {
|
|
98
|
+
if (!existsSync(pathOf(dir)))
|
|
99
|
+
return false;
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(readFileSync(pathOf(dir), 'utf8')).heldDown === true;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return true;
|
|
105
|
+
} // fail closed on corrupt authorization state
|
|
106
|
+
}
|
|
107
|
+
export function resetModelRecovery(dir, role) {
|
|
108
|
+
const path = pathOf(dir);
|
|
109
|
+
if (existsSync(path))
|
|
110
|
+
replaceFileAtomically(path, '');
|
|
111
|
+
return reconcileModelRecovery(dir, role);
|
|
112
|
+
}
|
|
113
|
+
const writeModelRecovery = (dir, ledger) => replaceFileAtomically(pathOf(dir), `${JSON.stringify(ledger, null, 2)}\n`);
|
|
114
|
+
const writeStatus = (dir, ledger) => replaceFileAtomically(join(dir, '.model-status'), `${JSON.stringify({
|
|
115
|
+
schemaVersion: 1,
|
|
116
|
+
declaredModel: ledger.declaredModel,
|
|
117
|
+
effectiveModel: ledger.effectiveModel,
|
|
118
|
+
heldDown: ledger.heldDown,
|
|
119
|
+
transitionedAt: ledger.transitionedAt,
|
|
120
|
+
}, null, 2)}\n`);
|
package/dist/monitor.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MonitorConfig, NotifyEventType } from './config.js';
|
|
2
|
+
import { type FailureEvidence } from './model-recovery.js';
|
|
2
3
|
/** A content-free arrival event as the daemon serves it over the notifications API. */
|
|
3
4
|
export interface NotifyEvent {
|
|
4
5
|
event?: NotifyEventType | string;
|
|
@@ -53,6 +54,8 @@ export interface MonitorDeps {
|
|
|
53
54
|
detail?: string;
|
|
54
55
|
}>;
|
|
55
56
|
};
|
|
57
|
+
/** Body-free, typed evidence for runner-owned model recovery. */
|
|
58
|
+
onFailureEvidence?(evidence: FailureEvidence): boolean;
|
|
56
59
|
}
|
|
57
60
|
/**
|
|
58
61
|
* Why the monitor is not healthy. Each cause clears on its OWN recovery signal
|
package/dist/monitor.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { classifyFailureText } from './model-recovery.js';
|
|
4
5
|
// Code constants (not config — YAGNI, design §2).
|
|
5
6
|
const DEFAULT_PORT = 3050;
|
|
6
7
|
const LONGPOLL_TIMEOUT_MS = 35_000; // > the daemon's 25s hold
|
|
@@ -517,8 +518,8 @@ export class Monitor {
|
|
|
517
518
|
// The wake landed and a turn started; observe how that turn terminates so a
|
|
518
519
|
// refusal-wedge (every turn dies with `API Error:` while delivery stays green)
|
|
519
520
|
// becomes visible in `.monitor-status` instead of masquerading as armed (#19).
|
|
520
|
-
await this.observeTurnOutcome(pid);
|
|
521
|
-
return
|
|
521
|
+
const recoveryTriggered = await this.observeTurnOutcome(pid);
|
|
522
|
+
return !recoveryTriggered;
|
|
522
523
|
}
|
|
523
524
|
/**
|
|
524
525
|
* Watch the pane until the just-triggered turn settles, then fold its outcome
|
|
@@ -530,25 +531,30 @@ export class Monitor {
|
|
|
530
531
|
async observeTurnOutcome(pid) {
|
|
531
532
|
for (let i = 0; i < TURN_OBSERVE_POLLS; i++) {
|
|
532
533
|
if (this.stopped)
|
|
533
|
-
return; // shutting down — leave status
|
|
534
|
+
return false; // shutting down — leave status
|
|
534
535
|
if (!this.deps.isAlive(pid) || !(await this.deps.tmux.has(this.name)))
|
|
535
|
-
return; // loop marks offline
|
|
536
|
+
return false; // loop marks offline
|
|
536
537
|
const capture = await safeCapture(this.deps.tmux, this.name);
|
|
537
538
|
if (!capture.ok) {
|
|
538
539
|
this.degrade('delivery', 'capture failed during turn observation');
|
|
539
|
-
return;
|
|
540
|
+
return false;
|
|
540
541
|
}
|
|
541
542
|
if (looksApiError(capture.pane)) {
|
|
543
|
+
const evidence = classifyFailureText(capture.pane);
|
|
544
|
+
const recoveryTriggered = evidence
|
|
545
|
+
? this.deps.onFailureEvidence?.(evidence) === true
|
|
546
|
+
: false;
|
|
542
547
|
this.recordTurn('api-error');
|
|
543
|
-
return;
|
|
548
|
+
return recoveryTriggered;
|
|
544
549
|
}
|
|
545
550
|
if (!looksRunning(capture.pane)) {
|
|
546
551
|
this.recordTurn('completed');
|
|
547
|
-
return;
|
|
552
|
+
return false;
|
|
548
553
|
}
|
|
549
554
|
await this.deps.sleep(TURN_OBSERVE_INTERVAL_MS);
|
|
550
555
|
}
|
|
551
556
|
this.recordTurn('inconclusive'); // still running at give-up: hold the streak, don't re-arm
|
|
557
|
+
return false;
|
|
552
558
|
}
|
|
553
559
|
/** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
|
|
554
560
|
recordTurn(outcome) {
|
package/dist/permissions.d.ts
CHANGED
|
@@ -8,12 +8,14 @@ import type { UnattendedCapability } from './harness/types.js';
|
|
|
8
8
|
* — and, being unattended, will report no error while failing to.
|
|
9
9
|
*/
|
|
10
10
|
export declare const UNATTENDED_FLOOR: readonly UnattendedCapability[];
|
|
11
|
+
/** Fleet-owned wake delivery removes the native Monitor-tool requirement. */
|
|
12
|
+
export declare function requiredUnattendedFloor(role: ResolvedRole): readonly UnattendedCapability[];
|
|
11
13
|
export interface FloorResult {
|
|
12
14
|
meets: boolean;
|
|
13
15
|
missing: UnattendedCapability[];
|
|
14
16
|
}
|
|
15
17
|
/** Which floor capabilities a set of granted capabilities fails to cover. */
|
|
16
|
-
export declare function checkUnattendedFloor(granted: readonly UnattendedCapability[]): FloorResult;
|
|
18
|
+
export declare function checkUnattendedFloor(granted: readonly UnattendedCapability[], required?: readonly UnattendedCapability[]): FloorResult;
|
|
17
19
|
/**
|
|
18
20
|
* One role's neutral permissions, resolved through its harness adapter.
|
|
19
21
|
*
|
package/dist/permissions.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { getAdapter } from './harness/registry.js';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { home } from './paths.js';
|
|
2
5
|
/**
|
|
3
6
|
* The capability floor every unattended role must clear. These are not
|
|
4
7
|
* nice-to-haves: an agent that cannot read its briefing, append its worklog,
|
|
@@ -9,9 +12,15 @@ import { getAdapter } from './harness/registry.js';
|
|
|
9
12
|
export const UNATTENDED_FLOOR = [
|
|
10
13
|
'read-state', 'write-state', 'messaging', 'monitor', 'workspace-edit', 'status-commands',
|
|
11
14
|
];
|
|
15
|
+
/** Fleet-owned wake delivery removes the native Monitor-tool requirement. */
|
|
16
|
+
export function requiredUnattendedFloor(role) {
|
|
17
|
+
return role.monitor?.mode === 'fleet'
|
|
18
|
+
? UNATTENDED_FLOOR.filter(capability => capability !== 'monitor')
|
|
19
|
+
: UNATTENDED_FLOOR;
|
|
20
|
+
}
|
|
12
21
|
/** Which floor capabilities a set of granted capabilities fails to cover. */
|
|
13
|
-
export function checkUnattendedFloor(granted) {
|
|
14
|
-
const missing =
|
|
22
|
+
export function checkUnattendedFloor(granted, required = UNATTENDED_FLOOR) {
|
|
23
|
+
const missing = required.filter(c => !granted.includes(c));
|
|
15
24
|
return { meets: missing.length === 0, missing };
|
|
16
25
|
}
|
|
17
26
|
/**
|
|
@@ -50,7 +59,8 @@ export function analyzeRolePermissions(role) {
|
|
|
50
59
|
catch (e) {
|
|
51
60
|
return { ...base, supported: false, warnings: [`role '${role.name}': ${e.message}`] };
|
|
52
61
|
}
|
|
53
|
-
const
|
|
62
|
+
const neutral = adapter.translatePermissions(role.permissions);
|
|
63
|
+
let translation = adapter.effectivePermissions?.(role) ?? neutral;
|
|
54
64
|
if (!translation.supported) {
|
|
55
65
|
return {
|
|
56
66
|
...base, supported: false,
|
|
@@ -58,8 +68,14 @@ export function analyzeRolePermissions(role) {
|
|
|
58
68
|
`permissions — ${translation.reason}`],
|
|
59
69
|
};
|
|
60
70
|
}
|
|
61
|
-
const
|
|
62
|
-
|
|
71
|
+
const inspection = inspectClaudeDontAskSettings(role);
|
|
72
|
+
if (inspection && translation.supported) {
|
|
73
|
+
translation = { ...translation, capabilities: inspection.capabilities };
|
|
74
|
+
}
|
|
75
|
+
const conflicts = neutral.supported
|
|
76
|
+
? findConflicts(role, neutral.native, adapter.nativePermissionOverrides(role.harness_options))
|
|
77
|
+
: [];
|
|
78
|
+
const floor = checkUnattendedFloor(translation.capabilities, requiredUnattendedFloor(role));
|
|
63
79
|
const floorSeverity = role.permissions.unattended === 'deny' ? 'fail' : 'warn';
|
|
64
80
|
return {
|
|
65
81
|
...base,
|
|
@@ -74,7 +90,65 @@ export function analyzeRolePermissions(role) {
|
|
|
74
90
|
`capability floor — missing ${floor.missing.join(', ')} ` +
|
|
75
91
|
`(${formatNative(translation.native)}; unattended=${role.permissions.unattended} means these ` +
|
|
76
92
|
`requests will ${role.permissions.unattended === 'deny' ? 'be denied silently' : 'block the turn'})`),
|
|
77
|
-
warnings:
|
|
93
|
+
warnings: [
|
|
94
|
+
...translation.warnings.map(w => `role '${role.name}': ${w}`),
|
|
95
|
+
...(inspection?.warning ? [`role '${role.name}': ${inspection.warning}`] : []),
|
|
96
|
+
],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function inspectClaudeDontAskSettings(role) {
|
|
100
|
+
if (role.harness !== 'claude-code'
|
|
101
|
+
|| role.harness_options?.permission_mode !== 'dontAsk')
|
|
102
|
+
return undefined;
|
|
103
|
+
const sources = [
|
|
104
|
+
join(home(), '.claude', 'settings.json'),
|
|
105
|
+
...(role.cwd ? [
|
|
106
|
+
join(role.cwd, '.claude', 'settings.json'),
|
|
107
|
+
join(role.cwd, '.claude', 'settings.local.json'),
|
|
108
|
+
] : []),
|
|
109
|
+
];
|
|
110
|
+
const allow = [];
|
|
111
|
+
const deny = [];
|
|
112
|
+
const used = [];
|
|
113
|
+
for (const source of sources) {
|
|
114
|
+
if (!existsSync(source))
|
|
115
|
+
continue;
|
|
116
|
+
try {
|
|
117
|
+
const parsed = JSON.parse(readFileSync(source, 'utf8'));
|
|
118
|
+
if (Array.isArray(parsed.permissions?.allow))
|
|
119
|
+
allow.push(...parsed.permissions.allow.filter((v) => typeof v === 'string'));
|
|
120
|
+
if (Array.isArray(parsed.permissions?.deny))
|
|
121
|
+
deny.push(...parsed.permissions.deny.filter((v) => typeof v === 'string'));
|
|
122
|
+
used.push(source);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
return {
|
|
126
|
+
capabilities: ['read-state'],
|
|
127
|
+
warning: `cannot prove Claude dontAsk allow-list because ${source} is unreadable or invalid: `
|
|
128
|
+
+ `${e.message}`,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const permitted = (patterns) => patterns.some(pattern => allow.some(rule => pattern.test(rule)))
|
|
133
|
+
&& !patterns.some(pattern => deny.some(rule => pattern.test(rule)));
|
|
134
|
+
const capabilities = [];
|
|
135
|
+
if (permitted([/^Read(?:\(|$)/i]))
|
|
136
|
+
capabilities.push('read-state');
|
|
137
|
+
if (permitted([/^Write(?:\(|$)/i, /^Edit(?:\(|$)/i]))
|
|
138
|
+
capabilities.push('write-state');
|
|
139
|
+
if (permitted([/^mcp__(?:ours|plugin_ours)__|^ours[:_]/i]))
|
|
140
|
+
capabilities.push('messaging');
|
|
141
|
+
if (role.monitor?.mode === 'fleet' || permitted([/^Monitor(?:\(|$)/i]))
|
|
142
|
+
capabilities.push('monitor');
|
|
143
|
+
if (permitted([/^Write(?:\(|$)/i, /^Edit(?:\(|$)/i, /^Bash(?:\(|$)/i]))
|
|
144
|
+
capabilities.push('workspace-edit');
|
|
145
|
+
if (permitted([/^Bash(?:\(|$)/i]))
|
|
146
|
+
capabilities.push('status-commands');
|
|
147
|
+
return {
|
|
148
|
+
capabilities,
|
|
149
|
+
warning: used.length
|
|
150
|
+
? `Claude dontAsk allow-list inspected from ${used.join(' + ')}`
|
|
151
|
+
: 'cannot prove Claude dontAsk allow-list: no readable settings source',
|
|
78
152
|
};
|
|
79
153
|
}
|
|
80
154
|
/** Every line a command should show for a role: translation, conflicts, floor. */
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { FleetConfig, ResolvedRole } from './config.js';
|
|
2
|
+
export declare const RESOLVED_PLAN_SCHEMA_VERSION = 1;
|
|
3
|
+
/** Stable, secret-safe machine contract consumed by config JSON and spawn dry-run. */
|
|
4
|
+
export declare function resolvedPlan(cfg: FleetConfig): Record<string, unknown>;
|
|
5
|
+
export declare function resolvedRolePlan(role: ResolvedRole): Record<string, unknown>;
|