cc-viewer 1.6.348 → 1.7.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/README.md +13 -1
- package/cli.js +116 -37
- package/dist/assets/App-DTkacfRW.js +2 -0
- package/dist/assets/{MdxEditorPanel-ualoRFOL.js → MdxEditorPanel-C5Q6uUyH.js} +1 -1
- package/dist/assets/Mobile-MoBp9c8k.js +1 -0
- package/dist/assets/{_baseUniq-CG81mgN_.js → _baseUniq-D9_E5ZDp.js} +1 -1
- package/dist/assets/{arc-C161J4f2.js → arc-D4iFrlhb.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-BmbDMdn9.js → architectureDiagram-Q4EWVU46-f1neo7hg.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DXpMvdxd.js → blockDiagram-DXYQGD6D-DWi9e9tG.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-8Mypg0IU.js → c4Diagram-AHTNJAMY-DAdj49A2.js} +1 -1
- package/dist/assets/{channel-B2khdmvc.js → channel-B3VthT0m.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-I61r1g6z.js → chunk-4BX2VUAB-BrPcAHV3.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-VFPlRE5E.js → chunk-4TB4RGXK-Bvopc1V9.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-DvCamg_6.js → chunk-55IACEB6-Juw-7Z3i.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-Dj4Q8RwR.js → chunk-EDXVE4YY-DVYP9tpB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-_bgQuuJh.js → chunk-FMBD7UC4-VQ7VOXl3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-3to38LyN.js → chunk-OYMX7WX6-F8Rj4ZUZ.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Zl26wKKz.js → chunk-QZHKN3VN-Dn7K3yi9.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-RLYPrlh0.js → chunk-YZCP3GAM-UjO-KTsn.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +1 -0
- package/dist/assets/clone-BQtvy9Ae.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CsYOjJ0V.js → cose-bilkent-S5V4N54A-BDhNj8rq.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-C3YXXaFU.js → dagre-KV5264BT-BzdcDuyt.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DUCOg1ld.js → diagram-5BDNPKRD-DP_SX65N.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-BtlLl_zX.js → diagram-G4DWMVQ6-B-pBFw8Q.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-1Qp9xfOk.js → diagram-MMDJMWI5-6XSfN1eN.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-Cj7VMp3L.js → diagram-TYMM5635-DJvGxvpE.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CVZVn541.js → erDiagram-SMLLAGMA-CpqdpGYj.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-CiU9Fkkv.js → flowDiagram-DWJPFMVM-C9m7wjwI.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-aRaG97ID.js → ganttDiagram-T4ZO3ILL-CU0XOfY3.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Cr2LJlvX.js → gitGraphDiagram-UUTBAWPF-ipyGULnP.js} +1 -1
- package/dist/assets/{graph-B59lDyMn.js → graph-BR2KmES9.js} +1 -1
- package/dist/assets/{index-C1ptnCpq.js → index--wgRW_OB.js} +1 -1
- package/dist/assets/index-Ac8vnV3v.js +2 -0
- package/dist/assets/{index-CRAWA8PC.js → index-C-be29ey.js} +1 -1
- package/dist/assets/{index-CSM1Kw8l.js → index-C16fiVhv.js} +1 -1
- package/dist/assets/{index-Ddk-kg22.js → index-D5YruOKD.js} +1 -1
- package/dist/assets/{index-BnhNIjbj.js → index-D5tSeMU7.js} +1 -1
- package/dist/assets/{index-vFEQq-NB.js → index-DuZQ6FqR.js} +1 -1
- package/dist/assets/{index-CO2hZ1s4.js → index-OWzSX2T8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-CnO22bw8.js → infoDiagram-42DDH7IO-BQWQjfFX.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-ByNzpScH.js → ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-yaVaCWPq.js → journeyDiagram-VCZTEJTY-CMbB_4G9.js} +1 -1
- package/dist/assets/{jszip.min-BdVFP3OW.js → jszip.min-B6k2t9Ch.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-RIPR_RQ3.js → kanban-definition-6JOO6SKY-CImdlKN3.js} +1 -1
- package/dist/assets/{layout-0rzel0v_.js → layout-DQl4aw09.js} +1 -1
- package/dist/assets/{linear-sktN0tB6.js → linear-CLD3aPlK.js} +1 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +7 -0
- package/dist/assets/{min-rspGOkz_.js → min-_D86henJ.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-BsVzZzTz.js → mindmap-definition-QFDTVHPH-2JVcu1nk.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-CsoWsw4f.js → pieDiagram-DEJITSTG-Dik-m5u7.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-CTMKuCAI.js → quadrantDiagram-34T5L4WZ-Bm5l40Jq.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C67256te.js → requirementDiagram-MS252O5E-Ck9w5rTj.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-X8Y7KnW0.js → sankeyDiagram-XADWPNL6-BbGVCFEf.js} +1 -1
- package/dist/assets/seqResourceLoaders-Cd_TAXTb.js +2 -0
- package/dist/assets/{seqResourceLoaders-B5Zy_CC_.css → seqResourceLoaders-CxuD1CYl.css} +1 -1
- package/dist/assets/{sequenceDiagram-FGHM5R23-Cils0TAp.js → sequenceDiagram-FGHM5R23-vjvEdksk.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DFnohvpG.js → stateDiagram-FHFEXIEX-DTqMoiB2.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BTX_4o81.js → stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-DrIYFrzZ.js → timeline-definition-GMOUNBTQ-Cw8O-52d.js} +1 -1
- package/dist/assets/{vendor-antd-D5HmSDSg.js → vendor-antd-DI7JL-mE.js} +1 -1
- package/dist/assets/{vendor-codemirror-C5yuj7ze.js → vendor-codemirror-B9c49dtM.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-BcEooTvb.js → vendor-mdxeditor-B6cpBtIE.js} +2 -2
- package/dist/assets/{vendor-qrcode-d9LCn0-9.js → vendor-qrcode-Dn90-s-u.js} +1 -1
- package/dist/assets/{vendor-virtuoso-K9m4MN6a.js → vendor-virtuoso-CpdSKD_6.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-lRqBYTr9.js → vennDiagram-DHZGUBPP-DCuqymb7.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-e8YfxpOe.js → wardley-RL74JXVD-BJ20wJUU.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Bxrg774h.js → wardleyDiagram-NUSXRM2D-Vz6US3qt.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-DWhZqhq8.js → xychartDiagram-5P7HB3ND-CzkLKrmi.js} +1 -1
- package/dist/index.html +4 -4
- package/findcc.js +4 -3
- package/package.json +2 -1
- package/server/i18n.js +21 -141
- package/server/interceptor.js +211 -403
- package/server/lib/async-write-queue.js +12 -1
- package/server/lib/delta-reconstructor.js +3 -3
- package/server/lib/error-report.js +45 -0
- package/server/lib/im-log-watcher.js +68 -8
- package/server/lib/interceptor-core.js +19 -174
- package/server/lib/log-management.js +214 -247
- package/server/lib/log-stream.js +59 -108
- package/server/lib/log-watcher.js +34 -272
- package/server/lib/log-zip.js +148 -0
- package/server/lib/session-boundary.js +266 -0
- package/server/lib/session-pin-store.js +10 -19
- package/server/lib/stats-worker.js +138 -267
- package/server/lib/user-prompt-extract.js +250 -0
- package/server/lib/v2/adapter.js +1059 -0
- package/server/lib/v2/blob-store.js +44 -0
- package/server/lib/v2/conversation-store.js +217 -0
- package/server/lib/v2/convert-manager.js +79 -0
- package/server/lib/v2/convert-worker.js +42 -0
- package/server/lib/v2/convert.js +409 -0
- package/server/lib/v2/identity.js +204 -0
- package/server/lib/v2/journal.js +62 -0
- package/server/lib/v2/layout.js +167 -0
- package/server/lib/v2/live-feed.js +451 -0
- package/server/lib/v2/migrate-prompt.js +54 -0
- package/server/lib/v2/replay.js +191 -0
- package/server/lib/v2/session-select.js +222 -0
- package/server/lib/v2/singleflight.js +104 -0
- package/server/lib/v2/v2-writer.js +513 -0
- package/server/lib/v2/verify.js +233 -0
- package/server/routes/events.js +30 -130
- package/server/routes/files-fs.js +4 -2
- package/server/routes/im.js +11 -2
- package/server/routes/logs.js +194 -152
- package/server/routes/preferences.js +0 -3
- package/server/routes/project-meta.js +2 -3
- package/server/routes/session-pin.js +7 -8
- package/server/routes/workspaces.js +44 -9
- package/server/server.js +47 -65
- package/server/workspace-registry.js +24 -4
- package/dist/assets/App-VTo4NVdo.js +0 -2
- package/dist/assets/Mobile-CcmYvMm_.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BXnyUgsj.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BXnyUgsj.js +0 -1
- package/dist/assets/clone-md-Mc1-T.js +0 -1
- package/dist/assets/index-DCo650PG.js +0 -2
- package/dist/assets/mermaid.core-C2Parj1q.js +0 -7
- package/dist/assets/seqResourceLoaders-BVozq68i.js +0 -2
- package/server/lib/instance-registry.js +0 -57
- package/server/lib/jsonl-archive.js +0 -180
- package/server/lib/teammate-detect.js +0 -32
- package/server/lib/zip-safety.js +0 -68
package/server/interceptor.js
CHANGED
|
@@ -7,16 +7,18 @@ const _ccvSkipArgs = ['--version', '-v', '--v', '--help', '-h', 'doctor', 'insta
|
|
|
7
7
|
const _ccvSkip = _ccvSkipArgs.includes(process.argv[2]);
|
|
8
8
|
|
|
9
9
|
import './lib/proxy-env.js';
|
|
10
|
-
import {
|
|
11
|
-
import { AsyncWriteQueue } from './lib/async-write-queue.js';
|
|
12
|
-
import { renameSyncWithRetry } from './lib/file-api.js';
|
|
10
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync, watchFile } from 'node:fs';
|
|
13
11
|
import http from 'node:http';
|
|
14
12
|
import https from 'node:https';
|
|
15
13
|
import { homedir } from 'node:os';
|
|
16
14
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
15
|
import { dirname, join, basename } from 'node:path';
|
|
18
16
|
import { LOG_DIR } from '../findcc.js';
|
|
19
|
-
import { assembleStreamMessage, createStreamAssembler,
|
|
17
|
+
import { assembleStreamMessage, createStreamAssembler, isAnthropicApiPath, isMainAgentRequest, replaceTopLevelModel, injectOutputConfigEffort, resolveProfileModel, extractAgentSpawnPairs } from './lib/interceptor-core.js';
|
|
18
|
+
import { V2Writer } from './lib/v2/v2-writer.js';
|
|
19
|
+
import { reportSwallowed } from './lib/error-report.js';
|
|
20
|
+
import { latestMainSessionDir, sessionHasCompletedMainTurn } from './lib/v2/session-select.js';
|
|
21
|
+
import { sanitizePathComponent } from './lib/v2/layout.js';
|
|
20
22
|
|
|
21
23
|
|
|
22
24
|
|
|
@@ -200,126 +202,27 @@ function _replaceProxyAuthHeaders(headers, apiKey) {
|
|
|
200
202
|
|
|
201
203
|
export { _activeProfile, _defaultConfig, _loadProxyProfile, PROFILE_PATH, setActiveProfileForWorkspace, getActiveProfileId };
|
|
202
204
|
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
+ String(now.getMonth() + 1).padStart(2, '0')
|
|
208
|
-
+ String(now.getDate()).padStart(2, '0')
|
|
209
|
-
+ '_'
|
|
210
|
-
+ String(now.getHours()).padStart(2, '0')
|
|
211
|
-
+ String(now.getMinutes()).padStart(2, '0')
|
|
212
|
-
+ String(now.getSeconds()).padStart(2, '0');
|
|
205
|
+
// 1.7.0: the v1 single-file write path is retired — logs live in per-session
|
|
206
|
+
// v2 dirs owned by V2Writer. Only the project binding (name + dir) remains
|
|
207
|
+
// here; there is no log FILE to create, resume, rotate or delta-encode.
|
|
208
|
+
function resolveProjectBinding() {
|
|
213
209
|
let cwd;
|
|
214
210
|
try { cwd = process.cwd(); } catch { cwd = homedir(); }
|
|
215
211
|
const projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
216
212
|
const dir = join(LOG_DIR, projectName);
|
|
217
213
|
try { mkdirSync(dir, { recursive: true }); } catch { }
|
|
218
|
-
|
|
219
|
-
// 内部读 env(与上面内部读 cwd 同理)→ 轮转/workspace 等所有 caller 自动带前缀,无需逐处传参。
|
|
220
|
-
// 前缀走单一来源 logFilePrefix(与 matcher 共用,防漂移)。
|
|
221
|
-
const instanceId = process.env.CCV_INSTANCE_ID || '';
|
|
222
|
-
return { filePath: join(dir, `${logFilePrefix(projectName, instanceId)}${ts}.jsonl`), dir, projectName };
|
|
214
|
+
return { dir, projectName };
|
|
223
215
|
}
|
|
224
216
|
|
|
225
|
-
//
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
if (choice === 'continue') {
|
|
235
|
-
// 将临时文件内容追加到旧日志
|
|
236
|
-
if (existsSync(tempFile)) {
|
|
237
|
-
const tempContent = readFileSync(tempFile, 'utf-8');
|
|
238
|
-
if (tempContent.trim()) {
|
|
239
|
-
appendFileSync(recentFile, tempContent);
|
|
240
|
-
}
|
|
241
|
-
unlinkSync(tempFile);
|
|
242
|
-
}
|
|
243
|
-
LOG_FILE = recentFile;
|
|
244
|
-
seedSpawnRegistryFromLogHead(LOG_FILE);
|
|
245
|
-
} else {
|
|
246
|
-
// new: 将临时文件 rename 为正式新日志文件名(空文件直接删除)
|
|
247
|
-
const newPath = tempFile.replace('_temp.jsonl', '.jsonl');
|
|
248
|
-
if (existsSync(tempFile)) {
|
|
249
|
-
const sz = statSync(tempFile).size;
|
|
250
|
-
if (sz > 0) {
|
|
251
|
-
renameSyncWithRetry(tempFile, newPath);
|
|
252
|
-
} else {
|
|
253
|
-
try { unlinkSync(tempFile); } catch { }
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
LOG_FILE = newPath;
|
|
257
|
-
}
|
|
258
|
-
} catch (err) {
|
|
259
|
-
console.error('[CC Viewer] resolveResumeChoice error:', err);
|
|
260
|
-
}
|
|
261
|
-
const result = { logFile: LOG_FILE };
|
|
262
|
-
_resumeState = null;
|
|
263
|
-
_resolveChoice(result);
|
|
264
|
-
return result;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Delta storage: 增量存储开关和状态(默认开启,设置 CCV_DISABLE_DELTA=1 关闭)
|
|
268
|
-
// 注意:delta 计算原本依赖 mainAgent 请求串行假设。实证发现 teammate 终止 / 多 SSE 通道
|
|
269
|
-
// 注入等情况会让两条 mainAgent 请求 30ms 内连续到达(前一条流式响应未完成时后一条已发起),
|
|
270
|
-
// 导致仅在 completed 时更新 _lastMessagesCount/_lastTailFp 出现状态滞后 → Plan C 漏检。
|
|
271
|
-
const _deltaStorageEnabled = process.env.CCV_DISABLE_DELTA !== '1';
|
|
272
|
-
// In-place last-msg replace 检测开关(默认开启,设置 CCV_DISABLE_TAIL_FP_CHECKPOINT=1 关闭)。
|
|
273
|
-
// 关闭后回退到旧行为(仅按长度算 delta,遇到末位原地替换会丢失"末位换内容"信息)。
|
|
274
|
-
const _tailFpCheckEnabled = process.env.CCV_DISABLE_TAIL_FP_CHECKPOINT !== '1';
|
|
275
|
-
// 这两个变量代表"截至本次请求开始前的最新已知态"。请求开始处理时即同步更新(eager),
|
|
276
|
-
// 不再等到 _commitDeltaState(completed 时执行)。Plan C 检测使用进入函数前的快照。
|
|
277
|
-
// 异常分支(请求失败 / 服务端不发送)不会回滚——下一个成功请求覆盖即可,状态不会永久错位。
|
|
278
|
-
// 命名说明:变量名保留 `_last` 前缀(历史命名),但语义已由"上次 commit 后"变为"上次见到的最新态"。
|
|
279
|
-
// 三路并发场景下,连续 3 个请求若 length 非单调(如 257→259→258),_lastMessagesCount 会跟随
|
|
280
|
-
// 最新一次 startRequest,可能让早到的更大值被覆盖;这种情况 Plan C 走 length 不等支路最终
|
|
281
|
-
// 命中 needsCheckpoint 写完整快照,client 拿到正确数据,不破坏正确性。
|
|
282
|
-
let _lastMessagesCount = 0; // 截至最近一次 startRequest 的完整 messages 数量(eager-updated)
|
|
283
|
-
let _lastTailFp = ''; // 截至最近一次 startRequest 的末位 message 指纹(eager-updated)
|
|
284
|
-
let _mainAgentDeltaCount = 0; // mainAgent 请求计数器(用于触发定期 checkpoint)
|
|
285
|
-
const CHECKPOINT_INTERVAL = 10; // 每 N 条 mainAgent 请求写一个 checkpoint
|
|
286
|
-
|
|
287
|
-
// 完成序倒置守卫(KEEP IN SYNC: server/lib/delta-reconstructor.js + docs/WIRE_FORMAT.md §3.7):
|
|
288
|
-
// entry 形态在请求发起时冻结,但 completed entry 按响应完成顺序落盘(AsyncWriteQueue FIFO)。
|
|
289
|
-
// burst 下慢请求的条目会落在快请求之后,文件序 ≠ 请求序,重建器按文件序拼接会翻倍。
|
|
290
|
-
// `_seq` 记录请求发起序(语义序),`_seqEpoch` 标识写进程(重启 / 多进程混写时 seq 不可比,
|
|
291
|
-
// 重建器据 epoch 切换基线而不是误判乱序)。teammate 子进程不参与(其条目不进 mainAgent 重建)。
|
|
292
|
-
let _seqCounter = 0;
|
|
293
|
-
// 时间戳 + 6 位随机尾:随机尾用于区分同毫秒启动的第二写进程(IM worker 场景)
|
|
294
|
-
const _seqEpoch = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* Delta storage: completed 写入成功后更新状态。
|
|
298
|
-
*
|
|
299
|
-
* 幂等守卫(`originalLength > _lastMessagesCount`):eager-update 已在请求开始时把
|
|
300
|
-
* _lastMessagesCount/_lastTailFp 推到本次值;本函数对同 originalLength 的 commit no-op,
|
|
301
|
-
* 且必须严格大于才更新——防止两条 mainAgent 请求乱序完成时,先到的较短 commit 把
|
|
302
|
-
* 已被 eager 推高的状态倒推回去(A 流式数秒、B 短先 commit、A 后 commit 时 _lastMessagesCount
|
|
303
|
-
* 与 _lastTailFp 都会被 A.length/fp 覆盖,下条 C 拿陈旧 prev → Plan C 漏/误检 → doubled-history
|
|
304
|
-
* 残余)。等长情况下也不动 fp:等长 in-place replace 的 _lastTailFp 已被 eager 推到 latest 值,
|
|
305
|
-
* commit 倒推会让下条请求的 Plan C 误判 in-place。
|
|
306
|
-
*
|
|
307
|
-
* 作用域纠正(不是真的兜底):本函数与 eager 块共享 "可写入" 前置条件——caller 传
|
|
308
|
-
* `_deltaOriginalMessagesLength`,该值只有在 `_deltaStorageEnabled && mainAgent &&
|
|
309
|
-
* Array.isArray(messages) && messages.length>0` 时才非 0;其它分支传 0 进来这里就
|
|
310
|
-
* short-circuit。即 `_deltaStorageEnabled=false / messages 非数组` 等 eager 跳过的分支
|
|
311
|
-
* 本函数同样跳过,不能视作异常路径的兜底。保留本函数是为了首次启动 / 定期 checkpoint
|
|
312
|
-
* 后的快路径回写(与 eager 等价但解耦 commit 时序),让未来 eager 块若重构调用顺序时
|
|
313
|
-
* commit 仍能把状态推上去。请求失败 / 服务端不发送时永远不调本函数;状态由 eager 残留,
|
|
314
|
-
* 下个成功请求覆盖即可,不会永久错位。
|
|
315
|
-
*/
|
|
316
|
-
function _commitDeltaState(originalLength, originalTailFp) {
|
|
317
|
-
if (_deltaStorageEnabled && originalLength > 0 && originalLength > _lastMessagesCount) {
|
|
318
|
-
_lastMessagesCount = originalLength;
|
|
319
|
-
if (typeof originalTailFp === 'string') {
|
|
320
|
-
_lastTailFp = originalTailFp;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
217
|
+
// The single completion-write helper shared by all five completion/error
|
|
218
|
+
// paths (streaming happy/assemble-fail, stream-setup catch, non-streaming
|
|
219
|
+
// happy, read-error). V2Writer catches + reportSwallowed internally; the outer
|
|
220
|
+
// catch is belt-and-braces so a broken guard can never crash the fetch hook —
|
|
221
|
+
// but a throw that ESCAPED the writer's own guards is a dropped log entry,
|
|
222
|
+
// which must never be silent (CLAUDE.md swallowed-catch rule).
|
|
223
|
+
function _writeCompletedEntry(requestEntry, v2Handle) {
|
|
224
|
+
try { _v2Writer.ingestCompletion(v2Handle, requestEntry); }
|
|
225
|
+
catch (err) { reportSwallowed('interceptor.ingest-completion', err); }
|
|
323
226
|
}
|
|
324
227
|
|
|
325
228
|
// Teammate 子进程检测:--parent-session-id(旧模式)或 --agent-name(原生 team 模式)
|
|
@@ -327,160 +230,204 @@ const _isTeammate = process.argv.includes('--parent-session-id') || process.argv
|
|
|
327
230
|
// 提取 teammate 元数据(--agent-name worker-1 --team-name fix-ts-errors)
|
|
328
231
|
let _teammateName = null;
|
|
329
232
|
let _teamName = null;
|
|
233
|
+
let _parentSessionId = null;
|
|
330
234
|
{
|
|
331
235
|
const args = process.argv;
|
|
332
236
|
const nameIdx = args.indexOf('--agent-name');
|
|
333
237
|
if (nameIdx !== -1 && nameIdx + 1 < args.length) _teammateName = args[nameIdx + 1];
|
|
334
238
|
const teamIdx = args.indexOf('--team-name');
|
|
335
239
|
if (teamIdx !== -1 && teamIdx + 1 < args.length) _teamName = args[teamIdx + 1];
|
|
240
|
+
const parentIdx = args.indexOf('--parent-session-id');
|
|
241
|
+
if (parentIdx !== -1 && parentIdx + 1 < args.length) _parentSessionId = args[parentIdx + 1];
|
|
336
242
|
}
|
|
337
243
|
|
|
338
|
-
//
|
|
339
|
-
|
|
340
|
-
const INSTANCE_ID = process.env.CCV_INSTANCE_ID || null;
|
|
341
|
-
|
|
342
|
-
// 初始化日志文件路径(异步,支持用户交互)
|
|
343
|
-
// 工作区模式下延迟到选择工作区后再初始化
|
|
344
|
-
let _newLogFile, _logDir, _projectName;
|
|
244
|
+
// 项目绑定初始化。工作区模式下延迟到选择工作区后再初始化。
|
|
245
|
+
let _logDir, _projectName;
|
|
345
246
|
if (process.env.CCV_WORKSPACE_MODE === '1') {
|
|
346
|
-
_newLogFile = '';
|
|
347
247
|
_logDir = '';
|
|
348
248
|
_projectName = '';
|
|
349
|
-
} else if (_isTeammate) {
|
|
350
|
-
// Teammate 子进程:只需 projectName 和 logDir 来查找 leader 日志,不生成新文件路径
|
|
351
|
-
let cwd;
|
|
352
|
-
try { cwd = process.cwd(); } catch { cwd = homedir(); }
|
|
353
|
-
_projectName = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
354
|
-
_logDir = join(LOG_DIR, _projectName);
|
|
355
|
-
// teammate 子进程继承同一 env(含 CCV_INSTANCE_ID)→ 命中 leader 的 pid 日志;无 env 时为 null = 现状。
|
|
356
|
-
const _leaderLog = findRecentLog(_logDir, _projectName, INSTANCE_ID);
|
|
357
|
-
_newLogFile = _leaderLog || ''; // 没有 leader 日志时不写入
|
|
358
249
|
} else {
|
|
359
|
-
({
|
|
360
|
-
|
|
361
|
-
|
|
250
|
+
({ dir: _logDir, projectName: _projectName } = resolveProjectBinding());
|
|
251
|
+
}
|
|
252
|
+
// Deprecated (1.7.0): the v1 log file no longer exists. The always-empty
|
|
253
|
+
// export keeps legacy read fallbacks ("no file → empty stream") type-stable
|
|
254
|
+
// until the last consumers are deleted with the continuity machinery (P3).
|
|
255
|
+
const LOG_FILE = '';
|
|
256
|
+
|
|
257
|
+
// The v2 writer (docs/refactor/WIRE_FORMAT_V2.md §13) — the ONLY log store
|
|
258
|
+
// since 1.7.0. Every method is internally caught + reportSwallowed; project
|
|
259
|
+
// resolves through a getter because workspace mode rebinds _projectName at
|
|
260
|
+
// runtime. The disk-space guard inside is the single remaining write inhibitor.
|
|
261
|
+
const _v2Writer = new V2Writer({
|
|
262
|
+
logDir: LOG_DIR,
|
|
263
|
+
project: () => _projectName,
|
|
264
|
+
...(_isTeammate && {
|
|
265
|
+
leader: {
|
|
266
|
+
...(_teammateName && { agentName: _teammateName }),
|
|
267
|
+
...(_teamName && { teamName: _teamName }),
|
|
268
|
+
...(_parentSessionId && { parentSessionId: _parentSessionId }),
|
|
269
|
+
},
|
|
270
|
+
}),
|
|
271
|
+
});
|
|
272
|
+
export { _v2Writer };
|
|
273
|
+
|
|
274
|
+
// S6b: the live read source — the current v2 session dir once the writer has
|
|
275
|
+
// resolved a session; '' before the first request (readers treat a missing
|
|
276
|
+
// path as an empty stream, same as v1's not-yet-created file did).
|
|
277
|
+
//
|
|
278
|
+
// Task B (2026-07-15): the [对话] panel must not cold-load empty. Two cases
|
|
279
|
+
// produce an empty current session: (a) before the first request, currentSid is
|
|
280
|
+
// null; (b) a session's sid resolved but it has NO main turn yet (`-c` startup
|
|
281
|
+
// or a background sub/count_tokens request set _currentSid before the user sent
|
|
282
|
+
// anything) — cold-loading THAT renders blank (the refresh bug: fresh load
|
|
283
|
+
// shows data, a refresh once the empty current session exists shows nothing,
|
|
284
|
+
// filling only after the user sends a request). So we serve the current session
|
|
285
|
+
// ONLY when it has a main turn ("activated"); otherwise we fall back to the
|
|
286
|
+
// newest MAIN session that does. Selection is idempotent across refreshes.
|
|
287
|
+
//
|
|
288
|
+
// The live feed still follows the ACTUAL written dir (it never reads this
|
|
289
|
+
// function), so once the current session gets its first main turn, its entries
|
|
290
|
+
// form a newer session the frontend auto-switches to. Selection rides the same
|
|
291
|
+
// bounded cold-load window (DEFAULT_EVENTS_LIMIT) as any session — never
|
|
292
|
+
// limit=0 — so it stays memory-safe (S10). All three cold-load consumers
|
|
293
|
+
// (/events, /api/requests, workspace reload) route through here.
|
|
294
|
+
export function getLiveLogSource() {
|
|
295
|
+
const dir = _v2Writer.currentSessionDir();
|
|
296
|
+
// "Activated" requires a COMPLETED main turn, not merely a written main
|
|
297
|
+
// request line: a session with only an in-flight first request has nothing a
|
|
298
|
+
// cold load can render yet, so keep falling back to the previous conversation
|
|
299
|
+
// (which CAN render) until the current one has a done. Removes the blank flash
|
|
300
|
+
// between "first `-c`/fresh main request written" and "its response emitted".
|
|
301
|
+
if (dir && sessionHasCompletedMainTurn(dir)) return dir; // activated current session
|
|
302
|
+
if (!_projectName) return ''; // no project bound yet (mirrors v2-writer's guard)
|
|
303
|
+
try {
|
|
304
|
+
// excludeDir: the current session just failed the completed-turn gate, but
|
|
305
|
+
// the picker's weaker has-a-main-req gate would re-select it (it is the
|
|
306
|
+
// newest dir once its first main req is written) — handing back exactly the
|
|
307
|
+
// blank in-flight session the strict gate rejected. Excluding it makes the
|
|
308
|
+
// fallback actually land on the previous, renderable conversation.
|
|
309
|
+
const fallback = latestMainSessionDir(join(LOG_DIR, sanitizePathComponent(_projectName)), { excludeDir: dir });
|
|
310
|
+
// A current-but-empty session with nothing else on disk: '' (still empty,
|
|
311
|
+
// but the live feed fills it in place — no worse than before the fix).
|
|
312
|
+
return fallback || dir || '';
|
|
313
|
+
} catch (err) {
|
|
314
|
+
reportSwallowed('cold-load.fallback-select', err);
|
|
315
|
+
return dir || '';
|
|
316
|
+
}
|
|
362
317
|
}
|
|
363
|
-
let LOG_FILE = _newLogFile;
|
|
364
318
|
|
|
365
|
-
//
|
|
366
|
-
|
|
319
|
+
// P2 (-c migration guidance): three detection channels, any one marks this
|
|
320
|
+
// launch as "continuing an older conversation" — the migrate prompt then
|
|
321
|
+
// re-prompts even a dismissed user, because the conversation's first half
|
|
322
|
+
// lives in un-migrated v1 logs and only migration makes it viewable.
|
|
323
|
+
// ① cli.js scans claude argv and sets CCV_CLAUDE_CONTINUE before importing
|
|
324
|
+
// the server (its own -c pass-through);
|
|
325
|
+
// ② the workspace launcher injects -c server-side (WorkspaceList heuristic)
|
|
326
|
+
// and calls markContinuedLaunch();
|
|
327
|
+
// ③ wire-level fallback in V2Writer: a brand-new session whose FIRST main
|
|
328
|
+
// snapshot already contains assistant turns can only be a continuation.
|
|
329
|
+
let _continuedLaunchMarked = false;
|
|
330
|
+
let _forkLaunchMarked = false;
|
|
331
|
+
export function markContinuedLaunch() { _continuedLaunchMarked = true; _syncContinuationMode(); }
|
|
332
|
+
export function isContinuedLaunch() {
|
|
333
|
+
return process.env.CCV_CLAUDE_CONTINUE === '1'
|
|
334
|
+
|| _continuedLaunchMarked
|
|
335
|
+
|| _v2Writer.sawContinuedSession();
|
|
336
|
+
}
|
|
337
|
+
// `--fork-session` (resume/continue but mint a NEW session id) — the user
|
|
338
|
+
// explicitly wants a fresh session, so `-c` folder adoption must NOT fire.
|
|
339
|
+
// Detected two ways, mirroring the continuation channels: ① cli.js sets
|
|
340
|
+
// CCV_CLAUDE_FORK_SESSION from the claude argv; ② the workspace launcher calls
|
|
341
|
+
// markForkSession().
|
|
342
|
+
export function markForkSession() { _forkLaunchMarked = true; _syncContinuationMode(); }
|
|
343
|
+
export function isForkSession() {
|
|
344
|
+
return process.env.CCV_CLAUDE_FORK_SESSION === '1' || _forkLaunchMarked;
|
|
345
|
+
}
|
|
346
|
+
// Explicit `-r`/`--resume` — the user targets a session of THEIR choosing (an
|
|
347
|
+
// id or the interactive picker), while adoption always targets the LATEST main
|
|
348
|
+
// session; if Claude mints a fresh wire sid for the resume, adoption would
|
|
349
|
+
// misroute the resumed conversation into whatever session happens to be newest.
|
|
350
|
+
// So an explicit resume keeps the pre-adoption behavior (its own folder);
|
|
351
|
+
// adoption serves `-c`/`--continue` only. Same two channels as fork: ① cli.js
|
|
352
|
+
// sets CCV_CLAUDE_RESUME from the claude argv; ② the workspace launcher calls
|
|
353
|
+
// markResumeSession(). The launch still counts as "continued" for the migrate
|
|
354
|
+
// prompt (isContinuedLaunch is unchanged).
|
|
355
|
+
let _resumeLaunchMarked = false;
|
|
356
|
+
export function markResumeSession() { _resumeLaunchMarked = true; _syncContinuationMode(); }
|
|
357
|
+
export function isResumeSession() {
|
|
358
|
+
return process.env.CCV_CLAUDE_RESUME === '1' || _resumeLaunchMarked;
|
|
359
|
+
}
|
|
360
|
+
// Push the launch's continuation/fork/resume intent into the writer so `-c`
|
|
361
|
+
// folder adoption can decide before the first request. Uses ONLY the
|
|
362
|
+
// pre-request continuation signals (env / workspace marker) — never the
|
|
363
|
+
// wire-level sawContinuedSession, which is known too late to avoid minting the
|
|
364
|
+
// folder.
|
|
365
|
+
function _syncContinuationMode() {
|
|
366
|
+
_v2Writer.setContinuationMode({
|
|
367
|
+
continued: process.env.CCV_CLAUDE_CONTINUE === '1' || _continuedLaunchMarked,
|
|
368
|
+
fork: isForkSession(),
|
|
369
|
+
resume: isResumeSession(),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
_syncContinuationMode(); // seed from the CLI env at module load (`ccv -c`)
|
|
367
373
|
|
|
368
374
|
// 现在 _projectName/_logDir 已初始化,可以安全加载 proxy profile(含 workspace override)
|
|
369
375
|
// 并挂载 watchFile 同步列表变化。
|
|
370
376
|
_loadProxyProfile();
|
|
371
377
|
try { watchFile(PROFILE_PATH, { interval: 1500 }, _loadProxyProfile); } catch { }
|
|
372
378
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
try {
|
|
377
|
-
let recentLog = findRecentLog(_logDir, _projectName, INSTANCE_ID);
|
|
378
|
-
// 首启接管(仅 --pid 实例):本 pid 还没有自己的日志时,可接管项目里最近的【无标签】日志
|
|
379
|
-
// (原子 rename 成 `<pid>__…`),从此并入该 pid 血脉。claim 必须在 build _resumeState 之前,
|
|
380
|
-
// 这样 _resumeState.recentFile 指向 rename 后的新路径(否则会指向已被移走的旧名)。
|
|
381
|
-
if (!recentLog && INSTANCE_ID) {
|
|
382
|
-
const claimed = claimUntaggedLog(_logDir, _projectName, INSTANCE_ID);
|
|
383
|
-
if (claimed) recentLog = claimed;
|
|
384
|
-
}
|
|
385
|
-
if (recentLog) {
|
|
386
|
-
// IM worker:无人值守、无 UI 可应答 resume 交互。直接 continue 最近会话日志(保留记忆、
|
|
387
|
-
// 让记录弹窗读到同一份持续增长的文件),不进入 resume 交互状态(否则会一直写 *_temp.jsonl,
|
|
388
|
-
// 仅在干净退出时才 rename,SIGKILL 下丢失)。
|
|
389
|
-
if (process.env.CCV_IM_PLATFORM) {
|
|
390
|
-
LOG_FILE = recentLog;
|
|
391
|
-
seedSpawnRegistryFromLogHead(LOG_FILE);
|
|
392
|
-
return;
|
|
393
|
-
}
|
|
394
|
-
// Leader / 普通进程:走 resume 交互流程
|
|
395
|
-
const tempFile = _newLogFile.replace('.jsonl', '_temp.jsonl');
|
|
396
|
-
LOG_FILE = tempFile;
|
|
397
|
-
_resumeState = {
|
|
398
|
-
recentFile: recentLog,
|
|
399
|
-
recentFileName: basename(recentLog),
|
|
400
|
-
tempFile,
|
|
401
|
-
};
|
|
402
|
-
}
|
|
403
|
-
} catch { }
|
|
404
|
-
})();
|
|
379
|
+
// Kept as an awaited boot barrier for callers; nothing asynchronous remains
|
|
380
|
+
// since the v1 resume flow retired (v2 sessions key off wire session_ids).
|
|
381
|
+
const _initPromise = Promise.resolve();
|
|
405
382
|
|
|
406
|
-
export { LOG_FILE, _initPromise,
|
|
383
|
+
export { LOG_FILE, _initPromise, _projectName, _logDir };
|
|
407
384
|
|
|
408
|
-
//
|
|
409
|
-
//
|
|
410
|
-
|
|
385
|
+
// 工作区模式:绑定指定路径的项目(v2 会话目录由 V2Writer 按需创建)。
|
|
386
|
+
// forceNew 仅存于签名兼容(Electron multi-tab 传入):v2 下每个 claude 进程
|
|
387
|
+
// 天然是新 session,无旧文件可复用。
|
|
388
|
+
export function initForWorkspace(projectPath, { forceNew = false } = {}) { // eslint-disable-line no-unused-vars
|
|
411
389
|
const projectName = basename(projectPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
412
390
|
const dir = join(LOG_DIR, projectName);
|
|
413
391
|
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
414
392
|
|
|
415
|
-
cleanupTempFiles(dir, projectName, INSTANCE_ID);
|
|
416
|
-
|
|
417
|
-
// 检查是否有最近的日志文件可以复用(始终复用最新日志)
|
|
418
|
-
// forceNew: Electron multi-tab 模式下强制创建新文件,避免与已有 ccv 实例共享日志
|
|
419
|
-
let recentLog = !forceNew && findRecentLog(dir, projectName, INSTANCE_ID);
|
|
420
|
-
// 首启接管(与单项目模式 _initPromise 一致):本 pid 在该 workspace 还没有自己的日志时,
|
|
421
|
-
// 接管最近的无标签日志,避免 workspace 模式下 --pid 首启不接管的不一致。forceNew 时显式不接管。
|
|
422
|
-
if (!recentLog && INSTANCE_ID && !forceNew) {
|
|
423
|
-
const claimed = claimUntaggedLog(dir, projectName, INSTANCE_ID);
|
|
424
|
-
if (claimed) recentLog = claimed;
|
|
425
|
-
}
|
|
426
|
-
if (recentLog) {
|
|
427
|
-
_projectName = projectName;
|
|
428
|
-
_logDir = dir;
|
|
429
|
-
LOG_FILE = recentLog;
|
|
430
|
-
// Same lifecycle as the single-project boot paths: start this workspace's
|
|
431
|
-
// registry fresh, then re-seed from the resumed segment's head sentinel so
|
|
432
|
-
// the next rotation's carry-forward stays complete after a restart/switch.
|
|
433
|
-
_agentSpawnRegistry.clear();
|
|
434
|
-
seedSpawnRegistryFromLogHead(LOG_FILE);
|
|
435
|
-
// workspace 切换后,重读该 workspace 的 active-profile.json(可能和上一个 workspace 不同)
|
|
436
|
-
_loadProxyProfile();
|
|
437
|
-
return { filePath: recentLog, dir, projectName, resumed: true };
|
|
438
|
-
}
|
|
439
|
-
|
|
440
|
-
// 没有最近日志,创建新文件
|
|
441
|
-
const now = new Date();
|
|
442
|
-
const ts = now.getFullYear().toString()
|
|
443
|
-
+ String(now.getMonth() + 1).padStart(2, '0')
|
|
444
|
-
+ String(now.getDate()).padStart(2, '0')
|
|
445
|
-
+ '_'
|
|
446
|
-
+ String(now.getHours()).padStart(2, '0')
|
|
447
|
-
+ String(now.getMinutes()).padStart(2, '0')
|
|
448
|
-
+ String(now.getSeconds()).padStart(2, '0');
|
|
449
|
-
|
|
450
|
-
// 与 generateNewLogFilePath 同款命名(共用 logFilePrefix):`--pid` 实例带 `<pid>__` 前缀。
|
|
451
|
-
const filePath = join(dir, `${logFilePrefix(projectName, INSTANCE_ID)}${ts}.jsonl`);
|
|
452
|
-
|
|
453
393
|
_projectName = projectName;
|
|
454
394
|
_logDir = dir;
|
|
455
|
-
|
|
456
|
-
//
|
|
395
|
+
// Fresh workspace context — no carried names apply; future requests must
|
|
396
|
+
// create session dirs under the NEW project.
|
|
457
397
|
_agentSpawnRegistry.clear();
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
398
|
+
_v2Writer.resetSessions(); // also clears the per-process `-c` adoption latch
|
|
399
|
+
// A `--fork-session` / `-r` launch marks fork/resume intent; neither must
|
|
400
|
+
// leak into a LATER workspace's `-c` (in a long-lived server that would
|
|
401
|
+
// permanently suppress adoption). Clear both marks per launch and re-sync —
|
|
402
|
+
// the workspace launcher re-marks right after if THIS launch is itself a
|
|
403
|
+
// fork/resume. (`_continuedLaunchMarked` stays sticky on purpose: the migrate
|
|
404
|
+
// prompt relies on it, and a stale continued flag is harmless — adoption also
|
|
405
|
+
// requires the wire to actually replay assistant history.)
|
|
406
|
+
_forkLaunchMarked = false;
|
|
407
|
+
_resumeLaunchMarked = false;
|
|
408
|
+
_syncContinuationMode();
|
|
409
|
+
_loadProxyProfile(); // 重读该 workspace 的 active-profile.json
|
|
410
|
+
|
|
411
|
+
return { filePath: '', dir, projectName, resumed: false };
|
|
461
412
|
}
|
|
462
413
|
|
|
463
414
|
// 工作区模式:重置日志状态(返回工作区列表时调用)
|
|
464
415
|
export function resetWorkspace() {
|
|
465
416
|
_projectName = '';
|
|
466
417
|
_logDir = '';
|
|
467
|
-
LOG_FILE = '';
|
|
468
418
|
// Workspace context gone: drop carried teammate names — the registry is
|
|
469
419
|
// module-global, and without this a rotation in the NEXT workspace would
|
|
470
420
|
// write a sentinel carrying THIS workspace's names (unbounded growth +
|
|
471
421
|
// cross-workspace leakage into the route's teammateNames snapshot).
|
|
472
422
|
_agentSpawnRegistry.clear();
|
|
423
|
+
_v2Writer.resetSessions(); // wire-v2: empty project → ingest no-ops until re-init
|
|
473
424
|
_loadProxyProfile(); // workspace 上下文消失,回落到 profile.json.active
|
|
474
425
|
}
|
|
475
426
|
|
|
476
|
-
// Windows NTFS + Defender 下大文件 I/O 代价远高于 Mac/Linux,降低分割阈值减轻压力
|
|
477
|
-
const MAX_LOG_SIZE = (process.platform === 'win32' ? 150 : 300) * 1024 * 1024;
|
|
478
|
-
|
|
479
427
|
// Agent-spawn registry: prompt-prefix(60) → teammate name, accumulated from
|
|
480
|
-
// mainAgent responses
|
|
481
|
-
// the rotation-
|
|
482
|
-
//
|
|
483
|
-
// extraction/parsing lives in interceptor-core.js (unit-tested there).
|
|
428
|
+
// mainAgent responses. v2 identity keying (ConvResolver) is the surviving
|
|
429
|
+
// consumer; the v1 rotation-sentinel carry retired with rotation itself. The
|
|
430
|
+
// pure extraction/parsing lives in interceptor-core.js (unit-tested there).
|
|
484
431
|
const _agentSpawnRegistry = new Map();
|
|
485
432
|
|
|
486
433
|
function collectAgentSpawns(entry) {
|
|
@@ -492,66 +439,6 @@ function collectAgentSpawns(entry) {
|
|
|
492
439
|
} catch { }
|
|
493
440
|
}
|
|
494
441
|
|
|
495
|
-
// Boot re-seed: when resuming an existing log whose FIRST frame is a
|
|
496
|
-
// rotation-context sentinel, load its carried names so a restarted leader
|
|
497
|
-
// still writes a complete sentinel at the next rotation. Bounded read of the
|
|
498
|
-
// file head only — never a whole-segment scan (segments reach 300MB).
|
|
499
|
-
function seedSpawnRegistryFromLogHead(file) {
|
|
500
|
-
try {
|
|
501
|
-
if (!file || !existsSync(file)) return;
|
|
502
|
-
const fd = openSync(file, 'r');
|
|
503
|
-
let head;
|
|
504
|
-
try {
|
|
505
|
-
const buf = Buffer.alloc(64 * 1024);
|
|
506
|
-
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
507
|
-
head = buf.toString('utf-8', 0, n);
|
|
508
|
-
} finally {
|
|
509
|
-
closeSync(fd);
|
|
510
|
-
}
|
|
511
|
-
const entry = parseRotationContextHead(head);
|
|
512
|
-
if (!entry || !Array.isArray(entry.teammateNames)) return;
|
|
513
|
-
for (const pair of entry.teammateNames) {
|
|
514
|
-
if (Array.isArray(pair) && pair[0] && pair[1]) _agentSpawnRegistry.set(pair[0], pair[1]);
|
|
515
|
-
}
|
|
516
|
-
} catch { }
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
async function checkAndRotateLogFile() {
|
|
520
|
-
// Teammate 不做日志轮转,由 leader 负责
|
|
521
|
-
if (_isTeammate) return;
|
|
522
|
-
try {
|
|
523
|
-
if (!existsSync(LOG_FILE) || statSync(LOG_FILE).size < MAX_LOG_SIZE) return;
|
|
524
|
-
} catch { return; }
|
|
525
|
-
await _writeQueue.flush();
|
|
526
|
-
const { filePath } = generateNewLogFilePath();
|
|
527
|
-
// Sentinel is baked into the new file's CREATION (rotateLogFile initial
|
|
528
|
-
// content): queueing it after the fact races the watcher's rotation-follow
|
|
529
|
-
// and can leave it undelivered. Synthetic url gives it a stable dedup key.
|
|
530
|
-
const sentinel = JSON.stringify({
|
|
531
|
-
ccvRotationContext: 1,
|
|
532
|
-
url: 'ccv://rotation-context',
|
|
533
|
-
from: basename(LOG_FILE),
|
|
534
|
-
teammateNames: [..._agentSpawnRegistry.entries()],
|
|
535
|
-
timestamp: new Date().toISOString(),
|
|
536
|
-
}) + '\n---\n';
|
|
537
|
-
const result = rotateLogFile(LOG_FILE, filePath, MAX_LOG_SIZE, sentinel);
|
|
538
|
-
if (result.rotated) {
|
|
539
|
-
LOG_FILE = result.newFile;
|
|
540
|
-
// 重置 delta 状态,强制下一条 mainAgent 请求写完整 checkpoint
|
|
541
|
-
if (_deltaStorageEnabled) {
|
|
542
|
-
_lastMessagesCount = 0;
|
|
543
|
-
_lastTailFp = '';
|
|
544
|
-
_mainAgentDeltaCount = 0;
|
|
545
|
-
}
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
// Exposed for the /api/prev-segment-teammates route (belt-and-braces seed
|
|
550
|
-
// delivery when the in-band sentinel falls outside the client's load window).
|
|
551
|
-
export function getAgentSpawnRegistrySnapshot() {
|
|
552
|
-
return [..._agentSpawnRegistry.entries()];
|
|
553
|
-
}
|
|
554
|
-
|
|
555
442
|
// 从环境变量 ANTHROPIC_BASE_URL 提取域名用于请求匹配
|
|
556
443
|
function getBaseUrlHost() {
|
|
557
444
|
try {
|
|
@@ -634,16 +521,25 @@ export function setupInterceptor() {
|
|
|
634
521
|
}
|
|
635
522
|
};
|
|
636
523
|
|
|
524
|
+
// The v2 queue drains on every exit path — close() falls back to synchronous
|
|
525
|
+
// drain, so a Ctrl-C cannot strand queued lines. The 2s race is insurance:
|
|
526
|
+
// a hung async appendFile (network FS, etc.) must never hold Ctrl-C hostage;
|
|
527
|
+
// the sync-drain fallback inside close() is blocking and cannot hang on the
|
|
528
|
+
// event loop.
|
|
529
|
+
const _closeQueuesBounded = () => Promise.race([
|
|
530
|
+
_v2Writer.close(),
|
|
531
|
+
new Promise(resolve => { const t = setTimeout(resolve, 2000); if (t.unref) t.unref(); }),
|
|
532
|
+
]);
|
|
637
533
|
process.on('SIGINT', () => {
|
|
638
|
-
|
|
534
|
+
_closeQueuesBounded().then(() => cleanupViewer()).finally(() => process.exit(0));
|
|
639
535
|
});
|
|
640
536
|
|
|
641
537
|
process.on('SIGTERM', () => {
|
|
642
|
-
|
|
538
|
+
_closeQueuesBounded().then(() => cleanupViewer()).finally(() => process.exit(0));
|
|
643
539
|
});
|
|
644
540
|
|
|
645
541
|
process.on('beforeExit', () => {
|
|
646
|
-
|
|
542
|
+
_closeQueuesBounded().then(() => cleanupViewer());
|
|
647
543
|
});
|
|
648
544
|
|
|
649
545
|
const _originalFetch = globalThis.fetch;
|
|
@@ -753,9 +649,7 @@ export function setupInterceptor() {
|
|
|
753
649
|
}
|
|
754
650
|
} catch { }
|
|
755
651
|
|
|
756
|
-
// 用户新指令边界:检查日志文件大小,超过 250MB 则切换新文件
|
|
757
652
|
if (requestEntry?.mainAgent) {
|
|
758
|
-
await checkAndRotateLogFile();
|
|
759
653
|
// 仅 mainAgent 请求时缓存模型名,避免 SubAgent 覆盖
|
|
760
654
|
if (requestEntry.body?.model && typeof requestEntry.body.model === 'string') {
|
|
761
655
|
_cachedModel = requestEntry.body.model;
|
|
@@ -766,96 +660,10 @@ export function setupInterceptor() {
|
|
|
766
660
|
}
|
|
767
661
|
}
|
|
768
662
|
|
|
769
|
-
// Delta storage:仅 mainAgent 且开关启用时,将 body.messages 转为增量格式
|
|
770
|
-
let _deltaOriginalMessagesLength = 0; // 缓存本次请求的原始 messages 长度,用于 completed 后更新状态
|
|
771
|
-
let _deltaOriginalTailFp = ''; // 缓存本次请求末位 message 的指纹,用于 completed 后更新 _lastTailFp
|
|
772
|
-
if (_deltaStorageEnabled && requestEntry?.mainAgent && Array.isArray(requestEntry.body?.messages)) {
|
|
773
|
-
const messages = requestEntry.body.messages;
|
|
774
|
-
_deltaOriginalMessagesLength = messages.length;
|
|
775
|
-
// 立即把末位 fp 算成字符串保存(不存对象引用),避免后续 mutation 风险
|
|
776
|
-
_deltaOriginalTailFp = messages.length > 0 ? fingerprintMsg(messages[messages.length - 1]) : '';
|
|
777
|
-
_mainAgentDeltaCount++;
|
|
778
|
-
|
|
779
|
-
// 完成序倒置守卫:请求发起序号(必须与下方 Plan C eager 块同处一个同步段,中间不得插 await)
|
|
780
|
-
if (!_isTeammate) {
|
|
781
|
-
requestEntry._seq = ++_seqCounter;
|
|
782
|
-
requestEntry._seqEpoch = _seqEpoch;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
// 并发竞态修复(详见模块顶部注释 + history.md Unreleased 段 fix(interceptor) 条目):
|
|
786
|
-
// snapshot 上一请求处理时的 count/fp 给 Plan C 用,然后 eager 把模块级状态推到本次值
|
|
787
|
-
// (不等 _commitDeltaState)。BUG 来源:teammate 终止快速串行让 mainAgent 30ms 内连续
|
|
788
|
-
// firing,旧 commit 时序使 Plan C 拿陈旧 prev 漏检 → client doubled-history。
|
|
789
|
-
const _prevMessagesCount = _lastMessagesCount;
|
|
790
|
-
const _prevTailFp = _lastTailFp;
|
|
791
|
-
if (_deltaOriginalMessagesLength > 0) {
|
|
792
|
-
_lastMessagesCount = _deltaOriginalMessagesLength;
|
|
793
|
-
if (_deltaOriginalTailFp !== '') _lastTailFp = _deltaOriginalTailFp;
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
// In-place last-msg replace 检测:messages.length 不变但末位 fp 不同。
|
|
797
|
-
// 触发场景:CLI 在 mainAgent 末位"原地替换"user msg(SUGGESTION MODE → 用户真实输入;
|
|
798
|
-
// synthetic recap 通道注入;teammate 终止快速串行 → SUGGESTION MODE 多次替换;等),
|
|
799
|
-
// wire 上长度未变内容变了。旧逻辑 messages.slice(_lastMessagesCount) 算出 delta=[],
|
|
800
|
-
// 丢失了"末位换内容"信息 → 客户端重建拿到错误的"前态末位"。
|
|
801
|
-
// 检测命中即强制写 checkpoint,让客户端拿到完整 wire 真实内容。
|
|
802
|
-
const _sameLenInPlaceReplace =
|
|
803
|
-
_tailFpCheckEnabled &&
|
|
804
|
-
messages.length === _prevMessagesCount &&
|
|
805
|
-
_prevMessagesCount > 0 &&
|
|
806
|
-
_prevTailFp !== '' &&
|
|
807
|
-
_deltaOriginalTailFp !== '' &&
|
|
808
|
-
_deltaOriginalTailFp !== _prevTailFp;
|
|
809
|
-
|
|
810
|
-
// 判断是否需要写 checkpoint
|
|
811
|
-
const needsCheckpoint =
|
|
812
|
-
_prevMessagesCount === 0 || // 进程重启 / 首次请求
|
|
813
|
-
messages.length < _prevMessagesCount || // messages 缩短(/clear、context 压缩)
|
|
814
|
-
(_mainAgentDeltaCount % CHECKPOINT_INTERVAL === 0) || // 定期 checkpoint
|
|
815
|
-
_sameLenInPlaceReplace; // in-place last-msg replace 检测
|
|
816
|
-
|
|
817
|
-
if (needsCheckpoint) {
|
|
818
|
-
// checkpoint:保持完整 messages,标记 _isCheckpoint
|
|
819
|
-
requestEntry._deltaFormat = 1;
|
|
820
|
-
requestEntry._totalMessageCount = messages.length;
|
|
821
|
-
requestEntry._conversationId = 'mainAgent';
|
|
822
|
-
requestEntry._isCheckpoint = true;
|
|
823
|
-
if (_sameLenInPlaceReplace) {
|
|
824
|
-
// 诊断字段:标记此 checkpoint 是被 in-place replace 检测触发的(频率约 1-2%,
|
|
825
|
-
// 用于在生产 jsonl 里事后核对触发率,不影响重建逻辑)。
|
|
826
|
-
// 双方协议(KEEP IN SYNC: src/utils/sessionManager.js applyInPlaceLastMsgReplace):
|
|
827
|
-
// 客户端 helper 看到此字段=true(与 _isCheckpoint:true 同时存在)时直接 in-place 替换
|
|
828
|
-
// lastSession.messages 末位,跳过 sessionMerge prefix-overlap 算法(避开 doubled-history)。
|
|
829
|
-
// 字段重命名 / 删除前需同步两端 + 重跑双向回归测试。
|
|
830
|
-
requestEntry._inPlaceReplaceDetected = true;
|
|
831
|
-
}
|
|
832
|
-
} else {
|
|
833
|
-
// delta:只保留新增的 messages(必须用 _prevMessagesCount,不是 eager 已更新的 _lastMessagesCount)
|
|
834
|
-
const delta = messages.slice(_prevMessagesCount);
|
|
835
|
-
requestEntry._deltaFormat = 1;
|
|
836
|
-
requestEntry._totalMessageCount = messages.length;
|
|
837
|
-
requestEntry._conversationId = 'mainAgent';
|
|
838
|
-
requestEntry._isCheckpoint = false;
|
|
839
|
-
requestEntry.body.messages = delta;
|
|
840
|
-
}
|
|
841
|
-
}
|
|
842
|
-
|
|
843
663
|
// 生成唯一请求 ID,用于关联在途请求和完成请求
|
|
844
664
|
const requestId = `${Date.now()}_${Math.random().toString(36).substring(2, 11)}`;
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
requestEntry.inProgress = true; // 标记为在途请求
|
|
848
|
-
}
|
|
849
|
-
|
|
850
|
-
// 在发起请求前先写入一条未完成的条目,让前端可以检测在途请求
|
|
851
|
-
// 例外:live-streaming 场景下,placeholder 由 sendStreamChunk 通过 HTTP 即时投递,
|
|
852
|
-
// 跳过磁盘预写可避免 log-watcher 500ms 后用空 placeholder 覆盖已显示的流式内容
|
|
853
|
-
if (requestEntry) {
|
|
854
|
-
const willLiveStream = !!_livePort && requestEntry.mainAgent && !_isTeammate;
|
|
855
|
-
if (!willLiveStream) {
|
|
856
|
-
_writeQueue.append(JSON.stringify(requestEntry) + '\n---\n');
|
|
857
|
-
}
|
|
858
|
-
}
|
|
665
|
+
let _v2Handle = null;
|
|
666
|
+
if (requestEntry) requestEntry.requestId = requestId;
|
|
859
667
|
|
|
860
668
|
// 流式请求状态追踪(仅对 Claude API 流式请求)
|
|
861
669
|
if (requestEntry?.isStream) {
|
|
@@ -954,6 +762,22 @@ export function setupInterceptor() {
|
|
|
954
762
|
} catch { }
|
|
955
763
|
}
|
|
956
764
|
|
|
765
|
+
if (requestEntry) {
|
|
766
|
+
// v2 req-phase ingest: journal seq is allocated inside, still in the
|
|
767
|
+
// fetch hook's synchronous segment (the proxy rewrite above is fully
|
|
768
|
+
// synchronous) — initiation order can never diverge from arrival order
|
|
769
|
+
// (§3.7 guard). Running AFTER the rewrite lets the journal req line
|
|
770
|
+
// capture proxyProfile/proxyUrl (they are assigned inside the rewrite;
|
|
771
|
+
// ingesting earlier silently dropped them — 1.8 review finding). The
|
|
772
|
+
// entry carries the pristine wire messages (the delta mutation retired
|
|
773
|
+
// with v1 write). Internally caught; the outer catch is belt-and-braces
|
|
774
|
+
// for a broken guard, same as _writeCompletedEntry — an escaping throw
|
|
775
|
+
// is a dropped entry and must be reported, never silent. The journal req
|
|
776
|
+
// line replaces the v1 in-flight placeholder pre-write.
|
|
777
|
+
try { _v2Handle = _v2Writer.ingestRequest(requestEntry, null); }
|
|
778
|
+
catch (err) { reportSwallowed('interceptor.ingest-request', err); }
|
|
779
|
+
}
|
|
780
|
+
|
|
957
781
|
let response;
|
|
958
782
|
try {
|
|
959
783
|
response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
|
|
@@ -1016,7 +840,7 @@ export function setupInterceptor() {
|
|
|
1016
840
|
body: { model: requestEntry.body?.model },
|
|
1017
841
|
};
|
|
1018
842
|
sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
|
|
1019
|
-
// 413 → 禁用当次流式,后续全由最终
|
|
843
|
+
// 413 → 禁用当次流式,后续全由最终 v2 completion 交付
|
|
1020
844
|
if (!ok) liveStreamEnabled = false;
|
|
1021
845
|
});
|
|
1022
846
|
// 短延迟后清标志,允许下一次发送;若中途有新快照等待,立即再发
|
|
@@ -1088,11 +912,8 @@ export function setupInterceptor() {
|
|
|
1088
912
|
collectAgentSpawns(requestEntry);
|
|
1089
913
|
|
|
1090
914
|
|
|
1091
|
-
//
|
|
1092
|
-
|
|
1093
|
-
delete requestEntry.requestId;
|
|
1094
|
-
{ const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
|
|
1095
|
-
_writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
|
|
915
|
+
// 移除在途请求标记,保持原始报文(seam 内先做 v2 completion ingest)
|
|
916
|
+
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1096
917
|
// Release memory: clear large objects after disk write
|
|
1097
918
|
streamedChunks = [];
|
|
1098
919
|
streamedContentLen = 0;
|
|
@@ -1100,10 +921,7 @@ export function setupInterceptor() {
|
|
|
1100
921
|
resetStreamingState();
|
|
1101
922
|
} catch (err) {
|
|
1102
923
|
requestEntry.response.body = fullContent.slice(0, 1000);
|
|
1103
|
-
|
|
1104
|
-
delete requestEntry.requestId;
|
|
1105
|
-
{ const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
|
|
1106
|
-
_writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
|
|
924
|
+
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1107
925
|
streamedChunks = [];
|
|
1108
926
|
streamedContentLen = 0;
|
|
1109
927
|
requestEntry.response = null;
|
|
@@ -1185,10 +1003,7 @@ export function setupInterceptor() {
|
|
|
1185
1003
|
headers: Object.fromEntries(response.headers.entries()),
|
|
1186
1004
|
body: '[Streaming Response - Capture failed]'
|
|
1187
1005
|
};
|
|
1188
|
-
|
|
1189
|
-
delete requestEntry.requestId;
|
|
1190
|
-
{ const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
|
|
1191
|
-
_writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
|
|
1006
|
+
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1192
1007
|
resetStreamingState();
|
|
1193
1008
|
}
|
|
1194
1009
|
} else {
|
|
@@ -1213,16 +1028,9 @@ export function setupInterceptor() {
|
|
|
1213
1028
|
|
|
1214
1029
|
collectAgentSpawns(requestEntry);
|
|
1215
1030
|
|
|
1216
|
-
|
|
1217
|
-
delete requestEntry.requestId;
|
|
1218
|
-
|
|
1219
|
-
{ const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
|
|
1220
|
-
_writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
|
|
1031
|
+
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1221
1032
|
} catch (err) {
|
|
1222
|
-
|
|
1223
|
-
delete requestEntry.requestId;
|
|
1224
|
-
{ const _dl = _deltaOriginalMessagesLength, _tf = _deltaOriginalTailFp;
|
|
1225
|
-
_writeQueue.append(JSON.stringify(requestEntry) + '\n---\n', () => _commitDeltaState(_dl, _tf)); }
|
|
1033
|
+
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1226
1034
|
}
|
|
1227
1035
|
}
|
|
1228
1036
|
}
|