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.
Files changed (37) hide show
  1. package/README.md +5 -5
  2. package/cli.js +29 -2
  3. package/dist/assets/App-Bj_-wfrB.js +2 -0
  4. package/dist/assets/App-DuDeDOuX.css +1 -0
  5. package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
  6. package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-BQBQkr2V.js} +1 -1
  7. package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-8VYl6n39.js} +1 -1
  8. package/dist/assets/index-CloXv0sm.js +2 -0
  9. package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
  10. package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
  11. package/dist/index.html +1 -1
  12. package/package.json +1 -1
  13. package/server/interceptor.js +176 -14
  14. package/server/lib/git-diff.js +14 -8
  15. package/server/lib/scope-router.js +68 -0
  16. package/server/lib/sdk-manager.js +1524 -895
  17. package/server/lib/task-state.js +74 -34
  18. package/server/lib/turn-end-bridge.js +6 -1
  19. package/server/lib/v2/live-feed.js +7 -6
  20. package/server/lib/v2/view-router.js +48 -0
  21. package/server/lib/workflow-watcher.js +13 -6
  22. package/server/lib/ws-mux.js +473 -0
  23. package/server/pty-manager.js +53 -0
  24. package/server/routes/ask-perm.js +13 -8
  25. package/server/routes/events.js +190 -74
  26. package/server/routes/resume.js +79 -1
  27. package/server/routes/sdk-context.js +24 -0
  28. package/server/routes/sdk-mcp-status.js +24 -0
  29. package/server/routes/session-pin.js +12 -2
  30. package/server/routes/workflow-journal.js +5 -2
  31. package/server/routes/workspaces.js +31 -2
  32. package/server/scratch-pty-manager.js +127 -46
  33. package/server/server.js +616 -108
  34. package/dist/assets/App-BzXO6ATr.js +0 -2
  35. package/dist/assets/App-DJ4yNvwz.css +0 -1
  36. package/dist/assets/index-Dq5UPr6v.js +0 -2
  37. package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
@@ -33,10 +33,13 @@ function workflowJournal(req, res, parsedUrl, isLocal, deps) {
33
33
  }
34
34
 
35
35
  // 惰性 arm:只监视真有人在看的 session workflows 目录,后续 journal 覆写经 SSE 实时推送。
36
+ // Per-workspace: the server-supplied filter routes the frames to only this
37
+ // project's viewers (absent ⇒ legacy broadcast-to-all).
38
+ const _wfFilter = typeof deps.filterClientsByViewProject === 'function' ? deps.filterClientsByViewProject : null;
36
39
  const wfDir = resolveWorkflowsDir(session, project);
37
40
  if (wfDir && deps && Array.isArray(deps.clients)) {
38
41
  try {
39
- armWorkflowWatch({ workflowsDir: wfDir, sessionId: session, project, clients: deps.clients });
42
+ armWorkflowWatch({ workflowsDir: wfDir, sessionId: session, project, clients: deps.clients, clientFilter: _wfFilter });
40
43
  } catch {}
41
44
  }
42
45
 
@@ -57,7 +60,7 @@ function workflowJournal(req, res, parsedUrl, isLocal, deps) {
57
60
  const live = runDir ? deriveLiveJournal(runDir, runId) : null;
58
61
  if (live) {
59
62
  if (deps && Array.isArray(deps.clients)) {
60
- try { armWorkflowLiveWatch({ runDir, runId, sessionId: session, project, clients: deps.clients }); } catch {}
63
+ try { armWorkflowLiveWatch({ runDir, runId, sessionId: session, project, clients: deps.clients, clientFilter: _wfFilter }); } catch {}
61
64
  }
62
65
  res.writeHead(200, { 'Content-Type': 'application/json' });
63
66
  res.end(JSON.stringify({ ok: true, data: live }));
@@ -1,7 +1,7 @@
1
1
  // Workspace routes (moved verbatim from server.js handleRequest).
2
2
  import { existsSync, statSync } from 'node:fs';
3
3
  import { basename } from 'node:path';
4
- import { initForWorkspace, resetWorkspace, getLiveLogSource, markContinuedLaunch, markForkSession, markResumeSession, isContinuedLaunch } from '../interceptor.js';
4
+ import { initForWorkspace, resetWorkspace, getLiveLogSource, markContinuedLaunch, markForkSession, markResumeSession, isContinuedLaunch, clearScopeState } from '../interceptor.js';
5
5
  import { LOG_DIR } from '../../findcc.js';
6
6
  import { migrationStatus } from '../lib/v2/migrate-prompt.js';
7
7
  import { reportSwallowed } from '@ccv/core/error-report';
@@ -85,9 +85,26 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
85
85
  // it to the latest main session.
86
86
  if (mergedArgs.some((a) => RESUME_FLAGS.has(a))) markResumeSession();
87
87
 
88
+ // SDK mode (2026-10-12, per-cwd multi-session): launch = register/activate this cwd's
89
+ // resident SDK session — NOT a phantom claude PTY the SDK UI never consumes. PTY mode is
90
+ // untouched (the proxyPort branch below stays PTY-only).
91
+ if (deps.isSdkMode) {
92
+ try {
93
+ const sdk = await import('../lib/sdk-manager.js');
94
+ if (!sdk.setActiveSdkSession(wsPath)) {
95
+ // New cwd: register a session carrying the PROCESS-LEVEL launch config template
96
+ // (registered by cli.js at boot) so its query gets the proxy env / model /
97
+ // permission / executable — without it the child would bypass the ccv proxy
98
+ // (no transcript) and skip the per-cwd routing header. Process-global callbacks
99
+ // (broadcast/turn-end) are reused via initSdkSession's only-if-provided guard.
100
+ sdk.initSdkSession(wsPath, result.projectName, sdk.getSdkLaunchConfig() || undefined);
101
+ }
102
+ } catch (err) { reportSwallowed('workspaces.sdk-launch', err); }
103
+ }
104
+
88
105
  // 启动 PTY
89
106
  const proxyPort = process.env.CCV_PROXY_PORT;
90
- if (proxyPort) {
107
+ if (proxyPort && !deps.isSdkMode) {
91
108
  // Multi-instance (2026-10-06): spawnClaude now mints a fresh instanceKey per call and
92
109
  // no longer kills an existing same-cwd record, so re-launching an ALREADY-LIVE cwd
93
110
  // here would silently start a duplicate claude. ensurePtyForCwd restores the intended
@@ -186,6 +203,12 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
186
203
  // old project's claude leaks past the workspace session.
187
204
  import('../pty-manager.js').then(({ killAllMain }) => killAllMain()),
188
205
  import('../scratch-pty-manager.js').then(({ killAllScratch }) => killAllScratch()).catch(() => {}),
206
+ // SDK multi-session (2026-10-12): returning to the workspace list must also stop every
207
+ // resident SDK session, or they (and their child claude processes) accumulate for the
208
+ // whole process lifetime.
209
+ deps.isSdkMode
210
+ ? import('../lib/sdk-manager.js').then((m) => m.stopSession()).catch((err) => reportSwallowed('workspaces.sdk-stop', err))
211
+ : Promise.resolve(),
189
212
  ]).then(() => {
190
213
  // 接续原有清理流程
191
214
 
@@ -196,6 +219,12 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
196
219
  // 重置 interceptor 状态
197
220
  resetWorkspace();
198
221
  deps.setWorkspaceLaunched(false);
222
+ // Per-workspace: tear down every scope's streaming/turn buckets AND the
223
+ // server-side per-scope maps (session tags, streaming edges) — a PTY
224
+ // killed here fires no Stop POST, so without this the killed workspaces'
225
+ // buckets stay `active` forever and a stale session tag rejects the fresh
226
+ // session's first UserPromptSubmit on a same-name relaunch.
227
+ try { (deps.resetScopeRuntime ? deps.resetScopeRuntime() : clearScopeState()); } catch { /* best-effort */ }
199
228
 
200
229
  // 通知所有 SSE 客户端
201
230
  deps.clients.forEach(client => {
@@ -25,9 +25,23 @@ const MAX_BUFFER = 50000;
25
25
  // 裁剪滞回:超 MAX_BUFFER 后一次裁到 TRIM_TO,降低每 chunk slice 重分配频率(同 pty-manager.js)
26
26
  const BUFFER_TRIM_TO = 45000;
27
27
 
28
- // id -> { ptyProcess, dataListeners, exitListeners, lastExitCode, outputBuffer, lastCols, lastRows, batchBuffer, batchScheduled }
28
+ // Multi-project (2026-10-12): scratch PTYs are keyed by (tab id, resolved workspace
29
+ // cwd) so each viewed workspace owns an isolated shell group; switching away keeps the
30
+ // old group's processes alive and switching back re-attaches them. Callers that omit
31
+ // cwd fall back to STARTUP_CWD (pre-multi-project API shape — keeps older tests and
32
+ // any un-updated call site on a single, well-defined bucket).
33
+ function effCwd(opts) {
34
+ return (opts && typeof opts.cwd === 'string' && opts.cwd) ? opts.cwd : STARTUP_CWD;
35
+ }
36
+ function keyFor(id, cwd) {
37
+ // Space separator is collision-free: the id charset (SCRATCH_ID_RE, [A-Za-z0-9_-])
38
+ // excludes spaces, so the first space always delimits even when the cwd contains them.
39
+ return `${id} ${cwd || STARTUP_CWD}`;
40
+ }
41
+
42
+ // composite key -> { ptyProcess, dataListeners, exitListeners, lastExitCode, outputBuffer, lastCols, lastRows, batchBuffer, batchScheduled, cwd }
29
43
  const ptys = new Map();
30
- // id -> in-flight spawn Promise,防同 id 并发 spawn 双开
44
+ // composite key -> in-flight spawn Promise; dedupes concurrent spawns for the same id+cwd
31
45
  const _spawnInflight = new Map();
32
46
  let _ptyImportForTests = null;
33
47
 
@@ -43,7 +57,7 @@ async function getPty() {
43
57
  return ptyMod.default || ptyMod;
44
58
  }
45
59
 
46
- function newState() {
60
+ function newState(cwd) {
47
61
  return {
48
62
  ptyProcess: null,
49
63
  dataListeners: [],
@@ -54,23 +68,28 @@ function newState() {
54
68
  lastRows: 24,
55
69
  batchBuffer: '',
56
70
  batchScheduled: false,
71
+ cwd: cwd || STARTUP_CWD,
57
72
  };
58
73
  }
59
74
 
60
75
  // 仅在确实有消费方时才创建条目;为 onScratchData/onScratchExit 与 spawnScratch 复用
61
- function getOrInit(id) {
62
- let s = ptys.get(id);
76
+ function getOrInit(id, cwd) {
77
+ const k = keyFor(id, cwd);
78
+ let s = ptys.get(k);
63
79
  if (!s) {
64
- s = newState();
65
- ptys.set(id, s);
80
+ s = newState(cwd);
81
+ ptys.set(k, s);
66
82
  }
67
- return s;
83
+ return { k, s };
68
84
  }
69
85
 
70
- // 当某 id 既无 pty 又无监听器时清掉空记录,防止懒注册场景下 Map 长尾膨胀
71
- function maybeReap(id, s) {
86
+ // 当某 key 既无 pty 又无监听器时清掉空记录,防止懒注册场景下 Map 长尾膨胀。
87
+ // Identity check: a kill+quick-respawn replaces the entry under the same key — the old
88
+ // process's async onExit must not delete the NEW live record (leaks an unreachable
89
+ // shell and desyncs the caps).
90
+ function maybeReap(k, s) {
72
91
  if (!s.ptyProcess && s.dataListeners.length === 0 && s.exitListeners.length === 0) {
73
- ptys.delete(id);
92
+ if (ptys.get(k) === s) ptys.delete(k);
74
93
  }
75
94
  }
76
95
 
@@ -117,14 +136,24 @@ function fixSpawnHelperPermissions() {
117
136
  // 注:spawn 失败时 s 留在 Map 但 s.ptyProcess 仍为 null。server connection handler 在
118
137
  // 失败后仍会通过 onScratchData/onScratchExit 注册 listener;ws.close 时它们通过 maybeReap
119
138
  // 清空记录,所以无长期泄漏。已知降级体验:用户看到空终端,下次 input 会再试一次 spawn。
120
- export async function spawnScratch(id) {
139
+ // Multi-project: the shell's cwd comes from the viewed-workspace resolution threaded
140
+ // through opts.cwd (server.js upgrade handler); STARTUP_CWD remains the fallback.
141
+ export async function spawnScratch(id, opts) {
121
142
  if (!id || typeof id !== 'string') throw new Error('spawnScratch requires id');
122
- const s = getOrInit(id);
143
+ const cwd = effCwd(opts);
144
+ const { k, s } = getOrInit(id, cwd);
123
145
  if (s.ptyProcess) return s.ptyProcess;
124
- if (_spawnInflight.has(id)) return _spawnInflight.get(id);
146
+ if (_spawnInflight.has(k)) return _spawnInflight.get(k);
125
147
  const p = (async () => {
126
148
  const pty = await getPty();
127
149
  fixSpawnHelperPermissions();
150
+ // Stale-record guard: a killAll/killForCwd during the async getPty() window deletes
151
+ // the record from ptys. Without this check the freshly spawned process below would
152
+ // be assigned to an orphaned state object — a live shell no Map entry can reach
153
+ // (invisible to getScratchPid/writeScratch/kill, uncounted against the caps).
154
+ if (ptys.get(k) !== s) {
155
+ throw new Error('scratch record reaped during spawn');
156
+ }
128
157
 
129
158
  const shell = process.env.SHELL || (process.platform === 'win32' ? (process.env.ComSpec || 'cmd.exe') : '/bin/sh');
130
159
  const env = { ...process.env };
@@ -142,16 +171,23 @@ export async function spawnScratch(id) {
142
171
  applyClaudeAltScreenPref(env);
143
172
  const shellSpawn = prepareEmbeddedShellSpawn(shell, env);
144
173
 
145
- s.lastExitCode = null;
146
- s.outputBuffer = '';
147
-
148
- s.ptyProcess = pty.spawn(shellSpawn.command, shellSpawn.args, {
174
+ const proc = pty.spawn(shellSpawn.command, shellSpawn.args, {
149
175
  name: 'xterm-256color',
150
176
  cols: s.lastCols,
151
177
  rows: s.lastRows,
152
- cwd: STARTUP_CWD,
178
+ cwd: s.cwd,
153
179
  env: shellSpawn.env,
154
180
  });
181
+ // The synchronous spawn above cannot interleave with a reap, but belt-and-braces:
182
+ // re-check before publishing so a process is never attached to a reaped record.
183
+ if (ptys.get(k) !== s) {
184
+ try { proc.kill(); } catch { }
185
+ throw new Error('scratch record reaped during spawn');
186
+ }
187
+
188
+ s.lastExitCode = null;
189
+ s.outputBuffer = '';
190
+ s.ptyProcess = proc;
155
191
 
156
192
  s.ptyProcess.onData((data) => {
157
193
  s.outputBuffer += data;
@@ -174,20 +210,23 @@ export async function spawnScratch(id) {
174
210
  for (const cb of [...s.exitListeners]) {
175
211
  try { cb(exitCode); } catch { }
176
212
  }
213
+ // A shell that exits while its page is away (switch-away already removed
214
+ // listeners) would otherwise linger as a dead entry against the caps.
215
+ maybeReap(k, s);
177
216
  });
178
217
 
179
218
  return s.ptyProcess;
180
219
  })();
181
- _spawnInflight.set(id, p);
220
+ _spawnInflight.set(k, p);
182
221
  try {
183
222
  return await p;
184
223
  } finally {
185
- _spawnInflight.delete(id);
224
+ _spawnInflight.delete(k);
186
225
  }
187
226
  }
188
227
 
189
- export function writeScratch(id, data) {
190
- const s = ptys.get(id);
228
+ export function writeScratch(id, data, opts) {
229
+ const s = ptys.get(keyFor(id, effCwd(opts)));
191
230
  if (s?.ptyProcess) {
192
231
  s.ptyProcess.write(data);
193
232
  return true;
@@ -203,8 +242,8 @@ function _clampScratchDim(v, min, max, fallback) {
203
242
  return Math.max(min, Math.min(max, n));
204
243
  }
205
244
 
206
- export function resizeScratch(id, cols, rows) {
207
- const s = ptys.get(id);
245
+ export function resizeScratch(id, cols, rows, opts) {
246
+ const s = ptys.get(keyFor(id, effCwd(opts)));
208
247
  if (!s) return;
209
248
  s.lastCols = _clampScratchDim(cols, 2, 1000, s.lastCols);
210
249
  s.lastRows = _clampScratchDim(rows, 1, 1000, s.lastRows);
@@ -213,8 +252,10 @@ export function resizeScratch(id, cols, rows) {
213
252
  }
214
253
  }
215
254
 
216
- export function killScratch(id) {
217
- const s = ptys.get(id);
255
+ // Shared teardown for killScratch/killAllScratch: kill the process tree, flush the
256
+ // batch pipeline, then drop the record entirely (listeners included — the frontend
257
+ // ws.close path unsubscribes separately).
258
+ function _killEntry(k, s) {
218
259
  if (!s) return;
219
260
  if (s.ptyProcess) {
220
261
  flushBatch(s, true);
@@ -227,50 +268,80 @@ export function killScratch(id) {
227
268
  }
228
269
  s.ptyProcess = null;
229
270
  }
230
- // 显式 kill 后整条记录清掉,监听器一并丢弃(前端 ws.close 也会清)
231
- ptys.delete(id);
271
+ ptys.delete(k);
272
+ }
273
+
274
+ export function killScratch(id, opts) {
275
+ _killEntry(keyFor(id, effCwd(opts)), ptys.get(keyFor(id, effCwd(opts))));
232
276
  }
233
277
 
234
278
  export function killAllScratch() {
235
- for (const id of [...ptys.keys()]) {
236
- killScratch(id);
279
+ for (const [k, s] of [...ptys.entries()]) {
280
+ _killEntry(k, s);
237
281
  }
238
282
  }
239
283
 
240
- export function onScratchData(id, cb) {
241
- const s = getOrInit(id);
284
+ // Kill every scratch PTY belonging to ONE workspace cwd (closing a project/instance via
285
+ // /api/live-processes/close). The match is against the record's stored spawn cwd, which
286
+ // was realpathSync-normalized at the upgrade — so callers pass candidates in BOTH path
287
+ // spaces (raw spawn cwd + its realpath): when the workspace dir was later deleted,
288
+ // realpathSync(raw) throws and only the raw candidate can match, while the bucket key
289
+ // may be the realpath. Same-basename twins have distinct cwds, so this never reaps a
290
+ // sibling workspace's shells. Accepts a single cwd string or an array of candidates.
291
+ // Returns the number reaped.
292
+ export function killScratchForCwd(cwd) {
293
+ const cands = (Array.isArray(cwd) ? cwd : [cwd]).filter((c) => typeof c === 'string' && c);
294
+ if (cands.length === 0) return 0;
295
+ let n = 0;
296
+ for (const [k, s] of [...ptys.entries()]) {
297
+ if (s && cands.includes(s.cwd)) {
298
+ _killEntry(k, s);
299
+ n++;
300
+ }
301
+ }
302
+ return n;
303
+ }
304
+
305
+ export function onScratchData(id, cb, opts) {
306
+ const cwd = effCwd(opts);
307
+ const { k, s } = getOrInit(id, cwd);
242
308
  s.dataListeners.push(cb);
243
309
  return () => {
244
310
  s.dataListeners = s.dataListeners.filter(l => l !== cb);
245
- maybeReap(id, s);
311
+ maybeReap(k, s);
246
312
  };
247
313
  }
248
314
 
249
- export function onScratchExit(id, cb) {
250
- const s = getOrInit(id);
315
+ export function onScratchExit(id, cb, opts) {
316
+ const cwd = effCwd(opts);
317
+ const { k, s } = getOrInit(id, cwd);
251
318
  s.exitListeners.push(cb);
252
319
  return () => {
253
320
  s.exitListeners = s.exitListeners.filter(l => l !== cb);
254
- maybeReap(id, s);
321
+ maybeReap(k, s);
255
322
  };
256
323
  }
257
324
 
258
- export function getScratchPid(id) {
259
- const s = ptys.get(id);
325
+ export function getScratchPid(id, opts) {
326
+ const s = ptys.get(keyFor(id, effCwd(opts)));
260
327
  return s?.ptyProcess ? s.ptyProcess.pid : null;
261
328
  }
262
329
 
263
- export function getScratchState(id) {
264
- const s = ptys.get(id);
330
+ export function getScratchState(id, opts) {
331
+ const cwd = effCwd(opts);
332
+ const s = ptys.get(keyFor(id, cwd));
265
333
  return {
266
334
  running: !!(s?.ptyProcess),
267
335
  exitCode: s?.lastExitCode ?? null,
268
- cwd: STARTUP_CWD,
336
+ // The record always carries its real spawn cwd; only a missing record falls back to
337
+ // the requested cwd (pre-spawn probe). No `|| cwd` blur — a corrupted record must
338
+ // surface, not silently report the requester's cwd.
339
+ cwd: s ? s.cwd : cwd,
269
340
  };
270
341
  }
271
342
 
272
- export function getScratchOutputBuffer(id) {
273
- return ptys.get(id)?.outputBuffer ?? '';
343
+ export function getScratchOutputBuffer(id, opts) {
344
+ return ptys.get(keyFor(id, effCwd(opts)))?.outputBuffer ?? '';
274
345
  }
275
346
 
276
347
  export function getScratchStartupCwd() {
@@ -288,9 +359,19 @@ export function getScratchPtyCount() {
288
359
  return ptys.size;
289
360
  }
290
361
 
362
+ // Per-workspace cap companion to the global count above: entries belonging to one
363
+ // resolved cwd (hidden workspaces keep their PTYs alive by design, so the global cap
364
+ // alone would starve multi-workspace use).
365
+ export function getScratchPtyCountForCwd(cwd) {
366
+ const target = effCwd({ cwd });
367
+ let n = 0;
368
+ for (const s of ptys.values()) if (s.cwd === target) n++;
369
+ return n;
370
+ }
371
+
291
372
  // 服务端 upgrade 阶段判断 id 是否是"已有重连",避免 cap 误伤老 tab 重连
292
- export function hasScratchPty(id) {
293
- return ptys.has(id);
373
+ export function hasScratchPty(id, opts) {
374
+ return ptys.has(keyFor(id, effCwd(opts)));
294
375
  }
295
376
 
296
377
  // 当前 spawn 用的 shell basename(zsh / bash / fish 等),供前端渲染 tab 标签