cc-viewer 1.9.17 → 1.9.18
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/cli.js +17 -1
- package/dist/assets/{App-BlNbziAQ.js → App-COyZwmVt.js} +2 -2
- package/dist/assets/{MdxEditorPanel-C9cr-WBH.js → MdxEditorPanel-BsHDgZCY.js} +1 -1
- package/dist/assets/{Mobile-CDQPYYQo.js → Mobile-B1H_PrNl.js} +1 -1
- package/dist/assets/{ProxyStatsModal-BEqrTR-F.js → ProxyStatsModal-CSQNNwpU.js} +1 -1
- package/dist/assets/{index-B4J-uwYG.js → index-CPn0gvr6.js} +2 -2
- package/dist/assets/seqResourceLoaders-Cm1JnNsu.js +2 -0
- package/dist/assets/{seqResourceLoaders-B-5xGs9q.css → seqResourceLoaders-DZA-JHK0.css} +1 -1
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/plugin-loader.js +34 -2
- package/server/lib/sdk-manager.js +450 -45
- package/server/proxy.js +52 -8
- package/server/routes/files-content.js +24 -1
- package/server/server.js +51 -8
- package/dist/assets/seqResourceLoaders-DaZ8fZXu.js +0 -2
package/server/proxy.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
2
|
import { createServer } from 'node:http';
|
|
3
|
+
import { Readable, Transform, pipeline } from 'node:stream';
|
|
3
4
|
import { readFileSync, existsSync } from 'node:fs';
|
|
4
5
|
import { join, basename } from 'node:path';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
@@ -11,7 +12,7 @@ import { getClaudeConfigDir } from '../findcc.js';
|
|
|
11
12
|
import { isAnthropicApiPath, classifyProxyRole } from './lib/interceptor-core.js';
|
|
12
13
|
import { parseAgentId, findHeader } from './lib/v2/agent-id.js';
|
|
13
14
|
import { liveSystemPromptEnabled } from './lib/system-prompt-live.js';
|
|
14
|
-
import { executeRequest, extractModel } from './lib/proxy/proxy-retry.js';
|
|
15
|
+
import { executeRequest, extractModel, isStreamResponse } from './lib/proxy/proxy-retry.js';
|
|
15
16
|
import { buildRecord, appendRecord, dailyFilePath, todayStr, emitProxyStatsUpdate } from './lib/proxy/proxy-stats.js';
|
|
16
17
|
import { reportSwallowed } from './lib/error-report.js';
|
|
17
18
|
import { LOG_DIR } from '../findcc.js';
|
|
@@ -231,7 +232,6 @@ export function startProxy() {
|
|
|
231
232
|
res.writeHead(response.status, responseHeaders);
|
|
232
233
|
|
|
233
234
|
if (response.body) {
|
|
234
|
-
const { Readable, pipeline } = await import('node:stream');
|
|
235
235
|
// @ts-ignore
|
|
236
236
|
const nodeStream = Readable.fromWeb(response.body);
|
|
237
237
|
// 持久 error handler 兜底:防止 pipeline 清理后延迟到达的 error 事件导致进程崩溃
|
|
@@ -388,15 +388,59 @@ async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyD
|
|
|
388
388
|
res.writeHead(finalStatus, responseHeaders);
|
|
389
389
|
|
|
390
390
|
if (response.body) {
|
|
391
|
-
const { Readable, pipeline } = await import('node:stream');
|
|
392
391
|
// @ts-ignore
|
|
393
392
|
const nodeStream = Readable.fromWeb(response.body);
|
|
394
393
|
nodeStream.on('error', () => {});
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
394
|
+
const streamIdleMs = retryConfig && retryConfig.mode === 'off'
|
|
395
|
+
? (retryConfig.streamIdleTimeoutMs ?? 0)
|
|
396
|
+
: 0;
|
|
397
|
+
// Only gate SSE (streaming) responses: a non-stream body legitimately arrives as one
|
|
398
|
+
// whole payload after a long upstream think, and must not be truncated mid-wait.
|
|
399
|
+
if (streamIdleMs > 0 && isStreamResponse(response)) {
|
|
400
|
+
// Off-mode-only idle watchdog: retry modes already attach the equivalent
|
|
401
|
+
// guard on the Web-stream side upstream (applyStreamIdleWatchdog). That
|
|
402
|
+
// variant cannot run for off mode — its pipeThrough races the
|
|
403
|
+
// interceptor's getReader() body reconstruction (spurious `fetch failed`).
|
|
404
|
+
// This Node-side Transform sits after Readable.fromWeb, so it is immune
|
|
405
|
+
// to that race. The timer arms at construction, BEFORE the first chunk,
|
|
406
|
+
// so a stream that never sends its first byte is still bounded.
|
|
407
|
+
let watchdog = null;
|
|
408
|
+
let idleTimer = null;
|
|
409
|
+
const disarmIdle = () => {
|
|
410
|
+
if (idleTimer) {
|
|
411
|
+
clearTimeout(idleTimer);
|
|
412
|
+
idleTimer = null;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const armIdle = () => {
|
|
416
|
+
disarmIdle();
|
|
417
|
+
idleTimer = setTimeout(() => {
|
|
418
|
+
idleTimer = null;
|
|
419
|
+
// Log unconditionally: a destroyed stream surfaces downstream as an unexplained
|
|
420
|
+
// `fetch failed`; without this line there is no way to attribute it.
|
|
421
|
+
console.warn(`[CC-Viewer Proxy] off-mode stream idle timeout (${streamIdleMs}ms) — destroying upstream stream`);
|
|
422
|
+
watchdog.destroy(new Error(`proxy stream idle timeout (${streamIdleMs}ms)`));
|
|
423
|
+
}, streamIdleMs);
|
|
424
|
+
if (typeof idleTimer.unref === 'function') idleTimer.unref();
|
|
425
|
+
};
|
|
426
|
+
watchdog = new Transform({
|
|
427
|
+
transform(chunk, _enc, cb) { armIdle(); cb(null, chunk); },
|
|
428
|
+
flush(cb) { disarmIdle(); cb(); },
|
|
429
|
+
destroy(err, cb) { disarmIdle(); cb(err); },
|
|
430
|
+
});
|
|
431
|
+
armIdle();
|
|
432
|
+
pipeline(nodeStream, watchdog, res, (err) => {
|
|
433
|
+
if (err && process.env.CCV_DEBUG) {
|
|
434
|
+
console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
} else {
|
|
438
|
+
pipeline(nodeStream, res, (err) => {
|
|
439
|
+
if (err && process.env.CCV_DEBUG) {
|
|
440
|
+
console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
}
|
|
400
444
|
} else if (response.text) {
|
|
401
445
|
// 竞速失败兜底 response:用 text() 取 body
|
|
402
446
|
const text = await response.text();
|
|
@@ -548,6 +548,29 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
|
|
|
548
548
|
// encoding. Normalize only this mutation's containment comparisons.
|
|
549
549
|
let writeRoot;
|
|
550
550
|
const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
|
|
551
|
+
// isPathContained realpaths its target, so it returns false for a not-yet-existing
|
|
552
|
+
// file — which wrongly 403s every new-file save in target mode. For a missing leaf,
|
|
553
|
+
// realpath the nearest existing ancestor, re-append the descent, then prefix-compare
|
|
554
|
+
// against the (already-real) write root; an existing path keeps the strict realpath.
|
|
555
|
+
const isInsideWriteRootAllowNew = (path) => {
|
|
556
|
+
if (isInsideWriteRoot(path)) return true;
|
|
557
|
+
if (existsSync(path)) return false; // exists but resolves outside → strict deny
|
|
558
|
+
const root = writeRoot.endsWith(sep) ? writeRoot : writeRoot + sep;
|
|
559
|
+
let cursor = resolve(path, '..');
|
|
560
|
+
const descent = [basename(path)];
|
|
561
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
562
|
+
if (existsSync(cursor)) {
|
|
563
|
+
let rebuilt;
|
|
564
|
+
try { rebuilt = join(realpathSync(cursor), ...descent); } catch { return false; }
|
|
565
|
+
return rebuilt === writeRoot || rebuilt.startsWith(root);
|
|
566
|
+
}
|
|
567
|
+
const parent = resolve(cursor, '..');
|
|
568
|
+
if (parent === cursor) return false;
|
|
569
|
+
descent.unshift(basename(cursor));
|
|
570
|
+
cursor = parent;
|
|
571
|
+
}
|
|
572
|
+
return false;
|
|
573
|
+
};
|
|
551
574
|
const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
|
|
552
575
|
let body = '';
|
|
553
576
|
let overflow = false;
|
|
@@ -685,7 +708,7 @@ function fileContentPost(req, res, parsedUrl, isLocal) {
|
|
|
685
708
|
// exists OR is being created — otherwise a crafted relative path could write
|
|
686
709
|
// outside the navigation root (isWriteAllowed only bounds by the allowlist).
|
|
687
710
|
// Project mode keeps the legacy existing-file-only check.
|
|
688
|
-
if (!editorSession && targetNavRoot && !
|
|
711
|
+
if (!editorSession && targetNavRoot && !isInsideWriteRootAllowNew(targetReal)) {
|
|
689
712
|
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
690
713
|
res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
|
|
691
714
|
return;
|
package/server/server.js
CHANGED
|
@@ -2216,11 +2216,10 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2216
2216
|
const fproj = (typeof frame.cwd === 'string' && frame.cwd) ? (projectKeyForCwd(frame.cwd) || null) : null;
|
|
2217
2217
|
return !fproj || fproj === viewProject; // no-cwd frame → replay; else must match
|
|
2218
2218
|
};
|
|
2219
|
-
const replaySdkPendingApprovals = (
|
|
2219
|
+
const replaySdkPendingApprovals = () => {
|
|
2220
2220
|
if (!_sdkGetPendingApprovals) return;
|
|
2221
2221
|
for (const p of _sdkGetPendingApprovals() || []) {
|
|
2222
2222
|
if (!p || !p.replay || !p.id) continue;
|
|
2223
|
-
if (planOnly && p.kind !== 'plan') continue;
|
|
2224
2223
|
const payload = { ...p.replay };
|
|
2225
2224
|
if (!_sdkFrameForView(payload)) continue;
|
|
2226
2225
|
// The generic replay filter is project-scoped. When an exact instance
|
|
@@ -2459,9 +2458,24 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2459
2458
|
}
|
|
2460
2459
|
}
|
|
2461
2460
|
// View changes use attach rather than a new connection: restore that view's
|
|
2462
|
-
// pending approvals too, so a plan cleared on switch-away can be
|
|
2463
|
-
// after switching back instead of waiting for the server timeout.
|
|
2464
|
-
|
|
2461
|
+
// pending approvals too, so a plan/ask/perm cleared on switch-away can be
|
|
2462
|
+
// answered after switching back instead of waiting for the server timeout.
|
|
2463
|
+
// (Was plan-only; ask/perm were orphaned into a timeout deny on re-attach.
|
|
2464
|
+
// The replay helpers already filter by the just-stamped view project+instance,
|
|
2465
|
+
// and the frontend dedupes by id, so a full replay never double-opens a modal.)
|
|
2466
|
+
replaySdkPendingApprovals();
|
|
2467
|
+
// Re-announce the just-viewed workspace's turn state so the spinner / Stop
|
|
2468
|
+
// button reflects it immediately (otherwise a long-running turn shows idle
|
|
2469
|
+
// until the next state change or heartbeat).
|
|
2470
|
+
if (_sdkGetSessionStateSnapshot) {
|
|
2471
|
+
const stateAnchor = ws._ccvViewInstance
|
|
2472
|
+
? { instanceKey: ws._ccvViewInstance }
|
|
2473
|
+
: (ws._ccvViewProject ? { project: ws._ccvViewProject } : null);
|
|
2474
|
+
const stateSnap = stateAnchor ? _sdkGetSessionStateSnapshot(stateAnchor) : _sdkGetSessionStateSnapshot();
|
|
2475
|
+
if (stateSnap && (!ws._ccvViewInstance || stateSnap.instanceKey === ws._ccvViewInstance)) {
|
|
2476
|
+
try { ws.send(JSON.stringify(stateSnap)); } catch (err) { reportSwallowed('sdk.session-state-replay', err); }
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2465
2479
|
replayQueueSnapshot();
|
|
2466
2480
|
return;
|
|
2467
2481
|
}
|
|
@@ -2879,7 +2893,11 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2879
2893
|
if (_sdkSendUserMessage && msg.text) {
|
|
2880
2894
|
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2881
2895
|
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2882
|
-
|
|
2896
|
+
reportSwallowed('server.sdk-send', err);
|
|
2897
|
+
// Directed back to the initiator ONLY (a global broadcast would show every
|
|
2898
|
+
// viewer a send failure for a message only one of them typed).
|
|
2899
|
+
try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
|
|
2900
|
+
catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
|
|
2883
2901
|
});
|
|
2884
2902
|
}
|
|
2885
2903
|
} else if (msg.type === 'sdk-interrupt') {
|
|
@@ -2897,6 +2915,25 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2897
2915
|
// 切非运行态并等 4s 兜底才翻回,输出仍在流 —— 打日志让这类错配可观测。
|
|
2898
2916
|
console.warn('[SDK] sdk-interrupt ignored: isSdkMode=%s, handler=%s', isSdkMode, !!_sdkInterruptTurn);
|
|
2899
2917
|
}
|
|
2918
|
+
} else if (msg.type === 'sdk-retry-turn') {
|
|
2919
|
+
// Manual [retry] of the last failed (retryable) turn: pushes a synthetic
|
|
2920
|
+
// continue instruction instead of resending the user's original message.
|
|
2921
|
+
// Refusals go back ONLY to the initiating ws (a global broadcast would show
|
|
2922
|
+
// every viewer a reject for an action only one of them took).
|
|
2923
|
+
if (isSdkMode && _sdkRetryTurn) {
|
|
2924
|
+
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2925
|
+
_sdkRetryTurn(typeof msg.turnId === 'string' ? msg.turnId : null, anchor).then((r) => {
|
|
2926
|
+
if (!r || r.ok) return;
|
|
2927
|
+
// Echo the view stamps so a user who switched views before the reply
|
|
2928
|
+
// lands doesn't see the rejection (and its sdkRetry clear) in the new view.
|
|
2929
|
+
try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: r.reason || 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
|
|
2930
|
+
catch (err) { reportSwallowed('server.sdk-retry-reject', err); }
|
|
2931
|
+
}).catch((err) => {
|
|
2932
|
+
reportSwallowed('server.sdk-retry-turn', err);
|
|
2933
|
+
try { ws.send(JSON.stringify({ type: 'sdk-retry-state', phase: 'rejected', reason: 'unavailable', turnId: msg.turnId || null, project: msg.project, instanceKey: msg.instanceKey, ts: Date.now() })); }
|
|
2934
|
+
catch (e2) { reportSwallowed('server.sdk-retry-reject', e2); }
|
|
2935
|
+
});
|
|
2936
|
+
}
|
|
2900
2937
|
} else if (msg.type === 'queue-message') {
|
|
2901
2938
|
// Chat-composer busy-queue enqueue (terminal-hidden PTY mode). These queue-* types
|
|
2902
2939
|
// are deliberately NOT in the SDK no-op gate above: each branch routes by mode.
|
|
@@ -2907,7 +2944,9 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2907
2944
|
if (_sdkSendUserMessage) {
|
|
2908
2945
|
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2909
2946
|
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2910
|
-
|
|
2947
|
+
reportSwallowed('server.sdk-queue-relay', err);
|
|
2948
|
+
try { ws.send(JSON.stringify({ type: 'sdk-send-failed', reason: err?.code === 'not-delivered' ? 'not-delivered' : 'error', detail: String(err?.message || err), ts: Date.now() })); }
|
|
2949
|
+
catch (e2) { reportSwallowed('server.sdk-send-failed-send', e2); }
|
|
2911
2950
|
});
|
|
2912
2951
|
}
|
|
2913
2952
|
} else {
|
|
@@ -3007,7 +3046,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
3007
3046
|
_armRedrawRetries();
|
|
3008
3047
|
}
|
|
3009
3048
|
}
|
|
3010
|
-
} catch {}
|
|
3049
|
+
} catch (err) { reportSwallowed('server.ws-dispatch', err); }
|
|
3011
3050
|
});
|
|
3012
3051
|
|
|
3013
3052
|
ws.on('close', () => {
|
|
@@ -3805,6 +3844,10 @@ export function setSdkSendUserMessage(fn) { _sdkSendUserMessage = fn; }
|
|
|
3805
3844
|
let _sdkInterruptTurn = null;
|
|
3806
3845
|
export function setSdkInterruptTurn(fn) { _sdkInterruptTurn = fn; }
|
|
3807
3846
|
|
|
3847
|
+
/** Reference to sdk-manager's retryInterruptedTurn (set by cli.js after import). */
|
|
3848
|
+
let _sdkRetryTurn = null;
|
|
3849
|
+
export function setSdkRetryTurn(fn) { _sdkRetryTurn = fn; }
|
|
3850
|
+
|
|
3808
3851
|
/** Reference to sdk-manager's getPendingApprovals (set by cli.js after import). */
|
|
3809
3852
|
let _sdkGetPendingApprovals = null;
|
|
3810
3853
|
export function setSdkGetPendingApprovals(fn) { _sdkGetPendingApprovals = fn; }
|