cc-viewer 1.6.263 → 1.6.264

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 (92) hide show
  1. package/README.md +13 -0
  2. package/cli.js +4 -0
  3. package/dist/assets/App-CKzgXXd9.js +1 -0
  4. package/dist/assets/{MdxEditorPanel--reKHew0.js → MdxEditorPanel-BUWD79wR.js} +1 -1
  5. package/dist/assets/Mobile-Dwh-S57S.js +1 -0
  6. package/dist/assets/{_baseUniq-DiLy7vi3.js → _baseUniq-Dgkw4IXM.js} +1 -1
  7. package/dist/assets/{arc-CAB2oIHx.js → arc-AiHQLijx.js} +1 -1
  8. package/dist/assets/{architectureDiagram-Q4EWVU46-Cijl_JpW.js → architectureDiagram-Q4EWVU46-CPRvAIHK.js} +1 -1
  9. package/dist/assets/{blockDiagram-DXYQGD6D-Bk4yWCPQ.js → blockDiagram-DXYQGD6D-CK2cwrfX.js} +1 -1
  10. package/dist/assets/{c4Diagram-AHTNJAMY-Vz4JKuzi.js → c4Diagram-AHTNJAMY-BP-UBbgv.js} +1 -1
  11. package/dist/assets/{channel-BnYKz_zI.js → channel-Ny3Nm_-t.js} +1 -1
  12. package/dist/assets/{chunk-4BX2VUAB-DM3ZjqKX.js → chunk-4BX2VUAB-DdsULqPZ.js} +1 -1
  13. package/dist/assets/{chunk-4TB4RGXK-BTiJOoNa.js → chunk-4TB4RGXK-BDSjQHh0.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-B4fMQcTE.js → chunk-55IACEB6-DrKr3wBa.js} +1 -1
  15. package/dist/assets/{chunk-EDXVE4YY-B_WylnyS.js → chunk-EDXVE4YY-o_0SUbAB.js} +1 -1
  16. package/dist/assets/{chunk-FMBD7UC4-Cx2lqZi9.js → chunk-FMBD7UC4-Ca_AgqWi.js} +1 -1
  17. package/dist/assets/{chunk-OYMX7WX6-CPZm7o6V.js → chunk-OYMX7WX6-CyWWbq5o.js} +1 -1
  18. package/dist/assets/{chunk-QZHKN3VN-DuYVzv7E.js → chunk-QZHKN3VN-5rXHErSL.js} +1 -1
  19. package/dist/assets/{chunk-YZCP3GAM-Bk3OysLK.js → chunk-YZCP3GAM-DznXBadU.js} +1 -1
  20. package/dist/assets/classDiagram-6PBFFD2Q-CLYcbnwx.js +1 -0
  21. package/dist/assets/classDiagram-v2-HSJHXN6E-CLYcbnwx.js +1 -0
  22. package/dist/assets/clone-5GFhU8Pv.js +1 -0
  23. package/dist/assets/{cose-bilkent-S5V4N54A-CQuaqKHt.js → cose-bilkent-S5V4N54A-BhGyix0v.js} +1 -1
  24. package/dist/assets/{dagre-KV5264BT-BFdoRcuo.js → dagre-KV5264BT-CzzHxIvc.js} +1 -1
  25. package/dist/assets/{diagram-5BDNPKRD-ByFdSFIu.js → diagram-5BDNPKRD-tu3BXl0c.js} +1 -1
  26. package/dist/assets/{diagram-G4DWMVQ6-C1TcKWp0.js → diagram-G4DWMVQ6-C6WkK7sj.js} +1 -1
  27. package/dist/assets/{diagram-MMDJMWI5-B5N1Sn5F.js → diagram-MMDJMWI5-DBeD_WW-.js} +1 -1
  28. package/dist/assets/{diagram-TYMM5635-B-payI0e.js → diagram-TYMM5635-BXUyHHJ4.js} +1 -1
  29. package/dist/assets/{erDiagram-SMLLAGMA-zziefklH.js → erDiagram-SMLLAGMA-Bye5tnW2.js} +1 -1
  30. package/dist/assets/{flowDiagram-DWJPFMVM-BOSomu1b.js → flowDiagram-DWJPFMVM-C3pYOs38.js} +1 -1
  31. package/dist/assets/{ganttDiagram-T4ZO3ILL-DILUsv0T.js → ganttDiagram-T4ZO3ILL-DxXkI_FW.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-UUTBAWPF-BKp2DE69.js → gitGraphDiagram-UUTBAWPF-nsxsXsGX.js} +1 -1
  33. package/dist/assets/{graph-NObGxitU.js → graph-Da-Z9hB7.js} +1 -1
  34. package/dist/assets/{index-Bq9Sic2n.js → index-4gmR7Eun.js} +1 -1
  35. package/dist/assets/{index-BI-0Lyyt.js → index-Brh2V8V0.js} +1 -1
  36. package/dist/assets/{index-0aPBVZuP.js → index-C0PhJcXG.js} +1 -1
  37. package/dist/assets/{index-BbXZgnby.js → index-C8w5Sxw3.js} +1 -1
  38. package/dist/assets/{index-VqFARC4A.js → index-CA8JGh5J.js} +1 -1
  39. package/dist/assets/{index-PsZiLKrC.js → index-CsuhosSl.js} +1 -1
  40. package/dist/assets/{index-C88BDuL0.js → index-D7XF7UJ8.js} +1 -1
  41. package/dist/assets/{index-DHUf_c1w.js → index-tHUiY0PG.js} +2 -2
  42. package/dist/assets/{infoDiagram-42DDH7IO-D409o-BL.js → infoDiagram-42DDH7IO-Ca9j90t5.js} +1 -1
  43. package/dist/assets/{ishikawaDiagram-UXIWVN3A-CMVPOGr3.js → ishikawaDiagram-UXIWVN3A-DhjV0XPD.js} +1 -1
  44. package/dist/assets/{journeyDiagram-VCZTEJTY-Bl_5WlaZ.js → journeyDiagram-VCZTEJTY-CRSHLZPV.js} +1 -1
  45. package/dist/assets/{jszip.min-C2654z9i.js → jszip.min-CcCCdMNW.js} +1 -1
  46. package/dist/assets/{kanban-definition-6JOO6SKY-BfCyUP29.js → kanban-definition-6JOO6SKY-Bg0CUwgc.js} +1 -1
  47. package/dist/assets/{layout-CgAMa0xE.js → layout-CWNu13XT.js} +1 -1
  48. package/dist/assets/{linear-J1N1npGr.js → linear-Dcmw1639.js} +1 -1
  49. package/dist/assets/{mermaid.core-YnqOkuoS.js → mermaid.core-1heNIJ5f.js} +2 -2
  50. package/dist/assets/{min-CowkZam8.js → min-CC9CkAxn.js} +1 -1
  51. package/dist/assets/{mindmap-definition-QFDTVHPH-D7yMfot2.js → mindmap-definition-QFDTVHPH-Gr0ex_Ny.js} +1 -1
  52. package/dist/assets/{pieDiagram-DEJITSTG-DKUHBCwB.js → pieDiagram-DEJITSTG-D7P3sUJY.js} +1 -1
  53. package/dist/assets/{quadrantDiagram-34T5L4WZ-DhRqBNfT.js → quadrantDiagram-34T5L4WZ-Bov3lcpV.js} +1 -1
  54. package/dist/assets/{requirementDiagram-MS252O5E-DVE3wKT7.js → requirementDiagram-MS252O5E-BcLptaOU.js} +1 -1
  55. package/dist/assets/{sankeyDiagram-XADWPNL6-Rn_9b5V_.js → sankeyDiagram-XADWPNL6-B2qAUsON.js} +1 -1
  56. package/dist/assets/{seqResourceLoaders-DZvMjXCl.css → seqResourceLoaders-BsgJ9V64.css} +2 -2
  57. package/dist/assets/seqResourceLoaders-LrrYgtsO.js +2 -0
  58. package/dist/assets/{sequenceDiagram-FGHM5R23-CemLRaXC.js → sequenceDiagram-FGHM5R23-Do62Uz-a.js} +1 -1
  59. package/dist/assets/{stateDiagram-FHFEXIEX-DNT1gAty.js → stateDiagram-FHFEXIEX-Wu8aqa8C.js} +1 -1
  60. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-DPlMhu-M.js → stateDiagram-v2-QKLJ7IA2-BfYq7Jgo.js} +1 -1
  61. package/dist/assets/{timeline-definition-GMOUNBTQ-B-F8vgZN.js → timeline-definition-GMOUNBTQ-DYfz5xD6.js} +1 -1
  62. package/dist/assets/{vendor-antd-Dq3DHFa-.js → vendor-antd-BG1SvzuN.js} +2 -2
  63. package/dist/assets/{vendor-codemirror-DjMkT0sn.js → vendor-codemirror-8NDhydlF.js} +1 -1
  64. package/dist/assets/{vendor-mdxeditor-CrZ9SWce.js → vendor-mdxeditor-BB4hhpxM.js} +2 -2
  65. package/dist/assets/{vendor-qrcode-C_77dtHg.js → vendor-qrcode-DMsNGQ10.js} +1 -1
  66. package/dist/assets/{vendor-virtuoso-aMZPf2fi.js → vendor-virtuoso-BUT96ALa.js} +1 -1
  67. package/dist/assets/{vennDiagram-DHZGUBPP-K67JHnnN.js → vennDiagram-DHZGUBPP-CGr-cc7e.js} +1 -1
  68. package/dist/assets/{wardley-RL74JXVD-CCis01LD.js → wardley-RL74JXVD-BN899vMf.js} +1 -1
  69. package/dist/assets/{wardleyDiagram-NUSXRM2D-BjqRIOpN.js → wardleyDiagram-NUSXRM2D-xUsI1E7h.js} +1 -1
  70. package/dist/assets/{xychartDiagram-5P7HB3ND-CaXETYTI.js → xychartDiagram-5P7HB3ND-woQrslzB.js} +1 -1
  71. package/dist/index.html +4 -4
  72. package/dist/voice-packs/default/askQuestion.wav +0 -0
  73. package/dist/voice-packs/default/pack.json +34 -0
  74. package/dist/voice-packs/default/planApproval.wav +0 -0
  75. package/dist/voice-packs/default/timeoutWarning5min.wav +0 -0
  76. package/dist/voice-packs/default/timeoutWarning60s.wav +0 -0
  77. package/dist/voice-packs/default/turnEnd.wav +0 -0
  78. package/lib/approval-modal-prefs.js +71 -0
  79. package/lib/ensure-hooks.js +48 -4
  80. package/lib/sdk-manager.js +12 -1
  81. package/lib/turn-end-bridge.js +117 -0
  82. package/lib/voice-pack-events.js +32 -0
  83. package/lib/voice-pack-manager.js +246 -0
  84. package/package.json +1 -1
  85. package/pty-manager.js +8 -1
  86. package/server.js +304 -2
  87. package/dist/assets/App-CX6bF6ke.js +0 -1
  88. package/dist/assets/Mobile-YwIGAQWc.js +0 -1
  89. package/dist/assets/classDiagram-6PBFFD2Q-CeAAXpgl.js +0 -1
  90. package/dist/assets/classDiagram-v2-HSJHXN6E-CeAAXpgl.js +0 -1
  91. package/dist/assets/clone-BcGHaFBY.js +0 -1
  92. package/dist/assets/seqResourceLoaders-B9D4RGth.js +0 -2
@@ -2,15 +2,22 @@
2
2
  * Register AskUserQuestion and permission approval hooks into ~/.claude/settings.json.
3
3
  * Shared between cli.js and electron/tab-worker.js.
4
4
  */
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync } from 'node:fs';
6
6
  import { resolve, dirname } from 'node:path';
7
7
  import { fileURLToPath } from 'node:url';
8
+ import { randomBytes } from 'node:crypto';
8
9
  import { getClaudeConfigDir } from '../findcc.js';
9
10
 
10
11
  const __filename = fileURLToPath(import.meta.url);
11
12
  const __dirname = dirname(__filename);
12
13
  const rootDir = resolve(__dirname, '..');
13
14
 
15
+ // Marker stamped on hook command strings so a future `cc-viewer cleanup-hooks`
16
+ // CLI (or the user manually) can identify entries owned by cc-viewer and remove
17
+ // stale ones without touching third-party hooks. Round-3 P0 fix for the
18
+ // "npm uninstall leaves zombie paths" footgun — README documents the cleanup recipe.
19
+ const CCV_HOOK_MARKER = '# cc-viewer-managed';
20
+
14
21
  export function ensureHooks() {
15
22
  try {
16
23
  const claudeDir = getClaudeConfigDir();
@@ -23,13 +30,14 @@ export function ensureHooks() {
23
30
 
24
31
  if (!settings.hooks) settings.hooks = {};
25
32
  if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
33
+ if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
26
34
 
27
35
  let changed = false;
28
36
 
29
37
  // AskUserQuestion hook → ask-bridge.js
30
38
  // Guard: only execute when CCVIEWER_PORT is set (i.e. launched by cc-viewer)
31
39
  const askBridgePath = resolve(rootDir, 'lib', 'ask-bridge.js');
32
- const askCmd = `[ -n "$CCVIEWER_PORT" ] && node "${askBridgePath}" || true`;
40
+ const askCmd = `[ -n "$CCVIEWER_PORT" ] && node "${askBridgePath}" || true ${CCV_HOOK_MARKER}`;
33
41
  const askExisting = settings.hooks.PreToolUse.find(h => h.matcher === 'AskUserQuestion');
34
42
  if (askExisting) {
35
43
  if ((askExisting.hooks?.[0]?.command || '') !== askCmd) {
@@ -47,7 +55,7 @@ export function ensureHooks() {
47
55
  // Permission approval hook → perm-bridge.js (matcher: "" = match all tools)
48
56
  // Guard: only execute when CCVIEWER_PORT is set (i.e. launched by cc-viewer)
49
57
  const permBridgePath = resolve(rootDir, 'lib', 'perm-bridge.js');
50
- const permCmd = `[ -n "$CCVIEWER_PORT" ] && node "${permBridgePath}" || true`;
58
+ const permCmd = `[ -n "$CCVIEWER_PORT" ] && node "${permBridgePath}" || true ${CCV_HOOK_MARKER}`;
51
59
  const permMatcher = '';
52
60
  // Clean up legacy entries
53
61
  for (let i = settings.hooks.PreToolUse.length - 1; i >= 0; i--) {
@@ -78,9 +86,45 @@ export function ensureHooks() {
78
86
  changed = true;
79
87
  }
80
88
 
89
+ // Stop hook → turn-end-bridge.js. Fires when Claude finishes responding (real
90
+ // end of a user-prompt turn), so the voice-pack `turnEnd` event can play at the
91
+ // right moment — not after every individual API call like the SSE streaming
92
+ // signal would. Same `CCVIEWER_PORT` guard pattern as the other bridges.
93
+ const turnEndBridgePath = resolve(rootDir, 'lib', 'turn-end-bridge.js');
94
+ const turnEndCmd = `[ -n "$CCVIEWER_PORT" ] && node "${turnEndBridgePath}" || true ${CCV_HOOK_MARKER}`;
95
+ // Stop hooks use matcher: '' (or unset) since there's no tool name to scope by.
96
+ // Find any existing entry that already points at our bridge to update-in-place.
97
+ const turnEndExisting = settings.hooks.Stop.find(h => {
98
+ const cmd = h.hooks?.[0]?.command || '';
99
+ return cmd.includes('turn-end-bridge.js');
100
+ });
101
+ if (turnEndExisting) {
102
+ if ((turnEndExisting.hooks?.[0]?.command || '') !== turnEndCmd) {
103
+ turnEndExisting.hooks = [{ type: 'command', command: turnEndCmd }];
104
+ changed = true;
105
+ }
106
+ } else {
107
+ settings.hooks.Stop.push({
108
+ hooks: [{ type: 'command', command: turnEndCmd }],
109
+ });
110
+ changed = true;
111
+ }
112
+
81
113
  if (changed) {
82
114
  mkdirSync(claudeDir, { recursive: true });
83
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
115
+ // Atomic write(): write to a sibling temp file then rename. Concurrent
116
+ // cc-viewer launches each had a read→mutate→write window where the second writer
117
+ // would clobber the first writer's additions. rename(2) is atomic on POSIX/NTFS,
118
+ // so the worst-case outcome is "last writer's snapshot wins as a whole" — never
119
+ // a partially-applied mutation that loses a hook entry silently.
120
+ const tmpPath = `${settingsPath}.tmp.${process.pid}.${randomBytes(4).toString('hex')}`;
121
+ try {
122
+ writeFileSync(tmpPath, JSON.stringify(settings, null, 2));
123
+ renameSync(tmpPath, settingsPath);
124
+ } catch (err) {
125
+ try { if (existsSync(tmpPath)) unlinkSync(tmpPath); } catch { /* ignore */ }
126
+ throw err;
127
+ }
84
128
  }
85
129
  } catch (err) {
86
130
  console.warn('[CC Viewer] Failed to ensure hooks:', err.message);
@@ -44,6 +44,7 @@ let _streamThrottleTimer = null;
44
44
  // Callbacks registered by server.js
45
45
  let _onEntry = null;
46
46
  let _onStreamingStatus = null;
47
+ let _onTurnEnd = null; // SDK mode has no Stop hook (ensureHooks() skipped) — fire turnEnd directly when the SDK 'result' message arrives
47
48
  let _broadcastWs = null;
48
49
  let _runWaterfallHook = null;
49
50
 
@@ -61,11 +62,12 @@ export function isSdkAvailable() {
61
62
  * Initialize SDK session.
62
63
  * Does NOT start a query — waits for the first user message via sendUserMessage().
63
64
  */
64
- export function initSdkSession(cwd, projectName, { onEntry, onStreamingStatus, broadcastWs, permissionMode, runWaterfallHook }) {
65
+ export function initSdkSession(cwd, projectName, { onEntry, onStreamingStatus, broadcastWs, permissionMode, runWaterfallHook, onTurnEnd }) {
65
66
  _cwd = cwd;
66
67
  _projectName = projectName;
67
68
  _onEntry = onEntry;
68
69
  _onStreamingStatus = onStreamingStatus;
70
+ _onTurnEnd = onTurnEnd;
69
71
  _broadcastWs = broadcastWs;
70
72
  _permissionMode = permissionMode || 'default';
71
73
  _runWaterfallHook = runWaterfallHook || null;
@@ -217,6 +219,15 @@ function _processMessage(msg) {
217
219
  case 'result':
218
220
  if (msg.session_id) _sessionId = msg.session_id;
219
221
  if (_onStreamingStatus) _onStreamingStatus(buildStreamingStatus(false));
222
+ // SDK turn-end signal(). Equivalent to Claude Code's Stop hook
223
+ // in CLI mode — fires once per user-prompt response when the whole chain
224
+ // (assistant text + all tool calls + final reply) completes. SDK mode
225
+ // doesn't go through ensureHooks() so this in-process callback is the
226
+ // only way the renderer learns the turn is over.
227
+ if (_onTurnEnd) {
228
+ try { _onTurnEnd({ sessionId: _sessionId, ts: Date.now() }); }
229
+ catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
230
+ }
220
231
  break;
221
232
 
222
233
  default:
@@ -0,0 +1,117 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * turn-end-bridge.js — Stop hook bridge for "Claude turn ended" signal.
4
+ *
5
+ * Called by Claude Code when the model finishes responding. Fires a one-shot
6
+ * POST to cc-viewer's /api/turn-end-notify so the running cc-viewer server can
7
+ * broadcast a `turn_end` SSE event to every connected client. The frontend uses
8
+ * that signal to play the voice-pack `turnEnd` audio.
9
+ *
10
+ * Why this instead of isStreaming falling-edge on the SSE stream:
11
+ * `streamingState.active` resets after **each individual Claude API call**, not
12
+ * after the whole user-prompt response completes. Between tool calls
13
+ * isStreaming flips false then true; with slow tools (Bash > 2s, network, etc.)
14
+ * the 2s spinner debounce isn't long enough and we'd fire turnEnd mid-prompt.
15
+ * The Stop hook fires exactly once per real turn end, so it's the right signal.
16
+ *
17
+ * Hook config in ~/.claude/settings.json (injected by ensure-hooks.js, tagged
18
+ * `# cc-viewer-managed`):
19
+ * "hooks": {
20
+ * "Stop": [{ "hooks": [{ "type": "command",
21
+ * "command": "[ -n \"$CCVIEWER_PORT\" ] && node /path/to/turn-end-bridge.js || true # cc-viewer-managed" }] }]
22
+ * }
23
+ *
24
+ * Output contract: nothing on stdout (so we don't pollute other Stop hooks' decision
25
+ * channel), optional stderr only when `CCVIEWER_DEBUG=1`. Always exit 0 so a failed
26
+ * notify never blocks Claude Code's hook chain.
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs';
30
+ import http from 'node:http';
31
+ import https from 'node:https';
32
+
33
+ const debug = (msg) => {
34
+ if (process.env.CCVIEWER_DEBUG === '1') {
35
+ try { process.stderr.write(`[turn-end-bridge] ${msg}\n`); } catch { /* ignore */ }
36
+ }
37
+ };
38
+
39
+ const port = process.env.CCVIEWER_PORT;
40
+ const rawProtocol = process.env.CCVIEWER_PROTOCOL;
41
+ const isHttps = rawProtocol === 'https';
42
+ const httpClient = isHttps ? https : http;
43
+
44
+ // cc-viewer not running — exit silently. We must NOT write `{ continue: true, ... }`
45
+ // to stdout: Claude Code interprets Stop hook stdout as decision-control JSON, and
46
+ // our payload would risk overriding another user-installed Stop hook's `decision`.
47
+ // Just exit 0 (round-3 defensive P1 — stdout pollution).
48
+ if (!port) {
49
+ debug('CCVIEWER_PORT unset — exit silently');
50
+ process.exit(0);
51
+ }
52
+
53
+ // Drain stdin best-effort. Claude Code passes a JSON payload with session_id /
54
+ // transcript_path; only session_id is forwarded. Capped to 64 KB to defang any
55
+ // malformed huge payload().
56
+ let stdinData = '';
57
+ try {
58
+ const buf = readFileSync(0);
59
+ stdinData = (buf.length > 64 * 1024 ? buf.slice(0, 64 * 1024) : buf).toString('utf-8');
60
+ } catch { /* stdin may not be piped — fine, still notify */ }
61
+ let sessionId = null;
62
+ try { sessionId = JSON.parse(stdinData)?.session_id || null; } catch { /* fine */ }
63
+
64
+ const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
65
+ const body = JSON.stringify({ sessionId, ts: Date.now() });
66
+ const reqOpts = {
67
+ hostname: '127.0.0.1',
68
+ port: parseInt(port, 10),
69
+ path: '/api/turn-end-notify',
70
+ method: 'POST',
71
+ headers: {
72
+ 'Content-Type': 'application/json',
73
+ 'Content-Length': Buffer.byteLength(body),
74
+ // X-CCViewer-Internal: anti-CSRF / anti-spoof header — matched against the
75
+ // server's INTERNAL_TOKEN (random per-startup, only env-leaked to claude
76
+ // child via pty-manager). A loopback-resident attacker that doesn't know
77
+ // the token still can't fake turn_end events into cc-viewer SSE.
78
+ ...(internalToken ? { 'X-CCViewer-Internal': internalToken } : {}),
79
+ },
80
+ // Round-3 P2: keep timeout snappy so a stale cc-viewer doesn't block the
81
+ // Claude Code hook chain for a noticeable beat before the user can type again.
82
+ timeout: 500,
83
+ };
84
+ if (isHttps) {
85
+ // Loopback HTTPS is typically self-signed; certificate validation would reject.
86
+ // Round-3 P1 cross-bridge regression — same fix should land in ask/perm bridges.
87
+ reqOpts.rejectUnauthorized = false;
88
+ }
89
+
90
+ let exited = false;
91
+ const finish = (reason) => {
92
+ if (exited) return;
93
+ exited = true;
94
+ if (reason) debug(reason);
95
+ process.exit(0);
96
+ };
97
+
98
+ let req;
99
+ try {
100
+ req = httpClient.request(reqOpts, (res) => {
101
+ res.resume();
102
+ res.on('end', () => finish(`POST done (status=${res.statusCode})`));
103
+ });
104
+ req.on('error', (err) => finish(`POST error: ${err?.message}`));
105
+ req.on('timeout', () => { try { req.destroy(); } catch { /* ignore */ } finish('POST timeout'); });
106
+ // Wrap the synchronous write/end so an immediate EPIPE doesn't bubble to
107
+ // Claude Code's transcript as `Error: write EPIPE` (round-3 defensive P1).
108
+ try {
109
+ req.write(body);
110
+ req.end();
111
+ } catch (err) {
112
+ finish(`req.write threw: ${err?.message}`);
113
+ }
114
+ } catch (err) {
115
+ // httpClient.request itself failed (invalid options, etc.) — give up cleanly.
116
+ finish(`request() threw: ${err?.message}`);
117
+ }
@@ -0,0 +1,32 @@
1
+ // Single source of truth for voice-pack event keys + their default bindings.
2
+ //
3
+ // Why a shared module: this list was previously duplicated across
4
+ // - lib/voice-pack-manager.js (EVENT_KEYS for whitelist + reconcile)
5
+ // - server.js (preferences merge / reconcile)
6
+ // - src/AppBase.jsx (initial state default)
7
+ // - src/components/VoicePackSettings.jsx (UI rows + reset handler)
8
+ // - scripts/gen-placeholder-voicepack.js (pattern table keys)
9
+ // - src/components/AskTimeoutCountdown.jsx (threshold list keys)
10
+ // Adding a 6th event meant editing 5+ files and any miss silently dropped audio
11
+ //(). All consumers now import from here.
12
+
13
+ export const EVENT_KEYS = [
14
+ 'planApproval',
15
+ 'askQuestion',
16
+ 'timeoutWarning5min',
17
+ 'timeoutWarning60s',
18
+ 'turnEnd',
19
+ ];
20
+
21
+ // Per-event default binding when no user override is set:
22
+ // - 'default' → play the bundled default-pack audio
23
+ // - null → event is OFF by default (user must opt in)
24
+ // turnEnd defaults to null because firing on every Claude reply is noisy
25
+ //( — frequency overload mitigation).
26
+ export const DEFAULT_BINDINGS = Object.freeze({
27
+ planApproval: 'default',
28
+ askQuestion: 'default',
29
+ timeoutWarning5min: 'default',
30
+ timeoutWarning60s: 'default',
31
+ turnEnd: null,
32
+ });
@@ -0,0 +1,246 @@
1
+ // Voice-pack manager — file-backed audio store for ApprovalModal sound hooks.
2
+ //
3
+ // Layout:
4
+ // <LOG_DIR>/voice-packs/<id>.<ext> ← user-uploaded audio
5
+ // <repo>/public/voice-packs/default/ ← bundled default pack (Pixel Buddy chiptune)
6
+ //
7
+ // Why a UUID-keyed flat dir (no nested user-supplied paths): the audio id ends up
8
+ // in URL path (/api/voice-pack/audio/:id), so we whitelist [a-f0-9-]{8,64} and
9
+ // reject anything else — defeats `../` traversal at the routing layer.
10
+
11
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync, readdirSync, statSync, readFileSync, lstatSync } from 'node:fs';
12
+ import { join, extname } from 'node:path';
13
+ import { randomUUID } from 'node:crypto';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { dirname } from 'node:path';
16
+ import { EVENT_KEYS, DEFAULT_BINDINGS } from './voice-pack-events.js';
17
+
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = dirname(__filename);
20
+
21
+ // Default pack lookup order:
22
+ // 1. <repo>/dist/voice-packs/default/ — production (Vite copies public/* into dist/, npm ships dist/ only)
23
+ // 2. <repo>/public/voice-packs/default/ — dev (source tree, before `npm run build`)
24
+ // Content-neutral folder name — the bundled audio's theme can change (Pixel Buddy
25
+ // today, "皇上" recordings tomorrow, user override later) without renaming dirs.
26
+ const DEFAULT_PACK_DIRS = [
27
+ join(__dirname, '..', 'dist', 'voice-packs', 'default'),
28
+ join(__dirname, '..', 'public', 'voice-packs', 'default'),
29
+ ];
30
+
31
+ // Surface a packaging-regression warning at module load if neither default-pack
32
+ // dir exists — keeps a future relocation of this file or a broken
33
+ // `npm pack` from silently shipping a feature without its bundled audio.
34
+ if (!DEFAULT_PACK_DIRS.some(d => existsSync(d))) {
35
+ console.warn('[voice-pack] no default-pack directory found at', DEFAULT_PACK_DIRS.join(' | '), '— bundled audio will 404, frontend falls back to chime');
36
+ }
37
+
38
+ export { EVENT_KEYS } from './voice-pack-events.js';
39
+ export const ID_PATTERN = /^[a-f0-9-]{8,64}$/;
40
+ export const ALLOWED_EXTS = new Set(['.mp3', '.wav', '.ogg', '.m4a']);
41
+ export const MAX_AUDIO_BYTES = 2 * 1024 * 1024; // 2MB per file
42
+
43
+ // Magic-bytes check — Content-Type from the upload is untrusted; verify the file
44
+ // actually starts with a known audio signature. Catches rename-to-bypass attacks.
45
+ export function detectAudioFormat(buf) {
46
+ if (!buf || buf.length < 12) return null;
47
+ // ID3v2-prefixed MP3
48
+ if (buf[0] === 0x49 && buf[1] === 0x44 && buf[2] === 0x33) return 'mp3';
49
+ // MPEG frame sync (MP3 without ID3): 0xFF Ex/Fx
50
+ if (buf[0] === 0xFF && (buf[1] & 0xE0) === 0xE0) return 'mp3';
51
+ // RIFF....WAVE
52
+ if (buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46
53
+ && buf[8] === 0x57 && buf[9] === 0x41 && buf[10] === 0x56 && buf[11] === 0x45) return 'wav';
54
+ // OggS
55
+ if (buf[0] === 0x4F && buf[1] === 0x67 && buf[2] === 0x67 && buf[3] === 0x53) return 'ogg';
56
+ // M4A / MP4 container: 'ftyp' at offset 4
57
+ if (buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70) return 'm4a';
58
+ return null;
59
+ }
60
+
61
+ export function mimeForFormat(fmt) {
62
+ switch (fmt) {
63
+ case 'mp3': return 'audio/mpeg';
64
+ case 'wav': return 'audio/wav';
65
+ case 'ogg': return 'audio/ogg';
66
+ case 'm4a': return 'audio/mp4';
67
+ default: return 'application/octet-stream';
68
+ }
69
+ }
70
+
71
+ export function isValidId(id) {
72
+ return typeof id === 'string' && ID_PATTERN.test(id);
73
+ }
74
+
75
+ function ensureUploadDir(logDir) {
76
+ const dir = join(logDir, 'voice-packs');
77
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
78
+ return dir;
79
+ }
80
+
81
+ // Save uploaded audio. `loopbackOnly` short-circuits — we refuse non-loopback uploads
82
+ // at the route layer for now (LAN clients can play but not upload), passed through here
83
+ // just for completeness if tests stub a non-loopback path.
84
+ export function saveAudio(logDir, filename, buf, { loopbackOnly = true, isLoopback = true } = {}) {
85
+ if (loopbackOnly && !isLoopback) {
86
+ const err = new Error('Upload allowed from loopback only');
87
+ err.code = 'NOT_LOOPBACK';
88
+ throw err;
89
+ }
90
+ if (!Buffer.isBuffer(buf)) {
91
+ throw new Error('saveAudio: buf must be a Buffer');
92
+ }
93
+ if (buf.length === 0) {
94
+ throw new Error('Empty file');
95
+ }
96
+ if (buf.length > MAX_AUDIO_BYTES) {
97
+ const err = new Error(`File too large (max ${MAX_AUDIO_BYTES} bytes)`);
98
+ err.code = 'TOO_LARGE';
99
+ throw err;
100
+ }
101
+ const fmt = detectAudioFormat(buf);
102
+ if (!fmt) {
103
+ const err = new Error('Not a recognised audio file (mp3/wav/ogg/m4a)');
104
+ err.code = 'BAD_FORMAT';
105
+ throw err;
106
+ }
107
+ const dir = ensureUploadDir(logDir);
108
+ const id = randomUUID();
109
+ const ext = `.${fmt}`;
110
+ const path = join(dir, `${id}${ext}`);
111
+ writeFileSync(path, buf);
112
+ // Persist a sidecar with the original filename (display only, not used for FS access).
113
+ const sidecar = join(dir, `${id}.json`);
114
+ try {
115
+ const safeName = String(filename || '').replace(/[\x00-\x1f/\\]/g, '_').slice(0, 200);
116
+ writeFileSync(sidecar, JSON.stringify({ id, originalName: safeName, ext, uploadedAt: Date.now() }, null, 2));
117
+ } catch { /* sidecar is best-effort */ }
118
+ return { id, ext, path, size: buf.length, format: fmt };
119
+ }
120
+
121
+ export function listUserAudio(logDir) {
122
+ const dir = join(logDir, 'voice-packs');
123
+ if (!existsSync(dir)) return [];
124
+ const out = [];
125
+ let entries;
126
+ try { entries = readdirSync(dir); } catch { return []; }
127
+ for (const name of entries) {
128
+ const ext = extname(name).toLowerCase();
129
+ if (!ALLOWED_EXTS.has(ext)) continue;
130
+ const id = name.slice(0, name.length - ext.length);
131
+ if (!ID_PATTERN.test(id)) continue;
132
+ const full = join(dir, name);
133
+ let st;
134
+ try { st = statSync(full); } catch { continue; }
135
+ let originalName = `${id}${ext}`;
136
+ try {
137
+ const sidecar = JSON.parse(readFileSync(join(dir, `${id}.json`), 'utf-8'));
138
+ if (sidecar?.originalName) originalName = sidecar.originalName;
139
+ } catch { /* sidecar optional */ }
140
+ out.push({ id, ext, size: st.size, mtime: st.mtimeMs, originalName });
141
+ }
142
+ return out.sort((a, b) => b.mtime - a.mtime);
143
+ }
144
+
145
+ export function getUserAudioPath(logDir, id) {
146
+ if (!isValidId(id)) return null;
147
+ const dir = join(logDir, 'voice-packs');
148
+ if (!existsSync(dir)) return null;
149
+ for (const ext of ALLOWED_EXTS) {
150
+ const p = join(dir, `${id}${ext}`);
151
+ if (!existsSync(p)) continue;
152
+ // Symlink hardening: a local attacker who can write into
153
+ // <LOG_DIR>/voice-packs/ could otherwise drop `<uuid>.mp3 → /etc/passwd` and
154
+ // have it streamed back over LAN. Skip the entry rather than expose it.
155
+ try { if (lstatSync(p).isSymbolicLink()) continue; } catch { continue; }
156
+ return { path: p, format: ext.slice(1) };
157
+ }
158
+ return null;
159
+ }
160
+
161
+ export function deleteUserAudio(logDir, id) {
162
+ if (!isValidId(id)) return false;
163
+ const dir = join(logDir, 'voice-packs');
164
+ if (!existsSync(dir)) return false;
165
+ let removed = false;
166
+ for (const ext of ALLOWED_EXTS) {
167
+ const p = join(dir, `${id}${ext}`);
168
+ if (existsSync(p)) { try { unlinkSync(p); removed = true; } catch { /* ignore */ } }
169
+ }
170
+ const sidecar = join(dir, `${id}.json`);
171
+ if (existsSync(sidecar)) { try { unlinkSync(sidecar); } catch { /* ignore */ } }
172
+ return removed;
173
+ }
174
+
175
+ // Default-pack lookup — eventKey → bundled file under DEFAULT_PACK_DIRS.
176
+ // Returns null when the file is absent (e.g. placeholder generation never ran),
177
+ // letting the API surface a clear 404 and the front-end fall back to its Web Audio chime.
178
+ export function getDefaultPackPath(eventKey) {
179
+ if (!EVENT_KEYS.includes(eventKey)) return null;
180
+ for (const dir of DEFAULT_PACK_DIRS) {
181
+ for (const ext of ALLOWED_EXTS) {
182
+ const p = join(dir, `${eventKey}${ext}`);
183
+ if (!existsSync(p)) continue;
184
+ // Symlink hardening — same threat model as getUserAudioPath above. The
185
+ // default-pack directories live inside the package install, so a tampered
186
+ // install could ship symlinks; skip rather than dereference.
187
+ try { if (lstatSync(p).isSymbolicLink()) continue; } catch { continue; }
188
+ return { path: p, format: ext.slice(1) };
189
+ }
190
+ }
191
+ return null;
192
+ }
193
+
194
+ export function listDefaultPack() {
195
+ const haveAnyDir = DEFAULT_PACK_DIRS.some(d => existsSync(d));
196
+ if (!haveAnyDir) return [];
197
+ const out = [];
198
+ for (const eventKey of EVENT_KEYS) {
199
+ const hit = getDefaultPackPath(eventKey);
200
+ if (hit) {
201
+ let size = 0;
202
+ try { size = statSync(hit.path).size; } catch { /* ignore */ }
203
+ out.push({ eventKey, format: hit.format, size });
204
+ }
205
+ }
206
+ return out;
207
+ }
208
+
209
+ // Surfaces the pack.json `placeholder` flag so the Settings UI can label the
210
+ // "Default" option as a placeholder (— discoverability of the
211
+ // placeholder→real-recording replacement path). Returns false when no manifest
212
+ // is present (treats absence as "not flagged" rather than guessing).
213
+ export function isDefaultPackPlaceholder() {
214
+ for (const dir of DEFAULT_PACK_DIRS) {
215
+ const p = join(dir, 'pack.json');
216
+ if (!existsSync(p)) continue;
217
+ try {
218
+ const meta = JSON.parse(readFileSync(p, 'utf-8'));
219
+ return !!meta?.placeholder;
220
+ } catch { /* keep looking */ }
221
+ }
222
+ return false;
223
+ }
224
+
225
+ // Reconcile a voice-pack settings blob against on-disk state. Any event whose
226
+ // bound id no longer exists is silently reset to null — surfaces in the next
227
+ // GET /api/preferences without the client doing anything. Returns the reconciled
228
+ // object (caller decides whether to persist it).
229
+ export function reconcileVoicePackPrefs(logDir, vp) {
230
+ if (!vp || typeof vp !== 'object') return vp;
231
+ const events = vp.events && typeof vp.events === 'object' ? { ...vp.events } : {};
232
+ for (const key of EVENT_KEYS) {
233
+ const val = events[key];
234
+ if (val == null || val === 'default') continue;
235
+ if (typeof val !== 'string' || !isValidId(val)) { events[key] = null; continue; }
236
+ if (!getUserAudioPath(logDir, val)) events[key] = null;
237
+ }
238
+ return { ...vp, events };
239
+ }
240
+
241
+ // Re-export shared defaults so consumers can pull everything from one module.
242
+ export { DEFAULT_BINDINGS };
243
+
244
+ // mergeApprovalModalPrefs / mergeVoicePackInto moved to lib/approval-modal-prefs.js
245
+ //( — merge logic isn't voice-pack-specific). Import from
246
+ // './approval-modal-prefs.js' directly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.263",
3
+ "version": "1.6.264",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/pty-manager.js CHANGED
@@ -125,7 +125,7 @@ export function _markThinkingDisplayRejected(claudePath) {
125
125
  _thinkingDisplayRejectedPaths.add(claudePath);
126
126
  }
127
127
 
128
- export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http') {
128
+ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = null, isNpmVersion = false, serverPort = null, serverProtocol = 'http', internalToken = null) {
129
129
  if (ptyProcess) {
130
130
  killPty();
131
131
  }
@@ -172,6 +172,12 @@ export async function spawnClaude(proxyPort, cwd, extraArgs = [], claudePath = n
172
172
  env.CCV_EDITOR_PORT = String(serverPort);
173
173
  env.CCVIEWER_PORT = String(serverPort); // For ask-hook bridge
174
174
  env.CCVIEWER_PROTOCOL = serverProtocol; // For ask/perm-bridge (http vs https)
175
+ if (internalToken) {
176
+ // Anti-CSRF token for bridge → server calls (round-3 P1). Same shared
177
+ // secret across ask / perm / turn-end bridges so server can route-check
178
+ // header `X-CCViewer-Internal`. Loopback-only by design.
179
+ env.CCVIEWER_INTERNAL_TOKEN = internalToken;
180
+ }
175
181
  }
176
182
 
177
183
  // 禁用 Claude Code CLI 的鼠标事件捕获,保住 xterm 面板原生文本选中(复制粘贴)。
@@ -355,6 +361,7 @@ export async function spawnShell() {
355
361
  delete shellEnv.CCVIEWER_PORT;
356
362
  delete shellEnv.CCV_EDITOR_PORT;
357
363
  delete shellEnv.CCVIEWER_PROTOCOL;
364
+ delete shellEnv.CCVIEWER_INTERNAL_TOKEN;
358
365
  // 交互 shell 里手动敲 claude 时也禁鼠标,理由同 spawnClaude。
359
366
  shellEnv.CLAUDE_CODE_DISABLE_MOUSE ??= '1';
360
367
  const shellSpawn = prepareEmbeddedShellSpawn(shell, shellEnv);