@m2canvas/connect 0.3.1 → 0.5.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.
@@ -0,0 +1,379 @@
1
+ /**
2
+ * 本机 ACP bridge 的 HTTP + WebSocket 服务:连接准入、握手、配对、方法分派、ACP 转发(页面 → agent 先经 acp-guard.ts 检查,agent → 页面原样)、心跳与清理。
3
+ * 协议规范见 protocol.ts 文件头指向的 design「页面协议」一节。
4
+ *
5
+ * 安全模型(改动前先读 security.ts / pairing.ts / acp-guard.ts 的文件头):
6
+ * **配对 = 授权该页面以用户的身份驱动本机 agent**,包括让 agent 读写文件、执行命令(提示词、替用户回答审批、切换工作模式
7
+ * 都由页面发出)。所以安全的重心在「谁能配对」,配对之后能做的事只做纵深防御:
8
+ * - 只监听 127.0.0.1;upgrade 校验(对端、Host、Origin、路径)在完成握手之前,失败直接写 HTTP 状态并销毁 socket。
9
+ * - 握手后第一条消息必须是 hello;未配对的连接只能调用 pair,其余已知方法与 acp 帧一律 not_paired。
10
+ * 连接只有两种方式成为已配对:hello 带有效 token,或 hello 的 pairSecret 与启动命令的承诺一致(pairing.ts 文件头)。
11
+ * - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
12
+ * - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
13
+ *
14
+ * 边界:bridge 不解析 agent 发来的 ACP,不判断用户是否开通了本地 Agent(change 4 / 3 负责),不替用户登录。
15
+ */
16
+ import { createServer } from 'node:http';
17
+ import { randomUUID } from 'node:crypto';
18
+ import { WebSocketServer } from 'ws';
19
+ import { TOKEN_IDLE_DAYS } from './pairing.js';
20
+ import { ProcessError, ProcessManager } from './processes.js';
21
+ import { BRIDGE_AGENT_IDS, BRIDGE_CLOSE_CODES, BRIDGE_METHODS, BRIDGE_SUPPORTED_PROTOCOLS, } from './protocol.js';
22
+ import { checkPageAcpLine } from './acp-guard.js';
23
+ import { checkUpgrade } from './security.js';
24
+ import { BridgeFailure } from './service.js';
25
+ export const HELLO_TIMEOUT_MS = 10_000;
26
+ export const UNPAIRED_TIMEOUT_MS = 10 * 60 * 1000;
27
+ export const MAX_UNPAIRED_CONNECTIONS = 4;
28
+ export const PING_INTERVAL_MS = 30_000;
29
+ export const MAX_FRAME_BYTES = 64 * 1024 * 1024;
30
+ export const REJECT_LOG_WINDOW_MS = 60_000;
31
+ const MAX_CALL_ID = 64;
32
+ function isObject(value) {
33
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
34
+ }
35
+ function isAgentId(value) {
36
+ return typeof value === 'string' && BRIDGE_AGENT_IDS.includes(value);
37
+ }
38
+ function isMethod(value) {
39
+ return typeof value === 'string' && BRIDGE_METHODS.includes(value);
40
+ }
41
+ function rejectUpgrade(socket, status) {
42
+ const text = status === 403 ? 'Forbidden' : 'Not Found';
43
+ socket.end(`HTTP/1.1 ${status} ${text}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`);
44
+ socket.destroy();
45
+ }
46
+ export function startBridgeServer(options) {
47
+ const timing = {
48
+ helloTimeoutMs: options.timing?.helloTimeoutMs ?? HELLO_TIMEOUT_MS,
49
+ unpairedTimeoutMs: options.timing?.unpairedTimeoutMs ?? UNPAIRED_TIMEOUT_MS,
50
+ pingIntervalMs: options.timing?.pingIntervalMs ?? PING_INTERVAL_MS,
51
+ };
52
+ const connections = new Map();
53
+ const log = options.log;
54
+ const send = (conn, frame) => {
55
+ if (conn.ws.readyState === conn.ws.OPEN)
56
+ conn.ws.send(JSON.stringify(frame));
57
+ };
58
+ const sendError = (conn, id, error) => send(conn, { type: 'error', id, error });
59
+ const processes = new ProcessManager({
60
+ env: options.agentEnv,
61
+ cwd: options.cwd,
62
+ killGraceMs: options.timing?.killGraceMs,
63
+ maxPerOwner: options.limits?.maxPerOwner,
64
+ maxTotal: options.limits?.maxTotal,
65
+ maxLineChars: options.limits?.maxLineChars,
66
+ // 每个进程只属于拉起它的连接,只投递给它
67
+ onLine: (ownerId, processId, line) => {
68
+ const conn = connections.get(ownerId);
69
+ if (conn)
70
+ send(conn, { type: 'acp', processId, line });
71
+ },
72
+ onExit: (ownerId, info) => {
73
+ const conn = connections.get(ownerId);
74
+ log(`${info.agent} exited (${info.reason}${info.code !== null ? `, code ${info.code}` : ''}${info.signal ? `, ${info.signal}` : ''})`);
75
+ if (conn)
76
+ send(conn, { type: 'event', event: 'process/exited', data: info });
77
+ },
78
+ onStderr: options.verbose ? (_id, agent, text) => process.stderr.write(`[${agent}] ${text}`) : undefined,
79
+ });
80
+ const unpairedCount = () => [...connections.values()].filter((conn) => !conn.paired).length;
81
+ const markPaired = (conn) => {
82
+ conn.paired = true;
83
+ if (conn.timer)
84
+ clearTimeout(conn.timer);
85
+ conn.timer = null;
86
+ };
87
+ async function handleCall(conn, id, method, params) {
88
+ if (method === 'pair') {
89
+ const code = params.code;
90
+ if (typeof code !== 'string')
91
+ throw new BridgeFailure('bad_message', 'pair needs a string "code"');
92
+ const result = options.pairing.pair(code, conn.origin);
93
+ if (!result.ok) {
94
+ if (result.code === 'pairing_invalid')
95
+ throw new BridgeFailure('pairing_invalid', 'wrong pairing code', { attemptsLeft: result.attemptsLeft });
96
+ if (result.code === 'pairing_locked')
97
+ throw new BridgeFailure('pairing_locked', 'too many wrong pairing codes; new pages cannot pair until the bridge is restarted');
98
+ throw new BridgeFailure('pairing_code_rotated', 'pairing code expired or used up; a new one is shown in the terminal');
99
+ }
100
+ markPaired(conn);
101
+ log(`Paired with ${conn.origin}.`);
102
+ return { token: result.token, cwd: options.cwd, idleExpiryDays: TOKEN_IDLE_DAYS };
103
+ }
104
+ if (!conn.paired)
105
+ throw new BridgeFailure('not_paired', 'pair this page first');
106
+ if (method === 'process/stop') {
107
+ const processId = params.processId;
108
+ if (typeof processId !== 'string' || !processes.stop(conn.id, processId))
109
+ throw new BridgeFailure('process_not_found', 'no such process on this connection');
110
+ return {};
111
+ }
112
+ if (method === 'agents/list')
113
+ return { agents: await options.service.list() };
114
+ const agent = params.agent;
115
+ if (!isAgentId(agent))
116
+ throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
117
+ switch (method) {
118
+ case 'agent/prepare':
119
+ return { agent, adapter: await options.service.prepare(agent) };
120
+ case 'agent/precheck':
121
+ return options.service.precheck(agent);
122
+ case 'm2/configure':
123
+ log(`Page asked to add M2 to ${agent}'s MCP config.`);
124
+ return { agent, ...(await options.service.configure(agent)) };
125
+ case 'process/start': {
126
+ const launch = await options.service.launch(agent);
127
+ try {
128
+ const processId = await processes.start(conn.id, agent, launch);
129
+ // 启动期间页面已断开:没有人会再停它,立刻清理
130
+ if (!connections.has(conn.id))
131
+ processes.stopOwner(conn.id);
132
+ log(`Started ${agent}.`);
133
+ return { processId, agent, cwd: options.cwd };
134
+ }
135
+ catch (error) {
136
+ if (error instanceof ProcessError)
137
+ throw new BridgeFailure(error.code, error.message);
138
+ throw error;
139
+ }
140
+ }
141
+ }
142
+ // 未知方法在调用前已被 isMethod 拦下;走到这里说明 BRIDGE_METHODS 与上面的分支不一致
143
+ throw new BridgeFailure('internal', `unhandled method ${method} (call ${id})`);
144
+ }
145
+ function onHello(conn, frame) {
146
+ const protocols = Array.isArray(frame.protocols) ? frame.protocols.filter((value) => typeof value === 'number') : [];
147
+ if (frame.type !== 'hello' || protocols.length === 0) {
148
+ conn.ws.close(BRIDGE_CLOSE_CODES.badHandshake, 'expected hello');
149
+ return;
150
+ }
151
+ const common = protocols.filter((value) => BRIDGE_SUPPORTED_PROTOCOLS.includes(value));
152
+ if (common.length === 0) {
153
+ sendError(conn, null, { code: 'protocol_unsupported', message: 'no common protocol version', data: { supported: [...BRIDGE_SUPPORTED_PROTOCOLS] } });
154
+ conn.ws.close(BRIDGE_CLOSE_CODES.protocolUnsupported, 'protocol unsupported');
155
+ return;
156
+ }
157
+ // conn.hello 等 welcome 发出前一刻才置位:中间任何一步抛异常时,消息监听器的 catch 看到「还在握手」,
158
+ // 以 1011 关闭连接,而不是留下一个收不到 welcome、计时器又已清掉的挂起连接
159
+ if (conn.timer)
160
+ clearTimeout(conn.timer);
161
+ conn.timer = null;
162
+ const token = typeof frame.token === 'string' ? frame.token : undefined;
163
+ let paired = options.pairing.verifyToken(token, conn.origin);
164
+ // 命令承诺配对:在未配对名额检查之前 —— 合法页面不被别人占满的未配对名额挡住。对不上(含写盘失败)一律按未配对继续,不抛出
165
+ let issuedToken;
166
+ if (!paired && typeof frame.pairSecret === 'string') {
167
+ const result = options.pairing.pairWithSecret(frame.pairSecret, conn.origin);
168
+ if (result.ok) {
169
+ paired = true;
170
+ issuedToken = result.token;
171
+ log(`Paired with ${conn.origin}.`);
172
+ }
173
+ else if (options.verbose)
174
+ log(`${conn.origin} did not pair from the start command (${result.reason}).`);
175
+ }
176
+ if (paired) {
177
+ markPaired(conn);
178
+ }
179
+ else {
180
+ if (unpairedCount() > MAX_UNPAIRED_CONNECTIONS) {
181
+ conn.ws.close(BRIDGE_CLOSE_CODES.tooManyUnpaired, 'too many unpaired pages');
182
+ return;
183
+ }
184
+ options.pairing.ensureCode(conn.origin);
185
+ conn.timer = setTimeout(() => conn.ws.close(BRIDGE_CLOSE_CODES.timeout, 'pairing timed out'), timing.unpairedTimeoutMs);
186
+ }
187
+ const client = isObject(frame.client) && typeof frame.client.name === 'string' ? ` (${frame.client.name})` : '';
188
+ if (options.verbose)
189
+ log(`Page connected from ${conn.origin}${client}, ${paired ? 'paired' : 'not paired'}.`);
190
+ conn.hello = true;
191
+ send(conn, {
192
+ type: 'welcome',
193
+ protocol: Math.max(...common),
194
+ bridge: options.bridge,
195
+ paired,
196
+ tokenRejected: token !== undefined && !paired,
197
+ cwd: paired ? options.cwd : null,
198
+ ...(issuedToken ? { token: issuedToken } : {}),
199
+ });
200
+ }
201
+ function onFrame(conn, data, isBinary) {
202
+ if (isBinary) {
203
+ conn.ws.close(BRIDGE_CLOSE_CODES.unsupportedData, 'text frames only');
204
+ return;
205
+ }
206
+ let frame;
207
+ try {
208
+ frame = JSON.parse(data.toString());
209
+ }
210
+ catch {
211
+ frame = null;
212
+ }
213
+ if (!conn.hello) {
214
+ if (isObject(frame))
215
+ onHello(conn, frame);
216
+ else
217
+ conn.ws.close(BRIDGE_CLOSE_CODES.badHandshake, 'expected hello');
218
+ return;
219
+ }
220
+ if (!isObject(frame)) {
221
+ sendError(conn, null, { code: 'bad_message', message: 'frame must be a JSON object' });
222
+ return;
223
+ }
224
+ if (frame.type === 'acp') {
225
+ if (!conn.paired)
226
+ return sendError(conn, null, { code: 'not_paired', message: 'pair this page first' });
227
+ const { processId, line } = frame;
228
+ if (typeof processId !== 'string' || typeof line !== 'string' || /[\r\n]/.test(line)) {
229
+ return sendError(conn, null, { code: 'bad_message', message: 'acp frame needs processId and a single-line "line"' });
230
+ }
231
+ const guard = checkPageAcpLine(line, options.cwd);
232
+ if (!guard.ok) {
233
+ // 不转发:页面那个 JSON-RPC 请求不会得到 agent 的回应,页面按 data.rpcId 自己结束它
234
+ return sendError(conn, null, { code: 'acp_rejected', message: guard.reason, data: { processId, method: guard.method, rpcId: guard.rpcId } });
235
+ }
236
+ if (!processes.write(conn.id, processId, line))
237
+ sendError(conn, null, { code: 'process_not_found', message: 'no such process on this connection', data: { processId } });
238
+ return;
239
+ }
240
+ if (frame.type === 'call') {
241
+ const { id, method } = frame;
242
+ if (typeof id !== 'string' || id.length === 0 || id.length > MAX_CALL_ID) {
243
+ return sendError(conn, null, { code: 'bad_message', message: 'call needs a string id (≤ 64 chars)' });
244
+ }
245
+ if (!isMethod(method))
246
+ return sendError(conn, id, { code: 'unknown_method', message: `unknown method ${String(method)}` });
247
+ const params = isObject(frame.params) ? frame.params : {};
248
+ handleCall(conn, id, method, params).then((result) => send(conn, { type: 'result', id, result }), (error) => {
249
+ if (error instanceof BridgeFailure)
250
+ sendError(conn, id, { code: error.code, message: error.message, ...(error.data === undefined ? {} : { data: error.data }) });
251
+ else {
252
+ log(`Internal error in ${method}: ${error instanceof Error ? error.message : String(error)}`);
253
+ sendError(conn, id, { code: 'internal', message: error instanceof Error ? error.message : String(error) });
254
+ }
255
+ });
256
+ return;
257
+ }
258
+ sendError(conn, null, { code: 'bad_message', message: `unknown frame type ${String(frame.type)}` });
259
+ }
260
+ const http = createServer((_req, res) => {
261
+ res.writeHead(426, { 'Content-Type': 'text/plain', Connection: 'close' });
262
+ res.end('M2 local agent bridge: WebSocket only.\n');
263
+ });
264
+ const wss = new WebSocketServer({ noServer: true, maxPayload: MAX_FRAME_BYTES });
265
+ http.on('upgrade', (req, socket, head) => {
266
+ const port = http.address()?.port ?? options.port;
267
+ const decision = checkUpgrade({ origin: req.headers.origin, host: req.headers.host, remoteAddress: req.socket.remoteAddress, path: req.url }, port, options.allowedOrigins);
268
+ if (!decision.ok) {
269
+ rejectLog.note(decision.reason);
270
+ rejectUpgrade(socket, decision.status);
271
+ return;
272
+ }
273
+ wss.handleUpgrade(req, socket, head, (ws) => {
274
+ const conn = { id: randomUUID(), ws, origin: req.headers.origin, hello: false, paired: false, alive: true, timer: null };
275
+ connections.set(conn.id, conn);
276
+ conn.timer = setTimeout(() => ws.close(BRIDGE_CLOSE_CODES.timeout, 'hello timed out'), timing.helloTimeoutMs);
277
+ ws.on('pong', () => {
278
+ conn.alive = true;
279
+ });
280
+ ws.on('message', (data, isBinary) => {
281
+ // 任何同步异常都不能逃出监听器:未捕获异常会让 bridge 退出,exit 钩子会杀掉所有页面的 agent
282
+ try {
283
+ onFrame(conn, data, isBinary);
284
+ }
285
+ catch (error) {
286
+ const message = error instanceof Error ? error.message : String(error);
287
+ log(`Internal error while handling a message from ${conn.origin}: ${message}`);
288
+ if (conn.hello)
289
+ sendError(conn, null, { code: 'internal', message });
290
+ else
291
+ ws.close(BRIDGE_CLOSE_CODES.internalError, 'internal error');
292
+ }
293
+ });
294
+ ws.on('close', () => {
295
+ if (conn.timer)
296
+ clearTimeout(conn.timer);
297
+ connections.delete(conn.id);
298
+ processes.stopOwner(conn.id);
299
+ });
300
+ // ws 的错误(非法帧、超过 maxPayload 等)之后必然紧跟 'close',清理在 close 里做;这里只防止未处理的 error 事件让进程崩溃
301
+ ws.on('error', () => undefined);
302
+ });
303
+ });
304
+ // 心跳:浏览器自动回 pong;连续一个周期没回(休眠、崩溃、断网)就断开并清理它的进程
305
+ // 被拒连接的终端日志限频:任何网站都能反复连 127.0.0.1,逐条打印会把配对码和「X wants to pair」顶出视野(review r3 S5)。
306
+ // 同一原因在一个窗口内只打第一条,窗口结束时汇总被合并的次数。
307
+ const rejectWindowMs = options.timing?.rejectLogWindowMs ?? REJECT_LOG_WINDOW_MS;
308
+ const rejectLog = (() => {
309
+ const suppressed = new Map();
310
+ const flush = () => {
311
+ for (const [reason, count] of suppressed) {
312
+ if (count > 0)
313
+ log(`…and ${count} more connection${count === 1 ? '' : 's'} refused for the same reason (${reason}) in the last ${Math.round(rejectWindowMs / 1000)}s.`);
314
+ }
315
+ suppressed.clear();
316
+ };
317
+ const timer = setInterval(flush, rejectWindowMs);
318
+ timer.unref();
319
+ return {
320
+ note(reason) {
321
+ const count = suppressed.get(reason);
322
+ if (count === undefined) {
323
+ log(`Rejected a connection: ${reason}.`);
324
+ suppressed.set(reason, 0);
325
+ }
326
+ else
327
+ suppressed.set(reason, count + 1);
328
+ },
329
+ stop() {
330
+ clearInterval(timer);
331
+ flush();
332
+ },
333
+ };
334
+ })();
335
+ const heartbeat = setInterval(() => {
336
+ for (const conn of connections.values()) {
337
+ if (!conn.alive) {
338
+ conn.ws.terminate();
339
+ continue;
340
+ }
341
+ conn.alive = false;
342
+ conn.ws.ping();
343
+ }
344
+ }, timing.pingIntervalMs);
345
+ return new Promise((resolve, reject) => {
346
+ http.once('error', reject);
347
+ http.listen(options.port, '127.0.0.1', () => {
348
+ http.off('error', reject);
349
+ const address = http.address();
350
+ resolve({
351
+ port: address.port,
352
+ address: address.address,
353
+ processes,
354
+ broadcast(event, data) {
355
+ for (const conn of connections.values())
356
+ if (conn.paired)
357
+ send(conn, { type: 'event', event, data });
358
+ },
359
+ close() {
360
+ clearInterval(heartbeat);
361
+ rejectLog.stop();
362
+ for (const conn of connections.values())
363
+ conn.ws.close(BRIDGE_CLOSE_CODES.goingAway, 'bridge is shutting down');
364
+ processes.stopAll();
365
+ return new Promise((done) => {
366
+ wss.close();
367
+ http.close(() => done());
368
+ // 已建立的 WebSocket 不会因为 http.close 结束,给它们一点时间收到关闭帧后强制断开
369
+ setTimeout(() => {
370
+ for (const client of wss.clients)
371
+ client.terminate();
372
+ http.closeAllConnections();
373
+ }, 500).unref();
374
+ });
375
+ },
376
+ });
377
+ });
378
+ });
379
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 页面方法背后的 agent 操作:列表、准备适配器、预检、写 m2 配置、生成启动规格。server.ts 只做协议,这里只做业务。
3
+ *
4
+ * 前置条件统一在这里检查,错误以 BridgeFailure(带协议错误码)抛出:
5
+ * - 未安装 → agent_not_installed(安装命令与文档链接由前端提供,bridge 不代装);
6
+ * - 适配器类 agent 未就绪 → adapter_not_ready(页面要先调 agent/prepare)。
7
+ *
8
+ * m2/configure 以子进程运行本包自己的 cli.js `--client <id> --yes`:stdin 不是 TTY,cli.js 走现有的非交互分支
9
+ * (只写配置、不跑登录、退出码 0 = 全部写入或已存在)。这样一字不改 clients.ts,也不让它的 spawnSync 卡住转发。
10
+ * 改 cli.js 非交互分支的行为时要同步检查这里。
11
+ */
12
+ import { ADAPTERS, isAdapterAgent } from './adapters.js';
13
+ import { LAUNCH_KIND, NATIVE_ACP_ARGS } from './agents.js';
14
+ import { combinedOutput } from './exec.js';
15
+ import { precheckAgent, precheckM2 } from './precheck.js';
16
+ import { BRIDGE_AGENT_IDS } from './protocol.js';
17
+ export const CONFIGURE_TIMEOUT_MS = 120_000;
18
+ /**
19
+ * Claude 适配器(claude-agent-acp@0.84.0)声明 `node >=22`,实测在 Node 20 上可用(ACP PoC 与本 change 的端到端都是 Node 20);
20
+ * 更低的版本没有验证过。connect 本身的 engines 是 >=18,所以在这里明确拦下,而不是拉起后崩溃。
21
+ */
22
+ export const MIN_NODE_MAJOR_FOR_CLAUDE = 20;
23
+ const CONFIGURE_OUTPUT_LIMIT = 4096;
24
+ export class BridgeFailure extends Error {
25
+ code;
26
+ data;
27
+ constructor(code, message, data) {
28
+ super(message);
29
+ this.code = code;
30
+ this.data = data;
31
+ }
32
+ }
33
+ /** bridge 的 agent id → connect `--client` 的取值 */
34
+ const CLIENT_ID = {
35
+ claude: 'claude-code',
36
+ codex: 'codex',
37
+ cursor: 'cursor',
38
+ opencode: 'opencode',
39
+ qoder: 'qoder',
40
+ };
41
+ export function createAgentService(deps) {
42
+ const precheckDeps = { ...deps, env: deps.probeEnv };
43
+ async function installed(agent) {
44
+ const detected = await deps.detect(agent);
45
+ if (!detected.installed)
46
+ throw new BridgeFailure('agent_not_installed', `${agent} is not installed on this machine`);
47
+ return detected;
48
+ }
49
+ async function ready(agent) {
50
+ const detected = await installed(agent);
51
+ if (isAdapterAgent(agent) && !deps.adapters.isReady(agent)) {
52
+ throw new BridgeFailure('adapter_not_ready', `call agent/prepare for ${agent} first`, { adapter: deps.adapters.info(agent) });
53
+ }
54
+ return detected;
55
+ }
56
+ return {
57
+ async list() {
58
+ const detected = await Promise.all(BRIDGE_AGENT_IDS.map((id) => deps.detect(id)));
59
+ return detected.map((agent) => ({
60
+ id: agent.id,
61
+ installed: agent.installed,
62
+ path: agent.path,
63
+ version: agent.version,
64
+ launch: LAUNCH_KIND[agent.id],
65
+ adapter: isAdapterAgent(agent.id) ? deps.adapters.info(agent.id) : null,
66
+ }));
67
+ },
68
+ async prepare(agent) {
69
+ await installed(agent);
70
+ if (!isAdapterAgent(agent))
71
+ return null;
72
+ const major = Number(deps.nodeVersion.split('.')[0]);
73
+ if (agent === 'claude' && major < MIN_NODE_MAJOR_FOR_CLAUDE) {
74
+ throw new BridgeFailure('adapter_install_failed', `the Claude adapter needs Node.js ${MIN_NODE_MAJOR_FOR_CLAUDE} or newer; this bridge runs on Node.js ${deps.nodeVersion}`, { adapter: { ...deps.adapters.info(agent), state: 'failed', error: `Node.js ${deps.nodeVersion} is too old` } });
75
+ }
76
+ const info = await deps.adapters.prepare(agent);
77
+ if (info.state !== 'ready') {
78
+ throw new BridgeFailure('adapter_install_failed', `installing ${ADAPTERS[agent].package}@${ADAPTERS[agent].version} failed`, { adapter: info });
79
+ }
80
+ return info;
81
+ },
82
+ async precheck(agent) {
83
+ return precheckAgent(await ready(agent), precheckDeps);
84
+ },
85
+ async configure(agent) {
86
+ const detected = await ready(agent);
87
+ const result = await deps.exec(deps.nodePath, [deps.cliPath, '--client', CLIENT_ID[agent], '--yes'], {
88
+ env: deps.probeEnv,
89
+ cwd: deps.cwd,
90
+ timeoutMs: CONFIGURE_TIMEOUT_MS,
91
+ });
92
+ const output = combinedOutput(result);
93
+ return {
94
+ ok: result.code === 0,
95
+ output: output.length > CONFIGURE_OUTPUT_LIMIT ? output.slice(-CONFIGURE_OUTPUT_LIMIT) : output,
96
+ m2: await precheckM2(detected, precheckDeps),
97
+ };
98
+ },
99
+ async launch(agent) {
100
+ const detected = await ready(agent);
101
+ if (isAdapterAgent(agent))
102
+ return { command: deps.nodePath, args: [deps.adapters.entryPath(agent)] };
103
+ // 已安装的原生 agent 必有 path(detectAgent 的约定),这里只为收窄类型
104
+ if (!detected.path)
105
+ throw new BridgeFailure('internal', `no executable path for ${agent}`);
106
+ return { command: detected.path, args: NATIVE_ACP_ARGS[agent] };
107
+ },
108
+ };
109
+ }
package/dist/cli.js CHANGED
@@ -12,6 +12,7 @@ import { CLIENTS, detectClients, isClientId } from './clients.js';
12
12
  import { run } from './system.js';
13
13
  const CLIENT_IDS = Object.keys(CLIENTS).join(', ');
14
14
  const HELP = `Usage: npx @m2canvas/connect [--client <id>] [--yes] [--dry-run]
15
+ npx @m2canvas/connect bridge [--help]
15
16
 
16
17
  Adds M2 Canvas (${M2_MCP_URL}) to every agent client found on this machine
17
18
  (Claude Code, Codex, Cursor, OpenCode, Qoder), then signs each one in.
@@ -23,7 +24,9 @@ sign-in steps.
23
24
  --client <id> only set up this client (${CLIENT_IDS})
24
25
  -y, --yes skip the confirmation prompt
25
26
  --dry-run only show what would change
26
- -h, --help show this help`;
27
+ -h, --help show this help
28
+
29
+ bridge run the local agent bridge so M2 can use the agents on this machine`;
27
30
  async function confirm(question) {
28
31
  const rl = createInterface({ input: process.stdin, output: process.stdout });
29
32
  try {
@@ -151,7 +154,10 @@ function printExistingSignIn(plans, interactive) {
151
154
  console.log(` - ${plan.label}: ${describePending(plan.signIn(interactive, plan.existing))}`);
152
155
  }
153
156
  }
154
- main().then((code) => process.exit(code), (error) => {
157
+ // `bridge` 子命令(本机 ACP bridge)按需动态加载:默认流程(Copy to agent 起手式)不加载它的代码和依赖。
158
+ // 必须是子命令而不是另一个 bin —— npx 遇到多个 bin 只运行与包名同名的,加 bin 会让 `npx @m2canvas/connect` 失效。
159
+ const entry = process.argv[2] === 'bridge' ? import('./bridge/main.js').then((bridge) => bridge.runBridge(process.argv.slice(3))) : main();
160
+ entry.then((code) => process.exit(code), (error) => {
155
161
  console.error(error instanceof Error ? error.message : error);
156
162
  process.exit(1);
157
163
  });
package/package.json CHANGED
@@ -1,17 +1,21 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.3.1",
4
- "description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command.",
3
+ "version": "0.5.0",
4
+ "description": "Connect your local agent clients (Claude Code, Codex, Cursor, OpenCode, Qoder) to M2 Canvas over MCP in one command, and run the local bridge that lets M2 drive them over ACP.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "m2canvas-connect": "dist/cli.js"
8
8
  },
9
9
  "files": [
10
- "dist"
10
+ "dist",
11
+ "adapters"
11
12
  ],
12
13
  "engines": {
13
14
  "node": ">=18"
14
15
  },
16
+ "dependencies": {
17
+ "ws": "8.22.0"
18
+ },
15
19
  "scripts": {
16
20
  "build": "tsc -p tsconfig.json",
17
21
  "prepublishOnly": "npm run build"