@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
@@ -28,6 +28,59 @@ function slotOf(format, timestampMs, resolution) {
28
28
  function emptyTotals() {
29
29
  return { ...EMPTY_TOTALS };
30
30
  }
31
+ function foldBucket(buckets, slot, record, account, price, cost) {
32
+ const key = `${slot}\0${record.provider}\0${record.model}\0${account ?? ''}`;
33
+ let bucket = buckets.get(key);
34
+ if (bucket === undefined) {
35
+ bucket = {
36
+ slot,
37
+ provider: record.provider,
38
+ model: record.model,
39
+ account,
40
+ totals: emptyTotals(),
41
+ costUsd: 0,
42
+ priced: false,
43
+ cacheSavingsUsd: 0,
44
+ sessions: new Set()
45
+ };
46
+ buckets.set(key, bucket);
47
+ }
48
+ bucket.totals = addTotals(bucket.totals, record.totals);
49
+ bucket.costUsd += cost;
50
+ bucket.priced ||= price !== null;
51
+ bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
52
+ if (record.sessionId !== '') {
53
+ bucket.sessions.add(record.sessionId);
54
+ }
55
+ }
56
+ function foldModel(models, record, account, lookup, cost) {
57
+ const key = `${record.provider}\0${record.model}\0${account ?? ''}`;
58
+ let model = models.get(key);
59
+ if (model === undefined) {
60
+ model = {
61
+ provider: record.provider,
62
+ model: record.model,
63
+ account,
64
+ totals: emptyTotals(),
65
+ costUsd: 0,
66
+ priced: false,
67
+ basis: lookup.basis,
68
+ pricedAs: lookup.pricedAs
69
+ };
70
+ models.set(key, model);
71
+ }
72
+ model.totals = addTotals(model.totals, record.totals);
73
+ model.costUsd += cost;
74
+ model.priced ||= lookup.price !== null;
75
+ }
76
+ function foldProject(project, record, cost) {
77
+ project.totals = addTotals(project.totals, record.totals);
78
+ project.costUsd += cost;
79
+ const share = project.byProvider.get(record.provider) ?? { costUsd: 0, tokens: 0 };
80
+ share.costUsd += cost;
81
+ share.tokens += totalTokensOf(record.totals);
82
+ project.byProvider.set(record.provider, share);
83
+ }
31
84
  /* The account a record was made under, the default one under its CLI's kind. */
32
85
  export function accountOfRecord(record) {
33
86
  return record.account ?? record.provider;
@@ -41,8 +94,6 @@ export async function aggregate(records, payload, prices, known) {
41
94
  const format = slotFormatter(payload.timeZone, payload.resolution);
42
95
  const buckets = new Map();
43
96
  const models = new Map();
44
- /* The basis of the first record of a model prices the whole row: a model has one price. */
45
- const modelBasis = new Map();
46
97
  const projects = new Map();
47
98
  const sessions = new Set();
48
99
  const resolver = new ProjectResolver(known);
@@ -59,42 +110,13 @@ export async function aggregate(records, payload, prices, known) {
59
110
  if (day < payload.from || day > payload.to) {
60
111
  continue;
61
112
  }
62
- const { price, basis, pricedAs } = prices.look(record.model);
63
- const cost = price === null ? 0 : costOf(record.totals, price);
64
- const bucketKey = `${slot}\0${record.provider}\0${record.model}\0${account ?? ''}`;
65
- let bucket = buckets.get(bucketKey);
66
- if (bucket === undefined) {
67
- bucket = {
68
- slot,
69
- provider: record.provider,
70
- model: record.model,
71
- account,
72
- totals: emptyTotals(),
73
- costUsd: 0,
74
- priced: false,
75
- cacheSavingsUsd: 0,
76
- sessions: new Set()
77
- };
78
- buckets.set(bucketKey, bucket);
79
- }
80
- bucket.totals = addTotals(bucket.totals, record.totals);
81
- bucket.costUsd += cost;
82
- bucket.priced ||= price !== null;
83
- bucket.cacheSavingsUsd += price === null ? 0 : cacheSavingsOf(record.totals, price);
113
+ const lookup = prices.look(record.model);
114
+ const cost = lookup.price === null ? 0 : costOf(record.totals, lookup.price);
115
+ foldBucket(buckets, slot, record, account, lookup.price, cost);
116
+ foldModel(models, record, account, lookup, cost);
84
117
  if (record.sessionId !== '') {
85
- bucket.sessions.add(record.sessionId);
86
118
  sessions.add(`${record.provider}\0${record.sessionId}`);
87
119
  }
88
- const modelKey = `${record.provider}\0${record.model}\0${account ?? ''}`;
89
- let model = models.get(modelKey);
90
- if (model === undefined) {
91
- model = { provider: record.provider, model: record.model, account, totals: emptyTotals(), costUsd: 0, priced: false };
92
- models.set(modelKey, model);
93
- modelBasis.set(modelKey, { basis, pricedAs });
94
- }
95
- model.totals = addTotals(model.totals, record.totals);
96
- model.costUsd += cost;
97
- model.priced ||= price !== null;
98
120
  let folder = folders.get(record.cwd);
99
121
  if (folder === undefined) {
100
122
  const resolved = await resolver.resolve(record.cwd);
@@ -104,13 +126,7 @@ export async function aggregate(records, payload, prices, known) {
104
126
  projects.set(folder, { folder, name: resolved.name, projectId: resolved.projectId, byProvider: new Map(), totals: emptyTotals(), costUsd: 0 });
105
127
  }
106
128
  }
107
- const project = projects.get(folder);
108
- project.totals = addTotals(project.totals, record.totals);
109
- project.costUsd += cost;
110
- const share = project.byProvider.get(record.provider) ?? { costUsd: 0, tokens: 0 };
111
- share.costUsd += cost;
112
- share.tokens += totalTokensOf(record.totals);
113
- project.byProvider.set(record.provider, share);
129
+ foldProject(projects.get(folder), record, cost);
114
130
  }
115
131
  return {
116
132
  buckets: [...buckets.values()]
@@ -128,15 +144,15 @@ export async function aggregate(records, payload, prices, known) {
128
144
  a.provider.localeCompare(b.provider) ||
129
145
  a.model.localeCompare(b.model) ||
130
146
  (a.account ?? '').localeCompare(b.account ?? '')),
131
- models: [...models.entries()]
132
- .map(([key, model]) => ({
147
+ models: [...models.values()]
148
+ .map((model) => ({
133
149
  provider: model.provider,
134
150
  model: model.model,
135
151
  ...(model.account === null ? {} : { account: model.account }),
136
152
  totals: model.totals,
137
153
  costUsd: model.priced ? model.costUsd : null,
138
- priceBasis: modelBasis.get(key)?.basis ?? 'unknown',
139
- pricedAs: modelBasis.get(key)?.pricedAs ?? null
154
+ priceBasis: model.basis,
155
+ pricedAs: model.pricedAs
140
156
  }))
141
157
  .sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1)),
142
158
  projects: [...projects.values()]
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { isNotFound, writeAtomic } from '../fs.js';
4
3
  import { errorText } from '../error-text.js';
4
+ import { isNotFound, writeAtomic } from '../fs.js';
5
5
  export const FRANKFURTER_URL = 'https://api.frankfurter.app/latest';
6
6
  /* The bank publishes one rate a day, so a rate a day old is the rate. */
7
7
  const TTL_MS = 24 * 60 * 60 * 1000;
@@ -1,8 +1,8 @@
1
1
  import type { UsageLimitsSnapshot, UsageProvider } from '@adecore/agent-contracts';
2
- import type { ProviderRegistry } from '../../providers/registry.ts';
2
+ import type { CodexClientInfo } from '../../chat/codex-transport.ts';
3
3
  import type { AgentSink } from '../../events.ts';
4
+ import type { ProviderRegistry } from '../../providers/registry.ts';
4
5
  import { type LimitsUpdate } from './normalize.ts';
5
- import type { CodexClientInfo } from '../../chat/codex-transport.ts';
6
6
  import { type ProbeResult } from './probe.ts';
7
7
  export type ProbeEnv = Record<string, string>;
8
8
  export interface LimitAccount {
@@ -1,8 +1,8 @@
1
+ import { ClientSinks } from '../../client-sinks.js';
2
+ import { errorText } from '../../error-text.js';
1
3
  import { definedEnv } from '../../providers/accounts/launch.js';
2
4
  import { mergeWindows } from './normalize.js';
3
5
  import { probeClaude, probeCodex } from './probe.js';
4
- import { ClientSinks } from '../../client-sinks.js';
5
- import { errorText } from '../../error-text.js';
6
6
  /* Often enough that a bar is never far behind, rarely enough that two CLIs are not started all day. */
7
7
  const PROBE_INTERVAL_MS = 5 * 60_000;
8
8
  const MAX_BACKOFF_MS = 60 * 60_000;
@@ -1,8 +1,8 @@
1
1
  import { withTimeout } from '../../async.js';
2
2
  import { spawnChatProcess } from '../../chat/chat-process.js';
3
3
  import { CodexTransport, DEFAULT_CODEX_CLIENT } from '../../chat/codex-transport.js';
4
- import { readClaudeUsage, readCodexLimits } from './normalize.js';
5
4
  import { errorText } from '../../error-text.js';
5
+ import { readClaudeUsage, readCodexLimits } from './normalize.js';
6
6
  /* A CLI that has not answered by now is not going to; the next pass tries again. */
7
7
  const PROBE_TIMEOUT_MS = 20_000;
8
8
  const CODEX_READ_TIMEOUT_MS = 3_000;
@@ -1,7 +1,7 @@
1
1
  import { mkdir, readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { isNotFound, writeAtomic } from '../fs.js';
4
3
  import { errorText } from '../error-text.js';
4
+ import { isNotFound, writeAtomic } from '../fs.js';
5
5
  import bundled from './prices-snapshot.json' with { type: 'json' };
6
6
  export const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
7
7
  /* A table a day old still prices everything anyone ran today, and nobody has to be online for it. */
@@ -2,8 +2,7 @@ import { homedir } from 'node:os';
2
2
  import { join } from 'node:path';
3
3
  /* Where in its config folder each CLI leaves its transcripts. */
4
4
  const TRANSCRIPTS = { claude: 'projects', codex: 'sessions' };
5
- /* Where each CLI leaves its transcripts. The same environment variables the hooks installer reads,
6
- so the scanner and the installer never disagree about where a CLI keeps its things. */
5
+ /* The transcripts of the default accounts, found through the variables the CLIs themselves read. */
7
6
  export function usageRoots(env = process.env) {
8
7
  const home = env.HOME ?? homedir();
9
8
  return [
@@ -1,12 +1,12 @@
1
1
  import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { errorText } from '../error-text.js';
3
4
  import { isNotFound, writeAtomic } from '../fs.js';
4
5
  import { decodeIndex, encodeIndex } from './index-file.js';
5
6
  import { foldByKey } from './record.js';
6
7
  import { claudeMightCarryUsage, parseClaudeLine } from './readers/claude.js';
7
8
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
8
9
  import { usageRoots } from './roots.js';
9
- import { errorText } from '../error-text.js';
10
10
  /* What a file holds from `from` to its end, as it is at the read. */
11
11
  async function readFrom(path, from) {
12
12
  const handle = await open(path, 'r');
@@ -53,11 +53,11 @@ async function listJsonl(dir, out = []) {
53
53
  * read again next time. The Codex state is snapshotted before that tail for the same reason.
54
54
  */
55
55
  function parseChunk(text, provider, from, state) {
56
- let records = [];
57
56
  const end = text.lastIndexOf('\n');
58
57
  const whole = end === -1 ? '' : text.slice(0, end);
59
58
  const tail = text.slice(end + 1);
60
59
  const read = (chunk, codex) => {
60
+ const records = [];
61
61
  for (const raw of chunk.split('\n')) {
62
62
  const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
63
63
  if (line === '') {
@@ -79,14 +79,12 @@ function parseChunk(text, provider, from, state) {
79
79
  }
80
80
  }
81
81
  }
82
+ return records;
82
83
  };
83
84
  const codex = provider === 'codex' ? (state ?? createCodexState()) : null;
84
- read(whole, codex);
85
+ const counted = read(whole, codex);
85
86
  const committed = codex === null ? null : cloneCodexState(codex);
86
- const counted = records;
87
- records = [];
88
- read(tail, codex);
89
- return { records: counted, tail: records, offset: from + (end === -1 ? 0 : Buffer.byteLength(whole, 'utf8') + 1), codex: committed };
87
+ return { records: counted, tail: read(tail, codex), offset: from + (end === -1 ? 0 : Buffer.byteLength(whole, 'utf8') + 1), codex: committed };
90
88
  }
91
89
  /*
92
90
  * The account a record of a root belongs to, as a record keeps it (absent for the default account):
@@ -113,8 +111,8 @@ function withAccount(records, accountOf) {
113
111
  });
114
112
  }
115
113
  /*
116
- * Read appended transcript bytes incrementally, but restart after rewrites or truncation. Preserve
117
- * totals for deleted transcripts because their usage still happened.
114
+ * Reads only what a transcript appended since the last scan, and starts a file over once it was
115
+ * rewritten or cut. A deleted transcript keeps its totals, since its usage still happened.
118
116
  */
119
117
  export class UsageScanner {
120
118
  home;
@@ -1,8 +1,8 @@
1
1
  import type { UsageAccount, UsageSummaryPayload, UsageSummaryResult } from '@adecore/agent-contracts';
2
2
  import type { AgentSink } from '../events.ts';
3
3
  import type { KnownProject } from './projects.ts';
4
- import { type UsageScannerOptions } from './scanner.ts';
5
4
  import type { UsageRootPath } from './roots.ts';
5
+ import { type UsageScannerOptions } from './scanner.ts';
6
6
  export interface UsageServiceOptions {
7
7
  home: string;
8
8
  allowPriceFetch?: boolean;
@@ -31,5 +31,4 @@ export declare class UsageService {
31
31
  stop(): void;
32
32
  private rescan;
33
33
  private runScan;
34
- private emit;
35
34
  }
@@ -1,9 +1,9 @@
1
+ import { ClientSinks } from '../client-sinks.js';
2
+ import { errorText } from '../error-text.js';
1
3
  import { accountOfRecord, aggregate } from './aggregate.js';
2
4
  import { ExchangeRates } from './exchange.js';
3
5
  import { PriceBook } from './pricing.js';
4
6
  import { UsageScanner } from './scanner.js';
5
- import { errorText } from '../error-text.js';
6
- import { ClientSinks } from '../client-sinks.js';
7
7
  /* A scan this fresh answers the question the page is asking, so nothing is opened for it. */
8
8
  const SCAN_TTL_MS = 60_000;
9
9
  /* The accounts of the machine, and after them any a record names that the machine no longer has, under its id. */
@@ -121,10 +121,7 @@ export class UsageService {
121
121
  }
122
122
  this.report = report;
123
123
  if (report.changedFiles > 0) {
124
- this.emit({ event: 'usage.changed', payload: { scannedAt: report.at } });
124
+ this.sinks.emit({ event: 'usage.changed', payload: { scannedAt: report.at } });
125
125
  }
126
126
  }
127
- emit(event) {
128
- this.sinks.emit(event);
129
- }
130
127
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.21.0",
3
+ "version": "0.21.2",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -20,8 +20,8 @@
20
20
  "test": "bun --conditions=source test"
21
21
  },
22
22
  "dependencies": {
23
- "@adecore/agent-contracts": "0.21.0",
24
- "@adecore/intelligent-ui": "0.21.0",
23
+ "@adecore/agent-contracts": "0.21.2",
24
+ "@adecore/intelligent-ui": "0.21.2",
25
25
  "zod": "^4.6.5"
26
26
  },
27
27
  "devDependencies": {
package/src/async.ts CHANGED
@@ -1,9 +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
-
3
1
  /*
4
- * Resolves after `ms`, or at once when the signal is raised. An abort ends the wait rather than
5
- * failing it: every caller here is a loop that checks the signal itself on the next turn, and a
6
- * 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.
7
4
  */
8
5
  export function wait(ms: number, signal?: AbortSignal): Promise<void> {
9
6
  return new Promise((resolve) => {
@@ -19,10 +16,7 @@ export function wait(ms: number, signal?: AbortSignal): Promise<void> {
19
16
  });
20
17
  }
21
18
 
22
- /*
23
- * The promise, or `message` when it takes longer than `ms`. The timer is cleared either way, so a
24
- * call that answers at once does not hold the process for the rest of its deadline.
25
- */
19
+ /* The timer is cleared either way, so a call that answers at once does not hold the process until its deadline. */
26
20
  export function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
27
21
  return new Promise((resolve, reject) => {
28
22
  const timer = setTimeout(() => reject(new Error(message)), ms);
@@ -27,7 +27,6 @@ import {
27
27
  type ModelSelection,
28
28
  type RuntimeMode
29
29
  } from '@adecore/agent-contracts';
30
- import { ChatUiQueries, type ChatUiHost } from './ui-queries.ts';
31
30
  import { ClientSinks } from '../client-sinks.ts';
32
31
  import { errorText } from '../error-text.ts';
33
32
  import type { AgentEvent, AgentSink } from '../events.ts';
@@ -38,6 +37,7 @@ import { SkillIndex } from '../skills.ts';
38
37
  import type { LimitsUpdate } from '../usage/limits/normalize.ts';
39
38
  import { usageRoots } from '../usage/roots.ts';
40
39
  import type { AttachmentStore } from './attachment-store.ts';
40
+ import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
41
41
  import type { BookmarkStore } from './bookmark-store.ts';
42
42
  import { ChatLog, COMPACT_ABOVE_BYTES } from './chat-log.ts';
43
43
  import type { SpawnChatProcess } from './chat-process.ts';
@@ -58,8 +58,9 @@ import type { CodexClientInfo } from './codex-transport.ts';
58
58
  import { ComposerPreferences } from './composer-preferences.ts';
59
59
  import { DeltaCoalescer } from './delta-coalescer.ts';
60
60
  import { ChatError } from './errors.ts';
61
- import { commandLabel, isBackgroundWork, runsInBackground, type BackgroundWork } from './background-work.ts';
61
+ import { readableAssistantText } from './readable-text.ts';
62
62
  import { SubagentReader, type SubagentReaderOptions } from './subagent-reader.ts';
63
+ import { ChatUiQueries, type ChatUiHost } from './ui-queries.ts';
63
64
  import type { VisualInput, VisualStore } from './visual-store.ts';
64
65
 
65
66
  /* A turn that was running when the host went down, and the attempt that would take it up again. */
@@ -71,12 +72,12 @@ export interface InterruptedRun {
71
72
 
72
73
  type BookmarkableItem = Extract<ChatItem, { kind: 'user' | 'assistant' }>;
73
74
 
74
- /* A message a person or the agent wrote in the chat's own thread; a subagent's words belong to its row. */
75
75
  function withoutHidden(info: ChatInfo): ChatInfo {
76
76
  const { hidden: _hidden, ...rest } = info;
77
77
  return rest;
78
78
  }
79
79
 
80
+ /* A message a person or the agent wrote in the chat's own thread; a subagent's words belong to its row. */
80
81
  function isBookmarkable(item: ChatItem): item is BookmarkableItem {
81
82
  return item.kind === 'user' || (item.kind === 'assistant' && (item.parentToolUseId ?? null) === null);
82
83
  }
@@ -84,7 +85,8 @@ function isBookmarkable(item: ChatItem): item is BookmarkableItem {
84
85
  /* The start of a message on one line; a message of only attachments is its file names. */
85
86
  function excerptOf(item: BookmarkableItem): string {
86
87
  const files = item.kind === 'user' ? (item.attachments ?? []).map((attachment) => attachment.name).join(', ') : '';
87
- return (item.text.trim() === '' ? files : item.text).replace(/\s+/g, ' ').trim();
88
+ const text = item.kind === 'assistant' ? readableAssistantText(item) : item.text;
89
+ return (text.trim() === '' ? files : text).replace(/\s+/g, ' ').trim();
88
90
  }
89
91
 
90
92
  // 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.
@@ -102,6 +104,8 @@ const DEFAULT_RESUME_WORDS: ResumeWords = {
102
104
 
103
105
  export interface ChatCoreOptions {
104
106
  intelligentUi?: ChatUiHost;
107
+ // The info string that marks a UI fence in a reply, for a host that keeps a name of its own.
108
+ uiFenceLanguage?: string;
105
109
  providers: ProviderRegistry;
106
110
  // Where the threads are kept; without it a chat lives as long as the process.
107
111
  store?: ChatStore;
@@ -175,6 +179,7 @@ export class ChatCore {
175
179
  readonly claudeProjectsDir: string;
176
180
  readonly composerPreferences = new ComposerPreferences();
177
181
  private readonly uiQueries: ChatUiQueries | null;
182
+ private readonly uiFenceLanguage: string | undefined;
178
183
  private readonly checkpoints: TurnCheckpoints | null;
179
184
  private readonly instructions: string | null;
180
185
  private readonly skillIndex: SkillIndex;
@@ -188,7 +193,7 @@ export class ChatCore {
188
193
  // How big each record was the last time it went to disk, and the writes waiting for a big one.
189
194
  private readonly sizes = new Map<string, number>();
190
195
  private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
191
- // What the last record held that no event carries (preambles, the host's extras), as its JSON.
196
+ // What the last record held that no event carries (preambles, the host's extras, captured UI access), as its JSON.
192
197
  private readonly unlogged = new Map<string, string>();
193
198
  // The write in flight per chat, so the next one queues behind it instead of racing it.
194
199
  private readonly writes = new Map<string, Promise<void>>();
@@ -202,6 +207,7 @@ export class ChatCore {
202
207
 
203
208
  constructor(options: ChatCoreOptions) {
204
209
  this.uiQueries = options.intelligentUi ? new ChatUiQueries(options.intelligentUi) : null;
210
+ this.uiFenceLanguage = options.uiFenceLanguage;
205
211
  this.providers = options.providers;
206
212
  this.store = options.store ?? null;
207
213
  this.bookmarks = options.bookmarks ?? null;
@@ -398,9 +404,11 @@ export class ChatCore {
398
404
  const references = this.referencesFor(payload.chatId);
399
405
  const session = new ChatSession({
400
406
  info,
407
+ ...(this.uiFenceLanguage ? { uiFenceLanguage: this.uiFenceLanguage } : {}),
401
408
  ...(this.uiQueries ? { ui: { schemas: this.uiQueries.schemas, observe: (item, final) => this.uiQueries!.observe(session, item, final) } } : {}),
402
409
  items: stored?.items ?? [],
403
410
  preambles: stored?.preambles ?? [],
411
+ uiAccess: stored?.uiAccess ?? {},
404
412
  provider,
405
413
  command: this.commands[kind] ?? provider.command,
406
414
  ...(this.spawn ? { spawn: this.spawn } : {}),
@@ -735,7 +743,10 @@ export class ChatCore {
735
743
  return this.uiQueries.query(this.require(payload.chatId), payload);
736
744
  }
737
745
 
738
- async choose(payload: ChatUiChoicePayload): Promise<{ queued: boolean; turnId: string }> {
746
+ async choose(payload: ChatUiChoicePayload, clientId: string): Promise<{ queued: boolean; turnId: string }> {
747
+ if (!this.attached.get(payload.chatId)?.has(clientId)) {
748
+ throw new ChatError('refused-query', 'Attach to the chat before answering its UI.');
749
+ }
739
750
  const session = this.require(payload.chatId);
740
751
  const queries = await this.uiQueries?.choiceValues(session, payload);
741
752
  const result = session.choose(payload, queries);
@@ -1022,7 +1033,15 @@ export class ChatCore {
1022
1033
  const log = this.logs.get(chatId);
1023
1034
  // Not folded here: an older write still in flight may land after this one, and the log is what covers for it.
1024
1035
  try {
1025
- this.store.writeSync(chatId, info, items, { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 }, session.preambles, this.recordExtras(chatId));
1036
+ this.store.writeSync(
1037
+ chatId,
1038
+ info,
1039
+ items,
1040
+ { seq: log?.seq ?? 0, resetSeq: log?.resetSeq ?? 0 },
1041
+ session.preambles,
1042
+ this.recordExtras(chatId),
1043
+ session.uiAccessRecord
1044
+ );
1026
1045
  } catch (e) {
1027
1046
  console.error(`Chat record for ${chatId} failed:`, errorText(e));
1028
1047
  }
@@ -1181,7 +1200,8 @@ export class ChatCore {
1181
1200
  // A delta held back is already in the thread, so it gets its seq before the snapshot says where it ends.
1182
1201
  this.coalescers.get(chatId)?.flush();
1183
1202
  const extras = this.recordExtras(chatId);
1184
- const unlogged = JSON.stringify([session.preambles, extras]);
1203
+ const uiAccess = session.uiAccessRecord;
1204
+ const unlogged = JSON.stringify([session.preambles, extras, uiAccess]);
1185
1205
  const folds = fold || log.size > COMPACT_ABOVE_BYTES || !log.onDisk;
1186
1206
  // The log already holds every change to the thread, so the whole record is rewritten only to fold
1187
1207
  // the log, on a chat's first write, or for what no event carries.
@@ -1190,7 +1210,7 @@ export class ChatCore {
1190
1210
  }
1191
1211
  const { info, items } = session.thread.snapshot();
1192
1212
  const at = { seq: log.seq, resetSeq: log.resetSeq };
1193
- this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras));
1213
+ this.sizes.set(chatId, await this.store.write(chatId, info, items, at, session.preambles, extras, uiAccess));
1194
1214
  this.unlogged.set(chatId, unlogged);
1195
1215
  // A chat killed while the write was out has no log left to fold.
1196
1216
  if (this.logs.get(chatId) === log && folds) {
@@ -48,8 +48,8 @@ export interface ChatLogState {
48
48
  }
49
49
 
50
50
  /*
51
- * Number and synchronously append each broadcast event so disk, memory and snapshot boundaries keep
52
- * one order. Recent lines stay in memory for immediate `since` attachment.
51
+ * Numbers every broadcast event and appends it synchronously, so disk, memory and snapshot boundaries
52
+ * keep one order. Recent lines stay in memory to answer an attach with `since` at once.
53
53
  */
54
54
  export class ChatLog {
55
55
  private readonly path: string | null;