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.
Files changed (33) hide show
  1. package/cli.js +5 -2
  2. package/dist/assets/App-GhvyKB0E.js +2 -0
  3. package/dist/assets/{MdxEditorPanel-C8Cij93y.js → MdxEditorPanel-DwwKGPIQ.js} +1 -1
  4. package/dist/assets/{Mobile-OKCVi8un.js → Mobile-CGiDFGRO.js} +1 -1
  5. package/dist/assets/{ProxyStatsModal-DGlngtM9.js → ProxyStatsModal-CI5PkF_B.js} +1 -1
  6. package/dist/assets/{index-Clt4dw8Y.js → index-D0_0_HIv.js} +2 -2
  7. package/dist/assets/seqResourceLoaders-DHr1E1Ye.js +2 -0
  8. package/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/server/interceptor.js +165 -14
  11. package/server/lib/git-diff.js +14 -8
  12. package/server/lib/scope-router.js +68 -0
  13. package/server/lib/sdk-manager.js +155 -32
  14. package/server/lib/sdk-session-index.js +25 -0
  15. package/server/lib/task-state.js +74 -34
  16. package/server/lib/turn-end-bridge.js +6 -1
  17. package/server/lib/v2/live-feed.js +7 -6
  18. package/server/lib/v2/view-router.js +48 -0
  19. package/server/lib/view-root.js +30 -10
  20. package/server/lib/workflow-watcher.js +13 -6
  21. package/server/lib/ws-mux.js +492 -0
  22. package/server/pty-manager.js +53 -0
  23. package/server/routes/ask-perm.js +13 -8
  24. package/server/routes/events.js +185 -86
  25. package/server/routes/project-meta.js +11 -4
  26. package/server/routes/proxy-stats.js +8 -4
  27. package/server/routes/resume.js +64 -10
  28. package/server/routes/session-pin.js +12 -2
  29. package/server/routes/workflow-journal.js +5 -2
  30. package/server/routes/workspaces.js +7 -1
  31. package/server/server.js +353 -120
  32. package/dist/assets/App-DtBVNYE7.js +0 -2
  33. 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-Clt4dw8Y.js"></script>
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.4",
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",
@@ -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
- if (requestEntry?.isStream) {
1050
- streamingState.active = true;
1051
- streamingState.requestId = requestId;
1052
- streamingState.startTime = Date.now();
1053
- streamingState.model = requestEntry.body?.model || '';
1054
- streamingState.bytesReceived = 0;
1055
- streamingState.chunksReceived = 0;
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 (requestEntry?.isStream) resetStreamingState();
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
- resetStreamingState();
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
- resetStreamingState();
1649
+ _resetStreamingBucket();
1499
1650
  }
1500
1651
  controller.close();
1501
1652
  break;
1502
1653
  }
1503
- streamingState.bytesReceived += value.byteLength;
1504
- streamingState.chunksReceived++;
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
- resetStreamingState();
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
- resetStreamingState();
1728
+ _resetStreamingBucket();
1578
1729
  }
1579
1730
  } else {
1580
1731
  // 对于非流式响应,可以安全读取body
@@ -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: 3000 });
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: 3000 });
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: 3000 });
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: 5000, maxBuffer: 5 * 1024 * 1024 }
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: 3000 });
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: 3000 });
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: 5000, maxBuffer: 5 * 1024 * 1024 });
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: 5000, maxBuffer: 5 * 1024 * 1024 });
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
+ }