@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.
- package/dist/claude-pty-bridge.d.ts +4 -9
- package/dist/claude-pty-bridge.js +6 -16
- package/dist/config.js +2 -0
- package/dist/process-manager.js +2 -2
- package/dist/pty-text-utils.d.ts +6 -0
- package/dist/pty-text-utils.js +6 -0
- package/dist/server-session-routes.js +9 -3
- package/dist/server.js +5 -1
- package/dist/session-logger.d.ts +3 -1
- package/dist/session-logger.js +29 -16
- package/dist/structured-session-manager.d.ts +33 -0
- package/dist/structured-session-manager.js +560 -28
- package/dist/types.d.ts +3 -1
- package/dist/web-ui/content/scripts.js +178 -133
- package/dist/ws-broadcast.d.ts +6 -0
- package/dist/ws-broadcast.js +25 -38
- package/package.json +2 -1
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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;
|