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,264 @@
|
|
|
1
|
+
// Unified JSON store kernel — one locked + atomic read/write path per config file.
|
|
2
|
+
//
|
|
3
|
+
// cc-viewer historically copy-pasted the same "read → mutate → tmp→rename" block across
|
|
4
|
+
// prefs-store / auth / im-config / workspace-registry / ask-store / session-pin-store, with
|
|
5
|
+
// divergent lock and permission handling (and a few writers that were neither locked nor
|
|
6
|
+
// atomic — see lib/im/im-config.js). This module extracts that pattern ONCE so the lock and
|
|
7
|
+
// the 0600 policy live in a single place. It is the prerequisite for any consistent local
|
|
8
|
+
// state (and, later, for cloud sync): a file cannot be projected or compared unless every
|
|
9
|
+
// writer goes through the same locked, atomic path.
|
|
10
|
+
//
|
|
11
|
+
// Boundary: L1-lib. Imports only node builtins + the two L0-leaf primitives
|
|
12
|
+
// (file-api.renameSyncWithRetry, async-file-lock.withFileLockAsync) + @ccv/core/error-report.
|
|
13
|
+
// It deliberately does NOT import LOG_DIR — callers inject the file path so the kernel stays
|
|
14
|
+
// a pure, reusable primitive with no data-root coupling.
|
|
15
|
+
//
|
|
16
|
+
// Two lock flavors are provided because callers split into two camps:
|
|
17
|
+
// - mutateJson — async lock (withFileLockAsync), for async mutators.
|
|
18
|
+
// - mutateJsonSync — synchronous spin lock (openSync('wx')), for the many existing SYNC
|
|
19
|
+
// writers (auth.js, im-config.js) that cannot become async without
|
|
20
|
+
// cascading signature changes through routes and server startup.
|
|
21
|
+
// Both serialize same-process callers and mutex cross-process on the same `${file}.lock`.
|
|
22
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync, openSync, closeSync, statSync } from 'node:fs';
|
|
23
|
+
import { dirname } from 'node:path';
|
|
24
|
+
import { randomBytes } from 'node:crypto';
|
|
25
|
+
import { renameSyncWithRetry } from './file-api.js';
|
|
26
|
+
import { withFileLockAsync, hasLiveDiskHolder } from './async-file-lock.js';
|
|
27
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
28
|
+
|
|
29
|
+
/** Lock file lives next to the data file, derived from ITS name (not a fixed basename), so two
|
|
30
|
+
* different files in the same directory never share a lock by accident. */
|
|
31
|
+
export function lockPathFor(file) { return `${file}.lock`; }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Tolerant JSON read. Missing / corrupt / non-object (when fallback is a plain object) → fallback.
|
|
35
|
+
* Arrays are accepted when the fallback is an array; a scalar never satisfies an object fallback.
|
|
36
|
+
*/
|
|
37
|
+
export function readJsonSafe(file, fallback = {}) {
|
|
38
|
+
try {
|
|
39
|
+
if (!existsSync(file)) return fallback;
|
|
40
|
+
const obj = JSON.parse(readFileSync(file, 'utf-8'));
|
|
41
|
+
if (obj === null || typeof obj !== 'object') return fallback;
|
|
42
|
+
// Shape guard: an array fallback accepts only an array; a plain-object fallback rejects one.
|
|
43
|
+
if (Array.isArray(fallback) !== Array.isArray(obj)) return fallback;
|
|
44
|
+
return obj;
|
|
45
|
+
} catch {
|
|
46
|
+
return fallback;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Sentinel for "file exists but cannot be read/parsed". A MUTATE path can choose how to treat it:
|
|
51
|
+
// - DEFAULT (tolerant): collapse to `fallback` and write, so a corrupt preferences.json stays
|
|
52
|
+
// self-healing (the long-standing L66 behavior — a user re-saving recovers the file).
|
|
53
|
+
// - strictCorrupt:true: refuse to write, preserving the corrupt bytes — for files whose loss is
|
|
54
|
+
// unacceptable (e.g. profile.json, where a {} overwrite would drop every profile).
|
|
55
|
+
const CORRUPT = Symbol('json-store.corrupt');
|
|
56
|
+
function _readForMutation(file, fallback) {
|
|
57
|
+
if (!existsSync(file)) return fallback; // genuinely absent → seeding is fine
|
|
58
|
+
try {
|
|
59
|
+
const obj = JSON.parse(readFileSync(file, 'utf-8'));
|
|
60
|
+
if (obj === null || typeof obj !== 'object') return CORRUPT;
|
|
61
|
+
if (Array.isArray(fallback) !== Array.isArray(obj)) return CORRUPT;
|
|
62
|
+
return obj;
|
|
63
|
+
} catch {
|
|
64
|
+
return CORRUPT;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _resolveForMutation(data, file, strictCorrupt) {
|
|
69
|
+
if (data !== CORRUPT) return data;
|
|
70
|
+
if (strictCorrupt) {
|
|
71
|
+
const err = new Error(`json-store: refusing to overwrite corrupt/unreadable file ${file}`);
|
|
72
|
+
err.code = 'JSON_STORE_CORRUPT';
|
|
73
|
+
reportSwallowed('json-store.corrupt-guard', err);
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
return undefined; // tolerant: signal "use the fallback"
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Atomic write (tmp + renameSyncWithRetry). `mode` defaults to 0600 (the file may carry secrets);
|
|
81
|
+
* pass `mode: false` to skip the permission bit entirely (non-secret stores under umask).
|
|
82
|
+
* Serialization: `pretty` (2-space, the prefs convention) or compact.
|
|
83
|
+
*/
|
|
84
|
+
export function writeJsonAtomic(file, data, { mode = 0o600, pretty = true } = {}) {
|
|
85
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
86
|
+
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
87
|
+
try {
|
|
88
|
+
writeFileSync(tmp, pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data), mode ? { mode } : undefined);
|
|
89
|
+
renameSyncWithRetry(tmp, file);
|
|
90
|
+
if (mode) {
|
|
91
|
+
// writeFileSync's mode only applies on creation; re-assert on a pre-existing file.
|
|
92
|
+
try { chmodSync(file, mode); } catch { /* best-effort; non-POSIX or race */ }
|
|
93
|
+
}
|
|
94
|
+
} catch (err) {
|
|
95
|
+
try { unlinkSync(tmp); } catch {}
|
|
96
|
+
throw err;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Locked read-modify-write (async). Reads inside the lock, runs `mutator(data)` (mutate in
|
|
102
|
+
* place; may be async), atomically writes, returns the mutator's value when defined else data.
|
|
103
|
+
*/
|
|
104
|
+
export async function mutateJson(file, mutator, { mode = 0o600, pretty = true, fallback = {}, ensureDir, strictCorrupt = false } = {}) {
|
|
105
|
+
return withFileLockAsync(lockPathFor(file), async () => {
|
|
106
|
+
const resolved = _resolveForMutation(_readForMutation(file, fallback), file, strictCorrupt);
|
|
107
|
+
const data = resolved === undefined ? fallback : resolved;
|
|
108
|
+
const result = await mutator(data);
|
|
109
|
+
writeJsonAtomic(file, data, { mode, pretty });
|
|
110
|
+
return result !== undefined ? result : data;
|
|
111
|
+
}, { ensureDir: ensureDir ?? dirname(file) });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Lock-only primitive: holds the file's `${file}.lock` around `fn()` WITHOUT doing any read
|
|
116
|
+
* or write itself. For stores whose on-disk shape differs from their in-memory shape (e.g.
|
|
117
|
+
* ask-store's {version, entries} wrap) and therefore need to run their own domain read/save
|
|
118
|
+
* inside the mutex rather than the kernel's read→mutate→write. Shares the same lock as
|
|
119
|
+
* mutateJson / mutateJsonSync for the file.
|
|
120
|
+
*/
|
|
121
|
+
export async function withJsonLock(file, fn, { ensureDir } = {}) {
|
|
122
|
+
return withFileLockAsync(lockPathFor(file), fn, { ensureDir: ensureDir ?? dirname(file) });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Synchronous spin lock ───
|
|
126
|
+
// Mirrors async-file-lock's two-tier stale detection (dead PID or aged mtime) but with
|
|
127
|
+
// openSync('wx') + a blocking Atomics.wait so sync callers can hold the SAME `${file}.lock`.
|
|
128
|
+
// Same-process serialization is inherent (JS is single-threaded; a sync critical section has
|
|
129
|
+
// no interleaving). Cross-process mutual exclusion comes from the lock file.
|
|
130
|
+
|
|
131
|
+
function _isPidAlive(pid) {
|
|
132
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
133
|
+
try { process.kill(pid, 0); return true; } catch (err) { return err && err.code === 'EPERM'; }
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function _readLockOwnerPid(path) {
|
|
137
|
+
try {
|
|
138
|
+
const raw = readFileSync(path, 'utf-8');
|
|
139
|
+
if (!raw) return null;
|
|
140
|
+
const obj = JSON.parse(raw);
|
|
141
|
+
if (obj && Number.isInteger(obj.pid)) return obj.pid;
|
|
142
|
+
} catch {}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _isLockStale(path, mtimeFallbackMs) {
|
|
147
|
+
const pid = _readLockOwnerPid(path);
|
|
148
|
+
if (pid !== null) {
|
|
149
|
+
if (pid === process.pid) {
|
|
150
|
+
// Own pid is a crash leftover ONLY when this process does not currently hold the disk lock
|
|
151
|
+
// via the async flavor. An async holder (mutateJson / withJsonLock / mutatePrefs) is alive
|
|
152
|
+
// and mid-critical-section right now — it yields the JS thread at every await, so this sync
|
|
153
|
+
// caller can observe its lock; stealing it would let both writers commit and lose an update.
|
|
154
|
+
if (hasLiveDiskHolder(path)) return false;
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
return !_isPidAlive(pid);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
const stats = statSync(path);
|
|
161
|
+
return Date.now() - stats.mtimeMs > mtimeFallbackMs;
|
|
162
|
+
} catch {
|
|
163
|
+
return false; // conservative: cannot stat → assume held
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _blockingSleep(ms) {
|
|
168
|
+
try {
|
|
169
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
170
|
+
} catch { /* Atomics.wait unavailable on the main thread in some embedders — best-effort */ }
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Returns true when the lock was acquired, false on timeout (caller decides how to degrade).
|
|
174
|
+
// When the lock is held by THIS process's async flavor (mutateJson / withJsonLock / mutatePrefs),
|
|
175
|
+
// the sync caller must NOT spin-then-degrade: the async holder is alive and mid-critical-section,
|
|
176
|
+
// and a sync write issued from inside that critical section would alias the SAME in-memory object
|
|
177
|
+
// the async holder is about to write back — so the sync update is silently lost (the exact failure
|
|
178
|
+
// this kernel exists to prevent). That reentrancy is a programming error, not a race to ride out:
|
|
179
|
+
// throw so the caller fails loudly instead of corrupting state. Foreign-process contention still
|
|
180
|
+
// degrades per the caller's timeout policy.
|
|
181
|
+
function _acquireLockSync(lockPath, { deadline = 2000, retryMs = 25, staleThresholdMs = 5000 } = {}) {
|
|
182
|
+
if (hasLiveDiskHolder(lockPath)) {
|
|
183
|
+
const err = new Error(`mutateJsonSync re-entered a lock held by this process's async flavor: ${lockPath}`);
|
|
184
|
+
err.code = 'JSON_STORE_REENTRANT';
|
|
185
|
+
reportSwallowed('json-store.reentrant-sync', err);
|
|
186
|
+
throw err;
|
|
187
|
+
}
|
|
188
|
+
const deadlineAt = Date.now() + deadline;
|
|
189
|
+
while (true) {
|
|
190
|
+
let fd;
|
|
191
|
+
try {
|
|
192
|
+
fd = openSync(lockPath, 'wx');
|
|
193
|
+
try { writeFileSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() })); } finally { closeSync(fd); }
|
|
194
|
+
return true;
|
|
195
|
+
} catch (err) {
|
|
196
|
+
if (err?.code === 'EEXIST') {
|
|
197
|
+
if (Date.now() < deadlineAt) {
|
|
198
|
+
if (_isLockStale(lockPath, staleThresholdMs)) {
|
|
199
|
+
try { unlinkSync(lockPath); } catch {}
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
_blockingSleep(retryMs);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (_isLockStale(lockPath, staleThresholdMs)) {
|
|
206
|
+
try { unlinkSync(lockPath); } catch {}
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
return false; // timeout — caller degrades instead of crashing
|
|
210
|
+
}
|
|
211
|
+
throw err;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Synchronous locked read-modify-write, for the existing SYNC writers (auth.js, im-config.js).
|
|
218
|
+
* Holds the SAME `${file}.lock` as mutateJson so a sync writer and an async writer of the same
|
|
219
|
+
* file still mutex cross-process. `mutator` must be synchronous (no await).
|
|
220
|
+
*
|
|
221
|
+
* Timeout / reentrancy policy:
|
|
222
|
+
* - Foreign process holds the lock and it cannot be acquired in time: DEGRADE to an unlocked
|
|
223
|
+
* atomic write rather than throwing — a 2s freeze plus an uncaught throw out of a route
|
|
224
|
+
* callback would kill the whole server. The atomic write still can't tear the file; the
|
|
225
|
+
* residual lost-update window is the pre-kernel status quo and is reported so it's visible.
|
|
226
|
+
* Pass { strict: true } to throw instead.
|
|
227
|
+
* - THIS process's async flavor holds the lock (reentrant sync call from inside an async
|
|
228
|
+
* critical section): THROW (JSON_STORE_REENTRANT). That write would alias the async holder's
|
|
229
|
+
* in-memory object and be silently overwritten — a fail-closed throw is the only safe answer.
|
|
230
|
+
*/
|
|
231
|
+
export function mutateJsonSync(file, mutator, { mode = 0o600, pretty = true, fallback = {}, ensureDir, deadline, strict = false, strictCorrupt = false } = {}) {
|
|
232
|
+
const dir = ensureDir ?? dirname(file);
|
|
233
|
+
try { mkdirSync(dir, { recursive: true }); } catch {}
|
|
234
|
+
const lockPath = lockPathFor(file);
|
|
235
|
+
const acquired = _acquireLockSync(lockPath, deadline ? { deadline } : {});
|
|
236
|
+
if (!acquired && strict) {
|
|
237
|
+
throw new Error(`Lock acquisition timeout: ${lockPath} (held by live process)`);
|
|
238
|
+
}
|
|
239
|
+
if (!acquired) {
|
|
240
|
+
reportSwallowed('json-store.lock-timeout-degraded', new Error(`mutateJsonSync degraded to unlocked write: ${file}`));
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
const resolved = _resolveForMutation(_readForMutation(file, fallback), file, strictCorrupt);
|
|
244
|
+
const data = resolved === undefined ? fallback : resolved;
|
|
245
|
+
const result = mutator(data);
|
|
246
|
+
writeJsonAtomic(file, data, { mode, pretty });
|
|
247
|
+
return result !== undefined ? result : data;
|
|
248
|
+
} finally {
|
|
249
|
+
if (acquired) {
|
|
250
|
+
try { unlinkSync(lockPath); } catch (err) { reportSwallowed('json-store.lock-release', err); }
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Generic shallow merge of a patch onto `target` IN PLACE. Domain-specific merges (e.g.
|
|
257
|
+
* approvalModal reconciliation in prefs-store) stay in the per-store layer — the kernel only
|
|
258
|
+
* provides the plain-object assign so every store doesn't re-hand-roll it.
|
|
259
|
+
*/
|
|
260
|
+
export function applyJsonPatch(target, patch) {
|
|
261
|
+
if (!patch || typeof patch !== 'object') return target;
|
|
262
|
+
Object.assign(target, patch);
|
|
263
|
+
return target;
|
|
264
|
+
}
|
|
@@ -5,6 +5,7 @@ import { reconstructEntries } from '@ccv/core/delta-reconstructor';
|
|
|
5
5
|
import { enrichEntry } from './enrich-plan-input.js';
|
|
6
6
|
import { enrichEntry as enrichWorkflowEntry } from './enrich-workflow.js';
|
|
7
7
|
import { sseWrite } from './wire-compress.js';
|
|
8
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
8
9
|
|
|
9
10
|
// 1.7.0: the v1 log-file tail (fs.watch + byte cursors + rotation follow)
|
|
10
11
|
// retired with the v1 write path — the live channel is server/lib/v2/
|
|
@@ -66,8 +67,15 @@ function _safeSseWrite(clients, client, payload) {
|
|
|
66
67
|
// streams. The drain listener below stays on the res: the encoder is
|
|
67
68
|
// piped into it, so socket drain still propagates.
|
|
68
69
|
ok = sseWrite(client, payload);
|
|
69
|
-
} catch {
|
|
70
|
+
} catch (err) {
|
|
71
|
+
// A throwing write means this connection is unusable. Removing it from
|
|
72
|
+
// `clients` alone left the socket open and the 30s ping timer alive: the
|
|
73
|
+
// browser kept renewing its heartbeat watchdog on those pings, so it never
|
|
74
|
+
// reconnected and the chat panel stalled forever while the terminal WS
|
|
75
|
+
// stayed healthy. Report and CLOSE so the client's auto-reconnect fires.
|
|
76
|
+
reportSwallowed('sse.safe-write', err);
|
|
70
77
|
_removeClient(clients, client);
|
|
78
|
+
try { client.end(); } catch {}
|
|
71
79
|
return false;
|
|
72
80
|
}
|
|
73
81
|
if (!ok) {
|
|
@@ -7,11 +7,8 @@
|
|
|
7
7
|
// callers serialize via withFileLockAsync's per-lockPath Promise chain; cross-process
|
|
8
8
|
// callers mutex on the lock file. This prevents a concurrent writer from clobbering the
|
|
9
9
|
// password-bearing file or losing a fork update.
|
|
10
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
|
|
11
10
|
import { join, dirname } from 'node:path';
|
|
12
|
-
import {
|
|
13
|
-
import { renameSyncWithRetry } from './file-api.js';
|
|
14
|
-
import { withFileLockAsync } from './async-file-lock.js';
|
|
11
|
+
import { mutateJson, readJsonSafe, writeJsonAtomic } from './json-store.js';
|
|
15
12
|
import { mergeApprovalModalPrefs } from '@ccv/core/approval-modal-prefs';
|
|
16
13
|
import { reconcileVoicePackPrefs } from './voice-pack-manager.js';
|
|
17
14
|
import { LOG_DIR } from '../../findcc.js';
|
|
@@ -22,46 +19,24 @@ import { LOG_DIR } from '../../findcc.js';
|
|
|
22
19
|
// override on the helpers below exists only so the preferences route can forward its
|
|
23
20
|
// deps.getPrefsFile() seam (used by branch tests) and stay symmetric with the GET read.
|
|
24
21
|
export function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
|
|
25
|
-
// Lock lives next to the file so every writer of the SAME preferences.json shares one lock
|
|
26
|
-
// (global POST, fork ops, …). In prod all paths are canonical ⇒ one lock ⇒ serialized.
|
|
27
|
-
function getPrefsLock(file) { return join(dirname(file), 'preferences.lock'); }
|
|
28
22
|
|
|
29
23
|
/** Read the raw on-disk prefs object (no stripping, no virtual defaults). {} on miss/corrupt. */
|
|
30
24
|
export function readPrefsRaw(file = getPrefsFile()) {
|
|
31
|
-
|
|
32
|
-
if (!existsSync(file)) return {};
|
|
33
|
-
const obj = JSON.parse(readFileSync(file, 'utf-8'));
|
|
34
|
-
return obj && typeof obj === 'object' ? obj : {};
|
|
35
|
-
} catch { return {}; }
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
/** Atomic write (tmp + rename) with 0600 — the file may carry the base64 password. */
|
|
39
|
-
function writePrefsAtomic(prefs, file) {
|
|
40
|
-
mkdirSync(dirname(file), { recursive: true });
|
|
41
|
-
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
42
|
-
try {
|
|
43
|
-
writeFileSync(tmp, JSON.stringify(prefs, null, 2), { mode: 0o600 });
|
|
44
|
-
renameSyncWithRetry(tmp, file);
|
|
45
|
-
// writeFileSync's mode only applies on creation; re-assert 0600 on a pre-existing file.
|
|
46
|
-
try { chmodSync(file, 0o600); } catch { /* best-effort; non-POSIX or race */ }
|
|
47
|
-
} catch (err) {
|
|
48
|
-
try { unlinkSync(tmp); } catch {}
|
|
49
|
-
throw err;
|
|
50
|
-
}
|
|
25
|
+
return readJsonSafe(file, {});
|
|
51
26
|
}
|
|
52
27
|
|
|
53
28
|
/**
|
|
54
29
|
* Locked read-modify-write. Reads the raw prefs inside the lock, runs mutator(prefs)
|
|
55
30
|
* (mutate in place; may be async), atomically writes, and returns the mutator's return
|
|
56
31
|
* value when defined, else the mutated prefs object. `file` defaults to the canonical path.
|
|
32
|
+
*
|
|
33
|
+
* Delegates to the unified json-store kernel (mutateJson): one async file lock derived from
|
|
34
|
+
* the file name + atomic tmp→rename at 0600. The lock is shared with the SYNC writers of the
|
|
35
|
+
* same file (auth.js / im-config.js via mutateJsonSync), so all preferences.json writers now
|
|
36
|
+
* mutex on the same `preferences.json.lock`.
|
|
57
37
|
*/
|
|
58
38
|
export async function mutatePrefs(mutator, file = getPrefsFile()) {
|
|
59
|
-
return
|
|
60
|
-
const prefs = readPrefsRaw(file);
|
|
61
|
-
const result = await mutator(prefs);
|
|
62
|
-
writePrefsAtomic(prefs, file);
|
|
63
|
-
return result !== undefined ? result : prefs;
|
|
64
|
-
}, { ensureDir: dirname(file) });
|
|
39
|
+
return mutateJson(file, mutator, { mode: 0o600, fallback: {}, ensureDir: dirname(file) });
|
|
65
40
|
}
|
|
66
41
|
|
|
67
42
|
/**
|
|
@@ -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
|
+
}
|
|
@@ -8,10 +8,9 @@
|
|
|
8
8
|
// '' means no active project (workspace mode not yet launched) → read = null / write = no-op.
|
|
9
9
|
// Writes are atomic (tmp + rename) so a torn read can't happen when several writers race
|
|
10
10
|
// (mirrors server/lib/prefs-store.js).
|
|
11
|
-
import { existsSync, readFileSync,
|
|
11
|
+
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
|
-
import {
|
|
14
|
-
import { renameSyncWithRetry } from './file-api.js';
|
|
13
|
+
import { writeJsonAtomic } from './json-store.js';
|
|
15
14
|
|
|
16
15
|
/** Pin file path inside the project dir: `.session-pin.json`. */
|
|
17
16
|
export function pinFilePath(logDir) {
|
|
@@ -32,8 +31,10 @@ export function readPin(logDir) {
|
|
|
32
31
|
|
|
33
32
|
/**
|
|
34
33
|
* Write (or clear) the pinned session id. No project (logDir = '') → no-op, returns false.
|
|
35
|
-
* A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic
|
|
36
|
-
*
|
|
34
|
+
* A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic via the
|
|
35
|
+
* json-store kernel (tmp + rename). Non-secret → mode:false (umask); the pin is a view
|
|
36
|
+
* preference, not a credential. Returns true on success, false on no-project / write
|
|
37
|
+
* failure (view state is best-effort).
|
|
37
38
|
*/
|
|
38
39
|
export function writePin(logDir, pinnedSessionId) {
|
|
39
40
|
if (!logDir) return false;
|
|
@@ -44,15 +45,7 @@ export function writePin(logDir, pinnedSessionId) {
|
|
|
44
45
|
try { unlinkSync(file); } catch { /* already absent */ }
|
|
45
46
|
return true;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
|
|
49
|
-
try {
|
|
50
|
-
writeFileSync(tmp, JSON.stringify({ pinnedSessionId: id }));
|
|
51
|
-
renameSyncWithRetry(tmp, file);
|
|
52
|
-
} catch (err) {
|
|
53
|
-
try { unlinkSync(tmp); } catch {}
|
|
54
|
-
throw err;
|
|
55
|
-
}
|
|
48
|
+
writeJsonAtomic(file, { pinnedSessionId: id }, { mode: false, pretty: false });
|
|
56
49
|
return true;
|
|
57
50
|
} catch { return false; }
|
|
58
51
|
}
|
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,8 @@
|
|
|
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 {
|
|
9
|
+
import { readdir as readdirAsync, stat as statAsync } from 'node:fs/promises';
|
|
10
|
+
import { join, resolve } from 'node:path';
|
|
10
11
|
|
|
11
12
|
// The wire-format version this build writes AND the only one it reads.
|
|
12
13
|
// Stamped into meta.json and the journal sentinel at session creation; the
|
|
@@ -28,6 +29,29 @@ export function sanitizePathComponent(name) {
|
|
|
28
29
|
return cleaned;
|
|
29
30
|
}
|
|
30
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Normalize a session's owning cwd for cross-dir same-basename comparison
|
|
34
|
+
* (2026-10-07). Lexical ONLY — `resolve` collapses `.`/`..` and duplicate
|
|
35
|
+
* separators without touching the filesystem, then a trailing-separator strip
|
|
36
|
+
* evens `/a/x` vs `/a/x/`. (On POSIX `resolve` already drops trailing slashes,
|
|
37
|
+
* so the strip is redundant there; it still guards Windows `\` and the root
|
|
38
|
+
* case.) Deliberately NOT realpath: the write side stamps the raw spawn cwd and
|
|
39
|
+
* the read side reads the live PTY record's raw cwd, and both are the SAME raw
|
|
40
|
+
* string — realpath would introduce a symlink-mismatch (/tmp → /private/tmp)
|
|
41
|
+
* where the two raw sides already agree byte-for-byte. Applied identically on
|
|
42
|
+
* the write (meta.cwd stamp), read-candidate, and read-target sides so the
|
|
43
|
+
* comparison stays symmetric across a respawn. Absolute cwds pass through
|
|
44
|
+
* deterministically; a relative cwd would resolve against process.cwd() (never
|
|
45
|
+
* occurs for a spawn cwd, which is always absolute).
|
|
46
|
+
* @param {string} cwd
|
|
47
|
+
* @returns {string} '' for empty/non-string input.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeSessionCwd(cwd) {
|
|
50
|
+
if (!cwd || typeof cwd !== 'string') return '';
|
|
51
|
+
const out = resolve(cwd);
|
|
52
|
+
return out.length > 1 ? out.replace(/[\\/]+$/, '') : out;
|
|
53
|
+
}
|
|
54
|
+
|
|
31
55
|
/** Session creation time (ISO) → a 14-digit LOCAL-time stamp `yyyymmddhhmmss`
|
|
32
56
|
* for the session dir-name prefix (task C). Deliberately NO internal separator
|
|
33
57
|
* (unlike log-management's `compactLocalTs`, which emits `YYYYMMDD_HHMMSS`) so
|
|
@@ -84,6 +108,30 @@ export function dirSizeSync(dir) {
|
|
|
84
108
|
return total;
|
|
85
109
|
}
|
|
86
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Async sibling of dirSizeSync (2026-10-08): same semantics (recursive byte size,
|
|
113
|
+
* raced-deletion tolerant), but never blocks the event loop. Used by read-only,
|
|
114
|
+
* latency-sensitive paths (the /api/workspaces list) where freezing the whole
|
|
115
|
+
* Node process for ~2.4s while we walk 80k files is unacceptable — SSE streams
|
|
116
|
+
* and PTY output starve. The sync variant stays for offline converter / writer
|
|
117
|
+
* paths where blocking is fine (spawn-time, not request-time).
|
|
118
|
+
*/
|
|
119
|
+
export async function dirSizeAsync(dir) {
|
|
120
|
+
let total = 0;
|
|
121
|
+
let entries;
|
|
122
|
+
try { entries = await readdirAsync(dir, { withFileTypes: true }); } catch { return total; }
|
|
123
|
+
// Sequential per directory (avoid fd exhaustion on wide trees); parallel across
|
|
124
|
+
// sibling directories would still serialize on disk I/O in practice.
|
|
125
|
+
for (const e of entries) {
|
|
126
|
+
const p = join(dir, e.name);
|
|
127
|
+
try {
|
|
128
|
+
if (e.isDirectory()) total += await dirSizeAsync(p);
|
|
129
|
+
else if (e.isFile()) total += (await statAsync(p)).size;
|
|
130
|
+
} catch { /* raced deletion — skip */ }
|
|
131
|
+
}
|
|
132
|
+
return total;
|
|
133
|
+
}
|
|
134
|
+
|
|
87
135
|
export function convEpochPath(paths, convKey, epoch) {
|
|
88
136
|
return join(paths.conversationsDir, sanitizePathComponent(convKey), `e${epoch}.jsonl`);
|
|
89
137
|
}
|
|
@@ -122,8 +170,10 @@ export function writeFileAtomicSync(finalPath, data) {
|
|
|
122
170
|
* targeting this session. Returns the paths object.
|
|
123
171
|
*
|
|
124
172
|
* 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).
|
|
173
|
+
* userIdEncoding, leader?, im?, cwd? } — spec §3. Existing meta is never
|
|
174
|
+
* rewritten here (later additive updates go through updateMetaSync). `cwd` is
|
|
175
|
+
* the session's owning full working directory (same-name isolation, 2026-10-07);
|
|
176
|
+
* absent for legacy/external producers.
|
|
127
177
|
*/
|
|
128
178
|
export function ensureSessionDirSync(logDir, project, sessionId, meta = {}, sessionsDirName = SESSIONS_DIR_NAME, dirName = sessionId) {
|
|
129
179
|
// 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 ────────────────────────────────────────────────────────────
|