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.
- package/README.md +252 -0
- package/dist/attach-manager.js +259 -0
- package/dist/bridge.js +931 -0
- package/dist/cli-args.js +14 -0
- package/dist/cli.js +742 -0
- package/dist/codex-prompts.js +148 -0
- package/dist/codex-thread-source.js +495 -0
- package/dist/codex-transcript.js +415 -0
- package/dist/dev-server.js +126 -0
- package/dist/discovery.js +111 -0
- package/dist/e2e/codec.js +119 -0
- package/dist/e2e/crypto.js +127 -0
- package/dist/e2e/key-store.js +48 -0
- package/dist/e2e/keychain-identity.js +29 -0
- package/dist/engine/adapter.js +5 -0
- package/dist/engine/claude-adapter.js +77 -0
- package/dist/engine/codex-adapter.js +593 -0
- package/dist/file-preview.js +292 -0
- package/dist/hub-protocol.js +28 -0
- package/dist/hub-server.js +106 -0
- package/dist/hub.js +84 -0
- package/dist/install-util.js +33 -0
- package/dist/local-shell.js +32 -0
- package/dist/mcp-config.js +230 -0
- package/dist/mcp-device-proxy.js +501 -0
- package/dist/media-hydrator.js +222 -0
- package/dist/message-counter.js +79 -0
- package/dist/phone-probe.js +55 -0
- package/dist/prompt-detector.js +213 -0
- package/dist/protocol.js +3 -0
- package/dist/pty-mirror.js +80 -0
- package/dist/pty-spawn.js +53 -0
- package/dist/scanner.js +422 -0
- package/dist/self-update.js +122 -0
- package/dist/session-map.js +15 -0
- package/dist/session-runner.js +131 -0
- package/dist/shell.js +104 -0
- package/dist/skills-scanner.js +167 -0
- package/dist/stdin-encode.js +32 -0
- package/dist/stream-translate.js +122 -0
- package/dist/terminal-render.js +29 -0
- package/dist/transcript-watcher.js +138 -0
- package/dist/transcript.js +346 -0
- package/dist/turn-probe.js +152 -0
- package/dist/types.js +2 -0
- package/dist/watch-manager.js +77 -0
- package/install-cc.sh +90 -0
- package/install-codex.sh +97 -0
- package/package.json +39 -0
- 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
|
+
}
|