@adecore/agents 0.38.0 → 0.40.0

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 (170) 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.d.ts +1 -0
  5. package/dist/chat/chat-core.js +9 -2
  6. package/dist/chat/chat-process.d.ts +1 -0
  7. package/dist/chat/chat-process.js +1 -1
  8. package/dist/chat/chat-session.d.ts +2 -0
  9. package/dist/chat/chat-session.js +9 -10
  10. package/dist/chat/claude-protocol.js +1 -6
  11. package/dist/chat/claude-transcript.js +1 -3
  12. package/dist/chat/codex-protocol.js +1 -6
  13. package/dist/chat/projector.js +1 -3
  14. package/dist/chat/request-summary.js +1 -3
  15. package/dist/coded-error.js +1 -5
  16. package/dist/context/argv.js +1 -2
  17. package/dist/context/verb.js +1 -3
  18. package/dist/error-text.d.ts +1 -0
  19. package/dist/error-text.js +4 -0
  20. package/dist/fields.d.ts +3 -0
  21. package/dist/fields.js +12 -0
  22. package/dist/fs.d.ts +1 -0
  23. package/dist/fs.js +8 -4
  24. package/dist/lineage.js +11 -5
  25. package/dist/messages/notice-store.js +3 -4
  26. package/dist/providers/accounts/acp-account.js +3 -20
  27. package/dist/providers/accounts/service.d.ts +1 -2
  28. package/dist/providers/accounts/service.js +5 -9
  29. package/dist/providers/accounts/status.js +1 -3
  30. package/dist/providers/acp-agent-state.js +1 -3
  31. package/dist/providers/claude.js +0 -1
  32. package/dist/providers/codex.js +0 -1
  33. package/dist/providers/detect.js +1 -1
  34. package/dist/providers/shell-commands.js +1 -2
  35. package/dist/run-process.js +1 -3
  36. package/dist/skills.d.ts +1 -1
  37. package/dist/skills.js +5 -8
  38. package/dist/tasks/background-limit.js +3 -2
  39. package/dist/tasks/task-coordinator.js +11 -17
  40. package/dist/testing/watch.js +0 -1
  41. package/dist/usage/limits/monitor.js +1 -2
  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 +218 -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 +414 -13
  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-rules.ts +3 -0
  126. package/src/providers/codex.ts +2 -1
  127. package/src/providers/detect.ts +6 -1
  128. package/src/providers/provider.ts +1 -1
  129. package/src/providers/registry.ts +18 -2
  130. package/src/providers/shell-commands.ts +37 -2
  131. package/src/record-directory.ts +19 -0
  132. package/src/run-process.ts +24 -3
  133. package/src/serializer.ts +3 -0
  134. package/src/skills.ts +73 -10
  135. package/src/tasks/background-limit.ts +9 -2
  136. package/src/tasks/end-children.ts +9 -0
  137. package/src/tasks/give-task.ts +8 -0
  138. package/src/tasks/task-coordinator.ts +81 -17
  139. package/src/tasks/task-store.ts +28 -0
  140. package/src/tasks/waiting-child.ts +21 -0
  141. package/src/tasks/wake-parent.ts +26 -0
  142. package/src/tasks/wiring.ts +22 -0
  143. package/src/testing/accounts.ts +2 -0
  144. package/src/testing/fake-acp-capture.ts +16 -0
  145. package/src/testing/fake-acp.ts +66 -0
  146. package/src/testing/fake-claude.ts +177 -0
  147. package/src/testing/fake-cli.ts +29 -0
  148. package/src/testing/fake-codex.ts +122 -0
  149. package/src/testing/manual-clock.ts +2 -0
  150. package/src/testing/watch.ts +7 -1
  151. package/src/title-file.ts +12 -0
  152. package/src/usage/accounts.ts +6 -0
  153. package/src/usage/aggregate.ts +19 -0
  154. package/src/usage/exchange.ts +12 -0
  155. package/src/usage/index-file.ts +27 -0
  156. package/src/usage/limits/monitor.ts +31 -3
  157. package/src/usage/limits/normalize.ts +30 -0
  158. package/src/usage/limits/probe.ts +17 -0
  159. package/src/usage/pricing.ts +35 -0
  160. package/src/usage/projects.ts +8 -0
  161. package/src/usage/readers/claude.ts +12 -9
  162. package/src/usage/readers/codex.ts +40 -8
  163. package/src/usage/readers/grok.ts +17 -9
  164. package/src/usage/readers/host-log.ts +9 -9
  165. package/src/usage/record.ts +22 -0
  166. package/src/usage/roots.ts +6 -0
  167. package/src/usage/scanner.ts +61 -0
  168. package/src/usage/usage-service.ts +14 -0
  169. package/src/watch-seam.ts +8 -0
  170. 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,
@@ -625,22 +675,36 @@ export class ChatCore {
625
675
  * read leaves its list out rather than the thread.
626
676
  */
627
677
  async attachWithBookmarks(chatId: string, clientId: string, historyLimit?: number, since?: number): Promise<ChatAttachResult> {
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.
679
+ await this.loaded(chatId);
680
+
681
+ if (!this.chats.has(chatId) && (await this.store?.has(chatId)) === true) {
682
+ await this.create({ chatId });
683
+ }
684
+
628
685
  this.require(chatId);
686
+
629
687
  const unreadable = (what: string) => (e: unknown) => {
630
688
  console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
689
+
631
690
  return undefined;
632
691
  };
692
+
633
693
  let bookmarks: ChatBookmark[] | undefined;
634
694
  let visuals: ChatVisual[] | undefined;
635
695
  let changes: number | undefined;
696
+
636
697
  do {
637
698
  changes = this.listChanges.get(chatId);
699
+
638
700
  [bookmarks, visuals] = await Promise.all([
639
701
  this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
640
702
  this.visuals?.list(chatId).catch(unreadable('visuals'))
641
703
  ]);
642
704
  } while (this.listChanges.get(chatId) !== changes);
705
+
643
706
  const result = this.attach(chatId, clientId, historyLimit, since);
707
+
644
708
  return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
645
709
  }
646
710
 
@@ -651,19 +715,23 @@ export class ChatCore {
651
715
  /* Marks a message of the chat's own thread; a message that already has one keeps it. */
652
716
  addBookmark(chatId: string, itemId: string, name?: string): Promise<ChatBookmark[]> {
653
717
  const item = this.require(chatId).thread.get(itemId);
718
+
654
719
  if (!item || !isBookmarkable(item)) {
655
720
  throw new ChatError('item-not-found', `Chat ${chatId} has no message ${itemId} to bookmark`);
656
721
  }
722
+
657
723
  return this.requireBookmarks().add(chatId, { itemId, excerpt: excerptOf(item), ...(name === undefined ? {} : { name }) }, Date.now());
658
724
  }
659
725
 
660
726
  renameBookmark(chatId: string, itemId: string, name: string): Promise<ChatBookmark[]> {
661
727
  this.require(chatId);
728
+
662
729
  return this.requireBookmarks().rename(chatId, itemId, name);
663
730
  }
664
731
 
665
732
  removeBookmark(chatId: string, itemId: string): Promise<ChatBookmark[]> {
666
733
  this.require(chatId);
734
+
667
735
  return this.requireBookmarks().remove(chatId, itemId);
668
736
  }
669
737
 
@@ -671,6 +739,7 @@ export class ChatCore {
671
739
  if (!this.bookmarks) {
672
740
  throw new ChatError('chat-unsupported', 'This host keeps no bookmarks');
673
741
  }
742
+
674
743
  return this.bookmarks;
675
744
  }
676
745
 
@@ -681,10 +750,13 @@ export class ChatCore {
681
750
  async publishVisual(chatId: string, input: VisualInput): Promise<ChatVisual> {
682
751
  const visuals = this.requireVisuals();
683
752
  await this.loaded(chatId);
753
+
684
754
  if (!(await this.hasStored(chatId))) {
685
755
  throw new ChatError('chat-not-found', `No chat ${chatId}`);
686
756
  }
757
+
687
758
  const turnId = input.turnId ?? this.chats.get(chatId)?.info.activeTurnId ?? undefined;
759
+
688
760
  return visuals.publish(chatId, turnId === undefined ? input : { ...input, turnId });
689
761
  }
690
762
 
@@ -695,6 +767,7 @@ export class ChatCore {
695
767
 
696
768
  removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]> {
697
769
  this.require(chatId);
770
+
698
771
  return this.requireVisuals().remove(chatId, visualId);
699
772
  }
700
773
 
@@ -702,6 +775,7 @@ export class ChatCore {
702
775
  if (!this.visuals) {
703
776
  throw new ChatError('chat-unsupported', 'This host keeps no visuals');
704
777
  }
778
+
705
779
  return this.visuals;
706
780
  }
707
781
 
@@ -709,6 +783,7 @@ export class ChatCore {
709
783
  history(chatId: string, cursor?: string, limit?: number): ChatHistoryResult {
710
784
  const session = this.require(chatId);
711
785
  this.coalescers.get(chatId)?.flush();
786
+
712
787
  return session.thread.history(limit, cursor);
713
788
  }
714
789
 
@@ -724,6 +799,7 @@ export class ChatCore {
724
799
  clients.delete(clientId);
725
800
  }
726
801
  }
802
+
727
803
  this.subagents.releaseClient(clientId);
728
804
  }
729
805
 
@@ -736,11 +812,14 @@ export class ChatCore {
736
812
  if (payload.watch === false) {
737
813
  this.subagents.release(clientId, payload.chatId, payload.toolUseId);
738
814
  }
815
+
739
816
  const result = await this.subagents.read(payload.chatId, payload.toolUseId, payload.cursor, payload.limit);
740
817
  await this.chats.get(payload.chatId)?.recheckOrphan(payload.toolUseId);
818
+
741
819
  if (payload.watch === true) {
742
820
  this.subagents.hold(clientId, payload.chatId, payload.toolUseId);
743
821
  }
822
+
744
823
  return result;
745
824
  }
746
825
 
@@ -753,15 +832,19 @@ export class ChatCore {
753
832
  await this.loaded(chatId);
754
833
  const session = this.require(chatId);
755
834
  const row = session.thread.find('subagent', (item) => item.toolUseId === toolUseId);
835
+
756
836
  if (!row) {
757
837
  throw new ChatError('subagent-not-found', 'This chat has no such sub-agent');
758
838
  }
839
+
759
840
  if (row.status !== 'running') {
760
841
  return;
761
842
  }
843
+
762
844
  if (row.origin === HOST_TASK_ORIGIN) {
763
845
  throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
764
846
  }
847
+
765
848
  session.stopSubagent(toolUseId);
766
849
  }
767
850
 
@@ -795,30 +878,40 @@ export class ChatCore {
795
878
  if (!this.store) {
796
879
  throw new ChatError('chat-unsupported', 'This host keeps no chat records');
797
880
  }
881
+
798
882
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
799
883
  throw new ChatError('chat-busy', `Chat ${chatId} is already loaded`);
800
884
  }
885
+
801
886
  const copies = new Map<string, Promise<ChatAttachment>>();
887
+
802
888
  const copy = (attachment: ChatAttachment): Promise<ChatAttachment> => {
803
889
  let pending = copies.get(attachment.id);
890
+
804
891
  if (!pending) {
805
892
  pending = this.attachments.copy(chatId, attachment);
806
893
  copies.set(attachment.id, pending);
807
894
  }
895
+
808
896
  return pending;
809
897
  };
898
+
810
899
  const copied = await Promise.all(
811
900
  items.map(async (item): Promise<ChatItem> => {
812
901
  if (item.kind === 'user' && item.attachments?.length) {
813
902
  return { ...item, attachments: await Promise.all(item.attachments.map(copy)) };
814
903
  }
904
+
815
905
  const generated = generatedImageAttachment(item);
906
+
816
907
  if (item.kind === 'tool' && generated) {
817
908
  return { ...item, input: { ...(item.input as Record<string, unknown>), attachment: await copy(generated) } };
818
909
  }
910
+
819
911
  return item;
820
912
  })
821
913
  );
914
+
822
915
  await this.store.write(chatId, info, copied, { seq: 0, resetSeq: 0 }, preambles);
823
916
  }
824
917
 
@@ -830,34 +923,42 @@ export class ChatCore {
830
923
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
831
924
  return false;
832
925
  }
926
+
833
927
  await Promise.all([
834
928
  this.store?.delete(chatId),
835
929
  this.attachments.removeAll(chatId),
836
930
  this.bookmarks?.removeChat(chatId),
837
931
  this.visuals?.removeChat(chatId)
838
932
  ]);
933
+
839
934
  return true;
840
935
  }
841
936
 
842
937
  /* The turn a chat took last, read off the record without loading a chat nobody has; null before its first. */
843
938
  async lastTurn(chatId: string): Promise<ChatTurnItem | null> {
844
939
  const session = this.chats.get(chatId);
940
+
845
941
  if (session) {
846
942
  return session.thread.find('turn', () => true) ?? null;
847
943
  }
944
+
848
945
  const stored = await this.store?.read(chatId);
946
+
849
947
  return stored?.items.findLast((item): item is ChatTurnItem => item.kind === 'turn') ?? null;
850
948
  }
851
949
 
852
950
  /* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
853
951
  async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void> {
854
952
  await this.loaded(chatId);
953
+
855
954
  if (!(await this.hasStored(chatId))) {
856
955
  return;
857
956
  }
957
+
858
958
  if (!this.chats.has(chatId)) {
859
959
  await this.create({ chatId });
860
960
  }
961
+
861
962
  this.chats.get(chatId)?.addNote(level, text);
862
963
  }
863
964
 
@@ -867,12 +968,15 @@ export class ChatCore {
867
968
  */
868
969
  async deliverNote(chatId: string, delivery: { noteId: string; note: string; from?: string; preamble: string }): Promise<boolean> {
869
970
  await this.loaded(chatId);
971
+
870
972
  if (!(await this.hasStored(chatId))) {
871
973
  return false;
872
974
  }
975
+
873
976
  if (!this.chats.has(chatId)) {
874
977
  await this.create({ chatId });
875
978
  }
979
+
876
980
  return this.require(chatId).deliverNote(delivery);
877
981
  }
878
982
 
@@ -883,10 +987,13 @@ export class ChatCore {
883
987
  async send(chatId: string, text: string, extras: ChatSendExtras = {}, uploads: ChatAttachmentUpload[] = []): Promise<{ queued: boolean; turnId: string }> {
884
988
  const session = this.require(chatId);
885
989
  const checked = ChatAttachmentUploadsSchema.safeParse(uploads);
990
+
886
991
  if (!checked.success) {
887
992
  throw new ChatError('invalid-attachments', checked.error.issues[0]!.message);
888
993
  }
994
+
889
995
  const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
996
+
890
997
  return session.sendInput(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
891
998
  }
892
999
 
@@ -894,6 +1001,7 @@ export class ChatCore {
894
1001
  if (!this.attached.get(payload.chatId)?.has(clientId) || !this.uiQueries) {
895
1002
  throw new ChatError('refused-query', 'Attach to the chat before resolving its UI links.');
896
1003
  }
1004
+
897
1005
  return this.uiQueries.link(this.require(payload.chatId), payload);
898
1006
  }
899
1007
 
@@ -901,9 +1009,11 @@ export class ChatCore {
901
1009
  if (!this.attached.get(payload.chatId)?.has(clientId)) {
902
1010
  throw new ChatError('refused-query', 'Attach to the chat before reading its UI queries.');
903
1011
  }
1012
+
904
1013
  if (!this.uiQueries) {
905
1014
  throw new ChatError('refused-query', 'This host does not provide UI query sources.');
906
1015
  }
1016
+
907
1017
  return this.uiQueries.query(this.require(payload.chatId), payload);
908
1018
  }
909
1019
 
@@ -911,40 +1021,50 @@ export class ChatCore {
911
1021
  if (!this.attached.get(payload.chatId)?.has(clientId)) {
912
1022
  throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
913
1023
  }
1024
+
914
1025
  const session = this.require(payload.chatId);
915
1026
  const queries = await this.uiQueries?.choiceValues(session, payload);
916
1027
  const result = session.choose(payload, queries);
917
1028
  await this.persisted(payload.chatId);
1029
+
918
1030
  return result;
919
1031
  }
920
1032
 
921
1033
  /* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
922
1034
  attachment(chatId: string, id: string): ChatAttachment | null {
923
1035
  const session = this.chats.get(chatId);
1036
+
924
1037
  if (!session) {
925
1038
  return null;
926
1039
  }
1040
+
927
1041
  for (const item of session.thread.list()) {
928
1042
  const generated = generatedImageAttachment(item);
929
1043
  const found = item.kind === 'user' ? item.attachments?.find((attachment) => attachment.id === id) : generated?.id === id ? generated : null;
1044
+
930
1045
  if (found) {
931
1046
  return found;
932
1047
  }
933
1048
  }
1049
+
934
1050
  for (const message of session.info.queue ?? []) {
935
1051
  const found = message.attachments?.find((attachment) => attachment.id === id);
1052
+
936
1053
  if (found) {
937
1054
  return found;
938
1055
  }
939
1056
  }
1057
+
940
1058
  return this.visuals?.attachment(chatId, id) ?? null;
941
1059
  }
942
1060
 
943
1061
  unqueue(chatId: string, messageId: string): ChatQueuedMessage {
944
1062
  const message = this.require(chatId).unqueue(messageId);
1063
+
945
1064
  if (!message) {
946
1065
  throw new ChatError('request-not-found', `No queued message ${messageId} in chat ${chatId}`);
947
1066
  }
1067
+
948
1068
  return message;
949
1069
  }
950
1070
 
@@ -957,9 +1077,15 @@ export class ChatCore {
957
1077
  /* What the chat's CLI would run as a skill. */
958
1078
  skills(chatId: string): Promise<ChatSkill[]> {
959
1079
  const session = this.require(chatId);
1080
+
960
1081
  return session.skills(() => this.skillIndex.list(session.info.provider, session.info.cwd));
961
1082
  }
962
1083
 
1084
+ /* What a CLI would run as a skill in a folder before any chat of it exists there, such as for a prompt saved to send later. */
1085
+ skillsIn(provider: AgentKind, cwd: string): Promise<ChatSkill[]> {
1086
+ return this.skillIndex.list(provider, cwd);
1087
+ }
1088
+
963
1089
  /* Ends the chat's CLI and keeps its conversation; the next message resumes it in a process that reads its skills and plugins again. */
964
1090
  async restart(chatId: string): Promise<void> {
965
1091
  const session = this.require(chatId);
@@ -972,9 +1098,11 @@ export class ChatCore {
972
1098
  compact(chatId: string): void {
973
1099
  const session = this.require(chatId);
974
1100
  this.refuseWhileRelocating(chatId);
1101
+
975
1102
  if (session.busy) {
976
1103
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
977
1104
  }
1105
+
978
1106
  session.compact();
979
1107
  }
980
1108
 
@@ -982,15 +1110,18 @@ export class ChatCore {
982
1110
  async clear(chatId: string, force = false): Promise<void> {
983
1111
  const session = this.require(chatId);
984
1112
  this.refuseWhileRelocating(chatId);
1113
+
985
1114
  if (session.busy && !force) {
986
1115
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
987
1116
  }
1117
+
988
1118
  await this.hooks.clearing?.(chatId);
989
1119
  const ended = session.clear(force);
990
1120
  // Before the record is written, so what it takes along from the record is gone from the write as well.
991
1121
  const cleared = this.hooks.cleared?.(chatId);
992
1122
  // A debounced write still waiting holds the old thread and must not land after the empty one.
993
1123
  this.cancelWaiting(chatId);
1124
+
994
1125
  // Folded right away: every line before the reset describes a thread that is gone.
995
1126
  await Promise.all([
996
1127
  this.persistNow(chatId, true),
@@ -1005,6 +1136,7 @@ export class ChatCore {
1005
1136
  cancel(chatId: string, subagents = false): void {
1006
1137
  const session = this.require(chatId);
1007
1138
  session.cancel();
1139
+
1008
1140
  if (subagents) {
1009
1141
  session.markSubagentsStopped();
1010
1142
  }
@@ -1037,6 +1169,7 @@ export class ChatCore {
1037
1169
  holdForPerson(chatId: string, key: string): boolean {
1038
1170
  const session = this.chats.get(chatId);
1039
1171
  session?.holdForPerson(key);
1172
+
1040
1173
  return session !== undefined;
1041
1174
  }
1042
1175
 
@@ -1052,11 +1185,14 @@ export class ChatCore {
1052
1185
  const session = this.require(chatId);
1053
1186
  this.refuseUnlessRelocatable(session);
1054
1187
  const running = this.relocateNow(session, cwd, during);
1188
+
1055
1189
  const tracked = running.then(
1056
1190
  () => undefined,
1057
1191
  () => undefined
1058
1192
  );
1193
+
1059
1194
  this.relocations.set(chatId, tracked);
1195
+
1060
1196
  try {
1061
1197
  return await running;
1062
1198
  } finally {
@@ -1074,12 +1210,15 @@ export class ChatCore {
1074
1210
  private async relocateNow(session: ChatSession, cwd: string, during: (() => Promise<void>) | undefined): Promise<ChatInfo> {
1075
1211
  const chatId = session.id;
1076
1212
  await this.hooks.admit?.(chatId, cwd);
1213
+
1077
1214
  // A message sent while the folder was asked about may have opened a turn.
1078
1215
  if (!session.relocatable) {
1079
1216
  throw this.busyRelocating(chatId);
1080
1217
  }
1218
+
1081
1219
  const previous = session.info.cwd;
1082
1220
  session.beginRelocate();
1221
+
1083
1222
  try {
1084
1223
  await session.dropProcess();
1085
1224
  await during?.();
@@ -1087,11 +1226,14 @@ export class ChatCore {
1087
1226
  await this.persistNow(chatId, true);
1088
1227
  } catch (e) {
1089
1228
  session.setCwd(previous);
1229
+
1090
1230
  throw e;
1091
1231
  } finally {
1092
1232
  session.endRelocate();
1093
1233
  }
1234
+
1094
1235
  this.skillIndex.forget(session.info.provider, previous);
1236
+
1095
1237
  return session.info;
1096
1238
  }
1097
1239
 
@@ -1115,9 +1257,11 @@ export class ChatCore {
1115
1257
  async kill(chatId: string): Promise<void> {
1116
1258
  await this.loaded(chatId);
1117
1259
  const moving = this.relocations.get(chatId);
1260
+
1118
1261
  if (moving) {
1119
1262
  await moving;
1120
1263
  }
1264
+
1121
1265
  const session = this.require(chatId);
1122
1266
  // A write already out would put the record back after the delete.
1123
1267
  const writing = this.writes.get(chatId);
@@ -1141,6 +1285,7 @@ export class ChatCore {
1141
1285
  this.hooks.forgotten?.(chatId);
1142
1286
  await writing;
1143
1287
  await ended;
1288
+
1144
1289
  await Promise.all([
1145
1290
  this.store?.delete(chatId),
1146
1291
  this.attachments.removeAll(chatId),
@@ -1162,12 +1307,15 @@ export class ChatCore {
1162
1307
  /* The chats with a CLI process, for a process monitor. */
1163
1308
  processTargets(): { id: string; pid: number; provider: AgentKind; status: ChatInfo['status']; updatedAt: number }[] {
1164
1309
  const targets = [];
1310
+
1165
1311
  for (const [id, session] of this.chats) {
1166
1312
  const pid = session.pid;
1313
+
1167
1314
  if (pid !== null) {
1168
1315
  targets.push({ id, pid, provider: session.info.provider, status: session.info.status, updatedAt: this.activity.get(id) ?? 0 });
1169
1316
  }
1170
1317
  }
1318
+
1171
1319
  return targets;
1172
1320
  }
1173
1321
 
@@ -1186,12 +1334,15 @@ export class ChatCore {
1186
1334
  */
1187
1335
  async resumeRun(chatId: string, turnId: string, attempt: number): Promise<void> {
1188
1336
  await this.loaded(chatId);
1337
+
1189
1338
  if (!this.chats.has(chatId)) {
1190
1339
  if (!(await this.store?.has(chatId))) {
1191
1340
  return;
1192
1341
  }
1342
+
1193
1343
  await this.create({ chatId });
1194
1344
  }
1345
+
1195
1346
  await this.chats.get(chatId)?.resume(turnId, attempt, (await this.hooks.resumeWords?.(chatId)) ?? DEFAULT_RESUME_WORDS);
1196
1347
  }
1197
1348
 
@@ -1201,22 +1352,30 @@ export class ChatCore {
1201
1352
  */
1202
1353
  async takeUpAfterLimit(chatId: string, turnId: string): Promise<OutboxOutcome> {
1203
1354
  await this.loaded(chatId);
1355
+
1204
1356
  if (!this.chats.has(chatId)) {
1205
1357
  if (!(await this.store?.has(chatId))) {
1206
1358
  return;
1207
1359
  }
1360
+
1208
1361
  await this.create({ chatId });
1209
1362
  }
1363
+
1210
1364
  const session = this.chats.get(chatId);
1365
+
1211
1366
  if (!session) {
1212
1367
  return;
1213
1368
  }
1369
+
1214
1370
  const turn = session.thread.get(turnId);
1215
1371
  const ended = this.hooks.endedAt?.(chatId) ?? null;
1372
+
1216
1373
  if (turn !== undefined && ended !== null && ended >= turn.createdAt) {
1217
1374
  session.lapseResume();
1375
+
1218
1376
  return;
1219
1377
  }
1378
+
1220
1379
  return session.takeUpAfterLimit(turnId) === 'wait' ? 'wait' : undefined;
1221
1380
  }
1222
1381
 
@@ -1241,16 +1400,20 @@ export class ChatCore {
1241
1400
  if (!this.store) {
1242
1401
  return;
1243
1402
  }
1403
+
1244
1404
  for (const chatId of await this.store.list()) {
1245
1405
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
1246
1406
  continue;
1247
1407
  }
1408
+
1248
1409
  // Parsing the whole thread of every settled chat was most of the cost of a start.
1249
1410
  if (await this.store.settled(chatId).catch(() => false)) {
1250
1411
  continue;
1251
1412
  }
1413
+
1252
1414
  const record = await this.store.read(chatId).catch(() => null);
1253
1415
  const unowed = record?.info.resumeAt !== undefined && this.limitResume?.owed(chatId) === false;
1416
+
1254
1417
  if (record !== null && (interruptedTurn(record) !== null || unowed)) {
1255
1418
  await this.create({ chatId }).catch((e: unknown) => console.error(`Loading chat ${chatId} after a restart failed:`, errorText(e)));
1256
1419
  }
@@ -1265,13 +1428,16 @@ export class ChatCore {
1265
1428
  for (const session of this.chats.values()) {
1266
1429
  session.freeze();
1267
1430
  }
1431
+
1268
1432
  for (const chatId of this.chats.keys()) {
1269
1433
  this.cancelWaiting(chatId);
1270
1434
  }
1435
+
1271
1436
  // A thread that cannot be written still ends its CLI, whose process group would otherwise outlive the host.
1272
1437
  const written = await Promise.allSettled([...this.chats.keys()].map((chatId) => this.persistNow(chatId, true)));
1273
1438
  await Promise.allSettled([...this.chats.values()].map((session) => session.dispose()));
1274
1439
  const failed = written.find((result) => result.status === 'rejected');
1440
+
1275
1441
  if (failed) {
1276
1442
  throw failed.reason;
1277
1443
  }
@@ -1285,14 +1451,17 @@ export class ChatCore {
1285
1451
  if (!this.store) {
1286
1452
  return;
1287
1453
  }
1454
+
1288
1455
  for (const session of this.chats.values()) {
1289
1456
  session.freeze();
1290
1457
  }
1458
+
1291
1459
  for (const [chatId, session] of this.chats) {
1292
1460
  this.cancelWaiting(chatId);
1293
1461
  this.coalescers.get(chatId)?.flush();
1294
1462
  const { info, items } = session.thread.snapshot();
1295
1463
  const log = this.logs.get(chatId);
1464
+
1296
1465
  // Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
1297
1466
  try {
1298
1467
  this.store.writeSync(
@@ -1322,9 +1491,11 @@ export class ChatCore {
1322
1491
 
1323
1492
  protected require(chatId: string): ChatSession {
1324
1493
  const session = this.chats.get(chatId);
1494
+
1325
1495
  if (!session) {
1326
1496
  throw new ChatError('chat-not-found', `No chat ${chatId}`);
1327
1497
  }
1498
+
1328
1499
  return session;
1329
1500
  }
1330
1501
 
@@ -1337,15 +1508,19 @@ export class ChatCore {
1337
1508
  private persistSoon(chatId: string): void {
1338
1509
  if ((this.sizes.get(chatId) ?? 0) < DEBOUNCE_ABOVE_BYTES) {
1339
1510
  this.persist(chatId);
1511
+
1340
1512
  return;
1341
1513
  }
1514
+
1342
1515
  if (this.waiting.has(chatId)) {
1343
1516
  return;
1344
1517
  }
1518
+
1345
1519
  const timer = setTimeout(() => {
1346
1520
  this.waiting.delete(chatId);
1347
1521
  this.persist(chatId);
1348
1522
  }, DEBOUNCE_MS);
1523
+
1349
1524
  // A pending write must never be the reason the process stays up.
1350
1525
  timer.unref?.();
1351
1526
  this.waiting.set(chatId, timer);
@@ -1353,6 +1528,7 @@ export class ChatCore {
1353
1528
 
1354
1529
  private cancelWaiting(chatId: string): void {
1355
1530
  const timer = this.waiting.get(chatId);
1531
+
1356
1532
  if (timer) {
1357
1533
  clearTimeout(timer);
1358
1534
  this.waiting.delete(chatId);
@@ -1367,33 +1543,40 @@ export class ChatCore {
1367
1543
  const next = (this.writes.get(chatId) ?? Promise.resolve()).then(async () => {
1368
1544
  const session = this.chats.get(chatId);
1369
1545
  const log = this.logs.get(chatId);
1546
+
1370
1547
  if (!session || !this.store || !log) {
1371
1548
  return;
1372
1549
  }
1550
+
1373
1551
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
1374
1552
  this.coalescers.get(chatId)?.flush();
1375
1553
  const extras = this.hooks.recordExtras?.(chatId) ?? {};
1376
1554
  const uiAccess = session.uiAccessRecord;
1377
1555
  const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
1378
1556
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
1557
+
1379
1558
  // The log already holds every change to the thread, so the whole record is rewritten only to fold
1380
1559
  // the log, on a chat's first write, or for what no event carries.
1381
1560
  if (!folds && this.sizes.has(chatId) && this.unlogged.get(chatId) === unlogged) {
1382
1561
  return;
1383
1562
  }
1563
+
1384
1564
  const { info, items } = session.thread.snapshot();
1385
1565
  const at = { seq: log.seq, resetSeq: log.resetSeq };
1386
1566
  this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
1387
1567
  this.unlogged.set(chatId, unlogged);
1568
+
1388
1569
  // A chat killed while the write was out has no log left to fold.
1389
1570
  if (this.logs.get(chatId) === log && folds) {
1390
1571
  log.compact(at.seq);
1391
1572
  }
1392
1573
  });
1574
+
1393
1575
  this.writes.set(
1394
1576
  chatId,
1395
1577
  next.catch(() => undefined)
1396
1578
  );
1579
+
1397
1580
  return next;
1398
1581
  }
1399
1582
 
@@ -1401,17 +1584,22 @@ export class ChatCore {
1401
1584
  if (event.type === 'delta' && event.ui !== undefined) {
1402
1585
  // Previews follow all pending text but have no log position or observer side effects.
1403
1586
  this.coalescers.get(chatId)?.flush();
1587
+
1404
1588
  for (const clientId of this.attached.get(chatId) ?? []) {
1405
1589
  this.sinks.to(clientId, { event: 'chat.event', payload: { chatId, event } });
1406
1590
  }
1591
+
1407
1592
  return;
1408
1593
  }
1594
+
1409
1595
  this.activity.set(chatId, Date.now());
1410
1596
  let coalescer = this.coalescers.get(chatId);
1597
+
1411
1598
  if (!coalescer) {
1412
1599
  coalescer = new DeltaCoalescer((coalesced) => this.broadcast(chatId, coalesced));
1413
1600
  this.coalescers.set(chatId, coalescer);
1414
1601
  }
1602
+
1415
1603
  coalescer.push(event);
1416
1604
  }
1417
1605
 
@@ -1419,18 +1607,23 @@ export class ChatCore {
1419
1607
  private broadcast(chatId: string, event: ChatEvent): void {
1420
1608
  const seq = this.logs.get(chatId)?.append(event, Date.now());
1421
1609
  const payload = seq === undefined ? { chatId, event } : { chatId, event, seq };
1610
+
1422
1611
  for (const sink of this.observers) {
1423
1612
  sink({ event: 'chat.event', payload });
1424
1613
  }
1614
+
1425
1615
  this.hooks.broadcasted?.(chatId, event);
1616
+
1426
1617
  for (const clientId of this.attached.get(chatId) ?? []) {
1427
1618
  this.sinks.to(clientId, { event: 'chat.event', payload });
1428
1619
  }
1620
+
1429
1621
  // After the thread, so a client reading it has already had this info and the status is a no-op there.
1430
1622
  if (event.type === 'info' || event.type === 'reset') {
1431
1623
  this.announceStatus(chatId, event.info);
1432
1624
  } else if (event.type === 'item' && (event.item.kind === 'approval' || event.item.kind === 'question')) {
1433
1625
  const session = this.chats.get(chatId);
1626
+
1434
1627
  if (session) {
1435
1628
  this.announceStatus(chatId, session.info);
1436
1629
  }
@@ -1455,9 +1648,11 @@ export class ChatCore {
1455
1648
  info.requests?.map((request) => request.requestId) ?? [],
1456
1649
  info.goal === undefined ? null : [info.goal.status, info.goal.objective]
1457
1650
  ]);
1651
+
1458
1652
  if (this.announced.get(chatId) === said) {
1459
1653
  return;
1460
1654
  }
1655
+
1461
1656
  this.announced.set(chatId, said);
1462
1657
  this.sinks.emit({ event: 'chat.status', payload: { chatId, info } });
1463
1658
  }
@@ -1471,42 +1666,57 @@ export class ChatCore {
1471
1666
  if (!this.store) {
1472
1667
  return new ChatLog(null);
1473
1668
  }
1669
+
1474
1670
  const path = this.store.logPath(chatId);
1671
+
1475
1672
  if (stored) {
1476
1673
  return new ChatLog(path, stored);
1477
1674
  }
1675
+
1478
1676
  const left = await this.store.discardLog(chatId);
1677
+
1479
1678
  return new ChatLog(path, { seq: left, resetSeq: left === 0 ? 0 : left + 1, lines: [] });
1480
1679
  }
1481
1680
 
1482
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. */
1483
1682
  private async owedResume(chatId: string, stored: ChatRecord): Promise<ResumeDecision> {
1484
1683
  const turn = interruptedTurn(stored);
1684
+
1485
1685
  const skip = (reason: string): ResumeDecision => ({ resumeTurnId: null, reason });
1686
+
1486
1687
  if (turn === null) {
1487
1688
  return { resumeTurnId: null, reason: null };
1488
1689
  }
1690
+
1489
1691
  if (turn.deliveryPending) {
1490
1692
  return skip('the wake prompt was not acknowledged; its result waits for your next message');
1491
1693
  }
1694
+
1492
1695
  if (!this.onInterruptedRun) {
1493
1696
  return skip('this machine does not resume turns');
1494
1697
  }
1698
+
1495
1699
  if (stored.info.agentSessionId === null) {
1496
1700
  return skip('the agent had not started a session to resume yet');
1497
1701
  }
1702
+
1498
1703
  const ended = this.hooks.endedAt?.(chatId) ?? null;
1704
+
1499
1705
  if (ended !== null && ended >= turn.createdAt) {
1500
1706
  return skip('the agent that opened it was stopped');
1501
1707
  }
1708
+
1502
1709
  if ((turn.attempt ?? 1) >= MAX_ATTEMPTS && !turn.resumePending) {
1503
1710
  return skip('it was already resumed after an earlier restart');
1504
1711
  }
1712
+
1505
1713
  try {
1506
1714
  const owed = await this.onInterruptedRun({ chatId, turnId: turn.id, attempt: (turn.attempt ?? 1) + (turn.resumePending ? 0 : 1) });
1715
+
1507
1716
  return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.hooks.unownedReason?.(chatId) ?? 'nothing holds this chat any more');
1508
1717
  } catch (e) {
1509
1718
  console.error(`Owing a resume for chat ${chatId} failed:`, errorText(e));
1719
+
1510
1720
  return skip(errorText(e));
1511
1721
  }
1512
1722
  }
@@ -1527,5 +1737,6 @@ function limitHooks(hooks: NonNullable<ChatCoreOptions['limitResume']>, chatId:
1527
1737
  function interruptedTurn(record: { info: ChatInfo; items: ChatItem[] }): Extract<ChatItem, { kind: 'turn' }> | null {
1528
1738
  const turnId = record.info.activeTurnId;
1529
1739
  const turn = turnId === null ? undefined : record.items.find((item) => item.id === turnId);
1740
+
1530
1741
  return turn?.kind === 'turn' && turn.state === 'running' ? turn : null;
1531
1742
  }