cc-viewer 1.7.15 → 1.7.18

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.
@@ -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;
@@ -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
+ }
@@ -18,6 +18,10 @@ export { messageFingerprint };
18
18
  * 无 live-port 配置下"提问气泡请求时即显示"依赖这一行为。
19
19
  * AppBase 的 SSE 与批量两个 merge 入口、以及单测共用此谓词,防三处逻辑漂移。
20
20
  *
21
+ * 批量路径的降级例外见 shouldDegradeBrokenMerge:当 broken 条目恰好是它那个
22
+ * session 的唯一载体(slim 只留最后一条 main)时,其 messages 是可信前缀,
23
+ * 降级合并比整会话空白更可取(会话打 _partialData 标记,ChatView 显示提示条)。
24
+ *
21
25
  * @param {object} entry
22
26
  * @param {object} [options]
23
27
  * @param {boolean} [options.batch=false] - 批量(强刷/历史加载)路径
@@ -30,6 +34,36 @@ export function isMergeBlockedEntry(entry, options = {}) {
30
34
  return false;
31
35
  }
32
36
 
37
+ /**
38
+ * 批量路径降级例外:broken 载体能否以"可信前缀"身份合并进 mainAgentSessions。
39
+ *
40
+ * 仅在两条安全路径放行(两条都只会走进 mergeMainAgentSessions 的创建分支,
41
+ * 不会触发 anchor/rebuild 等可能翻倍的对齐逻辑):
42
+ * - prevSessions 为空:会话直接创建,无对齐风险;
43
+ * - 双方都有 _seqEpoch 且不同:确定性会话边界,走 epochChanged append 分支
44
+ * (三部分守卫防 v1 无 epoch 的条目被误放行)。
45
+ *
46
+ * `_staleReorder` 明确不降级(流内稍后有权威副本自建会话);inProgress 不降级
47
+ * (批量下其 messages 是裸切片,不是可信前缀)。v1 / 无 epoch 会话的 broken
48
+ * 载体也刻意不降级(放松守卫会让 broken 条目掉进同会话分支,错位前缀 → 翻倍)。
49
+ *
50
+ * @param {object} entry
51
+ * @param {Array} prevSessions - 当前已合并的 sessions
52
+ * @returns {boolean} true = 允许降级合并
53
+ */
54
+ export function shouldDegradeBrokenMerge(entry, prevSessions) {
55
+ if (!entry || !entry._reconstructBroken) return false;
56
+ if (entry._staleReorder || entry.inProgress) return false;
57
+ if (!Array.isArray(prevSessions) || prevSessions.length === 0) return true;
58
+ const e = entry._seqEpoch;
59
+ if (!e) return false; // v1 / epoch-less carriers never degrade
60
+ // Reject when ANY already-merged session shares the entry's epoch (a same-
61
+ // session broken carrier preceded by a healthy one must not degrade into the
62
+ // same-session merge branch). Today batches are single-session per epoch, so
63
+ // this is a defensive closure for future non-monotonic epoch streams.
64
+ return prevSessions.every((s) => !s._seqEpoch || s._seqEpoch !== e);
65
+ }
66
+
33
67
  /**
34
68
  * 增量合并 mainAgent sessions。
35
69
  *
@@ -39,6 +73,8 @@ export function isMergeBlockedEntry(entry, options = {}) {
39
73
  * - 命中且 overlapLen < newLen:push `newMessages[overlapLen..]`,引用稳定。
40
74
  * - 未命中:newLen<curLen → rebuild(/compact summary);newLen===curLen → 整段 append(Plan Mode 全新片段);
41
75
  * newLen>curLen → 严格前缀扩展语义(push tail),fp 加固后真正存在重叠的窗口必被 anchor 命中。
76
+ * - 降级 partial 基底(lastSession._partialData)且 anchor 未命中:任何长度的全量条目都整段替换
77
+ * (partial 前缀有中段缺口、不可信,push tail 会翻倍)。
42
78
  *
43
79
  * 顶部守卫(isPostClearCheckpoint / userId / transient filter)维持 1.6.245 行为不变。
44
80
  *
@@ -68,7 +104,13 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
68
104
  const entryModel = getEffectiveModel(entry);
69
105
 
70
106
  if (prevSessions.length === 0) {
71
- return [{ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
107
+ return [{
108
+ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
109
+ // Degraded-broken carrier: truthful prefix with a mid-session hole. Only
110
+ // the create branches carry it (a same-session merge onto a partial base
111
+ // is defused by the whole-replace below); ChatView renders the banner.
112
+ ...(entry._partialData === true && { _partialData: true }),
113
+ }];
72
114
  }
73
115
 
74
116
  const lastSession = prevSessions[prevSessions.length - 1];
@@ -90,7 +132,10 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
90
132
  for (let i = 0; i < newMessages.length; i++) {
91
133
  if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
92
134
  }
93
- return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
135
+ return [...prevSessions, {
136
+ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
137
+ ...(entry._partialData === true && { _partialData: true }),
138
+ }];
94
139
  }
95
140
 
96
141
  if (!options.skipTransientFilter && isNewConversation && newMessages.length <= 4 && prevMsgCount > 4) {
@@ -119,6 +164,17 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
119
164
  lastSession.messages.push(newMessages[i]);
120
165
  }
121
166
  }
167
+ // Anchor 对齐成功:新全量条目与 partial 前缀吻合,数据已恢复完整。
168
+ delete lastSession._partialData;
169
+ } else if (lastSession._partialData) {
170
+ // 降级合并的 partial 会话(中段缺口,前缀不可信):anchor 未命中时任何
171
+ // 同长/更长的全量条目都是权威真值,整段替换而非前缀扩展——否则会 push 出
172
+ // 尾部重复(partial 里已含 416..418,新全量 419 会被 append 一遍)。
173
+ for (let i = 0; i < newLen; i++) {
174
+ if (!newMessages[i]._timestamp) newMessages[i]._timestamp = entryTimestamp;
175
+ }
176
+ lastSession.messages = newMessages;
177
+ delete lastSession._partialData;
122
178
  } else if (newLen < curLen) {
123
179
  // /compact summary 等真重建:替换 messages 引用。
124
180
  for (let i = 0; i < newLen; i++) {
@@ -175,6 +231,9 @@ export function mergeMainAgentSessions(prevSessions, entry, options = {}) {
175
231
  if (entryModel) lastSession.model = entryModel; // latest wins; model-less entries keep the stamp
176
232
  return [...prevSessions];
177
233
  } else {
178
- return [...prevSessions, { userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch }];
234
+ return [...prevSessions, {
235
+ userId, messages: newMessages, response: newResponse, entryTimestamp, model: entryModel, _seqEpoch: seqEpoch,
236
+ ...(entry._partialData === true && { _partialData: true }),
237
+ }];
179
238
  }
180
239
  }
@@ -80,8 +80,20 @@ export function createV3Assembler() {
80
80
  _seqEpoch: `v2:${row.sessionId}`,
81
81
  _totalMessageCount: messages.length,
82
82
  _v3Assembled: true,
83
+ // The SOURCE row — lets chat classification read the server-side
84
+ // typeTag directly (same source the request list uses), instead of
85
+ // re-deriving from a body stripped of system/tools. Known limitation:
86
+ // AppBase._applyV3Delta dedups by (sessionId, seq, inProgress), so a
87
+ // classification-correction re-send (Preflight/Plan lookahead) does NOT
88
+ // rebuild this entry — the row ref stays the FIRST one until a cold
89
+ // reload. Only lookahead-dependent rows are affected (Teammate/SubAgent
90
+ // tags are lookahead-free); tracked as backlog.
91
+ _v3Row: row,
83
92
  };
84
93
  if (row.teammate) entry.teammate = row.teammate;
94
+ // Wire agent identity (x-claude-code-agent-id) — display name for native
95
+ // teammates, persisted server-side (see agent-id.js).
96
+ if (row.agent) entry.agent = row.agent;
85
97
  if (row.proxyUrl) entry.proxyUrl = row.proxyUrl;
86
98
  if (isSnapshot) entry._isCheckpoint = true;
87
99
  if (row.inProgress) {