dsh-lcx-codex 0.3.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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ 本项目遵循语义化版本。未通过真实验收的能力只记录在Unreleased,不作为已发布功能。
4
+
5
+ ## Unreleased
6
+
7
+ ### Fixed
8
+
9
+ - Hosted Search 与 Alpha Search 现在和 Native V2 Compact 一样,复用 DSH 已添加的 `openai-responses` provider 路由、凭据引用、headers 与 retry policy;不再要求用户为插件重复配置 `LCX_API_KEY`。
10
+ - README 将插件运行时凭据与运行在 DSH 外的 Alpha 探针/E2E 测试凭据明确分开。
11
+ - README 改为简短的用户手册,以 npm 安装为主;Alpha 提示前置,并按 NewAPI 当前源码区分 4 种中转渠道类型与 Sub2API 直连,共 5 种部署路径。
12
+
13
+ ## 0.3.0 - 2026-08-20
14
+
15
+ ### Changed
16
+
17
+ - 收紧 npm/DSH 安装包,只保留运行时代码、Alpha 运维探针、许可证和用户文档;开发测试与 schema 校验脚本继续保留在源码仓库。
18
+ - README 和包元数据明确 `LCX` 只是插件名称;支持 Sub2API 反代或 NewAPI 中转的 GPT 模型,不隶属于 OpenAI;Alpha 能力继续按部署 fingerprint 与可信 provenance 分类,不作全局 native 承诺。
19
+ - 明确 Alpha 经过 NewAPI 时渠道类型必须为 `Sub2API`,不能使用普通 `OpenAI` 渠道。
20
+ - README 改为面向用户的中文文档,提供经 DSH/pnpm 帮助核对的 GitHub URL、Release 包、更新和卸载命令;本地 `link:` 安装明确归入源码开发流程。
21
+
22
+ ### Added
23
+
24
+ - Hosted Search 完整结构化参数与 citation/source/image 输出。
25
+ - 独立 `websearch_alpha`,支持 search、image、open/find/click、PDF screenshot、finance、weather、sports 和 time;capability/ref sidecar 按 route 与 session 隔离。
26
+ - Native V2 checkpoint v3、同路由 replay、Sol/Luna portable migration、fork/tree/restart generation lease 与 durable-image migration。
27
+
28
+ ### Fixed
29
+
30
+ - Native replay and portable migration no longer depend on the nonexistent `GenerateOptions.branchId`; fork safety uses public session ancestry and derived marker history while preserving existing v3 fingerprint compatibility.
31
+ - README 的本地 link 安装示例不再包含开发机绝对路径。
32
+ - Alpha 从 rc.8 公共 `session.requestContext()` 读取 active route,避免模型切换后的 capability 误判。
33
+ - Alpha 对 HTTP 200 内的函数调用语义错误 fail closed,并修正 sports action 的 wire 字段。
34
+ - Responses SSE 去重、usage、工具配对、并发 sidecar、Windows ACL、图片 offload/hydrate 和 remote/local summary 边界。
35
+
36
+ ## 0.2.0
37
+
38
+ - Hosted Responses query-only Web Search。
39
+ - Native Remote Compaction V2,拒绝legacy transport。
40
+ - checkpoint v3、同route replay和第一批portable model migration。
41
+ - 图片同route attachment hydrate,portable image migration保持fail closed。
42
+ - 协议、大小、超时、重试、redirect和日志脱敏基础测试。
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 dsh-lcx-codex contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # dsh-lcx-codex
2
+
3
+ 社区维护的 DSH 插件,为兼容 OpenAI Responses/Codex 协议的 GPT 模型增加 Hosted Web Search、Alpha Search 和 Native V2 远程压缩。
4
+
5
+ > [!IMPORTANT]
6
+ > Alpha Search 有 5 种可用部署路径:Sub2API 直连,以及经 NewAPI 的 4 种渠道类型:`Sub2API`、`New API`、`ChatGPT Subscription (Codex)`、`Advanced Custom`。普通 `OpenAI` 渠道不支持 `/v1/alpha/search`,会在 NewAPI 内被拒绝。
7
+
8
+ 这份名单来自 NewAPI 当前主分支的 [`AlphaSearchHelper`](https://github.com/QuantumNous/new-api/blob/f116414284162ad15d8925f7bca494c109b83e93/relay/alpha_search_handler.go)。不同版本的 NewAPI 可能有差异,最终以实际 capability probe 为准。
9
+
10
+ `LCX` 只是插件名称,不是服务商或协议。本插件支持:
11
+
12
+ - 直接使用 Sub2API 反代的 GPT 模型。
13
+ - 使用 NewAPI 中转的 GPT 模型(也就是第三方中转);NewAPI 的上游渠道连接 Sub2API。
14
+
15
+ ```text
16
+ DSH -> Sub2API -> OpenAI Responses/Codex-compatible upstream
17
+ DSH -> NewAPI -> Sub2API/compatible channel -> OpenAI Responses/Codex-compatible upstream
18
+ ```
19
+
20
+ 本项目不隶属于 OpenAI,也不是 OpenAI 官方发布的插件或 OAuth 客户端。
21
+
22
+ ## 功能
23
+
24
+ | 功能 | 工具或协议 | 说明 |
25
+ |---|---|---|
26
+ | Hosted Web Search | `websearch_gpt` | `/responses` + `web_search`,返回正文、来源和 citations |
27
+ | Alpha Search | `websearch_alpha` | `/alpha/search`,支持 search、open/find/click、PDF screenshot、image、finance、weather、sports 和 time |
28
+ | Native V2 Compact | `/responses` + `compaction_trigger` | 保存 checkpoint v3,支持同路由回放、模型迁移、fork/tree、重启和图片 attachment |
29
+
30
+ Alpha 只有在 capability 记录与当前 endpoint、provider、model 和 schema 匹配时才会启用。Hosted 与 Alpha 是两条独立协议,不会互相静默降级。
31
+
32
+ ## 安装
33
+
34
+ 发布到 npm 后,推荐直接安装包名:
35
+
36
+ ```powershell
37
+ dsh plugin --profile web add dsh-lcx-codex
38
+ ```
39
+
40
+ 也可以下载 GitHub Release 中的 `.tgz` 安装指定版本:
41
+
42
+ ```powershell
43
+ dsh plugin --profile web add .\dsh-lcx-codex-0.3.0.tgz
44
+ ```
45
+
46
+ 安装后启动 DSH:
47
+
48
+ ```powershell
49
+ dsh web
50
+ ```
51
+
52
+ 打开 `设置 -> 插件 -> LCX / Codex 能力`,按需启用 Hosted、Alpha 或 Native Compact。插件默认关闭。
53
+
54
+ ## 要求
55
+
56
+ - Node.js 20 或更高版本
57
+ - DSH `0.1.0-rc.8` 或兼容版本
58
+ - 已在 DSH 中添加并能正常对话的 GPT 模型
59
+ - 模型使用 `llm-pi-ai` 的 `openai-responses` provider
60
+
61
+ 插件复用当前 DSH 模型的 provider、model、Responses 地址、凭据引用、headers 和 retry policy,并通过 DSH credentials service 取凭据。正常运行不需要再给插件配置一份 `LCX_API_KEY`。
62
+
63
+ 界面中的 endpoint 和 model 字段用于没有活动会话路由时的默认选择,以及旧版直连配置兼容;同名 DSH provider 已存在时,以 DSH provider 配置为准。
64
+
65
+ ## Alpha probe
66
+
67
+ Alpha 能力按部署记录为 `native`、`command-capable`、`emulated-search-only`、`unsupported` 或 `unknown`。HTTP 200 本身不能证明 action 是原生能力。
68
+
69
+ 探针是 DSH runtime 外的独立 Node.js 脚本,不能调用 DSH credentials service,因此探针需要本机 key 文件;插件运行时不需要重复配置。
70
+
71
+ ```powershell
72
+ $dshHome = if ($env:DSH_HOME) { $env:DSH_HOME } else { Join-Path $env:USERPROFILE '.dsh' }
73
+ $env:LCX_API_KEY_FILE = 'C:\path\to\local-key.txt'
74
+ $env:LCX_MODEL = '实际模型名'
75
+ node (Join-Path $dshHome 'profiles\web\node_modules\dsh-lcx-codex\scripts\probe-alpha.mjs')
76
+ ```
77
+
78
+ 要同时探测 image、finance、weather、sports 和 time:
79
+
80
+ ```powershell
81
+ $env:LCX_ALPHA_PROBE_STRUCTURED = '1'
82
+ node (Join-Path $dshHome 'profiles\web\node_modules\dsh-lcx-codex\scripts\probe-alpha.mjs')
83
+ ```
84
+
85
+ 探针不会输出 key 或完整响应正文。完成后重启 DSH,或关闭再开启 Alpha 设置。
86
+
87
+ ## 数据与限制
88
+
89
+ - Checkpoint:`$DSH_HOME/storages/lcx-codex/checkpoints-v3.json`
90
+ - Alpha capability:`$DSH_HOME/storages/lcx-codex/web-alpha-capabilities.json`
91
+ - Alpha refs:`$DSH_HOME/storages/lcx-codex/web-alpha-refs.json`
92
+ - 只支持 Native remote-compaction V2,不调用 `/responses/compact`
93
+ - Checkpoint 不保存图片原始字节或 data URL
94
+ - Opaque checkpoint 不跨不兼容 provider、model、base URL、session 或 lineage 回放
95
+ - 不包含图片生成功能
96
+
97
+ 不要把 API key、OAuth token、Authorization header、账户 ID、session cookie 或运行时 sidecar 提交到 GitHub。
98
+
99
+ ## 更新与卸载
100
+
101
+ ```powershell
102
+ dsh plugin --profile web update dsh-lcx-codex
103
+ dsh plugin --profile web remove dsh-lcx-codex
104
+ ```
105
+
106
+ 卸载不会删除 `$DSH_HOME/storages/lcx-codex/`。如果会话仍引用 checkpoint marker,不要单独删除对应 sidecar。
107
+
108
+ ## 开发
109
+
110
+ ```powershell
111
+ npm install
112
+ npm test
113
+ npm run test:schema
114
+ ```
115
+
116
+ 真实 E2E 和 Alpha probe 只应读取本机忽略文件或环境变量中的测试凭据。
117
+
118
+ ## License
119
+
120
+ [MIT](LICENSE)
@@ -0,0 +1,18 @@
1
+ # Adds optional search and compaction for Sub2API-proxied or NewAPI-relayed GPT
2
+ # models without touching existing profiles or presets.
3
+ - insert:
4
+ - id: lcx-codex
5
+ name: dsh-lcx-codex
6
+ config:
7
+ provider: lcx
8
+ baseURL: https://api.lcxbot.com/v1
9
+ apiKeyEnv: LCX_API_KEY
10
+ model: gpt-5.6-sol
11
+ compactTransport: native-v2
12
+ checkpointPath: !!js dshHomePath('storages/lcx-codex/checkpoints-v3.json')
13
+ alphaCapabilityPath: !!js dshHomePath('storages/lcx-codex/web-alpha-capabilities.json')
14
+ alphaRefPath: !!js dshHomePath('storages/lcx-codex/web-alpha-refs.json')
15
+ webSearchProvider: lcx-responses
16
+ webMaxResults: 8
17
+ timeoutMs: 300000
18
+ maxAttempts: 3
@@ -0,0 +1,459 @@
1
+ import {
2
+ chmodSync,
3
+ closeSync,
4
+ fsyncSync,
5
+ mkdirSync,
6
+ openSync,
7
+ readFileSync,
8
+ readdirSync,
9
+ renameSync,
10
+ statSync,
11
+ unlinkSync,
12
+ writeFileSync,
13
+ } from 'node:fs'
14
+ import { dirname, join, basename } from 'node:path'
15
+ import { randomUUID } from 'node:crypto'
16
+ import { PORTABLE_HISTORY_BYTE_BUDGET } from './compact.js'
17
+ import { ensurePrivateFileAcl } from './private-file.js'
18
+
19
+ export const CHECKPOINT_V3_VERSION = 3
20
+ export const CHECKPOINT_V3_MAX_RECORDS = 256
21
+ export const CHECKPOINT_V3_MAX_BYTES = 16 * 1024 * 1024
22
+
23
+ const LOCK_STALE_MS = 30 * 1000
24
+ const LOCK_WAIT_MS = 5 * 1000
25
+ const TEMP_STALE_MS = 60 * 60 * 1000
26
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu
27
+
28
+ export class CheckpointV3Error extends Error {
29
+ constructor(message, code, cause) {
30
+ super(message, cause === undefined ? undefined : { cause })
31
+ this.name = 'CheckpointV3Error'
32
+ this.code = code
33
+ }
34
+ }
35
+
36
+ function isObject(value) {
37
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
38
+ }
39
+
40
+ function bytes(value, pretty = false) {
41
+ return Buffer.byteLength(JSON.stringify(value, null, pretty ? 2 : undefined), 'utf8')
42
+ }
43
+
44
+ function validImageAttachmentRef(ref) {
45
+ return isObject(ref) && /^sha256:[a-f0-9]{64}$/iu.test(String(ref.attachmentId ?? '')) &&
46
+ ['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(ref.mediaType) &&
47
+ Number.isSafeInteger(ref.bytes) && ref.bytes > 0 && Number.isSafeInteger(ref.width) && ref.width > 0 &&
48
+ Number.isSafeInteger(ref.height) && ref.height > 0 && (ref.name === undefined || typeof ref.name === 'string')
49
+ }
50
+
51
+ function validContentPart(part) {
52
+ if (!isObject(part) || typeof part.type !== 'string' || part.type.length === 0 || part.type === 'input_image') return false
53
+ if (part.type === 'dsh_image_attachment') return validImageAttachmentRef(part.attachment)
54
+ if ((part.type === 'input_text' || part.type === 'output_text' || part.type === 'summary_text' || part.type === 'refusal') && typeof part.text !== 'string') return false
55
+ return true
56
+ }
57
+
58
+ function validMessageItem(item) {
59
+ return typeof item.role === 'string' && item.role.length > 0 && Array.isArray(item.content) && item.content.every(validContentPart)
60
+ }
61
+
62
+ function validPortableMessageItem(item) {
63
+ return typeof item.role === 'string' && item.role.length > 0 && Array.isArray(item.content) &&
64
+ item.content.every(validContentPart)
65
+ }
66
+
67
+ function validNativeOutputItem(item) {
68
+ if (!isObject(item) || typeof item.type !== 'string' || item.type.length === 0) return false
69
+ if (item.id !== undefined && (typeof item.id !== 'string' || item.id.length === 0)) return false
70
+ if (item.type === 'message') return validMessageItem(item)
71
+ if (item.type === 'compaction') return validNativeCompaction(item)
72
+ if (item.type === 'function_call') {
73
+ return typeof item.call_id === 'string' && item.call_id.length > 0 &&
74
+ typeof item.name === 'string' && item.name.length > 0 &&
75
+ typeof item.arguments === 'string'
76
+ }
77
+ if (item.type === 'function_call_output') {
78
+ return typeof item.call_id === 'string' && item.call_id.length > 0 &&
79
+ (typeof item.output === 'string' || (Array.isArray(item.output) && item.output.every(validContentPart)))
80
+ }
81
+ if (item.type === 'reasoning') {
82
+ return (item.encrypted_content === undefined || typeof item.encrypted_content === 'string') &&
83
+ (item.content === undefined || (Array.isArray(item.content) && item.content.every(validContentPart))) &&
84
+ (item.summary === undefined || (Array.isArray(item.summary) && item.summary.every(validContentPart)))
85
+ }
86
+ return true
87
+ }
88
+
89
+ function validPortableItem(item) {
90
+ if (!isObject(item) || typeof item.type !== 'string' || item.type.length === 0) return false
91
+ if (item.type === 'compaction' || item.type === 'context_compaction' || item.type === 'compaction_trigger') return false
92
+ if (item.type.startsWith('response.')) return false
93
+ if (Object.prototype.hasOwnProperty.call(item, 'encrypted_content')) return false
94
+ if (item.id !== undefined && (typeof item.id !== 'string' || item.id.length === 0)) return false
95
+ if (item.type === 'message') return validPortableMessageItem(item)
96
+ if (item.type === 'function_call') {
97
+ return typeof item.call_id === 'string' && item.call_id.length > 0 &&
98
+ typeof item.name === 'string' && item.name.length > 0 &&
99
+ typeof item.arguments === 'string'
100
+ }
101
+ if (item.type === 'function_call_output') {
102
+ return typeof item.call_id === 'string' && item.call_id.length > 0 &&
103
+ (typeof item.output === 'string' || (Array.isArray(item.output) && item.output.every(validContentPart)))
104
+ }
105
+ return true
106
+ }
107
+
108
+ function validNativeCompaction(item) {
109
+ return isObject(item) && item.type === 'compaction' &&
110
+ typeof item.encrypted_content === 'string' && item.encrypted_content.length > 0
111
+ }
112
+
113
+ function validateItemCollection(items, validator, strictPairing) {
114
+ const ids = new Set()
115
+ const callIndexes = new Map()
116
+ const outputIndexes = new Map()
117
+ for (let index = 0; index < items.length; index += 1) {
118
+ const item = items[index]
119
+ if (!validator(item)) return false
120
+ if (item.id !== undefined) {
121
+ if (ids.has(item.id)) return false
122
+ ids.add(item.id)
123
+ }
124
+ if (item.type === 'function_call') {
125
+ if (callIndexes.has(item.call_id)) return false
126
+ callIndexes.set(item.call_id, index)
127
+ }
128
+ if (item.type === 'function_call_output') {
129
+ if (outputIndexes.has(item.call_id)) return false
130
+ outputIndexes.set(item.call_id, index)
131
+ }
132
+ }
133
+ if (strictPairing) {
134
+ if (callIndexes.size !== outputIndexes.size) return false
135
+ for (const [callId, callIndex] of callIndexes) {
136
+ if (!outputIndexes.has(callId) || callIndex >= outputIndexes.get(callId)) return false
137
+ }
138
+ }
139
+ return true
140
+ }
141
+
142
+ function validRecord(record, { strictPortablePairing = false } = {}) {
143
+ if (!isObject(record) || record.version !== CHECKPOINT_V3_VERSION ||
144
+ typeof record.checkpointId !== 'string' || record.checkpointId.length < 8 ||
145
+ typeof record.lineageId !== 'string' || record.lineageId.length === 0 ||
146
+ typeof record.sourceSessionId !== 'string' || record.sourceSessionId.length === 0 ||
147
+ record.sourceSessionId !== record.lineageId ||
148
+ (record.sourceBranchId !== undefined && (typeof record.sourceBranchId !== 'string' || record.sourceBranchId.length === 0)) ||
149
+ (record.parentCheckpointId !== undefined && (typeof record.parentCheckpointId !== 'string' || !UUID_PATTERN.test(record.parentCheckpointId))) ||
150
+ (record.transport !== undefined && typeof record.transport !== 'string') ||
151
+ typeof record.provider !== 'string' || record.provider.length === 0 ||
152
+ typeof record.model !== 'string' || record.model.length === 0 ||
153
+ typeof record.modelKey !== 'string' || record.modelKey !== `${record.provider}:${record.model}` ||
154
+ typeof record.baseURLFingerprint !== 'string' || record.baseURLFingerprint.length === 0 ||
155
+ typeof record.routeFingerprint !== 'string' || record.routeFingerprint.length === 0 ||
156
+ !Array.isArray(record.nativeOutput) || record.nativeOutput.length === 0 ||
157
+ !validNativeCompaction(record.nativeCompaction) ||
158
+ !Array.isArray(record.portableHistory) ||
159
+ typeof record.portableSummary !== 'string' ||
160
+ (record.portableImageCount !== undefined && (!Number.isSafeInteger(record.portableImageCount) || record.portableImageCount < 0)) ||
161
+ !Number.isFinite(record.createdAt)) return false
162
+
163
+ const nativeCompactions = record.nativeOutput.filter((item) => isObject(item) && item.type === 'compaction')
164
+ if (nativeCompactions.length !== 1 ||
165
+ nativeCompactions[0].encrypted_content !== record.nativeCompaction.encrypted_content) return false
166
+ if (!validateItemCollection(record.nativeOutput, validNativeOutputItem, true)) return false
167
+ if (!validateItemCollection(record.portableHistory, validPortableItem, strictPortablePairing)) return false
168
+ return bytes(record.nativeOutput) <= PORTABLE_HISTORY_BYTE_BUDGET &&
169
+ bytes(record.portableHistory) <= PORTABLE_HISTORY_BYTE_BUDGET &&
170
+ bytes(record.portableSummary) <= 16 * 1024
171
+ }
172
+
173
+ function emptyData() {
174
+ return { version: CHECKPOINT_V3_VERSION, checkpoints: {} }
175
+ }
176
+
177
+ function normalizedLimits(options = {}) {
178
+ const maxRecords = options.maxRecords ?? options.maxRecordCount ?? CHECKPOINT_V3_MAX_RECORDS
179
+ const maxBytes = options.maxBytes ?? options.maxTotalBytes ?? CHECKPOINT_V3_MAX_BYTES
180
+ if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || !Number.isSafeInteger(maxBytes) || maxBytes < 1) {
181
+ throw new CheckpointV3Error('Invalid checkpoint v3 store limits', 'LCX_CHECKPOINT_V3_LIMIT_INVALID')
182
+ }
183
+ return { maxRecords, maxBytes }
184
+ }
185
+
186
+ function validateData(parsed, file, limits, { strict = false, strictIds = new Set() } = {}) {
187
+ if (parsed?.version !== CHECKPOINT_V3_VERSION) {
188
+ throw new CheckpointV3Error(`Unsupported checkpoint v3 store in ${file}`, 'LCX_CHECKPOINT_V3_VERSION_UNSUPPORTED')
189
+ }
190
+ if (!isObject(parsed.checkpoints)) {
191
+ throw new CheckpointV3Error(`Invalid checkpoint v3 store in ${file}`, 'LCX_CHECKPOINT_V3_CORRUPT')
192
+ }
193
+ const entries = Object.entries(parsed.checkpoints)
194
+ if (entries.length > limits.maxRecords) {
195
+ throw new CheckpointV3Error(`Checkpoint v3 store exceeds record limit (${entries.length}/${limits.maxRecords})`, 'LCX_CHECKPOINT_V3_LIMIT_EXCEEDED')
196
+ }
197
+ for (const [id, record] of entries) {
198
+ if (id !== record?.checkpointId) {
199
+ throw new CheckpointV3Error(`Checkpoint v3 map key does not match record checkpointId (${id})`, 'LCX_CHECKPOINT_V3_KEY_MISMATCH')
200
+ }
201
+ if (!validRecord(record, { strictPortablePairing: strict || strictIds.has(id) })) {
202
+ throw new CheckpointV3Error(`Invalid checkpoint v3 record ${id} in ${file}`, 'LCX_CHECKPOINT_V3_CORRUPT')
203
+ }
204
+ }
205
+ for (const [id, record] of entries) {
206
+ if (record.parentCheckpointId !== undefined) {
207
+ const parent = parsed.checkpoints[record.parentCheckpointId]
208
+ if (!parent) throw new CheckpointV3Error(`Checkpoint v3 parent ${record.parentCheckpointId} for ${id} is missing`, 'LCX_CHECKPOINT_V3_PARENT_MISSING')
209
+ if (parent.lineageId !== record.lineageId) throw new CheckpointV3Error(`Checkpoint v3 parent ${record.parentCheckpointId} for ${id} has a different lineage`, 'LCX_CHECKPOINT_V3_PARENT_MISMATCH')
210
+ }
211
+ }
212
+ const data = { version: CHECKPOINT_V3_VERSION, checkpoints: parsed.checkpoints }
213
+ if (bytes(data, true) > limits.maxBytes) {
214
+ throw new CheckpointV3Error(`Checkpoint v3 store exceeds byte limit (${bytes(data, true)}/${limits.maxBytes})`, 'LCX_CHECKPOINT_V3_LIMIT_EXCEEDED')
215
+ }
216
+ return data
217
+ }
218
+
219
+ function sleepSync(milliseconds) {
220
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds)
221
+ }
222
+
223
+ function fileSignature(file) {
224
+ try {
225
+ const info = statSync(file)
226
+ return { mtimeMs: info.mtimeMs, ctimeMs: info.ctimeMs, size: info.size, ino: info.ino }
227
+ } catch (error) {
228
+ if (error?.code === 'ENOENT') return undefined
229
+ throw error
230
+ }
231
+ }
232
+
233
+ function sameFileSignature(left, right) {
234
+ return left?.mtimeMs === right?.mtimeMs && left?.ctimeMs === right?.ctimeMs &&
235
+ left?.size === right?.size && left?.ino === right?.ino
236
+ }
237
+
238
+ function lockMetadata(text) {
239
+ const lines = String(text ?? '').split(/\r?\n/u)
240
+ const pid = Number(lines[0])
241
+ const startedAt = Number(lines[1])
242
+ const token = typeof lines[2] === 'string' && lines[2].length > 0 ? lines[2] : undefined
243
+ return {
244
+ pid: Number.isSafeInteger(pid) && pid > 0 ? pid : undefined,
245
+ startedAt: Number.isFinite(startedAt) && startedAt > 0 ? startedAt : undefined,
246
+ token,
247
+ }
248
+ }
249
+
250
+ function processAlive(pid) {
251
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false
252
+ try {
253
+ process.kill(pid, 0)
254
+ return true
255
+ } catch (error) {
256
+ return error?.code === 'EPERM'
257
+ }
258
+ }
259
+
260
+ function staleLockSnapshot(file) {
261
+ try {
262
+ const info = statSync(file)
263
+ const text = readFileSync(file, 'utf8')
264
+ const metadata = lockMetadata(text)
265
+ const age = Date.now() - Math.max(info.mtimeMs, metadata.startedAt ?? 0)
266
+ if (age <= LOCK_STALE_MS || processAlive(metadata.pid)) return undefined
267
+ return { text, mtimeMs: info.mtimeMs, size: info.size }
268
+ } catch {
269
+ return undefined
270
+ }
271
+ }
272
+
273
+ function unlinkLockIfUnchanged(file, snapshot) {
274
+ if (!snapshot) return false
275
+ try {
276
+ const info = statSync(file)
277
+ if (info.mtimeMs !== snapshot.mtimeMs || info.size !== snapshot.size || readFileSync(file, 'utf8') !== snapshot.text) return false
278
+ unlinkSync(file)
279
+ return true
280
+ } catch {
281
+ return false
282
+ }
283
+ }
284
+
285
+ function cleanupStaleTemps(file) {
286
+ const directory = dirname(file)
287
+ const prefix = `.${basename(file)}.`
288
+ let names
289
+ try {
290
+ names = readdirSync(directory)
291
+ } catch {
292
+ return
293
+ }
294
+ const now = Date.now()
295
+ for (const name of names) {
296
+ if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue
297
+ const temporary = join(directory, name)
298
+ try {
299
+ const info = statSync(temporary)
300
+ if (info.isFile() && now - info.mtimeMs > TEMP_STALE_MS) unlinkSync(temporary)
301
+ } catch {
302
+ // A concurrent writer may have removed or replaced the candidate.
303
+ }
304
+ }
305
+ }
306
+
307
+ export class CheckpointV3Store {
308
+ constructor(file, options = {}) {
309
+ this.file = file
310
+ this.lockFile = `${file}.lock`
311
+ this.limits = normalizedLimits(options)
312
+ this.fileSignature = undefined
313
+ this.data = this.load()
314
+ }
315
+
316
+ load(options = {}) {
317
+ cleanupStaleTemps(this.file)
318
+ try {
319
+ const data = validateData(JSON.parse(readFileSync(this.file, 'utf8')), this.file, this.limits, options)
320
+ this.fileSignature = fileSignature(this.file)
321
+ return data
322
+ } catch (error) {
323
+ if (error?.code === 'ENOENT') {
324
+ this.fileSignature = undefined
325
+ return emptyData()
326
+ }
327
+ if (error instanceof CheckpointV3Error) throw error
328
+ throw new CheckpointV3Error(`Unable to load checkpoint v3 store ${this.file}`, 'LCX_CHECKPOINT_V3_CORRUPT', error)
329
+ }
330
+ }
331
+
332
+ refreshIfChanged() {
333
+ const currentSignature = fileSignature(this.file)
334
+ if (!sameFileSignature(currentSignature, this.fileSignature)) this.data = this.load()
335
+ }
336
+
337
+ get(id) {
338
+ this.refreshIfChanged()
339
+ return Object.prototype.hasOwnProperty.call(this.data.checkpoints, id) ? this.data.checkpoints[id] : undefined
340
+ }
341
+
342
+ has(id) {
343
+ return this.get(id) !== undefined
344
+ }
345
+
346
+ put(id, record) {
347
+ if (typeof id !== 'string' || id !== record?.checkpointId) {
348
+ throw new CheckpointV3Error(`Checkpoint v3 map key does not match record checkpointId (${id})`, 'LCX_CHECKPOINT_V3_KEY_MISMATCH')
349
+ }
350
+ if (!validRecord(record, { strictPortablePairing: true })) {
351
+ throw new CheckpointV3Error(`Invalid checkpoint v3 record ${id}`, 'LCX_CHECKPOINT_V3_CORRUPT')
352
+ }
353
+ mkdirSync(dirname(this.file), { recursive: true })
354
+ if (fileSignature(this.file)) ensurePrivateFileAcl(this.file)
355
+ ensurePrivateFileAcl(dirname(this.file))
356
+ const release = this.acquireLock()
357
+ try {
358
+ const latest = this.load()
359
+ const candidate = {
360
+ version: CHECKPOINT_V3_VERSION,
361
+ checkpoints: { ...latest.checkpoints, [id]: structuredClone(record) },
362
+ }
363
+ this.data = validateData(candidate, this.file, this.limits, { strictIds: new Set([id]) })
364
+ this.saveAtomicUnlocked()
365
+ } finally {
366
+ release()
367
+ }
368
+ }
369
+
370
+ acquireLock() {
371
+ const started = Date.now()
372
+ while (Date.now() - started <= LOCK_WAIT_MS) {
373
+ let fd
374
+ const token = randomUUID()
375
+ const lockText = `${process.pid}\n${Date.now()}\n${token}\n`
376
+ try {
377
+ fd = openSync(this.lockFile, 'wx', 0o600)
378
+ try {
379
+ writeFileSync(fd, lockText, { encoding: 'utf8' })
380
+ fsyncSync(fd)
381
+ } finally {
382
+ closeSync(fd)
383
+ fd = undefined
384
+ }
385
+ ensurePrivateFileAcl(this.lockFile)
386
+ return () => {
387
+ try {
388
+ const current = readFileSync(this.lockFile, 'utf8')
389
+ if (current === lockText) unlinkSync(this.lockFile)
390
+ } catch {
391
+ // The lock may have been removed by stale recovery after a crash.
392
+ }
393
+ }
394
+ } catch (error) {
395
+ if (fd !== undefined) {
396
+ try { closeSync(fd) } catch {}
397
+ }
398
+ if (error?.code !== 'EEXIST') {
399
+ throw new CheckpointV3Error(`Unable to acquire checkpoint v3 lock ${this.lockFile}`, 'LCX_CHECKPOINT_V3_LOCK_FAILED', error)
400
+ }
401
+ if (unlinkLockIfUnchanged(this.lockFile, staleLockSnapshot(this.lockFile))) continue
402
+ sleepSync(10)
403
+ }
404
+ }
405
+ throw new CheckpointV3Error(`Timed out acquiring checkpoint v3 lock`, 'LCX_CHECKPOINT_V3_LOCK_TIMEOUT')
406
+ }
407
+
408
+ saveAtomic(options = {}) {
409
+ mkdirSync(dirname(this.file), { recursive: true })
410
+ if (fileSignature(this.file)) ensurePrivateFileAcl(this.file)
411
+ ensurePrivateFileAcl(dirname(this.file))
412
+ const release = this.acquireLock()
413
+ try {
414
+ const latest = this.load()
415
+ const merged = {
416
+ version: CHECKPOINT_V3_VERSION,
417
+ checkpoints: { ...latest.checkpoints, ...this.data.checkpoints },
418
+ }
419
+ this.data = validateData(merged, this.file, this.limits, { strict: options.strict === true })
420
+ this.saveAtomicUnlocked()
421
+ } finally {
422
+ release()
423
+ }
424
+ }
425
+
426
+ saveAtomicUnlocked() {
427
+ cleanupStaleTemps(this.file)
428
+ const directory = dirname(this.file)
429
+ mkdirSync(directory, { recursive: true })
430
+ const temporary = join(directory, `.${basename(this.file)}.${randomUUID()}.tmp`)
431
+ const serialized = JSON.stringify(this.data, null, 2)
432
+ let fd
433
+ try {
434
+ fd = openSync(temporary, 'wx', 0o600)
435
+ // Harden the empty temporary file before it receives checkpoint content.
436
+ if (!ensurePrivateFileAcl(temporary)) {
437
+ throw new Error(`Unable to apply private ACL to checkpoint temporary file ${temporary}`)
438
+ }
439
+ writeFileSync(fd, serialized, { encoding: 'utf8' })
440
+ fsyncSync(fd)
441
+ closeSync(fd)
442
+ fd = undefined
443
+ try { chmodSync(temporary, 0o600) } catch {
444
+ // Windows may not expose POSIX mode bits; the ACL was applied before writing.
445
+ }
446
+ renameSync(temporary, this.file)
447
+ ensurePrivateFileAcl(this.file)
448
+ this.fileSignature = fileSignature(this.file)
449
+ } catch (error) {
450
+ if (fd !== undefined) {
451
+ try { closeSync(fd) } catch {}
452
+ }
453
+ try { unlinkSync(temporary) } catch {
454
+ // Preserve the original write failure.
455
+ }
456
+ throw new CheckpointV3Error(`Unable to atomically write checkpoint v3 store ${this.file}`, 'LCX_CHECKPOINT_V3_WRITE_FAILED', error)
457
+ }
458
+ }
459
+ }