linkshell-cli 0.9.0 → 0.10.0

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