cc-viewer 1.8.19 → 1.8.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/cli.js +33 -4
- package/dist/assets/App-BmZyzd9Q.js +2 -0
- package/dist/assets/App-DOZI5HME.css +1 -0
- package/dist/assets/{MdxEditorPanel-DU_5CRtW.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
- package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
- package/dist/assets/Mobile-C10tOIQo.js +1 -0
- package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
- package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
- package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
- package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
- package/dist/assets/clone-CFj55h49.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
- package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
- package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
- package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
- package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
- package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
- package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
- package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
- package/dist/assets/index-DyVKoGOX.js +2 -0
- package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
- package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
- package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
- package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
- package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
- package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
- package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
- package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
- package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
- package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
- package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
- package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
- package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
- package/dist/index.html +5 -5
- package/package.json +1 -1
- package/server/interceptor.js +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/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/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 +28 -3
- package/server/lib/v2/live-feed.js +41 -4
- package/server/lib/v2/resume-list.js +150 -0
- package/server/lib/v2/session-select.js +63 -14
- package/server/lib/v2/v2-writer.js +172 -34
- package/server/lib/v2/view-router.js +83 -0
- package/server/lib/view-root.js +209 -0
- package/server/pty-manager.js +800 -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/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 +302 -0
- package/server/routes/search.js +23 -1
- package/server/routes/skills.js +18 -2
- package/server/routes/v2.js +6 -1
- package/server/routes/workspaces.js +10 -3
- package/server/server.js +199 -25
- package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
- package/server/workspace-registry.js +41 -35
- package/dist/assets/App-C9Tx6Iqw.js +0 -2
- package/dist/assets/App-M61pqQEx.css +0 -1
- package/dist/assets/Mobile--EECC49-.js +0 -1
- package/dist/assets/ProxyStatsModal-DJu0xq3Z.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-CJdRSHTP.js +0 -2
- package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
- package/dist/assets/seqResourceLoaders-C5UX6ecI.js +0 -2
|
@@ -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,7 @@
|
|
|
6
6
|
// and the swallowed ENOENT would silently drop v2 lines (plan risk F2).
|
|
7
7
|
|
|
8
8
|
import { mkdirSync, writeFileSync, renameSync, existsSync, openSync, writeSync, fsyncSync, closeSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
-
import { join } from 'node:path';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
10
|
|
|
11
11
|
// The wire-format version this build writes AND the only one it reads.
|
|
12
12
|
// Stamped into meta.json and the journal sentinel at session creation; the
|
|
@@ -28,6 +28,29 @@ export function sanitizePathComponent(name) {
|
|
|
28
28
|
return cleaned;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Normalize a session's owning cwd for cross-dir same-basename comparison
|
|
33
|
+
* (2026-10-07). Lexical ONLY — `resolve` collapses `.`/`..` and duplicate
|
|
34
|
+
* separators without touching the filesystem, then a trailing-separator strip
|
|
35
|
+
* evens `/a/x` vs `/a/x/`. (On POSIX `resolve` already drops trailing slashes,
|
|
36
|
+
* so the strip is redundant there; it still guards Windows `\` and the root
|
|
37
|
+
* case.) Deliberately NOT realpath: the write side stamps the raw spawn cwd and
|
|
38
|
+
* the read side reads the live PTY record's raw cwd, and both are the SAME raw
|
|
39
|
+
* string — realpath would introduce a symlink-mismatch (/tmp → /private/tmp)
|
|
40
|
+
* where the two raw sides already agree byte-for-byte. Applied identically on
|
|
41
|
+
* the write (meta.cwd stamp), read-candidate, and read-target sides so the
|
|
42
|
+
* comparison stays symmetric across a respawn. Absolute cwds pass through
|
|
43
|
+
* deterministically; a relative cwd would resolve against process.cwd() (never
|
|
44
|
+
* occurs for a spawn cwd, which is always absolute).
|
|
45
|
+
* @param {string} cwd
|
|
46
|
+
* @returns {string} '' for empty/non-string input.
|
|
47
|
+
*/
|
|
48
|
+
export function normalizeSessionCwd(cwd) {
|
|
49
|
+
if (!cwd || typeof cwd !== 'string') return '';
|
|
50
|
+
const out = resolve(cwd);
|
|
51
|
+
return out.length > 1 ? out.replace(/[\\/]+$/, '') : out;
|
|
52
|
+
}
|
|
53
|
+
|
|
31
54
|
/** Session creation time (ISO) → a 14-digit LOCAL-time stamp `yyyymmddhhmmss`
|
|
32
55
|
* for the session dir-name prefix (task C). Deliberately NO internal separator
|
|
33
56
|
* (unlike log-management's `compactLocalTs`, which emits `YYYYMMDD_HHMMSS`) so
|
|
@@ -122,8 +145,10 @@ export function writeFileAtomicSync(finalPath, data) {
|
|
|
122
145
|
* targeting this session. Returns the paths object.
|
|
123
146
|
*
|
|
124
147
|
* meta: { wireFormat:2, sessionId, project, pid, startTs, userIdRaw,
|
|
125
|
-
* userIdEncoding, leader?, im? } — spec §3. Existing meta is never
|
|
126
|
-
* rewritten here (later additive updates go through updateMetaSync).
|
|
148
|
+
* userIdEncoding, leader?, im?, cwd? } — spec §3. Existing meta is never
|
|
149
|
+
* rewritten here (later additive updates go through updateMetaSync). `cwd` is
|
|
150
|
+
* the session's owning full working directory (same-name isolation, 2026-10-07);
|
|
151
|
+
* absent for legacy/external producers.
|
|
127
152
|
*/
|
|
128
153
|
export function ensureSessionDirSync(logDir, project, sessionId, meta = {}, sessionsDirName = SESSIONS_DIR_NAME, dirName = sessionId) {
|
|
129
154
|
// Path uses dirName (`<ts>_<uuid>` for live, task C); IDENTITY (meta.sessionId
|
|
@@ -183,6 +183,14 @@ export class V2LiveFeed {
|
|
|
183
183
|
// row (v2_requests_delta). Explicit ctor param — this module has no access
|
|
184
184
|
// to the server deps object.
|
|
185
185
|
this._wireV3 = !!opts.wireV3;
|
|
186
|
+
// Per-project view layer (2026-10): this feed's project name + an optional
|
|
187
|
+
// client filter. The server runs ONE feed per project (bound + any viewed);
|
|
188
|
+
// each broadcast must reach only the clients whose viewed project matches
|
|
189
|
+
// THIS feed's project. `clientFilter(clients, project)` returns the subset;
|
|
190
|
+
// the default (no filter) is the legacy broadcast-to-all behavior, so the
|
|
191
|
+
// single-project case is byte-identical.
|
|
192
|
+
this._project = typeof opts.project === 'string' ? opts.project : '';
|
|
193
|
+
this._clientFilter = typeof opts.clientFilter === 'function' ? opts.clientFilter : null;
|
|
186
194
|
this._sessions = new Map(); // dir → cursor bundle
|
|
187
195
|
this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
|
|
188
196
|
this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
|
|
@@ -193,6 +201,29 @@ export class V2LiveFeed {
|
|
|
193
201
|
this._active = false;
|
|
194
202
|
}
|
|
195
203
|
|
|
204
|
+
/** Clients this feed's broadcasts should reach right now: the full list, or
|
|
205
|
+
* the per-project filtered subset when a filter is installed. */
|
|
206
|
+
_clientsFor() {
|
|
207
|
+
return this._clientFilter ? this._clientFilter(this._clients, this._project) : this._clients;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Instance-scoped variant (multi-instance, 2026-10-06): narrow the per-project clients to
|
|
212
|
+
* those viewing the SAME instance as this session. Two concurrent same-cwd instances share
|
|
213
|
+
* one project feed, so this keeps instance A's entries out of instance B's viewers.
|
|
214
|
+
* Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
|
|
215
|
+
* single-instance view) sees every session regardless of `sessionInstance`; a session with
|
|
216
|
+
* no stamped instance (legacy/external) is likewise visible to all.
|
|
217
|
+
*/
|
|
218
|
+
_clientsForInstance(sessionInstance) {
|
|
219
|
+
const base = this._clientsFor();
|
|
220
|
+
if (!sessionInstance) return base; // session carries no instance → visible to all viewers
|
|
221
|
+
let anyInstanceViewer = false;
|
|
222
|
+
for (const c of base) { if (c && c._ccvViewInstance) { anyInstanceViewer = true; break; } }
|
|
223
|
+
if (!anyInstanceViewer) return base; // nobody is instance-scoped → keep legacy broadcast
|
|
224
|
+
return base.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === sessionInstance);
|
|
225
|
+
}
|
|
226
|
+
|
|
196
227
|
/** Begin (or switch to) live-following a project's sessions/ root. */
|
|
197
228
|
start(projectDir) {
|
|
198
229
|
this.stop();
|
|
@@ -374,9 +405,14 @@ export class V2LiveFeed {
|
|
|
374
405
|
// 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
|
|
375
406
|
// read, cloned, or broadcast; the cold-load channel owns their history.
|
|
376
407
|
let convertOrigin = false;
|
|
408
|
+
let sessionInstance = null;
|
|
377
409
|
try {
|
|
378
410
|
const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
|
|
379
411
|
convertOrigin = !!meta && meta.origin === 'convert';
|
|
412
|
+
// Multi-instance (2026-10-06): the owning PTY instanceKey stamped on meta.json (by the
|
|
413
|
+
// writer) lets this feed route the session's entries to exactly the clients viewing
|
|
414
|
+
// THAT instance of a same-cwd project. Absent for legacy/convert/external sessions.
|
|
415
|
+
sessionInstance = (meta && typeof meta.instance === 'string' && meta.instance) ? meta.instance : null;
|
|
380
416
|
} catch (err) {
|
|
381
417
|
// meta.json is written synchronously BEFORE the journal's first async
|
|
382
418
|
// drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
|
|
@@ -409,6 +445,7 @@ export class V2LiveFeed {
|
|
|
409
445
|
dirty: false,
|
|
410
446
|
suppress: !!suppressExisting,
|
|
411
447
|
convertOrigin,
|
|
448
|
+
sessionInstance,
|
|
412
449
|
// Journal req seqs with no done yet. The journal is only touched at
|
|
413
450
|
// request start and completion, so a single >10min request would look
|
|
414
451
|
// "idle" to the mtime-based eviction and get detached mid-flight — its
|
|
@@ -611,7 +648,7 @@ export class V2LiveFeed {
|
|
|
611
648
|
try {
|
|
612
649
|
processWatchedEntry(parsed, {
|
|
613
650
|
reconstructor: cur.reconstructor,
|
|
614
|
-
clients: this.
|
|
651
|
+
clients: this._clientsForInstance(cur.sessionInstance),
|
|
615
652
|
getClaudePid: this._getClaudePid,
|
|
616
653
|
runParallelHook: this._runParallelHook,
|
|
617
654
|
suppressEntryBroadcast: this._wireV3,
|
|
@@ -648,7 +685,7 @@ export class V2LiveFeed {
|
|
|
648
685
|
? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
|
|
649
686
|
: `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
|
|
650
687
|
const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
|
|
651
|
-
sendEventRawToClients(this.
|
|
688
|
+
sendEventRawToClients(this._clientsForInstance(cur.sessionInstance), eventName, payload);
|
|
652
689
|
} catch (err) {
|
|
653
690
|
reportSwallowed('v2-live.native-forward', err);
|
|
654
691
|
}
|
|
@@ -721,12 +758,12 @@ export class V2LiveFeed {
|
|
|
721
758
|
const corrected = withAgentNameSubType(classifyRequest(prev.entry, parsed), prev.entry);
|
|
722
759
|
if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
|
|
723
760
|
prev.row.typeTag = corrected;
|
|
724
|
-
sendEventToClients(this.
|
|
761
|
+
sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', prev.row);
|
|
725
762
|
}
|
|
726
763
|
} catch (err) { reportSwallowed('v2-live.row-classify', err); }
|
|
727
764
|
}
|
|
728
765
|
cur._v3Last = { row, entry: parsed };
|
|
729
|
-
sendEventToClients(this.
|
|
766
|
+
sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', row);
|
|
730
767
|
}
|
|
731
768
|
|
|
732
769
|
// ── safety poll ────────────────────────────────────────────────────────────
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Per-project "recent sessions" list for the /resume hover list (2026-10).
|
|
2
|
+
//
|
|
3
|
+
// Serves the N most-recent sessions of ONE project (the currently-bound one),
|
|
4
|
+
// for the header's current-project hover dropdown. Kept as a pure lib function
|
|
5
|
+
// (no HTTP, no interceptor state) so it is unit-testable with a temp LOG_DIR and
|
|
6
|
+
// an injectable `now` seam — same test posture as listV2LogsPage / deleteLogFiles.
|
|
7
|
+
//
|
|
8
|
+
// Scope: ONLY ccv's own v2 store is listed (sessions ccv actually proxied).
|
|
9
|
+
//
|
|
10
|
+
// Cost shape: a CHEAP meta.json pass per session (startTs + leader), plus a
|
|
11
|
+
// bounded journal head-scan (sessionHasMainOrTeammateReq) that drops quota-probe
|
|
12
|
+
// orphan dirs BEFORE they can occupy a top slot. Candidates are sorted
|
|
13
|
+
// newest-first, sliced to the top `limit`, and only THEN run the EXPENSIVE
|
|
14
|
+
// summarize (journal fold + dir walk + prompts head) via summarizeSessionPage —
|
|
15
|
+
// which shares listV2Sessions' freshness-keyed row cache, so repeat hover-opens
|
|
16
|
+
// are ~1-3ms. Fields the cheap pass cannot supply (sessionUuid from
|
|
17
|
+
// meta.sessionId, running, pinnedId) are read fresh per call for the surviving
|
|
18
|
+
// rows only, never from the cached row.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
21
|
+
import { join } from 'node:path';
|
|
22
|
+
import { sanitizePathComponent } from './layout.js';
|
|
23
|
+
import { listSessionIds } from './replay.js';
|
|
24
|
+
import { summarizeSessionPage } from './session-list.js';
|
|
25
|
+
import { sessionHasMainOrTeammateReq } from './session-select.js';
|
|
26
|
+
import { readPin } from '../session-pin-store.js';
|
|
27
|
+
import { LIVE_SESSION_MTIME_MS } from '../log-file-utils.js';
|
|
28
|
+
import { reportSwallowed } from '@ccv/core/error-report';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Liveness heuristic for the "running" placeholder column. A session is
|
|
32
|
+
* treated as possibly-live when its journal was appended within
|
|
33
|
+
* LIVE_SESSION_MTIME_MS. Coarse by design (a background-but-idle >5min session
|
|
34
|
+
* reads as not-running) — this is a display hint only. `now` is injectable for tests.
|
|
35
|
+
* @param {string} sessionDir
|
|
36
|
+
* @param {number} now - ms epoch
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function isRecentlyActive(sessionDir, now) {
|
|
40
|
+
try {
|
|
41
|
+
const st = statSync(join(sessionDir, 'journal.jsonl'));
|
|
42
|
+
return (now - st.mtimeMs) < LIVE_SESSION_MTIME_MS;
|
|
43
|
+
} catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* List the `limit` most-recent v2 sessions of ONE project under `logDir`,
|
|
48
|
+
* newest-first. Row shape:
|
|
49
|
+
* { source:'ccv', project, file, sessionUuid, startTs, turns, preview, running, pinnedId }
|
|
50
|
+
* - `project` — the LOG_DIR subdirectory name (already sanitize-safe).
|
|
51
|
+
* - `file` — v2 addressing token `v2:<project>/<dirName>` (dirName is
|
|
52
|
+
* `<ts>_<uuid>` or legacy `<uuid>`), for the existing
|
|
53
|
+
* open-log / live-tail paths.
|
|
54
|
+
* - `sessionUuid` — the Claude Code session UUID from meta.sessionId (NOT the
|
|
55
|
+
* dir name), the key the client uses to scope a `?sid=` view.
|
|
56
|
+
* - `pinnedId` — the project's persisted pin value verbatim (readPin), a
|
|
57
|
+
* ts-based display id. The SERVER cannot map it to this row
|
|
58
|
+
* (the ts stable id is a frontend runtime _timestamp), so the
|
|
59
|
+
* row just carries it through; the CLIENT decides `selected`
|
|
60
|
+
* by comparing against its own session stable ids.
|
|
61
|
+
* - `running` — coarse liveness placeholder (see isRecentlyActive).
|
|
62
|
+
*
|
|
63
|
+
* @param {string} logDir - LOG_DIR root (e.g. ~/.claude/cc-viewer).
|
|
64
|
+
* @param {object} [opts]
|
|
65
|
+
* @param {string} opts.project - the single project to list (required).
|
|
66
|
+
* @param {number} [opts.limit=10]
|
|
67
|
+
* @param {number} [opts.now] - ms epoch, injectable for tests.
|
|
68
|
+
* @returns {{ items: Array, total: number }}
|
|
69
|
+
*/
|
|
70
|
+
export function listResumeSessions(logDir, { project, limit = 10, now = Date.now() } = {}) {
|
|
71
|
+
const out = { items: [], total: 0 };
|
|
72
|
+
if (!project || project !== sanitizePathComponent(project)) return out;
|
|
73
|
+
|
|
74
|
+
const projectDir = join(logDir, project);
|
|
75
|
+
if (!logDir || !existsSync(projectDir)) return out;
|
|
76
|
+
|
|
77
|
+
// ── ccv's own v2 store (sessions ccv proxied). Cheap meta.json pass per
|
|
78
|
+
// session for startTs + leader ordering/filtering, no journal fold.
|
|
79
|
+
const candidates = [];
|
|
80
|
+
let sessionIds;
|
|
81
|
+
try { sessionIds = listSessionIds(projectDir); } catch (err) {
|
|
82
|
+
reportSwallowed('resume-list.readdir', err, { project });
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
for (const dirName of sessionIds) {
|
|
86
|
+
let meta = null;
|
|
87
|
+
try { meta = JSON.parse(readFileSync(join(projectDir, 'sessions', dirName, 'meta.json'), 'utf-8')); } catch { /* journal is self-describing */ }
|
|
88
|
+
if (meta && meta.leader) continue; // teammate — folded into its leader's row
|
|
89
|
+
// Cheap discardable-session pre-check (bounded journal head-scan, early
|
|
90
|
+
// exit on the first main/teammate req): drops quota-probe orphan dirs
|
|
91
|
+
// BEFORE they can occupy a top-`limit` slot — without paying the full
|
|
92
|
+
// summarize (journal fold + dir walk) for every candidate. Same source
|
|
93
|
+
// predicate as session-list's isDiscardableSession (leader-absent + no
|
|
94
|
+
// main/teammate req), so a probe is filtered here and the summarize-time
|
|
95
|
+
// `discard` re-check below becomes a no-op for survivors.
|
|
96
|
+
if (!sessionHasMainOrTeammateReq(join(projectDir, 'sessions', dirName))) continue;
|
|
97
|
+
candidates.push({
|
|
98
|
+
project,
|
|
99
|
+
projectDir,
|
|
100
|
+
dirName,
|
|
101
|
+
startTs: (meta && meta.startTs) || '',
|
|
102
|
+
sessionUuid: (meta && meta.sessionId) || null,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Order newest-first, then take the top `limit` BEFORE the expensive
|
|
107
|
+
// summarize (journal fold + dir walk + prompts head) — so a large history
|
|
108
|
+
// pays the summarize for at most `limit` rows, not for every session. Rows
|
|
109
|
+
// dropped by the summarize-time filters (teammate fold / quota-probe orphan /
|
|
110
|
+
// unreadable) leave their slot empty rather than being back-filled; such rows
|
|
111
|
+
// are rare and `limit` is small, so the occasional short list is accepted in
|
|
112
|
+
// exchange for the bounded cost.
|
|
113
|
+
candidates.sort((a, b) =>
|
|
114
|
+
b.startTs.localeCompare(a.startTs)
|
|
115
|
+
|| String(b.dirName || '').localeCompare(String(a.dirName || '')));
|
|
116
|
+
const top = candidates.slice(0, limit);
|
|
117
|
+
|
|
118
|
+
const ccvRows = [];
|
|
119
|
+
for (const c of top) {
|
|
120
|
+
let s = null;
|
|
121
|
+
try { s = summarizeSessionPage(c.projectDir, c.dirName); } catch (err) {
|
|
122
|
+
reportSwallowed('resume-list.summarize', err, { project: c.project, dirName: c.dirName });
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (!s) continue;
|
|
126
|
+
if (s.leader) continue;
|
|
127
|
+
if (s.size === 0) continue;
|
|
128
|
+
if (s.discard) continue; // quota-probe orphans: never listed
|
|
129
|
+
const sessionDir = join(c.projectDir, 'sessions', c.dirName);
|
|
130
|
+
ccvRows.push({
|
|
131
|
+
source: 'ccv',
|
|
132
|
+
project: c.project,
|
|
133
|
+
file: `v2:${c.project}/${s.sid}`,
|
|
134
|
+
sessionUuid: c.sessionUuid,
|
|
135
|
+
startTs: s.startTs,
|
|
136
|
+
turns: s.turns,
|
|
137
|
+
preview: s.preview || [],
|
|
138
|
+
running: isRecentlyActive(sessionDir, now),
|
|
139
|
+
// The pin lives at the PROJECT dir and holds a ts-based display id, not a
|
|
140
|
+
// uuid — pass it through verbatim; the client maps it to a row.
|
|
141
|
+
pinnedId: readPin(c.projectDir),
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Already newest-first from the candidate sort (the summarize filter only
|
|
146
|
+
// drops rows, never reorders). total = surviving rows shown (≤ limit).
|
|
147
|
+
out.total = ccvRows.length;
|
|
148
|
+
out.items = ccvRows;
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
@@ -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
|
}
|