@kobinflow/canvas-agent 0.6.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.
Files changed (50) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +159 -0
  3. package/agent-instructions.md +13 -0
  4. package/dist/agent/claude.d.ts +3 -0
  5. package/dist/agent/claude.js +54 -0
  6. package/dist/agent/codex-client.d.ts +139 -0
  7. package/dist/agent/codex-client.js +937 -0
  8. package/dist/agent/codex-event-history.d.ts +21 -0
  9. package/dist/agent/codex-event-history.js +163 -0
  10. package/dist/agent/codex-history.d.ts +46 -0
  11. package/dist/agent/codex-history.js +822 -0
  12. package/dist/agent/codex-protocol.d.ts +335 -0
  13. package/dist/agent/codex-protocol.js +1 -0
  14. package/dist/agent/codex.d.ts +178 -0
  15. package/dist/agent/codex.js +509 -0
  16. package/dist/agent/message-metadata.d.ts +51 -0
  17. package/dist/agent/message-metadata.js +378 -0
  18. package/dist/agent/types.d.ts +45 -0
  19. package/dist/agent/types.js +1 -0
  20. package/dist/canvas/operations.d.ts +13 -0
  21. package/dist/canvas/operations.js +167 -0
  22. package/dist/canvas/schemas.d.ts +989 -0
  23. package/dist/canvas/schemas.js +155 -0
  24. package/dist/canvas/session.d.ts +163 -0
  25. package/dist/canvas/session.js +523 -0
  26. package/dist/canvas/tools.d.ts +214 -0
  27. package/dist/canvas/tools.js +27 -0
  28. package/dist/canvas/types.d.ts +34 -0
  29. package/dist/canvas/types.js +1 -0
  30. package/dist/config.d.ts +30 -0
  31. package/dist/config.js +86 -0
  32. package/dist/index.d.ts +2 -0
  33. package/dist/index.js +7 -0
  34. package/dist/server/http.d.ts +2 -0
  35. package/dist/server/http.js +576 -0
  36. package/dist/server/mcp.d.ts +2 -0
  37. package/dist/server/mcp.js +27 -0
  38. package/dist/skills/store.d.ts +54 -0
  39. package/dist/skills/store.js +426 -0
  40. package/dist/utils/agent-runtime.d.ts +8 -0
  41. package/dist/utils/agent-runtime.js +33 -0
  42. package/dist/utils/date.d.ts +2 -0
  43. package/dist/utils/date.js +7 -0
  44. package/dist/utils/logger.d.ts +17 -0
  45. package/dist/utils/logger.js +81 -0
  46. package/dist/utils/value.d.ts +5 -0
  47. package/dist/utils/value.js +8 -0
  48. package/dist/version-check.d.ts +2 -0
  49. package/dist/version-check.js +65 -0
  50. package/package.json +55 -0
@@ -0,0 +1,576 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import express from "express";
5
+ import { runClaudeTurn } from "../agent/claude.js";
6
+ import { archiveCodexThread, CodexSkillLookupError, configureCodexSkill, generateCodexSkillDraft, interruptCodexTurn, isRecoverableThreadError, listCodexModels, listCodexSkills, listCodexThreads, readCodexThread, resolveCodexApproval, resolveCodexSkill, resumeCodexThread, runCodexTurn, startCodexThread, summarizeCodexThread } from "../agent/codex.js";
7
+ import { messageMetadataStore } from "../agent/message-metadata.js";
8
+ import { AGENT_PROTOCOL_VERSION, CanvasSession } from "../canvas/session.js";
9
+ import { DEFAULT_PORT, ensureSiteWorkspace, loadConfig, saveConfig, updateSiteWorkspace } from "../config.js";
10
+ import { logger } from "../utils/logger.js";
11
+ import { checkVersions } from "../version-check.js";
12
+ import { SkillStore, SkillStoreError } from "../skills/store.js";
13
+ /** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
14
+ export function startHttpServer() {
15
+ const config = loadConfig(true);
16
+ const port = Number(process.env.PORT) || Number(new URL(config.url).port) || DEFAULT_PORT;
17
+ config.url = `http://127.0.0.1:${port}`;
18
+ saveConfig(config);
19
+ const initialWorkspace = ensureSiteWorkspace(config);
20
+ const session = new CanvasSession(initialWorkspace.activeThreadId || "");
21
+ const skillStore = new SkillStore(initialWorkspace.workspacePath);
22
+ /** 将 Agent 事件广播到所属线程或全部网页。 */
23
+ const emit = (type, payload) => {
24
+ const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload };
25
+ if (type === "skills_changed") {
26
+ session.emitAll(type, value);
27
+ return;
28
+ }
29
+ if (type === "agent_bootstrap" && value.phase === "preheat") {
30
+ if (value.type === "mcp.startup")
31
+ session.updateConversationMcp(String(value.name || ""), startupStatus(value.status), String(value.error || "") || null, String(value.failureReason || "") || null);
32
+ if (value.type === "mcp.complete")
33
+ session.completeConversationMcpInventory(mcpInventory(value.services));
34
+ }
35
+ const scope = session.codexBusy ? session.codexEventScope : { threadId: "", turnId: "", sourceClientId: "" };
36
+ const threadId = String(value.threadId || value.thread_id || scope.threadId || ensureSiteWorkspace(config).activeThreadId || "");
37
+ const turnId = String(value.turnId || value.turn_id || scope.turnId || "");
38
+ const sourceClientId = String(value.sourceClientId || scope.sourceClientId || "");
39
+ const data = {
40
+ ...value,
41
+ ...(threadId ? { threadId, thread_id: threadId } : {}),
42
+ ...(turnId ? { turnId, turn_id: turnId } : {}),
43
+ ...(sourceClientId ? { sourceClientId } : {}),
44
+ };
45
+ session.trackCodexEvent(type, data);
46
+ threadId ? session.emitThread(type, threadId, data) : session.emitAll(type, data);
47
+ };
48
+ /** 保存并广播当前站点工作空间的活跃线程。 */
49
+ const setActiveThread = (activeThreadId, payload = {}, preserveConversation = false) => {
50
+ const workspace = updateSiteWorkspace(config, { activeThreadId: activeThreadId || undefined });
51
+ if (!preserveConversation)
52
+ session.activateConversation(activeThreadId, String(payload.sourceClientId || "") || undefined);
53
+ if (!session.codexBusy && session.codexThreadId !== activeThreadId)
54
+ session.setCodexState({ threadId: activeThreadId, turnId: "" });
55
+ session.emitThread("workspace_changed", activeThreadId, { ...payload, activeThreadId, conversation: session.conversationStateSnapshot });
56
+ return workspace;
57
+ };
58
+ let draftThreadStart = null;
59
+ let skillDraftRunning = false;
60
+ const prepareDraftThread = (clientId, permission) => {
61
+ if (draftThreadStart)
62
+ return draftThreadStart;
63
+ const workspace = ensureSiteWorkspace(config);
64
+ let prepared;
65
+ prepared = (async () => {
66
+ emit("agent_bootstrap", { type: "codex.preparing", sourceClientId: clientId });
67
+ try {
68
+ const thread = await startCodexThread(emit, workspace.workspacePath, permission, true);
69
+ if (draftThreadStart !== prepared)
70
+ return thread;
71
+ const threadId = String(thread.id || "");
72
+ if (threadId && !ensureSiteWorkspace(config).activeThreadId) {
73
+ session.completeConversationPreparation(threadId);
74
+ setActiveThread(threadId, { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
75
+ }
76
+ return thread;
77
+ }
78
+ catch (error) {
79
+ if (draftThreadStart === prepared) {
80
+ const text = error instanceof Error ? error.message : String(error);
81
+ session.failConversationPreparation(text);
82
+ emit("agent_bootstrap", { type: "codex.prepare_failed", sourceClientId: clientId, error: text });
83
+ }
84
+ throw error;
85
+ }
86
+ finally {
87
+ if (draftThreadStart === prepared)
88
+ draftThreadStart = null;
89
+ }
90
+ })();
91
+ draftThreadStart = prepared;
92
+ return prepared;
93
+ };
94
+ /** 恢复已有线程并等待完整 MCP 清单,供启动恢复和手动切换共用。 */
95
+ const prepareExistingThread = async (threadId, clientId = "", permission = "request") => {
96
+ const workspace = ensureSiteWorkspace(config);
97
+ session.beginConversation({ conversationId: threadId, threadId, sourceClientId: clientId || undefined });
98
+ emit("agent_bootstrap", { type: "codex.preparing", threadId, sourceClientId: clientId || undefined });
99
+ const result = await resumeCodexThread(emit, threadId, workspace.workspacePath, permission, true);
100
+ session.completeConversationPreparation(threadId);
101
+ return result;
102
+ };
103
+ const failPreparedConversation = (error, threadId, clientId = "") => {
104
+ const text = error instanceof Error ? error.message : String(error);
105
+ session.failConversationPreparation(text);
106
+ emit("agent_bootstrap", { type: "codex.prepare_failed", threadId, sourceClientId: clientId || undefined, error: text });
107
+ };
108
+ const app = express();
109
+ app.disable("x-powered-by");
110
+ app.use(express.json({ limit: "30mb" }));
111
+ app.use((req, res, next) => {
112
+ if (!logger.enabled)
113
+ return next();
114
+ const startedAt = Date.now();
115
+ const url = requestUrl(req, config);
116
+ res.on("finish", () => {
117
+ if (req.method === "OPTIONS" || (res.statusCode < 400 && ["/health", "/canvas/state", "/canvas/activate"].includes(url.pathname)))
118
+ return;
119
+ logger.debug(`HTTP ${req.method} ${url.pathname}`, { status: res.statusCode, durationMs: Date.now() - startedAt });
120
+ });
121
+ next();
122
+ });
123
+ app.use((req, res, next) => {
124
+ const url = requestUrl(req, config);
125
+ if (!setCors(req, res, url, config))
126
+ return void res.status(403).json({ ok: false, error: "origin not allowed" });
127
+ if (req.method === "OPTIONS")
128
+ return void res.json({});
129
+ next();
130
+ });
131
+ app.get("/health", (_req, res) => res.json(session.health()));
132
+ app.get("/config", (_req, res) => res.json({ ok: true, protocolVersion: AGENT_PROTOCOL_VERSION, url: config.url, hasToken: true }));
133
+ app.use((req, res, next) => {
134
+ if (validToken(req, requestUrl(req, config), config.token))
135
+ return next();
136
+ res.status(401).json({ ok: false, error: "invalid token" });
137
+ });
138
+ app.get("/events", (req, res) => {
139
+ session.openEvents(requestUrl(req, config), res, ensureSiteWorkspace(config).activeThreadId || "");
140
+ });
141
+ app.post("/canvas/state", (req, res) => {
142
+ session.updateState(req.body, String(req.query.clientId || "") || undefined);
143
+ res.json({ ok: true });
144
+ });
145
+ app.post("/canvas/activate", (req, res) => {
146
+ session.activateClient(String(req.query.clientId || ""));
147
+ res.json({ ok: true });
148
+ });
149
+ app.post("/canvas/result", (req, res) => {
150
+ const ok = session.resolveResult(String(req.query.clientId || ""), req.body);
151
+ res.status(ok ? 200 : 409).json({ ok });
152
+ });
153
+ app.get("/agent/attachments/:attachmentId", route(async (req, res) => {
154
+ const attachment = session.getTurnAttachment(String(req.query.clientId || ""), routeParam(req.params.attachmentId));
155
+ const data = attachment.dataUrl.split(",", 2)[1];
156
+ if (!data)
157
+ throw new Error("图片附件内容无效");
158
+ res.setHeader("Cache-Control", "no-store");
159
+ res.type(attachment.type).send(Buffer.from(data, "base64"));
160
+ }));
161
+ app.get("/agent/message-assets/:messageKey/:assetFile", route(async (req, res) => {
162
+ const asset = await messageMetadataStore.readAsset(routeParam(req.params.messageKey), routeParam(req.params.assetFile));
163
+ if (!asset)
164
+ return void res.status(404).json({ ok: false, error: "message asset not found" });
165
+ res.setHeader("Cache-Control", "private, max-age=31536000, immutable");
166
+ res.type(asset.contentType).send(asset.data);
167
+ }));
168
+ app.post("/agent/local-file/reveal", route(async (req, res) => {
169
+ const filePath = String(req.body?.path || "");
170
+ if (!path.isAbsolute(filePath))
171
+ return res.status(400).json({ ok: false, error: "文件路径必须是绝对路径" });
172
+ const file = await stat(filePath);
173
+ await revealLocalFile(filePath, file.isDirectory());
174
+ res.json({ ok: true });
175
+ }));
176
+ app.post("/agent/local-image", route(async (req, res) => {
177
+ const filePath = String(req.body?.path || "");
178
+ if (!path.isAbsolute(filePath) || !/\.(?:avif|gif|jpe?g|png|webp)$/i.test(filePath))
179
+ return res.status(400).json({ ok: false, error: "图片路径无效" });
180
+ const file = await stat(filePath);
181
+ if (!file.isFile())
182
+ return res.status(400).json({ ok: false, error: "图片文件无效" });
183
+ res.setHeader("Cache-Control", "no-store");
184
+ res.type(path.extname(filePath)).send(await readFile(filePath));
185
+ }));
186
+ app.post("/api/tools", route(async (req, res) => res.json({ ok: true, result: await session.callTool(req.body?.name, req.body?.input || {}) })));
187
+ app.get("/agent/codex/workspace", (_req, res) => {
188
+ const workspace = ensureSiteWorkspace(config);
189
+ res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot });
190
+ });
191
+ app.get("/agent/codex/models", route(async (_req, res) => res.json({ ok: true, ...(await listCodexModels(emit)) })));
192
+ app.get("/agent/codex/skills", route(async (req, res) => {
193
+ const workspace = ensureSiteWorkspace(config);
194
+ const result = await listCodexSkills(emit, workspace.workspacePath, String(req.query.forceReload || "") === "1");
195
+ res.json({ ok: true, data: result.skills.map((skill) => ({ ...skill, managed: skillStore.isManagedPath(skill.path) })), errors: result.errors });
196
+ }));
197
+ app.post("/agent/codex/skills/draft", codexMutation(async (req, res) => {
198
+ const workspace = ensureSiteWorkspace(config);
199
+ const source = String(req.body?.source || "");
200
+ if (source !== "conversation" && source !== "canvas")
201
+ return res.status(400).json({ ok: false, error: "Skill 草稿来源无效" });
202
+ const clientId = String(req.body?.clientId || "");
203
+ if (!clientId || !session.hasClient(clientId))
204
+ return res.status(409).json({ ok: false, error: "发起提炼的网页已断开,请重新连接后再试" });
205
+ const model = String(req.body?.model || "") || undefined;
206
+ const effort = reasoningEffort(req.body?.effort);
207
+ const previousCodexState = session.codexStateSnapshot;
208
+ skillDraftRunning = true;
209
+ try {
210
+ if (source === "conversation") {
211
+ const threadId = String(req.body?.threadId || "");
212
+ if (!threadId)
213
+ return res.status(409).json({ ok: false, error: "当前没有可提炼的对话" });
214
+ if (threadId !== (workspace.activeThreadId || ""))
215
+ return res.status(409).json({ ok: false, error: "当前对话已在其他页面切换,请同步后重试" });
216
+ const history = await readCodexThread(emit, threadId, workspace.workspacePath);
217
+ if (!history.messages.some((message) => message.role === "user" && message.turnId))
218
+ return res.status(409).json({ ok: false, error: "当前对话还没有可提炼的已完成内容" });
219
+ session.setCodexState({ busy: true, threadId, turnId: "" }, { preserveReplay: true });
220
+ const data = await generateCodexSkillDraft(emit, workspace.workspacePath, { source, threadId, model, effort });
221
+ if (!session.hasClient(clientId))
222
+ return res.status(409).json({ ok: false, error: "发起提炼的网页已断开,请重新连接后再试" });
223
+ return res.json({ ok: true, data });
224
+ }
225
+ const snapshot = session.canvasStateForClient(clientId);
226
+ if (!snapshot || snapshot.hasCanvas === false)
227
+ return res.status(409).json({ ok: false, error: "当前页面没有可提炼的画布" });
228
+ session.setCodexState({ busy: true, threadId: workspace.activeThreadId || "", turnId: "" }, { preserveReplay: true });
229
+ const data = await generateCodexSkillDraft(emit, workspace.workspacePath, { source, snapshot, model, effort });
230
+ if (!session.hasClient(clientId))
231
+ return res.status(409).json({ ok: false, error: "发起提炼的网页已断开,请重新连接后再试" });
232
+ return res.json({ ok: true, data });
233
+ }
234
+ finally {
235
+ skillDraftRunning = false;
236
+ session.setCodexState(previousCodexState, { preserveReplay: true });
237
+ }
238
+ }));
239
+ app.get("/agent/codex/skills/:name", route(async (req, res) => {
240
+ res.json({ ok: true, data: await skillStore.get(routeParam(req.params.name)) });
241
+ }));
242
+ app.post("/agent/codex/skills", codexMutation(async (req, res) => {
243
+ const data = await skillStore.create(req.body);
244
+ session.emitAll("skills_changed", { forceReload: true });
245
+ res.status(201).json({ ok: true, data });
246
+ }));
247
+ app.post("/agent/codex/skills/:name/enabled", codexMutation(async (req, res) => {
248
+ if (typeof req.body?.enabled !== "boolean")
249
+ return res.status(400).json({ ok: false, error: "Skill 启用状态无效" });
250
+ const workspace = ensureSiteWorkspace(config);
251
+ const selector = skillSelector(req.body);
252
+ if (selector.name !== routeParam(req.params.name))
253
+ return res.status(400).json({ ok: false, error: "Skill 选择无效" });
254
+ const data = await configureCodexSkill(emit, workspace.workspacePath, selector, req.body.enabled);
255
+ session.emitAll("skills_changed", { forceReload: true });
256
+ res.json({ ok: true, data });
257
+ }));
258
+ app.post("/agent/codex/skills/:name/delete", codexMutation(async (req, res) => {
259
+ await skillStore.delete(routeParam(req.params.name), String(req.body?.expectedRevision || ""));
260
+ session.emitAll("skills_changed", { forceReload: true });
261
+ res.json({ ok: true });
262
+ }));
263
+ app.post("/agent/codex/skills/:name", codexMutation(async (req, res) => {
264
+ const data = await skillStore.update(routeParam(req.params.name), req.body);
265
+ session.emitAll("skills_changed", { forceReload: true });
266
+ res.json({ ok: true, data });
267
+ }));
268
+ app.get("/agent/codex/threads", route(async (req, res) => {
269
+ const workspace = ensureSiteWorkspace(config);
270
+ const result = await listCodexThreads(emit, { cwd: workspace.workspacePath, searchTerm: String(req.query.searchTerm || "") });
271
+ res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot, ...result });
272
+ }));
273
+ app.post("/agent/codex/threads/new", codexMutation(async (req, res) => {
274
+ const clientId = String(req.body?.clientId || "");
275
+ session.beginConversation({ sourceClientId: clientId });
276
+ setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
277
+ const thread = await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
278
+ res.json({ ok: true, workspace: ensureSiteWorkspace(config), conversation: session.conversationStateSnapshot, thread: summarizeCodexThread(thread), messages: [] });
279
+ }));
280
+ app.post("/agent/codex/threads/reset", codexMutation(async (req, res) => {
281
+ const clientId = String(req.body?.clientId || "");
282
+ session.beginConversation({ sourceClientId: clientId });
283
+ setActiveThread("", { emptyThread: true, draftThread: true, sourceClientId: clientId }, true);
284
+ await prepareDraftThread(clientId, permissionMode(req.body?.permissionMode));
285
+ res.json({ ok: true, workspace: ensureSiteWorkspace(config), conversation: session.conversationStateSnapshot });
286
+ }));
287
+ app.get("/agent/codex/threads/:threadId", route(async (req, res) => {
288
+ const workspace = ensureSiteWorkspace(config);
289
+ const threadId = routeParam(req.params.threadId);
290
+ res.json({ ok: true, workspace, conversation: session.conversationStateSnapshot, ...(await readCodexThread(emit, threadId, workspace.workspacePath)) });
291
+ }));
292
+ app.post("/agent/codex/history/ack", (req, res) => {
293
+ const threadId = String(req.body?.threadId || "");
294
+ const turnIds = Array.isArray(req.body?.turnIds) ? req.body.turnIds.map(String) : [];
295
+ session.acknowledgeCodexHistory(threadId, turnIds);
296
+ res.json({ ok: true });
297
+ });
298
+ app.post("/agent/codex/threads/:threadId/resume", codexMutation(async (req, res) => {
299
+ const threadId = routeParam(req.params.threadId);
300
+ const clientId = String(req.body?.clientId || "");
301
+ try {
302
+ const result = await prepareExistingThread(threadId, clientId, permissionMode(req.body?.permissionMode));
303
+ const nextWorkspace = setActiveThread(threadId, { sourceClientId: clientId }, true);
304
+ res.json({ ok: true, workspace: nextWorkspace, conversation: session.conversationStateSnapshot, ...result });
305
+ }
306
+ catch (error) {
307
+ failPreparedConversation(error, threadId, clientId);
308
+ throw error;
309
+ }
310
+ }));
311
+ app.post("/agent/codex/threads/:threadId/delete", codexMutation(async (req, res) => {
312
+ const workspace = ensureSiteWorkspace(config);
313
+ const threadId = routeParam(req.params.threadId);
314
+ await archiveCodexThread(emit, threadId, workspace.workspacePath);
315
+ const nextWorkspace = setActiveThread(workspace.activeThreadId === threadId ? "" : workspace.activeThreadId || "", { sourceClientId: String(req.body?.clientId || "") });
316
+ res.json({ ok: true, workspace: nextWorkspace, conversation: session.conversationStateSnapshot });
317
+ }));
318
+ app.post("/agent/codex/turn", codexMutation(async (req, res) => {
319
+ const attachments = Array.isArray(req.body?.attachments) ? req.body.attachments : [];
320
+ const workspace = ensureSiteWorkspace(config);
321
+ const prompt = String(req.body?.prompt || "");
322
+ if (!prompt.trim())
323
+ return res.status(400).json({ ok: false, error: "请输入任务内容" });
324
+ const clientId = String(req.body?.clientId || "");
325
+ if (!clientId || !session.hasClient(clientId))
326
+ return res.status(409).json({ ok: false, error: "发起任务的网页已断开,请重新连接后再试" });
327
+ const requestedThreadId = String(req.body?.threadId || "");
328
+ const activeThreadId = workspace.activeThreadId || "";
329
+ const conversation = session.conversationStateSnapshot;
330
+ const requestedConversationId = String(req.body?.conversationId || "");
331
+ const expectedRevision = Number(req.body?.expectedRevision || 0);
332
+ if (requestedThreadId !== activeThreadId || conversation.threadId !== activeThreadId || (requestedConversationId && requestedConversationId !== conversation.conversationId) || (expectedRevision && expectedRevision !== conversation.revision)) {
333
+ return res.status(409).json({ ok: false, code: "CONVERSATION_STALE", error: "当前会话已切换,已同步最新状态,请确认后重试", state: conversation });
334
+ }
335
+ if (!activeThreadId || !["ready", "warning"].includes(conversation.status)) {
336
+ return res.status(409).json({ ok: false, code: "CONVERSATION_NOT_READY", error: "Codex 对话仍在初始化,请等待 MCP 加载完成", state: conversation });
337
+ }
338
+ const model = String(req.body?.model || "") || undefined;
339
+ const effort = reasoningEffort(req.body?.effort);
340
+ const skill = req.body?.skill === undefined ? undefined : await resolveCodexSkill(emit, workspace.workspacePath, skillSelector(req.body.skill), true);
341
+ const messageId = String(req.body?.messageId || Date.now());
342
+ const messageText = String(req.body?.messageText || prompt || `发送了 ${attachments.length} 张图片`);
343
+ const messageMetadata = await messageMetadataStore.recordPending(messageId, req.body?.messageMetadata);
344
+ let threadId = activeThreadId;
345
+ logger.info("Codex turn accepted", { threadId: req.body?.threadId, model: model || "default", reasoningEffort: effort || "default", promptLength: prompt.length, attachmentCount: attachments.length });
346
+ session.bindClient(clientId);
347
+ session.markConversationRunning(threadId);
348
+ session.setCodexState({ busy: true, threadId, turnId: "" });
349
+ try {
350
+ let turnId = "";
351
+ const attachmentRefs = session.setTurnAttachments(clientId, attachments);
352
+ session.emitThread("chat_message", threadId, {
353
+ sourceClientId: clientId,
354
+ message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText, ...messageMetadata },
355
+ });
356
+ let chatTurnId = "";
357
+ /** 将包装层日志和兜底错误固定广播到当前 turn。 */
358
+ const lifecycleEmit = (type, payload) => {
359
+ const value = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : { value: payload };
360
+ const eventThreadId = String(value.threadId || value.thread_id || threadId);
361
+ const eventTurnId = String(value.turnId || value.turn_id || turnId);
362
+ const sourceClientId = String(value.sourceClientId || clientId);
363
+ session.emitThread(type, eventThreadId, {
364
+ ...value,
365
+ threadId: eventThreadId,
366
+ thread_id: eventThreadId,
367
+ ...(eventTurnId ? { turnId: eventTurnId, turn_id: eventTurnId } : {}),
368
+ ...(sourceClientId ? { sourceClientId } : {}),
369
+ });
370
+ };
371
+ void runCodexTurn(withAttachmentContext(prompt, attachmentRefs), lifecycleEmit, attachments, {
372
+ threadId,
373
+ cwd: workspace.workspacePath,
374
+ permissionMode: permissionMode(req.body?.permissionMode),
375
+ model,
376
+ effort,
377
+ ...(skill ? { skill: { name: skill.name, path: skill.path } } : {}),
378
+ messageText,
379
+ appEmit: emit,
380
+ onStart: () => session.bindClient(clientId),
381
+ onThread: (actualThreadId) => {
382
+ const threadChanged = actualThreadId !== threadId;
383
+ void messageMetadataStore.bindThread(messageId, actualThreadId).catch((error) => logger.warn("Failed to bind message metadata to thread", { clientMessageId: messageId, threadId: actualThreadId, error }));
384
+ if (actualThreadId !== threadId) {
385
+ threadId = actualThreadId;
386
+ setActiveThread(threadId, { emptyThread: true, sourceClientId: clientId });
387
+ }
388
+ session.markConversationRunning(threadId);
389
+ session.setCodexState({ busy: true, threadId, turnId: "" });
390
+ if (threadChanged) {
391
+ session.emitThread("chat_message", threadId, {
392
+ sourceClientId: clientId,
393
+ message: { id: `${threadId}:pending:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId: "", role: "user", text: messageText, ...messageMetadata },
394
+ });
395
+ }
396
+ },
397
+ onTurn: (actualTurnId) => {
398
+ turnId = actualTurnId;
399
+ void messageMetadataStore.bindTurn(messageId, threadId, turnId).catch((error) => logger.warn("Failed to bind message metadata to turn", { clientMessageId: messageId, threadId, turnId, error }));
400
+ if (chatTurnId !== turnId) {
401
+ chatTurnId = turnId;
402
+ session.emitThread("chat_message", threadId, {
403
+ turnId,
404
+ sourceClientId: clientId,
405
+ message: { id: `${threadId}:${turnId}:synthetic:user`, itemId: "synthetic:user", clientMessageId: messageId, threadId, turnId, role: "user", text: messageText, ...messageMetadata },
406
+ });
407
+ }
408
+ logger.info("Codex turn started", { threadId, turnId, model: model || "default", reasoningEffort: effort || "default" });
409
+ session.setCodexState({ busy: true, threadId, turnId });
410
+ },
411
+ onFinish: () => {
412
+ logger.info("Codex turn finished", { threadId, turnId });
413
+ if (!turnId)
414
+ void messageMetadataStore.remove(messageId, threadId).catch((error) => logger.warn("Failed to remove unbound message metadata", { clientMessageId: messageId, error }));
415
+ session.clearTurnAttachments(clientId);
416
+ if (clientId)
417
+ session.releaseClient(clientId);
418
+ session.setCodexState({ busy: false, threadId, turnId });
419
+ session.finishConversationRun(threadId);
420
+ },
421
+ });
422
+ res.json({ ok: true, threadId });
423
+ }
424
+ catch (error) {
425
+ await messageMetadataStore.remove(messageId, threadId).catch((metadataError) => logger.warn("Failed to remove rejected message metadata", { clientMessageId: messageId, error: metadataError }));
426
+ session.releaseClient(clientId);
427
+ session.setCodexState({ busy: false, threadId, turnId: "" });
428
+ session.finishConversationRun(threadId);
429
+ throw error;
430
+ }
431
+ }));
432
+ /** 将 Codex 写操作串行化,避免多窗口在异步请求期间交叉修改会话。 */
433
+ function codexMutation(handler) {
434
+ return route(async (req, res) => {
435
+ if (!session.beginCodexMutation())
436
+ return res.status(409).json({ ok: false, code: "CONVERSATION_BUSY", error: "Codex 正在运行或正在切换会话,请稍后重试", state: session.conversationStateSnapshot });
437
+ try {
438
+ return await handler(req, res);
439
+ }
440
+ finally {
441
+ session.endCodexMutation();
442
+ }
443
+ });
444
+ }
445
+ app.post("/agent/codex/approval", route(async (req, res) => {
446
+ const decision = String(req.body?.decision || "");
447
+ if (!["accept", "acceptForSession", "decline", "cancel"].includes(decision))
448
+ return res.status(400).json({ ok: false, error: "无效的审批决定" });
449
+ const ok = await resolveCodexApproval(String(req.body?.requestId || ""), decision);
450
+ res.status(ok ? 200 : 409).json({ ok, ...(ok ? {} : { error: "审批请求已失效" }) });
451
+ }));
452
+ app.post("/agent/codex/interrupt", route(async (req, res) => {
453
+ const ok = await interruptCodexTurn(skillDraftRunning ? undefined : String(req.body?.threadId || ""));
454
+ res.status(ok ? 200 : 409).json({ ok, ...(ok ? {} : { error: "当前没有可停止的任务" }) });
455
+ }));
456
+ app.post("/agent/claude/turn", (req, res) => {
457
+ runClaudeTurn(String(req.body?.prompt || ""), emit);
458
+ res.json({ ok: true });
459
+ });
460
+ app.use((_req, res) => res.status(404).json({ ok: false, error: "not found" }));
461
+ app.use((error, req, res, _next) => {
462
+ logger.error("HTTP request failed", { method: req.method, path: req.path, error });
463
+ if (error instanceof SkillStoreError || error instanceof CodexSkillLookupError)
464
+ return void res.status(error.statusCode).json({ ok: false, error: error.message });
465
+ res.status(500).json({ ok: false, error: error.message });
466
+ });
467
+ app.listen(port, "127.0.0.1", () => {
468
+ console.log("Dianran Canvas Agent");
469
+ checkVersions();
470
+ console.log(`Local URL: ${config.url}`);
471
+ console.log(`Connect token: ${config.token}`);
472
+ console.log("Codex MCP is not installed by this command.");
473
+ console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @kobinflow/canvas-agent@latest mcp");
474
+ console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
475
+ if (logger.enabled)
476
+ console.log(`Debug log: ${logger.filePath}`);
477
+ logger.info("Canvas Agent started", { url: config.url, workspace: ensureSiteWorkspace(config).workspacePath, debugLog: logger.filePath });
478
+ const activeThreadId = initialWorkspace.activeThreadId || "";
479
+ if (activeThreadId && session.beginCodexMutation()) {
480
+ void prepareExistingThread(activeThreadId).catch(async (error) => {
481
+ if (!isRecoverableThreadError(error))
482
+ return failPreparedConversation(error, activeThreadId);
483
+ session.beginConversation();
484
+ setActiveThread("", { emptyThread: true, draftThread: true }, true);
485
+ await prepareDraftThread("", "request");
486
+ }).finally(() => session.endCodexMutation()).catch(() => undefined);
487
+ }
488
+ });
489
+ }
490
+ /** 将异步 Express 路由异常交给统一错误处理中间件。 */
491
+ function route(handler) {
492
+ return (req, res, next) => void handler(req, res).catch(next);
493
+ }
494
+ /** 从 Express 路由参数中读取单个字符串。 */
495
+ function routeParam(value) {
496
+ return Array.isArray(value) ? value[0] || "" : value;
497
+ }
498
+ function permissionMode(value) {
499
+ return value === "automatic" || value === "full" ? value : "request";
500
+ }
501
+ function reasoningEffort(value) {
502
+ return value === "minimal" || value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max" || value === "ultra" ? value : undefined;
503
+ }
504
+ function startupStatus(value) {
505
+ return value === "starting" || value === "ready" || value === "failed" ? value : "cancelled";
506
+ }
507
+ function mcpInventory(value) {
508
+ if (!Array.isArray(value))
509
+ return [];
510
+ return value.flatMap((item) => {
511
+ if (!item || typeof item !== "object" || Array.isArray(item))
512
+ return [];
513
+ const server = item;
514
+ const name = String(server.name || "");
515
+ return name ? [{ name, authStatus: String(server.authStatus || "") || undefined }] : [];
516
+ });
517
+ }
518
+ /** 读取浏览器提交的 Skill 选择器;真实路径随后必须通过原生列表校验。 */
519
+ function skillSelector(value) {
520
+ const selector = value && typeof value === "object" && !Array.isArray(value) ? value : {};
521
+ const name = typeof selector.name === "string" ? selector.name : "";
522
+ const skillPath = typeof selector.path === "string" ? selector.path : "";
523
+ if (!name || !skillPath)
524
+ throw new CodexSkillLookupError("Skill 选择无效", 400);
525
+ return { name, path: skillPath };
526
+ }
527
+ /** 使用当前操作系统的文件管理器定位本地文件。 */
528
+ function revealLocalFile(filePath, isDirectory) {
529
+ const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "explorer.exe" : "xdg-open";
530
+ const args = process.platform === "darwin"
531
+ ? ["-R", filePath]
532
+ : process.platform === "win32"
533
+ ? [isDirectory ? filePath : `/select,${filePath}`]
534
+ : [isDirectory ? filePath : path.dirname(filePath)];
535
+ return new Promise((resolve, reject) => {
536
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
537
+ child.once("spawn", () => {
538
+ child.unref();
539
+ resolve();
540
+ });
541
+ child.once("error", reject);
542
+ });
543
+ }
544
+ /** 结合服务配置解析当前请求 URL。 */
545
+ function requestUrl(req, config) {
546
+ return new URL(req.originalUrl || req.url || "/", config.url);
547
+ }
548
+ /** 设置跨域响应头并记录通过 token 授权的来源。 */
549
+ function setCors(req, res, url, config) {
550
+ const origin = req.headers.origin;
551
+ res.setHeader("Access-Control-Allow-Origin", origin || "*");
552
+ res.setHeader("Access-Control-Allow-Headers", "content-type,x-canvas-agent-token");
553
+ res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
554
+ res.setHeader("Access-Control-Allow-Private-Network", "true");
555
+ if (!origin || req.method === "OPTIONS" || url.pathname === "/health" || url.pathname === "/config")
556
+ return true;
557
+ config.origins ||= [];
558
+ if (validToken(req, url, config.token) && !config.origins.includes(origin)) {
559
+ config.origins.push(origin);
560
+ saveConfig(config);
561
+ }
562
+ res.setHeader("Vary", "Origin");
563
+ return config.origins.includes(origin);
564
+ }
565
+ /** 校验请求查询参数或请求头中的连接 token。 */
566
+ function validToken(req, url, token) {
567
+ const header = req.headers["x-canvas-agent-token"];
568
+ return url.searchParams.get("token") === token || header === token || (Array.isArray(header) && header.includes(token));
569
+ }
570
+ /** 向 Agent 提示词追加本轮图片附件引用说明。 */
571
+ function withAttachmentContext(prompt, attachments) {
572
+ if (!attachments.length)
573
+ return prompt;
574
+ const list = attachments.map((item, index) => `${index + 1}. attachmentId=${item.id}, name=${JSON.stringify(item.name)}`).join("\n");
575
+ return `${prompt}\n\n本轮可用图片附件(顺序与图片输入一致):\n${list}\n需要把附件放入画布或作为生成参考图时,先调用 canvas_create_attachment_nodes,再使用返回的画布节点 ID 创建生成流程。`;
576
+ }
@@ -0,0 +1,2 @@
1
+ /** 启动通过标准输入输出通信的 MCP 服务。 */
2
+ export declare function startMcpServer(): Promise<void>;
@@ -0,0 +1,27 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { toolDescriptions, toolInputSchemas, toolNames } from "../canvas/schemas.js";
4
+ import { AGENT_PROMPT, loadConfig, VERSION } from "../config.js";
5
+ /** 启动通过标准输入输出通信的 MCP 服务。 */
6
+ export async function startMcpServer() {
7
+ const config = loadConfig(true);
8
+ const server = new McpServer({ name: "canvas-agent", version: VERSION }, { instructions: AGENT_PROMPT });
9
+ toolNames.forEach((name) => registerCanvasTool(server, config, name));
10
+ await server.connect(new StdioServerTransport());
11
+ }
12
+ /** 向 MCP Server 注册单个 Canvas Agent 工具。 */
13
+ function registerCanvasTool(server, config, name) {
14
+ const schema = toolInputSchemas[name];
15
+ server.registerTool(name, { description: toolDescriptions[name], inputSchema: schema.shape }, async (input) => {
16
+ const result = await postCanvasAgentTool(config, name, schema.parse(input));
17
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
18
+ });
19
+ }
20
+ /** 将 MCP 工具调用转发到本地 Canvas Agent HTTP 服务。 */
21
+ async function postCanvasAgentTool(config, name, input) {
22
+ const res = await fetch(`${config.url}/api/tools`, { method: "POST", headers: { "content-type": "application/json", "x-canvas-agent-token": config.token }, body: JSON.stringify({ name, input }) });
23
+ const body = (await res.json());
24
+ if (!body.ok)
25
+ throw new Error(body.error || "tool call failed");
26
+ return body.result;
27
+ }