@coda-console/claude 1.6.0 → 1.6.2
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 +28 -9
- package/agent/waits.mjs +24 -5
- 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
|
+
// What a turn says or does, as against what the session is (its settings, commands, title, usage).
|
|
46
|
+
const SAYING = new Set([...CHUNKS, 'tool_call', 'tool_call_update', 'plan']);
|
|
44
47
|
// How many messages sent into a prompt (steer) may be waiting to be taken at once.
|
|
45
48
|
const STEERING_LIMIT = 32;
|
|
46
49
|
|
|
@@ -108,6 +111,8 @@ export class ClaudeSession {
|
|
|
108
111
|
else early.push(update);
|
|
109
112
|
return;
|
|
110
113
|
}
|
|
114
|
+
// What a turn of Claude's own says comes after what it handles (`claim`).
|
|
115
|
+
if (sessionId === this.sessionId && SAYING.has(update.sessionUpdate)) this.waits.claim();
|
|
111
116
|
this.notify('session/update', { sessionId, update });
|
|
112
117
|
}
|
|
113
118
|
registered(sessionId) {
|
|
@@ -286,7 +291,7 @@ export class ClaudeSession {
|
|
|
286
291
|
if (!prompt) return;
|
|
287
292
|
// What ended before this turn ended was taken in by it.
|
|
288
293
|
this.waits.settled();
|
|
289
|
-
if (error) prompt.reject(
|
|
294
|
+
if (error) prompt.reject(error); else prompt.resolve({ stopReason });
|
|
290
295
|
}
|
|
291
296
|
// What is said to the session goes into what its title is made of.
|
|
292
297
|
heard(content) {
|
|
@@ -305,13 +310,14 @@ export class ClaudeSession {
|
|
|
305
310
|
this.begin();
|
|
306
311
|
// A subagent's request is in its session.
|
|
307
312
|
const sessionId = this.waits.childSession(context.agentID) ?? this.sessionId;
|
|
313
|
+
if (sessionId === this.sessionId) this.waits.claim();
|
|
308
314
|
return toolName === 'AskUserQuestion'
|
|
309
315
|
? this.ask('elicitation/create', questionForm({ sessionId, toolCallId: context.toolUseID, input }), signal, { behavior: 'deny', message: '问答已失效' })
|
|
310
316
|
: this.ask('session/request_permission', permissionRequest({ sessionId, toolName, input, context }), signal, denied);
|
|
311
317
|
}
|
|
312
318
|
elicitation(request, { signal }) {
|
|
313
319
|
if (!['starting', 'running'].includes(this.phase)) return Promise.resolve({ action: 'cancel' });
|
|
314
|
-
this.begin();
|
|
320
|
+
this.begin(); this.waits.claim();
|
|
315
321
|
return this.ask('elicitation/create', elicitationForm({ sessionId: this.sessionId, request }), signal, { action: 'cancel' });
|
|
316
322
|
}
|
|
317
323
|
// Asked of Coda until it answers; withdrawn (`$/cancel_request`) once the SDK no longer waits for the answer.
|
|
@@ -358,7 +364,9 @@ export class ClaudeSession {
|
|
|
358
364
|
case 'assistant': return this.assistant(m, parent);
|
|
359
365
|
case 'user': return this.toolResults(m, parent);
|
|
360
366
|
case 'result': return this.result(m);
|
|
361
|
-
case 'rate_limit_event':
|
|
367
|
+
case 'rate_limit_event':
|
|
368
|
+
this.turnLimits = m.rate_limit_info;
|
|
369
|
+
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);
|
|
362
370
|
}
|
|
363
371
|
}
|
|
364
372
|
system(m) {
|
|
@@ -366,6 +374,7 @@ export class ClaudeSession {
|
|
|
366
374
|
case 'init': {
|
|
367
375
|
if (m.session_id !== this.sessionId) return;
|
|
368
376
|
this.turning = true;
|
|
377
|
+
this.turnError = null; this.turnLimits = null;
|
|
369
378
|
this.terminal = m.terminal_slash_commands ?? [];
|
|
370
379
|
// Its effort is the one applied, the model's own included: what was chosen stays what it was. A query on the
|
|
371
380
|
// service's model reports it by that service's name for it.
|
|
@@ -462,6 +471,13 @@ export class ClaudeSession {
|
|
|
462
471
|
assistant(m, parent) {
|
|
463
472
|
const sessionId = parent ?? this.sessionId, apiId = m.message?.id, blocks = this.streamed.get(apiId);
|
|
464
473
|
if (parent) this.waits.model(parent, m.message?.model);
|
|
474
|
+
// API failures are synthetic assistant messages, followed by a result (even subtype "success" can be is_error).
|
|
475
|
+
// The result ends the turn and reports its failure; sending the synthetic message as prose would look like an answer.
|
|
476
|
+
// A child's error belongs to that child, never to the parent's prompt.
|
|
477
|
+
if (!parent) {
|
|
478
|
+
this.turnError = m.error ? { error: m.error, message: text(m.message?.content) } : null;
|
|
479
|
+
if (m.error) return;
|
|
480
|
+
}
|
|
465
481
|
for (const block of m.message?.content ?? []) {
|
|
466
482
|
if (block.type === 'tool_use') {
|
|
467
483
|
this.tools.add(block.id);
|
|
@@ -499,17 +515,20 @@ export class ClaudeSession {
|
|
|
499
515
|
const steered = ids.filter(id => this.steering.delete(id)).length > 0;
|
|
500
516
|
const id = ids.find(id => this.prompts.has(id)) ?? (steered ? this.held : undefined);
|
|
501
517
|
const cancelled = interrupted(m), failed = !cancelled && (m.is_error || m.subtype !== 'success');
|
|
518
|
+
const error = failed ? claudeFailure(m.errors?.length || m.result ? errorText(m) : this.turnError?.message || errorText(m),
|
|
519
|
+
{ error: this.turnError?.error, limits: this.turnLimits }) : null;
|
|
520
|
+
this.turnError = null; this.turnLimits = null;
|
|
502
521
|
if (id) {
|
|
503
|
-
const outcome = this.ending ?? (failed ? { failure:
|
|
522
|
+
const outcome = this.ending ?? (failed ? { failure: error } : { stopReason: cancelled ? 'cancelled' : 'end_turn' });
|
|
504
523
|
if (this.ending || !this.steering.size) this.settle(id, outcome);
|
|
505
524
|
else { this.held = id; if (cancelled || failed) this.dropRest(outcome); }
|
|
506
|
-
} else if (this.waits.handling) {
|
|
507
|
-
// A turn of Claude's own has no reply to fail
|
|
508
|
-
|
|
509
|
-
this.waits.finished();
|
|
525
|
+
} else if (this.waits.handling || this.waits.unclaimed) {
|
|
526
|
+
// A turn of Claude's own has no RPC reply to fail. End its own activity as failed with the reason, rather than
|
|
527
|
+
// saying the error as an answer and marking the work complete. The task that woke it retains its own outcome.
|
|
528
|
+
this.waits.finished(error ? { error: error.message, title: error.data?._meta?.coda?.failure?.kind === 'quota_exhausted' ? '额度不足' : undefined } : {});
|
|
510
529
|
} else if (failed && this.prompts.size) {
|
|
511
530
|
// A failure of the session itself names no input: none of those given can be said to have run.
|
|
512
|
-
for (const prompt of this.prompts.values()) prompt.reject(
|
|
531
|
+
for (const prompt of this.prompts.values()) prompt.reject(error);
|
|
513
532
|
this.prompts.clear();
|
|
514
533
|
}
|
|
515
534
|
void this.title?.refresh(!failed && !cancelled);
|
package/agent/waits.mjs
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
// What a subagent launches (`parent_task_id`) is told in that subagent's session, once a wait has told Coda that session.
|
|
5
5
|
// A task that ends while no turn goes will wake Claude for its result: until a turn takes it up, or the session says idle,
|
|
6
6
|
// it is still waited on. A turn Claude begins on its own handles what woke it (`takeUp`): what its opening message names,
|
|
7
|
-
// else the tasks that ended, else the one wakeup due, else a wait of the turn's own
|
|
7
|
+
// else the tasks that ended, else the one wakeup due, else a wait of the turn's own, told only once the turn says or asks
|
|
8
|
+
// something (`claim`); what it handles ends with that turn.
|
|
8
9
|
const FINAL = { completed: 'done', failed: 'failed', killed: 'cancelled', stopped: 'cancelled', ended: 'done' };
|
|
9
10
|
const open = state => state === 'waiting' || state === 'handling';
|
|
10
11
|
const cut = (value, limit) => typeof value === 'string' && value ? value.slice(0, limit) : undefined;
|
|
@@ -146,6 +147,12 @@ export class ClaudeWaits {
|
|
|
146
147
|
let handled = [];
|
|
147
148
|
if (origin?.kind === 'task-notification') handled = origin.subkind === 'scheduled-trigger' ? crons.filter(cron => cron.prompt && cron.prompt === text)
|
|
148
149
|
: [...this.tasks.values()].filter(task => origin.runId && task.runId === origin.runId && open(task.state));
|
|
150
|
+
// A message a subagent sent (SendMessage: its report handed back, or word of how it goes) is that subagent's. Taken up for
|
|
151
|
+
// what it said, not for its end (`messaged`): the subagent ends as its end is told, not with this turn.
|
|
152
|
+
if (origin?.kind === 'peer') {
|
|
153
|
+
handled = [...this.tasks.values()].filter(task => origin.senderTaskId && task.taskId === origin.senderTaskId && open(task.state));
|
|
154
|
+
for (const task of handled) task.messaged = true;
|
|
155
|
+
}
|
|
149
156
|
if (!handled.length) handled = pending.sort((a, b) => a.endedAt - b.endedAt);
|
|
150
157
|
if (!handled.length) {
|
|
151
158
|
// Which came due is not told: the one due, or else the only one there is.
|
|
@@ -153,23 +160,35 @@ export class ClaudeWaits {
|
|
|
153
160
|
if ((due.length || crons.length) === 1) handled = due.length ? due : crons;
|
|
154
161
|
}
|
|
155
162
|
if (handled.length) for (const wait of handled) this.report(wait, 'handling');
|
|
156
|
-
|
|
163
|
+
// Nothing it names: told as a wait of its own once it says or asks something. One that says nothing is not told at all,
|
|
164
|
+
// as Claude Code's turn for the notice of a subagent whose report an earlier turn took in, which reaches no model.
|
|
165
|
+
else this.unclaimed = true;
|
|
157
166
|
}
|
|
167
|
+
claim() { if (this.unclaimed) { this.unclaimed = false; this.hold(); } }
|
|
158
168
|
hold() {
|
|
159
169
|
const own = { waitId: `turn-${++this.turns}` };
|
|
160
170
|
this.own.add(own);
|
|
161
171
|
this.report(own, 'handling', { kind: 'task', title: 'Claude 自主继续', stoppable: false });
|
|
162
172
|
}
|
|
163
173
|
// That turn's end: what it handled ended with it, a recurring wakeup waits for its next time.
|
|
164
|
-
finished() {
|
|
165
|
-
|
|
174
|
+
finished({ error, title } = {}) {
|
|
175
|
+
// Processing a successful task's result can fail. Give that failure its own activity instead of changing the task's
|
|
176
|
+
// outcome, and leave recurring wakeups scheduled. There may already be a turn-owned activity (hold).
|
|
177
|
+
this.unclaimed = false;
|
|
178
|
+
if (error && !this.own.size) this.hold();
|
|
179
|
+
for (const task of this.tasks.values()) {
|
|
180
|
+
if (task.state !== 'handling') continue;
|
|
181
|
+
const messaged = task.messaged; task.messaged = false;
|
|
182
|
+
if (messaged && task.final === undefined) { this.report(task, 'waiting'); continue; }
|
|
183
|
+
this.report(task, task.final ?? 'done'); task.final = undefined;
|
|
184
|
+
}
|
|
166
185
|
for (const cron of this.crons.values()) {
|
|
167
186
|
if (cron.state !== 'handling') continue;
|
|
168
187
|
if (cron.final !== undefined || !cron.recurring) { this.report(cron, 'done'); continue; }
|
|
169
188
|
cron.at = nextRun(cron.schedule, this.now());
|
|
170
189
|
this.report(cron, 'waiting', { at: cron.at === null ? undefined : new Date(cron.at).toISOString() });
|
|
171
190
|
}
|
|
172
|
-
for (const own of this.own) this.report(own, 'done');
|
|
191
|
+
for (const own of this.own) this.report(own, error ? 'failed' : 'done', error ? { title, detail: cut(error, 4000) } : {});
|
|
173
192
|
this.own.clear();
|
|
174
193
|
}
|
|
175
194
|
// 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.
|
|
6
|
+
"version": "1.6.2",
|
|
7
7
|
"categories": ["Agent"],
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@agentclientprotocol/sdk": "1.4.0",
|