@adecore/agents 0.38.0 → 0.40.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/async.d.ts +5 -0
- package/dist/async.js +13 -5
- package/dist/chat/acp-protocol.js +3 -5
- package/dist/chat/chat-core.d.ts +1 -0
- package/dist/chat/chat-core.js +9 -2
- package/dist/chat/chat-process.d.ts +1 -0
- package/dist/chat/chat-process.js +1 -1
- package/dist/chat/chat-session.d.ts +2 -0
- package/dist/chat/chat-session.js +9 -10
- package/dist/chat/claude-protocol.js +1 -6
- package/dist/chat/claude-transcript.js +1 -3
- package/dist/chat/codex-protocol.js +1 -6
- package/dist/chat/projector.js +1 -3
- package/dist/chat/request-summary.js +1 -3
- package/dist/coded-error.js +1 -5
- package/dist/context/argv.js +1 -2
- package/dist/context/verb.js +1 -3
- package/dist/error-text.d.ts +1 -0
- package/dist/error-text.js +4 -0
- package/dist/fields.d.ts +3 -0
- package/dist/fields.js +12 -0
- package/dist/fs.d.ts +1 -0
- package/dist/fs.js +8 -4
- package/dist/lineage.js +11 -5
- package/dist/messages/notice-store.js +3 -4
- package/dist/providers/accounts/acp-account.js +3 -20
- package/dist/providers/accounts/service.d.ts +1 -2
- package/dist/providers/accounts/service.js +5 -9
- package/dist/providers/accounts/status.js +1 -3
- package/dist/providers/acp-agent-state.js +1 -3
- package/dist/providers/claude.js +0 -1
- package/dist/providers/codex.js +0 -1
- package/dist/providers/detect.js +1 -1
- package/dist/providers/shell-commands.js +1 -2
- package/dist/run-process.js +1 -3
- package/dist/skills.d.ts +1 -1
- package/dist/skills.js +5 -8
- package/dist/tasks/background-limit.js +3 -2
- package/dist/tasks/task-coordinator.js +11 -17
- package/dist/testing/watch.js +0 -1
- package/dist/usage/limits/monitor.js +1 -2
- package/dist/usage/readers/claude.js +3 -10
- package/dist/usage/readers/codex.js +2 -9
- package/dist/usage/readers/grok.js +3 -10
- package/dist/usage/readers/host-log.js +3 -10
- package/dist/usage/record.d.ts +2 -0
- package/dist/usage/record.js +13 -0
- package/package.json +6 -6
- package/src/async.ts +23 -5
- package/src/chat/acp-backend.ts +121 -0
- package/src/chat/acp-protocol.ts +172 -6
- package/src/chat/attachment-store.ts +21 -0
- package/src/chat/backend.ts +0 -2
- package/src/chat/bookmark-store.ts +23 -0
- package/src/chat/chat-core.ts +218 -7
- package/src/chat/chat-log.ts +19 -0
- package/src/chat/chat-process.ts +52 -1
- package/src/chat/chat-session.ts +414 -13
- package/src/chat/chat-store.ts +50 -0
- package/src/chat/chat-title.ts +9 -0
- package/src/chat/claude-backend.ts +50 -0
- package/src/chat/claude-protocol.ts +155 -8
- package/src/chat/claude-title.ts +10 -0
- package/src/chat/claude-transcript.ts +47 -4
- package/src/chat/codex-backend.ts +131 -0
- package/src/chat/codex-protocol.ts +180 -8
- package/src/chat/codex-thread.ts +23 -0
- package/src/chat/composer-preferences.ts +6 -0
- package/src/chat/context-breakdown.ts +11 -0
- package/src/chat/delta-coalescer.ts +8 -0
- package/src/chat/external-sessions.ts +28 -0
- package/src/chat/generated-image.ts +25 -0
- package/src/chat/index.ts +5 -0
- package/src/chat/input.ts +13 -0
- package/src/chat/json-rpc-transport.ts +28 -0
- package/src/chat/limit-resume.ts +13 -0
- package/src/chat/projector.ts +160 -4
- package/src/chat/readable-text.ts +3 -0
- package/src/chat/request-summary.ts +18 -4
- package/src/chat/subagent-projection.ts +3 -0
- package/src/chat/subagent-reader.ts +76 -0
- package/src/chat/subagent-settlement.ts +24 -0
- package/src/chat/thread-search.ts +13 -0
- package/src/chat/thread.ts +31 -0
- package/src/chat/ui-queries.ts +122 -0
- package/src/chat/ui-replies.ts +34 -0
- package/src/chat/visual-store.ts +49 -0
- package/src/chat/wake-chat.ts +3 -0
- package/src/client-sinks.ts +2 -0
- package/src/coded-error.ts +1 -5
- package/src/context/argv.ts +18 -2
- package/src/context/index.ts +1 -0
- package/src/context/refusal.ts +3 -0
- package/src/context/verb.ts +32 -4
- package/src/coordination/index.ts +10 -0
- package/src/error-text.ts +7 -0
- package/src/fields.ts +14 -0
- package/src/fs.ts +20 -4
- package/src/host/agent-host.ts +20 -0
- package/src/host/environment.ts +6 -0
- package/src/host/handlers.ts +18 -0
- package/src/host/memory-port.ts +4 -0
- package/src/host/wiring.ts +10 -0
- package/src/index.ts +1 -0
- package/src/lineage.ts +24 -5
- package/src/messages/deliver-message.ts +6 -0
- package/src/messages/deliver-notice.ts +13 -0
- package/src/messages/notice-store.ts +16 -4
- package/src/modes.ts +1 -0
- package/src/outbox/outbox-worker.ts +31 -0
- package/src/outbox/outbox.ts +5 -0
- package/src/providers/accounts/accounts.ts +36 -0
- package/src/providers/accounts/acp-account.ts +22 -27
- package/src/providers/accounts/launch.ts +4 -0
- package/src/providers/accounts/service.ts +110 -12
- package/src/providers/accounts/shadow-home.ts +10 -0
- package/src/providers/accounts/status.ts +13 -4
- package/src/providers/accounts/store.ts +25 -0
- package/src/providers/accounts/variables.ts +11 -1
- package/src/providers/acp-agent-state.ts +24 -3
- package/src/providers/acp-provider.ts +5 -0
- package/src/providers/acp-usage-log.ts +2 -0
- package/src/providers/catalog.ts +17 -0
- package/src/providers/claude.ts +10 -1
- package/src/providers/codex-rules.ts +3 -0
- package/src/providers/codex.ts +2 -1
- package/src/providers/detect.ts +6 -1
- package/src/providers/provider.ts +1 -1
- package/src/providers/registry.ts +18 -2
- package/src/providers/shell-commands.ts +37 -2
- package/src/record-directory.ts +19 -0
- package/src/run-process.ts +24 -3
- package/src/serializer.ts +3 -0
- package/src/skills.ts +73 -10
- package/src/tasks/background-limit.ts +9 -2
- package/src/tasks/end-children.ts +9 -0
- package/src/tasks/give-task.ts +8 -0
- package/src/tasks/task-coordinator.ts +81 -17
- package/src/tasks/task-store.ts +28 -0
- package/src/tasks/waiting-child.ts +21 -0
- package/src/tasks/wake-parent.ts +26 -0
- package/src/tasks/wiring.ts +22 -0
- package/src/testing/accounts.ts +2 -0
- package/src/testing/fake-acp-capture.ts +16 -0
- package/src/testing/fake-acp.ts +66 -0
- package/src/testing/fake-claude.ts +177 -0
- package/src/testing/fake-cli.ts +29 -0
- package/src/testing/fake-codex.ts +122 -0
- package/src/testing/manual-clock.ts +2 -0
- package/src/testing/watch.ts +7 -1
- package/src/title-file.ts +12 -0
- package/src/usage/accounts.ts +6 -0
- package/src/usage/aggregate.ts +19 -0
- package/src/usage/exchange.ts +12 -0
- package/src/usage/index-file.ts +27 -0
- package/src/usage/limits/monitor.ts +31 -3
- package/src/usage/limits/normalize.ts +30 -0
- package/src/usage/limits/probe.ts +17 -0
- package/src/usage/pricing.ts +35 -0
- package/src/usage/projects.ts +8 -0
- package/src/usage/readers/claude.ts +12 -9
- package/src/usage/readers/codex.ts +40 -8
- package/src/usage/readers/grok.ts +17 -9
- package/src/usage/readers/host-log.ts +9 -9
- package/src/usage/record.ts +22 -0
- package/src/usage/roots.ts +6 -0
- package/src/usage/scanner.ts +61 -0
- package/src/usage/usage-service.ts +14 -0
- package/src/watch-seam.ts +8 -0
- package/src/windows-command.ts +12 -0
package/src/chat/chat-core.ts
CHANGED
|
@@ -76,6 +76,7 @@ type BookmarkableItem = Extract<ChatItem, { kind: 'user' | 'assistant' }>;
|
|
|
76
76
|
|
|
77
77
|
function withoutHidden(info: ChatInfo): ChatInfo {
|
|
78
78
|
const { hidden: _hidden, ...rest } = info;
|
|
79
|
+
|
|
79
80
|
return rest;
|
|
80
81
|
}
|
|
81
82
|
|
|
@@ -88,6 +89,7 @@ function isBookmarkable(item: ChatItem): item is BookmarkableItem {
|
|
|
88
89
|
function excerptOf(item: BookmarkableItem): string {
|
|
89
90
|
const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
|
|
90
91
|
const text = item.kind === 'assistant' ? readableAssistantText(item) : item.text;
|
|
92
|
+
|
|
91
93
|
return (text.trim() === '' ? files : text).replace(/\s+/g, ' ').trim();
|
|
92
94
|
}
|
|
93
95
|
|
|
@@ -176,16 +178,13 @@ export interface ChatCoreOptions {
|
|
|
176
178
|
env?: Record<string, string | undefined>;
|
|
177
179
|
// What the agent is told once, at the start of every process, when the host has one note for every chat.
|
|
178
180
|
instructions?: string;
|
|
179
|
-
// The CLIs to run, when they are not the ones the providers name
|
|
181
|
+
// The CLIs to run, when they are not the ones the providers name.
|
|
180
182
|
command?: string[];
|
|
181
183
|
codexCommand?: string[];
|
|
182
|
-
// How a CLI is started; a test runs a fake in the same process instead of spawning one.
|
|
183
184
|
spawn?: SpawnChatProcess;
|
|
184
185
|
// How the host names itself to an app-server it opens for a question about a thread.
|
|
185
186
|
codexClient?: CodexClientInfo;
|
|
186
|
-
// Where the skill folders are looked for; a test points it at a temporary tree.
|
|
187
187
|
skills?: SkillIndex;
|
|
188
|
-
// Where the files people attach are written.
|
|
189
188
|
attachments: AttachmentStore;
|
|
190
189
|
bookmarks?: BookmarkStore;
|
|
191
190
|
// Pages agents publish in a chat; their files go in the same attachment folders.
|
|
@@ -196,7 +195,7 @@ export interface ChatCoreOptions {
|
|
|
196
195
|
claudeTitles?: { forSession(agentSessionId: string, projectsDir: string, cwd: string): Promise<string | null> };
|
|
197
196
|
// A name for a Codex chat, asked of a one-shot CLI: its app-server names no thread on its own.
|
|
198
197
|
nameChat?: (provider: AgentKind, input: ChatTitleInput) => Promise<string | null>;
|
|
199
|
-
// Where a subagent's whole conversation is read and how its growth is noticed
|
|
198
|
+
// Where a subagent's whole conversation is read and how its growth is noticed.
|
|
200
199
|
subagents?: Partial<Pick<SubagentReaderOptions, 'claudeProjectsDir' | 'seams' | 'now' | 'listOnce'>>;
|
|
201
200
|
/*
|
|
202
201
|
* Asked when a chat is loaded with a turn that could be resumed: true once the resume is owed,
|
|
@@ -291,28 +290,36 @@ export class ChatCore {
|
|
|
291
290
|
this.onInterruptedRun = options.onInterruptedRun ?? null;
|
|
292
291
|
this.limitResume = options.limitResume ?? null;
|
|
293
292
|
this.hooks = options.hooks ?? {};
|
|
293
|
+
|
|
294
294
|
this.commands = {
|
|
295
295
|
...(options.command ? { claude: options.command } : {}),
|
|
296
296
|
...(options.codexCommand ? { codex: options.codexCommand } : {})
|
|
297
297
|
};
|
|
298
|
+
|
|
298
299
|
this.spawn = options.spawn ?? null;
|
|
299
300
|
this.codexClient = options.codexClient ?? null;
|
|
300
301
|
this.env = definedEnv(options.env ?? process.env);
|
|
302
|
+
|
|
301
303
|
// Only whoever reads the thread has anything to point a bookmark at or to place a visual in, so the lists go where the thread goes.
|
|
302
304
|
this.bookmarks?.listen((chatId, bookmarks) => {
|
|
303
305
|
this.listChanged(chatId);
|
|
306
|
+
|
|
304
307
|
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
305
308
|
this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
|
|
306
309
|
}
|
|
307
310
|
});
|
|
311
|
+
|
|
308
312
|
this.visuals?.listen((chatId, visuals) => {
|
|
309
313
|
this.listChanged(chatId);
|
|
314
|
+
|
|
310
315
|
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
311
316
|
this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
|
|
312
317
|
}
|
|
313
318
|
});
|
|
319
|
+
|
|
314
320
|
this.claudeProjectsDir =
|
|
315
321
|
options.subagents?.claudeProjectsDir ?? usageRoots(options.env ?? process.env).find((root) => root.provider === 'claude')?.path ?? '';
|
|
322
|
+
|
|
316
323
|
this.subagents = new SubagentReader({
|
|
317
324
|
...options.subagents,
|
|
318
325
|
claudeProjectsDir: this.claudeProjectsDir,
|
|
@@ -320,6 +327,7 @@ export class ChatCore {
|
|
|
320
327
|
chatInfo: async (chatId) => (await this.readChat(chatId))?.info ?? null,
|
|
321
328
|
chat: (chatId) => {
|
|
322
329
|
const session = this.chats.get(chatId);
|
|
330
|
+
|
|
323
331
|
return session
|
|
324
332
|
? {
|
|
325
333
|
info: session.info,
|
|
@@ -351,15 +359,17 @@ export class ChatCore {
|
|
|
351
359
|
if (isDefaultAccountOf('claude', info.account)) {
|
|
352
360
|
return this.claudeProjectsDir;
|
|
353
361
|
}
|
|
362
|
+
|
|
354
363
|
const folder = this.accounts?.transcriptFolder('claude', info.account) ?? null;
|
|
364
|
+
|
|
355
365
|
return folder === null ? '' : join(folder, 'projects');
|
|
356
366
|
}
|
|
357
367
|
|
|
358
|
-
/* Whether a conversation of one account of this CLI can go on under the other. */
|
|
359
368
|
canContinue(kind: AgentKind, from: string | undefined, to: string | undefined): boolean {
|
|
360
369
|
if ((from ?? kind) === (to ?? kind)) {
|
|
361
370
|
return true;
|
|
362
371
|
}
|
|
372
|
+
|
|
363
373
|
return this.accounts?.canContinue(kind, from, to) ?? false;
|
|
364
374
|
}
|
|
365
375
|
|
|
@@ -368,7 +378,6 @@ export class ChatCore {
|
|
|
368
378
|
launchEnv(this.accounts, kind, account, this.env);
|
|
369
379
|
}
|
|
370
380
|
|
|
371
|
-
/* What a person calls an account of this CLI. */
|
|
372
381
|
accountLabel(kind: AgentKind, account: string | undefined): string {
|
|
373
382
|
return isDefaultAccountOf(kind, account) || account === undefined ? this.providers.get(kind).name : (this.accounts?.labelOf(account) ?? account);
|
|
374
383
|
}
|
|
@@ -377,10 +386,13 @@ export class ChatCore {
|
|
|
377
386
|
async readChat(chatId: string): Promise<{ info: ChatInfo; items: ChatItem[] } | null> {
|
|
378
387
|
await this.loaded(chatId);
|
|
379
388
|
const session = this.chats.get(chatId);
|
|
389
|
+
|
|
380
390
|
if (session) {
|
|
381
391
|
return session.thread.snapshot();
|
|
382
392
|
}
|
|
393
|
+
|
|
383
394
|
const stored = await this.store?.read(chatId);
|
|
395
|
+
|
|
384
396
|
return stored ? { info: stored.info, items: stored.items } : null;
|
|
385
397
|
}
|
|
386
398
|
|
|
@@ -389,6 +401,7 @@ export class ChatCore {
|
|
|
389
401
|
const catalog = this.providers.get(provider).catalog;
|
|
390
402
|
const preference = this.composerPreferences.for(provider);
|
|
391
403
|
const normalized = catalog.normalize(selection ?? preference.selection);
|
|
404
|
+
|
|
392
405
|
return {
|
|
393
406
|
selection: normalized,
|
|
394
407
|
...(preference.runtimeMode ? { runtimeMode: preference.runtimeMode } : {}),
|
|
@@ -402,6 +415,7 @@ export class ChatCore {
|
|
|
402
415
|
*/
|
|
403
416
|
observe(sink: AgentSink): () => void {
|
|
404
417
|
this.observers.add(sink);
|
|
418
|
+
|
|
405
419
|
return () => {
|
|
406
420
|
this.observers.delete(sink);
|
|
407
421
|
};
|
|
@@ -420,23 +434,30 @@ export class ChatCore {
|
|
|
420
434
|
create(payload: ChatCreatePayload): Promise<ChatInfo> {
|
|
421
435
|
// A create in flight has the chat in the map before its first prompt is sent, so a second caller waits for all of it.
|
|
422
436
|
const inFlight = this.creating.get(payload.chatId);
|
|
437
|
+
|
|
423
438
|
if (inFlight) {
|
|
424
439
|
return inFlight;
|
|
425
440
|
}
|
|
441
|
+
|
|
426
442
|
const existing = this.chats.get(payload.chatId);
|
|
443
|
+
|
|
427
444
|
if (existing) {
|
|
428
445
|
return Promise.resolve(existing.info);
|
|
429
446
|
}
|
|
447
|
+
|
|
430
448
|
const created = this.createNow(payload).finally(() => this.creating.delete(payload.chatId));
|
|
431
449
|
this.creating.set(payload.chatId, created);
|
|
450
|
+
|
|
432
451
|
return created;
|
|
433
452
|
}
|
|
434
453
|
|
|
435
454
|
private async createNow(payload: ChatCreatePayload): Promise<ChatInfo> {
|
|
436
455
|
const stored = await this.store?.read(payload.chatId);
|
|
456
|
+
|
|
437
457
|
if (!stored && (await this.store?.unreadable(payload.chatId))) {
|
|
438
458
|
throw new ChatError('chat-unreadable', `The record of chat ${payload.chatId} cannot be read by this version, so it is left as it is`);
|
|
439
459
|
}
|
|
460
|
+
|
|
440
461
|
// A thread on disk keeps its provider; the selection it stored only makes sense in that catalog.
|
|
441
462
|
const kind = stored?.info.provider ?? payload.provider ?? 'claude';
|
|
442
463
|
const provider = this.providers.get(kind);
|
|
@@ -448,13 +469,18 @@ export class ChatCore {
|
|
|
448
469
|
// A new one without an account takes the person's pick for this CLI, which is refused rather than replaced when it went.
|
|
449
470
|
// The pick is checked as named, before the default account loses its id, so one that is turned off is refused too.
|
|
450
471
|
const requested = payload.account ?? this.composerPreferences.for(kind).account;
|
|
472
|
+
|
|
451
473
|
if (!stored) {
|
|
452
474
|
this.requireAccount(kind, requested);
|
|
453
475
|
}
|
|
476
|
+
|
|
454
477
|
const account = stored ? stored.info.account : storedAccount(kind, requested);
|
|
478
|
+
|
|
455
479
|
const narrowed = (mode: RuntimeMode): RuntimeMode => (this.hooks.runtimeModeFor ? this.hooks.runtimeModeFor(payload.chatId, mode) : mode);
|
|
480
|
+
|
|
456
481
|
// Asked again for a chat on disk, since the host may have listed it since.
|
|
457
482
|
const hidden = this.hooks.hiddenFor?.(payload.chatId) ? { hidden: true } : {};
|
|
483
|
+
|
|
458
484
|
const info: ChatInfo = stored?.info
|
|
459
485
|
? { ...withoutHidden(stored.info), runtimeMode: narrowed(stored.info.runtimeMode), ...hidden }
|
|
460
486
|
: {
|
|
@@ -474,6 +500,7 @@ export class ChatCore {
|
|
|
474
500
|
usage: { contextTokens: 0, contextWindow: catalog.contextWindowFor(selection), costUsd: 0, turns: 0 },
|
|
475
501
|
createdAt: Date.now()
|
|
476
502
|
};
|
|
503
|
+
|
|
477
504
|
const resume: ResumeDecision = stored ? await this.owedResume(payload.chatId, stored) : { resumeTurnId: null, reason: null };
|
|
478
505
|
const claudeTitles = this.claudeTitles;
|
|
479
506
|
const nameChat = this.nameChat;
|
|
@@ -481,6 +508,7 @@ export class ChatCore {
|
|
|
481
508
|
this.logs.set(payload.chatId, await this.openLog(payload.chatId, stored ?? null));
|
|
482
509
|
const promptNotes = this.hooks.promptNotesFor?.(payload.chatId);
|
|
483
510
|
const references = this.hooks.referencesFor?.(payload.chatId);
|
|
511
|
+
|
|
484
512
|
const session = new ChatSession({
|
|
485
513
|
info,
|
|
486
514
|
...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
|
|
@@ -494,6 +522,7 @@ export class ChatCore {
|
|
|
494
522
|
env: (chatAccount) => {
|
|
495
523
|
const env = definedEnv(launchEnv(this.accounts, kind, chatAccount, base));
|
|
496
524
|
this.accounts?.launched?.(kind, chatAccount);
|
|
525
|
+
|
|
497
526
|
return env;
|
|
498
527
|
},
|
|
499
528
|
saveGeneratedImage: (ref, upload) => this.attachments.saveGenerated(payload.chatId, ref, upload),
|
|
@@ -517,24 +546,31 @@ export class ChatCore {
|
|
|
517
546
|
...(kind === 'claude' ? { subagentSettlement: (toolUseId: string) => this.subagents.claudeSettlement(payload.chatId, toolUseId) } : {}),
|
|
518
547
|
...(this.limitResume ? { limitResume: limitHooks(this.limitResume, payload.chatId) } : {})
|
|
519
548
|
});
|
|
549
|
+
|
|
520
550
|
this.chats.set(session.id, session);
|
|
551
|
+
|
|
521
552
|
if (stored) {
|
|
522
553
|
session.settleStored(selection, resume);
|
|
523
554
|
session.settleOwedResume();
|
|
524
555
|
await session.settleOrphanedSubagents();
|
|
525
556
|
}
|
|
557
|
+
|
|
526
558
|
await this.hooks.opened?.(session, stored ?? null);
|
|
559
|
+
|
|
527
560
|
return session.info;
|
|
528
561
|
}
|
|
529
562
|
|
|
530
563
|
async storedBackground(chatId: string): Promise<{ turnId: string | null; items: BackgroundWork[]; commands: string[]; goal: boolean }> {
|
|
531
564
|
const record = await this.store?.read(chatId);
|
|
532
565
|
const items = record?.items ?? [];
|
|
566
|
+
|
|
533
567
|
const background = items.filter(
|
|
534
568
|
(item): item is BackgroundWork =>
|
|
535
569
|
isBackgroundWork(item) && (runsInBackground(item) || items.some((note) => note.id === `lost-background-${item.id}`))
|
|
536
570
|
);
|
|
571
|
+
|
|
537
572
|
const last = items.findLast((item) => item.kind === 'turn');
|
|
573
|
+
|
|
538
574
|
return {
|
|
539
575
|
turnId: background.at(-1)?.turnId ?? last?.id ?? null,
|
|
540
576
|
items: background,
|
|
@@ -553,10 +589,12 @@ export class ChatCore {
|
|
|
553
589
|
|
|
554
590
|
configure(payload: ChatConfigurePayload): ChatInfo {
|
|
555
591
|
const session = this.require(payload.chatId);
|
|
592
|
+
|
|
556
593
|
if (payload.account !== undefined) {
|
|
557
594
|
this.refuseWhileRelocating(payload.chatId);
|
|
558
595
|
this.switchAccount(session, payload.account);
|
|
559
596
|
}
|
|
597
|
+
|
|
560
598
|
return session.configure(payload);
|
|
561
599
|
}
|
|
562
600
|
|
|
@@ -567,19 +605,24 @@ export class ChatCore {
|
|
|
567
605
|
protected switchAccount(session: ChatSession, requested: string): void {
|
|
568
606
|
const { provider: kind, account: from } = session.info;
|
|
569
607
|
const to = storedAccount(kind, requested);
|
|
608
|
+
|
|
570
609
|
if (to === from) {
|
|
571
610
|
return;
|
|
572
611
|
}
|
|
612
|
+
|
|
573
613
|
// As named, so a switch to the default account is refused while it is turned off.
|
|
574
614
|
this.requireAccount(kind, requested);
|
|
575
615
|
const spoke = session.info.agentSessionId !== null || session.info.usage.turns > 0;
|
|
616
|
+
|
|
576
617
|
if (spoke && !this.canContinue(kind, from, to)) {
|
|
577
618
|
const label = this.accountLabel(kind, to);
|
|
619
|
+
|
|
578
620
|
throw new AccountError(
|
|
579
621
|
'account-incompatible',
|
|
580
622
|
`This conversation is kept by the account '${this.accountLabel(kind, from)}', which '${label}' cannot read. Fork the chat to go on under '${label}'.`
|
|
581
623
|
);
|
|
582
624
|
}
|
|
625
|
+
|
|
583
626
|
session.setAccount(to);
|
|
584
627
|
}
|
|
585
628
|
|
|
@@ -590,24 +633,31 @@ export class ChatCore {
|
|
|
590
633
|
attach(chatId: string, clientId: string, historyLimit?: number, since?: number): ChatAttachResult {
|
|
591
634
|
const session = this.require(chatId);
|
|
592
635
|
let clients = this.attached.get(chatId);
|
|
636
|
+
|
|
593
637
|
if (!clients) {
|
|
594
638
|
clients = new Set();
|
|
595
639
|
this.attached.set(chatId, clients);
|
|
596
640
|
}
|
|
641
|
+
|
|
597
642
|
// The snapshot already holds the text of a delta that is waiting, so it goes out before this
|
|
598
643
|
// client is on the list: the text is either in the snapshot or in the stream, never both.
|
|
599
644
|
this.coalescers.get(chatId)?.flush();
|
|
600
645
|
clients.add(clientId);
|
|
601
646
|
const log = this.logs.get(chatId)!;
|
|
602
647
|
const events = since === undefined ? null : log.after(since);
|
|
648
|
+
|
|
603
649
|
if (events !== null) {
|
|
604
650
|
return { info: session.info, items: [], events: [...events, ...session.uiPreviews], seq: log.seq };
|
|
605
651
|
}
|
|
652
|
+
|
|
606
653
|
if (historyLimit === undefined) {
|
|
607
654
|
const snapshot = session.thread.snapshot();
|
|
655
|
+
|
|
608
656
|
return { ...snapshot, info: session.info, items: session.previewItems(snapshot.items), seq: log.seq };
|
|
609
657
|
}
|
|
658
|
+
|
|
610
659
|
const page = session.thread.history(historyLimit);
|
|
660
|
+
|
|
611
661
|
return {
|
|
612
662
|
...page,
|
|
613
663
|
info: session.info,
|
|
@@ -625,22 +675,36 @@ export class ChatCore {
|
|
|
625
675
|
* read leaves its list out rather than the thread.
|
|
626
676
|
*/
|
|
627
677
|
async attachWithBookmarks(chatId: string, clientId: string, historyLimit?: number, since?: number): Promise<ChatAttachResult> {
|
|
678
|
+
// A client that only watches a chat sends no create, so a chat on disk nobody loaded since the host started is loaded for it.
|
|
679
|
+
await this.loaded(chatId);
|
|
680
|
+
|
|
681
|
+
if (!this.chats.has(chatId) && (await this.store?.has(chatId)) === true) {
|
|
682
|
+
await this.create({ chatId });
|
|
683
|
+
}
|
|
684
|
+
|
|
628
685
|
this.require(chatId);
|
|
686
|
+
|
|
629
687
|
const unreadable = (what: string) => (e: unknown) => {
|
|
630
688
|
console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
|
|
689
|
+
|
|
631
690
|
return undefined;
|
|
632
691
|
};
|
|
692
|
+
|
|
633
693
|
let bookmarks: ChatBookmark[] | undefined;
|
|
634
694
|
let visuals: ChatVisual[] | undefined;
|
|
635
695
|
let changes: number | undefined;
|
|
696
|
+
|
|
636
697
|
do {
|
|
637
698
|
changes = this.listChanges.get(chatId);
|
|
699
|
+
|
|
638
700
|
[bookmarks, visuals] = await Promise.all([
|
|
639
701
|
this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
|
|
640
702
|
this.visuals?.list(chatId).catch(unreadable('visuals'))
|
|
641
703
|
]);
|
|
642
704
|
} while (this.listChanges.get(chatId) !== changes);
|
|
705
|
+
|
|
643
706
|
const result = this.attach(chatId, clientId, historyLimit, since);
|
|
707
|
+
|
|
644
708
|
return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
|
|
645
709
|
}
|
|
646
710
|
|
|
@@ -651,19 +715,23 @@ export class ChatCore {
|
|
|
651
715
|
/* Marks a message of the chat's own thread; a message that already has one keeps it. */
|
|
652
716
|
addBookmark(chatId: string, itemId: string, name?: string): Promise<ChatBookmark[]> {
|
|
653
717
|
const item = this.require(chatId).thread.get(itemId);
|
|
718
|
+
|
|
654
719
|
if (!item || !isBookmarkable(item)) {
|
|
655
720
|
throw new ChatError('item-not-found', `Chat ${chatId} has no message ${itemId} to bookmark`);
|
|
656
721
|
}
|
|
722
|
+
|
|
657
723
|
return this.requireBookmarks().add(chatId, { itemId, excerpt: excerptOf(item), ...(name === undefined ? {} : { name }) }, Date.now());
|
|
658
724
|
}
|
|
659
725
|
|
|
660
726
|
renameBookmark(chatId: string, itemId: string, name: string): Promise<ChatBookmark[]> {
|
|
661
727
|
this.require(chatId);
|
|
728
|
+
|
|
662
729
|
return this.requireBookmarks().rename(chatId, itemId, name);
|
|
663
730
|
}
|
|
664
731
|
|
|
665
732
|
removeBookmark(chatId: string, itemId: string): Promise<ChatBookmark[]> {
|
|
666
733
|
this.require(chatId);
|
|
734
|
+
|
|
667
735
|
return this.requireBookmarks().remove(chatId, itemId);
|
|
668
736
|
}
|
|
669
737
|
|
|
@@ -671,6 +739,7 @@ export class ChatCore {
|
|
|
671
739
|
if (!this.bookmarks) {
|
|
672
740
|
throw new ChatError('chat-unsupported', 'This host keeps no bookmarks');
|
|
673
741
|
}
|
|
742
|
+
|
|
674
743
|
return this.bookmarks;
|
|
675
744
|
}
|
|
676
745
|
|
|
@@ -681,10 +750,13 @@ export class ChatCore {
|
|
|
681
750
|
async publishVisual(chatId: string, input: VisualInput): Promise<ChatVisual> {
|
|
682
751
|
const visuals = this.requireVisuals();
|
|
683
752
|
await this.loaded(chatId);
|
|
753
|
+
|
|
684
754
|
if (!(await this.hasStored(chatId))) {
|
|
685
755
|
throw new ChatError('chat-not-found', `No chat ${chatId}`);
|
|
686
756
|
}
|
|
757
|
+
|
|
687
758
|
const turnId = input.turnId ?? this.chats.get(chatId)?.info.activeTurnId ?? undefined;
|
|
759
|
+
|
|
688
760
|
return visuals.publish(chatId, turnId === undefined ? input : { ...input, turnId });
|
|
689
761
|
}
|
|
690
762
|
|
|
@@ -695,6 +767,7 @@ export class ChatCore {
|
|
|
695
767
|
|
|
696
768
|
removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]> {
|
|
697
769
|
this.require(chatId);
|
|
770
|
+
|
|
698
771
|
return this.requireVisuals().remove(chatId, visualId);
|
|
699
772
|
}
|
|
700
773
|
|
|
@@ -702,6 +775,7 @@ export class ChatCore {
|
|
|
702
775
|
if (!this.visuals) {
|
|
703
776
|
throw new ChatError('chat-unsupported', 'This host keeps no visuals');
|
|
704
777
|
}
|
|
778
|
+
|
|
705
779
|
return this.visuals;
|
|
706
780
|
}
|
|
707
781
|
|
|
@@ -709,6 +783,7 @@ export class ChatCore {
|
|
|
709
783
|
history(chatId: string, cursor?: string, limit?: number): ChatHistoryResult {
|
|
710
784
|
const session = this.require(chatId);
|
|
711
785
|
this.coalescers.get(chatId)?.flush();
|
|
786
|
+
|
|
712
787
|
return session.thread.history(limit, cursor);
|
|
713
788
|
}
|
|
714
789
|
|
|
@@ -724,6 +799,7 @@ export class ChatCore {
|
|
|
724
799
|
clients.delete(clientId);
|
|
725
800
|
}
|
|
726
801
|
}
|
|
802
|
+
|
|
727
803
|
this.subagents.releaseClient(clientId);
|
|
728
804
|
}
|
|
729
805
|
|
|
@@ -736,11 +812,14 @@ export class ChatCore {
|
|
|
736
812
|
if (payload.watch === false) {
|
|
737
813
|
this.subagents.release(clientId, payload.chatId, payload.toolUseId);
|
|
738
814
|
}
|
|
815
|
+
|
|
739
816
|
const result = await this.subagents.read(payload.chatId, payload.toolUseId, payload.cursor, payload.limit);
|
|
740
817
|
await this.chats.get(payload.chatId)?.recheckOrphan(payload.toolUseId);
|
|
818
|
+
|
|
741
819
|
if (payload.watch === true) {
|
|
742
820
|
this.subagents.hold(clientId, payload.chatId, payload.toolUseId);
|
|
743
821
|
}
|
|
822
|
+
|
|
744
823
|
return result;
|
|
745
824
|
}
|
|
746
825
|
|
|
@@ -753,15 +832,19 @@ export class ChatCore {
|
|
|
753
832
|
await this.loaded(chatId);
|
|
754
833
|
const session = this.require(chatId);
|
|
755
834
|
const row = session.thread.find('subagent', (item) => item.toolUseId === toolUseId);
|
|
835
|
+
|
|
756
836
|
if (!row) {
|
|
757
837
|
throw new ChatError('subagent-not-found', 'This chat has no such sub-agent');
|
|
758
838
|
}
|
|
839
|
+
|
|
759
840
|
if (row.status !== 'running') {
|
|
760
841
|
return;
|
|
761
842
|
}
|
|
843
|
+
|
|
762
844
|
if (row.origin === HOST_TASK_ORIGIN) {
|
|
763
845
|
throw new ChatError('chat-unsupported', 'This row stands for work the host runs; stop it there');
|
|
764
846
|
}
|
|
847
|
+
|
|
765
848
|
session.stopSubagent(toolUseId);
|
|
766
849
|
}
|
|
767
850
|
|
|
@@ -795,30 +878,40 @@ export class ChatCore {
|
|
|
795
878
|
if (!this.store) {
|
|
796
879
|
throw new ChatError('chat-unsupported', 'This host keeps no chat records');
|
|
797
880
|
}
|
|
881
|
+
|
|
798
882
|
if (this.chats.has(chatId) || this.creating.has(chatId)) {
|
|
799
883
|
throw new ChatError('chat-busy', `Chat ${chatId} is already loaded`);
|
|
800
884
|
}
|
|
885
|
+
|
|
801
886
|
const copies = new Map<string, Promise<ChatAttachment>>();
|
|
887
|
+
|
|
802
888
|
const copy = (attachment: ChatAttachment): Promise<ChatAttachment> => {
|
|
803
889
|
let pending = copies.get(attachment.id);
|
|
890
|
+
|
|
804
891
|
if (!pending) {
|
|
805
892
|
pending = this.attachments.copy(chatId, attachment);
|
|
806
893
|
copies.set(attachment.id, pending);
|
|
807
894
|
}
|
|
895
|
+
|
|
808
896
|
return pending;
|
|
809
897
|
};
|
|
898
|
+
|
|
810
899
|
const copied = await Promise.all(
|
|
811
900
|
items.map(async (item): Promise<ChatItem> => {
|
|
812
901
|
if (item.kind === 'user' && item.attachments?.length) {
|
|
813
902
|
return { ...item, attachments: await Promise.all(item.attachments.map(copy)) };
|
|
814
903
|
}
|
|
904
|
+
|
|
815
905
|
const generated = generatedImageAttachment(item);
|
|
906
|
+
|
|
816
907
|
if (item.kind === 'tool' && generated) {
|
|
817
908
|
return { ...item, input: { ...(item.input as Record<string, unknown>), attachment: await copy(generated) } };
|
|
818
909
|
}
|
|
910
|
+
|
|
819
911
|
return item;
|
|
820
912
|
})
|
|
821
913
|
);
|
|
914
|
+
|
|
822
915
|
await this.store.write(chatId, info, copied, { seq: 0, resetSeq: 0 }, preambles);
|
|
823
916
|
}
|
|
824
917
|
|
|
@@ -830,34 +923,42 @@ export class ChatCore {
|
|
|
830
923
|
if (this.chats.has(chatId) || this.creating.has(chatId)) {
|
|
831
924
|
return false;
|
|
832
925
|
}
|
|
926
|
+
|
|
833
927
|
await Promise.all([
|
|
834
928
|
this.store?.delete(chatId),
|
|
835
929
|
this.attachments.removeAll(chatId),
|
|
836
930
|
this.bookmarks?.removeChat(chatId),
|
|
837
931
|
this.visuals?.removeChat(chatId)
|
|
838
932
|
]);
|
|
933
|
+
|
|
839
934
|
return true;
|
|
840
935
|
}
|
|
841
936
|
|
|
842
937
|
/* The turn a chat took last, read off the record without loading a chat nobody has; null before its first. */
|
|
843
938
|
async lastTurn(chatId: string): Promise<ChatTurnItem | null> {
|
|
844
939
|
const session = this.chats.get(chatId);
|
|
940
|
+
|
|
845
941
|
if (session) {
|
|
846
942
|
return session.thread.find('turn', () => true) ?? null;
|
|
847
943
|
}
|
|
944
|
+
|
|
848
945
|
const stored = await this.store?.read(chatId);
|
|
946
|
+
|
|
849
947
|
return stored?.items.findLast((item): item is ChatTurnItem => item.kind === 'turn') ?? null;
|
|
850
948
|
}
|
|
851
949
|
|
|
852
950
|
/* A line from the host in a chat's thread, loading the chat when nobody has; nothing for a chat that is gone. */
|
|
853
951
|
async addNote(chatId: string, level: 'info' | 'warning' | 'error', text: string): Promise<void> {
|
|
854
952
|
await this.loaded(chatId);
|
|
953
|
+
|
|
855
954
|
if (!(await this.hasStored(chatId))) {
|
|
856
955
|
return;
|
|
857
956
|
}
|
|
957
|
+
|
|
858
958
|
if (!this.chats.has(chatId)) {
|
|
859
959
|
await this.create({ chatId });
|
|
860
960
|
}
|
|
961
|
+
|
|
861
962
|
this.chats.get(chatId)?.addNote(level, text);
|
|
862
963
|
}
|
|
863
964
|
|
|
@@ -867,12 +968,15 @@ export class ChatCore {
|
|
|
867
968
|
*/
|
|
868
969
|
async deliverNote(chatId: string, delivery: { noteId: string; note: string; from?: string; preamble: string }): Promise<boolean> {
|
|
869
970
|
await this.loaded(chatId);
|
|
971
|
+
|
|
870
972
|
if (!(await this.hasStored(chatId))) {
|
|
871
973
|
return false;
|
|
872
974
|
}
|
|
975
|
+
|
|
873
976
|
if (!this.chats.has(chatId)) {
|
|
874
977
|
await this.create({ chatId });
|
|
875
978
|
}
|
|
979
|
+
|
|
876
980
|
return this.require(chatId).deliverNote(delivery);
|
|
877
981
|
}
|
|
878
982
|
|
|
@@ -883,10 +987,13 @@ export class ChatCore {
|
|
|
883
987
|
async send(chatId: string, text: string, extras: ChatSendExtras = {}, uploads: ChatAttachmentUpload[] = []): Promise<{ queued: boolean; turnId: string }> {
|
|
884
988
|
const session = this.require(chatId);
|
|
885
989
|
const checked = ChatAttachmentUploadsSchema.safeParse(uploads);
|
|
990
|
+
|
|
886
991
|
if (!checked.success) {
|
|
887
992
|
throw new ChatError('invalid-attachments', checked.error.issues[0]!.message);
|
|
888
993
|
}
|
|
994
|
+
|
|
889
995
|
const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
|
|
996
|
+
|
|
890
997
|
return session.sendInput(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
|
|
891
998
|
}
|
|
892
999
|
|
|
@@ -894,6 +1001,7 @@ export class ChatCore {
|
|
|
894
1001
|
if (!this.attached.get(payload.chatId)?.has(clientId) || !this.uiQueries) {
|
|
895
1002
|
throw new ChatError('refused-query', 'Attach to the chat before resolving its UI links.');
|
|
896
1003
|
}
|
|
1004
|
+
|
|
897
1005
|
return this.uiQueries.link(this.require(payload.chatId), payload);
|
|
898
1006
|
}
|
|
899
1007
|
|
|
@@ -901,9 +1009,11 @@ export class ChatCore {
|
|
|
901
1009
|
if (!this.attached.get(payload.chatId)?.has(clientId)) {
|
|
902
1010
|
throw new ChatError('refused-query', 'Attach to the chat before reading its UI queries.');
|
|
903
1011
|
}
|
|
1012
|
+
|
|
904
1013
|
if (!this.uiQueries) {
|
|
905
1014
|
throw new ChatError('refused-query', 'This host does not provide UI query sources.');
|
|
906
1015
|
}
|
|
1016
|
+
|
|
907
1017
|
return this.uiQueries.query(this.require(payload.chatId), payload);
|
|
908
1018
|
}
|
|
909
1019
|
|
|
@@ -911,40 +1021,50 @@ export class ChatCore {
|
|
|
911
1021
|
if (!this.attached.get(payload.chatId)?.has(clientId)) {
|
|
912
1022
|
throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
|
|
913
1023
|
}
|
|
1024
|
+
|
|
914
1025
|
const session = this.require(payload.chatId);
|
|
915
1026
|
const queries = await this.uiQueries?.choiceValues(session, payload);
|
|
916
1027
|
const result = session.choose(payload, queries);
|
|
917
1028
|
await this.persisted(payload.chatId);
|
|
1029
|
+
|
|
918
1030
|
return result;
|
|
919
1031
|
}
|
|
920
1032
|
|
|
921
1033
|
/* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
|
|
922
1034
|
attachment(chatId: string, id: string): ChatAttachment | null {
|
|
923
1035
|
const session = this.chats.get(chatId);
|
|
1036
|
+
|
|
924
1037
|
if (!session) {
|
|
925
1038
|
return null;
|
|
926
1039
|
}
|
|
1040
|
+
|
|
927
1041
|
for (const item of session.thread.list()) {
|
|
928
1042
|
const generated = generatedImageAttachment(item);
|
|
929
1043
|
const found = item.kind === 'user' ? item.attachments?.find((attachment) => attachment.id === id) : generated?.id === id ? generated : null;
|
|
1044
|
+
|
|
930
1045
|
if (found) {
|
|
931
1046
|
return found;
|
|
932
1047
|
}
|
|
933
1048
|
}
|
|
1049
|
+
|
|
934
1050
|
for (const message of session.info.queue ?? []) {
|
|
935
1051
|
const found = message.attachments?.find((attachment) => attachment.id === id);
|
|
1052
|
+
|
|
936
1053
|
if (found) {
|
|
937
1054
|
return found;
|
|
938
1055
|
}
|
|
939
1056
|
}
|
|
1057
|
+
|
|
940
1058
|
return this.visuals?.attachment(chatId, id) ?? null;
|
|
941
1059
|
}
|
|
942
1060
|
|
|
943
1061
|
unqueue(chatId: string, messageId: string): ChatQueuedMessage {
|
|
944
1062
|
const message = this.require(chatId).unqueue(messageId);
|
|
1063
|
+
|
|
945
1064
|
if (!message) {
|
|
946
1065
|
throw new ChatError('request-not-found', `No queued message ${messageId} in chat ${chatId}`);
|
|
947
1066
|
}
|
|
1067
|
+
|
|
948
1068
|
return message;
|
|
949
1069
|
}
|
|
950
1070
|
|
|
@@ -957,9 +1077,15 @@ export class ChatCore {
|
|
|
957
1077
|
/* What the chat's CLI would run as a skill. */
|
|
958
1078
|
skills(chatId: string): Promise<ChatSkill[]> {
|
|
959
1079
|
const session = this.require(chatId);
|
|
1080
|
+
|
|
960
1081
|
return session.skills(() => this.skillIndex.list(session.info.provider, session.info.cwd));
|
|
961
1082
|
}
|
|
962
1083
|
|
|
1084
|
+
/* What a CLI would run as a skill in a folder before any chat of it exists there, such as for a prompt saved to send later. */
|
|
1085
|
+
skillsIn(provider: AgentKind, cwd: string): Promise<ChatSkill[]> {
|
|
1086
|
+
return this.skillIndex.list(provider, cwd);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
963
1089
|
/* Ends the chat's CLI and keeps its conversation; the next message resumes it in a process that reads its skills and plugins again. */
|
|
964
1090
|
async restart(chatId: string): Promise<void> {
|
|
965
1091
|
const session = this.require(chatId);
|
|
@@ -972,9 +1098,11 @@ export class ChatCore {
|
|
|
972
1098
|
compact(chatId: string): void {
|
|
973
1099
|
const session = this.require(chatId);
|
|
974
1100
|
this.refuseWhileRelocating(chatId);
|
|
1101
|
+
|
|
975
1102
|
if (session.busy) {
|
|
976
1103
|
throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
|
|
977
1104
|
}
|
|
1105
|
+
|
|
978
1106
|
session.compact();
|
|
979
1107
|
}
|
|
980
1108
|
|
|
@@ -982,15 +1110,18 @@ export class ChatCore {
|
|
|
982
1110
|
async clear(chatId: string, force = false): Promise<void> {
|
|
983
1111
|
const session = this.require(chatId);
|
|
984
1112
|
this.refuseWhileRelocating(chatId);
|
|
1113
|
+
|
|
985
1114
|
if (session.busy && !force) {
|
|
986
1115
|
throw new ChatError('chat-busy', `Chat ${chatId} is still working on the previous message`);
|
|
987
1116
|
}
|
|
1117
|
+
|
|
988
1118
|
await this.hooks.clearing?.(chatId);
|
|
989
1119
|
const ended = session.clear(force);
|
|
990
1120
|
// Before the record is written, so what it takes along from the record is gone from the write as well.
|
|
991
1121
|
const cleared = this.hooks.cleared?.(chatId);
|
|
992
1122
|
// A debounced write still waiting holds the old thread and must not land after the empty one.
|
|
993
1123
|
this.cancelWaiting(chatId);
|
|
1124
|
+
|
|
994
1125
|
// Folded right away: every line before the reset describes a thread that is gone.
|
|
995
1126
|
await Promise.all([
|
|
996
1127
|
this.persistNow(chatId, true),
|
|
@@ -1005,6 +1136,7 @@ export class ChatCore {
|
|
|
1005
1136
|
cancel(chatId: string, subagents = false): void {
|
|
1006
1137
|
const session = this.require(chatId);
|
|
1007
1138
|
session.cancel();
|
|
1139
|
+
|
|
1008
1140
|
if (subagents) {
|
|
1009
1141
|
session.markSubagentsStopped();
|
|
1010
1142
|
}
|
|
@@ -1037,6 +1169,7 @@ export class ChatCore {
|
|
|
1037
1169
|
holdForPerson(chatId: string, key: string): boolean {
|
|
1038
1170
|
const session = this.chats.get(chatId);
|
|
1039
1171
|
session?.holdForPerson(key);
|
|
1172
|
+
|
|
1040
1173
|
return session !== undefined;
|
|
1041
1174
|
}
|
|
1042
1175
|
|
|
@@ -1052,11 +1185,14 @@ export class ChatCore {
|
|
|
1052
1185
|
const session = this.require(chatId);
|
|
1053
1186
|
this.refuseUnlessRelocatable(session);
|
|
1054
1187
|
const running = this.relocateNow(session, cwd, during);
|
|
1188
|
+
|
|
1055
1189
|
const tracked = running.then(
|
|
1056
1190
|
() => undefined,
|
|
1057
1191
|
() => undefined
|
|
1058
1192
|
);
|
|
1193
|
+
|
|
1059
1194
|
this.relocations.set(chatId, tracked);
|
|
1195
|
+
|
|
1060
1196
|
try {
|
|
1061
1197
|
return await running;
|
|
1062
1198
|
} finally {
|
|
@@ -1074,12 +1210,15 @@ export class ChatCore {
|
|
|
1074
1210
|
private async relocateNow(session: ChatSession, cwd: string, during: (() => Promise<void>) | undefined): Promise<ChatInfo> {
|
|
1075
1211
|
const chatId = session.id;
|
|
1076
1212
|
await this.hooks.admit?.(chatId, cwd);
|
|
1213
|
+
|
|
1077
1214
|
// A message sent while the folder was asked about may have opened a turn.
|
|
1078
1215
|
if (!session.relocatable) {
|
|
1079
1216
|
throw this.busyRelocating(chatId);
|
|
1080
1217
|
}
|
|
1218
|
+
|
|
1081
1219
|
const previous = session.info.cwd;
|
|
1082
1220
|
session.beginRelocate();
|
|
1221
|
+
|
|
1083
1222
|
try {
|
|
1084
1223
|
await session.dropProcess();
|
|
1085
1224
|
await during?.();
|
|
@@ -1087,11 +1226,14 @@ export class ChatCore {
|
|
|
1087
1226
|
await this.persistNow(chatId, true);
|
|
1088
1227
|
} catch (e) {
|
|
1089
1228
|
session.setCwd(previous);
|
|
1229
|
+
|
|
1090
1230
|
throw e;
|
|
1091
1231
|
} finally {
|
|
1092
1232
|
session.endRelocate();
|
|
1093
1233
|
}
|
|
1234
|
+
|
|
1094
1235
|
this.skillIndex.forget(session.info.provider, previous);
|
|
1236
|
+
|
|
1095
1237
|
return session.info;
|
|
1096
1238
|
}
|
|
1097
1239
|
|
|
@@ -1115,9 +1257,11 @@ export class ChatCore {
|
|
|
1115
1257
|
async kill(chatId: string): Promise<void> {
|
|
1116
1258
|
await this.loaded(chatId);
|
|
1117
1259
|
const moving = this.relocations.get(chatId);
|
|
1260
|
+
|
|
1118
1261
|
if (moving) {
|
|
1119
1262
|
await moving;
|
|
1120
1263
|
}
|
|
1264
|
+
|
|
1121
1265
|
const session = this.require(chatId);
|
|
1122
1266
|
// A write already out would put the record back after the delete.
|
|
1123
1267
|
const writing = this.writes.get(chatId);
|
|
@@ -1141,6 +1285,7 @@ export class ChatCore {
|
|
|
1141
1285
|
this.hooks.forgotten?.(chatId);
|
|
1142
1286
|
await writing;
|
|
1143
1287
|
await ended;
|
|
1288
|
+
|
|
1144
1289
|
await Promise.all([
|
|
1145
1290
|
this.store?.delete(chatId),
|
|
1146
1291
|
this.attachments.removeAll(chatId),
|
|
@@ -1162,12 +1307,15 @@ export class ChatCore {
|
|
|
1162
1307
|
/* The chats with a CLI process, for a process monitor. */
|
|
1163
1308
|
processTargets(): { id: string; pid: number; provider: AgentKind; status: ChatInfo['status']; updatedAt: number }[] {
|
|
1164
1309
|
const targets = [];
|
|
1310
|
+
|
|
1165
1311
|
for (const [id, session] of this.chats) {
|
|
1166
1312
|
const pid = session.pid;
|
|
1313
|
+
|
|
1167
1314
|
if (pid !== null) {
|
|
1168
1315
|
targets.push({ id, pid, provider: session.info.provider, status: session.info.status, updatedAt: this.activity.get(id) ?? 0 });
|
|
1169
1316
|
}
|
|
1170
1317
|
}
|
|
1318
|
+
|
|
1171
1319
|
return targets;
|
|
1172
1320
|
}
|
|
1173
1321
|
|
|
@@ -1186,12 +1334,15 @@ export class ChatCore {
|
|
|
1186
1334
|
*/
|
|
1187
1335
|
async resumeRun(chatId: string, turnId: string, attempt: number): Promise<void> {
|
|
1188
1336
|
await this.loaded(chatId);
|
|
1337
|
+
|
|
1189
1338
|
if (!this.chats.has(chatId)) {
|
|
1190
1339
|
if (!(await this.store?.has(chatId))) {
|
|
1191
1340
|
return;
|
|
1192
1341
|
}
|
|
1342
|
+
|
|
1193
1343
|
await this.create({ chatId });
|
|
1194
1344
|
}
|
|
1345
|
+
|
|
1195
1346
|
await this.chats.get(chatId)?.resume(turnId, attempt, (await this.hooks.resumeWords?.(chatId)) ?? DEFAULT_RESUME_WORDS);
|
|
1196
1347
|
}
|
|
1197
1348
|
|
|
@@ -1201,22 +1352,30 @@ export class ChatCore {
|
|
|
1201
1352
|
*/
|
|
1202
1353
|
async takeUpAfterLimit(chatId: string, turnId: string): Promise<OutboxOutcome> {
|
|
1203
1354
|
await this.loaded(chatId);
|
|
1355
|
+
|
|
1204
1356
|
if (!this.chats.has(chatId)) {
|
|
1205
1357
|
if (!(await this.store?.has(chatId))) {
|
|
1206
1358
|
return;
|
|
1207
1359
|
}
|
|
1360
|
+
|
|
1208
1361
|
await this.create({ chatId });
|
|
1209
1362
|
}
|
|
1363
|
+
|
|
1210
1364
|
const session = this.chats.get(chatId);
|
|
1365
|
+
|
|
1211
1366
|
if (!session) {
|
|
1212
1367
|
return;
|
|
1213
1368
|
}
|
|
1369
|
+
|
|
1214
1370
|
const turn = session.thread.get(turnId);
|
|
1215
1371
|
const ended = this.hooks.endedAt?.(chatId) ?? null;
|
|
1372
|
+
|
|
1216
1373
|
if (turn !== undefined && ended !== null && ended >= turn.createdAt) {
|
|
1217
1374
|
session.lapseResume();
|
|
1375
|
+
|
|
1218
1376
|
return;
|
|
1219
1377
|
}
|
|
1378
|
+
|
|
1220
1379
|
return session.takeUpAfterLimit(turnId) === 'wait' ? 'wait' : undefined;
|
|
1221
1380
|
}
|
|
1222
1381
|
|
|
@@ -1241,16 +1400,20 @@ export class ChatCore {
|
|
|
1241
1400
|
if (!this.store) {
|
|
1242
1401
|
return;
|
|
1243
1402
|
}
|
|
1403
|
+
|
|
1244
1404
|
for (const chatId of await this.store.list()) {
|
|
1245
1405
|
if (this.chats.has(chatId) || this.creating.has(chatId)) {
|
|
1246
1406
|
continue;
|
|
1247
1407
|
}
|
|
1408
|
+
|
|
1248
1409
|
// Parsing the whole thread of every settled chat was most of the cost of a start.
|
|
1249
1410
|
if (await this.store.settled(chatId).catch(() => false)) {
|
|
1250
1411
|
continue;
|
|
1251
1412
|
}
|
|
1413
|
+
|
|
1252
1414
|
const record = await this.store.read(chatId).catch(() => null);
|
|
1253
1415
|
const unowed = record?.info.resumeAt !== undefined && this.limitResume?.owed(chatId) === false;
|
|
1416
|
+
|
|
1254
1417
|
if (record !== null && (interruptedTurn(record) !== null || unowed)) {
|
|
1255
1418
|
await this.create({ chatId }).catch((e: unknown) => console.error(`Loading chat ${chatId} after a restart failed:`, errorText(e)));
|
|
1256
1419
|
}
|
|
@@ -1265,13 +1428,16 @@ export class ChatCore {
|
|
|
1265
1428
|
for (const session of this.chats.values()) {
|
|
1266
1429
|
session.freeze();
|
|
1267
1430
|
}
|
|
1431
|
+
|
|
1268
1432
|
for (const chatId of this.chats.keys()) {
|
|
1269
1433
|
this.cancelWaiting(chatId);
|
|
1270
1434
|
}
|
|
1435
|
+
|
|
1271
1436
|
// A thread that cannot be written still ends its CLI, whose process group would otherwise outlive the host.
|
|
1272
1437
|
const written = await Promise.allSettled([...this.chats.keys()].map((chatId) => this.persistNow(chatId, true)));
|
|
1273
1438
|
await Promise.allSettled([...this.chats.values()].map((session) => session.dispose()));
|
|
1274
1439
|
const failed = written.find((result) => result.status === 'rejected');
|
|
1440
|
+
|
|
1275
1441
|
if (failed) {
|
|
1276
1442
|
throw failed.reason;
|
|
1277
1443
|
}
|
|
@@ -1285,14 +1451,17 @@ export class ChatCore {
|
|
|
1285
1451
|
if (!this.store) {
|
|
1286
1452
|
return;
|
|
1287
1453
|
}
|
|
1454
|
+
|
|
1288
1455
|
for (const session of this.chats.values()) {
|
|
1289
1456
|
session.freeze();
|
|
1290
1457
|
}
|
|
1458
|
+
|
|
1291
1459
|
for (const [chatId, session] of this.chats) {
|
|
1292
1460
|
this.cancelWaiting(chatId);
|
|
1293
1461
|
this.coalescers.get(chatId)?.flush();
|
|
1294
1462
|
const { info, items } = session.thread.snapshot();
|
|
1295
1463
|
const log = this.logs.get(chatId);
|
|
1464
|
+
|
|
1296
1465
|
// Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
|
|
1297
1466
|
try {
|
|
1298
1467
|
this.store.writeSync(
|
|
@@ -1322,9 +1491,11 @@ export class ChatCore {
|
|
|
1322
1491
|
|
|
1323
1492
|
protected require(chatId: string): ChatSession {
|
|
1324
1493
|
const session = this.chats.get(chatId);
|
|
1494
|
+
|
|
1325
1495
|
if (!session) {
|
|
1326
1496
|
throw new ChatError('chat-not-found', `No chat ${chatId}`);
|
|
1327
1497
|
}
|
|
1498
|
+
|
|
1328
1499
|
return session;
|
|
1329
1500
|
}
|
|
1330
1501
|
|
|
@@ -1337,15 +1508,19 @@ export class ChatCore {
|
|
|
1337
1508
|
private persistSoon(chatId: string): void {
|
|
1338
1509
|
if ((this.sizes.get(chatId) ?? 0) < DEBOUNCE_ABOVE_BYTES) {
|
|
1339
1510
|
this.persist(chatId);
|
|
1511
|
+
|
|
1340
1512
|
return;
|
|
1341
1513
|
}
|
|
1514
|
+
|
|
1342
1515
|
if (this.waiting.has(chatId)) {
|
|
1343
1516
|
return;
|
|
1344
1517
|
}
|
|
1518
|
+
|
|
1345
1519
|
const timer = setTimeout(() => {
|
|
1346
1520
|
this.waiting.delete(chatId);
|
|
1347
1521
|
this.persist(chatId);
|
|
1348
1522
|
}, DEBOUNCE_MS);
|
|
1523
|
+
|
|
1349
1524
|
// A pending write must never be the reason the process stays up.
|
|
1350
1525
|
timer.unref?.();
|
|
1351
1526
|
this.waiting.set(chatId, timer);
|
|
@@ -1353,6 +1528,7 @@ export class ChatCore {
|
|
|
1353
1528
|
|
|
1354
1529
|
private cancelWaiting(chatId: string): void {
|
|
1355
1530
|
const timer = this.waiting.get(chatId);
|
|
1531
|
+
|
|
1356
1532
|
if (timer) {
|
|
1357
1533
|
clearTimeout(timer);
|
|
1358
1534
|
this.waiting.delete(chatId);
|
|
@@ -1367,33 +1543,40 @@ export class ChatCore {
|
|
|
1367
1543
|
const next = (this.writes.get(chatId) ?? Promise.resolve()).then(async () => {
|
|
1368
1544
|
const session = this.chats.get(chatId);
|
|
1369
1545
|
const log = this.logs.get(chatId);
|
|
1546
|
+
|
|
1370
1547
|
if (!session || !this.store || !log) {
|
|
1371
1548
|
return;
|
|
1372
1549
|
}
|
|
1550
|
+
|
|
1373
1551
|
// A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
|
|
1374
1552
|
this.coalescers.get(chatId)?.flush();
|
|
1375
1553
|
const extras = this.hooks.recordExtras?.(chatId) ?? {};
|
|
1376
1554
|
const uiAccess = session.uiAccessRecord;
|
|
1377
1555
|
const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
|
|
1378
1556
|
const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
|
|
1557
|
+
|
|
1379
1558
|
// The log already holds every change to the thread, so the whole record is rewritten only to fold
|
|
1380
1559
|
// the log, on a chat's first write, or for what no event carries.
|
|
1381
1560
|
if (!folds && this.sizes.has(chatId) && this.unlogged.get(chatId) === unlogged) {
|
|
1382
1561
|
return;
|
|
1383
1562
|
}
|
|
1563
|
+
|
|
1384
1564
|
const { info, items } = session.thread.snapshot();
|
|
1385
1565
|
const at = { seq: log.seq, resetSeq: log.resetSeq };
|
|
1386
1566
|
this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
|
|
1387
1567
|
this.unlogged.set(chatId, unlogged);
|
|
1568
|
+
|
|
1388
1569
|
// A chat killed while the write was out has no log left to fold.
|
|
1389
1570
|
if (this.logs.get(chatId) === log && folds) {
|
|
1390
1571
|
log.compact(at.seq);
|
|
1391
1572
|
}
|
|
1392
1573
|
});
|
|
1574
|
+
|
|
1393
1575
|
this.writes.set(
|
|
1394
1576
|
chatId,
|
|
1395
1577
|
next.catch(() => undefined)
|
|
1396
1578
|
);
|
|
1579
|
+
|
|
1397
1580
|
return next;
|
|
1398
1581
|
}
|
|
1399
1582
|
|
|
@@ -1401,17 +1584,22 @@ export class ChatCore {
|
|
|
1401
1584
|
if (event.type === 'delta' && event.ui !== undefined) {
|
|
1402
1585
|
// Previews follow all pending text but have no log position or observer side effects.
|
|
1403
1586
|
this.coalescers.get(chatId)?.flush();
|
|
1587
|
+
|
|
1404
1588
|
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
1405
1589
|
this.sinks.to(clientId, { event: 'chat.event', payload: { chatId, event } });
|
|
1406
1590
|
}
|
|
1591
|
+
|
|
1407
1592
|
return;
|
|
1408
1593
|
}
|
|
1594
|
+
|
|
1409
1595
|
this.activity.set(chatId, Date.now());
|
|
1410
1596
|
let coalescer = this.coalescers.get(chatId);
|
|
1597
|
+
|
|
1411
1598
|
if (!coalescer) {
|
|
1412
1599
|
coalescer = new DeltaCoalescer((coalesced) => this.broadcast(chatId, coalesced));
|
|
1413
1600
|
this.coalescers.set(chatId, coalescer);
|
|
1414
1601
|
}
|
|
1602
|
+
|
|
1415
1603
|
coalescer.push(event);
|
|
1416
1604
|
}
|
|
1417
1605
|
|
|
@@ -1419,18 +1607,23 @@ export class ChatCore {
|
|
|
1419
1607
|
private broadcast(chatId: string, event: ChatEvent): void {
|
|
1420
1608
|
const seq = this.logs.get(chatId)?.append(event, Date.now());
|
|
1421
1609
|
const payload = seq === undefined ? { chatId, event } : { chatId, event, seq };
|
|
1610
|
+
|
|
1422
1611
|
for (const sink of this.observers) {
|
|
1423
1612
|
sink({ event: 'chat.event', payload });
|
|
1424
1613
|
}
|
|
1614
|
+
|
|
1425
1615
|
this.hooks.broadcasted?.(chatId, event);
|
|
1616
|
+
|
|
1426
1617
|
for (const clientId of this.attached.get(chatId) ?? []) {
|
|
1427
1618
|
this.sinks.to(clientId, { event: 'chat.event', payload });
|
|
1428
1619
|
}
|
|
1620
|
+
|
|
1429
1621
|
// After the thread, so a client reading it has already had this info and the status is a no-op there.
|
|
1430
1622
|
if (event.type === 'info' || event.type === 'reset') {
|
|
1431
1623
|
this.announceStatus(chatId, event.info);
|
|
1432
1624
|
} else if (event.type === 'item' && (event.item.kind === 'approval' || event.item.kind === 'question')) {
|
|
1433
1625
|
const session = this.chats.get(chatId);
|
|
1626
|
+
|
|
1434
1627
|
if (session) {
|
|
1435
1628
|
this.announceStatus(chatId, session.info);
|
|
1436
1629
|
}
|
|
@@ -1455,9 +1648,11 @@ export class ChatCore {
|
|
|
1455
1648
|
info.requests?.map((request) => request.requestId) ?? [],
|
|
1456
1649
|
info.goal === undefined ? null : [info.goal.status, info.goal.objective]
|
|
1457
1650
|
]);
|
|
1651
|
+
|
|
1458
1652
|
if (this.announced.get(chatId) === said) {
|
|
1459
1653
|
return;
|
|
1460
1654
|
}
|
|
1655
|
+
|
|
1461
1656
|
this.announced.set(chatId, said);
|
|
1462
1657
|
this.sinks.emit({ event: 'chat.status', payload: { chatId, info } });
|
|
1463
1658
|
}
|
|
@@ -1471,42 +1666,57 @@ export class ChatCore {
|
|
|
1471
1666
|
if (!this.store) {
|
|
1472
1667
|
return new ChatLog(null);
|
|
1473
1668
|
}
|
|
1669
|
+
|
|
1474
1670
|
const path = this.store.logPath(chatId);
|
|
1671
|
+
|
|
1475
1672
|
if (stored) {
|
|
1476
1673
|
return new ChatLog(path, stored);
|
|
1477
1674
|
}
|
|
1675
|
+
|
|
1478
1676
|
const left = await this.store.discardLog(chatId);
|
|
1677
|
+
|
|
1479
1678
|
return new ChatLog(path, { seq: left, resetSeq: left === 0 ? 0 : left + 1, lines: [] });
|
|
1480
1679
|
}
|
|
1481
1680
|
|
|
1482
1681
|
/* Every reason says why in the words of the note the turn ends with, so a person can tell a skip from a failure. */
|
|
1483
1682
|
private async owedResume(chatId: string, stored: ChatRecord): Promise<ResumeDecision> {
|
|
1484
1683
|
const turn = interruptedTurn(stored);
|
|
1684
|
+
|
|
1485
1685
|
const skip = (reason: string): ResumeDecision => ({ resumeTurnId: null, reason });
|
|
1686
|
+
|
|
1486
1687
|
if (turn === null) {
|
|
1487
1688
|
return { resumeTurnId: null, reason: null };
|
|
1488
1689
|
}
|
|
1690
|
+
|
|
1489
1691
|
if (turn.deliveryPending) {
|
|
1490
1692
|
return skip('the wake prompt was not acknowledged; its result waits for your next message');
|
|
1491
1693
|
}
|
|
1694
|
+
|
|
1492
1695
|
if (!this.onInterruptedRun) {
|
|
1493
1696
|
return skip('this machine does not resume turns');
|
|
1494
1697
|
}
|
|
1698
|
+
|
|
1495
1699
|
if (stored.info.agentSessionId === null) {
|
|
1496
1700
|
return skip('the agent had not started a session to resume yet');
|
|
1497
1701
|
}
|
|
1702
|
+
|
|
1498
1703
|
const ended = this.hooks.endedAt?.(chatId) ?? null;
|
|
1704
|
+
|
|
1499
1705
|
if (ended !== null && ended >= turn.createdAt) {
|
|
1500
1706
|
return skip('the agent that opened it was stopped');
|
|
1501
1707
|
}
|
|
1708
|
+
|
|
1502
1709
|
if ((turn.attempt ?? 1) >= MAX_ATTEMPTS && !turn.resumePending) {
|
|
1503
1710
|
return skip('it was already resumed after an earlier restart');
|
|
1504
1711
|
}
|
|
1712
|
+
|
|
1505
1713
|
try {
|
|
1506
1714
|
const owed = await this.onInterruptedRun({ chatId, turnId: turn.id, attempt: (turn.attempt ?? 1) + (turn.resumePending ? 0 : 1) });
|
|
1715
|
+
|
|
1507
1716
|
return owed ? { resumeTurnId: turn.id, reason: null } : skip(this.hooks.unownedReason?.(chatId) ?? 'nothing holds this chat any more');
|
|
1508
1717
|
} catch (e) {
|
|
1509
1718
|
console.error(`Owing a resume for chat ${chatId} failed:`, errorText(e));
|
|
1719
|
+
|
|
1510
1720
|
return skip(errorText(e));
|
|
1511
1721
|
}
|
|
1512
1722
|
}
|
|
@@ -1527,5 +1737,6 @@ function limitHooks(hooks: NonNullable<ChatCoreOptions['limitResume']>, chatId:
|
|
|
1527
1737
|
function interruptedTurn(record: { info: ChatInfo; items: ChatItem[] }): Extract<ChatItem, { kind: 'turn' }> | null {
|
|
1528
1738
|
const turnId = record.info.activeTurnId;
|
|
1529
1739
|
const turn = turnId === null ? undefined : record.items.find((item) => item.id === turnId);
|
|
1740
|
+
|
|
1530
1741
|
return turn?.kind === 'turn' && turn.state === 'running' ? turn : null;
|
|
1531
1742
|
}
|