@childclm/codexx 0.1.0
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/LICENSE +21 -0
- package/README.md +122 -0
- package/bin/codexx.js +3 -0
- package/package.json +40 -0
- package/src/index.js +1176 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 clm
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# codexx
|
|
2
|
+
|
|
3
|
+
`codexx` 是一个跑在 `Codex CLI` 外层的跨平台启动器,目标是把这几件事收进一个终端入口里:
|
|
4
|
+
|
|
5
|
+
- 项目级主线 / 分支会话管理
|
|
6
|
+
- 多 `provider` / `apiKey` 切换
|
|
7
|
+
- 基于 Codex 原生会话的恢复、分叉、删除
|
|
8
|
+
|
|
9
|
+
它不替代 `codex`,而是建立在 `codex` 之上。
|
|
10
|
+
|
|
11
|
+
## 特性
|
|
12
|
+
|
|
13
|
+
- 跨平台:Node.js 实现,目标支持 macOS 与 Windows
|
|
14
|
+
- 项目隔离:项目元数据保存在 `<project>/.codexx/`
|
|
15
|
+
- Provider 全局共享:全局配置保存在用户配置目录
|
|
16
|
+
- 会话树:支持主线会话、分支会话、父子关系
|
|
17
|
+
- 恢复与分叉:底层直接调用 `codex resume` / `codex fork`
|
|
18
|
+
|
|
19
|
+
## 前置要求
|
|
20
|
+
|
|
21
|
+
- 已安装 Node.js `>=18`
|
|
22
|
+
- 已安装并可运行 `codex`
|
|
23
|
+
|
|
24
|
+
先确认:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node -v
|
|
28
|
+
codex --version
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 安装
|
|
32
|
+
|
|
33
|
+
### 本地开发安装
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install -g .
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### 发布后安装
|
|
40
|
+
|
|
41
|
+
如果包名可用并已发布到 npm:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install -g @childclm/codexx
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## 使用
|
|
48
|
+
|
|
49
|
+
在具体项目目录中运行:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
codexx
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
常见子命令:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
codexx init
|
|
59
|
+
codexx list
|
|
60
|
+
codexx providers
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## 存储位置
|
|
64
|
+
|
|
65
|
+
### Codex 原生数据
|
|
66
|
+
|
|
67
|
+
这些数据由 `codex` 自己维护:
|
|
68
|
+
|
|
69
|
+
- `~/.codex/sessions/`
|
|
70
|
+
- `~/.codex/session_index.jsonl`
|
|
71
|
+
- `~/.codex/history.jsonl`
|
|
72
|
+
|
|
73
|
+
### codexx 全局数据
|
|
74
|
+
|
|
75
|
+
macOS / Linux:
|
|
76
|
+
|
|
77
|
+
```text
|
|
78
|
+
~/.config/codexx/providers.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Windows:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
%APPDATA%/codexx/providers.json
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### codexx 项目数据
|
|
88
|
+
|
|
89
|
+
```text
|
|
90
|
+
<project>/.codexx/project.json
|
|
91
|
+
<project>/.codexx/sessions.json
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## 发布前检查
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
npm run check
|
|
98
|
+
npm run pack:dry
|
|
99
|
+
npm run publish:dry
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## 当前状态
|
|
103
|
+
|
|
104
|
+
截至 2026-07-17:
|
|
105
|
+
|
|
106
|
+
- 本地 `npm publish --dry-run` 可通过
|
|
107
|
+
- 已切换为 scoped 包名 `@childclm/codexx`
|
|
108
|
+
- 如果要正式发布,建议先执行:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
npm view @childclm/codexx name version
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
首次正式发布 scoped 包时,使用:
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm publish --access public
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
package/bin/codexx.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@childclm/codexx",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Cross-platform launcher for Codex CLI with project-scoped session trees and provider switching.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"codexx": "bin/codexx.js"
|
|
8
|
+
},
|
|
9
|
+
"publishConfig": {
|
|
10
|
+
"access": "public"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"preferGlobal": true,
|
|
19
|
+
"scripts": {
|
|
20
|
+
"start": "node bin/codexx.js",
|
|
21
|
+
"check": "node --check src/index.js",
|
|
22
|
+
"test": "node bin/codexx.js --help",
|
|
23
|
+
"pack:dry": "npm pack --dry-run",
|
|
24
|
+
"publish:dry": "npm publish --dry-run"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"codex",
|
|
28
|
+
"cli",
|
|
29
|
+
"openai",
|
|
30
|
+
"session-manager",
|
|
31
|
+
"terminal",
|
|
32
|
+
"cross-platform"
|
|
33
|
+
],
|
|
34
|
+
"author": "clm",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"type": "commonjs",
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=18.0.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,1176 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const readline = require("readline");
|
|
5
|
+
const crypto = require("crypto");
|
|
6
|
+
const { spawn, spawnSync } = require("child_process");
|
|
7
|
+
|
|
8
|
+
const APP_NAME = "codexx";
|
|
9
|
+
const HOME = os.homedir();
|
|
10
|
+
const CONFIG_ROOT =
|
|
11
|
+
process.platform === "win32"
|
|
12
|
+
? path.join(
|
|
13
|
+
process.env.APPDATA || path.join(HOME, "AppData", "Roaming"),
|
|
14
|
+
APP_NAME,
|
|
15
|
+
)
|
|
16
|
+
: path.join(HOME, ".config", APP_NAME);
|
|
17
|
+
const PROVIDERS_FILE = path.join(CONFIG_ROOT, "providers.json");
|
|
18
|
+
const SETTINGS_FILE = path.join(CONFIG_ROOT, "settings.json");
|
|
19
|
+
const CODEX_HOME = path.join(HOME, ".codex");
|
|
20
|
+
const CODEX_SESSIONS_DIR = path.join(CODEX_HOME, "sessions");
|
|
21
|
+
|
|
22
|
+
function ensureDir(dir) {
|
|
23
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function readJson(file, fallback) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
} catch {
|
|
30
|
+
return fallback;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeJson(file, data) {
|
|
35
|
+
ensureDir(path.dirname(file));
|
|
36
|
+
fs.writeFileSync(file, JSON.stringify(data, null, 2) + "\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function normalizePath(p) {
|
|
40
|
+
return path.resolve(p);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function md5(text) {
|
|
44
|
+
return crypto.createHash("md5").update(String(text)).digest("hex");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getProjectRoot(cwd = process.cwd()) {
|
|
48
|
+
const out = spawnSync("git", ["rev-parse", "--show-toplevel"], {
|
|
49
|
+
cwd,
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
});
|
|
52
|
+
if (out.status === 0) {
|
|
53
|
+
const root = (out.stdout || "").trim();
|
|
54
|
+
if (root) return normalizePath(root);
|
|
55
|
+
}
|
|
56
|
+
return normalizePath(cwd);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isHomeProject(root) {
|
|
60
|
+
return normalizePath(root) === normalizePath(HOME);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function ensureUsableProject(cwd = process.cwd()) {
|
|
64
|
+
const root = getProjectRoot(cwd);
|
|
65
|
+
if (isHomeProject(root)) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
root,
|
|
69
|
+
reason:
|
|
70
|
+
"当前目录落在 HOME 根目录,容易把不相关会话全搅一起。请先 cd 到具体项目目录再运行 codexx。",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
return { ok: true, root };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function getProjectPaths(cwd = process.cwd()) {
|
|
77
|
+
const root = getProjectRoot(cwd);
|
|
78
|
+
const stateDir = path.join(root, ".codexx");
|
|
79
|
+
return {
|
|
80
|
+
root,
|
|
81
|
+
stateDir,
|
|
82
|
+
projectFile: path.join(stateDir, "project.json"),
|
|
83
|
+
sessionsFile: path.join(stateDir, "sessions.json"),
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function loadProviders() {
|
|
88
|
+
return readJson(PROVIDERS_FILE, {
|
|
89
|
+
default: null,
|
|
90
|
+
providers: [],
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function loadSettings() {
|
|
95
|
+
return readJson(SETTINGS_FILE, {
|
|
96
|
+
codexCommand: "codex",
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function loadProject(cwd = process.cwd()) {
|
|
101
|
+
const { root, projectFile } = getProjectPaths(cwd);
|
|
102
|
+
return readJson(projectFile, {
|
|
103
|
+
projectId: md5(root),
|
|
104
|
+
name: path.basename(root),
|
|
105
|
+
root,
|
|
106
|
+
defaultProvider: null,
|
|
107
|
+
provider: null,
|
|
108
|
+
mainSessionId: null,
|
|
109
|
+
lastSessionId: null,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function saveProject(state, cwd = process.cwd()) {
|
|
114
|
+
const { projectFile } = getProjectPaths(cwd);
|
|
115
|
+
ensureDir(path.dirname(projectFile));
|
|
116
|
+
writeJson(projectFile, state);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function loadProjectSessionsMeta(cwd = process.cwd()) {
|
|
120
|
+
const { sessionsFile } = getProjectPaths(cwd);
|
|
121
|
+
return readJson(sessionsFile, { sessions: [] });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function saveProjectSessionsMeta(data, cwd = process.cwd()) {
|
|
125
|
+
const { sessionsFile } = getProjectPaths(cwd);
|
|
126
|
+
ensureDir(path.dirname(sessionsFile));
|
|
127
|
+
writeJson(sessionsFile, data);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function detectCodex() {
|
|
131
|
+
const settings = loadSettings();
|
|
132
|
+
return settings.codexCommand || "codex";
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function codexPath() {
|
|
136
|
+
const finder = process.platform === "win32" ? "where" : "which";
|
|
137
|
+
const out = spawnSync(finder, ["codex"], { encoding: "utf8" });
|
|
138
|
+
return (out.stdout || "").trim().split(/\r?\n/)[0] || "";
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function clearScreen() {
|
|
142
|
+
process.stdout.write("\x1b[2J\x1b[0f");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function prompt(rl, text) {
|
|
146
|
+
return new Promise((resolve) => rl.question(text, resolve));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function setRawMode(enabled) {
|
|
150
|
+
if (typeof process.stdin.setRawMode === "function") {
|
|
151
|
+
process.stdin.setRawMode(enabled);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function flushStdinBuffer() {
|
|
156
|
+
let chunk;
|
|
157
|
+
do {
|
|
158
|
+
chunk = process.stdin.read();
|
|
159
|
+
} while (chunk !== null);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function askText(rl, label) {
|
|
163
|
+
setRawMode(false);
|
|
164
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
165
|
+
flushStdinBuffer();
|
|
166
|
+
const answer = await prompt(rl, label);
|
|
167
|
+
setRawMode(true);
|
|
168
|
+
return answer;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function pause(rl, text = "按回车继续...") {
|
|
172
|
+
await askText(rl, `\n${text}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function renderMenu(title, items, selected, footer = "") {
|
|
176
|
+
clearScreen();
|
|
177
|
+
console.log(title);
|
|
178
|
+
console.log("=".repeat(Math.max(title.length, 18)));
|
|
179
|
+
items.forEach((item, index) => {
|
|
180
|
+
const prefix = index === selected ? "❯" : " ";
|
|
181
|
+
console.log(`${prefix} [${index + 1}] ${item}`);
|
|
182
|
+
});
|
|
183
|
+
if (footer) console.log(footer);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderContextMenu(title, contextLines, items, selected, footer = "") {
|
|
187
|
+
clearScreen();
|
|
188
|
+
console.log(title);
|
|
189
|
+
console.log("=".repeat(Math.max(title.length, 18)));
|
|
190
|
+
for (const line of contextLines) {
|
|
191
|
+
console.log(line);
|
|
192
|
+
}
|
|
193
|
+
if (contextLines.length) console.log("");
|
|
194
|
+
items.forEach((item, index) => {
|
|
195
|
+
const prefix = index === selected ? "❯" : " ";
|
|
196
|
+
console.log(`${prefix} [${index + 1}] ${item}`);
|
|
197
|
+
});
|
|
198
|
+
if (footer) console.log(footer);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function readKey() {
|
|
202
|
+
return new Promise((resolve) => {
|
|
203
|
+
const startedAt = Date.now();
|
|
204
|
+
const onKeypress = (_, key) => {
|
|
205
|
+
if (!key) return;
|
|
206
|
+
if (Date.now() - startedAt < 35) return;
|
|
207
|
+
process.stdin.off("keypress", onKeypress);
|
|
208
|
+
if (key.name === "up") return resolve("up");
|
|
209
|
+
if (key.name === "down") return resolve("down");
|
|
210
|
+
if (key.name === "return") return resolve("enter");
|
|
211
|
+
if (key.name === "escape") return resolve("escape");
|
|
212
|
+
if (key.ctrl && key.name === "c") return resolve("exit");
|
|
213
|
+
if (/^[1-9]$/.test(key.sequence || "")) {
|
|
214
|
+
return resolve({ type: "digit", value: Number(key.sequence) });
|
|
215
|
+
}
|
|
216
|
+
return resolve("other");
|
|
217
|
+
};
|
|
218
|
+
process.stdin.on("keypress", onKeypress);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
async function selectIndex(title, items, footer = "") {
|
|
223
|
+
if (!items.length) return null;
|
|
224
|
+
let selected = 0;
|
|
225
|
+
let showPicked = false;
|
|
226
|
+
while (true) {
|
|
227
|
+
const effectiveFooter = showPicked
|
|
228
|
+
? `${footer} · 当前选中: [${selected + 1}] ${items[selected]}`
|
|
229
|
+
: footer;
|
|
230
|
+
renderMenu(title, items, selected, effectiveFooter);
|
|
231
|
+
const key = await readKey();
|
|
232
|
+
if (typeof key === "object" && key?.type === "digit") {
|
|
233
|
+
const idx = key.value - 1;
|
|
234
|
+
if (idx >= 0 && idx < items.length) {
|
|
235
|
+
selected = idx;
|
|
236
|
+
showPicked = true;
|
|
237
|
+
}
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (key === "up") {
|
|
241
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
242
|
+
showPicked = false;
|
|
243
|
+
} else if (key === "down") {
|
|
244
|
+
selected = (selected + 1) % items.length;
|
|
245
|
+
showPicked = false;
|
|
246
|
+
}
|
|
247
|
+
else if (key === "enter") {
|
|
248
|
+
clearScreen();
|
|
249
|
+
return selected;
|
|
250
|
+
} else if (key === "escape" || key === "exit") {
|
|
251
|
+
clearScreen();
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function selectIndexWithContext(title, contextLines, items, footer = "") {
|
|
258
|
+
if (!items.length) return null;
|
|
259
|
+
let selected = 0;
|
|
260
|
+
let showPicked = false;
|
|
261
|
+
while (true) {
|
|
262
|
+
const effectiveFooter = showPicked
|
|
263
|
+
? `${footer} · 当前选中: [${selected + 1}] ${items[selected]}`
|
|
264
|
+
: footer;
|
|
265
|
+
renderContextMenu(title, contextLines, items, selected, effectiveFooter);
|
|
266
|
+
const key = await readKey();
|
|
267
|
+
if (typeof key === "object" && key?.type === "digit") {
|
|
268
|
+
const idx = key.value - 1;
|
|
269
|
+
if (idx >= 0 && idx < items.length) {
|
|
270
|
+
selected = idx;
|
|
271
|
+
showPicked = true;
|
|
272
|
+
}
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (key === "up") {
|
|
276
|
+
selected = (selected - 1 + items.length) % items.length;
|
|
277
|
+
showPicked = false;
|
|
278
|
+
} else if (key === "down") {
|
|
279
|
+
selected = (selected + 1) % items.length;
|
|
280
|
+
showPicked = false;
|
|
281
|
+
}
|
|
282
|
+
else if (key === "enter") {
|
|
283
|
+
clearScreen();
|
|
284
|
+
return selected;
|
|
285
|
+
} else if (key === "escape" || key === "exit") {
|
|
286
|
+
clearScreen();
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function isWithinProject(targetPath, projectRoot) {
|
|
293
|
+
const normalizedTarget = normalizePath(targetPath);
|
|
294
|
+
const normalizedProject = normalizePath(projectRoot);
|
|
295
|
+
return (
|
|
296
|
+
normalizedTarget === normalizedProject ||
|
|
297
|
+
normalizedTarget.startsWith(normalizedProject + path.sep)
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function walkFiles(dir, acc = []) {
|
|
302
|
+
if (!fs.existsSync(dir)) return acc;
|
|
303
|
+
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
304
|
+
for (const entry of entries) {
|
|
305
|
+
const full = path.join(dir, entry.name);
|
|
306
|
+
if (entry.isDirectory()) walkFiles(full, acc);
|
|
307
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl")) acc.push(full);
|
|
308
|
+
}
|
|
309
|
+
return acc;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function parseSessionMeta(file) {
|
|
313
|
+
try {
|
|
314
|
+
const text = fs.readFileSync(file, "utf8");
|
|
315
|
+
const firstLine = text.split(/\r?\n/, 1)[0];
|
|
316
|
+
if (!firstLine) return null;
|
|
317
|
+
const item = JSON.parse(firstLine);
|
|
318
|
+
if (item.type !== "session_meta" || !item.payload) return null;
|
|
319
|
+
return {
|
|
320
|
+
id: item.payload.id,
|
|
321
|
+
cwd: item.payload.cwd,
|
|
322
|
+
createdAt: item.payload.timestamp || item.timestamp,
|
|
323
|
+
modelProvider: item.payload.model_provider || "",
|
|
324
|
+
originator: item.payload.originator || "",
|
|
325
|
+
file,
|
|
326
|
+
};
|
|
327
|
+
} catch {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function listOfficialProjectSessions(projectRoot) {
|
|
333
|
+
const files = walkFiles(CODEX_SESSIONS_DIR);
|
|
334
|
+
return files
|
|
335
|
+
.map(parseSessionMeta)
|
|
336
|
+
.filter(Boolean)
|
|
337
|
+
.filter((item) => item.cwd && isWithinProject(item.cwd, projectRoot))
|
|
338
|
+
.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function hasOfficialSession(sessionId, projectRoot) {
|
|
342
|
+
return listOfficialProjectSessions(projectRoot).some(
|
|
343
|
+
(item) => item.id === sessionId,
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function buildCodexEnv(provider) {
|
|
348
|
+
return {
|
|
349
|
+
OPENAI_API_KEY: provider.apiKey,
|
|
350
|
+
OPENAI_BASE_URL: provider.baseUrl || process.env.OPENAI_BASE_URL,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function runCodex(args, env, cwd = process.cwd()) {
|
|
355
|
+
return new Promise((resolve, reject) => {
|
|
356
|
+
const cmd = detectCodex();
|
|
357
|
+
const child = spawn(cmd, args, {
|
|
358
|
+
cwd,
|
|
359
|
+
stdio: "inherit",
|
|
360
|
+
env: { ...process.env, ...env },
|
|
361
|
+
});
|
|
362
|
+
child.on("error", reject);
|
|
363
|
+
child.on("exit", (code) => resolve(code ?? 0));
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function maybeLinkLatestSession(projectRoot, providerName, startedAtMs) {
|
|
368
|
+
const sessions = listOfficialProjectSessions(projectRoot);
|
|
369
|
+
const latest = sessions.find(
|
|
370
|
+
(item) => new Date(item.createdAt).getTime() >= startedAtMs - 5000,
|
|
371
|
+
);
|
|
372
|
+
return latest ? latest.id : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function maskKey(key) {
|
|
376
|
+
if (!key) return "(空)";
|
|
377
|
+
if (key.length <= 8) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
|
378
|
+
return `${key.slice(0, 4)}***${key.slice(-4)}`;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function nowIso() {
|
|
382
|
+
return new Date().toISOString();
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function defaultTitle(providerName, kind) {
|
|
386
|
+
const d = new Date();
|
|
387
|
+
const pad = (v) => String(v).padStart(2, "0");
|
|
388
|
+
const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
389
|
+
return `${local} · ${providerName} · ${kind}`;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function upsertSessionMeta(session, cwd = process.cwd()) {
|
|
393
|
+
const data = loadProjectSessionsMeta(cwd);
|
|
394
|
+
const idx = data.sessions.findIndex((item) => item.sessionId === session.sessionId);
|
|
395
|
+
if (idx >= 0) data.sessions[idx] = { ...data.sessions[idx], ...session };
|
|
396
|
+
else data.sessions.push(session);
|
|
397
|
+
saveProjectSessionsMeta(data, cwd);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function removeSessionMeta(sessionId, cwd = process.cwd()) {
|
|
401
|
+
const data = loadProjectSessionsMeta(cwd);
|
|
402
|
+
data.sessions = data.sessions.filter((item) => item.sessionId !== sessionId);
|
|
403
|
+
saveProjectSessionsMeta(data, cwd);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function getSessionMeta(sessionId, cwd = process.cwd()) {
|
|
407
|
+
return loadProjectSessionsMeta(cwd).sessions.find((item) => item.sessionId === sessionId) || null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function listProjectSessionsWithMeta(cwd = process.cwd()) {
|
|
411
|
+
const project = loadProject(cwd);
|
|
412
|
+
const official = listOfficialProjectSessions(project.root);
|
|
413
|
+
const metaMap = new Map(
|
|
414
|
+
loadProjectSessionsMeta(cwd).sessions.map((item) => [item.sessionId, item]),
|
|
415
|
+
);
|
|
416
|
+
return official.map((session) => {
|
|
417
|
+
const meta = metaMap.get(session.id) || {};
|
|
418
|
+
return {
|
|
419
|
+
sessionId: session.id,
|
|
420
|
+
createdAt: session.createdAt,
|
|
421
|
+
updatedAt: meta.updatedAt || session.createdAt,
|
|
422
|
+
title: meta.title || defaultTitle(meta.provider || "unknown", meta.kind || "main"),
|
|
423
|
+
kind: meta.kind || "main",
|
|
424
|
+
parentSessionId: meta.parentSessionId || null,
|
|
425
|
+
provider: meta.provider || null,
|
|
426
|
+
isMain: Boolean(meta.isMain),
|
|
427
|
+
cwd: session.cwd,
|
|
428
|
+
};
|
|
429
|
+
}).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function hydrateProjectMetadata(cwd = process.cwd()) {
|
|
433
|
+
const project = loadProject(cwd);
|
|
434
|
+
const data = loadProjectSessionsMeta(cwd);
|
|
435
|
+
const official = listOfficialProjectSessions(project.root);
|
|
436
|
+
const officialIds = new Set(official.map((item) => item.id));
|
|
437
|
+
let changed = false;
|
|
438
|
+
|
|
439
|
+
data.sessions = data.sessions
|
|
440
|
+
.filter((item) => {
|
|
441
|
+
const keep = officialIds.has(item.sessionId);
|
|
442
|
+
if (!keep) changed = true;
|
|
443
|
+
return keep;
|
|
444
|
+
})
|
|
445
|
+
.map((item) => {
|
|
446
|
+
const next = { ...item };
|
|
447
|
+
if (!next.kind) {
|
|
448
|
+
next.kind = next.parentSessionId ? "branch" : "main";
|
|
449
|
+
changed = true;
|
|
450
|
+
}
|
|
451
|
+
if (!next.title) {
|
|
452
|
+
next.title = defaultTitle(next.provider || "unknown", next.kind);
|
|
453
|
+
changed = true;
|
|
454
|
+
}
|
|
455
|
+
if (!next.createdAt) {
|
|
456
|
+
next.createdAt = next.updatedAt || nowIso();
|
|
457
|
+
changed = true;
|
|
458
|
+
}
|
|
459
|
+
if (!next.updatedAt) {
|
|
460
|
+
next.updatedAt = next.createdAt || nowIso();
|
|
461
|
+
changed = true;
|
|
462
|
+
}
|
|
463
|
+
if (typeof next.isMain !== "boolean") {
|
|
464
|
+
next.isMain = next.kind === "main";
|
|
465
|
+
changed = true;
|
|
466
|
+
}
|
|
467
|
+
return next;
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
if (!project.mainSessionId) {
|
|
471
|
+
const main = data.sessions.find((item) => item.isMain || item.kind === "main");
|
|
472
|
+
if (main) {
|
|
473
|
+
project.mainSessionId = main.sessionId;
|
|
474
|
+
changed = true;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
if (changed) {
|
|
479
|
+
saveProjectSessionsMeta(data, cwd);
|
|
480
|
+
saveProject(project, cwd);
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function getProviderForSession(sessionId, cwd = process.cwd()) {
|
|
485
|
+
return getSessionMeta(sessionId, cwd)?.provider || null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function getCurrentMainSession(projectState, cwd = process.cwd()) {
|
|
489
|
+
const sessions = listProjectSessionsWithMeta(cwd);
|
|
490
|
+
if (projectState.mainSessionId) {
|
|
491
|
+
const hit = sessions.find((item) => item.sessionId === projectState.mainSessionId);
|
|
492
|
+
if (hit) return hit;
|
|
493
|
+
}
|
|
494
|
+
return sessions.find((item) => item.kind === "main" || item.isMain) || null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function countChildBranches(sessionId, cwd = process.cwd()) {
|
|
498
|
+
return loadProjectSessionsMeta(cwd).sessions.filter(
|
|
499
|
+
(item) => item.parentSessionId === sessionId,
|
|
500
|
+
).length;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function getChildSessions(sessionId, cwd = process.cwd()) {
|
|
504
|
+
return listProjectSessionsWithMeta(cwd)
|
|
505
|
+
.filter((item) => item.parentSessionId === sessionId)
|
|
506
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function getMainSessions(cwd = process.cwd()) {
|
|
510
|
+
return listProjectSessionsWithMeta(cwd)
|
|
511
|
+
.filter((item) => item.kind === "main" || item.isMain || !item.parentSessionId)
|
|
512
|
+
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
function formatSessionLabel(session, cwd = process.cwd()) {
|
|
516
|
+
const childCount = getChildSessions(session.sessionId, cwd).length;
|
|
517
|
+
const role = session.kind === "branch" ? "branch" : "main";
|
|
518
|
+
return `${session.title} | ${role} | ${session.provider || "未知 provider"} | 分支:${childCount}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function chooseProviderMenu(rl, projectState, providersState) {
|
|
522
|
+
if (!providersState.providers.length) return null;
|
|
523
|
+
const titles = providersState.providers.map((provider) => {
|
|
524
|
+
const marker =
|
|
525
|
+
provider.name === (projectState.provider || projectState.defaultProvider)
|
|
526
|
+
? " (当前项目)"
|
|
527
|
+
: "";
|
|
528
|
+
return `${provider.name}${marker}`;
|
|
529
|
+
});
|
|
530
|
+
const idx = await selectIndex(
|
|
531
|
+
"选择 provider",
|
|
532
|
+
titles,
|
|
533
|
+
"↑↓/数字 选择 · Enter 确认 · Esc 返回",
|
|
534
|
+
);
|
|
535
|
+
if (idx === null) return null;
|
|
536
|
+
return providersState.providers[idx];
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function providerManagementMenu(rl, cwd = process.cwd()) {
|
|
540
|
+
hydrateProjectMetadata(cwd);
|
|
541
|
+
while (true) {
|
|
542
|
+
const projectState = loadProject(cwd);
|
|
543
|
+
const providersState = loadProviders();
|
|
544
|
+
const action = await selectIndex(
|
|
545
|
+
"Provider 管理",
|
|
546
|
+
["新增/更新 provider", "切换当前项目 provider", "查看 provider 列表", "删除 provider", "返回"],
|
|
547
|
+
"↑↓/数字 选择 · Enter 确认 · Esc 返回",
|
|
548
|
+
);
|
|
549
|
+
if (action === null || action === 4) return;
|
|
550
|
+
|
|
551
|
+
if (action === 0) {
|
|
552
|
+
clearScreen();
|
|
553
|
+
console.log("新增/更新 provider");
|
|
554
|
+
console.log("================");
|
|
555
|
+
const name = (await askText(rl, "provider 名称: ")).trim();
|
|
556
|
+
if (!name) {
|
|
557
|
+
await pause(rl, "名称不能为空,按回车返回...");
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const apiKey = (await askText(rl, "api-key: ")).trim();
|
|
561
|
+
const baseUrl = (await askText(rl, "base url (可空): ")).trim();
|
|
562
|
+
const modelProvider = (await askText(
|
|
563
|
+
rl,
|
|
564
|
+
"codex provider 名称 (可空): ",
|
|
565
|
+
)).trim();
|
|
566
|
+
const provider = { name, apiKey, baseUrl, modelProvider };
|
|
567
|
+
providersState.providers = providersState.providers.filter(
|
|
568
|
+
(item) => item.name !== name,
|
|
569
|
+
);
|
|
570
|
+
providersState.providers.push(provider);
|
|
571
|
+
if (!providersState.default) providersState.default = name;
|
|
572
|
+
writeJson(PROVIDERS_FILE, providersState);
|
|
573
|
+
clearScreen();
|
|
574
|
+
console.log("已保存 provider:", name);
|
|
575
|
+
await pause(rl);
|
|
576
|
+
continue;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (action === 1) {
|
|
580
|
+
const selected = await chooseProviderMenu(rl, projectState, providersState);
|
|
581
|
+
if (!selected) continue;
|
|
582
|
+
projectState.provider = selected.name;
|
|
583
|
+
projectState.defaultProvider = selected.name;
|
|
584
|
+
saveProject(projectState, cwd);
|
|
585
|
+
clearScreen();
|
|
586
|
+
console.log("当前项目 provider 已切换为:", selected.name);
|
|
587
|
+
await pause(rl);
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (action === 2) {
|
|
592
|
+
clearScreen();
|
|
593
|
+
console.log("Provider 列表");
|
|
594
|
+
console.log("=============");
|
|
595
|
+
if (!providersState.providers.length) {
|
|
596
|
+
console.log("(空)");
|
|
597
|
+
} else {
|
|
598
|
+
providersState.providers.forEach((item, index) => {
|
|
599
|
+
console.log(
|
|
600
|
+
`${index + 1}. ${item.name} | key=${maskKey(item.apiKey)} | base=${item.baseUrl || "(默认)"}`,
|
|
601
|
+
);
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
await pause(rl);
|
|
605
|
+
continue;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (action === 3) {
|
|
609
|
+
if (!providersState.providers.length) {
|
|
610
|
+
clearScreen();
|
|
611
|
+
console.log("没有可删除的 provider。");
|
|
612
|
+
await pause(rl);
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const idx = await selectIndex(
|
|
616
|
+
"删除 provider",
|
|
617
|
+
providersState.providers.map((item) => item.name),
|
|
618
|
+
"↑↓/数字 选择 · Enter 删除 · Esc 返回",
|
|
619
|
+
);
|
|
620
|
+
if (idx === null) continue;
|
|
621
|
+
const removed = providersState.providers[idx];
|
|
622
|
+
providersState.providers.splice(idx, 1);
|
|
623
|
+
if (providersState.default === removed.name) {
|
|
624
|
+
providersState.default = providersState.providers[0]?.name || null;
|
|
625
|
+
}
|
|
626
|
+
writeJson(PROVIDERS_FILE, providersState);
|
|
627
|
+
if (projectState.provider === removed.name) {
|
|
628
|
+
projectState.provider = providersState.default;
|
|
629
|
+
projectState.defaultProvider = providersState.default;
|
|
630
|
+
saveProject(projectState, cwd);
|
|
631
|
+
}
|
|
632
|
+
clearScreen();
|
|
633
|
+
console.log("已删除 provider:", removed.name);
|
|
634
|
+
await pause(rl);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function showCurrentStatus(rl, cwd = process.cwd()) {
|
|
640
|
+
hydrateProjectMetadata(cwd);
|
|
641
|
+
const projectState = loadProject(cwd);
|
|
642
|
+
const providersState = loadProviders();
|
|
643
|
+
const sessions = listProjectSessionsWithMeta(cwd);
|
|
644
|
+
clearScreen();
|
|
645
|
+
console.log("当前状态");
|
|
646
|
+
console.log("========");
|
|
647
|
+
console.log("项目 ID:", projectState.projectId);
|
|
648
|
+
console.log("项目名:", projectState.name);
|
|
649
|
+
console.log("项目根:", projectState.root);
|
|
650
|
+
console.log("默认 provider:", projectState.defaultProvider || "(未设置)");
|
|
651
|
+
console.log("当前 provider:", projectState.provider || "(未设置)");
|
|
652
|
+
console.log("主线 session:", projectState.mainSessionId || "(无)");
|
|
653
|
+
console.log("最近 session:", projectState.lastSessionId || "(无)");
|
|
654
|
+
console.log("provider 数量:", providersState.providers.length);
|
|
655
|
+
console.log("项目会话数:", sessions.length);
|
|
656
|
+
console.log("codex 命令:", codexPath() || "(未找到)");
|
|
657
|
+
await pause(rl);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
async function showProjectSessions(rl, cwd = process.cwd()) {
|
|
661
|
+
hydrateProjectMetadata(cwd);
|
|
662
|
+
const sessions = listProjectSessionsWithMeta(cwd);
|
|
663
|
+
clearScreen();
|
|
664
|
+
console.log("本项目会话");
|
|
665
|
+
console.log("==========");
|
|
666
|
+
if (!sessions.length) {
|
|
667
|
+
console.log("(空)");
|
|
668
|
+
} else {
|
|
669
|
+
sessions.slice(0, 30).forEach((session, index) => {
|
|
670
|
+
const role = session.kind === "branch" ? "branch" : "main";
|
|
671
|
+
const branchInfo = session.parentSessionId
|
|
672
|
+
? ` <- ${session.parentSessionId.slice(0, 8)}`
|
|
673
|
+
: "";
|
|
674
|
+
console.log(
|
|
675
|
+
`${index + 1}. ${session.title} | ${role} | ${session.provider || "(未知 provider)"} | ${session.sessionId}${branchInfo}`,
|
|
676
|
+
);
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
await pause(rl);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
async function captureTitle(rl, providerName, kind) {
|
|
683
|
+
clearScreen();
|
|
684
|
+
console.log("会话标题");
|
|
685
|
+
console.log("========");
|
|
686
|
+
const input = (await askText(
|
|
687
|
+
rl,
|
|
688
|
+
`输入标题(留空自动生成:${defaultTitle(providerName, kind)}): `,
|
|
689
|
+
)).trim();
|
|
690
|
+
return input || defaultTitle(providerName, kind);
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
async function launchNewSession(rl, cwd = process.cwd()) {
|
|
694
|
+
hydrateProjectMetadata(cwd);
|
|
695
|
+
const projectState = loadProject(cwd);
|
|
696
|
+
const providersState = loadProviders();
|
|
697
|
+
if (!providersState.providers.length) {
|
|
698
|
+
clearScreen();
|
|
699
|
+
console.log("先去配一个 provider/key 再开。");
|
|
700
|
+
await pause(rl);
|
|
701
|
+
return;
|
|
702
|
+
}
|
|
703
|
+
let provider = providersState.providers.find(
|
|
704
|
+
(item) => item.name === (projectState.provider || projectState.defaultProvider),
|
|
705
|
+
);
|
|
706
|
+
if (!provider) {
|
|
707
|
+
provider =
|
|
708
|
+
(await chooseProviderMenu(rl, projectState, providersState)) ||
|
|
709
|
+
providersState.providers.find((item) => item.name === providersState.default) ||
|
|
710
|
+
providersState.providers[0];
|
|
711
|
+
}
|
|
712
|
+
if (!provider) return;
|
|
713
|
+
|
|
714
|
+
projectState.provider = provider.name;
|
|
715
|
+
projectState.defaultProvider = provider.name;
|
|
716
|
+
saveProject(projectState, cwd);
|
|
717
|
+
const title = await captureTitle(rl, provider.name, "main");
|
|
718
|
+
|
|
719
|
+
clearScreen();
|
|
720
|
+
console.log(`准备启动新主线会话 · provider=${provider.name}`);
|
|
721
|
+
await pause(rl, "按回车进入 Codex...");
|
|
722
|
+
|
|
723
|
+
setRawMode(false);
|
|
724
|
+
const startedAtMs = Date.now();
|
|
725
|
+
await runCodex([], buildCodexEnv(provider), projectState.root);
|
|
726
|
+
setRawMode(true);
|
|
727
|
+
|
|
728
|
+
const linkedSessionId = await maybeLinkLatestSession(
|
|
729
|
+
projectState.root,
|
|
730
|
+
provider.name,
|
|
731
|
+
startedAtMs,
|
|
732
|
+
);
|
|
733
|
+
if (linkedSessionId) {
|
|
734
|
+
projectState.lastSessionId = linkedSessionId;
|
|
735
|
+
projectState.mainSessionId = linkedSessionId;
|
|
736
|
+
saveProject(projectState, cwd);
|
|
737
|
+
upsertSessionMeta(
|
|
738
|
+
{
|
|
739
|
+
sessionId: linkedSessionId,
|
|
740
|
+
title,
|
|
741
|
+
kind: "main",
|
|
742
|
+
parentSessionId: null,
|
|
743
|
+
provider: provider.name,
|
|
744
|
+
isMain: true,
|
|
745
|
+
createdAt: nowIso(),
|
|
746
|
+
updatedAt: nowIso(),
|
|
747
|
+
},
|
|
748
|
+
cwd,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function resumeLatestSession(rl, cwd = process.cwd()) {
|
|
754
|
+
hydrateProjectMetadata(cwd);
|
|
755
|
+
const sessions = listProjectSessionsWithMeta(cwd);
|
|
756
|
+
if (!sessions.length) {
|
|
757
|
+
clearScreen();
|
|
758
|
+
console.log("本项目还没有可恢复的会话,先开新的。");
|
|
759
|
+
await pause(rl);
|
|
760
|
+
return;
|
|
761
|
+
}
|
|
762
|
+
return resumeSpecificSession(rl, sessions[0].sessionId, cwd);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function resumeMainSession(rl, cwd = process.cwd()) {
|
|
766
|
+
hydrateProjectMetadata(cwd);
|
|
767
|
+
const projectState = loadProject(cwd);
|
|
768
|
+
const main = getCurrentMainSession(projectState, cwd);
|
|
769
|
+
if (!main) {
|
|
770
|
+
clearScreen();
|
|
771
|
+
console.log("当前项目没有主线会话。");
|
|
772
|
+
await pause(rl);
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
return resumeSpecificSession(rl, main.sessionId, cwd);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
async function resumeSpecificSession(rl, sessionId, cwd = process.cwd()) {
|
|
779
|
+
hydrateProjectMetadata(cwd);
|
|
780
|
+
const projectState = loadProject(cwd);
|
|
781
|
+
const providersState = loadProviders();
|
|
782
|
+
const linkedProviderName =
|
|
783
|
+
projectState.provider ||
|
|
784
|
+
projectState.defaultProvider ||
|
|
785
|
+
getProviderForSession(sessionId, cwd) ||
|
|
786
|
+
providersState.default;
|
|
787
|
+
const provider = providersState.providers.find(
|
|
788
|
+
(item) => item.name === linkedProviderName,
|
|
789
|
+
);
|
|
790
|
+
if (!provider) {
|
|
791
|
+
clearScreen();
|
|
792
|
+
console.log("找不到对应 provider。先去 provider 管理里配好。");
|
|
793
|
+
await pause(rl);
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
projectState.provider = provider.name;
|
|
798
|
+
projectState.lastSessionId = sessionId;
|
|
799
|
+
saveProject(projectState, cwd);
|
|
800
|
+
upsertSessionMeta(
|
|
801
|
+
{
|
|
802
|
+
sessionId,
|
|
803
|
+
provider: provider.name,
|
|
804
|
+
updatedAt: nowIso(),
|
|
805
|
+
},
|
|
806
|
+
cwd,
|
|
807
|
+
);
|
|
808
|
+
|
|
809
|
+
clearScreen();
|
|
810
|
+
console.log(`准备恢复会话 ${sessionId}`);
|
|
811
|
+
console.log(`provider=${provider.name}`);
|
|
812
|
+
await pause(rl, "按回车进入 Codex...");
|
|
813
|
+
|
|
814
|
+
setRawMode(false);
|
|
815
|
+
await runCodex(["resume", sessionId], buildCodexEnv(provider), projectState.root);
|
|
816
|
+
setRawMode(true);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
async function pickSessionAndResume(rl, cwd = process.cwd()) {
|
|
820
|
+
hydrateProjectMetadata(cwd);
|
|
821
|
+
const sessions = listProjectSessionsWithMeta(cwd);
|
|
822
|
+
if (!sessions.length) {
|
|
823
|
+
clearScreen();
|
|
824
|
+
console.log("本项目还没有可恢复的会话。");
|
|
825
|
+
await pause(rl);
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
const idx = await selectIndex(
|
|
829
|
+
"选择历史会话",
|
|
830
|
+
sessions.slice(0, 30).map((session) => {
|
|
831
|
+
return `${session.title} | ${session.provider || "未知 provider"} | ${session.kind} | ${session.sessionId}`;
|
|
832
|
+
}),
|
|
833
|
+
"↑↓/数字 选择 · Enter 恢复 · Esc 返回",
|
|
834
|
+
);
|
|
835
|
+
if (idx === null) return;
|
|
836
|
+
await resumeSpecificSession(rl, sessions[idx].sessionId, cwd);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function chooseMainSessionMenu(rl, cwd = process.cwd()) {
|
|
840
|
+
hydrateProjectMetadata(cwd);
|
|
841
|
+
const projectState = loadProject(cwd);
|
|
842
|
+
const mains = getMainSessions(cwd);
|
|
843
|
+
if (!mains.length) {
|
|
844
|
+
clearScreen();
|
|
845
|
+
console.log("当前项目还没有主线会话。先开一个新的。");
|
|
846
|
+
await pause(rl);
|
|
847
|
+
return null;
|
|
848
|
+
}
|
|
849
|
+
const idx = await selectIndex(
|
|
850
|
+
"主线列表",
|
|
851
|
+
mains.map((session) => {
|
|
852
|
+
const marker =
|
|
853
|
+
session.sessionId === projectState.mainSessionId ? " (当前主线)" : "";
|
|
854
|
+
return `${formatSessionLabel(session, cwd)}${marker}`;
|
|
855
|
+
}),
|
|
856
|
+
"↑↓/数字 选择 · Enter 进入 · Esc 返回",
|
|
857
|
+
);
|
|
858
|
+
if (idx === null) return null;
|
|
859
|
+
return mains[idx];
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async function navigateSessionTree(rl, rootSessionId, cwd = process.cwd()) {
|
|
863
|
+
hydrateProjectMetadata(cwd);
|
|
864
|
+
let currentId = rootSessionId;
|
|
865
|
+
while (currentId) {
|
|
866
|
+
const allSessions = listProjectSessionsWithMeta(cwd);
|
|
867
|
+
const current = allSessions.find((item) => item.sessionId === currentId);
|
|
868
|
+
if (!current) {
|
|
869
|
+
clearScreen();
|
|
870
|
+
console.log("当前会话不存在了,可能已经被删除。");
|
|
871
|
+
await pause(rl);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const children = getChildSessions(current.sessionId, cwd);
|
|
876
|
+
const actions = [
|
|
877
|
+
`恢复当前会话`,
|
|
878
|
+
`从当前会话分叉`,
|
|
879
|
+
...(children.length ? ["进入子分支"] : []),
|
|
880
|
+
...(current.parentSessionId ? ["返回父会话"] : []),
|
|
881
|
+
`返回主线列表`,
|
|
882
|
+
`删除当前会话(硬删除)`,
|
|
883
|
+
];
|
|
884
|
+
|
|
885
|
+
const idx = await selectIndexWithContext(
|
|
886
|
+
`会话树 · ${current.title}`,
|
|
887
|
+
[
|
|
888
|
+
`会话ID: ${current.sessionId}`,
|
|
889
|
+
`类型: ${current.kind}`,
|
|
890
|
+
`会话 Provider: ${current.provider || "未知 provider"}`,
|
|
891
|
+
`项目当前 Provider: ${loadProject(cwd).provider || loadProject(cwd).defaultProvider || "未设置"}`,
|
|
892
|
+
`子分支数: ${children.length}`,
|
|
893
|
+
],
|
|
894
|
+
actions,
|
|
895
|
+
"↑↓/数字 选择 · Enter 确认 · Esc 返回",
|
|
896
|
+
);
|
|
897
|
+
if (idx === null) return;
|
|
898
|
+
const action = actions[idx];
|
|
899
|
+
if (action === "恢复当前会话") {
|
|
900
|
+
await resumeSpecificSession(rl, current.sessionId, cwd);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (action === "从当前会话分叉") {
|
|
904
|
+
await forkFromSession(rl, current, cwd);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
if (action === "进入子分支") {
|
|
908
|
+
const childIdx = await selectIndex(
|
|
909
|
+
`子分支 · ${current.title}`,
|
|
910
|
+
children.map((session) => formatSessionLabel(session, cwd)),
|
|
911
|
+
"↑↓/数字 选择 · Enter 进入 · Esc 返回",
|
|
912
|
+
);
|
|
913
|
+
if (childIdx === null) continue;
|
|
914
|
+
currentId = children[childIdx].sessionId;
|
|
915
|
+
continue;
|
|
916
|
+
}
|
|
917
|
+
if (action === "返回父会话") {
|
|
918
|
+
currentId = current.parentSessionId;
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
if (action === "删除当前会话(硬删除)") {
|
|
922
|
+
const result = await deleteSpecificSession(rl, current, cwd);
|
|
923
|
+
if (!result || result.deleted === false) {
|
|
924
|
+
continue;
|
|
925
|
+
}
|
|
926
|
+
if (result.nextSessionId) {
|
|
927
|
+
currentId = result.nextSessionId;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
if (result.backToMainList) {
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
if (action === "返回主线列表") {
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
async function forkFromSession(rl, source, cwd = process.cwd()) {
|
|
942
|
+
hydrateProjectMetadata(cwd);
|
|
943
|
+
const projectState = loadProject(cwd);
|
|
944
|
+
const providersState = loadProviders();
|
|
945
|
+
const provider =
|
|
946
|
+
providersState.providers.find((item) => item.name === projectState.provider) ||
|
|
947
|
+
providersState.providers.find((item) => item.name === projectState.defaultProvider) ||
|
|
948
|
+
providersState.providers.find((item) => item.name === source.provider) ||
|
|
949
|
+
providersState.providers[0];
|
|
950
|
+
if (!provider) {
|
|
951
|
+
clearScreen();
|
|
952
|
+
console.log("找不到可用 provider。");
|
|
953
|
+
await pause(rl);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
const title = await captureTitle(rl, provider.name, "branch");
|
|
957
|
+
clearScreen();
|
|
958
|
+
console.log(`准备从 ${source.title} 分叉`);
|
|
959
|
+
console.log(`provider=${provider.name}`);
|
|
960
|
+
await pause(rl, "按回车进入 Codex...");
|
|
961
|
+
|
|
962
|
+
setRawMode(false);
|
|
963
|
+
const startedAtMs = Date.now();
|
|
964
|
+
await runCodex(["fork", source.sessionId], buildCodexEnv(provider), projectState.root);
|
|
965
|
+
setRawMode(true);
|
|
966
|
+
|
|
967
|
+
const linkedSessionId = await maybeLinkLatestSession(
|
|
968
|
+
projectState.root,
|
|
969
|
+
provider.name,
|
|
970
|
+
startedAtMs,
|
|
971
|
+
);
|
|
972
|
+
if (linkedSessionId) {
|
|
973
|
+
projectState.provider = provider.name;
|
|
974
|
+
projectState.defaultProvider = provider.name;
|
|
975
|
+
projectState.lastSessionId = linkedSessionId;
|
|
976
|
+
saveProject(projectState, cwd);
|
|
977
|
+
upsertSessionMeta(
|
|
978
|
+
{
|
|
979
|
+
sessionId: linkedSessionId,
|
|
980
|
+
title,
|
|
981
|
+
kind: "branch",
|
|
982
|
+
parentSessionId: source.sessionId,
|
|
983
|
+
provider: provider.name,
|
|
984
|
+
isMain: false,
|
|
985
|
+
createdAt: nowIso(),
|
|
986
|
+
updatedAt: nowIso(),
|
|
987
|
+
},
|
|
988
|
+
cwd,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
async function deleteSpecificSession(rl, target, cwd = process.cwd()) {
|
|
994
|
+
hydrateProjectMetadata(cwd);
|
|
995
|
+
const projectState = loadProject(cwd);
|
|
996
|
+
const fallbackParentSessionId = target.parentSessionId || null;
|
|
997
|
+
const childCount = countChildBranches(target.sessionId, cwd);
|
|
998
|
+
clearScreen();
|
|
999
|
+
console.log("硬删除确认");
|
|
1000
|
+
console.log("============");
|
|
1001
|
+
console.log("标题:", target.title);
|
|
1002
|
+
console.log("会话:", target.sessionId);
|
|
1003
|
+
console.log("类型:", target.kind);
|
|
1004
|
+
console.log("下游分支数:", childCount);
|
|
1005
|
+
console.log("注意:这里只删除当前会话本身,不自动删它的分支。");
|
|
1006
|
+
const confirm = (await askText(rl, "输入 yes / y 确认删除: ")).trim().toLowerCase();
|
|
1007
|
+
if (!["yes", "y"].includes(confirm)) {
|
|
1008
|
+
clearScreen();
|
|
1009
|
+
console.log("已取消删除。");
|
|
1010
|
+
await pause(rl);
|
|
1011
|
+
return {
|
|
1012
|
+
deleted: false,
|
|
1013
|
+
nextSessionId: target.sessionId,
|
|
1014
|
+
backToMainList: false,
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
setRawMode(false);
|
|
1019
|
+
const exitCode = await runCodex(
|
|
1020
|
+
["delete", "--force", target.sessionId],
|
|
1021
|
+
{},
|
|
1022
|
+
projectState.root,
|
|
1023
|
+
);
|
|
1024
|
+
setRawMode(true);
|
|
1025
|
+
|
|
1026
|
+
const stillExists = hasOfficialSession(target.sessionId, projectState.root);
|
|
1027
|
+
if (exitCode !== 0 || stillExists) {
|
|
1028
|
+
clearScreen();
|
|
1029
|
+
console.log("删除失败");
|
|
1030
|
+
console.log("========");
|
|
1031
|
+
console.log("Codex 没有成功删除这个会话。");
|
|
1032
|
+
console.log(`退出码: ${exitCode}`);
|
|
1033
|
+
console.log(`会话仍存在: ${stillExists ? "是" : "否"}`);
|
|
1034
|
+
await pause(rl, "按回车返回当前会话...");
|
|
1035
|
+
return {
|
|
1036
|
+
deleted: false,
|
|
1037
|
+
nextSessionId: target.sessionId,
|
|
1038
|
+
backToMainList: false,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
removeSessionMeta(target.sessionId, cwd);
|
|
1043
|
+
if (projectState.lastSessionId === target.sessionId) projectState.lastSessionId = null;
|
|
1044
|
+
if (projectState.mainSessionId === target.sessionId) projectState.mainSessionId = null;
|
|
1045
|
+
saveProject(projectState, cwd);
|
|
1046
|
+
clearScreen();
|
|
1047
|
+
console.log("已硬删除会话:", target.sessionId);
|
|
1048
|
+
await pause(rl);
|
|
1049
|
+
return {
|
|
1050
|
+
deleted: true,
|
|
1051
|
+
nextSessionId: fallbackParentSessionId,
|
|
1052
|
+
backToMainList: !fallbackParentSessionId,
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
async function interactive(cwd = process.cwd()) {
|
|
1057
|
+
ensureDir(CONFIG_ROOT);
|
|
1058
|
+
const projectCheck = ensureUsableProject(cwd);
|
|
1059
|
+
const rl = readline.createInterface({
|
|
1060
|
+
input: process.stdin,
|
|
1061
|
+
output: process.stdout,
|
|
1062
|
+
});
|
|
1063
|
+
readline.emitKeypressEvents(process.stdin, rl);
|
|
1064
|
+
setRawMode(true);
|
|
1065
|
+
|
|
1066
|
+
try {
|
|
1067
|
+
if (!projectCheck.ok) {
|
|
1068
|
+
clearScreen();
|
|
1069
|
+
console.log("项目检查未通过");
|
|
1070
|
+
console.log("==============");
|
|
1071
|
+
console.log(projectCheck.reason);
|
|
1072
|
+
await pause(rl);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
hydrateProjectMetadata(cwd);
|
|
1076
|
+
while (true) {
|
|
1077
|
+
const projectState = loadProject(cwd);
|
|
1078
|
+
const action = await selectIndex(
|
|
1079
|
+
`Codexx · ${projectState.name}`,
|
|
1080
|
+
[
|
|
1081
|
+
"新主线会话",
|
|
1082
|
+
"选择主线",
|
|
1083
|
+
"恢复最近会话",
|
|
1084
|
+
"Provider 管理",
|
|
1085
|
+
"查看当前状态",
|
|
1086
|
+
"退出",
|
|
1087
|
+
],
|
|
1088
|
+
"↑↓/数字 选择 · Enter 确认 · Esc 退出",
|
|
1089
|
+
);
|
|
1090
|
+
|
|
1091
|
+
if (action === null || action === 5) return;
|
|
1092
|
+
if (action === 0) await launchNewSession(rl, cwd);
|
|
1093
|
+
else if (action === 1) {
|
|
1094
|
+
const selectedMain = await chooseMainSessionMenu(rl, cwd);
|
|
1095
|
+
if (selectedMain) await navigateSessionTree(rl, selectedMain.sessionId, cwd);
|
|
1096
|
+
} else if (action === 2) await resumeLatestSession(rl, cwd);
|
|
1097
|
+
else if (action === 3) await providerManagementMenu(rl, cwd);
|
|
1098
|
+
else if (action === 4) await showCurrentStatus(rl, cwd);
|
|
1099
|
+
}
|
|
1100
|
+
} finally {
|
|
1101
|
+
setRawMode(false);
|
|
1102
|
+
rl.close();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
function printHelp() {
|
|
1107
|
+
console.log(`codexx
|
|
1108
|
+
|
|
1109
|
+
用法:
|
|
1110
|
+
codexx 进入交互菜单
|
|
1111
|
+
codexx init 初始化当前项目状态
|
|
1112
|
+
codexx list 列出当前项目会话
|
|
1113
|
+
codexx providers 查看 provider 列表
|
|
1114
|
+
`);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async function main() {
|
|
1118
|
+
const argv = process.argv.slice(2);
|
|
1119
|
+
const projectCheck = ensureUsableProject(process.cwd());
|
|
1120
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
1121
|
+
printHelp();
|
|
1122
|
+
return;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
const cmd = argv[0];
|
|
1126
|
+
if (!cmd) {
|
|
1127
|
+
if (!projectCheck.ok) {
|
|
1128
|
+
console.error(projectCheck.reason);
|
|
1129
|
+
process.exit(1);
|
|
1130
|
+
}
|
|
1131
|
+
await interactive();
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (cmd === "init") {
|
|
1136
|
+
if (!projectCheck.ok) {
|
|
1137
|
+
console.error(projectCheck.reason);
|
|
1138
|
+
process.exit(1);
|
|
1139
|
+
}
|
|
1140
|
+
const state = loadProject();
|
|
1141
|
+
saveProject(state);
|
|
1142
|
+
console.log("项目已初始化:", getProjectPaths().projectFile);
|
|
1143
|
+
return;
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (cmd === "providers") {
|
|
1147
|
+
console.log(JSON.stringify(loadProviders(), null, 2));
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
if (cmd === "list") {
|
|
1152
|
+
if (!projectCheck.ok) {
|
|
1153
|
+
console.error(projectCheck.reason);
|
|
1154
|
+
process.exit(1);
|
|
1155
|
+
}
|
|
1156
|
+
hydrateProjectMetadata();
|
|
1157
|
+
const sessions = listProjectSessionsWithMeta();
|
|
1158
|
+
if (!sessions.length) {
|
|
1159
|
+
console.log("(空)");
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
sessions.forEach((session) => {
|
|
1163
|
+
console.log(
|
|
1164
|
+
`${session.sessionId} ${session.provider || "未知 provider"} ${session.kind} ${session.title}`,
|
|
1165
|
+
);
|
|
1166
|
+
});
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
printHelp();
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
main().catch((error) => {
|
|
1174
|
+
console.error(error);
|
|
1175
|
+
process.exit(1);
|
|
1176
|
+
});
|