@ours.network/fleet 1.2.0-nightly.57 → 1.2.0-nightly.59

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.
@@ -5,6 +5,7 @@ import { isAbsolute, join, resolve } from 'node:path';
5
5
  import { boundedJson } from './conversation-normalizer.js';
6
6
  import { ConversationEventStore } from './conversation-store.js';
7
7
  import { SessionEvents } from './events.js';
8
+ import { memberAuthority, memberBindings, pendingInventory, takeBatch, } from './prompt-batch.js';
8
9
  import { CodexAppServerTransport } from './codex-app-server-transport.js';
9
10
  import { CODEX_APP_SERVER_CANCEL_DEADLINE_EXCEEDED, SessionControlError, sessionBackendCapabilities, turnResult, } from './types.js';
10
11
  const PERMISSION_TIMEOUT_MS = 10 * 60_000;
@@ -75,6 +76,31 @@ export function nativePromptAdditionalContext(origin) {
75
76
  };
76
77
  return undefined;
77
78
  }
79
+ const PROMPT_BATCH_CONTEXT_KEY = 'ours-fleet://prompt-batch';
80
+ /**
81
+ * Application context for a turn that carries several admitted prompts.
82
+ *
83
+ * The single-prompt context says "the accompanying user text", which would
84
+ * cover every member. A shared turn therefore carries this one entry instead:
85
+ * each member is its own user text item, in order, and its route and exact
86
+ * bytes are stated here by the server. Text imitating this wording is still
87
+ * only user text; it cannot add an application-context entry.
88
+ */
89
+ export function nativeBatchAdditionalContext(members) {
90
+ if (members.length === 1)
91
+ return nativePromptAdditionalContext(members[0].origin);
92
+ const bindings = memberBindings(members, origin => sourceFor(origin).source);
93
+ return {
94
+ [PROMPT_BATCH_CONTEXT_KEY]: {
95
+ kind: 'application',
96
+ value: `Fleet queued prompt batch: ${members.length} separately admitted prompts in submission order, `
97
+ + 'one user text item each. Server-generated provenance per item, describing only that item: '
98
+ + bindings.map(binding => `item ${binding.index} of ${binding.of} `
99
+ + `(source=${binding.source}, bytes=${binding.bytes}, sha256=${binding.sha256}): `
100
+ + `${memberAuthority(binding.source)}`).join('; ') + '.',
101
+ },
102
+ };
103
+ }
78
104
  function toolItem(item) {
79
105
  switch (item.type) {
80
106
  case 'commandExecution': return {
@@ -123,6 +149,10 @@ export class CodexAppServerSession {
123
149
  exit = null;
124
150
  queueDepth = 0;
125
151
  promptTail = Promise.resolve();
152
+ /** Admitted prompts not yet handed to the app-server, in admission order. */
153
+ pendingPrompts = [];
154
+ /** Admitted prompt → the turn that carries it, while that turn is open. */
155
+ sharedTurns = new Map();
126
156
  activeTurn;
127
157
  controllerCount = 0;
128
158
  controllerGrace;
@@ -173,7 +203,9 @@ export class CodexAppServerSession {
173
203
  alive: this.isAlive(),
174
204
  readiness: this.readiness,
175
205
  sessionId: this.threadId,
176
- ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.origin.kind } : {}),
206
+ // A shared turn is a human console turn if any member is: inbox traffic must not cut it short.
207
+ ...(this.activeTurn?.origin ? { activePromptSource: this.activeTurn.console
208
+ ? 'owner-admin-console' : this.activeTurn.origin.kind } : {}),
177
209
  lastError: this.lastError,
178
210
  pendingPermissionId: this.pendingPermissions.keys().next().value,
179
211
  runtimeModel: this.runtimeModel,
@@ -204,8 +236,7 @@ export class CodexAppServerSession {
204
236
  const queuedBehind = this.queueDepth;
205
237
  this.admit(promptId, text, queuedBehind, options);
206
238
  this.queueDepth++;
207
- const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
208
- this.promptTail = run.then(() => undefined, () => undefined);
239
+ const run = this.enqueue(promptId, text, options.origin);
209
240
  const completion = run.then(result => {
210
241
  this.queueDepth = Math.max(0, this.queueDepth - 1);
211
242
  return result;
@@ -218,6 +249,42 @@ export class CodexAppServerSession {
218
249
  delivery: delivery ?? (queuedBehind ? 'queued' : 'started'),
219
250
  };
220
251
  }
252
+ /** The turn whose events and result answer this admitted prompt; itself unless a release shared it. */
253
+ sharedTurnId(promptId) {
254
+ return this.sharedTurns.get(promptId);
255
+ }
256
+ /** Each prompt keeps its own step in the chain; the step it reaches releases a whole snapshot. */
257
+ enqueue(promptId, text, origin) {
258
+ const entry = {
259
+ promptId, text, origin, ...sourceFor(origin), admittedAt: new Date().toISOString(),
260
+ // The one text the native session runs as an operation instead of a turn.
261
+ command: text.trim() === '/compact',
262
+ };
263
+ this.pendingPrompts.push(entry);
264
+ const run = this.promptTail.then(() => this.release(entry));
265
+ this.promptTail = run.then(() => undefined, () => undefined);
266
+ return run;
267
+ }
268
+ /**
269
+ * The turn boundary: the first waiting prompt to get here synchronously
270
+ * takes every pending prompt delivered with it into one turn. A prompt
271
+ * admitted afterwards stays pending for the next boundary, and a step whose
272
+ * prompt was already taken reports that turn's result.
273
+ */
274
+ release(entry) {
275
+ if (entry.released)
276
+ return entry.released;
277
+ const members = takeBatch(this.pendingPrompts, entry);
278
+ for (const member of members)
279
+ this.sharedTurns.set(member.promptId, members[0].promptId);
280
+ const run = this.runPrompt(members).finally(() => {
281
+ for (const member of members)
282
+ this.sharedTurns.delete(member.promptId);
283
+ });
284
+ for (const member of members)
285
+ member.released = run;
286
+ return run;
287
+ }
221
288
  async submitPrompt(text, options = {}) {
222
289
  try {
223
290
  return await (await this.queuePrompt(text, options)).completion;
@@ -232,7 +299,7 @@ export class CodexAppServerSession {
232
299
  async interruptPrompt(generation, promptId) {
233
300
  if (generation !== this.sessionGeneration)
234
301
  throw new Error('session_changed: Agent session changed.');
235
- if (this.activeTurn?.promptId !== promptId)
302
+ if (!this.activeTurn?.promptIds.includes(promptId))
236
303
  return { state: 'settled' };
237
304
  return this.interrupt('local-console');
238
305
  }
@@ -370,6 +437,7 @@ export class CodexAppServerSession {
370
437
  sessionGeneration: this.sessionGeneration,
371
438
  readiness: this.isAlive() ? this.readiness : 'offline',
372
439
  queueDepth: this.queueDepth,
440
+ ...pendingInventory(this.pendingPrompts),
373
441
  pendingPermissionIds: [...this.pendingPermissions.keys()],
374
442
  ...(this.conversation.degraded ? { historyDegraded: true } : {}),
375
443
  };
@@ -465,8 +533,7 @@ export class CodexAppServerSession {
465
533
  const origin = open.commandId
466
534
  ? { kind: 'owner-admin-console', commandId: open.commandId }
467
535
  : undefined;
468
- const run = this.promptTail.then(() => this.runPrompt(open.text, open.promptId, origin));
469
- this.promptTail = run.then(() => undefined, () => undefined);
536
+ const run = this.enqueue(open.promptId, open.text, origin);
470
537
  void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); });
471
538
  }
472
539
  }
@@ -498,21 +565,27 @@ export class CodexAppServerSession {
498
565
  else
499
566
  this.conversation.appendSafe(draft);
500
567
  }
501
- async runPrompt(text, promptId, origin) {
568
+ async runPrompt(members) {
569
+ const { text, promptId, origin } = members[0];
502
570
  if (!this.threadId || !this.transport.isAlive())
503
571
  return turnResult(false, 'failed', this.lastError ?? 'Codex app-server session is offline');
504
572
  let settle;
505
573
  const settled = new Promise(resolveResult => { settle = resolveResult; });
506
- const active = { promptId, origin, output: '', settled, settle };
574
+ const active = {
575
+ promptId, origin, output: '', settled, settle,
576
+ promptIds: members.map(member => member.promptId),
577
+ console: members.some(member => member.origin?.kind === 'owner-admin-console'),
578
+ };
507
579
  this.activeTurn = active;
508
580
  this.finalAnswerObserved = false;
509
581
  this.activeItems.clear();
510
582
  this.readiness = 'running';
511
583
  this.events.emit('state', { turnId: promptId, status: 'running', origin });
512
- this.conversation.appendSafe({
584
+ members.forEach((member, index) => this.conversation.appendSafe({
513
585
  kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
514
- promptId, turnId: promptId, source: sourceFor(origin).source, payload: {},
515
- });
586
+ promptId: member.promptId, turnId: promptId, source: sourceFor(member.origin).source,
587
+ payload: members.length > 1 ? { batch: { turnId: promptId, index, size: members.length } } : {},
588
+ }));
516
589
  try {
517
590
  if (text.trim() === '/compact') {
518
591
  // The acknowledgement is not completion. Standard turn notifications
@@ -522,12 +595,13 @@ export class CodexAppServerSession {
522
595
  }
523
596
  const response = await this.transport.request('turn/start', {
524
597
  threadId: this.threadId,
525
- input: [{ type: 'text', text, text_elements: [] }],
598
+ // One unchanged user text item per admitted prompt, in admission order.
599
+ input: members.map(member => ({ type: 'text', text: member.text, text_elements: [] })),
526
600
  approvalPolicy: this.options.approvalPolicy,
527
601
  model: this.options.model ?? null,
528
602
  effort: this.options.effort ?? null,
529
- ...(nativePromptAdditionalContext(origin)
530
- ? { additionalContext: nativePromptAdditionalContext(origin) } : {}),
603
+ ...(nativeBatchAdditionalContext(members)
604
+ ? { additionalContext: nativeBatchAdditionalContext(members) } : {}),
531
605
  });
532
606
  const turn = isObject(response.turn) ? response.turn : undefined;
533
607
  active.nativeTurnId ??= string(turn?.id);
@@ -806,14 +880,16 @@ export class CodexAppServerSession {
806
880
  cancellationSource: active.cancellationSource,
807
881
  });
808
882
  this.events.emit('state', { status: this.readiness });
809
- this.conversation.appendSafe({
810
- kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
811
- promptId: active.promptId, turnId: active.promptId,
812
- payload: {
813
- outcome, ...(detail ? { stopReason: detail } : {}),
814
- ...(active.cancellationSource ? { cancellationSource: active.cancellationSource } : {}),
815
- },
816
- });
883
+ // Every member closes with the shared outcome, so none is left waiting.
884
+ for (const member of active.promptIds)
885
+ this.conversation.appendSafe({
886
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
887
+ promptId: member, turnId: active.promptId,
888
+ payload: {
889
+ outcome, ...(detail ? { stopReason: detail } : {}),
890
+ ...(active.cancellationSource ? { cancellationSource: active.cancellationSource } : {}),
891
+ },
892
+ });
817
893
  active.settle(result);
818
894
  }
819
895
  /**
@@ -176,6 +176,12 @@ export interface PromptAdmittedPayload {
176
176
  }
177
177
  export interface PromptStartedPayload {
178
178
  queuedBehind?: number;
179
+ /** Present when one adapter turn carries several admitted prompts; `turnId` is the shared turn. */
180
+ batch?: {
181
+ turnId: string;
182
+ index: number;
183
+ size: number;
184
+ };
179
185
  }
180
186
  export interface PromptInterruptRequestedPayload {
181
187
  commandId?: string;
@@ -289,12 +295,25 @@ export interface PermissionDecisionCommand {
289
295
  sessionGeneration: string;
290
296
  optionId: string;
291
297
  }
298
+ /** One admitted prompt not yet handed to the agent, as reported by the queue that owns it. */
299
+ export interface PendingPromptSummary {
300
+ promptId: string;
301
+ source: ConversationSource;
302
+ admittedAt: string;
303
+ bytes: number;
304
+ /** A bounded preview, present only for a body the ledger already holds. */
305
+ text?: string;
306
+ textTruncated?: boolean;
307
+ }
292
308
  export interface ConversationSnapshot {
293
309
  sessionGeneration: string;
294
310
  readiness: 'starting' | 'idle' | 'running' | 'awaiting_permission' | 'failed' | 'offline';
295
311
  queueDepth: number;
296
312
  pendingPermissionIds: string[];
297
313
  historyDegraded?: boolean;
314
+ /** Every waiting prompt, in delivery order; `pendingPromptCount` equals its length. */
315
+ pendingPrompts?: PendingPromptSummary[];
316
+ pendingPromptCount?: number;
298
317
  }
299
318
  export interface ConversationPage {
300
319
  events: ConversationEventV1[];
@@ -0,0 +1,63 @@
1
+ import type { ConversationSource, PendingPromptSummary } from './conversation-types.js';
2
+ import type { PromptOrigin, TurnResult } from './types.js';
3
+ /**
4
+ * A prompt the session has admitted and not yet handed to the adapter.
5
+ *
6
+ * Every admitted prompt keeps its own id, ledger records and completion. What
7
+ * changes at a turn boundary is how many of them one adapter turn carries.
8
+ */
9
+ export interface PendingPrompt {
10
+ promptId: string;
11
+ text: string;
12
+ origin?: PromptOrigin;
13
+ source: ConversationSource;
14
+ /** Whether the ledger already holds this body; an external body is never copied out of the queue. */
15
+ persistBody: boolean;
16
+ admittedAt: string;
17
+ /** Whether the session recognised the text, at admission, as one of its own control operations. */
18
+ command?: boolean;
19
+ /** Set when a release takes this prompt; every member of that turn shares it. */
20
+ released?: Promise<TurnResult>;
21
+ }
22
+ /**
23
+ * How a waiting prompt takes part in a release.
24
+ *
25
+ * - `message`: something a person or a peer route submitted for the agent to
26
+ * read — the paired console, the owner channel, the local console, a
27
+ * monitor wake. Every waiting message is delivered at one boundary, in
28
+ * admission order, whatever its route.
29
+ * - `command`: a control operation the session itself supports — the native
30
+ * `/compact`, a command the ACP adapter advertised. It must reach the
31
+ * adapter as the entire prompt, so it is its own turn and a boundary
32
+ * between the messages before and after it. The session decides this at
33
+ * admission; text that merely starts with a slash is an ordinary message.
34
+ * - `internal`: startup, scheduled-loop and stall-watchdog prompts. They own
35
+ * their turn's lifecycle (and, for a loop, the redaction of its whole
36
+ * transcript), run alone, and never split the messages around them.
37
+ */
38
+ export type PromptClass = 'message' | 'command' | 'internal';
39
+ export declare function promptClass(prompt: Pick<PendingPrompt, 'origin' | 'command'>): PromptClass;
40
+ /** The command name a prompt invokes, when its whole text has the shape of one. */
41
+ export declare function commandName(text: string): string | undefined;
42
+ /**
43
+ * Detach, in admission order, every pending prompt that is delivered with
44
+ * `head`. Synchronous on purpose: this is the snapshot boundary, and a prompt
45
+ * admitted after it returns belongs to the next release.
46
+ */
47
+ export declare function takeBatch(pending: PendingPrompt[], head: PendingPrompt): PendingPrompt[];
48
+ /** Exact, server-computed identity of one member's body inside a shared turn. */
49
+ export interface MemberBinding {
50
+ index: number;
51
+ of: number;
52
+ source: ConversationSource;
53
+ bytes: number;
54
+ sha256: string;
55
+ }
56
+ export declare function memberBindings(members: ReadonlyArray<Pick<PendingPrompt, 'text' | 'origin'>>, sourceOf: (origin: PromptOrigin | undefined) => ConversationSource): MemberBinding[];
57
+ /** What the server asserts about a member's route; never derived from the member's text. */
58
+ export declare function memberAuthority(source: ConversationSource): string;
59
+ /** What a client may show for prompts still waiting: all of them, in order, and never an unpersisted body. */
60
+ export declare function pendingInventory(pending: readonly PendingPrompt[]): {
61
+ pendingPrompts: PendingPromptSummary[];
62
+ pendingPromptCount: number;
63
+ };
@@ -0,0 +1,86 @@
1
+ import { createHash } from 'node:crypto';
2
+ /**
3
+ * The inventory names every waiting prompt — a client must be able to show the
4
+ * whole queue, not a count of what it could not see. What keeps a snapshot
5
+ * small is the body: each entry carries a short preview, not the prompt.
6
+ */
7
+ const INVENTORY_TEXT_CHARS = 200;
8
+ const INTERNAL = new Set(['startup', 'scheduled-loop', 'stall-watchdog']);
9
+ export function promptClass(prompt) {
10
+ if (INTERNAL.has(prompt.origin?.kind ?? 'local-console'))
11
+ return 'internal';
12
+ return prompt.command ? 'command' : 'message';
13
+ }
14
+ /** The command name a prompt invokes, when its whole text has the shape of one. */
15
+ export function commandName(text) {
16
+ return /^\/([a-z][a-z0-9_-]*)(?:\s|$)/i.exec(text.trim())?.[1];
17
+ }
18
+ /**
19
+ * Detach, in admission order, every pending prompt that is delivered with
20
+ * `head`. Synchronous on purpose: this is the snapshot boundary, and a prompt
21
+ * admitted after it returns belongs to the next release.
22
+ */
23
+ export function takeBatch(pending, head) {
24
+ const members = [];
25
+ if (promptClass(head) !== 'message')
26
+ members.push(head);
27
+ else {
28
+ for (const prompt of pending) {
29
+ const kind = promptClass(prompt);
30
+ if (kind === 'command')
31
+ break;
32
+ if (kind === 'message')
33
+ members.push(prompt);
34
+ }
35
+ if (!members.includes(head))
36
+ members.unshift(head);
37
+ }
38
+ for (const member of members) {
39
+ const at = pending.indexOf(member);
40
+ if (at >= 0)
41
+ pending.splice(at, 1);
42
+ }
43
+ return members;
44
+ }
45
+ export function memberBindings(members, sourceOf) {
46
+ return members.map((member, at) => ({
47
+ index: at + 1,
48
+ of: members.length,
49
+ source: sourceOf(member.origin),
50
+ bytes: Buffer.byteLength(member.text),
51
+ sha256: createHash('sha256').update(member.text).digest('hex'),
52
+ }));
53
+ }
54
+ /** What the server asserts about a member's route; never derived from the member's text. */
55
+ export function memberAuthority(source) {
56
+ switch (source) {
57
+ case 'owner_admin_console':
58
+ return 'direct owner input from the server-authenticated paired console';
59
+ case 'owner_channel':
60
+ return 'admitted through the authenticated Fleet owner channel';
61
+ case 'fleet_monitor':
62
+ return 'a Fleet monitor notification, not owner input';
63
+ default:
64
+ return 'local console input, not authenticated owner-console or owner-channel input';
65
+ }
66
+ }
67
+ /** What a client may show for prompts still waiting: all of them, in order, and never an unpersisted body. */
68
+ export function pendingInventory(pending) {
69
+ return {
70
+ pendingPromptCount: pending.length,
71
+ pendingPrompts: pending.map(prompt => {
72
+ const display = prompt.origin?.kind === 'owner' ? prompt.origin.displayText
73
+ : prompt.persistBody ? prompt.text : undefined;
74
+ return {
75
+ promptId: prompt.promptId,
76
+ source: prompt.source,
77
+ admittedAt: prompt.admittedAt,
78
+ bytes: Buffer.byteLength(prompt.text),
79
+ ...(display === undefined ? {} : {
80
+ text: display.slice(0, INVENTORY_TEXT_CHARS),
81
+ ...(display.length > INVENTORY_TEXT_CHARS ? { textTruncated: true } : {}),
82
+ }),
83
+ };
84
+ }),
85
+ };
86
+ }
@@ -307,6 +307,11 @@ export interface AgentSession {
307
307
  * Hand the session a prompt and return as soon as it has accepted
308
308
  * responsibility for it. Throws `SessionControlError` if it cannot.
309
309
  */
310
+ /**
311
+ * The turn that carries an admitted prompt when one adapter turn delivered
312
+ * several; absent means the prompt's own id is its turn id.
313
+ */
314
+ sharedTurnId?(promptId: string): string | undefined;
310
315
  queuePrompt(text: string, options?: SubmitPromptOptions): Promise<QueuedPrompt>;
311
316
  /** Queue a prompt and wait for its terminal result. */
312
317
  submitPrompt(text: string, options?: SubmitPromptOptions): Promise<TurnResult>;
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.57",
3
+ "version": "1.2.0-nightly.59",
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
- }