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.
- package/README.md +11 -6
- package/dist/cli/src/commands/gateway.d.ts +2 -0
- package/dist/cli/src/commands/gateway.js +29 -0
- package/dist/cli/src/commands/gateway.js.map +1 -0
- package/dist/cli/src/commands/host.d.ts +2 -0
- package/dist/cli/src/commands/host.js +12 -2
- package/dist/cli/src/commands/host.js.map +1 -1
- package/dist/cli/src/commands/setup.js +4 -2
- package/dist/cli/src/commands/setup.js.map +1 -1
- package/dist/cli/src/index.js +12 -334
- package/dist/cli/src/index.js.map +1 -1
- package/dist/cli/src/utils/daemon.d.ts +0 -7
- package/dist/cli/src/utils/daemon.js +1 -18
- package/dist/cli/src/utils/daemon.js.map +1 -1
- package/dist/cli/tsconfig.tsbuildinfo +1 -1
- package/dist/gateway/src/accounts.d.ts +6 -0
- package/dist/gateway/src/accounts.js +32 -0
- package/dist/gateway/src/accounts.js.map +1 -0
- package/dist/gateway/src/index.d.ts +4 -0
- package/dist/gateway/src/index.js +5 -0
- package/dist/gateway/src/index.js.map +1 -0
- package/dist/gateway/src/main.d.ts +1 -0
- package/dist/gateway/src/main.js +57 -0
- package/dist/gateway/src/main.js.map +1 -0
- package/dist/gateway/src/rate-limit.d.ts +22 -0
- package/dist/gateway/src/rate-limit.js +66 -0
- package/dist/gateway/src/rate-limit.js.map +1 -0
- package/dist/gateway/src/relay.d.ts +84 -0
- package/dist/gateway/src/relay.js +434 -0
- package/dist/gateway/src/relay.js.map +1 -0
- package/dist/gateway/src/serve.d.ts +34 -0
- package/dist/gateway/src/serve.js +102 -0
- package/dist/gateway/src/serve.js.map +1 -0
- package/dist/gateway/src/store.d.ts +35 -0
- package/dist/gateway/src/store.js +109 -0
- package/dist/gateway/src/store.js.map +1 -0
- package/dist/gateway/src/subscription.d.ts +18 -0
- package/dist/gateway/src/subscription.js +46 -0
- package/dist/gateway/src/subscription.js.map +1 -0
- package/dist/host/src/drivers/claude/transcript.d.ts +23 -23
- package/dist/host/src/drivers/codex/app-server.d.ts +31 -6
- package/dist/host/src/drivers/codex/app-server.js +99 -24
- package/dist/host/src/drivers/codex/app-server.js.map +1 -1
- package/dist/host/src/drivers/codex/desktop-ipc.d.ts +8 -0
- package/dist/host/src/drivers/codex/desktop-ipc.js +124 -0
- package/dist/host/src/drivers/codex/desktop-ipc.js.map +1 -0
- package/dist/host/src/drivers/codex/driver.d.ts +111 -2
- package/dist/host/src/drivers/codex/driver.js +629 -63
- package/dist/host/src/drivers/codex/driver.js.map +1 -1
- package/dist/host/src/drivers/codex/mapper.d.ts +21 -0
- package/dist/host/src/drivers/codex/mapper.js +45 -6
- package/dist/host/src/drivers/codex/mapper.js.map +1 -1
- package/dist/host/src/drivers/types.d.ts +6 -0
- package/dist/host/src/fs.d.ts +3 -3
- package/dist/host/src/hub.js +8 -1
- package/dist/host/src/hub.js.map +1 -1
- package/dist/host/src/input.d.ts +4 -4
- package/dist/host/src/terminals.d.ts +1 -1
- package/dist/wire/src/model.d.ts +6 -6
- package/dist/wire/src/relay.d.ts +8 -8
- package/dist/wire/src/rpc.d.ts +818 -818
- package/dist/wire/src/updates.d.ts +18 -18
- package/package.json +5 -14
- package/src/commands/gateway.ts +31 -0
- package/src/commands/host.ts +13 -2
- package/src/commands/setup.ts +4 -2
- package/src/index.ts +14 -429
- package/src/utils/daemon.ts +2 -27
- package/dist/cli/src/config.d.ts +0 -14
- package/dist/cli/src/config.js +0 -23
- package/dist/cli/src/config.js.map +0 -1
- package/dist/cli/src/machine-id.d.ts +0 -6
- package/dist/cli/src/machine-id.js +0 -50
- package/dist/cli/src/machine-id.js.map +0 -1
- package/dist/cli/src/providers.d.ts +0 -13
- package/dist/cli/src/providers.js +0 -77
- package/dist/cli/src/providers.js.map +0 -1
- package/dist/cli/src/runtime/acp/acp-client.d.ts +0 -128
- package/dist/cli/src/runtime/acp/acp-client.js +0 -430
- package/dist/cli/src/runtime/acp/acp-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-process-discovery.d.ts +0 -48
- package/dist/cli/src/runtime/acp/agent-process-discovery.js +0 -278
- package/dist/cli/src/runtime/acp/agent-process-discovery.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-session.d.ts +0 -62
- package/dist/cli/src/runtime/acp/agent-session.js +0 -1081
- package/dist/cli/src/runtime/acp/agent-session.js.map +0 -1
- package/dist/cli/src/runtime/acp/agent-workspace.d.ts +0 -311
- package/dist/cli/src/runtime/acp/agent-workspace.js +0 -6083
- package/dist/cli/src/runtime/acp/agent-workspace.js.map +0 -1
- package/dist/cli/src/runtime/acp/claude-sdk-client.d.ts +0 -121
- package/dist/cli/src/runtime/acp/claude-sdk-client.js +0 -1147
- package/dist/cli/src/runtime/acp/claude-sdk-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/claude-stream-json-client.d.ts +0 -53
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js +0 -552
- package/dist/cli/src/runtime/acp/claude-stream-json-client.js.map +0 -1
- package/dist/cli/src/runtime/acp/json-rpc.d.ts +0 -22
- package/dist/cli/src/runtime/acp/json-rpc.js +0 -160
- package/dist/cli/src/runtime/acp/json-rpc.js.map +0 -1
- package/dist/cli/src/runtime/acp/provider-resolver.d.ts +0 -16
- package/dist/cli/src/runtime/acp/provider-resolver.js +0 -132
- package/dist/cli/src/runtime/acp/provider-resolver.js.map +0 -1
- package/dist/cli/src/runtime/bridge-session.d.ts +0 -97
- package/dist/cli/src/runtime/bridge-session.js +0 -1364
- package/dist/cli/src/runtime/bridge-session.js.map +0 -1
- package/dist/cli/src/runtime/hook-command.d.ts +0 -9
- package/dist/cli/src/runtime/hook-command.js +0 -80
- package/dist/cli/src/runtime/hook-command.js.map +0 -1
- package/dist/cli/src/runtime/screen-fallback.d.ts +0 -22
- package/dist/cli/src/runtime/screen-fallback.js +0 -202
- package/dist/cli/src/runtime/screen-fallback.js.map +0 -1
- package/dist/cli/src/runtime/screen-share.d.ts +0 -34
- package/dist/cli/src/runtime/screen-share.js +0 -376
- package/dist/cli/src/runtime/screen-share.js.map +0 -1
- package/dist/cli/src/runtime/scrollback.d.ts +0 -11
- package/dist/cli/src/runtime/scrollback.js +0 -33
- package/dist/cli/src/runtime/scrollback.js.map +0 -1
- package/dist/cli/src/runtime/usage-report.d.ts +0 -34
- package/dist/cli/src/runtime/usage-report.js +0 -332
- package/dist/cli/src/runtime/usage-report.js.map +0 -1
- package/dist/cli/src/utils/keep-awake.d.ts +0 -6
- package/dist/cli/src/utils/keep-awake.js +0 -48
- package/dist/cli/src/utils/keep-awake.js.map +0 -1
- package/dist/cli/src/utils/lan-ip.d.ts +0 -9
- package/dist/cli/src/utils/lan-ip.js +0 -61
- package/dist/cli/src/utils/lan-ip.js.map +0 -1
- package/dist/host/src/input-helper.d.ts +0 -1
- package/dist/host/src/input-helper.js +0 -564
- package/dist/host/src/input-helper.js.map +0 -1
- package/dist/shared-protocol/src/index.d.ts +0 -17029
- package/dist/shared-protocol/src/index.js +0 -1064
- package/dist/shared-protocol/src/index.js.map +0 -1
- package/scripts/copy-web.mjs +0 -34
- package/src/config.ts +0 -36
- package/src/machine-id.ts +0 -61
- package/src/providers.ts +0 -97
- package/src/runtime/acp/acp-client.ts +0 -521
- package/src/runtime/acp/agent-process-discovery.ts +0 -308
- package/src/runtime/acp/agent-session.ts +0 -1186
- package/src/runtime/acp/agent-workspace.ts +0 -6590
- package/src/runtime/acp/claude-sdk-client.ts +0 -1221
- package/src/runtime/acp/claude-stream-json-client.ts +0 -633
- package/src/runtime/acp/json-rpc.ts +0 -184
- package/src/runtime/acp/provider-resolver.ts +0 -154
- package/src/runtime/bridge-session.ts +0 -1549
- package/src/runtime/hook-command.ts +0 -73
- package/src/runtime/screen-fallback.ts +0 -235
- package/src/runtime/screen-share.ts +0 -426
- package/src/runtime/scrollback.ts +0 -43
- package/src/runtime/usage-report.ts +0 -342
- package/src/utils/keep-awake.ts +0 -61
- package/src/utils/lan-ip.ts +0 -61
- package/web/assets/FileBrowser-wOAfY-xj.js +0 -2
- package/web/assets/PortPreview-Cbvv7yzA.js +0 -2
- package/web/assets/TerminalPanel-CTSklkct.js +0 -1
- package/web/assets/UsageDashboard-CJ1AsNmd.js +0 -1
- package/web/assets/index-BsnDaeCt.css +0 -33
- package/web/assets/index-q85psYZ5.js +0 -36
- package/web/assets/vendor-highlight-NXWAAyIv.js +0 -5
- package/web/assets/vendor-react-M7xG3qiw.js +0 -49
- package/web/assets/vendor-xterm-D15THcI4.js +0 -9
- package/web/index.html +0 -37
- package/web/logos/claude.png +0 -0
- package/web/logos/codex.png +0 -0
- package/web/logos/copilot.png +0 -0
- package/web/logos/gemini.png +0 -0
- package/web/logos/linkshell-1024.png +0 -0
- package/web/logos/linkshell-splash.png +0 -0
- 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
|