@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 +14 -1
- package/agent/providers.mjs +27 -25
- package/agent/session.mjs +24 -14
- package/package.json +9 -5
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),
|
|
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
|
}
|
package/agent/providers.mjs
CHANGED
|
@@ -1,38 +1,39 @@
|
|
|
1
|
-
// The model
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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.
|
|
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
|
|
14
|
-
//
|
|
15
|
-
export function
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|
28
|
-
// model
|
|
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:
|
|
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
|
|
49
|
-
export async function listedModels(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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
|
|
63
|
-
this.
|
|
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 }) => {
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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())
|
|
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
|
|
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.
|
|
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.
|
|
39
|
-
"type": "
|
|
40
|
-
"
|
|
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": "
|
|
47
|
+
"description": "勾选插件提供的模型服务(如 Magpie),每个服务各成一组,它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中哪个服务的模型,那个会话就走那个服务,Claude 自己的模型照旧。只列 Anthropic 接口(anthropic-messages)的服务。"
|
|
44
48
|
}
|
|
45
49
|
}
|
|
46
50
|
},
|