cc-viewer 1.7.1 → 1.7.3
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 +21 -3
- package/dist/assets/{App-gUSmU6Ny.js → App-DRsuJXEw.js} +1 -1
- package/dist/assets/{MdxEditorPanel-DAR39A3S.js → MdxEditorPanel-D_dewIIc.js} +1 -1
- package/dist/assets/{Mobile-qfRsRth0.js → Mobile-BIzb7vpA.js} +1 -1
- package/dist/assets/{index-B51Ci0GL.js → index-DidP9FCD.js} +2 -2
- package/dist/assets/{seqResourceLoaders-DZJxMMaR.js → seqResourceLoaders-BtGpfGRW.js} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/i18n.js +20 -0
- package/server/interceptor.js +45 -1
- package/server/lib/ensure-hooks.js +28 -2
- package/server/lib/log-management.js +1 -0
- package/server/lib/log-watcher.js +8 -0
- package/server/lib/pid-alive.js +15 -0
- package/server/lib/session-start-bridge.js +121 -0
- package/server/lib/settings-merge.js +170 -0
- package/server/lib/stats-worker.js +21 -2
- package/server/lib/v2/adapter.js +52 -12
- package/server/lib/v2/journal.js +9 -5
- package/server/lib/v2/jsonl-read.js +127 -0
- package/server/lib/v2/live-feed.js +302 -66
- package/server/lib/v2/meta-rows.js +22 -21
- package/server/lib/v2/replay.js +5 -5
- package/server/lib/v2/session-owner.js +135 -0
- package/server/lib/v2/session-select.js +84 -4
- package/server/lib/v2/v2-writer.js +181 -6
- package/server/pty-manager.js +28 -4
- package/server/routes/events.js +44 -0
- package/server/routes/im.js +1 -1
- package/server/server.js +4 -1
- package/server/workspace-registry.js +5 -0
|
@@ -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
|
|
@@ -75,9 +76,13 @@ const SCAN_CHUNK = 64 * 1024;
|
|
|
75
76
|
* @param {string} dir - absolute session dir
|
|
76
77
|
* @param {number} budget - max bytes to read before giving up
|
|
77
78
|
* @param {(line:string) => boolean} onLine
|
|
79
|
+
* @param {boolean} [ioErrorResult=false] - returned when the journal EXISTS
|
|
80
|
+
* but reading it throws (transient lock / fd exhaustion). The cold-load
|
|
81
|
+
* scanners keep the false default ("not activated"); the discard predicate
|
|
82
|
+
* passes true so an I/O hiccup can never hide a REAL session.
|
|
78
83
|
* @returns {boolean}
|
|
79
84
|
*/
|
|
80
|
-
function scanJournal(dir, budget, onLine) {
|
|
85
|
+
function scanJournal(dir, budget, onLine, ioErrorResult = false) {
|
|
81
86
|
const journal = join(dir, 'journal.jsonl');
|
|
82
87
|
if (!existsSync(journal)) return false;
|
|
83
88
|
let fd;
|
|
@@ -105,7 +110,7 @@ function scanJournal(dir, budget, onLine) {
|
|
|
105
110
|
const last = (carry + decoder.end()).trim();
|
|
106
111
|
return !!(last && onLine(last));
|
|
107
112
|
} catch {
|
|
108
|
-
return
|
|
113
|
+
return ioErrorResult;
|
|
109
114
|
} finally {
|
|
110
115
|
if (fd !== undefined) { try { closeSync(fd); } catch {} }
|
|
111
116
|
}
|
|
@@ -167,6 +172,69 @@ export function sessionHasCompletedMainTurn(dir) {
|
|
|
167
172
|
});
|
|
168
173
|
}
|
|
169
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Does this session dir have at least one req of kind 'main' OR 'teammate'?
|
|
177
|
+
* The positive half of the discardable-session predicate. Budgeted with the
|
|
178
|
+
* WIDE budget (not MAIN_REQ_SCAN_BUDGET): journal req lines run ~1.1-1.3KB
|
|
179
|
+
* (headers + params), and time-driven heartbeat/countTokens lines can pile up
|
|
180
|
+
* before a real session's first main — a 256KB budget could give up early and
|
|
181
|
+
* misjudge a REAL session as discardable. Sessions that have a main still
|
|
182
|
+
* early-exit at its line; only genuinely main-less journals pay the budget.
|
|
183
|
+
* @param {string} dir - absolute session dir
|
|
184
|
+
* @returns {boolean}
|
|
185
|
+
*/
|
|
186
|
+
export function sessionHasMainOrTeammateReq(dir) {
|
|
187
|
+
// ioErrorResult=true: a transient read error must KEEP the session (treat
|
|
188
|
+
// as main-bearing) — hiding a real session is the worse failure direction;
|
|
189
|
+
// a missing journal still returns false (discard is correct there).
|
|
190
|
+
return scanJournal(dir, COMPLETED_TURN_SCAN_BUDGET, (line) => {
|
|
191
|
+
if (!line.includes('"ph":"req"')) return false;
|
|
192
|
+
if (!line.includes('"kind":"main"') && !line.includes('"kind":"teammate"')) return false;
|
|
193
|
+
try {
|
|
194
|
+
const o = JSON.parse(line);
|
|
195
|
+
return !!(o && o.ph === 'req' && (o.kind === 'main' || o.kind === 'teammate'));
|
|
196
|
+
} catch { return false; } // torn line — keep scanning
|
|
197
|
+
}, true);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Discardable-session predicate (2026-07-16): a session dir that must be
|
|
202
|
+
* DISCARDED by every read surface — never listed, counted, followed, or used
|
|
203
|
+
* as a candidate in any logic. These are orphan dirs minted by Claude Code's
|
|
204
|
+
* quota probes (max_tokens:1, one `'quota'` user message, a throwaway
|
|
205
|
+
* session_id per probe — fired at launches and agent-team spawns), plus any
|
|
206
|
+
* torn/empty dir with no renderable identity.
|
|
207
|
+
*
|
|
208
|
+
* discard ⟺ meta.leader ABSENT (not a teammate session)
|
|
209
|
+
* AND no journal req line of kind 'main' or 'teammate'
|
|
210
|
+
*
|
|
211
|
+
* The 'teammate' kind clause is the safety net for a torn meta.json. Every
|
|
212
|
+
* real session carries a main req at/near the journal head (verified across
|
|
213
|
+
* the full real dataset: 18/18 main-less dirs were single quota probes), so a
|
|
214
|
+
* kept session early-exits its scan cheaply. Read-side only and self-healing:
|
|
215
|
+
* the moment a dir gains its first main req, the predicate flips and every
|
|
216
|
+
* surface picks it up on its next scan/poll.
|
|
217
|
+
*
|
|
218
|
+
* KEEP IN SYNC: adapter.js listV2Sessions pre-computes the same verdict
|
|
219
|
+
* inline (hasMainOrTeammate inside its existing full journal fold — unbounded,
|
|
220
|
+
* vs this predicate's 8MB budget; intentional asymmetry) and then CONFIRMS a
|
|
221
|
+
* discard through this predicate so the error→keep direction is shared.
|
|
222
|
+
* Change the rule here and there together.
|
|
223
|
+
*
|
|
224
|
+
* @param {string} dir - absolute session dir
|
|
225
|
+
* @param {object|null} [meta] - pre-parsed meta.json (avoids a re-read);
|
|
226
|
+
* omitted → read here; unreadable → treated as leaderless (journal decides)
|
|
227
|
+
* @returns {boolean} true = discard everywhere
|
|
228
|
+
*/
|
|
229
|
+
export function isDiscardableSession(dir, meta) {
|
|
230
|
+
let m = meta;
|
|
231
|
+
if (m === undefined) {
|
|
232
|
+
try { m = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { m = null; }
|
|
233
|
+
}
|
|
234
|
+
if (m && m.leader) return false;
|
|
235
|
+
return !sessionHasMainOrTeammateReq(dir);
|
|
236
|
+
}
|
|
237
|
+
|
|
170
238
|
/**
|
|
171
239
|
* The newest readable, non-teammate session that HAS a main turn, as
|
|
172
240
|
* `{ dir, sessionId }`, or null. "Readable" mirrors listV2Sessions' gates (spec
|
|
@@ -180,17 +248,29 @@ export function sessionHasCompletedMainTurn(dir) {
|
|
|
180
248
|
* has-a-main-req gate would otherwise re-select exactly the dir the caller's
|
|
181
249
|
* completed-turn gate just rejected (it IS the newest once its first main req
|
|
182
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.
|
|
183
260
|
* @param {string} projectDir - absolute LOG_DIR/<project>
|
|
184
|
-
* @param {{excludeDir?: string}} [opts]
|
|
261
|
+
* @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts]
|
|
185
262
|
* @returns {{dir:string, sessionId:string}|null}
|
|
186
263
|
*/
|
|
187
|
-
export function latestMainSession(projectDir, { excludeDir = '' } = {}) {
|
|
264
|
+
export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false } = {}) {
|
|
188
265
|
if (!projectDir) return null;
|
|
189
266
|
const candidates = []; // { dir, sessionId, startTs }
|
|
190
267
|
for (const name of listSessionIds(projectDir)) {
|
|
191
268
|
const dir = join(projectDir, 'sessions', name);
|
|
192
269
|
if (excludeDir && dir === excludeDir) continue;
|
|
193
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;
|
|
194
274
|
let meta = null;
|
|
195
275
|
try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { continue; }
|
|
196
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/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;
|
package/server/routes/events.js
CHANGED
|
@@ -52,6 +52,49 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
|
|
|
52
52
|
});
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// SessionStart hook notify (session-start-bridge.js): the conversation-switch
|
|
56
|
+
// signal for an in-terminal /resume. Same security shape as turnEndNotify
|
|
57
|
+
// (loopback-only + internal token + 16KB cap); the actual gating on
|
|
58
|
+
// payload.source and the V2Writer re-bind live behind deps.onSessionStartNotify
|
|
59
|
+
// (server.js → interceptor.markSessionStart).
|
|
60
|
+
function sessionStartNotify(req, res, parsedUrl, isLocal, deps) {
|
|
61
|
+
if (!isLocal) {
|
|
62
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
63
|
+
res.end(JSON.stringify({ error: 'Loopback only' }));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (req.headers['x-ccviewer-internal'] !== deps.INTERNAL_TOKEN) {
|
|
67
|
+
res.writeHead(403, { 'Content-Type': 'application/json' });
|
|
68
|
+
res.end(JSON.stringify({ error: 'Invalid bridge token' }));
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
let body = '';
|
|
72
|
+
let truncated = false;
|
|
73
|
+
req.on('data', chunk => {
|
|
74
|
+
body += chunk;
|
|
75
|
+
if (body.length > 16384) { truncated = true; req.destroy(); }
|
|
76
|
+
});
|
|
77
|
+
req.on('end', () => {
|
|
78
|
+
if (truncated) {
|
|
79
|
+
console.warn('[session-start-notify] body exceeded 16KB cap — request destroyed');
|
|
80
|
+
return; // socket already closed by destroy()
|
|
81
|
+
}
|
|
82
|
+
let payload = {};
|
|
83
|
+
let badJson = false;
|
|
84
|
+
try { payload = body ? JSON.parse(body) : {}; }
|
|
85
|
+
catch { badJson = true; console.warn('[session-start-notify] malformed JSON body'); }
|
|
86
|
+
if (badJson) {
|
|
87
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
88
|
+
res.end(JSON.stringify({ error: 'malformed JSON body' }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
try { deps.onSessionStartNotify(payload); }
|
|
92
|
+
catch (err) { reportSwallowed('session-start-notify.dispatch', err); }
|
|
93
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
94
|
+
res.end(JSON.stringify({ ok: true }));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
55
98
|
// SSE endpoint
|
|
56
99
|
// 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
|
|
57
100
|
// 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
|
|
@@ -358,6 +401,7 @@ async function requests(req, res) {
|
|
|
358
401
|
|
|
359
402
|
export const eventsRoutes = [
|
|
360
403
|
{ method: 'POST', match: 'exact', path: '/api/turn-end-notify', handler: turnEndNotify },
|
|
404
|
+
{ method: 'POST', match: 'exact', path: '/api/session-start-notify', handler: sessionStartNotify },
|
|
361
405
|
{ method: 'GET', match: 'exact', path: '/events', handler: events },
|
|
362
406
|
{ method: 'GET', match: 'exact', path: '/api/requests', handler: requests },
|
|
363
407
|
];
|