cc-viewer 1.7.2 → 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-B224r-8M.js → App-DRsuJXEw.js} +1 -1
- package/dist/assets/{MdxEditorPanel-BUubiE6Y.js → MdxEditorPanel-D_dewIIc.js} +1 -1
- package/dist/assets/{Mobile-CKmfRRvo.js → Mobile-BIzb7vpA.js} +1 -1
- package/dist/assets/{index-CjsQEQc2.js → index-DidP9FCD.js} +2 -2
- package/dist/assets/{seqResourceLoaders-C-T_EMiO.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-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/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/pty-manager.js +28 -4
- package/server/routes/events.js +44 -0
- package/server/server.js +4 -1
|
@@ -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
|
];
|
package/server/server.js
CHANGED
|
@@ -76,7 +76,7 @@ function execWithStdin(cmd, args, input, options) {
|
|
|
76
76
|
child.stdin.end();
|
|
77
77
|
});
|
|
78
78
|
}
|
|
79
|
-
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText } from './interceptor.js';
|
|
79
|
+
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
|
|
80
80
|
import { V2LiveFeed } from './lib/v2/live-feed.js';
|
|
81
81
|
import { sanitizePathComponent } from './lib/v2/layout.js';
|
|
82
82
|
import { maybeResumeConvert } from './lib/v2/convert-manager.js';
|
|
@@ -554,6 +554,9 @@ const deps = {
|
|
|
554
554
|
startLogWatch,
|
|
555
555
|
stopLogWatch,
|
|
556
556
|
scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
|
|
557
|
+
// SessionStart hook notify → conversation-switch signal (in-terminal
|
|
558
|
+
// /resume). The source gate + V2Writer re-bind live in the interceptor.
|
|
559
|
+
onSessionStartNotify: markSessionStart,
|
|
557
560
|
ensureImWatch: _ensureImWatch,
|
|
558
561
|
maskProfiles: _maskProfiles,
|
|
559
562
|
maskApiKey: _maskApiKey,
|