@4yi-dev/cli 0.1.16 → 0.1.18

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 CHANGED
@@ -39,9 +39,13 @@ On macOS, `4yi connect claude` also configures an installed Claude Desktop throu
39
39
 
40
40
  `--scope project` changes only the project's Claude Code settings and never changes the global Claude Desktop profile.
41
41
 
42
- Codex CLI and Codex App share the same Codex home on native Windows (`%USERPROFILE%\\.codex`). After `4yi connect codex`, finish active App tasks, fully quit and reopen the App, and start a new chat so its embedded Codex process loads the new provider. Microsoft Store/AppX installations may not expose a conventional executable path, but that detection does not gate the shared configuration update. While the external 4YI provider supplies authentication, Codex App may hide its `Log out` action because there is no App-managed provider credential to clear. To switch back to OpenAI, run `4yi restore codex`, fully quit and reopen the App, and start a new chat.
42
+ Codex CLI and Codex App share the same Codex home on native Windows (`%USERPROFILE%\\.codex`). Microsoft Store/AppX installations may not expose a conventional executable path, but that detection does not gate the shared configuration update. While the external 4YI provider supplies authentication, Codex App may hide its `Log out` action because there is no App-managed provider credential to clear.
43
43
 
44
- Existing Codex tasks keep the provider they were created with. Run `4yi migrate codex`, choose a recent task by number, and 4YI creates a new task with the same history and project using your Plan's default model. The original task is not modified. Scripts can use `--thread <id>` and optionally `--model <id>`.
44
+ `4yi migrate codex` connects Codex to 4YI and switches every stored Codex task to the 4YI provider in place. `4yi restore codex` restores the protected OpenAI configuration and switches every task back to OpenAI in place. Task IDs, projects, visible user messages, assistant answers, and tool history remain on the original sidebar entries. Finish active turns before running either command, then fully quit and reopen Codex App when it completes.
45
+
46
+ Before changing anything, the CLI validates the current `state_5.sqlite` and `thread_history_1.sqlite` schemas and every target rollout. It then creates consistent database and rollout backups under `~/.4yi/backups/codex-migrations`, removes provider-private reasoning/compaction references, updates the provider/model and UI projection offsets as one migration, and rolls back automatically if a commit or verification step fails. Node.js 22.13 or newer is required for this protected SQLite workflow.
47
+
48
+ Use `--thread <id>` to switch only one task, `--model <id>` to override the target default model, or `--fork` to retain the older compatibility behavior that creates a migrated copy instead of changing the original task.
45
49
 
46
50
  The Codex provider uses the official command-backed authentication configuration. Its helper reads the current token from `~/.4yi/config.json`; `config.toml` and the model catalog do not contain the bearer token.
47
51
 
package/bin/4yi.mjs CHANGED
@@ -1,8 +1,20 @@
1
1
  #!/usr/bin/env node
2
2
  import { login, loadSession, clearSession } from "../src/auth.mjs";
3
3
  import { runCode } from "../src/opencode.mjs";
4
- import { connect, connectionStatus, prepareConnectionTools, restoreConnection } from "../src/connect.mjs";
5
- import { migrateCodexTask } from "../src/codex-migrate.mjs";
4
+ import {
5
+ captureCodexConnectionState,
6
+ connect,
7
+ connectionStatus,
8
+ prepareConnectionTools,
9
+ restoreCodexConnectionState,
10
+ restoreConnection,
11
+ } from "../src/connect.mjs";
12
+ import {
13
+ migrateCodexTask,
14
+ migrateCodexTasksTo4yi,
15
+ restoreCodexTasksByFork,
16
+ restoreCodexTasksToOpenAI,
17
+ } from "../src/codex-migrate.mjs";
6
18
 
7
19
  const command = process.argv[2] || "help";
8
20
 
@@ -13,11 +25,16 @@ if (command === "help" || command === "--help" || command === "-h") {
13
25
  console.log(" 4yi connect <claude|codex|all> connect existing coding tools to 4YI");
14
26
  console.log(" --yes install a missing CLI without prompting");
15
27
  console.log(" Claude on macOS also configures installed Claude Desktop 3P Gateway mode");
16
- console.log(" 4yi migrate codex continue an existing Codex task through 4YI");
17
- console.log(" --thread ID select a specific task without prompting");
28
+ console.log(" 4yi migrate codex switch every Codex task in place to 4YI");
29
+ console.log(" --thread ID switch only one task in place");
30
+ console.log(" --fork create a migrated copy instead (compatibility fallback)");
18
31
  console.log(" --model X choose a model (defaults to your Plan model)");
19
32
  console.log(" 4yi status [claude|codex|all] inspect the current connection");
20
33
  console.log(" 4yi restore <claude|codex|all> restore the latest protected config");
34
+ console.log(" Codex: switch every task in place to OpenAI after restoring");
35
+ console.log(" --thread ID switch only one task in place");
36
+ console.log(" --fork create migrated copies instead (compatibility fallback)");
37
+ console.log(" --model X use a specific target-provider model");
21
38
  process.exit(0);
22
39
  }
23
40
 
@@ -48,6 +65,7 @@ function parseMigrateArgs(args) {
48
65
  if (target !== "codex") throw new Error("Use `4yi migrate codex`.");
49
66
  let threadId;
50
67
  let model;
68
+ let fork = false;
51
69
  for (let i = 1; i < args.length; i += 1) {
52
70
  const arg = args[i];
53
71
  if (arg === "--thread") {
@@ -60,10 +78,52 @@ function parseMigrateArgs(args) {
60
78
  if (!model || model.startsWith("-")) throw new Error("--model requires a model id.");
61
79
  }
62
80
  else if (arg.startsWith("--model=")) model = arg.slice("--model=".length);
81
+ else if (arg === "--fork") fork = true;
63
82
  else throw new Error(`Unknown option: ${arg}`);
64
83
  }
65
84
  if (threadId === "" || model === "") throw new Error("--thread and --model require a value.");
66
- return { threadId, model };
85
+ return { threadId, model, fork };
86
+ }
87
+
88
+ function parseRestoreArgs(args) {
89
+ let target = "all";
90
+ let threadId;
91
+ let migrateTasks;
92
+ let model;
93
+ let fork = false;
94
+ let targetSeen = false;
95
+ for (let i = 0; i < args.length; i += 1) {
96
+ const arg = args[i];
97
+ if (!arg.startsWith("-") && !targetSeen) {
98
+ target = arg;
99
+ targetSeen = true;
100
+ } else if (arg === "--thread") {
101
+ threadId = args[++i];
102
+ if (!threadId || threadId.startsWith("-")) throw new Error("--thread requires a task id.");
103
+ } else if (arg.startsWith("--thread=")) {
104
+ threadId = arg.slice("--thread=".length);
105
+ } else if (arg === "--migrate-tasks") {
106
+ migrateTasks = args[++i];
107
+ if (!migrateTasks || migrateTasks.startsWith("-")) throw new Error("--migrate-tasks requires `all`.");
108
+ } else if (arg.startsWith("--migrate-tasks=")) {
109
+ migrateTasks = arg.slice("--migrate-tasks=".length);
110
+ } else if (arg === "--model" || arg === "-m") {
111
+ model = args[++i];
112
+ if (!model || model.startsWith("-")) throw new Error("--model requires a model id.");
113
+ } else if (arg.startsWith("--model=")) {
114
+ model = arg.slice("--model=".length);
115
+ } else if (arg === "--fork") {
116
+ fork = true;
117
+ } else {
118
+ throw new Error(`Unknown option: ${arg}`);
119
+ }
120
+ }
121
+ if (migrateTasks && migrateTasks !== "all") throw new Error("--migrate-tasks currently supports only `all`.");
122
+ if (threadId && migrateTasks) throw new Error("Use either --thread or --migrate-tasks all, not both.");
123
+ if ((threadId || migrateTasks || model || fork) && !new Set(["codex", "all"]).has(String(target).toLowerCase())) {
124
+ throw new Error("Codex task migration options require `4yi restore codex` or `4yi restore all`.");
125
+ }
126
+ return { target, threadId, migrateTasks, model, fork };
67
127
  }
68
128
 
69
129
  /** Split out `--model <id>` / `--model=<id>` / `-m <id>`; the rest pass through to OpenCode. */
@@ -117,13 +177,38 @@ try {
117
177
  } else if (command === "migrate") {
118
178
  const migrateArgs = parseMigrateArgs(process.argv.slice(3));
119
179
  const session = loadSession();
120
- await migrateCodexTask({ session, ...migrateArgs });
180
+ await prepareConnectionTools({ target: "codex" });
181
+ const codexSnapshot = migrateArgs.fork ? null : captureCodexConnectionState();
182
+ try {
183
+ await connect({ session, target: "codex", skipToolCheck: true });
184
+ if (migrateArgs.fork) await migrateCodexTask({ session, ...migrateArgs });
185
+ else await migrateCodexTasksTo4yi({ session, ...migrateArgs });
186
+ } catch (error) {
187
+ if (codexSnapshot) {
188
+ try { restoreCodexConnectionState(codexSnapshot); } catch { /* Preserve the migration error. */ }
189
+ }
190
+ throw error;
191
+ }
192
+ console.log("请完全退出并重新打开 Codex App,使所有窗口加载新的 provider 配置。");
121
193
  } else if (command === "status") {
122
194
  const { target, scope } = parseConnectArgs(process.argv.slice(3));
123
195
  connectionStatus({ target, scope });
124
196
  } else if (command === "restore") {
125
- const { target } = parseConnectArgs(process.argv.slice(3));
126
- restoreConnection({ target });
197
+ const restoreArgs = parseRestoreArgs(process.argv.slice(3));
198
+ const codexSnapshot = restoreArgs.fork ? null : captureCodexConnectionState();
199
+ try {
200
+ const restored = restoreConnection({ target: restoreArgs.target });
201
+ if (restored.restoredCodex) {
202
+ if (restoreArgs.fork) await restoreCodexTasksByFork(restoreArgs);
203
+ else await restoreCodexTasksToOpenAI(restoreArgs);
204
+ console.log("Codex 已恢复为 OpenAI。请完全退出并重新打开 Codex App;原任务可在原位置继续。");
205
+ }
206
+ } catch (error) {
207
+ if (codexSnapshot) {
208
+ try { restoreCodexConnectionState(codexSnapshot); } catch { /* Preserve the restore error. */ }
209
+ }
210
+ throw error;
211
+ }
127
212
  } else {
128
213
  console.error(`Unknown command: ${command}`);
129
214
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4yi-dev/cli",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "description": "4YI command-line launcher for OAuth login and OpenCode runtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -22,7 +22,7 @@
22
22
  "publish:prod": "node scripts/stage-package.mjs prod --publish"
23
23
  },
24
24
  "engines": {
25
- "node": ">=20.0.0"
25
+ "node": ">=22.13.0"
26
26
  },
27
27
  "license": "UNLICENSED"
28
28
  }
@@ -0,0 +1,495 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import process from "node:process";
5
+
6
+ function timestampForPath(date = new Date()) {
7
+ return date.toISOString().replace(/[-:TZ.]/g, "").slice(0, 17);
8
+ }
9
+
10
+ function latestNumberedDatabase(codexHome, prefix) {
11
+ const expression = new RegExp(`^${prefix}_(\\d+)\\.sqlite$`);
12
+ const matches = fs.readdirSync(codexHome, { withFileTypes: true })
13
+ .filter((entry) => entry.isFile() && expression.test(entry.name))
14
+ .map((entry) => ({
15
+ file: path.join(codexHome, entry.name),
16
+ version: Number(entry.name.match(expression)[1]),
17
+ }))
18
+ .sort((left, right) => right.version - left.version);
19
+ if (matches.length === 0) throw new Error(`Codex 数据库缺失:${prefix}_*.sqlite`);
20
+ return matches[0];
21
+ }
22
+
23
+ function tableColumns(database, table) {
24
+ return new Set(database.prepare(`PRAGMA table_info(${table})`).all().map((row) => row.name));
25
+ }
26
+
27
+ function requireColumns(database, table, required) {
28
+ const columns = tableColumns(database, table);
29
+ const missing = required.filter((column) => !columns.has(column));
30
+ if (missing.length > 0) {
31
+ throw new Error(`不支持当前 Codex 数据库结构:${table} 缺少 ${missing.join(", ")}`);
32
+ }
33
+ }
34
+
35
+ function resolveRolloutPath(codexHome, rolloutPath) {
36
+ if (path.isAbsolute(rolloutPath)) return rolloutPath;
37
+ return path.resolve(codexHome, rolloutPath);
38
+ }
39
+
40
+ function lineParts(buffer) {
41
+ const text = buffer.toString("utf8");
42
+ const parts = text.match(/[^\n]*\n|[^\n]+$/g) || [];
43
+ return parts.map((line) => ({
44
+ body: line.endsWith("\n") ? line.slice(0, -1) : line,
45
+ newline: line.endsWith("\n") ? "\n" : "",
46
+ }));
47
+ }
48
+
49
+ function privateRecordKind(record) {
50
+ if (record?.type === "response_item" && record.payload?.type === "reasoning") return "reasoning";
51
+ if (record?.type === "event_msg" && record.payload?.type === "item_completed" && record.payload?.item?.type === "reasoning") {
52
+ return "reasoning-event";
53
+ }
54
+ if (record?.type === "compacted") return "compaction";
55
+ if (record?.type === "event_msg" && record.payload?.type === "context_compacted") return "compaction-event";
56
+ return null;
57
+ }
58
+
59
+ function tombstoneRecord(record, kind, byteLength) {
60
+ let tombstone = {
61
+ timestamp: record.timestamp,
62
+ type: "event_msg",
63
+ payload: { type: "token_count", info: null, migrated: kind },
64
+ };
65
+ if (record.ordinal !== undefined) tombstone.ordinal = record.ordinal;
66
+ let json = JSON.stringify(tombstone);
67
+ if (Buffer.byteLength(json) > byteLength) {
68
+ tombstone = { type: "event_msg", payload: { type: "token_count" } };
69
+ json = JSON.stringify(tombstone);
70
+ }
71
+ const length = Buffer.byteLength(json);
72
+ if (length > byteLength) {
73
+ throw new Error(`无法安全替换 ${kind} 记录:占位记录比原记录长。`);
74
+ }
75
+ return `${json}${" ".repeat(byteLength - length)}`;
76
+ }
77
+
78
+ function newTransformState(threadId, targetProvider) {
79
+ return {
80
+ threadId,
81
+ targetProvider,
82
+ sessionMetaCount: 0,
83
+ matchingSessionMetaCount: 0,
84
+ sourceProviders: new Set(),
85
+ removedReasoning: 0,
86
+ removedCompaction: 0,
87
+ originalOffset: 0,
88
+ cumulativeDelta: 0,
89
+ offsetChanges: [],
90
+ };
91
+ }
92
+
93
+ function transformRolloutBody(body, state) {
94
+ if (!body.trim()) return body;
95
+ let record;
96
+ try {
97
+ record = JSON.parse(body);
98
+ } catch {
99
+ throw new Error(`任务 ${state.threadId} 的 rollout 包含无法解析的 JSONL 记录。`);
100
+ }
101
+ if (record.type === "session_meta") {
102
+ state.sessionMetaCount += 1;
103
+ const recordThreadId = record.payload?.id || record.payload?.session_id;
104
+ if (recordThreadId === state.threadId) state.matchingSessionMetaCount += 1;
105
+ state.sourceProviders.add(record.payload?.model_provider || record.payload?.modelProvider || "openai");
106
+ record.payload.model_provider = state.targetProvider;
107
+ delete record.payload.modelProvider;
108
+ return JSON.stringify(record);
109
+ }
110
+ const kind = privateRecordKind(record);
111
+ if (!kind) return body;
112
+ if (kind.startsWith("reasoning")) state.removedReasoning += 1;
113
+ else state.removedCompaction += 1;
114
+ return tombstoneRecord(record, kind, Buffer.byteLength(body));
115
+ }
116
+
117
+ function recordOffsetChange(state, originalLength, transformedLength) {
118
+ state.originalOffset += originalLength;
119
+ const delta = transformedLength - originalLength;
120
+ if (delta !== 0) {
121
+ state.cumulativeDelta += delta;
122
+ state.offsetChanges.push({ afterOffset: state.originalOffset, delta: state.cumulativeDelta });
123
+ }
124
+ }
125
+
126
+ function transformResult(state) {
127
+ if (state.sessionMetaCount < 1) throw new Error(`任务 ${state.threadId} 缺少 session_meta。`);
128
+ if (state.matchingSessionMetaCount < 1) throw new Error(`任务 ${state.threadId} 的 rollout 不包含自身 session_meta。`);
129
+ return {
130
+ sourceProvider: [...state.sourceProviders].join(","),
131
+ byteDelta: state.cumulativeDelta,
132
+ offsetChanges: state.offsetChanges,
133
+ removedReasoning: state.removedReasoning,
134
+ removedCompaction: state.removedCompaction,
135
+ };
136
+ }
137
+
138
+ export function transformCodexRollout(buffer, { threadId, targetProvider }) {
139
+ const state = newTransformState(threadId, targetProvider);
140
+ const transformed = [];
141
+ for (const { body, newline } of lineParts(buffer)) {
142
+ const nextLine = `${transformRolloutBody(body, state)}${newline}`;
143
+ transformed.push(nextLine);
144
+ recordOffsetChange(state, Buffer.byteLength(`${body}${newline}`), Buffer.byteLength(nextLine));
145
+ }
146
+ const output = Buffer.from(transformed.join(""), "utf8");
147
+ return { buffer: output, ...transformResult(state) };
148
+ }
149
+
150
+ function transformCodexRolloutFile(file, { threadId, targetProvider }) {
151
+ const temporary = `${file}.4yi-migration-${process.pid}.tmp`;
152
+ const input = fs.openSync(file, "r");
153
+ const output = fs.openSync(temporary, "wx", 0o600);
154
+ const state = newTransformState(threadId, targetProvider);
155
+ let pending = Buffer.alloc(0);
156
+ const chunk = Buffer.alloc(1024 * 1024);
157
+ let failure;
158
+ try {
159
+ while (true) {
160
+ const bytes = fs.readSync(input, chunk, 0, chunk.length, null);
161
+ if (bytes === 0) break;
162
+ pending = pending.length === 0 ? Buffer.from(chunk.subarray(0, bytes)) : Buffer.concat([pending, chunk.subarray(0, bytes)]);
163
+ let newlineIndex;
164
+ while ((newlineIndex = pending.indexOf(0x0a)) >= 0) {
165
+ const originalLine = pending.subarray(0, newlineIndex + 1);
166
+ const body = originalLine.subarray(0, -1).toString("utf8");
167
+ const nextLine = Buffer.from(`${transformRolloutBody(body, state)}\n`, "utf8");
168
+ fs.writeSync(output, nextLine);
169
+ recordOffsetChange(state, originalLine.length, nextLine.length);
170
+ pending = pending.subarray(newlineIndex + 1);
171
+ }
172
+ }
173
+ if (pending.length > 0) {
174
+ const body = pending.toString("utf8");
175
+ const nextLine = Buffer.from(transformRolloutBody(body, state), "utf8");
176
+ fs.writeSync(output, nextLine);
177
+ recordOffsetChange(state, pending.length, nextLine.length);
178
+ }
179
+ } catch (error) {
180
+ failure = error;
181
+ } finally {
182
+ fs.closeSync(input);
183
+ fs.closeSync(output);
184
+ }
185
+ if (failure) {
186
+ try { fs.unlinkSync(temporary); } catch { /* Best effort cleanup. */ }
187
+ throw failure;
188
+ }
189
+ try {
190
+ return { temporary, ...transformResult(state) };
191
+ } catch (error) {
192
+ try { fs.unlinkSync(temporary); } catch { /* Best effort cleanup. */ }
193
+ throw error;
194
+ }
195
+ }
196
+
197
+ function mapRolloutOffset(offset, changes) {
198
+ if (offset === null || offset === undefined) return offset;
199
+ let delta = 0;
200
+ for (const change of changes) {
201
+ if (Number(offset) < change.afterOffset) break;
202
+ delta = change.delta;
203
+ }
204
+ return Number(offset) + delta;
205
+ }
206
+
207
+ function atomicWrite(file, buffer, mode) {
208
+ const temporary = `${file}.4yi-migration-${process.pid}.tmp`;
209
+ fs.writeFileSync(temporary, buffer, { mode });
210
+ try {
211
+ fs.renameSync(temporary, file);
212
+ } catch (error) {
213
+ try { fs.unlinkSync(temporary); } catch { /* Best effort cleanup. */ }
214
+ throw error;
215
+ }
216
+ }
217
+
218
+ function writeManifest(file, manifest) {
219
+ const buffer = Buffer.from(`${JSON.stringify(manifest, null, 2)}\n`);
220
+ atomicWrite(file, buffer, 0o600);
221
+ }
222
+
223
+ async function loadSqlite() {
224
+ try {
225
+ return await import("node:sqlite");
226
+ } catch {
227
+ throw new Error("原地迁移需要 Node.js 22.13 或更高版本(需要内置 node:sqlite)。");
228
+ }
229
+ }
230
+
231
+ function acquireMigrationLock(codexHome) {
232
+ const directory = path.join(path.dirname(codexHome), ".4yi", "locks");
233
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
234
+ const file = path.join(directory, "codex-provider-migration.lock");
235
+ let descriptor;
236
+ try {
237
+ descriptor = fs.openSync(file, "wx", 0o600);
238
+ } catch (error) {
239
+ if (error.code === "EEXIST") throw new Error(`已有 Codex 迁移正在执行。如确认没有迁移进程,请删除锁文件:${file}`);
240
+ throw error;
241
+ }
242
+ fs.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`);
243
+ return () => {
244
+ try { fs.closeSync(descriptor); } catch { /* Already closed. */ }
245
+ try { fs.unlinkSync(file); } catch { /* Best effort cleanup. */ }
246
+ };
247
+ }
248
+
249
+ function assertUnchanged(item) {
250
+ const current = fs.statSync(item.file);
251
+ if (current.size !== item.originalSize || current.mtimeMs !== item.originalMtimeMs) {
252
+ throw new Error(`迁移准备期间任务仍在写入:${item.threadId}。请完全退出 Codex App 后重试。`);
253
+ }
254
+ }
255
+
256
+ function restoreFiles(prepared) {
257
+ for (const item of prepared) {
258
+ if (!item.backupFile || !fs.existsSync(item.backupFile)) continue;
259
+ const backup = fs.readFileSync(item.backupFile);
260
+ atomicWrite(item.file, backup, item.mode);
261
+ }
262
+ }
263
+
264
+ function restoreDatabase(backupFile, destination) {
265
+ for (const suffix of ["-wal", "-shm"]) {
266
+ try { fs.unlinkSync(`${destination}${suffix}`); } catch { /* Missing sidecar. */ }
267
+ }
268
+ fs.copyFileSync(backupFile, destination);
269
+ }
270
+
271
+ function migrationCandidates(database, codexHome, targetProvider, threadId) {
272
+ const rows = database.prepare("SELECT id, rollout_path, model_provider, model FROM threads ORDER BY created_at ASC").all();
273
+ const selected = threadId ? rows.filter((row) => row.id === threadId) : rows;
274
+ if (threadId && selected.length === 0) throw new Error(`找不到 Codex 任务:${threadId}`);
275
+ return selected.map((row) => ({
276
+ ...row,
277
+ file: resolveRolloutPath(codexHome, row.rollout_path),
278
+ needsMigration: row.model_provider !== targetProvider,
279
+ }));
280
+ }
281
+
282
+ export async function switchCodexThreadsInPlace({
283
+ targetProvider,
284
+ targetModel,
285
+ threadId,
286
+ codexHome = process.env.CODEX_HOME || path.join(process.env.HOME || process.env.USERPROFILE || os.homedir(), ".codex"),
287
+ backupRoot = path.join(path.dirname(codexHome), ".4yi", "backups", "codex-migrations"),
288
+ stdout = console.log,
289
+ now = new Date(),
290
+ dryRun = false,
291
+ } = {}) {
292
+ if (!new Set(["4yi", "openai"]).has(targetProvider)) throw new Error(`不支持的目标 provider:${targetProvider}`);
293
+ if (!targetModel) throw new Error("缺少目标 Codex 模型。需要先验证目标 provider 的模型列表。");
294
+
295
+ const releaseLock = acquireMigrationLock(codexHome);
296
+ let stateDatabase;
297
+ let historyDatabase;
298
+ let stateBackup;
299
+ let historyBackup;
300
+ let prepared = [];
301
+ try {
302
+ const sqlite = await loadSqlite();
303
+ const state = latestNumberedDatabase(codexHome, "state");
304
+ const history = latestNumberedDatabase(codexHome, "thread_history");
305
+ if (state.version !== 5 || history.version !== 1) {
306
+ throw new Error(`尚未适配当前 Codex 存储版本:state_${state.version} / thread_history_${history.version}。未修改任何任务。`);
307
+ }
308
+ stateDatabase = new sqlite.DatabaseSync(state.file);
309
+ historyDatabase = new sqlite.DatabaseSync(history.file);
310
+ requireColumns(stateDatabase, "threads", ["id", "rollout_path", "model_provider", "model"]);
311
+ requireColumns(historyDatabase, "thread_turns", ["thread_id", "rollout_byte_offset", "rollout_end_byte_offset"]);
312
+ requireColumns(historyDatabase, "thread_items", ["thread_id", "item_type"]);
313
+ requireColumns(historyDatabase, "thread_history_projection_state", ["thread_id", "next_rollout_byte_offset"]);
314
+ requireColumns(historyDatabase, "thread_realtime_items", ["thread_id", "item_type"]);
315
+
316
+ const rows = migrationCandidates(stateDatabase, codexHome, targetProvider, threadId);
317
+ const failures = [];
318
+ for (const row of rows) {
319
+ if (!fs.existsSync(row.file)) {
320
+ failures.push({ threadId: row.id, error: `rollout 文件不存在:${row.file}` });
321
+ continue;
322
+ }
323
+ try {
324
+ const stat = fs.statSync(row.file);
325
+ const transformed = transformCodexRolloutFile(row.file, { threadId: row.id, targetProvider });
326
+ if (!row.needsMigration && transformed.sourceProvider === targetProvider) {
327
+ fs.unlinkSync(transformed.temporary);
328
+ continue;
329
+ }
330
+ prepared.push({
331
+ threadId: row.id,
332
+ file: row.file,
333
+ mode: stat.mode & 0o777,
334
+ originalSize: stat.size,
335
+ originalMtimeMs: stat.mtimeMs,
336
+ ...transformed,
337
+ });
338
+ } catch (error) {
339
+ failures.push({ threadId: row.id, error: error.message });
340
+ }
341
+ }
342
+
343
+ if (failures.length > 0) {
344
+ for (const item of prepared) {
345
+ try { fs.unlinkSync(item.temporary); } catch { /* Best effort cleanup. */ }
346
+ }
347
+ throw new Error(`全量迁移预检查失败,未修改任何任务。首个错误:${failures[0].error}`);
348
+ }
349
+
350
+ if (prepared.length === 0) {
351
+ stdout(`全部 Codex 任务已经使用 ${targetProvider},无需修改。`);
352
+ return { migrated: [], failed: [], backupDirectory: null, targetProvider, targetModel };
353
+ }
354
+
355
+ if (dryRun) {
356
+ for (const item of prepared) fs.unlinkSync(item.temporary);
357
+ return {
358
+ planned: prepared.map((item) => ({ threadId: item.threadId, sourceProvider: item.sourceProvider })),
359
+ failed: [],
360
+ targetProvider,
361
+ targetModel,
362
+ };
363
+ }
364
+
365
+ const backupDirectory = path.join(backupRoot, `${timestampForPath(now)}-to-${targetProvider}`);
366
+ const rolloutBackupDirectory = path.join(backupDirectory, "rollouts");
367
+ fs.mkdirSync(rolloutBackupDirectory, { recursive: true, mode: 0o700 });
368
+ stateBackup = path.join(backupDirectory, path.basename(state.file));
369
+ historyBackup = path.join(backupDirectory, path.basename(history.file));
370
+ await sqlite.backup(stateDatabase, stateBackup);
371
+ await sqlite.backup(historyDatabase, historyBackup);
372
+
373
+ for (const item of prepared) {
374
+ item.backupFile = path.join(rolloutBackupDirectory, `${item.threadId}.jsonl`);
375
+ fs.copyFileSync(item.file, item.backupFile);
376
+ }
377
+ const manifestFile = path.join(backupDirectory, "manifest.json");
378
+ const manifest = {
379
+ version: 1,
380
+ status: "prepared",
381
+ createdAt: now.toISOString(),
382
+ targetProvider,
383
+ targetModel,
384
+ stateDatabase: state.file,
385
+ historyDatabase: history.file,
386
+ threads: prepared.map((item) => ({
387
+ threadId: item.threadId,
388
+ rolloutPath: item.file,
389
+ backupPath: item.backupFile,
390
+ sourceProvider: item.sourceProvider,
391
+ byteDelta: item.byteDelta,
392
+ removedReasoning: item.removedReasoning,
393
+ removedCompaction: item.removedCompaction,
394
+ })),
395
+ failures,
396
+ };
397
+ writeManifest(manifestFile, manifest);
398
+
399
+ for (const item of prepared) assertUnchanged(item);
400
+ for (const item of prepared) {
401
+ fs.chmodSync(item.temporary, item.mode);
402
+ fs.renameSync(item.temporary, item.file);
403
+ }
404
+
405
+ stateDatabase.exec("BEGIN IMMEDIATE");
406
+ historyDatabase.exec("BEGIN IMMEDIATE");
407
+ try {
408
+ const updateThread = stateDatabase.prepare("UPDATE threads SET model_provider = ?, model = ? WHERE id = ?");
409
+ const selectOffsets = historyDatabase.prepare("SELECT turn_id, rollout_byte_offset, rollout_end_byte_offset FROM thread_turns WHERE thread_id = ?");
410
+ const updateOffsets = historyDatabase.prepare("UPDATE thread_turns SET rollout_byte_offset = ?, rollout_end_byte_offset = ? WHERE thread_id = ? AND turn_id = ?");
411
+ const selectProjection = historyDatabase.prepare("SELECT next_rollout_byte_offset FROM thread_history_projection_state WHERE thread_id = ?");
412
+ const updateProjection = historyDatabase.prepare("UPDATE thread_history_projection_state SET next_rollout_byte_offset = ? WHERE thread_id = ?");
413
+ const deleteItems = historyDatabase.prepare("DELETE FROM thread_items WHERE thread_id = ? AND item_type IN ('reasoning', 'contextCompaction')");
414
+ const deleteRealtime = historyDatabase.prepare("DELETE FROM thread_realtime_items WHERE thread_id = ? AND item_type IN ('reasoning', 'contextCompaction')");
415
+ for (const item of prepared) {
416
+ updateThread.run(targetProvider, targetModel, item.threadId);
417
+ if (item.offsetChanges.length > 0) {
418
+ for (const turn of selectOffsets.all(item.threadId)) {
419
+ updateOffsets.run(
420
+ mapRolloutOffset(turn.rollout_byte_offset, item.offsetChanges),
421
+ mapRolloutOffset(turn.rollout_end_byte_offset, item.offsetChanges),
422
+ item.threadId,
423
+ turn.turn_id,
424
+ );
425
+ }
426
+ const projection = selectProjection.get(item.threadId);
427
+ if (projection) {
428
+ updateProjection.run(mapRolloutOffset(projection.next_rollout_byte_offset, item.offsetChanges), item.threadId);
429
+ }
430
+ }
431
+ deleteItems.run(item.threadId);
432
+ deleteRealtime.run(item.threadId);
433
+ }
434
+ historyDatabase.exec("COMMIT");
435
+ stateDatabase.exec("COMMIT");
436
+ } catch (error) {
437
+ try { historyDatabase.exec("ROLLBACK"); } catch { /* Transaction may already be closed. */ }
438
+ try { stateDatabase.exec("ROLLBACK"); } catch { /* Transaction may already be closed. */ }
439
+ throw error;
440
+ }
441
+
442
+ const remaining = stateDatabase.prepare("SELECT COUNT(*) AS count FROM threads WHERE model_provider <> ?").get(targetProvider).count;
443
+ if (Number(remaining) !== 0 && !threadId) throw new Error(`数据库验证失败:仍有 ${remaining} 个任务未切换 provider。`);
444
+ for (const item of prepared) {
445
+ const first = lineParts(fs.readFileSync(item.file)).find((part) => part.body.trim());
446
+ const meta = JSON.parse(first.body);
447
+ if (meta.type !== "session_meta" || meta.payload?.model_provider !== targetProvider) {
448
+ throw new Error(`rollout 验证失败:${item.threadId}`);
449
+ }
450
+ }
451
+
452
+ manifest.status = "complete";
453
+ manifest.completedAt = new Date().toISOString();
454
+ writeManifest(manifestFile, manifest);
455
+ const migrated = prepared.map((item) => ({
456
+ threadId: item.threadId,
457
+ sourceProvider: item.sourceProvider,
458
+ removedReasoning: item.removedReasoning,
459
+ removedCompaction: item.removedCompaction,
460
+ }));
461
+ stdout(`✓ 已将 ${migrated.length} 个 Codex 任务原地切换到 ${targetProvider};任务 ID 和可见聊天历史保持不变。`);
462
+ stdout(`迁移备份:${backupDirectory}`);
463
+ return { migrated, failed: failures, backupDirectory, targetProvider, targetModel };
464
+ } catch (error) {
465
+ try { stateDatabase?.close(); } catch { /* Best effort. */ }
466
+ try { historyDatabase?.close(); } catch { /* Best effort. */ }
467
+ stateDatabase = null;
468
+ historyDatabase = null;
469
+ if (prepared.some((item) => item.backupFile)) {
470
+ try {
471
+ restoreFiles(prepared);
472
+ if (stateBackup) restoreDatabase(stateBackup, latestNumberedDatabase(codexHome, "state").file);
473
+ if (historyBackup) restoreDatabase(historyBackup, latestNumberedDatabase(codexHome, "thread_history").file);
474
+ } catch (rollbackError) {
475
+ throw new Error(`${error.message};自动回滚也失败:${rollbackError.message}`);
476
+ }
477
+ }
478
+ throw error;
479
+ } finally {
480
+ for (const item of prepared) {
481
+ if (!item.temporary) continue;
482
+ try { fs.unlinkSync(item.temporary); } catch { /* Already committed or cleaned up. */ }
483
+ }
484
+ try { stateDatabase?.close(); } catch { /* Best effort. */ }
485
+ try { historyDatabase?.close(); } catch { /* Best effort. */ }
486
+ releaseLock();
487
+ }
488
+ }
489
+
490
+ export const __testing = {
491
+ latestNumberedDatabase,
492
+ mapRolloutOffset,
493
+ privateRecordKind,
494
+ timestampForPath,
495
+ };
@@ -5,6 +5,7 @@ import process from "node:process";
5
5
  import { spawn as nodeSpawn } from "node:child_process";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { requestJson } from "./http.mjs";
8
+ import { switchCodexThreadsInPlace } from "./codex-in-place.mjs";
8
9
 
9
10
  const DEFAULT_LIMIT = 10;
10
11
  const REQUEST_TIMEOUT_MS = 15_000;
@@ -136,6 +137,20 @@ function mergeCandidateThreads(serverThreads, sessionThreads, limit) {
136
137
  .slice(0, limit);
137
138
  }
138
139
 
140
+ function mergeProviderThreads(serverThreads, sessionThreads, provider, limit = Number.POSITIVE_INFINITY) {
141
+ const threads = new Map();
142
+ for (const thread of sessionThreads) if (thread?.id) threads.set(thread.id, thread);
143
+ for (const thread of serverThreads) if (thread?.id) threads.set(thread.id, { ...threads.get(thread.id), ...thread });
144
+ return [...threads.values()]
145
+ .filter((thread) => thread.modelProvider === provider && !thread.ephemeral)
146
+ .sort((left, right) => {
147
+ const leftTime = left.recencyAt || left.updatedAt || left.createdAt || 0;
148
+ const rightTime = right.recencyAt || right.updatedAt || right.createdAt || 0;
149
+ return rightTime - leftTime;
150
+ })
151
+ .slice(0, limit);
152
+ }
153
+
139
154
  export function describeCodexThread(thread, { now = Date.now() } = {}) {
140
155
  const title = oneLine(thread.name || thread.preview, "未命名任务");
141
156
  const project = path.basename(String(thread.cwd || "")) || String(thread.cwd || "未知项目");
@@ -173,6 +188,35 @@ export async function selectCodexThread(threads, {
173
188
  }
174
189
  }
175
190
 
191
+ export async function selectCodexRestoreThreads(threads, {
192
+ input = process.stdin,
193
+ output = process.stdout,
194
+ now = Date.now(),
195
+ } = {}) {
196
+ if (!input?.isTTY || !output?.isTTY) return null;
197
+
198
+ output.write("\n发现仍绑定 4YI Gateway 的 Codex 任务。选择要复制到 OpenAI 的任务:\n\n");
199
+ threads.forEach((thread, index) => {
200
+ const item = describeCodexThread(thread, { now });
201
+ output.write(`${index + 1}. ${item.title}\n ${item.project} · ${item.age}\n`);
202
+ });
203
+
204
+ const readline = createInterface({ input, output });
205
+ try {
206
+ while (true) {
207
+ const answer = await readline.question("\n输入序号(默认 1,a 复制以上全部,s 跳过):");
208
+ const value = answer.trim().toLowerCase();
209
+ if (value === "s" || value === "skip" || value === "q" || value === "quit") return [];
210
+ if (value === "a" || value === "all") return threads;
211
+ const index = value === "" ? 0 : Number(value) - 1;
212
+ if (Number.isInteger(index) && index >= 0 && index < threads.length) return [threads[index]];
213
+ output.write(`请输入 1-${threads.length} 之间的序号、a 或 s。\n`);
214
+ }
215
+ } finally {
216
+ readline.close();
217
+ }
218
+ }
219
+
176
220
  export class CodexAppServerClient {
177
221
  constructor({
178
222
  command = executableName("codex"),
@@ -292,6 +336,152 @@ async function loadDefaultCodexModel(session) {
292
336
  return ids.includes(response.default_model) ? response.default_model : ids[0];
293
337
  }
294
338
 
339
+ async function loadDefaultOpenAIModel(appServer) {
340
+ const response = await appServer.request("model/list", { limit: 100, includeHidden: false });
341
+ const models = response?.data || [];
342
+ const selected = models.find((model) => model?.isDefault) || models[0];
343
+ const id = selected?.id || selected?.model;
344
+ if (!id) throw new Error("Codex did not report an available OpenAI model after restore.");
345
+ return id;
346
+ }
347
+
348
+ async function listProviderThreads(appServer, provider, limit = SESSION_SCAN_LIMIT) {
349
+ const threads = [];
350
+ let cursor;
351
+ do {
352
+ const response = await appServer.request("thread/list", {
353
+ cursor,
354
+ limit: Math.min(100, limit - threads.length),
355
+ sortKey: "recency_at",
356
+ sortDirection: "desc",
357
+ modelProviders: [provider],
358
+ useStateDbOnly: false,
359
+ });
360
+ threads.push(...(response?.data || []));
361
+ cursor = response?.nextCursor || null;
362
+ } while (cursor && threads.length < limit);
363
+ return threads;
364
+ }
365
+
366
+ export async function restoreCodexTasksByFork({
367
+ threadId,
368
+ migrateTasks,
369
+ model,
370
+ limit = DEFAULT_LIMIT,
371
+ input = process.stdin,
372
+ output = process.stdout,
373
+ stdout = console.log,
374
+ client,
375
+ clientOptions = {},
376
+ selectThreads = selectCodexRestoreThreads,
377
+ scanThreads = scanCodexSessionThreads,
378
+ } = {}) {
379
+ const appServer = client || new CodexAppServerClient(clientOptions);
380
+ try {
381
+ await appServer.initialize();
382
+ let selected;
383
+ if (threadId) {
384
+ selected = [{ id: threadId }];
385
+ } else {
386
+ const serverThreads = await listProviderThreads(appServer, "4yi");
387
+ const sessionThreads = scanThreads({ codexHome: codexHomeForEnv(clientOptions.env || process.env) });
388
+ const candidates = mergeProviderThreads(serverThreads, sessionThreads, "4yi");
389
+ if (candidates.length === 0) {
390
+ stdout("未发现仍绑定 4YI Gateway 的 Codex 任务。新任务将使用 OpenAI。");
391
+ return { cancelled: false, migrated: [], failed: [] };
392
+ }
393
+ if (migrateTasks === "all") {
394
+ selected = candidates;
395
+ } else if (!input?.isTTY || !output?.isTTY) {
396
+ stdout(`发现 ${candidates.length} 个仍绑定 4YI Gateway 的任务;非交互模式未自动复制。可运行 \`4yi restore codex --migrate-tasks all\` 或使用 \`--thread <任务ID>\`。`);
397
+ return { cancelled: true, migrated: [], failed: [] };
398
+ } else {
399
+ selected = await selectThreads(candidates.slice(0, limit), { input, output });
400
+ if (!selected || selected.length === 0) {
401
+ stdout("已跳过任务复制。原 4YI 任务未修改。");
402
+ return { cancelled: true, migrated: [], failed: [] };
403
+ }
404
+ }
405
+ }
406
+
407
+ const selectedModel = model || await loadDefaultOpenAIModel(appServer);
408
+ const migrated = [];
409
+ const failed = [];
410
+ for (const thread of selected) {
411
+ const item = describeCodexThread(thread);
412
+ stdout(`正在复制到 OpenAI:${item.title}…`);
413
+ try {
414
+ const result = await appServer.request("thread/fork", {
415
+ threadId: thread.id,
416
+ model: selectedModel,
417
+ modelProvider: "openai",
418
+ excludeTurns: true,
419
+ });
420
+ if (!result?.thread?.id) throw new Error("Codex did not return the copied task id.");
421
+ if (result.modelProvider !== "openai" && result.thread.modelProvider !== "openai") {
422
+ throw new Error("Codex created the task but did not apply the OpenAI provider.");
423
+ }
424
+ migrated.push({ sourceThreadId: thread.id, threadId: result.thread.id });
425
+ stdout(`✓ OpenAI 任务 ID: ${result.thread.id}`);
426
+ } catch (error) {
427
+ failed.push({ sourceThreadId: thread.id, error: error.message });
428
+ stdout(`✗ 复制失败(${thread.id}):${error.message}`);
429
+ }
430
+ }
431
+ stdout(`任务复制完成:成功 ${migrated.length},失败 ${failed.length}。原 4YI 任务均未修改。`);
432
+ return { cancelled: false, migrated, failed, model: selectedModel };
433
+ } finally {
434
+ if (!client) await appServer.close();
435
+ }
436
+ }
437
+
438
+ export async function restoreCodexTasksToOpenAI({
439
+ threadId,
440
+ model,
441
+ stdout = console.log,
442
+ client,
443
+ clientOptions = {},
444
+ switchThreads = switchCodexThreadsInPlace,
445
+ dryRun = false,
446
+ } = {}) {
447
+ const appServer = client || new CodexAppServerClient(clientOptions);
448
+ try {
449
+ await appServer.initialize();
450
+ const selectedModel = model || await loadDefaultOpenAIModel(appServer);
451
+ return await switchThreads({
452
+ targetProvider: "openai",
453
+ targetModel: selectedModel,
454
+ threadId,
455
+ codexHome: codexHomeForEnv(clientOptions.env || process.env),
456
+ stdout,
457
+ dryRun,
458
+ });
459
+ } finally {
460
+ if (!client) await appServer.close();
461
+ }
462
+ }
463
+
464
+ export async function migrateCodexTasksTo4yi({
465
+ session,
466
+ threadId,
467
+ model,
468
+ stdout = console.log,
469
+ clientOptions = {},
470
+ switchThreads = switchCodexThreadsInPlace,
471
+ dryRun = false,
472
+ } = {}) {
473
+ if (!session?.token) throw new Error("Not signed in. Run `4yi login`.");
474
+ const selectedModel = model || await loadDefaultCodexModel(session);
475
+ return switchThreads({
476
+ targetProvider: "4yi",
477
+ targetModel: selectedModel,
478
+ threadId,
479
+ codexHome: codexHomeForEnv(clientOptions.env || process.env),
480
+ stdout,
481
+ dryRun,
482
+ });
483
+ }
484
+
295
485
  export async function migrateCodexTask({
296
486
  session,
297
487
  threadId,
@@ -355,6 +545,9 @@ export const __testing = {
355
545
  codexHomeForEnv,
356
546
  loadDefaultCodexModel,
357
547
  mergeCandidateThreads,
548
+ mergeProviderThreads,
549
+ listProviderThreads,
550
+ loadDefaultOpenAIModel,
358
551
  relativeTime,
359
552
  scanCodexSessionThreads,
360
553
  sessionThreadFromFile,
package/src/connect.mjs CHANGED
@@ -720,12 +720,35 @@ function restoreOne(target, home, stdout, { required = true } = {}) {
720
720
 
721
721
  export function restoreConnection({ target = "all", home = os.homedir(), stdout = console.log } = {}) {
722
722
  const normalized = normalizeTarget(target);
723
- if (normalized === "claude") return restoreOne("claude", home, stdout);
724
- if (normalized === "codex") return restoreOne("codex", home, stdout);
723
+ if (normalized === "claude") {
724
+ restoreOne("claude", home, stdout);
725
+ return { restoredClaude: true, restoredCodex: false };
726
+ }
727
+ if (normalized === "codex") {
728
+ restoreOne("codex", home, stdout);
729
+ return { restoredClaude: false, restoredCodex: true };
730
+ }
725
731
  const restoredClaude = restoreOne("claude", home, stdout, { required: false });
726
732
  const restoredCodex = restoreOne("codex", home, stdout, { required: false });
727
733
  if (!restoredClaude && !restoredCodex) throw new Error("No Claude or Codex backup found.");
728
- return true;
734
+ return { restoredClaude, restoredCodex };
735
+ }
736
+
737
+ export function captureCodexConnectionState({ home = os.homedir(), codexHome = process.env.CODEX_HOME } = {}) {
738
+ const paths = connectionPaths({ home, codexHome });
739
+ const files = [paths.codexConfig, paths.codexCatalog, paths.codexCredentialHelper];
740
+ return files.map((file) => {
741
+ if (!fs.existsSync(file)) return { file, exists: false };
742
+ const stat = fs.statSync(file);
743
+ return { file, exists: true, content: fs.readFileSync(file), mode: stat.mode & 0o777 };
744
+ });
745
+ }
746
+
747
+ export function restoreCodexConnectionState(snapshot = []) {
748
+ for (const item of snapshot) {
749
+ if (item.exists) atomicWrite(item.file, item.content, item.mode);
750
+ else if (fs.existsSync(item.file)) fs.unlinkSync(item.file);
751
+ }
729
752
  }
730
753
 
731
754
  export const __testing = {