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
@@ -1,26 +1,56 @@
1
1
  /**
2
2
  * task-state.js — in-memory reducer for the Claude Code task checklist.
3
3
  *
4
- * Maintains the single shared checklist of the session this cc-viewer process
5
- * hosts (fed by task-bridge.js via POST /api/task-event → deps.onTaskEvent).
6
- * The checklist is deliberately ONE map, not per-session: the main agent and
7
- * its teammates share the same task list in Claude Code, and each cc-viewer
8
- * server process only receives hooks from its own claude child tree
9
- * (CCVIEWER_PORT is injected per child). Cross-session leftovers are cleared
10
- * by resetTasks() on session boundaries (startup/clear from the MAIN agent —
11
- * see server.js onTaskSessionBoundary, which skips subagent events via
12
- * agentId).
4
+ * Per-workspace (2026-10): the checklist is bucketed by producer scope
5
+ * ('' = the bound project — the legacy singleton bucket; a parallel workspace
6
+ * gets its own bucket) so two concurrent claudes' task lists never clobber
7
+ * each other over SSE. Within a bucket the original single-map semantics are
8
+ * preserved: the main agent and its teammates share one list, cleared at
9
+ * session boundaries.
10
+ *
11
+ * The bucket KEY is computed by the caller (server.js's hook-payload scope
12
+ * resolver) — this module stays import-free (L1-lib, boundary gate) and never
13
+ * sees pty-manager. Callers that pass no bucket get the legacy '' bucket, so
14
+ * the single-workspace behavior is byte-identical.
13
15
  *
14
16
  * L1-lib: pure module, Node builtins only, no imports.
15
17
  */
16
18
 
17
- /** @type {string|null} last seen main session id (SSE tagging only) */
18
- let _sessionId = null;
19
- /** @type {Map<string, object>} insertion order = stable display order */
20
- const _tasks = new Map();
19
+ /** @type {Map<string, {sessionId: string|null, tasks: Map<string, object>, lastUsedAt: number}>} */
20
+ const _buckets = new Map();
21
+ const _BUCKET_CAP = 16;
21
22
 
22
23
  const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
23
24
 
25
+ function _normKey(bucket) {
26
+ return (typeof bucket === 'string' && bucket) ? bucket : '';
27
+ }
28
+
29
+ /** Get (or create) one scope's bucket. LRU-capped; the '' bucket is never
30
+ * evicted (it is the legacy singleton view). */
31
+ function _bucketFor(bucket) {
32
+ const k = _normKey(bucket);
33
+ let b = _buckets.get(k);
34
+ if (b) {
35
+ _buckets.delete(k);
36
+ _buckets.set(k, b);
37
+ return b;
38
+ }
39
+ b = { sessionId: null, tasks: new Map(), lastUsedAt: Date.now() };
40
+ _buckets.set(k, b);
41
+ while (_buckets.size > _BUCKET_CAP) {
42
+ let evicted = false;
43
+ for (const [bk] of _buckets) {
44
+ if (bk === '') continue; // never evict the bound bucket
45
+ _buckets.delete(bk);
46
+ evicted = true;
47
+ break;
48
+ }
49
+ if (!evicted) break;
50
+ }
51
+ return b;
52
+ }
53
+
24
54
  function _touch(task) {
25
55
  task.updatedAt = Date.now();
26
56
  return task;
@@ -30,26 +60,29 @@ function _touch(task) {
30
60
  * Apply one normalized task event (the camelCase envelope task-bridge.js
31
61
  * POSTs). Unknown/malformed payloads are ignored.
32
62
  * @param {object} payload
63
+ * @param {string} [bucket] - producer scope key ('' = bound, the legacy view)
33
64
  */
34
- export function applyTaskEvent(payload) {
65
+ export function applyTaskEvent(payload, bucket = '') {
35
66
  if (!payload || typeof payload !== 'object') return;
67
+ const b = _bucketFor(bucket);
68
+ b.lastUsedAt = Date.now();
36
69
  const { hookEventName } = payload;
37
70
  // New-prompt reset. MUST stay above the taskId guard (this event has no
38
- // task_id) and MUST NOT move _sessionId — a foreign/malformed prompt event
39
- // must not change the shouldResetTasks comparison base. resetTasks() nulls
40
- // the tag anyway, so the next TaskCreated re-tags the session.
71
+ // task_id) and MUST NOT move the sessionId tag — a foreign/malformed prompt
72
+ // event must not change the shouldResetTasks comparison base. resetTasks()
73
+ // nulls the tag anyway, so the next TaskCreated re-tags the session.
41
74
  if (hookEventName === 'UserPromptSubmit') {
42
- if (shouldResetTasksOnPrompt(payload, _sessionId)) resetTasks();
75
+ if (shouldResetTasksOnPrompt(payload, b.sessionId)) resetTasks(bucket);
43
76
  return;
44
77
  }
45
78
  const taskId = payload.taskId != null ? String(payload.taskId) : null;
46
79
  // Validate BEFORE touching the session tag: a malformed/unknown event must
47
80
  // not move the shouldResetTasks comparison base.
48
81
  if (!taskId) return;
49
- if (payload.sessionId && !payload.agentId) _sessionId = payload.sessionId;
82
+ if (payload.sessionId && !payload.agentId) b.sessionId = payload.sessionId;
50
83
 
51
84
  if (hookEventName === 'TaskCreated') {
52
- const existing = _tasks.get(taskId);
85
+ const existing = b.tasks.get(taskId);
53
86
  if (existing) {
54
87
  // Idempotent re-fire: keep the current status, refresh the text fields
55
88
  // and the teammate attribution (same field set as TaskCompleted).
@@ -59,7 +92,7 @@ export function applyTaskEvent(payload) {
59
92
  _touch(existing);
60
93
  return;
61
94
  }
62
- _tasks.set(taskId, _touch({
95
+ b.tasks.set(taskId, _touch({
63
96
  taskId,
64
97
  subject: payload.taskSubject ?? null,
65
98
  description: payload.taskDescription ?? null,
@@ -74,8 +107,8 @@ export function applyTaskEvent(payload) {
74
107
  }
75
108
 
76
109
  if (hookEventName === 'TaskCompleted') {
77
- const existing = _tasks.get(taskId) || _stub(taskId, payload);
78
- if (!_tasks.has(taskId)) _tasks.set(taskId, existing);
110
+ const existing = b.tasks.get(taskId) || _stub(taskId, payload);
111
+ if (!b.tasks.has(taskId)) b.tasks.set(taskId, existing);
79
112
  existing.status = 'completed';
80
113
  if (payload.taskSubject != null) existing.subject = payload.taskSubject;
81
114
  if (payload.taskDescription != null) existing.description = payload.taskDescription;
@@ -87,11 +120,11 @@ export function applyTaskEvent(payload) {
87
120
  if (hookEventName === 'PostToolUse' && payload.toolName === 'TaskUpdate') {
88
121
  const status = VALID_STATUSES.has(payload.status) ? payload.status : null;
89
122
  if (status === 'deleted') {
90
- _tasks.delete(taskId);
123
+ b.tasks.delete(taskId);
91
124
  return;
92
125
  }
93
- const existing = _tasks.get(taskId) || _stub(taskId, payload);
94
- if (!_tasks.has(taskId)) _tasks.set(taskId, existing);
126
+ const existing = b.tasks.get(taskId) || _stub(taskId, payload);
127
+ if (!b.tasks.has(taskId)) b.tasks.set(taskId, existing);
95
128
  // Patch only the fields present in the event; a TaskUpdate may carry any
96
129
  // subset (status flips, renames, owner claims). Re-opening a completed
97
130
  // task (completed → in_progress → pending) is valid and passed through.
@@ -124,10 +157,16 @@ function _stub(taskId, payload) {
124
157
  });
125
158
  }
126
159
 
127
- /** Clear the whole checklist (session boundary: startup/clear by main agent). */
128
- export function resetTasks() {
129
- _tasks.clear();
130
- _sessionId = null;
160
+ /** Clear ONE bucket's checklist (session boundary: startup/clear by main agent). */
161
+ export function resetTasks(bucket = '') {
162
+ const b = _bucketFor(bucket);
163
+ b.tasks.clear();
164
+ b.sessionId = null;
165
+ }
166
+
167
+ /** Tear down every bucket (server stop/start cycles). */
168
+ export function resetAllTasks() {
169
+ _buckets.clear();
131
170
  }
132
171
 
133
172
  /**
@@ -171,12 +210,13 @@ export function shouldResetTasksOnPrompt(payload, currentSessionId) {
171
210
  return true;
172
211
  }
173
212
 
174
- /** Full-snapshot view for SSE broadcast; insertion order preserved. */
175
- export function getTaskSnapshot() {
176
- return { sessionId: _sessionId, tasks: [..._tasks.values()] };
213
+ /** Full-snapshot view of ONE bucket for SSE broadcast; insertion order preserved. */
214
+ export function getTaskSnapshot(bucket = '') {
215
+ const b = _bucketFor(bucket);
216
+ return { sessionId: b.sessionId, tasks: [...b.tasks.values()] };
177
217
  }
178
218
 
179
219
  /** Test hook: restore pristine module state. */
180
220
  export function __resetForTests() {
181
- resetTasks();
221
+ resetAllTasks();
182
222
  }
@@ -60,16 +60,21 @@ try {
60
60
  } catch { /* stdin may not be piped — fine, still notify */ }
61
61
  let sessionId = null;
62
62
  let transcriptPath = null;
63
+ let cwd = null;
63
64
  try {
64
65
  const parsed = JSON.parse(stdinData);
65
66
  sessionId = parsed?.session_id || null;
66
67
  // transcript_path lets the DingTalk bridge read the final assistant turn straight from
67
68
  // Claude's own JSONL (the clean source, free of ANSI noise / the log-dedup bug).
68
69
  transcriptPath = parsed?.transcript_path || null;
70
+ // Per-workspace routing (2026-10): the Stop-hook stdin carries the session cwd —
71
+ // the server prefers sessionId→PTY resolution, with this cwd as the fallback
72
+ // scope hint (same precedence the task bridge already uses).
73
+ cwd = parsed?.cwd || null;
69
74
  } catch { /* fine */ }
70
75
 
71
76
  const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
72
- const body = JSON.stringify({ sessionId, transcriptPath, ts: Date.now() });
77
+ const body = JSON.stringify({ sessionId, transcriptPath, cwd, ts: Date.now() });
73
78
  const reqOpts = {
74
79
  hostname: '127.0.0.1',
75
80
  port: parseInt(port, 10),
@@ -27,6 +27,7 @@ import { join } from 'node:path';
27
27
  import { reportSwallowed } from '@ccv/core/error-report';
28
28
  import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
29
29
  import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
30
+ import { filterClientsByInstance } from './view-router.js';
30
31
  import { SessionSynthesizer } from './adapter.js';
31
32
  import { isDiscardableSession } from './session-select.js';
32
33
  import { isForeignLiveOwned } from './session-owner.js';
@@ -214,14 +215,14 @@ export class V2LiveFeed {
214
215
  * Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
215
216
  * single-instance view) sees every session regardless of `sessionInstance`; a session with
216
217
  * no stamped instance (legacy/external) is likewise visible to all.
218
+ *
219
+ * The project half keeps going through `_clientsFor()` (the server's filter
220
+ * closure — not necessarily the pure predicate); only the instance narrowing
221
+ * is delegated to the shared rule (view-router.filterClientsByInstance) so it
222
+ * can never drift from the server-side scoped broadcasts.
217
223
  */
218
224
  _clientsForInstance(sessionInstance) {
219
- const base = this._clientsFor();
220
- if (!sessionInstance) return base; // session carries no instance → visible to all viewers
221
- let anyInstanceViewer = false;
222
- for (const c of base) { if (c && c._ccvViewInstance) { anyInstanceViewer = true; break; } }
223
- if (!anyInstanceViewer) return base; // nobody is instance-scoped → keep legacy broadcast
224
- return base.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === sessionInstance);
225
+ return filterClientsByInstance(this._clientsFor(), sessionInstance || null);
225
226
  }
226
227
 
227
228
  /** Begin (or switch to) live-following a project's sessions/ root. */
@@ -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>`
@@ -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
  };