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,148 @@
1
+ // Codex custom prompts (~/.codex/prompts/*.md) — the codex engine's answer to
2
+ // cc_list_skills, surfaced through the SAME SkillListing shape the phone
3
+ // already speaks: each prompt is a command ("/name"), and Codex/agent/plugin
4
+ // SKILL.md files are surfaced as skills.
5
+ //
6
+ // Unlike claude's stream-json child, `codex app-server` does NOT expand custom
7
+ // prompts itself — that's a TUI feature. So the bridge also owns expansion:
8
+ // `expandCodexPrompt` rewrites a leading "/name [args]" into the prompt body
9
+ // at send time (codex-adapter calls it from encodeUserTurn).
10
+ import fs from "node:fs";
11
+ import fsp from "node:fs/promises";
12
+ import os from "node:os";
13
+ import path from "node:path";
14
+ import { collectFiles, commandNameFromPath, parseFrontmatter, pluginNameFromPath, sortSkills, } from "./skills-scanner.js";
15
+ export function codexPromptsDir() {
16
+ return path.join(os.homedir(), ".codex", "prompts");
17
+ }
18
+ /** Frontmatter `description:` if present, else the first meaningful body line. */
19
+ function promptDescription(body) {
20
+ const lines = body.split("\n");
21
+ let i = 0;
22
+ if (lines[0]?.trim() === "---") {
23
+ for (let j = 1; j < lines.length; j++) {
24
+ const m = lines[j].match(/^description:\s*(.+)$/);
25
+ if (m)
26
+ return m[1]
27
+ .trim()
28
+ .replace(/^["']|["']$/g, "")
29
+ .slice(0, 200);
30
+ if (lines[j].trim() === "---") {
31
+ i = j + 1;
32
+ break;
33
+ }
34
+ }
35
+ }
36
+ for (; i < lines.length; i++) {
37
+ const t = lines[i].trim();
38
+ if (t && t !== "---")
39
+ return t.replace(/^#+\s*/, "").slice(0, 200);
40
+ }
41
+ return "";
42
+ }
43
+ export async function listCodexPrompts(dir = codexPromptsDir(), opts = {}) {
44
+ const files = await collectFiles(dir, 4, (name) => name.toLowerCase().endsWith(".md"));
45
+ const commands = [];
46
+ for (const f of files.sort()) {
47
+ let description = "";
48
+ try {
49
+ description = promptDescription(await fsp.readFile(f, "utf8"));
50
+ }
51
+ catch {
52
+ /* unreadable → still listed, no description */
53
+ }
54
+ commands.push({
55
+ name: commandNameFromPath(dir, f),
56
+ description,
57
+ scope: "user",
58
+ });
59
+ }
60
+ return { skills: await listCodexSkills(opts), commands };
61
+ }
62
+ async function readCodexSkill(file, source, namePrefix) {
63
+ try {
64
+ const fm = parseFrontmatter(await fsp.readFile(file, "utf8"));
65
+ const base = fm.name ?? path.basename(path.dirname(file));
66
+ return {
67
+ name: namePrefix ? `${namePrefix}:${base}` : base,
68
+ description: fm.description ?? "",
69
+ source,
70
+ };
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }
76
+ async function listCodexSkills(opts) {
77
+ const home = os.homedir();
78
+ const isSkill = (n) => n === "SKILL.md";
79
+ const sources = [];
80
+ if (opts.cwd?.trim()) {
81
+ sources.push({
82
+ root: path.join(opts.cwd, ".agents", "skills"),
83
+ depth: 3,
84
+ source: "project",
85
+ });
86
+ }
87
+ for (const root of opts.userSkillDirs ?? [
88
+ path.join(home, ".codex", "skills"),
89
+ path.join(home, ".agents", "skills"),
90
+ ]) {
91
+ sources.push({ root, depth: 3, source: "user" });
92
+ }
93
+ for (const root of opts.pluginDirs ?? [
94
+ path.join(home, ".codex", "plugins"),
95
+ ]) {
96
+ sources.push({ root, depth: 8, source: "plugin", pluginRoot: root });
97
+ }
98
+ const skills = [];
99
+ const seen = new Set();
100
+ for (const source of sources) {
101
+ const files = await collectFiles(source.root, source.depth, isSkill);
102
+ const entries = await Promise.all(files.map((f) => readCodexSkill(f, source.source, source.pluginRoot
103
+ ? pluginNameFromPath(source.pluginRoot, f)
104
+ : undefined)));
105
+ for (const e of entries) {
106
+ if (!e || seen.has(e.name))
107
+ continue;
108
+ seen.add(e.name);
109
+ skills.push(e);
110
+ }
111
+ }
112
+ return sortSkills(skills);
113
+ }
114
+ /** Strip the frontmatter block (the listing reads it; the model shouldn't). */
115
+ function stripFrontmatter(body) {
116
+ if (!body.startsWith("---"))
117
+ return body;
118
+ const end = body.indexOf("\n---", 3);
119
+ if (end < 0)
120
+ return body;
121
+ return body.slice(body.indexOf("\n", end + 1) + 1).replace(/^\n+/, "");
122
+ }
123
+ /** Expand a leading "/name [args]" into the prompt body ($ARGUMENTS substituted,
124
+ * or args appended). Anything that doesn't match a prompt file passes through
125
+ * untouched — including claude-style commands the user types by habit. */
126
+ export function expandCodexPrompt(content, dir = codexPromptsDir()) {
127
+ if (!content.startsWith("/"))
128
+ return content;
129
+ const space = content.search(/\s/);
130
+ const name = (space < 0 ? content : content.slice(0, space)).slice(1);
131
+ const args = space < 0 ? "" : content.slice(space + 1).trim();
132
+ const parts = name.split(":");
133
+ if (!name ||
134
+ parts.some((part) => !part || part === "." || part === ".." || /[/\\]/.test(part))) {
135
+ return content;
136
+ }
137
+ let body;
138
+ try {
139
+ body = fs.readFileSync(path.join(dir, ...parts) + ".md", "utf8");
140
+ }
141
+ catch {
142
+ return content;
143
+ }
144
+ body = stripFrontmatter(body).trim();
145
+ if (body.includes("$ARGUMENTS"))
146
+ return body.replaceAll("$ARGUMENTS", args);
147
+ return args ? `${body}\n\n${args}` : body;
148
+ }
@@ -0,0 +1,495 @@
1
+ // Codex session-list source — replaces disk scanning with the official
2
+ // `thread/list` RPC, fused with the signals thread/list does NOT carry:
3
+ //
4
+ // origin — rollout jsonl first line session_meta.payload.originator
5
+ // (cached per (path, mtime) so we never re-read unchanged files)
6
+ // openIn — the desktop app's app-server process holds a write FD on every
7
+ // rollout it has open; `lsof -p <pid> -Fn` lists them
8
+ // appChat — desktop sidebar "对话" (projectless chats) ids live in
9
+ // ~/.codex/.codex-global-state.json `projectless-thread-ids`
10
+ // title — thread/list `name` ("app-title") → thread/list `preview`
11
+ // ("preview") → ~/.codex/session_index.jsonl thread_name
12
+ // ("app-title", degraded fallback)
13
+ //
14
+ // ╔═══════════════════════════════════════════════════════════════════════╗
15
+ // ║ HARD CONSTRAINT — READ-ONLY RPC ONLY. ║
16
+ // ║ The long-lived query child may ONLY ever send: ║
17
+ // ║ initialize / initialized (handshake) and thread/list. ║
18
+ // ║ NEVER thread/start, thread/resume, or turn/* — those CREATE or RESUME ║
19
+ // ║ real sessions on the user's machine. Adding any mutating RPC here is ║
20
+ // ║ a bug, full stop. ║
21
+ // ╚═══════════════════════════════════════════════════════════════════════╝
22
+ //
23
+ // Any failure (spawn failure, RPC timeout, parse failure) makes
24
+ // listSessions() return null so the caller falls back to the existing
25
+ // discoverCodexSessions() disk scan — the snapshot supply must never break.
26
+ import { execFile, spawn as nodeSpawn } from "node:child_process";
27
+ import { promisify } from "node:util";
28
+ import { StringDecoder } from "node:string_decoder";
29
+ import fsp from "node:fs/promises";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { encodeProjectDir } from "./discovery.js";
33
+ import { mapOriginator, parsePsOutput, readFirstLine } from "./scanner.js";
34
+ import { resolveCodexBin } from "./engine/codex-adapter.js";
35
+ import { findCodexSessionFile, summarizeCodexFile, codexMessageCounter, } from "./codex-transcript.js";
36
+ const execP = promisify(execFile);
37
+ /** Our own phone-probe sessions — never shown in the snapshot. */
38
+ export const CODEX_PROBE_CWD = "/tmp/codex-probe-cwd";
39
+ const RPC_TIMEOUT_MS = 5000;
40
+ const PAGE_LIMIT = 100;
41
+ const MAX_PAGES = 20; // safety cap: 2000 threads is plenty
42
+ const BACKOFF_MIN_MS = 1000;
43
+ const BACKOFF_MAX_MS = 60_000;
44
+ const ROLLOUT_UUID_RE = /([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})\.jsonl$/;
45
+ /** Extract the session uuid from a rollout filename
46
+ * (rollout-<timestamp>-<uuid>.jsonl — the timestamp also contains dashes). */
47
+ export function uuidFromRolloutPath(p) {
48
+ const m = p.match(ROLLOUT_UUID_RE);
49
+ return m ? m[1].toLowerCase() : null;
50
+ }
51
+ /** Find the DESKTOP app's main app-server pid in a process list. Exact path
52
+ * prefix match (Codex.app/Contents/Resources/) keeps the npm-installed codex
53
+ * out; `--analytics-default-enabled` + no `--listen` keeps out the stdio
54
+ * helper children the same app spawns. */
55
+ export function findDesktopAppServerPid(procs) {
56
+ for (const p of procs) {
57
+ const c = p.command;
58
+ if (!c.includes("Codex.app/Contents/Resources/codex"))
59
+ continue;
60
+ if (!/\bapp-server\b/.test(c))
61
+ continue;
62
+ if (!c.includes("--analytics-default-enabled"))
63
+ continue;
64
+ if (c.includes("--listen"))
65
+ continue;
66
+ return p.pid;
67
+ }
68
+ return null;
69
+ }
70
+ /** Parse `lsof -p <pid> -Fn` output into the set of session uuids whose
71
+ * rollout files the process holds open. */
72
+ export function parseLsofSessionUuids(raw) {
73
+ const uuids = new Set();
74
+ for (const line of raw.split("\n")) {
75
+ if (!line.startsWith("n"))
76
+ continue;
77
+ const file = line.slice(1);
78
+ if (!file.includes(".codex/sessions"))
79
+ continue;
80
+ const id = uuidFromRolloutPath(file);
81
+ if (id)
82
+ uuids.add(id);
83
+ }
84
+ return uuids;
85
+ }
86
+ /** Parse ~/.codex/.codex-global-state.json → projectless thread-id set.
87
+ * Tolerant: Electron writes the file atomically so it may briefly not exist
88
+ * or be mid-replace — any failure yields an empty set, never an error. */
89
+ export function parseProjectlessThreadIds(jsonText) {
90
+ try {
91
+ const obj = JSON.parse(jsonText);
92
+ const ids = obj?.["projectless-thread-ids"];
93
+ if (!Array.isArray(ids))
94
+ return new Set();
95
+ return new Set(ids
96
+ .filter((x) => typeof x === "string")
97
+ .map((s) => s.toLowerCase()));
98
+ }
99
+ catch {
100
+ return new Set();
101
+ }
102
+ }
103
+ /** Parse ~/.codex/session_index.jsonl lines ({id, thread_name, ...}) into an
104
+ * id → thread_name map. Bad lines are skipped. */
105
+ export function parseSessionIndexLines(lines) {
106
+ const map = new Map();
107
+ for (const line of lines) {
108
+ const s = line.trim();
109
+ if (!s)
110
+ continue;
111
+ try {
112
+ const o = JSON.parse(s);
113
+ if (typeof o?.id === "string" && typeof o?.thread_name === "string") {
114
+ map.set(o.id.toLowerCase(), o.thread_name);
115
+ }
116
+ }
117
+ catch {
118
+ /* skip bad line */
119
+ }
120
+ }
121
+ return map;
122
+ }
123
+ const PREVIEW_TITLE_MAX = 60;
124
+ /** Title resolution: thread/list name ("app-title") → preview, truncated
125
+ * ("preview") → session_index thread_name ("app-title", degraded fallback) →
126
+ * none. This matches the Codex App sidebar: rows with no generated name still
127
+ * display the thread/list preview instead of hiding behind a no-title state. */
128
+ export function pickThreadTitle(thread, indexName) {
129
+ const name = thread.name?.trim();
130
+ if (name)
131
+ return { title: name, nameSource: "app-title" };
132
+ const preview = thread.preview?.trim();
133
+ if (preview) {
134
+ return {
135
+ title: preview.slice(0, PREVIEW_TITLE_MAX),
136
+ nameSource: "preview",
137
+ };
138
+ }
139
+ const idx = indexName?.trim();
140
+ if (idx)
141
+ return { title: idx, nameSource: "app-title" };
142
+ return { title: null, nameSource: null };
143
+ }
144
+ /** Thread.status → SessionInfo.status: only `active` means running. */
145
+ export function threadStatusToSessionStatus(status) {
146
+ return status?.type === "active" ? "running" : "idle";
147
+ }
148
+ /** Fuse a thread/list row + side-channel signals into the wire SessionInfo. */
149
+ export function threadToSessionInfo(t, extras) {
150
+ // title/nameSource are filled by the caller via pickThreadTitle (it also
151
+ // needs the session_index map, which this pure mapper doesn't see).
152
+ const cwd = t.cwd ?? "";
153
+ return {
154
+ sessionId: t.id,
155
+ projectPath: encodeProjectDir(cwd),
156
+ cwd,
157
+ status: threadStatusToSessionStatus(t.status),
158
+ source: "codex",
159
+ title: null,
160
+ nameSource: null,
161
+ summary: null,
162
+ lastActiveAt: t.updatedAt
163
+ ? new Date(t.updatedAt * 1000).toISOString()
164
+ : null,
165
+ firstMessageAt: t.createdAt
166
+ ? new Date(t.createdAt * 1000).toISOString()
167
+ : null,
168
+ lastAgentMessageAt: null,
169
+ recentMessages: [],
170
+ totalMessages: 0,
171
+ controllable: false, // stampControllable overwrites for bridge-attached ids
172
+ origin: extras.origin,
173
+ openIn: extras.openIn,
174
+ appChat: extras.appChat,
175
+ };
176
+ }
177
+ export function createCodexThreadSource(opts = {}) {
178
+ const log = opts.log ?? (() => { });
179
+ const home = opts.homedir ?? os.homedir();
180
+ const rpcTimeout = opts.rpcTimeoutMs ?? RPC_TIMEOUT_MS;
181
+ const exec = opts.exec ??
182
+ (async (cmd, args) => {
183
+ const { stdout } = await execP(cmd, args, {
184
+ maxBuffer: 16 * 1024 * 1024,
185
+ });
186
+ return { stdout };
187
+ });
188
+ const spawnFn = opts.spawn ??
189
+ ((command, args, o) => nodeSpawn(command, args, {
190
+ cwd: o.cwd,
191
+ stdio: ["pipe", "pipe", "ignore"],
192
+ }));
193
+ let stopped = false;
194
+ let child = null;
195
+ let ready = null;
196
+ let nextId = 0;
197
+ const pending = new Map();
198
+ // Exponential-backoff respawn gate: listSessions() runs on the snapshot
199
+ // interval, so the "timer" is lazy — within the backoff window we return
200
+ // null (→ disk-scan fallback) without touching the child.
201
+ let backoffMs = 0;
202
+ let nextSpawnAllowedAt = 0;
203
+ function failAllPending(err) {
204
+ for (const [, p] of pending) {
205
+ clearTimeout(p.timer);
206
+ p.reject(err);
207
+ }
208
+ pending.clear();
209
+ }
210
+ function noteChildDown(reason) {
211
+ if (child)
212
+ log(`codex-thread-source: child down (${reason})`);
213
+ child = null;
214
+ ready = null;
215
+ failAllPending(new Error(`app-server child down: ${reason}`));
216
+ backoffMs = backoffMs
217
+ ? Math.min(backoffMs * 2, BACKOFF_MAX_MS)
218
+ : BACKOFF_MIN_MS;
219
+ nextSpawnAllowedAt = Date.now() + backoffMs;
220
+ }
221
+ function request(method, params) {
222
+ const c = child;
223
+ if (!c)
224
+ return Promise.reject(new Error("no app-server child"));
225
+ const id = nextId++;
226
+ return new Promise((resolve, reject) => {
227
+ const timer = setTimeout(() => {
228
+ pending.delete(id);
229
+ reject(new Error(`${method} timed out after ${rpcTimeout}ms`));
230
+ }, rpcTimeout);
231
+ pending.set(id, { resolve, reject, timer });
232
+ try {
233
+ c.stdin.write(JSON.stringify({ id, method, params }) + "\n");
234
+ }
235
+ catch (e) {
236
+ clearTimeout(timer);
237
+ pending.delete(id);
238
+ reject(e);
239
+ }
240
+ });
241
+ }
242
+ function ensureReady() {
243
+ if (stopped)
244
+ return Promise.reject(new Error("stopped"));
245
+ if (child && ready)
246
+ return ready;
247
+ if (Date.now() < nextSpawnAllowedAt) {
248
+ return Promise.reject(new Error(`respawn backoff (${backoffMs}ms) in effect`));
249
+ }
250
+ const bin = opts.codexBin ?? resolveCodexBin();
251
+ log(`codex-thread-source: spawning query app-server (${bin})`);
252
+ let c;
253
+ try {
254
+ c = spawnFn(bin, ["app-server"], { cwd: home });
255
+ }
256
+ catch (e) {
257
+ noteChildDown(`spawn failed: ${e.message}`);
258
+ return Promise.reject(e);
259
+ }
260
+ child = c;
261
+ // StringDecoder buffers split multi-byte UTF-8 (CJK thread names/previews
262
+ // arrive in these RPC results) so a chunk boundary mid-character never
263
+ // becomes U+FFFD garbage in the session-list titles.
264
+ const decoder = new StringDecoder("utf8");
265
+ let buf = "";
266
+ c.stdout.on("data", (chunk) => {
267
+ buf += typeof chunk === "string" ? chunk : decoder.write(chunk);
268
+ let nl;
269
+ while ((nl = buf.indexOf("\n")) !== -1) {
270
+ const line = buf.slice(0, nl);
271
+ buf = buf.slice(nl + 1);
272
+ if (!line.trim())
273
+ continue;
274
+ let o;
275
+ try {
276
+ o = JSON.parse(line);
277
+ }
278
+ catch {
279
+ continue;
280
+ }
281
+ // Route responses to their pending request; ignore notifications and
282
+ // server_requests (a read-only client never has anything to approve).
283
+ if (typeof o?.id === "number" && ("result" in o || "error" in o)) {
284
+ const p = pending.get(o.id);
285
+ if (!p)
286
+ continue;
287
+ pending.delete(o.id);
288
+ clearTimeout(p.timer);
289
+ if (o.error) {
290
+ p.reject(new Error(`rpc error ${o.error.code}: ${o.error.message}`));
291
+ }
292
+ else {
293
+ p.resolve(o.result);
294
+ }
295
+ }
296
+ }
297
+ });
298
+ c.on("error", (e) => noteChildDown(`error: ${e.message}`));
299
+ c.on("exit", (code, signal) => noteChildDown(`exit code=${code} signal=${signal}`));
300
+ // Handshake: initialize → (response) → initialized notification.
301
+ // READ-ONLY: nothing else is ever sent (see file-top constraint).
302
+ ready = request("initialize", {
303
+ clientInfo: {
304
+ name: "pinclaw-codex-bridge",
305
+ title: "Nexting",
306
+ version: "0.6.0",
307
+ },
308
+ }).then(() => {
309
+ child?.stdin.write(JSON.stringify({ method: "initialized" }) + "\n");
310
+ });
311
+ return ready;
312
+ }
313
+ /** Paginate thread/list to the full set (newest first by updatedAt). */
314
+ async function listThreads() {
315
+ const all = [];
316
+ let cursor = null;
317
+ for (let page = 0; page < MAX_PAGES; page++) {
318
+ const res = await request("thread/list", {
319
+ // NOTE: wire enum is snake_case ("updated_at"), unlike the camelCase
320
+ // field names — verified live against codex 0.138.0.
321
+ sortKey: "updated_at",
322
+ sortDirection: "desc",
323
+ limit: PAGE_LIMIT,
324
+ cursor,
325
+ });
326
+ const data = Array.isArray(res?.data) ? res.data : [];
327
+ all.push(...data);
328
+ if (!res?.nextCursor || data.length === 0)
329
+ break;
330
+ cursor = res.nextCursor;
331
+ }
332
+ return all;
333
+ }
334
+ const metaCache = new Map();
335
+ const idToPath = new Map();
336
+ async function fileMetaFor(t) {
337
+ let file = t.path ?? idToPath.get(t.id);
338
+ if (file === undefined) {
339
+ // Resolve under opts.homedir (NOT the process home) — tests and any
340
+ // future multi-home setups depend on it.
341
+ file = await findCodexSessionFile(t.id, path.join(home, ".codex", "sessions"));
342
+ idToPath.set(t.id, file);
343
+ }
344
+ if (!file)
345
+ return { origin: null, sum: null };
346
+ let mtimeMs;
347
+ try {
348
+ mtimeMs = (await fsp.stat(file)).mtimeMs;
349
+ }
350
+ catch {
351
+ return { origin: null, sum: null };
352
+ }
353
+ const cached = metaCache.get(file);
354
+ if (cached && cached.mtimeMs === mtimeMs)
355
+ return cached;
356
+ let origin = null;
357
+ try {
358
+ const obj = JSON.parse(await readFirstLine(file));
359
+ if (obj?.type === "session_meta") {
360
+ origin = mapOriginator(obj?.payload?.originator);
361
+ }
362
+ }
363
+ catch {
364
+ /* unreadable/garbled first line → null */
365
+ }
366
+ let sum = null;
367
+ try {
368
+ sum = await summarizeCodexFile(file);
369
+ // Exact incremental count beats the windowed approximation.
370
+ sum.totalMessages = await codexMessageCounter
371
+ .count(file, (await fsp.stat(file)).size)
372
+ .catch(() => sum.totalMessages);
373
+ }
374
+ catch {
375
+ /* summary is best-effort */
376
+ }
377
+ const meta = { mtimeMs, origin, sum };
378
+ metaCache.set(file, meta);
379
+ return meta;
380
+ }
381
+ // --- session_index.jsonl cache (by mtime)
382
+ let indexCache = null;
383
+ async function sessionIndexMap() {
384
+ const file = path.join(home, ".codex", "session_index.jsonl");
385
+ let mtimeMs;
386
+ try {
387
+ mtimeMs = (await fsp.stat(file)).mtimeMs;
388
+ }
389
+ catch {
390
+ return new Map();
391
+ }
392
+ if (indexCache && indexCache.mtimeMs === mtimeMs)
393
+ return indexCache.map;
394
+ try {
395
+ const raw = await fsp.readFile(file, "utf8");
396
+ indexCache = { mtimeMs, map: parseSessionIndexLines(raw.split("\n")) };
397
+ return indexCache.map;
398
+ }
399
+ catch {
400
+ return indexCache?.map ?? new Map();
401
+ }
402
+ }
403
+ /** Desktop-app open-session uuids: ps → main app-server pid → lsof.
404
+ * App not running / lsof failed → empty set, never an error. Runs once per
405
+ * listSessions call (the snapshot loop is already throttled). */
406
+ async function desktopOpenUuids() {
407
+ try {
408
+ const { stdout } = await exec("ps", ["-axww", "-o", "pid,ppid,command"]);
409
+ const pid = findDesktopAppServerPid(parsePsOutput(stdout));
410
+ if (!pid)
411
+ return new Set();
412
+ const { stdout: lsofOut } = await exec("lsof", [
413
+ "-p",
414
+ String(pid),
415
+ "-Fn",
416
+ ]);
417
+ return parseLsofSessionUuids(lsofOut);
418
+ }
419
+ catch {
420
+ return new Set();
421
+ }
422
+ }
423
+ async function projectlessIds() {
424
+ try {
425
+ const raw = await fsp.readFile(path.join(home, ".codex", ".codex-global-state.json"), "utf8");
426
+ return parseProjectlessThreadIds(raw);
427
+ }
428
+ catch {
429
+ return new Set(); // Electron atomic write window / file missing
430
+ }
431
+ }
432
+ async function listSessions() {
433
+ try {
434
+ await ensureReady();
435
+ const threads = await listThreads();
436
+ // Success resets the respawn backoff.
437
+ backoffMs = 0;
438
+ nextSpawnAllowedAt = 0;
439
+ const [openSet, appChatIds, indexMap] = await Promise.all([
440
+ desktopOpenUuids(),
441
+ projectlessIds(),
442
+ sessionIndexMap(),
443
+ ]);
444
+ const sessions = [];
445
+ for (const t of threads) {
446
+ if (!t?.id)
447
+ continue;
448
+ if (t.cwd === CODEX_PROBE_CWD)
449
+ continue; // our own probe sessions
450
+ const idLc = t.id.toLowerCase();
451
+ const meta = await fileMetaFor(t);
452
+ const info = threadToSessionInfo(t, {
453
+ origin: meta.origin,
454
+ openIn: openSet.has(idLc) ? "desktop-app" : null,
455
+ appChat: appChatIds.has(idLc),
456
+ });
457
+ const { title, nameSource } = pickThreadTitle(t, indexMap.get(idLc));
458
+ info.title = title;
459
+ info.nameSource = nameSource;
460
+ if (meta.sum) {
461
+ info.totalMessages = meta.sum.totalMessages;
462
+ info.lastAgentMessageAt = meta.sum.lastAgentMessageAt;
463
+ info.summary = meta.sum.summary;
464
+ if (!info.firstMessageAt && meta.sum.firstMessageAt)
465
+ info.firstMessageAt = meta.sum.firstMessageAt;
466
+ if (!info.title && meta.sum.title) {
467
+ info.title = meta.sum.title;
468
+ info.nameSource = "user-text";
469
+ }
470
+ }
471
+ sessions.push(info);
472
+ }
473
+ return sessions;
474
+ }
475
+ catch (e) {
476
+ log(`codex-thread-source: falling back to disk scan: ${e.message}`);
477
+ return null;
478
+ }
479
+ }
480
+ return {
481
+ listSessions,
482
+ stop: () => {
483
+ stopped = true;
484
+ failAllPending(new Error("stopped"));
485
+ try {
486
+ child?.kill();
487
+ }
488
+ catch {
489
+ /* already gone */
490
+ }
491
+ child = null;
492
+ ready = null;
493
+ },
494
+ };
495
+ }