@co0ontty/wand 1.21.4 → 1.21.5

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",
@@ -977,7 +1084,7 @@ export class StructuredSessionManager {
977
1084
  this.emit({
978
1085
  type: "output",
979
1086
  sessionId,
980
- data: buildIncrementalStructuredPayload(current),
1087
+ data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}),
981
1088
  });
982
1089
  };
983
1090
  const scheduleEmit = () => {
@@ -1016,8 +1123,9 @@ export class StructuredSessionManager {
1016
1123
  };
1017
1124
  this.sessions.set(sessionId, patched);
1018
1125
  // 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);
1126
+ // latest assistant turn to the pre-stream snapshot. Throttled because
1127
+ // saveSession serializes the full messages array.
1128
+ this.saveStreamingSnapshot(patched);
1021
1129
  };
1022
1130
  const processLine = (line) => {
1023
1131
  const trimmed = line.trim();
@@ -1103,8 +1211,8 @@ export class StructuredSessionManager {
1103
1211
  stderr += text;
1104
1212
  });
1105
1213
  child.on("error", (error) => {
1106
- console.log("[WAND] claude -p child error:", error.message);
1107
1214
  this.pendingChildren.delete(sessionId);
1215
+ this.lastStreamSaveAt.delete(sessionId);
1108
1216
  if (emitTimer)
1109
1217
  clearTimeout(emitTimer);
1110
1218
  this.logger?.appendStructuredSpawn(sessionId, {
@@ -1117,8 +1225,8 @@ export class StructuredSessionManager {
1117
1225
  reject(error);
1118
1226
  });
1119
1227
  child.on("close", (code) => {
1120
- console.log("[WAND] claude -p child close code:", code, "stderr:", stderr.substring(0, 200));
1121
1228
  this.pendingChildren.delete(sessionId);
1229
+ this.lastStreamSaveAt.delete(sessionId);
1122
1230
  this.logger?.appendStructuredSpawn(sessionId, {
1123
1231
  kind: "claude-print-close",
1124
1232
  pid: child.pid ?? null,
@@ -1140,7 +1248,11 @@ export class StructuredSessionManager {
1140
1248
  reject(new Error("Session removed during execution."));
1141
1249
  return;
1142
1250
  }
1143
- if (code !== 0 && code !== null) {
1251
+ // 如果是用户主动中断(interruptedWith 里有新消息),claude -p 收到 SIGTERM
1252
+ // 可能以非零 exit code 退出(内部 handler 调了 exit(1))。这种情况属于正常
1253
+ // 中断流程,不应走失败路径——后续 interruptedWith 逻辑会发送新消息。
1254
+ const interruptedByUser = this.interruptedWith.has(sessionId);
1255
+ if (code !== 0 && code !== null && !interruptedByUser) {
1144
1256
  const errorText = stderr.trim() || `claude -p exited with code ${code}`;
1145
1257
  const failureTurn = {
1146
1258
  role: "assistant",
@@ -1232,11 +1344,28 @@ export class StructuredSessionManager {
1232
1344
  // 用户中断当前回复:保存部分回复后立即发送新消息。
1233
1345
  if (interruptPrompt) {
1234
1346
  this.interruptedWith.delete(sessionId);
1235
- console.log("[WAND] interrupt-and-send for session:", sessionId, "prompt:", interruptPrompt.substring(0, 50));
1236
1347
  resolve();
1237
1348
  setImmediate(() => {
1238
1349
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
1239
1350
  console.error("[WAND] interrupt-and-send failed:", err);
1351
+ // 续接失败:把状态回滚到 idle,让用户可以重新输入而不是卡在 running 状态
1352
+ const afterFail = this.sessions.get(sessionId);
1353
+ if (afterFail) {
1354
+ const recovered = {
1355
+ ...afterFail,
1356
+ status: "idle",
1357
+ exitCode: 0,
1358
+ endedAt: new Date().toISOString(),
1359
+ structuredState: {
1360
+ ...afterFail.structuredState,
1361
+ inFlight: false,
1362
+ activeRequestId: null,
1363
+ },
1364
+ };
1365
+ this.sessions.set(sessionId, recovered);
1366
+ this.storage.saveSession(recovered);
1367
+ this.emitStructuredSnapshot(recovered);
1368
+ }
1240
1369
  });
1241
1370
  });
1242
1371
  return;
@@ -1247,7 +1376,6 @@ export class StructuredSessionManager {
1247
1376
  // so the plan is actually carried out.
1248
1377
  const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
1249
1378
  if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
1250
- console.log("[WAND] ExitPlanMode detected – auto-continuing plan execution for session:", sessionId);
1251
1379
  resolve();
1252
1380
  setImmediate(() => {
1253
1381
  this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
@@ -1264,6 +1392,396 @@ export class StructuredSessionManager {
1264
1392
  });
1265
1393
  }
1266
1394
  // ---------------------------------------------------------------------------
1395
+ // Streaming claude-agent-sdk execution
1396
+ // ---------------------------------------------------------------------------
1397
+ /**
1398
+ * Use @anthropic-ai/claude-agent-sdk instead of spawning claude -p directly.
1399
+ * The SDK still spawns the claude binary but provides typed AsyncGenerator<SDKMessage>
1400
+ * messages, so we skip NDJSON parsing. Options are 1:1 with the CLI flags.
1401
+ *
1402
+ * Streaming is enabled via includePartialMessages: true — the SDK emits
1403
+ * SDKPartialAssistantMessage (type: "stream_event") with BetaRawMessageStreamEvent
1404
+ * payloads for incremental text/thinking/tool_use updates, followed by a final
1405
+ * SDKAssistantMessage with the authoritative complete content.
1406
+ */
1407
+ runClaudeSdkStreaming(sessionId, session, prompt) {
1408
+ return new Promise((resolve, reject) => {
1409
+ void this._runClaudeSdkStreamingAsync(sessionId, session, prompt).then(resolve, reject);
1410
+ });
1411
+ }
1412
+ async _runClaudeSdkStreamingAsync(sessionId, session, prompt) {
1413
+ let sdkQuery;
1414
+ try {
1415
+ const sdkMod = await import("@anthropic-ai/claude-agent-sdk");
1416
+ sdkQuery = sdkMod.query;
1417
+ }
1418
+ catch {
1419
+ throw new Error("@anthropic-ai/claude-agent-sdk 未安装,无法使用 SDK runner。");
1420
+ }
1421
+ const abortController = new AbortController();
1422
+ this.pendingSdkAbort.set(sessionId, abortController);
1423
+ const isManaged = session.mode === "managed";
1424
+ let killedForAskUserQuestion = false;
1425
+ // Derive permission mode (mirrors buildPermissionArgs logic)
1426
+ const shouldBypass = (session.autoApprovePermissions ?? false) || session.mode === "full-access" || session.mode === "managed";
1427
+ const shouldAcceptEdits = session.mode === "auto-edit";
1428
+ let permissionMode = "default";
1429
+ let allowedToolsForRoot;
1430
+ if (!isRunningAsRoot()) {
1431
+ if (shouldBypass)
1432
+ permissionMode = "bypassPermissions";
1433
+ else if (shouldAcceptEdits)
1434
+ permissionMode = "acceptEdits";
1435
+ }
1436
+ else {
1437
+ // Root: acceptEdits + allowedTools (same workaround as CLI runner)
1438
+ if (shouldBypass || shouldAcceptEdits) {
1439
+ permissionMode = "acceptEdits";
1440
+ allowedToolsForRoot = ["Bash", "Edit", "Write", "Read", "Glob", "Grep", "NotebookEdit", "WebFetch", "WebSearch"];
1441
+ }
1442
+ }
1443
+ // System prompt additions
1444
+ const isChinese = this.config.language?.trim() === "中文";
1445
+ const systemPromptParts = [];
1446
+ if (isManaged) {
1447
+ systemPromptParts.push(isChinese
1448
+ ? "你正在完全托管的自主模式下运行。用户可能无法及时回复问题或确认。你必须独立做出所有决策——自行选择最佳方案,而不是向用户询问偏好、确认或澄清。如果有多种可行方案,选择你认为最合适的并继续执行。除非任务本身存在根本性的歧义且无法合理推断,否则不要等待用户输入。果断行动,自主决策。"
1449
+ : "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.");
1450
+ }
1451
+ const language = this.config.language?.trim();
1452
+ if (language) {
1453
+ systemPromptParts.push(isChinese
1454
+ ? "请使用中文回复。所有解释、注释和对话文本都使用中文。"
1455
+ : `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`);
1456
+ }
1457
+ const sdkOptions = {
1458
+ cwd: session.cwd,
1459
+ abortController,
1460
+ permissionMode,
1461
+ ...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
1462
+ ...(allowedToolsForRoot ? { allowedTools: allowedToolsForRoot } : {}),
1463
+ ...(isManaged ? { disallowedTools: ["AskUserQuestion"] } : {}),
1464
+ includePartialMessages: true,
1465
+ ...(systemPromptParts.length > 0 ? { appendSystemPrompt: systemPromptParts.join("\n\n") } : {}),
1466
+ };
1467
+ if (session.claudeSessionId)
1468
+ sdkOptions.resume = session.claudeSessionId;
1469
+ const modelChoice = session.selectedModel?.trim();
1470
+ if (modelChoice && modelChoice !== "default")
1471
+ sdkOptions.model = modelChoice;
1472
+ const turnState = {
1473
+ blocks: [],
1474
+ result: "",
1475
+ sessionId: null,
1476
+ model: undefined,
1477
+ usage: undefined,
1478
+ };
1479
+ // Tracks in-progress streaming blocks keyed by content_block index from stream_event
1480
+ const streamingBlockByIndex = new Map();
1481
+ let emitTimer = null;
1482
+ const flushEmit = () => {
1483
+ if (emitTimer) {
1484
+ clearTimeout(emitTimer);
1485
+ emitTimer = null;
1486
+ }
1487
+ const current = this.sessions.get(sessionId);
1488
+ if (!current)
1489
+ return;
1490
+ this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
1491
+ };
1492
+ const scheduleEmit = () => {
1493
+ if (!emitTimer)
1494
+ emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
1495
+ };
1496
+ // Rebuild ContentBlock[] from the in-progress streaming blocks map
1497
+ const rebuildStreamingBlocks = () => {
1498
+ const sorted = [...streamingBlockByIndex.entries()].sort((a, b) => a[0] - b[0]);
1499
+ const blocks = [];
1500
+ for (const [, sb] of sorted) {
1501
+ if (sb.type === "text") {
1502
+ blocks.push({ type: "text", text: sb.text });
1503
+ }
1504
+ else if (sb.type === "thinking") {
1505
+ blocks.push({ type: "thinking", thinking: sb.thinking });
1506
+ }
1507
+ else if (sb.type === "tool_use" && sb.id && sb.name) {
1508
+ let input = {};
1509
+ if (sb.finalized && sb.partialInput) {
1510
+ try {
1511
+ input = JSON.parse(sb.partialInput);
1512
+ }
1513
+ catch { /* partial json */ }
1514
+ }
1515
+ blocks.push({ type: "tool_use", id: sb.id, name: sb.name, input });
1516
+ }
1517
+ }
1518
+ return blocks;
1519
+ };
1520
+ const syncSnapshot = () => {
1521
+ const current = this.sessions.get(sessionId);
1522
+ if (!current)
1523
+ return;
1524
+ const inProgressTurn = {
1525
+ role: "assistant",
1526
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1527
+ usage: turnState.usage,
1528
+ };
1529
+ const msgs = [...(current.messages ?? [])];
1530
+ const lastMsg = msgs[msgs.length - 1];
1531
+ if (lastMsg && lastMsg.role === "assistant")
1532
+ msgs[msgs.length - 1] = inProgressTurn;
1533
+ else
1534
+ msgs.push(inProgressTurn);
1535
+ const patched = {
1536
+ ...current,
1537
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1538
+ messages: msgs,
1539
+ output: turnState.result || current.output,
1540
+ structuredState: {
1541
+ ...current.structuredState,
1542
+ model: turnState.model ?? current.structuredState?.model,
1543
+ },
1544
+ };
1545
+ this.sessions.set(sessionId, patched);
1546
+ this.saveStreamingSnapshot(patched);
1547
+ };
1548
+ const spawnedAt = new Date().toISOString();
1549
+ this.logger?.appendStructuredSpawn(sessionId, {
1550
+ kind: "claude-sdk",
1551
+ provider: "claude",
1552
+ cwd: session.cwd,
1553
+ permissionMode,
1554
+ prompt: prompt.slice(0, 2048),
1555
+ promptLength: prompt.length,
1556
+ claudeSessionId: session.claudeSessionId,
1557
+ spawnedAt,
1558
+ });
1559
+ try {
1560
+ for await (const msg of sdkQuery({ prompt, options: sdkOptions })) {
1561
+ if (abortController.signal.aborted)
1562
+ break;
1563
+ // Incremental streaming events (opt-in via includePartialMessages: true)
1564
+ if (msg.type === "stream_event") {
1565
+ const ev = msg.event;
1566
+ if (ev.type === "content_block_start") {
1567
+ const cb = ev.content_block;
1568
+ const blockType = cb.type;
1569
+ if (blockType === "text" || blockType === "thinking" || blockType === "tool_use") {
1570
+ streamingBlockByIndex.set(ev.index, {
1571
+ type: blockType,
1572
+ id: typeof cb.id === "string" ? cb.id : undefined,
1573
+ name: typeof cb.name === "string" ? cb.name : undefined,
1574
+ text: typeof cb.text === "string" ? cb.text : "",
1575
+ thinking: typeof cb.thinking === "string" ? cb.thinking : "",
1576
+ partialInput: "",
1577
+ finalized: false,
1578
+ });
1579
+ turnState.blocks = rebuildStreamingBlocks();
1580
+ syncSnapshot();
1581
+ scheduleEmit();
1582
+ }
1583
+ }
1584
+ else if (ev.type === "content_block_delta") {
1585
+ const sb = streamingBlockByIndex.get(ev.index);
1586
+ if (sb) {
1587
+ const delta = ev.delta;
1588
+ if (delta.type === "text_delta" && typeof delta.text === "string") {
1589
+ sb.text += delta.text;
1590
+ turnState.result = sb.text;
1591
+ }
1592
+ else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") {
1593
+ sb.thinking += delta.thinking;
1594
+ }
1595
+ else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
1596
+ sb.partialInput += delta.partial_json;
1597
+ }
1598
+ turnState.blocks = rebuildStreamingBlocks();
1599
+ syncSnapshot();
1600
+ scheduleEmit();
1601
+ }
1602
+ }
1603
+ else if (ev.type === "content_block_stop") {
1604
+ const sb = streamingBlockByIndex.get(ev.index);
1605
+ if (sb) {
1606
+ sb.finalized = true;
1607
+ turnState.blocks = rebuildStreamingBlocks();
1608
+ syncSnapshot();
1609
+ scheduleEmit();
1610
+ }
1611
+ }
1612
+ continue;
1613
+ }
1614
+ // Complete assistant turn — authoritative content replaces streaming blocks
1615
+ if (msg.type === "assistant") {
1616
+ const assistantMsg = msg;
1617
+ const extracted = this.extractAssistantMessage(assistantMsg.message);
1618
+ // Keep tool_result blocks from previous user messages, replace streaming assistant content
1619
+ const toolResults = turnState.blocks.filter(b => b.type === "tool_result");
1620
+ turnState.blocks = [...extracted.content, ...toolResults];
1621
+ streamingBlockByIndex.clear();
1622
+ if (assistantMsg.session_id)
1623
+ turnState.sessionId = assistantMsg.session_id;
1624
+ syncSnapshot();
1625
+ scheduleEmit();
1626
+ // Non-managed mode: detect AskUserQuestion, abort to let user answer
1627
+ if (!isManaged && !killedForAskUserQuestion) {
1628
+ const askBlock = extracted.content.find((b) => b.type === "tool_use" && b.name === "AskUserQuestion");
1629
+ if (askBlock) {
1630
+ killedForAskUserQuestion = true;
1631
+ flushEmit();
1632
+ abortController.abort();
1633
+ }
1634
+ }
1635
+ continue;
1636
+ }
1637
+ // Tool results fed back from the claude subprocess
1638
+ if (msg.type === "user") {
1639
+ const userMsg = msg;
1640
+ const content = Array.isArray(userMsg.message?.content) ? userMsg.message.content : [];
1641
+ for (const block of content) {
1642
+ const b = block;
1643
+ if (b?.type === "tool_result") {
1644
+ turnState.blocks.push({
1645
+ type: "tool_result",
1646
+ tool_use_id: typeof b.tool_use_id === "string" ? b.tool_use_id : "",
1647
+ content: this.normalizeToolResultContent(b.content),
1648
+ is_error: b.is_error === true,
1649
+ });
1650
+ }
1651
+ }
1652
+ syncSnapshot();
1653
+ scheduleEmit();
1654
+ continue;
1655
+ }
1656
+ // Final result — capture session_id, usage, model
1657
+ if (msg.type === "result") {
1658
+ const resultMsg = msg;
1659
+ if (typeof resultMsg.result === "string")
1660
+ turnState.result = resultMsg.result.trim();
1661
+ if (typeof resultMsg.session_id === "string")
1662
+ turnState.sessionId = resultMsg.session_id;
1663
+ turnState.model = this.extractModelName(resultMsg.modelUsage) ?? turnState.model;
1664
+ turnState.usage = this.extractSdkUsage(resultMsg);
1665
+ syncSnapshot();
1666
+ scheduleEmit();
1667
+ continue;
1668
+ }
1669
+ }
1670
+ }
1671
+ catch (err) {
1672
+ // AbortError from abortController.abort() is intentional — fall through to finish logic
1673
+ const isAbort = abortController.signal.aborted || (err instanceof Error && err.name === "AbortError");
1674
+ if (!isAbort) {
1675
+ this.pendingSdkAbort.delete(sessionId);
1676
+ this.lastStreamSaveAt.delete(sessionId);
1677
+ if (emitTimer)
1678
+ clearTimeout(emitTimer);
1679
+ this.logger?.appendStructuredSpawn(sessionId, {
1680
+ kind: "claude-sdk-error",
1681
+ spawnedAt,
1682
+ closedAt: new Date().toISOString(),
1683
+ error: err instanceof Error ? err.message : String(err),
1684
+ });
1685
+ throw err;
1686
+ }
1687
+ }
1688
+ // Cleanup
1689
+ this.pendingSdkAbort.delete(sessionId);
1690
+ this.lastStreamSaveAt.delete(sessionId);
1691
+ if (emitTimer)
1692
+ clearTimeout(emitTimer);
1693
+ flushEmit();
1694
+ const current = this.sessions.get(sessionId);
1695
+ if (!current)
1696
+ throw new Error("Session removed during execution.");
1697
+ this.logger?.appendStructuredSpawn(sessionId, {
1698
+ kind: "claude-sdk-close",
1699
+ spawnedAt,
1700
+ closedAt: new Date().toISOString(),
1701
+ killedForAskUserQuestion,
1702
+ sessionId: turnState.sessionId,
1703
+ });
1704
+ const interruptedByUser = this.interruptedWith.has(sessionId);
1705
+ // Build final assistant turn
1706
+ const finalContent = this.compactContentBlocks([...turnState.blocks], turnState.result);
1707
+ const assistantTurn = {
1708
+ role: "assistant",
1709
+ content: finalContent,
1710
+ usage: turnState.usage,
1711
+ };
1712
+ const msgs = [...(current.messages ?? [])];
1713
+ const lastMsg = msgs[msgs.length - 1];
1714
+ if (lastMsg && lastMsg.role === "assistant")
1715
+ msgs[msgs.length - 1] = assistantTurn;
1716
+ else
1717
+ msgs.push(assistantTurn);
1718
+ const interruptPrompt = this.interruptedWith.get(sessionId);
1719
+ const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
1720
+ const finished = {
1721
+ ...current,
1722
+ status: keepRunning ? "running" : "idle",
1723
+ exitCode: keepRunning ? null : 0,
1724
+ endedAt: keepRunning ? null : new Date().toISOString(),
1725
+ output: turnState.result,
1726
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1727
+ messages: msgs,
1728
+ queuedMessages: interruptPrompt ? [] : current.queuedMessages,
1729
+ pendingEscalation: null,
1730
+ permissionBlocked: false,
1731
+ structuredState: {
1732
+ ...current.structuredState,
1733
+ model: turnState.model ?? current.structuredState?.model,
1734
+ inFlight: false,
1735
+ activeRequestId: null,
1736
+ lastError: null,
1737
+ },
1738
+ };
1739
+ this.sessions.set(sessionId, finished);
1740
+ this.storage.saveSession(finished);
1741
+ this.emitStructuredSnapshot(finished);
1742
+ if (!keepRunning)
1743
+ this.emitStructuredSnapshot(finished, "ended");
1744
+ if (killedForAskUserQuestion)
1745
+ return;
1746
+ if (interruptPrompt) {
1747
+ this.interruptedWith.delete(sessionId);
1748
+ setImmediate(() => {
1749
+ this.sendMessage(sessionId, interruptPrompt).catch((err) => {
1750
+ console.error("[WAND] sdk interrupt-and-send failed:", err);
1751
+ const afterFail = this.sessions.get(sessionId);
1752
+ if (afterFail) {
1753
+ const recovered = {
1754
+ ...afterFail,
1755
+ status: "idle",
1756
+ exitCode: 0,
1757
+ endedAt: new Date().toISOString(),
1758
+ structuredState: {
1759
+ ...afterFail.structuredState,
1760
+ inFlight: false,
1761
+ activeRequestId: null,
1762
+ },
1763
+ };
1764
+ this.sessions.set(sessionId, recovered);
1765
+ this.storage.saveSession(recovered);
1766
+ this.emitStructuredSnapshot(recovered);
1767
+ }
1768
+ });
1769
+ });
1770
+ return;
1771
+ }
1772
+ // Auto-continue after ExitPlanMode (same as CLI runner)
1773
+ const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
1774
+ if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
1775
+ setImmediate(() => {
1776
+ this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
1777
+ console.error("[WAND] sdk auto-continue after ExitPlanMode failed:", err);
1778
+ });
1779
+ });
1780
+ return;
1781
+ }
1782
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
1783
+ }
1784
+ // ---------------------------------------------------------------------------
1267
1785
  // Parsing helpers (unchanged logic, extracted from previous implementation)
1268
1786
  // ---------------------------------------------------------------------------
1269
1787
  extractAssistantMessage(message) {
@@ -1459,6 +1977,20 @@ export class StructuredSessionManager {
1459
1977
  }
1460
1978
  return value;
1461
1979
  }
1980
+ /** Extract usage from an SDKResultSuccess message (sdk runner). */
1981
+ extractSdkUsage(result) {
1982
+ const usage = result?.usage;
1983
+ const value = {
1984
+ inputTokens: typeof usage?.input_tokens === "number" ? usage.input_tokens : undefined,
1985
+ outputTokens: typeof usage?.output_tokens === "number" ? usage.output_tokens : undefined,
1986
+ cacheReadInputTokens: typeof usage?.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined,
1987
+ cacheCreationInputTokens: typeof usage?.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined,
1988
+ totalCostUsd: typeof result?.total_cost_usd === "number" ? result.total_cost_usd : undefined,
1989
+ };
1990
+ if (Object.values(value).every(v => v === undefined))
1991
+ return undefined;
1992
+ return value;
1993
+ }
1462
1994
  extractCodexUsage(source) {
1463
1995
  if (!source || typeof source !== "object")
1464
1996
  return undefined;