@xiaohhhh1/canvas-agent 0.2.2 → 0.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.
Files changed (47) hide show
  1. package/README.md +21 -4
  2. package/agent-instructions.md +26 -0
  3. package/dist/agent/claude.d.ts +3 -0
  4. package/dist/agent/claude.js +46 -0
  5. package/dist/agent/codex-client.d.ts +73 -0
  6. package/dist/agent/codex-client.js +438 -0
  7. package/dist/agent/codex-history.d.ts +25 -0
  8. package/dist/agent/codex-history.js +405 -0
  9. package/dist/agent/codex-protocol.d.ts +208 -0
  10. package/dist/{agents.d.ts → agent/codex.d.ts} +34 -31
  11. package/dist/agent/codex.js +210 -0
  12. package/dist/agent/types.d.ts +14 -0
  13. package/dist/agent/types.js +1 -0
  14. package/dist/canvas/operations.d.ts +13 -0
  15. package/dist/canvas/operations.js +161 -0
  16. package/dist/{schemas.d.ts → canvas/schemas.d.ts} +21 -20
  17. package/dist/{schemas.js → canvas/schemas.js} +1 -0
  18. package/dist/{canvas-session.d.ts → canvas/session.d.ts} +21 -1
  19. package/dist/canvas/session.js +256 -0
  20. package/dist/{tools.d.ts → canvas/tools.d.ts} +13 -8
  21. package/dist/{tools.js → canvas/tools.js} +5 -0
  22. package/dist/{types.d.ts → canvas/types.d.ts} +1 -10
  23. package/dist/canvas/types.js +1 -0
  24. package/dist/config.d.ts +5 -1
  25. package/dist/config.js +22 -4
  26. package/dist/index.js +6 -3
  27. package/dist/server/ensure-http.d.ts +2 -0
  28. package/dist/server/ensure-http.js +28 -0
  29. package/dist/server/http.d.ts +2 -0
  30. package/dist/{http-server.js → server/http.js} +130 -16
  31. package/dist/server/mcp.d.ts +2 -0
  32. package/dist/server/mcp.js +61 -0
  33. package/dist/utils/date.d.ts +2 -0
  34. package/dist/utils/date.js +7 -0
  35. package/dist/utils/logger.d.ts +17 -0
  36. package/dist/utils/logger.js +83 -0
  37. package/dist/utils/value.d.ts +5 -0
  38. package/dist/utils/value.js +8 -0
  39. package/dist/workflow/manager.d.ts +160 -0
  40. package/dist/workflow/manager.js +461 -0
  41. package/package.json +7 -4
  42. package/dist/agents.js +0 -557
  43. package/dist/canvas-session.js +0 -391
  44. package/dist/http-server.d.ts +0 -1
  45. package/dist/mcp-server.d.ts +0 -1
  46. package/dist/mcp-server.js +0 -24
  47. /package/dist/{types.js → agent/codex-protocol.js} +0 -0
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Infinite Canvas Agent
2
2
 
3
- 本地 Canvas Agent 用来连接画布网页和用户电脑上的 Codex / Claude Code。本地开发时优先连接 `http://localhost:3000`,不需要先使用线上站点。
3
+ 本地 Canvas Agent 用来连接网站和用户自己电脑上的 Codex。它还负责持久化 Flow C 脚本队列、分段调用本机 Codex、自动回传草案,以及在网页关闭后继续把完成视频校验并保存到用户选择的磁盘目录。
4
4
 
5
5
  ## 启动
6
6
 
@@ -8,6 +8,14 @@
8
8
  npx -y @xiaohhhh1/canvas-agent
9
9
  ```
10
10
 
11
+ 需要排查连接、线程、Codex app-server 或工具调用问题时,可开启 Debug 模式:
12
+
13
+ ```bash
14
+ npx -y @xiaohhhh1/canvas-agent --debug
15
+ ```
16
+
17
+ Debug 日志会以 `[DEBUG][HH:mm:ss]` 等传统格式输出到终端,并按启动日期保存到 `~/.infinite-canvas/logs/canvas-agent-YYYY-MM-DD.log`。终端日志带级别颜色,文件日志为纯文本;日志包含 HTTP、SSE、线程、turn、Codex app-server 和工具调用事件,token 与图片 Data URL 会自动隐藏。
18
+
11
19
  本仓库开发时也可以直接运行:
12
20
 
13
21
  ```bash
@@ -66,13 +74,22 @@ codex plugin marketplace add "$(pwd)"
66
74
  codex plugin add infinite-canvas@infinite-canvas-local
67
75
  ```
68
76
 
69
- 插件默认通过 npm 启动 MCP;这个命令只提供 MCP 工具,不会把 MCP 写入全局配置,也不会在退出时自动卸载:
77
+ 插件默认通过 npm 启动 MCPMCP 启动时会自动检查并拉起常驻本机 HTTP/中继服务,因此客户安装插件后不需要另开终端:
70
78
 
71
79
  ```bash
72
80
  npx -y @xiaohhhh1/canvas-agent mcp
73
81
  ```
74
82
 
75
- 使用时可以直接在 Codex 里说“打开 Infinite Canvas”,插件会启动本地 Agent,读取 Local URL 和 Connect token,然后在右侧打开 `https://canvas.best/` 并自动新建、连接画布;只有明确要求使用本地项目时才会启动本地前端。
83
+ 使用时可以直接打开 `https://canvas.xiaohhhh1.com/workflow-batches`。网站通过已认证的 Agent Relay 与本机助手通信,不把本机目录、Codex 登录信息或短期任务令牌显示给客户。
84
+
85
+ ## Flow C 本机后台工作流
86
+
87
+ 1. 客户在网站添加一个或多个产品,按顺序上传每个产品 1–5 张图片并填写数量。
88
+ 2. 点击“交给本机 Codex 写全部脚本”。本机助手每次只处理 10 条并持久化进度,断网或重启后可继续;脚本完成会自动回传网站,不创建付费任务。
89
+ 3. 客户审阅脚本并确认费用后,中心 `workflow-runner` 才执行故事板和视频生成。
90
+ 4. 客户只需首次选择一个本机目录。Agent 每 10 秒检查交付,先写 `.part`,校验大小与 SHA-256 后原子改名;清单已记录的批次序号不会重复下载,网页关闭后仍会继续。
91
+
92
+ 本机持久状态默认保存在 `~/.infinite-canvas/workflow-state.json`,权限设为仅当前用户可读写。这里包含短期能力令牌和本机路径,不应上传、提交到 Git 或发到聊天中。
76
93
 
77
94
  Canvas Agent 启动后,给 Codex 添加 MCP:
78
95
 
@@ -126,7 +143,7 @@ default_tools_approval_mode = "approve"
126
143
 
127
144
  本地面板会把提示词发送给 Canvas Agent。Canvas Agent 使用官方 `@openai/codex` CLI 的 `codex app-server --stdio` 启动并复用同一个 Codex thread,启动时会注入 `infinite-canvas` MCP 配置并自动放行 MCP 审批,真正执行画布修改前仍由网页侧边栏二次确认。
128
145
 
129
- 侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;收到 app-server 的 `item/agentMessage/delta` 时,Canvas Agent 会转成 `item.updated`,网页会用同一条消息做真实流式更新,并把工具细节收进运行日志。
146
+ 侧边栏会展示 Codex 返回的 `thread.started`、`turn.started`、`item.*`、`turn.completed` 等结构化事件;Canvas Agent 会合并短时间内的回复、思考摘要和命令输出增量,网页使用同一条消息持续更新,并把任务进度、计划、搜索、文件修改与工具操作整理为中文过程时间线。
130
147
 
131
148
  侧边栏上传或粘贴的图片会先发到本机 Canvas Agent,再由 Canvas Agent 临时写入本机文件并作为 app-server `localImage` 输入传给 Codex;前端会提示附件体积,单次请求体限制为 30MB。
132
149
 
@@ -0,0 +1,26 @@
1
+ # Infinite Canvas Agent
2
+
3
+ 你正在帮助用户操作 Infinite Canvas 网站。
4
+
5
+ - 用户要求操作画布时,默认目标就是网页当前已经打开的画布。需要了解内容时先使用 `canvas_get_state`;读取成功后直接在该画布执行任务,不要调用 `canvas_list_projects`,也不要用 `site_navigate` 重复进入画布。
6
+ - 只有用户明确要求查看、选择或切换其他画布,或者 `canvas_get_state` 明确提示当前没有已连接画布时,才使用 `canvas_list_projects` 和 `site_navigate`。`site_navigate` 可跳转 `/`、`/canvas`、`/canvas/:id`、`/image`、`/video`、`/prompts`、`/assets`、`/config`。
7
+ - 修改当前画布时根据任务使用已配置的 infinite-canvas MCP 工具;复杂批量改动使用 `canvas_apply_ops`。
8
+ - 用户要求把上传附件放入画布或作为生成参考图时,必须先用 `canvas_create_attachment_nodes` 创建真实图片节点,再把节点 ID 传给生成流程,不要创建空图片占位节点。
9
+ - 生图与视频工作台分别使用 `workbench_image_*`、`workbench_video_*` 工具;提示词和素材分别使用 `prompts_search`、`assets_*` 工具。
10
+ - 用户要求生成图片、视频、音频或文本时,默认调用对应的 `canvas_generate_image`、`canvas_generate_video`、`canvas_generate_audio`、`canvas_generate_text`,通过当前画布的生成节点完成任务。
11
+ - 只有用户明确要求使用“Codex 内置生图”“ImageGen 技能”或意思明确相同的能力时,才使用 Codex 自带的 `imagegen`;不要因为用户只说“生成图片”就自行改用内置生图。内置生图完成后,其结果会由 Canvas Agent 自动展示到对话并插入当前画布,无需再创建空节点或重复生成。
12
+ - 只有用户明确说要在生图/视频工作台生成时,才使用 `workbench_image_*`、`workbench_video_*`。生成任务提交后应说明已经在画布或工作台开始生成,不要在实际没有结果时声称“已生成”。
13
+ - 需要生成内容时直接调用对应生成工具,不要绑定特定业务场景,不要模拟鼠标点击,不要要求用户手动复制 JSON。
14
+
15
+ ## 流程 C:脚本分镜到视频
16
+
17
+ - 用户要求打开带货任务时,调用 `open_flow_c_website`,不要输出或询问 Local URL、连接令牌。
18
+ - 网站把脚本任务交给本机 Codex 后,使用 `flow_c_get_script_task` 读取完整、服务器持久化的任务,再用 `flow_c_submit_script_chunk` 每次回传 1–25 条。不要要求用户复制 JSON、选择交接文件或把短期令牌发到聊天中。
19
+ - 大批量脚本按本机助手指定的序号分段创作;批量大小不能成为降低质量、缩短脚本或套用模板的理由。每条都必须独立构思、符合目标市场自然语言习惯、保持紧凑但清晰可说完的 10 秒节奏。
20
+ - 写脚本和回传草案不创建付费任务。只有客户在网站审阅并确认费用后,中心服务才可开始故事板和视频生成。
21
+
22
+ - 用户明确选择流程 C 时,必须新建独立画布、清单和归档目录,不得混用流程 A 或 B 的资产。
23
+ - 先使用原始同 SKU 商品图和完整脚本生成 9:16、10 秒导演分镜板;每镜必须包含时长、画面/镜头、对应配音、音效/BGM 节奏和情绪,并附专业技术规格栏。
24
+ - 原始商品图是唯一商品标准:每一格都必须保持颜色、外形、文字或 Logo、比例、部件和数量一致。分镜通过逐格检查前,严禁提交视频任务。
25
+ - 视频阶段必须同时提供已通过的分镜板和原始商品图。技术失败、超时、明显商品不一致或明显分镜错误时自动重做;提示词、参考图或平台合规问题才暂停等待用户修改。
26
+ - 未配置流程 C 的第三方视频接口时,只能完成分镜准备和验收,明确说明“待管理员配置视频接口”,不得假装已提交或产生扣费。
@@ -0,0 +1,3 @@
1
+ import type { AgentEmit } from "./types.js";
2
+ /** 使用 Claude CLI 执行一次带 Canvas Agent 工具的任务。 */
3
+ export declare function runClaudeTurn(prompt: string, emit: AgentEmit): void;
@@ -0,0 +1,46 @@
1
+ import { spawn } from "node:child_process";
2
+ import { AGENT_PROMPT } from "../config.js";
3
+ import { errorMessage } from "../utils/value.js";
4
+ /** 使用 Claude CLI 执行一次带 Canvas Agent 工具的任务。 */
5
+ export function runClaudeTurn(prompt, emit) {
6
+ const fullPrompt = withAgentPrompt(prompt);
7
+ if (!fullPrompt)
8
+ return;
9
+ const child = spawnAgent("claude", ["-p", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--allowedTools", "mcp__infinite-canvas__*", fullPrompt], emit);
10
+ if (child)
11
+ pipeJsonLines(child, emit, "claude");
12
+ }
13
+ /** 为 Claude CLI 请求拼接 Canvas Agent 指令。 */
14
+ function withAgentPrompt(prompt) {
15
+ return prompt.trim() ? `${AGENT_PROMPT}\n\n用户请求:${prompt}` : "";
16
+ }
17
+ /** 将 Claude CLI 的 JSON Lines 输出转换为 Agent 事件。 */
18
+ function pipeJsonLines(child, emit, agent) {
19
+ let out = "";
20
+ child.stdout?.on("data", (chunk) => {
21
+ out += chunk.toString();
22
+ const lines = out.split(/\r?\n/);
23
+ out = lines.pop() || "";
24
+ lines.filter(Boolean).forEach((line) => {
25
+ try {
26
+ emit("agent_event", { agent, ...JSON.parse(line) });
27
+ }
28
+ catch {
29
+ emit("agent_event", { agent, type: "raw", text: line });
30
+ }
31
+ });
32
+ });
33
+ child.stderr?.on("data", (chunk) => emit("agent_log", { text: chunk.toString() }));
34
+ child.on("error", (error) => emit("agent_error", { message: error.message }));
35
+ child.on("close", (code) => emit("agent_done", { agent, code }));
36
+ }
37
+ /** 启动外部 Agent CLI,并将同步启动异常转换为事件。 */
38
+ function spawnAgent(name, args, emit) {
39
+ try {
40
+ return spawn(name, args, { stdio: ["ignore", "pipe", "pipe"], shell: process.platform === "win32", windowsHide: true });
41
+ }
42
+ catch (error) {
43
+ emit("agent_error", { message: errorMessage(error) });
44
+ return null;
45
+ }
46
+ }
@@ -0,0 +1,73 @@
1
+ import type { CodexPlanUpdate, CodexRequestParams } from "./codex-protocol.js";
2
+ import type { AgentEmit, AgentPermissionMode } from "./types.js";
3
+ /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
4
+ export declare class CodexAppClient {
5
+ private child;
6
+ private emit;
7
+ private nextId;
8
+ private buffer;
9
+ private currentThreadId;
10
+ private currentTurnId;
11
+ private textByItem;
12
+ private lastUsage;
13
+ private pending;
14
+ private activeTurns;
15
+ private completedTurns;
16
+ private pendingDeltas;
17
+ private plansByTurn;
18
+ private approvalRequests;
19
+ /** 保存 app-server 子进程和事件出口。 */
20
+ private constructor();
21
+ /** 启动并初始化 Codex app-server。 */
22
+ static start(emit: AgentEmit, onExit: () => void): Promise<CodexAppClient>;
23
+ /** 创建新的 Codex 线程。 */
24
+ startThread(cwd?: string, permissionMode?: AgentPermissionMode): Promise<import("./codex-protocol.js").CodexThread>;
25
+ /** 恢复已有 Codex 线程。 */
26
+ resumeThread(threadId: string, cwd?: string, permissionMode?: AgentPermissionMode): Promise<import("./codex-protocol.js").CodexThread>;
27
+ /** 查询 Codex 线程列表。 */
28
+ listThreads(params: CodexRequestParams<"thread/list">): Promise<{
29
+ data: import("./codex-protocol.js").CodexThread[];
30
+ nextCursor: string | null;
31
+ backwardsCursor: string | null;
32
+ }>;
33
+ /** 读取指定 Codex 线程。 */
34
+ readThread(threadId: string, includeTurns?: boolean): Promise<{
35
+ thread: import("./codex-protocol.js").CodexThread;
36
+ }>;
37
+ /** 归档指定 Codex 线程。 */
38
+ archiveThread(threadId: string): Promise<Record<string, never>>;
39
+ /** 返回指定线程在当前进程中收到的最新任务计划。 */
40
+ planUpdates(threadId: string): CodexPlanUpdate[];
41
+ /** 清理已归档线程的任务计划缓存。 */
42
+ clearPlanUpdates(threadId: string): void;
43
+ /** 启动一个 Codex turn 并等待完成通知。 */
44
+ startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void): Promise<void>;
45
+ /** 中断当前正在运行的 Codex turn。 */
46
+ interruptCurrentTurn(): Promise<boolean>;
47
+ /** 回复网页端已经确认的 Codex 权限请求。 */
48
+ resolveApproval(requestId: string, decision: string): boolean;
49
+ /** 发送 JSON-RPC 请求并保存待处理 Promise。 */
50
+ private request;
51
+ /** 发送无需响应的 JSON-RPC 通知。 */
52
+ private notify;
53
+ /** 将 JSON-RPC 消息写入 app-server 标准输入。 */
54
+ private write;
55
+ /** 按行解析 app-server 标准输出。 */
56
+ private read;
57
+ /** 分派单条 JSON-RPC 响应、请求或通知。 */
58
+ private handle;
59
+ /** 转换并广播 app-server 通知。 */
60
+ private handleNotification;
61
+ /** 合并并广播 Agent 文本或执行输出增量。 */
62
+ private emitDelta;
63
+ /** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
64
+ private flushDelta;
65
+ /** 自动回复 app-server 发起的授权或交互请求。 */
66
+ private answerServerRequest;
67
+ /** 完成指定 JSON-RPC 请求。 */
68
+ private resolve;
69
+ /** 拒绝指定 JSON-RPC 请求。 */
70
+ private reject;
71
+ /** 拒绝进程退出时仍未完成的请求与 turn。 */
72
+ private failAll;
73
+ }
@@ -0,0 +1,438 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+ import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { VERSION } from "../config.js";
6
+ import { logger } from "../utils/logger.js";
7
+ import { field } from "../utils/value.js";
8
+ const canvasAgentMcp = canvasAgentMcpCommand();
9
+ const require = createRequire(import.meta.url);
10
+ const STREAM_UPDATE_INTERVAL_MS = 40;
11
+ /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
12
+ export class CodexAppClient {
13
+ child;
14
+ emit;
15
+ nextId = 1;
16
+ buffer = "";
17
+ currentThreadId = "";
18
+ currentTurnId = "";
19
+ textByItem = new Map();
20
+ lastUsage = null;
21
+ pending = new Map();
22
+ activeTurns = new Map();
23
+ completedTurns = new Map();
24
+ pendingDeltas = new Map();
25
+ plansByTurn = new Map();
26
+ approvalRequests = new Map();
27
+ /** 保存 app-server 子进程和事件出口。 */
28
+ constructor(child, emit) {
29
+ this.child = child;
30
+ this.emit = emit;
31
+ }
32
+ /** 启动并初始化 Codex app-server。 */
33
+ static async start(emit, onExit) {
34
+ logger.info("Starting Codex app-server", { executable: process.execPath, codex: codexBin() });
35
+ const child = spawn(process.execPath, [codexBin(), "app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
36
+ const client = new CodexAppClient(child, emit);
37
+ child.stdout?.on("data", (chunk) => client.read(chunk.toString()));
38
+ child.stderr?.on("data", (chunk) => {
39
+ const text = chunk.toString();
40
+ logger.warn("Codex app-server stderr", { text });
41
+ emit("agent_log", { text });
42
+ });
43
+ child.on("error", (error) => {
44
+ logger.error("Codex app-server process error", error);
45
+ emit("agent_error", { message: error.message });
46
+ });
47
+ child.on("exit", (code) => {
48
+ logger.warn("Codex app-server exited", { code });
49
+ client.failAll(`Codex app-server exited: ${code ?? 0}`);
50
+ onExit();
51
+ emit("agent_log", { text: `Codex app-server exited: ${code ?? 0}` });
52
+ });
53
+ await client.request("initialize", { clientInfo: { name: "canvas-agent", title: "Infinite Canvas Agent", version: VERSION }, capabilities: { experimentalApi: true, requestAttestation: false } });
54
+ client.notify("initialized");
55
+ return client;
56
+ }
57
+ /** 创建新的 Codex 线程。 */
58
+ async startThread(cwd, permissionMode = "request") {
59
+ const { thread } = await this.request("thread/start", { ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}), threadSource: "user" });
60
+ if (!thread.id)
61
+ throw new Error("Codex app-server 没有返回 thread id");
62
+ return thread;
63
+ }
64
+ /** 恢复已有 Codex 线程。 */
65
+ async resumeThread(threadId, cwd, permissionMode = "request") {
66
+ const { thread } = await this.request("thread/resume", { threadId, ...threadSettings(permissionMode), ...(cwd ? { cwd } : {}) });
67
+ if (!thread.id)
68
+ throw new Error("Codex app-server 没有返回 thread id");
69
+ return thread;
70
+ }
71
+ /** 查询 Codex 线程列表。 */
72
+ listThreads(params) {
73
+ return this.request("thread/list", params);
74
+ }
75
+ /** 读取指定 Codex 线程。 */
76
+ readThread(threadId, includeTurns = true) {
77
+ return this.request("thread/read", { threadId, includeTurns });
78
+ }
79
+ /** 归档指定 Codex 线程。 */
80
+ archiveThread(threadId) {
81
+ return this.request("thread/archive", { threadId });
82
+ }
83
+ /** 返回指定线程在当前进程中收到的最新任务计划。 */
84
+ planUpdates(threadId) {
85
+ return [...this.plansByTurn.values()].filter((item) => item.threadId === threadId);
86
+ }
87
+ /** 清理已归档线程的任务计划缓存。 */
88
+ clearPlanUpdates(threadId) {
89
+ this.plansByTurn.forEach((item, turnId) => {
90
+ if (item.threadId === threadId)
91
+ this.plansByTurn.delete(turnId);
92
+ });
93
+ }
94
+ /** 启动一个 Codex turn 并等待完成通知。 */
95
+ async startTurn(threadId, prompt, images, permissionMode, onTurn) {
96
+ this.currentThreadId = threadId;
97
+ const { turn } = await this.request("turn/start", { threadId, input: codexInput(prompt, images), ...turnSettings(permissionMode) });
98
+ const turnId = turn.id;
99
+ if (!turnId)
100
+ throw new Error("Codex app-server 没有返回 turn id");
101
+ this.currentTurnId = turnId;
102
+ onTurn?.(turnId);
103
+ const completed = this.completedTurns.get(turnId);
104
+ if (this.completedTurns.has(turnId)) {
105
+ this.completedTurns.delete(turnId);
106
+ this.currentThreadId = "";
107
+ this.currentTurnId = "";
108
+ if (completed)
109
+ throw completed;
110
+ return;
111
+ }
112
+ await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
113
+ }
114
+ /** 中断当前正在运行的 Codex turn。 */
115
+ async interruptCurrentTurn() {
116
+ const threadId = this.currentThreadId;
117
+ const turnId = this.currentTurnId;
118
+ if (!threadId || !turnId)
119
+ return false;
120
+ try {
121
+ logger.warn("Interrupting active Codex turn", { threadId, turnId });
122
+ await this.request("turn/interrupt", { threadId, turnId });
123
+ return true;
124
+ }
125
+ catch (error) {
126
+ logger.warn("Failed to interrupt Codex turn", { error, threadId, turnId });
127
+ return false;
128
+ }
129
+ }
130
+ /** 回复网页端已经确认的 Codex 权限请求。 */
131
+ resolveApproval(requestId, decision) {
132
+ const request = this.approvalRequests.get(requestId);
133
+ if (!request)
134
+ return false;
135
+ this.approvalRequests.delete(requestId);
136
+ const permissions = field(request.params, "permissions") || field(request.params, "requestedPermissions");
137
+ const result = request.method === "item/permissions/requestApproval"
138
+ ? { permissions: decision === "decline" ? {} : permissions || {}, scope: decision === "acceptForSession" ? "session" : "turn" }
139
+ : { decision };
140
+ this.write({ id: request.id, result });
141
+ return true;
142
+ }
143
+ /** 发送 JSON-RPC 请求并保存待处理 Promise。 */
144
+ request(method, params) {
145
+ const id = this.nextId++;
146
+ this.write({ id, method, params });
147
+ return new Promise((resolve, reject) => this.pending.set(id, { resolve: (result) => resolve(result), reject }));
148
+ }
149
+ /** 发送无需响应的 JSON-RPC 通知。 */
150
+ notify(method, params) {
151
+ this.write(params === undefined ? { method } : { method, params });
152
+ }
153
+ /** 将 JSON-RPC 消息写入 app-server 标准输入。 */
154
+ write(value) {
155
+ const method = String(field(value, "method") || "");
156
+ const params = field(value, "params");
157
+ if (method)
158
+ logger.debug(`Codex ${method}`, { id: field(value, "id"), threadId: field(params, "threadId") });
159
+ this.child.stdin?.write(`${JSON.stringify(value)}\n`);
160
+ }
161
+ /** 按行解析 app-server 标准输出。 */
162
+ read(chunk) {
163
+ this.buffer += chunk;
164
+ const lines = this.buffer.split(/\r?\n/);
165
+ this.buffer = lines.pop() || "";
166
+ lines.filter(Boolean).forEach((line) => {
167
+ try {
168
+ this.handle(JSON.parse(line));
169
+ }
170
+ catch (error) {
171
+ logger.warn("Invalid Codex app-server output", { error, line });
172
+ this.emit("agent_log", { text: line });
173
+ }
174
+ });
175
+ }
176
+ /** 分派单条 JSON-RPC 响应、请求或通知。 */
177
+ handle(message) {
178
+ const id = Number(message.id);
179
+ if (message.error && this.pending.has(id)) {
180
+ const error = String(field(message.error, "message") || "Codex request failed");
181
+ if (/not materialized yet.*includeTurns/i.test(error))
182
+ logger.debug("Codex thread has no messages yet", { id });
183
+ else
184
+ logger.warn("Codex request failed", { id, error });
185
+ return this.reject(id, error);
186
+ }
187
+ if (this.pending.has(id))
188
+ return this.resolve(id, message.result);
189
+ if (typeof message.method === "string" && "id" in message)
190
+ return this.answerServerRequest(message);
191
+ if (typeof message.method === "string")
192
+ this.handleNotification(message.method, (message.params || {}));
193
+ }
194
+ /** 转换并广播 app-server 通知。 */
195
+ handleNotification(method, params) {
196
+ if (method === "serverRequest/resolved") {
197
+ const requestId = String(field(params, "requestId") || "");
198
+ if (requestId)
199
+ this.approvalRequests.delete(requestId);
200
+ this.emit("codex_approval_resolved", { requestId, ...params });
201
+ return;
202
+ }
203
+ if (!field(params, "threadId") && this.currentThreadId && (method === "turn/started" || method === "turn/completed" || method === "turn/plan/updated"))
204
+ params = { ...params, threadId: this.currentThreadId };
205
+ if (method === "item/agentMessage/delta") {
206
+ const value = params;
207
+ this.textByItem.set(value.itemId, `${this.textByItem.get(value.itemId) || ""}${value.delta}`);
208
+ return this.emitDelta("agent_message", value);
209
+ }
210
+ if (method === "item/plan/delta")
211
+ return this.emitDelta("plan", params);
212
+ if (method === "item/reasoning/summaryTextDelta")
213
+ return this.emitDelta("reasoning", params);
214
+ if (method === "item/commandExecution/outputDelta")
215
+ return this.emitDelta("command_execution", params);
216
+ if (method === "turn/plan/updated") {
217
+ const value = params;
218
+ const update = { ...value, threadId: value.threadId || "" };
219
+ if (update.threadId && update.turnId)
220
+ this.plansByTurn.set(update.turnId, update);
221
+ params = update;
222
+ }
223
+ if (method === "thread/tokenUsage/updated") {
224
+ this.lastUsage = normalizeUsage(params);
225
+ this.emit("agent_event", { agent: "codex", type: "usage.updated", usage: this.lastUsage, ...codexEventScope(params) });
226
+ return;
227
+ }
228
+ const event = normalizeCodexNotification(method, params);
229
+ if (!event)
230
+ return;
231
+ if (event.type === "item.completed") {
232
+ const item = field(event, "item");
233
+ const id = String(field(item, "id") || "");
234
+ this.flushDelta(id);
235
+ const streamedText = this.textByItem.get(id);
236
+ if (item?.type === "agent_message" && streamedText && !item.text)
237
+ item.text = streamedText;
238
+ if (id)
239
+ this.textByItem.delete(id);
240
+ }
241
+ if (event.type === "turn.completed") {
242
+ const turn = field(params, "turn");
243
+ const turnId = String(field(turn, "id") || field(params, "turnId") || "");
244
+ const plan = this.plansByTurn.get(turnId);
245
+ if (plan)
246
+ this.plansByTurn.set(turnId, { ...plan, turnStatus: String(field(turn, "status") || "completed") });
247
+ }
248
+ if (event.type === "turn.completed")
249
+ event.usage = this.lastUsage;
250
+ this.emit("agent_event", { agent: "codex", ...event });
251
+ if (event.type === "turn.completed") {
252
+ const turn = params.turn;
253
+ const turnId = turn.id;
254
+ const pending = this.activeTurns.get(turnId);
255
+ const error = turn.error;
256
+ if (pending) {
257
+ this.activeTurns.delete(turnId);
258
+ error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(event);
259
+ }
260
+ else if (turnId) {
261
+ this.completedTurns.set(turnId, error ? new Error(error.message || "Codex turn failed") : null);
262
+ }
263
+ if (turnId === this.currentTurnId) {
264
+ this.currentThreadId = "";
265
+ this.currentTurnId = "";
266
+ }
267
+ this.emit("agent_done", { agent: "codex", usage: event.usage, ...codexEventScope(params) });
268
+ }
269
+ }
270
+ /** 合并并广播 Agent 文本或执行输出增量。 */
271
+ emitDelta(itemType, params) {
272
+ const id = params.itemId;
273
+ const pending = this.pendingDeltas.get(id);
274
+ if (pending) {
275
+ pending.delta += params.delta;
276
+ pending.itemType = itemType;
277
+ pending.params = params;
278
+ return;
279
+ }
280
+ this.pendingDeltas.set(id, {
281
+ delta: params.delta,
282
+ itemType,
283
+ params,
284
+ timer: setTimeout(() => this.flushDelta(id), STREAM_UPDATE_INTERVAL_MS),
285
+ });
286
+ }
287
+ /** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
288
+ flushDelta(id) {
289
+ const pending = this.pendingDeltas.get(id);
290
+ if (!pending)
291
+ return;
292
+ clearTimeout(pending.timer);
293
+ this.pendingDeltas.delete(id);
294
+ if (pending.delta)
295
+ this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params) });
296
+ }
297
+ /** 自动回复 app-server 发起的授权或交互请求。 */
298
+ answerServerRequest(message) {
299
+ const method = String(message.method);
300
+ const params = field(message, "params") || {};
301
+ if (["item/commandExecution/requestApproval", "item/fileChange/requestApproval", "item/permissions/requestApproval"].includes(method)) {
302
+ const requestId = String(message.id);
303
+ this.approvalRequests.set(requestId, { id: Number(message.id), method, params });
304
+ this.emit("codex_approval", { requestId, method, ...params });
305
+ return;
306
+ }
307
+ const result = method === "mcpServer/elicitation/request" ? { action: "accept", content: {}, _meta: null } : { decision: "decline" };
308
+ this.write({ id: message.id, result });
309
+ this.emit("agent_event", { agent: "codex", type: "server.request", method, params, result });
310
+ }
311
+ /** 完成指定 JSON-RPC 请求。 */
312
+ resolve(id, result) {
313
+ const pending = this.pending.get(id);
314
+ if (pending)
315
+ (this.pending.delete(id), pending.resolve(result));
316
+ }
317
+ /** 拒绝指定 JSON-RPC 请求。 */
318
+ reject(id, message) {
319
+ const pending = this.pending.get(id);
320
+ if (pending)
321
+ (this.pending.delete(id), pending.reject(new Error(message)));
322
+ }
323
+ /** 拒绝进程退出时仍未完成的请求与 turn。 */
324
+ failAll(message) {
325
+ [...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
326
+ this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
327
+ this.pending.clear();
328
+ this.activeTurns.clear();
329
+ this.pendingDeltas.clear();
330
+ this.textByItem.clear();
331
+ this.approvalRequests.clear();
332
+ this.currentThreadId = "";
333
+ this.currentTurnId = "";
334
+ }
335
+ }
336
+ /** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
337
+ function canvasAgentMcpCommand() {
338
+ const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
339
+ const entry = path.resolve(current || fileURLToPath(new URL("../index.js", import.meta.url)));
340
+ const tsx = path.join(path.dirname(entry), "..", "node_modules", "tsx", "dist", "cli.mjs");
341
+ return entry.endsWith(".ts") ? { command: process.execPath, args: [tsx, entry, "mcp"] } : { command: process.execPath, args: [entry, "mcp"] };
342
+ }
343
+ /** 生成 Codex app-server 使用的 MCP 配置。 */
344
+ function codexConfig(permissionMode) {
345
+ return { model_reasoning_summary: "auto", ...(permissionMode === "automatic" ? { approvals_reviewer: "auto_review" } : {}), mcp_servers: { "infinite-canvas": { command: canvasAgentMcp.command, args: canvasAgentMcp.args, default_tools_approval_mode: "approve", startup_timeout_sec: 20, tool_timeout_sec: 90 } } };
346
+ }
347
+ function threadSettings(permissionMode) {
348
+ return { approvalPolicy: permissionMode === "full" ? "never" : "on-request", sandbox: permissionMode === "full" ? "danger-full-access" : "workspace-write", config: codexConfig(permissionMode) };
349
+ }
350
+ function turnSettings(permissionMode) {
351
+ return {
352
+ approvalPolicy: permissionMode === "full" ? "never" : "on-request",
353
+ sandboxPolicy: permissionMode === "full" ? { type: "dangerFullAccess" } : { type: "workspaceWrite", networkAccess: false },
354
+ };
355
+ }
356
+ /** 将文本和本地图片转换为 Codex turn 输入。 */
357
+ function codexInput(prompt, images) {
358
+ return [{ type: "text", text: prompt, text_elements: [] }, ...images.map((file) => ({ type: "localImage", path: file }))];
359
+ }
360
+ /** 将 app-server 通知转换为前端使用的 Agent 事件。 */
361
+ function normalizeCodexNotification(method, params) {
362
+ const scope = codexEventScope(params);
363
+ if (method === "thread/started")
364
+ return { type: "thread.started", ...scope };
365
+ if (method === "turn/started")
366
+ return { type: "turn.started", ...scope };
367
+ if (method === "turn/completed")
368
+ return { type: "turn.completed", status: field(field(params, "turn"), "status"), error: field(field(params, "turn"), "error"), usage: null, duration_ms: field(field(params, "turn"), "durationMs"), ...scope };
369
+ if (method === "turn/plan/updated")
370
+ return { type: "plan.updated", explanation: field(params, "explanation"), plan: field(params, "plan"), ...scope };
371
+ if (method === "item/started")
372
+ return { type: "item.started", item: normalizeItem(field(params, "item")), ...scope };
373
+ if (method === "item/completed")
374
+ return { type: "item.completed", item: normalizeItem(field(params, "item")), ...scope };
375
+ if (method === "error")
376
+ return { type: "error", message: field(field(params, "error"), "message"), ...scope };
377
+ return null;
378
+ }
379
+ /** 提取 Codex 事件所属的线程和 turn。 */
380
+ function codexEventScope(params) {
381
+ const threadId = String(field(params, "threadId") || field(field(params, "thread"), "id") || "");
382
+ const turnId = String(field(params, "turnId") || field(field(params, "turn"), "id") || "");
383
+ return { ...(threadId ? { thread_id: threadId } : {}), ...(turnId ? { turn_id: turnId } : {}) };
384
+ }
385
+ /** 统一 app-server item 的类型和参数格式。 */
386
+ function normalizeItem(item) {
387
+ const value = item && typeof item === "object" ? { ...item } : {};
388
+ if (value.type === "agentMessage")
389
+ value.type = "agent_message";
390
+ if (value.type === "mcpToolCall")
391
+ value.type = "mcp_tool_call";
392
+ if (value.type === "commandExecution")
393
+ value.type = "command_execution";
394
+ if (value.type === "fileChange")
395
+ value.type = "file_change";
396
+ if (value.type === "dynamicToolCall")
397
+ value.type = "dynamic_tool_call";
398
+ if (value.type === "collabToolCall")
399
+ value.type = "collab_tool_call";
400
+ if (value.type === "webSearch")
401
+ value.type = "web_search";
402
+ if (value.type === "imageView")
403
+ value.type = "image_view";
404
+ if (value.type === "imageGeneration")
405
+ value.type = "image_generation";
406
+ if (value.type === "contextCompaction")
407
+ value.type = "context_compaction";
408
+ if (value.type === "agent_message" && typeof value.id === "string")
409
+ value.text = String(value.text || "");
410
+ if ("arguments" in value)
411
+ value.arguments = parseMaybeJson(value.arguments);
412
+ return value;
413
+ }
414
+ /** 将 Codex token usage 转换为前端字段。 */
415
+ function normalizeUsage(params) {
416
+ const last = params.tokenUsage.last;
417
+ return {
418
+ input_tokens: last.inputTokens,
419
+ cached_input_tokens: last.cachedInputTokens,
420
+ output_tokens: last.outputTokens,
421
+ reasoning_output_tokens: last.reasoningOutputTokens,
422
+ };
423
+ }
424
+ /** 尝试将字符串解析为 JSON,失败时保留原值。 */
425
+ function parseMaybeJson(value) {
426
+ if (typeof value !== "string")
427
+ return value;
428
+ try {
429
+ return JSON.parse(value);
430
+ }
431
+ catch {
432
+ return value;
433
+ }
434
+ }
435
+ /** 定位当前依赖中 Codex CLI 的执行文件。 */
436
+ function codexBin() {
437
+ return path.join(path.dirname(require.resolve("@openai/codex/package.json")), "bin", "codex.js");
438
+ }