@co0ontty/wand 1.21.4 → 1.21.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/claude-pty-bridge.d.ts +4 -9
- package/dist/claude-pty-bridge.js +6 -16
- package/dist/cli.js +44 -18
- package/dist/config.d.ts +34 -0
- package/dist/config.js +165 -1
- 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 +48 -47
- package/dist/session-logger.d.ts +3 -1
- package/dist/session-logger.js +29 -16
- package/dist/storage.d.ts +6 -0
- package/dist/storage.js +29 -0
- package/dist/structured-session-manager.d.ts +33 -0
- package/dist/structured-session-manager.js +616 -31
- package/dist/types.d.ts +3 -1
- package/dist/web-ui/content/scripts.js +301 -181
- package/dist/web-ui/content/styles.css +1471 -254
- package/dist/ws-broadcast.d.ts +6 -0
- package/dist/ws-broadcast.js +25 -38
- package/package.json +2 -3
|
@@ -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",
|
|
@@ -958,6 +1065,30 @@ export class StructuredSessionManager {
|
|
|
958
1065
|
model: undefined,
|
|
959
1066
|
usage: undefined,
|
|
960
1067
|
};
|
|
1068
|
+
// claude -p --output-format stream-json 在同一条消息流式生成期间会重复
|
|
1069
|
+
// emit 同一个 message.id 的 "assistant" 事件,每次 content 略多一些;子
|
|
1070
|
+
// agent 流(Task 工具)则会插入若干 parent_tool_use_id 不同的 message.id。
|
|
1071
|
+
// 朴素的 push(...content) 会让早期片段被反复合并复制,最终被 compact 出
|
|
1072
|
+
// 怪异结果,导致 UI 上 tool_use / 子 agent 输出"显示一下就消失"。
|
|
1073
|
+
// 这里按 (message.id) 去重,相同 id 视作同一消息的更新覆盖;tool_result
|
|
1074
|
+
// 用单调递增的合成 key 顺序追加。每次事件后用插入顺序重建 turnState.blocks。
|
|
1075
|
+
const blocksByKey = new Map();
|
|
1076
|
+
const keyOrder = [];
|
|
1077
|
+
let toolResultSeq = 0;
|
|
1078
|
+
const upsertBlocks = (key, blocks) => {
|
|
1079
|
+
if (!blocksByKey.has(key))
|
|
1080
|
+
keyOrder.push(key);
|
|
1081
|
+
blocksByKey.set(key, blocks);
|
|
1082
|
+
};
|
|
1083
|
+
const rebuildTurnBlocks = () => {
|
|
1084
|
+
const flat = [];
|
|
1085
|
+
for (const key of keyOrder) {
|
|
1086
|
+
const entry = blocksByKey.get(key);
|
|
1087
|
+
if (entry && entry.length > 0)
|
|
1088
|
+
flat.push(...entry);
|
|
1089
|
+
}
|
|
1090
|
+
turnState.blocks = flat;
|
|
1091
|
+
};
|
|
961
1092
|
// Line buffer for NDJSON: chunks from stdout may split mid-line.
|
|
962
1093
|
let lineBuf = "";
|
|
963
1094
|
// Debounce output events to avoid flooding the WebSocket.
|
|
@@ -977,7 +1108,7 @@ export class StructuredSessionManager {
|
|
|
977
1108
|
this.emit({
|
|
978
1109
|
type: "output",
|
|
979
1110
|
sessionId,
|
|
980
|
-
data: buildIncrementalStructuredPayload(current),
|
|
1111
|
+
data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}),
|
|
981
1112
|
});
|
|
982
1113
|
};
|
|
983
1114
|
const scheduleEmit = () => {
|
|
@@ -1016,8 +1147,9 @@ export class StructuredSessionManager {
|
|
|
1016
1147
|
};
|
|
1017
1148
|
this.sessions.set(sessionId, patched);
|
|
1018
1149
|
// Persist streaming progress so a server restart does not roll back the
|
|
1019
|
-
// latest assistant turn to the pre-stream snapshot.
|
|
1020
|
-
|
|
1150
|
+
// latest assistant turn to the pre-stream snapshot. Throttled because
|
|
1151
|
+
// saveSession serializes the full messages array.
|
|
1152
|
+
this.saveStreamingSnapshot(patched);
|
|
1021
1153
|
};
|
|
1022
1154
|
const processLine = (line) => {
|
|
1023
1155
|
const trimmed = line.trim();
|
|
@@ -1033,8 +1165,15 @@ export class StructuredSessionManager {
|
|
|
1033
1165
|
this.logger?.appendStreamEvent(sessionId, parsed);
|
|
1034
1166
|
if (parsed && parsed.type === "assistant" && parsed.message) {
|
|
1035
1167
|
const extracted = this.extractAssistantMessage(parsed.message);
|
|
1168
|
+
// 用 message.id 作为 key:claude -p 流式重发同一条消息时整段覆盖
|
|
1169
|
+
// (而不是与早期片段累加),子 agent 的不同消息 id 各占一格、保留
|
|
1170
|
+
// 父子完整顺序。没有 id 时退化为合成 key 走追加模式。
|
|
1171
|
+
const msgId = typeof parsed.message.id === "string" && parsed.message.id
|
|
1172
|
+
? `assistant:${parsed.message.id}`
|
|
1173
|
+
: `assistant:anon:${keyOrder.length}`;
|
|
1036
1174
|
if (extracted.content.length > 0) {
|
|
1037
|
-
|
|
1175
|
+
upsertBlocks(msgId, extracted.content);
|
|
1176
|
+
rebuildTurnBlocks();
|
|
1038
1177
|
}
|
|
1039
1178
|
// NOTE: usage from streaming "assistant" events contains partial/incremental
|
|
1040
1179
|
// token counts (e.g. output_tokens=1 during streaming) and is NOT accurate.
|
|
@@ -1058,9 +1197,11 @@ export class StructuredSessionManager {
|
|
|
1058
1197
|
return;
|
|
1059
1198
|
}
|
|
1060
1199
|
if (parsed && parsed.type === "user" && parsed.message && Array.isArray(parsed.message.content)) {
|
|
1200
|
+
// tool_result 没有自身 id,按到达顺序用合成 key 追加(永远不被覆盖)。
|
|
1201
|
+
const collected = [];
|
|
1061
1202
|
for (const block of parsed.message.content) {
|
|
1062
1203
|
if (block && block.type === "tool_result") {
|
|
1063
|
-
|
|
1204
|
+
collected.push({
|
|
1064
1205
|
type: "tool_result",
|
|
1065
1206
|
tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "",
|
|
1066
1207
|
content: this.normalizeToolResultContent(block.content),
|
|
@@ -1068,6 +1209,10 @@ export class StructuredSessionManager {
|
|
|
1068
1209
|
});
|
|
1069
1210
|
}
|
|
1070
1211
|
}
|
|
1212
|
+
if (collected.length > 0) {
|
|
1213
|
+
upsertBlocks(`tool_result:${toolResultSeq++}`, collected);
|
|
1214
|
+
rebuildTurnBlocks();
|
|
1215
|
+
}
|
|
1071
1216
|
syncSnapshot();
|
|
1072
1217
|
scheduleEmit();
|
|
1073
1218
|
return;
|
|
@@ -1103,8 +1248,8 @@ export class StructuredSessionManager {
|
|
|
1103
1248
|
stderr += text;
|
|
1104
1249
|
});
|
|
1105
1250
|
child.on("error", (error) => {
|
|
1106
|
-
console.log("[WAND] claude -p child error:", error.message);
|
|
1107
1251
|
this.pendingChildren.delete(sessionId);
|
|
1252
|
+
this.lastStreamSaveAt.delete(sessionId);
|
|
1108
1253
|
if (emitTimer)
|
|
1109
1254
|
clearTimeout(emitTimer);
|
|
1110
1255
|
this.logger?.appendStructuredSpawn(sessionId, {
|
|
@@ -1117,8 +1262,8 @@ export class StructuredSessionManager {
|
|
|
1117
1262
|
reject(error);
|
|
1118
1263
|
});
|
|
1119
1264
|
child.on("close", (code) => {
|
|
1120
|
-
console.log("[WAND] claude -p child close code:", code, "stderr:", stderr.substring(0, 200));
|
|
1121
1265
|
this.pendingChildren.delete(sessionId);
|
|
1266
|
+
this.lastStreamSaveAt.delete(sessionId);
|
|
1122
1267
|
this.logger?.appendStructuredSpawn(sessionId, {
|
|
1123
1268
|
kind: "claude-print-close",
|
|
1124
1269
|
pid: child.pid ?? null,
|
|
@@ -1140,7 +1285,11 @@ export class StructuredSessionManager {
|
|
|
1140
1285
|
reject(new Error("Session removed during execution."));
|
|
1141
1286
|
return;
|
|
1142
1287
|
}
|
|
1143
|
-
|
|
1288
|
+
// 如果是用户主动中断(interruptedWith 里有新消息),claude -p 收到 SIGTERM 后
|
|
1289
|
+
// 可能以非零 exit code 退出(内部 handler 调了 exit(1))。这种情况属于正常
|
|
1290
|
+
// 中断流程,不应走失败路径——后续 interruptedWith 逻辑会发送新消息。
|
|
1291
|
+
const interruptedByUser = this.interruptedWith.has(sessionId);
|
|
1292
|
+
if (code !== 0 && code !== null && !interruptedByUser) {
|
|
1144
1293
|
const errorText = stderr.trim() || `claude -p exited with code ${code}`;
|
|
1145
1294
|
const failureTurn = {
|
|
1146
1295
|
role: "assistant",
|
|
@@ -1232,11 +1381,28 @@ export class StructuredSessionManager {
|
|
|
1232
1381
|
// 用户中断当前回复:保存部分回复后立即发送新消息。
|
|
1233
1382
|
if (interruptPrompt) {
|
|
1234
1383
|
this.interruptedWith.delete(sessionId);
|
|
1235
|
-
console.log("[WAND] interrupt-and-send for session:", sessionId, "prompt:", interruptPrompt.substring(0, 50));
|
|
1236
1384
|
resolve();
|
|
1237
1385
|
setImmediate(() => {
|
|
1238
1386
|
this.sendMessage(sessionId, interruptPrompt).catch((err) => {
|
|
1239
1387
|
console.error("[WAND] interrupt-and-send failed:", err);
|
|
1388
|
+
// 续接失败:把状态回滚到 idle,让用户可以重新输入而不是卡在 running 状态
|
|
1389
|
+
const afterFail = this.sessions.get(sessionId);
|
|
1390
|
+
if (afterFail) {
|
|
1391
|
+
const recovered = {
|
|
1392
|
+
...afterFail,
|
|
1393
|
+
status: "idle",
|
|
1394
|
+
exitCode: 0,
|
|
1395
|
+
endedAt: new Date().toISOString(),
|
|
1396
|
+
structuredState: {
|
|
1397
|
+
...afterFail.structuredState,
|
|
1398
|
+
inFlight: false,
|
|
1399
|
+
activeRequestId: null,
|
|
1400
|
+
},
|
|
1401
|
+
};
|
|
1402
|
+
this.sessions.set(sessionId, recovered);
|
|
1403
|
+
this.storage.saveSession(recovered);
|
|
1404
|
+
this.emitStructuredSnapshot(recovered);
|
|
1405
|
+
}
|
|
1240
1406
|
});
|
|
1241
1407
|
});
|
|
1242
1408
|
return;
|
|
@@ -1247,7 +1413,6 @@ export class StructuredSessionManager {
|
|
|
1247
1413
|
// so the plan is actually carried out.
|
|
1248
1414
|
const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
|
|
1249
1415
|
if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
|
|
1250
|
-
console.log("[WAND] ExitPlanMode detected – auto-continuing plan execution for session:", sessionId);
|
|
1251
1416
|
resolve();
|
|
1252
1417
|
setImmediate(() => {
|
|
1253
1418
|
this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
|
|
@@ -1264,6 +1429,409 @@ export class StructuredSessionManager {
|
|
|
1264
1429
|
});
|
|
1265
1430
|
}
|
|
1266
1431
|
// ---------------------------------------------------------------------------
|
|
1432
|
+
// Streaming claude-agent-sdk execution
|
|
1433
|
+
// ---------------------------------------------------------------------------
|
|
1434
|
+
/**
|
|
1435
|
+
* Use @anthropic-ai/claude-agent-sdk instead of spawning claude -p directly.
|
|
1436
|
+
* The SDK still spawns the claude binary but provides typed AsyncGenerator<SDKMessage>
|
|
1437
|
+
* messages, so we skip NDJSON parsing. Options are 1:1 with the CLI flags.
|
|
1438
|
+
*
|
|
1439
|
+
* Streaming is enabled via includePartialMessages: true — the SDK emits
|
|
1440
|
+
* SDKPartialAssistantMessage (type: "stream_event") with BetaRawMessageStreamEvent
|
|
1441
|
+
* payloads for incremental text/thinking/tool_use updates, followed by a final
|
|
1442
|
+
* SDKAssistantMessage with the authoritative complete content.
|
|
1443
|
+
*/
|
|
1444
|
+
runClaudeSdkStreaming(sessionId, session, prompt) {
|
|
1445
|
+
return new Promise((resolve, reject) => {
|
|
1446
|
+
void this._runClaudeSdkStreamingAsync(sessionId, session, prompt).then(resolve, reject);
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1449
|
+
async _runClaudeSdkStreamingAsync(sessionId, session, prompt) {
|
|
1450
|
+
let sdkQuery;
|
|
1451
|
+
try {
|
|
1452
|
+
const sdkMod = await import("@anthropic-ai/claude-agent-sdk");
|
|
1453
|
+
sdkQuery = sdkMod.query;
|
|
1454
|
+
}
|
|
1455
|
+
catch {
|
|
1456
|
+
throw new Error("@anthropic-ai/claude-agent-sdk 未安装,无法使用 SDK runner。");
|
|
1457
|
+
}
|
|
1458
|
+
const abortController = new AbortController();
|
|
1459
|
+
this.pendingSdkAbort.set(sessionId, abortController);
|
|
1460
|
+
const isManaged = session.mode === "managed";
|
|
1461
|
+
let killedForAskUserQuestion = false;
|
|
1462
|
+
// Derive permission mode (mirrors buildPermissionArgs logic)
|
|
1463
|
+
const shouldBypass = (session.autoApprovePermissions ?? false) || session.mode === "full-access" || session.mode === "managed";
|
|
1464
|
+
const shouldAcceptEdits = session.mode === "auto-edit";
|
|
1465
|
+
let permissionMode = "default";
|
|
1466
|
+
let allowedToolsForRoot;
|
|
1467
|
+
if (!isRunningAsRoot()) {
|
|
1468
|
+
if (shouldBypass)
|
|
1469
|
+
permissionMode = "bypassPermissions";
|
|
1470
|
+
else if (shouldAcceptEdits)
|
|
1471
|
+
permissionMode = "acceptEdits";
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
// Root: acceptEdits + allowedTools (same workaround as CLI runner)
|
|
1475
|
+
if (shouldBypass || shouldAcceptEdits) {
|
|
1476
|
+
permissionMode = "acceptEdits";
|
|
1477
|
+
allowedToolsForRoot = ["Bash", "Edit", "Write", "Read", "Glob", "Grep", "NotebookEdit", "WebFetch", "WebSearch"];
|
|
1478
|
+
}
|
|
1479
|
+
}
|
|
1480
|
+
// System prompt additions
|
|
1481
|
+
const isChinese = this.config.language?.trim() === "中文";
|
|
1482
|
+
const systemPromptParts = [];
|
|
1483
|
+
if (isManaged) {
|
|
1484
|
+
systemPromptParts.push(isChinese
|
|
1485
|
+
? "你正在完全托管的自主模式下运行。用户可能无法及时回复问题或确认。你必须独立做出所有决策——自行选择最佳方案,而不是向用户询问偏好、确认或澄清。如果有多种可行方案,选择你认为最合适的并继续执行。除非任务本身存在根本性的歧义且无法合理推断,否则不要等待用户输入。果断行动,自主决策。"
|
|
1486
|
+
: "You are running in a fully managed, autonomous mode. The user may not be available to respond to questions or confirmations in a timely manner. You MUST make all decisions independently — choose the best approach yourself instead of asking the user for preferences, confirmations, or clarifications. If multiple approaches are viable, pick the one you judge most appropriate and proceed. Never block on user input unless the task is fundamentally ambiguous and cannot be reasonably inferred. Be decisive and self-directed.");
|
|
1487
|
+
}
|
|
1488
|
+
const language = this.config.language?.trim();
|
|
1489
|
+
if (language) {
|
|
1490
|
+
systemPromptParts.push(isChinese
|
|
1491
|
+
? "请使用中文回复。所有解释、注释和对话文本都使用中文。"
|
|
1492
|
+
: `Please respond in ${language}. Use ${language} for all your explanations, comments, and conversational text.`);
|
|
1493
|
+
}
|
|
1494
|
+
const sdkOptions = {
|
|
1495
|
+
cwd: session.cwd,
|
|
1496
|
+
abortController,
|
|
1497
|
+
permissionMode,
|
|
1498
|
+
...(permissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } : {}),
|
|
1499
|
+
...(allowedToolsForRoot ? { allowedTools: allowedToolsForRoot } : {}),
|
|
1500
|
+
...(isManaged ? { disallowedTools: ["AskUserQuestion"] } : {}),
|
|
1501
|
+
includePartialMessages: true,
|
|
1502
|
+
...(systemPromptParts.length > 0 ? { appendSystemPrompt: systemPromptParts.join("\n\n") } : {}),
|
|
1503
|
+
};
|
|
1504
|
+
if (session.claudeSessionId)
|
|
1505
|
+
sdkOptions.resume = session.claudeSessionId;
|
|
1506
|
+
const modelChoice = session.selectedModel?.trim();
|
|
1507
|
+
if (modelChoice && modelChoice !== "default")
|
|
1508
|
+
sdkOptions.model = modelChoice;
|
|
1509
|
+
const turnState = {
|
|
1510
|
+
blocks: [],
|
|
1511
|
+
result: "",
|
|
1512
|
+
sessionId: null,
|
|
1513
|
+
model: undefined,
|
|
1514
|
+
usage: undefined,
|
|
1515
|
+
};
|
|
1516
|
+
// Tracks in-progress streaming blocks keyed by content_block index from stream_event.
|
|
1517
|
+
// The map is cleared whenever a complete `assistant` message arrives — its blocks
|
|
1518
|
+
// are then promoted into `finalizedBlocks` below.
|
|
1519
|
+
const streamingBlockByIndex = new Map();
|
|
1520
|
+
// Blocks from messages that have already completed within this turn — including
|
|
1521
|
+
// the parent assistant's prior messages, every subagent assistant message, and
|
|
1522
|
+
// every tool_result. Subagent (Task tool) flows produce many assistant messages
|
|
1523
|
+
// back-to-back; without this list, each new streaming message would visually
|
|
1524
|
+
// erase everything that came before it in the same turn.
|
|
1525
|
+
const finalizedBlocks = [];
|
|
1526
|
+
let emitTimer = null;
|
|
1527
|
+
const flushEmit = () => {
|
|
1528
|
+
if (emitTimer) {
|
|
1529
|
+
clearTimeout(emitTimer);
|
|
1530
|
+
emitTimer = null;
|
|
1531
|
+
}
|
|
1532
|
+
const current = this.sessions.get(sessionId);
|
|
1533
|
+
if (!current)
|
|
1534
|
+
return;
|
|
1535
|
+
this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
|
|
1536
|
+
};
|
|
1537
|
+
const scheduleEmit = () => {
|
|
1538
|
+
if (!emitTimer)
|
|
1539
|
+
emitTimer = setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS);
|
|
1540
|
+
};
|
|
1541
|
+
// Rebuild ContentBlock[] from finalized history + the in-progress streaming map.
|
|
1542
|
+
// Returning only the streaming blocks would drop every prior parent/subagent
|
|
1543
|
+
// message in this turn (the original disappearing-output bug).
|
|
1544
|
+
const rebuildStreamingBlocks = () => {
|
|
1545
|
+
const sorted = [...streamingBlockByIndex.entries()].sort((a, b) => a[0] - b[0]);
|
|
1546
|
+
const streaming = [];
|
|
1547
|
+
for (const [, sb] of sorted) {
|
|
1548
|
+
if (sb.type === "text") {
|
|
1549
|
+
streaming.push({ type: "text", text: sb.text });
|
|
1550
|
+
}
|
|
1551
|
+
else if (sb.type === "thinking") {
|
|
1552
|
+
streaming.push({ type: "thinking", thinking: sb.thinking });
|
|
1553
|
+
}
|
|
1554
|
+
else if (sb.type === "tool_use" && sb.id && sb.name) {
|
|
1555
|
+
let input = {};
|
|
1556
|
+
if (sb.finalized && sb.partialInput) {
|
|
1557
|
+
try {
|
|
1558
|
+
input = JSON.parse(sb.partialInput);
|
|
1559
|
+
}
|
|
1560
|
+
catch { /* partial json */ }
|
|
1561
|
+
}
|
|
1562
|
+
streaming.push({ type: "tool_use", id: sb.id, name: sb.name, input });
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
return [...finalizedBlocks, ...streaming];
|
|
1566
|
+
};
|
|
1567
|
+
const syncSnapshot = () => {
|
|
1568
|
+
const current = this.sessions.get(sessionId);
|
|
1569
|
+
if (!current)
|
|
1570
|
+
return;
|
|
1571
|
+
const inProgressTurn = {
|
|
1572
|
+
role: "assistant",
|
|
1573
|
+
content: this.compactContentBlocks([...turnState.blocks], turnState.result),
|
|
1574
|
+
usage: turnState.usage,
|
|
1575
|
+
};
|
|
1576
|
+
const msgs = [...(current.messages ?? [])];
|
|
1577
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
1578
|
+
if (lastMsg && lastMsg.role === "assistant")
|
|
1579
|
+
msgs[msgs.length - 1] = inProgressTurn;
|
|
1580
|
+
else
|
|
1581
|
+
msgs.push(inProgressTurn);
|
|
1582
|
+
const patched = {
|
|
1583
|
+
...current,
|
|
1584
|
+
claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
|
|
1585
|
+
messages: msgs,
|
|
1586
|
+
output: turnState.result || current.output,
|
|
1587
|
+
structuredState: {
|
|
1588
|
+
...current.structuredState,
|
|
1589
|
+
model: turnState.model ?? current.structuredState?.model,
|
|
1590
|
+
},
|
|
1591
|
+
};
|
|
1592
|
+
this.sessions.set(sessionId, patched);
|
|
1593
|
+
this.saveStreamingSnapshot(patched);
|
|
1594
|
+
};
|
|
1595
|
+
const spawnedAt = new Date().toISOString();
|
|
1596
|
+
this.logger?.appendStructuredSpawn(sessionId, {
|
|
1597
|
+
kind: "claude-sdk",
|
|
1598
|
+
provider: "claude",
|
|
1599
|
+
cwd: session.cwd,
|
|
1600
|
+
permissionMode,
|
|
1601
|
+
prompt: prompt.slice(0, 2048),
|
|
1602
|
+
promptLength: prompt.length,
|
|
1603
|
+
claudeSessionId: session.claudeSessionId,
|
|
1604
|
+
spawnedAt,
|
|
1605
|
+
});
|
|
1606
|
+
try {
|
|
1607
|
+
for await (const msg of sdkQuery({ prompt, options: sdkOptions })) {
|
|
1608
|
+
if (abortController.signal.aborted)
|
|
1609
|
+
break;
|
|
1610
|
+
// Incremental streaming events (opt-in via includePartialMessages: true)
|
|
1611
|
+
if (msg.type === "stream_event") {
|
|
1612
|
+
const ev = msg.event;
|
|
1613
|
+
if (ev.type === "content_block_start") {
|
|
1614
|
+
const cb = ev.content_block;
|
|
1615
|
+
const blockType = cb.type;
|
|
1616
|
+
if (blockType === "text" || blockType === "thinking" || blockType === "tool_use") {
|
|
1617
|
+
streamingBlockByIndex.set(ev.index, {
|
|
1618
|
+
type: blockType,
|
|
1619
|
+
id: typeof cb.id === "string" ? cb.id : undefined,
|
|
1620
|
+
name: typeof cb.name === "string" ? cb.name : undefined,
|
|
1621
|
+
text: typeof cb.text === "string" ? cb.text : "",
|
|
1622
|
+
thinking: typeof cb.thinking === "string" ? cb.thinking : "",
|
|
1623
|
+
partialInput: "",
|
|
1624
|
+
finalized: false,
|
|
1625
|
+
});
|
|
1626
|
+
turnState.blocks = rebuildStreamingBlocks();
|
|
1627
|
+
syncSnapshot();
|
|
1628
|
+
scheduleEmit();
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
else if (ev.type === "content_block_delta") {
|
|
1632
|
+
const sb = streamingBlockByIndex.get(ev.index);
|
|
1633
|
+
if (sb) {
|
|
1634
|
+
const delta = ev.delta;
|
|
1635
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") {
|
|
1636
|
+
sb.text += delta.text;
|
|
1637
|
+
turnState.result = sb.text;
|
|
1638
|
+
}
|
|
1639
|
+
else if (delta.type === "thinking_delta" && typeof delta.thinking === "string") {
|
|
1640
|
+
sb.thinking += delta.thinking;
|
|
1641
|
+
}
|
|
1642
|
+
else if (delta.type === "input_json_delta" && typeof delta.partial_json === "string") {
|
|
1643
|
+
sb.partialInput += delta.partial_json;
|
|
1644
|
+
}
|
|
1645
|
+
turnState.blocks = rebuildStreamingBlocks();
|
|
1646
|
+
syncSnapshot();
|
|
1647
|
+
scheduleEmit();
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
else if (ev.type === "content_block_stop") {
|
|
1651
|
+
const sb = streamingBlockByIndex.get(ev.index);
|
|
1652
|
+
if (sb) {
|
|
1653
|
+
sb.finalized = true;
|
|
1654
|
+
turnState.blocks = rebuildStreamingBlocks();
|
|
1655
|
+
syncSnapshot();
|
|
1656
|
+
scheduleEmit();
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
continue;
|
|
1660
|
+
}
|
|
1661
|
+
// Complete assistant turn — promote streaming content into the finalized
|
|
1662
|
+
// history so subsequent messages (subagents, follow-up parent messages)
|
|
1663
|
+
// append to it instead of erasing it.
|
|
1664
|
+
if (msg.type === "assistant") {
|
|
1665
|
+
const assistantMsg = msg;
|
|
1666
|
+
const extracted = this.extractAssistantMessage(assistantMsg.message);
|
|
1667
|
+
finalizedBlocks.push(...extracted.content);
|
|
1668
|
+
streamingBlockByIndex.clear();
|
|
1669
|
+
turnState.blocks = rebuildStreamingBlocks();
|
|
1670
|
+
if (assistantMsg.session_id)
|
|
1671
|
+
turnState.sessionId = assistantMsg.session_id;
|
|
1672
|
+
syncSnapshot();
|
|
1673
|
+
scheduleEmit();
|
|
1674
|
+
// Non-managed mode: detect AskUserQuestion, abort to let user answer
|
|
1675
|
+
if (!isManaged && !killedForAskUserQuestion) {
|
|
1676
|
+
const askBlock = extracted.content.find((b) => b.type === "tool_use" && b.name === "AskUserQuestion");
|
|
1677
|
+
if (askBlock) {
|
|
1678
|
+
killedForAskUserQuestion = true;
|
|
1679
|
+
flushEmit();
|
|
1680
|
+
abortController.abort();
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
// Tool results fed back from the claude subprocess (parent's view of a
|
|
1686
|
+
// tool call, or a subagent's tool_result during Task execution).
|
|
1687
|
+
if (msg.type === "user") {
|
|
1688
|
+
const userMsg = msg;
|
|
1689
|
+
const content = Array.isArray(userMsg.message?.content) ? userMsg.message.content : [];
|
|
1690
|
+
for (const block of content) {
|
|
1691
|
+
const b = block;
|
|
1692
|
+
if (b?.type === "tool_result") {
|
|
1693
|
+
finalizedBlocks.push({
|
|
1694
|
+
type: "tool_result",
|
|
1695
|
+
tool_use_id: typeof b.tool_use_id === "string" ? b.tool_use_id : "",
|
|
1696
|
+
content: this.normalizeToolResultContent(b.content),
|
|
1697
|
+
is_error: b.is_error === true,
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
turnState.blocks = rebuildStreamingBlocks();
|
|
1702
|
+
syncSnapshot();
|
|
1703
|
+
scheduleEmit();
|
|
1704
|
+
continue;
|
|
1705
|
+
}
|
|
1706
|
+
// Final result — capture session_id, usage, model
|
|
1707
|
+
if (msg.type === "result") {
|
|
1708
|
+
const resultMsg = msg;
|
|
1709
|
+
if (typeof resultMsg.result === "string")
|
|
1710
|
+
turnState.result = resultMsg.result.trim();
|
|
1711
|
+
if (typeof resultMsg.session_id === "string")
|
|
1712
|
+
turnState.sessionId = resultMsg.session_id;
|
|
1713
|
+
turnState.model = this.extractModelName(resultMsg.modelUsage) ?? turnState.model;
|
|
1714
|
+
turnState.usage = this.extractSdkUsage(resultMsg);
|
|
1715
|
+
syncSnapshot();
|
|
1716
|
+
scheduleEmit();
|
|
1717
|
+
continue;
|
|
1718
|
+
}
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
catch (err) {
|
|
1722
|
+
// AbortError from abortController.abort() is intentional — fall through to finish logic
|
|
1723
|
+
const isAbort = abortController.signal.aborted || (err instanceof Error && err.name === "AbortError");
|
|
1724
|
+
if (!isAbort) {
|
|
1725
|
+
this.pendingSdkAbort.delete(sessionId);
|
|
1726
|
+
this.lastStreamSaveAt.delete(sessionId);
|
|
1727
|
+
if (emitTimer)
|
|
1728
|
+
clearTimeout(emitTimer);
|
|
1729
|
+
this.logger?.appendStructuredSpawn(sessionId, {
|
|
1730
|
+
kind: "claude-sdk-error",
|
|
1731
|
+
spawnedAt,
|
|
1732
|
+
closedAt: new Date().toISOString(),
|
|
1733
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1734
|
+
});
|
|
1735
|
+
throw err;
|
|
1736
|
+
}
|
|
1737
|
+
}
|
|
1738
|
+
// Cleanup
|
|
1739
|
+
this.pendingSdkAbort.delete(sessionId);
|
|
1740
|
+
this.lastStreamSaveAt.delete(sessionId);
|
|
1741
|
+
if (emitTimer)
|
|
1742
|
+
clearTimeout(emitTimer);
|
|
1743
|
+
flushEmit();
|
|
1744
|
+
const current = this.sessions.get(sessionId);
|
|
1745
|
+
if (!current)
|
|
1746
|
+
throw new Error("Session removed during execution.");
|
|
1747
|
+
this.logger?.appendStructuredSpawn(sessionId, {
|
|
1748
|
+
kind: "claude-sdk-close",
|
|
1749
|
+
spawnedAt,
|
|
1750
|
+
closedAt: new Date().toISOString(),
|
|
1751
|
+
killedForAskUserQuestion,
|
|
1752
|
+
sessionId: turnState.sessionId,
|
|
1753
|
+
});
|
|
1754
|
+
const interruptedByUser = this.interruptedWith.has(sessionId);
|
|
1755
|
+
// Build final assistant turn
|
|
1756
|
+
const finalContent = this.compactContentBlocks([...turnState.blocks], turnState.result);
|
|
1757
|
+
const assistantTurn = {
|
|
1758
|
+
role: "assistant",
|
|
1759
|
+
content: finalContent,
|
|
1760
|
+
usage: turnState.usage,
|
|
1761
|
+
};
|
|
1762
|
+
const msgs = [...(current.messages ?? [])];
|
|
1763
|
+
const lastMsg = msgs[msgs.length - 1];
|
|
1764
|
+
if (lastMsg && lastMsg.role === "assistant")
|
|
1765
|
+
msgs[msgs.length - 1] = assistantTurn;
|
|
1766
|
+
else
|
|
1767
|
+
msgs.push(assistantTurn);
|
|
1768
|
+
const interruptPrompt = this.interruptedWith.get(sessionId);
|
|
1769
|
+
const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
|
|
1770
|
+
const finished = {
|
|
1771
|
+
...current,
|
|
1772
|
+
status: keepRunning ? "running" : "idle",
|
|
1773
|
+
exitCode: keepRunning ? null : 0,
|
|
1774
|
+
endedAt: keepRunning ? null : new Date().toISOString(),
|
|
1775
|
+
output: turnState.result,
|
|
1776
|
+
claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
|
|
1777
|
+
messages: msgs,
|
|
1778
|
+
queuedMessages: interruptPrompt ? [] : current.queuedMessages,
|
|
1779
|
+
pendingEscalation: null,
|
|
1780
|
+
permissionBlocked: false,
|
|
1781
|
+
structuredState: {
|
|
1782
|
+
...current.structuredState,
|
|
1783
|
+
model: turnState.model ?? current.structuredState?.model,
|
|
1784
|
+
inFlight: false,
|
|
1785
|
+
activeRequestId: null,
|
|
1786
|
+
lastError: null,
|
|
1787
|
+
},
|
|
1788
|
+
};
|
|
1789
|
+
this.sessions.set(sessionId, finished);
|
|
1790
|
+
this.storage.saveSession(finished);
|
|
1791
|
+
this.emitStructuredSnapshot(finished);
|
|
1792
|
+
if (!keepRunning)
|
|
1793
|
+
this.emitStructuredSnapshot(finished, "ended");
|
|
1794
|
+
if (killedForAskUserQuestion)
|
|
1795
|
+
return;
|
|
1796
|
+
if (interruptPrompt) {
|
|
1797
|
+
this.interruptedWith.delete(sessionId);
|
|
1798
|
+
setImmediate(() => {
|
|
1799
|
+
this.sendMessage(sessionId, interruptPrompt).catch((err) => {
|
|
1800
|
+
console.error("[WAND] sdk interrupt-and-send failed:", err);
|
|
1801
|
+
const afterFail = this.sessions.get(sessionId);
|
|
1802
|
+
if (afterFail) {
|
|
1803
|
+
const recovered = {
|
|
1804
|
+
...afterFail,
|
|
1805
|
+
status: "idle",
|
|
1806
|
+
exitCode: 0,
|
|
1807
|
+
endedAt: new Date().toISOString(),
|
|
1808
|
+
structuredState: {
|
|
1809
|
+
...afterFail.structuredState,
|
|
1810
|
+
inFlight: false,
|
|
1811
|
+
activeRequestId: null,
|
|
1812
|
+
},
|
|
1813
|
+
};
|
|
1814
|
+
this.sessions.set(sessionId, recovered);
|
|
1815
|
+
this.storage.saveSession(recovered);
|
|
1816
|
+
this.emitStructuredSnapshot(recovered);
|
|
1817
|
+
}
|
|
1818
|
+
});
|
|
1819
|
+
});
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
// Auto-continue after ExitPlanMode (same as CLI runner)
|
|
1823
|
+
const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
|
|
1824
|
+
if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
|
|
1825
|
+
setImmediate(() => {
|
|
1826
|
+
this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
|
|
1827
|
+
console.error("[WAND] sdk auto-continue after ExitPlanMode failed:", err);
|
|
1828
|
+
});
|
|
1829
|
+
});
|
|
1830
|
+
return;
|
|
1831
|
+
}
|
|
1832
|
+
setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
|
|
1833
|
+
}
|
|
1834
|
+
// ---------------------------------------------------------------------------
|
|
1267
1835
|
// Parsing helpers (unchanged logic, extracted from previous implementation)
|
|
1268
1836
|
// ---------------------------------------------------------------------------
|
|
1269
1837
|
extractAssistantMessage(message) {
|
|
@@ -1303,7 +1871,10 @@ export class StructuredSessionManager {
|
|
|
1303
1871
|
if (previous
|
|
1304
1872
|
&& previous.type === "text"
|
|
1305
1873
|
&& block.type === "text") {
|
|
1306
|
-
previous.text
|
|
1874
|
+
// 用新对象替换 compacted 末尾,**不要**就地改 previous.text —— previous
|
|
1875
|
+
// 通常和调用方持有的 turnState.blocks 共享引用,原地 mutate 会让下次
|
|
1876
|
+
// syncSnapshot 把已合并的内容再合并一次,呈指数级复制。
|
|
1877
|
+
compacted[compacted.length - 1] = { type: "text", text: `${previous.text}${block.text}` };
|
|
1307
1878
|
continue;
|
|
1308
1879
|
}
|
|
1309
1880
|
compacted.push(block);
|
|
@@ -1459,6 +2030,20 @@ export class StructuredSessionManager {
|
|
|
1459
2030
|
}
|
|
1460
2031
|
return value;
|
|
1461
2032
|
}
|
|
2033
|
+
/** Extract usage from an SDKResultSuccess message (sdk runner). */
|
|
2034
|
+
extractSdkUsage(result) {
|
|
2035
|
+
const usage = result?.usage;
|
|
2036
|
+
const value = {
|
|
2037
|
+
inputTokens: typeof usage?.input_tokens === "number" ? usage.input_tokens : undefined,
|
|
2038
|
+
outputTokens: typeof usage?.output_tokens === "number" ? usage.output_tokens : undefined,
|
|
2039
|
+
cacheReadInputTokens: typeof usage?.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined,
|
|
2040
|
+
cacheCreationInputTokens: typeof usage?.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined,
|
|
2041
|
+
totalCostUsd: typeof result?.total_cost_usd === "number" ? result.total_cost_usd : undefined,
|
|
2042
|
+
};
|
|
2043
|
+
if (Object.values(value).every(v => v === undefined))
|
|
2044
|
+
return undefined;
|
|
2045
|
+
return value;
|
|
2046
|
+
}
|
|
1462
2047
|
extractCodexUsage(source) {
|
|
1463
2048
|
if (!source || typeof source !== "object")
|
|
1464
2049
|
return undefined;
|