@xiaohhhh1/canvas-agent 0.4.6 → 0.4.8

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/dist/config.js CHANGED
@@ -5,25 +5,121 @@ import path from "node:path";
5
5
  export const DEFAULT_PORT = 17371;
6
6
  export const CONFIG_DIR = path.join(os.homedir(), ".infinite-canvas");
7
7
  export const CONFIG_FILE = path.join(CONFIG_DIR, "canvas-agent.json");
8
+ const CONFIG_BACKUP_FILE = `${CONFIG_FILE}.bak`;
9
+ const CONFIG_LOCK_FILE = `${CONFIG_FILE}.lock`;
8
10
  export const VERSION = readPackageVersion();
9
11
  export const AGENT_PROMPT = fs.readFileSync(new URL("../agent-instructions.md", import.meta.url), "utf8");
10
12
  const initializedWorkspaces = new Set();
11
13
  /** 读取本地 Canvas Agent 配置,不存在时生成默认配置。 */
12
14
  export function loadConfig(create = false) {
13
- try {
14
- return JSON.parse(fs.readFileSync(CONFIG_FILE, "utf8"));
15
+ const existing = readStoredConfig();
16
+ if (existing)
17
+ return existing;
18
+ if (fs.existsSync(CONFIG_FILE) || fs.existsSync(CONFIG_BACKUP_FILE)) {
19
+ throw new Error(`Canvas Agent configuration is unreadable: ${CONFIG_FILE}`);
15
20
  }
16
- catch {
17
- const config = { url: `http://127.0.0.1:${Number(process.env.PORT) || DEFAULT_PORT}`, token: crypto.randomBytes(18).toString("hex") };
18
- if (create)
19
- saveConfig(config);
21
+ const config = { url: `http://127.0.0.1:${Number(process.env.PORT) || DEFAULT_PORT}`, token: crypto.randomBytes(18).toString("hex") };
22
+ if (!create)
20
23
  return config;
21
- }
24
+ return withConfigLock(() => {
25
+ const raced = readStoredConfig();
26
+ if (raced)
27
+ return raced;
28
+ writeStoredConfig(config);
29
+ return config;
30
+ });
22
31
  }
23
32
  /** 将 Canvas Agent 配置写入用户配置目录。 */
24
33
  export function saveConfig(config) {
34
+ withConfigLock(() => {
35
+ const current = readStoredConfig();
36
+ // The token is the durable identity used by the browser relay. A stale
37
+ // concurrent process may update workspace preferences, but it must never
38
+ // rotate that identity and strand every already-paired browser.
39
+ if (current?.token && current.token !== config.token)
40
+ config.token = current.token;
41
+ writeStoredConfig(config);
42
+ });
43
+ }
44
+ function readStoredConfig() {
45
+ return readConfigFile(CONFIG_FILE) || readConfigFile(CONFIG_BACKUP_FILE);
46
+ }
47
+ function readConfigFile(file) {
48
+ try {
49
+ const value = JSON.parse(fs.readFileSync(file, "utf8"));
50
+ if (typeof value.url !== "string" || !value.url || typeof value.token !== "string" || !value.token)
51
+ return null;
52
+ return value;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function writeStoredConfig(config) {
25
59
  fs.mkdirSync(CONFIG_DIR, { recursive: true });
26
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
60
+ const body = JSON.stringify(config, null, 2);
61
+ writeAtomic(CONFIG_BACKUP_FILE, body);
62
+ writeAtomic(CONFIG_FILE, body);
63
+ }
64
+ function writeAtomic(target, body) {
65
+ const temporary = `${target}.${process.pid}.${crypto.randomUUID()}.tmp`;
66
+ try {
67
+ fs.writeFileSync(temporary, body, { encoding: "utf8", flag: "wx" });
68
+ try {
69
+ fs.renameSync(temporary, target);
70
+ }
71
+ catch (error) {
72
+ const code = error.code;
73
+ if (code !== "EEXIST" && code !== "EPERM")
74
+ throw error;
75
+ // Windows does not consistently replace an existing file with
76
+ // renameSync. The backup is written first, so readers remain able
77
+ // to recover the durable identity during this tiny replacement.
78
+ fs.unlinkSync(target);
79
+ fs.renameSync(temporary, target);
80
+ }
81
+ }
82
+ finally {
83
+ try {
84
+ fs.unlinkSync(temporary);
85
+ }
86
+ catch { }
87
+ }
88
+ }
89
+ function withConfigLock(operation) {
90
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
91
+ let descriptor;
92
+ for (let attempt = 0; attempt < 200; attempt += 1) {
93
+ try {
94
+ descriptor = fs.openSync(CONFIG_LOCK_FILE, "wx");
95
+ break;
96
+ }
97
+ catch (error) {
98
+ if (error.code !== "EEXIST")
99
+ throw error;
100
+ try {
101
+ if (Date.now() - fs.statSync(CONFIG_LOCK_FILE).mtimeMs > 30_000)
102
+ fs.unlinkSync(CONFIG_LOCK_FILE);
103
+ }
104
+ catch { }
105
+ sleepSync(10);
106
+ }
107
+ }
108
+ if (descriptor === undefined)
109
+ throw new Error("Canvas Agent configuration is busy");
110
+ try {
111
+ return operation();
112
+ }
113
+ finally {
114
+ fs.closeSync(descriptor);
115
+ try {
116
+ fs.unlinkSync(CONFIG_LOCK_FILE);
117
+ }
118
+ catch { }
119
+ }
120
+ }
121
+ function sleepSync(milliseconds) {
122
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
27
123
  }
28
124
  /** 确保站点级 Codex 工作空间存在并已初始化。 */
29
125
  export function ensureSiteWorkspace(config) {
@@ -1,6 +1,7 @@
1
1
  import WebSocket from "ws";
2
2
  const DEFAULT_RELAY_URL = "wss://canvas.xiaohhhh1.com/api/agent-relay";
3
3
  const RECONNECT_DELAY_MS = 3_000;
4
+ const HEARTBEAT_INTERVAL_MS = 15_000;
4
5
  /**
5
6
  * Keeps an outbound, encrypted connection to the production relay. The canvas
6
7
  * browser can then use same-origin requests instead of directly reaching a
@@ -12,6 +13,7 @@ export function startRelayBridge(config) {
12
13
  let socket = null;
13
14
  let stopped = false;
14
15
  let reconnectTimer = null;
16
+ let heartbeatTimer = null;
15
17
  const send = (message) => {
16
18
  if (socket?.readyState === WebSocket.OPEN)
17
19
  socket.send(JSON.stringify(message));
@@ -70,9 +72,17 @@ export function startRelayBridge(config) {
70
72
  return;
71
73
  try {
72
74
  socket = new WebSocket(relayUrl);
73
- socket.on("open", () => send({ type: "hello", role: "agent", token: config.token }));
75
+ socket.on("open", () => {
76
+ send({ type: "hello", role: "agent", token: config.token });
77
+ if (heartbeatTimer)
78
+ clearInterval(heartbeatTimer);
79
+ heartbeatTimer = setInterval(() => send({ type: "heartbeat", time: Date.now() }), HEARTBEAT_INTERVAL_MS);
80
+ });
74
81
  socket.on("message", onMessage);
75
82
  socket.on("close", () => {
83
+ if (heartbeatTimer)
84
+ clearInterval(heartbeatTimer);
85
+ heartbeatTimer = null;
76
86
  subscriptions.forEach((controller) => controller.abort());
77
87
  subscriptions.clear();
78
88
  if (!stopped)
@@ -89,6 +99,8 @@ export function startRelayBridge(config) {
89
99
  stopped = true;
90
100
  if (reconnectTimer)
91
101
  clearTimeout(reconnectTimer);
102
+ if (heartbeatTimer)
103
+ clearInterval(heartbeatTimer);
92
104
  subscriptions.forEach((controller) => controller.abort());
93
105
  subscriptions.clear();
94
106
  socket?.close();
@@ -271,7 +271,7 @@ export function startHttpServer() {
271
271
  httpServer = app.listen(port, "127.0.0.1", () => {
272
272
  console.log("Infinite Canvas Agent");
273
273
  console.log(`Local URL: ${config.url}`);
274
- console.log(`Connect token: ${config.token}`);
274
+ console.log("Connect token: stored securely in the local Agent configuration");
275
275
  console.log("Codex MCP is not installed by this command.");
276
276
  console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @xiaohhhh1/canvas-agent mcp");
277
277
  console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
@@ -391,6 +391,7 @@ function scriptChunkPrompt(id, task, ordinals) {
391
391
  本段 productIndex 必须严格按此映射填写:${scriptProductAssignments(task.product_quantities, ordinals)}。不得凭产品名称猜测或把相邻产品编号混用。
392
392
  脚本质量绝不能因批量而降低:每条都必须独立构思、完整、真实合规,严格遵守任务 instructions、产品图片顺序和 productIndex;使用目标市场 ${task.market} 的自然本地语言与偏快但清晰的 10 秒节奏。
393
393
  用户没有指定带货方向时,不得随意只用一种泛化形式;必须按任务 instructions 中“已批准的创意方向”逐条做产品适配轮换。轮换必须按每个产品自己的序号连续计算,不能因 30/15/10 条分段、换会话或跨产品边界而从第一个方向重新开始;让同一产品在重复某一方向前优先覆盖其他适配方向。
394
+ 工厂风格 A/B 是默认轮换中的演绎带货布景,不是商品来源声明;不得因用户未提供真实工厂资料而跳过,也绝不能写成我们的真实工厂、真实生产流程、真实产地、工厂直销、厂家出货或仓库现货。
394
395
  写完后必须调用 flow_c_submit_script_chunk 一次回传这 ${ordinals.length} 条,handoffId=${id}。不要创建付费批次,不要调用供应商模型,不要在聊天输出大段 JSON。工具返回成功后仅简短结束。`;
395
396
  }
396
397
  function scriptProductAssignments(productQuantities, ordinals) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xiaohhhh1/canvas-agent",
3
- "version": "0.4.6",
3
+ "version": "0.4.8",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",