@coda-console/claude 1.7.2 → 1.7.6

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.
@@ -3,6 +3,7 @@
3
3
  // can change Claude's permissions.
4
4
  import { randomUUID } from 'node:crypto';
5
5
  import { homedir } from 'node:os';
6
+ import { toolCallFields } from './tools.mjs';
6
7
 
7
8
  const deny = { behavior: 'deny', message: '用户未批准此操作' };
8
9
 
@@ -12,8 +13,8 @@ export function permissionRequest({ sessionId, toolName, input, context = {} })
12
13
  const options = [{ optionId: 'allow-once', name: '仅允许本次', kind: 'allow_once' },
13
14
  ...suggestions.map((change, i) => ({ optionId: `suggestion-${i}`, name: describeChange(change, input), kind: 'allow_always' })),
14
15
  { optionId: 'deny', name: '拒绝', kind: 'reject_once' }];
15
- const params = { sessionId, options, toolCall: { toolCallId: context.toolUseID ?? randomUUID(), title: toolName, rawInput: input, status: 'pending',
16
- ...(context.decisionReason ? { content: [{ type: 'content', content: { type: 'text', text: context.decisionReason } }] } : {}) } };
16
+ const params = { sessionId, options, toolCall: { toolCallId: context.toolUseID ?? randomUUID(), status: 'pending',
17
+ ...toolCallFields(toolName, input, context.decisionReason ? [{ type: 'content', content: { type: 'text', text: context.decisionReason } }] : []) } };
17
18
  const answer = response => {
18
19
  const { outcome, optionId } = response?.outcome ?? {};
19
20
  if (outcome !== 'selected') return deny;
package/agent/session.mjs CHANGED
@@ -17,6 +17,7 @@ import { chosenServices, listedModels, serviceSettings } from './providers.mjs';
17
17
  import { SessionTitle } from './titles.mjs';
18
18
  import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from './models.mjs';
19
19
  import { permissionRequest } from './permissions.mjs';
20
+ import { toolCallFields, ToolCallCache } from './tools.mjs';
20
21
  import { elicitationForm, questionForm } from './questions.mjs';
21
22
  import { ClaudeWaits } from './waits.mjs';
22
23
  import { CacheUse } from './compaction.mjs';
@@ -81,7 +82,7 @@ export class ClaudeSession {
81
82
  this.services = chosenServices(env); this.serviceModel = null;
82
83
  this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
83
84
  // Streamed text by API message, so that the complete message adds only what was not streamed; tool calls said.
84
- this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
85
+ this.streams = new Map(); this.streamed = new Map(); this.tools = new ToolCallCache(); this.children = new Set(); this.early = new Map(); this.compaction = null;
85
86
  this.waits = new ClaudeWaits({ say: (params, sessionId = this.sessionId) => this.notify('_coda/wait', { sessionId, ...params }),
86
87
  going: () => this.prompts.size > 0 || this.waits.handling, registered: sessionId => this.registered(sessionId) });
87
88
  // What is told of the main thread's cache (compaction.mjs); while a compaction Coda asked for goes, its input and answer; and
@@ -547,8 +548,9 @@ export class ClaudeSession {
547
548
  }
548
549
  for (const block of m.message?.content ?? []) {
549
550
  if (block.type === 'tool_use') {
550
- this.tools.add(block.id);
551
- this.update(sessionId, { sessionUpdate: 'tool_call', toolCallId: block.id, title: block.name, rawInput: block.input, status: 'in_progress' });
551
+ const fields = toolCallFields(block.name, block.input);
552
+ this.tools.start(block.id, block.name, fields.content ?? []);
553
+ this.update(sessionId, { sessionUpdate: 'tool_call', toolCallId: block.id, ...fields, status: 'in_progress' });
552
554
  } else if (['text', 'thinking'].includes(block.type)) {
553
555
  const said = block.text ?? block.thinking ?? '';
554
556
  // One complete packet carries one block of the message: the first streamed one of its kind not yet confirmed.
@@ -561,10 +563,13 @@ export class ClaudeSession {
561
563
  }
562
564
  toolResults(m, parent) {
563
565
  for (const block of Array.isArray(m.message?.content) ? m.message.content : []) {
564
- if (block.type !== 'tool_result' || !this.tools.has(block.tool_use_id)) continue;
566
+ if (block.type !== 'tool_result') continue;
567
+ const content = this.tools.finish(block.tool_use_id, m.tool_use_result, block.is_error);
568
+ if (!content) continue;
565
569
  const said = text(block.content);
566
570
  this.update(parent ?? this.sessionId, { sessionUpdate: 'tool_call_update', toolCallId: block.tool_use_id, status: block.is_error ? 'failed' : 'completed',
567
- rawOutput: m.tool_use_result ?? block.content, content: [{ type: 'content', content: { type: 'text', text: said } }] });
571
+ // ACP content updates replace the list: keep the latest diffs alongside the result text.
572
+ rawOutput: m.tool_use_result ?? block.content, content: [...content, { type: 'content', content: { type: 'text', text: said } }] });
568
573
  // Claude Code says where a command it put in the background writes in the launch's result.
569
574
  const path = said.match(/Output is being written to: (\S+?\.output)\b/)?.[1];
570
575
  if (path) this.waits.outputFile(block.tool_use_id, path);
@@ -0,0 +1,102 @@
1
+ // Claude Agent SDK 0.3.296's sdk-tools.d.ts, plus names used by older Claude Code sessions.
2
+ // Only native tool names carry these meanings; MCP tools and unknown tools stay `other`.
3
+ const kinds = new Map(Object.entries({
4
+ execute: ['Bash', 'BashOutput', 'KillBash', 'KillShell', 'TaskOutput', 'TaskStop', 'Monitor'],
5
+ read: ['Read', 'NotebookRead'],
6
+ edit: ['Edit', 'MultiEdit', 'Write', 'NotebookEdit'],
7
+ search: ['Grep', 'Glob', 'LS'],
8
+ fetch: ['WebFetch', 'WebSearch'],
9
+ think: ['TodoWrite', 'EnterPlanMode', 'ExitPlanMode', 'TaskCreate', 'TaskGet', 'TaskUpdate', 'TaskList', 'ReportFindings', 'ProposeGoal'],
10
+ }).flatMap(([kind, names]) => names.map(name => [name, kind])));
11
+
12
+ const string = value => typeof value === 'string';
13
+ const firstLine = value => string(value) ? value.split(/\r\n|\r|\n/, 1)[0].trim() : '';
14
+ const short = value => { const chars = Array.from(firstLine(value)); return chars.length > 200 ? `${chars.slice(0, 199).join('')}…` : chars.join(''); };
15
+
16
+ // Pure presentation shared by calls and permission requests. rawInput is the SDK's original object.
17
+ // Existing content (e.g. why permission is needed) accompanies the diffs, never replaces them.
18
+ export function toolCallFields(name, rawInput, content = []) {
19
+ const input = rawInput ?? {}, kind = kinds.get(name) ?? 'other';
20
+ const path = ['read', 'edit'].includes(kind) ? (input.file_path ?? input.notebook_path) : undefined;
21
+ const hasPath = string(path) && path.length > 0;
22
+ let title = name;
23
+ if (kind === 'execute') title = short(input.command) || name;
24
+ if (kind === 'search') title = short(input.pattern ?? input.query ?? input.path) || name;
25
+ if (kind === 'fetch') title = short(input.url ?? input.query) || name;
26
+ if (hasPath) title = short(`${kind === 'read' ? '读取' : '编辑'} ${path}`);
27
+
28
+ const diffs = [];
29
+ if (hasPath) {
30
+ if (name === 'Write' && string(input.content)) diffs.push({ type: 'diff', path, oldText: null, newText: input.content });
31
+ const edits = name === 'Edit' ? [input] : name === 'MultiEdit' && Array.isArray(input.edits) ? input.edits : [];
32
+ for (const edit of edits) {
33
+ if (string(edit?.old_string) && string(edit?.new_string)) diffs.push({ type: 'diff', path, oldText: edit.old_string, newText: edit.new_string });
34
+ }
35
+ }
36
+ const combined = [...diffs, ...content];
37
+ return { title, kind, rawInput,
38
+ ...(hasPath ? { locations: [{ path, ...(name === 'Read' && Number.isInteger(input.offset) && input.offset >= 0 ? { line: Math.max(1, input.offset) } : {}) }] } : {}),
39
+ ...(combined.length ? { content: combined } : {}),
40
+ };
41
+ }
42
+
43
+ // A patch line includes its newline unless the immediately following marker says otherwise.
44
+ // Context belongs to both sides; a marker after '-' or '+' only applies to that side.
45
+ function patchText(lines, side) {
46
+ return lines.map((line, i) => line.startsWith(side) || line.startsWith(' ')
47
+ ? line.slice(1) + (lines[i + 1] === '\' ? '' : '\n') : '').join('');
48
+ }
49
+
50
+ // A successful SDK result is authoritative: a Write may overwrite a file, and an Edit may replace
51
+ // many occurrences or be changed by the user. Keep previews only when no structured result is given.
52
+ export function toolResultContent(name, content, result, failed = false) {
53
+ if (failed || !['Edit', 'MultiEdit', 'Write'].includes(name) || !string(result?.filePath)) return content;
54
+ let diffs;
55
+ if (result.staged === true) diffs = []; // SDK: held for review, the file is unchanged.
56
+ else if (name === 'Write' && string(result.content)
57
+ && (string(result.originalFile) || (result.type === 'create' && result.originalFile === null))) {
58
+ diffs = [{ type: 'diff', path: result.filePath, oldText: result.originalFile, newText: result.content }];
59
+ } else if (Array.isArray(result.structuredPatch)
60
+ && result.structuredPatch.every(hunk => Array.isArray(hunk?.lines) && hunk.lines.every(string))) {
61
+ // Each hunk carries both sides, including context. Do not merge distant hunks: that would
62
+ // invent adjacent lines and can undercount independent replacements.
63
+ diffs = result.structuredPatch.map(hunk => ({ type: 'diff', path: result.filePath,
64
+ oldText: patchText(hunk.lines, '-'), newText: patchText(hunk.lines, '+'),
65
+ }));
66
+ }
67
+ // An update whose original text was too large and whose patch is empty has no measurable diff;
68
+ // in particular, do not keep the input preview that would report it as a newly created file.
69
+ return diffs === undefined ? content : [...diffs, ...content.filter(block => block.type !== 'diff')];
70
+ }
71
+
72
+ // Pending calls must survive other tools finishing. Completed calls only serve repeated SDK results:
73
+ // keep the most recently completed 128 calls, with at most 4 MiB of estimated string storage.
74
+ // Evicted results are ignored, so they cannot overwrite the diff already sent to the client.
75
+ export class ToolCallCache {
76
+ #pending = new Map(); #completed = new Map(); #bytes = 0;
77
+ constructor({ maxCompleted = 128, maxBytes = 4 * 1024 * 1024 } = {}) {
78
+ this.maxCompleted = maxCompleted; this.maxBytes = maxBytes;
79
+ }
80
+ #forget(id) {
81
+ const entry = this.#completed.get(id);
82
+ if (entry) { this.#bytes -= entry.bytes; this.#completed.delete(id); }
83
+ }
84
+ start(id, name, content) {
85
+ this.#forget(id);
86
+ this.#pending.set(id, { name, content });
87
+ }
88
+ finish(id, result, failed = false) {
89
+ const previous = this.#pending.get(id) ?? this.#completed.get(id)?.tool;
90
+ if (!previous) return undefined;
91
+ const tool = { name: previous.name, content: toolResultContent(previous.name, previous.content, result, failed) };
92
+ this.#pending.delete(id); this.#forget(id);
93
+ // Two bytes per UTF-16 code unit, including ID and serialized field names; the count limit also
94
+ // bounds object overhead. An oversized result is delivered once without evicting smaller entries.
95
+ const bytes = 2 * (id.length + JSON.stringify(tool).length);
96
+ if (bytes <= this.maxBytes) {
97
+ this.#completed.set(id, { tool, bytes }); this.#bytes += bytes;
98
+ while (this.#completed.size > this.maxCompleted || this.#bytes > this.maxBytes) this.#forget(this.#completed.keys().next().value);
99
+ }
100
+ return tool.content;
101
+ }
102
+ }
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.7.2",
6
+ "version": "1.7.6",
7
7
  "categories": ["Agent"],
8
8
  "dependencies": {
9
9
  "@agentclientprotocol/sdk": "1.4.0",