cc-viewer 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +109 -20
- package/dist/assets/App-CKvCavmF.js +2 -0
- package/dist/assets/App-Dj37fluO.css +1 -0
- package/dist/assets/{MdxEditorPanel-BUubiE6Y.js → MdxEditorPanel-DeSJp_ct.js} +1 -1
- package/dist/assets/{Mobile-CKmfRRvo.js → Mobile-CWou-jtF.js} +1 -1
- package/dist/assets/{_baseUniq-D9_E5ZDp.js → _baseUniq-BK7Nhp_x.js} +1 -1
- package/dist/assets/{arc-D4iFrlhb.js → arc-Cm4HMXz6.js} +1 -1
- package/dist/assets/{architectureDiagram-Q4EWVU46-f1neo7hg.js → architectureDiagram-Q4EWVU46-Co8QCowN.js} +1 -1
- package/dist/assets/{blockDiagram-DXYQGD6D-DWi9e9tG.js → blockDiagram-DXYQGD6D-Cudecf7x.js} +1 -1
- package/dist/assets/{c4Diagram-AHTNJAMY-DAdj49A2.js → c4Diagram-AHTNJAMY-4JeFoCHX.js} +1 -1
- package/dist/assets/{channel-B3VthT0m.js → channel-K7eT7Hjr.js} +1 -1
- package/dist/assets/{chunk-4BX2VUAB-BrPcAHV3.js → chunk-4BX2VUAB-BBjl6EVW.js} +1 -1
- package/dist/assets/{chunk-4TB4RGXK-Bvopc1V9.js → chunk-4TB4RGXK-DBP9YnwO.js} +1 -1
- package/dist/assets/{chunk-55IACEB6-Juw-7Z3i.js → chunk-55IACEB6--A_i9gwR.js} +1 -1
- package/dist/assets/{chunk-EDXVE4YY-DVYP9tpB.js → chunk-EDXVE4YY-UBsL-3qr.js} +1 -1
- package/dist/assets/{chunk-FMBD7UC4-VQ7VOXl3.js → chunk-FMBD7UC4-DBO0asmI.js} +1 -1
- package/dist/assets/{chunk-OYMX7WX6-F8Rj4ZUZ.js → chunk-OYMX7WX6-DOCcEaE6.js} +1 -1
- package/dist/assets/{chunk-QZHKN3VN-Dn7K3yi9.js → chunk-QZHKN3VN-B-j8QbQh.js} +1 -1
- package/dist/assets/{chunk-YZCP3GAM-UjO-KTsn.js → chunk-YZCP3GAM-B0WO1U-U.js} +1 -1
- package/dist/assets/classDiagram-6PBFFD2Q-Ba3tOmih.js +1 -0
- package/dist/assets/classDiagram-v2-HSJHXN6E-Ba3tOmih.js +1 -0
- package/dist/assets/clone-DjURvTeN.js +1 -0
- package/dist/assets/{cose-bilkent-S5V4N54A-BDhNj8rq.js → cose-bilkent-S5V4N54A-DQgpss_a.js} +1 -1
- package/dist/assets/{dagre-KV5264BT-BzdcDuyt.js → dagre-KV5264BT-CCJBKmKv.js} +1 -1
- package/dist/assets/{diagram-5BDNPKRD-DP_SX65N.js → diagram-5BDNPKRD-DgUcnfkU.js} +1 -1
- package/dist/assets/{diagram-G4DWMVQ6-B-pBFw8Q.js → diagram-G4DWMVQ6-CRpVvexw.js} +1 -1
- package/dist/assets/{diagram-MMDJMWI5-6XSfN1eN.js → diagram-MMDJMWI5-C3IhpsM-.js} +1 -1
- package/dist/assets/{diagram-TYMM5635-DJvGxvpE.js → diagram-TYMM5635-BaVbsrwt.js} +1 -1
- package/dist/assets/{erDiagram-SMLLAGMA-CpqdpGYj.js → erDiagram-SMLLAGMA-BxRoBUR8.js} +1 -1
- package/dist/assets/{flowDiagram-DWJPFMVM-C9m7wjwI.js → flowDiagram-DWJPFMVM-Du2lIzoM.js} +1 -1
- package/dist/assets/{ganttDiagram-T4ZO3ILL-CU0XOfY3.js → ganttDiagram-T4ZO3ILL-DR1pDqLK.js} +1 -1
- package/dist/assets/{gitGraphDiagram-UUTBAWPF-ipyGULnP.js → gitGraphDiagram-UUTBAWPF-DHAmE20O.js} +1 -1
- package/dist/assets/{graph-BR2KmES9.js → graph-D8Yv0IU1.js} +1 -1
- package/dist/assets/{index-C-be29ey.js → index-AOmFG_I_.js} +1 -1
- package/dist/assets/{index-D5tSeMU7.js → index-B6ZWIxY0.js} +1 -1
- package/dist/assets/{index-D5YruOKD.js → index-Bq95HjYM.js} +1 -1
- package/dist/assets/{index--wgRW_OB.js → index-D0pvxpcl.js} +1 -1
- package/dist/assets/{index-DuZQ6FqR.js → index-D2c77__8.js} +1 -1
- package/dist/assets/{index-C16fiVhv.js → index-DgtUyP57.js} +1 -1
- package/dist/assets/{index-CjsQEQc2.js → index-Dpu92rw_.js} +2 -2
- package/dist/assets/{index-OWzSX2T8.js → index-cCB-lhEO.js} +1 -1
- package/dist/assets/{infoDiagram-42DDH7IO-BQWQjfFX.js → infoDiagram-42DDH7IO-BAqRIm2G.js} +1 -1
- package/dist/assets/{ishikawaDiagram-UXIWVN3A-Df-KH_Hz.js → ishikawaDiagram-UXIWVN3A-CzH4tezb.js} +1 -1
- package/dist/assets/{journeyDiagram-VCZTEJTY-CMbB_4G9.js → journeyDiagram-VCZTEJTY-Qs4eiyuI.js} +1 -1
- package/dist/assets/{jszip.min-B6k2t9Ch.js → jszip.min-C0OccDsk.js} +1 -1
- package/dist/assets/{kanban-definition-6JOO6SKY-CImdlKN3.js → kanban-definition-6JOO6SKY-BAjl0gpV.js} +1 -1
- package/dist/assets/{layout-DQl4aw09.js → layout-Cf6cd9xE.js} +1 -1
- package/dist/assets/{linear-CLD3aPlK.js → linear-PzdCkji6.js} +1 -1
- package/dist/assets/mermaid.core-DV9745Lq.js +7 -0
- package/dist/assets/{min-_D86henJ.js → min-CHgWxbzS.js} +1 -1
- package/dist/assets/{mindmap-definition-QFDTVHPH-2JVcu1nk.js → mindmap-definition-QFDTVHPH-CHUjMdpi.js} +1 -1
- package/dist/assets/{pieDiagram-DEJITSTG-Dik-m5u7.js → pieDiagram-DEJITSTG-D_r2QC0f.js} +1 -1
- package/dist/assets/{quadrantDiagram-34T5L4WZ-Bm5l40Jq.js → quadrantDiagram-34T5L4WZ-DPzGygdN.js} +1 -1
- package/dist/assets/{requirementDiagram-MS252O5E-Ck9w5rTj.js → requirementDiagram-MS252O5E-BA2ynnGb.js} +1 -1
- package/dist/assets/{sankeyDiagram-XADWPNL6-BbGVCFEf.js → sankeyDiagram-XADWPNL6-XHVvXuqU.js} +1 -1
- package/dist/assets/{seqResourceLoaders-DFrHmgnK.css → seqResourceLoaders-0RXZfUKp.css} +1 -1
- package/dist/assets/seqResourceLoaders-kBEzcKLh.js +2 -0
- package/dist/assets/{sequenceDiagram-FGHM5R23-vjvEdksk.js → sequenceDiagram-FGHM5R23-C47sA8YH.js} +1 -1
- package/dist/assets/{stateDiagram-FHFEXIEX-DTqMoiB2.js → stateDiagram-FHFEXIEX-BOmer839.js} +1 -1
- package/dist/assets/{stateDiagram-v2-QKLJ7IA2-CNbAwnhu.js → stateDiagram-v2-QKLJ7IA2-D-kVvTHA.js} +1 -1
- package/dist/assets/{timeline-definition-GMOUNBTQ-Cw8O-52d.js → timeline-definition-GMOUNBTQ-CTAKVZgs.js} +1 -1
- package/dist/assets/{vendor-antd-DI7JL-mE.js → vendor-antd-BuJ6oz45.js} +2 -2
- package/dist/assets/{vendor-codemirror-B9c49dtM.js → vendor-codemirror-D2KAW4ty.js} +1 -1
- package/dist/assets/{vendor-mdxeditor-B6cpBtIE.js → vendor-mdxeditor-BsFVVUcj.js} +2 -2
- package/dist/assets/{vendor-qrcode-Dn90-s-u.js → vendor-qrcode-BpxSq04x.js} +1 -1
- package/dist/assets/{vendor-virtuoso-CpdSKD_6.js → vendor-virtuoso-an4WvAvS.js} +1 -1
- package/dist/assets/{vennDiagram-DHZGUBPP-DCuqymb7.js → vennDiagram-DHZGUBPP-CuXzoroh.js} +1 -1
- package/dist/assets/{wardley-RL74JXVD-BJ20wJUU.js → wardley-RL74JXVD-CFCmxnck.js} +1 -1
- package/dist/assets/{wardleyDiagram-NUSXRM2D-Vz6US3qt.js → wardleyDiagram-NUSXRM2D-gfhDB9SR.js} +1 -1
- package/dist/assets/{xychartDiagram-5P7HB3ND-CzkLKrmi.js → xychartDiagram-5P7HB3ND-X-pj66qI.js} +1 -1
- package/dist/index.html +4 -4
- package/package.json +1 -1
- package/server/i18n.js +120 -55
- package/server/interceptor.js +72 -2
- package/server/lib/ccswitch-import.js +251 -0
- package/server/lib/ensure-hooks.js +28 -2
- package/server/lib/log-watcher.js +8 -0
- package/server/lib/pid-alive.js +15 -0
- package/server/lib/proxy-retry.js +716 -0
- package/server/lib/session-start-bridge.js +121 -0
- package/server/lib/settings-merge.js +170 -0
- package/server/lib/v2/live-feed.js +189 -48
- package/server/lib/v2/session-owner.js +135 -0
- package/server/lib/v2/session-select.js +15 -2
- package/server/lib/v2/v2-writer.js +181 -6
- package/server/proxy.js +115 -11
- package/server/pty-manager.js +28 -4
- package/server/routes/events.js +44 -0
- package/server/routes/preferences.js +184 -1
- package/server/server.js +15 -1
- package/src/utils/contentFilter.js +17 -1
- package/dist/assets/App-B224r-8M.js +0 -2
- package/dist/assets/App-dYPa5-df.css +0 -1
- package/dist/assets/classDiagram-6PBFFD2Q-DmSozxoO.js +0 -1
- package/dist/assets/classDiagram-v2-HSJHXN6E-DmSozxoO.js +0 -1
- package/dist/assets/clone-BQtvy9Ae.js +0 -1
- package/dist/assets/mermaid.core-CN35dvKJ.js +0 -7
- package/dist/assets/seqResourceLoaders-C-T_EMiO.js +0 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Per-session-dir live-owner claim (multi-window log isolation, 2026-07-17).
|
|
2
|
+
//
|
|
3
|
+
// Problem: every ccv window (one process = V2Writer + viewer server) writes
|
|
4
|
+
// into the SHARED `LOG_DIR/<project>/sessions/` root. The "newest session"
|
|
5
|
+
// selectors (cold-load fallback, `-c` folder adoption) and the live feed's
|
|
6
|
+
// whole-root follow have no notion of "which process owns which dir", so
|
|
7
|
+
// parallel windows on one project cross-read (and, via adoption, cross-WRITE)
|
|
8
|
+
// each other's sessions.
|
|
9
|
+
//
|
|
10
|
+
// Mechanism: the writing process claims its session dir with an `owner.lock`
|
|
11
|
+
// sidecar `{pid, startedAt}`. The file is only a POINTER — the claim's
|
|
12
|
+
// validity is "the owning process is alive" (`process.kill(pid, 0)`, a
|
|
13
|
+
// kernel-memory fact), so a crashed owner's claim expires the instant the
|
|
14
|
+
// process dies and a dir can never be locked forever. Same liveness model as
|
|
15
|
+
// im-lock.js / log-management.js. The sidecar never touches journal /
|
|
16
|
+
// conversations / blobs — it is not part of the wire format.
|
|
17
|
+
//
|
|
18
|
+
// Error-direction asymmetry (deliberate — each path errs toward its safe side):
|
|
19
|
+
// - acquire: an unreadable/half-written lock is treated as HELD (refuse) —
|
|
20
|
+
// worst case the caller mints a fresh dir, which is Claude Code's own
|
|
21
|
+
// `-c` fork semantics anyway; never two writers on one journal.
|
|
22
|
+
// - isForeignLiveOwned: an unreadable lock is treated as UNOWNED (readable) —
|
|
23
|
+
// a torn crash residue must never permanently gate a dir out of the live
|
|
24
|
+
// feed / cold-load fallback.
|
|
25
|
+
|
|
26
|
+
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { isPidAlive } from '../pid-alive.js';
|
|
29
|
+
import { reportSwallowed } from '../error-report.js';
|
|
30
|
+
|
|
31
|
+
export const OWNER_LOCK_NAME = 'owner.lock';
|
|
32
|
+
|
|
33
|
+
// Contention retries for the wx create-exclusive acquire (im-lock precedent).
|
|
34
|
+
const MAX_CLAIM_ATTEMPTS = 3;
|
|
35
|
+
|
|
36
|
+
export function ownerLockPath(dir) { return join(dir, OWNER_LOCK_NAME); }
|
|
37
|
+
|
|
38
|
+
/** Read the claim; missing / half-written / non-object → null (tolerated,
|
|
39
|
+
* never grounds for deletion here — acquire has its own recycle rules). */
|
|
40
|
+
export function readSessionClaim(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const o = JSON.parse(readFileSync(ownerLockPath(dir), 'utf-8'));
|
|
43
|
+
return (o && typeof o === 'object') ? o : null;
|
|
44
|
+
} catch { return null; }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Atomically claim `dir` for `pid` (default: this process). Modeled on
|
|
49
|
+
* im-lock.js acquireImLock: `wx` create-exclusive IS the arbiter, so two
|
|
50
|
+
* processes racing to adopt the same dead-owned dir get exactly one winner.
|
|
51
|
+
* Two DELIBERATE divergences from that model (don't "fix" them back):
|
|
52
|
+
* own-pid holder → idempotent {ok:true} without rewriting (acquire runs twice
|
|
53
|
+
* per dir: adoption, then _session), where im-lock recycles its own residue;
|
|
54
|
+
* and content is written in the same call as creation ('wx' flag) — no
|
|
55
|
+
* empty-file window between sentinel and payload, so no temp+rename step.
|
|
56
|
+
* `startedAt` is diagnostic only — validity is pid-liveness alone (the
|
|
57
|
+
* accepted im-lock-style pid-reuse tradeoff; a socket-bind probe would close
|
|
58
|
+
* it and is backlog).
|
|
59
|
+
*
|
|
60
|
+
* @param {string} dir - absolute session dir
|
|
61
|
+
* @param {{pid?: number, pidAlive?: (pid:number)=>boolean}} [opts] test seams
|
|
62
|
+
* @returns {{ok:true}|{ok:false, holder?:object|null}} ok:false = cannot
|
|
63
|
+
* claim, for any of: a live foreign holder, an unreadable/half-written lock
|
|
64
|
+
* (conservatively treated as held), an unexpected write I/O failure
|
|
65
|
+
* (reported via reportSwallowed), or contention exhausting the retries.
|
|
66
|
+
* `holder` is set only when a live foreign pid was identified. Either way
|
|
67
|
+
* the caller must NOT write into this dir (adoption: mint a fresh one).
|
|
68
|
+
*/
|
|
69
|
+
export function acquireSessionClaim(dir, opts = {}) {
|
|
70
|
+
const pid = opts.pid ?? process.pid;
|
|
71
|
+
const pidAlive = opts.pidAlive || isPidAlive;
|
|
72
|
+
const p = ownerLockPath(dir);
|
|
73
|
+
for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(p, JSON.stringify({ pid, startedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
76
|
+
return { ok: true };
|
|
77
|
+
} catch (e) {
|
|
78
|
+
if (!e || e.code !== 'EEXIST') {
|
|
79
|
+
// Unexpected I/O failure (EACCES, EROFS, fd exhaustion…): a silently
|
|
80
|
+
// missing claim means the isolation quietly stops working — report.
|
|
81
|
+
reportSwallowed('v2-owner.claim', e);
|
|
82
|
+
return { ok: false, holder: null };
|
|
83
|
+
}
|
|
84
|
+
const holder = readSessionClaim(dir);
|
|
85
|
+
if (holder && holder.pid === pid) return { ok: true }; // idempotent re-entry
|
|
86
|
+
if (holder && pidAlive(holder.pid)) return { ok: false, holder };
|
|
87
|
+
if (!holder) {
|
|
88
|
+
// Unreadable: either a concurrent recycler unlinked it between our
|
|
89
|
+
// EEXIST and the read (→ retry the atomic create) or it is genuinely
|
|
90
|
+
// half-written (→ conservatively held; the writer is mid-claim).
|
|
91
|
+
if (!existsSync(p)) continue;
|
|
92
|
+
return { ok: false, holder: null };
|
|
93
|
+
}
|
|
94
|
+
// Dead holder → recycle the stale lock and retry the atomic create.
|
|
95
|
+
try { unlinkSync(p); } catch { /* concurrent recycler won the unlink — retry */ }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Contention exhausted the retries: report the current holder if live.
|
|
99
|
+
const holder = readSessionClaim(dir);
|
|
100
|
+
if (holder && holder.pid !== pid && pidAlive(holder.pid)) return { ok: false, holder };
|
|
101
|
+
return { ok: false, holder: holder || null };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Release the claim on `dir` — identity-checked: only unlinks when the lock
|
|
106
|
+
* is held by `pid` (default: this process), so a successor process's claim is
|
|
107
|
+
* never deleted by a late-exiting predecessor. Missing file = already
|
|
108
|
+
* released. Synchronous on purpose: it runs at the top of V2Writer.close()
|
|
109
|
+
* ahead of the (bounded, raceable) async queue drain.
|
|
110
|
+
* @returns {boolean} true when the dir is no longer claimed by `pid`
|
|
111
|
+
*/
|
|
112
|
+
export function releaseSessionClaim(dir, opts = {}) {
|
|
113
|
+
const pid = opts.pid ?? process.pid;
|
|
114
|
+
const holder = readSessionClaim(dir);
|
|
115
|
+
if (holder && holder.pid !== pid) return false; // taken over — don't touch
|
|
116
|
+
try { unlinkSync(ownerLockPath(dir)); } catch { /* ENOENT = already released */ }
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Is `dir` exclusively owned by ANOTHER live process? The single skip
|
|
122
|
+
* predicate used by latestMainSession (cold-load fallback + `-c` adoption
|
|
123
|
+
* candidates) and the live feed's attach/re-check gates. History readers
|
|
124
|
+
* never call this — a dead owner's claim makes the dir plain unowned data.
|
|
125
|
+
* @param {string} dir - absolute session dir
|
|
126
|
+
* @param {{pid?: number, pidAlive?: (pid:number)=>boolean}} [opts] test seams
|
|
127
|
+
* @returns {boolean}
|
|
128
|
+
*/
|
|
129
|
+
export function isForeignLiveOwned(dir, opts = {}) {
|
|
130
|
+
const pid = opts.pid ?? process.pid;
|
|
131
|
+
const pidAlive = opts.pidAlive || isPidAlive;
|
|
132
|
+
const holder = readSessionClaim(dir); // unreadable → null → unowned (self-healing)
|
|
133
|
+
if (!holder || !Number.isInteger(holder.pid)) return false;
|
|
134
|
+
return holder.pid !== pid && pidAlive(holder.pid);
|
|
135
|
+
}
|
|
@@ -24,6 +24,7 @@ import { join } from 'node:path';
|
|
|
24
24
|
import { StringDecoder } from 'node:string_decoder';
|
|
25
25
|
import { listSessionIds } from './replay.js';
|
|
26
26
|
import { isSupportedWireFormat } from './layout.js';
|
|
27
|
+
import { isForeignLiveOwned } from './session-owner.js';
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Find the on-disk dir NAME of an existing session for `sessionId` (a UUID), or
|
|
@@ -247,17 +248,29 @@ export function isDiscardableSession(dir, meta) {
|
|
|
247
248
|
* has-a-main-req gate would otherwise re-select exactly the dir the caller's
|
|
248
249
|
* completed-turn gate just rejected (it IS the newest once its first main req
|
|
249
250
|
* is written), nullifying the fallback.
|
|
251
|
+
*
|
|
252
|
+
* `skipForeignLive` (multi-window isolation, 2026-07-17) drops candidates
|
|
253
|
+
* whose `owner.lock` is held by ANOTHER live process — a parallel ccv window's
|
|
254
|
+
* in-flight session. Without it, the cold-load fallback serves the other
|
|
255
|
+
* window's conversation and `-c` adoption writes into its journal. A dead
|
|
256
|
+
* owner's claim expires with its pid (kernel liveness), so crashed windows'
|
|
257
|
+
* sessions stay selectable — never a permanent lock. Both consumers
|
|
258
|
+
* (getLiveLogSource fallback, _resolveAdoption) pass true; default false keeps
|
|
259
|
+
* the raw picker semantics for everything else.
|
|
250
260
|
* @param {string} projectDir - absolute LOG_DIR/<project>
|
|
251
|
-
* @param {{excludeDir?: string}} [opts]
|
|
261
|
+
* @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts]
|
|
252
262
|
* @returns {{dir:string, sessionId:string}|null}
|
|
253
263
|
*/
|
|
254
|
-
export function latestMainSession(projectDir, { excludeDir = '' } = {}) {
|
|
264
|
+
export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false } = {}) {
|
|
255
265
|
if (!projectDir) return null;
|
|
256
266
|
const candidates = []; // { dir, sessionId, startTs }
|
|
257
267
|
for (const name of listSessionIds(projectDir)) {
|
|
258
268
|
const dir = join(projectDir, 'sessions', name);
|
|
259
269
|
if (excludeDir && dir === excludeDir) continue;
|
|
260
270
|
if (!existsSync(join(dir, 'journal.jsonl'))) continue;
|
|
271
|
+
// After the cheaper journal-existence reject: skips one owner.lock read
|
|
272
|
+
// per torn/non-session dir on large-history cold loads.
|
|
273
|
+
if (skipForeignLive && isForeignLiveOwned(dir)) continue;
|
|
261
274
|
let meta = null;
|
|
262
275
|
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { continue; }
|
|
263
276
|
if (!meta) continue;
|
|
@@ -22,6 +22,7 @@ import { AsyncWriteQueue } from '../async-write-queue.js';
|
|
|
22
22
|
import { reportSwallowed } from '../error-report.js';
|
|
23
23
|
import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent } from './layout.js';
|
|
24
24
|
import { resolveSessionDirName, latestMainSession } from './session-select.js';
|
|
25
|
+
import { acquireSessionClaim, releaseSessionClaim, isForeignLiveOwned } from './session-owner.js';
|
|
25
26
|
import { BlobStore } from './blob-store.js';
|
|
26
27
|
import { Journal } from './journal.js';
|
|
27
28
|
import { ConversationStore } from './conversation-store.js';
|
|
@@ -76,6 +77,10 @@ export class V2Writer {
|
|
|
76
77
|
// latency. Purely a nudge — the feed's data source stays the files.
|
|
77
78
|
this._onActivity = typeof opts.onActivity === 'function' ? opts.onActivity : null;
|
|
78
79
|
this._sessions = new Map(); // sessionId → {paths, blobs, journal, convs, resolver}
|
|
80
|
+
// Multi-window isolation (2026-07-17): session dirs this process has
|
|
81
|
+
// claimed via owner.lock. Released synchronously in resetSessions()/close()
|
|
82
|
+
// — a crash skips release harmlessly (claim validity is pid liveness).
|
|
83
|
+
this._claimedDirs = new Set();
|
|
79
84
|
this._currentSid = null; // last successfully resolved session (fallback routing §8.3)
|
|
80
85
|
this._pendingNoSid = []; // requests seen before any sid (cold-start heartbeats).
|
|
81
86
|
// Contract with the caller: originalMessages held here must be a reference
|
|
@@ -92,6 +97,12 @@ export class V2Writer {
|
|
|
92
97
|
this._forkSession = false;
|
|
93
98
|
this._resumeSession = false;
|
|
94
99
|
this._adopted = false;
|
|
100
|
+
// In-terminal /resume switch (SessionStart hook, source:'resume'): the
|
|
101
|
+
// next MAIN request must be re-routed to the resumed conversation's dir.
|
|
102
|
+
// {transcriptUuid, hookSid} | null; last-wins, consumed one-shot, no TTL
|
|
103
|
+
// (after a /resume the next main request belongs to the resumed
|
|
104
|
+
// conversation no matter how much later it is typed).
|
|
105
|
+
this._pendingResumeSwitch = null;
|
|
95
106
|
}
|
|
96
107
|
|
|
97
108
|
/** P2: true once any session's FIRST main wire already carried assistant
|
|
@@ -141,11 +152,126 @@ export class V2Writer {
|
|
|
141
152
|
if (!m || m.length <= 1 || !m.some((x) => x && x.role === 'assistant')) return null;
|
|
142
153
|
const projectDir = join(this._logDir, sanitizePathComponent(project));
|
|
143
154
|
if (resolveSessionDirName(projectDir, sid, this._sessionsDirName)) return null; // same-UUID restart
|
|
144
|
-
|
|
155
|
+
// Multi-window isolation: never adopt a dir another LIVE window is writing
|
|
156
|
+
// (that is the two-writers-one-journal corruption), and CLAIM the picked
|
|
157
|
+
// dir atomically BEFORE committing — two simultaneous `-c` launches racing
|
|
158
|
+
// onto the same dead-owned dir get exactly one adopter via the owner.lock
|
|
159
|
+
// 'wx' arbiter; the loser falls through to a fresh dir, which is Claude
|
|
160
|
+
// Code's own continue/fork semantics (fresh wire sid per continue).
|
|
161
|
+
// skipForeignLive is deliberately UNCONDITIONAL while the claim below is
|
|
162
|
+
// _claimsEnabled()-gated: a non-claiming writer must still never adopt a
|
|
163
|
+
// live window's dir — it just doesn't stamp ownership of its own.
|
|
164
|
+
const prev = latestMainSession(projectDir, { skipForeignLive: true });
|
|
145
165
|
if (!prev || !prev.sessionId) return null;
|
|
166
|
+
if (this._claimsEnabled()) {
|
|
167
|
+
if (!acquireSessionClaim(prev.dir).ok) return null;
|
|
168
|
+
// Record immediately (not only in _session): a throw between here and
|
|
169
|
+
// _session must not leak an untracked lock file until process exit.
|
|
170
|
+
this._claimedDirs.add(prev.dir);
|
|
171
|
+
}
|
|
146
172
|
return { dirName: basename(prev.dir), identityUUID: prev.sessionId };
|
|
147
173
|
}
|
|
148
174
|
|
|
175
|
+
/** In-terminal /resume signal (interceptor.markSessionStart → here): arm a
|
|
176
|
+
* one-shot routing switch. `transcriptUuid` is the resumed conversation's
|
|
177
|
+
* stable identity (transcript basename); `hookSid` is the fresh session
|
|
178
|
+
* uuid the hook minted — used as the NEW dir identity when the resumed
|
|
179
|
+
* conversation was never recorded by ccv (it must not be the old wire sid:
|
|
180
|
+
* `<ts>_<oldSid>` would be re-resolved back to the OLD dir by
|
|
181
|
+
* resolveSessionDirName). Last-wins across repeated /resume picks. */
|
|
182
|
+
beginResumeSwitch({ transcriptUuid, hookSid } = {}) {
|
|
183
|
+
try {
|
|
184
|
+
if (!transcriptUuid || typeof transcriptUuid !== 'string') return;
|
|
185
|
+
this._pendingResumeSwitch = {
|
|
186
|
+
transcriptUuid,
|
|
187
|
+
hookSid: (typeof hookSid === 'string' && hookSid) ? hookSid : null,
|
|
188
|
+
};
|
|
189
|
+
} catch (err) {
|
|
190
|
+
reportSwallowed('v2-write.resume-switch', err);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Consume the pending /resume switch for the arriving MAIN request: pick
|
|
196
|
+
* the target dir, take its claim, drop the old same-sid binding, and return
|
|
197
|
+
* an adoptTarget for `_session()` (the same channel `-c` adoption uses).
|
|
198
|
+
* Target precedence: the recorded dir of the resumed conversation
|
|
199
|
+
* (resolveSessionDirName by transcript uuid — the log re-joins its original
|
|
200
|
+
* folder, identity preserved) unless another LIVE window holds its claim
|
|
201
|
+
* (acquire fails → fork semantics, mirror of `_resolveAdoption`); else a
|
|
202
|
+
* fresh `<ts>_<hookSid>` dir. Works for BOTH wire behaviors: same-sid
|
|
203
|
+
* (re-bind: delete the old map entry so `_session` re-creates it against
|
|
204
|
+
* the new dir) and fresh-sid (plain targeted adoption).
|
|
205
|
+
* @param {string|null} prevSid - the writer's active sid BEFORE this
|
|
206
|
+
* request: on a fresh-sid resume the departing conversation is keyed by
|
|
207
|
+
* it, not by the incoming `sid`
|
|
208
|
+
* @returns {{dirName:string, identityUUID:string}}
|
|
209
|
+
*/
|
|
210
|
+
_resolveResumeSwitch(sid, project, prevSid = null) {
|
|
211
|
+
const sw = this._pendingResumeSwitch;
|
|
212
|
+
this._pendingResumeSwitch = null; // one-shot
|
|
213
|
+
const projectDir = join(this._logDir, sanitizePathComponent(project));
|
|
214
|
+
let dirName = resolveSessionDirName(projectDir, sw.transcriptUuid, this._sessionsDirName);
|
|
215
|
+
let identity = sw.transcriptUuid;
|
|
216
|
+
if (dirName) {
|
|
217
|
+
const targetDir = join(projectDir, this._sessionsDirName, dirName);
|
|
218
|
+
// Liveness guard is UNCONDITIONAL (mirror _resolveAdoption's
|
|
219
|
+
// unconditional skipForeignLive): even a non-claiming writer must never
|
|
220
|
+
// re-bind into a dir another live window is writing. The acquire below
|
|
221
|
+
// additionally arbitrates for claiming writers.
|
|
222
|
+
if (isForeignLiveOwned(targetDir)
|
|
223
|
+
|| (this._claimsEnabled() && !acquireSessionClaim(targetDir).ok)) {
|
|
224
|
+
dirName = null; // live foreign owner — never share its journal
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
if (dirName) {
|
|
228
|
+
// Adopt the recorded dir under ITS identity (meta.sessionId,
|
|
229
|
+
// first-write-wins) so `_seqEpoch` continues that conversation.
|
|
230
|
+
try {
|
|
231
|
+
const m = JSON.parse(readFileSync(join(projectDir, this._sessionsDirName, dirName, 'meta.json'), 'utf-8'));
|
|
232
|
+
if (m && m.sessionId) identity = m.sessionId;
|
|
233
|
+
} catch { /* torn meta — transcriptUuid is the correct fallback identity */ }
|
|
234
|
+
} else {
|
|
235
|
+
identity = sw.hookSid || `resume-${process.pid}-${Date.now()}`;
|
|
236
|
+
dirName = `${compactLocalTs14(new Date().toISOString())}_${identity}`;
|
|
237
|
+
}
|
|
238
|
+
// Drop the departing conversation's binding and release its claim — this
|
|
239
|
+
// process has switched away and will not write it again, so other windows
|
|
240
|
+
// may now resume/adopt it. Same-sid wire (the observed behavior): the old
|
|
241
|
+
// binding is keyed by the INCOMING sid; fresh-sid wire: it is keyed by
|
|
242
|
+
// prevSid (review P2 — without this branch the old dir's claim + map
|
|
243
|
+
// entry leaked until process exit).
|
|
244
|
+
const oldKey = this._sessions.has(sid) ? sid
|
|
245
|
+
: (prevSid && prevSid !== sid && this._sessions.has(prevSid)) ? prevSid : null;
|
|
246
|
+
if (oldKey !== null) {
|
|
247
|
+
const old = this._sessions.get(oldKey);
|
|
248
|
+
this._sessions.delete(oldKey);
|
|
249
|
+
releaseSessionClaim(old.paths.dir);
|
|
250
|
+
this._claimedDirs.delete(old.paths.dir);
|
|
251
|
+
}
|
|
252
|
+
return { dirName, identityUUID: identity };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Multi-window isolation: does this writer claim its session dirs? Only a
|
|
256
|
+
* MAIN interactive leader does — teammate writers (leader set) and IM
|
|
257
|
+
* workers (CCV_IM_PLATFORM) stay unclaimed so any project viewer keeps
|
|
258
|
+
* following them (cross-process producers by design), and the offline
|
|
259
|
+
* converter (staging sessionsDirName) must never stamp live-ownership onto
|
|
260
|
+
* migrated history. */
|
|
261
|
+
_claimsEnabled() {
|
|
262
|
+
return this._enabled && !this._leader
|
|
263
|
+
&& !process.env.CCV_IM_PLATFORM
|
|
264
|
+
&& this._sessionsDirName === 'sessions';
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Release every dir this process claimed (identity-checked unlinks).
|
|
268
|
+
* Synchronous on purpose — close() runs it ahead of the bounded async queue
|
|
269
|
+
* drain so a hung drain can't strand live-looking locks on a clean exit. */
|
|
270
|
+
_releaseAllClaims() {
|
|
271
|
+
for (const dir of this._claimedDirs) releaseSessionClaim(dir);
|
|
272
|
+
this._claimedDirs.clear();
|
|
273
|
+
}
|
|
274
|
+
|
|
149
275
|
_session(sessionId, userIdRaw, encoding, project, startTsIso, adoptTarget = null) {
|
|
150
276
|
let s = this._sessions.get(sessionId);
|
|
151
277
|
if (s) return s; // hot path: map hit is O(1), the scan below only runs on miss
|
|
@@ -184,6 +310,14 @@ export class V2Writer {
|
|
|
184
310
|
}
|
|
185
311
|
}
|
|
186
312
|
const paths = ensureSessionDirSync(this._logDir, project, identityId, meta, this._sessionsDirName, dirName);
|
|
313
|
+
// Multi-window isolation: stamp this process's live claim on the dir. A
|
|
314
|
+
// fresh wire-UUID dir has no contention (always succeeds); the adoption
|
|
315
|
+
// path already holds the claim (idempotent re-entry). Failure (a live
|
|
316
|
+
// foreign holder appeared) degrades to today's unclaimed behavior — the
|
|
317
|
+
// write itself must never be blocked by the claim.
|
|
318
|
+
if (this._claimsEnabled() && acquireSessionClaim(paths.dir).ok) {
|
|
319
|
+
this._claimedDirs.add(paths.dir);
|
|
320
|
+
}
|
|
187
321
|
s = {
|
|
188
322
|
paths,
|
|
189
323
|
blobs: new BlobStore(paths),
|
|
@@ -231,6 +365,13 @@ export class V2Writer {
|
|
|
231
365
|
if (!this._diskOk()) return null;
|
|
232
366
|
|
|
233
367
|
const parsed = parseUserId(entry.body && entry.body.metadata && entry.body.metadata.user_id);
|
|
368
|
+
// Captured BEFORE the reassignment below: when a /resume switch arrives
|
|
369
|
+
// on a FRESH wire sid, the departing conversation's binding is keyed by
|
|
370
|
+
// this previous sid, not the incoming one (review P2 — the fresh-sid
|
|
371
|
+
// variant would otherwise leak the old dir's claim + map entry until
|
|
372
|
+
// process exit, blocking other windows from resuming that ended
|
|
373
|
+
// conversation).
|
|
374
|
+
const prevSid = this._currentSid;
|
|
234
375
|
let sid;
|
|
235
376
|
if (parsed) {
|
|
236
377
|
sid = parsed.sessionId;
|
|
@@ -258,8 +399,26 @@ export class V2Writer {
|
|
|
258
399
|
// before the folder is created; null on every non-adoption path.
|
|
259
400
|
let adoptTarget = null;
|
|
260
401
|
if (parsed) {
|
|
261
|
-
|
|
262
|
-
|
|
402
|
+
// In-terminal /resume switch outranks launch adoption: the pending
|
|
403
|
+
// signal is consumed by the first REAL main request (countTokens
|
|
404
|
+
// probes and heartbeats wear main-agent body shapes but are not the
|
|
405
|
+
// user's resumed turn — consuming on them would re-bind too early
|
|
406
|
+
// with the same result, but keep the gate strict for clarity).
|
|
407
|
+
if (this._pendingResumeSwitch && entry.mainAgent
|
|
408
|
+
&& !entry.isCountTokens && !entry.isHeartbeat) {
|
|
409
|
+
// Locally guarded (review P2): the switch resolution does dir-scan
|
|
410
|
+
// fs work (resolveSessionDirName → readdirSync) whose residual
|
|
411
|
+
// throw surface (EACCES/ENOTDIR) would otherwise escape to the
|
|
412
|
+
// outer catch and drop THIS request entirely — the resumed
|
|
413
|
+
// conversation's first main turn. A failure must degrade to normal
|
|
414
|
+
// routing (recorded in the current dir), never to a lost entry.
|
|
415
|
+
try { adoptTarget = this._resolveResumeSwitch(sid, project, prevSid); }
|
|
416
|
+
catch (err) { reportSwallowed('v2-write.resume-switch-apply', err); }
|
|
417
|
+
}
|
|
418
|
+
if (!adoptTarget) {
|
|
419
|
+
adoptTarget = this._resolveAdoption(sid, project, originalMessages, entry);
|
|
420
|
+
if (adoptTarget) this._adopted = true;
|
|
421
|
+
}
|
|
263
422
|
}
|
|
264
423
|
|
|
265
424
|
// entry.timestamp = this session's first-request time → meta.startTs +
|
|
@@ -484,9 +643,12 @@ export class V2Writer {
|
|
|
484
643
|
return s ? s.paths.dir : null;
|
|
485
644
|
}
|
|
486
645
|
|
|
487
|
-
/** Lifecycle hook
|
|
488
|
-
*
|
|
489
|
-
*
|
|
646
|
+
/** Lifecycle hook: drop in-memory conversation continuity so the next
|
|
647
|
+
* request snapshots fresh. Journal seq keeps counting (same process, same
|
|
648
|
+
* session dirs — seq must stay monotonic per session). NOTE: the
|
|
649
|
+
* in-terminal /resume switch does NOT ride this — it needs a DIR re-bind,
|
|
650
|
+
* not just fresh continuity (see beginResumeSwitch); this stays for
|
|
651
|
+
* in-place resets that keep the dir. */
|
|
490
652
|
resetConversations() {
|
|
491
653
|
try {
|
|
492
654
|
for (const s of this._sessions.values()) {
|
|
@@ -505,6 +667,10 @@ export class V2Writer {
|
|
|
505
667
|
* under the new project — different directory, so no seq collision. */
|
|
506
668
|
resetSessions() {
|
|
507
669
|
try {
|
|
670
|
+
// Release live claims FIRST (before the session map is gone): a
|
|
671
|
+
// switched-away workspace's dirs are dormant and legitimately adoptable
|
|
672
|
+
// by other windows from this moment on.
|
|
673
|
+
this._releaseAllClaims();
|
|
508
674
|
this._sessions.clear();
|
|
509
675
|
this._pendingNoSid.length = 0;
|
|
510
676
|
this._lateHandles.clear();
|
|
@@ -512,6 +678,9 @@ export class V2Writer {
|
|
|
512
678
|
// A second workspace's `-c` must be able to adopt afresh — the previous
|
|
513
679
|
// workspace's adoption doesn't carry over.
|
|
514
680
|
this._adopted = false;
|
|
681
|
+
// A /resume signal armed in the previous workspace context is
|
|
682
|
+
// meaningless after the switch (its transcript belongs to that project).
|
|
683
|
+
this._pendingResumeSwitch = null;
|
|
515
684
|
} catch (err) {
|
|
516
685
|
reportSwallowed('v2-write.reset-sessions', err);
|
|
517
686
|
}
|
|
@@ -523,6 +692,12 @@ export class V2Writer {
|
|
|
523
692
|
}
|
|
524
693
|
|
|
525
694
|
async close() {
|
|
695
|
+
// Synchronous claim release BEFORE the async drain: close() runs under
|
|
696
|
+
// interceptor.js's 2s-bounded shutdown race, and a hung drain must not
|
|
697
|
+
// strand owner.lock files on a clean exit. (A crash skips this entirely —
|
|
698
|
+
// harmless, the claim's validity dies with the pid.)
|
|
699
|
+
try { this._releaseAllClaims(); }
|
|
700
|
+
catch (err) { reportSwallowed('v2-write.release-claims', err); }
|
|
526
701
|
await this._queue.close();
|
|
527
702
|
}
|
|
528
703
|
}
|
package/server/proxy.js
CHANGED
|
@@ -8,6 +8,8 @@ import { setupInterceptor } from './interceptor.js';
|
|
|
8
8
|
import { extractApiErrorMessage, formatProxyRequestError } from './lib/proxy-errors.js';
|
|
9
9
|
import { getProxyDispatcher } from './lib/proxy-env.js';
|
|
10
10
|
import { getClaudeConfigDir } from '../findcc.js';
|
|
11
|
+
import { isAnthropicApiPath } from './lib/interceptor-core.js';
|
|
12
|
+
import { executeRequest } from './lib/proxy-retry.js';
|
|
11
13
|
|
|
12
14
|
// Setup interceptor to patch fetch
|
|
13
15
|
setupInterceptor();
|
|
@@ -111,27 +113,35 @@ export function startProxy() {
|
|
|
111
113
|
headers: headers,
|
|
112
114
|
};
|
|
113
115
|
|
|
114
|
-
// 标记此请求为 CC-Viewer 代理转发的 Claude API 请求
|
|
115
|
-
// 拦截器识别到此 Header 会强制记录,忽略 URL 匹配规则
|
|
116
|
-
fetchOptions.headers['x-cc-viewer-trace'] = 'true';
|
|
117
|
-
|
|
118
|
-
if (body.length > 0) {
|
|
119
|
-
fetchOptions.body = body;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
116
|
// 走用户的网络代理:Node 内置全局 fetch 既不读 http_proxy/https_proxy,也看不到
|
|
123
117
|
// userland undici 的 setGlobalDispatcher,必须把代理 dispatcher 显式传进来,否则
|
|
124
118
|
// 上游请求会绕过代理直连 api.anthropic.com(详见 lib/proxy-env.js 注释)。
|
|
125
119
|
const proxyDispatcher = getProxyDispatcher();
|
|
126
|
-
if (proxyDispatcher) {
|
|
127
|
-
fetchOptions.dispatcher = proxyDispatcher;
|
|
128
|
-
}
|
|
129
120
|
|
|
130
121
|
// 拼接完整 URL,保留 originalBaseUrl 中的路径前缀
|
|
131
122
|
const cleanBase = originalBaseUrl.endsWith('/') ? originalBaseUrl.slice(0, -1) : originalBaseUrl;
|
|
132
123
|
const cleanReq = req.url.startsWith('/') ? req.url.slice(1) : req.url;
|
|
133
124
|
const fullUrl = `${cleanBase}/${cleanReq}`;
|
|
134
125
|
|
|
126
|
+
// 大模型 API 请求(/v1/messages 等)走重试引擎:serial/race/stagger 重试。
|
|
127
|
+
// 重试引擎内部用 x-cc-viewer-trace 头让 interceptor 记录每次重试尝试到会话日志(网络视图数据源),
|
|
128
|
+
// 模型替换由重试引擎用 resolveProfileModel 纯函数完成(interceptor 对 trace 请求会再跑一次,幂等 no-op)。
|
|
129
|
+
// 非大模型 API 请求走原逻辑(同样用 x-cc-viewer-trace 让 interceptor 记录,但不经重试引擎)。
|
|
130
|
+
if (isAnthropicApiPath(fullUrl)) {
|
|
131
|
+
await handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ── 非大模型 API:原逻辑(透传,interceptor 记录)──
|
|
136
|
+
fetchOptions.headers['x-cc-viewer-trace'] = 'true';
|
|
137
|
+
|
|
138
|
+
if (body.length > 0) {
|
|
139
|
+
fetchOptions.body = body;
|
|
140
|
+
}
|
|
141
|
+
if (proxyDispatcher) {
|
|
142
|
+
fetchOptions.dispatcher = proxyDispatcher;
|
|
143
|
+
}
|
|
144
|
+
|
|
135
145
|
const response = await fetch(fullUrl, fetchOptions);
|
|
136
146
|
|
|
137
147
|
// fetch 自动解压,需移除编码相关 header 避免客户端重复解压
|
|
@@ -201,3 +211,97 @@ export function startProxy() {
|
|
|
201
211
|
});
|
|
202
212
|
});
|
|
203
213
|
}
|
|
214
|
+
|
|
215
|
+
// ── 大模型 API 请求处理:重试引擎 ──────────────────────
|
|
216
|
+
// mode=off 时退化为单次 fetch(与原透传一致)。
|
|
217
|
+
// 重试配置由 interceptor._retryConfigState 提供(live binding,watchFile 热刷新),
|
|
218
|
+
// 每请求取最新值,UI 改 retry-config.json 后下一个请求即生效,无需重启。
|
|
219
|
+
const _retryConfigGetter = () => interceptor._retryConfigState;
|
|
220
|
+
|
|
221
|
+
async function handleLlmApiRequest(req, res, fullUrl, fetchOptions, body, proxyDispatcher) {
|
|
222
|
+
const retryConfig = _retryConfigGetter(); // live binding:每请求取最新重试配置
|
|
223
|
+
const profile = interceptor._activeProfile || null;
|
|
224
|
+
|
|
225
|
+
// 清理可能的 trace 头(executeRequest 会重新加,避免旧值干扰);interceptor 会正常记录请求
|
|
226
|
+
// 同时清理 hop-by-hop / 传输编码头:body 已被 buffer 成完整 Buffer,
|
|
227
|
+
// 残留的 transfer-encoding:chunked / content-length 会让 undici 拒绝(invalid transfer-encoding header)
|
|
228
|
+
const retryFetchOptions = {
|
|
229
|
+
method: fetchOptions.method,
|
|
230
|
+
headers: { ...fetchOptions.headers },
|
|
231
|
+
};
|
|
232
|
+
delete retryFetchOptions.headers['x-cc-viewer-trace'];
|
|
233
|
+
delete retryFetchOptions.headers['transfer-encoding'];
|
|
234
|
+
delete retryFetchOptions.headers['Transfer-Encoding'];
|
|
235
|
+
delete retryFetchOptions.headers['connection'];
|
|
236
|
+
delete retryFetchOptions.headers['Connection'];
|
|
237
|
+
delete retryFetchOptions.headers['content-length'];
|
|
238
|
+
delete retryFetchOptions.headers['Content-Length'];
|
|
239
|
+
if (body.length > 0) {
|
|
240
|
+
retryFetchOptions.body = body;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Client-disconnect propagation: without this, a client that gave up leaves
|
|
244
|
+
// the retry loop hammering (and billing) the upstream for up to
|
|
245
|
+
// maxRetryDurationMs. res 'close' before the response is finished means the
|
|
246
|
+
// client is gone → abort every in-flight/queued attempt via the engine.
|
|
247
|
+
const clientAbort = new AbortController();
|
|
248
|
+
res.on('close', () => {
|
|
249
|
+
if (!res.writableEnded) {
|
|
250
|
+
try { clientAbort.abort(); } catch { /* best effort */ }
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// 执行带重试的请求
|
|
255
|
+
const result = await executeRequest({
|
|
256
|
+
url: fullUrl,
|
|
257
|
+
fetchOptions: retryFetchOptions,
|
|
258
|
+
retryConfig,
|
|
259
|
+
ctx: { dispatcher: proxyDispatcher, profile, signal: clientAbort.signal },
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const { response, attempts, finalStatus } = result;
|
|
263
|
+
|
|
264
|
+
// Client already gone: nothing to write; the engine has aborted upstream work.
|
|
265
|
+
if (clientAbort.signal.aborted || res.writableEnded) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// 注入 X-Forward-Attempts 头(告知客户端本次重试了几次,与 llm-retry-proxy 一致)
|
|
270
|
+
const responseHeaders = {};
|
|
271
|
+
if (response.headers && typeof response.headers.entries === 'function') {
|
|
272
|
+
for (const [key, value] of response.headers.entries()) {
|
|
273
|
+
if (key.toLowerCase() !== 'content-encoding' && key.toLowerCase() !== 'transfer-encoding' && key.toLowerCase() !== 'content-length') {
|
|
274
|
+
responseHeaders[key] = value;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
responseHeaders['x-forward-attempts'] = String(attempts);
|
|
279
|
+
|
|
280
|
+
// 网络异常(finalStatus=0)或竞速全失败:返回 502 + Proxy Error,与原 catch 分支一致
|
|
281
|
+
if (finalStatus === 0) {
|
|
282
|
+
res.writeHead(502, { 'x-forward-attempts': String(attempts) });
|
|
283
|
+
res.end('Proxy Error');
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// 响应客户端
|
|
288
|
+
res.writeHead(finalStatus, responseHeaders);
|
|
289
|
+
|
|
290
|
+
if (response.body) {
|
|
291
|
+
const { Readable, pipeline } = await import('node:stream');
|
|
292
|
+
// @ts-ignore
|
|
293
|
+
const nodeStream = Readable.fromWeb(response.body);
|
|
294
|
+
nodeStream.on('error', () => {});
|
|
295
|
+
pipeline(nodeStream, res, (err) => {
|
|
296
|
+
if (err && process.env.CCV_DEBUG) {
|
|
297
|
+
console.error('[CC-Viewer Proxy] Stream pipeline error:', err.message);
|
|
298
|
+
}
|
|
299
|
+
});
|
|
300
|
+
} else if (response.text) {
|
|
301
|
+
// 竞速失败兜底 response:用 text() 取 body
|
|
302
|
+
const text = await response.text();
|
|
303
|
+
res.end(text);
|
|
304
|
+
} else {
|
|
305
|
+
res.end();
|
|
306
|
+
}
|
|
307
|
+
}
|
package/server/pty-manager.js
CHANGED
|
@@ -11,6 +11,8 @@ import { buildSystemPromptFileArgs } from './lib/system-prompt-files.js';
|
|
|
11
11
|
import { renderSystemPromptFileArgs } from './lib/system-prompt-render.js';
|
|
12
12
|
import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
|
|
13
13
|
import { resolveSpawnModel } from './lib/spawn-model-resolver.js';
|
|
14
|
+
import { mergeSettingsIntoArgs } from './lib/settings-merge.js';
|
|
15
|
+
import { t, tFor } from './i18n.js';
|
|
14
16
|
|
|
15
17
|
const __filename = fileURLToPath(import.meta.url);
|
|
16
18
|
const __dirname = dirname(__filename);
|
|
@@ -302,14 +304,29 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
302
304
|
],
|
|
303
305
|
};
|
|
304
306
|
}
|
|
305
|
-
const settingsJson = JSON.stringify(settingsObj);
|
|
306
|
-
|
|
307
307
|
// 注入 --thinking-display summarized;以下任一情况跳过注入:
|
|
308
308
|
// - 路径在拒绝集里(上次因此 crash 过)
|
|
309
309
|
// - 环境变量 CCV_SKIP_THINKING_DISPLAY=1(用户全局 opt-out,与 cli.js 保持一致)
|
|
310
310
|
const shouldInjectThinkingDisplay = !_thinkingDisplayRejectedPaths.has(claudePath)
|
|
311
311
|
&& process.env.CCV_SKIP_THINKING_DISPLAY !== '1';
|
|
312
|
-
|
|
312
|
+
|
|
313
|
+
// Fold any user-supplied --settings into the injected settings so the final argv
|
|
314
|
+
// carries a SINGLE --settings flag. claude is last-wins for duplicate --settings
|
|
315
|
+
// (empirically verified), so a user flag sitting after ours would silently clobber
|
|
316
|
+
// the injected ANTHROPIC_BASE_URL proxy override and the CCV_IM_DENY deny hardening.
|
|
317
|
+
// Merged: injected keys win, deny is unioned, other user config rides along.
|
|
318
|
+
// Runs on the RAW user args BEFORE our own --thinking-display / --system-prompt-file
|
|
319
|
+
// tokens are appended: otherwise a trailing valueless user --settings would consume
|
|
320
|
+
// an injected token as its value, silently dropping the injection. Relative settings
|
|
321
|
+
// paths resolve against the cwd claude itself runs with (spawnDir, computed below).
|
|
322
|
+
const spawnDir = cwd || process.cwd();
|
|
323
|
+
const settingsMerge = mergeSettingsIntoArgs(extraArgs, settingsObj, { cwd: spawnDir });
|
|
324
|
+
if (settingsMerge.warningDetail) {
|
|
325
|
+
console.warn(`[CC Viewer] ${tFor('cli.settingsMergeFailed', 'en', settingsMerge.warningDetail)}`);
|
|
326
|
+
}
|
|
327
|
+
const settingsJson = settingsMerge.settingsJson;
|
|
328
|
+
const userArgs = settingsMerge.args;
|
|
329
|
+
const finalExtraArgs = shouldInjectThinkingDisplay ? withDefaultThinkingDisplay(userArgs) : userArgs;
|
|
313
330
|
|
|
314
331
|
// 启动目录存在 CC_SYSTEM.md / CC_APPEND_SYSTEM.md(非空)时,自动追加
|
|
315
332
|
// --system-prompt-file / --append-system-prompt-file(两者独立、用户已传同义 flag 时跳过对应项)。
|
|
@@ -318,7 +335,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
318
335
|
// 注:currentWorkspacePath 在下方才赋值,这里用 cwd 参数判定启动目录。
|
|
319
336
|
// LOG_DIR 内的 spawn(IM worker 工作目录 = <LOG_DIR>/IM_<id>/)跳过模型匹配:
|
|
320
337
|
// IM 人格依赖默认 sentinel CC_APPEND_SYSTEM.md 注入,全局模型条目不得静默取代它。
|
|
321
|
-
|
|
338
|
+
// (spawnDir 已在上方 settings 合并处赋值。)
|
|
322
339
|
// insideLogDir 留在 try 外:下方 onExit 的启动兜底门控也要用它。
|
|
323
340
|
const insideLogDir = spawnDir === LOG_DIR || spawnDir.startsWith(LOG_DIR + sep);
|
|
324
341
|
// 整个 system prompt 构建 + 渲染管道包在 try-catch 里(PR#128):任何意外抛错(模型解析、
|
|
@@ -495,6 +512,13 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
|
|
|
495
512
|
const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
|
|
496
513
|
emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
|
|
497
514
|
}
|
|
515
|
+
// Settings-merge failures surface via emitSpawnNotice too: console.warn only reaches
|
|
516
|
+
// the server stdout, invisible in the embedded terminal. Localized here (the console.warn
|
|
517
|
+
// above stays English for greppable server logs). Must be emitted after spawn — the
|
|
518
|
+
// outputBuffer reset right before pty.spawn would swallow an earlier write.
|
|
519
|
+
if (settingsMerge.warningDetail && !_suppressNextSpawnNotice) {
|
|
520
|
+
emitSpawnNotice(`[CC Viewer] ${t('cli.settingsMergeFailed', settingsMerge.warningDetail)}`);
|
|
521
|
+
}
|
|
498
522
|
_suppressNextSpawnNotice = false;
|
|
499
523
|
|
|
500
524
|
return ptyProcess;
|