@ours.network/fleet 0.15.3 → 0.15.4

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