@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
@@ -1,3 +1,4 @@
1
+ import type { FileDeliveryInput, CopyChatFile, DeliveredFile } from '../file-delivery/types.js';
1
2
  import { type TaskNoticeBinding, type TaskCreatedNotice } from './task-notice.js';
2
3
  import * as acp from '@agentclientprotocol/sdk';
3
4
  import type { CommonPermissions } from '../config.js';
@@ -176,6 +177,7 @@ export declare class AcpSession implements AgentSession {
176
177
  private stallTimer?;
177
178
  private stallAttempt?;
178
179
  private readonly toolBoundaryWaiters;
180
+ private fileExportBusy;
179
181
  private activeTurn?;
180
182
  private constructor();
181
183
  static start(options: AcpSessionOptions): Promise<AcpSession>;
@@ -303,6 +305,8 @@ export declare class AcpSession implements AgentSession {
303
305
  */
304
306
  private settlePendingAutomatically;
305
307
  exitResult(): ExitRecord | null;
308
+ /** One attempt, tied to the managed live turn captured before any approval/read. */
309
+ sendFileToChat(input: FileDeliveryInput, signal: AbortSignal, copy: CopyChatFile, policy: CommonPermissions): Promise<DeliveredFile>;
306
310
  close(): Promise<void>;
307
311
  /** ACP requires the field. Bundled agents treat [] as no client-added servers. */
308
312
  private declaredMcpServers;
@@ -278,6 +278,7 @@ export class AcpSession {
278
278
  stallTimer;
279
279
  stallAttempt;
280
280
  toolBoundaryWaiters = new Set();
281
+ fileExportBusy = false;
281
282
  activeTurn;
282
283
  constructor(options, child, connection) {
283
284
  this.options = options;
@@ -823,6 +824,8 @@ export class AcpSession {
823
824
  if (!this.sessionId)
824
825
  return;
825
826
  const active = this.activeTurn;
827
+ if (active)
828
+ active.fileDeliveryRevoked = true;
826
829
  const previousSource = active?.cancellationSource;
827
830
  if (active && (source === 'owner' || source === 'local-console' || !previousSource))
828
831
  active.cancellationSource = source;
@@ -1026,6 +1029,54 @@ export class AcpSession {
1026
1029
  exitResult() {
1027
1030
  return this.exit;
1028
1031
  }
1032
+ /** One attempt, tied to the managed live turn captured before any approval/read. */
1033
+ async sendFileToChat(input, signal, copy, policy) {
1034
+ const active = this.activeTurn, sessionId = this.sessionId;
1035
+ const check = () => {
1036
+ signal.throwIfAborted();
1037
+ if (!active || !sessionId || this.activeTurn !== active || this.sessionId !== sessionId || active.fileDeliveryRevoked
1038
+ || active.cancellationSource || this.closing || !this.isAlive() || this.replaying || this.steeringOccupied
1039
+ || active.origin?.kind === 'scheduled-loop' || policy.approval === 'deny')
1040
+ throw Error('CURRENT_CHAT_DELIVERY_UNAVAILABLE');
1041
+ };
1042
+ check();
1043
+ if (this.fileExportBusy)
1044
+ throw Error('FILE_DELIVERY_BUSY');
1045
+ this.fileExportBusy = true;
1046
+ const toolCallId = 'fleet-file-export-' + randomUUID();
1047
+ try {
1048
+ const onAbort = () => {
1049
+ for (const [id, pending] of this.pendingPermissions)
1050
+ if (pending.toolCallId === toolCallId)
1051
+ this.settlePendingAutomatically(id, pending, 'cancelled', undefined, 'File send cancelled');
1052
+ };
1053
+ signal.addEventListener('abort', onAbort, { once: true });
1054
+ let answer;
1055
+ try {
1056
+ answer = await this.requestPermission({ sessionId: sessionId, toolCall: {
1057
+ toolCallId, title: 'Send file to this ACP chat: ' + (input.filename ?? input.path), kind: 'read', status: 'pending',
1058
+ locations: [{ path: resolve(this.options.cwd, input.path) }],
1059
+ }, options: [{ optionId: 'send', name: 'Send file', kind: 'allow_once' }, { optionId: 'deny', name: 'Do not send', kind: 'reject_once' }] }, policy);
1060
+ }
1061
+ finally {
1062
+ signal.removeEventListener('abort', onAbort);
1063
+ }
1064
+ check();
1065
+ if (answer.outcome.outcome !== 'selected' || answer.outcome.optionId !== 'send')
1066
+ throw Error('FILE_SEND_DENIED');
1067
+ const attachment = await copy({ sessionGeneration: this.sessionGeneration, acpSessionId: sessionId, turnId: active.id });
1068
+ // Synchronous check + durable append establishes order with cancellation/stop.
1069
+ // No await, pending-send journal, replay or recovery exists after this point.
1070
+ check();
1071
+ this.conversation.append({ kind: 'file.attached', source: 'agent', sessionGeneration: this.sessionGeneration,
1072
+ acpSessionId: sessionId, promptId: active.id, turnId: active.id, payload: { attachment } });
1073
+ return attachment;
1074
+ }
1075
+ finally {
1076
+ this.releaseTool(toolCallId);
1077
+ this.fileExportBusy = false;
1078
+ }
1079
+ }
1029
1080
  async close() {
1030
1081
  this.closing = true;
1031
1082
  if (this.stallTimer)
@@ -1414,6 +1465,8 @@ export class AcpSession {
1414
1465
  }
1415
1466
  async steerPrompt(text) {
1416
1467
  this.steeringWasUsed = true;
1468
+ if (this.activeTurn)
1469
+ this.activeTurn.fileDeliveryRevoked = true;
1417
1470
  if (!this.sessionId || !this.isAlive())
1418
1471
  return turnResult(false, 'failed', this.lastError ?? 'ACP session is offline');
1419
1472
  this.steeringRequests++;
@@ -1445,7 +1498,7 @@ export class AcpSession {
1445
1498
  this.steeringRequests--;
1446
1499
  }
1447
1500
  }
1448
- requestPermission(params) {
1501
+ requestPermission(params, neutralPolicy) {
1449
1502
  // `kinds` is a PRIORITY order. Scanning the agent's option array instead
1450
1503
  // (`options.find(o => kinds.includes(o.kind))`) hands the choice to whatever
1451
1504
  // order the agent happened to list, which is exactly how an automatic denial
@@ -1463,7 +1516,7 @@ export class AcpSession {
1463
1516
  // Permission is part of the tool lifecycle. Reserve before any policy or
1464
1517
  // human decision so a monitor wake cannot slip between request and answer.
1465
1518
  this.reservePermission(toolCallId, permissionId);
1466
- if (this.isEffectiveCodexProtectedMcpApproval(params)) {
1519
+ if (!neutralPolicy && this.isEffectiveCodexProtectedMcpApproval(params)) {
1467
1520
  // Protected MCP approval is already the tool's narrow gate. Never turn
1468
1521
  // this one decision into an adapter-wide standing grant.
1469
1522
  const option = choose(['allow_once']);
@@ -1474,9 +1527,9 @@ export class AcpSession {
1474
1527
  this.releasePermission(toolCallId, permissionId);
1475
1528
  return Promise.resolve(response);
1476
1529
  }
1477
- if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
1530
+ if ((neutralPolicy ?? this.options.permissions).approval === 'allow' && this.withinAutomaticBoundary(params, neutralPolicy)) {
1478
1531
  const option = choose(['allow_always', 'allow_once']);
1479
- const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
1532
+ const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${(neutralPolicy ?? this.options.permissions).filesystem} boundary`);
1480
1533
  if (option)
1481
1534
  this.allowPermission(toolCallId, permissionId);
1482
1535
  else
@@ -1486,8 +1539,8 @@ export class AcpSession {
1486
1539
  // A live grace window still counts as attended: the controller may be
1487
1540
  // mid-reconnect, and denying instantly is exactly what the grace prevents.
1488
1541
  const unattended = this.controllerCount === 0 && !this.controllerGrace
1489
- && this.options.permissions.unattended === 'deny';
1490
- if (this.options.permissions.approval === 'deny' || unattended) {
1542
+ && (neutralPolicy ?? this.options.permissions).unattended === 'deny';
1543
+ if ((neutralPolicy ?? this.options.permissions).approval === 'deny' || unattended) {
1491
1544
  // reject_once FIRST: `reject_always` teaches the agent a standing rule from
1492
1545
  // a decision no human made, so one unattended denial would silently disable
1493
1546
  // the tool for the rest of the session.
@@ -1588,8 +1641,8 @@ export class AcpSession {
1588
1641
  ? { outcome: { outcome: 'selected', optionId: option.optionId } }
1589
1642
  : { outcome: { outcome: 'cancelled' } };
1590
1643
  }
1591
- withinAutomaticBoundary(params) {
1592
- const filesystem = this.options.permissions.filesystem;
1644
+ withinAutomaticBoundary(params, policy) {
1645
+ const filesystem = (policy ?? this.options.permissions).filesystem;
1593
1646
  if (filesystem === 'unrestricted')
1594
1647
  return true;
1595
1648
  if (filesystem === 'read-only' && params.toolCall.kind !== 'read')
@@ -8,7 +8,7 @@ import type { PromptOrigin, TurnCancellationSource, TurnOutcome } from './types.
8
8
  * file touches the wire: transport updates are reduced into these shapes by a
9
9
  * session adapter, and the store assigns `seq`/`eventId`/timestamps.
10
10
  */
11
- export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
11
+ export type ConversationEventKind = 'prompt.admitted' | 'prompt.started' | 'prompt.interrupt_requested' | 'message.chunk' | 'message.replace' | 'thought.chunk' | 'thought.replace' | 'plan.replace' | 'tool.upsert' | 'tool.content_chunk' | 'permission.requested' | 'permission.resolved' | 'file.attached' | 'fleet.task_created' | 'monitor.delivery' | 'usage.updated' | 'turn.state' | 'turn.completed' | 'session.state' | 'session.info' | 'capabilities.updated' | 'error'
12
12
  /** A well-formed transport update this version cannot represent. Bounded, never a crash. */
13
13
  | 'unsupported';
14
14
  /** Where a conversation record came from. Typed provenance, never prompt text. */
@@ -228,7 +228,9 @@ export interface BoundedJson {
228
228
  digest?: string;
229
229
  redacted?: true;
230
230
  }
231
- export type ConversationPayload = MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
231
+ export type ConversationPayload = {
232
+ attachment: import('../file-delivery/types.js').DeliveredFile;
233
+ } | MessageChunkPayload | ThoughtChunkPayload | PlanReplacePayload | ToolUpsertPayload | UsageUpdatedPayload | SessionStatePayload | SessionInfoPayload | CapabilitiesUpdatedPayload | UnsupportedPayload | PromptAdmittedPayload | PromptStartedPayload | PromptInterruptRequestedPayload | PermissionRequestedPayload | PermissionResolvedPayload | TurnStatePayload | TurnCompletedPayload | MonitorDeliveryPayload | SessionLifecyclePayload | ErrorPayload | TaskCreatedNotice;
232
234
  export interface ConversationEventV1 {
233
235
  schemaVersion: 1;
234
236
  roleId: string;
@@ -284,6 +284,7 @@ export interface ConversationHandlePage {
284
284
  * on any concrete transport which implements it.
285
285
  */
286
286
  export interface AgentSession {
287
+ sendFileToChat?: (input: import('../file-delivery/types.js').FileDeliveryInput, signal: AbortSignal, copy: import('../file-delivery/types.js').CopyChatFile, policy: import('../config.js').CommonPermissions) => Promise<import('../file-delivery/types.js').DeliveredFile>;
287
288
  readonly backend: SessionBackendId;
288
289
  readonly pid: number;
289
290
  /** Live capabilities; optional only for source compatibility with external adapters. */
@@ -0,0 +1,4 @@
1
+ /** Readiness is a separate turn after identity/profile and room admission succeed. */
2
+ export declare function managedStartupPrompt(stateDir: string, mode: 'fresh' | 'resume', hasPriorBoot?: boolean): string;
3
+ /** Configured work/continuity runs after readiness, never inside the fast turn. */
4
+ export declare function managedTaskPrompt(stateDir: string, mode: 'fresh' | 'resume', hasPriorBoot?: boolean): string;
@@ -0,0 +1,36 @@
1
+ import { join } from 'node:path';
2
+ /** Readiness is a separate turn after identity/profile and room admission succeed. */
3
+ export function managedStartupPrompt(stateDir, mode, hasPriorBoot = false) {
4
+ const briefing = join(stateDir, 'briefing.md');
5
+ const continuity = mode === 'resume' || hasPriorBoot
6
+ ? ` For a later continuation task, read ${join(stateDir, 'WORKLOG.md')} once for continuity.`
7
+ : '';
8
+ return 'Fleet readiness check. The supervisor has verified your assigned identity, configured Role profile, '
9
+ + 'and any required room admission. Reply with a short final console acknowledgement that you are ready, '
10
+ + 'then end this turn. Announce readiness before any tool calls or file reads. '
11
+ + 'Do not read briefing, routines, worklog, inbox, history, help or config during this readiness turn; '
12
+ + 'do not perform identity/profile setup or broad tool discovery. Do not claim task work has been done. '
13
+ + 'The supervisor delivers configured work separately; otherwise wait for a concrete task or supervisor wake. '
14
+ + `For a later task or mail wake, read ${briefing} once before acting and reuse it within that wake. `
15
+ + 'Read routines only for concrete work; an idle launch needs no detail reads. '
16
+ + 'The supervisor delivers initial unread mail and later arrivals as wakes; do not poll the inbox at startup.'
17
+ + continuity;
18
+ }
19
+ /** Configured work/continuity runs after readiness, never inside the fast turn. */
20
+ export function managedTaskPrompt(stateDir, mode, hasPriorBoot = false) {
21
+ const briefing = join(stateDir, 'briefing.md');
22
+ const continuity = mode === 'resume' || hasPriorBoot
23
+ ? ` Read ${join(stateDir, 'WORKLOG.md')} once for continuity.`
24
+ : '';
25
+ return `Read and follow ${briefing} for the assigned work. Readiness has already been announced; `
26
+ + 'this is a separate task turn, so do not repeat the readiness acknowledgement.' + continuity + ' '
27
+ + 'Identify the concrete assignment in the briefing or unfinished work in the continuation log. '
28
+ + 'A role charter or public bio alone is not a new task. If there is no concrete work, end this turn '
29
+ + 'and await a task without reading routines, inbox, history, or unrelated files. '
30
+ + 'For concrete work, read any existing routines once, reuse details already read in this wake, '
31
+ + 'acknowledge the actual task and next action through its assigned reply route, and proceed. '
32
+ + (mode === 'fresh' && !hasPriorBoot ? 'On a fresh launch, do not read a worklog for initialization. ' : '')
33
+ + 'Use inbox/history only when a mail wake, unread pagination, or the actual task needs them. '
34
+ + 'The supervisor delivers initial unread mail; no model-owned startup poll is needed. '
35
+ + 'Identity/profile setup, help/config exploration and broad tool discovery are not startup rituals.';
36
+ }
@@ -0,0 +1,16 @@
1
+ import type { ConversationEventV1 } from './session/conversation-types.js';
2
+ /** Body-free startup diagnostics. First output is an observation, not proof of useful work. */
3
+ export declare class StartupTelemetry {
4
+ private readonly now;
5
+ private readonly log;
6
+ private readonly started;
7
+ private promptAt?;
8
+ private promptId?;
9
+ private firstOutput;
10
+ private completed;
11
+ private readonly tools;
12
+ constructor(now: () => number, log: (line: string) => void);
13
+ mark(stage: 'supervisor_started' | 'identity_prepare_started' | 'identity_ready' | 'harness_starting' | 'harness_ready' | 'startup_submitted' | 'readiness_turn_completed'): void;
14
+ observe(event: ConversationEventV1): void;
15
+ finish(): void;
16
+ }
@@ -0,0 +1,46 @@
1
+ /** Body-free startup diagnostics. First output is an observation, not proof of useful work. */
2
+ export class StartupTelemetry {
3
+ now;
4
+ log;
5
+ started;
6
+ promptAt;
7
+ promptId;
8
+ firstOutput = false;
9
+ completed = false;
10
+ tools = new Set();
11
+ constructor(now, log) {
12
+ this.now = now;
13
+ this.log = log;
14
+ this.started = now();
15
+ }
16
+ mark(stage) {
17
+ const at = this.now();
18
+ if (stage === 'startup_submitted')
19
+ this.promptAt = at;
20
+ this.log(`startup stage=${stage} elapsed_ms=${Math.max(0, at - this.started)}`);
21
+ }
22
+ observe(event) {
23
+ if (this.promptAt === undefined || this.completed || event.source === 'agent_replay')
24
+ return;
25
+ if (event.kind === 'prompt.started' && event.source === 'startup')
26
+ this.promptId = event.promptId;
27
+ if (this.promptId && event.promptId && event.promptId !== this.promptId)
28
+ return;
29
+ if (event.kind === 'tool.upsert' && 'toolCallId' in event.payload && event.payload.toolCallId)
30
+ this.tools.add(event.payload.toolCallId);
31
+ if (!this.firstOutput && ['message.chunk', 'message.replace'].includes(event.kind)
32
+ && 'role' in event.payload && event.payload.role === 'assistant'
33
+ && 'content' in event.payload && event.payload.content.type === 'text'
34
+ && event.payload.content.text.trim()) {
35
+ this.firstOutput = true;
36
+ const at = this.now();
37
+ this.log(`startup stage=first_assistant_output elapsed_ms=${Math.max(0, at - this.started)} model_and_tools_ms=${Math.max(0, at - this.promptAt)} tools=${this.tools.size}`);
38
+ }
39
+ }
40
+ finish() {
41
+ if (this.completed)
42
+ return;
43
+ this.completed = true;
44
+ this.log(`startup stage=turn_settled elapsed_ms=${Math.max(0, this.now() - this.started)} tools=${this.tools.size} first_output=${this.firstOutput}`);
45
+ }
46
+ }
@@ -1,3 +1,5 @@
1
+ import { conversationHasAttachment } from '../application/conversation-history.js';
2
+ import { readDeliveredFile } from '../file-delivery/store.js';
1
3
  import { WorkspaceDeviceAuthError } from './workspace-devices.js';
2
4
  import { readFileSync } from 'node:fs';
3
5
  import { packagedPresetRoot } from '../preset-bootstrap.js';
@@ -756,6 +758,28 @@ export async function buildWebServer(services, boundary, options = {}) {
756
758
  const page = await control.conversationPage({ after: request.query.after, limit });
757
759
  return { ...page, events: page.events.map(present) };
758
760
  });
761
+ app.get('/api/v1/roles/:id/artifacts/:artifactId', async (request, reply) => {
762
+ auth.authenticate(request);
763
+ const role = await services.repository.get(request.params.id);
764
+ if (!role)
765
+ throw new FleetError('role_not_found', 'Agent not found');
766
+ const stateDir = services.repository.stateDir(role);
767
+ if (!stateDir)
768
+ throw new FleetError('resource_not_found', 'Agent file not found');
769
+ try {
770
+ const { record, bytes } = await readDeliveredFile(stateDir, request.params.artifactId);
771
+ if (!await conversationHasAttachment(stateDir, record))
772
+ throw Error('ARTIFACT_NOT_PUBLISHED');
773
+ reply.header('Cache-Control', 'private, no-store');
774
+ reply.header('X-Content-Type-Options', 'nosniff');
775
+ reply.header('Content-Security-Policy', "default-src 'none'; sandbox");
776
+ reply.header('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(record.name).replace(/'/g, '%27')}`);
777
+ return reply.type('application/octet-stream').send(bytes);
778
+ }
779
+ catch {
780
+ throw new FleetError('resource_not_found', 'Agent file unavailable');
781
+ }
782
+ });
759
783
  app.get('/api/v1/roles/:id/attachments/:attachmentId', async (request, reply) => {
760
784
  auth.authenticate(request);
761
785
  const role = await services.repository.get(request.params.id);
@@ -0,0 +1,102 @@
1
+ # Sending files in an ACP chat
2
+
3
+ An opted-in managed Agent uses the existing Ours `send_file` tool:
4
+
5
+ ```json
6
+ {"path":"deliverables/report.pdf"}
7
+ ```
8
+
9
+ Omitting `contact` sends to the current ACP chat. Supplying a non-empty contact
10
+ keeps ordinary Ours delivery:
11
+
12
+ ```json
13
+ {"contact":"Alice","path":"deliverables/report.pdf"}
14
+ ```
15
+
16
+ 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.
20
+
21
+ Each call makes one attempt. Failures are returned to the agent. There is no
22
+ request ID, deduplication, automatic retry or recovery of failed sends. If a user
23
+ asks to resend, another tool call makes a new attempt and can produce another
24
+ attachment. UUIDs identify stored attachments; the stored SHA-256 checks integrity.
25
+ Neither is a retry key.
26
+
27
+ Set this on the Agent definition, not manifest defaults or a Brain:
28
+
29
+ ```yaml
30
+ file_delivery:
31
+ enabled: true
32
+ directory: deliverables
33
+ ```
34
+
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:
62
+
63
+ ```yaml
64
+ harness_options:
65
+ mcp_servers_only: true
66
+ mcp_servers:
67
+ ours: { command: ours-mcp, args: [proxy] }
68
+ ```
69
+
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.
73
+
74
+ Successful sends copy complete bytes into private immutable storage and append a
75
+ server-origin `file.attached` event containing the actual session generation,
76
+ ACP session and managed turn. One export can run at a time. Cancellation or
77
+ steering before publication revokes it; cancellation after publication does not
78
+ undo it. Unpublished stored bytes are never replayed or republished. Storage is
79
+ bounded to 100 files and 200 MiB per role incarnation, including incomplete and
80
+ orphaned copies. Exhausted storage fails; no automatic recovery is performed.
81
+
82
+ The App renders persisted file cards from these typed events. HTML and images
83
+ also download as files. Downloads require authentication, the matching role
84
+ incarnation, a published conversation event and intact bytes. Deleted, missing,
85
+ changed or orphaned artifacts return unavailable. Remote workspaces fetch bytes
86
+ using their existing device credential, limit the read to 20 MiB and prevent
87
+ saving after a workspace change.
88
+
89
+ ## Release prerequisite and qualification
90
+
91
+ This source requires the companion MCP implementation's
92
+ `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.
@@ -80,6 +80,37 @@ any remaining original archive and still refuse replacement live member state
80
80
  or a recreated identity even after the archive has been consumed. Failed deletion
81
81
  remains retryable with its recorded error.
82
82
 
83
+ Explicit deletion restores missing owner read/write/traversal bits on real
84
+ directories inside a proven owned workspace or retired archive before the Git
85
+ audit and removal. This handles restrictive artifacts left by tests even when
86
+ they belong to the same Unix user. It adds no group/other access, follows no
87
+ artifact symlinks, crosses no mounted device boundary, and never changes the
88
+ owner. Only Git pointer files that the audit must read may gain owner-read;
89
+ hardlinked unreadable pointers are refused to avoid changing an outside alias.
90
+ Permission changes target a verified open inode. Readable foreign-UID directories
91
+ may be traversed unchanged; missing owner access is never repaired for another
92
+ UID. On Linux, traversal and Git pointer reads use pinned directory/file
93
+ descriptors, and a detected pathname replacement stops cleanup before descending.
94
+ Unreadable entries
95
+ use an `O_PATH` descriptor through `/proc/self/fd`; other platforms retain the
96
+ error if they cannot open the entry safely. A restricted workspace root is
97
+ temporarily accessible to verify its exact ownership marker; failed marker
98
+ verification restores its original mode. Partial token-derived deletion
99
+ tombstones resume directory cleanup without re-reading already-removed Git
100
+ metadata. List, show, and ordinary Git audit do not repair permissions.
101
+
102
+ An accepted deletion is hidden from ordinary task lists while cleanup settles.
103
+ Use `ours-fleet task list --include-deleting --json` (or the API's
104
+ `includeDeleting=true`) to include retained cleanup intents, and `task show ID`
105
+ to see pending/error/retry information. Disappearance from the ordinary list is
106
+ not proof of completed deletion; successful deletion removes the task record
107
+ after workspace and member cleanup.
108
+ Cowork HTTP transport failures record the RPC method, failing stage and a
109
+ bounded error category in the cleanup intent. Credential rejection remains an
110
+ explicit `unauthorized` protocol error. Transport diagnostics do not include
111
+ request bodies, credentials, URLs or raw exception text, and failed mutations
112
+ are never automatically replayed.
113
+
83
114
  Deletion rejects traversal, foreign recorded paths, changed markers and symlinked
84
115
  workspace ancestors. Ordinary artifact symlinks are unlinked without following
85
116
  their targets. Git control symlinks and foreign pointers are refused. As with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "1.2.0-nightly.52",
3
+ "version": "1.2.0-nightly.54",
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",
@@ -22,7 +22,8 @@
22
22
  "docs/live-voice.md",
23
23
  "docs/room-layouts.md",
24
24
  "docs/validation/managed-cli-permissions.md",
25
- "LICENSE"
25
+ "LICENSE",
26
+ "docs/acp-files.md"
26
27
  ],
27
28
  "engines": {
28
29
  "node": ">=22.13.0"
@@ -43,7 +44,7 @@
43
44
  "@agentclientprotocol/sdk": "^1.3.0",
44
45
  "@fastify/websocket": "^11.2.0",
45
46
  "@ours.network/cli": "2.8.1-nightly.15",
46
- "@ours.network/mcp": "1.2.0-nightly.12",
47
+ "@ours.network/mcp": "1.2.0-nightly.13",
47
48
  "@ours.network/sdk": "3.8.1-nightly.17",
48
49
  "commander": "^12.1.0",
49
50
  "fastify": "^5.4.0",