@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
@@ -3,6 +3,7 @@ import { join } from 'node:path';
3
3
  import {
4
4
  ChatAttachmentUploadsSchema,
5
5
  generatedImageAttachment,
6
+ HOST_TASK_ORIGIN,
6
7
  type AgentKind,
7
8
  type ChatAttachment,
8
9
  type ChatAttachResult,
@@ -13,6 +14,7 @@ import {
13
14
  type ChatCreatePayload,
14
15
  type ChatEvent,
15
16
  type ChatHistoryResult,
17
+ type ChatGoalAction,
16
18
  type ChatInfo,
17
19
  type ChatItem,
18
20
  type ChatQueuedMessage,
@@ -102,6 +104,57 @@ const DEFAULT_RESUME_WORDS: ResumeWords = {
102
104
  preamble: null
103
105
  };
104
106
 
107
+ /*
108
+ * What a host adds to its chats. Every hook may be left out, and does nothing without it; none of them
109
+ * knows the client, so a check of who asks belongs on the wire.
110
+ */
111
+ export interface ChatCoreHooks {
112
+ /* Throws for a folder this chat may not start in; asked every time a chat is loaded, from disk too. */
113
+ admit?(chatId: string, cwd: string): Promise<void>;
114
+ /* The model a new chat starts on when the host already knows; the explicit one still wins. */
115
+ openingSelection?(chatId: string, kind: AgentKind): ModelSelection | undefined;
116
+ /* The mode a chat runs in, which a host may narrow whatever its record or its client says. */
117
+ runtimeModeFor?(chatId: string, mode: RuntimeMode): RuntimeMode;
118
+ /* What the agent is told once at the start of every process of this chat, in place of `instructions`; null for nothing. */
119
+ instructionsFor?(chatId: string): string | null;
120
+ /* What of the instructions a resumed thread hears again in front of its next prompt, for a CLI that ignores them on a resume. */
121
+ resumeNoteFor?(chatId: string): string | null;
122
+ /*
123
+ * Folders outside its working directory a chat's agent may reach without asking, absolute. Asked
124
+ * before every turn, so a host can hand one over for as long as it is needed: a change starts the
125
+ * next turn in a new process that resumes the thread, and never cuts a turn in flight.
126
+ */
127
+ foldersFor?(chatId: string): readonly string[];
128
+ /* Whether no list of chats shows this one, which `ChatInfo.hidden` says to every client. */
129
+ hiddenFor?(chatId: string): boolean;
130
+ /* What the host says in front of a chat's next real prompt, beside what the chat keeps itself. */
131
+ promptNotesFor?(chatId: string): PromptNotes | undefined;
132
+ /* How the chats a person attached to a message are named to the agent; without it they are dropped. */
133
+ referencesFor?(chatId: string): ChatReferences | undefined;
134
+ /* The environment of this chat's CLI before its account adds its own. */
135
+ envFor?(chatId: string, base: Record<string, string>): Record<string, string | undefined>;
136
+ /* A chat just loaded or made, its stored turns settled; whatever the host lays down in it goes here. */
137
+ opened?(session: ChatSession, stored: ChatRecord | null): Promise<void>;
138
+ /* What the host keeps in the chat's record beside the thread. */
139
+ recordExtras?(chatId: string): ChatRecordExtras;
140
+ /* A clear is about to start; it waits for this. */
141
+ clearing?(chatId: string): Promise<void>;
142
+ /* The chat was cleared; what runs before the first await is in the record written right after. */
143
+ cleared?(chatId: string): Promise<void>;
144
+ /* The chat left memory for good, before its files go. */
145
+ forgotten?(chatId: string): void;
146
+ /* The chat was removed; what the host keeps of it goes along. */
147
+ removed?(chatId: string, chat: { info: ChatInfo; items: ChatItem[] }): Promise<void>;
148
+ /* An event of a chat went out, after the observers and before the clients reading its thread. */
149
+ broadcasted?(chatId: string, event: ChatEvent): void;
150
+ /* When whoever opened this chat stopped it; a turn from before that is never taken up again. */
151
+ endedAt?(chatId: string): number | null;
152
+ /* Why a turn the host went down in stays down when `onInterruptedRun` says no, in the words of the note it ends with. */
153
+ unownedReason?(chatId: string): string;
154
+ /* How a turn the host went down in is taken up in this chat. */
155
+ resumeWords?(chatId: string): Promise<ResumeWords>;
156
+ }
157
+
105
158
  export interface ChatCoreOptions {
106
159
  intelligentUi?: ChatUiHost;
107
160
  // The info string that marks a UI fence in a reply, for a host that keeps a name of its own.
@@ -132,7 +185,7 @@ export interface ChatCoreOptions {
132
185
  // What a running turn says about the plan its CLI runs on; the usage monitor takes it from here.
133
186
  onLimits?: (update: LimitsUpdate) => void;
134
187
  // Where Claude Code's own name for a session is read, in the projects folder of the chat's account; the other CLIs write none down.
135
- claudeTitles?: { forSession(agentSessionId: string, projectsDir: string): Promise<string | null> };
188
+ claudeTitles?: { forSession(agentSessionId: string, projectsDir: string, cwd: string): Promise<string | null> };
136
189
  // A name for a Codex chat, asked of a one-shot CLI: its app-server names no thread on its own.
137
190
  nameChat?: (provider: AgentKind, input: ChatTitleInput) => Promise<string | null>;
138
191
  // Where a subagent's whole conversation is read and how its growth is noticed; a test hands in fakes.
@@ -153,28 +206,33 @@ export interface ChatCoreOptions {
153
206
  };
154
207
  // The accounts a chat may run under; without them every chat runs under its CLI's default account.
155
208
  accounts?: AccountLaunches;
209
+ hooks?: ChatCoreHooks;
156
210
  }
157
211
 
158
212
  /*
159
213
  * The chats of one host: a `ChatSession` per chat, its record and its log, and the clients that read
160
214
  * them. Everything here holds for any host. What a host adds (notes for the agent, rows of its own,
161
- * what a clear or a removal takes along) it adds by overriding the protected methods below, which
162
- * do nothing of their own.
215
+ * what a clear or a removal takes along) it adds through `ChatCoreOptions.hooks`. A subclass may add
216
+ * methods of its own over the public ones; the state behind them stays here.
163
217
  */
164
218
  export class ChatCore {
165
- protected readonly providers: ProviderRegistry;
166
- protected readonly store: ChatStore | null;
219
+ // The stores the host handed in, which a subclass reads to copy a chat's bookmarks or visuals.
167
220
  protected readonly bookmarks: BookmarkStore | null;
168
221
  protected readonly visuals: VisualStore | null;
169
- protected readonly accounts: AccountLaunches | null;
170
- protected readonly attachments: AttachmentStore;
171
- protected readonly env: Record<string, string>;
172
- protected readonly chats = new Map<string, ChatSession>();
173
- protected readonly creating = new Map<string, Promise<ChatInfo>>();
174
- protected readonly sinks = new ClientSinks<AgentEvent>((clientId) => this.composerPreferences.forget(clientId));
175
- protected readonly attached = new Map<string, Set<string>>();
176
- protected readonly coalescers = new Map<string, DeltaCoalescer>();
177
- protected readonly subagents: SubagentReader;
222
+ private readonly providers: ProviderRegistry;
223
+ private readonly store: ChatStore | null;
224
+ private readonly accounts: AccountLaunches | null;
225
+ private readonly attachments: AttachmentStore;
226
+ private readonly env: Record<string, string>;
227
+ private readonly hooks: ChatCoreHooks;
228
+ private readonly chats = new Map<string, ChatSession>();
229
+ private readonly creating = new Map<string, Promise<ChatInfo>>();
230
+ private readonly sinks = new ClientSinks<AgentEvent>((clientId) => this.composerPreferences.forget(clientId));
231
+ private readonly attached = new Map<string, Set<string>>();
232
+ // How often a chat's bookmarks or visuals changed, so an attach can tell that its read went stale before it joined.
233
+ private readonly listChanges = new Map<string, number>();
234
+ private readonly coalescers = new Map<string, DeltaCoalescer>();
235
+ private readonly subagents: SubagentReader;
178
236
  /* Where Claude Code keeps the projects of its default account on this machine; empty when it has none. */
179
237
  readonly claudeProjectsDir: string;
180
238
  readonly composerPreferences = new ComposerPreferences();
@@ -222,6 +280,7 @@ export class ChatCore {
222
280
  this.nameChat = options.nameChat ?? null;
223
281
  this.onInterruptedRun = options.onInterruptedRun ?? null;
224
282
  this.limitResume = options.limitResume ?? null;
283
+ this.hooks = options.hooks ?? {};
225
284
  this.commands = {
226
285
  ...(options.command ? { claude: options.command } : {}),
227
286
  ...(options.codexCommand ? { codex: options.codexCommand } : {})
@@ -231,11 +290,13 @@ export class ChatCore {
231
290
  this.env = definedEnv(options.env ?? process.env);
232
291
  // 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.
233
292
  this.bookmarks?.listen((chatId, bookmarks) => {
293
+ this.listChanged(chatId);
234
294
  for (const clientId of this.attached.get(chatId) ?? []) {
235
295
  this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
236
296
  }
237
297
  });
238
298
  this.visuals?.listen((chatId, visuals) => {
299
+ this.listChanged(chatId);
239
300
  for (const clientId of this.attached.get(chatId) ?? []) {
240
301
  this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
241
302
  }
@@ -340,6 +401,11 @@ export class ChatCore {
340
401
  return this.sinks.subscribe(clientId, sink);
341
402
  }
342
403
 
404
+ /* Sends one client an event of the host's own, such as a change to something it follows that no chat event carries. */
405
+ sendTo(clientId: string, event: AgentEvent): void {
406
+ this.sinks.to(clientId, event);
407
+ }
408
+
343
409
  /* Registers a chat; nothing is spawned until the first message. An existing chat answers its current info. */
344
410
  create(payload: ChatCreatePayload): Promise<ChatInfo> {
345
411
  // 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.
@@ -365,19 +431,20 @@ export class ChatCore {
365
431
  const kind = stored?.info.provider ?? payload.provider ?? 'claude';
366
432
  const provider = this.providers.get(kind);
367
433
  const catalog = provider.catalog;
368
- const selection = catalog.normalize(stored?.info.selection ?? this.openingSelection(payload.chatId, kind) ?? payload.selection);
434
+ const selection = catalog.normalize(stored?.info.selection ?? this.hooks.openingSelection?.(payload.chatId, kind) ?? payload.selection);
369
435
  const cwd = stored?.info.cwd ?? payload.cwd ?? this.env.HOME ?? homedir();
370
- await this.admit(payload.chatId, cwd);
436
+ await this.hooks.admit?.(payload.chatId, cwd);
371
437
  // A thread on disk keeps its account too. One it lost still opens, and says why on its next turn.
372
438
  // A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
373
439
  const account = stored ? stored.info.account : storedAccount(kind, payload.account ?? this.composerPreferences.for(kind).account);
374
440
  if (!stored) {
375
441
  this.requireAccount(kind, account);
376
442
  }
443
+ const narrowed = (mode: RuntimeMode): RuntimeMode => (this.hooks.runtimeModeFor ? this.hooks.runtimeModeFor(payload.chatId, mode) : mode);
377
444
  // Asked again for a chat on disk, since the host may have listed it since.
378
- const hidden = this.hiddenFor(payload.chatId) ? { hidden: true } : {};
445
+ const hidden = this.hooks.hiddenFor?.(payload.chatId) ? { hidden: true } : {};
379
446
  const info: ChatInfo = stored?.info
380
- ? { ...withoutHidden(stored.info), runtimeMode: this.runtimeModeFor(payload.chatId, stored.info.runtimeMode), ...hidden }
447
+ ? { ...withoutHidden(stored.info), runtimeMode: narrowed(stored.info.runtimeMode), ...hidden }
381
448
  : {
382
449
  ...hidden,
383
450
  chatId: payload.chatId,
@@ -387,7 +454,7 @@ export class ChatCore {
387
454
  agentSessionId: payload.resume ?? null,
388
455
  model: null,
389
456
  selection,
390
- runtimeMode: this.runtimeModeFor(payload.chatId, payload.runtimeMode ?? 'full-access'),
457
+ runtimeMode: narrowed(payload.runtimeMode ?? 'full-access'),
391
458
  status: 'idle',
392
459
  running: false,
393
460
  activeTurnId: null,
@@ -398,10 +465,10 @@ export class ChatCore {
398
465
  const resume: ResumeDecision = stored ? await this.owedResume(payload.chatId, stored) : { resumeTurnId: null, reason: null };
399
466
  const claudeTitles = this.claudeTitles;
400
467
  const nameChat = this.nameChat;
401
- const base = this.envFor(payload.chatId, this.env);
468
+ const base = this.hooks.envFor ? this.hooks.envFor(payload.chatId, this.env) : this.env;
402
469
  this.logs.set(payload.chatId, await this.openLog(payload.chatId, stored ?? null));
403
- const promptNotes = this.promptNotesFor(payload.chatId);
404
- const references = this.referencesFor(payload.chatId);
470
+ const promptNotes = this.hooks.promptNotesFor?.(payload.chatId);
471
+ const references = this.hooks.referencesFor?.(payload.chatId);
405
472
  const session = new ChatSession({
406
473
  info,
407
474
  ...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
@@ -418,9 +485,9 @@ export class ChatCore {
418
485
  return env;
419
486
  },
420
487
  saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
421
- instructions: () => this.instructionsFor(payload.chatId),
422
- resumeNote: () => this.resumeNoteFor(payload.chatId),
423
- folders: () => this.foldersFor(payload.chatId),
488
+ instructions: () => (this.hooks.instructionsFor ? this.hooks.instructionsFor(payload.chatId) : this.instructions),
489
+ resumeNote: () => this.hooks.resumeNoteFor?.(payload.chatId) ?? null,
490
+ folders: () => this.hooks.foldersFor?.(payload.chatId) ?? [],
424
491
  ...(promptNotes ? { promptNotes } : {}),
425
492
  ...(references ? { references } : {}),
426
493
  ...(this.checkpoints ? { checkpoints: this.checkpoints } : {}),
@@ -430,7 +497,7 @@ export class ChatCore {
430
497
  save: () => this.persistNow(payload.chatId),
431
498
  persistSoon: () => this.persistSoon(payload.chatId),
432
499
  ...(kind === 'claude' && claudeTitles
433
- ? { readTitle: (agentSessionId: string) => claudeTitles.forSession(agentSessionId, this.claudeProjectsDirOf(session.info)) }
500
+ ? { readTitle: (agentSessionId: string) => claudeTitles.forSession(agentSessionId, this.claudeProjectsDirOf(session.info), session.info.cwd) }
434
501
  : {}),
435
502
  ...(kind === 'codex' && nameChat ? { nameThread: (input: ChatTitleInput) => nameChat(kind, input) } : {}),
436
503
  ...(kind === 'claude' ? { subagentSettlement: (toolUseId: string) => this.subagents.claudeSettlement(payload.chatId, toolUseId) } : {}),
@@ -442,11 +509,11 @@ export class ChatCore {
442
509
  session.settleOwedResume();
443
510
  await session.settleOrphanedSubagents();
444
511
  }
445
- await this.opened(session, stored ?? null);
512
+ await this.hooks.opened?.(session, stored ?? null);
446
513
  return session.info;
447
514
  }
448
515
 
449
- async storedBackground(chatId: string): Promise<{ turnId: string | null; items: BackgroundWork[]; commands: string[] }> {
516
+ async storedBackground(chatId: string): Promise<{ turnId: string | null; items: BackgroundWork[]; commands: string[]; goal: boolean }> {
450
517
  const record = await this.store?.read(chatId);
451
518
  const items = record?.items ?? [];
452
519
  const background = items.filter(
@@ -454,7 +521,12 @@ export class ChatCore {
454
521
  isBackgroundWork(item) && (runsInBackground(item) || items.some((note) => note.id === `lost-background-${item.id}`))
455
522
  );
456
523
  const last = items.findLast((item) => item.kind === 'turn');
457
- return { turnId: background.at(-1)?.turnId ?? last?.id ?? null, items: background, commands: (record?.info.background ?? []).map(commandLabel) };
524
+ return {
525
+ turnId: background.at(-1)?.turnId ?? last?.id ?? null,
526
+ items: background,
527
+ commands: (record?.info.background ?? []).map(commandLabel),
528
+ goal: record?.info.goal?.status === 'active'
529
+ };
458
530
  }
459
531
 
460
532
  save(chatId: string): Promise<void> {
@@ -526,9 +598,10 @@ export class ChatCore {
526
598
  }
527
599
 
528
600
  /*
529
- * `attach` with the chat's bookmarks and visuals. They are read before the client joins, so a
530
- * change written after the read reaches it as `chat.bookmarks` or `chat.visuals` and never falls
531
- * between the two. A file that cannot be read leaves its list out rather than the thread.
601
+ * `attach` with the chat's bookmarks and visuals. They are read before the client joins, and
602
+ * read again when either changed during the read, so a change reaches the client in the answer
603
+ * or as `chat.bookmarks` or `chat.visuals` and never falls between the two. A file that cannot be
604
+ * read leaves its list out rather than the thread.
532
605
  */
533
606
  async attachWithBookmarks(chatId: string, clientId: string, historyLimit?: number, since?: number): Promise<ChatAttachResult> {
534
607
  this.require(chatId);
@@ -536,14 +609,24 @@ export class ChatCore {
536
609
  console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
537
610
  return undefined;
538
611
  };
539
- const [bookmarks, visuals] = await Promise.all([
540
- this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
541
- this.visuals?.list(chatId).catch(unreadable('visuals'))
542
- ]);
612
+ let bookmarks: ChatBookmark[] | undefined;
613
+ let visuals: ChatVisual[] | undefined;
614
+ let changes: number | undefined;
615
+ do {
616
+ changes = this.listChanges.get(chatId);
617
+ [bookmarks, visuals] = await Promise.all([
618
+ this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
619
+ this.visuals?.list(chatId).catch(unreadable('visuals'))
620
+ ]);
621
+ } while (this.listChanges.get(chatId) !== changes);
543
622
  const result = this.attach(chatId, clientId, historyLimit, since);
544
623
  return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
545
624
  }
546
625
 
626
+ private listChanged(chatId: string): void {
627
+ this.listChanges.set(chatId, (this.listChanges.get(chatId) ?? 0) + 1);
628
+ }
629
+
547
630
  /* Marks a message of the chat's own thread; a message that already has one keeps it. */
548
631
  addBookmark(chatId: string, itemId: string, name?: string): Promise<ChatBookmark[]> {
549
632
  const item = this.require(chatId).thread.get(itemId);
@@ -601,7 +684,8 @@ export class ChatCore {
601
684
  return this.visuals;
602
685
  }
603
686
 
604
- history(chatId: string, cursor: string, limit?: number): ChatHistoryResult {
687
+ /* The page of a chat's thread before `cursor`, or its newest page without one. */
688
+ history(chatId: string, cursor?: string, limit?: number): ChatHistoryResult {
605
689
  const session = this.require(chatId);
606
690
  this.coalescers.get(chatId)?.flush();
607
691
  return session.thread.history(limit, cursor);
@@ -654,7 +738,7 @@ export class ChatCore {
654
738
  if (row.status !== 'running') {
655
739
  return;
656
740
  }
657
- if (row.origin === 'ruimte') {
741
+ if (row.origin === HOST_TASK_ORIGIN) {
658
742
  throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
659
743
  }
660
744
  session.stopSubagent(toolUseId);
@@ -666,6 +750,11 @@ export class ChatCore {
666
750
  this.require(chatId).stopTask(taskId);
667
751
  }
668
752
 
753
+ async goal(chatId: string, action: ChatGoalAction): Promise<void> {
754
+ await this.loaded(chatId);
755
+ await this.require(chatId).goal(action);
756
+ }
757
+
669
758
  /* The whole conversation of a subagent of the CLI's own, for an agent that reads it as text. */
670
759
  async subagentItems(chatId: string, toolUseId: string): Promise<ChatItem[]> {
671
760
  return this.subagents.readAll(chatId, toolUseId);
@@ -676,6 +765,59 @@ export class ChatCore {
676
765
  return this.chats.has(chatId) || (await this.store?.has(chatId)) === true;
677
766
  }
678
767
 
768
+ /*
769
+ * Writes the whole record of a chat nobody has loaded, such as a fork's thread before anyone opens it.
770
+ * The files its messages and generated images point at are copied into its own folder, so removing
771
+ * the chat they came from leaves them.
772
+ */
773
+ async writeRecord(chatId: string, info: ChatInfo, items: ChatItem[], preambles: string[] = []): Promise<void> {
774
+ if (!this.store) {
775
+ throw new ChatError('chat-unsupported', 'This host keeps no chat records');
776
+ }
777
+ if (this.chats.has(chatId) || this.creating.has(chatId)) {
778
+ throw new ChatError('chat-busy', `Chat ${chatId} is already loaded`);
779
+ }
780
+ const copies = new Map<string, Promise<ChatAttachment>>();
781
+ const copy = (attachment: ChatAttachment): Promise<ChatAttachment> => {
782
+ let pending = copies.get(attachment.id);
783
+ if (!pending) {
784
+ pending = this.attachments.copy(chatId, attachment);
785
+ copies.set(attachment.id, pending);
786
+ }
787
+ return pending;
788
+ };
789
+ const copied = await Promise.all(
790
+ items.map(async (item): Promise<ChatItem> => {
791
+ if (item.kind === 'user' && item.attachments?.length) {
792
+ return { ...item, attachments: await Promise.all(item.attachments.map(copy)) };
793
+ }
794
+ const generated = generatedImageAttachment(item);
795
+ if (item.kind === 'tool' && generated) {
796
+ return { ...item, input: { ...(item.input as Record<string, unknown>), attachment: await copy(generated) } };
797
+ }
798
+ return item;
799
+ })
800
+ );
801
+ await this.store.write(chatId, info, copied, { seq: 0, resetSeq: 0 }, preambles);
802
+ }
803
+
804
+ /*
805
+ * Removes what a chat nobody loaded keeps on disk: its record and log, attachments, bookmarks and
806
+ * visuals. False, with nothing removed, for a chat that is loaded or loading; `kill` removes that one.
807
+ */
808
+ async deleteRecord(chatId: string): Promise<boolean> {
809
+ if (this.chats.has(chatId) || this.creating.has(chatId)) {
810
+ return false;
811
+ }
812
+ await Promise.all([
813
+ this.store?.delete(chatId),
814
+ this.attachments.removeAll(chatId),
815
+ this.bookmarks?.removeChat(chatId),
816
+ this.visuals?.removeChat(chatId)
817
+ ]);
818
+ return true;
819
+ }
820
+
679
821
  /* The turn a chat took last, read off the record without loading a chat nobody has; null before its first. */
680
822
  async lastTurn(chatId: string): Promise<ChatTurnItem | null> {
681
823
  const session = this.chats.get(chatId);
@@ -797,6 +939,14 @@ export class ChatCore {
797
939
  return session.skills(() => this.skillIndex.list(session.info.provider, session.info.cwd));
798
940
  }
799
941
 
942
+ /* Ends the chat's CLI and keeps its conversation; the next message resumes it in a process that reads its skills and plugins again. */
943
+ async restart(chatId: string): Promise<void> {
944
+ const session = this.require(chatId);
945
+ const ended = session.restart();
946
+ this.skillIndex.forget(session.info.provider, session.info.cwd);
947
+ await ended;
948
+ }
949
+
800
950
  compact(chatId: string): void {
801
951
  const session = this.require(chatId);
802
952
  if (session.busy) {
@@ -811,10 +961,10 @@ export class ChatCore {
811
961
  if (session.busy && !force) {
812
962
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
813
963
  }
814
- await this.clearing(chatId);
964
+ await this.hooks.clearing?.(chatId);
815
965
  const ended = session.clear(force);
816
966
  // Before the record is written, so what it takes along from the record is gone from the write as well.
817
- const cleared = this.cleared(chatId);
967
+ const cleared = this.hooks.cleared?.(chatId);
818
968
  // A debounced write still waiting holds the old thread and must not land after the empty one.
819
969
  this.cancelWaiting(chatId);
820
970
  // Folded right away: every line before the reset describes a thread that is gone.
@@ -859,6 +1009,17 @@ export class ChatCore {
859
1009
  }
860
1010
  }
861
1011
 
1012
+ /* Holds a chat at `needs-you` for what the host asks a person; false for a chat that is not here. */
1013
+ holdForPerson(chatId: string, key: string): boolean {
1014
+ const session = this.chats.get(chatId);
1015
+ session?.holdForPerson(key);
1016
+ return session !== undefined;
1017
+ }
1018
+
1019
+ releaseHold(chatId: string, key: string): void {
1020
+ this.chats.get(chatId)?.releaseHold(key);
1021
+ }
1022
+
862
1023
  /* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
863
1024
  async kill(chatId: string): Promise<void> {
864
1025
  await this.loaded(chatId);
@@ -879,9 +1040,10 @@ export class ChatCore {
879
1040
  this.activity.delete(chatId);
880
1041
  this.chats.delete(chatId);
881
1042
  this.attached.delete(chatId);
1043
+ this.listChanges.delete(chatId);
882
1044
  this.announced.delete(chatId);
883
1045
  const snapshot = session.thread.snapshot();
884
- this.forgotten(chatId);
1046
+ this.hooks.forgotten?.(chatId);
885
1047
  await writing;
886
1048
  await ended;
887
1049
  await Promise.all([
@@ -889,7 +1051,7 @@ export class ChatCore {
889
1051
  this.attachments.removeAll(chatId),
890
1052
  this.bookmarks?.removeChat(chatId),
891
1053
  this.visuals?.removeChat(chatId),
892
- this.removed(chatId, snapshot)
1054
+ this.hooks.removed?.(chatId, snapshot)
893
1055
  ]);
894
1056
  }
895
1057
 
@@ -935,7 +1097,7 @@ export class ChatCore {
935
1097
  }
936
1098
  await this.create({ chatId });
937
1099
  }
938
- await this.chats.get(chatId)?.resume(turnId, attempt, await this.resumeWords(chatId));
1100
+ await this.chats.get(chatId)?.resume(turnId, attempt, (await this.hooks.resumeWords?.(chatId)) ?? DEFAULT_RESUME_WORDS);
939
1101
  }
940
1102
 
941
1103
  /*
@@ -955,7 +1117,7 @@ export class ChatCore {
955
1117
  return;
956
1118
  }
957
1119
  const turn = session.thread.get(turnId);
958
- const ended = this.endedAt(chatId);
1120
+ const ended = this.hooks.endedAt?.(chatId) ?? null;
959
1121
  if (turn !== undefined && ended !== null && ended >= turn.createdAt) {
960
1122
  session.lapseResume();
961
1123
  return;
@@ -988,6 +1150,10 @@ export class ChatCore {
988
1150
  if (this.chats.has(chatId) || this.creating.has(chatId)) {
989
1151
  continue;
990
1152
  }
1153
+ // Parsing the whole thread of every settled chat was most of the cost of a start.
1154
+ if (await this.store.settled(chatId).catch(() => false)) {
1155
+ continue;
1156
+ }
991
1157
  const record = await this.store.read(chatId).catch(() => null);
992
1158
  const unowed = record?.info.resumeAt !== undefined && this.limitResume?.owed(chatId) === false;
993
1159
  if (record !== null && (interruptedTurn(record) !== null || unowed)) {
@@ -1040,7 +1206,7 @@ export class ChatCore {
1040
1206
  items,
1041
1207
  { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 },
1042
1208
  session.preambles,
1043
- this.recordExtras(chatId),
1209
+ this.hooks.recordExtras?.(chatId) ?? {},
1044
1210
  session.uiAccessRecord
1045
1211
  );
1046
1212
  } catch (e) {
@@ -1059,11 +1225,6 @@ export class ChatCore {
1059
1225
  await this.creating.get(chatId)?.catch(() => undefined);
1060
1226
  }
1061
1227
 
1062
- protected persist(chatId: string): void {
1063
- this.cancelWaiting(chatId);
1064
- void this.persistNow(chatId).catch((e) => console.error(`Chat record for ${chatId} failed:`, errorText(e)));
1065
- }
1066
-
1067
1228
  protected require(chatId: string): ChatSession {
1068
1229
  const session = this.chats.get(chatId);
1069
1230
  if (!session) {
@@ -1072,93 +1233,9 @@ export class ChatCore {
1072
1233
  return session;
1073
1234
  }
1074
1235
 
1075
- /* What the agent is told once at the start of every process of this chat; null for nothing. */
1076
- protected instructionsFor(_chatId: string): string | null {
1077
- return this.instructions;
1078
- }
1079
-
1080
- /* What of the instructions a resumed thread hears again in front of its next prompt, for a CLI that ignores them on a resume. */
1081
- protected resumeNoteFor(_chatId: string): string | null {
1082
- return null;
1083
- }
1084
-
1085
- /*
1086
- * Folders outside its working directory a chat's agent may reach without asking, absolute. Asked
1087
- * before every turn, so a host can hand one over for as long as it is needed: a change starts the
1088
- * next turn in a new process that resumes the thread, and never cuts a turn in flight.
1089
- */
1090
- protected foldersFor(_chatId: string): readonly string[] {
1091
- return [];
1092
- }
1093
-
1094
- /* Whether no list of chats shows this one, which `ChatInfo.hidden` says to every client. */
1095
- protected hiddenFor(_chatId: string): boolean {
1096
- return false;
1097
- }
1098
-
1099
- /* What the host says in front of a chat's next real prompt, beside what the chat keeps itself. */
1100
- protected promptNotesFor(_chatId: string): PromptNotes | undefined {
1101
- return undefined;
1102
- }
1103
-
1104
- /* How the chats a person attached to a message are named to the agent; without it they are dropped. */
1105
- protected referencesFor(_chatId: string): ChatReferences | undefined {
1106
- return undefined;
1107
- }
1108
-
1109
- /* The environment of this chat's CLI before its account adds its own. */
1110
- protected envFor(_chatId: string, base: Record<string, string>): Record<string, string | undefined> {
1111
- return base;
1112
- }
1113
-
1114
- /* Throws for a folder this chat may not start in; asked every time a chat is loaded. */
1115
- protected async admit(_chatId: string, _cwd: string): Promise<void> {}
1116
-
1117
- /* The model a new chat starts on when the host already knows; the explicit one still wins. */
1118
- protected openingSelection(_chatId: string, _kind: AgentKind): ModelSelection | undefined {
1119
- return undefined;
1120
- }
1121
-
1122
- /* The mode a chat runs in, which a host may narrow whatever its record or its client says. */
1123
- protected runtimeModeFor(_chatId: string, mode: RuntimeMode): RuntimeMode {
1124
- return mode;
1125
- }
1126
-
1127
- /* A chat just loaded or made, its stored turns settled; whatever the host lays down in it goes here. */
1128
- protected async opened(_session: ChatSession, _stored: ChatRecord | null): Promise<void> {}
1129
-
1130
- /* What the host keeps in the chat's record beside the thread. */
1131
- protected recordExtras(_chatId: string): ChatRecordExtras {
1132
- return {};
1133
- }
1134
-
1135
- /* The chat was cleared; what runs before the first await is in the record written right after. */
1136
- protected async cleared(_chatId: string): Promise<void> {}
1137
-
1138
- protected async clearing(_chatId: string): Promise<void> {}
1139
-
1140
- /* The chat left memory for good, before its files go. */
1141
- protected forgotten(_chatId: string): void {}
1142
-
1143
- /* The chat was removed; what the host keeps of it goes along. */
1144
- protected async removed(_chatId: string, _chat: { info: ChatInfo; items: ChatItem[] }): Promise<void> {}
1145
-
1146
- /* An event of a chat went out, after the observers and before the clients reading its thread. */
1147
- protected broadcasted(_chatId: string, _event: ChatEvent): void {}
1148
-
1149
- /* When whoever opened this chat stopped it; a turn from before that is never taken up again. */
1150
- protected endedAt(_chatId: string): number | null {
1151
- return null;
1152
- }
1153
-
1154
- /* Why a turn the host went down in stays down when `onInterruptedRun` says no, in the words of the note it ends with. */
1155
- protected unownedReason(_chatId: string): string {
1156
- return 'nothing holds this chat any more';
1157
- }
1158
-
1159
- /* How a turn the host went down in is taken up in this chat. */
1160
- protected async resumeWords(_chatId: string): Promise<ResumeWords> {
1161
- return DEFAULT_RESUME_WORDS;
1236
+ private persist(chatId: string): void {
1237
+ this.cancelWaiting(chatId);
1238
+ void this.persistNow(chatId).catch((e) => console.error(`Chat record for ${chatId} failed:`, errorText(e)));
1162
1239
  }
1163
1240
 
1164
1241
  /* A write that may wait: a long thread is not rewritten for every small change. */
@@ -1200,7 +1277,7 @@ export class ChatCore {
1200
1277
  }
1201
1278
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
1202
1279
  this.coalescers.get(chatId)?.flush();
1203
- const extras = this.recordExtras(chatId);
1280
+ const extras = this.hooks.recordExtras?.(chatId) ?? {};
1204
1281
  const uiAccess = session.uiAccessRecord;
1205
1282
  const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
1206
1283
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
@@ -1250,7 +1327,7 @@ export class ChatCore {
1250
1327
  for (const sink of this.observers) {
1251
1328
  sink({ event: 'chat.event', payload });
1252
1329
  }
1253
- this.broadcasted(chatId, event);
1330
+ this.hooks.broadcasted?.(chatId, event);
1254
1331
  for (const clientId of this.attached.get(chatId) ?? []) {
1255
1332
  this.sinks.to(clientId, { event: 'chat.event', payload });
1256
1333
  }
@@ -1273,14 +1350,15 @@ export class ChatCore {
1273
1350
  */
1274
1351
  private announceStatus(chatId: string, info: ChatInfo): void {
1275
1352
  // 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,
1276
- // and so does a request opening or settling while the chat already waits on another.
1353
+ // and so does a request opening or settling while the chat already waits on another, and a goal set, moving on or gone.
1277
1354
  const said = JSON.stringify([
1278
1355
  info.status,
1279
1356
  info.limit ?? null,
1280
1357
  info.resumeAt ?? null,
1281
1358
  info.resumeAtReset ?? null,
1282
1359
  info.delegating ?? false,
1283
- info.requests?.map((request) => request.requestId) ?? []
1360
+ info.requests?.map((request) => request.requestId) ?? [],
1361
+ info.goal === undefined ? null : [info.goal.status, info.goal.objective]
1284
1362
  ]);
1285
1363
  if (this.announced.get(chatId) === said) {
1286
1364
  return;
@@ -1322,7 +1400,7 @@ export class ChatCore {
1322
1400
  if (stored.info.agentSessionId === null) {
1323
1401
  return skip('the agent had not started a session to resume yet');
1324
1402
  }
1325
- const ended = this.endedAt(chatId);
1403
+ const ended = this.hooks.endedAt?.(chatId) ?? null;
1326
1404
  if (ended !== null && ended >= turn.createdAt) {
1327
1405
  return skip('the agent that opened it was stopped');
1328
1406
  }
@@ -1331,7 +1409,7 @@ export class ChatCore {
1331
1409
  }
1332
1410
  try {
1333
1411
  const owed = await this.onInterruptedRun({ chatId, turnId: turn.id, attempt: (turn.attempt ?? 1) + (turn.resumePending ? 0 : 1) });
1334
- return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.unownedReason(chatId));
1412
+ return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.hooks.unownedReason?.(chatId) ?? 'nothing holds this chat any more');
1335
1413
  } catch (e) {
1336
1414
  console.error(`Owing a resume for chat ${chatId} failed:`, errorText(e));
1337
1415
  return skip(errorText(e));