@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.
- package/dist/claude-pty-bridge.d.ts +5 -2
- package/dist/claude-pty-bridge.js +26 -13
- package/dist/config.js +2 -0
- package/dist/git-quick-commit.js +12 -4
- package/dist/models.d.ts +3 -1
- package/dist/models.js +45 -7
- package/dist/process-manager.d.ts +2 -0
- package/dist/process-manager.js +82 -19
- package/dist/pty-text-utils.d.ts +31 -1
- package/dist/pty-text-utils.js +164 -2
- package/dist/server-session-routes.d.ts +1 -1
- package/dist/server-session-routes.js +31 -11
- package/dist/server.d.ts +3 -0
- package/dist/server.js +54 -13
- package/dist/session-logger.d.ts +18 -5
- package/dist/session-logger.js +81 -20
- package/dist/structured-session-manager.d.ts +45 -2
- package/dist/structured-session-manager.js +1010 -35
- package/dist/types.d.ts +15 -2
- package/dist/web-ui/content/scripts.js +785 -238
- package/dist/web-ui/content/styles.css +137 -41
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/dist/ws-broadcast.d.ts +6 -0
- package/dist/ws-broadcast.js +69 -20
- package/package.json +2 -1
|
@@ -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
|
|
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" ? "
|
|
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
|
|
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
|
|
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
|
|
182
|
-
runner
|
|
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: "
|
|
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
|
|
200
|
-
runner
|
|
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
|
-
|
|
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: "
|
|
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 ??
|
|
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
|
-
|
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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" : "
|
|
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
|
}
|