cc-viewer 1.9.0 → 1.9.2
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/README.md +3 -1
- package/cli.js +33 -4
- package/concepts/ar/UltraPlan.md +1 -1
- package/concepts/da/UltraPlan.md +1 -1
- package/concepts/de/UltraPlan.md +1 -1
- package/concepts/en/UltraPlan.md +1 -1
- package/concepts/es/UltraPlan.md +1 -1
- package/concepts/fr/UltraPlan.md +1 -1
- package/concepts/it/UltraPlan.md +1 -1
- package/concepts/ja/UltraPlan.md +1 -1
- package/concepts/ko/UltraPlan.md +1 -1
- package/concepts/no/UltraPlan.md +1 -1
- package/concepts/pl/UltraPlan.md +1 -1
- package/concepts/pt-BR/UltraPlan.md +1 -1
- package/concepts/ru/UltraPlan.md +1 -1
- package/concepts/th/UltraPlan.md +1 -1
- package/concepts/tr/UltraPlan.md +1 -1
- package/concepts/uk/UltraPlan.md +1 -1
- package/concepts/zh/UltraPlan.md +1 -1
- package/concepts/zh-TW/UltraPlan.md +1 -1
- package/dist/assets/App-DJ4yNvwz.css +1 -0
- package/dist/assets/App-DPRn6oGn.js +2 -0
- package/dist/assets/{MdxEditorPanel-3j8rS3IT.js → MdxEditorPanel-Ci4ldhd7.js} +1 -1
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/Mobile-Dq-KcO1s.js +1 -0
- package/dist/assets/ProxyStatsModal-CUFpqt1s.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-BLzca-ta.css +1 -0
- 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-Ci38xZiX.js +2 -0
- 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-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-B0p7e_lM.js +2 -0
- package/dist/assets/seqResourceLoaders-BsMFNfvR.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 +192 -24
- package/server/lib/ask/ask-store.js +7 -3
- package/server/lib/async-file-lock.js +29 -5
- package/server/lib/auth.js +71 -54
- package/server/lib/chat-queue.js +67 -4
- package/server/lib/config-backup.js +8 -5
- package/server/lib/credential-access.js +233 -0
- package/server/lib/credential-migrate.js +147 -0
- package/server/lib/credential-store.js +120 -0
- package/server/lib/credential-vault.js +112 -0
- package/server/lib/file-access-policy.js +16 -0
- package/server/lib/git-diff.js +49 -20
- package/server/lib/im/im-config.js +81 -31
- package/server/lib/im-deny.js +22 -8
- package/server/lib/json-store.js +264 -0
- package/server/lib/log-watcher.js +9 -1
- package/server/lib/prefs-store.js +8 -33
- package/server/lib/same-origin.js +22 -0
- package/server/lib/session-pin-store.js +7 -14
- package/server/lib/v2/convert.js +1 -1
- package/server/lib/v2/layout.js +53 -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 +809 -163
- package/server/routes/ask-perm.js +21 -1
- package/server/routes/auth.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/im.js +15 -2
- package/server/routes/preferences.js +59 -46
- package/server/routes/project-meta.js +32 -3
- package/server/routes/proxy-stats.js +33 -5
- package/server/routes/resume.js +457 -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 +18 -5
- package/server/server.js +199 -25
- package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
- package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
- package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
- package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
- package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
- package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/system-prompt-templates/systemPromptModel.md +0 -1
- package/server/workspace-registry.js +150 -75
- package/ultraAgents/code-expert.json +1 -1
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/App-_HfxMJvv.js +0 -2
- package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
- package/dist/assets/ProxyStatsModal-dlPIDlx-.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-CjbBUDGP.js +0 -2
- package/dist/assets/index-Nvk8r3tO.css +0 -1
- package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
- package/dist/assets/seqResourceLoaders-hqLhsiGO.js +0 -2
|
@@ -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
|
+
}
|
|
@@ -23,7 +23,7 @@ import { existsSync, readFileSync, openSync, readSync, closeSync } from 'node:fs
|
|
|
23
23
|
import { join } from 'node:path';
|
|
24
24
|
import { StringDecoder } from 'node:string_decoder';
|
|
25
25
|
import { listSessionIds } from './replay.js';
|
|
26
|
-
import { isSupportedWireFormat } from './layout.js';
|
|
26
|
+
import { isSupportedWireFormat, normalizeSessionCwd } from './layout.js';
|
|
27
27
|
import { isForeignLiveOwned } from './session-owner.js';
|
|
28
28
|
|
|
29
29
|
/**
|
|
@@ -261,12 +261,21 @@ export function isDiscardableSession(dir, meta) {
|
|
|
261
261
|
* (getLiveLogSource fallback, _resolveAdoption) pass true; default false keeps
|
|
262
262
|
* the raw picker semantics for everything else.
|
|
263
263
|
* @param {string} projectDir - absolute LOG_DIR/<project>
|
|
264
|
-
* @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts]
|
|
264
|
+
* @param {{excludeDir?: string, skipForeignLive?: boolean, instanceKey?: string, targetCwd?: string}} [opts]
|
|
265
|
+
* `instanceKey` (multi-instance, 2026-10-06): when set, only consider session dirs whose
|
|
266
|
+
* meta.json `instance` matches it — so two concurrent same-cwd instances (sharing one
|
|
267
|
+
* basename project dir but distinct session dirs) each resolve to THEIR OWN latest session.
|
|
268
|
+
* Dirs with no `instance` (legacy / external producers) are EXCLUDED under this filter.
|
|
269
|
+
* `targetCwd` (same-name isolation, 2026-10-07): when set WITH `instanceKey`, scopes the
|
|
270
|
+
* cross-instance FALLBACK pool to sessions stamped with this cwd (or with NO meta.cwd —
|
|
271
|
+
* keep-missing). Applies ONLY to the fallback pool, never the instance filter; an empty
|
|
272
|
+
* value disables the cwd filter. No effect without `instanceKey`.
|
|
265
273
|
* @returns {{dir:string, sessionId:string}|null}
|
|
266
274
|
*/
|
|
267
|
-
export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false } = {}) {
|
|
275
|
+
export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false, instanceKey = '', targetCwd = '' } = {}) {
|
|
268
276
|
if (!projectDir) return null;
|
|
269
|
-
const
|
|
277
|
+
const instanceCandidates = []; // session dirs stamped with the requested instanceKey
|
|
278
|
+
const projectCandidates = []; // every eligible session dir of the project (any/no instance)
|
|
270
279
|
for (const name of listSessionIds(projectDir)) {
|
|
271
280
|
const dir = join(projectDir, 'sessions', name);
|
|
272
281
|
if (excludeDir && dir === excludeDir) continue;
|
|
@@ -282,24 +291,64 @@ export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive
|
|
|
282
291
|
// folds in its siblings when rendered, so a teammate dir loaded directly
|
|
283
292
|
// renders wrong. Keep only main (leader-less) sessions.
|
|
284
293
|
if (meta.leader) continue;
|
|
285
|
-
|
|
294
|
+
const cand = {
|
|
295
|
+
dir,
|
|
296
|
+
sessionId: meta.sessionId || '',
|
|
297
|
+
startTs: meta.startTs || '',
|
|
298
|
+
// Same-name isolation (2026-10-07): the session's owning cwd (absent on legacy
|
|
299
|
+
// dirs → null). Read once here from the already-parsed meta; used ONLY to scope
|
|
300
|
+
// the cross-instance fallback pool below, never the instance filter. Normalized
|
|
301
|
+
// with the SAME lexical normalizer the writer/read side use, so the comparison
|
|
302
|
+
// stays symmetric even when a raw cwd carries `.`/`..`/duplicate separators.
|
|
303
|
+
metaCwd: (typeof meta.cwd === 'string' && meta.cwd) ? normalizeSessionCwd(meta.cwd) : null,
|
|
304
|
+
};
|
|
305
|
+
projectCandidates.push(cand);
|
|
306
|
+
if (instanceKey && meta.instance === instanceKey) instanceCandidates.push(cand);
|
|
286
307
|
}
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
308
|
+
const byNewest = (a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0);
|
|
309
|
+
const newestWithMainTurn = (list) => {
|
|
310
|
+
const sorted = [...list].sort(byNewest);
|
|
311
|
+
for (const c of sorted) { if (sessionHasMainTurn(c.dir)) return c; }
|
|
312
|
+
return null;
|
|
313
|
+
};
|
|
314
|
+
// Multi-instance (2026-10-06): "show the instance's session when it has content, else fall
|
|
315
|
+
// back to the project's latest session WITH content" (user-pinned). An instance that exists
|
|
316
|
+
// but has only sub/system requests (no user main turn — a freshly-spawned same-cwd twin, or
|
|
317
|
+
// one rebuilt by a server restart) must NOT blank the view with its empty shell, and must
|
|
318
|
+
// NOT hard-error as "not found" either. Scope: instance-with-content → that; else → the
|
|
319
|
+
// project's newest content-bearing session (the history the user actually wants back).
|
|
320
|
+
if (instanceKey) {
|
|
321
|
+
const own = newestWithMainTurn(instanceCandidates);
|
|
322
|
+
if (own) return own;
|
|
323
|
+
// Same-name isolation (2026-10-07): scope the cross-instance fallback to the
|
|
324
|
+
// TARGET cwd so a cross-dir same-basename project sharing this pool can't be
|
|
325
|
+
// served. Keep-missing: legacy dirs with NO meta.cwd (null) stay eligible —
|
|
326
|
+
// excluding them would re-create the historical "excluded history" regression
|
|
327
|
+
// (sid-not-found / mis-detach). Same-cwd candidates always match, so same-cwd
|
|
328
|
+
// twin collaboration is unaffected. An empty targetCwd (instance dead / cwd
|
|
329
|
+
// unknown) disables the filter entirely — conservative, identical to today.
|
|
330
|
+
// This filters ONLY the fallback pool (projectCandidates), NEVER
|
|
331
|
+
// instanceCandidates above — an adopted/resumed dir's meta.cwd belongs to its
|
|
332
|
+
// original author and must not disqualify it from its own instance filter.
|
|
333
|
+
const fallbackPool = targetCwd
|
|
334
|
+
? projectCandidates.filter((c) => c.metaCwd == null || c.metaCwd === targetCwd)
|
|
335
|
+
: projectCandidates;
|
|
336
|
+
const proj = newestWithMainTurn(fallbackPool);
|
|
337
|
+
if (proj) return proj;
|
|
338
|
+
// Nothing with a main turn anywhere: degrade to the instance's own newest dir (a truly
|
|
339
|
+
// fresh project) — still strictly this instance, never another's.
|
|
340
|
+
const ownAny = [...instanceCandidates].sort(byNewest)[0];
|
|
341
|
+
return ownAny || null;
|
|
292
342
|
}
|
|
293
|
-
return
|
|
343
|
+
return newestWithMainTurn(projectCandidates);
|
|
294
344
|
}
|
|
295
345
|
|
|
296
346
|
/**
|
|
297
347
|
* Absolute dir of the newest readable, non-teammate session that HAS a main
|
|
298
348
|
* turn, or '' if there is none. Thin wrapper over {@link latestMainSession}.
|
|
299
349
|
* @param {string} projectDir - absolute LOG_DIR/<project>
|
|
300
|
-
* @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts] - see {@link latestMainSession}
|
|
350
|
+
* @param {{excludeDir?: string, skipForeignLive?: boolean, instanceKey?: string, targetCwd?: string}} [opts] - see {@link latestMainSession}
|
|
301
351
|
* @returns {string} absolute session dir, or ''
|
|
302
352
|
*/
|
|
303
|
-
export function latestMainSessionDir(projectDir, opts) {
|
|
304
|
-
return latestMainSession(projectDir, opts)?.dir || '';
|
|
353
|
+
export function latestMainSessionDir(projectDir, opts) { return latestMainSession(projectDir, opts)?.dir || '';
|
|
305
354
|
}
|
|
@@ -17,10 +17,10 @@
|
|
|
17
17
|
// correctness dependency.
|
|
18
18
|
|
|
19
19
|
import { statfsSync, existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
20
|
-
import { join, basename } from 'node:path';
|
|
20
|
+
import { join, basename, sep } from 'node:path';
|
|
21
21
|
import { AsyncWriteQueue } from '../async-write-queue.js';
|
|
22
22
|
import { reportSwallowed } from '@ccv/core/error-report';
|
|
23
|
-
import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent } from './layout.js';
|
|
23
|
+
import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent, normalizeSessionCwd } from './layout.js';
|
|
24
24
|
import { resolveSessionDirName, latestMainSession } from './session-select.js';
|
|
25
25
|
import { acquireSessionClaim, releaseSessionClaim, isForeignLiveOwned } from './session-owner.js';
|
|
26
26
|
import { BlobStore } from './blob-store.js';
|
|
@@ -78,6 +78,10 @@ export class V2Writer {
|
|
|
78
78
|
// in-process live cursor can read the fresh appends with zero fs-watch
|
|
79
79
|
// latency. Purely a nudge — the feed's data source stays the files.
|
|
80
80
|
this._onActivity = typeof opts.onActivity === 'function' ? opts.onActivity : null;
|
|
81
|
+
// Fired once per request that resolves a (sid, project) pair — lets the
|
|
82
|
+
// server feed a PTY's sessionId index (pty-manager.setPtySessionId) so chat
|
|
83
|
+
// sends can be routed to the PTY owning that conversation (2026-10-05).
|
|
84
|
+
this._onSessionResolved = typeof opts.onSessionResolved === 'function' ? opts.onSessionResolved : null;
|
|
81
85
|
this._sessions = new Map(); // sessionId → {paths, blobs, journal, convs, resolver}
|
|
82
86
|
// Multi-window isolation (2026-07-17): session dirs this process has
|
|
83
87
|
// claimed via owner.lock. Released synchronously in resetSessions()/close()
|
|
@@ -98,13 +102,16 @@ export class V2Writer {
|
|
|
98
102
|
this._continuationLaunch = false;
|
|
99
103
|
this._forkSession = false;
|
|
100
104
|
this._resumeSession = false;
|
|
101
|
-
|
|
105
|
+
// Per-project `-c` adoption latch (was a single boolean): each project may
|
|
106
|
+
// adopt at most once. Keyed by sanitized project so a scoped resetSessions(t)
|
|
107
|
+
// clears only project t's latch, not a background project's.
|
|
108
|
+
this._adopted = new Set();
|
|
102
109
|
// In-terminal /resume switch (SessionStart hook, source:'resume'): the
|
|
103
110
|
// next MAIN request must be re-routed to the resumed conversation's dir.
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
this._pendingResumeSwitch =
|
|
111
|
+
// Per-project (was a single value): keyed by sanitized project, so a scoped
|
|
112
|
+
// reset clears only the switched-away project's pending switch. Value:
|
|
113
|
+
// {transcriptUuid, hookSid}; consumed one-shot on the next main request.
|
|
114
|
+
this._pendingResumeSwitch = new Map();
|
|
108
115
|
}
|
|
109
116
|
|
|
110
117
|
/** P2: true once any session's FIRST main wire already carried assistant
|
|
@@ -131,6 +138,7 @@ export class V2Writer {
|
|
|
131
138
|
/** Late wiring seam: the live feed is constructed by server.js AFTER this
|
|
132
139
|
* writer exists (module init order), so the nudge callback arrives here. */
|
|
133
140
|
setOnActivity(fn) { this._onActivity = typeof fn === 'function' ? fn : null; }
|
|
141
|
+
setOnSessionResolved(fn) { this._onSessionResolved = typeof fn === 'function' ? fn : null; }
|
|
134
142
|
|
|
135
143
|
/**
|
|
136
144
|
* If this request should adopt the previous main session's folder — a `-c`
|
|
@@ -145,8 +153,14 @@ export class V2Writer {
|
|
|
145
153
|
* history-less requests); and a previous main session exists.
|
|
146
154
|
* @returns {{dirName:string, identityUUID:string}|null}
|
|
147
155
|
*/
|
|
156
|
+
/** Sanitized per-project key for the per-project latches (_adopted /
|
|
157
|
+
* _pendingResumeSwitch). Single place so the latch keying can't drift. */
|
|
158
|
+
_projKey(project) {
|
|
159
|
+
return sanitizePathComponent(project || '');
|
|
160
|
+
}
|
|
161
|
+
|
|
148
162
|
_resolveAdoption(sid, project, msgs, entry) {
|
|
149
|
-
if (this._adopted || this._leader) return null;
|
|
163
|
+
if (this._adopted.has(this._projKey(project)) || this._leader) return null;
|
|
150
164
|
if (!this._continuationLaunch || this._forkSession || this._resumeSession) return null;
|
|
151
165
|
if (this._sessions.has(sid)) return null;
|
|
152
166
|
const m = Array.isArray(msgs) ? msgs
|
|
@@ -181,13 +195,17 @@ export class V2Writer {
|
|
|
181
195
|
* conversation was never recorded by ccv (it must not be the old wire sid:
|
|
182
196
|
* `<ts>_<oldSid>` would be re-resolved back to the OLD dir by
|
|
183
197
|
* resolveSessionDirName). Last-wins across repeated /resume picks. */
|
|
184
|
-
beginResumeSwitch({ transcriptUuid, hookSid } = {}) {
|
|
198
|
+
beginResumeSwitch({ transcriptUuid, hookSid, project } = {}) {
|
|
185
199
|
try {
|
|
186
200
|
if (!transcriptUuid || typeof transcriptUuid !== 'string') return;
|
|
187
|
-
|
|
201
|
+
// Keyed by the project the resumed conversation belongs to (defaults to the
|
|
202
|
+
// currently-bound project — markSessionStart's guard already ensures the
|
|
203
|
+
// hook's cwd matches it). Per-project so a scoped reset clears only that one.
|
|
204
|
+
const key = sanitizePathComponent(project || this._projectFn() || '');
|
|
205
|
+
this._pendingResumeSwitch.set(key, {
|
|
188
206
|
transcriptUuid,
|
|
189
207
|
hookSid: (typeof hookSid === 'string' && hookSid) ? hookSid : null,
|
|
190
|
-
};
|
|
208
|
+
});
|
|
191
209
|
} catch (err) {
|
|
192
210
|
reportSwallowed('v2-write.resume-switch', err);
|
|
193
211
|
}
|
|
@@ -210,8 +228,9 @@ export class V2Writer {
|
|
|
210
228
|
* @returns {{dirName:string, identityUUID:string}}
|
|
211
229
|
*/
|
|
212
230
|
_resolveResumeSwitch(sid, project, prevSid = null) {
|
|
213
|
-
const
|
|
214
|
-
this._pendingResumeSwitch
|
|
231
|
+
const key = this._projKey(project);
|
|
232
|
+
const sw = this._pendingResumeSwitch.get(key);
|
|
233
|
+
this._pendingResumeSwitch.delete(key); // one-shot
|
|
215
234
|
const projectDir = join(this._logDir, sanitizePathComponent(project));
|
|
216
235
|
let dirName = resolveSessionDirName(projectDir, sw.transcriptUuid, this._sessionsDirName);
|
|
217
236
|
let identity = sw.transcriptUuid;
|
|
@@ -274,8 +293,17 @@ export class V2Writer {
|
|
|
274
293
|
this._claimedDirs.clear();
|
|
275
294
|
}
|
|
276
295
|
|
|
277
|
-
_session(sessionId, userIdRaw, encoding, project, startTsIso, adoptTarget = null) {
|
|
296
|
+
_session(sessionId, userIdRaw, encoding, project, startTsIso, adoptTarget = null, instanceKey = null, cwd = null) {
|
|
278
297
|
let s = this._sessions.get(sessionId);
|
|
298
|
+
// Cross-project guard: the map is keyed by bare sid, so a sid reused across two
|
|
299
|
+
// projects (a cross-project /resume takeover running alongside its old project)
|
|
300
|
+
// would otherwise reuse the OTHER project's session object and cross-write into
|
|
301
|
+
// the wrong dir. On a project mismatch, drop the stale binding and rebuild for
|
|
302
|
+
// THIS project. The hot path (same project) stays an O(1) map hit.
|
|
303
|
+
if (s && s.project && project && sanitizePathComponent(s.project) !== sanitizePathComponent(project)) {
|
|
304
|
+
this._sessions.delete(sessionId);
|
|
305
|
+
s = null;
|
|
306
|
+
}
|
|
279
307
|
if (s) return s; // hot path: map hit is O(1), the scan below only runs on miss
|
|
280
308
|
|
|
281
309
|
// Task C: the dir name carries a creation-time prefix `<ts>_<uuid>`. On a
|
|
@@ -291,6 +319,18 @@ export class V2Writer {
|
|
|
291
319
|
...(userIdRaw && { userIdRaw }),
|
|
292
320
|
...(encoding && { userIdEncoding: encoding }),
|
|
293
321
|
...(this._leader && { leader: this._leader }),
|
|
322
|
+
// Multi-instance (2026-10-06): stamp the owning PTY instanceKey on the session dir's
|
|
323
|
+
// meta.json (first-write-wins), so two concurrent same-cwd claude instances — which
|
|
324
|
+
// share one basename project dir but mint DIFFERENT wire session UUIDs (different
|
|
325
|
+
// session dirs) — can each be told apart at the directory level by readers/feed/cold
|
|
326
|
+
// load. Absent for legacy/external producers (no self-report) → instance-agnostic.
|
|
327
|
+
...(instanceKey && { instance: instanceKey }),
|
|
328
|
+
// Same-name session isolation (2026-10-07): stamp the session's owning full cwd so
|
|
329
|
+
// the read-side fallback can tighten by path, telling two cross-dir same-basename
|
|
330
|
+
// projects apart (basename routing cannot). First-write-wins, so an adopted/resumed
|
|
331
|
+
// dir keeps its ORIGINAL author's cwd — never relabeled by the adopting entry.
|
|
332
|
+
// Normalized lexically (no realpath) to match the read side's targetCwd.
|
|
333
|
+
...(cwd && { cwd: normalizeSessionCwd(cwd) }),
|
|
294
334
|
...(this._metaExtra || {}),
|
|
295
335
|
};
|
|
296
336
|
const projectDir = join(this._logDir, sanitizePathComponent(project));
|
|
@@ -326,6 +366,11 @@ export class V2Writer {
|
|
|
326
366
|
journal: new Journal(paths, this._queue),
|
|
327
367
|
convs: new ConversationStore(paths, this._queue, { exactFps: this._exactConvFps }),
|
|
328
368
|
resolver: new ConvResolver(),
|
|
369
|
+
// The project this session's dir lives under — needed by resetSessions(target)
|
|
370
|
+
// to clear ONLY one project's sessions on a workspace switch, leaving a
|
|
371
|
+
// concurrently-running background project's bindings (a cross-project /resume
|
|
372
|
+
// takeover's old project) intact.
|
|
373
|
+
project,
|
|
329
374
|
};
|
|
330
375
|
this._sessions.set(sessionId, s);
|
|
331
376
|
return s;
|
|
@@ -362,7 +407,18 @@ export class V2Writer {
|
|
|
362
407
|
ingestRequest(entry, originalMessages) {
|
|
363
408
|
if (!this._enabled || !entry) return null;
|
|
364
409
|
try {
|
|
365
|
-
|
|
410
|
+
// Per-request project override (/resume global sessions): only when the
|
|
411
|
+
// interceptor resolved a child claude's own project from the
|
|
412
|
+
// x-ccv-project-dir header does it set `entry._resumeProject` (already
|
|
413
|
+
// sanitized). We MUST key off that explicit marker, NOT the generic
|
|
414
|
+
// `entry.project` — the latter is set for every request (to the process
|
|
415
|
+
// cwd basename) and would silently misroute the moment it diverges from
|
|
416
|
+
// the writer's bound project (e.g. fixtures / teammate reshuffles).
|
|
417
|
+
// Each entry carries its own override, so concurrent sessions from
|
|
418
|
+
// different projects never cross-assign. Falls back to the bound project.
|
|
419
|
+
const project = (typeof entry._resumeProject === 'string' && entry._resumeProject)
|
|
420
|
+
? entry._resumeProject
|
|
421
|
+
: this._projectFn();
|
|
366
422
|
if (!project) return null; // workspace not selected yet — mirrors v1's empty-LOG_FILE no-op
|
|
367
423
|
if (!this._diskOk()) return null;
|
|
368
424
|
|
|
@@ -377,7 +433,41 @@ export class V2Writer {
|
|
|
377
433
|
let sid;
|
|
378
434
|
if (parsed) {
|
|
379
435
|
sid = parsed.sessionId;
|
|
380
|
-
|
|
436
|
+
// `_currentSid` = the session of the ACTIVE MAIN claude (the one whose cwd
|
|
437
|
+
// matches the server's currently-bound project). Every claude now
|
|
438
|
+
// self-reports its project via `x-ccv-project-dir` (spawnClaude injects it
|
|
439
|
+
// too, not just resume scratches), so `entry._resumeProject` is that
|
|
440
|
+
// claude's own project. Only the claude whose project == the bound project
|
|
441
|
+
// (`this._projectFn()`) is the main one — a kept-alive OLD project's
|
|
442
|
+
// background claude (header = old project ≠ bound) and resume scratches
|
|
443
|
+
// must NOT move `_currentSid`, or the /resume list's "current" blue dot
|
|
444
|
+
// would drift to whichever background session last emitted a request.
|
|
445
|
+
// A request with NO header at all (legacy / external producer) is treated
|
|
446
|
+
// as the main project's own (project === bound), preserving the old
|
|
447
|
+
// no-header behavior.
|
|
448
|
+
const entryProject = (typeof entry._resumeProject === 'string' && entry._resumeProject)
|
|
449
|
+
? entry._resumeProject
|
|
450
|
+
: this._projectFn();
|
|
451
|
+
if (entryProject && entryProject === this._projectFn()) {
|
|
452
|
+
this._currentSid = sid;
|
|
453
|
+
}
|
|
454
|
+
// Feed the PTY sid index: this request's owning project just resolved a
|
|
455
|
+
// concrete sessionId, so the PTY serving that project can be addressed by
|
|
456
|
+
// sid. Multi-instance (2026-10-06): also forward the self-reported instanceKey
|
|
457
|
+
// (`_ccvInstance`) so the server can pin the sid to THIS exact process — basename
|
|
458
|
+
// routing cannot tell two same-cwd instances apart. Best-effort — a listener
|
|
459
|
+
// error must not break ingestion.
|
|
460
|
+
// MAIN-AGENT GATE (review P1, 2026-10-06): only pin a sid for the main interactive
|
|
461
|
+
// claude's own requests. A teammate/subagent child process INHERITS the parent's
|
|
462
|
+
// `ANTHROPIC_CUSTOM_HEADERS` (so it carries the parent's `x-ccv-instance`) but runs
|
|
463
|
+
// its OWN conversation/sid — without this gate it would overwrite the parent record's
|
|
464
|
+
// sessionId with the teammate's sid, mis-pinning the instance. Teammate / heartbeat /
|
|
465
|
+
// count_tokens requests are skipped.
|
|
466
|
+
const isMainAgentTurn = entry.mainAgent === true && !entry.teammate && !entry.isHeartbeat && !entry.isCountTokens;
|
|
467
|
+
if (this._onSessionResolved && entryProject && isMainAgentTurn) {
|
|
468
|
+
const instKey = (typeof entry._ccvInstance === 'string' && entry._ccvInstance) ? entry._ccvInstance : null;
|
|
469
|
+
try { this._onSessionResolved(sid, entryProject, instKey); } catch (err) { reportSwallowed('v2-write.session-resolved', err); }
|
|
470
|
+
}
|
|
381
471
|
} else if (this._currentSid) {
|
|
382
472
|
sid = this._currentSid; // §8.3 fallback: route metadata-less requests to the active session
|
|
383
473
|
} else {
|
|
@@ -406,7 +496,7 @@ export class V2Writer {
|
|
|
406
496
|
// probes and heartbeats wear main-agent body shapes but are not the
|
|
407
497
|
// user's resumed turn — consuming on them would re-bind too early
|
|
408
498
|
// with the same result, but keep the gate strict for clarity).
|
|
409
|
-
if (this._pendingResumeSwitch && entry.mainAgent
|
|
499
|
+
if (this._pendingResumeSwitch.has(this._projKey(project)) && entry.mainAgent
|
|
410
500
|
&& !entry.isCountTokens && !entry.isHeartbeat) {
|
|
411
501
|
// Locally guarded (review P2): the switch resolution does dir-scan
|
|
412
502
|
// fs work (resolveSessionDirName → readdirSync) whose residual
|
|
@@ -419,7 +509,7 @@ export class V2Writer {
|
|
|
419
509
|
}
|
|
420
510
|
if (!adoptTarget) {
|
|
421
511
|
adoptTarget = this._resolveAdoption(sid, project, originalMessages, entry);
|
|
422
|
-
if (adoptTarget) this._adopted
|
|
512
|
+
if (adoptTarget) this._adopted.add(this._projKey(project));
|
|
423
513
|
}
|
|
424
514
|
}
|
|
425
515
|
|
|
@@ -427,7 +517,9 @@ export class V2Writer {
|
|
|
427
517
|
// the dir-name ts prefix (task C). First-write-wins: meta is only written
|
|
428
518
|
// when the dir is first created, so this is the session's creation time
|
|
429
519
|
// (live ≈ now; convert = the historical first-entry ts).
|
|
430
|
-
const s = this._session(sid, parsed && entry.body.metadata.user_id, parsed && parsed.encoding, project, entry.timestamp, adoptTarget
|
|
520
|
+
const s = this._session(sid, parsed && entry.body.metadata.user_id, parsed && parsed.encoding, project, entry.timestamp, adoptTarget,
|
|
521
|
+
(typeof entry._ccvInstance === 'string' && entry._ccvInstance) ? entry._ccvInstance : null,
|
|
522
|
+
(typeof entry._resumeProjectCwd === 'string' && entry._resumeProjectCwd) ? entry._resumeProjectCwd : null);
|
|
431
523
|
|
|
432
524
|
// System-prompt snapshot, Bind A (wire): the FIRST main request of a session
|
|
433
525
|
// carries the launch's rendered injection in body.system — match it against the
|
|
@@ -682,6 +774,14 @@ export class V2Writer {
|
|
|
682
774
|
return s ? s.paths.dir : null;
|
|
683
775
|
}
|
|
684
776
|
|
|
777
|
+
/** The writer's current (fallback-routing) session UUID, or null before the
|
|
778
|
+
* first sid-bearing request. Exposed so the /resume list can mark exactly ONE
|
|
779
|
+
* row as the live "current" session (the multi-blue-dot fix) — the row whose
|
|
780
|
+
* sessionUuid matches this id is the one the main process is actively running. */
|
|
781
|
+
currentSessionId() {
|
|
782
|
+
return this._currentSid || null;
|
|
783
|
+
}
|
|
784
|
+
|
|
685
785
|
/** Lifecycle hook: drop in-memory conversation continuity so the next
|
|
686
786
|
* request snapshots fresh. Journal seq keeps counting (same process, same
|
|
687
787
|
* session dirs — seq must stay monotonic per session). NOTE: the
|
|
@@ -700,26 +800,64 @@ export class V2Writer {
|
|
|
700
800
|
}
|
|
701
801
|
}
|
|
702
802
|
|
|
703
|
-
/** Lifecycle hook (workspace switch): drop
|
|
704
|
-
* next request re-creates its session dir under the newly
|
|
705
|
-
*
|
|
706
|
-
*
|
|
707
|
-
|
|
803
|
+
/** Lifecycle hook (workspace switch): drop the TARGET project's cached session
|
|
804
|
+
* bindings so the next request re-creates its session dir under the newly
|
|
805
|
+
* resolved project. `targetProject` is the project being switched AWAY from —
|
|
806
|
+
* only its sessions/claims are dropped, so a concurrently-running BACKGROUND
|
|
807
|
+
* project (a cross-project /resume takeover's old project, still being written
|
|
808
|
+
* via `entry._resumeProject`) keeps its bindings and continues uninterrupted.
|
|
809
|
+
* With no argument (legacy callers) it clears everything, matching the old
|
|
810
|
+
* behavior byte-for-byte. A sid that persists across the switch gets a fresh
|
|
811
|
+
* dir (and fresh seq) under the new project — different directory, so no seq
|
|
812
|
+
* collision. */
|
|
813
|
+
resetSessions(targetProject = null) {
|
|
708
814
|
try {
|
|
815
|
+
const matchAll = targetProject == null;
|
|
816
|
+
const targetSan = matchAll ? null : sanitizePathComponent(targetProject);
|
|
709
817
|
// Release live claims FIRST (before the session map is gone): a
|
|
710
818
|
// switched-away workspace's dirs are dormant and legitimately adoptable
|
|
711
|
-
// by other windows from this moment on.
|
|
712
|
-
|
|
713
|
-
|
|
819
|
+
// by other windows from this moment on. Only the target project's claims
|
|
820
|
+
// are released on a scoped reset — the background project keeps its lock.
|
|
821
|
+
if (matchAll) {
|
|
822
|
+
this._releaseAllClaims();
|
|
823
|
+
} else {
|
|
824
|
+
// Match by the project dir prefix (<logDir>/<projectSan>) rather than by
|
|
825
|
+
// positional path segment — the old slice(-3,-2) index assumed a fixed
|
|
826
|
+
// <logDir>/<project>/sessions/<ts>_<sid> depth and broke when
|
|
827
|
+
// _sessionsDirName changed the layout (offline converter staging dirs).
|
|
828
|
+
const prefix = join(this._logDir, targetSan) + sep;
|
|
829
|
+
for (const dir of [...this._claimedDirs]) {
|
|
830
|
+
if (dir === join(this._logDir, targetSan) || dir.startsWith(prefix)) {
|
|
831
|
+
releaseSessionClaim(dir);
|
|
832
|
+
this._claimedDirs.delete(dir);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
for (const [sid, s] of [...this._sessions]) {
|
|
837
|
+
if (matchAll || sanitizePathComponent(s.project || '') === targetSan) {
|
|
838
|
+
this._sessions.delete(sid);
|
|
839
|
+
}
|
|
840
|
+
}
|
|
714
841
|
this._pendingNoSid.length = 0;
|
|
715
842
|
this._lateHandles.clear();
|
|
716
|
-
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
843
|
+
// _currentSid only clears when it pointed at the dropped project — the
|
|
844
|
+
// background project's live "current" pointer survives a scoped reset.
|
|
845
|
+
if (matchAll) {
|
|
846
|
+
this._currentSid = null;
|
|
847
|
+
} else {
|
|
848
|
+
const cur = this._currentSid ? this._sessions.get(this._currentSid) : null;
|
|
849
|
+
if (this._currentSid && !cur) this._currentSid = null;
|
|
850
|
+
}
|
|
851
|
+
// `-c` adoption latch + pending /resume switch, both per-project now: a
|
|
852
|
+
// scoped reset clears only the switched-away project's latch (a background
|
|
853
|
+
// project's armed resume switch / adoption survives); a full reset clears all.
|
|
854
|
+
if (matchAll) {
|
|
855
|
+
this._adopted.clear();
|
|
856
|
+
this._pendingResumeSwitch.clear();
|
|
857
|
+
} else {
|
|
858
|
+
this._adopted.delete(targetSan);
|
|
859
|
+
this._pendingResumeSwitch.delete(targetSan);
|
|
860
|
+
}
|
|
723
861
|
} catch (err) {
|
|
724
862
|
reportSwallowed('v2-write.reset-sessions', err);
|
|
725
863
|
}
|