cc-viewer 1.6.263 → 1.6.265
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/cli.js +4 -0
- package/dist/assets/App-Dn8sM_4p.js +1 -0
- package/dist/assets/{MdxEditorPanel--reKHew0.js → MdxEditorPanel-CVaK7mOf.js} +1 -1
- package/dist/assets/Mobile-HEclS2va.js +1 -0
- package/dist/assets/{_baseUniq-DiLy7vi3.js → _baseUniq-Dgkw4IXM.js} +1 -1
- package/dist/assets/{arc-CAB2oIHx.js → arc-AiHQLijx.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-Cijl_JpW.js → architectureDiagram-Q4EWVU46-CPRvAIHK.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-Bk4yWCPQ.js → blockDiagram-DXYQGD6D-CK2cwrfX.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-Vz4JKuzi.js → c4Diagram-AHTNJAMY-BP-UBbgv.js} +1 -1
- package/dist/assets/{channel-BnYKz_zI.js → channel-Ny3Nm_-t.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-DM3ZjqKX.js → chunk-4BX2VUAB-DdsULqPZ.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-BTiJOoNa.js → chunk-4TB4RGXK-BDSjQHh0.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-B4fMQcTE.js → chunk-55IACEB6-DrKr3wBa.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-B_WylnyS.js → chunk-EDXVE4YY-o_0SUbAB.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-Cx2lqZi9.js → chunk-FMBD7UC4-Ca_AgqWi.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-CPZm7o6V.js → chunk-OYMX7WX6-CyWWbq5o.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-DuYVzv7E.js → chunk-QZHKN3VN-5rXHErSL.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-Bk3OysLK.js → chunk-YZCP3GAM-DznXBadU.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CLYcbnwx.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-CLYcbnwx.js +1 -0
- package/dist/assets/clone-5GFhU8Pv.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-CQuaqKHt.js → cose-bilkent-S5V4N54A-BhGyix0v.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BFdoRcuo.js → dagre-KV5264BT-CzzHxIvc.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-ByFdSFIu.js → diagram-5BDNPKRD-tu3BXl0c.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-C1TcKWp0.js → diagram-G4DWMVQ6-C6WkK7sj.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-B5N1Sn5F.js → diagram-MMDJMWI5-DBeD_WW-.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-B-payI0e.js → diagram-TYMM5635-BXUyHHJ4.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-zziefklH.js → erDiagram-SMLLAGMA-Bye5tnW2.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BOSomu1b.js → flowDiagram-DWJPFMVM-C3pYOs38.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-DILUsv0T.js → ganttDiagram-T4ZO3ILL-DxXkI_FW.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-BKp2DE69.js → gitGraphDiagram-UUTBAWPF-nsxsXsGX.js} +1 -1
- package/dist/assets/{graph-NObGxitU.js → graph-Da-Z9hB7.js} +1 -1
- package/dist/assets/{index-Bq9Sic2n.js → index-4gmR7Eun.js} +1 -1
- package/dist/assets/{index-DHUf_c1w.js → index-BOxeMjZZ.js} +2 -2
- package/dist/assets/{index-BI-0Lyyt.js → index-Brh2V8V0.js} +1 -1
- package/dist/assets/{index-0aPBVZuP.js → index-C0PhJcXG.js} +1 -1
- package/dist/assets/{index-BbXZgnby.js → index-C8w5Sxw3.js} +1 -1
- package/dist/assets/{index-VqFARC4A.js → index-CA8JGh5J.js} +1 -1
- package/dist/assets/{index-PsZiLKrC.js → index-CsuhosSl.js} +1 -1
- package/dist/assets/{index-C88BDuL0.js → index-D7XF7UJ8.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-D409o-BL.js → infoDiagram-42DDH7IO-Ca9j90t5.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-CMVPOGr3.js → ishikawaDiagram-UXIWVN3A-DhjV0XPD.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-Bl_5WlaZ.js → journeyDiagram-VCZTEJTY-CRSHLZPV.js} +1 -1
- package/dist/assets/{jszip.min-C2654z9i.js → jszip.min-CcCCdMNW.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-BfCyUP29.js → kanban-definition-6JOO6SKY-Bg0CUwgc.js} +1 -1
- package/dist/assets/{layout-CgAMa0xE.js → layout-CWNu13XT.js} +1 -1
- package/dist/assets/{linear-J1N1npGr.js → linear-Dcmw1639.js} +1 -1
- package/dist/assets/{mermaid.core-YnqOkuoS.js → mermaid.core-1heNIJ5f.js} +2 -2
- package/dist/assets/{min-CowkZam8.js → min-CC9CkAxn.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-D7yMfot2.js → mindmap-definition-QFDTVHPH-Gr0ex_Ny.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DKUHBCwB.js → pieDiagram-DEJITSTG-D7P3sUJY.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-DhRqBNfT.js → quadrantDiagram-34T5L4WZ-Bov3lcpV.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-DVE3wKT7.js → requirementDiagram-MS252O5E-BcLptaOU.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-Rn_9b5V_.js → sankeyDiagram-XADWPNL6-B2qAUsON.js} +1 -1
- package/dist/assets/seqResourceLoaders-BGxPc8Yp.js +2 -0
- package/dist/assets/{seqResourceLoaders-DZvMjXCl.css → seqResourceLoaders-DmvKKyh9.css} +3 -3
- package/dist/assets/{sequenceDiagram-FGHM5R23-CemLRaXC.js → sequenceDiagram-FGHM5R23-Do62Uz-a.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DNT1gAty.js → stateDiagram-FHFEXIEX-Wu8aqa8C.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-DPlMhu-M.js → stateDiagram-v2-QKLJ7IA2-BfYq7Jgo.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-B-F8vgZN.js → timeline-definition-GMOUNBTQ-DYfz5xD6.js} +1 -1
- package/dist/assets/{vendor-antd-Dq3DHFa-.js → vendor-antd-BG1SvzuN.js} +2 -2
- package/dist/assets/{vendor-codemirror-DjMkT0sn.js → vendor-codemirror-8NDhydlF.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-CrZ9SWce.js → vendor-mdxeditor-BB4hhpxM.js} +2 -2
- package/dist/assets/{vendor-qrcode-C_77dtHg.js → vendor-qrcode-DMsNGQ10.js} +1 -1
- package/dist/assets/{vendor-virtuoso-aMZPf2fi.js → vendor-virtuoso-BUT96ALa.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-K67JHnnN.js → vennDiagram-DHZGUBPP-CGr-cc7e.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-CCis01LD.js → wardley-RL74JXVD-BN899vMf.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-BjqRIOpN.js → wardleyDiagram-NUSXRM2D-xUsI1E7h.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CaXETYTI.js → xychartDiagram-5P7HB3ND-woQrslzB.js} +1 -1
- package/dist/index.html +4 -4
- package/dist/voice-packs/default/askQuestion.wav +0 -0
- package/dist/voice-packs/default/pack.json +34 -0
- package/dist/voice-packs/default/planApproval.wav +0 -0
- package/dist/voice-packs/default/timeoutWarning5min.wav +0 -0
- package/dist/voice-packs/default/timeoutWarning60s.wav +0 -0
- package/dist/voice-packs/default/turnEnd.wav +0 -0
- package/interceptor.js +18 -5
- package/lib/approval-modal-prefs.js +71 -0
- package/lib/ensure-hooks.js +48 -4
- package/lib/git-diff.js +4 -0
- package/lib/sdk-manager.js +12 -1
- package/lib/turn-end-bridge.js +117 -0
- package/lib/voice-pack-events.js +32 -0
- package/lib/voice-pack-manager.js +246 -0
- package/package.json +1 -1
- package/pty-manager.js +8 -1
- package/server.js +304 -2
- package/dist/assets/App-CX6bF6ke.js +0 -1
- package/dist/assets/Mobile-YwIGAQWc.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-CeAAXpgl.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-CeAAXpgl.js +0 -1
- package/dist/assets/clone-BcGHaFBY.js +0 -1
- package/dist/assets/seqResourceLoaders-B9D4RGth.js +0 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Approval-modal preferences merge logic.
|
|
2
|
+
//
|
|
3
|
+
// Lives here (not in voice-pack-manager) because it merges *generic* approvalModal
|
|
4
|
+
// fields (modalEnabled, soundEnabled, notifyOnlyWhenHidden) plus the voicePack subtree.
|
|
5
|
+
// voice-pack-manager.js stays focused on the file/audio backing store.
|
|
6
|
+
//
|
|
7
|
+
// Both server.js (handles POST /api/preferences) and src/AppBase.jsx (hydrate +
|
|
8
|
+
// handleVoicePackChange) use this so the merge contract is single-sourced.
|
|
9
|
+
|
|
10
|
+
import { EVENT_KEYS } from './voice-pack-events.js';
|
|
11
|
+
|
|
12
|
+
// Whitelist guard: only keys from EVENT_KEYS land in the merged events map.
|
|
13
|
+
// Defense-in-depth against a malicious/buggy client posting `{ events: { constructor: ... } }`
|
|
14
|
+
// or other unexpected keys that would persist to preferences.json and surface in
|
|
15
|
+
// dropdown rendering().
|
|
16
|
+
function _filterEvents(events) {
|
|
17
|
+
if (!events || typeof events !== 'object') return {};
|
|
18
|
+
const out = {};
|
|
19
|
+
for (const k of EVENT_KEYS) {
|
|
20
|
+
if (k in events) out[k] = events[k];
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Deep-merge an incoming voicePack patch into the base voicePack subtree.
|
|
27
|
+
* Pure function — no I/O, no reconcile. Caller decides whether to run a
|
|
28
|
+
* reconcile pass over the result.
|
|
29
|
+
*
|
|
30
|
+
* Contract:
|
|
31
|
+
* - top-level fields (enabled, volume, …) shallow-merged
|
|
32
|
+
* - events map merged key-by-key, filtered through EVENT_KEYS whitelist
|
|
33
|
+
* - returns a new object; inputs untouched
|
|
34
|
+
*/
|
|
35
|
+
export function mergeVoicePackInto(baseVP, incVP) {
|
|
36
|
+
const base = (baseVP && typeof baseVP === 'object') ? baseVP : {};
|
|
37
|
+
if (!incVP || typeof incVP !== 'object') {
|
|
38
|
+
// Still pass base.events through the whitelist so persisted-but-stale keys
|
|
39
|
+
// get cleaned up on the next save.
|
|
40
|
+
return { ...base, events: _filterEvents(base.events) };
|
|
41
|
+
}
|
|
42
|
+
const { events: incEvents, ...incRest } = incVP;
|
|
43
|
+
const mergedEvents = {
|
|
44
|
+
..._filterEvents(base.events),
|
|
45
|
+
..._filterEvents(incEvents),
|
|
46
|
+
};
|
|
47
|
+
return { ...base, ...incRest, events: mergedEvents };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Deep-merge an incoming approvalModal patch into the base approvalModal subtree.
|
|
52
|
+
* Top-level approvalModal fields shallow-merge; the voicePack subtree goes
|
|
53
|
+
* through mergeVoicePackInto.
|
|
54
|
+
*
|
|
55
|
+
* `reconcile` is an optional callback `(voicePack) => voicePack` — typically
|
|
56
|
+
* voice-pack-manager.reconcileVoicePackPrefs(logDir, …) — applied to the
|
|
57
|
+
* merged voicePack to strip references to audio ids that no longer exist on disk.
|
|
58
|
+
*/
|
|
59
|
+
export function mergeApprovalModalPrefs(baseAM, incAM, { reconcile = null } = {}) {
|
|
60
|
+
if (!incAM || typeof incAM !== 'object') return baseAM;
|
|
61
|
+
const base = (baseAM && typeof baseAM === 'object') ? baseAM : {};
|
|
62
|
+
const { voicePack: incVP, ...incAMRest } = incAM;
|
|
63
|
+
const merged = { ...base, ...incAMRest };
|
|
64
|
+
if (incVP && typeof incVP === 'object') {
|
|
65
|
+
merged.voicePack = mergeVoicePackInto(base.voicePack, incVP);
|
|
66
|
+
if (typeof reconcile === 'function') {
|
|
67
|
+
merged.voicePack = reconcile(merged.voicePack);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return merged;
|
|
71
|
+
}
|
package/lib/ensure-hooks.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/lib/git-diff.js
CHANGED
|
@@ -299,6 +299,10 @@ export async function getGitDiffs(cwd, files, commitHash) {
|
|
|
299
299
|
}
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
|
+
|
|
303
|
+
// 统一换行符,避免 Windows CRLF 与 Git LF 差异导致整文件被标记为变更
|
|
304
|
+
old_content = old_content.replace(/\r\n/g, '\n');
|
|
305
|
+
new_content = new_content.replace(/\r\n/g, '\n');
|
|
302
306
|
}
|
|
303
307
|
|
|
304
308
|
diffs.push({
|
package/lib/sdk-manager.js
CHANGED
|
@@ -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
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);
|