@tunnelbox/cursor 0.1.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.
Files changed (3) hide show
  1. package/README.md +79 -0
  2. package/dist/index.mjs +935 -0
  3. package/package.json +30 -0
package/README.md ADDED
@@ -0,0 +1,79 @@
1
+ # @tunnelbox/cursor
2
+
3
+ tunnelbox 的 **Cursor 适配器**(独立进程,驱动 **Cursor CLI** 的 headless 模式):手机远程驱动电脑上的 Cursor agent(会话/流式/中止/删除),复用 `@tunnelbox/core`(RelayClient/状态/二维码)。
4
+
5
+ ```
6
+ 手机 PWA ──WSS──► relay ──WSS──► 本适配器(电脑上常驻 Node 进程)
7
+ └─ agent -p --output-format stream-json --stream-partial-output
8
+ ```
9
+
10
+ > 说明:Cursor 官方提供了 headless CLI(`agent -p`,见 https://cursor.com/docs/cli ),
11
+ > 本适配器即其"CLI 外部驱动"桥接(仓库原列为 C 类/IDE 型,落地路径取官方 agent CLI)。
12
+
13
+ ## 前置条件
14
+
15
+ - Node.js ≥ 22;
16
+ - 已安装 **Cursor CLI** 并登录/配置 API key:
17
+ - macOS/Linux:`curl https://cursor.com/install -fsS | bash`
18
+ - Windows:`irm 'https://cursor.com/install?win32=true' | iex`
19
+ - `agent -p "hello"` 可正常输出(headless 走 Cursor 计费,需在计划内)。
20
+
21
+ ## 构建与运行
22
+
23
+ ```bash
24
+ cd plugin && npm install # workspaces 根(含 cursor)
25
+ cd cursor && npm run build
26
+ node dist/index.mjs # 或 npm start
27
+ ```
28
+
29
+ ## 配置
30
+
31
+ | 环境变量 | 默认 | 说明 |
32
+ |---|---|---|
33
+ | `TUNNELBOX_RELAY_URL` | state 保存地址 | 中继地址 |
34
+ | `TUNNELBOX_CWD` | `process.cwd()` | 默认工作区(agent 在该目录跑) |
35
+ | `TUNNELBOX_CURSOR_MODE` | `agent` | `agent` / `plan` / `ask`(`--mode`) |
36
+ | `TUNNELBOX_CURSOR_MODEL` | Cursor 默认 | `--model` |
37
+ | `TUNNELBOX_CURSOR_FORCE` | 关 | 加 `--force`:允许直接改文件(无确认) |
38
+ | `TUNNELBOX_CURSOR_YOLO` | 关 | 加 `--yolo`(更强放行,危险,慎用) |
39
+ | `TUNNELBOX_CURSOR_SANDBOX` | `enabled` | `--sandbox enabled/disabled` |
40
+ | `TUNNELBOX_CURSOR_BIN` | `agent` | Cursor CLI 可执行名/绝对路径 |
41
+
42
+ ```bash
43
+ TUNNELBOX_RELAY_URL=wss://chat.example.com TUNNELBOX_CURSOR_MODE=plan TUNNELBOX_CURSOR_FORCE=1 node dist/index.mjs
44
+ ```
45
+
46
+ ## 能力位
47
+
48
+ | 能力 | 值 | 说明 |
49
+ |---|---|---|
50
+ | streaming | true | stream-json:assistant 增量行(timestamp_ms)→ delta;整块行(model_call_id)→ 收尾 |
51
+ | thinking | true | 流式推理文本(如输出 agent_reasoning)→ thinking |
52
+ | permission | **false** | `agent -p` 无审批回调;放行由 `--force/--yolo` + `--sandbox` 决定(默认只读式沙箱) |
53
+ | commands | true | `/new` `/help` |
54
+ | abort | true | kill 子进程(SIGTERM → SIGKILL) |
55
+
56
+ ## 会话模型
57
+
58
+ - 会话 = 本地镜像(`~/.config/opencode/cursor-sessions/<uuid>/`):每个流式部件/用户消息即时落一行,供列表/历史/删除。
59
+ - 续聊:尽力从流中采集 Cursor chat id(`meta.resumeId`)→ 后续消息用 `--resume=<id>`;采集不到则该会话每次为独立问答(连续上下文受限)。
60
+ - 状态文件:`~/.config/opencode/remote-state.cursor.json`。
61
+
62
+ ## 目录结构
63
+
64
+ ```
65
+ plugin/cursor/
66
+ ├── package.json / tsconfig.json
67
+ ├── scripts/build.mjs / smoke.mjs
68
+ └── src/
69
+ ├── index.ts # env 解析 + 连接中继
70
+ ├── bridge.ts # relay/配对/消息路由/回合/镜像写入
71
+ ├── runner.ts # agent -p stream-json 子进程(增量解析 + tool_call + kill)
72
+ ├── mirror.ts # 会话镜像存储(列表/历史/删除/meta)
73
+ └── globals.d.ts
74
+ ```
75
+
76
+ ## 备注
77
+
78
+ - 未验证项(需装有 Cursor CLI 实测校正):stream-json 行的确切字段(assistant 增量/整块、tool_call 形态、可 resume 的 chat id 出现在哪个事件)、`--force/--yolo` 与 `--sandbox` 组合的放行语义、`--mode` 参数名。解析集中在 `runner.ts`,便于按实际输出调整。
79
+ - 安全:默认不传 `--force`(agent 不改文件、只出方案),并开 `--sandbox=enabled`;要实际改代码需显式 `TUNNELBOX_CURSOR_FORCE=1`。请勿在不受信目录启用 yolo。
package/dist/index.mjs ADDED
@@ -0,0 +1,935 @@
1
+ // tunnelbox cursor adapter — built by scripts/build.mjs
2
+
3
+ // src/index.ts
4
+ import { resolve as resolve2 } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // src/bridge.ts
8
+ import { randomBytes as randomBytes2 } from "node:crypto";
9
+ import { existsSync as existsSync3, statSync as statSync2 } from "node:fs";
10
+ import { hostname } from "node:os";
11
+ import { resolve } from "node:path";
12
+
13
+ // ../core/src/types.ts
14
+ function envelope(type, payload, id) {
15
+ return { id, type, payload, ts: Date.now() };
16
+ }
17
+
18
+ // ../core/src/relay.ts
19
+ var BASE_DELAY = 1e3;
20
+ var MAX_DELAY = 15e3;
21
+ var RelayClient = class {
22
+ ws = null;
23
+ url = "";
24
+ headers = {};
25
+ handlers = null;
26
+ timer = null;
27
+ retry = 0;
28
+ stopped = false;
29
+ connected = false;
30
+ get isConnected() {
31
+ return this.connected;
32
+ }
33
+ connect(url, headers, handlers) {
34
+ this.url = url;
35
+ this.headers = headers;
36
+ this.handlers = handlers;
37
+ this.stopped = false;
38
+ this.retry = 0;
39
+ this.open();
40
+ }
41
+ send(env) {
42
+ if (this.ws && this.connected) {
43
+ try {
44
+ this.ws.send(JSON.stringify(env));
45
+ return true;
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+ return false;
51
+ }
52
+ close() {
53
+ this.stopped = true;
54
+ if (this.timer) clearTimeout(this.timer);
55
+ this.timer = null;
56
+ if (this.ws) {
57
+ try {
58
+ this.ws.close();
59
+ } catch {
60
+ }
61
+ }
62
+ this.ws = null;
63
+ this.connected = false;
64
+ }
65
+ open() {
66
+ if (this.stopped) return;
67
+ try {
68
+ const ws = new WebSocket(this.url, { headers: this.headers });
69
+ this.ws = ws;
70
+ ws.onopen = () => {
71
+ this.connected = true;
72
+ this.retry = 0;
73
+ this.handlers?.onOpen();
74
+ };
75
+ ws.onmessage = (ev) => {
76
+ try {
77
+ const msg = JSON.parse(String(ev.data));
78
+ this.handlers?.onMessage(msg);
79
+ } catch {
80
+ }
81
+ };
82
+ ws.onerror = (e) => this.handlers?.onError(e);
83
+ ws.onclose = () => {
84
+ this.connected = false;
85
+ this.ws = null;
86
+ this.handlers?.onClose();
87
+ this.scheduleReconnect();
88
+ };
89
+ } catch (e) {
90
+ this.handlers?.onError(e);
91
+ this.scheduleReconnect();
92
+ }
93
+ }
94
+ scheduleReconnect() {
95
+ if (this.stopped) return;
96
+ const delay = Math.min(BASE_DELAY * 2 ** this.retry, MAX_DELAY);
97
+ this.retry++;
98
+ this.timer = setTimeout(() => this.open(), delay);
99
+ }
100
+ };
101
+
102
+ // ../core/src/state.ts
103
+ import { randomBytes } from "node:crypto";
104
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
105
+ import { homedir } from "node:os";
106
+ import { dirname, join } from "node:path";
107
+ var DEFAULT_RELAY_URL = process.env.TUNNELBOX_RELAY_URL || (process.env.NODE_ENV === "production" ? "wss://chat.wxngrok.com" : "ws://127.0.0.1:8080");
108
+ function baseName() {
109
+ return join(homedir(), ".config", "opencode");
110
+ }
111
+ function suffix(type) {
112
+ return type ? `.${type}` : "";
113
+ }
114
+ function statePath(type) {
115
+ return join(baseName(), `remote-state${suffix(type)}.json`);
116
+ }
117
+ function saveState(st, type) {
118
+ try {
119
+ const p = statePath(type);
120
+ mkdirSync(dirname(p), { recursive: true });
121
+ writeFileSync(p, JSON.stringify(st, null, 2), "utf8");
122
+ } catch (e) {
123
+ console.error("[tunnelbox] \u4FDD\u5B58\u72B6\u6001\u5931\u8D25", e);
124
+ }
125
+ }
126
+ function loadState(type) {
127
+ const fallback = {
128
+ agentId: randomBytes(16).toString("hex"),
129
+ relayUrl: DEFAULT_RELAY_URL
130
+ };
131
+ try {
132
+ if (!existsSync(statePath(type))) {
133
+ saveState(fallback, type);
134
+ return fallback;
135
+ }
136
+ const parsed = JSON.parse(readFileSync(statePath(type), "utf8"));
137
+ if (parsed.agentId && parsed.agentId.length >= 16) {
138
+ return {
139
+ agentId: parsed.agentId,
140
+ relayUrl: parsed.relayUrl || DEFAULT_RELAY_URL,
141
+ claimed: parsed.claimed === true
142
+ };
143
+ }
144
+ saveState(fallback, type);
145
+ return fallback;
146
+ } catch {
147
+ saveState(fallback, type);
148
+ return fallback;
149
+ }
150
+ }
151
+ function writePairingFile(link, code, type) {
152
+ const p = join(baseName(), `remote-pairing${suffix(type)}.txt`);
153
+ try {
154
+ mkdirSync(dirname(p), { recursive: true });
155
+ writeFileSync(p, `\u914D\u5BF9\u7801: ${code}
156
+ \u914D\u5BF9\u94FE\u63A5: ${link}
157
+ `, "utf8");
158
+ } catch {
159
+ }
160
+ return p;
161
+ }
162
+ function writePairingJson(info, type) {
163
+ const p = join(baseName(), `remote-pairing${suffix(type)}.json`);
164
+ try {
165
+ mkdirSync(dirname(p), { recursive: true });
166
+ writeFileSync(p, JSON.stringify(info, null, 2), "utf8");
167
+ } catch {
168
+ }
169
+ return p;
170
+ }
171
+
172
+ // ../core/src/qr.ts
173
+ async function printPairing(relayHttpBase, code, type, name) {
174
+ const p = new URLSearchParams({ code });
175
+ if (type) p.set("type", type);
176
+ if (name) p.set("name", name);
177
+ const link = `${relayHttpBase}/app/#/pages/index/index?${p.toString()}`;
178
+ const file = writePairingFile(link, code, type);
179
+ const tag = type ? ` ${type}` : "";
180
+ console.log("");
181
+ console.log("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
182
+ console.log(`\u2502 tunnelbox${tag} \u624B\u673A\u914D\u5BF9 \u2502`);
183
+ console.log("\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524");
184
+ console.log(`\u2502 \u914D\u5BF9\u7801: ${code.padEnd(42)} \u2502`);
185
+ console.log(`\u2502 \u94FE\u63A5 : ${link.padEnd(42)} \u2502`);
186
+ console.log("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518");
187
+ console.log(" \u624B\u673A\u6D4F\u89C8\u5668\u6253\u5F00\u94FE\u63A5\uFF0C\u6216\u6253\u5F00 App \u540E\u624B\u52A8\u8F93\u5165\u914D\u5BF9\u7801\u3002");
188
+ try {
189
+ const mod = await import("qrcode");
190
+ const render = mod.default ?? mod;
191
+ const toString = render.toString;
192
+ if (toString) {
193
+ const qr = await toString(link, { type: "terminal", small: true });
194
+ if (qr) console.log(qr);
195
+ }
196
+ } catch {
197
+ console.log(" \uFF08\u672A\u5B89\u88C5 qrcode\uFF0C\u8BF7\u4F7F\u7528\u4E0A\u65B9\u94FE\u63A5\u6216\u914D\u5BF9\u7801\uFF09");
198
+ }
199
+ console.log(` \u914D\u5BF9\u4FE1\u606F\u5DF2\u5199\u5165: ${file}`);
200
+ console.log("");
201
+ }
202
+
203
+ // src/runner.ts
204
+ import { spawn } from "node:child_process";
205
+ function rec(v) {
206
+ return v && typeof v === "object" ? v : {};
207
+ }
208
+ function parseLine(line) {
209
+ const s = line.trim();
210
+ if (!s) return null;
211
+ try {
212
+ return rec(JSON.parse(s));
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+ function toolNameAndArgs(tool) {
218
+ const o = rec(tool);
219
+ for (const key of Object.keys(o)) {
220
+ const sub = rec(o[key]);
221
+ const innerName = typeof sub.name === "string" ? sub.name : "";
222
+ const name = innerName || key.replace(/ToolCall$/, "").replace(/[A-Z]/g, (c) => "_" + c.toLowerCase()).replace(/^_/, "") || key;
223
+ const rawArgs = sub.args ?? sub.input ?? sub.parameters;
224
+ let args = "";
225
+ if (rawArgs !== void 0 && rawArgs !== null) {
226
+ try {
227
+ args = JSON.stringify(rawArgs, null, 2);
228
+ } catch {
229
+ args = String(rawArgs);
230
+ }
231
+ }
232
+ return { name, args };
233
+ }
234
+ return { name: "tool", args: "" };
235
+ }
236
+ var CursorProcess = class {
237
+ constructor(displaySessionID, hooks) {
238
+ this.displaySessionID = displaySessionID;
239
+ this.hooks = hooks;
240
+ }
241
+ child = null;
242
+ killed = false;
243
+ textId = null;
244
+ toolSeq = 0;
245
+ turnSeq = 0;
246
+ emitCount = 0;
247
+ tail = "";
248
+ exitCode = null;
249
+ capturedResumeId = null;
250
+ get resumeId() {
251
+ return this.capturedResumeId;
252
+ }
253
+ get produced() {
254
+ return this.emitCount > 0;
255
+ }
256
+ start(opts, text, resumeId) {
257
+ return new Promise((resolve3, reject) => {
258
+ let stderrBuf = "";
259
+ let settled = false;
260
+ const done = (err) => {
261
+ if (settled) return;
262
+ settled = true;
263
+ if (err) reject(err);
264
+ else resolve3();
265
+ };
266
+ const bin = opts.bin || "agent";
267
+ const args = ["-p", "--output-format", "stream-json", "--stream-partial-output"];
268
+ if (opts.model) args.push("--model", opts.model);
269
+ if (opts.mode && opts.mode !== "agent") args.push("--mode", opts.mode);
270
+ if (opts.force) args.push("--force");
271
+ if (opts.yolo) args.push("--yolo");
272
+ args.push("--sandbox", opts.sandbox || "enabled");
273
+ if (resumeId) args.push(`--resume=${resumeId}`);
274
+ args.push(text);
275
+ let child;
276
+ try {
277
+ child = spawn(bin, args, { cwd: opts.cwd, env: process.env, stdio: ["ignore", "pipe", "pipe"] });
278
+ } catch (e) {
279
+ done(new Error(`\u65E0\u6CD5\u542F\u52A8 Cursor CLI\uFF08agent\uFF09\uFF1A\u8BF7\u786E\u8BA4\u5DF2\u5B89\u88C5\u5E76\u767B\u5F55\uFF08${e.message}\uFF09`));
280
+ return;
281
+ }
282
+ this.child = child;
283
+ child.stdout?.setEncoding("utf8");
284
+ child.stdout?.on("data", (chunk) => this.onChunk(chunk));
285
+ child.stderr?.setEncoding("utf8");
286
+ child.stderr?.on("data", (chunk) => {
287
+ stderrBuf += chunk;
288
+ for (const l of chunk.split("\n")) {
289
+ const s = l.trim();
290
+ if (s && /error|login|auth|denied|key|not (found|installed)|permission/i.test(s)) {
291
+ this.hooks.log(`cursor: ${s.slice(0, 300)}`);
292
+ }
293
+ }
294
+ });
295
+ child.on("error", (e) => {
296
+ if (!this.killed) done(new Error(`Cursor CLI \u542F\u52A8\u5931\u8D25: ${e.message}`));
297
+ });
298
+ child.on("close", (code, signal) => {
299
+ this.exitCode = code;
300
+ if (this.killed || signal) {
301
+ done();
302
+ return;
303
+ }
304
+ if (code !== 0) {
305
+ const err = stderrBuf.trim().split("\n").pop() || `Cursor CLI \u9000\u51FA\u7801 ${code}`;
306
+ this.emitError(`Cursor CLI \u9000\u51FA(${code}): ${err.slice(0, 500)}`);
307
+ }
308
+ done();
309
+ });
310
+ });
311
+ }
312
+ kill() {
313
+ this.killed = true;
314
+ if (this.child && this.child.exitCode === null) {
315
+ try {
316
+ this.child.kill("SIGTERM");
317
+ } catch {
318
+ }
319
+ setTimeout(() => {
320
+ try {
321
+ if (this.child && this.child.exitCode === null) this.child.kill("SIGKILL");
322
+ } catch {
323
+ }
324
+ }, 1500);
325
+ }
326
+ }
327
+ buf = "";
328
+ onChunk(chunk) {
329
+ this.buf += chunk;
330
+ let idx;
331
+ while ((idx = this.buf.indexOf("\n")) >= 0) {
332
+ const line = this.buf.slice(0, idx);
333
+ this.buf = this.buf.slice(idx + 1);
334
+ this.onLine(line);
335
+ }
336
+ }
337
+ nextTextId() {
338
+ return `m-${Date.now()}-${++this.turnSeq}`;
339
+ }
340
+ emit(sessionID, messageID, part) {
341
+ this.emitCount++;
342
+ this.hooks.emit(sessionID, messageID, part);
343
+ }
344
+ emitError(text) {
345
+ this.emit(this.displaySessionID, `m-err-${Date.now()}`, { id: "err", type: "text", text: `[cursor] ${text}`, complete: true });
346
+ this.hooks.status(this.displaySessionID, "error");
347
+ }
348
+ /** 尽力采集可 resume 的会话标识 */
349
+ maybeCaptureId(o) {
350
+ if (this.capturedResumeId) return;
351
+ const candidates = [];
352
+ const walk = (v) => {
353
+ if (typeof v === "string") {
354
+ if (v.length >= 8 && /^[0-9a-zA-Z_-]+$/.test(v)) candidates.push(v);
355
+ return;
356
+ }
357
+ if (Array.isArray(v)) {
358
+ for (const x of v) walk(x);
359
+ return;
360
+ }
361
+ if (v && typeof v === "object") {
362
+ for (const [k, val] of Object.entries(v)) {
363
+ if (/(^id$|session|chat|thread|conversation)/i.test(k)) walk(val);
364
+ }
365
+ }
366
+ };
367
+ walk(o);
368
+ const hit = candidates.find((c) => c.length >= 8);
369
+ if (hit) {
370
+ this.capturedResumeId = hit;
371
+ this.hooks.onResumeId?.(hit);
372
+ }
373
+ }
374
+ onLine(line) {
375
+ const o = parseLine(line);
376
+ if (!o) return;
377
+ const type = String(o.type ?? "");
378
+ const subtype = typeof o.subtype === "string" ? o.subtype : "";
379
+ if (type === "assistant") {
380
+ const hasTs = Object.prototype.hasOwnProperty.call(o, "timestamp_ms");
381
+ const hasMc = Object.prototype.hasOwnProperty.call(o, "model_call_id");
382
+ const msg = rec(o.message);
383
+ const content = msg.content;
384
+ let text = "";
385
+ if (Array.isArray(content)) {
386
+ const first = rec(content[0]);
387
+ if (typeof first.text === "string") text = first.text;
388
+ } else if (typeof msg.text === "string") {
389
+ text = msg.text;
390
+ }
391
+ if (hasTs && !hasMc) {
392
+ if (text) {
393
+ if (!this.textId) this.textId = this.nextTextId();
394
+ this.emit(this.displaySessionID, this.textId, { id: "t", type: "text", delta: text, complete: false });
395
+ }
396
+ return;
397
+ }
398
+ if (hasMc) {
399
+ if (this.textId) {
400
+ this.emit(this.displaySessionID, this.textId, { id: "t", type: "text", complete: true });
401
+ this.textId = null;
402
+ }
403
+ return;
404
+ }
405
+ if (text) {
406
+ this.emit(this.displaySessionID, this.nextTextId(), { id: "t", type: "text", text, complete: true });
407
+ }
408
+ return;
409
+ }
410
+ if (type === "tool_call") {
411
+ const call = o.tool_call;
412
+ const { name, args } = toolNameAndArgs(call);
413
+ const key = `mt-${++this.toolSeq}`;
414
+ if (subtype === "started") {
415
+ this.emit(this.displaySessionID, key, { id: "c", type: "tool", tool: name, args: args || "{}", complete: false });
416
+ } else {
417
+ this.emit(this.displaySessionID, key, { id: "c", type: "tool", tool: name, args: args || "{}", complete: true });
418
+ }
419
+ return;
420
+ }
421
+ if (type === "result") {
422
+ const out = o.output ?? o.result ?? o.finalAnswer;
423
+ if (typeof out === "string" && out) this.tail = out;
424
+ return;
425
+ }
426
+ if (type === "error") {
427
+ const msg = o.error ?? o.message;
428
+ this.emitError(typeof msg === "string" ? msg : "Cursor \u51FA\u9519");
429
+ return;
430
+ }
431
+ this.maybeCaptureId(o);
432
+ }
433
+ };
434
+
435
+ // src/mirror.ts
436
+ import {
437
+ existsSync as existsSync2,
438
+ mkdirSync as mkdirSync2,
439
+ readFileSync as readFileSync2,
440
+ readdirSync,
441
+ rmSync,
442
+ statSync,
443
+ writeFileSync as writeFileSync2
444
+ } from "node:fs";
445
+ import { homedir as homedir2 } from "node:os";
446
+ import { join as join2 } from "node:path";
447
+ function root() {
448
+ return join2(homedir2(), ".config", "opencode", "cursor-sessions");
449
+ }
450
+ function dirOf(sessionID) {
451
+ return join2(root(), sessionID);
452
+ }
453
+ var FILE = "session.jsonl";
454
+ var META = "meta.json";
455
+ function readMeta(sessionID) {
456
+ try {
457
+ const p = join2(dirOf(sessionID), META);
458
+ if (existsSync2(p)) return JSON.parse(readFileSync2(p, "utf8"));
459
+ } catch {
460
+ }
461
+ return null;
462
+ }
463
+ function getMeta(sessionID) {
464
+ return readMeta(sessionID);
465
+ }
466
+ function writeMeta(sessionID, meta) {
467
+ try {
468
+ mkdirSync2(dirOf(sessionID), { recursive: true });
469
+ writeFileSync2(join2(dirOf(sessionID), META), JSON.stringify(meta, null, 2), "utf8");
470
+ } catch {
471
+ }
472
+ }
473
+ function newSessionId() {
474
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
475
+ return `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
476
+ }
477
+ function saveMeta(sessionID, patch) {
478
+ const prev = readMeta(sessionID) || { title: "\u65B0\u4F1A\u8BDD", created: Date.now(), updated: Date.now() };
479
+ const meta = { ...prev, ...patch, updated: Date.now() };
480
+ writeMeta(sessionID, meta);
481
+ return meta;
482
+ }
483
+ function appendPart(sessionID, msgId, role, part, created = Date.now()) {
484
+ try {
485
+ const d = dirOf(sessionID);
486
+ mkdirSync2(d, { recursive: true });
487
+ const line = JSON.stringify({ id: msgId, role, parts: [part], created });
488
+ writeFileSync2(join2(d, FILE), line + "\n", { encoding: "utf8", flag: "a" });
489
+ if (role === "user" && part.type === "text" && part.text) {
490
+ const meta = readMeta(sessionID);
491
+ if (!meta || meta.title === "\u65B0\u4F1A\u8BDD") {
492
+ const t = part.text.trim();
493
+ saveMeta(sessionID, { title: t.length > 30 ? `${t.slice(0, 30)}\u2026` : t });
494
+ }
495
+ }
496
+ saveMeta(sessionID, {});
497
+ } catch {
498
+ }
499
+ }
500
+ function exists(sessionID) {
501
+ return existsSync2(join2(dirOf(sessionID), FILE));
502
+ }
503
+ function walkDirs() {
504
+ try {
505
+ return readdirSync(root()).filter((n) => existsSync2(join2(root(), n, FILE)));
506
+ } catch {
507
+ return [];
508
+ }
509
+ }
510
+ async function listSessions() {
511
+ const out = [];
512
+ for (const id of walkDirs()) {
513
+ const meta = readMeta(id);
514
+ if (!meta) continue;
515
+ out.push({ id, title: meta.title, created: meta.created, updated: meta.updated, status: "idle", workspace: meta.cwd });
516
+ }
517
+ return out.sort((a, b) => b.updated - a.updated);
518
+ }
519
+ async function readHistory(sessionID) {
520
+ let lines;
521
+ try {
522
+ lines = readFileSync2(join2(dirOf(sessionID), FILE), "utf8").split("\n");
523
+ } catch {
524
+ return [];
525
+ }
526
+ const byId = /* @__PURE__ */ new Map();
527
+ const order = [];
528
+ for (const line of lines) {
529
+ const s = line.trim();
530
+ if (!s) continue;
531
+ try {
532
+ const o = JSON.parse(s);
533
+ if (!o.id || !Array.isArray(o.parts)) continue;
534
+ const hit = byId.get(o.id);
535
+ if (hit) {
536
+ hit.parts.push(...o.parts);
537
+ } else {
538
+ order.push(o.id);
539
+ byId.set(o.id, { id: o.id, role: o.role, parts: [...o.parts], created: o.created ?? Date.now() });
540
+ }
541
+ } catch {
542
+ }
543
+ }
544
+ return order.map((id) => byId.get(id)).filter((m) => m.parts.length);
545
+ }
546
+ async function removeSession(sessionID) {
547
+ try {
548
+ rmSync(dirOf(sessionID), { recursive: true, force: true });
549
+ } catch {
550
+ }
551
+ }
552
+
553
+ // src/bridge.ts
554
+ var VERSION = "0.1.0";
555
+ var AGENT_TYPE = "cursor";
556
+ var ADAPTER_COMMANDS = [
557
+ { name: "new", description: "\u65B0\u5EFA\u4F1A\u8BDD" },
558
+ { name: "help", description: "\u663E\u793A\u5E2E\u52A9" }
559
+ ];
560
+ var CursorBridge = class {
561
+ constructor(cfg) {
562
+ this.cfg = cfg;
563
+ this.currentWorkspace = resolve(cfg.cwd || process.cwd());
564
+ }
565
+ ws = new RelayClient();
566
+ state = loadState(AGENT_TYPE);
567
+ claimed = !!this.state.claimed;
568
+ everConnected = false;
569
+ relayBase = "";
570
+ currentWorkspace;
571
+ pendingWorkspaces = /* @__PURE__ */ new Map();
572
+ sessionCwd = /* @__PURE__ */ new Map();
573
+ activeRuns = /* @__PURE__ */ new Map();
574
+ get relayHttpBase() {
575
+ return this.relayBase.replace(/^ws/, "http").replace(/\/+$/, "");
576
+ }
577
+ async start() {
578
+ const url = (this.cfg.relayUrl || this.state.relayUrl || "").replace(/\/+$/, "");
579
+ this.relayBase = url;
580
+ this.connect(url);
581
+ }
582
+ dispose() {
583
+ this.ws.close();
584
+ for (const p of this.activeRuns.values()) p.kill();
585
+ this.activeRuns.clear();
586
+ }
587
+ log(msg) {
588
+ console.log(`[tunnelbox:cursor] ${msg}`);
589
+ }
590
+ send(e) {
591
+ return this.ws.send(e);
592
+ }
593
+ reply(src, type, payload) {
594
+ const msg = envelope(type, payload, src.id);
595
+ msg.clientID = src.clientID;
596
+ this.send(msg);
597
+ }
598
+ pairingLink(code) {
599
+ const p = new URLSearchParams({ code, type: AGENT_TYPE, name: hostname() });
600
+ return `${this.relayHttpBase}/app/#/pages/index/index?${p.toString()}`;
601
+ }
602
+ connect(url) {
603
+ const agentID = this.state.agentId;
604
+ const wsUrl = `${url}/ws/agent?token=${encodeURIComponent(agentID)}`;
605
+ this.ws.connect(
606
+ wsUrl,
607
+ { Authorization: `Bearer ${agentID}` },
608
+ {
609
+ onOpen: () => {
610
+ this.everConnected = true;
611
+ this.log("\u5DF2\u8FDE\u63A5\u4E2D\u7EE7 " + url);
612
+ this.announce();
613
+ },
614
+ onMessage: (env) => {
615
+ void this.onMessage(env).catch((e) => this.log(`\u6D88\u606F\u5904\u7406\u672A\u6355\u83B7\u5F02\u5E38: ${e.message}`));
616
+ },
617
+ onClose: () => {
618
+ if (this.everConnected) {
619
+ this.everConnected = false;
620
+ this.log("\u4E0E\u4E2D\u7EE7\u65AD\u5F00\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u8FDE\u2026");
621
+ }
622
+ },
623
+ onError: () => {
624
+ }
625
+ }
626
+ );
627
+ }
628
+ announce() {
629
+ const info = {
630
+ name: hostname(),
631
+ version: VERSION,
632
+ type: AGENT_TYPE,
633
+ capabilities: {
634
+ streaming: true,
635
+ thinking: true,
636
+ // Cursor 流式增量正文;推理如输出则进 thinking
637
+ permission: false,
638
+ // agent -p 无审批回调:放行由 --force/--yolo + --sandbox 决定
639
+ commands: true,
640
+ abort: true
641
+ // kill 子进程
642
+ },
643
+ platform: process.platform,
644
+ directory: this.currentWorkspace,
645
+ timezone: localTimezone()
646
+ };
647
+ this.send(envelope("agent.info", info));
648
+ if (!this.claimed) this.send(envelope("pair.create", {}));
649
+ }
650
+ // ---- 消息路由 ----
651
+ async onMessage(env) {
652
+ try {
653
+ switch (env.type) {
654
+ case "pair.created": {
655
+ const code = env.payload.code;
656
+ const link = this.pairingLink(code);
657
+ writePairingJson({ code, link, relayUrl: this.relayBase, at: Date.now() }, AGENT_TYPE);
658
+ this.log("\u751F\u6210\u914D\u5BF9\u7801: " + code);
659
+ void printPairing(this.relayHttpBase, code, AGENT_TYPE, hostname());
660
+ return;
661
+ }
662
+ case "agent.claimed":
663
+ this.claimed = true;
664
+ this.state.claimed = true;
665
+ saveState(this.state, AGENT_TYPE);
666
+ this.log("\u5DF2\u7ED1\u5B9A\u5230\u624B\u673A\u8D26\u53F7\uFF0C\u4E4B\u540E\u624B\u673A\u4ECE\u300C\u6211\u7684\u7535\u8111\u300D\u76F4\u63A5\u8FDE\u63A5");
667
+ return;
668
+ case "agent.revoked":
669
+ this.resetIdentity();
670
+ return;
671
+ case "session.list":
672
+ return this.sessionList(env);
673
+ case "session.create":
674
+ return this.sessionCreate(env);
675
+ case "session.messages":
676
+ return this.sessionMessages(env);
677
+ case "session.prompt":
678
+ return this.sessionPrompt(env);
679
+ case "session.abort":
680
+ return this.sessionAbort(env);
681
+ case "session.delete":
682
+ return this.sessionDelete(env);
683
+ case "command.list":
684
+ return this.commandList(env);
685
+ case "workspace.list":
686
+ return this.workspaceList(env);
687
+ case "workspace.set":
688
+ return this.workspaceSet(env);
689
+ case "error":
690
+ return;
691
+ default:
692
+ this.log("\u672A\u5904\u7406\u7684\u6D88\u606F\u7C7B\u578B: " + env.type);
693
+ }
694
+ } catch (e) {
695
+ this.log(`\u5904\u7406 ${env.type} \u5931\u8D25: ${e.message}`);
696
+ this.reply(env, "error", { code: "HANDLER_ERROR", message: e.message, id: env.id });
697
+ }
698
+ }
699
+ // ---- 会话 ----
700
+ async sessionList(env) {
701
+ const sessions = (await listSessions()).map((s) => ({
702
+ ...s,
703
+ status: this.activeRuns.has(s.id) ? "running" : s.status
704
+ }));
705
+ this.reply(env, "sessions", { sessions });
706
+ }
707
+ async sessionCreate(env) {
708
+ const payload = env.payload;
709
+ const workspace = payload?.workspace || this.currentWorkspace;
710
+ const id = newSessionId();
711
+ const now = Date.now();
712
+ saveMeta(id, { title: "\u65B0\u4F1A\u8BDD", cwd: workspace, created: now, updated: now });
713
+ this.pendingWorkspaces.set(id, workspace);
714
+ this.reply(env, "session.created", {
715
+ session: { id, title: "\u65B0\u4F1A\u8BDD", created: now, updated: now, status: "idle", workspace }
716
+ });
717
+ }
718
+ async sessionMessages(env) {
719
+ const { sessionID } = env.payload;
720
+ const messages = exists(sessionID) ? await readHistory(sessionID) : [];
721
+ this.reply(env, "messages", { sessionID, messages });
722
+ }
723
+ async sessionPrompt(env) {
724
+ const payload = env.payload;
725
+ const text = (payload.text || "").trim();
726
+ if (!text) return;
727
+ if (text.startsWith("/new")) return this.sessionCreate(env);
728
+ if (text.startsWith("/help")) {
729
+ this.reply(env, "message.part", {
730
+ sessionID: payload.sessionID,
731
+ messageID: `m-${Date.now()}`,
732
+ part: { id: "t", type: "text", text: "\u652F\u6301\u7684\u547D\u4EE4\uFF1A\n- /new \u65B0\u5EFA\u4F1A\u8BDD\n- /help \u663E\u793A\u5E2E\u52A9", complete: true }
733
+ });
734
+ return;
735
+ }
736
+ if (this.activeRuns.has(payload.sessionID)) {
737
+ this.reply(env, "error", { code: "BUSY", message: "\u8BE5\u4F1A\u8BDD\u6B63\u5728\u751F\u6210\u4E2D\uFF0C\u8BF7\u7B49\u5F85\u5B8C\u6210\u6216\u5148\u505C\u6B62", id: env.id });
738
+ return;
739
+ }
740
+ try {
741
+ await this.runTurn(payload.sessionID, text);
742
+ } catch (e) {
743
+ const msg = e.message || String(e);
744
+ this.log(`session.prompt \u5931\u8D25: ${msg}`);
745
+ this.reply(env, "error", { code: "PROMPT_ERROR", message: msg, id: env.id });
746
+ }
747
+ }
748
+ async sessionAbort(env) {
749
+ const { sessionID } = env.payload;
750
+ const p = this.activeRuns.get(sessionID);
751
+ if (p) {
752
+ p.kill();
753
+ this.log(`\u5DF2\u53D1\u9001\u4E2D\u6B62 (session=${sessionID})`);
754
+ }
755
+ this.send(envelope("session.status", { sessionID, status: "idle" }));
756
+ }
757
+ async sessionDelete(env) {
758
+ const { sessionID } = env.payload;
759
+ const p = this.activeRuns.get(sessionID);
760
+ if (p) p.kill();
761
+ this.activeRuns.delete(sessionID);
762
+ this.pendingWorkspaces.delete(sessionID);
763
+ this.sessionCwd.delete(sessionID);
764
+ await removeSession(sessionID);
765
+ this.reply(env, "session.deleted", { sessionID });
766
+ }
767
+ async commandList(env) {
768
+ this.reply(env, "commands", { commands: ADAPTER_COMMANDS });
769
+ }
770
+ // ---- 工作区(占位:仅当前目录,前端默认不显示) ----
771
+ async workspaceList(env) {
772
+ this.reply(env, "workspaces", {
773
+ workspaces: [{ path: this.currentWorkspace, name: this.currentWorkspace.split(/[\\/]/).pop() || this.currentWorkspace, sessionCount: 0, updated: 0 }],
774
+ current: this.currentWorkspace
775
+ });
776
+ }
777
+ async workspaceSet(env) {
778
+ const raw = (env.payload?.path || "").trim();
779
+ const path = raw ? resolve(raw) : "";
780
+ if (!path || !existsSync3(path) || !statSync2(path).isDirectory()) {
781
+ this.reply(env, "error", { code: "BAD_WORKSPACE", message: "\u8BE5\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u53EF\u7528", id: env.id });
782
+ return;
783
+ }
784
+ this.currentWorkspace = path;
785
+ this.log("\u5207\u6362\u5DE5\u4F5C\u533A: " + path);
786
+ this.announce();
787
+ await this.workspaceList(env);
788
+ }
789
+ // ---- 回合 ----
790
+ async runTurn(sessionID, text) {
791
+ const dir = this.pendingWorkspaces.get(sessionID) || this.sessionCwd.get(sessionID) || this.currentWorkspace;
792
+ if (!existsSync3(dir) || !statSync2(dir).isDirectory()) {
793
+ throw new Error(`\u5DE5\u4F5C\u533A\u4E0D\u5B58\u5728: ${dir}`);
794
+ }
795
+ this.pendingWorkspaces.delete(sessionID);
796
+ const now = Date.now();
797
+ appendPart(sessionID, `u-${now}`, "user", { id: "u", type: "text", text, complete: true }, now);
798
+ this.send(envelope("session.status", { sessionID, status: "running" }));
799
+ const meta = getMeta(sessionID);
800
+ const resumeId = meta?.resumeId || void 0;
801
+ const proc = new CursorProcess(sessionID, {
802
+ emit: (s, messageID, part) => {
803
+ this.send(envelope("message.part", { sessionID: s, messageID, part }));
804
+ appendPart(sessionID, messageID, "assistant", part);
805
+ },
806
+ status: (s, status) => this.send(envelope("session.status", { sessionID: s, status })),
807
+ log: (m) => this.log(m),
808
+ onResumeId: (id) => saveMeta(sessionID, { resumeId: id, cwd: dir })
809
+ });
810
+ this.activeRuns.set(sessionID, proc);
811
+ try {
812
+ await proc.start(
813
+ {
814
+ cwd: dir,
815
+ mode: this.cfg.mode,
816
+ model: this.cfg.model,
817
+ force: this.cfg.force,
818
+ yolo: this.cfg.yolo,
819
+ sandbox: this.cfg.sandbox,
820
+ bin: this.cfg.bin
821
+ },
822
+ text,
823
+ resumeId
824
+ );
825
+ if (!proc.produced && proc.tail) {
826
+ const id = `m-tail-${Date.now()}`;
827
+ this.send(envelope("message.part", { sessionID, messageID: id, part: { id: "t", type: "text", text: proc.tail, complete: true } }));
828
+ appendPart(sessionID, id, "assistant", { id: "t", type: "text", text: proc.tail, complete: true });
829
+ }
830
+ this.sessionCwd.set(sessionID, dir);
831
+ if (!getMeta(sessionID)) saveMeta(sessionID, { title: "Cursor \u4F1A\u8BDD", cwd: dir });
832
+ } catch (e) {
833
+ const msg = e.message || String(e);
834
+ this.log(`\u56DE\u5408\u51FA\u9519 (session=${sessionID}): ${msg}`);
835
+ const id = `m-err-${Date.now()}`;
836
+ const part = { id: "err", type: "text", text: `[cursor] ${msg}`, complete: true };
837
+ this.send(envelope("message.part", { sessionID, messageID: id, part }));
838
+ appendPart(sessionID, id, "assistant", part);
839
+ } finally {
840
+ this.activeRuns.delete(sessionID);
841
+ this.send(envelope("session.status", { sessionID, status: "idle" }));
842
+ void this.broadcastSessionUpdate(sessionID);
843
+ }
844
+ }
845
+ async broadcastSessionUpdate(sessionID) {
846
+ try {
847
+ if (!this.ws.isConnected) return;
848
+ const list = await listSessions().catch(() => []);
849
+ const found = list.find((s) => s.id === sessionID);
850
+ if (found) this.send(envelope("session.updated", { session: found }));
851
+ } catch {
852
+ }
853
+ }
854
+ resetIdentity() {
855
+ this.log("\u88AB\u8D26\u53F7\u89E3\u7ED1\uFF0C\u6B63\u5728\u91CD\u7F6E\u8EAB\u4EFD\u5E76\u91CD\u65B0\u751F\u6210\u914D\u5BF9\u7801\u2026");
856
+ this.claimed = false;
857
+ this.state.agentId = randomBytes2(16).toString("hex");
858
+ this.state.claimed = false;
859
+ saveState(this.state, AGENT_TYPE);
860
+ this.ws.close();
861
+ this.connect(this.relayBase);
862
+ }
863
+ };
864
+ function localTimezone() {
865
+ try {
866
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
867
+ if (tz) return tz;
868
+ } catch {
869
+ }
870
+ return "";
871
+ }
872
+
873
+ // src/index.ts
874
+ var MODES = /* @__PURE__ */ new Set(["agent", "plan", "ask"]);
875
+ var SANDBOXES = /* @__PURE__ */ new Set(["enabled", "disabled"]);
876
+ function envOn(name) {
877
+ return /^(1|true|yes|on)$/i.test(process.env[name] || "");
878
+ }
879
+ function parseMode(raw) {
880
+ const m = (raw || "").trim().toLowerCase();
881
+ if (m && !MODES.has(m)) {
882
+ console.warn(`[tunnelbox:cursor] \u5FFD\u7565\u65E0\u6548 TUNNELBOX_CURSOR_MODE: ${raw}\uFF08agent|plan|ask\uFF09`);
883
+ return void 0;
884
+ }
885
+ return m || void 0;
886
+ }
887
+ function parseSandbox(raw) {
888
+ const s = (raw || "").trim().toLowerCase();
889
+ if (s && SANDBOXES.has(s)) return s;
890
+ return "enabled";
891
+ }
892
+ function start(opts = {}) {
893
+ const cfg = {
894
+ relayUrl: (process.env.TUNNELBOX_RELAY_URL || opts.relayUrl || "").trim(),
895
+ cwd: process.env.TUNNELBOX_CWD || opts.cwd || process.cwd(),
896
+ mode: parseMode(process.env.TUNNELBOX_CURSOR_MODE || opts.mode),
897
+ model: process.env.TUNNELBOX_CURSOR_MODEL?.trim() || opts.model || void 0,
898
+ force: envOn("TUNNELBOX_CURSOR_FORCE") || !!opts.force,
899
+ yolo: envOn("TUNNELBOX_CURSOR_YOLO") || !!opts.yolo,
900
+ sandbox: parseSandbox(process.env.TUNNELBOX_CURSOR_SANDBOX || opts.sandbox),
901
+ bin: process.env.TUNNELBOX_CURSOR_BIN?.trim() || opts.bin || "agent"
902
+ };
903
+ const bridge = new CursorBridge(cfg);
904
+ void bridge.start().catch((e) => {
905
+ console.error("[tunnelbox:cursor] \u542F\u52A8\u5931\u8D25:", e);
906
+ });
907
+ let stopped = false;
908
+ return {
909
+ stop() {
910
+ if (stopped) return;
911
+ stopped = true;
912
+ bridge.dispose();
913
+ }
914
+ };
915
+ }
916
+ var isEntry = (() => {
917
+ try {
918
+ return !!process.argv[1] && resolve2(process.argv[1]) === fileURLToPath(import.meta.url);
919
+ } catch {
920
+ return false;
921
+ }
922
+ })();
923
+ if (isEntry) {
924
+ const { stop } = start();
925
+ const onSignal = () => {
926
+ stop();
927
+ process.exit(0);
928
+ };
929
+ process.once("SIGINT", onSignal);
930
+ process.once("SIGTERM", onSignal);
931
+ console.log("[tunnelbox:cursor] Cursor \u9002\u914D\u5668\u5DF2\u542F\u52A8\uFF08Ctrl+C \u9000\u51FA\uFF09");
932
+ }
933
+ export {
934
+ start
935
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@tunnelbox/cursor",
3
+ "version": "0.1.0",
4
+ "description": "tunnelbox 的 Cursor 适配器(C 类/B2 头less):手机远程驱动本机 Cursor CLI(agent -p stream-json,会话/流式/中止/删除)",
5
+ "type": "module",
6
+ "main": "./dist/index.mjs",
7
+ "files": [
8
+ "dist",
9
+ "README.md"
10
+ ],
11
+ "scripts": {
12
+ "type-check": "tsc --noEmit",
13
+ "build": "node scripts/build.mjs",
14
+ "start": "node dist/index.mjs",
15
+ "smoke": "node scripts/smoke.mjs"
16
+ },
17
+ "engines": {
18
+ "node": ">=22.0.0"
19
+ },
20
+ "dependencies": {
21
+ "qrcode": "^1.5.3"
22
+ },
23
+ "devDependencies": {
24
+ "@tunnelbox/core": "0.1.0",
25
+ "@types/node": "^22.0.0",
26
+ "@types/qrcode": "^1.5.5",
27
+ "esbuild": "^0.24.0",
28
+ "typescript": "^5.4.5"
29
+ }
30
+ }