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.
- package/README.md +90 -78
- package/cli.js +5 -0
- package/dist/assets/App-CKzgXXd9.js +1 -0
- package/dist/assets/{MdxEditorPanel-j9aQWwCJ.js → MdxEditorPanel-BUWD79wR.js} +1 -1
- package/dist/assets/Mobile-Dwh-S57S.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-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/{index-Dzkxj8m_.css → index-_4BCXKKF.css} +1 -1
- package/dist/assets/{index-DX4SlYho.js → index-tHUiY0PG.js} +2 -2
- 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-DSKrKxVy.css → seqResourceLoaders-BsgJ9V64.css} +2 -2
- package/dist/assets/seqResourceLoaders-LrrYgtsO.js +2 -0
- 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 +5 -5
- 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/lib/approval-modal-prefs.js +71 -0
- package/lib/ask-bridge.js +19 -1
- package/lib/ensure-hooks.js +48 -4
- package/lib/sdk-manager.js +60 -6
- 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 +414 -13
- package/dist/assets/App-DfhQt_ed.js +0 -1
- package/dist/assets/Mobile-0ZF71DQy.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-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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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; }
|