@tunnelbox/core 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.
package/README.md ADDED
@@ -0,0 +1,23 @@
1
+ # @tunnelbox/core
2
+
3
+ tunnelbox 共享核心,供各智能体适配器复用:
4
+
5
+ | 模块 | 说明 |
6
+ |---|---|
7
+ | `types.ts` | 统一协议类型 + `envelope()`(与 relay/web 镜像互通) |
8
+ | `relay.ts` | `RelayClient`:出站 WS + 指数退避**静默**重连 |
9
+ | `state.ts` | 状态持久化(`remote-state[.type].json`,多 agent 隔离)+ 配对文件 |
10
+ | `qr.ts` | `printPairing`:终端二维码 + 配对码打印 |
11
+
12
+ ## 使用
13
+
14
+ ```ts
15
+ import { RelayClient, envelope, loadState, printPairing } from "@tunnelbox/core";
16
+
17
+ const state = loadState("dsh"); // 多智能体:各适配器独立状态文件
18
+ const ws = new RelayClient();
19
+ ws.connect(`${state.relayUrl}/ws/agent`, { Authorization: `Bearer ${state.agentId}` }, { … });
20
+ ws.send(envelope("agent.info", { name: "host", type: "dsh", version: "0.1.0" }));
21
+ ```
22
+
23
+ 消费方(`plugin/opencode` / `plugin/dsh-tunnelbox` / 各适配器)通过 bundler(esbuild/vite)直接打包本包 TS 源码;无需独立构建步骤。
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@tunnelbox/core",
3
+ "version": "0.1.0",
4
+ "description": "tunnelbox 共享核心:协议类型、中继客户端、状态持久化、二维码配对",
5
+ "type": "module",
6
+ "main": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": "./src/index.ts"
10
+ },
11
+ "files": [
12
+ "src"
13
+ ],
14
+ "scripts": {
15
+ "type-check": "tsc --noEmit"
16
+ },
17
+ "dependencies": {
18
+ "qrcode": "^1.5.3"
19
+ },
20
+ "devDependencies": {
21
+ "@types/node": "^22.0.0",
22
+ "@types/qrcode": "^1.5.5",
23
+ "typescript": "^5.4.5"
24
+ }
25
+ }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 共享核心运行环境(Bun/Node)全局类型补充。不引入 DOM lib,仅声明用到的 WebSocket 成员。
3
+ */
4
+ interface WebSocket {
5
+ onopen: (() => void) | null;
6
+ onmessage: ((ev: { data: unknown }) => void) | null;
7
+ onerror: ((err: unknown) => void) | null;
8
+ onclose: (() => void) | null;
9
+ send(data: string): void;
10
+ close(): void;
11
+ }
12
+
13
+ declare const WebSocket: {
14
+ new (url: string, protocolsOrOptions?: unknown): WebSocket;
15
+ };
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * @tunnelbox/core —— 共享核心。
3
+ * 供各智能体适配器(opencode 插件 / dsh / claude-code / codex / …)复用:
4
+ * 统一协议类型、中继客户端(静默重连)、状态持久化、二维码配对。
5
+ */
6
+ export * from "./types";
7
+ export * from "./relay";
8
+ export * from "./state";
9
+ export * from "./qr";
package/src/qr.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * 终端二维码与配对信息打印。qrcode 为可选依赖,失败时降级为链接。
3
+ */
4
+ import { writePairingFile } from "./state";
5
+
6
+ export async function printPairing(
7
+ relayHttpBase: string,
8
+ code: string,
9
+ type?: string,
10
+ name?: string,
11
+ ): Promise<void> {
12
+ const p = new URLSearchParams({ code });
13
+ if (type) p.set("type", type);
14
+ if (name) p.set("name", name);
15
+ const link = `${relayHttpBase}/app/#/pages/index/index?${p.toString()}`;
16
+ const file = writePairingFile(link, code, type);
17
+ const tag = type ? ` ${type}` : "";
18
+
19
+ console.log("");
20
+ console.log("┌──────────────────────────────────────────────────────────┐");
21
+ console.log(`│ tunnelbox${tag} 手机配对 │`);
22
+ console.log("├──────────────────────────────────────────────────────────┤");
23
+ console.log(`│ 配对码: ${code.padEnd(42)} │`);
24
+ console.log(`│ 链接 : ${link.padEnd(42)} │`);
25
+ console.log("└──────────────────────────────────────────────────────────┘");
26
+ console.log(" 手机浏览器打开链接,或打开 App 后手动输入配对码。");
27
+
28
+ try {
29
+ const mod = await import("qrcode");
30
+ const render = (mod as { default?: unknown }).default ?? mod;
31
+ const toString = (render as { toString?: (text: string, opts: unknown) => Promise<string> }).toString;
32
+ if (toString) {
33
+ const qr = await toString(link, { type: "terminal", small: true });
34
+ if (qr) console.log(qr);
35
+ }
36
+ } catch {
37
+ console.log(" (未安装 qrcode,请使用上方链接或配对码)");
38
+ }
39
+
40
+ console.log(` 配对信息已写入: ${file}`);
41
+ console.log("");
42
+ }
package/src/relay.ts ADDED
@@ -0,0 +1,106 @@
1
+ /**
2
+ * 出站 WebSocket 客户端(连接中继 /ws/agent),带指数退避自动重连。
3
+ * 使用运行时全局 WebSocket(Bun/Node ≥21 均可用)。各智能体适配器共用。
4
+ */
5
+ import type { Envelope } from "./types";
6
+
7
+ export interface RelayClientHandlers {
8
+ onOpen: () => void;
9
+ onMessage: (env: Envelope) => void;
10
+ onClose: () => void;
11
+ onError: (err: unknown) => void;
12
+ }
13
+
14
+ const BASE_DELAY = 1000;
15
+ const MAX_DELAY = 15000;
16
+
17
+ export class RelayClient {
18
+ private ws: WebSocket | null = null;
19
+ private url = "";
20
+ private headers: Record<string, string> = {};
21
+ private handlers: RelayClientHandlers | null = null;
22
+ private timer: ReturnType<typeof setTimeout> | null = null;
23
+ private retry = 0;
24
+ private stopped = false;
25
+ private connected = false;
26
+
27
+ get isConnected(): boolean {
28
+ return this.connected;
29
+ }
30
+
31
+ connect(url: string, headers: Record<string, string>, handlers: RelayClientHandlers): void {
32
+ this.url = url;
33
+ this.headers = headers;
34
+ this.handlers = handlers;
35
+ this.stopped = false;
36
+ this.retry = 0;
37
+ this.open();
38
+ }
39
+
40
+ send(env: Envelope): boolean {
41
+ if (this.ws && this.connected) {
42
+ try {
43
+ this.ws.send(JSON.stringify(env));
44
+ return true;
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+
52
+ close(): void {
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
+ /* ignore */
61
+ }
62
+ }
63
+ this.ws = null;
64
+ this.connected = false;
65
+ }
66
+
67
+ private open(): void {
68
+ if (this.stopped) return;
69
+ try {
70
+ // 部分运行时 WebSocket 不支持 headers 选项;中继同时支持 URL query token 作为兜底。
71
+ const ws = new WebSocket(this.url, { headers: this.headers }) as WebSocket;
72
+ this.ws = ws;
73
+
74
+ ws.onopen = () => {
75
+ this.connected = true;
76
+ this.retry = 0;
77
+ this.handlers?.onOpen();
78
+ };
79
+ ws.onmessage = (ev) => {
80
+ try {
81
+ const msg = JSON.parse(String(ev.data)) as Envelope;
82
+ this.handlers?.onMessage(msg);
83
+ } catch {
84
+ /* 忽略非 JSON 帧 */
85
+ }
86
+ };
87
+ ws.onerror = (e) => this.handlers?.onError(e);
88
+ ws.onclose = () => {
89
+ this.connected = false;
90
+ this.ws = null;
91
+ this.handlers?.onClose();
92
+ this.scheduleReconnect();
93
+ };
94
+ } catch (e) {
95
+ this.handlers?.onError(e);
96
+ this.scheduleReconnect();
97
+ }
98
+ }
99
+
100
+ private scheduleReconnect(): void {
101
+ if (this.stopped) return;
102
+ const delay = Math.min(BASE_DELAY * 2 ** this.retry, MAX_DELAY);
103
+ this.retry++;
104
+ this.timer = setTimeout(() => this.open(), delay);
105
+ }
106
+ }
package/src/state.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * 本地状态持久化。默认存于 opencode 全局配置目录;支持按智能体类型隔离文件
3
+ * (多 agent 同机时各适配器使用独立文件:remote-state.<type>.json)。
4
+ */
5
+ import { randomBytes } from "node:crypto";
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { dirname, join } from "node:path";
9
+
10
+ /**
11
+ * 默认中继地址(按环境区分,生产部署建议用 opencode.json 或
12
+ * TUNNELBOX_RELAY_URL 显式配置,优先级最高):
13
+ * - 生产(NODE_ENV=production):wss://chat.wxngrok.com
14
+ * - 开发/其他:ws://127.0.0.1:8080
15
+ */
16
+ export const DEFAULT_RELAY_URL =
17
+ process.env.TUNNELBOX_RELAY_URL ||
18
+ (process.env.NODE_ENV === "production" ? "wss://chat.wxngrok.com" : "ws://127.0.0.1:8080");
19
+
20
+ export interface State {
21
+ agentId: string;
22
+ relayUrl: string;
23
+ /** 账号体系(Phase 1):是否已被手机账号认领。认领后不再自动申请配对码。 */
24
+ claimed?: boolean;
25
+ }
26
+
27
+ function baseName(): string {
28
+ return join(homedir(), ".config", "opencode");
29
+ }
30
+
31
+ function suffix(type?: string): string {
32
+ return type ? `.${type}` : "";
33
+ }
34
+
35
+ export function statePath(type?: string): string {
36
+ return join(baseName(), `remote-state${suffix(type)}.json`);
37
+ }
38
+
39
+ export function saveState(st: State, type?: string): void {
40
+ try {
41
+ const p = statePath(type);
42
+ mkdirSync(dirname(p), { recursive: true });
43
+ writeFileSync(p, JSON.stringify(st, null, 2), "utf8");
44
+ } catch (e) {
45
+ console.error("[tunnelbox] 保存状态失败", e);
46
+ }
47
+ }
48
+
49
+ /** 读取状态;不存在时生成新 agentID 并持久化。 */
50
+ export function loadState(type?: string): State {
51
+ const fallback: State = {
52
+ agentId: randomBytes(16).toString("hex"),
53
+ relayUrl: DEFAULT_RELAY_URL,
54
+ };
55
+ try {
56
+ if (!existsSync(statePath(type))) {
57
+ saveState(fallback, type);
58
+ return fallback;
59
+ }
60
+ const parsed = JSON.parse(readFileSync(statePath(type), "utf8")) as Partial<State>;
61
+ if (parsed.agentId && parsed.agentId.length >= 16) {
62
+ return {
63
+ agentId: parsed.agentId,
64
+ relayUrl: parsed.relayUrl || DEFAULT_RELAY_URL,
65
+ claimed: parsed.claimed === true,
66
+ };
67
+ }
68
+ saveState(fallback, type);
69
+ return fallback;
70
+ } catch {
71
+ saveState(fallback, type);
72
+ return fallback;
73
+ }
74
+ }
75
+
76
+ /** 把配对信息写入本地文件,作为终端日志被吞时的兜底。 */
77
+ export function writePairingFile(link: string, code: string, type?: string): string {
78
+ const p = join(baseName(), `remote-pairing${suffix(type)}.txt`);
79
+ try {
80
+ mkdirSync(dirname(p), { recursive: true });
81
+ writeFileSync(p, `配对码: ${code}\n配对链接: ${link}\n`, "utf8");
82
+ } catch {
83
+ /* ignore */
84
+ }
85
+ return p;
86
+ }
87
+
88
+ export interface PairingInfo {
89
+ code: string;
90
+ link: string;
91
+ relayUrl: string;
92
+ at: number;
93
+ }
94
+
95
+ /** 把最新配对信息写入 JSON(供配套 UI/工具读取)。 */
96
+ export function writePairingJson(info: PairingInfo, type?: string): string {
97
+ const p = join(baseName(), `remote-pairing${suffix(type)}.json`);
98
+ try {
99
+ mkdirSync(dirname(p), { recursive: true });
100
+ writeFileSync(p, JSON.stringify(info, null, 2), "utf8");
101
+ } catch {
102
+ /* ignore */
103
+ }
104
+ return p;
105
+ }
package/src/types.ts ADDED
@@ -0,0 +1,127 @@
1
+ /**
2
+ * tunnelbox 统一协议类型(各智能体适配器共享)。
3
+ * 与 relay/internal/proto、web/src/protocol 通过 JSON 互通(镜像维护)。
4
+ */
5
+
6
+ export interface Envelope<T = unknown> {
7
+ id?: string;
8
+ type: string;
9
+ payload: T;
10
+ ts: number;
11
+ /** 中继注入/回填:手机端会话 id,用于回路由 */
12
+ clientID?: string;
13
+ }
14
+
15
+ export function envelope<T>(type: string, payload: T, id?: string): Envelope<T> {
16
+ return { id, type, payload, ts: Date.now() };
17
+ }
18
+
19
+ export interface AgentInfo {
20
+ name: string;
21
+ version: string;
22
+ platform?: string;
23
+ directory?: string;
24
+ /** 智能体类型:opencode | claude-code | codex | dsh | openclaw | hermes | cursor */
25
+ type?: string;
26
+ /** IANA 时区名(如 Asia/Shanghai),仅用于"电脑本地时间"展示 */
27
+ timezone?: string;
28
+ /** 能力位 */
29
+ capabilities?: {
30
+ streaming?: boolean;
31
+ thinking?: boolean;
32
+ permission?: boolean;
33
+ commands?: boolean;
34
+ abort?: boolean;
35
+ };
36
+ }
37
+
38
+ export type SessionStatus = "queued" | "running" | "idle" | "error";
39
+
40
+ export interface SessionInfo {
41
+ id: string;
42
+ title: string;
43
+ created: number;
44
+ updated: number;
45
+ status: SessionStatus;
46
+ /** 会话所在工作区目录(dsh 按 workspace 分目录持久化) */
47
+ workspace?: string;
48
+ }
49
+
50
+ export interface WorkspaceInfo {
51
+ /** 工作区目录绝对路径 */
52
+ path: string;
53
+ /** 展示名(取目录名) */
54
+ name: string;
55
+ /** 该工作区内的会话数 */
56
+ sessionCount: number;
57
+ /** 最近会话更新时间 */
58
+ updated: number;
59
+ }
60
+
61
+ export interface Part {
62
+ id: string;
63
+ type: "text" | "tool" | "thinking";
64
+ text?: string;
65
+ delta?: string;
66
+ tool?: string;
67
+ args?: string;
68
+ complete?: boolean;
69
+ }
70
+
71
+ export interface ChatMessage {
72
+ id: string;
73
+ role: "user" | "assistant" | "tool" | "system";
74
+ parts: Part[];
75
+ created: number;
76
+ }
77
+
78
+ /** 权限请求的作答方式:approval=允许/拒绝/总是允许;choice=从选项中选择;input=输入文本。 */
79
+ export type PermissionKind = "approval" | "choice" | "input";
80
+
81
+ export interface PermissionRequest {
82
+ id: string;
83
+ sessionID: string;
84
+ tool: string;
85
+ args: Record<string, unknown>;
86
+ prompt?: string;
87
+ createdAt: number;
88
+ /** 作答方式(缺省视为 approval) */
89
+ kind?: PermissionKind;
90
+ /** choice 类型的可选项(显示为按钮列表) */
91
+ options?: string[];
92
+ }
93
+
94
+ /** 结构化提问(opencode question.v2.asked 等)中的单条问题。 */
95
+ export interface QuestionInfo {
96
+ question: string;
97
+ header?: string;
98
+ options: string[];
99
+ multiple?: boolean;
100
+ custom?: boolean;
101
+ }
102
+
103
+ export interface QuestionRequest {
104
+ id: string;
105
+ sessionID: string;
106
+ prompt?: string;
107
+ questions: QuestionInfo[];
108
+ createdAt: number;
109
+ }
110
+
111
+ export interface CommandInfo {
112
+ name: string;
113
+ description?: string;
114
+ }
115
+
116
+ /** 账号体系(Phase 1):电脑被手机账号认领后 relay 下发。 */
117
+ export interface AgentClaimedMessage {
118
+ email?: string;
119
+ }
120
+
121
+ /** 账号体系(Phase 1):电脑被解绑后 relay 下发,适配器应重置并重新生成 agentID。 */
122
+ export interface AgentRevokedMessage {}
123
+
124
+ /** agent 断开后中继推送给已连接手机(在线信号为 agent.info 广播)。 */
125
+ export interface AgentOfflineMessage {
126
+ agentID?: string;
127
+ }