@ours.network/fleet 0.18.0 → 0.19.0-nightly.1

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 (69) hide show
  1. package/README.md +57 -121
  2. package/dist/application/fleet-query-service.js +0 -12
  3. package/dist/application/role-creation-service.js +10 -7
  4. package/dist/application/types.d.ts +0 -11
  5. package/dist/briefing.js +6 -15
  6. package/dist/build-info.json +6 -7
  7. package/dist/capabilities.d.ts +1 -3
  8. package/dist/capabilities.js +0 -3
  9. package/dist/cli.js +7 -83
  10. package/dist/config.d.ts +3 -11
  11. package/dist/config.js +15 -40
  12. package/dist/creation.d.ts +38 -22
  13. package/dist/creation.js +111 -24
  14. package/dist/docs.d.ts +1 -1
  15. package/dist/docs.js +46 -120
  16. package/dist/doctor.d.ts +5 -1
  17. package/dist/doctor.js +18 -11
  18. package/dist/fleet-proxy.d.ts +0 -5
  19. package/dist/harness/acp-agent.js +6 -11
  20. package/dist/harness/claude-code.js +11 -204
  21. package/dist/harness/codex.d.ts +1 -4
  22. package/dist/harness/codex.js +12 -74
  23. package/dist/harness/types.d.ts +4 -54
  24. package/dist/index.d.ts +0 -2
  25. package/dist/index.js +0 -1
  26. package/dist/loops/manager.d.ts +1 -30
  27. package/dist/loops/manager.js +6 -69
  28. package/dist/loops/state.d.ts +0 -18
  29. package/dist/loops/state.js +0 -4
  30. package/dist/monitor.js +1 -1
  31. package/dist/ops.d.ts +3 -0
  32. package/dist/ops.js +8 -3
  33. package/dist/owner-channel/attachments.d.ts +25 -2
  34. package/dist/owner-channel/attachments.js +61 -5
  35. package/dist/owner-channel/channel.d.ts +29 -30
  36. package/dist/owner-channel/channel.js +291 -291
  37. package/dist/owner-channel/message-recovery.d.ts +25 -0
  38. package/dist/owner-channel/message-recovery.js +114 -0
  39. package/dist/owner-channel/notices.d.ts +0 -7
  40. package/dist/owner-channel/notices.js +0 -9
  41. package/dist/owner-channel/ours-client.d.ts +148 -0
  42. package/dist/owner-channel/ours-client.js +231 -0
  43. package/dist/resolved-plan.js +0 -1
  44. package/dist/runner.d.ts +0 -48
  45. package/dist/runner.js +94 -236
  46. package/dist/session/acp.d.ts +0 -104
  47. package/dist/session/acp.js +10 -213
  48. package/dist/session/conversation-normalizer.d.ts +0 -6
  49. package/dist/session/conversation-normalizer.js +10 -153
  50. package/dist/session/conversation-types.d.ts +4 -23
  51. package/dist/session/types.d.ts +0 -35
  52. package/dist/spawn.js +26 -33
  53. package/dist/supervisor/systemd.js +29 -2
  54. package/dist/watchdog/briefing.js +0 -7
  55. package/dist/watchdog/run.js +3 -3
  56. package/dist/web-app/assets/{TerminalView-C_G1ID2P.js → TerminalView-BAVk1Bot.js} +1 -1
  57. package/dist/web-app/assets/{index-BCBK78hw.js → index-C3S-xFRU.js} +5 -5
  58. package/dist/web-app/index.html +1 -1
  59. package/dist/worklog.d.ts +1 -7
  60. package/dist/worklog.js +39 -191
  61. package/package.json +3 -1
  62. package/dist/harness-plugins.d.ts +0 -48
  63. package/dist/harness-plugins.js +0 -309
  64. package/dist/model-env.d.ts +0 -71
  65. package/dist/model-env.js +0 -106
  66. package/dist/owner-channel/mcp.d.ts +0 -24
  67. package/dist/owner-channel/mcp.js +0 -145
  68. package/dist/session/activity.d.ts +0 -31
  69. package/dist/session/activity.js +0 -48
@@ -16,21 +16,6 @@ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
16
16
  const CONTROLLER_GRACE_MS = 12_000;
17
17
  /** Bound safe-boundary waiting without turning a hung tool into cancellation. */
18
18
  export const AFTER_TOOL_BOUNDARY_TIMEOUT_MS = 120_000;
19
- /**
20
- * How long a steering-started turn is presumed to still own the adapter after
21
- * its last update. Such a turn has no prompt id, so it never reports a
22
- * stopReason and there is no exact end to observe — silence is the only signal
23
- * available, and this is the bound that turns it into a decision.
24
- *
25
- * Sized from the fleet's own scheduled-run history: across 1513 completed
26
- * scheduled runs the longest silence WITHIN a working turn was 120.2 s (p99
27
- * 41.0 s; 5 runs above 60 s). A shorter grace would release the lease while the
28
- * adapter is still working and re-admit a prompt into a busy turn, which is the
29
- * FLEET-003 failure itself. The costs are deliberately asymmetric: holding too
30
- * long skips one best-effort maintenance tick, releasing too early SIGTERMs a
31
- * live role.
32
- */
33
- export const STEERING_OCCUPANCY_IDLE_MS = 150_000;
34
19
  const TERMINAL_TOOL_STATUSES = new Set(['completed', 'failed']);
35
20
  const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
36
21
  const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
@@ -208,8 +193,6 @@ export class AcpSession {
208
193
  child;
209
194
  events;
210
195
  conversation;
211
- /** Cursor before this runner generation began; older durable events stay off the live console. */
212
- conversationStartCursor;
213
196
  /** New on every runner start; permission/turn IDs from prior generations are stale. */
214
197
  sessionGeneration = randomUUID();
215
198
  /** True while `session/load` replays history as ordinary updates. */
@@ -219,12 +202,6 @@ export class AcpSession {
219
202
  connection;
220
203
  sessionId;
221
204
  readiness = 'starting';
222
- /**
223
- * Last non-replayed session update from the agent. `readiness` cannot answer
224
- * "is this agent working" for a steered turn (FLEET-002), and this is the
225
- * evidence that can.
226
- */
227
- lastUpdateAt;
228
205
  lastError;
229
206
  promptTail = Promise.resolve();
230
207
  queueDepth = 0;
@@ -240,13 +217,6 @@ export class AcpSession {
240
217
  cancelEscalation;
241
218
  cancelForceKill;
242
219
  cancelRecoveryReason;
243
- /**
244
- * Held while a steering-started turn is believed to own the adapter. It is a
245
- * lease, not a latch: `steeringRelease` always fires, so the role can never be
246
- * stranded busy by a wake whose turn ended without telling anyone.
247
- */
248
- steeringOccupied = false;
249
- steeringRelease;
250
220
  /**
251
221
  * Rejects the moment the adapter process is gone. Every in-flight ACP request
252
222
  * races it, so a dead adapter can never leave a turn — and therefore a
@@ -267,7 +237,6 @@ export class AcpSession {
267
237
  this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
268
238
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
269
239
  });
270
- this.conversationStartCursor = this.conversation.lastCursor();
271
240
  this.sessionFile = join(options.stateDir, '.acp-session-id');
272
241
  this.terminated = new Promise((_resolve, reject) => { this.terminate = reject; });
273
242
  // Nothing awaits this promise until a request races it; an unobserved
@@ -278,7 +247,6 @@ export class AcpSession {
278
247
  if (this.cancelForceKill)
279
248
  clearTimeout(this.cancelForceKill);
280
249
  this.cancelForceKill = undefined;
281
- this.releaseSteeringOccupancy('adapter exited');
282
250
  // Record the child's real exit code/signal. The tmux path can only see a
283
251
  // shell's `$?`; here the truth is available, so keep it.
284
252
  const classified = classifyChildExit(code, signal);
@@ -379,60 +347,17 @@ export class AcpSession {
379
347
  // terminal fact (signal exits deliberately leave exitCode null).
380
348
  return this.child.exitCode === null && (this.child.signalCode ?? null) === null;
381
349
  }
382
- /**
383
- * Take the occupancy lease for a turn the adapter started on its own behalf.
384
- * Refreshed by every adapter update, so it tracks work actually happening
385
- * rather than a fixed guess at how long a wake takes.
386
- */
387
- holdSteeringOccupancy() {
388
- if (this.closing || !this.isAlive())
389
- return;
390
- this.steeringOccupied = true;
391
- this.refreshSteeringOccupancy();
392
- }
393
- refreshSteeringOccupancy() {
394
- if (!this.steeringOccupied)
395
- return;
396
- if (this.steeringRelease)
397
- clearTimeout(this.steeringRelease);
398
- this.steeringRelease = setTimeout(() => this.releaseSteeringOccupancy('adapter silent'), this.options.steeringOccupancyIdleMs ?? STEERING_OCCUPANCY_IDLE_MS);
399
- this.steeringRelease.unref?.();
400
- }
401
- /**
402
- * Every exit from occupancy comes through here, including the ones that are
403
- * not the timer: a real turn boundary, close, and adapter exit. A lease that
404
- * can leak is worse than the bug it fixes — it would leave the role reporting
405
- * `running` forever and starve scheduled admission permanently.
406
- */
407
- releaseSteeringOccupancy(reason) {
408
- if (this.steeringRelease)
409
- clearTimeout(this.steeringRelease);
410
- this.steeringRelease = undefined;
411
- if (!this.steeringOccupied)
412
- return;
413
- this.steeringOccupied = false;
414
- this.options.log(`[${this.options.name}] steering-started turn no longer holds the adapter (${reason})`);
415
- }
416
350
  snapshot() {
417
351
  return {
418
352
  backend: 'acp',
419
353
  alive: this.isAlive(),
420
- // A steering-started turn is real work with no prompt id. Reporting the
421
- // session idle while it runs is what let the arbiter admit a scheduled
422
- // prompt into a busy adapter, whose `session/prompt` then never returned
423
- // a stopReason and ended in a cancellation deadline and a SIGTERM.
424
- readiness: this.readiness === 'idle' && this.steeringOccupied
425
- ? 'running' : this.readiness,
354
+ readiness: this.readiness,
426
355
  sessionId: this.sessionId,
427
356
  lastError: this.lastError,
428
357
  pendingPermissionId: this.pendingPermissions.keys().next().value,
429
358
  runtimeModel: this.runtimeModel,
430
359
  reasoningEffort: this.reasoningEffort,
431
360
  permissionMode: this.options.permissionMode,
432
- activity: {
433
- activeToolCalls: this.activeToolCalls.size,
434
- ...(this.lastUpdateAt ? { lastUpdateAt: this.lastUpdateAt } : {}),
435
- },
436
361
  };
437
362
  }
438
363
  toolCall(toolCallId) {
@@ -601,19 +526,15 @@ export class AcpSession {
601
526
  throw new SessionControlError('control-unavailable', 'ACP adapter restart is in progress after the cancellation deadline', ACP_CANCEL_DEADLINE_EXCEEDED);
602
527
  if (this.closing || !this.sessionId || !this.isAlive())
603
528
  throw new SessionControlError('offline', this.lastError ?? 'ACP session is offline');
604
- const delivery = options.interrupt
605
- ? await this.prepareInterruptingDelivery(options.interruptSource ?? 'local-console')
606
- : undefined;
529
+ if (options.interrupt)
530
+ await this.cancelActive(options.interruptSource ?? 'local-console');
607
531
  // Interrupting delivery must still use steering when supported. With no
608
532
  // live turn, the extension starts one and acknowledges `startedNewTurn`
609
533
  // immediately; a normal session/prompt would keep the monitor blocked until
610
534
  // the entire wake-triggered turn terminated.
611
535
  if (options.steer && this.steeringSupported) {
612
536
  const promptId = randomUUID();
613
- return {
614
- promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin,
615
- ...(delivery ? { delivery } : {}),
616
- };
537
+ return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
617
538
  }
618
539
  const promptId = randomUUID();
619
540
  const queuedBehind = this.queueDepth;
@@ -625,46 +546,7 @@ export class AcpSession {
625
546
  this.queueDepth = Math.max(0, this.queueDepth - 1);
626
547
  return turnResult(false, 'failed', error?.message ?? String(error));
627
548
  });
628
- return {
629
- promptId, queuedBehind, completion, origin: options.origin,
630
- delivery: delivery ?? (queuedBehind > 0 ? 'queued' : 'started'),
631
- };
632
- }
633
- /**
634
- * Prepare the session for a prompt that asked to pre-empt current work.
635
- *
636
- * The old behaviour was one unconditional `session/cancel` notification
637
- * followed immediately by `session/prompt`. That is what produced the owner's
638
- * "request failed before completion":
639
- *
640
- * - `cancelActive` only awaits settlement when `this.activeTurn` is set, and
641
- * a turn the ADAPTER started (steering's `startedNewTurn`) is never tracked
642
- * here. So the cancel raced the adapter's own transcript repair and the new
643
- * prompt landed while the last assistant message still held an unresolved
644
- * `tool_use` — rejected with `stop_reason=tool_use`.
645
- * - With nothing running at all, it still sent the cancel, and the prompt
646
- * landed on a bare interrupted user message — rejected with
647
- * `stop_reason=null`.
648
- *
649
- * So: never cancel across a tool boundary, and never cancel something whose
650
- * settlement cannot be awaited. Everything else is queued, which the ACP queue
651
- * already does correctly. The returned state is what the caller may claim to a
652
- * human — `interrupted` only when a turn really was cancelled.
653
- */
654
- async prepareInterruptingDelivery(source) {
655
- if (!this.sessionId)
656
- return 'started';
657
- // No fleet-tracked turn to await. Either the session is idle — cancelling it
658
- // corrupts the transcript for no gain — or the adapter is running a turn
659
- // fleet never started, whose settlement nothing here can wait for. Queue in
660
- // both cases: the ACP queue already orders this correctly.
661
- if (!this.activeTurn)
662
- return this.activeToolCalls.size > 0 ? 'deferred' : 'started';
663
- // A tracked turn IS safe to cancel: cancelActive settles pending permissions
664
- // and awaits the turn's own settlement before this returns, so the prompt
665
- // below cannot race the adapter's transcript repair.
666
- await this.cancelActive(source);
667
- return 'interrupted';
549
+ return { promptId, queuedBehind, completion, origin: options.origin };
668
550
  }
669
551
  /**
670
552
  * Durably record a prompt admission BEFORE acceptance is returned. Browser
@@ -973,7 +855,6 @@ export class AcpSession {
973
855
  if (this.controllerGrace)
974
856
  clearTimeout(this.controllerGrace);
975
857
  this.controllerGrace = undefined;
976
- this.releaseSteeringOccupancy('session closed');
977
858
  for (const [permissionId, pending] of [...this.pendingPermissions])
978
859
  this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
979
860
  this.releaseAllTools();
@@ -991,17 +872,6 @@ export class AcpSession {
991
872
  });
992
873
  this.conversation.close();
993
874
  }
994
- /**
995
- * The role's declared MCP servers, or `[]`.
996
- *
997
- * Sent on resume and load as well as on new: the agent builds its server set
998
- * once per session, so a resumed session that omitted them would come back
999
- * without the tools the role's config declares — which is exactly the shape of
1000
- * silent drop this plumbing exists to end.
1001
- */
1002
- declaredMcpServers() {
1003
- return this.options.mcpServers ?? [];
1004
- }
1005
875
  async initialize() {
1006
876
  const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
1007
877
  protocolVersion: acp.PROTOCOL_VERSION,
@@ -1021,7 +891,7 @@ export class AcpSession {
1021
891
  const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
1022
892
  sessionId: persisted,
1023
893
  cwd: this.options.cwd,
1024
- mcpServers: this.declaredMcpServers(),
894
+ mcpServers: [],
1025
895
  });
1026
896
  this.captureRuntimeMetadata(resumed.configOptions);
1027
897
  this.sessionId = persisted;
@@ -1034,7 +904,7 @@ export class AcpSession {
1034
904
  const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
1035
905
  sessionId: persisted,
1036
906
  cwd: this.options.cwd,
1037
- mcpServers: this.declaredMcpServers(),
907
+ mcpServers: [],
1038
908
  });
1039
909
  this.captureRuntimeMetadata(loaded.configOptions);
1040
910
  }
@@ -1046,8 +916,7 @@ export class AcpSession {
1046
916
  else {
1047
917
  const created = await this.connection.agent.request(acp.methods.agent.session.new, {
1048
918
  cwd: this.options.cwd,
1049
- mcpServers: this.declaredMcpServers(),
1050
- ...(this.options.sessionMeta ? { _meta: this.options.sessionMeta } : {}),
919
+ mcpServers: [],
1051
920
  });
1052
921
  this.sessionId = created.sessionId;
1053
922
  this.captureRuntimeMetadata(created.configOptions);
@@ -1144,10 +1013,6 @@ export class AcpSession {
1144
1013
  }
1145
1014
  finally {
1146
1015
  this.releaseAllTools();
1147
- // A turn this client owned has ended, so the adapter has reported a
1148
- // boundary: whatever a steering call started before it is over too. This
1149
- // is the release path that does not depend on the silence timer.
1150
- this.releaseSteeringOccupancy('turn boundary');
1151
1016
  if (this.activeTurn?.id === turnId) {
1152
1017
  this.activeTurn.settle();
1153
1018
  if (this.cancelEscalation)
@@ -1170,12 +1035,6 @@ export class AcpSession {
1170
1035
  ]);
1171
1036
  if (response.outcome === 'failed')
1172
1037
  return turnResult(false, 'failed', 'ACP steering failed');
1173
- // `injected` joined a turn this client already owns and will settle.
1174
- // `startedNewTurn` created one nobody owns: the adapter is working and
1175
- // will never answer for it, so admission has to learn about it here or
1176
- // not at all.
1177
- if (response.outcome === 'startedNewTurn')
1178
- this.holdSteeringOccupancy();
1179
1038
  return turnResult(true, 'inconclusive', response.outcome);
1180
1039
  }
1181
1040
  catch (error) {
@@ -1203,17 +1062,6 @@ export class AcpSession {
1203
1062
  // Permission is part of the tool lifecycle. Reserve before any policy or
1204
1063
  // human decision so a monitor wake cannot slip between request and answer.
1205
1064
  this.reservePermission(toolCallId, permissionId);
1206
- if (this.isEffectiveCodexProtectedMcpApproval(params)) {
1207
- // Protected MCP approval is already the tool's narrow gate. Never turn
1208
- // this one decision into an adapter-wide standing grant.
1209
- const option = choose(['allow_once']);
1210
- const response = this.settleAutomatically(params, option, 'allowed', 'permissionMode.fleetMode=allow', 'the trusted Codex adapter authenticated a protected MCP approval request');
1211
- if (option)
1212
- this.allowPermission(toolCallId, permissionId);
1213
- else
1214
- this.releasePermission(toolCallId, permissionId);
1215
- return Promise.resolve(response);
1216
- }
1217
1065
  if (this.options.permissions.approval === 'allow' && this.withinAutomaticBoundary(params)) {
1218
1066
  const option = choose(['allow_always', 'allow_once']);
1219
1067
  const response = this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`);
@@ -1340,34 +1188,7 @@ export class AcpSession {
1340
1188
  const cwd = resolve(this.options.cwd);
1341
1189
  return canonicallyWithin(cwd, locations.map(location => resolve(location.path)));
1342
1190
  }
1343
- /**
1344
- * Codex ACP 1.1.7 marks its protected MCP elicitation bridge on a locationless
1345
- * execute request. The marker is meaningful only together with the runner's
1346
- * independently supplied, adapter-authenticated metadata vocabulary and effective
1347
- * mode: an arbitrary ACP process cannot gain this path by copying `_meta` alone.
1348
- * Exact option ids/kinds bind recognition to the protected-MCP shape and keep
1349
- * malformed requests on the ordinary fail-closed path.
1350
- */
1351
- isEffectiveCodexProtectedMcpApproval(params) {
1352
- const locations = params.toolCall.locations ?? [];
1353
- return this.options.permissionMetadataSource === 'codex-acp'
1354
- && this.options.permissionMode?.fleetMode === 'allow'
1355
- && params.toolCall.kind === 'execute'
1356
- && params.toolCall.status === 'pending'
1357
- && locations.length === 0
1358
- && params._meta?.is_mcp_tool_approval === true
1359
- && params.options.some(option => option.optionId === 'allow_once' && option.kind === 'allow_once')
1360
- && params.options.some(option => option.optionId === 'decline' && option.kind === 'reject_once');
1361
- }
1362
1191
  recordUpdate(update) {
1363
- // Replayed history is not current activity: `session/load` would otherwise
1364
- // make a cold session look like it had just been working. The same reason
1365
- // keeps it from extending the steering lease, which is evidence the adapter
1366
- // is working right now — for a steering-started turn, the only evidence.
1367
- if (!this.replaying) {
1368
- this.lastUpdateAt = new Date().toISOString();
1369
- this.refreshSteeringOccupancy();
1370
- }
1371
1192
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
1372
1193
  const messagePhase = update.sessionUpdate === 'agent_message_chunk'
1373
1194
  ? this.codexMessagePhase(update) : undefined;
@@ -1467,25 +1288,7 @@ export class AcpSession {
1467
1288
  }
1468
1289
  // ── conversation ledger access (SessionHandle) ─────────────────────────────
1469
1290
  conversationPage(request = {}) {
1470
- const floor = Number(this.conversationStartCursor ?? 0);
1471
- const requested = Number(request.after ?? 0);
1472
- let after = String(Math.max(Number.isSafeInteger(floor) ? floor : 0, Number.isSafeInteger(requested) ? requested : 0));
1473
- const limit = Math.min(Math.max(request.limit ?? 200, 1), 1_000);
1474
- let page = this.conversation.page({ after, limit });
1475
- let visible = page.events.filter(event => this.isCurrentConversationEvent(event));
1476
- // A resumed adapter may replay a page made entirely of prior session/load
1477
- // history. Advance over it without exposing it or making the browser stop
1478
- // before later current-session records.
1479
- while (!visible.length && page.hasMore && page.nextCursor && page.nextCursor !== after) {
1480
- after = page.nextCursor;
1481
- page = this.conversation.page({ after, limit });
1482
- visible = page.events.filter(event => this.isCurrentConversationEvent(event));
1483
- }
1484
- return {
1485
- ...page,
1486
- events: visible,
1487
- snapshot: this.conversationSnapshot(),
1488
- };
1291
+ return { ...this.conversation.page(request), snapshot: this.conversationSnapshot() };
1489
1292
  }
1490
1293
  conversationSnapshot() {
1491
1294
  return {
@@ -1497,13 +1300,7 @@ export class AcpSession {
1497
1300
  };
1498
1301
  }
1499
1302
  subscribeConversation(listener) {
1500
- return this.conversation.subscribe(event => {
1501
- if (this.isCurrentConversationEvent(event))
1502
- listener(event);
1503
- });
1504
- }
1505
- isCurrentConversationEvent(event) {
1506
- return event.sessionGeneration === this.sessionGeneration && event.source !== 'agent_replay';
1303
+ return this.conversation.subscribe(listener);
1507
1304
  }
1508
1305
  fail(error) {
1509
1306
  this.lastError = error?.message ?? String(error);
@@ -11,12 +11,6 @@ import type { AdapterMeta, ConversationEventKind, ConversationPayload } from './
11
11
  */
12
12
  /** Cap for any single normalized text payload (spec §5.3). */
13
13
  export declare const MAX_TEXT_BYTES: number;
14
- /** Cap for each retained side of an oversized snapshot-style file diff. */
15
- export declare const MAX_DIFF_TEXT_BYTES: number;
16
- /** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
17
- export declare const MAX_PATH_BYTES: number;
18
- /** Hard cap for the complete normalized update before the durable event envelope is added. */
19
- export declare const MAX_NORMALIZED_UPDATE_BYTES: number;
20
14
  /** Cap for one adapter `_meta` namespace value. */
21
15
  export declare const MAX_META_BYTES: number;
22
16
  /** Cap for serialized raw tool input/output retained as structured JSON. */
@@ -10,12 +10,6 @@ import { createHash } from 'node:crypto';
10
10
  */
11
11
  /** Cap for any single normalized text payload (spec §5.3). */
12
12
  export const MAX_TEXT_BYTES = 256 * 1024;
13
- /** Cap for each retained side of an oversized snapshot-style file diff. */
14
- export const MAX_DIFF_TEXT_BYTES = 64 * 1024;
15
- /** Cap for attacker-controlled filesystem paths while retaining their useful basename tail. */
16
- export const MAX_PATH_BYTES = 4 * 1024;
17
- /** Hard cap for the complete normalized update before the durable event envelope is added. */
18
- export const MAX_NORMALIZED_UPDATE_BYTES = 320 * 1024;
19
13
  /** Cap for one adapter `_meta` namespace value. */
20
14
  export const MAX_META_BYTES = 16 * 1024;
21
15
  /** Cap for serialized raw tool input/output retained as structured JSON. */
@@ -33,28 +27,6 @@ function truncateUtf8(text, maxBytes) {
33
27
  const buffer = Buffer.from(text).subarray(0, maxBytes);
34
28
  return buffer.toString('utf8').replace(/�+$/u, '');
35
29
  }
36
- /** Keep a UTF-8-safe suffix. Paths and identifiers have no line semantics. */
37
- function truncateUtf8Tail(text, maxBytes) {
38
- const buffer = Buffer.from(text);
39
- let start = Math.max(0, buffer.length - maxBytes);
40
- while (start < buffer.length && (buffer[start] & 0xc0) === 0x80)
41
- start++;
42
- return { text: buffer.subarray(start).toString('utf8'), omittedPrefixBytes: start };
43
- }
44
- function boundedPath(raw) {
45
- const path = asString(raw) ?? '';
46
- const pathBytes = Buffer.byteLength(path);
47
- if (pathBytes <= MAX_PATH_BYTES)
48
- return { path };
49
- const retained = truncateUtf8Tail(path, MAX_PATH_BYTES);
50
- return {
51
- path: retained.text,
52
- pathBytes,
53
- pathTruncated: true,
54
- pathDigest: digest24(path),
55
- pathOmittedPrefixBytes: retained.omittedPrefixBytes,
56
- };
57
- }
58
30
  function cappedText(raw, redact) {
59
31
  const text = asString(raw) ?? '';
60
32
  const bytes = Buffer.byteLength(text);
@@ -67,95 +39,6 @@ function cappedText(raw, redact) {
67
39
  truncated: true, digest: digest24(text),
68
40
  };
69
41
  }
70
- /** Keep the newest UTF-8 tail, aligning to a whole line whenever one fits. */
71
- function cappedTextTail(text, maxBytes) {
72
- const buffer = Buffer.from(text);
73
- const bytes = buffer.length;
74
- if (bytes <= maxBytes)
75
- return { text, bytes };
76
- let start = bytes - maxBytes;
77
- while (start < bytes && (buffer[start] & 0xc0) === 0x80)
78
- start++;
79
- let startsMidLine = start > 0 && buffer[start - 1] !== 0x0a;
80
- if (startsMidLine) {
81
- const newline = buffer.indexOf(0x0a, start);
82
- if (newline >= 0 && newline + 1 < bytes) {
83
- start = newline + 1;
84
- startsMidLine = false;
85
- }
86
- }
87
- return {
88
- text: buffer.subarray(start).toString('utf8'), bytes,
89
- truncated: true, digest: digest24(text), omittedPrefixBytes: start,
90
- ...(startsMidLine ? { startsMidLine: true } : {}),
91
- };
92
- }
93
- /** Common unchanged edges in UTF-16 indices, adjusted away from split surrogates. */
94
- function commonEdges(oldText, newText) {
95
- const limit = Math.min(oldText.length, newText.length);
96
- let prefix = 0;
97
- while (prefix < limit && oldText.charCodeAt(prefix) === newText.charCodeAt(prefix))
98
- prefix++;
99
- if (prefix > 0 && prefix < limit
100
- && oldText.charCodeAt(prefix) >= 0xdc00 && oldText.charCodeAt(prefix) <= 0xdfff)
101
- prefix--;
102
- let oldEnd = oldText.length;
103
- let newEnd = newText.length;
104
- while (oldEnd > prefix && newEnd > prefix
105
- && oldText.charCodeAt(oldEnd - 1) === newText.charCodeAt(newEnd - 1)) {
106
- oldEnd--;
107
- newEnd--;
108
- }
109
- // A suffix must never begin at the low half of a surrogate pair.
110
- if (oldEnd < oldText.length && oldText.charCodeAt(oldEnd) >= 0xdc00
111
- && oldText.charCodeAt(oldEnd) <= 0xdfff) {
112
- oldEnd++;
113
- newEnd++;
114
- }
115
- return { prefix, suffix: oldText.length - oldEnd };
116
- }
117
- function normalizedDiff(item, redact) {
118
- const path = boundedPath(item.path);
119
- const oldText = asString(item.oldText);
120
- const newText = asString(item.newText) ?? '';
121
- // Preserve the established small-diff contract exactly. Redacted turns also
122
- // retain their established placeholder shape and never derive private text.
123
- if (redact !== undefined || oldText === undefined
124
- || (Buffer.byteLength(oldText) <= MAX_TEXT_BYTES
125
- && Buffer.byteLength(newText) <= MAX_TEXT_BYTES)) {
126
- return {
127
- type: 'diff', ...path,
128
- newText: cappedText(newText, redact),
129
- ...(oldText !== undefined ? { oldText: cappedText(oldText, redact) } : {}),
130
- };
131
- }
132
- // ACP adapters may describe an append by sending two complete file snapshots.
133
- // Persist only the changed region: otherwise a multi-megabyte historical file
134
- // contributes its prefix twice while the current append disappears past the cap.
135
- const { prefix, suffix } = commonEdges(oldText, newText);
136
- const oldEnd = oldText.length - suffix;
137
- const newEnd = newText.length - suffix;
138
- const oldDelta = oldText.slice(prefix, oldEnd);
139
- const newDelta = newText.slice(prefix, newEnd);
140
- const beforeBytes = Buffer.byteLength(oldText);
141
- const afterBytes = Buffer.byteLength(newText);
142
- const commonPrefixBytes = Buffer.byteLength(oldText.slice(0, prefix));
143
- const commonSuffixBytes = Buffer.byteLength(oldText.slice(oldEnd));
144
- const operation = oldDelta.length === 0 && newDelta.length === 0
145
- ? 'noop'
146
- : oldDelta.length === 0 && prefix === oldText.length
147
- ? 'append'
148
- : newDelta.length === 0
149
- ? 'delete'
150
- : prefix === 0 && suffix === 0 ? 'replace' : 'edit';
151
- return {
152
- type: 'diff', ...path, operation, beforeBytes, afterBytes,
153
- commonPrefixBytes, commonSuffixBytes, bounded: true,
154
- newText: cappedTextTail(newDelta, MAX_DIFF_TEXT_BYTES),
155
- ...(oldDelta.length > 0
156
- ? { oldText: cappedTextTail(oldDelta, MAX_DIFF_TEXT_BYTES) } : {}),
157
- };
158
- }
159
42
  function normalizedText(raw, redact) {
160
43
  const text = asString(raw) ?? '';
161
44
  const bytes = Buffer.byteLength(text);
@@ -306,7 +189,12 @@ function normalizeToolContent(raw, redact) {
306
189
  return raw.filter(isRecord).map((item) => {
307
190
  switch (item.type) {
308
191
  case 'diff':
309
- return normalizedDiff(item, redact);
192
+ return {
193
+ type: 'diff',
194
+ path: asString(item.path) ?? '',
195
+ newText: cappedText(item.newText, redact),
196
+ ...(item.oldText != null ? { oldText: cappedText(item.oldText, redact) } : {}),
197
+ };
310
198
  case 'terminal':
311
199
  return { type: 'terminal', terminalId: asString(item.terminalId) ?? '' };
312
200
  case 'content':
@@ -332,7 +220,7 @@ function toolUpsert(update, snapshot, options) {
332
220
  payload.content = content;
333
221
  if (Array.isArray(update.locations)) {
334
222
  payload.locations = update.locations.filter(isRecord).map(location => ({
335
- ...boundedPath(location.path),
223
+ path: asString(location.path) ?? '',
336
224
  ...(asFiniteNumber(location.line) !== undefined ? { line: asFiniteNumber(location.line) } : {}),
337
225
  }));
338
226
  }
@@ -352,49 +240,18 @@ function unsupported(update) {
352
240
  }
353
241
  const kind = isRecord(update) ? asString(update.sessionUpdate) : undefined;
354
242
  return {
355
- sessionUpdate: truncateUtf8(kind ?? 'unknown', 256),
243
+ sessionUpdate: kind ?? 'unknown',
356
244
  bytes: Buffer.byteLength(serialized),
357
245
  preview: serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS),
358
- ...(Buffer.byteLength(serialized) > Buffer.byteLength(serialized.slice(0, MAX_UNSUPPORTED_PREVIEW_CHARS))
359
- ? { digest: digest24(serialized) } : {}),
360
- };
361
- }
362
- function capNormalizedUpdate(result, sessionUpdate) {
363
- let serialized;
364
- try {
365
- serialized = JSON.stringify(result);
366
- }
367
- catch {
368
- return {
369
- kind: 'unsupported',
370
- payload: {
371
- sessionUpdate: truncateUtf8(sessionUpdate, 256),
372
- bytes: 0,
373
- preview: '[normalized update was not serializable]',
374
- },
375
- };
376
- }
377
- const bytes = Buffer.byteLength(serialized);
378
- if (bytes <= MAX_NORMALIZED_UPDATE_BYTES)
379
- return result;
380
- return {
381
- kind: 'unsupported',
382
- payload: {
383
- sessionUpdate: truncateUtf8(sessionUpdate, 256),
384
- bytes,
385
- digest: digest24(serialized),
386
- preview: `[normalized update exceeded ${MAX_NORMALIZED_UPDATE_BYTES}-byte durable-event cap]`,
387
- },
388
246
  };
389
247
  }
390
248
  export function normalizeSessionUpdate(update, options = {}) {
391
249
  const redact = options.redactText;
392
250
  const raw = update;
393
251
  if (!isRecord(raw) || typeof raw.sessionUpdate !== 'string')
394
- return capNormalizedUpdate({ kind: 'unsupported', payload: unsupported(raw) }, 'unknown');
395
- const sessionUpdate = raw.sessionUpdate;
252
+ return { kind: 'unsupported', payload: unsupported(raw) };
396
253
  const adapterMeta = quarantineMeta(raw._meta);
397
- const withMeta = (result) => capNormalizedUpdate(adapterMeta ? { ...result, adapterMeta } : result, sessionUpdate);
254
+ const withMeta = (result) => adapterMeta ? { ...result, adapterMeta } : result;
398
255
  switch (raw.sessionUpdate) {
399
256
  case 'user_message_chunk':
400
257
  case 'agent_message_chunk': {
@@ -48,33 +48,15 @@ export interface CappedText {
48
48
  bytes: number;
49
49
  truncated?: true;
50
50
  digest?: string;
51
- /** Bytes omitted from the front when the retained fragment is a tail. */
52
- omittedPrefixBytes?: number;
53
- /** The retained tail starts inside one logical line because that line alone exceeded the cap. */
54
- startsMidLine?: true;
55
- }
56
- /** A path kept as a UTF-8-safe tail, with additive provenance only when capped. */
57
- export interface BoundedPath {
58
- path: string;
59
- pathBytes?: number;
60
- pathTruncated?: true;
61
- pathDigest?: string;
62
- pathOmittedPrefixBytes?: number;
63
51
  }
64
52
  export type NormalizedToolContent = {
65
53
  type: 'content';
66
54
  content: NormalizedContentBlock;
67
- } | BoundedPath & {
55
+ } | {
68
56
  type: 'diff';
57
+ path: string;
69
58
  newText: CappedText;
70
59
  oldText?: CappedText;
71
- /** Additive provenance for oversized snapshot diffs reduced to their changed region. */
72
- operation?: 'append' | 'edit' | 'delete' | 'noop' | 'replace';
73
- beforeBytes?: number;
74
- afterBytes?: number;
75
- commonPrefixBytes?: number;
76
- commonSuffixBytes?: number;
77
- bounded?: true;
78
60
  }
79
61
  /** A tool-owned display terminal reference — never a PTY attachment. */
80
62
  | {
@@ -123,7 +105,8 @@ export interface ToolUpsertPayload {
123
105
  kind?: string;
124
106
  status?: string;
125
107
  content?: NormalizedToolContent[];
126
- locations?: Array<BoundedPath & {
108
+ locations?: Array<{
109
+ path: string;
127
110
  line?: number;
128
111
  }>;
129
112
  rawInput?: BoundedJson;
@@ -157,8 +140,6 @@ export interface UnsupportedPayload {
157
140
  /** The wire discriminant (or 'unknown' when even that was absent). */
158
141
  sessionUpdate: string;
159
142
  bytes: number;
160
- /** Digest of the omitted original/normalized representation when it was bounded. */
161
- digest?: string;
162
143
  /** Sanitized JSON preview, capped; enough to diagnose, never to exhaust. */
163
144
  preview?: string;
164
145
  }