@bitkyc08/opencodex 2.52.0 → 2.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-BlO4Yl6q.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -0,0 +1,474 @@
1
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Database } from "bun:sqlite";
4
+ import { SQLITE_ID_CHUNK, chunkIds, columnExists, insertRowsConflictIgnore, openDbWritable, selectRows, sqlRowEqual, tableExists, updateRowFromSnapshot, withWritableDb } from "./db";
5
+ import type { RuntimeDbPaths, SqlRow } from "./db";
6
+ import { writePrivateFile } from "./paths";
7
+
8
+ const JOB_KIND_MEMORY_STAGE1 = "memory_stage1";
9
+
10
+ const JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL = "memory_consolidate_global";
11
+
12
+ const MEMORY_CONSOLIDATION_JOB_KEY = "global";
13
+
14
+ const DEFAULT_RETRY_REMAINING = 3;
15
+
16
+ export interface SatelliteBackup {
17
+ threadIds: string[];
18
+ /** Full `threads` row images (SELECT *) captured under the state write lock. */
19
+ threads?: SqlRow[];
20
+ dynamicTools?: SqlRow[];
21
+ spawnEdges?: SqlRow[];
22
+ logs?: { path: string; rows: SqlRow[] };
23
+ memories?: {
24
+ path: string;
25
+ stage1: SqlRow[];
26
+ stage1Jobs: SqlRow[];
27
+ consolidateJob: SqlRow | null;
28
+ consolidateTouched: boolean;
29
+ /** Row image after deleteMemoriesInTx; set before memories commit (in-memory only). */
30
+ consolidatePostImage?: SqlRow | null;
31
+ };
32
+ goals?: {
33
+ path: string;
34
+ goals: SqlRow[];
35
+ deferrals: SqlRow[];
36
+ };
37
+ }
38
+
39
+ type SatelliteBackupRead =
40
+ | { status: "missing" }
41
+ | { status: "ok"; backup: SatelliteBackup }
42
+ | { status: "invalid" };
43
+
44
+ export const SATELLITE_BACKUP_FILE = "satellite-backup.json";
45
+
46
+ /** Snapshot state-DB dependents that cleanup deletes with the thread rows. */
47
+ export function snapshotStateDependents(
48
+ db: Database,
49
+ threadIds: string[],
50
+ ): Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> {
51
+ const out: Pick<SatelliteBackup, "threads" | "dynamicTools" | "spawnEdges"> = {};
52
+ if (threadIds.length === 0 || !tableExists(db, "threads")) return out;
53
+
54
+ const threads: SqlRow[] = [];
55
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
56
+ const placeholders = chunk.map(() => "?").join(",");
57
+ threads.push(...selectRows(db, `SELECT * FROM threads WHERE id IN (${placeholders})`, chunk));
58
+ }
59
+ out.threads = threads;
60
+
61
+ if (tableExists(db, "thread_dynamic_tools")) {
62
+ const dynamicTools: SqlRow[] = [];
63
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
64
+ const placeholders = chunk.map(() => "?").join(",");
65
+ dynamicTools.push(...selectRows(
66
+ db,
67
+ `SELECT * FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`,
68
+ chunk,
69
+ ));
70
+ }
71
+ out.dynamicTools = dynamicTools;
72
+ }
73
+
74
+ if (tableExists(db, "thread_spawn_edges")) {
75
+ const spawnEdges: SqlRow[] = [];
76
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK)) {
77
+ const placeholders = chunk.map(() => "?").join(",");
78
+ spawnEdges.push(...selectRows(
79
+ db,
80
+ `SELECT * FROM thread_spawn_edges
81
+ WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
82
+ [...chunk, ...chunk],
83
+ ));
84
+ }
85
+ out.spawnEdges = spawnEdges;
86
+ }
87
+
88
+ return out;
89
+ }
90
+
91
+ /** Remap serialized absolute DB paths onto the newest DBs under the current Codex home. */
92
+ export function remapSatelliteBackupPaths(
93
+ backup: SatelliteBackup,
94
+ paths: RuntimeDbPaths,
95
+ ): { ok: true; backup: SatelliteBackup } | { ok: false } {
96
+ const next: SatelliteBackup = {
97
+ threadIds: backup.threadIds,
98
+ ...(backup.threads ? { threads: backup.threads } : {}),
99
+ ...(backup.dynamicTools ? { dynamicTools: backup.dynamicTools } : {}),
100
+ ...(backup.spawnEdges ? { spawnEdges: backup.spawnEdges } : {}),
101
+ };
102
+ if (backup.logs) {
103
+ if (!paths.logs) return { ok: false };
104
+ next.logs = { ...backup.logs, path: paths.logs };
105
+ }
106
+ if (backup.memories) {
107
+ if (!paths.memories) return { ok: false };
108
+ next.memories = { ...backup.memories, path: paths.memories };
109
+ }
110
+ if (backup.goals) {
111
+ if (!paths.goals) return { ok: false };
112
+ next.goals = { ...backup.goals, path: paths.goals };
113
+ }
114
+ return { ok: true, backup: next };
115
+ }
116
+
117
+ export function readConsolidateGlobalJob(db: Database): SqlRow | null {
118
+ if (!tableExists(db, "jobs")) return null;
119
+ return db.query<SqlRow, [string, string]>(
120
+ "SELECT * FROM jobs WHERE kind = ? AND job_key = ?",
121
+ ).get(JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY) as SqlRow | null;
122
+ }
123
+
124
+ /** Revert delete-time enqueue only when the row still matches cleanup's post-delete image. */
125
+ export function restoreConsolidateGlobalJob(
126
+ db: Database,
127
+ snapshot: SqlRow | null,
128
+ postImage: SqlRow | null | undefined,
129
+ ): void {
130
+ if (!postImage) return;
131
+ const current = readConsolidateGlobalJob(db);
132
+ if (!current) {
133
+ if (snapshot) insertRowsConflictIgnore(db, "jobs", [snapshot]);
134
+ return;
135
+ }
136
+ if (!sqlRowEqual(current, postImage)) return;
137
+ if (snapshot) {
138
+ updateRowFromSnapshot(db, "jobs", snapshot, ["kind", "job_key"]);
139
+ } else {
140
+ db.run(
141
+ "DELETE FROM jobs WHERE kind = ? AND job_key = ?",
142
+ [JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY],
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Atomically replace satellite-backup.json: private temp in the stage, full write + fsync,
149
+ * rename (with Windows sharing-violation retries), then best-effort directory fsync.
150
+ * An interrupted update never truncates the last valid backup that was written before a
151
+ * satellite DB commit.
152
+ */
153
+ export function writeSatelliteBackup(
154
+ stageDir: string,
155
+ backup: SatelliteBackup,
156
+ options?: { failWrite?: boolean; failReplaceBeforeRename?: boolean },
157
+ ): void {
158
+ if (options?.failWrite) throw new Error("test_fail_satellite_backup_write");
159
+ const dest = join(stageDir, SATELLITE_BACKUP_FILE);
160
+ const replacing = existsSync(dest);
161
+ writePrivateFile(dest, JSON.stringify(backup), () => {
162
+ if (options?.failReplaceBeforeRename && replacing) {
163
+ throw new Error("test_fail_satellite_backup_replace");
164
+ }
165
+ });
166
+ }
167
+
168
+ export function clearSatelliteBackup(stageDir: string): void {
169
+ try { unlinkSync(join(stageDir, SATELLITE_BACKUP_FILE)); } catch { /* */ }
170
+ }
171
+
172
+ interface SatelliteWriteLock {
173
+ path: string;
174
+ db: Database;
175
+ }
176
+
177
+ export interface SatelliteWriteLocks {
178
+ logs?: SatelliteWriteLock;
179
+ memories?: SatelliteWriteLock;
180
+ goals?: SatelliteWriteLock;
181
+ }
182
+
183
+ /** Deterministic order: logs → memories → goals. Each present DB gets BEGIN IMMEDIATE. */
184
+ export function beginSatelliteWriteLocks(
185
+ paths: RuntimeDbPaths,
186
+ busyTimeoutMs: number,
187
+ only?: Partial<Record<"logs" | "memories" | "goals", boolean>>,
188
+ ): SatelliteWriteLocks {
189
+ const locks: SatelliteWriteLocks = {};
190
+ const order: Array<{ key: "logs" | "memories" | "goals"; path: string | null }> = [
191
+ { key: "logs", path: paths.logs },
192
+ { key: "memories", path: paths.memories },
193
+ { key: "goals", path: paths.goals },
194
+ ];
195
+ try {
196
+ for (const { key, path } of order) {
197
+ if (only && !only[key]) continue;
198
+ if (!path || !existsSync(path)) continue;
199
+ const db = openDbWritable(path, busyTimeoutMs);
200
+ try {
201
+ db.exec("BEGIN IMMEDIATE");
202
+ locks[key] = { path, db };
203
+ } catch (error) {
204
+ try { db.close(); } catch { /* */ }
205
+ throw error;
206
+ }
207
+ }
208
+ return locks;
209
+ } catch (error) {
210
+ rollbackAllSatelliteLocks(locks);
211
+ throw error;
212
+ }
213
+ }
214
+
215
+ function rollbackSatelliteLock(lock: SatelliteWriteLock | undefined): void {
216
+ if (!lock) return;
217
+ try { lock.db.exec("ROLLBACK"); } catch { /* */ }
218
+ try { lock.db.close(); } catch { /* */ }
219
+ }
220
+
221
+ export function rollbackAllSatelliteLocks(locks: SatelliteWriteLocks): void {
222
+ rollbackSatelliteLock(locks.logs);
223
+ rollbackSatelliteLock(locks.memories);
224
+ rollbackSatelliteLock(locks.goals);
225
+ locks.logs = undefined;
226
+ locks.memories = undefined;
227
+ locks.goals = undefined;
228
+ }
229
+
230
+ export function commitSatelliteLock(lock: SatelliteWriteLock | undefined): void {
231
+ if (!lock) return;
232
+ lock.db.exec("COMMIT");
233
+ lock.db.close();
234
+ }
235
+
236
+ function snapshotLogsInTx(
237
+ db: Database,
238
+ path: string,
239
+ threadIds: string[],
240
+ ): SatelliteBackup["logs"] {
241
+ if (threadIds.length === 0) return undefined;
242
+ if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
243
+ const rows: SqlRow[] = [];
244
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
245
+ const placeholders = chunk.map(() => "?").join(",");
246
+ rows.push(...selectRows(db, `SELECT * FROM logs WHERE thread_id IN (${placeholders})`, chunk));
247
+ }
248
+ return { path, rows };
249
+ }
250
+
251
+ function snapshotMemoriesInTx(
252
+ db: Database,
253
+ path: string,
254
+ threadIds: string[],
255
+ ): SatelliteBackup["memories"] {
256
+ if (threadIds.length === 0) return undefined;
257
+ if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
258
+ const stage1: SqlRow[] = [];
259
+ let stage1Jobs: SqlRow[] = [];
260
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
261
+ const placeholders = chunk.map(() => "?").join(",");
262
+ stage1.push(...selectRows(
263
+ db,
264
+ `SELECT * FROM stage1_outputs WHERE thread_id IN (${placeholders})`,
265
+ chunk,
266
+ ));
267
+ if (tableExists(db, "jobs")) {
268
+ stage1Jobs.push(...selectRows(
269
+ db,
270
+ `SELECT * FROM jobs WHERE kind = ? AND job_key IN (${placeholders})`,
271
+ [JOB_KIND_MEMORY_STAGE1, ...chunk],
272
+ ));
273
+ }
274
+ }
275
+ let consolidateJob: SqlRow | null = null;
276
+ let selectedForPhase2 = 0;
277
+ if (columnExists(db, "stage1_outputs", "selected_for_phase2")) {
278
+ selectedForPhase2 = stage1.filter(r => Number(r.selected_for_phase2 ?? 0) !== 0).length;
279
+ }
280
+ if (tableExists(db, "jobs")) {
281
+ consolidateJob = readConsolidateGlobalJob(db);
282
+ }
283
+ return {
284
+ path,
285
+ stage1,
286
+ stage1Jobs,
287
+ consolidateJob,
288
+ consolidateTouched: selectedForPhase2 > 0,
289
+ };
290
+ }
291
+
292
+ function snapshotGoalsInTx(
293
+ db: Database,
294
+ path: string,
295
+ threadIds: string[],
296
+ ): SatelliteBackup["goals"] {
297
+ if (threadIds.length === 0) return undefined;
298
+ if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
299
+ const goals: SqlRow[] = [];
300
+ let deferrals: SqlRow[] = [];
301
+ for (const chunk of chunkIds(threadIds, SQLITE_ID_CHUNK * 2)) {
302
+ const placeholders = chunk.map(() => "?").join(",");
303
+ goals.push(...selectRows(
304
+ db,
305
+ `SELECT * FROM thread_goals WHERE thread_id IN (${placeholders})`,
306
+ chunk,
307
+ ));
308
+ if (tableExists(db, "thread_goal_continuation_deferrals")) {
309
+ deferrals.push(...selectRows(
310
+ db,
311
+ `SELECT * FROM thread_goal_continuation_deferrals WHERE thread_id IN (${placeholders})`,
312
+ chunk,
313
+ ));
314
+ }
315
+ }
316
+ return { path, goals, deferrals };
317
+ }
318
+
319
+ /** Snapshot every present satellite under its write lock (rows stable until commit). */
320
+ export function snapshotSatelliteBackupInLocks(
321
+ locks: SatelliteWriteLocks,
322
+ threadIds: string[],
323
+ ): SatelliteBackup {
324
+ const backup: SatelliteBackup = { threadIds };
325
+ if (locks.logs) {
326
+ backup.logs = snapshotLogsInTx(locks.logs.db, locks.logs.path, threadIds);
327
+ }
328
+ if (locks.memories) {
329
+ backup.memories = snapshotMemoriesInTx(locks.memories.db, locks.memories.path, threadIds);
330
+ }
331
+ if (locks.goals) {
332
+ backup.goals = snapshotGoalsInTx(locks.goals.db, locks.goals.path, threadIds);
333
+ }
334
+ return backup;
335
+ }
336
+
337
+ export function deleteLogsInTx(db: Database, rows: SqlRow[]): void {
338
+ if (rows.length === 0) return;
339
+ if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
340
+ const ids = rows.map(r => r.id).filter(id => id !== null && id !== undefined);
341
+ if (ids.length === 0) return;
342
+ for (const chunk of chunkIds(ids as string[], SQLITE_ID_CHUNK * 2)) {
343
+ const placeholders = chunk.map(() => "?").join(",");
344
+ db.run(`DELETE FROM logs WHERE id IN (${placeholders})`, chunk as Array<string | number>);
345
+ }
346
+ }
347
+
348
+ export function deleteMemoriesInTx(
349
+ db: Database,
350
+ section: NonNullable<SatelliteBackup["memories"]>,
351
+ ): void {
352
+ if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
353
+ const stage1Ids = section.stage1.map(r => String(r.thread_id));
354
+ for (const chunk of chunkIds(stage1Ids, SQLITE_ID_CHUNK * 2)) {
355
+ if (chunk.length === 0) continue;
356
+ const placeholders = chunk.map(() => "?").join(",");
357
+ db.run(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`, chunk);
358
+ }
359
+ if (tableExists(db, "jobs")) {
360
+ const jobKeys = section.stage1Jobs.map(r => String(r.job_key));
361
+ for (const chunk of chunkIds(jobKeys, SQLITE_ID_CHUNK * 2)) {
362
+ if (chunk.length === 0) continue;
363
+ const placeholders = chunk.map(() => "?").join(",");
364
+ db.run(
365
+ `DELETE FROM jobs WHERE kind = ? AND job_key IN (${placeholders})`,
366
+ [JOB_KIND_MEMORY_STAGE1, ...chunk],
367
+ );
368
+ }
369
+ if (section.consolidateTouched) {
370
+ const now = Math.floor(Date.now() / 1000);
371
+ db.run(
372
+ `INSERT INTO jobs (
373
+ kind, job_key, status, worker_id, ownership_token, started_at, finished_at,
374
+ lease_until, retry_at, retry_remaining, last_error, input_watermark, last_success_watermark
375
+ ) VALUES (?, ?, 'pending', NULL, NULL, NULL, NULL, NULL, NULL, ?, NULL, ?, 0)
376
+ ON CONFLICT(kind, job_key) DO UPDATE SET
377
+ status = CASE WHEN jobs.status = 'running' THEN 'running' ELSE 'pending' END,
378
+ retry_at = CASE WHEN jobs.status = 'running' THEN jobs.retry_at ELSE NULL END,
379
+ retry_remaining = max(jobs.retry_remaining, excluded.retry_remaining),
380
+ input_watermark = CASE
381
+ WHEN excluded.input_watermark > COALESCE(jobs.input_watermark, 0)
382
+ THEN excluded.input_watermark
383
+ ELSE COALESCE(jobs.input_watermark, 0) + 1
384
+ END`,
385
+ [JOB_KIND_MEMORY_CONSOLIDATE_GLOBAL, MEMORY_CONSOLIDATION_JOB_KEY, DEFAULT_RETRY_REMAINING, now],
386
+ );
387
+ }
388
+ }
389
+ }
390
+
391
+ export function deleteGoalsInTx(
392
+ db: Database,
393
+ section: NonNullable<SatelliteBackup["goals"]>,
394
+ ): void {
395
+ if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
396
+ const deferralIds = section.deferrals.map(r => String(r.thread_id));
397
+ if (tableExists(db, "thread_goal_continuation_deferrals")) {
398
+ for (const chunk of chunkIds(deferralIds, SQLITE_ID_CHUNK * 2)) {
399
+ if (chunk.length === 0) continue;
400
+ const placeholders = chunk.map(() => "?").join(",");
401
+ db.run(
402
+ `DELETE FROM thread_goal_continuation_deferrals WHERE thread_id IN (${placeholders})`,
403
+ chunk,
404
+ );
405
+ }
406
+ }
407
+ const goalIds = section.goals.map(r => String(r.thread_id));
408
+ for (const chunk of chunkIds(goalIds, SQLITE_ID_CHUNK * 2)) {
409
+ if (chunk.length === 0) continue;
410
+ const placeholders = chunk.map(() => "?").join(",");
411
+ db.run(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`, chunk);
412
+ }
413
+ }
414
+
415
+ /** Restore only snapshotted rows; concurrent inserts/updates after commit stay intact. */
416
+ export function restoreSatelliteBackup(
417
+ backup: SatelliteBackup,
418
+ busyTimeoutMs: number,
419
+ failRestore = false,
420
+ ): boolean {
421
+ if (failRestore) return false;
422
+ try {
423
+ if (backup.logs) {
424
+ const restored = withWritableDb(backup.logs.path, busyTimeoutMs, db => {
425
+ if (!tableExists(db, "logs")) throw new Error("missing_logs_table");
426
+ insertRowsConflictIgnore(db, "logs", backup.logs!.rows);
427
+ });
428
+ if (!restored.ok) return false;
429
+ }
430
+ if (backup.memories) {
431
+ const mem = backup.memories;
432
+ const restored = withWritableDb(mem.path, busyTimeoutMs, db => {
433
+ if (!tableExists(db, "stage1_outputs")) throw new Error("missing_stage1_outputs_table");
434
+ insertRowsConflictIgnore(db, "stage1_outputs", mem.stage1);
435
+ if (tableExists(db, "jobs")) {
436
+ insertRowsConflictIgnore(db, "jobs", mem.stage1Jobs);
437
+ if (mem.consolidateTouched) {
438
+ restoreConsolidateGlobalJob(db, mem.consolidateJob, mem.consolidatePostImage);
439
+ }
440
+ }
441
+ });
442
+ if (!restored.ok) return false;
443
+ }
444
+ if (backup.goals) {
445
+ const g = backup.goals;
446
+ const restored = withWritableDb(g.path, busyTimeoutMs, db => {
447
+ if (!tableExists(db, "thread_goals")) throw new Error("missing_thread_goals_table");
448
+ insertRowsConflictIgnore(db, "thread_goals", g.goals);
449
+ if (tableExists(db, "thread_goal_continuation_deferrals")) {
450
+ insertRowsConflictIgnore(db, "thread_goal_continuation_deferrals", g.deferrals);
451
+ }
452
+ });
453
+ if (!restored.ok) return false;
454
+ }
455
+ return true;
456
+ } catch {
457
+ return false;
458
+ }
459
+ }
460
+
461
+ export function readSatelliteBackupFile(stageDir: string): SatelliteBackupRead {
462
+ const path = join(stageDir, SATELLITE_BACKUP_FILE);
463
+ if (!existsSync(path)) return { status: "missing" };
464
+ try {
465
+ const raw = JSON.parse(readFileSync(path, "utf8")) as unknown;
466
+ if (!raw || typeof raw !== "object") return { status: "invalid" };
467
+ const o = raw as SatelliteBackup;
468
+ if (!Array.isArray(o.threadIds)) return { status: "invalid" };
469
+ return { status: "ok", backup: o };
470
+ } catch {
471
+ // File exists but is truncated / malformed — distinct from a missing backup.
472
+ return { status: "invalid" };
473
+ }
474
+ }
@@ -0,0 +1,129 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ renameSync,
6
+ rmSync,
7
+ unlinkSync,
8
+ } from "node:fs";
9
+ import { basename, isAbsolute, join, relative, resolve } from "node:path";
10
+ import { TRASH_DIR, toForwardSlash } from "./paths";
11
+ import type { ArchivedCandidate } from "./types";
12
+
13
+ export type StagedFile = { from: string; to: string; relPath: string };
14
+
15
+ export function absFromRel(codexHome: string, relPath: string): string {
16
+ if (relPath.includes("..") || isAbsolute(relPath) || /^[A-Za-z]:[\\/]/.test(relPath)) {
17
+ throw new Error("invalid_rel_path");
18
+ }
19
+ const abs = resolve(codexHome, ...relPath.split("/"));
20
+ const homeAbs = resolve(codexHome);
21
+ const rel = toForwardSlash(relative(homeAbs, abs));
22
+ if (!rel || rel.startsWith("..")) throw new Error("path_escape");
23
+ return abs;
24
+ }
25
+
26
+ export function stageCandidates(
27
+ codexHome: string,
28
+ candidates: ArchivedCandidate[],
29
+ stageDir: string,
30
+ opts?: { blockDestBasenames?: Set<string> },
31
+ ): { ok: true; staged: StagedFile[] } | { ok: false; staged: StagedFile[] } {
32
+ const staged: StagedFile[] = [];
33
+ const usedBasenames = new Set<string>();
34
+ try {
35
+ mkdirSync(stageDir, { recursive: true });
36
+ for (const candidate of candidates) {
37
+ for (const rel of candidate.physicalRelPaths) {
38
+ const from = absFromRel(codexHome, rel);
39
+ const base = basename(rel);
40
+ // archived_sessions/ is flat today; refuse collisions so a future nested walk
41
+ // cannot silently overwrite another staged file.
42
+ if (usedBasenames.has(base)) {
43
+ throw new Error("stage_basename_collision");
44
+ }
45
+ usedBasenames.add(base);
46
+ const to = join(stageDir, base);
47
+ if (opts?.blockDestBasenames?.has(base)) {
48
+ mkdirSync(to, { recursive: true });
49
+ }
50
+ renameSync(from, to);
51
+ staged.push({ from, to, relPath: rel });
52
+ }
53
+ }
54
+ return { ok: true, staged };
55
+ } catch {
56
+ return { ok: false, staged };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Rename staged files back to their originals.
62
+ * Returns whether every staged file was restored. Unrestored entries stay in `remaining`.
63
+ */
64
+ export function rollbackStaged(
65
+ staged: StagedFile[],
66
+ opts?: { failBasenames?: Set<string> },
67
+ ): { restored: boolean; remaining: StagedFile[] } {
68
+ const remaining: StagedFile[] = [];
69
+ for (let i = staged.length - 1; i >= 0; i--) {
70
+ const item = staged[i]!;
71
+ const base = basename(item.to);
72
+ if (opts?.failBasenames?.has(base)) {
73
+ remaining.push(item);
74
+ continue;
75
+ }
76
+ try {
77
+ if (existsSync(item.to) && !existsSync(item.from)) {
78
+ renameSync(item.to, item.from);
79
+ } else if (existsSync(item.to)) {
80
+ // Destination occupied — cannot restore without clobbering.
81
+ remaining.push(item);
82
+ }
83
+ } catch {
84
+ remaining.push(item);
85
+ }
86
+ }
87
+ return { restored: remaining.length === 0, remaining };
88
+ }
89
+
90
+ export function purgeStaged(
91
+ staged: StagedFile[],
92
+ opts?: { failBasenames?: Set<string> },
93
+ ): { purged: StagedFile[]; remaining: StagedFile[] } {
94
+ const purged: StagedFile[] = [];
95
+ const remaining: StagedFile[] = [];
96
+ for (const item of staged) {
97
+ const base = basename(item.to);
98
+ if (opts?.failBasenames?.has(base)) {
99
+ remaining.push(item);
100
+ continue;
101
+ }
102
+ try {
103
+ unlinkSync(item.to);
104
+ purged.push(item);
105
+ } catch {
106
+ remaining.push(item);
107
+ }
108
+ }
109
+ return { purged, remaining };
110
+ }
111
+
112
+ /** Remove stageDir only when it contains no unrestored staged files. */
113
+ export function removeStageIfEmpty(stageDir: string, remaining: StagedFile[]): void {
114
+ if (remaining.length > 0) return;
115
+ try { rmSync(stageDir, { recursive: true, force: true }); } catch { /* */ }
116
+ }
117
+
118
+ export function removeEmptyTrashRoot(codexHome: string): void {
119
+ try {
120
+ const trashRoot = join(codexHome, TRASH_DIR);
121
+ if (existsSync(trashRoot) && readdirSync(trashRoot).length === 0) {
122
+ rmSync(trashRoot, { recursive: true, force: true });
123
+ }
124
+ } catch { /* */ }
125
+ }
126
+
127
+ export function trashRelPath(codexHome: string, stageDir: string): string {
128
+ return toForwardSlash(relative(codexHome, stageDir) || stageDir);
129
+ }
@@ -0,0 +1,98 @@
1
+ export type CleanupMode = "quarantine" | "permanent";
2
+
3
+ /** Mapped failure codes only — never embed absolute host paths. */
4
+ export type CleanupErrorCode =
5
+ | "invalid_mode"
6
+ | "invalid_digest"
7
+ | "stale_preview"
8
+ | "codex_busy"
9
+ | "storage_mutation_busy"
10
+ | "fs_failed"
11
+ | "db_reconcile_failed"
12
+ | "referenced_history"
13
+ | "pinned_thread"
14
+ | "restore_pending_overlap"
15
+ | "cleanup_failed";
16
+
17
+ export interface ArchivedCandidate {
18
+ /** Path relative to CODEX_HOME, forward-slash separated (logical `.jsonl` path). */
19
+ relPath: string;
20
+ absPath: string;
21
+ bytes: number;
22
+ mtimeMs: number;
23
+ /** All physical files for this logical rollout (`.jsonl` and/or `.jsonl.zst`). */
24
+ physicalRelPaths: string[];
25
+ /** Per-physical-file metadata bound into the preview digest. */
26
+ physicalFiles: Array<{ relPath: string; bytes: number; mtimeMs: number }>;
27
+ }
28
+
29
+ export interface CleanupPreview {
30
+ codexHome: string;
31
+ percent: number;
32
+ count: number;
33
+ bytes: number;
34
+ /** HMAC-free content digest binding execute to this exact candidate set. */
35
+ digest: string;
36
+ candidates: ArchivedCandidate[];
37
+ }
38
+
39
+ export interface CleanupManifestEntry {
40
+ relPath: string;
41
+ bytes: number;
42
+ mtimeMs: number;
43
+ physicalRelPaths: string[];
44
+ threadId?: string;
45
+ rolloutPath?: string;
46
+ archived?: number | null;
47
+ }
48
+
49
+ export interface CleanupResult {
50
+ ok: boolean;
51
+ mode: CleanupMode;
52
+ percent: number;
53
+ count: number;
54
+ bytes: number;
55
+ trashDir?: string;
56
+ error?: CleanupErrorCode;
57
+ removedPaths: string[];
58
+ skippedReferencedPaths?: string[];
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Phase 2.1 — quarantine list + restore
63
+ // ---------------------------------------------------------------------------
64
+
65
+ export type RestoreErrorCode =
66
+ | "invalid_trash"
67
+ | "missing_trash"
68
+ | "codex_busy"
69
+ | "storage_mutation_busy"
70
+ | "fs_failed"
71
+ | "db_reconcile_failed"
72
+ | "dest_exists"
73
+ | "restore_failed"
74
+ | "restore_worker_timeout"
75
+ | "restore_worker_aborted"
76
+ | "restore_worker_failed";
77
+
78
+ export interface TrashEntrySummary {
79
+ /** CODEX_HOME-relative path, e.g. `.trash/1700000000000`. */
80
+ id: string;
81
+ /** Epoch directory name (may include collision suffix, e.g. `1700-1`). */
82
+ epoch: string;
83
+ fileCount: number;
84
+ bytes: number;
85
+ quarantinedAt?: number;
86
+ mode?: CleanupMode;
87
+ }
88
+
89
+ export interface RestoreResult {
90
+ ok: boolean;
91
+ trashDir?: string;
92
+ count: number;
93
+ bytes: number;
94
+ restoredPaths: string[];
95
+ error?: RestoreErrorCode;
96
+ /** Optional operator-facing detail when the error code alone is insufficient. */
97
+ message?: string;
98
+ }