@coda-console/claude 0.0.0 → 1.3.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/auth.mjs ADDED
@@ -0,0 +1,37 @@
1
+ import { constants } from 'node:fs';
2
+ import { open } from 'node:fs/promises';
3
+ import { isAbsolute } from 'node:path';
4
+
5
+ // Variables that make the Claude CLI use something other than the user's normal login.
6
+ const authOverrides = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_BASE_URL', 'ANTHROPIC_CUSTOM_HEADERS',
7
+ 'CLAUDE_CODE_OAUTH_TOKEN', 'CLAUDE_CODE_OAUTH_REFRESH_TOKEN', 'CLAUDE_CODE_OAUTH_SCOPES',
8
+ 'CLAUDE_CODE_USE_BEDROCK', 'CLAUDE_CODE_USE_VERTEX', 'CLAUDE_CODE_USE_FOUNDRY'];
9
+
10
+ // Read only in the SDK driver, never in the product service or host journal.
11
+ export async function sdkEnvironment(env = process.env) {
12
+ const path = env.CODA_CLAUDE_OAUTH_TOKEN_FILE;
13
+ // Let the SDK use the user's normal login or provider configuration. A token
14
+ // file is an explicit override, never a dependency on a maintainer's machine.
15
+ if (path === undefined) return { ...env };
16
+ const fail = reason => new Error(`Claude SDK setup token: ${reason}`);
17
+ if (!isAbsolute(path)) throw fail('file path must be absolute');
18
+ let file;
19
+ try {
20
+ file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
21
+ const info = await file.stat();
22
+ if (!info.isFile() || info.uid !== process.getuid() || (info.mode & 0o7777) !== 0o600)
23
+ throw fail('file must be a regular file owned by the current user with mode 0600');
24
+ const buffer = Buffer.alloc(8193);
25
+ const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
26
+ const token = buffer.subarray(0, bytesRead).toString('utf8').replace(/\r?\n$/, '');
27
+ if (bytesRead > 8192 || !/^sk-ant-oat01-[A-Za-z0-9_-]+$/.test(token))
28
+ throw fail('file must contain one subscription OAuth token');
29
+ const result = { ...env };
30
+ for (const key of authOverrides) delete result[key];
31
+ result.CLAUDE_CODE_OAUTH_TOKEN = token;
32
+ return result;
33
+ } catch (error) {
34
+ if (error.message.startsWith('Claude SDK setup token:')) throw error;
35
+ throw fail('cannot read CODA_CLAUDE_OAUTH_TOKEN_FILE; choose a readable token file or remove this override to use your normal login');
36
+ } finally { await file?.close(); }
37
+ }
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ // The Claude agent as Coda starts it (the plugin's `contributes.agents`): Claude Code through the plugin's own Claude Agent SDK
3
+ // (its `@anthropic-ai/claude-agent-sdk` dependency), speaking ACP and Coda's waits over standard input and output
4
+ // (session.mjs). It ends when Coda's side of the pipe closes, when it is told to, or when its session ends.
5
+ import { Readable, Writable } from 'node:stream';
6
+ import { ndJsonStream } from '@agentclientprotocol/sdk';
7
+ import { ClaudeSession } from './session.mjs';
8
+
9
+ if (process.argv.includes('--help')) {
10
+ process.stdout.write('coda-claude: Claude Code as an ACP agent for Coda over standard input and output; no terminal UI.\n');
11
+ } else {
12
+ const sdk = await import('@anthropic-ai/claude-agent-sdk');
13
+ for (const name of ['query', 'getSessionInfo', 'renameSession']) {
14
+ if (typeof sdk[name] !== 'function') throw new Error(`Claude SDK 缺少所需接口:${name}`);
15
+ }
16
+ let stopping = false;
17
+ const stop = async (code = 0) => {
18
+ if (stopping) return;
19
+ stopping = true;
20
+ // A Claude process must not outlive this one even if closing the SDK hangs; the session host has a shorter grace of its own.
21
+ setTimeout(() => process.exit(code || 1), 5000).unref();
22
+ try { await session.close(); } finally { process.stdout.write('', () => process.exit(code)); }
23
+ };
24
+ const session = new ClaudeSession({ sdk, onEnded: () => void stop(1) });
25
+ session.connect(ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin))).closed.then(() => stop());
26
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) process.once(signal, () => void stop());
27
+ }
@@ -0,0 +1,87 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync, renameSync, statSync, writeFileSync } from 'node:fs';
3
+
4
+ // The quota of the account Claude sessions run as, read from what the sessions themselves report. A setup token has no
5
+ // profile scope, so /usage cannot be asked with it; but the CLI sends a `rate_limit_event` whenever a window's rounded
6
+ // percentage or reset time moves, with the fraction of the window used and when it resets (SDKRateLimitInfo). Each
7
+ // session's entry (session.mjs) writes it to `limits.json` in this plugin's state; the「Claude」panel reads it
8
+ // (scripts/panel.mjs, docs/plugins.md「内置插件:Claude」).
9
+ // `unifiedWindows`, an internal field of newer CLIs, carries every window each time; the top-level fields, which every
10
+ // CLI sends, only the one that limits now.
11
+ const labels = { five_hour: '5 小时', seven_day: '7 天', seven_day_opus: 'Opus · 7 天', seven_day_sonnet: 'Sonnet · 7 天', seven_day_overage_included: 'Fable · 7 天' };
12
+ const used = fraction => Math.round(fraction * 100);
13
+
14
+ function reported(info) {
15
+ const windows = info?.unifiedWindows && typeof info.unifiedWindows === 'object' ? { ...info.unifiedWindows } : {};
16
+ if (info?.rateLimitType && !windows[info.rateLimitType]) windows[info.rateLimitType] = info;
17
+ return Object.entries(windows);
18
+ }
19
+
20
+ // The latest each window was reported at, kept across restarts: events come only as a window moves, so a backend that
21
+ // started just now would otherwise show nothing until one does. A window past its reset is no longer known, not full:
22
+ // what was used since is not seen from here.
23
+ // The account they are of is kept beside them, as a hash of its email (`account`): what Coda's group view compares across
24
+ // machines, the email itself staying here. The file is the account the latest session started as (session.mjs); none
25
+ // where Claude runs on an API key or a cloud provider, or the login does not say.
26
+ export const accountHash = email => createHash('sha256').update(email.trim().toLowerCase()).digest('hex').slice(0, 32);
27
+ export class ClaudeSessionLimits {
28
+ constructor({ path = null, now = Date.now } = {}) {
29
+ this.path = path; this.now = now; this.windows = new Map(); this.account = null; this.stamp = null;
30
+ this.read();
31
+ }
32
+ read() {
33
+ if (!this.path) return;
34
+ let stamp;
35
+ try { stamp = statSync(this.path).mtimeMs; } catch { return; }
36
+ if (stamp === this.stamp) return;
37
+ this.stamp = stamp; this.windows = new Map(); this.account = null;
38
+ try {
39
+ const saved = JSON.parse(readFileSync(this.path, 'utf8'));
40
+ for (const [key, w] of Object.entries(saved.windows)) this.take(key, w?.utilization, w?.resetsAt);
41
+ if (typeof saved.account === 'string' && /^[0-9a-f]{32}$/.test(saved.account)) this.account = saved.account;
42
+ }
43
+ catch { /* Not readable now: read again when it next changes. */ this.stamp = null; }
44
+ }
45
+ known() { this.read(); return this.windows.size > 0; }
46
+ take(key, utilization, resetsAt) {
47
+ if (!Object.hasOwn(labels, key) || !(Number.isFinite(utilization) && utilization >= 0) || !(Number.isSafeInteger(resetsAt) && resetsAt > 0)) return false;
48
+ const was = this.windows.get(key);
49
+ if (was?.resetsAt === resetsAt && used(was.utilization) === used(utilization)) return false;
50
+ this.windows.set(key, { utilization, resetsAt });
51
+ return true;
52
+ }
53
+ // The account a session started as, a hash or null when it is not known: from then on the file is that one's. Another
54
+ // than before starts the windows afresh, as another account's say nothing of it; an unknown one names none.
55
+ startedAs(account) {
56
+ if (account === this.account) return;
57
+ this.windows = new Map(); this.account = account;
58
+ this.save();
59
+ }
60
+ // What a session reports, as of the account it started as: one that started as another (still running from before a
61
+ // switch) says nothing of the account the file is now.
62
+ observe(info, account = null) {
63
+ if (account !== this.account) return;
64
+ let changed = false;
65
+ for (const [key, w] of reported(info)) changed = this.take(key, w?.utilization, w?.resetsAt) || changed;
66
+ if (changed) this.save();
67
+ }
68
+ save() {
69
+ if (!this.path) return;
70
+ try {
71
+ // Every Claude session writes it: a temporary file of each writer's own.
72
+ const temp = `${this.path}.${process.pid}.tmp`;
73
+ writeFileSync(temp, JSON.stringify({ version: 1, windows: Object.fromEntries(this.windows), ...(this.account ? { account: this.account } : {}) }), { mode: 0o600 });
74
+ renameSync(temp, this.path);
75
+ } catch { /* Not written: the next window that moves writes again. */ }
76
+ }
77
+ // The windows still to reset, in a fixed order: what is left of each and when it resets. None when all have passed.
78
+ quota() {
79
+ this.read();
80
+ const now = this.now();
81
+ return Object.keys(labels).flatMap(key => {
82
+ const w = this.windows.get(key);
83
+ return w && w.resetsAt * 1000 > now ? [{ id: key, label: labels[key], remainingPercent: Math.max(0, 100 - used(w.utilization)),
84
+ resetsAt: new Date(w.resetsAt * 1000).toISOString() }] : [];
85
+ });
86
+ }
87
+ }
@@ -0,0 +1,41 @@
1
+ // Claude's settings as ACP config options. A model is named by the concrete ID it runs as, never by an alias: each row the
2
+ // SDK lists is its resolved wire ID with the context variant it selects (`[1m]`), or its own value when it resolves to
3
+ // none, and rows that come to the same ID are one model. What the row is called is kept, the ID beside it when the name
4
+ // does not say it.
5
+ export const MODES = [['default', '人工审批'], ['acceptEdits', '自动接受编辑'], ['plan', '规划'], ['dontAsk', '未预先允许则拒绝'], ['auto', '自动审批'],
6
+ ['bypassPermissions', '跳过所有审批']].map(([id, name]) => ({ id, name }));
7
+
8
+ export function claudeModels(rows = []) {
9
+ const models = new Map();
10
+ for (const row of rows) {
11
+ if (typeof row?.value !== 'string' || !row.value) continue;
12
+ const variant = row.value.match(/\[[^\]]+\]$/)?.[0] ?? '', resolved = row.resolvedModel?.trim();
13
+ const id = resolved ? resolved + (resolved.endsWith(variant) ? '' : variant) : row.value;
14
+ const label = row.displayName || row.value, name = label.includes(id) ? label : `${label} (${id})`;
15
+ const known = models.get(id);
16
+ // The SDK's `default` row is the model it would pick: the row naming that model says what it is.
17
+ if (!known) models.set(id, { id, name, efforts: row.supportedEffortLevels ?? [], from: row.value });
18
+ else if (known.from === 'default') Object.assign(known, { name, from: row.value });
19
+ }
20
+ return [...models.values()];
21
+ }
22
+
23
+ // The model a native init reports: one of the list, keeping the context variant chosen when its base is the one reported.
24
+ export function reportedModel(models, current, model) {
25
+ if (typeof model !== 'string' || !model) return current;
26
+ if (current === model || current?.replace(/\[[^\]]+\]$/, '') === model) return current;
27
+ return models.find(m => m.id === model)?.id ?? model;
28
+ }
29
+
30
+ const select = (id, name, category, currentValue, choices) => ({ id, name, category, type: 'select', currentValue,
31
+ options: choices.map(c => ({ value: c.id, name: c.name, ...(c._meta ? { _meta: c._meta } : {}) })) });
32
+ // The effort that leaves it to the model, marked as the agent's own default (docs/agent-protocol.md): what Coda goes back to
33
+ // when the session's effort is taken off.
34
+ export const MODEL_EFFORT = 'default';
35
+ // `current`: { mode, model, effort }. An effort not chosen is null: the model's own default.
36
+ export function configOptions(models, current) {
37
+ const efforts = models.find(m => m.id === current.model)?.efforts ?? [];
38
+ return [select('mode', '审批模式', 'mode', current.mode, MODES), select('model', '模型', 'model', current.model, models),
39
+ ...(efforts.length ? [select('effort', '推理强度', 'thought_level', efforts.includes(current.effort) ? current.effort : MODEL_EFFORT,
40
+ [{ id: MODEL_EFFORT, name: '模型默认', _meta: { coda: { default: true } } }, ...efforts.map(id => ({ id, name: id }))])] : [])];
41
+ }
@@ -0,0 +1,68 @@
1
+ // A tool Claude asks to use, as an ACP permission request: allow once, each rule Claude suggests to stop asking (its whole
2
+ // sentence the option's name, the rule kept here), and deny. Why it asks is the tool call's content. Only an option offered
3
+ // can change Claude's permissions.
4
+ import { randomUUID } from 'node:crypto';
5
+ import { homedir } from 'node:os';
6
+
7
+ const deny = { behavior: 'deny', message: '用户未批准此操作' };
8
+
9
+ export function permissionRequest({ sessionId, toolName, input, context = {} }) {
10
+ // Identical suggestions would be two options that do the same thing.
11
+ const suggestions = [...new Map((context.suggestions ?? []).map(change => [JSON.stringify(change), change])).values()];
12
+ const options = [{ optionId: 'allow-once', name: '仅允许本次', kind: 'allow_once' },
13
+ ...suggestions.map((change, i) => ({ optionId: `suggestion-${i}`, name: describeChange(change, input), kind: 'allow_always' })),
14
+ { 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 } }] } : {}) } };
17
+ const answer = response => {
18
+ const { outcome, optionId } = response?.outcome ?? {};
19
+ if (outcome !== 'selected') return deny;
20
+ if (optionId === 'allow-once') return { behavior: 'allow', updatedInput: input };
21
+ const change = suggestions[/^suggestion-(\d+)$/.exec(optionId ?? '')?.[1]];
22
+ return change ? { behavior: 'allow', updatedInput: input, updatedPermissions: [change] } : deny;
23
+ };
24
+ return { params, answer };
25
+ }
26
+
27
+ // Two suggestions for the same tool are different grants; the name says what each allows, and where it is kept.
28
+ const scopes = { session: '本会话', cliArg: '本进程', localSettings: '本项目 · 本机', projectSettings: '本项目 · 共享', userSettings: '所有项目' };
29
+ const verbs = { allow: '允许', deny: '拒绝', ask: '每次询问' };
30
+ const modes = { default: '默认', acceptEdits: '自动允许修改文件', bypassPermissions: '跳过所有审批', plan: '计划', dontAsk: '不询问' };
31
+ const readTools = new Set(['Read', 'Glob', 'Grep']), editTools = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
32
+ const shortPath = path => { const home = homedir(); return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path; };
33
+
34
+ function describeRule({ toolName, ruleContent }, input, verb) {
35
+ if (ruleContent === undefined) return [`${verb}所有 ${toolName} 操作`];
36
+ if (toolName === 'Bash') {
37
+ if (ruleContent === input?.command) return [`${verb}这条完全相同的命令`];
38
+ if (ruleContent.endsWith(':*')) return [`${verb}以此开头的命令`, ruleContent.slice(0, -2)];
39
+ return [`${verb}命令`, ruleContent];
40
+ }
41
+ // Claude writes absolute paths in rules as `//abs/path`.
42
+ const path = shortPath(ruleContent.replace(/^\/\//, '/'));
43
+ if (readTools.has(toolName)) return [`${verb}读取`, path];
44
+ if (editTools.has(toolName)) return [`${verb}修改`, path];
45
+ if (toolName === 'WebFetch' && ruleContent.startsWith('domain:')) return [`${verb}访问`, ruleContent.slice(7)];
46
+ return [`${verb} ${toolName}`, ruleContent];
47
+ }
48
+
49
+ export function describeChange(change, input) {
50
+ const scope = scopes[change.destination] ?? change.destination ?? 'Agent 建议的范围', verb = verbs[change.behavior] ?? '允许';
51
+ const rules = change.rules ?? [], listed = rules.map(r => `${r.toolName}(${r.ruleContent ?? ''})`).join('、');
52
+ const directories = (change.directories ?? []).map(shortPath).join('、');
53
+ const [what, target] = (() => {
54
+ switch (change.type) {
55
+ case 'addRules': case 'replaceRules': {
56
+ const [added, target] = rules.length === 1 ? describeRule(rules[0], input, verb) : [`${verb} ${rules.length} 条规则`, listed];
57
+ // Replacing drops the scope's existing rules of this behavior.
58
+ return [change.type === 'replaceRules' ? `替换现有规则为:${added}` : added, target];
59
+ }
60
+ case 'removeRules': return [`移除${verb}规则`, listed];
61
+ case 'addDirectories': return ['允许访问目录', directories];
62
+ case 'removeDirectories': return ['移除目录', directories];
63
+ case 'setMode': return [`切换为「${modes[change.mode] ?? change.mode}」模式`];
64
+ default: return ['应用 Agent 建议的权限调整'];
65
+ }
66
+ })();
67
+ return `${what}${target ? ` ${target}` : ''}(${scope})`;
68
+ }
@@ -0,0 +1,39 @@
1
+ // What Claude asks the user, as ACP forms in plain JSON Schema: its own questions (AskUserQuestion) and those of the MCP
2
+ // servers it runs (a form, or a link to open).
3
+ import { randomUUID } from 'node:crypto';
4
+
5
+ // The choice that says the answer is the one written beside it: ACP forms carry no rule that one of two fields be filled,
6
+ // so the question stays required and this is one of its choices.
7
+ export const OTHER = 'coda:other';
8
+
9
+ export function questionForm({ sessionId, toolCallId, input }) {
10
+ const questions = Array.isArray(input?.questions) ? input.questions : [];
11
+ const properties = {};
12
+ questions.forEach((q, i) => {
13
+ const choices = [...(q.options ?? []).map(o => ({ const: o.label, title: o.label + (o.description ? ` — ${o.description}` : '') })),
14
+ { const: OTHER, title: '其他(写在下面)' }];
15
+ properties[`q${i}`] = { title: q.question, description: q.header ?? '', ...(q.multiSelect
16
+ ? { type: 'array', items: { anyOf: choices }, minItems: 1 } : { type: 'string', oneOf: choices }) };
17
+ properties[`q${i}_other`] = { type: 'string', title: '其他' };
18
+ });
19
+ const params = { sessionId, ...(toolCallId ? { toolCallId } : {}), mode: 'form', message: 'Claude 需要你的回答',
20
+ requestedSchema: { type: 'object', properties, required: questions.map((_, i) => `q${i}`) } };
21
+ const answer = response => {
22
+ if (response?.action !== 'accept') return { behavior: 'deny', message: '用户取消了问答' };
23
+ const content = response.content ?? {};
24
+ return { behavior: 'allow', updatedInput: { ...input, answers: Object.fromEntries(questions.map((q, i) => {
25
+ const other = String(content[`q${i}_other`] ?? '').trim() || '其他', pick = value => value === OTHER ? other : value;
26
+ const value = content[`q${i}`];
27
+ return [q.question, Array.isArray(value) ? value.map(pick).join(', ') : pick(value)];
28
+ })) } };
29
+ };
30
+ return { params, answer };
31
+ }
32
+
33
+ // An MCP server's request (SDK onElicitation): its form as it is, or its link with nothing to fill in.
34
+ export function elicitationForm({ sessionId, request }) {
35
+ if (request.mode === 'url') return { params: { sessionId, mode: 'url', message: request.message ?? '', url: request.url,
36
+ elicitationId: request.elicitationId ?? randomUUID() }, answer: response => ({ action: response?.action ?? 'cancel' }) };
37
+ return { params: { sessionId, mode: 'form', message: request.message ?? '', requestedSchema: request.requestedSchema ?? { type: 'object', properties: {} } },
38
+ answer: response => ['accept', 'decline', 'cancel'].includes(response?.action) ? response : { action: 'cancel' } };
39
+ }
@@ -0,0 +1,408 @@
1
+ // One Claude Code session through its SDK (one `query` per process), as an ACP agent with Coda's waits
2
+ // (docs/agent-protocol.md). Coda's prompts go into the query's input; a prompt is answered by the native result that names
3
+ // its input. What Claude says, its tools and their results, its settings, its title and its usage become session updates;
4
+ // what it keeps going beside a turn, waits (waits.mjs); what it asks, permission requests and forms, withdrawn as the SDK
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.
7
+ import { randomUUID } from 'node:crypto';
8
+ import { readFileSync } from 'node:fs';
9
+ import { realpath, stat } from 'node:fs/promises';
10
+ import { join } from 'node:path';
11
+ import { agent, PROTOCOL_VERSION, RequestError } from '@agentclientprotocol/sdk';
12
+ import { ClaudeSessionLimits, accountHash } from './limits.mjs';
13
+ import { sdkEnvironment } from './auth.mjs';
14
+ import { SessionTitle } from './titles.mjs';
15
+ import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from './models.mjs';
16
+ import { permissionRequest } from './permissions.mjs';
17
+ import { elicitationForm, questionForm } from './questions.mjs';
18
+ import { ClaudeWaits } from './waits.mjs';
19
+
20
+ // The Claude Agent SDK this entry runs, as the handshake's `agentInfo` names it (Coda's protocol「Agent 版本」).
21
+ const SDK = (() => {
22
+ try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.resolve('@anthropic-ai/claude-agent-sdk')), 'utf8')).version; }
23
+ catch { return null; }
24
+ })();
25
+
26
+ // Coda's session instructions (docs/agent-protocol.md「会话指令」), ACP's `systemPrompt`: its text, '' when there is none. Only
27
+ // text appended to the agent's own is understood; anything else is refused before a session opens.
28
+ export function sessionInstructions(systemPrompt) {
29
+ if (systemPrompt === undefined || systemPrompt === null) return '';
30
+ if (systemPrompt.mode !== 'append') throw new RequestError(-32602, '会话指令只支持 append 模式');
31
+ const { content } = systemPrompt;
32
+ if (!Array.isArray(content) || content.some(block => block?.type !== 'text' || typeof block.text !== 'string')) throw new RequestError(-32602, '会话指令只接受文本块');
33
+ return content.map(block => block.text).join('\n\n');
34
+ }
35
+ const text = content => typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
36
+ const interrupted = m => ['interrupt', 'cancelled'].includes(m.stop_reason) || ['aborted_streaming', 'aborted_tools'].includes(m.terminal_reason);
37
+ const errorText = m => (m.errors?.length ? m.errors : [m.result || m.subtype]).join('\n');
38
+ const failure = (message, code = -32603) => new RequestError(code, message);
39
+ const denied = { behavior: 'deny', message: 'Coda 没有在请求结束前给出审批' };
40
+ // A child session's output that comes before its task, kept until the task names that session: all of it, since what streamed
41
+ // counts as said, a message's streamed pieces as one.
42
+ const CHUNKS = new Set(['agent_message_chunk', 'agent_thought_chunk']);
43
+
44
+ class InputStream {
45
+ queue = []; wake = null; ended = false;
46
+ push(message) { this.queue.push(message); this.wake?.(); }
47
+ close() { this.ended = true; this.wake?.(); }
48
+ async *[Symbol.asyncIterator]() {
49
+ while (!this.ended) {
50
+ if (this.queue.length) yield this.queue.shift();
51
+ else await new Promise(resolve => { this.wake = resolve; });
52
+ }
53
+ }
54
+ }
55
+
56
+ export class ClaudeSession {
57
+ // `env`: what the SDK's process is started with; `onEnded`: the query ended without being closed.
58
+ constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
59
+ Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
60
+ this.phase = 'new'; this.input = new InputStream(); this.prompts = new Map(); this.withdrawals = new Set();
61
+ this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
62
+ // Streamed text by API message, so that the complete message adds only what was not streamed; tool calls said.
63
+ this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
64
+ this.waits = new ClaudeWaits({ say: (params, sessionId = this.sessionId) => this.notify('_coda/wait', { sessionId, ...params }),
65
+ going: () => this.prompts.size > 0 || this.waits.handling, registered: sessionId => this.registered(sessionId) });
66
+ }
67
+ connect(stream) {
68
+ this.connection = agent({ name: 'coda-claude' })
69
+ .onRequest('initialize', () => ({ protocolVersion: PROTOCOL_VERSION, agentInfo: SDK ? { name: 'claude-agent-sdk', version: SDK } : { name: 'coda-claude', version: '1' },
70
+ agentCapabilities: { loadSession: true, sessionCapabilities: { additionalDirectories: {}, systemPrompt: {} }, _meta: { coda: { waits: {} } } } }))
71
+ // The SDK's own reading of these drops what its schema does not name, `systemPrompt` among them: they are read as sent.
72
+ .onRequest('session/new', params => params ?? {}, ({ params }) => this.open(params))
73
+ .onRequest('session/load', params => params ?? {}, ({ params }) => this.open(params, params.sessionId))
74
+ .onRequest('session/set_config_option', ({ params }) => this.configure(params.configId, params.value))
75
+ .onRequest('session/set_mode', async ({ params }) => { await this.configure('mode', params.modeId); return {}; })
76
+ .onRequest('session/prompt', ({ params }) => this.prompt(params))
77
+ .onNotification('session/cancel', ({ params }) => { if (params.sessionId === this.sessionId) void this.query?.interrupt().catch(() => {}); })
78
+ .onRequest('_coda/wait/stop', params => params, ({ params }) => this.stopWait(params))
79
+ .connect(stream);
80
+ this.client = this.connection.client;
81
+ return this.connection;
82
+ }
83
+ notify(method, params) { void this.client?.notify(method, params).catch(() => {}); }
84
+ update(sessionId, update) {
85
+ // Coda takes a child session's updates once a wait has named it.
86
+ if (sessionId !== this.sessionId && !this.children.has(sessionId)) {
87
+ if (!this.early.has(sessionId)) this.early.set(sessionId, []);
88
+ const early = this.early.get(sessionId), last = early.at(-1);
89
+ if (CHUNKS.has(update.sessionUpdate) && last?.sessionUpdate === update.sessionUpdate && last.messageId !== undefined && last.messageId === update.messageId)
90
+ early[early.length - 1] = { ...last, content: { type: 'text', text: last.content.text + update.content.text } };
91
+ else early.push(update);
92
+ return;
93
+ }
94
+ this.notify('session/update', { sessionId, update });
95
+ }
96
+ registered(sessionId) {
97
+ this.children.add(sessionId);
98
+ for (const update of this.early.get(sessionId) ?? []) this.notify('session/update', { sessionId, update });
99
+ this.early.delete(sessionId);
100
+ }
101
+ options() { return configOptions(this.models, this.current); }
102
+
103
+ async open({ cwd, mcpServers = [], additionalDirectories = [], systemPrompt }, resume) {
104
+ if (this.phase !== 'new') throw failure('每个 Claude 进程只有一个会话', -32600);
105
+ const instructions = sessionInstructions(systemPrompt);
106
+ this.phase = 'starting';
107
+ try {
108
+ const dir = await realpath(cwd);
109
+ if (!(await stat(dir)).isDirectory()) throw failure('cwd 不是目录', -32602);
110
+ if (resume && !await this.sdk.getSessionInfo(resume, { dir })) throw failure('原生 Claude 会话不存在', -32002);
111
+ const environment = await this.environment(this.env);
112
+ if (this.phase !== 'starting') throw failure('Claude 会话在启动时已关闭', -32001);
113
+ this.sessionId = resume ?? randomUUID(); this.cwd = dir;
114
+ // Coda's servers are stdio ones; the SDK keys them by name.
115
+ const servers = Object.fromEntries(mcpServers.filter(server => typeof server.command === 'string').map(({ name, command, args = [], env = [] }) =>
116
+ [name, { command, args, ...(env.length ? { env: Object.fromEntries(env.map(variable => [variable.name, variable.value])) } : {}) }]));
117
+ this.query = this.sdk.query({ prompt: this.input, options: {
118
+ // Skipping every approval is a mode the user may switch to later; the SDK refuses it unless allowed at start.
119
+ permissionMode: 'default', allowDangerouslySkipPermissions: true, settingSources: ['user', 'project', 'local'],
120
+ // Coda's session instructions (what its clients render, what the project's plugins say) follow Claude Code's own.
121
+ systemPrompt: { type: 'preset', preset: 'claude_code', ...(instructions ? { append: instructions } : {}) },
122
+ // Sessions reach each other through Coda's send, which the user approves and both threads show. A native peer
123
+ // message would start a turn Coda cannot attribute. Continuing a native subagent is in-process and unaffected.
124
+ settings: { crossSessionInbound: 'refuse' },
125
+ includePartialMessages: true, forwardSubagentText: true, cwd: dir,
126
+ ...(Object.keys(servers).length ? { mcpServers: servers } : {}), ...(additionalDirectories.length ? { additionalDirectories } : {}),
127
+ ...(resume ? { resume } : { sessionId: this.sessionId }),
128
+ // Claude Code reports running and idle only when asked: idle is what ends a task's wait.
129
+ env: { ...environment, CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' },
130
+ // Stop supplies the complete list of scheduled wakeups. A CronCreate result alone cannot tell whether one was later
131
+ // deleted or fired.
132
+ hooks: { Stop: [{ hooks: [async input => {
133
+ if (input.session_id === this.sessionId && Array.isArray(input.session_crons)) this.waits.scheduled(input.session_crons.filter(cron =>
134
+ typeof cron?.id === 'string' && cron.id && typeof cron.schedule === 'string' && typeof cron.recurring === 'boolean').slice(0, 50));
135
+ return {};
136
+ }] }] },
137
+ canUseTool: (toolName, input, context) => this.permission(toolName, input, context),
138
+ onElicitation: (request, context) => this.elicitation(request, context),
139
+ stderr: data => process.stderr.write(data),
140
+ } });
141
+ this.pump = this.consume();
142
+ let timer;
143
+ const initialization = await Promise.race([this.query.initializationResult(),
144
+ this.pump.then(() => { throw failure('Claude 会话在初始化时结束', -32001); }),
145
+ new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
146
+ .finally(() => clearTimeout(timer));
147
+ if (this.phase !== 'starting') throw failure('Claude 会话在初始化时结束', -32001);
148
+ this.models = claudeModels(initialization.models); this.commands = initialization.commands ?? [];
149
+ // Who the session runs as, for the「Claude」panel (limits.mjs): the windows it reports are that account's, and
150
+ // only while the panel is of that account.
151
+ const email = initialization.account?.email;
152
+ this.account = typeof email === 'string' && email.trim() ? accountHash(email) : null;
153
+ if (this.env.CODA_PLUGIN_STATE) new ClaudeSessionLimits({ path: join(this.env.CODA_PLUGIN_STATE, 'limits.json') }).startedAs(this.account);
154
+ this.current.model = this.models[0]?.id;
155
+ this.phase = 'running';
156
+ return { ...(resume ? {} : { sessionId: this.sessionId }), configOptions: this.options() };
157
+ } catch (error) {
158
+ await this.close();
159
+ throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
160
+ }
161
+ }
162
+ async configure(configId, value) {
163
+ if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
164
+ if (configId === 'mode') {
165
+ if (!MODES.some(mode => mode.id === value)) throw failure('审批模式不可用', -32602);
166
+ await this.query.setPermissionMode(value); this.current.mode = value;
167
+ } else if (configId === 'model') {
168
+ if (!this.models.some(model => model.id === value)) throw failure('模型不可用', -32602);
169
+ await this.query.setModel(value); this.current.model = value;
170
+ // An effort chosen for the last model that this one does not have is no choice of it: the model's own goes on.
171
+ if (this.current.effort !== null && !this.models.find(model => model.id === value).efforts.includes(this.current.effort)) {
172
+ await this.query.applyFlagSettings({ effortLevel: null }); this.current.effort = null;
173
+ }
174
+ } else if (configId === 'effort') {
175
+ const effort = value === MODEL_EFFORT ? null : value;
176
+ if (effort !== null && !this.models.find(model => model.id === this.current.model)?.efforts.includes(effort)) throw failure('推理强度不适用于当前模型', -32602);
177
+ await this.query.applyFlagSettings({ effortLevel: effort }); this.current.effort = effort;
178
+ } else throw failure(`没有设置项 ${configId}`, -32602);
179
+ return { configOptions: this.options() };
180
+ }
181
+ // Answered when the native result that names this input arrives; accepting it into the input says nothing yet.
182
+ prompt({ prompt }) {
183
+ if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
184
+ const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
185
+ const answered = new Promise((resolve, reject) => this.prompts.set(id, { resolve, reject }));
186
+ this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content } });
187
+ this.titleContext = ((this.titleContext ?? '') + '\n' + content + '\n').slice(-1000);
188
+ if (this.title) { this.title.append(this.titleContext); this.titleContext = ''; }
189
+ return answered;
190
+ }
191
+ async stopWait({ waitId }) {
192
+ const taskId = this.phase === 'running' ? this.waits.stop(waitId) : null;
193
+ if (taskId === null) throw failure('没有可以停止的等待项', -32602);
194
+ if (taskId) await this.query.stopTask(taskId);
195
+ return {};
196
+ }
197
+
198
+ permission(toolName, input, { signal, ...context }) {
199
+ if (!['starting', 'running'].includes(this.phase)) return Promise.resolve(denied);
200
+ this.begin();
201
+ // A subagent's request is in its session.
202
+ const sessionId = this.waits.childSession(context.agentID) ?? this.sessionId;
203
+ return toolName === 'AskUserQuestion'
204
+ ? this.ask('elicitation/create', questionForm({ sessionId, toolCallId: context.toolUseID, input }), signal, { behavior: 'deny', message: '问答已失效' })
205
+ : this.ask('session/request_permission', permissionRequest({ sessionId, toolName, input, context }), signal, denied);
206
+ }
207
+ elicitation(request, { signal }) {
208
+ if (!['starting', 'running'].includes(this.phase)) return Promise.resolve({ action: 'cancel' });
209
+ this.begin();
210
+ return this.ask('elicitation/create', elicitationForm({ sessionId: this.sessionId, request }), signal, { action: 'cancel' });
211
+ }
212
+ // Asked of Coda until it answers; withdrawn (`$/cancel_request`) once the SDK no longer waits for the answer.
213
+ async ask(method, { params, answer }, signal, fallback) {
214
+ if (signal?.aborted) return fallback;
215
+ const withdrawn = new AbortController(), withdraw = () => withdrawn.abort();
216
+ signal?.addEventListener('abort', withdraw, { once: true });
217
+ this.withdrawals.add(withdrawn);
218
+ try {
219
+ const response = await Promise.race([this.client.request(method, params, { cancellationSignal: withdrawn.signal }),
220
+ new Promise(resolve => withdrawn.signal.addEventListener('abort', () => resolve(null), { once: true }))]);
221
+ return withdrawn.signal.aborted ? fallback : answer(response);
222
+ } catch { return fallback; } finally { signal?.removeEventListener('abort', withdraw); this.withdrawals.delete(withdrawn); }
223
+ }
224
+
225
+ async consume() {
226
+ let error;
227
+ try {
228
+ for await (const message of this.query) {
229
+ try { this.receive(message); }
230
+ catch (failed) { process.stderr.write(`Claude 消息处理失败:${failed.message}\n`); }
231
+ }
232
+ } catch (value) { if (this.phase !== 'closing') error = value; }
233
+ finally {
234
+ const intended = this.phase === 'closing';
235
+ this.phase = 'ended'; this.input.close();
236
+ for (const withdrawn of this.withdrawals) withdrawn.abort();
237
+ for (const { reject } of this.prompts.values()) reject(failure(error?.message ?? 'Claude 会话已结束'));
238
+ this.prompts.clear();
239
+ try { this.query.close(); } catch {}
240
+ if (!intended) this.onEnded(error);
241
+ }
242
+ }
243
+ receive(m) {
244
+ const parent = m.parent_tool_use_id ?? null;
245
+ this.begin(m.type === 'user' && !parent && m.origin ? { origin: m.origin, text: text(m.message?.content) } : {});
246
+ switch (m.type) {
247
+ case 'system': return this.system(m);
248
+ case 'stream_event': return this.stream(m.event, parent);
249
+ case 'assistant': return this.assistant(m, parent);
250
+ case 'user': return this.toolResults(m, parent);
251
+ case 'result': return this.result(m);
252
+ case 'rate_limit_event': 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);
253
+ }
254
+ }
255
+ system(m) {
256
+ switch (m.subtype) {
257
+ case 'init': {
258
+ if (m.session_id !== this.sessionId) return;
259
+ this.terminal = m.terminal_slash_commands ?? [];
260
+ // Its effort is the one applied, the model's own included: what was chosen stays what it was.
261
+ this.current = { ...this.current, model: reportedModel(this.models, this.current.model, m.model), mode: m.permissionMode ?? this.current.mode };
262
+ if (!this.title) {
263
+ const sessionId = this.sessionId;
264
+ this.title = new SessionTitle({ sdk: this.sdk, query: this.query, cwd: this.cwd, sessionId, alive: () => ['starting', 'running'].includes(this.phase),
265
+ publish: title => this.update(sessionId, { sessionUpdate: 'session_info_update', title }) });
266
+ this.title.append(this.titleContext ?? ''); this.titleContext = '';
267
+ void this.title.refresh();
268
+ }
269
+ // Every native turn opens with an init. One no prompt of Coda's began, and no handling already holds, is Claude's own.
270
+ if (!this.prompts.size && !this.waits.handling) this.opening = m;
271
+ else this.announce(m);
272
+ return;
273
+ }
274
+ case 'commands_changed': this.commands = m.commands ?? []; return this.announceCommands();
275
+ case 'status': case 'compact_boundary': {
276
+ if (m.permissionMode && m.permissionMode !== this.current.mode) {
277
+ this.current.mode = m.permissionMode;
278
+ this.update(this.sessionId, { sessionUpdate: 'config_option_update', configOptions: this.options() });
279
+ }
280
+ if (m.status === 'compacting' && !this.compaction?.active) this.compaction = { id: m.uuid ?? randomUUID(), active: true };
281
+ if (m.status === 'compacting' || m.subtype === 'compact_boundary' || this.compaction?.active) {
282
+ this.compaction ??= { id: m.uuid ?? randomUUID(), active: false };
283
+ this.compaction.active = m.status === 'compacting';
284
+ this.update(this.sessionId, { sessionUpdate: 'compaction_update', compactionId: this.compaction.id,
285
+ status: this.compaction.active ? 'in_progress' : m.compact_result === 'failed' ? 'failed' : 'completed', error: m.compact_error ?? null });
286
+ if (!this.compaction.active) this.compaction = null;
287
+ }
288
+ return;
289
+ }
290
+ case 'local_command_output': {
291
+ const said = m.content ?? m.stdout;
292
+ if (typeof said === 'string') this.update(this.sessionId, { sessionUpdate: 'agent_message_chunk', messageId: m.uuid, content: { type: 'text', text: said } });
293
+ return;
294
+ }
295
+ case 'session_state_changed': if (m.state === 'idle') this.waits.settled(); return;
296
+ case 'task_started': return this.waits.started(m, { start: true });
297
+ case 'task_updated': {
298
+ if (m.patch?.is_backgrounded === true) this.waits.backgrounded(m.task_id);
299
+ if (m.patch?.description) this.waits.renamed(m.task_id, m.patch.description);
300
+ return this.waits.ended(m.task_id, m.patch?.status);
301
+ }
302
+ case 'task_notification': return this.waits.ended(m.task_id, m.status, { outputFile: m.output_file });
303
+ case 'background_tasks_changed': if (Array.isArray(m.tasks)) this.waits.snapshot(m.tasks);
304
+ }
305
+ }
306
+ // A turn Claude began on its own is told by what it handles, before anything said or asked in it; its opening message, when
307
+ // that comes first, may say what that is.
308
+ begin(opening) {
309
+ if (!this.opening) return;
310
+ const init = this.opening; this.opening = null;
311
+ this.waits.takeUp(opening);
312
+ this.announce(init);
313
+ }
314
+ // What a turn's init says: the settings the session runs with, and its commands.
315
+ announce(init) {
316
+ this.update(this.sessionId, { sessionUpdate: 'config_option_update', configOptions: this.options() });
317
+ this.announceCommands();
318
+ }
319
+ // Until the native init says which commands are the terminal's own, the list is not known.
320
+ announceCommands() {
321
+ if (this.terminal === null) return;
322
+ this.update(this.sessionId, { sessionUpdate: 'available_commands_update', availableCommands: this.commands.filter(c => !this.terminal.includes(c.name))
323
+ .map(c => ({ name: c.name, description: c.description ?? '', ...(c.argumentHint ? { input: { hint: c.argumentHint } } : {}) })) });
324
+ }
325
+ chunk(sessionId, type, messageId, said) {
326
+ if (!said) return;
327
+ this.update(sessionId, { sessionUpdate: type === 'text' ? 'agent_message_chunk' : 'agent_thought_chunk', ...(messageId ? { messageId } : {}),
328
+ content: { type: 'text', text: said } });
329
+ }
330
+ stream(event, parent) {
331
+ const owner = parent ?? 'root', sessionId = parent ?? this.sessionId;
332
+ if (event?.type === 'message_start') {
333
+ this.streams.set(owner, event.message.id);
334
+ this.streamed.set(event.message.id, new Map());
335
+ // Kept for the messages streaming now; a message whose complete form came long ago is not.
336
+ if (this.streamed.size > 64) this.streamed.delete(this.streamed.keys().next().value);
337
+ if (parent) this.waits.model(parent, event.message.model);
338
+ return;
339
+ }
340
+ const apiId = this.streams.get(owner), blocks = this.streamed.get(apiId);
341
+ if (!blocks) return;
342
+ if (event.type === 'content_block_start' && ['text', 'thinking'].includes(event.content_block?.type)) {
343
+ const block = { type: event.content_block.type, sent: 0, confirmed: false }, said = event.content_block.text ?? event.content_block.thinking ?? '';
344
+ blocks.set(event.index, block); block.sent += said.length; this.chunk(sessionId, block.type, apiId, said);
345
+ } else if (event.type === 'content_block_delta' && ['text_delta', 'thinking_delta'].includes(event.delta?.type)) {
346
+ const block = blocks.get(event.index), said = event.delta.text ?? event.delta.thinking ?? '';
347
+ if (!block) return;
348
+ block.sent += said.length; this.chunk(sessionId, block.type, apiId, said);
349
+ }
350
+ }
351
+ assistant(m, parent) {
352
+ const sessionId = parent ?? this.sessionId, apiId = m.message?.id, blocks = this.streamed.get(apiId);
353
+ if (parent) this.waits.model(parent, m.message?.model);
354
+ for (const block of m.message?.content ?? []) {
355
+ if (block.type === 'tool_use') {
356
+ this.tools.add(block.id);
357
+ this.update(sessionId, { sessionUpdate: 'tool_call', toolCallId: block.id, title: block.name, rawInput: block.input, status: 'in_progress' });
358
+ } else if (['text', 'thinking'].includes(block.type)) {
359
+ const said = block.text ?? block.thinking ?? '';
360
+ // One complete packet carries one block of the message: the first streamed one of its kind not yet confirmed.
361
+ const streamed = [...(blocks?.values() ?? [])].find(b => b.type === block.type && !b.confirmed);
362
+ if (streamed) streamed.confirmed = true;
363
+ this.chunk(sessionId, block.type, apiId, said.slice(streamed?.sent ?? 0));
364
+ if (!parent && block.type === 'text') this.title?.append(said);
365
+ }
366
+ }
367
+ }
368
+ toolResults(m, parent) {
369
+ for (const block of Array.isArray(m.message?.content) ? m.message.content : []) {
370
+ if (block.type !== 'tool_result' || !this.tools.has(block.tool_use_id)) continue;
371
+ const said = text(block.content);
372
+ this.update(parent ?? this.sessionId, { sessionUpdate: 'tool_call_update', toolCallId: block.tool_use_id, status: block.is_error ? 'failed' : 'completed',
373
+ rawOutput: m.tool_use_result ?? block.content, content: [{ type: 'content', content: { type: 'text', text: said } }] });
374
+ // Claude Code says where a command it put in the background writes in the launch's result.
375
+ const path = said.match(/Output is being written to: (\S+?\.output)\b/)?.[1];
376
+ if (path) this.waits.outputFile(block.tool_use_id, path);
377
+ }
378
+ }
379
+ result(m) {
380
+ const usage = m.usage ?? {}, windows = Object.values(m.modelUsage ?? {});
381
+ this.update(this.sessionId, { sessionUpdate: 'usage_update', used: (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0),
382
+ size: Math.max(0, ...windows.map(w => w.contextWindow ?? 0)), cost: { amount: m.total_cost_usd ?? 0, currency: 'USD' } });
383
+ const ids = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : []), id = ids.find(id => this.prompts.has(id));
384
+ const cancelled = interrupted(m), failed = !cancelled && (m.is_error || m.subtype !== 'success');
385
+ if (id) {
386
+ const prompt = this.prompts.get(id); this.prompts.delete(id);
387
+ // What ended before this turn ended was taken in by it.
388
+ this.waits.settled();
389
+ if (failed) prompt.reject(failure(errorText(m))); else prompt.resolve({ stopReason: cancelled ? 'cancelled' : 'end_turn' });
390
+ } else if (this.waits.handling) {
391
+ // A turn of Claude's own has no reply to fail: what went wrong is said in it.
392
+ if (failed) this.chunk(this.sessionId, 'text', m.uuid, errorText(m));
393
+ this.waits.finished();
394
+ } else if (failed && this.prompts.size) {
395
+ // A failure of the session itself names no input: none of those given can be said to have run.
396
+ for (const prompt of this.prompts.values()) prompt.reject(failure(errorText(m)));
397
+ this.prompts.clear();
398
+ }
399
+ void this.title?.refresh(!failed && !cancelled);
400
+ }
401
+ async close() {
402
+ if (this.phase === 'ended' || this.phase === 'closing') return this.pump;
403
+ this.phase = 'closing'; this.input.close();
404
+ for (const withdrawn of this.withdrawals) withdrawn.abort();
405
+ try { this.query?.close(); } catch {}
406
+ if (this.pump) await this.pump; else this.phase = 'ended';
407
+ }
408
+ }
@@ -0,0 +1,36 @@
1
+ // SDK headless sessions do not automatically generate the CLI's topic title.
2
+ // Use the same native generator as the ACP adapter, without blocking the event
3
+ // pump or asking the main conversation to produce extra output.
4
+ export class SessionTitle {
5
+ context = ''; settled = false; pending = null; last = null;
6
+ constructor({ sdk, query, cwd, sessionId, publish, alive, onError = () => {} }) {
7
+ Object.assign(this, { sdk, query, cwd, sessionId, publish, alive, onError });
8
+ }
9
+ append(text) { if (!this.settled) this.context = (this.context + text).slice(-1000); }
10
+ refresh(generate = false) {
11
+ // Serialize reads/generation; a result arriving during the initial read
12
+ // must still get its chance to generate a title.
13
+ this.pending = (this.pending ?? Promise.resolve()).then(() => this.update(generate))
14
+ .catch(error => this.onError(error)).catch(() => {});
15
+ return this.pending;
16
+ }
17
+ async update(generate) {
18
+ if (!this.alive()) return;
19
+ const info = await this.sdk.getSessionInfo(this.sessionId, { dir: this.cwd });
20
+ let title = info?.customTitle;
21
+ if (!title && generate && !this.settled && this.context.trim().length >= 10 &&
22
+ typeof this.query.generateSessionTitle === 'function') {
23
+ title = await this.query.generateSessionTitle(this.context.trim(), { persist: false });
24
+ if (!this.alive()) return;
25
+ // A /rename completed while generation was in flight takes precedence.
26
+ const current = await this.sdk.getSessionInfo(this.sessionId, { dir: this.cwd });
27
+ if (current?.customTitle) title = current.customTitle;
28
+ else if (typeof title === 'string' && title.trim())
29
+ await this.sdk.renameSession(this.sessionId, title, { dir: this.cwd });
30
+ }
31
+ if (!this.alive() || typeof title !== 'string' || !title.trim()) return;
32
+ title = title.replace(/\s+/g, ' ').trim();
33
+ this.settled = true; this.context = '';
34
+ if (title !== this.last) { await this.publish(title); this.last = title; }
35
+ }
36
+ }
@@ -0,0 +1,229 @@
1
+ // What Claude Code keeps going beside a turn, told to Coda as waits of its own (docs/agent-protocol.md「_coda/wait」): its
2
+ // background tasks and subagents as the SDK reports them, and the wakeups it scheduled as its Stop hook lists them. A
3
+ // subagent's session is the tool call that launched it; continued (SendMessage), it is another run under the same session.
4
+ // What a subagent launches (`parent_task_id`) is told in that subagent's session, once a wait has told Coda that session.
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
+ // 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; what it handles ends with that turn.
8
+ const FINAL = { completed: 'done', failed: 'failed', killed: 'cancelled', stopped: 'cancelled', ended: 'done' };
9
+ const open = state => state === 'waiting' || state === 'handling';
10
+ const cut = (value, limit) => typeof value === 'string' && value ? value.slice(0, limit) : undefined;
11
+
12
+ export class ClaudeWaits {
13
+ // `say`: a wait's report, and its session when that is a subagent's; `going`: whether a turn goes; `registered`: a subagent's
14
+ // session is known.
15
+ constructor({ say, going = () => false, registered = () => {}, now = Date.now }) {
16
+ this.say = say; this.going = going; this.registered = registered; this.now = now;
17
+ this.tasks = new Map(); this.crons = new Map(); this.own = new Set(); this.turns = 0;
18
+ // Commands in the foreground, by task id: each is its own tool call, which the turn (or the subagent) blocks on.
19
+ this.foreground = new Map();
20
+ // What is known of a launching tool call before its task is: a subagent's model, a command's output file.
21
+ this.models = new Map(); this.outputs = new Map();
22
+ }
23
+ get handling() { return this.own.size > 0 || [...this.tasks.values(), ...this.crons.values()].some(w => w.state === 'handling'); }
24
+ report(wait, state, fields = {}) {
25
+ wait.state = state;
26
+ const params = { waitId: wait.waitId, state, ...Object.fromEntries(Object.entries(fields).filter(([, value]) => value !== undefined)) };
27
+ // Kept while the session it is in is not yet told (`named`).
28
+ if (wait.held) wait.held.push(params); else this.say(params, wait.sessionId);
29
+ }
30
+ // A task begun (`task_started`, `start`), or listed among those in the background (`listed`).
31
+ started(native, { start = false, listed = false } = {}) {
32
+ if (native.ambient || typeof native.task_id !== 'string' || !native.task_id) return;
33
+ // A command in the foreground is not waited on until it is put in the background (`backgrounded`). A subagent in the
34
+ // foreground still is: its session is that card's.
35
+ if (native.is_backgrounded === false && native.task_type !== 'local_agent' && !this.tasks.has(native.task_id)) {
36
+ this.foreground.set(native.task_id, native);
37
+ return;
38
+ }
39
+ const known = this.tasks.get(native.task_id);
40
+ if (known) {
41
+ if (listed) known.listed = true;
42
+ // A snapshot names no tool call and may come before its run's start, even after its end: that start says the launching
43
+ // call, and with it a subagent's session. A start of the run already known is that run's, never another.
44
+ const sameRun = native.run_id && known.runId ? native.run_id === known.runId : !known.toolUseId;
45
+ if (start && sameRun && !known.toolUseId && native.tool_use_id) {
46
+ const toolUseId = cut(native.tool_use_id, 200), subagent = known.kind === 'subagent';
47
+ Object.assign(known, { toolUseId, runId: known.runId ?? native.run_id }, subagent ? { childSessionId: toolUseId } : {});
48
+ if (open(known.state)) this.report(known, known.state, { childSessionId: subagent ? toolUseId : undefined, toolCallId: toolUseId,
49
+ outputFile: subagent ? undefined : this.outputs.get(toolUseId), model: subagent ? this.models.get(toolUseId) : undefined });
50
+ if (subagent && !known.held) this.named(known);
51
+ // An end held for its session is told now while a turn goes, which takes it in.
52
+ if (known.final !== undefined && known.state === 'waiting' && this.going()) { this.report(known, known.final); known.final = undefined; }
53
+ return;
54
+ }
55
+ // Only a start of another run under another call brings a subagent that ended back; snapshots and repeats cannot.
56
+ if (!start || sameRun || known.kind !== 'subagent' || (open(known.state) && known.final === undefined) || !native.tool_use_id || native.tool_use_id === known.toolUseId) return;
57
+ // Its last run ends before the next begins; one the turn going handles leaves it to a wait of the turn's own.
58
+ if (known.state === 'handling' && this.own.size === 0) this.hold();
59
+ if (known.final !== undefined) { this.report(known, known.final); known.final = undefined; }
60
+ known.run++; Object.assign(known, { waitId: `${known.taskId}#${known.run}`, toolUseId: native.tool_use_id, runId: native.run_id });
61
+ this.report(known, 'waiting', { kind: 'subagent', title: cut(native.description, 500) ?? '子 Agent', childSessionId: known.childSessionId,
62
+ stoppable: true, model: this.models.get(known.childSessionId) });
63
+ return;
64
+ }
65
+ const subagent = native.task_type === 'local_agent', toolUseId = cut(native.tool_use_id, 200);
66
+ const task = { taskId: native.task_id, waitId: native.task_id.slice(0, 200), run: 1, kind: subagent ? 'subagent' : 'task', toolUseId, runId: native.run_id,
67
+ listed: listed || native.is_backgrounded === true, parentTaskId: native.parent_task_id, ...(subagent ? { childSessionId: toolUseId ?? native.task_id.slice(0, 200) } : {}) };
68
+ if (task.parentTaskId) {
69
+ const parent = this.tasks.get(task.parentTaskId);
70
+ if (parent?.toolUseId && !parent.held) task.sessionId = parent.childSessionId; else task.held = [];
71
+ }
72
+ this.tasks.set(task.taskId, task);
73
+ this.report(task, 'waiting', { kind: task.kind, title: cut(native.description, 500) ?? (subagent ? '子 Agent' : '后台任务'), stoppable: true,
74
+ childSessionId: task.childSessionId, toolCallId: toolUseId, outputFile: this.outputs.get(toolUseId),
75
+ model: subagent ? this.models.get(task.childSessionId) : undefined });
76
+ if (subagent && !task.held) this.named(task);
77
+ }
78
+ // A subagent's session told to Coda: what it said there comes after, and what it launched is told there, held reports first.
79
+ named(subagent) {
80
+ this.registered(subagent.childSessionId);
81
+ if (!subagent.toolUseId) return;
82
+ for (const task of this.tasks.values()) {
83
+ if (!task.held || task.parentTaskId !== subagent.taskId) continue;
84
+ const held = task.held;
85
+ task.held = undefined; task.sessionId = subagent.childSessionId;
86
+ for (const params of held) this.say(params, task.sessionId);
87
+ if (task.kind === 'subagent') this.named(task);
88
+ }
89
+ }
90
+ // A command in the foreground put in the background: from now on it is waited on, as begun there.
91
+ backgrounded(taskId) {
92
+ const native = this.foreground.get(taskId);
93
+ if (!native) return;
94
+ this.foreground.delete(taskId);
95
+ this.started({ ...native, is_backgrounded: true }, { start: true });
96
+ }
97
+ renamed(taskId, description) {
98
+ const task = this.tasks.get(taskId);
99
+ if (task && open(task.state) && cut(description, 500)) this.report(task, task.state, { title: cut(description, 500) });
100
+ }
101
+ // A task's end (`status` as the SDK says it; `ended` when it left the list without one): told now while a turn goes, which
102
+ // takes in its result; otherwise once a turn has taken it up.
103
+ ended(taskId, status, { outputFile } = {}) {
104
+ this.foreground.delete(taskId);
105
+ const task = this.tasks.get(taskId), final = FINAL[status];
106
+ if (!task || !final || !open(task.state)) return;
107
+ if (task.final !== undefined) { if (status !== 'ended') task.final = final; return; }
108
+ const fields = { outputFile: cut(outputFile, 4096) };
109
+ // A subagent whose session is not yet known ends once it is (`started`), or as the turn or the session settles.
110
+ if (task.state === 'handling' || !this.going() || (task.kind === 'subagent' && !task.toolUseId)) {
111
+ Object.assign(task, { final, endedAt: this.now() });
112
+ if (fields.outputFile) this.report(task, task.state, fields);
113
+ } else this.report(task, final, fields);
114
+ }
115
+ snapshot(list) {
116
+ const live = new Set(list.map(native => native.task_id));
117
+ for (const task of this.tasks.values()) if (task.listed && !live.has(task.taskId)) this.ended(task.taskId, 'ended');
118
+ for (const native of list) { this.backgrounded(native.task_id); this.started(native, { listed: true }); }
119
+ }
120
+ // The Stop hook's list of the wakeups the session has, complete each time.
121
+ scheduled(list) {
122
+ const listed = new Set();
123
+ for (const native of list) {
124
+ listed.add(native.id);
125
+ if (this.crons.has(native.id)) continue;
126
+ const cron = { waitId: native.id.slice(0, 200), schedule: native.schedule, recurring: native.recurring, prompt: native.prompt, at: nextRun(native.schedule, this.now()) };
127
+ this.crons.set(native.id, cron);
128
+ this.report(cron, 'waiting', { kind: 'timer', title: cut(native.prompt, 100) ?? cut(native.schedule, 500), detail: cut(native.schedule, 4000),
129
+ recurring: native.recurring, stoppable: false, at: cron.at === null ? undefined : new Date(cron.at).toISOString() });
130
+ }
131
+ for (const [id, cron] of this.crons) {
132
+ if (listed.has(id) || !open(cron.state)) continue;
133
+ if (cron.state === 'handling') cron.final = 'done'; else this.report(cron, 'done');
134
+ }
135
+ }
136
+ // The tasks that ended since the last turn: taken in by the turn that ended now, or not to wake Claude now it is idle.
137
+ settled() {
138
+ for (const task of this.tasks.values()) if (task.final !== undefined && task.state === 'waiting') { this.report(task, task.final); task.final = undefined; }
139
+ }
140
+ // A turn Claude began on its own: what it handles. `origin`, `text`: its opening message's, when that came first.
141
+ takeUp({ origin, text } = {}) {
142
+ const pending = [...this.tasks.values()].filter(task => task.final !== undefined && task.state === 'waiting');
143
+ const crons = [...this.crons.values()].filter(cron => open(cron.state));
144
+ let handled = [];
145
+ if (origin?.kind === 'task-notification') handled = origin.subkind === 'scheduled-trigger' ? crons.filter(cron => cron.prompt && cron.prompt === text)
146
+ : [...this.tasks.values()].filter(task => origin.runId && task.runId === origin.runId && open(task.state));
147
+ if (!handled.length) handled = pending.sort((a, b) => a.endedAt - b.endedAt);
148
+ if (!handled.length) {
149
+ // Which came due is not told: the one due, or else the only one there is.
150
+ const due = crons.filter(cron => cron.at !== null && cron.at <= this.now() + 60_000);
151
+ if ((due.length || crons.length) === 1) handled = due.length ? due : crons;
152
+ }
153
+ if (handled.length) for (const wait of handled) this.report(wait, 'handling');
154
+ else this.hold();
155
+ }
156
+ hold() {
157
+ const own = { waitId: `turn-${++this.turns}` };
158
+ this.own.add(own);
159
+ this.report(own, 'handling', { kind: 'task', title: 'Claude 自主继续', stoppable: false });
160
+ }
161
+ // That turn's end: what it handled ended with it, a recurring wakeup waits for its next time.
162
+ finished() {
163
+ for (const task of this.tasks.values()) if (task.state === 'handling') { this.report(task, task.final ?? 'done'); task.final = undefined; }
164
+ for (const cron of this.crons.values()) {
165
+ if (cron.state !== 'handling') continue;
166
+ if (cron.final !== undefined || !cron.recurring) { this.report(cron, 'done'); continue; }
167
+ cron.at = nextRun(cron.schedule, this.now());
168
+ this.report(cron, 'waiting', { at: cron.at === null ? undefined : new Date(cron.at).toISOString() });
169
+ }
170
+ for (const own of this.own) this.report(own, 'done');
171
+ this.own.clear();
172
+ }
173
+ // 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
174
+ // not waited for any more: it ends now ('').
175
+ stop(waitId) {
176
+ const task = [...this.tasks.values()].find(task => task.waitId === waitId && open(task.state));
177
+ if (!task) return null;
178
+ if (task.final === undefined) return task.taskId;
179
+ if (task.state === 'waiting') { this.report(task, task.final); task.final = undefined; }
180
+ return '';
181
+ }
182
+ childSession(agentId) { return typeof agentId === 'string' ? this.tasks.get(agentId)?.childSessionId ?? null : null; }
183
+ // A subagent's actual model, from its own replies (which can come before its task).
184
+ model(toolUseId, model) {
185
+ if (typeof model !== 'string' || !model.trim() || model === '<synthetic>') return;
186
+ this.models.set(toolUseId, model);
187
+ const task = [...this.tasks.values()].find(task => task.childSessionId === toolUseId && open(task.state));
188
+ if (task && task.model !== model) { task.model = model; this.report(task, task.state, { model: cut(model, 200) }); }
189
+ }
190
+ // Where a command put in the background writes, as its launch said.
191
+ outputFile(toolUseId, path) {
192
+ this.outputs.set(toolUseId, path);
193
+ const task = [...this.tasks.values()].find(task => task.toolUseId === toolUseId && task.kind === 'task' && open(task.state));
194
+ if (task) this.report(task, task.state, { outputFile: cut(path, 4096) });
195
+ }
196
+ }
197
+
198
+ // The next minute after `from` a five-field cron schedule matches, in local time (as Claude Code schedules); null when it
199
+ // cannot be read or never matches within five years.
200
+ export function nextRun(schedule, from = Date.now()) {
201
+ const fields = typeof schedule === 'string' ? schedule.trim().split(/\s+/) : [];
202
+ if (fields.length !== 5) return null;
203
+ const [minutes, hours, days, months, weekdays] = fields.map((field, i) => values(field, ...[[0, 59], [0, 23], [1, 31], [1, 12], [0, 7]][i]));
204
+ if (![minutes, hours, days, months, weekdays].every(Boolean)) return null;
205
+ if (weekdays.has(7)) weekdays.add(0);
206
+ // Both day fields restricted: either matches.
207
+ const day = date => fields[2] === '*' ? fields[4] === '*' || weekdays.has(date.getDay())
208
+ : fields[4] === '*' ? days.has(date.getDate()) : days.has(date.getDate()) || weekdays.has(date.getDay());
209
+ const t = new Date(from); t.setSeconds(0, 0); t.setMinutes(t.getMinutes() + 1);
210
+ for (const limit = from + 5 * 366 * 86_400_000; t.getTime() < limit;) {
211
+ if (!months.has(t.getMonth() + 1)) { t.setMonth(t.getMonth() + 1, 1); t.setHours(0, 0, 0, 0); }
212
+ else if (!day(t)) { t.setDate(t.getDate() + 1); t.setHours(0, 0, 0, 0); }
213
+ else if (!hours.has(t.getHours())) t.setHours(t.getHours() + 1, 0, 0, 0);
214
+ else if (!minutes.has(t.getMinutes())) t.setMinutes(t.getMinutes() + 1, 0, 0);
215
+ else return t.getTime();
216
+ }
217
+ return null;
218
+ }
219
+ function values(field, min, max) {
220
+ const set = new Set();
221
+ for (const part of field.split(',')) {
222
+ const m = /^(?:(\*)|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
223
+ if (!m) return null;
224
+ const low = m[1] ? min : Number(m[2]), high = m[1] ? max : m[3] !== undefined ? Number(m[3]) : m[4] ? max : low, step = m[4] ? Number(m[4]) : 1;
225
+ if (low < min || high > max || low > high || step < 1) return null;
226
+ for (let v = low; v <= high; v += step) set.add(v);
227
+ }
228
+ return set;
229
+ }
@@ -0,0 +1,15 @@
1
+ # Claude 品牌图形
2
+
3
+ 仅用于标识会话所用的服务,不作为 Coda 自身标志。商标归各自权利人所有。
4
+
5
+ - `claude.svg`:取自 [Claude 官网](https://claude.com/) 导航字标中的独立花形路径,保留官方 `#D97757` 色和几何形状;供 Claude SDK 使用。
6
+
7
+ 提取日期:2026-09-19。SVG 是可审阅的源文件,PDF 是由 CairoSVG 转换的等比矢量资源,清单的 `icon` 指向 PDF。重新生成 PDF(开发工具,不是构建或运行依赖):
8
+
9
+ ```sh
10
+ uv run --with cairosvg python - <<'PY'
11
+ import cairosvg
12
+ for name in ['claude']:
13
+ cairosvg.svg2pdf(url=f'{name}.svg', write_to=f'{name}.pdf')
14
+ PY
15
+ ```
Binary file
@@ -0,0 +1,3 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="125" height="125" viewBox="0 0 125 125">
2
+ <path d="M54.375 118.75L56.125 111L58.125 101L59.75 93L61.25 83.125L62.125 79.875L62 79.625L61.375 79.75L53.875 90L42.5 105.375L33.5 114.875L31.375 115.75L27.625 113.875L28 110.375L30.125 107.375L42.5 91.5L50 81.625L54.875 76L54.75 75.25H54.5L21.5 96.75L15.625 97.5L13 95.125L13.375 91.25L14.625 90L24.5 83.125L49.125 69.375L49.5 68.125L49.125 67.5H47.875L43.75 67.25L29.75 66.875L17.625 66.375L5.75 65.75L2.75 65.125L0 61.375L0.25 59.5L2.75 57.875L6.375 58.125L14.25 58.75L26.125 59.5L34.75 60L47.5 61.375H49.5L49.75 60.5L49.125 60L48.625 59.5L36.25 51.25L23 42.5L16 37.375L12.25 34.75L10.375 32.375L9.625 27.125L13 23.375L17.625 23.75L18.75 24L23.375 27.625L33.25 35.25L46.25 44.875L48.125 46.375L49 45.875V45.5L48.125 44.125L41.125 31.375L33.625 18.375L30.25 13L29.375 9.75C29.0417 8.625 28.875 7.375 28.875 6L32.75 0.750006L34.875 0L40.125 0.750006L42.25 2.625L45.5 10L50.625 21.625L58.75 37.375L61.125 42.125L62.375 46.375L62.875 47.75H63.75V47L64.375 38L65.625 27.125L66.875 13.125L67.25 9.125L69.25 4.375L73.125 1.87501L76.125 3.25L78.625 6.875L78.25 9.125L76.875 18.75L73.875 33.875L72 44.125H73.125L74.375 42.75L79.5 36L88.125 25.25L91.875 21L96.375 16.25L99.25 14H104.625L108.5 19.875L106.75 26L101.25 33L96.625 38.875L90 47.75L86 54.875L86.375 55.375H87.25L102.125 52.125L110.25 50.75L119.75 49.125L124.125 51.125L124.625 53.125L122.875 57.375L112.625 59.875L100.625 62.25L82.75 66.5L82.5 66.625L82.75 67L90.75 67.75L94.25 68H102.75L118.5 69.125L122.625 71.875L125 75.125L124.625 77.75L118.25 80.875L109.75 78.875L89.75 74.125L83 72.5H82V73L87.75 78.625L98.125 88L111.25 100.125L111.875 103.125L110.25 105.625L108.5 105.375L97 96.625L92.5 92.75L82.5 84.375H81.875V85.25L84.125 88.625L96.375 107L97 112.625L96.125 114.375L92.875 115.5L89.5 114.875L82.25 104.875L74.875 93.5L68.875 83.375L68.25 83.875L64.625 121.625L63 123.5L59.25 125L56.125 122.625L54.375 118.75Z" fill="#D97757"></path>
3
+ </svg>
package/package.json CHANGED
@@ -1,7 +1,51 @@
1
1
  {
2
2
  "name": "@coda-console/claude",
3
- "version": "0.0.0",
4
- "description": "Reserved for the first Coda plugin release.",
3
+ "id": "claude",
4
+ "displayName": "Claude",
5
+ "description": "Claude SDK Agent:Coda 基于 Claude Agent SDK 写的 ACP 入口,一个进程一个 SDK 会话;资源页的「Claude」面板显示会话报告的额度,可从 Coda 登录。",
6
+ "version": "1.3.1",
7
+ "dependencies": {
8
+ "@agentclientprotocol/sdk": "1.4.0",
9
+ "@anthropic-ai/claude-agent-sdk": "0.3.296"
10
+ },
11
+ "contributes": {
12
+ "agents": [
13
+ {
14
+ "id": "claude-sdk",
15
+ "displayName": "Claude SDK",
16
+ "icon": {
17
+ "light": "icons/claude.pdf"
18
+ },
19
+ "command": "exec \"$CODA_NODE\" \"$CODA_PLUGIN_PATH/agent/entry.mjs\"",
20
+ "modes": {
21
+ "title": "审批模式",
22
+ "unset": "人工审批",
23
+ "auto": "auto",
24
+ "full": "bypassPermissions"
25
+ },
26
+ "skillsDir": ".claude/skills",
27
+ "configFiles": [
28
+ "${CLAUDE_CONFIG_DIR:-~/.claude}/settings.json",
29
+ ".claude/settings.json",
30
+ ".claude/settings.local.json"
31
+ ],
32
+ "setupHint": "请在运行机配置 Claude SDK 认证;若指定了 token 文件,请检查路径和权限。"
33
+ }
34
+ ],
35
+ "panels": [
36
+ {
37
+ "id": "limits",
38
+ "title": "Claude",
39
+ "command": "node \"$CODA_PLUGIN_PATH/scripts/panel.mjs\"",
40
+ "signIn": {
41
+ "command": "exec node \"$CODA_PLUGIN_PATH/scripts/login.mjs\"",
42
+ "installed": "node \"$CODA_PLUGIN_PATH/scripts/login.mjs\" --installed",
43
+ "input": "authorizationCode",
44
+ "note": "登录保存在这台机器上,供 Claude SDK 使用;额度在会话运行后出现。这台机器若已配置 OAuth token、API key 或云服务商,会话仍优先用那些配置。"
45
+ }
46
+ }
47
+ ]
48
+ },
5
49
  "license": "MIT",
6
50
  "repository": {
7
51
  "type": "git",
@@ -10,5 +54,10 @@
10
54
  },
11
55
  "publishConfig": {
12
56
  "access": "public"
13
- }
57
+ },
58
+ "files": [
59
+ "agent",
60
+ "scripts",
61
+ "icons"
62
+ ]
14
63
  }
@@ -0,0 +1,76 @@
1
+ // The「Claude」panel's login (`signIn`): `claude auth login --claudeai` of the CLI that comes with the plugin's own Claude
2
+ // SDK (the one its sessions run), never another on the PATH. Coda shows the authorization link this prints
3
+ // and writes the code the page gave back to its input, once (docs/plugins.md「面板的登录」). Only the official link is
4
+ // passed on, and only a code bound to it is handed to the CLI, which does the exchange, keeps and renews the credentials.
5
+ // What the CLI says otherwise is not passed on: it may carry the code. `--installed` says whether that CLI is there.
6
+ import { spawn } from 'node:child_process';
7
+ import { createRequire } from 'node:module';
8
+ import { resolve } from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
10
+ import { stripVTControlCharacters } from 'node:util';
11
+
12
+ // Resolved beside the SDK, as its query() resolves it.
13
+ export function claudeBinary() {
14
+ const sdk = createRequire(createRequire(import.meta.url).resolve('@anthropic-ai/claude-agent-sdk'));
15
+ const platform = process.platform, arch = process.arch;
16
+ const musl = platform === 'linux' && !process.report.getReport().header.glibcVersionRuntime;
17
+ for (const suffix of platform === 'linux' ? (musl ? ['-musl', ''] : ['', '-musl']) : ['']) {
18
+ try { return sdk.resolve(`@anthropic-ai/claude-agent-sdk-${platform}-${arch}${suffix}/claude${platform === 'win32' ? '.exe' : ''}`); }
19
+ catch {}
20
+ }
21
+ return null;
22
+ }
23
+ // The official authorization link among whole lines of what the CLI printed, terminal hyperlinks included.
24
+ export function authorization(output) {
25
+ const lines = stripVTControlCharacters(output.slice(0, output.lastIndexOf('\n') + 1));
26
+ for (const match of lines.matchAll(/https:\/\/[^\s]+/g)) {
27
+ try {
28
+ const url = new URL(match[0]);
29
+ if (((url.origin === 'https://claude.ai' && url.pathname === '/oauth/authorize') ||
30
+ (url.origin === 'https://claude.com' && url.pathname === '/cai/oauth/authorize')) &&
31
+ url.searchParams.get('state') && url.searchParams.get('code_challenge') && url.searchParams.get('response_type') === 'code') return url;
32
+ } catch {}
33
+ }
34
+ return null;
35
+ }
36
+ // The code the page gives is `<code>#<state>`: one for another login is not handed over.
37
+ export const bound = (code, url) => /^[A-Za-z0-9_-]+#[A-Za-z0-9_-]+$/.test(code) && code.split('#')[1] === url.searchParams.get('state');
38
+
39
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
40
+ const binary = claudeBinary();
41
+ if (process.argv[2] === '--installed') process.exit(binary ? 0 : 1);
42
+ if (!binary) { console.error('找不到 Claude SDK 自带的 CLI'); process.exit(1); }
43
+ const env = { ...process.env, BROWSER: '/usr/bin/true', NO_COLOR: '1' };
44
+ // auth login otherwise silently imports this token instead of opening a fresh browser login.
45
+ delete env.CLAUDE_CODE_OAUTH_REFRESH_TOKEN; delete env.CLAUDE_CODE_OAUTH_SCOPES;
46
+ const child = spawn(binary, ['auth', 'login', '--claudeai'], { env, stdio: ['pipe', 'pipe', 'pipe'] });
47
+ child.stdin.on('error', () => {});
48
+ let output = '', url = null, failed = null;
49
+ const heard = chunk => {
50
+ if (url) return;
51
+ output += chunk;
52
+ url = authorization(output);
53
+ if (url) console.log(url.href);
54
+ };
55
+ child.stdout.on('data', heard); child.stderr.on('data', heard);
56
+ // Coda writes the code once, ending it with a newline.
57
+ let typed = '';
58
+ process.stdin.setEncoding('utf8').on('data', chunk => {
59
+ typed += chunk;
60
+ const end = typed.indexOf('\n');
61
+ if (end < 0) return;
62
+ const code = typed.slice(0, end).trim();
63
+ process.stdin.pause();
64
+ if (url && bound(code, url)) return child.stdin.write(`${code}\n`);
65
+ failed = '授权码不对:要贴这次登录的网页给出的完整授权码,请重新登录。';
66
+ child.kill();
67
+ });
68
+ child.on('error', () => { failed ??= 'Claude CLI 没能启动'; });
69
+ // Coda ends the whole group; one ended alone takes its CLI along.
70
+ for (const signal of ['SIGTERM', 'SIGINT']) process.on(signal, () => { child.kill(); process.exit(1); });
71
+ child.on('close', code => {
72
+ if (code === 0 && !failed) process.exit(0);
73
+ console.error(failed ?? 'Claude 登录没有完成,请重新登录。');
74
+ process.exit(1);
75
+ });
76
+ }
@@ -0,0 +1,6 @@
1
+ // The「Claude」panel's reading, printed once as `{ items }`, from what the sessions wrote into this plugin's state.
2
+ import { join } from 'node:path';
3
+ import { ClaudeSessionLimits } from '../agent/limits.mjs';
4
+ import { panel } from './quota.mjs';
5
+
6
+ console.log(JSON.stringify(panel(new ClaudeSessionLimits({ path: process.env.CODA_PLUGIN_STATE ? join(process.env.CODA_PLUGIN_STATE, 'limits.json') : null }))));
@@ -0,0 +1,11 @@
1
+ // What the「Claude」panel shows (docs/plugins.md「内置插件:Claude」): the plan windows this machine's Claude sessions last
2
+ // reported (agent/limits.mjs). Nothing reported yet shows nothing; the panel is then offered under「添加资源」 for signing in.
3
+ export function panel(limits) {
4
+ if (!limits.known()) return { items: [] };
5
+ const windows = limits.quota();
6
+ // The account the windows are of, compared across machines by Coda's group view.
7
+ return { items: [{ type: 'card', title: 'Claude SDK', ...(limits.account ? { identity: `claude:${limits.account}` } : {}), items: windows.length
8
+ ? windows.map(w => ({ type: 'meter', label: w.label, value: `剩余 ${w.remainingPercent}%`, fraction: w.remainingPercent / 100, time: w.resetsAt, timeLabel: '重置' }))
9
+ // Past its reset a window is not known, neither full nor empty: what was used since is not seen from here.
10
+ : [{ type: 'error', text: '额度记录已过期,会话再运行后更新' }] }] };
11
+ }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # @coda-console/claude
2
-
3
- Bootstrap placeholder. The usable release will be published by coda-console/coda-plugins.