cc-viewer 1.9.5 → 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.
@@ -19,10 +19,11 @@ import { listLivePtys } from '../pty-manager.js';
19
19
  // not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
20
20
  // when it resolves to a registered workspace / live PTY (or the bound
21
21
  // project), never an arbitrary sanitized name.
22
- function _resolveStatsName(projectParam, res) {
23
- if (!projectParam) return _projectName || '';
22
+ function _resolveStatsName(projectParam, instanceParam, res) {
23
+ if (!projectParam && !instanceParam) return _projectName || '';
24
24
  const r = resolveViewRoot({
25
25
  projectParam,
26
+ instanceParam,
26
27
  boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
27
28
  loadWorkspaces,
28
29
  listLivePtys,
@@ -32,7 +33,9 @@ function _resolveStatsName(projectParam, res) {
32
33
  res.end(JSON.stringify({ error: r.error }));
33
34
  return null;
34
35
  }
35
- return sanitizePathComponent(projectParam);
36
+ // Same contract as project-meta: the store name derives from the resolver's
37
+ // RAW matched path, never the raw request name nor the realpath'd root.
38
+ return sanitizePathComponent(r.projectName);
36
39
  }
37
40
 
38
41
  // GET /api/proxy-stats — returns the VIEWED project's proxy retry stats
@@ -41,7 +44,8 @@ function _resolveStatsName(projectParam, res) {
41
44
  function getProxyStats(req, res, parsedUrl) {
42
45
  try {
43
46
  const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
44
- const name = _resolveStatsName(projectParam, res);
47
+ const instanceParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('instance') : null;
48
+ const name = _resolveStatsName(projectParam, instanceParam, res);
45
49
  if (name === null) return; // error already replied
46
50
  if (!name) {
47
51
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -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,7 +295,7 @@ 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); }
265
300
  // A PTY killed here fires no Stop POST, so its scope's streaming/turn
266
301
  // bucket would stay `active:true` forever — the heartbeat/streaming timer
@@ -311,7 +346,7 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
311
346
  }
312
347
  }
313
348
  } catch (err) { reportSwallowed('resume-route.close-kill-scratch', err); }
314
- 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 });
315
350
  } catch (err) {
316
351
  reportSwallowed('resume-route.close', err);
317
352
  sendJson(res, 500, { ok: false, reason: 'close-failed' });
@@ -339,7 +374,7 @@ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
339
374
  // request can observe a half-applied rebind, and a throwing side-effect can no
340
375
  // longer strand the "binding moved but nobody was told" state (the broadcast
341
376
  // has already gone out by then).
342
- function _maybeRebindAfterClose(killResult, deps) {
377
+ function _maybeRebindAfterClose(killResult, deps, survivorRecords = null) {
343
378
  // Electron multi-tab: the manager process does NOT own log init (the tab
344
379
  // workers do); mirroring workspaces.js:56, skip rebind entirely there.
345
380
  if (process.env.CCV_ELECTRON_MULTITAB === '1') return null;
@@ -355,7 +390,7 @@ function _maybeRebindAfterClose(killResult, deps) {
355
390
  // terminal attachment to (reattachedTo), then the terminal's live attachment
356
391
  // (isActive — preserves the launch-time invariant bound == attached), else
357
392
  // the first live claude record in Map order.
358
- 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'));
359
394
  if (!live.length) return null;
360
395
  // A same-cwd TWIN of the closed bound project still lives → the bound
361
396
  // identity (same project name, same log dir) is already correct for it;
@@ -492,7 +527,9 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
492
527
  const sdk = await import('../lib/sdk-manager.js');
493
528
  // Multi-session (2026-10-12): honor the requested workspace — resume the VIEWED
494
529
  // session, not always the active one. project is the workspace name from the client.
495
- const anchor = (typeof project === 'string' && project) ? { project } : undefined;
530
+ const anchor = (project || instanceKey)
531
+ ? { project: project || undefined, instanceKey: instanceKey || undefined }
532
+ : undefined;
496
533
  const r = sdk.switchToSession(sessionUuid, anchor);
497
534
  if (r.ok) { sendJson(res, 200, { ok: true }); return; }
498
535
  if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
package/server/server.js CHANGED
@@ -327,6 +327,9 @@ const _editorCleanupTimer = setInterval(() => {
327
327
  }, 60000);
328
328
  _editorCleanupTimer.unref(); // Don't keep process alive for cleanup
329
329
  let terminalWss = null; // WebSocketServer reference for broadcasting
330
+ // Test seam: inject a stubbed client set so broadcastWsMessage's instance-narrowing
331
+ // predicate is directly unit-testable (ws-instance-isolation.test.js).
332
+ export function setTerminalWssForTests(wss) { terminalWss = wss; }
330
333
  let streamMux = null; // /ws/mux dialogue-channel stream mux (SSE stays the fallback)
331
334
  let _writeToPty = null; // PTY write function reference (set by setupTerminalWebSocket)
332
335
  let _onPtyData = null; // PTY data listener registration (set by setupTerminalWebSocket)
@@ -2060,6 +2063,7 @@ async function setupTerminalWebSocket(httpServer) {
2060
2063
  // 新视图的 sdk-session-state/审批/队列帧会被静默丢弃。
2061
2064
  if (msg.type === 'attach') {
2062
2065
  try { ws._ccvViewProject = (typeof msg.project === 'string' && msg.project) || null; } catch {}
2066
+ try { ws._ccvViewInstance = (typeof msg.instanceKey === 'string' && msg.instanceKey) || null; } catch {}
2063
2067
  if (isSdkMode) return;
2064
2068
  } else if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
2065
2069
  || msg.type === 'resync-request' || msg.type === 'resize')) {
@@ -2413,7 +2417,7 @@ async function setupTerminalWebSocket(httpServer) {
2413
2417
  // User message in SDK mode — relay to sdk-manager, addressed to the viewed
2414
2418
  // workspace's session (multi-session). msg.project is the workspace basename.
2415
2419
  if (_sdkSendUserMessage && msg.text) {
2416
- const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
2420
+ const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2417
2421
  _sdkSendUserMessage(msg.text, anchor).catch(err => {
2418
2422
  console.error('[SDK] sendUserMessage error:', err.message);
2419
2423
  });
@@ -2424,7 +2428,7 @@ async function setupTerminalWebSocket(httpServer) {
2424
2428
  // fetch hook → streamingState → SSE), same as PTY mode; sdk-manager
2425
2429
  // no longer emits its own streaming_status.
2426
2430
  if (isSdkMode && _sdkInterruptTurn) {
2427
- const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
2431
+ const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2428
2432
  const cancelled = _sdkInterruptTurn(anchor) || [];
2429
2433
  _broadcastApprovalCloses(cancelled, 'Turn interrupted');
2430
2434
  } else {
@@ -2441,7 +2445,7 @@ async function setupTerminalWebSocket(httpServer) {
2441
2445
  if (typeof msg.text === 'string' && msg.text) {
2442
2446
  if (isSdkMode) {
2443
2447
  if (_sdkSendUserMessage) {
2444
- const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
2448
+ const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2445
2449
  _sdkSendUserMessage(msg.text, anchor).catch(err => {
2446
2450
  console.error('[SDK] queue-message relay error:', err.message);
2447
2451
  });
@@ -2460,7 +2464,7 @@ async function setupTerminalWebSocket(httpServer) {
2460
2464
  if (_sdkSendQueuedNow) {
2461
2465
  // Multi-session: address the bubble's own workspace (the viewed project), not
2462
2466
  // the global active session — bubbles are cwd-scoped, the action must match.
2463
- const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
2467
+ const qAnchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
2464
2468
  try { _broadcastApprovalCloses(_sdkSendQueuedNow(qid, qAnchor) || [], 'Queued message sent now'); }
2465
2469
  catch (err) { reportSwallowed('server.queue-send-now', err); }
2466
2470
  }
@@ -2472,7 +2476,7 @@ async function setupTerminalWebSocket(httpServer) {
2472
2476
  const qid = (typeof msg.id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(msg.id)) ? msg.id : null;
2473
2477
  if (qid) {
2474
2478
  if (isSdkMode) {
2475
- if (_sdkRemoveQueued) { const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined; try { _sdkRemoveQueued(qid, qAnchor); } catch (err) { reportSwallowed('server.queue-remove', err); } }
2479
+ if (_sdkRemoveQueued) { const qAnchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined; try { _sdkRemoveQueued(qid, qAnchor); } catch (err) { reportSwallowed('server.queue-remove', err); } }
2476
2480
  } else {
2477
2481
  try { chatQueue.remove(qid); } catch (err) { reportSwallowed('server.queue-remove', err); }
2478
2482
  }
@@ -2481,7 +2485,7 @@ async function setupTerminalWebSocket(httpServer) {
2481
2485
  // Session/workspace switch: drop the parked queue WITHOUT interrupting a running
2482
2486
  // turn. (Stop semantics live elsewhere: PTY queue-suppress, SDK sdk-interrupt.)
2483
2487
  if (isSdkMode) {
2484
- if (_sdkClearQueued) { const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined; try { _sdkClearQueued(qAnchor); } catch (err) { reportSwallowed('server.queue-clear', err); } }
2488
+ if (_sdkClearQueued) { const qAnchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined; try { _sdkClearQueued(qAnchor); } catch (err) { reportSwallowed('server.queue-clear', err); } }
2485
2489
  } else {
2486
2490
  try { chatQueue.clear(); } catch (err) { reportSwallowed('server.queue-clear', err); }
2487
2491
  }
@@ -3226,8 +3230,7 @@ async function _doStop() {
3226
3230
  // are intentionally no pushSdkEntry/setSdkStreamingState shortcuts anymore.
3227
3231
 
3228
3232
  /** Broadcast a message to all terminal WS clients (for SDK canUseTool). */
3229
- export function broadcastWsMessage(msg) {
3230
- if (terminalWss) {
3233
+ export function broadcastWsMessage(msg) { if (terminalWss) {
3231
3234
  // SDK multi-session (2026-10-12): a cwd-tagged frame belongs to one workspace's session.
3232
3235
  // Route it only to clients viewing that workspace (or the bound view with no ?project=),
3233
3236
  // so a background workspace's approval/queue/spinner never bleeds into the foreground.
@@ -3238,10 +3241,17 @@ export function broadcastWsMessage(msg) {
3238
3241
  // — every frame for such a workspace would be silently dropped for its viewer.
3239
3242
  let frameProj = null;
3240
3243
  if (frameCwd) frameProj = projectKeyForCwd(frameCwd) || null;
3244
+ // Same-name twin isolation (review P2): a frame tagged with an instance key narrows
3245
+ // delivery to clients viewing THAT instance — otherwise two same-basename workspaces
3246
+ // (same frameProj) would cross-deliver (e.g. a close teardown's idle/queue-state wipe
3247
+ // killing the surviving twin viewer's spinner and queue bubbles). Untagged/cwd-only
3248
+ // frames keep the historical project-level behavior.
3249
+ const frameInstance = (msg && typeof msg === 'object' && typeof msg.instanceKey === 'string' && msg.instanceKey) ? msg.instanceKey : null;
3241
3250
  const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
3242
3251
  terminalWss.clients.forEach((c) => {
3243
3252
  if (c.readyState !== 1) return;
3244
3253
  if (frameProj && c._ccvViewProject && c._ccvViewProject !== frameProj) return; // other workspace's frame
3254
+ if (frameInstance && c._ccvViewInstance && c._ccvViewInstance !== frameInstance) return; // other instance of the same project
3245
3255
  try { c.send(str); } catch {}
3246
3256
  });
3247
3257
  }