cc-viewer 1.8.20 → 1.8.21
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.
- package/cli.js +33 -4
- package/dist/assets/App-BmZyzd9Q.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-Dsnv2wIw.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/Mobile-C10tOIQo.js +1 -0
- package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
- package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
- package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
- package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
- package/dist/assets/clone-CFj55h49.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
- package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
- package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
- package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
- package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
- package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
- package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
- package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
- package/dist/assets/index-DyVKoGOX.js +2 -0
- package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
- package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
- package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
- package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
- package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
- package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
- package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
- package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
- package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
- package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
- package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/interceptor.js +119 -13
- package/server/lib/chat-queue.js +67 -4
- package/server/lib/same-origin.js +22 -0
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/layout.js +28 -3
- package/server/lib/v2/live-feed.js +41 -4
- package/server/lib/v2/resume-list.js +150 -0
- package/server/lib/v2/session-select.js +63 -14
- package/server/lib/v2/v2-writer.js +172 -34
- package/server/lib/v2/view-router.js +83 -0
- package/server/lib/view-root.js +209 -0
- package/server/pty-manager.js +790 -159
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/events.js +123 -7
- package/server/routes/expert.js +46 -14
- package/server/routes/files-content.js +72 -7
- package/server/routes/files-fs.js +28 -3
- package/server/routes/git.js +31 -5
- package/server/routes/preferences.js +1 -16
- package/server/routes/project-meta.js +32 -3
- package/server/routes/proxy-stats.js +33 -5
- package/server/routes/resume.js +302 -0
- package/server/routes/search.js +23 -1
- package/server/routes/skills.js +18 -2
- package/server/routes/v2.js +6 -1
- package/server/routes/workspaces.js +10 -3
- package/server/server.js +191 -25
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/workspace-registry.js +15 -1
- package/dist/assets/App-CesjoFSv.js +0 -2
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/Mobile-bvGZ392d.js +0 -1
- package/dist/assets/ProxyStatsModal-HFk51cI8.js +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
- package/dist/assets/clone-B0_ghF3F.js +0 -1
- package/dist/assets/index-DanpP6qP.js +0 -2
- package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
- 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
|
-
|
|
178
|
-
|
|
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.
|
|
495
|
-
//
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
|
533
|
+
return feed;
|
|
509
534
|
}
|
|
510
535
|
|
|
511
|
-
/** Start the live channel for the
|
|
512
|
-
*
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1926
|
+
_write(msg.data);
|
|
1827
1927
|
}
|
|
1828
1928
|
} else {
|
|
1829
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
-
|
|
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'),
|