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
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="./assets/index-
|
|
24
|
+
<script type="module" crossorigin src="./assets/index-DidP9FCD.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="./assets/vendor-antd-DI7JL-mE.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-B9c49dtM.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-B6cpBtIE.js">
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cc-viewer",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.3",
|
|
4
4
|
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"main": "server.js",
|
package/server/i18n.js
CHANGED
|
@@ -142,6 +142,26 @@ const i18nData = {
|
|
|
142
142
|
"tr": " PATH'te de \"claude\" native komutu bulunamadı. @anthropic-ai/claude-code'un yüklü olduğundan emin olun",
|
|
143
143
|
"uk": " Також не знайдено нативну команду \"claude\" в PATH. Переконайтеся, що @anthropic-ai/claude-code встановлено"
|
|
144
144
|
},
|
|
145
|
+
"cli.settingsMergeFailed": {
|
|
146
|
+
"zh": "⚠️ 无法加载用户 --settings 值 {value}({reason}),已仅以 CC Viewer 注入的 settings 启动",
|
|
147
|
+
"en": "⚠️ Could not load user --settings value {value} ({reason}); launching with CC Viewer's injected settings only",
|
|
148
|
+
"zh-TW": "⚠️ 無法載入使用者 --settings 值 {value}({reason}),已僅以 CC Viewer 注入的 settings 啟動",
|
|
149
|
+
"ko": "⚠️ 사용자 --settings 값 {value}을(를) 로드할 수 없음({reason}), CC Viewer가 주입한 settings만으로 시작합니다",
|
|
150
|
+
"ja": "⚠️ ユーザーの --settings 値 {value} を読み込めません({reason})。CC Viewer が注入した settings のみで起動します",
|
|
151
|
+
"de": "⚠️ Der --settings-Wert des Benutzers {value} konnte nicht geladen werden ({reason}); Start nur mit den von CC Viewer injizierten Settings",
|
|
152
|
+
"es": "⚠️ No se pudo cargar el valor --settings del usuario {value} ({reason}); se inicia solo con los settings inyectados por CC Viewer",
|
|
153
|
+
"fr": "⚠️ Impossible de charger la valeur --settings de l'utilisateur {value} ({reason}) ; lancement avec uniquement les settings injectés par CC Viewer",
|
|
154
|
+
"it": "⚠️ Impossibile caricare il valore --settings dell'utente {value} ({reason}); avvio solo con i settings iniettati da CC Viewer",
|
|
155
|
+
"da": "⚠️ Kunne ikke indlæse brugerens --settings-værdi {value} ({reason}); starter kun med CC Viewers injicerede settings",
|
|
156
|
+
"pl": "⚠️ Nie można wczytać wartości --settings użytkownika {value} ({reason}); uruchamianie tylko z ustawieniami wstrzykniętymi przez CC Viewer",
|
|
157
|
+
"ru": "⚠️ Не удалось загрузить пользовательское значение --settings {value} ({reason}); запуск только с настройками, внедрёнными CC Viewer",
|
|
158
|
+
"ar": "⚠️ تعذّر تحميل قيمة --settings الخاصة بالمستخدم {value} ({reason})؛ سيتم التشغيل بإعدادات CC Viewer المحقونة فقط",
|
|
159
|
+
"no": "⚠️ Kunne ikke laste brukerens --settings-verdi {value} ({reason}); starter kun med CC Viewers injiserte settings",
|
|
160
|
+
"pt-BR": "⚠️ Não foi possível carregar o valor --settings do usuário {value} ({reason}); iniciando apenas com os settings injetados pelo CC Viewer",
|
|
161
|
+
"th": "⚠️ ไม่สามารถโหลดค่า --settings ของผู้ใช้ {value} ({reason}) จะเริ่มด้วย settings ที่ CC Viewer ฉีดเข้าไปเท่านั้น",
|
|
162
|
+
"tr": "⚠️ Kullanıcının --settings değeri {value} yüklenemedi ({reason}); yalnızca CC Viewer'ın enjekte ettiği settings ile başlatılıyor",
|
|
163
|
+
"uk": "⚠️ Не вдалося завантажити користувацьке значення --settings {value} ({reason}); запуск лише з налаштуваннями, впровадженими CC Viewer"
|
|
164
|
+
},
|
|
145
165
|
"cli.inject.notFound": {
|
|
146
166
|
"zh": "❌ 找不到 Claude Code cli.js: {path}",
|
|
147
167
|
"en": "❌ Claude Code cli.js not found: {path}",
|
package/server/interceptor.js
CHANGED
|
@@ -306,7 +306,10 @@ export function getLiveLogSource() {
|
|
|
306
306
|
// newest dir once its first main req is written) — handing back exactly the
|
|
307
307
|
// blank in-flight session the strict gate rejected. Excluding it makes the
|
|
308
308
|
// fallback actually land on the previous, renderable conversation.
|
|
309
|
-
|
|
309
|
+
// skipForeignLive: a parallel ccv window's in-flight session must never be
|
|
310
|
+
// served as THIS window's cold load (multi-window isolation); a crashed
|
|
311
|
+
// window's claim expires with its pid, so its session stays selectable.
|
|
312
|
+
const fallback = latestMainSessionDir(join(LOG_DIR, sanitizePathComponent(_projectName)), { excludeDir: dir, skipForeignLive: true });
|
|
310
313
|
// A current-but-empty session with nothing else on disk: '' (still empty,
|
|
311
314
|
// but the live feed fills it in place — no worse than before the fix).
|
|
312
315
|
return fallback || dir || '';
|
|
@@ -316,6 +319,47 @@ export function getLiveLogSource() {
|
|
|
316
319
|
}
|
|
317
320
|
}
|
|
318
321
|
|
|
322
|
+
// SessionStart hook notify (session-start-bridge.js → /api/session-start-notify
|
|
323
|
+
// → server.js deps). The only source acted on is 'resume': an in-terminal
|
|
324
|
+
// /resume switches the running claude to a PAST conversation while the wire
|
|
325
|
+
// session_id may stay the same, so without this signal the writer keeps
|
|
326
|
+
// routing the resumed conversation into the OLD session dir (and the panel —
|
|
327
|
+
// keyed on `_seqEpoch = v2:<dir identity>` — never switches). The transcript
|
|
328
|
+
// basename is the resumed conversation's stable identity; the hook session_id
|
|
329
|
+
// is a fresh uuid usable as the new dir identity when nothing was recorded.
|
|
330
|
+
// 'startup'/'clear'/'compact' are deliberately ignored (startup needs nothing;
|
|
331
|
+
// /clear already has epoch machinery; teammate processes inherit
|
|
332
|
+
// CCVIEWER_PORT and fire startup events at this endpoint — the source gate
|
|
333
|
+
// drops them).
|
|
334
|
+
export function markSessionStart(payload) {
|
|
335
|
+
try {
|
|
336
|
+
const { source, sessionId, transcriptPath, cwd } = payload || {};
|
|
337
|
+
if (source !== 'resume') return;
|
|
338
|
+
if (!transcriptPath || typeof transcriptPath !== 'string') {
|
|
339
|
+
console.warn('[ccv session-start] resume signal without transcript_path — ignored');
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
// Soft cross-project guard: a foreign project's claude that somehow
|
|
343
|
+
// carries our CCVIEWER_PORT must not re-bind THIS project's writer. Only
|
|
344
|
+
// enforced when both sides are known (workspace mode may leave
|
|
345
|
+
// _projectName empty until bound).
|
|
346
|
+
if (cwd && typeof cwd === 'string' && _projectName) {
|
|
347
|
+
const cwdProject = basename(cwd).replace(/[^a-zA-Z0-9_\-\.]/g, '_');
|
|
348
|
+
if (cwdProject !== _projectName) {
|
|
349
|
+
console.warn(`[ccv session-start] resume signal from project "${cwdProject}" ignored (bound to "${_projectName}")`);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
const transcriptUuid = basename(transcriptPath, '.jsonl');
|
|
354
|
+
_v2Writer.beginResumeSwitch({
|
|
355
|
+
transcriptUuid,
|
|
356
|
+
hookSid: (typeof sessionId === 'string' && sessionId) ? sessionId : null,
|
|
357
|
+
});
|
|
358
|
+
} catch (err) {
|
|
359
|
+
reportSwallowed('session-start.mark', err);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
319
363
|
// P2 (-c migration guidance): three detection channels, any one marks this
|
|
320
364
|
// launch as "continuing an older conversation" — the migrate prompt then
|
|
321
365
|
// re-prompts even a dismissed user, because the conversation's first half
|
|
@@ -78,7 +78,7 @@ function _looksStaleManagedCommand(cmd) {
|
|
|
78
78
|
|
|
79
79
|
function _purgeStaleManagedHooks(settings) {
|
|
80
80
|
let removed = 0;
|
|
81
|
-
for (const sectionKey of ['PreToolUse', 'Stop']) {
|
|
81
|
+
for (const sectionKey of ['PreToolUse', 'Stop', 'SessionStart']) {
|
|
82
82
|
const arr = settings.hooks?.[sectionKey];
|
|
83
83
|
if (!Array.isArray(arr)) continue;
|
|
84
84
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
@@ -99,7 +99,7 @@ function _purgeStaleManagedHooks(settings) {
|
|
|
99
99
|
// 调用方负责 atomic write-back。返回 removed 数量。
|
|
100
100
|
export function removeAllManagedHooks(settings) {
|
|
101
101
|
let removed = 0;
|
|
102
|
-
for (const sectionKey of ['PreToolUse', 'Stop']) {
|
|
102
|
+
for (const sectionKey of ['PreToolUse', 'Stop', 'SessionStart']) {
|
|
103
103
|
const arr = settings.hooks?.[sectionKey];
|
|
104
104
|
if (!Array.isArray(arr)) continue;
|
|
105
105
|
for (let i = arr.length - 1; i >= 0; i--) {
|
|
@@ -129,6 +129,7 @@ export function ensureHooks() {
|
|
|
129
129
|
if (!settings.hooks) settings.hooks = {};
|
|
130
130
|
if (!Array.isArray(settings.hooks.PreToolUse)) settings.hooks.PreToolUse = [];
|
|
131
131
|
if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = [];
|
|
132
|
+
if (!Array.isArray(settings.hooks.SessionStart)) settings.hooks.SessionStart = [];
|
|
132
133
|
|
|
133
134
|
let changed = false;
|
|
134
135
|
// 先一次性清掉「path 已 stale 但还带 cc-viewer-managed marker」的老条目,
|
|
@@ -214,6 +215,31 @@ export function ensureHooks() {
|
|
|
214
215
|
changed = true;
|
|
215
216
|
}
|
|
216
217
|
|
|
218
|
+
// SessionStart hook → session-start-bridge.js. Fires on startup/resume/
|
|
219
|
+
// clear/compact; no matcher (= all sources) — the bridge forwards `source`
|
|
220
|
+
// and the server gates on it. The `resume` source is the conversation-
|
|
221
|
+
// switch signal the V2Writer needs to re-bind routing after an in-terminal
|
|
222
|
+
// /resume (the wire session_id may not change, so no wire-level signal
|
|
223
|
+
// exists). Same CCVIEWER_PORT guard pattern as the other bridges.
|
|
224
|
+
const sessionStartBridgePath = resolve(SERVER_LIB, 'session-start-bridge.js');
|
|
225
|
+
const sessionStartCmd = `[ -n "$CCVIEWER_PORT" ] && node "${sessionStartBridgePath}" || true ${CCV_HOOK_MARKER}`;
|
|
226
|
+
const sessionStartDesired = _buildHookObj(sessionStartCmd);
|
|
227
|
+
const sessionStartExisting = settings.hooks.SessionStart.find(h => {
|
|
228
|
+
const cmd = h.hooks?.[0]?.command || '';
|
|
229
|
+
return cmd.includes('session-start-bridge.js');
|
|
230
|
+
});
|
|
231
|
+
if (sessionStartExisting) {
|
|
232
|
+
if (!_hookObjEqual(sessionStartExisting.hooks?.[0], sessionStartDesired)) {
|
|
233
|
+
sessionStartExisting.hooks = [_mergeHookObj(sessionStartExisting.hooks?.[0], sessionStartDesired)];
|
|
234
|
+
changed = true;
|
|
235
|
+
}
|
|
236
|
+
} else {
|
|
237
|
+
settings.hooks.SessionStart.push({
|
|
238
|
+
hooks: [sessionStartDesired],
|
|
239
|
+
});
|
|
240
|
+
changed = true;
|
|
241
|
+
}
|
|
242
|
+
|
|
217
243
|
if (changed) {
|
|
218
244
|
mkdirSync(claudeDir, { recursive: true });
|
|
219
245
|
// Atomic write(): write to a sibling temp file then rename. Concurrent
|
|
@@ -99,6 +99,7 @@ export function listV2Logs(logDir, currentProjectName) {
|
|
|
99
99
|
for (const s of listV2Sessions(join(logDir, project))) {
|
|
100
100
|
if (s.leader) continue;
|
|
101
101
|
if (s.size === 0) continue;
|
|
102
|
+
if (s.discard) continue; // quota-probe orphans: never listed (2026-07-16)
|
|
102
103
|
if (!grouped[project]) grouped[project] = [];
|
|
103
104
|
grouped[project].push({
|
|
104
105
|
file: `v2:${project}/${s.sid}`,
|
|
@@ -92,7 +92,12 @@ function _safeSseWrite(clients, client, payload) {
|
|
|
92
92
|
return true;
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
+
// Zero-client short-circuit (all four senders): the payload template is built
|
|
96
|
+
// BEFORE the client loop, so with no SSE consumer connected every broadcast
|
|
97
|
+
// still paid a full JSON.stringify — for reconstructed entries carrying
|
|
98
|
+
// cumulative messages that is a giant allocation for nothing.
|
|
95
99
|
export function sendToClients(clients, entry) {
|
|
100
|
+
if (clients.length === 0) return;
|
|
96
101
|
const payload = `data: ${JSON.stringify(entry)}\n\n`;
|
|
97
102
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
98
103
|
_safeSseWrite(clients, clients[i], payload);
|
|
@@ -100,6 +105,7 @@ export function sendToClients(clients, entry) {
|
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
export function sendEventToClients(clients, eventName, data) {
|
|
108
|
+
if (clients.length === 0) return;
|
|
103
109
|
const payload = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
104
110
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
105
111
|
_safeSseWrite(clients, clients[i], payload);
|
|
@@ -109,6 +115,7 @@ export function sendEventToClients(clients, eventName, data) {
|
|
|
109
115
|
/** Like sendEventToClients but `json` is an ALREADY-serialized JSON string —
|
|
110
116
|
* wire v3 forwards raw stored lines verbatim without a parse/stringify trip. */
|
|
111
117
|
export function sendEventRawToClients(clients, eventName, json) {
|
|
118
|
+
if (clients.length === 0) return;
|
|
112
119
|
const payload = `event: ${eventName}\ndata: ${json}\n\n`;
|
|
113
120
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
114
121
|
_safeSseWrite(clients, clients[i], payload);
|
|
@@ -116,6 +123,7 @@ export function sendEventRawToClients(clients, eventName, json) {
|
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
export function sendChunkToClients(clients, dataJson) {
|
|
126
|
+
if (clients.length === 0) return;
|
|
119
127
|
const payload = `event: load_chunk\ndata: ${dataJson}\n\n`;
|
|
120
128
|
for (let i = clients.length - 1; i >= 0; i--) {
|
|
121
129
|
_safeSseWrite(clients, clients[i], payload);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Process-liveness probe shared by pid-validity claims (session-owner.js).
|
|
2
|
+
// Deliberately dependency-free: im-lock.js's identical helper drags findcc.js
|
|
3
|
+
// (LOG_DIR env resolution, a top-level node:worker_threads threadId import)
|
|
4
|
+
// into every importer's module graph — the stats worker's loader shim has no
|
|
5
|
+
// threadId export, and the v2 writer's synchronous ingest path doesn't want
|
|
6
|
+
// node:http along for the ride. im-lock.js / async-file-lock.js keep their
|
|
7
|
+
// local copies (same semantics); consolidation is backlog.
|
|
8
|
+
|
|
9
|
+
/** Is `pid` alive? Signal 0 probes without delivering; EPERM means the
|
|
10
|
+
* process exists but belongs to another user (still alive). */
|
|
11
|
+
export function isPidAlive(pid) {
|
|
12
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
13
|
+
try { process.kill(pid, 0); return true; }
|
|
14
|
+
catch (e) { return e && e.code === 'EPERM'; }
|
|
15
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* session-start-bridge.js — SessionStart hook bridge for conversation-switch
|
|
4
|
+
* signals (in-terminal /resume, and future /clear / compact interest).
|
|
5
|
+
*
|
|
6
|
+
* Called by Claude Code whenever a session (re)starts: source is one of
|
|
7
|
+
* "startup" | "resume" | "clear" | "compact". The one that matters today is
|
|
8
|
+
* `resume` — an in-terminal /resume switches the running process to a PAST
|
|
9
|
+
* conversation while the wire session_id may stay the same, so without this
|
|
10
|
+
* signal cc-viewer keeps writing the resumed conversation into the OLD
|
|
11
|
+
* session dir and the [对话] panel never switches. The bridge forwards the
|
|
12
|
+
* hook payload to cc-viewer's /api/session-start-notify; the server gates on
|
|
13
|
+
* source and tells the V2Writer to re-bind routing (see v2-writer.js
|
|
14
|
+
* beginResumeSwitch).
|
|
15
|
+
*
|
|
16
|
+
* Hook config in ~/.claude/settings.json (injected by ensure-hooks.js, tagged
|
|
17
|
+
* `# cc-viewer-managed`):
|
|
18
|
+
* "hooks": {
|
|
19
|
+
* "SessionStart": [{ "hooks": [{ "type": "command",
|
|
20
|
+
* "command": "[ -n \"$CCVIEWER_PORT\" ] && node /path/to/session-start-bridge.js || true # cc-viewer-managed" }] }]
|
|
21
|
+
* }
|
|
22
|
+
*
|
|
23
|
+
* Output contract (same as turn-end-bridge.js): NOTHING on stdout — Claude
|
|
24
|
+
* Code interprets SessionStart hook stdout as context-injection JSON
|
|
25
|
+
* (hookSpecificOutput.additionalContext), and any stray bytes would pollute
|
|
26
|
+
* the conversation. Optional stderr only when CCVIEWER_DEBUG=1. Always exit 0
|
|
27
|
+
* so a failed notify never blocks Claude Code's hook chain.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { readFileSync } from 'node:fs';
|
|
31
|
+
import http from 'node:http';
|
|
32
|
+
import https from 'node:https';
|
|
33
|
+
|
|
34
|
+
const debug = (msg) => {
|
|
35
|
+
if (process.env.CCVIEWER_DEBUG === '1') {
|
|
36
|
+
try { process.stderr.write(`[session-start-bridge] ${msg}\n`); } catch { /* ignore */ }
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const port = process.env.CCVIEWER_PORT;
|
|
41
|
+
const rawProtocol = process.env.CCVIEWER_PROTOCOL;
|
|
42
|
+
const isHttps = rawProtocol === 'https';
|
|
43
|
+
const httpClient = isHttps ? https : http;
|
|
44
|
+
|
|
45
|
+
// cc-viewer not running — exit silently (stdout must stay clean, see header).
|
|
46
|
+
if (!port) {
|
|
47
|
+
debug('CCVIEWER_PORT unset — exit silently');
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Drain stdin best-effort. Claude Code passes the hook JSON payload
|
|
52
|
+
// ({session_id, transcript_path, source, cwd, ...}); capped to 64 KB to
|
|
53
|
+
// defang any malformed huge payload.
|
|
54
|
+
let stdinData = '';
|
|
55
|
+
try {
|
|
56
|
+
const buf = readFileSync(0);
|
|
57
|
+
stdinData = (buf.length > 64 * 1024 ? buf.slice(0, 64 * 1024) : buf).toString('utf-8');
|
|
58
|
+
} catch { /* stdin may not be piped — fine, still notify */ }
|
|
59
|
+
let sessionId = null;
|
|
60
|
+
let transcriptPath = null;
|
|
61
|
+
let source = null;
|
|
62
|
+
let cwd = null;
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(stdinData);
|
|
65
|
+
sessionId = parsed?.session_id || null;
|
|
66
|
+
transcriptPath = parsed?.transcript_path || null;
|
|
67
|
+
source = parsed?.source || null;
|
|
68
|
+
cwd = parsed?.cwd || null;
|
|
69
|
+
} catch { /* fine — the server tolerates missing fields */ }
|
|
70
|
+
debug(`payload source=${source} session_id=${sessionId} transcript=${transcriptPath}`);
|
|
71
|
+
|
|
72
|
+
const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
|
|
73
|
+
const body = JSON.stringify({ source, sessionId, transcriptPath, cwd, ts: Date.now() });
|
|
74
|
+
const reqOpts = {
|
|
75
|
+
hostname: '127.0.0.1',
|
|
76
|
+
port: parseInt(port, 10),
|
|
77
|
+
path: '/api/session-start-notify',
|
|
78
|
+
method: 'POST',
|
|
79
|
+
headers: {
|
|
80
|
+
'Content-Type': 'application/json',
|
|
81
|
+
'Content-Length': Buffer.byteLength(body),
|
|
82
|
+
// Same anti-spoof header as turn-end-bridge: matched against the server's
|
|
83
|
+
// per-startup INTERNAL_TOKEN, env-leaked only to the claude child.
|
|
84
|
+
...(internalToken ? { 'X-CCViewer-Internal': internalToken } : {}),
|
|
85
|
+
},
|
|
86
|
+
// Keep the timeout snappy so a stale cc-viewer never blocks the Claude Code
|
|
87
|
+
// hook chain for a noticeable beat.
|
|
88
|
+
timeout: 500,
|
|
89
|
+
};
|
|
90
|
+
if (isHttps) {
|
|
91
|
+
// Loopback HTTPS is typically self-signed; validation would reject.
|
|
92
|
+
reqOpts.rejectUnauthorized = false;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
let exited = false;
|
|
96
|
+
const finish = (reason) => {
|
|
97
|
+
if (exited) return;
|
|
98
|
+
exited = true;
|
|
99
|
+
if (reason) debug(reason);
|
|
100
|
+
process.exit(0);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let req;
|
|
104
|
+
try {
|
|
105
|
+
req = httpClient.request(reqOpts, (res) => {
|
|
106
|
+
res.resume();
|
|
107
|
+
res.on('end', () => finish(`POST done (status=${res.statusCode})`));
|
|
108
|
+
});
|
|
109
|
+
req.on('error', (err) => finish(`POST error: ${err?.message}`));
|
|
110
|
+
req.on('timeout', () => { try { req.destroy(); } catch { /* ignore */ } finish('POST timeout'); });
|
|
111
|
+
// Wrap the synchronous write/end so an immediate EPIPE never bubbles into
|
|
112
|
+
// Claude Code's transcript (same defensive shape as turn-end-bridge).
|
|
113
|
+
try {
|
|
114
|
+
req.write(body);
|
|
115
|
+
req.end();
|
|
116
|
+
} catch (err) {
|
|
117
|
+
finish(`req.write threw: ${err?.message}`);
|
|
118
|
+
}
|
|
119
|
+
} catch (err) {
|
|
120
|
+
finish(`request() threw: ${err?.message}`);
|
|
121
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
// Settings files are tiny in practice; this caps a hostile `--settings /dev/zero`
|
|
5
|
+
// (a never-EOF read that would hang the server event loop) or a huge-file read.
|
|
6
|
+
const MAX_SETTINGS_FILE_BYTES = 1024 * 1024;
|
|
7
|
+
|
|
8
|
+
// Merges a user-supplied `--settings` launch arg into cc-viewer's injected settings
|
|
9
|
+
// object, so the final claude argv carries a SINGLE `--settings` flag.
|
|
10
|
+
//
|
|
11
|
+
// Why: claude's argv parser is last-wins for duplicate `--settings` (empirically
|
|
12
|
+
// verified on 2.1.212 — not documented). cc-viewer prepends its injected flag before
|
|
13
|
+
// user args, so a user-supplied `--settings` used to silently clobber the injected
|
|
14
|
+
// env.ANTHROPIC_BASE_URL proxy override (breaking capture) and the CCV_IM_DENY
|
|
15
|
+
// permissions.deny hardening. Merging keeps both: injected keys win, everything
|
|
16
|
+
// else the user set rides along.
|
|
17
|
+
//
|
|
18
|
+
// Parse rules pinned by experiments against claude 2.1.212:
|
|
19
|
+
// - Tokens after a literal `--` are prompt text, never flags → scan stops there.
|
|
20
|
+
// - `--settings <next>` consumes the next token as its value unconditionally, even
|
|
21
|
+
// an option-like one (`--settings --print` → claude reads "--print" as a settings
|
|
22
|
+
// path and hard-errors). The helper mirrors that consumption.
|
|
23
|
+
// - A trailing valueless `--settings` makes claude exit with "argument missing" →
|
|
24
|
+
// left in place so claude surfaces its own usage error, exactly as pre-fix.
|
|
25
|
+
// - An empty `--settings=` does NOT error but still clobbers earlier `--settings`
|
|
26
|
+
// flags via last-wins (the most dangerous form) → always stripped.
|
|
27
|
+
//
|
|
28
|
+
// Known accepted limitation (shared with withDefaultThinkingDisplay / hasArg): a
|
|
29
|
+
// literal "--settings" appearing before `--` as ANOTHER value-flag's argument
|
|
30
|
+
// (e.g. `--append-system-prompt "--settings"`) is indistinguishable without a full
|
|
31
|
+
// claude flag table. Probability negligible; bounded by the stop-at-`--` rule.
|
|
32
|
+
|
|
33
|
+
const FLAG = '--settings';
|
|
34
|
+
|
|
35
|
+
function stripBom(s) {
|
|
36
|
+
return s.charCodeAt(0) === 0xFEFF ? s.slice(1) : s;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isPlainObject(v) {
|
|
40
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Remove C0 control chars (and DEL) so a value/reason echoed into the embedded xterm
|
|
44
|
+
// (via emitSpawnNotice) or a log line cannot inject ANSI escape sequences — the raw
|
|
45
|
+
// user value and a JSON.parse SyntaxError can both carry ESC bytes.
|
|
46
|
+
function stripControls(s) {
|
|
47
|
+
return String(s).replace(/[\u0000-\u001F\u007F]/g, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Read + parse a user-pointed settings file DEFENSIVELY. The failure reason is echoed
|
|
51
|
+
// into a client-readable surface (the persistent terminal buffer + logs), so it must
|
|
52
|
+
// never disclose file contents or resolved paths — a raw readFileSync/JSON.parse error
|
|
53
|
+
// embeds the leading file bytes and the absolute path, turning the warning into a
|
|
54
|
+
// file-probing oracle. All failures collapse to generic, content-free messages; a
|
|
55
|
+
// statSync isFile()+size gate blocks /dev/zero-style hangs and oversized reads.
|
|
56
|
+
function readSettingsFile(path) {
|
|
57
|
+
let st;
|
|
58
|
+
try { st = statSync(path); } catch { throw new Error('settings file not found'); }
|
|
59
|
+
if (!st.isFile()) throw new Error('settings path is not a regular file');
|
|
60
|
+
if (st.size > MAX_SETTINGS_FILE_BYTES) throw new Error('settings file too large');
|
|
61
|
+
let raw;
|
|
62
|
+
try { raw = stripBom(readFileSync(path, 'utf8')); } catch { throw new Error('settings file could not be read'); }
|
|
63
|
+
let parsed;
|
|
64
|
+
try { parsed = JSON.parse(raw); } catch { throw new Error('settings file is not valid JSON'); }
|
|
65
|
+
if (!isPlainObject(parsed)) throw new Error('settings file must be a JSON object');
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Load a --settings value the way claude does: inline JSON when it looks like an
|
|
70
|
+
// object literal, otherwise a settings file path (resolved against the cwd claude
|
|
71
|
+
// itself will run with). Throws on any failure — caller turns that into a warning.
|
|
72
|
+
function loadSettingsValue(value, cwd) {
|
|
73
|
+
const trimmed = stripBom(value).trim();
|
|
74
|
+
if (trimmed === '') throw new Error('empty value');
|
|
75
|
+
if (trimmed.startsWith('{')) {
|
|
76
|
+
// Inline JSON is the user's own text; echoing it back on failure is not disclosure
|
|
77
|
+
// (control chars are stripped at the warning surface). File contents are different —
|
|
78
|
+
// handled by readSettingsFile, which never surfaces them.
|
|
79
|
+
const parsed = JSON.parse(trimmed);
|
|
80
|
+
if (!isPlainObject(parsed)) throw new Error('settings must be a JSON object');
|
|
81
|
+
return parsed;
|
|
82
|
+
}
|
|
83
|
+
return readSettingsFile(resolve(cwd, trimmed));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Shallow top-level merge with two special cases; injected keys win. Deep-merging
|
|
87
|
+
// other keys (hooks, statusLine, ...) would splice into structures the user owns.
|
|
88
|
+
function mergeSettings(userSettings, injectedSettings) {
|
|
89
|
+
const merged = { ...userSettings };
|
|
90
|
+
for (const [key, injectedValue] of Object.entries(injectedSettings)) {
|
|
91
|
+
if (key === 'env' && isPlainObject(merged.env)) {
|
|
92
|
+
merged.env = { ...merged.env, ...injectedValue };
|
|
93
|
+
} else if (key === 'permissions' && isPlainObject(merged.permissions)) {
|
|
94
|
+
const userDeny = Array.isArray(merged.permissions.deny) ? merged.permissions.deny : [];
|
|
95
|
+
const injectedDeny = Array.isArray(injectedValue.deny) ? injectedValue.deny : [];
|
|
96
|
+
merged.permissions = {
|
|
97
|
+
...merged.permissions,
|
|
98
|
+
...injectedValue,
|
|
99
|
+
deny: [...new Set([...userDeny, ...injectedDeny])],
|
|
100
|
+
};
|
|
101
|
+
} else {
|
|
102
|
+
merged[key] = injectedValue;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return merged;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Extract any user `--settings` occurrences from launch args and merge the last
|
|
110
|
+
* one into the injected settings object (injected keys win; permissions.deny is
|
|
111
|
+
* unioned). Never throws — a value that cannot be loaded is dropped and reported
|
|
112
|
+
* via `warningDetail` so the caller can surface it (injection must never block spawn).
|
|
113
|
+
*
|
|
114
|
+
* @param {unknown} args launch args (user-controlled; may contain non-strings)
|
|
115
|
+
* @param {object} injectedSettings cc-viewer's settings object (env.ANTHROPIC_BASE_URL, optional permissions.deny)
|
|
116
|
+
* @param {{cwd?: string}} [opts] base dir for relative settings file paths — must match the cwd claude runs with
|
|
117
|
+
* @returns {{args: unknown[], settingsJson: string, merged: boolean, warningDetail: {value: string, reason: string}|null}}
|
|
118
|
+
* args: input args with consumed `--settings` occurrences removed;
|
|
119
|
+
* settingsJson: JSON for the single injected flag (byte-identical to
|
|
120
|
+
* JSON.stringify(injectedSettings) when nothing merged);
|
|
121
|
+
* merged: whether a user value was folded in; warningDetail: load-failure parts
|
|
122
|
+
* ({value, reason}, control-chars stripped) for the caller to render/localize —
|
|
123
|
+
* the English sentence lives only in the `cli.settingsMergeFailed` i18n key, so
|
|
124
|
+
* there is no duplicated copy here to drift.
|
|
125
|
+
*/
|
|
126
|
+
export function mergeSettingsIntoArgs(args, injectedSettings, { cwd = process.cwd() } = {}) {
|
|
127
|
+
const passthrough = { merged: false, warningDetail: null, settingsJson: JSON.stringify(injectedSettings) };
|
|
128
|
+
if (!Array.isArray(args)) return { args: [], ...passthrough };
|
|
129
|
+
|
|
130
|
+
const cleaned = [];
|
|
131
|
+
let lastValue = null; // last consumed --settings value (claude is last-wins)
|
|
132
|
+
for (let i = 0; i < args.length; i++) {
|
|
133
|
+
const token = args[i];
|
|
134
|
+
if (typeof token !== 'string') { cleaned.push(token); continue; }
|
|
135
|
+
if (token === '--') { cleaned.push(...args.slice(i)); break; }
|
|
136
|
+
if (token.startsWith(FLAG + '=')) {
|
|
137
|
+
lastValue = token.slice(FLAG.length + 1); // may be '' — still stripped (empty form clobbers via last-wins)
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (token === FLAG) {
|
|
141
|
+
if (i + 1 >= args.length) { cleaned.push(token); continue; } // trailing valueless: claude errors "argument missing" itself
|
|
142
|
+
lastValue = args[i + 1];
|
|
143
|
+
i++;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
cleaned.push(token);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (lastValue === null) return { args: cleaned, ...passthrough };
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
if (typeof lastValue !== 'string') throw new Error('value is not a string');
|
|
153
|
+
const userSettings = loadSettingsValue(lastValue, cwd);
|
|
154
|
+
return {
|
|
155
|
+
args: cleaned,
|
|
156
|
+
settingsJson: JSON.stringify(mergeSettings(userSettings, injectedSettings)),
|
|
157
|
+
merged: true,
|
|
158
|
+
warningDetail: null,
|
|
159
|
+
};
|
|
160
|
+
} catch (err) {
|
|
161
|
+
const shown = typeof lastValue === 'string' ? lastValue : String(lastValue);
|
|
162
|
+
// Strip control chars: `shown` is raw user input and `err.message` from an inline
|
|
163
|
+
// JSON.parse can echo user bytes — both flow into the terminal buffer / logs.
|
|
164
|
+
return {
|
|
165
|
+
args: cleaned,
|
|
166
|
+
...passthrough,
|
|
167
|
+
warningDetail: { value: stripControls(shown), reason: stripControls(err?.message || err) },
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
}
|
|
@@ -9,6 +9,8 @@ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from '
|
|
|
9
9
|
import { join, basename } from 'node:path';
|
|
10
10
|
import { readJsonlTolerant, listSessionIds } from './v2/replay.js';
|
|
11
11
|
import { dirSizeSync } from './v2/layout.js';
|
|
12
|
+
import { isDiscardableSession } from './v2/session-select.js';
|
|
13
|
+
import { reportSwallowed } from './error-report.js';
|
|
12
14
|
import {
|
|
13
15
|
INTER_SESSION_TYPES, isSystemText, extractUserTexts, isSuggestionMode,
|
|
14
16
|
collectPromptsFromEvents, sortEpochFiles,
|
|
@@ -23,7 +25,11 @@ export { INTER_SESSION_TYPES, isSystemText, extractUserTexts };
|
|
|
23
25
|
// v9: v2 session-dir units (files map keyed `sessions/<sid>`), journal-based counts
|
|
24
26
|
// v10: per-session `size` = recursive session-dir bytes (was journal-only);
|
|
25
27
|
// `journalSize` carries the incremental-cache key
|
|
26
|
-
|
|
28
|
+
// v11: discardable sessions (quota-probe orphans) excluded — the bump
|
|
29
|
+
// invalidates pre-discard caches so their probe units can't be reused
|
|
30
|
+
// back into filesStats, which lets the discard check sit AFTER the
|
|
31
|
+
// cache-reuse branch (cache hits skip the journal head scan entirely)
|
|
32
|
+
const STATS_VERSION = 11;
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
35
|
* Parse one v2 session directory into the same stats shape parseJsonlFile
|
|
@@ -199,7 +205,20 @@ function generateProjectStats(projectDir, projectName, onlyFile) {
|
|
|
199
205
|
}
|
|
200
206
|
|
|
201
207
|
const sessionDir = join(projectDir, 'sessions', sid);
|
|
202
|
-
|
|
208
|
+
// Discardable sessions (quota-probe orphans — no main/teammate req, no
|
|
209
|
+
// leader) never count toward stats. Runs only on cache misses: v11+
|
|
210
|
+
// caches are written exclusively by post-discard code, so a cache hit can
|
|
211
|
+
// never resurrect a probe unit (the v10→v11 bump invalidated older ones).
|
|
212
|
+
if (isDiscardableSession(sessionDir)) continue;
|
|
213
|
+
let parsed;
|
|
214
|
+
try {
|
|
215
|
+
parsed = parseSessionDir(sessionDir);
|
|
216
|
+
} catch (err) {
|
|
217
|
+
// One unreadable session must not kill the whole worker (issue #129) —
|
|
218
|
+
// skip its stats and keep counting the healthy ones.
|
|
219
|
+
reportSwallowed('stats-worker.session-parse-failed', new Error(`${sid}: ${err.message}`));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
203
222
|
filesStats[unitKey] = {
|
|
204
223
|
models: parsed.models,
|
|
205
224
|
summary: parsed.summary,
|