@adecore/agents 0.17.0-beta.2 → 0.18.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.
@@ -5,6 +5,6 @@ export declare class AttachmentStore {
5
5
  constructor(home: string);
6
6
  save(chatId: string, upload: ChatAttachmentUpload): Promise<ChatAttachment>;
7
7
  removeAll(chatId: string): Promise<void>;
8
- private folder;
8
+ folderOf(chatId: string): string;
9
9
  }
10
10
  export declare function migrateInlineAttachments(chatId: string, record: unknown, store: AttachmentStore): Promise<unknown | null>;
@@ -44,7 +44,7 @@ export class AttachmentStore {
44
44
  const bytes = Buffer.from(upload.data, 'base64');
45
45
  const mime = attachmentImageMime(upload) ?? upload.mime;
46
46
  const id = randomBytes(8).toString('hex');
47
- const folder = this.folder(chatId);
47
+ const folder = this.folderOf(chatId);
48
48
  await mkdir(folder, { recursive: true, mode: 0o700 });
49
49
  const path = join(folder, `${id}.${extensionFor(upload.name, mime)}`);
50
50
  await writeFile(path, bytes, { mode: 0o600 });
@@ -52,9 +52,10 @@ export class AttachmentStore {
52
52
  }
53
53
  /* Everything a chat attached; used when the chat itself is deleted. */
54
54
  async removeAll(chatId) {
55
- await rm(this.folder(chatId), { recursive: true, force: true });
55
+ await rm(this.folderOf(chatId), { recursive: true, force: true });
56
56
  }
57
- folder(chatId) {
57
+ /* Where the files of one chat are; a store that keeps more of a chat's files beside them writes them here. */
58
+ folderOf(chatId) {
58
59
  return join(this.dir, encodeURIComponent(chatId));
59
60
  }
60
61
  }
@@ -1,4 +1,4 @@
1
- import { type AgentKind, type ChatAttachment, type ChatAttachResult, type ChatAttachmentUpload, type ChatBookmark, type ChatCheckpointDiff, type ChatConfigurePayload, type ChatCreatePayload, type ChatEvent, type ChatHistoryResult, type ChatInfo, type ChatItem, type ChatQueuedMessage, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
1
+ import { type AgentKind, type ChatAttachment, type ChatAttachResult, type ChatAttachmentUpload, type ChatBookmark, type ChatCheckpointDiff, type ChatConfigurePayload, type ChatCreatePayload, type ChatEvent, type ChatHistoryResult, type ChatInfo, type ChatItem, type ChatQueuedMessage, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
2
2
  import { ClientSinks } from '../client-sinks.ts';
3
3
  import type { AgentEvent, AgentSink } from '../events.ts';
4
4
  import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
@@ -18,6 +18,7 @@ import { ComposerPreferences } from './composer-preferences.ts';
18
18
  import { DeltaCoalescer } from './delta-coalescer.ts';
19
19
  import { type BackgroundWork } from './background-work.ts';
20
20
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
21
+ import type { VisualInput, VisualStore } from './visual-store.ts';
21
22
  export interface InterruptedRun {
22
23
  chatId: string;
23
24
  turnId: string;
@@ -36,6 +37,7 @@ export interface ChatCoreOptions {
36
37
  skills?: SkillIndex;
37
38
  attachments: AttachmentStore;
38
39
  bookmarks?: BookmarkStore;
40
+ visuals?: VisualStore;
39
41
  onLimits?: (update: LimitsUpdate) => void;
40
42
  claudeTitles?: {
41
43
  forSession(agentSessionId: string, projectsDir: string): Promise<string | null>;
@@ -56,6 +58,7 @@ export declare class ChatCore {
56
58
  protected readonly providers: ProviderRegistry;
57
59
  protected readonly store: ChatStore | null;
58
60
  protected readonly bookmarks: BookmarkStore | null;
61
+ protected readonly visuals: VisualStore | null;
59
62
  protected readonly accounts: AccountLaunches | null;
60
63
  protected readonly attachments: AttachmentStore;
61
64
  protected readonly env: Record<string, string>;
@@ -224,6 +227,10 @@ export declare class ChatCore {
224
227
  renameBookmark(chatId: string, itemId: string, name: string): Promise<ChatBookmark[]>;
225
228
  removeBookmark(chatId: string, itemId: string): Promise<ChatBookmark[]>;
226
229
  private requireBookmarks;
230
+ publishVisual(chatId: string, input: VisualInput): Promise<ChatVisual>;
231
+ listVisuals(chatId: string): Promise<ChatVisual[]>;
232
+ removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]>;
233
+ private requireVisuals;
227
234
  history(chatId: string, cursor: string, limit?: number): ChatHistoryResult;
228
235
  detach(chatId: string, clientId: string): void;
229
236
  detachAll(clientId: string): void;
@@ -46,6 +46,7 @@ export class ChatCore {
46
46
  providers;
47
47
  store;
48
48
  bookmarks;
49
+ visuals;
49
50
  accounts;
50
51
  attachments;
51
52
  env;
@@ -86,6 +87,7 @@ export class ChatCore {
86
87
  this.providers = options.providers;
87
88
  this.store = options.store ?? null;
88
89
  this.bookmarks = options.bookmarks ?? null;
90
+ this.visuals = options.visuals ?? null;
89
91
  this.accounts = options.accounts ?? null;
90
92
  this.attachments = options.attachments;
91
93
  this.checkpoints = options.checkpoints ?? null;
@@ -103,12 +105,17 @@ export class ChatCore {
103
105
  this.spawn = options.spawn ?? null;
104
106
  this.codexClient = options.codexClient ?? null;
105
107
  this.env = definedEnv(options.env ?? process.env);
106
- // Only whoever reads the thread has anything to point a bookmark at, so the list goes where the thread goes.
108
+ // 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.
107
109
  this.bookmarks?.listen((chatId, bookmarks) => {
108
110
  for (const clientId of this.attached.get(chatId) ?? []) {
109
111
  this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
110
112
  }
111
113
  });
114
+ this.visuals?.listen((chatId, visuals) => {
115
+ for (const clientId of this.attached.get(chatId) ?? []) {
116
+ this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
117
+ }
118
+ });
112
119
  this.claudeProjectsDir =
113
120
  options.subagents?.claudeProjectsDir ?? usageRoots(options.env ?? process.env).find((root) => root.provider === 'claude')?.path ?? '';
114
121
  this.subagents = new SubagentReader({
@@ -362,18 +369,22 @@ export class ChatCore {
362
369
  : { info: session.info, ...session.thread.history(historyLimit), pending: session.thread.pending(), seq: log.seq };
363
370
  }
364
371
  /*
365
- * `attach` with the chat's bookmarks. They are read before the client joins, so a change written
366
- * after the read reaches it as `chat.bookmarks` and never falls between the two. A file that
367
- * cannot be read leaves the list out rather than the thread.
372
+ * `attach` with the chat's bookmarks and visuals. They are read before the client joins, so a
373
+ * change written after the read reaches it as `chat.bookmarks` or `chat.visuals` and never falls
374
+ * between the two. A file that cannot be read leaves its list out rather than the thread.
368
375
  */
369
376
  async attachWithBookmarks(chatId, clientId, historyLimit, since) {
370
377
  this.require(chatId);
371
- const bookmarks = await this.bookmarks?.read(chatId).catch((e) => {
372
- console.error(`The bookmarks of chat ${chatId} could not be read:`, errorText(e));
378
+ const unreadable = (what) => (e) => {
379
+ console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
373
380
  return undefined;
374
- });
381
+ };
382
+ const [bookmarks, visuals] = await Promise.all([
383
+ this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
384
+ this.visuals?.list(chatId).catch(unreadable('visuals'))
385
+ ]);
375
386
  const result = this.attach(chatId, clientId, historyLimit, since);
376
- return bookmarks === undefined ? result : { ...result, bookmarks };
387
+ return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
377
388
  }
378
389
  /* Marks a message of the chat's own thread; a message that already has one keeps it. */
379
390
  addBookmark(chatId, itemId, name) {
@@ -397,6 +408,33 @@ export class ChatCore {
397
408
  }
398
409
  return this.bookmarks;
399
410
  }
411
+ /*
412
+ * Publishes a page an agent made in a chat, for an app's own agent command. Without a `turnId` it
413
+ * belongs to the turn that is running, if one is. The chat may be one nobody loaded.
414
+ */
415
+ async publishVisual(chatId, input) {
416
+ const visuals = this.requireVisuals();
417
+ await this.loaded(chatId);
418
+ if (!(await this.hasStored(chatId))) {
419
+ throw new ChatError('chat-not-found', `No chat ${chatId}`);
420
+ }
421
+ const turnId = input.turnId ?? this.chats.get(chatId)?.info.activeTurnId ?? undefined;
422
+ return visuals.publish(chatId, turnId === undefined ? input : { ...input, turnId });
423
+ }
424
+ /* The visuals of a chat in the order they were published. */
425
+ listVisuals(chatId) {
426
+ return this.requireVisuals().list(chatId);
427
+ }
428
+ removeVisual(chatId, visualId) {
429
+ this.require(chatId);
430
+ return this.requireVisuals().remove(chatId, visualId);
431
+ }
432
+ requireVisuals() {
433
+ if (!this.visuals) {
434
+ throw new ChatError('chat-unsupported', 'This host keeps no visuals');
435
+ }
436
+ return this.visuals;
437
+ }
400
438
  history(chatId, cursor, limit) {
401
439
  const session = this.require(chatId);
402
440
  this.coalescers.get(chatId)?.flush();
@@ -510,7 +548,7 @@ export class ChatCore {
510
548
  const attachments = await Promise.all(uploads.map((upload) => this.attachments.save(chatId, upload)));
511
549
  return session.send(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
512
550
  }
513
- /* The file behind an attachment id: what this chat's thread or queue says it is. */
551
+ /* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
514
552
  attachment(chatId, id) {
515
553
  const session = this.chats.get(chatId);
516
554
  if (!session) {
@@ -528,7 +566,7 @@ export class ChatCore {
528
566
  return found;
529
567
  }
530
568
  }
531
- return null;
569
+ return this.visuals?.attachment(chatId, id) ?? null;
532
570
  }
533
571
  unqueue(chatId, messageId) {
534
572
  const message = this.require(chatId).unqueue(messageId);
@@ -554,7 +592,7 @@ export class ChatCore {
554
592
  }
555
593
  session.compact();
556
594
  }
557
- /* Empties the thread and drops the CLI's session and the chat's bookmarks; `force` stops a turn that is in the way. */
595
+ /* 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. */
558
596
  async clear(chatId, force = false) {
559
597
  const session = this.require(chatId);
560
598
  if (session.busy && !force) {
@@ -567,7 +605,13 @@ export class ChatCore {
567
605
  // A debounced write still waiting holds the old thread and must not land after the empty one.
568
606
  this.cancelWaiting(chatId);
569
607
  // Folded right away: every line before the reset describes a thread that is gone.
570
- await Promise.all([this.persistNow(chatId, true), this.attachments.removeAll(chatId), this.bookmarks?.removeChat(chatId), cleared]);
608
+ await Promise.all([
609
+ this.persistNow(chatId, true),
610
+ this.attachments.removeAll(chatId),
611
+ this.bookmarks?.removeChat(chatId),
612
+ this.visuals?.removeChat(chatId),
613
+ cleared
614
+ ]);
571
615
  }
572
616
  /* Stops the running turn; with `subagents` also marks the CLI's own subagents stopped, which that turn no longer waits on. */
573
617
  cancel(chatId, subagents = false) {
@@ -596,7 +640,7 @@ export class ChatCore {
596
640
  throw new ChatError('request-not-found', `No question to dismiss under ${itemId}`);
597
641
  }
598
642
  }
599
- /* Removes a chat: its CLI, its record, its log, its attachments and its bookmarks. */
643
+ /* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
600
644
  async kill(chatId) {
601
645
  await this.loaded(chatId);
602
646
  const session = this.require(chatId);
@@ -619,7 +663,13 @@ export class ChatCore {
619
663
  const snapshot = session.thread.snapshot();
620
664
  this.forgotten(chatId);
621
665
  await writing;
622
- await Promise.all([this.store?.delete(chatId), this.attachments.removeAll(chatId), this.bookmarks?.removeChat(chatId), this.removed(chatId, snapshot)]);
666
+ await Promise.all([
667
+ this.store?.delete(chatId),
668
+ this.attachments.removeAll(chatId),
669
+ this.bookmarks?.removeChat(chatId),
670
+ this.visuals?.removeChat(chatId),
671
+ this.removed(chatId, snapshot)
672
+ ]);
623
673
  }
624
674
  /*
625
675
  * Ends the CLI of a chat and keeps its thread. A chat nobody loaded has no CLI to end, and a host
@@ -8,6 +8,7 @@ import { isBookmarkFileName } from './bookmark-store.js';
8
8
  import { migrateInlineAttachments } from './attachment-store.js';
9
9
  import { parseLog } from './chat-log.js';
10
10
  import { ChatThread } from './thread.js';
11
+ import { isVisualFileName } from './visual-store.js';
11
12
  import { recordFileName } from '../record-directory.js';
12
13
  // zod strips what it does not know inside the info and the items, so a file written by an older build
13
14
  // (with `interactionMode`, say) still parses and loses only the dropped field. A record without `seq`
@@ -62,8 +63,8 @@ export class ChatStore {
62
63
  throw e;
63
64
  }
64
65
  const ids = names
65
- // A chat's bookmarks, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
66
- .filter((name) => (name.endsWith('.json') && !isBookmarkFileName(name) && !this.isSidecar(name)) || name.endsWith('.log'))
66
+ // A chat's bookmarks and visuals, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
67
+ .filter((name) => (name.endsWith('.json') && !isBookmarkFileName(name) && !isVisualFileName(name) && !this.isSidecar(name)) || name.endsWith('.log'))
67
68
  .map((name) => decodeURIComponent(name.replace(/\.(json|log)$/, '')));
68
69
  return [...new Set(ids)];
69
70
  }
@@ -1,4 +1,4 @@
1
1
  import { CodedError } from '../coded-error.ts';
2
- export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks';
2
+ export type ChatErrorCode = 'history-expired' | 'chat-not-found' | 'chat-unreadable' | 'chat-busy' | 'request-not-found' | 'chat-unsupported' | 'invalid-attachments' | 'subagent-not-found' | 'task-not-found' | 'transcript-missing' | 'transcript-format' | 'fork-failed' | 'turn-not-found' | 'item-not-found' | 'bookmark-not-found' | 'too-many-bookmarks' | 'visual-invalid' | 'visual-too-large';
3
3
  export declare class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {
4
4
  }
@@ -0,0 +1,35 @@
1
+ import { type ChatAttachment, type ChatVisual } from '@adecore/agent-contracts';
2
+ import type { AttachmentStore } from './attachment-store.ts';
3
+ export declare function visualFileName(chatId: string): string;
4
+ export declare function isVisualFileName(name: string): boolean;
5
+ export interface VisualInput {
6
+ title: string;
7
+ html: string;
8
+ maxHeight?: number;
9
+ heights?: ReadonlyArray<readonly [number, number]>;
10
+ turnId?: string;
11
+ }
12
+ export interface VisualStoreOptions {
13
+ now?: () => number;
14
+ }
15
+ export type VisualListener = (chatId: string, visuals: ChatVisual[]) => void;
16
+ export declare class VisualStore {
17
+ readonly dir: string;
18
+ private readonly attachments;
19
+ private readonly now;
20
+ private readonly writes;
21
+ private readonly listeners;
22
+ private readonly known;
23
+ constructor(home: string, attachments: AttachmentStore, options?: VisualStoreOptions);
24
+ listen(listener: VisualListener): () => void;
25
+ list(chatId: string): Promise<ChatVisual[]>;
26
+ publish(chatId: string, input: VisualInput): Promise<ChatVisual>;
27
+ remove(chatId: string, visualId: string): Promise<ChatVisual[]>;
28
+ removeChat(chatId: string): Promise<void>;
29
+ copyChat(fromChatId: string, toChatId: string, keep?: (visual: ChatVisual) => boolean): Promise<ChatVisual[]>;
30
+ attachment(chatId: string, visualId: string): ChatAttachment | null;
31
+ pagePath(chatId: string, visualId: string): string;
32
+ private tell;
33
+ private readFile;
34
+ private writeFile;
35
+ }
@@ -0,0 +1,230 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { ChatVisualsSchema, injectVisualBootstrap, VISUAL_LIMITS } from '@adecore/agent-contracts';
5
+ import { z } from 'zod';
6
+ import { isNotFound, writeAtomic } from '../fs.js';
7
+ import { KeyedSerializer } from '../serializer.js';
8
+ import { ChatError } from './errors.js';
9
+ const SUFFIX = '.visuals.json';
10
+ const VisualFileSchema = z.object({ version: z.literal(1), visuals: ChatVisualsSchema });
11
+ export function visualFileName(chatId) {
12
+ return `${encodeURIComponent(chatId)}${SUFFIX}`;
13
+ }
14
+ /* Whether a name in the chats folder is a visuals file, which the chat records beside it must not count as a chat. */
15
+ export function isVisualFileName(name) {
16
+ return name.endsWith(SUFFIX);
17
+ }
18
+ const MIB = 1024 * 1024;
19
+ function mebibytes(bytes) {
20
+ return `${(bytes / MIB).toFixed(1)} MiB`;
21
+ }
22
+ function refuse(code, message) {
23
+ throw new ChatError(code, message);
24
+ }
25
+ function isSize(value) {
26
+ return typeof value === 'number' && Number.isFinite(value);
27
+ }
28
+ /* The input as it is kept, or a refusal that tells the agent what to change. */
29
+ function checked(input) {
30
+ const title = typeof input.title === 'string' ? input.title.trim() : '';
31
+ if (title === '') {
32
+ refuse('visual-invalid', 'A visual needs a title; give it a short one that says what the page shows');
33
+ }
34
+ if (title.length > VISUAL_LIMITS.title) {
35
+ refuse('visual-invalid', `The title has ${title.length} characters; keep it to ${VISUAL_LIMITS.title} or fewer`);
36
+ }
37
+ if (typeof input.html !== 'string' || input.html.trim() === '') {
38
+ refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
39
+ }
40
+ const maxHeight = input.maxHeight ?? VISUAL_LIMITS.maxHeight;
41
+ if (!isSize(maxHeight) || maxHeight < VISUAL_LIMITS.minHeight || maxHeight > VISUAL_LIMITS.maxHeight) {
42
+ refuse('visual-invalid', `The maximum height must be from ${VISUAL_LIMITS.minHeight} to ${VISUAL_LIMITS.maxHeight} CSS pixels; leave it out for ${VISUAL_LIMITS.maxHeight}`);
43
+ }
44
+ const heights = input.heights ?? [];
45
+ if (!Array.isArray(heights) ||
46
+ heights.length > VISUAL_LIMITS.heights ||
47
+ heights.some((pair) => !Array.isArray(pair) || !isSize(pair[0]) || !isSize(pair[1]) || pair[0] <= 0 || pair[1] < 0)) {
48
+ refuse('visual-invalid', `Measured heights are at most ${VISUAL_LIMITS.heights} pairs of [width, height] in CSS pixels, with a positive width`);
49
+ }
50
+ // One measurement per width, the last one given, ascending as `visualFrameHeight` reads them.
51
+ const byWidth = new Map(heights.map((pair) => [Math.max(1, Math.round(pair[0])), Math.ceil(pair[1])]));
52
+ const sorted = [...byWidth].sort((a, b) => a[0] - b[0]);
53
+ return { title, html: input.html, maxHeight: Math.round(maxHeight), heights: sorted.length === 0 ? undefined : sorted };
54
+ }
55
+ function tooLarge(bytes) {
56
+ return refuse('visual-too-large', `The page is ${mebibytes(bytes)} and a visual may be at most ${mebibytes(VISUAL_LIMITS.bytes)}; ` +
57
+ 'load libraries from a public CDN URL and refer to large images by their http(s) URL instead of inlining them');
58
+ }
59
+ /*
60
+ * The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
61
+ * each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
62
+ * serves a page by its id unchanged. One chain per chat puts a publish and a removal after each other,
63
+ * and every change is told to the listeners from inside that chain.
64
+ */
65
+ export class VisualStore {
66
+ dir;
67
+ attachments;
68
+ now;
69
+ writes = new KeyedSerializer();
70
+ listeners = new Set();
71
+ // The lists last read or written, so an attachment lookup, which cannot wait, finds a page.
72
+ known = new Map();
73
+ constructor(home, attachments, options = {}) {
74
+ this.dir = join(home, 'chats');
75
+ this.attachments = attachments;
76
+ this.now = options.now ?? Date.now;
77
+ }
78
+ listen(listener) {
79
+ this.listeners.add(listener);
80
+ return () => {
81
+ this.listeners.delete(listener);
82
+ };
83
+ }
84
+ /* The visuals of one chat in the order they were published, after any write still on its way. */
85
+ list(chatId) {
86
+ return this.writes.run(chatId, () => this.readFile(chatId));
87
+ }
88
+ /* Stores a page with its bootstrap and adds it to the chat at the host's current time. */
89
+ async publish(chatId, input) {
90
+ const { title, html, maxHeight, heights } = checked(input);
91
+ // Before the bootstrap is added, so a page far too large is never copied first.
92
+ if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
93
+ tooLarge(Buffer.byteLength(html, 'utf8'));
94
+ }
95
+ const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
96
+ if (page.byteLength > VISUAL_LIMITS.bytes) {
97
+ tooLarge(page.byteLength);
98
+ }
99
+ const turnId = typeof input.turnId === 'string' && input.turnId !== '' ? input.turnId : undefined;
100
+ return this.writes.run(chatId, async () => {
101
+ const visuals = await this.readFile(chatId);
102
+ const visual = {
103
+ id: randomBytes(8).toString('hex'),
104
+ title,
105
+ at: this.now(),
106
+ maxHeight,
107
+ ...(heights === undefined ? {} : { heights }),
108
+ size: page.byteLength,
109
+ ...(turnId === undefined ? {} : { turnId })
110
+ };
111
+ await mkdir(this.attachments.folderOf(chatId), { recursive: true, mode: 0o700 });
112
+ await writeAtomic(this.pagePath(chatId, visual.id), page);
113
+ const next = [...visuals, visual];
114
+ try {
115
+ await this.writeFile(chatId, next);
116
+ }
117
+ catch (e) {
118
+ await rm(this.pagePath(chatId, visual.id), { force: true });
119
+ throw e;
120
+ }
121
+ this.tell(chatId, next);
122
+ return visual;
123
+ });
124
+ }
125
+ /* Takes a visual and its page away; one that is already gone changes nothing. */
126
+ remove(chatId, visualId) {
127
+ return this.writes.run(chatId, async () => {
128
+ const visuals = await this.readFile(chatId);
129
+ if (!visuals.some((visual) => visual.id === visualId)) {
130
+ return visuals;
131
+ }
132
+ const next = visuals.filter((visual) => visual.id !== visualId);
133
+ await this.writeFile(chatId, next);
134
+ await rm(this.pagePath(chatId, visualId), { force: true });
135
+ this.tell(chatId, next);
136
+ return next;
137
+ });
138
+ }
139
+ /* The visuals go with the chat or with a clear of it. A list that no longer parses goes too, since nothing could show it again. */
140
+ removeChat(chatId) {
141
+ return this.writes.run(chatId, async () => {
142
+ const visuals = await this.readFile(chatId).catch(() => null);
143
+ await rm(join(this.dir, visualFileName(chatId)), { force: true });
144
+ await Promise.all((visuals ?? []).map((visual) => rm(this.pagePath(chatId, visual.id), { force: true })));
145
+ this.known.delete(chatId);
146
+ if (visuals === null || visuals.length > 0) {
147
+ this.tell(chatId, []);
148
+ }
149
+ });
150
+ }
151
+ /*
152
+ * Gives a fork the visuals `keep` picks, every one without it, each with a page of its own, so
153
+ * removing either chat leaves the other's pages where they are. A page that is gone is left out.
154
+ */
155
+ async copyChat(fromChatId, toChatId, keep = () => true) {
156
+ const kept = (await this.list(fromChatId)).filter(keep);
157
+ if (kept.length === 0) {
158
+ return this.list(toChatId);
159
+ }
160
+ return this.writes.run(toChatId, async () => {
161
+ const copied = [];
162
+ await mkdir(this.attachments.folderOf(toChatId), { recursive: true, mode: 0o700 });
163
+ for (const visual of kept) {
164
+ const page = await readFile(this.pagePath(fromChatId, visual.id)).catch((e) => {
165
+ if (isNotFound(e)) {
166
+ return null;
167
+ }
168
+ throw e;
169
+ });
170
+ if (page !== null) {
171
+ await writeAtomic(this.pagePath(toChatId, visual.id), page);
172
+ copied.push(visual);
173
+ }
174
+ }
175
+ const existing = await this.readFile(toChatId);
176
+ const ids = new Set(existing.map((visual) => visual.id));
177
+ const next = [...existing, ...copied.filter((visual) => !ids.has(visual.id))];
178
+ await this.writeFile(toChatId, next);
179
+ this.tell(toChatId, next);
180
+ return next;
181
+ });
182
+ }
183
+ /* A page as an attachment of its chat, from the lists this store has seen; null for one it does not know. */
184
+ attachment(chatId, visualId) {
185
+ const visual = this.known.get(chatId)?.find((candidate) => candidate.id === visualId);
186
+ if (!visual) {
187
+ return null;
188
+ }
189
+ return { id: visual.id, name: `${visual.title}.html`, mime: 'text/html', size: visual.size, path: this.pagePath(chatId, visual.id) };
190
+ }
191
+ pagePath(chatId, visualId) {
192
+ return join(this.attachments.folderOf(chatId), `${visualId}.html`);
193
+ }
194
+ tell(chatId, visuals) {
195
+ for (const listener of this.listeners) {
196
+ listener(chatId, visuals);
197
+ }
198
+ }
199
+ async readFile(chatId) {
200
+ let raw;
201
+ try {
202
+ raw = await readFile(join(this.dir, visualFileName(chatId)), 'utf8');
203
+ }
204
+ catch (e) {
205
+ if (isNotFound(e)) {
206
+ this.known.delete(chatId);
207
+ return [];
208
+ }
209
+ throw e;
210
+ }
211
+ // A file that does not parse is refused rather than read as empty, which the next write would make true.
212
+ const parsed = VisualFileSchema.safeParse(JSON.parse(raw));
213
+ if (!parsed.success) {
214
+ throw new Error(`The visuals file of chat ${chatId} is not valid: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`);
215
+ }
216
+ this.known.set(chatId, parsed.data.visuals);
217
+ return parsed.data.visuals;
218
+ }
219
+ async writeFile(chatId, visuals) {
220
+ const path = join(this.dir, visualFileName(chatId));
221
+ if (visuals.length === 0) {
222
+ await rm(path, { force: true });
223
+ this.known.delete(chatId);
224
+ return;
225
+ }
226
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
227
+ await writeAtomic(path, JSON.stringify({ version: 1, visuals }));
228
+ this.known.set(chatId, [...visuals]);
229
+ }
230
+ }
@@ -70,6 +70,7 @@ export function chatHandlers(chats, providers) {
70
70
  'chat.addBookmark': async (payload) => ({ bookmarks: await chats.addBookmark(payload.chatId, payload.itemId, payload.name) }),
71
71
  'chat.renameBookmark': async (payload) => ({ bookmarks: await chats.renameBookmark(payload.chatId, payload.itemId, payload.name) }),
72
72
  'chat.removeBookmark': async (payload) => ({ bookmarks: await chats.removeBookmark(payload.chatId, payload.itemId) }),
73
+ 'chat.removeVisual': async (payload) => ({ visuals: await chats.removeVisual(payload.chatId, payload.visualId) }),
73
74
  'chat.kill': async (payload) => {
74
75
  await chats.kill(payload.chatId);
75
76
  return {};
@@ -3,6 +3,7 @@ import { BookmarkStore } from '../chat/bookmark-store.js';
3
3
  import { ChatCore } from '../chat/chat-core.js';
4
4
  import { ChatStore } from '../chat/chat-store.js';
5
5
  import { suggestChatTitle } from '../chat/chat-title.js';
6
+ import { VisualStore } from '../chat/visual-store.js';
6
7
  import { ClaudeTitleReader } from '../chat/claude-title.js';
7
8
  import { DEFAULT_CODEX_CLIENT } from '../chat/codex-transport.js';
8
9
  import { definedEnv } from '../providers/accounts/launch.js';
@@ -58,6 +59,7 @@ export function wireAgents(options) {
58
59
  store: new ChatStore(options.dataDir, { attachments }),
59
60
  attachments,
60
61
  bookmarks: new BookmarkStore(options.dataDir),
62
+ visuals: new VisualStore(options.dataDir, attachments),
61
63
  env,
62
64
  ...(options.systemNote === undefined ? {} : { instructions: options.systemNote }),
63
65
  ...(options.command ? { command: options.command } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.17.0-beta.2",
3
+ "version": "0.18.0",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,7 +20,7 @@
20
20
  "test": "bun --conditions=source test"
21
21
  },
22
22
  "dependencies": {
23
- "@adecore/agent-contracts": "0.17.0-beta.2",
23
+ "@adecore/agent-contracts": "0.18.0",
24
24
  "zod": "^4.6.5"
25
25
  },
26
26
  "devDependencies": {
@@ -50,7 +50,7 @@ export class AttachmentStore {
50
50
  const bytes = Buffer.from(upload.data, 'base64');
51
51
  const mime = attachmentImageMime(upload) ?? upload.mime;
52
52
  const id = randomBytes(8).toString('hex');
53
- const folder = this.folder(chatId);
53
+ const folder = this.folderOf(chatId);
54
54
  await mkdir(folder, { recursive: true, mode: 0o700 });
55
55
  const path = join(folder, `${id}.${extensionFor(upload.name, mime)}`);
56
56
  await writeFile(path, bytes, { mode: 0o600 });
@@ -59,10 +59,11 @@ export class AttachmentStore {
59
59
 
60
60
  /* Everything a chat attached; used when the chat itself is deleted. */
61
61
  async removeAll(chatId: string): Promise<void> {
62
- await rm(this.folder(chatId), { recursive: true, force: true });
62
+ await rm(this.folderOf(chatId), { recursive: true, force: true });
63
63
  }
64
64
 
65
- private folder(chatId: string): string {
65
+ /* Where the files of one chat are; a store that keeps more of a chat's files beside them writes them here. */
66
+ folderOf(chatId: string): string {
66
67
  return join(this.dir, encodeURIComponent(chatId));
67
68
  }
68
69
  }
@@ -19,6 +19,7 @@ import {
19
19
  type ChatSubagentPayload,
20
20
  type ChatSubagentResult,
21
21
  type ChatTurnItem,
22
+ type ChatVisual,
22
23
  type ModelSelection,
23
24
  type RuntimeMode
24
25
  } from '@adecore/agent-contracts';
@@ -54,6 +55,7 @@ import { DeltaCoalescer } from './delta-coalescer.ts';
54
55
  import { ChatError } from './errors.ts';
55
56
  import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
56
57
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
58
+ import type { VisualInput, VisualStore } from './visual-store.ts';
57
59
 
58
60
  /* A turn that was running when the host went down, and the attempt that would take it up again. */
59
61
  export interface InterruptedRun {
@@ -115,6 +117,8 @@ export interface ChatCoreOptions {
115
117
  // Where the files people attach are written.
116
118
  attachments: AttachmentStore;
117
119
  bookmarks?: BookmarkStore;
120
+ // Pages agents publish in a chat; their files go in the same attachment folders.
121
+ visuals?: VisualStore;
118
122
  // What a running turn says about the plan its CLI runs on; the usage monitor takes it from here.
119
123
  onLimits?: (update: LimitsUpdate) => void;
120
124
  // Where Claude Code's own name for a session is read, in the projects folder of the chat's account; the other CLIs write none down.
@@ -151,6 +155,7 @@ export class ChatCore {
151
155
  protected readonly providers: ProviderRegistry;
152
156
  protected readonly store: ChatStore | null;
153
157
  protected readonly bookmarks: BookmarkStore | null;
158
+ protected readonly visuals: VisualStore | null;
154
159
  protected readonly accounts: AccountLaunches | null;
155
160
  protected readonly attachments: AttachmentStore;
156
161
  protected readonly env: Record<string, string>;
@@ -192,6 +197,7 @@ export class ChatCore {
192
197
  this.providers = options.providers;
193
198
  this.store = options.store ?? null;
194
199
  this.bookmarks = options.bookmarks ?? null;
200
+ this.visuals = options.visuals ?? null;
195
201
  this.accounts = options.accounts ?? null;
196
202
  this.attachments = options.attachments;
197
203
  this.checkpoints = options.checkpoints ?? null;
@@ -209,12 +215,17 @@ export class ChatCore {
209
215
  this.spawn = options.spawn ?? null;
210
216
  this.codexClient = options.codexClient ?? null;
211
217
  this.env = definedEnv(options.env ?? process.env);
212
- // Only whoever reads the thread has anything to point a bookmark at, so the list goes where the thread goes.
218
+ // 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.
213
219
  this.bookmarks?.listen((chatId, bookmarks) => {
214
220
  for (const clientId of this.attached.get(chatId) ?? []) {
215
221
  this.sinks.to(clientId, { event: 'chat.bookmarks', payload: { chatId, bookmarks } });
216
222
  }
217
223
  });
224
+ this.visuals?.listen((chatId, visuals) => {
225
+ for (const clientId of this.attached.get(chatId) ?? []) {
226
+ this.sinks.to(clientId, { event: 'chat.visuals', payload: { chatId, visuals } });
227
+ }
228
+ });
218
229
  this.claudeProjectsDir =
219
230
  options.subagents?.claudeProjectsDir ?? usageRoots(options.env ?? process.env).find((root) => root.provider === 'claude')?.path ?? '';
220
231
  this.subagents = new SubagentReader({
@@ -492,18 +503,22 @@ export class ChatCore {
492
503
  }
493
504
 
494
505
  /*
495
- * `attach` with the chat's bookmarks. They are read before the client joins, so a change written
496
- * after the read reaches it as `chat.bookmarks` and never falls between the two. A file that
497
- * cannot be read leaves the list out rather than the thread.
506
+ * `attach` with the chat's bookmarks and visuals. They are read before the client joins, so a
507
+ * change written after the read reaches it as `chat.bookmarks` or `chat.visuals` and never falls
508
+ * between the two. A file that cannot be read leaves its list out rather than the thread.
498
509
  */
499
510
  async attachWithBookmarks(chatId: string, clientId: string, historyLimit?: number, since?: number): Promise<ChatAttachResult> {
500
511
  this.require(chatId);
501
- const bookmarks = await this.bookmarks?.read(chatId).catch((e: unknown) => {
502
- console.error(`The bookmarks of chat ${chatId} could not be read:`, errorText(e));
512
+ const unreadable = (what: string) => (e: unknown) => {
513
+ console.error(`The ${what} of chat ${chatId} could not be read:`, errorText(e));
503
514
  return undefined;
504
- });
515
+ };
516
+ const [bookmarks, visuals] = await Promise.all([
517
+ this.bookmarks?.read(chatId).catch(unreadable('bookmarks')),
518
+ this.visuals?.list(chatId).catch(unreadable('visuals'))
519
+ ]);
505
520
  const result = this.attach(chatId, clientId, historyLimit, since);
506
- return bookmarks === undefined ? result : { ...result, bookmarks };
521
+ return { ...result, ...(bookmarks === undefined ? {} : { bookmarks }), ...(visuals === undefined ? {} : { visuals }) };
507
522
  }
508
523
 
509
524
  /* Marks a message of the chat's own thread; a message that already has one keeps it. */
@@ -532,6 +547,37 @@ export class ChatCore {
532
547
  return this.bookmarks;
533
548
  }
534
549
 
550
+ /*
551
+ * Publishes a page an agent made in a chat, for an app's own agent command. Without a `turnId` it
552
+ * belongs to the turn that is running, if one is. The chat may be one nobody loaded.
553
+ */
554
+ async publishVisual(chatId: string, input: VisualInput): Promise<ChatVisual> {
555
+ const visuals = this.requireVisuals();
556
+ await this.loaded(chatId);
557
+ if (!(await this.hasStored(chatId))) {
558
+ throw new ChatError('chat-not-found', `No chat ${chatId}`);
559
+ }
560
+ const turnId = input.turnId ?? this.chats.get(chatId)?.info.activeTurnId ?? undefined;
561
+ return visuals.publish(chatId, turnId === undefined ? input : { ...input, turnId });
562
+ }
563
+
564
+ /* The visuals of a chat in the order they were published. */
565
+ listVisuals(chatId: string): Promise<ChatVisual[]> {
566
+ return this.requireVisuals().list(chatId);
567
+ }
568
+
569
+ removeVisual(chatId: string, visualId: string): Promise<ChatVisual[]> {
570
+ this.require(chatId);
571
+ return this.requireVisuals().remove(chatId, visualId);
572
+ }
573
+
574
+ private requireVisuals(): VisualStore {
575
+ if (!this.visuals) {
576
+ throw new ChatError('chat-unsupported', 'This host keeps no visuals');
577
+ }
578
+ return this.visuals;
579
+ }
580
+
535
581
  history(chatId: string, cursor: string, limit?: number): ChatHistoryResult {
536
582
  const session = this.require(chatId);
537
583
  this.coalescers.get(chatId)?.flush();
@@ -657,7 +703,7 @@ export class ChatCore {
657
703
  return session.send(text, { ...extras, ...(attachments.length > 0 ? { attachments } : {}) });
658
704
  }
659
705
 
660
- /* The file behind an attachment id: what this chat's thread or queue says it is. */
706
+ /* The file behind an attachment id: what this chat's thread or queue says it is, or a visual's page. */
661
707
  attachment(chatId: string, id: string): ChatAttachment | null {
662
708
  const session = this.chats.get(chatId);
663
709
  if (!session) {
@@ -675,7 +721,7 @@ export class ChatCore {
675
721
  return found;
676
722
  }
677
723
  }
678
- return null;
724
+ return this.visuals?.attachment(chatId, id) ?? null;
679
725
  }
680
726
 
681
727
  unqueue(chatId: string, messageId: string): ChatQueuedMessage {
@@ -706,7 +752,7 @@ export class ChatCore {
706
752
  session.compact();
707
753
  }
708
754
 
709
- /* Empties the thread and drops the CLI's session and the chat's bookmarks; `force` stops a turn that is in the way. */
755
+ /* 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. */
710
756
  async clear(chatId: string, force = false): Promise<void> {
711
757
  const session = this.require(chatId);
712
758
  if (session.busy && !force) {
@@ -719,7 +765,13 @@ export class ChatCore {
719
765
  // A debounced write still waiting holds the old thread and must not land after the empty one.
720
766
  this.cancelWaiting(chatId);
721
767
  // Folded right away: every line before the reset describes a thread that is gone.
722
- await Promise.all([this.persistNow(chatId, true), this.attachments.removeAll(chatId), this.bookmarks?.removeChat(chatId), cleared]);
768
+ await Promise.all([
769
+ this.persistNow(chatId, true),
770
+ this.attachments.removeAll(chatId),
771
+ this.bookmarks?.removeChat(chatId),
772
+ this.visuals?.removeChat(chatId),
773
+ cleared
774
+ ]);
723
775
  }
724
776
 
725
777
  /* Stops the running turn; with `subagents` also marks the CLI's own subagents stopped, which that turn no longer waits on. */
@@ -754,7 +806,7 @@ export class ChatCore {
754
806
  }
755
807
  }
756
808
 
757
- /* Removes a chat: its CLI, its record, its log, its attachments and its bookmarks. */
809
+ /* Removes a chat: its CLI, its record, its log, its attachments, its bookmarks and its visuals. */
758
810
  async kill(chatId: string): Promise<void> {
759
811
  await this.loaded(chatId);
760
812
  const session = this.require(chatId);
@@ -777,7 +829,13 @@ export class ChatCore {
777
829
  const snapshot = session.thread.snapshot();
778
830
  this.forgotten(chatId);
779
831
  await writing;
780
- await Promise.all([this.store?.delete(chatId), this.attachments.removeAll(chatId), this.bookmarks?.removeChat(chatId), this.removed(chatId, snapshot)]);
832
+ await Promise.all([
833
+ this.store?.delete(chatId),
834
+ this.attachments.removeAll(chatId),
835
+ this.bookmarks?.removeChat(chatId),
836
+ this.visuals?.removeChat(chatId),
837
+ this.removed(chatId, snapshot)
838
+ ]);
781
839
  }
782
840
 
783
841
  /*
@@ -8,6 +8,7 @@ import { isBookmarkFileName } from './bookmark-store.ts';
8
8
  import { migrateInlineAttachments, type AttachmentStore } from './attachment-store.ts';
9
9
  import { parseLog, type ChatLogLine } from './chat-log.ts';
10
10
  import { ChatThread } from './thread.ts';
11
+ import { isVisualFileName } from './visual-store.ts';
11
12
  import { recordFileName } from '../record-directory.ts';
12
13
 
13
14
  // zod strips what it does not know inside the info and the items, so a file written by an older build
@@ -98,8 +99,10 @@ export class ChatStore {
98
99
  throw e;
99
100
  }
100
101
  const ids = names
101
- // A chat's bookmarks, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
102
- .filter((name) => (name.endsWith('.json') && !isBookmarkFileName(name) && !this.isSidecar(name)) || name.endsWith('.log'))
102
+ // A chat's bookmarks and visuals, and whatever a host keeps per chat, sit beside its record under names that also end in .json.
103
+ .filter(
104
+ (name) => (name.endsWith('.json') && !isBookmarkFileName(name) && !isVisualFileName(name) && !this.isSidecar(name)) || name.endsWith('.log')
105
+ )
103
106
  .map((name) => decodeURIComponent(name.replace(/\.(json|log)$/, '')));
104
107
  return [...new Set(ids)];
105
108
  }
@@ -19,7 +19,10 @@ export type ChatErrorCode =
19
19
  // What a bookmark is refused with.
20
20
  | 'item-not-found'
21
21
  | 'bookmark-not-found'
22
- | 'too-many-bookmarks';
22
+ | 'too-many-bookmarks'
23
+ // What publishing a visual is refused with; the message says what to change.
24
+ | 'visual-invalid'
25
+ | 'visual-too-large';
23
26
 
24
27
  /* A host that refuses more than a chat does widens the codes with its own. */
25
28
  export class ChatError<TCode extends string = ChatErrorCode> extends CodedError<TCode> {}
@@ -0,0 +1,278 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { ChatVisualsSchema, injectVisualBootstrap, VISUAL_LIMITS, type ChatAttachment, type ChatVisual, type VisualHeight } from '@adecore/agent-contracts';
5
+ import { z } from 'zod';
6
+ import { isNotFound, writeAtomic } from '../fs.ts';
7
+ import { KeyedSerializer } from '../serializer.ts';
8
+ import type { AttachmentStore } from './attachment-store.ts';
9
+ import { ChatError } from './errors.ts';
10
+
11
+ const SUFFIX = '.visuals.json';
12
+
13
+ const VisualFileSchema = z.object({ version: z.literal(1), visuals: ChatVisualsSchema });
14
+
15
+ export function visualFileName(chatId: string): string {
16
+ return `${encodeURIComponent(chatId)}${SUFFIX}`;
17
+ }
18
+
19
+ /* Whether a name in the chats folder is a visuals file, which the chat records beside it must not count as a chat. */
20
+ export function isVisualFileName(name: string): boolean {
21
+ return name.endsWith(SUFFIX);
22
+ }
23
+
24
+ /* What an agent hands over: the page as it wrote it, which gets its bootstrap here. */
25
+ export interface VisualInput {
26
+ title: string;
27
+ html: string;
28
+ // CSS pixels; absent, the largest a frame may be.
29
+ maxHeight?: number;
30
+ // `[width, height]` in CSS pixels, from a host that measured the page before publishing it.
31
+ heights?: ReadonlyArray<readonly [number, number]>;
32
+ turnId?: string;
33
+ }
34
+
35
+ export interface VisualStoreOptions {
36
+ // The clock `at` is stamped with, which has to be the one the thread's `createdAt` comes from.
37
+ now?: () => number;
38
+ }
39
+
40
+ export type VisualListener = (chatId: string, visuals: ChatVisual[]) => void;
41
+
42
+ const MIB = 1024 * 1024;
43
+
44
+ function mebibytes(bytes: number): string {
45
+ return `${(bytes / MIB).toFixed(1)} MiB`;
46
+ }
47
+
48
+ function refuse(code: 'visual-invalid' | 'visual-too-large', message: string): never {
49
+ throw new ChatError(code, message);
50
+ }
51
+
52
+ function isSize(value: unknown): value is number {
53
+ return typeof value === 'number' && Number.isFinite(value);
54
+ }
55
+
56
+ /* The input as it is kept, or a refusal that tells the agent what to change. */
57
+ function checked(input: VisualInput): { title: string; html: string; maxHeight: number; heights: VisualHeight[] | undefined } {
58
+ const title = typeof input.title === 'string' ? input.title.trim() : '';
59
+ if (title === '') {
60
+ refuse('visual-invalid', 'A visual needs a title; give it a short one that says what the page shows');
61
+ }
62
+ if (title.length > VISUAL_LIMITS.title) {
63
+ refuse('visual-invalid', `The title has ${title.length} characters; keep it to ${VISUAL_LIMITS.title} or fewer`);
64
+ }
65
+ if (typeof input.html !== 'string' || input.html.trim() === '') {
66
+ refuse('visual-invalid', 'The page is empty; pass one self-contained HTML document');
67
+ }
68
+ const maxHeight = input.maxHeight ?? VISUAL_LIMITS.maxHeight;
69
+ if (!isSize(maxHeight) || maxHeight < VISUAL_LIMITS.minHeight || maxHeight > VISUAL_LIMITS.maxHeight) {
70
+ refuse(
71
+ 'visual-invalid',
72
+ `The maximum height must be from ${VISUAL_LIMITS.minHeight} to ${VISUAL_LIMITS.maxHeight} CSS pixels; leave it out for ${VISUAL_LIMITS.maxHeight}`
73
+ );
74
+ }
75
+ const heights = input.heights ?? [];
76
+ if (
77
+ !Array.isArray(heights) ||
78
+ heights.length > VISUAL_LIMITS.heights ||
79
+ heights.some((pair) => !Array.isArray(pair) || !isSize(pair[0]) || !isSize(pair[1]) || pair[0] <= 0 || pair[1] < 0)
80
+ ) {
81
+ refuse('visual-invalid', `Measured heights are at most ${VISUAL_LIMITS.heights} pairs of [width, height] in CSS pixels, with a positive width`);
82
+ }
83
+ // One measurement per width, the last one given, ascending as `visualFrameHeight` reads them.
84
+ const byWidth = new Map(heights.map((pair): [number, number] => [Math.max(1, Math.round(pair[0])), Math.ceil(pair[1])]));
85
+ const sorted = [...byWidth].sort((a, b) => a[0] - b[0]);
86
+ return { title, html: input.html, maxHeight: Math.round(maxHeight), heights: sorted.length === 0 ? undefined : sorted };
87
+ }
88
+
89
+ function tooLarge(bytes: number): never {
90
+ return refuse(
91
+ 'visual-too-large',
92
+ `The page is ${mebibytes(bytes)} and a visual may be at most ${mebibytes(VISUAL_LIMITS.bytes)}; ` +
93
+ 'load libraries from a public CDN URL and refer to large images by their http(s) URL instead of inlining them'
94
+ );
95
+ }
96
+
97
+ /*
98
+ * The visuals of every chat. The list of a chat is one file beside its record under `<home>/chats`;
99
+ * each page is a file in the chat's attachment folder, `<id>.html`, so whatever serves an attachment
100
+ * serves a page by its id unchanged. One chain per chat puts a publish and a removal after each other,
101
+ * and every change is told to the listeners from inside that chain.
102
+ */
103
+ export class VisualStore {
104
+ readonly dir: string;
105
+ private readonly attachments: AttachmentStore;
106
+ private readonly now: () => number;
107
+ private readonly writes = new KeyedSerializer();
108
+ private readonly listeners = new Set<VisualListener>();
109
+ // The lists last read or written, so an attachment lookup, which cannot wait, finds a page.
110
+ private readonly known = new Map<string, ChatVisual[]>();
111
+
112
+ constructor(home: string, attachments: AttachmentStore, options: VisualStoreOptions = {}) {
113
+ this.dir = join(home, 'chats');
114
+ this.attachments = attachments;
115
+ this.now = options.now ?? Date.now;
116
+ }
117
+
118
+ listen(listener: VisualListener): () => void {
119
+ this.listeners.add(listener);
120
+ return () => {
121
+ this.listeners.delete(listener);
122
+ };
123
+ }
124
+
125
+ /* The visuals of one chat in the order they were published, after any write still on its way. */
126
+ list(chatId: string): Promise<ChatVisual[]> {
127
+ return this.writes.run(chatId, () => this.readFile(chatId));
128
+ }
129
+
130
+ /* Stores a page with its bootstrap and adds it to the chat at the host's current time. */
131
+ async publish(chatId: string, input: VisualInput): Promise<ChatVisual> {
132
+ const { title, html, maxHeight, heights } = checked(input);
133
+ // Before the bootstrap is added, so a page far too large is never copied first.
134
+ if (Buffer.byteLength(html, 'utf8') > VISUAL_LIMITS.bytes) {
135
+ tooLarge(Buffer.byteLength(html, 'utf8'));
136
+ }
137
+ const page = Buffer.from(injectVisualBootstrap(html), 'utf8');
138
+ if (page.byteLength > VISUAL_LIMITS.bytes) {
139
+ tooLarge(page.byteLength);
140
+ }
141
+ const turnId = typeof input.turnId === 'string' && input.turnId !== '' ? input.turnId : undefined;
142
+ return this.writes.run(chatId, async () => {
143
+ const visuals = await this.readFile(chatId);
144
+ const visual: ChatVisual = {
145
+ id: randomBytes(8).toString('hex'),
146
+ title,
147
+ at: this.now(),
148
+ maxHeight,
149
+ ...(heights === undefined ? {} : { heights }),
150
+ size: page.byteLength,
151
+ ...(turnId === undefined ? {} : { turnId })
152
+ };
153
+ await mkdir(this.attachments.folderOf(chatId), { recursive: true, mode: 0o700 });
154
+ await writeAtomic(this.pagePath(chatId, visual.id), page);
155
+ const next = [...visuals, visual];
156
+ try {
157
+ await this.writeFile(chatId, next);
158
+ } catch (e) {
159
+ await rm(this.pagePath(chatId, visual.id), { force: true });
160
+ throw e;
161
+ }
162
+ this.tell(chatId, next);
163
+ return visual;
164
+ });
165
+ }
166
+
167
+ /* Takes a visual and its page away; one that is already gone changes nothing. */
168
+ remove(chatId: string, visualId: string): Promise<ChatVisual[]> {
169
+ return this.writes.run(chatId, async () => {
170
+ const visuals = await this.readFile(chatId);
171
+ if (!visuals.some((visual) => visual.id === visualId)) {
172
+ return visuals;
173
+ }
174
+ const next = visuals.filter((visual) => visual.id !== visualId);
175
+ await this.writeFile(chatId, next);
176
+ await rm(this.pagePath(chatId, visualId), { force: true });
177
+ this.tell(chatId, next);
178
+ return next;
179
+ });
180
+ }
181
+
182
+ /* The visuals go with the chat or with a clear of it. A list that no longer parses goes too, since nothing could show it again. */
183
+ removeChat(chatId: string): Promise<void> {
184
+ return this.writes.run(chatId, async () => {
185
+ const visuals = await this.readFile(chatId).catch(() => null);
186
+ await rm(join(this.dir, visualFileName(chatId)), { force: true });
187
+ await Promise.all((visuals ?? []).map((visual) => rm(this.pagePath(chatId, visual.id), { force: true })));
188
+ this.known.delete(chatId);
189
+ if (visuals === null || visuals.length > 0) {
190
+ this.tell(chatId, []);
191
+ }
192
+ });
193
+ }
194
+
195
+ /*
196
+ * Gives a fork the visuals `keep` picks, every one without it, each with a page of its own, so
197
+ * removing either chat leaves the other's pages where they are. A page that is gone is left out.
198
+ */
199
+ async copyChat(fromChatId: string, toChatId: string, keep: (visual: ChatVisual) => boolean = () => true): Promise<ChatVisual[]> {
200
+ const kept = (await this.list(fromChatId)).filter(keep);
201
+ if (kept.length === 0) {
202
+ return this.list(toChatId);
203
+ }
204
+ return this.writes.run(toChatId, async () => {
205
+ const copied: ChatVisual[] = [];
206
+ await mkdir(this.attachments.folderOf(toChatId), { recursive: true, mode: 0o700 });
207
+ for (const visual of kept) {
208
+ const page = await readFile(this.pagePath(fromChatId, visual.id)).catch((e: unknown) => {
209
+ if (isNotFound(e)) {
210
+ return null;
211
+ }
212
+ throw e;
213
+ });
214
+ if (page !== null) {
215
+ await writeAtomic(this.pagePath(toChatId, visual.id), page);
216
+ copied.push(visual);
217
+ }
218
+ }
219
+ const existing = await this.readFile(toChatId);
220
+ const ids = new Set(existing.map((visual) => visual.id));
221
+ const next = [...existing, ...copied.filter((visual) => !ids.has(visual.id))];
222
+ await this.writeFile(toChatId, next);
223
+ this.tell(toChatId, next);
224
+ return next;
225
+ });
226
+ }
227
+
228
+ /* A page as an attachment of its chat, from the lists this store has seen; null for one it does not know. */
229
+ attachment(chatId: string, visualId: string): ChatAttachment | null {
230
+ const visual = this.known.get(chatId)?.find((candidate) => candidate.id === visualId);
231
+ if (!visual) {
232
+ return null;
233
+ }
234
+ return { id: visual.id, name: `${visual.title}.html`, mime: 'text/html', size: visual.size, path: this.pagePath(chatId, visual.id) };
235
+ }
236
+
237
+ pagePath(chatId: string, visualId: string): string {
238
+ return join(this.attachments.folderOf(chatId), `${visualId}.html`);
239
+ }
240
+
241
+ private tell(chatId: string, visuals: ChatVisual[]): void {
242
+ for (const listener of this.listeners) {
243
+ listener(chatId, visuals);
244
+ }
245
+ }
246
+
247
+ private async readFile(chatId: string): Promise<ChatVisual[]> {
248
+ let raw: string;
249
+ try {
250
+ raw = await readFile(join(this.dir, visualFileName(chatId)), 'utf8');
251
+ } catch (e) {
252
+ if (isNotFound(e)) {
253
+ this.known.delete(chatId);
254
+ return [];
255
+ }
256
+ throw e;
257
+ }
258
+ // A file that does not parse is refused rather than read as empty, which the next write would make true.
259
+ const parsed = VisualFileSchema.safeParse(JSON.parse(raw));
260
+ if (!parsed.success) {
261
+ throw new Error(`The visuals file of chat ${chatId} is not valid: ${parsed.error.issues[0]?.message ?? 'unknown problem'}`);
262
+ }
263
+ this.known.set(chatId, parsed.data.visuals);
264
+ return parsed.data.visuals;
265
+ }
266
+
267
+ private async writeFile(chatId: string, visuals: readonly ChatVisual[]): Promise<void> {
268
+ const path = join(this.dir, visualFileName(chatId));
269
+ if (visuals.length === 0) {
270
+ await rm(path, { force: true });
271
+ this.known.delete(chatId);
272
+ return;
273
+ }
274
+ await mkdir(this.dir, { recursive: true, mode: 0o700 });
275
+ await writeAtomic(path, JSON.stringify({ version: 1, visuals }));
276
+ this.known.set(chatId, [...visuals]);
277
+ }
278
+ }
@@ -99,6 +99,7 @@ export function chatHandlers(chats: ChatCore, providers: ProviderRegistry): { [T
99
99
  'chat.addBookmark': async (payload) => ({ bookmarks: await chats.addBookmark(payload.chatId, payload.itemId, payload.name) }),
100
100
  'chat.renameBookmark': async (payload) => ({ bookmarks: await chats.renameBookmark(payload.chatId, payload.itemId, payload.name) }),
101
101
  'chat.removeBookmark': async (payload) => ({ bookmarks: await chats.removeBookmark(payload.chatId, payload.itemId) }),
102
+ 'chat.removeVisual': async (payload) => ({ visuals: await chats.removeVisual(payload.chatId, payload.visualId) }),
102
103
  'chat.kill': async (payload) => {
103
104
  await chats.kill(payload.chatId);
104
105
  return {};
@@ -5,6 +5,7 @@ import { ChatCore, type ChatCoreOptions } from '../chat/chat-core.ts';
5
5
  import type { SpawnChatProcess } from '../chat/chat-process.ts';
6
6
  import { ChatStore } from '../chat/chat-store.ts';
7
7
  import { suggestChatTitle } from '../chat/chat-title.ts';
8
+ import { VisualStore } from '../chat/visual-store.ts';
8
9
  import type { ClaudeBackendOptions } from '../chat/claude-backend.ts';
9
10
  import { ClaudeTitleReader } from '../chat/claude-title.ts';
10
11
  import { DEFAULT_CODEX_CLIENT, type CodexClientInfo } from '../chat/codex-transport.ts';
@@ -24,7 +25,7 @@ import { cliEnvironment, type CliEnvironmentPolicy } from './environment.ts';
24
25
  import { accountHandlers, chatHandlers, usageHandlers, type AgentHandlers } from './handlers.ts';
25
26
 
26
27
  export interface AgentWiringOptions<Core extends ChatCore = ChatCore> {
27
- // Where the chats, their attachments and bookmarks, the accounts and the usage index are kept.
28
+ // Where the chats, their attachments, bookmarks and visuals, the accounts and the usage index are kept.
28
29
  dataDir: string;
29
30
  // The environment the CLIs start in; absent, this process's own through `cliEnvironment`.
30
31
  env?: Record<string, string | undefined>;
@@ -103,6 +104,7 @@ export function wireAgents<Core extends ChatCore = ChatCore>(options: AgentWirin
103
104
  store: new ChatStore(options.dataDir, { attachments }),
104
105
  attachments,
105
106
  bookmarks: new BookmarkStore(options.dataDir),
107
+ visuals: new VisualStore(options.dataDir, attachments),
106
108
  env,
107
109
  ...(options.systemNote === undefined ? {} : { instructions: options.systemNote }),
108
110
  ...(options.command ? { command: options.command } : {}),