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/cli.js ADDED
@@ -0,0 +1,742 @@
1
+ #!/usr/bin/env node
2
+ // nexting-cc-bridge — entry point.
3
+ // nexting-cc-bridge start run the bridge (reads config from ~/.nexting/cc-bridge.json or env)
4
+ // nexting-cc-bridge once print one discovery snapshot as JSON and exit (debug)
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import net from "node:net";
9
+ import { fileURLToPath } from "node:url";
10
+ import { spawn, spawnSync } from "node:child_process";
11
+ import { randomUUID, randomBytes } from "node:crypto";
12
+ import http from "node:http";
13
+ import https from "node:https";
14
+ import { startBridge, VERSION } from "./bridge.js";
15
+ import { startSelfUpdate, maybeSelfUpdate } from "./self-update.js";
16
+ import { discoverSessions } from "./scanner.js";
17
+ import { addPinclawPathBlock, stripPinclawBlock } from "./install-util.js";
18
+ import { sessionIdFromArgs } from "./cli-args.js";
19
+ const CONFIG = path.join(os.homedir(), ".nexting", "cc-bridge.json");
20
+ // §2.6 bridge-token-authed logout endpoints (revoke server-side on terminal teardown).
21
+ const CC_LOGOUT_URL = "https://api.nexting.ai/api/v1/cc-bridge/logout";
22
+ const CODEX_LOGOUT_URL = "https://api.nexting.ai/api/v1/codex-bridge/logout";
23
+ const HUB_SOCK = path.join(os.homedir(), ".nexting", "cc-hub.sock");
24
+ const SHIM_DIR = path.join(os.homedir(), ".nexting", "bin");
25
+ const SHIM = path.join(SHIM_DIR, "claude");
26
+ const PLIST = path.join(os.homedir(), "Library", "LaunchAgents", "ai.nexting.cc-bridge.plist");
27
+ const RC_FILES = [".zshrc", ".bashrc", ".bash_profile"];
28
+ /** Write the launchd plist that keeps the hub daemon running, and (re)load it. */
29
+ function setupLaunchdHub() {
30
+ const node = process.execPath;
31
+ const cli = fileURLToPath(import.meta.url);
32
+ const nodeDir = path.dirname(node);
33
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
34
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
35
+ <plist version="1.0">
36
+ <dict>
37
+ <key>Label</key><string>ai.nexting.cc-bridge</string>
38
+ <key>ProgramArguments</key><array><string>${node}</string><string>${cli}</string><string>hub</string></array>
39
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${nodeDir}:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
40
+ <key>RunAtLoad</key><true/>
41
+ <key>KeepAlive</key><true/>
42
+ <key>StandardOutPath</key><string>${path.join(os.homedir(), ".nexting", "cc-bridge.log")}</string>
43
+ <key>StandardErrorPath</key><string>${path.join(os.homedir(), ".nexting", "cc-bridge.err.log")}</string>
44
+ </dict>
45
+ </plist>
46
+ `;
47
+ fs.mkdirSync(path.dirname(PLIST), { recursive: true });
48
+ fs.writeFileSync(PLIST, plist);
49
+ spawnSync("launchctl", ["unload", PLIST], { stdio: "ignore" });
50
+ spawnSync("launchctl", ["load", PLIST], { stdio: "ignore" });
51
+ }
52
+ /** Connect to the hub socket; if no hub is running, spawn one (detached) and wait
53
+ * for it to come up. Returns when the socket is connectable. */
54
+ async function ensureHubRunning() {
55
+ const tryConnect = () => new Promise((resolve) => {
56
+ const s = net.connect(HUB_SOCK);
57
+ s.on("connect", () => {
58
+ s.end();
59
+ resolve(true);
60
+ });
61
+ s.on("error", () => resolve(false));
62
+ });
63
+ if (await tryConnect())
64
+ return;
65
+ // Spawn the hub daemon detached so it outlives this shell.
66
+ const self = fileURLToPath(import.meta.url);
67
+ const child = spawn(process.execPath, [self, "hub"], {
68
+ detached: true,
69
+ stdio: "ignore",
70
+ });
71
+ child.unref();
72
+ for (let i = 0; i < 50; i++) {
73
+ await new Promise((r) => setTimeout(r, 200));
74
+ if (await tryConnect())
75
+ return;
76
+ }
77
+ throw new Error("hub did not start in time");
78
+ }
79
+ /** Lazily generate a stable 32-hex bridgeId in `cfg` (mutated in place) and
80
+ * persist it back to `file` if it was absent. The bridgeId is always file-backed
81
+ * (env-var token overrides do not affect it). Returns the resolved bridgeId. */
82
+ function ensureBridgeId(cfg, file) {
83
+ if (typeof cfg.bridgeId === "string" && cfg.bridgeId)
84
+ return cfg.bridgeId;
85
+ const bridgeId = randomBytes(16).toString("hex");
86
+ cfg.bridgeId = bridgeId;
87
+ try {
88
+ fs.mkdirSync(path.dirname(file), { recursive: true });
89
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
90
+ }
91
+ catch {
92
+ /* best-effort persist; the id is still returned for this run */
93
+ }
94
+ return bridgeId;
95
+ }
96
+ // Last-resort pinned IPs for the primary control-plane host, dialed with SNI+Host
97
+ // when DNS fails (the dominant China outage: flaky residential `getaddrinfo
98
+ // ENOTFOUND`). The bridge prefers the live cached IP first, then these.
99
+ const PINNED_IPS = ["157.230.249.229"]; // api.nexting.ai → DigitalOcean
100
+ /** Build the China-resilience knobs for `startBridge` from a config file:
101
+ * an optional preferred-endpoint list (e.g. a Cloudflare-proxied
102
+ * `bridge.nexting.ai`), raw-IP fallbacks (cached-good first, then pinned), and a
103
+ * persist-on-connect callback that caches the last-known-good peer IP so the
104
+ * next cold start survives a DNS outage. */
105
+ function buildNet(file, cfg, url) {
106
+ // Explicit config wins. Otherwise, for the default `api.nexting.ai` ingress,
107
+ // PREFER the Cloudflare-proxied `bridge.nexting.ai` (China→CF-edge→origin is
108
+ // far more stable than China→US-direct) and fall back to the direct host. CF
109
+ // is strictly additive here: if it ever fails, the dial escalates to the
110
+ // direct endpoint, then the raw-IP fallbacks. Ships to every user via
111
+ // self-update; non-default custom URLs are left untouched.
112
+ let endpoints = Array.isArray(cfg.endpoints)
113
+ ? cfg.endpoints.filter((e) => typeof e === "string" && e)
114
+ : undefined;
115
+ if ((!endpoints || !endpoints.length) && url.includes("api.nexting.ai")) {
116
+ endpoints = [url.replace("api.nexting.ai", "bridge.nexting.ai"), url];
117
+ }
118
+ const fallbackIps = Array.from(new Set([
119
+ typeof cfg.cachedIp === "string" ? cfg.cachedIp : "",
120
+ ...PINNED_IPS,
121
+ ].filter(Boolean)));
122
+ let lastPersisted = typeof cfg.cachedIp === "string" ? cfg.cachedIp : "";
123
+ const engineEnv = cfg.engineEnv && typeof cfg.engineEnv === "object"
124
+ ? Object.fromEntries(Object.entries(cfg.engineEnv).filter(([, v]) => typeof v === "string"))
125
+ : undefined;
126
+ return {
127
+ endpoints: endpoints && endpoints.length ? endpoints : undefined,
128
+ fallbackIps,
129
+ engineEnv: engineEnv && Object.keys(engineEnv).length ? engineEnv : undefined,
130
+ onConnectedIp: (ip) => {
131
+ if (!ip || ip === lastPersisted)
132
+ return;
133
+ lastPersisted = ip;
134
+ try {
135
+ let j = {};
136
+ try {
137
+ j = JSON.parse(fs.readFileSync(file, "utf8"));
138
+ }
139
+ catch {
140
+ /* new/absent config */
141
+ }
142
+ j.cachedIp = ip;
143
+ fs.mkdirSync(path.dirname(file), { recursive: true });
144
+ fs.writeFileSync(file, JSON.stringify(j, null, 2));
145
+ }
146
+ catch {
147
+ /* best-effort cache */
148
+ }
149
+ },
150
+ };
151
+ }
152
+ function loadConfig() {
153
+ let cfg = {};
154
+ try {
155
+ cfg = JSON.parse(fs.readFileSync(CONFIG, "utf8"));
156
+ }
157
+ catch {
158
+ /* fall back to env */
159
+ }
160
+ const url = process.env.NEXTING_CC_URL ??
161
+ cfg.url ??
162
+ "wss://api.nexting.ai/cc-bridge/connect";
163
+ const token = process.env.NEXTING_CC_TOKEN ?? cfg.token ?? "";
164
+ const bridgeId = ensureBridgeId(cfg, CONFIG);
165
+ return {
166
+ url,
167
+ token,
168
+ bridgeId,
169
+ net: buildNet(CONFIG, cfg, url),
170
+ e2eEnabled: cfg.e2eEnabled === true,
171
+ };
172
+ }
173
+ const CODEX_CONFIG = path.join(os.homedir(), ".nexting", "codex-bridge.json");
174
+ const CODEX_PLIST = path.join(os.homedir(), "Library", "LaunchAgents", "ai.nexting.codex-bridge.plist");
175
+ /** Write the launchd plist that keeps the codex-bridge daemon running, and (re)load it. */
176
+ function setupLaunchdCodex() {
177
+ const node = process.execPath;
178
+ const cli = fileURLToPath(import.meta.url);
179
+ const nodeDir = path.dirname(node);
180
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
181
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
182
+ <plist version="1.0">
183
+ <dict>
184
+ <key>Label</key><string>ai.nexting.codex-bridge</string>
185
+ <key>ProgramArguments</key><array><string>${node}</string><string>${cli}</string><string>codex-start</string></array>
186
+ <key>EnvironmentVariables</key><dict><key>PATH</key><string>${nodeDir}:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
187
+ <key>RunAtLoad</key><true/>
188
+ <key>KeepAlive</key><true/>
189
+ <key>StandardOutPath</key><string>${path.join(os.homedir(), ".nexting", "codex-bridge.log")}</string>
190
+ <key>StandardErrorPath</key><string>${path.join(os.homedir(), ".nexting", "codex-bridge.err.log")}</string>
191
+ </dict>
192
+ </plist>
193
+ `;
194
+ fs.mkdirSync(path.dirname(CODEX_PLIST), { recursive: true });
195
+ fs.writeFileSync(CODEX_PLIST, plist);
196
+ spawnSync("launchctl", ["unload", CODEX_PLIST], { stdio: "ignore" });
197
+ spawnSync("launchctl", ["load", CODEX_PLIST], { stdio: "ignore" });
198
+ }
199
+ function loadCodexConfig() {
200
+ let cfg = {};
201
+ try {
202
+ cfg = JSON.parse(fs.readFileSync(CODEX_CONFIG, "utf8"));
203
+ }
204
+ catch {
205
+ /* env fallback */
206
+ }
207
+ const url = process.env.NEXTING_CODEX_URL ??
208
+ cfg.url ??
209
+ "wss://api.nexting.ai/codex-bridge/connect";
210
+ const token = process.env.NEXTING_CODEX_TOKEN ?? cfg.token ?? "";
211
+ const bridgeId = ensureBridgeId(cfg, CODEX_CONFIG);
212
+ return {
213
+ url,
214
+ token,
215
+ bridgeId,
216
+ net: buildNet(CODEX_CONFIG, cfg, url),
217
+ e2eEnabled: cfg.e2eEnabled === true,
218
+ };
219
+ }
220
+ /** Remove `token` + `bridgeId` from the bridge config JSON (keep `url`). Used by
221
+ * logout/uninstall so the next launch does not auto-reconnect. Best-effort. */
222
+ function clearTokenAndBridgeId(file) {
223
+ let cfg = {};
224
+ try {
225
+ cfg = JSON.parse(fs.readFileSync(file, "utf8"));
226
+ }
227
+ catch {
228
+ return; // no config file → nothing to clear
229
+ }
230
+ delete cfg.token;
231
+ delete cfg.bridgeId;
232
+ try {
233
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
234
+ }
235
+ catch {
236
+ /* best-effort */
237
+ }
238
+ }
239
+ /** Best-effort POST to a §2.6 bridge-authed logout endpoint with a short timeout.
240
+ * Network/HTTP failure is swallowed — it MUST NOT block local teardown. */
241
+ async function bestEffortLogout(endpoint, token, bridgeId) {
242
+ if (!token || !bridgeId)
243
+ return;
244
+ await new Promise((resolve) => {
245
+ let done = false;
246
+ const finish = () => {
247
+ if (done)
248
+ return;
249
+ done = true;
250
+ resolve();
251
+ };
252
+ try {
253
+ const body = JSON.stringify({ token, bridgeId });
254
+ const u = new URL(endpoint);
255
+ const lib = u.protocol === "http:" ? http : https;
256
+ const req = lib.request({
257
+ method: "POST",
258
+ hostname: u.hostname,
259
+ port: u.port || undefined,
260
+ path: u.pathname + u.search,
261
+ headers: {
262
+ "content-type": "application/json",
263
+ "content-length": Buffer.byteLength(body),
264
+ },
265
+ timeout: 4000,
266
+ }, (res) => {
267
+ res.on("data", () => { });
268
+ res.on("end", finish);
269
+ });
270
+ req.on("error", finish);
271
+ req.on("timeout", () => {
272
+ req.destroy();
273
+ finish();
274
+ });
275
+ req.write(body);
276
+ req.end();
277
+ }
278
+ catch {
279
+ finish();
280
+ }
281
+ });
282
+ }
283
+ async function main() {
284
+ const cmd = process.argv[2] ?? "start";
285
+ if (cmd === "once") {
286
+ const sessions = await discoverSessions();
287
+ console.log(JSON.stringify(sessions, null, 2));
288
+ return;
289
+ }
290
+ // Mirror the user's terminal Claude egress into the bridge's engineEnv so a
291
+ // phone-spawned engine exits from the SAME residential IP / timezone / locale
292
+ // as their terminal. Critical for region-locked users (e.g. CN): a mismatched
293
+ // exit IP on the same account is an account-ban risk. Re-run after rotating
294
+ // the proxy. PARSES the shell rc statically (never sources it — sourcing an
295
+ // interactive rc with a prompt framework / network checks can hang): reads a
296
+ // `CLAUDE_PROXY="..."` line, or assembles it from
297
+ // `CLAUDE_PROXY_{USER,PASS,HOST,PORT}`.
298
+ if (cmd === "sync-proxy") {
299
+ const readRc = () => {
300
+ for (const f of [".zshrc", ".bashrc", ".zprofile", ".bash_profile"]) {
301
+ try {
302
+ return fs.readFileSync(path.join(os.homedir(), f), "utf8");
303
+ }
304
+ catch {
305
+ /* try next */
306
+ }
307
+ }
308
+ return "";
309
+ };
310
+ const rc = readRc();
311
+ const grab = (name) => rc.match(new RegExp(`${name}=["']?([^"'\\n]+)`))?.[1]?.trim() ?? "";
312
+ let proxy = grab("CLAUDE_PROXY");
313
+ if (!proxy || proxy.includes("$")) {
314
+ // Assemble from components (CLAUDE_PROXY is often `...${USER}:${PASS}@...`).
315
+ const u = grab("CLAUDE_PROXY_USER"), pw = grab("CLAUDE_PROXY_PASS"), host = grab("CLAUDE_PROXY_HOST"), port = grab("CLAUDE_PROXY_PORT");
316
+ proxy =
317
+ u && pw && host && port ? `http://${u}:${pw}@${host}:${port}` : "";
318
+ }
319
+ if (!proxy) {
320
+ console.error("No CLAUDE_PROXY (or CLAUDE_PROXY_{USER,PASS,HOST,PORT}) found in your shell rc. Set it (the proxy your terminal `claude` uses) and re-run, or edit engineEnv in ~/.nexting/{cc,codex}-bridge.json by hand.");
321
+ process.exit(1);
322
+ }
323
+ const engineEnv = {
324
+ https_proxy: proxy,
325
+ http_proxy: proxy,
326
+ all_proxy: proxy,
327
+ no_proxy: "localhost,127.0.0.1,::1",
328
+ TZ: grab("CLAUDE_TZ") || "America/New_York",
329
+ LANG: grab("CLAUDE_LANG") || "en_US.UTF-8",
330
+ LC_ALL: grab("CLAUDE_LANG") || "en_US.UTF-8",
331
+ };
332
+ for (const file of [CONFIG, CODEX_CONFIG]) {
333
+ let cfg = {};
334
+ try {
335
+ cfg = JSON.parse(fs.readFileSync(file, "utf8"));
336
+ }
337
+ catch {
338
+ /* create */
339
+ }
340
+ cfg.engineEnv = engineEnv;
341
+ try {
342
+ fs.mkdirSync(path.dirname(file), { recursive: true });
343
+ fs.writeFileSync(file, JSON.stringify(cfg, null, 2));
344
+ }
345
+ catch {
346
+ /* best-effort */
347
+ }
348
+ }
349
+ const host = proxy.replace(/^[a-z]+:\/\/[^@/]*@/i, "").replace(/\/.*$/, "");
350
+ console.log(`✓ engineEnv synced to both bridges (proxy host ${host}, TZ ${engineEnv.TZ}). Restart: launchctl kickstart -k gui/$(id -u)/ai.nexting.cc-bridge`);
351
+ return;
352
+ }
353
+ if (cmd === "codex-once") {
354
+ const { discoverCodexSessions } = await import("./scanner.js");
355
+ console.log(JSON.stringify(await discoverCodexSessions(), null, 2));
356
+ return;
357
+ }
358
+ if (cmd === "codex-run") {
359
+ const { url, token, bridgeId, net, e2eEnabled } = loadCodexConfig();
360
+ if (!token) {
361
+ console.error("No token. Run the Codex installer (curl -fsSL https://nexting.ai/install-codex | bash) or set NEXTING_CODEX_TOKEN.");
362
+ process.exit(1);
363
+ }
364
+ const resumeIdx = process.argv.indexOf("--resume");
365
+ const sessionId = resumeIdx >= 0 ? (process.argv[resumeIdx + 1] ?? "new") : "new";
366
+ const cwd = process.cwd();
367
+ console.log(`[codex-bridge] live shell → ${url} (session=${sessionId}, cwd=${cwd})`);
368
+ console.log("输入文字回车发送给 Codex;手机可同时接入这个会话。Ctrl-C 退出。\n");
369
+ const handle = startBridge({
370
+ url,
371
+ token,
372
+ bridgeId,
373
+ ...net,
374
+ engine: "codex",
375
+ localShell: { sessionId, cwd },
376
+ onTerminalRevoke: () => clearTokenAndBridgeId(CODEX_CONFIG),
377
+ e2eEnabled,
378
+ });
379
+ const quit = () => {
380
+ handle.stop();
381
+ process.exit(0);
382
+ };
383
+ process.on("SIGINT", quit);
384
+ process.on("SIGTERM", quit);
385
+ return;
386
+ }
387
+ if (cmd === "codex-start") {
388
+ // Background daemon that launchd keeps alive. Connects to the cloud and
389
+ // pushes Codex session snapshots so they appear on the phone.
390
+ const { url, token, bridgeId, net, e2eEnabled } = loadCodexConfig();
391
+ if (!token) {
392
+ console.error("No token. Run the Codex installer (curl -fsSL https://nexting.ai/install-codex | bash) or set NEXTING_CODEX_TOKEN.");
393
+ process.exit(1);
394
+ }
395
+ console.log(`[codex-bridge] starting → ${url}`);
396
+ const codexUpdateDeps = {
397
+ currentVersion: VERSION,
398
+ label: "ai.nexting.codex-bridge",
399
+ log: (m) => console.log(`[codex-bridge] ${m}`),
400
+ };
401
+ const handle = startBridge({
402
+ url,
403
+ token,
404
+ bridgeId,
405
+ ...net,
406
+ engine: "codex",
407
+ onTerminalRevoke: () => clearTokenAndBridgeId(CODEX_CONFIG),
408
+ e2eEnabled,
409
+ onUpdateSignal: () => {
410
+ if ((process.env.NEXTING_NO_SELF_UPDATE ?? "").trim() === "1")
411
+ return;
412
+ void maybeSelfUpdate(codexUpdateDeps, { relaunch: true });
413
+ },
414
+ });
415
+ // Same self-update loop as the cc hub, but kicks the codex daemon label.
416
+ const selfUpdate = startSelfUpdate(codexUpdateDeps);
417
+ const quit = () => {
418
+ selfUpdate.stop();
419
+ handle.stop();
420
+ process.exit(0);
421
+ };
422
+ process.on("SIGINT", quit);
423
+ process.on("SIGTERM", quit);
424
+ return;
425
+ }
426
+ if (cmd === "codex-install") {
427
+ // Codex has no shim; just keep a background daemon alive that connects to
428
+ // the cloud and pushes your Codex session snapshots (phone-visible + attachable).
429
+ setupLaunchdCodex();
430
+ console.log("✓ Nexting Codex connected. Your Codex sessions now appear on your phone.\n Live co-control a folder: nexting-cc-bridge codex-run · Remove: nexting-cc-bridge codex-uninstall");
431
+ return;
432
+ }
433
+ if (cmd === "codex-logout") {
434
+ // §6.2: best-effort server-side revoke, THEN clear config, THEN unload daemon.
435
+ const { token, bridgeId } = loadCodexConfig();
436
+ await bestEffortLogout(CODEX_LOGOUT_URL, token, bridgeId);
437
+ clearTokenAndBridgeId(CODEX_CONFIG);
438
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/ai.nexting.codex-bridge`], { stdio: "ignore" });
439
+ spawnSync("launchctl", ["unload", CODEX_PLIST], { stdio: "ignore" });
440
+ console.log("✓ Logged out. This Mac will no longer connect. Re-run the installer to reconnect.");
441
+ return;
442
+ }
443
+ if (cmd === "codex-uninstall") {
444
+ // §6.4: best-effort server-side revoke + clear config FIRST.
445
+ const { token, bridgeId } = loadCodexConfig();
446
+ await bestEffortLogout(CODEX_LOGOUT_URL, token, bridgeId);
447
+ clearTokenAndBridgeId(CODEX_CONFIG);
448
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/ai.nexting.codex-bridge`], { stdio: "ignore" });
449
+ spawnSync("launchctl", ["unload", CODEX_PLIST], { stdio: "ignore" });
450
+ try {
451
+ fs.unlinkSync(CODEX_PLIST);
452
+ }
453
+ catch {
454
+ /* gone */
455
+ }
456
+ console.log("✓ Nexting Codex removed. This Mac is disconnected from your account.");
457
+ return;
458
+ }
459
+ if (cmd === "start") {
460
+ const { url, token, bridgeId, net, e2eEnabled } = loadConfig();
461
+ if (!token) {
462
+ console.error("No token. Run the installer (curl -fsSL https://nexting.ai/install-cc | bash) or set NEXTING_CC_TOKEN.");
463
+ process.exit(1);
464
+ }
465
+ console.log(`[cc-bridge] starting → ${url}`);
466
+ const handle = startBridge({
467
+ url,
468
+ token,
469
+ bridgeId,
470
+ ...net,
471
+ onTerminalRevoke: () => clearTokenAndBridgeId(CONFIG),
472
+ e2eEnabled,
473
+ });
474
+ process.on("SIGINT", () => {
475
+ handle.stop();
476
+ process.exit(0);
477
+ });
478
+ process.on("SIGTERM", () => {
479
+ handle.stop();
480
+ process.exit(0);
481
+ });
482
+ return;
483
+ }
484
+ if (cmd === "run") {
485
+ const { url, token, bridgeId, net, e2eEnabled } = loadConfig();
486
+ if (!token) {
487
+ console.error("No token. Run the installer (curl -fsSL https://nexting.ai/install-cc | bash) or set NEXTING_CC_TOKEN.");
488
+ process.exit(1);
489
+ }
490
+ // nexting-cc-bridge run → 新会话(手机可接入)
491
+ // nexting-cc-bridge run --resume X → 续接已有(未在终端开着的)会话
492
+ const resumeIdx = process.argv.indexOf("--resume");
493
+ const sessionId = resumeIdx >= 0 ? (process.argv[resumeIdx + 1] ?? "new") : "new";
494
+ const cwd = process.cwd();
495
+ console.log(`[cc-bridge] live shell → ${url} (session=${sessionId}, cwd=${cwd})`);
496
+ console.log("输入文字回车发送给 Claude Code;手机可同时接入这个会话。Ctrl-C 退出。\n");
497
+ const handle = startBridge({
498
+ url,
499
+ token,
500
+ bridgeId,
501
+ ...net,
502
+ localShell: { sessionId, cwd },
503
+ onTerminalRevoke: () => clearTokenAndBridgeId(CONFIG),
504
+ e2eEnabled,
505
+ });
506
+ const quit = () => {
507
+ handle.stop();
508
+ process.exit(0);
509
+ };
510
+ process.on("SIGINT", quit);
511
+ process.on("SIGTERM", quit);
512
+ return;
513
+ }
514
+ if (cmd === "mirror") {
515
+ const { url, token, bridgeId, net, e2eEnabled } = loadConfig();
516
+ if (!token) {
517
+ console.error("No token. Run the installer (curl -fsSL https://nexting.ai/install-cc | bash) or set NEXTING_CC_TOKEN.");
518
+ process.exit(1);
519
+ }
520
+ // nexting-cc-bridge mirror [-- <claude args>]
521
+ // Runs claude in a pty (native TUI in your terminal) AND mirrors it to the phone.
522
+ const sep = process.argv.indexOf("--");
523
+ const args = sep >= 0 ? process.argv.slice(sep + 1) : [];
524
+ const { resolveClaudeBin } = await import("./bridge.js");
525
+ const command = resolveClaudeBin();
526
+ const cwd = process.cwd();
527
+ console.log(`[cc-bridge] mirror → ${url} (claude=${command}, cwd=${cwd}) 手机可镜像+键入。Ctrl-C 退出。`);
528
+ const handle = startBridge({
529
+ url,
530
+ token,
531
+ bridgeId,
532
+ ...net,
533
+ mirror: { command, args, cwd },
534
+ onTerminalRevoke: () => clearTokenAndBridgeId(CONFIG),
535
+ e2eEnabled,
536
+ });
537
+ const quit = () => {
538
+ handle.stop();
539
+ process.exit(0);
540
+ };
541
+ process.on("SIGINT", quit);
542
+ process.on("SIGTERM", quit);
543
+ return;
544
+ }
545
+ if (cmd === "hub") {
546
+ const { url, token, bridgeId, net, e2eEnabled } = loadConfig();
547
+ if (!token) {
548
+ console.error("No token. Run the installer (curl -fsSL https://nexting.ai/install-cc | bash) or set NEXTING_CC_TOKEN.");
549
+ process.exit(1);
550
+ }
551
+ console.log(`[cc-bridge] hub → ${url} socket=${HUB_SOCK}`);
552
+ const ccUpdateDeps = {
553
+ currentVersion: VERSION,
554
+ label: "ai.nexting.cc-bridge",
555
+ log: (m) => console.log(`[cc-bridge] ${m}`),
556
+ };
557
+ const handle = startBridge({
558
+ url,
559
+ token,
560
+ bridgeId,
561
+ ...net,
562
+ hub: { socketPath: HUB_SOCK },
563
+ onTerminalRevoke: () => clearTokenAndBridgeId(CONFIG),
564
+ e2eEnabled,
565
+ // Cloud push-update: jump on a published release at connect time instead
566
+ // of waiting for the periodic check. Respects the dev opt-out.
567
+ onUpdateSignal: () => {
568
+ if ((process.env.NEXTING_NO_SELF_UPDATE ?? "").trim() === "1")
569
+ return;
570
+ void maybeSelfUpdate(ccUpdateDeps, { relaunch: true });
571
+ },
572
+ });
573
+ // Keep the installed bridge current (ask_user, binding, push features only
574
+ // take effect on the new version). Startup check may relaunch this daemon.
575
+ const selfUpdate = startSelfUpdate(ccUpdateDeps);
576
+ const quit = () => {
577
+ selfUpdate.stop();
578
+ handle.stop();
579
+ process.exit(0);
580
+ };
581
+ process.on("SIGINT", quit);
582
+ process.on("SIGTERM", quit);
583
+ return;
584
+ }
585
+ if (cmd === "term") {
586
+ // A wrapped `claude` that registers with the local hub (what the `claude`
587
+ // shim calls). Your terminal stays native; the hub multiplexes it to phone.
588
+ const sep = process.argv.indexOf("--");
589
+ let args = sep >= 0 ? process.argv.slice(sep + 1) : [];
590
+ // Resolve the claude session id so the phone can map this live pty to a
591
+ // session (and inject into it). If the user didn't pin one, inject a fresh
592
+ // --session-id so claude uses a known id from the start.
593
+ let sessionId = sessionIdFromArgs(args);
594
+ if (!sessionId) {
595
+ sessionId = randomUUID();
596
+ args = [...args, "--session-id", sessionId];
597
+ }
598
+ const { resolveClaudeBin } = await import("./bridge.js");
599
+ const { startShell } = await import("./shell.js");
600
+ const { realSpawnPty } = await import("./pty-spawn.js");
601
+ const { realHubConnect } = await import("./hub-server.js");
602
+ const { StringDecoder } = await import("node:string_decoder");
603
+ const command = resolveClaudeBin();
604
+ const cwd = process.cwd();
605
+ const termId = `${process.pid}-${cwd.replace(/[^a-zA-Z0-9]/g, "-")}`;
606
+ try {
607
+ await ensureHubRunning();
608
+ }
609
+ catch (e) {
610
+ console.error(`[cc-bridge] ${e.message}; running claude直接`);
611
+ // Fall back to plain claude so the user is never blocked.
612
+ const c = spawn(command, args, { stdio: "inherit" });
613
+ c.on("exit", (code) => process.exit(code ?? 0));
614
+ return;
615
+ }
616
+ const cols = process.stdout.columns || 80;
617
+ const rows = process.stdout.rows || 24;
618
+ if (process.stdin.isTTY)
619
+ process.stdin.setRawMode(true);
620
+ const exitTerm = () => {
621
+ if (process.stdin.isTTY)
622
+ process.stdin.setRawMode(false);
623
+ process.exit(0);
624
+ };
625
+ const shell = startShell({
626
+ spawnPty: realSpawnPty,
627
+ connect: () => realHubConnect(HUB_SOCK),
628
+ termId,
629
+ command,
630
+ args,
631
+ cwd,
632
+ cols,
633
+ rows,
634
+ sessionId,
635
+ onLocal: (d) => process.stdout.write(d),
636
+ onExit: exitTerm,
637
+ });
638
+ // Decode stdin through a StringDecoder so a multi-byte UTF-8 char (a CJK glyph
639
+ // is 3 bytes; IME paste/fast typing emits them) split across two `data` events
640
+ // is buffered and reassembled instead of each half becoming U+FFFD. ASCII /
641
+ // control bytes (escape sequences) are <0x80 so they never buffer — no latency.
642
+ const stdinDecoder = new StringDecoder("utf8");
643
+ process.stdin.on("data", (b) => shell.writeInput(stdinDecoder.write(b)));
644
+ process.stdout.on("resize", () => shell.resize(process.stdout.columns || 80, process.stdout.rows || 24));
645
+ const quit = () => {
646
+ shell.stop();
647
+ exitTerm();
648
+ };
649
+ process.on("SIGINT", quit);
650
+ process.on("SIGTERM", quit);
651
+ return;
652
+ }
653
+ if (cmd === "install") {
654
+ // Install the `claude` shim (→ ~/.nexting/bin/claude, put first on PATH) so a
655
+ // plain `claude` transparently runs through the phone-controllable hub: a
656
+ // terminal session is born controllable and mirrors to your phone. The shim
657
+ // self-resolves the REAL claude and honors NEXTING_CC_DISABLE=1 as an escape
658
+ // hatch; `uninstall` removes it + the PATH block.
659
+ fs.mkdirSync(SHIM_DIR, { recursive: true });
660
+ // Copy the packaged shim (../shim/claude relative to dist/cli.js) into place.
661
+ const shimSrc = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "shim", "claude");
662
+ try {
663
+ fs.copyFileSync(shimSrc, SHIM);
664
+ }
665
+ catch {
666
+ console.error(`✗ shim not found at ${shimSrc}`);
667
+ process.exit(1);
668
+ }
669
+ fs.chmodSync(SHIM, 0o755);
670
+ // Put the shim dir on PATH via the user's shell rc(s). Always ensure zsh
671
+ // (macOS default); touch bash files only if they already exist.
672
+ const zshrc = path.join(os.homedir(), ".zshrc");
673
+ for (const rc of [".zshrc", ".bashrc", ".bash_profile"]) {
674
+ const p = path.join(os.homedir(), rc);
675
+ if (!fs.existsSync(p) && p !== zshrc)
676
+ continue; // don't create bash files
677
+ let txt = "";
678
+ try {
679
+ txt = fs.readFileSync(p, "utf8");
680
+ }
681
+ catch {
682
+ /* new file */
683
+ }
684
+ const out = addPinclawPathBlock(txt);
685
+ if (out !== txt)
686
+ fs.writeFileSync(p, out);
687
+ }
688
+ setupLaunchdHub();
689
+ console.log("✓ Nexting connected. `claude` now mirrors to your phone and is phone-controllable.\n" +
690
+ " Open a NEW terminal (or `source ~/.zshrc`).\n" +
691
+ " - Bypass once: NEXTING_CC_DISABLE=1 claude\n" +
692
+ " - Start Codex sync: nexting-cc-bridge codex-install\n" +
693
+ " - Remove: nexting-cc-bridge uninstall");
694
+ return;
695
+ }
696
+ if (cmd === "logout") {
697
+ // §6.1: best-effort server-side revoke, THEN clear config, THEN unload daemon.
698
+ const { token, bridgeId } = loadConfig();
699
+ await bestEffortLogout(CC_LOGOUT_URL, token, bridgeId);
700
+ clearTokenAndBridgeId(CONFIG);
701
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/ai.nexting.cc-bridge`], { stdio: "ignore" });
702
+ spawnSync("launchctl", ["unload", PLIST], { stdio: "ignore" });
703
+ console.log("✓ Logged out. This Mac will no longer connect. Re-run the installer to reconnect.");
704
+ return;
705
+ }
706
+ if (cmd === "uninstall") {
707
+ // §6.3: best-effort server-side revoke + clear config FIRST.
708
+ const { token, bridgeId } = loadConfig();
709
+ await bestEffortLogout(CC_LOGOUT_URL, token, bridgeId);
710
+ clearTokenAndBridgeId(CONFIG);
711
+ spawnSync("launchctl", ["bootout", `gui/${process.getuid?.() ?? ""}/ai.nexting.cc-bridge`], { stdio: "ignore" });
712
+ spawnSync("launchctl", ["unload", PLIST], { stdio: "ignore" });
713
+ for (const f of [SHIM, PLIST]) {
714
+ try {
715
+ fs.unlinkSync(f);
716
+ }
717
+ catch {
718
+ /* gone */
719
+ }
720
+ }
721
+ for (const rc of RC_FILES) {
722
+ const p = path.join(os.homedir(), rc);
723
+ try {
724
+ const txt = fs.readFileSync(p, "utf8");
725
+ const out = stripPinclawBlock(txt);
726
+ if (out !== txt)
727
+ fs.writeFileSync(p, out);
728
+ }
729
+ catch {
730
+ /* none */
731
+ }
732
+ }
733
+ console.log("✓ Nexting removed. `claude` is back to normal. This Mac is disconnected from your account.");
734
+ return;
735
+ }
736
+ console.error(`Unknown command: ${cmd}. Use "start", "hub", "term", "install", "uninstall", "logout", "run", "mirror", "once", "codex-run", "codex-once", "codex-start", "codex-install", "codex-uninstall", or "codex-logout".`);
737
+ process.exit(1);
738
+ }
739
+ main().catch((e) => {
740
+ console.error(e);
741
+ process.exit(1);
742
+ });