@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.
@@ -92,6 +92,7 @@ export declare class ChatCore {
92
92
  private readonly hooks;
93
93
  private readonly chats;
94
94
  private readonly creating;
95
+ private readonly relocations;
95
96
  private readonly sinks;
96
97
  private readonly attached;
97
98
  private readonly listChanges;
@@ -236,6 +237,12 @@ export declare class ChatCore {
236
237
  dismiss(chatId: string, itemId: string): void;
237
238
  holdForPerson(chatId: string, key: string): boolean;
238
239
  releaseHold(chatId: string, key: string): void;
240
+ relocate(chatId: string, cwd: string, during?: () => Promise<void>): Promise<ChatInfo>;
241
+ isRelocating(chatId: string): boolean;
242
+ private relocateNow;
243
+ private refuseUnlessRelocatable;
244
+ private busyRelocating;
245
+ private refuseWhileRelocating;
239
246
  kill(chatId: string): Promise<void>;
240
247
  stop(chatId: string, reason: string): Promise<void>;
241
248
  processTargets(): {
@@ -57,6 +57,8 @@ export class ChatCore {
57
57
  hooks;
58
58
  chats = new Map();
59
59
  creating = new Map();
60
+ // The relocations under way, settled (never rejected) when each ends.
61
+ relocations = new Map();
60
62
  sinks = new ClientSinks((clientId) => this.composerPreferences.forget(clientId));
61
63
  attached = new Map();
62
64
  // How often a chat's bookmarks or visuals changed, so an attach can tell that its read went stale before it joined.
@@ -350,6 +352,7 @@ export class ChatCore {
350
352
  configure(payload) {
351
353
  const session = this.require(payload.chatId);
352
354
  if (payload.account !== undefined) {
355
+ this.refuseWhileRelocating(payload.chatId);
353
356
  this.switchAccount(session, payload.account);
354
357
  }
355
358
  return session.configure(payload);
@@ -712,12 +715,14 @@ export class ChatCore {
712
715
  /* 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
716
  async restart(chatId) {
714
717
  const session = this.require(chatId);
718
+ this.refuseWhileRelocating(chatId);
715
719
  const ended = session.restart();
716
720
  this.skillIndex.forget(session.info.provider, session.info.cwd);
717
721
  await ended;
718
722
  }
719
723
  compact(chatId) {
720
724
  const session = this.require(chatId);
725
+ this.refuseWhileRelocating(chatId);
721
726
  if (session.busy) {
722
727
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
723
728
  }
@@ -726,6 +731,7 @@ export class ChatCore {
726
731
  /* 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. */
727
732
  async clear(chatId, force = false) {
728
733
  const session = this.require(chatId);
734
+ this.refuseWhileRelocating(chatId);
729
735
  if (session.busy && !force) {
730
736
  throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
731
737
  }
@@ -780,9 +786,74 @@ export class ChatCore {
780
786
  releaseHold(chatId, key) {
781
787
  this.chats.get(chatId)?.releaseHold(key);
782
788
  }
789
+ /*
790
+ * Moves a loaded chat to another working folder. `during` is the host's own work and runs with the CLI
791
+ * already ended; messages sent meanwhile queue and go out in the new folder. Any throw keeps the old folder.
792
+ */
793
+ async relocate(chatId, cwd, during) {
794
+ const session = this.require(chatId);
795
+ this.refuseUnlessRelocatable(session);
796
+ const running = this.relocateNow(session, cwd, during);
797
+ const tracked = running.then(() => undefined, () => undefined);
798
+ this.relocations.set(chatId, tracked);
799
+ try {
800
+ return await running;
801
+ }
802
+ finally {
803
+ if (this.relocations.get(chatId) === tracked) {
804
+ this.relocations.delete(chatId);
805
+ }
806
+ }
807
+ }
808
+ /* Whether a relocation of the chat is under way, which a host that forks or copies the chat has to refuse. */
809
+ isRelocating(chatId) {
810
+ return this.relocations.has(chatId);
811
+ }
812
+ async relocateNow(session, cwd, during) {
813
+ const chatId = session.id;
814
+ await this.hooks.admit?.(chatId, cwd);
815
+ // A message sent while the folder was asked about may have opened a turn.
816
+ if (!session.relocatable) {
817
+ throw this.busyRelocating(chatId);
818
+ }
819
+ const previous = session.info.cwd;
820
+ session.beginRelocate();
821
+ try {
822
+ await session.dropProcess();
823
+ await during?.();
824
+ session.setCwd(cwd);
825
+ await this.persistNow(chatId, true);
826
+ }
827
+ catch (e) {
828
+ session.setCwd(previous);
829
+ throw e;
830
+ }
831
+ finally {
832
+ session.endRelocate();
833
+ }
834
+ this.skillIndex.forget(session.info.provider, previous);
835
+ return session.info;
836
+ }
837
+ refuseUnlessRelocatable(session) {
838
+ if (this.relocations.has(session.id) || !session.relocatable) {
839
+ throw this.busyRelocating(session.id);
840
+ }
841
+ }
842
+ busyRelocating(chatId) {
843
+ return new ChatError('chat-busy', `Chat ${chatId} is still working; stop it before moving it to another folder`);
844
+ }
845
+ refuseWhileRelocating(chatId) {
846
+ if (this.relocations.has(chatId)) {
847
+ throw new ChatError('chat-busy', `Chat ${chatId} is being moved to another folder`);
848
+ }
849
+ }
783
850
  /* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
784
851
  async kill(chatId) {
785
852
  await this.loaded(chatId);
853
+ const moving = this.relocations.get(chatId);
854
+ if (moving) {
855
+ await moving;
856
+ }
786
857
  const session = this.require(chatId);
787
858
  // A write already out would put the record back after the delete.
788
859
  const writing = this.writes.get(chatId);
@@ -92,6 +92,8 @@ export declare class ChatSession {
92
92
  private awaitingBackgroundReport;
93
93
  private launchedSelection;
94
94
  private launchedAccount;
95
+ private launchedCwd;
96
+ private relocating;
95
97
  private launchedRuntimeMode;
96
98
  private launchedFolders;
97
99
  private turnReady;
@@ -125,6 +127,11 @@ export declare class ChatSession {
125
127
  resumeAtReset?: boolean;
126
128
  }): ChatInfo;
127
129
  setAccount(account: string | undefined): ChatInfo;
130
+ setCwd(cwd: string): ChatInfo;
131
+ get relocatable(): boolean;
132
+ get isRelocating(): boolean;
133
+ beginRelocate(): void;
134
+ endRelocate(): void;
128
135
  send(text: string, sent?: ChatSendExtras): {
129
136
  queued: boolean;
130
137
  turnId: string;
@@ -190,6 +197,7 @@ export declare class ChatSession {
190
197
  addNote(level: 'info' | 'warning' | 'error', text: string): void;
191
198
  end(reason: string): void;
192
199
  restart(): Promise<void>;
200
+ dropProcess(): Promise<void>;
193
201
  freeze(): void;
194
202
  dispose(): Promise<void>;
195
203
  turnDiff(turnId: string): Promise<ChatCheckpointDiff | null>;
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
2
+ import { ENDED_NOTE_PREFIX, HOST_TASK_ORIGIN, notResumedNote } from '@adecore/agent-contracts';
3
3
  import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure } from '@adecore/intelligent-ui';
4
4
  import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground } from './background-work.js';
5
5
  import { ChatError } from './errors.js';
@@ -55,6 +55,9 @@ export class ChatSession {
55
55
  launchedSelection = null;
56
56
  // The account the running CLI was started under, which what it reports about a plan belongs to.
57
57
  launchedAccount = undefined;
58
+ launchedCwd = null;
59
+ // Set while the host moves the chat's folder: messages queue and nothing starts a process until it ends.
60
+ relocating = false;
58
61
  launchedRuntimeMode = null;
59
62
  launchedFolders = [];
60
63
  // The checkpoint of the turn in flight; everything queued for that turn waits for it.
@@ -189,6 +192,39 @@ export class ChatSession {
189
192
  this.drainQueue();
190
193
  return this.thread.info;
191
194
  }
195
+ /* The next process starts in this folder; the host moves the chat through `ChatCore.relocate`, which has already ended the old one. */
196
+ setCwd(cwd) {
197
+ if (cwd === this.thread.info.cwd) {
198
+ return this.thread.info;
199
+ }
200
+ this.emit([this.thread.patchInfo({ cwd })]);
201
+ this.options.persist();
202
+ return this.thread.info;
203
+ }
204
+ get relocatable() {
205
+ return (!this.frozen &&
206
+ !this.relocating &&
207
+ this.thread.info.activeTurnId === null &&
208
+ !this.processInUse() &&
209
+ this.pendingStart === null &&
210
+ !this.thread.held);
211
+ }
212
+ get isRelocating() {
213
+ return this.relocating;
214
+ }
215
+ /* Messages sent from here on queue, and the queue stays put until `endRelocate`. */
216
+ beginRelocate() {
217
+ this.relocating = true;
218
+ }
219
+ /*
220
+ * Lets the queue go, in whichever folder the chat has now. A wake that found the chat relocating
221
+ * was turned away and waits for an event of this chat, so one goes out even when the queue was empty.
222
+ */
223
+ endRelocate() {
224
+ this.relocating = false;
225
+ this.drainQueue();
226
+ this.emit([this.thread.patchInfo({})]);
227
+ }
192
228
  /*
193
229
  * Internal follow-ups wait for a new turn. User input goes through `sendInput`, which can steer native work.
194
230
  */
@@ -196,7 +232,7 @@ export class ChatSession {
196
232
  const extras = this.vetted(sent);
197
233
  const turnId = newId('turn');
198
234
  // Behind whatever still waits (a CLI that crashed leaves its queue), so everything goes out in the order it was sent.
199
- if (this.busy || this.queue.length > 0 || this.replacementWaits()) {
235
+ if (this.busy || this.queue.length > 0 || this.replacementWaits() || this.relocating) {
200
236
  if (this.busy && this.info.queuePaused) {
201
237
  this.sendAfterStop = true;
202
238
  }
@@ -422,6 +458,7 @@ export class ChatSession {
422
458
  if (!next ||
423
459
  this.steering.size > 0 ||
424
460
  this.busy ||
461
+ this.relocating ||
425
462
  (!explicit && (this.info.queuePaused || (limited !== null && (limited.limit.kind === 'usage' || this.info.resumeAt !== undefined))))) {
426
463
  return;
427
464
  }
@@ -790,7 +827,7 @@ export class ChatSession {
790
827
  * with `origin: 'agent'` and close the wake it just made.
791
828
  */
792
829
  wake(wake) {
793
- if (this.frozen || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
830
+ if (this.frozen || this.relocating || this.thread.info.activeTurnId !== null || this.replacementWaits()) {
794
831
  return null;
795
832
  }
796
833
  this.turnOpened();
@@ -1007,7 +1044,7 @@ export class ChatSession {
1007
1044
  }
1008
1045
  }
1009
1046
  if (turnId !== null) {
1010
- events.push(this.thread.upsert({ id: newId('note'), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason }));
1047
+ events.push(this.thread.upsert({ id: newId(`${ENDED_NOTE_PREFIX}note`), kind: 'note', createdAt: now, turnId, level: 'warning', text: reason }));
1011
1048
  }
1012
1049
  events.push(this.thread.patchInfo({
1013
1050
  running: false,
@@ -1030,6 +1067,13 @@ export class ChatSession {
1030
1067
  if (this.thread.info.activeTurnId !== null || this.processInUse()) {
1031
1068
  throw new ChatError('chat-busy', `Chat ${this.id} is still working; stop it before restarting its agent session`);
1032
1069
  }
1070
+ return this.dropProcess();
1071
+ }
1072
+ /*
1073
+ * Ends the CLI and keeps the conversation, like `restart` without its refusal: the caller has
1074
+ * checked `relocatable` and holds the queue. Settles once the process exited or was sent the SIGKILL.
1075
+ */
1076
+ dropProcess() {
1033
1077
  this.clearStartWarning();
1034
1078
  this.clearWorkflowWarnings();
1035
1079
  const backend = this.backend;
@@ -1482,6 +1526,7 @@ export class ChatSession {
1482
1526
  (JSON.stringify(this.info.selection) !== JSON.stringify(this.launchedSelection) ||
1483
1527
  this.info.runtimeMode !== this.launchedRuntimeMode ||
1484
1528
  this.info.account !== this.launchedAccount ||
1529
+ this.info.cwd !== this.launchedCwd ||
1485
1530
  !sameFolders(this.options.folders?.() ?? [], this.launchedFolders)));
1486
1531
  }
1487
1532
  hasNativeWork() {
@@ -1519,6 +1564,7 @@ export class ChatSession {
1519
1564
  const info = this.thread.info;
1520
1565
  this.launchedSelection = info.selection;
1521
1566
  this.launchedRuntimeMode = info.runtimeMode;
1567
+ this.launchedCwd = info.cwd;
1522
1568
  const folders = this.options.folders?.() ?? [];
1523
1569
  this.launchedFolders = folders;
1524
1570
  const launch = {
@@ -10,7 +10,7 @@ export { type ChatRecordExtras, type ChatRecord, ChatStore, type ChatSeq, type C
10
10
  export { suggestChatTitle, type ChatTitleInput } from './chat-title.ts';
11
11
  export type { ClaudeBackendOptions } from './claude-backend.ts';
12
12
  export { ClaudeTitleReader } from './claude-title.ts';
13
- export { spokenText, claudeProjectSlug, findClaudeSession } from './claude-transcript.ts';
13
+ export { claudeProjectSlug, findClaudeSession } from './claude-transcript.ts';
14
14
  export type { CodexBackendOptions } from './codex-backend.ts';
15
15
  export { forkThreadOnce, type ThreadItemsParams, type CodexProcessSpec, type ThreadForkParams } from './codex-thread.ts';
16
16
  export type { CodexClientInfo } from './codex-transport.ts';
@@ -26,4 +26,4 @@ export { ChatThread } from './thread.ts';
26
26
  export { type ChatUiSource, type ChatUiHost, ChatUiRefusal } from './ui-queries.ts';
27
27
  export { visualFileName, VisualStore, type VisualInput, type VisualStoreOptions, type VisualListener } from './visual-store.ts';
28
28
  export { type WakeChat, chatOpener, type ChatOpenerDeps } from './wake-chat.ts';
29
- export { type SkillRootOptions, SkillIndex } from '../skills.ts';
29
+ export { type SkillRootOptions } from '../skills.ts';
@@ -9,7 +9,7 @@ export { ChatSession } from './chat-session.js';
9
9
  export { ChatStore } from './chat-store.js';
10
10
  export { suggestChatTitle } from './chat-title.js';
11
11
  export { ClaudeTitleReader } from './claude-title.js';
12
- export { spokenText, claudeProjectSlug, findClaudeSession } from './claude-transcript.js';
12
+ export { claudeProjectSlug, findClaudeSession } from './claude-transcript.js';
13
13
  export { forkThreadOnce } from './codex-thread.js';
14
14
  export { ChatError } from './errors.js';
15
15
  export { listClaudeSessions, listCodexSessions, nodeSessionFs } from './external-sessions.js';
@@ -20,4 +20,4 @@ export { ChatThread } from './thread.js';
20
20
  export { ChatUiRefusal } from './ui-queries.js';
21
21
  export { visualFileName, VisualStore } from './visual-store.js';
22
22
  export { chatOpener } from './wake-chat.js';
23
- export { SkillIndex } from '../skills.js';
23
+ export {} from '../skills.js';
@@ -17,6 +17,7 @@ export declare class ChatThread {
17
17
  upsert(item: ChatItem): ChatEvent;
18
18
  appendText(itemId: string, text: string): ChatEvent | null;
19
19
  patchInfo(patch: Partial<ChatInfo>): ChatEvent;
20
+ get held(): boolean;
20
21
  hold(key: string): boolean;
21
22
  release(key: string): boolean;
22
23
  reset(patch: Partial<ChatInfo>): ChatEvent;
@@ -69,6 +69,9 @@ export class ChatThread {
69
69
  this.info = { ...this.info, ...held };
70
70
  return { type: 'info', info: this.info };
71
71
  }
72
+ get held() {
73
+ return this.holds.size > 0;
74
+ }
72
75
  /* False when the key held the chat already. */
73
76
  hold(key) {
74
77
  if (this.holds.has(key)) {
@@ -1,6 +1,7 @@
1
1
  import type { z } from 'zod';
2
- import type { ChatUiLinkPayload, ChatUiLinkReading, ChatAssistantItem, ChatInfo, ChatUiChoicePayload, ChatUiQueryPayload, ChatUiQueryReading } from '@adecore/agent-contracts';
2
+ import type { ChatUiLinkPayload, ChatUiLinkReading, ChatUiPlaceReading, ChatAssistantItem, ChatInfo, ChatUiChoicePayload, ChatUiQueryPayload, ChatUiQueryReading } from '@adecore/agent-contracts';
3
3
  import { type UiValue } from '@adecore/intelligent-ui';
4
+ import { type UiPlaceQuery } from '@adecore/intelligent-ui/places';
4
5
  import { type UiLinkTarget } from '@adecore/intelligent-ui/links';
5
6
  import type { ChatSession } from './chat-session.ts';
6
7
  import { ChatError } from './errors.ts';
@@ -14,6 +15,7 @@ export interface ChatUiHost {
14
15
  capture(info: ChatInfo): Promise<unknown>;
15
16
  sources: Readonly<Record<string, ChatUiSource>>;
16
17
  link?(info: ChatInfo, access: unknown, target: UiLinkTarget): Promise<ChatUiLinkReading>;
18
+ place?(info: ChatInfo, query: UiPlaceQuery, signal: AbortSignal): Promise<Omit<ChatUiPlaceReading, 'address'>>;
17
19
  }
18
20
  declare class UiQueryError extends ChatError<string> {
19
21
  readonly reason: string;
@@ -26,6 +28,7 @@ export declare class ChatUiQueries {
26
28
  readonly schemas: Record<string, z.ZodType>;
27
29
  private readonly captures;
28
30
  private readonly freezing;
31
+ private readonly lives;
29
32
  private readonly cache;
30
33
  private readonly latest;
31
34
  private readonly newest;
@@ -36,6 +39,10 @@ export declare class ChatUiQueries {
36
39
  private readonly host;
37
40
  constructor(host: ChatUiHost);
38
41
  observe(session: ChatSession, item: ChatAssistantItem, final: boolean): void;
42
+ whenFrozen(session: ChatSession, itemId: string): Promise<void>;
43
+ private lookUpPlaces;
44
+ private lifeOf;
45
+ private placeReading;
39
46
  private access;
40
47
  private storedBlock;
41
48
  private stored;
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { ChatUiPlaceReadingSchema } from '@adecore/agent-contracts';
2
3
  import { copyUiValue, UI_HOST_LIMITS, UiBudget, uiMayReferenceHost } from '@adecore/intelligent-ui';
4
+ import { uiPlaceQueries } from '@adecore/intelligent-ui/places';
3
5
  import { UiLinkResolutionSchema, uiLinkTargets } from '@adecore/intelligent-ui/links';
4
6
  import { uiQueryArguments, uiQueryFallback } from '@adecore/intelligent-ui/query';
5
7
  import { ChatError } from './errors.js';
@@ -26,12 +28,19 @@ export class ChatUiRefusal extends UiQueryError {
26
28
  const MAX_QUERY_BYTES = 64 * 1024;
27
29
  const MAX_SNAPSHOT_BYTES = 128 * 1024;
28
30
  const READ_TIMEOUT_MS = 8_000;
31
+ const PLACE_TIMEOUT_MS = 5_000;
32
+ // Lookups run a few at a time, share one budget per block, and stop trying once the host keeps timing out.
33
+ const PLACE_PARALLEL = 4;
34
+ const PLACE_BLOCK_BUDGET_MS = 15_000;
35
+ const PLACE_TIMEOUTS_IN_A_ROW = 3;
29
36
  // A changed input reads at once, but a query never reads more often than this across its inputs.
30
37
  const INPUT_READ_MS = 250;
31
38
  export class ChatUiQueries {
32
39
  schemas;
33
40
  captures = new Map();
34
- freezing = new Set();
41
+ freezing = new Map();
42
+ // Ends with the chat, so a lookup that is under way stops when its chat does.
43
+ lives = new Map();
35
44
  cache = new Map();
36
45
  // The read id of the newest reading per cache key, which a remount or a second client is answered with.
37
46
  latest = new Map();
@@ -60,8 +69,7 @@ export class ChatUiQueries {
60
69
  if (this.freezing.has(key)) {
61
70
  return;
62
71
  }
63
- this.freezing.add(key);
64
- void capture
72
+ const frozen = capture
65
73
  .then(async () => {
66
74
  for (const block of (item.ui ?? []).slice(0, 16)) {
67
75
  const current = session.thread.get(item.id);
@@ -99,6 +107,10 @@ export class ChatUiQueries {
99
107
  }
100
108
  }
101
109
  }
110
+ const { places, found, ended } = await this.lookUpPlaces(session, item.id, block, values);
111
+ if (ended) {
112
+ return;
113
+ }
102
114
  const latest = session.thread.get(item.id);
103
115
  if (latest?.kind !== 'assistant' || !latest.uiQueries || latest.ui?.find((entry) => entry.id === block.id)?.revision !== block.revision) {
104
116
  return;
@@ -114,7 +126,8 @@ export class ChatUiQueries {
114
126
  revision: block.revision,
115
127
  readings,
116
128
  links,
117
- fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}`
129
+ ...(Object.keys(places).length > 0 ? { places } : {}),
130
+ fallback: `${uiQueryFallback(block, values).slice(0, 8192)}${names ? `\nSources: ${names}` : ''}${failures ? `\n${failures}` : ''}${found.length > 0 ? `\nPlaces: ${found.join('; ')}` : ''}`
118
131
  };
119
132
  const blocks = { ...latest.uiQueries.blocks, [block.id]: frozen };
120
133
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
@@ -123,16 +136,131 @@ export class ChatUiQueries {
123
136
  { state: 'failed', readAt: Date.now(), code: 'snapshot-too-large', reason: 'The frozen query values are too large.' }
124
137
  ]));
125
138
  frozen.links = Object.fromEntries(Object.keys(links).map((id) => [id, { state: 'plain', code: 'link-unchecked', reason: 'This link must be checked again.' }]));
139
+ if (frozen.places) {
140
+ frozen.places = Object.fromEntries(Object.entries(places).map(([id, reading]) => [
141
+ id,
142
+ { state: 'missing', address: reading.address, code: 'snapshot-too-large', reason: 'The frozen places are too large.' }
143
+ ]));
144
+ }
126
145
  frozen.fallback = `${block.fallback.slice(0, 2048)}\nSources: ${names}\nThe frozen query values are too large.`;
127
146
  }
128
147
  if (Buffer.byteLength(JSON.stringify(blocks)) > MAX_SNAPSHOT_BYTES) {
129
- delete blocks[block.id];
148
+ // The block stays marked as frozen with nothing in it, so a reader stops waiting for readings that will not come.
149
+ blocks[block.id] = { revision: block.revision, readings: {}, fallback: '' };
130
150
  }
131
151
  session.updateUiQueries(item.id, { ...latest.uiQueries, blocks });
132
152
  }
133
153
  })
134
154
  .catch(() => { })
135
155
  .finally(() => this.freezing.delete(key));
156
+ this.freezing.set(key, frozen);
157
+ }
158
+ /* Settles once the reply's readings, links and places are frozen, or at once where nothing is being frozen. */
159
+ async whenFrozen(session, itemId) {
160
+ await this.freezing.get(`${session.id}:${itemId}`);
161
+ }
162
+ /*
163
+ * The places of a block that name an address, looked up four at a time within one budget for the block.
164
+ * Past the budget, or after the host timed out three times in a row, the rest freeze as missing without
165
+ * asking. Nothing is asked once the chat ended, the reply is gone or the block changed; the caller then
166
+ * drops the write.
167
+ */
168
+ async lookUpPlaces(session, itemId, block, values) {
169
+ const places = Object.create(null);
170
+ const found = [];
171
+ if (!this.host.place) {
172
+ return { places, found, ended: false };
173
+ }
174
+ let queries;
175
+ try {
176
+ queries = Object.entries(uiPlaceQueries(block, {}, values)).slice(0, UI_HOST_LIMITS.places);
177
+ }
178
+ catch {
179
+ return { places, found, ended: false };
180
+ }
181
+ const life = this.lifeOf(session.id).signal;
182
+ const budget = new AbortController();
183
+ const timer = setTimeout(() => budget.abort(), PLACE_BLOCK_BUDGET_MS);
184
+ timer.unref?.();
185
+ const signal = AbortSignal.any([life, budget.signal]);
186
+ const readings = new Map();
187
+ let timeouts = 0;
188
+ let next = 0;
189
+ const stillThere = () => {
190
+ const current = session.thread.get(itemId);
191
+ return !life.aborted && current?.kind === 'assistant' && current.ui?.find((entry) => entry.id === block.id)?.revision === block.revision;
192
+ };
193
+ const worker = async () => {
194
+ for (let index = next++; index < queries.length; index = next++) {
195
+ const [id, query] = queries[index];
196
+ if (!stillThere()) {
197
+ return;
198
+ }
199
+ if (budget.signal.aborted || timeouts >= PLACE_TIMEOUTS_IN_A_ROW) {
200
+ readings.set(id, { state: 'missing', address: query.address, code: 'timed-out', reason: 'The place lookup timed out.' });
201
+ continue;
202
+ }
203
+ const reading = await this.placeReading(session.info, query, signal);
204
+ timeouts = reading.code === 'timed-out' ? timeouts + 1 : 0;
205
+ readings.set(id, reading);
206
+ }
207
+ };
208
+ try {
209
+ await Promise.all(Array.from({ length: Math.min(PLACE_PARALLEL, queries.length) }, worker));
210
+ }
211
+ finally {
212
+ clearTimeout(timer);
213
+ }
214
+ for (const [id, query] of queries) {
215
+ const reading = readings.get(id);
216
+ if (reading === undefined) {
217
+ continue;
218
+ }
219
+ places[id] = reading;
220
+ if (reading.state === 'found') {
221
+ found.push(`${query.name || query.address}: ${reading.lat}, ${reading.lon}`);
222
+ }
223
+ }
224
+ return { places, found, ended: life.aborted };
225
+ }
226
+ lifeOf(chatId) {
227
+ let life = this.lives.get(chatId);
228
+ if (life === undefined) {
229
+ life = new AbortController();
230
+ this.lives.set(chatId, life);
231
+ }
232
+ return life;
233
+ }
234
+ /*
235
+ * One lookup, cut off after five seconds or when `outer` aborts. Whatever goes wrong is a reading that says
236
+ * the place is missing. Only a refusal the host chose its words for (`ChatUiRefusal`) carries them: any
237
+ * other error stays out of the shared record, which every client reads.
238
+ */
239
+ async placeReading(info, query, outer) {
240
+ const timeout = new AbortController();
241
+ const timer = setTimeout(() => timeout.abort(), PLACE_TIMEOUT_MS);
242
+ timer.unref?.();
243
+ const signal = AbortSignal.any([timeout.signal, outer]);
244
+ try {
245
+ const reading = await Promise.race([
246
+ this.host.place(info, query, signal),
247
+ new Promise((_, reject) => signal.addEventListener('abort', () => reject(new UiQueryError('refused-query', 'timed-out', 'The place lookup timed out.')), {
248
+ once: true
249
+ }))
250
+ ]);
251
+ const parsed = ChatUiPlaceReadingSchema.parse({ ...copyUiValue(reading, new UiBudget()), address: query.address });
252
+ return parsed.state === 'found' && (parsed.lat === undefined || parsed.lon === undefined)
253
+ ? { state: 'missing', address: query.address, code: 'place-unreadable', reason: 'The host found no point for this address.' }
254
+ : parsed;
255
+ }
256
+ catch (error) {
257
+ return error instanceof UiQueryError
258
+ ? { state: 'missing', address: query.address, ...this.failure(error) }
259
+ : { state: 'missing', address: query.address, code: 'lookup-failed', reason: 'The place could not be looked up.' };
260
+ }
261
+ finally {
262
+ clearTimeout(timer);
263
+ }
136
264
  }
137
265
  /* The writer's access for a reply, captured once; null where it was denied or is unavailable. */
138
266
  access(session, item) {
@@ -349,6 +477,8 @@ export class ChatUiQueries {
349
477
  }
350
478
  }
351
479
  forget(chatId) {
480
+ this.lives.get(chatId)?.abort();
481
+ this.lives.delete(chatId);
352
482
  for (const [id, cached] of this.cache) {
353
483
  if (cached.chatId === chatId) {
354
484
  this.evict(id);
@@ -387,7 +517,7 @@ export class ChatUiQueries {
387
517
  return reading;
388
518
  }
389
519
  failure(error) {
390
- const reason = (error instanceof Error ? error.message : 'This query could not be read.').slice(0, 240);
391
- return error instanceof UiQueryError ? { reason, code: error.reason } : { reason };
520
+ // The reason is frozen into a record every client reads, so a message the host did not choose for that stays out of it.
521
+ return error instanceof UiQueryError ? { reason: error.message.slice(0, 240), code: error.reason } : { reason: 'This query could not be read.' };
392
522
  }
393
523
  }
@@ -11,6 +11,7 @@ export interface WakeChat {
11
11
  taskIds: string[];
12
12
  messageFrom?: string[];
13
13
  }): boolean;
14
+ relocating?(): boolean;
14
15
  }
15
16
  export interface ChatOpenerDeps {
16
17
  chats: Pick<ChatCore, 'get' | 'hasStored' | 'create' | 'save'>;
@@ -18,6 +18,7 @@ export function chatOpener(deps) {
18
18
  return session
19
19
  ? {
20
20
  items: () => session.thread.list(),
21
+ relocating: () => session.isRelocating,
21
22
  persist: () => deps.chats.save(chatId),
22
23
  // 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.
23
24
  wake: (wake) => session.info.resumeAt === undefined && session.wake(wake) !== null
package/dist/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export { wait, withTimeout } from './async.ts';
2
2
  export { CodedError } from './coded-error.ts';
3
3
  export { describeError, setErrorStacks, errorText } from './error-text.ts';
4
4
  export { tempNameFor, writeAtomic, replaceSync, writeAtomicSync, isNotFound, fileExists, type WriteAtomicOptions } from './fs.ts';
5
- export { recordFileName, RecordDirectory, type RecordDirectoryOptions } from './record-directory.ts';
5
+ export { RecordDirectory, type RecordDirectoryOptions } from './record-directory.ts';
6
6
  export { runProcess, type ProcessResult, type RunProcessOptions } from './run-process.ts';
7
7
  export { Serializer, KeyedSerializer } from './serializer.ts';
8
8
  export { cleanTitle, readLines } from './title-file.ts';
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ export { wait, withTimeout } from './async.js';
3
3
  export { CodedError } from './coded-error.js';
4
4
  export { describeError, setErrorStacks, errorText } from './error-text.js';
5
5
  export { tempNameFor, writeAtomic, replaceSync, writeAtomicSync, isNotFound, fileExists } from './fs.js';
6
- export { recordFileName, RecordDirectory } from './record-directory.js';
6
+ export { RecordDirectory } from './record-directory.js';
7
7
  export { runProcess } from './run-process.js';
8
8
  export { Serializer, KeyedSerializer } from './serializer.js';
9
9
  export { cleanTitle, readLines } from './title-file.js';
package/dist/lineage.d.ts CHANGED
@@ -24,6 +24,7 @@ export declare class AgentLineageStore {
24
24
  load(): Promise<void>;
25
25
  put(record: Omit<Lineage, 'createdAt' | 'endedAt'>): Promise<void>;
26
26
  descendants(nodeId: string): string[];
27
+ rehome(nodeId: string, projectId: string): Promise<boolean>;
27
28
  projectOf(nodeId: string): string | null;
28
29
  endedAt(nodeId: string): number | null;
29
30
  markEnded(nodeIds: readonly string[]): Promise<void>;
package/dist/lineage.js CHANGED
@@ -60,6 +60,14 @@ export class AgentLineageStore {
60
60
  }
61
61
  return found;
62
62
  }
63
+ /* 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. */
64
+ async rehome(nodeId, projectId) {
65
+ const entry = this.opened.get(nodeId);
66
+ if (entry === undefined) {
67
+ return false;
68
+ }
69
+ return this.opened.write({ ...entry, projectId });
70
+ }
63
71
  /* The project a node was made in, for work owed about it after the node that opened it is gone. */
64
72
  projectOf(nodeId) {
65
73
  return this.opened.get(nodeId)?.projectId ?? null;