@co0ontty/wand 1.20.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,7 +1,25 @@
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";
5
+ function defaultStructuredRunner(provider) {
6
+ return provider === "codex" ? "codex-cli-exec" : "claude-cli-print";
7
+ }
8
+ function defaultStructuredState(provider, runner = defaultStructuredRunner(provider)) {
9
+ return {
10
+ provider,
11
+ runner,
12
+ lastError: null,
13
+ inFlight: false,
14
+ activeRequestId: null,
15
+ };
16
+ }
4
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;
5
23
  const ARCHIVE_AFTER_MS = 1000 * 60 * 60 * 24;
6
24
  function isRunningAsRoot() {
7
25
  return process.getuid?.() === 0 || process.geteuid?.() === 0;
@@ -70,37 +88,55 @@ function buildStructuredOutputPayload(snapshot) {
70
88
  structuredState: snapshot.structuredState,
71
89
  };
72
90
  }
73
- function buildIncrementalStructuredPayload(snapshot) {
91
+ function buildIncrementalStructuredPayload(snapshot, cardDefaults) {
74
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;
75
98
  return {
76
99
  incremental: true,
77
100
  queuedMessages: snapshot.queuedMessages,
78
101
  sessionKind: "structured",
79
102
  structuredState: snapshot.structuredState,
80
- lastMessage: messages.length > 0 ? messages[messages.length - 1] : undefined,
103
+ lastMessage,
81
104
  messageCount: messages.length,
82
105
  };
83
106
  }
84
107
  export class StructuredSessionManager {
85
108
  storage;
86
109
  config;
110
+ logger;
87
111
  sessions = new Map();
88
112
  pendingChildren = new Map();
113
+ pendingSdkAbort = new Map();
89
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();
90
125
  emitEvent = null;
91
126
  archiveTimer = null;
92
- constructor(storage, config) {
127
+ constructor(storage, config, logger = null) {
93
128
  this.storage = storage;
94
129
  this.config = config;
130
+ this.logger = logger;
95
131
  for (const snapshot of this.storage.loadSessions()) {
96
132
  if ((snapshot.sessionKind ?? "pty") !== "structured")
97
133
  continue;
98
- const restoredStatus = snapshot.status === "running" ? "stopped" : snapshot.status;
134
+ const restoredStatus = snapshot.status === "running" ? "idle" : snapshot.status;
99
135
  const restored = {
100
136
  ...snapshot,
101
137
  sessionKind: "structured",
102
- provider: snapshot.provider ?? "claude",
103
- runner: snapshot.runner ?? "claude-cli-print",
138
+ provider: snapshot.provider ?? snapshot.structuredState?.provider ?? "claude",
139
+ runner: snapshot.runner ?? snapshot.structuredState?.runner ?? defaultStructuredRunner(snapshot.provider ?? snapshot.structuredState?.provider ?? "claude"),
104
140
  status: restoredStatus,
105
141
  autoApprovePermissions: snapshot.autoApprovePermissions ?? shouldAutoApproveForMode(snapshot.mode),
106
142
  approvalStats: snapshot.approvalStats ?? { tool: 0, command: 0, file: 0, total: 0 },
@@ -109,7 +145,7 @@ export class StructuredSessionManager {
109
145
  permissionBlocked: false,
110
146
  structuredState: {
111
147
  provider: snapshot.structuredState?.provider ?? snapshot.provider ?? "claude",
112
- runner: snapshot.runner ?? "claude-cli-print",
148
+ runner: snapshot.runner ?? snapshot.structuredState?.runner ?? defaultStructuredRunner(snapshot.structuredState?.provider ?? snapshot.provider ?? "claude"),
113
149
  model: snapshot.structuredState?.model ?? snapshot.selectedModel ?? undefined,
114
150
  lastError: snapshot.structuredState?.lastError ?? null,
115
151
  inFlight: false,
@@ -148,6 +184,20 @@ export class StructuredSessionManager {
148
184
  setEventEmitter(emitEvent) {
149
185
  this.emitEvent = emitEvent;
150
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
+ }
151
201
  list() {
152
202
  return Array.from(this.sessions.values())
153
203
  .map(withSummary)
@@ -171,6 +221,8 @@ export class StructuredSessionManager {
171
221
  const id = randomUUID();
172
222
  const startedAt = new Date().toISOString();
173
223
  const prompt = options.prompt?.trim();
224
+ const provider = options.provider === "codex" ? "codex" : "claude";
225
+ const runner = options.runner ?? defaultStructuredRunner(provider);
174
226
  const worktreeSetup = options.worktreeEnabled
175
227
  ? prepareSessionWorktree({ cwd: options.cwd, sessionId: id })
176
228
  : null;
@@ -178,14 +230,14 @@ export class StructuredSessionManager {
178
230
  const snapshot = {
179
231
  id,
180
232
  sessionKind: "structured",
181
- provider: "claude",
182
- runner: options.runner ?? "claude-cli-print",
183
- command: "claude -p --output-format stream-json",
233
+ provider,
234
+ runner,
235
+ command: provider === "codex" ? "codex exec --json" : "claude -p --output-format stream-json",
184
236
  cwd: worktreeSetup?.cwd ?? options.cwd,
185
237
  mode: options.mode,
186
238
  worktreeEnabled: Boolean(worktreeSetup),
187
239
  worktree: worktreeSetup?.worktree ?? null,
188
- status: "running",
240
+ status: "idle",
189
241
  exitCode: null,
190
242
  startedAt,
191
243
  endedAt: null,
@@ -196,8 +248,8 @@ export class StructuredSessionManager {
196
248
  messages: [],
197
249
  queuedMessages: [],
198
250
  structuredState: {
199
- provider: "claude",
200
- runner: options.runner ?? "claude-cli-print",
251
+ provider,
252
+ runner,
201
253
  model: selectedModel ?? undefined,
202
254
  inFlight: false,
203
255
  activeRequestId: null,
@@ -211,9 +263,6 @@ export class StructuredSessionManager {
211
263
  this.sessions.set(id, snapshot);
212
264
  this.storage.saveSession(snapshot);
213
265
  this.emit({ type: "started", sessionId: id, data: { sessionKind: "structured" } });
214
- if (prompt) {
215
- void this.sendMessage(id, prompt);
216
- }
217
266
  return snapshot;
218
267
  }
219
268
  async sendMessage(id, input, opts) {
@@ -221,7 +270,23 @@ export class StructuredSessionManager {
221
270
  const prompt = input.trim();
222
271
  if (!prompt)
223
272
  return session;
224
- 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
+ }
225
290
  if (session.structuredState?.inFlight) {
226
291
  const child = this.pendingChildren.get(id);
227
292
  const childAlive = child && !child.killed && child.exitCode === null;
@@ -230,7 +295,7 @@ export class StructuredSessionManager {
230
295
  this.pendingChildren.delete(id);
231
296
  const recovered = {
232
297
  ...session,
233
- status: "stopped",
298
+ status: "idle",
234
299
  endedAt: session.endedAt ?? new Date().toISOString(),
235
300
  structuredState: {
236
301
  ...session.structuredState,
@@ -248,6 +313,9 @@ export class StructuredSessionManager {
248
313
  child.kill("SIGTERM");
249
314
  }
250
315
  catch (_err) { /* ignore */ }
316
+ const sdkAbort = this.pendingSdkAbort.get(id);
317
+ if (sdkAbort)
318
+ sdkAbort.abort();
251
319
  return session;
252
320
  }
253
321
  else {
@@ -293,7 +361,7 @@ export class StructuredSessionManager {
293
361
  endedAt: null,
294
362
  messages: [...(session.messages ?? []), userTurn],
295
363
  structuredState: {
296
- ...(session.structuredState ?? { provider: "claude", runner: "claude-cli-print", lastError: null, inFlight: false, activeRequestId: null }),
364
+ ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
297
365
  inFlight: true,
298
366
  activeRequestId: requestId,
299
367
  lastError: null,
@@ -313,7 +381,15 @@ export class StructuredSessionManager {
313
381
  ? `[对刚才 AskUserQuestion 工具的回答 — 结构化模式不支持工具结果回传,下面是用户从选项中的选择]\n${prompt}`
314
382
  : prompt;
315
383
  try {
316
- await this.runClaudeStreaming(id, updated, claudePrompt);
384
+ if ((updated.provider ?? "claude") === "codex") {
385
+ await this.runCodexStreaming(id, updated, prompt);
386
+ }
387
+ else if (this.config.structuredRunner === "sdk") {
388
+ await this.runClaudeSdkStreaming(id, updated, claudePrompt);
389
+ }
390
+ else {
391
+ await this.runClaudeStreaming(id, updated, claudePrompt);
392
+ }
317
393
  const finished = this.requireSession(id);
318
394
  return finished;
319
395
  }
@@ -364,7 +440,7 @@ export class StructuredSessionManager {
364
440
  ...session,
365
441
  selectedModel: normalized,
366
442
  structuredState: {
367
- ...(session.structuredState ?? { provider: "claude", runner: "claude-cli-print", inFlight: false, activeRequestId: null, lastError: null }),
443
+ ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
368
444
  model: normalized ?? undefined,
369
445
  },
370
446
  };
@@ -421,6 +497,11 @@ export class StructuredSessionManager {
421
497
  child.kill();
422
498
  this.pendingChildren.delete(id);
423
499
  }
500
+ const sdkAbort = this.pendingSdkAbort.get(id);
501
+ if (sdkAbort) {
502
+ sdkAbort.abort();
503
+ this.pendingSdkAbort.delete(id);
504
+ }
424
505
  const stopped = {
425
506
  ...session,
426
507
  status: "stopped",
@@ -428,7 +509,7 @@ export class StructuredSessionManager {
428
509
  pendingEscalation: null,
429
510
  permissionBlocked: false,
430
511
  structuredState: {
431
- ...(session.structuredState ?? { provider: "claude", runner: "claude-cli-print", lastError: null, inFlight: false, activeRequestId: null }),
512
+ ...(session.structuredState ?? defaultStructuredState(session.provider ?? "claude", session.runner)),
432
513
  inFlight: false,
433
514
  activeRequestId: null,
434
515
  },
@@ -444,8 +525,15 @@ export class StructuredSessionManager {
444
525
  child.kill();
445
526
  this.pendingChildren.delete(id);
446
527
  }
528
+ const sdkAbort = this.pendingSdkAbort.get(id);
529
+ if (sdkAbort) {
530
+ sdkAbort.abort();
531
+ this.pendingSdkAbort.delete(id);
532
+ }
447
533
  this.sessions.delete(id);
534
+ this.lastStreamSaveAt.delete(id);
448
535
  this.storage.deleteSession(id);
536
+ this.logger?.deleteSession(id);
449
537
  }
450
538
  // ---------------------------------------------------------------------------
451
539
  // Private helpers
@@ -507,6 +595,17 @@ export class StructuredSessionManager {
507
595
  }
508
596
  catch (error) {
509
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
+ }
510
609
  }
511
610
  }
512
611
  emit(event) {
@@ -580,6 +679,314 @@ export class StructuredSessionManager {
580
679
  }
581
680
  return [];
582
681
  }
682
+ buildCodexArgs(session) {
683
+ const args = ["exec", "--json", "--color", "never"];
684
+ const shouldBypass = session.autoApprovePermissions === true || session.mode === "full-access" || session.mode === "managed";
685
+ if (shouldBypass) {
686
+ args.push("--dangerously-bypass-approvals-and-sandbox");
687
+ }
688
+ else if (session.mode === "auto-edit" || session.mode === "agent" || session.mode === "agent-max") {
689
+ args.push("--sandbox", "workspace-write");
690
+ }
691
+ else {
692
+ args.push("--sandbox", "read-only");
693
+ }
694
+ args.push("--skip-git-repo-check");
695
+ const modelChoice = session.selectedModel?.trim();
696
+ if (modelChoice && modelChoice !== "default") {
697
+ args.push("--model", modelChoice);
698
+ }
699
+ if (session.claudeSessionId) {
700
+ args.push("resume", session.claudeSessionId, "-");
701
+ }
702
+ else {
703
+ args.push("-");
704
+ }
705
+ return args;
706
+ }
707
+ // ---------------------------------------------------------------------------
708
+ // Streaming codex exec --json execution
709
+ // ---------------------------------------------------------------------------
710
+ runCodexStreaming(sessionId, session, prompt) {
711
+ return new Promise((resolve, reject) => {
712
+ const args = this.buildCodexArgs(session);
713
+ const spawnedAt = new Date().toISOString();
714
+ const child = spawn("codex", args, {
715
+ cwd: session.cwd,
716
+ env: process.env,
717
+ stdio: ["pipe", "pipe", "pipe"],
718
+ });
719
+ this.logger?.appendStructuredSpawn(sessionId, {
720
+ kind: "codex-exec",
721
+ provider: "codex",
722
+ pid: child.pid ?? null,
723
+ cwd: session.cwd,
724
+ args,
725
+ prompt: prompt.slice(0, 2048),
726
+ promptLength: prompt.length,
727
+ threadId: session.claudeSessionId,
728
+ spawnedAt,
729
+ });
730
+ this.pendingChildren.set(sessionId, child);
731
+ child.stdin?.end(prompt);
732
+ const turnState = {
733
+ blocks: [],
734
+ result: "",
735
+ sessionId: session.claudeSessionId,
736
+ model: session.selectedModel ?? session.structuredState?.model,
737
+ usage: undefined,
738
+ };
739
+ let lineBuf = "";
740
+ let stderr = "";
741
+ let emitTimer = null;
742
+ // codex 把所有错误(包括重试日志和最终失败原因)都通过 stdout 的 NDJSON 事件
743
+ // 输出,stderr 通常是空的。我们在 processLine 里收集这些,然后在 close 中
744
+ // 决定真正的报错文本。
745
+ const codexErrors = [];
746
+ let codexTurnFailed = null;
747
+ const flushEmit = () => {
748
+ if (emitTimer) {
749
+ clearTimeout(emitTimer);
750
+ emitTimer = null;
751
+ }
752
+ const current = this.sessions.get(sessionId);
753
+ if (!current)
754
+ return;
755
+ this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
756
+ };
757
+ const scheduleEmit = () => {
758
+ if (!emitTimer)
759
+ emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
760
+ };
761
+ const syncSnapshot = () => {
762
+ const current = this.sessions.get(sessionId);
763
+ if (!current)
764
+ return;
765
+ const inProgressTurn = {
766
+ role: "assistant",
767
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
768
+ usage: turnState.usage,
769
+ };
770
+ const msgs = [...(current.messages ?? [])];
771
+ const lastMsg = msgs[msgs.length - 1];
772
+ if (lastMsg && lastMsg.role === "assistant") {
773
+ msgs[msgs.length - 1] = inProgressTurn;
774
+ }
775
+ else {
776
+ msgs.push(inProgressTurn);
777
+ }
778
+ const patched = {
779
+ ...current,
780
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
781
+ messages: msgs,
782
+ output: turnState.result || current.output,
783
+ structuredState: {
784
+ ...current.structuredState,
785
+ model: turnState.model ?? current.structuredState?.model,
786
+ },
787
+ };
788
+ this.sessions.set(sessionId, patched);
789
+ this.saveStreamingSnapshot(patched);
790
+ };
791
+ const processLine = (line) => {
792
+ const trimmed = line.trim();
793
+ if (!trimmed)
794
+ return;
795
+ let parsed;
796
+ try {
797
+ parsed = JSON.parse(trimmed);
798
+ }
799
+ catch {
800
+ return;
801
+ }
802
+ this.logger?.appendStreamEvent(sessionId, parsed);
803
+ if (parsed?.type === "thread.started" && typeof parsed.thread_id === "string") {
804
+ turnState.sessionId = parsed.thread_id;
805
+ syncSnapshot();
806
+ return;
807
+ }
808
+ if (parsed?.type === "item.started" && parsed.item) {
809
+ const block = this.extractCodexItemBlock(parsed.item, false);
810
+ if (block) {
811
+ turnState.blocks.push(block);
812
+ syncSnapshot();
813
+ scheduleEmit();
814
+ }
815
+ return;
816
+ }
817
+ if (parsed?.type === "item.completed" && parsed.item) {
818
+ const block = this.extractCodexItemBlock(parsed.item, true);
819
+ if (block) {
820
+ if (block.type === "text")
821
+ turnState.result = block.text;
822
+ this.upsertCodexBlock(turnState.blocks, block);
823
+ syncSnapshot();
824
+ scheduleEmit();
825
+ }
826
+ return;
827
+ }
828
+ if (parsed?.type === "turn.completed") {
829
+ turnState.usage = this.extractCodexUsage(parsed.usage) ?? turnState.usage;
830
+ syncSnapshot();
831
+ scheduleEmit();
832
+ return;
833
+ }
834
+ if (parsed?.type === "error") {
835
+ const message = typeof parsed.message === "string" ? parsed.message : "";
836
+ if (message)
837
+ codexErrors.push(message);
838
+ return;
839
+ }
840
+ if (parsed?.type === "turn.failed") {
841
+ const errObj = (parsed.error && typeof parsed.error === "object") ? parsed.error : null;
842
+ const message = (errObj && typeof errObj.message === "string" && errObj.message)
843
+ || (typeof parsed.message === "string" ? parsed.message : "")
844
+ || "codex turn failed";
845
+ codexTurnFailed = message;
846
+ return;
847
+ }
848
+ };
849
+ child.stdout?.on("data", (chunk) => {
850
+ const text = chunk.toString();
851
+ this.logger?.appendStructuredStdout(sessionId, text);
852
+ lineBuf += text;
853
+ const lines = lineBuf.split("\n");
854
+ lineBuf = lines.pop() ?? "";
855
+ for (const line of lines)
856
+ processLine(line);
857
+ });
858
+ child.stderr?.on("data", (chunk) => {
859
+ const text = chunk.toString();
860
+ this.logger?.appendStructuredStderr(sessionId, text);
861
+ stderr += text;
862
+ });
863
+ child.on("error", (error) => {
864
+ this.pendingChildren.delete(sessionId);
865
+ this.lastStreamSaveAt.delete(sessionId);
866
+ if (emitTimer)
867
+ clearTimeout(emitTimer);
868
+ this.logger?.appendStructuredSpawn(sessionId, {
869
+ kind: "codex-exec-error",
870
+ pid: child.pid ?? null,
871
+ spawnedAt,
872
+ closedAt: new Date().toISOString(),
873
+ spawnError: error.message,
874
+ });
875
+ reject(error);
876
+ });
877
+ child.on("close", (code) => {
878
+ this.pendingChildren.delete(sessionId);
879
+ this.lastStreamSaveAt.delete(sessionId);
880
+ if (lineBuf.trim()) {
881
+ processLine(lineBuf);
882
+ lineBuf = "";
883
+ }
884
+ flushEmit();
885
+ const closedAt = new Date().toISOString();
886
+ this.logger?.appendStructuredSpawn(sessionId, {
887
+ kind: "codex-exec-close",
888
+ pid: child.pid ?? null,
889
+ spawnedAt,
890
+ closedAt,
891
+ exitCode: code,
892
+ stderrTail: stderr.slice(-2048),
893
+ codexErrors,
894
+ codexTurnFailed,
895
+ });
896
+ const current = this.sessions.get(sessionId);
897
+ if (!current) {
898
+ reject(new Error("Session removed during execution."));
899
+ return;
900
+ }
901
+ // 主动中断时(interruptedWith 里有新消息),不走失败路径
902
+ const interruptedByUser = this.interruptedWith.has(sessionId);
903
+ const interruptPrompt = this.interruptedWith.get(sessionId);
904
+ // codex 把模型/网络/沙箱等错误写到 stdout 的 NDJSON 流(type: error / turn.failed),
905
+ // 而不是 stderr。我们以 turn.failed 的 message 为准,其次是最后一个 error 事件。
906
+ const codexFailed = codexTurnFailed !== null;
907
+ if ((codexFailed || (code !== 0 && code !== null)) && !interruptedByUser) {
908
+ const errorText = (codexTurnFailed && codexTurnFailed.trim())
909
+ || (codexErrors.length > 0 ? codexErrors[codexErrors.length - 1] : "")
910
+ || stderr.trim()
911
+ || `codex exec exited with code ${code}`;
912
+ const exitForSnapshot = typeof code === "number" ? code : 1;
913
+ const failed = this.finishStructuredFailure(current, exitForSnapshot, errorText, turnState);
914
+ this.sessions.set(sessionId, failed);
915
+ this.storage.saveSession(failed);
916
+ this.emitStructuredSnapshot(failed);
917
+ this.emitStructuredSnapshot(failed, "ended");
918
+ reject(new Error(errorText));
919
+ return;
920
+ }
921
+ const assistantTurn = {
922
+ role: "assistant",
923
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
924
+ usage: turnState.usage,
925
+ };
926
+ const msgs = [...(current.messages ?? [])];
927
+ const lastMsg = msgs[msgs.length - 1];
928
+ if (lastMsg && lastMsg.role === "assistant")
929
+ msgs[msgs.length - 1] = assistantTurn;
930
+ else
931
+ msgs.push(assistantTurn);
932
+ const keepRunning = !!interruptPrompt;
933
+ const finished = {
934
+ ...current,
935
+ status: keepRunning ? "running" : "idle",
936
+ exitCode: keepRunning ? null : 0,
937
+ endedAt: keepRunning ? null : new Date().toISOString(),
938
+ output: turnState.result,
939
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
940
+ messages: msgs,
941
+ queuedMessages: interruptPrompt ? [] : current.queuedMessages,
942
+ pendingEscalation: null,
943
+ permissionBlocked: false,
944
+ structuredState: {
945
+ ...current.structuredState,
946
+ model: turnState.model ?? current.structuredState?.model,
947
+ inFlight: false,
948
+ activeRequestId: null,
949
+ lastError: null,
950
+ },
951
+ };
952
+ this.sessions.set(sessionId, finished);
953
+ this.storage.saveSession(finished);
954
+ this.emitStructuredSnapshot(finished);
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
+ }
985
+ resolve();
986
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
987
+ });
988
+ });
989
+ }
583
990
  // ---------------------------------------------------------------------------
584
991
  // Streaming claude -p execution
585
992
  // ---------------------------------------------------------------------------
@@ -597,7 +1004,6 @@ export class StructuredSessionManager {
597
1004
  runClaudeStreaming(sessionId, session, prompt) {
598
1005
  return new Promise((resolve, reject) => {
599
1006
  const args = ["-p", "--verbose", "--output-format", "stream-json"];
600
- console.log("[WAND] runClaudeStreaming sessionId:", sessionId, "mode:", session.mode, "claudeSessionId:", session.claudeSessionId);
601
1007
  // Add permission args based on mode + autoApprovePermissions toggle
602
1008
  const permArgs = this.buildPermissionArgs(session.mode, session.autoApprovePermissions ?? false);
603
1009
  args.push(...permArgs);
@@ -633,12 +1039,23 @@ export class StructuredSessionManager {
633
1039
  // variadic 参数贪婪吞掉(commander 的 <tools...> 会一直吃 positional 直到
634
1040
  // 下一个 flag)。表现为 claude 报 "Input must be provided either through
635
1041
  // stdin or as a prompt argument when using --print"。
1042
+ const spawnedAt = new Date().toISOString();
636
1043
  const child = spawn("claude", args, {
637
1044
  cwd: session.cwd,
638
1045
  env: process.env,
639
1046
  stdio: ["pipe", "pipe", "pipe"],
640
1047
  });
641
- console.log("[WAND] spawned claude -p pid:", child.pid, "args:", args.join(" "));
1048
+ this.logger?.appendStructuredSpawn(sessionId, {
1049
+ kind: "claude-print",
1050
+ provider: "claude",
1051
+ pid: child.pid ?? null,
1052
+ cwd: session.cwd,
1053
+ args,
1054
+ prompt: prompt.slice(0, 2048),
1055
+ promptLength: prompt.length,
1056
+ claudeSessionId: session.claudeSessionId,
1057
+ spawnedAt,
1058
+ });
642
1059
  this.pendingChildren.set(sessionId, child);
643
1060
  child.stdin?.end(prompt);
644
1061
  const turnState = {
@@ -667,7 +1084,7 @@ export class StructuredSessionManager {
667
1084
  this.emit({
668
1085
  type: "output",
669
1086
  sessionId,
670
- data: buildIncrementalStructuredPayload(current),
1087
+ data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}),
671
1088
  });
672
1089
  };
673
1090
  const scheduleEmit = () => {
@@ -706,8 +1123,9 @@ export class StructuredSessionManager {
706
1123
  };
707
1124
  this.sessions.set(sessionId, patched);
708
1125
  // Persist streaming progress so a server restart does not roll back the
709
- // latest assistant turn to the pre-stream snapshot.
710
- 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);
711
1129
  };
712
1130
  const processLine = (line) => {
713
1131
  const trimmed = line.trim();
@@ -720,6 +1138,7 @@ export class StructuredSessionManager {
720
1138
  catch {
721
1139
  return;
722
1140
  }
1141
+ this.logger?.appendStreamEvent(sessionId, parsed);
723
1142
  if (parsed && parsed.type === "assistant" && parsed.message) {
724
1143
  const extracted = this.extractAssistantMessage(parsed.message);
725
1144
  if (extracted.content.length > 0) {
@@ -776,7 +1195,9 @@ export class StructuredSessionManager {
776
1195
  };
777
1196
  let stderr = "";
778
1197
  child.stdout?.on("data", (chunk) => {
779
- lineBuf += chunk.toString();
1198
+ const text = chunk.toString();
1199
+ this.logger?.appendStructuredStdout(sessionId, text);
1200
+ lineBuf += text;
780
1201
  const lines = lineBuf.split("\n");
781
1202
  // Keep the last (possibly incomplete) segment in the buffer.
782
1203
  lineBuf = lines.pop() ?? "";
@@ -785,18 +1206,35 @@ export class StructuredSessionManager {
785
1206
  }
786
1207
  });
787
1208
  child.stderr?.on("data", (chunk) => {
788
- stderr += chunk.toString();
1209
+ const text = chunk.toString();
1210
+ this.logger?.appendStructuredStderr(sessionId, text);
1211
+ stderr += text;
789
1212
  });
790
1213
  child.on("error", (error) => {
791
- console.log("[WAND] claude -p child error:", error.message);
792
1214
  this.pendingChildren.delete(sessionId);
1215
+ this.lastStreamSaveAt.delete(sessionId);
793
1216
  if (emitTimer)
794
1217
  clearTimeout(emitTimer);
1218
+ this.logger?.appendStructuredSpawn(sessionId, {
1219
+ kind: "claude-print-error",
1220
+ pid: child.pid ?? null,
1221
+ spawnedAt,
1222
+ closedAt: new Date().toISOString(),
1223
+ spawnError: error.message,
1224
+ });
795
1225
  reject(error);
796
1226
  });
797
1227
  child.on("close", (code) => {
798
- console.log("[WAND] claude -p child close code:", code, "stderr:", stderr.substring(0, 200));
799
1228
  this.pendingChildren.delete(sessionId);
1229
+ this.lastStreamSaveAt.delete(sessionId);
1230
+ this.logger?.appendStructuredSpawn(sessionId, {
1231
+ kind: "claude-print-close",
1232
+ pid: child.pid ?? null,
1233
+ spawnedAt,
1234
+ closedAt: new Date().toISOString(),
1235
+ exitCode: code,
1236
+ stderrTail: stderr.slice(-2048),
1237
+ });
800
1238
  // Process any remaining data in the line buffer.
801
1239
  if (lineBuf.trim()) {
802
1240
  processLine(lineBuf);
@@ -810,7 +1248,11 @@ export class StructuredSessionManager {
810
1248
  reject(new Error("Session removed during execution."));
811
1249
  return;
812
1250
  }
813
- 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) {
814
1256
  const errorText = stderr.trim() || `claude -p exited with code ${code}`;
815
1257
  const failureTurn = {
816
1258
  role: "assistant",
@@ -871,7 +1313,7 @@ export class StructuredSessionManager {
871
1313
  const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
872
1314
  const finished = {
873
1315
  ...current,
874
- status: keepRunning ? "running" : "stopped",
1316
+ status: keepRunning ? "running" : "idle",
875
1317
  exitCode: keepRunning ? null : 0,
876
1318
  endedAt: keepRunning ? null : new Date().toISOString(),
877
1319
  output: turnState.result,
@@ -902,11 +1344,28 @@ export class StructuredSessionManager {
902
1344
  // 用户中断当前回复:保存部分回复后立即发送新消息。
903
1345
  if (interruptPrompt) {
904
1346
  this.interruptedWith.delete(sessionId);
905
- console.log("[WAND] interrupt-and-send for session:", sessionId, "prompt:", interruptPrompt.substring(0, 50));
906
1347
  resolve();
907
1348
  setImmediate(() => {
908
1349
  this.sendMessage(sessionId, interruptPrompt).catch((err) => {
909
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
+ }
910
1369
  });
911
1370
  });
912
1371
  return;
@@ -917,7 +1376,6 @@ export class StructuredSessionManager {
917
1376
  // so the plan is actually carried out.
918
1377
  const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
919
1378
  if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
920
- console.log("[WAND] ExitPlanMode detected – auto-continuing plan execution for session:", sessionId);
921
1379
  resolve();
922
1380
  setImmediate(() => {
923
1381
  this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
@@ -934,6 +1392,396 @@ export class StructuredSessionManager {
934
1392
  });
935
1393
  }
936
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
+ // ---------------------------------------------------------------------------
937
1785
  // Parsing helpers (unchanged logic, extracted from previous implementation)
938
1786
  // ---------------------------------------------------------------------------
939
1787
  extractAssistantMessage(message) {
@@ -1002,6 +1850,106 @@ export class StructuredSessionManager {
1002
1850
  }
1003
1851
  return typeof content === "undefined" || content === null ? "" : String(content);
1004
1852
  }
1853
+ extractCodexText(value) {
1854
+ if (typeof value === "string")
1855
+ return value;
1856
+ if (!value || typeof value !== "object")
1857
+ return "";
1858
+ if (Array.isArray(value)) {
1859
+ return value.map((item) => this.extractCodexText(item)).filter(Boolean).join("");
1860
+ }
1861
+ const record = value;
1862
+ for (const key of ["text", "output_text", "message", "content", "summary"]) {
1863
+ const extracted = this.extractCodexText(record[key]);
1864
+ if (extracted)
1865
+ return extracted;
1866
+ }
1867
+ return "";
1868
+ }
1869
+ extractCodexItemBlock(item, completed) {
1870
+ const id = typeof item.id === "string" ? item.id : randomUUID();
1871
+ const type = typeof item.type === "string" ? item.type : "unknown";
1872
+ if (type === "agent_message") {
1873
+ const text = this.extractCodexText(item);
1874
+ return text ? { type: "text", text } : null;
1875
+ }
1876
+ if (type === "reasoning") {
1877
+ const text = this.extractCodexText(item);
1878
+ return text ? { type: "thinking", thinking: text } : null;
1879
+ }
1880
+ if (type === "command_execution") {
1881
+ const command = typeof item.command === "string" ? item.command : "";
1882
+ const aggregatedOutput = typeof item.aggregated_output === "string" ? item.aggregated_output : "";
1883
+ const exitCode = typeof item.exit_code === "number" ? item.exit_code : null;
1884
+ const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
1885
+ if (!completed) {
1886
+ return {
1887
+ type: "tool_use",
1888
+ id,
1889
+ name: "Bash",
1890
+ input: { command, status },
1891
+ };
1892
+ }
1893
+ return {
1894
+ type: "tool_result",
1895
+ tool_use_id: id,
1896
+ content: aggregatedOutput || (exitCode === null ? "" : `exit_code: ${exitCode}`),
1897
+ is_error: typeof exitCode === "number" && exitCode !== 0,
1898
+ };
1899
+ }
1900
+ if (completed) {
1901
+ const text = this.extractCodexText(item);
1902
+ if (text)
1903
+ return { type: "text", text };
1904
+ }
1905
+ return null;
1906
+ }
1907
+ upsertCodexBlock(blocks, block) {
1908
+ if (block.type === "tool_result") {
1909
+ const toolUseIndex = blocks.findIndex((existing) => existing.type === "tool_use" && existing.id === block.tool_use_id);
1910
+ if (toolUseIndex >= 0) {
1911
+ const nextIndex = toolUseIndex + 1;
1912
+ if (blocks[nextIndex]?.type === "tool_result" && blocks[nextIndex].tool_use_id === block.tool_use_id) {
1913
+ blocks[nextIndex] = block;
1914
+ }
1915
+ else {
1916
+ blocks.splice(nextIndex, 0, block);
1917
+ }
1918
+ return;
1919
+ }
1920
+ }
1921
+ blocks.push(block);
1922
+ }
1923
+ finishStructuredFailure(current, code, errorText, turnState) {
1924
+ const failureTurn = {
1925
+ role: "assistant",
1926
+ content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
1927
+ };
1928
+ const msgs = [...(current.messages ?? [])];
1929
+ const lastMsg = msgs[msgs.length - 1];
1930
+ if (lastMsg && lastMsg.role === "assistant")
1931
+ msgs[msgs.length - 1] = failureTurn;
1932
+ else
1933
+ msgs.push(failureTurn);
1934
+ return {
1935
+ ...current,
1936
+ status: "failed",
1937
+ exitCode: code,
1938
+ endedAt: new Date().toISOString(),
1939
+ output: errorText,
1940
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1941
+ messages: msgs,
1942
+ pendingEscalation: null,
1943
+ permissionBlocked: false,
1944
+ structuredState: {
1945
+ ...current.structuredState,
1946
+ model: turnState.model ?? current.structuredState?.model,
1947
+ inFlight: false,
1948
+ activeRequestId: null,
1949
+ lastError: errorText,
1950
+ },
1951
+ };
1952
+ }
1005
1953
  extractModelName(modelUsage) {
1006
1954
  if (!modelUsage)
1007
1955
  return undefined;
@@ -1029,4 +1977,31 @@ export class StructuredSessionManager {
1029
1977
  }
1030
1978
  return value;
1031
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
+ }
1994
+ extractCodexUsage(source) {
1995
+ if (!source || typeof source !== "object")
1996
+ return undefined;
1997
+ const value = {
1998
+ inputTokens: typeof source.input_tokens === "number" ? source.input_tokens : undefined,
1999
+ outputTokens: typeof source.output_tokens === "number" ? source.output_tokens : undefined,
2000
+ cacheReadInputTokens: typeof source.cached_input_tokens === "number" ? source.cached_input_tokens : undefined,
2001
+ };
2002
+ if (value.inputTokens === undefined && value.outputTokens === undefined && value.cacheReadInputTokens === undefined) {
2003
+ return undefined;
2004
+ }
2005
+ return value;
2006
+ }
1032
2007
  }