@ours.network/fleet 0.15.1 → 0.15.3
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 +36 -7
- package/dist/briefing.js +11 -0
- package/dist/cli.js +38 -6
- package/dist/creation.d.ts +6 -3
- package/dist/creation.js +5 -1
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +33 -6
- package/dist/fleet-proxy.d.ts +25 -0
- package/dist/fleet-proxy.js +36 -0
- package/dist/owner-channel/channel.d.ts +13 -0
- package/dist/owner-channel/channel.js +185 -4
- package/dist/owner-channel/state.d.ts +7 -1
- package/dist/owner-channel/state.js +41 -4
- package/dist/runner.d.ts +2 -0
- package/dist/runner.js +84 -2
- package/dist/session/control.d.ts +6 -1
- package/dist/session/control.js +13 -0
- package/dist/spawn.d.ts +5 -1
- package/dist/spawn.js +14 -12
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -130,6 +130,16 @@ ours-fleet spawn Coder --harness codex --model gpt-5.4 \
|
|
|
130
130
|
# wake owner separately in fleet.yaml with monitor.mode: fleet|native.
|
|
131
131
|
```
|
|
132
132
|
|
|
133
|
+
Inside a managed ACP role, `ours-fleet spawn` is transparently routed through
|
|
134
|
+
that role's live supervisor. `ours-fleet spawn --role DeveloperX --temp` is the
|
|
135
|
+
minimal form: omitted harness, session, cwd, coordinator, neutral permissions,
|
|
136
|
+
fleet monitor policy, and same-harness model inherit from the caller. Explicit
|
|
137
|
+
flags win; changing harness without a model lets the selected harness/fleet
|
|
138
|
+
defaults choose one. After creation succeeds, fleet can deterministically notify
|
|
139
|
+
the caller's owner channel with the caller and spawned-role details. This is an
|
|
140
|
+
honest-actor convenience and attribution path, not a security boundary; tmux,
|
|
141
|
+
host shells, and deliberately bypassed absolute binaries retain direct behavior.
|
|
142
|
+
|
|
133
143
|
## Local web console
|
|
134
144
|
|
|
135
145
|
The interactive console is packaged with `@ours.network/fleet` and binds to
|
|
@@ -267,7 +277,7 @@ ours-fleet up|down|restart|force-restart [-c FILE] [Name...]
|
|
|
267
277
|
ours-fleet config [-c FILE] validate + print merged plan
|
|
268
278
|
ours-fleet ls | attach | peek | logs [-f] | status <Name>
|
|
269
279
|
ours-fleet send <Name> "text" | --key <K>
|
|
270
|
-
ours-fleet spawn [--temp] <Name> [--harness --session --mission --model --approval ...]
|
|
280
|
+
ours-fleet spawn [--temp] [<Name> | --role <Name>] [--harness --session --mission --model --approval ...]
|
|
271
281
|
ours-fleet loops validate|list|status
|
|
272
282
|
ours-fleet loops reload <Role>
|
|
273
283
|
ours-fleet loops run-now|disable|enable <Role> <Loop>
|
|
@@ -657,9 +667,14 @@ An owner request follows one ordered lifecycle on its authenticated source wire:
|
|
|
657
667
|
3. The agent may send any non-final message to the channel identity through its
|
|
658
668
|
ordinary ours MCP tool. CID authentication is the message gate; no task ID,
|
|
659
669
|
request ID, phase, reply reference, or routing command is used.
|
|
660
|
-
4.
|
|
661
|
-
|
|
662
|
-
|
|
670
|
+
4. The agent may send a caption and one or more files to the channel identity.
|
|
671
|
+
A reply reference selects the authenticated owner of that exact source wire;
|
|
672
|
+
an uncorrelated group uses the latest authenticated owner (or the sole-owner
|
|
673
|
+
fallback). Fleet resolves that route once, admits every file before emitting
|
|
674
|
+
any part, then sends the caption and files to the same owner on the same route.
|
|
675
|
+
5. Fleet independently emits exactly one final ACP response (or a sanitized
|
|
676
|
+
terminal outcome). Successful owner-request turns send regular files from the
|
|
677
|
+
request outbox afterward, correlated to the same source wire.
|
|
663
678
|
|
|
664
679
|
Fleet chooses the stored latest authenticated owner for every managed-agent
|
|
665
680
|
message; the model supplies only text and the channel contact. Relay audit logs
|
|
@@ -667,6 +682,16 @@ contain hashed wire prefixes and sizes, never bodies. A durable pre-send marker
|
|
|
667
682
|
prevents blind replay after an ambiguous transport outcome. `/interrupt` remains
|
|
668
683
|
an owner-only supervisor command and does not change the outbound relay contract.
|
|
669
684
|
|
|
685
|
+
Managed-agent caption/file groups have one admission and delivery boundary.
|
|
686
|
+
Fleet authenticates every group member, fixes one owner route before retrieval,
|
|
687
|
+
and validates all selected bytes before sending the caption or any file. A
|
|
688
|
+
correlated source wire never falls back to a different owner. Missing routes stay
|
|
689
|
+
queued without byte retrieval and produce at most one bounded correlated notice
|
|
690
|
+
per running bridge. Policy or admission rejection consumes the entire group and
|
|
691
|
+
sends one correlated NACK. Once outbound emission starts, any transport error is
|
|
692
|
+
recorded as terminal uncertain delivery: the whole group is consumed and never
|
|
693
|
+
blind-retried, because some parts may already have reached the owner.
|
|
694
|
+
|
|
670
695
|
Background work must not keep an ACP turn open. The agent can finalize, return to
|
|
671
696
|
idle, verify the later result on a future wake, and send the result to the same
|
|
672
697
|
channel identity with ordinary `send_message`. Fleet applies the same CID gate and
|
|
@@ -770,9 +795,13 @@ Request files are removed after final delivery and stale directories are removed
|
|
|
770
795
|
after `retention_ms`. A bounded mode-0600 recovery journal stores only owner CID
|
|
771
796
|
and wire routing metadata—never filenames, paths, captions, transcripts, or file
|
|
772
797
|
bytes. If ours-mcp already marked a selected file processed when fleet restarts,
|
|
773
|
-
fleet resumes only that journaled wire with `save_file`;
|
|
774
|
-
|
|
775
|
-
|
|
798
|
+
fleet resumes only that journaled wire with `save_file`; a deferred managed-agent
|
|
799
|
+
caption is replayed with its journaled processed files before the group is
|
|
800
|
+
admitted or relayed. Conversation route state migrates from v1 to a bounded v2
|
|
801
|
+
source-wire index so a correlated group keeps the authenticated owner selected by
|
|
802
|
+
its original request even after later owner traffic. Recovered voice is explicitly
|
|
803
|
+
marked transcript-unavailable. Corrupt recovery state disables attachment
|
|
804
|
+
admission. The host must run an ours-mcp version whose
|
|
776
805
|
`list_incoming_files`, selective `get_files`, and `save_file` schemas support
|
|
777
806
|
these guarantees; `ours-mcp voice-status --json` reports whether transcription
|
|
778
807
|
is currently configured.
|
package/dist/briefing.js
CHANGED
|
@@ -113,6 +113,17 @@ export function generateBriefing(role, v, opts) {
|
|
|
113
113
|
L.push('System acceptance, queue, progress, interruption, failure, and final-delivery notices');
|
|
114
114
|
L.push('on the owner channel are fleet-generated; do not imitate or resend them.');
|
|
115
115
|
}
|
|
116
|
+
if (role.session === 'acp') {
|
|
117
|
+
L.push('', '### Managed fleet commands');
|
|
118
|
+
L.push('This ACP role has a supervisor-scoped ours-fleet proxy. Use the ordinary');
|
|
119
|
+
L.push('`ours-fleet spawn` command; the CLI routes it through your live supervisor, which');
|
|
120
|
+
L.push('records you as the caller and reports successful creation to your owner channel.');
|
|
121
|
+
L.push('A minimal call is `ours-fleet spawn --role DeveloperName --temp`.');
|
|
122
|
+
L.push('For omitted execution settings, the supervisor inherits your harness, session, model,');
|
|
123
|
+
L.push('working directory, neutral permissions, coordinator, and fleet monitor policy. Every');
|
|
124
|
+
L.push('explicit spawn option wins. An explicit different harness does not inherit your model.');
|
|
125
|
+
L.push('This proxy is attribution and convenience, not a security boundary for unisolated roles.');
|
|
126
|
+
}
|
|
116
127
|
if (role.coordinator) {
|
|
117
128
|
L.push(`7. ANNOUNCE yourself: call **${v.sendTool}** to contact "${role.coordinator}" with text:`);
|
|
118
129
|
L.push(` "${role.name} online — identity '${id}' bound, ready."`);
|
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { spawn as spawnChild } from 'node:child_process';
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
4
4
|
import { realpathSync } from 'node:fs';
|
|
5
|
-
import { join as joinPath } from 'node:path';
|
|
5
|
+
import { join as joinPath, resolve as resolvePath } from 'node:path';
|
|
6
6
|
import { createInterface } from 'node:readline';
|
|
7
7
|
import { Command } from 'commander';
|
|
8
8
|
import { VERSION } from './version.js';
|
|
@@ -33,6 +33,7 @@ import { startWebConsole } from './web/runtime.js';
|
|
|
33
33
|
import { requestWebControl } from './web/control.js';
|
|
34
34
|
import { WebServiceManager } from './web/service.js';
|
|
35
35
|
import { WebAccessStore, passwordAccess, validatePublicOrigin } from './web/access.js';
|
|
36
|
+
import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, } from './fleet-proxy.js';
|
|
36
37
|
import './harness/claude-code.js'; // registers the claude-code adapter
|
|
37
38
|
import './harness/codex.js'; // registers the codex adapter
|
|
38
39
|
// sudo/su shells lack XDG_RUNTIME_DIR, breaking every systemctl/journalctl
|
|
@@ -989,7 +990,8 @@ cOpt(program.command('rm <name>').description('stop + delete state dir (+ its fl
|
|
|
989
990
|
die(e);
|
|
990
991
|
}
|
|
991
992
|
});
|
|
992
|
-
cOpt(program.command('spawn
|
|
993
|
+
cOpt(program.command('spawn [name]').description('spawn a new agent (permanent by default)'))
|
|
994
|
+
.option('--role <name>', 'role name (alternative to the positional name)')
|
|
993
995
|
.option('--temp', 'temporary: detached supervisor, auto-cleaned, gone on reboot')
|
|
994
996
|
.option('--harness <id>', 'harness adapter (default: defaults.harness)')
|
|
995
997
|
.option('--session <backend>', 'session backend: tmux|acp (default: defaults.session or tmux)')
|
|
@@ -1017,8 +1019,13 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
1017
1019
|
.option('--json', 'with --dry-run, emit a stable secret-safe JSON result')
|
|
1018
1020
|
.action(async (name, opts) => {
|
|
1019
1021
|
try {
|
|
1022
|
+
const roleName = String(name ?? opts.role ?? '');
|
|
1023
|
+
if (!roleName)
|
|
1024
|
+
throw new Error('role name is required (positional or --role)');
|
|
1025
|
+
if (name && opts.role && name !== opts.role)
|
|
1026
|
+
throw new Error(`positional role '${name}' conflicts with --role '${opts.role}'`);
|
|
1020
1027
|
const o = {
|
|
1021
|
-
name, temp: opts.temp, harness: opts.harness, session: opts.session, mission: opts.mission,
|
|
1028
|
+
name: roleName, temp: opts.temp, harness: opts.harness, session: opts.session, mission: opts.mission,
|
|
1022
1029
|
missionFile: opts.missionFile,
|
|
1023
1030
|
identity: opts.identity, cwd: opts.cwd, coordinator: opts.coordinator,
|
|
1024
1031
|
model: opts.model,
|
|
@@ -1049,13 +1056,38 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
1049
1056
|
}
|
|
1050
1057
|
return;
|
|
1051
1058
|
}
|
|
1059
|
+
const proxyStateDir = process.env[FLEET_PROXY_STATE_DIR_ENV];
|
|
1060
|
+
if (proxyStateDir) {
|
|
1061
|
+
// Paths entered in the agent shell belong to that shell's cwd, not the
|
|
1062
|
+
// supervisor process. Normalize before crossing the control boundary.
|
|
1063
|
+
for (const key of ['missionFile', 'bioFile', 'personaFile', 'isolationFile']) {
|
|
1064
|
+
if (o[key])
|
|
1065
|
+
o[key] = resolvePath(o[key]);
|
|
1066
|
+
}
|
|
1067
|
+
const response = await controlRequest(proxyStateDir, { command: 'fleet_spawn', spawn: o }, 10 * 60_000);
|
|
1068
|
+
if (!response.ok)
|
|
1069
|
+
throw new SessionControlError(response.kind ?? 'backend', response.error ?? 'managed spawn failed');
|
|
1070
|
+
const result = response.result;
|
|
1071
|
+
const expectedCaller = process.env[FLEET_PROXY_CALLER_ENV];
|
|
1072
|
+
if (expectedCaller && result.caller !== expectedCaller)
|
|
1073
|
+
throw new Error(`fleet proxy caller mismatch: expected '${expectedCaller}', got '${result.caller}'`);
|
|
1074
|
+
console.log(`spawned ${result.lifetime} agent '${result.role}' through `
|
|
1075
|
+
+ `${result.caller}'s fleet proxy (state: ${result.statePath})`);
|
|
1076
|
+
console.log(` ${result.harness}/${result.session}`
|
|
1077
|
+
+ `${result.model ? ` model=${result.model}` : ''}; `
|
|
1078
|
+
+ `monitor=${result.monitor.mode} interrupt=${result.monitor.interrupt}`);
|
|
1079
|
+
if (result.inherited.length)
|
|
1080
|
+
console.log(` inherited omitted defaults from ${result.caller}: ${result.inherited.join(', ')}`);
|
|
1081
|
+
console.log(`→ watch it: ours-fleet peek ${result.role} | attach: ours-fleet attach ${result.role}`);
|
|
1082
|
+
return;
|
|
1083
|
+
}
|
|
1052
1084
|
if (o.temp) {
|
|
1053
1085
|
const dir = await spawnTemp(o, binPath);
|
|
1054
|
-
console.log(`spawned temp agent '${
|
|
1086
|
+
console.log(`spawned temp agent '${roleName}' (state: ${dir}; gone on exit/reboot)`);
|
|
1055
1087
|
}
|
|
1056
1088
|
else {
|
|
1057
1089
|
const file = await spawnPermanent(o, deps());
|
|
1058
|
-
console.log(`spawned '${
|
|
1090
|
+
console.log(`spawned '${roleName}' (config: ${file})`);
|
|
1059
1091
|
}
|
|
1060
1092
|
// The same provenance that was persisted, so what the operator reads now
|
|
1061
1093
|
// and what a reviewer reads later cannot disagree (6.6).
|
|
@@ -1065,7 +1097,7 @@ cOpt(program.command('spawn <name>').description('spawn a new agent (permanent b
|
|
|
1065
1097
|
for (const line of formatProvenance(lastProvenance))
|
|
1066
1098
|
console.log(line);
|
|
1067
1099
|
}
|
|
1068
|
-
console.log(`→ watch it: ours-fleet peek ${
|
|
1100
|
+
console.log(`→ watch it: ours-fleet peek ${roleName} | attach: ours-fleet attach ${roleName}`);
|
|
1069
1101
|
}
|
|
1070
1102
|
catch (e) {
|
|
1071
1103
|
die(e);
|
package/dist/creation.d.ts
CHANGED
|
@@ -141,7 +141,7 @@ export declare function daemonIdentityProvisioner(env?: NodeJS.ProcessEnv, fetch
|
|
|
141
141
|
/** Atomically write a role's fleet.d file, journalling it for rollback. */
|
|
142
142
|
export declare function writeRoleFile(tx: CreationTransaction, file: string, contents: string): void;
|
|
143
143
|
/** Where a setting's effective value came from. */
|
|
144
|
-
export type ProvenanceSource = 'cli' | 'fleet-default' | 'built-in';
|
|
144
|
+
export type ProvenanceSource = 'cli' | 'fleet-default' | 'caller-role' | 'built-in';
|
|
145
145
|
export interface ProvenanceEntry {
|
|
146
146
|
value: unknown;
|
|
147
147
|
source: ProvenanceSource;
|
|
@@ -155,8 +155,10 @@ export interface CreationProvenance {
|
|
|
155
155
|
lifetime: 'permanent' | 'temporary';
|
|
156
156
|
role: string;
|
|
157
157
|
/** Additive correlation for non-CLI creation surfaces; never contains request data. */
|
|
158
|
-
surface?: 'cli' | 'web';
|
|
158
|
+
surface?: 'cli' | 'web' | 'agent';
|
|
159
159
|
creationActionId?: string;
|
|
160
|
+
/** Managed role which requested creation through its supervisor proxy. */
|
|
161
|
+
callerRole?: string;
|
|
160
162
|
/** Effective settings, each tagged with where its value came from. */
|
|
161
163
|
settings: Record<string, ProvenanceEntry>;
|
|
162
164
|
}
|
|
@@ -179,8 +181,9 @@ export declare function buildProvenance(o: {
|
|
|
179
181
|
fleetVersion: string;
|
|
180
182
|
now?: Date;
|
|
181
183
|
settings: Record<string, ProvenanceEntry>;
|
|
182
|
-
surface?: 'cli' | 'web';
|
|
184
|
+
surface?: 'cli' | 'web' | 'agent';
|
|
183
185
|
creationActionId?: string;
|
|
186
|
+
callerRole?: string;
|
|
184
187
|
}): CreationProvenance;
|
|
185
188
|
/** Write the provenance record atomically, before the role is started. */
|
|
186
189
|
export declare function writeProvenance(stateDir: string, p: CreationProvenance): void;
|
package/dist/creation.js
CHANGED
|
@@ -247,6 +247,7 @@ export function buildProvenance(o) {
|
|
|
247
247
|
role: o.role,
|
|
248
248
|
surface: o.surface ?? 'cli',
|
|
249
249
|
creationActionId: o.creationActionId,
|
|
250
|
+
callerRole: o.callerRole,
|
|
250
251
|
settings: o.settings,
|
|
251
252
|
};
|
|
252
253
|
}
|
|
@@ -256,7 +257,10 @@ export function writeProvenance(stateDir, p) {
|
|
|
256
257
|
}
|
|
257
258
|
/** One concise line per non-built-in setting, for the post-creation summary. */
|
|
258
259
|
export function formatProvenance(p) {
|
|
259
|
-
const mark = {
|
|
260
|
+
const mark = {
|
|
261
|
+
cli: 'explicit', 'fleet-default': 'fleet default', 'caller-role': 'caller role',
|
|
262
|
+
'built-in': 'built-in',
|
|
263
|
+
};
|
|
260
264
|
return Object.entries(p.settings)
|
|
261
265
|
.filter(([, e]) => e.value !== undefined)
|
|
262
266
|
.map(([k, e]) => ` ${k.padEnd(12)} ${String(e.value)} (${mark[e.source]})`);
|
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 IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted 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\nTemporary-role identity bootstrap is capability-based. The generated briefing\nfirst tries to bind the exact assigned identity and preserves it when it already\nexists. If missing, it uses ours MCP `create_temporary_identity` when that tool\nis exposed, tying a newly-created identity to the connector session lifecycle;\nolder servers fall back to `create_identity`. Collisions and creation errors\nstop safely without force-adopting or deleting identity state. Permanent roles\nretain normal `create_identity` behavior.\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 agent: authenticated-managed-agent-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`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet 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 an owner\nrecipient or calls ours `send_file` for an owner-channel response.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/interrupt`, `/clear`, `/compact`, `/model <model-id>`,\n`/restart`, `/force-restart`, `/ls`, `/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\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 for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\nRapid supervised restart is serialized by a role-scoped single-binder lease.\nThe predecessor closes its authenticated control socket and MCP proxy before\nreleasing ownership. The replacement waits at most five seconds and retries the\ndaemon bind only when PID/start-marker metadata proves the holder was the same\nrole and owner-channel identity. Foreign, live, corrupt, or otherwise\nunverifiable ownership remains fail-closed; fleet never uses `force=true`.\n\nIf that matching predecessor misses the bound, its still-authenticated control\nroute may send one fixed, digest-deduplicated recovery notice through the latest\nauthenticated owner conversation (or the sole configured owner). Notice\nplaintext is never persisted. With no safe deterministic route fleet guesses no\nrecipient and leaves the actionable failure in the web console and role logs.\nThe remote recovery action is `/restart`; inspect repeated failures with\n`ours-fleet logs <Role>` or the web console.\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>\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. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `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 any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\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";
|
|
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 IPv4-loopback-only by default. Both `localhost` and\n`127.0.0.1` are accepted locally. For an nginx/TLS reverse proxy, keep the\ndefault bind and declare the exact browser origin:\n\n`ours-fleet web install --public-origin https://fleet.example.com --password-file /secure/fleet-password`\n\nFleet reads the password file during setup and persists only a salted scrypt\nverifier. New browsers authenticate and retain rotating HttpOnly/SameSite\ntrusted-device credentials. If nginx already authenticates, the operator may\ndeliberately select `--no-password`; the CLI and browser warn that anyone\nreaching the origin can control the fleet. First setup requires an explicit\nchoice: `--password-file` or `--pairing` for protected access, or\n`--no-password` for intentional unprotected access.\n\nUse `--bind ADDRESS` only for an intentional direct listen. A non-loopback\nbind is rejected unless `--public-origin` is also present. Host/Origin checks\nuse the declaration and do not trust forwarded headers. Configure nginx to\nproxy HTTP and WebSocket upgrades to `127.0.0.1:49271` and terminate TLS;\nfleet accepts nginx's loopback upstream Host, so no Host rewrite is required.\nBrowser credentials add Secure for HTTPS, and `revoke-all` invalidates all\ntrusted 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 | --role 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\nTemporary-role identity bootstrap is capability-based. The generated briefing\nfirst tries to bind the exact assigned identity and preserves it when it already\nexists. If missing, it uses ours MCP `create_temporary_identity` when that tool\nis exposed, tying a newly-created identity to the connector session lifecycle;\nolder servers fall back to `create_identity`. Collisions and creation errors\nstop safely without force-adopting or deleting identity state. Permanent roles\nretain normal `create_identity` behavior.\n\nInside a managed ACP role, the same CLI automatically routes a real `spawn`\nthrough that role's authenticated supervisor control socket. `--role Name` is\naccepted as an alternative to the positional name, so a minimal delegated call\nis `ours-fleet spawn --role DeveloperX --temp`. The supervisor records the\ncalling role, performs creation, and only after success sends a structured\nspawn notice through the caller's owner channel when one is configured.\n\nOmitted harness, session, working directory, coordinator, neutral permissions,\nfleet monitor policy, and (when the harness is unchanged) model inherit from the\ncalling role. Explicit options always win. Selecting a different harness without\n`--model` leaves model selection to that harness/fleet defaults rather than\ncopying an incompatible caller model. This automatic proxy is a convenience and\nattribution mechanism, not an isolation boundary: an unrestricted role can still\ninvoke another binary path directly. Tmux roles and host/operator shells keep the\nordinary direct CLI behavior.\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 agent: authenticated-managed-agent-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`. Mail arriving on the dedicated channel from a CID in `owners`\nis injected as a direct `[fleet-owner]` prompt. Mail from the exact `agent`\nCID is forwarded as a new message to the latest authenticated owner conversation;\nits files may also be relayed through this channel. A reply reference selects the\nowner of that authenticated source wire instead of the latest conversation.\nEvery other CID is rejected and warned about without reflecting its body. Fleet 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. For proactive or in-turn agent\nattachments, the agent calls ours `send_file` to the channel identity and may\npair it with a reply-linked caption; fleet, not the agent, chooses the owner.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/interrupt`, `/clear`, `/compact`, `/model <model-id>`,\n`/restart`, `/force-restart`, `/ls`, `/peek`, `/worklog`, and\n`/version`. Unknown or malformed commands answer with the help text instead of\nbeing forwarded; plain messages reach the agent unchanged. `/clear`,\n`/compact`, and `/model` are forwarded only when the role's bundled ACP\nadapter executes them locally (claude-code: all three; codex: `/compact`\nonly) and are otherwise refused with a notice, so slash text never reaches the\nmodel as a prompt.\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`. A deferred agent\ncaption is replayed with its processed files before the group is admitted. Fleet\nresolves one authenticated owner route before retrieving bytes, admits every file\nbefore emitting the caption or any file, and sends every part to that same route.\nUnknown correlated routes remain queued without retrieval and receive one bounded\ncorrelated notice. Admission rejection consumes the whole group with one NACK;\nonce emission starts, a transport error becomes terminal uncertain delivery and\nthe group is never blind-retried. Bounded v2 source-wire routing state is migrated\nfrom v1 on read. Corrupt state disables attachment admission rather than weakening\nprovenance 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 for contact\ninspection and setup; it never starts another ours client and never force-binds:\n\nRapid supervised restart is serialized by a role-scoped single-binder lease.\nThe predecessor closes its authenticated control socket and MCP proxy before\nreleasing ownership. The replacement waits at most five seconds and retries the\ndaemon bind only when PID/start-marker metadata proves the holder was the same\nrole and owner-channel identity. Foreign, live, corrupt, or otherwise\nunverifiable ownership remains fail-closed; fleet never uses `force=true`.\n\nIf that matching predecessor misses the bound, its still-authenticated control\nroute may send one fixed, digest-deduplicated recovery notice through the latest\nauthenticated owner conversation (or the sole configured owner). Notice\nplaintext is never persisted. With no safe deterministic route fleet guesses no\nrecipient and leaves the actionable failure in the web console and role logs.\nThe remote recovery action is `/restart`; inspect repeated failures with\n`ours-fleet logs <Role>` or the web console.\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>\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. On legacy channels without `agent`,\nlive authorizations/revocations are an immediately effective, restart-persistent\noverlay. Managed-agent CID gating makes fleet configuration authoritative and\ndisables live owner mutation and direct control-socket sends. `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 any non-final message\u2014progress, blocker, suggestion, or later proactive note\u2014\nthe managed agent calls ordinary ours `send_message` to the channel identity.\nFleet checks only that the authenticated sender CID exactly equals `agent`, then\nforwards the text as a new message. There is no task/request/update type, phase,\nreply correlation, or owner recipient argument. A sole owner is the safe fallback;\nwith multiple owners and no inbound route history the relay fails closed. Devices\nsharing one identity share its CID; separate owner identities hand off the route\nwhen either sends channel mail. The ACP final is separate: fleet extracts it from\nthe completed turn and deterministically replies to the initiating owner wire.\n\nThe bounded mode-0600 route state stores CIDs, wire IDs, timestamps, delivery state,\nand hashes but never message plaintext. Unauthorized attempts produce a bounded\nCID-only owner warning; attempted bodies are neither reflected nor persisted.\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
|
@@ -101,7 +101,7 @@ harness's own default.
|
|
|
101
101
|
## Spawn
|
|
102
102
|
|
|
103
103
|
\`\`\`sh
|
|
104
|
-
ours-fleet spawn [--temp] Name \\
|
|
104
|
+
ours-fleet spawn [--temp] [Name | --role Name] \\
|
|
105
105
|
--harness codex|claude-code --session tmux|acp \\
|
|
106
106
|
--mission "one line" --cwd /absolute/path --identity Identity \\
|
|
107
107
|
--coordinator Coordinator --model MODEL \\
|
|
@@ -123,6 +123,22 @@ older servers fall back to \`create_identity\`. Collisions and creation errors
|
|
|
123
123
|
stop safely without force-adopting or deleting identity state. Permanent roles
|
|
124
124
|
retain normal \`create_identity\` behavior.
|
|
125
125
|
|
|
126
|
+
Inside a managed ACP role, the same CLI automatically routes a real \`spawn\`
|
|
127
|
+
through that role's authenticated supervisor control socket. \`--role Name\` is
|
|
128
|
+
accepted as an alternative to the positional name, so a minimal delegated call
|
|
129
|
+
is \`ours-fleet spawn --role DeveloperX --temp\`. The supervisor records the
|
|
130
|
+
calling role, performs creation, and only after success sends a structured
|
|
131
|
+
spawn notice through the caller's owner channel when one is configured.
|
|
132
|
+
|
|
133
|
+
Omitted harness, session, working directory, coordinator, neutral permissions,
|
|
134
|
+
fleet monitor policy, and (when the harness is unchanged) model inherit from the
|
|
135
|
+
calling role. Explicit options always win. Selecting a different harness without
|
|
136
|
+
\`--model\` leaves model selection to that harness/fleet defaults rather than
|
|
137
|
+
copying an incompatible caller model. This automatic proxy is a convenience and
|
|
138
|
+
attribution mechanism, not an isolation boundary: an unrestricted role can still
|
|
139
|
+
invoke another binary path directly. Tmux roles and host/operator shells keep the
|
|
140
|
+
ordinary direct CLI behavior.
|
|
141
|
+
|
|
126
142
|
Codex-specific spawn flags: \`--sandbox\`, \`--permission-mode\`, \`--launcher\`,
|
|
127
143
|
\`--profile\`, \`--search\`, repeatable \`--codex-config key=value\`, repeatable
|
|
128
144
|
\`--add-dir\`, and legacy \`--monitor\` (consent for the native Codex monitor,
|
|
@@ -396,15 +412,18 @@ This does not replace the role identity. Normal identity mail remains untrusted
|
|
|
396
412
|
peer input: the agent reads it through \`get_messages\` and replies through
|
|
397
413
|
\`send_message\`. Mail arriving on the dedicated channel from a CID in \`owners\`
|
|
398
414
|
is injected as a direct \`[fleet-owner]\` prompt. Mail from the exact \`agent\`
|
|
399
|
-
CID is forwarded as a new message to the latest authenticated owner conversation
|
|
415
|
+
CID is forwarded as a new message to the latest authenticated owner conversation;
|
|
416
|
+
its files may also be relayed through this channel. A reply reference selects the
|
|
417
|
+
owner of that authenticated source wire instead of the latest conversation.
|
|
400
418
|
Every other CID is rejected and warned about without reflecting its body. Fleet sends
|
|
401
419
|
accepted/queued/progress/interrupted/failure notices and routes the ACP turn's
|
|
402
420
|
final assistant text back to the authenticated sender with its source wire ID.
|
|
403
421
|
For file replies, fleet injects a request-specific outbox path into the owner
|
|
404
422
|
prompt. The agent copies completed artifacts there; fleet sends every regular
|
|
405
423
|
file from the channel identity with the same source wire ID and removes the
|
|
406
|
-
temporary outbox only after successful delivery.
|
|
407
|
-
|
|
424
|
+
temporary outbox only after successful delivery. For proactive or in-turn agent
|
|
425
|
+
attachments, the agent calls ours \`send_file\` to the channel identity and may
|
|
426
|
+
pair it with a reply-linked caption; fleet, not the agent, chooses the owner.
|
|
408
427
|
Owner messages whose trimmed text starts with \`/\` are deterministic
|
|
409
428
|
supervisor commands and never enter the model: \`/help\` (alias \`/commands\`),
|
|
410
429
|
\`/status\`, \`/interrupt\`, \`/clear\`, \`/compact\`, \`/model <model-id>\`,
|
|
@@ -431,8 +450,16 @@ Failure or unavailability is explicit and preserves the private audio path as th
|
|
|
431
450
|
fallback. Run \`ours-mcp voice-status --json\` to inspect the host configuration.
|
|
432
451
|
A mode-0600 crash journal contains only authenticated CID and wire routing data;
|
|
433
452
|
it never stores captions, filenames, paths, transcript text, or bytes. Journaled
|
|
434
|
-
post-retrieval files resume selectively through \`save_file
|
|
435
|
-
|
|
453
|
+
post-retrieval files resume selectively through \`save_file\`. A deferred agent
|
|
454
|
+
caption is replayed with its processed files before the group is admitted. Fleet
|
|
455
|
+
resolves one authenticated owner route before retrieving bytes, admits every file
|
|
456
|
+
before emitting the caption or any file, and sends every part to that same route.
|
|
457
|
+
Unknown correlated routes remain queued without retrieval and receive one bounded
|
|
458
|
+
correlated notice. Admission rejection consumes the whole group with one NACK;
|
|
459
|
+
once emission starts, a transport error becomes terminal uncertain delivery and
|
|
460
|
+
the group is never blind-retried. Bounded v2 source-wire routing state is migrated
|
|
461
|
+
from v1 on read. Corrupt state disables attachment admission rather than weakening
|
|
462
|
+
provenance checks.
|
|
436
463
|
|
|
437
464
|
The channel identity must be unique and must not be a role identity. The bridge
|
|
438
465
|
persists bounded wire IDs only, never message/reply plaintext, and requeues input
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { MonitorConfig, ResolvedRole } from './config.js';
|
|
2
|
+
import type { SpawnOpts } from './spawn.js';
|
|
3
|
+
/** Present only inside a managed role process. The CLI treats it as a routing hint, not authority. */
|
|
4
|
+
export declare const FLEET_PROXY_STATE_DIR_ENV = "OURS_FLEET_PROXY_STATE_DIR";
|
|
5
|
+
export declare const FLEET_PROXY_CALLER_ENV = "OURS_FLEET_PROXY_CALLER";
|
|
6
|
+
export interface ManagedFleetSpawnResult {
|
|
7
|
+
caller: string;
|
|
8
|
+
role: string;
|
|
9
|
+
lifetime: 'permanent' | 'temporary';
|
|
10
|
+
statePath: string;
|
|
11
|
+
harness: string;
|
|
12
|
+
session: 'tmux' | 'acp';
|
|
13
|
+
model?: string;
|
|
14
|
+
monitor: Pick<MonitorConfig, 'mode' | 'interrupt'>;
|
|
15
|
+
inherited: string[];
|
|
16
|
+
creationActionId: string;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Fill only omitted spawn settings from the live caller. Explicit agent choices
|
|
20
|
+
* always win. This is convenience attribution, not an authorization boundary.
|
|
21
|
+
*/
|
|
22
|
+
export declare function inheritCallerSpawnDefaults(caller: ResolvedRole, requested: SpawnOpts, configPath: string | undefined): {
|
|
23
|
+
options: SpawnOpts;
|
|
24
|
+
inherited: string[];
|
|
25
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Present only inside a managed role process. The CLI treats it as a routing hint, not authority. */
|
|
2
|
+
export const FLEET_PROXY_STATE_DIR_ENV = 'OURS_FLEET_PROXY_STATE_DIR';
|
|
3
|
+
export const FLEET_PROXY_CALLER_ENV = 'OURS_FLEET_PROXY_CALLER';
|
|
4
|
+
/**
|
|
5
|
+
* Fill only omitted spawn settings from the live caller. Explicit agent choices
|
|
6
|
+
* always win. This is convenience attribution, not an authorization boundary.
|
|
7
|
+
*/
|
|
8
|
+
export function inheritCallerSpawnDefaults(caller, requested, configPath) {
|
|
9
|
+
const options = { ...requested };
|
|
10
|
+
const inherited = [];
|
|
11
|
+
const take = (key, value) => {
|
|
12
|
+
if (options[key] !== undefined || value === undefined)
|
|
13
|
+
return;
|
|
14
|
+
options[key] = value;
|
|
15
|
+
inherited.push(String(key));
|
|
16
|
+
};
|
|
17
|
+
const sameHarness = requested.harness === undefined || requested.harness === caller.harness;
|
|
18
|
+
take('harness', caller.harness);
|
|
19
|
+
take('session', caller.session);
|
|
20
|
+
take('cwd', caller.cwd);
|
|
21
|
+
take('coordinator', caller.name);
|
|
22
|
+
take('approval', caller.permissions.approval);
|
|
23
|
+
take('filesystem', caller.permissions.filesystem);
|
|
24
|
+
take('unattended', caller.permissions.unattended);
|
|
25
|
+
take('monitorConfig', structuredClone(caller.monitor));
|
|
26
|
+
// A model name and native harness options are not portable across harnesses.
|
|
27
|
+
// When the caller explicitly switches harness, let that harness/fleet defaults
|
|
28
|
+
// select its model instead of copying (for example) a Codex model into Claude.
|
|
29
|
+
if (sameHarness)
|
|
30
|
+
take('model', caller.model);
|
|
31
|
+
options.configPath = configPath;
|
|
32
|
+
options.surface = 'agent';
|
|
33
|
+
options.callerRole = caller.name;
|
|
34
|
+
options.inheritedFromCaller = [...inherited];
|
|
35
|
+
return { options, inherited };
|
|
36
|
+
}
|
|
@@ -2,6 +2,7 @@ import { type ChildProcessWithoutNullStreams } from 'node:child_process';
|
|
|
2
2
|
import { type OwnerChannelConfig } from '../config.js';
|
|
3
3
|
import type { SessionHandle } from '../session/types.js';
|
|
4
4
|
import { type OwnerFleetOps } from './commands.js';
|
|
5
|
+
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
5
6
|
import { type OursToolClient } from './mcp.js';
|
|
6
7
|
import { type OwnerUpdatePhase } from './notices.js';
|
|
7
8
|
import { type OwnerEntry } from './state.js';
|
|
@@ -34,6 +35,8 @@ export interface OwnerChannelHandle {
|
|
|
34
35
|
drain(): Promise<void>;
|
|
35
36
|
close(): Promise<void>;
|
|
36
37
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
38
|
+
/** Fleet-owned deterministic lifecycle notice; absent on legacy test doubles. */
|
|
39
|
+
notifyFleetSpawn?(event: ManagedFleetSpawnResult): Promise<void>;
|
|
37
40
|
}
|
|
38
41
|
export type OwnerChannelManagementRequest = {
|
|
39
42
|
action: 'contact_list';
|
|
@@ -155,6 +158,7 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
155
158
|
drain(): Promise<void>;
|
|
156
159
|
close(): Promise<void>;
|
|
157
160
|
manage(request: OwnerChannelManagementRequest): Promise<OwnerChannelManagementResult>;
|
|
161
|
+
notifyFleetSpawn(event: ManagedFleetSpawnResult): Promise<void>;
|
|
158
162
|
private manageNow;
|
|
159
163
|
private contacts;
|
|
160
164
|
private contact;
|
|
@@ -195,6 +199,15 @@ export declare class OwnerChannel implements OwnerChannelHandle {
|
|
|
195
199
|
private isAgentSender;
|
|
196
200
|
private isEffectiveOwner;
|
|
197
201
|
private relayManagedAgentMessage;
|
|
202
|
+
/**
|
|
203
|
+
* Caption and files are admitted as one relay transaction. The authenticated
|
|
204
|
+
* route and optional source wire are fixed before bytes are retrieved, and no
|
|
205
|
+
* outbound part is emitted until every file passes admission. A transport
|
|
206
|
+
* failure after emission starts is durably uncertain and never blind-retried;
|
|
207
|
+
* the managed agent receives one bounded NACK for the whole transaction.
|
|
208
|
+
*/
|
|
209
|
+
private handleManagedAgentAttachmentGroup;
|
|
210
|
+
private managedAttachmentReplyWire;
|
|
198
211
|
/**
|
|
199
212
|
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
200
213
|
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
@@ -157,6 +157,20 @@ export class OwnerChannel {
|
|
|
157
157
|
this.managementTail = run.then(() => undefined, () => undefined);
|
|
158
158
|
return run;
|
|
159
159
|
}
|
|
160
|
+
notifyFleetSpawn(event) {
|
|
161
|
+
const run = this.managementTail.then(async () => {
|
|
162
|
+
if (!this.ready || this.stopping)
|
|
163
|
+
throw new Error('owner-channel MCP client is unavailable');
|
|
164
|
+
const model = event.model ? `, model ${event.model}` : '';
|
|
165
|
+
const monitor = `${event.monitor.mode} monitor${event.monitor.interrupt ? ' with interruption' : ''}`;
|
|
166
|
+
const inherited = event.inherited.length
|
|
167
|
+
? ` Supervisor inherited omitted defaults: ${event.inherited.join(', ')}.` : '';
|
|
168
|
+
await this.sendProactiveMessage(`🧑💻 ${event.caller} spawned ${event.lifetime} agent ${event.role} `
|
|
169
|
+
+ `(${event.harness}/${event.session}${model}; ${monitor}).${inherited}`, `fleet-spawn\0${event.creationActionId}`, 0);
|
|
170
|
+
});
|
|
171
|
+
this.managementTail = run.then(() => undefined, () => undefined);
|
|
172
|
+
return run;
|
|
173
|
+
}
|
|
160
174
|
async manageNow(request) {
|
|
161
175
|
if (!this.ready || this.stopping)
|
|
162
176
|
throw new Error('owner-channel MCP client is unavailable');
|
|
@@ -337,13 +351,15 @@ export class OwnerChannel {
|
|
|
337
351
|
await send;
|
|
338
352
|
return { action: request.action, requestId: request.requestId, sequence };
|
|
339
353
|
}
|
|
340
|
-
async sendProactiveMessage(messageValue) {
|
|
354
|
+
async sendProactiveMessage(messageValue, digestValue = messageValue, minIntervalMs) {
|
|
341
355
|
if (!this.authorizationIntegrity().ok)
|
|
342
356
|
throw new Error('owner authorization state is corrupt; proactive messages are disabled');
|
|
343
357
|
const message = this.safeProactiveMessage(messageValue);
|
|
344
358
|
const route = this.conversations.route(this.effectiveOwners());
|
|
345
|
-
const digest = createHash('sha256').update(
|
|
346
|
-
const sending =
|
|
359
|
+
const digest = createHash('sha256').update(digestValue).digest('hex');
|
|
360
|
+
const sending = minIntervalMs === undefined
|
|
361
|
+
? this.conversations.beginSend(route.contact, digest)
|
|
362
|
+
: this.conversations.beginSend(route.contact, digest, Date.now(), minIntervalMs);
|
|
347
363
|
try {
|
|
348
364
|
if (!this.isEffectiveOwner(route.contact))
|
|
349
365
|
throw new Error('selected proactive owner is no longer authorized');
|
|
@@ -542,7 +558,18 @@ export class OwnerChannel {
|
|
|
542
558
|
if (exact.some(file => file.senderId !== recovery.contact))
|
|
543
559
|
continue;
|
|
544
560
|
exact.forEach(file => used.add(file.wireId));
|
|
545
|
-
|
|
561
|
+
const caption = recovery.originWireId === recovery.fileWireIds[0]
|
|
562
|
+
? undefined : messageByWire.get(recovery.originWireId);
|
|
563
|
+
if (caption && this.sender(caption).id !== recovery.contact)
|
|
564
|
+
continue;
|
|
565
|
+
// A managed-agent caption is deferred before retrieval. If recovery sees
|
|
566
|
+
// the processed file before that body is replayed, keep the file reserved
|
|
567
|
+
// by the journal until both halves are present again.
|
|
568
|
+
if (!caption && !recovery.fileWireIds.includes(recovery.originWireId))
|
|
569
|
+
continue;
|
|
570
|
+
if (caption)
|
|
571
|
+
consumed.add(caption);
|
|
572
|
+
groups.push({ files: exact, recovery, ...(caption ? { caption } : {}) });
|
|
546
573
|
}
|
|
547
574
|
for (const file of files) {
|
|
548
575
|
if (used.has(file.wireId))
|
|
@@ -578,6 +605,9 @@ export class OwnerChannel {
|
|
|
578
605
|
if (handledWireIds.some(wire => this.inFlight.has(wire)))
|
|
579
606
|
return false;
|
|
580
607
|
const sender = { id: group.files[0].senderId, name: group.files[0].senderName };
|
|
608
|
+
if (group.files.every(file => this.isAgentSender(file.senderId))
|
|
609
|
+
&& (!group.caption || this.isAgentSender(this.sender(group.caption).id)))
|
|
610
|
+
return this.handleManagedAgentAttachmentGroup(group, handledWireIds, sender.id);
|
|
581
611
|
if (group.files.some(file => file.senderId !== sender.id)
|
|
582
612
|
|| !this.isEffectiveOwner(sender.id)) {
|
|
583
613
|
this.options.log(`[${this.options.role}] owner channel ignored unauthorized attachment sender ${sender.id}`);
|
|
@@ -590,6 +620,14 @@ export class OwnerChannel {
|
|
|
590
620
|
catch { }
|
|
591
621
|
return true;
|
|
592
622
|
}
|
|
623
|
+
// Owner files are requests too: retain their authenticated source wire so
|
|
624
|
+
// a later managed-agent attachment reply cannot drift to a newer owner.
|
|
625
|
+
try {
|
|
626
|
+
this.conversations.recordInbound(sender.id, originWireId);
|
|
627
|
+
}
|
|
628
|
+
catch (error) {
|
|
629
|
+
this.logError('owner attachment route update failed', error);
|
|
630
|
+
}
|
|
593
631
|
const rejection = !this.attachmentRecovery.integrity()
|
|
594
632
|
? 'attachment recovery state is unavailable'
|
|
595
633
|
: validateAttachmentSelection(group.files, this.attachmentConfig);
|
|
@@ -936,6 +974,149 @@ export class OwnerChannel {
|
|
|
936
974
|
+ `wire=${createHash('sha256').update(wireId).digest('hex').slice(0, 12)} `
|
|
937
975
|
+ `basis=${route.basis} chars=${Array.from(text).length} bytes=${Buffer.byteLength(text)}`);
|
|
938
976
|
}
|
|
977
|
+
/**
|
|
978
|
+
* Caption and files are admitted as one relay transaction. The authenticated
|
|
979
|
+
* route and optional source wire are fixed before bytes are retrieved, and no
|
|
980
|
+
* outbound part is emitted until every file passes admission. A transport
|
|
981
|
+
* failure after emission starts is durably uncertain and never blind-retried;
|
|
982
|
+
* the managed agent receives one bounded NACK for the whole transaction.
|
|
983
|
+
*/
|
|
984
|
+
async handleManagedAgentAttachmentGroup(group, handledWireIds, agent) {
|
|
985
|
+
const captionWire = group.caption ? this.wireId(group.caption) : undefined;
|
|
986
|
+
const nackWire = captionWire ?? group.files[0].wireId;
|
|
987
|
+
const nackMessage = group.caption ?? { wire_id: nackWire };
|
|
988
|
+
let requestDir;
|
|
989
|
+
let recovery;
|
|
990
|
+
try {
|
|
991
|
+
if (!this.authorizationIntegrity().ok)
|
|
992
|
+
throw new Error('owner authorization state is corrupt; managed-agent relay is disabled');
|
|
993
|
+
const caption = group.caption ? this.safeRelayMessage(group.caption.text) : undefined;
|
|
994
|
+
const replyTo = this.managedAttachmentReplyWire(group, captionWire);
|
|
995
|
+
let route;
|
|
996
|
+
try {
|
|
997
|
+
route = replyTo
|
|
998
|
+
? this.conversations.routeForWire(replyTo, this.effectiveOwners())
|
|
999
|
+
: this.conversations.route(this.effectiveOwners());
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
throw new RelayUnroutableError(this.errorText(error));
|
|
1003
|
+
}
|
|
1004
|
+
if (!this.isEffectiveOwner(route.contact))
|
|
1005
|
+
throw new Error('selected attachment relay owner is no longer authorized');
|
|
1006
|
+
const contact = await this.routableContact(route);
|
|
1007
|
+
const rejection = !this.attachmentRecovery.integrity()
|
|
1008
|
+
? 'attachment recovery state is unavailable'
|
|
1009
|
+
: validateAttachmentSelection(group.files, this.attachmentConfig);
|
|
1010
|
+
if (rejection)
|
|
1011
|
+
throw new Error(rejection);
|
|
1012
|
+
const transactionId = this.requestId(`managed-agent-attachment:${handledWireIds.slice().sort().join(':')}`);
|
|
1013
|
+
recovery = group.recovery ?? {
|
|
1014
|
+
id: transactionId, contact: agent, originWireId: captionWire ?? group.files[0].wireId,
|
|
1015
|
+
fileWireIds: group.files.map(file => file.wireId), createdAt: Date.now(),
|
|
1016
|
+
};
|
|
1017
|
+
if (!group.recovery)
|
|
1018
|
+
this.attachmentRecovery.add(recovery);
|
|
1019
|
+
requestDir = await prepareAttachmentDirectory(this.attachmentRoot, transactionId);
|
|
1020
|
+
const unread = group.files.filter(file => file.status === 'unread');
|
|
1021
|
+
const processed = group.files.filter(file => file.status !== 'unread');
|
|
1022
|
+
const retrieved = unread.length
|
|
1023
|
+
? parseRetrievedAttachments(await this.client.callTool('get_files', {
|
|
1024
|
+
wire_ids: unread.map(file => file.wireId),
|
|
1025
|
+
}), unread)
|
|
1026
|
+
: [];
|
|
1027
|
+
for (const file of processed) {
|
|
1028
|
+
if (!group.recovery)
|
|
1029
|
+
throw new Error('unexpected processed attachment without recovery route');
|
|
1030
|
+
const recoveryPath = join(requestDir, `.recovered-${file.wireId}-${randomUUID()}`);
|
|
1031
|
+
await this.client.callTool('save_file', { wire_id: file.wireId, dest_path: recoveryPath });
|
|
1032
|
+
retrieved.push(await recoveredAttachment(file, recoveryPath));
|
|
1033
|
+
}
|
|
1034
|
+
const order = new Map(group.files.map((file, index) => [file.wireId, index]));
|
|
1035
|
+
retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
|
|
1036
|
+
const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
|
|
1037
|
+
const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
|
|
1038
|
+
const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
|
|
1039
|
+
try {
|
|
1040
|
+
if (caption)
|
|
1041
|
+
await this.send(contact, caption, replyTo);
|
|
1042
|
+
for (const file of admitted) {
|
|
1043
|
+
await this.client.callTool('send_file', {
|
|
1044
|
+
contact, path: file.path, filename: file.filename,
|
|
1045
|
+
...(replyTo ? { reply_to_wire_id: replyTo } : {}),
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
catch {
|
|
1050
|
+
try {
|
|
1051
|
+
this.conversations.finishSend(sending.id, 'uncertain');
|
|
1052
|
+
}
|
|
1053
|
+
catch (error) {
|
|
1054
|
+
this.logError('managed-agent attachment uncertainty persist failed', error);
|
|
1055
|
+
}
|
|
1056
|
+
throw new Error('caption/file relay delivery outcome is uncertain; it was not retried');
|
|
1057
|
+
}
|
|
1058
|
+
this.conversations.finishSend(sending.id, 'delivered');
|
|
1059
|
+
for (const wire of handledWireIds)
|
|
1060
|
+
this.state.remember(wire);
|
|
1061
|
+
this.attachmentRecovery.remove(recovery.id);
|
|
1062
|
+
this.options.log(`[${this.options.role}] managed-agent attachment transaction relayed `
|
|
1063
|
+
+ `wires=${handledWireIds.length} basis=${route.basis} files=${admitted.length} `
|
|
1064
|
+
+ `bytes=${admitted.reduce((total, file) => total + file.size, 0)}`);
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
catch (error) {
|
|
1068
|
+
if (error instanceof DuplicateSendError) {
|
|
1069
|
+
this.options.log(`[${this.options.role}] managed-agent attachment replay consumed`);
|
|
1070
|
+
for (const wire of handledWireIds)
|
|
1071
|
+
this.state.remember(wire);
|
|
1072
|
+
if (recovery)
|
|
1073
|
+
try {
|
|
1074
|
+
this.attachmentRecovery.remove(recovery.id);
|
|
1075
|
+
}
|
|
1076
|
+
catch { }
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
if (error instanceof RelayUnroutableError) {
|
|
1080
|
+
this.options.log(`[${this.options.role}] managed-agent attachment has no owner route; `
|
|
1081
|
+
+ `transaction stays queued: ${this.errorText(error)}`);
|
|
1082
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayQueued());
|
|
1083
|
+
return false;
|
|
1084
|
+
}
|
|
1085
|
+
this.logError('managed-agent caption/file relay refused', error);
|
|
1086
|
+
await this.nackManagedAgent(agent, nackMessage, nackWire, ownerNotices.relayRefused(this.errorText(error)));
|
|
1087
|
+
// Rejection/admission failure and uncertain transport are terminal and
|
|
1088
|
+
// visible. Consuming every correlated wire prevents a later partial replay.
|
|
1089
|
+
for (const wire of handledWireIds)
|
|
1090
|
+
this.state.remember(wire);
|
|
1091
|
+
if (recovery)
|
|
1092
|
+
try {
|
|
1093
|
+
this.attachmentRecovery.remove(recovery.id);
|
|
1094
|
+
}
|
|
1095
|
+
catch { }
|
|
1096
|
+
return true;
|
|
1097
|
+
}
|
|
1098
|
+
finally {
|
|
1099
|
+
if (requestDir)
|
|
1100
|
+
await removeRequestDirectory(requestDir).catch(error => {
|
|
1101
|
+
this.logError('managed-agent attachment cleanup failed', error);
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
managedAttachmentReplyWire(group, captionWire) {
|
|
1106
|
+
const captionReply = group.caption?.reply_to?.wire_id;
|
|
1107
|
+
const candidates = new Set();
|
|
1108
|
+
if (captionReply)
|
|
1109
|
+
candidates.add(captionReply);
|
|
1110
|
+
for (const file of group.files) {
|
|
1111
|
+
const wire = file.replyTo?.wire_id;
|
|
1112
|
+
if (!wire || wire === captionWire)
|
|
1113
|
+
continue;
|
|
1114
|
+
candidates.add(wire);
|
|
1115
|
+
}
|
|
1116
|
+
if (candidates.size > 1)
|
|
1117
|
+
throw new Error('managed-agent caption/file group has conflicting owner reply wires');
|
|
1118
|
+
return candidates.values().next().value;
|
|
1119
|
+
}
|
|
939
1120
|
/**
|
|
940
1121
|
* One bounded NACK per wire: an unroutable or refused relay must be visible
|
|
941
1122
|
* to the authenticated agent, while its deferred replays stay quiet. NACK
|
|
@@ -11,7 +11,7 @@ export declare class OwnerChannelState {
|
|
|
11
11
|
has(wireId: string): boolean;
|
|
12
12
|
remember(wireId: string): void;
|
|
13
13
|
}
|
|
14
|
-
export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner';
|
|
14
|
+
export type OwnerConversationRouteBasis = 'last-inbound' | 'sole-owner' | 'source-wire';
|
|
15
15
|
interface OwnerProactiveSend {
|
|
16
16
|
id: string;
|
|
17
17
|
contact: string;
|
|
@@ -28,6 +28,7 @@ interface OwnerProactiveSend {
|
|
|
28
28
|
export declare class OwnerConversationState {
|
|
29
29
|
private readonly path;
|
|
30
30
|
private conversations;
|
|
31
|
+
private routes;
|
|
31
32
|
private sends;
|
|
32
33
|
private corruptReason?;
|
|
33
34
|
constructor(path: string);
|
|
@@ -41,12 +42,17 @@ export declare class OwnerConversationState {
|
|
|
41
42
|
contact: string;
|
|
42
43
|
basis: OwnerConversationRouteBasis;
|
|
43
44
|
};
|
|
45
|
+
routeForWire(wireId: string, effective: Set<string>): {
|
|
46
|
+
contact: string;
|
|
47
|
+
basis: OwnerConversationRouteBasis;
|
|
48
|
+
};
|
|
44
49
|
beginSend(contact: string, digest: string, now?: number, minIntervalMs?: number, dedupe?: 'contact' | 'all'): OwnerProactiveSend;
|
|
45
50
|
finishSend(id: string, status: 'delivered' | 'uncertain'): void;
|
|
46
51
|
private mutate;
|
|
47
52
|
private persist;
|
|
48
53
|
private assertHealthy;
|
|
49
54
|
private validConversation;
|
|
55
|
+
private validRoute;
|
|
50
56
|
private validSend;
|
|
51
57
|
}
|
|
52
58
|
export type OwnerSource = 'baseline' | 'dynamic';
|
|
@@ -42,6 +42,7 @@ export class OwnerChannelState {
|
|
|
42
42
|
}
|
|
43
43
|
}
|
|
44
44
|
const CONVERSATION_LIMIT = 64;
|
|
45
|
+
const WIRE_ROUTE_LIMIT = 512;
|
|
45
46
|
const PROACTIVE_SEND_LIMIT = 256;
|
|
46
47
|
const PROACTIVE_MIN_INTERVAL_MS = 30_000;
|
|
47
48
|
const HEX_64_LOWER = /^[a-f0-9]{64}$/;
|
|
@@ -55,6 +56,7 @@ const CID = /^[A-Fa-f0-9]{64}$/;
|
|
|
55
56
|
export class OwnerConversationState {
|
|
56
57
|
path;
|
|
57
58
|
conversations = [];
|
|
59
|
+
routes = [];
|
|
58
60
|
sends = [];
|
|
59
61
|
corruptReason;
|
|
60
62
|
constructor(path) {
|
|
@@ -63,18 +65,29 @@ export class OwnerConversationState {
|
|
|
63
65
|
return;
|
|
64
66
|
try {
|
|
65
67
|
const raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
66
|
-
|
|
68
|
+
const legacy = raw.version === 1;
|
|
69
|
+
const routes = legacy
|
|
70
|
+
? (raw.conversations ?? []).map(record => ({
|
|
71
|
+
contact: record.contact, wireId: record.lastInboundWireId, at: record.lastInboundAt,
|
|
72
|
+
}))
|
|
73
|
+
: raw.routes;
|
|
74
|
+
if (![1, 2].includes(raw.version ?? 0)
|
|
75
|
+
|| !Array.isArray(raw.conversations) || !Array.isArray(routes) || !Array.isArray(raw.sends)
|
|
67
76
|
|| raw.conversations.length > CONVERSATION_LIMIT
|
|
77
|
+
|| routes.length > WIRE_ROUTE_LIMIT
|
|
68
78
|
|| raw.sends.length > PROACTIVE_SEND_LIMIT
|
|
69
79
|
|| !raw.conversations.every(record => this.validConversation(record))
|
|
80
|
+
|| !routes.every(route => this.validRoute(route))
|
|
70
81
|
|| !raw.sends.every(send => this.validSend(send)))
|
|
71
82
|
throw new Error('invalid or unbounded conversation state');
|
|
72
83
|
if (new Set(raw.conversations.map(record => record.contact)).size !== raw.conversations.length
|
|
84
|
+
|| new Set(routes.map(route => route.wireId)).size !== routes.length
|
|
73
85
|
|| new Set(raw.sends.map(send => send.id)).size !== raw.sends.length)
|
|
74
86
|
throw new Error('duplicate conversation state entry');
|
|
75
87
|
this.conversations = raw.conversations.map(record => ({ ...record }));
|
|
88
|
+
this.routes = routes.map(route => ({ ...route }));
|
|
76
89
|
this.sends = raw.sends.map(send => ({ ...send }));
|
|
77
|
-
let recovered =
|
|
90
|
+
let recovered = legacy;
|
|
78
91
|
for (const send of this.sends) {
|
|
79
92
|
if (send.status === 'sending') {
|
|
80
93
|
send.status = 'uncertain';
|
|
@@ -88,6 +101,7 @@ export class OwnerConversationState {
|
|
|
88
101
|
catch {
|
|
89
102
|
this.corruptReason = 'invalid persisted owner conversation state';
|
|
90
103
|
this.conversations = [];
|
|
104
|
+
this.routes = [];
|
|
91
105
|
this.sends = [];
|
|
92
106
|
try {
|
|
93
107
|
chmodSync(path, 0o600);
|
|
@@ -118,6 +132,9 @@ export class OwnerConversationState {
|
|
|
118
132
|
throw new Error(`owner conversations are limited to ${CONVERSATION_LIMIT}`);
|
|
119
133
|
this.conversations.push({ contact, lastInboundAt: acceptedAt, lastInboundWireId: wireId });
|
|
120
134
|
}
|
|
135
|
+
this.routes = this.routes.filter(route => route.wireId !== wireId);
|
|
136
|
+
this.routes.push({ contact, wireId, at: acceptedAt });
|
|
137
|
+
this.routes = this.routes.slice(-WIRE_ROUTE_LIMIT);
|
|
121
138
|
});
|
|
122
139
|
}
|
|
123
140
|
remove(contact) {
|
|
@@ -127,6 +144,7 @@ export class OwnerConversationState {
|
|
|
127
144
|
return;
|
|
128
145
|
this.mutate(() => {
|
|
129
146
|
this.conversations = this.conversations.filter(record => canonicalCid(record.contact) !== canonical);
|
|
147
|
+
this.routes = this.routes.filter(route => canonicalCid(route.contact) !== canonical);
|
|
130
148
|
});
|
|
131
149
|
}
|
|
132
150
|
route(effective) {
|
|
@@ -146,6 +164,14 @@ export class OwnerConversationState {
|
|
|
146
164
|
return { contact: [...effective][0], basis: 'sole-owner' };
|
|
147
165
|
throw new Error('no authenticated owner conversation route is available yet');
|
|
148
166
|
}
|
|
167
|
+
routeForWire(wireId, effective) {
|
|
168
|
+
this.assertHealthy();
|
|
169
|
+
const route = [...this.routes].reverse().find(item => item.wireId === wireId);
|
|
170
|
+
const allowed = new Set([...effective].map(canonicalCid));
|
|
171
|
+
if (!route || !allowed.has(canonicalCid(route.contact)))
|
|
172
|
+
throw new Error('no authenticated owner route matches the source wire');
|
|
173
|
+
return { contact: route.contact, basis: 'source-wire' };
|
|
174
|
+
}
|
|
149
175
|
beginSend(contact, digest, now = Date.now(), minIntervalMs = PROACTIVE_MIN_INTERVAL_MS, dedupe = 'contact') {
|
|
150
176
|
this.assertHealthy();
|
|
151
177
|
if (!CID.test(contact) || !HEX_64_LOWER.test(digest))
|
|
@@ -177,7 +203,9 @@ export class OwnerConversationState {
|
|
|
177
203
|
this.mutate(() => { send.status = status; });
|
|
178
204
|
}
|
|
179
205
|
mutate(change) {
|
|
180
|
-
const snapshot = JSON.stringify({
|
|
206
|
+
const snapshot = JSON.stringify({
|
|
207
|
+
conversations: this.conversations, routes: this.routes, sends: this.sends,
|
|
208
|
+
});
|
|
181
209
|
change();
|
|
182
210
|
try {
|
|
183
211
|
this.persist();
|
|
@@ -185,13 +213,14 @@ export class OwnerConversationState {
|
|
|
185
213
|
catch (error) {
|
|
186
214
|
const old = JSON.parse(snapshot);
|
|
187
215
|
this.conversations = old.conversations;
|
|
216
|
+
this.routes = old.routes;
|
|
188
217
|
this.sends = old.sends;
|
|
189
218
|
throw error;
|
|
190
219
|
}
|
|
191
220
|
}
|
|
192
221
|
persist() {
|
|
193
222
|
replaceFileAtomically(this.path, JSON.stringify({
|
|
194
|
-
version:
|
|
223
|
+
version: 2, conversations: this.conversations, routes: this.routes, sends: this.sends,
|
|
195
224
|
}) + '\n', 0o600);
|
|
196
225
|
chmodSync(this.path, 0o600);
|
|
197
226
|
}
|
|
@@ -207,6 +236,14 @@ export class OwnerConversationState {
|
|
|
207
236
|
&& record.lastInboundAt >= 0 && typeof record.lastInboundWireId === 'string'
|
|
208
237
|
&& record.lastInboundWireId.length > 0 && record.lastInboundWireId.length <= 1_024;
|
|
209
238
|
}
|
|
239
|
+
validRoute(value) {
|
|
240
|
+
if (!value || typeof value !== 'object')
|
|
241
|
+
return false;
|
|
242
|
+
const route = value;
|
|
243
|
+
return CID.test(route.contact) && typeof route.wireId === 'string'
|
|
244
|
+
&& route.wireId.length > 0 && route.wireId.length <= 1_024
|
|
245
|
+
&& Number.isSafeInteger(route.at) && route.at >= 0;
|
|
246
|
+
}
|
|
210
247
|
validSend(value) {
|
|
211
248
|
if (!value || typeof value !== 'object')
|
|
212
249
|
return false;
|
package/dist/runner.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export interface RunnerDeps {
|
|
|
27
27
|
/** Lets a test (or a shutdown path) end the supervised restart loop. */
|
|
28
28
|
shouldStop?(): boolean;
|
|
29
29
|
}
|
|
30
|
+
/** Environment injected only into the managed harness process. */
|
|
31
|
+
export declare function managedFleetProxyEnv(role: ResolvedRole, stateDir: string): Record<string, string>;
|
|
30
32
|
/**
|
|
31
33
|
* Record who owns wake delivery for this run. Returning true means a fleet
|
|
32
34
|
* monitor is taking ownership back from a native harness and must start at the
|
package/dist/runner.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { parse } from 'yaml';
|
|
@@ -22,6 +22,7 @@ import { OwnerChannel } from './owner-channel/channel.js';
|
|
|
22
22
|
import { acquireOwnerBinderLease, OwnerBinderHandoffTimeoutError, } from './owner-channel/binder.js';
|
|
23
23
|
import { RoleTurnArbiter } from './session/arbiter.js';
|
|
24
24
|
import { ScheduledLoopManager, } from './loops/manager.js';
|
|
25
|
+
import { FLEET_PROXY_CALLER_ENV, FLEET_PROXY_STATE_DIR_ENV, inheritCallerSpawnDefaults, } from './fleet-proxy.js';
|
|
25
26
|
const defaultDeps = () => ({
|
|
26
27
|
tmux: new Tmux(),
|
|
27
28
|
exec: realExec,
|
|
@@ -54,6 +55,60 @@ const defaultDeps = () => ({
|
|
|
54
55
|
},
|
|
55
56
|
});
|
|
56
57
|
const MONITOR_OWNER_FILE = '.monitor-owner';
|
|
58
|
+
/** Environment injected only into the managed harness process. */
|
|
59
|
+
export function managedFleetProxyEnv(role, stateDir) {
|
|
60
|
+
return {
|
|
61
|
+
...(role.env ?? {}),
|
|
62
|
+
[FLEET_PROXY_STATE_DIR_ENV]: stateDir,
|
|
63
|
+
[FLEET_PROXY_CALLER_ENV]: role.name,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Execute a typed proxy request in the caller's supervisor. Dynamic imports
|
|
68
|
+
* avoid a runner↔spawn initialization cycle (spawn imports runner constants).
|
|
69
|
+
*/
|
|
70
|
+
async function executeManagedSpawn(caller, configPath, requested, log) {
|
|
71
|
+
const { options, inherited } = inheritCallerSpawnDefaults(caller, requested, configPath);
|
|
72
|
+
const creationActionId = randomUUID();
|
|
73
|
+
options.creationActionId = creationActionId;
|
|
74
|
+
const spawnModule = await import('./spawn.js');
|
|
75
|
+
const preview = spawnModule.spawnDryRun(options).resolvedRole;
|
|
76
|
+
const runtimeBinPath = (() => {
|
|
77
|
+
try {
|
|
78
|
+
return realpathSync(process.argv[1]);
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return process.argv[1];
|
|
82
|
+
}
|
|
83
|
+
})();
|
|
84
|
+
let statePath;
|
|
85
|
+
if (options.temp) {
|
|
86
|
+
statePath = await spawnModule.spawnTemp(options, runtimeBinPath);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
const { pickBackend } = await import('./supervisor/index.js');
|
|
90
|
+
const { WatchdogServiceManager } = await import('./watchdog/service.js');
|
|
91
|
+
statePath = await spawnModule.spawnPermanent(options, {
|
|
92
|
+
backend: pickBackend(), binPath: runtimeBinPath, log,
|
|
93
|
+
watchdogService: new WatchdogServiceManager(),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const result = {
|
|
97
|
+
caller: caller.name,
|
|
98
|
+
role: options.name,
|
|
99
|
+
lifetime: options.temp ? 'temporary' : 'permanent',
|
|
100
|
+
statePath,
|
|
101
|
+
harness: preview.harness,
|
|
102
|
+
session: preview.session,
|
|
103
|
+
...(preview.model ? { model: preview.model } : {}),
|
|
104
|
+
monitor: { mode: preview.monitor.mode, interrupt: preview.monitor.interrupt },
|
|
105
|
+
inherited,
|
|
106
|
+
creationActionId,
|
|
107
|
+
};
|
|
108
|
+
log(`[${caller.name}] managed fleet proxy spawned ${result.lifetime} role ${result.role} `
|
|
109
|
+
+ `harness=${result.harness} session=${result.session}`);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
57
112
|
/**
|
|
58
113
|
* Record who owns wake delivery for this run. Returning true means a fleet
|
|
59
114
|
* monitor is taking ownership back from a native harness and must start at the
|
|
@@ -435,6 +490,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
435
490
|
let acpStartupComplete = false;
|
|
436
491
|
let ownerChannel;
|
|
437
492
|
let ownerBinder;
|
|
493
|
+
const pendingFleetSpawnNotices = [];
|
|
438
494
|
let loopManager;
|
|
439
495
|
let arbiter;
|
|
440
496
|
let reloadLoopConfig;
|
|
@@ -453,7 +509,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
453
509
|
name,
|
|
454
510
|
argv: wrappedArgv,
|
|
455
511
|
cwd: runCwd,
|
|
456
|
-
env: { ...launch.env, ...(role
|
|
512
|
+
env: { ...launch.env, ...managedFleetProxyEnv(role, dir) },
|
|
457
513
|
stateDir: dir,
|
|
458
514
|
mode,
|
|
459
515
|
permissions: perms,
|
|
@@ -501,6 +557,23 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
501
557
|
unsubscribeRecovery?.();
|
|
502
558
|
throw error;
|
|
503
559
|
}
|
|
560
|
+
control.setFleetSpawner(async (requested) => {
|
|
561
|
+
const event = await executeManagedSpawn(role, configPath, requested, deps.log);
|
|
562
|
+
if (!role.owner_channel)
|
|
563
|
+
return event;
|
|
564
|
+
if (ownerChannel?.notifyFleetSpawn) {
|
|
565
|
+
try {
|
|
566
|
+
await ownerChannel.notifyFleetSpawn(event);
|
|
567
|
+
}
|
|
568
|
+
catch (error) {
|
|
569
|
+
deps.log(`[${name}] spawned-agent owner notice failed: `
|
|
570
|
+
+ `${error?.message ?? String(error)}`);
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
else
|
|
574
|
+
pendingFleetSpawnNotices.push(event);
|
|
575
|
+
return event;
|
|
576
|
+
});
|
|
504
577
|
resolvedMonitorDeps.delivery = {
|
|
505
578
|
// A wake is only delivered when its turn TERMINATES successfully. A
|
|
506
579
|
// refusal or a cancellation reached the agent and was not acted on, so
|
|
@@ -589,6 +662,15 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
|
|
|
589
662
|
throw new Error(`[${name}] owner channel failed to start: `
|
|
590
663
|
+ `${error?.message ?? String(error)}`);
|
|
591
664
|
}
|
|
665
|
+
for (const event of pendingFleetSpawnNotices.splice(0)) {
|
|
666
|
+
try {
|
|
667
|
+
await ownerChannel.notifyFleetSpawn?.(event);
|
|
668
|
+
}
|
|
669
|
+
catch (error) {
|
|
670
|
+
deps.log(`[${name}] deferred spawned-agent owner notice failed: `
|
|
671
|
+
+ `${error?.message ?? String(error)}`);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
592
674
|
}
|
|
593
675
|
if (ownerChannel)
|
|
594
676
|
control.setOwnerChannel(ownerChannel);
|
|
@@ -2,11 +2,13 @@ import { type Socket } from 'node:net';
|
|
|
2
2
|
import type { ControlFailureKind, SessionHandle } from './types.js';
|
|
3
3
|
import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
|
|
4
4
|
import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
|
|
5
|
+
import type { SpawnOpts } from '../spawn.js';
|
|
6
|
+
import type { ManagedFleetSpawnResult } from '../fleet-proxy.js';
|
|
5
7
|
export interface ControlRequest {
|
|
6
8
|
version: 1 | 2;
|
|
7
9
|
id: string;
|
|
8
10
|
token: string;
|
|
9
|
-
command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config';
|
|
11
|
+
command: 'status' | 'snapshot' | 'submit_prompt' | 'respond_permission' | 'interrupt' | 'follow' | 'events_since' | 'owner_channel_manage' | 'loop_status' | 'loop_run_now' | 'loop_disable' | 'loop_enable' | 'reload_config' | 'fleet_spawn';
|
|
10
12
|
text?: string;
|
|
11
13
|
permissionId?: string;
|
|
12
14
|
optionId?: string;
|
|
@@ -15,6 +17,7 @@ export interface ControlRequest {
|
|
|
15
17
|
controller?: boolean;
|
|
16
18
|
ownerChannel?: OwnerChannelManagementRequest;
|
|
17
19
|
loop?: string;
|
|
20
|
+
spawn?: SpawnOpts;
|
|
18
21
|
}
|
|
19
22
|
export interface ControlResponse {
|
|
20
23
|
version: 1;
|
|
@@ -78,6 +81,7 @@ export declare class RoleControlServer {
|
|
|
78
81
|
private ownerChannel?;
|
|
79
82
|
private loopManager?;
|
|
80
83
|
private reloadConfig?;
|
|
84
|
+
private fleetSpawner?;
|
|
81
85
|
constructor(stateDir: string, session: SessionHandle, log: (line: string) => void);
|
|
82
86
|
start(): Promise<void>;
|
|
83
87
|
close(): Promise<void>;
|
|
@@ -85,6 +89,7 @@ export declare class RoleControlServer {
|
|
|
85
89
|
setOwnerChannel(ownerChannel: OwnerChannelHandle | undefined): void;
|
|
86
90
|
setLoopManager(loopManager: ScheduledLoopManagerHandle | undefined): void;
|
|
87
91
|
setConfigReloader(reloadConfig: (() => Promise<unknown>) | undefined): void;
|
|
92
|
+
setFleetSpawner(fleetSpawner: ((options: SpawnOpts) => Promise<ManagedFleetSpawnResult>) | undefined): void;
|
|
88
93
|
private accept;
|
|
89
94
|
private handle;
|
|
90
95
|
private write;
|
package/dist/session/control.js
CHANGED
|
@@ -98,6 +98,7 @@ export class RoleControlServer {
|
|
|
98
98
|
ownerChannel;
|
|
99
99
|
loopManager;
|
|
100
100
|
reloadConfig;
|
|
101
|
+
fleetSpawner;
|
|
101
102
|
constructor(stateDir, session, log) {
|
|
102
103
|
this.session = session;
|
|
103
104
|
this.log = log;
|
|
@@ -140,6 +141,9 @@ export class RoleControlServer {
|
|
|
140
141
|
setConfigReloader(reloadConfig) {
|
|
141
142
|
this.reloadConfig = reloadConfig;
|
|
142
143
|
}
|
|
144
|
+
setFleetSpawner(fleetSpawner) {
|
|
145
|
+
this.fleetSpawner = fleetSpawner;
|
|
146
|
+
}
|
|
143
147
|
accept(socket) {
|
|
144
148
|
this.sockets.add(socket);
|
|
145
149
|
socket.setEncoding('utf8');
|
|
@@ -271,6 +275,15 @@ export class RoleControlServer {
|
|
|
271
275
|
});
|
|
272
276
|
return;
|
|
273
277
|
}
|
|
278
|
+
case 'fleet_spawn': {
|
|
279
|
+
if (request.version !== 2 || !request.spawn || typeof request.spawn.name !== 'string')
|
|
280
|
+
throw new SessionControlError('rejected', 'version 2 and typed spawn options are required');
|
|
281
|
+
if (!this.fleetSpawner)
|
|
282
|
+
throw new SessionControlError('rejected', 'managed fleet spawning is unavailable for this role');
|
|
283
|
+
const result = await this.fleetSpawner(request.spawn);
|
|
284
|
+
this.write(socket, { version: 1, id: request.id, ok: true, result });
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
274
287
|
case 'owner_channel_manage': {
|
|
275
288
|
if (!request.ownerChannel || typeof request.ownerChannel.action !== 'string')
|
|
276
289
|
throw new SessionControlError('rejected', 'owner-channel management action is required');
|
package/dist/spawn.d.ts
CHANGED
|
@@ -40,8 +40,12 @@ export interface SpawnOpts {
|
|
|
40
40
|
bio?: string;
|
|
41
41
|
persona?: string;
|
|
42
42
|
/** Internal, non-sensitive provenance correlation for typed presentation layers. */
|
|
43
|
-
surface?: 'cli' | 'web';
|
|
43
|
+
surface?: 'cli' | 'web' | 'agent';
|
|
44
44
|
creationActionId?: string;
|
|
45
|
+
/** Set only by a live role supervisor after a role-scoped proxy request. */
|
|
46
|
+
callerRole?: string;
|
|
47
|
+
/** Internal provenance labels for values filled by the caller's supervisor. */
|
|
48
|
+
inheritedFromCaller?: string[];
|
|
45
49
|
/**
|
|
46
50
|
* Path to a file holding exactly the existing `isolation:` mapping — the same
|
|
47
51
|
* schema fleet.yaml uses, not a second policy language. The ONE new operator
|
package/dist/spawn.js
CHANGED
|
@@ -238,29 +238,31 @@ export function spawnDryRun(o) {
|
|
|
238
238
|
*/
|
|
239
239
|
function provenanceSettings(o, defaults) {
|
|
240
240
|
const perms = (defaults.permissions ?? {});
|
|
241
|
+
const callerDefaults = new Set(o.inheritedFromCaller ?? []);
|
|
242
|
+
const tagged = (key, entry) => callerDefaults.has(key) ? { ...entry, source: 'caller-role' } : entry;
|
|
241
243
|
const explicitModel = typeof o.model === 'string' ? o.model.trim() : undefined;
|
|
242
244
|
const inheritedModel = resolveRoleModel(undefined, o.harness, defaults);
|
|
243
245
|
return {
|
|
244
|
-
harness: provenanceOf(o.harness, defaults.harness, 'claude-code'),
|
|
245
|
-
session: provenanceOf(o.session, defaults.session, 'tmux'),
|
|
246
|
+
harness: tagged('harness', provenanceOf(o.harness, defaults.harness, 'claude-code')),
|
|
247
|
+
session: tagged('session', provenanceOf(o.session, defaults.session, 'tmux')),
|
|
246
248
|
identity: o.identity
|
|
247
249
|
? { value: o.identity, source: 'cli' }
|
|
248
250
|
: { value: o.name, source: 'built-in' }, // defaults to the role name
|
|
249
|
-
cwd: provenanceOf(o.cwd, undefined, undefined),
|
|
250
|
-
model: o.model === null
|
|
251
|
+
cwd: tagged('cwd', provenanceOf(o.cwd, undefined, undefined)),
|
|
252
|
+
model: tagged('model', o.model === null
|
|
251
253
|
? { value: undefined, source: 'cli' }
|
|
252
254
|
: explicitModel
|
|
253
255
|
? { value: explicitModel, source: 'cli' }
|
|
254
|
-
: { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' },
|
|
255
|
-
coordinator: provenanceOf(o.coordinator, undefined, undefined),
|
|
256
|
+
: { value: inheritedModel, source: inheritedModel ? 'fleet-default' : 'built-in' }),
|
|
257
|
+
coordinator: tagged('coordinator', provenanceOf(o.coordinator, undefined, undefined)),
|
|
256
258
|
permission_mode: provenanceOf(o.permissionMode, undefined, undefined),
|
|
257
|
-
approval: provenanceOf(o.approval, perms.approval, 'ask'),
|
|
258
|
-
filesystem: provenanceOf(o.filesystem, perms.filesystem, 'workspace'),
|
|
259
|
-
unattended: provenanceOf(o.unattended, perms.unattended, 'deny'),
|
|
259
|
+
approval: tagged('approval', provenanceOf(o.approval, perms.approval, 'ask')),
|
|
260
|
+
filesystem: tagged('filesystem', provenanceOf(o.filesystem, perms.filesystem, 'workspace')),
|
|
261
|
+
unattended: tagged('unattended', provenanceOf(o.unattended, perms.unattended, 'deny')),
|
|
260
262
|
isolation: o.isolationFile
|
|
261
263
|
? { value: 'declared via --isolation-file', source: 'cli' }
|
|
262
264
|
: { value: defaults.isolation ? 'from fleet defaults' : undefined, source: defaults.isolation ? 'fleet-default' : 'built-in' },
|
|
263
|
-
monitor: provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' }),
|
|
265
|
+
monitor: tagged('monitorConfig', provenanceOf(o.monitorConfig, defaults.monitor, { mode: 'fleet' })),
|
|
264
266
|
};
|
|
265
267
|
}
|
|
266
268
|
/** Permanent spawn: persist to ~/fleet.d/<Name>.yaml, then bring it up. */
|
|
@@ -324,7 +326,7 @@ export async function spawnPermanent(o, deps, creation = {}) {
|
|
|
324
326
|
const provenance = buildProvenance({
|
|
325
327
|
role: o.name, lifetime: 'permanent', fleetVersion: VERSION,
|
|
326
328
|
settings: provenanceSettings(o, cfg.defaults),
|
|
327
|
-
surface: o.surface, creationActionId: o.creationActionId,
|
|
329
|
+
surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
|
|
328
330
|
});
|
|
329
331
|
mkdirSync(agentDir(o.name), { recursive: true });
|
|
330
332
|
writeProvenance(agentDir(o.name), provenance);
|
|
@@ -414,7 +416,7 @@ async function spawnTempInner(o, binPath, launch, tx, guarantee, onStage) {
|
|
|
414
416
|
const provenance = buildProvenance({
|
|
415
417
|
role: o.name, lifetime: 'temporary', fleetVersion: VERSION,
|
|
416
418
|
settings: provenanceSettings(o, cfg.defaults),
|
|
417
|
-
surface: o.surface, creationActionId: o.creationActionId,
|
|
419
|
+
surface: o.surface, creationActionId: o.creationActionId, callerRole: o.callerRole,
|
|
418
420
|
});
|
|
419
421
|
writeProvenance(dir, provenance);
|
|
420
422
|
lastProvenance = provenance;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ours.network/fleet",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.3",
|
|
4
4
|
"description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "FSL-1.1-Apache-2.0",
|