@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
package/src/skills.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, type Dirent } 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 type { AgentKind, ChatSkill, ChatSkillSource } from '@adecore/agent-contracts';
@@ -10,8 +10,57 @@ export interface SkillRoot {
10
10
  source: ChatSkillSource;
11
11
  // A plugin names its skills `<plugin>:<skill>`, the way the CLI's init frame does.
12
12
  prefix?: string;
13
+ // How many folders below `dir` a skill folder may sit; absent is one, right under it.
14
+ depth?: number;
13
15
  }
14
16
 
17
+ /* How far one scan reaches, so a home or a project of any size answers quickly. */
18
+ export interface SkillScanLimits {
19
+ // Folders listed under one root, nearest first, so a deep tree never costs the skills near the top.
20
+ directories: number;
21
+ skills: number;
22
+ // Entries of one folder looked at, by name; absent is `ENTRIES_PER_FOLDER`.
23
+ entries?: number;
24
+ }
25
+
26
+ const SCAN_LIMITS: SkillScanLimits = { directories: 2000, skills: 1000 };
27
+
28
+ const ENTRIES_PER_FOLDER = 1000;
29
+
30
+ // Paths one folder resolves or reads at once, so a large folder never runs the process out of file handles.
31
+ const SCAN_CONCURRENCY = 16;
32
+
33
+ // A process short of file handles gets them back as other work closes its files, so a few short waits cover it.
34
+ const BUSY_RETRIES = 3;
35
+ const BUSY_RETRY_MS = 50;
36
+
37
+ // Codex finds skills up to five folders below a root (`MAX_SCAN_DEPTH` of 6 in its skill loader counts the root itself).
38
+ const CODEX_SKILL_DEPTH = 5;
39
+
40
+ // Folders a package manager or a build fills. One may hold a SKILL.md of its own, but nothing under it is listed.
41
+ const DEPENDENCY_FOLDERS = new Set([
42
+ 'node_modules',
43
+ 'bower_components',
44
+ 'jspm_packages',
45
+ 'vendor',
46
+ 'Pods',
47
+ 'Carthage',
48
+ 'DerivedData',
49
+ '__pycache__',
50
+ 'venv',
51
+ 'site-packages',
52
+ 'target',
53
+ 'dist',
54
+ 'build',
55
+ 'out',
56
+ 'coverage'
57
+ ]);
58
+
59
+ // What a scan reads of a SKILL.md; the front matter sits at its top.
60
+ const HEAD_BYTES = 64 * 1024;
61
+
62
+ const MAX_DESCRIPTION = 1024;
63
+
15
64
  export interface SkillRootOptions {
16
65
  // The person's home directory; a test points it at a temporary tree.
17
66
  home?: string;
@@ -118,9 +167,9 @@ export async function skillRootsFor(kind: AgentKind, cwd: string, options: Skill
118
167
  const home = options.home ?? homedir();
119
168
  if (kind === 'codex') {
120
169
  return [
121
- { dir: join(home, '.agents', 'skills'), source: 'user' },
122
- { dir: join(home, '.codex', 'skills'), source: 'user' },
123
- ...projectDirs(cwd, home).map((dir): SkillRoot => ({ dir: join(dir, '.agents', 'skills'), source: 'project' }))
170
+ { dir: join(home, '.agents', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
171
+ { dir: join(home, '.codex', 'skills'), source: 'user', depth: CODEX_SKILL_DEPTH },
172
+ ...projectDirs(cwd, home).map((dir): SkillRoot => ({ dir: join(dir, '.agents', 'skills'), source: 'project', depth: CODEX_SKILL_DEPTH }))
124
173
  ];
125
174
  }
126
175
  const configDir = options.claudeConfigDir ?? join(home, '.claude');
@@ -131,37 +180,167 @@ export async function skillRootsFor(kind: AgentKind, cwd: string, options: Skill
131
180
  ];
132
181
  }
133
182
 
134
- async function readSkill(root: SkillRoot, folder: string): Promise<ChatSkill | null> {
135
- let text: string;
183
+ /* Too many files open, in this process or on the system: it says nothing about the path that was asked for. */
184
+ function isBusy(e: unknown): boolean {
185
+ return typeof e === 'object' && e !== null && 'code' in e && (e.code === 'EMFILE' || e.code === 'ENFILE');
186
+ }
187
+
188
+ function sleep(ms: number): Promise<void> {
189
+ return new Promise((resolve) => setTimeout(resolve, ms));
190
+ }
191
+
192
+ /* Runs `operation` again a few times while the process is short of file handles, and throws if it still is. */
193
+ export async function whileBusy<T>(operation: () => Promise<T>, wait: (ms: number) => Promise<void> = sleep): Promise<T> {
194
+ for (let attempt = 1; ; attempt++) {
195
+ try {
196
+ return await operation();
197
+ } catch (e) {
198
+ if (!isBusy(e) || attempt > BUSY_RETRIES) {
199
+ throw e;
200
+ }
201
+ await wait(BUSY_RETRY_MS * attempt);
202
+ }
203
+ }
204
+ }
205
+
206
+ /* `work` over every item with at most `limit` running at once, answered in the order of `items`. */
207
+ export async function mapLimited<T, R>(items: readonly T[], limit: number, work: (item: T) => Promise<R>): Promise<R[]> {
208
+ const results = new Array<R>(items.length);
209
+ let next = 0;
210
+ const worker = async (): Promise<void> => {
211
+ while (next < items.length) {
212
+ const index = next++;
213
+ results[index] = await work(items[index]!);
214
+ }
215
+ };
216
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
217
+ return results;
218
+ }
219
+
220
+ /* 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. */
221
+ async function skillHead(folder: string): Promise<string | null> {
222
+ let handle;
223
+ try {
224
+ handle = await whileBusy(() => open(join(folder, 'SKILL.md'), 'r'));
225
+ } catch (e) {
226
+ if (isBusy(e)) {
227
+ throw e;
228
+ }
229
+ return null;
230
+ }
136
231
  try {
137
- text = await readFile(join(root.dir, folder, 'SKILL.md'), 'utf8');
232
+ if (!(await handle.stat()).isFile()) {
233
+ return null;
234
+ }
235
+ const buffer = Buffer.alloc(HEAD_BYTES);
236
+ const { bytesRead } = await handle.read(buffer, 0, HEAD_BYTES, 0);
237
+ return buffer.subarray(0, bytesRead).toString('utf8');
138
238
  } catch {
139
239
  return null;
240
+ } finally {
241
+ await handle.close();
140
242
  }
243
+ }
244
+
245
+ function skillOf(root: SkillRoot, folder: string, text: string): ChatSkill | null {
141
246
  const fields = parseFrontmatter(text);
142
247
  const own = fields.name && NAME.test(fields.name) ? fields.name : folder;
143
248
  const name = root.prefix ? `${root.prefix}:${own}` : own;
144
249
  if (!NAME.test(name)) {
145
250
  return null;
146
251
  }
147
- return { name, description: fields.description ?? '', source: root.source };
252
+ return { name, description: (fields.description ?? '').slice(0, MAX_DESCRIPTION), source: root.source };
253
+ }
254
+
255
+ /* The folder a path leads to after every link, or null when it leads to no folder. */
256
+ async function realFolder(path: string): Promise<string | null> {
257
+ try {
258
+ const real = await whileBusy(() => realpath(path));
259
+ return (await whileBusy(() => stat(real))).isDirectory() ? real : null;
260
+ } catch (e) {
261
+ if (isBusy(e)) {
262
+ throw e;
263
+ }
264
+ return null;
265
+ }
266
+ }
267
+
268
+ /*
269
+ * 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
270
+ * searched further. Each root keeps its own record of the folders it read or went into, by real path, so a link back
271
+ * up ends there, while a skill two plugins link in shows under each of them.
272
+ */
273
+ async function scanRoot(root: SkillRoot, limits: SkillScanLimits): Promise<ChatSkill[]> {
274
+ const start = await realFolder(root.dir);
275
+ if (start === null) {
276
+ return [];
277
+ }
278
+ const visited = new Set([start]);
279
+ const depth = root.depth ?? 1;
280
+ const entriesPerFolder = limits.entries ?? ENTRIES_PER_FOLDER;
281
+ const skills: ChatSkill[] = [];
282
+ let level = [root.dir];
283
+ let listed = 0;
284
+ for (let below = 1; below <= depth && level.length > 0; below++) {
285
+ const next: string[] = [];
286
+ for (const folder of level) {
287
+ if (listed >= limits.directories || skills.length >= limits.skills) {
288
+ return skills;
289
+ }
290
+ listed++;
291
+ let entries: Dirent[];
292
+ try {
293
+ entries = await whileBusy(() => readdir(folder, { withFileTypes: true }));
294
+ } catch (e) {
295
+ if (isBusy(e)) {
296
+ throw e;
297
+ }
298
+ continue;
299
+ }
300
+ // A skill manager links each skill in from a shared folder, so a link counts as much as a folder.
301
+ const candidates = entries
302
+ .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.'))
303
+ .map((entry) => ({ name: entry.name, path: join(folder, entry.name) }))
304
+ .sort((a, b) => a.name.localeCompare(b.name))
305
+ .slice(0, entriesPerFolder);
306
+ const reals = await mapLimited(candidates, SCAN_CONCURRENCY, (candidate) => realFolder(candidate.path));
307
+ const fresh = candidates.flatMap((candidate, index) => {
308
+ const real = reals[index];
309
+ return real === null || real === undefined || visited.has(real) ? [] : [{ ...candidate, real }];
310
+ });
311
+ const heads = await mapLimited(fresh, SCAN_CONCURRENCY, (candidate) => skillHead(candidate.path));
312
+ for (const [index, candidate] of fresh.entries()) {
313
+ // Two links in one folder can lead to the same place; the first one read it.
314
+ if (visited.has(candidate.real)) {
315
+ continue;
316
+ }
317
+ const head = heads[index];
318
+ if (head !== null && head !== undefined) {
319
+ visited.add(candidate.real);
320
+ const skill = skillOf(root, candidate.name, head);
321
+ if (skill !== null && skills.length < limits.skills) {
322
+ skills.push(skill);
323
+ }
324
+ } else if (below < depth && !DEPENDENCY_FOLDERS.has(candidate.name)) {
325
+ visited.add(candidate.real);
326
+ next.push(candidate.path);
327
+ }
328
+ }
329
+ }
330
+ level = next;
331
+ }
332
+ return skills;
148
333
  }
149
334
 
150
335
  /* Every skill under these roots, sorted by name; the first root that carries a name wins. */
151
- export async function scanSkillRoots(roots: SkillRoot[]): Promise<ChatSkill[]> {
336
+ export async function scanSkillRoots(roots: SkillRoot[], limits: SkillScanLimits = SCAN_LIMITS): Promise<ChatSkill[]> {
152
337
  const found = new Map<string, ChatSkill>();
153
338
  for (const root of roots) {
154
- let entries: Dirent[];
155
- try {
156
- entries = await readdir(root.dir, { withFileTypes: true });
157
- } catch {
158
- continue;
339
+ if (found.size >= limits.skills) {
340
+ break;
159
341
  }
160
- // A skill manager links each skill in from a shared folder; a link to anything but a skill folder reads as no skill.
161
- const folders = entries.filter((entry) => entry.isDirectory() || entry.isSymbolicLink());
162
- const skills = await Promise.all(folders.map((entry) => readSkill(root, entry.name)));
163
- for (const skill of skills) {
164
- if (skill && !found.has(skill.name)) {
342
+ for (const skill of await scanRoot(root, limits)) {
343
+ if (!found.has(skill.name) && found.size < limits.skills) {
165
344
  found.set(skill.name, skill);
166
345
  }
167
346
  }
@@ -2,7 +2,8 @@
2
2
  * An ACP agent that answers with the frames a real one sent, recorded in a capture: its `initialize`, its
3
3
  * `session/new`, and every update of one prompt before the response. Every prompt gets the same turn. A
4
4
  * `{ permission }` entry among the updates is a `session/request_permission` the turn waits on until the
5
- * client answers it, an `{ update, _meta }` entry is a notification with a `_meta` of its own, and with
5
+ * client answers it, a `{ request: { method, params } }` entry is any other request of the agent the turn waits on the
6
+ * same way, an `{ update, _meta }` entry is a notification with a `_meta` of its own, and with
6
7
  * `titleFromPrompt` a `session_info_update` carries the prompt's own text as its title, as Copilot CLI names a session.
7
8
  */
8
9
  import type { FakeCli } from './fake-cli.ts';
@@ -18,6 +19,8 @@ export interface AcpCapture {
18
19
  setConfigOption: unknown;
19
20
  promptUpdates: unknown[];
20
21
  promptResponse: unknown;
22
+ // The JSON-RPC error a prompt fails with instead of the response, when set.
23
+ promptError?: unknown;
21
24
  titleFromPrompt?: boolean;
22
25
  }
23
26
 
@@ -36,16 +39,17 @@ export function fakeAcpCapture(capture: AcpCapture, answers: unknown[] = []): Fa
36
39
  const play = (updates: unknown[], promptId: unknown, text: unknown): void => {
37
40
  for (const [index, fields] of updates.entries()) {
38
41
  const permission = (fields as Frame).permission;
39
- if (permission !== undefined) {
42
+ const request = (fields as Frame).request as { method: string; params: unknown } | undefined;
43
+ if (permission !== undefined || request !== undefined) {
40
44
  const rpcId = nextRpcId++;
41
45
  waiting = { rpcId, rest: updates.slice(index + 1), promptId, text };
42
- send({ id: rpcId, method: 'session/request_permission', params: permission });
46
+ send({ id: rpcId, method: request?.method ?? 'session/request_permission', params: request?.params ?? permission });
43
47
  return;
44
48
  }
45
49
  const named = capture.titleFromPrompt === true && (fields as Frame).sessionUpdate === 'session_info_update';
46
50
  update(named ? { ...(fields as Frame), title: text } : fields);
47
51
  }
48
- send({ id: promptId, result: capture.promptResponse });
52
+ send(capture.promptError === undefined ? { id: promptId, result: capture.promptResponse } : { id: promptId, error: capture.promptError });
49
53
  };
50
54
  return {
51
55
  onLine: (line) => {
@@ -1,5 +1,5 @@
1
1
  import { USAGE_PROVIDERS, type ChatInfo, type ProviderAccounts, type UsageAccount, type UsageProvider } from '@adecore/agent-contracts';
2
- import { definedEnv } from '../providers/accounts/launch.ts';
2
+ import { definedEnv, storedAccount } from '../providers/accounts/launch.ts';
3
3
  import type { ProviderAccountsService } from '../providers/accounts/service.ts';
4
4
  import type { LimitAccount, LimitAccounts } from './limits/monitor.ts';
5
5
  import { accountRoots, type UsageRootPath } from './roots.ts';
@@ -69,7 +69,8 @@ export function limitAccountsOf(
69
69
  machineAccounts(service.snapshot(), nameOf)
70
70
  .filter((account) => account.isDefault || (account.enabled && account.signedIn))
71
71
  .map((account): LimitAccount => ({ ...usageAccount(account), isDefault: account.isDefault })),
72
- envFor: (kind, id) => definedEnv(service.envFor(kind, id, baseEnv)),
72
+ // A plan read is no start: the default account goes unnamed, so turning it off does not fail its read.
73
+ envFor: (kind, id) => definedEnv(service.envFor(kind, storedAccount(kind, id), baseEnv)),
73
74
  lastUsedAt: (id) => service.lastLaunchAt(id)
74
75
  };
75
76
  }