@coda-console/claude 0.0.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent/auth.mjs +37 -0
- package/agent/entry.mjs +27 -0
- package/agent/limits.mjs +87 -0
- package/agent/models.mjs +41 -0
- package/agent/permissions.mjs +68 -0
- package/agent/providers.mjs +53 -0
- package/agent/questions.mjs +39 -0
- package/agent/session.mjs +462 -0
- package/agent/titles.mjs +36 -0
- package/agent/waits.mjs +231 -0
- package/icons/README.md +15 -0
- package/icons/claude.pdf +0 -0
- package/icons/claude.svg +3 -0
- package/package.json +64 -3
- package/scripts/login.mjs +76 -0
- package/scripts/panel.mjs +6 -0
- package/scripts/quota.mjs +11 -0
- package/README.md +0 -3
|
@@ -0,0 +1,462 @@
|
|
|
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 { chosenService, listedModels, serviceSettings } from './providers.mjs';
|
|
15
|
+
import { SessionTitle } from './titles.mjs';
|
|
16
|
+
import { MODEL_EFFORT, MODES, claudeModels, configOptions, reportedModel } from './models.mjs';
|
|
17
|
+
import { permissionRequest } from './permissions.mjs';
|
|
18
|
+
import { elicitationForm, questionForm } from './questions.mjs';
|
|
19
|
+
import { ClaudeWaits } from './waits.mjs';
|
|
20
|
+
|
|
21
|
+
// The Claude Agent SDK this entry runs, as the handshake's `agentInfo` names it (Coda's protocol「Agent 版本」).
|
|
22
|
+
const SDK = (() => {
|
|
23
|
+
try { return JSON.parse(readFileSync(new URL('./package.json', import.meta.resolve('@anthropic-ai/claude-agent-sdk')), 'utf8')).version; }
|
|
24
|
+
catch { return null; }
|
|
25
|
+
})();
|
|
26
|
+
|
|
27
|
+
// Coda's session instructions (docs/agent-protocol.md「会话指令」), ACP's `systemPrompt`: its text, '' when there is none. Only
|
|
28
|
+
// text appended to the agent's own is understood; anything else is refused before a session opens.
|
|
29
|
+
export function sessionInstructions(systemPrompt) {
|
|
30
|
+
if (systemPrompt === undefined || systemPrompt === null) return '';
|
|
31
|
+
if (systemPrompt.mode !== 'append') throw new RequestError(-32602, '会话指令只支持 append 模式');
|
|
32
|
+
const { content } = systemPrompt;
|
|
33
|
+
if (!Array.isArray(content) || content.some(block => block?.type !== 'text' || typeof block.text !== 'string')) throw new RequestError(-32602, '会话指令只接受文本块');
|
|
34
|
+
return content.map(block => block.text).join('\n\n');
|
|
35
|
+
}
|
|
36
|
+
const text = content => typeof content === 'string' ? content : Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
|
|
37
|
+
const interrupted = m => ['interrupt', 'cancelled'].includes(m.stop_reason) || ['aborted_streaming', 'aborted_tools'].includes(m.terminal_reason);
|
|
38
|
+
const errorText = m => (m.errors?.length ? m.errors : [m.result || m.subtype]).join('\n');
|
|
39
|
+
const failure = (message, code = -32603) => new RequestError(code, message);
|
|
40
|
+
const denied = { behavior: 'deny', message: 'Coda 没有在请求结束前给出审批' };
|
|
41
|
+
// A child session's output that comes before its task, kept until the task names that session: all of it, since what streamed
|
|
42
|
+
// counts as said, a message's streamed pieces as one.
|
|
43
|
+
const CHUNKS = new Set(['agent_message_chunk', 'agent_thought_chunk']);
|
|
44
|
+
|
|
45
|
+
class InputStream {
|
|
46
|
+
queue = []; wake = null; ended = false;
|
|
47
|
+
push(message) { this.queue.push(message); this.wake?.(); }
|
|
48
|
+
close() { this.ended = true; this.wake?.(); }
|
|
49
|
+
async *[Symbol.asyncIterator]() {
|
|
50
|
+
while (!this.ended) {
|
|
51
|
+
if (this.queue.length) yield this.queue.shift();
|
|
52
|
+
else await new Promise(resolve => { this.wake = resolve; });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ClaudeSession {
|
|
58
|
+
// `env`: what the SDK's process is started with; `onEnded`: the query ended without being closed.
|
|
59
|
+
constructor({ sdk, env = process.env, environment = sdkEnvironment, initializationTimeoutMs = 60_000, onEnded = () => {} }) {
|
|
60
|
+
Object.assign(this, { sdk, env, environment, initializationTimeoutMs, onEnded });
|
|
61
|
+
this.phase = 'new'; this.input = new InputStream(); this.prompts = new Map(); this.withdrawals = new Set();
|
|
62
|
+
// The model service chosen in Coda (providers.mjs), and the model of it the query runs on: null on Claude's own.
|
|
63
|
+
this.service = chosenService(env); this.serviceModel = null;
|
|
64
|
+
this.models = []; this.commands = []; this.terminal = null; this.current = { mode: 'default', model: undefined, effort: null };
|
|
65
|
+
// Streamed text by API message, so that the complete message adds only what was not streamed; tool calls said.
|
|
66
|
+
this.streams = new Map(); this.streamed = new Map(); this.tools = new Set(); this.children = new Set(); this.early = new Map(); this.compaction = null;
|
|
67
|
+
this.waits = new ClaudeWaits({ say: (params, sessionId = this.sessionId) => this.notify('_coda/wait', { sessionId, ...params }),
|
|
68
|
+
going: () => this.prompts.size > 0 || this.waits.handling, registered: sessionId => this.registered(sessionId) });
|
|
69
|
+
}
|
|
70
|
+
connect(stream) {
|
|
71
|
+
this.connection = agent({ name: 'coda-claude' })
|
|
72
|
+
.onRequest('initialize', () => ({ protocolVersion: PROTOCOL_VERSION, agentInfo: SDK ? { name: 'claude-agent-sdk', version: SDK } : { name: 'coda-claude', version: '1' },
|
|
73
|
+
agentCapabilities: { loadSession: true, sessionCapabilities: { additionalDirectories: {}, systemPrompt: {} }, _meta: { coda: { waits: {} } } } }))
|
|
74
|
+
// The SDK's own reading of these drops what its schema does not name, `systemPrompt` among them: they are read as sent.
|
|
75
|
+
.onRequest('session/new', params => params ?? {}, ({ params }) => this.open(params))
|
|
76
|
+
.onRequest('session/load', params => params ?? {}, ({ params }) => this.open(params, params.sessionId))
|
|
77
|
+
.onRequest('session/set_config_option', ({ params }) => this.configure(params.configId, params.value))
|
|
78
|
+
.onRequest('session/set_mode', async ({ params }) => { await this.configure('mode', params.modeId); return {}; })
|
|
79
|
+
.onRequest('session/prompt', ({ params }) => this.prompt(params))
|
|
80
|
+
.onNotification('session/cancel', ({ params }) => { if (params.sessionId === this.sessionId) void this.query?.interrupt().catch(() => {}); })
|
|
81
|
+
.onRequest('_coda/wait/stop', params => params, ({ params }) => this.stopWait(params))
|
|
82
|
+
.connect(stream);
|
|
83
|
+
this.client = this.connection.client;
|
|
84
|
+
return this.connection;
|
|
85
|
+
}
|
|
86
|
+
notify(method, params) { void this.client?.notify(method, params).catch(() => {}); }
|
|
87
|
+
update(sessionId, update) {
|
|
88
|
+
// Coda takes a child session's updates once a wait has named it.
|
|
89
|
+
if (sessionId !== this.sessionId && !this.children.has(sessionId)) {
|
|
90
|
+
if (!this.early.has(sessionId)) this.early.set(sessionId, []);
|
|
91
|
+
const early = this.early.get(sessionId), last = early.at(-1);
|
|
92
|
+
if (CHUNKS.has(update.sessionUpdate) && last?.sessionUpdate === update.sessionUpdate && last.messageId !== undefined && last.messageId === update.messageId)
|
|
93
|
+
early[early.length - 1] = { ...last, content: { type: 'text', text: last.content.text + update.content.text } };
|
|
94
|
+
else early.push(update);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
this.notify('session/update', { sessionId, update });
|
|
98
|
+
}
|
|
99
|
+
registered(sessionId) {
|
|
100
|
+
this.children.add(sessionId);
|
|
101
|
+
for (const update of this.early.get(sessionId) ?? []) this.notify('session/update', { sessionId, update });
|
|
102
|
+
this.early.delete(sessionId);
|
|
103
|
+
}
|
|
104
|
+
options() { return configOptions(this.models, this.current); }
|
|
105
|
+
|
|
106
|
+
async open({ cwd, mcpServers = [], additionalDirectories = [], systemPrompt }, resume) {
|
|
107
|
+
if (this.phase !== 'new') throw failure('每个 Claude 进程只有一个会话', -32600);
|
|
108
|
+
const instructions = sessionInstructions(systemPrompt);
|
|
109
|
+
this.phase = 'starting';
|
|
110
|
+
try {
|
|
111
|
+
const dir = await realpath(cwd);
|
|
112
|
+
if (!(await stat(dir)).isDirectory()) throw failure('cwd 不是目录', -32602);
|
|
113
|
+
if (resume && !await this.sdk.getSessionInfo(resume, { dir })) throw failure('原生 Claude 会话不存在', -32002);
|
|
114
|
+
const environment = await this.environment(this.env);
|
|
115
|
+
if (this.phase !== 'starting') throw failure('Claude 会话在启动时已关闭', -32001);
|
|
116
|
+
this.sessionId = resume ?? randomUUID(); this.cwd = dir;
|
|
117
|
+
// Coda's servers are stdio ones; the SDK keys them by name.
|
|
118
|
+
const servers = Object.fromEntries(mcpServers.filter(server => typeof server.command === 'string').map(({ name, command, args = [], env = [] }) =>
|
|
119
|
+
[name, { command, args, ...(env.length ? { env: Object.fromEntries(env.map(variable => [variable.name, variable.value])) } : {}) }]));
|
|
120
|
+
// What every query of the session starts with; a model of the chosen service runs in a query of its own (`reroute`).
|
|
121
|
+
this.launch = {
|
|
122
|
+
// Skipping every approval is a mode the user may switch to later; the SDK refuses it unless allowed at start.
|
|
123
|
+
permissionMode: 'default', allowDangerouslySkipPermissions: true, settingSources: ['user', 'project', 'local'],
|
|
124
|
+
// Coda's session instructions (what its clients render, what the project's plugins say) follow Claude Code's own.
|
|
125
|
+
systemPrompt: { type: 'preset', preset: 'claude_code', ...(instructions ? { append: instructions } : {}) },
|
|
126
|
+
// Sessions reach each other through Coda's send, which the user approves and both threads show. A native peer
|
|
127
|
+
// message would start a turn Coda cannot attribute. Continuing a native subagent is in-process and unaffected.
|
|
128
|
+
settings: { crossSessionInbound: 'refuse' },
|
|
129
|
+
includePartialMessages: true, forwardSubagentText: true, cwd: dir,
|
|
130
|
+
...(Object.keys(servers).length ? { mcpServers: servers } : {}), ...(additionalDirectories.length ? { additionalDirectories } : {}),
|
|
131
|
+
// Claude Code reports running and idle only when asked: idle is what ends a task's wait.
|
|
132
|
+
env: { ...environment, CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' },
|
|
133
|
+
// Stop supplies the complete list of scheduled wakeups. A CronCreate result alone cannot tell whether one was later
|
|
134
|
+
// deleted or fired.
|
|
135
|
+
hooks: { Stop: [{ hooks: [async input => {
|
|
136
|
+
if (input.session_id === this.sessionId && Array.isArray(input.session_crons)) this.waits.scheduled(input.session_crons.filter(cron =>
|
|
137
|
+
typeof cron?.id === 'string' && cron.id && typeof cron.schedule === 'string' && typeof cron.recurring === 'boolean').slice(0, 50));
|
|
138
|
+
return {};
|
|
139
|
+
}] }] },
|
|
140
|
+
canUseTool: (toolName, input, context) => this.permission(toolName, input, context),
|
|
141
|
+
onElicitation: (request, context) => this.elicitation(request, context),
|
|
142
|
+
stderr: data => process.stderr.write(data),
|
|
143
|
+
};
|
|
144
|
+
const listed = listedModels(this.service);
|
|
145
|
+
this.start(resume ? { resume } : { sessionId: this.sessionId });
|
|
146
|
+
const initialization = await this.initialized(), served = await listed;
|
|
147
|
+
if (this.phase !== 'starting') throw failure('Claude 会话在初始化时结束', -32001);
|
|
148
|
+
this.models = [...claudeModels(initialization.models), ...served]; 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
|
+
// A query of the session, reading a prompt stream of its own: on Claude's own models, or on `model` of the chosen
|
|
163
|
+
// service, which it runs with that service as its API and that model for every role. One started after the session
|
|
164
|
+
// opened goes on with the model, mode and effort chosen.
|
|
165
|
+
start(at, model) {
|
|
166
|
+
this.input = new InputStream();
|
|
167
|
+
const entry = this.served(model);
|
|
168
|
+
const settings = { ...this.launch.settings, ...(this.current.effort ? { effortLevel: this.current.effort } : {}),
|
|
169
|
+
...(entry ? serviceSettings(this.service, entry.model) : {}) };
|
|
170
|
+
this.serviceModel = entry ? model : null;
|
|
171
|
+
this.query = this.sdk.query({ prompt: this.input, options: { ...this.launch, ...at, settings,
|
|
172
|
+
...(this.phase === 'running' ? { permissionMode: this.current.mode, model: entry ? entry.model : this.current.model } : {}) } });
|
|
173
|
+
this.pump = this.consume(this.query);
|
|
174
|
+
}
|
|
175
|
+
async initialized() {
|
|
176
|
+
let timer;
|
|
177
|
+
return Promise.race([this.query.initializationResult(),
|
|
178
|
+
this.pump.then(() => { throw failure('Claude 会话在初始化时结束', -32001); }),
|
|
179
|
+
new Promise((_, reject) => { timer = setTimeout(() => reject(failure('Claude 初始化超时', -32001)), this.initializationTimeoutMs); })])
|
|
180
|
+
.finally(() => clearTimeout(timer));
|
|
181
|
+
}
|
|
182
|
+
// The model chosen is of another service than the query's (Claude's own, or the chosen service's): the session goes on in a
|
|
183
|
+
// query started for it, resuming what the last one said. Before a turn, as Coda sets a session's model before each.
|
|
184
|
+
async reroute() {
|
|
185
|
+
const previous = this.query;
|
|
186
|
+
this.query = null; this.input.close();
|
|
187
|
+
try { previous.close(); } catch {}
|
|
188
|
+
await this.pump;
|
|
189
|
+
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
190
|
+
// One that cannot be started, or does not start, ends the session, as one that ends of itself does: no later turn waits on it.
|
|
191
|
+
try {
|
|
192
|
+
const said = await this.sdk.getSessionInfo(this.sessionId, { dir: this.cwd });
|
|
193
|
+
this.start(said ? { resume: this.sessionId } : { sessionId: this.sessionId }, this.current.model);
|
|
194
|
+
if (this.title) this.title.query = this.query;
|
|
195
|
+
await this.initialized();
|
|
196
|
+
} catch (error) {
|
|
197
|
+
this.end(this.query, error);
|
|
198
|
+
throw error instanceof RequestError ? error : failure(error?.message || 'Claude 会话启动失败');
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
// The chosen service's model that `model` is; null for one of Claude's own.
|
|
202
|
+
served(model) { return this.models.find(m => m.id === model && m.model) ?? null; }
|
|
203
|
+
// Whether the next turn runs in another query than this one: on the service's models, or back on Claude's own.
|
|
204
|
+
rerouted() { return (this.served(this.current.model)?.id ?? null) !== this.serviceModel; }
|
|
205
|
+
async configure(configId, value) {
|
|
206
|
+
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
207
|
+
if (configId === 'mode') {
|
|
208
|
+
if (!MODES.some(mode => mode.id === value)) throw failure('审批模式不可用', -32602);
|
|
209
|
+
await this.query.setPermissionMode(value); this.current.mode = value;
|
|
210
|
+
} else if (configId === 'model') {
|
|
211
|
+
if (!this.models.some(model => model.id === value)) throw failure('模型不可用', -32602);
|
|
212
|
+
// Between Claude's own models the query switches; to or from the service's, the next turn runs in another one, and
|
|
213
|
+
// what this one still runs would end with it.
|
|
214
|
+
const switching = (this.served(value)?.id ?? null) !== this.serviceModel;
|
|
215
|
+
if (switching && (this.prompts.size || this.waits.pending)) throw failure('后台任务、子 Agent 或定时唤醒还没结束,结束后才能换到这个模型', -32602);
|
|
216
|
+
if (!switching && !this.serviceModel) await this.query.setModel(value);
|
|
217
|
+
this.current.model = value;
|
|
218
|
+
// An effort chosen for the last model that this one does not have is no choice of it: the model's own goes on.
|
|
219
|
+
if (this.current.effort !== null && !this.models.find(model => model.id === value).efforts.includes(this.current.effort)) {
|
|
220
|
+
await this.query.applyFlagSettings({ effortLevel: null }); this.current.effort = null;
|
|
221
|
+
}
|
|
222
|
+
} else if (configId === 'effort') {
|
|
223
|
+
const effort = value === MODEL_EFFORT ? null : value;
|
|
224
|
+
if (effort !== null && !this.models.find(model => model.id === this.current.model)?.efforts.includes(effort)) throw failure('推理强度不适用于当前模型', -32602);
|
|
225
|
+
await this.query.applyFlagSettings({ effortLevel: effort }); this.current.effort = effort;
|
|
226
|
+
} else throw failure(`没有设置项 ${configId}`, -32602);
|
|
227
|
+
return { configOptions: this.options() };
|
|
228
|
+
}
|
|
229
|
+
// Answered when the native result that names this input arrives; accepting it into the input says nothing yet.
|
|
230
|
+
async prompt({ prompt }) {
|
|
231
|
+
if (this.phase !== 'running') throw failure('Claude 会话未在运行', -32001);
|
|
232
|
+
if (this.rerouted()) await (this.rerouting ??= this.reroute().finally(() => { this.rerouting = null; }));
|
|
233
|
+
const id = randomUUID(), content = prompt.filter(block => block.type === 'text').map(block => block.text).join('\n');
|
|
234
|
+
const answered = new Promise((resolve, reject) => this.prompts.set(id, { resolve, reject }));
|
|
235
|
+
this.input.push({ type: 'user', uuid: id, session_id: this.sessionId, parent_tool_use_id: null, message: { role: 'user', content } });
|
|
236
|
+
this.titleContext = ((this.titleContext ?? '') + '\n' + content + '\n').slice(-1000);
|
|
237
|
+
if (this.title) { this.title.append(this.titleContext); this.titleContext = ''; }
|
|
238
|
+
return answered;
|
|
239
|
+
}
|
|
240
|
+
async stopWait({ waitId }) {
|
|
241
|
+
const taskId = this.phase === 'running' ? this.waits.stop(waitId) : null;
|
|
242
|
+
if (taskId === null) throw failure('没有可以停止的等待项', -32602);
|
|
243
|
+
if (taskId) await this.query.stopTask(taskId);
|
|
244
|
+
return {};
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
permission(toolName, input, { signal, ...context }) {
|
|
248
|
+
if (!['starting', 'running'].includes(this.phase)) return Promise.resolve(denied);
|
|
249
|
+
this.begin();
|
|
250
|
+
// A subagent's request is in its session.
|
|
251
|
+
const sessionId = this.waits.childSession(context.agentID) ?? this.sessionId;
|
|
252
|
+
return toolName === 'AskUserQuestion'
|
|
253
|
+
? this.ask('elicitation/create', questionForm({ sessionId, toolCallId: context.toolUseID, input }), signal, { behavior: 'deny', message: '问答已失效' })
|
|
254
|
+
: this.ask('session/request_permission', permissionRequest({ sessionId, toolName, input, context }), signal, denied);
|
|
255
|
+
}
|
|
256
|
+
elicitation(request, { signal }) {
|
|
257
|
+
if (!['starting', 'running'].includes(this.phase)) return Promise.resolve({ action: 'cancel' });
|
|
258
|
+
this.begin();
|
|
259
|
+
return this.ask('elicitation/create', elicitationForm({ sessionId: this.sessionId, request }), signal, { action: 'cancel' });
|
|
260
|
+
}
|
|
261
|
+
// Asked of Coda until it answers; withdrawn (`$/cancel_request`) once the SDK no longer waits for the answer.
|
|
262
|
+
async ask(method, { params, answer }, signal, fallback) {
|
|
263
|
+
if (signal?.aborted) return fallback;
|
|
264
|
+
const withdrawn = new AbortController(), withdraw = () => withdrawn.abort();
|
|
265
|
+
signal?.addEventListener('abort', withdraw, { once: true });
|
|
266
|
+
this.withdrawals.add(withdrawn);
|
|
267
|
+
try {
|
|
268
|
+
const response = await Promise.race([this.client.request(method, params, { cancellationSignal: withdrawn.signal }),
|
|
269
|
+
new Promise(resolve => withdrawn.signal.addEventListener('abort', () => resolve(null), { once: true }))]);
|
|
270
|
+
return withdrawn.signal.aborted ? fallback : answer(response);
|
|
271
|
+
} catch { return fallback; } finally { signal?.removeEventListener('abort', withdraw); this.withdrawals.delete(withdrawn); }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async consume(query) {
|
|
275
|
+
let error;
|
|
276
|
+
try {
|
|
277
|
+
for await (const message of query) {
|
|
278
|
+
try { this.receive(message); }
|
|
279
|
+
catch (failed) { process.stderr.write(`Claude 消息处理失败:${failed.message}\n`); }
|
|
280
|
+
}
|
|
281
|
+
} catch (value) { if (this.phase !== 'closing') error = value; }
|
|
282
|
+
// The session ends with its query; one it moved off, for another model's, ends with nothing to say.
|
|
283
|
+
if (query === this.query) this.end(query, error);
|
|
284
|
+
}
|
|
285
|
+
// The session ends with its query: what waits on it is let go, and the entry told unless it was closing.
|
|
286
|
+
end(query, error) {
|
|
287
|
+
if (this.phase === 'ended') return;
|
|
288
|
+
const intended = this.phase === 'closing';
|
|
289
|
+
this.phase = 'ended'; this.input.close();
|
|
290
|
+
for (const withdrawn of this.withdrawals) withdrawn.abort();
|
|
291
|
+
for (const { reject } of this.prompts.values()) reject(failure(error?.message ?? 'Claude 会话已结束'));
|
|
292
|
+
this.prompts.clear();
|
|
293
|
+
try { query?.close(); } catch {}
|
|
294
|
+
if (!intended) this.onEnded(error);
|
|
295
|
+
}
|
|
296
|
+
receive(m) {
|
|
297
|
+
const parent = m.parent_tool_use_id ?? null;
|
|
298
|
+
this.begin(m.type === 'user' && !parent && m.origin ? { origin: m.origin, text: text(m.message?.content) } : {});
|
|
299
|
+
switch (m.type) {
|
|
300
|
+
case 'system': return this.system(m);
|
|
301
|
+
case 'stream_event': return this.stream(m.event, parent);
|
|
302
|
+
case 'assistant': return this.assistant(m, parent);
|
|
303
|
+
case 'user': return this.toolResults(m, parent);
|
|
304
|
+
case 'result': return this.result(m);
|
|
305
|
+
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);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
system(m) {
|
|
309
|
+
switch (m.subtype) {
|
|
310
|
+
case 'init': {
|
|
311
|
+
if (m.session_id !== this.sessionId) return;
|
|
312
|
+
this.terminal = m.terminal_slash_commands ?? [];
|
|
313
|
+
// Its effort is the one applied, the model's own included: what was chosen stays what it was. A query on the
|
|
314
|
+
// service's model reports it by the service's name for it.
|
|
315
|
+
this.current = { ...this.current, model: this.serviceModel ?? reportedModel(this.models, this.current.model, m.model), mode: m.permissionMode ?? this.current.mode };
|
|
316
|
+
if (!this.title) {
|
|
317
|
+
const sessionId = this.sessionId;
|
|
318
|
+
this.title = new SessionTitle({ sdk: this.sdk, query: this.query, cwd: this.cwd, sessionId, alive: () => ['starting', 'running'].includes(this.phase),
|
|
319
|
+
publish: title => this.update(sessionId, { sessionUpdate: 'session_info_update', title }) });
|
|
320
|
+
this.title.append(this.titleContext ?? ''); this.titleContext = '';
|
|
321
|
+
void this.title.refresh();
|
|
322
|
+
}
|
|
323
|
+
// Every native turn opens with an init. One no prompt of Coda's began, and no handling already holds, is Claude's own.
|
|
324
|
+
if (!this.prompts.size && !this.waits.handling) this.opening = m;
|
|
325
|
+
else this.announce(m);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
case 'commands_changed': this.commands = m.commands ?? []; return this.announceCommands();
|
|
329
|
+
case 'status': case 'compact_boundary': {
|
|
330
|
+
if (m.permissionMode && m.permissionMode !== this.current.mode) {
|
|
331
|
+
this.current.mode = m.permissionMode;
|
|
332
|
+
this.update(this.sessionId, { sessionUpdate: 'config_option_update', configOptions: this.options() });
|
|
333
|
+
}
|
|
334
|
+
if (m.status === 'compacting' && !this.compaction?.active) this.compaction = { id: m.uuid ?? randomUUID(), active: true };
|
|
335
|
+
if (m.status === 'compacting' || m.subtype === 'compact_boundary' || this.compaction?.active) {
|
|
336
|
+
this.compaction ??= { id: m.uuid ?? randomUUID(), active: false };
|
|
337
|
+
this.compaction.active = m.status === 'compacting';
|
|
338
|
+
this.update(this.sessionId, { sessionUpdate: 'compaction_update', compactionId: this.compaction.id,
|
|
339
|
+
status: this.compaction.active ? 'in_progress' : m.compact_result === 'failed' ? 'failed' : 'completed', error: m.compact_error ?? null });
|
|
340
|
+
if (!this.compaction.active) this.compaction = null;
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
case 'local_command_output': {
|
|
345
|
+
const said = m.content ?? m.stdout;
|
|
346
|
+
if (typeof said === 'string') this.update(this.sessionId, { sessionUpdate: 'agent_message_chunk', messageId: m.uuid, content: { type: 'text', text: said } });
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
case 'session_state_changed': if (m.state === 'idle') this.waits.settled(); return;
|
|
350
|
+
case 'task_started': return this.waits.started(m, { start: true });
|
|
351
|
+
case 'task_updated': {
|
|
352
|
+
if (m.patch?.is_backgrounded === true) this.waits.backgrounded(m.task_id);
|
|
353
|
+
if (m.patch?.description) this.waits.renamed(m.task_id, m.patch.description);
|
|
354
|
+
return this.waits.ended(m.task_id, m.patch?.status);
|
|
355
|
+
}
|
|
356
|
+
case 'task_notification': return this.waits.ended(m.task_id, m.status, { outputFile: m.output_file });
|
|
357
|
+
case 'background_tasks_changed': if (Array.isArray(m.tasks)) this.waits.snapshot(m.tasks);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
// A turn Claude began on its own is told by what it handles, before anything said or asked in it; its opening message, when
|
|
361
|
+
// that comes first, may say what that is.
|
|
362
|
+
begin(opening) {
|
|
363
|
+
if (!this.opening) return;
|
|
364
|
+
const init = this.opening; this.opening = null;
|
|
365
|
+
this.waits.takeUp(opening);
|
|
366
|
+
this.announce(init);
|
|
367
|
+
}
|
|
368
|
+
// What a turn's init says: the settings the session runs with, and its commands.
|
|
369
|
+
announce(init) {
|
|
370
|
+
this.update(this.sessionId, { sessionUpdate: 'config_option_update', configOptions: this.options() });
|
|
371
|
+
this.announceCommands();
|
|
372
|
+
}
|
|
373
|
+
// Until the native init says which commands are the terminal's own, the list is not known.
|
|
374
|
+
announceCommands() {
|
|
375
|
+
if (this.terminal === null) return;
|
|
376
|
+
this.update(this.sessionId, { sessionUpdate: 'available_commands_update', availableCommands: this.commands.filter(c => !this.terminal.includes(c.name))
|
|
377
|
+
.map(c => ({ name: c.name, description: c.description ?? '', ...(c.argumentHint ? { input: { hint: c.argumentHint } } : {}) })) });
|
|
378
|
+
}
|
|
379
|
+
chunk(sessionId, type, messageId, said) {
|
|
380
|
+
if (!said) return;
|
|
381
|
+
this.update(sessionId, { sessionUpdate: type === 'text' ? 'agent_message_chunk' : 'agent_thought_chunk', ...(messageId ? { messageId } : {}),
|
|
382
|
+
content: { type: 'text', text: said } });
|
|
383
|
+
}
|
|
384
|
+
stream(event, parent) {
|
|
385
|
+
const owner = parent ?? 'root', sessionId = parent ?? this.sessionId;
|
|
386
|
+
if (event?.type === 'message_start') {
|
|
387
|
+
this.streams.set(owner, event.message.id);
|
|
388
|
+
this.streamed.set(event.message.id, new Map());
|
|
389
|
+
// Kept for the messages streaming now; a message whose complete form came long ago is not.
|
|
390
|
+
if (this.streamed.size > 64) this.streamed.delete(this.streamed.keys().next().value);
|
|
391
|
+
if (parent) this.waits.model(parent, event.message.model);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const apiId = this.streams.get(owner), blocks = this.streamed.get(apiId);
|
|
395
|
+
if (!blocks) return;
|
|
396
|
+
if (event.type === 'content_block_start' && ['text', 'thinking'].includes(event.content_block?.type)) {
|
|
397
|
+
const block = { type: event.content_block.type, sent: 0, confirmed: false }, said = event.content_block.text ?? event.content_block.thinking ?? '';
|
|
398
|
+
blocks.set(event.index, block); block.sent += said.length; this.chunk(sessionId, block.type, apiId, said);
|
|
399
|
+
} else if (event.type === 'content_block_delta' && ['text_delta', 'thinking_delta'].includes(event.delta?.type)) {
|
|
400
|
+
const block = blocks.get(event.index), said = event.delta.text ?? event.delta.thinking ?? '';
|
|
401
|
+
if (!block) return;
|
|
402
|
+
block.sent += said.length; this.chunk(sessionId, block.type, apiId, said);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
assistant(m, parent) {
|
|
406
|
+
const sessionId = parent ?? this.sessionId, apiId = m.message?.id, blocks = this.streamed.get(apiId);
|
|
407
|
+
if (parent) this.waits.model(parent, m.message?.model);
|
|
408
|
+
for (const block of m.message?.content ?? []) {
|
|
409
|
+
if (block.type === 'tool_use') {
|
|
410
|
+
this.tools.add(block.id);
|
|
411
|
+
this.update(sessionId, { sessionUpdate: 'tool_call', toolCallId: block.id, title: block.name, rawInput: block.input, status: 'in_progress' });
|
|
412
|
+
} else if (['text', 'thinking'].includes(block.type)) {
|
|
413
|
+
const said = block.text ?? block.thinking ?? '';
|
|
414
|
+
// One complete packet carries one block of the message: the first streamed one of its kind not yet confirmed.
|
|
415
|
+
const streamed = [...(blocks?.values() ?? [])].find(b => b.type === block.type && !b.confirmed);
|
|
416
|
+
if (streamed) streamed.confirmed = true;
|
|
417
|
+
this.chunk(sessionId, block.type, apiId, said.slice(streamed?.sent ?? 0));
|
|
418
|
+
if (!parent && block.type === 'text') this.title?.append(said);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
toolResults(m, parent) {
|
|
423
|
+
for (const block of Array.isArray(m.message?.content) ? m.message.content : []) {
|
|
424
|
+
if (block.type !== 'tool_result' || !this.tools.has(block.tool_use_id)) continue;
|
|
425
|
+
const said = text(block.content);
|
|
426
|
+
this.update(parent ?? this.sessionId, { sessionUpdate: 'tool_call_update', toolCallId: block.tool_use_id, status: block.is_error ? 'failed' : 'completed',
|
|
427
|
+
rawOutput: m.tool_use_result ?? block.content, content: [{ type: 'content', content: { type: 'text', text: said } }] });
|
|
428
|
+
// Claude Code says where a command it put in the background writes in the launch's result.
|
|
429
|
+
const path = said.match(/Output is being written to: (\S+?\.output)\b/)?.[1];
|
|
430
|
+
if (path) this.waits.outputFile(block.tool_use_id, path);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
result(m) {
|
|
434
|
+
const usage = m.usage ?? {}, windows = Object.values(m.modelUsage ?? {});
|
|
435
|
+
this.update(this.sessionId, { sessionUpdate: 'usage_update', used: (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0),
|
|
436
|
+
size: Math.max(0, ...windows.map(w => w.contextWindow ?? 0)), cost: { amount: m.total_cost_usd ?? 0, currency: 'USD' } });
|
|
437
|
+
const ids = m.user_message_uuids ?? (m.user_message_uuid ? [m.user_message_uuid] : []), id = ids.find(id => this.prompts.has(id));
|
|
438
|
+
const cancelled = interrupted(m), failed = !cancelled && (m.is_error || m.subtype !== 'success');
|
|
439
|
+
if (id) {
|
|
440
|
+
const prompt = this.prompts.get(id); this.prompts.delete(id);
|
|
441
|
+
// What ended before this turn ended was taken in by it.
|
|
442
|
+
this.waits.settled();
|
|
443
|
+
if (failed) prompt.reject(failure(errorText(m))); else prompt.resolve({ stopReason: cancelled ? 'cancelled' : 'end_turn' });
|
|
444
|
+
} else if (this.waits.handling) {
|
|
445
|
+
// A turn of Claude's own has no reply to fail: what went wrong is said in it.
|
|
446
|
+
if (failed) this.chunk(this.sessionId, 'text', m.uuid, errorText(m));
|
|
447
|
+
this.waits.finished();
|
|
448
|
+
} else if (failed && this.prompts.size) {
|
|
449
|
+
// A failure of the session itself names no input: none of those given can be said to have run.
|
|
450
|
+
for (const prompt of this.prompts.values()) prompt.reject(failure(errorText(m)));
|
|
451
|
+
this.prompts.clear();
|
|
452
|
+
}
|
|
453
|
+
void this.title?.refresh(!failed && !cancelled);
|
|
454
|
+
}
|
|
455
|
+
async close() {
|
|
456
|
+
if (this.phase === 'ended' || this.phase === 'closing') return this.pump;
|
|
457
|
+
this.phase = 'closing'; this.input.close();
|
|
458
|
+
for (const withdrawn of this.withdrawals) withdrawn.abort();
|
|
459
|
+
try { this.query?.close(); } catch {}
|
|
460
|
+
if (this.pump) await this.pump; else this.phase = 'ended';
|
|
461
|
+
}
|
|
462
|
+
}
|
package/agent/titles.mjs
ADDED
|
@@ -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
|
+
}
|