@adecore/agents 0.24.2 → 0.25.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,8 +8,9 @@ export const DeliverMessageWorkSchema = z.object({
8
8
  * Opens the turn a message earns a chat. The messages wait in the notice store and the turn's preamble
9
9
  * takes them (`NoticeNotes`), as a person's own prompt does, so a message is heard once either way.
10
10
  *
11
- * One attempt, never `wait`: a chat already in a turn reads the message in front of its next one, and
12
- * holding the entry would start turns nobody asked for long after the news mattered.
11
+ * One attempt: a chat already in a turn reads the message in front of its next one, and holding the
12
+ * entry would start turns nobody asked for long after the news mattered. The one exception is a chat
13
+ * being moved to another folder, which has no turn and turns every wake away until the move ends.
13
14
  */
14
15
  export function deliverMessageHandler(deps) {
15
16
  return async (entry) => {
@@ -25,6 +26,9 @@ export function deliverMessageHandler(deps) {
25
26
  if (chat === null) {
26
27
  return;
27
28
  }
29
+ if (chat.relocating?.() === true) {
30
+ return 'wait';
31
+ }
28
32
  // No note and no preamble on the thread: a person read the message as it landed (`showNotices`),
29
33
  // and the label on the turn names who it came from.
30
34
  chat.wake({
@@ -101,9 +101,12 @@ export class RecordDirectory {
101
101
  return rm(this.pathOf(id), { force: true });
102
102
  }
103
103
  async prune(drop) {
104
- for (const record of this.all()) {
105
- if (drop(record)) {
106
- await this.remove(this.idOf(record));
104
+ for (const listed of this.all()) {
105
+ const id = this.idOf(listed);
106
+ // Read again after each await: a record written meanwhile may no longer be one to drop.
107
+ const current = this.records.get(id);
108
+ if (current !== undefined && drop(current)) {
109
+ await this.remove(id);
107
110
  }
108
111
  }
109
112
  }
@@ -1,6 +1,6 @@
1
1
  export { type FakeIo, type FakeProgram, type FakeCli, type StartedFake, type InProcessCli, inProcess, runOverStdio } from './fake-cli.ts';
2
2
  export { fakeClaude, claudeStoppingOnResume } from './fake-claude.ts';
3
3
  export { FAKE_CHILD_STEPS, fakeCodexForks, type FakeCodexOptions, fakeCodexWith, fakeCodex } from './fake-codex.ts';
4
- export { memorySecrets, testAccounts } from './accounts.ts';
4
+ export { testAccounts } from './accounts.ts';
5
5
  export { ManualClock } from './manual-clock.ts';
6
- export { FakeDirectoryWatcher, FakeWatch, EventLog } from './watch.ts';
6
+ export { FakeWatch, EventLog } from './watch.ts';
@@ -2,6 +2,6 @@
2
2
  export { inProcess, runOverStdio } from './fake-cli.js';
3
3
  export { fakeClaude, claudeStoppingOnResume } from './fake-claude.js';
4
4
  export { FAKE_CHILD_STEPS, fakeCodexForks, fakeCodexWith, fakeCodex } from './fake-codex.js';
5
- export { memorySecrets, testAccounts } from './accounts.js';
5
+ export { testAccounts } from './accounts.js';
6
6
  export { ManualClock } from './manual-clock.js';
7
- export { FakeDirectoryWatcher, FakeWatch, EventLog } from './watch.js';
7
+ export { FakeWatch, EventLog } from './watch.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.24.2",
3
+ "version": "0.25.1",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.24.2",
85
- "@adecore/intelligent-ui": "0.24.2",
84
+ "@adecore/agent-contracts": "0.25.1",
85
+ "@adecore/intelligent-ui": "0.25.1",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -227,6 +227,8 @@ export class ChatCore {
227
227
  private readonly hooks: ChatCoreHooks;
228
228
  private readonly chats = new Map<string, ChatSession>();
229
229
  private readonly creating = new Map<string, Promise<ChatInfo>>();
230
+ // The relocations under way, settled (never rejected) when each ends.
231
+ private readonly relocations = new Map<string, Promise<void>>();
230
232
  private readonly sinks = new ClientSinks<AgentEvent>((clientId) => this.composerPreferences.forget(clientId));
231
233
  private readonly attached = new Map<string, Set<string>>();
232
234
  // How often a chat's bookmarks or visuals changed, so an attach can tell that its read went stale before it joined.
@@ -540,6 +542,7 @@ export class ChatCore {
540
542
  configure(payload: ChatConfigurePayload): ChatInfo {
541
543
  const session = this.require(payload.chatId);
542
544
  if (payload.account !== undefined) {
545
+ this.refuseWhileRelocating(payload.chatId);
543
546
  this.switchAccount(session, payload.account);
544
547
  }
545
548
  return session.configure(payload);
@@ -942,6 +945,7 @@ export class ChatCore {
942
945
  /* 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
946
  async restart(chatId: string): Promise<void> {
944
947
  const session = this.require(chatId);
948
+ this.refuseWhileRelocating(chatId);
945
949
  const ended = session.restart();
946
950
  this.skillIndex.forget(session.info.provider, session.info.cwd);
947
951
  await ended;
@@ -949,6 +953,7 @@ export class ChatCore {
949
953
 
950
954
  compact(chatId: string): void {
951
955
  const session = this.require(chatId);
956
+ this.refuseWhileRelocating(chatId);
952
957
  if (session.busy) {
953
958
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
954
959
  }
@@ -958,6 +963,7 @@ export class ChatCore {
958
963
  /* Empties the thread and drops the CLI's session and the chat's bookmarks and visuals; `force` stops a turn that is in the way. */
959
964
  async clear(chatId: string, force = false): Promise<void> {
960
965
  const session = this.require(chatId);
966
+ this.refuseWhileRelocating(chatId);
961
967
  if (session.busy && !force) {
962
968
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
963
969
  }
@@ -1020,9 +1026,80 @@ export class ChatCore {
1020
1026
  this.chats.get(chatId)?.releaseHold(key);
1021
1027
  }
1022
1028
 
1029
+ /*
1030
+ * Moves a loaded chat to another working folder. `during` is the host's own work and runs with the CLI
1031
+ * already ended; messages sent meanwhile queue and go out in the new folder. Any throw keeps the old folder.
1032
+ */
1033
+ async relocate(chatId: string, cwd: string, during?: () => Promise<void>): Promise<ChatInfo> {
1034
+ const session = this.require(chatId);
1035
+ this.refuseUnlessRelocatable(session);
1036
+ const running = this.relocateNow(session, cwd, during);
1037
+ const tracked = running.then(
1038
+ () => undefined,
1039
+ () => undefined
1040
+ );
1041
+ this.relocations.set(chatId, tracked);
1042
+ try {
1043
+ return await running;
1044
+ } finally {
1045
+ if (this.relocations.get(chatId) === tracked) {
1046
+ this.relocations.delete(chatId);
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ /* Whether a relocation of the chat is under way, which a host that forks or copies the chat has to refuse. */
1052
+ isRelocating(chatId: string): boolean {
1053
+ return this.relocations.has(chatId);
1054
+ }
1055
+
1056
+ private async relocateNow(session: ChatSession, cwd: string, during: (() => Promise<void>) | undefined): Promise<ChatInfo> {
1057
+ const chatId = session.id;
1058
+ await this.hooks.admit?.(chatId, cwd);
1059
+ // A message sent while the folder was asked about may have opened a turn.
1060
+ if (!session.relocatable) {
1061
+ throw this.busyRelocating(chatId);
1062
+ }
1063
+ const previous = session.info.cwd;
1064
+ session.beginRelocate();
1065
+ try {
1066
+ await session.dropProcess();
1067
+ await during?.();
1068
+ session.setCwd(cwd);
1069
+ await this.persistNow(chatId, true);
1070
+ } catch (e) {
1071
+ session.setCwd(previous);
1072
+ throw e;
1073
+ } finally {
1074
+ session.endRelocate();
1075
+ }
1076
+ this.skillIndex.forget(session.info.provider, previous);
1077
+ return session.info;
1078
+ }
1079
+
1080
+ private refuseUnlessRelocatable(session: ChatSession): void {
1081
+ if (this.relocations.has(session.id) || !session.relocatable) {
1082
+ throw this.busyRelocating(session.id);
1083
+ }
1084
+ }
1085
+
1086
+ private busyRelocating(chatId: string): ChatError {
1087
+ return new ChatError('chat-busy', `Chat ${chatId} is still working; stop it before moving it to another folder`);
1088
+ }
1089
+
1090
+ private refuseWhileRelocating(chatId: string): void {
1091
+ if (this.relocations.has(chatId)) {
1092
+ throw new ChatError('chat-busy', `Chat ${chatId} is being moved to another folder`);
1093
+ }
1094
+ }
1095
+
1023
1096
  /* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
1024
1097
  async kill(chatId: string): Promise<void> {
1025
1098
  await this.loaded(chatId);
1099
+ const moving = this.relocations.get(chatId);
1100
+ if (moving) {
1101
+ await moving;
1102
+ }
1026
1103
  const session = this.require(chatId);
1027
1104
  // A write already out would put the record back after the delete.
1028
1105
  const writing = this.writes.get(chatId);
@@ -19,7 +19,7 @@ import type {
19
19
  ModelSelection,
20
20
  RuntimeMode
21
21
  } from '@adecore/agent-contracts';
22
- import { HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
22
+ import { ENDED_NOTE_PREFIX, HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
23
23
  import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure } from '@adecore/intelligent-ui';
24
24
  import type { ChatProvider } from '../providers/provider.ts';
25
25
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
@@ -179,6 +179,9 @@ export class ChatSession {
179
179
  private launchedSelection: ModelSelection | null = null;
180
180
  // The account the running CLI was started under, which what it reports about a plan belongs to.
181
181
  private launchedAccount: string | undefined = undefined;
182
+ private launchedCwd: string | null = null;
183
+ // Set while the host moves the chat's folder: messages queue and nothing starts a process until it ends.
184
+ private relocating = false;
182
185
  private launchedRuntimeMode: RuntimeMode | null = null;
183
186
  private launchedFolders: readonly string[] = [];
184
187
  // The checkpoint of the turn in flight; everything queued for that turn waits for it.
@@ -327,6 +330,46 @@ export class ChatSession {
327
330
  return this.thread.info;
328
331
  }
329
332
 
333
+ /* The next process starts in this folder; the host moves the chat through `ChatCore.relocate`, which has already ended the old one. */
334
+ setCwd(cwd: string): ChatInfo {
335
+ if (cwd === this.thread.info.cwd) {
336
+ return this.thread.info;
337
+ }
338
+ this.emit([this.thread.patchInfo({ cwd })]);
339
+ this.options.persist();
340
+ return this.thread.info;
341
+ }
342
+
343
+ get relocatable(): boolean {
344
+ return (
345
+ !this.frozen &&
346
+ !this.relocating &&
347
+ this.thread.info.activeTurnId === null &&
348
+ !this.processInUse() &&
349
+ this.pendingStart === null &&
350
+ !this.thread.held
351
+ );
352
+ }
353
+
354
+ get isRelocating(): boolean {
355
+ return this.relocating;
356
+ }
357
+
358
+ /* Messages sent from here on queue, and the queue stays put until `endRelocate`. */
359
+ beginRelocate(): void {
360
+ this.relocating = true;
361
+ }
362
+
363
+ /*
364
+ * Lets the queue go, in whichever folder the chat has now. A wake that found the chat relocating
365
+ * was turned away and waits for an event of this chat, so one goes out even when the queue was empty.
366
+ */
367
+ endRelocate(): void {
368
+ this.relocating = false;
369
+ this.drainQueue();
370
+ this.emit([this.thread.patchInfo({})]);
371
+ }
372
+
330
373
  /*
331
374
  * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
332
375
  */
@@ -334,7 +377,7 @@ export class ChatSession {
334
377
  const extras = this.vetted(sent);
335
378
  const turnId = newId('turn');
336
379
  // Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
337
- if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
380
+ if (this.busy || this.queue.length > 0 || this.replacementWaits() || this.relocating) {
338
381
  if (this.busy && this.info.queuePaused) {
339
382
  this.sendAfterStop = true;
340
383
  }
@@ -579,6 +622,7 @@ export class ChatSession {
579
622
  !next ||
580
623
  this.steering.size > 0 ||
581
624
  this.busy ||
625
+ this.relocating ||
582
626
  (!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))
583
627
  ) {
584
628
  return;
@@ -969,7 +1013,7 @@ export class ChatSession {
969
1013
  * with `origin: 'agent'` and close the wake it just made.
970
1014
  */
971
1015
  wake(wake: { text: string; label: string; note?: string; taskIds: string[]; summaryFor?: string; messageFrom?: string[] }): string | null {
972
- if (this.frozen || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
1016
+ if (this.frozen || this.relocating || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
973
1017
  return null;
974
1018
  }
975
1019
  this.turnOpened();
@@ -1200,7 +1244,7 @@ export class ChatSession {
1200
1244
  }
1201
1245
  }
1202
1246
  if (turnId !== null) {
1203
- events.push(this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason }));
1247
+ events.push(this.thread.upsert({ id: newId(`${ENDED_NOTE_PREFIX}note`), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason }));
1204
1248
  }
1205
1249
  events.push(
1206
1250
  this.thread.patchInfo({
@@ -1226,6 +1270,14 @@ export class ChatSession {
1226
1270
  if (this.thread.info.activeTurnId !== null || this.processInUse()) {
1227
1271
  throw new ChatError('chat-busy', `Chat ${this.id} is still working; stop it before restarting its agent session`);
1228
1272
  }
1273
+ return this.dropProcess();
1274
+ }
1275
+
1276
+ /*
1277
+ * Ends the CLI and keeps the conversation, like `restart` without its refusal: the caller has
1278
+ * checked `relocatable` and holds the queue. Settles once the process exited or was sent the SIGKILL.
1279
+ */
1280
+ dropProcess(): Promise<void> {
1229
1281
  this.clearStartWarning();
1230
1282
  this.clearWorkflowWarnings();
1231
1283
  const backend = this.backend;
@@ -1707,6 +1759,7 @@ export class ChatSession {
1707
1759
  (JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
1708
1760
  this.info.runtimeMode !== this.launchedRuntimeMode ||
1709
1761
  this.info.account !== this.launchedAccount ||
1762
+ this.info.cwd !== this.launchedCwd ||
1710
1763
  !sameFolders(this.options.folders?.() ?? [], this.launchedFolders))
1711
1764
  );
1712
1765
  }
@@ -1748,6 +1801,7 @@ export class ChatSession {
1748
1801
  const info = this.thread.info;
1749
1802
  this.launchedSelection = info.selection;
1750
1803
  this.launchedRuntimeMode = info.runtimeMode;
1804
+ this.launchedCwd = info.cwd;
1751
1805
  const folders = this.options.folders?.() ?? [];
1752
1806
  this.launchedFolders = folders;
1753
1807
  const launch: BackendLaunch = {
package/src/chat/index.ts CHANGED
@@ -30,7 +30,7 @@ export { type ChatRecordExtras, type ChatRecord, ChatStore, type ChatSeq, type C
30
30
  export { suggestChatTitle, type ChatTitleInput } from './chat-title.ts';
31
31
  export type { ClaudeBackendOptions } from './claude-backend.ts';
32
32
  export { ClaudeTitleReader } from './claude-title.ts';
33
- export { spokenText, claudeProjectSlug, findClaudeSession } from './claude-transcript.ts';
33
+ export { claudeProjectSlug, findClaudeSession } from './claude-transcript.ts';
34
34
  export type { CodexBackendOptions } from './codex-backend.ts';
35
35
  export { forkThreadOnce, type ThreadItemsParams, type CodexProcessSpec, type ThreadForkParams } from './codex-thread.ts';
36
36
  export type { CodexClientInfo } from './codex-transport.ts';
@@ -46,4 +46,4 @@ export { ChatThread } from './thread.ts';
46
46
  export { type ChatUiSource, type ChatUiHost, ChatUiRefusal } from './ui-queries.ts';
47
47
  export { visualFileName, VisualStore, type VisualInput, type VisualStoreOptions, type VisualListener } from './visual-store.ts';
48
48
  export { type WakeChat, chatOpener, type ChatOpenerDeps } from './wake-chat.ts';
49
- export { type SkillRootOptions, SkillIndex } from '../skills.ts';
49
+ export { type SkillRootOptions } from '../skills.ts';
@@ -79,6 +79,10 @@ export class ChatThread {
79
79
  return { type: 'info', info: this.info };
80
80
  }
81
81
 
82
+ get held(): boolean {
83
+ return this.holds.size > 0;
84
+ }
85
+
82
86
  /* False when the key held the chat already. */
83
87
  hold(key: string): boolean {
84
88
  if (this.holds.has(key)) {
@@ -3,13 +3,17 @@ import type { z } from 'zod';
3
3
  import type {
4
4
  ChatUiLinkPayload,
5
5
  ChatUiLinkReading,
6
+ ChatUiPlaceReading,
7
+ ChatUiQueryState,
6
8
  ChatAssistantItem,
7
9
  ChatInfo,
8
10
  ChatUiChoicePayload,
9
11
  ChatUiQueryPayload,
10
12
  ChatUiQueryReading
11
13
  } from '@adecore/agent-contracts';
14
+ import { ChatUiPlaceReadingSchema } from '@adecore/agent-contracts';
12
15
  import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost, type UiValue } from '@adecore/intelligent-ui';
16
+ import { uiPlaceQueries, type UiPlaceQuery } from '@adecore/intelligent-ui/places';
13
17
  import { UiLinkResolutionSchema, uiLinkTargets, type UiLinkTarget } from '@adecore/intelligent-ui/links';
14
18
  import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
15
19
  import type { UiBlock } from '@adecore/intelligent-ui/compiler';
@@ -27,6 +31,12 @@ export interface ChatUiHost {
27
31
  capture(info: ChatInfo): Promise<unknown>;
28
32
  sources: Readonly<Record<string, ChatUiSource>>;
29
33
  link?(info: ChatInfo, access: unknown, target: UiLinkTarget): Promise<ChatUiLinkReading>;
34
+ /*
35
+ * Looks up the point of a Place that names an address, once, when its reply settles; the reading is
36
+ * frozen beside the block for every client. `signal` aborts after five seconds. A host without it
37
+ * freezes nothing, and such a place reads as not found.
38
+ */
39
+ place?(info: ChatInfo, query: UiPlaceQuery, signal: AbortSignal): Promise<Omit<ChatUiPlaceReading, 'address'>>;
30
40
  }
31
41
 
32
42
  interface CachedReading {
@@ -63,13 +73,20 @@ export class ChatUiRefusal extends UiQueryError {
63
73
  const MAX_QUERY_BYTES = 64 * 1024;
64
74
  const MAX_SNAPSHOT_BYTES = 128 * 1024;
65
75
  const READ_TIMEOUT_MS = 8_000;
76
+ const PLACE_TIMEOUT_MS = 5_000;
77
+ // Lookups run a few at a time, share one budget per block, and stop trying once the host keeps timing out.
78
+ const PLACE_PARALLEL = 4;
79
+ const PLACE_BLOCK_BUDGET_MS = 15_000;
80
+ const PLACE_TIMEOUTS_IN_A_ROW = 3;
66
81
  // A changed input reads at once, but a query never reads more often than this across its inputs.
67
82
  const INPUT_READ_MS = 250;
68
83
 
69
84
  export class ChatUiQueries {
70
85
  readonly schemas: Record<string, z.ZodType>;
71
86
  private readonly captures = new Map<string, Promise<unknown>>();
72
- private readonly freezing = new Set<string>();
87
+ private readonly freezing = new Map<string, Promise<void>>();
88
+ // Ends with the chat, so a lookup that is under way stops when its chat does.
89
+ private readonly lives = new Map<string, AbortController>();
73
90
  private readonly cache = new Map<string, CachedReading>();
74
91
  // The read id of the newest reading per cache key, which a remount or a second client is answered with.
75
92
  private readonly latest = new Map<string, string>();
@@ -101,8 +118,7 @@ export class ChatUiQueries {
101
118
  if (this.freezing.has(key)) {
102
119
  return;
103
120
  }
104
- this.freezing.add(key);
105
- void capture
121
+ const frozen = capture
106
122
  .then(async () => {
107
123
  for (const block of (item.ui ?? []).slice(0, 16)) {
108
124
  const current = session.thread.get(item.id);
@@ -139,6 +155,10 @@ export class ChatUiQueries {
139
155
  }
140
156
  }
141
157
  }
158
+ const { places, found, ended } = await this.lookUpPlaces(session, item.id, block, values);
159
+ if (ended) {
160
+ return;
161
+ }
142
162
  const latest = session.thread.get(item.id);
143
163
  if (latest?.kind !== 'assistant' || !latest.uiQueries || latest.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
144
164
  return;
@@ -150,11 +170,12 @@ export class ChatUiQueries {
150
170
  .filter(([, reading]) => reading.state !== 'fresh')
151
171
  .map(([name, reading]) => `${name}: ${reading.reason}`)
152
172
  .join('\n');
153
- const frozen = {
173
+ const frozen: ChatUiQueryState['blocks'][string] = {
154
174
  revision: block.revision!,
155
175
  readings,
156
176
  links,
157
- fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}`
177
+ ...(Object.keys(places).length > 0 ? { places } : {}),
178
+ fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}${found.length > 0 ? `\nPlaces: ${found.join('; ')}` : ''}`
158
179
  };
159
180
  const blocks = { ...latest.uiQueries.blocks, [block.id]: frozen };
160
181
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
@@ -167,16 +188,140 @@ export class ChatUiQueries {
167
188
  frozen.links = Object.fromEntries(
168
189
  Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }])
169
190
  );
191
+ if (frozen.places) {
192
+ frozen.places = Object.fromEntries(
193
+ Object.entries(places).map(([id, reading]) => [
194
+ id,
195
+ { state: 'missing', address: reading.address, code: 'snapshot-too-large', reason: 'The frozen places are too large.' }
196
+ ])
197
+ );
198
+ }
170
199
  frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
171
200
  }
172
201
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
173
- delete blocks[block.id];
202
+ // The block stays marked as frozen with nothing in it, so a reader stops waiting for readings that will not come.
203
+ blocks[block.id] = { revision: block.revision!, readings: {}, fallback: '' };
174
204
  }
175
205
  session.updateUiQueries(item.id, { ...latest.uiQueries, blocks });
176
206
  }
177
207
  })
178
208
  .catch(() => {})
179
209
  .finally(() => this.freezing.delete(key));
210
+ this.freezing.set(key, frozen);
211
+ }
212
+
213
+ /* Settles once the reply's readings, links and places are frozen, or at once where nothing is being frozen. */
214
+ async whenFrozen(session: ChatSession, itemId: string): Promise<void> {
215
+ await this.freezing.get(`${session.id}:${itemId}`);
216
+ }
217
+
218
+ /*
219
+ * The places of a block that name an address, looked up four at a time within one budget for the block.
220
+ * Past the budget, or after the host timed out three times in a row, the rest freeze as missing without
221
+ * asking. Nothing is asked once the chat ended, the reply is gone or the block changed; the caller then
222
+ * drops the write.
223
+ */
224
+ private async lookUpPlaces(
225
+ session: ChatSession,
226
+ itemId: string,
227
+ block: UiBlock,
228
+ values: Record<string, unknown>
229
+ ): Promise<{ places: Record<string, ChatUiPlaceReading>; found: string[]; ended: boolean }> {
230
+ const places: Record<string, ChatUiPlaceReading> = Object.create(null);
231
+ const found: string[] = [];
232
+ if (!this.host.place) {
233
+ return { places, found, ended: false };
234
+ }
235
+ let queries: Array<[string, UiPlaceQuery]>;
236
+ try {
237
+ queries = Object.entries(uiPlaceQueries(block, {}, values)).slice(0, UI_HOST_LIMITS.places);
238
+ } catch {
239
+ return { places, found, ended: false };
240
+ }
241
+ const life = this.lifeOf(session.id).signal;
242
+ const budget = new AbortController();
243
+ const timer = setTimeout(() => budget.abort(), PLACE_BLOCK_BUDGET_MS);
244
+ timer.unref?.();
245
+ const signal = AbortSignal.any([life, budget.signal]);
246
+ const readings = new Map<string, ChatUiPlaceReading>();
247
+ let timeouts = 0;
248
+ let next = 0;
249
+ const stillThere = (): boolean => {
250
+ const current = session.thread.get(itemId);
251
+ return !life.aborted && current?.kind === 'assistant' && current.ui?.find((entry) => entry.id === block.id)?.revision === block.revision;
252
+ };
253
+ const worker = async (): Promise<void> => {
254
+ for (let index = next++; index < queries.length; index = next++) {
255
+ const [id, query] = queries[index]!;
256
+ if (!stillThere()) {
257
+ return;
258
+ }
259
+ if (budget.signal.aborted || timeouts >= PLACE_TIMEOUTS_IN_A_ROW) {
260
+ readings.set(id, { state: 'missing', address: query.address, code: 'timed-out', reason: 'The place lookup timed out.' });
261
+ continue;
262
+ }
263
+ const reading = await this.placeReading(session.info, query, signal);
264
+ timeouts = reading.code === 'timed-out' ? timeouts + 1 : 0;
265
+ readings.set(id, reading);
266
+ }
267
+ };
268
+ try {
269
+ await Promise.all(Array.from({ length: Math.min(PLACE_PARALLEL, queries.length) }, worker));
270
+ } finally {
271
+ clearTimeout(timer);
272
+ }
273
+ for (const [id, query] of queries) {
274
+ const reading = readings.get(id);
275
+ if (reading === undefined) {
276
+ continue;
277
+ }
278
+ places[id] = reading;
279
+ if (reading.state === 'found') {
280
+ found.push(`${query.name || query.address}: ${reading.lat}, ${reading.lon}`);
281
+ }
282
+ }
283
+ return { places, found, ended: life.aborted };
284
+ }
285
+
286
+ private lifeOf(chatId: string): AbortController {
287
+ let life = this.lives.get(chatId);
288
+ if (life === undefined) {
289
+ life = new AbortController();
290
+ this.lives.set(chatId, life);
291
+ }
292
+ return life;
293
+ }
294
+
295
+ /*
296
+ * One lookup, cut off after five seconds or when `outer` aborts. Whatever goes wrong is a reading that says
297
+ * the place is missing. Only a refusal the host chose its words for (`ChatUiRefusal`) carries them: any
298
+ * other error stays out of the shared record, which every client reads.
299
+ */
300
+ private async placeReading(info: ChatInfo, query: UiPlaceQuery, outer: AbortSignal): Promise<ChatUiPlaceReading> {
301
+ const timeout = new AbortController();
302
+ const timer = setTimeout(() => timeout.abort(), PLACE_TIMEOUT_MS);
303
+ timer.unref?.();
304
+ const signal = AbortSignal.any([timeout.signal, outer]);
305
+ try {
306
+ const reading = await Promise.race([
307
+ this.host.place!(info, query, signal),
308
+ new Promise<never>((_, reject) =>
309
+ signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The place lookup timed out.')), {
310
+ once: true
311
+ })
312
+ )
313
+ ]);
314
+ const parsed = ChatUiPlaceReadingSchema.parse({ ...(copyUiValue(reading, new UiBudget()) as object), address: query.address });
315
+ return parsed.state === 'found' && (parsed.lat === undefined || parsed.lon === undefined)
316
+ ? { state: 'missing', address: query.address, code: 'place-unreadable', reason: 'The host found no point for this address.' }
317
+ : parsed;
318
+ } catch (error) {
319
+ return error instanceof UiQueryError
320
+ ? { state: 'missing', address: query.address, ...this.failure(error) }
321
+ : { state: 'missing', address: query.address, code: 'lookup-failed', reason: 'The place could not be looked up.' };
322
+ } finally {
323
+ clearTimeout(timer);
324
+ }
180
325
  }
181
326
 
182
327
  /* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
@@ -408,6 +553,8 @@ export class ChatUiQueries {
408
553
  }
409
554
 
410
555
  forget(chatId: string): void {
556
+ this.lives.get(chatId)?.abort();
557
+ this.lives.delete(chatId);
411
558
  for (const [id, cached] of this.cache) {
412
559
  if (cached.chatId === chatId) {
413
560
  this.evict(id);
@@ -451,7 +598,7 @@ export class ChatUiQueries {
451
598
  }
452
599
 
453
600
  private failure(error: unknown): { reason: string; code?: string } {
454
- const reason = (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
455
- return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
601
+ // The reason is frozen into a record every client reads, so a message the host did not choose for that stays out of it.
602
+ return error instanceof UiQueryError ? { reason: error.message.slice(0, 240), code: error.reason } : { reason: 'This query could not be read.' };
456
603
  }
457
604
  }
@@ -10,6 +10,8 @@ export interface WakeChat {
10
10
  items(): ChatItem[];
11
11
  persist?(): Promise<void>;
12
12
  wake(wake: { text: string; label: string; note?: string; taskIds: string[]; messageFrom?: string[] }): boolean;
13
+ /* Whether the host is moving the chat to another folder, which turns every wake away until it ends. */
14
+ relocating?(): boolean;
13
15
  }
14
16
 
15
17
  export interface ChatOpenerDeps {
@@ -39,6 +41,7 @@ export function chatOpener(deps: ChatOpenerDeps) {
39
41
  return session
40
42
  ? {
41
43
  items: () => session.thread.list(),
44
+ relocating: () => session.isRelocating,
42
45
  persist: () => deps.chats.save(chatId),
43
46
  // A turn before the reset would stop on the same limit and spend one of the resume's tries, so it waits for the resume turn.
44
47
  wake: (wake) => session.info.resumeAt === undefined && session.wake(wake) !== null
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export { wait, withTimeout } from './async.ts';
3
3
  export { CodedError } from './coded-error.ts';
4
4
  export { describeError, setErrorStacks, errorText } from './error-text.ts';
5
5
  export { tempNameFor, writeAtomic, replaceSync, writeAtomicSync, isNotFound, fileExists, type WriteAtomicOptions } from './fs.ts';
6
- export { recordFileName, RecordDirectory, type RecordDirectoryOptions } from './record-directory.ts';
6
+ export { RecordDirectory, type RecordDirectoryOptions } from './record-directory.ts';
7
7
  export { runProcess, type ProcessResult, type RunProcessOptions } from './run-process.ts';
8
8
  export { Serializer, KeyedSerializer } from './serializer.ts';
9
9
  export { cleanTitle, readLines } from './title-file.ts';
package/src/lineage.ts CHANGED
@@ -70,6 +70,15 @@ export class AgentLineageStore {
70
70
  return found;
71
71
  }
72
72
 
73
+ /* Moves a node's record to another project with its dates as they were; false when nothing was written down for it or a prune took it meanwhile. */
74
+ async rehome(nodeId: string, projectId: string): Promise<boolean> {
75
+ const entry = this.opened.get(nodeId);
76
+ if (entry === undefined) {
77
+ return false;
78
+ }
79
+ return this.opened.write({ ...entry, projectId });
80
+ }
81
+
73
82
  /* The project a node was made in, for work owed about it after the node that opened it is gone. */
74
83
  projectOf(nodeId: string): string | null {
75
84
  return this.opened.get(nodeId)?.projectId ?? null;