@bitkyc08/opencodex 2.52.0-preview.20260912 → 2.53.0-preview.20260913

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-D7ynYo2K.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-D_t6sCWs.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,189 @@
1
+ import {
2
+ closeSync,
3
+ fsyncSync,
4
+ linkSync,
5
+ mkdirSync,
6
+ openSync,
7
+ unlinkSync,
8
+ writeFileSync,
9
+ chmodSync,
10
+ } from "node:fs";
11
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
12
+ import { randomUUID } from "node:crypto";
13
+ import { renameAtomicFile } from "../../lib/windows-atomic-replace";
14
+
15
+ export const ARCHIVED_SESSIONS_DIR = "archived_sessions";
16
+
17
+ export const TRASH_DIR = ".trash";
18
+
19
+ const JSONL_SUFFIX = ".jsonl";
20
+
21
+ export const ZST_SUFFIX = ".jsonl.zst";
22
+
23
+ export function chmodPrivatePath(path: string, mode: number): void {
24
+ try { chmodSync(path, mode); } catch { /* best-effort (e.g. Windows ACLs) */ }
25
+ }
26
+
27
+ /** Publish complete stage metadata without truncating the last recovery record. */
28
+ export function writePrivateFile(
29
+ path: string,
30
+ content: string,
31
+ beforeRename?: (temporaryPath: string, targetPath: string) => void,
32
+ ): void {
33
+ const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
34
+ let descriptor: number | undefined;
35
+ let created = false;
36
+ try {
37
+ descriptor = openSync(temporaryPath, "wx", 0o600);
38
+ created = true;
39
+ writeFileSync(descriptor, content, "utf8");
40
+ fsyncSync(descriptor);
41
+ closeSync(descriptor);
42
+ descriptor = undefined;
43
+ chmodPrivatePath(temporaryPath, 0o600);
44
+ beforeRename?.(temporaryPath, path);
45
+ renameAtomicFile(temporaryPath, path, undefined, "storage-cleanup");
46
+ chmodPrivatePath(path, 0o600);
47
+ fsyncDirectoryBestEffort(dirname(path));
48
+ } finally {
49
+ if (descriptor !== undefined) {
50
+ try { closeSync(descriptor); } catch { /* preserve publication failure */ }
51
+ }
52
+ if (created) {
53
+ try { unlinkSync(temporaryPath); } catch { /* renamed or cleanup unavailable */ }
54
+ }
55
+ }
56
+ }
57
+
58
+ /** Create `.trash/<epoch>` exclusively; suffix on collision. */
59
+ export function createExclusiveStageDir(codexHome: string, epoch: number): string {
60
+ const trashRoot = join(codexHome, TRASH_DIR);
61
+ mkdirSync(trashRoot, { recursive: true });
62
+ chmodPrivatePath(trashRoot, 0o700);
63
+ for (let attempt = 0; attempt < 100; attempt++) {
64
+ const name = attempt === 0 ? String(epoch) : `${epoch}-${attempt}`;
65
+ const stageDir = join(trashRoot, name);
66
+ try {
67
+ mkdirSync(stageDir);
68
+ chmodPrivatePath(stageDir, 0o700);
69
+ return stageDir;
70
+ } catch (error) {
71
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") continue;
72
+ throw error;
73
+ }
74
+ }
75
+ throw new Error("stage_dir_collision");
76
+ }
77
+
78
+ export function isSafeArchiveFileName(name: string): boolean {
79
+ if (name.includes("/") || name.includes("\\") || name.includes("..")) return false;
80
+ return isRolloutFileName(name);
81
+ }
82
+
83
+ export function toForwardSlash(p: string): string {
84
+ return p.split(sep).join("/");
85
+ }
86
+
87
+ /** Strip trailing `.zst` so plain + compressed share one logical rollout id. */
88
+ export function logicalRolloutRelPath(relPath: string): string {
89
+ const normalized = toForwardSlash(relPath);
90
+ return normalized.endsWith(ZST_SUFFIX)
91
+ ? normalized.slice(0, -".zst".length)
92
+ : normalized;
93
+ }
94
+
95
+ export function isRolloutFileName(name: string): boolean {
96
+ return name.endsWith(ZST_SUFFIX) || name.endsWith(JSONL_SUFFIX);
97
+ }
98
+
99
+ /**
100
+ * Normalize a DB `rollout_path` to a CODEX_HOME-relative forward-slash path, then
101
+ * to the logical `.jsonl` form. Returns null when the path is not under
102
+ * `archived_sessions/` (rejects active `sessions/` and foreign paths).
103
+ */
104
+ export function normalizeArchivedRolloutPath(rolloutPath: string, codexHome: string): string | null {
105
+ const raw = toForwardSlash(rolloutPath.trim());
106
+ if (!raw) return null;
107
+ let relativePath = raw;
108
+ try {
109
+ // Prefer Node's absolute-path detection. Do not treat a colon anywhere in the
110
+ // filename (Codex ISO timestamps) as an absolute Windows path.
111
+ const looksAbsolute = isAbsolute(raw) || /^[A-Za-z]:[\\/]/.test(raw);
112
+ const abs = looksAbsolute ? resolve(raw) : resolve(codexHome, raw);
113
+ const homeAbs = resolve(codexHome);
114
+ const rel = toForwardSlash(relative(homeAbs, abs));
115
+ if (rel.startsWith("..") || rel === "") return null;
116
+ relativePath = rel;
117
+ } catch {
118
+ return null;
119
+ }
120
+ const logical = logicalRolloutRelPath(relativePath);
121
+ if (!logical.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return null;
122
+ if (!logical.endsWith(JSONL_SUFFIX)) return null;
123
+ // Reject path tricks: only a single file under archived_sessions/
124
+ const rest = logical.slice(ARCHIVED_SESSIONS_DIR.length + 1);
125
+ if (!rest || rest.includes("/") || rest.includes("..")) return null;
126
+ return logical;
127
+ }
128
+
129
+ /**
130
+ * Same-volume move that never replaces an existing destination.
131
+ *
132
+ * `existsSync` + `renameSync` is TOCTOU: a live file created between the check
133
+ * and rename can be overwritten (Windows rename replaces files). Hard-link then
134
+ * unlink fails with EEXIST if `to` appears, which is what trash → archived_sessions
135
+ * restore needs. Callers under the same `CODEX_HOME` volume should not hit EXDEV.
136
+ */
137
+ export function renameNoReplace(from: string, to: string): void {
138
+ try {
139
+ linkSync(from, to);
140
+ } catch (error) {
141
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
142
+ // Hard links unavailable (rare FS) — refuse rather than clobber via rename.
143
+ if (code === "EXDEV" || code === "EPERM" || code === "ENOTSUP" || code === "EINVAL") {
144
+ throw Object.assign(new Error("rename_no_replace_unsupported"), { code, cause: error });
145
+ }
146
+ throw error;
147
+ }
148
+ try {
149
+ unlinkSync(from);
150
+ } catch (error) {
151
+ // Roll back the hard link so we do not leave the file at both paths.
152
+ try { unlinkSync(to); } catch { /* best-effort */ }
153
+ throw error;
154
+ }
155
+ }
156
+
157
+ export function isExistError(error: unknown): boolean {
158
+ return (error as NodeJS.ErrnoException | undefined)?.code === "EEXIST";
159
+ }
160
+
161
+ /**
162
+ * Best-effort directory fsync so a preceding rename is durable on crash.
163
+ * Unsupported on some Windows setups — never treat failure as fatal.
164
+ */
165
+ function fsyncDirectoryBestEffort(dirPath: string): void {
166
+ let fd: number | undefined;
167
+ try {
168
+ fd = openSync(dirPath, "r");
169
+ fsyncSync(fd);
170
+ } catch {
171
+ /* best-effort */
172
+ } finally {
173
+ if (fd !== undefined) {
174
+ try { closeSync(fd); } catch { /* */ }
175
+ }
176
+ }
177
+ }
178
+
179
+ /** Epoch dir names: digits, optionally `-N` from createExclusiveStageDir collision. */
180
+ export const TRASH_EPOCH_DIR = /^(\d+)(-\d+)?$/;
181
+
182
+ export function isSafeArchivedPhysicalRel(rel: string): boolean {
183
+ const normalized = toForwardSlash(rel);
184
+ if (!normalized.startsWith(`${ARCHIVED_SESSIONS_DIR}/`)) return false;
185
+ if (normalized.includes("..")) return false;
186
+ const rest = normalized.slice(ARCHIVED_SESSIONS_DIR.length + 1);
187
+ if (!rest || rest.includes("/")) return false;
188
+ return isRolloutFileName(rest);
189
+ }
@@ -0,0 +1,140 @@
1
+ import {
2
+ closeSync,
3
+ existsSync,
4
+ fsyncSync,
5
+ openSync,
6
+ readdirSync,
7
+ readFileSync,
8
+ unlinkSync,
9
+ writeSync,
10
+ } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { renameAtomicFile } from "../../lib/windows-atomic-replace";
13
+ import { TRASH_DIR, TRASH_EPOCH_DIR, chmodPrivatePath } from "./paths";
14
+
15
+ /** Accepted destination paths from every valid in-progress restore marker under `.trash`. */
16
+ export function collectRestorePendingAcceptedDestRels(codexHome: string): Set<string> {
17
+ const out = new Set<string>();
18
+ const trashRoot = join(codexHome, TRASH_DIR);
19
+ if (!existsSync(trashRoot)) return out;
20
+ for (const name of readdirSync(trashRoot)) {
21
+ if (!TRASH_EPOCH_DIR.test(name)) continue;
22
+ const read = readRestorePending(join(trashRoot, name));
23
+ if (read.status !== "valid") continue;
24
+ for (const rel of read.state.acceptedDestRels) out.add(rel);
25
+ }
26
+ return out;
27
+ }
28
+
29
+ /** Marks an incomplete restore so retries can accept dest files and resume metadata. */
30
+ export const RESTORE_PENDING_FILE = "restore-pending.json";
31
+
32
+ export interface RestorePendingSections {
33
+ state: boolean;
34
+ logs: boolean;
35
+ memories: boolean;
36
+ goals: boolean;
37
+ }
38
+
39
+ export interface RestorePendingState {
40
+ version: 1;
41
+ filesRestored: true;
42
+ /**
43
+ * Planned CODEX_HOME-relative destinations for this restore attempt.
44
+ * Written before moves so a mid-loop failure can still accept placed dests
45
+ * on resume while finishing files that remain staged.
46
+ */
47
+ acceptedDestRels: string[];
48
+ /** Sections that still need reconciliation on retry. */
49
+ pending: RestorePendingSections;
50
+ }
51
+
52
+ type RestorePendingRead =
53
+ | { status: "missing" }
54
+ | { status: "valid"; state: RestorePendingState }
55
+ | { status: "invalid" };
56
+
57
+ let _restorePendingSeq = 0;
58
+
59
+ function parseRestorePendingState(raw: unknown): RestorePendingState | null {
60
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
61
+ const o = raw as Record<string, unknown>;
62
+ if (o.version !== 1 || o.filesRestored !== true) return null;
63
+ if (!Array.isArray(o.acceptedDestRels)) return null;
64
+ const acceptedDestRels = o.acceptedDestRels.filter((r): r is string => typeof r === "string");
65
+ if (acceptedDestRels.length !== o.acceptedDestRels.length) return null;
66
+ const pendingRaw = o.pending;
67
+ if (!pendingRaw || typeof pendingRaw !== "object" || Array.isArray(pendingRaw)) return null;
68
+ const p = pendingRaw as Record<string, unknown>;
69
+ if (
70
+ typeof p.state !== "boolean"
71
+ || typeof p.logs !== "boolean"
72
+ || typeof p.memories !== "boolean"
73
+ || typeof p.goals !== "boolean"
74
+ ) {
75
+ return null;
76
+ }
77
+ return {
78
+ version: 1,
79
+ filesRestored: true,
80
+ acceptedDestRels,
81
+ pending: {
82
+ state: p.state,
83
+ logs: p.logs,
84
+ memories: p.memories,
85
+ goals: p.goals,
86
+ },
87
+ };
88
+ }
89
+
90
+ /**
91
+ * Distinguish a missing marker from a present-but-malformed one. An invalid marker
92
+ * must never be treated as a fresh restore (that would ignore already-moved files).
93
+ */
94
+ export function readRestorePending(stageDir: string): RestorePendingRead {
95
+ const path = join(stageDir, RESTORE_PENDING_FILE);
96
+ if (!existsSync(path)) return { status: "missing" };
97
+ try {
98
+ const state = parseRestorePendingState(JSON.parse(readFileSync(path, "utf8")) as unknown);
99
+ if (!state) return { status: "invalid" };
100
+ return { status: "valid", state };
101
+ } catch {
102
+ return { status: "invalid" };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Atomically replace restore-pending.json: private temp in the stage, fsync, then rename.
108
+ * An interrupted update leaves the previous valid marker intact.
109
+ */
110
+ export function writeRestorePending(
111
+ stageDir: string,
112
+ state: RestorePendingState,
113
+ options?: { failBeforeRename?: boolean; failWrite?: boolean },
114
+ ): void {
115
+ if (options?.failWrite) throw new Error("test_fail_pending_write");
116
+ const dest = join(stageDir, RESTORE_PENDING_FILE);
117
+ const tmp = join(stageDir, `${RESTORE_PENDING_FILE}.${process.pid}.${++_restorePendingSeq}.tmp`);
118
+ const payload = JSON.stringify(state);
119
+ const fd = openSync(tmp, "w", 0o600);
120
+ try {
121
+ writeSync(fd, payload, null, "utf8");
122
+ fsyncSync(fd);
123
+ } catch (error) {
124
+ try { closeSync(fd); } catch { /* */ }
125
+ try { unlinkSync(tmp); } catch { /* */ }
126
+ throw error;
127
+ }
128
+ closeSync(fd);
129
+ chmodPrivatePath(tmp, 0o600);
130
+ if (options?.failBeforeRename) {
131
+ try { unlinkSync(tmp); } catch { /* */ }
132
+ throw new Error("test_fail_pending_rename");
133
+ }
134
+ try {
135
+ renameAtomicFile(tmp, dest, undefined, "storage-cleanup");
136
+ } catch (error) {
137
+ try { unlinkSync(tmp); } catch { /* */ }
138
+ throw error;
139
+ }
140
+ }
@@ -0,0 +1,292 @@
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, readdirSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { Database } from "bun:sqlite";
5
+ import { resolveCodexHomeDir } from "../../codex/home";
6
+ import { columnExists, discoverRuntimeDbPaths, tableExists } from "./db";
7
+ import { ARCHIVED_SESSIONS_DIR, isSafeArchiveFileName, logicalRolloutRelPath, normalizeArchivedRolloutPath } from "./paths";
8
+ import { collectRestorePendingAcceptedDestRels } from "./pending";
9
+ import type { ArchivedCandidate, CleanupPreview } from "./types";
10
+
11
+ export function clampPercent(percent: unknown): number {
12
+ if (typeof percent !== "number" || !Number.isFinite(percent)) return 0;
13
+ return Math.max(0, Math.min(100, Math.floor(percent)));
14
+ }
15
+
16
+ function candidateDigestLines(candidates: ArchivedCandidate[]): string[] {
17
+ return candidates
18
+ .map(c => {
19
+ const physical = [...c.physicalFiles]
20
+ .sort((a, b) => a.relPath.localeCompare(b.relPath))
21
+ .map(f => `${f.relPath}|${f.bytes}|${Math.trunc(f.mtimeMs)}`)
22
+ .join(",");
23
+ return `${c.relPath}|${c.bytes}|${Math.trunc(c.mtimeMs)}|${physical}`;
24
+ })
25
+ .sort();
26
+ }
27
+
28
+ /** Content digest of the exact previewed candidate set (paths + size + mtime). */
29
+ export function computePreviewDigest(candidates: ArchivedCandidate[], percent: number): string {
30
+ return createHash("sha256")
31
+ .update(`${clampPercent(percent)}\n${candidateDigestLines(candidates).join("\n")}`)
32
+ .digest("hex");
33
+ }
34
+
35
+ /**
36
+ * Digest bound to an explicit candidate list (not a percent selection).
37
+ * Used when reduceToBytes needs an exact count that percent rounding cannot represent.
38
+ */
39
+ export function computeExactPreviewDigest(candidates: ArchivedCandidate[]): string {
40
+ return createHash("sha256")
41
+ .update(`exact\n${candidateDigestLines(candidates).join("\n")}`)
42
+ .digest("hex");
43
+ }
44
+
45
+ /** List archived rollout groups oldest-first. Never walks `sessions/`. */
46
+ export function listArchivedCandidates(codexHome: string): ArchivedCandidate[] {
47
+ const dir = join(codexHome, ARCHIVED_SESSIONS_DIR);
48
+ let names: string[] = [];
49
+ try {
50
+ names = readdirSync(dir);
51
+ } catch {
52
+ return [];
53
+ }
54
+
55
+ type Acc = {
56
+ logicalRel: string;
57
+ files: Array<{ name: string; absPath: string; relPath: string; bytes: number; mtimeMs: number }>;
58
+ };
59
+ const groups = new Map<string, Acc>();
60
+
61
+ for (const name of names) {
62
+ if (!isSafeArchiveFileName(name)) continue;
63
+ const absPath = join(dir, name);
64
+ try {
65
+ const st = statSync(absPath);
66
+ if (!st.isFile()) continue;
67
+ const relPath = `${ARCHIVED_SESSIONS_DIR}/${name}`;
68
+ const logicalRel = logicalRolloutRelPath(relPath);
69
+ let acc = groups.get(logicalRel);
70
+ if (!acc) {
71
+ acc = { logicalRel, files: [] };
72
+ groups.set(logicalRel, acc);
73
+ }
74
+ acc.files.push({
75
+ name,
76
+ absPath,
77
+ relPath,
78
+ bytes: st.size,
79
+ mtimeMs: st.mtimeMs,
80
+ });
81
+ } catch {
82
+ /* vanished mid-scan */
83
+ }
84
+ }
85
+
86
+ const out: ArchivedCandidate[] = [];
87
+ for (const acc of groups.values()) {
88
+ // Prefer the plain `.jsonl` path as the public/logical identity when both exist.
89
+ acc.files.sort((a, b) => a.relPath.localeCompare(b.relPath));
90
+ const primary =
91
+ acc.files.find(f => f.relPath === acc.logicalRel) ??
92
+ acc.files[0]!;
93
+ out.push({
94
+ relPath: acc.logicalRel,
95
+ absPath: primary.absPath,
96
+ bytes: acc.files.reduce((sum, f) => sum + f.bytes, 0),
97
+ mtimeMs: Math.min(...acc.files.map(f => f.mtimeMs)),
98
+ physicalRelPaths: acc.files.map(f => f.relPath),
99
+ physicalFiles: acc.files.map(f => ({ relPath: f.relPath, bytes: f.bytes, mtimeMs: f.mtimeMs })),
100
+ });
101
+ }
102
+ out.sort((a, b) => a.mtimeMs - b.mtimeMs || a.relPath.localeCompare(b.relPath));
103
+ return out;
104
+ }
105
+
106
+ export function selectOldestPercent(candidates: ArchivedCandidate[], percent: number): ArchivedCandidate[] {
107
+ const pct = clampPercent(percent);
108
+ if (pct <= 0 || candidates.length === 0) return [];
109
+ if (pct >= 100) return [...candidates];
110
+ const n = percentSelectionTargetCount(candidates.length, pct);
111
+ return candidates.slice(0, n);
112
+ }
113
+
114
+ /** Count implied by percent selection over the full candidate list. */
115
+ export function percentSelectionTargetCount(totalCount: number, percent: number): number {
116
+ const pct = clampPercent(percent);
117
+ if (pct <= 0 || totalCount === 0) return 0;
118
+ if (pct >= 100) return totalCount;
119
+ return Math.max(1, Math.floor((totalCount * pct) / 100));
120
+ }
121
+
122
+ export function candidateOverlapsPendingRestore(
123
+ candidate: ArchivedCandidate,
124
+ pendingDestRels: ReadonlySet<string>,
125
+ ): boolean {
126
+ if (pendingDestRels.size === 0) return false;
127
+ for (const rel of candidate.physicalRelPaths) {
128
+ if (pendingDestRels.has(rel)) return true;
129
+ }
130
+ return pendingDestRels.has(candidate.relPath);
131
+ }
132
+
133
+ /** Drop cleanup candidates whose physical paths overlap an in-progress restore. */
134
+ export function filterCandidatesExcludingPendingRestore(
135
+ candidates: ArchivedCandidate[],
136
+ codexHome: string = resolveCodexHomeDir(),
137
+ ): ArchivedCandidate[] {
138
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
139
+ if (pendingDestRels.size === 0) return candidates;
140
+ return candidates.filter(c => !candidateOverlapsPendingRestore(c, pendingDestRels));
141
+ }
142
+
143
+ /**
144
+ * Oldest-first percent selection that skips pending-restore destinations without
145
+ * consuming the percent budget, backfilling with the next oldest safe candidates.
146
+ */
147
+ export function selectOldestPercentSkippingPendingRestore(
148
+ candidates: ArchivedCandidate[],
149
+ percent: number,
150
+ codexHome: string = resolveCodexHomeDir(),
151
+ ): ArchivedCandidate[] {
152
+ const target = percentSelectionTargetCount(candidates.length, percent);
153
+ if (target === 0) return [];
154
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
155
+ const out: ArchivedCandidate[] = [];
156
+ for (const c of candidates) {
157
+ if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
158
+ out.push(c);
159
+ if (out.length >= target) break;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Reduce archived total toward `reduceToBytes` using oldest safe candidates only.
166
+ * Pending-restore destinations are skipped and do not count toward bytes freed.
167
+ */
168
+ export function selectReduceToBytesSkippingPendingRestore(
169
+ candidates: ArchivedCandidate[],
170
+ reduceToBytes: number,
171
+ codexHome: string = resolveCodexHomeDir(),
172
+ ): ArchivedCandidate[] {
173
+ if (!Number.isFinite(reduceToBytes) || reduceToBytes < 0) return [];
174
+ const total = candidates.reduce((sum, c) => sum + c.bytes, 0);
175
+ if (total <= reduceToBytes) return [];
176
+ const need = total - reduceToBytes;
177
+ const pendingDestRels = collectRestorePendingAcceptedDestRels(codexHome);
178
+ const out: ArchivedCandidate[] = [];
179
+ let freed = 0;
180
+ for (const c of candidates) {
181
+ if (candidateOverlapsPendingRestore(c, pendingDestRels)) continue;
182
+ out.push(c);
183
+ freed += c.bytes;
184
+ if (freed >= need) break;
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * Normalized rollout paths of pinned threads. Pinned threads are never
191
+ * cleanup candidates: a pin is the user's explicit "keep this" signal and
192
+ * deleting its rollout would be permanent task-data loss (#858).
193
+ *
194
+ * Selection-time use is advisory: on any DB problem this returns an empty
195
+ * set, and the write-locked re-check inside reconcileDeletedThreads stays
196
+ * the fail-closed gate. Older schemas without `is_pinned` keep prior
197
+ * behavior.
198
+ */
199
+ function collectPinnedArchivedRolloutPaths(codexHome: string): Set<string> {
200
+ const statePath = discoverRuntimeDbPaths(codexHome).state;
201
+ if (!statePath || !existsSync(statePath)) return new Set();
202
+ let db: Database | undefined;
203
+ try {
204
+ db = new Database(statePath, { readonly: true });
205
+ if (!tableExists(db, "threads") || !columnExists(db, "threads", "is_pinned")) {
206
+ return new Set();
207
+ }
208
+ const rows = db.query<{ rollout_path: string }, []>(
209
+ `SELECT rollout_path FROM threads WHERE is_pinned = 1`,
210
+ ).all();
211
+ const out = new Set<string>();
212
+ for (const row of rows) {
213
+ const normalized = normalizeArchivedRolloutPath(row.rollout_path, codexHome);
214
+ if (normalized) out.add(normalized);
215
+ }
216
+ return out;
217
+ } catch {
218
+ return new Set();
219
+ } finally {
220
+ try { db?.close(); } catch { /* */ }
221
+ }
222
+ }
223
+
224
+ /** Drop candidates whose rollout belongs to a pinned thread (#858). */
225
+ export function filterCandidatesExcludingPinned(
226
+ candidates: ArchivedCandidate[],
227
+ codexHome: string,
228
+ ): ArchivedCandidate[] {
229
+ const pinned = collectPinnedArchivedRolloutPaths(codexHome);
230
+ if (pinned.size === 0) return candidates;
231
+ return candidates.filter(c => !pinned.has(c.relPath));
232
+ }
233
+
234
+ export function previewArchivedCleanup(
235
+ percent: number,
236
+ codexHome: string = resolveCodexHomeDir(),
237
+ ): CleanupPreview {
238
+ const all = listArchivedCandidates(codexHome);
239
+ const safe = selectOldestPercentSkippingPendingRestore(
240
+ filterCandidatesExcludingPinned(all, codexHome),
241
+ percent,
242
+ codexHome,
243
+ );
244
+ const pct = clampPercent(percent);
245
+ return {
246
+ codexHome,
247
+ percent: pct,
248
+ count: safe.length,
249
+ bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
250
+ digest: computePreviewDigest(safe, pct),
251
+ candidates: safe,
252
+ };
253
+ }
254
+
255
+ /** Preview bound to an explicit candidate set (exact digest, percent left at 0). */
256
+ export function previewExactArchivedCleanup(
257
+ candidates: ArchivedCandidate[],
258
+ codexHome: string = resolveCodexHomeDir(),
259
+ ): CleanupPreview {
260
+ const safe = filterCandidatesExcludingPinned(
261
+ filterCandidatesExcludingPendingRestore(candidates, codexHome),
262
+ codexHome,
263
+ );
264
+ return {
265
+ codexHome,
266
+ percent: 0,
267
+ count: safe.length,
268
+ bytes: safe.reduce((sum, c) => sum + c.bytes, 0),
269
+ digest: computeExactPreviewDigest(safe),
270
+ candidates: safe,
271
+ };
272
+ }
273
+
274
+ /**
275
+ * Resolve an exact candidate list from current archive state.
276
+ * Returns null when any requested path is missing or drifted (caller maps to stale_preview).
277
+ */
278
+ export function resolveExactArchivedCandidates(
279
+ candidateRelPaths: string[],
280
+ codexHome: string = resolveCodexHomeDir(),
281
+ ): ArchivedCandidate[] | null {
282
+ if (!Array.isArray(candidateRelPaths) || candidateRelPaths.length === 0) return [];
283
+ const all = listArchivedCandidates(codexHome);
284
+ const byRel = new Map(all.map(c => [c.relPath, c]));
285
+ const selected: ArchivedCandidate[] = [];
286
+ for (const rel of candidateRelPaths) {
287
+ const hit = byRel.get(rel);
288
+ if (!hit) return null;
289
+ selected.push(hit);
290
+ }
291
+ return selected;
292
+ }