@ours.network/fleet 1.2.0-nightly.62 → 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 (41) hide show
  1. package/README.md +6 -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/role-removal-service.js +1 -1
  6. package/dist/application/session-control.d.ts +31 -17
  7. package/dist/application/session-control.js +148 -33
  8. package/dist/build-info.json +4 -4
  9. package/dist/notifications/fleet-producer.js +4 -4
  10. package/dist/notifications/target-cleanup.d.ts +2 -0
  11. package/dist/notifications/target-cleanup.js +24 -2
  12. package/dist/ops.js +1 -1
  13. package/dist/session/acp.d.ts +8 -1
  14. package/dist/session/acp.js +97 -43
  15. package/dist/session/arbiter.d.ts +4 -0
  16. package/dist/session/arbiter.js +18 -0
  17. package/dist/session/codex-app-server.d.ts +7 -1
  18. package/dist/session/codex-app-server.js +60 -9
  19. package/dist/session/control.d.ts +5 -2
  20. package/dist/session/control.js +145 -20
  21. package/dist/session/conversation-observation.d.ts +25 -0
  22. package/dist/session/conversation-observation.js +100 -0
  23. package/dist/session/conversation-replay.d.ts +50 -0
  24. package/dist/session/conversation-replay.js +203 -0
  25. package/dist/session/conversation-store.d.ts +14 -0
  26. package/dist/session/conversation-store.js +201 -17
  27. package/dist/session/conversation-types.d.ts +29 -1
  28. package/dist/session/types.d.ts +6 -0
  29. package/dist/subscriptions/login.d.ts +1 -0
  30. package/dist/subscriptions/login.js +30 -5
  31. package/dist/web/auth.d.ts +2 -0
  32. package/dist/web/auth.js +10 -1
  33. package/dist/web/conversation-stream-codec.d.ts +23 -0
  34. package/dist/web/conversation-stream-codec.js +110 -0
  35. package/dist/web/conversation-stream.d.ts +25 -0
  36. package/dist/web/conversation-stream.js +231 -0
  37. package/dist/web/runtime.js +12 -2
  38. package/dist/web/server.d.ts +2 -0
  39. package/dist/web/server.js +2 -0
  40. package/docs/conversation-stream.md +176 -0
  41. package/package.json +2 -1
@@ -1,5 +1,5 @@
1
1
  import { roomsDir } from '../rooms-tasks/room-state.js';
2
- import { drainNotificationTargetCleanup } from './target-cleanup.js';
2
+ import { drainNotificationTargetCleanup, notificationAgentId } from './target-cleanup.js';
3
3
  import { closeSync, existsSync, lstatSync, openSync, readSync, readFileSync, readdirSync, statSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { createHash } from 'node:crypto';
@@ -71,7 +71,7 @@ export class FleetNotificationProducer {
71
71
  }
72
72
  poll() {
73
73
  this.cleanup ??= drainNotificationTargetCleanup(this.config, undefined, undefined, { beforeDelete: async (url) => {
74
- const name = new URL(url, 'https://ours.invalid').searchParams.get('chat');
74
+ const name = notificationAgentId(url);
75
75
  if (!name || !/^[A-Za-z0-9_-]+$/.test(name))
76
76
  return false;
77
77
  // A recreated or retained role still has a valid target. Filesystem errors cannot prove removal.
@@ -126,7 +126,7 @@ export class FleetNotificationProducer {
126
126
  if (!outbox) {
127
127
  const key = createHash('sha256').update(roleDir).digest('hex');
128
128
  outbox = new NotificationOutbox(join(this.stateDir, key + '.json'), this.config, async (value, eventId) => ({ eventId, title: `${roleId}: agent completed`, body: 'Your agent finished responding. Open the chat to view the result.',
129
- url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1`, ...value }), this.warn);
129
+ url: `/fleet/chats/agents/${encodeURIComponent(roleId)}`, ...value }), this.warn);
130
130
  if (!outbox.checkpoint) {
131
131
  const segment = this.existing.has(roleDir) ? segments.at(-1) : segments[0];
132
132
  outbox.saveCheckpoint({ segment, offset: this.existing.has(roleDir) ? statSync(join(dir, segment)).size : 0, prompts: [] });
@@ -187,7 +187,7 @@ export class FleetNotificationProducer {
187
187
  if (['message.chunk', 'message.replace'].includes(event.kind) && event.promptId && event.messageId && current.prompts.includes(prompt))
188
188
  current.messages[prompt] = event.messageId;
189
189
  if (event.kind === 'turn.completed' && event.promptId && current.prompts.includes(prompt)) {
190
- if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats?chat=${encodeURIComponent(roleId)}&detail=1#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
190
+ if (!outbox.enqueue(`${roleId}:${event.sessionGeneration}:${event.seq}`, { ...(taskRoles ? { taskId: taskRoles.get(roleId) ?? null } : {}), url: `/fleet/chats/agents/${encodeURIComponent(roleId)}#fleet-message-${encodeURIComponent(current.messages[prompt] ?? event.promptId)}` }))
191
191
  break;
192
192
  current.prompts = current.prompts.filter(p => p !== prompt);
193
193
  delete current.messages[prompt];
@@ -1,4 +1,6 @@
1
1
  import { type ProducerConfig } from './outbox.js';
2
+ /** Only the canonical agent namespace can authorize role-target cleanup. */
3
+ export declare function notificationAgentId(path: string): string | null;
2
4
  /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
3
5
  export declare function retireNotificationTarget(url: string, config?: ProducerConfig | undefined, dir?: string, request?: typeof fetch): Promise<void>;
4
6
  /** Persisted retries also run from the ordinary web notification producer after restart. */
@@ -5,6 +5,24 @@ import { replaceFileAtomically } from '../atomic-file.js';
5
5
  import { stateRoot } from '../paths.js';
6
6
  import { producerConfig } from './outbox.js';
7
7
  const cleanupDir = () => join(stateRoot(), 'notifications', 'removed-targets');
8
+ /** Only the canonical agent namespace can authorize role-target cleanup. */
9
+ export function notificationAgentId(path) {
10
+ try {
11
+ if (typeof path !== 'string' || /[\\\x00-\x20]|%(?:2f|5c|00|0[ad])/i.test(path))
12
+ return null;
13
+ const match = /^\/fleet\/chats\/agents\/([^/?#]+)(?:[?#]|$)/.exec(path);
14
+ if (!match)
15
+ return null;
16
+ const url = new URL(path, 'https://ours.invalid');
17
+ if (['source', 'chat', 'task', 'detail'].some(key => url.searchParams.has(key)))
18
+ return null;
19
+ const id = decodeURIComponent(match[1]);
20
+ return /^[A-Za-z0-9_-]+$/.test(id) ? id : null;
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
8
26
  /** Queue only after the owning lifecycle has established deletion, never on a failed inventory read. */
9
27
  export async function retireNotificationTarget(url, config = producerConfig(), dir = cleanupDir(), request = fetch) {
10
28
  if (!config)
@@ -17,11 +35,15 @@ export async function retireNotificationTarget(url, config = producerConfig(), d
17
35
  export async function drainNotificationTargetCleanup(config, dir = cleanupDir(), request = fetch, options = {}) {
18
36
  if (!existsSync(dir))
19
37
  return;
20
- for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file)).slice(0, 32)) {
38
+ let attempts = 0;
39
+ for (const file of readdirSync(dir).filter(file => /^[a-f0-9]{64}\.json$/.test(file))) {
40
+ if (attempts >= 32)
41
+ break;
21
42
  try {
22
43
  const path = join(dir, file), value = JSON.parse(readFileSync(path, 'utf8'));
23
- if (typeof value.url !== 'string' || !value.url.startsWith('/fleet/chats?'))
44
+ if (!notificationAgentId(value.url))
24
45
  continue;
46
+ attempts++;
25
47
  if (options.beforeDelete && !(await options.beforeDelete(value.url))) {
26
48
  rmSync(path, { force: true });
27
49
  continue;
package/dist/ops.js CHANGED
@@ -271,7 +271,7 @@ export async function rmRole(cfg, name, deps) {
271
271
  rmSync(agentDir(name), { recursive: true, force: true });
272
272
  if (generatedSource && existsSync(role.sourceFile)) {
273
273
  unlinkSync(role.sourceFile);
274
- await retireNotificationTarget(`/fleet/chats?chat=${encodeURIComponent(name)}`).catch(() => { });
274
+ await retireNotificationTarget(`/fleet/chats/agents/${encodeURIComponent(name)}`).catch(() => { });
275
275
  deps.log(`removed ${role.sourceFile}`);
276
276
  }
277
277
  deps.log(`removed '${name}' (its ours identity is left intact)`);
@@ -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>;