@co0ontty/wand 1.21.4 → 1.21.7

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.
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
3
  import { prepareSessionWorktree } from "./git-worktree.js";
4
+ import { truncateMessagesForTransport } from "./message-truncator.js";
4
5
  function defaultStructuredRunner(provider) {
5
6
  return provider === "codex" ? "codex-cli-exec" : "claude-cli-print";
6
7
  }
@@ -14,6 +15,11 @@ function defaultStructuredState(provider, runner = defaultStructuredRunner(provi
14
15
  };
15
16
  }
16
17
  const STREAM_EMIT_DEBOUNCE_MS = 16;
18
+ /** Min interval between full saveSession() calls for an in-progress streaming turn.
19
+ * saveSession serializes the entire messages array, so doing it on every NDJSON
20
+ * event is N². close-path always calls saveSession unconditionally to take the
21
+ * authoritative final snapshot. */
22
+ const STREAM_SAVE_THROTTLE_MS = 200;
17
23
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
18
24
  function isRunningAsRoot() {
19
25
  return process.getuid?.() === 0 || process.geteuid?.() === 0;
@@ -82,14 +88,19 @@ function buildStructuredOutputPayload(snapshot) {
82
88
  structuredState: snapshot.structuredState,
83
89
  };
84
90
  }
85
- function buildIncrementalStructuredPayload(snapshot) {
91
+ function buildIncrementalStructuredPayload(snapshot, cardDefaults) {
86
92
  const messages = snapshot.messages ?? [];
93
+ const lastTurn = messages.length > 0 ? messages[messages.length - 1] : undefined;
94
+ // Streaming turn (index 0 here) is preserved verbatim; truncation only kicks
95
+ // in if the live response is already bigger than the transport threshold,
96
+ // matching the PTY runner's behaviour in process-manager.ts.
97
+ const lastMessage = lastTurn ? truncateMessagesForTransport([lastTurn], cardDefaults, 0)[0] : undefined;
87
98
  return {
88
99
  incremental: true,
89
100
  queuedMessages: snapshot.queuedMessages,
90
101
  sessionKind: "structured",
91
102
  structuredState: snapshot.structuredState,
92
- lastMessage: messages.length > 0 ? messages[messages.length - 1] : undefined,
103
+ lastMessage,
93
104
  messageCount: messages.length,
94
105
  };
95
106
  }
@@ -99,7 +110,18 @@ export class StructuredSessionManager {
99
110
  logger;
100
111
  sessions = new Map();
101
112
  pendingChildren = new Map();
113
+ pendingSdkAbort = new Map();
102
114
  interruptedWith = new Map();
115
+ /** Last wall-clock time (ms) we did a full saveSession for a streaming session. */
116
+ lastStreamSaveAt = new Map();
117
+ /**
118
+ * Idempotency keys we've already accepted, mapped to their wall-clock timestamp.
119
+ * Android WebView 在进程恢复时偶尔会重发上一个未收到响应的 POST(HTTP/2 stream
120
+ * reset 等场景),客户端 JS 没有重试逻辑也拦不住。这里用 (sessionId, key) 永
121
+ * 久去重,重复就抛错让前端弹 toast 提示,**不**做任何处理。timestamp 仅用于
122
+ * map 大小溢出时按时间裁剪。
123
+ */
124
+ seenIdempotencyKeys = new Map();
103
125
  emitEvent = null;
104
126
  archiveTimer = null;
105
127
  constructor(storage, config, logger = null) {
@@ -162,6 +184,20 @@ export class StructuredSessionManager {
162
184
  setEventEmitter(emitEvent) {
163
185
  this.emitEvent = emitEvent;
164
186
  }
187
+ /**
188
+ * In-memory snapshot is updated unconditionally; the SQLite write is rate-
189
+ * limited to once per STREAM_SAVE_THROTTLE_MS. Caller must still invoke
190
+ * `storage.saveSession` directly at terminal events (close / failure) so the
191
+ * final state is durable.
192
+ */
193
+ saveStreamingSnapshot(snapshot) {
194
+ const now = Date.now();
195
+ const last = this.lastStreamSaveAt.get(snapshot.id) ?? 0;
196
+ if (now - last < STREAM_SAVE_THROTTLE_MS)
197
+ return;
198
+ this.lastStreamSaveAt.set(snapshot.id, now);
199
+ this.storage.saveSession(snapshot);
200
+ }
165
201
  list() {
166
202
  return Array.from(this.sessions.values())
167
203
  .map(withSummary)
@@ -234,7 +270,23 @@ export class StructuredSessionManager {
234
270
  const prompt = input.trim();
235
271
  if (!prompt)
236
272
  return session;
237
- console.log("[WAND] StructuredSessionManager.sendMessage id:", id, "inFlight:", session.structuredState?.inFlight, "hasPendingChild:", this.pendingChildren.has(id), "status:", session.status);
273
+ if (opts?.idempotencyKey) {
274
+ const mapKey = `${id}:${opts.idempotencyKey}`;
275
+ if (this.seenIdempotencyKeys.has(mapKey)) {
276
+ console.log("[WAND] sendMessage: duplicate idempotency key rejected", { id, key: opts.idempotencyKey });
277
+ const err = new Error("检测到重复发送,已拦截。");
278
+ err.code = "duplicate_idempotency_key";
279
+ throw err;
280
+ }
281
+ this.seenIdempotencyKeys.set(mapKey, Date.now());
282
+ // 防止 map 无限增长:超过 1024 条时按时间裁掉一半最早的
283
+ if (this.seenIdempotencyKeys.size > 1024) {
284
+ const sorted = Array.from(this.seenIdempotencyKeys.entries()).sort((a, b) => a[1] - b[1]);
285
+ for (let i = 0; i < sorted.length / 2; i++) {
286
+ this.seenIdempotencyKeys.delete(sorted[i][0]);
287
+ }
288
+ }
289
+ }
238
290
  if (session.structuredState?.inFlight) {
239
291
  const child = this.pendingChildren.get(id);
240
292
  const childAlive = child && !child.killed && child.exitCode === null;
@@ -261,6 +313,9 @@ export class StructuredSessionManager {
261
313
  child.kill("SIGTERM");
262
314
  }
263
315
  catch (_err) { /* ignore */ }
316
+ const sdkAbort = this.pendingSdkAbort.get(id);
317
+ if (sdkAbort)
318
+ sdkAbort.abort();
264
319
  return session;
265
320
  }
266
321
  else {
@@ -329,6 +384,9 @@ export class StructuredSessionManager {
329
384
  if ((updated.provider ?? "claude") === "codex") {
330
385
  await this.runCodexStreaming(id, updated, prompt);
331
386
  }
387
+ else if (this.config.structuredRunner === "sdk") {
388
+ await this.runClaudeSdkStreaming(id, updated, claudePrompt);
389
+ }
332
390
  else {
333
391
  await this.runClaudeStreaming(id, updated, claudePrompt);
334
392
  }
@@ -439,6 +497,11 @@ export class StructuredSessionManager {
439
497
  child.kill();
440
498
  this.pendingChildren.delete(id);
441
499
  }
500
+ const sdkAbort = this.pendingSdkAbort.get(id);
501
+ if (sdkAbort) {
502
+ sdkAbort.abort();
503
+ this.pendingSdkAbort.delete(id);
504
+ }
442
505
  const stopped = {
443
506
  ...session,
444
507
  status: "stopped",
@@ -462,7 +525,13 @@ export class StructuredSessionManager {
462
525
  child.kill();
463
526
  this.pendingChildren.delete(id);
464
527
  }
528
+ const sdkAbort = this.pendingSdkAbort.get(id);
529
+ if (sdkAbort) {
530
+ sdkAbort.abort();
531
+ this.pendingSdkAbort.delete(id);
532
+ }
465
533
  this.sessions.delete(id);
534
+ this.lastStreamSaveAt.delete(id);
466
535
  this.storage.deleteSession(id);
467
536
  this.logger?.deleteSession(id);
468
537
  }
@@ -526,6 +595,17 @@ export class StructuredSessionManager {
526
595
  }
527
596
  catch (error) {
528
597
  console.error("[WAND] flushNextQueuedMessage failed:", error);
598
+ // 发送失败时把消息放回队首,避免永久丢失
599
+ const afterFail = this.sessions.get(sessionId);
600
+ if (afterFail) {
601
+ const rescued = {
602
+ ...afterFail,
603
+ queuedMessages: [nextInput, ...(afterFail.queuedMessages ?? [])],
604
+ };
605
+ this.sessions.set(sessionId, rescued);
606
+ this.storage.saveSession(rescued);
607
+ this.emitStructuredSnapshot(rescued);
608
+ }
529
609
  }
530
610
  }
531
611
  emit(event) {
@@ -630,14 +710,12 @@ export class StructuredSessionManager {
630
710
  runCodexStreaming(sessionId, session, prompt) {
631
711
  return new Promise((resolve, reject) => {
632
712
  const args = this.buildCodexArgs(session);
633
- console.log("[WAND] runCodexStreaming sessionId:", sessionId, "mode:", session.mode, "threadId:", session.claudeSessionId);
634
713
  const spawnedAt = new Date().toISOString();
635
714
  const child = spawn("codex", args, {
636
715
  cwd: session.cwd,
637
716
  env: process.env,
638
717
  stdio: ["pipe", "pipe", "pipe"],
639
718
  });
640
- console.log("[WAND] spawned codex exec pid:", child.pid, "args:", args.join(" "));
641
719
  this.logger?.appendStructuredSpawn(sessionId, {
642
720
  kind: "codex-exec",
643
721
  provider: "codex",
@@ -674,7 +752,7 @@ export class StructuredSessionManager {
674
752
  const current = this.sessions.get(sessionId);
675
753
  if (!current)
676
754
  return;
677
- this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current) });
755
+ this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
678
756
  };
679
757
  const scheduleEmit = () => {
680
758
  if (!emitTimer)
@@ -708,7 +786,7 @@ export class StructuredSessionManager {
708
786
  },
709
787
  };
710
788
  this.sessions.set(sessionId, patched);
711
- this.storage.saveSession(patched);
789
+ this.saveStreamingSnapshot(patched);
712
790
  };
713
791
  const processLine = (line) => {
714
792
  const trimmed = line.trim();
@@ -755,10 +833,8 @@ export class StructuredSessionManager {
755
833
  }
756
834
  if (parsed?.type === "error") {
757
835
  const message = typeof parsed.message === "string" ? parsed.message : "";
758
- if (message) {
759
- console.log("[WAND] codex error event:", message.slice(0, 300));
836
+ if (message)
760
837
  codexErrors.push(message);
761
- }
762
838
  return;
763
839
  }
764
840
  if (parsed?.type === "turn.failed") {
@@ -766,7 +842,6 @@ export class StructuredSessionManager {
766
842
  const message = (errObj && typeof errObj.message === "string" && errObj.message)
767
843
  || (typeof parsed.message === "string" ? parsed.message : "")
768
844
  || "codex turn failed";
769
- console.log("[WAND] codex turn.failed:", message.slice(0, 300));
770
845
  codexTurnFailed = message;
771
846
  return;
772
847
  }
@@ -786,8 +861,8 @@ export class StructuredSessionManager {
786
861
  stderr += text;
787
862
  });
788
863
  child.on("error", (error) => {
789
- console.log("[WAND] codex exec child error:", error.message);
790
864
  this.pendingChildren.delete(sessionId);
865
+ this.lastStreamSaveAt.delete(sessionId);
791
866
  if (emitTimer)
792
867
  clearTimeout(emitTimer);
793
868
  this.logger?.appendStructuredSpawn(sessionId, {
@@ -800,8 +875,8 @@ export class StructuredSessionManager {
800
875
  reject(error);
801
876
  });
802
877
  child.on("close", (code) => {
803
- console.log("[WAND] codex exec child close code:", code, "stderr:", stderr.substring(0, 200), "errors:", codexErrors.length, "turnFailed:", codexTurnFailed?.slice(0, 100));
804
878
  this.pendingChildren.delete(sessionId);
879
+ this.lastStreamSaveAt.delete(sessionId);
805
880
  if (lineBuf.trim()) {
806
881
  processLine(lineBuf);
807
882
  lineBuf = "";
@@ -823,10 +898,13 @@ export class StructuredSessionManager {
823
898
  reject(new Error("Session removed during execution."));
824
899
  return;
825
900
  }
901
+ // 主动中断时(interruptedWith 里有新消息),不走失败路径
902
+ const interruptedByUser = this.interruptedWith.has(sessionId);
903
+ const interruptPrompt = this.interruptedWith.get(sessionId);
826
904
  // codex 把模型/网络/沙箱等错误写到 stdout 的 NDJSON 流(type: error / turn.failed),
827
905
  // 而不是 stderr。我们以 turn.failed 的 message 为准,其次是最后一个 error 事件。
828
906
  const codexFailed = codexTurnFailed !== null;
829
- if (codexFailed || (code !== 0 && code !== null)) {
907
+ if ((codexFailed || (code !== 0 && code !== null)) && !interruptedByUser) {
830
908
  const errorText = (codexTurnFailed && codexTurnFailed.trim())
831
909
  || (codexErrors.length > 0 ? codexErrors[codexErrors.length - 1] : "")
832
910
  || stderr.trim()
@@ -851,14 +929,16 @@ export class StructuredSessionManager {
851
929
  msgs[msgs.length - 1] = assistantTurn;
852
930
  else
853
931
  msgs.push(assistantTurn);
932
+ const keepRunning = !!interruptPrompt;
854
933
  const finished = {
855
934
  ...current,
856
- status: "idle",
857
- exitCode: 0,
858
- endedAt: new Date().toISOString(),
935
+ status: keepRunning ? "running" : "idle",
936
+ exitCode: keepRunning ? null : 0,
937
+ endedAt: keepRunning ? null : new Date().toISOString(),
859
938
  output: turnState.result,
860
939
  claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
861
940
  messages: msgs,
941
+ queuedMessages: interruptPrompt ? [] : current.queuedMessages,
862
942
  pendingEscalation: null,
863
943
  permissionBlocked: false,
864
944
  structuredState: {
@@ -872,7 +952,36 @@ export class StructuredSessionManager {
872
952
  this.sessions.set(sessionId, finished);
873
953
  this.storage.saveSession(finished);
874
954
  this.emitStructuredSnapshot(finished);
875
- this.emitStructuredSnapshot(finished, "ended");
955
+ if (!keepRunning) {
956
+ this.emitStructuredSnapshot(finished, "ended");
957
+ }
958
+ if (interruptPrompt) {
959
+ this.interruptedWith.delete(sessionId);
960
+ resolve();
961
+ setImmediate(() => {
962
+ this.sendMessage(sessionId, interruptPrompt).catch((err) => {
963
+ console.error("[WAND] codex interrupt-and-send failed:", err);
964
+ const afterFail = this.sessions.get(sessionId);
965
+ if (afterFail) {
966
+ const recovered = {
967
+ ...afterFail,
968
+ status: "idle",
969
+ exitCode: 0,
970
+ endedAt: new Date().toISOString(),
971
+ structuredState: {
972
+ ...afterFail.structuredState,
973
+ inFlight: false,
974
+ activeRequestId: null,
975
+ },
976
+ };
977
+ this.sessions.set(sessionId, recovered);
978
+ this.storage.saveSession(recovered);
979
+ this.emitStructuredSnapshot(recovered);
980
+ }
981
+ });
982
+ });
983
+ return;
984
+ }
876
985
  resolve();
877
986
  setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
878
987
  });
@@ -895,7 +1004,6 @@ export class StructuredSessionManager {
895
1004
  runClaudeStreaming(sessionId, session, prompt) {
896
1005
  return new Promise((resolve, reject) => {
897
1006
  const args = ["-p", "--verbose", "--output-format", "stream-json"];
898
- console.log("[WAND] runClaudeStreaming sessionId:", sessionId, "mode:", session.mode, "claudeSessionId:", session.claudeSessionId);
899
1007
  // Add permission args based on mode + autoApprovePermissions toggle
900
1008
  const permArgs = this.buildPermissionArgs(session.mode, session.autoApprovePermissions ?? false);
901
1009
  args.push(...permArgs);
@@ -937,7 +1045,6 @@ export class StructuredSessionManager {
937
1045
  env: process.env,
938
1046
  stdio: ["pipe", "pipe", "pipe"],
939
1047
  });
940
- console.log("[WAND] spawned claude -p pid:", child.pid, "args:", args.join(" "));
941
1048
  this.logger?.appendStructuredSpawn(sessionId, {
942
1049
  kind: "claude-print",
943
1050
  provider: "claude",
@@ -958,6 +1065,30 @@ export class StructuredSessionManager {
958
1065
  model: undefined,
959
1066
  usage: undefined,
960
1067
  };
1068
+ // claude -p --output-format stream-json 在同一条消息流式生成期间会重复
1069
+ // emit 同一个 message.id 的 "assistant" 事件,每次 content 略多一些;子
1070
+ // agent 流(Task 工具)则会插入若干 parent_tool_use_id 不同的 message.id。
1071
+ // 朴素的 push(...content) 会让早期片段被反复合并复制,最终被 compact 出
1072
+ // 怪异结果,导致 UI 上 tool_use / 子 agent 输出"显示一下就消失"。
1073
+ // 这里按 (message.id) 去重,相同 id 视作同一消息的更新覆盖;tool_result
1074
+ // 用单调递增的合成 key 顺序追加。每次事件后用插入顺序重建 turnState.blocks。
1075
+ const blocksByKey = new Map();
1076
+ const keyOrder = [];
1077
+ let toolResultSeq = 0;
1078
+ const upsertBlocks = (key, blocks) => {
1079
+ if (!blocksByKey.has(key))
1080
+ keyOrder.push(key);
1081
+ blocksByKey.set(key, blocks);
1082
+ };
1083
+ const rebuildTurnBlocks = () => {
1084
+ const flat = [];
1085
+ for (const key of keyOrder) {
1086
+ const entry = blocksByKey.get(key);
1087
+ if (entry && entry.length > 0)
1088
+ flat.push(...entry);
1089
+ }
1090
+ turnState.blocks = flat;
1091
+ };
961
1092
  // Line buffer for NDJSON: chunks from stdout may split mid-line.
962
1093
  let lineBuf = "";
963
1094
  // Debounce output events to avoid flooding the WebSocket.
@@ -977,7 +1108,7 @@ export class StructuredSessionManager {
977
1108
  this.emit({
978
1109
  type: "output",
979
1110
  sessionId,
980
- data: buildIncrementalStructuredPayload(current),
1111
+ data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}),
981
1112
  });
982
1113
  };
983
1114
  const scheduleEmit = () => {
@@ -1016,8 +1147,9 @@ export class StructuredSessionManager {
1016
1147
  };
1017
1148
  this.sessions.set(sessionId, patched);
1018
1149
  // Persist streaming progress so a server restart does not roll back the
1019
- // latest assistant turn to the pre-stream snapshot.
1020
- this.storage.saveSession(patched);
1150
+ // latest assistant turn to the pre-stream snapshot. Throttled because
1151
+ // saveSession serializes the full messages array.
1152
+ this.saveStreamingSnapshot(patched);
1021
1153
  };
1022
1154
  const processLine = (line) => {
1023
1155
  const trimmed = line.trim();
@@ -1033,8 +1165,15 @@ export class StructuredSessionManager {
1033
1165
  this.logger?.appendStreamEvent(sessionId, parsed);
1034
1166
  if (parsed && parsed.type === "assistant" && parsed.message) {
1035
1167
  const extracted = this.extractAssistantMessage(parsed.message);
1168
+ // 用 message.id 作为 key:claude -p 流式重发同一条消息时整段覆盖
1169
+ // (而不是与早期片段累加),子 agent 的不同消息 id 各占一格、保留
1170
+ // 父子完整顺序。没有 id 时退化为合成 key 走追加模式。
1171
+ const msgId = typeof parsed.message.id === "string" && parsed.message.id
1172
+ ? `assistant:${parsed.message.id}`
1173
+ : `assistant:anon:${keyOrder.length}`;
1036
1174
  if (extracted.content.length > 0) {
1037
- turnState.blocks.push(...extracted.content);
1175
+ upsertBlocks(msgId, extracted.content);
1176
+ rebuildTurnBlocks();
1038
1177
  }
1039
1178
  // NOTE: usage from streaming "assistant" events contains partial/incremental
1040
1179
  // token counts (e.g. output_tokens=1 during streaming) and is NOT accurate.
@@ -1058,9 +1197,11 @@ export class StructuredSessionManager {
1058
1197
  return;
1059
1198
  }
1060
1199
  if (parsed && parsed.type === "user" && parsed.message && Array.isArray(parsed.message.content)) {
1200
+ // tool_result 没有自身 id,按到达顺序用合成 key 追加(永远不被覆盖)。
1201
+ const collected = [];
1061
1202
  for (const block of parsed.message.content) {
1062
1203
  if (block && block.type === "tool_result") {
1063
- turnState.blocks.push({
1204
+ collected.push({
1064
1205
  type: "tool_result",
1065
1206
  tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "",
1066
1207
  content: this.normalizeToolResultContent(block.content),
@@ -1068,6 +1209,10 @@ export class StructuredSessionManager {
1068
1209
  });
1069
1210
  }
1070
1211
  }
1212
+ if (collected.length > 0) {
1213
+ upsertBlocks(`tool_result:${toolResultSeq++}`, collected);
1214
+ rebuildTurnBlocks();
1215
+ }
1071
1216
  syncSnapshot();
1072
1217
  scheduleEmit();
1073
1218
  return;
@@ -1103,8 +1248,8 @@ export class StructuredSessionManager {
1103
1248
  stderr += text;
1104
1249
  });
1105
1250
  child.on("error", (error) => {
1106
- console.log("[WAND] claude -p child error:", error.message);
1107
1251
  this.pendingChildren.delete(sessionId);
1252
+ this.lastStreamSaveAt.delete(sessionId);
1108
1253
  if (emitTimer)
1109
1254
  clearTimeout(emitTimer);
1110
1255
  this.logger?.appendStructuredSpawn(sessionId, {
@@ -1117,8 +1262,8 @@ export class StructuredSessionManager {
1117
1262
  reject(error);
1118
1263
  });
1119
1264
  child.on("close", (code) => {
1120
- console.log("[WAND] claude -p child close code:", code, "stderr:", stderr.substring(0, 200));
1121
1265
  this.pendingChildren.delete(sessionId);
1266
+ this.lastStreamSaveAt.delete(sessionId);
1122
1267
  this.logger?.appendStructuredSpawn(sessionId, {
1123
1268
  kind: "claude-print-close",
1124
1269
  pid: child.pid ?? null,
@@ -1140,7 +1285,11 @@ export class StructuredSessionManager {
1140
1285
  reject(new Error("Session removed during execution."));
1141
1286
  return;
1142
1287
  }
1143
- if (code !== 0 && code !== null) {
1288
+ // 如果是用户主动中断(interruptedWith 里有新消息),claude -p 收到 SIGTERM
1289
+ // 可能以非零 exit code 退出(内部 handler 调了 exit(1))。这种情况属于正常
1290
+ // 中断流程,不应走失败路径——后续 interruptedWith 逻辑会发送新消息。
1291
+ const interruptedByUser = this.interruptedWith.has(sessionId);
1292
+ if (code !== 0 && code !== null && !interruptedByUser) {
1144
1293
  const errorText = stderr.trim() || `claude -p exited with code ${code}`;
1145
1294
  const failureTurn = {
1146
1295
  role: "assistant",
@@ -1232,11 +1381,28 @@ export class StructuredSessionManager {
1232
1381
  // 用户中断当前回复:保存部分回复后立即发送新消息。
1233
1382
  if (interruptPrompt) {
1234
1383
  this.interruptedWith.delete(sessionId);
1235
- console.log("[WAND] interrupt-and-send for session:", sessionId, "prompt:", interruptPrompt.substring(0, 50));
1236
1384
  resolve();
1237
1385
  setImmediate(() => {
1238
1386
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
1239
1387
  console.error("[WAND] interrupt-and-send failed:", err);
1388
+ // 续接失败:把状态回滚到 idle,让用户可以重新输入而不是卡在 running 状态
1389
+ const afterFail = this.sessions.get(sessionId);
1390
+ if (afterFail) {
1391
+ const recovered = {
1392
+ ...afterFail,
1393
+ status: "idle",
1394
+ exitCode: 0,
1395
+ endedAt: new Date().toISOString(),
1396
+ structuredState: {
1397
+ ...afterFail.structuredState,
1398
+ inFlight: false,
1399
+ activeRequestId: null,
1400
+ },
1401
+ };
1402
+ this.sessions.set(sessionId, recovered);
1403
+ this.storage.saveSession(recovered);
1404
+ this.emitStructuredSnapshot(recovered);
1405
+ }
1240
1406
  });
1241
1407
  });
1242
1408
  return;
@@ -1247,7 +1413,6 @@ export class StructuredSessionManager {
1247
1413
  // so the plan is actually carried out.
1248
1414
  const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
1249
1415
  if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
1250
- console.log("[WAND] ExitPlanMode detected – auto-continuing plan execution for session:", sessionId);
1251
1416
  resolve();
1252
1417
  setImmediate(() => {
1253
1418
  this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
@@ -1264,6 +1429,409 @@ export class StructuredSessionManager {
1264
1429
  });
1265
1430
  }
1266
1431
  // ---------------------------------------------------------------------------
1432
+ // Streaming claude-agent-sdk execution
1433
+ // ---------------------------------------------------------------------------
1434
+ /**
1435
+ * Use @anthropic-ai/claude-agent-sdk instead of spawning claude -p directly.
1436
+ * The SDK still spawns the claude binary but provides typed AsyncGenerator<SDKMessage>
1437
+ * messages, so we skip NDJSON parsing. Options are 1:1 with the CLI flags.
1438
+ *
1439
+ * Streaming is enabled via includePartialMessages: true — the SDK emits
1440
+ * SDKPartialAssistantMessage (type: "stream_event") with BetaRawMessageStreamEvent
1441
+ * payloads for incremental text/thinking/tool_use updates, followed by a final
1442
+ * SDKAssistantMessage with the authoritative complete content.
1443
+ */
1444
+ runClaudeSdkStreaming(sessionId, session, prompt) {
1445
+ return new Promise((resolve, reject) => {
1446
+ void this._runClaudeSdkStreamingAsync(sessionId, session, prompt).then(resolve, reject);
1447
+ });
1448
+ }
1449
+ async _runClaudeSdkStreamingAsync(sessionId, session, prompt) {
1450
+ let sdkQuery;
1451
+ try {
1452
+ const sdkMod = await import("@anthropic-ai/claude-agent-sdk");
1453
+ sdkQuery = sdkMod.query;
1454
+ }
1455
+ catch {
1456
+ throw new Error("@anthropic-ai/claude-agent-sdk 未安装,无法使用 SDK runner。");
1457
+ }
1458
+ const abortController = new AbortController();
1459
+ this.pendingSdkAbort.set(sessionId, abortController);
1460
+ const isManaged = session.mode === "managed";
1461
+ let killedForAskUserQuestion = false;
1462
+ // Derive permission mode (mirrors buildPermissionArgs logic)
1463
+ const shouldBypass = (session.autoApprovePermissions ?? false) || session.mode === "full-access" || session.mode === "managed";
1464
+ const shouldAcceptEdits = session.mode === "auto-edit";
1465
+ let permissionMode = "default";
1466
+ let allowedToolsForRoot;
1467
+ if (!isRunningAsRoot()) {
1468
+ if (shouldBypass)
1469
+ permissionMode = "bypassPermissions";
1470
+ else if (shouldAcceptEdits)
1471
+ permissionMode = "acceptEdits";
1472
+ }
1473
+ else {
1474
+ // Root: acceptEdits + allowedTools (same workaround as CLI runner)
1475
+ if (shouldBypass || shouldAcceptEdits) {
1476
+ permissionMode = "acceptEdits";
1477
+ allowedToolsForRoot = ["Bash", "Edit", "Write", "Read", "Glob", "Grep", "NotebookEdit", "WebFetch", "WebSearch"];
1478
+ }
1479
+ }
1480
+ // System prompt additions
1481
+ const isChinese = this.config.language?.trim() === "中文";
1482
+ const systemPromptParts = [];
1483
+ if (isManaged) {
1484
+ systemPromptParts.push(isChinese
1485
+ ? "你正在完全托管的自主模式下运行。用户可能无法及时回复问题或确认。你必须独立做出所有决策——自行选择最佳方案,而不是向用户询问偏好、确认或澄清。如果有多种可行方案,选择你认为最合适的并继续执行。除非任务本身存在根本性的歧义且无法合理推断,否则不要等待用户输入。果断行动,自主决策。"
1486
+ : "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.");
1487
+ }
1488
+ const language = this.config.language?.trim();
1489
+ if (language) {
1490
+ systemPromptParts.push(isChinese
1491
+ ? "请使用中文回复。所有解释、注释和对话文本都使用中文。"
1492
+ : `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`);
1493
+ }
1494
+ const sdkOptions = {
1495
+ cwd: session.cwd,
1496
+ abortController,
1497
+ permissionMode,
1498
+ ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
1499
+ ...(allowedToolsForRoot ? { allowedTools: allowedToolsForRoot } : {}),
1500
+ ...(isManaged ? { disallowedTools: ["AskUserQuestion"] } : {}),
1501
+ includePartialMessages: true,
1502
+ ...(systemPromptParts.length > 0 ? { appendSystemPrompt: systemPromptParts.join("\n\n") } : {}),
1503
+ };
1504
+ if (session.claudeSessionId)
1505
+ sdkOptions.resume = session.claudeSessionId;
1506
+ const modelChoice = session.selectedModel?.trim();
1507
+ if (modelChoice && modelChoice !== "default")
1508
+ sdkOptions.model = modelChoice;
1509
+ const turnState = {
1510
+ blocks: [],
1511
+ result: "",
1512
+ sessionId: null,
1513
+ model: undefined,
1514
+ usage: undefined,
1515
+ };
1516
+ // Tracks in-progress streaming blocks keyed by content_block index from stream_event.
1517
+ // The map is cleared whenever a complete `assistant` message arrives — its blocks
1518
+ // are then promoted into `finalizedBlocks` below.
1519
+ const streamingBlockByIndex = new Map();
1520
+ // Blocks from messages that have already completed within this turn — including
1521
+ // the parent assistant's prior messages, every subagent assistant message, and
1522
+ // every tool_result. Subagent (Task tool) flows produce many assistant messages
1523
+ // back-to-back; without this list, each new streaming message would visually
1524
+ // erase everything that came before it in the same turn.
1525
+ const finalizedBlocks = [];
1526
+ let emitTimer = null;
1527
+ const flushEmit = () => {
1528
+ if (emitTimer) {
1529
+ clearTimeout(emitTimer);
1530
+ emitTimer = null;
1531
+ }
1532
+ const current = this.sessions.get(sessionId);
1533
+ if (!current)
1534
+ return;
1535
+ this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
1536
+ };
1537
+ const scheduleEmit = () => {
1538
+ if (!emitTimer)
1539
+ emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
1540
+ };
1541
+ // Rebuild ContentBlock[] from finalized history + the in-progress streaming map.
1542
+ // Returning only the streaming blocks would drop every prior parent/subagent
1543
+ // message in this turn (the original disappearing-output bug).
1544
+ const rebuildStreamingBlocks = () => {
1545
+ const sorted = [...streamingBlockByIndex.entries()].sort((a, b) => a[0] - b[0]);
1546
+ const streaming = [];
1547
+ for (const [, sb] of sorted) {
1548
+ if (sb.type === "text") {
1549
+ streaming.push({ type: "text", text: sb.text });
1550
+ }
1551
+ else if (sb.type === "thinking") {
1552
+ streaming.push({ type: "thinking", thinking: sb.thinking });
1553
+ }
1554
+ else if (sb.type === "tool_use" && sb.id && sb.name) {
1555
+ let input = {};
1556
+ if (sb.finalized && sb.partialInput) {
1557
+ try {
1558
+ input = JSON.parse(sb.partialInput);
1559
+ }
1560
+ catch { /* partial json */ }
1561
+ }
1562
+ streaming.push({ type: "tool_use", id: sb.id, name: sb.name, input });
1563
+ }
1564
+ }
1565
+ return [...finalizedBlocks, ...streaming];
1566
+ };
1567
+ const syncSnapshot = () => {
1568
+ const current = this.sessions.get(sessionId);
1569
+ if (!current)
1570
+ return;
1571
+ const inProgressTurn = {
1572
+ role: "assistant",
1573
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1574
+ usage: turnState.usage,
1575
+ };
1576
+ const msgs = [...(current.messages ?? [])];
1577
+ const lastMsg = msgs[msgs.length - 1];
1578
+ if (lastMsg && lastMsg.role === "assistant")
1579
+ msgs[msgs.length - 1] = inProgressTurn;
1580
+ else
1581
+ msgs.push(inProgressTurn);
1582
+ const patched = {
1583
+ ...current,
1584
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1585
+ messages: msgs,
1586
+ output: turnState.result || current.output,
1587
+ structuredState: {
1588
+ ...current.structuredState,
1589
+ model: turnState.model ?? current.structuredState?.model,
1590
+ },
1591
+ };
1592
+ this.sessions.set(sessionId, patched);
1593
+ this.saveStreamingSnapshot(patched);
1594
+ };
1595
+ const spawnedAt = new Date().toISOString();
1596
+ this.logger?.appendStructuredSpawn(sessionId, {
1597
+ kind: "claude-sdk",
1598
+ provider: "claude",
1599
+ cwd: session.cwd,
1600
+ permissionMode,
1601
+ prompt: prompt.slice(0, 2048),
1602
+ promptLength: prompt.length,
1603
+ claudeSessionId: session.claudeSessionId,
1604
+ spawnedAt,
1605
+ });
1606
+ try {
1607
+ for await (const msg of sdkQuery({ prompt, options: sdkOptions })) {
1608
+ if (abortController.signal.aborted)
1609
+ break;
1610
+ // Incremental streaming events (opt-in via includePartialMessages: true)
1611
+ if (msg.type === "stream_event") {
1612
+ const ev = msg.event;
1613
+ if (ev.type === "content_block_start") {
1614
+ const cb = ev.content_block;
1615
+ const blockType = cb.type;
1616
+ if (blockType === "text" || blockType === "thinking" || blockType === "tool_use") {
1617
+ streamingBlockByIndex.set(ev.index, {
1618
+ type: blockType,
1619
+ id: typeof cb.id === "string" ? cb.id : undefined,
1620
+ name: typeof cb.name === "string" ? cb.name : undefined,
1621
+ text: typeof cb.text === "string" ? cb.text : "",
1622
+ thinking: typeof cb.thinking === "string" ? cb.thinking : "",
1623
+ partialInput: "",
1624
+ finalized: false,
1625
+ });
1626
+ turnState.blocks = rebuildStreamingBlocks();
1627
+ syncSnapshot();
1628
+ scheduleEmit();
1629
+ }
1630
+ }
1631
+ else if (ev.type === "content_block_delta") {
1632
+ const sb = streamingBlockByIndex.get(ev.index);
1633
+ if (sb) {
1634
+ const delta = ev.delta;
1635
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
1636
+ sb.text += delta.text;
1637
+ turnState.result = sb.text;
1638
+ }
1639
+ else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") {
1640
+ sb.thinking += delta.thinking;
1641
+ }
1642
+ else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
1643
+ sb.partialInput += delta.partial_json;
1644
+ }
1645
+ turnState.blocks = rebuildStreamingBlocks();
1646
+ syncSnapshot();
1647
+ scheduleEmit();
1648
+ }
1649
+ }
1650
+ else if (ev.type === "content_block_stop") {
1651
+ const sb = streamingBlockByIndex.get(ev.index);
1652
+ if (sb) {
1653
+ sb.finalized = true;
1654
+ turnState.blocks = rebuildStreamingBlocks();
1655
+ syncSnapshot();
1656
+ scheduleEmit();
1657
+ }
1658
+ }
1659
+ continue;
1660
+ }
1661
+ // Complete assistant turn — promote streaming content into the finalized
1662
+ // history so subsequent messages (subagents, follow-up parent messages)
1663
+ // append to it instead of erasing it.
1664
+ if (msg.type === "assistant") {
1665
+ const assistantMsg = msg;
1666
+ const extracted = this.extractAssistantMessage(assistantMsg.message);
1667
+ finalizedBlocks.push(...extracted.content);
1668
+ streamingBlockByIndex.clear();
1669
+ turnState.blocks = rebuildStreamingBlocks();
1670
+ if (assistantMsg.session_id)
1671
+ turnState.sessionId = assistantMsg.session_id;
1672
+ syncSnapshot();
1673
+ scheduleEmit();
1674
+ // Non-managed mode: detect AskUserQuestion, abort to let user answer
1675
+ if (!isManaged && !killedForAskUserQuestion) {
1676
+ const askBlock = extracted.content.find((b) => b.type === "tool_use" && b.name === "AskUserQuestion");
1677
+ if (askBlock) {
1678
+ killedForAskUserQuestion = true;
1679
+ flushEmit();
1680
+ abortController.abort();
1681
+ }
1682
+ }
1683
+ continue;
1684
+ }
1685
+ // Tool results fed back from the claude subprocess (parent's view of a
1686
+ // tool call, or a subagent's tool_result during Task execution).
1687
+ if (msg.type === "user") {
1688
+ const userMsg = msg;
1689
+ const content = Array.isArray(userMsg.message?.content) ? userMsg.message.content : [];
1690
+ for (const block of content) {
1691
+ const b = block;
1692
+ if (b?.type === "tool_result") {
1693
+ finalizedBlocks.push({
1694
+ type: "tool_result",
1695
+ tool_use_id: typeof b.tool_use_id === "string" ? b.tool_use_id : "",
1696
+ content: this.normalizeToolResultContent(b.content),
1697
+ is_error: b.is_error === true,
1698
+ });
1699
+ }
1700
+ }
1701
+ turnState.blocks = rebuildStreamingBlocks();
1702
+ syncSnapshot();
1703
+ scheduleEmit();
1704
+ continue;
1705
+ }
1706
+ // Final result — capture session_id, usage, model
1707
+ if (msg.type === "result") {
1708
+ const resultMsg = msg;
1709
+ if (typeof resultMsg.result === "string")
1710
+ turnState.result = resultMsg.result.trim();
1711
+ if (typeof resultMsg.session_id === "string")
1712
+ turnState.sessionId = resultMsg.session_id;
1713
+ turnState.model = this.extractModelName(resultMsg.modelUsage) ?? turnState.model;
1714
+ turnState.usage = this.extractSdkUsage(resultMsg);
1715
+ syncSnapshot();
1716
+ scheduleEmit();
1717
+ continue;
1718
+ }
1719
+ }
1720
+ }
1721
+ catch (err) {
1722
+ // AbortError from abortController.abort() is intentional — fall through to finish logic
1723
+ const isAbort = abortController.signal.aborted || (err instanceof Error && err.name === "AbortError");
1724
+ if (!isAbort) {
1725
+ this.pendingSdkAbort.delete(sessionId);
1726
+ this.lastStreamSaveAt.delete(sessionId);
1727
+ if (emitTimer)
1728
+ clearTimeout(emitTimer);
1729
+ this.logger?.appendStructuredSpawn(sessionId, {
1730
+ kind: "claude-sdk-error",
1731
+ spawnedAt,
1732
+ closedAt: new Date().toISOString(),
1733
+ error: err instanceof Error ? err.message : String(err),
1734
+ });
1735
+ throw err;
1736
+ }
1737
+ }
1738
+ // Cleanup
1739
+ this.pendingSdkAbort.delete(sessionId);
1740
+ this.lastStreamSaveAt.delete(sessionId);
1741
+ if (emitTimer)
1742
+ clearTimeout(emitTimer);
1743
+ flushEmit();
1744
+ const current = this.sessions.get(sessionId);
1745
+ if (!current)
1746
+ throw new Error("Session removed during execution.");
1747
+ this.logger?.appendStructuredSpawn(sessionId, {
1748
+ kind: "claude-sdk-close",
1749
+ spawnedAt,
1750
+ closedAt: new Date().toISOString(),
1751
+ killedForAskUserQuestion,
1752
+ sessionId: turnState.sessionId,
1753
+ });
1754
+ const interruptedByUser = this.interruptedWith.has(sessionId);
1755
+ // Build final assistant turn
1756
+ const finalContent = this.compactContentBlocks([...turnState.blocks], turnState.result);
1757
+ const assistantTurn = {
1758
+ role: "assistant",
1759
+ content: finalContent,
1760
+ usage: turnState.usage,
1761
+ };
1762
+ const msgs = [...(current.messages ?? [])];
1763
+ const lastMsg = msgs[msgs.length - 1];
1764
+ if (lastMsg && lastMsg.role === "assistant")
1765
+ msgs[msgs.length - 1] = assistantTurn;
1766
+ else
1767
+ msgs.push(assistantTurn);
1768
+ const interruptPrompt = this.interruptedWith.get(sessionId);
1769
+ const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
1770
+ const finished = {
1771
+ ...current,
1772
+ status: keepRunning ? "running" : "idle",
1773
+ exitCode: keepRunning ? null : 0,
1774
+ endedAt: keepRunning ? null : new Date().toISOString(),
1775
+ output: turnState.result,
1776
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1777
+ messages: msgs,
1778
+ queuedMessages: interruptPrompt ? [] : current.queuedMessages,
1779
+ pendingEscalation: null,
1780
+ permissionBlocked: false,
1781
+ structuredState: {
1782
+ ...current.structuredState,
1783
+ model: turnState.model ?? current.structuredState?.model,
1784
+ inFlight: false,
1785
+ activeRequestId: null,
1786
+ lastError: null,
1787
+ },
1788
+ };
1789
+ this.sessions.set(sessionId, finished);
1790
+ this.storage.saveSession(finished);
1791
+ this.emitStructuredSnapshot(finished);
1792
+ if (!keepRunning)
1793
+ this.emitStructuredSnapshot(finished, "ended");
1794
+ if (killedForAskUserQuestion)
1795
+ return;
1796
+ if (interruptPrompt) {
1797
+ this.interruptedWith.delete(sessionId);
1798
+ setImmediate(() => {
1799
+ this.sendMessage(sessionId, interruptPrompt).catch((err) => {
1800
+ console.error("[WAND] sdk interrupt-and-send failed:", err);
1801
+ const afterFail = this.sessions.get(sessionId);
1802
+ if (afterFail) {
1803
+ const recovered = {
1804
+ ...afterFail,
1805
+ status: "idle",
1806
+ exitCode: 0,
1807
+ endedAt: new Date().toISOString(),
1808
+ structuredState: {
1809
+ ...afterFail.structuredState,
1810
+ inFlight: false,
1811
+ activeRequestId: null,
1812
+ },
1813
+ };
1814
+ this.sessions.set(sessionId, recovered);
1815
+ this.storage.saveSession(recovered);
1816
+ this.emitStructuredSnapshot(recovered);
1817
+ }
1818
+ });
1819
+ });
1820
+ return;
1821
+ }
1822
+ // Auto-continue after ExitPlanMode (same as CLI runner)
1823
+ const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
1824
+ if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
1825
+ setImmediate(() => {
1826
+ this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
1827
+ console.error("[WAND] sdk auto-continue after ExitPlanMode failed:", err);
1828
+ });
1829
+ });
1830
+ return;
1831
+ }
1832
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
1833
+ }
1834
+ // ---------------------------------------------------------------------------
1267
1835
  // Parsing helpers (unchanged logic, extracted from previous implementation)
1268
1836
  // ---------------------------------------------------------------------------
1269
1837
  extractAssistantMessage(message) {
@@ -1303,7 +1871,10 @@ export class StructuredSessionManager {
1303
1871
  if (previous
1304
1872
  && previous.type === "text"
1305
1873
  && block.type === "text") {
1306
- previous.text = `${previous.text}${block.text}`;
1874
+ // 用新对象替换 compacted 末尾,**不要**就地改 previous.text —— previous
1875
+ // 通常和调用方持有的 turnState.blocks 共享引用,原地 mutate 会让下次
1876
+ // syncSnapshot 把已合并的内容再合并一次,呈指数级复制。
1877
+ compacted[compacted.length - 1] = { type: "text", text: `${previous.text}${block.text}` };
1307
1878
  continue;
1308
1879
  }
1309
1880
  compacted.push(block);
@@ -1459,6 +2030,20 @@ export class StructuredSessionManager {
1459
2030
  }
1460
2031
  return value;
1461
2032
  }
2033
+ /** Extract usage from an SDKResultSuccess message (sdk runner). */
2034
+ extractSdkUsage(result) {
2035
+ const usage = result?.usage;
2036
+ const value = {
2037
+ inputTokens: typeof usage?.input_tokens === "number" ? usage.input_tokens : undefined,
2038
+ outputTokens: typeof usage?.output_tokens === "number" ? usage.output_tokens : undefined,
2039
+ cacheReadInputTokens: typeof usage?.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined,
2040
+ cacheCreationInputTokens: typeof usage?.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined,
2041
+ totalCostUsd: typeof result?.total_cost_usd === "number" ? result.total_cost_usd : undefined,
2042
+ };
2043
+ if (Object.values(value).every(v => v === undefined))
2044
+ return undefined;
2045
+ return value;
2046
+ }
1462
2047
  extractCodexUsage(source) {
1463
2048
  if (!source || typeof source !== "object")
1464
2049
  return undefined;