@ours.network/fleet 0.15.3 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +54 -19
  2. package/dist/application/fleet-query-service.js +3 -0
  3. package/dist/application/model-catalog.d.ts +20 -0
  4. package/dist/application/model-catalog.js +57 -0
  5. package/dist/application/role-creation-service.d.ts +7 -0
  6. package/dist/application/role-creation-service.js +21 -4
  7. package/dist/application/role-removal-service.d.ts +32 -0
  8. package/dist/application/role-removal-service.js +87 -0
  9. package/dist/application/role-repository.js +13 -1
  10. package/dist/application/session-control.d.ts +74 -0
  11. package/dist/application/session-control.js +66 -1
  12. package/dist/application/types.d.ts +18 -0
  13. package/dist/briefing.js +10 -1
  14. package/dist/cli.js +1 -1
  15. package/dist/config.d.ts +4 -1
  16. package/dist/config.js +3 -2
  17. package/dist/docs.d.ts +1 -1
  18. package/dist/docs.js +14 -5
  19. package/dist/fleet-proxy.js +3 -1
  20. package/dist/harness/claude-code.js +20 -3
  21. package/dist/harness/codex.js +14 -2
  22. package/dist/harness/types.d.ts +6 -1
  23. package/dist/index.d.ts +2 -1
  24. package/dist/index.js +1 -0
  25. package/dist/owner-channel/attachments.d.ts +11 -1
  26. package/dist/owner-channel/attachments.js +24 -3
  27. package/dist/owner-channel/channel.d.ts +2 -0
  28. package/dist/owner-channel/channel.js +134 -12
  29. package/dist/owner-channel/state.d.ts +1 -0
  30. package/dist/owner-channel/state.js +6 -3
  31. package/dist/permissions.d.ts +5 -0
  32. package/dist/permissions.js +7 -0
  33. package/dist/runner.js +2 -0
  34. package/dist/session/acp.d.ts +66 -1
  35. package/dist/session/acp.js +427 -22
  36. package/dist/session/arbiter.d.ts +10 -1
  37. package/dist/session/arbiter.js +24 -0
  38. package/dist/session/control.d.ts +28 -2
  39. package/dist/session/control.js +145 -5
  40. package/dist/session/conversation-normalizer.d.ts +34 -0
  41. package/dist/session/conversation-normalizer.js +356 -0
  42. package/dist/session/conversation-store.d.ts +88 -0
  43. package/dist/session/conversation-store.js +347 -0
  44. package/dist/session/conversation-types.d.ts +274 -0
  45. package/dist/session/conversation-types.js +1 -0
  46. package/dist/session/events.js +6 -1
  47. package/dist/session/types.d.ts +49 -0
  48. package/dist/spawn.d.ts +1 -0
  49. package/dist/spawn.js +9 -4
  50. package/dist/web/auth.d.ts +1 -1
  51. package/dist/web/fleet-config-service.d.ts +47 -0
  52. package/dist/web/fleet-config-service.js +204 -0
  53. package/dist/web/runtime.js +14 -1
  54. package/dist/web/server.d.ts +6 -0
  55. package/dist/web/server.js +181 -9
  56. package/dist/web/topology.d.ts +31 -0
  57. package/dist/web/topology.js +61 -0
  58. package/dist/web-app/assets/{TerminalView-DMoT8udI.js → TerminalView-hZpyUFY_.js} +1 -1
  59. package/dist/web-app/assets/index-COg4Azq1.css +1 -0
  60. package/dist/web-app/assets/index-Cde9auW0.js +10 -0
  61. package/dist/web-app/index.html +2 -2
  62. package/package.json +1 -1
  63. package/dist/web-app/assets/index-B-jtLAkp.css +0 -1
  64. package/dist/web-app/assets/index-B6T8JLSd.js +0 -9
@@ -4,9 +4,56 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
4
4
  import { isAbsolute, join, relative, resolve } from 'node:path';
5
5
  import { Readable, Writable } from 'node:stream';
6
6
  import * as acp from '@agentclientprotocol/sdk';
7
+ import { normalizeSessionUpdate } from './conversation-normalizer.js';
8
+ import { ConversationEventStore } from './conversation-store.js';
7
9
  import { SessionEvents } from './events.js';
8
10
  import { SessionControlError, classifyChildExit, turnResult } from './types.js';
9
11
  const CANCEL_SETTLE_GRACE_MS = 15_000;
12
+ /** A permission no human answered is eventually a decision nobody made. */
13
+ const PERMISSION_TIMEOUT_MS = 10 * 60_000;
14
+ /** Spec §4.3: 10-15 s before a vanished controller triggers the unattended policy. */
15
+ const CONTROLLER_GRACE_MS = 12_000;
16
+ const SCHEDULED_LOOP_REDACTION = '[scheduled-loop content redacted]';
17
+ const OWNER_COMMENTARY_REDACTION = '[assistant commentary redacted]';
18
+ const scheduledTurn = (turn) => turn?.origin?.kind === 'scheduled-loop';
19
+ /**
20
+ * Map typed prompt provenance to the conversation ledger's source vocabulary.
21
+ * Only operator-authored local sources may persist prompt bodies; external
22
+ * E2E bodies (owner channel, monitor wakes) and scheduled-loop content are
23
+ * recorded as digest/size placeholders (spec §8.3).
24
+ */
25
+ function conversationSource(origin) {
26
+ switch (origin?.kind) {
27
+ case 'owner-admin-console': return { source: 'owner_admin_console', persistBody: true };
28
+ case 'startup': return { source: 'startup', persistBody: true };
29
+ case 'owner': return { source: 'owner_channel', persistBody: false };
30
+ case 'fleet-monitor': return { source: 'fleet_monitor', persistBody: false };
31
+ case 'scheduled-loop': return { source: 'scheduled_loop', persistBody: false };
32
+ case 'local-console':
33
+ default:
34
+ return { source: 'local_console', persistBody: true };
35
+ }
36
+ }
37
+ /** Server-generated typed provenance followed by the exact human-authored body. */
38
+ export function promptContentBlocks(text, origin) {
39
+ if (origin?.kind !== 'owner-admin-console')
40
+ return [{ type: 'text', text }];
41
+ return [{
42
+ type: 'resource_link',
43
+ uri: 'ours-fleet://prompt-provenance?source=owner_admin_console',
44
+ name: 'Direct owner admin console',
45
+ description: 'Server-authenticated paired console provenance; accompanying text is direct owner input.',
46
+ mimeType: 'application/vnd.ours-fleet.prompt-provenance+json',
47
+ }, { type: 'text', text }];
48
+ }
49
+ export function runtimeSelector(options, category) {
50
+ const option = options?.find(candidate => candidate.category === category);
51
+ if (!option || typeof option.currentValue !== 'string')
52
+ return undefined;
53
+ const choices = Array.isArray(option.options) ? option.options.flatMap(choice => 'options' in choice ? choice.options : [choice]) : [];
54
+ const selected = choices.find(choice => choice.value === option.currentValue);
55
+ return { value: option.currentValue, ...(selected?.name ? { label: selected.name } : {}) };
56
+ }
10
57
  /**
11
58
  * Classify an ACP `stopReason` into a terminal outcome. A refusal and a
12
59
  * cancellation are the two ways a delivered prompt ends without being carried
@@ -29,6 +76,11 @@ export class AcpSession {
29
76
  pid;
30
77
  child;
31
78
  events;
79
+ conversation;
80
+ /** New on every runner start; permission/turn IDs from prior generations are stale. */
81
+ sessionGeneration = randomUUID();
82
+ /** True while `session/load` replays history as ordinary updates. */
83
+ replaying = false;
32
84
  sessionFile;
33
85
  pendingPermissions = new Map();
34
86
  connection;
@@ -40,7 +92,11 @@ export class AcpSession {
40
92
  exit = null;
41
93
  steeringSupported = false;
42
94
  capabilities;
95
+ runtimeModel;
96
+ reasoningEffort;
43
97
  controllerCount = 0;
98
+ /** Armed when the last controller detaches; unattended policy applies on fire. */
99
+ controllerGrace;
44
100
  cancelEscalation;
45
101
  activeTurn;
46
102
  constructor(options, child, connection) {
@@ -49,6 +105,9 @@ export class AcpSession {
49
105
  this.connection = connection;
50
106
  this.pid = child.pid ?? -1;
51
107
  this.events = new SessionEvents(join(options.stateDir, '.session-events.jsonl'));
108
+ this.conversation = new ConversationEventStore(join(options.stateDir, '.conversation'), {
109
+ roleId: options.name, log: line => options.log(`[${options.name}] ${line}`),
110
+ });
52
111
  this.sessionFile = join(options.stateDir, '.acp-session-id');
53
112
  child.stderr.on('data', chunk => options.log(`[${options.name}] acp: ${String(chunk).trimEnd()}`));
54
113
  child.once('exit', (code, signal) => {
@@ -61,6 +120,11 @@ export class AcpSession {
61
120
  this.lastError = `ACP agent ${this.exit.detail}`;
62
121
  }
63
122
  this.events.emit('state', { status: 'failed', text: this.lastError });
123
+ this.conversation.appendSafe({
124
+ kind: 'session.state', sessionGeneration: this.sessionGeneration,
125
+ acpSessionId: this.sessionId,
126
+ payload: { status: 'failed', detail: this.lastError },
127
+ });
64
128
  });
65
129
  }
66
130
  static async start(options) {
@@ -90,6 +154,7 @@ export class AcpSession {
90
154
  instance = new AcpSession(options, child, connection);
91
155
  try {
92
156
  await instance.initialize();
157
+ instance.recoverOpenPrompts();
93
158
  return instance;
94
159
  }
95
160
  catch (error) {
@@ -98,6 +163,42 @@ export class AcpSession {
98
163
  throw error;
99
164
  }
100
165
  }
166
+ /**
167
+ * Honest restart recovery (spec §5.3): a prompt that was admitted but never
168
+ * started is safe to dispatch again; a turn that had already started may
169
+ * have caused side effects, so it is closed as `unknown_after_restart` —
170
+ * never silently replayed.
171
+ */
172
+ recoverOpenPrompts() {
173
+ for (const open of this.conversation.openPrompts()) {
174
+ if (open.sessionGeneration === this.sessionGeneration)
175
+ continue;
176
+ if (open.state === 'started') {
177
+ this.conversation.appendSafe({
178
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
179
+ promptId: open.promptId, turnId: open.promptId,
180
+ payload: { outcome: 'unknown_after_restart' },
181
+ });
182
+ continue;
183
+ }
184
+ if (open.text === undefined) {
185
+ // Admitted, never started, and the body was deliberately not retained
186
+ // (external E2E source): there is nothing faithful left to dispatch.
187
+ this.conversation.appendSafe({
188
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
189
+ promptId: open.promptId, turnId: open.promptId,
190
+ payload: { outcome: 'failed', stopReason: 'prompt-body-not-retained' },
191
+ });
192
+ continue;
193
+ }
194
+ const text = open.text;
195
+ const promptId = open.promptId;
196
+ this.queueDepth++;
197
+ const run = this.promptTail.then(() => this.runPrompt(text, promptId));
198
+ this.promptTail = run.then(() => undefined, () => undefined);
199
+ void run.finally(() => { this.queueDepth = Math.max(0, this.queueDepth - 1); });
200
+ }
201
+ }
101
202
  isAlive() {
102
203
  return this.child.exitCode === null && !this.child.killed;
103
204
  }
@@ -109,6 +210,9 @@ export class AcpSession {
109
210
  sessionId: this.sessionId,
110
211
  lastError: this.lastError,
111
212
  pendingPermissionId: this.pendingPermissions.keys().next().value,
213
+ runtimeModel: this.runtimeModel,
214
+ reasoningEffort: this.reasoningEffort,
215
+ permissionMode: this.options.permissionMode,
112
216
  };
113
217
  }
114
218
  /**
@@ -130,7 +234,9 @@ export class AcpSession {
130
234
  return { promptId, queuedBehind: 0, completion: this.steerPrompt(text), origin: options.origin };
131
235
  }
132
236
  const promptId = randomUUID();
133
- const queuedBehind = this.queueDepth++;
237
+ const queuedBehind = this.queueDepth;
238
+ this.admitToLedger(promptId, text, queuedBehind, options);
239
+ this.queueDepth++;
134
240
  const run = this.promptTail.then(() => this.runPrompt(text, promptId, options.origin));
135
241
  this.promptTail = run.then(() => undefined, () => undefined);
136
242
  const completion = run.then(result => { this.queueDepth = Math.max(0, this.queueDepth - 1); return result; }, error => {
@@ -139,6 +245,66 @@ export class AcpSession {
139
245
  });
140
246
  return { promptId, queuedBehind, completion, origin: options.origin };
141
247
  }
248
+ /**
249
+ * Durably record a prompt admission BEFORE acceptance is returned. Browser
250
+ * admissions are transactional — a prompt the ledger cannot hold is refused,
251
+ * because an acknowledged-then-lost prompt is worse than an error. Every
252
+ * other source degrades to best-effort so the agent keeps working (§5.3).
253
+ */
254
+ admitToLedger(promptId, text, queuedBehind, options) {
255
+ const { source, persistBody } = conversationSource(options.origin);
256
+ const bytes = Buffer.byteLength(text);
257
+ const draft = {
258
+ kind: 'prompt.admitted',
259
+ sessionGeneration: this.sessionGeneration,
260
+ acpSessionId: this.sessionId,
261
+ promptId, turnId: promptId, source,
262
+ ...(options.origin?.kind === 'owner-admin-console' ? { commandId: options.origin.commandId } : {}),
263
+ ...(options.actor ? { actor: options.actor } : {}),
264
+ payload: {
265
+ queuedBehind,
266
+ ...(options.origin?.kind === 'owner' && options.origin.displayText !== undefined
267
+ ? { displayText: { type: 'text', text: options.origin.displayText,
268
+ bytes: Buffer.byteLength(options.origin.displayText) } }
269
+ : {}),
270
+ ...(persistBody
271
+ ? { text: { type: 'text', text, bytes } }
272
+ : { external: { digest: ConversationEventStore.bodyDigest(text), bytes } }),
273
+ },
274
+ };
275
+ if (source === 'owner_admin_console') {
276
+ try {
277
+ this.conversation.append(draft);
278
+ }
279
+ catch (error) {
280
+ throw new SessionControlError('backend', `conversation store cannot record the prompt: ${error.message}`);
281
+ }
282
+ }
283
+ else {
284
+ this.conversation.appendSafe(draft);
285
+ }
286
+ }
287
+ /** Idempotent browser prompt admission (control v3 `submit_prompt_v2`). */
288
+ async submitPromptBrowser(command) {
289
+ const bodyDigest = ConversationEventStore.bodyDigest(command.text);
290
+ const existing = this.conversation.receiptFor(command.commandId, bodyDigest);
291
+ if (existing)
292
+ return existing;
293
+ const queued = await this.queuePrompt(command.text, {
294
+ origin: { kind: 'owner-admin-console', commandId: command.commandId },
295
+ actor: { browserSession: command.actorBrowserSession },
296
+ });
297
+ const receipt = {
298
+ commandId: command.commandId,
299
+ promptId: queued.promptId,
300
+ state: queued.queuedBehind > 0 ? 'queued' : 'starting',
301
+ queuedBehind: queued.queuedBehind,
302
+ acceptedAt: new Date().toISOString(),
303
+ eventCursor: this.conversation.lastCursor() ?? '0',
304
+ };
305
+ this.conversation.recordReceipt(command.commandId, receipt, bodyDigest);
306
+ return receipt;
307
+ }
142
308
  async submitPrompt(text, options = {}) {
143
309
  try {
144
310
  return await (await this.queuePrompt(text, options)).completion;
@@ -168,6 +334,11 @@ export class AcpSession {
168
334
  throw error;
169
335
  }
170
336
  if (active) {
337
+ this.conversation.appendSafe({
338
+ kind: 'prompt.interrupt_requested', sessionGeneration: this.sessionGeneration,
339
+ acpSessionId: this.sessionId, promptId: active.id, turnId: active.id,
340
+ payload: { cancellationSource: source },
341
+ });
171
342
  if (this.cancelEscalation)
172
343
  clearTimeout(this.cancelEscalation);
173
344
  const turnId = active.id;
@@ -181,9 +352,8 @@ export class AcpSession {
181
352
  }, this.options.cancelGraceMs ?? CANCEL_SETTLE_GRACE_MS);
182
353
  this.cancelEscalation.unref?.();
183
354
  }
184
- for (const pending of this.pendingPermissions.values())
185
- pending.resolve({ outcome: { outcome: 'cancelled' } });
186
- this.pendingPermissions.clear();
355
+ for (const [permissionId, pending] of [...this.pendingPermissions])
356
+ this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the turn was cancelled while this request was pending');
187
357
  }
188
358
  respondPermission(permissionId, optionId) {
189
359
  const pending = this.pendingPermissions.get(permissionId);
@@ -191,20 +361,39 @@ export class AcpSession {
191
361
  if (!pending || !chosen)
192
362
  return false;
193
363
  this.pendingPermissions.delete(permissionId);
364
+ if (pending.expiry)
365
+ clearTimeout(pending.expiry);
194
366
  pending.resolve({ outcome: { outcome: 'selected', optionId } });
367
+ const decision = chosen.kind.startsWith('reject') ? 'denied' : 'allowed';
195
368
  this.events.emit('permission', {
196
369
  turnId: this.activeTurn?.id,
197
370
  origin: this.activeTurn?.origin,
198
371
  permissionId,
199
372
  status: 'completed',
200
- decision: chosen.kind.startsWith('reject') ? 'denied' : 'allowed',
373
+ decision,
201
374
  decisionSource: 'manual',
202
375
  reason: `answered from an attached controller (${chosen.kind})`,
203
376
  optionId,
204
377
  });
378
+ this.conversation.appendSafe({
379
+ kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
380
+ acpSessionId: this.sessionId, permissionId,
381
+ promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
382
+ payload: { decision, decisionSource: 'manual', optionId },
383
+ });
205
384
  this.readiness = 'running';
206
385
  return true;
207
386
  }
387
+ /**
388
+ * A v2 decision binds to the session generation it was shown under. A stale
389
+ * generation, an already-settled request, or an unknown option are all the
390
+ * same answer: someone else's decision (or a restart) got there first.
391
+ */
392
+ respondPermissionV2(permissionId, optionId, sessionGeneration) {
393
+ if (sessionGeneration !== this.sessionGeneration)
394
+ return 'stale';
395
+ return this.respondPermission(permissionId, optionId) ? 'accepted' : 'stale';
396
+ }
208
397
  eventsSince(seq) {
209
398
  return this.events.since(seq);
210
399
  }
@@ -212,7 +401,76 @@ export class AcpSession {
212
401
  return this.events.subscribe(listener);
213
402
  }
214
403
  setControllerAttached(attached) {
404
+ const before = this.controllerCount;
215
405
  this.controllerCount = Math.max(0, this.controllerCount + (attached ? 1 : -1));
406
+ if (attached) {
407
+ if (this.controllerGrace)
408
+ clearTimeout(this.controllerGrace);
409
+ this.controllerGrace = undefined;
410
+ return;
411
+ }
412
+ // The LAST controller just walked away with permissions possibly pending.
413
+ // Nothing is decided yet: a reconnect within the grace keeps everything
414
+ // alive; only its expiry hands the pendings to the unattended policy.
415
+ if (before > 0 && this.controllerCount === 0 && this.pendingPermissions.size > 0)
416
+ this.armControllerGrace();
417
+ }
418
+ armControllerGrace() {
419
+ if (this.controllerGrace)
420
+ clearTimeout(this.controllerGrace);
421
+ this.controllerGrace = setTimeout(() => {
422
+ this.controllerGrace = undefined;
423
+ if (this.controllerCount > 0 || this.options.permissions.unattended !== 'deny')
424
+ return;
425
+ for (const [permissionId, pending] of [...this.pendingPermissions])
426
+ this.settlePendingAutomatically(permissionId, pending, 'denied', 'permissions.unattended=deny', 'the last attached controller disconnected and the grace period expired');
427
+ }, this.options.controllerGraceMs ?? CONTROLLER_GRACE_MS);
428
+ this.controllerGrace.unref?.();
429
+ }
430
+ /**
431
+ * Settle one pending request without a human decision — unattended policy,
432
+ * expiry, or cancellation — and leave the same durable evidence a manual
433
+ * decision would. A denial selects the agent's own one-shot reject option;
434
+ * everything else resolves as cancelled toward the agent.
435
+ */
436
+ settlePendingAutomatically(permissionId, pending, decision, policy, reason) {
437
+ if (!this.pendingPermissions.delete(permissionId))
438
+ return;
439
+ if (pending.expiry)
440
+ clearTimeout(pending.expiry);
441
+ const rejectOption = decision === 'denied'
442
+ ? pending.options.find(option => option.kind === 'reject_once')
443
+ ?? pending.options.find(option => option.kind === 'reject_always')
444
+ : undefined;
445
+ pending.resolve(rejectOption
446
+ ? { outcome: { outcome: 'selected', optionId: rejectOption.optionId } }
447
+ : { outcome: { outcome: 'cancelled' } });
448
+ const settled = decision === 'denied' && !rejectOption ? 'cancelled' : decision;
449
+ this.events.emit('permission', {
450
+ turnId: this.activeTurn?.id,
451
+ origin: this.activeTurn?.origin,
452
+ permissionId,
453
+ toolCallId: pending.toolCallId,
454
+ status: 'completed',
455
+ decision: settled === 'expired' ? 'cancelled' : settled,
456
+ decisionSource: 'automatic',
457
+ policy,
458
+ reason,
459
+ optionId: rejectOption?.optionId,
460
+ });
461
+ this.conversation.appendSafe({
462
+ kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
463
+ acpSessionId: this.sessionId, permissionId,
464
+ promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
465
+ toolCallId: pending.toolCallId,
466
+ payload: {
467
+ decision: settled, decisionSource: 'automatic',
468
+ ...(policy ? { policy } : {}), reason,
469
+ ...(rejectOption ? { optionId: rejectOption.optionId } : {}),
470
+ },
471
+ });
472
+ if (this.pendingPermissions.size === 0 && this.readiness === 'awaiting_permission')
473
+ this.readiness = 'running';
216
474
  }
217
475
  exitResult() {
218
476
  return this.exit;
@@ -221,15 +479,22 @@ export class AcpSession {
221
479
  if (this.cancelEscalation)
222
480
  clearTimeout(this.cancelEscalation);
223
481
  this.cancelEscalation = undefined;
224
- for (const pending of this.pendingPermissions.values())
225
- pending.resolve({ outcome: { outcome: 'cancelled' } });
226
- this.pendingPermissions.clear();
482
+ if (this.controllerGrace)
483
+ clearTimeout(this.controllerGrace);
484
+ this.controllerGrace = undefined;
485
+ for (const [permissionId, pending] of [...this.pendingPermissions])
486
+ this.settlePendingAutomatically(permissionId, pending, 'cancelled', undefined, 'the session closed while this request was pending');
227
487
  if (this.sessionId && this.capabilities?.sessionCapabilities?.close != null) {
228
488
  await this.connection.agent.request(acp.methods.agent.session.close, { sessionId: this.sessionId }).catch(() => undefined);
229
489
  }
230
490
  this.connection.close();
231
491
  if (this.isAlive())
232
492
  this.child.kill('SIGTERM');
493
+ this.conversation.appendSafe({
494
+ kind: 'session.state', sessionGeneration: this.sessionGeneration,
495
+ acpSessionId: this.sessionId, payload: { status: 'offline' },
496
+ });
497
+ this.conversation.close();
233
498
  }
234
499
  async initialize() {
235
500
  const initialized = await this.connection.agent.request(acp.methods.agent.initialize, {
@@ -247,19 +512,29 @@ export class AcpSession {
247
512
  ? readFileSync(this.sessionFile, 'utf8').trim()
248
513
  : '';
249
514
  if (persisted && this.capabilities?.sessionCapabilities?.resume != null) {
250
- await this.connection.agent.request(acp.methods.agent.session.resume, {
515
+ const resumed = await this.connection.agent.request(acp.methods.agent.session.resume, {
251
516
  sessionId: persisted,
252
517
  cwd: this.options.cwd,
253
518
  mcpServers: [],
254
519
  });
520
+ this.captureRuntimeMetadata(resumed.configOptions);
255
521
  this.sessionId = persisted;
256
522
  }
257
523
  else if (persisted && this.capabilities?.loadSession) {
258
- await this.connection.agent.request(acp.methods.agent.session.load, {
259
- sessionId: persisted,
260
- cwd: this.options.cwd,
261
- mcpServers: [],
262
- });
524
+ // `session/load` replays prior history as ordinary updates before the
525
+ // response; those records carry `agent_replay` provenance, never `agent`.
526
+ this.replaying = true;
527
+ try {
528
+ const loaded = await this.connection.agent.request(acp.methods.agent.session.load, {
529
+ sessionId: persisted,
530
+ cwd: this.options.cwd,
531
+ mcpServers: [],
532
+ });
533
+ this.captureRuntimeMetadata(loaded.configOptions);
534
+ }
535
+ finally {
536
+ this.replaying = false;
537
+ }
263
538
  this.sessionId = persisted;
264
539
  }
265
540
  else {
@@ -268,6 +543,7 @@ export class AcpSession {
268
543
  mcpServers: [],
269
544
  });
270
545
  this.sessionId = created.sessionId;
546
+ this.captureRuntimeMetadata(created.configOptions);
271
547
  }
272
548
  writeFileSync(this.sessionFile, this.sessionId + '\n', { mode: 0o600 });
273
549
  // Deliver the configured permission mode whichever way the session came up
@@ -288,6 +564,14 @@ export class AcpSession {
288
564
  }
289
565
  this.readiness = 'idle';
290
566
  this.events.emit('state', { status: 'idle', text: `ACP session ${this.sessionId}` });
567
+ this.conversation.appendSafe({
568
+ kind: 'session.state', sessionGeneration: this.sessionGeneration,
569
+ acpSessionId: this.sessionId, payload: { status: 'idle' },
570
+ });
571
+ }
572
+ captureRuntimeMetadata(options) {
573
+ this.runtimeModel = runtimeSelector(options, 'model');
574
+ this.reasoningEffort = runtimeSelector(options, 'thought_level');
291
575
  }
292
576
  async runPrompt(text, turnId = randomUUID(), origin) {
293
577
  if (!this.sessionId || !this.isAlive())
@@ -295,18 +579,32 @@ export class AcpSession {
295
579
  this.readiness = 'running';
296
580
  this.activeTurn = { id: turnId, output: '', origin };
297
581
  this.events.emit('state', { turnId, status: 'running', origin });
582
+ this.conversation.appendSafe({
583
+ kind: 'prompt.started', sessionGeneration: this.sessionGeneration,
584
+ acpSessionId: this.sessionId, promptId: turnId, turnId,
585
+ source: conversationSource(origin).source, payload: {},
586
+ });
298
587
  try {
299
588
  const response = await this.connection.agent.request(acp.methods.agent.session.prompt, {
300
589
  sessionId: this.sessionId,
301
- prompt: [{ type: 'text', text }],
590
+ prompt: promptContentBlocks(text, origin),
302
591
  });
303
592
  this.readiness = 'idle';
593
+ const cancellationSource = this.activeTurn?.id === turnId
594
+ ? this.activeTurn.cancellationSource : undefined;
304
595
  this.events.emit('turn_stop', {
305
- turnId, stopReason: response.stopReason, origin,
306
- cancellationSource: this.activeTurn?.id === turnId
307
- ? this.activeTurn.cancellationSource : undefined,
596
+ turnId, stopReason: response.stopReason, origin, cancellationSource,
308
597
  });
309
598
  this.events.emit('state', { status: 'idle' });
599
+ this.conversation.appendSafe({
600
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
601
+ acpSessionId: this.sessionId, promptId: turnId, turnId,
602
+ payload: {
603
+ outcome: classifyStopReason(response.stopReason),
604
+ stopReason: response.stopReason,
605
+ ...(cancellationSource ? { cancellationSource } : {}),
606
+ },
607
+ });
310
608
  // The prompt was accepted either way — the agent answered. Whether the
311
609
  // turn SUCCEEDED is a separate question, and only `stopReason` answers it.
312
610
  return turnResult(true, classifyStopReason(response.stopReason), response.stopReason, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined, this.activeTurn?.id === turnId ? this.activeTurn.cancellationSource : undefined);
@@ -321,6 +619,11 @@ export class AcpSession {
321
619
  });
322
620
  if (this.isAlive())
323
621
  this.events.emit('state', { status: 'idle' });
622
+ this.conversation.appendSafe({
623
+ kind: 'turn.completed', sessionGeneration: this.sessionGeneration,
624
+ acpSessionId: this.sessionId, promptId: turnId, turnId,
625
+ payload: { outcome: 'failed', stopReason: this.lastError },
626
+ });
324
627
  return turnResult(false, 'failed', this.lastError, this.activeTurn?.id === turnId ? this.activeTurn.output : undefined);
325
628
  }
326
629
  finally {
@@ -368,7 +671,10 @@ export class AcpSession {
368
671
  const option = choose(['allow_always', 'allow_once']);
369
672
  return Promise.resolve(this.settleAutomatically(params, option, 'allowed', 'permissions.approval=allow', `the request is inside the ${this.options.permissions.filesystem} boundary`));
370
673
  }
371
- const unattended = this.controllerCount === 0 && this.options.permissions.unattended === 'deny';
674
+ // A live grace window still counts as attended: the controller may be
675
+ // mid-reconnect, and denying instantly is exactly what the grace prevents.
676
+ const unattended = this.controllerCount === 0 && !this.controllerGrace
677
+ && this.options.permissions.unattended === 'deny';
372
678
  if (this.options.permissions.approval === 'deny' || unattended) {
373
679
  // reject_once FIRST: `reject_always` teaches the agent a standing rule from
374
680
  // a decision no human made, so one unattended denial would silently disable
@@ -379,6 +685,8 @@ export class AcpSession {
379
685
  : 'the role denies every permission request by policy'));
380
686
  }
381
687
  const permissionId = randomUUID();
688
+ const timeoutMs = this.options.permissionTimeoutMs ?? PERMISSION_TIMEOUT_MS;
689
+ const expiresAt = new Date(Date.now() + timeoutMs).toISOString();
382
690
  this.readiness = 'awaiting_permission';
383
691
  this.events.emit('permission', {
384
692
  turnId: this.activeTurn?.id,
@@ -395,8 +703,34 @@ export class AcpSession {
395
703
  kind: option.kind,
396
704
  })),
397
705
  });
706
+ this.conversation.appendSafe({
707
+ kind: 'permission.requested', sessionGeneration: this.sessionGeneration,
708
+ acpSessionId: this.sessionId, permissionId,
709
+ promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
710
+ toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
711
+ payload: {
712
+ toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
713
+ title: scheduledTurn(this.activeTurn)
714
+ ? 'Scheduled-loop permission requested' : params.toolCall.title ?? 'Permission requested',
715
+ options: params.options.map(option => ({
716
+ optionId: option.optionId,
717
+ name: scheduledTurn(this.activeTurn) ? option.kind : option.name,
718
+ kind: option.kind,
719
+ })),
720
+ expiresAt,
721
+ },
722
+ });
398
723
  return new Promise(resolve => {
399
- this.pendingPermissions.set(permissionId, { options: params.options, resolve });
724
+ const pending = {
725
+ options: params.options, resolve,
726
+ toolCallId: scheduledTurn(this.activeTurn)
727
+ ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
728
+ };
729
+ pending.expiry = setTimeout(() => {
730
+ this.settlePendingAutomatically(permissionId, pending, 'expired', undefined, `no decision arrived within ${Math.round(timeoutMs / 1000)}s`);
731
+ }, timeoutMs);
732
+ pending.expiry.unref?.();
733
+ this.pendingPermissions.set(permissionId, pending);
400
734
  });
401
735
  }
402
736
  /**
@@ -426,6 +760,17 @@ export class AcpSession {
426
760
  kind: o.kind,
427
761
  })),
428
762
  });
763
+ this.conversation.appendSafe({
764
+ kind: 'permission.resolved', sessionGeneration: this.sessionGeneration,
765
+ acpSessionId: this.sessionId, permissionId: randomUUID(),
766
+ promptId: this.activeTurn?.id, turnId: this.activeTurn?.id,
767
+ toolCallId: scheduledTurn(this.activeTurn) ? 'scheduled-loop-tool' : params.toolCall.toolCallId,
768
+ payload: {
769
+ decision: settled,
770
+ decisionSource: 'automatic', policy, reason,
771
+ ...(option ? { optionId: option.optionId } : {}),
772
+ },
773
+ });
429
774
  return option
430
775
  ? { outcome: { outcome: 'selected', optionId: option.optionId } }
431
776
  : { outcome: { outcome: 'cancelled' } };
@@ -448,17 +793,29 @@ export class AcpSession {
448
793
  }
449
794
  recordUpdate(update) {
450
795
  const scheduled = this.activeTurn?.origin?.kind === 'scheduled-loop';
796
+ const messagePhase = update.sessionUpdate === 'agent_message_chunk'
797
+ ? this.codexMessagePhase(update) : undefined;
798
+ this.recordConversationUpdate(update, scheduled, messagePhase === 'commentary');
799
+ if (update.sessionUpdate === 'config_option_update')
800
+ this.captureRuntimeMetadata(update.configOptions);
451
801
  switch (update.sessionUpdate) {
452
- case 'agent_message_chunk':
453
- if (this.activeTurn && update.content.type === 'text')
802
+ case 'agent_message_chunk': {
803
+ const phase = messagePhase;
804
+ if (this.activeTurn && update.content.type === 'text'
805
+ && phase !== 'commentary' && phase !== 'ambiguous')
454
806
  this.activeTurn.output += update.content.text;
455
807
  this.events.emit('agent_text', {
456
808
  turnId: this.activeTurn?.id,
457
809
  origin: this.activeTurn?.origin,
458
810
  text: scheduled ? '[scheduled-loop output redacted]'
459
811
  : update.content.type === 'text' ? update.content.text : `[${update.content.type}]`,
812
+ ...(phase === 'commentary' || phase === 'final_answer'
813
+ ? { messagePhase: phase } : {}),
814
+ ...(typeof update.messageId === 'string' ? { messageId: update.messageId } : {}),
815
+ ...(this.replaying ? { replayed: true } : {}),
460
816
  });
461
817
  break;
818
+ }
462
819
  case 'agent_thought_chunk':
463
820
  this.events.emit('thought', {
464
821
  turnId: this.activeTurn?.id,
@@ -489,6 +846,54 @@ export class AcpSession {
489
846
  break;
490
847
  }
491
848
  }
849
+ /**
850
+ * Codex ACP's phase extension is the only currently supported visibility
851
+ * signal. Never infer commentary from text, message order, or unknown meta.
852
+ */
853
+ codexMessagePhase(update) {
854
+ const meta = update._meta;
855
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta))
856
+ return undefined;
857
+ const codex = meta.codex;
858
+ if (!codex || typeof codex !== 'object' || Array.isArray(codex))
859
+ return undefined;
860
+ const phase = codex.phase;
861
+ if (phase === undefined)
862
+ return undefined;
863
+ return phase === 'commentary' || phase === 'final_answer' ? phase : 'ambiguous';
864
+ }
865
+ /** Normalize every ACP update losslessly into the durable ledger. */
866
+ recordConversationUpdate(update, scheduled, commentary = false) {
867
+ const normalized = normalizeSessionUpdate(update, scheduled ? { redactText: SCHEDULED_LOOP_REDACTION }
868
+ : commentary ? { redactText: OWNER_COMMENTARY_REDACTION } : {});
869
+ this.conversation.appendSafe({
870
+ kind: normalized.kind,
871
+ sessionGeneration: this.sessionGeneration,
872
+ acpSessionId: this.sessionId,
873
+ ...(this.activeTurn ? { promptId: this.activeTurn.id, turnId: this.activeTurn.id } : {}),
874
+ ...(normalized.messageId ? { messageId: normalized.messageId } : {}),
875
+ ...(normalized.toolCallId ? { toolCallId: normalized.toolCallId } : {}),
876
+ source: this.replaying ? 'agent_replay' : 'agent',
877
+ payload: normalized.payload,
878
+ ...(normalized.adapterMeta ? { adapterMeta: normalized.adapterMeta } : {}),
879
+ });
880
+ }
881
+ // ── conversation ledger access (SessionHandle) ─────────────────────────────
882
+ conversationPage(request = {}) {
883
+ return { ...this.conversation.page(request), snapshot: this.conversationSnapshot() };
884
+ }
885
+ conversationSnapshot() {
886
+ return {
887
+ sessionGeneration: this.sessionGeneration,
888
+ readiness: this.isAlive() ? this.readiness : 'offline',
889
+ queueDepth: this.queueDepth,
890
+ pendingPermissionIds: [...this.pendingPermissions.keys()],
891
+ ...(this.conversation.degraded ? { historyDegraded: true } : {}),
892
+ };
893
+ }
894
+ subscribeConversation(listener) {
895
+ return this.conversation.subscribe(listener);
896
+ }
492
897
  fail(error) {
493
898
  this.lastError = error?.message ?? String(error);
494
899
  this.readiness = 'failed';