cc-viewer 1.7.2 → 1.7.4
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/cli.js +109 -20
- package/dist/assets/App-CKvCavmF.js +2 -0
- package/dist/assets/App-Dj37fluO.css +1 -0
- package/dist/assets/{MdxEditorPanel-BUubiE6Y.js → MdxEditorPanel-DeSJp_ct.js} +1 -1
- package/dist/assets/{Mobile-CKmfRRvo.js → Mobile-CWou-jtF.js} +1 -1
- package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-BK7Nhp_x.js} +1 -1
- package/dist/assets/{arc-D4iFrlhb.js → arc-Cm4HMXz6.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-Co8QCowN.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-Cudecf7x.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-4JeFoCHX.js} +1 -1
- package/dist/assets/{channel-B3VthT0m.js → channel-K7eT7Hjr.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-BBjl6EVW.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-DBP9YnwO.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6--A_i9gwR.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-UBsL-3qr.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-DBO0asmI.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DOCcEaE6.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-B-j8QbQh.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.js → chunk-YZCP3GAM-B0WO1U-U.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +1 -0
- package/dist/assets/clone-DjURvTeN.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BDhNj8rq.js → cose-bilkent-S5V4N54A-DQgpss_a.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-CCJBKmKv.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-DgUcnfkU.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CRpVvexw.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-C3IhpsM-.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-BaVbsrwt.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-BxRoBUR8.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-Du2lIzoM.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-DR1pDqLK.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DHAmE20O.js} +1 -1
- package/dist/assets/{graph-BR2KmES9.js → graph-D8Yv0IU1.js} +1 -1
- package/dist/assets/{index-C-be29ey.js → index-AOmFG_I_.js} +1 -1
- package/dist/assets/{index-D5tSeMU7.js → index-B6ZWIxY0.js} +1 -1
- package/dist/assets/{index-D5YruOKD.js → index-Bq95HjYM.js} +1 -1
- package/dist/assets/{index--wgRW_OB.js → index-D0pvxpcl.js} +1 -1
- package/dist/assets/{index-DuZQ6FqR.js → index-D2c77__8.js} +1 -1
- package/dist/assets/{index-C16fiVhv.js → index-DgtUyP57.js} +1 -1
- package/dist/assets/{index-CjsQEQc2.js → index-Dpu92rw_.js} +2 -2
- package/dist/assets/{index-OWzSX2T8.js → index-cCB-lhEO.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-BAqRIm2G.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-CzH4tezb.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Qs4eiyuI.js} +1 -1
- package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-C0OccDsk.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-BAjl0gpV.js} +1 -1
- package/dist/assets/{layout-DQl4aw09.js → layout-Cf6cd9xE.js} +1 -1
- package/dist/assets/{linear-CLD3aPlK.js → linear-PzdCkji6.js} +1 -1
- package/dist/assets/mermaid.core-DV9745Lq.js +7 -0
- package/dist/assets/{min-_D86henJ.js → min-CHgWxbzS.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-CHUjMdpi.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-D_r2QC0f.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-DPzGygdN.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-BA2ynnGb.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-XHVvXuqU.js} +1 -1
- package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
- package/dist/assets/seqResourceLoaders-kBEzcKLh.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-C47sA8YH.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-BOmer839.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-CTAKVZgs.js} +1 -1
- package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-BuJ6oz45.js} +2 -2
- package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-D2KAW4ty.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-BsFVVUcj.js} +2 -2
- package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-BpxSq04x.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-an4WvAvS.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-CuXzoroh.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CFCmxnck.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-gfhDB9SR.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-X-pj66qI.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/i18n.js +120 -55
- package/server/interceptor.js +72 -2
- package/server/lib/ccswitch-import.js +251 -0
- package/server/lib/ensure-hooks.js +28 -2
- package/server/lib/log-watcher.js +8 -0
- package/server/lib/pid-alive.js +15 -0
- package/server/lib/proxy-retry.js +716 -0
- package/server/lib/session-start-bridge.js +121 -0
- package/server/lib/settings-merge.js +170 -0
- package/server/lib/v2/live-feed.js +189 -48
- package/server/lib/v2/session-owner.js +135 -0
- package/server/lib/v2/session-select.js +15 -2
- package/server/lib/v2/v2-writer.js +181 -6
- package/server/proxy.js +115 -11
- package/server/pty-manager.js +28 -4
- package/server/routes/events.js +44 -0
- package/server/routes/preferences.js +184 -1
- package/server/server.js +15 -1
- package/src/utils/contentFilter.js +17 -1
- package/dist/assets/App-B224r-8M.js +0 -2
- package/dist/assets/App-dYPa5-df.css +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
- package/dist/assets/clone-BQtvy9Ae.js +0 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
- package/dist/assets/seqResourceLoaders-C-T_EMiO.js +0 -2
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* session-start-bridge.js — SessionStart hook bridge for conversation-switch
|
|
4
|
+
* signals (in-terminal /resume, and future /clear / compact interest).
|
|
5
|
+
*
|
|
6
|
+
* Called by Claude Code whenever a session (re)starts: source is one of
|
|
7
|
+
* "startup" | "resume" | "clear" | "compact". The one that matters today is
|
|
8
|
+
* `resume` — an in-terminal /resume switches the running process to a PAST
|
|
9
|
+
* conversation while the wire session_id may stay the same, so without this
|
|
10
|
+
* signal cc-viewer keeps writing the resumed conversation into the OLD
|
|
11
|
+
* session dir and the [对话] panel never switches. The bridge forwards the
|
|
12
|
+
* hook payload to cc-viewer's /api/session-start-notify; the server gates on
|
|
13
|
+
* source and tells the V2Writer to re-bind routing (see v2-writer.js
|
|
14
|
+
* beginResumeSwitch).
|
|
15
|
+
*
|
|
16
|
+
* Hook config in ~/.claude/settings.json (injected by ensure-hooks.js, tagged
|
|
17
|
+
* `# cc-viewer-managed`):
|
|
18
|
+
* "hooks": {
|
|
19
|
+
* "SessionStart": [{ "hooks": [{ "type": "command",
|
|
20
|
+
* "command": "[ -n \"$CCVIEWER_PORT\" ] && node /path/to/session-start-bridge.js || true # cc-viewer-managed" }] }]
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Output contract (same as turn-end-bridge.js): NOTHING on stdout — Claude
|
|
24
|
+
* Code interprets SessionStart hook stdout as context-injection JSON
|
|
25
|
+
* (hookSpecificOutput.additionalContext), and any stray bytes would pollute
|
|
26
|
+
* the conversation. Optional stderr only when CCVIEWER_DEBUG=1. Always exit 0
|
|
27
|
+
* so a failed notify never blocks Claude Code's hook chain.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import http from 'node:http';
|
|
32
|
+
import https from 'node:https';
|
|
33
|
+
|
|
34
|
+
const debug = (msg) => {
|
|
35
|
+
if (process.env.CCVIEWER_DEBUG === '1') {
|
|
36
|
+
try { process.stderr.write(`[session-start-bridge] ${msg}\n`); } catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const port = process.env.CCVIEWER_PORT;
|
|
41
|
+
const rawProtocol = process.env.CCVIEWER_PROTOCOL;
|
|
42
|
+
const isHttps = rawProtocol === 'https';
|
|
43
|
+
const httpClient = isHttps ? https : http;
|
|
44
|
+
|
|
45
|
+
// cc-viewer not running — exit silently (stdout must stay clean, see header).
|
|
46
|
+
if (!port) {
|
|
47
|
+
debug('CCVIEWER_PORT unset — exit silently');
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Drain stdin best-effort. Claude Code passes the hook JSON payload
|
|
52
|
+
// ({session_id, transcript_path, source, cwd, ...}); capped to 64 KB to
|
|
53
|
+
// defang any malformed huge payload.
|
|
54
|
+
let stdinData = '';
|
|
55
|
+
try {
|
|
56
|
+
const buf = readFileSync(0);
|
|
57
|
+
stdinData = (buf.length > 64 * 1024 ? buf.slice(0, 64 * 1024) : buf).toString('utf-8');
|
|
58
|
+
} catch { /* stdin may not be piped — fine, still notify */ }
|
|
59
|
+
let sessionId = null;
|
|
60
|
+
let transcriptPath = null;
|
|
61
|
+
let source = null;
|
|
62
|
+
let cwd = null;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(stdinData);
|
|
65
|
+
sessionId = parsed?.session_id || null;
|
|
66
|
+
transcriptPath = parsed?.transcript_path || null;
|
|
67
|
+
source = parsed?.source || null;
|
|
68
|
+
cwd = parsed?.cwd || null;
|
|
69
|
+
} catch { /* fine — the server tolerates missing fields */ }
|
|
70
|
+
debug(`payload source=${source} session_id=${sessionId} transcript=${transcriptPath}`);
|
|
71
|
+
|
|
72
|
+
const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
|
|
73
|
+
const body = JSON.stringify({ source, sessionId, transcriptPath, cwd, ts: Date.now() });
|
|
74
|
+
const reqOpts = {
|
|
75
|
+
hostname: '127.0.0.1',
|
|
76
|
+
port: parseInt(port, 10),
|
|
77
|
+
path: '/api/session-start-notify',
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'Content-Length': Buffer.byteLength(body),
|
|
82
|
+
// Same anti-spoof header as turn-end-bridge: matched against the server's
|
|
83
|
+
// per-startup INTERNAL_TOKEN, env-leaked only to the claude child.
|
|
84
|
+
...(internalToken ? { 'X-CCViewer-Internal': internalToken } : {}),
|
|
85
|
+
},
|
|
86
|
+
// Keep the timeout snappy so a stale cc-viewer never blocks the Claude Code
|
|
87
|
+
// hook chain for a noticeable beat.
|
|
88
|
+
timeout: 500,
|
|
89
|
+
};
|
|
90
|
+
if (isHttps) {
|
|
91
|
+
// Loopback HTTPS is typically self-signed; validation would reject.
|
|
92
|
+
reqOpts.rejectUnauthorized = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let exited = false;
|
|
96
|
+
const finish = (reason) => {
|
|
97
|
+
if (exited) return;
|
|
98
|
+
exited = true;
|
|
99
|
+
if (reason) debug(reason);
|
|
100
|
+
process.exit(0);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let req;
|
|
104
|
+
try {
|
|
105
|
+
req = httpClient.request(reqOpts, (res) => {
|
|
106
|
+
res.resume();
|
|
107
|
+
res.on('end', () => finish(`POST done (status=${res.statusCode})`));
|
|
108
|
+
});
|
|
109
|
+
req.on('error', (err) => finish(`POST error: ${err?.message}`));
|
|
110
|
+
req.on('timeout', () => { try { req.destroy(); } catch { /* ignore */ } finish('POST timeout'); });
|
|
111
|
+
// Wrap the synchronous write/end so an immediate EPIPE never bubbles into
|
|
112
|
+
// Claude Code's transcript (same defensive shape as turn-end-bridge).
|
|
113
|
+
try {
|
|
114
|
+
req.write(body);
|
|
115
|
+
req.end();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
finish(`req.write threw: ${err?.message}`);
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
finish(`request() threw: ${err?.message}`);
|
|
121
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Settings files are tiny in practice; this caps a hostile `--settings /dev/zero`
|
|
5
|
+
// (a never-EOF read that would hang the server event loop) or a huge-file read.
|
|
6
|
+
const MAX_SETTINGS_FILE_BYTES = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
// Merges a user-supplied `--settings` launch arg into cc-viewer's injected settings
|
|
9
|
+
// object, so the final claude argv carries a SINGLE `--settings` flag.
|
|
10
|
+
//
|
|
11
|
+
// Why: claude's argv parser is last-wins for duplicate `--settings` (empirically
|
|
12
|
+
// verified on 2.1.212 — not documented). cc-viewer prepends its injected flag before
|
|
13
|
+
// user args, so a user-supplied `--settings` used to silently clobber the injected
|
|
14
|
+
// env.ANTHROPIC_BASE_URL proxy override (breaking capture) and the CCV_IM_DENY
|
|
15
|
+
// permissions.deny hardening. Merging keeps both: injected keys win, everything
|
|
16
|
+
// else the user set rides along.
|
|
17
|
+
//
|
|
18
|
+
// Parse rules pinned by experiments against claude 2.1.212:
|
|
19
|
+
// - Tokens after a literal `--` are prompt text, never flags → scan stops there.
|
|
20
|
+
// - `--settings <next>` consumes the next token as its value unconditionally, even
|
|
21
|
+
// an option-like one (`--settings --print` → claude reads "--print" as a settings
|
|
22
|
+
// path and hard-errors). The helper mirrors that consumption.
|
|
23
|
+
// - A trailing valueless `--settings` makes claude exit with "argument missing" →
|
|
24
|
+
// left in place so claude surfaces its own usage error, exactly as pre-fix.
|
|
25
|
+
// - An empty `--settings=` does NOT error but still clobbers earlier `--settings`
|
|
26
|
+
// flags via last-wins (the most dangerous form) → always stripped.
|
|
27
|
+
//
|
|
28
|
+
// Known accepted limitation (shared with withDefaultThinkingDisplay / hasArg): a
|
|
29
|
+
// literal "--settings" appearing before `--` as ANOTHER value-flag's argument
|
|
30
|
+
// (e.g. `--append-system-prompt "--settings"`) is indistinguishable without a full
|
|
31
|
+
// claude flag table. Probability negligible; bounded by the stop-at-`--` rule.
|
|
32
|
+
|
|
33
|
+
const FLAG = '--settings';
|
|
34
|
+
|
|
35
|
+
function stripBom(s) {
|
|
36
|
+
return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isPlainObject(v) {
|
|
40
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Remove C0 control chars (and DEL) so a value/reason echoed into the embedded xterm
|
|
44
|
+
// (via emitSpawnNotice) or a log line cannot inject ANSI escape sequences — the raw
|
|
45
|
+
// user value and a JSON.parse SyntaxError can both carry ESC bytes.
|
|
46
|
+
function stripControls(s) {
|
|
47
|
+
return String(s).replace(/[\u0000-\u001F\u007F]/g, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read + parse a user-pointed settings file DEFENSIVELY. The failure reason is echoed
|
|
51
|
+
// into a client-readable surface (the persistent terminal buffer + logs), so it must
|
|
52
|
+
// never disclose file contents or resolved paths — a raw readFileSync/JSON.parse error
|
|
53
|
+
// embeds the leading file bytes and the absolute path, turning the warning into a
|
|
54
|
+
// file-probing oracle. All failures collapse to generic, content-free messages; a
|
|
55
|
+
// statSync isFile()+size gate blocks /dev/zero-style hangs and oversized reads.
|
|
56
|
+
function readSettingsFile(path) {
|
|
57
|
+
let st;
|
|
58
|
+
try { st = statSync(path); } catch { throw new Error('settings file not found'); }
|
|
59
|
+
if (!st.isFile()) throw new Error('settings path is not a regular file');
|
|
60
|
+
if (st.size > MAX_SETTINGS_FILE_BYTES) throw new Error('settings file too large');
|
|
61
|
+
let raw;
|
|
62
|
+
try { raw = stripBom(readFileSync(path, 'utf8')); } catch { throw new Error('settings file could not be read'); }
|
|
63
|
+
let parsed;
|
|
64
|
+
try { parsed = JSON.parse(raw); } catch { throw new Error('settings file is not valid JSON'); }
|
|
65
|
+
if (!isPlainObject(parsed)) throw new Error('settings file must be a JSON object');
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Load a --settings value the way claude does: inline JSON when it looks like an
|
|
70
|
+
// object literal, otherwise a settings file path (resolved against the cwd claude
|
|
71
|
+
// itself will run with). Throws on any failure — caller turns that into a warning.
|
|
72
|
+
function loadSettingsValue(value, cwd) {
|
|
73
|
+
const trimmed = stripBom(value).trim();
|
|
74
|
+
if (trimmed === '') throw new Error('empty value');
|
|
75
|
+
if (trimmed.startsWith('{')) {
|
|
76
|
+
// Inline JSON is the user's own text; echoing it back on failure is not disclosure
|
|
77
|
+
// (control chars are stripped at the warning surface). File contents are different —
|
|
78
|
+
// handled by readSettingsFile, which never surfaces them.
|
|
79
|
+
const parsed = JSON.parse(trimmed);
|
|
80
|
+
if (!isPlainObject(parsed)) throw new Error('settings must be a JSON object');
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
return readSettingsFile(resolve(cwd, trimmed));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Shallow top-level merge with two special cases; injected keys win. Deep-merging
|
|
87
|
+
// other keys (hooks, statusLine, ...) would splice into structures the user owns.
|
|
88
|
+
function mergeSettings(userSettings, injectedSettings) {
|
|
89
|
+
const merged = { ...userSettings };
|
|
90
|
+
for (const [key, injectedValue] of Object.entries(injectedSettings)) {
|
|
91
|
+
if (key === 'env' && isPlainObject(merged.env)) {
|
|
92
|
+
merged.env = { ...merged.env, ...injectedValue };
|
|
93
|
+
} else if (key === 'permissions' && isPlainObject(merged.permissions)) {
|
|
94
|
+
const userDeny = Array.isArray(merged.permissions.deny) ? merged.permissions.deny : [];
|
|
95
|
+
const injectedDeny = Array.isArray(injectedValue.deny) ? injectedValue.deny : [];
|
|
96
|
+
merged.permissions = {
|
|
97
|
+
...merged.permissions,
|
|
98
|
+
...injectedValue,
|
|
99
|
+
deny: [...new Set([...userDeny, ...injectedDeny])],
|
|
100
|
+
};
|
|
101
|
+
} else {
|
|
102
|
+
merged[key] = injectedValue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return merged;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract any user `--settings` occurrences from launch args and merge the last
|
|
110
|
+
* one into the injected settings object (injected keys win; permissions.deny is
|
|
111
|
+
* unioned). Never throws — a value that cannot be loaded is dropped and reported
|
|
112
|
+
* via `warningDetail` so the caller can surface it (injection must never block spawn).
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} args launch args (user-controlled; may contain non-strings)
|
|
115
|
+
* @param {object} injectedSettings cc-viewer's settings object (env.ANTHROPIC_BASE_URL, optional permissions.deny)
|
|
116
|
+
* @param {{cwd?: string}} [opts] base dir for relative settings file paths — must match the cwd claude runs with
|
|
117
|
+
* @returns {{args: unknown[], settingsJson: string, merged: boolean, warningDetail: {value: string, reason: string}|null}}
|
|
118
|
+
* args: input args with consumed `--settings` occurrences removed;
|
|
119
|
+
* settingsJson: JSON for the single injected flag (byte-identical to
|
|
120
|
+
* JSON.stringify(injectedSettings) when nothing merged);
|
|
121
|
+
* merged: whether a user value was folded in; warningDetail: load-failure parts
|
|
122
|
+
* ({value, reason}, control-chars stripped) for the caller to render/localize —
|
|
123
|
+
* the English sentence lives only in the `cli.settingsMergeFailed` i18n key, so
|
|
124
|
+
* there is no duplicated copy here to drift.
|
|
125
|
+
*/
|
|
126
|
+
export function mergeSettingsIntoArgs(args, injectedSettings, { cwd = process.cwd() } = {}) {
|
|
127
|
+
const passthrough = { merged: false, warningDetail: null, settingsJson: JSON.stringify(injectedSettings) };
|
|
128
|
+
if (!Array.isArray(args)) return { args: [], ...passthrough };
|
|
129
|
+
|
|
130
|
+
const cleaned = [];
|
|
131
|
+
let lastValue = null; // last consumed --settings value (claude is last-wins)
|
|
132
|
+
for (let i = 0; i < args.length; i++) {
|
|
133
|
+
const token = args[i];
|
|
134
|
+
if (typeof token !== 'string') { cleaned.push(token); continue; }
|
|
135
|
+
if (token === '--') { cleaned.push(...args.slice(i)); break; }
|
|
136
|
+
if (token.startsWith(FLAG + '=')) {
|
|
137
|
+
lastValue = token.slice(FLAG.length + 1); // may be '' — still stripped (empty form clobbers via last-wins)
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (token === FLAG) {
|
|
141
|
+
if (i + 1 >= args.length) { cleaned.push(token); continue; } // trailing valueless: claude errors "argument missing" itself
|
|
142
|
+
lastValue = args[i + 1];
|
|
143
|
+
i++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
cleaned.push(token);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (lastValue === null) return { args: cleaned, ...passthrough };
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
if (typeof lastValue !== 'string') throw new Error('value is not a string');
|
|
153
|
+
const userSettings = loadSettingsValue(lastValue, cwd);
|
|
154
|
+
return {
|
|
155
|
+
args: cleaned,
|
|
156
|
+
settingsJson: JSON.stringify(mergeSettings(userSettings, injectedSettings)),
|
|
157
|
+
merged: true,
|
|
158
|
+
warningDetail: null,
|
|
159
|
+
};
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const shown = typeof lastValue === 'string' ? lastValue : String(lastValue);
|
|
162
|
+
// Strip control chars: `shown` is raw user input and `err.message` from an inline
|
|
163
|
+
// JSON.parse can echo user bytes — both flow into the terminal buffer / logs.
|
|
164
|
+
return {
|
|
165
|
+
args: cleaned,
|
|
166
|
+
...passthrough,
|
|
167
|
+
warningDetail: { value: stripControls(shown), reason: stripControls(err?.message || err) },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -22,13 +22,15 @@
|
|
|
22
22
|
// through log-watcher's processWatchedEntry pipeline — the same enrichment,
|
|
23
23
|
// reconstruction and side-events the v1 tail produced.
|
|
24
24
|
|
|
25
|
-
import { watch, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
25
|
+
import { watch, existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { reportSwallowed } from '../error-report.js';
|
|
28
28
|
import { createIncrementalReconstructor } from '../delta-reconstructor.js';
|
|
29
29
|
import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
|
|
30
30
|
import { SessionSynthesizer } from './adapter.js';
|
|
31
31
|
import { isDiscardableSession } from './session-select.js';
|
|
32
|
+
import { isForeignLiveOwned } from './session-owner.js';
|
|
33
|
+
import { isConvertRunning } from './convert-manager.js';
|
|
32
34
|
import { computeCacheLoss } from './meta-rows.js';
|
|
33
35
|
import { classifyRequest } from '../../../src/utils/requestType.js';
|
|
34
36
|
|
|
@@ -38,6 +40,12 @@ const DEFER_MS = 3000; // synthesizer park deadline (lagging lines)
|
|
|
38
40
|
const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
|
|
39
41
|
const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
|
|
40
42
|
const TICK_RETRY_MAX = 8;
|
|
43
|
+
// Idle cursor eviction: a followed dir whose journal hasn't moved for this
|
|
44
|
+
// long is detached, freeing its synthesizer/reconstructor state (a migration
|
|
45
|
+
// can promote hundreds of dirs at once — holding all of them pins ~the whole
|
|
46
|
+
// project in heap). Re-attach self-heals via the safety poll's mtime-bump
|
|
47
|
+
// scan or the leader's tick() (both suppress history on a seen dir).
|
|
48
|
+
const IDLE_EVICT_MS = 10 * 60 * 1000;
|
|
41
49
|
|
|
42
50
|
const READ_CHUNK_BYTES = 8 * 1024 * 1024;
|
|
43
51
|
// Node's max string length (~512MiB) — a longer line can never be decoded.
|
|
@@ -149,7 +157,11 @@ export class V2LiveFeed {
|
|
|
149
157
|
* @param {number} [opts.safetyPollMs] - 0 disables the timer (tests drive
|
|
150
158
|
* _safetyTick manually)
|
|
151
159
|
* @param {number} [opts.deferMs]
|
|
160
|
+
* @param {number} [opts.idleEvictMs] - 0 disables idle cursor eviction (tests)
|
|
152
161
|
* @param {Function} [opts.now]
|
|
162
|
+
* @param {Function} [opts.isConvertRunningFn] - injection seam (tests)
|
|
163
|
+
* @param {Function} [opts.isForeignLiveOwnedFn] - injection seam (tests):
|
|
164
|
+
* (dir) => boolean, defaults to session-owner's isForeignLiveOwned
|
|
153
165
|
*/
|
|
154
166
|
constructor(opts = {}) {
|
|
155
167
|
this._clients = opts.clients || [];
|
|
@@ -159,7 +171,14 @@ export class V2LiveFeed {
|
|
|
159
171
|
this._watchImpl = opts.watchImpl || watch;
|
|
160
172
|
this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
|
|
161
173
|
this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
|
|
174
|
+
this._idleEvictMs = typeof opts.idleEvictMs === 'number' ? opts.idleEvictMs : IDLE_EVICT_MS;
|
|
162
175
|
this._now = opts.now || Date.now;
|
|
176
|
+
this._isConvertRunning = opts.isConvertRunningFn || isConvertRunning;
|
|
177
|
+
// Claim owners are compared against THIS process's pid: the feed must
|
|
178
|
+
// co-reside with its V2Writer in one process (worker_threads would still
|
|
179
|
+
// share the pid; moving the feed into a forked CHILD process would make
|
|
180
|
+
// every own dir look foreign and break single-window live-follow).
|
|
181
|
+
this._isForeignLiveOwned = opts.isForeignLiveOwnedFn || isForeignLiveOwned;
|
|
163
182
|
// Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
|
|
164
183
|
// row (v2_requests_delta). Explicit ctor param — this module has no access
|
|
165
184
|
// to the server deps object.
|
|
@@ -207,7 +226,12 @@ export class V2LiveFeed {
|
|
|
207
226
|
if (!this._active || !sessionDir) return;
|
|
208
227
|
let cur = this._sessions.get(sessionDir);
|
|
209
228
|
if (!cur) {
|
|
210
|
-
|
|
229
|
+
// A dir we have ALREADY seen re-attaching through tick() is an idle
|
|
230
|
+
// eviction (or a watcher loss) resuming — its backlog is history and
|
|
231
|
+
// must be seeded suppressed, or the re-attach replays the whole session
|
|
232
|
+
// through the broadcast path (the exact OOM this module was fixed for).
|
|
233
|
+
// Only a genuinely never-seen dir emits from byte 0.
|
|
234
|
+
cur = this._attach(sessionDir, { suppressExisting: this._seenDirs.has(sessionDir) });
|
|
211
235
|
if (!cur) {
|
|
212
236
|
if (_retries < TICK_RETRY_MAX) {
|
|
213
237
|
setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
|
|
@@ -315,6 +339,16 @@ export class V2LiveFeed {
|
|
|
315
339
|
_attach(dir, { suppressExisting }) {
|
|
316
340
|
if (this._sessions.has(dir)) return this._sessions.get(dir);
|
|
317
341
|
if (!existsSync(join(dir, 'journal.jsonl'))) return null;
|
|
342
|
+
// Multi-window isolation: a dir exclusively claimed by ANOTHER live ccv
|
|
343
|
+
// window (owner.lock, pid-liveness validity) never enters this feed — its
|
|
344
|
+
// traffic belongs to that window. Checked BEFORE the discard gate so a
|
|
345
|
+
// foreign dir never lands in _discardGated (whose delete side-effect would
|
|
346
|
+
// flip suppressExisting to false on a later attach and flood clients with
|
|
347
|
+
// the whole foreign history). Re-evaluated on every attach attempt — no
|
|
348
|
+
// persistent gate set — so a dead owner's claim stops mattering the
|
|
349
|
+
// moment its pid dies. Own dirs (tick path) and unclaimed teammate/IM
|
|
350
|
+
// dirs pass through untouched.
|
|
351
|
+
if (this._isForeignLiveOwned(dir)) return null;
|
|
318
352
|
// Discardable sessions (quota-probe orphans — no main/teammate req, no
|
|
319
353
|
// meta.leader) are never followed: single choke point, every attach path
|
|
320
354
|
// (_initialScan / _maybeAttachNew / tick / _safetyTick / _rebuildCursor)
|
|
@@ -332,6 +366,32 @@ export class V2LiveFeed {
|
|
|
332
366
|
// not swallow it: cross-process producers (IM worker, second ccv) have no
|
|
333
367
|
// cold-load fallback for a connected client.
|
|
334
368
|
if (this._discardGated.delete(dir)) suppressExisting = false;
|
|
369
|
+
// Migration output is dead history: converted sessions (meta origin:
|
|
370
|
+
// 'convert') have no producer and never append again, yet a promote
|
|
371
|
+
// renames dozens of them into sessions/ at once with fresh birthtimes —
|
|
372
|
+
// full-history seeding synthesized + JSON-round-tripped every entry of
|
|
373
|
+
// every promoted session inside the safety-poll/debounce timers (the
|
|
374
|
+
// 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
|
|
375
|
+
// read, cloned, or broadcast; the cold-load channel owns their history.
|
|
376
|
+
let convertOrigin = false;
|
|
377
|
+
try {
|
|
378
|
+
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
|
|
379
|
+
convertOrigin = !!meta && meta.origin === 'convert';
|
|
380
|
+
} catch (err) {
|
|
381
|
+
// meta.json is written synchronously BEFORE the journal's first async
|
|
382
|
+
// drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
|
|
383
|
+
// abnormal. Never fall through to a broadcast replay: mid-conversion
|
|
384
|
+
// (same-process check — the converter worker runs inside the server
|
|
385
|
+
// process) the dir is almost certainly a promote in flight — treat as
|
|
386
|
+
// convert output; a truly missing meta otherwise seeds suppressed (cold
|
|
387
|
+
// load covers the history). Any OTHER errno is a transient lock on an
|
|
388
|
+
// EXISTING meta (Windows AV/EBUSY) — keep the caller's suppress verdict:
|
|
389
|
+
// flipping a fresh live session to suppressed would silently drop its
|
|
390
|
+
// first turn (review finding).
|
|
391
|
+
reportSwallowed('v2-live.meta-read', err);
|
|
392
|
+
if (this._isConvertRunning()) convertOrigin = true;
|
|
393
|
+
else if (err && err.code === 'ENOENT') suppressExisting = true;
|
|
394
|
+
}
|
|
335
395
|
const cur = {
|
|
336
396
|
dir,
|
|
337
397
|
synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
|
|
@@ -348,24 +408,92 @@ export class V2LiveFeed {
|
|
|
348
408
|
reading: false,
|
|
349
409
|
dirty: false,
|
|
350
410
|
suppress: !!suppressExisting,
|
|
411
|
+
convertOrigin,
|
|
412
|
+
// Journal req seqs with no done yet. The journal is only touched at
|
|
413
|
+
// request start and completion, so a single >10min request would look
|
|
414
|
+
// "idle" to the mtime-based eviction and get detached mid-flight — its
|
|
415
|
+
// completion would then re-attach suppressed and never resolve the
|
|
416
|
+
// already-broadcast placeholder (review finding). Eviction skips
|
|
417
|
+
// cursors with an open request. A crash orphan (req, no done ever)
|
|
418
|
+
// pins its one session un-evicted — acceptable: eviction is a memory
|
|
419
|
+
// optimization, a stuck live card is a correctness bug.
|
|
420
|
+
openReqs: new Set(),
|
|
351
421
|
};
|
|
352
422
|
this._sessions.set(dir, cur);
|
|
353
423
|
this._seenDirs.set(dir, this._journalMtime(dir));
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
424
|
+
if (!convertOrigin) {
|
|
425
|
+
try {
|
|
426
|
+
cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
|
|
427
|
+
cur.watcher.on('error', (err) => {
|
|
428
|
+
reportSwallowed('v2-live.session-watch', err);
|
|
429
|
+
try { cur.watcher.close(); } catch { /* already closed */ }
|
|
430
|
+
cur.watcher = null; // safety poll keeps the cursor alive
|
|
431
|
+
});
|
|
432
|
+
} catch (err) {
|
|
357
433
|
reportSwallowed('v2-live.session-watch', err);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
434
|
+
}
|
|
435
|
+
this._readCursor(cur); // seed (suppressed history or brand-new content)
|
|
436
|
+
} else {
|
|
437
|
+
// No per-dir watcher either: a promote can bring hundreds of dead dirs
|
|
438
|
+
// at once and each fs.watch costs an fd. The safety poll's unconditional
|
|
439
|
+
// _readCursor still picks up any (theoretical) append past the seeked
|
|
440
|
+
// offsets until idle eviction reclaims the cursor.
|
|
441
|
+
this._seekCursorsToEof(cur);
|
|
363
442
|
}
|
|
364
|
-
this._readCursor(cur); // seed (suppressed history or brand-new content)
|
|
365
443
|
cur.suppress = false;
|
|
366
444
|
return cur;
|
|
367
445
|
}
|
|
368
446
|
|
|
447
|
+
/** Position every cursor of a session at the current end of its file —
|
|
448
|
+
* attach without reading: nothing existing is synthesized or emitted, only
|
|
449
|
+
* bytes appended AFTER this point would flow. Enumerates ALL conv epoch
|
|
450
|
+
* files up front so pre-existing epochs can't be replayed from 0 later. */
|
|
451
|
+
_seekCursorsToEof(cur) {
|
|
452
|
+
const sizeOf = (p) => {
|
|
453
|
+
try { return statSync(p).size; } catch (err) {
|
|
454
|
+
if (err && err.code === 'ENOENT') return 0; // missing file: offset 0 IS its EOF
|
|
455
|
+
// Any other stat failure must never seed offset 0 — that re-arms the
|
|
456
|
+
// full replay this seek exists to prevent. Park the cursor past any
|
|
457
|
+
// real size; a later successful read sees size < offset and rebuilds.
|
|
458
|
+
reportSwallowed('v2-live.seek-stat', err);
|
|
459
|
+
return Number.MAX_SAFE_INTEGER;
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
cur.journal.offset = sizeOf(cur.journal.path);
|
|
463
|
+
cur.responses.offset = sizeOf(cur.responses.path);
|
|
464
|
+
for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
|
|
465
|
+
cur.convFiles.set(path, { key, path, offset: sizeOf(path) });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Enumerate a session's conversation epoch files as [{key, path}], epochs
|
|
470
|
+
* numerically ordered within each conv key — the single home of the
|
|
471
|
+
* two-level conversations/<key>/e<N>.jsonl walk (shared by the EOF-seek
|
|
472
|
+
* attach and the incremental reader; review dedup). NB: file order is NOT
|
|
473
|
+
* guaranteed to be globally seq-ordered (a pre-fix writer restart appended
|
|
474
|
+
* newer seqs into an older epoch file) — the synthesizer's ingestConvLine
|
|
475
|
+
* keeps its event window seq-sorted on insert, so consumers only need a
|
|
476
|
+
* stable feed order. */
|
|
477
|
+
_enumerateConvFiles(dir) {
|
|
478
|
+
const convRoot = join(dir, 'conversations');
|
|
479
|
+
const out = [];
|
|
480
|
+
let keys = [];
|
|
481
|
+
try {
|
|
482
|
+
keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
483
|
+
} catch {
|
|
484
|
+
return out; // no conversations dir yet
|
|
485
|
+
}
|
|
486
|
+
for (const key of keys) {
|
|
487
|
+
let files = [];
|
|
488
|
+
try {
|
|
489
|
+
files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
|
|
490
|
+
} catch { continue; }
|
|
491
|
+
files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
|
|
492
|
+
for (const f of files) out.push({ key, path: join(convRoot, key, f) });
|
|
493
|
+
}
|
|
494
|
+
return out;
|
|
495
|
+
}
|
|
496
|
+
|
|
369
497
|
_closeCursor(cur) {
|
|
370
498
|
if (cur.debounce) { clearTimeout(cur.debounce); cur.debounce = null; }
|
|
371
499
|
if (cur.watcher) { try { cur.watcher.close(); } catch { /* already closed */ } cur.watcher = null; }
|
|
@@ -405,7 +533,11 @@ export class V2LiveFeed {
|
|
|
405
533
|
try { line = JSON.parse(raw); } catch (err) {
|
|
406
534
|
reportSwallowed('v2-live.journal-parse', new Error(`${cur.dir}: ${err.message}`));
|
|
407
535
|
}
|
|
408
|
-
if (line)
|
|
536
|
+
if (line) {
|
|
537
|
+
if (line.ph === 'req') cur.openReqs.add(line.seq);
|
|
538
|
+
else if (line.ph === 'done') cur.openReqs.delete(line.seq);
|
|
539
|
+
cur.synth.ingestJournalLine(line);
|
|
540
|
+
}
|
|
409
541
|
}
|
|
410
542
|
this._emitDrained(cur);
|
|
411
543
|
} while (cur.dirty);
|
|
@@ -415,44 +547,22 @@ export class V2LiveFeed {
|
|
|
415
547
|
}
|
|
416
548
|
|
|
417
549
|
_feedConvFiles(cur) {
|
|
418
|
-
const
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
} catch {
|
|
424
|
-
return;
|
|
425
|
-
}
|
|
426
|
-
for (const key of keys) {
|
|
427
|
-
let files = [];
|
|
428
|
-
try {
|
|
429
|
-
files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
|
|
430
|
-
} catch {
|
|
431
|
-
continue;
|
|
550
|
+
for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
|
|
551
|
+
let fc = cur.convFiles.get(path);
|
|
552
|
+
if (!fc) {
|
|
553
|
+
fc = { key, path, offset: 0 };
|
|
554
|
+
cur.convFiles.set(path, fc);
|
|
432
555
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
const p = join(convRoot, key, f);
|
|
440
|
-
let fc = cur.convFiles.get(p);
|
|
441
|
-
if (!fc) {
|
|
442
|
-
fc = { key, path: p, offset: 0 };
|
|
443
|
-
cur.convFiles.set(p, fc);
|
|
556
|
+
const lines = readNewLines(fc);
|
|
557
|
+
if (lines === null) { this._rebuildCursor(cur); return; }
|
|
558
|
+
for (const raw of lines) {
|
|
559
|
+
let ev = null;
|
|
560
|
+
try { ev = JSON.parse(raw); } catch (err) {
|
|
561
|
+
reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
|
|
444
562
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
let ev = null;
|
|
449
|
-
try { ev = JSON.parse(raw); } catch (err) {
|
|
450
|
-
reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
|
|
451
|
-
}
|
|
452
|
-
if (ev) {
|
|
453
|
-
cur.synth.ingestConvLine(key, ev);
|
|
454
|
-
if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
|
|
455
|
-
}
|
|
563
|
+
if (ev) {
|
|
564
|
+
cur.synth.ingestConvLine(key, ev);
|
|
565
|
+
if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
|
|
456
566
|
}
|
|
457
567
|
}
|
|
458
568
|
}
|
|
@@ -622,7 +732,38 @@ export class V2LiveFeed {
|
|
|
622
732
|
if (!this._active) return;
|
|
623
733
|
this._armRootWatcher(); // re-arm after errors / late directory creation
|
|
624
734
|
// Attached sessions: unconditional slow re-read (fs.watch is lossy).
|
|
735
|
+
// Idle eviction first: a cursor whose journal has not moved for
|
|
736
|
+
// _idleEvictMs is detached, releasing its synthesizer/reconstructor/
|
|
737
|
+
// _v3PrevMain retained state (post-migration this is ~the whole project).
|
|
738
|
+
// _seenDirs keeps the mtime, so the existing mtime-bump scan below (or the
|
|
739
|
+
// leader's tick(), now suppress-on-seen) re-attaches on real new activity.
|
|
625
740
|
for (const cur of [...this._sessions.values()]) {
|
|
741
|
+
// Multi-window isolation: an ATTACHED dir can turn foreign-owned after
|
|
742
|
+
// the fact — an unowned (dead-owner) dir followed since startup gets
|
|
743
|
+
// adopted by another window's `ccv -c`. The attach-time gate can't see
|
|
744
|
+
// that, so re-check here and detach before the unconditional re-read
|
|
745
|
+
// would stream the adopter's traffic into this window (bounded to one
|
|
746
|
+
// safety-poll period). _seenDirs keeps the current mtime so a later
|
|
747
|
+
// ownership release (owner exits) plus new activity re-attaches through
|
|
748
|
+
// the normal mtime-bump path below.
|
|
749
|
+
if (this._isForeignLiveOwned(cur.dir)) {
|
|
750
|
+
this._closeCursor(cur);
|
|
751
|
+
this._sessions.delete(cur.dir);
|
|
752
|
+
this._seenDirs.set(cur.dir, this._journalMtime(cur.dir));
|
|
753
|
+
continue;
|
|
754
|
+
}
|
|
755
|
+
// openReqs guard: never evict mid-flight — a long request keeps the
|
|
756
|
+
// journal mtime frozen between its req and done lines, and evicting
|
|
757
|
+
// then would strand its already-broadcast placeholder (see openReqs).
|
|
758
|
+
if (this._idleEvictMs > 0 && cur.openReqs.size === 0) {
|
|
759
|
+
const mtime = this._journalMtime(cur.dir);
|
|
760
|
+
if (mtime > 0 && this._now() - mtime > this._idleEvictMs) {
|
|
761
|
+
this._closeCursor(cur);
|
|
762
|
+
this._sessions.delete(cur.dir);
|
|
763
|
+
this._seenDirs.set(cur.dir, mtime);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
626
767
|
this._readCursor(cur);
|
|
627
768
|
}
|
|
628
769
|
// Unattached dirs: attach brand-new ones (missed root events) and
|