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.
Files changed (94) hide show
  1. package/README.md +13 -0
  2. package/cli.js +4 -0
  3. package/dist/assets/App-Dn8sM_4p.js +1 -0
  4. package/dist/assets/{MdxEditorPanel--reKHew0.js → MdxEditorPanel-CVaK7mOf.js} +1 -1
  5. package/dist/assets/Mobile-HEclS2va.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-DHUf_c1w.js → index-BOxeMjZZ.js} +2 -2
  36. package/dist/assets/{index-BI-0Lyyt.js → index-Brh2V8V0.js} +1 -1
  37. package/dist/assets/{index-0aPBVZuP.js → index-C0PhJcXG.js} +1 -1
  38. package/dist/assets/{index-BbXZgnby.js → index-C8w5Sxw3.js} +1 -1
  39. package/dist/assets/{index-VqFARC4A.js → index-CA8JGh5J.js} +1 -1
  40. package/dist/assets/{index-PsZiLKrC.js → index-CsuhosSl.js} +1 -1
  41. package/dist/assets/{index-C88BDuL0.js → index-D7XF7UJ8.js} +1 -1
  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-BGxPc8Yp.js +2 -0
  57. package/dist/assets/{seqResourceLoaders-DZvMjXCl.css → seqResourceLoaders-DmvKKyh9.css} +3 -3
  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/interceptor.js +18 -5
  79. package/lib/approval-modal-prefs.js +71 -0
  80. package/lib/ensure-hooks.js +48 -4
  81. package/lib/git-diff.js +4 -0
  82. package/lib/sdk-manager.js +12 -1
  83. package/lib/turn-end-bridge.js +117 -0
  84. package/lib/voice-pack-events.js +32 -0
  85. package/lib/voice-pack-manager.js +246 -0
  86. package/package.json +1 -1
  87. package/pty-manager.js +8 -1
  88. package/server.js +304 -2
  89. package/dist/assets/App-CX6bF6ke.js +0 -1
  90. package/dist/assets/Mobile-YwIGAQWc.js +0 -1
  91. package/dist/assets/classDiagram-6PBFFD2Q-CeAAXpgl.js +0 -1
  92. package/dist/assets/classDiagram-v2-HSJHXN6E-CeAAXpgl.js +0 -1
  93. package/dist/assets/clone-BcGHaFBY.js +0 -1
  94. package/dist/assets/seqResourceLoaders-B9D4RGth.js +0 -2
package/server.js CHANGED
@@ -52,6 +52,21 @@ import { awaitDrainOrClose } from './lib/sse-backpressure.js';
52
52
  import { enrichRawIfNeeded } from './lib/enrich-plan-input.js';
53
53
  import { buildTeamStatusResponse } from './lib/team-runtime.js';
54
54
  import { discoverClaudeMdCandidates, readCandidateById } from './lib/claude-md-discovery.js';
55
+ import {
56
+ saveAudio as vpSaveAudio,
57
+ listUserAudio as vpListUserAudio,
58
+ deleteUserAudio as vpDeleteUserAudio,
59
+ getUserAudioPath as vpGetUserAudioPath,
60
+ getDefaultPackPath as vpGetDefaultPackPath,
61
+ listDefaultPack as vpListDefaultPack,
62
+ isDefaultPackPlaceholder as vpIsDefaultPackPlaceholder,
63
+ reconcileVoicePackPrefs as vpReconcile,
64
+ mimeForFormat as vpMime,
65
+ isValidId as vpIsValidId,
66
+ EVENT_KEYS as VP_EVENT_KEYS,
67
+ MAX_AUDIO_BYTES as VP_MAX_BYTES,
68
+ } from './lib/voice-pack-manager.js';
69
+ import { mergeApprovalModalPrefs as vpMergeAM } from './lib/approval-modal-prefs.js';
55
70
 
56
71
 
57
72
  // 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
@@ -221,6 +236,10 @@ const HOST = '0.0.0.0';
221
236
 
222
237
  // 局域网访问 token(本地 127.0.0.1 免验证)
223
238
  const ACCESS_TOKEN = randomBytes(16).toString('hex');
239
+ // Internal token used ONLY for bridge → server calls (env-leaked to the spawned
240
+ // claude process via pty-manager). Separate from ACCESS_TOKEN so the LAN URL
241
+ // token can't double as a bridge auth bypass for same-host CSRF (round-3 P1).
242
+ const INTERNAL_TOKEN = randomBytes(16).toString('hex');
224
243
 
225
244
  let clients = [];
226
245
  let server;
@@ -649,6 +668,11 @@ async function handleRequest(req, res) {
649
668
  // join() 而非字符串拼接,避免 Windows 分隔符不匹配导致比较失败
650
669
  const _cDir = getClaudeConfigDir();
651
670
  prefs.claudeConfigDir = _cDir === join(homedir(), '.claude') ? '~/.claude' : _cDir;
671
+ // voice-pack id reconcile — strip references to audio files that no longer exist
672
+ // so the client never tries to play a 404. Read-only here; client save path also runs this.
673
+ if (prefs.approvalModal?.voicePack) {
674
+ prefs.approvalModal.voicePack = vpReconcile(LOG_DIR, prefs.approvalModal.voicePack);
675
+ }
652
676
  res.writeHead(200, { 'Content-Type': 'application/json' });
653
677
  res.end(JSON.stringify(prefs));
654
678
  return;
@@ -666,7 +690,15 @@ async function handleRequest(req, res) {
666
690
  }
667
691
  let prefs = {};
668
692
  try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
669
- Object.assign(prefs, incoming);
693
+ // Deep-merge approvalModal so partial updates (e.g. `{ voicePack: { events: { askQuestion: id } } }`)
694
+ // don't blow away unrelated approval prefs. Shallow Object.assign for everything else.
695
+ const { approvalModal: incAM, ...incRest } = incoming;
696
+ Object.assign(prefs, incRest);
697
+ if (incAM && typeof incAM === 'object') {
698
+ prefs.approvalModal = vpMergeAM(prefs.approvalModal, incAM, {
699
+ reconcile: (vp) => vpReconcile(LOG_DIR, vp),
700
+ });
701
+ }
670
702
  // 确保目录存在
671
703
  const prefsFile = getPrefsFile();
672
704
  const prefsDir = dirname(prefsFile);
@@ -707,6 +739,249 @@ async function handleRequest(req, res) {
707
739
  return;
708
740
  }
709
741
 
742
+ // ── Voice Pack API ────────────────────────────────────────────────────────
743
+ // Manages user-uploaded audio + serves the bundled "皇上系列" default pack.
744
+ // Uploads are loopback-only — LAN clients can play but not write.
745
+ if (url === '/api/voice-pack/list' && method === 'GET') {
746
+ try {
747
+ const userAudio = vpListUserAudio(LOG_DIR);
748
+ const defaultPack = vpListDefaultPack();
749
+ // defaultPackPlaceholder lets Settings UI label the Default option as
750
+ // "(placeholder)" until real recordings replace the bundled sine-wave WAVs.
751
+ res.writeHead(200, { 'Content-Type': 'application/json' });
752
+ res.end(JSON.stringify({
753
+ userAudio,
754
+ defaultPack,
755
+ defaultPackPlaceholder: vpIsDefaultPackPlaceholder(),
756
+ eventKeys: VP_EVENT_KEYS,
757
+ maxBytes: VP_MAX_BYTES,
758
+ }));
759
+ } catch (err) {
760
+ res.writeHead(500, { 'Content-Type': 'application/json' });
761
+ res.end(JSON.stringify({ error: 'list failed', detail: err?.message }));
762
+ }
763
+ return;
764
+ }
765
+
766
+ if (url === '/api/voice-pack/upload' && method === 'POST') {
767
+ // Loopback-only — refuse LAN clients even if they hold a valid token.
768
+ // The token already gates LAN access but voice-pack writes touch the local FS
769
+ // and end up reachable from every client; keep the write side strictly local.
770
+ if (!isLocal) {
771
+ res.writeHead(403, { 'Content-Type': 'application/json' });
772
+ res.end(JSON.stringify({ error: 'Upload allowed from loopback only' }));
773
+ return;
774
+ }
775
+ const contentType = req.headers['content-type'] || '';
776
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
777
+ if (!boundaryMatch) {
778
+ res.writeHead(400, { 'Content-Type': 'application/json' });
779
+ res.end(JSON.stringify({ error: 'Missing boundary' }));
780
+ return;
781
+ }
782
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
783
+ if (contentLength > VP_MAX_BYTES + 4096) {
784
+ res.writeHead(413, { 'Content-Type': 'application/json' });
785
+ res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
786
+ return;
787
+ }
788
+ const boundary = boundaryMatch[1];
789
+ const chunks = [];
790
+ let totalSize = 0;
791
+ let aborted = false;
792
+ req.on('data', chunk => {
793
+ totalSize += chunk.length;
794
+ if (totalSize > VP_MAX_BYTES + 4096) {
795
+ aborted = true;
796
+ res.writeHead(413, { 'Content-Type': 'application/json' });
797
+ res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
798
+ req.destroy();
799
+ return;
800
+ }
801
+ chunks.push(chunk);
802
+ });
803
+ req.on('end', () => {
804
+ if (aborted) return;
805
+ try {
806
+ const buf = Buffer.concat(chunks);
807
+ const headerEnd = buf.indexOf('\r\n\r\n');
808
+ if (headerEnd === -1) throw new Error('Malformed multipart');
809
+ const headerStr = buf.slice(0, headerEnd).toString();
810
+ const nameMatch = headerStr.match(/filename="([^"]+)"/);
811
+ const originalName = nameMatch ? nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_') : 'upload';
812
+ const bodyStart = headerEnd + 4;
813
+ const closingBoundary = Buffer.from('\r\n--' + boundary);
814
+ const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
815
+ const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
816
+ const result = vpSaveAudio(LOG_DIR, originalName, fileData, { isLoopback: true });
817
+ res.writeHead(200, { 'Content-Type': 'application/json' });
818
+ res.end(JSON.stringify({ ok: true, ...result }));
819
+ } catch (err) {
820
+ const status = err?.code === 'TOO_LARGE' ? 413 : err?.code === 'BAD_FORMAT' ? 415 : 400;
821
+ res.writeHead(status, { 'Content-Type': 'application/json' });
822
+ res.end(JSON.stringify({ error: err?.message || 'Upload failed' }));
823
+ }
824
+ });
825
+ return;
826
+ }
827
+
828
+ if (url.startsWith('/api/voice-pack/delete/') && method === 'DELETE') {
829
+ if (!isLocal) {
830
+ res.writeHead(403, { 'Content-Type': 'application/json' });
831
+ res.end(JSON.stringify({ error: 'Delete allowed from loopback only' }));
832
+ return;
833
+ }
834
+ const id = url.slice('/api/voice-pack/delete/'.length);
835
+ if (!vpIsValidId(id)) {
836
+ res.writeHead(400, { 'Content-Type': 'application/json' });
837
+ res.end(JSON.stringify({ error: 'Invalid id' }));
838
+ return;
839
+ }
840
+ const ok = vpDeleteUserAudio(LOG_DIR, id);
841
+ res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
842
+ res.end(JSON.stringify({ ok }));
843
+ return;
844
+ }
845
+
846
+ // Serve audio — supports HTTP Range so iOS Safari / mobile players can seek mp3
847
+ // (Safari refuses to start playback when the server returns 200 without Accept-Ranges).
848
+ // Path forms:
849
+ // /api/voice-pack/audio/default/<eventKey> — bundled default pack
850
+ // /api/voice-pack/audio/<uuid> — user-uploaded file
851
+ if (url.startsWith('/api/voice-pack/audio/') && method === 'GET') {
852
+ const tail = url.slice('/api/voice-pack/audio/'.length);
853
+ let resolved = null;
854
+ let isDefault = false;
855
+ if (tail.startsWith('default/')) {
856
+ const eventKey = tail.slice('default/'.length);
857
+ resolved = vpGetDefaultPackPath(eventKey);
858
+ isDefault = true;
859
+ } else {
860
+ resolved = vpGetUserAudioPath(LOG_DIR, tail);
861
+ }
862
+ if (!resolved) {
863
+ res.writeHead(404, { 'Content-Type': 'application/json' });
864
+ res.end(JSON.stringify({ error: 'Not found' }));
865
+ return;
866
+ }
867
+ // Cache strategy:
868
+ // - default-pack: short max-age + must-revalidate, no `immutable` — the on-disk
869
+ // file *can* change when a placeholder is replaced by a real recording at the
870
+ // same path. `must-revalidate` keeps the file out of the stale bucket once
871
+ // max-age expires. Paired with the ETag below, this lets browsers
872
+ // conditional-request and pick up regenerated audio after a `gen-default-voicepack`
873
+ // run — without an ETag they'd silently serve cached stale content.
874
+ // - user audio: content-addressed by UUID (delete + re-upload always mints a
875
+ // new id), so safe to mark immutable for a full day. Loopback-only writes,
876
+ // so the LAN audience cannot mutate.
877
+ const cacheControl = isDefault
878
+ ? 'public, max-age=300, must-revalidate'
879
+ : 'private, max-age=86400, immutable';
880
+ try {
881
+ // Symlink hardening: refuse to serve symlinks even though the routing layer
882
+ // already enforces the id whitelist. A local attacker who can write to
883
+ // LOG_DIR/voice-packs/ could otherwise drop `<uuid>.mp3 → /etc/passwd` and
884
+ // have it streamed over LAN. Same family as the file-access-policy realpath
885
+ // check used elsewhere in server.js for /api/read-file.
886
+ const ls = lstatSync(resolved.path);
887
+ if (ls.isSymbolicLink()) {
888
+ res.writeHead(404, { 'Content-Type': 'application/json' });
889
+ res.end(JSON.stringify({ error: 'Not found' }));
890
+ return;
891
+ }
892
+ const stat = statSync(resolved.path);
893
+ const fileSize = stat.size;
894
+ // ETag = "<size>-<mtime ms>" — cheap, stable across restarts, changes whenever
895
+ // the file is rewritten. Honors If-None-Match → 304 so a regenerated default
896
+ // pack actually reaches the browser instead of being silently served stale.
897
+ const etag = `"${fileSize.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
898
+ if (req.headers['if-none-match'] === etag) {
899
+ res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl });
900
+ res.end();
901
+ return;
902
+ }
903
+ const mime = vpMime(resolved.format);
904
+ const range = req.headers.range;
905
+ if (range) {
906
+ const m = range.match(/bytes=(\d+)-(\d*)/);
907
+ if (m) {
908
+ const start = parseInt(m[1], 10);
909
+ const end = m[2] ? parseInt(m[2], 10) : fileSize - 1;
910
+ if (Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end < fileSize && start <= end) {
911
+ res.writeHead(206, {
912
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
913
+ 'Accept-Ranges': 'bytes',
914
+ 'Content-Length': end - start + 1,
915
+ 'Content-Type': mime,
916
+ 'Cache-Control': cacheControl,
917
+ ETag: etag,
918
+ });
919
+ createReadStream(resolved.path, { start, end }).pipe(res);
920
+ return;
921
+ }
922
+ }
923
+ res.writeHead(416, { 'Content-Range': `bytes */${fileSize}` });
924
+ res.end();
925
+ return;
926
+ }
927
+ res.writeHead(200, {
928
+ 'Content-Length': fileSize,
929
+ 'Content-Type': mime,
930
+ 'Accept-Ranges': 'bytes',
931
+ 'Cache-Control': cacheControl,
932
+ ETag: etag,
933
+ });
934
+ createReadStream(resolved.path).pipe(res);
935
+ } catch (err) {
936
+ res.writeHead(500, { 'Content-Type': 'application/json' });
937
+ res.end(JSON.stringify({ error: 'Read failed', detail: err?.message }));
938
+ }
939
+ return;
940
+ }
941
+ // ──────────────────────────────────────────────────────────────────────────
942
+
943
+ // Turn-end notification — POSTed by lib/turn-end-bridge.js when Claude Code's
944
+ // Stop hook fires (CLI/PTY mode) AND directly by lib/sdk-manager.js's
945
+ // onTurnEnd callback (SDK mode — Stop hooks don't exist there, so cli.js
946
+ // wires the SDK 'result' event to broadcastTurnEnd() instead). Broadcasts a
947
+ // `turn_end` SSE so all connected tabs play voice-pack turnEnd at the real
948
+ // end of a user-prompt turn.
949
+ //
950
+ // Auth: loopback IP + X-CCViewer-Internal header matching INTERNAL_TOKEN.
951
+ // Defense-in-depth against a same-host malicious page POSTing fake turn_end
952
+ // events (round-3 P1). Internal-only POST → in-process SDK callback skips
953
+ // this endpoint and calls `_broadcastTurnEnd` directly below.
954
+ if (url === '/api/turn-end-notify' && method === 'POST') {
955
+ if (!isLocal) {
956
+ res.writeHead(403, { 'Content-Type': 'application/json' });
957
+ res.end(JSON.stringify({ error: 'Loopback only' }));
958
+ return;
959
+ }
960
+ if (req.headers['x-ccviewer-internal'] !== INTERNAL_TOKEN) {
961
+ res.writeHead(403, { 'Content-Type': 'application/json' });
962
+ res.end(JSON.stringify({ error: 'Invalid bridge token' }));
963
+ return;
964
+ }
965
+ let body = '';
966
+ let truncated = false;
967
+ req.on('data', chunk => {
968
+ body += chunk;
969
+ if (body.length > 16384) { truncated = true; req.destroy(); }
970
+ });
971
+ req.on('end', () => {
972
+ if (truncated) {
973
+ console.warn('[turn-end-notify] body exceeded 16KB cap — request destroyed');
974
+ return; // socket already closed by destroy()
975
+ }
976
+ let payload = {};
977
+ try { payload = JSON.parse(body); } catch { /* tolerate empty / malformed */ }
978
+ _broadcastTurnEnd(payload.sessionId || null, payload.ts || Date.now());
979
+ res.writeHead(200, { 'Content-Type': 'application/json' });
980
+ res.end(JSON.stringify({ ok: true }));
981
+ });
982
+ return;
983
+ }
984
+
710
985
  // 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
711
986
  if (url === '/api/register-log' && method === 'POST') {
712
987
  let body = '';
@@ -870,7 +1145,7 @@ async function handleRequest(req, res) {
870
1145
  if (proxyPort) {
871
1146
  const { spawnClaude } = await import('./pty-manager.js');
872
1147
  const mergedArgs = [..._workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
873
- await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort, serverProtocol);
1148
+ await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort, serverProtocol, INTERNAL_TOKEN);
874
1149
  }
875
1150
 
876
1151
  _workspaceLaunched = true;
@@ -4460,6 +4735,33 @@ export function getAccessToken() {
4460
4735
  return ACCESS_TOKEN;
4461
4736
  }
4462
4737
 
4738
+ export function getInternalToken() {
4739
+ return INTERNAL_TOKEN;
4740
+ }
4741
+
4742
+ // In-process broadcast helper for the `turn_end` SSE event. Two callers:
4743
+ // 1. /api/turn-end-notify POST handler (PTY/CLI mode via Stop hook bridge)
4744
+ // 2. cli.js runSdkMode → sdkManager.initSdkSession({ onTurnEnd }) (SDK mode —
4745
+ // ensureHooks() isn't called so Stop hook isn't installed; emit the event
4746
+ // directly when SDK's 'result' message fires).
4747
+ // Same SSE payload shape regardless of source so the frontend listener doesn't
4748
+ // care which path produced it.
4749
+ function _broadcastTurnEnd(sessionId, ts) {
4750
+ try {
4751
+ if (clients.length > 0 && sendEventToClients) {
4752
+ sendEventToClients(clients, 'turn_end', {
4753
+ sessionId: sessionId || null,
4754
+ ts: ts || Date.now(),
4755
+ });
4756
+ }
4757
+ } catch (err) {
4758
+ console.warn('[turn-end] broadcast failed:', err.message);
4759
+ }
4760
+ }
4761
+ export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
4762
+ _broadcastTurnEnd(sessionId, ts);
4763
+ }
4764
+
4463
4765
  // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端
4464
4766
  let _streamingStatusTimer = null;
4465
4767
  let _lastStreamingActive = false;