linkshell-cli 0.9.0 → 0.10.1

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 (168) hide show
  1. package/README.md +11 -6
  2. package/dist/cli/src/commands/gateway.d.ts +2 -0
  3. package/dist/cli/src/commands/gateway.js +29 -0
  4. package/dist/cli/src/commands/gateway.js.map +1 -0
  5. package/dist/cli/src/commands/host.d.ts +2 -0
  6. package/dist/cli/src/commands/host.js +12 -2
  7. package/dist/cli/src/commands/host.js.map +1 -1
  8. package/dist/cli/src/commands/setup.js +4 -2
  9. package/dist/cli/src/commands/setup.js.map +1 -1
  10. package/dist/cli/src/index.js +12 -334
  11. package/dist/cli/src/index.js.map +1 -1
  12. package/dist/cli/src/utils/daemon.d.ts +0 -7
  13. package/dist/cli/src/utils/daemon.js +1 -18
  14. package/dist/cli/src/utils/daemon.js.map +1 -1
  15. package/dist/cli/tsconfig.tsbuildinfo +1 -1
  16. package/dist/gateway/src/accounts.d.ts +6 -0
  17. package/dist/gateway/src/accounts.js +32 -0
  18. package/dist/gateway/src/accounts.js.map +1 -0
  19. package/dist/gateway/src/index.d.ts +4 -0
  20. package/dist/gateway/src/index.js +5 -0
  21. package/dist/gateway/src/index.js.map +1 -0
  22. package/dist/gateway/src/main.d.ts +1 -0
  23. package/dist/gateway/src/main.js +57 -0
  24. package/dist/gateway/src/main.js.map +1 -0
  25. package/dist/gateway/src/rate-limit.d.ts +22 -0
  26. package/dist/gateway/src/rate-limit.js +66 -0
  27. package/dist/gateway/src/rate-limit.js.map +1 -0
  28. package/dist/gateway/src/relay.d.ts +84 -0
  29. package/dist/gateway/src/relay.js +434 -0
  30. package/dist/gateway/src/relay.js.map +1 -0
  31. package/dist/gateway/src/serve.d.ts +34 -0
  32. package/dist/gateway/src/serve.js +102 -0
  33. package/dist/gateway/src/serve.js.map +1 -0
  34. package/dist/gateway/src/store.d.ts +35 -0
  35. package/dist/gateway/src/store.js +109 -0
  36. package/dist/gateway/src/store.js.map +1 -0
  37. package/dist/gateway/src/subscription.d.ts +18 -0
  38. package/dist/gateway/src/subscription.js +46 -0
  39. package/dist/gateway/src/subscription.js.map +1 -0
  40. package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
  41. package/dist/host/src/drivers/codex/app-server.d.ts +31 -6
  42. package/dist/host/src/drivers/codex/app-server.js +99 -24
  43. package/dist/host/src/drivers/codex/app-server.js.map +1 -1
  44. package/dist/host/src/drivers/codex/desktop-ipc.d.ts +8 -0
  45. package/dist/host/src/drivers/codex/desktop-ipc.js +124 -0
  46. package/dist/host/src/drivers/codex/desktop-ipc.js.map +1 -0
  47. package/dist/host/src/drivers/codex/driver.d.ts +111 -2
  48. package/dist/host/src/drivers/codex/driver.js +629 -63
  49. package/dist/host/src/drivers/codex/driver.js.map +1 -1
  50. package/dist/host/src/drivers/codex/mapper.d.ts +21 -0
  51. package/dist/host/src/drivers/codex/mapper.js +45 -6
  52. package/dist/host/src/drivers/codex/mapper.js.map +1 -1
  53. package/dist/host/src/drivers/types.d.ts +6 -0
  54. package/dist/host/src/fs.d.ts +3 -3
  55. package/dist/host/src/hub.js +8 -1
  56. package/dist/host/src/hub.js.map +1 -1
  57. package/dist/host/src/input.d.ts +4 -4
  58. package/dist/host/src/terminals.d.ts +1 -1
  59. package/dist/wire/src/model.d.ts +6 -6
  60. package/dist/wire/src/relay.d.ts +8 -8
  61. package/dist/wire/src/rpc.d.ts +818 -818
  62. package/dist/wire/src/updates.d.ts +18 -18
  63. package/package.json +5 -14
  64. package/src/commands/gateway.ts +31 -0
  65. package/src/commands/host.ts +13 -2
  66. package/src/commands/setup.ts +4 -2
  67. package/src/index.ts +14 -429
  68. package/src/utils/daemon.ts +2 -27
  69. package/dist/cli/src/config.d.ts +0 -14
  70. package/dist/cli/src/config.js +0 -23
  71. package/dist/cli/src/config.js.map +0 -1
  72. package/dist/cli/src/machine-id.d.ts +0 -6
  73. package/dist/cli/src/machine-id.js +0 -50
  74. package/dist/cli/src/machine-id.js.map +0 -1
  75. package/dist/cli/src/providers.d.ts +0 -13
  76. package/dist/cli/src/providers.js +0 -77
  77. package/dist/cli/src/providers.js.map +0 -1
  78. package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -128
  79. package/dist/cli/src/runtime/acp/acp-client.js +0 -430
  80. package/dist/cli/src/runtime/acp/acp-client.js.map +0 -1
  81. package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +0 -48
  82. package/dist/cli/src/runtime/acp/agent-process-discovery.js +0 -278
  83. package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +0 -1
  84. package/dist/cli/src/runtime/acp/agent-session.d.ts +0 -62
  85. package/dist/cli/src/runtime/acp/agent-session.js +0 -1081
  86. package/dist/cli/src/runtime/acp/agent-session.js.map +0 -1
  87. package/dist/cli/src/runtime/acp/agent-workspace.d.ts +0 -311
  88. package/dist/cli/src/runtime/acp/agent-workspace.js +0 -6083
  89. package/dist/cli/src/runtime/acp/agent-workspace.js.map +0 -1
  90. package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +0 -121
  91. package/dist/cli/src/runtime/acp/claude-sdk-client.js +0 -1147
  92. package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +0 -1
  93. package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +0 -53
  94. package/dist/cli/src/runtime/acp/claude-stream-json-client.js +0 -552
  95. package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +0 -1
  96. package/dist/cli/src/runtime/acp/json-rpc.d.ts +0 -22
  97. package/dist/cli/src/runtime/acp/json-rpc.js +0 -160
  98. package/dist/cli/src/runtime/acp/json-rpc.js.map +0 -1
  99. package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -16
  100. package/dist/cli/src/runtime/acp/provider-resolver.js +0 -132
  101. package/dist/cli/src/runtime/acp/provider-resolver.js.map +0 -1
  102. package/dist/cli/src/runtime/bridge-session.d.ts +0 -97
  103. package/dist/cli/src/runtime/bridge-session.js +0 -1364
  104. package/dist/cli/src/runtime/bridge-session.js.map +0 -1
  105. package/dist/cli/src/runtime/hook-command.d.ts +0 -9
  106. package/dist/cli/src/runtime/hook-command.js +0 -80
  107. package/dist/cli/src/runtime/hook-command.js.map +0 -1
  108. package/dist/cli/src/runtime/screen-fallback.d.ts +0 -22
  109. package/dist/cli/src/runtime/screen-fallback.js +0 -202
  110. package/dist/cli/src/runtime/screen-fallback.js.map +0 -1
  111. package/dist/cli/src/runtime/screen-share.d.ts +0 -34
  112. package/dist/cli/src/runtime/screen-share.js +0 -376
  113. package/dist/cli/src/runtime/screen-share.js.map +0 -1
  114. package/dist/cli/src/runtime/scrollback.d.ts +0 -11
  115. package/dist/cli/src/runtime/scrollback.js +0 -33
  116. package/dist/cli/src/runtime/scrollback.js.map +0 -1
  117. package/dist/cli/src/runtime/usage-report.d.ts +0 -34
  118. package/dist/cli/src/runtime/usage-report.js +0 -332
  119. package/dist/cli/src/runtime/usage-report.js.map +0 -1
  120. package/dist/cli/src/utils/keep-awake.d.ts +0 -6
  121. package/dist/cli/src/utils/keep-awake.js +0 -48
  122. package/dist/cli/src/utils/keep-awake.js.map +0 -1
  123. package/dist/cli/src/utils/lan-ip.d.ts +0 -9
  124. package/dist/cli/src/utils/lan-ip.js +0 -61
  125. package/dist/cli/src/utils/lan-ip.js.map +0 -1
  126. package/dist/host/src/input-helper.d.ts +0 -1
  127. package/dist/host/src/input-helper.js +0 -564
  128. package/dist/host/src/input-helper.js.map +0 -1
  129. package/dist/shared-protocol/src/index.d.ts +0 -17029
  130. package/dist/shared-protocol/src/index.js +0 -1064
  131. package/dist/shared-protocol/src/index.js.map +0 -1
  132. package/scripts/copy-web.mjs +0 -34
  133. package/src/config.ts +0 -36
  134. package/src/machine-id.ts +0 -61
  135. package/src/providers.ts +0 -97
  136. package/src/runtime/acp/acp-client.ts +0 -521
  137. package/src/runtime/acp/agent-process-discovery.ts +0 -308
  138. package/src/runtime/acp/agent-session.ts +0 -1186
  139. package/src/runtime/acp/agent-workspace.ts +0 -6590
  140. package/src/runtime/acp/claude-sdk-client.ts +0 -1221
  141. package/src/runtime/acp/claude-stream-json-client.ts +0 -633
  142. package/src/runtime/acp/json-rpc.ts +0 -184
  143. package/src/runtime/acp/provider-resolver.ts +0 -154
  144. package/src/runtime/bridge-session.ts +0 -1549
  145. package/src/runtime/hook-command.ts +0 -73
  146. package/src/runtime/screen-fallback.ts +0 -235
  147. package/src/runtime/screen-share.ts +0 -426
  148. package/src/runtime/scrollback.ts +0 -43
  149. package/src/runtime/usage-report.ts +0 -342
  150. package/src/utils/keep-awake.ts +0 -61
  151. package/src/utils/lan-ip.ts +0 -61
  152. package/web/assets/FileBrowser-wOAfY-xj.js +0 -2
  153. package/web/assets/PortPreview-Cbvv7yzA.js +0 -2
  154. package/web/assets/TerminalPanel-CTSklkct.js +0 -1
  155. package/web/assets/UsageDashboard-CJ1AsNmd.js +0 -1
  156. package/web/assets/index-BsnDaeCt.css +0 -33
  157. package/web/assets/index-q85psYZ5.js +0 -36
  158. package/web/assets/vendor-highlight-NXWAAyIv.js +0 -5
  159. package/web/assets/vendor-react-M7xG3qiw.js +0 -49
  160. package/web/assets/vendor-xterm-D15THcI4.js +0 -9
  161. package/web/index.html +0 -37
  162. package/web/logos/claude.png +0 -0
  163. package/web/logos/codex.png +0 -0
  164. package/web/logos/copilot.png +0 -0
  165. package/web/logos/gemini.png +0 -0
  166. package/web/logos/linkshell-1024.png +0 -0
  167. package/web/logos/linkshell-splash.png +0 -0
  168. package/web/logos/linkshell.png +0 -0
@@ -1,1549 +0,0 @@
1
- import * as pty from "node-pty";
2
- import * as http from "node:http";
3
- import WebSocket from "ws";
4
- import { hostname, platform, homedir } from "node:os";
5
- import { writeFileSync, readFileSync, readdirSync, statSync, mkdirSync, existsSync, openSync, readSync, closeSync } from "node:fs";
6
- import { tmpdir } from "node:os";
7
- import { join, basename, resolve, relative, isAbsolute } from "node:path";
8
- import {
9
- agentV2MessageRoute,
10
- createEnvelope,
11
- parseEnvelope,
12
- parseTypedPayload,
13
- serializeEnvelope,
14
- PROTOCOL_VERSION,
15
- } from "@linkshell/protocol";
16
- import type { Envelope } from "@linkshell/protocol";
17
- import type { ProviderConfig } from "../providers.js";
18
- import { ScrollbackBuffer } from "./scrollback.js";
19
- import { ScreenFallback } from "./screen-fallback.js";
20
- import { ScreenShare } from "./screen-share.js";
21
- import { getLanIp } from "../utils/lan-ip.js";
22
- import { startKeepAwake, type KeepAwakeHandle } from "../utils/keep-awake.js";
23
- import { loadOrCreateMachineIdentity, type MachineIdentity } from "../machine-id.js";
24
- import { getValidToken, refreshAccessToken } from "../auth.js";
25
- import { AgentSessionProxy } from "./acp/agent-session.js";
26
- import { AgentWorkspaceProxy, makeAgentV2RemoteConversationId } from "./acp/agent-workspace.js";
27
- import { detectAvailableProviders, type AgentProvider } from "./acp/provider-resolver.js";
28
- import { sweepLinkShellHookConfigs } from "./hook-command.js";
29
-
30
- export interface BridgeSessionOptions {
31
- gatewayUrl: string;
32
- gatewayHttpUrl: string;
33
- pairingGateway?: string;
34
- sessionId?: string;
35
- cols: number;
36
- rows: number;
37
- clientName: string;
38
- hostname?: string;
39
- verbose?: boolean;
40
- screen?: boolean;
41
- providerConfig: ProviderConfig;
42
- authToken?: string;
43
- keepAwake?: boolean;
44
- agentUi?: boolean;
45
- agentProvider?: AgentProvider;
46
- agentCommand?: string;
47
- /**
48
- * Confinement root for client-driven filesystem operations
49
- * (terminal.browse / terminal.file.read / terminal.mkdir).
50
- * Defaults to the user's home directory. Paths resolving outside this root are rejected.
51
- */
52
- fileRoot?: string;
53
- /**
54
- * Ports the tunnel proxy is allowed to connect to on 127.0.0.1.
55
- * Defaults to DEFAULT_TUNNEL_PORTS when omitted.
56
- */
57
- allowedTunnelPorts?: number[];
58
- }
59
-
60
- const HEARTBEAT_INTERVAL = 15_000;
61
- // Transport-layer WS keepalive: ping the gateway periodically; if a pong
62
- // doesn't arrive before the next ping, the socket is half-open (network
63
- // silently dropped, no FIN) — terminate it so the reconnect loop kicks in.
64
- const WS_PING_INTERVAL = 20_000;
65
- const RECONNECT_BASE_DELAY = 1_000;
66
- const RECONNECT_MAX_DELAY = 30_000;
67
- // Cap the exponent so the backoff levels off at RECONNECT_MAX_DELAY instead of
68
- // overflowing. There is NO attempt ceiling — a connected host never gives up
69
- // (only `stop()`/all-PTYs-exited ends it), so it survives gateway restarts and
70
- // arbitrarily long network outages, reconnecting whenever connectivity returns.
71
- const RECONNECT_MAX_EXPONENT = 5;
72
- const DEFAULT_TERMINAL_ID = "default";
73
- const SCROLLBACK_LINES = 500;
74
- // Upper bound on concurrent live terminals — forceNew removed the cwd-dedup
75
- // ceiling, so bound growth to avoid PTY/fd exhaustion from a runaway client.
76
- const MAX_TERMINALS = 12;
77
- // How long to keep an exited terminal (with its scrollback) in memory so the
78
- // client can replay its final output before it is reaped.
79
- const EXITED_TERMINAL_GRACE_MS = 30_000;
80
- // Default ports the bridge will proxy tunnel requests to when no explicit
81
- // allowlist is configured. Covers the common dev servers the app tunnels to.
82
- const DEFAULT_TUNNEL_PORTS = [3000, 3001, 4321, 5173, 5174, 8080, 8000, 8081];
83
- interface TerminalInstance {
84
- id: string;
85
- pty: pty.IPty;
86
- cwd: string;
87
- projectName: string;
88
- provider: string;
89
- scrollback: ScrollbackBuffer;
90
- outputSeq: number;
91
- statusSeq: number;
92
- status: "running" | "exited";
93
- }
94
-
95
- function stripGatewayBase(url: URL): string {
96
- url.hash = "";
97
- url.search = "";
98
- url.pathname = "";
99
- return url.toString().replace(/\/+$/, "");
100
- }
101
-
102
- function isHttpGatewayUrl(url: URL): boolean {
103
- return url.protocol === "http:" || url.protocol === "https:";
104
- }
105
-
106
- function getPairingGatewayParam(gatewayHttpUrl: string): string | undefined {
107
- try {
108
- const url = new URL(gatewayHttpUrl);
109
- if (!isHttpGatewayUrl(url)) {
110
- return undefined;
111
- }
112
- const hostname = url.hostname.trim().toLowerCase();
113
- if (
114
- hostname === "localhost" ||
115
- hostname === "127.0.0.1" ||
116
- hostname === "0.0.0.0" ||
117
- hostname === "::1"
118
- ) {
119
- // Replace localhost with LAN IP so real devices can reach it
120
- const lanIp = getLanIp();
121
- if (lanIp === "127.0.0.1") {
122
- return undefined; // No LAN interface found, can't help
123
- }
124
- url.hostname = lanIp;
125
- return stripGatewayBase(url);
126
- }
127
- return stripGatewayBase(url);
128
- } catch {
129
- return gatewayHttpUrl.replace(/\/+$/, "") || undefined;
130
- }
131
- }
132
-
133
- export function resolvePairingGateway(
134
- gatewayHttpUrl: string,
135
- pairingGateway?: string,
136
- ): string | undefined {
137
- const override = pairingGateway?.trim();
138
- if (!override) {
139
- return getPairingGatewayParam(gatewayHttpUrl);
140
- }
141
-
142
- try {
143
- const absoluteUrl = new URL(override);
144
- if (isHttpGatewayUrl(absoluteUrl)) {
145
- return stripGatewayBase(absoluteUrl);
146
- }
147
- } catch {
148
- // Fall through and treat the override as a host[:port] value.
149
- }
150
-
151
- try {
152
- const baseUrl = new URL(gatewayHttpUrl);
153
- if (!isHttpGatewayUrl(baseUrl)) {
154
- return override.replace(/\/+$/, "") || undefined;
155
- }
156
- const normalizedHost = override
157
- .replace(/^https?:\/\//i, "")
158
- .replace(/\/.*$/, "")
159
- .trim();
160
-
161
- if (!normalizedHost) {
162
- return getPairingGatewayParam(gatewayHttpUrl);
163
- }
164
-
165
- baseUrl.host = normalizedHost;
166
- return stripGatewayBase(baseUrl);
167
- } catch {
168
- try {
169
- const prefixed = new URL(`http://${override.replace(/^\/+/, "")}`);
170
- return stripGatewayBase(prefixed);
171
- } catch {
172
- return undefined;
173
- }
174
- }
175
- }
176
-
177
- function normalizeAgentProvider(provider: unknown): AgentProvider {
178
- if (typeof provider === "string" && provider.trim()) return provider.trim();
179
- return "codex";
180
- }
181
-
182
- export function resolveAgentWorkspaceProviders(options: {
183
- agentProvider?: AgentProvider;
184
- agentCommand?: string;
185
- }): AgentProvider[] {
186
- if (options.agentCommand?.trim()) {
187
- return [normalizeAgentProvider(options.agentProvider ?? "custom")];
188
- }
189
- const defaultProviders: AgentProvider[] = ["codex", "claude"];
190
- const detected = detectAvailableProviders();
191
- const requested = options.agentProvider ? normalizeAgentProvider(options.agentProvider) : undefined;
192
- const ordered = [
193
- ...(requested ? [requested] : []),
194
- ...detected,
195
- ...defaultProviders,
196
- ];
197
- const unique: AgentProvider[] = [];
198
- for (const provider of ordered) {
199
- if (!unique.includes(provider)) unique.push(provider);
200
- }
201
- return unique;
202
- }
203
-
204
- export class BridgeSession {
205
- private readonly options: BridgeSessionOptions;
206
- private socket: WebSocket | undefined;
207
- private terminals = new Map<string, TerminalInstance>();
208
- private heartbeatTimer: ReturnType<typeof setInterval> | undefined;
209
- private wsPingTimer: ReturnType<typeof setInterval> | undefined;
210
- // True between sending a WS ping and receiving its pong. If still true at the
211
- // next ping tick, the socket is half-open and gets terminated.
212
- private awaitingPong = false;
213
- private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
214
- private reconnectAttempts = 0;
215
- private reconnecting = false;
216
- private sessionId = "";
217
- private hostToken: string | undefined;
218
- private exited = false;
219
- private stopped = false;
220
- private screenCapture: ScreenFallback | undefined;
221
- private screenShare: ScreenShare | undefined;
222
- private tunnelSockets = new Map<string, WebSocket>();
223
- private keepAwake: KeepAwakeHandle | undefined;
224
- private agentSession: AgentSessionProxy | undefined;
225
- private agentWorkspace: AgentWorkspaceProxy | undefined;
226
- private outboundAgentQueue: Envelope[] = [];
227
- private machineIdentity: MachineIdentity | undefined;
228
- // Default host workspace shown to clients and used by the initial PTY.
229
- private readonly defaultCwd: string;
230
- // Confinement root for client-driven filesystem ops; resolved once at construction.
231
- private readonly fileRoot: string;
232
- // Ports the tunnel proxy may connect to on 127.0.0.1.
233
- private readonly allowedTunnelPorts: Set<number>;
234
- // Pending deletions of exited terminals (terminalId → timer) so we can clear on stop().
235
- private exitedTerminalTimers = new Map<string, ReturnType<typeof setTimeout>>();
236
- // Invoked when all terminals have exited naturally — closes embedded gateway,
237
- // removes daemon PID file, etc. Set by the CLI entry point.
238
- private onAllTerminalsExited: (() => void | Promise<void>) | undefined;
239
-
240
- constructor(options: BridgeSessionOptions) {
241
- this.options = options;
242
- this.sessionId = options.sessionId ?? "";
243
- this.defaultCwd = resolve(homedir());
244
- // Confine client-driven file ops to the user's home by default (single-user
245
- // self-hosted model: the owner browses their own projects under ~). This
246
- // still blocks /etc, /usr, other users' dirs. Override via --file-root.
247
- this.fileRoot = resolve(options.fileRoot ?? homedir());
248
- this.allowedTunnelPorts = new Set(
249
- options.allowedTunnelPorts && options.allowedTunnelPorts.length > 0
250
- ? options.allowedTunnelPorts
251
- : DEFAULT_TUNNEL_PORTS,
252
- );
253
- }
254
-
255
- /** Register a callback invoked once all terminals exit naturally (process self-cleanup). */
256
- setOnAllTerminalsExited(handler: () => void | Promise<void>): void {
257
- this.onAllTerminalsExited = handler;
258
- }
259
-
260
- /**
261
- * Resolve a client-supplied path inside the confinement root.
262
- * Returns the absolute resolved path, or null if it escapes the root.
263
- */
264
- private resolveConfinedPath(clientPath: string): string | null {
265
- const expanded = clientPath.startsWith("~")
266
- ? clientPath.replace(/^~/, homedir())
267
- : clientPath;
268
- // Resolve relative paths against the confinement root, not process.cwd().
269
- const target = isAbsolute(expanded)
270
- ? resolve(expanded)
271
- : resolve(this.fileRoot, expanded);
272
- const rel = relative(this.fileRoot, target);
273
- if (rel === "") return target; // the root itself
274
- if (rel.startsWith("..") || isAbsolute(rel)) return null;
275
- return target;
276
- }
277
-
278
- private log(msg: string): void {
279
- if (this.options.verbose) {
280
- process.stderr.write(`[bridge:verbose] ${msg}\n`);
281
- }
282
- }
283
-
284
- async start(): Promise<void> {
285
- this.log(
286
- `starting session (gateway=${this.options.gatewayUrl}, provider=${this.options.providerConfig.provider})`,
287
- );
288
- this.machineIdentity = loadOrCreateMachineIdentity();
289
- // One-shot leftover sweep. Structured agent control is ACP/SDK subprocesses;
290
- // we never rewrite hook configs.
291
- sweepLinkShellHookConfigs(homedir(), [join(this.defaultCwd, "hooks.json")]);
292
- if (!this.sessionId) {
293
- await this.createPairing();
294
- }
295
- if (this.options.keepAwake) {
296
- this.keepAwake = startKeepAwake();
297
- } else {
298
- process.stderr.write("[bridge] keep-awake disabled\n");
299
- }
300
- if (this.options.agentUi) {
301
- const availableProviders = resolveAgentWorkspaceProviders(this.options);
302
- const agentOptions = {
303
- sessionId: this.sessionId,
304
- cwd: this.defaultCwd,
305
- availableProviders,
306
- command: this.options.agentCommand,
307
- verbose: this.options.verbose,
308
- send: (envelope: Envelope) => this.send(envelope),
309
- };
310
- this.agentSession = new AgentSessionProxy({
311
- ...agentOptions,
312
- });
313
- this.agentWorkspace = new AgentWorkspaceProxy({
314
- ...agentOptions,
315
- });
316
- process.stderr.write(`[bridge] agent workspace channel enabled (providers: ${availableProviders.join(", ") || "none"})\n`);
317
- }
318
- await this.spawnTerminal(DEFAULT_TERMINAL_ID, this.defaultCwd);
319
- await this.connectGateway();
320
- }
321
-
322
- private async createPairing(): Promise<void> {
323
- const headers: Record<string, string> = { "content-type": "application/json" };
324
- const authToken = await this.resolveAuthToken();
325
- if (authToken) {
326
- headers["Authorization"] = `Bearer ${authToken}`;
327
- }
328
- const res = await fetch(`${this.options.gatewayHttpUrl}/pairings`, {
329
- method: "POST",
330
- headers,
331
- body: JSON.stringify({}),
332
- });
333
- if (!res.ok) {
334
- throw new Error(`Failed to create pairing: ${res.status}`);
335
- }
336
- const body = (await res.json()) as {
337
- sessionId: string;
338
- pairingCode: string;
339
- hostToken?: string;
340
- expiresAt: string;
341
- };
342
- this.sessionId = body.sessionId;
343
- // Secret token proving we are the legitimate host for this session; sent on
344
- // every host WS connect so a third party who learns the sessionId can't
345
- // hijack the host role.
346
- this.hostToken = body.hostToken;
347
-
348
- const pairingGateway = resolvePairingGateway(
349
- this.options.gatewayHttpUrl,
350
- this.options.pairingGateway,
351
- );
352
- const deepLink = pairingGateway
353
- ? `linkshell://pair?code=${body.pairingCode}&gateway=${encodeURIComponent(pairingGateway)}`
354
- : `linkshell://pair?code=${body.pairingCode}`;
355
-
356
- process.stderr.write(
357
- `\n \x1b[1mPairing code: \x1b[36m${body.pairingCode}\x1b[0m\n`,
358
- );
359
- process.stderr.write(` Session: ${body.sessionId}\n`);
360
- process.stderr.write(` Expires: ${body.expiresAt}\n\n`);
361
- if (!pairingGateway) {
362
- process.stderr.write(
363
- " Note: QR will use the app's current gateway because the CLI is pointed at a local-only address.\n\n",
364
- );
365
- } else if (this.options.pairingGateway) {
366
- process.stderr.write(` Pairing gateway: ${pairingGateway}\n\n`);
367
- }
368
-
369
- // Show QR code for mobile scanning
370
- try {
371
- const qrModule = await import("qrcode-terminal");
372
- const qrDriver = qrModule.default ?? qrModule;
373
- if (typeof qrDriver.generate === "function") {
374
- qrDriver.generate(deepLink, { small: true }, (code: string) => {
375
- process.stderr.write(` Scan to connect:\n`);
376
- for (const line of code.split("\n")) {
377
- process.stderr.write(` ${line}\n`);
378
- }
379
- process.stderr.write(`\n`);
380
- });
381
- }
382
- } catch {
383
- // qrcode-terminal not available, skip
384
- }
385
-
386
- process.stderr.write(` Deep link: ${deepLink}\n\n`);
387
- }
388
-
389
- private async resolveAuthToken(): Promise<string | undefined> {
390
- if (!this.options.authToken) return undefined;
391
- try {
392
- const token = await getValidToken();
393
- if (token) {
394
- this.options.authToken = token;
395
- return token;
396
- }
397
- // Refresh failed (e.g. offline / transient Supabase error). Do NOT clear
398
- // the token — keep using the last one we had. It may be expired and the
399
- // gateway may reject it, but the close handler force-refreshes on the next
400
- // attempt, and clearing it would permanently downgrade us to no-auth and
401
- // lock a pro host out of its own sessions once connectivity returns.
402
- process.stderr.write(
403
- "[bridge] token refresh failed; reusing last token (will retry refresh on reconnect)\n",
404
- );
405
- return this.options.authToken;
406
- } catch (error) {
407
- this.log(`failed to refresh login token: ${error instanceof Error ? error.message : String(error)}`);
408
- return this.options.authToken;
409
- }
410
- }
411
-
412
- // After an auth-class WS close, force a token refresh before reconnecting so
413
- // we don't spin on 401/4001/4003. A gateway restart surfaces as 1006 right
414
- // after a fresh connect, so we refresh on that too (cheap, debounced by the
415
- // reconnect backoff). No-op for clean/normal closes.
416
- private async maybeRefreshTokenForClose(code: number): Promise<void> {
417
- if (!this.options.authToken) return;
418
- const authClass = code === 4001 || code === 4003 || code === 1006 || code === 401;
419
- if (!authClass) return;
420
- try {
421
- const refreshed = await refreshAccessToken();
422
- if (refreshed?.accessToken) {
423
- this.options.authToken = refreshed.accessToken;
424
- this.log("refreshed auth token after auth-class close");
425
- }
426
- } catch {
427
- // Offline — keep the old token; the reconnect loop will retry later.
428
- }
429
- }
430
-
431
- private async connectGateway(): Promise<void> {
432
- if (this.stopped) {
433
- return;
434
- }
435
-
436
- const url = new URL(this.options.gatewayUrl);
437
- url.searchParams.set("sessionId", this.sessionId);
438
- url.searchParams.set("role", "host");
439
- const authToken = await this.resolveAuthToken();
440
- const wsOptions: WebSocket.ClientOptions = {};
441
- const headers: Record<string, string> = {};
442
- if (authToken) {
443
- // Prefer the Authorization header so the token doesn't leak into URLs/logs.
444
- headers["Authorization"] = `Bearer ${authToken}`;
445
- // TODO: remove the query param once all gateways read the Authorization
446
- // header. Kept for backward compatibility with older gateways that only
447
- // read the `auth_token` query parameter.
448
- url.searchParams.set("auth_token", authToken);
449
- }
450
- if (this.hostToken) {
451
- headers["x-linkshell-host-token"] = this.hostToken;
452
- }
453
- if (Object.keys(headers).length > 0) {
454
- wsOptions.headers = headers;
455
- }
456
-
457
- this.socket = new WebSocket(url, wsOptions);
458
-
459
- this.socket.on("open", () => {
460
- process.stderr.write(
461
- this.reconnectAttempts > 0
462
- ? "[bridge] gateway reconnected\n"
463
- : "[bridge] gateway connected\n",
464
- );
465
- this.reconnectAttempts = 0;
466
- this.reconnecting = false;
467
- this.send(
468
- createEnvelope({
469
- type: "session.connect",
470
- sessionId: this.sessionId,
471
- payload: {
472
- role: "host" as const,
473
- clientName: this.options.clientName,
474
- provider: this.options.providerConfig.provider,
475
- protocolVersion: PROTOCOL_VERSION,
476
- machineId: this.machineIdentity?.machineId,
477
- hostname: this.options.hostname || hostname(),
478
- platform: platform(),
479
- cwd: this.defaultCwd,
480
- projectName: basename(this.defaultCwd),
481
- },
482
- }),
483
- );
484
- if (this.outboundAgentQueue.length > 0) {
485
- const queued = this.outboundAgentQueue.splice(0);
486
- for (const envelope of queued) this.send(envelope);
487
- }
488
- this.startHeartbeat();
489
- this.startWsPing();
490
- });
491
-
492
- // Gateway answered our WS ping — connection is alive.
493
- this.socket.on("pong", () => {
494
- this.awaitingPong = false;
495
- });
496
-
497
- this.socket.on("message", (data) => {
498
- let envelope: Envelope;
499
- try {
500
- envelope = parseEnvelope(data.toString());
501
- } catch (err) {
502
- this.log(`invalid gateway message ignored: ${err}`);
503
- return;
504
- }
505
- this.log(
506
- `recv ${envelope.type}${envelope.seq !== undefined ? ` seq=${envelope.seq}` : ""}`,
507
- );
508
- this.handleMessage(envelope).catch((err) => {
509
- this.log(`handleMessage error: ${err}`);
510
- });
511
- });
512
-
513
- this.socket.on("close", (code, reasonBuffer) => {
514
- this.stopHeartbeat();
515
- this.stopWsPing();
516
- this.socket = undefined;
517
- const reason = reasonBuffer.toString();
518
- // Quiet during an ongoing outage: log the first close (we were connected,
519
- // attempts==0) and every 10th, so multi-hour outages don't flood the log.
520
- if (this.reconnectAttempts === 0 || this.reconnectAttempts % 10 === 0) {
521
- process.stderr.write(
522
- `[bridge] gateway connection closed (code=${code}${reason ? `, reason=${reason}` : ""})\n`,
523
- );
524
- }
525
- if (!this.exited) {
526
- // Auth-class closes (token expired / gateway restarted) get a forced
527
- // token refresh before the next attempt, so we don't loop on 401/4xxx.
528
- this.maybeRefreshTokenForClose(code).finally(() => {
529
- this.scheduleReconnect();
530
- });
531
- }
532
- });
533
-
534
- this.socket.on("error", (error) => {
535
- // During an outage every retry emits a connection error; the `close`
536
- // handler already logs (quietly) and drives reconnect. Only surface the
537
- // first error of an outage to avoid flooding the daemon log.
538
- if (this.reconnectAttempts <= 1) {
539
- process.stderr.write(`[bridge] gateway error: ${error.message || "connection failed"}\n`);
540
- }
541
- });
542
- }
543
-
544
- private async handleMessage(envelope: Envelope): Promise<void> {
545
- const tid = envelope.terminalId ?? DEFAULT_TERMINAL_ID;
546
- const agentV2Route = agentV2MessageRoute(envelope.type);
547
- if (agentV2Route === "client_write" || agentV2Route === "client_read") {
548
- if (!this.agentWorkspace) {
549
- this.send(
550
- createEnvelope({
551
- type: "agent.v2.capabilities",
552
- sessionId: this.sessionId,
553
- payload: {
554
- enabled: false,
555
- provider: normalizeAgentProvider(
556
- this.options.agentProvider ?? "codex",
557
- ),
558
- machineId: this.machineIdentity?.machineId,
559
- workspaceProtocolVersion: 2,
560
- error: "Agent Workspace is not enabled. Start CLI with --agent-ui.",
561
- supportsSessionList: false,
562
- supportsSessionLoad: false,
563
- supportsImages: false,
564
- supportsAudio: false,
565
- supportsPermission: false,
566
- supportsPlan: false,
567
- supportsCancel: false,
568
- },
569
- }),
570
- );
571
- return;
572
- }
573
- try {
574
- await this.agentWorkspace.handleEnvelope(envelope);
575
- } catch (error) {
576
- const message = error instanceof Error ? error.message : String(error);
577
- this.log(`agent.v2 invalid message: ${message}`);
578
- this.send(createEnvelope({
579
- type: "session.error",
580
- sessionId: this.sessionId,
581
- payload: {
582
- code: "invalid_message",
583
- message: `Invalid Agent Workspace message: ${message}`,
584
- },
585
- }));
586
- }
587
- return;
588
- }
589
- switch (envelope.type) {
590
- case "terminal.input": {
591
- const p = parseTypedPayload("terminal.input", envelope.payload);
592
- const term = this.terminals.get(tid);
593
- if (term && term.status === "running") term.pty.write(p.data);
594
- break;
595
- }
596
- case "terminal.resize": {
597
- const p = parseTypedPayload("terminal.resize", envelope.payload);
598
- const term = this.terminals.get(tid);
599
- if (term && term.status === "running") term.pty.resize(p.cols, p.rows);
600
- break;
601
- }
602
- case "terminal.spawn": {
603
- const p = parseTypedPayload("terminal.spawn", envelope.payload);
604
- const normalizedCwd = resolve(p.cwd);
605
- // Cap concurrent terminals — forceNew removed the cwd-dedup ceiling, so
606
- // bound growth explicitly to avoid PTY/fd exhaustion from a runaway client.
607
- const liveCount = [...this.terminals.values()].filter((t) => t.status === "running").length;
608
- if (liveCount >= MAX_TERMINALS) {
609
- this.send(createEnvelope({
610
- type: "session.error",
611
- sessionId: this.sessionId,
612
- payload: { code: "too_many_terminals", message: `最多同时打开 ${MAX_TERMINALS} 个终端` },
613
- }));
614
- break;
615
- }
616
- // Dedup by cwd UNLESS the client explicitly asks for a fresh terminal
617
- // (forceNew lets web open multiple tabs in the same directory).
618
- const existing = p.forceNew
619
- ? undefined
620
- : [...this.terminals.values()].find(
621
- (t) => t.status === "running" && resolve(t.cwd) === normalizedCwd,
622
- );
623
- if (existing) {
624
- this.send(createEnvelope({
625
- type: "terminal.spawned",
626
- sessionId: this.sessionId,
627
- terminalId: existing.id,
628
- payload: { terminalId: existing.id, cwd: existing.cwd, projectName: existing.projectName, provider: existing.provider },
629
- }));
630
- } else {
631
- const newId = `term-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
632
- try {
633
- await this.spawnTerminal(newId, normalizedCwd, p.provider);
634
- this.send(createEnvelope({
635
- type: "terminal.spawned",
636
- sessionId: this.sessionId,
637
- terminalId: newId,
638
- payload: { terminalId: newId, cwd: normalizedCwd, projectName: basename(normalizedCwd), provider: p.provider },
639
- }));
640
- } catch (err) {
641
- this.log(`failed to spawn terminal ${newId}: ${err}`);
642
- this.send(createEnvelope({
643
- type: "terminal.exit",
644
- sessionId: this.sessionId,
645
- terminalId: newId,
646
- payload: { exitCode: 1, signal: 0 },
647
- }));
648
- }
649
- }
650
- this.sendTerminalList();
651
- break;
652
- }
653
- case "terminal.kill": {
654
- const p = parseTypedPayload("terminal.kill", envelope.payload);
655
- const term = this.terminals.get(p.terminalId);
656
- if (term && term.status === "running") {
657
- term.pty.kill();
658
- }
659
- break;
660
- }
661
- case "terminal.browse": {
662
- const p = parseTypedPayload("terminal.browse", envelope.payload);
663
- const browsePath = this.resolveConfinedPath(p.path);
664
- if (browsePath === null) {
665
- this.send(createEnvelope({
666
- type: "terminal.browse.result",
667
- sessionId: this.sessionId,
668
- payload: { path: p.path, entries: [], error: "Path is outside the allowed root", requestId: p.requestId },
669
- }));
670
- break;
671
- }
672
- try {
673
- const entries = readdirSync(browsePath, { withFileTypes: true })
674
- .filter((d) => !d.name.startsWith(".") && (d.isDirectory() || (p.includeFiles && d.isFile())))
675
- .map((d) => {
676
- const entryPath = join(browsePath, d.name);
677
- const stats = statSync(entryPath);
678
- return {
679
- name: d.name,
680
- path: entryPath,
681
- isDirectory: d.isDirectory(),
682
- size: stats.size,
683
- modifiedAt: stats.mtime.toISOString(),
684
- };
685
- })
686
- .sort((a, b) => {
687
- if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
688
- return a.name.localeCompare(b.name);
689
- });
690
- this.send(createEnvelope({
691
- type: "terminal.browse.result",
692
- sessionId: this.sessionId,
693
- payload: { path: browsePath, entries, requestId: p.requestId },
694
- }));
695
- } catch (err: unknown) {
696
- this.send(createEnvelope({
697
- type: "terminal.browse.result",
698
- sessionId: this.sessionId,
699
- payload: { path: browsePath, entries: [], error: (err as Error).message, requestId: p.requestId },
700
- }));
701
- }
702
- break;
703
- }
704
- case "terminal.file.read": {
705
- const p = parseTypedPayload("terminal.file.read", envelope.payload);
706
- const filePath = this.resolveConfinedPath(p.path);
707
- if (filePath === null) {
708
- this.send(createEnvelope({
709
- type: "terminal.file.read.result",
710
- sessionId: this.sessionId,
711
- payload: {
712
- path: p.path,
713
- content: "",
714
- encoding: "utf8",
715
- truncated: false,
716
- error: "Path is outside the allowed root",
717
- requestId: p.requestId,
718
- },
719
- }));
720
- break;
721
- }
722
- try {
723
- const stats = statSync(filePath);
724
- if (!stats.isFile()) {
725
- throw new Error("Path is not a file");
726
- }
727
- const maxBytes = p.maxBytes ?? 256_000;
728
- const bytesToRead = Math.min(stats.size, maxBytes);
729
- const buffer = Buffer.alloc(bytesToRead);
730
- const fd = openSync(filePath, "r");
731
- try {
732
- readSync(fd, buffer, 0, bytesToRead, 0);
733
- } finally {
734
- closeSync(fd);
735
- }
736
- if (buffer.includes(0)) {
737
- throw new Error("Binary files cannot be previewed");
738
- }
739
- this.send(createEnvelope({
740
- type: "terminal.file.read.result",
741
- sessionId: this.sessionId,
742
- payload: {
743
- path: filePath,
744
- content: buffer.toString("utf8"),
745
- encoding: "utf8",
746
- size: stats.size,
747
- truncated: stats.size > maxBytes,
748
- requestId: p.requestId,
749
- },
750
- }));
751
- } catch (err: unknown) {
752
- this.send(createEnvelope({
753
- type: "terminal.file.read.result",
754
- sessionId: this.sessionId,
755
- payload: {
756
- path: filePath,
757
- content: "",
758
- encoding: "utf8",
759
- truncated: false,
760
- error: (err as Error).message,
761
- requestId: p.requestId,
762
- },
763
- }));
764
- }
765
- break;
766
- }
767
- case "terminal.mkdir": {
768
- const p = parseTypedPayload("terminal.mkdir", envelope.payload);
769
- const dirPath = this.resolveConfinedPath(p.path);
770
- if (dirPath === null) {
771
- this.send(createEnvelope({
772
- type: "terminal.browse.result",
773
- sessionId: this.sessionId,
774
- payload: { path: p.path, entries: [], error: "Path is outside the allowed root" },
775
- }));
776
- break;
777
- }
778
- try {
779
- mkdirSync(dirPath, { recursive: true });
780
- // Browse the parent to refresh the listing
781
- const parentPath = join(dirPath, "..");
782
- const entries = readdirSync(parentPath, { withFileTypes: true })
783
- .filter((d) => d.isDirectory() && !d.name.startsWith("."))
784
- .sort((a, b) => a.name.localeCompare(b.name))
785
- .map((d) => ({
786
- name: d.name,
787
- path: join(parentPath, d.name),
788
- isDirectory: true,
789
- }));
790
- this.send(createEnvelope({
791
- type: "terminal.browse.result",
792
- sessionId: this.sessionId,
793
- payload: { path: parentPath, entries },
794
- }));
795
- } catch (err: unknown) {
796
- this.send(createEnvelope({
797
- type: "terminal.browse.result",
798
- sessionId: this.sessionId,
799
- payload: { path: dirPath, entries: [], error: (err as Error).message },
800
- }));
801
- }
802
- break;
803
- }
804
- case "terminal.list": {
805
- this.sendTerminalList();
806
- break;
807
- }
808
- case "terminal.history.request": {
809
- const p = parseTypedPayload("terminal.history.request", envelope.payload);
810
- const count = p.count ?? 100;
811
- let entries: string[] = [];
812
- let shell = "unknown";
813
- try {
814
- const home = homedir();
815
- // Try zsh first, then bash
816
- const histFiles = [
817
- { path: join(home, ".zsh_history"), shell: "zsh" },
818
- { path: join(home, ".bash_history"), shell: "bash" },
819
- ];
820
- for (const hf of histFiles) {
821
- if (existsSync(hf.path)) {
822
- const raw = readFileSync(hf.path, "utf8");
823
- const lines = raw.split("\n").filter(Boolean);
824
- // zsh history lines may start with ": <timestamp>:0;" — strip prefix
825
- const parsed = lines.map((l) => {
826
- const m = l.match(/^:\s*\d+:\d+;(.*)$/);
827
- return m ? m[1]! : l;
828
- });
829
- // Deduplicate and take last N
830
- const unique = [...new Set(parsed.reverse())].slice(0, count).reverse();
831
- entries = unique;
832
- shell = hf.shell;
833
- break;
834
- }
835
- }
836
- } catch {}
837
- this.send(createEnvelope({
838
- type: "terminal.history.response",
839
- sessionId: this.sessionId,
840
- payload: { entries, shell },
841
- }));
842
- break;
843
- }
844
- case "session.ack": {
845
- const p = parseTypedPayload("session.ack", envelope.payload);
846
- const term = this.terminals.get(tid);
847
- if (term) {
848
- term.scrollback.trimUpTo(p.seq);
849
- }
850
- break;
851
- }
852
- case "session.resume": {
853
- const p = parseTypedPayload("session.resume", envelope.payload);
854
- // Replay all terminals
855
- for (const [termId, term] of this.terminals) {
856
- this.replayFrom(
857
- termId,
858
- term,
859
- p.lastAckedSeqByTerminal[termId] ?? p.lastAckedSeq,
860
- );
861
- }
862
- // Also send terminal list so client knows what's available
863
- this.sendTerminalList();
864
- break;
865
- }
866
- case "session.heartbeat":
867
- break;
868
- case "screen.start": {
869
- const p = parseTypedPayload("screen.start", envelope.payload);
870
- this.startScreenCapture(p.fps, p.quality, p.scale);
871
- break;
872
- }
873
- case "screen.stop": {
874
- this.stopScreenCapture();
875
- break;
876
- }
877
- case "screen.answer": {
878
- const p = parseTypedPayload("screen.answer", envelope.payload);
879
- this.screenShare?.handleAnswer(p.sdp);
880
- break;
881
- }
882
- case "screen.ice": {
883
- const p = parseTypedPayload("screen.ice", envelope.payload);
884
- this.screenShare?.handleIceCandidate(p.candidate, p.sdpMid, p.sdpMLineIndex);
885
- break;
886
- }
887
- case "agent.initialize":
888
- case "agent.session.new":
889
- case "agent.session.load":
890
- case "agent.session.list":
891
- case "agent.prompt":
892
- case "agent.cancel": {
893
- if (!this.agentSession) {
894
- this.send(
895
- createEnvelope({
896
- type: "agent.capabilities",
897
- sessionId: this.sessionId,
898
- payload: {
899
- enabled: false,
900
- provider: normalizeAgentProvider(
901
- this.options.agentProvider ?? "codex",
902
- ),
903
- machineId: this.machineIdentity?.machineId,
904
- error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
905
- supportsSessionList: false,
906
- supportsSessionLoad: false,
907
- supportsImages: false,
908
- supportsAudio: false,
909
- supportsPermission: false,
910
- supportsPlan: false,
911
- supportsCancel: false,
912
- },
913
- }),
914
- );
915
- break;
916
- }
917
- await this.agentSession.handleEnvelope(envelope);
918
- break;
919
- }
920
- case "agent.permission.response": {
921
- const p = parseTypedPayload("agent.permission.response", envelope.payload);
922
- if (!this.agentSession) {
923
- this.send(
924
- createEnvelope({
925
- type: "agent.capabilities",
926
- sessionId: this.sessionId,
927
- payload: {
928
- enabled: false,
929
- provider: normalizeAgentProvider(
930
- this.options.agentProvider ?? "codex",
931
- ),
932
- machineId: this.machineIdentity?.machineId,
933
- error: "Agent GUI is not enabled. Start CLI with --agent-ui.",
934
- supportsSessionList: false,
935
- supportsSessionLoad: false,
936
- supportsImages: false,
937
- supportsAudio: false,
938
- supportsPermission: false,
939
- supportsPlan: false,
940
- supportsCancel: false,
941
- },
942
- }),
943
- );
944
- break;
945
- }
946
- await this.agentSession.handleEnvelope(envelope);
947
- break;
948
- }
949
- case "file.upload": {
950
- const p = parseTypedPayload("file.upload", envelope.payload);
951
- const ext = p.filename.split(".").pop() || "png";
952
- const tempPath = join(tmpdir(), `linkshell-image-${Date.now()}.${ext}`);
953
- writeFileSync(tempPath, Buffer.from(p.data, "base64"));
954
- this.log(`image saved to ${tempPath}`);
955
- const term = this.terminals.get(tid);
956
- if (term && term.status === "running") {
957
- term.pty.write(`\x1b[200~${tempPath}\x1b[201~`);
958
- }
959
- break;
960
- }
961
- case "permission.decision": {
962
- const p = envelope.payload as { requestId: string; decision: "allow" | "deny" };
963
- process.stderr.write(
964
- `[bridge] permission.decision ignored (ACP/SDK owns authorization) request=${p.requestId} decision=${p.decision}\n`,
965
- );
966
- this.send(createEnvelope({
967
- type: "permission.decision.result",
968
- sessionId: this.sessionId,
969
- terminalId: tid,
970
- payload: {
971
- requestId: p.requestId,
972
- decision: p.decision,
973
- resolved: false,
974
- delivered: false,
975
- source: "permission.decision",
976
- message: "Authorization is handled by the ACP/SDK agent subprocess",
977
- },
978
- }));
979
- break;
980
- }
981
- case "tunnel.request": {
982
- const p = parseTypedPayload("tunnel.request", envelope.payload);
983
- this.handleTunnelRequest(p);
984
- break;
985
- }
986
- case "tunnel.ws.data": {
987
- const p = parseTypedPayload("tunnel.ws.data", envelope.payload);
988
- this.handleTunnelWsData(p);
989
- break;
990
- }
991
- case "tunnel.ws.close": {
992
- const p = parseTypedPayload("tunnel.ws.close", envelope.payload);
993
- this.handleTunnelWsClose(p);
994
- break;
995
- }
996
- default:
997
- break;
998
- }
999
- }
1000
-
1001
- // ── Tunnel handlers ────────────────────────────────────────────────
1002
-
1003
- private handleTunnelRequest(payload: {
1004
- requestId: string;
1005
- method: string;
1006
- url: string;
1007
- headers: Record<string, string>;
1008
- body: string | null;
1009
- port: number;
1010
- }): void {
1011
- const { requestId, method, url: reqUrl, headers, body, port } = payload;
1012
-
1013
- // SSRF guard: only proxy to ports in the allowlist (pinned to 127.0.0.1).
1014
- if (!this.allowedTunnelPorts.has(port)) {
1015
- this.log(`rejecting tunnel request to disallowed port ${port}`);
1016
- if (headers.upgrade === "websocket") {
1017
- this.send(createEnvelope({
1018
- type: "tunnel.ws.close",
1019
- sessionId: this.sessionId,
1020
- payload: { requestId, code: 1008, reason: `Port ${port} is not allowed` },
1021
- }));
1022
- } else {
1023
- this.sendTunnelError(requestId, 403, `Port ${port} is not allowed`);
1024
- }
1025
- return;
1026
- }
1027
-
1028
- // WebSocket upgrade request
1029
- if (headers.upgrade === "websocket") {
1030
- this.handleTunnelWsUpgrade(requestId, port, reqUrl);
1031
- return;
1032
- }
1033
-
1034
- const parsedUrl = new URL(reqUrl, `http://127.0.0.1:${port}`);
1035
-
1036
- const reqOptions: http.RequestOptions = {
1037
- hostname: "127.0.0.1",
1038
- port,
1039
- path: parsedUrl.pathname + parsedUrl.search,
1040
- method,
1041
- headers: { ...headers, host: `127.0.0.1:${port}` },
1042
- };
1043
-
1044
- const proxyReq = http.request(reqOptions, (proxyRes) => {
1045
- // Collect response headers
1046
- const resHeaders: Record<string, string> = {};
1047
- for (const [key, val] of Object.entries(proxyRes.headers)) {
1048
- if (typeof val === "string") resHeaders[key] = val;
1049
- else if (Array.isArray(val)) resHeaders[key] = val.join(", ");
1050
- }
1051
-
1052
- let firstChunk = true;
1053
- proxyRes.on("data", (chunk: Buffer) => {
1054
- this.send(
1055
- createEnvelope({
1056
- type: "tunnel.response",
1057
- sessionId: this.sessionId,
1058
- payload: {
1059
- requestId,
1060
- statusCode: proxyRes.statusCode ?? 200,
1061
- headers: firstChunk ? resHeaders : {},
1062
- body: chunk.toString("base64"),
1063
- isFinal: false,
1064
- },
1065
- }),
1066
- );
1067
- firstChunk = false;
1068
- });
1069
-
1070
- proxyRes.on("end", () => {
1071
- this.send(
1072
- createEnvelope({
1073
- type: "tunnel.response",
1074
- sessionId: this.sessionId,
1075
- payload: {
1076
- requestId,
1077
- statusCode: proxyRes.statusCode ?? 200,
1078
- headers: firstChunk ? resHeaders : {},
1079
- body: "",
1080
- isFinal: true,
1081
- },
1082
- }),
1083
- );
1084
- });
1085
-
1086
- proxyRes.on("error", () => {
1087
- this.sendTunnelError(requestId, 502, "Upstream read error");
1088
- });
1089
- });
1090
-
1091
- proxyReq.on("error", () => {
1092
- this.sendTunnelError(requestId, 502, "Connection refused");
1093
- });
1094
-
1095
- proxyReq.setTimeout(30_000, () => {
1096
- proxyReq.destroy();
1097
- this.sendTunnelError(requestId, 504, "Upstream timeout");
1098
- });
1099
-
1100
- if (body) {
1101
- proxyReq.write(Buffer.from(body, "base64"));
1102
- }
1103
- proxyReq.end();
1104
- }
1105
-
1106
- private handleTunnelWsUpgrade(requestId: string, port: number, url: string): void {
1107
- const wsUrl = `ws://127.0.0.1:${port}${url}`;
1108
- const localWs = new WebSocket(wsUrl);
1109
-
1110
- localWs.on("open", () => {
1111
- this.tunnelSockets.set(requestId, localWs);
1112
- });
1113
-
1114
- localWs.on("message", (data: Buffer | string) => {
1115
- const isBinary = typeof data !== "string";
1116
- const buf = typeof data === "string" ? Buffer.from(data) : data;
1117
- this.send(
1118
- createEnvelope({
1119
- type: "tunnel.ws.data",
1120
- sessionId: this.sessionId,
1121
- payload: {
1122
- requestId,
1123
- data: buf.toString("base64"),
1124
- isBinary,
1125
- },
1126
- }),
1127
- );
1128
- });
1129
-
1130
- localWs.on("close", (code, reason) => {
1131
- this.tunnelSockets.delete(requestId);
1132
- const safeCode = typeof code === "number" && code >= 1000 && code <= 4999 ? code : 1000;
1133
- this.send(
1134
- createEnvelope({
1135
- type: "tunnel.ws.close",
1136
- sessionId: this.sessionId,
1137
- payload: {
1138
- requestId,
1139
- code: safeCode,
1140
- reason: reason?.toString() || "",
1141
- },
1142
- }),
1143
- );
1144
- });
1145
-
1146
- localWs.on("error", () => {
1147
- this.tunnelSockets.delete(requestId);
1148
- this.send(
1149
- createEnvelope({
1150
- type: "tunnel.ws.close",
1151
- sessionId: this.sessionId,
1152
- payload: {
1153
- requestId,
1154
- code: 1001,
1155
- reason: "Local WebSocket error",
1156
- },
1157
- }),
1158
- );
1159
- });
1160
- }
1161
-
1162
- private handleTunnelWsData(payload: {
1163
- requestId: string;
1164
- data: string;
1165
- isBinary: boolean;
1166
- }): void {
1167
- const ws = this.tunnelSockets.get(payload.requestId);
1168
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
1169
- const buf = Buffer.from(payload.data, "base64");
1170
- ws.send(payload.isBinary ? buf : buf.toString("utf8"));
1171
- }
1172
-
1173
- private handleTunnelWsClose(payload: {
1174
- requestId: string;
1175
- code?: number;
1176
- reason?: string;
1177
- }): void {
1178
- const ws = this.tunnelSockets.get(payload.requestId);
1179
- if (!ws) return;
1180
- const code = payload.code && payload.code >= 1000 && payload.code <= 4999 ? payload.code : 1000;
1181
- ws.close(code, payload.reason ?? "");
1182
- this.tunnelSockets.delete(payload.requestId);
1183
- }
1184
-
1185
- private sendTunnelError(requestId: string, statusCode: number, message: string): void {
1186
- this.send(
1187
- createEnvelope({
1188
- type: "tunnel.response",
1189
- sessionId: this.sessionId,
1190
- payload: {
1191
- requestId,
1192
- statusCode,
1193
- headers: { "content-type": "text/plain" },
1194
- body: Buffer.from(message).toString("base64"),
1195
- isFinal: true,
1196
- },
1197
- }),
1198
- );
1199
- }
1200
-
1201
- private sendTerminalList(): void {
1202
- const terminals = [...this.terminals.values()].map((t) => ({
1203
- terminalId: t.id,
1204
- cwd: t.cwd,
1205
- projectName: t.projectName,
1206
- provider: t.provider,
1207
- status: t.status,
1208
- }));
1209
- this.send(createEnvelope({
1210
- type: "terminal.list",
1211
- sessionId: this.sessionId,
1212
- payload: { terminals },
1213
- }));
1214
- }
1215
-
1216
- private replayFrom(terminalId: string, term: TerminalInstance, afterSeq: number): void {
1217
- const messages = term.scrollback.replayFrom(afterSeq);
1218
- for (const msg of messages) {
1219
- const payload = msg.payload as {
1220
- stream: string;
1221
- data: string;
1222
- encoding: string;
1223
- isReplay: boolean;
1224
- isFinal: boolean;
1225
- };
1226
- this.send(
1227
- createEnvelope({
1228
- type: "terminal.output",
1229
- sessionId: this.sessionId,
1230
- terminalId,
1231
- seq: msg.seq,
1232
- payload: { ...payload, isReplay: true },
1233
- }),
1234
- );
1235
- }
1236
- }
1237
-
1238
- private async spawnTerminal(terminalId: string, cwd: string, providerOverride?: string): Promise<void> {
1239
- const cleanEnv: Record<string, string> = {};
1240
- for (const [k, v] of Object.entries(this.options.providerConfig.env)) {
1241
- if (v !== undefined) cleanEnv[k] = v;
1242
- }
1243
-
1244
- const provider = providerOverride ?? this.options.providerConfig.provider;
1245
- const args = [...this.options.providerConfig.args];
1246
-
1247
- const term: TerminalInstance = {
1248
- id: terminalId,
1249
- pty: pty.spawn(
1250
- this.options.providerConfig.command,
1251
- args,
1252
- {
1253
- name: "xterm-256color",
1254
- cols: this.options.cols,
1255
- rows: this.options.rows,
1256
- cwd,
1257
- env: cleanEnv,
1258
- },
1259
- ),
1260
- cwd,
1261
- projectName: basename(cwd),
1262
- provider,
1263
- scrollback: new ScrollbackBuffer(SCROLLBACK_LINES),
1264
- outputSeq: 0,
1265
- statusSeq: 0,
1266
- status: "running",
1267
- };
1268
-
1269
- term.pty.onData((data) => {
1270
- const seq = term.outputSeq++;
1271
- const envelope = createEnvelope({
1272
- type: "terminal.output",
1273
- sessionId: this.sessionId,
1274
- terminalId,
1275
- seq,
1276
- payload: {
1277
- stream: "stdout" as const,
1278
- data,
1279
- encoding: "utf8" as const,
1280
- isReplay: false,
1281
- isFinal: false,
1282
- },
1283
- });
1284
- term.scrollback.push(envelope);
1285
- this.send(envelope);
1286
- });
1287
-
1288
- term.pty.onExit(({ exitCode, signal }) => {
1289
- term.status = "exited";
1290
- this.send(createEnvelope({
1291
- type: "terminal.exit",
1292
- sessionId: this.sessionId,
1293
- terminalId,
1294
- payload: { exitCode, signal },
1295
- }));
1296
- this.sendTerminalList();
1297
-
1298
- // Reap the exited terminal (and its scrollback) after a grace period so
1299
- // clients can replay final output, then free the memory. Tracked so the
1300
- // timer can be cleared on stop().
1301
- const reapTimer = setTimeout(() => {
1302
- this.exitedTerminalTimers.delete(terminalId);
1303
- const t = this.terminals.get(terminalId);
1304
- if (t && t.status === "exited") {
1305
- this.terminals.delete(terminalId);
1306
- this.log(`reaped exited terminal ${terminalId}`);
1307
- }
1308
- }, EXITED_TERMINAL_GRACE_MS);
1309
- if (typeof reapTimer.unref === "function") reapTimer.unref();
1310
- this.exitedTerminalTimers.set(terminalId, reapTimer);
1311
-
1312
- // If all terminals exited, close the session
1313
- const allExited = [...this.terminals.values()].every((t) => t.status === "exited");
1314
- if (allExited) {
1315
- this.exited = true;
1316
- setTimeout(() => {
1317
- this.stopHeartbeat();
1318
- this.socket?.close();
1319
- // Self-clean process-level resources (embedded gateway + PID file)
1320
- // since the PTYs exited naturally rather than via a signal handler.
1321
- void this.onAllTerminalsExited?.();
1322
- }, 500);
1323
- process.exitCode = exitCode ?? 0;
1324
- }
1325
- });
1326
-
1327
- this.terminals.set(terminalId, term);
1328
- this.log(`spawned terminal ${terminalId} in ${cwd}`);
1329
- }
1330
-
1331
-
1332
- private send(message: Envelope): void {
1333
- if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
1334
- if (message.type.startsWith("agent.v2.")) {
1335
- this.outboundAgentQueue.push(message);
1336
- if (this.outboundAgentQueue.length > 40) {
1337
- this.outboundAgentQueue.splice(0, this.outboundAgentQueue.length - 40);
1338
- }
1339
- }
1340
- return;
1341
- }
1342
- const machineId = this.machineIdentity?.machineId;
1343
- const enriched = machineId && (
1344
- message.type === "terminal.status" ||
1345
- message.type === "agent.capabilities" ||
1346
- message.type === "agent.snapshot" ||
1347
- message.type === "agent.v2.capabilities" ||
1348
- message.type === "agent.v2.snapshot"
1349
- )
1350
- ? {
1351
- ...message,
1352
- payload: {
1353
- ...(message.payload as Record<string, unknown>),
1354
- machineId,
1355
- },
1356
- }
1357
- : message;
1358
- this.socket.send(serializeEnvelope(enriched));
1359
- }
1360
-
1361
- private startHeartbeat(): void {
1362
- this.stopHeartbeat();
1363
- this.heartbeatTimer = setInterval(() => {
1364
- this.send(
1365
- createEnvelope({
1366
- type: "session.heartbeat",
1367
- sessionId: this.sessionId,
1368
- payload: { ts: Date.now() },
1369
- }),
1370
- );
1371
- }, HEARTBEAT_INTERVAL);
1372
- }
1373
-
1374
- private stopHeartbeat(): void {
1375
- if (this.heartbeatTimer) {
1376
- clearInterval(this.heartbeatTimer);
1377
- this.heartbeatTimer = undefined;
1378
- }
1379
- }
1380
-
1381
- // Transport-layer keepalive. WS ping/pong detects a half-open socket (network
1382
- // dropped without a TCP FIN, e.g. laptop sleep / Wi-Fi loss) that would
1383
- // otherwise hang silently. If the previous ping's pong never arrived, the
1384
- // socket is dead → terminate it, which fires `close` → scheduleReconnect.
1385
- private startWsPing(): void {
1386
- this.stopWsPing();
1387
- this.awaitingPong = false;
1388
- this.wsPingTimer = setInterval(() => {
1389
- const sock = this.socket;
1390
- if (!sock || sock.readyState !== WebSocket.OPEN) return;
1391
- if (this.awaitingPong) {
1392
- // No pong since the last ping → half-open. Forcibly close so we reconnect.
1393
- process.stderr.write("[bridge] no pong from gateway; terminating dead connection\n");
1394
- this.awaitingPong = false;
1395
- try {
1396
- sock.terminate();
1397
- } catch {
1398
- // terminate may throw if already closing; close handler still runs.
1399
- }
1400
- return;
1401
- }
1402
- this.awaitingPong = true;
1403
- try {
1404
- sock.ping();
1405
- } catch {
1406
- // ping failed → let the next tick terminate, or close already fired.
1407
- }
1408
- }, WS_PING_INTERVAL);
1409
- if (typeof this.wsPingTimer.unref === "function") this.wsPingTimer.unref();
1410
- }
1411
-
1412
- private stopWsPing(): void {
1413
- if (this.wsPingTimer) {
1414
- clearInterval(this.wsPingTimer);
1415
- this.wsPingTimer = undefined;
1416
- }
1417
- this.awaitingPong = false;
1418
- }
1419
-
1420
- private startScreenCapture(fps: number, quality: number, scale: number): void {
1421
- if (!this.options.screen) {
1422
- this.log("screen sharing not enabled (use --screen)");
1423
- this.send(
1424
- createEnvelope({
1425
- type: "screen.status",
1426
- sessionId: this.sessionId,
1427
- payload: { active: false, mode: "off" as const, error: "Screen sharing not enabled on host. Start CLI with --screen flag." },
1428
- }),
1429
- );
1430
- return;
1431
- }
1432
- this.stopScreenCapture();
1433
- this.log(`starting screen capture (fps=${fps}, quality=${quality}, scale=${scale})`);
1434
-
1435
- // Try WebRTC first, fall back to screenshot stream
1436
- if (ScreenShare.isAvailable()) {
1437
- this.log("WebRTC available, starting screen share");
1438
- this.screenShare = new ScreenShare({
1439
- sessionId: this.sessionId,
1440
- fps,
1441
- quality,
1442
- scale,
1443
- onSignal: (envelope) => this.send(envelope),
1444
- onStatus: (envelope) => this.send(envelope),
1445
- });
1446
- this.screenShare.start().catch((err) => {
1447
- this.log(`WebRTC failed, falling back to screenshot stream: ${err}`);
1448
- this.screenShare = undefined;
1449
- this.startFallbackCapture(fps, quality, scale);
1450
- });
1451
- } else {
1452
- this.log("WebRTC not available (missing werift or ffmpeg), using screenshot fallback");
1453
- this.startFallbackCapture(fps, quality, scale);
1454
- }
1455
- }
1456
-
1457
- private startFallbackCapture(fps: number, quality: number, scale: number): void {
1458
- this.screenCapture = new ScreenFallback({
1459
- fps,
1460
- quality,
1461
- scale,
1462
- sessionId: this.sessionId,
1463
- onFrame: (envelope) => this.send(envelope),
1464
- onStatus: (envelope) => this.send(envelope),
1465
- });
1466
- this.screenCapture.start();
1467
- }
1468
-
1469
- private stopScreenCapture(): void {
1470
- if (this.screenShare) {
1471
- this.log("stopping WebRTC screen share");
1472
- this.screenShare.stop();
1473
- this.screenShare = undefined;
1474
- }
1475
- if (this.screenCapture) {
1476
- this.log("stopping screenshot capture");
1477
- this.screenCapture.stop();
1478
- this.screenCapture = undefined;
1479
- }
1480
- }
1481
-
1482
- private scheduleReconnect(): void {
1483
- if (this.reconnecting || this.stopped || this.exited) return;
1484
-
1485
- this.reconnecting = true;
1486
- // Exponential backoff capped at RECONNECT_MAX_DELAY, with full jitter so a
1487
- // fleet of hosts doesn't stampede a restarting gateway. The attempt counter
1488
- // only ever grows toward the cap — it is NEVER reset to "give up"; the host
1489
- // keeps trying forever until stop()/exit.
1490
- const exponent = Math.min(this.reconnectAttempts, RECONNECT_MAX_EXPONENT);
1491
- const base = Math.min(RECONNECT_BASE_DELAY * 2 ** exponent, RECONNECT_MAX_DELAY);
1492
- const delay = Math.round(base * (0.5 + Math.random() * 0.5));
1493
- this.reconnectAttempts++;
1494
- // Quiet logging: only the first retry of an outage and every 10th attempt,
1495
- // so a multi-hour offline window doesn't flood the daemon log.
1496
- if (this.reconnectAttempts === 1 || this.reconnectAttempts % 10 === 0) {
1497
- process.stderr.write(
1498
- `[bridge] reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})\n`,
1499
- );
1500
- }
1501
- this.reconnectTimer = setTimeout(() => {
1502
- this.reconnectTimer = undefined;
1503
- if (this.stopped || this.exited) {
1504
- return;
1505
- }
1506
- this.reconnecting = false;
1507
- void this.connectGateway();
1508
- }, delay);
1509
- }
1510
-
1511
- stop(exitCode = 0): void {
1512
- if (this.stopped) {
1513
- return;
1514
- }
1515
-
1516
- this.stopped = true;
1517
- this.exited = true;
1518
- this.stopHeartbeat();
1519
- this.stopWsPing();
1520
- this.stopScreenCapture();
1521
- this.agentSession?.stop();
1522
- this.agentSession = undefined;
1523
- this.agentWorkspace?.stop();
1524
- this.agentWorkspace = undefined;
1525
- this.keepAwake?.stop();
1526
- this.keepAwake = undefined;
1527
- if (this.reconnectTimer) {
1528
- clearTimeout(this.reconnectTimer);
1529
- this.reconnectTimer = undefined;
1530
- }
1531
- this.socket?.close();
1532
- this.socket = undefined;
1533
- // Clean up tunnel WebSockets
1534
- for (const ws of this.tunnelSockets.values()) {
1535
- ws.close(1001, "Session stopped");
1536
- }
1537
- this.tunnelSockets.clear();
1538
- // Clear pending exited-terminal reap timers.
1539
- for (const timer of this.exitedTerminalTimers.values()) {
1540
- clearTimeout(timer);
1541
- }
1542
- this.exitedTerminalTimers.clear();
1543
- for (const term of this.terminals.values()) {
1544
- if (term.status === "running") term.pty.kill();
1545
- }
1546
- this.terminals.clear();
1547
- process.exitCode = exitCode;
1548
- }
1549
- }