cc-viewer 1.8.20 → 1.9.1

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 (114) hide show
  1. package/cli.js +33 -4
  2. package/dist/assets/App-DM-fbhNd.js +2 -0
  3. package/dist/assets/App-DOZI5HME.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  5. package/dist/assets/{MdxEditorPanel-Dsnv2wIw.js → MdxEditorPanel-DjwOQDh0.js} +1 -1
  6. package/dist/assets/Mobile-BD-hefIH.js +1 -0
  7. package/dist/assets/ProxyStatsModal-pYa_0QVL.js +1 -0
  8. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  9. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  13. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  24. package/dist/assets/clone-CFj55h49.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  35. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  36. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  37. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  38. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  39. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  40. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  41. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  42. package/dist/assets/index-DkIhbLjr.js +2 -0
  43. package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
  44. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  45. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  46. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  47. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  48. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  49. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  50. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  51. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  52. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  53. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  54. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  55. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  56. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  57. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  58. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  59. package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
  60. package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +2 -0
  61. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  62. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  63. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  64. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  65. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  66. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  67. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  68. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  69. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  70. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  71. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  72. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  73. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  74. package/dist/index.html +5 -5
  75. package/package.json +1 -1
  76. package/server/interceptor.js +119 -13
  77. package/server/lib/chat-queue.js +67 -4
  78. package/server/lib/same-origin.js +22 -0
  79. package/server/lib/v2/convert.js +1 -1
  80. package/server/lib/v2/layout.js +28 -3
  81. package/server/lib/v2/live-feed.js +41 -4
  82. package/server/lib/v2/resume-list.js +150 -0
  83. package/server/lib/v2/session-select.js +63 -14
  84. package/server/lib/v2/v2-writer.js +172 -34
  85. package/server/lib/v2/view-router.js +83 -0
  86. package/server/lib/view-root.js +209 -0
  87. package/server/pty-manager.js +790 -159
  88. package/server/routes/ask-perm.js +21 -1
  89. package/server/routes/events.js +123 -7
  90. package/server/routes/expert.js +46 -14
  91. package/server/routes/files-content.js +72 -7
  92. package/server/routes/files-fs.js +28 -3
  93. package/server/routes/git.js +31 -5
  94. package/server/routes/preferences.js +1 -16
  95. package/server/routes/project-meta.js +32 -3
  96. package/server/routes/proxy-stats.js +33 -5
  97. package/server/routes/resume.js +302 -0
  98. package/server/routes/search.js +23 -1
  99. package/server/routes/skills.js +18 -2
  100. package/server/routes/v2.js +6 -1
  101. package/server/routes/workspaces.js +10 -3
  102. package/server/server.js +191 -25
  103. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  104. package/server/workspace-registry.js +15 -1
  105. package/dist/assets/App-CesjoFSv.js +0 -2
  106. package/dist/assets/App-M61pqQEx.css +0 -1
  107. package/dist/assets/Mobile-bvGZ392d.js +0 -1
  108. package/dist/assets/ProxyStatsModal-HFk51cI8.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  111. package/dist/assets/clone-B0_ghF3F.js +0 -1
  112. package/dist/assets/index-DanpP6qP.js +0 -2
  113. package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
  114. package/dist/assets/seqResourceLoaders-ByHQMNIi.js +0 -2
package/server/server.js CHANGED
@@ -40,6 +40,7 @@ import { teamRoutes } from './routes/team.js';
40
40
  import { authRoutes } from './routes/auth.js';
41
41
  import { dingtalkRoutes } from './routes/dingtalk.js';
42
42
  import { imRoutes } from './routes/im.js';
43
+ import { resumeRoutes } from './routes/resume.js';
43
44
  import { proxyStatsRoutes } from './routes/proxy-stats.js';
44
45
  import { setProxyStatsListener } from './lib/proxy/proxy-stats.js';
45
46
  import * as imCore from './lib/im/im-bridge-core.js';
@@ -96,6 +97,7 @@ function execWithStdin(cmd, args, input, options) {
96
97
  }
97
98
  import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
98
99
  import { V2LiveFeed } from './lib/v2/live-feed.js';
100
+ import { filterClientsByViewProject, projectOfSessionDir, resolveActivityFeedKey } from './lib/v2/view-router.js';
99
101
  import { sanitizePathComponent } from './lib/v2/layout.js';
100
102
  import { maybeResumeConvert } from './lib/v2/convert-manager.js';
101
103
  import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
@@ -173,9 +175,12 @@ const IGNORED_PATTERNS = new Set([
173
175
  '.idea', '.vscode'
174
176
  ]);
175
177
 
176
- // 多 git 仓库支持:解析 repo 参数为安全的 cwd 路径
177
- function resolveRepoCwd(repoParam) {
178
- const projectDir = process.env.CCV_PROJECT_DIR || process.cwd();
178
+ // 多 git 仓库支持:解析 repo 参数为安全的 cwd 路径。
179
+ // Multi-project (2026-10): `baseDir` lets a viewed parallel project override
180
+ // the bound root (git READ routes pass resolveViewRoot's result); omitting it
181
+ // keeps the bound behavior byte-identical.
182
+ function resolveRepoCwd(repoParam, baseDir) {
183
+ const projectDir = baseDir || process.env.CCV_PROJECT_DIR || process.cwd();
179
184
  if (!repoParam || repoParam === '.') return projectDir;
180
185
  if (repoParam.includes('/') || repoParam.includes('..') || repoParam.includes('\\')) return null;
181
186
  const candidate = join(projectDir, repoParam);
@@ -491,34 +496,108 @@ const MIME_TYPES = {
491
496
  '.woff2': 'font/woff2',
492
497
  };
493
498
 
494
- // S6b: v2 live feed — the live channel when the v2 writer is active. Lazy
495
- // singleton so worker/test paths that never watch anything pay nothing.
496
- let _v2LiveFeed = null;
497
- function _ensureV2LiveFeed() {
498
- if (!_v2LiveFeed) {
499
- _v2LiveFeed = new V2LiveFeed({
499
+ // S6b: v2 live feed — the live channel when the v2 writer is active. ONE feed
500
+ // per project (the bound project plus any project a client is currently
501
+ // VIEWING via /events?project=). Feeds are lazy so worker/test paths that never
502
+ // watch anything pay nothing. Keyed by the absolute project dir.
503
+ const _v2LiveFeeds = new Map(); // projectDir → V2LiveFeed
504
+
505
+ // Per-project client filter handed to every feed: a feed for project P must
506
+ // broadcast only to SSE clients whose VIEWED project is P. Pure predicate lives
507
+ // in lib/v2/view-router.js (unit-tested); this closure just binds the live
508
+ // bound-project name.
509
+ function _filterClientsByViewProject(clientList, project) {
510
+ return filterClientsByViewProject(clientList, project, _projectName || '');
511
+ }
512
+
513
+ function _ensureProjectFeed(projectName) {
514
+ if (!projectName) return null;
515
+ const projectDir = join(LOG_DIR, sanitizePathComponent(projectName));
516
+ let feed = _v2LiveFeeds.get(projectDir);
517
+ if (!feed) {
518
+ feed = new V2LiveFeed({
500
519
  clients,
501
520
  getClaudePid,
502
521
  runParallelHook,
503
522
  // The stats worker's v2 units are session dirs (stats-worker.js v9).
504
523
  notifyStatsWorker,
505
524
  wireV3: WIRE_V3,
525
+ project: projectName,
526
+ clientFilter: _filterClientsByViewProject,
506
527
  });
528
+ feed.start(projectDir);
529
+ _v2LiveFeeds.set(projectDir, feed);
530
+ } else if (!feed._active) {
531
+ feed.start(projectDir);
507
532
  }
508
- return _v2LiveFeed;
533
+ return feed;
509
534
  }
510
535
 
511
- /** Start the live channel for the current project (v2 live feed). No-op until
512
- * a project is bound (workspace mode defers to the launch route). */
536
+ /** Start the live channel for the bound project (v2 live feed) and route the
537
+ * writer's per-session activity to the right project's feed. No-op until a
538
+ * project is bound (workspace mode defers to the launch route). */
513
539
  function startLogWatch() {
514
540
  if (!_projectName) return;
515
- const feed = _ensureV2LiveFeed();
516
- feed.start(join(LOG_DIR, sanitizePathComponent(_projectName)));
517
- _v2Writer.setOnActivity((dir) => feed.tick(dir));
541
+ _ensureProjectFeed(_projectName);
542
+ // The writer holds sessions for EVERY live project (per-project PTYs write
543
+ // through it). Route each activity dir to its owning project's feed by the
544
+ // <LOG_DIR>/<projectSan>/sessions/ prefix, so a viewed background project's
545
+ // live entries reach ITS feed (and its viewing clients) too.
546
+ _v2Writer.setOnActivity((dir) => {
547
+ const feed = _feedForSessionDir(dir);
548
+ if (feed) feed.tick(dir);
549
+ });
550
+ // Feed each resolved (sid, project) into the PTY sid index so chat sends can
551
+ // be routed to the PTY owning that conversation (anchor routing, 2026-10-05).
552
+ // Lazy import keeps pty-manager out of the early module graph (and off the
553
+ // boundary allowlist); a failure must never break logging.
554
+ // Multi-instance (2026-10-06): prefer the instanceKey-exact route when the claude
555
+ // self-reported one (`x-ccv-instance`) — it pins the sid to THIS process, which basename
556
+ // routing cannot do for two same-cwd instances. Fall back to the basename route for
557
+ // legacy / external producers that send no instance header.
558
+ _v2Writer.setOnSessionResolved((sid, project, instanceKey) => {
559
+ import('./pty-manager.js')
560
+ .then((pm) => {
561
+ try {
562
+ const pinned = instanceKey && typeof pm.setPtySessionIdForInstance === 'function'
563
+ ? pm.setPtySessionIdForInstance(instanceKey, sid)
564
+ : false;
565
+ if (!pinned) pm.setPtySessionId(project, sid);
566
+ } catch (err) { reportSwallowed('server.sid-pin', err); }
567
+ })
568
+ .catch((err) => { reportSwallowed('server.sid-pin-import', err); });
569
+ });
570
+ }
571
+
572
+ // The bound project's feed (may not exist yet). Single key builder, shared by
573
+ // both _feedForSessionDir fallbacks so the ensure/fallback keys can't drift.
574
+ function _boundFeed() {
575
+ return _v2LiveFeeds.get(join(LOG_DIR, sanitizePathComponent(_projectName || ''))) || null;
576
+ }
577
+
578
+ // Resolve the live feed that owns a session dir, from its
579
+ // <LOG_DIR>/<projectSan>/sessions/<dir> path. When the prefix isolates a
580
+ // known project but that project has NO live feed (no client is viewing it —
581
+ // feeds are lazy), return null rather than falling back to the bound feed:
582
+ // the activity belongs to a DIFFERENT project, and routing it to the bound
583
+ // project's viewers is exactly the cross-project bleed this layer exists to
584
+ // prevent (2026-10-05). Only when the prefix does not resolve a project at all
585
+ // (a dir outside <LOG_DIR>/<projectSan>/sessions/) do we fall back to the
586
+ // bound feed defensively.
587
+ function _feedForSessionDir(dir) {
588
+ const { feedKey, allowBoundFallback } = resolveActivityFeedKey(dir, LOG_DIR, sep);
589
+ // A resolved project owns this activity: route to ITS feed, or drop when it
590
+ // has none (lazy feeds — no viewer, no broadcast). Never re-route a foreign
591
+ // project's activity to the bound feed (the cross-project bleed, 2026-10-05).
592
+ if (feedKey) return _v2LiveFeeds.get(join(LOG_DIR, feedKey)) || null;
593
+ // No owning project (dir outside every <LOG_DIR>/<projectSan>/sessions/
594
+ // root): bound feed, defensively — the legacy single-project behavior.
595
+ return allowBoundFallback ? _boundFeed() : null;
518
596
  }
519
597
 
520
598
  function stopLogWatch() {
521
- if (_v2LiveFeed) _v2LiveFeed.stop();
599
+ for (const feed of _v2LiveFeeds.values()) feed.stop();
600
+ _v2LiveFeeds.clear();
522
601
  _v2Writer.setOnActivity(null);
523
602
  }
524
603
 
@@ -611,6 +690,7 @@ const deps = {
611
690
  notifyParentPending: _notifyParentPending,
612
691
  startLogWatch,
613
692
  stopLogWatch,
693
+ ensureProjectFeed: _ensureProjectFeed,
614
694
  scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
615
695
  // SessionStart hook notify → conversation-switch signal (in-terminal
616
696
  // /resume). The source gate + V2Writer re-bind live in the interceptor.
@@ -733,6 +813,7 @@ const _routes = [
733
813
  ...dingtalkRoutes,
734
814
  ...imRoutes,
735
815
  ...proxyStatsRoutes,
816
+ ...resumeRoutes,
736
817
  ];
737
818
  const dispatch = createDispatcher(_routes);
738
819
 
@@ -1299,7 +1380,7 @@ export async function startViewer() {
1299
1380
  async function setupTerminalWebSocket(httpServer) {
1300
1381
  try {
1301
1382
  const { WebSocketServer } = await import('ws');
1302
- const { writeToPty, writeToPtySequential, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart } = await import('./pty-manager.js');
1383
+ const { writeToPty, writeToPtySequential, writeToPtyFor, writeToPtySequentialFor, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart, attachPtyFor } = await import('./pty-manager.js');
1303
1384
  const {
1304
1385
  spawnScratch,
1305
1386
  writeScratch,
@@ -1324,6 +1405,7 @@ async function setupTerminalWebSocket(httpServer) {
1324
1405
  chatQueue.initChatQueue({
1325
1406
  writeToPty,
1326
1407
  writeToPtySequential,
1408
+ writeToPtySequentialFor,
1327
1409
  getPtyKind,
1328
1410
  isStreaming: () => streamingState.active,
1329
1411
  hasPendingApproval: () => pendingAskHooks.size > 0 || pendingPermHooks.size > 0,
@@ -1379,6 +1461,18 @@ async function setupTerminalWebSocket(httpServer) {
1379
1461
  return;
1380
1462
  }
1381
1463
  if (pathname === '/ws/terminal') {
1464
+ // Multi-PTY view attach (2026-10): `?project=<name>` pins the freshly
1465
+ // connected client to the project it is VIEWING, so the initial
1466
+ // state/buffer frames below come from that project's record — not from
1467
+ // whatever project happened to spawn last. Unknown/absent project is a
1468
+ // silent no-op (single-project behavior unchanged).
1469
+ const viewProject = wsUrl.searchParams.get('project');
1470
+ // Multi-instance: `&instance=<key>` pins the attach to THAT exact process when a
1471
+ // same-cwd project runs two concurrent instances (basename alone is ambiguous).
1472
+ const viewInstance = wsUrl.searchParams.get('instance');
1473
+ if (viewProject || viewInstance) {
1474
+ try { attachPtyFor({ project: viewProject || undefined, instanceKey: viewInstance || undefined }); } catch { }
1475
+ }
1382
1476
  wss.handleUpgrade(req, socket, head, (ws) => {
1383
1477
  wss.emit('connection', ws, req);
1384
1478
  });
@@ -1783,11 +1877,11 @@ async function setupTerminalWebSocket(httpServer) {
1783
1877
  ws.on('message', async (raw) => {
1784
1878
  try {
1785
1879
  const msg = JSON.parse(raw.toString());
1786
- // SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize)一律 no-op,
1880
+ // SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize/attach)一律 no-op,
1787
1881
  // 否则 'input' 会在无会话时触发 spawnShell() 拉出一个用户看不见的裸 shell。
1788
1882
  // sdk-*/ask-*/perm-*/image-* 等控制消息不受影响。scratch WS 是独立 handler,不受此闸门影响。
1789
1883
  if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
1790
- || msg.type === 'resync-request' || msg.type === 'resize')) {
1884
+ || msg.type === 'resync-request' || msg.type === 'resize' || msg.type === 'attach')) {
1791
1885
  return;
1792
1886
  }
1793
1887
  if (msg.type === 'input') {
@@ -1814,6 +1908,12 @@ async function setupTerminalWebSocket(httpServer) {
1814
1908
  }
1815
1909
  }
1816
1910
  // 拦截连续 Ctrl+C:2秒内连按2次则阻止并提醒,避免误退出 CLI
1911
+ // Anchor routing (2026-10-05): a send carries the viewed project's
1912
+ // {project, sessionId} so the input lands on that conversation's PTY,
1913
+ // not whichever record the global activePtyKey last pointed to. Falls
1914
+ // back to the legacy active-targeted write when no anchor is present.
1915
+ const _anchor = { project: typeof msg.project === 'string' ? msg.project : undefined, sessionId: typeof msg.sessionId === 'string' ? msg.sessionId : undefined, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined };
1916
+ const _write = (data) => (_anchor.project || _anchor.sessionId || _anchor.instanceKey) ? writeToPtyFor(data, _anchor) : writeToPty(data);
1817
1917
  if (msg.data === '\x03') {
1818
1918
  const now = Date.now();
1819
1919
  if (!ws._ctrlCLastTime) ws._ctrlCLastTime = 0;
@@ -1823,10 +1923,10 @@ async function setupTerminalWebSocket(httpServer) {
1823
1923
  // 不发送第二次 Ctrl+C 到 PTY
1824
1924
  } else {
1825
1925
  ws._ctrlCLastTime = now;
1826
- writeToPty(msg.data);
1926
+ _write(msg.data);
1827
1927
  }
1828
1928
  } else {
1829
- writeToPty(msg.data);
1929
+ _write(msg.data);
1830
1930
  }
1831
1931
  } else if (msg.type === 'input-sequential') {
1832
1932
  // Programmatic sequential input: send chunks one by one, waiting for PTY ACK
@@ -1852,7 +1952,12 @@ async function setupTerminalWebSocket(httpServer) {
1852
1952
  }
1853
1953
  };
1854
1954
  if (Array.isArray(chunks) && chunks.length > 0 && chunks.every(c => typeof c === 'string')) {
1855
- writeToPtySequential(chunks, replyDone, { settleMs: msg.settleMs || 150 });
1955
+ const _seqAnchor = { project: typeof msg.project === 'string' ? msg.project : undefined, sessionId: typeof msg.sessionId === 'string' ? msg.sessionId : undefined, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined };
1956
+ if (_seqAnchor.project || _seqAnchor.sessionId || _seqAnchor.instanceKey) {
1957
+ writeToPtySequentialFor(chunks, replyDone, { settleMs: msg.settleMs || 150 }, _seqAnchor);
1958
+ } else {
1959
+ writeToPtySequential(chunks, replyDone, { settleMs: msg.settleMs || 150 });
1960
+ }
1856
1961
  } else {
1857
1962
  replyDone(false);
1858
1963
  }
@@ -1862,6 +1967,39 @@ async function setupTerminalWebSocket(httpServer) {
1862
1967
  floodGate.reset();
1863
1968
  sendResync();
1864
1969
  }
1970
+ } else if (msg.type === 'attach') {
1971
+ // Multi-PTY view attach (2026-10): the client switched the main view to
1972
+ // another parallel project (chip click / launch / cold-load landing) —
1973
+ // re-anchor the shared PTY stream to THAT project's record, then replay
1974
+ // its outputBuffer so the terminal paints the right screen immediately.
1975
+ // attachPtyFor is idempotent and never spawns/kills; an unknown project
1976
+ // degrades to attach-failed with the current view left untouched.
1977
+ //
1978
+ // KNOWN LIMITATION (documented, pre-existing): the attachment is a single
1979
+ // server-wide pointer, not per-connection. Two tabs viewing DIFFERENT
1980
+ // projects share it — the last attach wins and the other tab's terminal
1981
+ // follows the new project too (its chat view is unaffected: SSE per-project
1982
+ // filtering is per-connection). Same-project tabs attach identically →
1983
+ // no-op. A per-connection PTY mux is out of scope for this fix.
1984
+ const target = typeof msg.project === 'string' ? msg.project : '';
1985
+ // Multi-instance: an explicit instanceKey pins the attach to THAT exact process
1986
+ // (a same-cwd project's basename alone is ambiguous across two live instances).
1987
+ const targetInstance = typeof msg.instanceKey === 'string' ? msg.instanceKey : '';
1988
+ let res = null;
1989
+ try { res = (target || targetInstance) ? attachPtyFor({ project: target || undefined, instanceKey: targetInstance || undefined }) : null; } catch { res = null; }
1990
+ if (res && res.ok) {
1991
+ try { ws.send(JSON.stringify({ type: 'attached', project: target, running: res.running, ptyKind: res.ptyKind, exitCode: res.exitCode })); } catch { }
1992
+ const snap = getOutputBuffer();
1993
+ if (snap) {
1994
+ try { ws.send(JSON.stringify({ type: 'data', data: snap })); } catch { }
1995
+ }
1996
+ } else {
1997
+ // Multi-instance: surface an ambiguous project-name attach (with the live
1998
+ // candidates) so the client re-issues with an instanceKey instead of silently
1999
+ // pinning an arbitrary same-cwd process.
2000
+ const reason = (res && res.reason) || 'not-found';
2001
+ try { ws.send(JSON.stringify({ type: 'attach-failed', project: target, reason, ...(res && res.candidates ? { candidates: res.candidates } : {}) })); } catch { }
2002
+ }
1865
2003
  } else if (msg.type === 'ask-hook-answer') {
1866
2004
  // Client answered AskUserQuestion via hook bridge.
1867
2005
  // New protocol: msg.id required to address one of multiple pending asks.
@@ -2124,7 +2262,9 @@ async function setupTerminalWebSocket(httpServer) {
2124
2262
  });
2125
2263
  }
2126
2264
  } else {
2127
- try { chatQueue.enqueue(msg.text); } catch (err) { reportSwallowed('server.queue-message', err); }
2265
+ const _qAnchor = { project: typeof msg.project === 'string' ? msg.project : undefined, sessionId: typeof msg.sessionId === 'string' ? msg.sessionId : undefined, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined };
2266
+ const _anchorArg = (_qAnchor.project || _qAnchor.sessionId || _qAnchor.instanceKey) ? _qAnchor : undefined;
2267
+ try { chatQueue.enqueue(msg.text, _anchorArg); } catch (err) { reportSwallowed('server.queue-message', err); }
2128
2268
  }
2129
2269
  }
2130
2270
  } else if (msg.type === 'queue-send-now') {
@@ -2493,7 +2633,15 @@ export function startStreamingStatusTimer() {
2493
2633
  const data = isActive
2494
2634
  ? { ...streamingState, elapsed: Date.now() - streamingState.startTime }
2495
2635
  : { active: false };
2496
- if (clients.length > 0 && sendEventToClients) sendEventToClients(clients, 'streaming_status', data);
2636
+ // Multi-project (2026-10): streamingState is a global singleton tracking the
2637
+ // BOUND project's main process — route the spinner only to clients viewing
2638
+ // that project, so a parallel project's view never shows the bound
2639
+ // project's streaming flag (cross-project spinner bleed). (Per-project
2640
+ // streaming state is a follow-up; today a parallel view simply shows no
2641
+ // spinner, matching the pre-multi-PTY single-project behavior.)
2642
+ if (clients.length > 0 && sendEventToClients) {
2643
+ sendEventToClients(_filterClientsByViewProject(clients, _projectName || ''), 'streaming_status', data);
2644
+ }
2497
2645
  }
2498
2646
  }, 500);
2499
2647
  _streamingStatusTimer.unref();
@@ -2725,7 +2873,25 @@ if (!globalThis._ccvServerSignalsRegistered) {
2725
2873
  process.on('exit', handleExit);
2726
2874
  // hardened:watchdog 5s 强退 + 重复触发立退(防 Windows 上 stopViewer 内部
2727
2875
  // await 挂住导致 .finally(exit) 永不执行 = Ctrl+C 完全无反应)。
2728
- const _hardenedStop = createHardenedCleanup({ doCleanup: () => stopViewer() });
2876
+ // Also reap scratch/resume children at this PROCESS-level exit point —
2877
+ // without it they orphan on SIGINT/SIGTERM (node-pty children are session
2878
+ // leaders, not cascade-killed by the OS). killAllScratch is idempotent.
2879
+ // _doStop (the session-level stop/start cycle point) deliberately does NOT
2880
+ // kill scratch PTYs: Electron tab switches go through it and must not reap a
2881
+ // resume child owned by another tab.
2882
+ const _hardenedStop = createHardenedCleanup({
2883
+ doCleanup: () => Promise.all([
2884
+ stopViewer(),
2885
+ import('./scratch-pty-manager.js')
2886
+ .then(({ killAllScratch }) => killAllScratch())
2887
+ .catch((e) => reportSwallowed('server.stop.scratch', e)),
2888
+ // Also reap every kept-alive main PTY (cross-project /resume leaves old
2889
+ // projects' main PTYs running); idempotent, equals killPty() when single.
2890
+ import('./pty-manager.js')
2891
+ .then(({ killAllMain }) => killAllMain())
2892
+ .catch((e) => reportSwallowed('server.stop.mainpty', e)),
2893
+ ]),
2894
+ });
2729
2895
  process.on('SIGINT', _hardenedStop);
2730
2896
  process.on('SIGTERM', _hardenedStop);
2731
2897
  }
@@ -36,7 +36,7 @@ IMPORTANT: Never generate or guess URLs unless you are confident they help the u
36
36
  - Do not wait passively for a teammate to report back: teammates sometimes finish their task without sending you a message. No notification ≠ not done — check `~/.claude/projects/<slug>/<session>/subagents/agent-*.jsonl` (or SendMessage the teammate) before waiting.
37
37
  - When a teammate goes quiet, ask it directly for its result or status — treat silence as "finished or stuck", never as "still working". Read results back with `tail -N <file> | jq -r 'select(.type=="assistant") | .message.content[] | select(.type=="text") | .text'` — never Read the whole jsonl.
38
38
  - Before telling the user a delegated task is blocked or incomplete, ping the teammate once; escalate to the user only if it still does not respond. After 2 empty wait rounds, change strategy; after ~10 minutes, do it yourself or tell the user. Never idle-poll.
39
- - When waiting for an async result (teammate/subagent report, background task), do not poll by repeatedly calling tools. These results arrive automatically as messages — stop calling tools, end your turn, and let them arrive. Reserve Monitor-style event-driven waiting for external state only (files, processes, ports). If you catch yourself checking the same thing's progress two turns in a row, that is polling — stop immediately.
39
+ - When waiting for an async result (teammate/subagent report, background task), end your turn and let it arrive — these results come back automatically as messages, so there is nothing to check. Never poll for completion: no Monitor, CronCreate, `sleep`, re-reading the agent's output file, or repeated TaskList/status checks. And never fill the wait with "waiting…" filler text or empty echo/status lines — if you have nothing to advance, stop. One short "delegated, awaiting result" line is enough. Reserve Monitor-style event-driven waiting for external state only (files, processes, ports). If you catch yourself checking the same thing's progress two turns in a row, that is polling — stop immediately.
40
40
 
41
41
  # Executing actions with care
42
42
  Consider the reversibility and blast radius of each action. Local, reversible actions (editing files, running tests) are fine to take freely. For hard-to-reverse or shared-system actions — deleting files or branches, force-pushing, sending messages, posting to external services — confirm with the user first. Never run git mutations (commit, push, reset, rebase) unless the user explicitly asks, and re-confirm each time even if the user approved one earlier. Investigate unexpected state before overwriting it.
@@ -1,5 +1,6 @@
1
1
  // Workspace Registry - 工作区持久化管理
2
2
  import { readdir, stat } from 'node:fs/promises';
3
+ import { statSync } from 'node:fs';
3
4
  import { mutateJson, readJsonSafe, writeJsonAtomic } from './lib/json-store.js';
4
5
  import { dirSizeSync } from './lib/v2/layout.js';
5
6
  import { isDiscardableSession } from './lib/v2/session-select.js';
@@ -32,7 +33,7 @@ export async function registerWorkspace(absolutePath) {
32
33
  const result = await mutateJson(getWorkspacesFile(), (data) => {
33
34
  const resolvedPath = resolve(absolutePath);
34
35
  const projectName = basename(resolvedPath).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
35
- const list = Array.isArray(data.workspaces) ? data.workspaces : [];
36
+ let list = Array.isArray(data.workspaces) ? data.workspaces : [];
36
37
  // Windows NTFS 不分大小写——`C:\App` 跟 `c:\app` 是同目录但 `===` 视为不同。
37
38
  // 仅 Win 下小写化比较;POSIX 保持原样不引入回归。
38
39
  const pathEq = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
@@ -43,6 +44,19 @@ export async function registerWorkspace(absolutePath) {
43
44
  data.workspaces = list;
44
45
  return existing;
45
46
  }
47
+ // Gentle self-heal (2026-10-05): a same-name entry whose path no longer
48
+ // exists (moved/deleted checkout residue) is pruned on register — otherwise
49
+ // the collision makes view-root's no-live-PTY branch 400. Same-name entries
50
+ // whose path still exists are kept (never auto-delete user data).
51
+ // Only ENOENT/ENOTDIR count as "gone": existsSync() also returns false on
52
+ // EIO/EACCES/EPERM/a transiently unmounted volume, which must NOT prune a
53
+ // legitimate workspace.
54
+ const isGone = (p) => {
55
+ try { statSync(p); return false; }
56
+ catch (e) { return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR'); }
57
+ };
58
+ list = list.filter(w =>
59
+ w.projectName !== projectName || pathEq(w.path, resolvedPath) || !isGone(w.path));
46
60
  const now = new Date().toISOString();
47
61
  const entry = {
48
62
  id: randomBytes(6).toString('hex'),