cc-viewer 1.9.3 → 1.9.5
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 +5 -5
- package/cli.js +29 -2
- package/dist/assets/App-Bj_-wfrB.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-BQBQkr2V.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-8VYl6n39.js} +1 -1
- package/dist/assets/index-CloXv0sm.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +176 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +1524 -895
- 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/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +473 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +190 -74
- package/server/routes/resume.js +79 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +31 -2
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +616 -108
- package/dist/assets/App-BzXO6ATr.js +0 -2
- package/dist/assets/App-DJ4yNvwz.css +0 -1
- package/dist/assets/index-Dq5UPr6v.js +0 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.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-CloXv0sm.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.5",
|
|
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,34 @@ 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);
|
|
178
|
+
|
|
179
|
+
// Turn-level active flag (whole user-prompt turn, incl. local tool execution
|
|
180
|
+
// between API calls). Lives here — next to streamingState — because the PTY hook
|
|
181
|
+
// paths (server.js onTaskEvent / queue-suppress) and the SSE connect replay
|
|
182
|
+
// (routes/events.js) share the value, and interceptor.js is already imported by
|
|
183
|
+
// both. SDK mode does NOT use this flag — it relays session_state_changed over
|
|
184
|
+
// the terminal WS instead (sdk-manager `_announceSessionState`). Transitions are
|
|
185
|
+
// broadcast by the caller (server.js `_broadcastTurnActive`); this module is
|
|
186
|
+
// state-only on purpose.
|
|
187
|
+
export const turnActive = { active: false };
|
|
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
|
+
}
|
|
54
203
|
|
|
55
204
|
// ─── IM 逐字流式的文本源 ───
|
|
56
205
|
// 钉钉 AI 卡片流式(server/lib/adapters/dingtalk-adapter.js)需要「对话过程中」的主 agent 增量文本。
|
|
@@ -1035,14 +1184,22 @@ export function setupInterceptor() {
|
|
|
1035
1184
|
if (requestEntry) requestEntry.requestId = requestId;
|
|
1036
1185
|
|
|
1037
1186
|
// 流式请求状态追踪(仅对 Claude API 流式请求)
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
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;
|
|
1045
1201
|
}
|
|
1202
|
+
const _resetStreamingBucket = () => { if (streamingScopeKey !== null) resetStreamingStateFor(streamingScopeKey); };
|
|
1046
1203
|
|
|
1047
1204
|
// Proxy profile request rewriting —— 按角色(main/subagent/teammate)解析有效 profile。
|
|
1048
1205
|
// 经 classifyProxyRole 正向分类(teammate 进程恒 teammate;utility 跟随主角色;
|
|
@@ -1334,7 +1491,7 @@ export function setupInterceptor() {
|
|
|
1334
1491
|
try {
|
|
1335
1492
|
response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
|
|
1336
1493
|
} catch (err) {
|
|
1337
|
-
if (
|
|
1494
|
+
if (streamingBucket) _resetStreamingBucket();
|
|
1338
1495
|
throw err;
|
|
1339
1496
|
}
|
|
1340
1497
|
|
|
@@ -1394,6 +1551,10 @@ export function setupInterceptor() {
|
|
|
1394
1551
|
// session uuid so an attached client can show only ITS sid's typewriter
|
|
1395
1552
|
// and drop the other session's. Additive — older clients ignore it.
|
|
1396
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),
|
|
1397
1558
|
};
|
|
1398
1559
|
sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
|
|
1399
1560
|
// 413 → 禁用当次流式,后续全由最终 v2 completion 交付
|
|
@@ -1420,6 +1581,7 @@ export function setupInterceptor() {
|
|
|
1420
1581
|
// B3: same sessionId tag on the skeleton (seq 0) so the overlay filter
|
|
1421
1582
|
// keys on (timestamp, sessionId) from the very first frame.
|
|
1422
1583
|
sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
|
|
1584
|
+
..._scopeStampForChunk(requestEntry),
|
|
1423
1585
|
}, 0, (ok) => { if (!ok) liveStreamEnabled = false; });
|
|
1424
1586
|
}
|
|
1425
1587
|
|
|
@@ -1477,20 +1639,20 @@ export function setupInterceptor() {
|
|
|
1477
1639
|
streamedChunks = [];
|
|
1478
1640
|
streamedContentLen = 0;
|
|
1479
1641
|
requestEntry.response = null;
|
|
1480
|
-
|
|
1642
|
+
_resetStreamingBucket();
|
|
1481
1643
|
} catch (err) {
|
|
1482
1644
|
requestEntry.response.body = fullContent.slice(0, 1000);
|
|
1483
1645
|
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1484
1646
|
streamedChunks = [];
|
|
1485
1647
|
streamedContentLen = 0;
|
|
1486
1648
|
requestEntry.response = null;
|
|
1487
|
-
|
|
1649
|
+
_resetStreamingBucket();
|
|
1488
1650
|
}
|
|
1489
1651
|
controller.close();
|
|
1490
1652
|
break;
|
|
1491
1653
|
}
|
|
1492
|
-
|
|
1493
|
-
|
|
1654
|
+
streamingBucket.bytesReceived += value.byteLength;
|
|
1655
|
+
streamingBucket.chunksReceived++;
|
|
1494
1656
|
const chunk = decoder.decode(value, { stream: true });
|
|
1495
1657
|
streamedChunks.push(chunk);
|
|
1496
1658
|
streamedContentLen += chunk.length;
|
|
@@ -1543,7 +1705,7 @@ export function setupInterceptor() {
|
|
|
1543
1705
|
}
|
|
1544
1706
|
}
|
|
1545
1707
|
} catch (err) {
|
|
1546
|
-
|
|
1708
|
+
_resetStreamingBucket();
|
|
1547
1709
|
controller.error(err);
|
|
1548
1710
|
}
|
|
1549
1711
|
}
|
|
@@ -1563,7 +1725,7 @@ export function setupInterceptor() {
|
|
|
1563
1725
|
body: '[Streaming Response - Capture failed]'
|
|
1564
1726
|
};
|
|
1565
1727
|
_writeCompletedEntry(requestEntry, _v2Handle);
|
|
1566
|
-
|
|
1728
|
+
_resetStreamingBucket();
|
|
1567
1729
|
}
|
|
1568
1730
|
} else {
|
|
1569
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
|
+
}
|