@ours.network/fleet 1.2.0-nightly.52 → 1.2.0-nightly.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/README.md +2 -0
  2. package/dist/agent-ours/bridge.js +10 -3
  3. package/dist/agent-ours/file-rpc.d.ts +8 -0
  4. package/dist/agent-ours/file-rpc.js +11 -1
  5. package/dist/agent-ours/harness.js +47 -3
  6. package/dist/agent-ours/mcp-endpoint.d.ts +5 -0
  7. package/dist/agent-ours/mcp-endpoint.js +4 -0
  8. package/dist/agent-ours/runtime.d.ts +3 -1
  9. package/dist/agent-ours/runtime.js +14 -2
  10. package/dist/agent-ours/service.d.ts +2 -0
  11. package/dist/agent-ours/service.js +44 -2
  12. package/dist/application/conversation-history.d.ts +2 -0
  13. package/dist/application/conversation-history.js +25 -2
  14. package/dist/application/supervisor-ours-tools.js +3 -1
  15. package/dist/briefing.d.ts +1 -1
  16. package/dist/briefing.js +52 -29
  17. package/dist/build-info.json +4 -4
  18. package/dist/config.d.ts +9 -0
  19. package/dist/config.js +13 -2
  20. package/dist/file-delivery/reader.d.ts +17 -0
  21. package/dist/file-delivery/reader.js +94 -0
  22. package/dist/file-delivery/store.d.ts +15 -0
  23. package/dist/file-delivery/store.js +124 -0
  24. package/dist/file-delivery/types.d.ts +19 -0
  25. package/dist/file-delivery/types.js +1 -0
  26. package/dist/harness/claude-code.js +11 -10
  27. package/dist/harness/codex-app-server-proxy.js +6 -2
  28. package/dist/harness/codex-file-inventory.d.ts +15 -0
  29. package/dist/harness/codex-file-inventory.js +113 -0
  30. package/dist/harness/codex.js +9 -5
  31. package/dist/isolation/policy.js +4 -1
  32. package/dist/monitor.d.ts +7 -0
  33. package/dist/monitor.js +65 -1
  34. package/dist/rooms-tasks/cli.js +12 -2
  35. package/dist/rooms-tasks/cowork-http.js +29 -1
  36. package/dist/rooms-tasks/erasure.js +5 -3
  37. package/dist/rooms-tasks/workspace-artifacts.js +1 -1
  38. package/dist/rooms-tasks/workspace.d.ts +8 -1
  39. package/dist/rooms-tasks/workspace.js +168 -43
  40. package/dist/runner.js +32 -9
  41. package/dist/session/acp.d.ts +4 -0
  42. package/dist/session/acp.js +61 -8
  43. package/dist/session/conversation-types.d.ts +4 -2
  44. package/dist/session/types.d.ts +1 -0
  45. package/dist/startup-prompt.d.ts +4 -0
  46. package/dist/startup-prompt.js +36 -0
  47. package/dist/startup-telemetry.d.ts +16 -0
  48. package/dist/startup-telemetry.js +46 -0
  49. package/dist/web/server.js +24 -0
  50. package/docs/acp-files.md +102 -0
  51. package/docs/task-workspaces.md +31 -0
  52. package/package.json +4 -3
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,8 @@ 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
+ managedService.setFileDelivery?.(agentSession.sendFileToChat?.bind(agentSession));
679
+ startupTelemetry.mark('harness_ready');
672
680
  pid = agentSession.pid;
673
681
  arbiter = new RoleTurnArbiter(agentSession);
674
682
  sessionHandle = arbiter;
@@ -679,6 +687,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
679
687
  if (evidence)
680
688
  resolvedMonitorDeps.onFailureEvidence?.(evidence);
681
689
  });
690
+ const unsubscribeTelemetry = agentSession.subscribeConversation?.(event => startupTelemetry.observe(event));
682
691
  const unsubscribeStartup = agentSession.subscribeConversation?.(event => {
683
692
  if (event.kind !== 'turn.completed' || !('outcome' in event.payload) || event.payload.outcome !== 'completed')
684
693
  return;
@@ -688,7 +697,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
688
697
  startMonitorAfterStartup();
689
698
  }
690
699
  });
691
- unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); };
700
+ unsubscribeRecovery = () => { unsubscribeFailure(); unsubscribeStartup?.(); unsubscribeTelemetry?.(); };
692
701
  if (role.owner_channel) {
693
702
  try {
694
703
  ownerBinder = await deps.acquireOwnerBinder(dir, name, role.owner_channel.identity);
@@ -805,12 +814,11 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
805
814
  };
806
815
  },
807
816
  };
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.`;
817
+ const firstPrompt = managedStartupPrompt(dir, mode, booted);
811
818
  // Wait for the first turn's TERMINAL result. An agent that accepts the
812
819
  // startup prompt and then refuses it has not started; logging the role as
813
- // up would hide a role that never read its briefing.
820
+ // up would hide a role that never completed its readiness turn.
821
+ startupTelemetry.mark('startup_submitted');
814
822
  const starting = arbiter.submitPrompt(firstPrompt, { origin: { kind: 'startup' } });
815
823
  // Keep the early cursor baseline; do not deliver wakes into the protected
816
824
  // first turn. Steering can pre-empt startup just like cancellation.
@@ -835,6 +843,7 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
835
843
  }
836
844
  }
837
845
  const started = await starting;
846
+ startupTelemetry.finish();
838
847
  // A temporary role's first turn can be the active turn when an ours wake
839
848
  // needs immediate attention. A typed console/monitor cancellation ends
840
849
  // only that turn: the already-live agent session and any queued wake remain
@@ -875,6 +884,20 @@ export async function runOnce(name, opts = {}, partialDeps = {}) {
875
884
  + 'keeping temporary supervisor alive');
876
885
  startupRecoveryAllowed = interruptedForWake;
877
886
  sessionStartupComplete = started.succeeded || (interruptedForWake && successfulTurnObserved);
887
+ if (started.succeeded)
888
+ startupTelemetry.mark('readiness_turn_completed');
889
+ // Readiness is now complete. Configured work (or retained continuity) is
890
+ // admitted as a distinct turn; its terminal result must not block console
891
+ // readiness, mail delivery, or supervisor liveness. Empty fresh agents wait
892
+ // for authenticated console input or the monitor's unread/arrival wake.
893
+ if (started.succeeded && !deps.shouldStop?.() && (role.mission?.trim() || role.roomMemberStartup?.task?.trim() || role.briefing_file?.trim() || booted)) {
894
+ const queued = await arbiter.queuePrompt(managedTaskPrompt(dir, mode, booted), {
895
+ origin: { kind: 'startup' },
896
+ });
897
+ void queued.completion.then(result => {
898
+ deps.log(`[${name}] configured task turn ${result.outcome}`);
899
+ }, () => deps.log(`[${name}] configured task turn failed`));
900
+ }
878
901
  reloadLoopConfig = async () => {
879
902
  const nextRole = findRole(loadConfig(configPath), name);
880
903
  const definitions = nextRole.loops ?? [];