nexting-cc-bridge 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +252 -0
- package/dist/attach-manager.js +259 -0
- package/dist/bridge.js +931 -0
- package/dist/cli-args.js +14 -0
- package/dist/cli.js +742 -0
- package/dist/codex-prompts.js +148 -0
- package/dist/codex-thread-source.js +495 -0
- package/dist/codex-transcript.js +415 -0
- package/dist/dev-server.js +126 -0
- package/dist/discovery.js +111 -0
- package/dist/e2e/codec.js +119 -0
- package/dist/e2e/crypto.js +127 -0
- package/dist/e2e/key-store.js +48 -0
- package/dist/e2e/keychain-identity.js +29 -0
- package/dist/engine/adapter.js +5 -0
- package/dist/engine/claude-adapter.js +77 -0
- package/dist/engine/codex-adapter.js +593 -0
- package/dist/file-preview.js +292 -0
- package/dist/hub-protocol.js +28 -0
- package/dist/hub-server.js +106 -0
- package/dist/hub.js +84 -0
- package/dist/install-util.js +33 -0
- package/dist/local-shell.js +32 -0
- package/dist/mcp-config.js +230 -0
- package/dist/mcp-device-proxy.js +501 -0
- package/dist/media-hydrator.js +222 -0
- package/dist/message-counter.js +79 -0
- package/dist/phone-probe.js +55 -0
- package/dist/prompt-detector.js +213 -0
- package/dist/protocol.js +3 -0
- package/dist/pty-mirror.js +80 -0
- package/dist/pty-spawn.js +53 -0
- package/dist/scanner.js +422 -0
- package/dist/self-update.js +122 -0
- package/dist/session-map.js +15 -0
- package/dist/session-runner.js +131 -0
- package/dist/shell.js +104 -0
- package/dist/skills-scanner.js +167 -0
- package/dist/stdin-encode.js +32 -0
- package/dist/stream-translate.js +122 -0
- package/dist/terminal-render.js +29 -0
- package/dist/transcript-watcher.js +138 -0
- package/dist/transcript.js +346 -0
- package/dist/turn-probe.js +152 -0
- package/dist/types.js +2 -0
- package/dist/watch-manager.js +77 -0
- package/install-cc.sh +90 -0
- package/install-codex.sh +97 -0
- package/package.json +39 -0
- package/shim/claude +55 -0
package/dist/bridge.js
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import readline from "node:readline";
|
|
6
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
7
|
+
import { HttpsProxyAgent } from "https-proxy-agent";
|
|
8
|
+
import { startLocalShell } from "./local-shell.js";
|
|
9
|
+
import { createHub } from "./hub.js";
|
|
10
|
+
import { startHubServer } from "./hub-server.js";
|
|
11
|
+
/** Build a proxy agent from env, if any. A direct China→US WSS gets reset by the
|
|
12
|
+
* network (ETIMEDOUT) and the bridge's own DNS intermittently fails (ENOTFOUND);
|
|
13
|
+
* routing the socket through the local proxy (Clash etc.) fixes both — the bridge
|
|
14
|
+
* dials 127.0.0.1 and the proxy resolves + tunnels to the server. */
|
|
15
|
+
export function proxyAgentFromEnv() {
|
|
16
|
+
const url = process.env.NEXTING_CC_PROXY ||
|
|
17
|
+
process.env.HTTPS_PROXY ||
|
|
18
|
+
process.env.https_proxy ||
|
|
19
|
+
process.env.ALL_PROXY ||
|
|
20
|
+
process.env.all_proxy;
|
|
21
|
+
return url ? new HttpsProxyAgent(url) : undefined;
|
|
22
|
+
}
|
|
23
|
+
import { createClaudeAdapter, resolveClaudeBin, } from "./engine/claude-adapter.js";
|
|
24
|
+
import { createCodexAdapter, resolveCodexBin } from "./engine/codex-adapter.js";
|
|
25
|
+
export { resolveClaudeBin, resolveCodexBin };
|
|
26
|
+
import { discoverSessions, discoverCodexSessions, readFullTranscript, listDir, } from "./scanner.js";
|
|
27
|
+
import { listSkills } from "./skills-scanner.js";
|
|
28
|
+
import { readFilePreview } from "./file-preview.js";
|
|
29
|
+
import { listCodexPrompts } from "./codex-prompts.js";
|
|
30
|
+
import { createCodexThreadSource, CODEX_PROBE_CWD, } from "./codex-thread-source.js";
|
|
31
|
+
import { createAttachManager } from "./attach-manager.js";
|
|
32
|
+
import { createWatchManager } from "./watch-manager.js";
|
|
33
|
+
import { createTurnProbe } from "./turn-probe.js";
|
|
34
|
+
import { createPromptDetector } from "./prompt-detector.js";
|
|
35
|
+
import { parseCodexTranscript, findCodexSessionFile, readCodexTranscript, } from "./codex-transcript.js";
|
|
36
|
+
import { createBridgeMediaUploader, hydrateTranscriptMedia, } from "./media-hydrator.js";
|
|
37
|
+
import { cloudHttpBaseFromWsUrl } from "./mcp-config.js";
|
|
38
|
+
import { SessionKeyStore } from "./e2e/key-store.js";
|
|
39
|
+
import { EnvelopeCodec } from "./e2e/codec.js";
|
|
40
|
+
import { keychainIdentityStore } from "./e2e/keychain-identity.js";
|
|
41
|
+
export const VERSION = "0.8.3";
|
|
42
|
+
/** Real spawn of an engine child process (bidirectional pipes). */
|
|
43
|
+
const realSpawn = (command, args, opts) => nodeSpawn(command, args, {
|
|
44
|
+
cwd: opts.cwd,
|
|
45
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
46
|
+
});
|
|
47
|
+
export function buildSnapshot(sessions) {
|
|
48
|
+
return { type: "cc_snapshot", sessions };
|
|
49
|
+
}
|
|
50
|
+
/** Stamp `controllable:true` on sessions the local shell owns. Pure for testing. */
|
|
51
|
+
export function stampControllable(sessions, controllable) {
|
|
52
|
+
return sessions.map((s) => ({
|
|
53
|
+
...s,
|
|
54
|
+
controllable: controllable.has(s.sessionId),
|
|
55
|
+
}));
|
|
56
|
+
}
|
|
57
|
+
const CODEX_STATE_FILES = new Set([
|
|
58
|
+
"state_5.sqlite",
|
|
59
|
+
"state_5.sqlite-wal",
|
|
60
|
+
"state_5.sqlite-shm",
|
|
61
|
+
"session_index.jsonl",
|
|
62
|
+
".codex-global-state.json",
|
|
63
|
+
]);
|
|
64
|
+
/** Top-level Codex App files that can change the visible sidebar title/list
|
|
65
|
+
* without a session JSONL write. `fs.watch` can omit filename on macOS, so
|
|
66
|
+
* null is treated as relevant and let the snapshot diff decide. */
|
|
67
|
+
export function isCodexStateFileChange(filename) {
|
|
68
|
+
if (filename == null)
|
|
69
|
+
return true;
|
|
70
|
+
const raw = String(filename);
|
|
71
|
+
if (raw.includes("/") || raw.includes("\\"))
|
|
72
|
+
return false;
|
|
73
|
+
return CODEX_STATE_FILES.has(path.basename(raw));
|
|
74
|
+
}
|
|
75
|
+
/** Pure-ish message handler: reacts to cloud frames. Unit-tested. */
|
|
76
|
+
export function makeBridgeHandler(deps) {
|
|
77
|
+
return async (msg) => {
|
|
78
|
+
if (msg.type === "cc_session_complete") {
|
|
79
|
+
const sessionId = msg.sessionId;
|
|
80
|
+
const result = msg.result;
|
|
81
|
+
if (sessionId && result && deps.onSessionComplete) {
|
|
82
|
+
try {
|
|
83
|
+
await deps.onSessionComplete(sessionId, result);
|
|
84
|
+
}
|
|
85
|
+
catch (e) {
|
|
86
|
+
// Silently drop completion errors — completion is fire-and-forget.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (msg.type === "cc_get_transcript") {
|
|
92
|
+
const sessionId = msg.sessionId;
|
|
93
|
+
const requestId = msg.requestId;
|
|
94
|
+
// Incremental slice: fromIndex>0 returns only entries[fromIndex..] plus
|
|
95
|
+
// `total`, so reconciliation costs a few KB instead of the whole session
|
|
96
|
+
// (full-transcript storms congested the bridge's uplink — 2026-06-10).
|
|
97
|
+
const fromIndex = Math.max(0, Number(msg.fromIndex) || 0);
|
|
98
|
+
const entries = await deps.readTranscript(sessionId);
|
|
99
|
+
const total = entries?.length ?? 0;
|
|
100
|
+
const from = Math.min(fromIndex, total);
|
|
101
|
+
deps.send({
|
|
102
|
+
type: "cc_transcript_result",
|
|
103
|
+
requestId,
|
|
104
|
+
sessionId,
|
|
105
|
+
entries: entries ? entries.slice(from) : [],
|
|
106
|
+
startIndex: from,
|
|
107
|
+
total,
|
|
108
|
+
notFound: entries === null,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (msg.type === "cc_list_dir") {
|
|
112
|
+
const requestId = msg.requestId;
|
|
113
|
+
const reqPath = msg.path;
|
|
114
|
+
const listing = await deps.listDir(reqPath);
|
|
115
|
+
deps.send({
|
|
116
|
+
type: "cc_dir_result",
|
|
117
|
+
requestId,
|
|
118
|
+
path: listing.path,
|
|
119
|
+
parent: listing.parent,
|
|
120
|
+
entries: listing.entries,
|
|
121
|
+
error: listing.error,
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (msg.type === "cc_read_file") {
|
|
126
|
+
const requestId = msg.requestId;
|
|
127
|
+
const reqPath = msg.path;
|
|
128
|
+
const cwd = msg.cwd;
|
|
129
|
+
const maxBytes = Number(msg.maxBytes);
|
|
130
|
+
const preview = reqPath
|
|
131
|
+
? await (deps.readFilePreview ?? readFilePreview)({
|
|
132
|
+
path: reqPath,
|
|
133
|
+
cwd,
|
|
134
|
+
maxBytes: Number.isFinite(maxBytes) ? maxBytes : undefined,
|
|
135
|
+
})
|
|
136
|
+
: {
|
|
137
|
+
path: "",
|
|
138
|
+
resolvedPath: "",
|
|
139
|
+
name: "",
|
|
140
|
+
kind: "unsupported",
|
|
141
|
+
mimeType: "application/octet-stream",
|
|
142
|
+
sizeBytes: 0,
|
|
143
|
+
mtimeMs: 0,
|
|
144
|
+
truncated: false,
|
|
145
|
+
error: "missing_path",
|
|
146
|
+
};
|
|
147
|
+
deps.send({
|
|
148
|
+
type: "cc_file_result",
|
|
149
|
+
requestId,
|
|
150
|
+
...preview,
|
|
151
|
+
});
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
// cc_watch_start/stop are the cloud's subscription-gate frames (sent on the
|
|
155
|
+
// first/last SSE subscriber) — aliases of the phone's explicit cc_watch /
|
|
156
|
+
// cc_unwatch. Both drive the same watch manager.
|
|
157
|
+
if (msg.type === "cc_watch" || msg.type === "cc_watch_start") {
|
|
158
|
+
const sessionId = msg.sessionId;
|
|
159
|
+
if (typeof sessionId === "string" && sessionId) {
|
|
160
|
+
await deps.watch?.(sessionId);
|
|
161
|
+
deps.reemitTurn?.(sessionId);
|
|
162
|
+
}
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (msg.type === "cc_unwatch" || msg.type === "cc_watch_stop") {
|
|
166
|
+
const sessionId = msg.sessionId;
|
|
167
|
+
if (typeof sessionId === "string" && sessionId)
|
|
168
|
+
deps.unwatch?.(sessionId);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (msg.type === "cc_list_skills") {
|
|
172
|
+
const requestId = msg.requestId;
|
|
173
|
+
const cwd = msg.cwd;
|
|
174
|
+
const listing = await deps.listSkills(cwd);
|
|
175
|
+
deps.send({
|
|
176
|
+
type: "cc_skills_result",
|
|
177
|
+
requestId,
|
|
178
|
+
skills: listing.skills,
|
|
179
|
+
commands: listing.commands,
|
|
180
|
+
error: listing.error,
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (msg.type === "cc_refresh_sessions") {
|
|
185
|
+
const requestId = msg.requestId;
|
|
186
|
+
if (typeof requestId === "string" && requestId) {
|
|
187
|
+
await deps.refreshSessions?.(requestId);
|
|
188
|
+
}
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
// heartbeat_ack and anything else: ignore.
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
export function startBridge(opts) {
|
|
195
|
+
const log = opts.onLog ?? ((l) => console.log(`[cc-bridge] ${l}`));
|
|
196
|
+
const interval = opts.snapshotIntervalMs ?? 15000;
|
|
197
|
+
let ws = null;
|
|
198
|
+
// E2E transport seam: an EnvelopeCodec wraps outbound content frames and
|
|
199
|
+
// unwraps inbound encrypted frames. When e2eEnabled is false (the default),
|
|
200
|
+
// both encryptOutbound and decryptInbound are identity functions — zero
|
|
201
|
+
// behavior change for existing deployments.
|
|
202
|
+
const e2eEnabled = opts.e2eEnabled === true;
|
|
203
|
+
const keyStore = new SessionKeyStore(keychainIdentityStore());
|
|
204
|
+
const codec = new EnvelopeCodec(keyStore, () => e2eEnabled);
|
|
205
|
+
// Track which sessionIds have already had their CEK uploaded to the cloud so
|
|
206
|
+
// we only POST /session-keys once per session (re-upload on peer-set change
|
|
207
|
+
// is a future follow-up — see TODO below).
|
|
208
|
+
const cekUploadedSessions = new Set();
|
|
209
|
+
/**
|
|
210
|
+
* Upload wrapped CEKs for any sessions not yet registered with the cloud.
|
|
211
|
+
* Best-effort: never throws or crashes the bridge.
|
|
212
|
+
*
|
|
213
|
+
* TODO(e2e follow-up): re-upload when the peer set changes (e.g. a new phone
|
|
214
|
+
* logs in after sessions already exist). Currently a session's CEK is only
|
|
215
|
+
* uploaded once; new devices that join later will not have a wrapped copy
|
|
216
|
+
* until the session rotates or the bridge restarts.
|
|
217
|
+
*/
|
|
218
|
+
async function uploadNewSessionCeks(sessions) {
|
|
219
|
+
if (!e2eEnabled || !opts.bridgeId)
|
|
220
|
+
return;
|
|
221
|
+
const newIds = sessions
|
|
222
|
+
.map((s) => s.sessionId)
|
|
223
|
+
.filter((id) => !cekUploadedSessions.has(id));
|
|
224
|
+
if (newIds.length === 0)
|
|
225
|
+
return;
|
|
226
|
+
const cloudBase = cloudHttpBaseFromWsUrl(opts.url);
|
|
227
|
+
const enginePath = engine === "codex" ? "codex" : "cc";
|
|
228
|
+
// Fetch peer device public keys (phones / other bridges registered to this account).
|
|
229
|
+
let peers = [];
|
|
230
|
+
try {
|
|
231
|
+
const res = await fetch(`${cloudBase}/api/v1/${enginePath}/peer-keys?exclude=${encodeURIComponent(opts.bridgeId)}`, {
|
|
232
|
+
headers: { authorization: `Bearer ${opts.token}` },
|
|
233
|
+
});
|
|
234
|
+
if (res.ok) {
|
|
235
|
+
const body = (await res.json());
|
|
236
|
+
peers = body.peers ?? [];
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
log(`e2e peer-keys fetch failed (${res.status}) — skipping CEK upload for ${newIds.length} session(s)`);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
log(`e2e peer-keys fetch error: ${err.message}`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (peers.length === 0) {
|
|
248
|
+
// No peers yet — mark sessions as uploaded (CEK still generated & cached
|
|
249
|
+
// by keyStore.cekFor so outbound encryption works immediately). When a peer
|
|
250
|
+
// registers later they get the CEK via their own device-keys lookup path.
|
|
251
|
+
for (const id of newIds)
|
|
252
|
+
cekUploadedSessions.add(id);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// Upload CEKs for each new session.
|
|
256
|
+
for (const sessionId of newIds) {
|
|
257
|
+
try {
|
|
258
|
+
// wrapForDevices also creates the CEK if it doesn't exist yet.
|
|
259
|
+
const wrapped = keyStore.wrapForDevices(sessionId, peers.map((p) => p.publicKey));
|
|
260
|
+
// zip: wrapForDevices returns {deviceKey: pubB64, wrappedCek} — map back to deviceId.
|
|
261
|
+
const pubToDeviceId = new Map(peers.map((p) => [p.publicKey, p.deviceId]));
|
|
262
|
+
const keys = wrapped.map((w) => ({
|
|
263
|
+
deviceId: pubToDeviceId.get(w.deviceKey) ?? w.deviceKey,
|
|
264
|
+
wrappedCek: w.wrappedCek,
|
|
265
|
+
}));
|
|
266
|
+
const res = await fetch(`${cloudBase}/api/v1/${enginePath}/session-keys`, {
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: {
|
|
269
|
+
"content-type": "application/json",
|
|
270
|
+
authorization: `Bearer ${opts.token}`,
|
|
271
|
+
},
|
|
272
|
+
body: JSON.stringify({ sessionId, keys }),
|
|
273
|
+
});
|
|
274
|
+
if (res.ok) {
|
|
275
|
+
cekUploadedSessions.add(sessionId);
|
|
276
|
+
log(`e2e CEK uploaded for session ${sessionId} (${peers.length} peer(s))`);
|
|
277
|
+
}
|
|
278
|
+
else {
|
|
279
|
+
const body = await res.text().catch(() => "");
|
|
280
|
+
log(`e2e session-keys POST failed (${res.status}): ${body}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (err) {
|
|
284
|
+
log(`e2e CEK upload error for ${sessionId}: ${err.message}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Single outbound seam: ALL content/data frames flow through sendFrame so
|
|
289
|
+
// the codec can encrypt them. Terminal frames (cc_term_*) and cc_hello pass
|
|
290
|
+
// through as well — codec returns them unchanged (default case in switch).
|
|
291
|
+
// Send only when the socket is actually OPEN. A frame fired while the socket
|
|
292
|
+
// is CONNECTING / mid-reconnect (mirror PTY output, a queued cloud frame, the
|
|
293
|
+
// heartbeat) would otherwise throw "WebSocket is not open: readyState 0" and
|
|
294
|
+
// crash the whole bridge process — which launchd restarts straight back into
|
|
295
|
+
// the same CONNECTING window, i.e. a crash loop that reads as a real outage on
|
|
296
|
+
// the phone. Dropping the frame is correct: snapshots/watches reconcile on the
|
|
297
|
+
// next OPEN, and the cloud relay is best-effort for these ephemeral frames.
|
|
298
|
+
function sendFrame(m) {
|
|
299
|
+
const f = codec.encryptOutbound(m);
|
|
300
|
+
if (ws?.readyState === WebSocket.OPEN)
|
|
301
|
+
ws.send(JSON.stringify(f));
|
|
302
|
+
}
|
|
303
|
+
const safeSend = (m) => sendFrame(m);
|
|
304
|
+
let snapTimer = null;
|
|
305
|
+
let hbTimer = null;
|
|
306
|
+
// Last time the cloud acked our heartbeat. The watchdog in hbTimer terminates a
|
|
307
|
+
// zombie (half-open) connection when this goes stale — the TCP "close" that
|
|
308
|
+
// would normally trigger reconnect never fires on sleep/wake, NAT/firewall
|
|
309
|
+
// silent drops, or a hung TUN dataplane.
|
|
310
|
+
let lastHeartbeatAck = Date.now();
|
|
311
|
+
let lastJson = "";
|
|
312
|
+
let stopped = false;
|
|
313
|
+
let backoff = 1000;
|
|
314
|
+
let shellStarted = false; // local shell is started once, survives reconnects
|
|
315
|
+
let rl = null;
|
|
316
|
+
let mirrorStarted = false; // pty mirror is started once, survives reconnects
|
|
317
|
+
let mirrorRef = null;
|
|
318
|
+
let mirrorTermId = "";
|
|
319
|
+
// Hub daemon: multiplexes many local shells onto this one cloud connection.
|
|
320
|
+
let hub = null;
|
|
321
|
+
let hubServer = null;
|
|
322
|
+
// Engine-aware transcript source: a codex bridge reads/tails rollout files
|
|
323
|
+
// under ~/.codex/sessions (its own line format); claude reads project jsonl.
|
|
324
|
+
const engine = opts.engine ?? "claude";
|
|
325
|
+
const isCodex = engine === "codex";
|
|
326
|
+
const rawReadTranscriptFn = isCodex
|
|
327
|
+
? readCodexTranscript
|
|
328
|
+
: readFullTranscript;
|
|
329
|
+
const mediaUploader = createBridgeMediaUploader({
|
|
330
|
+
bridgeUrl: opts.url,
|
|
331
|
+
token: opts.token,
|
|
332
|
+
engine,
|
|
333
|
+
e2eEnabled,
|
|
334
|
+
cekProvider: (sessionId) => {
|
|
335
|
+
if (!e2eEnabled)
|
|
336
|
+
return null;
|
|
337
|
+
// cekFor is get-or-create; only return a CEK if one already exists for
|
|
338
|
+
// this session (it will have been created by uploadNewSessionCeks when the
|
|
339
|
+
// snapshot was pushed). Calling cekFor here unconditionally would create a
|
|
340
|
+
// dangling CEK that was never uploaded to the cloud, making the phone
|
|
341
|
+
// unable to decrypt. hasCek guards that: if the session hasn't gone
|
|
342
|
+
// through the CEK-upload flow yet, upload unencrypted.
|
|
343
|
+
return keyStore.hasCek(sessionId) ? keyStore.cekFor(sessionId) : null;
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
async function hydrateMedia(entries, sessionId) {
|
|
347
|
+
try {
|
|
348
|
+
return await hydrateTranscriptMedia(entries, mediaUploader, sessionId);
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
log(`media hydration failed: ${err.message}`);
|
|
352
|
+
return entries;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
const readTranscriptFn = async (sessionId) => {
|
|
356
|
+
const entries = await rawReadTranscriptFn(sessionId);
|
|
357
|
+
return entries ? hydrateMedia(entries, sessionId) : null;
|
|
358
|
+
};
|
|
359
|
+
const adapterFactory = engine === "codex" ? createCodexAdapter : createClaudeAdapter;
|
|
360
|
+
// PTY turn-activity probe: watches wrapper-run terminals' output for the
|
|
361
|
+
// engine TUI's "running marker" (e.g. "esc to interrupt") and emits
|
|
362
|
+
// cc_event{kind:"term_turn", payload:{running}} transitions. Critically it
|
|
363
|
+
// covers the Esc-before-output abort the session jsonl NEVER records —
|
|
364
|
+
// running:false after 8s of marker silence stops the phone's spinner.
|
|
365
|
+
const turnProbe = createTurnProbe({
|
|
366
|
+
markers: adapterFactory().runningMarkers,
|
|
367
|
+
emit: (m) => sendFrame(m),
|
|
368
|
+
});
|
|
369
|
+
const promptDetector = createPromptDetector({
|
|
370
|
+
emit: (m) => sendFrame(m),
|
|
371
|
+
});
|
|
372
|
+
function injectTermInput(termId, data) {
|
|
373
|
+
if (hub && hub.activeTermIds().includes(termId)) {
|
|
374
|
+
hub.handleCloud({ type: "cc_term_input", termId, data });
|
|
375
|
+
}
|
|
376
|
+
else if (termId === mirrorTermId) {
|
|
377
|
+
mirrorRef?.writeInput(data);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// Transcript push: followers survive WS reconnects. A frame fired while the
|
|
381
|
+
// link is down is dropped — but the send reports that (returns false) so a
|
|
382
|
+
// transcript_append rolls back its tail state and the next poke re-emits it
|
|
383
|
+
// (transcript-watcher.ts); the phone also self-heals via the per-frame `total`
|
|
384
|
+
// cursor and the watch_ok{entryCount} heartbeat on the next re-watch.
|
|
385
|
+
const watchMgr = createWatchManager((m) => {
|
|
386
|
+
if (ws?.readyState === WebSocket.OPEN) {
|
|
387
|
+
sendFrame(m);
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
return false;
|
|
391
|
+
}, {
|
|
392
|
+
log,
|
|
393
|
+
findFile: isCodex ? findCodexSessionFile : undefined,
|
|
394
|
+
parse: isCodex ? parseCodexTranscript : undefined,
|
|
395
|
+
transform: hydrateMedia,
|
|
396
|
+
});
|
|
397
|
+
let handler;
|
|
398
|
+
// §4.3: a binding revocation (a `*_bridge_revoked` frame OR a 4012 close) is
|
|
399
|
+
// TERMINAL — stop reconnecting, print the pinned message, and let the CLI clear
|
|
400
|
+
// the local token + bridgeId. Idempotent (frame and 4012 close often both fire).
|
|
401
|
+
const revokedFrameType = engine === "codex" ? "codex_bridge_revoked" : "cc_bridge_revoked";
|
|
402
|
+
const updateFrameType = engine === "codex" ? "codex_update" : "cc_update";
|
|
403
|
+
let terminalRevokeHandled = false;
|
|
404
|
+
function handleTerminalRevoke() {
|
|
405
|
+
if (terminalRevokeHandled)
|
|
406
|
+
return;
|
|
407
|
+
terminalRevokeHandled = true;
|
|
408
|
+
stopped = true; // suppress reconnect backoff in ws.on("close")
|
|
409
|
+
log("This Mac was removed from your Nexting account. Re-run the installer to reconnect.");
|
|
410
|
+
try {
|
|
411
|
+
opts.onTerminalRevoke?.();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
/* clearing config is best-effort */
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const proxyAgent = proxyAgentFromEnv();
|
|
418
|
+
// v2 remote-control: owns live engine children for attached sessions.
|
|
419
|
+
const bin = engine === "codex" ? resolveCodexBin() : resolveClaudeBin();
|
|
420
|
+
log(`${engine} binary: ${bin}`);
|
|
421
|
+
if (proxyAgent)
|
|
422
|
+
log(`proxying WS via ${process.env.HTTPS_PROXY ?? process.env.ALL_PROXY ?? "env proxy"}`);
|
|
423
|
+
// Device-tool MCP proxy wiring: every spawned engine child gets a per-session
|
|
424
|
+
// `pinclaw-device` MCP server pointed at the cloud, authenticated with the
|
|
425
|
+
// bridge's own pbt agent-bus token (the cloud `verifyBusToken`s it back to this
|
|
426
|
+
// userId; the /cc/* vs /codex/* route + cx:-scope select the engine). The cloud
|
|
427
|
+
// HTTP base is derived from the WS connect url. No token → no device tools
|
|
428
|
+
// (omit mcp), but the bridge always has a token to even connect, so this is set.
|
|
429
|
+
const mcpWiring = opts.token
|
|
430
|
+
? { cloudUrl: cloudHttpBaseFromWsUrl(opts.url), busToken: opts.token }
|
|
431
|
+
: undefined;
|
|
432
|
+
// Engine children inherit this process's env PLUS opts.engineEnv (proxy/locale
|
|
433
|
+
// the launchd daemon doesn't have but the user's terminal claude relies on).
|
|
434
|
+
const engineSpawn = opts.engineEnv && Object.keys(opts.engineEnv).length
|
|
435
|
+
? (command, args, o) => nodeSpawn(command, args, {
|
|
436
|
+
cwd: o.cwd,
|
|
437
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
438
|
+
env: { ...process.env, ...opts.engineEnv },
|
|
439
|
+
})
|
|
440
|
+
: realSpawn;
|
|
441
|
+
// Egress audit: spell out exactly where a phone-spawned engine child's API
|
|
442
|
+
// traffic exits. Mixing the user's account across a residential-proxy exit
|
|
443
|
+
// (terminal) and a direct region-blocked exit (a mis-configured bridge) is an
|
|
444
|
+
// account-ban risk, so this MUST be loud and verifiable. A set `https_proxy`
|
|
445
|
+
// also fails CLOSED (a dead proxy errors the request; it never silently falls
|
|
446
|
+
// back to direct).
|
|
447
|
+
{
|
|
448
|
+
const ep = opts.engineEnv ?? {};
|
|
449
|
+
const proxy = ep.https_proxy || ep.HTTPS_PROXY || ep.all_proxy || ep.ALL_PROXY;
|
|
450
|
+
if (proxy) {
|
|
451
|
+
const redacted = String(proxy).replace(/\/\/[^@/]*@/, "//***@");
|
|
452
|
+
log(`engine egress: PROXIED via ${redacted}${ep.TZ ? ` TZ=${ep.TZ}` : ""} — mirrors the terminal; fails closed`);
|
|
453
|
+
}
|
|
454
|
+
else {
|
|
455
|
+
log(`engine egress: DIRECT (no engineEnv proxy). Region-blocked APIs (e.g. Anthropic from CN) will 403; set config.engineEnv to mirror your terminal's exit`);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
const attachMgr = createAttachManager({
|
|
459
|
+
send: safeSend,
|
|
460
|
+
spawn: engineSpawn,
|
|
461
|
+
adapterFactory,
|
|
462
|
+
engine,
|
|
463
|
+
mcp: mcpWiring,
|
|
464
|
+
});
|
|
465
|
+
if (opts.hub) {
|
|
466
|
+
hub = createHub({
|
|
467
|
+
// Guard readyState: a shell can register while the cloud WS is still
|
|
468
|
+
// CONNECTING / mid-reconnect; ws.send() throws if not OPEN. Drop here and
|
|
469
|
+
// rely on resyncToCloud() (on open) to re-announce every term.
|
|
470
|
+
onCloudSend: (m) => {
|
|
471
|
+
// Feed the turn probe BEFORE the readyState guard — probe state must
|
|
472
|
+
// keep tracking pty output even while the cloud link is down.
|
|
473
|
+
if (m.type === "cc_term_hello") {
|
|
474
|
+
turnProbe.noteTermHello(String(m.termId), typeof m.sessionId === "string" ? m.sessionId : undefined);
|
|
475
|
+
}
|
|
476
|
+
else if (m.type === "cc_term_output") {
|
|
477
|
+
turnProbe.noteOutput(String(m.termId), String(m.data ?? ""));
|
|
478
|
+
promptDetector.noteOutput(String(m.termId), String(m.data ?? ""));
|
|
479
|
+
}
|
|
480
|
+
else if (m.type === "cc_term_bye") {
|
|
481
|
+
turnProbe.noteTermBye(String(m.termId));
|
|
482
|
+
promptDetector.noteTermBye(String(m.termId));
|
|
483
|
+
}
|
|
484
|
+
sendFrame(m);
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
hubServer = startHubServer(hub, opts.hub.socketPath, log);
|
|
488
|
+
}
|
|
489
|
+
// Codex session lists come from the long-lived read-only thread/list child
|
|
490
|
+
// (origin/openIn/appChat/titles); on any failure it returns null and we fall
|
|
491
|
+
// back to the disk scan so the snapshot never goes dark.
|
|
492
|
+
const codexThreadSource = isCodex ? createCodexThreadSource({ log }) : null;
|
|
493
|
+
async function discoverForEngine() {
|
|
494
|
+
if (engine !== "codex")
|
|
495
|
+
return discoverSessions();
|
|
496
|
+
const viaRpc = await codexThreadSource?.listSessions();
|
|
497
|
+
if (viaRpc)
|
|
498
|
+
return viaRpc;
|
|
499
|
+
// Disk-scan fallback: apply the same probe-session filter.
|
|
500
|
+
return (await discoverCodexSessions()).filter((s) => s.cwd !== CODEX_PROBE_CWD);
|
|
501
|
+
}
|
|
502
|
+
// Near-realtime snapshots: watch the engine's session-file root so a session
|
|
503
|
+
// created/updated in a terminal reaches the cloud (and the phone's list)
|
|
504
|
+
// within ~2s instead of waiting for the 15s sweep. pushSnapshot's
|
|
505
|
+
// diff-skip-unchanged makes spurious fs events free; FS_MIN_GAP_MS caps the
|
|
506
|
+
// disk-scan rate while a session is streaming output.
|
|
507
|
+
const FS_DEBOUNCE_MS = 1500;
|
|
508
|
+
const FS_MIN_GAP_MS = 5000;
|
|
509
|
+
const sessionsRoot = isCodex
|
|
510
|
+
? path.join(os.homedir(), ".codex", "sessions")
|
|
511
|
+
: path.join(os.homedir(), ".claude", "projects");
|
|
512
|
+
let fsWatcher = null;
|
|
513
|
+
let codexStateWatcher = null;
|
|
514
|
+
let fsDebounce = null;
|
|
515
|
+
let codexStateDebounce = null;
|
|
516
|
+
let lastFsPush = 0;
|
|
517
|
+
try {
|
|
518
|
+
fsWatcher = fs.watch(sessionsRoot, { recursive: true }, (_ev, filename) => {
|
|
519
|
+
if (filename && !String(filename).endsWith(".jsonl"))
|
|
520
|
+
return;
|
|
521
|
+
if (fsDebounce)
|
|
522
|
+
clearTimeout(fsDebounce);
|
|
523
|
+
const wait = Math.max(FS_DEBOUNCE_MS, FS_MIN_GAP_MS - (Date.now() - lastFsPush));
|
|
524
|
+
fsDebounce = setTimeout(() => {
|
|
525
|
+
fsDebounce = null;
|
|
526
|
+
lastFsPush = Date.now();
|
|
527
|
+
pushSnapshot();
|
|
528
|
+
}, wait);
|
|
529
|
+
});
|
|
530
|
+
log(`watching ${sessionsRoot} for session changes`);
|
|
531
|
+
}
|
|
532
|
+
catch (e) {
|
|
533
|
+
// Root missing (engine never run) → the 15s sweep still covers us.
|
|
534
|
+
log(`session-root watch unavailable: ${e.message}`);
|
|
535
|
+
}
|
|
536
|
+
if (isCodex) {
|
|
537
|
+
try {
|
|
538
|
+
const codexRoot = path.join(os.homedir(), ".codex");
|
|
539
|
+
codexStateWatcher = fs.watch(codexRoot, (_ev, filename) => {
|
|
540
|
+
if (!isCodexStateFileChange(filename))
|
|
541
|
+
return;
|
|
542
|
+
if (codexStateDebounce)
|
|
543
|
+
clearTimeout(codexStateDebounce);
|
|
544
|
+
codexStateDebounce = setTimeout(() => {
|
|
545
|
+
codexStateDebounce = null;
|
|
546
|
+
pushSnapshot();
|
|
547
|
+
}, 900);
|
|
548
|
+
});
|
|
549
|
+
log(`watching ${codexRoot} for Codex sidebar state changes`);
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
log(`codex-state watch unavailable: ${e.message}`);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
async function pushSnapshot(force = false, requestId) {
|
|
556
|
+
if (ws?.readyState !== WebSocket.OPEN)
|
|
557
|
+
return;
|
|
558
|
+
try {
|
|
559
|
+
const discovered = await discoverForEngine();
|
|
560
|
+
const sessions = stampControllable(discovered, new Set(attachMgr.controllableIds()));
|
|
561
|
+
const frame = buildSnapshot(sessions);
|
|
562
|
+
if (requestId)
|
|
563
|
+
frame.requestId = requestId;
|
|
564
|
+
const json = JSON.stringify(frame);
|
|
565
|
+
if (!force && json === lastJson)
|
|
566
|
+
return; // skip unchanged
|
|
567
|
+
if (!requestId)
|
|
568
|
+
lastJson = json;
|
|
569
|
+
sendFrame(frame);
|
|
570
|
+
const running = sessions.filter((s) => s.status === "running").length;
|
|
571
|
+
log(`snapshot pushed: ${sessions.length} sessions (${running} running)`);
|
|
572
|
+
// E2E: upload wrapped CEKs for any sessions the cloud hasn't seen yet.
|
|
573
|
+
// Fire-and-forget: errors are logged inside, never bubble up here.
|
|
574
|
+
uploadNewSessionCeks(sessions).catch(() => { });
|
|
575
|
+
}
|
|
576
|
+
catch (e) {
|
|
577
|
+
log(`snapshot error: ${e.message}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
handler = makeBridgeHandler({
|
|
581
|
+
send: safeSend,
|
|
582
|
+
readTranscript: (id) => readTranscriptFn(id),
|
|
583
|
+
listDir: (p) => listDir(p),
|
|
584
|
+
readFilePreview: (input) => readFilePreview(input),
|
|
585
|
+
// Codex uses the same skills protocol shape: prompts are commands and
|
|
586
|
+
// SKILL.md files are skills, with cwd scoping project-local skills.
|
|
587
|
+
listSkills: (c) => isCodex ? listCodexPrompts(undefined, { cwd: c }) : listSkills(c),
|
|
588
|
+
refreshSessions: (requestId) => pushSnapshot(true, requestId),
|
|
589
|
+
watch: (id) => watchMgr.watch(id),
|
|
590
|
+
unwatch: (id) => watchMgr.unwatch(id),
|
|
591
|
+
reemitTurn: (id) => turnProbe.reemitForSession(id),
|
|
592
|
+
onSessionComplete: async (sessionId, result) => {
|
|
593
|
+
// Forward session completion to the cloud via the cloud message path.
|
|
594
|
+
sendFrame({ type: "cc_completion", sessionId, result });
|
|
595
|
+
},
|
|
596
|
+
});
|
|
597
|
+
// §China-resilience: build the ordered dial-target list and escalate through it
|
|
598
|
+
// on consecutive failures. Hosts first (normal DNS / a CF-proxied ingress),
|
|
599
|
+
// then raw-IP fallbacks for the primary host with SNI+Host pinned so TLS still
|
|
600
|
+
// validates against the real cert. `dialAttempt` advances on each close and
|
|
601
|
+
// resets to 0 on a successful open, so a recovered DNS path is always retried
|
|
602
|
+
// first next cycle.
|
|
603
|
+
const primaryHost = (() => {
|
|
604
|
+
try {
|
|
605
|
+
return new URL(opts.url).hostname;
|
|
606
|
+
}
|
|
607
|
+
catch {
|
|
608
|
+
return "";
|
|
609
|
+
}
|
|
610
|
+
})();
|
|
611
|
+
const endpoints = opts.endpoints && opts.endpoints.length ? opts.endpoints : [opts.url];
|
|
612
|
+
const fallbackIps = (opts.fallbackIps ?? []).filter(Boolean);
|
|
613
|
+
let dialAttempt = 0;
|
|
614
|
+
function dialTargets() {
|
|
615
|
+
const out = [];
|
|
616
|
+
const base = proxyAgent
|
|
617
|
+
? { agent: proxyAgent }
|
|
618
|
+
: {};
|
|
619
|
+
for (const ep of endpoints) {
|
|
620
|
+
out.push({
|
|
621
|
+
label: ep,
|
|
622
|
+
wsUrl: `${ep}?token=${encodeURIComponent(opts.token)}`,
|
|
623
|
+
wsOpts: base,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
for (const ip of fallbackIps) {
|
|
627
|
+
let ipUrl = "";
|
|
628
|
+
try {
|
|
629
|
+
const u = new URL(opts.url);
|
|
630
|
+
u.host = ip;
|
|
631
|
+
ipUrl = u.toString();
|
|
632
|
+
}
|
|
633
|
+
catch {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
out.push({
|
|
637
|
+
label: `${ip} (SNI ${primaryHost})`,
|
|
638
|
+
wsUrl: `${ipUrl}?token=${encodeURIComponent(opts.token)}`,
|
|
639
|
+
// Dial the IP directly but present the real hostname for SNI + Host so the
|
|
640
|
+
// TLS cert validates and the cloud's vhost routing still matches.
|
|
641
|
+
wsOpts: {
|
|
642
|
+
...base,
|
|
643
|
+
servername: primaryHost,
|
|
644
|
+
headers: { Host: primaryHost },
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
return out;
|
|
649
|
+
}
|
|
650
|
+
function connect() {
|
|
651
|
+
if (stopped)
|
|
652
|
+
return;
|
|
653
|
+
const targets = dialTargets();
|
|
654
|
+
const target = targets[Math.min(dialAttempt, targets.length - 1)];
|
|
655
|
+
ws = new WebSocket(target.wsUrl, target.wsOpts);
|
|
656
|
+
ws.on("open", async () => {
|
|
657
|
+
backoff = 1000;
|
|
658
|
+
dialAttempt = 0; // recovered — prefer the primary DNS/CF path next cycle
|
|
659
|
+
lastHeartbeatAck = Date.now();
|
|
660
|
+
const peerIp = (ws?._socket
|
|
661
|
+
?.remoteAddress ?? "").replace(/^::ffff:/, "");
|
|
662
|
+
if (peerIp) {
|
|
663
|
+
try {
|
|
664
|
+
opts.onConnectedIp?.(peerIp);
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
/* persistence is best-effort */
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
log(`connected to ${target.label}${peerIp ? ` [${peerIp}]` : ""}`);
|
|
671
|
+
ws.send(JSON.stringify({
|
|
672
|
+
type: "cc_hello",
|
|
673
|
+
host: os.hostname(),
|
|
674
|
+
machineName: os.hostname(),
|
|
675
|
+
version: VERSION,
|
|
676
|
+
engine,
|
|
677
|
+
...(opts.bridgeId ? { bridgeId: opts.bridgeId } : {}),
|
|
678
|
+
...(e2eEnabled
|
|
679
|
+
? {
|
|
680
|
+
e2ePublicKey: keyStore.publicKeyB64(),
|
|
681
|
+
e2eDeviceId: opts.bridgeId ?? "",
|
|
682
|
+
}
|
|
683
|
+
: {}),
|
|
684
|
+
}));
|
|
685
|
+
// Publish device public key to cloud so the phone can fetch it for E2E
|
|
686
|
+
// key agreement. Best-effort: a failed publish must NOT crash the bridge.
|
|
687
|
+
if (e2eEnabled && opts.bridgeId) {
|
|
688
|
+
const cloudBase = cloudHttpBaseFromWsUrl(opts.url);
|
|
689
|
+
const enginePath = engine === "codex" ? "codex" : "cc";
|
|
690
|
+
fetch(`${cloudBase}/api/v1/${enginePath}/device-keys`, {
|
|
691
|
+
method: "POST",
|
|
692
|
+
headers: {
|
|
693
|
+
"content-type": "application/json",
|
|
694
|
+
authorization: `Bearer ${opts.token}`,
|
|
695
|
+
},
|
|
696
|
+
body: JSON.stringify({
|
|
697
|
+
deviceId: opts.bridgeId,
|
|
698
|
+
deviceKind: "bridge",
|
|
699
|
+
publicKey: keyStore.publicKeyB64(),
|
|
700
|
+
label: os.hostname(),
|
|
701
|
+
}),
|
|
702
|
+
}).catch((err) => {
|
|
703
|
+
log(`e2e device-key publish failed: ${err.message}`);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
pushSnapshot(true);
|
|
707
|
+
// Re-announce hub terminals so any that registered while the cloud link was
|
|
708
|
+
// down/connecting are (re)created cloud-side.
|
|
709
|
+
hub?.resyncToCloud();
|
|
710
|
+
snapTimer = setInterval(() => pushSnapshot(), interval);
|
|
711
|
+
hbTimer = setInterval(() => {
|
|
712
|
+
// Zombie/half-open detection: the cloud acks every heartbeat
|
|
713
|
+
// (heartbeat_ack). No ack for >45s ≈ a dead link — force-close it so
|
|
714
|
+
// ws.on("close") runs the reconnect. Without this the bridge sits
|
|
715
|
+
// "connected" but dead until manually restarted (sleep/wake, NAT silent
|
|
716
|
+
// drop, hung TUN dataplane — the TCP close never fires). Tightened from
|
|
717
|
+
// 70s→45s (heartbeat 30s→20s) so a half-dead link recovers within ~45s
|
|
718
|
+
// instead of leaving the phone red/un-sendable for over a minute. The
|
|
719
|
+
// cloud also runs its own ws-level ping/pong now, so both ends detect a
|
|
720
|
+
// dead socket symmetrically.
|
|
721
|
+
if (Date.now() - lastHeartbeatAck > 45000) {
|
|
722
|
+
log("heartbeat ack timeout — terminating dead connection to reconnect");
|
|
723
|
+
try {
|
|
724
|
+
ws?.terminate();
|
|
725
|
+
}
|
|
726
|
+
catch {
|
|
727
|
+
/* close handler will reconnect */
|
|
728
|
+
}
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
safeSend({ type: "heartbeat" });
|
|
732
|
+
}, 20000);
|
|
733
|
+
// Start the phone-controllable local terminal shell once (the `run` command).
|
|
734
|
+
if (opts.localShell && !shellStarted) {
|
|
735
|
+
shellStarted = true;
|
|
736
|
+
const shell = startLocalShell({
|
|
737
|
+
attachMgr,
|
|
738
|
+
sessionId: opts.localShell.sessionId,
|
|
739
|
+
cwd: opts.localShell.cwd,
|
|
740
|
+
write: (s) => process.stdout.write(s),
|
|
741
|
+
});
|
|
742
|
+
rl = readline.createInterface({ input: process.stdin });
|
|
743
|
+
rl.on("line", (l) => shell.onLine(l));
|
|
744
|
+
log(`local shell attached (session=${opts.localShell.sessionId})`);
|
|
745
|
+
// Refresh the snapshot so the new controllable session is visible immediately.
|
|
746
|
+
pushSnapshot(true);
|
|
747
|
+
}
|
|
748
|
+
// Start the pty terminal mirror once (the `mirror` command).
|
|
749
|
+
if (opts.mirror && !mirrorStarted) {
|
|
750
|
+
mirrorStarted = true;
|
|
751
|
+
// Dynamic import keeps the native node-pty dep out of non-mirror paths.
|
|
752
|
+
const { realSpawnPty } = await import("./pty-spawn.js");
|
|
753
|
+
const { createMirror } = await import("./pty-mirror.js");
|
|
754
|
+
const cwd = opts.mirror.cwd;
|
|
755
|
+
const termId = `${process.pid}-${cwd.replace(/[^a-zA-Z0-9]/g, "-")}`;
|
|
756
|
+
mirrorTermId = termId;
|
|
757
|
+
const cols = process.stdout.columns || 80;
|
|
758
|
+
const rows = process.stdout.rows || 24;
|
|
759
|
+
sendFrame({
|
|
760
|
+
type: "cc_term_hello",
|
|
761
|
+
termId,
|
|
762
|
+
cwd,
|
|
763
|
+
title: opts.mirror.command.split("/").pop() ?? "claude",
|
|
764
|
+
cols,
|
|
765
|
+
rows,
|
|
766
|
+
});
|
|
767
|
+
mirrorRef = createMirror({
|
|
768
|
+
spawnPty: realSpawnPty,
|
|
769
|
+
command: opts.mirror.command,
|
|
770
|
+
args: opts.mirror.args,
|
|
771
|
+
cwd,
|
|
772
|
+
cols,
|
|
773
|
+
rows,
|
|
774
|
+
onLocal: (d) => process.stdout.write(d),
|
|
775
|
+
onOutput: (d) => {
|
|
776
|
+
const data = Buffer.from(d, "utf8").toString("base64");
|
|
777
|
+
promptDetector.noteOutput(termId, data);
|
|
778
|
+
safeSend({ type: "cc_term_output", termId, data });
|
|
779
|
+
},
|
|
780
|
+
onExit: () => {
|
|
781
|
+
promptDetector.noteTermBye(termId);
|
|
782
|
+
safeSend({ type: "cc_term_bye", termId });
|
|
783
|
+
process.exit(0);
|
|
784
|
+
},
|
|
785
|
+
});
|
|
786
|
+
if (process.stdin.isTTY)
|
|
787
|
+
process.stdin.setRawMode(true);
|
|
788
|
+
process.stdin.on("data", (b) => mirrorRef?.writeInput(b.toString("utf8")));
|
|
789
|
+
process.stdout.on("resize", () => mirrorRef?.resize(process.stdout.columns || 80, process.stdout.rows || 24));
|
|
790
|
+
log(`mirror attached (term=${termId})`);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
ws.on("message", (data) => {
|
|
794
|
+
let msg;
|
|
795
|
+
try {
|
|
796
|
+
msg = JSON.parse(data.toString());
|
|
797
|
+
}
|
|
798
|
+
catch {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
// Heartbeat liveness: the cloud acks every heartbeat. Recorded here (connect
|
|
802
|
+
// scope) so the zombie-connection watchdog in hbTimer can see it.
|
|
803
|
+
if (msg.type === "heartbeat_ack") {
|
|
804
|
+
lastHeartbeatAck = Date.now();
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
// §4.3 TERMINAL: a binding-revoked frame stops reconnect + clears config.
|
|
808
|
+
// The cloud follows it with a 4012 close (handled in ws.on("close")).
|
|
809
|
+
if (msg.type === revokedFrameType) {
|
|
810
|
+
handleTerminalRevoke();
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
// Cloud push-update signal: this bridge reported an old version on hello.
|
|
814
|
+
// Trigger a one-shot self-update + relaunch now (safe — nothing attached
|
|
815
|
+
// at connect time) so a published release lands on the next reconnect.
|
|
816
|
+
if (msg.type === updateFrameType) {
|
|
817
|
+
try {
|
|
818
|
+
opts.onUpdateSignal?.();
|
|
819
|
+
}
|
|
820
|
+
catch {
|
|
821
|
+
/* self-update is best-effort */
|
|
822
|
+
}
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
// Hub daemon: route terminal down-frames to the right shell.
|
|
826
|
+
if (hub &&
|
|
827
|
+
(msg.type === "cc_term_input" ||
|
|
828
|
+
msg.type === "cc_term_resize" ||
|
|
829
|
+
msg.type === "cc_term_refresh")) {
|
|
830
|
+
hub.handleCloud(msg);
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
// Single-mirror process: route down-frames to its pty.
|
|
834
|
+
if (msg.type === "cc_term_input" && msg.termId === mirrorTermId) {
|
|
835
|
+
mirrorRef?.writeInput(msg.data);
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
if (msg.type === "cc_term_resize" && msg.termId === mirrorTermId) {
|
|
839
|
+
mirrorRef?.resize(msg.cols, msg.rows);
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
if (msg.type === "cc_term_refresh" && msg.termId === mirrorTermId) {
|
|
843
|
+
mirrorRef?.refresh();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (msg.type === "cc_term_prompt_answer") {
|
|
847
|
+
const termId = String(msg.termId ?? "");
|
|
848
|
+
const seq = promptDetector.answer(termId, String(msg.promptId ?? ""), Number(msg.optionIndex));
|
|
849
|
+
if (seq) {
|
|
850
|
+
for (const key of seq)
|
|
851
|
+
injectTermInput(termId, key);
|
|
852
|
+
}
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
// Single inbound seam: decrypt content fields of cc_send/cc_answer when
|
|
856
|
+
// the field IS an envelope and we hold the CEK. Passthrough for all other
|
|
857
|
+
// frame types and when E2E is off — safe for unencrypted phone clients.
|
|
858
|
+
msg = codec.decryptInbound(msg);
|
|
859
|
+
handler(msg);
|
|
860
|
+
attachMgr.handle(msg);
|
|
861
|
+
});
|
|
862
|
+
ws.on("close", (code) => {
|
|
863
|
+
// §4.3: 4012 binding_revoked is TERMINAL — no reconnect, clear config.
|
|
864
|
+
// (4003 invalid-token and transient/network closes still retry below.)
|
|
865
|
+
if (code === 4012)
|
|
866
|
+
handleTerminalRevoke();
|
|
867
|
+
cleanup();
|
|
868
|
+
if (stopped)
|
|
869
|
+
return;
|
|
870
|
+
// Escalate to the next dial target (next endpoint, then raw-IP fallbacks)
|
|
871
|
+
// so a persistent DNS/TLS failure on the primary host rotates onto a path
|
|
872
|
+
// that works. Reset to 0 happens on the next successful open.
|
|
873
|
+
dialAttempt++;
|
|
874
|
+
log(`disconnected — retrying in ${backoff}ms`);
|
|
875
|
+
setTimeout(connect, backoff);
|
|
876
|
+
// Cap at 10s (was 30s): after a transient outage the bridge should be back
|
|
877
|
+
// within ~10s of the network recovering, not up to 30s.
|
|
878
|
+
backoff = Math.min(backoff * 2, 10000);
|
|
879
|
+
});
|
|
880
|
+
ws.on("error", (e) => log(`ws error: ${e.message}`));
|
|
881
|
+
}
|
|
882
|
+
function cleanup() {
|
|
883
|
+
if (snapTimer)
|
|
884
|
+
clearInterval(snapTimer);
|
|
885
|
+
if (hbTimer)
|
|
886
|
+
clearInterval(hbTimer);
|
|
887
|
+
snapTimer = hbTimer = null;
|
|
888
|
+
// On a transient disconnect, kill only remote-attached children; the local
|
|
889
|
+
// shell (if any) survives so the user's terminal session isn't dropped.
|
|
890
|
+
attachMgr.stopRemote();
|
|
891
|
+
// Watch followers SURVIVE a transient disconnect (their emits are dropped
|
|
892
|
+
// while the link is down) — the cloud re-sends cc_watch_start on reconnect
|
|
893
|
+
// and the phone's watch_ok{entryCount} heartbeat reconciles any gap.
|
|
894
|
+
}
|
|
895
|
+
connect();
|
|
896
|
+
return {
|
|
897
|
+
stop: () => {
|
|
898
|
+
stopped = true;
|
|
899
|
+
// Cleanly deregister the mirror so it doesn't linger as a ghost terminal
|
|
900
|
+
// (covers Ctrl-C / SIGTERM exit, not just the claude child exiting).
|
|
901
|
+
if (mirrorTermId) {
|
|
902
|
+
try {
|
|
903
|
+
sendFrame({ type: "cc_term_bye", termId: mirrorTermId });
|
|
904
|
+
}
|
|
905
|
+
catch {
|
|
906
|
+
/* ignore */
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
mirrorRef?.stop();
|
|
910
|
+
cleanup();
|
|
911
|
+
attachMgr.stopAll(); // full teardown incl. the local shell on real exit
|
|
912
|
+
watchMgr.stopAll();
|
|
913
|
+
turnProbe.stop();
|
|
914
|
+
promptDetector.stop();
|
|
915
|
+
codexThreadSource?.stop();
|
|
916
|
+
if (fsDebounce)
|
|
917
|
+
clearTimeout(fsDebounce);
|
|
918
|
+
if (codexStateDebounce)
|
|
919
|
+
clearTimeout(codexStateDebounce);
|
|
920
|
+
fsWatcher?.close();
|
|
921
|
+
fsWatcher = null;
|
|
922
|
+
codexStateWatcher?.close();
|
|
923
|
+
codexStateWatcher = null;
|
|
924
|
+
hubServer?.close();
|
|
925
|
+
hubServer = null;
|
|
926
|
+
rl?.close();
|
|
927
|
+
rl = null;
|
|
928
|
+
ws?.close();
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
}
|