@ours.network/fleet 1.2.0-nightly.51 → 1.2.0-nightly.53

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.
@@ -10,7 +10,9 @@ export interface AssignedIdentity {
10
10
  action: string;
11
11
  expectedCid?: string;
12
12
  allowCreate: boolean;
13
- bio: string;
13
+ /** Omitted fields preserve an existing identity's custom profile. */
14
+ bio?: string;
15
+ persona?: string;
14
16
  }
15
17
  export interface RoomAdmission {
16
18
  id: string;
@@ -62,6 +62,7 @@ export class AgentOursRuntime {
62
62
  actual.isRoot)
63
63
  throw Error('IDENTITY_PROOF_MISMATCH');
64
64
  this.deps.assertFence();
65
+ return actual;
65
66
  }
66
67
  async prepare(room, timeoutMs = 30_000) {
67
68
  if (['TERMINAL_INTENT', 'CLEANUP_PENDING', 'RELEASED', 'FAILED', 'QUIESCING'].includes(this.state.phase))
@@ -92,7 +93,7 @@ export class AgentOursRuntime {
92
93
  this.transition('RECOVERING');
93
94
  const args = {
94
95
  name: this.state.name,
95
- bio: this.assignment.bio,
96
+ bio: this.assignment.bio ?? '',
96
97
  exposeLocal: false,
97
98
  localAutoAccept: true,
98
99
  };
@@ -104,7 +105,18 @@ export class AgentOursRuntime {
104
105
  this.transition('OWNED', { cid: created.info.cid });
105
106
  }
106
107
  }
107
- await this.verify();
108
+ const profile = await this.verify();
109
+ // The supervisor owns profile setup, just like identity binding. Apply only
110
+ // explicitly configured fields, under the verified owner, before publishing
111
+ // readiness or admitting the harness. Repeating a prepare is idempotent.
112
+ if (this.assignment.bio !== undefined && profile.bio !== this.assignment.bio) {
113
+ this.deps.assertFence();
114
+ await this.deps.client.setBio({ bio: this.assignment.bio });
115
+ }
116
+ if (this.assignment.persona !== undefined && profile.persona !== this.assignment.persona) {
117
+ this.deps.assertFence();
118
+ await this.deps.client.setPersona({ persona: this.assignment.persona });
119
+ }
108
120
  if (!room && (this.state.room || this.state.admissionIntent))
109
121
  throw Error('ROOM_DESCRIPTOR_REQUIRED');
110
122
  if (room) {
@@ -146,7 +146,8 @@ export async function prepareManagedAgent(role, stateDir, temporary) {
146
146
  action: launch?.action ?? prior?.action ?? randomUUID(),
147
147
  expectedCid: pin?.cid,
148
148
  allowCreate: true,
149
- bio: role.bio ?? '',
149
+ bio: role.bio,
150
+ persona: role.persona,
150
151
  }, {
151
152
  client,
152
153
  journal: controller.journal,
@@ -8,7 +8,7 @@ export interface BriefingOpts {
8
8
  briefingBody?: string;
9
9
  /**
10
10
  * What spawn established about a persistent role's ours identity.
11
- * Temporary roles always create their own session-owned identity.
11
+ * The supervisor provisions temporary identities as well.
12
12
  * Defaults to `unverified`, because a briefing generated without that
13
13
  * knowledge must not claim one.
14
14
  */
package/dist/briefing.js CHANGED
@@ -19,6 +19,38 @@ function adminConsoleAuthority(session) {
19
19
  ];
20
20
  return [];
21
21
  }
22
+ /** Common guidance for Fleet-owned boot and subsequent wakes; tools stay available. */
23
+ function startupGuidance(v) {
24
+ return [
25
+ '', '## Readiness first and task-driven wakes',
26
+ 'On a fresh launch or restart, announce readiness with a short final console response before any tools',
27
+ 'or file reads. End that readiness turn; the supervisor delivers configured work in a separate turn.',
28
+ 'If readiness was already announced, do not repeat it. An idle agent waits for a concrete task or wake',
29
+ 'without reading routines, worklog, inbox, history, or unrelated files. A charter or bio alone is not a task.',
30
+ 'For a concrete task or mail wake, read this briefing once before acting. If a curated briefing has no',
31
+ 'concrete assignment, stop after that read and await work rather than exploring other files.',
32
+ 'For assigned work, read any existing routines once. On a continuation task after restart or resume,',
33
+ 'also read the active worklog once to recover unfinished work. Never read a worklog for fresh initialization,',
34
+ 'including a fresh temporary agent. Within a wake, reuse details already read; reread only when a file',
35
+ 'changed or you need a specific missing detail.',
36
+ 'Fleet owns identity binding, readiness, required room admission, and application of the configured Role',
37
+ 'bio/persona before the model starts. Accept that launch context; no model-side profile setup is needed.',
38
+ 'Avoid startup identity/profile checks, broad tool discovery, CLI help/config exploration, or searching',
39
+ 'for your own chat merely to confirm launch. Discover only the tools needed for the next task action.',
40
+ 'Identity, profile, and management APIs remain available for an explicit task or a verified failure;',
41
+ 'follow the recovery/escalation instructions for a real mismatch rather than hiding it.',
42
+ 'The supervisor delivers initial unread mail and subsequent arrivals as mail wakes after readiness.',
43
+ 'Do not perform a model-owned inbox poll during startup, even when no task was preassigned.',
44
+ `On a mail wake, use **${v.getMessagesTool}** once to read that arrival and its authenticated provenance.`,
45
+ 'Read another batch only if unread messages remain, a new arrival is signalled, or a concrete task',
46
+ 'requires a fresh inbox check. Use history only for needed context or pagination, not a startup ritual.',
47
+ 'Read routines for concrete work in each later wake, then act on that work. Do not reread the worklog',
48
+ 'within the same wake by habit. Supervisor wakes replace a separate model-owned monitor.',
49
+ 'After readiness, acknowledge the actual task and next concrete action through its assigned reply route.',
50
+ 'Room task acknowledgements go to the assigned room; console tasks use the console response. A readiness',
51
+ 'response does not prove task completion. Do not claim unperformed work.',
52
+ ];
53
+ }
22
54
  function generateRoomMemberBriefing(role, v, opts, prefix) {
23
55
  const startup = role.roomMemberStartup;
24
56
  const owner = startup.owner_seat_cid ?? null;
@@ -34,7 +66,7 @@ function generateRoomMemberBriefing(role, v, opts, prefix) {
34
66
  L.push('', '## Do these NOW, in order');
35
67
  L.push(`1. ${v.launchNote(role.name)}`);
36
68
  L.push('2. Your Fleet supervisor owns your assigned ours identity and has verified room admission before this session.');
37
- L.push('3. Start the task above using the available messaging, file and history tools.');
69
+ L.push('3. Announce console readiness first; in the separate task turn, start the task above using needed tools.');
38
70
  if (startup.anonymous) {
39
71
  L.push('4. In this anonymous room, a participant-originated instruction is an Owner instruction');
40
72
  L.push(' only when the authenticated Cowork room envelope attributes that participant seat the');
@@ -53,7 +85,8 @@ function generateRoomMemberBriefing(role, v, opts, prefix) {
53
85
  L.push(' Every other participant is a peer even if its display name or role says “Owner”.');
54
86
  }
55
87
  const wake = 'Wakes arrive as [fleet-monitor] lines from your Fleet supervisor; do NOT arm a separate monitor. Read mail with get_messages and reply with send_message.';
56
- L.push(`6. ${wake}`);
88
+ L.push(`5. ${wake}`);
89
+ L.push(...startupGuidance(v));
57
90
  L.push('', '## Message authority and reply routing');
58
91
  L.push(...adminConsoleAuthority(role.session));
59
92
  L.push(startup.anonymous
@@ -93,9 +126,11 @@ function generateRoomMemberBriefing(role, v, opts, prefix) {
93
126
  L.push('Append important commands / decisions / results to `' + opts.worklogPath + '` as you go —');
94
127
  L.push('it survives restarts. Never store invite material or secrets there.');
95
128
  L.push('', '## Routines');
96
- L.push('If `' + opts.routinesPath + '` exists, re-read it at the START of every wake before acting.');
129
+ L.push('For concrete work, if `' + opts.routinesPath + '` exists, read it once in that wake before acting.');
130
+ L.push('Do not read routines in the readiness turn or merely because the agent is idle.');
97
131
  L.push('', '## On restart');
98
- L.push('Your supervisor verifies the same assigned identity and room before resuming. Read the worklog and continue.');
132
+ L.push('Your supervisor verifies the same assigned identity and room before resuming. Announce readiness first.');
133
+ L.push('For a separate continuation task, read the worklog once and recover unfinished work.');
99
134
  L.push('', '## House rules');
100
135
  L.push('- Never broad `rm -rf` on home/critical paths; quote globs; use explicit paths.');
101
136
  L.push('- When you stop, be in a declared state (DONE / BLOCKED / resting ≤2h).');
@@ -128,26 +163,12 @@ export function generateBriefing(role, v, opts) {
128
163
  L.push('', '## Do these NOW, in order');
129
164
  L.push(`1. ${v.launchNote(role.name)}`);
130
165
  L.push('2. Your assigned ours identity is owned and verified by the Fleet supervisor before this session starts.');
131
- L.push(`3. RECONCILE your profile (idempotent): call **${v.currentIdentityTool}** and read your`);
132
- L.push(' current bio and persona, so you only write below when they actually differ.');
133
- if (opts.briefingBody !== undefined) {
134
- L.push('4. The curated briefing did not declare a profile source. Do not infer one or mutate');
135
- L.push(' bio/persona from arbitrary headings.');
136
- L.push('5. Continue with the curated operating instructions without a profile write.');
137
- }
138
- else {
139
- const profileSource = role.persona ? 'Charter' : 'Mission';
140
- L.push(`4. PUBLISH your public **bio** via **${v.setBioTool}**`);
141
- L.push(role.bio
142
- ? ' with the **Bio** section above, verbatim. Skip the call if it already matches.'
143
- : ` with a 1–2 sentence summary of your ${profileSource} above. Skip if it already matches.`);
144
- L.push(`5. SET your **persona** (local operating contract, never shared in invites) via`);
145
- L.push(` **${v.setPersonaTool}** with the **${profileSource}** section above, verbatim. Skip if it matches.`);
146
- }
166
+ L.push('3. Announce console readiness first; in the separate task turn, start assigned work and acknowledge its next action.');
167
+ L.push(...startupGuidance(v));
147
168
  // When the supervisor owns the monitor (monitor.mode=fleet), the agent must NOT arm
148
169
  // its own in-session watch — wakes are injected as [fleet-monitor] lines.
149
170
  const wakeNote = 'Wakes arrive as [fleet-monitor] lines from your Fleet supervisor; do NOT arm a separate monitor. Read mail with get_messages and reply with send_message.';
150
- L.push(`6. ${wakeNote}`);
171
+ L.push(wakeNote);
151
172
  if (role.owner_channel || managedSession(role)) {
152
173
  L.push('', '## Message authority and reply routing');
153
174
  }
@@ -209,13 +230,13 @@ export function generateBriefing(role, v, opts) {
209
230
  L.push(...(opts.managedCli ?? []));
210
231
  }
211
232
  if (role.coordinator) {
212
- L.push(`7. ANNOUNCE yourself: call **${v.sendTool}** to contact "${role.coordinator}" with text:`);
213
- L.push(` "${role.name} online — identity '${id}' bound, ready."`);
214
- L.push(`8. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
233
+ L.push(`For an assigned task, ANNOUNCE yourself: call **${v.sendTool}** to contact "${role.coordinator}".`);
234
+ L.push('After console readiness, name the actual task and your next action. An idle launch needs no coordinator call.');
235
+ L.push(`Await messages. When the monitor wakes you (or the owner requests a manual check),`);
215
236
  L.push(` call **${v.getMessagesTool}**, act, and reply.`);
216
237
  }
217
238
  else {
218
- L.push(`7. Await messages. When the monitor wakes you (or the owner requests a manual check),`);
239
+ L.push(`Await messages. When the monitor wakes you (or the owner requests a manual check),`);
219
240
  L.push(` call **${v.getMessagesTool}**, act on them,`);
220
241
  L.push(` and reply with ${v.sendTool}. No coordinator is configured — the owner drives you`);
221
242
  L.push(` via \`ours-fleet attach ${role.name}\` or by messaging "${id}".`);
@@ -261,12 +282,14 @@ export function generateBriefing(role, v, opts) {
261
282
  'Continue writing only WORKLOG.md.');
262
283
  }
263
284
  L.push('', '## Routines');
264
- L.push(`If \`${opts.routinesPath}\` exists, re-read it at the START of every wake — before acting`);
265
- L.push('on messages, timers, or prompts — and follow it for recurring or scheduled work. It may');
285
+ L.push(`For concrete work, if \`${opts.routinesPath}\` exists, read it once in that wake before acting`);
286
+ L.push('on that work and follow it for recurring or scheduled tasks. Do not read it in the readiness turn');
287
+ L.push('or merely because the agent is idle. It may');
266
288
  L.push('change between wakes without a restart; treat the file, not your memory of it, as current.');
267
289
  L.push('', '## On restart (you run under a supervised launcher)');
268
- L.push('The supervisor verifies your identity and room before resuming. Continue from your worklog.');
269
- L.push(`${wakeNote} Then continue from your WORKLOG.`);
290
+ L.push('The supervisor verifies your identity and room before resuming. Announce readiness first.');
291
+ L.push('For a separate continuation task, read your worklog once and recover unfinished work.');
292
+ L.push(wakeNote);
270
293
  L.push('Do not blindly re-run whatever may have crashed you.');
271
294
  L.push('', '## House rules');
272
295
  L.push('- Never broad `rm -rf` on home/critical paths; quote globs; use explicit paths.');
@@ -1,9 +1,9 @@
1
1
  {
2
- "version": "1.2.0-nightly.51",
3
- "buildId": "e05ce68930ef",
4
- "commit": "ef81d60bce4b5731ffb0d1e2f8b28419758d3d2b",
2
+ "version": "1.2.0-nightly.53",
3
+ "buildId": "bf5ca35d699f",
4
+ "commit": "cdf3842ad7869bd41fc843ff3e71859f4ef2f0f0",
5
5
  "dirty": true,
6
- "builtAt": "2026-10-07T12:00:34.954Z",
6
+ "builtAt": "2026-10-07T22:04:37.157Z",
7
7
  "capabilities": [
8
8
  "managed-cli.codex-reasoning-effort-v1",
9
9
  "managed-cli.setup-v1",
@@ -385,16 +385,17 @@ export function makeClaudeCodeAdapter(exec = realExec, transport) {
385
385
  supervisedWakeNote: () => 'Your mail wake-ups are delivered by the fleet supervisor directly into this console as ' +
386
386
  '`[fleet-monitor]` lines — do NOT arm an in-session Monitor. When such a line appears, run ' +
387
387
  '**get_messages** to drain the mail.',
388
- launchNote: name => `You were launched with \`--remote-control ${name}\`. Confirm you are running.`,
389
- restartPrompt: (id, worklog, role) => `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
390
- (role?.monitor?.mode === 'fleet'
391
- ? 'then continue from '
392
- : `then ${armMonitor(id)}, then continue from `) +
393
- `${worklog}. Do not re-run whatever crashed you.` +
394
- (role?.monitor?.mode === 'fleet'
395
- ? ' Your mail wakes arrive as `[fleet-monitor]` console lines from the supervisor — ' +
396
- 'do NOT arm an in-session Monitor.'
397
- : ''),
388
+ launchNote: name => `You were launched as Fleet role \`${name}\` under a Claude session. Announce console readiness before task details.`,
389
+ restartPrompt: (id, worklog, role) => role?.monitor?.mode === 'fleet'
390
+ ? `Session restarted. The supervisor has verified assigned identity "${id}"; ` +
391
+ 'accept that readiness and continue without rebinding or profile reconciliation. ' +
392
+ 'Your mail wakes arrive as `[fleet-monitor]` console lines from the supervisor — ' +
393
+ 'do NOT arm an in-session Monitor. ' +
394
+ 'Announce console readiness first without tools, then end that readiness turn. ' +
395
+ `For a separate continuation task, read ${worklog} once for continuity and reuse it in that wake. ` +
396
+ 'Do not re-run whatever crashed you.'
397
+ : `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
398
+ `then ${armMonitor(id)}, then continue from ${worklog}. Do not re-run whatever crashed you.`,
398
399
  },
399
400
  exitPolicy: { cleanExitIsFresh: true, fastFailSecs: 20 },
400
401
  };
@@ -621,12 +621,15 @@ export function makeCodexAdapter(exec = realExec, transport, nativeTransport) {
621
621
  supervisedWakeNote: () => 'Your mail wake-ups are delivered by the fleet supervisor directly into this console as ' +
622
622
  '`[fleet-monitor]` lines — do NOT arm arm_monitor or foreground_monitor. When such a line ' +
623
623
  'appears, call **get_messages**, handle the mail, and reply with send_message.',
624
- launchNote: name => `You were launched as the fleet role \`${name}\` under a Codex session. Confirm you are running.`,
624
+ launchNote: name => `You were launched as the fleet role \`${name}\` under a Codex session. Announce console readiness before task details.`,
625
625
  restartPrompt: (id, worklog, configuredRole) => {
626
626
  if (configuredRole?.monitor?.mode === 'fleet')
627
- return `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true); ` +
628
- 'your mail wakes are delivered by the fleet supervisor as `[fleet-monitor]` console lines, so do ' +
629
- `NOT arm arm_monitor/foreground_monitor. Continue from ${worklog}. Do not re-run whatever crashed you.`;
627
+ return `Session restarted. The supervisor has verified assigned identity "${id}"; ` +
628
+ 'accept that readiness and continue without rebinding or profile reconciliation. ' +
629
+ 'Mail wakes arrive as `[fleet-monitor]` lines; do NOT arm arm_monitor/foreground_monitor. ' +
630
+ 'Announce console readiness first without tools, then end that readiness turn. ' +
631
+ `For a separate continuation task, read ${worklog} once for continuity and reuse it in that wake. ` +
632
+ 'Do not re-run whatever crashed you.';
630
633
  const consented = configuredRole?.harness_options?.monitor === true;
631
634
  return `Session restarted. Re-bind your ours identity now (choose_identity name "${id}" force=true), ` +
632
635
  (consented
package/dist/monitor.d.ts CHANGED
@@ -28,6 +28,10 @@ export type FetchLike = (url: string, init?: {
28
28
  signal?: AbortSignal;
29
29
  }) => Promise<FetchResponse>;
30
30
  export interface MonitorPageClient {
31
+ /** Body-free snapshot; never consumes messages or changes read status. */
32
+ unread(): Promise<{
33
+ identities: Array<Record<string, unknown>>;
34
+ }>;
31
35
  readNotificationPage(identity: string, options?: {
32
36
  since?: number | 'tip';
33
37
  signal?: AbortSignal;
@@ -179,6 +183,7 @@ export declare class Monitor {
179
183
  private stopped;
180
184
  private bootDeadline;
181
185
  private currentAbort;
186
+ private initialBacklogPending;
182
187
  private apiErrorStreak;
183
188
  private readonly turnFailThreshold;
184
189
  /** Active degradations, keyed by cause. Empty means armed. */
@@ -198,6 +203,7 @@ export declare class Monitor {
198
203
  /** Gather stragglers arriving within batch_ms so a burst lands as one line. */
199
204
  private coalesce;
200
205
  private deliver;
206
+ private deliverLine;
201
207
  /** Update the consecutive-API-error streak and derive `.monitor-status` from it. */
202
208
  private recordTurn;
203
209
  /**
@@ -210,6 +216,7 @@ export declare class Monitor {
210
216
  */
211
217
  private doFetch;
212
218
  private profileClient;
219
+ private readInitialUnread;
213
220
  private disposeProfileClient;
214
221
  private advance;
215
222
  private persistCursor;
package/dist/monitor.js CHANGED
@@ -325,6 +325,7 @@ export class Monitor {
325
325
  stopped = false;
326
326
  bootDeadline = 0;
327
327
  currentAbort = null;
328
+ initialBacklogPending = true;
328
329
  // Refusal-wedge detector (issue #19): consecutive delivered wakes whose turn
329
330
  // ended in an API error with no completed turn in between.
330
331
  apiErrorStreak = 0;
@@ -389,6 +390,41 @@ export class Monitor {
389
390
  this.degrade('offline', 'session offline');
390
391
  return;
391
392
  }
393
+ // The native SessionStart hook is disabled for managed sessions. Check
394
+ // existing unread work AFTER the ready turn, without a model inbox ritual
395
+ // or touching read status. Keep retrying a failed probe/delivery before
396
+ // committing stream cursors, including on supervisor restarts.
397
+ if (this.initialBacklogPending) {
398
+ try {
399
+ const body = await this.readInitialUnread();
400
+ if (!Array.isArray(body.identities))
401
+ throw new Error('unread summary is malformed');
402
+ const row = body.identities.find(row => row.name === this.identity);
403
+ if (row && (!Number.isSafeInteger(row.count) || Number(row.count) < 0
404
+ || !Number.isSafeInteger(row.files) || Number(row.files) < 0))
405
+ throw new Error('unread identity summary is malformed');
406
+ const messages = this.cfg.wake_sources.includes('message_received') ? Number(row?.count ?? 0) : 0;
407
+ const files = this.cfg.wake_sources.includes('file_received') ? Number(row?.files ?? 0) : 0;
408
+ if ((messages || files) && !await this.deliverLine(`${PREFIX} unread backlog: ${messages} messages, ${files} files — run get_messages; drain remaining unread batches and authorize files before retrieving them`)) {
409
+ await this.deps.sleep(BACKOFF_MAX_MS);
410
+ continue;
411
+ }
412
+ this.initialBacklogPending = false;
413
+ this.recover('connectivity', 'auth');
414
+ }
415
+ catch (e) {
416
+ if (this.stopped)
417
+ return;
418
+ if (e instanceof AuthError)
419
+ this.degrade('auth', e.message);
420
+ else
421
+ this.degrade('connectivity', 'initial unread check unavailable; retrying');
422
+ await this.deps.sleep(BACKOFF_MAX_MS);
423
+ continue;
424
+ }
425
+ }
426
+ if (this.stopped)
427
+ return;
392
428
  let body;
393
429
  try {
394
430
  body = await this.doFetch(String(this.cursor ?? 0), LONGPOLL_STALL_MS, 'stall');
@@ -463,7 +499,9 @@ export class Monitor {
463
499
  catch { /* no stragglers / abort — deliver what we have */ }
464
500
  }
465
501
  async deliver(pid, batch) {
466
- const line = formatNotificationLine(batch);
502
+ return this.deliverLine(formatNotificationLine(batch));
503
+ }
504
+ async deliverLine(line) {
467
505
  if (!this.deps.delivery) {
468
506
  this.degrade('delivery', 'structured agent-session delivery is unavailable');
469
507
  return false;
@@ -551,6 +589,32 @@ export class Monitor {
551
589
  }
552
590
  return this.profileClientPromise;
553
591
  }
592
+ async readInitialUnread() {
593
+ const ctrl = new AbortController();
594
+ this.currentAbort = ctrl;
595
+ const timer = this.deps.timers.set(() => ctrl.abort(), 5_000);
596
+ let client;
597
+ try {
598
+ const { endpoint, expectedInstanceId, credentialPath } = this.profile;
599
+ const options = {
600
+ endpoint, expectedInstanceId, credentialPath,
601
+ sessionMode: 'external', leaseToken: this.monitorLeaseToken, env: {},
602
+ requestSignal: ctrl.signal,
603
+ };
604
+ client = await (this.deps.attachClient?.(options) ?? attachOursClient(options));
605
+ return await client.unread();
606
+ }
607
+ catch (error) {
608
+ if (/(?:HTTP\s*401|unauthori[sz]ed|API token)/i.test(msg(error)))
609
+ throw new AuthError('daemon rejected the API token (401); initial unread check unavailable; retrying');
610
+ throw error;
611
+ }
612
+ finally {
613
+ this.deps.timers.clear(timer);
614
+ this.currentAbort = null;
615
+ await client?.close().catch(() => undefined);
616
+ }
617
+ }
554
618
  async disposeProfileClient() {
555
619
  const pending = this.profileClientPromise;
556
620
  this.profileClientPromise = undefined;
@@ -247,6 +247,8 @@ const taskListMarkdown = (tasks, title = 'Tasks') => renderMarkdownList({
247
247
  icon: '📋', title, empty: 'No tasks found.',
248
248
  records: tasks.map(t => `${taskStatus(t.state)} ${markdownCode(t.task_id)} — ${markdownProse(t.title)}`
249
249
  + ` — List ${markdownCode(t.list_name ?? 'default')}`
250
+ + (t.deletion?.status === 'pending' ? ' — Deletion pending'
251
+ + (t.deletion.error ? `: ${markdownProse(t.deletion.error)}` : '') : '')
250
252
  + (t.blocked ? ` — 🚧 Blocked: ${markdownProse(t.blocked.reason)}` : '')),
251
253
  });
252
254
  const taskActionMarkdown = (title, task, fields = []) => {
@@ -714,6 +716,7 @@ export function registerTaskCommands(parent, cOpt) {
714
716
  .option('--state <state>', 'filter by state (backlog|active|provisioning|review|done|cancelled|failed|all)')
715
717
  .option('--list <name>', 'filter by task list')
716
718
  .option('--group-by-list', 'group deterministic results by list (JSON)')
719
+ .option('--include-deleting', 'include tasks whose accepted deletion is still pending')
717
720
  .option('--json', 'JSON output')
718
721
  .action(async (opts) => {
719
722
  try {
@@ -722,7 +725,8 @@ export function registerTaskCommands(parent, cOpt) {
722
725
  stateFilter = opts.state;
723
726
  }
724
727
  const service = taskRoomService(opts.configuration);
725
- const filter = { ...(stateFilter ? { state: stateFilter } : {}), ...(opts.list ? { list: opts.list } : {}) };
728
+ const filter = { ...(stateFilter ? { state: stateFilter } : {}), ...(opts.list ? { list: opts.list } : {}),
729
+ ...(opts.includeDeleting ? { includeDeleting: true } : {}) };
726
730
  const tasks = service.listTasks(filter);
727
731
  if (opts.json) {
728
732
  console.log(JSON.stringify(opts.groupByList
@@ -839,7 +843,8 @@ export function registerTaskCommands(parent, cOpt) {
839
843
  try {
840
844
  const service = taskRoomService(opts.configuration);
841
845
  const { task: t, orchestration: room } = service.getTask(id);
842
- const readiness = t.state === 'active' ? await service.observeTaskReadiness(id) : undefined;
846
+ const readiness = t.state === 'active' && t.deletion?.status !== 'pending'
847
+ ? await service.observeTaskReadiness(id) : undefined;
843
848
  if (opts.json) {
844
849
  console.log(JSON.stringify({
845
850
  schema_version: 1, task: t, orchestration: room ?? null,
@@ -853,6 +858,11 @@ export function registerTaskCommands(parent, cOpt) {
853
858
  { label: 'ID', value: t.task_id, kind: 'code' },
854
859
  { label: 'Title', value: t.title },
855
860
  { label: 'Status', value: taskStatus(t.state), kind: 'markdown' },
861
+ ...(t.deletion?.status === 'pending' ? [
862
+ { label: 'Deletion', value: 'Pending — cleanup is not complete' },
863
+ ...(t.deletion.error ? [{ label: 'Cleanup error', value: t.deletion.error }] : []),
864
+ { label: 'Retry', value: `ours-fleet task delete ${t.task_id} ${t.task_id}`, kind: 'code' },
865
+ ] : []),
856
866
  ...(readiness ? [{ label: 'Readiness', value: readiness.state },
857
867
  ...('reason' in readiness ? [{ label: 'Reason', value: readiness.reason }] : [])] : []),
858
868
  ...(t.blocked ? [{ label: 'Blocked', value: t.blocked.reason }] : []),
@@ -2,6 +2,26 @@ import { randomUUID } from 'node:crypto';
2
2
  import { attachOursClient } from '@ours.network/sdk/client';
3
3
  import { readPrivateFile } from '@ours.network/sdk/connector';
4
4
  import { CoworkProtocolError, CoworkUnavailableError } from './cowork-adapter.js';
5
+ const TRANSPORT_CODES = new Set(['EACCES', 'ENOENT', 'ECONNREFUSED', 'ECONNRESET',
6
+ 'ETIMEDOUT', 'ENOTFOUND', 'EAI_AGAIN', 'UND_ERR_CONNECT_TIMEOUT', 'UND_ERR_SOCKET',
7
+ 'CERT_HAS_EXPIRED', 'ERR_TLS_CERT_ALTNAME_INVALID']);
8
+ function safeFailureKind(error, signal) {
9
+ if (signal.aborted)
10
+ return 'timeout';
11
+ for (const value of [error, error instanceof Error ? error.cause : undefined]) {
12
+ if (!value || typeof value !== 'object')
13
+ continue;
14
+ const code = value.code;
15
+ if (typeof code === 'string' && TRANSPORT_CODES.has(code))
16
+ return code;
17
+ const name = value.name;
18
+ if (name === 'TimeoutError')
19
+ return 'timeout';
20
+ if (name === 'AbortError')
21
+ return 'aborted';
22
+ }
23
+ return 'transport_error';
24
+ }
5
25
  /** Same RPC protocol as the local socket; never replay a possibly delivered mutation. */
6
26
  export async function coworkHttpCall(profile, method, params, timeoutMs) {
7
27
  const id = randomUUID();
@@ -9,11 +29,15 @@ export async function coworkHttpCall(profile, method, params, timeoutMs) {
9
29
  const body = JSON.stringify({ version: 1, id, method, params });
10
30
  if (Buffer.byteLength(body) > 1024 * 1024)
11
31
  throw new CoworkProtocolError(method, 'request exceeded 1 MiB');
32
+ let stage = 'server verification';
12
33
  try {
13
34
  const client = await attachOursClient({ endpoint: profile.endpoint, expectedInstanceId: profile.expectedInstanceId,
14
35
  credentialPath: profile.credentialPath, sessionMode: 'external', leaseToken: `fleet-cowork-check-${id}`, env: {}, requestSignal: signal });
36
+ stage = 'verification cleanup';
15
37
  await client.close();
38
+ stage = 'credential read';
16
39
  const token = readPrivateFile(profile.credentialPath, 4096).toString('utf8').trim();
40
+ stage = 'management request';
17
41
  const response = await fetch(profile.serverUrl + '/cowork/management/rpc', {
18
42
  method: 'POST', redirect: 'error', signal,
19
43
  headers: { 'content-type': 'application/json', 'x-ours-api-token': token },
@@ -26,6 +50,7 @@ export async function coworkHttpCall(profile, method, params, timeoutMs) {
26
50
  const reader = response.body?.getReader();
27
51
  if (!reader)
28
52
  throw new CoworkProtocolError(method, 'empty HTTP response');
53
+ stage = 'management response';
29
54
  const chunks = [];
30
55
  let size = 0;
31
56
  try {
@@ -65,6 +90,9 @@ export async function coworkHttpCall(profile, method, params, timeoutMs) {
65
90
  catch (error) {
66
91
  if (error instanceof CoworkProtocolError)
67
92
  throw error;
68
- throw new CoworkUnavailableError('Cowork HTTP management failed; check the selected server and credential', { cause: error });
93
+ // Durable cleanup records retain only .message. Keep the RPC step and a
94
+ // bounded allowlisted category there, never raw URLs, credentials or cause text.
95
+ const operation = /^[a-z][a-z0-9_.-]{0,79}$/.test(method) ? method : 'management';
96
+ throw new CoworkUnavailableError(`Cowork HTTP ${operation} failed during ${stage} (${safeFailureKind(error, signal)})`, { cause: error });
69
97
  }
70
98
  }
@@ -6,7 +6,7 @@ import { parse } from 'yaml';
6
6
  import { replaceFileAtomically } from '../atomic-file.js';
7
7
  import { agentDir, stateRoot } from '../paths.js';
8
8
  import { readTempSupervisor, tempSupervisorLiveness, eraseTerminationEvents } from '../temp-lifecycle.js';
9
- import { assertSafeAncestors, auditWorkspaceGit } from './workspace.js';
9
+ import { assertSafeAncestors, auditWorkspaceGit, prepareOwnedDeletionTree } from './workspace.js';
10
10
  function entries(path) {
11
11
  try {
12
12
  return readdirSync(path);
@@ -98,7 +98,7 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
98
98
  throw new Error('Archive writer is not stopped');
99
99
  if (creation?.role === supervisor.role && typeof creation.creationActionId === 'string')
100
100
  actions.get(supervisor.role).add(creation.creationActionId);
101
- auditWorkspaceGit(path);
101
+ auditWorkspaceGit(path, { forDeletion: true });
102
102
  launches.push({ role: supervisor.role, launchId: supervisor.launchId });
103
103
  paths.push(path);
104
104
  }
@@ -127,7 +127,7 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
127
127
  const state = json(join(path, 'state.json')), instance = json(join(path, 'instance.json'));
128
128
  if (state?.lifetime === 'temporary' && actions.get(state.name)?.has(state.action)
129
129
  && instance?.role === state.name && instance?.temporary === true && instance?.instance === state.instance) {
130
- auditWorkspaceGit(path);
130
+ auditWorkspaceGit(path, { forDeletion: true });
131
131
  paths.push(path);
132
132
  }
133
133
  }
@@ -158,6 +158,8 @@ export async function eraseMemberArtifacts(owner, id, seats, roomIds) {
158
158
  }
159
159
  manifest.phases[path] = 'renamed';
160
160
  replaceFileAtomically(manifestPath, JSON.stringify(manifest));
161
+ if (existsSync(tombstone) && lstatSync(tombstone).isDirectory())
162
+ prepareOwnedDeletionTree(tombstone);
161
163
  rmSync(tombstone, { recursive: true, force: true });
162
164
  manifest.phases[path] = 'removed';
163
165
  replaceFileAtomically(manifestPath, JSON.stringify(manifest));
@@ -66,7 +66,7 @@ export function collectWorkspaceArchives(workspace) {
66
66
  });
67
67
  if (!terminated || entry.name.endsWith('.retiring'))
68
68
  throw new WorkspaceError('workspace archive retirement is incomplete');
69
- auditWorkspaceGit(path);
69
+ auditWorkspaceGit(path, { forDeletion: true });
70
70
  owned.push(path);
71
71
  }
72
72
  // Preflight the entire set before mutation; task/room record remains until all succeed.
@@ -11,13 +11,20 @@ export declare class WorkspaceError extends Error {
11
11
  export declare function assertSafeAncestors(path: string): void;
12
12
  export declare function planWorkspace(owner: OwnedWorkspace['owner'], id: string): OwnedWorkspace;
13
13
  export declare function validateWorkspace(w: OwnedWorkspace, owner?: "task" | "room", id?: string): void;
14
+ /** Caller must first prove the exact workspace marker or retired archive
15
+ * provenance, and hold its lifecycle lock with all managed writers retired.
16
+ * Directories need owner rwx for recursive removal; ordinary files do not.
17
+ */
18
+ export declare function prepareOwnedDeletionTree(path: string): void;
14
19
  /** Atomic directory publication; retry uses the durable token, never adopts another folder. */
15
20
  export declare function ensureWorkspace(w: OwnedWorkspace): string;
16
21
  /** Audit both directions of Git worktree registration, without executing repository config/hooks.
17
22
  * All registrations and common dirs must be inside this workspace. Removing the complete
18
23
  * workspace then removes their registries atomically with the owned repositories; never prune.
19
24
  */
20
- export declare function auditWorkspaceGit(path: string): void;
25
+ export declare function auditWorkspaceGit(path: string, options?: {
26
+ forDeletion?: boolean;
27
+ }): void;
21
28
  /** Called only after all managed writers retire, under the owner's lifecycle lock.
22
29
  * The tombstone is derived from the durable unguessable token. A crash during rm
23
30
  * resumes that exact tombstone even if rm already removed its marker.
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { chmodSync, closeSync, constants, fchmodSync, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
3
  import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
4
4
  import { stateRoot } from '../paths.js';
5
5
  const MARKER = '.fleet-workspace.json';
@@ -57,6 +57,104 @@ function marked(path, w) {
57
57
  function staging(w, phase) {
58
58
  return join(dirname(w.path), `.${w.id}.${w.token}.${phase}`);
59
59
  }
60
+ /** Recover only owner access on a pinned, real entry. Never chmod a symlink
61
+ * target or a hardlinked file through an alias outside the deletion tree.
62
+ * Linux O_PATH can pin an entry even when a same-owner fixture removed every
63
+ * permission; /proc/self/fd then addresses that inode rather than its old name.
64
+ */
65
+ function withOwnerPermissions(path, before, bits, work, rollback = false) {
66
+ if (before.isSymbolicLink() || (!before.isDirectory() && !before.isFile()))
67
+ fail(`unsafe permission repair ${path}`);
68
+ const changed = (before.mode & bits) !== bits;
69
+ const uid = process.geteuid?.();
70
+ if (changed && (uid === undefined || before.uid !== uid))
71
+ fail(`cannot restore owner access to ${path}: owned by uid ${before.uid}`);
72
+ if (changed && before.isFile() && before.nlink !== 1)
73
+ fail(`cannot restore owner access to hardlinked Git metadata ${path}`);
74
+ let descriptor, pathDescriptor = false;
75
+ try {
76
+ descriptor = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
77
+ }
78
+ catch (error) {
79
+ if (process.platform !== 'linux' || error.code !== 'EACCES')
80
+ throw error;
81
+ // O_PATH is Linux-specific and is not exposed in Node's fs.constants.
82
+ descriptor = openSync(path, 0x200000 | constants.O_NOFOLLOW);
83
+ pathDescriptor = true;
84
+ }
85
+ const setMode = (mode) => pathDescriptor
86
+ ? chmodSync(`/proc/self/fd/${descriptor}`, mode)
87
+ : fchmodSync(descriptor, mode);
88
+ try {
89
+ const pinned = fstatSync(descriptor);
90
+ if (pinned.dev !== before.dev || pinned.ino !== before.ino || pinned.uid !== before.uid
91
+ || pinned.mode !== before.mode || pinned.nlink !== before.nlink)
92
+ fail(`entry changed before permission repair ${path}`);
93
+ if (changed)
94
+ setMode((before.mode & 0o7777) | bits);
95
+ try {
96
+ const assertNamedEntry = () => {
97
+ const named = lstatSync(path);
98
+ if (named.dev !== before.dev || named.ino !== before.ino || named.uid !== before.uid
99
+ || named.isSymbolicLink() || named.isDirectory() !== before.isDirectory())
100
+ fail(`entry changed during permission repair ${path}`);
101
+ };
102
+ assertNamedEntry();
103
+ const result = work(process.platform === 'linux' ? `/proc/self/fd/${descriptor}` : path);
104
+ assertNamedEntry();
105
+ return result;
106
+ }
107
+ catch (error) {
108
+ if (rollback && changed)
109
+ setMode(before.mode & 0o7777);
110
+ throw error;
111
+ }
112
+ }
113
+ finally {
114
+ closeSync(descriptor);
115
+ }
116
+ }
117
+ /** Caller must first prove the exact workspace marker or retired archive
118
+ * provenance, and hold its lifecycle lock with all managed writers retired.
119
+ * Directories need owner rwx for recursive removal; ordinary files do not.
120
+ */
121
+ export function prepareOwnedDeletionTree(path) {
122
+ assertSafeAncestors(path);
123
+ const uid = process.geteuid?.();
124
+ if (uid === undefined || lstatSync(path).uid !== uid)
125
+ fail(`deletion root is not owned by the current uid: ${path}`);
126
+ const device = lstatSync(dirname(path)).dev;
127
+ const visit = (dir) => {
128
+ const entry = lstatSync(dir);
129
+ if (!entry.isDirectory() || entry.isSymbolicLink())
130
+ fail(`unsafe deletion directory ${dir}`);
131
+ if (entry.dev !== device)
132
+ fail(`foreign mounted directory ${dir}`);
133
+ withOwnerPermissions(dir, entry, 0o700, accessDir => {
134
+ for (const child of readdirSync(accessDir, { withFileTypes: true })) {
135
+ if (process.platform !== 'linux') {
136
+ const named = lstatSync(dir);
137
+ if (named.dev !== entry.dev || named.ino !== entry.ino || named.isSymbolicLink())
138
+ fail(`deletion directory changed during traversal ${dir}`);
139
+ }
140
+ if (child.isDirectory() && !child.isSymbolicLink())
141
+ visit(join(accessDir, child.name));
142
+ }
143
+ });
144
+ };
145
+ visit(path);
146
+ }
147
+ function markedForDeletion(w) {
148
+ assertSafeAncestors(w.path);
149
+ const before = lstatSync(w.path);
150
+ if (process.geteuid?.() !== before.uid)
151
+ fail(`workspace root is not owned by the current uid: ${w.path}`);
152
+ if (before.dev !== lstatSync(dirname(w.path)).dev)
153
+ fail(`foreign mounted directory ${w.path}`);
154
+ // The durable exact path plus current-UID real root permit temporary owner
155
+ // access to read the marker, but never substitute for the marker itself.
156
+ withOwnerPermissions(w.path, before, 0o700, () => marked(w.path, w), true);
157
+ }
60
158
  /** Atomic directory publication; retry uses the durable token, never adopts another folder. */
61
159
  export function ensureWorkspace(w) {
62
160
  validateWorkspace(w);
@@ -105,45 +203,70 @@ function ownedPointer(base, from, value) {
105
203
  * All registrations and common dirs must be inside this workspace. Removing the complete
106
204
  * workspace then removes their registries atomically with the owned repositories; never prune.
107
205
  */
108
- export function auditWorkspaceGit(path) {
206
+ export function auditWorkspaceGit(path, options = {}) {
207
+ const rootEntry = lstatSync(path);
208
+ if (options.forDeletion)
209
+ prepareOwnedDeletionTree(path);
109
210
  const device = lstatSync(path).dev;
110
- const visit = (dir, admin = false) => {
111
- admin ||= Boolean(stat(join(dir, 'HEAD')) && (stat(join(dir, 'objects')) || stat(join(dir, 'commondir'))));
112
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
113
- const p = join(dir, entry.name);
114
- if (entry.isSymbolicLink()) {
115
- if (admin || entry.name === '.git' || entry.name === 'commondir' || entry.name === 'gitdir')
116
- fail(`symlink git metadata ${p}`);
117
- continue; // rm unlinks ordinary artifact symlinks, never follows them.
118
- }
119
- if (entry.isDirectory()) {
120
- if (lstatSync(p).dev !== device)
121
- fail(`foreign mounted directory ${p}`);
122
- visit(p, admin || entry.name === '.git');
123
- continue;
124
- }
125
- if (!entry.isFile())
126
- continue; // sockets/FIFOs in retired runtime state are unlinked, never opened
127
- if (entry.name === '.git') {
128
- const value = readFileSync(p, 'utf8').trim();
129
- if (!value.startsWith('gitdir: '))
130
- fail(`invalid git file ${p}`);
131
- const gitdir = ownedPointer(path, dir, value.slice(8));
132
- if (!stat(gitdir)?.isDirectory())
133
- fail(`missing git directory ${gitdir}`);
134
- }
135
- // Git admin files, including bare repositories and submodules.
136
- if (entry.name === 'commondir' && (admin || stat(join(dir, 'HEAD')))) {
137
- const common = ownedPointer(path, dir, readFileSync(p, 'utf8'));
138
- if (!stat(common)?.isDirectory())
139
- fail(`invalid git common directory ${common}`);
140
- }
141
- if (entry.name === 'gitdir' && (admin || stat(join(dir, 'commondir')))) {
142
- const target = ownedPointer(path, dir, readFileSync(p, 'utf8'));
143
- if (stat(target) && !stat(target)?.isFile())
144
- fail(`invalid worktree backlink ${target}`);
211
+ if (options.forDeletion && (lstatSync(path).ino !== rootEntry.ino || device !== rootEntry.dev || lstatSync(path).isSymbolicLink()))
212
+ fail(`workspace changed before Git audit ${path}`);
213
+ const readPointer = (file) => {
214
+ if (!options.forDeletion)
215
+ return readFileSync(file, 'utf8');
216
+ const before = lstatSync(file);
217
+ if (before.dev !== device)
218
+ fail(`foreign mounted Git metadata ${file}`);
219
+ return withOwnerPermissions(file, before, 0o400, accessFile => readFileSync(accessFile, 'utf8'));
220
+ };
221
+ const visit = (dir, accessDir = dir, admin = false) => {
222
+ const before = lstatSync(accessDir);
223
+ const inspect = (pinnedDir) => {
224
+ admin ||= Boolean(stat(join(pinnedDir, 'HEAD')) && (stat(join(pinnedDir, 'objects')) || stat(join(pinnedDir, 'commondir'))));
225
+ for (const entry of readdirSync(pinnedDir, { withFileTypes: true })) {
226
+ if (options.forDeletion && process.platform !== 'linux') {
227
+ const named = lstatSync(accessDir);
228
+ if (named.dev !== before.dev || named.ino !== before.ino || named.isSymbolicLink())
229
+ fail(`directory changed during Git audit ${dir}`);
230
+ }
231
+ const p = join(pinnedDir, entry.name);
232
+ if (entry.isSymbolicLink()) {
233
+ if (admin || entry.name === '.git' || entry.name === 'commondir' || entry.name === 'gitdir')
234
+ fail(`symlink git metadata ${p}`);
235
+ continue; // rm unlinks ordinary artifact symlinks, never follows them.
236
+ }
237
+ if (entry.isDirectory()) {
238
+ if (lstatSync(p).dev !== device)
239
+ fail(`foreign mounted directory ${p}`);
240
+ visit(join(dir, entry.name), p, admin || entry.name === '.git');
241
+ continue;
242
+ }
243
+ if (!entry.isFile())
244
+ continue; // sockets/FIFOs in retired runtime state are unlinked, never opened
245
+ if (entry.name === '.git') {
246
+ const value = readPointer(p).trim();
247
+ if (!value.startsWith('gitdir: '))
248
+ fail(`invalid git file ${p}`);
249
+ const gitdir = ownedPointer(path, dir, value.slice(8));
250
+ if (!stat(gitdir)?.isDirectory())
251
+ fail(`missing git directory ${gitdir}`);
252
+ }
253
+ // Git admin files, including bare repositories and submodules.
254
+ if (entry.name === 'commondir' && (admin || stat(join(pinnedDir, 'HEAD')))) {
255
+ const common = ownedPointer(path, dir, readPointer(p));
256
+ if (!stat(common)?.isDirectory())
257
+ fail(`invalid git common directory ${common}`);
258
+ }
259
+ if (entry.name === 'gitdir' && (admin || stat(join(pinnedDir, 'commondir')))) {
260
+ const target = ownedPointer(path, dir, readPointer(p));
261
+ if (stat(target) && !stat(target)?.isFile())
262
+ fail(`invalid worktree backlink ${target}`);
263
+ }
145
264
  }
146
- }
265
+ };
266
+ if (options.forDeletion)
267
+ withOwnerPermissions(accessDir, before, 0o700, inspect);
268
+ else
269
+ inspect(accessDir);
147
270
  };
148
271
  visit(path);
149
272
  }
@@ -156,8 +279,8 @@ export function assertWorkspaceDeletable(w, owner, id) {
156
279
  if (stat(w.path)) {
157
280
  if (stat(staging(w, 'deleting')))
158
281
  fail('both live and deleting workspace exist');
159
- marked(w.path, w);
160
- auditWorkspaceGit(w.path);
282
+ markedForDeletion(w);
283
+ auditWorkspaceGit(w.path, { forDeletion: true });
161
284
  }
162
285
  const pending = staging(w, 'provisioning');
163
286
  if (stat(pending)) {
@@ -177,8 +300,8 @@ export function deleteWorkspace(w, owner, id) {
177
300
  if (stat(w.path)) {
178
301
  if (stat(tomb))
179
302
  fail('both live and deleting workspace exist');
180
- marked(w.path, w);
181
- auditWorkspaceGit(w.path);
303
+ markedForDeletion(w);
304
+ auditWorkspaceGit(w.path, { forDeletion: true });
182
305
  renameSync(w.path, tomb);
183
306
  }
184
307
  if (stat(pending)) {
@@ -189,7 +312,9 @@ export function deleteWorkspace(w, owner, id) {
189
312
  rmSync(pending, { recursive: true });
190
313
  }
191
314
  if (stat(tomb)) {
192
- assertSafeAncestors(tomb);
315
+ // This exact token-derived tombstone has already passed the marker and
316
+ // Git audit; partial removal may have consumed those proofs on disk.
317
+ prepareOwnedDeletionTree(tomb);
193
318
  rmSync(tomb, { recursive: true });
194
319
  }
195
320
  }
package/dist/runner.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { StartupTelemetry } from './startup-telemetry.js';
2
+ import { managedStartupPrompt, managedTaskPrompt } from './startup-prompt.js';
1
3
  import { createLayoutControl } from './rooms-tasks/layout-control.js';
2
4
  import { TemporaryChatIdle } from './temp-idle.js';
3
5
  import { prepareManagedAgent, releaseManagedAgent } from './agent-ours/service.js';
@@ -469,6 +471,8 @@ export function isRecoverableTempStartupCancellation(temp, result) {
469
471
  /** One session lifecycle. `runSupervised` (or a one-shot caller) drives it. */
470
472
  export async function runOnce(name, opts = {}, partialDeps = {}) {
471
473
  const deps = { ...defaultDeps(), ...partialDeps };
474
+ const startupTelemetry = new StartupTelemetry(deps.now, line => deps.log(`[${name}] ${line}`));
475
+ startupTelemetry.mark('supervisor_started');
472
476
  const temp = opts.temp === true;
473
477
  const dir = agentDir(name, temp);
474
478
  const configPath = temp ? opts.configPath : resolveConfigPath(dir, opts.configPath);
@@ -572,7 +576,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
572
576
  if (rprefix.length)
573
577
  wrappedArgv = [...rprefix, ...wrappedArgv];
574
578
  }
579
+ startupTelemetry.mark('identity_prepare_started');
575
580
  const managedService = await deps.prepareAgentOurs(role, dir, opts.identityLifetime ? opts.identityLifetime === 'temporary' : temp);
581
+ startupTelemetry.mark('identity_ready');
576
582
  let layoutRetiring = false;
577
583
  try {
578
584
  const managedHarness = prepareManagedHarness(role, dir, runCwd, managedService.descriptor, { ...harnessChildEnv(role, launch.env, dir), ...subscription?.env });
@@ -591,10 +597,9 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
591
597
  }
592
598
  }
593
599
  // Prime the supervisor mail monitor's notification cursor at the
594
- // stream tip BEFORE the session launches so no arrival is missed during boot
595
- // (backlog before the tip is the SessionStart hook's job). Native-mode roles
596
- // leave wake ownership to the harness. Temp snapshots predating `monitor:` are
597
- // treated as native (monitor may be undefined on an old role.yaml).
600
+ // stream tip BEFORE the session launches so no arrival is missed during boot.
601
+ // Monitor.run checks the body-free initial unread summary after readiness;
602
+ // managed native hooks do not own backlog delivery.
598
603
  let sessionHandle;
599
604
  let modelRecovery;
600
605
  const resolvedMonitorDeps = monitorDeps(deps, role.env);
@@ -662,6 +667,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
662
667
  if (perms.unattended === 'deny')
663
668
  deps.log(`[${name}] permission policy: unattended=deny — with no console attached, ` +
664
669
  `permission requests are automatically denied once each (reject_once) and the turn continues`);
670
+ startupTelemetry.mark('harness_starting');
665
671
  agentSession = await managedService.runtime.startHarness(() => deps.startAgentSession(adapter.agentSession, {
666
672
  managedOurs: managedHarness.ours,
667
673
  role, prep,
@@ -669,6 +675,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
669
675
  cwd: runCwd, stateDir: dir, mode, permissions: perms,
670
676
  permissionMode: effectivePermissionMode(role), log: deps.log,
671
677
  }));
678
+ startupTelemetry.mark('harness_ready');
672
679
  pid = agentSession.pid;
673
680
  arbiter = new RoleTurnArbiter(agentSession);
674
681
  sessionHandle = arbiter;
@@ -679,6 +686,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
679
686
  if (evidence)
680
687
  resolvedMonitorDeps.onFailureEvidence?.(evidence);
681
688
  });
689
+ const unsubscribeTelemetry = agentSession.subscribeConversation?.(event => startupTelemetry.observe(event));
682
690
  const unsubscribeStartup = agentSession.subscribeConversation?.(event => {
683
691
  if (event.kind !== 'turn.completed' || !('outcome' in event.payload) || event.payload.outcome !== 'completed')
684
692
  return;
@@ -688,7 +696,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
688
696
  startMonitorAfterStartup();
689
697
  }
690
698
  });
691
- unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); };
699
+ unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); unsubscribeTelemetry?.(); };
692
700
  if (role.owner_channel) {
693
701
  try {
694
702
  ownerBinder = await deps.acquireOwnerBinder(dir, name, role.owner_channel.identity);
@@ -805,12 +813,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
805
813
  };
806
814
  },
807
815
  };
808
- const firstPrompt = mode === 'fresh'
809
- ? `Read and follow ${join(dir, 'briefing.md')} now.`
810
- : `Your supervisor has verified your assigned identity and room readiness. Read ${join(dir, 'WORKLOG.md')} and ${join(dir, 'briefing.md')}, then continue using the available ours tools.`;
816
+ const firstPrompt = managedStartupPrompt(dir, mode, booted);
811
817
  // Wait for the first turn's TERMINAL result. An agent that accepts the
812
818
  // startup prompt and then refuses it has not started; logging the role as
813
- // up would hide a role that never read its briefing.
819
+ // up would hide a role that never completed its readiness turn.
820
+ startupTelemetry.mark('startup_submitted');
814
821
  const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
815
822
  // Keep the early cursor baseline; do not deliver wakes into the protected
816
823
  // first turn. Steering can pre-empt startup just like cancellation.
@@ -835,6 +842,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
835
842
  }
836
843
  }
837
844
  const started = await starting;
845
+ startupTelemetry.finish();
838
846
  // A temporary role's first turn can be the active turn when an ours wake
839
847
  // needs immediate attention. A typed console/monitor cancellation ends
840
848
  // only that turn: the already-live agent session and any queued wake remain
@@ -875,6 +883,20 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
875
883
  + 'keeping temporary supervisor alive');
876
884
  startupRecoveryAllowed = interruptedForWake;
877
885
  sessionStartupComplete = started.succeeded || (interruptedForWake && successfulTurnObserved);
886
+ if (started.succeeded)
887
+ startupTelemetry.mark('readiness_turn_completed');
888
+ // Readiness is now complete. Configured work (or retained continuity) is
889
+ // admitted as a distinct turn; its terminal result must not block console
890
+ // readiness, mail delivery, or supervisor liveness. Empty fresh agents wait
891
+ // for authenticated console input or the monitor's unread/arrival wake.
892
+ if (started.succeeded && !deps.shouldStop?.() && (role.mission?.trim() || role.roomMemberStartup?.task?.trim() || role.briefing_file?.trim() || booted)) {
893
+ const queued = await arbiter.queuePrompt(managedTaskPrompt(dir, mode, booted), {
894
+ origin: { kind: 'startup' },
895
+ });
896
+ void queued.completion.then(result => {
897
+ deps.log(`[${name}] configured task turn ${result.outcome}`);
898
+ }, () => deps.log(`[${name}] configured task turn failed`));
899
+ }
878
900
  reloadLoopConfig = async () => {
879
901
  const nextRole = findRole(loadConfig(configPath), name);
880
902
  const definitions = nextRole.loops ?? [];
@@ -0,0 +1,4 @@
1
+ /** Readiness is a separate turn after identity/profile and room admission succeed. */
2
+ export declare function managedStartupPrompt(stateDir: string, mode: 'fresh' | 'resume', hasPriorBoot?: boolean): string;
3
+ /** Configured work/continuity runs after readiness, never inside the fast turn. */
4
+ export declare function managedTaskPrompt(stateDir: string, mode: 'fresh' | 'resume', hasPriorBoot?: boolean): string;
@@ -0,0 +1,36 @@
1
+ import { join } from 'node:path';
2
+ /** Readiness is a separate turn after identity/profile and room admission succeed. */
3
+ export function managedStartupPrompt(stateDir, mode, hasPriorBoot = false) {
4
+ const briefing = join(stateDir, 'briefing.md');
5
+ const continuity = mode === 'resume' || hasPriorBoot
6
+ ? ` For a later continuation task, read ${join(stateDir, 'WORKLOG.md')} once for continuity.`
7
+ : '';
8
+ return 'Fleet readiness check. The supervisor has verified your assigned identity, configured Role profile, '
9
+ + 'and any required room admission. Reply with a short final console acknowledgement that you are ready, '
10
+ + 'then end this turn. Announce readiness before any tool calls or file reads. '
11
+ + 'Do not read briefing, routines, worklog, inbox, history, help or config during this readiness turn; '
12
+ + 'do not perform identity/profile setup or broad tool discovery. Do not claim task work has been done. '
13
+ + 'The supervisor delivers configured work separately; otherwise wait for a concrete task or supervisor wake. '
14
+ + `For a later task or mail wake, read ${briefing} once before acting and reuse it within that wake. `
15
+ + 'Read routines only for concrete work; an idle launch needs no detail reads. '
16
+ + 'The supervisor delivers initial unread mail and later arrivals as wakes; do not poll the inbox at startup.'
17
+ + continuity;
18
+ }
19
+ /** Configured work/continuity runs after readiness, never inside the fast turn. */
20
+ export function managedTaskPrompt(stateDir, mode, hasPriorBoot = false) {
21
+ const briefing = join(stateDir, 'briefing.md');
22
+ const continuity = mode === 'resume' || hasPriorBoot
23
+ ? ` Read ${join(stateDir, 'WORKLOG.md')} once for continuity.`
24
+ : '';
25
+ return `Read and follow ${briefing} for the assigned work. Readiness has already been announced; `
26
+ + 'this is a separate task turn, so do not repeat the readiness acknowledgement.' + continuity + ' '
27
+ + 'Identify the concrete assignment in the briefing or unfinished work in the continuation log. '
28
+ + 'A role charter or public bio alone is not a new task. If there is no concrete work, end this turn '
29
+ + 'and await a task without reading routines, inbox, history, or unrelated files. '
30
+ + 'For concrete work, read any existing routines once, reuse details already read in this wake, '
31
+ + 'acknowledge the actual task and next action through its assigned reply route, and proceed. '
32
+ + (mode === 'fresh' && !hasPriorBoot ? 'On a fresh launch, do not read a worklog for initialization. ' : '')
33
+ + 'Use inbox/history only when a mail wake, unread pagination, or the actual task needs them. '
34
+ + 'The supervisor delivers initial unread mail; no model-owned startup poll is needed. '
35
+ + 'Identity/profile setup, help/config exploration and broad tool discovery are not startup rituals.';
36
+ }
@@ -0,0 +1,16 @@
1
+ import type { ConversationEventV1 } from './session/conversation-types.js';
2
+ /** Body-free startup diagnostics. First output is an observation, not proof of useful work. */
3
+ export declare class StartupTelemetry {
4
+ private readonly now;
5
+ private readonly log;
6
+ private readonly started;
7
+ private promptAt?;
8
+ private promptId?;
9
+ private firstOutput;
10
+ private completed;
11
+ private readonly tools;
12
+ constructor(now: () => number, log: (line: string) => void);
13
+ mark(stage: 'supervisor_started' | 'identity_prepare_started' | 'identity_ready' | 'harness_starting' | 'harness_ready' | 'startup_submitted' | 'readiness_turn_completed'): void;
14
+ observe(event: ConversationEventV1): void;
15
+ finish(): void;
16
+ }
@@ -0,0 +1,46 @@
1
+ /** Body-free startup diagnostics. First output is an observation, not proof of useful work. */
2
+ export class StartupTelemetry {
3
+ now;
4
+ log;
5
+ started;
6
+ promptAt;
7
+ promptId;
8
+ firstOutput = false;
9
+ completed = false;
10
+ tools = new Set();
11
+ constructor(now, log) {
12
+ this.now = now;
13
+ this.log = log;
14
+ this.started = now();
15
+ }
16
+ mark(stage) {
17
+ const at = this.now();
18
+ if (stage === 'startup_submitted')
19
+ this.promptAt = at;
20
+ this.log(`startup stage=${stage} elapsed_ms=${Math.max(0, at - this.started)}`);
21
+ }
22
+ observe(event) {
23
+ if (this.promptAt === undefined || this.completed || event.source === 'agent_replay')
24
+ return;
25
+ if (event.kind === 'prompt.started' && event.source === 'startup')
26
+ this.promptId = event.promptId;
27
+ if (this.promptId && event.promptId && event.promptId !== this.promptId)
28
+ return;
29
+ if (event.kind === 'tool.upsert' && 'toolCallId' in event.payload && event.payload.toolCallId)
30
+ this.tools.add(event.payload.toolCallId);
31
+ if (!this.firstOutput && ['message.chunk', 'message.replace'].includes(event.kind)
32
+ && 'role' in event.payload && event.payload.role === 'assistant'
33
+ && 'content' in event.payload && event.payload.content.type === 'text'
34
+ && event.payload.content.text.trim()) {
35
+ this.firstOutput = true;
36
+ const at = this.now();
37
+ this.log(`startup stage=first_assistant_output elapsed_ms=${Math.max(0, at - this.started)} model_and_tools_ms=${Math.max(0, at - this.promptAt)} tools=${this.tools.size}`);
38
+ }
39
+ }
40
+ finish() {
41
+ if (this.completed)
42
+ return;
43
+ this.completed = true;
44
+ this.log(`startup stage=turn_settled elapsed_ms=${Math.max(0, this.now() - this.started)} tools=${this.tools.size} first_output=${this.firstOutput}`);
45
+ }
46
+ }
@@ -170,6 +170,9 @@ export function createPrefixGateway(options) {
170
170
  Object.assign(headers, cors);
171
171
  }
172
172
  res.writeHead(response.statusCode ?? 502, headers);
173
+ // Streaming headers, including exact-origin CORS and upstream errors,
174
+ // must reach the client before the first event or body byte arrives.
175
+ res.flushHeaders();
173
176
  response.on('error', () => res.destroy());
174
177
  response.on('aborted', () => res.destroy());
175
178
  response.on('close', () => { if (!response.complete)
@@ -80,6 +80,37 @@ any remaining original archive and still refuse replacement live member state
80
80
  or a recreated identity even after the archive has been consumed. Failed deletion
81
81
  remains retryable with its recorded error.
82
82
 
83
+ Explicit deletion restores missing owner read/write/traversal bits on real
84
+ directories inside a proven owned workspace or retired archive before the Git
85
+ audit and removal. This handles restrictive artifacts left by tests even when
86
+ they belong to the same Unix user. It adds no group/other access, follows no
87
+ artifact symlinks, crosses no mounted device boundary, and never changes the
88
+ owner. Only Git pointer files that the audit must read may gain owner-read;
89
+ hardlinked unreadable pointers are refused to avoid changing an outside alias.
90
+ Permission changes target a verified open inode. Readable foreign-UID directories
91
+ may be traversed unchanged; missing owner access is never repaired for another
92
+ UID. On Linux, traversal and Git pointer reads use pinned directory/file
93
+ descriptors, and a detected pathname replacement stops cleanup before descending.
94
+ Unreadable entries
95
+ use an `O_PATH` descriptor through `/proc/self/fd`; other platforms retain the
96
+ error if they cannot open the entry safely. A restricted workspace root is
97
+ temporarily accessible to verify its exact ownership marker; failed marker
98
+ verification restores its original mode. Partial token-derived deletion
99
+ tombstones resume directory cleanup without re-reading already-removed Git
100
+ metadata. List, show, and ordinary Git audit do not repair permissions.
101
+
102
+ An accepted deletion is hidden from ordinary task lists while cleanup settles.
103
+ Use `ours-fleet task list --include-deleting --json` (or the API's
104
+ `includeDeleting=true`) to include retained cleanup intents, and `task show ID`
105
+ to see pending/error/retry information. Disappearance from the ordinary list is
106
+ not proof of completed deletion; successful deletion removes the task record
107
+ after workspace and member cleanup.
108
+ Cowork HTTP transport failures record the RPC method, failing stage and a
109
+ bounded error category in the cleanup intent. Credential rejection remains an
110
+ explicit `unauthorized` protocol error. Transport diagnostics do not include
111
+ request bodies, credentials, URLs or raw exception text, and failed mutations
112
+ are never automatically replayed.
113
+
83
114
  Deletion rejects traversal, foreign recorded paths, changed markers and symlinked
84
115
  workspace ancestors. Ordinary artifact symlinks are unlinked without following
85
116
  their targets. Git control symlinks and foreign pointers are refused. As with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.51",
3
+ "version": "1.2.0-nightly.53",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, managed native/ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",