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,415 @@
1
+ // Codex rollout JSONL → TranscriptEntry[] — the codex twin of transcript.ts.
2
+ // Rollout lines are {timestamp, type, payload}. `response_item` rows are the
3
+ // canonical record (message / reasoning / function_call*). Most `event_msg`
4
+ // rows duplicate that stream and stay hidden; patch apply events are the
5
+ // exception because Codex stores the structured file-change summary there.
6
+ import fsp from "node:fs/promises";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ import { createMessageCounter } from "./message-counter.js";
10
+ import { mediaFromImageBlock } from "./media-hydrator.js";
11
+ const TEXT_CAP = 16 * 1024;
12
+ function capText(text) {
13
+ if (text.length <= TEXT_CAP)
14
+ return { text };
15
+ return {
16
+ text: text.slice(0, TEXT_CAP),
17
+ truncatedChars: text.length - TEXT_CAP,
18
+ };
19
+ }
20
+ /** message content blocks → readable text (input_text / output_text / text). */
21
+ function contentText(content) {
22
+ if (typeof content === "string")
23
+ return content;
24
+ if (!Array.isArray(content))
25
+ return "";
26
+ return content
27
+ .map((b) => (typeof b === "string" ? b : (b?.text ?? "")))
28
+ .join("");
29
+ }
30
+ function contentImages(content) {
31
+ if (!Array.isArray(content))
32
+ return [];
33
+ return content
34
+ .filter((block) => block && typeof block === "object")
35
+ .map((block) => mediaFromImageBlock(block))
36
+ .filter((media) => media != null);
37
+ }
38
+ // Codex injects bookkeeping into the conversation that its own TUI never
39
+ // renders: developer/system-role instruction messages, plus user-role
40
+ // session-prefix messages (environment context, AGENTS.md instructions,
41
+ // abort markers). Mirror that filtering so the phone shows exactly what
42
+ // Codex shows.
43
+ const HIDDEN_USER_PREFIXES = [
44
+ "<environment_context>",
45
+ "<user_instructions>",
46
+ "<turn_aborted>",
47
+ ];
48
+ function isHiddenMessage(role, text) {
49
+ if (role !== "user" && role !== "assistant")
50
+ return true;
51
+ if (role === "user") {
52
+ const t = text.trimStart();
53
+ return HIDDEN_USER_PREFIXES.some((p) => t.startsWith(p));
54
+ }
55
+ return false;
56
+ }
57
+ function patchKind(type, movePath) {
58
+ switch (type) {
59
+ case "add":
60
+ return { type: "add" };
61
+ case "delete":
62
+ return { type: "delete" };
63
+ case "update":
64
+ return {
65
+ type: "update",
66
+ move_path: typeof movePath === "string" ? movePath : null,
67
+ };
68
+ default:
69
+ return { type: "update", move_path: null };
70
+ }
71
+ }
72
+ function normalizeAppServerFileChanges(value) {
73
+ if (!Array.isArray(value))
74
+ return [];
75
+ return value
76
+ .map((raw) => {
77
+ const change = raw && typeof raw === "object" ? raw : {};
78
+ const kind = change.kind && typeof change.kind === "object"
79
+ ? change.kind
80
+ : null;
81
+ return {
82
+ path: String(change.path ?? ""),
83
+ kind: kind
84
+ ? patchKind(kind.type, kind.move_path)
85
+ : patchKind(change.kind, null),
86
+ diff: String(change.diff ?? ""),
87
+ };
88
+ })
89
+ .filter((change) => change.path.length > 0);
90
+ }
91
+ function normalizePatchEventChanges(value) {
92
+ if (!value || typeof value !== "object" || Array.isArray(value))
93
+ return [];
94
+ return Object.entries(value).map(([pathName, raw]) => {
95
+ const change = raw && typeof raw === "object" ? raw : {};
96
+ return {
97
+ path: pathName,
98
+ kind: patchKind(change.type, change.move_path),
99
+ diff: String(change.unified_diff ?? change.content ?? ""),
100
+ };
101
+ });
102
+ }
103
+ /** Rebuild the kind-tagged timeline from raw rollout JSONL lines. */
104
+ export function parseCodexTranscript(lines) {
105
+ const out = [];
106
+ for (const line of lines) {
107
+ const s = line.trim();
108
+ if (!s)
109
+ continue;
110
+ let o;
111
+ try {
112
+ o = JSON.parse(s);
113
+ }
114
+ catch {
115
+ continue;
116
+ }
117
+ const p = o.payload;
118
+ if (!p || typeof p !== "object")
119
+ continue;
120
+ const ts = o.timestamp ?? null;
121
+ if (o?.type === "event_msg") {
122
+ switch (p.type) {
123
+ case "patch_apply_begin":
124
+ case "patch_apply_end": {
125
+ const fileChanges = normalizePatchEventChanges(p.changes);
126
+ if (fileChanges.length === 0)
127
+ break;
128
+ out.push({
129
+ kind: "file_change",
130
+ uuid: p.call_id,
131
+ timestamp: ts,
132
+ fileChanges,
133
+ fileChangeStatus: p.status ??
134
+ (p.type === "patch_apply_begin" ? "inProgress" : "completed"),
135
+ isError: p.success === false || p.status === "failed",
136
+ });
137
+ break;
138
+ }
139
+ default:
140
+ break;
141
+ }
142
+ continue;
143
+ }
144
+ if (o?.type !== "response_item")
145
+ continue;
146
+ switch (p.type) {
147
+ case "message": {
148
+ const text = contentText(p.content);
149
+ const images = contentImages(p.content);
150
+ if (text && !isHiddenMessage(p.role, text)) {
151
+ out.push({
152
+ kind: p.role === "user" ? "user_text" : "assistant_text",
153
+ timestamp: ts,
154
+ ...capText(text),
155
+ });
156
+ }
157
+ if (p.role === "user" || p.role === "assistant") {
158
+ for (const media of images) {
159
+ out.push({ kind: "image", timestamp: ts, media });
160
+ }
161
+ }
162
+ break;
163
+ }
164
+ case "reasoning": {
165
+ const text = contentText(p.content) ||
166
+ (Array.isArray(p.summary)
167
+ ? p.summary.map((x) => x?.text ?? "").join("")
168
+ : "");
169
+ if (!text)
170
+ break;
171
+ out.push({ kind: "thinking", timestamp: ts, ...capText(text) });
172
+ break;
173
+ }
174
+ case "function_call":
175
+ out.push({
176
+ kind: "tool_use",
177
+ timestamp: ts,
178
+ name: p.name ?? "tool",
179
+ inputJson: typeof p.arguments === "string"
180
+ ? p.arguments
181
+ : JSON.stringify(p.arguments ?? {}),
182
+ ...(Array.isArray(p.commandActions)
183
+ ? { commandActions: p.commandActions }
184
+ : {}),
185
+ ...(typeof p.commandSource === "string"
186
+ ? { commandSource: p.commandSource }
187
+ : typeof p.source === "string"
188
+ ? { commandSource: p.source }
189
+ : {}),
190
+ });
191
+ break;
192
+ case "commandExecution":
193
+ out.push({
194
+ kind: "tool_use",
195
+ timestamp: ts,
196
+ name: "shell",
197
+ inputJson: JSON.stringify({ command: p.command ?? "" }),
198
+ ...(Array.isArray(p.commandActions)
199
+ ? { commandActions: p.commandActions }
200
+ : {}),
201
+ ...(typeof p.source === "string" ? { commandSource: p.source } : {}),
202
+ });
203
+ break;
204
+ case "fileChange": {
205
+ const fileChanges = normalizeAppServerFileChanges(p.changes);
206
+ if (fileChanges.length === 0)
207
+ break;
208
+ out.push({
209
+ kind: "file_change",
210
+ uuid: p.id,
211
+ timestamp: ts,
212
+ fileChanges,
213
+ fileChangeStatus: p.status,
214
+ isError: p.status === "failed",
215
+ });
216
+ break;
217
+ }
218
+ case "function_call_output": {
219
+ const raw = p.output;
220
+ const text = typeof raw === "string" ? raw : JSON.stringify(raw ?? "");
221
+ out.push({
222
+ kind: "tool_result",
223
+ timestamp: ts,
224
+ isError: false,
225
+ ...capText(text),
226
+ });
227
+ break;
228
+ }
229
+ default:
230
+ break;
231
+ }
232
+ }
233
+ return out;
234
+ }
235
+ /** Slice by Unicode code points — no split surrogates (mirrors transcript.ts). */
236
+ function sliceCodePoints(text, max) {
237
+ return [...text].slice(0, max).join("");
238
+ }
239
+ /** THE codex counting rule, shared by summarizeCodexLines (windowed fallback)
240
+ * and the exact incremental counter. A message = a visible user message or the
241
+ * start of an assistant turn; hidden injections neither count nor break a turn. */
242
+ export function codexCountEntry(o, st) {
243
+ if (o?.type !== "response_item")
244
+ return;
245
+ const p = o.payload;
246
+ if (!p || typeof p !== "object" || p.type !== "message")
247
+ return;
248
+ const text = contentText(p.content);
249
+ if (!text || isHiddenMessage(p.role, text))
250
+ return;
251
+ if (p.role === "user") {
252
+ st.messages++;
253
+ st.inAssistantTurn = false;
254
+ }
255
+ else if (!st.inAssistantTurn) {
256
+ st.messages++;
257
+ st.inAssistantTurn = true;
258
+ }
259
+ }
260
+ /** Raw-line reducer for createMessageCounter (codex engine). */
261
+ export function codexCountReducer(line, st) {
262
+ const s = line.trim();
263
+ if (!s)
264
+ return;
265
+ let o;
266
+ try {
267
+ o = JSON.parse(s);
268
+ }
269
+ catch {
270
+ return;
271
+ }
272
+ codexCountEntry(o, st);
273
+ }
274
+ /** Shared exact counter for codex rollout files (scanner disk fallback +
275
+ * thread-source both feed it; per-file incremental, see message-counter.ts). */
276
+ export const codexMessageCounter = createMessageCounter(codexCountReducer);
277
+ /** Derive list-row fields from rollout lines. Hidden injections (developer/
278
+ * system roles, env-context user prefixes) neither count nor break a turn. */
279
+ export function summarizeCodexLines(lines) {
280
+ let title = null;
281
+ const countState = { messages: 0, inAssistantTurn: false };
282
+ let firstMessageAt = null;
283
+ let lastAgentMessageAt = null;
284
+ let summary = null;
285
+ for (const line of lines) {
286
+ const s = line.trim();
287
+ if (!s)
288
+ continue;
289
+ let o;
290
+ try {
291
+ o = JSON.parse(s);
292
+ }
293
+ catch {
294
+ continue;
295
+ }
296
+ if (o?.type !== "response_item")
297
+ continue;
298
+ codexCountEntry(o, countState); // shared rule with the incremental counter
299
+ const p = o.payload;
300
+ if (!p || typeof p !== "object" || p.type !== "message")
301
+ continue;
302
+ const text = contentText(p.content);
303
+ if (!text || isHiddenMessage(p.role, text))
304
+ continue;
305
+ const ts = o.timestamp ?? null;
306
+ if (firstMessageAt === null)
307
+ firstMessageAt = ts;
308
+ if (p.role === "user") {
309
+ if (title === null)
310
+ title = sliceCodePoints(text, 120);
311
+ }
312
+ else if (ts) {
313
+ lastAgentMessageAt = ts;
314
+ }
315
+ summary = text.replace(/\s+/g, " ").slice(0, 200);
316
+ }
317
+ return {
318
+ title,
319
+ totalMessages: countState.messages,
320
+ firstMessageAt,
321
+ lastAgentMessageAt,
322
+ summary,
323
+ };
324
+ }
325
+ /** Combine head- and tail-window summaries of one rollout file (windows may
326
+ * overlap or miss the middle — counts use max, like CC's combineSummaries). */
327
+ export function combineCodexSummaries(head, tail) {
328
+ return {
329
+ title: head.title ?? tail.title,
330
+ totalMessages: Math.max(head.totalMessages, tail.totalMessages),
331
+ firstMessageAt: head.firstMessageAt ?? tail.firstMessageAt,
332
+ lastAgentMessageAt: tail.lastAgentMessageAt ?? head.lastAgentMessageAt,
333
+ summary: tail.summary ?? head.summary,
334
+ };
335
+ }
336
+ const SUM_HEAD_BYTES = 64 * 1024;
337
+ const SUM_TAIL_BYTES = 128 * 1024;
338
+ /** Summarize one rollout file from head+tail windows (no full-file read).
339
+ * Self-contained on purpose — scanner.ts and codex-thread-source.ts both call
340
+ * this, and importing scanner helpers here would create a cycle. */
341
+ export async function summarizeCodexFile(file) {
342
+ const fh = await fsp.open(file, "r");
343
+ try {
344
+ const { size } = await fh.stat();
345
+ const headLen = Math.min(SUM_HEAD_BYTES, size);
346
+ const headBuf = Buffer.alloc(headLen);
347
+ await fh.read(headBuf, 0, headLen, 0);
348
+ const head = headBuf.toString("utf8");
349
+ let tail = "";
350
+ let cleanStart = true;
351
+ if (size > headLen) {
352
+ const tailLen = Math.min(SUM_TAIL_BYTES, size);
353
+ const start = size - tailLen;
354
+ cleanStart = start === 0;
355
+ const tailBuf = Buffer.alloc(tailLen);
356
+ await fh.read(tailBuf, 0, tailLen, start);
357
+ tail = tailBuf.toString("utf8");
358
+ }
359
+ const headLines = head.split("\n").filter((l) => l.trim());
360
+ if (tail)
361
+ headLines.pop(); // last head line may be cut mid-record
362
+ let tailLines = [];
363
+ if (tail) {
364
+ const parts = tail.split("\n");
365
+ if (!cleanStart)
366
+ parts.shift(); // first tail line may be partial
367
+ tailLines = parts.filter((l) => l.trim());
368
+ }
369
+ const headSum = summarizeCodexLines(headLines);
370
+ return tailLines.length
371
+ ? combineCodexSummaries(headSum, summarizeCodexLines(tailLines))
372
+ : headSum;
373
+ }
374
+ finally {
375
+ await fh.close();
376
+ }
377
+ }
378
+ function codexSessionsRoot() {
379
+ return path.join(os.homedir(), ".codex", "sessions");
380
+ }
381
+ /** Locate a rollout file by session id — filenames end in `-<id>.jsonl`, so
382
+ * this is a directory walk with no file reads. */
383
+ export async function findCodexSessionFile(sessionId, root = codexSessionsRoot()) {
384
+ const suffix = `-${sessionId}.jsonl`;
385
+ async function walk(dir) {
386
+ let dirents;
387
+ try {
388
+ dirents = await fsp.readdir(dir, { withFileTypes: true });
389
+ }
390
+ catch {
391
+ return null;
392
+ }
393
+ for (const d of dirents) {
394
+ const full = path.join(dir, d.name);
395
+ if (d.isDirectory()) {
396
+ const hit = await walk(full);
397
+ if (hit)
398
+ return hit;
399
+ }
400
+ else if (d.isFile() && d.name.endsWith(suffix)) {
401
+ return full;
402
+ }
403
+ }
404
+ return null;
405
+ }
406
+ return walk(root);
407
+ }
408
+ /** Read one codex session's full transcript by id (viewer fetch / slices). */
409
+ export async function readCodexTranscript(sessionId, root = codexSessionsRoot()) {
410
+ const file = await findCodexSessionFile(sessionId, root);
411
+ if (!file)
412
+ return null;
413
+ const raw = await fsp.readFile(file, "utf8");
414
+ return parseCodexTranscript(raw.split("\n"));
415
+ }
@@ -0,0 +1,126 @@
1
+ // Local stand-in for the Nexting cloud, used to prove end-to-end connectivity
2
+ // without deploying. It mirrors exactly what the real `claude-code-handler` does:
3
+ // - accepts a bridge on /cc-bridge/connect, stores its latest cc_snapshot
4
+ // - serves a phone on /phone: get_sessions -> sessions_result (gateway shape),
5
+ // get_session_transcript -> RPC the bridge -> session_transcript
6
+ import { WebSocketServer } from "ws";
7
+ import { createServer } from "node:http";
8
+ import { toGatewaySession } from "./session-map.js";
9
+ const PORT = Number(process.env.CC_DEV_PORT ?? 7799);
10
+ let bridgeSocket = null;
11
+ let latestSnapshot = [];
12
+ const pendingTranscripts = new Map();
13
+ let reqCounter = 0;
14
+ const httpServer = createServer();
15
+ const wss = new WebSocketServer({ noServer: true });
16
+ httpServer.on("upgrade", (req, socket, head) => {
17
+ const url = new URL(req.url ?? "/", "http://localhost");
18
+ const path = url.pathname;
19
+ if (path === "/cc-bridge/connect" || path === "/phone") {
20
+ wss.handleUpgrade(req, socket, head, (ws) => {
21
+ ws._role = path === "/cc-bridge/connect" ? "bridge" : "phone";
22
+ wss.emit("connection", ws, req);
23
+ });
24
+ }
25
+ else {
26
+ socket.destroy();
27
+ }
28
+ });
29
+ wss.on("connection", (ws) => {
30
+ const role = ws._role;
31
+ if (role === "bridge") {
32
+ bridgeSocket = ws;
33
+ console.log("[dev-server] bridge connected");
34
+ ws.on("message", (data) => handleBridge(ws, data.toString()));
35
+ ws.on("close", () => {
36
+ if (bridgeSocket === ws)
37
+ bridgeSocket = null;
38
+ console.log("[dev-server] bridge disconnected");
39
+ });
40
+ }
41
+ else {
42
+ console.log("[dev-server] phone connected");
43
+ ws.on("message", (data) => handlePhone(ws, data.toString()));
44
+ }
45
+ });
46
+ function handleBridge(ws, raw) {
47
+ let msg;
48
+ try {
49
+ msg = JSON.parse(raw);
50
+ }
51
+ catch {
52
+ return;
53
+ }
54
+ switch (msg.type) {
55
+ case "cc_hello":
56
+ console.log(`[dev-server] hello from ${msg.host} v${msg.version}`);
57
+ break;
58
+ case "cc_snapshot":
59
+ latestSnapshot = msg.sessions;
60
+ console.log(`[dev-server] snapshot: ${msg.sessions.length} sessions`);
61
+ break;
62
+ case "heartbeat":
63
+ ws.send(JSON.stringify({ type: "heartbeat_ack" }));
64
+ break;
65
+ case "cc_transcript_result": {
66
+ const cb = pendingTranscripts.get(msg.requestId);
67
+ if (cb) {
68
+ pendingTranscripts.delete(msg.requestId);
69
+ cb(msg.entries, msg.notFound);
70
+ }
71
+ break;
72
+ }
73
+ }
74
+ }
75
+ function handlePhone(ws, raw) {
76
+ let msg;
77
+ try {
78
+ msg = JSON.parse(raw);
79
+ }
80
+ catch {
81
+ return;
82
+ }
83
+ if (msg.type === "get_sessions") {
84
+ ws.send(JSON.stringify({
85
+ type: "sessions_result",
86
+ sessions: latestSnapshot.map(toGatewaySession),
87
+ bridgeOnline: bridgeSocket !== null,
88
+ }));
89
+ }
90
+ else if (msg.type === "get_session_transcript") {
91
+ if (!bridgeSocket) {
92
+ ws.send(JSON.stringify({
93
+ type: "session_transcript",
94
+ sessionKey: msg.sessionKey,
95
+ error: "bridge_offline",
96
+ }));
97
+ return;
98
+ }
99
+ const requestId = `t${++reqCounter}`;
100
+ const timeout = setTimeout(() => {
101
+ pendingTranscripts.delete(requestId);
102
+ ws.send(JSON.stringify({
103
+ type: "session_transcript",
104
+ sessionKey: msg.sessionKey,
105
+ error: "timeout",
106
+ }));
107
+ }, 10000);
108
+ pendingTranscripts.set(requestId, (entries, notFound) => {
109
+ clearTimeout(timeout);
110
+ ws.send(JSON.stringify({
111
+ type: "session_transcript",
112
+ sessionKey: msg.sessionKey,
113
+ entries,
114
+ notFound,
115
+ }));
116
+ });
117
+ bridgeSocket.send(JSON.stringify({
118
+ type: "cc_get_transcript",
119
+ requestId,
120
+ sessionId: msg.sessionKey,
121
+ }));
122
+ }
123
+ }
124
+ httpServer.listen(PORT, () => {
125
+ console.log(`[dev-server] listening on ws://localhost:${PORT} (/cc-bridge/connect, /phone)`);
126
+ });
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Encode a working directory the way Claude Code names its project folder:
3
+ * every non-alphanumeric character becomes a dash.
4
+ * /Users/eric/AI_development/pinclaw -> -Users-eric-AI-development-pinclaw
5
+ */
6
+ export function encodeProjectDir(cwd) {
7
+ return cwd.replace(/[^a-zA-Z0-9]/g, "-");
8
+ }
9
+ /** A session JSONL is a UUID-named .jsonl; basename (sans ext) length >= 30 filters out meta files. */
10
+ export function isSessionFile(filename) {
11
+ if (!filename.endsWith(".jsonl"))
12
+ return false;
13
+ const base = filename.slice(0, -".jsonl".length);
14
+ return base.length >= 30;
15
+ }
16
+ /** Identify the Claude Code CLI process (not the desktop app or unrelated procs).
17
+ * Install layouts in the wild:
18
+ * - npm/pnpm: .../@anthropic-ai/claude-code/bin/claude(.exe)
19
+ * - native installer: ~/.local/bin/claude → ~/.local/share/claude/versions/<v>
20
+ * - bare `claude` via PATH
21
+ * Matching argv0's basename (exactly "claude") keeps the desktop app
22
+ * (".../MacOS/Claude") and lookalikes ("claudette") out. */
23
+ export function isClaudeCliCommand(command) {
24
+ const argv0 = command.trim().split(/\s+/)[0] ?? "";
25
+ const base = argv0.split("/").pop() ?? "";
26
+ if (base === "claude" || base === "claude.exe")
27
+ return true;
28
+ // Native installer's versioned binary (basename is the version number).
29
+ if (/\/claude\/versions\/[^/]+$/.test(argv0))
30
+ return true;
31
+ // Legacy npm path where argv0 is node and the script path follows.
32
+ return /claude-code\/bin\/claude/.test(command);
33
+ }
34
+ /**
35
+ * From a full process list, return only the *root* Claude CLI sessions:
36
+ * CLI processes whose parent is not itself a CLI process. This dedups any
37
+ * parent/child fork so one running session counts once.
38
+ */
39
+ export function rootClaudePids(procs) {
40
+ const claude = procs.filter((p) => isClaudeCliCommand(p.command));
41
+ const claudePidSet = new Set(claude.map((p) => p.pid));
42
+ return claude.filter((p) => !claudePidSet.has(p.ppid));
43
+ }
44
+ /** Extract the session uuid a CLI proc was started with (`--session-id <uuid>` /
45
+ * `--session-id=<uuid>`). Sessions run through the bridge's term wrapper always
46
+ * carry it, giving an exact pid↔session binding — no mtime guessing. */
47
+ export function sessionIdFromCommand(command) {
48
+ const m = command.match(/--session-id[=\s]+([0-9a-fA-F][0-9a-fA-F-]{7,})/);
49
+ return m ? m[1] : null;
50
+ }
51
+ /** Force-mark sessions claimed by a live proc's --session-id as running.
52
+ * Runs AFTER the per-project mtime heuristic and overrides it: an exact claim
53
+ * beats any recency ranking. */
54
+ export function applyExactLiveIds(sessions, liveIds) {
55
+ if (liveIds.size === 0)
56
+ return sessions;
57
+ return sessions.map((s) => liveIds.has(s.sessionId) ? { ...s, status: "running" } : s);
58
+ }
59
+ /** A jsonl untouched for longer than this can't be claimed "running" by the
60
+ * mtime heuristic. Bare procs (no --session-id) aren't bound to a specific
61
+ * jsonl, so with many parallel sessions the newest-N ranking happily marks
62
+ * hours-old sessions as running — which also defeats the phone UI's
63
+ * "idle sessions collapse" and renders every row. Exact --session-id claims
64
+ * are unaffected (applyExactLiveIds runs after this and overrides). */
65
+ export const HEURISTIC_FRESH_MS = 30 * 60_000;
66
+ /**
67
+ * mtime heuristic: with N live procs in a project, the N newest *recently
68
+ * active* JSONLs (mtime within HEURISTIC_FRESH_MS) are "running", the rest
69
+ * "idle". Extra procs beyond the JSONL count become virtual "pending"
70
+ * sessions (process up, no JSONL written yet).
71
+ */
72
+ export function computeProjectStatuses(input, now = Date.now()) {
73
+ const { cwd, projectPath, jsonls, livePids } = input;
74
+ const n = livePids.length;
75
+ const sorted = [...jsonls].sort((a, b) => b.mtime - a.mtime);
76
+ const sessions = sorted.map((j, idx) => ({
77
+ sessionId: j.sessionId,
78
+ projectPath,
79
+ cwd,
80
+ status: idx < n && now - j.mtime <= HEURISTIC_FRESH_MS ? "running" : "idle",
81
+ source: "claude_code",
82
+ title: null,
83
+ nameSource: null,
84
+ summary: null,
85
+ lastActiveAt: new Date(j.mtime).toISOString(),
86
+ firstMessageAt: null,
87
+ lastAgentMessageAt: null,
88
+ recentMessages: [],
89
+ totalMessages: 0,
90
+ }));
91
+ const extra = n - jsonls.length;
92
+ for (let i = 0; i < extra; i++) {
93
+ const pid = livePids[jsonls.length + i] ?? livePids[i];
94
+ sessions.push({
95
+ sessionId: `pending-${pid}`,
96
+ projectPath,
97
+ cwd,
98
+ status: "pending",
99
+ source: "claude_code",
100
+ title: null,
101
+ nameSource: null,
102
+ summary: null,
103
+ lastActiveAt: null,
104
+ firstMessageAt: null,
105
+ lastAgentMessageAt: null,
106
+ recentMessages: [],
107
+ totalMessages: 0,
108
+ });
109
+ }
110
+ return sessions;
111
+ }