cc-viewer 1.6.262 → 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 (94) hide show
  1. package/README.md +90 -78
  2. package/cli.js +5 -0
  3. package/dist/assets/App-CKzgXXd9.js +1 -0
  4. package/dist/assets/{MdxEditorPanel-j9aQWwCJ.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-Dzkxj8m_.css → index-_4BCXKKF.css} +1 -1
  42. package/dist/assets/{index-DX4SlYho.js → index-tHUiY0PG.js} +2 -2
  43. package/dist/assets/{infoDiagram-42DDH7IO-D409o-BL.js → infoDiagram-42DDH7IO-Ca9j90t5.js} +1 -1
  44. package/dist/assets/{ishikawaDiagram-UXIWVN3A-CMVPOGr3.js → ishikawaDiagram-UXIWVN3A-DhjV0XPD.js} +1 -1
  45. package/dist/assets/{journeyDiagram-VCZTEJTY-Bl_5WlaZ.js → journeyDiagram-VCZTEJTY-CRSHLZPV.js} +1 -1
  46. package/dist/assets/{jszip.min-C2654z9i.js → jszip.min-CcCCdMNW.js} +1 -1
  47. package/dist/assets/{kanban-definition-6JOO6SKY-BfCyUP29.js → kanban-definition-6JOO6SKY-Bg0CUwgc.js} +1 -1
  48. package/dist/assets/{layout-CgAMa0xE.js → layout-CWNu13XT.js} +1 -1
  49. package/dist/assets/{linear-J1N1npGr.js → linear-Dcmw1639.js} +1 -1
  50. package/dist/assets/{mermaid.core-YnqOkuoS.js → mermaid.core-1heNIJ5f.js} +2 -2
  51. package/dist/assets/{min-CowkZam8.js → min-CC9CkAxn.js} +1 -1
  52. package/dist/assets/{mindmap-definition-QFDTVHPH-D7yMfot2.js → mindmap-definition-QFDTVHPH-Gr0ex_Ny.js} +1 -1
  53. package/dist/assets/{pieDiagram-DEJITSTG-DKUHBCwB.js → pieDiagram-DEJITSTG-D7P3sUJY.js} +1 -1
  54. package/dist/assets/{quadrantDiagram-34T5L4WZ-DhRqBNfT.js → quadrantDiagram-34T5L4WZ-Bov3lcpV.js} +1 -1
  55. package/dist/assets/{requirementDiagram-MS252O5E-DVE3wKT7.js → requirementDiagram-MS252O5E-BcLptaOU.js} +1 -1
  56. package/dist/assets/{sankeyDiagram-XADWPNL6-Rn_9b5V_.js → sankeyDiagram-XADWPNL6-B2qAUsON.js} +1 -1
  57. package/dist/assets/{seqResourceLoaders-DSKrKxVy.css → seqResourceLoaders-BsgJ9V64.css} +2 -2
  58. package/dist/assets/seqResourceLoaders-LrrYgtsO.js +2 -0
  59. package/dist/assets/{sequenceDiagram-FGHM5R23-CemLRaXC.js → sequenceDiagram-FGHM5R23-Do62Uz-a.js} +1 -1
  60. package/dist/assets/{stateDiagram-FHFEXIEX-DNT1gAty.js → stateDiagram-FHFEXIEX-Wu8aqa8C.js} +1 -1
  61. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-DPlMhu-M.js → stateDiagram-v2-QKLJ7IA2-BfYq7Jgo.js} +1 -1
  62. package/dist/assets/{timeline-definition-GMOUNBTQ-B-F8vgZN.js → timeline-definition-GMOUNBTQ-DYfz5xD6.js} +1 -1
  63. package/dist/assets/{vendor-antd-Dq3DHFa-.js → vendor-antd-BG1SvzuN.js} +2 -2
  64. package/dist/assets/{vendor-codemirror-DjMkT0sn.js → vendor-codemirror-8NDhydlF.js} +1 -1
  65. package/dist/assets/{vendor-mdxeditor-CrZ9SWce.js → vendor-mdxeditor-BB4hhpxM.js} +2 -2
  66. package/dist/assets/{vendor-qrcode-C_77dtHg.js → vendor-qrcode-DMsNGQ10.js} +1 -1
  67. package/dist/assets/{vendor-virtuoso-aMZPf2fi.js → vendor-virtuoso-BUT96ALa.js} +1 -1
  68. package/dist/assets/{vennDiagram-DHZGUBPP-K67JHnnN.js → vennDiagram-DHZGUBPP-CGr-cc7e.js} +1 -1
  69. package/dist/assets/{wardley-RL74JXVD-CCis01LD.js → wardley-RL74JXVD-BN899vMf.js} +1 -1
  70. package/dist/assets/{wardleyDiagram-NUSXRM2D-BjqRIOpN.js → wardleyDiagram-NUSXRM2D-xUsI1E7h.js} +1 -1
  71. package/dist/assets/{xychartDiagram-5P7HB3ND-CaXETYTI.js → xychartDiagram-5P7HB3ND-woQrslzB.js} +1 -1
  72. package/dist/index.html +5 -5
  73. package/dist/voice-packs/default/askQuestion.wav +0 -0
  74. package/dist/voice-packs/default/pack.json +34 -0
  75. package/dist/voice-packs/default/planApproval.wav +0 -0
  76. package/dist/voice-packs/default/timeoutWarning5min.wav +0 -0
  77. package/dist/voice-packs/default/timeoutWarning60s.wav +0 -0
  78. package/dist/voice-packs/default/turnEnd.wav +0 -0
  79. package/lib/approval-modal-prefs.js +71 -0
  80. package/lib/ask-bridge.js +19 -1
  81. package/lib/ensure-hooks.js +48 -4
  82. package/lib/sdk-manager.js +60 -6
  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 +414 -13
  89. package/dist/assets/App-DfhQt_ed.js +0 -1
  90. package/dist/assets/Mobile-0ZF71DQy.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-CH1DqmCg.js +0 -2
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);
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 修改)
@@ -158,6 +173,8 @@ function _notifyParentPending(msg) {
158
173
  case 'sdk-ask-timeout':
159
174
  case 'ask-hook-resolved':
160
175
  case 'sdk-ask-resolved':
176
+ case 'ask-hook-cancelled':
177
+ // 注:ask-cancel handler 统一发 ask-hook-cancelled(不论 SDK / Hook 路径)。
161
178
  event = { type: 'pending-remove', kind: 'ask', id: msg.id != null ? String(msg.id) : '__ask__' };
162
179
  break;
163
180
  default:
@@ -219,6 +236,10 @@ const HOST = '0.0.0.0';
219
236
 
220
237
  // 局域网访问 token(本地 127.0.0.1 免验证)
221
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');
222
243
 
223
244
  let clients = [];
224
245
  let server;
@@ -647,6 +668,11 @@ async function handleRequest(req, res) {
647
668
  // join() 而非字符串拼接,避免 Windows 分隔符不匹配导致比较失败
648
669
  const _cDir = getClaudeConfigDir();
649
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
+ }
650
676
  res.writeHead(200, { 'Content-Type': 'application/json' });
651
677
  res.end(JSON.stringify(prefs));
652
678
  return;
@@ -664,7 +690,15 @@ async function handleRequest(req, res) {
664
690
  }
665
691
  let prefs = {};
666
692
  try { if (existsSync(getPrefsFile())) prefs = JSON.parse(readFileSync(getPrefsFile(), 'utf-8')); } catch { }
667
- 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
+ }
668
702
  // 确保目录存在
669
703
  const prefsFile = getPrefsFile();
670
704
  const prefsDir = dirname(prefsFile);
@@ -705,6 +739,249 @@ async function handleRequest(req, res) {
705
739
  return;
706
740
  }
707
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
+
708
985
  // 注册新的日志文件进行 watch(供新进程复用旧服务时调用)
709
986
  if (url === '/api/register-log' && method === 'POST') {
710
987
  let body = '';
@@ -868,7 +1145,7 @@ async function handleRequest(req, res) {
868
1145
  if (proxyPort) {
869
1146
  const { spawnClaude } = await import('./pty-manager.js');
870
1147
  const mergedArgs = [..._workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
871
- await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort, serverProtocol);
1148
+ await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, _workspaceClaudePath, _workspaceIsNpmVersion, actualPort, serverProtocol, INTERNAL_TOKEN);
872
1149
  }
873
1150
 
874
1151
  _workspaceLaunched = true;
@@ -2420,7 +2697,8 @@ async function handleRequest(req, res) {
2420
2697
  }
2421
2698
  }
2422
2699
 
2423
- const HOOK_TIMEOUT = 5 * 60 * 1000; // 5 minutes
2700
+ const HOOK_TIMEOUT = 60 * 60 * 1000; // 60 minutes — 等价 terminal Claude Code 的"无超时"体验
2701
+ // (terminal interactiveHandler 本身无 timeout,hook 子进程层 10min;这里 60min 远超人类响应时间)
2424
2702
  // toolUseId 路由策略:
2425
2703
  // - char whitelist + ≤256 长度 防恶意 1MB key 撑大 Map
2426
2704
  // - 已存在同 id 但旧 res 已断(writableEnded/destroyed)→ 复用槽位(ask-bridge 重试场景)
@@ -2505,11 +2783,12 @@ async function handleRequest(req, res) {
2505
2783
  }
2506
2784
  }, HOOK_TIMEOUT);
2507
2785
 
2508
- pendingAskHooks.set(id, { questions, res, timer, createdAt: Date.now() });
2786
+ const askStartedAt = Date.now();
2787
+ pendingAskHooks.set(id, { questions, res, timer, createdAt: askStartedAt });
2509
2788
 
2510
- // Broadcast to all terminal WS clients
2789
+ // Broadcast to all terminal WS clients — 附 startedAt + timeoutMs 让前端渲染倒计时
2511
2790
  if (terminalWss) {
2512
- const pmsg = JSON.stringify({ type: 'ask-hook-pending', id, questions });
2791
+ const pmsg = JSON.stringify({ type: 'ask-hook-pending', id, questions, startedAt: askStartedAt, timeoutMs: HOOK_TIMEOUT });
2513
2792
  terminalWss.clients.forEach((client) => {
2514
2793
  if (client.readyState === 1) {
2515
2794
  try { client.send(pmsg); } catch {}
@@ -4103,6 +4382,27 @@ async function setupTerminalWebSocket(httpServer) {
4103
4382
  ws.send(JSON.stringify({ type: 'data', data: buffer }));
4104
4383
  }
4105
4384
 
4385
+ // Replay pending ask-hook 请求:浏览器关 tab 再开(或 ws 重连)时,
4386
+ // 让新 ws 立即收到当前 server-side 仍 long-poll 的 ask 列表 + startedAt + 剩余 timeoutMs,
4387
+ // 否则前端 askMetaMap 空 → 倒计时不渲染 + lastPendingAskId 派生错。
4388
+ // ASK_HOOK_TIMEOUT 在闭包外(line 2425 const HOOK_TIMEOUT),不直接可见 — 用 60min 字面量同源。
4389
+ const REPLAY_HOOK_TIMEOUT = 60 * 60 * 1000;
4390
+ const now = Date.now();
4391
+ for (const [id, entry] of pendingAskHooks) {
4392
+ const elapsed = now - (entry.createdAt || now);
4393
+ const remaining = Math.max(0, REPLAY_HOOK_TIMEOUT - elapsed);
4394
+ if (remaining <= 0) continue;
4395
+ try {
4396
+ ws.send(JSON.stringify({
4397
+ type: 'ask-hook-pending',
4398
+ id,
4399
+ questions: entry.questions,
4400
+ startedAt: now, // 让前端按"还剩 remaining"起算(不是原 createdAt)
4401
+ timeoutMs: remaining, // 剩余可用时间
4402
+ }));
4403
+ } catch {}
4404
+ }
4405
+
4106
4406
  // 兜底重绘标记:claude TUI 在 alternate-screen 下只在收到 SIGWINCH 时重绘整屏。
4107
4407
  // 若前端首次 resize 与 PTY 当前尺寸恰好相等,pty.resize noop 不发 SIGWINCH → 前端空白。
4108
4408
  // 该 ws 收到第一条 resize 时(见 ws.on('message')),抖动 (rows+1) → (rows) 触发 SIGWINCH。
@@ -4189,18 +4489,15 @@ async function setupTerminalWebSocket(httpServer) {
4189
4489
  } else if (msg.type === 'ask-hook-answer') {
4190
4490
  // Client answered AskUserQuestion via hook bridge.
4191
4491
  // New protocol: msg.id required to address one of multiple pending asks.
4192
- // Legacy fallback: no id → resolve the oldest pending ask (preserves pre-Map client behavior).
4492
+ // 老协议 fallback(取最老)已废弃 pending 时会"答错对象"造成串答;
4493
+ // 缺 id 直接 WARN 并丢弃,让前端在 console 里看到为什么答案没生效。
4193
4494
  let askAnswered = false;
4194
4495
  let askId = msg.id;
4195
4496
  let askEntry = null;
4196
4497
  if (askId) {
4197
4498
  askEntry = pendingAskHooks.get(askId);
4198
4499
  } else {
4199
- const firstId = pendingAskHooks.keys().next().value;
4200
- if (firstId) {
4201
- askId = firstId;
4202
- askEntry = pendingAskHooks.get(firstId);
4203
- }
4500
+ console.warn('[server] ask-hook-answer missing id — legacy fallback removed to prevent cross-question mis-routing; ignoring');
4204
4501
  }
4205
4502
  if (askEntry) {
4206
4503
  const { res: hookRes, timer } = askEntry;
@@ -4222,6 +4519,21 @@ async function setupTerminalWebSocket(httpServer) {
4222
4519
  });
4223
4520
  }
4224
4521
  if (askAnswered) _notifyParentPending({ type: 'ask-hook-resolved', id: askId });
4522
+ // entry 不在(LRU evicted / 已答 / 跨 client race / 60min 超时)— 给发起方 ack
4523
+ // ask-hook-cancelled 让前端关 modal + _pendingFlushQueue 兜底处理(如有 user
4524
+ // message 等 ack 待 flush)。行为对齐 ask-cancel handler handled=false 分支语义。
4525
+ // 不广播给其他 client(与 ack-cancel handled=false 一致),防误覆盖真实 answer。
4526
+ if (!askAnswered && askId) {
4527
+ try {
4528
+ if (ws && ws.readyState === 1) {
4529
+ ws.send(JSON.stringify({
4530
+ type: 'ask-hook-cancelled',
4531
+ id: askId,
4532
+ reason: 'Ask entry no longer exists (timeout / evicted / already resolved)',
4533
+ }));
4534
+ }
4535
+ } catch {}
4536
+ }
4225
4537
  } else if (msg.type === 'perm-hook-answer') {
4226
4538
  // Permission approval — SDK mode (canUseTool) or PTY mode (hook bridge)
4227
4539
  let permAnswered = false;
@@ -4261,6 +4573,61 @@ async function setupTerminalWebSocket(httpServer) {
4261
4573
  });
4262
4574
  }
4263
4575
  if (msg.id) _notifyParentPending({ type: 'sdk-ask-resolved', id: msg.id });
4576
+ } else if (msg.type === 'ask-cancel') {
4577
+ // 用户主动取消 AskUserQuestion(或 ChatInputBar 提交新 prompt 时打断 pending ask)。
4578
+ // 双模式分流:先查 SDK _pendingApprovals → 再查 Hook pendingAskHooks → 都没有也广播 ack
4579
+ // (LRU evicted / plugin-already-resolved / WS 重发等场景兜底,让所有 client modal 同步关掉)。
4580
+ // cancelId/Reason 校验:与 toolUseId 同套白名单(≤256 字符 + [a-zA-Z0-9_-])+ reason ≤500
4581
+ // 防恶意/buggy client 塞超长 key 撑大 _pendingApprovals 或塞 1MB reason 打爆 broadcast。
4582
+ const rawId = msg.id != null ? String(msg.id) : null;
4583
+ const cancelId = rawId && rawId.length > 0 && rawId.length <= 256 && /^[a-zA-Z0-9_-]+$/.test(rawId) ? rawId : null;
4584
+ if (rawId && !cancelId) {
4585
+ console.warn('[server] ask-cancel rejected: invalid id format');
4586
+ return;
4587
+ }
4588
+ const cancelReason = (typeof msg.reason === 'string' ? msg.reason : 'User aborted').slice(0, 500);
4589
+ let handled = false;
4590
+ // SDK 路径:调 cancelApproval 让 _waitForApproval resolve cancel sentinel
4591
+ // (sdk-manager.js canUseTool 检测 sentinel 后返回 { behavior: 'deny', message: cancelReason }
4592
+ // → SDK 包内置 ensureToolResultPairing 兜住 transcript)
4593
+ if (cancelId && _sdkCancelApproval) {
4594
+ try { handled = _sdkCancelApproval(cancelId, cancelReason) === true; } catch {}
4595
+ }
4596
+ // Hook 路径:给对应 res 回 200 + { cancelled: true, reason }
4597
+ // ask-bridge.js 检测 cancelled 字段后输出 { hookSpecificOutput: { permissionDecision: 'deny', ... } }
4598
+ if (!handled && cancelId) {
4599
+ const askEntry = pendingAskHooks.get(cancelId);
4600
+ if (askEntry) {
4601
+ const { res: hookRes, timer } = askEntry;
4602
+ if (timer) clearTimeout(timer);
4603
+ pendingAskHooks.delete(cancelId);
4604
+ handled = true;
4605
+ try {
4606
+ if (hookRes && !hookRes.headersSent) {
4607
+ hookRes.writeHead(200, { 'Content-Type': 'application/json' });
4608
+ hookRes.end(JSON.stringify({ cancelled: true, reason: cancelReason }));
4609
+ }
4610
+ } catch {}
4611
+ }
4612
+ }
4613
+ // ack 广播分两档:
4614
+ // - handled=true(真的取消了 SDK 或 Hook entry)→ 广播给所有 client + 通知 parent
4615
+ // - handled=false(LRU evicted / plugin 已答 / 重发等)→ 只 ack 给发起方,不广播
4616
+ // 原因:那些场景下其他 client 看到的是 answered 而非 cancelled,广播会让前端
4617
+ // localAskAnswers 误覆盖真实 answer 涂成灰态。发起方自身已经乐观写过,此时
4618
+ // server 的 ack 实际只起"sync 错过的 ack"作用。
4619
+ if (cancelId) {
4620
+ const cmsg = JSON.stringify({ type: 'ask-hook-cancelled', id: cancelId, reason: cancelReason });
4621
+ if (handled && terminalWss) {
4622
+ terminalWss.clients.forEach((c) => {
4623
+ if (c.readyState === 1) try { c.send(cmsg); } catch {}
4624
+ });
4625
+ _notifyParentPending({ type: 'ask-hook-cancelled', id: cancelId });
4626
+ } else if (!handled && ws && ws.readyState === 1) {
4627
+ // 仅 ack 给发起方,让其前端 _waitingCancelAck flush user message
4628
+ try { ws.send(cmsg); } catch {}
4629
+ }
4630
+ }
4264
4631
  } else if (msg.type === 'sdk-plan-answer') {
4265
4632
  // Plan approval in SDK mode
4266
4633
  if (_sdkResolveApproval) {
@@ -4368,6 +4735,33 @@ export function getAccessToken() {
4368
4735
  return ACCESS_TOKEN;
4369
4736
  }
4370
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
+
4371
4765
  // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端
4372
4766
  let _streamingStatusTimer = null;
4373
4767
  let _lastStreamingActive = false;
@@ -4465,7 +4859,8 @@ export function broadcastWsMessage(msg) {
4465
4859
  // 显式调用 _notifyParentPending 的分支(ask-hook-resolved 等)走 ws.send 不进这里,无重复触发。
4466
4860
  if (msg && typeof msg === 'object' && typeof msg.type === 'string'
4467
4861
  && (msg.type === 'sdk-ask-pending' || msg.type === 'sdk-ask-resolved' || msg.type === 'sdk-ask-timeout'
4468
- || msg.type === 'ask-hook-pending' || msg.type === 'ask-hook-resolved' || msg.type === 'ask-hook-timeout')) {
4862
+ || msg.type === 'ask-hook-pending' || msg.type === 'ask-hook-resolved' || msg.type === 'ask-hook-timeout'
4863
+ || msg.type === 'ask-hook-cancelled')) {
4469
4864
  _notifyParentPending(msg);
4470
4865
  }
4471
4866
  }
@@ -4474,6 +4869,12 @@ export function broadcastWsMessage(msg) {
4474
4869
  let _sdkResolveApproval = null;
4475
4870
  export function setSdkResolveApproval(fn) { _sdkResolveApproval = fn; }
4476
4871
 
4872
+ /** Reference to sdk-manager's cancelApproval (set by cli.js after import). */
4873
+ // 与 _sdkResolveApproval 平行——但语义不同:cancelApproval 让 _waitForApproval resolve
4874
+ // 一个 cancel sentinel,让 canUseTool 走 deny 分支(而非 allow)。
4875
+ let _sdkCancelApproval = null;
4876
+ export function setSdkCancelApproval(fn) { _sdkCancelApproval = fn; }
4877
+
4477
4878
  /** Reference to sdk-manager's sendUserMessage (set by cli.js after import). */
4478
4879
  let _sdkSendUserMessage = null;
4479
4880
  export function setSdkSendUserMessage(fn) { _sdkSendUserMessage = fn; }