@xiaohhhh1/canvas-agent 0.4.5 → 0.4.7
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 +104 -8
- package/dist/index.js +3 -0
- package/dist/relay-bridge.js +13 -1
- package/dist/server/ensure-http.js +1 -1
- package/dist/server/http.js +4 -3
- package/dist/server/supervisor.d.ts +4 -0
- package/dist/server/supervisor.js +38 -0
- package/package.json +1 -1
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
import { startHttpServer } from "./server/http.js";
|
|
3
3
|
import { ensureHttpServer } from "./server/ensure-http.js";
|
|
4
4
|
import { startMcpServer } from "./server/mcp.js";
|
|
5
|
+
import { startHttpSupervisor } from "./server/supervisor.js";
|
|
5
6
|
if (process.argv[2] === "mcp") {
|
|
6
7
|
await ensureHttpServer();
|
|
7
8
|
await startMcpServer();
|
|
8
9
|
}
|
|
10
|
+
else if (process.argv[2] === "watch")
|
|
11
|
+
startHttpSupervisor();
|
|
9
12
|
else
|
|
10
13
|
startHttpServer();
|
package/dist/relay-bridge.js
CHANGED
|
@@ -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", () =>
|
|
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();
|
|
@@ -19,7 +19,7 @@ export async function ensureHttpServer() {
|
|
|
19
19
|
const entry = process.argv[1];
|
|
20
20
|
if (!entry)
|
|
21
21
|
throw new Error("无法定位 Canvas Agent 启动文件");
|
|
22
|
-
const child = spawn(process.execPath, [entry, "
|
|
22
|
+
const child = spawn(process.execPath, [entry, "watch"], { detached: true, stdio: "ignore", windowsHide: true });
|
|
23
23
|
child.unref();
|
|
24
24
|
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
25
25
|
await new Promise((resolve) => setTimeout(resolve, 250));
|
package/dist/server/http.js
CHANGED
|
@@ -10,6 +10,7 @@ import { startRelayBridge } from "../relay-bridge.js";
|
|
|
10
10
|
import { logger } from "../utils/logger.js";
|
|
11
11
|
import { windowsRootExecutable, windowsSystemExecutable } from "../utils/windows.js";
|
|
12
12
|
import { WorkflowManager } from "../workflow/manager.js";
|
|
13
|
+
import { AGENT_REPLACED_EXIT_CODE } from "./supervisor.js";
|
|
13
14
|
/** 启动仅监听本机的 Canvas Agent HTTP 服务。 */
|
|
14
15
|
export function startHttpServer() {
|
|
15
16
|
const config = loadConfig(true);
|
|
@@ -64,8 +65,8 @@ export function startHttpServer() {
|
|
|
64
65
|
app.post("/agent/shutdown", (_req, res) => {
|
|
65
66
|
res.json({ ok: true, version: VERSION });
|
|
66
67
|
setTimeout(() => {
|
|
67
|
-
httpServer?.close(() => process.exit(
|
|
68
|
-
setTimeout(() => process.exit(
|
|
68
|
+
httpServer?.close(() => process.exit(AGENT_REPLACED_EXIT_CODE));
|
|
69
|
+
setTimeout(() => process.exit(AGENT_REPLACED_EXIT_CODE), 2000).unref();
|
|
69
70
|
}, 50).unref();
|
|
70
71
|
});
|
|
71
72
|
app.get("/events", (req, res) => session.openEvents(requestUrl(req, config), res));
|
|
@@ -270,7 +271,7 @@ export function startHttpServer() {
|
|
|
270
271
|
httpServer = app.listen(port, "127.0.0.1", () => {
|
|
271
272
|
console.log("Infinite Canvas Agent");
|
|
272
273
|
console.log(`Local URL: ${config.url}`);
|
|
273
|
-
console.log(
|
|
274
|
+
console.log("Connect token: stored securely in the local Agent configuration");
|
|
274
275
|
console.log("Codex MCP is not installed by this command.");
|
|
275
276
|
console.log("Optional MCP add: codex mcp add infinite-canvas -- npx -y @xiaohhhh1/canvas-agent mcp");
|
|
276
277
|
console.log("Remove manually added MCP: codex mcp remove infinite-canvas");
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export declare const AGENT_REPLACED_EXIT_CODE = 75;
|
|
2
|
+
export declare function shouldRestartAgent(code: number | null, stopping: boolean): boolean;
|
|
3
|
+
/** Keep the local HTTP worker alive, but step aside when a newer package replaces it. */
|
|
4
|
+
export declare function startHttpSupervisor(): void;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
export const AGENT_REPLACED_EXIT_CODE = 75;
|
|
3
|
+
const RESTART_DELAY_MS = 1000;
|
|
4
|
+
export function shouldRestartAgent(code, stopping) {
|
|
5
|
+
return !stopping && code !== AGENT_REPLACED_EXIT_CODE;
|
|
6
|
+
}
|
|
7
|
+
/** Keep the local HTTP worker alive, but step aside when a newer package replaces it. */
|
|
8
|
+
export function startHttpSupervisor() {
|
|
9
|
+
const entry = process.argv[1];
|
|
10
|
+
if (!entry)
|
|
11
|
+
throw new Error("无法定位 Canvas Agent 启动文件");
|
|
12
|
+
let child;
|
|
13
|
+
let stopping = false;
|
|
14
|
+
let restartTimer;
|
|
15
|
+
const launch = () => {
|
|
16
|
+
if (stopping)
|
|
17
|
+
return;
|
|
18
|
+
child = spawn(process.execPath, [entry, "serve"], { stdio: "ignore", windowsHide: true });
|
|
19
|
+
child.once("exit", (code) => {
|
|
20
|
+
child = undefined;
|
|
21
|
+
if (!shouldRestartAgent(code, stopping))
|
|
22
|
+
return void process.exit(0);
|
|
23
|
+
restartTimer = setTimeout(launch, RESTART_DELAY_MS);
|
|
24
|
+
});
|
|
25
|
+
};
|
|
26
|
+
const stop = () => {
|
|
27
|
+
if (stopping)
|
|
28
|
+
return;
|
|
29
|
+
stopping = true;
|
|
30
|
+
if (restartTimer)
|
|
31
|
+
clearTimeout(restartTimer);
|
|
32
|
+
child?.kill();
|
|
33
|
+
setTimeout(() => process.exit(0), 2000).unref();
|
|
34
|
+
};
|
|
35
|
+
process.once("SIGINT", stop);
|
|
36
|
+
process.once("SIGTERM", stop);
|
|
37
|
+
launch();
|
|
38
|
+
}
|