@m2canvas/connect 0.5.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,11 +9,13 @@
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
20
  import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
19
21
  import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
@@ -29,7 +31,7 @@ const LABELS = {
29
31
  opencode: 'OpenCode',
30
32
  qoder: 'Qoder',
31
33
  };
32
- export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>]
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
@@ -40,6 +42,8 @@ The command M2 gives you pairs that page automatically; other pages ask for the
40
42
  pairing code shown here. A paired page can make these agents read and change
41
43
  files and run commands as you, so only run commands you copied from M2.
42
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.
43
47
 
44
48
  --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
45
49
  --cwd <dir> working directory for agent sessions (default: current directory)
@@ -48,11 +52,14 @@ Agents run in the directory you start the bridge from (or --cwd), with your own
48
52
  any app served on that port could then ask to pair
49
53
  --pair <value> pair the M2 page that gave you this command (M2 adds it for you;
50
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
51
58
  --unpair forget all paired pages and exit
52
59
  -h, --help show this help`;
53
60
  /** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
54
61
  export function parseBridgeArgs(argv) {
55
- const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null };
62
+ const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false };
56
63
  for (let i = 0; i < argv.length; i++) {
57
64
  const arg = argv[i];
58
65
  if (arg === '-h' || arg === '--help')
@@ -63,6 +70,8 @@ export function parseBridgeArgs(argv) {
63
70
  args.unpair = true;
64
71
  else if (arg === '--dev')
65
72
  args.dev = true;
73
+ else if (arg === '--replace')
74
+ args.replace = true;
66
75
  else if (arg === '--pair') {
67
76
  const value = argv[++i];
68
77
  if (value === undefined)
@@ -167,6 +176,8 @@ export async function runBridge(argv) {
167
176
  });
168
177
  // 承诺必须在开始监听之前挂上:等待中的页面每 2 秒探测一次,监听一开始就可能连上;晚挂一步它就撞上 no_commitment、落到输码
169
178
  const pairUntil = parsed.pair ? pairing.armCommitment(parsed.pair) : null;
179
+ // 进度更新(D12)也经 onState 广播,终端只在状态变化时打一行
180
+ const loggedState = new Map();
170
181
  const adapters = new AdapterManager({
171
182
  sourceRoot: join(PACKAGE_ROOT, 'adapters'),
172
183
  installRoot: join(m2Dir, 'acp-adapters'),
@@ -178,9 +189,12 @@ export async function runBridge(argv) {
178
189
  shell: process.platform === 'win32',
179
190
  }),
180
191
  onState: (agent, info) => {
181
- const line = describeAdapter(agent, info);
182
- if (line)
183
- 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
+ }
184
198
  server?.broadcast('adapter/state', { agent, adapter: info });
185
199
  },
186
200
  });
@@ -195,34 +209,55 @@ export async function runBridge(argv) {
195
209
  nodePath: process.execPath,
196
210
  nodeVersion: process.versions.node,
197
211
  cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)),
212
+ platform: process.platform,
213
+ expectAvailable: () => isExecutable(EXPECT_PATH),
198
214
  });
199
215
  const version = packageVersion();
200
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;
201
243
  try {
202
- server = await startBridgeServer({
203
- port: parsed.port,
204
- cwd: parsed.cwd,
205
- bridge: { name: '@m2canvas/connect', version, platform: process.platform, arch: process.arch, node: process.versions.node },
206
- pairing,
207
- service,
208
- agentEnv: buildAgentEnv(),
209
- allowedOrigins: origins,
210
- log,
211
- verbose: parsed.verbose,
212
- });
244
+ // startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
245
+ writeInstanceRecord(instancePath, { version: 1, pid: process.pid, port: parsed.port, startedAt: processStartedAt() });
213
246
  }
214
247
  catch (error) {
215
- const code = error.code;
216
- if (code === 'EADDRINUSE') {
217
- console.error(`Port ${parsed.port} is already in use. Another bridge may already be running; stop it, or start this one with --port <n>.`);
218
- return 1;
219
- }
220
- throw error;
248
+ console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
221
249
  }
222
- const running = server;
223
250
  // 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
224
251
  // 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
225
252
  process.on('exit', () => {
253
+ // 只删属于本进程的状态文件;删不掉只会让之后的认定落到「pid 不在」,不影响退出
254
+ try {
255
+ removeInstanceRecord(instancePath, process.pid);
256
+ }
257
+ catch {
258
+ // 见上
259
+ }
260
+ running.authorizations.killAllNow();
226
261
  running.processes.killAllNow();
227
262
  killActiveExecs();
228
263
  });
@@ -237,7 +272,9 @@ export async function runBridge(argv) {
237
272
  });
238
273
  const stop = async () => {
239
274
  log('Stopping the bridge and its agents…');
240
- // 一次性命令(npm ci、预检、检测)没有「正常收尾」可言,先同步杀掉,不等 close
275
+ // 一次性命令(npm ci、预检、检测、授权)没有「正常收尾」可言,先同步杀掉,不等 close。
276
+ // 授权先标记取消并删临时目录,被杀的授权命令不会被当成失败再去重跑预检
277
+ running.authorizations.killAllNow();
241
278
  killActiveExecs();
242
279
  await running.close();
243
280
  const deadline = Date.now() + KILL_GRACE_MS;
@@ -250,6 +287,7 @@ export async function runBridge(argv) {
250
287
  graceful: () => void stop(),
251
288
  // 收尾期间又来一个信号(连按 Ctrl+C):不再等宽限期,立刻清理退出(exit 钩子也会再清一遍)
252
289
  immediate: () => {
290
+ running.authorizations.killAllNow();
253
291
  running.processes.killAllNow();
254
292
  killActiveExecs();
255
293
  process.exit(130);
@@ -4,15 +4,19 @@
4
4
  * 为什么要预检:m2 没配置、没授权、还没连上时,ACP 里没有任何信号,agent 只会说「没有这个工具」(PoC 报告 §4.1)。
5
5
  *
6
6
  * 边界:
7
- * - 只运行不修改配置的状态命令(status、mcp list;注意 `claude mcp list` 会启动用户配置的 stdio MCP 做健康检查);bridge 不替用户登录、不替用户授权,只返回命令让用户在自己的终端执行。
7
+ * - 只运行不修改配置的状态命令(status、mcp list;注意 `claude mcp list` 会启动用户配置的 stdio MCP 做健康检查)。
8
+ * 预检本身不登录、不授权,只返回命令(终端兜底);代跑写死清单里的授权命令是 agent/authorize 的事(authorize.ts),
9
+ * 预检结果里的 `authorize` 能力由 service.ts 加上。
8
10
  * - 不调用 ACP authenticate 探测登录:Qoder 未登录时 60 秒不响应(PoC 报告 §2.5)。
9
11
  * - `qodercli mcp list` 会以 `mcp-test-client` 身份连 M2,服务端判成 mcp、不进项目房间(总控 §6):页面不要把这次连接当成 Qoder 会话。
10
12
  * - Cursor 的 m2 授权按目录保存,mcp list 与授权命令都必须在会话 cwd 下执行。
11
13
  * - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。
14
+ * - Claude 同样用适配器自带的 claude(D9):会话用的就是它,本机那份只决定兜底命令怎么显示。找不到自带程序时报 unknown,
15
+ * 不退回本机的 claude(否则预检和会话看的是两个程序)。
12
16
  */
13
17
  import { join } from 'node:path';
14
18
  import { findM2Names } from './m2-config.js';
15
- import { CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
19
+ import { ADAPTERS, CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
16
20
  import { combinedOutput } from './exec.js';
17
21
  import { parseClaudeAuthStatus, parseClaudeMcpList, parseCodexLoginStatus, parseCodexMcpListJson, parseCursorMcpList, parseCursorStatus, parseOpencodeMcpList, parseQoderMcpList, parseQoderStatus, tail, } from './precheck-parse.js';
18
22
  export const PRECHECK_TIMEOUT_MS = 45_000;
@@ -41,20 +45,27 @@ function plan(agent, deps) {
41
45
  const cmd = agent.command ?? agent.path ?? agent.id;
42
46
  const path = agent.path ?? cmd;
43
47
  switch (agent.id) {
44
- case 'claude':
48
+ case 'claude': {
49
+ const binary = deps.adapters.claudeBinary();
50
+ // 兜底命令:本机装了 claude 就显示 `claude …`(用户熟悉),否则显示自带程序的绝对路径(D9)
51
+ const shown = agent.installed ? cmd : (binary ?? cmd);
45
52
  return {
46
- exec: { command: path, prefix: [] },
47
- login: { args: ['auth', 'status', '--json'], parse: (r) => parseClaudeAuthStatus(r.stdout, r.code), next: run(cmd, ['auth', 'login']) },
53
+ exec: { command: binary ?? '', prefix: [] },
54
+ missing: binary
55
+ ? undefined
56
+ : `Could not find the Claude Code program bundled with ${ADAPTERS.claude.package}@${ADAPTERS.claude.version}; try reinstalling the adapter.`,
57
+ login: { args: ['auth', 'status', '--json'], parse: (r) => parseClaudeAuthStatus(r.stdout, r.code), next: run(shown, ['auth', 'login']) },
48
58
  m2: {
49
59
  args: ['mcp', 'list'],
50
60
  parse: (r) => parseClaudeMcpList(combinedOutput(r), r.code),
51
61
  next: (state, entry) => state === 'needs_auth'
52
- ? run(cmd, ['mcp', 'login', entry])
62
+ ? run(shown, ['mcp', 'login', entry])
53
63
  : state === 'needs_enable'
54
64
  ? { kind: 'manual', text: `Run claude in ${deps.cwd} and approve the project MCP server "${entry}".` }
55
65
  : null,
56
66
  },
57
67
  };
68
+ }
58
69
  case 'codex': {
59
70
  const dir = deps.adapters.installDir('codex');
60
71
  const display = join(dir, CODEX_BIN_LINK);
@@ -126,6 +137,8 @@ async function runStep(deps, p, args) {
126
137
  }
127
138
  export async function precheckM2(agent, deps) {
128
139
  const p = plan(agent, deps);
140
+ if (p.missing)
141
+ return toM2({ state: 'unknown', entry: null, raw: tail(p.missing) }, p.m2.next);
129
142
  const result = await runStep(deps, p, p.m2.args);
130
143
  const failure = failed(result);
131
144
  return toM2(failure ? { state: 'unknown', entry: null, raw: failure } : p.m2.parse(result), p.m2.next);
@@ -133,6 +146,9 @@ export async function precheckM2(agent, deps) {
133
146
  /** 调用方保证 agent 已安装;适配器类 agent 的适配器已就绪 */
134
147
  export async function precheckAgent(agent, deps, now = () => new Date()) {
135
148
  const p = plan(agent, deps);
149
+ if (p.missing) {
150
+ return { agent: agent.id, login: toLogin({ state: 'unknown', raw: tail(p.missing) }, p.login.next), m2: await precheckM2(agent, deps), checkedAt: now().toISOString() };
151
+ }
136
152
  const loginArgs = p.login.args;
137
153
  const [loginResult, m2] = await Promise.all([
138
154
  loginArgs ? runStep(deps, p, loginArgs) : Promise.resolve(null),
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 本机 ACP bridge ↔ M2 页面的 WebSocket 协议(change acp-local-bridge,给 change 3 acp-panel-local-agent 用)。
3
3
  *
4
- * 规范正文在 openspec 里该 change 的 design「页面协议」一节;本文件是它的类型落地,两者不一致时以 design 为准并修正这里。
4
+ * 规范正文在 openspec/archive/acp-local-bridge/design.md 的「页面协议」一节(之后改协议的 change 在自己的 design 里写出变化);
5
+ * 本文件是它的类型落地,两者不一致时以 design 为准并修正这里。长期契约见 openspec/specs/connect/local-bridge.md。
5
6
  *
6
7
  * 维护约束:
7
8
  * - 本文件同时被 bridge(Node,ES2022 / NodeNext)和前端(浏览器,ES2020 / bundler / noUncheckedIndexedAccess)编译,
@@ -20,6 +21,13 @@ export const BRIDGE_DEFAULT_PORT = 47821;
20
21
  export const BRIDGE_WS_PATH = '/bridge';
21
22
  /** 与前端 src/lib/agent-types 的 AgentType 取值一致(是它的子集),顺序即 agents/list 的返回顺序 */
22
23
  export const BRIDGE_AGENT_IDS = ['claude', 'codex', 'cursor', 'opencode', 'qoder'];
24
+ /**
25
+ * `agent/authorize` 的动作(change acp-terminal-free-auth,0.6.0 起):login = agent 账号登录,m2 = m2 MCP 授权。
26
+ * 页面只能选「哪个 agent × 哪个动作」,可执行文件、参数、环境、工作目录全部由 bridge 的写死清单决定(契约 3)。
27
+ */
28
+ export const BRIDGE_AUTH_ACTIONS = ['login', 'm2'];
29
+ /** `auth/link` 的链接上限(bridge 只转交不超过它的 https 链接,页面收到时再按同一上限把一次关) */
30
+ export const BRIDGE_MAX_AUTH_LINK_CHARS = 8 * 1024;
23
31
  /**
24
32
  * 页面可以发给 agent 的 ACP 方法(bridge 的 acp-guard.ts 按它放行,名单外回 `acp_rejected`、不转发)。
25
33
  * 页面对 agent 请求的响应(没有 method 键,例如审批结果)不受它限制。页面要用新方法:先在这里加上并发新版 bridge,
@@ -46,6 +54,8 @@ export const BRIDGE_METHODS = [
46
54
  'm2/configure',
47
55
  'process/start',
48
56
  'process/stop',
57
+ 'agent/authorize',
58
+ 'agent/authorize-cancel',
49
59
  ];
50
60
  export const BRIDGE_CLOSE_CODES = {
51
61
  normal: 1000,