@m2canvas/connect 0.4.0 → 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.
- package/README.md +3 -3
- package/dist/bridge/main.js +46 -17
- package/dist/bridge/pairing.js +76 -1
- package/dist/bridge/server.js +15 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,11 +32,11 @@ After setup, restart each client so it loads the new server.
|
|
|
32
32
|
npx -y @m2canvas/connect@latest bridge
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on this machine over [ACP](https://agentclientprotocol.com): Claude Code, Codex, Cursor CLI, OpenCode and Qoder. Run it in the project directory you want the agents to work in, and keep the window open.
|
|
35
|
+
Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on this machine over [ACP](https://agentclientprotocol.com): Claude Code, Codex, Cursor CLI, OpenCode and Qoder. Run it in the project directory you want the agents to work in, and keep the window open. Copy the command from M2's Local Agent tab: it adds `--pair <value>` so that page pairs by itself.
|
|
36
36
|
|
|
37
37
|
- **Local only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://m2ui.ai`. Other sites, other machines and other hosts are refused before a connection is made. (`--dev` also accepts a local M2 dev server on port 5173; any app you serve on that port could then ask to pair, so only use it while developing M2.)
|
|
38
|
-
- **Pairing gives the page your agents.** A paired page can make these agents read and change files and run commands as you, the same as typing into them yourself. Only enter the code into M2.
|
|
39
|
-
- **Pairing.** The
|
|
38
|
+
- **Pairing gives the page your agents.** A paired page can make these agents read and change files and run commands as you, the same as typing into them yourself. Only run commands you copied from M2, and only enter the code into M2.
|
|
39
|
+
- **Pairing.** When you start the bridge with the command M2 gives you, the page that gave it pairs automatically within 10 minutes. The command only carries a hash (`--pair`); the secret it matches stays in that browser tab, so the command is safe to appear in your shell history, and it works once. Any other page asks for the pairing code printed in this window; the window also shows which site is asking. After that the page remembers the pairing for 30 days of inactivity. After too many wrong codes, new pages can't pair until you restart the bridge (pages already paired keep working); close the page the window names first. `bridge --unpair` forgets all paired pages.
|
|
40
40
|
- **Your own logins.** Agents use their own sign-in and their own M2 MCP setup; the bridge never signs in for you. 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
|
- **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 (a few hundred MB each, because they bundle the agent). Codex then needs its own M2 sign-in once, with the bundled `codex`; M2 shows the exact command.
|
|
42
42
|
- Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
|
package/dist/bridge/main.js
CHANGED
|
@@ -15,7 +15,7 @@ import { detectAgent } from './agents.js';
|
|
|
15
15
|
import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
|
|
16
16
|
import { execCommand, killActiveExecs } from './exec.js';
|
|
17
17
|
import { installShutdownSignals } from './lifecycle.js';
|
|
18
|
-
import { PairingManager, createFilePairingStore } from './pairing.js';
|
|
18
|
+
import { PairingManager, createFilePairingStore, isPairCommitment } from './pairing.js';
|
|
19
19
|
import { BRIDGE_AGENT_IDS, BRIDGE_DEFAULT_PORT, BRIDGE_WS_PATH } from './protocol.js';
|
|
20
20
|
import { DEV_ORIGINS, PROD_ORIGINS, allowedOrigins } from './security.js';
|
|
21
21
|
import { startBridgeServer } from './server.js';
|
|
@@ -29,26 +29,30 @@ const LABELS = {
|
|
|
29
29
|
opencode: 'OpenCode',
|
|
30
30
|
qoder: 'Qoder',
|
|
31
31
|
};
|
|
32
|
-
const
|
|
32
|
+
export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>]
|
|
33
33
|
npx @m2canvas/connect bridge --unpair
|
|
34
34
|
|
|
35
35
|
Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
|
|
36
36
|
installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
|
|
37
37
|
|
|
38
38
|
It only listens on 127.0.0.1 and only accepts the M2 web app (${PROD_ORIGINS.join(', ')}).
|
|
39
|
-
The
|
|
40
|
-
|
|
39
|
+
The command M2 gives you pairs that page automatically; other pages ask for the
|
|
40
|
+
pairing code shown here. A paired page can make these agents read and change
|
|
41
|
+
files and run commands as you, so only run commands you copied from M2.
|
|
41
42
|
Agents run in the directory you start the bridge from (or --cwd), with your own logins.
|
|
42
43
|
|
|
43
|
-
--port <n>
|
|
44
|
-
--cwd <dir>
|
|
45
|
-
--verbose
|
|
46
|
-
--dev
|
|
47
|
-
|
|
48
|
-
--
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
44
|
+
--port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
|
|
45
|
+
--cwd <dir> working directory for agent sessions (default: current directory)
|
|
46
|
+
--verbose also print agent stderr and connection details
|
|
47
|
+
--dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
|
|
48
|
+
any app served on that port could then ask to pair
|
|
49
|
+
--pair <value> pair the M2 page that gave you this command (M2 adds it for you;
|
|
50
|
+
valid for 10 minutes, once)
|
|
51
|
+
--unpair forget all paired pages and exit
|
|
52
|
+
-h, --help show this help`;
|
|
53
|
+
/** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
|
|
54
|
+
export function parseBridgeArgs(argv) {
|
|
55
|
+
const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null };
|
|
52
56
|
for (let i = 0; i < argv.length; i++) {
|
|
53
57
|
const arg = argv[i];
|
|
54
58
|
if (arg === '-h' || arg === '--help')
|
|
@@ -59,6 +63,14 @@ function parseArgs(argv) {
|
|
|
59
63
|
args.unpair = true;
|
|
60
64
|
else if (arg === '--dev')
|
|
61
65
|
args.dev = true;
|
|
66
|
+
else if (arg === '--pair') {
|
|
67
|
+
const value = argv[++i];
|
|
68
|
+
if (value === undefined)
|
|
69
|
+
return '--pair needs a value';
|
|
70
|
+
if (!isPairCommitment(value))
|
|
71
|
+
return `invalid --pair value ${value}; copy the start command from M2 again`;
|
|
72
|
+
args.pair = value;
|
|
73
|
+
}
|
|
62
74
|
else if (arg === '--port' || arg === '--cwd') {
|
|
63
75
|
const value = argv[++i];
|
|
64
76
|
if (value === undefined)
|
|
@@ -96,6 +108,15 @@ function printCode(info) {
|
|
|
96
108
|
else
|
|
97
109
|
console.log(`\n→ Too many wrong pairing codes${info.origin ? ` from ${info.origin}` : ''}. New ${code}\n`);
|
|
98
110
|
}
|
|
111
|
+
function clock(at) {
|
|
112
|
+
return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
113
|
+
}
|
|
114
|
+
/** 启动命令里的配对已被用掉,同一个密钥又出现:同一个 M2 标签页被复制 / 连接途中刷新,或者别的程序抢先配对了(review r1 S2) */
|
|
115
|
+
function printReused(info) {
|
|
116
|
+
console.log(`\n→ Warning: the pairing in your start command was already used (by ${info.firstOrigin} at ${clock(info.usedAt)}), ` +
|
|
117
|
+
`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 ` +
|
|
118
|
+
`may have paired with this bridge: press Ctrl+C, run "npx @m2canvas/connect bridge --unpair", then start the bridge again.\n`);
|
|
119
|
+
}
|
|
99
120
|
function printLocked(origin) {
|
|
100
121
|
const from = origin ? ` (the last ones came from ${origin})` : '';
|
|
101
122
|
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 +133,13 @@ function describeAdapter(agent, info) {
|
|
|
112
133
|
return null;
|
|
113
134
|
}
|
|
114
135
|
export async function runBridge(argv) {
|
|
115
|
-
const parsed =
|
|
136
|
+
const parsed = parseBridgeArgs(argv);
|
|
116
137
|
if (typeof parsed === 'string') {
|
|
117
|
-
console.error(`${parsed}\n\n${
|
|
138
|
+
console.error(`${parsed}\n\n${BRIDGE_HELP}`);
|
|
118
139
|
return 2;
|
|
119
140
|
}
|
|
120
141
|
if (parsed.help) {
|
|
121
|
-
console.log(
|
|
142
|
+
console.log(BRIDGE_HELP);
|
|
122
143
|
return 0;
|
|
123
144
|
}
|
|
124
145
|
const m2Dir = join(home, '.m2canvas');
|
|
@@ -142,7 +163,10 @@ export async function runBridge(argv) {
|
|
|
142
163
|
onCodeIssued: printCode,
|
|
143
164
|
onLocked: printLocked,
|
|
144
165
|
onProblem: (message) => console.warn(message),
|
|
166
|
+
onCommitmentReused: printReused,
|
|
145
167
|
});
|
|
168
|
+
// 承诺必须在开始监听之前挂上:等待中的页面每 2 秒探测一次,监听一开始就可能连上;晚挂一步它就撞上 no_commitment、落到输码
|
|
169
|
+
const pairUntil = parsed.pair ? pairing.armCommitment(parsed.pair) : null;
|
|
146
170
|
const adapters = new AdapterManager({
|
|
147
171
|
sourceRoot: join(PACKAGE_ROOT, 'adapters'),
|
|
148
172
|
installRoot: join(m2Dir, 'acp-adapters'),
|
|
@@ -237,7 +261,12 @@ export async function runBridge(argv) {
|
|
|
237
261
|
console.log(` Accepts pages from: ${origins.join(', ')}`);
|
|
238
262
|
if (parsed.dev)
|
|
239
263
|
console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
|
|
240
|
-
|
|
264
|
+
// 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
|
|
265
|
+
// 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
|
|
266
|
+
if (pairUntil !== null)
|
|
267
|
+
console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
|
|
268
|
+
else
|
|
269
|
+
pairing.issueCode('startup');
|
|
241
270
|
const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
|
|
242
271
|
console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
|
|
243
272
|
console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
|
package/dist/bridge/pairing.js
CHANGED
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* 和本机其他进程(可以伪造 Origin)。它们都拿不到只出现在终端里的配对码。
|
|
4
4
|
* 配对 = 授权该页面以用户的身份驱动本机 agent(包括执行命令),所以码和 token 都按这个级别保管。
|
|
5
5
|
*
|
|
6
|
-
*
|
|
6
|
+
* 两条途径,都要证明「终端用户同意了这个页面」(change acp-bridge-command-pairing):
|
|
7
|
+
* - 输码:终端打印一次性配对码 → 用户在页面输入 → 换取长期 token(页面存 localStorage)→ 之后每次 hello 带上。
|
|
8
|
+
* - 命令承诺:页面生成密钥 S,启动命令只带承诺 c = base64url(sha256(S) 的前 16 字节)(`bridge --pair c`);
|
|
9
|
+
* 页面在 hello 里出示 S,一致就换取同样的 token。「在自己终端里运行了这个页面给的命令」与「抄了终端里的码」证明的是同一件事。
|
|
10
|
+
* 命令会进 `ps` 与 shell 历史,所以命令里只能有承诺、不能有 S;承诺只在内存里、自挂上起 CODE_TTL_MS 有效、成功一次即作废。
|
|
7
11
|
*
|
|
8
12
|
* 维护约束:
|
|
9
13
|
* - 本地只存 token 的 sha256,不存明文;文件 0600、目录 0700。
|
|
@@ -33,6 +37,18 @@ const PROMPT_DEDUPE_MS = 60_000;
|
|
|
33
37
|
/** Crockford base32:去掉易混的 I、L、O、U */
|
|
34
38
|
const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
35
39
|
const CODE_LENGTH = 8;
|
|
40
|
+
/** 页面密钥 S:32 字节随机数的 base64url(无填充) */
|
|
41
|
+
const PAIR_SECRET_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
42
|
+
/** 承诺:sha256(S) 前 16 字节的 base64url(无填充) */
|
|
43
|
+
const PAIR_COMMITMENT_RE = /^[A-Za-z0-9_-]{22}$/;
|
|
44
|
+
const COMMITMENT_BYTES = 16;
|
|
45
|
+
export function isPairCommitment(value) {
|
|
46
|
+
return PAIR_COMMITMENT_RE.test(value);
|
|
47
|
+
}
|
|
48
|
+
/** 承诺 = base64url(sha256(S 的 UTF-8) 的前 16 字节)。前端 `bridge-pair-secret.ts` 用 WebCrypto 算同一个值,改这里要同批改那边。 */
|
|
49
|
+
export function pairCommitmentOf(secret) {
|
|
50
|
+
return sha256(secret).subarray(0, COMMITMENT_BYTES).toString('base64url');
|
|
51
|
+
}
|
|
36
52
|
/** 用户输入归一:大小写、空格、连字符;按 Crockford 规则把 O 视同 0、I / L 视同 1 */
|
|
37
53
|
export function normalizePairingCode(input) {
|
|
38
54
|
return input
|
|
@@ -57,6 +73,10 @@ function errorText(error) {
|
|
|
57
73
|
}
|
|
58
74
|
export class PairingManager {
|
|
59
75
|
active = null;
|
|
76
|
+
/** `--pair` 挂上的承诺(只在内存里:bridge 重启即失效,重新运行同一条命令会重新挂上) */
|
|
77
|
+
commitment = null;
|
|
78
|
+
/** 已用掉的承诺:同一个密钥再出现时告警(只在内存里,只记最近一个) */
|
|
79
|
+
consumed = null;
|
|
60
80
|
/** 自上次成功配对以来累计的错码次数(跨码、跨过期);配对成功时清零 */
|
|
61
81
|
failuresSinceSuccess = 0;
|
|
62
82
|
locked = false;
|
|
@@ -67,12 +87,14 @@ export class PairingManager {
|
|
|
67
87
|
onCodeIssued;
|
|
68
88
|
onLocked;
|
|
69
89
|
onProblem;
|
|
90
|
+
onCommitmentReused;
|
|
70
91
|
constructor(options) {
|
|
71
92
|
this.store = options.store;
|
|
72
93
|
this.now = options.now ?? Date.now;
|
|
73
94
|
this.onCodeIssued = options.onCodeIssued;
|
|
74
95
|
this.onLocked = options.onLocked ?? (() => undefined);
|
|
75
96
|
this.onProblem = options.onProblem ?? (() => undefined);
|
|
97
|
+
this.onCommitmentReused = options.onCommitmentReused ?? (() => undefined);
|
|
76
98
|
}
|
|
77
99
|
/** 生成新码(旧码立即失效)并通知调用方打印,返回显示形式 */
|
|
78
100
|
issueCode(reason, origin = null) {
|
|
@@ -152,6 +174,59 @@ export class PairingManager {
|
|
|
152
174
|
this.issueCode('too_many_failures', origin);
|
|
153
175
|
return { ok: false, code: 'pairing_code_rotated' };
|
|
154
176
|
}
|
|
177
|
+
/** 挂上启动命令里的承诺,返回它的过期时刻。格式不对时抛出:由 `--pair` 的参数解析先挡掉。 */
|
|
178
|
+
armCommitment(commitment) {
|
|
179
|
+
if (!isPairCommitment(commitment))
|
|
180
|
+
throw new Error('invalid pairing commitment');
|
|
181
|
+
const armedAt = this.now();
|
|
182
|
+
this.commitment = { value: Buffer.from(commitment, 'base64url'), armedAt };
|
|
183
|
+
return armedAt + CODE_TTL_MS;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* 页面在 hello 里出示 S。对不上不计入错码次数:S 有 32 字节、承诺有 128 bit,不存在可猜性,
|
|
187
|
+
* 计入只会让多标签页、旧 S 这类无害的不匹配把用户锁住。锁定期间一律不成立 —— 「锁定只能重启解除」不设例外。
|
|
188
|
+
* 签发 token 写盘失败时不抛出(hello 里抛出会以 1011 关闭,等待中的页面会反复撞上)、不消费承诺,交给调用方按未配对处理。
|
|
189
|
+
*/
|
|
190
|
+
pairWithSecret(secret, origin) {
|
|
191
|
+
const commitment = this.commitment;
|
|
192
|
+
// 重现检查在锁定检查之前:否则抢先配对的一方再用错码把 bridge 锁住,就能压掉这次告警(review r2 S1)
|
|
193
|
+
if (!commitment)
|
|
194
|
+
return this.checkReused(secret, origin);
|
|
195
|
+
if (this.locked)
|
|
196
|
+
return { ok: false, reason: 'locked' };
|
|
197
|
+
if (this.now() - commitment.armedAt > CODE_TTL_MS) {
|
|
198
|
+
this.commitment = null;
|
|
199
|
+
return { ok: false, reason: 'expired' };
|
|
200
|
+
}
|
|
201
|
+
if (!PAIR_SECRET_RE.test(secret))
|
|
202
|
+
return { ok: false, reason: 'malformed' };
|
|
203
|
+
if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), commitment.value))
|
|
204
|
+
return { ok: false, reason: 'mismatch' };
|
|
205
|
+
let token;
|
|
206
|
+
try {
|
|
207
|
+
token = this.createToken(origin);
|
|
208
|
+
}
|
|
209
|
+
catch (error) {
|
|
210
|
+
this.onProblem(`Could not save the pairing (${errorText(error)}). This page will be asked for a pairing code instead.`);
|
|
211
|
+
return { ok: false, reason: 'store_failed' };
|
|
212
|
+
}
|
|
213
|
+
this.commitment = null;
|
|
214
|
+
this.consumed = { value: commitment.value, origin, usedAt: this.now(), warned: false };
|
|
215
|
+
this.failuresSinceSuccess = 0;
|
|
216
|
+
return { ok: true, token };
|
|
217
|
+
}
|
|
218
|
+
checkReused(secret, origin) {
|
|
219
|
+
const consumed = this.consumed;
|
|
220
|
+
if (!consumed || !PAIR_SECRET_RE.test(secret))
|
|
221
|
+
return { ok: false, reason: 'no_commitment' };
|
|
222
|
+
if (!timingSafeEqual(sha256(secret).subarray(0, COMMITMENT_BYTES), consumed.value))
|
|
223
|
+
return { ok: false, reason: 'no_commitment' };
|
|
224
|
+
if (!consumed.warned) {
|
|
225
|
+
consumed.warned = true;
|
|
226
|
+
this.onCommitmentReused({ origin, firstOrigin: consumed.origin, usedAt: consumed.usedAt });
|
|
227
|
+
}
|
|
228
|
+
return { ok: false, reason: 'reused' };
|
|
229
|
+
}
|
|
155
230
|
createToken(origin) {
|
|
156
231
|
const token = randomBytes(32).toString('base64url');
|
|
157
232
|
const nowIso = new Date(this.now()).toISOString();
|
package/dist/bridge/server.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* 都由页面发出)。所以安全的重心在「谁能配对」,配对之后能做的事只做纵深防御:
|
|
8
8
|
* - 只监听 127.0.0.1;upgrade 校验(对端、Host、Origin、路径)在完成握手之前,失败直接写 HTTP 状态并销毁 socket。
|
|
9
9
|
* - 握手后第一条消息必须是 hello;未配对的连接只能调用 pair,其余已知方法与 acp 帧一律 not_paired。
|
|
10
|
+
* 连接只有两种方式成为已配对:hello 带有效 token,或 hello 的 pairSecret 与启动命令的承诺一致(pairing.ts 文件头)。
|
|
10
11
|
* - bridge 自己的方法里,页面可控的参数只有 agent id(枚举校验)、配对码、processId —— 进不了 spawn 的命令、参数、cwd、环境。
|
|
11
12
|
* - 页面发给 agent 的 ACP 行禁止含换行,并经 acp-guard.ts 检查:只能用白名单里的方法、会话目录固定为 bridge 的 cwd、不能让 agent 额外拉起 MCP 服务器。
|
|
12
13
|
*
|
|
@@ -159,7 +160,19 @@ export function startBridgeServer(options) {
|
|
|
159
160
|
clearTimeout(conn.timer);
|
|
160
161
|
conn.timer = null;
|
|
161
162
|
const token = typeof frame.token === 'string' ? frame.token : undefined;
|
|
162
|
-
|
|
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
|
+
}
|
|
163
176
|
if (paired) {
|
|
164
177
|
markPaired(conn);
|
|
165
178
|
}
|
|
@@ -182,6 +195,7 @@ export function startBridgeServer(options) {
|
|
|
182
195
|
paired,
|
|
183
196
|
tokenRejected: token !== undefined && !paired,
|
|
184
197
|
cwd: paired ? options.cwd : null,
|
|
198
|
+
...(issuedToken ? { token: issuedToken } : {}),
|
|
185
199
|
});
|
|
186
200
|
}
|
|
187
201
|
function onFrame(conn, data, isBinary) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@m2canvas/connect",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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": {
|