@ours.network/fleet 0.15.4 → 0.15.5

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.
package/README.md CHANGED
@@ -328,7 +328,7 @@ roles:
328
328
  owner_channel: # optional trusted owner ingress; requires session: acp
329
329
  identity: "Name Owner Channel" # existing, dedicated ours identity bound only by fleet
330
330
  owners: [owner-contact-cid] # authenticated ours contact IDs, never display names
331
- agent: managed-agent-cid # exact role identity CID allowed to relay messages outward
331
+ agent: managed-agent-cid # exact role CID allowed to relay messages/files outward
332
332
  interrupt: false # false queues; true cancels current work first
333
333
  progress_interval_ms: 30000 # fleet-generated progress notices; 0 disables
334
334
  attachments: # secure inbound documents, images, and voice
@@ -651,15 +651,16 @@ These commands require a running ACP role with `owner_channel` enabled. Missing,
651
651
  stopped, tmux, disabled, draining, and unavailable-MCP targets fail without
652
652
  starting a second client, binding an identity, or opening a network listener.
653
653
 
654
- The managed agent has one outbound rule: use its ordinary ours `send_message`
655
- tool to message the channel identity. Fleet checks that the authenticated sender
656
- CID exactly equals `agent`, then forwards the body as a new message to the owner
657
- CID that most recently sent inbound channel mail. This applies equally to progress,
658
- blockers, suggestions, and proactive notes: there is no task/request/update type.
659
- The agent never chooses an owner recipient. A single configured owner is the safe
660
- fallback; multiple owners without route history fail closed instead of guessing or
661
- broadcasting. Different devices sharing one ours identity share one CID, while
662
- separate authorized identities naturally hand off the route when either sends.
654
+ The managed agent can use its ordinary ours `send_message` or `send_file` tool to
655
+ message the channel identity. Fleet checks that the authenticated sender CID
656
+ exactly equals `agent`, then forwards the body/file to a resolved authenticated
657
+ owner route. This applies equally to blockers, suggestions, proactive notes, and
658
+ ordinary files: there is no model-selected owner recipient. An explicit reply wire
659
+ pins a file to that wire's owner; otherwise the latest authenticated owner is used.
660
+ A single configured owner is the safe fallback; multiple owners without route
661
+ history fail closed instead of guessing or broadcasting. Different devices sharing
662
+ one ours identity share one CID, while separate authorized identities naturally
663
+ hand off the unscoped route when either sends.
663
664
 
664
665
  Messages from CIDs which are neither an owner nor the configured agent are never
665
666
  injected or relayed. Fleet consumes the attempt, does not answer its sender, and
@@ -671,18 +672,32 @@ An owner request follows one ordered lifecycle on its authenticated source wire:
671
672
 
672
673
  1. Fleet sends an immediate receipt describing started, queued, or interrupting state.
673
674
  2. Periodic fleet-generated summaries may report allowlisted ACP activity shapes.
674
- 3. The agent may send any non-final message to the channel identity through its
675
+ 3. On maintained Codex ACP adapters, assistant chunks carrying the exact
676
+ `_meta.codex.phase = "commentary"` marker are automatically batched and forwarded
677
+ on this request's fixed owner CID/source wire. Unknown or absent phases are never
678
+ inferred as commentary; thoughts, tools, permissions, prompts, and raw events are
679
+ excluded by event kind. Older adapters therefore retain final-only behavior.
680
+ 4. The agent may also send any non-final or out-of-turn message through its
675
681
  ordinary ours MCP tool. CID authentication is the message gate; no task ID,
676
682
  request ID, phase, reply reference, or routing command is used.
677
- 4. The agent may send a caption and one or more files to the channel identity.
683
+ 4. The agent may send a caption and one or more files to the channel identity.
678
684
  A reply reference selects the authenticated owner of that exact source wire;
679
685
  an uncorrelated group uses the latest authenticated owner (or the sole-owner
680
686
  fallback). Fleet resolves that route once, admits every file before emitting
681
687
  any part, then sends the caption and files to the same owner on the same route.
682
- 5. Fleet independently emits exactly one final ACP response (or a sanitized
688
+ 5. Fleet independently emits exactly one final ACP response (or a sanitized
683
689
  terminal outcome). Successful owner-request turns send regular files from the
684
690
  request outbox afterward, correlated to the same source wire.
685
691
 
692
+ Commentary uses a 750 ms latency flush plus paragraph and 1,600-character/6,400-byte
693
+ boundaries, with at most 32 updates and 512 in-memory fragment dedupe keys per turn.
694
+ The terminal boundary flushes commentary before the final. Replay-marked fragments,
695
+ wrong turns, wrong typed origins, missing message IDs, and unsafe/ambiguous content
696
+ fail closed. Commentary plaintext buffers and fragment keys are memory-only; bounded
697
+ wire-and-batch digests plus sending/delivered/uncertain status are persisted before
698
+ transport. Thus reconnect replay and crash recovery never blindly resend the same
699
+ batch, while an uncertain in-flight update may be omitted rather than duplicated.
700
+
686
701
  Fleet chooses the stored latest authenticated owner for every managed-agent
687
702
  message; the model supplies only text and the channel contact. Relay audit logs
688
703
  contain hashed wire prefixes and sizes, never bodies. A durable pre-send marker
@@ -813,6 +828,19 @@ admission. The host must run an ours-mcp version whose
813
828
  these guarantees; `ours-mcp voice-status --json` reports whether transcription
814
829
  is currently configured.
815
830
 
831
+ Managed-agent file egress is a separate trust direction. It retains the same
832
+ metadata provenance checks, count/per-file/request byte caps, regular-file and
833
+ no-symlink reads, actual byte-count/SHA-256 verification, filename sanitization,
834
+ private staging, fixed owner routing, and bounded body-free recovery metadata.
835
+ It deliberately does not consult `attachments.enabled` or `allowed_mime`; MIME is
836
+ still detected during byte validation, but a declared-versus-detected mismatch is
837
+ not an egress deny gate. Markdown, HTML, octet-stream, unknown extensions, and normal
838
+ binaries are therefore routable only from the exact configured `agent` CID.
839
+ Owner-to-agent admission above remains byte-for-byte strict. Each outbound file
840
+ gets a durable pre-send marker: a failed/ambiguous send becomes `uncertain` and is
841
+ not blindly retried, while files not yet attempted remain recoverable after restart.
842
+ Logs contain counts and byte totals, never filenames or raw bytes.
843
+
816
844
  Owner channels currently require `session: acp`. Fleet needs structured,
817
845
  turn-correlated assistant output for automatic replies; scraping a tmux pane
818
846
  cannot reliably distinguish the final answer from thoughts, tool output, or
@@ -45,8 +45,15 @@ export interface AdmittedAttachment {
45
45
  export declare function parseIncomingAttachments(raw: unknown): IncomingAttachment[];
46
46
  export declare function parseRetrievedAttachments(raw: unknown, expected: IncomingAttachment[], recovered?: boolean): RetrievedAttachment[];
47
47
  export declare function validateAttachmentSelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
48
+ /**
49
+ * Managed-agent -> owner egress limits. This intentionally does not consult
50
+ * `enabled` or `allowed_mime`: those are owner -> agent admission policy.
51
+ */
52
+ export declare function validateAttachmentRelaySelection(files: IncomingAttachment[], config: OwnerAttachmentConfig): string | undefined;
48
53
  export declare function prepareAttachmentDirectory(root: string, requestId: string): Promise<string>;
49
- export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig): Promise<AdmittedAttachment[]>;
54
+ export declare function admitAttachments(files: RetrievedAttachment[], dir: string, config: OwnerAttachmentConfig, options?: {
55
+ mimePolicy?: 'strict' | 'report-only';
56
+ }): Promise<AdmittedAttachment[]>;
50
57
  export declare function recoveredAttachment(file: IncomingAttachment, path: string): Promise<RetrievedAttachment>;
51
58
  export declare function removeRequestDirectory(path: string): Promise<void>;
52
59
  export declare function cleanupAttachmentRoot(root: string, now: number, retentionMs: number, limit?: number): Promise<number>;
@@ -56,6 +63,9 @@ export interface PendingAttachmentRequest {
56
63
  originWireId: string;
57
64
  fileWireIds: string[];
58
65
  createdAt: number;
66
+ /** Present only for exact-managed-agent -> owner relay recovery. */
67
+ relayContact?: string;
68
+ relayReplyTo?: string;
59
69
  }
60
70
  export declare class AttachmentRecoveryState {
61
71
  private readonly path;
@@ -118,6 +118,23 @@ export function validateAttachmentSelection(files, config) {
118
118
  }
119
119
  return undefined;
120
120
  }
121
+ /**
122
+ * Managed-agent -> owner egress limits. This intentionally does not consult
123
+ * `enabled` or `allowed_mime`: those are owner -> agent admission policy.
124
+ */
125
+ export function validateAttachmentRelaySelection(files, config) {
126
+ if (files.length > config.max_files_per_request)
127
+ return `the relay exceeds the ${config.max_files_per_request}-file limit`;
128
+ let total = 0;
129
+ for (const file of files) {
130
+ if (file.size > config.max_file_bytes)
131
+ return `a relayed file exceeds the ${config.max_file_bytes}-byte limit`;
132
+ total += file.size;
133
+ if (!Number.isSafeInteger(total) || total > config.max_request_bytes)
134
+ return `the relay exceeds the ${config.max_request_bytes}-byte total limit`;
135
+ }
136
+ return undefined;
137
+ }
121
138
  export async function prepareAttachmentDirectory(root, requestId) {
122
139
  mkdirSync(root, { recursive: true, mode: 0o700 });
123
140
  const rootStat = lstatSync(root);
@@ -134,7 +151,7 @@ export async function prepareAttachmentDirectory(root, requestId) {
134
151
  await chmod(dir, 0o700);
135
152
  return dir;
136
153
  }
137
- export async function admitAttachments(files, dir, config) {
154
+ export async function admitAttachments(files, dir, config, options = {}) {
138
155
  const admitted = [];
139
156
  let total = 0;
140
157
  for (let index = 0; index < files.length; index++) {
@@ -163,7 +180,8 @@ export async function admitAttachments(files, dir, config) {
163
180
  throw new Error('retrieved attachments exceed the request size limit');
164
181
  const declaredMime = policyMime(file);
165
182
  const detectedMime = detectMime(bytes, declaredMime);
166
- if (!mimeCompatible(declaredMime, detectedMime))
183
+ if ((options.mimePolicy ?? 'strict') === 'strict'
184
+ && !mimeCompatible(declaredMime, detectedMime))
167
185
  throw new Error(`retrieved attachment content does not match declared MIME ${declaredMime}`);
168
186
  const filename = sanitizeFilename(file.filename);
169
187
  const finalPath = join(dir, `${index + 1}-${file.wireId.slice(0, 12)}-${filename}`);
@@ -327,7 +345,10 @@ function validPending(value) {
327
345
  && Array.isArray(item.fileWireIds) && item.fileWireIds.length >= 1 && item.fileWireIds.length <= 32
328
346
  && item.fileWireIds.every(wire => WIRE.test(wire))
329
347
  && new Set(item.fileWireIds).size === item.fileWireIds.length
330
- && Number.isSafeInteger(item.createdAt) && item.createdAt >= 0;
348
+ && Number.isSafeInteger(item.createdAt) && item.createdAt >= 0
349
+ && (item.relayContact === undefined || CID.test(item.relayContact))
350
+ && (item.relayReplyTo === undefined || WIRE.test(item.relayReplyTo))
351
+ && (item.relayReplyTo === undefined || item.relayContact !== undefined);
331
352
  }
332
353
  export function safeField(value, max) {
333
354
  return Array.from(String(value ?? '').normalize('NFC')
@@ -225,6 +225,8 @@ export declare class OwnerChannel implements OwnerChannelHandle {
225
225
  private effectiveOwners;
226
226
  private authorizationIntegrity;
227
227
  private complete;
228
+ /** Model-authored commentary only; raw protocol/tool data never reaches here. */
229
+ private safeCommentary;
228
230
  private ownerAttachmentPrompt;
229
231
  private ownerPrompt;
230
232
  private outboxDir;
@@ -10,7 +10,7 @@ import { OursMcpClient } from './mcp.js';
10
10
  import { ownerNotices } from './notices.js';
11
11
  import { DuplicateSendError, OwnerAuthorizationState, OwnerChannelState, OwnerConversationState, } from './state.js';
12
12
  import { OwnerTaskState, ownerTaskAuditId, ownerTaskDigest, } from './tasks.js';
13
- import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, } from './attachments.js';
13
+ import { AttachmentRecoveryState, admitAttachments, cleanupAttachmentRoot, parseIncomingAttachments, parseRetrievedAttachments, prepareAttachmentDirectory, recoveredAttachment, removeRequestDirectory, safeField, validateAttachmentSelection, validateAttachmentRelaySelection, } from './attachments.js';
14
14
  import { acquireOwnerBinderLease, OWNER_BIND_HANDOFF_TIMEOUT_MS, } from './binder.js';
15
15
  const OWNER_UPDATE_MIN_INTERVAL_MS = 5_000;
16
16
  const OWNER_UPDATE_MAX_COUNT = 20;
@@ -19,6 +19,11 @@ const OWNER_UPDATE_MAX_BYTES = 1_024;
19
19
  const PROACTIVE_MESSAGE_MAX_CHARS = 4_000;
20
20
  const PROACTIVE_MESSAGE_MAX_BYTES = 16_384;
21
21
  const RELAY_NACK_MEMORY = 512;
22
+ const COMMENTARY_FLUSH_MS = 750;
23
+ const COMMENTARY_MAX_CHARS = 1_600;
24
+ const COMMENTARY_MAX_BYTES = 6_400;
25
+ const COMMENTARY_MAX_UPDATES = 32;
26
+ const COMMENTARY_DEDUPE_LIMIT = 512;
22
27
  /** A relay attempt that failed only because no owner route exists yet. */
23
28
  class RelayUnroutableError extends Error {
24
29
  }
@@ -686,6 +691,8 @@ export class OwnerChannel {
686
691
  const active = {
687
692
  contact: sender.id, wireId: originWireId, requestId, outboundTail: receipt,
688
693
  finalizing: false, updateCount: 0, updateDigests: new Set(), handledWireIds,
694
+ commentaryBuffer: '', commentaryCount: 0, commentaryKeys: new Set(),
695
+ commentaryDisabled: false,
689
696
  };
690
697
  this.activeRequests.set(requestId, active);
691
698
  const cleanupDir = requestDir;
@@ -823,6 +830,8 @@ export class OwnerChannel {
823
830
  const active = {
824
831
  contact: sender.id, wireId, requestId, outboundTail: receipt, finalizing: false,
825
832
  updateCount: 0, updateDigests: new Set(), handledWireIds: [wireId],
833
+ commentaryBuffer: '', commentaryCount: 0, commentaryKeys: new Set(),
834
+ commentaryDisabled: false,
826
835
  };
827
836
  this.activeRequests.set(requestId, active);
828
837
  const task = this.complete(active, outbox, queued, activityCursor)
@@ -1007,7 +1016,7 @@ export class OwnerChannel {
1007
1016
  const contact = await this.routableContact(route);
1008
1017
  const rejection = !this.attachmentRecovery.integrity()
1009
1018
  ? 'attachment recovery state is unavailable'
1010
- : validateAttachmentSelection(group.files, this.attachmentConfig);
1019
+ : validateAttachmentRelaySelection(group.files, this.attachmentConfig);
1011
1020
  if (rejection)
1012
1021
  throw new Error(rejection);
1013
1022
  const transactionId = this.requestId(`managed-agent-attachment:${handledWireIds.slice().sort().join(':')}`);
@@ -1034,7 +1043,7 @@ export class OwnerChannel {
1034
1043
  }
1035
1044
  const order = new Map(group.files.map((file, index) => [file.wireId, index]));
1036
1045
  retrieved.sort((a, b) => order.get(a.wireId) - order.get(b.wireId));
1037
- const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig);
1046
+ const admitted = await admitAttachments(retrieved, requestDir, this.attachmentConfig, { mimePolicy: 'report-only' });
1038
1047
  const digest = createHash('sha256').update(`managed-agent-attachment\0${handledWireIds.slice().sort().join('\0')}`).digest('hex');
1039
1048
  const sending = this.conversations.beginSend(route.contact, digest, Date.now(), 0, 'all');
1040
1049
  try {
@@ -1197,6 +1206,95 @@ export class OwnerChannel {
1197
1206
  let startedAt;
1198
1207
  let phase = 'starting request';
1199
1208
  let timer;
1209
+ const flushCommentary = () => {
1210
+ if (active.commentaryTimer)
1211
+ clearTimeout(active.commentaryTimer);
1212
+ active.commentaryTimer = undefined;
1213
+ const text = active.commentaryBuffer.trim();
1214
+ active.commentaryBuffer = '';
1215
+ if (!text || active.commentaryDisabled || active.finalizing)
1216
+ return;
1217
+ if (active.commentaryCount >= COMMENTARY_MAX_UPDATES) {
1218
+ active.commentaryDisabled = true;
1219
+ return;
1220
+ }
1221
+ let safe;
1222
+ try {
1223
+ safe = this.safeCommentary(text);
1224
+ }
1225
+ catch (error) {
1226
+ active.commentaryDisabled = true;
1227
+ this.logError('ACP commentary forwarding disabled for unsafe content', error);
1228
+ return;
1229
+ }
1230
+ const digest = createHash('sha256')
1231
+ .update(`owner-commentary\0${active.wireId}\0${safe}`).digest('hex');
1232
+ let sending;
1233
+ try {
1234
+ sending = this.conversations.beginSend(active.contact, digest, Date.now(), 0, 'all');
1235
+ }
1236
+ catch (error) {
1237
+ if (error instanceof DuplicateSendError)
1238
+ return;
1239
+ active.commentaryDisabled = true;
1240
+ this.logError('ACP commentary forwarding disabled by dedupe state', error);
1241
+ return;
1242
+ }
1243
+ active.commentaryCount++;
1244
+ active.outboundTail = active.outboundTail
1245
+ .then(async () => {
1246
+ try {
1247
+ if (!this.isEffectiveOwner(active.contact))
1248
+ throw new Error('initiating owner is no longer authorized');
1249
+ await this.send(active.contact, safe, active.wireId);
1250
+ }
1251
+ catch {
1252
+ this.conversations.finishSend(sending.id, 'uncertain');
1253
+ throw new Error('ACP commentary delivery outcome is uncertain');
1254
+ }
1255
+ this.conversations.finishSend(sending.id, 'delivered');
1256
+ })
1257
+ .catch(error => this.logError('ACP commentary delivery failed', error));
1258
+ };
1259
+ const acceptCommentary = (event) => {
1260
+ if (active.commentaryDisabled || active.finalizing || event.turnId !== queued.promptId
1261
+ || event.kind !== 'agent_text' || event.messagePhase !== 'commentary'
1262
+ || event.replayed || event.origin?.kind !== 'owner'
1263
+ || event.origin.requestId !== active.requestId
1264
+ || typeof event.messageId !== 'string' || !event.messageId
1265
+ || typeof event.text !== 'string' || !event.text)
1266
+ return;
1267
+ const key = createHash('sha256')
1268
+ .update(`${event.messageId}\0${event.text}`).digest('hex');
1269
+ if (active.commentaryKeys.has(key))
1270
+ return;
1271
+ active.commentaryKeys.add(key);
1272
+ if (active.commentaryKeys.size > COMMENTARY_DEDUPE_LIMIT)
1273
+ active.commentaryKeys.delete(active.commentaryKeys.values().next().value);
1274
+ let chars = Array.from(active.commentaryBuffer).length;
1275
+ let bytes = Buffer.byteLength(active.commentaryBuffer);
1276
+ for (const point of event.text) {
1277
+ const pointBytes = Buffer.byteLength(point);
1278
+ if (chars >= COMMENTARY_MAX_CHARS || bytes + pointBytes > COMMENTARY_MAX_BYTES) {
1279
+ flushCommentary();
1280
+ if (active.commentaryDisabled)
1281
+ return;
1282
+ chars = 0;
1283
+ bytes = 0;
1284
+ }
1285
+ active.commentaryBuffer += point;
1286
+ chars++;
1287
+ bytes += pointBytes;
1288
+ }
1289
+ if (/\n\s*\n$/u.test(active.commentaryBuffer)) {
1290
+ flushCommentary();
1291
+ return;
1292
+ }
1293
+ if (!active.commentaryTimer) {
1294
+ active.commentaryTimer = setTimeout(flushCommentary, COMMENTARY_FLUSH_MS);
1295
+ active.commentaryTimer.unref?.();
1296
+ }
1297
+ };
1200
1298
  const reportProgress = () => {
1201
1299
  const events = this.options.session.eventsSince(lastSeq);
1202
1300
  lastSeq = Math.max(lastSeq, this.latestEventSeq(events));
@@ -1235,19 +1333,28 @@ export class OwnerChannel {
1235
1333
  timer = setInterval(reportProgress, progressMs);
1236
1334
  timer.unref();
1237
1335
  };
1238
- const unsubscribe = progressMs > 0
1239
- ? this.options.session.subscribe(event => startProgress(event))
1240
- : undefined;
1336
+ const unsubscribe = typeof this.options.session.subscribe === 'function'
1337
+ ? this.options.session.subscribe(event => {
1338
+ startProgress(event);
1339
+ // Automatic commentary is an ACP phase extension. Other backends and
1340
+ // older adapters retain their established final-only behavior.
1341
+ if (this.options.session.backend === 'acp')
1342
+ acceptCommentary(event);
1343
+ })
1344
+ : () => undefined;
1241
1345
  startProgress();
1242
1346
  let result;
1243
1347
  try {
1244
1348
  result = await queued.completion;
1245
1349
  }
1246
1350
  finally {
1247
- unsubscribe?.();
1351
+ unsubscribe();
1248
1352
  if (timer)
1249
1353
  clearInterval(timer);
1354
+ if (active.commentaryTimer)
1355
+ clearTimeout(active.commentaryTimer);
1250
1356
  }
1357
+ flushCommentary();
1251
1358
  active.finalizing = true;
1252
1359
  await active.outboundTail;
1253
1360
  const output = result.output?.trim();
@@ -1268,6 +1375,20 @@ export class OwnerChannel {
1268
1375
  for (const wire of active.handledWireIds)
1269
1376
  this.state.remember(wire);
1270
1377
  }
1378
+ /** Model-authored commentary only; raw protocol/tool data never reaches here. */
1379
+ safeCommentary(value) {
1380
+ const message = value.trim().normalize('NFC');
1381
+ if (!message)
1382
+ throw new Error('commentary is empty');
1383
+ if (Array.from(message).length > COMMENTARY_MAX_CHARS
1384
+ || Buffer.byteLength(message) > COMMENTARY_MAX_BYTES)
1385
+ throw new Error('commentary batch exceeds its bounded size');
1386
+ if (/\u0000|[\u202a-\u202e\u2066-\u2069]/u.test(message))
1387
+ throw new Error('commentary contains unsafe control characters');
1388
+ if (/-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[_ -]?key|access[_ -]?token|authorization|password|secret)\s*[:=]|(?:chain of thought|private reasoning|internal reasoning)|^(?:stdout|stderr|tool (?:output|result)|command):/imu.test(message))
1389
+ throw new Error('commentary appears to contain secret, reasoning, or raw tool content');
1390
+ return message;
1391
+ }
1271
1392
  ownerAttachmentPrompt(sender, wireId, requestId, outbox, files, caption) {
1272
1393
  const lines = [
1273
1394
  '[fleet-owner]',
@@ -41,6 +41,7 @@ export declare class OwnerConversationState {
41
41
  route(effective: Set<string>): {
42
42
  contact: string;
43
43
  basis: OwnerConversationRouteBasis;
44
+ replyToWireId?: string;
44
45
  };
45
46
  routeForWire(wireId: string, effective: Set<string>): {
46
47
  contact: string;
@@ -43,7 +43,7 @@ export class OwnerChannelState {
43
43
  }
44
44
  const CONVERSATION_LIMIT = 64;
45
45
  const WIRE_ROUTE_LIMIT = 512;
46
- const PROACTIVE_SEND_LIMIT = 256;
46
+ const PROACTIVE_SEND_LIMIT = 2_048;
47
47
  const PROACTIVE_MIN_INTERVAL_MS = 30_000;
48
48
  const HEX_64_LOWER = /^[a-f0-9]{64}$/;
49
49
  const CID = /^[A-Fa-f0-9]{64}$/;
@@ -158,7 +158,10 @@ export class OwnerConversationState {
158
158
  if (candidates.length) {
159
159
  if (candidates[1]?.lastInboundAt === candidates[0].lastInboundAt)
160
160
  throw new Error('proactive owner route is ambiguous');
161
- return { contact: candidates[0].contact, basis: 'last-inbound' };
161
+ return {
162
+ contact: candidates[0].contact, basis: 'last-inbound',
163
+ replyToWireId: candidates[0].lastInboundWireId,
164
+ };
162
165
  }
163
166
  if (canonical.size === 1)
164
167
  return { contact: [...effective][0], basis: 'sole-owner' };
@@ -180,7 +183,7 @@ export class OwnerConversationState {
180
183
  const recent = this.sends.filter(send => canonicalCid(send.contact) === canonical).slice(-128);
181
184
  // 'all' scope serves wire-keyed idempotency: a crash replay must not
182
185
  // deliver the same wire to a second owner after the route moved.
183
- const scope = dedupe === 'all' ? this.sends.slice(-128) : recent;
186
+ const scope = dedupe === 'all' ? this.sends : recent;
184
187
  if (scope.some(send => send.digest === digest))
185
188
  throw new DuplicateSendError('duplicate proactive owner message refused');
186
189
  const last = recent.at(-1);
@@ -127,6 +127,11 @@ export declare class AcpSession implements SessionHandle {
127
127
  private settleAutomatically;
128
128
  private withinAutomaticBoundary;
129
129
  private recordUpdate;
130
+ /**
131
+ * Codex ACP's phase extension is the only currently supported visibility
132
+ * signal. Never infer commentary from text, message order, or unknown meta.
133
+ */
134
+ private codexMessagePhase;
130
135
  /** Normalize every ACP update losslessly into the durable ledger. */
131
136
  private recordConversationUpdate;
132
137
  conversationPage(request?: {
@@ -14,6 +14,7 @@ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
14
14
  /** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
15
15
  const CONTROLLER_GRACE_MS = 12_000;
16
16
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
17
+ const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
17
18
  const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
18
19
  /**
19
20
  * Map typed prompt provenance to the conversation ledger's source vocabulary.
@@ -792,20 +793,29 @@ export class AcpSession {
792
793
  }
793
794
  recordUpdate(update) {
794
795
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
795
- this.recordConversationUpdate(update, scheduled);
796
+ const messagePhase = update.sessionUpdate === 'agent_message_chunk'
797
+ ? this.codexMessagePhase(update) : undefined;
798
+ this.recordConversationUpdate(update, scheduled, messagePhase === 'commentary');
796
799
  if (update.sessionUpdate === 'config_option_update')
797
800
  this.captureRuntimeMetadata(update.configOptions);
798
801
  switch (update.sessionUpdate) {
799
- case 'agent_message_chunk':
800
- if (this.activeTurn && update.content.type === 'text')
802
+ case 'agent_message_chunk': {
803
+ const phase = messagePhase;
804
+ if (this.activeTurn && update.content.type === 'text'
805
+ && phase !== 'commentary' && phase !== 'ambiguous')
801
806
  this.activeTurn.output += update.content.text;
802
807
  this.events.emit('agent_text', {
803
808
  turnId: this.activeTurn?.id,
804
809
  origin: this.activeTurn?.origin,
805
810
  text: scheduled ? '[scheduled-loop output redacted]'
806
811
  : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
812
+ ...(phase === 'commentary' || phase === 'final_answer'
813
+ ? { messagePhase: phase } : {}),
814
+ ...(typeof update.messageId === 'string' ? { messageId: update.messageId } : {}),
815
+ ...(this.replaying ? { replayed: true } : {}),
807
816
  });
808
817
  break;
818
+ }
809
819
  case 'agent_thought_chunk':
810
820
  this.events.emit('thought', {
811
821
  turnId: this.activeTurn?.id,
@@ -836,9 +846,26 @@ export class AcpSession {
836
846
  break;
837
847
  }
838
848
  }
849
+ /**
850
+ * Codex ACP's phase extension is the only currently supported visibility
851
+ * signal. Never infer commentary from text, message order, or unknown meta.
852
+ */
853
+ codexMessagePhase(update) {
854
+ const meta = update._meta;
855
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta))
856
+ return undefined;
857
+ const codex = meta.codex;
858
+ if (!codex || typeof codex !== 'object' || Array.isArray(codex))
859
+ return undefined;
860
+ const phase = codex.phase;
861
+ if (phase === undefined)
862
+ return undefined;
863
+ return phase === 'commentary' || phase === 'final_answer' ? phase : 'ambiguous';
864
+ }
839
865
  /** Normalize every ACP update losslessly into the durable ledger. */
840
- recordConversationUpdate(update, scheduled) {
841
- const normalized = normalizeSessionUpdate(update, scheduled ? { redactText: SCHEDULED_LOOP_REDACTION } : {});
866
+ recordConversationUpdate(update, scheduled, commentary = false) {
867
+ const normalized = normalizeSessionUpdate(update, scheduled ? { redactText: SCHEDULED_LOOP_REDACTION }
868
+ : commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
842
869
  this.conversation.appendSafe({
843
870
  kind: normalized.kind,
844
871
  sessionGeneration: this.sessionGeneration,
@@ -24,7 +24,12 @@ export class SessionEvents {
24
24
  this.events.shift();
25
25
  try {
26
26
  this.rotateIfNeeded();
27
- appendFileSync(this.path, JSON.stringify(event) + '\n', { mode: 0o600 });
27
+ // Owner-visible commentary remains live/in-memory for the authenticated
28
+ // bridge, but its plaintext must not become diagnostic state on disk.
29
+ const persisted = event.kind === 'agent_text' && event.messagePhase === 'commentary'
30
+ ? { ...event, text: '[assistant commentary redacted]' }
31
+ : event;
32
+ appendFileSync(this.path, JSON.stringify(persisted) + '\n', { mode: 0o600 });
28
33
  }
29
34
  catch { /* diagnostics must never terminate a role */ }
30
35
  for (const listener of this.listeners)
@@ -149,6 +149,15 @@ export interface SessionEvent {
149
149
  toolCallId?: string;
150
150
  permissionId?: string;
151
151
  text?: string;
152
+ /**
153
+ * Adapter-authenticated presentation phase for assistant text. Only the
154
+ * exact Codex ACP marker is promoted; absence/unknown values stay unset.
155
+ */
156
+ messagePhase?: 'commentary' | 'final_answer';
157
+ /** Stable adapter message/item id when supplied. */
158
+ messageId?: string;
159
+ /** True when the adapter is replaying history rather than emitting live work. */
160
+ replayed?: boolean;
152
161
  title?: string;
153
162
  status?: string;
154
163
  stopReason?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ours.network/fleet",
3
- "version": "0.15.4",
3
+ "version": "0.15.5",
4
4
  "description": "Harness-agnostic fleet of persistent, identity-bound AI agents. Declarative fleet.yaml, tmux or ACP sessions, supervision, and ours.network messaging.",
5
5
  "type": "module",
6
6
  "license": "FSL-1.1-Apache-2.0",