@adecore/agents 0.23.0 → 0.24.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 (153) hide show
  1. package/README.md +3 -3
  2. package/dist/chat/attachment-store.js +4 -5
  3. package/dist/chat/backend.d.ts +6 -1
  4. package/dist/chat/background-work.d.ts +1 -1
  5. package/dist/chat/background-work.js +2 -1
  6. package/dist/chat/chat-core.d.ts +53 -161
  7. package/dist/chat/chat-core.js +146 -112
  8. package/dist/chat/chat-log.js +3 -3
  9. package/dist/chat/chat-process.d.ts +21 -0
  10. package/dist/chat/chat-process.js +109 -9
  11. package/dist/chat/chat-session.d.ts +10 -17
  12. package/dist/chat/chat-session.js +141 -182
  13. package/dist/chat/chat-store.d.ts +1 -0
  14. package/dist/chat/chat-store.js +105 -1
  15. package/dist/chat/claude-backend.d.ts +4 -0
  16. package/dist/chat/claude-backend.js +51 -27
  17. package/dist/chat/claude-protocol.d.ts +12 -0
  18. package/dist/chat/claude-protocol.js +137 -7
  19. package/dist/chat/claude-title.d.ts +1 -3
  20. package/dist/chat/claude-title.js +11 -39
  21. package/dist/chat/claude-transcript.d.ts +1 -0
  22. package/dist/chat/claude-transcript.js +36 -17
  23. package/dist/chat/codex-backend.d.ts +21 -1
  24. package/dist/chat/codex-backend.js +168 -10
  25. package/dist/chat/codex-protocol.d.ts +2 -0
  26. package/dist/chat/codex-protocol.js +37 -3
  27. package/dist/chat/codex-thread.js +1 -3
  28. package/dist/chat/codex-transport.d.ts +3 -0
  29. package/dist/chat/codex-transport.js +38 -35
  30. package/dist/chat/context-breakdown.js +2 -2
  31. package/dist/chat/errors.d.ts +1 -1
  32. package/dist/chat/external-sessions.js +2 -2
  33. package/dist/chat/fixtures/claude-2.1.296-goal.json +136 -0
  34. package/dist/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  35. package/dist/chat/index.d.ts +29 -0
  36. package/dist/chat/index.js +23 -0
  37. package/dist/chat/projector.d.ts +2 -1
  38. package/dist/chat/projector.js +12 -3
  39. package/dist/chat/request-summary.js +1 -4
  40. package/dist/chat/subagent-settlement.js +1 -3
  41. package/dist/chat/thread.d.ts +3 -0
  42. package/dist/chat/thread.js +20 -3
  43. package/dist/chat/ui-queries.d.ts +2 -0
  44. package/dist/chat/ui-queries.js +40 -5
  45. package/dist/chat/ui-replies.d.ts +33 -0
  46. package/dist/chat/ui-replies.js +152 -0
  47. package/dist/context/index.d.ts +2 -0
  48. package/dist/context/index.js +3 -0
  49. package/dist/coordination/index.d.ts +15 -0
  50. package/dist/coordination/index.js +16 -0
  51. package/dist/host/handlers.js +8 -0
  52. package/dist/host/index.d.ts +7 -0
  53. package/dist/host/index.js +7 -0
  54. package/dist/index.d.ts +9 -0
  55. package/dist/index.js +10 -0
  56. package/dist/providers/accounts/status.js +1 -3
  57. package/dist/providers/claude.js +3 -1
  58. package/dist/providers/codex.js +3 -1
  59. package/dist/providers/index.d.ts +14 -0
  60. package/dist/providers/index.js +12 -0
  61. package/dist/run-process.js +54 -14
  62. package/dist/skills.d.ts +1 -0
  63. package/dist/skills.js +4 -0
  64. package/dist/tasks/task-coordinator.d.ts +4 -0
  65. package/dist/tasks/task-coordinator.js +16 -2
  66. package/dist/tasks/wiring.js +7 -1
  67. package/dist/{providers/accounts/test-accounts.d.ts → testing/accounts.d.ts} +2 -2
  68. package/dist/{providers/accounts/test-accounts.js → testing/accounts.js} +3 -3
  69. package/dist/testing/bin/fake-claude.d.ts +1 -0
  70. package/dist/testing/bin/fake-claude.js +6 -0
  71. package/dist/testing/bin/fake-codex.d.ts +1 -0
  72. package/dist/testing/bin/fake-codex.js +6 -0
  73. package/dist/{chat → testing}/fake-claude.d.ts +1 -1
  74. package/dist/{chat → testing}/fake-claude.js +0 -4
  75. package/dist/{chat → testing}/fake-cli.d.ts +1 -1
  76. package/dist/{chat → testing}/fake-codex.d.ts +1 -1
  77. package/dist/{chat → testing}/fake-codex.js +70 -8
  78. package/dist/testing/index.d.ts +6 -0
  79. package/dist/testing/index.js +7 -0
  80. package/dist/{outbox → testing}/manual-clock.d.ts +1 -1
  81. package/dist/{watch-test-helpers.d.ts → testing/watch.d.ts} +1 -1
  82. package/dist/usage/exchange.d.ts +5 -3
  83. package/dist/usage/exchange.js +25 -16
  84. package/dist/usage/index-file.d.ts +1 -0
  85. package/dist/usage/index-file.js +20 -2
  86. package/dist/usage/index.d.ts +9 -0
  87. package/dist/usage/index.js +6 -0
  88. package/dist/usage/limits/normalize.js +1 -3
  89. package/dist/usage/pricing.d.ts +5 -3
  90. package/dist/usage/pricing.js +28 -19
  91. package/dist/usage/scanner.js +39 -11
  92. package/dist/usage/usage-service.js +8 -3
  93. package/package.json +72 -11
  94. package/src/chat/attachment-store.ts +4 -6
  95. package/src/chat/backend.ts +8 -0
  96. package/src/chat/background-work.ts +2 -2
  97. package/src/chat/chat-core.ts +221 -143
  98. package/src/chat/chat-log.ts +3 -3
  99. package/src/chat/chat-process.ts +128 -8
  100. package/src/chat/chat-session.ts +153 -204
  101. package/src/chat/chat-store.ts +104 -1
  102. package/src/chat/claude-backend.ts +58 -26
  103. package/src/chat/claude-protocol.ts +147 -8
  104. package/src/chat/claude-title.ts +11 -39
  105. package/src/chat/claude-transcript.ts +38 -18
  106. package/src/chat/codex-backend.ts +182 -20
  107. package/src/chat/codex-protocol.ts +40 -5
  108. package/src/chat/codex-thread.ts +1 -4
  109. package/src/chat/codex-transport.ts +45 -35
  110. package/src/chat/context-breakdown.ts +2 -2
  111. package/src/chat/errors.ts +1 -0
  112. package/src/chat/external-sessions.ts +2 -2
  113. package/src/chat/fixtures/claude-2.1.296-goal.json +136 -0
  114. package/src/chat/fixtures/claude-projects/-work-demo/5f1c2a9e-0b7d-4c1e-9a53-3e2f8d6b7a10.jsonl +2 -0
  115. package/src/chat/index.ts +49 -0
  116. package/src/chat/projector.ts +13 -4
  117. package/src/chat/request-summary.ts +8 -5
  118. package/src/chat/subagent-settlement.ts +1 -4
  119. package/src/chat/thread.ts +22 -3
  120. package/src/chat/ui-queries.ts +46 -5
  121. package/src/chat/ui-replies.ts +187 -0
  122. package/src/context/index.ts +26 -0
  123. package/src/coordination/index.ts +67 -0
  124. package/src/host/handlers.ts +8 -0
  125. package/src/host/index.ts +8 -0
  126. package/src/index.ts +20 -0
  127. package/src/providers/accounts/status.ts +1 -4
  128. package/src/providers/claude.ts +3 -1
  129. package/src/providers/codex.ts +3 -1
  130. package/src/providers/index.ts +15 -0
  131. package/src/run-process.ts +56 -16
  132. package/src/skills.ts +5 -0
  133. package/src/tasks/task-coordinator.ts +22 -2
  134. package/src/tasks/wiring.ts +7 -1
  135. package/src/{providers/accounts/test-accounts.ts → testing/accounts.ts} +4 -4
  136. package/src/testing/bin/fake-claude.ts +7 -0
  137. package/src/testing/bin/fake-codex.ts +7 -0
  138. package/src/{chat → testing}/fake-claude.ts +1 -5
  139. package/src/{chat → testing}/fake-cli.ts +1 -1
  140. package/src/{chat → testing}/fake-codex.ts +75 -6
  141. package/src/testing/index.ts +7 -0
  142. package/src/{outbox → testing}/manual-clock.ts +1 -1
  143. package/src/{watch-test-helpers.ts → testing/watch.ts} +1 -1
  144. package/src/usage/exchange.ts +31 -20
  145. package/src/usage/index-file.ts +23 -2
  146. package/src/usage/index.ts +10 -0
  147. package/src/usage/limits/normalize.ts +1 -4
  148. package/src/usage/pricing.ts +34 -23
  149. package/src/usage/scanner.ts +48 -11
  150. package/src/usage/usage-service.ts +8 -3
  151. /package/dist/{chat → testing}/fake-cli.js +0 -0
  152. /package/dist/{outbox → testing}/manual-clock.js +0 -0
  153. /package/dist/{watch-test-helpers.js → testing/watch.js} +0 -0
@@ -1,6 +1,6 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
- import { ChatAttachmentUploadsSchema, generatedImageAttachment } from '@adecore/agent-contracts';
3
+ import { ChatAttachmentUploadsSchema, generatedImageAttachment, HOST_TASK_ORIGIN } from '@adecore/agent-contracts';
4
4
  import { ClientSinks } from '../client-sinks.js';
5
5
  import { errorText } from '../error-text.js';
6
6
  import { AccountError, definedEnv, isDefaultAccountOf, launchEnv, storedAccount } from '../providers/accounts/launch.js';
@@ -42,21 +42,25 @@ const DEFAULT_RESUME_WORDS = {
42
42
  /*
43
43
  * The chats of one host: a `ChatSession` per chat, its record and its log, and the clients that read
44
44
  * them. Everything here holds for any host. What a host adds (notes for the agent, rows of its own,
45
- * what a clear or a removal takes along) it adds by overriding the protected methods below, which
46
- * do nothing of their own.
45
+ * what a clear or a removal takes along) it adds through `ChatCoreOptions.hooks`. A subclass may add
46
+ * methods of its own over the public ones; the state behind them stays here.
47
47
  */
48
48
  export class ChatCore {
49
- providers;
50
- store;
49
+ // The stores the host handed in, which a subclass reads to copy a chat's bookmarks or visuals.
51
50
  bookmarks;
52
51
  visuals;
52
+ providers;
53
+ store;
53
54
  accounts;
54
55
  attachments;
55
56
  env;
57
+ hooks;
56
58
  chats = new Map();
57
59
  creating = new Map();
58
60
  sinks = new ClientSinks((clientId) => this.composerPreferences.forget(clientId));
59
61
  attached = new Map();
62
+ // How often a chat's bookmarks or visuals changed, so an attach can tell that its read went stale before it joined.
63
+ listChanges = new Map();
60
64
  coalescers = new Map();
61
65
  subagents;
62
66
  /* Where Claude Code keeps the projects of its default account on this machine; empty when it has none. */
@@ -105,6 +109,7 @@ export class ChatCore {
105
109
  this.nameChat = options.nameChat ?? null;
106
110
  this.onInterruptedRun = options.onInterruptedRun ?? null;
107
111
  this.limitResume = options.limitResume ?? null;
112
+ this.hooks = options.hooks ?? {};
108
113
  this.commands = {
109
114
  ...(options.command ? { claude: options.command } : {}),
110
115
  ...(options.codexCommand ? { codex: options.codexCommand } : {})
@@ -114,11 +119,13 @@ export class ChatCore {
114
119
  this.env = definedEnv(options.env ?? process.env);
115
120
  // 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.
116
121
  this.bookmarks?.listen((chatId, bookmarks) => {
122
+ this.listChanged(chatId);
117
123
  for (const clientId of this.attached.get(chatId) ?? []) {
118
124
  this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
119
125
  }
120
126
  });
121
127
  this.visuals?.listen((chatId, visuals) => {
128
+ this.listChanged(chatId);
122
129
  for (const clientId of this.attached.get(chatId) ?? []) {
123
130
  this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
124
131
  }
@@ -213,6 +220,10 @@ export class ChatCore {
213
220
  subscribe(clientId, sink) {
214
221
  return this.sinks.subscribe(clientId, sink);
215
222
  }
223
+ /* Sends one client an event of the host's own, such as a change to something it follows that no chat event carries. */
224
+ sendTo(clientId, event) {
225
+ this.sinks.to(clientId, event);
226
+ }
216
227
  /* Registers a chat; nothing is spawned until the first message. An existing chat answers its current info. */
217
228
  create(payload) {
218
229
  // 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.
@@ -237,19 +248,20 @@ export class ChatCore {
237
248
  const kind = stored?.info.provider ?? payload.provider ?? 'claude';
238
249
  const provider = this.providers.get(kind);
239
250
  const catalog = provider.catalog;
240
- const selection = catalog.normalize(stored?.info.selection ?? this.openingSelection(payload.chatId, kind) ?? payload.selection);
251
+ const selection = catalog.normalize(stored?.info.selection ?? this.hooks.openingSelection?.(payload.chatId, kind) ?? payload.selection);
241
252
  const cwd = stored?.info.cwd ?? payload.cwd ?? this.env.HOME ?? homedir();
242
- await this.admit(payload.chatId, cwd);
253
+ await this.hooks.admit?.(payload.chatId, cwd);
243
254
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
244
255
  // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
245
256
  const account = stored ? stored.info.account : storedAccount(kind, payload.account ?? this.composerPreferences.for(kind).account);
246
257
  if (!stored) {
247
258
  this.requireAccount(kind, account);
248
259
  }
260
+ const narrowed = (mode) => (this.hooks.runtimeModeFor ? this.hooks.runtimeModeFor(payload.chatId, mode) : mode);
249
261
  // Asked again for a chat on disk, since the host may have listed it since.
250
- const hidden = this.hiddenFor(payload.chatId) ? { hidden: true } : {};
262
+ const hidden = this.hooks.hiddenFor?.(payload.chatId) ? { hidden: true } : {};
251
263
  const info = stored?.info
252
- ? { ...withoutHidden(stored.info), runtimeMode: this.runtimeModeFor(payload.chatId, stored.info.runtimeMode), ...hidden }
264
+ ? { ...withoutHidden(stored.info), runtimeMode: narrowed(stored.info.runtimeMode), ...hidden }
253
265
  : {
254
266
  ...hidden,
255
267
  chatId: payload.chatId,
@@ -259,7 +271,7 @@ export class ChatCore {
259
271
  agentSessionId: payload.resume ?? null,
260
272
  model: null,
261
273
  selection,
262
- runtimeMode: this.runtimeModeFor(payload.chatId, payload.runtimeMode ?? 'full-access'),
274
+ runtimeMode: narrowed(payload.runtimeMode ?? 'full-access'),
263
275
  status: 'idle',
264
276
  running: false,
265
277
  activeTurnId: null,
@@ -270,10 +282,10 @@ export class ChatCore {
270
282
  const resume = stored ? await this.owedResume(payload.chatId, stored) : { resumeTurnId: null, reason: null };
271
283
  const claudeTitles = this.claudeTitles;
272
284
  const nameChat = this.nameChat;
273
- const base = this.envFor(payload.chatId, this.env);
285
+ const base = this.hooks.envFor ? this.hooks.envFor(payload.chatId, this.env) : this.env;
274
286
  this.logs.set(payload.chatId, await this.openLog(payload.chatId, stored ?? null));
275
- const promptNotes = this.promptNotesFor(payload.chatId);
276
- const references = this.referencesFor(payload.chatId);
287
+ const promptNotes = this.hooks.promptNotesFor?.(payload.chatId);
288
+ const references = this.hooks.referencesFor?.(payload.chatId);
277
289
  const session = new ChatSession({
278
290
  info,
279
291
  ...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
@@ -290,9 +302,9 @@ export class ChatCore {
290
302
  return env;
291
303
  },
292
304
  saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
293
- instructions: () => this.instructionsFor(payload.chatId),
294
- resumeNote: () => this.resumeNoteFor(payload.chatId),
295
- folders: () => this.foldersFor(payload.chatId),
305
+ instructions: () => (this.hooks.instructionsFor ? this.hooks.instructionsFor(payload.chatId) : this.instructions),
306
+ resumeNote: () => this.hooks.resumeNoteFor?.(payload.chatId) ?? null,
307
+ folders: () => this.hooks.foldersFor?.(payload.chatId) ?? [],
296
308
  ...(promptNotes ? { promptNotes } : {}),
297
309
  ...(references ? { references } : {}),
298
310
  ...(this.checkpoints ? { checkpoints: this.checkpoints } : {}),
@@ -302,7 +314,7 @@ export class ChatCore {
302
314
  save: () => this.persistNow(payload.chatId),
303
315
  persistSoon: () => this.persistSoon(payload.chatId),
304
316
  ...(kind === 'claude' && claudeTitles
305
- ? { readTitle: (agentSessionId) => claudeTitles.forSession(agentSessionId, this.claudeProjectsDirOf(session.info)) }
317
+ ? { readTitle: (agentSessionId) => claudeTitles.forSession(agentSessionId, this.claudeProjectsDirOf(session.info), session.info.cwd) }
306
318
  : {}),
307
319
  ...(kind === 'codex' && nameChat ? { nameThread: (input) => nameChat(kind, input) } : {}),
308
320
  ...(kind === 'claude' ? { subagentSettlement: (toolUseId) => this.subagents.claudeSettlement(payload.chatId, toolUseId) } : {}),
@@ -314,7 +326,7 @@ export class ChatCore {
314
326
  session.settleOwedResume();
315
327
  await session.settleOrphanedSubagents();
316
328
  }
317
- await this.opened(session, stored ?? null);
329
+ await this.hooks.opened?.(session, stored ?? null);
318
330
  return session.info;
319
331
  }
320
332
  async storedBackground(chatId) {
@@ -322,7 +334,12 @@ export class ChatCore {
322
334
  const items = record?.items ?? [];
323
335
  const background = items.filter((item) => isBackgroundWork(item) && (runsInBackground(item) || items.some((note) => note.id === `lost-background-${item.id}`)));
324
336
  const last = items.findLast((item) => item.kind === 'turn');
325
- return { turnId: background.at(-1)?.turnId ?? last?.id ?? null, items: background, commands: (record?.info.background ?? []).map(commandLabel) };
337
+ return {
338
+ turnId: background.at(-1)?.turnId ?? last?.id ?? null,
339
+ items: background,
340
+ commands: (record?.info.background ?? []).map(commandLabel),
341
+ goal: record?.info.goal?.status === 'active'
342
+ };
326
343
  }
327
344
  save(chatId) {
328
345
  return this.persistNow(chatId, true);
@@ -385,9 +402,10 @@ export class ChatCore {
385
402
  };
386
403
  }
387
404
  /*
388
- * `attach` with the chat's bookmarks and visuals. They are read before the client joins, so a
389
- * change written after the read reaches it as `chat.bookmarks` or `chat.visuals` and never falls
390
- * between the two. A file that cannot be read leaves its list out rather than the thread.
405
+ * `attach` with the chat's bookmarks and visuals. They are read before the client joins, and
406
+ * read again when either changed during the read, so a change reaches the client in the answer
407
+ * or as `chat.bookmarks` or `chat.visuals` and never falls between the two. A file that cannot be
408
+ * read leaves its list out rather than the thread.
391
409
  */
392
410
  async attachWithBookmarks(chatId, clientId, historyLimit, since) {
393
411
  this.require(chatId);
@@ -395,13 +413,22 @@ export class ChatCore {
395
413
  console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
396
414
  return undefined;
397
415
  };
398
- const [bookmarks, visuals] = await Promise.all([
399
- this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
400
- this.visuals?.list(chatId).catch(unreadable('visuals'))
401
- ]);
416
+ let bookmarks;
417
+ let visuals;
418
+ let changes;
419
+ do {
420
+ changes = this.listChanges.get(chatId);
421
+ [bookmarks, visuals] = await Promise.all([
422
+ this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
423
+ this.visuals?.list(chatId).catch(unreadable('visuals'))
424
+ ]);
425
+ } while (this.listChanges.get(chatId) !== changes);
402
426
  const result = this.attach(chatId, clientId, historyLimit, since);
403
427
  return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
404
428
  }
429
+ listChanged(chatId) {
430
+ this.listChanges.set(chatId, (this.listChanges.get(chatId) ?? 0) + 1);
431
+ }
405
432
  /* Marks a message of the chat's own thread; a message that already has one keeps it. */
406
433
  addBookmark(chatId, itemId, name) {
407
434
  const item = this.require(chatId).thread.get(itemId);
@@ -451,6 +478,7 @@ export class ChatCore {
451
478
  }
452
479
  return this.visuals;
453
480
  }
481
+ /* The page of a chat's thread before `cursor`, or its newest page without one. */
454
482
  history(chatId, cursor, limit) {
455
483
  const session = this.require(chatId);
456
484
  this.coalescers.get(chatId)?.flush();
@@ -500,7 +528,7 @@ export class ChatCore {
500
528
  if (row.status !== 'running') {
501
529
  return;
502
530
  }
503
- if (row.origin === 'ruimte') {
531
+ if (row.origin === HOST_TASK_ORIGIN) {
504
532
  throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
505
533
  }
506
534
  session.stopSubagent(toolUseId);
@@ -510,6 +538,10 @@ export class ChatCore {
510
538
  await this.loaded(chatId);
511
539
  this.require(chatId).stopTask(taskId);
512
540
  }
541
+ async goal(chatId, action) {
542
+ await this.loaded(chatId);
543
+ await this.require(chatId).goal(action);
544
+ }
513
545
  /* The whole conversation of a subagent of the CLI's own, for an agent that reads it as text. */
514
546
  async subagentItems(chatId, toolUseId) {
515
547
  return this.subagents.readAll(chatId, toolUseId);
@@ -518,6 +550,55 @@ export class ChatCore {
518
550
  async hasStored(chatId) {
519
551
  return this.chats.has(chatId) || (await this.store?.has(chatId)) === true;
520
552
  }
553
+ /*
554
+ * Writes the whole record of a chat nobody has loaded, such as a fork's thread before anyone opens it.
555
+ * The files its messages and generated images point at are copied into its own folder, so removing
556
+ * the chat they came from leaves them.
557
+ */
558
+ async writeRecord(chatId, info, items, preambles = []) {
559
+ if (!this.store) {
560
+ throw new ChatError('chat-unsupported', 'This host keeps no chat records');
561
+ }
562
+ if (this.chats.has(chatId) || this.creating.has(chatId)) {
563
+ throw new ChatError('chat-busy', `Chat ${chatId} is already loaded`);
564
+ }
565
+ const copies = new Map();
566
+ const copy = (attachment) => {
567
+ let pending = copies.get(attachment.id);
568
+ if (!pending) {
569
+ pending = this.attachments.copy(chatId, attachment);
570
+ copies.set(attachment.id, pending);
571
+ }
572
+ return pending;
573
+ };
574
+ const copied = await Promise.all(items.map(async (item) => {
575
+ if (item.kind === 'user' && item.attachments?.length) {
576
+ return { ...item, attachments: await Promise.all(item.attachments.map(copy)) };
577
+ }
578
+ const generated = generatedImageAttachment(item);
579
+ if (item.kind === 'tool' && generated) {
580
+ return { ...item, input: { ...item.input, attachment: await copy(generated) } };
581
+ }
582
+ return item;
583
+ }));
584
+ await this.store.write(chatId, info, copied, { seq: 0, resetSeq: 0 }, preambles);
585
+ }
586
+ /*
587
+ * Removes what a chat nobody loaded keeps on disk: its record and log, attachments, bookmarks and
588
+ * visuals. False, with nothing removed, for a chat that is loaded or loading; `kill` removes that one.
589
+ */
590
+ async deleteRecord(chatId) {
591
+ if (this.chats.has(chatId) || this.creating.has(chatId)) {
592
+ return false;
593
+ }
594
+ await Promise.all([
595
+ this.store?.delete(chatId),
596
+ this.attachments.removeAll(chatId),
597
+ this.bookmarks?.removeChat(chatId),
598
+ this.visuals?.removeChat(chatId)
599
+ ]);
600
+ return true;
601
+ }
521
602
  /* The turn a chat took last, read off the record without loading a chat nobody has; null before its first. */
522
603
  async lastTurn(chatId) {
523
604
  const session = this.chats.get(chatId);
@@ -628,6 +709,13 @@ export class ChatCore {
628
709
  const session = this.require(chatId);
629
710
  return session.skills(() => this.skillIndex.list(session.info.provider, session.info.cwd));
630
711
  }
712
+ /* Ends the chat's CLI and keeps its conversation; the next message resumes it in a process that reads its skills and plugins again. */
713
+ async restart(chatId) {
714
+ const session = this.require(chatId);
715
+ const ended = session.restart();
716
+ this.skillIndex.forget(session.info.provider, session.info.cwd);
717
+ await ended;
718
+ }
631
719
  compact(chatId) {
632
720
  const session = this.require(chatId);
633
721
  if (session.busy) {
@@ -641,10 +729,10 @@ export class ChatCore {
641
729
  if (session.busy && !force) {
642
730
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
643
731
  }
644
- await this.clearing(chatId);
732
+ await this.hooks.clearing?.(chatId);
645
733
  const ended = session.clear(force);
646
734
  // Before the record is written, so what it takes along from the record is gone from the write as well.
647
- const cleared = this.cleared(chatId);
735
+ const cleared = this.hooks.cleared?.(chatId);
648
736
  // A debounced write still waiting holds the old thread and must not land after the empty one.
649
737
  this.cancelWaiting(chatId);
650
738
  // Folded right away: every line before the reset describes a thread that is gone.
@@ -683,6 +771,15 @@ export class ChatCore {
683
771
  throw new ChatError('request-not-found', `No question to dismiss under ${itemId}`);
684
772
  }
685
773
  }
774
+ /* Holds a chat at `needs-you` for what the host asks a person; false for a chat that is not here. */
775
+ holdForPerson(chatId, key) {
776
+ const session = this.chats.get(chatId);
777
+ session?.holdForPerson(key);
778
+ return session !== undefined;
779
+ }
780
+ releaseHold(chatId, key) {
781
+ this.chats.get(chatId)?.releaseHold(key);
782
+ }
686
783
  /* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
687
784
  async kill(chatId) {
688
785
  await this.loaded(chatId);
@@ -703,9 +800,10 @@ export class ChatCore {
703
800
  this.activity.delete(chatId);
704
801
  this.chats.delete(chatId);
705
802
  this.attached.delete(chatId);
803
+ this.listChanges.delete(chatId);
706
804
  this.announced.delete(chatId);
707
805
  const snapshot = session.thread.snapshot();
708
- this.forgotten(chatId);
806
+ this.hooks.forgotten?.(chatId);
709
807
  await writing;
710
808
  await ended;
711
809
  await Promise.all([
@@ -713,7 +811,7 @@ export class ChatCore {
713
811
  this.attachments.removeAll(chatId),
714
812
  this.bookmarks?.removeChat(chatId),
715
813
  this.visuals?.removeChat(chatId),
716
- this.removed(chatId, snapshot)
814
+ this.hooks.removed?.(chatId, snapshot)
717
815
  ]);
718
816
  }
719
817
  /*
@@ -754,7 +852,7 @@ export class ChatCore {
754
852
  }
755
853
  await this.create({ chatId });
756
854
  }
757
- await this.chats.get(chatId)?.resume(turnId, attempt, await this.resumeWords(chatId));
855
+ await this.chats.get(chatId)?.resume(turnId, attempt, (await this.hooks.resumeWords?.(chatId)) ?? DEFAULT_RESUME_WORDS);
758
856
  }
759
857
  /*
760
858
  * Takes up a turn that stopped on a limit, once its reset or its retry came. Loads the chat when
@@ -773,7 +871,7 @@ export class ChatCore {
773
871
  return;
774
872
  }
775
873
  const turn = session.thread.get(turnId);
776
- const ended = this.endedAt(chatId);
874
+ const ended = this.hooks.endedAt?.(chatId) ?? null;
777
875
  if (turn !== undefined && ended !== null && ended >= turn.createdAt) {
778
876
  session.lapseResume();
779
877
  return;
@@ -803,6 +901,10 @@ export class ChatCore {
803
901
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
804
902
  continue;
805
903
  }
904
+ // Parsing the whole thread of every settled chat was most of the cost of a start.
905
+ if (await this.store.settled(chatId).catch(() => false)) {
906
+ continue;
907
+ }
806
908
  const record = await this.store.read(chatId).catch(() => null);
807
909
  const unowed = record?.info.resumeAt !== undefined && this.limitResume?.owed(chatId) === false;
808
910
  if (record !== null && (interruptedTurn(record) !== null || unowed)) {
@@ -847,7 +949,7 @@ export class ChatCore {
847
949
  const log = this.logs.get(chatId);
848
950
  // Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
849
951
  try {
850
- this.store.writeSync(chatId, info, items, { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 }, session.preambles, this.recordExtras(chatId), session.uiAccessRecord);
952
+ this.store.writeSync(chatId, info, items, { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 }, session.preambles, this.hooks.recordExtras?.(chatId) ?? {}, session.uiAccessRecord);
851
953
  }
852
954
  catch (e) {
853
955
  console.error(`Chat record for ${chatId} failed:`, errorText(e));
@@ -862,10 +964,6 @@ export class ChatCore {
862
964
  async loaded(chatId) {
863
965
  await this.creating.get(chatId)?.catch(() => undefined);
864
966
  }
865
- persist(chatId) {
866
- this.cancelWaiting(chatId);
867
- void this.persistNow(chatId).catch((e) => console.error(`Chat record for ${chatId} failed:`, errorText(e)));
868
- }
869
967
  require(chatId) {
870
968
  const session = this.chats.get(chatId);
871
969
  if (!session) {
@@ -873,74 +971,9 @@ export class ChatCore {
873
971
  }
874
972
  return session;
875
973
  }
876
- /* What the agent is told once at the start of every process of this chat; null for nothing. */
877
- instructionsFor(_chatId) {
878
- return this.instructions;
879
- }
880
- /* What of the instructions a resumed thread hears again in front of its next prompt, for a CLI that ignores them on a resume. */
881
- resumeNoteFor(_chatId) {
882
- return null;
883
- }
884
- /*
885
- * Folders outside its working directory a chat's agent may reach without asking, absolute. Asked
886
- * before every turn, so a host can hand one over for as long as it is needed: a change starts the
887
- * next turn in a new process that resumes the thread, and never cuts a turn in flight.
888
- */
889
- foldersFor(_chatId) {
890
- return [];
891
- }
892
- /* Whether no list of chats shows this one, which `ChatInfo.hidden` says to every client. */
893
- hiddenFor(_chatId) {
894
- return false;
895
- }
896
- /* What the host says in front of a chat's next real prompt, beside what the chat keeps itself. */
897
- promptNotesFor(_chatId) {
898
- return undefined;
899
- }
900
- /* How the chats a person attached to a message are named to the agent; without it they are dropped. */
901
- referencesFor(_chatId) {
902
- return undefined;
903
- }
904
- /* The environment of this chat's CLI before its account adds its own. */
905
- envFor(_chatId, base) {
906
- return base;
907
- }
908
- /* Throws for a folder this chat may not start in; asked every time a chat is loaded. */
909
- async admit(_chatId, _cwd) { }
910
- /* The model a new chat starts on when the host already knows; the explicit one still wins. */
911
- openingSelection(_chatId, _kind) {
912
- return undefined;
913
- }
914
- /* The mode a chat runs in, which a host may narrow whatever its record or its client says. */
915
- runtimeModeFor(_chatId, mode) {
916
- return mode;
917
- }
918
- /* A chat just loaded or made, its stored turns settled; whatever the host lays down in it goes here. */
919
- async opened(_session, _stored) { }
920
- /* What the host keeps in the chat's record beside the thread. */
921
- recordExtras(_chatId) {
922
- return {};
923
- }
924
- /* The chat was cleared; what runs before the first await is in the record written right after. */
925
- async cleared(_chatId) { }
926
- async clearing(_chatId) { }
927
- /* The chat left memory for good, before its files go. */
928
- forgotten(_chatId) { }
929
- /* The chat was removed; what the host keeps of it goes along. */
930
- async removed(_chatId, _chat) { }
931
- /* An event of a chat went out, after the observers and before the clients reading its thread. */
932
- broadcasted(_chatId, _event) { }
933
- /* When whoever opened this chat stopped it; a turn from before that is never taken up again. */
934
- endedAt(_chatId) {
935
- return null;
936
- }
937
- /* Why a turn the host went down in stays down when `onInterruptedRun` says no, in the words of the note it ends with. */
938
- unownedReason(_chatId) {
939
- return 'nothing holds this chat any more';
940
- }
941
- /* How a turn the host went down in is taken up in this chat. */
942
- async resumeWords(_chatId) {
943
- return DEFAULT_RESUME_WORDS;
974
+ persist(chatId) {
975
+ this.cancelWaiting(chatId);
976
+ void this.persistNow(chatId).catch((e) => console.error(`Chat record for ${chatId} failed:`, errorText(e)));
944
977
  }
945
978
  /* A write that may wait: a long thread is not rewritten for every small change. */
946
979
  persistSoon(chatId) {
@@ -979,7 +1012,7 @@ export class ChatCore {
979
1012
  }
980
1013
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
981
1014
  this.coalescers.get(chatId)?.flush();
982
- const extras = this.recordExtras(chatId);
1015
+ const extras = this.hooks.recordExtras?.(chatId) ?? {};
983
1016
  const uiAccess = session.uiAccessRecord;
984
1017
  const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
985
1018
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
@@ -1024,7 +1057,7 @@ export class ChatCore {
1024
1057
  for (const sink of this.observers) {
1025
1058
  sink({ event: 'chat.event', payload });
1026
1059
  }
1027
- this.broadcasted(chatId, event);
1060
+ this.hooks.broadcasted?.(chatId, event);
1028
1061
  for (const clientId of this.attached.get(chatId) ?? []) {
1029
1062
  this.sinks.to(clientId, { event: 'chat.event', payload });
1030
1063
  }
@@ -1047,14 +1080,15 @@ export class ChatCore {
1047
1080
  */
1048
1081
  announceStatus(chatId, info) {
1049
1082
  // A limit, the resume owed after it and subagents still at work are what a header shows of an idle chat, so they count as a change too,
1050
- // and so does a request opening or settling while the chat already waits on another.
1083
+ // and so does a request opening or settling while the chat already waits on another, and a goal set, moving on or gone.
1051
1084
  const said = JSON.stringify([
1052
1085
  info.status,
1053
1086
  info.limit ?? null,
1054
1087
  info.resumeAt ?? null,
1055
1088
  info.resumeAtReset ?? null,
1056
1089
  info.delegating ?? false,
1057
- info.requests?.map((request) => request.requestId) ?? []
1090
+ info.requests?.map((request) => request.requestId) ?? [],
1091
+ info.goal === undefined ? null : [info.goal.status, info.goal.objective]
1058
1092
  ]);
1059
1093
  if (this.announced.get(chatId) === said) {
1060
1094
  return;
@@ -1094,7 +1128,7 @@ export class ChatCore {
1094
1128
  if (stored.info.agentSessionId === null) {
1095
1129
  return skip('the agent had not started a session to resume yet');
1096
1130
  }
1097
- const ended = this.endedAt(chatId);
1131
+ const ended = this.hooks.endedAt?.(chatId) ?? null;
1098
1132
  if (ended !== null && ended >= turn.createdAt) {
1099
1133
  return skip('the agent that opened it was stopped');
1100
1134
  }
@@ -1103,7 +1137,7 @@ export class ChatCore {
1103
1137
  }
1104
1138
  try {
1105
1139
  const owed = await this.onInterruptedRun({ chatId, turnId: turn.id, attempt: (turn.attempt ?? 1) + (turn.resumePending ? 0 : 1) });
1106
- return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.unownedReason(chatId));
1140
+ return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.hooks.unownedReason?.(chatId) ?? 'nothing holds this chat any more');
1107
1141
  }
1108
1142
  catch (e) {
1109
1143
  console.error(`Owing a resume for chat ${chatId} failed:`, errorText(e));
@@ -48,7 +48,7 @@ export class ChatLog {
48
48
  constructor(path, state = { seq: 0, resetSeq: 0, lines: [] }) {
49
49
  this.path = path;
50
50
  this.lines = state.lines;
51
- this.bytes = state.lines.reduce((sum, line) => sum + JSON.stringify(line).length + 1, 0);
51
+ this.bytes = state.lines.reduce((sum, line) => sum + Buffer.byteLength(JSON.stringify(line)) + 1, 0);
52
52
  this.seqValue = Math.max(state.seq, state.lines.at(-1)?.seq ?? 0);
53
53
  this.resetSeqValue = state.resetSeq;
54
54
  const first = state.lines[0];
@@ -84,7 +84,7 @@ export class ChatLog {
84
84
  }
85
85
  const text = `${JSON.stringify(line)}\n`;
86
86
  this.lines.push(line);
87
- this.bytes += text.length;
87
+ this.bytes += Buffer.byteLength(text);
88
88
  if (this.path !== null) {
89
89
  try {
90
90
  this.fd ??= this.open(this.path);
@@ -122,7 +122,7 @@ export class ChatLog {
122
122
  this.lines = this.lines.filter((line) => line.seq > seq);
123
123
  this.base = seq;
124
124
  const text = this.lines.map((line) => `${JSON.stringify(line)}\n`).join('');
125
- this.bytes = text.length;
125
+ this.bytes = Buffer.byteLength(text);
126
126
  if (this.path === null) {
127
127
  return;
128
128
  }
@@ -18,6 +18,12 @@ export interface ChatSpawnOptions {
18
18
  }
19
19
  export type SpawnChatProcess = (options: ChatSpawnOptions) => ChatProcess;
20
20
  export declare function spawnChatProcess(options: ChatSpawnOptions): ChatProcess;
21
+ export interface ProcessTreeKillOptions {
22
+ platform?: NodeJS.Platform;
23
+ kill?(pid: number, signal: NodeJS.Signals): void;
24
+ run?(command: string[]): void;
25
+ }
26
+ export declare function killProcessTree(pid: number, signal: 'SIGTERM' | 'SIGKILL', options?: ProcessTreeKillOptions): void;
21
27
  export declare class StreamTail {
22
28
  readonly done: Promise<void>;
23
29
  private text;
@@ -25,6 +31,21 @@ export declare class StreamTail {
25
31
  lines(): string | null;
26
32
  private read;
27
33
  }
34
+ export declare const MAX_LINE_LENGTH = 64000000;
35
+ export declare class LineSplitter {
36
+ private readonly onLine;
37
+ private readonly onOverflow;
38
+ private readonly max;
39
+ private parts;
40
+ private length;
41
+ private dropping;
42
+ constructor(onLine: (line: string) => void, onOverflow: () => void, max?: number);
43
+ push(text: string): void;
44
+ end(): void;
45
+ private add;
46
+ private take;
47
+ }
48
+ export declare function readStreamLines(stream: ReadableStream<Uint8Array>, onLine: (line: string) => void, onOverflow: () => void, max?: number): Promise<void>;
28
49
  export interface ChatChildOptions extends Omit<ChatSpawnOptions, 'onExit'> {
29
50
  spawn?: SpawnChatProcess;
30
51
  onExit(exitCode: number | null, stderr: string | null): void;