@4yi-dev/cli 0.1.18 → 0.1.20
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 +4 -0
- package/package.json +1 -1
- package/src/codex-in-place.mjs +227 -34
- package/src/connect.mjs +17 -0
package/README.md
CHANGED
|
@@ -37,12 +37,16 @@ Both packages install the `4yi` command. The dev package points at `https://xcla
|
|
|
37
37
|
|
|
38
38
|
On macOS, `4yi connect claude` also configures an installed Claude Desktop through its official third-party Gateway mode. The Desktop credential helper reads the current token from `~/.4yi/config.json`; the token is not copied into Claude's configuration. Quit and reopen Claude Desktop only after its active tasks finish. Third-party mode has a separate session list from the normal Claude profile. Run `4yi restore claude`, then reopen Claude Desktop, to return to the previous profile and session list.
|
|
39
39
|
|
|
40
|
+
`4yi restore claude` skips backup snapshots that were themselves created while 4YI was active, so repeated `connect` calls still return to the latest official configuration. After restoring, quit every running Claude Code and Claude App process and open a new terminal: an existing process has already loaded `ANTHROPIC_*` into memory, and Claude's `/logout` command does not override provider environment variables.
|
|
41
|
+
|
|
40
42
|
`--scope project` changes only the project's Claude Code settings and never changes the global Claude Desktop profile.
|
|
41
43
|
|
|
42
44
|
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
45
|
|
|
44
46
|
`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
47
|
|
|
48
|
+
Paginated tasks are migrated as a complete history lineage. The CLI validates every ancestor cutoff, removes provider-private reasoning and compaction objects across all linked rollouts, rebuilds byte offsets from stable ordinals, and invalidates the UI projection for a clean rebuild. If any lineage or database check fails, the whole migration is rolled back from its backup.
|
|
49
|
+
|
|
46
50
|
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
51
|
|
|
48
52
|
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.
|
package/package.json
CHANGED
package/src/codex-in-place.mjs
CHANGED
|
@@ -47,8 +47,8 @@ function lineParts(buffer) {
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
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") {
|
|
50
|
+
if (record?.type === "response_item" && String(record.payload?.type).toLowerCase() === "reasoning") return "reasoning";
|
|
51
|
+
if (record?.type === "event_msg" && record.payload?.type === "item_completed" && String(record.payload?.item?.type).toLowerCase() === "reasoning") {
|
|
52
52
|
return "reasoning-event";
|
|
53
53
|
}
|
|
54
54
|
if (record?.type === "compacted") return "compaction";
|
|
@@ -75,10 +75,11 @@ function tombstoneRecord(record, kind, byteLength) {
|
|
|
75
75
|
return `${json}${" ".repeat(byteLength - length)}`;
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
function newTransformState(threadId, targetProvider) {
|
|
78
|
+
function newTransformState(threadId, targetProvider, historyBase) {
|
|
79
79
|
return {
|
|
80
80
|
threadId,
|
|
81
81
|
targetProvider,
|
|
82
|
+
historyBase,
|
|
82
83
|
sessionMetaCount: 0,
|
|
83
84
|
matchingSessionMetaCount: 0,
|
|
84
85
|
sourceProviders: new Set(),
|
|
@@ -87,6 +88,8 @@ function newTransformState(threadId, targetProvider) {
|
|
|
87
88
|
originalOffset: 0,
|
|
88
89
|
cumulativeDelta: 0,
|
|
89
90
|
offsetChanges: [],
|
|
91
|
+
ordinalBoundaries: [],
|
|
92
|
+
changed: false,
|
|
90
93
|
};
|
|
91
94
|
}
|
|
92
95
|
|
|
@@ -103,12 +106,20 @@ function transformRolloutBody(body, state) {
|
|
|
103
106
|
const recordThreadId = record.payload?.id || record.payload?.session_id;
|
|
104
107
|
if (recordThreadId === state.threadId) state.matchingSessionMetaCount += 1;
|
|
105
108
|
state.sourceProviders.add(record.payload?.model_provider || record.payload?.modelProvider || "openai");
|
|
109
|
+
if (record.payload?.model_provider !== state.targetProvider || record.payload?.modelProvider !== undefined) {
|
|
110
|
+
state.changed = true;
|
|
111
|
+
}
|
|
106
112
|
record.payload.model_provider = state.targetProvider;
|
|
107
113
|
delete record.payload.modelProvider;
|
|
114
|
+
if (state.historyBase && recordThreadId === state.threadId) {
|
|
115
|
+
if (JSON.stringify(record.payload.history_base) !== JSON.stringify(state.historyBase)) state.changed = true;
|
|
116
|
+
record.payload.history_base = state.historyBase;
|
|
117
|
+
}
|
|
108
118
|
return JSON.stringify(record);
|
|
109
119
|
}
|
|
110
120
|
const kind = privateRecordKind(record);
|
|
111
121
|
if (!kind) return body;
|
|
122
|
+
state.changed = true;
|
|
112
123
|
if (kind.startsWith("reasoning")) state.removedReasoning += 1;
|
|
113
124
|
else state.removedCompaction += 1;
|
|
114
125
|
return tombstoneRecord(record, kind, Buffer.byteLength(body));
|
|
@@ -132,26 +143,40 @@ function transformResult(state) {
|
|
|
132
143
|
offsetChanges: state.offsetChanges,
|
|
133
144
|
removedReasoning: state.removedReasoning,
|
|
134
145
|
removedCompaction: state.removedCompaction,
|
|
146
|
+
ordinalBoundaries: state.ordinalBoundaries,
|
|
147
|
+
changed: state.changed,
|
|
135
148
|
};
|
|
136
149
|
}
|
|
137
150
|
|
|
138
|
-
export function transformCodexRollout(buffer, { threadId, targetProvider }) {
|
|
139
|
-
const state = newTransformState(threadId, targetProvider);
|
|
151
|
+
export function transformCodexRollout(buffer, { threadId, targetProvider, historyBase }) {
|
|
152
|
+
const state = newTransformState(threadId, targetProvider, historyBase);
|
|
140
153
|
const transformed = [];
|
|
141
154
|
for (const { body, newline } of lineParts(buffer)) {
|
|
155
|
+
const originalStart = state.originalOffset;
|
|
156
|
+
let ordinal;
|
|
157
|
+
try { ordinal = JSON.parse(body).ordinal; } catch { /* transformRolloutBody reports malformed JSON. */ }
|
|
142
158
|
const nextLine = `${transformRolloutBody(body, state)}${newline}`;
|
|
143
159
|
transformed.push(nextLine);
|
|
144
160
|
recordOffsetChange(state, Buffer.byteLength(`${body}${newline}`), Buffer.byteLength(nextLine));
|
|
161
|
+
if (Number.isSafeInteger(ordinal) && ordinal >= 0) {
|
|
162
|
+
state.ordinalBoundaries.push({
|
|
163
|
+
ordinal,
|
|
164
|
+
originalStart,
|
|
165
|
+
originalEnd: state.originalOffset,
|
|
166
|
+
transformedStart: originalStart + state.cumulativeDelta - (Buffer.byteLength(nextLine) - Buffer.byteLength(`${body}${newline}`)),
|
|
167
|
+
transformedEnd: state.originalOffset + state.cumulativeDelta,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
145
170
|
}
|
|
146
171
|
const output = Buffer.from(transformed.join(""), "utf8");
|
|
147
172
|
return { buffer: output, ...transformResult(state) };
|
|
148
173
|
}
|
|
149
174
|
|
|
150
|
-
function transformCodexRolloutFile(file, { threadId, targetProvider }) {
|
|
175
|
+
function transformCodexRolloutFile(file, { threadId, targetProvider, historyBase }) {
|
|
151
176
|
const temporary = `${file}.4yi-migration-${process.pid}.tmp`;
|
|
152
177
|
const input = fs.openSync(file, "r");
|
|
153
178
|
const output = fs.openSync(temporary, "wx", 0o600);
|
|
154
|
-
const state = newTransformState(threadId, targetProvider);
|
|
179
|
+
const state = newTransformState(threadId, targetProvider, historyBase);
|
|
155
180
|
let pending = Buffer.alloc(0);
|
|
156
181
|
const chunk = Buffer.alloc(1024 * 1024);
|
|
157
182
|
let failure;
|
|
@@ -164,17 +189,43 @@ function transformCodexRolloutFile(file, { threadId, targetProvider }) {
|
|
|
164
189
|
while ((newlineIndex = pending.indexOf(0x0a)) >= 0) {
|
|
165
190
|
const originalLine = pending.subarray(0, newlineIndex + 1);
|
|
166
191
|
const body = originalLine.subarray(0, -1).toString("utf8");
|
|
192
|
+
const originalStart = state.originalOffset;
|
|
193
|
+
let ordinal;
|
|
194
|
+
try { ordinal = JSON.parse(body).ordinal; } catch { /* transformRolloutBody reports malformed JSON. */ }
|
|
167
195
|
const nextLine = Buffer.from(`${transformRolloutBody(body, state)}\n`, "utf8");
|
|
168
196
|
fs.writeSync(output, nextLine);
|
|
197
|
+
const previousDelta = state.cumulativeDelta;
|
|
169
198
|
recordOffsetChange(state, originalLine.length, nextLine.length);
|
|
199
|
+
if (Number.isSafeInteger(ordinal) && ordinal >= 0) {
|
|
200
|
+
state.ordinalBoundaries.push({
|
|
201
|
+
ordinal,
|
|
202
|
+
originalStart,
|
|
203
|
+
originalEnd: state.originalOffset,
|
|
204
|
+
transformedStart: originalStart + previousDelta,
|
|
205
|
+
transformedEnd: state.originalOffset + state.cumulativeDelta,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
170
208
|
pending = pending.subarray(newlineIndex + 1);
|
|
171
209
|
}
|
|
172
210
|
}
|
|
173
211
|
if (pending.length > 0) {
|
|
174
212
|
const body = pending.toString("utf8");
|
|
213
|
+
const originalStart = state.originalOffset;
|
|
214
|
+
let ordinal;
|
|
215
|
+
try { ordinal = JSON.parse(body).ordinal; } catch { /* transformRolloutBody reports malformed JSON. */ }
|
|
175
216
|
const nextLine = Buffer.from(transformRolloutBody(body, state), "utf8");
|
|
176
217
|
fs.writeSync(output, nextLine);
|
|
218
|
+
const previousDelta = state.cumulativeDelta;
|
|
177
219
|
recordOffsetChange(state, pending.length, nextLine.length);
|
|
220
|
+
if (Number.isSafeInteger(ordinal) && ordinal >= 0) {
|
|
221
|
+
state.ordinalBoundaries.push({
|
|
222
|
+
ordinal,
|
|
223
|
+
originalStart,
|
|
224
|
+
originalEnd: state.originalOffset,
|
|
225
|
+
transformedStart: originalStart + previousDelta,
|
|
226
|
+
transformedEnd: state.originalOffset + state.cumulativeDelta,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
178
229
|
}
|
|
179
230
|
} catch (error) {
|
|
180
231
|
failure = error;
|
|
@@ -194,6 +245,99 @@ function transformCodexRolloutFile(file, { threadId, targetProvider }) {
|
|
|
194
245
|
}
|
|
195
246
|
}
|
|
196
247
|
|
|
248
|
+
function readSessionMeta(file) {
|
|
249
|
+
const descriptor = fs.openSync(file, "r");
|
|
250
|
+
let pending = Buffer.alloc(0);
|
|
251
|
+
const chunk = Buffer.alloc(64 * 1024);
|
|
252
|
+
try {
|
|
253
|
+
while (pending.length <= 4 * 1024 * 1024) {
|
|
254
|
+
const bytes = fs.readSync(descriptor, chunk, 0, chunk.length, null);
|
|
255
|
+
if (bytes === 0) break;
|
|
256
|
+
pending = Buffer.concat([pending, chunk.subarray(0, bytes)]);
|
|
257
|
+
const newline = pending.indexOf(0x0a);
|
|
258
|
+
if (newline >= 0) pending = pending.subarray(0, newline);
|
|
259
|
+
if (newline >= 0) break;
|
|
260
|
+
}
|
|
261
|
+
} finally {
|
|
262
|
+
fs.closeSync(descriptor);
|
|
263
|
+
}
|
|
264
|
+
if (pending.length > 4 * 1024 * 1024) throw new Error(`rollout 的 session_meta 过大:${file}`);
|
|
265
|
+
let record;
|
|
266
|
+
try { record = JSON.parse(pending.toString("utf8")); } catch { throw new Error(`rollout 首行不是有效 JSON:${file}`); }
|
|
267
|
+
if (record?.type !== "session_meta" || !(record.payload?.id || record.payload?.session_id)) {
|
|
268
|
+
throw new Error(`rollout 首行缺少 session_meta:${file}`);
|
|
269
|
+
}
|
|
270
|
+
return record.payload;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function boundaryForOrdinalExclusive(item, endOrdinalExclusive) {
|
|
274
|
+
if (!Number.isSafeInteger(endOrdinalExclusive) || endOrdinalExclusive <= 0) {
|
|
275
|
+
throw new Error(`任务 ${item.threadId} 的 history_base.end_ordinal_exclusive 无效。`);
|
|
276
|
+
}
|
|
277
|
+
const boundary = item.ordinalBoundaries.find((entry) => entry.ordinal === endOrdinalExclusive - 1);
|
|
278
|
+
if (!boundary) {
|
|
279
|
+
throw new Error(`任务 ${item.threadId} 找不到 ordinal ${endOrdinalExclusive - 1} 的分页边界。`);
|
|
280
|
+
}
|
|
281
|
+
return boundary;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function resolveLineage(rows, allRows = rows) {
|
|
285
|
+
const byId = new Map(allRows.map((row) => [row.id, row]));
|
|
286
|
+
const metadataById = new Map();
|
|
287
|
+
for (const row of allRows) {
|
|
288
|
+
if (!fs.existsSync(row.file)) continue;
|
|
289
|
+
metadataById.set(row.id, readSessionMeta(row.file));
|
|
290
|
+
}
|
|
291
|
+
const included = new Set(rows.map((row) => row.id));
|
|
292
|
+
let ancestorsExpanded = true;
|
|
293
|
+
while (ancestorsExpanded) {
|
|
294
|
+
ancestorsExpanded = false;
|
|
295
|
+
for (const id of [...included]) {
|
|
296
|
+
const parentId = metadataById.get(id)?.history_base?.thread_id;
|
|
297
|
+
if (parentId && !included.has(parentId)) {
|
|
298
|
+
included.add(parentId);
|
|
299
|
+
ancestorsExpanded = true;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let expanded = true;
|
|
304
|
+
while (expanded) {
|
|
305
|
+
expanded = false;
|
|
306
|
+
for (const row of allRows) {
|
|
307
|
+
const parentId = metadataById.get(row.id)?.history_base?.thread_id;
|
|
308
|
+
if (parentId && included.has(parentId) && !included.has(row.id)) {
|
|
309
|
+
included.add(row.id);
|
|
310
|
+
expanded = true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const resolved = new Map();
|
|
315
|
+
const visiting = new Set();
|
|
316
|
+
function visit(row) {
|
|
317
|
+
if (resolved.has(row.id)) return;
|
|
318
|
+
if (visiting.has(row.id)) throw new Error(`检测到循环 paginated lineage:${row.id}`);
|
|
319
|
+
visiting.add(row.id);
|
|
320
|
+
if (!fs.existsSync(row.file)) throw new Error(`rollout 文件不存在:${row.file}`);
|
|
321
|
+
const metadata = metadataById.get(row.id) || readSessionMeta(row.file);
|
|
322
|
+
if ((metadata.id || metadata.session_id) !== row.id) {
|
|
323
|
+
throw new Error(`任务 ${row.id} 的 rollout session_meta ID 不一致。`);
|
|
324
|
+
}
|
|
325
|
+
const historyBase = metadata.history_base;
|
|
326
|
+
if (historyBase) {
|
|
327
|
+
if (metadata.history_mode !== "paginated") throw new Error(`任务 ${row.id} 包含 history_base,但 history_mode 不是 paginated。`);
|
|
328
|
+
const parent = byId.get(historyBase.thread_id);
|
|
329
|
+
if (!parent) throw new Error(`任务 ${row.id} 的分页祖先不存在:${historyBase.thread_id}`);
|
|
330
|
+
visit(parent);
|
|
331
|
+
}
|
|
332
|
+
visiting.delete(row.id);
|
|
333
|
+
resolved.set(row.id, { ...row, metadata });
|
|
334
|
+
}
|
|
335
|
+
for (const row of allRows) {
|
|
336
|
+
if (included.has(row.id)) visit(row);
|
|
337
|
+
}
|
|
338
|
+
return [...resolved.values()];
|
|
339
|
+
}
|
|
340
|
+
|
|
197
341
|
function mapRolloutOffset(offset, changes) {
|
|
198
342
|
if (offset === null || offset === undefined) return offset;
|
|
199
343
|
let delta = 0;
|
|
@@ -204,6 +348,13 @@ function mapRolloutOffset(offset, changes) {
|
|
|
204
348
|
return Number(offset) + delta;
|
|
205
349
|
}
|
|
206
350
|
|
|
351
|
+
function transformedOffsetForOrdinal(item, ordinal, edge) {
|
|
352
|
+
if (ordinal === null || ordinal === undefined) return null;
|
|
353
|
+
const boundary = item.ordinalBoundaries.find((entry) => entry.ordinal === Number(ordinal));
|
|
354
|
+
if (!boundary) throw new Error(`任务 ${item.threadId} 找不到 turn ordinal ${ordinal} 的字节边界。`);
|
|
355
|
+
return edge === "start" ? boundary.transformedStart : boundary.transformedEnd;
|
|
356
|
+
}
|
|
357
|
+
|
|
207
358
|
function atomicWrite(file, buffer, mode) {
|
|
208
359
|
const temporary = `${file}.4yi-migration-${process.pid}.tmp`;
|
|
209
360
|
fs.writeFileSync(temporary, buffer, { mode });
|
|
@@ -270,13 +421,14 @@ function restoreDatabase(backupFile, destination) {
|
|
|
270
421
|
|
|
271
422
|
function migrationCandidates(database, codexHome, targetProvider, threadId) {
|
|
272
423
|
const rows = database.prepare("SELECT id, rollout_path, model_provider, model FROM threads ORDER BY created_at ASC").all();
|
|
273
|
-
const
|
|
274
|
-
if (threadId && selected.length === 0) throw new Error(`找不到 Codex 任务:${threadId}`);
|
|
275
|
-
return selected.map((row) => ({
|
|
424
|
+
const all = rows.map((row) => ({
|
|
276
425
|
...row,
|
|
277
426
|
file: resolveRolloutPath(codexHome, row.rollout_path),
|
|
278
427
|
needsMigration: row.model_provider !== targetProvider,
|
|
279
428
|
}));
|
|
429
|
+
const selected = threadId ? all.filter((row) => row.id === threadId) : all;
|
|
430
|
+
if (threadId && selected.length === 0) throw new Error(`找不到 Codex 任务:${threadId}`);
|
|
431
|
+
return { all, selected };
|
|
280
432
|
}
|
|
281
433
|
|
|
282
434
|
export async function switchCodexThreadsInPlace({
|
|
@@ -313,8 +465,15 @@ export async function switchCodexThreadsInPlace({
|
|
|
313
465
|
requireColumns(historyDatabase, "thread_history_projection_state", ["thread_id", "next_rollout_byte_offset"]);
|
|
314
466
|
requireColumns(historyDatabase, "thread_realtime_items", ["thread_id", "item_type"]);
|
|
315
467
|
|
|
316
|
-
const
|
|
468
|
+
const candidates = migrationCandidates(stateDatabase, codexHome, targetProvider, threadId);
|
|
469
|
+
let rows;
|
|
470
|
+
try {
|
|
471
|
+
rows = resolveLineage(candidates.selected, candidates.all);
|
|
472
|
+
} catch (error) {
|
|
473
|
+
throw new Error(`全量迁移预检查失败,未修改任何任务。首个错误:${error.message}`);
|
|
474
|
+
}
|
|
317
475
|
const failures = [];
|
|
476
|
+
const analyzed = new Map();
|
|
318
477
|
for (const row of rows) {
|
|
319
478
|
if (!fs.existsSync(row.file)) {
|
|
320
479
|
failures.push({ threadId: row.id, error: `rollout 文件不存在:${row.file}` });
|
|
@@ -322,19 +481,36 @@ export async function switchCodexThreadsInPlace({
|
|
|
322
481
|
}
|
|
323
482
|
try {
|
|
324
483
|
const stat = fs.statSync(row.file);
|
|
325
|
-
|
|
326
|
-
if (
|
|
327
|
-
|
|
328
|
-
|
|
484
|
+
let historyBase = row.metadata.history_base;
|
|
485
|
+
if (historyBase) {
|
|
486
|
+
const parent = analyzed.get(historyBase.thread_id);
|
|
487
|
+
if (!parent) throw new Error(`任务 ${row.id} 的分页祖先尚未完成预检查:${historyBase.thread_id}`);
|
|
488
|
+
if (Number(historyBase.end_byte_offset) > parent.originalSize) {
|
|
489
|
+
throw new Error(`任务 ${row.id} 的分页 cutoff ${historyBase.end_byte_offset} 超出祖先文件长度 ${parent.originalSize}。`);
|
|
490
|
+
}
|
|
491
|
+
const boundary = boundaryForOrdinalExclusive(parent, Number(historyBase.end_ordinal_exclusive));
|
|
492
|
+
if (Number(historyBase.end_byte_offset) !== boundary.originalEnd) {
|
|
493
|
+
throw new Error(`任务 ${row.id} 的分页 cutoff 未落在 ordinal ${historyBase.end_ordinal_exclusive} 的合法边界。`);
|
|
494
|
+
}
|
|
495
|
+
historyBase = { ...historyBase, end_byte_offset: boundary.transformedEnd };
|
|
329
496
|
}
|
|
330
|
-
|
|
497
|
+
const transformed = transformCodexRolloutFile(row.file, { threadId: row.id, targetProvider, historyBase });
|
|
498
|
+
const analyzedItem = {
|
|
331
499
|
threadId: row.id,
|
|
332
500
|
file: row.file,
|
|
333
501
|
mode: stat.mode & 0o777,
|
|
334
502
|
originalSize: stat.size,
|
|
335
503
|
originalMtimeMs: stat.mtimeMs,
|
|
504
|
+
historyMode: row.metadata.history_mode || "legacy",
|
|
505
|
+
historyBase,
|
|
336
506
|
...transformed,
|
|
337
|
-
}
|
|
507
|
+
};
|
|
508
|
+
analyzed.set(row.id, analyzedItem);
|
|
509
|
+
if (!row.needsMigration && !transformed.changed) {
|
|
510
|
+
fs.unlinkSync(transformed.temporary);
|
|
511
|
+
continue;
|
|
512
|
+
}
|
|
513
|
+
prepared.push(analyzedItem);
|
|
338
514
|
} catch (error) {
|
|
339
515
|
failures.push({ threadId: row.id, error: error.message });
|
|
340
516
|
}
|
|
@@ -347,6 +523,34 @@ export async function switchCodexThreadsInPlace({
|
|
|
347
523
|
throw new Error(`全量迁移预检查失败,未修改任何任务。首个错误:${failures[0].error}`);
|
|
348
524
|
}
|
|
349
525
|
|
|
526
|
+
const turnColumns = tableColumns(historyDatabase, "thread_turns");
|
|
527
|
+
const hasOrdinalOffsets = turnColumns.has("rollout_ordinal") && turnColumns.has("rollout_end_ordinal");
|
|
528
|
+
const selectOffsets = historyDatabase.prepare(hasOrdinalOffsets
|
|
529
|
+
? "SELECT turn_id, rollout_ordinal, rollout_byte_offset, rollout_end_ordinal, rollout_end_byte_offset FROM thread_turns WHERE thread_id = ?"
|
|
530
|
+
: "SELECT turn_id, rollout_byte_offset, rollout_end_byte_offset FROM thread_turns WHERE thread_id = ?");
|
|
531
|
+
try {
|
|
532
|
+
for (const item of prepared) {
|
|
533
|
+
const rebuildByOrdinal = hasOrdinalOffsets && item.ordinalBoundaries.length > 0;
|
|
534
|
+
if (item.historyMode === "paginated" && !rebuildByOrdinal) {
|
|
535
|
+
throw new Error(`任务 ${item.threadId} 是 paginated 历史,但 rollout 不包含可重建的 ordinal。`);
|
|
536
|
+
}
|
|
537
|
+
item.turnOffsets = selectOffsets.all(item.threadId).map((turn) => ({
|
|
538
|
+
turnId: turn.turn_id,
|
|
539
|
+
start: rebuildByOrdinal
|
|
540
|
+
? transformedOffsetForOrdinal(item, turn.rollout_ordinal, "start")
|
|
541
|
+
: mapRolloutOffset(turn.rollout_byte_offset, item.offsetChanges),
|
|
542
|
+
end: rebuildByOrdinal && turn.rollout_end_ordinal !== null
|
|
543
|
+
? transformedOffsetForOrdinal(item, turn.rollout_end_ordinal, "end")
|
|
544
|
+
: mapRolloutOffset(turn.rollout_end_byte_offset, item.offsetChanges),
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
} catch (error) {
|
|
548
|
+
for (const item of prepared) {
|
|
549
|
+
try { fs.unlinkSync(item.temporary); } catch { /* Best effort cleanup. */ }
|
|
550
|
+
}
|
|
551
|
+
throw new Error(`全量迁移预检查失败,未修改任何任务。首个错误:${error.message}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
350
554
|
if (prepared.length === 0) {
|
|
351
555
|
stdout(`全部 Codex 任务已经使用 ${targetProvider},无需修改。`);
|
|
352
556
|
return { migrated: [], failed: [], backupDirectory: null, targetProvider, targetModel };
|
|
@@ -376,7 +580,7 @@ export async function switchCodexThreadsInPlace({
|
|
|
376
580
|
}
|
|
377
581
|
const manifestFile = path.join(backupDirectory, "manifest.json");
|
|
378
582
|
const manifest = {
|
|
379
|
-
version:
|
|
583
|
+
version: 2,
|
|
380
584
|
status: "prepared",
|
|
381
585
|
createdAt: now.toISOString(),
|
|
382
586
|
targetProvider,
|
|
@@ -387,6 +591,7 @@ export async function switchCodexThreadsInPlace({
|
|
|
387
591
|
threadId: item.threadId,
|
|
388
592
|
rolloutPath: item.file,
|
|
389
593
|
backupPath: item.backupFile,
|
|
594
|
+
historyBase: item.historyBase || null,
|
|
390
595
|
sourceProvider: item.sourceProvider,
|
|
391
596
|
byteDelta: item.byteDelta,
|
|
392
597
|
removedReasoning: item.removedReasoning,
|
|
@@ -406,28 +611,16 @@ export async function switchCodexThreadsInPlace({
|
|
|
406
611
|
historyDatabase.exec("BEGIN IMMEDIATE");
|
|
407
612
|
try {
|
|
408
613
|
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
614
|
const updateOffsets = historyDatabase.prepare("UPDATE thread_turns SET rollout_byte_offset = ?, rollout_end_byte_offset = ? WHERE thread_id = ? AND turn_id = ?");
|
|
411
|
-
const
|
|
412
|
-
const updateProjection = historyDatabase.prepare("UPDATE thread_history_projection_state SET next_rollout_byte_offset = ? WHERE thread_id = ?");
|
|
615
|
+
const invalidateProjection = historyDatabase.prepare("DELETE FROM thread_history_projection_state WHERE thread_id = ?");
|
|
413
616
|
const deleteItems = historyDatabase.prepare("DELETE FROM thread_items WHERE thread_id = ? AND item_type IN ('reasoning', 'contextCompaction')");
|
|
414
617
|
const deleteRealtime = historyDatabase.prepare("DELETE FROM thread_realtime_items WHERE thread_id = ? AND item_type IN ('reasoning', 'contextCompaction')");
|
|
415
618
|
for (const item of prepared) {
|
|
416
619
|
updateThread.run(targetProvider, targetModel, item.threadId);
|
|
417
|
-
|
|
418
|
-
|
|
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
|
-
}
|
|
620
|
+
for (const turn of item.turnOffsets) {
|
|
621
|
+
updateOffsets.run(turn.start, turn.end, item.threadId, turn.turnId);
|
|
430
622
|
}
|
|
623
|
+
invalidateProjection.run(item.threadId);
|
|
431
624
|
deleteItems.run(item.threadId);
|
|
432
625
|
deleteRealtime.run(item.threadId);
|
|
433
626
|
}
|
package/src/connect.mjs
CHANGED
|
@@ -269,6 +269,19 @@ function backupGroups(target, home) {
|
|
|
269
269
|
|
|
270
270
|
function latestBackups(target, home) {
|
|
271
271
|
const groups = backupGroups(target, home);
|
|
272
|
+
if (target === "claude") {
|
|
273
|
+
return groups.find((group) => !group.some(({ record }) => {
|
|
274
|
+
if (typeof record.content !== "string") return false;
|
|
275
|
+
try {
|
|
276
|
+
const value = JSON.parse(record.content);
|
|
277
|
+
return value?.env?.CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY === "1"
|
|
278
|
+
|| value?.appliedId === CLAUDE_DESKTOP_PROFILE_ID
|
|
279
|
+
|| value?.inferenceCredentialHelper?.includes("/.4yi/helpers/claude-desktop-credential");
|
|
280
|
+
} catch {
|
|
281
|
+
return record.content.includes("$HOME/.4yi/config.json");
|
|
282
|
+
}
|
|
283
|
+
})) || [];
|
|
284
|
+
}
|
|
272
285
|
if (target !== "codex") return groups[0] || [];
|
|
273
286
|
|
|
274
287
|
// Older CLI releases created a fresh backup every time `connect codex` ran,
|
|
@@ -722,6 +735,7 @@ export function restoreConnection({ target = "all", home = os.homedir(), stdout
|
|
|
722
735
|
const normalized = normalizeTarget(target);
|
|
723
736
|
if (normalized === "claude") {
|
|
724
737
|
restoreOne("claude", home, stdout);
|
|
738
|
+
stdout("Claude 已恢复为官方配置。请退出所有 Claude Code/Claude App 进程并打开新终端;当前终端继承的 ANTHROPIC_* 环境变量无法由子进程清除,/logout 也不会覆盖它们。");
|
|
725
739
|
return { restoredClaude: true, restoredCodex: false };
|
|
726
740
|
}
|
|
727
741
|
if (normalized === "codex") {
|
|
@@ -731,6 +745,9 @@ export function restoreConnection({ target = "all", home = os.homedir(), stdout
|
|
|
731
745
|
const restoredClaude = restoreOne("claude", home, stdout, { required: false });
|
|
732
746
|
const restoredCodex = restoreOne("codex", home, stdout, { required: false });
|
|
733
747
|
if (!restoredClaude && !restoredCodex) throw new Error("No Claude or Codex backup found.");
|
|
748
|
+
if (restoredClaude) {
|
|
749
|
+
stdout("Claude 已恢复为官方配置。请退出所有 Claude Code/Claude App 进程并打开新终端;当前终端继承的 ANTHROPIC_* 环境变量无法由子进程清除,/logout 也不会覆盖它们。");
|
|
750
|
+
}
|
|
734
751
|
return { restoredClaude, restoredCodex };
|
|
735
752
|
}
|
|
736
753
|
|