@adecore/agents 0.21.0 → 0.21.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/dist/async.js +3 -8
  2. package/dist/chat/chat-core.d.ts +7 -3
  3. package/dist/chat/chat-core.js +19 -9
  4. package/dist/chat/chat-log.js +2 -2
  5. package/dist/chat/chat-session.d.ts +13 -0
  6. package/dist/chat/chat-session.js +75 -5
  7. package/dist/chat/chat-store.d.ts +3 -2
  8. package/dist/chat/chat-store.js +44 -16
  9. package/dist/chat/chat-title.js +4 -8
  10. package/dist/chat/claude-backend.d.ts +1 -0
  11. package/dist/chat/claude-backend.js +8 -6
  12. package/dist/chat/claude-title.js +1 -1
  13. package/dist/chat/codex-backend.d.ts +2 -2
  14. package/dist/chat/codex-backend.js +5 -8
  15. package/dist/chat/codex-protocol.js +1 -1
  16. package/dist/chat/codex-thread.js +4 -5
  17. package/dist/chat/composer-preferences.js +2 -3
  18. package/dist/chat/limit-resume.js +2 -7
  19. package/dist/chat/readable-text.d.ts +2 -0
  20. package/dist/chat/readable-text.js +16 -0
  21. package/dist/chat/ui-queries.d.ts +12 -1
  22. package/dist/chat/ui-queries.js +107 -60
  23. package/dist/chat/visual-store.js +8 -6
  24. package/dist/context/refusal.js +3 -7
  25. package/dist/context/verb.js +1 -5
  26. package/dist/host/agent-host.js +2 -2
  27. package/dist/host/handlers.js +1 -2
  28. package/dist/host/wiring.js +1 -1
  29. package/dist/lineage.js +2 -3
  30. package/dist/messages/deliver-message.js +6 -11
  31. package/dist/messages/deliver-notice.js +8 -12
  32. package/dist/messages/notice-store.js +3 -5
  33. package/dist/outbox/outbox-worker.js +5 -2
  34. package/dist/providers/accounts/service.d.ts +2 -1
  35. package/dist/providers/accounts/service.js +11 -13
  36. package/dist/providers/claude.js +0 -1
  37. package/dist/providers/detect.js +2 -10
  38. package/dist/record-directory.js +0 -1
  39. package/dist/tasks/task-coordinator.d.ts +1 -0
  40. package/dist/tasks/task-coordinator.js +40 -36
  41. package/dist/tasks/task-store.js +1 -1
  42. package/dist/tasks/task-work.js +2 -2
  43. package/dist/tasks/waiting-child.js +5 -3
  44. package/dist/tasks/wake-parent.js +15 -10
  45. package/dist/tasks/wiring.js +32 -37
  46. package/dist/usage/aggregate.js +62 -46
  47. package/dist/usage/exchange.js +1 -1
  48. package/dist/usage/limits/monitor.d.ts +2 -2
  49. package/dist/usage/limits/monitor.js +2 -2
  50. package/dist/usage/limits/probe.js +1 -1
  51. package/dist/usage/pricing.js +1 -1
  52. package/dist/usage/roots.js +1 -2
  53. package/dist/usage/scanner.js +7 -9
  54. package/dist/usage/usage-service.d.ts +1 -2
  55. package/dist/usage/usage-service.js +3 -6
  56. package/package.json +3 -3
  57. package/src/async.ts +3 -9
  58. package/src/chat/chat-core.ts +29 -9
  59. package/src/chat/chat-log.ts +2 -2
  60. package/src/chat/chat-session.ts +85 -5
  61. package/src/chat/chat-store.ts +54 -16
  62. package/src/chat/chat-title.ts +4 -8
  63. package/src/chat/claude-backend.ts +9 -6
  64. package/src/chat/claude-title.ts +1 -1
  65. package/src/chat/claude-transcript.ts +1 -1
  66. package/src/chat/codex-backend.ts +5 -8
  67. package/src/chat/codex-protocol.ts +1 -1
  68. package/src/chat/codex-thread.ts +6 -5
  69. package/src/chat/composer-preferences.ts +2 -3
  70. package/src/chat/limit-resume.ts +2 -7
  71. package/src/chat/readable-text.ts +18 -0
  72. package/src/chat/thread.ts +1 -1
  73. package/src/chat/ui-queries.ts +120 -64
  74. package/src/chat/visual-store.ts +9 -6
  75. package/src/chat/wake-chat.ts +4 -1
  76. package/src/context/refusal.ts +3 -7
  77. package/src/context/verb.ts +1 -5
  78. package/src/host/agent-host.ts +2 -2
  79. package/src/host/handlers.ts +1 -2
  80. package/src/host/wiring.ts +1 -1
  81. package/src/lineage.ts +2 -3
  82. package/src/messages/deliver-message.ts +6 -11
  83. package/src/messages/deliver-notice.ts +8 -12
  84. package/src/messages/notice-store.ts +3 -5
  85. package/src/outbox/outbox-worker.ts +5 -2
  86. package/src/providers/accounts/service.ts +12 -13
  87. package/src/providers/accounts/variables.ts +1 -0
  88. package/src/providers/claude.ts +0 -1
  89. package/src/providers/detect.ts +2 -11
  90. package/src/record-directory.ts +0 -1
  91. package/src/skills.ts +0 -1
  92. package/src/tasks/task-coordinator.ts +41 -35
  93. package/src/tasks/task-store.ts +1 -1
  94. package/src/tasks/task-work.ts +2 -2
  95. package/src/tasks/waiting-child.ts +6 -3
  96. package/src/tasks/wake-parent.ts +21 -16
  97. package/src/tasks/wiring.ts +33 -41
  98. package/src/usage/aggregate.ts +80 -50
  99. package/src/usage/exchange.ts +1 -1
  100. package/src/usage/limits/monitor.ts +4 -4
  101. package/src/usage/limits/probe.ts +2 -2
  102. package/src/usage/pricing.ts +1 -1
  103. package/src/usage/roots.ts +1 -2
  104. package/src/usage/scanner.ts +8 -10
  105. package/src/usage/usage-service.ts +5 -9
package/dist/async.js CHANGED
@@ -1,8 +1,6 @@
1
- /* The two waits the host does: a pause it can be woken from, and a deadline on somebody else's promise. */
2
1
  /*
3
- * Resolves after `ms`, or at once when the signal is raised. An abort ends the wait rather than
4
- * failing it: every caller here is a loop that checks the signal itself on the next turn, and a
5
- * rejection would only be caught and thrown away.
2
+ * Resolves after `ms`, or at once when the signal is raised. An abort resolves instead of rejecting:
3
+ * every caller is a loop that checks the signal itself on its next turn.
6
4
  */
7
5
  export function wait(ms, signal) {
8
6
  return new Promise((resolve) => {
@@ -13,10 +11,7 @@ export function wait(ms, signal) {
13
11
  }, { once: true });
14
12
  });
15
13
  }
16
- /*
17
- * The promise, or `message` when it takes longer than `ms`. The timer is cleared either way, so a
18
- * call that answers at once does not hold the process for the rest of its deadline.
19
- */
14
+ /* The timer is cleared either way, so a call that answers at once does not hold the process until its deadline. */
20
15
  export function withTimeout(promise, ms, message) {
21
16
  return new Promise((resolve, reject) => {
22
17
  const timer = setTimeout(() => reject(new Error(message)), ms);
@@ -1,5 +1,4 @@
1
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 ChatUiChoicePayload, type ChatUiQueryPayload, type ChatUiLinkPayload, type ChatSkill, type ChatSubagentPayload, type ChatSubagentResult, type ChatTurnItem, type ChatVisual, type ModelSelection, type RuntimeMode } from '@adecore/agent-contracts';
2
- import { type ChatUiHost } from './ui-queries.ts';
3
2
  import { ClientSinks } from '../client-sinks.ts';
4
3
  import type { AgentEvent, AgentSink } from '../events.ts';
5
4
  import type { OutboxOutcome } from '../outbox/outbox-worker.ts';
@@ -8,6 +7,7 @@ import type { ProviderRegistry } from '../providers/registry.ts';
8
7
  import { SkillIndex } from '../skills.ts';
9
8
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
10
9
  import type { AttachmentStore } from './attachment-store.ts';
10
+ import { type BackgroundWork } from './background-work.ts';
11
11
  import type { BookmarkStore } from './bookmark-store.ts';
12
12
  import type { SpawnChatProcess } from './chat-process.ts';
13
13
  import { ChatSession, type ChatReferences, type ChatSendExtras, type PromptNotes, type ResumeWords, type TurnCheckpoints } from './chat-session.ts';
@@ -17,8 +17,8 @@ import type { CodexProcessSpec } from './codex-thread.ts';
17
17
  import type { CodexClientInfo } from './codex-transport.ts';
18
18
  import { ComposerPreferences } from './composer-preferences.ts';
19
19
  import { DeltaCoalescer } from './delta-coalescer.ts';
20
- import { type BackgroundWork } from './background-work.ts';
21
20
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
21
+ import { type ChatUiHost } from './ui-queries.ts';
22
22
  import type { VisualInput, VisualStore } from './visual-store.ts';
23
23
  export interface InterruptedRun {
24
24
  chatId: string;
@@ -27,6 +27,7 @@ export interface InterruptedRun {
27
27
  }
28
28
  export interface ChatCoreOptions {
29
29
  intelligentUi?: ChatUiHost;
30
+ uiFenceLanguage?: string;
30
31
  providers: ProviderRegistry;
31
32
  store?: ChatStore;
32
33
  checkpoints?: TurnCheckpoints;
@@ -190,6 +191,7 @@ export declare class ChatCore {
190
191
  readonly claudeProjectsDir: string;
191
192
  readonly composerPreferences: ComposerPreferences;
192
193
  private readonly uiQueries;
194
+ private readonly uiFenceLanguage;
193
195
  private readonly checkpoints;
194
196
  private readonly instructions;
195
197
  private readonly skillIndex;
@@ -290,6 +292,7 @@ export declare class ChatCore {
290
292
  conflicted?: boolean | undefined;
291
293
  relativePath?: string | undefined;
292
294
  label?: string | undefined;
295
+ code?: string | undefined;
293
296
  reason?: string | undefined;
294
297
  }>;
295
298
  queryUi(payload: ChatUiQueryPayload, clientId: string): Promise<{
@@ -297,9 +300,10 @@ export declare class ChatCore {
297
300
  value?: unknown;
298
301
  readId?: string | undefined;
299
302
  readAt: number;
303
+ code?: string | undefined;
300
304
  reason?: string | undefined;
301
305
  }>;
302
- choose(payload: ChatUiChoicePayload): Promise<{
306
+ choose(payload: ChatUiChoicePayload, clientId: string): Promise<{
303
307
  queued: boolean;
304
308
  turnId: string;
305
309
  }>;
@@ -1,31 +1,33 @@
1
1
  import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  import { ChatAttachmentUploadsSchema, generatedImageAttachment } from '@adecore/agent-contracts';
4
- import { ChatUiQueries } from './ui-queries.js';
5
4
  import { ClientSinks } from '../client-sinks.js';
6
5
  import { errorText } from '../error-text.js';
7
6
  import { AccountError, definedEnv, isDefaultAccountOf, launchEnv, storedAccount } from '../providers/accounts/launch.js';
8
7
  import { SkillIndex } from '../skills.js';
9
8
  import { usageRoots } from '../usage/roots.js';
9
+ import { commandLabel, isBackgroundWork, runsInBackground } from './background-work.js';
10
10
  import { ChatLog, COMPACT_ABOVE_BYTES } from './chat-log.js';
11
11
  import { ChatSession } from './chat-session.js';
12
12
  import { ComposerPreferences } from './composer-preferences.js';
13
13
  import { DeltaCoalescer } from './delta-coalescer.js';
14
14
  import { ChatError } from './errors.js';
15
- import { commandLabel, isBackgroundWork, runsInBackground } from './background-work.js';
15
+ import { readableAssistantText } from './readable-text.js';
16
16
  import { SubagentReader } from './subagent-reader.js';
17
- /* A message a person or the agent wrote in the chat's own thread; a subagent's words belong to its row. */
17
+ import { ChatUiQueries } from './ui-queries.js';
18
18
  function withoutHidden(info) {
19
19
  const { hidden: _hidden, ...rest } = info;
20
20
  return rest;
21
21
  }
22
+ /* A message a person or the agent wrote in the chat's own thread; a subagent's words belong to its row. */
22
23
  function isBookmarkable(item) {
23
24
  return item.kind === 'user' || (item.kind === 'assistant' && (item.parentToolUseId ?? null) === null);
24
25
  }
25
26
  /* The start of a message on one line; a message of only attachments is its file names. */
26
27
  function excerptOf(item) {
27
28
  const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
28
- return (item.text.trim() === '' ? files : item.text).replace(/\s+/g, ' ').trim();
29
+ const text = item.kind === 'assistant' ? readableAssistantText(item) : item.text;
30
+ return (text.trim() === '' ? files : text).replace(/\s+/g, ' ').trim();
29
31
  }
30
32
  // One process worked on the turn and one more may take it up after a restart; a loop of resumes could redo a command forever.
31
33
  const MAX_ATTEMPTS = 2;
@@ -61,6 +63,7 @@ export class ChatCore {
61
63
  claudeProjectsDir;
62
64
  composerPreferences = new ComposerPreferences();
63
65
  uiQueries;
66
+ uiFenceLanguage;
64
67
  checkpoints;
65
68
  instructions;
66
69
  skillIndex;
@@ -74,7 +77,7 @@ export class ChatCore {
74
77
  // How big each record was the last time it went to disk, and the writes waiting for a big one.
75
78
  sizes = new Map();
76
79
  waiting = new Map();
77
- // What the last record held that no event carries (preambles, the host's extras), as its JSON.
80
+ // What the last record held that no event carries (preambles, the host's extras, captured UI access), as its JSON.
78
81
  unlogged = new Map();
79
82
  // The write in flight per chat, so the next one queues behind it instead of racing it.
80
83
  writes = new Map();
@@ -87,6 +90,7 @@ export class ChatCore {
87
90
  limitResume;
88
91
  constructor(options) {
89
92
  this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
93
+ this.uiFenceLanguage = options.uiFenceLanguage;
90
94
  this.providers = options.providers;
91
95
  this.store = options.store ?? null;
92
96
  this.bookmarks = options.bookmarks ?? null;
@@ -272,9 +276,11 @@ export class ChatCore {
272
276
  const references = this.referencesFor(payload.chatId);
273
277
  const session = new ChatSession({
274
278
  info,
279
+ ...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
275
280
  ...(this.uiQueries ? { ui: { schemas: this.uiQueries.schemas, observe: (item, final) => this.uiQueries.observe(session, item, final) } } : {}),
276
281
  items: stored?.items ?? [],
277
282
  preambles: stored?.preambles ?? [],
283
+ uiAccess: stored?.uiAccess ?? {},
278
284
  provider,
279
285
  command: this.commands[kind] ?? provider.command,
280
286
  ...(this.spawn ? { spawn: this.spawn } : {}),
@@ -573,7 +579,10 @@ export class ChatCore {
573
579
  }
574
580
  return this.uiQueries.query(this.require(payload.chatId), payload);
575
581
  }
576
- async choose(payload) {
582
+ async choose(payload, clientId) {
583
+ if (!this.attached.get(payload.chatId)?.has(clientId)) {
584
+ throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
585
+ }
577
586
  const session = this.require(payload.chatId);
578
587
  const queries = await this.uiQueries?.choiceValues(session, payload);
579
588
  const result = session.choose(payload, queries);
@@ -837,7 +846,7 @@ export class ChatCore {
837
846
  const log = this.logs.get(chatId);
838
847
  // Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
839
848
  try {
840
- this.store.writeSync(chatId, info, items, { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 }, session.preambles, this.recordExtras(chatId));
849
+ this.store.writeSync(chatId, info, items, { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 }, session.preambles, this.recordExtras(chatId), session.uiAccessRecord);
841
850
  }
842
851
  catch (e) {
843
852
  console.error(`Chat record for ${chatId} failed:`, errorText(e));
@@ -970,7 +979,8 @@ export class ChatCore {
970
979
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
971
980
  this.coalescers.get(chatId)?.flush();
972
981
  const extras = this.recordExtras(chatId);
973
- const unlogged = JSON.stringify([session.preambles, extras]);
982
+ const uiAccess = session.uiAccessRecord;
983
+ const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
974
984
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
975
985
  // The log already holds every change to the thread, so the whole record is rewritten only to fold
976
986
  // the log, on a chat's first write, or for what no event carries.
@@ -979,7 +989,7 @@ export class ChatCore {
979
989
  }
980
990
  const { info, items } = session.thread.snapshot();
981
991
  const at = { seq: log.seq, resetSeq: log.resetSeq };
982
- this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras));
992
+ this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
983
993
  this.unlogged.set(chatId, unlogged);
984
994
  // A chat killed while the write was out has no log left to fold.
985
995
  if (this.logs.get(chatId) === log && folds) {
@@ -31,8 +31,8 @@ export function parseLog(raw) {
31
31
  return lines;
32
32
  }
33
33
  /*
34
- * Number and synchronously append each broadcast event so disk, memory and snapshot boundaries keep
35
- * one order. Recent lines stay in memory for immediate `since` attachment.
34
+ * Numbers every broadcast event and appends it synchronously, so disk, memory and snapshot boundaries
35
+ * keep one order. Recent lines stay in memory to answer an attach with `since` at once.
36
36
  */
37
37
  export class ChatLog {
38
38
  path;
@@ -32,6 +32,7 @@ export interface ChatSessionOptions {
32
32
  clock?: OutboxClock;
33
33
  items?: ChatItem[];
34
34
  preambles?: string[];
35
+ uiAccess?: Readonly<Record<string, unknown>>;
35
36
  provider: ChatProvider;
36
37
  command: string[];
37
38
  env(account: string | undefined): Record<string, string>;
@@ -43,6 +44,7 @@ export interface ChatSessionOptions {
43
44
  promptNotes?: PromptNotes;
44
45
  references?: ChatReferences;
45
46
  checkpoints?: TurnCheckpoints;
47
+ uiFenceLanguage?: string;
46
48
  ui?: {
47
49
  schemas: UiCompileOptions['querySchemas'];
48
50
  observe(item: ChatAssistantItem, final: boolean): void;
@@ -105,6 +107,11 @@ export declare class ChatSession {
105
107
  private readonly steering;
106
108
  private readonly uiStreams;
107
109
  private readonly uiCompiled;
110
+ private readonly capturedUiAccess;
111
+ private readonly uiScanned;
112
+ private readonly uiReferencing;
113
+ private readonly uiAttachments;
114
+ private readonly uiFenceLanguage;
108
115
  constructor(options: ChatSessionOptions);
109
116
  get uiPreviews(): ChatEvent[];
110
117
  previewItems(items: ChatItem[]): ChatItem[];
@@ -124,6 +131,9 @@ export declare class ChatSession {
124
131
  queued: boolean;
125
132
  turnId: string;
126
133
  };
134
+ uiAccess(itemId: string): unknown;
135
+ setUiAccess(itemId: string, access: unknown): void;
136
+ get uiAccessRecord(): Record<string, unknown>;
127
137
  updateUiQueries(itemId: string, uiQueries: ChatUiQueryState): void;
128
138
  choose(payload: ChatUiChoicePayload, queries?: Readonly<Record<string, unknown>>): {
129
139
  queued: boolean;
@@ -226,7 +236,10 @@ export declare class ChatSession {
226
236
  private settleFromTranscripts;
227
237
  private settleRows;
228
238
  private emit;
239
+ private unscannedUiText;
240
+ private forgetUiScan;
229
241
  private latestUiAttachment;
242
+ private findUiAttachment;
230
243
  private clearUi;
231
244
  private delegatingChange;
232
245
  }
@@ -1,16 +1,19 @@
1
1
  import { createHash, randomUUID } from 'node:crypto';
2
2
  import { GeneratedImageInputSchema, notResumedNote } from '@adecore/agent-contracts';
3
- import { resolveUiChoice, UiFailure } from '@adecore/intelligent-ui';
3
+ import { resolveUiChoice, UI_FENCE_LANGUAGE, UiFailure, uiHasFence, uiMayReferenceHost } from '@adecore/intelligent-ui';
4
4
  import { UiStream } from '@adecore/intelligent-ui/stream';
5
5
  import { commandLabel, isBackgroundWork, reportsOnBackgroundWork, runningInBackground } from './background-work.js';
6
6
  import { ChatError } from './errors.js';
7
7
  import { limitedTurn, limitResumeAt, limitResumeWake } from './limit-resume.js';
8
8
  import { isMainAgentOutput, ThreadProjector } from './projector.js';
9
+ import { readableAssistantText } from './readable-text.js';
9
10
  import { ChatThread } from './thread.js';
10
11
  import { errorText } from '../error-text.js';
11
12
  import { systemClock } from '../outbox/outbox-worker.js';
12
13
  // Claude Code names a session about six seconds after its first prompt, and a first turn can run for minutes.
13
14
  const TITLE_RECHECK_MS = 10_000;
15
+ // Longer than any fence line or the name of a tag a scan looks for, so a match never falls between two deltas.
16
+ const UI_SCAN_OVERLAP = 64;
14
17
  /* One per limited turn, so a second fork of the same turn writes no second note. */
15
18
  function continuedNoteId(turnId) {
16
19
  return `continued-${turnId}`;
@@ -74,9 +77,17 @@ export class ChatSession {
74
77
  steering = new Set();
75
78
  uiStreams = new Map();
76
79
  uiCompiled = new Map();
80
+ capturedUiAccess;
81
+ // How much of a streaming reply's text the fence and host scans already covered, so a delta scans only its tail.
82
+ uiScanned = new Map();
83
+ uiReferencing = new Set();
84
+ uiAttachments = new Map();
85
+ uiFenceLanguage;
77
86
  constructor(options) {
78
87
  this.options = options;
88
+ this.uiFenceLanguage = options.uiFenceLanguage ?? UI_FENCE_LANGUAGE;
79
89
  this.pendingPreambles = options.preambles ?? [];
90
+ this.capturedUiAccess = new Map(Object.entries(options.uiAccess ?? {}));
80
91
  this.thread = new ChatThread(options.info, options.items);
81
92
  this.projector = new ThreadProjector(this.thread, { providerName: options.provider.name });
82
93
  }
@@ -182,6 +193,21 @@ export class ChatSession {
182
193
  this.dispatch(text, extras, turnId);
183
194
  return { queued: false, turnId };
184
195
  }
196
+ /* What a UI host captured for a reply; null where the capture was denied or is still out, undefined where none started. */
197
+ uiAccess(itemId) {
198
+ return this.capturedUiAccess.get(itemId);
199
+ }
200
+ setUiAccess(itemId, access) {
201
+ if (this.frozen) {
202
+ return;
203
+ }
204
+ this.capturedUiAccess.set(itemId, access);
205
+ this.options.persistSoon();
206
+ }
207
+ /* The captured access of the replies still in the thread, for the record. */
208
+ get uiAccessRecord() {
209
+ return Object.fromEntries([...this.capturedUiAccess].filter(([itemId]) => this.thread.get(itemId) !== undefined));
210
+ }
185
211
  updateUiQueries(itemId, uiQueries) {
186
212
  const item = this.thread.get(itemId);
187
213
  if (this.frozen || item?.kind !== 'assistant' || item.parentToolUseId) {
@@ -1598,7 +1624,7 @@ export class ChatSession {
1598
1624
  this.naming = true;
1599
1625
  const turnId = done[0].id;
1600
1626
  const answer = items
1601
- .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? item.text : ''))
1627
+ .map((item) => (item.kind === 'assistant' && item.turnId === turnId ? readableAssistantText(item) : ''))
1602
1628
  .filter((text) => text !== '')
1603
1629
  .join('\n\n');
1604
1630
  const agentSessionId = this.thread.info.agentSessionId;
@@ -1652,19 +1678,28 @@ export class ChatSession {
1652
1678
  if (event.type === 'reset') {
1653
1679
  this.clearUi();
1654
1680
  }
1681
+ if (event.type === 'item' && event.item.kind === 'tool') {
1682
+ this.uiAttachments.delete(event.item.turnId);
1683
+ }
1655
1684
  const item = event.type === 'item' ? event.item : event.type === 'delta' ? this.thread.get(event.itemId) : null;
1656
1685
  if (item?.kind !== 'assistant' || item.parentToolUseId) {
1657
1686
  this.options.emit(event);
1658
1687
  continue;
1659
1688
  }
1689
+ const tail = this.unscannedUiText(item, event.type === 'item');
1690
+ if (uiMayReferenceHost(tail)) {
1691
+ this.uiReferencing.add(item.id);
1692
+ }
1660
1693
  let stream = this.uiStreams.get(item.id);
1661
- if (!stream && !item.text.includes('ruimte-ui')) {
1694
+ if (!stream && !uiHasFence(tail, this.uiFenceLanguage)) {
1695
+ this.forgetUiScan(item);
1662
1696
  this.options.emit(event);
1663
1697
  continue;
1664
1698
  }
1665
1699
  if (!stream) {
1666
1700
  stream = new UiStream({
1667
1701
  querySchemas: this.options.ui?.schemas,
1702
+ fenceLanguage: this.uiFenceLanguage,
1668
1703
  id: item.id,
1669
1704
  clock: this.options.clock ?? systemClock,
1670
1705
  emit: (preview) => {
@@ -1674,7 +1709,10 @@ export class ChatSession {
1674
1709
  });
1675
1710
  this.uiStreams.set(item.id, stream);
1676
1711
  }
1677
- this.options.ui?.observe(item, false);
1712
+ if (this.uiReferencing.has(item.id)) {
1713
+ this.options.ui?.observe(item, false);
1714
+ }
1715
+ this.forgetUiScan(item);
1678
1716
  const latestAttachment = this.latestUiAttachment(item.turnId);
1679
1717
  if (event.type === 'item' && !item.streaming) {
1680
1718
  const ui = stream
@@ -1684,7 +1722,7 @@ export class ChatSession {
1684
1722
  this.uiCompiled.delete(item.id);
1685
1723
  const diagnostics = [...new Set(ui.flatMap((block) => block.diagnostics.map((diagnostic) => diagnostic.message)))].slice(0, 6);
1686
1724
  if (diagnostics.length > 0) {
1687
- const repair = `Your last ruimte-ui block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
1725
+ const repair = `Your last ${this.uiFenceLanguage} block had these diagnostics: ${diagnostics.join(' ')} Use the catalog's supported props and components.`.slice(0, 1200);
1688
1726
  if (!this.pendingPreambles.includes(repair)) {
1689
1727
  this.pendingPreambles = [...this.pendingPreambles, repair];
1690
1728
  }
@@ -1701,7 +1739,36 @@ export class ChatSession {
1701
1739
  }
1702
1740
  }
1703
1741
  }
1742
+ /*
1743
+ * The text a scan has not covered yet, from the start of the line the last scan ended in, since a fence
1744
+ * is one line; a long line is cut short, behind a character no fence or tag can follow.
1745
+ */
1746
+ unscannedUiText(item, whole) {
1747
+ const scanned = whole ? 0 : (this.uiScanned.get(item.id) ?? 0);
1748
+ this.uiScanned.set(item.id, item.text.length);
1749
+ if (scanned === 0 || scanned > item.text.length) {
1750
+ return item.text;
1751
+ }
1752
+ const line = item.text.lastIndexOf('\n', scanned - 1) + 1;
1753
+ const from = Math.max(line, scanned - UI_SCAN_OVERLAP);
1754
+ return from === line ? item.text.slice(from) : `\0${item.text.slice(from)}`;
1755
+ }
1756
+ forgetUiScan(item) {
1757
+ if (!item.streaming) {
1758
+ this.uiScanned.delete(item.id);
1759
+ this.uiReferencing.delete(item.id);
1760
+ }
1761
+ }
1762
+ /* Looked up once per turn, and again only after one of its tool calls changed. */
1704
1763
  latestUiAttachment(turnId) {
1764
+ if (this.uiAttachments.has(turnId)) {
1765
+ return this.uiAttachments.get(turnId);
1766
+ }
1767
+ const attachment = this.findUiAttachment(turnId);
1768
+ this.uiAttachments.set(turnId, attachment);
1769
+ return attachment;
1770
+ }
1771
+ findUiAttachment(turnId) {
1705
1772
  for (const item of this.thread.list().toReversed()) {
1706
1773
  if (item.kind !== 'tool' || item.turnId !== turnId || item.name !== 'ImageGeneration' || item.state !== 'done') {
1707
1774
  continue;
@@ -1719,6 +1786,9 @@ export class ChatSession {
1719
1786
  }
1720
1787
  this.uiStreams.clear();
1721
1788
  this.uiCompiled.clear();
1789
+ this.uiScanned.clear();
1790
+ this.uiReferencing.clear();
1791
+ this.uiAttachments.clear();
1722
1792
  }
1723
1793
  /* Said on the info as well as by the rows, since a client with the thread closed sees only the info. */
1724
1794
  delegatingChange(events) {
@@ -8,6 +8,7 @@ export interface ChatRecord {
8
8
  seq: number;
9
9
  resetSeq: number;
10
10
  preambles: string[];
11
+ uiAccess: Record<string, unknown>;
11
12
  extras: ChatRecordExtras;
12
13
  lines: ChatLogLine[];
13
14
  }
@@ -28,8 +29,8 @@ export declare class ChatStore {
28
29
  list(): Promise<string[]>;
29
30
  has(chatId: string): Promise<boolean>;
30
31
  discardLog(chatId: string): Promise<number>;
31
- write(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras): Promise<number>;
32
- writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras): void;
32
+ write(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): Promise<number>;
33
+ writeSync(chatId: string, info: ChatInfo, items: ChatItem[], at?: ChatSeq, preambles?: readonly string[], extras?: ChatRecordExtras, uiAccess?: Readonly<Record<string, unknown>>): void;
33
34
  read(chatId: string): Promise<ChatRecord | null>;
34
35
  unreadable(chatId: string): Promise<boolean>;
35
36
  private readSnapshot;
@@ -4,12 +4,12 @@ import { join } from 'node:path';
4
4
  import { ChatInfoSchema, ChatItemSchema } from '@adecore/agent-contracts';
5
5
  import { z } from 'zod';
6
6
  import { isNotFound, writeAtomic, writeAtomicSync } from '../fs.js';
7
- import { isBookmarkFileName } from './bookmark-store.js';
7
+ import { recordFileName } from '../record-directory.js';
8
8
  import { migrateInlineAttachments } from './attachment-store.js';
9
+ import { isBookmarkFileName } from './bookmark-store.js';
9
10
  import { parseLog } from './chat-log.js';
10
11
  import { ChatThread } from './thread.js';
11
12
  import { isVisualFileName } from './visual-store.js';
12
- import { recordFileName } from '../record-directory.js';
13
13
  // zod strips what it does not know inside the info and the items, so a file written by an older build
14
14
  // (with `interactionMode`, say) still parses and loses only the dropped field. A record without `seq`
15
15
  // predates the log. Keys beside these are the host's own, and come back to it as they were written.
@@ -19,20 +19,40 @@ const RecordSchema = z.looseObject({
19
19
  seq: z.number().int().nonnegative().optional(),
20
20
  resetSeq: z.number().int().nonnegative().optional(),
21
21
  // Never on the wire: what goes in front of the next real prompt, once (a fork's note for its agent).
22
- preambles: z.array(z.string()).optional()
22
+ preambles: z.array(z.string()).optional(),
23
+ // Never on the wire: the access a UI host captured for each reply that reads it, by item id.
24
+ uiAccess: z.record(z.string(), z.unknown()).optional()
23
25
  });
24
- const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles']);
25
- function extrasOf(record) {
26
+ const OWN_KEYS = new Set(['info', 'items', 'seq', 'resetSeq', 'preambles', 'uiAccess']);
27
+ function withoutOwnKeys(record) {
26
28
  return Object.fromEntries(Object.entries(record).filter(([key]) => !OWN_KEYS.has(key)));
27
29
  }
28
30
  function logName(chatId) {
29
31
  return `${encodeURIComponent(chatId)}.log`;
30
32
  }
31
- function recordBody(info, items, at, preambles, extras) {
32
- return JSON.stringify({ info, items, ...at, ...(preambles.length === 0 ? {} : { preambles }), ...withoutOwnKeys(extras) });
33
+ function recordBody(info, items, at, preambles, extras, uiAccess) {
34
+ return JSON.stringify({
35
+ info,
36
+ items,
37
+ ...at,
38
+ ...(preambles.length === 0 ? {} : { preambles }),
39
+ ...(Object.keys(uiAccess).length === 0 ? {} : { uiAccess }),
40
+ ...withoutOwnKeys(extras)
41
+ });
33
42
  }
34
- function withoutOwnKeys(extras) {
35
- return Object.fromEntries(Object.entries(extras).filter(([key]) => !OWN_KEYS.has(key)));
43
+ /* A record from before `uiAccess` kept the access inside each reply's `uiQueries`, which the item schema now drops. */
44
+ function liftUiAccess(record) {
45
+ if (typeof record !== 'object' || record === null || 'uiAccess' in record || !Array.isArray(record.items)) {
46
+ return record;
47
+ }
48
+ const uiAccess = {};
49
+ for (const item of record.items) {
50
+ const queries = item?.uiQueries;
51
+ if (typeof queries === 'object' && queries !== null && 'access' in queries) {
52
+ uiAccess[String(item.id)] = queries.access;
53
+ }
54
+ }
55
+ return Object.keys(uiAccess).length === 0 ? record : { ...record, uiAccess };
36
56
  }
37
57
  /*
38
58
  * One JSON file per chat under `<home>/chats`, written while a turn runs and on shutdown, with
@@ -80,8 +100,8 @@ export class ChatStore {
80
100
  return lines.at(-1)?.seq ?? 0;
81
101
  }
82
102
  /* Answers how many bytes the record took, which is what tells a caller whether writing it is cheap. */
83
- async write(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}) {
84
- const body = recordBody(info, items, at, preambles, extras);
103
+ async write(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
104
+ const body = recordBody(info, items, at, preambles, extras, uiAccess);
85
105
  await mkdir(this.dir, { recursive: true, mode: 0o700 });
86
106
  await writeAtomic(join(this.dir, recordFileName(chatId)), body);
87
107
  return body.length;
@@ -90,9 +110,9 @@ export class ChatStore {
90
110
  * The same write without a turn of the event loop. A `bun --watch` reload restarts the module
91
111
  * before an awaited write comes back, so a shutdown has to put the threads down synchronously.
92
112
  */
93
- writeSync(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}) {
113
+ writeSync(chatId, info, items, at = { seq: 0, resetSeq: 0 }, preambles = [], extras = {}, uiAccess = {}) {
94
114
  mkdirSync(this.dir, { recursive: true, mode: 0o700 });
95
- writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras));
115
+ writeAtomicSync(join(this.dir, recordFileName(chatId)), recordBody(info, items, at, preambles, extras, uiAccess));
96
116
  }
97
117
  /* Null for a chat with nothing on disk to make it of, and for one whose record does not parse (see `unreadable`). */
98
118
  async read(chatId) {
@@ -117,7 +137,15 @@ export class ChatStore {
117
137
  resetSeq = line.seq;
118
138
  }
119
139
  }
120
- return { ...thread.snapshot(), seq, resetSeq, preambles: snapshot.preambles ?? [], extras: extrasOf(snapshot), lines };
140
+ return {
141
+ ...thread.snapshot(),
142
+ seq,
143
+ resetSeq,
144
+ preambles: snapshot.preambles ?? [],
145
+ uiAccess: snapshot.uiAccess ?? {},
146
+ extras: withoutOwnKeys(snapshot),
147
+ lines
148
+ };
121
149
  }
122
150
  /*
123
151
  * Whether a record is on disk that this host cannot read: one a newer version wrote, one cut short,
@@ -146,7 +174,7 @@ export class ChatStore {
146
174
  await writeAtomic(join(this.dir, recordFileName(chatId)), JSON.stringify(migrated));
147
175
  }
148
176
  }
149
- const parsed = RecordSchema.safeParse(record);
177
+ const parsed = RecordSchema.safeParse(liftUiAccess(record));
150
178
  return parsed.success ? parsed.data : UNREADABLE;
151
179
  }
152
180
  async delete(chatId) {
@@ -199,5 +227,5 @@ function fromLogAlone(lines) {
199
227
  resetSeq = line.seq;
200
228
  }
201
229
  }
202
- return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], extras: {}, lines };
230
+ return { ...thread.snapshot(), seq: 0, resetSeq, preambles: [], uiAccess: {}, extras: {}, lines };
203
231
  }
@@ -1,5 +1,5 @@
1
- import { cleanTitle } from '../title-file.js';
2
1
  import { runProcess } from '../run-process.js';
2
+ import { cleanTitle } from '../title-file.js';
3
3
  // Enough of a conversation to name it; the rest of a long prompt or answer says nothing a title needs.
4
4
  const MAX_PROMPT_CHARS = 2000;
5
5
  const MAX_ANSWER_CHARS = 1500;
@@ -8,10 +8,7 @@ const TIMEOUT_MS = 45_000;
8
8
  function cap(text, limit) {
9
9
  return text.length <= limit ? text : `${text.slice(0, limit)}...`;
10
10
  }
11
- /*
12
- * What the CLI is asked. JSON again, as for a commit message: a model left to write freely opens
13
- * with a sentence, and that sentence would become the name of the node.
14
- */
11
+ /* JSON, since a model left to write freely opens with a sentence, and that sentence would become the name. */
15
12
  export function buildTitlePrompt(prompt, answer) {
16
13
  return [
17
14
  'Write a short title for the conversation below, as a person would name it in a list of chats.',
@@ -30,9 +27,8 @@ export function buildTitlePrompt(prompt, answer) {
30
27
  ].join('\n');
31
28
  }
32
29
  /*
33
- * The title out of what the CLI printed, or null. Strict on purpose, unlike a commit message: a run
34
- * that holds no `{"title": "..."}` object printed something other than a title, and a derived name
35
- * is better than a stray line of it.
30
+ * Strict on purpose: a run that holds no `{"title": "..."}` object printed something other than a
31
+ * title, and a derived name is better than a stray line of it.
36
32
  */
37
33
  export function parseTitle(output) {
38
34
  const start = [...output.matchAll(/\{\s*"title"/g)].at(-1)?.index;
@@ -27,6 +27,7 @@ export declare class ClaudeBackend implements ChatBackend {
27
27
  declineRequest(requestId: string, message: string): boolean;
28
28
  stop(): void;
29
29
  dispose(): Promise<void>;
30
+ private userMessage;
30
31
  private readLines;
31
32
  private handleLine;
32
33
  private readLine;
@@ -1,5 +1,5 @@
1
- import { errorText } from '../error-text.js';
2
1
  import { randomUUID } from 'node:crypto';
2
+ import { errorText } from '../error-text.js';
3
3
  import { claudeArgs, claudeEnv, promptPrefix } from '../providers/claude.js';
4
4
  import { ChatChild } from './chat-process.js';
5
5
  import { ClaudeProtocol } from './claude-protocol.js';
@@ -59,12 +59,10 @@ export class ClaudeBackend {
59
59
  return Promise.resolve();
60
60
  }
61
61
  sendTurn(input) {
62
- // The prefix is what the CLI must see first (ultrathink), then the note about the links, then what was typed.
63
- const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
64
62
  const promptId = input.promptId ?? randomUUID();
65
63
  this.active = true;
66
64
  this.protocol.beginPrompt(promptId);
67
- this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
65
+ this.write(this.userMessage(input, promptId));
68
66
  }
69
67
  steerTurn(input) {
70
68
  if (!this.child || !this.active || this.stdinClosed) {
@@ -75,8 +73,7 @@ export class ClaudeBackend {
75
73
  this.steers.set(promptId, { resolve, reject });
76
74
  this.protocol.beginSteer(promptId);
77
75
  try {
78
- const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
79
- this.write(buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId }));
76
+ this.write(this.userMessage(input, promptId));
80
77
  }
81
78
  catch (error) {
82
79
  this.steers.delete(promptId);
@@ -126,6 +123,11 @@ export class ClaudeBackend {
126
123
  this.child = null;
127
124
  return child?.end() ?? Promise.resolve();
128
125
  }
126
+ userMessage(input, promptId) {
127
+ // What the CLI must see first (ultrathink), then the preamble, then what was typed.
128
+ const prefix = `${promptPrefix(this.launch.selection)}${input.preamble === null ? '' : `${input.preamble}\n\n`}`;
129
+ return buildUserMessage({ text: input.text, attachments: input.attachments, prefix, skills: input.skills, promptId });
130
+ }
129
131
  async readLines(child) {
130
132
  const reader = child.process.stdout.getReader();
131
133
  const decoder = new TextDecoder();
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { usageRoots } from '../usage/roots.js';
5
4
  import { cleanTitle, readLines } from '../title-file.js';
5
+ import { usageRoots } from '../usage/roots.js';
6
6
  // The two records carry these words; a line without them is never parsed.
7
7
  const MARKERS = ['"ai-title"', '"custom-title"'];
8
8
  /*