@adecore/agents 0.31.11 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/chat/acp-backend.d.ts +4 -2
  2. package/dist/chat/acp-backend.js +50 -11
  3. package/dist/chat/acp-protocol.d.ts +29 -4
  4. package/dist/chat/acp-protocol.js +262 -23
  5. package/dist/chat/backend.d.ts +3 -0
  6. package/dist/chat/chat-core.js +6 -3
  7. package/dist/chat/chat-session.js +5 -2
  8. package/dist/chat/claude-backend.js +4 -4
  9. package/dist/chat/claude-protocol.d.ts +4 -1
  10. package/dist/chat/claude-protocol.js +46 -2
  11. package/dist/chat/codex-backend.js +4 -4
  12. package/dist/chat/fixtures/antigravity-1.4.1-question.json +69 -0
  13. package/dist/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
  14. package/dist/chat/fixtures/grok-1.0.62-limits.json +66 -0
  15. package/dist/chat/fixtures/grok-1.0.62-question.json +99 -0
  16. package/dist/chat/json-rpc-transport.js +2 -2
  17. package/dist/chat/projector.js +22 -10
  18. package/dist/providers/accounts/service.js +4 -0
  19. package/dist/providers/claude-provider.js +2 -1
  20. package/dist/providers/detect.d.ts +1 -1
  21. package/dist/providers/detect.js +6 -3
  22. package/dist/providers/registry.d.ts +7 -0
  23. package/dist/providers/registry.js +64 -5
  24. package/dist/skills.d.ts +9 -1
  25. package/dist/skills.js +178 -20
  26. package/dist/testing/fake-acp-capture.d.ts +1 -0
  27. package/dist/testing/fake-acp-capture.js +4 -3
  28. package/dist/usage/accounts.js +3 -2
  29. package/package.json +3 -3
  30. package/src/chat/acp-backend.ts +56 -11
  31. package/src/chat/acp-protocol.ts +308 -26
  32. package/src/chat/backend.ts +5 -1
  33. package/src/chat/chat-core.ts +6 -3
  34. package/src/chat/chat-session.ts +5 -2
  35. package/src/chat/claude-backend.ts +4 -4
  36. package/src/chat/claude-protocol.ts +48 -2
  37. package/src/chat/codex-backend.ts +4 -4
  38. package/src/chat/fixtures/antigravity-1.4.1-question.json +69 -0
  39. package/src/chat/fixtures/antigravity-1.4.1-quota.json +35 -0
  40. package/src/chat/fixtures/grok-1.0.62-limits.json +66 -0
  41. package/src/chat/fixtures/grok-1.0.62-question.json +99 -0
  42. package/src/chat/json-rpc-transport.ts +2 -2
  43. package/src/chat/projector.ts +24 -12
  44. package/src/providers/accounts/service.ts +4 -0
  45. package/src/providers/claude-provider.ts +2 -1
  46. package/src/providers/detect.ts +7 -3
  47. package/src/providers/registry.ts +71 -5
  48. package/src/skills.ts +198 -19
  49. package/src/testing/fake-acp-capture.ts +8 -4
  50. package/src/usage/accounts.ts +3 -2
@@ -51,12 +51,45 @@ export class ProviderRegistry {
51
51
  detect;
52
52
  env;
53
53
  cache = new Map();
54
+ listeners = new Set();
55
+ // What the registry listens to on each provider, so one taken away is let go.
56
+ stops = new Map();
54
57
  constructor(options = {}) {
55
- this.providers = options.providers ?? [...BUILT_IN_PROVIDERS];
58
+ this.providers = [...(options.providers ?? BUILT_IN_PROVIDERS)];
56
59
  this.commands = options.commands ?? {};
57
60
  this.detect = options.detect ?? null;
58
61
  this.env = options.env ?? process.env;
59
62
  }
63
+ /*
64
+ * Offers one more provider after the host started, such as a CLI a person installed since, or puts it in the
65
+ * place of the one of its kind. The `onChange` listeners hear its kind, so a host hands its clients the new list.
66
+ */
67
+ add(provider) {
68
+ const index = this.providers.findIndex((entry) => entry.kind === provider.kind);
69
+ if (index === -1) {
70
+ this.providers.push(provider);
71
+ }
72
+ else {
73
+ this.unfollow(this.providers[index]);
74
+ this.providers[index] = provider;
75
+ }
76
+ this.cache.delete(provider.kind);
77
+ if (this.listeners.size > 0) {
78
+ this.follow(provider);
79
+ }
80
+ this.changed(provider.kind);
81
+ }
82
+ /* Stops offering a kind; a record that names it gets the stand-in `get` answers for a kind the host does not offer. */
83
+ remove(kind) {
84
+ const index = this.providers.findIndex((entry) => entry.kind === kind);
85
+ if (index === -1) {
86
+ return;
87
+ }
88
+ this.unfollow(this.providers[index]);
89
+ this.providers.splice(index, 1);
90
+ this.cache.delete(kind);
91
+ this.changed(kind);
92
+ }
60
93
  async list() {
61
94
  return await Promise.all(this.providers.map(async (provider) => ({
62
95
  kind: provider.kind,
@@ -97,12 +130,24 @@ export class ProviderRegistry {
97
130
  enabled(kind) {
98
131
  return this.get(kind).enabled?.() !== false;
99
132
  }
100
- /* Calls the listener with the kind of every provider whose models or capabilities changed, so a host can list them again; answers how to stop. */
133
+ /*
134
+ * Calls the listener with the kind of every provider whose models or capabilities changed, and of every one
135
+ * added or removed, so a host can list them again; answers how to stop.
136
+ */
101
137
  onChange(listener) {
102
- const stops = this.providers.flatMap((provider) => (provider.onChange ? [provider.onChange(() => listener(provider.kind))] : []));
138
+ if (this.listeners.size === 0) {
139
+ for (const provider of this.providers) {
140
+ this.follow(provider);
141
+ }
142
+ }
143
+ this.listeners.add(listener);
103
144
  return () => {
104
- for (const stop of stops) {
105
- stop();
145
+ this.listeners.delete(listener);
146
+ if (this.listeners.size === 0) {
147
+ for (const stop of this.stops.values()) {
148
+ stop();
149
+ }
150
+ this.stops.clear();
106
151
  }
107
152
  };
108
153
  }
@@ -112,6 +157,20 @@ export class ProviderRegistry {
112
157
  fallback(kind) {
113
158
  return unofferedProvider(kind);
114
159
  }
160
+ follow(provider) {
161
+ if (provider.onChange && !this.stops.has(provider)) {
162
+ this.stops.set(provider, provider.onChange(() => this.changed(provider.kind)));
163
+ }
164
+ }
165
+ unfollow(provider) {
166
+ this.stops.get(provider)?.();
167
+ this.stops.delete(provider);
168
+ }
169
+ changed(kind) {
170
+ for (const listener of [...this.listeners]) {
171
+ listener(kind);
172
+ }
173
+ }
115
174
  async detection(provider) {
116
175
  if (provider.enabled?.() === false) {
117
176
  return DISABLED;
package/dist/skills.d.ts CHANGED
@@ -3,6 +3,12 @@ export interface SkillRoot {
3
3
  dir: string;
4
4
  source: ChatSkillSource;
5
5
  prefix?: string;
6
+ depth?: number;
7
+ }
8
+ export interface SkillScanLimits {
9
+ directories: number;
10
+ skills: number;
11
+ entries?: number;
6
12
  }
7
13
  export interface SkillRootOptions {
8
14
  home?: string;
@@ -11,7 +17,9 @@ export interface SkillRootOptions {
11
17
  export declare function parseFrontmatter(text: string): Record<string, string>;
12
18
  export declare function pluginSkillRoots(configDir: string): Promise<SkillRoot[]>;
13
19
  export declare function skillRootsFor(kind: AgentKind, cwd: string, options?: SkillRootOptions): Promise<SkillRoot[]>;
14
- export declare function scanSkillRoots(roots: SkillRoot[]): Promise<ChatSkill[]>;
20
+ export declare function whileBusy<T>(operation: () => Promise<T>, wait?: (ms: number) => Promise<void>): Promise<T>;
21
+ export declare function mapLimited<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]>;
22
+ export declare function scanSkillRoots(roots: SkillRoot[], limits?: SkillScanLimits): Promise<ChatSkill[]>;
15
23
  export declare function discoverSkills(kind: AgentKind, cwd: string, options?: SkillRootOptions): Promise<ChatSkill[]>;
16
24
  export declare class SkillIndex {
17
25
  private readonly options;
package/dist/skills.js CHANGED
@@ -1,8 +1,38 @@
1
1
  import { existsSync } from 'node:fs';
2
- import { readdir, readFile } from 'node:fs/promises';
2
+ import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join, parse } from 'node:path';
5
5
  import { isNotFound } from './fs.js';
6
+ const SCAN_LIMITS = { directories: 2000, skills: 1000 };
7
+ const ENTRIES_PER_FOLDER = 1000;
8
+ // Paths one folder resolves or reads at once, so a large folder never runs the process out of file handles.
9
+ const SCAN_CONCURRENCY = 16;
10
+ // A process short of file handles gets them back as other work closes its files, so a few short waits cover it.
11
+ const BUSY_RETRIES = 3;
12
+ const BUSY_RETRY_MS = 50;
13
+ // Codex finds skills up to five folders below a root (`MAX_SCAN_DEPTH` of 6 in its skill loader counts the root itself).
14
+ const CODEX_SKILL_DEPTH = 5;
15
+ // Folders a package manager or a build fills. One may hold a SKILL.md of its own, but nothing under it is listed.
16
+ const DEPENDENCY_FOLDERS = new Set([
17
+ 'node_modules',
18
+ 'bower_components',
19
+ 'jspm_packages',
20
+ 'vendor',
21
+ 'Pods',
22
+ 'Carthage',
23
+ 'DerivedData',
24
+ '__pycache__',
25
+ 'venv',
26
+ 'site-packages',
27
+ 'target',
28
+ 'dist',
29
+ 'build',
30
+ 'out',
31
+ 'coverage'
32
+ ]);
33
+ // What a scan reads of a SKILL.md; the front matter sits at its top.
34
+ const HEAD_BYTES = 64 * 1024;
35
+ const MAX_DESCRIPTION = 1024;
6
36
  const NAME = /^[A-Za-z][A-Za-z0-9_:-]*$/;
7
37
  function unquote(value) {
8
38
  const quoted = /^(['"])([\s\S]*)\1$/.exec(value);
@@ -99,9 +129,9 @@ export async function skillRootsFor(kind, cwd, options = {}) {
99
129
  const home = options.home ?? homedir();
100
130
  if (kind === 'codex') {
101
131
  return [
102
- { dir: join(home, '.agents', 'skills'), source: 'user' },
103
- { dir: join(home, '.codex', 'skills'), source: 'user' },
104
- ...projectDirs(cwd, home).map((dir) => ({ dir: join(dir, '.agents', 'skills'), source: 'project' }))
132
+ { dir: join(home, '.agents', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
133
+ { dir: join(home, '.codex', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
134
+ ...projectDirs(cwd, home).map((dir) => ({ dir: join(dir, '.agents', 'skills'), source: 'project', depth: CODEX_SKILL_DEPTH }))
105
135
  ];
106
136
  }
107
137
  const configDir = options.claudeConfigDir ?? join(home, '.claude');
@@ -111,38 +141,166 @@ export async function skillRootsFor(kind, cwd, options = {}) {
111
141
  ...(await pluginSkillRoots(configDir))
112
142
  ];
113
143
  }
114
- async function readSkill(root, folder) {
115
- let text;
144
+ /* Too many files open, in this process or on the system: it says nothing about the path that was asked for. */
145
+ function isBusy(e) {
146
+ return typeof e === 'object' && e !== null && 'code' in e && (e.code === 'EMFILE' || e.code === 'ENFILE');
147
+ }
148
+ function sleep(ms) {
149
+ return new Promise((resolve) => setTimeout(resolve, ms));
150
+ }
151
+ /* Runs `operation` again a few times while the process is short of file handles, and throws if it still is. */
152
+ export async function whileBusy(operation, wait = sleep) {
153
+ for (let attempt = 1;; attempt++) {
154
+ try {
155
+ return await operation();
156
+ }
157
+ catch (e) {
158
+ if (!isBusy(e) || attempt > BUSY_RETRIES) {
159
+ throw e;
160
+ }
161
+ await wait(BUSY_RETRY_MS * attempt);
162
+ }
163
+ }
164
+ }
165
+ /* `work` over every item with at most `limit` running at once, answered in the order of `items`. */
166
+ export async function mapLimited(items, limit, work) {
167
+ const results = new Array(items.length);
168
+ let next = 0;
169
+ const worker = async () => {
170
+ while (next < items.length) {
171
+ const index = next++;
172
+ results[index] = await work(items[index]);
173
+ }
174
+ };
175
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
176
+ return results;
177
+ }
178
+ /* The top of a folder's SKILL.md, or null when it has none. A process short of file handles throws instead of finding none. */
179
+ async function skillHead(folder) {
180
+ let handle;
116
181
  try {
117
- text = await readFile(join(root.dir, folder, 'SKILL.md'), 'utf8');
182
+ handle = await whileBusy(() => open(join(folder, 'SKILL.md'), 'r'));
183
+ }
184
+ catch (e) {
185
+ if (isBusy(e)) {
186
+ throw e;
187
+ }
188
+ return null;
189
+ }
190
+ try {
191
+ if (!(await handle.stat()).isFile()) {
192
+ return null;
193
+ }
194
+ const buffer = Buffer.alloc(HEAD_BYTES);
195
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
196
+ return buffer.subarray(0, bytesRead).toString('utf8');
118
197
  }
119
198
  catch {
120
199
  return null;
121
200
  }
201
+ finally {
202
+ await handle.close();
203
+ }
204
+ }
205
+ function skillOf(root, folder, text) {
122
206
  const fields = parseFrontmatter(text);
123
207
  const own = fields.name && NAME.test(fields.name) ? fields.name : folder;
124
208
  const name = root.prefix ? `${root.prefix}:${own}` : own;
125
209
  if (!NAME.test(name)) {
126
210
  return null;
127
211
  }
128
- return { name, description: fields.description ?? '', source: root.source };
212
+ return { name, description: (fields.description ?? '').slice(0, MAX_DESCRIPTION), source: root.source };
213
+ }
214
+ /* The folder a path leads to after every link, or null when it leads to no folder. */
215
+ async function realFolder(path) {
216
+ try {
217
+ const real = await whileBusy(() => realpath(path));
218
+ return (await whileBusy(() => stat(real))).isDirectory() ? real : null;
219
+ }
220
+ catch (e) {
221
+ if (isBusy(e)) {
222
+ throw e;
223
+ }
224
+ return null;
225
+ }
226
+ }
227
+ /*
228
+ * The skills under one root, nearest first, down to the root's depth. A folder with a SKILL.md is a skill and is not
229
+ * searched further. Each root keeps its own record of the folders it read or went into, by real path, so a link back
230
+ * up ends there, while a skill two plugins link in shows under each of them.
231
+ */
232
+ async function scanRoot(root, limits) {
233
+ const start = await realFolder(root.dir);
234
+ if (start === null) {
235
+ return [];
236
+ }
237
+ const visited = new Set([start]);
238
+ const depth = root.depth ?? 1;
239
+ const entriesPerFolder = limits.entries ?? ENTRIES_PER_FOLDER;
240
+ const skills = [];
241
+ let level = [root.dir];
242
+ let listed = 0;
243
+ for (let below = 1; below <= depth && level.length > 0; below++) {
244
+ const next = [];
245
+ for (const folder of level) {
246
+ if (listed >= limits.directories || skills.length >= limits.skills) {
247
+ return skills;
248
+ }
249
+ listed++;
250
+ let entries;
251
+ try {
252
+ entries = await whileBusy(() => readdir(folder, { withFileTypes: true }));
253
+ }
254
+ catch (e) {
255
+ if (isBusy(e)) {
256
+ throw e;
257
+ }
258
+ continue;
259
+ }
260
+ // A skill manager links each skill in from a shared folder, so a link counts as much as a folder.
261
+ const candidates = entries
262
+ .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.'))
263
+ .map((entry) => ({ name: entry.name, path: join(folder, entry.name) }))
264
+ .sort((a, b) => a.name.localeCompare(b.name))
265
+ .slice(0, entriesPerFolder);
266
+ const reals = await mapLimited(candidates, SCAN_CONCURRENCY, (candidate) => realFolder(candidate.path));
267
+ const fresh = candidates.flatMap((candidate, index) => {
268
+ const real = reals[index];
269
+ return real === null || real === undefined || visited.has(real) ? [] : [{ ...candidate, real }];
270
+ });
271
+ const heads = await mapLimited(fresh, SCAN_CONCURRENCY, (candidate) => skillHead(candidate.path));
272
+ for (const [index, candidate] of fresh.entries()) {
273
+ // Two links in one folder can lead to the same place; the first one read it.
274
+ if (visited.has(candidate.real)) {
275
+ continue;
276
+ }
277
+ const head = heads[index];
278
+ if (head !== null && head !== undefined) {
279
+ visited.add(candidate.real);
280
+ const skill = skillOf(root, candidate.name, head);
281
+ if (skill !== null && skills.length < limits.skills) {
282
+ skills.push(skill);
283
+ }
284
+ }
285
+ else if (below < depth && !DEPENDENCY_FOLDERS.has(candidate.name)) {
286
+ visited.add(candidate.real);
287
+ next.push(candidate.path);
288
+ }
289
+ }
290
+ }
291
+ level = next;
292
+ }
293
+ return skills;
129
294
  }
130
295
  /* Every skill under these roots, sorted by name; the first root that carries a name wins. */
131
- export async function scanSkillRoots(roots) {
296
+ export async function scanSkillRoots(roots, limits = SCAN_LIMITS) {
132
297
  const found = new Map();
133
298
  for (const root of roots) {
134
- let entries;
135
- try {
136
- entries = await readdir(root.dir, { withFileTypes: true });
137
- }
138
- catch {
139
- continue;
299
+ if (found.size >= limits.skills) {
300
+ break;
140
301
  }
141
- // A skill manager links each skill in from a shared folder; a link to anything but a skill folder reads as no skill.
142
- const folders = entries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink());
143
- const skills = await Promise.all(folders.map((entry) => readSkill(root, entry.name)));
144
- for (const skill of skills) {
145
- if (skill && !found.has(skill.name)) {
302
+ for (const skill of await scanRoot(root, limits)) {
303
+ if (!found.has(skill.name) && found.size < limits.skills) {
146
304
  found.set(skill.name, skill);
147
305
  }
148
306
  }
@@ -7,6 +7,7 @@ export interface AcpCapture {
7
7
  setConfigOption: unknown;
8
8
  promptUpdates: unknown[];
9
9
  promptResponse: unknown;
10
+ promptError?: unknown;
10
11
  titleFromPrompt?: boolean;
11
12
  }
12
13
  export declare function fakeAcpCapture(capture: AcpCapture, answers?: unknown[]): FakeCli;
@@ -13,16 +13,17 @@ export function fakeAcpCapture(capture, answers = []) {
13
13
  const play = (updates, promptId, text) => {
14
14
  for (const [index, fields] of updates.entries()) {
15
15
  const permission = fields.permission;
16
- if (permission !== undefined) {
16
+ const request = fields.request;
17
+ if (permission !== undefined || request !== undefined) {
17
18
  const rpcId = nextRpcId++;
18
19
  waiting = { rpcId, rest: updates.slice(index + 1), promptId, text };
19
- send({ id: rpcId, method: 'session/request_permission', params: permission });
20
+ send({ id: rpcId, method: request?.method ?? 'session/request_permission', params: request?.params ?? permission });
20
21
  return;
21
22
  }
22
23
  const named = capture.titleFromPrompt === true && fields.sessionUpdate === 'session_info_update';
23
24
  update(named ? { ...fields, title: text } : fields);
24
25
  }
25
- send({ id: promptId, result: capture.promptResponse });
26
+ send(capture.promptError === undefined ? { id: promptId, result: capture.promptResponse } : { id: promptId, error: capture.promptError });
26
27
  };
27
28
  return {
28
29
  onLine: (line) => {
@@ -1,5 +1,5 @@
1
1
  import { USAGE_PROVIDERS } from '@adecore/agent-contracts';
2
- import { definedEnv } from '../providers/accounts/launch.js';
2
+ import { definedEnv, storedAccount } from '../providers/accounts/launch.js';
3
3
  import { accountRoots } from './roots.js';
4
4
  function isUsageProvider(kind) {
5
5
  return USAGE_PROVIDERS.includes(kind);
@@ -48,7 +48,8 @@ export function limitAccountsOf(service, nameOf, baseEnv) {
48
48
  list: () => machineAccounts(service.snapshot(), nameOf)
49
49
  .filter((account) => account.isDefault || (account.enabled && account.signedIn))
50
50
  .map((account) => ({ ...usageAccount(account), isDefault: account.isDefault })),
51
- envFor: (kind, id) => definedEnv(service.envFor(kind, id, baseEnv)),
51
+ // A plan read is no start: the default account goes unnamed, so turning it off does not fail its read.
52
+ envFor: (kind, id) => definedEnv(service.envFor(kind, storedAccount(kind, id), baseEnv)),
52
53
  lastUsedAt: (id) => service.lastLaunchAt(id)
53
54
  };
54
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.11",
3
+ "version": "0.32.0",
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.11",
85
- "@adecore/intelligent-ui": "0.31.11",
84
+ "@adecore/agent-contracts": "0.32.0",
85
+ "@adecore/intelligent-ui": "0.32.0",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -1,15 +1,17 @@
1
1
  import { readFileSync, realpathSync, statSync } from 'node:fs';
2
2
  import { isAbsolute, relative, resolve } from 'node:path';
3
3
  import { pathToFileURL } from 'node:url';
4
- import { attachmentImageMime, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
4
+ import { attachmentImageMime, type AgentKind, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
5
5
  import { errorText } from '../error-text.ts';
6
6
  import { MODE_ORDER } from '../modes.ts';
7
7
  import {
8
8
  ACP_AUTH_REQUIRED,
9
9
  ACP_PROTOCOL_VERSION,
10
10
  AcpProtocol,
11
+ acpErrorLimit,
11
12
  acpStopWords,
12
13
  autoAllows,
14
+ GROK_QUESTION_METHODS,
13
15
  parseInitialize,
14
16
  parseSessionInfo,
15
17
  promptUsageOf,
@@ -21,6 +23,7 @@ import {
21
23
  type AcpAgentCapabilities,
22
24
  type AcpModeMap,
23
25
  type AcpPermissionRequest,
26
+ type AcpQuestion,
24
27
  type AcpSessionInfo,
25
28
  type AcpThoughtLevel,
26
29
  type AcpTurnUsage
@@ -42,6 +45,8 @@ export const DEFAULT_ACP_CLIENT: AcpClientInfo = { name: 'agents', title: 'Agent
42
45
  export interface AcpAgentSpec {
43
46
  // The agent as a person reads it, in the notes and errors the backend writes.
44
47
  name: string;
48
+ // Which agent this is, for an error code only that agent gives a meaning; absent reads none of them.
49
+ kind?: AgentKind;
45
50
  // Variables set over the launch's environment.
46
51
  env?: Record<string, string>;
47
52
  // The agent's own mode ids per runtime mode, best first. A runtime mode the agent has no id for runs in
@@ -124,7 +129,7 @@ export class AcpBackend implements ChatBackend {
124
129
  this.host = host;
125
130
  this.spec = spec;
126
131
  this.client = spec.client ?? DEFAULT_ACP_CLIENT;
127
- this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true);
132
+ this.protocol = new AcpProtocol(launch.generation, spec.modes ?? {}, acpStopWords(spec.name), spec.adoptTitles === true, launch.now);
128
133
  }
129
134
 
130
135
  get running(): boolean {
@@ -411,7 +416,7 @@ export class AcpBackend implements ChatBackend {
411
416
  sendTurn(input: TurnInput): void {
412
417
  const transport = this.transport;
413
418
  if (!transport) {
414
- this.emit({ type: 'failed', message: `session/prompt failed: ${this.spec.name} is not running` });
419
+ this.emit({ type: 'failed', message: `${this.spec.name} is not running` });
415
420
  return;
416
421
  }
417
422
  const prompt = this.promptBlocks(input);
@@ -430,7 +435,8 @@ export class AcpBackend implements ChatBackend {
430
435
  // A process that went has its exit tell the thread; anything else ends this turn alone.
431
436
  if (this.transport === transport && transport.alive && this.prompt === turn) {
432
437
  this.prompt = null;
433
- this.emitAll(this.protocol.abort(turn.promptId, turn.cancelled ? null : errorText(error)));
438
+ const limit = error instanceof JsonRpcError ? acpErrorLimit(error.code, error.data, this.spec.kind ?? null) : null;
439
+ this.emitAll(this.protocol.abort(turn.promptId, turn.cancelled ? null : errorText(error), limit));
434
440
  }
435
441
  });
436
442
  }
@@ -441,7 +447,7 @@ export class AcpBackend implements ChatBackend {
441
447
  this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
442
448
  return;
443
449
  }
444
- this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
450
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot compact its context.` });
445
451
  this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
446
452
  }
447
453
 
@@ -457,7 +463,7 @@ export class AcpBackend implements ChatBackend {
457
463
  }
458
464
  this.prompt.cancelled = true;
459
465
  try {
460
- for (const cancelled of this.protocol.cancelPermissions()) {
466
+ for (const cancelled of this.protocol.cancelRequests()) {
461
467
  transport.respond(cancelled.rpcId, cancelled.result);
462
468
  this.emit({ type: 'request.withdrawn', requestId: cancelled.requestId });
463
469
  }
@@ -479,12 +485,23 @@ export class AcpBackend implements ChatBackend {
479
485
  return true;
480
486
  }
481
487
 
482
- /* ACP asks no questions of its own; an agent asks in its message and the next prompt answers. */
483
- respondQuestion(): boolean {
484
- return false;
488
+ respondQuestion(requestId: string, answers: Record<string, string>): boolean {
489
+ const answer = this.protocol.answerQuestion(requestId, answers);
490
+ if (!answer) {
491
+ return false;
492
+ }
493
+ if (!this.respond(requestId, answer.rpcId, answer.result)) {
494
+ return false;
495
+ }
496
+ this.emitAll(answer.events);
497
+ return true;
485
498
  }
486
499
 
487
500
  declineRequest(requestId: string): boolean {
501
+ if (this.protocol.hasQuestion(requestId)) {
502
+ const cancelled = this.protocol.cancelQuestion(requestId);
503
+ return cancelled !== null && this.respond(requestId, cancelled.rpcId, cancelled.result);
504
+ }
488
505
  return this.protocol.hasPermission(requestId) && this.respondApproval(requestId, 'deny');
489
506
  }
490
507
 
@@ -532,10 +549,25 @@ export class AcpBackend implements ChatBackend {
532
549
  if (id === null) {
533
550
  return;
534
551
  }
552
+ if (typeof frame.method === 'string' && GROK_QUESTION_METHODS.includes(frame.method)) {
553
+ const question = this.protocol.question(id, this.sessionId, frame.params);
554
+ if (question === null) {
555
+ transport.respondError(id, -32602, 'Not a question of this session');
556
+ return;
557
+ }
558
+ this.ask(transport, question);
559
+ return;
560
+ }
535
561
  if (frame.method !== 'session/request_permission') {
536
562
  transport.respondError(id, -32601, `${nameOf(this.client)} does not handle ${String(frame.method)}`);
537
563
  return;
538
564
  }
565
+ // Before any runtime mode answers it: a question is never the agent's to allow.
566
+ const interaction = this.protocol.interaction(id, this.sessionId, frame.params);
567
+ if (interaction !== null) {
568
+ this.ask(transport, interaction);
569
+ return;
570
+ }
539
571
  const permission = this.protocol.permission(id, this.sessionId, frame.params);
540
572
  if (permission === null) {
541
573
  transport.respondError(id, -32602, 'Not a permission request of this session');
@@ -543,7 +575,7 @@ export class AcpBackend implements ChatBackend {
543
575
  }
544
576
  if (this.prompt?.cancelled) {
545
577
  this.emitAll(permission.events.filter((event) => event.type !== 'approval.requested'));
546
- const answer = this.protocol.cancelPermissions().find((cancelled) => cancelled.requestId === permission.requestId);
578
+ const answer = this.protocol.cancelRequests().find((cancelled) => cancelled.requestId === permission.requestId);
547
579
  if (answer) {
548
580
  transport.respond(answer.rpcId, answer.result);
549
581
  }
@@ -560,6 +592,19 @@ export class AcpBackend implements ChatBackend {
560
592
  this.emitAll(permission.events);
561
593
  }
562
594
 
595
+ /* Puts a question to the person, or turns it down at once when the turn is being cancelled. */
596
+ private ask(transport: JsonRpcTransport, question: AcpQuestion): void {
597
+ if (this.prompt?.cancelled) {
598
+ this.emitAll(question.events.filter((event) => event.type !== 'question.requested'));
599
+ const cancelled = this.protocol.cancelQuestion(question.requestId);
600
+ if (cancelled) {
601
+ transport.respond(cancelled.rpcId, cancelled.result);
602
+ }
603
+ return;
604
+ }
605
+ this.emitAll(question.events);
606
+ }
607
+
563
608
  /* Holds an update until the session is known; of a history a load replays, only the commands, since the thread has the rest. */
564
609
  private holdEarly(params: unknown): void {
565
610
  const update = sessionUpdateOf(params);
@@ -582,7 +627,7 @@ export class AcpBackend implements ChatBackend {
582
627
  }
583
628
  this.transport = null;
584
629
  this.prompt = null;
585
- this.protocol.cancelPermissions();
630
+ this.protocol.cancelRequests();
586
631
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
587
632
  }
588
633