@m2canvas/connect 0.4.0 → 0.6.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,9 @@
1
+ export class BridgeFailure extends Error {
2
+ code;
3
+ data;
4
+ constructor(code, message, data) {
5
+ super(message);
6
+ this.code = code;
7
+ this.data = data;
8
+ }
9
+ }
@@ -9,6 +9,9 @@
9
9
  * 超时必须真的封顶:命令可能再拉起子进程(`claude mcp list` 会启动用户配置的 stdio MCP 做健康检查,npm ci 有自己的子进程),
10
10
  * 孙进程继承了输出管道时,'close' 要等它们全部退出才触发。所以 POSIX 下每条命令是独立进程组,超时对整组 SIGKILL 并**立即**返回,
11
11
  * 不再等 'close'。bridge 退出时 killActiveExecs 清掉仍在运行的命令(main.ts 的退出路径调用)。
12
+ *
13
+ * startCommand 是可从外部结束的版本(授权命令:页面取消、连接关闭、10 分钟超时,见 authorize.ts);execCommand 建在它上面,
14
+ * 两者共用同一个 active 集合,所以 bridge 退出时授权命令也一起被清理(design D7)。
12
15
  */
13
16
  import { spawn } from 'node:child_process';
14
17
  const DEFAULT_MAX_OUTPUT = 1024 * 1024;
@@ -34,22 +37,56 @@ export function killActiveExecs() {
34
37
  killTree(child);
35
38
  active.clear();
36
39
  }
37
- export const execCommand = (command, args, options) => new Promise((resolve) => {
40
+ function signalTree(child, signal) {
41
+ if (child.pid === undefined)
42
+ return;
43
+ try {
44
+ if (GROUPS)
45
+ process.kill(-child.pid, signal);
46
+ else
47
+ child.kill(signal);
48
+ }
49
+ catch {
50
+ // ESRCH:已经退出,'close' 会照常结束这条命令
51
+ }
52
+ }
53
+ export function startCommand(command, args, options) {
38
54
  const max = options.maxOutput ?? DEFAULT_MAX_OUTPUT;
39
55
  let stdout = '';
40
56
  let stderr = '';
41
57
  let settled = false;
42
- let timer;
58
+ let graceTimer;
43
59
  let child;
44
- const finish = (result, timedOut = false) => {
60
+ let resolve = () => undefined;
61
+ const result = new Promise((done) => {
62
+ resolve = done;
63
+ });
64
+ const finish = (outcome, timedOut = false) => {
45
65
  if (settled)
46
66
  return;
47
67
  settled = true;
48
- if (timer)
49
- clearTimeout(timer);
68
+ if (graceTimer)
69
+ clearTimeout(graceTimer);
50
70
  if (child)
51
71
  active.delete(child);
52
- resolve({ ...result, stdout, stderr, timedOut });
72
+ resolve({ ...outcome, stdout, stderr, timedOut });
73
+ };
74
+ const kill = (error, timedOut = false) => {
75
+ if (settled)
76
+ return;
77
+ if (child)
78
+ killTree(child);
79
+ finish({ code: null, signal: null, error }, timedOut);
80
+ };
81
+ const handle = {
82
+ result,
83
+ kill,
84
+ terminate(graceMs, error, timedOut = false) {
85
+ if (settled || !child || graceTimer)
86
+ return;
87
+ signalTree(child, 'SIGTERM');
88
+ graceTimer = setTimeout(() => kill(error, timedOut), graceMs);
89
+ },
53
90
  };
54
91
  try {
55
92
  child = spawn(command, args, {
@@ -64,25 +101,29 @@ export const execCommand = (command, args, options) => new Promise((resolve) =>
64
101
  catch (error) {
65
102
  // 同步抛出只发生在参数非法时;按启动失败处理
66
103
  finish({ code: null, signal: null, error: error instanceof Error ? error.message : String(error) });
67
- return;
104
+ return handle;
68
105
  }
69
106
  const running = child;
70
107
  active.add(running);
71
- timer = setTimeout(() => {
72
- killTree(running);
73
- finish({ code: null, signal: null, error: `timed out after ${options.timeoutMs}ms` }, true);
74
- }, options.timeoutMs);
75
108
  running.stdout?.setEncoding('utf8');
76
109
  running.stderr?.setEncoding('utf8');
77
110
  running.stdout?.on('data', (chunk) => {
78
111
  stdout = (stdout + chunk).slice(-max);
112
+ options.onOutput?.(chunk);
79
113
  });
80
114
  running.stderr?.on('data', (chunk) => {
81
115
  stderr = (stderr + chunk).slice(-max);
116
+ options.onOutput?.(chunk);
82
117
  });
83
118
  running.on('error', (error) => finish({ code: null, signal: null, error: error.message }));
84
119
  running.on('close', (code, signal) => finish({ code, signal, error: null }));
85
- });
120
+ return handle;
121
+ }
122
+ export const execCommand = (command, args, options) => {
123
+ const running = startCommand(command, args, options);
124
+ const timer = setTimeout(() => running.kill(`timed out after ${options.timeoutMs}ms`, true), options.timeoutMs);
125
+ return running.result.finally(() => clearTimeout(timer));
126
+ };
86
127
  /** stdout 与 stderr 合并(有些 CLI 把状态写在 stderr,例如 `codex login status`) */
87
128
  export function combinedOutput(result) {
88
129
  const parts = [result.stdout, result.stderr, result.error ?? ''].filter((part) => part.length > 0);
@@ -0,0 +1,239 @@
1
+ /**
2
+ * 端口被占时识别并接管正在运行的 bridge(change acp-terminal-free-auth,design D20,`connect/local-bridge` 契约 13)。
3
+ *
4
+ * 场景:页面已经连上一个 bridge、只是没配对,用户却找不到那个 bridge 的终端(在别的窗口、被别的工具在后台拉起),
5
+ * 拿不到配对码;新运行的命令又撞端口。这里让新命令认出占端口的是不是 M2 bridge,给出 pid 与终端,或者在 `--replace` 时接管。
6
+ *
7
+ * 维护约束:
8
+ * - 状态文件只放 pid / port / startedAt,**不放配对码、token、承诺**:契约 2 要求码只出现在终端,
9
+ * 能读 `~/.m2canvas`、又能连本机端口的进程(例如沙箱里的 agent)拿到码就能自己配对。
10
+ * - 「认定」要同时满足:文件端口相符、pid 存活、`ps` 算出的启动时刻与 startedAt 相差 ≤ START_TOLERANCE_MS。
11
+ * 崩溃留下的旧文件、被复用的 pid 都过不了这一关;认不出来就什么都不停。
12
+ * - 停止只发 SIGTERM(第一次走旧 bridge 的正常收尾,第二次走它「连按两次」的立即清理),**不发 SIGKILL**:
13
+ * SIGKILL 跳过清理,agent 都在各自的进程组里,会变成孤儿(契约 5)。
14
+ * - Windows 没有 `ps`,一律不认定(D1 / CO-1)。
15
+ * - 认不出来时只提示:用 lsof 查到占端口的 pid 与命令行给用户看,让用户自己决定停不停;这里从不停认不出来的进程。
16
+ */
17
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18
+ import { dirname, join } from 'node:path';
19
+ /** startedAt 是进程启动时刻(不是写文件时刻),与 ps 的 etime 只差秒级取整;留 3 秒余量 */
20
+ export const START_TOLERANCE_MS = 3_000;
21
+ /** 第一次 SIGTERM 后等多久:旧 bridge 正常收尾先关连接、停 agent,再等 agent 退出(宽限 3 秒),还要留出启动横幅里检测命令的余量 */
22
+ export const STOP_GRACEFUL_WAIT_MS = 8_000;
23
+ /** 第二次 SIGTERM(立即清理)后再等多久 */
24
+ export const STOP_IMMEDIATE_WAIT_MS = 3_000;
25
+ const STOP_POLL_MS = 100;
26
+ const PS_PATH = '/bin/ps';
27
+ const PS_TIMEOUT_MS = 5_000;
28
+ const LSOF_PATHS = ['/usr/sbin/lsof', '/usr/bin/lsof'];
29
+ const COMMAND_DISPLAY_CHARS = 160;
30
+ export function instanceFilePath(m2Dir, port) {
31
+ return join(m2Dir, `bridge-${port}.json`);
32
+ }
33
+ /** 本进程的启动时刻 */
34
+ export function processStartedAt() {
35
+ return Math.round(Date.now() - process.uptime() * 1000);
36
+ }
37
+ /** 只写这四个字段:调用方多带的任何字段都不落盘(契约 2:文件里不能有配对码、token、承诺) */
38
+ export function writeInstanceRecord(path, record) {
39
+ const { pid, port, startedAt } = record;
40
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
41
+ chmodSync(dirname(path), 0o700);
42
+ writeFileSync(path, `${JSON.stringify({ version: 1, pid, port, startedAt })}\n`, { mode: 0o600 });
43
+ chmodSync(path, 0o600);
44
+ }
45
+ function isPositiveInteger(value) {
46
+ return typeof value === 'number' && Number.isInteger(value) && value > 0;
47
+ }
48
+ export function readInstanceRecord(path) {
49
+ let parsed;
50
+ try {
51
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
57
+ return null;
58
+ const record = parsed;
59
+ if (record.version !== 1 || !isPositiveInteger(record.pid) || !isPositiveInteger(record.port) || !isPositiveInteger(record.startedAt))
60
+ return null;
61
+ return { version: 1, pid: record.pid, port: record.port, startedAt: record.startedAt };
62
+ }
63
+ /** 只删属于这个 pid 的文件:接管它的新 bridge 可能已经写了自己的 */
64
+ export function removeInstanceRecord(path, pid) {
65
+ if (readInstanceRecord(path)?.pid !== pid)
66
+ return;
67
+ rmSync(path, { force: true });
68
+ }
69
+ /** `ps -o etime=` 的格式 `[[dd-]hh:]mm:ss` → 秒 */
70
+ export function parseEtime(value) {
71
+ const match = /^(?:(?:(\d+)-)?(\d{1,2}):)?(\d{1,2}):(\d{2})$/.exec(value.trim());
72
+ if (!match)
73
+ return null;
74
+ const [, days, hours, minutes, seconds] = match;
75
+ const [d, h, m, s] = [days, hours, minutes, seconds].map((part) => (part === undefined ? 0 : Number(part)));
76
+ if (m === undefined || s === undefined || s > 59 || m > 59)
77
+ return null;
78
+ return (d ?? 0) * 86_400 + (h ?? 0) * 3_600 + m * 60 + s;
79
+ }
80
+ export function createPsProbe(exec, env, now = Date.now) {
81
+ return {
82
+ async inspect(pid) {
83
+ const result = await exec(PS_PATH, ['-o', 'etime=,tty=', '-p', String(pid)], {
84
+ env: { ...env, LC_ALL: 'C' },
85
+ timeoutMs: PS_TIMEOUT_MS,
86
+ maxOutput: 1024,
87
+ });
88
+ if (result.code !== 0)
89
+ return null;
90
+ const [etime, tty] = result.stdout.trim().split(/\s+/);
91
+ const seconds = etime === undefined ? null : parseEtime(etime);
92
+ if (seconds === null)
93
+ return null;
94
+ const terminal = tty && !/^[?-]+$/.test(tty) ? tty : null;
95
+ return { startedAt: now() - seconds * 1000, terminal };
96
+ },
97
+ };
98
+ }
99
+ /** 用 lsof 查占端口的进程(只读)。没有 lsof(多数 Linux 默认没装)、没有监听者、输出认不出时 null */
100
+ export function createLsofProbe(exec, env, exists = existsSync) {
101
+ return {
102
+ async find(port) {
103
+ const lsof = LSOF_PATHS.find((path) => exists(path));
104
+ if (!lsof)
105
+ return null;
106
+ const options = { env: { ...env, LC_ALL: 'C' }, timeoutMs: PS_TIMEOUT_MS, maxOutput: 4096 };
107
+ const listed = await exec(lsof, ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'], options);
108
+ const pid = Number(/^p(\d+)$/m.exec(listed.stdout)?.[1]);
109
+ if (listed.code !== 0 || !Number.isInteger(pid) || pid <= 0)
110
+ return null;
111
+ const ps = await exec(PS_PATH, ['-o', 'command=', '-p', String(pid)], options);
112
+ const full = ps.code === 0 ? ps.stdout.trim() : '';
113
+ const command = full.length > COMMAND_DISPLAY_CHARS ? `${full.slice(0, COMMAND_DISPLAY_CHARS - 1)}…` : full;
114
+ return { pid, command: command || null };
115
+ },
116
+ };
117
+ }
118
+ export async function identifyRunningBridge(path, port, probe, platform) {
119
+ if (platform === 'win32')
120
+ return null;
121
+ const record = readInstanceRecord(path);
122
+ if (!record || record.port !== port)
123
+ return null;
124
+ const info = await probe.inspect(record.pid);
125
+ if (!info || Math.abs(info.startedAt - record.startedAt) > START_TOLERANCE_MS)
126
+ return null;
127
+ return { pid: record.pid, port: record.port, startedAt: record.startedAt, terminal: info.terminal };
128
+ }
129
+ /**
130
+ * 本机进程的实现。存活要两步判断:`kill(pid, 0)` 报 ESRCH 就是不在了;否则(成功或 EPERM)再看 `ps` 的状态,
131
+ * 僵尸(Z,已退出、父进程还没回收)按已退出处理 —— 端口已经释放,而 `kill(pid, 0)` 对僵尸照样成功。
132
+ * 被别的工具在后台拉起、父进程不及时回收的 bridge 会停在僵尸状态(实测:测试脚本拉起的 bridge 退出后一直是 Z)。
133
+ */
134
+ export function createStopDeps(exec, env, kill = process.kill.bind(process)) {
135
+ return {
136
+ signal: (pid, signal) => {
137
+ kill(pid, signal);
138
+ },
139
+ async isAlive(pid) {
140
+ try {
141
+ kill(pid, 0);
142
+ }
143
+ catch (error) {
144
+ if (error.code === 'ESRCH')
145
+ return false;
146
+ }
147
+ const result = await exec(PS_PATH, ['-o', 'stat=', '-p', String(pid)], { env: { ...env, LC_ALL: 'C' }, timeoutMs: PS_TIMEOUT_MS, maxOutput: 256 });
148
+ if (result.code !== 0)
149
+ return false;
150
+ return !result.stdout.trim().startsWith('Z');
151
+ },
152
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
153
+ now: Date.now,
154
+ };
155
+ }
156
+ async function waitForExit(pid, deps, timeoutMs) {
157
+ const deadline = deps.now() + timeoutMs;
158
+ while (await deps.isAlive(pid)) {
159
+ if (deps.now() >= deadline)
160
+ return false;
161
+ await deps.sleep(STOP_POLL_MS);
162
+ }
163
+ return true;
164
+ }
165
+ function sendTerm(pid, deps) {
166
+ try {
167
+ deps.signal(pid, 'SIGTERM');
168
+ }
169
+ catch {
170
+ // 不在这里判断:已经退出(ESRCH)会被随后的存活检查认成「已停止」,发不了信号(EPERM)会被认成「没停掉」
171
+ }
172
+ }
173
+ /** 停掉认定的 bridge:SIGTERM → 等正常收尾 → 再一次 SIGTERM(立即清理)→ 再等。从不 SIGKILL。返回是否已退出 */
174
+ export async function stopRunningBridge(pid, deps) {
175
+ sendTerm(pid, deps);
176
+ if (await waitForExit(pid, deps, STOP_GRACEFUL_WAIT_MS))
177
+ return true;
178
+ sendTerm(pid, deps);
179
+ return waitForExit(pid, deps, STOP_IMMEDIATE_WAIT_MS);
180
+ }
181
+ function isAddressInUse(error) {
182
+ return error?.code === 'EADDRINUSE';
183
+ }
184
+ function describe(bridge, clock) {
185
+ const where = bridge.terminal ? `in terminal ${bridge.terminal}` : 'in the background';
186
+ return `pid ${bridge.pid}, started at ${clock(bridge.startedAt)} ${where}`;
187
+ }
188
+ /** 认不出来时的提示:只给可操作的查找 / 停止方法,不停任何进程 */
189
+ function unrecognizedMessage(port, listener, platform, replace) {
190
+ const note = replace ? ' --replace only stops a bridge it can recognize (0.6.0 or later, not on Windows), so nothing was stopped.' : '';
191
+ if (listener) {
192
+ return (`Port ${port} is already in use by pid ${listener.pid}${listener.command ? ` (${listener.command})` : ''}.\n` +
193
+ `If that's an older M2 bridge, stop it with "kill ${listener.pid}" (or press Ctrl+C in its terminal), then run this command again. ` +
194
+ `Otherwise start this one with --port <n>.${note}`);
195
+ }
196
+ const find = platform === 'win32'
197
+ ? `find it with "netstat -ano | findstr :${port}" and stop it with "taskkill /PID <pid>"`
198
+ : `find it with "lsof -nP -iTCP:${port} -sTCP:LISTEN" and stop it with "kill <pid>"`;
199
+ return (`Port ${port} is already in use. If another bridge is running, ${find} (or press Ctrl+C in its terminal), then run this command again. ` +
200
+ `Otherwise start this one with --port <n>.${note}`);
201
+ }
202
+ /**
203
+ * 监听;撞端口时认定占用者并按 `--replace` 处理。返回 null 表示已打印原因、调用方以退出码 1 结束。
204
+ * 不是 EADDRINUSE 的错误照常抛出。
205
+ */
206
+ export async function listenWithReplace(listen, ctx) {
207
+ try {
208
+ return await listen();
209
+ }
210
+ catch (error) {
211
+ if (!isAddressInUse(error))
212
+ throw error;
213
+ }
214
+ const running = await ctx.identify();
215
+ if (!running) {
216
+ ctx.error(unrecognizedMessage(ctx.port, await ctx.findListener(), ctx.platform, ctx.replace));
217
+ return null;
218
+ }
219
+ if (!ctx.replace) {
220
+ const stopIt = running.terminal ? `press Ctrl+C in that terminal or run "kill ${running.pid}"` : `stop it with "kill ${running.pid}"`;
221
+ ctx.error(`Port ${ctx.port} is already in use by another M2 bridge (${describe(running, ctx.clock)}).\n` +
222
+ `To start this one instead, ${stopIt}, then run this command again. Or run it again with --replace to stop that bridge first.`);
223
+ return null;
224
+ }
225
+ ctx.log(`Stopping the M2 bridge already running on port ${ctx.port} (${describe(running, ctx.clock)})…`);
226
+ if (!(await ctx.stop(running.pid))) {
227
+ ctx.error(`The M2 bridge on port ${ctx.port} (pid ${running.pid}) didn't stop. Stop it with "kill ${running.pid}" (or press Ctrl+C in its terminal), then run this command again.`);
228
+ return null;
229
+ }
230
+ try {
231
+ return await listen();
232
+ }
233
+ catch (error) {
234
+ if (!isAddressInUse(error))
235
+ throw error;
236
+ ctx.error(`Port ${ctx.port} is still in use after stopping the old bridge. Wait a few seconds and run this command again.`);
237
+ return null;
238
+ }
239
+ }
@@ -9,13 +9,15 @@
9
9
  import { existsSync, readFileSync, statSync } from 'node:fs';
10
10
  import { join, resolve } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { home, which } from '../system.js';
12
+ import { home, isExecutable, which } from '../system.js';
13
13
  import { AdapterManager, ADAPTERS } from './adapters.js';
14
14
  import { detectAgent } from './agents.js';
15
+ import { EXPECT_PATH } from './authorize.js';
15
16
  import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
16
17
  import { execCommand, killActiveExecs } from './exec.js';
18
+ import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
17
19
  import { installShutdownSignals } from './lifecycle.js';
18
- import { PairingManager, createFilePairingStore } from './pairing.js';
20
+ import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
19
21
  import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
20
22
  import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
21
23
  import { startBridgeServer } from './server.js';
@@ -29,26 +31,35 @@ const LABELS = {
29
31
  opencode: 'OpenCode',
30
32
  qoder: 'Qoder',
31
33
  };
32
- const HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev]
34
+ export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>] [--replace]
33
35
  npx @m2canvas/connect bridge --unpair
34
36
 
35
37
  Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
36
38
  installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
37
39
 
38
40
  It only listens on 127.0.0.1 and only accepts the M2 web app (${PROD_ORIGINS.join(', ')}).
39
- The first time, M2 asks for the pairing code shown here. A paired page can make
40
- these agents read and change files and run commands as you, so only pair M2.
41
+ The command M2 gives you pairs that page automatically; other pages ask for the
42
+ pairing code shown here. A paired page can make these agents read and change
43
+ files and run commands as you, so only run commands you copied from M2.
41
44
  Agents run in the directory you start the bridge from (or --cwd), with your own logins.
45
+ On macOS a paired page can also start an agent's own sign-in; you finish it in
46
+ your browser.
42
47
 
43
- --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
44
- --cwd <dir> working directory for agent sessions (default: current directory)
45
- --verbose also print agent stderr and connection details
46
- --dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
47
- any app served on that port could then ask to pair
48
- --unpair forget all paired pages and exit
49
- -h, --help show this help`;
50
- function parseArgs(argv) {
51
- const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false };
48
+ --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
49
+ --cwd <dir> working directory for agent sessions (default: current directory)
50
+ --verbose also print agent stderr and connection details
51
+ --dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
52
+ any app served on that port could then ask to pair
53
+ --pair <value> pair the M2 page that gave you this command (M2 adds it for you;
54
+ valid for 10 minutes, once)
55
+ --replace if an M2 bridge (0.6.0 or later) is already running on the port,
56
+ stop it first (its agent sessions end) and start this one instead;
57
+ not on Windows
58
+ --unpair forget all paired pages and exit
59
+ -h, --help show this help`;
60
+ /** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
61
+ export function parseBridgeArgs(argv) {
62
+ const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false };
52
63
  for (let i = 0; i < argv.length; i++) {
53
64
  const arg = argv[i];
54
65
  if (arg === '-h' || arg === '--help')
@@ -59,6 +70,16 @@ function parseArgs(argv) {
59
70
  args.unpair = true;
60
71
  else if (arg === '--dev')
61
72
  args.dev = true;
73
+ else if (arg === '--replace')
74
+ args.replace = true;
75
+ else if (arg === '--pair') {
76
+ const value = argv[++i];
77
+ if (value === undefined)
78
+ return '--pair needs a value';
79
+ if (!isPairCommitment(value))
80
+ return `invalid --pair value ${value}; copy the start command from M2 again`;
81
+ args.pair = value;
82
+ }
62
83
  else if (arg === '--port' || arg === '--cwd') {
63
84
  const value = argv[++i];
64
85
  if (value === undefined)
@@ -96,6 +117,15 @@ function printCode(info) {
96
117
  else
97
118
  console.log(`\n→ Too many wrong pairing codes${info.origin ? ` from ${info.origin}` : ''}. New ${code}\n`);
98
119
  }
120
+ function clock(at) {
121
+ return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
122
+ }
123
+ /** 启动命令里的配对已被用掉,同一个密钥又出现:同一个 M2 标签页被复制 / 连接途中刷新,或者别的程序抢先配对了(review r1 S2) */
124
+ function printReused(info) {
125
+ console.log(`\n→ Warning: the pairing in your start command was already used (by ${info.firstOrigin} at ${clock(info.usedAt)}), ` +
126
+ `and ${info.origin} just presented it again. If you didn't duplicate or reload that M2 tab while it was connecting, another program on this machine ` +
127
+ `may have paired with this bridge: press Ctrl+C, run "npx @m2canvas/connect bridge --unpair", then start the bridge again.\n`);
128
+ }
99
129
  function printLocked(origin) {
100
130
  const from = origin ? ` (the last ones came from ${origin})` : '';
101
131
  console.log(`\n→ Too many wrong pairing codes${from}. New pages can't pair until you restart the bridge (Ctrl+C, then run it again); ` +
@@ -112,13 +142,13 @@ function describeAdapter(agent, info) {
112
142
  return null;
113
143
  }
114
144
  export async function runBridge(argv) {
115
- const parsed = parseArgs(argv);
145
+ const parsed = parseBridgeArgs(argv);
116
146
  if (typeof parsed === 'string') {
117
- console.error(`${parsed}\n\n${HELP}`);
147
+ console.error(`${parsed}\n\n${BRIDGE_HELP}`);
118
148
  return 2;
119
149
  }
120
150
  if (parsed.help) {
121
- console.log(HELP);
151
+ console.log(BRIDGE_HELP);
122
152
  return 0;
123
153
  }
124
154
  const m2Dir = join(home, '.m2canvas');
@@ -142,7 +172,12 @@ export async function runBridge(argv) {
142
172
  onCodeIssued: printCode,
143
173
  onLocked: printLocked,
144
174
  onProblem: (message) => console.warn(message),
175
+ onCommitmentReused: printReused,
145
176
  });
177
+ // 承诺必须在开始监听之前挂上:等待中的页面每 2 秒探测一次,监听一开始就可能连上;晚挂一步它就撞上 no_commitment、落到输码
178
+ const pairUntil = parsed.pair ? pairing.armCommitment(parsed.pair) : null;
179
+ // 进度更新(D12)也经 onState 广播,终端只在状态变化时打一行
180
+ const loggedState = new Map();
146
181
  const adapters = new AdapterManager({
147
182
  sourceRoot: join(PACKAGE_ROOT, 'adapters'),
148
183
  installRoot: join(m2Dir, 'acp-adapters'),
@@ -154,9 +189,12 @@ export async function runBridge(argv) {
154
189
  shell: process.platform === 'win32',
155
190
  }),
156
191
  onState: (agent, info) => {
157
- const line = describeAdapter(agent, info);
158
- if (line)
159
- log(line);
192
+ if (loggedState.get(agent) !== info.state) {
193
+ loggedState.set(agent, info.state);
194
+ const line = describeAdapter(agent, info);
195
+ if (line)
196
+ log(line);
197
+ }
160
198
  server?.broadcast('adapter/state', { agent, adapter: info });
161
199
  },
162
200
  });
@@ -171,34 +209,55 @@ export async function runBridge(argv) {
171
209
  nodePath: process.execPath,
172
210
  nodeVersion: process.versions.node,
173
211
  cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)),
212
+ platform: process.platform,
213
+ expectAvailable: () => isExecutable(EXPECT_PATH),
174
214
  });
175
215
  const version = packageVersion();
176
216
  const origins = allowedOrigins(parsed.dev);
217
+ // 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
218
+ const instancePath = instanceFilePath(m2Dir, parsed.port);
219
+ server = await listenWithReplace(() => startBridgeServer({
220
+ port: parsed.port,
221
+ cwd: parsed.cwd,
222
+ bridge: { name: '@m2canvas/connect', version, platform: process.platform, arch: process.arch, node: process.versions.node },
223
+ pairing,
224
+ service,
225
+ agentEnv: buildAgentEnv(),
226
+ allowedOrigins: origins,
227
+ log,
228
+ verbose: parsed.verbose,
229
+ }), {
230
+ port: parsed.port,
231
+ replace: parsed.replace,
232
+ identify: () => identifyRunningBridge(instancePath, parsed.port, createPsProbe(execCommand, probeEnv), process.platform),
233
+ findListener: () => createLsofProbe(execCommand, probeEnv).find(parsed.port),
234
+ platform: process.platform,
235
+ stop: (pid) => stopRunningBridge(pid, createStopDeps(execCommand, probeEnv)),
236
+ log,
237
+ error: (message) => console.error(message),
238
+ clock,
239
+ });
240
+ if (!server)
241
+ return 1;
242
+ const running = server;
177
243
  try {
178
- server = await startBridgeServer({
179
- port: parsed.port,
180
- cwd: parsed.cwd,
181
- bridge: { name: '@m2canvas/connect', version, platform: process.platform, arch: process.arch, node: process.versions.node },
182
- pairing,
183
- service,
184
- agentEnv: buildAgentEnv(),
185
- allowedOrigins: origins,
186
- log,
187
- verbose: parsed.verbose,
188
- });
244
+ // startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
245
+ writeInstanceRecord(instancePath, { version: 1, pid: process.pid, port: parsed.port, startedAt: processStartedAt() });
189
246
  }
190
247
  catch (error) {
191
- const code = error.code;
192
- if (code === 'EADDRINUSE') {
193
- console.error(`Port ${parsed.port} is already in use. Another bridge may already be running; stop it, or start this one with --port <n>.`);
194
- return 1;
195
- }
196
- throw error;
248
+ console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
197
249
  }
198
- const running = server;
199
250
  // 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
200
251
  // 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
201
252
  process.on('exit', () => {
253
+ // 只删属于本进程的状态文件;删不掉只会让之后的认定落到「pid 不在」,不影响退出
254
+ try {
255
+ removeInstanceRecord(instancePath, process.pid);
256
+ }
257
+ catch {
258
+ // 见上
259
+ }
260
+ running.authorizations.killAllNow();
202
261
  running.processes.killAllNow();
203
262
  killActiveExecs();
204
263
  });
@@ -213,7 +272,9 @@ export async function runBridge(argv) {
213
272
  });
214
273
  const stop = async () => {
215
274
  log('Stopping the bridge and its agents…');
216
- // 一次性命令(npm ci、预检、检测)没有「正常收尾」可言,先同步杀掉,不等 close
275
+ // 一次性命令(npm ci、预检、检测、授权)没有「正常收尾」可言,先同步杀掉,不等 close。
276
+ // 授权先标记取消并删临时目录,被杀的授权命令不会被当成失败再去重跑预检
277
+ running.authorizations.killAllNow();
217
278
  killActiveExecs();
218
279
  await running.close();
219
280
  const deadline = Date.now() + KILL_GRACE_MS;
@@ -226,6 +287,7 @@ export async function runBridge(argv) {
226
287
  graceful: () => void stop(),
227
288
  // 收尾期间又来一个信号(连按 Ctrl+C):不再等宽限期,立刻清理退出(exit 钩子也会再清一遍)
228
289
  immediate: () => {
290
+ running.authorizations.killAllNow();
229
291
  running.processes.killAllNow();
230
292
  killActiveExecs();
231
293
  process.exit(130);
@@ -237,7 +299,12 @@ export async function runBridge(argv) {
237
299
  console.log(` Accepts pages from: ${origins.join(', ')}`);
238
300
  if (parsed.dev)
239
301
  console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
240
- pairing.issueCode('startup');
302
+ // 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
303
+ // 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
304
+ if (pairUntil !== null)
305
+ console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
306
+ else
307
+ pairing.issueCode('startup');
241
308
  const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
242
309
  console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
243
310
  console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');