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
@@ -38,6 +38,54 @@ export function filterClientsByViewProject(clientList, project, boundProject) {
38
38
  return clientList.filter((c) => c && ((c._ccvViewProject || bound) === project));
39
39
  }
40
40
 
41
+ /**
42
+ * Instance-narrowed variant of filterClientsByViewProject (multi-instance,
43
+ * 2026-10-06 rule lifted from live-feed._clientsForInstance): a frame stamped
44
+ * with an instance is visible to every viewer of the project EXCEPT clients
45
+ * stamped with a DIFFERENT instance. Unset on either side ⇒ visible (a client
46
+ * with no `_ccvViewInstance` stamp sees every instance; a frame with no
47
+ * instance reaches all of the project's viewers).
48
+ *
49
+ * @param {Array} clientList - SSE response objects.
50
+ * @param {string} project - the frame's project ('' ⇒ bound).
51
+ * @param {string|null} instance - the frame's instance, when known.
52
+ * @param {string} boundProject - the server's bound project.
53
+ * @returns {Array} the subset of clients that should receive the frame.
54
+ */
55
+ export function filterClientsByScope(clientList, project, instance, boundProject) {
56
+ // Composition over duplication: project half, then the shared instance rule —
57
+ // the two can never drift apart.
58
+ return filterClientsByInstance(filterClientsByViewProject(clientList, project, boundProject), instance);
59
+ }
60
+
61
+ /**
62
+ * Instance-only narrowing over an ALREADY project-filtered client list — the
63
+ * live-feed composes its own project filter (`opts.clientFilter`, which may be
64
+ * a server-supplied closure rather than the pure predicate) with this rule.
65
+ * Same semantics as the instance half of filterClientsByScope: unset on either
66
+ * side ⇒ visible; nobody instance-stamped ⇒ the input array (same reference).
67
+ *
68
+ * @param {Array} clientList - already project-filtered SSE responses.
69
+ * @param {string|null} instance - the frame's instance, when known.
70
+ * @returns {Array}
71
+ */
72
+ export function filterClientsByInstance(clientList, instance) {
73
+ if (!instance) return clientList;
74
+ // Exclude ONLY clients explicitly stamped with a DIFFERENT instance. An
75
+ // UNSTAMPED (project-level) viewer of the same project must also receive the
76
+ // frame: if an earlier `active:true` frame reached it while no instance viewer
77
+ // was online, the matching `active:false` must reach it too or its spinner
78
+ // never clears (the clear would be narrowed away the moment a parallel
79
+ // instance chip connects). Keeping active/clear on the SAME recipient set
80
+ // avoids that split delivery.
81
+ // Fast path: nobody is excluded (no differently-stamped client) ⇒ return the
82
+ // SAME array reference (no filter alloc), preserving legacy identity.
83
+ let anyExcluded = false;
84
+ for (const c of clientList) { if (c && c._ccvViewInstance && c._ccvViewInstance !== instance) { anyExcluded = true; break; } }
85
+ if (!anyExcluded) return clientList;
86
+ return clientList.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === instance);
87
+ }
88
+
41
89
  /**
42
90
  * Extract the owning project dir name from a session dir path of the form
43
91
  * `<LOG_DIR>/<projectSan>/sessions/<dirName>`. Returns the on-disk `<projectSan>`
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { realpathSync, existsSync, statSync } from 'node:fs';
28
28
  import { projectKeyForCwd } from './system-prompt-snapshots.js';
29
+ import { listSdkSessionInstances } from './sdk-session-index.js';
29
30
 
30
31
  // A project name must be sanitizer-stable: projectKeyForCwd maps every legal
31
32
  // basename to [a-zA-Z0-9_\-\.] (collapsing only ALL-dot degenerate names to
@@ -49,11 +50,13 @@ function isValidProjectName(name) {
49
50
  * @param {string} opts.boundCwd - the bound project root (CCV_PROJECT_DIR || process.cwd()).
50
51
  * @param {() => Array<{path:string, projectName:string}>} opts.loadWorkspaces - registry reader.
51
52
  * @param {() => Array<{cwd:string, instanceKey?:string}>} opts.listLivePtys - live PTY records.
52
- * @returns {{ ok:true, root:string, via:'bound'|'registry'|'live'|'live-instance' } | { ok:false, status:number, error:string }}
53
+ * @returns {{ ok:true, root:string, projectName:string, via:'bound'|'registry'|'live'|'live-instance' } | { ok:false, status:number, error:string }}
54
+ * `root` is the realpath'd real dir; `projectName` is the RAW-path store key
55
+ * (basename of the matched candidate BEFORE realpath) — the wire/log-store name.
56
+ * They differ for a leaf-symlinked workspace, and the store is keyed by the raw name.
53
57
  */
54
- export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWorkspaces, listLivePtys } = {}) {
55
- const bound = boundCwd || process.cwd();
56
- // Multi-instance (2026-10-06): `instance` is the per-spawn instanceKey, the
58
+ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWorkspaces, listLivePtys, listSdkSessions = listSdkSessionInstances } = {}) {
59
+ const bound = boundCwd || process.cwd();// Multi-instance (2026-10-06): `instance` is the per-spawn instanceKey, the
57
60
  // authoritative disambiguator when two same-basename projects run at once —
58
61
  // the name path below would 400 on that pair as "ambiguous". Resolve it FIRST
59
62
  // (before the empty-name bound short-circuit AND name validation): a valid live
@@ -67,13 +70,21 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
67
70
  const rec = (listLivePtys() || []).find((p) => p && p.instanceKey === instance);
68
71
  if (rec && typeof rec.cwd === 'string' && rec.cwd) {
69
72
  if (existsSync(rec.cwd) && statSync(rec.cwd).isDirectory()) {
70
- return { ok: true, root: realpathSync(rec.cwd), via: 'live-instance' };
73
+ return { ok: true, root: realpathSync(rec.cwd), projectName: projectKeyForCwd(rec.cwd), via: 'live-instance' };
71
74
  }
72
75
  }
73
76
  } catch { /* pty map unavailable / fs race → fall through to name path */ }
77
+ try {
78
+ const rec = (listSdkSessions() || []).find((s) => s && s.instanceKey === instance);
79
+ if (rec && typeof rec.cwd === 'string' && rec.cwd) {
80
+ if (existsSync(rec.cwd) && statSync(rec.cwd).isDirectory()) {
81
+ return { ok: true, root: realpathSync(rec.cwd), projectName: projectKeyForCwd(rec.cwd), via: 'live-instance' };
82
+ }
83
+ }
84
+ } catch { /* SDK registry unavailable / fs race → fall through to name path */ }
74
85
  }
75
86
  const name = (typeof projectParam === 'string' && projectParam) || '';
76
- if (!name) return { ok: true, root: bound, via: 'bound' };
87
+ if (!name) return { ok: true, root: bound, projectName: projectKeyForCwd(bound), via: 'bound' };
77
88
  if (!isValidProjectName(name)) {
78
89
  return { ok: false, status: 400, error: 'invalid project name' };
79
90
  }
@@ -103,6 +114,14 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
103
114
  }
104
115
  }
105
116
  } catch { /* pty map unavailable → registry-only */ }
117
+ try {
118
+ for (const s of listSdkSessions() || []) {
119
+ const cwd = s && s.cwd;
120
+ if (typeof cwd === 'string' && cwd && projectKeyForCwd(cwd) === name) {
121
+ candidates.push({ path: cwd, via: 'live' });
122
+ }
123
+ }
124
+ } catch { /* SDK registry unavailable → PTY/registry-only */ }
106
125
 
107
126
  // Dedupe by path string (a project live AND registered under the SAME path
108
127
  // is one candidate). Keys are the raw candidate paths — not realpathSync —
@@ -132,7 +151,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
132
151
  if (!existsSync(root) || !statSync(root).isDirectory()) {
133
152
  return { ok: false, status: 404, error: 'project dir missing' };
134
153
  }
135
- return { ok: true, root: realpathSync(root), via: 'live' };
154
+ return { ok: true, root: realpathSync(root), projectName: projectKeyForCwd(root), via: 'live' };
136
155
  } catch {
137
156
  return { ok: false, status: 404, error: 'project dir unreadable' };
138
157
  }
@@ -156,7 +175,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
156
175
  }
157
176
  const root = alive[0].path;
158
177
  try {
159
- return { ok: true, root: realpathSync(root), via: alive[0].via };
178
+ return { ok: true, root: realpathSync(root), projectName: projectKeyForCwd(root), via: alive[0].via };
160
179
  } catch {
161
180
  return { ok: false, status: 404, error: 'project dir unreadable' };
162
181
  }
@@ -166,7 +185,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
166
185
  if (!existsSync(root) || !statSync(root).isDirectory()) {
167
186
  return { ok: false, status: 404, error: 'project dir missing' };
168
187
  }
169
- return { ok: true, root: realpathSync(root), via: uniq[0].via };
188
+ return { ok: true, root: realpathSync(root), projectName: projectKeyForCwd(root), via: uniq[0].via };
170
189
  } catch {
171
190
  return { ok: false, status: 404, error: 'project dir unreadable' };
172
191
  }
@@ -192,13 +211,14 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
192
211
  * @param {string} [opts.bodyInstance] - POST-body instance override (wins over query).
193
212
  * @returns {string|null} the resolved absolute root, or null (already replied).
194
213
  */
195
- export function viewRootOrReply(req, res, parsedUrl, { boundCwd, loadWorkspaces, listLivePtys, bodyProject, bodyInstance } = {}) {
214
+ export function viewRootOrReply(req, res, parsedUrl, { boundCwd, loadWorkspaces, listLivePtys, listSdkSessions, bodyProject, bodyInstance } = {}) {
196
215
  const r = resolveViewRoot({
197
216
  projectParam: (typeof bodyProject === 'string' && bodyProject) || parsedUrl?.searchParams?.get('project'),
198
217
  instanceParam: (typeof bodyInstance === 'string' && bodyInstance) || parsedUrl?.searchParams?.get('instance'),
199
218
  boundCwd,
200
219
  loadWorkspaces,
201
220
  listLivePtys,
221
+ listSdkSessions,
202
222
  });
203
223
  if (!r.ok) {
204
224
  res.writeHead(r.status, { 'Content-Type': 'application/json' });
@@ -39,7 +39,7 @@ function _signature(p) {
39
39
  function _isJournalFile(f) { return f.startsWith('wf_') && f.endsWith('.json'); }
40
40
 
41
41
  function _scan(state) {
42
- const { workflowsDir, sessionId, project, clients, seen } = state;
42
+ const { workflowsDir, sessionId, project, clients, clientFilter, seen } = state;
43
43
  let files;
44
44
  try { files = readdirSync(workflowsDir); } catch { return; }
45
45
  for (const f of files) {
@@ -51,7 +51,11 @@ function _scan(state) {
51
51
  seen.set(f, sig);
52
52
  const data = readNormalizedJournal(p);
53
53
  if (!data) continue;
54
- sendEventToClients(clients, 'workflow_update', {
54
+ // Per-workspace: a journal belongs to one project; when a client filter is
55
+ // installed the frame reaches only that project's viewers (legacy default:
56
+ // broadcast to all).
57
+ const target = typeof clientFilter === 'function' ? clientFilter(clients, project) : clients;
58
+ sendEventToClients(target, 'workflow_update', {
55
59
  sessionId,
56
60
  project: project || null,
57
61
  runId: data.runId,
@@ -99,7 +103,7 @@ function _baseline(state) {
99
103
  * 武装对某 workflows 目录的监视。同目录重复调用只刷新 clients 引用。
100
104
  * @param {{ workflowsDir: string, sessionId?: string, project?: string, clients: Array }} opts
101
105
  */
102
- export function armWorkflowWatch({ workflowsDir, sessionId, project, clients } = {}) {
106
+ export function armWorkflowWatch({ workflowsDir, sessionId, project, clients, clientFilter } = {}) {
103
107
  if (!workflowsDir || !Array.isArray(clients)) return null;
104
108
 
105
109
  const existing = _armed.get(workflowsDir);
@@ -107,6 +111,7 @@ export function armWorkflowWatch({ workflowsDir, sessionId, project, clients } =
107
111
 
108
112
  const state = {
109
113
  workflowsDir, sessionId, project, clients,
114
+ clientFilter: typeof clientFilter === 'function' ? clientFilter : null,
110
115
  seen: new Map(), watcher: null,
111
116
  debounceTimer: null, safetyTimer: null, presenceTimer: null,
112
117
  };
@@ -177,13 +182,14 @@ function _authoritativeJournalExists(runDir, runId) {
177
182
  }
178
183
 
179
184
  function _scanLive(state) {
180
- const { runDir, runId, sessionId, project, clients } = state;
185
+ const { runDir, runId, sessionId, project, clients, clientFilter } = state;
181
186
  const data = deriveLiveJournal(runDir, runId);
182
187
  if (data) {
183
188
  const sig = _liveSignature(data);
184
189
  if (sig !== state.lastSig) { // 有实质变化才广播
185
190
  state.lastSig = sig;
186
- sendEventToClients(clients, 'workflow_update', {
191
+ const target = typeof clientFilter === 'function' ? clientFilter(clients, project) : clients;
192
+ sendEventToClients(target, 'workflow_update', {
187
193
  sessionId,
188
194
  project: project || null,
189
195
  runId: data.runId,
@@ -228,7 +234,7 @@ function _startLiveWatch(state) {
228
234
  * 同 runDir 重复调用只刷新 clients。arm 时立即广播一次当前推导态(闭合 REST/arm 竞态)。
229
235
  * @param {{ runDir: string, runId: string, sessionId?: string, project?: string, clients: Array }} opts
230
236
  */
231
- export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, clients } = {}) {
237
+ export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, clients, clientFilter } = {}) {
232
238
  if (!runDir || !runId || !Array.isArray(clients)) return null;
233
239
 
234
240
  const existing = _armedLive.get(runDir);
@@ -236,6 +242,7 @@ export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, client
236
242
 
237
243
  const state = {
238
244
  runDir, runId, sessionId, project, clients,
245
+ clientFilter: typeof clientFilter === 'function' ? clientFilter : null,
239
246
  lastSig: null, watcher: null,
240
247
  debounceTimer: null, safetyTimer: null, presenceTimer: null,
241
248
  };