@coda-console/claude 1.5.1 → 1.6.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/last-listed.mjs +34 -0
- package/agent/providers.mjs +19 -12
- package/agent/session.mjs +62 -10
- package/package.json +1 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// The same file in the Claude, Codex and OpenCode plugins: an agent plugin imports nothing of another's.
|
|
2
|
+
// What a model service listed when it last answered, kept in the plugin's state directory (CODA_PLUGIN_STATE) under the
|
|
3
|
+
// service's name. One that cannot be asked now (its machine restarting, the network to it down) is listed as it last said
|
|
4
|
+
// rather than left out of the session's models, and of Coda's catalog of them for as long as that is kept; a request to it
|
|
5
|
+
// fails then as any request to it would. One never listed here, or without a state directory, keeps its asking's error.
|
|
6
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const file = (state, ref) => join(state, 'service-models', `${ref.replaceAll('/', '.')}.json`);
|
|
10
|
+
const readText = path => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
|
|
11
|
+
const kept = path => { try { const models = JSON.parse(readText(path)); return Array.isArray(models) ? models : null; } catch { return null; } };
|
|
12
|
+
|
|
13
|
+
// `ask()`: the models the service lists now.
|
|
14
|
+
export async function lastListed(ref, ask, state, log) {
|
|
15
|
+
let models;
|
|
16
|
+
try { models = await ask(); } catch (error) {
|
|
17
|
+
const last = state ? kept(file(state, ref)) : null;
|
|
18
|
+
if (!last) throw error;
|
|
19
|
+
log(`模型服务 ${ref} 列不出模型(${error.message}),沿用上次列出的 ${last.length} 个`);
|
|
20
|
+
return last;
|
|
21
|
+
}
|
|
22
|
+
if (state) remember(file(state, ref), JSON.stringify(models));
|
|
23
|
+
return models;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Written when it is not that already, whole: another session's process may read it meanwhile.
|
|
27
|
+
function remember(path, text) {
|
|
28
|
+
if (readText(path) === text) return;
|
|
29
|
+
try {
|
|
30
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
31
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
32
|
+
writeFileSync(temporary, text); renameSync(temporary, path);
|
|
33
|
+
} catch { /* Not kept: what the service listed still stands. */ }
|
|
34
|
+
}
|
package/agent/providers.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
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
|
+
import { lastListed } from './last-listed.mjs';
|
|
7
8
|
|
|
8
9
|
export const SETTING = 'claude.modelProviders';
|
|
9
10
|
const log = text => process.stderr.write(`[claude-agent] ${text}\n`);
|
|
@@ -25,15 +26,20 @@ export function chosenServices(env = process.env) {
|
|
|
25
26
|
});
|
|
26
27
|
}
|
|
27
28
|
|
|
28
|
-
// The models a service lists, asked as an Anthropic client asks (Coda's「检查可用模型」)
|
|
29
|
-
// model, in the service's group. They have no
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
29
|
+
// The models a service lists, asked as an Anthropic client asks (Coda's「检查可用模型」), or as it last listed them when
|
|
30
|
+
// it cannot be asked now (last-listed.mjs): `<服务>/<模型>`, named by the model, in the service's group. They have no
|
|
31
|
+
// effort to choose.
|
|
32
|
+
export async function serviceModels({ ref, title, url, key }, fetcher = fetch, state) {
|
|
33
|
+
const listed = await lastListed(ref, async () => {
|
|
34
|
+
const at = `${url.replace(/\/+$/, '')}/models?limit=1000`;
|
|
35
|
+
const response = await fetcher(at, { headers: key ? { 'x-api-key': key, 'anthropic-version': '2023-06-01' } : {}, signal: AbortSignal.timeout(LIST_TIMEOUT) });
|
|
36
|
+
if (!response.ok) throw new Error(`${at} 回答 ${response.status}`);
|
|
37
|
+
const body = await response.json();
|
|
38
|
+
// Only a list replaces the one kept: an answer of another shape is the service not answering.
|
|
39
|
+
if (!Array.isArray(body?.data)) throw new Error(`${at} 的回答不是模型列表`);
|
|
40
|
+
return body.data.filter(model => typeof model?.id === 'string' && model.id);
|
|
41
|
+
}, state, log);
|
|
42
|
+
return listed.map(model => ({ id: `${ref}/${model.id}`, name: model.id, efforts: [], model: model.id, service: ref, group: title }));
|
|
37
43
|
}
|
|
38
44
|
|
|
39
45
|
// The flag settings Claude Code runs a service's model with (above the machine's and the project's settings, its environment
|
|
@@ -46,10 +52,11 @@ export function serviceSettings({ url, key }, model) {
|
|
|
46
52
|
CLAUDE_CODE_SUBAGENT_MODEL: model } };
|
|
47
53
|
}
|
|
48
54
|
|
|
49
|
-
// The services' models, listed with Claude's own in the order the services are chosen: none of one that cannot list them
|
|
50
|
-
|
|
55
|
+
// The services' models, listed with Claude's own in the order the services are chosen: none of one that cannot list them
|
|
56
|
+
// now and never did (`state`: the plugin's state directory).
|
|
57
|
+
export async function listedModels(services, state) {
|
|
51
58
|
return (await Promise.all(services.map(async service => {
|
|
52
|
-
try { return await serviceModels(service); }
|
|
59
|
+
try { return await serviceModels(service, fetch, state); }
|
|
53
60
|
catch (e) { log(`模型服务 ${service.ref} 列不出模型:${e.message}`); return []; }
|
|
54
61
|
}))).flat();
|
|
55
62
|
}
|
package/agent/session.mjs
CHANGED
|
@@ -41,6 +41,8 @@ const denied = { behavior: 'deny', message: 'Coda 没有在请求结束前给出
|
|
|
41
41
|
// A child session's output that comes before its task, kept until the task names that session: all of it, since what streamed
|
|
42
42
|
// counts as said, a message's streamed pieces as one.
|
|
43
43
|
const CHUNKS = new Set(['agent_message_chunk', 'agent_thought_chunk']);
|
|
44
|
+
// How many messages sent into a prompt (steer) may be waiting to be taken at once.
|
|
45
|
+
const STEERING_LIMIT = 32;
|
|
44
46
|
|
|
45
47
|
class InputStream {
|
|
46
48
|
queue = []; wake = null; ended = false;
|
|
@@ -59,6 +61,9 @@ export class ClaudeSession {
|
|
|
59
61
|
constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
|
|
60
62
|
Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
|
|
61
63
|
this.phase = 'new'; this.input = new InputStream(); this.prompts = new Map(); this.withdrawals = new Set();
|
|
64
|
+
// The messages sent into the prompt's turn not yet taken (steer), the prompt whose own turn ended before they were, how
|
|
65
|
+
// the prompt ends once it is ending before they are taken (dropRest), and whether a native turn is going.
|
|
66
|
+
this.steering = new Set(); this.held = null; this.ending = null; this.turning = false;
|
|
62
67
|
// The model services chosen in Coda (providers.mjs), and the service's model the query runs on: null on Claude's own.
|
|
63
68
|
this.services = chosenServices(env); this.serviceModel = null;
|
|
64
69
|
this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
|
|
@@ -70,18 +75,22 @@ export class ClaudeSession {
|
|
|
70
75
|
connect(stream) {
|
|
71
76
|
this.connection = agent({ name: 'coda-claude' })
|
|
72
77
|
.onRequest('initialize', () => ({ protocolVersion: PROTOCOL_VERSION, agentInfo: SDK ? { name: 'claude-agent-sdk', version: SDK } : { name: 'coda-claude', version: '1' },
|
|
73
|
-
agentCapabilities: { loadSession: true, sessionCapabilities: { additionalDirectories: {}, systemPrompt: {} }, _meta: { coda: { waits: {} } } }
|
|
78
|
+
agentCapabilities: { loadSession: true, sessionCapabilities: { additionalDirectories: {}, systemPrompt: {} }, _meta: { coda: { waits: {} } } },
|
|
79
|
+
_meta: { steering: { supported: true } } }))
|
|
74
80
|
// The SDK's own reading of these drops what its schema does not name, `systemPrompt` among them: they are read as sent.
|
|
75
81
|
.onRequest('session/new', params => params ?? {}, ({ params }) => this.open(params))
|
|
76
82
|
.onRequest('session/load', params => params ?? {}, ({ params }) => this.open(params, params.sessionId))
|
|
77
83
|
.onRequest('session/set_config_option', ({ params }) => this.configure(params.configId, params.value))
|
|
78
84
|
.onRequest('session/set_mode', async ({ params }) => { await this.configure('mode', params.modeId); return {}; })
|
|
79
85
|
.onRequest('session/prompt', ({ params }) => this.prompt(params))
|
|
86
|
+
.onRequest('_session/steering', params => params ?? {}, ({ params }) => this.steer(params))
|
|
80
87
|
.onNotification('session/cancel', ({ params }) => {
|
|
81
88
|
if (params.sessionId !== this.sessionId) return;
|
|
82
89
|
// While the session moves to another query there is none to interrupt: the turn waiting on the move is stopped there.
|
|
83
90
|
if (this.rerouting) this.cancelling = true;
|
|
84
|
-
|
|
91
|
+
// Messages sent into the turn and not taken yet (steer) are stopped with it: else Claude Code would run them after.
|
|
92
|
+
if (this.steering.size) this.dropRest({ stopReason: 'cancelled' });
|
|
93
|
+
else void this.query?.interrupt().catch(() => {});
|
|
85
94
|
})
|
|
86
95
|
.onRequest('_coda/wait/stop', params => params, ({ params }) => this.stopWait(params))
|
|
87
96
|
.connect(stream);
|
|
@@ -146,7 +155,7 @@ export class ClaudeSession {
|
|
|
146
155
|
onElicitation: (request, context) => this.elicitation(request, context),
|
|
147
156
|
stderr: data => process.stderr.write(data),
|
|
148
157
|
};
|
|
149
|
-
const listed = listedModels(this.services);
|
|
158
|
+
const listed = listedModels(this.services, this.env.CODA_PLUGIN_STATE);
|
|
150
159
|
this.start(resume ? { resume } : { sessionId: this.sessionId });
|
|
151
160
|
const initialization = await this.initialized(), served = await listed;
|
|
152
161
|
if (this.phase !== 'starting') throw failure('Claude 会话在初始化时结束', -32001);
|
|
@@ -243,9 +252,46 @@ export class ClaudeSession {
|
|
|
243
252
|
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
244
253
|
const answered = new Promise((resolve, reject) => this.prompts.set(id, { resolve, reject }));
|
|
245
254
|
this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content } });
|
|
255
|
+
this.heard(content);
|
|
256
|
+
return answered;
|
|
257
|
+
}
|
|
258
|
+
// A message into the turn going (docs/agent-protocol.md「插话」): Claude Code takes one sent with priority `next` into the
|
|
259
|
+
// turn it runs at its next tool round, and runs one the turn ended before as a turn of its own right after, which the
|
|
260
|
+
// prompt waits for too (result): either way it is that prompt's. With no prompt of Coda's going, Coda sends it as the next.
|
|
261
|
+
// A result names at most 64 of the messages it took in (the SDK's user_message_uuids): few enough go in at a time that each is
|
|
262
|
+
// named; one more is Coda's to send as the next turn, as it is with no turn going.
|
|
263
|
+
steer({ prompt }) {
|
|
264
|
+
if (this.phase !== 'running' || !this.prompts.size || this.ending) return { outcome: 'promptRequired', reason: 'noRunningTurn' };
|
|
265
|
+
if (this.steering.size >= STEERING_LIMIT) return { outcome: 'promptRequired', reason: 'tooManyPending' };
|
|
266
|
+
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
267
|
+
this.steering.add(id);
|
|
268
|
+
this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, priority: 'next', message: { role: 'user', content } });
|
|
269
|
+
this.heard(content);
|
|
270
|
+
return { outcome: 'injected' };
|
|
271
|
+
}
|
|
272
|
+
// What was said into the prompt and is not taken yet is not to run after it ends, stopped or failed: Claude Code drops it
|
|
273
|
+
// (cancelQueued), and the prompt ends as `outcome` once nothing of it goes on — at the drop's receipt when no native turn
|
|
274
|
+
// is going (one held, whose own turn ended), else as the turn going ends (result).
|
|
275
|
+
dropRest(outcome) {
|
|
276
|
+
this.ending = outcome;
|
|
277
|
+
void this.query?.interrupt({ cancelQueued: true }).then(receipt => {
|
|
278
|
+
for (const id of receipt?.cancelled ?? []) this.steering.delete(id);
|
|
279
|
+
if (this.held && !this.turning) this.settle(this.held, this.ending);
|
|
280
|
+
}, () => {});
|
|
281
|
+
}
|
|
282
|
+
// The prompt answered: as it ended, and with nothing of it left to take.
|
|
283
|
+
settle(id, { stopReason, failure: error }) {
|
|
284
|
+
const prompt = this.prompts.get(id);
|
|
285
|
+
this.prompts.delete(id); this.held = null; this.ending = null; this.steering.clear();
|
|
286
|
+
if (!prompt) return;
|
|
287
|
+
// What ended before this turn ended was taken in by it.
|
|
288
|
+
this.waits.settled();
|
|
289
|
+
if (error) prompt.reject(failure(error)); else prompt.resolve({ stopReason });
|
|
290
|
+
}
|
|
291
|
+
// What is said to the session goes into what its title is made of.
|
|
292
|
+
heard(content) {
|
|
246
293
|
this.titleContext = ((this.titleContext ?? '') + '\n' + content + '\n').slice(-1000);
|
|
247
294
|
if (this.title) { this.title.append(this.titleContext); this.titleContext = ''; }
|
|
248
|
-
return answered;
|
|
249
295
|
}
|
|
250
296
|
async stopWait({ waitId }) {
|
|
251
297
|
const taskId = this.phase === 'running' ? this.waits.stop(waitId) : null;
|
|
@@ -296,7 +342,7 @@ export class ClaudeSession {
|
|
|
296
342
|
end(query, error) {
|
|
297
343
|
if (this.phase === 'ended') return;
|
|
298
344
|
const intended = this.phase === 'closing';
|
|
299
|
-
this.phase = 'ended'; this.input.close();
|
|
345
|
+
this.phase = 'ended'; this.input.close(); this.steering.clear(); this.held = null; this.ending = null;
|
|
300
346
|
for (const withdrawn of this.withdrawals) withdrawn.abort();
|
|
301
347
|
for (const { reject } of this.prompts.values()) reject(failure(error?.message ?? 'Claude 会话已结束'));
|
|
302
348
|
this.prompts.clear();
|
|
@@ -319,6 +365,7 @@ export class ClaudeSession {
|
|
|
319
365
|
switch (m.subtype) {
|
|
320
366
|
case 'init': {
|
|
321
367
|
if (m.session_id !== this.sessionId) return;
|
|
368
|
+
this.turning = true;
|
|
322
369
|
this.terminal = m.terminal_slash_commands ?? [];
|
|
323
370
|
// Its effort is the one applied, the model's own included: what was chosen stays what it was. A query on the
|
|
324
371
|
// service's model reports it by that service's name for it.
|
|
@@ -444,13 +491,18 @@ export class ClaudeSession {
|
|
|
444
491
|
const usage = m.usage ?? {}, windows = Object.values(m.modelUsage ?? {});
|
|
445
492
|
this.update(this.sessionId, { sessionUpdate: 'usage_update', used: (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0),
|
|
446
493
|
size: Math.max(0, ...windows.map(w => w.contextWindow ?? 0)), cost: { amount: m.total_cost_usd ?? 0, currency: 'USD' } });
|
|
447
|
-
|
|
494
|
+
this.turning = false;
|
|
495
|
+
const ids = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : []);
|
|
496
|
+
// A message sent into the prompt's turn (steer) is the prompt's, and so is the turn Claude Code runs it in when the prompt's
|
|
497
|
+
// own ended before taking it: the prompt is answered once none is left to take. Stopped or failed with some not taken
|
|
498
|
+
// yet, it ends once those are dropped (dropRest); one ending already ends as it was to.
|
|
499
|
+
const steered = ids.filter(id => this.steering.delete(id)).length > 0;
|
|
500
|
+
const id = ids.find(id => this.prompts.has(id)) ?? (steered ? this.held : undefined);
|
|
448
501
|
const cancelled = interrupted(m), failed = !cancelled && (m.is_error || m.subtype !== 'success');
|
|
449
502
|
if (id) {
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
this.
|
|
453
|
-
if (failed) prompt.reject(failure(errorText(m))); else prompt.resolve({ stopReason: cancelled ? 'cancelled' : 'end_turn' });
|
|
503
|
+
const outcome = this.ending ?? (failed ? { failure: errorText(m) } : { stopReason: cancelled ? 'cancelled' : 'end_turn' });
|
|
504
|
+
if (this.ending || !this.steering.size) this.settle(id, outcome);
|
|
505
|
+
else { this.held = id; if (cancelled || failed) this.dropRest(outcome); }
|
|
454
506
|
} else if (this.waits.handling) {
|
|
455
507
|
// A turn of Claude's own has no reply to fail: what went wrong is said in it.
|
|
456
508
|
if (failed) this.chunk(this.sessionId, 'text', m.uuid, errorText(m));
|
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.6.0",
|
|
7
7
|
"categories": ["Agent"],
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@agentclientprotocol/sdk": "1.4.0",
|