@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
@@ -46,6 +46,22 @@ function contextTokens(usage: unknown): number {
46
46
  return isRecord(usage) ? num(usage.input_tokens) + num(usage.cache_creation_input_tokens) + num(usage.cache_read_input_tokens) : 0;
47
47
  }
48
48
 
49
+ // How long a prompt cache entry lives, by the field of `usage.cache_creation` that counts what a request wrote with it.
50
+ const CACHE_TTL_MS: Record<string, number> = { ephemeral_5m_input_tokens: 5 * 60_000, ephemeral_1h_input_tokens: 60 * 60_000 };
51
+
52
+ /*
53
+ * The lifetime of the cache a response wrote, or null when it wrote none or names no lifetime. A response that wrote
54
+ * with both answers the shorter, which is when the part of its context that goes cold first does.
55
+ */
56
+ function cacheTtlWritten(usage: unknown): number | null {
57
+ const creation = isRecord(usage) && isRecord(usage.cache_creation) ? usage.cache_creation : null;
58
+ if (creation === null) {
59
+ return null;
60
+ }
61
+ const written = Object.entries(CACHE_TTL_MS).filter(([field]) => num(creation[field]) > 0);
62
+ return written.length === 0 ? null : Math.min(...written.map(([, ttl]) => ttl));
63
+ }
64
+
49
65
  // The kinds of task Claude Code keeps beside its turns that are a command or a monitor; a subagent has a row of its own.
50
66
  const BACKGROUND_TASK_TYPES = new Set(['local_bash', 'monitor_mcp', 'monitor_ws']);
51
67
  const ENDED_TASK_STATUSES = new Set(['completed', 'failed', 'killed', 'stopped']);
@@ -202,6 +218,9 @@ export class ClaudeProtocol {
202
218
  private readonly backgroundTasks = new Set<string>();
203
219
  // Workflows still running, by task id, to the Workflow call that launched each.
204
220
  private readonly workflows = new Map<string, string>();
221
+ // The main-chain response whose cache lifetime the thread has, and the lifetime of the cache last written.
222
+ private cacheMessageId: string | null = null;
223
+ private cacheTtlMs: number | null;
205
224
  // The uuid of the last main-chain assistant frame of the turn, which is the line of the transcript the turn ends on.
206
225
  private lastUuid: string | null = null;
207
226
  // The model each subagent last answered with, so a frame only reports one it had not named yet.
@@ -224,9 +243,11 @@ export class ClaudeProtocol {
224
243
  // Background subagents still at work; Claude Code holds a goal's check until they report.
225
244
  private readonly backgroundAgents = new Set<string>();
226
245
 
227
- constructor(goal: ChatGoal | null = null, now: () => number = () => Date.now()) {
246
+ // `cacheTtlMs` is the lifetime the chat knew before this process, so a resumed CLI that only reads the cache still moves its expiry.
247
+ constructor(goal: ChatGoal | null = null, now: () => number = () => Date.now(), cacheTtlMs: number | null = null) {
228
248
  this.goal = goal;
229
249
  this.now = now;
250
+ this.cacheTtlMs = cacheTtlMs;
230
251
  }
231
252
 
232
253
  /* A stop is on its way; the turn it ends leaves the goal set but nobody working on it. */
@@ -572,6 +593,7 @@ export class ClaudeProtocol {
572
593
  }
573
594
  if (event.type === 'message_start') {
574
595
  const message = isRecord(event.message) ? event.message : {};
596
+ this.noteCache(str(message.id), message.usage, events);
575
597
  this.streamMessageId = str(message.id);
576
598
  this.streamTextCount = 0;
577
599
  this.streamThinkingCount = 0;
@@ -607,6 +629,27 @@ export class ClaudeProtocol {
607
629
  }
608
630
  }
609
631
 
632
+ /*
633
+ * When the prompt cache goes cold, from the first frame of each main-chain response: its `message_start`, or its
634
+ * first `assistant` frame when the CLI streams no partial messages. The lifetime runs from the start of the request,
635
+ * and a response that only read the cache starts the lifetime it was written with again, as a cache hit does.
636
+ */
637
+ private noteCache(messageId: string | null, usage: unknown, events: BackendEvent[]): void {
638
+ if (messageId === null || messageId === this.cacheMessageId || !isRecord(usage)) {
639
+ return;
640
+ }
641
+ this.cacheMessageId = messageId;
642
+ const written = cacheTtlWritten(usage);
643
+ if (written !== null) {
644
+ this.cacheTtlMs = written;
645
+ } else if (num(usage.cache_read_input_tokens) === 0) {
646
+ return;
647
+ }
648
+ if (this.cacheTtlMs !== null) {
649
+ events.push({ type: 'usage', cacheTtlMs: this.cacheTtlMs, cacheExpiresAt: this.now() + this.cacheTtlMs });
650
+ }
651
+ }
652
+
610
653
  private handleAssistant(frame: Frame, events: BackendEvent[]): void {
611
654
  const message = isRecord(frame.message) ? frame.message : {};
612
655
  const messageId = str(message.id) ?? 'message';
@@ -653,6 +696,9 @@ export class ClaudeProtocol {
653
696
  if (tokens > 0) {
654
697
  events.push({ type: 'usage', contextTokens: tokens });
655
698
  }
699
+ if (!parentRef) {
700
+ this.noteCache(str(message.id), message.usage, events);
701
+ }
656
702
  const error = str(frame.error);
657
703
  if (error) {
658
704
  events.push({ type: 'note', level: 'error', text: `The request failed: ${error.replaceAll('_', ' ')}` });
@@ -756,7 +802,7 @@ export class ClaudeProtocol {
756
802
  events.push({ type: 'usage', costUsd: num(frame.total_cost_usd) });
757
803
  }
758
804
  if (frame.is_error === true) {
759
- events.push({ type: 'note', level: 'warning', text: 'A background Claude turn failed; the submitted prompt is still pending.' });
805
+ events.push({ type: 'note', level: 'warning', text: 'Claude could not finish a turn in the background. Your message is still waiting.' });
760
806
  }
761
807
  return;
762
808
  }
@@ -262,7 +262,7 @@ export class CodexBackend implements ChatBackend {
262
262
  private async runGoalCommand(command: GoalCommand, input: TurnInput): Promise<void> {
263
263
  const transport = this.transport;
264
264
  if (!transport) {
265
- this.emit({ type: 'failed', message: 'thread/goal failed: Codex is not running' });
265
+ this.emit({ type: 'failed', message: 'Codex is not running' });
266
266
  return;
267
267
  }
268
268
  const threadId = this.threadId;
@@ -328,7 +328,7 @@ export class CodexBackend implements ChatBackend {
328
328
  async goal(action: ChatGoalAction): Promise<void> {
329
329
  const transport = this.transport;
330
330
  if (!transport) {
331
- throw new Error('thread/goal failed: Codex is not running');
331
+ throw new Error('Codex is not running');
332
332
  }
333
333
  if (action === 'clear') {
334
334
  await transport.request('thread/goal/clear', { threadId: this.threadId, origin: 'user' });
@@ -443,7 +443,7 @@ export class CodexBackend implements ChatBackend {
443
443
  listThreadItems(params: { threadId: string; cursor?: string; limit: number; sortDirection: 'asc' | 'desc' }): Promise<unknown> {
444
444
  const transport = this.transport;
445
445
  if (!transport) {
446
- return Promise.reject(new Error('thread/items/list failed: Codex is not running'));
446
+ return Promise.reject(new Error('Codex is not running'));
447
447
  }
448
448
  return transport.request('thread/items/list', params);
449
449
  }
@@ -537,7 +537,7 @@ export class CodexBackend implements ChatBackend {
537
537
  private request(method: string, params: unknown, accepted?: () => void): void {
538
538
  const transport = this.transport;
539
539
  if (!transport) {
540
- this.emit({ type: 'failed', message: `${method} failed: Codex is not running` });
540
+ this.emit({ type: 'failed', message: 'Codex is not running' });
541
541
  return;
542
542
  }
543
543
  void transport
@@ -0,0 +1,69 @@
1
+ {
2
+ "version": "Antigravity ACP server 1.4.1, `agy_acp_server.par`",
3
+ "source": "Not recorded: a question needs the model to choose to ask one. Built from `ExternalOnInteractionHook` in the server's acp_server/hooks.py inside agy_acp_server.par 1.4.1: a `tool_call` with an `interaction_` id and an empty rawInput, then one `session/request_permission` per question with an `allow_once` option per choice, answered with the chosen optionId.",
4
+ "sessionId": "agy-session-1",
5
+ "initialize": {
6
+ "protocolVersion": 1,
7
+ "agentCapabilities": {
8
+ "loadSession": true,
9
+ "promptCapabilities": {
10
+ "image": false,
11
+ "embeddedContext": true
12
+ }
13
+ },
14
+ "authMethods": []
15
+ },
16
+ "sessionNew": {
17
+ "sessionId": "agy-session-1"
18
+ },
19
+ "commands": [],
20
+ "setConfigOption": {},
21
+ "promptUpdates": [
22
+ {
23
+ "sessionUpdate": "tool_call",
24
+ "toolCallId": "interaction_3f9a1c2e",
25
+ "title": "Which test runner should I set up?",
26
+ "rawInput": {},
27
+ "status": "pending"
28
+ },
29
+ {
30
+ "permission": {
31
+ "sessionId": "agy-session-1",
32
+ "toolCall": {
33
+ "toolCallId": "interaction_3f9a1c2e",
34
+ "title": "Which test runner should I set up?",
35
+ "rawInput": {},
36
+ "status": "pending"
37
+ },
38
+ "options": [
39
+ {
40
+ "optionId": "opt_builtin",
41
+ "name": "The built-in runner",
42
+ "kind": "allow_once"
43
+ },
44
+ {
45
+ "optionId": "opt_separate",
46
+ "name": "A separate runner",
47
+ "kind": "allow_once"
48
+ }
49
+ ]
50
+ }
51
+ },
52
+ {
53
+ "sessionUpdate": "tool_call_update",
54
+ "toolCallId": "interaction_3f9a1c2e",
55
+ "status": "completed",
56
+ "rawOutput": "Response received"
57
+ },
58
+ {
59
+ "sessionUpdate": "agent_message_chunk",
60
+ "content": {
61
+ "type": "text",
62
+ "text": "Setting it up."
63
+ }
64
+ }
65
+ ],
66
+ "promptResponse": {
67
+ "stopReason": "end_turn"
68
+ }
69
+ }
@@ -0,0 +1,35 @@
1
+ {
2
+ "version": "Antigravity ACP server 1.4.1, `agy_acp_server.par`",
3
+ "source": "Not recorded: no account here runs out of quota on demand. Built from the server's own Python modules inside agy_acp_server.par 1.4.1 (acp_server/server.py and ccpa_connection/quota_errors.py): on QUOTA_EXHAUSTED it writes this message as an agent_message_chunk and answers the prompt with `end_turn`; the reset is the delay in its two largest units, or the time when only that is known.",
4
+ "sessionId": "agy-session-1",
5
+ "initialize": {
6
+ "protocolVersion": 1,
7
+ "agentCapabilities": {
8
+ "loadSession": true,
9
+ "promptCapabilities": {
10
+ "image": false,
11
+ "embeddedContext": true
12
+ }
13
+ },
14
+ "authMethods": []
15
+ },
16
+ "sessionNew": {
17
+ "sessionId": "agy-session-1"
18
+ },
19
+ "commands": [],
20
+ "setConfigOption": {},
21
+ "promptUpdates": [
22
+ {
23
+ "sessionUpdate": "agent_message_chunk",
24
+ "content": {
25
+ "type": "text",
26
+ "text": "Usage Limit Reached\n\nYou have reached your current quota for this period. Your limit will reset in 4 days, 23 hours."
27
+ }
28
+ }
29
+ ],
30
+ "promptResponse": {
31
+ "stopReason": "end_turn"
32
+ },
33
+ "resetOn": "Usage Limit Reached\n\nYou have reached your current quota for this period. Your limit will reset on Oct 23, 2026 14:05 UTC.",
34
+ "noReset": "Usage Limit Reached\n\nYou have reached your current quota for this period."
35
+ }
@@ -0,0 +1,66 @@
1
+ {
2
+ "version": "Grok 1.0.62, `grok --no-auto-update agent stdio`",
3
+ "source": "Not recorded: no account here runs into these limits on demand. Built from the source of xai-org/grok-build at 2bdd1d6a, the closest to 1.0.62 it publishes: `handle_sampling_failure` in acp_session_impl/sampler_turn.rs fails `session/prompt` with -32003 after a 429 and with -32603 for an overloaded model once its retries ran out, and sampling/error.rs builds the `data` (a string, or `message` with `http_status`). The bodies after the status line are examples.",
4
+ "sessionId": "grok-session-1",
5
+ "initialize": {
6
+ "protocolVersion": 1,
7
+ "agentCapabilities": {
8
+ "loadSession": true,
9
+ "promptCapabilities": {
10
+ "image": false,
11
+ "embeddedContext": true
12
+ }
13
+ },
14
+ "authMethods": []
15
+ },
16
+ "sessionNew": {
17
+ "sessionId": "grok-session-1"
18
+ },
19
+ "commands": [],
20
+ "setConfigOption": {},
21
+ "promptUpdates": [
22
+ {
23
+ "sessionUpdate": "agent_thought_chunk",
24
+ "content": {
25
+ "type": "text",
26
+ "text": "Reading the request."
27
+ }
28
+ }
29
+ ],
30
+ "promptResponse": {
31
+ "stopReason": "end_turn"
32
+ },
33
+ "errors": {
34
+ "freeUsage": {
35
+ "code": -32003,
36
+ "message": "Rate limited",
37
+ "data": "API error (status 429 Too Many Requests): {\"code\":\"subscription:free-usage-exhausted\",\"error\":\"You have used the free usage of this period.\"}"
38
+ },
39
+ "rateLimited": {
40
+ "code": -32003,
41
+ "message": "Rate limited",
42
+ "data": "API error (status 429 Too Many Requests): {\"code\":\"rate_limit_exceeded\",\"error\":\"Too many requests.\"}"
43
+ },
44
+ "overloaded": {
45
+ "code": -32603,
46
+ "message": "Internal error",
47
+ "data": {
48
+ "message": "API error (status 529): Overloaded",
49
+ "http_status": 529
50
+ }
51
+ },
52
+ "overloadedStream": {
53
+ "code": -32603,
54
+ "message": "Internal error",
55
+ "data": "stream error (overloaded_error): Overloaded"
56
+ },
57
+ "badRequest": {
58
+ "code": -32603,
59
+ "message": "Internal error",
60
+ "data": {
61
+ "message": "API error (status 400 Bad Request): The prompt is too long.",
62
+ "http_status": 400
63
+ }
64
+ }
65
+ }
66
+ }
@@ -0,0 +1,99 @@
1
+ {
2
+ "version": "Grok 1.0.62, `grok --no-auto-update agent stdio`",
3
+ "source": "Not recorded: a question needs the model to choose to ask one. Built from the source of xai-org/grok-build at 2bdd1d6a, the closest to 1.0.62 it publishes: the ext request `_x.ai/ask_user_question` (acp_session_impl/spawn.rs) with the params of ask_user_question/types.rs; its answer is `{ outcome: \"accepted\", answers, annotations? }`, keyed by the question's text.",
4
+ "sessionId": "grok-session-1",
5
+ "initialize": {
6
+ "protocolVersion": 1,
7
+ "agentCapabilities": {
8
+ "loadSession": true,
9
+ "promptCapabilities": {
10
+ "image": false,
11
+ "embeddedContext": true
12
+ }
13
+ },
14
+ "authMethods": []
15
+ },
16
+ "sessionNew": {
17
+ "sessionId": "grok-session-1"
18
+ },
19
+ "commands": [],
20
+ "setConfigOption": {},
21
+ "promptUpdates": [
22
+ {
23
+ "sessionUpdate": "tool_call",
24
+ "toolCallId": "call-ask-1",
25
+ "title": "Ask the user",
26
+ "kind": "other",
27
+ "status": "pending"
28
+ },
29
+ {
30
+ "request": {
31
+ "method": "_x.ai/ask_user_question",
32
+ "params": {
33
+ "sessionId": "grok-session-1",
34
+ "toolCallId": "call-ask-1",
35
+ "mode": "default",
36
+ "questions": [
37
+ {
38
+ "question": "Which database should the service use?",
39
+ "options": [
40
+ {
41
+ "label": "Postgres",
42
+ "description": "The one the other services run on"
43
+ },
44
+ {
45
+ "label": "SQLite",
46
+ "description": "A file beside the service"
47
+ }
48
+ ],
49
+ "multiSelect": false
50
+ },
51
+ {
52
+ "question": "Which checks should run before a release?",
53
+ "options": [
54
+ {
55
+ "label": "Tests",
56
+ "description": "The unit tests"
57
+ },
58
+ {
59
+ "label": "Lint, strict",
60
+ "description": "Every warning fails"
61
+ },
62
+ {
63
+ "label": "Types",
64
+ "description": "The type check"
65
+ }
66
+ ],
67
+ "multiSelect": true
68
+ },
69
+ {
70
+ "question": "Anything else to keep in mind?",
71
+ "options": [
72
+ {
73
+ "label": "No",
74
+ "description": "Nothing"
75
+ }
76
+ ],
77
+ "multiSelect": null
78
+ }
79
+ ]
80
+ }
81
+ }
82
+ },
83
+ {
84
+ "sessionUpdate": "tool_call_update",
85
+ "toolCallId": "call-ask-1",
86
+ "status": "completed"
87
+ },
88
+ {
89
+ "sessionUpdate": "agent_message_chunk",
90
+ "content": {
91
+ "type": "text",
92
+ "text": "Noted."
93
+ }
94
+ }
95
+ ],
96
+ "promptResponse": {
97
+ "stopReason": "end_turn"
98
+ }
99
+ }
@@ -84,7 +84,7 @@ export class JsonRpcTransport {
84
84
  const timeoutMs = options.timeoutMs === undefined ? this.options.requestTimeoutMs : options.timeoutMs;
85
85
  return new Promise((resolve, reject) => {
86
86
  if (this.closed) {
87
- reject(new Error(`${method} failed: ${label} is not running`));
87
+ reject(new Error(`${label} is not running`));
88
88
  return;
89
89
  }
90
90
  const id = this.nextId++;
@@ -165,7 +165,7 @@ export class JsonRpcTransport {
165
165
  }
166
166
 
167
167
  private failed(error: unknown): void {
168
- const failure = new Error(`${this.options.label} transport failed: ${errorText(error)}`);
168
+ const failure = new Error(`Lost the connection to ${this.options.label}: ${errorText(error)}`);
169
169
  this.rejectPending(() => failure);
170
170
  try {
171
171
  this.options.onError?.(failure);
@@ -351,18 +351,30 @@ export class ThreadProjector {
351
351
  break;
352
352
  }
353
353
  case 'usage': {
354
- const contextTokens = event.contextTokens ?? info.usage.contextTokens;
355
- events.push(
356
- this.thread.patchInfo({
357
- usage: {
358
- ...info.usage,
359
- contextTokens,
360
- contextWindow: event.contextWindow ?? info.usage.contextWindow,
361
- costUsd: Math.max(info.usage.costUsd, event.costUsd ?? 0),
362
- breakdown: contextTokens > 0 ? estimateContextBreakdown(this.thread.list(), contextTokens) : undefined
363
- }
364
- })
365
- );
354
+ const usage = info.usage;
355
+ const contextTokens = event.contextTokens ?? usage.contextTokens;
356
+ const next = {
357
+ ...usage,
358
+ contextTokens,
359
+ contextWindow: event.contextWindow ?? usage.contextWindow,
360
+ costUsd: Math.max(usage.costUsd, event.costUsd ?? 0),
361
+ // The estimate reads the whole thread, so only a new count of tokens is worth it.
362
+ ...(contextTokens === usage.contextTokens
363
+ ? {}
364
+ : { breakdown: contextTokens > 0 ? estimateContextBreakdown(this.thread.list(), contextTokens) : undefined }),
365
+ ...(event.cacheTtlMs === undefined || event.cacheExpiresAt === undefined
366
+ ? {}
367
+ : { cacheTtlMs: event.cacheTtlMs, cacheExpiresAt: event.cacheExpiresAt })
368
+ };
369
+ if (
370
+ next.contextTokens !== usage.contextTokens ||
371
+ next.contextWindow !== usage.contextWindow ||
372
+ next.costUsd !== usage.costUsd ||
373
+ next.cacheTtlMs !== usage.cacheTtlMs ||
374
+ next.cacheExpiresAt !== usage.cacheExpiresAt
375
+ ) {
376
+ events.push(this.thread.patchInfo({ usage: next }));
377
+ }
366
378
  break;
367
379
  }
368
380
  case 'compaction':
@@ -225,6 +225,10 @@ export class ProviderAccountsService implements AccountLaunches {
225
225
  };
226
226
  if (isDefaultAccountOf(kind, id)) {
227
227
  const account = this.accounts[kind] ?? { kind };
228
+ // Only a start that names it: every turn of a chat on the default account asks with none.
229
+ if (id !== undefined && account.enabled === false) {
230
+ throw refuse('it is turned off');
231
+ }
228
232
  return accountEnv(kind, account, provider, baseEnv, variables(account));
229
233
  }
230
234
  const account = this.accounts[key];
@@ -33,7 +33,8 @@ export function createClaudeProvider(options: ClaudeBackendOptions = {}): ChatPr
33
33
  { kind: 'env', variable: 'ANTHROPIC_API_KEY' }
34
34
  ]
35
35
  },
36
- detect: detectCli,
36
+ // The version probe must never be the start that updates the CLI.
37
+ detect: (command, env) => detectCli(command, { ...env, DISABLE_AUTOUPDATER: '1' }),
37
38
  oneShotArgs: (prompt) => ['-p', prompt, '--output-format', 'text'],
38
39
  // `-p` without a prompt argument reads the prompt from stdin.
39
40
  oneShotStdinArgs: ['-p', '--output-format', 'text'],
@@ -6,18 +6,22 @@ export interface CliDetection {
6
6
  version: string | null;
7
7
  }
8
8
 
9
- /* Asks a CLI for its version; a missing binary is a plain "not installed", never an error. */
9
+ /*
10
+ * Asks a CLI for its version; a missing binary is a plain "not installed", never an error. `flags` go before
11
+ * `--version`, for a CLI that would otherwise look for an update of itself on any start.
12
+ */
10
13
  export async function detectCli(
11
14
  command: string,
12
15
  env: Record<string, string | undefined> = process.env,
13
- spawn: SpawnChatProcess = spawnChatProcess
16
+ spawn: SpawnChatProcess = spawnChatProcess,
17
+ flags: readonly string[] = []
14
18
  ): Promise<CliDetection> {
15
19
  try {
16
20
  let exited: (code: number | null) => void = () => undefined;
17
21
  const code = new Promise<number | null>((resolve) => {
18
22
  exited = resolve;
19
23
  });
20
- const proc = spawn({ command: [command, '--version'], cwd: process.cwd(), env: definedEnv(env), onExit: (exitCode) => exited(exitCode) });
24
+ const proc = spawn({ command: [command, ...flags, '--version'], cwd: process.cwd(), env: definedEnv(env), onExit: (exitCode) => exited(exitCode) });
21
25
  proc.stdin.end();
22
26
  const output = await new Response(proc.stdout).text();
23
27
  if ((await code) !== 0) {
@@ -72,14 +72,48 @@ export class ProviderRegistry {
72
72
  private readonly detect: ((command: string) => Promise<CliDetection>) | null;
73
73
  private readonly env: Record<string, string | undefined>;
74
74
  private readonly cache = new Map<AgentKind, { at: number; detection: CliDetection }>();
75
+ private readonly listeners = new Set<(kind: AgentKind) => void>();
76
+ // What the registry listens to on each provider, so one taken away is let go.
77
+ private readonly stops = new Map<ChatProvider, () => void>();
75
78
 
76
79
  constructor(options: ProviderRegistryOptions = {}) {
77
- this.providers = options.providers ?? [...BUILT_IN_PROVIDERS];
80
+ this.providers = [...(options.providers ?? BUILT_IN_PROVIDERS)];
78
81
  this.commands = options.commands ?? {};
79
82
  this.detect = options.detect ?? null;
80
83
  this.env = options.env ?? process.env;
81
84
  }
82
85
 
86
+ /*
87
+ * Offers one more provider after the host started, such as a CLI a person installed since, or puts it in the
88
+ * place of the one of its kind. The `onChange` listeners hear its kind, so a host hands its clients the new list.
89
+ */
90
+ add(provider: ChatProvider): void {
91
+ const index = this.providers.findIndex((entry) => entry.kind === provider.kind);
92
+ if (index === -1) {
93
+ this.providers.push(provider);
94
+ } else {
95
+ this.unfollow(this.providers[index]!);
96
+ this.providers[index] = provider;
97
+ }
98
+ this.cache.delete(provider.kind);
99
+ if (this.listeners.size > 0) {
100
+ this.follow(provider);
101
+ }
102
+ this.changed(provider.kind);
103
+ }
104
+
105
+ /* Stops offering a kind; a record that names it gets the stand-in `get` answers for a kind the host does not offer. */
106
+ remove(kind: AgentKind): void {
107
+ const index = this.providers.findIndex((entry) => entry.kind === kind);
108
+ if (index === -1) {
109
+ return;
110
+ }
111
+ this.unfollow(this.providers[index]!);
112
+ this.providers.splice(index, 1);
113
+ this.cache.delete(kind);
114
+ this.changed(kind);
115
+ }
116
+
83
117
  async list(): Promise<ProviderInfo[]> {
84
118
  return await Promise.all(
85
119
  this.providers.map(async (provider) => ({
@@ -128,12 +162,24 @@ export class ProviderRegistry {
128
162
  return this.get(kind).enabled?.() !== false;
129
163
  }
130
164
 
131
- /* 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. */
165
+ /*
166
+ * Calls the listener with the kind of every provider whose models or capabilities changed, and of every one
167
+ * added or removed, so a host can list them again; answers how to stop.
168
+ */
132
169
  onChange(listener: (kind: AgentKind) => void): () => void {
133
- const stops = this.providers.flatMap((provider) => (provider.onChange ? [provider.onChange(() => listener(provider.kind))] : []));
170
+ if (this.listeners.size === 0) {
171
+ for (const provider of this.providers) {
172
+ this.follow(provider);
173
+ }
174
+ }
175
+ this.listeners.add(listener);
134
176
  return () => {
135
- for (const stop of stops) {
136
- stop();
177
+ this.listeners.delete(listener);
178
+ if (this.listeners.size === 0) {
179
+ for (const stop of this.stops.values()) {
180
+ stop();
181
+ }
182
+ this.stops.clear();
137
183
  }
138
184
  };
139
185
  }
@@ -146,6 +192,26 @@ export class ProviderRegistry {
146
192
  return unofferedProvider(kind);
147
193
  }
148
194
 
195
+ private follow(provider: ChatProvider): void {
196
+ if (provider.onChange && !this.stops.has(provider)) {
197
+ this.stops.set(
198
+ provider,
199
+ provider.onChange(() => this.changed(provider.kind))
200
+ );
201
+ }
202
+ }
203
+
204
+ private unfollow(provider: ChatProvider): void {
205
+ this.stops.get(provider)?.();
206
+ this.stops.delete(provider);
207
+ }
208
+
209
+ private changed(kind: AgentKind): void {
210
+ for (const listener of [...this.listeners]) {
211
+ listener(kind);
212
+ }
213
+ }
214
+
149
215
  private async detection(provider: ChatProvider): Promise<CliDetection> {
150
216
  if (provider.enabled?.() === false) {
151
217
  return DISABLED;