@coda-console/claude 1.3.1 → 1.4.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/auth.mjs +1 -1
- package/agent/providers.mjs +53 -0
- package/agent/session.mjs +79 -25
- package/agent/waits.mjs +2 -0
- package/package.json +13 -1
package/agent/auth.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { open } from 'node:fs/promises';
|
|
|
3
3
|
import { isAbsolute } from 'node:path';
|
|
4
4
|
|
|
5
5
|
// Variables that make the Claude CLI use something other than the user's normal login.
|
|
6
|
-
const authOverrides = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS',
|
|
6
|
+
export const authOverrides = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS',
|
|
7
7
|
'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN', 'CLAUDE_CODE_OAUTH_SCOPES',
|
|
8
8
|
'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'];
|
|
9
9
|
|
|
@@ -0,0 +1,53 @@
|
|
|
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.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { authOverrides } from './auth.mjs';
|
|
7
|
+
|
|
8
|
+
export const SETTING = 'claude.modelProvider';
|
|
9
|
+
const log = text => process.stderr.write(`[claude-agent] ${text}\n`);
|
|
10
|
+
const parse = text => { try { return JSON.parse(text || '{}'); } catch { return {}; } };
|
|
11
|
+
const LIST_TIMEOUT = 8000;
|
|
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 };
|
|
25
|
+
}
|
|
26
|
+
|
|
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.
|
|
29
|
+
export async function serviceModels({ ref, title, url, key }, fetcher = fetch) {
|
|
30
|
+
const at = `${url.replace(/\/+$/, '')}/models?limit=1000`;
|
|
31
|
+
const response = await fetcher(at, { headers: key ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
|
|
32
|
+
if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
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
|
+
}
|
|
37
|
+
|
|
38
|
+
// The flag settings Claude Code runs a service's model with (above the machine's and the project's settings, its environment
|
|
39
|
+
// beneath them): the service as its API, a token of the service's own (`none` without one), and the model for every role,
|
|
40
|
+
// subagents' and background work's included. None of the machine's own ways to the API goes with it: an API key, a key
|
|
41
|
+
// helper, an OAuth token or custom headers would be sent to the service, a cloud provider's would take the requests away.
|
|
42
|
+
export function serviceSettings({ url, key }, model) {
|
|
43
|
+
return { apiKeyHelper: '', env: { ...Object.fromEntries(authOverrides.map(name => [name, ''])), ANTHROPIC_BASE_URL: url, ANTHROPIC_AUTH_TOKEN: key ?? 'none',
|
|
44
|
+
ANTHROPIC_MODEL: model, ANTHROPIC_DEFAULT_OPUS_MODEL: model, ANTHROPIC_DEFAULT_SONNET_MODEL: model, ANTHROPIC_DEFAULT_HAIKU_MODEL: model,
|
|
45
|
+
CLAUDE_CODE_SUBAGENT_MODEL: model } };
|
|
46
|
+
}
|
|
47
|
+
|
|
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 []; }
|
|
53
|
+
}
|
package/agent/session.mjs
CHANGED
|
@@ -11,6 +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
15
|
import { SessionTitle } from './titles.mjs';
|
|
15
16
|
import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from './models.mjs';
|
|
16
17
|
import { permissionRequest } from './permissions.mjs';
|
|
@@ -58,6 +59,8 @@ export class ClaudeSession {
|
|
|
58
59
|
constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
|
|
59
60
|
Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
|
|
60
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;
|
|
61
64
|
this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
|
|
62
65
|
// Streamed text by API message, so that the complete message adds only what was not streamed; tool calls said.
|
|
63
66
|
this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
|
|
@@ -114,7 +117,8 @@ export class ClaudeSession {
|
|
|
114
117
|
// Coda's servers are stdio ones; the SDK keys them by name.
|
|
115
118
|
const servers = Object.fromEntries(mcpServers.filter(server => typeof server.command === 'string').map(({ name, command, args = [], env = [] }) =>
|
|
116
119
|
[name, { command, args, ...(env.length ? { env: Object.fromEntries(env.map(variable => [variable.name, variable.value])) } : {}) }]));
|
|
117
|
-
|
|
120
|
+
// What every query of the session starts with; a model of the chosen service runs in a query of its own (`reroute`).
|
|
121
|
+
this.launch = {
|
|
118
122
|
// Skipping every approval is a mode the user may switch to later; the SDK refuses it unless allowed at start.
|
|
119
123
|
permissionMode: 'default', allowDangerouslySkipPermissions: true, settingSources: ['user', 'project', 'local'],
|
|
120
124
|
// Coda's session instructions (what its clients render, what the project's plugins say) follow Claude Code's own.
|
|
@@ -124,7 +128,6 @@ export class ClaudeSession {
|
|
|
124
128
|
settings: { crossSessionInbound: 'refuse' },
|
|
125
129
|
includePartialMessages: true, forwardSubagentText: true, cwd: dir,
|
|
126
130
|
...(Object.keys(servers).length ? { mcpServers: servers } : {}), ...(additionalDirectories.length ? { additionalDirectories } : {}),
|
|
127
|
-
...(resume ? { resume } : { sessionId: this.sessionId }),
|
|
128
131
|
// Claude Code reports running and idle only when asked: idle is what ends a task's wait.
|
|
129
132
|
env: { ...environment, CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' },
|
|
130
133
|
// Stop supplies the complete list of scheduled wakeups. A CronCreate result alone cannot tell whether one was later
|
|
@@ -137,15 +140,12 @@ export class ClaudeSession {
|
|
|
137
140
|
canUseTool: (toolName, input, context) => this.permission(toolName, input, context),
|
|
138
141
|
onElicitation: (request, context) => this.elicitation(request, context),
|
|
139
142
|
stderr: data => process.stderr.write(data),
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const initialization = await
|
|
144
|
-
this.pump.then(() => { throw failure('Claude 会话在初始化时结束', -32001); }),
|
|
145
|
-
new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
|
|
146
|
-
.finally(() => clearTimeout(timer));
|
|
143
|
+
};
|
|
144
|
+
const listed = listedModels(this.service);
|
|
145
|
+
this.start(resume ? { resume } : { sessionId: this.sessionId });
|
|
146
|
+
const initialization = await this.initialized(), served = await listed;
|
|
147
147
|
if (this.phase !== 'starting') throw failure('Claude 会话在初始化时结束', -32001);
|
|
148
|
-
this.models = claudeModels(initialization.models); this.commands = initialization.commands ?? [];
|
|
148
|
+
this.models = [...claudeModels(initialization.models), ...served]; this.commands = initialization.commands ?? [];
|
|
149
149
|
// Who the session runs as, for the「Claude」panel (limits.mjs): the windows it reports are that account's, and
|
|
150
150
|
// only while the panel is of that account.
|
|
151
151
|
const email = initialization.account?.email;
|
|
@@ -159,6 +159,49 @@ export class ClaudeSession {
|
|
|
159
159
|
throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
|
|
160
160
|
}
|
|
161
161
|
}
|
|
162
|
+
// A query of the session, reading a prompt stream of its own: on Claude's own models, or on `model` of the chosen
|
|
163
|
+
// service, which it runs with that service as its API and that model for every role. One started after the session
|
|
164
|
+
// opened goes on with the model, mode and effort chosen.
|
|
165
|
+
start(at, model) {
|
|
166
|
+
this.input = new InputStream();
|
|
167
|
+
const entry = this.served(model);
|
|
168
|
+
const settings = { ...this.launch.settings, ...(this.current.effort ? { effortLevel: this.current.effort } : {}),
|
|
169
|
+
...(entry ? serviceSettings(this.service, entry.model) : {}) };
|
|
170
|
+
this.serviceModel = entry ? model : null;
|
|
171
|
+
this.query = this.sdk.query({ prompt: this.input, options: { ...this.launch, ...at, settings,
|
|
172
|
+
...(this.phase === 'running' ? { permissionMode: this.current.mode, model: entry ? entry.model : this.current.model } : {}) } });
|
|
173
|
+
this.pump = this.consume(this.query);
|
|
174
|
+
}
|
|
175
|
+
async initialized() {
|
|
176
|
+
let timer;
|
|
177
|
+
return Promise.race([this.query.initializationResult(),
|
|
178
|
+
this.pump.then(() => { throw failure('Claude 会话在初始化时结束', -32001); }),
|
|
179
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
|
|
180
|
+
.finally(() => clearTimeout(timer));
|
|
181
|
+
}
|
|
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
|
|
183
|
+
// query started for it, resuming what the last one said. Before a turn, as Coda sets a session's model before each.
|
|
184
|
+
async reroute() {
|
|
185
|
+
const previous = this.query;
|
|
186
|
+
this.query = null; this.input.close();
|
|
187
|
+
try { previous.close(); } catch {}
|
|
188
|
+
await this.pump;
|
|
189
|
+
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
190
|
+
// One that cannot be started, or does not start, ends the session, as one that ends of itself does: no later turn waits on it.
|
|
191
|
+
try {
|
|
192
|
+
const said = await this.sdk.getSessionInfo(this.sessionId, { dir: this.cwd });
|
|
193
|
+
this.start(said ? { resume: this.sessionId } : { sessionId: this.sessionId }, this.current.model);
|
|
194
|
+
if (this.title) this.title.query = this.query;
|
|
195
|
+
await this.initialized();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.end(this.query, error);
|
|
198
|
+
throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// The chosen service's model that `model` is; null for one of Claude's own.
|
|
202
|
+
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.
|
|
204
|
+
rerouted() { return (this.served(this.current.model)?.id ?? null) !== this.serviceModel; }
|
|
162
205
|
async configure(configId, value) {
|
|
163
206
|
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
164
207
|
if (configId === 'mode') {
|
|
@@ -166,7 +209,12 @@ export class ClaudeSession {
|
|
|
166
209
|
await this.query.setPermissionMode(value); this.current.mode = value;
|
|
167
210
|
} else if (configId === 'model') {
|
|
168
211
|
if (!this.models.some(model => model.id === value)) throw failure('模型不可用', -32602);
|
|
169
|
-
|
|
212
|
+
// Between Claude's own models the query switches; to or from the service's, the next turn runs in another one, and
|
|
213
|
+
// what this one still runs would end with it.
|
|
214
|
+
const switching = (this.served(value)?.id ?? null) !== this.serviceModel;
|
|
215
|
+
if (switching && (this.prompts.size || this.waits.pending)) throw failure('后台任务、子 Agent 或定时唤醒还没结束,结束后才能换到这个模型', -32602);
|
|
216
|
+
if (!switching && !this.serviceModel) await this.query.setModel(value);
|
|
217
|
+
this.current.model = value;
|
|
170
218
|
// An effort chosen for the last model that this one does not have is no choice of it: the model's own goes on.
|
|
171
219
|
if (this.current.effort !== null && !this.models.find(model => model.id === value).efforts.includes(this.current.effort)) {
|
|
172
220
|
await this.query.applyFlagSettings({ effortLevel: null }); this.current.effort = null;
|
|
@@ -179,8 +227,9 @@ export class ClaudeSession {
|
|
|
179
227
|
return { configOptions: this.options() };
|
|
180
228
|
}
|
|
181
229
|
// Answered when the native result that names this input arrives; accepting it into the input says nothing yet.
|
|
182
|
-
prompt({ prompt }) {
|
|
230
|
+
async prompt({ prompt }) {
|
|
183
231
|
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
232
|
+
if (this.rerouted()) await (this.rerouting ??= this.reroute().finally(() => { this.rerouting = null; }));
|
|
184
233
|
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
185
234
|
const answered = new Promise((resolve, reject) => this.prompts.set(id, { resolve, reject }));
|
|
186
235
|
this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content } });
|
|
@@ -222,23 +271,27 @@ export class ClaudeSession {
|
|
|
222
271
|
} catch { return fallback; } finally { signal?.removeEventListener('abort', withdraw); this.withdrawals.delete(withdrawn); }
|
|
223
272
|
}
|
|
224
273
|
|
|
225
|
-
async consume() {
|
|
274
|
+
async consume(query) {
|
|
226
275
|
let error;
|
|
227
276
|
try {
|
|
228
|
-
for await (const message of
|
|
277
|
+
for await (const message of query) {
|
|
229
278
|
try { this.receive(message); }
|
|
230
279
|
catch (failed) { process.stderr.write(`Claude 消息处理失败:${failed.message}\n`); }
|
|
231
280
|
}
|
|
232
281
|
} catch (value) { if (this.phase !== 'closing') error = value; }
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
282
|
+
// The session ends with its query; one it moved off, for another model's, ends with nothing to say.
|
|
283
|
+
if (query === this.query) this.end(query, error);
|
|
284
|
+
}
|
|
285
|
+
// The session ends with its query: what waits on it is let go, and the entry told unless it was closing.
|
|
286
|
+
end(query, error) {
|
|
287
|
+
if (this.phase === 'ended') return;
|
|
288
|
+
const intended = this.phase === 'closing';
|
|
289
|
+
this.phase = 'ended'; this.input.close();
|
|
290
|
+
for (const withdrawn of this.withdrawals) withdrawn.abort();
|
|
291
|
+
for (const { reject } of this.prompts.values()) reject(failure(error?.message ?? 'Claude 会话已结束'));
|
|
292
|
+
this.prompts.clear();
|
|
293
|
+
try { query?.close(); } catch {}
|
|
294
|
+
if (!intended) this.onEnded(error);
|
|
242
295
|
}
|
|
243
296
|
receive(m) {
|
|
244
297
|
const parent = m.parent_tool_use_id ?? null;
|
|
@@ -257,8 +310,9 @@ export class ClaudeSession {
|
|
|
257
310
|
case 'init': {
|
|
258
311
|
if (m.session_id !== this.sessionId) return;
|
|
259
312
|
this.terminal = m.terminal_slash_commands ?? [];
|
|
260
|
-
// Its effort is the one applied, the model's own included: what was chosen stays what it was.
|
|
261
|
-
|
|
313
|
+
// 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.
|
|
315
|
+
this.current = { ...this.current, model: this.serviceModel ?? reportedModel(this.models, this.current.model, m.model), mode: m.permissionMode ?? this.current.mode };
|
|
262
316
|
if (!this.title) {
|
|
263
317
|
const sessionId = this.sessionId;
|
|
264
318
|
this.title = new SessionTitle({ sdk: this.sdk, query: this.query, cwd: this.cwd, sessionId, alive: () => ['starting', 'running'].includes(this.phase),
|
package/agent/waits.mjs
CHANGED
|
@@ -21,6 +21,8 @@ export class ClaudeWaits {
|
|
|
21
21
|
this.models = new Map(); this.outputs = new Map();
|
|
22
22
|
}
|
|
23
23
|
get handling() { return this.own.size > 0 || [...this.tasks.values(), ...this.crons.values()].some(w => w.state === 'handling'); }
|
|
24
|
+
// Whether anything is still waited on: what ends with the Claude process that runs it.
|
|
25
|
+
get pending() { return this.own.size > 0 || [...this.tasks.values(), ...this.crons.values()].some(w => open(w.state)); }
|
|
24
26
|
report(wait, state, fields = {}) {
|
|
25
27
|
wait.state = state;
|
|
26
28
|
const params = { waitId: wait.waitId, state, ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) };
|
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.4.0",
|
|
7
7
|
"dependencies": {
|
|
8
8
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
9
9
|
"@anthropic-ai/claude-agent-sdk": "0.3.296"
|
|
@@ -32,6 +32,18 @@
|
|
|
32
32
|
"setupHint": "请在运行机配置 Claude SDK 认证;若指定了 token 文件,请检查路径和权限。"
|
|
33
33
|
}
|
|
34
34
|
],
|
|
35
|
+
"configuration": {
|
|
36
|
+
"title": "Claude",
|
|
37
|
+
"properties": {
|
|
38
|
+
"claude.modelProvider": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"format": "coda-model-provider",
|
|
41
|
+
"title": "模型服务",
|
|
42
|
+
"order": 1,
|
|
43
|
+
"description": "选一个插件提供的模型服务(如 Magpie),它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中它的模型,那个会话就走这个服务,Claude 自己的模型照旧。只接 Anthropic 接口(anthropic-messages)的服务。"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
35
47
|
"panels": [
|
|
36
48
|
{
|
|
37
49
|
"id": "limits",
|