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/assets/{App-D11UR-a4.js → App-BzhZff4n.js} +2 -2
- package/dist/assets/{MdxEditorPanel-Dd__S_61.js → MdxEditorPanel-DDD8Of6g.js} +1 -1
- package/dist/assets/{Mobile-DozTru1m.js → Mobile-S2A7u-mh.js} +1 -1
- package/dist/assets/{ProxyStatsModal-BKZHRUOy.js → ProxyStatsModal-C8l4YL-L.js} +1 -1
- package/dist/assets/index-8EGcVOKG.js +2 -0
- package/dist/assets/seqResourceLoaders-C7Vn-EYS.js +2 -0
- package/dist/assets/{seqResourceLoaders-pBUn3c_a.css → seqResourceLoaders-bfmeQ_vN.css} +2 -2
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/delta-reconstructor.js +3 -0
- package/server/lib/v2/adapter.js +44 -2
- package/server/lib/v2/agent-id.js +55 -0
- package/server/lib/v2/live-feed.js +6 -5
- package/server/lib/v2/meta-rows.js +5 -3
- package/server/lib/v2/v2-writer.js +6 -0
- package/server/lib/v2-transcript-normalizer.js +519 -0
- package/src/utils/contentFilter.js +17 -3
- package/src/utils/identityHeal.js +9 -0
- package/src/utils/requestType.js +30 -0
- package/src/utils/sessionManager.js +15 -4
- package/src/utils/sessionMerge.js +62 -3
- package/src/utils/toolResultBuilder.js +19 -1
- package/src/utils/toolResultCore.js +27 -0
- package/src/utils/v3Assembler.js +12 -0
- package/dist/assets/index-CPLS-pSI.js +0 -2
- package/dist/assets/seqResourceLoaders-BV8bzDPn.js +0 -2
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
// CLIENT-SAFE: no node deps. Imported by src/ — do not add fs/process/node: imports.
|
|
2
|
+
/**
|
|
3
|
+
* V2 Transcript Normalizer — Claude Code 2.x JSONL → legacy-compatible entry
|
|
4
|
+
*
|
|
5
|
+
* Claude Code 2.x writes session transcripts in a new line format: each JSONL
|
|
6
|
+
* line carries `{ type, message: { role, content }, parentUuid, sessionId,
|
|
7
|
+
* timestamp, ... }` and has NO `body` / `mainAgent` / `_deltaFormat` / `url`.
|
|
8
|
+
* The legacy cc-viewer pipeline (isMainAgent + delta-reconstructor +
|
|
9
|
+
* applyBatchEntryTimestamps + mergeMainAgentSessions) only understands
|
|
10
|
+
* `{ mainAgent: true, body: { messages } }` requests, so v2 lines currently
|
|
11
|
+
* never reach the Chat view — they surface in the raw request list as
|
|
12
|
+
* text-only rows.
|
|
13
|
+
*
|
|
14
|
+
* Key insight: v2 `message.content` blocks are byte-identical to the API wire
|
|
15
|
+
* format (tool_use / tool_result / text / thinking / image). Reassembling them
|
|
16
|
+
* into a legacy-shaped entry therefore unlocks the entire existing render
|
|
17
|
+
* chain (toolResultMap pairing → extractToolResultImages → ToolResultView)
|
|
18
|
+
* with zero changes there.
|
|
19
|
+
*
|
|
20
|
+
* Design decisions (validated against real 2.x transcripts):
|
|
21
|
+
* - One synthetic entry per (sessionId, /clear segment). Segments preserve the
|
|
22
|
+
* v1 /clear session-boundary semantics; entry.timestamp = messages[0]._timestamp
|
|
23
|
+
* so the `entry.timestamp === messages[0]._timestamp` invariant held by
|
|
24
|
+
* sessionManager (stable session id / pin) is satisfied.
|
|
25
|
+
* - Synthetic entries are APPENDED to the end of the entries array (never
|
|
26
|
+
* interleaved with legacy rows) so a synthetic baseline cannot poison
|
|
27
|
+
* subsequent legacy delta reconstruction.
|
|
28
|
+
* - Assistant rows sharing a `message.id` (a single assistant message split
|
|
29
|
+
* across thinking/text/tool_use lines) are merged into one message, mirroring
|
|
30
|
+
* the legacy message shape.
|
|
31
|
+
* - Line-level dedup key is `uuid` — message.id is shared across a message's
|
|
32
|
+
* split rows, promptId is shared across a turn, and timestamps collide
|
|
33
|
+
* (8 same-ms groups observed in real data).
|
|
34
|
+
* - The redundant top-level `toolUseResult` (a second, binary encoding of the
|
|
35
|
+
* same image) is ignored — message.content already carries the standard
|
|
36
|
+
* base64 data; honoring both would double memory.
|
|
37
|
+
* - Metadata rows (mode / ai-title / last-prompt / file-history-snapshot /
|
|
38
|
+
* queue-operation) carry no message and are dropped — isRelevantRequest
|
|
39
|
+
* would otherwise surface them as garbage request rows.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const V2_LINE_TYPES = new Set(['user', 'assistant']);
|
|
43
|
+
const V2_LINE_ROLES = new Set(['user', 'assistant']);
|
|
44
|
+
|
|
45
|
+
// /clear marker in a string user content — a segment boundary. Matches ONLY
|
|
46
|
+
// the explicit command tag (v1 /clear rows carry `<command-name>/clear</command-name>`);
|
|
47
|
+
// a bare "clear" user message must never split the session. /compact is
|
|
48
|
+
// deliberately NOT a boundary: legacy semantics treat it as a same-session
|
|
49
|
+
// continuation (session-boundary.js), so v2 rows must match that.
|
|
50
|
+
const CLEAR_CMD_RE = /<command-name>\/?(?:clear)<\/command-name>/i;
|
|
51
|
+
|
|
52
|
+
const DEDUP_MAX = 1024;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* True when `entry` is a v2 transcript line worth rendering: a user/assistant
|
|
56
|
+
* message row on the main agent stream (isSidechain marks teammate rows, which
|
|
57
|
+
* must never enter the main-agent session — same rationale as the
|
|
58
|
+
* delta-reconstructor teammate exclusion).
|
|
59
|
+
*/
|
|
60
|
+
export function isV2TranscriptLine(entry) {
|
|
61
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
62
|
+
if (!V2_LINE_TYPES.has(entry.type)) return false;
|
|
63
|
+
if (entry.isSidechain === true) return false;
|
|
64
|
+
const msg = entry.message;
|
|
65
|
+
if (!msg || typeof msg !== 'object') return false;
|
|
66
|
+
return V2_LINE_ROLES.has(msg.role);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* True for a legacy (or already-normalized) renderable request row.
|
|
71
|
+
* Anything that is neither a v2 line nor a legacy request is a metadata row
|
|
72
|
+
* (mode / ai-title / ...) and gets dropped by normalizeV2Entries.
|
|
73
|
+
*/
|
|
74
|
+
function isLegacyRequestLine(entry) {
|
|
75
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
76
|
+
if (entry.mainAgent === true) return true;
|
|
77
|
+
if (entry.body && Array.isArray(entry.body.messages)) return true;
|
|
78
|
+
return typeof entry.url === 'string' && entry.url.length > 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* True for a metadata row (mode / ai-title / last-prompt / file-history-snapshot
|
|
83
|
+
* / queue-operation / system / attachment frames): neither a v2 transcript
|
|
84
|
+
* line nor a legacy request. isRelevantRequest would otherwise surface these
|
|
85
|
+
* as garbage rows, so the live SSE path skips them.
|
|
86
|
+
*/
|
|
87
|
+
export function isMetadataRow(entry) {
|
|
88
|
+
if (!entry || typeof entry !== 'object') return false;
|
|
89
|
+
return !isV2TranscriptLine(entry) && !isLegacyRequestLine(entry);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseTs(ts) {
|
|
93
|
+
if (typeof ts !== 'string') return 0;
|
|
94
|
+
const n = Date.parse(ts);
|
|
95
|
+
return Number.isFinite(n) ? n : 0;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isClearRow(entry) {
|
|
99
|
+
const c = entry?.message?.content;
|
|
100
|
+
return typeof c === 'string' && CLEAR_CMD_RE.test(c);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Sort v2 lines by timestamp; equal timestamps keep input (file) order
|
|
104
|
+
* (Array.prototype.sort is stable per ES2019). */
|
|
105
|
+
function sortLines(lines) {
|
|
106
|
+
return [...lines].sort((a, b) => parseTs(a.timestamp) - parseTs(b.timestamp));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Merge the lines of one /clear segment into an ordered message array.
|
|
111
|
+
* Assistant rows sharing a message.id are folded into a single message with
|
|
112
|
+
* concatenated content (thinking → text → tool_use order by timestamp).
|
|
113
|
+
*
|
|
114
|
+
* @returns {Array} messages, each pre-stamped with _timestamp / _generatedTs
|
|
115
|
+
* (assistant) / _entryTs (filled below in buildSyntheticEntry).
|
|
116
|
+
*/
|
|
117
|
+
function buildSegmentMessages(lines) {
|
|
118
|
+
const messages = [];
|
|
119
|
+
const assistantById = new Map(); // message.id → message object
|
|
120
|
+
for (const line of lines) {
|
|
121
|
+
const msg = line.message;
|
|
122
|
+
if (msg.role === 'user') {
|
|
123
|
+
messages.push({
|
|
124
|
+
role: 'user',
|
|
125
|
+
content: msg.content,
|
|
126
|
+
_timestamp: line.timestamp,
|
|
127
|
+
_generatedTs: undefined,
|
|
128
|
+
});
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
// assistant
|
|
132
|
+
const mid = msg.id;
|
|
133
|
+
if (mid && assistantById.has(mid)) {
|
|
134
|
+
const existing = assistantById.get(mid);
|
|
135
|
+
if (Array.isArray(msg.content) && Array.isArray(existing.content)) {
|
|
136
|
+
existing.content = existing.content.concat(msg.content);
|
|
137
|
+
} else if (Array.isArray(msg.content) && typeof existing.content === 'string') {
|
|
138
|
+
// legacy string content: replace with the array form
|
|
139
|
+
existing.content = msg.content;
|
|
140
|
+
} else if (typeof msg.content === 'string' && typeof existing.content === 'string') {
|
|
141
|
+
existing.content = existing.content + '\n' + msg.content;
|
|
142
|
+
}
|
|
143
|
+
if (line.timestamp) existing._generatedTs = line.timestamp;
|
|
144
|
+
if (msg.model) existing.model = msg.model;
|
|
145
|
+
if (msg.usage) existing.usage = msg.usage;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const m = {
|
|
149
|
+
role: 'assistant',
|
|
150
|
+
content: msg.content,
|
|
151
|
+
_timestamp: line.timestamp,
|
|
152
|
+
_generatedTs: line.timestamp,
|
|
153
|
+
};
|
|
154
|
+
if (msg.model) m.model = msg.model;
|
|
155
|
+
if (msg.usage) m.usage = msg.usage;
|
|
156
|
+
if (mid) m._mid = mid; // merge-key for the incremental normalizer
|
|
157
|
+
messages.push(m);
|
|
158
|
+
if (mid) assistantById.set(mid, m);
|
|
159
|
+
}
|
|
160
|
+
return messages;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Build one synthetic legacy entry from the sorted v2 lines of a single
|
|
165
|
+
* sessionId segment. `messages` are pre-stamped with per-line _timestamp /
|
|
166
|
+
* _generatedTs; _entryTs is filled here to satisfy the entry.timestamp ===
|
|
167
|
+
* messages[0]._timestamp invariant.
|
|
168
|
+
*/
|
|
169
|
+
export function buildSyntheticEntry(lines, sessionId, segIdx) {
|
|
170
|
+
const messages = buildSegmentMessages(lines);
|
|
171
|
+
if (messages.length === 0) return null;
|
|
172
|
+
const entryTs = messages[0]._timestamp;
|
|
173
|
+
for (const m of messages) m._entryTs = entryTs;
|
|
174
|
+
|
|
175
|
+
// Last assistant row carries model/usage — expose it as a synthetic
|
|
176
|
+
// response so model display / token stats / KV-cache branches work.
|
|
177
|
+
let lastModel = null;
|
|
178
|
+
let lastUsage = null;
|
|
179
|
+
for (const m of messages) {
|
|
180
|
+
if (m.role === 'assistant') {
|
|
181
|
+
if (m.model) lastModel = m.model;
|
|
182
|
+
if (m.usage) lastUsage = m.usage;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const entry = {
|
|
187
|
+
mainAgent: true,
|
|
188
|
+
body: { messages },
|
|
189
|
+
timestamp: entryTs,
|
|
190
|
+
url: `claude-code://session/${sessionId}:${segIdx}`,
|
|
191
|
+
sessionId,
|
|
192
|
+
_seqEpoch: `v2:${sessionId}:${segIdx}`,
|
|
193
|
+
_syntheticV2: true,
|
|
194
|
+
_messageCount: messages.length,
|
|
195
|
+
};
|
|
196
|
+
if (lastModel || lastUsage) {
|
|
197
|
+
entry.response = { body: {} };
|
|
198
|
+
if (lastModel) entry.response.body.model = lastModel;
|
|
199
|
+
if (lastUsage) entry.response.body.usage = lastUsage;
|
|
200
|
+
}
|
|
201
|
+
return entry;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Split sorted v2 lines of one sessionId into /clear-delimited segments.
|
|
206
|
+
* A /clear row starts a new segment (it carries the command itself), so the
|
|
207
|
+
* caveat/descendant rows land in the new segment — mirroring v1's
|
|
208
|
+
* "new session after /clear" behavior.
|
|
209
|
+
*/
|
|
210
|
+
function splitSegments(sortedLines) {
|
|
211
|
+
const segments = [];
|
|
212
|
+
let current = [];
|
|
213
|
+
for (const line of sortedLines) {
|
|
214
|
+
if (isClearRow(line)) {
|
|
215
|
+
// /clear closes the current segment and starts a new one; the command
|
|
216
|
+
// row itself is not conversation content, so it is dropped.
|
|
217
|
+
if (current.length > 0) segments.push(current);
|
|
218
|
+
current = [];
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
current.push(line);
|
|
222
|
+
}
|
|
223
|
+
if (current.length > 0) segments.push(current);
|
|
224
|
+
return segments;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Batch normalizer: convert v2 transcript lines in `rawEntries` into
|
|
229
|
+
* legacy-shaped synthetic entries.
|
|
230
|
+
*
|
|
231
|
+
* - No v2 lines → returns the SAME array reference (zero behavior change).
|
|
232
|
+
* - Metadata rows (neither v2 nor legacy) are dropped.
|
|
233
|
+
* - Legacy rows keep their relative order; synthetic entries are appended at
|
|
234
|
+
* the end (per sessionId, ts-sorted, /clear-segmented) so a synthetic
|
|
235
|
+
* baseline can never precede legacy delta rows.
|
|
236
|
+
*/
|
|
237
|
+
export function normalizeV2Entries(rawEntries) {
|
|
238
|
+
if (!Array.isArray(rawEntries) || rawEntries.length === 0) return rawEntries;
|
|
239
|
+
|
|
240
|
+
let v2Count = 0;
|
|
241
|
+
const kept = [];
|
|
242
|
+
for (const e of rawEntries) {
|
|
243
|
+
if (isV2TranscriptLine(e)) {
|
|
244
|
+
v2Count++;
|
|
245
|
+
} else if (isLegacyRequestLine(e)) {
|
|
246
|
+
kept.push(e);
|
|
247
|
+
}
|
|
248
|
+
// metadata rows: dropped
|
|
249
|
+
}
|
|
250
|
+
if (v2Count === 0) return rawEntries;
|
|
251
|
+
|
|
252
|
+
const bySession = new Map(); // sessionId → v2 lines
|
|
253
|
+
for (const e of rawEntries) {
|
|
254
|
+
if (!isV2TranscriptLine(e)) continue;
|
|
255
|
+
const sid = e.sessionId || 'unknown';
|
|
256
|
+
let arr = bySession.get(sid);
|
|
257
|
+
if (!arr) bySession.set(sid, (arr = []));
|
|
258
|
+
arr.push(e);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const synthetics = [];
|
|
262
|
+
for (const [sid, lines] of bySession) {
|
|
263
|
+
const sorted = sortLines(lines);
|
|
264
|
+
const segments = splitSegments(sorted);
|
|
265
|
+
for (let i = 0; i < segments.length; i++) {
|
|
266
|
+
const entry = buildSyntheticEntry(segments[i], sid, i);
|
|
267
|
+
if (entry) synthetics.push(entry);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return [...kept, ...synthetics];
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Incremental normalizer for live SSE: rebuilds the accumulated message array
|
|
275
|
+
* line by line and returns the current full synthetic entry per line.
|
|
276
|
+
*
|
|
277
|
+
* Ordering: live trusts append order (single-writer append-only file; replay
|
|
278
|
+
* is deduped by uuid). Timestamp reordering only matters for historical files,
|
|
279
|
+
* which the batch path handles.
|
|
280
|
+
*
|
|
281
|
+
* `prime(syntheticEntry)` seeds accumulated from a cold-loaded synthetic entry
|
|
282
|
+
* so the first live flush extends the existing session instead of truncating
|
|
283
|
+
* it (merge's REBUILD branch would replace a 282-message session with a
|
|
284
|
+
* 1-message snapshot on an empty baseline).
|
|
285
|
+
*/
|
|
286
|
+
export function createV2IncrementalReconstructor() {
|
|
287
|
+
const state = {
|
|
288
|
+
sessionId: null,
|
|
289
|
+
entryTs: null,
|
|
290
|
+
epoch: null, // _seqEpoch carried by snapshots (must match the
|
|
291
|
+
// primed cold entry's segment epoch, or the merge
|
|
292
|
+
// boundary check would split the session)
|
|
293
|
+
accumulated: null, // array of messages (cold snapshot or live-built)
|
|
294
|
+
shared: false, // accumulated entries came from prime (copy-on-write)
|
|
295
|
+
assistantById: new Map(),
|
|
296
|
+
// tool_use blocks merged into an already-scanned message by a later split
|
|
297
|
+
// row (thinking/text/tool_use share a message.id). ChatView's incremental
|
|
298
|
+
// toolResultMap scans by message index only, so these would never pair —
|
|
299
|
+
// they are surfaced here for a follow-up scan.
|
|
300
|
+
mergedToolUses: [],
|
|
301
|
+
seenUuids: new Set(),
|
|
302
|
+
uuidOrder: [],
|
|
303
|
+
lastAssistant: null,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Merge one live assistant line into accumulated.
|
|
308
|
+
* A row sharing a cold message's _mid appends to that message — with
|
|
309
|
+
* copy-on-write: primed messages are shallow clones shared with the rendered
|
|
310
|
+
* cold entry, so the target is re-cloned (message + content array) before
|
|
311
|
+
* mutating, keeping the rendered snapshot pristine.
|
|
312
|
+
*/
|
|
313
|
+
function pushAssistant(msg) {
|
|
314
|
+
const mid = msg._mid;
|
|
315
|
+
if (mid && state.assistantById.has(mid)) {
|
|
316
|
+
let existing = state.assistantById.get(mid);
|
|
317
|
+
if (state.shared && state.accumulated.includes(existing)) {
|
|
318
|
+
const clone = {
|
|
319
|
+
...existing,
|
|
320
|
+
content: Array.isArray(existing.content) ? [...existing.content] : existing.content,
|
|
321
|
+
};
|
|
322
|
+
const idx = state.accumulated.indexOf(existing);
|
|
323
|
+
state.accumulated[idx] = clone;
|
|
324
|
+
state.assistantById.set(mid, clone);
|
|
325
|
+
if (state.lastAssistant === existing) state.lastAssistant = clone;
|
|
326
|
+
existing = clone;
|
|
327
|
+
}
|
|
328
|
+
if (Array.isArray(msg.content) && Array.isArray(existing.content)) {
|
|
329
|
+
existing.content = existing.content.concat(msg.content);
|
|
330
|
+
}
|
|
331
|
+
if (msg._generatedTs) existing._generatedTs = msg._generatedTs;
|
|
332
|
+
if (msg.model) existing.model = msg.model;
|
|
333
|
+
if (msg.usage) existing.usage = msg.usage;
|
|
334
|
+
// Surface newly merged tool_use blocks on the message itself AND the
|
|
335
|
+
// entry-level _toolUses — the message may have already been scanned by
|
|
336
|
+
// the incremental toolResultMap, so the blocks must be re-registered.
|
|
337
|
+
const merged = [];
|
|
338
|
+
if (Array.isArray(msg.content)) {
|
|
339
|
+
for (const b of msg.content) {
|
|
340
|
+
if (b && b.type === 'tool_use' && b.id) {
|
|
341
|
+
merged.push(b);
|
|
342
|
+
state.mergedToolUses.push(b);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
if (merged.length > 0) {
|
|
347
|
+
existing._toolUses = [...(existing._toolUses || []), ...merged];
|
|
348
|
+
}
|
|
349
|
+
state.lastAssistant = existing;
|
|
350
|
+
return existing;
|
|
351
|
+
}
|
|
352
|
+
const m = {
|
|
353
|
+
role: 'assistant',
|
|
354
|
+
content: msg.content,
|
|
355
|
+
_timestamp: msg._timestamp,
|
|
356
|
+
_generatedTs: msg._generatedTs,
|
|
357
|
+
};
|
|
358
|
+
if (msg.model) m.model = msg.model;
|
|
359
|
+
if (msg.usage) m.usage = msg.usage;
|
|
360
|
+
if (mid) m._mid = mid;
|
|
361
|
+
state.accumulated.push(m);
|
|
362
|
+
state.assistantById.set(mid, m);
|
|
363
|
+
state.lastAssistant = m;
|
|
364
|
+
return m;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function snapshotEntry() {
|
|
368
|
+
const messages = [...state.accumulated];
|
|
369
|
+
// entry.timestamp must equal messages[0]._timestamp (the invariant
|
|
370
|
+
// sessionManager's stable session id / pin depends on). After a live
|
|
371
|
+
// /clear the segment starts fresh, so fall back to the first message's ts
|
|
372
|
+
// instead of the stale clear-row ts (same as the batch path).
|
|
373
|
+
const entryTs = messages.length > 0 ? (messages[0]._timestamp || state.entryTs) : state.entryTs;
|
|
374
|
+
const epoch = state.epoch || `v2:${state.sessionId}:0`;
|
|
375
|
+
const entry = {
|
|
376
|
+
mainAgent: true,
|
|
377
|
+
body: { messages },
|
|
378
|
+
timestamp: entryTs,
|
|
379
|
+
url: `claude-code://session/${state.sessionId}:${epoch.slice(epoch.lastIndexOf(':') + 1)}`,
|
|
380
|
+
sessionId: state.sessionId,
|
|
381
|
+
_seqEpoch: epoch,
|
|
382
|
+
_syntheticV2: true,
|
|
383
|
+
_messageCount: messages.length,
|
|
384
|
+
};
|
|
385
|
+
if (state.lastAssistant) {
|
|
386
|
+
const body = {};
|
|
387
|
+
if (state.lastAssistant.model) body.model = state.lastAssistant.model;
|
|
388
|
+
if (state.lastAssistant.usage) body.usage = state.lastAssistant.usage;
|
|
389
|
+
if (body.model || body.usage) entry.response = { body };
|
|
390
|
+
}
|
|
391
|
+
// Surface merged tool_use blocks for the incremental toolResultMap scan
|
|
392
|
+
// (drained per snapshot — each entry carries only the not-yet-paired ones).
|
|
393
|
+
if (state.mergedToolUses.length > 0) {
|
|
394
|
+
entry._toolUses = state.mergedToolUses.splice(0);
|
|
395
|
+
}
|
|
396
|
+
return entry;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return {
|
|
400
|
+
/** @returns {boolean} true when no baseline exists yet. */
|
|
401
|
+
empty() {
|
|
402
|
+
return state.accumulated === null;
|
|
403
|
+
},
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* Seed accumulated from a cold-loaded synthetic entry (same sessionId).
|
|
407
|
+
* Messages are shallow-copied so later live appends never mutate the
|
|
408
|
+
* rendered cold snapshot.
|
|
409
|
+
*/
|
|
410
|
+
prime(syntheticEntry) {
|
|
411
|
+
if (!syntheticEntry || syntheticEntry._syntheticV2 !== true) return;
|
|
412
|
+
if (state.accumulated !== null) return;
|
|
413
|
+
const messages = (syntheticEntry.body && syntheticEntry.body.messages) || [];
|
|
414
|
+
if (messages.length === 0) return;
|
|
415
|
+
state.sessionId = syntheticEntry.sessionId || 'unknown';
|
|
416
|
+
state.entryTs = syntheticEntry.timestamp;
|
|
417
|
+
state.epoch = syntheticEntry._seqEpoch || null;
|
|
418
|
+
state.accumulated = messages.map((m) => ({
|
|
419
|
+
...m,
|
|
420
|
+
content: Array.isArray(m.content) ? [...m.content] : m.content,
|
|
421
|
+
}));
|
|
422
|
+
state.shared = true;
|
|
423
|
+
state.assistantById.clear();
|
|
424
|
+
for (const m of state.accumulated) {
|
|
425
|
+
if (m.role === 'assistant') {
|
|
426
|
+
if (m._mid) state.assistantById.set(m._mid, m);
|
|
427
|
+
state.lastAssistant = m;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Process one v2 line, returning the current full synthetic entry, or null
|
|
434
|
+
* for a skip (sidechain row, replay of a seen uuid).
|
|
435
|
+
* Non-v2 lines are passed through unchanged (defensive; callers branch on
|
|
436
|
+
* isV2TranscriptLine first).
|
|
437
|
+
*/
|
|
438
|
+
reconstruct(line) {
|
|
439
|
+
if (!isV2TranscriptLine(line)) return null;
|
|
440
|
+
// Session switch (workspace switch / another project's file on the same
|
|
441
|
+
// SSE): the old session is already rendered — drop its baseline and
|
|
442
|
+
// start a fresh one so rows never cross sessions.
|
|
443
|
+
if (state.sessionId !== null && line.sessionId && line.sessionId !== state.sessionId) {
|
|
444
|
+
state.sessionId = line.sessionId;
|
|
445
|
+
state.entryTs = line.timestamp;
|
|
446
|
+
state.epoch = null;
|
|
447
|
+
state.accumulated = [];
|
|
448
|
+
state.shared = false;
|
|
449
|
+
state.assistantById.clear();
|
|
450
|
+
state.lastAssistant = null;
|
|
451
|
+
state.mergedToolUses = [];
|
|
452
|
+
}
|
|
453
|
+
const uuid = line.uuid;
|
|
454
|
+
if (uuid && state.seenUuids.has(uuid)) return null;
|
|
455
|
+
if (uuid) {
|
|
456
|
+
state.seenUuids.add(uuid);
|
|
457
|
+
state.uuidOrder.push(uuid);
|
|
458
|
+
if (state.uuidOrder.length > DEDUP_MAX) {
|
|
459
|
+
state.seenUuids.delete(state.uuidOrder.shift());
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (state.accumulated === null) {
|
|
463
|
+
// No cold baseline (tail-load / file-mid start): begin fresh.
|
|
464
|
+
state.sessionId = line.sessionId || 'unknown';
|
|
465
|
+
state.entryTs = line.timestamp;
|
|
466
|
+
state.epoch = null;
|
|
467
|
+
state.accumulated = [];
|
|
468
|
+
state.shared = false;
|
|
469
|
+
}
|
|
470
|
+
const msg = line.message;
|
|
471
|
+
if (msg.role === 'user' && isClearRow(line)) {
|
|
472
|
+
// Live /clear: start a new segment (new epoch) so the batch and live
|
|
473
|
+
// paths split sessions identically. The command row itself is dropped.
|
|
474
|
+
// seenUuids is intentionally kept — a replay of a pre-clear row must
|
|
475
|
+
// still dedup; a replay of the clear row itself returns null above.
|
|
476
|
+
// Returns null (skip): an empty snapshot would sink into requests as a
|
|
477
|
+
// ghost MainAgent entry / empty session (regression P2).
|
|
478
|
+
state.epoch = `v2:${state.sessionId}:${(state.epoch?.split(':').pop() ?? 0) + 1}`;
|
|
479
|
+
state.entryTs = null; // first message of the new segment owns the ts
|
|
480
|
+
state.accumulated = [];
|
|
481
|
+
state.shared = false;
|
|
482
|
+
state.assistantById.clear();
|
|
483
|
+
state.lastAssistant = null;
|
|
484
|
+
state.mergedToolUses = [];
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
if (msg.role === 'user') {
|
|
488
|
+
const m = {
|
|
489
|
+
role: 'user',
|
|
490
|
+
content: msg.content,
|
|
491
|
+
_timestamp: line.timestamp,
|
|
492
|
+
};
|
|
493
|
+
state.accumulated.push(m);
|
|
494
|
+
} else {
|
|
495
|
+
pushAssistant({
|
|
496
|
+
_mid: msg.id,
|
|
497
|
+
content: msg.content,
|
|
498
|
+
_timestamp: line.timestamp,
|
|
499
|
+
_generatedTs: line.timestamp,
|
|
500
|
+
model: msg.model,
|
|
501
|
+
usage: msg.usage,
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
return snapshotEntry();
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
reset() {
|
|
508
|
+
state.sessionId = null;
|
|
509
|
+
state.entryTs = null;
|
|
510
|
+
state.epoch = null;
|
|
511
|
+
state.accumulated = null;
|
|
512
|
+
state.shared = false;
|
|
513
|
+
state.assistantById.clear();
|
|
514
|
+
state.seenUuids.clear();
|
|
515
|
+
state.uuidOrder = [];
|
|
516
|
+
state.lastAssistant = null;
|
|
517
|
+
},
|
|
518
|
+
};
|
|
519
|
+
}
|
|
@@ -127,19 +127,33 @@ export function isTeammate(req) {
|
|
|
127
127
|
if (!req) return false;
|
|
128
128
|
const cached = _isTeammateCache.get(req);
|
|
129
129
|
if (cached !== undefined) return cached;
|
|
130
|
+
// 【最高优先级】wire agent 身份信号(x-claude-code-agent-id / cc_is_subagent,
|
|
131
|
+
// 见 agent-id.js)。自 Claude Code 2.1.199 起普通 subagent 也被授予 SendMessage
|
|
132
|
+
// 工具,isNativeTeammate 的 SendMessage 判据不再可靠——这两个信号是硬判据:
|
|
133
|
+
// - 持久化 agent 命名形态(name@session-…)→ 一定是 Teammate;
|
|
134
|
+
// - 持久化 agent 匿名 hex 形态 → 一定是 SubAgent(否决下方 SendMessage 判据);
|
|
135
|
+
// - cc_is_subagent=true(CLI 自报 billing 标记,2.1.181+)→ 一定是 SubAgent。
|
|
136
|
+
const agent = req.agent;
|
|
137
|
+
if (agent) {
|
|
138
|
+
if (agent.named) { _isTeammateCache.set(req, true); return true; }
|
|
139
|
+
_isTeammateCache.set(req, false); return false;
|
|
140
|
+
}
|
|
141
|
+
const sysText = getSystemText(req.body || {});
|
|
142
|
+
if (SUBAGENT_BILLING_RE.test(sysText)) { _isTeammateCache.set(req, false); return false; }
|
|
130
143
|
// interceptor 模式:通过 process.argv 写入的 teammate 字段
|
|
131
144
|
if (req.teammate) { _isTeammateCache.set(req, true); return true; }
|
|
132
145
|
// native teammate:同进程内 Agent 子代理(system prompt 包含 "You are a Claude agent")
|
|
133
146
|
if (isNativeTeammate(req)) {
|
|
134
|
-
// 注入 teammate 字段供下游 requestType.js 的 formatTeammateLabel
|
|
147
|
+
// 注入 teammate 字段供下游 requestType.js 的 formatTeammateLabel 使用。
|
|
148
|
+
// 持久化的 wire agent 名(x-claude-code-agent-id,见 agent-id.js)优先——
|
|
149
|
+
// 它不依赖窗口内的注册表,冷加载/时序缺失时名字仍可靠。
|
|
135
150
|
if (!req.teammate) {
|
|
136
|
-
req.teammate = extractNativeTeammateName(req) || null;
|
|
151
|
+
req.teammate = req.agent?.agentName || extractNativeTeammateName(req) || null;
|
|
137
152
|
}
|
|
138
153
|
_isTeammateCache.set(req, true);
|
|
139
154
|
return true;
|
|
140
155
|
}
|
|
141
156
|
// proxy 模式:通过 system prompt 检测(外部进程 teammate)
|
|
142
|
-
const sysText = getSystemText(req.body || {});
|
|
143
157
|
const result = TEAMMATE_SYSTEM_RE.test(sysText);
|
|
144
158
|
_isTeammateCache.set(req, result);
|
|
145
159
|
return result;
|
|
@@ -76,6 +76,15 @@ export function refreshResolvedModelInfo(items, resolveModelInfo) {
|
|
|
76
76
|
export function healUnresolvedTeammateEntries(entries) {
|
|
77
77
|
for (const entry of entries) {
|
|
78
78
|
if (!entry || !entry.unresolved || !entry.req || !entry.req.teammate) continue;
|
|
79
|
+
// Wire-v3 entries carry the SERVER-side typeTag (via _v3Row). When the
|
|
80
|
+
// tag has a real Teammate name (extractTeammateName / persisted agent
|
|
81
|
+
// name), it may differ from req.teammate (the heuristic registry) —
|
|
82
|
+
// overwriting would DOWNGRADE a good server label ("Teammate:
|
|
83
|
+
// frontend-reviewer" → "Teammate: X"), so skip those. When the tag is
|
|
84
|
+
// Teammate WITHOUT a name (old journals, no persisted agent), let the heal
|
|
85
|
+
// upgrade from the registry's real name.
|
|
86
|
+
const tag = entry.req._v3Row && entry.req._v3Row.typeTag;
|
|
87
|
+
if (tag && tag.type === 'Teammate' && tag.subType) continue;
|
|
79
88
|
const label = formatTeammateLabel(entry.req.teammate, entry.req.body?.model);
|
|
80
89
|
if (label !== entry.label) entry.label = label;
|
|
81
90
|
entry.unresolved = false;
|
package/src/utils/requestType.js
CHANGED
|
@@ -148,6 +148,17 @@ function isPreflightRequest(req, nextReq) {
|
|
|
148
148
|
* @param {object} [nextReq] - 下一条请求(用于 Preflight 判断)
|
|
149
149
|
*/
|
|
150
150
|
export function classifyRequest(req, nextReq) {
|
|
151
|
+
// Wire-v3 assembled entry: the SERVER already classified this row on the
|
|
152
|
+
// full body (system/tools intact) — trust it verbatim. The assembled body
|
|
153
|
+
// strips system/tools, so a client re-derivation (isTeammate / isMainAgent /
|
|
154
|
+
// isPreflightRequest) would mis-tag every teammate as SubAgent. No
|
|
155
|
+
// Plan/Preflight exclusion: those need nextReq lookahead, which the server
|
|
156
|
+
// already applied (cold Pass B / live correction); the client cannot
|
|
157
|
+
// re-derive them from the stripped body either.
|
|
158
|
+
if (req && req._v3Row && req._v3Row.typeTag) {
|
|
159
|
+
return req._v3Row.typeTag;
|
|
160
|
+
}
|
|
161
|
+
|
|
151
162
|
// Teammate 子进程的请求优先识别(收敛于 contentFilter.isTeammate)
|
|
152
163
|
if (isTeammate(req)) {
|
|
153
164
|
if (req.teammate) return { type: 'Teammate', subType: req.teammate };
|
|
@@ -206,3 +217,22 @@ export function formatTeammateLabel(name, model) {
|
|
|
206
217
|
const short = model.replace(/^claude-/i, '').replace(/-\d{8}$/, '');
|
|
207
218
|
return `Teammate: ${displayName}(${short})`;
|
|
208
219
|
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Normalize a classifyRequest tag, then surface the persisted agent name as the
|
|
223
|
+
* Teammate subType (ONLY for Teammate rows — a SubAgent subType is a role tag
|
|
224
|
+
* like "Bash" and must not be overwritten). Shared by the server's meta-rows
|
|
225
|
+
* Pass B and live-feed row emission so the list/chat labels never drift.
|
|
226
|
+
* KEEP IN SYNC: both server callers import this from here.
|
|
227
|
+
*
|
|
228
|
+
* @param {{type: string, subType: string|null}|null} tag
|
|
229
|
+
* @param {object} [entry] - entry carrying `agent` ({agentName, named})
|
|
230
|
+
* @returns {{type: string, subType: string|null}|null}
|
|
231
|
+
*/
|
|
232
|
+
export function withAgentNameSubType(tag, entry) {
|
|
233
|
+
const base = tag ? { type: tag.type, subType: tag.subType ?? null } : null;
|
|
234
|
+
if (tag && tag.type === 'Teammate' && entry && entry.agent && entry.agent.agentName) {
|
|
235
|
+
return { type: tag.type, subType: entry.agent.agentName };
|
|
236
|
+
}
|
|
237
|
+
return base;
|
|
238
|
+
}
|
|
@@ -41,6 +41,8 @@ export function assignMessageTimestamps(messages, prevMessages, isNewSession, pr
|
|
|
41
41
|
}
|
|
42
42
|
} else if (m.role === 'assistant' && !m._generatedTs && prevMainAgentTs) {
|
|
43
43
|
// 已有 _timestamp 但缺 _generatedTs(混合输入:部分 entry 来自旧版本):补 _generatedTs
|
|
44
|
+
// Synthetic v2 messages carry their own _generatedTs (stamped by the
|
|
45
|
+
// normalizer), so they never reach this branch.
|
|
44
46
|
m._generatedTs = prevMainAgentTs;
|
|
45
47
|
}
|
|
46
48
|
}
|
|
@@ -245,14 +247,23 @@ export function applyBatchEntryTimestamps(st, entry) {
|
|
|
245
247
|
// filter below), otherwise the first count=1 entry after a delta rebuild would
|
|
246
248
|
// be swallowed and its _timestamp stolen by the next count>4 entry.
|
|
247
249
|
const postClearCheckpoint = isPostClearCheckpoint(entry, prevCount);
|
|
250
|
+
// V2 transcript synthetic entries pre-stamp per-message _timestamp/_generatedTs
|
|
251
|
+
// with the real row timestamps; the positional overwrites below would flatten
|
|
252
|
+
// every message to entry.timestamp, so they become protective for synthetics.
|
|
253
|
+
const isSyntheticV2 = entry._syntheticV2 === true;
|
|
248
254
|
const epoch = entry._seqEpoch || null;
|
|
249
255
|
const epochChanged = !!(epoch && st.prevEpoch && epoch !== st.prevEpoch);
|
|
250
|
-
|
|
256
|
+
// A synthetic v2 entry is a complete /clear-segmented session — always a new
|
|
257
|
+
// session (its _seqEpoch is a definitive boundary marker), even when the
|
|
258
|
+
// count-based heuristics would call it a continuation.
|
|
259
|
+
const isNewSession = isSyntheticV2 || isSessionBoundary(entry, { prevCount, count, prevUserId: st.prevUserId, userId, prevEpoch: st.prevEpoch, epoch });
|
|
251
260
|
// Transient protection: very short entries (<=4 msgs) after a long conversation
|
|
252
261
|
// are usually in-flight requests (request body only, no response yet) and must
|
|
253
262
|
// not reset the accumulated timestamps. Real /clear starts AND epoch changes
|
|
254
263
|
// (task B: a definitive new session, even when short) are exempt.
|
|
255
|
-
|
|
264
|
+
// A synthetic v2 entry is never transient — it is a complete /clear-segmented
|
|
265
|
+
// session, so it must start a session even right after a long legacy one.
|
|
266
|
+
const isTransient = !isSyntheticV2 && isNewSession && !postClearCheckpoint && !epochChanged && count <= 4 && prevCount > 4 && count < prevCount * 0.5;
|
|
256
267
|
if (isNewSession && !isTransient) {
|
|
257
268
|
st.currentSessionId = timestamp;
|
|
258
269
|
st.timestamps = [];
|
|
@@ -283,8 +294,8 @@ export function applyBatchEntryTimestamps(st, entry) {
|
|
|
283
294
|
for (let j = 0; j < messages.length; j++) {
|
|
284
295
|
const m = messages[j];
|
|
285
296
|
if (!m) continue;
|
|
286
|
-
m._timestamp = st.timestamps[j];
|
|
287
|
-
if (m.role === 'assistant' && st.generatedTimestamps[j]) {
|
|
297
|
+
if (!isSyntheticV2 || m._timestamp == null) m._timestamp = st.timestamps[j];
|
|
298
|
+
if (m.role === 'assistant' && st.generatedTimestamps[j] && (!isSyntheticV2 || m._generatedTs == null)) {
|
|
288
299
|
m._generatedTs = st.generatedTimestamps[j];
|
|
289
300
|
}
|
|
290
301
|
}
|