qcanvas-local-agent 0.1.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/README.md +36 -0
- package/dist/agents.d.ts +90 -0
- package/dist/agents.js +748 -0
- package/dist/canvas-session.d.ts +16 -0
- package/dist/canvas-session.js +154 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +150 -0
- package/dist/generation-flow-compiler.d.ts +67 -0
- package/dist/generation-flow-compiler.js +207 -0
- package/dist/http-server.d.ts +1 -0
- package/dist/http-server.js +275 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +10 -0
- package/dist/mcp-server.d.ts +1 -0
- package/dist/mcp-server.js +49 -0
- package/dist/schemas.d.ts +257 -0
- package/dist/schemas.js +78 -0
- package/dist/types.d.ts +35 -0
- package/dist/types.js +1 -0
- package/package.json +44 -0
package/dist/agents.js
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { AGENT_PROMPT, VERSION } from "./config.js";
|
|
8
|
+
export const LOCAL_AGENTS = [
|
|
9
|
+
{ id: "codex", label: "Codex", threads: true },
|
|
10
|
+
{ id: "claude", label: "Claude", threads: false },
|
|
11
|
+
];
|
|
12
|
+
export function isLocalAgentId(value) {
|
|
13
|
+
return typeof value === "string" && LOCAL_AGENTS.some((agent) => agent.id === value);
|
|
14
|
+
}
|
|
15
|
+
let codexQueue = Promise.resolve();
|
|
16
|
+
let codexApp = null;
|
|
17
|
+
let codexThreadId = "";
|
|
18
|
+
const canvasAgentMcp = canvasAgentMcpCommand();
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
export function withAgentPrompt(prompt) {
|
|
21
|
+
return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
|
|
22
|
+
}
|
|
23
|
+
export async function runCodexTurn(prompt, emit, attachments = [], options = {}) {
|
|
24
|
+
if (!prompt.trim())
|
|
25
|
+
return;
|
|
26
|
+
codexQueue = codexQueue.catch(() => undefined).then(() => runCodexTurnNow(prompt, emit, attachments, options));
|
|
27
|
+
await codexQueue;
|
|
28
|
+
}
|
|
29
|
+
async function runCodexTurnNow(prompt, emit, attachments, options) {
|
|
30
|
+
let files = [];
|
|
31
|
+
try {
|
|
32
|
+
files = await writeAttachmentFiles(attachments);
|
|
33
|
+
codexApp ||= await CodexAppClient.start(emit);
|
|
34
|
+
const threadId = await ensureCodexThread(codexApp, options);
|
|
35
|
+
await codexApp.startTurn(threadId, prompt, files);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
emit("agent_error", { agent: "codex", message: errorMessage(error) });
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
await Promise.all(files.map((file) => fs.unlink(file).catch(() => undefined)));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export async function startCodexThread(emit, cwd) {
|
|
45
|
+
codexApp ||= await CodexAppClient.start(emit);
|
|
46
|
+
const thread = await codexApp.startThread(cwd);
|
|
47
|
+
codexThreadId = String(field(thread, "id") || "");
|
|
48
|
+
return thread;
|
|
49
|
+
}
|
|
50
|
+
export async function resumeCodexThread(emit, threadId, cwd) {
|
|
51
|
+
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
|
52
|
+
return { thread, messages: threadMessages(thread) };
|
|
53
|
+
}
|
|
54
|
+
export async function listCodexThreads(emit, options) {
|
|
55
|
+
codexApp ||= await CodexAppClient.start(emit);
|
|
56
|
+
const result = await codexApp.listThreads({
|
|
57
|
+
limit: options.limit || 40,
|
|
58
|
+
sortKey: "updated_at",
|
|
59
|
+
sortDirection: "desc",
|
|
60
|
+
sourceKinds: ["cli", "vscode", "appServer", "exec"],
|
|
61
|
+
cwd: options.cwd,
|
|
62
|
+
...(options.searchTerm ? { searchTerm: options.searchTerm } : {}),
|
|
63
|
+
});
|
|
64
|
+
const data = Array.isArray(field(result, "data")) ? field(result, "data").map(summarizeCodexThread).filter((thread) => threadInWorkspace(thread, options.cwd)) : [];
|
|
65
|
+
return { data, nextCursor: field(result, "nextCursor") || null, backwardsCursor: field(result, "backwardsCursor") || null };
|
|
66
|
+
}
|
|
67
|
+
export async function readCodexThread(emit, threadId, cwd) {
|
|
68
|
+
const thread = await loadCodexThread(emit, threadId, cwd, true);
|
|
69
|
+
return { thread: summarizeCodexThread(thread), messages: threadMessages(thread) };
|
|
70
|
+
}
|
|
71
|
+
export async function verifyCodexThreadWorkspace(emit, threadId, cwd) {
|
|
72
|
+
await loadCodexThread(emit, threadId, cwd, false);
|
|
73
|
+
}
|
|
74
|
+
export async function archiveCodexThread(emit, threadId, cwd) {
|
|
75
|
+
codexApp ||= await CodexAppClient.start(emit);
|
|
76
|
+
await loadCodexThread(emit, threadId, cwd, false);
|
|
77
|
+
await codexApp.archiveThread(threadId);
|
|
78
|
+
}
|
|
79
|
+
let claudeQueue = Promise.resolve();
|
|
80
|
+
export async function runClaudeTurn(prompt, emit, attachments = [], options = {}) {
|
|
81
|
+
if (!prompt.trim())
|
|
82
|
+
return;
|
|
83
|
+
claudeQueue = claudeQueue.catch(() => undefined).then(() => runClaudeTurnNow(prompt, emit, attachments, options));
|
|
84
|
+
await claudeQueue;
|
|
85
|
+
}
|
|
86
|
+
async function runClaudeTurnNow(prompt, emit, attachments, options) {
|
|
87
|
+
const tempFiles = [];
|
|
88
|
+
try {
|
|
89
|
+
const images = await writeAttachmentFiles(attachments);
|
|
90
|
+
tempFiles.push(...images);
|
|
91
|
+
const mcpConfigFile = await writeClaudeMcpConfigFile();
|
|
92
|
+
tempFiles.push(mcpConfigFile);
|
|
93
|
+
const fullPrompt = images.length
|
|
94
|
+
? `${prompt}\n\n参考图片(本地文件路径,可直接查看):\n${images.map((file) => `- ${file}`).join("\n")}`
|
|
95
|
+
: prompt;
|
|
96
|
+
try {
|
|
97
|
+
await runClaudeProcess(fullPrompt, emit, mcpConfigFile, options);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (options.allowFreshFallback && options.threadId && isMissingClaudeSessionError(error)) {
|
|
101
|
+
emit("agent_log", { agent: "claude", text: `保存的 Claude 会话 ${options.threadId} 已失效,改为开启新会话。` });
|
|
102
|
+
await runClaudeProcess(fullPrompt, emit, mcpConfigFile, { ...options, threadId: undefined });
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
catch (error) {
|
|
109
|
+
emit("agent_error", { agent: "claude", message: errorMessage(error) });
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
await Promise.all(tempFiles.map((file) => fs.unlink(file).catch(() => undefined)));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
async function writeClaudeMcpConfigFile() {
|
|
116
|
+
const file = path.join(os.tmpdir(), `qcanvas-claude-mcp-${Date.now()}-${Math.random().toString(16).slice(2)}.json`);
|
|
117
|
+
const config = {
|
|
118
|
+
mcpServers: {
|
|
119
|
+
qcanvas: { command: canvasAgentMcp.command, args: canvasAgentMcp.args, env: { QCANVAS_AGENT_ID: "claude" } },
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
await fs.writeFile(file, JSON.stringify(config));
|
|
123
|
+
return file;
|
|
124
|
+
}
|
|
125
|
+
function runClaudeProcess(prompt, emit, mcpConfigFile, options) {
|
|
126
|
+
return new Promise((resolve, reject) => {
|
|
127
|
+
const args = [
|
|
128
|
+
"-p",
|
|
129
|
+
"--output-format", "stream-json",
|
|
130
|
+
"--verbose",
|
|
131
|
+
"--include-partial-messages",
|
|
132
|
+
"--mcp-config", mcpConfigFile,
|
|
133
|
+
"--strict-mcp-config",
|
|
134
|
+
"--allowedTools", "mcp__qcanvas",
|
|
135
|
+
...(options.threadId ? ["--resume", options.threadId] : []),
|
|
136
|
+
];
|
|
137
|
+
// 显式配置(env/config 的完整路径)优先;否则用随包安装的 claude;再否则回退 PATH 上的 claude。
|
|
138
|
+
const explicitCommand = options.command && options.command !== "claude" ? options.command : null;
|
|
139
|
+
const command = explicitCommand || claudeBin() || "claude";
|
|
140
|
+
let child;
|
|
141
|
+
try {
|
|
142
|
+
child = spawnClaudeCli(command, args, options.cwd);
|
|
143
|
+
}
|
|
144
|
+
catch (error) {
|
|
145
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const parser = new ClaudeTurnParser(emit, options);
|
|
149
|
+
let stderrTail = "";
|
|
150
|
+
let settled = false;
|
|
151
|
+
const fail = (error) => {
|
|
152
|
+
if (!settled)
|
|
153
|
+
(settled = true, reject(error));
|
|
154
|
+
};
|
|
155
|
+
child.stdout?.on("data", (chunk) => parser.push(chunk.toString()));
|
|
156
|
+
child.stderr?.on("data", (chunk) => {
|
|
157
|
+
const text = chunk.toString();
|
|
158
|
+
stderrTail = `${stderrTail}${text}`.slice(-4000);
|
|
159
|
+
emit("agent_log", { agent: "claude", text });
|
|
160
|
+
});
|
|
161
|
+
child.on("error", (error) => fail(new Error(`启动 ${command} 失败:${error.message}`)));
|
|
162
|
+
child.on("close", (code) => {
|
|
163
|
+
parser.flush();
|
|
164
|
+
if (parser.sawResult && !settled) {
|
|
165
|
+
settled = true;
|
|
166
|
+
resolve();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
fail(new Error(claudeExitMessage(code, stderrTail, command)));
|
|
170
|
+
});
|
|
171
|
+
emit("agent_event", { agent: "claude", type: "turn.started" });
|
|
172
|
+
child.stdin?.write(prompt);
|
|
173
|
+
child.stdin?.end();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function spawnClaudeCli(command, args, cwd) {
|
|
177
|
+
// Windows 下 claude 是 .cmd/.ps1 shim,必须经 shell 启动;此时 Node 不会自动为参数加引号。
|
|
178
|
+
const useShell = process.platform === "win32";
|
|
179
|
+
return spawn(useShell ? quoteWindowsShellArg(command) : command, useShell ? args.map(quoteWindowsShellArg) : args, {
|
|
180
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
181
|
+
shell: useShell,
|
|
182
|
+
windowsHide: true,
|
|
183
|
+
...(cwd ? { cwd } : {}),
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function quoteWindowsShellArg(value) {
|
|
187
|
+
if (!/[\s"^&|<>()]/.test(value))
|
|
188
|
+
return value;
|
|
189
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
190
|
+
}
|
|
191
|
+
function claudeExitMessage(code, stderrTail, command) {
|
|
192
|
+
const detail = stderrTail.trim().split(/\r?\n/).slice(-6).join("\n").trim();
|
|
193
|
+
// cmd 找不到命令时 errorlevel 为 9009;中文系统的 stderr 是 GBK 编码,正则难以匹配,优先看退出码。
|
|
194
|
+
if (code === 9009 || /not recognized|command not found|ENOENT|不是内部或外部命令/i.test(detail)) {
|
|
195
|
+
return `未找到 ${command} 命令:请安装 Claude Code CLI(npm install -g @anthropic-ai/claude-code),或在 ~/.qcanvas/qcanvas-local-agent.json 的 claudeCommand 字段填写完整路径。`;
|
|
196
|
+
}
|
|
197
|
+
return detail || `Claude CLI exited: ${code ?? 0}`;
|
|
198
|
+
}
|
|
199
|
+
function isMissingClaudeSessionError(error) {
|
|
200
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
201
|
+
return /no conversation found/i.test(message);
|
|
202
|
+
}
|
|
203
|
+
class ClaudeTurnParser {
|
|
204
|
+
emit;
|
|
205
|
+
options;
|
|
206
|
+
sawResult = false;
|
|
207
|
+
buffer = "";
|
|
208
|
+
currentMessageId = "";
|
|
209
|
+
streamTextById = new Map();
|
|
210
|
+
toolNames = new Map();
|
|
211
|
+
constructor(emit, options) {
|
|
212
|
+
this.emit = emit;
|
|
213
|
+
this.options = options;
|
|
214
|
+
}
|
|
215
|
+
push(chunk) {
|
|
216
|
+
this.buffer += chunk;
|
|
217
|
+
const lines = this.buffer.split(/\r?\n/);
|
|
218
|
+
this.buffer = lines.pop() || "";
|
|
219
|
+
lines.filter(Boolean).forEach((line) => this.handleLine(line));
|
|
220
|
+
}
|
|
221
|
+
flush() {
|
|
222
|
+
const line = this.buffer.trim();
|
|
223
|
+
this.buffer = "";
|
|
224
|
+
if (line)
|
|
225
|
+
this.handleLine(line);
|
|
226
|
+
}
|
|
227
|
+
handleLine(line) {
|
|
228
|
+
let message;
|
|
229
|
+
try {
|
|
230
|
+
message = JSON.parse(line);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
this.emit("agent_log", { agent: "claude", text: line });
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
const type = String(message.type || "");
|
|
237
|
+
if (type === "system" && message.subtype === "init")
|
|
238
|
+
return this.handleInit(message);
|
|
239
|
+
if (type === "stream_event")
|
|
240
|
+
return this.handleStreamEvent(field(message, "event"));
|
|
241
|
+
if (type === "assistant")
|
|
242
|
+
return this.handleAssistant(field(message, "message"));
|
|
243
|
+
if (type === "user")
|
|
244
|
+
return this.handleToolResults(field(message, "message"));
|
|
245
|
+
if (type === "result")
|
|
246
|
+
return this.handleResult(message);
|
|
247
|
+
}
|
|
248
|
+
handleInit(message) {
|
|
249
|
+
const sessionId = String(message.session_id || "");
|
|
250
|
+
if (!sessionId)
|
|
251
|
+
return;
|
|
252
|
+
this.options.onThread?.(sessionId);
|
|
253
|
+
this.emit("agent_event", { agent: "claude", type: "thread.started", thread_id: sessionId });
|
|
254
|
+
}
|
|
255
|
+
handleStreamEvent(event) {
|
|
256
|
+
const type = String(field(event, "type") || "");
|
|
257
|
+
if (type === "message_start") {
|
|
258
|
+
const id = String(field(field(event, "message"), "id") || "");
|
|
259
|
+
if (id)
|
|
260
|
+
this.currentMessageId = id;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (type !== "content_block_delta")
|
|
264
|
+
return;
|
|
265
|
+
const delta = field(event, "delta");
|
|
266
|
+
if (String(field(delta, "type") || "") !== "text_delta")
|
|
267
|
+
return;
|
|
268
|
+
const text = String(field(delta, "text") || "");
|
|
269
|
+
if (!text)
|
|
270
|
+
return;
|
|
271
|
+
const id = this.currentMessageId || "claude-stream";
|
|
272
|
+
const merged = `${this.streamTextById.get(id) || ""}${text}`;
|
|
273
|
+
this.streamTextById.set(id, merged);
|
|
274
|
+
this.emit("agent_event", { agent: "claude", type: "item.updated", item: { id, type: "agent_message", text: merged } });
|
|
275
|
+
}
|
|
276
|
+
handleAssistant(message) {
|
|
277
|
+
const messageId = String(field(message, "id") || this.currentMessageId || "");
|
|
278
|
+
const blocks = arrayField(message, "content");
|
|
279
|
+
blocks.forEach((block, index) => {
|
|
280
|
+
const type = String(field(block, "type") || "");
|
|
281
|
+
if (type === "text") {
|
|
282
|
+
const text = String(field(block, "text") || "");
|
|
283
|
+
if (!text)
|
|
284
|
+
return;
|
|
285
|
+
const itemId = messageId || `claude-msg-${index}`;
|
|
286
|
+
this.streamTextById.set(itemId, text);
|
|
287
|
+
this.emit("agent_event", { agent: "claude", type: "item.completed", item: { id: itemId, type: "agent_message", text } });
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (type === "tool_use") {
|
|
291
|
+
const toolUseId = String(field(block, "id") || "");
|
|
292
|
+
const name = String(field(block, "name") || "");
|
|
293
|
+
if (toolUseId && name)
|
|
294
|
+
this.toolNames.set(toolUseId, name);
|
|
295
|
+
this.emit("agent_event", {
|
|
296
|
+
agent: "claude",
|
|
297
|
+
type: "item.started",
|
|
298
|
+
item: { id: toolUseId, type: "mcp_tool_call", tool: name, status: "running", arguments: field(block, "input") },
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
handleToolResults(message) {
|
|
304
|
+
arrayField(message, "content").forEach((block) => {
|
|
305
|
+
if (String(field(block, "type") || "") !== "tool_result")
|
|
306
|
+
return;
|
|
307
|
+
const toolUseId = String(field(block, "tool_use_id") || "");
|
|
308
|
+
const tool = this.toolNames.get(toolUseId) || "工具调用";
|
|
309
|
+
const isError = field(block, "is_error") === true;
|
|
310
|
+
const text = claudeToolResultText(field(block, "content"));
|
|
311
|
+
this.emit("agent_event", {
|
|
312
|
+
agent: "claude",
|
|
313
|
+
type: "item.completed",
|
|
314
|
+
item: {
|
|
315
|
+
id: toolUseId,
|
|
316
|
+
type: "mcp_tool_call",
|
|
317
|
+
tool,
|
|
318
|
+
status: isError ? "failed" : "completed",
|
|
319
|
+
...(isError ? { error: { message: text || "工具执行失败" } } : {}),
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
handleResult(message) {
|
|
325
|
+
this.sawResult = true;
|
|
326
|
+
// resume 在部分 CLI 版本会 fork 出新 session id,以 result 上报的为准。
|
|
327
|
+
const sessionId = String(message.session_id || "");
|
|
328
|
+
if (sessionId) {
|
|
329
|
+
this.options.onThread?.(sessionId);
|
|
330
|
+
this.emit("agent_event", { agent: "claude", type: "thread.started", thread_id: sessionId });
|
|
331
|
+
}
|
|
332
|
+
const usage = normalizeClaudeUsage(field(message, "usage"));
|
|
333
|
+
if (message.is_error === true || String(message.subtype || "") !== "success") {
|
|
334
|
+
this.emit("agent_event", { agent: "claude", type: "error", message: String(message.result || message.subtype || "Claude turn failed") });
|
|
335
|
+
}
|
|
336
|
+
this.emit("agent_event", { agent: "claude", type: "turn.completed", usage });
|
|
337
|
+
this.emit("agent_done", { agent: "claude", usage });
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
function arrayField(value, key) {
|
|
341
|
+
const raw = field(value, key);
|
|
342
|
+
return Array.isArray(raw) ? raw : [];
|
|
343
|
+
}
|
|
344
|
+
function claudeToolResultText(content) {
|
|
345
|
+
if (typeof content === "string")
|
|
346
|
+
return content;
|
|
347
|
+
if (!Array.isArray(content))
|
|
348
|
+
return "";
|
|
349
|
+
return content
|
|
350
|
+
.map((item) => (String(field(item, "type") || "") === "text" ? String(field(item, "text") || "") : ""))
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.join("\n");
|
|
353
|
+
}
|
|
354
|
+
function normalizeClaudeUsage(usage) {
|
|
355
|
+
if (!usage || typeof usage !== "object")
|
|
356
|
+
return null;
|
|
357
|
+
return {
|
|
358
|
+
input_tokens: field(usage, "input_tokens"),
|
|
359
|
+
cached_input_tokens: field(usage, "cache_read_input_tokens"),
|
|
360
|
+
output_tokens: field(usage, "output_tokens"),
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
async function ensureCodexThread(app, options) {
|
|
364
|
+
if (options.threadId) {
|
|
365
|
+
// A freshly started thread is already the active in-memory thread and has no
|
|
366
|
+
// rollout yet — thread/resume would fail with "no rollout found". Only resume
|
|
367
|
+
// when switching to a different (already persisted) thread from history.
|
|
368
|
+
if (options.threadId === codexThreadId)
|
|
369
|
+
return codexThreadId;
|
|
370
|
+
const thread = await app.resumeThread(options.threadId, options.cwd);
|
|
371
|
+
assertThreadWorkspace(thread, options.cwd);
|
|
372
|
+
codexThreadId = String(field(thread, "id") || options.threadId);
|
|
373
|
+
return codexThreadId;
|
|
374
|
+
}
|
|
375
|
+
if (!codexThreadId) {
|
|
376
|
+
const thread = await app.startThread(options.cwd);
|
|
377
|
+
codexThreadId = String(field(thread, "id") || "");
|
|
378
|
+
}
|
|
379
|
+
return codexThreadId;
|
|
380
|
+
}
|
|
381
|
+
class CodexAppClient {
|
|
382
|
+
child;
|
|
383
|
+
emit;
|
|
384
|
+
nextId = 1;
|
|
385
|
+
buffer = "";
|
|
386
|
+
textByItem = new Map();
|
|
387
|
+
deltaCount = 0;
|
|
388
|
+
lastUsage = null;
|
|
389
|
+
pending = new Map();
|
|
390
|
+
activeTurns = new Map();
|
|
391
|
+
completedTurns = new Map();
|
|
392
|
+
constructor(child, emit) {
|
|
393
|
+
this.child = child;
|
|
394
|
+
this.emit = emit;
|
|
395
|
+
}
|
|
396
|
+
static async start(emit) {
|
|
397
|
+
const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
|
|
398
|
+
const client = new CodexAppClient(child, emit);
|
|
399
|
+
child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
|
|
400
|
+
child.stderr?.on("data", (chunk) => emit("agent_log", { agent: "codex", text: chunk.toString() }));
|
|
401
|
+
child.on("error", (error) => emit("agent_error", { agent: "codex", message: error.message }));
|
|
402
|
+
child.on("exit", (code) => {
|
|
403
|
+
client.failAll(`Codex app-server exited: ${code ?? 0}`);
|
|
404
|
+
codexApp = null;
|
|
405
|
+
codexThreadId = "";
|
|
406
|
+
emit("agent_log", { agent: "codex", text: `Codex app-server exited: ${code ?? 0}` });
|
|
407
|
+
});
|
|
408
|
+
await client.request("initialize", { clientInfo: { name: "qcanvas-local-agent", title: "QCanvas Local Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
|
|
409
|
+
client.notify("initialized");
|
|
410
|
+
return client;
|
|
411
|
+
}
|
|
412
|
+
async startThread(cwd) {
|
|
413
|
+
const result = await this.request("thread/start", { approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}), threadSource: "user" });
|
|
414
|
+
const thread = field(result, "thread");
|
|
415
|
+
const id = String(field(thread, "id") || "");
|
|
416
|
+
if (!id)
|
|
417
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
418
|
+
return thread || {};
|
|
419
|
+
}
|
|
420
|
+
async resumeThread(threadId, cwd) {
|
|
421
|
+
const result = await this.request("thread/resume", { threadId, approvalPolicy: "never", sandbox: "workspace-write", config: codexConfig(), ...(cwd ? { cwd } : {}) });
|
|
422
|
+
const thread = field(result, "thread");
|
|
423
|
+
const id = String(field(thread, "id") || "");
|
|
424
|
+
if (!id)
|
|
425
|
+
throw new Error("Codex app-server 没有返回 thread id");
|
|
426
|
+
return thread || {};
|
|
427
|
+
}
|
|
428
|
+
listThreads(params) {
|
|
429
|
+
return this.request("thread/list", params);
|
|
430
|
+
}
|
|
431
|
+
readThread(threadId, includeTurns = true) {
|
|
432
|
+
return this.request("thread/read", { threadId, includeTurns });
|
|
433
|
+
}
|
|
434
|
+
archiveThread(threadId) {
|
|
435
|
+
return this.request("thread/archive", { threadId });
|
|
436
|
+
}
|
|
437
|
+
async startTurn(threadId, prompt, images) {
|
|
438
|
+
const result = await this.request("turn/start", { threadId, input: codexInput(prompt, images), approvalPolicy: "never" });
|
|
439
|
+
const turnId = String(field(field(result, "turn"), "id") || "");
|
|
440
|
+
if (!turnId)
|
|
441
|
+
throw new Error("Codex app-server 没有返回 turn id");
|
|
442
|
+
const completed = this.completedTurns.get(turnId);
|
|
443
|
+
if (this.completedTurns.has(turnId)) {
|
|
444
|
+
this.completedTurns.delete(turnId);
|
|
445
|
+
if (completed)
|
|
446
|
+
throw completed;
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
|
|
450
|
+
}
|
|
451
|
+
request(method, params) {
|
|
452
|
+
const id = this.nextId++;
|
|
453
|
+
this.write({ id, method, params });
|
|
454
|
+
return new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
|
|
455
|
+
}
|
|
456
|
+
notify(method, params) {
|
|
457
|
+
this.write(params === undefined ? { method } : { method, params });
|
|
458
|
+
}
|
|
459
|
+
write(value) {
|
|
460
|
+
this.child.stdin?.write(`${JSON.stringify(value)}\n`);
|
|
461
|
+
}
|
|
462
|
+
read(chunk) {
|
|
463
|
+
this.buffer += chunk;
|
|
464
|
+
const lines = this.buffer.split(/\r?\n/);
|
|
465
|
+
this.buffer = lines.pop() || "";
|
|
466
|
+
lines.filter(Boolean).forEach((line) => {
|
|
467
|
+
try {
|
|
468
|
+
this.handle(JSON.parse(line));
|
|
469
|
+
}
|
|
470
|
+
catch {
|
|
471
|
+
this.emit("agent_log", { agent: "codex", text: line });
|
|
472
|
+
}
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
handle(message) {
|
|
476
|
+
const id = Number(message.id);
|
|
477
|
+
if (message.error && this.pending.has(id))
|
|
478
|
+
return this.reject(id, String(field(message.error, "message") || "Codex request failed"));
|
|
479
|
+
if (this.pending.has(id))
|
|
480
|
+
return this.resolve(id, message.result);
|
|
481
|
+
if (typeof message.method === "string" && "id" in message)
|
|
482
|
+
return this.answerServerRequest(message);
|
|
483
|
+
if (typeof message.method === "string")
|
|
484
|
+
this.handleNotification(message.method, (message.params || {}));
|
|
485
|
+
}
|
|
486
|
+
handleNotification(method, params) {
|
|
487
|
+
if (method === "item/agentMessage/delta")
|
|
488
|
+
return this.emitDelta(params);
|
|
489
|
+
if (method === "thread/tokenUsage/updated")
|
|
490
|
+
this.lastUsage = normalizeUsage(params);
|
|
491
|
+
const event = normalizeCodexNotification(method, params);
|
|
492
|
+
if (!event)
|
|
493
|
+
return;
|
|
494
|
+
if (event.type === "turn.completed")
|
|
495
|
+
event.usage = this.lastUsage;
|
|
496
|
+
this.emit("agent_event", { agent: "codex", ...event });
|
|
497
|
+
if (event.type === "turn.completed") {
|
|
498
|
+
const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
|
|
499
|
+
const pending = this.activeTurns.get(turnId);
|
|
500
|
+
const error = field(field(params, "turn"), "error");
|
|
501
|
+
if (pending) {
|
|
502
|
+
this.activeTurns.delete(turnId);
|
|
503
|
+
error ? pending.reject(new Error(String(field(error, "message") || "Codex turn failed"))) : pending.resolve(event);
|
|
504
|
+
}
|
|
505
|
+
else if (turnId) {
|
|
506
|
+
this.completedTurns.set(turnId, error ? new Error(String(field(error, "message") || "Codex turn failed")) : null);
|
|
507
|
+
}
|
|
508
|
+
this.emit("agent_event", { agent: "codex", type: "stream.summary", delta_count: this.deltaCount });
|
|
509
|
+
this.deltaCount = 0;
|
|
510
|
+
this.emit("agent_done", { agent: "codex", usage: event.usage });
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
emitDelta(params) {
|
|
514
|
+
const id = String(field(params, "itemId") || "");
|
|
515
|
+
const text = `${this.textByItem.get(id) || ""}${String(field(params, "delta") || "")}`;
|
|
516
|
+
this.deltaCount += 1;
|
|
517
|
+
this.textByItem.set(id, text);
|
|
518
|
+
this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: "agent_message", text } });
|
|
519
|
+
}
|
|
520
|
+
answerServerRequest(message) {
|
|
521
|
+
const method = String(message.method);
|
|
522
|
+
const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
|
|
523
|
+
this.write({ id: message.id, result });
|
|
524
|
+
this.emit("agent_event", { agent: "codex", type: "server.request", method, params: message.params, result });
|
|
525
|
+
}
|
|
526
|
+
resolve(id, result) {
|
|
527
|
+
const pending = this.pending.get(id);
|
|
528
|
+
if (pending)
|
|
529
|
+
(this.pending.delete(id), pending.resolve(result));
|
|
530
|
+
}
|
|
531
|
+
reject(id, message) {
|
|
532
|
+
const pending = this.pending.get(id);
|
|
533
|
+
if (pending)
|
|
534
|
+
(this.pending.delete(id), pending.reject(new Error(message)));
|
|
535
|
+
}
|
|
536
|
+
failAll(message) {
|
|
537
|
+
[...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
|
|
538
|
+
this.pending.clear();
|
|
539
|
+
this.activeTurns.clear();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function canvasAgentMcpCommand() {
|
|
543
|
+
const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
|
|
544
|
+
const entry = path.resolve(current || fileURLToPath(new URL("./index.js", import.meta.url)));
|
|
545
|
+
const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
|
|
546
|
+
return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
|
|
547
|
+
}
|
|
548
|
+
function codexConfig() {
|
|
549
|
+
return { mcp_servers: { qcanvas: { command: canvasAgentMcp.command, args: canvasAgentMcp.args, env: { QCANVAS_AGENT_ID: "codex" }, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
|
|
550
|
+
}
|
|
551
|
+
function codexInput(prompt, images) {
|
|
552
|
+
return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
|
|
553
|
+
}
|
|
554
|
+
function normalizeCodexNotification(method, params) {
|
|
555
|
+
if (method === "thread/started")
|
|
556
|
+
return { type: "thread.started", thread_id: field(field(params, "thread"), "id") };
|
|
557
|
+
if (method === "turn/started")
|
|
558
|
+
return { type: "turn.started" };
|
|
559
|
+
if (method === "turn/completed")
|
|
560
|
+
return { type: "turn.completed", usage: null };
|
|
561
|
+
if (method === "item/started")
|
|
562
|
+
return { type: "item.started", item: normalizeItem(field(params, "item")) };
|
|
563
|
+
if (method === "item/completed")
|
|
564
|
+
return { type: "item.completed", item: normalizeItem(field(params, "item")) };
|
|
565
|
+
if (method === "error")
|
|
566
|
+
return { type: "error", message: field(field(params, "error"), "message") || field(params, "message") };
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
async function loadCodexThread(emit, threadId, cwd, includeTurns) {
|
|
570
|
+
codexApp ||= await CodexAppClient.start(emit);
|
|
571
|
+
if (threadId !== codexThreadId) {
|
|
572
|
+
const resumed = await codexApp.resumeThread(threadId, cwd);
|
|
573
|
+
assertThreadWorkspace(resumed, cwd);
|
|
574
|
+
codexThreadId = String(field(resumed, "id") || threadId);
|
|
575
|
+
if (!includeTurns)
|
|
576
|
+
return resumed;
|
|
577
|
+
}
|
|
578
|
+
const result = await codexApp.readThread(threadId, includeTurns);
|
|
579
|
+
const thread = field(result, "thread") || {};
|
|
580
|
+
assertThreadWorkspace(thread, cwd);
|
|
581
|
+
return thread;
|
|
582
|
+
}
|
|
583
|
+
function assertThreadWorkspace(thread, cwd) {
|
|
584
|
+
if (!cwd || threadInWorkspace(thread, cwd))
|
|
585
|
+
return;
|
|
586
|
+
throw new Error("该 Codex 会话不属于当前画布工作空间");
|
|
587
|
+
}
|
|
588
|
+
function threadInWorkspace(thread, cwd) {
|
|
589
|
+
const threadCwd = String(field(thread, "cwd") || "");
|
|
590
|
+
return Boolean(threadCwd && path.resolve(threadCwd) === path.resolve(cwd));
|
|
591
|
+
}
|
|
592
|
+
function normalizeItem(item) {
|
|
593
|
+
const value = item && typeof item === "object" ? { ...item } : {};
|
|
594
|
+
if (value.type === "agentMessage")
|
|
595
|
+
value.type = "agent_message";
|
|
596
|
+
if (value.type === "mcpToolCall")
|
|
597
|
+
value.type = "mcp_tool_call";
|
|
598
|
+
if (value.type === "agent_message" && typeof value.id === "string")
|
|
599
|
+
value.text = String(value.text || "");
|
|
600
|
+
if ("arguments" in value)
|
|
601
|
+
value.arguments = parseMaybeJson(value.arguments);
|
|
602
|
+
return value;
|
|
603
|
+
}
|
|
604
|
+
function normalizeUsage(params) {
|
|
605
|
+
const total = field(field(params, "tokenUsage"), "total");
|
|
606
|
+
return {
|
|
607
|
+
input_tokens: field(total, "inputTokens"),
|
|
608
|
+
cached_input_tokens: field(total, "cachedInputTokens"),
|
|
609
|
+
output_tokens: field(total, "outputTokens"),
|
|
610
|
+
reasoning_output_tokens: field(total, "reasoningOutputTokens"),
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
function parseMaybeJson(value) {
|
|
614
|
+
if (typeof value !== "string")
|
|
615
|
+
return value;
|
|
616
|
+
try {
|
|
617
|
+
return JSON.parse(value);
|
|
618
|
+
}
|
|
619
|
+
catch {
|
|
620
|
+
return value;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
function field(value, key) {
|
|
624
|
+
return value && typeof value === "object" ? value[key] : undefined;
|
|
625
|
+
}
|
|
626
|
+
export function summarizeCodexThread(thread) {
|
|
627
|
+
return {
|
|
628
|
+
id: String(field(thread, "id") || ""),
|
|
629
|
+
sessionId: String(field(thread, "sessionId") || ""),
|
|
630
|
+
preview: displayUserText(String(field(thread, "preview") || "")),
|
|
631
|
+
name: stringOrNull(field(thread, "name")),
|
|
632
|
+
cwd: String(field(thread, "cwd") || ""),
|
|
633
|
+
status: String(field(thread, "status") || ""),
|
|
634
|
+
source: field(thread, "source"),
|
|
635
|
+
threadSource: field(thread, "threadSource"),
|
|
636
|
+
createdAt: Number(field(thread, "createdAt") || 0),
|
|
637
|
+
updatedAt: Number(field(thread, "updatedAt") || 0),
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function threadMessages(thread) {
|
|
641
|
+
const turns = arrayValue(field(thread, "turns"));
|
|
642
|
+
const messages = [];
|
|
643
|
+
turns.forEach((turn, turnIndex) => {
|
|
644
|
+
arrayValue(field(turn, "items")).forEach((item, itemIndex) => {
|
|
645
|
+
const type = String(field(item, "type") || "");
|
|
646
|
+
const id = String(field(item, "id") || `${turnIndex}-${itemIndex}`);
|
|
647
|
+
if (type === "userMessage") {
|
|
648
|
+
const text = displayUserText(userInputText(field(item, "content")));
|
|
649
|
+
if (text)
|
|
650
|
+
messages.push({ id, role: "user", text });
|
|
651
|
+
}
|
|
652
|
+
if (type === "agentMessage") {
|
|
653
|
+
const text = String(field(item, "text") || "").trim();
|
|
654
|
+
if (text)
|
|
655
|
+
messages.push({ id, role: "assistant", title: "Codex", text, streamId: id });
|
|
656
|
+
}
|
|
657
|
+
if (type === "mcpToolCall") {
|
|
658
|
+
const tool = String(field(item, "tool") || "工具调用");
|
|
659
|
+
const error = field(field(item, "error"), "message");
|
|
660
|
+
messages.push({ id, role: error ? "error" : "tool", title: toolName(tool), text: error ? String(error) : `${toolName(tool)} ${String(field(item, "status") || "完成")}`, detail: item });
|
|
661
|
+
}
|
|
662
|
+
if (type === "commandExecution") {
|
|
663
|
+
const command = String(field(item, "command") || "").trim();
|
|
664
|
+
if (command)
|
|
665
|
+
messages.push({ id, role: "tool", title: "命令", text: command, detail: { cwd: field(item, "cwd"), status: field(item, "status"), exitCode: field(item, "exitCode") } });
|
|
666
|
+
}
|
|
667
|
+
if (type === "fileChange")
|
|
668
|
+
messages.push({ id, role: "tool", title: "文件变更", text: "Codex 修改了文件", detail: item });
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
return messages.filter((item) => item.text).slice(-120);
|
|
672
|
+
}
|
|
673
|
+
function userInputText(content) {
|
|
674
|
+
return arrayValue(content)
|
|
675
|
+
.map((item) => {
|
|
676
|
+
const type = String(field(item, "type") || "");
|
|
677
|
+
if (type === "text")
|
|
678
|
+
return String(field(item, "text") || "");
|
|
679
|
+
if (type === "image" || type === "localImage")
|
|
680
|
+
return "图片附件";
|
|
681
|
+
if (type === "mention")
|
|
682
|
+
return `@${String(field(item, "name") || "文件")}`;
|
|
683
|
+
return "";
|
|
684
|
+
})
|
|
685
|
+
.filter(Boolean)
|
|
686
|
+
.join("\n");
|
|
687
|
+
}
|
|
688
|
+
function displayUserText(text) {
|
|
689
|
+
const value = text.trim();
|
|
690
|
+
const marker = "用户请求:";
|
|
691
|
+
const index = value.lastIndexOf(marker);
|
|
692
|
+
return (index >= 0 ? value.slice(index + marker.length) : value).trim();
|
|
693
|
+
}
|
|
694
|
+
function arrayValue(value) {
|
|
695
|
+
return Array.isArray(value) ? value : [];
|
|
696
|
+
}
|
|
697
|
+
function stringOrNull(value) {
|
|
698
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
699
|
+
}
|
|
700
|
+
function toolName(name) {
|
|
701
|
+
if (name.endsWith("tapcanvas_flow_get"))
|
|
702
|
+
return "读取画布";
|
|
703
|
+
if (name.endsWith("tapcanvas_flow_patch"))
|
|
704
|
+
return "画布操作";
|
|
705
|
+
return name;
|
|
706
|
+
}
|
|
707
|
+
async function writeAttachmentFiles(attachments) {
|
|
708
|
+
return await Promise.all(attachments.filter((item) => item.dataUrl?.startsWith("data:image/")).map(writeAttachmentFile));
|
|
709
|
+
}
|
|
710
|
+
async function writeAttachmentFile(item) {
|
|
711
|
+
const [, meta = "", data = ""] = item.dataUrl?.match(/^data:([^;]+);base64,(.+)$/) || [];
|
|
712
|
+
if (!data)
|
|
713
|
+
throw new Error(`图片附件无效:${item.name || "未命名图片"}`);
|
|
714
|
+
const file = path.join(os.tmpdir(), `qcanvas-${Date.now()}-${Math.random().toString(16).slice(2)}.${imageExt(meta || item.type)}`);
|
|
715
|
+
await fs.writeFile(file, Buffer.from(data, "base64"));
|
|
716
|
+
return file;
|
|
717
|
+
}
|
|
718
|
+
function imageExt(type = "") {
|
|
719
|
+
if (type.includes("png"))
|
|
720
|
+
return "png";
|
|
721
|
+
if (type.includes("webp"))
|
|
722
|
+
return "webp";
|
|
723
|
+
return "jpg";
|
|
724
|
+
}
|
|
725
|
+
function codexBin() {
|
|
726
|
+
return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* 定位随本包一起安装的 Claude Code 可执行文件(`@anthropic-ai/claude-code`)。
|
|
730
|
+
* 通过 npx 安装时它会作为依赖存在,从而让 Claude 也开箱即用;未安装则返回 null,
|
|
731
|
+
* 由调用方回退到 PATH 上的 `claude`。bin 路径读自其 package.json,跨平台稳妥。
|
|
732
|
+
*/
|
|
733
|
+
function claudeBin() {
|
|
734
|
+
try {
|
|
735
|
+
const pkgPath = require.resolve("@anthropic-ai/claude-code/package.json");
|
|
736
|
+
const pkg = require("@anthropic-ai/claude-code/package.json");
|
|
737
|
+
const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.claude;
|
|
738
|
+
if (!binRel)
|
|
739
|
+
return null;
|
|
740
|
+
return path.join(path.dirname(pkgPath), binRel);
|
|
741
|
+
}
|
|
742
|
+
catch {
|
|
743
|
+
return null;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function errorMessage(error) {
|
|
747
|
+
return error instanceof Error ? error.message : String(error);
|
|
748
|
+
}
|