@ours.network/fleet 1.2.0-nightly.56 → 1.2.0-nightly.58

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.
@@ -1,5 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { CodexFileInventory } from './codex-file-inventory.js';
3
2
  import { spawn } from 'node:child_process';
4
3
  import { createRequire } from 'node:module';
5
4
  import { createInterface } from 'node:readline';
@@ -447,7 +446,6 @@ export function runCodexAppServerProxy() {
447
446
  });
448
447
  }
449
448
  };
450
- const inventory = process.env.FLEET_CURRENT_CHAT_FILES === '1' ? new CodexFileInventory(writeToChild, writeToClient) : undefined;
451
449
  const recovery = new CodexTerminalRecovery({
452
450
  sendToCodex: writeToChild,
453
451
  emitToClient: writeToClient,
@@ -458,7 +456,6 @@ export function runCodexAppServerProxy() {
458
456
  return;
459
457
  finished = true;
460
458
  recovery.close();
461
- inventory?.close();
462
459
  input.close();
463
460
  process.stdin.destroy();
464
461
  // stdout/stderr are often pipes under Fleet and tests. Setting exitCode
@@ -491,11 +488,10 @@ export function runCodexAppServerProxy() {
491
488
  return;
492
489
  const rewritten = rewriteCodexAppServerRequest(line, approval, expectedSandbox, disableInheritedMcp, managedOursTools);
493
490
  recovery.observeClientLine(rewritten);
494
- if (!inventory || inventory.observeClientLine(rewritten))
495
- writeToChild(rewritten);
491
+ writeToChild(rewritten);
496
492
  });
497
493
  output.on('line', line => {
498
- if (!finished && (!inventory || inventory.observeServerLine(line)) && recovery.observeServerLine(line))
494
+ if (!finished && recovery.observeServerLine(line))
499
495
  writeToClient(line);
500
496
  });
501
497
  input.once('close', () => {
@@ -262,7 +262,6 @@ function codexAcpEnvironment(role, dirs) {
262
262
  if (!source)
263
263
  throw new Error('Codex app-server proxy is missing; rebuild ours-fleet');
264
264
  writeFileSync(proxyModule, readFileSync(source, 'utf8'), { mode: 0o600 });
265
- writeFileSync(join(runtimeDir, 'codex-file-inventory.js'), readFileSync(join(dirname(source), 'codex-file-inventory.js'), 'utf8'), { mode: 0o600 });
266
265
  const windows = process.platform === 'win32';
267
266
  const command = join(runtimeDir, windows ? 'codex-app-server-proxy.cmd' : 'codex-app-server-proxy');
268
267
  const script = windows
@@ -483,7 +482,7 @@ export function makeCodexAdapter(exec = realExec, transport, nativeTransport) {
483
482
  catch (error) {
484
483
  throw new Error(`Codex ACP runtime check failed: ${error.message}; check CODEX_PATH or reinstall Fleet with optional dependencies`);
485
484
  }
486
- const minimum = role.file_delivery?.enabled ? '0.159.0' : role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
485
+ const minimum = role.model === 'gpt-6-astra' || resolution.version === '1.10.0'
487
486
  ? '0.153.3' : '0.145.0';
488
487
  if (!codexVersionAtLeast(runtime.version, minimum))
489
488
  throw new Error(`${role.model ?? 'Codex ACP'} requires a newer Codex (>=${minimum}): ACP selects ${runtime.executable} (${runtime.version}); upgrade Fleet or set CODEX_PATH to a supported executable in the agent/service environment`);
@@ -0,0 +1,18 @@
1
+ import type { ProducerConfig } from './outbox.js';
2
+ /** The Fleet manager owns this sender even when the web console is stopped.
3
+ * Events are already durable in their owning task commit, so no second outbox
4
+ * admission/crash window is needed. A failed task does not block other tasks. */
5
+ export declare class TaskNotificationProducer {
6
+ private readonly config;
7
+ private readonly warn;
8
+ private readonly request;
9
+ private readonly timer;
10
+ private running?;
11
+ private offset;
12
+ private stopped;
13
+ private readonly shutdown;
14
+ constructor(config: ProducerConfig, warn?: (line: string) => void, request?: typeof fetch);
15
+ drain(): Promise<void>;
16
+ private drainOnce;
17
+ close(): Promise<void>;
18
+ }
@@ -0,0 +1,67 @@
1
+ import { acknowledgeTaskNotification, pendingTaskNotifications } from '../rooms-tasks/task-state.js';
2
+ /** The Fleet manager owns this sender even when the web console is stopped.
3
+ * Events are already durable in their owning task commit, so no second outbox
4
+ * admission/crash window is needed. A failed task does not block other tasks. */
5
+ export class TaskNotificationProducer {
6
+ config;
7
+ warn;
8
+ request;
9
+ timer;
10
+ running;
11
+ offset = 0;
12
+ stopped = false;
13
+ shutdown = new AbortController();
14
+ constructor(config, warn = () => { }, request = fetch) {
15
+ this.config = config;
16
+ this.warn = warn;
17
+ this.request = request;
18
+ this.timer = setInterval(() => { void this.drain(); }, 3000);
19
+ this.timer.unref();
20
+ void this.drain();
21
+ }
22
+ drain() {
23
+ return this.running ??= this.drainOnce().catch(() => {
24
+ this.warn('Task notification snapshot unavailable; committed events retained');
25
+ }).finally(() => { this.running = undefined; });
26
+ }
27
+ async drainOnce() {
28
+ if (this.stopped)
29
+ return;
30
+ const pending = pendingTaskNotifications(this.warn);
31
+ if (!pending.length)
32
+ return;
33
+ const start = this.offset % pending.length;
34
+ const batch = [...pending.slice(start), ...pending.slice(0, start)].slice(0, 32);
35
+ this.offset = start + batch.length;
36
+ for (let index = 0; index < batch.length; index += 4) {
37
+ if (this.stopped)
38
+ break;
39
+ await Promise.all(batch.slice(index, index + 4).map(async (task) => {
40
+ for (const event of task.events.slice(0, 32)) {
41
+ if (this.stopped)
42
+ break;
43
+ try {
44
+ const response = await this.request(this.config.origin + '/api/v1/send', {
45
+ method: 'POST', redirect: 'error', signal: AbortSignal.any([this.shutdown.signal, AbortSignal.timeout(10_000)]),
46
+ headers: { ...(this.config.gatewayCredential
47
+ ? { 'X-Ours-Api-Token': this.config.gatewayCredential, 'X-Ours-Notifications-Producer': this.config.token }
48
+ : { Authorization: `Bearer ${this.config.token}` }), 'Content-Type': 'application/json' },
49
+ body: JSON.stringify(event),
50
+ });
51
+ await response.body?.cancel();
52
+ if (!response.ok) {
53
+ this.warn(`Task notification delivery pending (HTTP ${response.status}); committed event retained`);
54
+ break;
55
+ }
56
+ acknowledgeTaskNotification(task.taskId, event.eventId);
57
+ }
58
+ catch {
59
+ this.warn('Task notification delivery pending; committed event retained');
60
+ break;
61
+ }
62
+ }
63
+ }));
64
+ }
65
+ }
66
+ async close() { this.stopped = true; clearInterval(this.timer); this.shutdown.abort(); await this.running; }
67
+ }
@@ -1,8 +1,15 @@
1
1
  import type { ArchivedMemberAbsence } from './types.js';
2
- import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase } from './types.js';
2
+ import type { TaskRecord, TaskState, TaskOrigin, TaskTemplateRef, TaskOutcome, TaskMemberRole, TaskTerminalIntent, TaskDeletionActor, TaskDeletionMemberPhase, TaskNotificationEvent } from './types.js';
3
3
  export declare const tasksDir: () => string;
4
4
  export declare class TaskStateError extends Error {
5
5
  }
6
+ /** Atomic snapshots contain the events, including intermediate states between polls. */
7
+ export declare function pendingTaskNotifications(warn?: (line: string) => void): Array<{
8
+ taskId: string;
9
+ events: TaskNotificationEvent[];
10
+ }>;
11
+ /** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
12
+ export declare function acknowledgeTaskNotification(id: string, eventId: string): void;
6
13
  /**
7
14
  * Accepted deletion is the per-task epoch guard: once pending, every lifecycle
8
15
  * mutation, terminal settlement, and room publication must fail boundedly.
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync, rmSync, renameSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
4
  import { replaceFileAtomically } from '../atomic-file.js';
@@ -89,8 +89,77 @@ function writeTask(record) {
89
89
  mkdirSync(tasksDir(), { recursive: true });
90
90
  const persisted = { ...record };
91
91
  delete persisted.list_name;
92
+ const path = taskPath(record.task_id);
93
+ const previous = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined;
94
+ const status = (task) => task.deletion?.status === 'pending' ? 'deleting' : task.blocked ? `${task.state} (blocked)` : task.state;
95
+ // Read pending events under the same task lock as every mutation. Metadata edits
96
+ // and retries preserve the queue; acknowledgement cannot be resurrected by stale input.
97
+ const pending = [...(previous?.notification_events ?? [])];
98
+ if (!previous || status(previous) !== status(record)) {
99
+ const next = status(record), before = previous ? status(previous) : undefined;
100
+ pending.push({ eventId: `task:${record.task_id}:${randomUUID()}`, taskId: record.task_id,
101
+ title: `Task ${!previous ? 'created' : 'updated'}: ${record.title}`.slice(0, 160),
102
+ body: `Task ${record.task_id}: ${before ? `${before} → ` : 'created in '}${next}.`,
103
+ url: `/fleet/tasks/${record.task_id}` });
104
+ }
105
+ if (pending.length)
106
+ persisted.notification_events = pending;
107
+ else
108
+ delete persisted.notification_events;
92
109
  replaceFileAtomically(taskPath(record.task_id), JSON.stringify(persisted, null, 2) + '\n');
93
110
  }
111
+ const retiredNotificationsDir = () => join(tasksDir(), '.notification-retired');
112
+ /** Atomic snapshots contain the events, including intermediate states between polls. */
113
+ export function pendingTaskNotifications(warn = () => { }) {
114
+ const result = [];
115
+ for (const dir of [tasksDir(), retiredNotificationsDir()]) {
116
+ if (!existsSync(dir))
117
+ continue;
118
+ for (const file of readdirSync(dir).filter(file => TASK_ID_PATTERN.test(file.slice(0, -5)) && file.endsWith('.json'))) {
119
+ try {
120
+ const path = join(dir, file), stat = lstatSync(path);
121
+ if (!stat.isFile() || stat.isSymbolicLink())
122
+ throw new Error('invalid notification source');
123
+ const value = JSON.parse(readFileSync(path, 'utf8'));
124
+ const id = file.slice(0, -5);
125
+ // A prepared deletion copy has no authority while the original still exists.
126
+ if (dir === retiredNotificationsDir() && existsSync(taskPath(id)))
127
+ continue;
128
+ if (value.notification_events === undefined)
129
+ continue;
130
+ if (!Array.isArray(value.notification_events) || value.notification_events.some(e => !e
131
+ || typeof e.eventId !== 'string' || !e.eventId.startsWith(`task:${id}:`) || e.eventId.length > 512
132
+ || e.taskId !== id || (e.url !== `/fleet/tasks/${id}` && !(e.eventId === `task:${id}:deleted` && e.url === `/fleet/tasks?deletedTask=${id}`)) || typeof e.title !== 'string' || !e.title.trim() || e.title.length > 160
133
+ || typeof e.body !== 'string' || !e.body.trim() || e.body.length > 512))
134
+ throw new Error('invalid notification events');
135
+ if (value.notification_events.length)
136
+ result.push({ taskId: id, events: value.notification_events });
137
+ }
138
+ catch (error) {
139
+ if (!isNotFoundError(error))
140
+ warn('Task notification record unavailable; other task deliveries continue');
141
+ }
142
+ }
143
+ }
144
+ return result;
145
+ }
146
+ /** Service event-id dedupe covers acceptance followed by a crash before this acknowledgement. */
147
+ export function acknowledgeTaskNotification(id, eventId) {
148
+ assertCanonicalTaskId(id);
149
+ withTaskLock(id, () => {
150
+ for (const path of [taskPath(id), join(retiredNotificationsDir(), `${id}.json`)]) {
151
+ if (!existsSync(path))
152
+ continue;
153
+ const value = JSON.parse(readFileSync(path, 'utf8'));
154
+ if (!value.notification_events?.some(event => event.eventId === eventId))
155
+ continue;
156
+ value.notification_events = value.notification_events.filter(event => event.eventId !== eventId);
157
+ replaceFileAtomically(path, JSON.stringify(value, null, 2) + '\n');
158
+ if (path.startsWith(retiredNotificationsDir() + '/') && !value.notification_events.length)
159
+ unlinkSync(path);
160
+ }
161
+ });
162
+ }
94
163
  function presentTask(record) {
95
164
  const listId = record.list_id ?? DEFAULT_TASK_LIST_ID;
96
165
  const list = readTaskLists().find(item => item.list_id === listId);
@@ -812,6 +881,12 @@ export function unlinkDeletedTask(id) {
812
881
  }
813
882
  if (stored.deletion?.status !== 'pending')
814
883
  throw new TaskStateError(`task ${id} has no pending deletion`);
884
+ // Preserve committed deliveries outside the record before deletion. A crash
885
+ // before unlink may replay two copies; both carry identical service dedupe IDs.
886
+ const removed = { eventId: `task:${id}:deleted`, taskId: id,
887
+ title: `Task deleted: ${stored.title}`.slice(0, 160), body: `Task ${id}: deleting → deleted.`,
888
+ url: `/fleet/tasks?deletedTask=${id}` };
889
+ replaceFileAtomically(join(retiredNotificationsDir(), `${id}.json`), JSON.stringify({ notification_events: [...(stored.notification_events ?? []), removed] }) + '\n');
815
890
  try {
816
891
  unlinkSync(taskPath(id));
817
892
  }
@@ -106,7 +106,16 @@ export interface TaskLayoutLink {
106
106
  /** sha256 of the canonical source layout definition the run must snapshot. */
107
107
  definition_hash: string;
108
108
  }
109
+ export interface TaskNotificationEvent {
110
+ eventId: string;
111
+ title: string;
112
+ body: string;
113
+ url: string;
114
+ taskId: string;
115
+ }
109
116
  export interface TaskRecord {
117
+ /** Pending lifecycle events, committed atomically with their task change. */
118
+ notification_events?: TaskNotificationEvent[];
110
119
  workspace?: import('./workspace.js').OwnedWorkspace;
111
120
  task_id: string;
112
121
  /** Stable organizational list identifier. Missing legacy values mean `default`. */
@@ -2,6 +2,8 @@ import { spawn } from 'node:child_process';
2
2
  import { closeSync, existsSync, openSync, rmSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { randomUUID } from 'node:crypto';
5
+ import { producerConfig } from '../notifications/outbox.js';
6
+ import { TaskNotificationProducer } from '../notifications/task-producer.js';
5
7
  import { acquireOwnerBinderLease } from '../owner-channel/binder.js';
6
8
  import { stateRoot } from '../paths.js';
7
9
  import { readRestartLedger, writeRestartLedger, backoffFor, RESTART_FAIL_THRESHOLD } from '../runner.js';
@@ -34,6 +36,7 @@ export async function runFleetManager(entrypoint, deps = {}) {
34
36
  closeSync(fd);
35
37
  }
36
38
  });
39
+ let notifications;
37
40
  async function reconcile(key) {
38
41
  await withMemberLock(key, async () => {
39
42
  const member = readMember(key);
@@ -155,6 +158,14 @@ export async function runFleetManager(entrypoint, deps = {}) {
155
158
  });
156
159
  }
157
160
  try {
161
+ try {
162
+ const config = producerConfig(process.env, undefined, log);
163
+ if (config)
164
+ notifications = new TaskNotificationProducer(config, log);
165
+ }
166
+ catch {
167
+ log('[fleet] task notifications unavailable; committed events retained');
168
+ }
158
169
  try {
159
170
  const { resumeFleetTransfers } = await import('./adoption.js');
160
171
  await resumeFleetTransfers(entrypoint, exec);
@@ -178,6 +189,7 @@ export async function runFleetManager(entrypoint, deps = {}) {
178
189
  }
179
190
  finally {
180
191
  stopping = true;
192
+ await notifications?.close();
181
193
  // Shutdown preserves desired intent; the successor resumes the same catalog.
182
194
  const deadline = Date.now() + 17_000;
183
195
  while (children.size && Date.now() < deadline) {
package/docs/acp-files.md CHANGED
@@ -1,22 +1,24 @@
1
1
  # Sending files in an ACP chat
2
2
 
3
- An opted-in managed Agent uses the existing Ours `send_file` tool:
3
+ Every managed Agent can attach a file to its current ACP chat with the existing
4
+ Ours `send_file` tool. No Agent setting turns this on and no export directory is
5
+ configured or created:
4
6
 
5
7
  ```json
6
- {"path":"deliverables/report.pdf"}
8
+ {"path":"/home/me/project/build/report.pdf"}
7
9
  ```
8
10
 
9
11
  Omitting `contact` sends to the current ACP chat. Supplying a non-empty contact
10
12
  keeps ordinary Ours delivery:
11
13
 
12
14
  ```json
13
- {"contact":"Alice","path":"deliverables/report.pdf"}
15
+ {"contact":"Alice","path":"build/report.pdf"}
14
16
  ```
15
17
 
16
18
  Only an absent contact chooses the chat. Null, empty or whitespace-only contacts,
17
- unknown fields and unknown recipients fail without falling back to chat. Outside
18
- opt-in, contact remains required. Current-chat sending accepts `path`, optional
19
- `filename` and `mime`; inline base64 and Ours reply fields are unsupported.
19
+ unknown fields and unknown recipients fail without falling back to chat.
20
+ Current-chat sending accepts `path`, optional `filename` and `mime`; inline
21
+ base64 and Ours reply fields are unsupported.
20
22
 
21
23
  Each call makes one attempt. Failures are returned to the agent. There is no
22
24
  request ID, deduplication, automatic retry or recovery of failed sends. If a user
@@ -24,52 +26,84 @@ asks to resend, another tool call makes a new attempt and can produce another
24
26
  attachment. UUIDs identify stored attachments; the stored SHA-256 checks integrity.
25
27
  Neither is a retry key.
26
28
 
27
- Set this on the Agent definition, not manifest defaults or a Brain:
29
+ ## Paths
30
+
31
+ The Agent passes the path of the file it wants to send. Relative paths resolve
32
+ from the Agent's working directory; `..`, absolute paths, hidden directories and
33
+ symlinked parent directories are all accepted. The file does not have to be
34
+ inside the working directory or any particular subtree. It is opened by the
35
+ Fleet bridge inside the Agent's own OS sandbox, so the Agent can send exactly
36
+ what it can already read.
37
+
38
+ The file itself must pass ordinary integrity checks, each with its own result:
39
+
40
+ | Result | Meaning |
41
+ | --- | --- |
42
+ | `CHAT_FILE_NOT_FOUND` | nothing exists at the path |
43
+ | `CHAT_FILE_NOT_READABLE` | the Agent's OS user or sandbox cannot read it |
44
+ | `CHAT_FILE_IS_SYMLINK` | the named file is itself a symlink; pass its target |
45
+ | `CHAT_FILE_HARDLINKED` | the file has more than one hard link |
46
+ | `CHAT_FILE_NOT_REGULAR` | a directory, FIFO, socket or device |
47
+ | `FILE_EXCEEDS_20_MIB` | larger than the 20 MiB limit |
48
+ | `CHAT_FILE_CHANGED` / `SOURCE_SIZE_CHANGED` | modified while it was being read; finish writing first |
49
+ | `CHAT_FILE_REQUIRES_LINUX` | the Agent's bridge is not running on Linux |
50
+ | `PRIVATE_STATE_CANNOT_BE_SENT` | Fleet's own credentials, connector profile or stored attachments |
51
+
52
+ `PRIVATE_STATE_CANNOT_BE_SENT` covers only Fleet's private Ours runtime
53
+ directory, its attachment store and the connector profile and credential files,
54
+ checked on the provided path before approval and on the opened file's real path
55
+ afterwards. It is not configurable and is not a general path policy.
56
+
57
+ ## Supported sessions
58
+
59
+ Current-chat delivery needs Linux, the managed Ours connector and a Codex or
60
+ Claude ACP session with a tracked active turn. Those Agents need no special
61
+ `harness_options`, MCP restriction or clean configuration home: inherited and
62
+ additional MCP connectors stay as they are, and Fleet's bound bridge is the
63
+ `ours` connector as it already is for every managed Agent.
64
+
65
+ Any other backend still sees the optional `contact`, and omitting it returns a
66
+ precise result rather than a missing tool:
28
67
 
29
- ```yaml
30
- file_delivery:
31
- enabled: true
32
- directory: deliverables
33
68
  ```
69
+ CURRENT_CHAT_DELIVERY_UNAVAILABLE: sending a file to this chat requires a Codex or Claude ACP session; this Agent runs <harness>/<session>. Pass contact to send through Ours instead
70
+ ```
71
+
72
+ Idle, scheduled, steered, cancelled and replaced turns return
73
+ `CURRENT_CHAT_DELIVERY_UNAVAILABLE`; a second send while one is in progress
74
+ returns `FILE_DELIVERY_BUSY`.
75
+
76
+ A file is published only for a call that arrives over the Agent's
77
+ capability-authenticated bridge and binds to the turn that is live in that
78
+ Agent's own session. Connector inventory is not consulted, so unrelated or
79
+ similarly named connectors neither enable nor block delivery.
34
80
 
35
- The tool description advertises this directory and the 20 MiB limit to the Agent.
36
- The directory is a non-hidden subtree of the Agent's working directory. Both
37
- routes interpret relative paths from that working directory; absolute paths keep
38
- their usual meaning. Chat sending additionally enforces the export directory.
39
- The Agent should finish writing the file before calling `send_file`. Symlinks,
40
- hardlinks, hidden/credential paths, changed files and files over 20 MiB fail.
41
-
42
- Initial support requires Linux, the managed Ours connector, a stock Codex or
43
- Claude ACP adapter, and a tracked active turn. Idle, scheduled, steered, cancelled,
44
- replaced and unsupported sessions cannot export. Existing native tool approval
45
- and the neutral role permission policy both apply before bytes are read. An
46
- `ask` decision needs an attached controller; `deny` refuses. A native policy that
47
- refuses MCP execution (including Codex `never` without a native tool allowance)
48
- can still refuse before Fleet receives the call.
49
-
50
- Codex requires native runtime 0.159.0 or newer and Fleet's managed App Server
51
- proxy. Before each turn, its public exact-thread, paginated MCP inventory must
52
- show a connected managed `ours` with the expected optional-contact schema.
53
- Ours aliases, filtered aliases, ambiguous identity, failed discovery and an
54
- incompatible schema make the turn unavailable. Unrelated identified tools are
55
- preserved. Explicitly disabled Ours is rejected before replacement. Inherited
56
- TOML MCP settings, including project/profile sources, are conservatively
57
- unsupported until their disabling semantics can be qualified before replacement.
58
- Use a clean Codex configuration home for this initial opt-in.
59
-
60
- Claude ACP 0.63.0's installed wrapper has no public same-session inventory RPC. Initial
61
- support therefore requires an operator's explicit restricted MCP configuration:
81
+ ## Permissions
82
+
83
+ Existing native tool approval and the neutral role permission policy both apply
84
+ before bytes are read. With `approval: allow`, a path inside the role's
85
+ `filesystem` boundary is allowed automatically; a path outside it is put to the
86
+ attached controller like any other out-of-boundary request, and is denied when
87
+ nobody is attached and `unattended: deny`. An `ask` decision needs an attached
88
+ controller; `deny` refuses with `CURRENT_CHAT_DELIVERY_UNAVAILABLE`, and a
89
+ declined request returns `FILE_SEND_DENIED`. A native policy that refuses MCP
90
+ execution (including Codex `never` without a native tool allowance) can still
91
+ refuse before Fleet receives the call.
92
+
93
+ ## Retired configuration
94
+
95
+ `file_delivery` is retired. An existing block still loads so manifests need no
96
+ edit, and has no effect:
62
97
 
63
98
  ```yaml
64
- harness_options:
65
- mcp_servers_only: true
66
- mcp_servers:
67
- ours: { command: ours-mcp, args: [proxy] }
99
+ file_delivery:
100
+ enabled: false # ignored: delivery stays available
101
+ directory: deliverables # ignored: not created, not enforced
68
102
  ```
69
103
 
70
- Fleet replaces that connector with its bound bridge. Additional explicit MCP
71
- servers and custom ACP commands are unsupported for this feature. Default-off
72
- Agents retain their existing configuration.
104
+ Remove it at your convenience. Unknown keys inside it are still rejected.
105
+
106
+ ## Storage and download
73
107
 
74
108
  Successful sends copy complete bytes into private immutable storage and append a
75
109
  server-origin `file.attached` event containing the actual session generation,
@@ -90,13 +124,23 @@ saving after a workspace change.
90
124
 
91
125
  This source requires the companion MCP implementation's
92
126
  `MANAGED_FILE_DELIVERY_VERSION = 1`. Fleet pins published
93
- `@ours.network/mcp@1.2.0-nightly.13`, which includes it. Older MCP packages without
94
- this capability fail closed when file delivery is enabled. Changes to the MCP
95
- pin require clean installed-package qualification without a built MCP overlay.
96
-
97
- The deterministic native fixture uses real ACP adapters, the real managed
98
- connector and an isolated daemon. A local scripted provider supplies only tool
99
- intents; approval, read, copy, publication and bytes come from the implementation.
100
- It does not qualify external-model inference, deployment or arbitrary native
101
- MCP request-to-ACP tool-call ID mapping. Correlation is the authenticated bridge
102
- and the actual active Fleet turn, which is revoked by cancellation/steering.
127
+ `@ours.network/mcp@1.2.0-nightly.13`, which includes it and already treats the
128
+ export-directory hint as optional, so default delivery needs no MCP change. An
129
+ installed MCP package without the capability fails Agent startup with
130
+ `INCOMPATIBLE_MANAGED_FILE_DELIVERY` instead of quietly requiring a contact.
131
+ Changes to the MCP pin require clean installed-package qualification without a
132
+ built MCP overlay.
133
+
134
+ The deterministic native fixture (`test/file-delivery-native.integration.mjs`)
135
+ uses real ACP adapters, the real managed connector and an isolated daemon. Its
136
+ Agent has no `file_delivery` block and no MCP restriction, and it sends a file
137
+ from a hidden directory outside the working directory. It then downloads the
138
+ generated attachment over the authenticated HTTP route and compares bytes and
139
+ SHA-256, after checking that an anonymous and a forged-cookie request are
140
+ refused. `FLEET_FILE_NATIVE_VARIANT=connectors` adds unrelated MCP connectors
141
+ and requires them to stay usable. A local scripted provider supplies only tool
142
+ intents; approval, read, copy, publication and bytes come from the
143
+ implementation. It does not qualify external-model inference, deployment or
144
+ arbitrary native MCP request-to-ACP tool-call ID mapping. Correlation is the
145
+ authenticated bridge and the actual active Fleet turn, which is revoked by
146
+ cancellation/steering.
@@ -185,3 +185,41 @@ before erasing task/room/workspace resources. Permanent siblings remain intact.
185
185
  Standalone CLI/web removal refuses `TASK_MEMBER_IS_TASK_OWNED`; use the owning
186
186
  task lifecycle. Lost/corrupt task or launch records require restoring original
187
187
  backup evidence or explicit ownership reconciliation before deletion can finish.
188
+
189
+ ## Owner lifecycle notifications
190
+
191
+ A task mutation commits its notification event in the same atomic task record as
192
+ its effective status. Creation, backlog, provisioning, active, review, done,
193
+ cancelled and failed are covered; block/unblock and deletion are effective changes
194
+ as well. Editing a description, list, block reason or saga checkpoint is not a
195
+ status transition. Each event freezes the task identity, title, old/new status and
196
+ navigation route at commit time, so several transitions between delivery polls
197
+ remain separate. Legacy records begin emitting on their next actual change and
198
+ are not retroactively announced as newly created.
199
+
200
+ The Fleet manager drains committed task events without requiring the web console
201
+ or restarting agents. Its notification producer uses the installation's existing
202
+ owner-scoped credential; it grants no agent arbitrary-send capability. Missing
203
+ configuration or service failure retains the events. Delivery acknowledgement
204
+ runs under the task lock and removes only the accepted event, preserving newer
205
+ changes. A corrupt/unreadable task record is isolated with a safe warning, so
206
+ other tasks continue. Bounded rotating batches prevent one rejected task from
207
+ blocking the entire sender. The built-in `default` task list is unchanged.
208
+
209
+ Deleting is announced only after its intent commits. Before unlink, settlement
210
+ prepares retained pending events plus a stable deleted event outside the task-owned
211
+ workspace. The sender admits that retained copy only when the original task record
212
+ is absent; an interrupted or failed unlink cannot announce deletion. The final
213
+ event opens the task list and includes the removed task's identity. Earlier events
214
+ keep their immutable detail routes, which may be absent after explicit deletion.
215
+
216
+ A service restart or producer crash after acceptance may replay an event; the
217
+ Notifications service must retain its acceptance receipts across read/delete to
218
+ make that replay harmless. Upgrade Notifications alongside Fleet to obtain this
219
+ stronger guarantee. The service's authenticated delivery diagnostics distinguish
220
+ provider acceptance, retry, expired subscription and foreground suppression.
221
+ Provider acceptance is not evidence of actual phone receipt. iPhone Web Push
222
+ requires a supported Home Screen installation and granted device/browser permission;
223
+ reopen the workspace to reconcile an expired subscription and explicitly re-enable
224
+ permission if it was revoked. Every received push must display a notification to
225
+ honor Safari's user-visible push requirement.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.56",
3
+ "version": "1.2.0-nightly.58",
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",
@@ -1,15 +0,0 @@
1
- export declare class CodexFileInventory {
2
- private readonly sendToCodex;
3
- private readonly emitToClient;
4
- private checks;
5
- private sequence;
6
- constructor(sendToCodex: (line: string) => void, emitToClient: (line: string) => void);
7
- /** Hold only a turn/start; unrelated tools/configuration are preserved. */
8
- observeClientLine(line: string): boolean;
9
- observeServerLine(line: string): boolean;
10
- close(): void;
11
- private query;
12
- private qualified;
13
- private finish;
14
- private reject;
15
- }