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
@@ -54,6 +54,7 @@ import { isAdminReq } from '../lib/is-admin.js';
54
54
  import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
55
55
  import { sseWrite } from '../lib/wire-compress.js';
56
56
  import { reportSwallowed } from '@ccv/core/error-report';
57
+ import { findSdkSessionByInstance, listSdkSessionInstances } from '../lib/sdk-session-index.js';
57
58
 
58
59
  function sendJson(res, code, obj) {
59
60
  res.writeHead(code, { 'Content-Type': 'application/json' });
@@ -128,21 +129,22 @@ async function getLiveProcessesHandler(req, res, parsedUrl) {
128
129
  // first launch's PTY-less list collapsed to just the new project. Enumerate the per-cwd SDK
129
130
  // session registry (multi-session) as `kind:'main'` entries so each launched workspace shows
130
131
  // up exactly like a PTY project. `active` marks the session the SDK composer is bound to;
131
- // `closable:false` (no PTY to kill) tells the header to suppress the ×.
132
+ // a cwd-stable instanceKey drives exact view routing and the per-session × close path.
132
133
  if (process.env.CCV_SDK_MODE === '1') {
133
134
  try {
134
135
  const sdk = await import('../lib/sdk-manager.js');
135
136
  for (const e of sdk.listSdkSessions()) {
136
137
  if (!e || !e.cwd) continue;
137
138
  if (processes.some((p) => p && p.cwd === e.cwd)) continue; // a real PTY already covers this cwd
139
+ const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
140
+ if (!currentInstanceKey && e.instanceKey && e.cwd === boundCwd) currentInstanceKey = e.instanceKey;
138
141
  processes.push({
139
142
  kind: 'main',
140
143
  project: projectKeyForCwd(e.cwd),
141
144
  cwd: e.cwd,
142
145
  pid: null,
143
146
  active: e.active === true,
144
- instanceKey: null,
145
- closable: false, // SDK resident query — killed via session switch, not the × close path
147
+ instanceKey: e.instanceKey || null,
146
148
  });
147
149
  }
148
150
  } catch (err) { reportSwallowed('resume-route.live-sdk', err); }
@@ -176,6 +178,25 @@ function postLiveProcessAttachHandler(req, res, parsedUrl, isLocal, deps) {
176
178
  return;
177
179
  }
178
180
  try {
181
+ const sdkAnchor = instanceKey && findSdkSessionByInstance(instanceKey)
182
+ ? { instanceKey }
183
+ : (!instanceKey && listSdkSessionInstances().length ? { project: project || undefined } : null);
184
+ if (sdkAnchor) {
185
+ const sdk = await import('../lib/sdk-manager.js');
186
+ const r = sdk.attachSdkSession(sdkAnchor);
187
+ if (r.ok) {
188
+ sendJson(res, 200, { ok: true, project, instanceKey: r.instanceKey, running: true, ptyKind: 'sdk', switched: r.switched });
189
+ return;
190
+ }
191
+ if (r.reason === 'ambiguous') {
192
+ sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
193
+ return;
194
+ }
195
+ if (instanceKey) {
196
+ sendJson(res, 404, { ok: false, reason: 'not-found' });
197
+ return;
198
+ }
199
+ }
179
200
  const { attachPtyFor } = await import('../pty-manager.js');
180
201
  const r = attachPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
181
202
  if (!r.ok) {
@@ -238,8 +259,22 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
238
259
  return;
239
260
  }
240
261
  try {
241
- const { killPtyFor } = await import('../pty-manager.js');
242
- const r = killPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
262
+ const sdkByInstance = instanceKey ? findSdkSessionByInstance(instanceKey) : null;
263
+ let r;
264
+ let survivorRecords = null;
265
+ if (sdkByInstance) {
266
+ const sdk = await import('../lib/sdk-manager.js');
267
+ r = sdk.closeSdkSession({ instanceKey });
268
+ survivorRecords = listSdkSessionInstances().map((s) => ({ key: s.instanceKey, cwd: s.cwd, isActive: s.active === true }));
269
+ } else {
270
+ const { killPtyFor } = await import('../pty-manager.js');
271
+ r = killPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
272
+ if (!r.ok && r.reason === 'not-found' && listSdkSessionInstances().length) {
273
+ const sdk = await import('../lib/sdk-manager.js');
274
+ r = sdk.closeSdkSession({ project: project || undefined, instanceKey: instanceKey || undefined });
275
+ if (r.ok) survivorRecords = listSdkSessionInstances().map((s) => ({ key: s.instanceKey, cwd: s.cwd, isActive: s.active === true }));
276
+ }
277
+ }
243
278
  if (!r.ok) {
244
279
  // Multi-instance: an ambiguous project-name-only close (2+ live same-basename
245
280
  // instances) is a 409 with the live candidates, so the client re-issues with an
@@ -260,8 +295,25 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
260
295
  // CCV_PROJECT_DIR — never by basename, or a same-name twin close would false-hit.
261
296
  let rebound = null;
262
297
  try {
263
- rebound = _maybeRebindAfterClose(r, deps);
298
+ rebound = _maybeRebindAfterClose(r, deps, survivorRecords);
264
299
  } catch (err) { reportSwallowed('resume-route.close-rebind', err); }
300
+ // A PTY killed here fires no Stop POST, so its scope's streaming/turn
301
+ // bucket would stay `active:true` forever — the heartbeat/streaming timer
302
+ // keeps re-broadcasting it and the client's 30s staleness escape is
303
+ // perpetually reset by those same frames (the permanent-spinner leak).
304
+ // Reap exactly that scope. The producer buckets it under
305
+ // projectKeyForCwd(cwd) (interceptor scopeKeyForEntry / server
306
+ // _resolveScopeForHookPayload), NOT the instanceKey — so resolve the same
307
+ // project-scoped key here, collapsed to '' when it was the bound project.
308
+ try {
309
+ if (r.cwd && deps.clearScopeRuntimeFor) {
310
+ const { projectKeyForCwd } = await import('../lib/system-prompt-snapshots.js');
311
+ const { normalizeProducerScope, scopeKeyOf } = await import('../lib/scope-router.js');
312
+ const proj = projectKeyForCwd(r.cwd);
313
+ const n = normalizeProducerScope({ project: proj, instance: null }, deps.projectName || '');
314
+ deps.clearScopeRuntimeFor(scopeKeyOf(n.project, n.instance));
315
+ }
316
+ } catch (err) { reportSwallowed('resume-route.close-clear-scope', err); }
265
317
  // Closing a project/instance must also reap its scratch (mini-terminal) shells, or
266
318
  // the viewed-then-switched-away workspace's shells stay alive as orphans until a
267
319
  // full workspacesStop/process exit. The killed record's original spawn cwd keys
@@ -294,7 +346,7 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
294
346
  }
295
347
  }
296
348
  } catch (err) { reportSwallowed('resume-route.close-kill-scratch', err); }
297
- sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
349
+ sendJson(res, 200, { ok: true, project, instanceKey: r.instanceKey || r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
298
350
  } catch (err) {
299
351
  reportSwallowed('resume-route.close', err);
300
352
  sendJson(res, 500, { ok: false, reason: 'close-failed' });
@@ -322,7 +374,7 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
322
374
  // request can observe a half-applied rebind, and a throwing side-effect can no
323
375
  // longer strand the "binding moved but nobody was told" state (the broadcast
324
376
  // has already gone out by then).
325
- function _maybeRebindAfterClose(killResult, deps) {
377
+ function _maybeRebindAfterClose(killResult, deps, survivorRecords = null) {
326
378
  // Electron multi-tab: the manager process does NOT own log init (the tab
327
379
  // workers do); mirroring workspaces.js:56, skip rebind entirely there.
328
380
  if (process.env.CCV_ELECTRON_MULTITAB === '1') return null;
@@ -338,7 +390,7 @@ function _maybeRebindAfterClose(killResult, deps) {
338
390
  // terminal attachment to (reattachedTo), then the terminal's live attachment
339
391
  // (isActive — preserves the launch-time invariant bound == attached), else
340
392
  // the first live claude record in Map order.
341
- const live = listLivePtys().filter((p) => p && p.cwd && (!p.ptyKind || p.ptyKind === 'claude'));
393
+ const live = survivorRecords || listLivePtys().filter((p) => p && p.cwd && (!p.ptyKind || p.ptyKind === 'claude'));
342
394
  if (!live.length) return null;
343
395
  // A same-cwd TWIN of the closed bound project still lives → the bound
344
396
  // identity (same project name, same log dir) is already correct for it;
@@ -475,7 +527,9 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
475
527
  const sdk = await import('../lib/sdk-manager.js');
476
528
  // Multi-session (2026-10-12): honor the requested workspace — resume the VIEWED
477
529
  // session, not always the active one. project is the workspace name from the client.
478
- const anchor = (typeof project === 'string' && project) ? { project } : undefined;
530
+ const anchor = (project || instanceKey)
531
+ ? { project: project || undefined, instanceKey: instanceKey || undefined }
532
+ : undefined;
479
533
  const r = sdk.switchToSession(sessionUuid, anchor);
480
534
  if (r.ok) { sendJson(res, 200, { ok: true }); return; }
481
535
  if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
@@ -4,9 +4,10 @@
4
4
  // - GET /api/session-pin → { pinnedSessionId }
5
5
  // - POST /api/session-pin {pinnedSessionId: string|null} → persist + SSE-broadcast
6
6
  // `_logDir` is the live per-project dir ('' = no active project → GET null / POST no-op).
7
- import { _logDir } from '../interceptor.js';
7
+ import { _logDir, _projectName } from '../interceptor.js';
8
8
  import { readPin, writePin } from '../lib/session-pin-store.js';
9
9
  import { sendEventToClients } from '../lib/log-watcher.js';
10
+ import { filterClientsByViewProject } from '../lib/v2/view-router.js';
10
11
 
11
12
  function sendJson(res, code, obj) {
12
13
  res.writeHead(code, { 'Content-Type': 'application/json' });
@@ -28,7 +29,16 @@ function postHandler(req, res, parsedUrl, isLocal, deps) {
28
29
  const pinnedSessionId = (typeof raw === 'string' && raw) ? raw : null;
29
30
  writePin(_logDir, pinnedSessionId);
30
31
  // Broadcast to THIS process's SSE clients so every device stays in sync.
31
- sendEventToClients(deps.clients, 'session_pin', { pinnedSessionId });
32
+ // Per-workspace: the pin file is the BOUND project's (`_logDir`), so the
33
+ // frame must reach only bound-project viewers — a parallel view's pin must
34
+ // not be clobbered by it (and vice versa). Payload carries the project so
35
+ // the client can guard in depth.
36
+ const bound = _projectName || '';
37
+ sendEventToClients(
38
+ filterClientsByViewProject(deps.clients, bound, bound),
39
+ 'session_pin',
40
+ { pinnedSessionId, project: bound },
41
+ );
32
42
  sendJson(res, 200, { ok: true, pinnedSessionId });
33
43
  });
34
44
  req.on('error', () => { try { sendJson(res, 400, { error: 'read-error' }); } catch {} });
@@ -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';
@@ -219,6 +219,12 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
219
219
  // 重置 interceptor 状态
220
220
  resetWorkspace();
221
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 */ }
222
228
 
223
229
  // 通知所有 SSE 客户端
224
230
  deps.clients.forEach(client => {