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
@@ -0,0 +1,53 @@
1
+ // Real node-pty adapter, isolated so the native dep can be mocked elsewhere and
2
+ // kept out of non-mirror code paths (imported dynamically by bridge.ts).
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { createRequire } from "node:module";
6
+ const require = createRequire(import.meta.url);
7
+ /** node-pty 1.1.0 ships the darwin spawn-helper without the executable bit, so
8
+ * `pty.fork` dies with `posix_spawnp failed`. chmod +x it (idempotent; swallows
9
+ * missing-file / other-platform cases). */
10
+ export function ensureExecutable(file) {
11
+ try {
12
+ if (!fs.existsSync(file))
13
+ return;
14
+ const mode = fs.statSync(file).mode;
15
+ fs.chmodSync(file, mode | 0o111);
16
+ }
17
+ catch {
18
+ /* best effort */
19
+ }
20
+ }
21
+ function fixSpawnHelper() {
22
+ try {
23
+ const ptyDir = path.dirname(require.resolve("node-pty/package.json"));
24
+ for (const arch of ["darwin-arm64", "darwin-x64"]) {
25
+ ensureExecutable(path.join(ptyDir, "prebuilds", arch, "spawn-helper"));
26
+ }
27
+ }
28
+ catch {
29
+ /* node-pty not resolvable — ignore (handled at spawn time) */
30
+ }
31
+ }
32
+ export function realSpawnPty(o) {
33
+ fixSpawnHelper();
34
+ const nodePty = require("node-pty");
35
+ const p = nodePty.spawn(o.command, o.args, {
36
+ name: "xterm-256color",
37
+ cols: o.cols,
38
+ rows: o.rows,
39
+ cwd: o.cwd,
40
+ env: process.env,
41
+ });
42
+ return {
43
+ onData: (cb) => {
44
+ p.onData(cb);
45
+ },
46
+ onExit: (cb) => {
47
+ p.onExit(() => cb());
48
+ },
49
+ write: (d) => p.write(d),
50
+ resize: (c, r) => p.resize(c, r),
51
+ kill: () => p.kill(),
52
+ };
53
+ }
@@ -0,0 +1,422 @@
1
+ import { execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+ import fs from "node:fs";
4
+ import fsp from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { applyExactLiveIds, computeProjectStatuses, encodeProjectDir, isSessionFile, rootClaudePids, sessionIdFromCommand, } from "./discovery.js";
8
+ import { summarize, combineSummaries, parseTranscript, ccCountReducer, } from "./transcript.js";
9
+ import { summarizeCodexFile, codexMessageCounter } from "./codex-transcript.js";
10
+ import { createMessageCounter } from "./message-counter.js";
11
+ /** Exact per-file message counts (incremental; see message-counter.ts). The
12
+ * windowed summarize() count is only the fallback when this read fails. */
13
+ const ccMessageCounter = createMessageCounter(ccCountReducer);
14
+ const exec = promisify(execFile);
15
+ const HEAD_BYTES = 16 * 1024;
16
+ const TAIL_BYTES = 128 * 1024;
17
+ // ---- pure parsers (unit-tested) ----
18
+ export function parsePsOutput(raw) {
19
+ const procs = [];
20
+ const lines = raw.split("\n");
21
+ for (const line of lines.slice(1)) {
22
+ const m = line.match(/^\s*(\d+)\s+(\d+)\s+(.*\S)\s*$/);
23
+ if (!m)
24
+ continue;
25
+ procs.push({ pid: Number(m[1]), ppid: Number(m[2]), command: m[3] });
26
+ }
27
+ return procs;
28
+ }
29
+ export function parseLsofCwd(raw) {
30
+ for (const line of raw.split("\n")) {
31
+ if (line.startsWith("n"))
32
+ return line.slice(1);
33
+ }
34
+ return null;
35
+ }
36
+ /** Split a tail byte-window into whole JSONL lines, dropping the partial first line. */
37
+ export function linesFromTail(buf, cleanStart = false) {
38
+ const parts = buf.split("\n");
39
+ if (!cleanStart)
40
+ parts.shift(); // first line is partial
41
+ return parts.filter((l) => l.trim().length > 0);
42
+ }
43
+ /** Filter raw dirents to visible subdirectories, sorted, with absolute paths. */
44
+ export function dirEntriesFrom(dirents, basePath) {
45
+ return dirents
46
+ .filter((d) => d.isDirectory())
47
+ .filter((d) => !d.name.startsWith("."))
48
+ .map((d) => ({ name: d.name, path: path.join(basePath, d.name) }))
49
+ .sort((a, b) => a.name.localeCompare(b.name));
50
+ }
51
+ // ---- IO glue (verified by integration run) ----
52
+ export function projectsDir() {
53
+ return path.join(os.homedir(), ".claude", "projects");
54
+ }
55
+ /** List one directory level for the folder browser. Empty/missing input → home. */
56
+ export async function listDir(inputPath) {
57
+ const target = inputPath && inputPath.trim().length > 0 ? inputPath : os.homedir();
58
+ try {
59
+ const dirents = await fsp.readdir(target, { withFileTypes: true });
60
+ return {
61
+ path: target,
62
+ parent: path.dirname(target),
63
+ entries: dirEntriesFrom(dirents, target),
64
+ };
65
+ }
66
+ catch (e) {
67
+ return {
68
+ path: target,
69
+ parent: path.dirname(target),
70
+ entries: [],
71
+ error: e.code ?? "read_failed",
72
+ };
73
+ }
74
+ }
75
+ async function listProcs() {
76
+ try {
77
+ const { stdout } = await exec("ps", ["-axww", "-o", "pid,ppid,command"], {
78
+ maxBuffer: 16 * 1024 * 1024,
79
+ });
80
+ return parsePsOutput(stdout);
81
+ }
82
+ catch {
83
+ return [];
84
+ }
85
+ }
86
+ async function pidCwd(pid) {
87
+ try {
88
+ const { stdout } = await exec("lsof", [
89
+ "-a",
90
+ "-p",
91
+ String(pid),
92
+ "-d",
93
+ "cwd",
94
+ "-Fn",
95
+ ]);
96
+ return parseLsofCwd(stdout);
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
102
+ /** Split live (root) Claude CLI procs into exact session-id claims and
103
+ * heuristic procs. Procs started with `--session-id` (every wrapper-run
104
+ * session) bind pid↔session exactly; only bare procs without it fall back to
105
+ * the per-cwd mtime heuristic — and they're kept OUT of each other's counts
106
+ * so an exact proc can't double-mark a neighbour jsonl as running. */
107
+ async function liveClaudeProcs() {
108
+ const roots = rootClaudePids(await listProcs());
109
+ const exactIds = new Set();
110
+ const heuristic = [];
111
+ for (const p of roots) {
112
+ const id = sessionIdFromCommand(p.command);
113
+ if (id)
114
+ exactIds.add(id);
115
+ else
116
+ heuristic.push(p);
117
+ }
118
+ const byCwd = new Map();
119
+ await Promise.all(heuristic.map(async (p) => {
120
+ const cwd = await pidCwd(p.pid);
121
+ if (!cwd)
122
+ return;
123
+ const arr = byCwd.get(cwd) ?? [];
124
+ arr.push(p.pid);
125
+ byCwd.set(cwd, arr);
126
+ }));
127
+ return { exactIds, byCwd };
128
+ }
129
+ async function readHeadTail(file, headBytes = HEAD_BYTES, tailBytes = TAIL_BYTES) {
130
+ const fh = await fsp.open(file, "r");
131
+ try {
132
+ const { size } = await fh.stat();
133
+ const headLen = Math.min(headBytes, size);
134
+ const headBuf = Buffer.alloc(headLen);
135
+ await fh.read(headBuf, 0, headLen, 0);
136
+ let tail = "";
137
+ let clean = true;
138
+ if (size > headBytes) {
139
+ const tailLen = Math.min(tailBytes, size);
140
+ const start = size - tailLen;
141
+ clean = start === 0;
142
+ const tailBuf = Buffer.alloc(tailLen);
143
+ await fh.read(tailBuf, 0, tailLen, start);
144
+ tail = tailBuf.toString("utf8");
145
+ }
146
+ return { head: headBuf.toString("utf8"), tail, clean };
147
+ }
148
+ finally {
149
+ await fh.close();
150
+ }
151
+ }
152
+ /** Read a session file's lightweight summary fields without loading the whole file. */
153
+ async function summarizeFile(file) {
154
+ const { head, tail, clean } = await readHeadTail(file);
155
+ const headLines = head.split("\n").filter((l) => l.trim());
156
+ // Drop the last head line if it may be partial (file bigger than head window).
157
+ if (tail)
158
+ headLines.pop();
159
+ const tailLines = tail ? linesFromTail(tail, clean) : [];
160
+ const headSum = summarize(headLines);
161
+ const tailSum = tailLines.length ? summarize(tailLines) : headSum;
162
+ return combineSummaries(headSum, tailSum);
163
+ }
164
+ // ---- Codex session scanner ----
165
+ /** Map a rollout session_meta.payload.originator to the wire `origin` field.
166
+ * Unknown originators map to null (NOT undefined — "we looked, can't tell"). */
167
+ export function mapOriginator(raw) {
168
+ switch (raw) {
169
+ case "Codex Desktop":
170
+ return "desktop";
171
+ case "pinclaw-codex-bridge":
172
+ return "bridge";
173
+ case "codex_cli_rs":
174
+ return "cli";
175
+ case "codex_vscode":
176
+ return "vscode";
177
+ default:
178
+ return null;
179
+ }
180
+ }
181
+ /**
182
+ * Parse the first line of a Codex rollout JSONL file into a SessionInfo.
183
+ * Returns null if the line isn't a valid session_meta record.
184
+ */
185
+ export function parseCodexMeta(firstLine, _fallbackId, mtimeIso) {
186
+ try {
187
+ const obj = JSON.parse(firstLine);
188
+ if (obj?.type !== "session_meta")
189
+ return null;
190
+ const payload = obj?.payload;
191
+ if (!payload?.id)
192
+ return null;
193
+ return {
194
+ sessionId: payload.id,
195
+ origin: mapOriginator(payload.originator),
196
+ projectPath: encodeProjectDir(payload.cwd ?? ""),
197
+ cwd: payload.cwd ?? "",
198
+ status: "idle",
199
+ source: "codex",
200
+ title: null,
201
+ nameSource: null,
202
+ summary: null,
203
+ lastActiveAt: mtimeIso,
204
+ firstMessageAt: payload.timestamp ?? null,
205
+ lastAgentMessageAt: null,
206
+ recentMessages: [],
207
+ totalMessages: 0,
208
+ controllable: false,
209
+ };
210
+ }
211
+ catch {
212
+ return null;
213
+ }
214
+ }
215
+ const CODEX_HEAD_BYTES = 64 * 1024;
216
+ const CODEX_MAX_SESSIONS = 50;
217
+ /** Recursively collect all *.jsonl files under a directory. */
218
+ async function collectJsonlFiles(dir) {
219
+ const results = [];
220
+ try {
221
+ const dirents = await fsp.readdir(dir, { withFileTypes: true });
222
+ for (const d of dirents) {
223
+ const full = path.join(dir, d.name);
224
+ if (d.isDirectory()) {
225
+ const sub = await collectJsonlFiles(full);
226
+ results.push(...sub);
227
+ }
228
+ else if (d.isFile() && d.name.endsWith(".jsonl")) {
229
+ results.push(full);
230
+ }
231
+ }
232
+ }
233
+ catch {
234
+ /* unreadable dir — skip */
235
+ }
236
+ return results;
237
+ }
238
+ /** Read just the first line of a file efficiently (up to CODEX_HEAD_BYTES).
239
+ * Exported for codex-thread-source's per-(path,mtime) originator cache. */
240
+ export async function readFirstLine(filePath) {
241
+ const fh = await fsp.open(filePath, "r");
242
+ try {
243
+ const { size } = await fh.stat();
244
+ const readLen = Math.min(CODEX_HEAD_BYTES, size);
245
+ const buf = Buffer.alloc(readLen);
246
+ await fh.read(buf, 0, readLen, 0);
247
+ const text = buf.toString("utf8");
248
+ const nl = text.indexOf("\n");
249
+ return nl === -1 ? text : text.slice(0, nl);
250
+ }
251
+ finally {
252
+ await fh.close();
253
+ }
254
+ }
255
+ /**
256
+ * Discover Codex sessions from ~/.codex/sessions/.
257
+ * Files are nested as YYYY/MM/DD/rollout-<timestamp>-<uuid>.jsonl.
258
+ * Returns at most 50 sessions, sorted by mtime descending.
259
+ */
260
+ export async function discoverCodexSessions() {
261
+ const root = path.join(os.homedir(), ".codex", "sessions");
262
+ try {
263
+ await fsp.access(root);
264
+ }
265
+ catch {
266
+ return [];
267
+ }
268
+ const allFiles = await collectJsonlFiles(root);
269
+ // Stat all files, sort by mtime DESC, cap at CODEX_MAX_SESSIONS.
270
+ const withStats = (await Promise.all(allFiles.map(async (f) => {
271
+ try {
272
+ const st = await fsp.stat(f);
273
+ return {
274
+ file: f,
275
+ mtime: st.mtimeMs,
276
+ mtimeIso: st.mtime.toISOString(),
277
+ };
278
+ }
279
+ catch {
280
+ return null;
281
+ }
282
+ }))).filter((x) => x !== null);
283
+ withStats.sort((a, b) => b.mtime - a.mtime);
284
+ const top = withStats.slice(0, CODEX_MAX_SESSIONS);
285
+ const sessions = [];
286
+ for (const { file, mtimeIso } of top) {
287
+ try {
288
+ const firstLine = await readFirstLine(file);
289
+ // Extract uuid from filename as fallback id (rollout-<ts>-<uuid>.jsonl)
290
+ const basename = path.basename(file, ".jsonl");
291
+ const parts = basename.split("-");
292
+ const fallbackId = parts.length >= 3 ? parts.slice(2).join("-") : basename;
293
+ const info = parseCodexMeta(firstLine, fallbackId, mtimeIso);
294
+ if (!info)
295
+ continue;
296
+ // Enrich with user-visible message counts + title (same semantic as the
297
+ // CC summarize fields; without this every codex row showed 0 messages).
298
+ try {
299
+ const sum = await summarizeCodexFile(file);
300
+ const st = await fsp.stat(file);
301
+ info.totalMessages = await codexMessageCounter
302
+ .count(file, st.size)
303
+ .catch(() => sum.totalMessages);
304
+ info.lastAgentMessageAt = sum.lastAgentMessageAt;
305
+ info.summary = sum.summary;
306
+ if (sum.firstMessageAt)
307
+ info.firstMessageAt = sum.firstMessageAt;
308
+ if (sum.title) {
309
+ info.title = sum.title;
310
+ info.nameSource = "user-text";
311
+ }
312
+ }
313
+ catch {
314
+ /* summary is best-effort — keep the meta skeleton */
315
+ }
316
+ sessions.push(info);
317
+ }
318
+ catch {
319
+ /* unreadable file — skip */
320
+ }
321
+ }
322
+ return sessions;
323
+ }
324
+ /** Full discovery pipeline: scan projects + processes, assign status, enrich with summaries. */
325
+ export async function discoverSessions(dir = projectsDir()) {
326
+ const { exactIds, byCwd: live } = await liveClaudeProcs();
327
+ let projectDirs = [];
328
+ try {
329
+ projectDirs = await fsp.readdir(dir);
330
+ }
331
+ catch {
332
+ return [];
333
+ }
334
+ // Reverse-map encoded project dir -> real cwd via the live process map.
335
+ const encodedToCwd = new Map();
336
+ for (const cwd of live.keys())
337
+ encodedToCwd.set(encodeProjectDir(cwd), cwd);
338
+ const all = [];
339
+ for (const proj of projectDirs) {
340
+ const projPath = path.join(dir, proj);
341
+ let files = [];
342
+ try {
343
+ files = (await fsp.readdir(projPath)).filter(isSessionFile);
344
+ }
345
+ catch {
346
+ continue;
347
+ }
348
+ if (files.length === 0)
349
+ continue;
350
+ const jsonls = await Promise.all(files.map(async (f) => {
351
+ const st = await fsp.stat(path.join(projPath, f));
352
+ return {
353
+ sessionId: f.replace(/\.jsonl$/, ""),
354
+ mtime: st.mtimeMs,
355
+ size: st.size,
356
+ file: path.join(projPath, f),
357
+ };
358
+ }));
359
+ const cwd = encodedToCwd.get(proj) ?? "/" + proj.replace(/^-/, "").replace(/-/g, "/");
360
+ const livePids = live.get(cwd) ?? [];
361
+ const sessions = computeProjectStatuses({
362
+ cwd,
363
+ projectPath: proj,
364
+ jsonls: jsonls.map((j) => ({ sessionId: j.sessionId, mtime: j.mtime })),
365
+ livePids,
366
+ });
367
+ // Enrich real (non-pending) sessions with summaries.
368
+ for (const s of sessions) {
369
+ const j = jsonls.find((x) => x.sessionId === s.sessionId);
370
+ if (!j)
371
+ continue;
372
+ try {
373
+ const sum = await summarizeFile(j.file);
374
+ s.title = sum.title;
375
+ s.nameSource = sum.titleSource;
376
+ s.summary = sum.summary;
377
+ s.firstMessageAt = sum.firstMessageAt;
378
+ s.lastAgentMessageAt = sum.lastAgentMessageAt;
379
+ s.recentMessages = sum.recentMessages;
380
+ if (sum.cwd)
381
+ s.cwd = sum.cwd; // authoritative cwd from file content
382
+ // Exact count beats the windowed approximation (a single >16KB first
383
+ // line used to collapse a long session to "1 条消息").
384
+ s.totalMessages = await ccMessageCounter
385
+ .count(j.file, j.size)
386
+ .catch(() => sum.totalMessages);
387
+ }
388
+ catch {
389
+ /* unreadable file — keep skeleton */
390
+ }
391
+ }
392
+ all.push(...sessions);
393
+ }
394
+ all.sort((a, b) => (b.lastActiveAt ?? "").localeCompare(a.lastActiveAt ?? ""));
395
+ // Exact --session-id claims beat the heuristic: any session a live proc
396
+ // explicitly named is running, wherever its jsonl sorted.
397
+ return applyExactLiveIds(all, exactIds);
398
+ }
399
+ /** Locate one session's JSONL file by sessionId (shared by transcript read + watcher). */
400
+ export async function findSessionFile(sessionId, dir = projectsDir()) {
401
+ let projectDirs = [];
402
+ try {
403
+ projectDirs = await fsp.readdir(dir);
404
+ }
405
+ catch {
406
+ return null;
407
+ }
408
+ for (const proj of projectDirs) {
409
+ const file = path.join(dir, proj, `${sessionId}.jsonl`);
410
+ if (fs.existsSync(file))
411
+ return file;
412
+ }
413
+ return null;
414
+ }
415
+ /** Read one session's full transcript by sessionId (for on-demand viewer fetch). */
416
+ export async function readFullTranscript(sessionId, dir = projectsDir()) {
417
+ const file = await findSessionFile(sessionId, dir);
418
+ if (!file)
419
+ return null;
420
+ const raw = await fsp.readFile(file, "utf8");
421
+ return parseTranscript(raw.split("\n"));
422
+ }
@@ -0,0 +1,122 @@
1
+ // Bridge self-update. The bridge ships as the global npm package
2
+ // `nexting-cc-bridge`; new features (e.g. the ask_user tool, 1:1 binding, APNs
3
+ // push) only take effect once the user's installed bridge is on the new
4
+ // version. There is no update command and no auto-update today, so every bridge
5
+ // feature silently fails to roll out until the user manually re-runs the
6
+ // installer. This closes that gap: the persistent hub daemon checks npm for a
7
+ // newer version and updates itself.
8
+ //
9
+ // Safety:
10
+ // - Never throws. A failed update must never break the running bridge.
11
+ // - Relaunch (which kills+restarts the daemon) happens ONLY at the startup
12
+ // check, when the daemon has no attached runners yet, so a live phone-driven
13
+ // session is never interrupted. The periodic check only STAGES the new code
14
+ // (npm install) and lets it apply on the next natural restart.
15
+ // - No update loop: after install, the relaunched process runs the new
16
+ // VERSION, so its own startup check sees "current" and stops.
17
+ // - Opt out entirely with NEXTING_NO_SELF_UPDATE=1 (used in dev, where the
18
+ // daemon runs from a working checkout, not the global package).
19
+ import { execFile } from "node:child_process";
20
+ const PKG = "nexting-cc-bridge";
21
+ const CHECK_INTERVAL_MS = 90 * 60_000; // 90min (push-update covers immediacy; this is the backstop)
22
+ const STARTUP_DELAY_MS = 15_000;
23
+ const NPM_VIEW_TIMEOUT_MS = 30_000;
24
+ const NPM_INSTALL_TIMEOUT_MS = 180_000;
25
+ const RELAUNCH_TIMEOUT_MS = 15_000;
26
+ const defaultExec = (cmd, args, timeoutMs) => new Promise((resolve, reject) => {
27
+ execFile(cmd, args, { timeout: timeoutMs }, (err, stdout, stderr) => {
28
+ if (err)
29
+ reject(err);
30
+ else
31
+ resolve({ stdout: String(stdout), stderr: String(stderr) });
32
+ });
33
+ });
34
+ /** Parse the leading `major.minor.patch` of a version string. */
35
+ export function parseVersion(v) {
36
+ const m = v.trim().match(/^(\d+)\.(\d+)\.(\d+)/);
37
+ if (!m)
38
+ return null;
39
+ return [Number(m[1]), Number(m[2]), Number(m[3])];
40
+ }
41
+ /** true iff `latest` is a strictly higher semver than `current`. */
42
+ export function isNewerVersion(latest, current) {
43
+ const a = parseVersion(latest);
44
+ const b = parseVersion(current);
45
+ if (!a || !b)
46
+ return false;
47
+ for (let i = 0; i < 3; i++) {
48
+ if (a[i] > b[i])
49
+ return true;
50
+ if (a[i] < b[i])
51
+ return false;
52
+ }
53
+ return false;
54
+ }
55
+ /** `npm view nexting-cc-bridge version` → latest published version, or null on
56
+ * any failure (offline, npm missing, …). */
57
+ export async function npmLatestVersion(exec = defaultExec) {
58
+ try {
59
+ const { stdout } = await exec("npm", ["view", PKG, "version"], NPM_VIEW_TIMEOUT_MS);
60
+ return stdout.trim() || null;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ async function defaultRelaunch(label, exec) {
67
+ const uid = typeof process.getuid === "function" ? process.getuid() : 0;
68
+ // -k kills the running instance; launchd (KeepAlive) immediately restarts it
69
+ // with the freshly-installed code.
70
+ await exec("launchctl", ["kickstart", "-k", `gui/${uid}/${label}`], RELAUNCH_TIMEOUT_MS);
71
+ }
72
+ /** One-shot: check npm for a newer version; if found, install it globally and
73
+ * (optionally) relaunch the daemon. Never throws. */
74
+ export async function maybeSelfUpdate(deps, opts = { relaunch: true }) {
75
+ const exec = deps.exec ?? defaultExec;
76
+ try {
77
+ const latest = await npmLatestVersion(exec);
78
+ if (!latest)
79
+ return "error";
80
+ if (!isNewerVersion(latest, deps.currentVersion))
81
+ return "current";
82
+ deps.log(`self-update: ${deps.currentVersion} → ${latest}, installing…`);
83
+ await exec("npm", ["install", "-g", `${PKG}@${latest}`], NPM_INSTALL_TIMEOUT_MS);
84
+ if (!opts.relaunch) {
85
+ deps.log(`self-update: ${latest} staged; applies on next bridge restart`);
86
+ return "staged";
87
+ }
88
+ deps.log(`self-update: relaunching ${deps.label} on ${latest}`);
89
+ const relaunch = deps.relaunch ?? ((l) => defaultRelaunch(l, exec));
90
+ await relaunch(deps.label);
91
+ return "updated";
92
+ }
93
+ catch (err) {
94
+ deps.log(`self-update error: ${err instanceof Error ? err.message : String(err)}`);
95
+ return "error";
96
+ }
97
+ }
98
+ /** Start the self-update loop for a persistent daemon: one check shortly after
99
+ * startup (relaunch allowed — no attached runners yet), then every 6h a check
100
+ * that only STAGES (never interrupts a live session). No-op (returns an inert
101
+ * handle) when NEXTING_NO_SELF_UPDATE=1. */
102
+ export function startSelfUpdate(deps) {
103
+ if ((process.env.NEXTING_NO_SELF_UPDATE ?? "").trim() === "1") {
104
+ deps.log("self-update disabled (NEXTING_NO_SELF_UPDATE=1)");
105
+ return { stop: () => { } };
106
+ }
107
+ const startup = setTimeout(() => {
108
+ void maybeSelfUpdate(deps, { relaunch: true });
109
+ }, STARTUP_DELAY_MS);
110
+ const interval = setInterval(() => {
111
+ void maybeSelfUpdate(deps, { relaunch: false });
112
+ }, CHECK_INTERVAL_MS);
113
+ // Don't hold the event loop open solely for these timers.
114
+ startup.unref?.();
115
+ interval.unref?.();
116
+ return {
117
+ stop: () => {
118
+ clearTimeout(startup);
119
+ clearInterval(interval);
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,15 @@
1
+ export function toGatewaySession(s) {
2
+ return {
3
+ key: s.sessionId,
4
+ channel: "claude_code",
5
+ displayName: s.title?.trim() ? s.title.trim() : s.sessionId.slice(0, 8),
6
+ updatedAt: s.lastActiveAt,
7
+ totalTokens: 0,
8
+ status: s.status,
9
+ cwd: s.cwd,
10
+ projectPath: s.projectPath,
11
+ totalMessages: s.totalMessages,
12
+ summary: s.summary,
13
+ nameSource: s.nameSource ?? null,
14
+ };
15
+ }