@coda-console/claude 1.6.4 → 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.
@@ -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) &lt; 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() &lt; 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/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
- constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
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
- this.begin();
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
- this.begin(m.type === 'user' && !parent && m.origin ? { origin: m.origin, text: text(m.message?.content) } : {});
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);
@@ -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
- const said = m.content ?? m.stdout;
412
- if (typeof said === 'string') this.update(this.sessionId, { sessionUpdate: 'agent_message_chunk', messageId: m.uuid, content: { type: 'text', text: said } });
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
- const ids = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : []);
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.
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.4",
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"