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.
Files changed (50) hide show
  1. package/README.md +252 -0
  2. package/dist/attach-manager.js +259 -0
  3. package/dist/bridge.js +931 -0
  4. package/dist/cli-args.js +14 -0
  5. package/dist/cli.js +742 -0
  6. package/dist/codex-prompts.js +148 -0
  7. package/dist/codex-thread-source.js +495 -0
  8. package/dist/codex-transcript.js +415 -0
  9. package/dist/dev-server.js +126 -0
  10. package/dist/discovery.js +111 -0
  11. package/dist/e2e/codec.js +119 -0
  12. package/dist/e2e/crypto.js +127 -0
  13. package/dist/e2e/key-store.js +48 -0
  14. package/dist/e2e/keychain-identity.js +29 -0
  15. package/dist/engine/adapter.js +5 -0
  16. package/dist/engine/claude-adapter.js +77 -0
  17. package/dist/engine/codex-adapter.js +593 -0
  18. package/dist/file-preview.js +292 -0
  19. package/dist/hub-protocol.js +28 -0
  20. package/dist/hub-server.js +106 -0
  21. package/dist/hub.js +84 -0
  22. package/dist/install-util.js +33 -0
  23. package/dist/local-shell.js +32 -0
  24. package/dist/mcp-config.js +230 -0
  25. package/dist/mcp-device-proxy.js +501 -0
  26. package/dist/media-hydrator.js +222 -0
  27. package/dist/message-counter.js +79 -0
  28. package/dist/phone-probe.js +55 -0
  29. package/dist/prompt-detector.js +213 -0
  30. package/dist/protocol.js +3 -0
  31. package/dist/pty-mirror.js +80 -0
  32. package/dist/pty-spawn.js +53 -0
  33. package/dist/scanner.js +422 -0
  34. package/dist/self-update.js +122 -0
  35. package/dist/session-map.js +15 -0
  36. package/dist/session-runner.js +131 -0
  37. package/dist/shell.js +104 -0
  38. package/dist/skills-scanner.js +167 -0
  39. package/dist/stdin-encode.js +32 -0
  40. package/dist/stream-translate.js +122 -0
  41. package/dist/terminal-render.js +29 -0
  42. package/dist/transcript-watcher.js +138 -0
  43. package/dist/transcript.js +346 -0
  44. package/dist/turn-probe.js +152 -0
  45. package/dist/types.js +2 -0
  46. package/dist/watch-manager.js +77 -0
  47. package/install-cc.sh +90 -0
  48. package/install-codex.sh +97 -0
  49. package/package.json +39 -0
  50. 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
+ }