@adecore/agents 0.39.0 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (172) hide show
  1. package/dist/async.d.ts +5 -0
  2. package/dist/async.js +13 -5
  3. package/dist/chat/acp-protocol.js +3 -5
  4. package/dist/chat/chat-core.js +0 -2
  5. package/dist/chat/chat-process.d.ts +1 -0
  6. package/dist/chat/chat-process.js +1 -1
  7. package/dist/chat/chat-session.js +6 -8
  8. package/dist/chat/claude-protocol.js +1 -6
  9. package/dist/chat/claude-transcript.js +1 -3
  10. package/dist/chat/codex-protocol.js +1 -6
  11. package/dist/chat/projector.js +1 -3
  12. package/dist/chat/request-summary.js +1 -3
  13. package/dist/coded-error.js +1 -5
  14. package/dist/context/argv.js +1 -2
  15. package/dist/context/verb.js +1 -3
  16. package/dist/error-text.d.ts +1 -0
  17. package/dist/error-text.js +4 -0
  18. package/dist/fields.d.ts +3 -0
  19. package/dist/fields.js +12 -0
  20. package/dist/fs.d.ts +1 -0
  21. package/dist/fs.js +8 -4
  22. package/dist/lineage.js +11 -5
  23. package/dist/messages/notice-store.js +3 -4
  24. package/dist/providers/accounts/acp-account.js +3 -20
  25. package/dist/providers/accounts/service.d.ts +1 -2
  26. package/dist/providers/accounts/service.js +5 -9
  27. package/dist/providers/accounts/status.js +1 -3
  28. package/dist/providers/acp-agent-state.js +1 -3
  29. package/dist/providers/claude.js +0 -1
  30. package/dist/providers/codex-models.json +3 -2
  31. package/dist/providers/codex.js +0 -1
  32. package/dist/providers/detect.js +1 -1
  33. package/dist/providers/shell-commands.js +1 -2
  34. package/dist/run-process.js +1 -3
  35. package/dist/skills.d.ts +1 -1
  36. package/dist/skills.js +5 -8
  37. package/dist/tasks/background-limit.js +3 -2
  38. package/dist/tasks/task-coordinator.js +11 -17
  39. package/dist/testing/watch.js +0 -1
  40. package/dist/usage/limits/monitor.js +1 -2
  41. package/dist/usage/prices-snapshot.json +8 -0
  42. package/dist/usage/readers/claude.js +3 -10
  43. package/dist/usage/readers/codex.js +2 -9
  44. package/dist/usage/readers/grok.js +3 -10
  45. package/dist/usage/readers/host-log.js +3 -10
  46. package/dist/usage/record.d.ts +2 -0
  47. package/dist/usage/record.js +13 -0
  48. package/package.json +6 -6
  49. package/src/async.ts +23 -5
  50. package/src/chat/acp-backend.ts +121 -0
  51. package/src/chat/acp-protocol.ts +172 -6
  52. package/src/chat/attachment-store.ts +21 -0
  53. package/src/chat/backend.ts +0 -2
  54. package/src/chat/bookmark-store.ts +23 -0
  55. package/src/chat/chat-core.ts +208 -7
  56. package/src/chat/chat-log.ts +19 -0
  57. package/src/chat/chat-process.ts +52 -1
  58. package/src/chat/chat-session.ts +401 -10
  59. package/src/chat/chat-store.ts +50 -0
  60. package/src/chat/chat-title.ts +9 -0
  61. package/src/chat/claude-backend.ts +50 -0
  62. package/src/chat/claude-protocol.ts +155 -8
  63. package/src/chat/claude-title.ts +10 -0
  64. package/src/chat/claude-transcript.ts +47 -4
  65. package/src/chat/codex-backend.ts +131 -0
  66. package/src/chat/codex-protocol.ts +180 -8
  67. package/src/chat/codex-thread.ts +23 -0
  68. package/src/chat/composer-preferences.ts +6 -0
  69. package/src/chat/context-breakdown.ts +11 -0
  70. package/src/chat/delta-coalescer.ts +8 -0
  71. package/src/chat/external-sessions.ts +28 -0
  72. package/src/chat/generated-image.ts +25 -0
  73. package/src/chat/index.ts +5 -0
  74. package/src/chat/input.ts +13 -0
  75. package/src/chat/json-rpc-transport.ts +28 -0
  76. package/src/chat/limit-resume.ts +13 -0
  77. package/src/chat/projector.ts +160 -4
  78. package/src/chat/readable-text.ts +3 -0
  79. package/src/chat/request-summary.ts +18 -4
  80. package/src/chat/subagent-projection.ts +3 -0
  81. package/src/chat/subagent-reader.ts +76 -0
  82. package/src/chat/subagent-settlement.ts +24 -0
  83. package/src/chat/thread-search.ts +13 -0
  84. package/src/chat/thread.ts +31 -0
  85. package/src/chat/ui-queries.ts +122 -0
  86. package/src/chat/ui-replies.ts +34 -0
  87. package/src/chat/visual-store.ts +49 -0
  88. package/src/chat/wake-chat.ts +3 -0
  89. package/src/client-sinks.ts +2 -0
  90. package/src/coded-error.ts +1 -5
  91. package/src/context/argv.ts +18 -2
  92. package/src/context/index.ts +1 -0
  93. package/src/context/refusal.ts +3 -0
  94. package/src/context/verb.ts +32 -4
  95. package/src/coordination/index.ts +10 -0
  96. package/src/error-text.ts +7 -0
  97. package/src/fields.ts +14 -0
  98. package/src/fs.ts +20 -4
  99. package/src/host/agent-host.ts +20 -0
  100. package/src/host/environment.ts +6 -0
  101. package/src/host/handlers.ts +18 -0
  102. package/src/host/memory-port.ts +4 -0
  103. package/src/host/wiring.ts +10 -0
  104. package/src/index.ts +1 -0
  105. package/src/lineage.ts +24 -5
  106. package/src/messages/deliver-message.ts +6 -0
  107. package/src/messages/deliver-notice.ts +13 -0
  108. package/src/messages/notice-store.ts +16 -4
  109. package/src/modes.ts +1 -0
  110. package/src/outbox/outbox-worker.ts +31 -0
  111. package/src/outbox/outbox.ts +5 -0
  112. package/src/providers/accounts/accounts.ts +36 -0
  113. package/src/providers/accounts/acp-account.ts +22 -27
  114. package/src/providers/accounts/launch.ts +4 -0
  115. package/src/providers/accounts/service.ts +110 -12
  116. package/src/providers/accounts/shadow-home.ts +10 -0
  117. package/src/providers/accounts/status.ts +13 -4
  118. package/src/providers/accounts/store.ts +25 -0
  119. package/src/providers/accounts/variables.ts +11 -1
  120. package/src/providers/acp-agent-state.ts +24 -3
  121. package/src/providers/acp-provider.ts +5 -0
  122. package/src/providers/acp-usage-log.ts +2 -0
  123. package/src/providers/catalog.ts +17 -0
  124. package/src/providers/claude.ts +10 -1
  125. package/src/providers/codex-models.json +3 -2
  126. package/src/providers/codex-rules.ts +3 -0
  127. package/src/providers/codex.ts +2 -1
  128. package/src/providers/detect.ts +6 -1
  129. package/src/providers/provider.ts +1 -1
  130. package/src/providers/registry.ts +18 -2
  131. package/src/providers/shell-commands.ts +37 -2
  132. package/src/record-directory.ts +19 -0
  133. package/src/run-process.ts +24 -3
  134. package/src/serializer.ts +3 -0
  135. package/src/skills.ts +73 -10
  136. package/src/tasks/background-limit.ts +9 -2
  137. package/src/tasks/end-children.ts +9 -0
  138. package/src/tasks/give-task.ts +8 -0
  139. package/src/tasks/task-coordinator.ts +81 -17
  140. package/src/tasks/task-store.ts +28 -0
  141. package/src/tasks/waiting-child.ts +21 -0
  142. package/src/tasks/wake-parent.ts +26 -0
  143. package/src/tasks/wiring.ts +22 -0
  144. package/src/testing/accounts.ts +2 -0
  145. package/src/testing/fake-acp-capture.ts +16 -0
  146. package/src/testing/fake-acp.ts +66 -0
  147. package/src/testing/fake-claude.ts +177 -0
  148. package/src/testing/fake-cli.ts +29 -0
  149. package/src/testing/fake-codex.ts +122 -0
  150. package/src/testing/manual-clock.ts +2 -0
  151. package/src/testing/watch.ts +7 -1
  152. package/src/title-file.ts +12 -0
  153. package/src/usage/accounts.ts +6 -0
  154. package/src/usage/aggregate.ts +19 -0
  155. package/src/usage/exchange.ts +12 -0
  156. package/src/usage/index-file.ts +27 -0
  157. package/src/usage/limits/monitor.ts +31 -3
  158. package/src/usage/limits/normalize.ts +30 -0
  159. package/src/usage/limits/probe.ts +17 -0
  160. package/src/usage/prices-snapshot.json +8 -0
  161. package/src/usage/pricing.ts +35 -0
  162. package/src/usage/projects.ts +8 -0
  163. package/src/usage/readers/claude.ts +12 -9
  164. package/src/usage/readers/codex.ts +40 -8
  165. package/src/usage/readers/grok.ts +17 -9
  166. package/src/usage/readers/host-log.ts +9 -9
  167. package/src/usage/record.ts +22 -0
  168. package/src/usage/roots.ts +6 -0
  169. package/src/usage/scanner.ts +61 -0
  170. package/src/usage/usage-service.ts +14 -0
  171. package/src/watch-seam.ts +8 -0
  172. package/src/windows-command.ts +12 -0
@@ -76,6 +76,7 @@ type BookmarkableItem = Extract<ChatItem, { kind: 'user' | 'assistant' }>;
76
76
 
77
77
  function withoutHidden(info: ChatInfo): ChatInfo {
78
78
  const { hidden: _hidden, ...rest } = info;
79
+
79
80
  return rest;
80
81
  }
81
82
 
@@ -88,6 +89,7 @@ function isBookmarkable(item: ChatItem): item is BookmarkableItem {
88
89
  function excerptOf(item: BookmarkableItem): string {
89
90
  const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
90
91
  const text = item.kind === 'assistant' ? readableAssistantText(item) : item.text;
92
+
91
93
  return (text.trim() === '' ? files : text).replace(/\s+/g, ' ').trim();
92
94
  }
93
95
 
@@ -176,16 +178,13 @@ export interface ChatCoreOptions {
176
178
  env?: Record<string, string | undefined>;
177
179
  // What the agent is told once, at the start of every process, when the host has one note for every chat.
178
180
  instructions?: string;
179
- // The CLIs to run, when they are not the ones the providers name; a test points these at fakes.
181
+ // The CLIs to run, when they are not the ones the providers name.
180
182
  command?: string[];
181
183
  codexCommand?: string[];
182
- // How a CLI is started; a test runs a fake in the same process instead of spawning one.
183
184
  spawn?: SpawnChatProcess;
184
185
  // How the host names itself to an app-server it opens for a question about a thread.
185
186
  codexClient?: CodexClientInfo;
186
- // Where the skill folders are looked for; a test points it at a temporary tree.
187
187
  skills?: SkillIndex;
188
- // Where the files people attach are written.
189
188
  attachments: AttachmentStore;
190
189
  bookmarks?: BookmarkStore;
191
190
  // Pages agents publish in a chat; their files go in the same attachment folders.
@@ -196,7 +195,7 @@ export interface ChatCoreOptions {
196
195
  claudeTitles?: { forSession(agentSessionId: string, projectsDir: string, cwd: string): Promise<string | null> };
197
196
  // A name for a Codex chat, asked of a one-shot CLI: its app-server names no thread on its own.
198
197
  nameChat?: (provider: AgentKind, input: ChatTitleInput) => Promise<string | null>;
199
- // Where a subagent's whole conversation is read and how its growth is noticed; a test hands in fakes.
198
+ // Where a subagent's whole conversation is read and how its growth is noticed.
200
199
  subagents?: Partial<Pick<SubagentReaderOptions, 'claudeProjectsDir' | 'seams' | 'now' | 'listOnce'>>;
201
200
  /*
202
201
  * Asked when a chat is loaded with a turn that could be resumed: true once the resume is owed,
@@ -291,28 +290,36 @@ export class ChatCore {
291
290
  this.onInterruptedRun = options.onInterruptedRun ?? null;
292
291
  this.limitResume = options.limitResume ?? null;
293
292
  this.hooks = options.hooks ?? {};
293
+
294
294
  this.commands = {
295
295
  ...(options.command ? { claude: options.command } : {}),
296
296
  ...(options.codexCommand ? { codex: options.codexCommand } : {})
297
297
  };
298
+
298
299
  this.spawn = options.spawn ?? null;
299
300
  this.codexClient = options.codexClient ?? null;
300
301
  this.env = definedEnv(options.env ?? process.env);
302
+
301
303
  // Only whoever reads the thread has anything to point a bookmark at or to place a visual in, so the lists go where the thread goes.
302
304
  this.bookmarks?.listen((chatId, bookmarks) => {
303
305
  this.listChanged(chatId);
306
+
304
307
  for (const clientId of this.attached.get(chatId) ?? []) {
305
308
  this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
306
309
  }
307
310
  });
311
+
308
312
  this.visuals?.listen((chatId, visuals) => {
309
313
  this.listChanged(chatId);
314
+
310
315
  for (const clientId of this.attached.get(chatId) ?? []) {
311
316
  this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
312
317
  }
313
318
  });
319
+
314
320
  this.claudeProjectsDir =
315
321
  options.subagents?.claudeProjectsDir ?? usageRoots(options.env ?? process.env).find((root) => root.provider === 'claude')?.path ?? '';
322
+
316
323
  this.subagents = new SubagentReader({
317
324
  ...options.subagents,
318
325
  claudeProjectsDir: this.claudeProjectsDir,
@@ -320,6 +327,7 @@ export class ChatCore {
320
327
  chatInfo: async (chatId) => (await this.readChat(chatId))?.info ?? null,
321
328
  chat: (chatId) => {
322
329
  const session = this.chats.get(chatId);
330
+
323
331
  return session
324
332
  ? {
325
333
  info: session.info,
@@ -351,15 +359,17 @@ export class ChatCore {
351
359
  if (isDefaultAccountOf('claude', info.account)) {
352
360
  return this.claudeProjectsDir;
353
361
  }
362
+
354
363
  const folder = this.accounts?.transcriptFolder('claude', info.account) ?? null;
364
+
355
365
  return folder === null ? '' : join(folder, 'projects');
356
366
  }
357
367
 
358
- /* Whether a conversation of one account of this CLI can go on under the other. */
359
368
  canContinue(kind: AgentKind, from: string | undefined, to: string | undefined): boolean {
360
369
  if ((from ?? kind) === (to ?? kind)) {
361
370
  return true;
362
371
  }
372
+
363
373
  return this.accounts?.canContinue(kind, from, to) ?? false;
364
374
  }
365
375
 
@@ -368,7 +378,6 @@ export class ChatCore {
368
378
  launchEnv(this.accounts, kind, account, this.env);
369
379
  }
370
380
 
371
- /* What a person calls an account of this CLI. */
372
381
  accountLabel(kind: AgentKind, account: string | undefined): string {
373
382
  return isDefaultAccountOf(kind, account) || account === undefined ? this.providers.get(kind).name : (this.accounts?.labelOf(account) ?? account);
374
383
  }
@@ -377,10 +386,13 @@ export class ChatCore {
377
386
  async readChat(chatId: string): Promise<{ info: ChatInfo; items: ChatItem[] } | null> {
378
387
  await this.loaded(chatId);
379
388
  const session = this.chats.get(chatId);
389
+
380
390
  if (session) {
381
391
  return session.thread.snapshot();
382
392
  }
393
+
383
394
  const stored = await this.store?.read(chatId);
395
+
384
396
  return stored ? { info: stored.info, items: stored.items } : null;
385
397
  }
386
398
 
@@ -389,6 +401,7 @@ export class ChatCore {
389
401
  const catalog = this.providers.get(provider).catalog;
390
402
  const preference = this.composerPreferences.for(provider);
391
403
  const normalized = catalog.normalize(selection ?? preference.selection);
404
+
392
405
  return {
393
406
  selection: normalized,
394
407
  ...(preference.runtimeMode ? { runtimeMode: preference.runtimeMode } : {}),
@@ -402,6 +415,7 @@ export class ChatCore {
402
415
  */
403
416
  observe(sink: AgentSink): () => void {
404
417
  this.observers.add(sink);
418
+
405
419
  return () => {
406
420
  this.observers.delete(sink);
407
421
  };
@@ -420,23 +434,30 @@ export class ChatCore {
420
434
  create(payload: ChatCreatePayload): Promise<ChatInfo> {
421
435
  // A create in flight has the chat in the map before its first prompt is sent, so a second caller waits for all of it.
422
436
  const inFlight = this.creating.get(payload.chatId);
437
+
423
438
  if (inFlight) {
424
439
  return inFlight;
425
440
  }
441
+
426
442
  const existing = this.chats.get(payload.chatId);
443
+
427
444
  if (existing) {
428
445
  return Promise.resolve(existing.info);
429
446
  }
447
+
430
448
  const created = this.createNow(payload).finally(() => this.creating.delete(payload.chatId));
431
449
  this.creating.set(payload.chatId, created);
450
+
432
451
  return created;
433
452
  }
434
453
 
435
454
  private async createNow(payload: ChatCreatePayload): Promise<ChatInfo> {
436
455
  const stored = await this.store?.read(payload.chatId);
456
+
437
457
  if (!stored && (await this.store?.unreadable(payload.chatId))) {
438
458
  throw new ChatError('chat-unreadable', `The record of chat ${payload.chatId} cannot be read by this version, so it is left as it is`);
439
459
  }
460
+
440
461
  // A thread on disk keeps its provider; the selection it stored only makes sense in that catalog.
441
462
  const kind = stored?.info.provider ?? payload.provider ?? 'claude';
442
463
  const provider = this.providers.get(kind);
@@ -448,13 +469,18 @@ export class ChatCore {
448
469
  // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
449
470
  // The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
450
471
  const requested = payload.account ?? this.composerPreferences.for(kind).account;
472
+
451
473
  if (!stored) {
452
474
  this.requireAccount(kind, requested);
453
475
  }
476
+
454
477
  const account = stored ? stored.info.account : storedAccount(kind, requested);
478
+
455
479
  const narrowed = (mode: RuntimeMode): RuntimeMode => (this.hooks.runtimeModeFor ? this.hooks.runtimeModeFor(payload.chatId, mode) : mode);
480
+
456
481
  // Asked again for a chat on disk, since the host may have listed it since.
457
482
  const hidden = this.hooks.hiddenFor?.(payload.chatId) ? { hidden: true } : {};
483
+
458
484
  const info: ChatInfo = stored?.info
459
485
  ? { ...withoutHidden(stored.info), runtimeMode: narrowed(stored.info.runtimeMode), ...hidden }
460
486
  : {
@@ -474,6 +500,7 @@ export class ChatCore {
474
500
  usage: { contextTokens: 0, contextWindow: catalog.contextWindowFor(selection), costUsd: 0, turns: 0 },
475
501
  createdAt: Date.now()
476
502
  };
503
+
477
504
  const resume: ResumeDecision = stored ? await this.owedResume(payload.chatId, stored) : { resumeTurnId: null, reason: null };
478
505
  const claudeTitles = this.claudeTitles;
479
506
  const nameChat = this.nameChat;
@@ -481,6 +508,7 @@ export class ChatCore {
481
508
  this.logs.set(payload.chatId, await this.openLog(payload.chatId, stored ?? null));
482
509
  const promptNotes = this.hooks.promptNotesFor?.(payload.chatId);
483
510
  const references = this.hooks.referencesFor?.(payload.chatId);
511
+
484
512
  const session = new ChatSession({
485
513
  info,
486
514
  ...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
@@ -494,6 +522,7 @@ export class ChatCore {
494
522
  env: (chatAccount) => {
495
523
  const env = definedEnv(launchEnv(this.accounts, kind, chatAccount, base));
496
524
  this.accounts?.launched?.(kind, chatAccount);
525
+
497
526
  return env;
498
527
  },
499
528
  saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
@@ -517,24 +546,31 @@ export class ChatCore {
517
546
  ...(kind === 'claude' ? { subagentSettlement: (toolUseId: string) => this.subagents.claudeSettlement(payload.chatId, toolUseId) } : {}),
518
547
  ...(this.limitResume ? { limitResume: limitHooks(this.limitResume, payload.chatId) } : {})
519
548
  });
549
+
520
550
  this.chats.set(session.id, session);
551
+
521
552
  if (stored) {
522
553
  session.settleStored(selection, resume);
523
554
  session.settleOwedResume();
524
555
  await session.settleOrphanedSubagents();
525
556
  }
557
+
526
558
  await this.hooks.opened?.(session, stored ?? null);
559
+
527
560
  return session.info;
528
561
  }
529
562
 
530
563
  async storedBackground(chatId: string): Promise<{ turnId: string | null; items: BackgroundWork[]; commands: string[]; goal: boolean }> {
531
564
  const record = await this.store?.read(chatId);
532
565
  const items = record?.items ?? [];
566
+
533
567
  const background = items.filter(
534
568
  (item): item is BackgroundWork =>
535
569
  isBackgroundWork(item) && (runsInBackground(item) || items.some((note) => note.id === `lost-background-${item.id}`))
536
570
  );
571
+
537
572
  const last = items.findLast((item) => item.kind === 'turn');
573
+
538
574
  return {
539
575
  turnId: background.at(-1)?.turnId ?? last?.id ?? null,
540
576
  items: background,
@@ -553,10 +589,12 @@ export class ChatCore {
553
589
 
554
590
  configure(payload: ChatConfigurePayload): ChatInfo {
555
591
  const session = this.require(payload.chatId);
592
+
556
593
  if (payload.account !== undefined) {
557
594
  this.refuseWhileRelocating(payload.chatId);
558
595
  this.switchAccount(session, payload.account);
559
596
  }
597
+
560
598
  return session.configure(payload);
561
599
  }
562
600
 
@@ -567,19 +605,24 @@ export class ChatCore {
567
605
  protected switchAccount(session: ChatSession, requested: string): void {
568
606
  const { provider: kind, account: from } = session.info;
569
607
  const to = storedAccount(kind, requested);
608
+
570
609
  if (to === from) {
571
610
  return;
572
611
  }
612
+
573
613
  // As named, so a switch to the default account is refused while it is turned off.
574
614
  this.requireAccount(kind, requested);
575
615
  const spoke = session.info.agentSessionId !== null || session.info.usage.turns > 0;
616
+
576
617
  if (spoke && !this.canContinue(kind, from, to)) {
577
618
  const label = this.accountLabel(kind, to);
619
+
578
620
  throw new AccountError(
579
621
  'account-incompatible',
580
622
  `This conversation is kept by the account '${this.accountLabel(kind, from)}', which '${label}' cannot read. Fork the chat to go on under '${label}'.`
581
623
  );
582
624
  }
625
+
583
626
  session.setAccount(to);
584
627
  }
585
628
 
@@ -590,24 +633,31 @@ export class ChatCore {
590
633
  attach(chatId: string, clientId: string, historyLimit?: number, since?: number): ChatAttachResult {
591
634
  const session = this.require(chatId);
592
635
  let clients = this.attached.get(chatId);
636
+
593
637
  if (!clients) {
594
638
  clients = new Set();
595
639
  this.attached.set(chatId, clients);
596
640
  }
641
+
597
642
  // The snapshot already holds the text of a delta that is waiting, so it goes out before this
598
643
  // client is on the list: the text is either in the snapshot or in the stream, never both.
599
644
  this.coalescers.get(chatId)?.flush();
600
645
  clients.add(clientId);
601
646
  const log = this.logs.get(chatId)!;
602
647
  const events = since === undefined ? null : log.after(since);
648
+
603
649
  if (events !== null) {
604
650
  return { info: session.info, items: [], events: [...events, ...session.uiPreviews], seq: log.seq };
605
651
  }
652
+
606
653
  if (historyLimit === undefined) {
607
654
  const snapshot = session.thread.snapshot();
655
+
608
656
  return { ...snapshot, info: session.info, items: session.previewItems(snapshot.items), seq: log.seq };
609
657
  }
658
+
610
659
  const page = session.thread.history(historyLimit);
660
+
611
661
  return {
612
662
  ...page,
613
663
  info: session.info,
@@ -627,25 +677,34 @@ export class ChatCore {
627
677
  async attachWithBookmarks(chatId: string, clientId: string, historyLimit?: number, since?: number): Promise<ChatAttachResult> {
628
678
  // A client that only watches a chat sends no create, so a chat on disk nobody loaded since the host started is loaded for it.
629
679
  await this.loaded(chatId);
680
+
630
681
  if (!this.chats.has(chatId) && (await this.store?.has(chatId)) === true) {
631
682
  await this.create({ chatId });
632
683
  }
684
+
633
685
  this.require(chatId);
686
+
634
687
  const unreadable = (what: string) => (e: unknown) => {
635
688
  console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
689
+
636
690
  return undefined;
637
691
  };
692
+
638
693
  let bookmarks: ChatBookmark[] | undefined;
639
694
  let visuals: ChatVisual[] | undefined;
640
695
  let changes: number | undefined;
696
+
641
697
  do {
642
698
  changes = this.listChanges.get(chatId);
699
+
643
700
  [bookmarks, visuals] = await Promise.all([
644
701
  this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
645
702
  this.visuals?.list(chatId).catch(unreadable('visuals'))
646
703
  ]);
647
704
  } while (this.listChanges.get(chatId) !== changes);
705
+
648
706
  const result = this.attach(chatId, clientId, historyLimit, since);
707
+
649
708
  return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
650
709
  }
651
710
 
@@ -656,19 +715,23 @@ export class ChatCore {
656
715
  /* Marks a message of the chat's own thread; a message that already has one keeps it. */
657
716
  addBookmark(chatId: string, itemId: string, name?: string): Promise<ChatBookmark[]> {
658
717
  const item = this.require(chatId).thread.get(itemId);
718
+
659
719
  if (!item || !isBookmarkable(item)) {
660
720
  throw new ChatError('item-not-found', `Chat ${chatId} has no message ${itemId} to bookmark`);
661
721
  }
722
+
662
723
  return this.requireBookmarks().add(chatId, { itemId, excerpt: excerptOf(item), ...(name === undefined ? {} : { name }) }, Date.now());
663
724
  }
664
725
 
665
726
  renameBookmark(chatId: string, itemId: string, name: string): Promise<ChatBookmark[]> {
666
727
  this.require(chatId);
728
+
667
729
  return this.requireBookmarks().rename(chatId, itemId, name);
668
730
  }
669
731
 
670
732
  removeBookmark(chatId: string, itemId: string): Promise<ChatBookmark[]> {
671
733
  this.require(chatId);
734
+
672
735
  return this.requireBookmarks().remove(chatId, itemId);
673
736
  }
674
737
 
@@ -676,6 +739,7 @@ export class ChatCore {
676
739
  if (!this.bookmarks) {
677
740
  throw new ChatError('chat-unsupported', 'This host keeps no bookmarks');
678
741
  }
742
+
679
743
  return this.bookmarks;
680
744
  }
681
745
 
@@ -686,10 +750,13 @@ export class ChatCore {
686
750
  async publishVisual(chatId: string, input: VisualInput): Promise<ChatVisual> {
687
751
  const visuals = this.requireVisuals();
688
752
  await this.loaded(chatId);
753
+
689
754
  if (!(await this.hasStored(chatId))) {
690
755
  throw new ChatError('chat-not-found', `No chat ${chatId}`);
691
756
  }
757
+
692
758
  const turnId = input.turnId ?? this.chats.get(chatId)?.info.activeTurnId ?? undefined;
759
+
693
760
  return visuals.publish(chatId, turnId === undefined ? input : { ...input, turnId });
694
761
  }
695
762
 
@@ -700,6 +767,7 @@ export class ChatCore {
700
767
 
701
768
  removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]> {
702
769
  this.require(chatId);
770
+
703
771
  return this.requireVisuals().remove(chatId, visualId);
704
772
  }
705
773
 
@@ -707,6 +775,7 @@ export class ChatCore {
707
775
  if (!this.visuals) {
708
776
  throw new ChatError('chat-unsupported', 'This host keeps no visuals');
709
777
  }
778
+
710
779
  return this.visuals;
711
780
  }
712
781
 
@@ -714,6 +783,7 @@ export class ChatCore {
714
783
  history(chatId: string, cursor?: string, limit?: number): ChatHistoryResult {
715
784
  const session = this.require(chatId);
716
785
  this.coalescers.get(chatId)?.flush();
786
+
717
787
  return session.thread.history(limit, cursor);
718
788
  }
719
789
 
@@ -729,6 +799,7 @@ export class ChatCore {
729
799
  clients.delete(clientId);
730
800
  }
731
801
  }
802
+
732
803
  this.subagents.releaseClient(clientId);
733
804
  }
734
805
 
@@ -741,11 +812,14 @@ export class ChatCore {
741
812
  if (payload.watch === false) {
742
813
  this.subagents.release(clientId, payload.chatId, payload.toolUseId);
743
814
  }
815
+
744
816
  const result = await this.subagents.read(payload.chatId, payload.toolUseId, payload.cursor, payload.limit);
745
817
  await this.chats.get(payload.chatId)?.recheckOrphan(payload.toolUseId);
818
+
746
819
  if (payload.watch === true) {
747
820
  this.subagents.hold(clientId, payload.chatId, payload.toolUseId);
748
821
  }
822
+
749
823
  return result;
750
824
  }
751
825
 
@@ -758,15 +832,19 @@ export class ChatCore {
758
832
  await this.loaded(chatId);
759
833
  const session = this.require(chatId);
760
834
  const row = session.thread.find('subagent', (item) => item.toolUseId === toolUseId);
835
+
761
836
  if (!row) {
762
837
  throw new ChatError('subagent-not-found', 'This chat has no such sub-agent');
763
838
  }
839
+
764
840
  if (row.status !== 'running') {
765
841
  return;
766
842
  }
843
+
767
844
  if (row.origin === HOST_TASK_ORIGIN) {
768
845
  throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
769
846
  }
847
+
770
848
  session.stopSubagent(toolUseId);
771
849
  }
772
850
 
@@ -800,30 +878,40 @@ export class ChatCore {
800
878
  if (!this.store) {
801
879
  throw new ChatError('chat-unsupported', 'This host keeps no chat records');
802
880
  }
881
+
803
882
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
804
883
  throw new ChatError('chat-busy', `Chat ${chatId} is already loaded`);
805
884
  }
885
+
806
886
  const copies = new Map<string, Promise<ChatAttachment>>();
887
+
807
888
  const copy = (attachment: ChatAttachment): Promise<ChatAttachment> => {
808
889
  let pending = copies.get(attachment.id);
890
+
809
891
  if (!pending) {
810
892
  pending = this.attachments.copy(chatId, attachment);
811
893
  copies.set(attachment.id, pending);
812
894
  }
895
+
813
896
  return pending;
814
897
  };
898
+
815
899
  const copied = await Promise.all(
816
900
  items.map(async (item): Promise<ChatItem> => {
817
901
  if (item.kind === 'user' && item.attachments?.length) {
818
902
  return { ...item, attachments: await Promise.all(item.attachments.map(copy)) };
819
903
  }
904
+
820
905
  const generated = generatedImageAttachment(item);
906
+
821
907
  if (item.kind === 'tool' && generated) {
822
908
  return { ...item, input: { ...(item.input as Record<string, unknown>), attachment: await copy(generated) } };
823
909
  }
910
+
824
911
  return item;
825
912
  })
826
913
  );
914
+
827
915
  await this.store.write(chatId, info, copied, { seq: 0, resetSeq: 0 }, preambles);
828
916
  }
829
917
 
@@ -835,34 +923,42 @@ export class ChatCore {
835
923
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
836
924
  return false;
837
925
  }
926
+
838
927
  await Promise.all([
839
928
  this.store?.delete(chatId),
840
929
  this.attachments.removeAll(chatId),
841
930
  this.bookmarks?.removeChat(chatId),
842
931
  this.visuals?.removeChat(chatId)
843
932
  ]);
933
+
844
934
  return true;
845
935
  }
846
936
 
847
937
  /* The turn a chat took last, read off the record without loading a chat nobody has; null before its first. */
848
938
  async lastTurn(chatId: string): Promise<ChatTurnItem | null> {
849
939
  const session = this.chats.get(chatId);
940
+
850
941
  if (session) {
851
942
  return session.thread.find('turn', () => true) ?? null;
852
943
  }
944
+
853
945
  const stored = await this.store?.read(chatId);
946
+
854
947
  return stored?.items.findLast((item): item is ChatTurnItem => item.kind === 'turn') ?? null;
855
948
  }
856
949
 
857
950
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
858
951
  async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void> {
859
952
  await this.loaded(chatId);
953
+
860
954
  if (!(await this.hasStored(chatId))) {
861
955
  return;
862
956
  }
957
+
863
958
  if (!this.chats.has(chatId)) {
864
959
  await this.create({ chatId });
865
960
  }
961
+
866
962
  this.chats.get(chatId)?.addNote(level, text);
867
963
  }
868
964
 
@@ -872,12 +968,15 @@ export class ChatCore {
872
968
  */
873
969
  async deliverNote(chatId: string, delivery: { noteId: string; note: string; from?: string; preamble: string }): Promise<boolean> {
874
970
  await this.loaded(chatId);
971
+
875
972
  if (!(await this.hasStored(chatId))) {
876
973
  return false;
877
974
  }
975
+
878
976
  if (!this.chats.has(chatId)) {
879
977
  await this.create({ chatId });
880
978
  }
979
+
881
980
  return this.require(chatId).deliverNote(delivery);
882
981
  }
883
982
 
@@ -888,10 +987,13 @@ export class ChatCore {
888
987
  async send(chatId: string, text: string, extras: ChatSendExtras = {}, uploads: ChatAttachmentUpload[] = []): Promise<{ queued: boolean; turnId: string }> {
889
988
  const session = this.require(chatId);
890
989
  const checked = ChatAttachmentUploadsSchema.safeParse(uploads);
990
+
891
991
  if (!checked.success) {
892
992
  throw new ChatError('invalid-attachments', checked.error.issues[0]!.message);
893
993
  }
994
+
894
995
  const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
996
+
895
997
  return session.sendInput(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
896
998
  }
897
999
 
@@ -899,6 +1001,7 @@ export class ChatCore {
899
1001
  if (!this.attached.get(payload.chatId)?.has(clientId) || !this.uiQueries) {
900
1002
  throw new ChatError('refused-query', 'Attach to the chat before resolving its UI links.');
901
1003
  }
1004
+
902
1005
  return this.uiQueries.link(this.require(payload.chatId), payload);
903
1006
  }
904
1007
 
@@ -906,9 +1009,11 @@ export class ChatCore {
906
1009
  if (!this.attached.get(payload.chatId)?.has(clientId)) {
907
1010
  throw new ChatError('refused-query', 'Attach to the chat before reading its UI queries.');
908
1011
  }
1012
+
909
1013
  if (!this.uiQueries) {
910
1014
  throw new ChatError('refused-query', 'This host does not provide UI query sources.');
911
1015
  }
1016
+
912
1017
  return this.uiQueries.query(this.require(payload.chatId), payload);
913
1018
  }
914
1019
 
@@ -916,40 +1021,50 @@ export class ChatCore {
916
1021
  if (!this.attached.get(payload.chatId)?.has(clientId)) {
917
1022
  throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
918
1023
  }
1024
+
919
1025
  const session = this.require(payload.chatId);
920
1026
  const queries = await this.uiQueries?.choiceValues(session, payload);
921
1027
  const result = session.choose(payload, queries);
922
1028
  await this.persisted(payload.chatId);
1029
+
923
1030
  return result;
924
1031
  }
925
1032
 
926
1033
  /* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
927
1034
  attachment(chatId: string, id: string): ChatAttachment | null {
928
1035
  const session = this.chats.get(chatId);
1036
+
929
1037
  if (!session) {
930
1038
  return null;
931
1039
  }
1040
+
932
1041
  for (const item of session.thread.list()) {
933
1042
  const generated = generatedImageAttachment(item);
934
1043
  const found = item.kind === 'user' ? item.attachments?.find((attachment) => attachment.id === id) : generated?.id === id ? generated : null;
1044
+
935
1045
  if (found) {
936
1046
  return found;
937
1047
  }
938
1048
  }
1049
+
939
1050
  for (const message of session.info.queue ?? []) {
940
1051
  const found = message.attachments?.find((attachment) => attachment.id === id);
1052
+
941
1053
  if (found) {
942
1054
  return found;
943
1055
  }
944
1056
  }
1057
+
945
1058
  return this.visuals?.attachment(chatId, id) ?? null;
946
1059
  }
947
1060
 
948
1061
  unqueue(chatId: string, messageId: string): ChatQueuedMessage {
949
1062
  const message = this.require(chatId).unqueue(messageId);
1063
+
950
1064
  if (!message) {
951
1065
  throw new ChatError('request-not-found', `No queued message ${messageId} in chat ${chatId}`);
952
1066
  }
1067
+
953
1068
  return message;
954
1069
  }
955
1070
 
@@ -962,6 +1077,7 @@ export class ChatCore {
962
1077
  /* What the chat's CLI would run as a skill. */
963
1078
  skills(chatId: string): Promise<ChatSkill[]> {
964
1079
  const session = this.require(chatId);
1080
+
965
1081
  return session.skills(() => this.skillIndex.list(session.info.provider, session.info.cwd));
966
1082
  }
967
1083
 
@@ -982,9 +1098,11 @@ export class ChatCore {
982
1098
  compact(chatId: string): void {
983
1099
  const session = this.require(chatId);
984
1100
  this.refuseWhileRelocating(chatId);
1101
+
985
1102
  if (session.busy) {
986
1103
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
987
1104
  }
1105
+
988
1106
  session.compact();
989
1107
  }
990
1108
 
@@ -992,15 +1110,18 @@ export class ChatCore {
992
1110
  async clear(chatId: string, force = false): Promise<void> {
993
1111
  const session = this.require(chatId);
994
1112
  this.refuseWhileRelocating(chatId);
1113
+
995
1114
  if (session.busy && !force) {
996
1115
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
997
1116
  }
1117
+
998
1118
  await this.hooks.clearing?.(chatId);
999
1119
  const ended = session.clear(force);
1000
1120
  // Before the record is written, so what it takes along from the record is gone from the write as well.
1001
1121
  const cleared = this.hooks.cleared?.(chatId);
1002
1122
  // A debounced write still waiting holds the old thread and must not land after the empty one.
1003
1123
  this.cancelWaiting(chatId);
1124
+
1004
1125
  // Folded right away: every line before the reset describes a thread that is gone.
1005
1126
  await Promise.all([
1006
1127
  this.persistNow(chatId, true),
@@ -1015,6 +1136,7 @@ export class ChatCore {
1015
1136
  cancel(chatId: string, subagents = false): void {
1016
1137
  const session = this.require(chatId);
1017
1138
  session.cancel();
1139
+
1018
1140
  if (subagents) {
1019
1141
  session.markSubagentsStopped();
1020
1142
  }
@@ -1047,6 +1169,7 @@ export class ChatCore {
1047
1169
  holdForPerson(chatId: string, key: string): boolean {
1048
1170
  const session = this.chats.get(chatId);
1049
1171
  session?.holdForPerson(key);
1172
+
1050
1173
  return session !== undefined;
1051
1174
  }
1052
1175
 
@@ -1062,11 +1185,14 @@ export class ChatCore {
1062
1185
  const session = this.require(chatId);
1063
1186
  this.refuseUnlessRelocatable(session);
1064
1187
  const running = this.relocateNow(session, cwd, during);
1188
+
1065
1189
  const tracked = running.then(
1066
1190
  () => undefined,
1067
1191
  () => undefined
1068
1192
  );
1193
+
1069
1194
  this.relocations.set(chatId, tracked);
1195
+
1070
1196
  try {
1071
1197
  return await running;
1072
1198
  } finally {
@@ -1084,12 +1210,15 @@ export class ChatCore {
1084
1210
  private async relocateNow(session: ChatSession, cwd: string, during: (() => Promise<void>) | undefined): Promise<ChatInfo> {
1085
1211
  const chatId = session.id;
1086
1212
  await this.hooks.admit?.(chatId, cwd);
1213
+
1087
1214
  // A message sent while the folder was asked about may have opened a turn.
1088
1215
  if (!session.relocatable) {
1089
1216
  throw this.busyRelocating(chatId);
1090
1217
  }
1218
+
1091
1219
  const previous = session.info.cwd;
1092
1220
  session.beginRelocate();
1221
+
1093
1222
  try {
1094
1223
  await session.dropProcess();
1095
1224
  await during?.();
@@ -1097,11 +1226,14 @@ export class ChatCore {
1097
1226
  await this.persistNow(chatId, true);
1098
1227
  } catch (e) {
1099
1228
  session.setCwd(previous);
1229
+
1100
1230
  throw e;
1101
1231
  } finally {
1102
1232
  session.endRelocate();
1103
1233
  }
1234
+
1104
1235
  this.skillIndex.forget(session.info.provider, previous);
1236
+
1105
1237
  return session.info;
1106
1238
  }
1107
1239
 
@@ -1125,9 +1257,11 @@ export class ChatCore {
1125
1257
  async kill(chatId: string): Promise<void> {
1126
1258
  await this.loaded(chatId);
1127
1259
  const moving = this.relocations.get(chatId);
1260
+
1128
1261
  if (moving) {
1129
1262
  await moving;
1130
1263
  }
1264
+
1131
1265
  const session = this.require(chatId);
1132
1266
  // A write already out would put the record back after the delete.
1133
1267
  const writing = this.writes.get(chatId);
@@ -1151,6 +1285,7 @@ export class ChatCore {
1151
1285
  this.hooks.forgotten?.(chatId);
1152
1286
  await writing;
1153
1287
  await ended;
1288
+
1154
1289
  await Promise.all([
1155
1290
  this.store?.delete(chatId),
1156
1291
  this.attachments.removeAll(chatId),
@@ -1172,12 +1307,15 @@ export class ChatCore {
1172
1307
  /* The chats with a CLI process, for a process monitor. */
1173
1308
  processTargets(): { id: string; pid: number; provider: AgentKind; status: ChatInfo['status']; updatedAt: number }[] {
1174
1309
  const targets = [];
1310
+
1175
1311
  for (const [id, session] of this.chats) {
1176
1312
  const pid = session.pid;
1313
+
1177
1314
  if (pid !== null) {
1178
1315
  targets.push({ id, pid, provider: session.info.provider, status: session.info.status, updatedAt: this.activity.get(id) ?? 0 });
1179
1316
  }
1180
1317
  }
1318
+
1181
1319
  return targets;
1182
1320
  }
1183
1321
 
@@ -1196,12 +1334,15 @@ export class ChatCore {
1196
1334
  */
1197
1335
  async resumeRun(chatId: string, turnId: string, attempt: number): Promise<void> {
1198
1336
  await this.loaded(chatId);
1337
+
1199
1338
  if (!this.chats.has(chatId)) {
1200
1339
  if (!(await this.store?.has(chatId))) {
1201
1340
  return;
1202
1341
  }
1342
+
1203
1343
  await this.create({ chatId });
1204
1344
  }
1345
+
1205
1346
  await this.chats.get(chatId)?.resume(turnId, attempt, (await this.hooks.resumeWords?.(chatId)) ?? DEFAULT_RESUME_WORDS);
1206
1347
  }
1207
1348
 
@@ -1211,22 +1352,30 @@ export class ChatCore {
1211
1352
  */
1212
1353
  async takeUpAfterLimit(chatId: string, turnId: string): Promise<OutboxOutcome> {
1213
1354
  await this.loaded(chatId);
1355
+
1214
1356
  if (!this.chats.has(chatId)) {
1215
1357
  if (!(await this.store?.has(chatId))) {
1216
1358
  return;
1217
1359
  }
1360
+
1218
1361
  await this.create({ chatId });
1219
1362
  }
1363
+
1220
1364
  const session = this.chats.get(chatId);
1365
+
1221
1366
  if (!session) {
1222
1367
  return;
1223
1368
  }
1369
+
1224
1370
  const turn = session.thread.get(turnId);
1225
1371
  const ended = this.hooks.endedAt?.(chatId) ?? null;
1372
+
1226
1373
  if (turn !== undefined && ended !== null && ended >= turn.createdAt) {
1227
1374
  session.lapseResume();
1375
+
1228
1376
  return;
1229
1377
  }
1378
+
1230
1379
  return session.takeUpAfterLimit(turnId) === 'wait' ? 'wait' : undefined;
1231
1380
  }
1232
1381
 
@@ -1251,16 +1400,20 @@ export class ChatCore {
1251
1400
  if (!this.store) {
1252
1401
  return;
1253
1402
  }
1403
+
1254
1404
  for (const chatId of await this.store.list()) {
1255
1405
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
1256
1406
  continue;
1257
1407
  }
1408
+
1258
1409
  // Parsing the whole thread of every settled chat was most of the cost of a start.
1259
1410
  if (await this.store.settled(chatId).catch(() => false)) {
1260
1411
  continue;
1261
1412
  }
1413
+
1262
1414
  const record = await this.store.read(chatId).catch(() => null);
1263
1415
  const unowed = record?.info.resumeAt !== undefined && this.limitResume?.owed(chatId) === false;
1416
+
1264
1417
  if (record !== null && (interruptedTurn(record) !== null || unowed)) {
1265
1418
  await this.create({ chatId }).catch((e: unknown) => console.error(`Loading chat ${chatId} after a restart failed:`, errorText(e)));
1266
1419
  }
@@ -1275,13 +1428,16 @@ export class ChatCore {
1275
1428
  for (const session of this.chats.values()) {
1276
1429
  session.freeze();
1277
1430
  }
1431
+
1278
1432
  for (const chatId of this.chats.keys()) {
1279
1433
  this.cancelWaiting(chatId);
1280
1434
  }
1435
+
1281
1436
  // A thread that cannot be written still ends its CLI, whose process group would otherwise outlive the host.
1282
1437
  const written = await Promise.allSettled([...this.chats.keys()].map((chatId) => this.persistNow(chatId, true)));
1283
1438
  await Promise.allSettled([...this.chats.values()].map((session) => session.dispose()));
1284
1439
  const failed = written.find((result) => result.status === 'rejected');
1440
+
1285
1441
  if (failed) {
1286
1442
  throw failed.reason;
1287
1443
  }
@@ -1295,14 +1451,17 @@ export class ChatCore {
1295
1451
  if (!this.store) {
1296
1452
  return;
1297
1453
  }
1454
+
1298
1455
  for (const session of this.chats.values()) {
1299
1456
  session.freeze();
1300
1457
  }
1458
+
1301
1459
  for (const [chatId, session] of this.chats) {
1302
1460
  this.cancelWaiting(chatId);
1303
1461
  this.coalescers.get(chatId)?.flush();
1304
1462
  const { info, items } = session.thread.snapshot();
1305
1463
  const log = this.logs.get(chatId);
1464
+
1306
1465
  // Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
1307
1466
  try {
1308
1467
  this.store.writeSync(
@@ -1332,9 +1491,11 @@ export class ChatCore {
1332
1491
 
1333
1492
  protected require(chatId: string): ChatSession {
1334
1493
  const session = this.chats.get(chatId);
1494
+
1335
1495
  if (!session) {
1336
1496
  throw new ChatError('chat-not-found', `No chat ${chatId}`);
1337
1497
  }
1498
+
1338
1499
  return session;
1339
1500
  }
1340
1501
 
@@ -1347,15 +1508,19 @@ export class ChatCore {
1347
1508
  private persistSoon(chatId: string): void {
1348
1509
  if ((this.sizes.get(chatId) ?? 0) < DEBOUNCE_ABOVE_BYTES) {
1349
1510
  this.persist(chatId);
1511
+
1350
1512
  return;
1351
1513
  }
1514
+
1352
1515
  if (this.waiting.has(chatId)) {
1353
1516
  return;
1354
1517
  }
1518
+
1355
1519
  const timer = setTimeout(() => {
1356
1520
  this.waiting.delete(chatId);
1357
1521
  this.persist(chatId);
1358
1522
  }, DEBOUNCE_MS);
1523
+
1359
1524
  // A pending write must never be the reason the process stays up.
1360
1525
  timer.unref?.();
1361
1526
  this.waiting.set(chatId, timer);
@@ -1363,6 +1528,7 @@ export class ChatCore {
1363
1528
 
1364
1529
  private cancelWaiting(chatId: string): void {
1365
1530
  const timer = this.waiting.get(chatId);
1531
+
1366
1532
  if (timer) {
1367
1533
  clearTimeout(timer);
1368
1534
  this.waiting.delete(chatId);
@@ -1377,33 +1543,40 @@ export class ChatCore {
1377
1543
  const next = (this.writes.get(chatId) ?? Promise.resolve()).then(async () => {
1378
1544
  const session = this.chats.get(chatId);
1379
1545
  const log = this.logs.get(chatId);
1546
+
1380
1547
  if (!session || !this.store || !log) {
1381
1548
  return;
1382
1549
  }
1550
+
1383
1551
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
1384
1552
  this.coalescers.get(chatId)?.flush();
1385
1553
  const extras = this.hooks.recordExtras?.(chatId) ?? {};
1386
1554
  const uiAccess = session.uiAccessRecord;
1387
1555
  const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
1388
1556
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
1557
+
1389
1558
  // The log already holds every change to the thread, so the whole record is rewritten only to fold
1390
1559
  // the log, on a chat's first write, or for what no event carries.
1391
1560
  if (!folds && this.sizes.has(chatId) && this.unlogged.get(chatId) === unlogged) {
1392
1561
  return;
1393
1562
  }
1563
+
1394
1564
  const { info, items } = session.thread.snapshot();
1395
1565
  const at = { seq: log.seq, resetSeq: log.resetSeq };
1396
1566
  this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
1397
1567
  this.unlogged.set(chatId, unlogged);
1568
+
1398
1569
  // A chat killed while the write was out has no log left to fold.
1399
1570
  if (this.logs.get(chatId) === log && folds) {
1400
1571
  log.compact(at.seq);
1401
1572
  }
1402
1573
  });
1574
+
1403
1575
  this.writes.set(
1404
1576
  chatId,
1405
1577
  next.catch(() => undefined)
1406
1578
  );
1579
+
1407
1580
  return next;
1408
1581
  }
1409
1582
 
@@ -1411,17 +1584,22 @@ export class ChatCore {
1411
1584
  if (event.type === 'delta' && event.ui !== undefined) {
1412
1585
  // Previews follow all pending text but have no log position or observer side effects.
1413
1586
  this.coalescers.get(chatId)?.flush();
1587
+
1414
1588
  for (const clientId of this.attached.get(chatId) ?? []) {
1415
1589
  this.sinks.to(clientId, { event: 'chat.event', payload: { chatId, event } });
1416
1590
  }
1591
+
1417
1592
  return;
1418
1593
  }
1594
+
1419
1595
  this.activity.set(chatId, Date.now());
1420
1596
  let coalescer = this.coalescers.get(chatId);
1597
+
1421
1598
  if (!coalescer) {
1422
1599
  coalescer = new DeltaCoalescer((coalesced) => this.broadcast(chatId, coalesced));
1423
1600
  this.coalescers.set(chatId, coalescer);
1424
1601
  }
1602
+
1425
1603
  coalescer.push(event);
1426
1604
  }
1427
1605
 
@@ -1429,18 +1607,23 @@ export class ChatCore {
1429
1607
  private broadcast(chatId: string, event: ChatEvent): void {
1430
1608
  const seq = this.logs.get(chatId)?.append(event, Date.now());
1431
1609
  const payload = seq === undefined ? { chatId, event } : { chatId, event, seq };
1610
+
1432
1611
  for (const sink of this.observers) {
1433
1612
  sink({ event: 'chat.event', payload });
1434
1613
  }
1614
+
1435
1615
  this.hooks.broadcasted?.(chatId, event);
1616
+
1436
1617
  for (const clientId of this.attached.get(chatId) ?? []) {
1437
1618
  this.sinks.to(clientId, { event: 'chat.event', payload });
1438
1619
  }
1620
+
1439
1621
  // After the thread, so a client reading it has already had this info and the status is a no-op there.
1440
1622
  if (event.type === 'info' || event.type === 'reset') {
1441
1623
  this.announceStatus(chatId, event.info);
1442
1624
  } else if (event.type === 'item' && (event.item.kind === 'approval' || event.item.kind === 'question')) {
1443
1625
  const session = this.chats.get(chatId);
1626
+
1444
1627
  if (session) {
1445
1628
  this.announceStatus(chatId, session.info);
1446
1629
  }
@@ -1465,9 +1648,11 @@ export class ChatCore {
1465
1648
  info.requests?.map((request) => request.requestId) ?? [],
1466
1649
  info.goal === undefined ? null : [info.goal.status, info.goal.objective]
1467
1650
  ]);
1651
+
1468
1652
  if (this.announced.get(chatId) === said) {
1469
1653
  return;
1470
1654
  }
1655
+
1471
1656
  this.announced.set(chatId, said);
1472
1657
  this.sinks.emit({ event: 'chat.status', payload: { chatId, info } });
1473
1658
  }
@@ -1481,42 +1666,57 @@ export class ChatCore {
1481
1666
  if (!this.store) {
1482
1667
  return new ChatLog(null);
1483
1668
  }
1669
+
1484
1670
  const path = this.store.logPath(chatId);
1671
+
1485
1672
  if (stored) {
1486
1673
  return new ChatLog(path, stored);
1487
1674
  }
1675
+
1488
1676
  const left = await this.store.discardLog(chatId);
1677
+
1489
1678
  return new ChatLog(path, { seq: left, resetSeq: left === 0 ? 0 : left + 1, lines: [] });
1490
1679
  }
1491
1680
 
1492
1681
  /* Every reason says why in the words of the note the turn ends with, so a person can tell a skip from a failure. */
1493
1682
  private async owedResume(chatId: string, stored: ChatRecord): Promise<ResumeDecision> {
1494
1683
  const turn = interruptedTurn(stored);
1684
+
1495
1685
  const skip = (reason: string): ResumeDecision => ({ resumeTurnId: null, reason });
1686
+
1496
1687
  if (turn === null) {
1497
1688
  return { resumeTurnId: null, reason: null };
1498
1689
  }
1690
+
1499
1691
  if (turn.deliveryPending) {
1500
1692
  return skip('the wake prompt was not acknowledged; its result waits for your next message');
1501
1693
  }
1694
+
1502
1695
  if (!this.onInterruptedRun) {
1503
1696
  return skip('this machine does not resume turns');
1504
1697
  }
1698
+
1505
1699
  if (stored.info.agentSessionId === null) {
1506
1700
  return skip('the agent had not started a session to resume yet');
1507
1701
  }
1702
+
1508
1703
  const ended = this.hooks.endedAt?.(chatId) ?? null;
1704
+
1509
1705
  if (ended !== null && ended >= turn.createdAt) {
1510
1706
  return skip('the agent that opened it was stopped');
1511
1707
  }
1708
+
1512
1709
  if ((turn.attempt ?? 1) >= MAX_ATTEMPTS && !turn.resumePending) {
1513
1710
  return skip('it was already resumed after an earlier restart');
1514
1711
  }
1712
+
1515
1713
  try {
1516
1714
  const owed = await this.onInterruptedRun({ chatId, turnId: turn.id, attempt: (turn.attempt ?? 1) + (turn.resumePending ? 0 : 1) });
1715
+
1517
1716
  return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.hooks.unownedReason?.(chatId) ?? 'nothing holds this chat any more');
1518
1717
  } catch (e) {
1519
1718
  console.error(`Owing a resume for chat ${chatId} failed:`, errorText(e));
1719
+
1520
1720
  return skip(errorText(e));
1521
1721
  }
1522
1722
  }
@@ -1537,5 +1737,6 @@ function limitHooks(hooks: NonNullable<ChatCoreOptions['limitResume']>, chatId:
1537
1737
  function interruptedTurn(record: { info: ChatInfo; items: ChatItem[] }): Extract<ChatItem, { kind: 'turn' }> | null {
1538
1738
  const turnId = record.info.activeTurnId;
1539
1739
  const turn = turnId === null ? undefined : record.items.find((item) => item.id === turnId);
1740
+
1540
1741
  return turn?.kind === 'turn' && turn.state === 'running' ? turn : null;
1541
1742
  }