cc-viewer 1.7.14 → 1.7.17

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/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-CPLS-pSI.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-8EGcVOKG.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-DADYo_zg.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-tF6HNoR6.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-CFAmRN3Y.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.7.14",
3
+ "version": "1.7.17",
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",
@@ -244,6 +244,9 @@ export function reconstructEntries(entries) {
244
244
  */
245
245
  function _tryRepairFromCandidate(brokenEntry, expectedCount, candidate) {
246
246
  if (!candidate.mainAgent || candidate.teammate || !Array.isArray(candidate.body?.messages)) return false;
247
+ // V2 transcript synthetic entries are never a valid repair source for legacy
248
+ // delta rows — their messages are a different conversation's content.
249
+ if (candidate._syntheticV2) return false;
247
250
  const candidateMsgs = candidate.body.messages;
248
251
  const candidateTotal = candidate._totalMessageCount || candidateMsgs.length;
249
252
  const isFullEntry = !candidate._deltaFormat || isCheckpointEntry(candidate);
@@ -34,6 +34,7 @@ import { isMainAgentRequest } from '../interceptor-core.js';
34
34
  import { readSession } from './replay.js';
35
35
  import { iterateJsonlLines } from './jsonl-read.js';
36
36
  import { isDiscardableSession } from './session-select.js';
37
+ import { LIVE_SESSION_MTIME_MS } from '../log-management.js';
37
38
  import { blobPath, isSupportedWireFormat } from './layout.js';
38
39
  import { SingleFlight } from './singleflight.js';
39
40
  import { listV2Sessions } from './session-list.js';
@@ -183,6 +184,29 @@ export class SessionSynthesizer {
183
184
  this._leader = opts.teammateOf || this._meta.leader || null;
184
185
  this._loadBlob = makeBlobLoader({ blobsDir: join(sessionDir, 'blobs') }, this.sessionId);
185
186
  this.unsupported = false;
187
+ // Liveness gate — KEEP IN SYNC with log-management.js deleteLogFiles
188
+ // (same pid(0)-probe + EPERM + LIVE_SESSION_MTIME_MS mtime triple; the
189
+ // constant is imported here so only the ordering can drift). Cold reads
190
+ // finalize crash orphans as completed deltas ONLY when the session is dead.
191
+ // A live writer may still emit an orphan's done later — its completed twin
192
+ // would then be replayed by the client's seq guard as stale (_staleReorder)
193
+ // and the tail of the chat would vanish (cold-read + live-feed race).
194
+ this._sessionLive = false;
195
+ try {
196
+ const meta = this._meta;
197
+ if (meta && typeof meta.pid === 'number' && meta.pid !== process.pid) {
198
+ try { process.kill(meta.pid, 0); this._sessionLive = true; } catch (err) {
199
+ // EPERM = the pid exists under another user — that IS live.
200
+ if (err && err.code === 'EPERM') this._sessionLive = true;
201
+ }
202
+ }
203
+ } catch { /* unreadable meta — fall through to the mtime guard */ }
204
+ if (!this._sessionLive) {
205
+ try {
206
+ const jStat = statSync(join(sessionDir, 'journal.jsonl'));
207
+ if (this._now() - jStat.mtimeMs < LIVE_SESSION_MTIME_MS) this._sessionLive = true;
208
+ } catch { /* no journal — nothing fresh to protect */ }
209
+ }
186
210
  if (meta && meta.wireFormat != null && !isSupportedWireFormat(meta.wireFormat)) {
187
211
  this._markUnsupported(meta.wireFormat);
188
212
  }
@@ -445,7 +469,11 @@ export class SessionSynthesizer {
445
469
  isHeartbeat: HEARTBEAT_URL_RE.test(req.url || ''),
446
470
  isCountTokens: COUNT_TOKENS_URL_RE.test(req.url || ''),
447
471
  mainAgent: false, // finalized below once messages are attached
448
- ...(isTeammateEntry && { teammate: leader.agentName || true, ...(leader.teamName && { teamName: leader.teamName }) }),
472
+ ...(isTeammateEntry && { teammate: leader.agentName || req.agent?.agentName || true, ...(leader.teamName && { teamName: leader.teamName }) }),
473
+ // Wire agent identity persisted by the writer (see agent-id.js) — the
474
+ // client uses req.agent.agentName as the display name when the heuristic
475
+ // registry has nothing (native teammate, cold load).
476
+ ...(req.agent && { agent: req.agent }),
449
477
  ...(req.proxy && { proxyProfile: req.proxy.profile, ...(req.proxy.url && { proxyUrl: req.proxy.url }) }),
450
478
  };
451
479
 
@@ -508,9 +536,23 @@ export class SessionSynthesizer {
508
536
 
509
537
  if (this._dones.has(seq)) {
510
538
  this._tryComplete(seq);
539
+ } else if (this._deferMs === 0 && !this._sessionLive) {
540
+ // Cold read of a DEAD session: the orphan is definitively terminal. Emit
541
+ // it as a completed delta (no inProgress / requestId — the exact shape
542
+ // _complete produces) so the client batch reconstructor accumulates its
543
+ // message slice: inProgress rows are skipped by the reconstructor, which
544
+ // would otherwise drop the slice and fail every later completed entry's
545
+ // _totalMessageCount integrity check — blanking the whole chat.
546
+ entry.response = { body: null }; // mirrors _complete's no-response branch
547
+ // Push the SAME item reference (phase aside): _complete's dedup splice
548
+ // (this._out.indexOf(item)) depends on the queued object identity — a
549
+ // spread copy would dodge it and the twin completed frame would double.
550
+ item.phase = 'completed';
551
+ this._out.push(item);
511
552
  } else {
512
553
  // req without done — in-flight or the process died (spec §4). This IS the
513
- // v1 placeholder, so it wears the same flags.
554
+ // v1 placeholder, so it wears the same flags. Live reads keep it so the
555
+ // incremental reconstructor can render the in-flight turn as it streams.
514
556
  entry.inProgress = true;
515
557
  entry.requestId = req.rid || `${seq}`;
516
558
  this._out.push(item);
@@ -0,0 +1,55 @@
1
+ // Agent identity parser — teammate/subagent names from the wire header.
2
+ //
3
+ // Claude Code 2.x sends an `x-claude-code-agent-id` request header on every
4
+ // sub-agent (teammate or subagent) request. Two shapes appear in real data:
5
+ // - named teammate: `frontend-reviewer@session-17e1f37a-...`
6
+ // - anonymous subagent: `a7eea0a140349f80d` (16-hex agent id, no @)
7
+ // The header is absent on main-agent requests, so its presence alone marks a
8
+ // sub-agent stream. We persist the NAME so display no longer depends on the
9
+ // frontend's window-scoped heuristic registry (resolveTeammateNames), which
10
+ // loses names when the lead's Agent tool_use falls outside the current window.
11
+
12
+ const ANON_RE = /^[0-9a-f]{8,64}$/i; // pure hex id, any plausible length
13
+ const MAX_NAME_LEN = 64;
14
+
15
+ /**
16
+ * Parse an `x-claude-code-agent-id` header value.
17
+ *
18
+ * @param {string|null|undefined} header
19
+ * @returns {{agentName: string|null, named: boolean}|null}
20
+ * - named teammate: {agentName, named: true}
21
+ * - anonymous agent: {agentName: null, named: false}
22
+ * - null when the header is missing/empty or its shape is not an agent id
23
+ * (never fabricate a name from an unrecognized value).
24
+ */
25
+ export function parseAgentId(header) {
26
+ if (typeof header !== 'string') return null;
27
+ const h = header.trim();
28
+ if (h.length === 0) return null;
29
+ const at = h.indexOf('@');
30
+ if (at === -1) {
31
+ // Anonymous: a bare hex agent id. Anything else is not an agent id — do
32
+ // not guess.
33
+ return ANON_RE.test(h) ? { agentName: null, named: false } : null;
34
+ }
35
+ const name = h.slice(0, at).trim();
36
+ // The @-suffix is deliberately NOT validated (real shapes include
37
+ // `session-<uuid>` and possibly plain uuids) — presence of @ + a non-hex
38
+ // prefix is enough to treat it as a named agent. A hex prefix is treated as
39
+ // anonymous (guards against `<uuid>@session-...` being misread as a name).
40
+ if (name.length === 0) return null;
41
+ if (ANON_RE.test(name)) return { agentName: null, named: false };
42
+ return { agentName: name.slice(0, MAX_NAME_LEN), named: true };
43
+ }
44
+
45
+ /** Case-insensitive header lookup (undici folds to lowercase; plain objects
46
+ * may keep the original casing). */
47
+ export function findHeader(headers, key) {
48
+ if (!headers || typeof headers !== 'object') return undefined;
49
+ if (Object.prototype.hasOwnProperty.call(headers, key)) return headers[key];
50
+ const lower = key.toLowerCase();
51
+ for (const k of Object.keys(headers)) {
52
+ if (k.toLowerCase() === lower) return headers[k];
53
+ }
54
+ return undefined;
55
+ }
@@ -32,7 +32,7 @@ import { isDiscardableSession } from './session-select.js';
32
32
  import { isForeignLiveOwned } from './session-owner.js';
33
33
  import { isConvertRunning } from './convert-manager.js';
34
34
  import { computeCacheLoss } from './meta-rows.js';
35
- import { classifyRequest } from '../../../src/utils/requestType.js';
35
+ import { classifyRequest, withAgentNameSubType } from '../../../src/utils/requestType.js';
36
36
 
37
37
  const FSWATCH_DEBOUNCE_MS = 80;
38
38
  const SAFETY_POLL_MS = 5000;
@@ -679,6 +679,9 @@ export class V2LiveFeed {
679
679
  kind: item.kind || (item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub')),
680
680
  mainAgent: item.isMain === true,
681
681
  teammate: parsed.teammate || undefined,
682
+ // Wire agent identity (x-claude-code-agent-id) — display name for native
683
+ // teammates (see agent-id.js).
684
+ agent: parsed.agent,
682
685
  model: parsed.body?.model,
683
686
  proxyUrl: parsed.proxyUrl || undefined,
684
687
  status: parsed.response?.status,
@@ -698,8 +701,7 @@ export class V2LiveFeed {
698
701
  _emitRow(cur, item, parsed) {
699
702
  const row = this._rowFrom(item, parsed);
700
703
  try {
701
- const tag = classifyRequest(parsed, null);
702
- row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
704
+ row.typeTag = withAgentNameSubType(classifyRequest(parsed, null), parsed);
703
705
  } catch (err) { reportSwallowed('v2-live.row-classify', err); }
704
706
  if (row.mainAgent && !row.inProgress) {
705
707
  const u = parsed.response?.body?.usage;
@@ -714,8 +716,7 @@ export class V2LiveFeed {
714
716
  const prev = cur._v3Last;
715
717
  if (prev && (prev.row.sessionId !== row.sessionId || prev.row.seq !== row.seq)) {
716
718
  try {
717
- const tag = classifyRequest(prev.entry, parsed);
718
- const corrected = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
719
+ const corrected = withAgentNameSubType(classifyRequest(prev.entry, parsed), prev.entry);
719
720
  if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
720
721
  prev.row.typeTag = corrected;
721
722
  sendEventToClients(this._clients, 'v2_requests_delta', prev.row);
@@ -22,7 +22,7 @@ import { iterateJsonlLines } from './jsonl-read.js';
22
22
  import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
23
23
  import { SingleFlight } from './singleflight.js';
24
24
  import { reportSwallowed } from '../error-report.js';
25
- import { classifyRequest } from '../../../src/utils/requestType.js';
25
+ import { classifyRequest, withAgentNameSubType } from '../../../src/utils/requestType.js';
26
26
 
27
27
  // KEEP IN SYNC: server/lib/v2/adapter.js itemKey — (sessionId, seq) identity.
28
28
  const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
@@ -113,6 +113,9 @@ function foldDir(dir) {
113
113
  mainAgent: r.kind === 'main' && !isTeammateDir,
114
114
  // v1 entry contract (adapter.js:427): teammate = agentName string | true
115
115
  teammate: isTeammateDir ? ((meta.leader && meta.leader.agentName) || true) : undefined,
116
+ // Wire agent identity (x-claude-code-agent-id) persisted on the journal
117
+ // req line — the display name for native teammates (see agent-id.js).
118
+ agent: r.agent,
116
119
  model: r.model,
117
120
  proxyUrl: r.proxy && r.proxy.url ? r.proxy.url : undefined,
118
121
  status: d && typeof d.http === 'number' ? d.http : undefined,
@@ -318,8 +321,7 @@ async function attachBodyFields(sessionDir, rows) {
318
321
  const finish = (slot, nextEntry) => {
319
322
  const { row, entry } = slot;
320
323
  try {
321
- const tag = classifyRequest(entry, nextEntry);
322
- row.typeTag = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
324
+ row.typeTag = withAgentNameSubType(classifyRequest(entry, nextEntry), entry);
323
325
  } catch (err) {
324
326
  // Diagnostic-worthy (a throw silently mis-tags the row; the live path
325
327
  // reports the same failure) — CLAUDE.md swallow rule.
@@ -27,6 +27,7 @@ import { BlobStore } from './blob-store.js';
27
27
  import { Journal } from './journal.js';
28
28
  import { ConversationStore } from './conversation-store.js';
29
29
  import { parseUserId, classifyKind, ConvResolver } from './identity.js';
30
+ import { parseAgentId, findHeader } from './agent-id.js';
30
31
  import { extractUserTexts, flattenPromptText, isSuggestionMode, readPromptsHead } from '../user-prompt-extract.js';
31
32
 
32
33
  // Below this many free bytes on the log volume, v2 skips writing and reports
@@ -505,6 +506,10 @@ export class V2Writer {
505
506
  }
506
507
 
507
508
  // 3. Journal req line — LAST, so it never references missing content.
509
+ // Persist the wire agent identity (x-claude-code-agent-id) so teammate
510
+ // names survive cold reads — display no longer depends on the frontend's
511
+ // window-scoped name registry (see agent-id.js).
512
+ const agent = parseAgentId(findHeader(entry.headers, 'x-claude-code-agent-id'));
508
513
  s.journal.writeReq({
509
514
  seq,
510
515
  rid,
@@ -517,6 +522,7 @@ export class V2Writer {
517
522
  ...(entry.body && entry.body.model && { model: entry.body.model }),
518
523
  ...(entry.isStream && { isStream: true }),
519
524
  ...(entry.headers && { headers: entry.headers }),
525
+ ...(agent && { agent }),
520
526
  ...(params && { params }),
521
527
  ...((toolsRef || sysRef) && { blobs: { ...(toolsRef && { tools: toolsRef }), ...(sysRef && { sys: sysRef }) } }),
522
528
  ...(convResult && { msgFrom: convResult.msgFrom, msgTo: convResult.msgTo }),