@m2canvas/connect 0.8.0 → 0.9.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.
package/README.md CHANGED
@@ -40,6 +40,7 @@ Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on
40
40
  - **Your own logins.** Agents use their own sign-in and their own M2 MCP setup. It starts agents with a minimal environment, so variables set by a host app (for example `ANTHROPIC_BASE_URL` inside Claude Code) don't leak into them.
41
41
  - **Signing in from M2 (macOS).** M2 can start an agent's own sign-in, or its M2 authorization, for you: the bridge runs that agent's own command from a fixed list in this package, and M2 opens the sign-in page in the browser you are using (Codex opens your default browser itself). You finish signing in there; nothing is entered for you. The page only picks the agent and whether to sign in or authorize M2, never the command. Covered: sign-in for Claude Code, Codex, Cursor and Qoder; M2 authorization for Claude Code, Codex, Cursor and OpenCode (Qoder's M2 authorization is only available inside Qoder). Claude Code's M2 authorization needs a terminal, so the bridge provides one with macOS's built-in `/usr/bin/expect`. A sign-in can be canceled from M2, gives up after 10 minutes, and stops when the page disconnects or the bridge exits. On other systems M2 shows the command to run instead.
42
42
  - **Claude and Codex** run through pinned versions of the official adapters (`@agentclientprotocol/claude-agent-acp@0.84.0`, `@agentclientprotocol/codex-acp@2.1.0`). They are installed on first use into `~/.m2canvas/acp-adapters/` from the lockfiles in this package (they bundle the agent: about 110 MB and 140 MB to download, 270 MB and 350 MB on disk); M2 shows the download progress. You don't need Claude Code or Codex installed yourself: checking sign-in, adding M2 and signing in all use the bundled `claude` and `codex`, which read the same settings as your own installs.
43
+ - **Checking an agent before use.** Before starting an agent, M2 asks the bridge whether it is signed in and has M2 set up (commands such as `claude auth status` and `claude mcp list`; `claude mcp list` starts the MCP servers you configured in order to check them). From 0.9.0 the bridge runs this check for Claude Code and Codex once when it starts, and remembers a result that passed, so opening another project doesn't wait for it again. It checks again when you click "Check again" in M2 and when M2 adds itself to an agent or signs in for you; when a page uses a remembered result that is more than 5 minutes old, it also checks again in the background and tells M2 if the result no longer holds. There is no periodic check. Results that need you to do something are never remembered, and nothing is written to disk.
43
44
  - Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
44
45
 
45
46
  Agent sessions start in that directory.
@@ -38,7 +38,8 @@ export async function handleCall(ctx, conn, id, method, params) {
38
38
  case 'agent/prepare':
39
39
  return { agent, adapter: await ctx.service.prepare(agent) };
40
40
  case 'agent/precheck':
41
- return ctx.service.precheck(agent);
41
+ // 带连接 id:每条连接第一次查这家可用缓存,之后一律真跑(契约 18)
42
+ return ctx.service.precheck(agent, conn.id);
42
43
  case 'm2/configure':
43
44
  ctx.log(`Page asked to add M2 to ${agent}'s MCP config.`);
44
45
  return { agent, ...(await ctx.service.configure(agent)) };
@@ -15,7 +15,7 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
15
15
  import { join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { home, isExecutable, which } from '../system.js';
18
- import { AdapterManager, ADAPTERS } from './adapters.js';
18
+ import { AdapterManager, ADAPTERS, isAdapterAgent } from './adapters.js';
19
19
  import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
20
20
  import { detectAgent } from './agents.js';
21
21
  import { EXPECT_PATH } from './authorize.js';
@@ -29,6 +29,7 @@ import { PairingManager, createFilePairingStore } from './pairing.js';
29
29
  import { BRIDGE_AGENT_IDS, BRIDGE_WS_PATH } from './protocol.js';
30
30
  import { resolveNpmCommand } from './npm-command.js';
31
31
  import { allowedOrigins } from './security.js';
32
+ import { prewarmPrechecks } from './precheck-cache.js';
32
33
  import { selfTestProblems } from './self-test.js';
33
34
  import { runCheckUpdate } from './update-io.js';
34
35
  import { startBridgeServer } from './server.js';
@@ -36,6 +37,8 @@ import { createAgentService } from './service.js';
36
37
  import { KILL_GRACE_MS } from './processes.js';
37
38
  import { createSelfUninstaller, uninstallScriptPath } from './uninstall.js';
38
39
  const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
40
+ /** 启动时预热预检的 agent(契约 18;用户 2026-11-04 拍板 B-P1:随包的两家,不管用没用过) */
41
+ const PREWARM_AGENTS = ['claude', 'codex'];
39
42
  const LABELS = {
40
43
  claude: 'Claude Code',
41
44
  codex: 'Codex',
@@ -191,6 +194,9 @@ export async function runBridge(argv) {
191
194
  cliPath: fileURLToPath(new URL('../cli.js', import.meta.url)),
192
195
  platform: process.platform,
193
196
  expectAvailable: () => isExecutable(EXPECT_PATH),
197
+ // 出现过「通过」之后一次真跑没通过:告诉之前可能拿到通过的页面(尽力收回,契约 18)
198
+ onPrecheckRevoked: (result) => server?.broadcast('precheck/updated', result),
199
+ log,
194
200
  });
195
201
  const version = packageVersion();
196
202
  const origins = allowedOrigins(parsed.dev);
@@ -330,6 +336,13 @@ export async function runBridge(argv) {
330
336
  log,
331
337
  });
332
338
  }
339
+ // 预热(契约 18,用户 2026-11-04 拍板 B-P1):信号处理装好之后再起,收尾时 killActiveExecs 能杀掉它的命令;不等它,页面来查会加入
340
+ void prewarmPrechecks({
341
+ agents: PREWARM_AGENTS,
342
+ ready: (agent) => isAdapterAgent(agent) && adapters.isReady(agent),
343
+ warm: (agent) => service.warmPrecheck(agent),
344
+ log,
345
+ });
333
346
  const lifetime = backgroundLifetimeMs(parsed);
334
347
  if (lifetime !== null) {
335
348
  setTimeout(() => {
@@ -0,0 +1,144 @@
1
+ /** 缓存最长可用这么久;之后的第一次查询真跑 */
2
+ export const PRECHECK_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
3
+ /** 用到的缓存比这更旧时,同时在后台复查一次 */
4
+ export const PRECHECK_REFRESH_AFTER_MS = 5 * 60 * 1000;
5
+ /** 能缓存的「通过」:比页面的 precheckPasses 严 —— 登录 unknown、已跳过的 m2 unknown 页面放行,这里都不记 */
6
+ export function isCacheablePrecheck(result) {
7
+ const login = result.login.state;
8
+ const m2 = result.m2.state;
9
+ return (login === 'logged_in' || login === 'not_required') && (m2 === 'ready' || m2 === 'unverified');
10
+ }
11
+ export class PrecheckCache {
12
+ deps;
13
+ slots = new Map();
14
+ /** 每条连接查过哪几家;连接关闭时 forgetConnection */
15
+ asked = new Map();
16
+ sequence = 0;
17
+ constructor(deps) {
18
+ this.deps = deps;
19
+ }
20
+ /** 页面连接上的一次 `agent/precheck` */
21
+ forConnection(connectionId, agent) {
22
+ const asked = this.asked.get(connectionId) ?? new Set();
23
+ this.asked.set(connectionId, asked);
24
+ if (asked.has(agent))
25
+ return this.fresh(agent, 'same page checked again');
26
+ asked.add(agent);
27
+ const slot = this.slot(agent);
28
+ const cached = slot.cached;
29
+ if (cached && this.deps.usable(agent)) {
30
+ const age = this.deps.now() - cached.startedAt;
31
+ // 年龄为负(系统时钟往回拨过)当作过期:不能让它一直「不到 5 分钟」而永不复查(review r1 N2)
32
+ if (age >= 0 && age <= PRECHECK_CACHE_MAX_AGE_MS) {
33
+ const refresh = age > PRECHECK_REFRESH_AFTER_MS;
34
+ this.deps.log(`Precheck of ${agent} for a page: used the result from ${Math.round(age / 1000)}s ago${refresh ? ' (checking again in the background)' : ''}.`);
35
+ if (refresh)
36
+ this.warm(agent, 'background re-check').catch(() => undefined); // 结果与出错由 start 记日志
37
+ slot.passHandedOut = true;
38
+ return Promise.resolve(cached.result);
39
+ }
40
+ }
41
+ if (slot.shared) {
42
+ this.deps.log(`Precheck of ${agent} for a page: joined a check already running.`);
43
+ return slot.shared;
44
+ }
45
+ return this.warm(agent, 'first check for a page');
46
+ }
47
+ /** 独立的一次真跑(同一连接的再查、授权结束后的重查):不加入任何在跑的,结果照常写回 */
48
+ fresh(agent, reason) {
49
+ return this.start(agent, reason);
50
+ }
51
+ /** 可共享的真跑:这家已有一次在跑就加入它,否则起一次 */
52
+ warm(agent, reason) {
53
+ const slot = this.slot(agent);
54
+ if (slot.shared)
55
+ return slot.shared;
56
+ const running = this.start(agent, reason);
57
+ slot.shared = running;
58
+ const clear = () => {
59
+ if (slot.shared === running)
60
+ slot.shared = null;
61
+ };
62
+ running.then(clear, clear);
63
+ return running;
64
+ }
65
+ /** 写配置 / 授权开始时:这家的状态马上要变 */
66
+ invalidate(agent) {
67
+ const slot = this.slot(agent);
68
+ slot.generation += 1;
69
+ slot.cached = null;
70
+ slot.latest = null;
71
+ slot.shared = null;
72
+ }
73
+ forgetConnection(connectionId) {
74
+ this.asked.delete(connectionId);
75
+ }
76
+ slot(agent) {
77
+ let slot = this.slots.get(agent);
78
+ if (!slot) {
79
+ slot = { generation: 0, cached: null, latest: null, lastWrite: 0, passHandedOut: false, shared: null };
80
+ this.slots.set(agent, slot);
81
+ }
82
+ return slot;
83
+ }
84
+ start(agent, reason) {
85
+ const slot = this.slot(agent);
86
+ const run = { sequence: ++this.sequence, generation: slot.generation, startedAt: this.deps.now() };
87
+ return this.deps.run(agent).then((result) => {
88
+ const seconds = ((this.deps.now() - run.startedAt) / 1000).toFixed(1);
89
+ this.deps.log(`Precheck of ${agent} (${reason}): ${verdict(result)} in ${seconds}s.`);
90
+ this.write(agent, run, result);
91
+ // 被更晚开始、已写入的真跑推翻:交出那个更新的结论。不看代数:latest 作废时清空、之后只由作废后开始的真跑写入,必然更新
92
+ const handedOut = run.sequence < slot.lastWrite && slot.latest ? slot.latest : result;
93
+ if (isCacheablePrecheck(handedOut))
94
+ slot.passHandedOut = true;
95
+ return handedOut;
96
+ }, (error) => {
97
+ this.deps.log(`Precheck of ${agent} (${reason}) did not run: ${error instanceof Error ? error.message : String(error)}`);
98
+ // 前置条件不满足:原来的通过不再可信,删掉;不推送 —— 调用方自己会拿到这个错误
99
+ if (this.canWrite(slot, run)) {
100
+ slot.lastWrite = run.sequence;
101
+ slot.cached = null;
102
+ slot.latest = null;
103
+ }
104
+ throw error;
105
+ });
106
+ }
107
+ canWrite(slot, run) {
108
+ return run.generation === slot.generation && run.sequence > slot.lastWrite;
109
+ }
110
+ write(agent, run, result) {
111
+ const slot = this.slot(agent);
112
+ if (!this.canWrite(slot, run))
113
+ return;
114
+ slot.lastWrite = run.sequence;
115
+ slot.latest = result;
116
+ if (isCacheablePrecheck(result)) {
117
+ slot.cached = { result, startedAt: run.startedAt };
118
+ return;
119
+ }
120
+ slot.cached = null;
121
+ if (slot.passHandedOut) {
122
+ slot.passHandedOut = false;
123
+ this.deps.onRevoked(agent, result);
124
+ }
125
+ }
126
+ }
127
+ function verdict(result) {
128
+ return isCacheablePrecheck(result) ? 'passed' : `not passed (sign-in ${result.login.state}, M2 ${result.m2.state})`;
129
+ }
130
+ /**
131
+ * bridge 开始监听之后预热(design D6,用户 2026-11-04 拍板 B-P1:Claude 与 Codex,不管用没用过)。
132
+ * 副作用(契约 8):`claude mcp list` 会启动用户配置的 stdio MCP 并联网,客户端用户同意连接后每次打开客户端都会发生一次。
133
+ * 只在启动时一次,不定时重复;各家并行;失败不抛出。结果与耗时由检查本身记一行(`Precheck of … (startup): …`),
134
+ * 真机的 bridge.log 因此自带每次启动的预检耗时。
135
+ */
136
+ export async function prewarmPrechecks(deps) {
137
+ await Promise.all(deps.agents.map(async (agent) => {
138
+ if (!deps.ready(agent)) {
139
+ deps.log(`Skipped the startup precheck of ${agent}: its adapter isn't installed.`);
140
+ return;
141
+ }
142
+ await deps.warm(agent).catch(() => undefined);
143
+ }));
144
+ }
@@ -10,7 +10,8 @@
10
10
  * - 不调用 ACP authenticate 探测登录:Qoder 未登录时 60 秒不响应(PoC 报告 §2.5)。
11
11
  * - `qodercli mcp list` 会以 `mcp-test-client` 身份连 M2,服务端判成 mcp、不进项目房间(总控 §6):页面不要把这次连接当成 Qoder 会话。
12
12
  * - Cursor 的 m2 授权按目录保存,mcp list 与授权命令都必须在会话 cwd 下执行。
13
- * - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。
13
+ * - Codex 的预检与授权都用适配器自带的 codex(调用方保证适配器已就绪)。Codex 的 m2 检查先断网跑一次,只有本地就能定的
14
+ * 三种结果直接用,其余联网再跑(`CODEX_OFFLINE_PROXY` 处的说明)。
14
15
  * - Claude 同样用适配器自带的 claude(D9):会话用的就是它,本机那份只决定兜底命令怎么显示。找不到自带程序时报 unknown,
15
16
  * 不退回本机的 claude(否则预检和会话看的是两个程序)。
16
17
  */
@@ -20,6 +21,24 @@ import { ADAPTERS, CODEX_BIN_LINK, CODEX_SCRIPT } from './adapters.js';
20
21
  import { combinedOutput } from './exec.js';
21
22
  import { parseClaudeAuthStatus, parseClaudeMcpList, parseCodexLoginStatus, parseCodexMcpListJson, parseCursorMcpList, parseCursorStatus, parseOpencodeMcpList, parseQoderMcpList, parseQoderStatus, tail, } from './precheck-parse.js';
22
23
  export const PRECHECK_TIMEOUT_MS = 45_000;
24
+ /**
25
+ * Codex 的 m2 检查先断网跑一次(change bridge-precheck-speedup D12)。`codex mcp list --json` 联网时会探测每个还没存凭证的
26
+ * HTTP 服务器支不支持 OAuth(用户真实环境 3–6.6 秒);而 M2 的 `o_auth`(有本地凭证)、没配置、停用三种状态只由本地配置与凭证决定,
27
+ * 断网结果与联网一致(2026-11-04 隔离实测,`evidence/measurement.md` §1c)。「配了没授权」断网会给 unknown(联网是 not_logged_in),
28
+ * 所以只有这三种直接采用,其余一律联网再跑、以联网为准 —— 结果与只联网逐状态一致,变化只在耗时。
29
+ * 依赖 Codex 的实现细节:升级 Codex 适配器时按该 evidence 的做法重测三种状态(契约 7)。
30
+ */
31
+ export const CODEX_OFFLINE_PROXY = 'http://127.0.0.1:9';
32
+ /**
33
+ * 断网那次实测 0.06 秒;超过这个时限就当没结果、联网再跑。不用更紧的值:新铺的程序第一次运行有约 4 秒的系统扫描
34
+ * (`desktop-bundled-local-runtime` 实测),太紧会让首次 Codex 预检白等一次再联网(review r1 N3)。
35
+ */
36
+ export const CODEX_OFFLINE_TIMEOUT_MS = 10_000;
37
+ const OFFLINE_TRUSTED = new Set(['unverified', 'not_configured', 'needs_enable']);
38
+ function offlineEnv(env) {
39
+ const proxy = CODEX_OFFLINE_PROXY;
40
+ return { ...env, HTTPS_PROXY: proxy, HTTP_PROXY: proxy, ALL_PROXY: proxy, https_proxy: proxy, http_proxy: proxy, all_proxy: proxy, NO_PROXY: '', no_proxy: '' };
41
+ }
23
42
  export function shellQuote(value) {
24
43
  return /^[\w@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
25
44
  }
@@ -74,6 +93,7 @@ function plan(agent, deps) {
74
93
  login: { args: ['login', 'status'], parse: (r) => parseCodexLoginStatus(combinedOutput(r), r.code), next: run(display, ['login']) },
75
94
  m2: {
76
95
  args: ['mcp', 'list', '--json'],
96
+ offlineFirst: true,
77
97
  parse: (r) => parseCodexMcpListJson(r.stdout, r.code),
78
98
  next: (state, entry) => state === 'needs_auth' || state === 'unverified'
79
99
  ? run(display, ['mcp', 'login', entry])
@@ -132,13 +152,23 @@ function plan(agent, deps) {
132
152
  };
133
153
  }
134
154
  }
135
- async function runStep(deps, p, args) {
136
- return deps.exec(p.exec.command, [...p.exec.prefix, ...args], { env: deps.env, cwd: deps.cwd, timeoutMs: PRECHECK_TIMEOUT_MS });
155
+ async function runStep(deps, p, args, options = {}) {
156
+ return deps.exec(p.exec.command, [...p.exec.prefix, ...args], {
157
+ env: options.offline ? offlineEnv(deps.env) : deps.env,
158
+ cwd: deps.cwd,
159
+ timeoutMs: options.offline ? CODEX_OFFLINE_TIMEOUT_MS : PRECHECK_TIMEOUT_MS,
160
+ });
137
161
  }
138
162
  export async function precheckM2(agent, deps) {
139
163
  const p = plan(agent, deps);
140
164
  if (p.missing)
141
165
  return toM2({ state: 'unknown', entry: null, raw: tail(p.missing) }, p.m2.next);
166
+ if (p.m2.offlineFirst) {
167
+ // 断网那次出任何意外(起不来、超时、退出码非 0、认不出),Codex 的解析都给 unknown(退出码不是 0 就不信输出),于是联网再跑
168
+ const offline = p.m2.parse(await runStep(deps, p, p.m2.args, { offline: true }));
169
+ if (OFFLINE_TRUSTED.has(offline.state))
170
+ return toM2(offline, p.m2.next);
171
+ }
142
172
  const result = await runStep(deps, p, p.m2.args);
143
173
  const failure = failed(result);
144
174
  return toM2(failure ? { state: 'unknown', entry: null, raw: failure } : p.m2.parse(result), p.m2.next);
@@ -304,6 +304,7 @@ export function startBridgeServer(options) {
304
304
  connections.delete(conn.id);
305
305
  processes.stopOwner(conn.id);
306
306
  authorizations.cancelOwner(conn.id);
307
+ options.service.forgetConnection(conn.id);
307
308
  });
308
309
  // ws 的错误(非法帧、超过 maxPayload 等)之后必然紧跟 'close',清理在 close 里做;这里只防止未处理的 error 事件让进程崩溃
309
310
  ws.on('error', () => undefined);
@@ -14,6 +14,10 @@
14
14
  * 授权(change acp-terminal-free-auth):authorizePlan 按写死清单生成命令(authorize.ts),m2 的配置项名只取 bridge 自己的 m2 预检:
15
15
  * 优先用最近一次预检 / 写配置时得到的名字(D19,页面先预检才会显示按钮,省掉一次重跑 —— Claude 的 `mcp list` 实测约 5 秒),没有记录时重跑;
16
16
  * 预检 / 写配置的结果按同一判据带上 `authorize` 能力,页面只在能力存在时才调用 agent/authorize。
17
+ *
18
+ * 预检缓存(change bridge-precheck-speedup,契约 18,规则在 precheck-cache.ts):带连接 id 的预检是页面的 `agent/precheck`,
19
+ * 每条连接第一次查某家可以用缓存;不带的(授权结束后的重查)一律真跑并写回。写配置、授权在**开始运行之前**作废这家的缓存 ——
20
+ * 前置条件不满足而什么都没运行时不作废。缓存存不带能力的原始结果,给出时再按当前判据加 `authorize`。
17
21
  */
18
22
  import { dirname, join } from 'node:path';
19
23
  import { ADAPTERS, isAdapterAgent } from './adapters.js';
@@ -23,6 +27,7 @@ import { authCapability, buildAuthPlan, isSafeAuthEntry } from './authorize.js';
23
27
  import { prependPath } from './env.js';
24
28
  import { combinedOutput } from './exec.js';
25
29
  import { precheckAgent, precheckM2 } from './precheck.js';
30
+ import { PrecheckCache } from './precheck-cache.js';
26
31
  import { BRIDGE_AGENT_IDS, } from './protocol.js';
27
32
  export const CONFIGURE_TIMEOUT_MS = 120_000;
28
33
  /**
@@ -92,6 +97,18 @@ export function createAgentService(deps) {
92
97
  login: withCapability(result.login, result.agent, 'login'),
93
98
  m2: withCapability(result.m2, result.agent, 'm2'),
94
99
  });
100
+ const precheckCache = new PrecheckCache({
101
+ run: async (agent) => {
102
+ const result = await precheckAgent(await ready(agent), precheckDeps);
103
+ remember(agent, result.m2);
104
+ return result;
105
+ },
106
+ // 只看文件状态、不起子进程;原生 agent 的检测要跑命令行,不在给缓存前做(拉起时 launch 会报 agent_not_installed)
107
+ usable: (agent) => !isAdapterAgent(agent) || deps.adapters.isReady(agent),
108
+ now: deps.now ?? Date.now,
109
+ onRevoked: (_agent, result) => deps.onPrecheckRevoked?.(withCapabilities(result)),
110
+ log: deps.log ?? (() => undefined),
111
+ });
95
112
  /** D10:clients.ts 只认 PATH 上能跑起来的 claude / codex */
96
113
  function configureEnv(agent) {
97
114
  const node = dirname(deps.nodePath);
@@ -130,13 +147,21 @@ export function createAgentService(deps) {
130
147
  }
131
148
  return info;
132
149
  },
133
- async precheck(agent) {
134
- const result = await precheckAgent(await ready(agent), precheckDeps);
135
- remember(agent, result.m2);
150
+ async precheck(agent, connectionId) {
151
+ // 不带连接的只有授权结束后的重查(auth-call.ts):一律新起一次真跑,不加入授权期间别的连接起的共享真跑(契约 12 delta)
152
+ const result = connectionId === undefined ? await precheckCache.fresh(agent, 'after sign-in or M2 authorization') : await precheckCache.forConnection(connectionId, agent);
136
153
  return withCapabilities(result);
137
154
  },
155
+ async warmPrecheck(agent) {
156
+ return precheckCache.warm(agent, 'startup');
157
+ },
158
+ forgetConnection(connectionId) {
159
+ precheckCache.forgetConnection(connectionId);
160
+ },
138
161
  async configure(agent) {
139
162
  const detected = await ready(agent);
163
+ // 马上要写配置:这家的预检结果不再可信(契约 18)
164
+ precheckCache.invalidate(agent);
140
165
  const result = await deps.exec(deps.nodePath, [deps.cliPath, '--client', CLIENT_ID[agent], '--yes'], {
141
166
  env: configureEnv(agent),
142
167
  cwd: deps.cwd,
@@ -165,6 +190,9 @@ export function createAgentService(deps) {
165
190
  const detected = await ready(agent);
166
191
  if (!capability(agent, action))
167
192
  throw new BridgeFailure('auth_unavailable', `the bridge cannot run ${verb} for ${agent}; use the terminal command instead`);
193
+ // 取计划之后就运行授权命令(AuthorizationManager.run):登录 / 授权状态马上要变,先作废缓存(契约 18)。
194
+ // 下面因配置项名缺失而 auth_unavailable 时也已作废 —— 没运行任何命令,多一次真跑无害
195
+ precheckCache.invalidate(agent);
168
196
  let entry = null;
169
197
  if (action === 'm2') {
170
198
  // 配置项名不一定叫 m2,也不能由页面指定:用 bridge 最近一次预检的结果,没有就重跑(D19);OpenCode 总是重跑(D21)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@m2canvas/connect",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
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": {