cc-viewer 1.9.4 → 1.9.6
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 +5 -2
- package/dist/assets/App-GhvyKB0E.js +2 -0
- package/dist/assets/{MdxEditorPanel-C8Cij93y.js → MdxEditorPanel-DwwKGPIQ.js} +1 -1
- package/dist/assets/{Mobile-OKCVi8un.js → Mobile-CGiDFGRO.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DGlngtM9.js → ProxyStatsModal-CI5PkF_B.js} +1 -1
- package/dist/assets/{index-Clt4dw8Y.js → index-D0_0_HIv.js} +2 -2
- package/dist/assets/seqResourceLoaders-DHr1E1Ye.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +165 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +155 -32
- package/server/lib/sdk-session-index.js +25 -0
- package/server/lib/task-state.js +74 -34
- package/server/lib/turn-end-bridge.js +6 -1
- package/server/lib/v2/live-feed.js +7 -6
- package/server/lib/v2/view-router.js +48 -0
- package/server/lib/view-root.js +30 -10
- package/server/lib/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +492 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +185 -86
- package/server/routes/project-meta.js +11 -4
- package/server/routes/proxy-stats.js +8 -4
- package/server/routes/resume.js +64 -10
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +7 -1
- package/server/server.js +353 -120
- package/dist/assets/App-DtBVNYE7.js +0 -2
- package/dist/assets/seqResourceLoaders-zlIVjapi.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="./assets/index-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-D0_0_HIv.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-CS_S2mBN.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-B5l-VbBO.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-Cz09a1_I.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.6",
|
|
4
4
|
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/server/interceptor.js
CHANGED
|
@@ -26,6 +26,7 @@ import { writeJsonAtomic, mutateJsonSync } from './lib/json-store.js';
|
|
|
26
26
|
import { readSecretOr, writeSecret, removeSecret } from './lib/credential-access.js';
|
|
27
27
|
import { setProjectName } from './lib/project-state.js';
|
|
28
28
|
import { consumePendingForResume, writeSnapshot, projectKeyForCwd } from './lib/system-prompt-snapshots.js';
|
|
29
|
+
import { BOUND_SCOPE, scopeKeyOf } from './lib/scope-router.js';
|
|
29
30
|
import { liveSystemPromptEnabled, getLaunchSystemPromptInfo, getLiveEntry, putLiveEntry, selectEntriesForModel, applyLiveSystem, knownInjectedTexts } from './lib/system-prompt-live.js';
|
|
30
31
|
import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
|
|
31
32
|
|
|
@@ -42,6 +43,126 @@ let _liveProtocol = 'http';
|
|
|
42
43
|
export function setLivePort(port, protocol) { _livePort = port ? String(port) : null; _liveProtocol = protocol || 'http'; }
|
|
43
44
|
|
|
44
45
|
// 流式请求的实时状态(供 server.js SSE 推送)
|
|
46
|
+
// Per-workspace (2026-10): state is keyed by producer scope ('' = the bound
|
|
47
|
+
// project — the legacy singleton bucket; a foreign project/instance gets its
|
|
48
|
+
// own bucket). `streamingState` below is literally the '' bucket's object, so
|
|
49
|
+
// every legacy reader/writer (IM bridge, chat-queue, tests) keeps working
|
|
50
|
+
// unchanged; scoped callers go through streamingStateFor()/resetStreamingStateFor().
|
|
51
|
+
const _SCOPE_STATE_CAP = 16;
|
|
52
|
+
const _streamingByScope = new Map(); // scopeKey → {active, requestId, startTime, model, bytesReceived, chunksReceived}
|
|
53
|
+
const _turnByScope = new Map(); // scopeKey → {active, sessionId}
|
|
54
|
+
|
|
55
|
+
function _newStreamingBucket() {
|
|
56
|
+
return { active: false, requestId: null, startTime: null, model: null, bytesReceived: 0, chunksReceived: 0 };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Producer scope for one intercepted request entry: the bound project's own
|
|
60
|
+
* claude (self-report header present but matching the bound name) collapses to
|
|
61
|
+
* ''; a foreign project keeps its name, narrowed by instance when present. */
|
|
62
|
+
export function scopeKeyForEntry(entry) {
|
|
63
|
+
if (!entry || typeof entry !== 'object') return BOUND_SCOPE;
|
|
64
|
+
const p = (typeof entry._resumeProject === 'string' && entry._resumeProject) || '';
|
|
65
|
+
if (!p || p === (_projectName || '')) return BOUND_SCOPE;
|
|
66
|
+
return scopeKeyOf(p, (typeof entry._ccvInstance === 'string' && entry._ccvInstance) || null);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Get (or create) the streaming bucket for a scope. LRU-capped: an evicted
|
|
70
|
+
* bucket is only ever an INACTIVE tail — the bound bucket and any active
|
|
71
|
+
* (in-flight) bucket are never evicted, or a mid-turn eviction would either
|
|
72
|
+
* resurrect as a spurious rising edge or strand the spinner on. */
|
|
73
|
+
export function streamingStateFor(scopeKey) {
|
|
74
|
+
const k = (typeof scopeKey === 'string' && scopeKey) || BOUND_SCOPE;
|
|
75
|
+
let b = _streamingByScope.get(k);
|
|
76
|
+
if (b) {
|
|
77
|
+
// refresh recency
|
|
78
|
+
_streamingByScope.delete(k);
|
|
79
|
+
_streamingByScope.set(k, b);
|
|
80
|
+
return b;
|
|
81
|
+
}
|
|
82
|
+
b = k === BOUND_SCOPE ? streamingState : _newStreamingBucket();
|
|
83
|
+
_streamingByScope.set(k, b);
|
|
84
|
+
_evictScopeMaps(_streamingByScope);
|
|
85
|
+
return b;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _evictScopeMaps(map) {
|
|
89
|
+
while (map.size > _SCOPE_STATE_CAP) {
|
|
90
|
+
let evicted = false;
|
|
91
|
+
for (const [k, v] of map) {
|
|
92
|
+
if (k === BOUND_SCOPE) continue;
|
|
93
|
+
if (v && v.active) continue; // never evict an in-flight bucket
|
|
94
|
+
map.delete(k);
|
|
95
|
+
evicted = true;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
if (!evicted) break; // everything left is bound or active — stop
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Reset one scope's streaming bucket in place (same object identity kept). */
|
|
103
|
+
export function resetStreamingStateFor(scopeKey) {
|
|
104
|
+
const b = streamingStateFor(scopeKey);
|
|
105
|
+
b.active = false;
|
|
106
|
+
b.requestId = null;
|
|
107
|
+
b.startTime = null;
|
|
108
|
+
b.model = null;
|
|
109
|
+
b.bytesReceived = 0;
|
|
110
|
+
b.chunksReceived = 0;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Iterate active streaming buckets (server streaming-status timer). */
|
|
114
|
+
export function activeStreamingScopes() {
|
|
115
|
+
const out = [];
|
|
116
|
+
for (const [k, v] of _streamingByScope) if (v && v.active) out.push(k);
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Iterate active turn buckets (server turn-active heartbeat). */
|
|
121
|
+
export function activeTurnScopes() {
|
|
122
|
+
const out = [];
|
|
123
|
+
for (const [k, v] of _turnByScope) if (v && v.active) out.push(k);
|
|
124
|
+
return out;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Get (or create) the turn bucket for a scope. */
|
|
128
|
+
export function turnStateFor(scopeKey) {
|
|
129
|
+
const k = (typeof scopeKey === 'string' && scopeKey) || BOUND_SCOPE;
|
|
130
|
+
let b = _turnByScope.get(k);
|
|
131
|
+
if (b) {
|
|
132
|
+
_turnByScope.delete(k);
|
|
133
|
+
_turnByScope.set(k, b);
|
|
134
|
+
return b;
|
|
135
|
+
}
|
|
136
|
+
b = k === BOUND_SCOPE ? turnActive : { active: false };
|
|
137
|
+
_turnByScope.set(k, b);
|
|
138
|
+
_evictScopeMaps(_turnByScope);
|
|
139
|
+
return b;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Tear down every non-bound scope bucket (stop/start cycles, workspace stop). */
|
|
143
|
+
export function clearScopeState() {
|
|
144
|
+
for (const k of [..._streamingByScope.keys()]) if (k !== BOUND_SCOPE) _streamingByScope.delete(k);
|
|
145
|
+
for (const k of [..._turnByScope.keys()]) if (k !== BOUND_SCOPE) _turnByScope.delete(k);
|
|
146
|
+
resetStreamingState();
|
|
147
|
+
turnActive.active = false;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Tear down ONE scope's buckets (a PTY died / its workspace stopped mid-turn).
|
|
151
|
+
* Without this a silently-killed workspace leaves its bucket `active:true`
|
|
152
|
+
* forever — the heartbeat/streaming timer keeps re-broadcasting it and the
|
|
153
|
+
* client's staleness escape is perpetually reset by those same frames (the
|
|
154
|
+
* permanent-spinner leak). The bound bucket resets in place (identity kept). */
|
|
155
|
+
export function clearScopeStateFor(scopeKey) {
|
|
156
|
+
const k = (typeof scopeKey === 'string' && scopeKey) || BOUND_SCOPE;
|
|
157
|
+
if (k === BOUND_SCOPE) {
|
|
158
|
+
resetStreamingState();
|
|
159
|
+
turnActive.active = false;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
_streamingByScope.delete(k);
|
|
163
|
+
_turnByScope.delete(k);
|
|
164
|
+
}
|
|
165
|
+
|
|
45
166
|
export const streamingState = { active: false, requestId: null, startTime: null, model: null, bytesReceived: 0, chunksReceived: 0 };
|
|
46
167
|
export function resetStreamingState() {
|
|
47
168
|
streamingState.active = false;
|
|
@@ -51,6 +172,9 @@ export function resetStreamingState() {
|
|
|
51
172
|
streamingState.bytesReceived = 0;
|
|
52
173
|
streamingState.chunksReceived = 0;
|
|
53
174
|
}
|
|
175
|
+
// The bound bucket IS the legacy singleton — register it so scoped iteration
|
|
176
|
+
// sees it too (identity must stay this exact object for legacy readers).
|
|
177
|
+
_streamingByScope.set(BOUND_SCOPE, streamingState);
|
|
54
178
|
|
|
55
179
|
// Turn-level active flag (whole user-prompt turn, incl. local tool execution
|
|
56
180
|
// between API calls). Lives here — next to streamingState — because the PTY hook
|
|
@@ -62,6 +186,20 @@ export function resetStreamingState() {
|
|
|
62
186
|
// state-only on purpose.
|
|
63
187
|
export const turnActive = { active: false };
|
|
64
188
|
export function setTurnActive(v) { turnActive.active = !!v; }
|
|
189
|
+
// The bound turn bucket IS the legacy singleton (same posture as streamingState).
|
|
190
|
+
_turnByScope.set(BOUND_SCOPE, turnActive);
|
|
191
|
+
|
|
192
|
+
/** Producer-scope stamp for a live stream chunk payload: raw (unnormalized)
|
|
193
|
+
* _resumeProject/_ccvInstance — the ask-perm route normalizes against the
|
|
194
|
+
* bound project. Absent when the producer self-reported nothing (bound claude
|
|
195
|
+
* without headers ⇒ the route falls back to bound, today's behavior). */
|
|
196
|
+
export function _scopeStampForChunk(entry) {
|
|
197
|
+
if (!entry || typeof entry !== 'object') return {};
|
|
198
|
+
const out = {};
|
|
199
|
+
if (typeof entry._resumeProject === 'string' && entry._resumeProject) out.project = entry._resumeProject;
|
|
200
|
+
if (typeof entry._ccvInstance === 'string' && entry._ccvInstance) out.instance = entry._ccvInstance;
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
65
203
|
|
|
66
204
|
// ─── IM 逐字流式的文本源 ───
|
|
67
205
|
// 钉钉 AI 卡片流式(server/lib/adapters/dingtalk-adapter.js)需要「对话过程中」的主 agent 增量文本。
|
|
@@ -1046,14 +1184,22 @@ export function setupInterceptor() {
|
|
|
1046
1184
|
if (requestEntry) requestEntry.requestId = requestId;
|
|
1047
1185
|
|
|
1048
1186
|
// 流式请求状态追踪(仅对 Claude API 流式请求)
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1187
|
+
// Per-workspace: the bucket belongs to the request's producer scope ('' =
|
|
1188
|
+
// bound). The KEY is captured once here — NOT re-derived at the stream tail:
|
|
1189
|
+
// `_projectName` can be rebound mid-flight (workspace launch/stop), and a
|
|
1190
|
+
// recomputed key would reset a DIFFERENT bucket while the activation bucket
|
|
1191
|
+
// stays `active` forever (the stuck-spinner regression).
|
|
1192
|
+
const streamingScopeKey = requestEntry?.isStream ? scopeKeyForEntry(requestEntry) : null;
|
|
1193
|
+
const streamingBucket = streamingScopeKey !== null ? streamingStateFor(streamingScopeKey) : null;
|
|
1194
|
+
if (streamingBucket) {
|
|
1195
|
+
streamingBucket.active = true;
|
|
1196
|
+
streamingBucket.requestId = requestId;
|
|
1197
|
+
streamingBucket.startTime = Date.now();
|
|
1198
|
+
streamingBucket.model = requestEntry.body?.model || '';
|
|
1199
|
+
streamingBucket.bytesReceived = 0;
|
|
1200
|
+
streamingBucket.chunksReceived = 0;
|
|
1056
1201
|
}
|
|
1202
|
+
const _resetStreamingBucket = () => { if (streamingScopeKey !== null) resetStreamingStateFor(streamingScopeKey); };
|
|
1057
1203
|
|
|
1058
1204
|
// Proxy profile request rewriting —— 按角色(main/subagent/teammate)解析有效 profile。
|
|
1059
1205
|
// 经 classifyProxyRole 正向分类(teammate 进程恒 teammate;utility 跟随主角色;
|
|
@@ -1345,7 +1491,7 @@ export function setupInterceptor() {
|
|
|
1345
1491
|
try {
|
|
1346
1492
|
response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
|
|
1347
1493
|
} catch (err) {
|
|
1348
|
-
if (
|
|
1494
|
+
if (streamingBucket) _resetStreamingBucket();
|
|
1349
1495
|
throw err;
|
|
1350
1496
|
}
|
|
1351
1497
|
|
|
@@ -1405,6 +1551,10 @@ export function setupInterceptor() {
|
|
|
1405
1551
|
// session uuid so an attached client can show only ITS sid's typewriter
|
|
1406
1552
|
// and drop the other session's. Additive — older clients ignore it.
|
|
1407
1553
|
sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
|
|
1554
|
+
// Per-workspace (2026-10): the typewriter's producer scope — without
|
|
1555
|
+
// it ask-perm's project filter fell back to the BOUND project (dead
|
|
1556
|
+
// filter) and a parallel workspace's stream painted on the bound view.
|
|
1557
|
+
..._scopeStampForChunk(requestEntry),
|
|
1408
1558
|
};
|
|
1409
1559
|
sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
|
|
1410
1560
|
// 413 → 禁用当次流式,后续全由最终 v2 completion 交付
|
|
@@ -1431,6 +1581,7 @@ export function setupInterceptor() {
|
|
|
1431
1581
|
// B3: same sessionId tag on the skeleton (seq 0) so the overlay filter
|
|
1432
1582
|
// keys on (timestamp, sessionId) from the very first frame.
|
|
1433
1583
|
sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
|
|
1584
|
+
..._scopeStampForChunk(requestEntry),
|
|
1434
1585
|
}, 0, (ok) => { if (!ok) liveStreamEnabled = false; });
|
|
1435
1586
|
}
|
|
1436
1587
|
|
|
@@ -1488,20 +1639,20 @@ export function setupInterceptor() {
|
|
|
1488
1639
|
streamedChunks = [];
|
|
1489
1640
|
streamedContentLen = 0;
|
|
1490
1641
|
requestEntry.response = null;
|
|
1491
|
-
|
|
1642
|
+
_resetStreamingBucket();
|
|
1492
1643
|
} catch (err) {
|
|
1493
1644
|
requestEntry.response.body = fullContent.slice(0, 1000);
|
|
1494
1645
|
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1495
1646
|
streamedChunks = [];
|
|
1496
1647
|
streamedContentLen = 0;
|
|
1497
1648
|
requestEntry.response = null;
|
|
1498
|
-
|
|
1649
|
+
_resetStreamingBucket();
|
|
1499
1650
|
}
|
|
1500
1651
|
controller.close();
|
|
1501
1652
|
break;
|
|
1502
1653
|
}
|
|
1503
|
-
|
|
1504
|
-
|
|
1654
|
+
streamingBucket.bytesReceived += value.byteLength;
|
|
1655
|
+
streamingBucket.chunksReceived++;
|
|
1505
1656
|
const chunk = decoder.decode(value, { stream: true });
|
|
1506
1657
|
streamedChunks.push(chunk);
|
|
1507
1658
|
streamedContentLen += chunk.length;
|
|
@@ -1554,7 +1705,7 @@ export function setupInterceptor() {
|
|
|
1554
1705
|
}
|
|
1555
1706
|
}
|
|
1556
1707
|
} catch (err) {
|
|
1557
|
-
|
|
1708
|
+
_resetStreamingBucket();
|
|
1558
1709
|
controller.error(err);
|
|
1559
1710
|
}
|
|
1560
1711
|
}
|
|
@@ -1574,7 +1725,7 @@ export function setupInterceptor() {
|
|
|
1574
1725
|
body: '[Streaming Response - Capture failed]'
|
|
1575
1726
|
};
|
|
1576
1727
|
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1577
|
-
|
|
1728
|
+
_resetStreamingBucket();
|
|
1578
1729
|
}
|
|
1579
1730
|
} else {
|
|
1580
1731
|
// 对于非流式响应,可以安全读取body
|
package/server/lib/git-diff.js
CHANGED
|
@@ -11,6 +11,12 @@ const execFileAsync = (cmd, args, opts) => _execFileAsyncRaw(cmd, args, { window
|
|
|
11
11
|
const UNTRACKED_MAX_BYTES = 5 * 1024 * 1024;
|
|
12
12
|
const BINARY_PROBE_BYTES = 8192;
|
|
13
13
|
|
|
14
|
+
// Per-invocation git timeout. The fake-git test fixture (a PATH-injected POSIX
|
|
15
|
+
// sh script) is far slower to exec than real git, so under the full-suite
|
|
16
|
+
// concurrency load the old 3s wall was flaky. 10s keeps production responsive
|
|
17
|
+
// while leaving ample headroom for the slower fixture.
|
|
18
|
+
const GIT_TIMEOUT_MS = 10000;
|
|
19
|
+
|
|
14
20
|
// Reject only `..` as a path *segment* (start, between slashes, or end).
|
|
15
21
|
// Substring match (`'node..modules'.includes('..')`) was rejecting valid paths.
|
|
16
22
|
const PATH_TRAVERSAL = /(?:^|\/)\.\.(?:\/|$)/;
|
|
@@ -86,7 +92,7 @@ export function countUntrackedLines(cwd, file) {
|
|
|
86
92
|
export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
|
|
87
93
|
let branch = null;
|
|
88
94
|
try {
|
|
89
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf-8', timeout:
|
|
95
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS });
|
|
90
96
|
branch = stdout.trim();
|
|
91
97
|
} catch {
|
|
92
98
|
return { commits: [], hasUpstream: false, branch: null, upstream: null };
|
|
@@ -97,7 +103,7 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
|
|
|
97
103
|
let upstream = null;
|
|
98
104
|
if (!detached) {
|
|
99
105
|
try {
|
|
100
|
-
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, encoding: 'utf-8', timeout:
|
|
106
|
+
const { stdout } = await execFileAsync('git', ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'], { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS });
|
|
101
107
|
const name = stdout.trim();
|
|
102
108
|
if (name && SAFE_REF.test(name)) upstream = name;
|
|
103
109
|
} catch { /* no upstream configured — fall through to the remote-refs fallback */ }
|
|
@@ -199,7 +205,7 @@ export async function getUnpushedCommits(cwd, { maxCommits = 100 } = {}) {
|
|
|
199
205
|
let truncated = commits.length === maxCommits;
|
|
200
206
|
if (truncated) {
|
|
201
207
|
try {
|
|
202
|
-
const r = await execFileAsync('git', ['rev-list', '--count', ...rangeArgs], { cwd, encoding: 'utf-8', timeout:
|
|
208
|
+
const r = await execFileAsync('git', ['rev-list', '--count', ...rangeArgs], { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS });
|
|
203
209
|
const parsed = parseInt(r.stdout.trim(), 10);
|
|
204
210
|
if (Number.isFinite(parsed) && parsed > 0) {
|
|
205
211
|
totalCount = parsed;
|
|
@@ -242,7 +248,7 @@ export async function getGitDiffs(cwd, files, commitHash) {
|
|
|
242
248
|
const { stdout } = await execFileAsync(
|
|
243
249
|
'git',
|
|
244
250
|
['diff-tree', '-r', '--no-commit-id', '--name-status', '--root', commitHash],
|
|
245
|
-
{ cwd, encoding: 'utf-8', timeout:
|
|
251
|
+
{ cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS, maxBuffer: 5 * 1024 * 1024 }
|
|
246
252
|
);
|
|
247
253
|
commitStatusMap = new Map();
|
|
248
254
|
for (const line of stdout.split('\n')) {
|
|
@@ -272,7 +278,7 @@ export async function getGitDiffs(cwd, files, commitHash) {
|
|
|
272
278
|
is_new = status === 'A';
|
|
273
279
|
is_deleted = status === 'D';
|
|
274
280
|
} else {
|
|
275
|
-
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout:
|
|
281
|
+
const { stdout: statusOutput } = await execFileAsync('git', ['status', '--porcelain', '--', file], { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS });
|
|
276
282
|
if (!statusOutput.trim()) continue;
|
|
277
283
|
status = statusOutput.substring(0, 2).trim();
|
|
278
284
|
is_new = status === 'A' || status === '??';
|
|
@@ -286,7 +292,7 @@ export async function getGitDiffs(cwd, files, commitHash) {
|
|
|
286
292
|
const numstatArgs = useCommit
|
|
287
293
|
? ['diff-tree', '-r', '--no-commit-id', '--numstat', '--root', commitHash, '--', file]
|
|
288
294
|
: ['diff', '--numstat', 'HEAD', '--', file];
|
|
289
|
-
const { stdout: diffCheck } = await execFileAsync('git', numstatArgs, { cwd, encoding: 'utf-8', timeout:
|
|
295
|
+
const { stdout: diffCheck } = await execFileAsync('git', numstatArgs, { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS });
|
|
290
296
|
if (diffCheck.includes('-\t-\t')) {
|
|
291
297
|
is_binary = true;
|
|
292
298
|
}
|
|
@@ -301,7 +307,7 @@ export async function getGitDiffs(cwd, files, commitHash) {
|
|
|
301
307
|
if (!is_new) {
|
|
302
308
|
try {
|
|
303
309
|
const oldRef = useCommit ? `${commitHash}^:${file}` : `HEAD:${file}`;
|
|
304
|
-
const { stdout } = await execFileAsync('git', ['show', oldRef], { cwd, encoding: 'utf-8', timeout:
|
|
310
|
+
const { stdout } = await execFileAsync('git', ['show', oldRef], { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS, maxBuffer: 5 * 1024 * 1024 });
|
|
305
311
|
old_content = stdout;
|
|
306
312
|
} catch {
|
|
307
313
|
old_content = '';
|
|
@@ -312,7 +318,7 @@ export async function getGitDiffs(cwd, files, commitHash) {
|
|
|
312
318
|
if (!is_deleted) {
|
|
313
319
|
if (useCommit) {
|
|
314
320
|
try {
|
|
315
|
-
const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${file}`], { cwd, encoding: 'utf-8', timeout:
|
|
321
|
+
const { stdout } = await execFileAsync('git', ['show', `${commitHash}:${file}`], { cwd, encoding: 'utf-8', timeout: GIT_TIMEOUT_MS, maxBuffer: 5 * 1024 * 1024 });
|
|
316
322
|
new_content = stdout;
|
|
317
323
|
if (Buffer.byteLength(new_content, 'utf-8') > 5 * 1024 * 1024) {
|
|
318
324
|
diffs.push({ file, status, is_large: true, size: Buffer.byteLength(new_content, 'utf-8') });
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Per-workspace SSE scope keys (2026-10): one scope = one broadcast domain on
|
|
2
|
+
// the server, one persistent channel on the client. The BOUND project's scope
|
|
3
|
+
// key is the EMPTY STRING sentinel — every producer/state map then collapses to
|
|
4
|
+
// exactly one bucket when no parallel workspace exists (byte-identical legacy
|
|
5
|
+
// behavior). Pure module, no imports.
|
|
6
|
+
|
|
7
|
+
/** Bound-scope sentinel: never a real project name (projectKeyForCwd/sanitizePathComponent
|
|
8
|
+
* never yield ''). */
|
|
9
|
+
export const BOUND_SCOPE = '';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build the canonical scope key for a (project, instance) pair.
|
|
13
|
+
* - no project ⇒ BOUND_SCOPE ('')
|
|
14
|
+
* - project alone ⇒ project
|
|
15
|
+
* - project + instance ⇒ `project\x00instance` (NUL-separated — a project name
|
|
16
|
+
* can never contain NUL, so the composite is collision-free)
|
|
17
|
+
* @param {string|null|undefined} project
|
|
18
|
+
* @param {string|null|undefined} instance
|
|
19
|
+
* @returns {string} '' | project | `project\x00instance`
|
|
20
|
+
*/
|
|
21
|
+
export function scopeKeyOf(project, instance) {
|
|
22
|
+
const p = (typeof project === 'string' && project) ? project : '';
|
|
23
|
+
if (!p) return BOUND_SCOPE;
|
|
24
|
+
const i = (typeof instance === 'string' && instance) ? instance : '';
|
|
25
|
+
return i ? `${p}\x00${i}` : p;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Inverse of scopeKeyOf. '' ⇒ the bound scope ({project:'', instance:null}).
|
|
30
|
+
* @param {string} key
|
|
31
|
+
* @returns {{project: string, instance: string|null}}
|
|
32
|
+
*/
|
|
33
|
+
export function parseScopeKey(key) {
|
|
34
|
+
if (typeof key !== 'string' || !key) return { project: '', instance: null };
|
|
35
|
+
const at = key.indexOf('\x00');
|
|
36
|
+
if (at === -1) return { project: key, instance: null };
|
|
37
|
+
return { project: key.slice(0, at), instance: key.slice(at + 1) || null };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Normalize a producer-reported scope against the bound project: the bound
|
|
42
|
+
* project (or no project) collapses to the '' sentinel so its state bucket is
|
|
43
|
+
* THE legacy singleton bucket. A foreign project keeps its name; its instance
|
|
44
|
+
* is preserved when present.
|
|
45
|
+
* @param {{project?: string|null, instance?: string|null}} scope
|
|
46
|
+
* @param {string} boundProject - the server's bound project (`_projectName`)
|
|
47
|
+
* @returns {{project: string, instance: string|null}} normalized scope (project
|
|
48
|
+
* '' ⇒ bound; instance null ⇒ project-wide)
|
|
49
|
+
*/
|
|
50
|
+
export function normalizeProducerScope(scope, boundProject) {
|
|
51
|
+
const p = (scope && typeof scope.project === 'string' && scope.project) || '';
|
|
52
|
+
const i = (scope && typeof scope.instance === 'string' && scope.instance) || null;
|
|
53
|
+
const bound = boundProject || '';
|
|
54
|
+
if (!p || p === bound) return { project: '', instance: null };
|
|
55
|
+
return { project: p, instance: i };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Scope key for a producer-reported scope, already normalized against the
|
|
60
|
+
* bound project (normalizeProducerScope + scopeKeyOf in one step).
|
|
61
|
+
* @param {{project?: string|null, instance?: string|null}} scope
|
|
62
|
+
* @param {string} boundProject
|
|
63
|
+
* @returns {string}
|
|
64
|
+
*/
|
|
65
|
+
export function scopeKeyForProducer(scope, boundProject) {
|
|
66
|
+
const n = normalizeProducerScope(scope, boundProject);
|
|
67
|
+
return scopeKeyOf(n.project, n.instance);
|
|
68
|
+
}
|