@coda-console/claude 1.6.3 → 1.7.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/.coda/workflows/claude-compaction.bpmn +57 -0
- package/agent/compaction.mjs +37 -0
- package/agent/errors.mjs +13 -3
- package/agent/session.mjs +105 -19
- package/package.json +9 -1
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!-- Claude 缓存过期前压缩(设置 claude.idleCompaction):只跟随 Claude SDK 的会话,一个会话一条运行。
|
|
3
|
+
会话停下后还要接着用(置顶了,或还在等你、等 Coda 跑的流程与子会话、等它自己的后台任务、子 Agent 与定时),就在一小时的提示词缓存
|
|
4
|
+
过期前请 Agent 压缩一次上下文(compact-session),趁缓存还热时做摘要,回来时只需重新缓存摘要。
|
|
5
|
+
判断全在这里,依据是 Agent 每轮报来的 facts.usage:lastRequestAt(主线程最后一次请求模型,缓存最后一次被用到)、contextTokens(那次请求带的上下文)、
|
|
6
|
+
cacheTtl(缓存时长)、compactedAt(最近一次压缩,不管是谁压的)。照 Claude Code 2.1.296 自带的空闲压缩:只压一小时的缓存、上下文不少于 15 万 token,
|
|
7
|
+
在缓存寿命的 9/10(最后一次请求后 54 分钟)请 Agent 压缩,到点已过 58 分钟(机器睡过、后端停过,缓存多半已失效)就不压。
|
|
8
|
+
最后一次请求之后已经压缩过就不再压;同一次请求只请一次(compact.usage 是请求时的事实)。Agent 只执行,结果记在 compact 这一步里。
|
|
9
|
+
流程只读会话事实、只请 Agent 压缩,不改会话自己的状态,不发布阶段。 -->
|
|
10
|
+
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
11
|
+
xmlns:coda="https://coda.pub/schema/bpmn/1.0" id="claude-compaction-definitions" targetNamespace="https://coda.pub/plugins/claude">
|
|
12
|
+
<message id="opened" name="session.opened" />
|
|
13
|
+
<message id="facts" name="session.facts" />
|
|
14
|
+
<process id="claude-compaction" name="Claude 缓存过期前压缩" isExecutable="true" coda:dispatch="false" coda:agents="claude-sdk">
|
|
15
|
+
<startEvent id="start" name="会话打开"><messageEventDefinition messageRef="opened" /></startEvent>
|
|
16
|
+
<sequenceFlow id="start-pick" sourceRef="start" targetRef="pick" />
|
|
17
|
+
|
|
18
|
+
<!-- 第一个成立的条件生效,所以顺序就是优先级。 -->
|
|
19
|
+
<exclusiveGateway id="pick" name="会话现在怎样" default="pick-idle" />
|
|
20
|
+
<sequenceFlow id="pick-end" sourceRef="pick" targetRef="end">
|
|
21
|
+
<conditionExpression xsi:type="tFormalExpression">=facts != null and facts.archived</conditionExpression>
|
|
22
|
+
</sequenceFlow>
|
|
23
|
+
<!-- 打开了设置;进程在、没在跑一轮;还要接着用;一小时的缓存、上下文够大;最后一次请求之后没压缩过,也还没为它请过。 -->
|
|
24
|
+
<sequenceFlow id="pick-due" sourceRef="pick" targetRef="waiting">
|
|
25
|
+
<conditionExpression xsi:type="tFormalExpression">=facts != null and vars.claude.idleCompaction and facts.live and not(facts.busy)
|
|
26
|
+
and (facts.pinned or facts.input or facts.waitsForSystem or facts.background or facts.scheduled)
|
|
27
|
+
and facts.usage != null and facts.usage.lastRequestAt != null and facts.usage.cacheTtl = "PT1H" and facts.usage.contextTokens >= 150000
|
|
28
|
+
and (facts.usage.compactedAt = null or date and time(facts.usage.compactedAt) < date and time(facts.usage.lastRequestAt))
|
|
29
|
+
and not(compact != null and compact.usage != null and compact.usage.lastRequestAt = facts.usage.lastRequestAt)</conditionExpression>
|
|
30
|
+
</sequenceFlow>
|
|
31
|
+
<sequenceFlow id="pick-idle" sourceRef="pick" targetRef="idle" />
|
|
32
|
+
|
|
33
|
+
<!-- 不计时:等事实变了再看。 -->
|
|
34
|
+
<receiveTask id="idle" name="等会话的事实" messageRef="facts" />
|
|
35
|
+
<sequenceFlow id="idle-pick" sourceRef="idle" targetRef="pick" />
|
|
36
|
+
|
|
37
|
+
<!-- 最后一次请求 54 分钟后到点;期间事实变了就回去重看,最后一次请求没变时期限不变。 -->
|
|
38
|
+
<receiveTask id="waiting" name="等缓存快过期" messageRef="facts" />
|
|
39
|
+
<sequenceFlow id="waiting-pick" sourceRef="waiting" targetRef="pick" />
|
|
40
|
+
<boundaryEvent id="due" name="缓存快过期了" attachedToRef="waiting">
|
|
41
|
+
<timerEventDefinition><timeDuration xsi:type="tFormalExpression">{{facts.usage.lastRequestAt}}/PT54M</timeDuration></timerEventDefinition>
|
|
42
|
+
</boundaryEvent>
|
|
43
|
+
<sequenceFlow id="due-late" sourceRef="due" targetRef="late" />
|
|
44
|
+
|
|
45
|
+
<!-- 到点晚了(过了 58 分钟)就不压,等下一次请求。 -->
|
|
46
|
+
<exclusiveGateway id="late" name="还来得及吗" default="late-idle" />
|
|
47
|
+
<sequenceFlow id="late-compact" sourceRef="late" targetRef="compact">
|
|
48
|
+
<conditionExpression xsi:type="tFormalExpression">=now() < date and time(facts.usage.lastRequestAt) + duration("PT58M")</conditionExpression>
|
|
49
|
+
</sequenceFlow>
|
|
50
|
+
<sequenceFlow id="late-idle" sourceRef="late" targetRef="idle" />
|
|
51
|
+
|
|
52
|
+
<serviceTask id="compact" name="请 Agent 压缩" coda:action="compact-session" />
|
|
53
|
+
<sequenceFlow id="compact-pick" sourceRef="compact" targetRef="pick" />
|
|
54
|
+
|
|
55
|
+
<endEvent id="end" name="会话已归档" />
|
|
56
|
+
</process>
|
|
57
|
+
</definitions>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// What the plugin's lifecycle process (.coda/workflows/claude-compaction.bpmn, setting `claude.idleCompaction`) decides by
|
|
2
|
+
// whether to compact a session before the prompt cache of its main thread expires, told to Coda with each turn's usage
|
|
3
|
+
// (`usage_update` `_meta.coda`, which Coda passes on as the session's fact `usage`): when that cache was last used (the turn's
|
|
4
|
+
// last request), how large the context it carries is, how long it lasts, and when the session was last compacted — by Claude
|
|
5
|
+
// Code, by /compact or as asked —, which none is to follow. The process decides; the session only compacts when asked
|
|
6
|
+
// (`_coda/session/compact`).
|
|
7
|
+
export class CacheUse {
|
|
8
|
+
constructor({ now = Date.now } = {}) {
|
|
9
|
+
this.now = now;
|
|
10
|
+
// The last request of the main thread, that of the last turn's, and the last compaction.
|
|
11
|
+
this.cache = null; this.context = null; this.lastRequest = null; this.turn = null; this.compactedAt = null;
|
|
12
|
+
}
|
|
13
|
+
// A main-thread request: its cache is kept from now.
|
|
14
|
+
requested() { this.lastRequest = this.now(); }
|
|
15
|
+
// A main-thread response's usage: the context it carried, and how long what it wrote to the cache lasts. One that wrote
|
|
16
|
+
// nothing says nothing of that; one that wrote any for five minutes has that much gone in five.
|
|
17
|
+
used(usage) {
|
|
18
|
+
if (!usage) return;
|
|
19
|
+
const context = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
20
|
+
if (context) this.context = context;
|
|
21
|
+
const written = usage.cache_creation ?? {};
|
|
22
|
+
if (written.ephemeral_5m_input_tokens) this.cache = 'PT5M';
|
|
23
|
+
else if (written.ephemeral_1h_input_tokens) this.cache = 'PT1H';
|
|
24
|
+
}
|
|
25
|
+
// Another query of the session (another model, another service): what its cache is is not known until it answers.
|
|
26
|
+
restarted() { this.cache = null; }
|
|
27
|
+
// The session compacted, by Claude Code, by /compact or as asked: its context is what the summary left.
|
|
28
|
+
compacted(tokens) { this.context = Number.isFinite(tokens) ? tokens : null; this.compactedAt = this.now(); }
|
|
29
|
+
// A turn of the session ended (not a compaction's): its cache runs from its last request.
|
|
30
|
+
ended() { this.turn = this.lastRequest; }
|
|
31
|
+
// What Coda is told, what is known of it.
|
|
32
|
+
told() {
|
|
33
|
+
return { ...(this.turn !== null ? { lastRequestAt: new Date(this.turn).toISOString() } : {}),
|
|
34
|
+
...(this.context !== null ? { contextTokens: this.context } : {}), ...(this.cache ? { cacheTtl: this.cache } : {}),
|
|
35
|
+
...(this.compactedAt !== null ? { compactedAt: new Date(this.compactedAt).toISOString() } : {}) };
|
|
36
|
+
}
|
|
37
|
+
}
|
package/agent/errors.mjs
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { RequestError } from '@agentclientprotocol/sdk';
|
|
2
2
|
|
|
3
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
|
|
4
|
+
// evidence, ordinary assistant text and a bare HTTP 429 are not. Keep the provider's recovery advice intact (its reset time
|
|
5
|
+
// said as a time mark: `marked`).
|
|
5
6
|
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
|
|
|
7
|
-
|
|
8
|
+
// Claude Code says when the window that limits now resets in the time zone of the machine it runs on ("resets 5:10am
|
|
9
|
+
// (Australia/Sydney)"), not of whoever reads it. With that window's reset known (`window`: the latest rate_limit_info,
|
|
10
|
+
// which the CLI sends only as a window moves), the reason says it as a time mark instead, which each client writes in
|
|
11
|
+
// its own time zone (Coda's docs/agent-protocol.md「文字里的时刻」).
|
|
12
|
+
const windowReset = /^(You've (?:hit|reached) your [^·]+ · resets )[^·()]+ \([^()]+\)/;
|
|
13
|
+
const marked = (message, window) => window?.status === 'rejected' && Number.isSafeInteger(window.resetsAt) && window.resetsAt > 0
|
|
14
|
+
? message.replace(windowReset, (_, said) => `${said}<t:${new Date(window.resetsAt * 1000).toISOString()}>`) : message;
|
|
15
|
+
|
|
16
|
+
export function claudeFailure(message, { error, limits, window } = {}) {
|
|
8
17
|
const quota = (!error || ['rate_limit', 'billing_error'].includes(error)) &&
|
|
9
18
|
(exhausted(message) || (error === 'rate_limit' && limits?.status === 'rejected'));
|
|
10
|
-
|
|
19
|
+
const data = quota ? { _meta: { coda: { failure: { kind: 'quota_exhausted' } } } } : undefined;
|
|
20
|
+
return new RequestError(-32603, quota ? marked(message, window) : message, data);
|
|
11
21
|
}
|
package/agent/session.mjs
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// its input. What Claude says, its tools and their results, its settings, its title and its usage become session updates;
|
|
4
4
|
// what it keeps going beside a turn, waits (waits.mjs); what it asks, permission requests and forms, withdrawn as the SDK
|
|
5
5
|
// withdraws them. A turn Claude begins on its own is told by what it handles. The plan windows it reports go to the file
|
|
6
|
-
// the plugin's「Claude」panel reads (limits.json in CODA_PLUGIN_STATE, limits.mjs), not to Coda's session.
|
|
6
|
+
// the plugin's「Claude」panel reads (limits.json in CODA_PLUGIN_STATE, limits.mjs), not to Coda's session. Chosen, a session
|
|
7
|
+
// that is to go on later is compacted before its cache expires, as the plugin's lifecycle process asks (compaction.mjs).
|
|
7
8
|
import { randomUUID } from 'node:crypto';
|
|
8
9
|
import { readFileSync } from 'node:fs';
|
|
9
10
|
import { realpath, stat } from 'node:fs/promises';
|
|
@@ -18,6 +19,7 @@ import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from
|
|
|
18
19
|
import { permissionRequest } from './permissions.mjs';
|
|
19
20
|
import { elicitationForm, questionForm } from './questions.mjs';
|
|
20
21
|
import { ClaudeWaits } from './waits.mjs';
|
|
22
|
+
import { CacheUse } from './compaction.mjs';
|
|
21
23
|
|
|
22
24
|
// The Claude Agent SDK this entry runs, as the handshake's `agentInfo` names it (Coda's protocol「Agent 版本」).
|
|
23
25
|
const SDK = (() => {
|
|
@@ -37,6 +39,13 @@ export function sessionInstructions(systemPrompt) {
|
|
|
37
39
|
const text = content => typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
|
|
38
40
|
const interrupted = m => ['interrupt', 'cancelled'].includes(m.stop_reason) || ['aborted_streaming', 'aborted_tools'].includes(m.terminal_reason);
|
|
39
41
|
const errorText = m => (m.errors?.length ? m.errors : [m.result || m.subtype]).join('\n');
|
|
42
|
+
// The inputs a result names as taken in by its turn.
|
|
43
|
+
const takenIn = m => m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : []);
|
|
44
|
+
// What a turn of the main thread says of itself in conversation: what Claude says, its tools' results, what it was woken by.
|
|
45
|
+
// A compaction says none of it, nor does a subagent going on beside it, so that while one goes only these tell that a turn of
|
|
46
|
+
// Claude's own has begun.
|
|
47
|
+
const conversing = m => !m.parent_tool_use_id && (['assistant', 'stream_event'].includes(m.type)
|
|
48
|
+
|| (m.type === 'user' && (!!m.origin || (Array.isArray(m.message?.content) && m.message.content.some(block => block?.type === 'tool_result')))));
|
|
40
49
|
const failure = (message, code = -32603) => new RequestError(code, message);
|
|
41
50
|
const denied = { behavior: 'deny', message: 'Coda 没有在请求结束前给出审批' };
|
|
42
51
|
// A child session's output that comes before its task, kept until the task names that session: all of it, since what streamed
|
|
@@ -60,8 +69,9 @@ class InputStream {
|
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
export class ClaudeSession {
|
|
63
|
-
// `env`: what the SDK's process is started with; `onEnded`: the query ended without being closed
|
|
64
|
-
|
|
72
|
+
// `env`: what the SDK's process is started with; `onEnded`: the query ended without being closed; `now`: the clock what is
|
|
73
|
+
// told of the main thread's cache is read by.
|
|
74
|
+
constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {}, now }) {
|
|
65
75
|
Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
|
|
66
76
|
this.phase = 'new'; this.input = new InputStream(); this.prompts = new Map(); this.withdrawals = new Set();
|
|
67
77
|
// The messages sent into the prompt's turn not yet taken (steer), the prompt whose own turn ended before they were, how
|
|
@@ -74,11 +84,15 @@ export class ClaudeSession {
|
|
|
74
84
|
this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
|
|
75
85
|
this.waits = new ClaudeWaits({ say: (params, sessionId = this.sessionId) => this.notify('_coda/wait', { sessionId, ...params }),
|
|
76
86
|
going: () => this.prompts.size > 0 || this.waits.handling, registered: sessionId => this.registered(sessionId) });
|
|
87
|
+
// What is told of the main thread's cache (compaction.mjs); while a compaction Coda asked for goes, its input and answer; and
|
|
88
|
+
// what the native turn going said of compacting (its boundary, its failure, its command's output), the compaction's once
|
|
89
|
+
// that turn's result names its input.
|
|
90
|
+
this.cacheUse = new CacheUse({ now }); this.compacting = null; this.turnCompaction = {};
|
|
77
91
|
}
|
|
78
92
|
connect(stream) {
|
|
79
93
|
this.connection = agent({ name: 'coda-claude' })
|
|
80
94
|
.onRequest('initialize', () => ({ protocolVersion: PROTOCOL_VERSION, agentInfo: SDK ? { name: 'claude-agent-sdk', version: SDK } : { name: 'coda-claude', version: '1' },
|
|
81
|
-
agentCapabilities: { loadSession: true, sessionCapabilities: { additionalDirectories: {}, systemPrompt: {} }, _meta: { coda: { waits: {} } } },
|
|
95
|
+
agentCapabilities: { loadSession: true, sessionCapabilities: { additionalDirectories: {}, systemPrompt: {} }, _meta: { coda: { waits: {}, compact: {} } } },
|
|
82
96
|
_meta: { steering: { supported: true } } }))
|
|
83
97
|
// The SDK's own reading of these drops what its schema does not name, `systemPrompt` among them: they are read as sent.
|
|
84
98
|
.onRequest('session/new', params => params ?? {}, ({ params }) => this.open(params))
|
|
@@ -93,9 +107,12 @@ export class ClaudeSession {
|
|
|
93
107
|
if (this.rerouting) this.cancelling = true;
|
|
94
108
|
// Messages sent into the turn and not taken yet (steer) are stopped with it: else Claude Code would run them after.
|
|
95
109
|
if (this.steering.size) this.dropRest({ stopReason: 'cancelled' });
|
|
110
|
+
// Prompts waiting behind a compaction Coda asked for have not begun: they are dropped with it, else they would run after.
|
|
111
|
+
else if (this.compacting && this.prompts.size) this.dropQueued();
|
|
96
112
|
else void this.query?.interrupt().catch(() => {});
|
|
97
113
|
})
|
|
98
114
|
.onRequest('_coda/wait/stop', params => params, ({ params }) => this.stopWait(params))
|
|
115
|
+
.onRequest('_coda/session/compact', params => params ?? {}, ({ params }) => this.compact(params))
|
|
99
116
|
.connect(stream);
|
|
100
117
|
this.client = this.connection.client;
|
|
101
118
|
return this.connection;
|
|
@@ -186,7 +203,9 @@ export class ClaudeSession {
|
|
|
186
203
|
const entry = this.served(model);
|
|
187
204
|
const settings = { ...this.launch.settings, ...(this.current.effort ? { effortLevel: this.current.effort } : {}),
|
|
188
205
|
...(entry ? serviceSettings(this.services.find(service => service.ref === entry.service), entry.model) : {}) };
|
|
189
|
-
this.serviceModel = entry ? model : null;
|
|
206
|
+
this.serviceModel = entry ? model : null; this.cacheUse.restarted();
|
|
207
|
+
// A compaction in the query moved off ends with it.
|
|
208
|
+
this.compacting?.answer({ compacted: false, reason: '会话换到另一个模型,压缩没有做完' }); this.compacting = null;
|
|
190
209
|
this.query = this.sdk.query({ prompt: this.input, options: { ...this.launch, ...at, settings,
|
|
191
210
|
...(this.phase === 'running' ? { permissionMode: this.current.mode, model: entry ? entry.model : this.current.model } : {}) } });
|
|
192
211
|
this.pump = this.consume(this.query);
|
|
@@ -266,7 +285,7 @@ export class ClaudeSession {
|
|
|
266
285
|
// 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
|
|
267
286
|
// named; one more is Coda's to send as the next turn, as it is with no turn going.
|
|
268
287
|
steer({ prompt }) {
|
|
269
|
-
if (this.phase !== 'running' || !this.prompts.size || this.ending) return { outcome: 'promptRequired', reason: 'noRunningTurn' };
|
|
288
|
+
if (this.phase !== 'running' || !this.prompts.size || this.ending || this.compacting) return { outcome: 'promptRequired', reason: 'noRunningTurn' };
|
|
270
289
|
if (this.steering.size >= STEERING_LIMIT) return { outcome: 'promptRequired', reason: 'tooManyPending' };
|
|
271
290
|
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
272
291
|
this.steering.add(id);
|
|
@@ -281,9 +300,26 @@ export class ClaudeSession {
|
|
|
281
300
|
this.ending = outcome;
|
|
282
301
|
void this.query?.interrupt({ cancelQueued: true }).then(receipt => {
|
|
283
302
|
for (const id of receipt?.cancelled ?? []) this.steering.delete(id);
|
|
303
|
+
this.dropped(receipt?.cancelled);
|
|
284
304
|
if (this.held && !this.turning) this.settle(this.held, this.ending);
|
|
285
305
|
}, () => {});
|
|
286
306
|
}
|
|
307
|
+
// The turn going stopped, and what was given behind it dropped (cancelQueued): the prompts among that answered as stopped.
|
|
308
|
+
dropQueued() {
|
|
309
|
+
void this.query?.interrupt({ cancelQueued: true }).then(receipt => {
|
|
310
|
+
for (const id of receipt?.cancelled ?? []) {
|
|
311
|
+
const prompt = this.prompts.get(id);
|
|
312
|
+
if (prompt) { this.prompts.delete(id); prompt.resolve({ stopReason: 'cancelled' }); }
|
|
313
|
+
}
|
|
314
|
+
this.dropped(receipt?.cancelled);
|
|
315
|
+
}, () => {});
|
|
316
|
+
}
|
|
317
|
+
// A compaction Coda asked for among the inputs Claude Code dropped before taking them: no turn of it will answer, so it is
|
|
318
|
+
// answered now.
|
|
319
|
+
dropped(ids = []) {
|
|
320
|
+
if (!this.compacting || !ids.includes(this.compacting.id)) return;
|
|
321
|
+
this.compacting.answer({ compacted: false, reason: '压缩被取消' }); this.compacting = null;
|
|
322
|
+
}
|
|
287
323
|
// The prompt answered: as it ended, and with nothing of it left to take.
|
|
288
324
|
settle(id, { stopReason, failure: error }) {
|
|
289
325
|
const prompt = this.prompts.get(id);
|
|
@@ -293,6 +329,18 @@ export class ClaudeSession {
|
|
|
293
329
|
this.waits.settled();
|
|
294
330
|
if (error) prompt.reject(error); else prompt.resolve({ stopReason });
|
|
295
331
|
}
|
|
332
|
+
// Coda asks the session compacted (the plugin's lifecycle process decides when, compaction.mjs): it compacts as /compact
|
|
333
|
+
// would, in a turn that is neither Coda's nor Claude's own (result), and answers with the context before and after. One not
|
|
334
|
+
// running, or in a turn, cannot now.
|
|
335
|
+
compact({ sessionId }) {
|
|
336
|
+
if (sessionId !== this.sessionId) throw failure('没有这个会话', -32602);
|
|
337
|
+
if (this.phase !== 'running' || this.rerouting) return { compacted: false, reason: 'Claude 会话未在运行' };
|
|
338
|
+
if (this.prompts.size || this.turning || this.opening || this.compacting || this.waits.handling || this.waits.unclaimed) return { compacted: false, reason: '会话正在进行一轮' };
|
|
339
|
+
return new Promise(answer => {
|
|
340
|
+
this.compacting = { id: randomUUID(), answer, before: this.cacheUse.context ?? undefined };
|
|
341
|
+
this.input.push({ type: 'user', uuid: this.compacting.id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content: '/compact' } });
|
|
342
|
+
});
|
|
343
|
+
}
|
|
296
344
|
// What is said to the session goes into what its title is made of.
|
|
297
345
|
heard(content) {
|
|
298
346
|
this.titleContext = ((this.titleContext ?? '') + '\n' + content + '\n').slice(-1000);
|
|
@@ -307,7 +355,8 @@ export class ClaudeSession {
|
|
|
307
355
|
|
|
308
356
|
permission(toolName, input, { signal, ...context }) {
|
|
309
357
|
if (!['starting', 'running'].includes(this.phase)) return Promise.resolve(denied);
|
|
310
|
-
|
|
358
|
+
// A subagent's request opens no turn of Claude's own while a compaction Coda asked for goes (receive).
|
|
359
|
+
if (!this.compacting || !context.agentID) this.begin();
|
|
311
360
|
// A subagent's request is in its session.
|
|
312
361
|
const sessionId = this.waits.childSession(context.agentID) ?? this.sessionId;
|
|
313
362
|
if (sessionId === this.sessionId) this.waits.claim();
|
|
@@ -349,6 +398,7 @@ export class ClaudeSession {
|
|
|
349
398
|
if (this.phase === 'ended') return;
|
|
350
399
|
const intended = this.phase === 'closing';
|
|
351
400
|
this.phase = 'ended'; this.input.close(); this.steering.clear(); this.held = null; this.ending = null;
|
|
401
|
+
this.compacting?.answer({ compacted: false, reason: error?.message ?? 'Claude 会话已结束' }); this.compacting = null;
|
|
352
402
|
for (const withdrawn of this.withdrawals) withdrawn.abort();
|
|
353
403
|
for (const { reject } of this.prompts.values()) reject(failure(error?.message ?? 'Claude 会话已结束'));
|
|
354
404
|
this.prompts.clear();
|
|
@@ -357,7 +407,10 @@ export class ClaudeSession {
|
|
|
357
407
|
}
|
|
358
408
|
receive(m) {
|
|
359
409
|
const parent = m.parent_tool_use_id ?? null;
|
|
360
|
-
|
|
410
|
+
// While a compaction Coda asked for goes, a turn opened then (its init) is Claude's own once it converses, or ends with a
|
|
411
|
+
// result not the compaction's; the compaction's once its result names the compaction's input (result).
|
|
412
|
+
const compacting = this.compacting && (m.type === 'result' ? takenIn(m).includes(this.compacting.id) : !conversing(m));
|
|
413
|
+
if (!compacting) this.begin(m.type === 'user' && !parent && m.origin ? { origin: m.origin, text: text(m.message?.content) } : {});
|
|
361
414
|
switch (m.type) {
|
|
362
415
|
case 'system': return this.system(m);
|
|
363
416
|
case 'stream_event': return this.stream(m.event, parent);
|
|
@@ -365,7 +418,7 @@ export class ClaudeSession {
|
|
|
365
418
|
case 'user': return this.toolResults(m, parent);
|
|
366
419
|
case 'result': return this.result(m);
|
|
367
420
|
case 'rate_limit_event':
|
|
368
|
-
this.turnLimits = m.rate_limit_info;
|
|
421
|
+
this.turnLimits = this.window = m.rate_limit_info;
|
|
369
422
|
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);
|
|
370
423
|
}
|
|
371
424
|
}
|
|
@@ -386,7 +439,8 @@ export class ClaudeSession {
|
|
|
386
439
|
this.title.append(this.titleContext ?? ''); this.titleContext = '';
|
|
387
440
|
void this.title.refresh();
|
|
388
441
|
}
|
|
389
|
-
// Every native turn opens with an init. One no prompt of Coda's began, and no handling already holds, is Claude's own
|
|
442
|
+
// Every native turn opens with an init. One no prompt of Coda's began, and no handling already holds, is Claude's own,
|
|
443
|
+
// unless it is a compaction Coda asked for (receive, result).
|
|
390
444
|
if (!this.prompts.size && !this.waits.handling) this.opening = m;
|
|
391
445
|
else this.announce(m);
|
|
392
446
|
return;
|
|
@@ -405,13 +459,18 @@ export class ClaudeSession {
|
|
|
405
459
|
status: this.compaction.active ? 'in_progress' : m.compact_result === 'failed' ? 'failed' : 'completed', error: m.compact_error ?? null });
|
|
406
460
|
if (!this.compaction.active) this.compaction = null;
|
|
407
461
|
}
|
|
462
|
+
if (m.subtype === 'compact_boundary') {
|
|
463
|
+
this.cacheUse.compacted(m.compact_metadata?.post_tokens);
|
|
464
|
+
Object.assign(this.turnCompaction, { before: m.compact_metadata?.pre_tokens, after: m.compact_metadata?.post_tokens, done: true });
|
|
465
|
+
}
|
|
466
|
+
if (m.compact_result === 'failed') this.turnCompaction.error = m.compact_error || '压缩失败';
|
|
408
467
|
return;
|
|
409
468
|
}
|
|
410
|
-
case 'local_command_output':
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
return;
|
|
414
|
-
|
|
469
|
+
case 'local_command_output':
|
|
470
|
+
// While a compaction Coda asked for goes, a command's output waits for its turn's result: the compaction's says why it
|
|
471
|
+
// did not compact, and is not said; any other turn's is said then (result).
|
|
472
|
+
if (this.compacting) { (this.turnCompaction.outputs ??= []).push(m); return; }
|
|
473
|
+
return this.commandOutput(m);
|
|
415
474
|
case 'session_state_changed': if (m.state === 'idle') this.waits.settled(); return;
|
|
416
475
|
case 'task_started': return this.waits.started(m, { start: true });
|
|
417
476
|
case 'task_updated': {
|
|
@@ -423,6 +482,10 @@ export class ClaudeSession {
|
|
|
423
482
|
case 'background_tasks_changed': if (Array.isArray(m.tasks)) this.waits.snapshot(m.tasks);
|
|
424
483
|
}
|
|
425
484
|
}
|
|
485
|
+
commandOutput(m) {
|
|
486
|
+
const said = m.content ?? m.stdout;
|
|
487
|
+
if (typeof said === 'string') this.update(this.sessionId, { sessionUpdate: 'agent_message_chunk', messageId: m.uuid, content: { type: 'text', text: said } });
|
|
488
|
+
}
|
|
426
489
|
// A turn Claude began on its own is told by what it handles, before anything said or asked in it; its opening message, when
|
|
427
490
|
// that comes first, may say what that is.
|
|
428
491
|
begin(opening) {
|
|
@@ -454,7 +517,7 @@ export class ClaudeSession {
|
|
|
454
517
|
this.streamed.set(event.message.id, new Map());
|
|
455
518
|
// Kept for the messages streaming now; a message whose complete form came long ago is not.
|
|
456
519
|
if (this.streamed.size > 64) this.streamed.delete(this.streamed.keys().next().value);
|
|
457
|
-
if (parent) this.waits.model(parent, event.message.model);
|
|
520
|
+
if (parent) this.waits.model(parent, event.message.model); else this.cacheUse.requested();
|
|
458
521
|
return;
|
|
459
522
|
}
|
|
460
523
|
const apiId = this.streams.get(owner), blocks = this.streamed.get(apiId);
|
|
@@ -477,6 +540,9 @@ export class ClaudeSession {
|
|
|
477
540
|
if (!parent) {
|
|
478
541
|
this.turnError = m.error ? { error: m.error, message: text(m.message?.content) } : null;
|
|
479
542
|
if (m.error) return;
|
|
543
|
+
// One not streamed was a request too.
|
|
544
|
+
if (!blocks) this.cacheUse.requested();
|
|
545
|
+
this.cacheUse.used(m.message?.usage);
|
|
480
546
|
}
|
|
481
547
|
for (const block of m.message?.content ?? []) {
|
|
482
548
|
if (block.type === 'tool_use') {
|
|
@@ -505,10 +571,30 @@ export class ClaudeSession {
|
|
|
505
571
|
}
|
|
506
572
|
result(m) {
|
|
507
573
|
const usage = m.usage ?? {}, windows = Object.values(m.modelUsage ?? {});
|
|
574
|
+
const ids = takenIn(m);
|
|
575
|
+
// The compaction Coda asked for (compact) is the turn that names its input. Any other is the session's own, whose cache runs
|
|
576
|
+
// from its last request. Coda is told what is known of that cache (compaction.mjs).
|
|
577
|
+
const compaction = this.compacting && ids.includes(this.compacting.id) ? this.compacting : null;
|
|
578
|
+
// What the turn said of compacting is this turn's: the compaction's answer, or else said now, its output held back.
|
|
579
|
+
const turn = this.turnCompaction;
|
|
580
|
+
this.turnCompaction = {};
|
|
581
|
+
if (!compaction) { this.cacheUse.ended(); for (const output of turn.outputs ?? []) this.commandOutput(output); }
|
|
582
|
+
const told = this.cacheUse.told();
|
|
508
583
|
this.update(this.sessionId, { sessionUpdate: 'usage_update', used: (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0),
|
|
509
|
-
size: Math.max(0, ...windows.map(w => w.contextWindow ?? 0)), cost: { amount: m.total_cost_usd ?? 0, currency: 'USD' }
|
|
584
|
+
size: Math.max(0, ...windows.map(w => w.contextWindow ?? 0)), cost: { amount: m.total_cost_usd ?? 0, currency: 'USD' },
|
|
585
|
+
...(Object.keys(told).length ? { _meta: { coda: told } } : {}) });
|
|
510
586
|
this.turning = false;
|
|
511
|
-
|
|
587
|
+
// The compaction's: Coda is answered, and nothing is taken up.
|
|
588
|
+
if (compaction) {
|
|
589
|
+
const { answer } = compaction, { done, error, outputs = [] } = turn, before = turn.before ?? compaction.before, after = turn.after;
|
|
590
|
+
const said = outputs.map(output => output.content ?? output.stdout).filter(text => typeof text === 'string').join('\n').trim();
|
|
591
|
+
// Its init, which opened no turn of Claude's own.
|
|
592
|
+
this.compacting = null; this.turnError = null; this.turnLimits = null; this.opening = null;
|
|
593
|
+
const failed = interrupted(m) ? '压缩被停止' : error ?? (m.is_error || m.subtype !== 'success' ? errorText(m) : done ? null
|
|
594
|
+
: said ? said.slice(0, 1000) : '没有压缩');
|
|
595
|
+
answer(failed ? { compacted: false, reason: failed } : { compacted: true, ...(Number.isFinite(before) ? { before } : {}), ...(Number.isFinite(after) ? { after } : {}) });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
512
598
|
// 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
|
|
513
599
|
// own ended before taking it: the prompt is answered once none is left to take. Stopped or failed with some not taken
|
|
514
600
|
// yet, it ends once those are dropped (dropRest); one ending already ends as it was to.
|
|
@@ -516,7 +602,7 @@ export class ClaudeSession {
|
|
|
516
602
|
const id = ids.find(id => this.prompts.has(id)) ?? (steered ? this.held : undefined);
|
|
517
603
|
const cancelled = interrupted(m), failed = !cancelled && (m.is_error || m.subtype !== 'success');
|
|
518
604
|
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;
|
|
605
|
+
{ error: this.turnError?.error, limits: this.turnLimits, window: this.window }) : null;
|
|
520
606
|
this.turnError = null; this.turnLimits = null;
|
|
521
607
|
if (id) {
|
|
522
608
|
const outcome = this.ending ?? (failed ? { failure: error } : { stopReason: cancelled ? 'cancelled' : 'end_turn' });
|
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.7.0",
|
|
7
7
|
"categories": ["Agent"],
|
|
8
8
|
"dependencies": {
|
|
9
9
|
"@agentclientprotocol/sdk": "1.4.0",
|
|
@@ -46,6 +46,13 @@
|
|
|
46
46
|
"title": "模型服务",
|
|
47
47
|
"order": 1,
|
|
48
48
|
"description": "勾选插件提供的模型服务(如 Magpie),每个服务各成一组,它列出的模型和 Claude 自己的模型一起出现在会话的模型里;选中哪个服务的模型,那个会话就走那个服务,Claude 自己的模型照旧。只列 Anthropic 接口(anthropic-messages)的服务。"
|
|
49
|
+
},
|
|
50
|
+
"claude.idleCompaction": {
|
|
51
|
+
"type": "boolean",
|
|
52
|
+
"title": "缓存过期前压缩",
|
|
53
|
+
"order": 2,
|
|
54
|
+
"default": false,
|
|
55
|
+
"description": "会话停下后还要接着用时(在 Coda 里置顶了,或还在等你、等流程或子会话、等后台命令、子 Agent 与定时唤醒),在一小时的提示词缓存过期前(最后一次请求模型 54 分钟后)压缩一次上下文,同 /compact:回来接着做时只需重新缓存摘要,不必按原价重新缓存整段对话。由插件的生命周期流程「Claude 缓存过期前压缩」按会话计时与判断:只压一小时的缓存、上下文超过 15 万 token 的,最近一件事已经是压缩就不再压;每次做了什么、为什么没做都记在那条流程的运行里。"
|
|
49
56
|
}
|
|
50
57
|
}
|
|
51
58
|
},
|
|
@@ -73,6 +80,7 @@
|
|
|
73
80
|
"access": "public"
|
|
74
81
|
},
|
|
75
82
|
"files": [
|
|
83
|
+
".coda",
|
|
76
84
|
"agent",
|
|
77
85
|
"scripts",
|
|
78
86
|
"icons"
|