@coda-console/claude 1.4.0 → 1.5.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.
package/agent/models.mjs CHANGED
@@ -29,13 +29,26 @@ export function reportedModel(models, current, model) {
29
29
 
30
30
  const select = (id, name, category, currentValue, choices) => ({ id, name, category, type: 'select', currentValue,
31
31
  options: choices.map(c => ({ value: c.id, name: c.name, ...(c._meta ? { _meta: c._meta } : {}) })) });
32
+ // The models, grouped by where they come from once a model service's are among them (ACP's select groups, Coda's
33
+ // docs/agent-protocol.md「模型的分组」): Claude's own, then each service's, under its title. Claude's alone are one plain list.
34
+ function modelSelect(currentValue, models) {
35
+ if (!models.some(model => model.service)) return select('model', '模型', 'model', currentValue, models);
36
+ const groups = [{ group: 'claude', name: 'Claude', options: [] }];
37
+ for (const model of models) {
38
+ const id = model.service ?? 'claude';
39
+ let group = groups.find(g => g.group === id);
40
+ if (!group) groups.push(group = { group: id, name: model.group || id, options: [] });
41
+ group.options.push({ value: model.id, name: model.name });
42
+ }
43
+ return { id: 'model', name: '模型', category: 'model', type: 'select', currentValue, options: groups.filter(g => g.options.length) };
44
+ }
32
45
  // The effort that leaves it to the model, marked as the agent's own default (docs/agent-protocol.md): what Coda goes back to
33
46
  // when the session's effort is taken off.
34
47
  export const MODEL_EFFORT = 'default';
35
48
  // `current`: { mode, model, effort }. An effort not chosen is null: the model's own default.
36
49
  export function configOptions(models, current) {
37
50
  const efforts = models.find(m => m.id === current.model)?.efforts ?? [];
38
- return [select('mode', '审批模式', 'mode', current.mode, MODES), select('model', '模型', 'model', current.model, models),
51
+ return [select('mode', '审批模式', 'mode', current.mode, MODES), modelSelect(current.model, models),
39
52
  ...(efforts.length ? [select('effort', '推理强度', 'thought_level', efforts.includes(current.effort) ? current.effort : MODEL_EFFORT,
40
53
  [{ id: MODEL_EFFORT, name: '模型默认', _meta: { coda: { default: true } } }, ...efforts.map(id => ({ id, name: id }))])] : [])];
41
54
  }
@@ -1,38 +1,39 @@
1
- // The model service chosen in Coda (`claude.modelProvider`, Coda's docs/plugins.md「模型服务」): another plugin's service that
2
- // speaks Anthropic Messages, from CODA_MODEL_PROVIDERS. The models its address lists (`<地址>/models`) join Claude's own, each
3
- // named by the service and the model; a session on one runs Claude Code with the service as its API (`serviceSettings`), set
4
- // for that session's process only.
1
+ // The model services chosen in Coda (`claude.modelProviders`, Coda's docs/plugins.md「模型服务」): other plugins' services
2
+ // that speak Anthropic Messages, from CODA_MODEL_PROVIDERS. The models each lists (`<地址>/models`) join Claude's own, a group
3
+ // of the service's each (Coda's docs/agent-protocol.md「模型的分组」); a session on one runs Claude Code with its service as
4
+ // its API (`serviceSettings`), set for that session's process only.
5
5
  import { readFileSync } from 'node:fs';
6
6
  import { authOverrides } from './auth.mjs';
7
7
 
8
- export const SETTING = 'claude.modelProvider';
8
+ export const SETTING = 'claude.modelProviders';
9
9
  const log = text => process.stderr.write(`[claude-agent] ${text}\n`);
10
10
  const parse = text => { try { return JSON.parse(text || '{}'); } catch { return {}; } };
11
11
  const LIST_TIMEOUT = 8000;
12
12
 
13
- // The chosen service: its address, title and key (read from the file Coda keeps it in). Null when none is chosen, or the
14
- // one chosen is not on this machine or speaks another API.
15
- export function chosenService(env = process.env) {
16
- const ref = parse(env.CODA_PLUGIN_SETTINGS)[SETTING];
17
- if (!ref) return null;
18
- const provided = parse(env.CODA_MODEL_PROVIDERS)[ref];
19
- if (!provided?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return null; }
20
- if (provided.api !== 'anthropic-messages') { log(`模型服务 ${ref} 讲 ${provided.api},Claude 只接 anthropic-messages 的`); return null; }
21
- let key;
22
- try { key = provided.keyFile ? readFileSync(provided.keyFile, 'utf8').trim() || undefined : undefined; }
23
- catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
24
- return { ref, title: provided.title || ref, url: provided.url, key };
13
+ // The services chosen, in the order chosen: each its address, title and key (read from the file Coda keeps it in). One not
14
+ // on this machine, or speaking another API, is left out.
15
+ export function chosenServices(env = process.env) {
16
+ const refs = parse(env.CODA_PLUGIN_SETTINGS)[SETTING], provided = parse(env.CODA_MODEL_PROVIDERS);
17
+ return (Array.isArray(refs) ? refs : []).flatMap(ref => {
18
+ const service = provided[ref];
19
+ if (!service?.url) { log(`模型服务 ${ref} 在这台机器上没有`); return []; }
20
+ if (service.api !== 'anthropic-messages') { log(`模型服务 ${ref} 讲 ${service.api},Claude 只接 anthropic-messages 的`); return []; }
21
+ let key;
22
+ try { key = service.keyFile ? readFileSync(service.keyFile, 'utf8').trim() || undefined : undefined; }
23
+ catch (e) { log(`读不到模型服务 ${ref} 的 Key:${e.message}`); }
24
+ return [{ ref, title: service.title || ref, url: service.url, key }];
25
+ });
25
26
  }
26
27
 
27
- // The models the service lists, asked as an Anthropic client asks (Coda's「检查可用模型」): `<服务>/<模型>`, named by the
28
- // model and the service. They have no effort to choose.
28
+ // The models a service lists, asked as an Anthropic client asks (Coda's「检查可用模型」): `<服务>/<模型>`, named by the
29
+ // model, in the service's group. They have no effort to choose.
29
30
  export async function serviceModels({ ref, title, url, key }, fetcher = fetch) {
30
31
  const at = `${url.replace(/\/+$/, '')}/models?limit=1000`;
31
32
  const response = await fetcher(at, { headers: key ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
32
33
  if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
33
34
  const body = await response.json();
34
35
  return (Array.isArray(body?.data) ? body.data : []).filter(model => typeof model?.id === 'string' && model.id)
35
- .map(model => ({ id: `${ref}/${model.id}`, name: `${model.id} (${title})`, efforts: [], model: model.id }));
36
+ .map(model => ({ id: `${ref}/${model.id}`, name: model.id, efforts: [], model: model.id, service: ref, group: title }));
36
37
  }
37
38
 
38
39
  // The flag settings Claude Code runs a service's model with (above the machine's and the project's settings, its environment
@@ -45,9 +46,10 @@ export function serviceSettings({ url, key }, model) {
45
46
  CLAUDE_CODE_SUBAGENT_MODEL: model } };
46
47
  }
47
48
 
48
- // The service's models, listed with Claude's own: none when it cannot list them now.
49
- export async function listedModels(service) {
50
- if (!service) return [];
51
- try { return await serviceModels(service); }
52
- catch (e) { log(`模型服务 ${service.ref} 列不出模型:${e.message}`); return []; }
49
+ // The services' models, listed with Claude's own in the order the services are chosen: none of one that cannot list them now.
50
+ export async function listedModels(services) {
51
+ return (await Promise.all(services.map(async service => {
52
+ try { return await serviceModels(service); }
53
+ catch (e) { log(`模型服务 ${service.ref} 列不出模型:${e.message}`); return []; }
54
+ }))).flat();
53
55
  }
package/agent/session.mjs CHANGED
@@ -11,7 +11,7 @@ import { join } from 'node:path';
11
11
  import { agent, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
12
12
  import { ClaudeSessionLimits, accountHash } from './limits.mjs';
13
13
  import { sdkEnvironment } from './auth.mjs';
14
- import { chosenService, listedModels, serviceSettings } from './providers.mjs';
14
+ import { chosenServices, listedModels, serviceSettings } from './providers.mjs';
15
15
  import { SessionTitle } from './titles.mjs';
16
16
  import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from './models.mjs';
17
17
  import { permissionRequest } from './permissions.mjs';
@@ -59,8 +59,8 @@ export class ClaudeSession {
59
59
  constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
60
60
  Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
61
61
  this.phase = 'new'; this.input = new InputStream(); this.prompts = new Map(); this.withdrawals = new Set();
62
- // The model service chosen in Coda (providers.mjs), and the model of it the query runs on: null on Claude's own.
63
- this.service = chosenService(env); this.serviceModel = null;
62
+ // The model services chosen in Coda (providers.mjs), and the service's model the query runs on: null on Claude's own.
63
+ this.services = chosenServices(env); this.serviceModel = null;
64
64
  this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
65
65
  // Streamed text by API message, so that the complete message adds only what was not streamed; tool calls said.
66
66
  this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
@@ -77,7 +77,12 @@ export class ClaudeSession {
77
77
  .onRequest('session/set_config_option', ({ params }) => this.configure(params.configId, params.value))
78
78
  .onRequest('session/set_mode', async ({ params }) => { await this.configure('mode', params.modeId); return {}; })
79
79
  .onRequest('session/prompt', ({ params }) => this.prompt(params))
80
- .onNotification('session/cancel', ({ params }) => { if (params.sessionId === this.sessionId) void this.query?.interrupt().catch(() => {}); })
80
+ .onNotification('session/cancel', ({ params }) => {
81
+ if (params.sessionId !== this.sessionId) return;
82
+ // While the session moves to another query there is none to interrupt: the turn waiting on the move is stopped there.
83
+ if (this.rerouting) this.cancelling = true;
84
+ void this.query?.interrupt().catch(() => {});
85
+ })
81
86
  .onRequest('_coda/wait/stop', params => params, ({ params }) => this.stopWait(params))
82
87
  .connect(stream);
83
88
  this.client = this.connection.client;
@@ -117,7 +122,7 @@ export class ClaudeSession {
117
122
  // Coda's servers are stdio ones; the SDK keys them by name.
118
123
  const servers = Object.fromEntries(mcpServers.filter(server => typeof server.command === 'string').map(({ name, command, args = [], env = [] }) =>
119
124
  [name, { command, args, ...(env.length ? { env: Object.fromEntries(env.map(variable => [variable.name, variable.value])) } : {}) }]));
120
- // What every query of the session starts with; a model of the chosen service runs in a query of its own (`reroute`).
125
+ // What every query of the session starts with; a model of a chosen service runs in a query of its own (`reroute`).
121
126
  this.launch = {
122
127
  // Skipping every approval is a mode the user may switch to later; the SDK refuses it unless allowed at start.
123
128
  permissionMode: 'default', allowDangerouslySkipPermissions: true, settingSources: ['user', 'project', 'local'],
@@ -141,7 +146,7 @@ export class ClaudeSession {
141
146
  onElicitation: (request, context) => this.elicitation(request, context),
142
147
  stderr: data => process.stderr.write(data),
143
148
  };
144
- const listed = listedModels(this.service);
149
+ const listed = listedModels(this.services);
145
150
  this.start(resume ? { resume } : { sessionId: this.sessionId });
146
151
  const initialization = await this.initialized(), served = await listed;
147
152
  if (this.phase !== 'starting') throw failure('Claude 会话在初始化时结束', -32001);
@@ -159,14 +164,14 @@ export class ClaudeSession {
159
164
  throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
160
165
  }
161
166
  }
162
- // A query of the session, reading a prompt stream of its own: on Claude's own models, or on `model` of the chosen
167
+ // A query of the session, reading a prompt stream of its own: on Claude's own models, or on `model` of a chosen
163
168
  // service, which it runs with that service as its API and that model for every role. One started after the session
164
169
  // opened goes on with the model, mode and effort chosen.
165
170
  start(at, model) {
166
171
  this.input = new InputStream();
167
172
  const entry = this.served(model);
168
173
  const settings = { ...this.launch.settings, ...(this.current.effort ? { effortLevel: this.current.effort } : {}),
169
- ...(entry ? serviceSettings(this.service, entry.model) : {}) };
174
+ ...(entry ? serviceSettings(this.services.find(service => service.ref === entry.service), entry.model) : {}) };
170
175
  this.serviceModel = entry ? model : null;
171
176
  this.query = this.sdk.query({ prompt: this.input, options: { ...this.launch, ...at, settings,
172
177
  ...(this.phase === 'running' ? { permissionMode: this.current.mode, model: entry ? entry.model : this.current.model } : {}) } });
@@ -179,7 +184,7 @@ export class ClaudeSession {
179
184
  new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
180
185
  .finally(() => clearTimeout(timer));
181
186
  }
182
- // The model chosen is of another service than the query's (Claude's own, or the chosen service's): the session goes on in a
187
+ // The model chosen is of another service than the query's (Claude's own, or a chosen service's): the session goes on in a
183
188
  // query started for it, resuming what the last one said. Before a turn, as Coda sets a session's model before each.
184
189
  async reroute() {
185
190
  const previous = this.query;
@@ -198,9 +203,9 @@ export class ClaudeSession {
198
203
  throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
199
204
  }
200
205
  }
201
- // The chosen service's model that `model` is; null for one of Claude's own.
206
+ // The chosen services' model that `model` is; null for one of Claude's own.
202
207
  served(model) { return this.models.find(m => m.id === model && m.model) ?? null; }
203
- // Whether the next turn runs in another query than this one: on the service's models, or back on Claude's own.
208
+ // Whether the next turn runs in another query than this one: on another service's model, or back on Claude's own.
204
209
  rerouted() { return (this.served(this.current.model)?.id ?? null) !== this.serviceModel; }
205
210
  async configure(configId, value) {
206
211
  if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
@@ -209,7 +214,7 @@ export class ClaudeSession {
209
214
  await this.query.setPermissionMode(value); this.current.mode = value;
210
215
  } else if (configId === 'model') {
211
216
  if (!this.models.some(model => model.id === value)) throw failure('模型不可用', -32602);
212
- // Between Claude's own models the query switches; to or from the service's, the next turn runs in another one, and
217
+ // Between Claude's own models the query switches; to or from a service's, the next turn runs in another one, and
213
218
  // what this one still runs would end with it.
214
219
  const switching = (this.served(value)?.id ?? null) !== this.serviceModel;
215
220
  if (switching && (this.prompts.size || this.waits.pending)) throw failure('后台任务、子 Agent 或定时唤醒还没结束,结束后才能换到这个模型', -32602);
@@ -229,7 +234,12 @@ export class ClaudeSession {
229
234
  // Answered when the native result that names this input arrives; accepting it into the input says nothing yet.
230
235
  async prompt({ prompt }) {
231
236
  if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
232
- if (this.rerouted()) await (this.rerouting ??= this.reroute().finally(() => { this.rerouting = null; }));
237
+ if (this.rerouted()) {
238
+ if (!this.rerouting) { this.cancelling = false; this.rerouting = this.reroute().finally(() => { this.rerouting = null; }); }
239
+ await this.rerouting;
240
+ // Stopped while it moved: the input is not given to the new query.
241
+ if (this.cancelling) { this.cancelling = false; return { stopReason: 'cancelled' }; }
242
+ }
233
243
  const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
234
244
  const answered = new Promise((resolve, reject) => this.prompts.set(id, { resolve, reject }));
235
245
  this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content } });
@@ -311,7 +321,7 @@ export class ClaudeSession {
311
321
  if (m.session_id !== this.sessionId) return;
312
322
  this.terminal = m.terminal_slash_commands ?? [];
313
323
  // Its effort is the one applied, the model's own included: what was chosen stays what it was. A query on the
314
- // service's model reports it by the service's name for it.
324
+ // service's model reports it by that service's name for it.
315
325
  this.current = { ...this.current, model: this.serviceModel ?? reportedModel(this.models, this.current.model, m.model), mode: m.permissionMode ?? this.current.mode };
316
326
  if (!this.title) {
317
327
  const sessionId = this.sessionId;
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "id": "claude",
4
4
  "displayName": "Claude",
5
5
  "description": "Claude SDK Agent:Coda 基于 Claude Agent SDK 写的 ACP 入口,一个进程一个 SDK 会话;资源页的「Claude」面板显示会话报告的额度,可从 Coda 登录。",
6
- "version": "1.4.0",
6
+ "version": "1.5.0",
7
7
  "dependencies": {
8
8
  "@agentclientprotocol/sdk": "1.4.0",
9
9
  "@anthropic-ai/claude-agent-sdk": "0.3.296"
@@ -35,12 +35,16 @@
35
35
  "configuration": {
36
36
  "title": "Claude",
37
37
  "properties": {
38
- "claude.modelProvider": {
39
- "type": "string",
40
- "format": "coda-model-provider",
38
+ "claude.modelProviders": {
39
+ "type": "array",
40
+ "items": {
41
+ "type": "string",
42
+ "format": "coda-model-provider",
43
+ "modelApis": ["anthropic-messages"]
44
+ },
41
45
  "title": "模型服务",
42
46
  "order": 1,
43
- "description": "选一个插件提供的模型服务(如 Magpie),它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中它的模型,那个会话就走这个服务,Claude 自己的模型照旧。只接 Anthropic 接口(anthropic-messages)的服务。"
47
+ "description": "勾选插件提供的模型服务(如 Magpie),每个服务各成一组,它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中哪个服务的模型,那个会话就走那个服务,Claude 自己的模型照旧。只列 Anthropic 接口(anthropic-messages)的服务。"
44
48
  }
45
49
  }
46
50
  },