@xiaohhhh1/canvas-agent 0.4.43 → 0.4.45

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.
@@ -1,5 +1,5 @@
1
1
  import { type JsonRecord } from "../utils/value.js";
2
- import type { CodexPlanUpdate, CodexRequestParams } from "./codex-protocol.js";
2
+ import type { CodexPlanUpdate, CodexRequestParams, CodexTurn } from "./codex-protocol.js";
3
3
  import type { AgentEmit, AgentPermissionMode } from "./types.js";
4
4
  /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
5
5
  export declare class CodexAppClient {
@@ -46,6 +46,8 @@ export declare class CodexAppClient {
46
46
  startTurn(threadId: string, prompt: string, images: string[], permissionMode: AgentPermissionMode, onTurn?: (turnId: string) => void, outputSchema?: JsonRecord): Promise<string>;
47
47
  /** 中断当前正在运行的 Codex turn。 */
48
48
  interruptCurrentTurn(): Promise<boolean>;
49
+ /** 终止失去响应的 app-server,让后续队列可以在新进程继续。 */
50
+ terminate(message?: string): Promise<void>;
49
51
  /** 回复网页端已经确认的 Codex 权限请求。 */
50
52
  resolveApproval(requestId: string, decision: string): boolean;
51
53
  /** 发送 JSON-RPC 请求并保存待处理 Promise。 */
@@ -64,6 +66,13 @@ export declare class CodexAppClient {
64
66
  private emitDelta;
65
67
  /** 合并短时间内的文本增量,减少 SSE 传输和前端渲染次数。 */
66
68
  private flushDelta;
69
+ /**
70
+ * app-server 的流式通知偶尔会在本地重连时丢失。定期读取线程的最终
71
+ * 状态,保证被中断的 turn 不会只因少了一条通知而永久占着 Flow C 队列。
72
+ */
73
+ private scheduleTurnReconciliation;
74
+ /** 清除 turn 等待器与其轮询计时器。 */
75
+ private clearActiveTurn;
67
76
  /** 自动回复 app-server 发起的授权或交互请求。 */
68
77
  private answerServerRequest;
69
78
  /** 完成指定 JSON-RPC 请求。 */
@@ -73,3 +82,9 @@ export declare class CodexAppClient {
73
82
  /** 拒绝进程退出时仍未完成的请求与 turn。 */
74
83
  private failAll;
75
84
  }
85
+ /** 只有正常 completed 才能被调用方视为可用脚本。 */
86
+ export declare function isTerminalTurnStatus(status: unknown): boolean;
87
+ /** interrupted 也必须作为失败传回,不能被误当成空的成功响应。 */
88
+ export declare function turnFailure(turn: Pick<CodexTurn, "status" | "error">): Error | null;
89
+ /** 补偿轮询发现终态时,尽量取回已完成的结构化文本。 */
90
+ export declare function turnText(turn: CodexTurn): string;
@@ -8,6 +8,7 @@ import { field } from "../utils/value.js";
8
8
  const canvasAgentMcp = canvasAgentMcpCommand();
9
9
  const require = createRequire(import.meta.url);
10
10
  const STREAM_UPDATE_INTERVAL_MS = 40;
11
+ const TURN_RECONCILE_INTERVAL_MS = 5_000;
11
12
  /** 封装 Codex app-server 的 JSON-RPC 通信与事件转换。 */
12
13
  export class CodexAppClient {
13
14
  child;
@@ -110,7 +111,10 @@ export class CodexAppClient {
110
111
  throw completed.error;
111
112
  return completed?.text || "";
112
113
  }
113
- return await new Promise((resolve, reject) => this.activeTurns.set(turnId, { resolve, reject }));
114
+ return await new Promise((resolve, reject) => {
115
+ this.activeTurns.set(turnId, { resolve, reject });
116
+ this.scheduleTurnReconciliation(threadId, turnId);
117
+ });
114
118
  }
115
119
  /** 中断当前正在运行的 Codex turn。 */
116
120
  async interruptCurrentTurn() {
@@ -128,6 +132,17 @@ export class CodexAppClient {
128
132
  return false;
129
133
  }
130
134
  }
135
+ /** 终止失去响应的 app-server,让后续队列可以在新进程继续。 */
136
+ terminate(message = "Codex app-server was restarted") {
137
+ this.failAll(message);
138
+ if (this.child.exitCode !== null)
139
+ return Promise.resolve();
140
+ return new Promise((resolve) => {
141
+ const timer = setTimeout(resolve, 2000);
142
+ this.child.once("exit", () => { clearTimeout(timer); resolve(); });
143
+ this.child.kill();
144
+ });
145
+ }
131
146
  /** 回复网页端已经确认的 Codex 权限请求。 */
132
147
  resolveApproval(requestId, decision) {
133
148
  const request = this.approvalRequests.get(requestId);
@@ -256,11 +271,11 @@ export class CodexAppClient {
256
271
  const turn = params.turn;
257
272
  const turnId = turn.id;
258
273
  const pending = this.activeTurns.get(turnId);
259
- const error = turn.error;
274
+ const error = turnFailure(turn);
260
275
  const text = this.finalTextByTurn.get(turnId) || "";
261
276
  this.finalTextByTurn.delete(turnId);
262
277
  if (pending) {
263
- this.activeTurns.delete(turnId);
278
+ this.clearActiveTurn(turnId);
264
279
  error ? pending.reject(new Error(error.message || "Codex turn failed")) : pending.resolve(text);
265
280
  }
266
281
  else if (turnId) {
@@ -300,6 +315,53 @@ export class CodexAppClient {
300
315
  if (pending.delta)
301
316
  this.emit("agent_event", { agent: "codex", type: "item.updated", item: { id, type: pending.itemType, delta: pending.delta }, ...codexEventScope(pending.params) });
302
317
  }
318
+ /**
319
+ * app-server 的流式通知偶尔会在本地重连时丢失。定期读取线程的最终
320
+ * 状态,保证被中断的 turn 不会只因少了一条通知而永久占着 Flow C 队列。
321
+ */
322
+ scheduleTurnReconciliation(threadId, turnId) {
323
+ const check = async () => {
324
+ const pending = this.activeTurns.get(turnId);
325
+ if (!pending)
326
+ return;
327
+ try {
328
+ const result = await this.readThread(threadId, true);
329
+ const thread = field(result, "thread");
330
+ const turns = Array.isArray(field(thread, "turns")) ? field(thread, "turns") : [];
331
+ const turn = turns.find((item) => item?.id === turnId);
332
+ if (turn && isTerminalTurnStatus(turn.status)) {
333
+ const error = turnFailure(turn);
334
+ const text = turnText(turn);
335
+ this.finalTextByTurn.delete(turnId);
336
+ this.clearActiveTurn(turnId);
337
+ error ? pending.reject(new Error(error.message)) : pending.resolve(text);
338
+ if (turnId === this.currentTurnId) {
339
+ this.currentThreadId = "";
340
+ this.currentTurnId = "";
341
+ }
342
+ return;
343
+ }
344
+ }
345
+ catch (error) {
346
+ // The normal deadline in the workflow manager remains the hard
347
+ // fallback. A transient read error must not cancel live work.
348
+ logger.debug("Codex turn reconciliation read failed", { threadId, turnId, error });
349
+ }
350
+ const current = this.activeTurns.get(turnId);
351
+ if (current)
352
+ current.reconcileTimer = setTimeout(() => void check(), TURN_RECONCILE_INTERVAL_MS);
353
+ };
354
+ const pending = this.activeTurns.get(turnId);
355
+ if (pending)
356
+ pending.reconcileTimer = setTimeout(() => void check(), TURN_RECONCILE_INTERVAL_MS);
357
+ }
358
+ /** 清除 turn 等待器与其轮询计时器。 */
359
+ clearActiveTurn(turnId) {
360
+ const pending = this.activeTurns.get(turnId);
361
+ if (pending?.reconcileTimer)
362
+ clearTimeout(pending.reconcileTimer);
363
+ this.activeTurns.delete(turnId);
364
+ }
303
365
  /** 自动回复 app-server 发起的授权或交互请求。 */
304
366
  answerServerRequest(message) {
305
367
  const method = String(message.method);
@@ -328,7 +390,13 @@ export class CodexAppClient {
328
390
  }
329
391
  /** 拒绝进程退出时仍未完成的请求与 turn。 */
330
392
  failAll(message) {
331
- [...this.pending.values(), ...this.activeTurns.values()].forEach((item) => item.reject(new Error(message)));
393
+ [...this.pending.values()].forEach((item) => item.reject(new Error(message)));
394
+ [...this.activeTurns.entries()].forEach(([turnId, item]) => {
395
+ if (item.reconcileTimer)
396
+ clearTimeout(item.reconcileTimer);
397
+ this.activeTurns.delete(turnId);
398
+ item.reject(new Error(message));
399
+ });
332
400
  this.pendingDeltas.forEach((item) => clearTimeout(item.timer));
333
401
  this.pending.clear();
334
402
  this.activeTurns.clear();
@@ -340,6 +408,35 @@ export class CodexAppClient {
340
408
  this.currentTurnId = "";
341
409
  }
342
410
  }
411
+ /** 只有正常 completed 才能被调用方视为可用脚本。 */
412
+ export function isTerminalTurnStatus(status) {
413
+ return ["completed", "failed", "interrupted", "cancelled", "canceled"].includes(String(status || "").toLowerCase());
414
+ }
415
+ /** interrupted 也必须作为失败传回,不能被误当成空的成功响应。 */
416
+ export function turnFailure(turn) {
417
+ if (turn.error)
418
+ return new Error(turn.error.message || "Codex turn failed");
419
+ const status = String(turn.status || "completed").toLowerCase();
420
+ return status === "completed" ? null : new Error(`Codex turn ${status}`);
421
+ }
422
+ /** 补偿轮询发现终态时,尽量取回已完成的结构化文本。 */
423
+ export function turnText(turn) {
424
+ const items = Array.isArray(field(turn, "items")) ? field(turn, "items") : [];
425
+ for (const item of [...items].reverse()) {
426
+ if (!["agent_message", "agentMessage"].includes(String(field(item, "type") || "")))
427
+ continue;
428
+ const text = field(item, "text");
429
+ if (typeof text === "string" && text)
430
+ return text;
431
+ const content = field(item, "content");
432
+ if (Array.isArray(content)) {
433
+ const joined = content.map((part) => String(field(part, "text") || "")).join("");
434
+ if (joined)
435
+ return joined;
436
+ }
437
+ }
438
+ return "";
439
+ }
343
440
  /** 生成 Codex 调用 Canvas Agent MCP 的启动命令。 */
344
441
  function canvasAgentMcpCommand() {
345
442
  const current = process.argv.find((arg) => /index\.(t|j)s$/.test(arg)) || "";
@@ -6,6 +6,7 @@ export type CodexThread = JsonRecord & {
6
6
  };
7
7
  export type CodexTurn = JsonRecord & {
8
8
  id: string;
9
+ status?: string;
9
10
  error?: CodexTurnError | null;
10
11
  durationMs?: number | null;
11
12
  };
@@ -22,6 +22,8 @@ export { summarizeCodexThread } from "./codex-history.js";
22
22
  export declare function runCodexTurn(prompt: string, emit: AgentEmit, attachments?: AgentAttachment[], options?: CodexRunOptions): Promise<CodexRunResult>;
23
23
  /** 中断当前线程正在执行的 Codex turn。 */
24
24
  export declare function interruptCodexTurn(threadId?: string): Promise<boolean>;
25
+ /** 强制回收失去响应的 app-server;只用于有界超时恢复。 */
26
+ export declare function restartCodexApp(message?: string): Promise<void>;
25
27
  /** 回复当前 app-server 的待处理权限请求。 */
26
28
  export declare function resolveCodexApproval(requestId: string, decision: string): Promise<boolean>;
27
29
  /** 创建新的 Codex 线程并记录当前线程 ID。 */
@@ -24,6 +24,14 @@ export async function interruptCodexTurn(threadId) {
24
24
  return false;
25
25
  return await codexApp.interruptCurrentTurn();
26
26
  }
27
+ /** 强制回收失去响应的 app-server;只用于有界超时恢复。 */
28
+ export async function restartCodexApp(message = "Codex 执行超时,正在重启本机脚本引擎") {
29
+ const app = codexApp;
30
+ codexApp = null;
31
+ codexAppStart = null;
32
+ codexThreadId = "";
33
+ await app?.terminate(message);
34
+ }
27
35
  /** 回复当前 app-server 的待处理权限请求。 */
28
36
  export async function resolveCodexApproval(requestId, decision) {
29
37
  return Boolean(codexApp?.resolveApproval(requestId, decision));
@@ -975,16 +975,16 @@ export declare const toolInputSchemas: {
975
975
  }, "strip", z.ZodTypeAny, {
976
976
  title: string;
977
977
  kind: "text" | "image";
978
- source?: string | undefined;
979
978
  content?: string | undefined;
979
+ source?: string | undefined;
980
980
  tags?: string[] | undefined;
981
981
  imageUrl?: string | undefined;
982
982
  note?: string | undefined;
983
983
  }, {
984
984
  title: string;
985
985
  kind: "text" | "image";
986
- source?: string | undefined;
987
986
  content?: string | undefined;
987
+ source?: string | undefined;
988
988
  tags?: string[] | undefined;
989
989
  imageUrl?: string | undefined;
990
990
  note?: string | undefined;
@@ -173,8 +173,8 @@ export declare function parseToolInput(name: ToolName, input: unknown): {
173
173
  } | {
174
174
  title: string;
175
175
  kind: "text" | "image";
176
- source?: string | undefined;
177
176
  content?: string | undefined;
177
+ source?: string | undefined;
178
178
  tags?: string[] | undefined;
179
179
  imageUrl?: string | undefined;
180
180
  note?: string | undefined;
package/dist/index.js CHANGED
@@ -7,6 +7,10 @@ if (process.argv[2] === "mcp") {
7
7
  await ensureHttpServer();
8
8
  await startMcpServer();
9
9
  }
10
+ else if (process.argv[2] === "upgrade") {
11
+ await ensureHttpServer();
12
+ console.log("Canvas Agent upgraded and running in the background.");
13
+ }
10
14
  else if (process.argv[2] === "watch")
11
15
  startHttpSupervisor();
12
16
  else
@@ -59,7 +59,7 @@ export function startHttpServer() {
59
59
  return void res.json({});
60
60
  next();
61
61
  });
62
- app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
62
+ app.get("/health", (_req, res) => res.json({ ...session.health(), version: VERSION, workflow: workflows.health(), relayReady: relayStatus.ready, relayLastReadyAt: relayStatus.lastReadyAt, relayLastDisconnectAt: relayStatus.lastDisconnectAt }));
63
63
  app.get("/config", (_req, res) => res.json({ ok: true, url: config.url, hasToken: true }));
64
64
  app.use((req, res, next) => {
65
65
  if (validToken(req, requestUrl(req, config), config.token))
@@ -1,7 +1,22 @@
1
1
  import type { AgentEmit } from "../agent/types.js";
2
2
  import { type CanvasAgentConfig } from "../config.js";
3
+ export declare const FLOW_C_CODEX_TURN_TIMEOUT_MS: number;
3
4
  type ScriptStatus = "queued" | "running" | "complete" | "error" | "expired";
4
5
  type DownloadStatus = "waiting" | "running" | "complete" | "error" | "expired";
6
+ type ScriptRecord = {
7
+ id: string;
8
+ apiBase: string;
9
+ accessToken: string;
10
+ status: ScriptStatus;
11
+ requestedCount: number;
12
+ receivedOrdinals: number[];
13
+ threadId?: string;
14
+ message?: string;
15
+ expiresAt?: string;
16
+ attempts: number;
17
+ priorityAt?: string;
18
+ updatedAt: string;
19
+ };
5
20
  type ScriptTask = {
6
21
  id: string;
7
22
  workflow: "flow-c";
@@ -86,6 +101,11 @@ export declare class WorkflowManager {
86
101
  updatedAt: string;
87
102
  }[];
88
103
  };
104
+ health(): {
105
+ activeScriptHandoffIds: string[];
106
+ queuedScripts: number;
107
+ blockedScripts: number;
108
+ };
89
109
  startDownloadDirectorySelection(): {
90
110
  id: string;
91
111
  status: "selecting" | "selected" | "cancelled" | "error";
@@ -174,4 +194,5 @@ export declare class WorkflowManager {
174
194
  private downloadRecord;
175
195
  private save;
176
196
  }
197
+ export declare function compareScriptQueueRecords(left: Pick<ScriptRecord, "priorityAt" | "updatedAt">, right: Pick<ScriptRecord, "priorityAt" | "updatedAt">): number;
177
198
  export {};
@@ -5,13 +5,14 @@ import { mkdir, open, rename, stat, unlink } from "node:fs/promises";
5
5
  import path from "node:path";
6
6
  import { Readable, Transform } from "node:stream";
7
7
  import { pipeline } from "node:stream/promises";
8
- import { runCodexTurn, startCodexThread } from "../agent/codex.js";
8
+ import { restartCodexApp, runCodexTurn, startCodexThread } from "../agent/codex.js";
9
9
  import { CONFIG_DIR, ensureSiteWorkspace } from "../config.js";
10
10
  import { logger } from "../utils/logger.js";
11
11
  import { windowsPowerShellExecutable } from "../utils/windows.js";
12
12
  import { FLOW_C_SCRIPT_CHUNK_MAX, FLOW_C_SCRIPT_CHUNK_SIZES } from "./constants.js";
13
13
  import { flowCScriptOutputSchema, parseFlowCScriptOutput } from "./script-output.js";
14
14
  const STATE_FILE = path.join(CONFIG_DIR, "workflow-state.json");
15
+ export const FLOW_C_CODEX_TURN_TIMEOUT_MS = 8 * 60 * 1000;
15
16
  /** 本机持久化的 Flow C 控制器:脚本交给本机 Codex,视频直接落盘。 */
16
17
  export class WorkflowManager {
17
18
  config;
@@ -46,6 +47,7 @@ export class WorkflowManager {
46
47
  threadId: previous?.threadId,
47
48
  expiresAt: String(input.expiresAt || previous?.expiresAt || "") || undefined,
48
49
  attempts: previous?.attempts || 0,
50
+ priorityAt: now(),
49
51
  message: previous?.status === "complete" ? previous.message : "已进入本机 Codex 队列",
50
52
  updatedAt: now(),
51
53
  };
@@ -57,6 +59,7 @@ export class WorkflowManager {
57
59
  const id = workflowId(idValue, "脚本交接 ID");
58
60
  const record = this.scriptRecord(id);
59
61
  record.status = "queued";
62
+ record.priorityAt = now();
60
63
  record.message = "正在重新连接本机 Codex";
61
64
  record.updatedAt = now();
62
65
  this.save();
@@ -109,6 +112,14 @@ export class WorkflowManager {
109
112
  subscriptions: Object.values(this.state.downloads).map(publicDownload),
110
113
  };
111
114
  }
115
+ health() {
116
+ const records = Object.values(this.state.scripts);
117
+ return {
118
+ activeScriptHandoffIds: [...this.runningScripts],
119
+ queuedScripts: records.filter((record) => record.status === "queued" || record.status === "running").length,
120
+ blockedScripts: records.filter((record) => record.status === "error").length,
121
+ };
122
+ }
112
123
  startDownloadDirectorySelection() {
113
124
  if (this.directorySelection?.status === "selecting")
114
125
  return this.directorySelection;
@@ -177,7 +188,7 @@ export class WorkflowManager {
177
188
  while (true) {
178
189
  const next = Object.values(this.state.scripts)
179
190
  .filter((record) => (record.status === "queued" || record.status === "running") && !this.runningScripts.has(record.id))
180
- .sort((left, right) => left.updatedAt.localeCompare(right.updatedAt))[0];
191
+ .sort(compareScriptQueueRecords)[0];
181
192
  if (!next)
182
193
  break;
183
194
  await this.runScript(next.id);
@@ -207,6 +218,7 @@ export class WorkflowManager {
207
218
  this.runningScripts.add(id);
208
219
  const record = this.scriptRecord(id);
209
220
  try {
221
+ delete record.priorityAt;
210
222
  record.status = "running";
211
223
  record.message = "正在读取完整产品清单";
212
224
  record.updatedAt = now();
@@ -236,7 +248,7 @@ export class WorkflowManager {
236
248
  record.message = `本机 Codex 正在写第 ${missing[0]}–${missing.at(-1)} 条(总计 ${task.requested_count} 条)`;
237
249
  record.updatedAt = now();
238
250
  this.save();
239
- const result = await runCodexTurn(scriptChunkPrompt(id, task, missing), this.emit, [], { threadId: record.threadId, cwd: workspace.workspacePath, permissionMode: "full", outputSchema: flowCScriptOutputSchema(Number(task.duration_seconds || 10), missing.length), onThread: (threadId) => { record.threadId = threadId; this.save(); } });
251
+ const result = await runCodexScriptWithTimeout(scriptChunkPrompt(id, task, missing), this.emit, { threadId: record.threadId, cwd: workspace.workspacePath, permissionMode: "full", outputSchema: flowCScriptOutputSchema(Number(task.duration_seconds || 10), missing.length), onThread: (threadId) => { record.threadId = threadId; this.save(); } });
240
252
  await this.scriptTask(id);
241
253
  progressed = record.receivedOrdinals.length > before;
242
254
  if (!progressed && result.ok && result.text) {
@@ -417,6 +429,28 @@ export class WorkflowManager {
417
429
  }
418
430
  save() { saveState(this.state); }
419
431
  }
432
+ export function compareScriptQueueRecords(left, right) {
433
+ if (left.priorityAt || right.priorityAt)
434
+ return String(right.priorityAt || "").localeCompare(String(left.priorityAt || ""));
435
+ return left.updatedAt.localeCompare(right.updatedAt);
436
+ }
437
+ async function runCodexScriptWithTimeout(prompt, emit, options) {
438
+ let timer;
439
+ const turn = runCodexTurn(prompt, emit, [], options);
440
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), FLOW_C_CODEX_TURN_TIMEOUT_MS); });
441
+ try {
442
+ const result = await Promise.race([turn, timeout]);
443
+ if (result !== "timeout")
444
+ return result;
445
+ await restartCodexApp("Flow C 脚本回合超过 8 分钟,已自动终止并换新会话");
446
+ await turn;
447
+ return { ok: false, error: "Flow C 脚本回合超过 8 分钟,已自动终止并换新会话" };
448
+ }
449
+ finally {
450
+ if (timer)
451
+ clearTimeout(timer);
452
+ }
453
+ }
420
454
  class ExpiredCapabilityError extends Error {
421
455
  }
422
456
  function scriptChunkPrompt(id, task, ordinals) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.43",
3
+ "version": "0.4.45",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",