Skip to content

Commit f3a8413

Browse files
author
weiesky.wangc
committed
fix(chat): wire-agent identity for teammate/subagent classification (1.7.17)
- persist x-claude-code-agent-id on journal req lines (new agent-id.js parser); teammate display names no longer depend on the window-scoped registry - chat classification reuses the server-side typeTag via _v3Row, so [对话] and the request list agree on Teammate vs SubAgent (keeps role subTypes) - subagents are no longer mis-tagged as Teammate: since 2.1.199 subagents get SendMessage too; isTeammate now trusts the wire id shape (named@ → Teammate, hex → SubAgent) and cc_is_subagent=true first - shared withAgentNameSubType helper; identityHeal narrows its skip; Pass B wire-agent coverage tests added
1 parent e4fc788 commit f3a8413

18 files changed

Lines changed: 380 additions & 17 deletions

docs/refactor/WIRE_FORMAT_V2.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ LOG_DIR/<project>/sessions/<session_id>/
8484
"evt": "append" | "snapshot" | "ctl", // 对应 conversation 行类型(§6,replace-tail 等控制行为 ctl);无 conv 写入则省略
8585
"boundary": "clear" | "compact" | "replace-tail", // 触发的边界(可省略)
8686
"proxy": { "profile": "", "url": "" } // proxyProfile/proxyUrl(可省略)
87+
"agent": { "agentName": "frontend-reviewer" | null, "named": true | false } // 可选:从请求头 x-claude-code-agent-id 解析的 wire agent 身份(teammate/subagent 请求携带)。named=true 时 agentName 是展示名(前端不再依赖窗口内启发式注册表);named=false 表示匿名 hex id。旧行缺失时读侧回退(见 server/lib/v2/agent-id.js)
8788
}
8889
```
8990

history.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
# Changelog
22

3-
## 1.7.16 (unreleased)
3+
## 1.7.17 (2026-08-05)
44

5+
- feat(v2): **persist agent identity from the wire header** — new `server/lib/v2/agent-id.js` parses `x-claude-code-agent-id` (`name@session-…` named / pure-hex anonymous) and the writer stores it as an optional `agent` field on journal req lines. Teammate display names no longer depend on the frontend's window-scoped heuristic registry, so names survive cold loads and mid-session gaps (new captures only).
6+
- fix(chat): **chat classification reuses the server-side typeTag** — wire-v3 assembled entries carry their source row (`_v3Row`); `classifyRequest` returns `row.typeTag` verbatim instead of re-deriving from a body stripped of system/tools, so [对话] and the request list agree on Teammate vs SubAgent (and keep the server's role subTypes like `SubAgent:Bash`). Teammate rows surface the persisted agent name; main-agent detection prefers the kind-derived `mainAgent` flag.
7+
- fix(chat): **subagents are no longer mis-tagged as Teammate** — since Claude Code 2.1.199 plain subagents are also granted the SendMessage tool, which broke `isNativeTeammate`'s tool-based discriminator (anonymous Explore subagents rendered as "Teammate: X"). `isTeammate` now consults two hard signals first: the persisted `x-claude-code-agent-id` shape (`name@…` named → Teammate, pure-hex → SubAgent) and the CLI-reported `cc_is_subagent=true` billing marker (→ SubAgent), which the server's full-body classification applies too.
58
- fix(v2-read): **crash orphans finalize as completed deltas on cold reads** — a dead session's req-without-done now emits as a completed delta (`response:{body:null}`) instead of an inProgress placeholder, so the client reconstructor accumulates its message slice; previously the slice was skipped, every later entry failed its `_totalMessageCount` check and the batch slimmer's last-carrier merge blanked the whole chat. Live sessions keep the placeholder (liveness gate: owner pid + journal mtime).
69
- fix(chat): **degraded merge for broken carriers** — a `_reconstructBroken` entry that is its session's only carrier merges as a truthful prefix (create branches only, `_seqEpoch` three-part guard); the session is stamped `_partialData` and ChatView shows an "incomplete session" banner. Same-session merges onto a partial base whole-replace instead of prefix-extending (no tail duplication); anchor-hit merges clear the flag. Parity/reorder test pipelines kept in sync.
710

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "cc-viewer",
3-
"version": "1.7.15",
3+
"version": "1.7.17",
44
"description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
55
"license": "MIT",
66
"main": "server.js",

server/lib/v2/adapter.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -469,7 +469,11 @@ export class SessionSynthesizer {
469469
isHeartbeat: HEARTBEAT_URL_RE.test(req.url || ''),
470470
isCountTokens: COUNT_TOKENS_URL_RE.test(req.url || ''),
471471
mainAgent: false, // finalized below once messages are attached
472-
...(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 }),
473477
...(req.proxy && { proxyProfile: req.proxy.profile, ...(req.proxy.url && { proxyUrl: req.proxy.url }) }),
474478
};
475479

server/lib/v2/agent-id.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
}

server/lib/v2/live-feed.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { isDiscardableSession } from './session-select.js';
3232
import { isForeignLiveOwned } from './session-owner.js';
3333
import { isConvertRunning } from './convert-manager.js';
3434
import { computeCacheLoss } from './meta-rows.js';
35-
import { classifyRequest } from '../../../src/utils/requestType.js';
35+
import { classifyRequest, withAgentNameSubType } from '../../../src/utils/requestType.js';
3636

3737
const FSWATCH_DEBOUNCE_MS = 80;
3838
const SAFETY_POLL_MS = 5000;
@@ -679,6 +679,9 @@ export class V2LiveFeed {
679679
kind: item.kind || (item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub')),
680680
mainAgent: item.isMain === true,
681681
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,
682685
model: parsed.body?.model,
683686
proxyUrl: parsed.proxyUrl || undefined,
684687
status: parsed.response?.status,
@@ -698,8 +701,7 @@ export class V2LiveFeed {
698701
_emitRow(cur, item, parsed) {
699702
const row = this._rowFrom(item, parsed);
700703
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);
703705
} catch (err) { reportSwallowed('v2-live.row-classify', err); }
704706
if (row.mainAgent && !row.inProgress) {
705707
const u = parsed.response?.body?.usage;
@@ -714,8 +716,7 @@ export class V2LiveFeed {
714716
const prev = cur._v3Last;
715717
if (prev && (prev.row.sessionId !== row.sessionId || prev.row.seq !== row.seq)) {
716718
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);
719720
if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
720721
prev.row.typeTag = corrected;
721722
sendEventToClients(this._clients, 'v2_requests_delta', prev.row);

server/lib/v2/meta-rows.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import { iterateJsonlLines } from './jsonl-read.js';
2222
import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
2323
import { SingleFlight } from './singleflight.js';
2424
import { reportSwallowed } from '../error-report.js';
25-
import { classifyRequest } from '../../../src/utils/requestType.js';
25+
import { classifyRequest, withAgentNameSubType } from '../../../src/utils/requestType.js';
2626

2727
// KEEP IN SYNC: server/lib/v2/adapter.js itemKey — (sessionId, seq) identity.
2828
const itemKey = (sessionId, seq) => `${sessionId}\x00${seq}`;
@@ -113,6 +113,9 @@ function foldDir(dir) {
113113
mainAgent: r.kind === 'main' && !isTeammateDir,
114114
// v1 entry contract (adapter.js:427): teammate = agentName string | true
115115
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,
116119
model: r.model,
117120
proxyUrl: r.proxy && r.proxy.url ? r.proxy.url : undefined,
118121
status: d && typeof d.http === 'number' ? d.http : undefined,
@@ -318,8 +321,7 @@ async function attachBodyFields(sessionDir, rows) {
318321
const finish = (slot, nextEntry) => {
319322
const { row, entry } = slot;
320323
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);
323325
} catch (err) {
324326
// Diagnostic-worthy (a throw silently mis-tags the row; the live path
325327
// reports the same failure) — CLAUDE.md swallow rule.

server/lib/v2/v2-writer.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import { BlobStore } from './blob-store.js';
2727
import { Journal } from './journal.js';
2828
import { ConversationStore } from './conversation-store.js';
2929
import { parseUserId, classifyKind, ConvResolver } from './identity.js';
30+
import { parseAgentId, findHeader } from './agent-id.js';
3031
import { extractUserTexts, flattenPromptText, isSuggestionMode, readPromptsHead } from '../user-prompt-extract.js';
3132

3233
// Below this many free bytes on the log volume, v2 skips writing and reports
@@ -505,6 +506,10 @@ export class V2Writer {
505506
}
506507

507508
// 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'));
508513
s.journal.writeReq({
509514
seq,
510515
rid,
@@ -517,6 +522,7 @@ export class V2Writer {
517522
...(entry.body && entry.body.model && { model: entry.body.model }),
518523
...(entry.isStream && { isStream: true }),
519524
...(entry.headers && { headers: entry.headers }),
525+
...(agent && { agent }),
520526
...(params && { params }),
521527
...((toolsRef || sysRef) && { blobs: { ...(toolsRef && { tools: toolsRef }), ...(sysRef && { sys: sysRef }) } }),
522528
...(convResult && { msgFrom: convResult.msgFrom, msgTo: convResult.msgTo }),

src/components/chat/ChatView.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1605,6 +1605,11 @@ class ChatView extends React.Component {
16051605
let lastModelName = cache.modelName;
16061606
for (let i = startIdx; i < requests.length; i++) {
16071607
const req = requests[i];
1608+
// isMainAgent short-circuits on req.mainAgent (contentFilter.js
1609+
// _isMainAgentImpl) — v3 kind-derived entries pass through it, and the
1610+
// internal isTeammate / cc_is_subagent / legacy mis-tag guards keep
1611+
// protecting old logs. Do NOT short-circuit on the flag here: that
1612+
// would bypass the guards for legacy double-tagged entries.
16081613
const ma = isMainAgent(req);
16091614
if (ma && req.timestamp) {
16101615
cache.tsToIndex[req.timestamp] = i;

src/utils/contentFilter.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,19 +127,33 @@ export function isTeammate(req) {
127127
if (!req) return false;
128128
const cached = _isTeammateCache.get(req);
129129
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; }
130143
// interceptor 模式:通过 process.argv 写入的 teammate 字段
131144
if (req.teammate) { _isTeammateCache.set(req, true); return true; }
132145
// native teammate:同进程内 Agent 子代理(system prompt 包含 "You are a Claude agent")
133146
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+
// 它不依赖窗口内的注册表,冷加载/时序缺失时名字仍可靠。
135150
if (!req.teammate) {
136-
req.teammate = extractNativeTeammateName(req) || null;
151+
req.teammate = req.agent?.agentName || extractNativeTeammateName(req) || null;
137152
}
138153
_isTeammateCache.set(req, true);
139154
return true;
140155
}
141156
// proxy 模式:通过 system prompt 检测(外部进程 teammate)
142-
const sysText = getSystemText(req.body || {});
143157
const result = TEAMMATE_SYSTEM_RE.test(sysText);
144158
_isTeammateCache.set(req, result);
145159
return result;

0 commit comments

Comments
 (0)