@adecore/agents 0.31.8 → 0.31.10

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.
@@ -1,7 +1,8 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
- import { zstdDecompressSync } from 'node:zlib';
4
+ import { Readable } from 'node:stream';
5
+ import { createZstdDecompress } from 'node:zlib';
5
6
  import { errorText } from '../error-text.js';
6
7
  import { isNotFound, writeAtomic } from '../fs.js';
7
8
  import { decodeIndex, encodeIndex } from './index-file.js';
@@ -12,41 +13,58 @@ import { hostLogMightCarryUsage, parseHostUsageLine } from './readers/host-log.j
12
13
  import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine } from './readers/codex.js';
13
14
  import { usageRoots } from './roots.js';
14
15
  const HASH_CHUNK_BYTES = 65536;
16
+ // How much of a transcript is read at once. Codex writes sessions of more than a gigabyte, which read whole held several times their size.
17
+ const READ_CHUNK_BYTES = 1 << 20;
15
18
  const CODEX_PARSER_VERSION = 2;
16
- /*
17
- * Hashes the bytes before `from` and returns the rest, in one pass over one handle, so verifying
18
- * what a transcript committed and parsing what it added never reads a byte twice.
19
- */
20
- async function readHashed(path, from) {
19
+ const NEWLINE = 0x0a;
20
+ /* Hashes the first `length` bytes of a file into `hash`, a chunk at a time. */
21
+ async function hashPrefix(path, length, hash) {
21
22
  const handle = await open(path, 'r');
22
23
  try {
23
- const hash = createHash('sha256');
24
- const chunk = Buffer.alloc(Math.min(from, HASH_CHUNK_BYTES));
24
+ const chunk = Buffer.alloc(Math.min(length, HASH_CHUNK_BYTES));
25
25
  let offset = 0;
26
- while (offset < from) {
27
- const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, from - offset), offset);
26
+ while (offset < length) {
27
+ const { bytesRead } = await handle.read(chunk, 0, Math.min(chunk.length, length - offset), offset);
28
28
  if (bytesRead === 0) {
29
29
  break;
30
30
  }
31
31
  hash.update(chunk.subarray(0, bytesRead));
32
32
  offset += bytesRead;
33
33
  }
34
- const { size } = await handle.stat();
35
- const bytes = Buffer.alloc(Math.max(0, size - from));
36
- let filled = 0;
37
- while (filled < bytes.length) {
38
- const { bytesRead } = await handle.read(bytes, filled, bytes.length - filled, from + filled);
39
- if (bytesRead === 0) {
40
- break;
41
- }
42
- filled += bytesRead;
43
- }
44
- return { bytes: bytes.subarray(0, filled), hash };
34
+ return hash;
45
35
  }
46
36
  finally {
47
37
  await handle.close();
48
38
  }
49
39
  }
40
+ /*
41
+ * Hands every whole line of a byte stream to `onLine` and hashes it with its newline, holding no more
42
+ * than a chunk and the line it is in. Lines are split on the byte, which never occurs inside a UTF-8 sequence.
43
+ */
44
+ export async function forEachLine(chunks, hash, onLine) {
45
+ let consumed = 0;
46
+ let pending = [];
47
+ for await (const piece of chunks) {
48
+ const chunk = Buffer.from(piece.buffer, piece.byteOffset, piece.byteLength);
49
+ let start = 0;
50
+ let newline = chunk.indexOf(NEWLINE, start);
51
+ while (newline !== -1) {
52
+ const end = newline + 1;
53
+ const line = pending.length === 0 ? chunk.subarray(start, end) : Buffer.concat([...pending, chunk.subarray(start, end)]);
54
+ pending = [];
55
+ hash.update(line);
56
+ consumed += line.length;
57
+ onLine(line.toString('utf8', 0, line.length - 1));
58
+ start = end;
59
+ newline = chunk.indexOf(NEWLINE, start);
60
+ }
61
+ if (start < chunk.length) {
62
+ // A copy: the stream may hand the same memory out again for its next chunk.
63
+ pending.push(Buffer.from(chunk.subarray(start)));
64
+ }
65
+ }
66
+ return { consumed, rest: Buffer.concat(pending).toString('utf8') };
67
+ }
50
68
  /* Covers every committed byte, since a transcript can be rewritten after an unchanged session header. */
51
69
  function headOf(hash) {
52
70
  // Older indexes hashed only the first 4 KB; they cannot establish that the rest stayed unchanged.
@@ -79,54 +97,68 @@ async function listUsageFiles(dir, provider, out = []) {
79
97
  }
80
98
  return out;
81
99
  }
100
+ /* The bytes of a file from `from` to its end, a chunk at a time. */
101
+ async function* fileChunks(path, from) {
102
+ const handle = await open(path, 'r');
103
+ try {
104
+ let position = from;
105
+ for (;;) {
106
+ // A buffer per chunk: a decompressor may still hold the one before.
107
+ const chunk = Buffer.alloc(READ_CHUNK_BYTES);
108
+ const { bytesRead } = await handle.read(chunk, 0, chunk.length, position);
109
+ if (bytesRead === 0) {
110
+ return;
111
+ }
112
+ position += bytesRead;
113
+ yield chunk.subarray(0, bytesRead);
114
+ }
115
+ }
116
+ finally {
117
+ await handle.close();
118
+ }
119
+ }
120
+ /* The bytes of a file from `from` on, decompressed for a `.zst` one; a failure on either side ends the iteration with it. */
121
+ function readBytes(path, from, compressed) {
122
+ if (!compressed) {
123
+ return fileChunks(path, from);
124
+ }
125
+ const source = Readable.from(fileChunks(path, from));
126
+ const decompressed = createZstdDecompress();
127
+ source.on('error', (error) => decompressed.destroy(error));
128
+ return source.pipe(decompressed);
129
+ }
130
+ /* The usage record of one transcript line, when it carries one. */
131
+ function parseLine(raw, provider, codex) {
132
+ const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
133
+ if (line === '') {
134
+ return null;
135
+ }
136
+ if (provider === 'claude') {
137
+ return claudeMightCarryUsage(line) ? parseClaudeLine(line) : null;
138
+ }
139
+ if (provider === 'codex') {
140
+ return codex !== null && codexMightCarryUsage(line) ? parseCodexLine(line, codex) : null;
141
+ }
142
+ // Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
143
+ return hostLogMightCarryUsage(line) ? parseHostUsageLine(line, provider) : null;
144
+ }
82
145
  /*
83
146
  * Reads what a file added since the last scan. Only whole lines count towards the offset; the piece
84
147
  * after the last newline is a line still being written, so it is parsed for what it is worth and
85
148
  * read again next time. The Codex state is snapshotted before that tail for the same reason.
86
149
  */
87
- function parseChunk(text, provider, from, state) {
88
- const end = text.lastIndexOf('\n');
89
- const whole = end === -1 ? '' : text.slice(0, end);
90
- const tail = text.slice(end + 1);
91
- const read = (chunk, codex) => {
92
- const records = [];
93
- for (const raw of chunk.split('\n')) {
94
- const line = raw.endsWith('\r') ? raw.slice(0, -1) : raw;
95
- if (line === '') {
96
- continue;
97
- }
98
- if (provider === 'claude') {
99
- if (claudeMightCarryUsage(line)) {
100
- const record = parseClaudeLine(line);
101
- if (record !== null) {
102
- records.push(record);
103
- }
104
- }
105
- continue;
106
- }
107
- if (provider === 'codex') {
108
- if (codex !== null && codexMightCarryUsage(line)) {
109
- const record = parseCodexLine(line, codex);
110
- if (record !== null) {
111
- records.push(record);
112
- }
113
- }
114
- continue;
115
- }
116
- // Claude Code and Codex write transcripts of their own, Grok a file read whole; every other provider is a log its host keeps.
117
- if (hostLogMightCarryUsage(line)) {
118
- const record = parseHostUsageLine(line, provider);
119
- if (record !== null) {
120
- records.push(record);
121
- }
122
- }
123
- }
124
- return records;
125
- };
150
+ async function parseLines(chunks, hash, provider, from, state) {
126
151
  const codex = provider === 'codex' ? (state ?? createCodexState()) : null;
127
- const counted = read(whole, codex);
152
+ const records = [];
153
+ const read = await forEachLine(chunks, hash, (line) => {
154
+ const record = parseLine(line, provider, codex);
155
+ if (record !== null) {
156
+ records.push(record);
157
+ }
158
+ });
128
159
  const committed = codex === null ? null : cloneCodexState(codex);
129
- return { records: counted, tail: read(tail, codex), offset: from + (end === -1 ? 0 : Buffer.byteLength(whole, 'utf8') + 1), codex: committed };
160
+ const tail = parseLine(read.rest, provider, codex);
161
+ return { records, tail: tail === null ? [] : [tail], offset: from + read.consumed, codex: committed };
130
162
  }
131
163
  /*
132
164
  * The account a record of a root belongs to, as a record keeps it (absent for the default account):
@@ -275,18 +307,13 @@ export class UsageScanner {
275
307
  known.provider === provider &&
276
308
  info.size > known.size &&
277
309
  known.head?.startsWith('sha256:') === true;
278
- let read = compressed
279
- ? { bytes: zstdDecompressSync(await readFile(path)), hash: createHash('sha256') }
280
- : await readHashed(path, appended ? known.offset : 0);
281
310
  // A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
282
- const grown = appended && headOf(read.hash) === known.head;
283
- if (appended && !grown) {
284
- read = await readHashed(path, 0);
285
- }
286
- const from = grown ? known.offset : 0;
287
- const parsed = parseChunk(read.bytes.toString('utf8'), provider, from, grown ? known.codex : null);
288
- read.hash.update(read.bytes.subarray(0, parsed.offset - from));
289
- const before = grown ? known.records : [];
311
+ const prefix = appended ? await hashPrefix(path, known.offset, createHash('sha256')) : null;
312
+ const grown = prefix !== null && known !== undefined && headOf(prefix) === known.head ? known : null;
313
+ const hash = grown === null || prefix === null ? createHash('sha256') : prefix;
314
+ const from = grown?.offset ?? 0;
315
+ const parsed = await parseLines(readBytes(path, from, compressed), hash, provider, from, grown?.codex ?? null);
316
+ const before = grown?.records ?? [];
290
317
  const entry = {
291
318
  provider,
292
319
  size: info.size,
@@ -296,7 +323,7 @@ export class UsageScanner {
296
323
  records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
297
324
  tail: withAccount(parsed.tail, accountOf),
298
325
  codex: parsed.codex,
299
- head: headOf(read.hash),
326
+ head: headOf(hash),
300
327
  ...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
301
328
  };
302
329
  this.index.set(key, entry);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.8",
3
+ "version": "0.31.10",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.31.8",
85
- "@adecore/intelligent-ui": "0.31.8",
84
+ "@adecore/agent-contracts": "0.31.10",
85
+ "@adecore/intelligent-ui": "0.31.10",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -63,6 +63,9 @@ export interface AcpAgentSpec {
63
63
  onContextReport?(): void;
64
64
  // The slash commands a session offers (without the slash), each time the agent lists them.
65
65
  onCommands?(commands: string[]): void;
66
+ // True takes the session titles the agent sends (`session_info_update`) as the chat's suggested title. Absent leaves
67
+ // naming the chat to the host, since an agent may rename the session after every turn.
68
+ adoptTitles?: boolean;
66
69
  // A permission request the host answers itself, in any runtime mode: true allows it once without asking the
67
70
  // person, for a call the host already guards. A throw counts as false.
68
71
  autoAllow?(request: AcpPermissionRequest): boolean;
@@ -121,7 +124,7 @@ export class AcpBackend implements ChatBackend {
121
124
  this.host = host;
122
125
  this.spec = spec;
123
126
  this.client = spec.client ?? DEFAULT_ACP_CLIENT;
124
- this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name));
127
+ this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true);
125
128
  }
126
129
 
127
130
  get running(): boolean {
@@ -420,7 +423,7 @@ export class AcpBackend implements ChatBackend {
420
423
  if (this.transport === transport && this.prompt === turn) {
421
424
  this.prompt = null;
422
425
  this.report(result);
423
- this.emitAll(this.protocol.finish(turn.cancelled ? { stopReason: 'cancelled' } : result, turn.promptId));
426
+ this.emitAll(this.protocol.finish(result, turn.promptId, turn.cancelled));
424
427
  }
425
428
  })
426
429
  .catch((error: unknown) => {
@@ -469,7 +472,11 @@ export class AcpBackend implements ChatBackend {
469
472
  if (!answer) {
470
473
  return false;
471
474
  }
472
- return this.respond(requestId, answer.rpcId, answer.result);
475
+ if (!this.respond(requestId, answer.rpcId, answer.result)) {
476
+ return false;
477
+ }
478
+ this.emitAll(answer.events);
479
+ return true;
473
480
  }
474
481
 
475
482
  /* ACP asks no questions of its own; an agent asks in its message and the next prompt answers. */
@@ -133,6 +133,15 @@ function contextWindowOf(meta: unknown): number | null {
133
133
  return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
134
134
  }
135
135
 
136
+ /*
137
+ * The context tokens an agent counts outside the protocol, in `_meta.totalTokens`: Grok CLI 1.0.62 sends no `usage_update`
138
+ * and puts the session's count there, on every `session/update` notification and on the `session/prompt` response.
139
+ */
140
+ function vendorContextTokens(meta: unknown): number | null {
141
+ const tokens = isRecord(meta) ? meta.totalTokens : undefined;
142
+ return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
143
+ }
144
+
136
145
  const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
137
146
 
138
147
  const InitializeResponseSchema = z.looseObject({
@@ -296,6 +305,9 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
296
305
  const thought = thoughtOption(options);
297
306
  const modeState = ModeStateSchema.safeParse(modes);
298
307
  const modelState = ModelStateSchema.safeParse(models);
308
+ const entries = modelState.success ? validItems(ModelEntrySchema, modelState.data.availableModels) : [];
309
+ // Grok CLI 1.0.62 offers its models as a config option too, but names their windows only in the older field.
310
+ const windows = new Map(entries.map((model) => [model.modelId, contextWindowOf(model._meta)]));
299
311
  return {
300
312
  models: modelOption
301
313
  ? {
@@ -304,14 +316,14 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
304
316
  id: value.value,
305
317
  name: value.name,
306
318
  description: null,
307
- contextWindow: contextWindowOf(value._meta)
319
+ contextWindow: contextWindowOf(value._meta) ?? windows.get(value.value) ?? null
308
320
  })),
309
321
  configId: modelOption.id
310
322
  }
311
323
  : modelState.success
312
324
  ? {
313
325
  current: modelState.data.currentModelId,
314
- models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
326
+ models: entries.map((model) => ({
315
327
  id: model.modelId,
316
328
  name: model.name,
317
329
  description: model.description ?? null,
@@ -452,6 +464,8 @@ interface ToolState {
452
464
  // What the thread last heard, so an update that changes nothing about the row sends nothing.
453
465
  shown: string | null;
454
466
  settled: boolean;
467
+ // A person turned the call down, so whatever diff it carries was never written.
468
+ refused: boolean;
455
469
  }
456
470
 
457
471
  // `Edit` would have the thread draw a diff of nothing until the agent sends one.
@@ -467,6 +481,9 @@ const KIND_NAMES: Record<string, string> = {
467
481
  // A session's title comes from its opening prompts; later ones are not kept.
468
482
  const KEPT_PROMPTS = 3;
469
483
 
484
+ // What Copilot CLI 1.0.101 writes as a message of its own after a call a person turned down; the thread shows the refusal already.
485
+ const REFUSAL_LINE = 'Info: Operation cancelled by user';
486
+
470
487
  function foldSpace(text: string): string {
471
488
  return text.replace(/\s+/g, ' ').trim();
472
489
  }
@@ -551,7 +568,7 @@ function toolOutput(tool: ToolState): string | null {
551
568
  return tool.rawOutput;
552
569
  }
553
570
  if (isRecord(tool.rawOutput)) {
554
- const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error');
571
+ const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error', 'message');
555
572
  return text ?? JSON.stringify(tool.rawOutput);
556
573
  }
557
574
  return null;
@@ -601,18 +618,28 @@ export class AcpProtocol {
601
618
  private readonly modes: AcpModeMap;
602
619
  private readonly words: AcpStopWords;
603
620
  private readonly tools = new Map<string, ToolState>();
604
- private readonly permissions = new Map<string, { rpcId: number | string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
605
- private text: { ref: string; messageId: string | null; text: string } | null = null;
621
+ private readonly permissions = new Map<string, { rpcId: number | string; toolCallId: string; options: Array<z.infer<typeof PermissionOptionSchema>> }>();
622
+ // `held` while the message may still be the refusal line, whose chunks the thread has not heard yet.
623
+ private text: { ref: string; messageId: string | null; text: string; held: boolean } | null = null;
624
+ // Set by a refusal until the next message shows whether it is the refusal line.
625
+ private refusalLine = false;
606
626
  private thought: { ref: string; text: string } | null = null;
607
627
  private counter = 0;
608
628
  private costUsd = 0;
629
+ // Set by the first `usage_update`; from then on a count outside the protocol is never read.
630
+ private standardUsage = false;
631
+ private vendorTokens: number | null = null;
609
632
  // The opening prompts of the process, whitespace folded, which an agent may hand back as the session's title.
610
633
  private readonly prompts: string[] = [];
611
634
 
612
- constructor(generation: number, modes: AcpModeMap, words: AcpStopWords) {
635
+ private readonly titles: boolean;
636
+
637
+ // `titles` takes the session titles the agent sends as the chat's suggested title.
638
+ constructor(generation: number, modes: AcpModeMap, words: AcpStopWords, titles = false) {
613
639
  this.generation = generation;
614
640
  this.modes = modes;
615
641
  this.words = words;
642
+ this.titles = titles;
616
643
  }
617
644
 
618
645
  /* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
@@ -621,7 +648,10 @@ export class AcpProtocol {
621
648
  if (!notification.success || notification.data.sessionId !== sessionId) {
622
649
  return [];
623
650
  }
624
- const update = notification.data.update;
651
+ return [...this.mapUpdate(notification.data.update), ...this.vendorUsage(notification.data._meta)];
652
+ }
653
+
654
+ private mapUpdate(update: z.infer<typeof SessionNotificationSchema>['update']): BackendEvent[] {
625
655
  switch (update.sessionUpdate) {
626
656
  case 'agent_message_chunk':
627
657
  return this.messageChunk(update);
@@ -651,6 +681,7 @@ export class AcpProtocol {
651
681
  if (!usage.success) {
652
682
  return [];
653
683
  }
684
+ this.standardUsage = true;
654
685
  const cost = usage.data.cost?.currency === 'USD' ? usage.data.cost.amount : undefined;
655
686
  if (cost !== undefined) {
656
687
  this.costUsd = cost;
@@ -658,6 +689,9 @@ export class AcpProtocol {
658
689
  return [{ type: 'usage', contextTokens: usage.data.used, contextWindow: usage.data.size, ...(cost === undefined ? {} : { costUsd: cost }) }];
659
690
  }
660
691
  case 'session_info_update': {
692
+ if (!this.titles) {
693
+ return [];
694
+ }
661
695
  const title = cleanTitle(update.title);
662
696
  return title === null || this.restatesPrompt(title) ? [] : [{ type: 'title', title }];
663
697
  }
@@ -666,6 +700,16 @@ export class AcpProtocol {
666
700
  }
667
701
  }
668
702
 
703
+ /* The fallback for an agent that sends no `usage_update`: its own count of the context, once each time it changes. */
704
+ private vendorUsage(meta: unknown): BackendEvent[] {
705
+ const tokens = this.standardUsage ? null : vendorContextTokens(meta);
706
+ if (tokens === null || tokens === this.vendorTokens) {
707
+ return [];
708
+ }
709
+ this.vendorTokens = tokens;
710
+ return [{ type: 'usage', contextTokens: tokens }];
711
+ }
712
+
669
713
  /* Notes a prompt sent to the agent, so a title that only repeats its start is not taken for a name. */
670
714
  prompted(text: string): void {
671
715
  if (this.prompts.length < KEPT_PROMPTS) {
@@ -698,7 +742,7 @@ export class AcpProtocol {
698
742
  const tool = this.tools.get(request.data.toolCall.toolCallId)!;
699
743
  const row = toolRow(tool);
700
744
  const options = request.data.options;
701
- this.permissions.set(requestId, { rpcId, options });
745
+ this.permissions.set(requestId, { rpcId, toolCallId: request.data.toolCall.toolCallId, options });
702
746
  events.push({
703
747
  type: 'approval.requested',
704
748
  requestId,
@@ -716,9 +760,9 @@ export class AcpProtocol {
716
760
  /*
717
761
  * The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
718
762
  * A decision the agent offered no option for falls to the narrower one, and a refusal with no reject option
719
- * at all is a cancelled outcome.
763
+ * at all is a cancelled outcome. A refusal draws the call's row again without its diff.
720
764
  */
721
- answer(requestId: string, decision: ApprovalDecision): { rpcId: number | string; result: unknown } | null {
765
+ answer(requestId: string, decision: ApprovalDecision): { rpcId: number | string; result: unknown; events: BackendEvent[] } | null {
722
766
  const pending = this.permissions.get(requestId);
723
767
  if (!pending) {
724
768
  return null;
@@ -739,7 +783,14 @@ export class AcpProtocol {
739
783
  : decision === 'allow'
740
784
  ? byKind('allow_once', 'allow_always')
741
785
  : byKind('reject_once', 'reject_always');
742
- return { rpcId: pending.rpcId, result: { outcome: optionId === null ? { outcome: 'cancelled' } : { outcome: 'selected', optionId } } };
786
+ const result = { outcome: optionId === null ? { outcome: 'cancelled' } : { outcome: 'selected', optionId } };
787
+ const tool = this.tools.get(pending.toolCallId);
788
+ if (decision !== 'deny' || tool === undefined) {
789
+ return { rpcId: pending.rpcId, result, events: [] };
790
+ }
791
+ tool.refused = true;
792
+ this.refusalLine = true;
793
+ return { rpcId: pending.rpcId, result, events: this.toolCall({ toolCallId: pending.toolCallId }, false) };
743
794
  }
744
795
 
745
796
  /* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
@@ -757,11 +808,17 @@ export class AcpProtocol {
757
808
  return this.permissions.has(requestId);
758
809
  }
759
810
 
760
- /* The end of a turn, from the `stopReason` of its `session/prompt` response. */
761
- finish(result: unknown, promptId: string | undefined): BackendEvent[] {
811
+ /*
812
+ * The end of a turn, from the `stopReason` of its `session/prompt` response. Only a turn the client cancelled
813
+ * (`cancelled`) ends aborted: Grok 1.0.62 answers `cancelled` by itself when a person turns a call down, and
814
+ * that ends its turn the way any answer does.
815
+ */
816
+ finish(result: unknown, promptId: string | undefined, cancelled = false): BackendEvent[] {
762
817
  const parsed = PromptResponseSchema.safeParse(result);
763
- const reason = parsed.success ? parsed.data.stopReason : 'end_turn';
764
- const events = [...this.closeText(), ...this.closeThought()];
818
+ const stopReason = parsed.success ? parsed.data.stopReason : 'end_turn';
819
+ const reason = cancelled ? 'cancelled' : stopReason === 'cancelled' ? 'end_turn' : stopReason;
820
+ this.refusalLine = false;
821
+ const events = [...this.closeText(), ...this.closeThought(), ...this.vendorUsage(isRecord(result) ? result._meta : undefined)];
765
822
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
766
823
  switch (reason) {
767
824
  case 'cancelled':
@@ -784,6 +841,7 @@ export class AcpProtocol {
784
841
 
785
842
  /* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
786
843
  abort(promptId: string | undefined, error: string | null): BackendEvent[] {
844
+ this.refusalLine = false;
787
845
  const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
788
846
  return [
789
847
  ...this.closeText(),
@@ -803,10 +861,17 @@ export class AcpProtocol {
803
861
  events.push(...this.closeText());
804
862
  }
805
863
  if (this.text === null) {
806
- this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '' };
864
+ this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '', held: this.refusalLine };
865
+ this.refusalLine = false;
866
+ }
867
+ const text = this.text;
868
+ text.text += chunk.data.content.text;
869
+ if (text.held && !REFUSAL_LINE.startsWith(text.text)) {
870
+ text.held = false;
871
+ events.push({ type: 'text.delta', ref: text.ref, text: text.text });
872
+ } else if (!text.held) {
873
+ events.push({ type: 'text.delta', ref: text.ref, text: chunk.data.content.text });
807
874
  }
808
- this.text.text += chunk.data.content.text;
809
- events.push({ type: 'text.delta', ref: this.text.ref, text: chunk.data.content.text });
810
875
  return events;
811
876
  }
812
877
 
@@ -827,7 +892,10 @@ export class AcpProtocol {
827
892
  private closeText(): BackendEvent[] {
828
893
  const text = this.text;
829
894
  this.text = null;
830
- return text === null ? [] : [{ type: 'text.done', ref: text.ref, text: text.text }];
895
+ if (text === null || (text.held && text.text === REFUSAL_LINE)) {
896
+ return [];
897
+ }
898
+ return [{ type: 'text.done', ref: text.ref, text: text.text }];
831
899
  }
832
900
 
833
901
  private closeThought(): BackendEvent[] {
@@ -850,7 +918,8 @@ export class AcpProtocol {
850
918
  diffs: [],
851
919
  text: [],
852
920
  shown: null,
853
- settled: false
921
+ settled: false,
922
+ refused: false
854
923
  };
855
924
  tool.title = call.title ?? tool.title;
856
925
  tool.name = call.name ?? tool.name;
@@ -869,7 +938,7 @@ export class AcpProtocol {
869
938
  }
870
939
  this.tools.set(call.toolCallId, tool);
871
940
  const events: BackendEvent[] = [];
872
- const row = toolRow(tool);
941
+ const row = toolRow(tool.refused ? { ...tool, diffs: [] } : tool);
873
942
  const shown = JSON.stringify(row);
874
943
  if (shown !== tool.shown) {
875
944
  tool.shown = shown;
@@ -1963,6 +1963,17 @@ export class ChatSession {
1963
1963
  this.refuseStop(event.taskId, event.message);
1964
1964
  return;
1965
1965
  }
1966
+ /*
1967
+ * A CLI that counts its tokens but names no window, in a chat that has none yet: the catalog may have learned the
1968
+ * model's window since the chat began, as an ACP agent's catalog does from the first session that reports its models.
1969
+ */
1970
+ if (event.type === 'usage' && event.contextWindow === undefined && event.contextTokens !== undefined && this.thread.info.usage.contextWindow === null) {
1971
+ const contextWindow = this.options.provider.catalog.contextWindowFor(this.launchedSelection ?? this.thread.info.selection);
1972
+ if (contextWindow !== null) {
1973
+ this.emit(this.projector.project(generation, { ...event, contextWindow }));
1974
+ return;
1975
+ }
1976
+ }
1966
1977
  /*
1967
1978
  * A CLI reports the window it was started on. A pick made since waits for the restart the next
1968
1979
  * send does, so its report may not put the old size back under a meter that already reads the new one.