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.
- package/cli.js +33 -4
- package/dist/assets/App-DM-fbhNd.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/{MdxEditorPanel-Dsnv2wIw.js → MdxEditorPanel-DjwOQDh0.js} +1 -1
- package/dist/assets/Mobile-BD-hefIH.js +1 -0
- package/dist/assets/ProxyStatsModal-pYa_0QVL.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-DkIhbLjr.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-BdWMUSrC.css +41 -0
- package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +2 -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/interceptor.js
CHANGED
|
@@ -566,23 +566,49 @@ export function markSessionStart(payload) {
|
|
|
566
566
|
console.warn('[ccv session-start] resume signal without transcript_path — ignored');
|
|
567
567
|
return;
|
|
568
568
|
}
|
|
569
|
-
//
|
|
570
|
-
// carries our CCVIEWER_PORT must not re-bind THIS project's
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
569
|
+
// Cross-project guard (2026-10-06, relaxed for true-/resume): a foreign project's
|
|
570
|
+
// claude that somehow carries our CCVIEWER_PORT must not re-bind THIS project's
|
|
571
|
+
// writer — BUT a resume in a PARALLEL project this server also manages (a live ccv
|
|
572
|
+
// PTY whose cwd maps to that project) is legitimate and must re-bind keyed by THAT
|
|
573
|
+
// project. Resolve the target project; drop only when neither bound nor a known
|
|
574
|
+
// live ccv project. This is what lets /api/resume-session switch a parallel
|
|
575
|
+
// viewed project and still have its writer follow.
|
|
576
|
+
let resumeProject = null; // null = bound project (default)
|
|
577
|
+
if (cwd && typeof cwd === 'string') {
|
|
574
578
|
const cwdProject = projectKeyForCwd(cwd);
|
|
575
|
-
if (cwdProject
|
|
576
|
-
|
|
577
|
-
|
|
579
|
+
if (_projectName && cwdProject === _projectName) {
|
|
580
|
+
resumeProject = null; // bound project — default key
|
|
581
|
+
} else {
|
|
582
|
+
// Synchronously resolvable? No — isLiveClaudeProject lives in pty-manager
|
|
583
|
+
// (L2), imported lazily below. Compute the candidate key here and let the
|
|
584
|
+
// async gate decide whether to arm the switch.
|
|
585
|
+
resumeProject = cwdProject || null;
|
|
578
586
|
}
|
|
579
587
|
}
|
|
580
588
|
const transcriptUuid = basename(transcriptPath, '.jsonl');
|
|
581
589
|
if (source === 'resume') {
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
590
|
+
const armSwitch = () => {
|
|
591
|
+
_v2Writer.beginResumeSwitch({
|
|
592
|
+
transcriptUuid,
|
|
593
|
+
hookSid: (typeof sessionId === 'string' && sessionId) ? sessionId : null,
|
|
594
|
+
// Keyed by the resumed conversation's project (bound = undefined default).
|
|
595
|
+
project: resumeProject || undefined,
|
|
596
|
+
});
|
|
597
|
+
};
|
|
598
|
+
if (!resumeProject || (_projectName && resumeProject === _projectName)) {
|
|
599
|
+
armSwitch();
|
|
600
|
+
} else {
|
|
601
|
+
// Parallel project: only arm when it's a live ccv-managed claude PTY.
|
|
602
|
+
import('./pty-manager.js')
|
|
603
|
+
.then((pm) => {
|
|
604
|
+
if (pm && typeof pm.isLiveClaudeProject === 'function' && pm.isLiveClaudeProject(resumeProject)) {
|
|
605
|
+
armSwitch();
|
|
606
|
+
} else {
|
|
607
|
+
console.warn(`[ccv session-start] resume signal from unmanaged project "${resumeProject}" ignored`);
|
|
608
|
+
}
|
|
609
|
+
})
|
|
610
|
+
.catch((err) => reportSwallowed('session-start.resume-project-gate', err));
|
|
611
|
+
}
|
|
586
612
|
}
|
|
587
613
|
// System-prompt snapshot, Bind B (system-prompt-snapshots.js): a process-level
|
|
588
614
|
// -c/-r launch queued a resumeExpected pending at spawn (pty-manager). The
|
|
@@ -682,13 +708,20 @@ export function initForWorkspace(projectPath, { forceNew = false } = {}) { // es
|
|
|
682
708
|
const dir = join(LOG_DIR, projectName);
|
|
683
709
|
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
684
710
|
|
|
711
|
+
// Capture the project being switched AWAY from BEFORE rebinding: a scoped
|
|
712
|
+
// resetSessions(below) drops only THAT project's cached session bindings, so a
|
|
713
|
+
// concurrently-running BACKGROUND project (a cross-project /resume takeover's
|
|
714
|
+
// old project, still writing via entry._resumeProject) keeps its bindings and
|
|
715
|
+
// continues uninterrupted. Same-project re-init passes the same name (a no-op
|
|
716
|
+
// scoped clear of just that project, matching the old full clear for it).
|
|
717
|
+
const previousProject = _projectName;
|
|
685
718
|
_projectName = projectName;
|
|
686
719
|
setProjectName(projectName);
|
|
687
720
|
_logDir = dir;
|
|
688
721
|
// Fresh workspace context — no carried names apply; future requests must
|
|
689
722
|
// create session dirs under the NEW project.
|
|
690
723
|
_agentSpawnRegistry.clear();
|
|
691
|
-
_v2Writer.resetSessions(); // also clears the per-process `-c` adoption latch
|
|
724
|
+
_v2Writer.resetSessions(previousProject || null); // also clears the per-process `-c` adoption latch
|
|
692
725
|
// A `--fork-session` / `-r` launch marks fork/resume intent; neither must
|
|
693
726
|
// leak into a LATER workspace's `-c` (in a long-lived server that would
|
|
694
727
|
// permanently suppress adoption). Clear both marks per launch and re-sync —
|
|
@@ -863,6 +896,9 @@ export function setupInterceptor() {
|
|
|
863
896
|
if (isProxyTrace && options?.headers) {
|
|
864
897
|
delete options.headers['x-cc-viewer-trace'];
|
|
865
898
|
}
|
|
899
|
+
// NOTE: `x-ccv-project-dir` is stripped LATER (right before _originalFetch,
|
|
900
|
+
// after requestEntry has extracted `_resumeProject` from it) — stripping it
|
|
901
|
+
// here would delete it before the conversion copy below could read it.
|
|
866
902
|
|
|
867
903
|
const timestamp = new Date().toISOString();
|
|
868
904
|
let body = null;
|
|
@@ -926,10 +962,47 @@ export function setupInterceptor() {
|
|
|
926
962
|
safeHeaders['authorization'] = '****';
|
|
927
963
|
}
|
|
928
964
|
}
|
|
965
|
+
// ccv-internal self-report headers (x-ccv-project-dir / x-ccv-instance) are routing
|
|
966
|
+
// metadata, not request content — drop them from the logged/forwarded `safeHeaders`
|
|
967
|
+
// copy so they never land in the on-disk journal or reach the frontend wire (they are
|
|
968
|
+
// extracted onto `_resumeProject`/`_ccvInstance` separately). Case-insensitive.
|
|
969
|
+
for (const k of Object.keys(safeHeaders)) {
|
|
970
|
+
const lk = k.toLowerCase();
|
|
971
|
+
if (lk === 'x-ccv-project-dir' || lk === 'x-ccv-instance') delete safeHeaders[k];
|
|
972
|
+
}
|
|
929
973
|
|
|
930
974
|
requestEntry = {
|
|
931
975
|
timestamp,
|
|
932
976
|
project: (() => { try { return basename(process.cwd()); } catch { return 'unknown'; } })(),
|
|
977
|
+
// /resume global sessions: a child claude spawned for resume carries
|
|
978
|
+
// `x-ccv-project-dir` = its own cwd, so its traffic is logged under THAT
|
|
979
|
+
// project rather than this ccv process's cwd. Set ONLY the dedicated
|
|
980
|
+
// `_resumeProject` override (the writer prefers it over the bound
|
|
981
|
+
// project); `entry.project` keeps its original process-cwd meaning for
|
|
982
|
+
// every other consumer. projectKeyForCwd takes only the basename and
|
|
983
|
+
// sanitizes it, so the header cannot path-traverse.
|
|
984
|
+
...(() => {
|
|
985
|
+
const dir = findHeader(headers, 'x-ccv-project-dir');
|
|
986
|
+
const proj = (typeof dir === 'string' && dir) ? projectKeyForCwd(dir) : '';
|
|
987
|
+
if (!proj) return {};
|
|
988
|
+
// Same-name session isolation (2026-10-07): also surface the RAW full cwd
|
|
989
|
+
// (`dir`, the findHeader output — NOT the projectKeyForCwd basename above)
|
|
990
|
+
// as `_resumeProjectCwd`, so the writer can stamp `meta.cwd` on the session
|
|
991
|
+
// dir. The read-side fallback then tightens by full-path cwd, telling two
|
|
992
|
+
// cross-dir same-basename projects apart (basename routing cannot). Using the
|
|
993
|
+
// basename here would never equal the read side's full-path targetCwd.
|
|
994
|
+
return { _resumeProject: proj, _resumeProjectCwd: dir };
|
|
995
|
+
})(),
|
|
996
|
+
// Multi-instance (2026-10-06): the spawned claude also self-reports its PTY
|
|
997
|
+
// instanceKey via `x-ccv-instance` (spawnClaude injects it). Surface it as
|
|
998
|
+
// `_ccvInstance` so the writer can pin the resolved sessionId to THIS exact process
|
|
999
|
+
// — basename routing cannot tell two same-cwd instances apart. Validated to the
|
|
1000
|
+
// minted `ccv-<hex>` shape before use (it becomes a Map key downstream).
|
|
1001
|
+
...(() => {
|
|
1002
|
+
const inst = findHeader(headers, 'x-ccv-instance');
|
|
1003
|
+
const key = (typeof inst === 'string' && /^ccv-[0-9a-f]+$/.test(inst)) ? inst : '';
|
|
1004
|
+
return key ? { _ccvInstance: key } : {};
|
|
1005
|
+
})(),
|
|
933
1006
|
url: urlStr,
|
|
934
1007
|
method: options?.method || 'GET',
|
|
935
1008
|
headers: safeHeaders,
|
|
@@ -1231,6 +1304,32 @@ export function setupInterceptor() {
|
|
|
1231
1304
|
catch (err) { reportSwallowed('interceptor.ingest-request', err); }
|
|
1232
1305
|
}
|
|
1233
1306
|
|
|
1307
|
+
// Strip the ccv-internal project self-report header right before forwarding
|
|
1308
|
+
// upstream — AFTER requestEntry already read `_resumeProject` from it. Strip
|
|
1309
|
+
// case-insensitively (the claude client applies it via ANTHROPIC_CUSTOM_HEADERS,
|
|
1310
|
+
// so casing is not under ccv's control). `_fetchOpts` starts as the same object
|
|
1311
|
+
// as `options` and is only shallow-copied on rewrite, so scrub BOTH.
|
|
1312
|
+
// `x-ccv-instance` (the multi-instance self-report) is stripped the same way —
|
|
1313
|
+
// it must never reach Anthropic.
|
|
1314
|
+
const CCV_INTERNAL_HEADERS = ['x-ccv-project-dir', 'x-ccv-instance'];
|
|
1315
|
+
const scrubHeaderBag = (hdrs) => {
|
|
1316
|
+
if (!hdrs) return;
|
|
1317
|
+
if (hdrs instanceof Headers) {
|
|
1318
|
+
for (const name of CCV_INTERNAL_HEADERS) { if (hdrs.has(name)) hdrs.delete(name); }
|
|
1319
|
+
} else if (typeof hdrs === 'object') {
|
|
1320
|
+
for (const k of Object.keys(hdrs)) {
|
|
1321
|
+
if (CCV_INTERNAL_HEADERS.includes(k.toLowerCase())) delete hdrs[k];
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
for (const optsBag of [options, _fetchOpts]) {
|
|
1326
|
+
scrubHeaderBag(optsBag && optsBag.headers);
|
|
1327
|
+
}
|
|
1328
|
+
// Request-object form: `fetch(new Request(...))` carries headers on the Request itself
|
|
1329
|
+
// (options may be undefined), so scrub those too or the internal header reaches upstream.
|
|
1330
|
+
if (typeof Request !== 'undefined' && _fetchUrl instanceof Request) scrubHeaderBag(_fetchUrl.headers);
|
|
1331
|
+
if (typeof Request !== 'undefined' && url instanceof Request && url !== _fetchUrl) scrubHeaderBag(url.headers);
|
|
1332
|
+
|
|
1234
1333
|
let response;
|
|
1235
1334
|
try {
|
|
1236
1335
|
response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
|
|
@@ -1291,6 +1390,10 @@ export function setupInterceptor() {
|
|
|
1291
1390
|
url: requestEntry.url,
|
|
1292
1391
|
response: { body: snap },
|
|
1293
1392
|
body: { model: requestEntry.body?.model },
|
|
1393
|
+
// B3 (multi-session view-attach): tag the live overlay stream with the
|
|
1394
|
+
// session uuid so an attached client can show only ITS sid's typewriter
|
|
1395
|
+
// and drop the other session's. Additive — older clients ignore it.
|
|
1396
|
+
sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
|
|
1294
1397
|
};
|
|
1295
1398
|
sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
|
|
1296
1399
|
// 413 → 禁用当次流式,后续全由最终 v2 completion 交付
|
|
@@ -1314,6 +1417,9 @@ export function setupInterceptor() {
|
|
|
1314
1417
|
url: requestEntry.url,
|
|
1315
1418
|
response: { body: null },
|
|
1316
1419
|
body: { model: requestEntry.body?.model },
|
|
1420
|
+
// B3: same sessionId tag on the skeleton (seq 0) so the overlay filter
|
|
1421
|
+
// keys on (timestamp, sessionId) from the very first frame.
|
|
1422
|
+
sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
|
|
1317
1423
|
}, 0, (ok) => { if (!ok) liveStreamEnabled = false; });
|
|
1318
1424
|
}
|
|
1319
1425
|
|
package/server/lib/chat-queue.js
CHANGED
|
@@ -67,11 +67,12 @@ function _broadcast() {
|
|
|
67
67
|
catch (err) { reportSwallowed('chat-queue.broadcast', err); }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
function _makeItem(text) {
|
|
70
|
+
function _makeItem(text, anchor) {
|
|
71
71
|
return {
|
|
72
72
|
id: 'q_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
|
|
73
73
|
text,
|
|
74
74
|
ts: Date.now(),
|
|
75
|
+
anchor: anchor || null, // { project, sessionId } from the enqueueing client (2026-10-05)
|
|
75
76
|
};
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -95,6 +96,13 @@ function _clearEscTimer() {
|
|
|
95
96
|
* NOTE 2: `isStreaming` (streamingState.active) flickers false between tool calls mid-turn;
|
|
96
97
|
* injecting into that gap is harmless (the TUI natively queues the paste) but it means an
|
|
97
98
|
* "immediate" inject can land inside the current turn's chain — accepted risk class.
|
|
99
|
+
* KNOWN LIMITATION (multi-project, 2026-10-05): the busy gate is global/bound-scoped —
|
|
100
|
+
* `isStreaming` reads the bound project's streamingState and `getPtyKind` follows the
|
|
101
|
+
* global active PTY, and `_items` is a single FIFO across projects. A message queued on
|
|
102
|
+
* project A can be head-of-line blocked by (or misjudge the busyness of) project B.
|
|
103
|
+
* Injection itself IS anchor-routed (item.anchor), so a queued message still lands on
|
|
104
|
+
* the project it was written in; only the timing/busy heuristic is not per-project.
|
|
105
|
+
* Per-project streaming state + queue isolation is a follow-up.
|
|
98
106
|
*/
|
|
99
107
|
function _safeToInject() {
|
|
100
108
|
if (!_deps) return false;
|
|
@@ -126,7 +134,12 @@ function _injectHead() {
|
|
|
126
134
|
_injecting = true;
|
|
127
135
|
const gen = _gen; // invalidate on clear()/stop(): a stale completion must not re-park
|
|
128
136
|
try {
|
|
129
|
-
|
|
137
|
+
// Route the inject by the enqueueing client's anchor when one was captured,
|
|
138
|
+
// so a message queued while viewing project A never lands in project B's PTY.
|
|
139
|
+
const _write = _deps.writeToPtySequentialFor && item.anchor
|
|
140
|
+
? (chunks, done, opts) => _deps.writeToPtySequentialFor(chunks, done, opts, item.anchor)
|
|
141
|
+
: _deps.writeToPtySequential;
|
|
142
|
+
_write(bracketPasteSubmit(item.text), (ok) => {
|
|
130
143
|
_injecting = false;
|
|
131
144
|
if (gen !== _gen) return;
|
|
132
145
|
if (!ok) {
|
|
@@ -175,7 +188,7 @@ function _pollUntilSafe(fn, onTimeout) {
|
|
|
175
188
|
* `\x1b[201~` would otherwise break the paste frame), broadcasts, and arms a rescue timer for
|
|
176
189
|
* the stale-busy case (frontend's isStreaming lags the real turn end by up to its debounce).
|
|
177
190
|
*/
|
|
178
|
-
export function enqueue(text) {
|
|
191
|
+
export function enqueue(text, anchor) {
|
|
179
192
|
if (!_deps) return null;
|
|
180
193
|
const clean = sanitizeInbound(text == null ? '' : String(text)).trim();
|
|
181
194
|
if (!clean) return null;
|
|
@@ -186,7 +199,7 @@ export function enqueue(text) {
|
|
|
186
199
|
catch (err) { reportSwallowed('chat-queue.reject-broadcast', err); }
|
|
187
200
|
return null;
|
|
188
201
|
}
|
|
189
|
-
const item = _makeItem(clean);
|
|
202
|
+
const item = _makeItem(clean, anchor);
|
|
190
203
|
_items.push(item);
|
|
191
204
|
_clearRescueTimer();
|
|
192
205
|
_broadcast();
|
|
@@ -335,6 +348,56 @@ export function stop() {
|
|
|
335
348
|
_gen++;
|
|
336
349
|
}
|
|
337
350
|
|
|
351
|
+
const RESUME_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* True /resume (2026-10-06): inject `/resume <uuid>` into a target project's running claude
|
|
355
|
+
* PTY so the process switches INTO that session in place (Claude Code's native SessionStart
|
|
356
|
+
* hook then re-binds the v2 writer via beginResumeSwitch). This is the in-process switch —
|
|
357
|
+
* NOT a respawn — and must only happen when the TUI is observably idle.
|
|
358
|
+
*
|
|
359
|
+
* Promise-returning so the route can map the outcome to HTTP. Resolves:
|
|
360
|
+
* { ok:true } — the paste was written
|
|
361
|
+
* { ok:false, reason:'busy' } — TUI streaming / approval open / another inject or poll in
|
|
362
|
+
* flight / timed out waiting for idle (never blind-injects)
|
|
363
|
+
* { ok:false, reason:'bad-uuid' } — sessionUuid is not a UUID shape
|
|
364
|
+
* { ok:false, reason:'no-deps' } — initChatQueue never ran (SDK mode / not wired)
|
|
365
|
+
* { ok:false, reason:'write-failed' } — the PTY write failed or threw
|
|
366
|
+
*
|
|
367
|
+
* Shares the single `_injecting`/`_pollTimer` slots with the drain/sendNow paths, so it
|
|
368
|
+
* refuses up-front when either is owned (mirrors sendNow's guard) instead of clobbering a
|
|
369
|
+
* pending drain timer and stranding a queued message. The completion handler resets
|
|
370
|
+
* `_injecting` unconditionally (both callback and catch) so a mid-inject clear()/stop()
|
|
371
|
+
* generation bump can never wedge the queue.
|
|
372
|
+
*/
|
|
373
|
+
export function injectResumeCommand(sessionUuid, anchor) {
|
|
374
|
+
return new Promise((resolve) => {
|
|
375
|
+
if (!_deps) { resolve({ ok: false, reason: 'no-deps' }); return; }
|
|
376
|
+
const uuid = sanitizeInbound(String(sessionUuid == null ? '' : sessionUuid)).trim();
|
|
377
|
+
if (!RESUME_UUID_RE.test(uuid)) { resolve({ ok: false, reason: 'bad-uuid' }); return; }
|
|
378
|
+
// Refuse BEFORE touching _pollUntilSafe: a drain's poll owns the single _pollTimer slot,
|
|
379
|
+
// and an in-flight inject owns _injecting — barging in would strand a queued message.
|
|
380
|
+
if (_injecting || _pollTimer) { resolve({ ok: false, reason: 'busy' }); return; }
|
|
381
|
+
let settled = false;
|
|
382
|
+
const done = (r) => { if (!settled) { settled = true; resolve(r); } };
|
|
383
|
+
const inject = () => {
|
|
384
|
+
_injecting = true;
|
|
385
|
+
try {
|
|
386
|
+
_deps.writeToPtySequentialFor(bracketPasteSubmit(`/resume ${uuid}`), (ok) => {
|
|
387
|
+
_injecting = false;
|
|
388
|
+
done(ok ? { ok: true } : { ok: false, reason: 'write-failed' });
|
|
389
|
+
}, { settleMs: 250 }, anchor || {});
|
|
390
|
+
} catch (err) {
|
|
391
|
+
_injecting = false;
|
|
392
|
+
reportSwallowed('chat-queue.resume.inject', err);
|
|
393
|
+
done({ ok: false, reason: 'write-failed' });
|
|
394
|
+
}
|
|
395
|
+
};
|
|
396
|
+
if (_safeToInject()) { inject(); return; }
|
|
397
|
+
_pollUntilSafe(inject, () => done({ ok: false, reason: 'busy' }));
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
338
401
|
export function __resetForTests() {
|
|
339
402
|
stop();
|
|
340
403
|
_deps = null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Same-origin guard for sensitive browser-facing endpoints.
|
|
2
|
+
//
|
|
3
|
+
// Loopback identifies the socket peer, not the browser page that initiated a
|
|
4
|
+
// request. Since the server intentionally has permissive CORS for legacy APIs,
|
|
5
|
+
// sensitive endpoints (executable selection, process kill, ...) need their own
|
|
6
|
+
// same-origin guard stacked on the admin check: a cross-site page riding the
|
|
7
|
+
// user's browser (token cookie / ?token= URL) must not reach them, while
|
|
8
|
+
// CLI/native clients (no Origin header) and same-origin pages pass through.
|
|
9
|
+
//
|
|
10
|
+
// Canonical implementation — every call site imports from here.
|
|
11
|
+
export function isSameOriginBrowserRequest(req, parsedUrl) {
|
|
12
|
+
const origin = req.headers?.origin;
|
|
13
|
+
if (!origin) return true; // CLI/native clients do not send Origin.
|
|
14
|
+
if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
|
|
15
|
+
try {
|
|
16
|
+
const originUrl = new URL(origin);
|
|
17
|
+
if (parsedUrl?.origin) return originUrl.origin.toLowerCase() === parsedUrl.origin.toLowerCase();
|
|
18
|
+
return originUrl.host.toLowerCase() === String(req.headers?.host || '').toLowerCase();
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/server/lib/v2/convert.js
CHANGED
|
@@ -53,7 +53,7 @@ const STOP_CHECK_EVERY = 50; // entries between shouldStop() polls
|
|
|
53
53
|
const PROXY_SHARD_NAME = /^proxy_\d{4}-\d{2}-\d{2}\.jsonl$/;
|
|
54
54
|
|
|
55
55
|
/** Enumerate a project's v1 log files, ascending by filename timestamp.
|
|
56
|
-
* Excludes `_temp.jsonl`
|
|
56
|
+
* Excludes `_temp.jsonl` and `proxy_*.jsonl` (retry stats). */
|
|
57
57
|
export function listV1Files(projectDir) {
|
|
58
58
|
if (!existsSync(projectDir)) return [];
|
|
59
59
|
return readdirSync(projectDir)
|
package/server/lib/v2/layout.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// and the swallowed ENOENT would silently drop v2 lines (plan risk F2).
|
|
7
7
|
|
|
8
8
|
import { mkdirSync, writeFileSync, renameSync, existsSync, openSync, writeSync, fsyncSync, closeSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
10
|
|
|
11
11
|
// The wire-format version this build writes AND the only one it reads.
|
|
12
12
|
// Stamped into meta.json and the journal sentinel at session creation; the
|
|
@@ -28,6 +28,29 @@ export function sanitizePathComponent(name) {
|
|
|
28
28
|
return cleaned;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Normalize a session's owning cwd for cross-dir same-basename comparison
|
|
33
|
+
* (2026-10-07). Lexical ONLY — `resolve` collapses `.`/`..` and duplicate
|
|
34
|
+
* separators without touching the filesystem, then a trailing-separator strip
|
|
35
|
+
* evens `/a/x` vs `/a/x/`. (On POSIX `resolve` already drops trailing slashes,
|
|
36
|
+
* so the strip is redundant there; it still guards Windows `\` and the root
|
|
37
|
+
* case.) Deliberately NOT realpath: the write side stamps the raw spawn cwd and
|
|
38
|
+
* the read side reads the live PTY record's raw cwd, and both are the SAME raw
|
|
39
|
+
* string — realpath would introduce a symlink-mismatch (/tmp → /private/tmp)
|
|
40
|
+
* where the two raw sides already agree byte-for-byte. Applied identically on
|
|
41
|
+
* the write (meta.cwd stamp), read-candidate, and read-target sides so the
|
|
42
|
+
* comparison stays symmetric across a respawn. Absolute cwds pass through
|
|
43
|
+
* deterministically; a relative cwd would resolve against process.cwd() (never
|
|
44
|
+
* occurs for a spawn cwd, which is always absolute).
|
|
45
|
+
* @param {string} cwd
|
|
46
|
+
* @returns {string} '' for empty/non-string input.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeSessionCwd(cwd) {
|
|
49
|
+
if (!cwd || typeof cwd !== 'string') return '';
|
|
50
|
+
const out = resolve(cwd);
|
|
51
|
+
return out.length > 1 ? out.replace(/[\\/]+$/, '') : out;
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
/** Session creation time (ISO) → a 14-digit LOCAL-time stamp `yyyymmddhhmmss`
|
|
32
55
|
* for the session dir-name prefix (task C). Deliberately NO internal separator
|
|
33
56
|
* (unlike log-management's `compactLocalTs`, which emits `YYYYMMDD_HHMMSS`) so
|
|
@@ -122,8 +145,10 @@ export function writeFileAtomicSync(finalPath, data) {
|
|
|
122
145
|
* targeting this session. Returns the paths object.
|
|
123
146
|
*
|
|
124
147
|
* meta: { wireFormat:2, sessionId, project, pid, startTs, userIdRaw,
|
|
125
|
-
* userIdEncoding, leader?, im? } — spec §3. Existing meta is never
|
|
126
|
-
* rewritten here (later additive updates go through updateMetaSync).
|
|
148
|
+
* userIdEncoding, leader?, im?, cwd? } — spec §3. Existing meta is never
|
|
149
|
+
* rewritten here (later additive updates go through updateMetaSync). `cwd` is
|
|
150
|
+
* the session's owning full working directory (same-name isolation, 2026-10-07);
|
|
151
|
+
* absent for legacy/external producers.
|
|
127
152
|
*/
|
|
128
153
|
export function ensureSessionDirSync(logDir, project, sessionId, meta = {}, sessionsDirName = SESSIONS_DIR_NAME, dirName = sessionId) {
|
|
129
154
|
// Path uses dirName (`<ts>_<uuid>` for live, task C); IDENTITY (meta.sessionId
|
|
@@ -183,6 +183,14 @@ export class V2LiveFeed {
|
|
|
183
183
|
// row (v2_requests_delta). Explicit ctor param — this module has no access
|
|
184
184
|
// to the server deps object.
|
|
185
185
|
this._wireV3 = !!opts.wireV3;
|
|
186
|
+
// Per-project view layer (2026-10): this feed's project name + an optional
|
|
187
|
+
// client filter. The server runs ONE feed per project (bound + any viewed);
|
|
188
|
+
// each broadcast must reach only the clients whose viewed project matches
|
|
189
|
+
// THIS feed's project. `clientFilter(clients, project)` returns the subset;
|
|
190
|
+
// the default (no filter) is the legacy broadcast-to-all behavior, so the
|
|
191
|
+
// single-project case is byte-identical.
|
|
192
|
+
this._project = typeof opts.project === 'string' ? opts.project : '';
|
|
193
|
+
this._clientFilter = typeof opts.clientFilter === 'function' ? opts.clientFilter : null;
|
|
186
194
|
this._sessions = new Map(); // dir → cursor bundle
|
|
187
195
|
this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
|
|
188
196
|
this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
|
|
@@ -193,6 +201,29 @@ export class V2LiveFeed {
|
|
|
193
201
|
this._active = false;
|
|
194
202
|
}
|
|
195
203
|
|
|
204
|
+
/** Clients this feed's broadcasts should reach right now: the full list, or
|
|
205
|
+
* the per-project filtered subset when a filter is installed. */
|
|
206
|
+
_clientsFor() {
|
|
207
|
+
return this._clientFilter ? this._clientFilter(this._clients, this._project) : this._clients;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Instance-scoped variant (multi-instance, 2026-10-06): narrow the per-project clients to
|
|
212
|
+
* those viewing the SAME instance as this session. Two concurrent same-cwd instances share
|
|
213
|
+
* one project feed, so this keeps instance A's entries out of instance B's viewers.
|
|
214
|
+
* Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
|
|
215
|
+
* single-instance view) sees every session regardless of `sessionInstance`; a session with
|
|
216
|
+
* no stamped instance (legacy/external) is likewise visible to all.
|
|
217
|
+
*/
|
|
218
|
+
_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
|
+
}
|
|
226
|
+
|
|
196
227
|
/** Begin (or switch to) live-following a project's sessions/ root. */
|
|
197
228
|
start(projectDir) {
|
|
198
229
|
this.stop();
|
|
@@ -374,9 +405,14 @@ export class V2LiveFeed {
|
|
|
374
405
|
// 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
|
|
375
406
|
// read, cloned, or broadcast; the cold-load channel owns their history.
|
|
376
407
|
let convertOrigin = false;
|
|
408
|
+
let sessionInstance = null;
|
|
377
409
|
try {
|
|
378
410
|
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
|
|
379
411
|
convertOrigin = !!meta && meta.origin === 'convert';
|
|
412
|
+
// Multi-instance (2026-10-06): the owning PTY instanceKey stamped on meta.json (by the
|
|
413
|
+
// writer) lets this feed route the session's entries to exactly the clients viewing
|
|
414
|
+
// THAT instance of a same-cwd project. Absent for legacy/convert/external sessions.
|
|
415
|
+
sessionInstance = (meta && typeof meta.instance === 'string' && meta.instance) ? meta.instance : null;
|
|
380
416
|
} catch (err) {
|
|
381
417
|
// meta.json is written synchronously BEFORE the journal's first async
|
|
382
418
|
// drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
|
|
@@ -409,6 +445,7 @@ export class V2LiveFeed {
|
|
|
409
445
|
dirty: false,
|
|
410
446
|
suppress: !!suppressExisting,
|
|
411
447
|
convertOrigin,
|
|
448
|
+
sessionInstance,
|
|
412
449
|
// Journal req seqs with no done yet. The journal is only touched at
|
|
413
450
|
// request start and completion, so a single >10min request would look
|
|
414
451
|
// "idle" to the mtime-based eviction and get detached mid-flight — its
|
|
@@ -611,7 +648,7 @@ export class V2LiveFeed {
|
|
|
611
648
|
try {
|
|
612
649
|
processWatchedEntry(parsed, {
|
|
613
650
|
reconstructor: cur.reconstructor,
|
|
614
|
-
clients: this.
|
|
651
|
+
clients: this._clientsForInstance(cur.sessionInstance),
|
|
615
652
|
getClaudePid: this._getClaudePid,
|
|
616
653
|
runParallelHook: this._runParallelHook,
|
|
617
654
|
suppressEntryBroadcast: this._wireV3,
|
|
@@ -648,7 +685,7 @@ export class V2LiveFeed {
|
|
|
648
685
|
? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
|
|
649
686
|
: `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
|
|
650
687
|
const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
|
|
651
|
-
sendEventRawToClients(this.
|
|
688
|
+
sendEventRawToClients(this._clientsForInstance(cur.sessionInstance), eventName, payload);
|
|
652
689
|
} catch (err) {
|
|
653
690
|
reportSwallowed('v2-live.native-forward', err);
|
|
654
691
|
}
|
|
@@ -721,12 +758,12 @@ export class V2LiveFeed {
|
|
|
721
758
|
const corrected = withAgentNameSubType(classifyRequest(prev.entry, parsed), prev.entry);
|
|
722
759
|
if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
|
|
723
760
|
prev.row.typeTag = corrected;
|
|
724
|
-
sendEventToClients(this.
|
|
761
|
+
sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', prev.row);
|
|
725
762
|
}
|
|
726
763
|
} catch (err) { reportSwallowed('v2-live.row-classify', err); }
|
|
727
764
|
}
|
|
728
765
|
cur._v3Last = { row, entry: parsed };
|
|
729
|
-
sendEventToClients(this.
|
|
766
|
+
sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', row);
|
|
730
767
|
}
|
|
731
768
|
|
|
732
769
|
// ── safety poll ────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Per-project "recent sessions" list for the /resume hover list (2026-10).
|
|
2
|
+
//
|
|
3
|
+
// Serves the N most-recent sessions of ONE project (the currently-bound one),
|
|
4
|
+
// for the header's current-project hover dropdown. Kept as a pure lib function
|
|
5
|
+
// (no HTTP, no interceptor state) so it is unit-testable with a temp LOG_DIR and
|
|
6
|
+
// an injectable `now` seam — same test posture as listV2LogsPage / deleteLogFiles.
|
|
7
|
+
//
|
|
8
|
+
// Scope: ONLY ccv's own v2 store is listed (sessions ccv actually proxied).
|
|
9
|
+
//
|
|
10
|
+
// Cost shape: a CHEAP meta.json pass per session (startTs + leader), plus a
|
|
11
|
+
// bounded journal head-scan (sessionHasMainOrTeammateReq) that drops quota-probe
|
|
12
|
+
// orphan dirs BEFORE they can occupy a top slot. Candidates are sorted
|
|
13
|
+
// newest-first, sliced to the top `limit`, and only THEN run the EXPENSIVE
|
|
14
|
+
// summarize (journal fold + dir walk + prompts head) via summarizeSessionPage —
|
|
15
|
+
// which shares listV2Sessions' freshness-keyed row cache, so repeat hover-opens
|
|
16
|
+
// are ~1-3ms. Fields the cheap pass cannot supply (sessionUuid from
|
|
17
|
+
// meta.sessionId, running, pinnedId) are read fresh per call for the surviving
|
|
18
|
+
// rows only, never from the cached row.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { sanitizePathComponent } from './layout.js';
|
|
23
|
+
import { listSessionIds } from './replay.js';
|
|
24
|
+
import { summarizeSessionPage } from './session-list.js';
|
|
25
|
+
import { sessionHasMainOrTeammateReq } from './session-select.js';
|
|
26
|
+
import { readPin } from '../session-pin-store.js';
|
|
27
|
+
import { LIVE_SESSION_MTIME_MS } from '../log-file-utils.js';
|
|
28
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Liveness heuristic for the "running" placeholder column. A session is
|
|
32
|
+
* treated as possibly-live when its journal was appended within
|
|
33
|
+
* LIVE_SESSION_MTIME_MS. Coarse by design (a background-but-idle >5min session
|
|
34
|
+
* reads as not-running) — this is a display hint only. `now` is injectable for tests.
|
|
35
|
+
* @param {string} sessionDir
|
|
36
|
+
* @param {number} now - ms epoch
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isRecentlyActive(sessionDir, now) {
|
|
40
|
+
try {
|
|
41
|
+
const st = statSync(join(sessionDir, 'journal.jsonl'));
|
|
42
|
+
return (now - st.mtimeMs) < LIVE_SESSION_MTIME_MS;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* List the `limit` most-recent v2 sessions of ONE project under `logDir`,
|
|
48
|
+
* newest-first. Row shape:
|
|
49
|
+
* { source:'ccv', project, file, sessionUuid, startTs, turns, preview, running, pinnedId }
|
|
50
|
+
* - `project` — the LOG_DIR subdirectory name (already sanitize-safe).
|
|
51
|
+
* - `file` — v2 addressing token `v2:<project>/<dirName>` (dirName is
|
|
52
|
+
* `<ts>_<uuid>` or legacy `<uuid>`), for the existing
|
|
53
|
+
* open-log / live-tail paths.
|
|
54
|
+
* - `sessionUuid` — the Claude Code session UUID from meta.sessionId (NOT the
|
|
55
|
+
* dir name), the key the client uses to scope a `?sid=` view.
|
|
56
|
+
* - `pinnedId` — the project's persisted pin value verbatim (readPin), a
|
|
57
|
+
* ts-based display id. The SERVER cannot map it to this row
|
|
58
|
+
* (the ts stable id is a frontend runtime _timestamp), so the
|
|
59
|
+
* row just carries it through; the CLIENT decides `selected`
|
|
60
|
+
* by comparing against its own session stable ids.
|
|
61
|
+
* - `running` — coarse liveness placeholder (see isRecentlyActive).
|
|
62
|
+
*
|
|
63
|
+
* @param {string} logDir - LOG_DIR root (e.g. ~/.claude/cc-viewer).
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {string} opts.project - the single project to list (required).
|
|
66
|
+
* @param {number} [opts.limit=10]
|
|
67
|
+
* @param {number} [opts.now] - ms epoch, injectable for tests.
|
|
68
|
+
* @returns {{ items: Array, total: number }}
|
|
69
|
+
*/
|
|
70
|
+
export function listResumeSessions(logDir, { project, limit = 10, now = Date.now() } = {}) {
|
|
71
|
+
const out = { items: [], total: 0 };
|
|
72
|
+
if (!project || project !== sanitizePathComponent(project)) return out;
|
|
73
|
+
|
|
74
|
+
const projectDir = join(logDir, project);
|
|
75
|
+
if (!logDir || !existsSync(projectDir)) return out;
|
|
76
|
+
|
|
77
|
+
// ── ccv's own v2 store (sessions ccv proxied). Cheap meta.json pass per
|
|
78
|
+
// session for startTs + leader ordering/filtering, no journal fold.
|
|
79
|
+
const candidates = [];
|
|
80
|
+
let sessionIds;
|
|
81
|
+
try { sessionIds = listSessionIds(projectDir); } catch (err) {
|
|
82
|
+
reportSwallowed('resume-list.readdir', err, { project });
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
for (const dirName of sessionIds) {
|
|
86
|
+
let meta = null;
|
|
87
|
+
try { meta = JSON.parse(readFileSync(join(projectDir, 'sessions', dirName, 'meta.json'), 'utf-8')); } catch { /* journal is self-describing */ }
|
|
88
|
+
if (meta && meta.leader) continue; // teammate — folded into its leader's row
|
|
89
|
+
// Cheap discardable-session pre-check (bounded journal head-scan, early
|
|
90
|
+
// exit on the first main/teammate req): drops quota-probe orphan dirs
|
|
91
|
+
// BEFORE they can occupy a top-`limit` slot — without paying the full
|
|
92
|
+
// summarize (journal fold + dir walk) for every candidate. Same source
|
|
93
|
+
// predicate as session-list's isDiscardableSession (leader-absent + no
|
|
94
|
+
// main/teammate req), so a probe is filtered here and the summarize-time
|
|
95
|
+
// `discard` re-check below becomes a no-op for survivors.
|
|
96
|
+
if (!sessionHasMainOrTeammateReq(join(projectDir, 'sessions', dirName))) continue;
|
|
97
|
+
candidates.push({
|
|
98
|
+
project,
|
|
99
|
+
projectDir,
|
|
100
|
+
dirName,
|
|
101
|
+
startTs: (meta && meta.startTs) || '',
|
|
102
|
+
sessionUuid: (meta && meta.sessionId) || null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Order newest-first, then take the top `limit` BEFORE the expensive
|
|
107
|
+
// summarize (journal fold + dir walk + prompts head) — so a large history
|
|
108
|
+
// pays the summarize for at most `limit` rows, not for every session. Rows
|
|
109
|
+
// dropped by the summarize-time filters (teammate fold / quota-probe orphan /
|
|
110
|
+
// unreadable) leave their slot empty rather than being back-filled; such rows
|
|
111
|
+
// are rare and `limit` is small, so the occasional short list is accepted in
|
|
112
|
+
// exchange for the bounded cost.
|
|
113
|
+
candidates.sort((a, b) =>
|
|
114
|
+
b.startTs.localeCompare(a.startTs)
|
|
115
|
+
|| String(b.dirName || '').localeCompare(String(a.dirName || '')));
|
|
116
|
+
const top = candidates.slice(0, limit);
|
|
117
|
+
|
|
118
|
+
const ccvRows = [];
|
|
119
|
+
for (const c of top) {
|
|
120
|
+
let s = null;
|
|
121
|
+
try { s = summarizeSessionPage(c.projectDir, c.dirName); } catch (err) {
|
|
122
|
+
reportSwallowed('resume-list.summarize', err, { project: c.project, dirName: c.dirName });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!s) continue;
|
|
126
|
+
if (s.leader) continue;
|
|
127
|
+
if (s.size === 0) continue;
|
|
128
|
+
if (s.discard) continue; // quota-probe orphans: never listed
|
|
129
|
+
const sessionDir = join(c.projectDir, 'sessions', c.dirName);
|
|
130
|
+
ccvRows.push({
|
|
131
|
+
source: 'ccv',
|
|
132
|
+
project: c.project,
|
|
133
|
+
file: `v2:${c.project}/${s.sid}`,
|
|
134
|
+
sessionUuid: c.sessionUuid,
|
|
135
|
+
startTs: s.startTs,
|
|
136
|
+
turns: s.turns,
|
|
137
|
+
preview: s.preview || [],
|
|
138
|
+
running: isRecentlyActive(sessionDir, now),
|
|
139
|
+
// The pin lives at the PROJECT dir and holds a ts-based display id, not a
|
|
140
|
+
// uuid — pass it through verbatim; the client maps it to a row.
|
|
141
|
+
pinnedId: readPin(c.projectDir),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Already newest-first from the candidate sort (the summarize filter only
|
|
146
|
+
// drops rows, never reorders). total = surviving rows shown (≤ limit).
|
|
147
|
+
out.total = ccvRows.length;
|
|
148
|
+
out.items = ccvRows;
|
|
149
|
+
return out;
|
|
150
|
+
}
|