@tunnelbox/codex 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 +88 -0
  2. package/dist/index.mjs +1136 -0
  3. package/package.json +30 -0
package/README.md ADDED
@@ -0,0 +1,88 @@
1
+ # @tunnelbox/codex
2
+
3
+ tunnelbox 的 **Codex 适配器**(B 类,独立进程):手机远程驱动电脑上的 Codex CLI(会话/流式/中止/删除/工作区),复用 `@tunnelbox/core`(RelayClient/状态/二维码)。
4
+
5
+ ```
6
+ 手机 PWA ──WSS──► relay ──WSS──► 本适配器(电脑上常驻 Node 进程)
7
+ └─ codex exec --json(每次消息拉起一个 codex 子进程)
8
+ ```
9
+
10
+ ## 前置条件
11
+
12
+ - Node.js ≥ 22;
13
+ - 本机已安装并登录 **Codex CLI**(`codex --version` 可用、已 `codex login` / 配置好 API key)。SDK 无关,直接驱动 `codex exec`。
14
+
15
+ ## 构建与运行
16
+
17
+ ```bash
18
+ cd plugin && npm install # workspaces 根(含 codex)
19
+ cd codex && npm run build # 产出 dist/index.mjs
20
+ node dist/index.mjs # 或 npm start
21
+ ```
22
+
23
+ ## 配置
24
+
25
+ | 环境变量 | 默认 | 说明 |
26
+ |---|---|---|
27
+ | `TUNNELBOX_RELAY_URL` | state 保存地址 | 中继地址(如 `wss://chat.example.com`) |
28
+ | `TUNNELBOX_CWD` | `process.cwd()` | 默认工作区;手机工作区选择器可切换 |
29
+ | `TUNNELBOX_CODEX_SANDBOX` | `workspace-write` | `read-only` / `workspace-write` / `danger-full-access`(传给 `codex exec --sandbox`) |
30
+ | `TUNNELBOX_CODEX_MODEL` | codex 默认 | `-m <model>` |
31
+ | `TUNNELBOX_CODEX_BIN` | `codex` | codex 可执行名/绝对路径 |
32
+
33
+ ```bash
34
+ TUNNELBOX_RELAY_URL=wss://chat.example.com TUNNELBOX_CODEX_SANDBOX=workspace-write node dist/index.mjs
35
+ ```
36
+
37
+ ## 能力位
38
+
39
+ | 能力 | 值 | 实现 |
40
+ |---|---|---|
41
+ | streaming | true | `codex exec --json` NDJSON 事件(response_item/item_updated 快照替换)→ `message.part` |
42
+ | thinking | true | `agent_reasoning` → thinking 部件(如有) |
43
+ | permission | **false** | `codex exec` 无可靠审批回调;工具权限由 codex 自身沙箱策略 **fail-closed** 处理(不在手机弹审批卡) |
44
+ | commands | true | `/new` `/help` |
45
+ | abort | true | kill 子进程(SIGTERM → SIGKILL) |
46
+
47
+ ## 会话模型
48
+
49
+ - **适配器新建会话 = UUID(alias)**:手机「新建会话」返回适配器生成的 UUID;首次 `codex exec` 产生的 transcript `session_id` 落盘到别名表 `~/.config/opencode/codex-aliases.json`,后续续聊用 `codex exec --resume <codexId>`(跨进程稳定)。
50
+ - **codex 原生会话**:终端/CLI 里开过的会话经 `CODEX_HOME/sessions`(默认 `~/.codex/sessions`)JSONL 扫描出现在列表,直接以原生 id resume。
51
+ - 历史 = 读 transcript JSONL 转 ChatMessage(含工具调用卡/tool 结果卡);删除会话删除对应 jsonl。
52
+ - 工作区 = 会话 `cwd`(`codex exec -C`);跨会话并行(每会话一个子进程),同一会话运行中禁止再发。
53
+
54
+ ## 安全说明
55
+
56
+ - `codex exec` 非交互、无审批回调:**capability.permission=false**,手机上不出现工具审批卡。放行策略完全由 `--sandbox` 决定:
57
+ - `read-only`:只读,不会写文件;
58
+ - `workspace-write`(默认):允许写工作区内,工作区外的写/网络等需更高策略;非交互下 codex 对需审批的操作按自身策略拒绝;
59
+ - `danger-full-access`:全放行(危险,仅可信工作区使用)。
60
+ - 请勿把敏感工程目录暴露给不受信手机。
61
+
62
+ ## 目录结构
63
+
64
+ ```
65
+ plugin/codex/
66
+ ├── package.json
67
+ ├── tsconfig.json
68
+ ├── scripts/build.mjs # esbuild 单文件 dist/index.mjs
69
+ ├── scripts/smoke.mjs # 冒烟:连本地中继验证配对流程(不驱动 codex)
70
+ └── src/
71
+ ├── index.ts # 入口:env 解析(sandbox/model/bin)+ 连接中继
72
+ ├── bridge.ts # relay 连接/配对/消息路由/回合/别名/工作区
73
+ ├── runner.ts # codex exec --json 子进程:NDJSON 流式解析 + kill
74
+ ├── parser.ts # codex 事件/transcript → 协议 Part/ChatMessage
75
+ ├── sessions.ts # transcript 扫描/历史/删除 + 别名表持久化
76
+ └── globals.d.ts # WebSocket 全局类型
77
+ ```
78
+
79
+ ## 与官方 Codex 插件的关系(决策记录)
80
+
81
+ - 本包是 **headless 桥(B 类独立进程)**,已承载 tunnelbox 的全部远程功能需求:会话列表、消息、**流镜像**、审批(codex 沙箱 fail-closed)、中止、删除、工作区。
82
+ - 官方 Codex 插件(Agent Plugins v1 / `.codex-plugin` legacy)只能提供"会话内注入"类能力(skills/MCP/hooks),**无法提供流镜像、跨会话列表与远程驱动**——它们不是插件体系能表达的能力。
83
+ - 结论:**codex 适配器不需要按官方插件规范改造**。未来如想在"本地 codex 会话内"提供配对/状态等子集,可另补 official-plugin overlay,不影响本桥。
84
+
85
+ ## 备注
86
+
87
+ - 状态文件独立:`~/.config/opencode/remote-state.codex.json`;别名表 `~/.config/opencode/codex-aliases.json`。
88
+ - 未验证项:需本机装有 codex CLI 后实测校正(`codex exec --json` 事件结构、`--resume` 对 cwd 的匹配、transcript 目录/文件名格式、init 事件 session_id 字段)——解析集中在 `parser.ts`/`runner.ts`,便于按实际输出调整。
package/dist/index.mjs ADDED
@@ -0,0 +1,1136 @@
1
+ // tunnelbox codex 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 { isAbsolute, 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
+
206
+ // src/parser.ts
207
+ function rec(v) {
208
+ return v && typeof v === "object" ? v : {};
209
+ }
210
+ function safeJson(v, indent = 2) {
211
+ if (v === void 0 || v === null) return "";
212
+ try {
213
+ return typeof v === "string" ? v : JSON.stringify(v, null, indent);
214
+ } catch {
215
+ return String(v);
216
+ }
217
+ }
218
+ function parseLine(line) {
219
+ const s = line.trim();
220
+ if (!s) return null;
221
+ try {
222
+ const o = JSON.parse(s);
223
+ return rec(o);
224
+ } catch {
225
+ return null;
226
+ }
227
+ }
228
+ function blockText(b) {
229
+ for (const k of ["input_text", "output_text", "text", "thinking", "text_delta"]) {
230
+ const v = b[k] ?? b[k.replace("_text", "")];
231
+ if (typeof v === "string") return v;
232
+ }
233
+ for (const k of ["input_text", "output_text", "text"]) {
234
+ const v = b[k];
235
+ if (v && typeof v === "object") {
236
+ const inner = rec(v).text ?? rec(v).input_text ?? rec(v).output_text;
237
+ if (typeof inner === "string") return inner;
238
+ }
239
+ }
240
+ return "";
241
+ }
242
+ function blockToPart(b, index) {
243
+ const blk = rec(b);
244
+ const id = `c${index}`;
245
+ const type = blk.type ?? blk.kind ?? "";
246
+ if (type === "tool_call" || type === "function_call" || type === "tool_use") {
247
+ const args = safeJson(blk.arguments ?? blk.input ?? blk.parameters ?? "") || "{}";
248
+ return { id, type: "tool", tool: String(blk.name ?? blk.function ?? "tool"), args, complete: true };
249
+ }
250
+ if (type === "tool_result" || type === "function_call_output") {
251
+ const text2 = blockText(blk) || String(blk.output ?? blk.content ?? "");
252
+ return text2 ? { id, type: "text", text: text2 } : null;
253
+ }
254
+ if (type === "reasoning" || type === "agent_reasoning" || type === "thinking") {
255
+ const text2 = blockText(blk) || String(blk.text ?? "");
256
+ return text2 ? { id, type: "thinking", text: text2 } : null;
257
+ }
258
+ const text = blockText(blk) || String(blk.content ?? "");
259
+ if (!text) return null;
260
+ return { id, type: "text", text };
261
+ }
262
+ var TEXT_TYPES = /* @__PURE__ */ new Set(["text", "input_text", "output_text", "text_delta"]);
263
+ function userText(block) {
264
+ return blockText(rec(block));
265
+ }
266
+ function lineToEntry(line) {
267
+ const o = parseLine(line);
268
+ if (!o) return null;
269
+ if (o.type !== "response_item" && o.type !== "message") {
270
+ if (o.type === "item_updated" || o.type === "item_completed") {
271
+ }
272
+ return null;
273
+ }
274
+ const p = rec(o.payload);
275
+ const role = String(p.role ?? "");
276
+ if (role !== "user" && role !== "assistant") return null;
277
+ const id = String(p.id ?? o.id ?? "");
278
+ if (!id) return null;
279
+ const content = p.content;
280
+ if (!Array.isArray(content)) return null;
281
+ const userParts = [];
282
+ const toolParts = [];
283
+ let textSeen = false;
284
+ content.forEach((b, i) => {
285
+ const blk = rec(b);
286
+ const t = String(blk.type ?? blk.kind ?? "");
287
+ if (t === "tool_result" || t === "function_call_output") {
288
+ const text = userText(b) || String(blk.output ?? "");
289
+ if (text) toolParts.push({ id: `r${i}`, type: "text", text });
290
+ return;
291
+ }
292
+ if (t === "tool_call" || t === "function_call") {
293
+ if (role === "assistant") {
294
+ const part = blockToPart(b, i);
295
+ if (part) userParts.push(part);
296
+ }
297
+ return;
298
+ }
299
+ if (TEXT_TYPES.has(t) || !t) {
300
+ const text = userText(b);
301
+ if (text && !textSeen) {
302
+ userParts.push({ id: `u${i}`, type: "text", text });
303
+ textSeen = true;
304
+ }
305
+ }
306
+ });
307
+ if (role === "user") {
308
+ if (userParts.length) return { id, kind: "user", parts: userParts };
309
+ return toolParts.length ? { id, kind: "tool", parts: toolParts } : null;
310
+ }
311
+ return userParts.length ? { id, kind: "assistant", parts: userParts } : null;
312
+ }
313
+ function sessionIdFrom(o) {
314
+ const direct = o.session_id ?? rec(o.payload).session_id;
315
+ if (typeof direct === "string" && direct) return direct;
316
+ const file = o.file ?? rec(o.payload).file;
317
+ if (typeof file === "string") {
318
+ const m = file.match(/([0-9a-f-]{8,})\.jsonl$/);
319
+ if (m) return m[1];
320
+ }
321
+ return null;
322
+ }
323
+
324
+ // src/runner.ts
325
+ function buildExecArgs(opts, text, resumeCodexId) {
326
+ const args = [
327
+ "exec",
328
+ "--json",
329
+ "--skip-git-repo-check",
330
+ "--sandbox",
331
+ opts.sandbox,
332
+ "-C",
333
+ opts.cwd
334
+ ];
335
+ if (opts.model) {
336
+ args.push("-m", opts.model);
337
+ }
338
+ if (resumeCodexId) {
339
+ args.push("--resume", resumeCodexId);
340
+ }
341
+ args.push(text);
342
+ return args;
343
+ }
344
+ var CodexProcess = class {
345
+ constructor(displaySessionID, hooks) {
346
+ this.displaySessionID = displaySessionID;
347
+ this.hooks = hooks;
348
+ }
349
+ child = null;
350
+ killed = false;
351
+ seq = 0;
352
+ /** item id → content 块索引 → 已发送文本 */
353
+ sentText = /* @__PURE__ */ new Map();
354
+ sentParts = /* @__PURE__ */ new Set();
355
+ // `${itemId}:${index}`
356
+ produced = false;
357
+ tail = "";
358
+ exitCode = null;
359
+ capturedCodexId = null;
360
+ get codexId() {
361
+ return this.capturedCodexId;
362
+ }
363
+ start(opts, text, resumeCodexId) {
364
+ return new Promise((resolve3, reject) => {
365
+ let stderrBuf = "";
366
+ let settle = false;
367
+ const done = (err) => {
368
+ if (settle) return;
369
+ settle = true;
370
+ if (err) reject(err);
371
+ else resolve3();
372
+ };
373
+ const bin = opts.bin || "codex";
374
+ let child;
375
+ try {
376
+ child = spawn(bin, buildExecArgs(opts, text, resumeCodexId), {
377
+ cwd: opts.cwd,
378
+ env: process.env,
379
+ stdio: ["ignore", "pipe", "pipe"]
380
+ });
381
+ } catch (e) {
382
+ done(new Error(`\u65E0\u6CD5\u542F\u52A8 codex\uFF08\u8BF7\u786E\u8BA4\u5DF2\u5B89\u88C5\u5E76\u767B\u5F55 codex CLI\uFF09: ${e.message}`));
383
+ return;
384
+ }
385
+ this.child = child;
386
+ child.stdout?.setEncoding("utf8");
387
+ child.stdout?.on("data", (chunk) => this.onChunk(chunk));
388
+ child.stderr?.setEncoding("utf8");
389
+ child.stderr?.on("data", (chunk) => {
390
+ stderrBuf += chunk;
391
+ const lines = chunk.split("\n");
392
+ for (const l of lines) {
393
+ const s = l.trim();
394
+ if (s && /error|login|auth|denied|not (found|installed)|permission/i.test(s)) {
395
+ this.hooks.log(`codex: ${s.slice(0, 300)}`);
396
+ }
397
+ }
398
+ });
399
+ child.on("error", (e) => {
400
+ if (!this.killed) done(new Error(`codex \u542F\u52A8\u5931\u8D25: ${e.message}`));
401
+ });
402
+ child.on("close", (code, signal) => {
403
+ this.exitCode = code;
404
+ if (this.killed || signal) {
405
+ done();
406
+ return;
407
+ }
408
+ if (code !== 0) {
409
+ const err = stderrBuf.trim().split("\n").pop() || `codex \u9000\u51FA\u7801 ${code}`;
410
+ this.emitError(`codex \u9000\u51FA(${code}): ${err.slice(0, 500)}`);
411
+ }
412
+ done();
413
+ });
414
+ });
415
+ }
416
+ kill() {
417
+ this.killed = true;
418
+ if (this.child && this.child.exitCode === null) {
419
+ try {
420
+ this.child.kill("SIGTERM");
421
+ } catch {
422
+ }
423
+ setTimeout(() => {
424
+ try {
425
+ if (this.child && this.child.exitCode === null) this.child.kill("SIGKILL");
426
+ } catch {
427
+ }
428
+ }, 1500);
429
+ }
430
+ }
431
+ emit(part) {
432
+ this.hooks.emit(this.displaySessionID, this.currentItemId(), part);
433
+ }
434
+ currentItemId() {
435
+ if (!this.frameItemId) this.frameItemId = `m-${Date.now()}-${++this.seq}`;
436
+ return this.frameItemId;
437
+ }
438
+ frameItemId = "";
439
+ setFrame(itemId) {
440
+ this.frameItemId = itemId || `m-${Date.now()}-${++this.seq}`;
441
+ }
442
+ emitError(text) {
443
+ this.setFrame(`m-err-${Date.now()}`);
444
+ this.emit({ id: "err", type: "text", text: `[codex] ${text}`, complete: true });
445
+ this.hooks.status(this.displaySessionID, "error");
446
+ }
447
+ onChunk(chunk) {
448
+ this.buf += chunk;
449
+ let idx;
450
+ while ((idx = this.buf.indexOf("\n")) >= 0) {
451
+ const line = this.buf.slice(0, idx);
452
+ this.buf = this.buf.slice(idx + 1);
453
+ this.onLine(line);
454
+ }
455
+ }
456
+ buf = "";
457
+ onLine(line) {
458
+ const o = parseLine(line);
459
+ if (!o) return;
460
+ const type = String(o.type ?? "");
461
+ const payload = rec(o.payload);
462
+ if (type === "init") {
463
+ const sid = sessionIdFrom(o);
464
+ if (sid) this.capturedCodexId = sid;
465
+ return;
466
+ }
467
+ if (type === "response_item" || type === "item_updated" || type === "item_completed") {
468
+ this.onItem(type, payload);
469
+ return;
470
+ }
471
+ if (type === "result") {
472
+ const out = payload.output;
473
+ if (typeof out === "string" && out) this.tail = out;
474
+ const status = String(payload.status ?? "");
475
+ if (status && /error|failed|cancelled/i.test(status)) {
476
+ this.emitError(status);
477
+ }
478
+ return;
479
+ }
480
+ if (type === "error") {
481
+ const msg = typeof payload.message === "string" ? payload.message : typeof payload.error === "string" ? payload.error : "codex \u51FA\u9519";
482
+ this.emitError(String(msg));
483
+ return;
484
+ }
485
+ if (type === "agent_reasoning" || type === "reasoning") {
486
+ const text = String(payload.text ?? blockText(payload) ?? "");
487
+ if (text) {
488
+ this.setFrame(`r-${Date.now()}-${++this.seq}`);
489
+ this.emit({ id: "r", type: "thinking", delta: text, complete: false });
490
+ }
491
+ return;
492
+ }
493
+ if (type === "turn_start") {
494
+ this.hooks.status(this.displaySessionID, "running");
495
+ return;
496
+ }
497
+ }
498
+ onItem(kind, payload) {
499
+ const itemType = String(payload.type ?? "");
500
+ const role = String(payload.role ?? "");
501
+ if (itemType !== "message" || role !== "assistant" && role !== "user") return;
502
+ const itemId = String(payload.id ?? "");
503
+ if (!itemId) return;
504
+ this.setFrame(itemId);
505
+ const content = payload.content;
506
+ if (!Array.isArray(content)) return;
507
+ const textSent = this.sentText.get(itemId) || [];
508
+ let emitted = false;
509
+ content.forEach((raw, i) => {
510
+ const b = rec(raw);
511
+ const t = String(b.type ?? b.kind ?? "");
512
+ if (t === "tool_result" || t === "function_call_output") return;
513
+ if (t === "tool_call" || t === "function_call") {
514
+ if (this.sentParts.has(`${itemId}:${i}`)) return;
515
+ this.sentParts.add(`${itemId}:${i}`);
516
+ const part = blockToPart(b, i);
517
+ if (part) {
518
+ this.emit(part);
519
+ emitted = true;
520
+ }
521
+ return;
522
+ }
523
+ if (role === "user") return;
524
+ const text = blockText(b) || (typeof b.content === "string" ? b.content : "");
525
+ const prev = textSent[i];
526
+ if (text && text !== prev) {
527
+ textSent[i] = text;
528
+ this.emit({ id: `c${i}`, type: "text", text, complete: false });
529
+ emitted = true;
530
+ }
531
+ });
532
+ this.sentText.set(itemId, textSent);
533
+ if (kind === "item_completed") {
534
+ textSent.forEach((_, i) => {
535
+ this.emit({ id: `c${i}`, type: "text", complete: true });
536
+ });
537
+ }
538
+ if (emitted) this.produced = true;
539
+ }
540
+ };
541
+
542
+ // src/sessions.ts
543
+ import {
544
+ existsSync as existsSync2,
545
+ mkdirSync as mkdirSync2,
546
+ readFileSync as readFileSync2,
547
+ readdirSync,
548
+ rmSync,
549
+ statSync,
550
+ writeFileSync as writeFileSync2
551
+ } from "node:fs";
552
+ import { homedir as homedir2 } from "node:os";
553
+ import { basename, dirname as dirname2, join as join2 } from "node:path";
554
+ function codexHome() {
555
+ return process.env.CODEX_HOME || join2(homedir2(), ".codex");
556
+ }
557
+ function codexSessionsRoot() {
558
+ return join2(codexHome(), "sessions");
559
+ }
560
+ function aliasPath() {
561
+ return join2(homedir2(), ".config", "opencode", "codex-aliases.json");
562
+ }
563
+ function loadAliases() {
564
+ try {
565
+ const p = aliasPath();
566
+ if (existsSync2(p)) {
567
+ const parsed = JSON.parse(readFileSync2(p, "utf8"));
568
+ if (parsed && typeof parsed === "object") return parsed;
569
+ }
570
+ } catch {
571
+ }
572
+ return {};
573
+ }
574
+ function saveAliases(reg) {
575
+ try {
576
+ const p = aliasPath();
577
+ mkdirSync2(join2(p, ".."), { recursive: true });
578
+ writeFileSync2(p, JSON.stringify(reg, null, 2), "utf8");
579
+ } catch {
580
+ }
581
+ }
582
+ function walkFiles(dir, out = []) {
583
+ let entries;
584
+ try {
585
+ entries = readdirSync(dir);
586
+ } catch {
587
+ return out;
588
+ }
589
+ for (const name of entries) {
590
+ const p = join2(dir, name);
591
+ try {
592
+ const st = statSync(p);
593
+ if (st.isDirectory()) walkFiles(p, out);
594
+ else if (st.isFile() && name.endsWith(".jsonl")) out.push(p);
595
+ } catch {
596
+ }
597
+ }
598
+ return out;
599
+ }
600
+ function idFromFilename(file) {
601
+ const base = basename(file, ".jsonl");
602
+ const parts = base.split("_");
603
+ const last = parts[parts.length - 1];
604
+ return last && last.length >= 8 ? last : null;
605
+ }
606
+ function metaOf(file) {
607
+ let lines;
608
+ try {
609
+ lines = readFileSync2(file, "utf8").split("\n");
610
+ } catch {
611
+ return null;
612
+ }
613
+ const st = statSync(file);
614
+ let id = "";
615
+ let cwd = "";
616
+ let firstText = "";
617
+ for (const line of lines) {
618
+ const o = parseLine(line);
619
+ if (!o) continue;
620
+ if (!id) id = sessionIdFrom(o) || "";
621
+ const payload = rec(o.payload);
622
+ if (!cwd) {
623
+ const c = payload.cwd ?? payload.cwd_path ?? payload.directory;
624
+ if (typeof c === "string" && c) cwd = c;
625
+ }
626
+ if (!firstText) {
627
+ const e = lineToEntry(line);
628
+ if (e && e.kind === "user") {
629
+ const t = e.parts.map((p) => p.type === "text" ? p.text || "" : "").join(" ").trim();
630
+ if (t) firstText = t.length > 30 ? `${t.slice(0, 30)}\u2026` : t;
631
+ }
632
+ }
633
+ }
634
+ if (!id) id = idFromFilename(file) || "";
635
+ if (!id) return null;
636
+ const ts = st.mtimeMs;
637
+ return { sessionId: id, cwd: cwd || void 0, title: firstText || "Codex \u4F1A\u8BDD", created: ts, updated: ts };
638
+ }
639
+ function scanMetas() {
640
+ const root = codexSessionsRoot();
641
+ const out = [];
642
+ for (const f of walkFiles(root)) {
643
+ const m = metaOf(f);
644
+ if (m) out.push(m);
645
+ }
646
+ return out;
647
+ }
648
+ function transcriptExists(codexId) {
649
+ const root = codexSessionsRoot();
650
+ for (const f of walkFiles(root)) {
651
+ if (basename(f, ".jsonl").endsWith(codexId)) return true;
652
+ }
653
+ return scanMetas().some((m) => m.sessionId === codexId);
654
+ }
655
+ function findFile(codexId) {
656
+ const root = codexSessionsRoot();
657
+ for (const f of walkFiles(root)) {
658
+ if (basename(f, ".jsonl").endsWith(codexId)) return f;
659
+ }
660
+ return null;
661
+ }
662
+ async function listSessions(aliases, workspace) {
663
+ const metas = scanMetas();
664
+ const byId = /* @__PURE__ */ new Map();
665
+ for (const m of metas) byId.set(m.sessionId, m);
666
+ const out = [];
667
+ for (const [alias, entry] of Object.entries(aliases)) {
668
+ const m = entry.codexId ? byId.get(entry.codexId) : void 0;
669
+ if (!m) continue;
670
+ const cwd = entry.cwd || m.cwd;
671
+ if (workspace && cwd !== workspace) continue;
672
+ out.push({ id: alias, title: m.title, created: m.created, updated: m.updated, status: "idle", workspace: cwd });
673
+ }
674
+ for (const m of metas) {
675
+ if (aliases[findAliasByCodex(aliases, m.sessionId) || ""]) continue;
676
+ if (workspace && m.cwd !== workspace) continue;
677
+ out.push({ id: m.sessionId, title: m.title, created: m.created, updated: m.updated, status: "idle", workspace: m.cwd });
678
+ }
679
+ return out.sort((a, b) => b.updated - a.updated);
680
+ }
681
+ function findAliasByCodex(aliases, codexId) {
682
+ for (const [alias, e] of Object.entries(aliases)) {
683
+ if (e.codexId === codexId) return alias;
684
+ }
685
+ return null;
686
+ }
687
+ function resolveCodexId(aliases, sessionID) {
688
+ const alias = aliases[sessionID];
689
+ if (alias && alias.codexId) return { codexId: alias.codexId, cwd: alias.cwd };
690
+ return { codexId: sessionID };
691
+ }
692
+ async function readHistory(codexId) {
693
+ const file = findFile(codexId);
694
+ if (!file) return [];
695
+ let lines;
696
+ try {
697
+ lines = readFileSync2(file, "utf8").split("\n");
698
+ } catch {
699
+ return [];
700
+ }
701
+ const out = [];
702
+ const seen = /* @__PURE__ */ new Set();
703
+ for (const line of lines) {
704
+ const e = lineToEntry(line);
705
+ if (!e || seen.has(e.id)) continue;
706
+ seen.add(e.id);
707
+ if (e.kind !== "user" && e.kind !== "assistant" && e.kind !== "tool") continue;
708
+ out.push({ id: e.id, role: e.kind, parts: e.parts, created: Date.now() });
709
+ }
710
+ return out;
711
+ }
712
+ async function removeSession(codexId) {
713
+ const file = findFile(codexId);
714
+ if (!file) return false;
715
+ try {
716
+ rmSync(file, { force: true });
717
+ const dir = dirname2(file);
718
+ try {
719
+ if (!readdirSync(dir).length) rmSync(dir, { recursive: true, force: true });
720
+ } catch {
721
+ }
722
+ return true;
723
+ } catch {
724
+ return false;
725
+ }
726
+ }
727
+ function dirBasename(p) {
728
+ return p.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || p;
729
+ }
730
+ async function workspaceList(aliases, current) {
731
+ const metas = scanMetas();
732
+ const byPath = /* @__PURE__ */ new Map();
733
+ const bump = (p, ts) => {
734
+ if (!p) return;
735
+ const e = byPath.get(p) ?? { count: 0, updated: 0 };
736
+ e.count++;
737
+ if (ts && ts > e.updated) e.updated = ts;
738
+ byPath.set(p, e);
739
+ };
740
+ for (const m of metas) bump(m.cwd, m.updated);
741
+ for (const e of Object.values(aliases)) bump(e.cwd);
742
+ if (current && !byPath.has(current)) byPath.set(current, { count: 0, updated: 0 });
743
+ return [...byPath.entries()].map(([path, e]) => ({ path, name: dirBasename(path) || path, sessionCount: e.count, updated: e.updated })).sort((a, b) => b.updated - a.updated);
744
+ }
745
+ function newSessionId() {
746
+ if (typeof globalThis.crypto?.randomUUID === "function") return globalThis.crypto.randomUUID();
747
+ return `s-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
748
+ }
749
+
750
+ // src/bridge.ts
751
+ var VERSION = "0.1.0";
752
+ var AGENT_TYPE = "codex";
753
+ var ADAPTER_COMMANDS = [
754
+ { name: "new", description: "\u65B0\u5EFA\u4F1A\u8BDD" },
755
+ { name: "help", description: "\u663E\u793A\u5E2E\u52A9" }
756
+ ];
757
+ var CodexBridge = class {
758
+ constructor(cfg) {
759
+ this.cfg = cfg;
760
+ this.currentWorkspace = resolve(cfg.cwd || process.cwd());
761
+ }
762
+ ws = new RelayClient();
763
+ state = loadState(AGENT_TYPE);
764
+ claimed = !!this.state.claimed;
765
+ everConnected = false;
766
+ relayBase = "";
767
+ aliases = loadAliases();
768
+ currentWorkspace;
769
+ pendingWorkspaces = /* @__PURE__ */ new Map();
770
+ /** 会话 cwd 缓存(alias/native id → cwd) */
771
+ sessionCwd = /* @__PURE__ */ new Map();
772
+ /** 进行中的回合(sessionID → CodexProcess)。 */
773
+ activeRuns = /* @__PURE__ */ new Map();
774
+ get relayHttpBase() {
775
+ return this.relayBase.replace(/^ws/, "http").replace(/\/+$/, "");
776
+ }
777
+ async start() {
778
+ const url = (this.cfg.relayUrl || this.state.relayUrl || "").replace(/\/+$/, "");
779
+ this.relayBase = url;
780
+ this.connect(url);
781
+ }
782
+ dispose() {
783
+ this.ws.close();
784
+ for (const p of this.activeRuns.values()) p.kill();
785
+ this.activeRuns.clear();
786
+ }
787
+ log(msg) {
788
+ console.log(`[tunnelbox:codex] ${msg}`);
789
+ }
790
+ send(e) {
791
+ return this.ws.send(e);
792
+ }
793
+ reply(src, type, payload) {
794
+ const msg = envelope(type, payload, src.id);
795
+ msg.clientID = src.clientID;
796
+ this.send(msg);
797
+ }
798
+ pairingLink(code) {
799
+ const p = new URLSearchParams({ code, type: AGENT_TYPE, name: hostname() });
800
+ return `${this.relayHttpBase}/app/#/pages/index/index?${p.toString()}`;
801
+ }
802
+ connect(url) {
803
+ const agentID = this.state.agentId;
804
+ const wsUrl = `${url}/ws/agent?token=${encodeURIComponent(agentID)}`;
805
+ this.ws.connect(
806
+ wsUrl,
807
+ { Authorization: `Bearer ${agentID}` },
808
+ {
809
+ onOpen: () => {
810
+ this.everConnected = true;
811
+ this.log("\u5DF2\u8FDE\u63A5\u4E2D\u7EE7 " + url);
812
+ this.announce();
813
+ },
814
+ onMessage: (env) => {
815
+ void this.onMessage(env).catch((e) => this.log(`\u6D88\u606F\u5904\u7406\u672A\u6355\u83B7\u5F02\u5E38: ${e.message}`));
816
+ },
817
+ onClose: () => {
818
+ if (this.everConnected) {
819
+ this.everConnected = false;
820
+ this.log("\u4E0E\u4E2D\u7EE7\u65AD\u5F00\uFF0C\u6B63\u5728\u81EA\u52A8\u91CD\u8FDE\u2026");
821
+ }
822
+ },
823
+ onError: () => {
824
+ }
825
+ }
826
+ );
827
+ }
828
+ announce() {
829
+ const info = {
830
+ name: hostname(),
831
+ version: VERSION,
832
+ type: AGENT_TYPE,
833
+ capabilities: {
834
+ streaming: true,
835
+ thinking: true,
836
+ // codex agent_reasoning → thinking(如有)
837
+ permission: false,
838
+ // codex exec 无可靠审批回调:工具权限由 codex 自身沙箱策略 fail-closed 处理
839
+ commands: true,
840
+ abort: true
841
+ // kill 子进程
842
+ },
843
+ platform: process.platform,
844
+ directory: this.currentWorkspace,
845
+ timezone: localTimezone()
846
+ };
847
+ this.send(envelope("agent.info", info));
848
+ if (!this.claimed) this.send(envelope("pair.create", {}));
849
+ }
850
+ // ---- 消息路由 ----
851
+ async onMessage(env) {
852
+ try {
853
+ switch (env.type) {
854
+ case "pair.created": {
855
+ const code = env.payload.code;
856
+ const link = this.pairingLink(code);
857
+ writePairingJson({ code, link, relayUrl: this.relayBase, at: Date.now() }, AGENT_TYPE);
858
+ this.log("\u751F\u6210\u914D\u5BF9\u7801: " + code);
859
+ void printPairing(this.relayHttpBase, code, AGENT_TYPE, hostname());
860
+ return;
861
+ }
862
+ case "agent.claimed":
863
+ this.claimed = true;
864
+ this.state.claimed = true;
865
+ saveState(this.state, AGENT_TYPE);
866
+ 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");
867
+ return;
868
+ case "agent.revoked":
869
+ this.resetIdentity();
870
+ return;
871
+ case "session.list":
872
+ return this.sessionList(env);
873
+ case "session.create":
874
+ return this.sessionCreate(env);
875
+ case "session.messages":
876
+ return this.sessionMessages(env);
877
+ case "session.prompt":
878
+ return this.sessionPrompt(env);
879
+ case "session.abort":
880
+ return this.sessionAbort(env);
881
+ case "session.delete":
882
+ return this.sessionDelete(env);
883
+ case "command.list":
884
+ return this.commandList(env);
885
+ case "workspace.list":
886
+ return this.workspaceList(env);
887
+ case "workspace.set":
888
+ return this.workspaceSet(env);
889
+ case "error":
890
+ return;
891
+ default:
892
+ this.log("\u672A\u5904\u7406\u7684\u6D88\u606F\u7C7B\u578B: " + env.type);
893
+ }
894
+ } catch (e) {
895
+ this.log(`\u5904\u7406 ${env.type} \u5931\u8D25: ${e.message}`);
896
+ this.reply(env, "error", { code: "HANDLER_ERROR", message: e.message, id: env.id });
897
+ }
898
+ }
899
+ // ---- 会话 ----
900
+ async sessionList(env) {
901
+ const payload = env.payload;
902
+ const sessions = (await listSessions(this.aliases, payload?.workspace)).map((s) => ({
903
+ ...s,
904
+ status: this.activeRuns.has(s.id) ? "running" : s.status
905
+ }));
906
+ this.reply(env, "sessions", { sessions });
907
+ }
908
+ async sessionCreate(env) {
909
+ const payload = env.payload;
910
+ const workspace = payload?.workspace || this.currentWorkspace;
911
+ const id = newSessionId();
912
+ const now = Date.now();
913
+ this.pendingWorkspaces.set(id, workspace);
914
+ this.reply(env, "session.created", {
915
+ session: { id, title: "\u65B0\u4F1A\u8BDD", created: now, updated: now, status: "idle", workspace }
916
+ });
917
+ }
918
+ async sessionMessages(env) {
919
+ const { sessionID } = env.payload;
920
+ const { codexId } = resolveCodexId(this.aliases, sessionID);
921
+ let messages = [];
922
+ if (codexId && transcriptExists(codexId)) {
923
+ messages = await readHistory(codexId);
924
+ }
925
+ this.reply(env, "messages", { sessionID, messages });
926
+ }
927
+ async sessionPrompt(env) {
928
+ const payload = env.payload;
929
+ const text = (payload.text || "").trim();
930
+ if (!text) return;
931
+ if (text.startsWith("/new")) return this.sessionCreate(env);
932
+ if (text.startsWith("/help")) {
933
+ this.reply(env, "message.part", {
934
+ sessionID: payload.sessionID,
935
+ messageID: `m-${Date.now()}`,
936
+ 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 }
937
+ });
938
+ return;
939
+ }
940
+ if (this.activeRuns.has(payload.sessionID)) {
941
+ 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 });
942
+ return;
943
+ }
944
+ try {
945
+ await this.runTurn(payload.sessionID, text);
946
+ } catch (e) {
947
+ const msg = e.message || String(e);
948
+ this.log(`session.prompt \u5931\u8D25: ${msg}`);
949
+ this.reply(env, "error", { code: "PROMPT_ERROR", message: msg, id: env.id });
950
+ }
951
+ }
952
+ async sessionAbort(env) {
953
+ const { sessionID } = env.payload;
954
+ const p = this.activeRuns.get(sessionID);
955
+ if (p) {
956
+ p.kill();
957
+ this.log(`\u5DF2\u53D1\u9001\u4E2D\u6B62 (session=${sessionID})`);
958
+ }
959
+ this.send(envelope("session.status", { sessionID, status: "idle" }));
960
+ }
961
+ async sessionDelete(env) {
962
+ const { sessionID } = env.payload;
963
+ const p = this.activeRuns.get(sessionID);
964
+ if (p) p.kill();
965
+ this.activeRuns.delete(sessionID);
966
+ this.pendingWorkspaces.delete(sessionID);
967
+ this.sessionCwd.delete(sessionID);
968
+ const { codexId } = resolveCodexId(this.aliases, sessionID);
969
+ if (codexId) await removeSession(codexId);
970
+ if (this.aliases[sessionID]) {
971
+ delete this.aliases[sessionID];
972
+ saveAliases(this.aliases);
973
+ }
974
+ this.reply(env, "session.deleted", { sessionID });
975
+ }
976
+ async commandList(env) {
977
+ this.reply(env, "commands", { commands: ADAPTER_COMMANDS });
978
+ }
979
+ // ---- 工作区 ----
980
+ async workspaceList(env) {
981
+ const workspaces = await workspaceList(this.aliases, this.currentWorkspace);
982
+ this.reply(env, "workspaces", { workspaces, current: this.currentWorkspace });
983
+ }
984
+ async workspaceSet(env) {
985
+ const raw = (env.payload?.path || "").trim();
986
+ if (!raw) {
987
+ this.reply(env, "error", { code: "BAD_WORKSPACE", message: "\u7F3A\u5C11\u5DE5\u4F5C\u533A\u8DEF\u5F84", id: env.id });
988
+ return;
989
+ }
990
+ const path = isAbsolute(raw) ? raw : resolve(raw);
991
+ if (!existsSync3(path) || !statSync2(path).isDirectory()) {
992
+ this.reply(env, "error", { code: "BAD_WORKSPACE", message: "\u8BE5\u76EE\u5F55\u4E0D\u5B58\u5728\u6216\u4E0D\u53EF\u7528", id: env.id });
993
+ return;
994
+ }
995
+ this.currentWorkspace = path;
996
+ this.log("\u5207\u6362\u5DE5\u4F5C\u533A: " + path);
997
+ this.announce();
998
+ const workspaces = await workspaceList(this.aliases, this.currentWorkspace);
999
+ this.reply(env, "workspaces", { workspaces, current: this.currentWorkspace });
1000
+ }
1001
+ // ---- 回合 ----
1002
+ async runTurn(sessionID, text) {
1003
+ const dir = this.pendingWorkspaces.get(sessionID) || this.sessionCwd.get(sessionID) || this.currentWorkspace;
1004
+ if (!existsSync3(dir) || !statSync2(dir).isDirectory()) {
1005
+ throw new Error(`\u5DE5\u4F5C\u533A\u4E0D\u5B58\u5728: ${dir}`);
1006
+ }
1007
+ this.pendingWorkspaces.delete(sessionID);
1008
+ const alias = this.aliases[sessionID];
1009
+ const resumeCodexId = alias?.codexId || (transcriptExists(sessionID) ? sessionID : void 0);
1010
+ this.send(envelope("session.status", { sessionID, status: "running" }));
1011
+ const proc = new CodexProcess(
1012
+ sessionID,
1013
+ {
1014
+ emit: (s, messageID, part) => this.send(envelope("message.part", { sessionID: s, messageID, part })),
1015
+ status: (s, status) => this.send(envelope("session.status", { sessionID: s, status })),
1016
+ log: (m) => this.log(m)
1017
+ }
1018
+ );
1019
+ this.activeRuns.set(sessionID, proc);
1020
+ try {
1021
+ await proc.start(
1022
+ { cwd: dir, sandbox: this.cfg.sandbox, bin: this.cfg.bin, model: this.cfg.model },
1023
+ text,
1024
+ resumeCodexId
1025
+ );
1026
+ if (!resumeCodexId && proc.codexId) {
1027
+ this.aliases[sessionID] = { codexId: proc.codexId, cwd: dir };
1028
+ saveAliases(this.aliases);
1029
+ this.log(`\u65B0\u4F1A\u8BDD\u843D\u76D8 codexId=${proc.codexId} (alias=${sessionID})`);
1030
+ }
1031
+ if (!proc.produced && proc.tail) {
1032
+ this.send(
1033
+ envelope("message.part", {
1034
+ sessionID,
1035
+ messageID: `m-tail-${Date.now()}`,
1036
+ part: { id: "t", type: "text", text: proc.tail, complete: true }
1037
+ })
1038
+ );
1039
+ }
1040
+ this.sessionCwd.set(sessionID, dir);
1041
+ } catch (e) {
1042
+ this.log(`\u56DE\u5408\u51FA\u9519 (session=${sessionID}): ${e.message}`);
1043
+ this.send(
1044
+ envelope("message.part", {
1045
+ sessionID,
1046
+ messageID: `m-err-${Date.now()}`,
1047
+ part: { id: "err", type: "text", text: `[codex] ${e.message}`, complete: true }
1048
+ })
1049
+ );
1050
+ } finally {
1051
+ this.activeRuns.delete(sessionID);
1052
+ this.send(envelope("session.status", { sessionID, status: "idle" }));
1053
+ void this.broadcastSessionUpdate(sessionID);
1054
+ }
1055
+ }
1056
+ async broadcastSessionUpdate(sessionID) {
1057
+ try {
1058
+ if (!this.ws.isConnected) return;
1059
+ const list = await listSessions(this.aliases).catch(() => []);
1060
+ const found = list.find((s) => s.id === sessionID);
1061
+ if (!found) return;
1062
+ if (found.workspace) this.sessionCwd.set(sessionID, found.workspace);
1063
+ this.send(envelope("session.updated", { session: found }));
1064
+ } catch {
1065
+ }
1066
+ }
1067
+ resetIdentity() {
1068
+ this.log("\u88AB\u8D26\u53F7\u89E3\u7ED1\uFF0C\u6B63\u5728\u91CD\u7F6E\u8EAB\u4EFD\u5E76\u91CD\u65B0\u751F\u6210\u914D\u5BF9\u7801\u2026");
1069
+ this.claimed = false;
1070
+ this.state.agentId = randomBytes2(16).toString("hex");
1071
+ this.state.claimed = false;
1072
+ saveState(this.state, AGENT_TYPE);
1073
+ this.ws.close();
1074
+ this.connect(this.relayBase);
1075
+ }
1076
+ };
1077
+ function localTimezone() {
1078
+ try {
1079
+ const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
1080
+ if (tz) return tz;
1081
+ } catch {
1082
+ }
1083
+ return "";
1084
+ }
1085
+
1086
+ // src/index.ts
1087
+ var SANDBOXES = /* @__PURE__ */ new Set(["read-only", "workspace-write", "danger-full-access"]);
1088
+ function parseSandbox(raw) {
1089
+ const s = (raw || "").trim().toLowerCase();
1090
+ if (s && !SANDBOXES.has(s)) {
1091
+ console.warn(`[tunnelbox:codex] \u5FFD\u7565\u65E0\u6548 sandbox: ${raw}\uFF08read-only|workspace-write|danger-full-access\uFF09`);
1092
+ return "workspace-write";
1093
+ }
1094
+ return s || "workspace-write";
1095
+ }
1096
+ function start(opts = {}) {
1097
+ const cfg = {
1098
+ relayUrl: (process.env.TUNNELBOX_RELAY_URL || opts.relayUrl || "").trim(),
1099
+ cwd: process.env.TUNNELBOX_CWD || opts.cwd || process.cwd(),
1100
+ sandbox: parseSandbox(process.env.TUNNELBOX_CODEX_SANDBOX || opts.sandbox),
1101
+ bin: process.env.TUNNELBOX_CODEX_BIN?.trim() || opts.bin || "codex",
1102
+ model: process.env.TUNNELBOX_CODEX_MODEL?.trim() || opts.model || void 0
1103
+ };
1104
+ const bridge = new CodexBridge(cfg);
1105
+ void bridge.start().catch((e) => {
1106
+ console.error("[tunnelbox:codex] \u542F\u52A8\u5931\u8D25:", e);
1107
+ });
1108
+ let stopped = false;
1109
+ return {
1110
+ stop() {
1111
+ if (stopped) return;
1112
+ stopped = true;
1113
+ bridge.dispose();
1114
+ }
1115
+ };
1116
+ }
1117
+ var isEntry = (() => {
1118
+ try {
1119
+ return !!process.argv[1] && resolve2(process.argv[1]) === fileURLToPath(import.meta.url);
1120
+ } catch {
1121
+ return false;
1122
+ }
1123
+ })();
1124
+ if (isEntry) {
1125
+ const { stop } = start();
1126
+ const onSignal = () => {
1127
+ stop();
1128
+ process.exit(0);
1129
+ };
1130
+ process.once("SIGINT", onSignal);
1131
+ process.once("SIGTERM", onSignal);
1132
+ console.log("[tunnelbox:codex] Codex \u9002\u914D\u5668\u5DF2\u542F\u52A8\uFF08Ctrl+C \u9000\u51FA\uFF09");
1133
+ }
1134
+ export {
1135
+ start
1136
+ };
package/package.json ADDED
@@ -0,0 +1,30 @@
1
+ {
2
+ "name": "@tunnelbox/codex",
3
+ "version": "0.1.0",
4
+ "description": "tunnelbox 的 Codex 适配器(B 类):手机远程驱动本机 Codex CLI(codex exec --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
+ }