@tunnelbox/claude-code 0.1.1 → 0.1.3

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.
@@ -1,274 +1,332 @@
1
- #!/usr/bin/env node
2
- /**
3
- * tunnelbox 官方 Claude Code 插件 的 bin 工具(自包含 Node,无第三方依赖)。
4
- *
5
- * 子命令:
6
- * pair —— 向中继申请配对码并打印(二维码可选由 qrcode 提供,缺省降级为链接)
7
- * status —— 打印中继/agent/绑定状态
8
- * ask —— 供 hooks/PermissionRequest 调用:把当回合工具审批转发到手机
9
- *
10
- * 状态:优先存 ${CLAUDE_PLUGIN_DATA}(官方注入的插件持久目录),否则 ~/.config/opencode/official-plugin.json。
11
- * 环境:TUNNELBOX_RELAY_URL 可覆盖中继地址。
12
- * 通道约定:SDK 桥在 options.env 注入 TUNNELBOX_SDK_SESSION=1 → 本脚本“不拦截直接退出”,
13
- * 审批交由 SDK 的 canUseTool(避免 PermissionRequest hook 与 canUseTool 双弹)。
14
- * 独立 `claude --plugin-dir` 会话无该标记时才做手机转发;未配对/中继不可达时不输出(交本地询问)。
15
- */
16
- import { randomBytes, randomUUID } from "node:crypto";
17
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
- import { homedir, hostname } from "node:os";
19
- import { dirname, join } from "node:path";
20
-
21
- const DEFAULT_RELAY = process.env.TUNNELBOX_RELAY_URL || "ws://127.0.0.1:8080";
22
- const AGENT_TYPE = "claude-code";
23
- const TIMEOUT_MS = 120000;
24
-
25
- function stateDir() {
26
- if (process.env.CLAUDE_PLUGIN_DATA) return process.env.CLAUDE_PLUGIN_DATA;
27
- return join(homedir(), ".config", "opencode");
28
- }
29
- const STATE_PATH = () => join(stateDir(), "official-plugin.json");
30
- const PAIRING_PATH = () => join(stateDir(), "official-plugin-pairing.txt");
31
-
32
- function loadState() {
33
- try {
34
- if (existsSync(STATE_PATH())) {
35
- const o = JSON.parse(readFileSync(STATE_PATH(), "utf8"));
36
- if (o && o.agentId) return { agentId: o.agentId, relayUrl: o.relayUrl || DEFAULT_RELAY, claimed: !!o.claimed };
37
- }
38
- } catch {
39
- /* ignore */
40
- }
41
- const s = { agentId: randomBytes(16).toString("hex"), relayUrl: DEFAULT_RELAY, claimed: false };
42
- saveState(s);
43
- return s;
44
- }
45
- function saveState(s) {
46
- try {
47
- mkdirSync(dirname(STATE_PATH()), { recursive: true });
48
- writeFileSync(STATE_PATH(), JSON.stringify(s, null, 2), "utf8");
49
- } catch {
50
- /* ignore */
51
- }
52
- }
53
-
54
- function envelope(type, payload) {
55
- return { type, payload, ts: Date.now() };
56
- }
57
-
58
- /** 建立到中继的出站 WS,返回 { ready, send, close, wait }。 */
59
- function connect(url, agentId) {
60
- const wsUrl = `${url}/ws/agent?token=${encodeURIComponent(agentId)}`;
61
- const ws = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${agentId}` } });
62
- return new Promise((resolve) => {
63
- const listeners = new Map();
64
- let opened = false;
65
- ws.onopen = () => {
66
- opened = true;
67
- resolve({
68
- isOpen: () => opened && ws.readyState === 1,
69
- send: (env) => {
70
- if (opened && ws.readyState === 1) {
71
- ws.send(JSON.stringify(env));
72
- return true;
73
- }
74
- return false;
75
- },
76
- on: (type, fn) => {
77
- const arr = listeners.get(type) || [];
78
- arr.push(fn);
79
- listeners.set(type, arr);
80
- },
81
- close: () => {
82
- try {
83
- ws.close();
84
- } catch {
85
- /* ignore */
86
- }
87
- },
88
- });
89
- };
90
- ws.onerror = () => {
91
- if (!opened) {
92
- resolve({
93
- isOpen: () => false,
94
- send: () => false,
95
- on: () => {},
96
- close: () => {},
97
- });
98
- }
99
- };
100
- ws.onclose = () => {};
101
- ws.onmessage = (ev) => {
102
- let msg;
103
- try {
104
- msg = JSON.parse(String(ev.data));
105
- } catch {
106
- return;
107
- }
108
- const fns = listeners.get(msg.type) || [];
109
- for (const fn of fns) fn(msg);
110
- };
111
- });
112
- }
113
-
114
- async function runPair() {
115
- const state = loadState();
116
- const relay = state.relayUrl.replace(/\/+$/, "");
117
- const httpBase = relay.replace(/^ws/, "http");
118
- const link = (code) => {
119
- const p = new URLSearchParams({ code, type: AGENT_TYPE, name: hostname() });
120
- return `${httpBase}/app/#/pages/index/index?${p.toString()}`;
121
- };
122
-
123
- if (state.claimed) {
124
- console.log("本机已绑定手机账号,可直接在手机「我的电脑」里连接;如需重新配对,请先在手机端解绑。");
125
- return;
126
- }
127
-
128
- const c = await connect(`${relay}/ws/agent?token=${encodeURIComponent(state.agentId)}`, state.agentId);
129
- if (!c.isOpen()) {
130
- console.error("无法连接中继:" + relay + "\n请设置 TUNNELBOX_RELAY_URL 或在官方-plugin 状态文件中配置。");
131
- process.exit(1);
132
- }
133
- c.send(envelope("agent.info", {
134
- name: hostname(),
135
- version: "plugin",
136
- type: AGENT_TYPE,
137
- capabilities: { streaming: false, thinking: false, permission: true, commands: false, abort: false },
138
- }));
139
-
140
- await new Promise((resolve) => {
141
- const t = setTimeout(() => resolve(), 10000);
142
- c.on("pair.created", (m) => {
143
- const code = m.payload?.code;
144
- if (!code) return;
145
- clearTimeout(t);
146
- const l = link(code);
147
- console.log("");
148
- console.log("tunnelbox 手机配对");
149
- console.log(" 配对码: " + code);
150
- console.log(" 链接 : " + l);
151
- console.log("手机浏览器打开链接,或打开 tunnelbox 应用输入配对码(一次性)。");
152
- try {
153
- mkdirSync(dirname(PAIRING_PATH()), { recursive: true });
154
- writeFileSync(PAIRING_PATH(), `配对码: ${code}\n配对链接: ${l}\n`, "utf8");
155
- } catch {
156
- /* ignore */
157
- }
158
- console.log(` 配对信息已写入: ${PAIRING_PATH()}`);
159
- resolve();
160
- });
161
- c.on("agent.claimed", () => {
162
- state.claimed = true;
163
- saveState(state);
164
- });
165
- if (!c.send(envelope("pair.create", {}))) {
166
- clearTimeout(t);
167
- console.error("向中继申请配对码失败。");
168
- resolve();
169
- }
170
- });
171
- c.close();
172
- }
173
-
174
- function runStatus() {
175
- const state = loadState();
176
- console.log(`中继: ${state.relayUrl}`);
177
- console.log(`agent 标识: ${state.agentId}`);
178
- console.log(`已绑定手机: ${state.claimed ? "是" : "否"}`);
179
- console.log(`状态文件: ${STATE_PATH()}`);
180
- if (existsSync(PAIRING_PATH())) {
181
- console.log("最新配对信息:");
182
- console.log(readFileSync(PAIRING_PATH(), "utf8").trim());
183
- }
184
- }
185
-
186
- async function runAsk() {
187
- // SDK 桥已接管审批 → 不拦截,让 canUseTool 处理
188
- if (process.env.TUNNELBOX_SDK_SESSION === "1") return;
189
-
190
- let input = "";
191
- try {
192
- const chunks = [];
193
- for await (const c of process.stdin) chunks.push(c);
194
- input = Buffer.concat(chunks).toString("utf8");
195
- } catch {
196
- return;
197
- }
198
- let req;
199
- try {
200
- req = JSON.parse(input);
201
- } catch {
202
- return;
203
- }
204
- if (!req || typeof req !== "object") return;
205
-
206
- const state = loadState();
207
- // 未绑定 / 中继不可达 → 不输出,交本地询问
208
- if (!state.claimed) return;
209
- const tool = String(req.tool_name || req.toolName || "tool");
210
- const toolInput = req.tool_input || req.toolInput || req.tool_use_input || {};
211
- const id = `p-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
212
-
213
- const c = await connect(`${state.relayUrl.replace(/\/+$/, "")}/ws/agent?token=${encodeURIComponent(state.agentId)}`, state.agentId);
214
- if (!c.isOpen()) return;
215
-
216
- const sessionID = String(req.session_id || req.sessionId || req.project_dir || "local-session");
217
- const prompt =
218
- (typeof req.permission_decision_reason === "string" && req.permission_decision_reason) ||
219
- `Claude Code 请求执行 ${tool}` +
220
- (Object.keys(toolInput).length ? `\n${JSON.stringify(toolInput, null, 2)}` : "");
221
-
222
- const decision = await new Promise((resolve) => {
223
- let settled = false;
224
- const done = (v) => {
225
- if (settled) return;
226
- settled = true;
227
- clearTimeout(timer);
228
- resolve(v);
229
- };
230
- const timer = setTimeout(() => done({ permissionDecision: "deny", permissionDecisionReason: "审批超时(fail-closed)" }), TIMEOUT_MS);
231
- c.on("permission.reply", (m) => {
232
- const p = m.payload || {};
233
- if (String(p.permissionID) !== id) return;
234
- if (p.status === "allow") done({ permissionDecision: "allow", permissionDecisionReason: "手机已批准" });
235
- else done({ permissionDecision: "deny", permissionDecisionReason: p.reason || "手机已拒绝" });
236
- });
237
- c.on("permission.replied", (m) => {
238
- const p = m.payload || {};
239
- if (String(p.permissionID) !== id) return;
240
- if (p.status === "allow") done({ permissionDecision: "allow", permissionDecisionReason: "手机已批准" });
241
- else done({ permissionDecision: "deny", permissionDecisionReason: "手机已拒绝" });
242
- });
243
- c.send(
244
- envelope("permission.request", {
245
- id,
246
- sessionID,
247
- tool,
248
- args: toolInput,
249
- prompt,
250
- createdAt: Date.now(),
251
- }),
252
- );
253
- });
254
- c.close();
255
- process.stdout.write(JSON.stringify(decision) + "\n");
256
- }
257
-
258
- async function main() {
259
- const cmd = process.argv[2] || "";
260
- if (cmd === "pair") await runPair();
261
- else if (cmd === "status") runStatus();
262
- else if (cmd === "ask") await runAsk();
263
- else {
264
- console.log("用法: tunnelbox <pair|status|ask>");
265
- console.log(" pair 取手机配对码并打印");
266
- console.log(" status 显示中继/绑定状态");
267
- console.log(" ask 供 PermissionRequest hook 调用(把审批转发到手机)");
268
- }
269
- }
270
-
271
- main().catch((e) => {
272
- console.error(e && e.message ? e.message : String(e));
273
- process.exit(1);
274
- });
1
+ #!/usr/bin/env node
2
+ /**
3
+ * tunnelbox 官方 Claude Code 插件 的 bin 工具(自包含 Node,无第三方依赖)。
4
+ *
5
+ * 子命令:
6
+ * pair —— 向中继申请配对码并打印(二维码可选由 qrcode 提供,缺省降级为链接)
7
+ * status —— 打印中继/agent/绑定状态
8
+ * ask —— 供 hooks/PermissionRequest 调用:把当回合工具审批转发到手机
9
+ *
10
+ * 状态:优先存 ${CLAUDE_PLUGIN_DATA}(官方注入的插件持久目录),否则 ~/.tunnelbox/official-plugin.json。
11
+ * 环境:TUNNELBOX_RELAY_URL 可覆盖中继地址。
12
+ * 通道约定:SDK 桥在 options.env 注入 TUNNELBOX_SDK_SESSION=1 → 本脚本“不拦截直接退出”,
13
+ * 审批交由 SDK 的 canUseTool(避免 PermissionRequest hook 与 canUseTool 双弹)。
14
+ * 独立 `claude --plugin-dir` 会话无该标记时才做手机转发;未配对/中继不可达时不输出(交本地询问)。
15
+ */
16
+ import { randomBytes, randomUUID } from "node:crypto";
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { homedir, hostname } from "node:os";
19
+ import { dirname, join } from "node:path";
20
+
21
+ // 本文件为纯源码分发(不经 esbuild),源码不含任何中继 URL 默认值:
22
+ // 仅接受显式输入 TUNNELBOX_RELAY_URL(或状态文件里已持久化的显式值),否则不连接并提示。
23
+ const DEFAULT_RELAY = process.env.TUNNELBOX_RELAY_URL || "";
24
+ const AGENT_TYPE = "claude-code";
25
+ const TIMEOUT_MS = 120000;
26
+
27
+ // ---- 精简多语言(本文件为自包含纯源码分发,不经 esbuild,无法读取 core/locales)----
28
+ // 仅覆盖权限决策 message 等少量固定文案:语言取 TUNNELBOX_LANG / 系统 LANG/LC_ALL;
29
+ // 非中文回退 en-US。完整 8 语言与唯一数据源(core/locales)由 claude-code i18n 对齐统一。
30
+ const BIN_DICT = {
31
+ "zh-CN": {
32
+ "bin.denied": "手机已拒绝",
33
+ "bin.timeout": "审批超时(fail-closed)",
34
+ "bin.reqRun": "Claude Code 请求执行 ",
35
+ },
36
+ en: {
37
+ "bin.denied": "Rejected from your phone",
38
+ "bin.timeout": "Approval timed out (fail-closed)",
39
+ "bin.reqRun": "Claude Code requests to run ",
40
+ },
41
+ };
42
+ function detectLang() {
43
+ const raw = process.env.TUNNELBOX_LANG || process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "";
44
+ const s = String(raw).trim().replace(/_/g, "-").toLowerCase();
45
+ if (s.startsWith("zh")) return "zh-CN";
46
+ return "en";
47
+ }
48
+ function binMsg(key, vars) {
49
+ const lang = detectLang();
50
+ const dict = BIN_DICT[lang] || BIN_DICT.en;
51
+ let text = dict[key] ?? BIN_DICT.en[key] ?? key;
52
+ if (vars) for (const [k, v] of Object.entries(vars)) text = text.replaceAll(`{${k}}`, String(v));
53
+ return text;
54
+ }
55
+
56
+ function stateDir() {
57
+ if (process.env.CLAUDE_PLUGIN_DATA) return process.env.CLAUDE_PLUGIN_DATA;
58
+ return join(homedir(), ".tunnelbox");
59
+ }
60
+ const STATE_PATH = () => join(stateDir(), "official-plugin.json");
61
+ const PAIRING_PATH = () => join(stateDir(), "official-plugin-pairing.txt");
62
+
63
+ function loadState() {
64
+ try {
65
+ if (existsSync(STATE_PATH())) {
66
+ const o = JSON.parse(readFileSync(STATE_PATH(), "utf8"));
67
+ // 仅采用显式持久化值或 TUNNELBOX_RELAY_URL(源码不含默认中继 URL)
68
+ if (o && o.agentId) return { agentId: o.agentId, relayUrl: o.relayUrl || DEFAULT_RELAY, claimed: !!o.claimed };
69
+ }
70
+ } catch {
71
+ /* ignore */
72
+ }
73
+ const s = { agentId: randomBytes(16).toString("hex"), relayUrl: DEFAULT_RELAY, claimed: false };
74
+ saveState(s);
75
+ return s;
76
+ }
77
+ function saveState(s) {
78
+ try {
79
+ mkdirSync(dirname(STATE_PATH()), { recursive: true });
80
+ writeFileSync(STATE_PATH(), JSON.stringify(s, null, 2), "utf8");
81
+ } catch {
82
+ /* ignore */
83
+ }
84
+ }
85
+
86
+ function envelope(type, payload) {
87
+ return { type, payload, ts: Date.now() };
88
+ }
89
+
90
+ /** 建立到中继的出站 WS,返回 { ready, send, close, wait }。 */
91
+ function connect(url, agentId) {
92
+ const wsUrl = `${url}/ws/agent?token=${encodeURIComponent(agentId)}`;
93
+ const ws = new WebSocket(wsUrl, { headers: { Authorization: `Bearer ${agentId}` } });
94
+ return new Promise((resolve) => {
95
+ const listeners = new Map();
96
+ let opened = false;
97
+ ws.onopen = () => {
98
+ opened = true;
99
+ resolve({
100
+ isOpen: () => opened && ws.readyState === 1,
101
+ send: (env) => {
102
+ if (opened && ws.readyState === 1) {
103
+ ws.send(JSON.stringify(env));
104
+ return true;
105
+ }
106
+ return false;
107
+ },
108
+ on: (type, fn) => {
109
+ const arr = listeners.get(type) || [];
110
+ arr.push(fn);
111
+ listeners.set(type, arr);
112
+ },
113
+ close: () => {
114
+ try {
115
+ ws.close();
116
+ } catch {
117
+ /* ignore */
118
+ }
119
+ },
120
+ });
121
+ };
122
+ ws.onerror = () => {
123
+ if (!opened) {
124
+ resolve({
125
+ isOpen: () => false,
126
+ send: () => false,
127
+ on: () => {},
128
+ close: () => {},
129
+ });
130
+ }
131
+ };
132
+ ws.onclose = () => {};
133
+ ws.onmessage = (ev) => {
134
+ let msg;
135
+ try {
136
+ msg = JSON.parse(String(ev.data));
137
+ } catch {
138
+ return;
139
+ }
140
+ const fns = listeners.get(msg.type) || [];
141
+ for (const fn of fns) fn(msg);
142
+ };
143
+ });
144
+ }
145
+
146
+ async function runPair() {
147
+ const state = loadState();
148
+ const relay = state.relayUrl.replace(/\/+$/, "");
149
+ const httpBase = relay.replace(/^ws/, "http");
150
+ const link = (code) => {
151
+ const p = new URLSearchParams({ code, type: AGENT_TYPE, name: hostname() });
152
+ return `${httpBase}/app/#/pages/index/index?${p.toString()}`;
153
+ };
154
+
155
+ if (state.claimed) {
156
+ console.log("本机已绑定手机账号,可直接在手机「我的电脑」里连接;如需重新配对,请先在手机端解绑。");
157
+ return;
158
+ }
159
+
160
+ if (!relay) {
161
+ console.error("未配置中继地址:请设置环境变量 TUNNELBOX_RELAY_URL 后重试。");
162
+ process.exit(1);
163
+ }
164
+
165
+ const c = await connect(`${relay}/ws/agent?token=${encodeURIComponent(state.agentId)}`, state.agentId);
166
+ if (!c.isOpen()) {
167
+ console.error("无法连接中继:" + relay + "\n请设置 TUNNELBOX_RELAY_URL 或在官方-plugin 状态文件中配置。");
168
+ process.exit(1);
169
+ }
170
+ c.send(envelope("agent.info", {
171
+ name: hostname(),
172
+ version: "plugin",
173
+ type: AGENT_TYPE,
174
+ capabilities: { streaming: false, thinking: false, permission: true, commands: false, abort: false },
175
+ }));
176
+
177
+ await new Promise((resolve) => {
178
+ const t = setTimeout(() => resolve(), 10000);
179
+ c.on("pair.created", (m) => {
180
+ const code = m.payload?.code;
181
+ if (!code) return;
182
+ clearTimeout(t);
183
+ const l = link(code);
184
+ console.log("");
185
+ console.log("tunnelbox 手机配对");
186
+ console.log(" 配对码: " + code);
187
+ console.log(" 链接 : " + l);
188
+ console.log("手机浏览器打开链接,或打开 tunnelbox 应用输入配对码(一次性)。");
189
+ try {
190
+ mkdirSync(dirname(PAIRING_PATH()), { recursive: true });
191
+ writeFileSync(PAIRING_PATH(), `配对码: ${code}\n配对链接: ${l}\n`, "utf8");
192
+ } catch {
193
+ /* ignore */
194
+ }
195
+ console.log(` 配对信息已写入: ${PAIRING_PATH()}`);
196
+ resolve();
197
+ });
198
+ c.on("agent.claimed", () => {
199
+ state.claimed = true;
200
+ saveState(state);
201
+ });
202
+ if (!c.send(envelope("pair.create", {}))) {
203
+ clearTimeout(t);
204
+ console.error("向中继申请配对码失败。");
205
+ resolve();
206
+ }
207
+ });
208
+ c.close();
209
+ }
210
+
211
+ function runStatus() {
212
+ const state = loadState();
213
+ console.log(`中继: ${state.relayUrl}`);
214
+ console.log(`agent 标识: ${state.agentId}`);
215
+ console.log(`已绑定手机: ${state.claimed ? "是" : "否"}`);
216
+ console.log(`状态文件: ${STATE_PATH()}`);
217
+ if (existsSync(PAIRING_PATH())) {
218
+ console.log("最新配对信息:");
219
+ console.log(readFileSync(PAIRING_PATH(), "utf8").trim());
220
+ }
221
+ }
222
+
223
+ async function runAsk() {
224
+ // SDK 桥已接管审批 不拦截,让 canUseTool 处理
225
+ if (process.env.TUNNELBOX_SDK_SESSION === "1") return;
226
+
227
+ let input = "";
228
+ try {
229
+ const chunks = [];
230
+ for await (const c of process.stdin) chunks.push(c);
231
+ input = Buffer.concat(chunks).toString("utf8");
232
+ } catch {
233
+ return;
234
+ }
235
+ let req;
236
+ try {
237
+ req = JSON.parse(input);
238
+ } catch {
239
+ return;
240
+ }
241
+ if (!req || typeof req !== "object") return;
242
+
243
+ const state = loadState();
244
+ // 未绑定 / 未配置中继 / 中继不可达 → 不输出,交本地询问
245
+ if (!state.claimed || !state.relayUrl) return;
246
+ const tool = String(req.tool_name || req.toolName || "tool");
247
+ const id = `p-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
248
+
249
+ const c = await connect(`${state.relayUrl.replace(/\/+$/, "")}/ws/agent?token=${encodeURIComponent(state.agentId)}`, state.agentId);
250
+ if (!c.isOpen()) return;
251
+
252
+ const sessionID = String(req.session_id || req.sessionId || req.project_dir || "local-session");
253
+ const toolInput = req.tool_input || req.toolInput || req.tool_use_input || {};
254
+ // PermissionRequest hook 输入携带的"总是允许"候选(手机 allow+always 时原样回填 updatedPermissions)
255
+ const suggestions = req.permission_suggestions || req.permissionSuggestions || null;
256
+ const prompt =
257
+ binMsg("bin.reqRun") + tool +
258
+ (Object.keys(toolInput).length ? `\n${JSON.stringify(toolInput, null, 2)}` : "");
259
+
260
+ // PermissionRequest 事件的结构化决定:外层必须 hookSpecificOutput + hookEventName,
261
+ // decision.behavior allow/deny;deny 原因放 message;allow 时可用 updatedPermissions 回填"总是允许"
262
+ const decisionJson = (behavior, reason, updatedPermissions) => {
263
+ const decision = { behavior };
264
+ if (reason && behavior === "deny") decision.message = reason;
265
+ if (updatedPermissions && updatedPermissions.length) decision.updatedPermissions = updatedPermissions;
266
+ return {
267
+ hookSpecificOutput: {
268
+ hookEventName: "PermissionRequest",
269
+ decision,
270
+ },
271
+ };
272
+ };
273
+
274
+ const decision = await new Promise((resolve) => {
275
+ let settled = false;
276
+ const done = (v) => {
277
+ if (settled) return;
278
+ settled = true;
279
+ clearTimeout(timer);
280
+ resolve(v);
281
+ };
282
+ const timer = setTimeout(
283
+ () => done(decisionJson("deny", binMsg("bin.timeout"))),
284
+ TIMEOUT_MS,
285
+ );
286
+ c.on("permission.reply", (m) => {
287
+ const p = m.payload || {};
288
+ if (String(p.permissionID) !== id) return;
289
+ if (p.status === "allow") {
290
+ done(decisionJson("allow", "", p.always && suggestions ? suggestions : undefined));
291
+ } else {
292
+ done(decisionJson("deny", p.reason || binMsg("bin.denied")));
293
+ }
294
+ });
295
+ c.on("permission.replied", (m) => {
296
+ const p = m.payload || {};
297
+ if (String(p.permissionID) !== id) return;
298
+ if (p.status === "allow") done(decisionJson("allow"));
299
+ else done(decisionJson("deny", binMsg("bin.denied")));
300
+ });
301
+ c.send(
302
+ envelope("permission.request", {
303
+ id,
304
+ sessionID,
305
+ tool,
306
+ args: toolInput,
307
+ prompt,
308
+ createdAt: Date.now(),
309
+ }),
310
+ );
311
+ });
312
+ c.close();
313
+ process.stdout.write(JSON.stringify(decision) + "\n");
314
+ }
315
+
316
+ async function main() {
317
+ const cmd = process.argv[2] || "";
318
+ if (cmd === "pair") await runPair();
319
+ else if (cmd === "status") runStatus();
320
+ else if (cmd === "ask") await runAsk();
321
+ else {
322
+ console.log("用法: tunnelbox <pair|status|ask>");
323
+ console.log(" pair 取手机配对码并打印");
324
+ console.log(" status 显示中继/绑定状态");
325
+ console.log(" ask 供 PermissionRequest hook 调用(把审批转发到手机)");
326
+ }
327
+ }
328
+
329
+ main().catch((e) => {
330
+ console.error(e && e.message ? e.message : String(e));
331
+ process.exit(1);
332
+ });