@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@4yi-dev/cli",
3
- "version": "0.1.18",
3
+ "version": "0.1.20",
4
4
  "description": "4YI command-line launcher for OAuth login and OpenCode runtime",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 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) => ({
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 rows = migrationCandidates(stateDatabase, codexHome, targetProvider, threadId);
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
- const transformed = transformCodexRolloutFile(row.file, { threadId: row.id, targetProvider });
326
- if (!row.needsMigration && transformed.sourceProvider === targetProvider) {
327
- fs.unlinkSync(transformed.temporary);
328
- continue;
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
- prepared.push({
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: 1,
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 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 = ?");
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
- 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
- }
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