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.
Files changed (98) hide show
  1. package/cli.js +109 -20
  2. package/dist/assets/App-CKvCavmF.js +2 -0
  3. package/dist/assets/App-Dj37fluO.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-BUubiE6Y.js → MdxEditorPanel-DeSJp_ct.js} +1 -1
  5. package/dist/assets/{Mobile-CKmfRRvo.js → Mobile-CWou-jtF.js} +1 -1
  6. package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-BK7Nhp_x.js} +1 -1
  7. package/dist/assets/{arc-D4iFrlhb.js → arc-Cm4HMXz6.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-Co8QCowN.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-Cudecf7x.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-4JeFoCHX.js} +1 -1
  11. package/dist/assets/{channel-B3VthT0m.js → channel-K7eT7Hjr.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-BBjl6EVW.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-DBP9YnwO.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6--A_i9gwR.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-UBsL-3qr.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-DBO0asmI.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DOCcEaE6.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-B-j8QbQh.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.js → chunk-YZCP3GAM-B0WO1U-U.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +1 -0
  22. package/dist/assets/clone-DjURvTeN.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-BDhNj8rq.js → cose-bilkent-S5V4N54A-DQgpss_a.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-CCJBKmKv.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-DgUcnfkU.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CRpVvexw.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-C3IhpsM-.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-BaVbsrwt.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-BxRoBUR8.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-Du2lIzoM.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-DR1pDqLK.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DHAmE20O.js} +1 -1
  33. package/dist/assets/{graph-BR2KmES9.js → graph-D8Yv0IU1.js} +1 -1
  34. package/dist/assets/{index-C-be29ey.js → index-AOmFG_I_.js} +1 -1
  35. package/dist/assets/{index-D5tSeMU7.js → index-B6ZWIxY0.js} +1 -1
  36. package/dist/assets/{index-D5YruOKD.js → index-Bq95HjYM.js} +1 -1
  37. package/dist/assets/{index--wgRW_OB.js → index-D0pvxpcl.js} +1 -1
  38. package/dist/assets/{index-DuZQ6FqR.js → index-D2c77__8.js} +1 -1
  39. package/dist/assets/{index-C16fiVhv.js → index-DgtUyP57.js} +1 -1
  40. package/dist/assets/{index-CjsQEQc2.js → index-Dpu92rw_.js} +2 -2
  41. package/dist/assets/{index-OWzSX2T8.js → index-cCB-lhEO.js} +1 -1
  42. package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-BAqRIm2G.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-CzH4tezb.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Qs4eiyuI.js} +1 -1
  45. package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-C0OccDsk.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-BAjl0gpV.js} +1 -1
  47. package/dist/assets/{layout-DQl4aw09.js → layout-Cf6cd9xE.js} +1 -1
  48. package/dist/assets/{linear-CLD3aPlK.js → linear-PzdCkji6.js} +1 -1
  49. package/dist/assets/mermaid.core-DV9745Lq.js +7 -0
  50. package/dist/assets/{min-_D86henJ.js → min-CHgWxbzS.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-CHUjMdpi.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-D_r2QC0f.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-DPzGygdN.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-BA2ynnGb.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-XHVvXuqU.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
  57. package/dist/assets/seqResourceLoaders-kBEzcKLh.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-C47sA8YH.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-BOmer839.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-CTAKVZgs.js} +1 -1
  62. package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-BuJ6oz45.js} +2 -2
  63. package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-D2KAW4ty.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-BsFVVUcj.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-BpxSq04x.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-an4WvAvS.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-CuXzoroh.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CFCmxnck.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-gfhDB9SR.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-X-pj66qI.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/package.json +1 -1
  73. package/server/i18n.js +120 -55
  74. package/server/interceptor.js +72 -2
  75. package/server/lib/ccswitch-import.js +251 -0
  76. package/server/lib/ensure-hooks.js +28 -2
  77. package/server/lib/log-watcher.js +8 -0
  78. package/server/lib/pid-alive.js +15 -0
  79. package/server/lib/proxy-retry.js +716 -0
  80. package/server/lib/session-start-bridge.js +121 -0
  81. package/server/lib/settings-merge.js +170 -0
  82. package/server/lib/v2/live-feed.js +189 -48
  83. package/server/lib/v2/session-owner.js +135 -0
  84. package/server/lib/v2/session-select.js +15 -2
  85. package/server/lib/v2/v2-writer.js +181 -6
  86. package/server/proxy.js +115 -11
  87. package/server/pty-manager.js +28 -4
  88. package/server/routes/events.js +44 -0
  89. package/server/routes/preferences.js +184 -1
  90. package/server/server.js +15 -1
  91. package/src/utils/contentFilter.js +17 -1
  92. package/dist/assets/App-B224r-8M.js +0 -2
  93. package/dist/assets/App-dYPa5-df.css +0 -1
  94. package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
  95. package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
  96. package/dist/assets/clone-BQtvy9Ae.js +0 -1
  97. package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
  98. 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
- cur = this._attach(sessionDir, { suppressExisting: false });
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
- try {
355
- cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
356
- cur.watcher.on('error', (err) => {
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
- try { cur.watcher.close(); } catch { /* already closed */ }
359
- cur.watcher = null; // safety poll keeps the cursor alive
360
- });
361
- } catch (err) {
362
- reportSwallowed('v2-live.session-watch', err);
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) cur.synth.ingestJournalLine(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 convRoot = join(cur.dir, 'conversations');
419
- if (!existsSync(convRoot)) return;
420
- let keys = [];
421
- try {
422
- keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
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
- // Numeric epoch order. NB: file order is NOT guaranteed to be globally
434
- // seq-ordered (a pre-fix writer restart appended newer seqs into an
435
- // older epoch file) — the synthesizer's ingestConvLine keeps its event
436
- // window seq-sorted on insert, so feed order only needs to be stable.
437
- files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
438
- for (const f of files) {
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
- const lines = readNewLines(fc);
446
- if (lines === null) { this._rebuildCursor(cur); return; }
447
- for (const raw of lines) {
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