@coda-console/claude 1.5.2 → 1.6.1
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/errors.mjs +11 -0
- package/agent/session.mjs +80 -14
- package/agent/waits.mjs +5 -2
- package/package.json +1 -1
package/agent/errors.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { RequestError } from '@agentclientprotocol/sdk';
|
|
2
|
+
|
|
3
|
+
// Only failed turns come here. Claude Code's synthetic assistant error and the subscription's rejected window are
|
|
4
|
+
// evidence, ordinary assistant text and a bare HTTP 429 are not. Keep the provider's reset time/recovery advice intact.
|
|
5
|
+
const exhausted = message => /^(?:You've (?:hit|reached) your (?:session|weekly|5-hour|7-day|usage) limit\b|(?:Your )?[Cc]redit balance is too low\b|You're out of (?:usage credits|extra usage)\b|Your org is out of usage\b)/.test(message);
|
|
6
|
+
|
|
7
|
+
export function claudeFailure(message, { error, limits } = {}) {
|
|
8
|
+
const quota = (!error || ['rate_limit', 'billing_error'].includes(error)) &&
|
|
9
|
+
(exhausted(message) || (error === 'rate_limit' && limits?.status === 'rejected'));
|
|
10
|
+
return new RequestError(-32603, message, quota ? { _meta: { coda: { failure: { kind: 'quota_exhausted' } } } } : undefined);
|
|
11
|
+
}
|
package/agent/session.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { realpath, stat } from 'node:fs/promises';
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import { agent, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
|
|
12
12
|
import { ClaudeSessionLimits, accountHash } from './limits.mjs';
|
|
13
|
+
import { claudeFailure } from './errors.mjs';
|
|
13
14
|
import { sdkEnvironment } from './auth.mjs';
|
|
14
15
|
import { chosenServices, listedModels, serviceSettings } from './providers.mjs';
|
|
15
16
|
import { SessionTitle } from './titles.mjs';
|
|
@@ -41,6 +42,8 @@ const denied = { behavior: 'deny', message: 'Coda 没有在请求结束前给出
|
|
|
41
42
|
// A child session's output that comes before its task, kept until the task names that session: all of it, since what streamed
|
|
42
43
|
// counts as said, a message's streamed pieces as one.
|
|
43
44
|
const CHUNKS = new Set(['agent_message_chunk', 'agent_thought_chunk']);
|
|
45
|
+
// How many messages sent into a prompt (steer) may be waiting to be taken at once.
|
|
46
|
+
const STEERING_LIMIT = 32;
|
|
44
47
|
|
|
45
48
|
class InputStream {
|
|
46
49
|
queue = []; wake = null; ended = false;
|
|
@@ -59,6 +62,9 @@ export class ClaudeSession {
|
|
|
59
62
|
constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
|
|
60
63
|
Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
|
|
61
64
|
this.phase = 'new'; this.input = new InputStream(); this.prompts = new Map(); this.withdrawals = new Set();
|
|
65
|
+
// The messages sent into the prompt's turn not yet taken (steer), the prompt whose own turn ended before they were, how
|
|
66
|
+
// the prompt ends once it is ending before they are taken (dropRest), and whether a native turn is going.
|
|
67
|
+
this.steering = new Set(); this.held = null; this.ending = null; this.turning = false;
|
|
62
68
|
// The model services chosen in Coda (providers.mjs), and the service's model the query runs on: null on Claude's own.
|
|
63
69
|
this.services = chosenServices(env); this.serviceModel = null;
|
|
64
70
|
this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
|
|
@@ -70,18 +76,22 @@ export class ClaudeSession {
|
|
|
70
76
|
connect(stream) {
|
|
71
77
|
this.connection = agent({ name: 'coda-claude' })
|
|
72
78
|
.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: {} } } }
|
|
79
|
+
agentCapabilities: { loadSession: true, sessionCapabilities: { additionalDirectories: {}, systemPrompt: {} }, _meta: { coda: { waits: {} } } },
|
|
80
|
+
_meta: { steering: { supported: true } } }))
|
|
74
81
|
// The SDK's own reading of these drops what its schema does not name, `systemPrompt` among them: they are read as sent.
|
|
75
82
|
.onRequest('session/new', params => params ?? {}, ({ params }) => this.open(params))
|
|
76
83
|
.onRequest('session/load', params => params ?? {}, ({ params }) => this.open(params, params.sessionId))
|
|
77
84
|
.onRequest('session/set_config_option', ({ params }) => this.configure(params.configId, params.value))
|
|
78
85
|
.onRequest('session/set_mode', async ({ params }) => { await this.configure('mode', params.modeId); return {}; })
|
|
79
86
|
.onRequest('session/prompt', ({ params }) => this.prompt(params))
|
|
87
|
+
.onRequest('_session/steering', params => params ?? {}, ({ params }) => this.steer(params))
|
|
80
88
|
.onNotification('session/cancel', ({ params }) => {
|
|
81
89
|
if (params.sessionId !== this.sessionId) return;
|
|
82
90
|
// While the session moves to another query there is none to interrupt: the turn waiting on the move is stopped there.
|
|
83
91
|
if (this.rerouting) this.cancelling = true;
|
|
84
|
-
|
|
92
|
+
// Messages sent into the turn and not taken yet (steer) are stopped with it: else Claude Code would run them after.
|
|
93
|
+
if (this.steering.size) this.dropRest({ stopReason: 'cancelled' });
|
|
94
|
+
else void this.query?.interrupt().catch(() => {});
|
|
85
95
|
})
|
|
86
96
|
.onRequest('_coda/wait/stop', params => params, ({ params }) => this.stopWait(params))
|
|
87
97
|
.connect(stream);
|
|
@@ -243,9 +253,46 @@ export class ClaudeSession {
|
|
|
243
253
|
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
244
254
|
const answered = new Promise((resolve, reject) => this.prompts.set(id, { resolve, reject }));
|
|
245
255
|
this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content } });
|
|
256
|
+
this.heard(content);
|
|
257
|
+
return answered;
|
|
258
|
+
}
|
|
259
|
+
// A message into the turn going (docs/agent-protocol.md「插话」): Claude Code takes one sent with priority `next` into the
|
|
260
|
+
// 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
|
|
261
|
+
// 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.
|
|
262
|
+
// 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
|
|
263
|
+
// named; one more is Coda's to send as the next turn, as it is with no turn going.
|
|
264
|
+
steer({ prompt }) {
|
|
265
|
+
if (this.phase !== 'running' || !this.prompts.size || this.ending) return { outcome: 'promptRequired', reason: 'noRunningTurn' };
|
|
266
|
+
if (this.steering.size >= STEERING_LIMIT) return { outcome: 'promptRequired', reason: 'tooManyPending' };
|
|
267
|
+
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
268
|
+
this.steering.add(id);
|
|
269
|
+
this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, priority: 'next', message: { role: 'user', content } });
|
|
270
|
+
this.heard(content);
|
|
271
|
+
return { outcome: 'injected' };
|
|
272
|
+
}
|
|
273
|
+
// 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
|
|
274
|
+
// (cancelQueued), and the prompt ends as `outcome` once nothing of it goes on — at the drop's receipt when no native turn
|
|
275
|
+
// is going (one held, whose own turn ended), else as the turn going ends (result).
|
|
276
|
+
dropRest(outcome) {
|
|
277
|
+
this.ending = outcome;
|
|
278
|
+
void this.query?.interrupt({ cancelQueued: true }).then(receipt => {
|
|
279
|
+
for (const id of receipt?.cancelled ?? []) this.steering.delete(id);
|
|
280
|
+
if (this.held && !this.turning) this.settle(this.held, this.ending);
|
|
281
|
+
}, () => {});
|
|
282
|
+
}
|
|
283
|
+
// The prompt answered: as it ended, and with nothing of it left to take.
|
|
284
|
+
settle(id, { stopReason, failure: error }) {
|
|
285
|
+
const prompt = this.prompts.get(id);
|
|
286
|
+
this.prompts.delete(id); this.held = null; this.ending = null; this.steering.clear();
|
|
287
|
+
if (!prompt) return;
|
|
288
|
+
// What ended before this turn ended was taken in by it.
|
|
289
|
+
this.waits.settled();
|
|
290
|
+
if (error) prompt.reject(error); else prompt.resolve({ stopReason });
|
|
291
|
+
}
|
|
292
|
+
// What is said to the session goes into what its title is made of.
|
|
293
|
+
heard(content) {
|
|
246
294
|
this.titleContext = ((this.titleContext ?? '') + '\n' + content + '\n').slice(-1000);
|
|
247
295
|
if (this.title) { this.title.append(this.titleContext); this.titleContext = ''; }
|
|
248
|
-
return answered;
|
|
249
296
|
}
|
|
250
297
|
async stopWait({ waitId }) {
|
|
251
298
|
const taskId = this.phase === 'running' ? this.waits.stop(waitId) : null;
|
|
@@ -296,7 +343,7 @@ export class ClaudeSession {
|
|
|
296
343
|
end(query, error) {
|
|
297
344
|
if (this.phase === 'ended') return;
|
|
298
345
|
const intended = this.phase === 'closing';
|
|
299
|
-
this.phase = 'ended'; this.input.close();
|
|
346
|
+
this.phase = 'ended'; this.input.close(); this.steering.clear(); this.held = null; this.ending = null;
|
|
300
347
|
for (const withdrawn of this.withdrawals) withdrawn.abort();
|
|
301
348
|
for (const { reject } of this.prompts.values()) reject(failure(error?.message ?? 'Claude 会话已结束'));
|
|
302
349
|
this.prompts.clear();
|
|
@@ -312,13 +359,17 @@ export class ClaudeSession {
|
|
|
312
359
|
case 'assistant': return this.assistant(m, parent);
|
|
313
360
|
case 'user': return this.toolResults(m, parent);
|
|
314
361
|
case 'result': return this.result(m);
|
|
315
|
-
case 'rate_limit_event':
|
|
362
|
+
case 'rate_limit_event':
|
|
363
|
+
this.turnLimits = m.rate_limit_info;
|
|
364
|
+
if (this.env.CODA_PLUGIN_STATE) new ClaudeSessionLimits({ path: join(this.env.CODA_PLUGIN_STATE, 'limits.json') }).observe(m.rate_limit_info, this.account ?? null);
|
|
316
365
|
}
|
|
317
366
|
}
|
|
318
367
|
system(m) {
|
|
319
368
|
switch (m.subtype) {
|
|
320
369
|
case 'init': {
|
|
321
370
|
if (m.session_id !== this.sessionId) return;
|
|
371
|
+
this.turning = true;
|
|
372
|
+
this.turnError = null; this.turnLimits = null;
|
|
322
373
|
this.terminal = m.terminal_slash_commands ?? [];
|
|
323
374
|
// Its effort is the one applied, the model's own included: what was chosen stays what it was. A query on the
|
|
324
375
|
// service's model reports it by that service's name for it.
|
|
@@ -415,6 +466,13 @@ export class ClaudeSession {
|
|
|
415
466
|
assistant(m, parent) {
|
|
416
467
|
const sessionId = parent ?? this.sessionId, apiId = m.message?.id, blocks = this.streamed.get(apiId);
|
|
417
468
|
if (parent) this.waits.model(parent, m.message?.model);
|
|
469
|
+
// API failures are synthetic assistant messages, followed by a result (even subtype "success" can be is_error).
|
|
470
|
+
// The result ends the turn and reports its failure; sending the synthetic message as prose would look like an answer.
|
|
471
|
+
// A child's error belongs to that child, never to the parent's prompt.
|
|
472
|
+
if (!parent) {
|
|
473
|
+
this.turnError = m.error ? { error: m.error, message: text(m.message?.content) } : null;
|
|
474
|
+
if (m.error) return;
|
|
475
|
+
}
|
|
418
476
|
for (const block of m.message?.content ?? []) {
|
|
419
477
|
if (block.type === 'tool_use') {
|
|
420
478
|
this.tools.add(block.id);
|
|
@@ -444,20 +502,28 @@ export class ClaudeSession {
|
|
|
444
502
|
const usage = m.usage ?? {}, windows = Object.values(m.modelUsage ?? {});
|
|
445
503
|
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
504
|
size: Math.max(0, ...windows.map(w => w.contextWindow ?? 0)), cost: { amount: m.total_cost_usd ?? 0, currency: 'USD' } });
|
|
447
|
-
|
|
505
|
+
this.turning = false;
|
|
506
|
+
const ids = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : []);
|
|
507
|
+
// 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
|
|
508
|
+
// own ended before taking it: the prompt is answered once none is left to take. Stopped or failed with some not taken
|
|
509
|
+
// yet, it ends once those are dropped (dropRest); one ending already ends as it was to.
|
|
510
|
+
const steered = ids.filter(id => this.steering.delete(id)).length > 0;
|
|
511
|
+
const id = ids.find(id => this.prompts.has(id)) ?? (steered ? this.held : undefined);
|
|
448
512
|
const cancelled = interrupted(m), failed = !cancelled && (m.is_error || m.subtype !== 'success');
|
|
513
|
+
const error = failed ? claudeFailure(m.errors?.length || m.result ? errorText(m) : this.turnError?.message || errorText(m),
|
|
514
|
+
{ error: this.turnError?.error, limits: this.turnLimits }) : null;
|
|
515
|
+
this.turnError = null; this.turnLimits = null;
|
|
449
516
|
if (id) {
|
|
450
|
-
const
|
|
451
|
-
|
|
452
|
-
this.
|
|
453
|
-
if (failed) prompt.reject(failure(errorText(m))); else prompt.resolve({ stopReason: cancelled ? 'cancelled' : 'end_turn' });
|
|
517
|
+
const outcome = this.ending ?? (failed ? { failure: error } : { stopReason: cancelled ? 'cancelled' : 'end_turn' });
|
|
518
|
+
if (this.ending || !this.steering.size) this.settle(id, outcome);
|
|
519
|
+
else { this.held = id; if (cancelled || failed) this.dropRest(outcome); }
|
|
454
520
|
} else if (this.waits.handling) {
|
|
455
|
-
// A turn of Claude's own has no reply to fail
|
|
456
|
-
|
|
457
|
-
this.waits.finished();
|
|
521
|
+
// A turn of Claude's own has no RPC reply to fail. End its own activity as failed with the reason, rather than
|
|
522
|
+
// saying the error as an answer and marking the work complete. The task that woke it retains its own outcome.
|
|
523
|
+
this.waits.finished(error ? { error: error.message, title: error.data?._meta?.coda?.failure?.kind === 'quota_exhausted' ? '额度不足' : undefined } : {});
|
|
458
524
|
} else if (failed && this.prompts.size) {
|
|
459
525
|
// A failure of the session itself names no input: none of those given can be said to have run.
|
|
460
|
-
for (const prompt of this.prompts.values()) prompt.reject(
|
|
526
|
+
for (const prompt of this.prompts.values()) prompt.reject(error);
|
|
461
527
|
this.prompts.clear();
|
|
462
528
|
}
|
|
463
529
|
void this.title?.refresh(!failed && !cancelled);
|
package/agent/waits.mjs
CHANGED
|
@@ -161,7 +161,10 @@ export class ClaudeWaits {
|
|
|
161
161
|
this.report(own, 'handling', { kind: 'task', title: 'Claude 自主继续', stoppable: false });
|
|
162
162
|
}
|
|
163
163
|
// That turn's end: what it handled ended with it, a recurring wakeup waits for its next time.
|
|
164
|
-
finished() {
|
|
164
|
+
finished({ error, title } = {}) {
|
|
165
|
+
// Processing a successful task's result can fail. Give that failure its own activity instead of changing the task's
|
|
166
|
+
// outcome, and leave recurring wakeups scheduled. There may already be a turn-owned activity (hold).
|
|
167
|
+
if (error && !this.own.size) this.hold();
|
|
165
168
|
for (const task of this.tasks.values()) if (task.state === 'handling') { this.report(task, task.final ?? 'done'); task.final = undefined; }
|
|
166
169
|
for (const cron of this.crons.values()) {
|
|
167
170
|
if (cron.state !== 'handling') continue;
|
|
@@ -169,7 +172,7 @@ export class ClaudeWaits {
|
|
|
169
172
|
cron.at = nextRun(cron.schedule, this.now());
|
|
170
173
|
this.report(cron, 'waiting', { at: cron.at === null ? undefined : new Date(cron.at).toISOString() });
|
|
171
174
|
}
|
|
172
|
-
for (const own of this.own) this.report(own, 'done');
|
|
175
|
+
for (const own of this.own) this.report(own, error ? 'failed' : 'done', error ? { title, detail: cut(error, 4000) } : {});
|
|
173
176
|
this.own.clear();
|
|
174
177
|
}
|
|
175
178
|
// A stop Coda asks of a wait: the task to stop when it still goes (null: none). One that ended and is yet to be taken in is
|
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.1",
|
|
7
7
|
"categories": ["Agent"],
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@agentclientprotocol/sdk": "1.4.0",
|