@co0ontty/wand 1.29.6 → 1.30.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.
@@ -634,7 +634,10 @@ export class ProcessManager extends EventEmitter {
634
634
  knownClaudeProjectMtimes: knownClaudeProjectMtimes ?? undefined,
635
635
  approvalStats: { tool: 0, command: 0, file: 0, total: 0 },
636
636
  selectedModel: selectedModel ?? null,
637
- ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 400) : 120,
637
+ // cols 上限 256:与 @wterm/dom WASM grid maxCols 硬编码一致,
638
+ // 防止服务端按 >256 cols 让 Claude 用 CSI 绝对列定位写到 wterm 实际
639
+ // 渲染不到的列上(表现为"内容神奇复制下行")。
640
+ ptyCols: opts?.cols !== undefined ? clampDimension(opts.cols, 20, 256) : 120,
638
641
  ptyRows: opts?.rows !== undefined ? clampDimension(opts.rows, 10, 160) : 36,
639
642
  };
640
643
  if (isClaudeProvider) {
@@ -986,7 +989,7 @@ export class ProcessManager extends EventEmitter {
986
989
  if (!record.ptyProcess || record.status !== "running") {
987
990
  return this.snapshot(record);
988
991
  }
989
- const safeCols = clampDimension(cols, 20, 400);
992
+ const safeCols = clampDimension(cols, 20, 256);
990
993
  const safeRows = clampDimension(rows, 10, 160);
991
994
  const changed = safeCols !== record.ptyCols || safeRows !== record.ptyRows;
992
995
  record.ptyProcess.resize(safeCols, safeRows);
@@ -83,6 +83,84 @@ export function thinkingEffortToCodexFlag(effort) {
83
83
  default: return null;
84
84
  }
85
85
  }
86
+ function captureTaskMeta(blocks, registry) {
87
+ for (const b of blocks) {
88
+ if (b.type !== "tool_use")
89
+ continue;
90
+ if (registry.has(b.id))
91
+ continue;
92
+ const input = b.input ?? {};
93
+ // Claude SDK 把这类"派 subagent 干活"的内置工具叫做 "Agent",CLI/旧版本里
94
+ // 也叫过 "Task"。判定不靠工具名(容易随版本变),而是看 input 是否含有
95
+ // `subagent_type` 字段——这是 Agent/Task 系列的唯一标志。
96
+ const agentType = typeof input.subagent_type === "string" ? input.subagent_type : undefined;
97
+ if (!agentType && b.name !== "Task" && b.name !== "Agent")
98
+ continue;
99
+ const description = typeof input.description === "string" ? input.description : undefined;
100
+ registry.set(b.id, { agentType, description });
101
+ }
102
+ }
103
+ /**
104
+ * Stamp every block with `__subagent` meta keyed to `parentToolUseId`. When
105
+ * the id has no entry yet (rare race: subagent emits before we see the parent
106
+ * Task tool_use), we still stamp the bare taskId so the UI can group blocks;
107
+ * agentType / description backfill on later updates.
108
+ */
109
+ function tagSubagentBlocks(blocks, parentToolUseId, registry) {
110
+ if (!parentToolUseId)
111
+ return blocks;
112
+ const meta = registry.get(parentToolUseId);
113
+ const stamp = {
114
+ taskId: parentToolUseId,
115
+ ...(meta?.agentType ? { agentType: meta.agentType } : {}),
116
+ ...(meta?.description ? { taskDescription: meta.description } : {}),
117
+ };
118
+ return blocks.map((block) => ({ ...block, __subagent: stamp }));
119
+ }
120
+ /**
121
+ * 给已被 captureTaskMeta 识别为 Task/Agent 的 tool_use block 本身也盖 __subagent 章。
122
+ * taskId 用自己的 block.id —— 与子消息的 parent_tool_use_id(也等于这个 id)保持一致,
123
+ * 前端 splitTurnBySubagent 按 taskId 分组时父 Task tool_use 和 SDK 转发的子消息能合并到同一段。
124
+ */
125
+ function stampSelfTask(blocks, registry) {
126
+ return blocks.map((b) => {
127
+ if (b.type !== "tool_use")
128
+ return b;
129
+ if (b.__subagent)
130
+ return b; // 已盖章不重复(防止幂等问题)
131
+ const meta = registry.get(b.id);
132
+ if (!meta && b.name !== "Task" && b.name !== "Agent")
133
+ return b;
134
+ const stamp = {
135
+ taskId: b.id,
136
+ ...(meta?.agentType ? { agentType: meta.agentType } : {}),
137
+ ...(meta?.description ? { taskDescription: meta.description } : {}),
138
+ };
139
+ return { ...b, __subagent: stamp };
140
+ });
141
+ }
142
+ /**
143
+ * 当父 assistant 在 parentToolUseId === null 的 user turn 里收到 Task 工具的 tool_result 时,
144
+ * tagSubagentBlocks 不会被调用(它只在 parentToolUseId 非空时盖章)。这里按 tool_use_id
145
+ * 反查 registry,给这条 tool_result 单独盖章,让前端能把它归到同一个 subagent 段。
146
+ */
147
+ function stampParentTaskResults(blocks, registry) {
148
+ return blocks.map((b) => {
149
+ if (b.type !== "tool_result")
150
+ return b;
151
+ if (b.__subagent)
152
+ return b;
153
+ const meta = registry.get(b.tool_use_id);
154
+ if (!meta)
155
+ return b;
156
+ const stamp = {
157
+ taskId: b.tool_use_id,
158
+ ...(meta.agentType ? { agentType: meta.agentType } : {}),
159
+ ...(meta.description ? { taskDescription: meta.description } : {}),
160
+ };
161
+ return { ...b, __subagent: stamp };
162
+ });
163
+ }
86
164
  const STREAM_EMIT_DEBOUNCE_MS = 16;
87
165
  /** Min interval between full saveSession() calls for an in-progress streaming turn.
88
166
  * saveSession serializes the entire messages array, so doing it on every NDJSON
@@ -1342,6 +1420,9 @@ export class StructuredSessionManager {
1342
1420
  const blocksByKey = new Map();
1343
1421
  const keyOrder = [];
1344
1422
  let toolResultSeq = 0;
1423
+ // 本轮 Task tool_use_id → meta map,由父 assistant 消息里的 Task tool_use
1424
+ // 填充;子 agent message(parent_tool_use_id 非空)来时用它给每个 block 盖章。
1425
+ const taskMetaRegistry = new Map();
1345
1426
  // 估算单个 ContentBlock 的"信息体积"——文字 / thinking / tool input 长度之和。
1346
1427
  // 用于 upsertBlocks 的防御性合并:同一 message.id 重发时,按位置取信息量更大的
1347
1428
  // 那个版本,保证已经吐出的文字 / tool_use input 不会被一条更短的同 id 事件
@@ -1372,22 +1453,25 @@ export class StructuredSessionManager {
1372
1453
  blocksByKey.set(key, blocks);
1373
1454
  return;
1374
1455
  }
1375
- // claude -p 在同一 message.id 的多次 assistant 事件里是**累积**协议:
1376
- // 每次 event 的 content 总是包含之前所有 blocks + 可能的新 block,
1377
- // 长度只增不减、同位置类型不变。两条额外的防御性规则:
1456
+ // claude -p 在同一 message.id 的多次 assistant 事件有两种观察到的协议:
1457
+ // a) **累积模式**:每次 event 的 content = 之前所有 blocks + 0~N block,
1458
+ // 同位置类型一致。流式 text/thinking 的逐字增量属于这种。
1459
+ // b) **拼接模式**:SDK 把 thinking 和后续的 tool_use 拆成两条 event 给同
1460
+ // 一 msg.id 发出,第二条只带 tool_use,**不包含**之前的 thinking。
1461
+ // Opus 4.7 + claude-agent-sdk 实际跑下来就是这种。
1378
1462
  //
1379
- // 1) blocks.length < prev.length —— 短数组覆盖。上游异常 frame,直接拒绝
1380
- // 本次更新,下一帧正常累积 emit 会自然修正。
1463
+ // 老逻辑("同 index 类型不一致 → 保留 prev")只对 a) 友好,碰上 b) 会让第
1464
+ // 二条事件里的 tool_use 直接被丢掉——表现是 Agent / Read 等 tool_use 永远
1465
+ // 不出现在 messages 里,subagent 多角色无法关联 agentType 到父 Task。
1381
1466
  //
1382
- // 2) index 类型不一致 —— 比如 prev[0]=text 而 incoming[0]=tool_use
1383
- // 正常累积下不会发生;一旦发生,**保留 prev[i]**。早期版本走"取
1384
- // volume 大者",会让 tool_useinput JSON 通常更长)抢占 text 位,
1385
- // 导致流式过程中已经渲染的文字突然消失,只剩工具卡片——直到 result
1386
- // event 给出最终 turnState.result,compactContentBlocks 的 fallback
1387
- // 才补回 text。用户反馈"文字消失,回复完成后又出现"就是这条路径。
1467
+ // 新规则:当类型不一致时,把新 block **追加**到 merged 末尾而非覆盖 prev。
1468
+ // 既兼容 a)(同位置同类型仍按累积取大),又兼容 b)(拼接的新类型 block
1469
+ // 进入末尾),还能挡住 b 早期版本里"短回退"的异常 frameblocks.length
1470
+ // < prev.length 时直接拒绝)。
1388
1471
  if (blocks.length < prev.length)
1389
1472
  return;
1390
1473
  const merged = [];
1474
+ const appendix = [];
1391
1475
  for (let i = 0; i < blocks.length; i++) {
1392
1476
  const a = prev[i];
1393
1477
  const b = blocks[i];
@@ -1405,11 +1489,14 @@ export class StructuredSessionManager {
1405
1489
  merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
1406
1490
  }
1407
1491
  else {
1408
- // 类型变了:保留 prev,不让 tool_use 等抢占 text 位。
1492
+ // 类型变了:保留 prev[i],把 incoming block 追加到末尾。
1409
1493
  merged.push(a);
1494
+ appendix.push(b);
1410
1495
  }
1411
1496
  }
1412
1497
  }
1498
+ for (const b of appendix)
1499
+ merged.push(b);
1413
1500
  blocksByKey.set(key, merged);
1414
1501
  };
1415
1502
  const rebuildTurnBlocks = () => {
@@ -1503,8 +1590,19 @@ export class StructuredSessionManager {
1503
1590
  const msgId = typeof parsed.message.id === "string" && parsed.message.id
1504
1591
  ? `assistant:${parsed.message.id}`
1505
1592
  : `assistant:anon:${keyOrder.length}`;
1506
- if (extracted.content.length > 0) {
1507
- upsertBlocks(msgId, extracted.content);
1593
+ // parent_tool_use_id 决定父/子 agent。父 message 里的 Task tool_use 登记
1594
+ // 到 taskMetaRegistry;子 message 的每个 block 用 __subagent 盖章。
1595
+ const parentToolUseId = typeof parsed.parent_tool_use_id === "string" && parsed.parent_tool_use_id
1596
+ ? parsed.parent_tool_use_id
1597
+ : null;
1598
+ if (parentToolUseId === null) {
1599
+ captureTaskMeta(extracted.content, taskMetaRegistry);
1600
+ }
1601
+ const stamped = parentToolUseId === null
1602
+ ? stampSelfTask(extracted.content, taskMetaRegistry)
1603
+ : tagSubagentBlocks(extracted.content, parentToolUseId, taskMetaRegistry);
1604
+ if (stamped.length > 0) {
1605
+ upsertBlocks(msgId, stamped);
1508
1606
  rebuildTurnBlocks();
1509
1607
  }
1510
1608
  // NOTE: usage from streaming "assistant" events contains partial/incremental
@@ -1541,8 +1639,14 @@ export class StructuredSessionManager {
1541
1639
  });
1542
1640
  }
1543
1641
  }
1544
- if (collected.length > 0) {
1545
- upsertBlocks(`tool_result:${toolResultSeq++}`, collected);
1642
+ const parentToolUseId = typeof parsed.parent_tool_use_id === "string" && parsed.parent_tool_use_id
1643
+ ? parsed.parent_tool_use_id
1644
+ : null;
1645
+ const stamped = parentToolUseId === null
1646
+ ? stampParentTaskResults(collected, taskMetaRegistry)
1647
+ : tagSubagentBlocks(collected, parentToolUseId, taskMetaRegistry);
1648
+ if (stamped.length > 0) {
1649
+ upsertBlocks(`tool_result:${toolResultSeq++}`, stamped);
1546
1650
  rebuildTurnBlocks();
1547
1651
  }
1548
1652
  syncSnapshot();
@@ -1794,6 +1898,10 @@ export class StructuredSessionManager {
1794
1898
  ...(permPolicy.allowedTools ? { allowedTools: permPolicy.allowedTools } : {}),
1795
1899
  ...(isManaged ? { disallowedTools: ["AskUserQuestion"] } : {}),
1796
1900
  includePartialMessages: true,
1901
+ // 把子 agent 的 text/thinking 也转发回来,UI 才能把"被 Task 召唤来的协作者"
1902
+ // 渲染成独立角色的群聊消息。关掉这个开关时只会收到子 agent 的 tool_use/tool_result,
1903
+ // text/thinking 被 SDK 吞掉。
1904
+ forwardSubagentText: true,
1797
1905
  ...(systemPromptParts.length > 0 ? { appendSystemPrompt: systemPromptParts.join("\n\n") } : {}),
1798
1906
  ...(sdkClaudeBinary ? { pathToClaudeCodeExecutable: sdkClaudeBinary } : {}),
1799
1907
  };
@@ -1855,6 +1963,11 @@ export class StructuredSessionManager {
1855
1963
  // Tracks in-progress streaming blocks keyed by content_block index from stream_event.
1856
1964
  // The map is cleared whenever a complete `assistant` message arrives — its blocks
1857
1965
  // are then promoted into `finalizedBlocks` below.
1966
+ //
1967
+ // `parentToolUseId` carries through from SDKPartialAssistantMessage so we can
1968
+ // stamp streaming blocks with subagent persona *during* streaming, not only
1969
+ // after the completion event. Without it, subagent text shows up under the
1970
+ // parent's avatar for tens of ms then snaps to the subagent — visible flicker.
1858
1971
  const streamingBlockByIndex = new Map();
1859
1972
  // Blocks from messages that have already completed within this turn — including
1860
1973
  // the parent assistant's prior messages, every subagent assistant message, and
@@ -1862,6 +1975,9 @@ export class StructuredSessionManager {
1862
1975
  // back-to-back; without this list, each new streaming message would visually
1863
1976
  // erase everything that came before it in the same turn.
1864
1977
  const finalizedBlocks = [];
1978
+ // Per-turn Task tool_use_id → meta map; populated from the parent assistant's
1979
+ // Task tool_use blocks and consulted when subagent messages arrive.
1980
+ const taskMetaRegistry = new Map();
1865
1981
  let emitTimer = null;
1866
1982
  const flushEmit = () => {
1867
1983
  if (emitTimer) {
@@ -1884,11 +2000,12 @@ export class StructuredSessionManager {
1884
2000
  const sorted = [...streamingBlockByIndex.entries()].sort((a, b) => a[0] - b[0]);
1885
2001
  const streaming = [];
1886
2002
  for (const [, sb] of sorted) {
2003
+ let block = null;
1887
2004
  if (sb.type === "text") {
1888
- streaming.push({ type: "text", text: sb.text });
2005
+ block = { type: "text", text: sb.text };
1889
2006
  }
1890
2007
  else if (sb.type === "thinking") {
1891
- streaming.push({ type: "thinking", thinking: sb.thinking });
2008
+ block = { type: "thinking", thinking: sb.thinking };
1892
2009
  }
1893
2010
  else if (sb.type === "tool_use" && sb.id && sb.name) {
1894
2011
  let input = {};
@@ -1898,10 +2015,25 @@ export class StructuredSessionManager {
1898
2015
  }
1899
2016
  catch { /* partial json */ }
1900
2017
  }
1901
- streaming.push({ type: "tool_use", id: sb.id, name: sb.name, input });
2018
+ block = { type: "tool_use", id: sb.id, name: sb.name, input };
2019
+ }
2020
+ if (!block)
2021
+ continue;
2022
+ if (sb.parentToolUseId) {
2023
+ const [stamped] = tagSubagentBlocks([block], sb.parentToolUseId, taskMetaRegistry);
2024
+ streaming.push(stamped);
2025
+ }
2026
+ else {
2027
+ streaming.push(block);
1902
2028
  }
1903
2029
  }
1904
- return [...finalizedBlocks, ...streaming];
2030
+ // 流式阶段就给 Task/Agent tool_use 本身盖章,防止"先显示工具卡片几秒再跳为
2031
+ // handoff 行"的闪烁。content_block_start 阶段就有 name=Task/Agent,
2032
+ // stampSelfTask 据此即可命中;agentType 字段藏在 input 里,delta 累计后再由
2033
+ // 后续 captureTaskMeta 回填 registry,下次 rebuild 自动补上更完整的 stamp。
2034
+ captureTaskMeta(streaming, taskMetaRegistry);
2035
+ const stampedStreaming = stampSelfTask(streaming, taskMetaRegistry);
2036
+ return [...finalizedBlocks, ...stampedStreaming];
1905
2037
  };
1906
2038
  const syncSnapshot = () => {
1907
2039
  const current = this.sessions.get(sessionId);
@@ -1950,7 +2082,9 @@ export class StructuredSessionManager {
1950
2082
  break;
1951
2083
  // Incremental streaming events (opt-in via includePartialMessages: true)
1952
2084
  if (msg.type === "stream_event") {
1953
- const ev = msg.event;
2085
+ const partial = msg;
2086
+ const ev = partial.event;
2087
+ const partialParentId = partial.parent_tool_use_id ?? null;
1954
2088
  if (ev.type === "content_block_start") {
1955
2089
  const cb = ev.content_block;
1956
2090
  const blockType = cb.type;
@@ -1963,6 +2097,7 @@ export class StructuredSessionManager {
1963
2097
  thinking: typeof cb.thinking === "string" ? cb.thinking : "",
1964
2098
  partialInput: "",
1965
2099
  finalized: false,
2100
+ parentToolUseId: partialParentId,
1966
2101
  });
1967
2102
  turnState.blocks = rebuildStreamingBlocks();
1968
2103
  syncSnapshot();
@@ -2005,7 +2140,16 @@ export class StructuredSessionManager {
2005
2140
  if (msg.type === "assistant") {
2006
2141
  const assistantMsg = msg;
2007
2142
  const extracted = this.extractAssistantMessage(assistantMsg.message);
2008
- finalizedBlocks.push(...extracted.content);
2143
+ // 父 assistant 的 Task tool_use → 注册到本轮 taskMeta map;
2144
+ // 子 agent 的 message(parent_tool_use_id 非空)→ 给每个 block 盖章。
2145
+ const parentToolUseId = assistantMsg.parent_tool_use_id ?? null;
2146
+ if (parentToolUseId === null) {
2147
+ captureTaskMeta(extracted.content, taskMetaRegistry);
2148
+ finalizedBlocks.push(...stampSelfTask(extracted.content, taskMetaRegistry));
2149
+ }
2150
+ else {
2151
+ finalizedBlocks.push(...tagSubagentBlocks(extracted.content, parentToolUseId, taskMetaRegistry));
2152
+ }
2009
2153
  streamingBlockByIndex.clear();
2010
2154
  turnState.blocks = rebuildStreamingBlocks();
2011
2155
  if (assistantMsg.session_id)
@@ -2041,11 +2185,13 @@ export class StructuredSessionManager {
2041
2185
  // tool call, or a subagent's tool_result during Task execution).
2042
2186
  if (msg.type === "user") {
2043
2187
  const userMsg = msg;
2188
+ const parentToolUseId = userMsg.parent_tool_use_id ?? null;
2044
2189
  const content = Array.isArray(userMsg.message?.content) ? userMsg.message.content : [];
2190
+ const collected = [];
2045
2191
  for (const block of content) {
2046
2192
  const b = block;
2047
2193
  if (b?.type === "tool_result") {
2048
- finalizedBlocks.push({
2194
+ collected.push({
2049
2195
  type: "tool_result",
2050
2196
  tool_use_id: typeof b.tool_use_id === "string" ? b.tool_use_id : "",
2051
2197
  content: this.normalizeToolResultContent(b.content),
@@ -2053,6 +2199,12 @@ export class StructuredSessionManager {
2053
2199
  });
2054
2200
  }
2055
2201
  }
2202
+ if (parentToolUseId === null) {
2203
+ finalizedBlocks.push(...stampParentTaskResults(collected, taskMetaRegistry));
2204
+ }
2205
+ else {
2206
+ finalizedBlocks.push(...tagSubagentBlocks(collected, parentToolUseId, taskMetaRegistry));
2207
+ }
2056
2208
  turnState.blocks = rebuildStreamingBlocks();
2057
2209
  syncSnapshot();
2058
2210
  scheduleEmit();
@@ -2227,11 +2379,17 @@ export class StructuredSessionManager {
2227
2379
  const previous = compacted[compacted.length - 1];
2228
2380
  if (previous
2229
2381
  && previous.type === "text"
2230
- && block.type === "text") {
2382
+ && block.type === "text"
2383
+ // 子 agent 边界不合并:父 assistant 的 text 与子 agent 的 text 必须保持独立,
2384
+ // 渲染层才能切段并给子 agent 单独发头像。同一 subagent 内部允许合并。
2385
+ && (previous.__subagent?.taskId ?? null) === (block.__subagent?.taskId ?? null)) {
2231
2386
  // 用新对象替换 compacted 末尾,**不要**就地改 previous.text —— previous
2232
2387
  // 通常和调用方持有的 turnState.blocks 共享引用,原地 mutate 会让下次
2233
2388
  // syncSnapshot 把已合并的内容再合并一次,呈指数级复制。
2234
- compacted[compacted.length - 1] = { type: "text", text: `${previous.text}${block.text}` };
2389
+ const merged = { type: "text", text: `${previous.text}${block.text}` };
2390
+ if (previous.__subagent)
2391
+ merged.__subagent = previous.__subagent;
2392
+ compacted[compacted.length - 1] = merged;
2235
2393
  continue;
2236
2394
  }
2237
2395
  compacted.push(block);
package/dist/types.d.ts CHANGED
@@ -299,13 +299,30 @@ export interface ChatMessage {
299
299
  role: "user" | "assistant";
300
300
  content: string;
301
301
  }
302
+ /**
303
+ * Meta marker attached to blocks emitted by a Task-spawned subagent. Present
304
+ * on every block (text / thinking / tool_use / tool_result) whose origin is a
305
+ * subagent's stream rather than the main assistant. Drives the multi-persona
306
+ * chat rendering ("third cat joining the conversation").
307
+ *
308
+ * `taskId` is the parent Task tool_use id (= SDK's `parent_tool_use_id`).
309
+ * The parent Task tool_use block itself is NOT marked — it lives in the
310
+ * main assistant's stream.
311
+ */
312
+ export interface SubagentMeta {
313
+ taskId: string;
314
+ agentType?: string;
315
+ taskDescription?: string;
316
+ }
302
317
  export interface TextBlock {
303
318
  type: "text";
304
319
  text: string;
320
+ __subagent?: SubagentMeta;
305
321
  }
306
322
  export interface ThinkingBlock {
307
323
  type: "thinking";
308
324
  thinking: string;
325
+ __subagent?: SubagentMeta;
309
326
  }
310
327
  export interface ToolUseBlock {
311
328
  type: "tool_use";
@@ -313,6 +330,7 @@ export interface ToolUseBlock {
313
330
  name: string;
314
331
  description?: string;
315
332
  input: Record<string, unknown>;
333
+ __subagent?: SubagentMeta;
316
334
  }
317
335
  export interface ToolResultBlock {
318
336
  type: "tool_result";
@@ -324,6 +342,7 @@ export interface ToolResultBlock {
324
342
  is_error?: boolean;
325
343
  /** When true, content has been truncated for transport. Client should fetch full content via API. */
326
344
  _truncated?: boolean;
345
+ __subagent?: SubagentMeta;
327
346
  }
328
347
  export type ContentBlock = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock;
329
348
  export interface ConversationTurn {