@ours.network/fleet 1.2.0-nightly.63 → 1.2.0-nightly.64

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 (34) hide show
  1. package/README.md +3 -0
  2. package/dist/application/conversation-history.js +4 -1
  3. package/dist/application/conversation-role-observer.d.ts +62 -0
  4. package/dist/application/conversation-role-observer.js +206 -0
  5. package/dist/application/session-control.d.ts +31 -17
  6. package/dist/application/session-control.js +148 -33
  7. package/dist/build-info.json +4 -4
  8. package/dist/session/acp.d.ts +8 -1
  9. package/dist/session/acp.js +97 -43
  10. package/dist/session/arbiter.d.ts +4 -0
  11. package/dist/session/arbiter.js +18 -0
  12. package/dist/session/codex-app-server.d.ts +7 -1
  13. package/dist/session/codex-app-server.js +60 -9
  14. package/dist/session/control.d.ts +5 -2
  15. package/dist/session/control.js +145 -20
  16. package/dist/session/conversation-observation.d.ts +25 -0
  17. package/dist/session/conversation-observation.js +100 -0
  18. package/dist/session/conversation-replay.d.ts +50 -0
  19. package/dist/session/conversation-replay.js +203 -0
  20. package/dist/session/conversation-store.d.ts +14 -0
  21. package/dist/session/conversation-store.js +201 -17
  22. package/dist/session/conversation-types.d.ts +29 -1
  23. package/dist/session/types.d.ts +6 -0
  24. package/dist/web/auth.d.ts +2 -0
  25. package/dist/web/auth.js +10 -1
  26. package/dist/web/conversation-stream-codec.d.ts +23 -0
  27. package/dist/web/conversation-stream-codec.js +110 -0
  28. package/dist/web/conversation-stream.d.ts +25 -0
  29. package/dist/web/conversation-stream.js +231 -0
  30. package/dist/web/runtime.js +12 -2
  31. package/dist/web/server.d.ts +2 -0
  32. package/dist/web/server.js +2 -0
  33. package/docs/conversation-stream.md +176 -0
  34. package/package.json +2 -1
@@ -4,7 +4,7 @@ import * as acp from '@agentclientprotocol/sdk';
4
4
  import type { CommonPermissions } from '../config.js';
5
5
  import type { AcpMcpServer } from '../harness/types.js';
6
6
  import { ConversationEventStore } from './conversation-store.js';
7
- import type { ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
7
+ import type { ConversationObservationState, ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
8
8
  import { type PendingPrompt } from './prompt-batch.js';
9
9
  import type { ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, AgentSession, RuntimeSelectorMetadata, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnOutcome, TurnResult } from './types.js';
10
10
  /** Bound safe-boundary waiting without turning a hung tool into cancellation. */
@@ -157,7 +157,9 @@ export declare class AcpSession implements AgentSession {
157
157
  private readonly sharedTurns;
158
158
  /** Commands the adapter currently advertises; only these are control operations. */
159
159
  private advertisedCommands;
160
+ private presentedCommands;
160
161
  private queueDepth;
162
+ private readonly observation;
161
163
  private exit;
162
164
  private steeringSupported;
163
165
  private agentCapabilities?;
@@ -396,6 +398,11 @@ export declare class AcpSession implements AgentSession {
396
398
  after?: string;
397
399
  limit?: number;
398
400
  }): ConversationHandlePage;
401
+ subscribeConversationFailure(listener: (generation: string) => void): () => void;
402
+ conversationReplayAvailable(expectedGeneration: string): boolean;
403
+ replayConversation(request: import('./conversation-replay.js').ConversationReplayRequest): AsyncGenerator<import("./conversation-replay.js").ConversationReplayPage, any, any>;
404
+ observeConversation(listener: (state: ConversationObservationState) => void): import("./conversation-types.js").ConversationObservation;
405
+ private captureObservation;
399
406
  conversationSnapshot(): ConversationSnapshot;
400
407
  subscribeConversation(listener: Parameters<ConversationEventStore['subscribe']>[0]): () => void;
401
408
  private isCurrentConversationEvent;
@@ -1,3 +1,4 @@
1
+ import { ConversationObservation } from './conversation-observation.js';
1
2
  import { appendTaskCreated } from './task-notice.js';
2
3
  import { spawn } from 'node:child_process';
3
4
  import { createHash, randomUUID } from 'node:crypto';
@@ -278,7 +279,9 @@ export class AcpSession {
278
279
  sharedTurns = new Map();
279
280
  /** Commands the adapter currently advertises; only these are control operations. */
280
281
  advertisedCommands = new Set();
282
+ presentedCommands = [];
281
283
  queueDepth = 0;
284
+ observation = new ConversationObservation(() => this.captureObservation());
282
285
  exit = null;
283
286
  steeringSupported = false;
284
287
  agentCapabilities;
@@ -330,6 +333,9 @@ export class AcpSession {
330
333
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
331
334
  });
332
335
  this.conversationStartCursor = this.conversation.lastCursor();
336
+ // A durable append can precede its inventory mutation. Capture only after
337
+ // the synchronous owner checkpoint, never the state at append notification.
338
+ this.conversation.subscribe(() => this.observation.scheduleCapture());
333
339
  this.sessionFile = join(options.stateDir, '.acp-session-id');
334
340
  this.terminated = new Promise((_resolve, reject) => { this.terminate = reject; });
335
341
  // Nothing awaits this promise until a request races it; an unobserved
@@ -363,6 +369,7 @@ export class AcpSession {
363
369
  acpSessionId: this.sessionId,
364
370
  payload: { status: 'failed', detail: this.lastError },
365
371
  });
372
+ this.observation.publish();
366
373
  });
367
374
  }
368
375
  static async start(options) {
@@ -451,7 +458,8 @@ export class AcpSession {
451
458
  const promptId = open.promptId;
452
459
  this.queueDepth++;
453
460
  const run = this.enqueue(promptId, text, undefined);
454
- void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); });
461
+ this.observation.publish();
462
+ void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); this.observation.publish(); });
455
463
  }
456
464
  }
457
465
  isAlive() {
@@ -470,6 +478,7 @@ export class AcpSession {
470
478
  return;
471
479
  this.steeringOccupied = true;
472
480
  this.refreshSteeringOccupancy();
481
+ this.observation.publish();
473
482
  }
474
483
  refreshSteeringOccupancy() {
475
484
  if (!this.steeringOccupied)
@@ -492,6 +501,7 @@ export class AcpSession {
492
501
  if (!this.steeringOccupied)
493
502
  return;
494
503
  this.steeringOccupied = false;
504
+ this.observation.publish();
495
505
  this.options.log(`[${this.options.name}] steering-started turn no longer holds the adapter (${reason})`);
496
506
  }
497
507
  snapshot() {
@@ -714,11 +724,18 @@ export class AcpSession {
714
724
  }
715
725
  const promptId = randomUUID();
716
726
  const queuedBehind = this.queueDepth;
717
- this.admitToLedger(promptId, text, queuedBehind, options);
718
- this.queueDepth++;
719
- const run = this.enqueue(promptId, text, options.origin);
720
- const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
727
+ const run = this.observation.checkpoint(() => {
728
+ this.admitToLedger(promptId, text, queuedBehind, options);
729
+ this.queueDepth++;
730
+ return this.enqueue(promptId, text, options.origin);
731
+ });
732
+ const completion = run.then(result => {
733
+ this.queueDepth = Math.max(0, this.queueDepth - 1);
734
+ this.observation.publish();
735
+ return result;
736
+ }, error => {
721
737
  this.queueDepth = Math.max(0, this.queueDepth - 1);
738
+ this.observation.publish();
722
739
  return turnResult(false, 'failed', error?.message ?? String(error));
723
740
  });
724
741
  return {
@@ -856,6 +873,7 @@ export class AcpSession {
856
873
  actor: { browserSession: command.actorBrowserSession },
857
874
  });
858
875
  const receipt = {
876
+ sessionGeneration: this.sessionGeneration,
859
877
  commandId: command.commandId,
860
878
  promptId: queued.promptId,
861
879
  state: queued.queuedBehind > 0 ? 'queued' : 'starting',
@@ -1003,6 +1021,7 @@ export class AcpSession {
1003
1021
  this.allowPermission(pending.toolCallId, permissionId);
1004
1022
  else
1005
1023
  this.releasePermission(pending.toolCallId, permissionId);
1024
+ this.readiness = this.pendingPermissions.size > 0 ? 'awaiting_permission' : 'running';
1006
1025
  this.events.emit('permission', {
1007
1026
  turnId: this.activeTurn?.id,
1008
1027
  origin: this.activeTurn?.origin,
@@ -1019,7 +1038,7 @@ export class AcpSession {
1019
1038
  promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
1020
1039
  payload: { decision, decisionSource: 'manual', optionId },
1021
1040
  });
1022
- this.readiness = this.pendingPermissions.size > 0 ? 'awaiting_permission' : 'running';
1041
+ this.observation.publish();
1023
1042
  return true;
1024
1043
  }
1025
1044
  /**
@@ -1085,6 +1104,8 @@ export class AcpSession {
1085
1104
  : { outcome: { outcome: 'cancelled' } });
1086
1105
  const settled = decision === 'denied' && !rejectOption ? 'cancelled' : decision;
1087
1106
  this.releasePermission(pending.toolCallId, permissionId);
1107
+ if (this.pendingPermissions.size === 0 && this.readiness === 'awaiting_permission')
1108
+ this.readiness = 'running';
1088
1109
  this.events.emit('permission', {
1089
1110
  turnId: this.activeTurn?.id,
1090
1111
  origin: this.activeTurn?.origin,
@@ -1108,8 +1129,7 @@ export class AcpSession {
1108
1129
  ...(rejectOption ? { optionId: rejectOption.optionId } : {}),
1109
1130
  },
1110
1131
  });
1111
- if (this.pendingPermissions.size === 0 && this.readiness === 'awaiting_permission')
1112
- this.readiness = 'running';
1132
+ this.observation.publish();
1113
1133
  }
1114
1134
  exitResult() {
1115
1135
  return this.exit;
@@ -1322,6 +1342,7 @@ export class AcpSession {
1322
1342
  kind: 'session.state', sessionGeneration: this.sessionGeneration,
1323
1343
  acpSessionId: this.sessionId, payload: { status: 'idle' },
1324
1344
  });
1345
+ this.observation.publish();
1325
1346
  }
1326
1347
  captureRuntimeMetadata(options, modelId) {
1327
1348
  this.runtimeModel = runtimeSelector(options, 'model');
@@ -1460,8 +1481,11 @@ export class AcpSession {
1460
1481
  attempt.recoveryStartedAt = Date.now();
1461
1482
  const recoveryOrigin = origin?.kind === 'scheduled-loop'
1462
1483
  ? origin : { kind: 'stall-watchdog' };
1463
- this.admitToLedger(attempt.recoveryId, STALL_RECOVERY_PROMPT, 0, { origin: recoveryOrigin });
1464
- const recovered = await this.runSinglePrompt(STALL_RECOVERY_PROMPT, attempt.recoveryId, recoveryOrigin);
1484
+ const recovery = this.observation.checkpoint(() => {
1485
+ this.admitToLedger(attempt.recoveryId, STALL_RECOVERY_PROMPT, 0, { origin: recoveryOrigin });
1486
+ return this.runSinglePrompt(STALL_RECOVERY_PROMPT, attempt.recoveryId, recoveryOrigin);
1487
+ });
1488
+ const recovered = await recovery;
1465
1489
  attempt.report(attempt.superseded ? 'superseded'
1466
1490
  : recovered.succeeded && attempt.resumed ? 'recovery_completed' : 'blocked_recovery');
1467
1491
  // A failed diagnostic turn must not make startup tear down the session.
@@ -1477,6 +1501,7 @@ export class AcpSession {
1477
1501
  * so none is left open and none is reported delivered that was not sent.
1478
1502
  */
1479
1503
  async runSinglePrompt(text, turnId = randomUUID(), origin, members = [{ promptId: turnId, text, origin }]) {
1504
+ let completionPayload;
1480
1505
  const closeMembers = (payload) => {
1481
1506
  for (const member of members)
1482
1507
  this.conversation.appendSafe({
@@ -1494,6 +1519,7 @@ export class AcpSession {
1494
1519
  let settle;
1495
1520
  const settled = new Promise(resolve => { settle = resolve; });
1496
1521
  this.activeTurn = { toolEvidence: new Map(), startedAt: Date.now(), toolIds: new Set(), lastProgressAt: 0, progressCount: 0, transportFailures: 0, boundaryUnknown: false, id: turnId, output: '', origin, settled, settle, promptIds: members.map(member => member.promptId), console: members.some(member => member.origin?.kind === 'owner-admin-console') };
1522
+ this.observation.publish();
1497
1523
  this.events.emit('state', { turnId, status: 'running', origin });
1498
1524
  members.forEach((member, index) => this.conversation.appendSafe({
1499
1525
  kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
@@ -1516,11 +1542,11 @@ export class AcpSession {
1516
1542
  turnId, stopReason: response.stopReason, origin, cancellationSource,
1517
1543
  });
1518
1544
  this.events.emit('state', { status: 'idle' });
1519
- closeMembers({
1545
+ completionPayload = {
1520
1546
  outcome: classifyStopReason(response.stopReason),
1521
1547
  stopReason: response.stopReason,
1522
1548
  ...(cancellationSource ? { cancellationSource } : {}),
1523
- });
1549
+ };
1524
1550
  // The prompt was accepted either way — the agent answered. Whether the
1525
1551
  // turn SUCCEEDED is a separate question, and only `stopReason` answers it.
1526
1552
  return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
@@ -1540,7 +1566,7 @@ export class AcpSession {
1540
1566
  });
1541
1567
  if (this.isAlive())
1542
1568
  this.events.emit('state', { status: 'idle' });
1543
- closeMembers({ outcome: 'failed', stopReason: this.lastError });
1569
+ completionPayload = { outcome: 'failed', stopReason: this.lastError };
1544
1570
  return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
1545
1571
  }
1546
1572
  finally {
@@ -1556,6 +1582,9 @@ export class AcpSession {
1556
1582
  this.cancelEscalation = undefined;
1557
1583
  this.activeTurn = undefined;
1558
1584
  }
1585
+ if (completionPayload)
1586
+ closeMembers(completionPayload);
1587
+ this.observation.publish();
1559
1588
  }
1560
1589
  }
1561
1590
  async steerPrompt(text) {
@@ -1648,42 +1677,20 @@ export class AcpSession {
1648
1677
  }
1649
1678
  const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
1650
1679
  const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
1651
- this.readiness = 'awaiting_permission';
1652
- this.events.emit('permission', {
1653
- turnId: this.activeTurn?.id,
1654
- origin: this.activeTurn?.origin,
1655
- permissionId,
1656
- toolCallId: this.activeTurn?.origin?.kind === 'scheduled-loop'
1657
- ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
1658
- title: this.activeTurn?.origin?.kind === 'scheduled-loop'
1680
+ const presented = {
1681
+ toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : toolCallId,
1682
+ title: scheduledTurn(this.activeTurn)
1659
1683
  ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
1660
- status: 'pending',
1661
1684
  options: params.options.map(option => ({
1662
1685
  optionId: option.optionId,
1663
- name: this.activeTurn?.origin?.kind === 'scheduled-loop' ? option.kind : option.name,
1686
+ name: scheduledTurn(this.activeTurn) ? option.kind : option.name,
1664
1687
  kind: option.kind,
1665
1688
  })),
1666
- });
1667
- this.conversation.appendSafe({
1668
- kind: 'permission.requested', sessionGeneration: this.sessionGeneration,
1669
- acpSessionId: this.sessionId, permissionId,
1670
- promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
1671
- toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
1672
- payload: {
1673
- toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
1674
- title: scheduledTurn(this.activeTurn)
1675
- ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
1676
- options: params.options.map(option => ({
1677
- optionId: option.optionId,
1678
- name: scheduledTurn(this.activeTurn) ? option.kind : option.name,
1679
- kind: option.kind,
1680
- })),
1681
- expiresAt,
1682
- },
1683
- });
1689
+ expiresAt,
1690
+ };
1684
1691
  return new Promise(resolve => {
1685
1692
  const pending = {
1686
- options: params.options, resolve,
1693
+ options: params.options, resolve, presented,
1687
1694
  toolCallId,
1688
1695
  eventToolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : toolCallId,
1689
1696
  };
@@ -1692,6 +1699,18 @@ export class AcpSession {
1692
1699
  }, timeoutMs);
1693
1700
  pending.expiry.unref?.();
1694
1701
  this.pendingPermissions.set(permissionId, pending);
1702
+ this.readiness = 'awaiting_permission';
1703
+ this.events.emit('permission', {
1704
+ turnId: this.activeTurn?.id, origin: this.activeTurn?.origin,
1705
+ permissionId, ...presented, status: 'pending',
1706
+ });
1707
+ this.conversation.appendSafe({
1708
+ kind: 'permission.requested', sessionGeneration: this.sessionGeneration,
1709
+ acpSessionId: this.sessionId, permissionId,
1710
+ promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
1711
+ toolCallId: presented.toolCallId, payload: presented,
1712
+ });
1713
+ this.observation.publish();
1695
1714
  });
1696
1715
  }
1697
1716
  /**
@@ -1868,11 +1887,13 @@ export class AcpSession {
1868
1887
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
1869
1888
  const messagePhase = update.sessionUpdate === 'agent_message_chunk'
1870
1889
  ? this.codexMessagePhase(update) : undefined;
1871
- this.recordConversationUpdate(update, scheduled);
1890
+ // Install live capability state before the durable append can synchronously
1891
+ // notify a subscriber that reenters observation at the new ledger barrier.
1872
1892
  if (update.sessionUpdate === 'config_option_update')
1873
1893
  this.captureRuntimeMetadata(update.configOptions);
1874
1894
  if (update.sessionUpdate === 'available_commands_update')
1875
1895
  this.advertisedCommands = new Set((update.availableCommands ?? []).map(command => command.name));
1896
+ this.recordConversationUpdate(update, scheduled);
1876
1897
  if (update.sessionUpdate === 'usage_update') {
1877
1898
  // Claude subscription rate-limit snapshot (claude-agent-acp forwards the SDK rate_limit_event).
1878
1899
  const rateLimit = update._meta?.['_claude/rateLimit'];
@@ -1937,6 +1958,7 @@ export class AcpSession {
1937
1958
  default:
1938
1959
  break;
1939
1960
  }
1961
+ this.observation.publish();
1940
1962
  }
1941
1963
  /**
1942
1964
  * Codex ACP's phase extension is the only currently supported visibility
@@ -1963,6 +1985,8 @@ export class AcpSession {
1963
1985
  redactText: SCHEDULED_LOOP_REDACTION,
1964
1986
  redactToolCallId: 'scheduled-loop-tool',
1965
1987
  } : {});
1988
+ if (normalized.kind === 'capabilities.updated' && 'commands' in normalized.payload)
1989
+ this.presentedCommands = normalized.payload.commands ?? [];
1966
1990
  this.conversation.appendSafe({
1967
1991
  kind: normalized.kind,
1968
1992
  sessionGeneration: this.sessionGeneration,
@@ -2001,6 +2025,36 @@ export class AcpSession {
2001
2025
  snapshot: this.conversationSnapshot(),
2002
2026
  };
2003
2027
  }
2028
+ subscribeConversationFailure(listener) {
2029
+ return this.conversation.subscribeFailure(listener);
2030
+ }
2031
+ conversationReplayAvailable(expectedGeneration) {
2032
+ return expectedGeneration === this.sessionGeneration && this.conversation.generationDurable(expectedGeneration);
2033
+ }
2034
+ replayConversation(request) {
2035
+ if (request.expectedGeneration !== this.sessionGeneration)
2036
+ throw new Error('session generation changed');
2037
+ return this.conversation.replay(request);
2038
+ }
2039
+ observeConversation(listener) {
2040
+ return this.observation.observe(listener);
2041
+ }
2042
+ captureObservation() {
2043
+ const snapshot = this.snapshot();
2044
+ return {
2045
+ ...this.conversationSnapshot(),
2046
+ ...pendingInventory(this.pendingPrompts),
2047
+ ledgerBarrier: Number(this.conversation.lastCursor() ?? 0),
2048
+ alive: snapshot.alive,
2049
+ readiness: snapshot.alive ? snapshot.readiness : 'offline',
2050
+ activePromptIds: [...(this.activeTurn?.promptIds ?? [])],
2051
+ pendingPermissions: [...this.pendingPermissions].flatMap(([permissionId, pending]) => pending.presented ? [{ permissionId, ...pending.presented }] : []),
2052
+ commands: this.presentedCommands,
2053
+ runtimeModel: snapshot.runtimeModel,
2054
+ reasoningEffort: snapshot.reasoningEffort,
2055
+ permissionMode: snapshot.permissionMode,
2056
+ };
2057
+ }
2004
2058
  conversationSnapshot() {
2005
2059
  return {
2006
2060
  sessionGeneration: this.sessionGeneration,
@@ -79,6 +79,10 @@ export declare class RoleTurnArbiter implements AgentSession {
79
79
  limit?: number;
80
80
  }): ConversationHandlePage;
81
81
  conversationSnapshot(): ConversationSnapshot;
82
+ subscribeConversationFailure(listener: (generation: string) => void): () => void;
83
+ conversationReplayAvailable(expectedGeneration: string): boolean;
84
+ replayConversation(request: import('./conversation-replay.js').ConversationReplayRequest): AsyncIterable<import("./conversation-replay.js").ConversationReplayPage>;
85
+ observeConversation(listener: (state: import('./conversation-types.js').ConversationObservationState) => void): import('./conversation-types.js').ConversationObservation;
82
86
  subscribeConversation(listener: (event: ConversationEventV1) => void): () => void;
83
87
  submitPromptBrowser(command: SubmitPromptCommand): Promise<PromptReceipt>;
84
88
  get liveVoiceSupported(): boolean;
@@ -181,6 +181,24 @@ export class RoleTurnArbiter {
181
181
  throw new Error('conversation snapshot is unavailable');
182
182
  return this.session.conversationSnapshot();
183
183
  }
184
+ subscribeConversationFailure(listener) {
185
+ if (!this.session.subscribeConversationFailure)
186
+ throw new Error('conversation durability observation is unavailable');
187
+ return this.session.subscribeConversationFailure(listener);
188
+ }
189
+ conversationReplayAvailable(expectedGeneration) {
190
+ return this.session.conversationReplayAvailable?.(expectedGeneration) ?? false;
191
+ }
192
+ replayConversation(request) {
193
+ if (!this.session.replayConversation)
194
+ throw new Error('conversation replay is unavailable');
195
+ return this.session.replayConversation(request);
196
+ }
197
+ observeConversation(listener) {
198
+ if (!this.session.observeConversation)
199
+ throw new Error('conversation observation is unavailable');
200
+ return this.session.observeConversation(listener);
201
+ }
184
202
  subscribeConversation(listener) {
185
203
  if (!this.session.subscribeConversation)
186
204
  throw new Error('conversation subscription is unavailable');
@@ -1,6 +1,6 @@
1
1
  import { type TaskNoticeBinding, type TaskCreatedNotice } from './task-notice.js';
2
2
  import type { CommonPermissions } from '../config.js';
3
- import type { ConversationEventV1, ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
3
+ import type { ConversationEventV1, ConversationObservationState, ConversationSnapshot, PromptOrigin, PromptReceipt, SubmitPromptCommand } from './conversation-types.js';
4
4
  import { type PendingPrompt } from './prompt-batch.js';
5
5
  import type { CodexAppServerTransportFactory } from './codex-app-server-transport.js';
6
6
  import type { AgentSession, ConversationHandlePage, ExitRecord, InterruptOutcome, QueuedPrompt, SessionEvent, SessionSnapshot, SubmitPromptOptions, TurnCancellationSource, TurnResult } from './types.js';
@@ -66,6 +66,7 @@ export declare class CodexAppServerSession implements AgentSession {
66
66
  private lastError?;
67
67
  private exit;
68
68
  private queueDepth;
69
+ private readonly observation;
69
70
  private promptTail;
70
71
  /** Admitted prompts not yet handed to the app-server, in admission order. */
71
72
  private readonly pendingPrompts;
@@ -112,6 +113,11 @@ export declare class CodexAppServerSession implements AgentSession {
112
113
  after?: string;
113
114
  limit?: number;
114
115
  }): ConversationHandlePage;
116
+ subscribeConversationFailure(listener: (generation: string) => void): () => void;
117
+ conversationReplayAvailable(expectedGeneration: string): boolean;
118
+ replayConversation(request: import('./conversation-replay.js').ConversationReplayRequest): AsyncGenerator<import("./conversation-replay.js").ConversationReplayPage, any, any>;
119
+ observeConversation(listener: (state: ConversationObservationState) => void): import("./conversation-types.js").ConversationObservation;
120
+ private captureObservation;
115
121
  conversationSnapshot(): ConversationSnapshot;
116
122
  subscribeConversation(listener: (event: ConversationEventV1) => void): () => void;
117
123
  submitPromptBrowser(command: SubmitPromptCommand): Promise<PromptReceipt>;
@@ -1,3 +1,4 @@
1
+ import { ConversationObservation } from './conversation-observation.js';
1
2
  import { appendTaskCreated } from './task-notice.js';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import { existsSync, readFileSync, writeFileSync } from 'node:fs';
@@ -148,6 +149,7 @@ export class CodexAppServerSession {
148
149
  lastError;
149
150
  exit = null;
150
151
  queueDepth = 0;
152
+ observation = new ConversationObservation(() => this.captureObservation());
151
153
  promptTail = Promise.resolve();
152
154
  /** Admitted prompts not yet handed to the app-server, in admission order. */
153
155
  pendingPrompts = [];
@@ -170,6 +172,9 @@ export class CodexAppServerSession {
170
172
  roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
171
173
  });
172
174
  this.conversationStartCursor = this.conversation.lastCursor();
175
+ // A durable append can precede its inventory mutation. Capture only after
176
+ // the synchronous owner checkpoint, never the state at append notification.
177
+ this.conversation.subscribe(() => this.observation.scheduleCapture());
173
178
  // Use Fleet's provider-neutral resume marker so runner recovery rotates the
174
179
  // exact native thread id after a poisoned fast resume.
175
180
  this.threadFile = join(options.stateDir, '.session-id');
@@ -234,14 +239,18 @@ export class CodexAppServerSession {
234
239
  }
235
240
  const promptId = randomUUID();
236
241
  const queuedBehind = this.queueDepth;
237
- this.admit(promptId, text, queuedBehind, options);
238
- this.queueDepth++;
239
- const run = this.enqueue(promptId, text, options.origin);
242
+ const run = this.observation.checkpoint(() => {
243
+ this.admit(promptId, text, queuedBehind, options);
244
+ this.queueDepth++;
245
+ return this.enqueue(promptId, text, options.origin);
246
+ });
240
247
  const completion = run.then(result => {
241
248
  this.queueDepth = Math.max(0, this.queueDepth - 1);
249
+ this.observation.publish();
242
250
  return result;
243
251
  }, error => {
244
252
  this.queueDepth = Math.max(0, this.queueDepth - 1);
253
+ this.observation.publish();
245
254
  return turnResult(false, 'failed', error instanceof Error ? error.message : String(error));
246
255
  });
247
256
  return {
@@ -413,6 +422,7 @@ export class CodexAppServerSession {
413
422
  });
414
423
  this.conversation.close();
415
424
  await this.transport.close();
425
+ this.observation.publish();
416
426
  }
417
427
  taskNoticeBinding() {
418
428
  return this.activeTurn && this.threadId ? { sessionGeneration: this.sessionGeneration, acpSessionId: this.threadId, promptId: this.activeTurn.promptId } : undefined;
@@ -432,6 +442,36 @@ export class CodexAppServerSession {
432
442
  }
433
443
  return { ...page, events: visible, snapshot: this.conversationSnapshot() };
434
444
  }
445
+ subscribeConversationFailure(listener) {
446
+ return this.conversation.subscribeFailure(listener);
447
+ }
448
+ conversationReplayAvailable(expectedGeneration) {
449
+ return expectedGeneration === this.sessionGeneration && this.conversation.generationDurable(expectedGeneration);
450
+ }
451
+ replayConversation(request) {
452
+ if (request.expectedGeneration !== this.sessionGeneration)
453
+ throw new Error('session generation changed');
454
+ return this.conversation.replay(request);
455
+ }
456
+ observeConversation(listener) {
457
+ return this.observation.observe(listener);
458
+ }
459
+ captureObservation() {
460
+ const snapshot = this.snapshot();
461
+ return {
462
+ ...this.conversationSnapshot(),
463
+ ...pendingInventory(this.pendingPrompts),
464
+ ledgerBarrier: Number(this.conversation.lastCursor() ?? 0),
465
+ alive: snapshot.alive,
466
+ readiness: snapshot.alive ? snapshot.readiness : 'offline',
467
+ activePromptIds: [...(this.activeTurn?.promptIds ?? [])],
468
+ pendingPermissions: [...this.pendingPermissions].flatMap(([permissionId, pending]) => pending.presented ? [{ permissionId, ...pending.presented }] : []),
469
+ commands: [{ name: 'compact', description: textBlock('Compact session context') }],
470
+ runtimeModel: snapshot.runtimeModel,
471
+ reasoningEffort: snapshot.reasoningEffort,
472
+ permissionMode: snapshot.permissionMode,
473
+ };
474
+ }
435
475
  conversationSnapshot() {
436
476
  return {
437
477
  sessionGeneration: this.sessionGeneration,
@@ -460,6 +500,7 @@ export class CodexAppServerSession {
460
500
  actor: { browserSession: command.actorBrowserSession },
461
501
  });
462
502
  const receipt = {
503
+ sessionGeneration: this.sessionGeneration,
463
504
  commandId: command.commandId, promptId: queued.promptId,
464
505
  state: queued.queuedBehind ? 'queued' : 'starting', queuedBehind: queued.queuedBehind,
465
506
  acceptedAt: new Date().toISOString(), eventCursor: this.conversation.lastCursor() ?? '0',
@@ -515,6 +556,7 @@ export class CodexAppServerSession {
515
556
  kind: 'session.state', sessionGeneration: this.sessionGeneration,
516
557
  payload: { status: 'idle' },
517
558
  });
559
+ this.observation.publish();
518
560
  }
519
561
  recoverOpenPrompts() {
520
562
  for (const open of this.conversation.openPrompts()) {
@@ -534,7 +576,8 @@ export class CodexAppServerSession {
534
576
  ? { kind: 'owner-admin-console', commandId: open.commandId }
535
577
  : undefined;
536
578
  const run = this.enqueue(open.promptId, open.text, origin);
537
- void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); });
579
+ this.observation.publish();
580
+ void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); this.observation.publish(); });
538
581
  }
539
582
  }
540
583
  admit(promptId, text, queuedBehind, options) {
@@ -580,6 +623,7 @@ export class CodexAppServerSession {
580
623
  this.finalAnswerObserved = false;
581
624
  this.activeItems.clear();
582
625
  this.readiness = 'running';
626
+ this.observation.publish();
583
627
  this.events.emit('state', { turnId: promptId, status: 'running', origin });
584
628
  members.forEach((member, index) => this.conversation.appendSafe({
585
629
  kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
@@ -890,6 +934,7 @@ export class CodexAppServerSession {
890
934
  ...(active.cancellationSource ? { cancellationSource: active.cancellationSource } : {}),
891
935
  },
892
936
  });
937
+ this.observation.publish();
893
938
  active.settle(result);
894
939
  }
895
940
  /**
@@ -1053,7 +1098,6 @@ export class CodexAppServerSession {
1053
1098
  this.resolvePermission(permissionId, pending, 'denied', 'automatic', 'deny_once', unattended ? 'permissions.unattended=deny' : 'permissions.approval=deny', unattended ? 'no controller is attached' : 'the role denies permission requests');
1054
1099
  return;
1055
1100
  }
1056
- this.pendingPermissions.set(permissionId, pending);
1057
1101
  this.readiness = 'awaiting_permission';
1058
1102
  const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
1059
1103
  const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
@@ -1063,19 +1107,23 @@ export class CodexAppServerSession {
1063
1107
  ?? (method === 'item/fileChange/requestApproval' ? 'Apply file changes'
1064
1108
  : method === 'item/permissions/requestApproval' ? 'Grant additional permissions'
1065
1109
  : 'Run command');
1110
+ const presented = { toolCallId, title, options, expiresAt };
1111
+ pending.presented = presented;
1112
+ this.pendingPermissions.set(permissionId, pending);
1066
1113
  this.events.emit('permission', {
1067
1114
  turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
1068
- permissionId, toolCallId, title, status: 'pending', options,
1115
+ permissionId, ...presented, status: 'pending',
1069
1116
  });
1070
1117
  this.conversation.appendSafe({
1071
1118
  kind: 'permission.requested', sessionGeneration: this.sessionGeneration,
1072
1119
  permissionId, promptId: this.activeTurn?.promptId, turnId: this.activeTurn?.promptId,
1073
- toolCallId, payload: { toolCallId, title, options, expiresAt },
1120
+ toolCallId, payload: presented,
1074
1121
  });
1075
1122
  pending.expiry = setTimeout(() => {
1076
1123
  this.autoResolvePermission(permissionId, pending, undefined, `no decision arrived within ${Math.round(timeoutMs / 1000)}s`);
1077
1124
  }, timeoutMs);
1078
1125
  pending.expiry.unref?.();
1126
+ this.observation.publish();
1079
1127
  }
1080
1128
  serverRequestResolved(params) {
1081
1129
  const requestId = typeof params.requestId === 'string' || typeof params.requestId === 'number'
@@ -1101,6 +1149,8 @@ export class CodexAppServerSession {
1101
1149
  this.resolvePermission(permissionId, pending, 'denied', 'automatic', 'deny_once', policy, reason);
1102
1150
  }
1103
1151
  resolvePermission(permissionId, pending, decision, decisionSource, optionId, policy, reason) {
1152
+ if (!this.pendingPermissions.size && this.readiness === 'awaiting_permission')
1153
+ this.readiness = 'running';
1104
1154
  this.events.emit('permission', {
1105
1155
  turnId: this.activeTurn?.promptId, origin: this.activeTurn?.origin,
1106
1156
  permissionId, toolCallId: pending.toolCallId, status: 'completed', decision,
@@ -1113,8 +1163,7 @@ export class CodexAppServerSession {
1113
1163
  payload: { decision, decisionSource, ...(optionId ? { optionId } : {}),
1114
1164
  ...(policy ? { policy } : {}), ...(reason ? { reason } : {}) },
1115
1165
  });
1116
- if (!this.pendingPermissions.size && this.readiness === 'awaiting_permission')
1117
- this.readiness = 'running';
1166
+ this.observation.publish();
1118
1167
  this.scheduleTerminalReconciliation();
1119
1168
  }
1120
1169
  currentEvent(event) {
@@ -1162,10 +1211,12 @@ export class CodexAppServerSession {
1162
1211
  if (this.activeTurn)
1163
1212
  this.finishTurn('failed', this.lastError);
1164
1213
  }
1214
+ this.observation.publish();
1165
1215
  }
1166
1216
  fail(error) {
1167
1217
  this.lastError = error instanceof Error ? error.message : String(error);
1168
1218
  this.readiness = 'failed';
1169
1219
  this.events.emit('error', { text: this.lastError });
1220
+ this.observation.publish();
1170
1221
  }
1171
1222
  }
@@ -1,6 +1,7 @@
1
1
  import type { LayoutControlRequest } from '../rooms-tasks/layout-control.js';
2
2
  import type { SupervisorCorrespondence, AgentHistoryQuery } from '../agent-ours/correspondence.js';
3
3
  import { type Socket } from 'node:net';
4
+ import { type PassiveConversationFollowRequest } from './conversation-replay.js';
4
5
  import type { AgentSession, ControlFailureKind, SessionEvent, SessionSnapshot } from './types.js';
5
6
  import type { OwnerChannelHandle, OwnerChannelManagementRequest } from '../owner-channel/channel.js';
6
7
  import type { ScheduledLoopManagerHandle } from '../loops/manager.js';
@@ -26,7 +27,8 @@ export interface ControlRequest {
26
27
  ownerChannel?: OwnerChannelManagementRequest;
27
28
  loop?: string;
28
29
  /** Conversation cursor: replay strictly after this durable seq. */
29
- after?: string;
30
+ after?: string | number;
31
+ expectedGeneration?: string;
30
32
  limit?: number;
31
33
  /** Idempotency key for v3 mutations. */
32
34
  commandId?: string;
@@ -141,6 +143,7 @@ export declare class RoleControlServer {
141
143
  private handle;
142
144
  /** Version and capability gate shared by every conversation v3 command. */
143
145
  private requireConversation;
146
+ private observationFeatures;
144
147
  private write;
145
148
  }
146
149
  /**
@@ -160,7 +163,7 @@ export declare function followControl(stateDir: string, onMessage: (message: Rec
160
163
  * `{ version, conversationEvent }`. Closing the socket detaches the
161
164
  * controller-presence this connection contributed.
162
165
  */
163
- export declare function followConversation(stateDir: string, after: string | undefined, onMessage: (message: Record<string, unknown>) => void): Promise<{
166
+ export declare function followConversation(stateDir: string, after: string | undefined | PassiveConversationFollowRequest, onMessage: (message: Record<string, unknown>) => void): Promise<{
164
167
  socket: Socket;
165
168
  close(): void;
166
169
  }>;