@ours.network/fleet 1.1.0-nightly.13 → 1.1.0-nightly.14

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 CHANGED
@@ -525,6 +525,13 @@ Cowork role, and task. The agent creates that identity itself with ours MCP
525
525
  work immediately. Fleet activates the room from Cowork's authenticated seat; there
526
526
  is no briefing hash, startup ACK, or separate role-briefing readiness gate.
527
527
 
528
+ Set `room.anonymous: true` on a room template, or pass `--anonymous` to
529
+ `task create`, `task start`, `task work`, or `room create`, to create an
530
+ anonymous Cowork room. `--no-anonymous` explicitly overrides an anonymous
531
+ template. Fleet records the resolved value before room creation so retries keep
532
+ the same choice. Temporary members of an anonymous room are instructed to call
533
+ `create_temporary_identity` with `expose_local=false`.
534
+
528
535
  Human task and room results use the same compact Markdown presentation in the
529
536
  CLI and authenticated owner channel: a short heading, icon-plus-word status,
530
537
  code-formatted identifiers, bounded summaries, and actionable recovery or error
@@ -4,7 +4,7 @@ import { provisionMembers } from '../rooms-tasks/provision.js';
4
4
  import { moveTaskToList } from '../rooms-tasks/task-state.js';
5
5
  import { type MemberOverrides } from '../rooms-tasks/member-overrides.js';
6
6
  import { type TaskDeletionSettleResult } from '../rooms-tasks/deletion.js';
7
- import type { RoomOrchestrationRecord, TaskListRecord, TaskOrigin, TaskOutcome, TaskRecord, TaskState } from '../rooms-tasks/types.js';
7
+ import type { RoomLaunchPolicy, RoomOrchestrationRecord, TaskListRecord, TaskOrigin, TaskOutcome, TaskRecord, TaskState, TemplateSnapshot } from '../rooms-tasks/types.js';
8
8
  import type { TaskDeletionAcceptance } from '../rooms-tasks/task-state.js';
9
9
  export type TaskRoomActor = {
10
10
  kind: 'local_control';
@@ -64,6 +64,7 @@ export interface CreateTaskRequest {
64
64
  origin: TaskOrigin;
65
65
  list?: string;
66
66
  members?: MemberOverrides;
67
+ anonymous?: boolean;
67
68
  }
68
69
  export interface CreateRoomRequest {
69
70
  actor: TaskRoomActor;
@@ -73,7 +74,9 @@ export interface CreateRoomRequest {
73
74
  brief?: string;
74
75
  briefFile?: string;
75
76
  members?: MemberOverrides;
77
+ anonymous?: boolean;
76
78
  }
79
+ export declare function resolveRoomLaunchPolicy(template: TemplateSnapshot | undefined, override: boolean | undefined): RoomLaunchPolicy;
77
80
  export interface TaskRoomServiceDeps {
78
81
  loadConfiguration?(path?: string): FleetConfig;
79
82
  cowork?(config: FleetConfig): CoworkAdapter;
@@ -97,6 +100,7 @@ export declare class TaskRoomApplicationService {
97
100
  taskId: string;
98
101
  template?: string;
99
102
  members?: MemberOverrides;
103
+ anonymous?: boolean;
100
104
  }): Promise<TaskRecord>;
101
105
  listTasks(filter?: {
102
106
  state?: TaskState | TaskState[];
@@ -242,6 +246,7 @@ export declare class TaskRoomApplicationService {
242
246
  identity_cid: string;
243
247
  room_name: string;
244
248
  state: "provisioning" | "active" | "closing" | "closed";
249
+ anonymous: boolean;
245
250
  seats: import("../rooms-tasks/cowork-adapter.js").CoworkSeatInfo[];
246
251
  goal?: string;
247
252
  briefing?: string;
@@ -298,6 +303,7 @@ export declare class TaskRoomApplicationService {
298
303
  taskId: string;
299
304
  template?: string;
300
305
  members?: MemberOverrides;
306
+ anonymous?: boolean;
301
307
  }): Promise<{
302
308
  task: TaskRecord;
303
309
  status: 'ready' | 'already_active';
@@ -15,7 +15,7 @@ import { acceptTaskTerminalIntent, recordTaskTerminalIntentError, settleTaskTerm
15
15
  import { acceptTaskDeletion, recordTaskDeletionError, settleTaskDeletion, } from '../rooms-tasks/deletion.js';
16
16
  import { withFileLock } from '../atomic-file.js';
17
17
  import { launchFleetWorker } from '../rooms-tasks/external-worker.js';
18
- import { TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from '../rooms-tasks/types.js';
18
+ import { storedRoomLaunchPolicy, TASK_CANCELLABLE_STATES, TASK_TERMINAL_STATES } from '../rooms-tasks/types.js';
19
19
  export class TaskRoomApplicationError extends Error {
20
20
  code;
21
21
  fields;
@@ -26,6 +26,10 @@ export class TaskRoomApplicationError extends Error {
26
26
  this.name = 'TaskRoomApplicationError';
27
27
  }
28
28
  }
29
+ export function resolveRoomLaunchPolicy(template, override) {
30
+ const anonymous = override ?? template?.room?.anonymous ?? false;
31
+ return { anonymous };
32
+ }
29
33
  function launchSelection(definition, key) {
30
34
  const selected = definition?.[key];
31
35
  if (!selected || typeof selected !== 'object' || Array.isArray(selected))
@@ -81,6 +85,9 @@ export class TaskRoomApplicationService {
81
85
  async createTask(request) {
82
86
  const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
83
87
  let template = this.createTemplate(cfg, request.template, request.noRoom);
88
+ if (request.noRoom && request.anonymous !== undefined)
89
+ throw new ConfigError('--anonymous/--no-anonymous cannot be combined with --no-room');
90
+ const roomPolicy = resolveRoomLaunchPolicy(template, request.anonymous);
84
91
  let launchDefinitions;
85
92
  let executionPlan;
86
93
  let preparedPlan;
@@ -97,7 +104,8 @@ export class TaskRoomApplicationService {
97
104
  if (preparedPlan) {
98
105
  template = sealTemplateSnapshot(preparedPlan.snapshot, cfg.agentTemplates ?? {}, preparedPlan.launchDefinitions);
99
106
  sealedHash = template.launch_snapshot_hash;
100
- executionPlan = { schema_version: 1, snapshot: template, overrides: preparedPlan.overrides,
107
+ executionPlan = { schema_version: 1, snapshot: template, room_policy: roomPolicy,
108
+ overrides: preparedPlan.overrides,
101
109
  overrides_hash: preparedPlan.overridesHash,
102
110
  plan_hash: preparedPlan.planHash };
103
111
  }
@@ -129,7 +137,7 @@ export class TaskRoomApplicationService {
129
137
  try {
130
138
  const room = await this.provisionRoom(cfg, task, template, created => {
131
139
  task = updateTaskRoom(task.task_id, created.room_id, created.room_identity_cid);
132
- }, launchDefinitions);
140
+ }, launchDefinitions, roomPolicy);
133
141
  task = readTask(task.task_id);
134
142
  if (task.state === 'active' && room.state === 'active')
135
143
  recordFleetAuditPresentation({ kind: 'task', operation: 'work', id: task.task_id,
@@ -164,13 +172,15 @@ export class TaskRoomApplicationService {
164
172
  if (request.members && Object.keys(request.members).length) {
165
173
  const prepared = prepareExecutionPlan(definition, cfg, request.members);
166
174
  template = prepared.snapshot;
167
- return this.provisionRoom(cfg, { title: request.name, brief, goal: request.goal }, template, () => { }, prepared.launchDefinitions);
175
+ const roomPolicy = resolveRoomLaunchPolicy(template, request.anonymous);
176
+ return this.provisionRoom(cfg, { title: request.name, brief, goal: request.goal }, template, () => { }, prepared.launchDefinitions, roomPolicy);
168
177
  }
169
178
  template = snapshotTemplate(definition, cfg.agentTemplates);
170
179
  }
180
+ const roomPolicy = resolveRoomLaunchPolicy(template, request.anonymous);
171
181
  return this.provisionRoom(cfg, {
172
182
  title: request.name, brief, goal: request.goal,
173
- }, template, () => { });
183
+ }, template, () => { }, undefined, roomPolicy);
174
184
  }
175
185
  async startTask(input) {
176
186
  return (await this.ensureTaskWork(input)).task;
@@ -532,6 +542,14 @@ export class TaskRoomApplicationService {
532
542
  return { kind: 'deletion_worker_required', roomId: input.roomId };
533
543
  const room = await adapter.recoverRoom(input.roomId);
534
544
  orchestration = getRoomRecord(input.roomId);
545
+ if (orchestration) {
546
+ const policy = storedRoomLaunchPolicy(orchestration.room_policy);
547
+ if ((room.anonymous ?? false) !== policy.anonymous) {
548
+ const error = `Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(policy.anonymous)})`;
549
+ setSagaError(input.roomId, error, 'Do not respawn members. Repair or upgrade Cowork, then recover the Room without changing its policy.', 'waiting_cowork');
550
+ throw new Error(error);
551
+ }
552
+ }
535
553
  if (orchestration && !orchestration.owner_seat_cid
536
554
  && (orchestration.provisioning_detail === 'waiting_owner_invite'
537
555
  || orchestration.provisioning_detail === 'owner_cid_mismatch')) {
@@ -601,6 +619,10 @@ export class TaskRoomApplicationService {
601
619
  async ensureTaskWork(input) {
602
620
  const cfg = (this.deps.loadConfiguration ?? loadConfig)(this.configurationPath);
603
621
  let task = readTask(input.taskId);
622
+ const recordedRoom = task.room_id ? getRoomRecord(task.room_id) : undefined;
623
+ const recordedPolicy = storedRoomLaunchPolicy(recordedRoom?.room_policy ?? task.execution_plan?.room_policy);
624
+ if (task.room_id && input.anonymous !== undefined && input.anonymous !== recordedPolicy.anonymous)
625
+ throw new TaskRoomApplicationError('template_mismatch', 'anonymous override does not match the existing Room launch policy', { room: task.room_id });
604
626
  if (TASK_TERMINAL_STATES.includes(task.state))
605
627
  throw new TaskRoomApplicationError('task_terminal', 'task terminal', { task: input.taskId, state: task.state });
606
628
  if (task.state === 'active' && task.room_id) {
@@ -644,6 +666,11 @@ export class TaskRoomApplicationService {
644
666
  let preparedPlan = !durable && definition
645
667
  ? prepareExecutionPlan(definition, cfg, input.members ?? {}) : undefined;
646
668
  let snapshot = durable ?? preparedPlan.snapshot;
669
+ const roomPolicy = input.anonymous !== undefined
670
+ ? resolveRoomLaunchPolicy(snapshot, input.anonymous)
671
+ : task.execution_plan
672
+ ? storedRoomLaunchPolicy(task.execution_plan.room_policy)
673
+ : resolveRoomLaunchPolicy(snapshot, undefined);
647
674
  if (preparedPlan)
648
675
  launchDefinitions = preparedPlan.launchDefinitions;
649
676
  if (input.members && Object.keys(input.members).length && !storedOverridesMatch) {
@@ -686,6 +713,7 @@ export class TaskRoomApplicationService {
686
713
  try {
687
714
  sealed = sealTemplateSnapshot(snapshot, cfg.agentTemplates ?? {}, launchDefinitions);
688
715
  task = updateTaskExecutionPlan(task.task_id, { schema_version: 1, snapshot: sealed,
716
+ room_policy: roomPolicy,
689
717
  overrides: preparedPlan.overrides, overrides_hash: preparedPlan.overridesHash,
690
718
  plan_hash: preparedPlan.planHash });
691
719
  snapshot = sealed;
@@ -698,6 +726,9 @@ export class TaskRoomApplicationService {
698
726
  }
699
727
  unlock();
700
728
  }
729
+ else if (!task.room_id && task.execution_plan && input.anonymous !== undefined) {
730
+ task = updateTaskExecutionPlan(task.task_id, { ...task.execution_plan, room_policy: roomPolicy });
731
+ }
701
732
  else if (!task.template || task.template.name !== snapshot.name || task.template.content_hash !== snapshot.content_hash)
702
733
  task = updateTaskTemplate(task.task_id, { name: snapshot.name, version: snapshot.version, content_hash: snapshot.content_hash });
703
734
  if (task.state === 'backlog') {
@@ -711,7 +742,7 @@ export class TaskRoomApplicationService {
711
742
  try {
712
743
  await this.provisionRoom(cfg, task, snapshot, created => {
713
744
  task = updateTaskRoom(task.task_id, created.room_id, created.room_identity_cid);
714
- }, launchDefinitions);
745
+ }, launchDefinitions, roomPolicy);
715
746
  task = readTask(task.task_id);
716
747
  }
717
748
  catch (error) {
@@ -778,7 +809,7 @@ export class TaskRoomApplicationService {
778
809
  throw new TaskRoomApplicationError('task_template_drift', `task template snapshot no longer matches ${ref.name}@${ref.version}`);
779
810
  return snapshot;
780
811
  }
781
- async provisionRoom(cfg, task, template, onCreated, launchDefinitions) {
812
+ async provisionRoom(cfg, task, template, onCreated, launchDefinitions, policy = resolveRoomLaunchPolicy(template, undefined)) {
782
813
  const rooms = cfg.rooms;
783
814
  if (!rooms)
784
815
  throw new ConfigError('rooms: configuration is required');
@@ -811,11 +842,11 @@ export class TaskRoomApplicationService {
811
842
  room_name: task.title, goal: task.goal?.trim() || task.title,
812
843
  briefing: task.brief?.trim() || launchTemplate?.contract?.trim() || task.goal?.trim() || task.title,
813
844
  quiet_membership: launchTemplate?.room?.quiet_membership,
814
- anonymous: launchTemplate?.room?.anonymous,
845
+ anonymous: policy.anonymous,
815
846
  });
816
847
  const record = createRoomRecord({
817
848
  room_id: created.room_id, room_name: task.title, room_identity_cid: created.identity_cid,
818
- task_id: task.task_id, template_snapshot: launchTemplate,
849
+ task_id: task.task_id, template_snapshot: launchTemplate, room_policy: policy,
819
850
  });
820
851
  onCreated(record);
821
852
  return record;
package/dist/briefing.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { userInfo } from 'node:os';
2
2
  import { oversightTaxonomyLines } from './session/control.js';
3
- function temporaryIdentityBootstrap(id, v) {
3
+ function temporaryIdentityBootstrap(id, v, anonymous = false) {
4
4
  return [
5
5
  `2. CREATE your ours identity now: call **${v.temporaryCreateTool}** through ours MCP`,
6
- ` with the exact assigned name "${id}". The ours connector owns its cleanup when this`,
6
+ ` with the exact assigned name "${id}"${anonymous ? ' and expose_local=false' : ''}. The ours connector owns its cleanup when this`,
7
7
  ' connector session lifecycle ends.',
8
8
  ' Do not inspect, preserve, adopt, or use any pre-existing or persistent identity.',
9
9
  ' On a collision, missing tool, or creation error, STOP and',
@@ -24,7 +24,7 @@ function generateRoomMemberBriefing(role, v, opts, prefix) {
24
24
  L.push('', '### One-time room invite', '', '```text', startup.invite, '```');
25
25
  L.push('', '## Do these NOW, in order');
26
26
  L.push(`1. ${v.launchNote(role.name)}`);
27
- L.push(...temporaryIdentityBootstrap(startup.identity_name, v));
27
+ L.push(...temporaryIdentityBootstrap(startup.identity_name, v, startup.anonymous));
28
28
  L.push('3. Call **add_contact** through ours MCP with the exact one-time invite above. Confirm');
29
29
  L.push(` that it resolves to room CID \`${startup.room_identity_cid}\`. The contact may remain`);
30
30
  L.push(' pending while the room finishes its asynchronous verification.');
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.1.0-nightly.13",
3
- "buildId": "dfbf670d05b6",
4
- "commit": "f58249fc5ee836b0ee42f265d5400840a15ab7be",
2
+ "version": "1.1.0-nightly.14",
3
+ "buildId": "3651cb00af5a",
4
+ "commit": "974e9150cbb9d24f92412866de7a53a0cd6fde8f",
5
5
  "dirty": true,
6
- "builtAt": "2026-09-01T06:24:05.049Z",
6
+ "builtAt": "2026-09-01T08:58:08.364Z",
7
7
  "capabilities": [
8
8
  "monitor.interrupt.after_tool"
9
9
  ]
package/dist/config.d.ts CHANGED
@@ -175,6 +175,7 @@ export interface RoomMemberStartup {
175
175
  role: string;
176
176
  task: string;
177
177
  owner_seat_cid: string | null;
178
+ anonymous?: boolean;
178
179
  }
179
180
  export interface ResolvedRole extends Omit<RoleConfig, 'model' | 'owner_channel' | 'worklog'> {
180
181
  name: string;
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 through the\nstructured ACP session path:\n\n- harness: `claude-code` or `codex`\n- session: `acp` (default and only supported value)\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]\nours-fleet version [--json] # build identity, capabilities, every install on PATH\n```\n\nConfiguration v2 is `~/fleet.yaml` plus typed bare documents under the exact\nstem directories `~/fleet/agents`, `~/fleet/agent_templates`, `~/fleet/roles`, `~/fleet/brains`, and\n`~/fleet/room_templates`.\nThe manifest owns fleet-wide operational defaults and automation; each Agent\nselects one inline/ref Role and Brain and carries its operational fields.\nAgent Templates under `~/fleet/agent_templates` are inert reusable launch definitions;\nonly explicit files under `~/fleet/agents` are persistent lifecycle instances.\nRoom members use `agent_template` and receive immutable content-addressed snapshots.\nLegacy top-level `roles:` and `fleet.d` are rejected. Validate the complete\ntrusted source set with `config` and `doctor` before starting or restarting.\n\nPermanent `spawn` writes `~/fleet/agents/Name.yaml`. The web console edits an\nexplicit `{manifest, agents, agent_templates}` model while Role/Brain presets remain read-only.\nIts aggregate revision includes every Agent/Role/Brain/Room-template source, previews a\nredacted per-document diff in an exact-stem private staging tree, and saves under\none root lock with a private multi-file backup and full rollback. A no-op is\nbyte-identical and creates no backup.\n\n## Build identity and install provenance\n\n`--version` prints a semver and nothing else, and a semver does NOT identify an\nartifact. Version bumps land in a release commit of their own, so every build cut\nbetween two releases carries the PREVIOUS version while already containing new\nbehaviour. One host ran two installs that both reported 0.16.0 \u2014 same version,\ndifferent build. One accepted `monitor.interrupt: after_tool`, the other\nrejected it as invalid. Their\n`dist/cli.js` were byte-identical \u2014 the divergence was in other modules.\n\nEvery build therefore stamps `dist/build-info.json` with a build id (first 12 hex\nof a sha256 over the rest of `dist/`), the commit it was cut from, and the\ncapability tokens the shipped code declares \u2014 for example\n`monitor.interrupt.after_tool`. Ask any executable what it is:\n\n```sh\nours-fleet version # ours-fleet 0.17.0+9f1c2a3b4d5e, capabilities, PATH installs\nours-fleet version --json # the same as machine-readable JSON, no environment values\n```\n\nRead a capability, never a version number, to decide whether a setting is\nsupported. When a build rejects a value it knows the name of, it says which\ncapability is missing and which build rejected it, because another install on the\nsame host may accept the identical file. `config` prints the build that resolved\nthe plan; `status <Name>` says so when the build reporting on a role is not the\none that created it (roles record their creating build in `creation.json`).\n\n`ours-fleet doctor` runs an `install` check that lists every `ours-fleet` on\nPATH plus the one executing, and FAILS when two installs share a semver but are\ndifferent builds, or when the running artifact is a DIFFERENT artifact from the\none PATH resolves to. A second prefix holding identical content is not a skew\nand is not reported. A PATH entry the shell would not execute \u2014 a directory, or\na file without its execute bit \u2014 is not counted as an install at all.\nInstalls built before this stamp existed report `+unknown`; they are compared by\nhashing their `dist/` instead, so two pre-provenance installs are still told\napart. To fix a flagged host, remove or update the stale install \u2014 do not rely on\nPATH order.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init [-c FILE] # seed missing presets; never replace an existing file\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 rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` use the structured agent session.\nAttachment also accepts `/permit <permission-id> <option-id>`, `/interrupt`,\nand `/detach`.\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 | --name Name] \\\n --brain BRAIN_ID --role ROLE_ID \\\n --cwd /absolute/path --identity Identity --coordinator Coordinator \\\n --approval ask|auto|allow \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait --isolation-file /path/isolation.yaml\n```\n\nPermanent spawn writes `~/fleet/agents/Name.yaml` and starts a supervised role.\n`--temp` writes active state under `~/.ours-fleet/tmp` and starts an independent\ntransient supervisor (a collected systemd unit or submitted launchd job). It is\nnot enabled across reboot and does not die when the role that spawned it restarts.\nBrain definitions own the ACP session backend. When a temporary role's bound identity\ncloses or its session ends, the supervisor, monitor and live roster entry retire\ntogether; state moves intact to `~/.ours-fleet/recovery/temporary` with a\ntermination record. Failed launches use the same archive rather than deleting\ntheir briefing, provenance, logs or partial supervisor metadata.\n\nNamed `down` and `rm` commands can target an exact state-backed temporary role\neven though it is absent from merged fleet YAML. The recorded transient unit/job\nis authoritative. Missing/incomplete ownership metadata is reconciled only from\nan exact `_run-temp <role>` process-table match: one match may be adopted, zero\nsettles as stopped, and ambiguity or an unreadable table fails closed. Launching\nrecords receive a bounded grace so a not-yet-registered transient unit cannot be\nmistaken for a stopped one. Stale recorded supervisors are reclaimed in bounded\nbatches by moving their state to the same recovery archive, never by blind deletion.\n\nEvery temporary role creates a new session-owned identity by calling ours MCP\n`create_temporary_identity` with its exact assigned name. It never binds a\npre-existing identity and never falls back to permanent `create_identity`.\nFleet does not inspect, preserve, or provision an ours identity for temporary\nspawn; creation belongs exclusively to the launched temporary agent session.\nCollisions, missing tool support, and creation errors stop safely without\nforce-adopting or deleting identity state. Permanent roles\nare provisioned by fleet before launch and never delegate normal identity\ncreation to the harness.\n\nThe temporary supervisor treats its first positive identity observation as the\nlifecycle readiness gate: a cold harness may take as long as needed to read its\nbriefing and bind, without a fixed first-bind retirement timer. After readiness,\nonly sustained authoritative absence closes the role. Unreachable, malformed, or\nvalid-but-empty daemon indexes are ambiguous and reset closure debounce rather\nthan becoming cleanup authority.\n\nInside a managed ACP role, public `ours-fleet` commands cross an authenticated\nsupervisor attribution boundary before Commander parsing. The original CLI remains\nthe executor inside the role's existing OS sandbox, and ordinary CLI validation is\nthe source of truth. Hidden worker entry points remain internal; public lifecycle and\noperator commands are not restricted by the proxy.\n\nCommand invocation, raw argv, read-only work, validation failures, and generic\noutcomes are never forwarded to the Owner-visible channel. Fleet announces only\nconfirmed Agent, Task, and Room lifecycle changes. Local diagnostics retain\nstructurally redacted command metadata. Lifecycle delivery uncertainty is logged,\nnever recursively announced, and never reruns or blindly retries an effect.\nRoom participant summaries describe creation and activation. Fleet has no public\npost-create Room membership mutation, so it does not claim a separate membership event.\n\nOmitted Brain and Role selections, working directory, coordinator, neutral permissions,\nand fleet monitor policy inherit from the calling Agent. Explicit options always win.\nIdentity, mission/profile text, environment, owner routing, auth proxy, room startup,\nisolation, worklog, and sensitive inline Brain values never inherit implicitly.\nThis automatic proxy is a convenience and\nattribution mechanism, not an isolation boundary: an unrestricted role can still\ninvoke another binary path directly. Host/operator shells keep the ordinary direct\nCLI behavior.\n\nBrain owns harness, session, model, reasoning effort, token limits, and native harness\noptions. Removed runtime flags are rejected with migration guidance rather than silently\nreinterpreted. A selection is a stable ID or an explicit `inline:{...}` mapping.\n\n## fleet.yaml\n\n```yaml\napi_version: ours.network/fleet/v2\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\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 agent: { ref: WatchdogAgent } # required: declared Agent ID, or canonical inline Agent definition\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```\n\nAn Agent is a separate bare document under `~/fleet/agents/<ID>.yaml`:\n\n```yaml\nrole: { inline: { mission: Coordinate work and delegate implementation. } }\nbrain: { inline: { harness: codex, session: acp, model: gpt-model-id } }\nidentity: Coordinator\ncwd: ${work_root}/project\noversee: [{ agent: Worker, interval: 5m }]\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`); Agent/Role/Brain documents never own it.\nThe selected Agent owns Brain, Role, permissions, isolation, and every other\nagent setting. Legacy watchdog `harness`, `model`, `session`, and\n`isolation` fields fail with migration guidance.\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\nAgent operational values override manifest operational defaults. Role and Brain\nownership never cross-merges. `${name}` substitutes entries from `vars`.\nBrain fields include `max_tokens` and `autocompact_pct`; isolation is Agent-owned.\nUse README.md for the complete isolation policy and resource-cap schema.\n\nSupervised roles connect to the operator-configured ours daemon; they do not own its\nlifecycle. Fleet strips the obsolete, presence-sensitive `OURS_AUTOSTART` variable from\nagent-session children; `ours-mcp proxy` is client-only and never starts a daemon. Start\nthe shared daemon only through an explicit operator or installer/setup flow.\n\n## Rooms and tasks\n\n`init` materializes editable `single`, `pair`, and `team` Room templates plus\ntheir exact-cased Agent, Role, and Brain presets. The command prints the packaged\npreset revision and source directory. Inspect provenance and content before use:\n\n```sh\nours-fleet config [-c FILE]\nours-fleet template list [-c FILE]\nours-fleet template show team [-c FILE]\nours-fleet task create --title \"Solo task\" --template single [-c FILE]\nours-fleet task create --title \"Reviewed change\" --template pair [-c FILE]\nours-fleet task create --title \"Phased delivery\" --template team [-c FILE]\n```\n\nAn alternate manifest `-c /path/custom.yaml` uses `/path/custom/` as its split\nroot. Repeated init only fills missing files and never adopts a newer default.\nFor explicit adoption, copy one file from init's reported packaged source beside\nthe target as `.new-default`, inspect `diff -u TARGET TARGET.new-default`, then\nreplace TARGET yourself. The exact generated six-worker legacy starter set has an\nexplicit fail-closed migration (dry-run by default):\n\n`ours-fleet migrate-agent-templates [-c FILE]`\n`ours-fleet migrate-agent-templates [-c FILE] --write`\n\nReview the dry-run moves, addition, staging path, and retained recovery-backup path\nbefore `--write`. Customized/partial known starters and unsafe trees refuse without\nmutation; unrelated custom persistent Agents remain persistent. A manifest-level template\nmay shadow a same-named file only with `override_builtin: true` and a higher\nversion; this compatibility marker is deprecated and reported as a diagnostic.\n\nRooms always use `ours-cowork`; there is no room-provider selector. Configure\nthe cowork daemon connection and room owner directly:\n\n```yaml\nrooms:\n cowork:\n config: /home/me/.ours-cowork/config.json\n owner:\n expected_cid: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n public_invite_file: /home/me/.ours-fleet/owner-room-invite.txt\n defaults:\n template: team\n attach_owner: true\n close_when_task_done: true\ntasks:\n default_room_template: team\n create_mode: start\n close_room_on_done: true\n```\n\nFleet launches each template member with a dedicated one-time Cowork invite.\nThe generated temporary-agent briefing contains the exact identity name, invite,\nCowork role, and task. The agent creates that identity itself with ours MCP\n`create_temporary_identity`, accepts the invite with `add_contact`, and starts\nwork immediately. Fleet activates the room from Cowork's authenticated seat; there\nis no briefing hash, startup ACK, or separate role-briefing readiness gate.\n\nHuman task and room results use the same compact Markdown presentation in the\nCLI and authenticated owner channel: a short heading, icon-plus-word status,\ncode-formatted identifiers, bounded summaries, and actionable recovery or error\nsteps. Untrusted prose is context-escaped and control characters are neutralized;\nMessenger-bound results are capped at 3,500 Unicode code points and 12,000 UTF-8\nbytes with structural omission notices. `--json` bypasses this presentation layer\nand retains the versioned machine schema and serialization order.\n\nEvery task belongs to a named list. The built-in `default` list always exists,\nand legacy tasks or create calls without `--list` resolve to it. Use `task lists`,\n`task list-create <name>`, `task list-rename <name> <new-name>`, and\n`task list-delete <name> [--move-to <destination>]` to manage lists. A non-empty\nlist cannot be deleted without an explicit, different destination; Fleet moves\nthe assignments and never deletes the tasks. `task move <id> --list <name>`\nchanges only organizational metadata. `task list --list <name>` filters and\n`--group-by-list --json` returns deterministic groups.\n\nList names are NFC-normalized, case-sensitive, and limited to 64 Unicode code\npoints. Leading/trailing whitespace, controls, format/path characters, normalized\nduplicates, and the reserved exact name `default` are rejected. The authenticated\nowner channel provides the matching `/task` subcommands, while authenticated web\nclients use `/api/v1/task-lists`, `/api/v1/tasks`, and\n`/api/v1/tasks/:id/list`; every adapter delegates to the same application service.\nMessenger's multiline command grammar treats surrounding whitespace on each\nvalue line as transport framing; the canonical value passed to the shared service\nis the trimmed line. CLI arguments and REST strings are passed verbatim.\n\nOlder prerelease files with the exact legacy `provider: cowork` key under\n`rooms:` still load, but the key is ignored and omitted from resolved\nconfiguration. Remove it when editing the file. Any other legacy value is an\nerror. The optional `rooms.owner.provider` setting is separate and defaults to\n`messenger-server`.\n\nFinish and Delete are distinct terminal task actions:\n\n`ours-fleet task finish <id>` moves an active or review task to `done` and\ndeletes its associated Cowork room after retiring its members. The room then\ndisappears from normal Fleet and Cowork views; its brief, messages, repository\nreferences, and attachments are not retained as an inspectable archive.\nThe prerelease configuration names `tasks.close_room_on_done` and\n`rooms.defaults.close_when_task_done` are retained for compatibility, but\n`true` now means this close-then-delete behavior.\n\n`ours-fleet room delete <id> <id>` is the canonical destructive room command.\n`room close <id> <id>` remains a deprecated alias with identical deletion\nsemantics. Older prerelease `closed` room records are deleted directly the next\ntime `room list` reconciles Fleet with Cowork.\n\n`ours-fleet task delete <id> <id>` permanently deletes a task in ANY lifecycle\nstate \u2014 backlog, provisioning, active, blocked, review, done, cancelled, failed,\nor partially settled. The exact task ID is required twice for confirmation. A\ndurable deletion intent is persisted before any side effect; the cleanup worker\nthen retires managed room members with evidence, closes and deletes an attached\nroom (tolerating already-missing remote rooms), releases the sealed launch\nsnapshot, and unlinks the task record last. While cleanup settles the task is\nhidden from normal listings and every lifecycle mutation is rejected; if\ncleanup cannot complete (for example Cowork is unreachable), the deletion stays\nin a precise recoverable state \u2014 repeat the delete command or run\n`task recover <id>` to converge after outages, crashes, or restarts. Deletion\nnever fabricates a `done` transition. A metadata-only deletion receipt\n(acceptance actor, original state, timestamps, completion) is retained under\n`deletion-receipts/` as durable audit evidence. An already-missing task is an\nidempotent no-op. The Owner-channel equivalent is `/task delete <id> <id>`; the\nmanagement API equivalent is `DELETE /api/v1/tasks/<id>?confirm=<id>` (200 when\nsettled, 202 while pending; `GET /api/v1/tasks?includeDeleting=true` exposes\ndeletion-pending tasks to operators).\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|auto|allow`: portable permission policy. `deny` remains a\n deprecated, fail-closed compatibility alias for existing fleet files.\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: `ask` maps to Codex `untrusted` and Claude `default`.\n`auto` selects Codex ACP `agent` (`on-request` + `workspace-write`) and\nClaude `acceptEdits`. `approval: allow` selects Codex ACP's fully\nnon-interactive yolo mode, reported as `agent-full-access` (`never` +\n`danger-full-access`), and Claude `bypassPermissions`. These modes genuinely\npermit 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` becomes non-interactive. Legacy `deny` keeps\nits conservative Codex `on-request` / Claude `plan` translation. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nACP carries agent-advertised session mode IDs and `session/set_mode`, but those\nIDs are agent-specific and ACP defines no portable permission-policy capability.\nFleet therefore uses the ACP primitive where an adapter exposes a matching mode\nand otherwise performs the harness translation above. The bundled Codex ACP\nadapter couples approval and sandboxing in its advertised mode IDs. Neutral\n`allow` therefore selects `agent-full-access` and widens `filesystem:\nworkspace` or `read-only` to `danger-full-access`; neutral `auto` selects\n`agent` and `workspace-write` even when the neutral filesystem value differs.\nAn explicit `harness_options.sandbox` selects its corresponding ACP preset and\nstill wins, as does an explicit native approval override. `config` and\n`doctor` report a coupled-mode mismatch as approximate. Use per-role\n`isolation:` as the outer boundary for an `allow` ACP role. The live session\nreports both its effective normalized mode and the exact native mode selected.\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`,\n`mem_palace_midsession_autosave`, `mcp_servers` and `mcp_servers_only`.\n\n`mcp_servers` declares MCP servers for the role, in `.mcp.json`'s own shape\n(a map of name to `{ command, args, env }`, or `{ type: http|sse, url,\nheaders }`). By default they are ADDED to whatever the OS user running the role\nalready has configured. The Claude Code adapter sends them in `session/new`.\n\nWhen `mcp_servers` is absent, Fleet sends ACP's protocol-required empty\n`mcpServers` array without an exclusive override, so the agent keeps its inherited\nservers. An explicitly empty configured set is different: Fleet preserves that intent\nthrough the bundled adapter's compatibility path and disables every inherited server.\n\n`mcp_servers_only: true` makes the declared set EXCLUSIVE through\n`strictMcpConfig`. It is all-or-nothing and it ignores every\nother MCP configuration: project `.mcp.json`, user settings, and **plugins**.\nThe ours connector is normally installed as a plugin, so a strict role that does\nnot re-declare it has no `send_message` and no `get_messages` \u2014 it cannot even\nreport that it has gone mute. Fleet therefore refuses a strict role whose\n`mcp_servers` does not name the connector; declare it explicitly, e.g.\n`ours: { command: ours-mcp, args: [proxy] }`.\n\nBoth options, and `plugins`, reach an ACP session through the bundled Claude ACP\nagent's `_meta` vocabulary. A role that sets `session_options.acp.command` runs\nan agent fleet did not choose and cannot be promised them, so that combination is\nrefused at validation rather than accepted and dropped. This narrows a role's\ntool surface; it does not stop the harness deferring tool schemas, which is the\nharness's own decision.\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; Codex ACP continues to work on\nthe 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`.\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. Set it to `after_tool` to preserve an active ACP tool (and any\npending permission), then steer the wake at the first tool-terminal boundary\nwithout cancellation. A hung boundary is bounded at 120 seconds and falls back\nto non-cancelling steering/queueing; adapters without authenticated tool events\nuse the same conservative fallback. Explicit human/control interrupts remain\nimmediate. The policy is content-blind because the supervisor cannot inspect\nencrypted message bodies. Message bodies are released only when the role calls\nthe 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 ours identity which fleet \u2014 never the agent \u2014\ncreates when missing and 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 comments: true\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```\n\nPermanent role identities are also reconciled before launch. Fleet creates a\nmissing role identity with local exposure and local auto-accept enabled. A\nmissing owner-channel identity uses the safer inverse policy: both are disabled.\nThe short provisioning lease is released before the agent or channel binds.\nTemporary role identities remain connector-owned because their creating lease\ndefines their cleanup lifetime.\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 of every kind \u2014 a response artifact, a proactive note, or an\nin-turn attachment \u2014 the agent calls ours `send_file` to the channel identity\nand may pair it with a reply-linked caption; fleet, not the agent, chooses the\nowner. That is the only delivery route an agent is given: a tool call either\ndelivers or reports an error, where a file written to disk does neither.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/comments [status|on|off]`, `/interrupt`, `/clear`,\n`/compact`, `/model <model-id>`, `/restart`, `/force-restart`, `/ls`,\n`/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\nWhile a request runs, the agent's live ACP commentary is relayed as messages\nprefixed with the single stable label `\uD83D\uDFE1 Live update:`, so an owner can see\nexactly which messages the setting controls. `owner_channel.comments`\n(default `true`, so existing channels keep their current behavior) is the\nRESTART BASELINE; `/comments on|off` changes only the running session and is\ndeliberately not persisted, so a restart always returns to the checked-in\nconfiguration. `/comments status` reports the live value, the baseline, and\nwhether the backend emits live comments at all. Suppressing live comments never\nsuppresses receipts, progress notices, or the final answer.\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, or over-size 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,\nwhile MIME values, extensions, file categories, and declared-versus-detected mismatches\nremain report-only metadata. Symlinks or non-regular paths 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\nThe legacy `attachments.allowed_mime` key is accepted and ignored so existing\nconfigurations keep loading; it is omitted from resolved configuration and cannot\naffect admission.\n\nVoice prompts include a bounded transcript only when typed daemon metadata reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\ninput for direct review. Run `ours config show --json` and inspect `sttConfigured` without\nrevealing provider credentials.\nA mode-0600 message claim journal stores only wire ID, persistent-history\nsequence, and claim time. Fleet journals the exact body-free oldest-first slice\nbefore calling `getMessages` with that slice length, rejects a returned set\nmismatch, and loads a crash-recovered body only through `getHistoryItem`.\nThe attachment crash journal contains only authenticated CID and wire routing\ndata; it never stores captions, filenames, paths, transcript text, or bytes.\nJournaled read files resume through `getFileInfo` and `fetchFile`. A claimed\nagent caption is loaded from history and rejoined 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\nthe structured agent-session interface backed by ACP so correlated final replies\nretain their delivery 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, 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\nWORKLOG rotation is enabled by default with\n`worklog: { max_kb: 1024, keep_tail_kb: 256, max_archives: 12 }`. Maps may\noverride individual values; `worklog: false` on a role or in defaults opts out.\nFleet rotates only at that role's launch/resume lifecycle boundary. Concurrent\nchanges defer rotation. The active file keeps a bounded UTF-8 tail and advances\nto a line boundary when a complete line fits. If one logical line alone exceeds\nthe budget, its newest suffix remains and the rotation manifest records the\nmid-line start and omitted byte count. The complete prior inode receives a\ncollision-safe UTC archive name, and\n`.worklog-rotation.json` records restart provenance. `max_archives` bounds\nrecent archives beside WORKLOG.md; older complete archives move to\n`WORKLOG.archives/` without deletion. All archives share the role's sensitive\nstate boundary. Fleet refuses a symlinked/non-regular live log or a symlinked\ncold-archive boundary before replacing the live path and best-effort removes a\nduplicate publication left by a detected failure while the original inode is\nstill available. The manifest records SHA-256 digests for the archive and live\nbytes observed when it is written. These checks address ordinary path hazards,\nnot intentional path mutation by a malicious concurrent process with the same\nUnix authority; that is outside the threat model and requires OS-level isolation.\n\nACP tool diffs are bounded before entering web conversation events. Existing\nsmall before/after diffs are unchanged. Oversized whole-file snapshots are\nreduced to the actual changed region plus path, operation, original byte counts,\ndigest, and omission metadata. Each retained side is a newest-content UTF-8 tail\nof at most 64 KiB, advanced to a line boundary when a complete line fits. An\noverlong single line keeps its newest suffix and explicitly records a mid-line\nstart. Paths retain at most a 4 KiB suffix with byte count, digest, and omitted\nprefix metadata; the complete normalized update is capped at 320 KiB. A large\nappend therefore retains current appended content, not the historical prefix.\nThe live web-console transcript includes only the current runner generation and\nexcludes adapter session/load replay. Replayed events remain durable with\nagent_replay provenance for diagnosis and recovery.\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 through the\nstructured ACP session path:\n\n- harness: `claude-code` or `codex`\n- session: `acp` (default and only supported value)\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]\nours-fleet version [--json] # build identity, capabilities, every install on PATH\n```\n\nConfiguration v2 is `~/fleet.yaml` plus typed bare documents under the exact\nstem directories `~/fleet/agents`, `~/fleet/agent_templates`, `~/fleet/roles`, `~/fleet/brains`, and\n`~/fleet/room_templates`.\nThe manifest owns fleet-wide operational defaults and automation; each Agent\nselects one inline/ref Role and Brain and carries its operational fields.\nAgent Templates under `~/fleet/agent_templates` are inert reusable launch definitions;\nonly explicit files under `~/fleet/agents` are persistent lifecycle instances.\nRoom members use `agent_template` and receive immutable content-addressed snapshots.\nLegacy top-level `roles:` and `fleet.d` are rejected. Validate the complete\ntrusted source set with `config` and `doctor` before starting or restarting.\n\nPermanent `spawn` writes `~/fleet/agents/Name.yaml`. The web console edits an\nexplicit `{manifest, agents, agent_templates}` model while Role/Brain presets remain read-only.\nIts aggregate revision includes every Agent/Role/Brain/Room-template source, previews a\nredacted per-document diff in an exact-stem private staging tree, and saves under\none root lock with a private multi-file backup and full rollback. A no-op is\nbyte-identical and creates no backup.\n\n## Build identity and install provenance\n\n`--version` prints a semver and nothing else, and a semver does NOT identify an\nartifact. Version bumps land in a release commit of their own, so every build cut\nbetween two releases carries the PREVIOUS version while already containing new\nbehaviour. One host ran two installs that both reported 0.16.0 \u2014 same version,\ndifferent build. One accepted `monitor.interrupt: after_tool`, the other\nrejected it as invalid. Their\n`dist/cli.js` were byte-identical \u2014 the divergence was in other modules.\n\nEvery build therefore stamps `dist/build-info.json` with a build id (first 12 hex\nof a sha256 over the rest of `dist/`), the commit it was cut from, and the\ncapability tokens the shipped code declares \u2014 for example\n`monitor.interrupt.after_tool`. Ask any executable what it is:\n\n```sh\nours-fleet version # ours-fleet 0.17.0+9f1c2a3b4d5e, capabilities, PATH installs\nours-fleet version --json # the same as machine-readable JSON, no environment values\n```\n\nRead a capability, never a version number, to decide whether a setting is\nsupported. When a build rejects a value it knows the name of, it says which\ncapability is missing and which build rejected it, because another install on the\nsame host may accept the identical file. `config` prints the build that resolved\nthe plan; `status <Name>` says so when the build reporting on a role is not the\none that created it (roles record their creating build in `creation.json`).\n\n`ours-fleet doctor` runs an `install` check that lists every `ours-fleet` on\nPATH plus the one executing, and FAILS when two installs share a semver but are\ndifferent builds, or when the running artifact is a DIFFERENT artifact from the\none PATH resolves to. A second prefix holding identical content is not a skew\nand is not reported. A PATH entry the shell would not execute \u2014 a directory, or\na file without its execute bit \u2014 is not counted as an install at all.\nInstalls built before this stamp existed report `+unknown`; they are compared by\nhashing their `dist/` instead, so two pre-provenance installs are still told\napart. To fix a flagged host, remove or update the stale install \u2014 do not rely on\nPATH order.\n\n## Lifecycle and console commands\n\n```sh\nours-fleet init [-c FILE] # seed missing presets; never replace an existing file\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 rm Name\nours-fleet watchdog-report <name> [run-id] [--list] [--json]\nours-fleet watchdog-run <name>\n```\n\n`peek`, `attach`, and text `send` use the structured agent session.\nAttachment also accepts `/permit <permission-id> <option-id>`, `/interrupt`,\nand `/detach`.\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 | --name Name] \\\n --brain BRAIN_ID --role ROLE_ID \\\n --cwd /absolute/path --identity Identity --coordinator Coordinator \\\n --approval ask|auto|allow \\\n --filesystem read-only|workspace|unrestricted \\\n --unattended deny|wait --isolation-file /path/isolation.yaml\n```\n\nPermanent spawn writes `~/fleet/agents/Name.yaml` and starts a supervised role.\n`--temp` writes active state under `~/.ours-fleet/tmp` and starts an independent\ntransient supervisor (a collected systemd unit or submitted launchd job). It is\nnot enabled across reboot and does not die when the role that spawned it restarts.\nBrain definitions own the ACP session backend. When a temporary role's bound identity\ncloses or its session ends, the supervisor, monitor and live roster entry retire\ntogether; state moves intact to `~/.ours-fleet/recovery/temporary` with a\ntermination record. Failed launches use the same archive rather than deleting\ntheir briefing, provenance, logs or partial supervisor metadata.\n\nNamed `down` and `rm` commands can target an exact state-backed temporary role\neven though it is absent from merged fleet YAML. The recorded transient unit/job\nis authoritative. Missing/incomplete ownership metadata is reconciled only from\nan exact `_run-temp <role>` process-table match: one match may be adopted, zero\nsettles as stopped, and ambiguity or an unreadable table fails closed. Launching\nrecords receive a bounded grace so a not-yet-registered transient unit cannot be\nmistaken for a stopped one. Stale recorded supervisors are reclaimed in bounded\nbatches by moving their state to the same recovery archive, never by blind deletion.\n\nEvery temporary role creates a new session-owned identity by calling ours MCP\n`create_temporary_identity` with its exact assigned name. It never binds a\npre-existing identity and never falls back to permanent `create_identity`.\nFleet does not inspect, preserve, or provision an ours identity for temporary\nspawn; creation belongs exclusively to the launched temporary agent session.\nCollisions, missing tool support, and creation errors stop safely without\nforce-adopting or deleting identity state. Permanent roles\nare provisioned by fleet before launch and never delegate normal identity\ncreation to the harness.\n\nThe temporary supervisor treats its first positive identity observation as the\nlifecycle readiness gate: a cold harness may take as long as needed to read its\nbriefing and bind, without a fixed first-bind retirement timer. After readiness,\nonly sustained authoritative absence closes the role. Unreachable, malformed, or\nvalid-but-empty daemon indexes are ambiguous and reset closure debounce rather\nthan becoming cleanup authority.\n\nInside a managed ACP role, public `ours-fleet` commands cross an authenticated\nsupervisor attribution boundary before Commander parsing. The original CLI remains\nthe executor inside the role's existing OS sandbox, and ordinary CLI validation is\nthe source of truth. Hidden worker entry points remain internal; public lifecycle and\noperator commands are not restricted by the proxy.\n\nCommand invocation, raw argv, read-only work, validation failures, and generic\noutcomes are never forwarded to the Owner-visible channel. Fleet announces only\nconfirmed Agent, Task, and Room lifecycle changes. Local diagnostics retain\nstructurally redacted command metadata. Lifecycle delivery uncertainty is logged,\nnever recursively announced, and never reruns or blindly retries an effect.\nRoom participant summaries describe creation and activation. Fleet has no public\npost-create Room membership mutation, so it does not claim a separate membership event.\n\nOmitted Brain and Role selections, working directory, coordinator, neutral permissions,\nand fleet monitor policy inherit from the calling Agent. Explicit options always win.\nIdentity, mission/profile text, environment, owner routing, auth proxy, room startup,\nisolation, worklog, and sensitive inline Brain values never inherit implicitly.\nThis automatic proxy is a convenience and\nattribution mechanism, not an isolation boundary: an unrestricted role can still\ninvoke another binary path directly. Host/operator shells keep the ordinary direct\nCLI behavior.\n\nBrain owns harness, session, model, reasoning effort, token limits, and native harness\noptions. Removed runtime flags are rejected with migration guidance rather than silently\nreinterpreted. A selection is a stable ID or an explicit `inline:{...}` mapping.\n\n## fleet.yaml\n\n```yaml\napi_version: ours.network/fleet/v2\nvars:\n work_root: /home/me/work\nstart_stagger_ms: 0\ndefaults:\n permissions:\n approval: ask\n filesystem: workspace\n unattended: deny\n monitor:\n mode: fleet # fleet (default) | native\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 agent: { ref: WatchdogAgent } # required: declared Agent ID, or canonical inline Agent definition\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```\n\nAn Agent is a separate bare document under `~/fleet/agents/<ID>.yaml`:\n\n```yaml\nrole: { inline: { mission: Coordinate work and delegate implementation. } }\nbrain: { inline: { harness: codex, session: acp, model: gpt-model-id } }\nidentity: Coordinator\ncwd: ${work_root}/project\noversee: [{ agent: Worker, interval: 5m }]\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`); Agent/Role/Brain documents never own it.\nThe selected Agent owns Brain, Role, permissions, isolation, and every other\nagent setting. Legacy watchdog `harness`, `model`, `session`, and\n`isolation` fields fail with migration guidance.\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\nAgent operational values override manifest operational defaults. Role and Brain\nownership never cross-merges. `${name}` substitutes entries from `vars`.\nBrain fields include `max_tokens` and `autocompact_pct`; isolation is Agent-owned.\nUse README.md for the complete isolation policy and resource-cap schema.\n\nSupervised roles connect to the operator-configured ours daemon; they do not own its\nlifecycle. Fleet strips the obsolete, presence-sensitive `OURS_AUTOSTART` variable from\nagent-session children; `ours-mcp proxy` is client-only and never starts a daemon. Start\nthe shared daemon only through an explicit operator or installer/setup flow.\n\n## Rooms and tasks\n\n`init` materializes editable `single`, `pair`, and `team` Room templates plus\ntheir exact-cased Agent, Role, and Brain presets. The command prints the packaged\npreset revision and source directory. Inspect provenance and content before use:\n\n```sh\nours-fleet config [-c FILE]\nours-fleet template list [-c FILE]\nours-fleet template show team [-c FILE]\nours-fleet task create --title \"Solo task\" --template single [-c FILE]\nours-fleet task create --title \"Reviewed change\" --template pair [-c FILE]\nours-fleet task create --title \"Phased delivery\" --template team [-c FILE]\n```\n\nAn alternate manifest `-c /path/custom.yaml` uses `/path/custom/` as its split\nroot. Repeated init only fills missing files and never adopts a newer default.\nFor explicit adoption, copy one file from init's reported packaged source beside\nthe target as `.new-default`, inspect `diff -u TARGET TARGET.new-default`, then\nreplace TARGET yourself. The exact generated six-worker legacy starter set has an\nexplicit fail-closed migration (dry-run by default):\n\n`ours-fleet migrate-agent-templates [-c FILE]`\n`ours-fleet migrate-agent-templates [-c FILE] --write`\n\nReview the dry-run moves, addition, staging path, and retained recovery-backup path\nbefore `--write`. Customized/partial known starters and unsafe trees refuse without\nmutation; unrelated custom persistent Agents remain persistent. A manifest-level template\nmay shadow a same-named file only with `override_builtin: true` and a higher\nversion; this compatibility marker is deprecated and reported as a diagnostic.\n\nRooms always use `ours-cowork`; there is no room-provider selector. Configure\nthe cowork daemon connection and room owner directly:\n\n```yaml\nrooms:\n cowork:\n config: /home/me/.ours-cowork/config.json\n owner:\n expected_cid: 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef\n public_invite_file: /home/me/.ours-fleet/owner-room-invite.txt\n defaults:\n template: team\n attach_owner: true\n close_when_task_done: true\ntasks:\n default_room_template: team\n create_mode: start\n close_room_on_done: true\n```\n\nFleet launches each template member with a dedicated one-time Cowork invite.\nThe generated temporary-agent briefing contains the exact identity name, invite,\nCowork role, and task. The agent creates that identity itself with ours MCP\n`create_temporary_identity`, accepts the invite with `add_contact`, and starts\nwork immediately. Fleet activates the room from Cowork's authenticated seat; there\nis no briefing hash, startup ACK, or separate role-briefing readiness gate.\n\nSet `room.anonymous: true` on a room template, or pass `--anonymous` to\n`task create`, `task start`, `task work`, or `room create`, to create an\nanonymous Cowork room. `--no-anonymous` explicitly overrides an anonymous\ntemplate. Fleet records the resolved value before room creation so retries keep\nthe same choice. Temporary members of an anonymous room are instructed to call\n`create_temporary_identity` with `expose_local=false`.\n\nHuman task and room results use the same compact Markdown presentation in the\nCLI and authenticated owner channel: a short heading, icon-plus-word status,\ncode-formatted identifiers, bounded summaries, and actionable recovery or error\nsteps. Untrusted prose is context-escaped and control characters are neutralized;\nMessenger-bound results are capped at 3,500 Unicode code points and 12,000 UTF-8\nbytes with structural omission notices. `--json` bypasses this presentation layer\nand retains the versioned machine schema and serialization order.\n\nEvery task belongs to a named list. The built-in `default` list always exists,\nand legacy tasks or create calls without `--list` resolve to it. Use `task lists`,\n`task list-create <name>`, `task list-rename <name> <new-name>`, and\n`task list-delete <name> [--move-to <destination>]` to manage lists. A non-empty\nlist cannot be deleted without an explicit, different destination; Fleet moves\nthe assignments and never deletes the tasks. `task move <id> --list <name>`\nchanges only organizational metadata. `task list --list <name>` filters and\n`--group-by-list --json` returns deterministic groups.\n\nList names are NFC-normalized, case-sensitive, and limited to 64 Unicode code\npoints. Leading/trailing whitespace, controls, format/path characters, normalized\nduplicates, and the reserved exact name `default` are rejected. The authenticated\nowner channel provides the matching `/task` subcommands, while authenticated web\nclients use `/api/v1/task-lists`, `/api/v1/tasks`, and\n`/api/v1/tasks/:id/list`; every adapter delegates to the same application service.\nMessenger's multiline command grammar treats surrounding whitespace on each\nvalue line as transport framing; the canonical value passed to the shared service\nis the trimmed line. CLI arguments and REST strings are passed verbatim.\n\nOlder prerelease files with the exact legacy `provider: cowork` key under\n`rooms:` still load, but the key is ignored and omitted from resolved\nconfiguration. Remove it when editing the file. Any other legacy value is an\nerror. The optional `rooms.owner.provider` setting is separate and defaults to\n`messenger-server`.\n\nFinish and Delete are distinct terminal task actions:\n\n`ours-fleet task finish <id>` moves an active or review task to `done` and\ndeletes its associated Cowork room after retiring its members. The room then\ndisappears from normal Fleet and Cowork views; its brief, messages, repository\nreferences, and attachments are not retained as an inspectable archive.\nThe prerelease configuration names `tasks.close_room_on_done` and\n`rooms.defaults.close_when_task_done` are retained for compatibility, but\n`true` now means this close-then-delete behavior.\n\n`ours-fleet room delete <id> <id>` is the canonical destructive room command.\n`room close <id> <id>` remains a deprecated alias with identical deletion\nsemantics. Older prerelease `closed` room records are deleted directly the next\ntime `room list` reconciles Fleet with Cowork.\n\n`ours-fleet task delete <id> <id>` permanently deletes a task in ANY lifecycle\nstate \u2014 backlog, provisioning, active, blocked, review, done, cancelled, failed,\nor partially settled. The exact task ID is required twice for confirmation. A\ndurable deletion intent is persisted before any side effect; the cleanup worker\nthen retires managed room members with evidence, closes and deletes an attached\nroom (tolerating already-missing remote rooms), releases the sealed launch\nsnapshot, and unlinks the task record last. While cleanup settles the task is\nhidden from normal listings and every lifecycle mutation is rejected; if\ncleanup cannot complete (for example Cowork is unreachable), the deletion stays\nin a precise recoverable state \u2014 repeat the delete command or run\n`task recover <id>` to converge after outages, crashes, or restarts. Deletion\nnever fabricates a `done` transition. A metadata-only deletion receipt\n(acceptance actor, original state, timestamps, completion) is retained under\n`deletion-receipts/` as durable audit evidence. An already-missing task is an\nidempotent no-op. The Owner-channel equivalent is `/task delete <id> <id>`; the\nmanagement API equivalent is `DELETE /api/v1/tasks/<id>?confirm=<id>` (200 when\nsettled, 202 while pending; `GET /api/v1/tasks?includeDeleting=true` exposes\ndeletion-pending tasks to operators).\n\n## Permissions\n\nPrefer the harness-neutral `permissions` block:\n\n- `approval: ask|auto|allow`: portable permission policy. `deny` remains a\n deprecated, fail-closed compatibility alias for existing fleet files.\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: `ask` maps to Codex `untrusted` and Claude `default`.\n`auto` selects Codex ACP `agent` (`on-request` + `workspace-write`) and\nClaude `acceptEdits`. `approval: allow` selects Codex ACP's fully\nnon-interactive yolo mode, reported as `agent-full-access` (`never` +\n`danger-full-access`), and Claude `bypassPermissions`. These modes genuinely\npermit 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` becomes non-interactive. Legacy `deny` keeps\nits conservative Codex `on-request` / Claude `plan` translation. `allow` is therefore a real grant and\nrequires explicit authorization; per-role `isolation:` remains the outer\nboundary that a permission mode cannot cross.\n\nACP carries agent-advertised session mode IDs and `session/set_mode`, but those\nIDs are agent-specific and ACP defines no portable permission-policy capability.\nFleet therefore uses the ACP primitive where an adapter exposes a matching mode\nand otherwise performs the harness translation above. The bundled Codex ACP\nadapter couples approval and sandboxing in its advertised mode IDs. Neutral\n`allow` therefore selects `agent-full-access` and widens `filesystem:\nworkspace` or `read-only` to `danger-full-access`; neutral `auto` selects\n`agent` and `workspace-write` even when the neutral filesystem value differs.\nAn explicit `harness_options.sandbox` selects its corresponding ACP preset and\nstill wins, as does an explicit native approval override. `config` and\n`doctor` report a coupled-mode mismatch as approximate. Use per-role\n`isolation:` as the outer boundary for an `allow` ACP role. The live session\nreports both its effective normalized mode and the exact native mode selected.\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`,\n`mem_palace_midsession_autosave`, `mcp_servers` and `mcp_servers_only`.\n\n`mcp_servers` declares MCP servers for the role, in `.mcp.json`'s own shape\n(a map of name to `{ command, args, env }`, or `{ type: http|sse, url,\nheaders }`). By default they are ADDED to whatever the OS user running the role\nalready has configured. The Claude Code adapter sends them in `session/new`.\n\nWhen `mcp_servers` is absent, Fleet sends ACP's protocol-required empty\n`mcpServers` array without an exclusive override, so the agent keeps its inherited\nservers. An explicitly empty configured set is different: Fleet preserves that intent\nthrough the bundled adapter's compatibility path and disables every inherited server.\n\n`mcp_servers_only: true` makes the declared set EXCLUSIVE through\n`strictMcpConfig`. It is all-or-nothing and it ignores every\nother MCP configuration: project `.mcp.json`, user settings, and **plugins**.\nThe ours connector is normally installed as a plugin, so a strict role that does\nnot re-declare it has no `send_message` and no `get_messages` \u2014 it cannot even\nreport that it has gone mute. Fleet therefore refuses a strict role whose\n`mcp_servers` does not name the connector; declare it explicitly, e.g.\n`ours: { command: ours-mcp, args: [proxy] }`.\n\nBoth options, and `plugins`, reach an ACP session through the bundled Claude ACP\nagent's `_meta` vocabulary. A role that sets `session_options.acp.command` runs\nan agent fleet did not choose and cannot be promised them, so that combination is\nrefused at validation rather than accepted and dropped. This narrows a role's\ntool surface; it does not stop the harness deferring tool schemas, which is the\nharness's own decision.\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; Codex ACP continues to work on\nthe 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`.\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. Set it to `after_tool` to preserve an active ACP tool (and any\npending permission), then steer the wake at the first tool-terminal boundary\nwithout cancellation. A hung boundary is bounded at 120 seconds and falls back\nto non-cancelling steering/queueing; adapters without authenticated tool events\nuse the same conservative fallback. Explicit human/control interrupts remain\nimmediate. The policy is content-blind because the supervisor cannot inspect\nencrypted message bodies. Message bodies are released only when the role calls\nthe 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 ours identity which fleet \u2014 never the agent \u2014\ncreates when missing and 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 comments: true\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```\n\nPermanent role identities are also reconciled before launch. Fleet creates a\nmissing role identity with local exposure and local auto-accept enabled. A\nmissing owner-channel identity uses the safer inverse policy: both are disabled.\nThe short provisioning lease is released before the agent or channel binds.\nTemporary role identities remain connector-owned because their creating lease\ndefines their cleanup lifetime.\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 of every kind \u2014 a response artifact, a proactive note, or an\nin-turn attachment \u2014 the agent calls ours `send_file` to the channel identity\nand may pair it with a reply-linked caption; fleet, not the agent, chooses the\nowner. That is the only delivery route an agent is given: a tool call either\ndelivers or reports an error, where a file written to disk does neither.\nOwner messages whose trimmed text starts with `/` are deterministic\nsupervisor commands and never enter the model: `/help` (alias `/commands`),\n`/status`, `/comments [status|on|off]`, `/interrupt`, `/clear`,\n`/compact`, `/model <model-id>`, `/restart`, `/force-restart`, `/ls`,\n`/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\nWhile a request runs, the agent's live ACP commentary is relayed as messages\nprefixed with the single stable label `\uD83D\uDFE1 Live update:`, so an owner can see\nexactly which messages the setting controls. `owner_channel.comments`\n(default `true`, so existing channels keep their current behavior) is the\nRESTART BASELINE; `/comments on|off` changes only the running session and is\ndeliberately not persisted, so a restart always returns to the checked-in\nconfiguration. `/comments status` reports the live value, the baseline, and\nwhether the backend emits live comments at all. Suppressing live comments never\nsuppresses receipts, progress notices, or the final answer.\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, or over-size 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,\nwhile MIME values, extensions, file categories, and declared-versus-detected mismatches\nremain report-only metadata. Symlinks or non-regular paths 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\nThe legacy `attachments.allowed_mime` key is accepted and ignored so existing\nconfigurations keep loading; it is omitted from resolved configuration and cannot\naffect admission.\n\nVoice prompts include a bounded transcript only when typed daemon metadata reports success.\nFailure or unavailability is explicit and preserves the private audio path as the\ninput for direct review. Run `ours config show --json` and inspect `sttConfigured` without\nrevealing provider credentials.\nA mode-0600 message claim journal stores only wire ID, persistent-history\nsequence, and claim time. Fleet journals the exact body-free oldest-first slice\nbefore calling `getMessages` with that slice length, rejects a returned set\nmismatch, and loads a crash-recovered body only through `getHistoryItem`.\nThe attachment crash journal contains only authenticated CID and wire routing\ndata; it never stores captions, filenames, paths, transcript text, or bytes.\nJournaled read files resume through `getFileInfo` and `fetchFile`. A claimed\nagent caption is loaded from history and rejoined 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\nthe structured agent-session interface backed by ACP so correlated final replies\nretain their delivery 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, 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\nWORKLOG rotation is enabled by default with\n`worklog: { max_kb: 1024, keep_tail_kb: 256, max_archives: 12 }`. Maps may\noverride individual values; `worklog: false` on a role or in defaults opts out.\nFleet rotates only at that role's launch/resume lifecycle boundary. Concurrent\nchanges defer rotation. The active file keeps a bounded UTF-8 tail and advances\nto a line boundary when a complete line fits. If one logical line alone exceeds\nthe budget, its newest suffix remains and the rotation manifest records the\nmid-line start and omitted byte count. The complete prior inode receives a\ncollision-safe UTC archive name, and\n`.worklog-rotation.json` records restart provenance. `max_archives` bounds\nrecent archives beside WORKLOG.md; older complete archives move to\n`WORKLOG.archives/` without deletion. All archives share the role's sensitive\nstate boundary. Fleet refuses a symlinked/non-regular live log or a symlinked\ncold-archive boundary before replacing the live path and best-effort removes a\nduplicate publication left by a detected failure while the original inode is\nstill available. The manifest records SHA-256 digests for the archive and live\nbytes observed when it is written. These checks address ordinary path hazards,\nnot intentional path mutation by a malicious concurrent process with the same\nUnix authority; that is outside the threat model and requires OS-level isolation.\n\nACP tool diffs are bounded before entering web conversation events. Existing\nsmall before/after diffs are unchanged. Oversized whole-file snapshots are\nreduced to the actual changed region plus path, operation, original byte counts,\ndigest, and omission metadata. Each retained side is a newest-content UTF-8 tail\nof at most 64 KiB, advanced to a line boundary when a complete line fits. An\noverlong single line keeps its newest suffix and explicitly records a mid-line\nstart. Paths retain at most a 4 KiB suffix with byte count, digest, and omitted\nprefix metadata; the complete normalized update is capped at 320 KiB. A large\nappend therefore retains current appended content, not the historical prefix.\nThe live web-console transcript includes only the current runner generation and\nexcludes adapter session/load replay. Replayed events remain durable with\nagent_replay provenance for diagnosis and recovery.\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.
10
10
  *
package/dist/docs.js CHANGED
@@ -342,6 +342,13 @@ Cowork role, and task. The agent creates that identity itself with ours MCP
342
342
  work immediately. Fleet activates the room from Cowork's authenticated seat; there
343
343
  is no briefing hash, startup ACK, or separate role-briefing readiness gate.
344
344
 
345
+ Set \`room.anonymous: true\` on a room template, or pass \`--anonymous\` to
346
+ \`task create\`, \`task start\`, \`task work\`, or \`room create\`, to create an
347
+ anonymous Cowork room. \`--no-anonymous\` explicitly overrides an anonymous
348
+ template. Fleet records the resolved value before room creation so retries keep
349
+ the same choice. Temporary members of an anonymous room are instructed to call
350
+ \`create_temporary_identity\` with \`expose_local=false\`.
351
+
345
352
  Human task and room results use the same compact Markdown presentation in the
346
353
  CLI and authenticated owner channel: a short heading, icon-plus-word status,
347
354
  code-formatted identifiers, bounded summaries, and actionable recovery or error
@@ -29,6 +29,13 @@ function commandArgv(command) {
29
29
  root = root.parent;
30
30
  return root.rawArgs ?? process.argv.slice(2);
31
31
  }
32
+ function cliAnonymousOverride(argv) {
33
+ const enabled = argv.includes('--anonymous');
34
+ const disabled = argv.includes('--no-anonymous');
35
+ if (enabled && disabled)
36
+ throw new Error('--anonymous and --no-anonymous are mutually exclusive');
37
+ return enabled ? true : disabled ? false : undefined;
38
+ }
32
39
  import { getTask, getDeletingTask, activateTask, TaskStateError, } from './task-state.js';
33
40
  import { createRoomRecord, getRoomRecord, advanceSaga, setOwnerSeat, setSagaError, activateRoom, RoomStateError, } from './room-state.js';
34
41
  import { createCoworkAdapter, CoworkProtocolError } from './cowork-adapter.js';
@@ -690,6 +697,8 @@ export function registerTaskCommands(parent, cOpt) {
690
697
  .option('--brief-file <path>', 'task brief from file')
691
698
  .option('--backlog', 'create in backlog (do not start immediately)')
692
699
  .option('--no-room', 'create task without a room')
700
+ .option('--anonymous', 'create an anonymous Cowork room')
701
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
693
702
  .option('--idempotency-key <key>', 'idempotency key')
694
703
  .option('--list <name>', 'task list (default: default)')
695
704
  .option('--members-file <path>', 'typed YAML member overrides')
@@ -711,6 +720,7 @@ export function registerTaskCommands(parent, cOpt) {
711
720
  actor: { kind: 'local_control', surface: 'cli' }, title: opts.title,
712
721
  brief: opts.brief, briefFile: opts.briefFile, template: opts.template,
713
722
  backlog: opts.backlog, noRoom: opts.room === false,
723
+ anonymous: cliAnonymousOverride(commandArgv(command)),
714
724
  idempotencyKey: opts.idempotencyKey, origin: { type: 'cli' },
715
725
  list: opts.list,
716
726
  members,
@@ -903,6 +913,8 @@ export function registerTaskCommands(parent, cOpt) {
903
913
  cOpt(taskCmd.command('start <id>'))
904
914
  .description('idempotently select a plan, provision, and start a task')
905
915
  .option('--template <name>', 'room template')
916
+ .option('--anonymous', 'create an anonymous Cowork room')
917
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
906
918
  .option('--members-file <path>', 'typed YAML member overrides')
907
919
  .option('--member <slot>', 'begin a typed member override block')
908
920
  .option('--agent-template <id>', 'Agent Template for current member')
@@ -920,6 +932,7 @@ export function registerTaskCommands(parent, cOpt) {
920
932
  const t = await taskRoomService(opts.configuration).startTask({
921
933
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id,
922
934
  template: opts.template, members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
935
+ anonymous: cliAnonymousOverride(commandArgv(command)),
923
936
  });
924
937
  if (opts.json) {
925
938
  console.log(JSON.stringify({ schema_version: 1, task: t }, null, 2));
@@ -1310,6 +1323,8 @@ export function registerTaskCommands(parent, cOpt) {
1310
1323
  cOpt(taskCmd.command('work <id>'))
1311
1324
  .description('deprecated alias for task start')
1312
1325
  .option('--template <name>', 'room template')
1326
+ .option('--anonymous', 'create an anonymous Cowork room')
1327
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
1313
1328
  .option('--members-file <path>', 'typed YAML member overrides')
1314
1329
  .option('--member <slot>', 'begin a typed member override block')
1315
1330
  .option('--agent-template <id>', 'Agent Template for current member')
@@ -1329,6 +1344,7 @@ export function registerTaskCommands(parent, cOpt) {
1329
1344
  const result = await taskRoomService(opts.configuration).ensureTaskWork({
1330
1345
  actor: { kind: 'local_control', surface: 'cli' }, taskId: id, template: opts.template,
1331
1346
  members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
1347
+ anonymous: cliAnonymousOverride(commandArgv(command)),
1332
1348
  });
1333
1349
  const t = result.task;
1334
1350
  auditTask('work', t, previous);
@@ -1414,6 +1430,8 @@ export function registerRoomCommands(parent, cOpt) {
1414
1430
  .description('create a standalone room')
1415
1431
  .requiredOption('--name <name>', 'room name')
1416
1432
  .option('--template <name>', 'room template')
1433
+ .option('--anonymous', 'create an anonymous Cowork room')
1434
+ .option('--no-anonymous', 'explicitly disable template anonymous mode')
1417
1435
  .option('--goal <text>', 'room goal')
1418
1436
  .option('--brief <text>', 'room briefing')
1419
1437
  .option('--brief-file <path>', 'room briefing from file')
@@ -1435,6 +1453,7 @@ export function registerRoomCommands(parent, cOpt) {
1435
1453
  actor: { kind: 'local_control', surface: 'cli' }, name: opts.name,
1436
1454
  template: opts.template, goal: opts.goal, brief: opts.brief, briefFile: opts.briefFile,
1437
1455
  members: cliMemberOverrides(opts.membersFile, commandArgv(command)),
1456
+ anonymous: cliAnonymousOverride(commandArgv(command)),
1438
1457
  });
1439
1458
  if (opts.json) {
1440
1459
  console.log(JSON.stringify({ schema_version: 1, room: record }, null, 2));
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { createHash } from 'node:crypto';
3
- import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, } from './types.js';
3
+ import { ROOMS_KEYS as RK, ROOMS_OWNER_KEYS as ROK, ROOMS_COWORK_KEYS as RCK, ROOMS_DEFAULTS_KEYS as RDK, TASKS_KEYS as TK, TEMPLATE_KEYS as TPK, TEMPLATE_MEMBER_KEYS as TMK, TEMPLATE_ROOM_KEYS as TRK, } from './types.js';
4
4
  const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
5
5
  const CID_RE = /^[0-9a-fA-F]{64}$/;
6
6
  export class RoomsTasksConfigError extends Error {
@@ -153,6 +153,10 @@ export function validateRoomTemplatesConfig(raw, path) {
153
153
  if (tplRaw.room !== undefined) {
154
154
  if (!isPlainObject(tplRaw.room))
155
155
  throw new RoomsTasksConfigError(path, `room_templates.${name}.room: must be a mapping`);
156
+ rejectUnknown(tplRaw.room, TRK, path, `room_templates.${name}.room`);
157
+ for (const key of TRK)
158
+ if (tplRaw.room[key] !== undefined && typeof tplRaw.room[key] !== 'boolean')
159
+ throw new RoomsTasksConfigError(path, `room_templates.${name}.room.${key}: must be a boolean`);
156
160
  room = {
157
161
  quiet_membership: tplRaw.room.quiet_membership,
158
162
  anonymous: tplRaw.room.anonymous,
@@ -33,6 +33,7 @@ export interface CoworkRoomInfo {
33
33
  identity_cid: string;
34
34
  room_name: string;
35
35
  state: 'provisioning' | 'active' | 'closing' | 'closed';
36
+ anonymous: boolean;
36
37
  seats: CoworkSeatInfo[];
37
38
  goal?: string;
38
39
  briefing?: string;
@@ -47,6 +47,11 @@ function roomState(value, operation) {
47
47
  throw new CoworkProtocolError(operation, 'room state is invalid');
48
48
  return value;
49
49
  }
50
+ function boolean(value, operation, label) {
51
+ if (typeof value !== 'boolean')
52
+ throw new CoworkProtocolError(operation, `${label} must be a boolean`);
53
+ return value;
54
+ }
50
55
  function seatState(value, operation) {
51
56
  if (value !== 'pending' && value !== 'active' && value !== 'removed')
52
57
  throw new CoworkProtocolError(operation, 'seat state is invalid');
@@ -86,6 +91,7 @@ function projectRoom(value, operation) {
86
91
  identity_cid: text(room.identity_cid, operation, 'room.identity_cid'),
87
92
  room_name: string(room.room_name, operation, 'room.room_name'),
88
93
  state: roomState(room.state, operation),
94
+ anonymous: room.anonymous === undefined ? false : boolean(room.anonymous, operation, 'room.anonymous'),
89
95
  seats: room.seats.map((seat) => projectSeat(seat, operation)),
90
96
  role_briefings: projectedBriefings,
91
97
  ...(typeof mission?.goal === 'string' ? { goal: mission.goal } : {}),
@@ -4,6 +4,7 @@ import { join } from 'node:path';
4
4
  import { parse } from 'yaml';
5
5
  import { advanceSaga, setSagaError, updateMemberSeats, updateMemberStartup, activateRoom, getRoomRecord, } from './room-state.js';
6
6
  import { activateTask, updateTaskMembers, blockTask, unblockTask, getTask, } from './task-state.js';
7
+ import { storedRoomLaunchPolicy } from './types.js';
7
8
  import { spawnTemp } from '../spawn.js';
8
9
  import { effectivePermissionMode } from '../permissions.js';
9
10
  import { selectionOrigin, summarizeResolvedLaunch, } from '../lifecycle-summary.js';
@@ -142,7 +143,7 @@ function roomTask(input, member, settings, members, roomIdentityCid, ownerSeatCi
142
143
  })),
143
144
  });
144
145
  }
145
- function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId) {
146
+ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid, expectedInviteId, anonymous = false) {
146
147
  const provenance = readProvenance(dir);
147
148
  if (provenance?.creationActionId !== actionId || provenance.role !== member.name)
148
149
  return false;
@@ -154,6 +155,7 @@ function launchMatches(dir, member, actionId, taskSha, roomId, roomIdentityCid,
154
155
  && startup.room_identity_cid === roomIdentityCid
155
156
  && startup.identity_name === member.name
156
157
  && startup.role === member.coworkRole
158
+ && (startup.anonymous ?? false) === anonymous
157
159
  && sha256Text(startup.task ?? '') === taskSha
158
160
  && (expectedInviteId === undefined || startup.invite_id === expectedInviteId)
159
161
  && typeof startup.invite === 'string'
@@ -169,16 +171,17 @@ async function retainRunningLaunch(input) {
169
171
  .find(candidate => candidate.role_name === member.name);
170
172
  const dir = agentDir(member.name, true);
171
173
  const taskSha = sha256Text(task);
174
+ const anonymous = storedRoomLaunchPolicy(getRoomRecord(provision.roomId)?.room_policy).anonymous;
172
175
  if ((seat.launch?.state === 'intent' || seat.launch?.state === 'launched'
173
176
  || seat.launch?.state === 'failed') && existsSync(dir)) {
174
- if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
177
+ if (!seat.launch.action_id || !launchMatches(dir, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
175
178
  const provenance = readProvenance(dir);
176
179
  const adoptable = (seat.launch.state === 'intent' || seat.launch.state === 'failed')
177
180
  && Boolean(seat.launch.caller_role)
178
181
  && provenance?.surface === 'agent'
179
182
  && provenance.callerRole === seat.launch.caller_role
180
183
  && typeof provenance.creationActionId === 'string'
181
- && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id);
184
+ && launchMatches(dir, member, provenance.creationActionId, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous);
182
185
  if (!adoptable)
183
186
  throw new Error(`existing launch for ${member.name} does not match its durable intent`);
184
187
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -218,7 +221,7 @@ async function retainRunningLaunch(input) {
218
221
  if (!seat.launch.launch_id || !seat.launch.action_id)
219
222
  throw new Error(`missing durable launch identity for disappeared ${member.name}`);
220
223
  const archive = await secureStoppedTempArchive(member.name, seat.launch.launch_id);
221
- if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
224
+ if (!launchMatches(archive, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
222
225
  throw new Error(`archive for disappeared ${member.name} does not match its durable intent`);
223
226
  }
224
227
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -230,7 +233,7 @@ async function retainRunningLaunch(input) {
230
233
  if (!seat.launch.action_id)
231
234
  throw new Error(`missing action ID for disappeared launch intent ${member.name}`);
232
235
  const archive = tempArchiveForCreationAction(member.name, seat.launch.action_id);
233
- if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id)) {
236
+ if (!archive || !launchMatches(archive.path, member, seat.launch.action_id, taskSha, provision.roomId, roomIdentityCid, seat.invite_id, anonymous)) {
234
237
  throw new Error(`launch intent for ${member.name} has no exact live or terminated archive evidence`);
235
238
  }
236
239
  updateMemberStartup(provision.roomId, member.name, { launch: {
@@ -300,7 +303,7 @@ async function launchMemberUnlocked(input) {
300
303
  } });
301
304
  }
302
305
  const supervisor = readTempSupervisor(launchedDir);
303
- if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id)) {
306
+ if (!supervisor || supervisor.role !== member.name || !launchMatches(launchedDir, member, launched.creationActionId, taskSha, provision.roomId, startup.room_identity_cid, startup.invite_id, startup.anonymous ?? false)) {
304
307
  throw new Error(`new launch for ${member.name} did not persist matching provenance`);
305
308
  }
306
309
  const presentation = launched.configuration
@@ -371,6 +374,10 @@ function reconcileMemberSeats(roomId, members, observed) {
371
374
  updateMemberSeats(roomId, seats);
372
375
  return { complete, seats };
373
376
  }
377
+ function assertCoworkRoomPolicy(room, expectedAnonymous) {
378
+ if ((room.anonymous ?? false) !== expectedAnonymous)
379
+ throw new Error(`Cowork anonymity (${String(room.anonymous ?? false)}) does not match Fleet's durable Room policy (${String(expectedAnonymous)})`);
380
+ }
374
381
  export async function provisionMembers(input) {
375
382
  const { cfg, cowork, roomId, taskId, template } = input;
376
383
  // Deletion-epoch pre-check; each member launch re-checks under the lock.
@@ -387,6 +394,7 @@ export async function provisionMembers(input) {
387
394
  throw new Error(`room ${roomId} has no pinned room identity CID`);
388
395
  const roomIdentityCid = existing.room_identity_cid;
389
396
  const ownerSeatCid = existing.owner_seat_cid ?? null;
397
+ const roomPolicy = storedRoomLaunchPolicy(existing.room_policy);
390
398
  const persistedNames = new Set(existing.member_seats.map(seat => seat.role_name));
391
399
  const resuming = members.length > 0
392
400
  && members.every(member => persistedNames.has(member.name));
@@ -427,6 +435,7 @@ export async function provisionMembers(input) {
427
435
  advanceSaga(roomId, 'join_role_groups', 4);
428
436
  try {
429
437
  const initialRoom = await cowork.recoverRoom(roomId);
438
+ assertCoworkRoomPolicy(initialRoom, roomPolicy.anonymous);
430
439
  reconcileMemberSeats(roomId, members, initialRoom.seats);
431
440
  for (const member of members) {
432
441
  const task = tasks.get(member.name);
@@ -463,6 +472,7 @@ export async function provisionMembers(input) {
463
472
  role: member.coworkRole,
464
473
  task,
465
474
  owner_seat_cid: ownerSeatCid,
475
+ anonymous: roomPolicy.anonymous,
466
476
  },
467
477
  });
468
478
  }
@@ -493,6 +503,7 @@ export async function provisionMembers(input) {
493
503
  let delay = policy.initialDelayMs;
494
504
  for (;;) {
495
505
  const remote = await cowork.recoverRoom(roomId);
506
+ assertCoworkRoomPolicy(remote, roomPolicy.anonymous);
496
507
  const reconciled = reconcileMemberSeats(roomId, members, remote.seats);
497
508
  if (reconciled.complete)
498
509
  break;
@@ -9,6 +9,7 @@ export interface CreateRoomInput {
9
9
  room_identity_cid?: string;
10
10
  task_id?: string;
11
11
  template_snapshot?: import('./types.js').TemplateSnapshot;
12
+ room_policy?: import('./types.js').RoomLaunchPolicy;
12
13
  }
13
14
  export declare function createRoomRecord(input: CreateRoomInput): RoomOrchestrationRecord;
14
15
  export declare function getRoomRecord(id: string): RoomOrchestrationRecord | undefined;
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from 'node:f
2
2
  import { join } from 'node:path';
3
3
  import { replaceFileAtomically } from '../atomic-file.js';
4
4
  import { stateRoot } from '../paths.js';
5
+ import { storedRoomLaunchPolicy } from './types.js';
5
6
  import { releaseLaunchSnapshot } from './launch-snapshot.js';
6
7
  export const roomsDir = () => join(stateRoot(), 'rooms');
7
8
  function roomPath(id) { return join(roomsDir(), `${id}.json`); }
@@ -19,8 +20,13 @@ function writeRoom(record) {
19
20
  }
20
21
  export function createRoomRecord(input) {
21
22
  const existing = getRoomRecord(input.room_id);
22
- if (existing)
23
+ if (existing) {
24
+ const before = storedRoomLaunchPolicy(existing.room_policy);
25
+ const requested = storedRoomLaunchPolicy(input.room_policy);
26
+ if (JSON.stringify(before) !== JSON.stringify(requested))
27
+ throw new RoomStateError(`room ${input.room_id} launch policy mismatch`);
23
28
  return existing;
29
+ }
24
30
  const record = {
25
31
  room_id: input.room_id,
26
32
  room_name: input.room_name,
@@ -28,6 +34,7 @@ export function createRoomRecord(input) {
28
34
  room_identity_cid: input.room_identity_cid,
29
35
  task_id: input.task_id,
30
36
  template_snapshot: input.template_snapshot,
37
+ room_policy: input.room_policy,
31
38
  saga: { phase: 'persist_intent', step_index: 0 },
32
39
  member_seats: [],
33
40
  state: 'provisioning',
@@ -3,7 +3,7 @@ import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { replaceFileAtomically } from '../atomic-file.js';
5
5
  import { stateRoot } from '../paths.js';
6
- import { TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
6
+ import { storedRoomLaunchPolicy, TASK_TERMINAL_STATES, TASK_CANCELLABLE_STATES } from './types.js';
7
7
  import { DEFAULT_TASK_LIST_ID, readTaskLists } from './task-lists.js';
8
8
  export const tasksDir = () => join(stateRoot(), 'tasks');
9
9
  function taskPath(id) { return join(tasksDir(), `${id}.json`); }
@@ -131,7 +131,9 @@ export function createTask(input) {
131
131
  if (existing) {
132
132
  const existingPlan = existing.execution_plan?.plan_hash;
133
133
  const requestedPlan = input.execution_plan?.plan_hash;
134
- if (existingPlan !== requestedPlan)
134
+ const existingPolicy = storedRoomLaunchPolicy(existing.execution_plan?.room_policy);
135
+ const requestedPolicy = storedRoomLaunchPolicy(input.execution_plan?.room_policy);
136
+ if (existingPlan !== requestedPlan || JSON.stringify(existingPolicy) !== JSON.stringify(requestedPolicy))
135
137
  throw new TaskStateError(`idempotency key '${key}' was already used with a different execution plan`);
136
138
  return existing;
137
139
  }
@@ -161,8 +163,14 @@ export function updateTaskExecutionPlan(id, executionPlan) {
161
163
  return withTaskLock(id, () => {
162
164
  const task = readTask(id);
163
165
  assertNoPendingDeletion(task);
164
- if (task.execution_plan && task.execution_plan.plan_hash !== executionPlan.plan_hash)
165
- throw new TaskStateError(`task ${id} execution plan mismatch`);
166
+ if (task.execution_plan) {
167
+ if (task.execution_plan.plan_hash !== executionPlan.plan_hash)
168
+ throw new TaskStateError(`task ${id} execution plan mismatch`);
169
+ const before = storedRoomLaunchPolicy(task.execution_plan.room_policy);
170
+ const after = storedRoomLaunchPolicy(executionPlan.room_policy);
171
+ if (task.room_id && JSON.stringify(before) !== JSON.stringify(after))
172
+ throw new TaskStateError(`task ${id} Room launch policy cannot change after Room creation`);
173
+ }
166
174
  task.execution_plan = executionPlan;
167
175
  task.template = { name: executionPlan.snapshot.name, version: executionPlan.snapshot.version,
168
176
  content_hash: executionPlan.snapshot.content_hash };
@@ -101,6 +101,7 @@ export interface TaskRecord {
101
101
  execution_plan?: {
102
102
  schema_version: 1;
103
103
  snapshot: TemplateSnapshot;
104
+ room_policy?: RoomLaunchPolicy;
104
105
  overrides: Record<string, unknown>;
105
106
  overrides_hash: string;
106
107
  plan_hash: string;
@@ -232,6 +233,8 @@ export interface RoomOrchestrationRecord {
232
233
  goal?: string;
233
234
  task_id?: string;
234
235
  template_snapshot?: TemplateSnapshot;
236
+ /** Resolved once at launch; legacy absence means non-anonymous. */
237
+ room_policy?: RoomLaunchPolicy;
235
238
  saga: SagaCursor;
236
239
  provisioning_detail?: ProvisioningDetail;
237
240
  owner_seat_cid?: string;
@@ -256,6 +259,12 @@ export interface TemplateRoomConfig {
256
259
  quiet_membership?: boolean;
257
260
  anonymous?: boolean;
258
261
  }
262
+ export interface RoomLaunchPolicy {
263
+ anonymous: boolean;
264
+ }
265
+ export declare const LEGACY_ROOM_LAUNCH_POLICY: Readonly<RoomLaunchPolicy>;
266
+ /** Validate durable policy before it can influence room creation or recovery. */
267
+ export declare function storedRoomLaunchPolicy(value: RoomLaunchPolicy | undefined): RoomLaunchPolicy;
259
268
  export interface TemplateDefinition {
260
269
  name: string;
261
270
  version: number;
@@ -319,5 +328,6 @@ export declare const ROOMS_COWORK_KEYS: readonly ["config"];
319
328
  export declare const ROOMS_DEFAULTS_KEYS: readonly ["template", "attach_owner", "close_when_task_done"];
320
329
  export declare const TASKS_KEYS: readonly ["default_room_template", "create_mode", "close_room_on_done", "retain_completed_for"];
321
330
  export declare const TEMPLATE_KEYS: readonly ["version", "description", "room", "contract", "members", "override_builtin"];
331
+ export declare const TEMPLATE_ROOM_KEYS: readonly ["quiet_membership", "anonymous"];
322
332
  /** `agent` is accepted only so validation can emit its actionable migration error. */
323
333
  export declare const TEMPLATE_MEMBER_KEYS: readonly ["slot", "role", "count", "agent_template", "agent"];
@@ -7,6 +7,17 @@
7
7
  */
8
8
  export const TASK_TERMINAL_STATES = ['done', 'cancelled', 'failed'];
9
9
  export const TASK_CANCELLABLE_STATES = ['backlog', 'provisioning', 'active', 'review'];
10
+ export const LEGACY_ROOM_LAUNCH_POLICY = Object.freeze({
11
+ anonymous: false,
12
+ });
13
+ /** Validate durable policy before it can influence room creation or recovery. */
14
+ export function storedRoomLaunchPolicy(value) {
15
+ if (value === undefined)
16
+ return { ...LEGACY_ROOM_LAUNCH_POLICY };
17
+ if (typeof value.anonymous !== 'boolean')
18
+ throw new Error('invalid durable Room launch policy; anonymous must be a boolean');
19
+ return { ...value };
20
+ }
10
21
  // ── Validation keys ─────────────────────────────────────────────────────
11
22
  export const ROOMS_KEYS = ['cowork', 'owner', 'defaults'];
12
23
  export const ROOMS_OWNER_KEYS = ['provider', 'public_invite', 'public_invite_file', 'expected_cid', 'role'];
@@ -14,5 +25,6 @@ export const ROOMS_COWORK_KEYS = ['config'];
14
25
  export const ROOMS_DEFAULTS_KEYS = ['template', 'attach_owner', 'close_when_task_done'];
15
26
  export const TASKS_KEYS = ['default_room_template', 'create_mode', 'close_room_on_done', 'retain_completed_for'];
16
27
  export const TEMPLATE_KEYS = ['version', 'description', 'room', 'contract', 'members', 'override_builtin'];
28
+ export const TEMPLATE_ROOM_KEYS = ['quiet_membership', 'anonymous'];
17
29
  /** `agent` is accepted only so validation can emit its actionable migration error. */
18
30
  export const TEMPLATE_MEMBER_KEYS = ['slot', 'role', 'count', 'agent_template', 'agent'];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.1.0-nightly.13",
3
+ "version": "1.1.0-nightly.14",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",