@co0ontty/wand 4.3.0 → 4.4.0-beta.gdcdccb2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,4 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { spawn } from "node:child_process";
3
- import { existsSync, readFileSync, statSync } from "node:fs";
4
- import path from "node:path";
5
2
  import { query as sdkQuery } from "@anthropic-ai/claude-agent-sdk";
6
3
  import { prepareSessionWorktree } from "./git-worktree.js";
7
4
  import { truncateMessagesForTransport } from "./message-truncator.js";
@@ -10,9 +7,11 @@ import { getErrorMessage } from "./error-utils.js";
10
7
  import { resolveSdkClaudeBinary } from "./claude-sdk-runner.js";
11
8
  import { generateSessionTopic } from "./session-topic.js";
12
9
  import { resolveSessionCwd } from "./session-cwd.js";
13
- import { buildCodexArgs } from "./structured-codex-adapter.js";
14
- import { buildAppendSystemPromptParts, buildClaudeCliArgs, buildClaudeSdkThinking, derivePermissionPolicy, } from "./structured-claude-adapter.js";
15
- import { applyOpenCodeEvent, buildOpenCodeArgs } from "./structured-opencode-adapter.js";
10
+ import { CodexRunner } from "./structured-codex-adapter.js";
11
+ import { normalizeStructuredToolResultContent } from "./structured-content.js";
12
+ import { buildAppendSystemPromptParts, buildClaudeSdkThinking, ClaudeCliRunner, derivePermissionPolicy, } from "./structured-claude-adapter.js";
13
+ import { captureTaskMeta, extractClaudeAssistantMessage, extractClaudeModelName, normalizeClaudeToolInput, stampParentTaskResults, stampSelfTask, tagSubagentBlocks, } from "./structured-claude-protocol.js";
14
+ import { OpenCodeRunner } from "./structured-opencode-adapter.js";
16
15
  import { defaultStructuredRunner, defaultStructuredState, isStructuredRunnerForProvider, normalizeThinkingEffort, resolveStructuredRunner, } from "./structured-provider-common.js";
17
16
  export { isStructuredRunnerForProvider, normalizeThinkingEffort, resolveStructuredRunner, thinkingEffortToClaudeCliEffort, thinkingEffortToCodexReasoningEffort, thinkingEffortToOpenCodeVariant, thinkingEffortToSdkBudget, } from "./structured-provider-common.js";
18
17
  /** The runner already persisted/emitted its detailed terminal snapshot. */
@@ -22,430 +21,6 @@ class PersistedStructuredRunnerError extends Error {
22
21
  this.name = "PersistedStructuredRunnerError";
23
22
  }
24
23
  }
25
- function asRecord(value) {
26
- return value && typeof value === "object" && !Array.isArray(value)
27
- ? value
28
- : null;
29
- }
30
- function getString(value) {
31
- return typeof value === "string" ? value : "";
32
- }
33
- function parseJsonRecord(value) {
34
- if (asRecord(value))
35
- return value;
36
- if (typeof value !== "string" || !value.trim())
37
- return {};
38
- try {
39
- const parsed = JSON.parse(value);
40
- return asRecord(parsed) ?? {};
41
- }
42
- catch {
43
- return {};
44
- }
45
- }
46
- /**
47
- * Preserve both Responses content-part arrays and arbitrary structured tool output.
48
- * Arrays without a `type` discriminator (for example Codex tool_search results)
49
- * are serialized instead of being filtered to an empty result.
50
- */
51
- export function normalizeStructuredToolResultContent(content) {
52
- if (typeof content === "string")
53
- return content;
54
- if (Array.isArray(content)) {
55
- const parts = content.filter((item) => !!item && typeof item === "object" && typeof item.type === "string");
56
- if (parts.length === content.length)
57
- return parts;
58
- try {
59
- return JSON.stringify(content, null, 2);
60
- }
61
- catch {
62
- return String(content);
63
- }
64
- }
65
- return typeof content === "undefined" || content === null ? "" : String(content);
66
- }
67
- function codexPatchToolName(kind) {
68
- if (kind === "add")
69
- return "Write";
70
- return "Edit";
71
- }
72
- function codexPatchResultText(stdout, stderr, success) {
73
- const err = getString(stderr).trim();
74
- const out = getString(stdout).trim();
75
- if (!success)
76
- return err || out || "patch apply failed";
77
- return "";
78
- }
79
- export function buildCodexPatchApplyBlocks(item) {
80
- const changes = asRecord(item.changes);
81
- if (!changes)
82
- return [];
83
- const callId = getString(item.call_id) || getString(item.id) || "patch";
84
- const status = getString(item.status) || "completed";
85
- const success = item.success !== false && status !== "failed";
86
- const resultText = codexPatchResultText(item.stdout, item.stderr, success);
87
- const entries = Object.entries(changes);
88
- const blocks = [];
89
- entries.forEach(([filePath, rawChange], index) => {
90
- const change = asRecord(rawChange) ?? {};
91
- const kind = getString(change.type) || "update";
92
- const unifiedDiff = getString(change.unified_diff);
93
- const movePath = getString(change.move_path);
94
- const toolUseId = `${callId}#${index}`;
95
- const input = {
96
- file_path: filePath,
97
- kind,
98
- status,
99
- };
100
- if (unifiedDiff)
101
- input.unified_diff = unifiedDiff;
102
- if (movePath)
103
- input.move_path = movePath;
104
- blocks.push({
105
- type: "tool_use",
106
- id: toolUseId,
107
- name: codexPatchToolName(kind),
108
- description: kind,
109
- input,
110
- });
111
- blocks.push({
112
- type: "tool_result",
113
- tool_use_id: toolUseId,
114
- content: resultText,
115
- is_error: !success,
116
- });
117
- });
118
- return blocks;
119
- }
120
- const CODEX_FILE_SNAPSHOT_MAX_BYTES = 512 * 1024;
121
- const CODEX_DIFF_MAX_EDIT_DISTANCE = 512;
122
- const CODEX_DIFF_MAX_CHARS = 32 * 1024;
123
- const CODEX_DIFF_CONTEXT_LINES = 3;
124
- function readCodexFileSnapshot(filePath) {
125
- if (!filePath || !existsSync(filePath))
126
- return { exists: false, text: "" };
127
- try {
128
- const stat = statSync(filePath);
129
- if (!stat.isFile()) {
130
- return { exists: true, text: null, unavailableReason: "目标不是普通文件" };
131
- }
132
- if (stat.size > CODEX_FILE_SNAPSHOT_MAX_BYTES) {
133
- return { exists: true, text: null, unavailableReason: "文件过大,未生成差异正文" };
134
- }
135
- const content = readFileSync(filePath);
136
- if (content.includes(0)) {
137
- return { exists: true, text: null, unavailableReason: "二进制文件不支持文本差异" };
138
- }
139
- return { exists: true, text: content.toString("utf8") };
140
- }
141
- catch (error) {
142
- return {
143
- exists: true,
144
- text: null,
145
- unavailableReason: `读取文件失败:${getErrorMessage(error)}`,
146
- };
147
- }
148
- }
149
- /**
150
- * Myers line diff. File snapshots are bounded above; the edit-distance guard
151
- * keeps completely rewritten generated files from consuming quadratic memory.
152
- */
153
- function diffCodexLines(before, after) {
154
- const max = before.length + after.length;
155
- let frontier = new Map([[1, 0]]);
156
- const trace = [];
157
- let completedDistance = -1;
158
- for (let distance = 0; distance <= max && distance <= CODEX_DIFF_MAX_EDIT_DISTANCE; distance++) {
159
- trace.push(new Map(frontier));
160
- for (let diagonal = -distance; diagonal <= distance; diagonal += 2) {
161
- const down = frontier.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY;
162
- const right = frontier.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY;
163
- let oldIndex = diagonal === -distance || (diagonal !== distance && right < down)
164
- ? Math.max(0, down)
165
- : Math.max(0, right + 1);
166
- let newIndex = oldIndex - diagonal;
167
- while (oldIndex < before.length
168
- && newIndex < after.length
169
- && before[oldIndex] === after[newIndex]) {
170
- oldIndex++;
171
- newIndex++;
172
- }
173
- frontier.set(diagonal, oldIndex);
174
- if (oldIndex >= before.length && newIndex >= after.length) {
175
- completedDistance = distance;
176
- break;
177
- }
178
- }
179
- if (completedDistance >= 0)
180
- break;
181
- }
182
- // A very large rewrite is still useful to inspect. This fallback is not
183
- // minimal, but remains truthful and is later clipped by transport/UI limits.
184
- if (completedDistance < 0) {
185
- return [
186
- ...before.map((text) => ({ kind: "delete", text })),
187
- ...after.map((text) => ({ kind: "add", text })),
188
- ];
189
- }
190
- const reversed = [];
191
- let oldIndex = before.length;
192
- let newIndex = after.length;
193
- for (let distance = completedDistance; distance >= 0; distance--) {
194
- const previous = trace[distance];
195
- const diagonal = oldIndex - newIndex;
196
- const down = previous.get(diagonal + 1) ?? Number.NEGATIVE_INFINITY;
197
- const right = previous.get(diagonal - 1) ?? Number.NEGATIVE_INFINITY;
198
- const previousDiagonal = diagonal === -distance || (diagonal !== distance && right < down)
199
- ? diagonal + 1
200
- : diagonal - 1;
201
- const previousOldIndex = Math.max(0, previous.get(previousDiagonal) ?? 0);
202
- const previousNewIndex = previousOldIndex - previousDiagonal;
203
- while (oldIndex > previousOldIndex && newIndex > previousNewIndex) {
204
- reversed.push({ kind: "equal", text: before[oldIndex - 1] });
205
- oldIndex--;
206
- newIndex--;
207
- }
208
- if (distance === 0)
209
- break;
210
- if (oldIndex === previousOldIndex) {
211
- reversed.push({ kind: "add", text: after[newIndex - 1] });
212
- newIndex--;
213
- }
214
- else {
215
- reversed.push({ kind: "delete", text: before[oldIndex - 1] });
216
- oldIndex--;
217
- }
218
- }
219
- return reversed.reverse();
220
- }
221
- function codexDiffPath(filePath) {
222
- return filePath.replace(/[\r\n]/g, " ").replace(/^\/+/, "");
223
- }
224
- function codexDiffLines(text) {
225
- if (!text)
226
- return [];
227
- const normalized = text.replace(/\r\n/g, "\n");
228
- const lines = normalized.split("\n");
229
- if (normalized.endsWith("\n"))
230
- lines.pop();
231
- return lines;
232
- }
233
- function buildCodexUnifiedDiff(filePath, before, after) {
234
- if (before.text === null || after.text === null || before.text === after.text)
235
- return "";
236
- const oldLines = codexDiffLines(before.text);
237
- const newLines = codexDiffLines(after.text);
238
- const lines = diffCodexLines(oldLines, newLines);
239
- const changedIndexes = lines
240
- .map((line, index) => line.kind === "equal" ? -1 : index)
241
- .filter((index) => index >= 0);
242
- if (changedIndexes.length === 0)
243
- return "";
244
- const oldBefore = [];
245
- const newBefore = [];
246
- let oldCount = 0;
247
- let newCount = 0;
248
- lines.forEach((line, index) => {
249
- oldBefore[index] = oldCount;
250
- newBefore[index] = newCount;
251
- if (line.kind !== "add")
252
- oldCount++;
253
- if (line.kind !== "delete")
254
- newCount++;
255
- });
256
- const hunks = [];
257
- for (const changedIndex of changedIndexes) {
258
- const start = Math.max(0, changedIndex - CODEX_DIFF_CONTEXT_LINES);
259
- const end = Math.min(lines.length, changedIndex + CODEX_DIFF_CONTEXT_LINES + 1);
260
- const previous = hunks[hunks.length - 1];
261
- if (previous && start <= previous.end)
262
- previous.end = Math.max(previous.end, end);
263
- else
264
- hunks.push({ start, end });
265
- }
266
- const displayPath = codexDiffPath(filePath);
267
- const output = [
268
- before.exists ? `--- a/${displayPath}` : "--- /dev/null",
269
- after.exists ? `+++ b/${displayPath}` : "+++ /dev/null",
270
- ];
271
- for (const hunk of hunks) {
272
- const hunkLines = lines.slice(hunk.start, hunk.end);
273
- const hunkOldCount = hunkLines.filter((line) => line.kind !== "add").length;
274
- const hunkNewCount = hunkLines.filter((line) => line.kind !== "delete").length;
275
- const hunkOldStart = hunkOldCount === 0 ? oldBefore[hunk.start] : oldBefore[hunk.start] + 1;
276
- const hunkNewStart = hunkNewCount === 0 ? newBefore[hunk.start] : newBefore[hunk.start] + 1;
277
- output.push(`@@ -${hunkOldStart},${hunkOldCount} +${hunkNewStart},${hunkNewCount} @@`);
278
- for (const line of hunkLines) {
279
- output.push(`${line.kind === "add" ? "+" : line.kind === "delete" ? "-" : " "}${line.text}`);
280
- }
281
- }
282
- const diff = output.join("\n");
283
- if (diff.length <= CODEX_DIFF_MAX_CHARS)
284
- return diff;
285
- const cutAt = diff.lastIndexOf("\n", CODEX_DIFF_MAX_CHARS);
286
- return `${diff.slice(0, cutAt > 0 ? cutAt : CODEX_DIFF_MAX_CHARS)}\n…(差异正文已截断)`;
287
- }
288
- export function buildCodexFileChangeBlocks(item, completed, beforeSnapshots = new Map(), afterSnapshots = new Map()) {
289
- const id = getString(item.id) || "file-change";
290
- const rawChanges = Array.isArray(item.changes) ? item.changes : [];
291
- const status = getString(item.status) || (completed ? "completed" : "in_progress");
292
- const isError = status === "failed";
293
- const blocks = [];
294
- rawChanges.forEach((entry, index) => {
295
- const change = asRecord(entry);
296
- if (!change)
297
- return;
298
- const filePath = getString(change.path);
299
- const kind = getString(change.kind) || "update";
300
- const toolUseId = `${id}#${index}`;
301
- const input = { file_path: filePath, kind, status };
302
- const before = beforeSnapshots.get(toolUseId);
303
- const after = afterSnapshots.get(toolUseId);
304
- if (completed && before && after) {
305
- const unifiedDiff = buildCodexUnifiedDiff(filePath, before, after);
306
- if (unifiedDiff)
307
- input.unified_diff = unifiedDiff;
308
- const unavailableReason = before.unavailableReason || after.unavailableReason;
309
- if (!unifiedDiff && unavailableReason)
310
- input.diff_unavailable_reason = unavailableReason;
311
- }
312
- blocks.push({
313
- type: "tool_use",
314
- id: toolUseId,
315
- name: codexPatchToolName(kind),
316
- description: kind,
317
- input,
318
- });
319
- if (completed) {
320
- blocks.push({
321
- type: "tool_result",
322
- tool_use_id: toolUseId,
323
- content: isError ? `file change failed: ${filePath}` : "",
324
- is_error: isError,
325
- });
326
- }
327
- });
328
- return blocks;
329
- }
330
- /**
331
- * Codex `exec --json` only publishes authoritative usage with `turn.completed`.
332
- * Keep the bottom usage row useful while the turn is running by estimating the
333
- * model-produced text/tool arguments; the final provider value replaces this.
334
- */
335
- export function estimateCodexOutputTokens(blocks) {
336
- let asciiUnits = 0;
337
- let wideUnits = 0;
338
- const addText = (value) => {
339
- for (const char of value) {
340
- if (char.codePointAt(0) <= 0x7f)
341
- asciiUnits += 1;
342
- else
343
- wideUnits += 1;
344
- }
345
- };
346
- for (const block of blocks) {
347
- if (block.type === "text")
348
- addText(block.text);
349
- else if (block.type === "thinking")
350
- addText(block.thinking);
351
- else if (block.type === "tool_use") {
352
- addText(block.name);
353
- try {
354
- addText(JSON.stringify(block.input));
355
- }
356
- catch { /* best-effort live estimate */ }
357
- }
358
- }
359
- if (asciiUnits === 0 && wideUnits === 0)
360
- return 0;
361
- return Math.max(1, Math.ceil(asciiUnits / 4 + wideUnits));
362
- }
363
- function refreshEstimatedCodexUsage(turnState) {
364
- if (turnState.usage?.estimated !== true)
365
- return;
366
- turnState.usage = {
367
- outputTokens: estimateCodexOutputTokens(turnState.blocks),
368
- estimated: true,
369
- };
370
- }
371
- function captureTaskMeta(blocks, registry) {
372
- for (const b of blocks) {
373
- if (b.type !== "tool_use")
374
- continue;
375
- if (registry.has(b.id))
376
- continue;
377
- const input = b.input ?? {};
378
- // Claude SDK 把这类"派 subagent 干活"的内置工具叫做 "Agent",CLI/旧版本里
379
- // 也叫过 "Task"。判定不靠工具名(容易随版本变),而是看 input 是否含有
380
- // `subagent_type` 字段——这是 Agent/Task 系列的唯一标志。
381
- const agentType = typeof input.subagent_type === "string" ? input.subagent_type : undefined;
382
- if (!agentType && b.name !== "Task" && b.name !== "Agent")
383
- continue;
384
- const description = typeof input.description === "string" ? input.description : undefined;
385
- registry.set(b.id, { agentType, description });
386
- }
387
- }
388
- /**
389
- * Stamp every block with `__subagent` meta keyed to `parentToolUseId`. When
390
- * the id has no entry yet (rare race: subagent emits before we see the parent
391
- * Task tool_use), we still stamp the bare taskId so the UI can group blocks;
392
- * agentType / description backfill on later updates.
393
- */
394
- function tagSubagentBlocks(blocks, parentToolUseId, registry) {
395
- if (!parentToolUseId)
396
- return blocks;
397
- const meta = registry.get(parentToolUseId);
398
- const stamp = {
399
- taskId: parentToolUseId,
400
- ...(meta?.agentType ? { agentType: meta.agentType } : {}),
401
- ...(meta?.description ? { taskDescription: meta.description } : {}),
402
- };
403
- return blocks.map((block) => ({ ...block, __subagent: stamp }));
404
- }
405
- /**
406
- * 给已被 captureTaskMeta 识别为 Task/Agent 的 tool_use block 本身也盖 __subagent 章。
407
- * taskId 用自己的 block.id —— 与子消息的 parent_tool_use_id(也等于这个 id)保持一致,
408
- * 前端 splitTurnBySubagent 按 taskId 分组时父 Task tool_use 和 SDK 转发的子消息能合并到同一段。
409
- */
410
- function stampSelfTask(blocks, registry) {
411
- return blocks.map((b) => {
412
- if (b.type !== "tool_use")
413
- return b;
414
- if (b.__subagent)
415
- return b; // 已盖章不重复(防止幂等问题)
416
- const meta = registry.get(b.id);
417
- if (!meta && b.name !== "Task" && b.name !== "Agent")
418
- return b;
419
- const stamp = {
420
- taskId: b.id,
421
- ...(meta?.agentType ? { agentType: meta.agentType } : {}),
422
- ...(meta?.description ? { taskDescription: meta.description } : {}),
423
- };
424
- return { ...b, __subagent: stamp };
425
- });
426
- }
427
- /**
428
- * 当父 assistant 在 parentToolUseId === null 的 user turn 里收到 Task 工具的 tool_result 时,
429
- * tagSubagentBlocks 不会被调用(它只在 parentToolUseId 非空时盖章)。这里按 tool_use_id
430
- * 反查 registry,给这条 tool_result 单独盖章,让前端能把它归到同一个 subagent 段。
431
- */
432
- function stampParentTaskResults(blocks, registry) {
433
- return blocks.map((b) => {
434
- if (b.type !== "tool_result")
435
- return b;
436
- if (b.__subagent)
437
- return b;
438
- const meta = registry.get(b.tool_use_id);
439
- if (!meta)
440
- return b;
441
- const stamp = {
442
- taskId: b.tool_use_id,
443
- ...(meta.agentType ? { agentType: meta.agentType } : {}),
444
- ...(meta.description ? { taskDescription: meta.description } : {}),
445
- };
446
- return { ...b, __subagent: stamp };
447
- });
448
- }
449
24
  const STREAM_EMIT_DEBOUNCE_MS = 16;
450
25
  /** Min interval between full saveSession() calls for an in-progress streaming turn.
451
26
  * saveSession serializes the entire messages array, so doing it on every NDJSON
@@ -584,7 +159,7 @@ export class StructuredSessionManager {
584
159
  logger;
585
160
  sdkQueryFactory;
586
161
  sessions = new Map();
587
- pendingChildren = new Map();
162
+ pendingRunnerExecutions = new Map();
588
163
  pendingSdkAbort = new Map();
589
164
  /**
590
165
  * Active SDK Query handle per session, kept around so we can call
@@ -617,12 +192,18 @@ export class StructuredSessionManager {
617
192
  archiveTimer = null;
618
193
  topicRequests = new Set();
619
194
  streamEmitTimers = new Set();
195
+ claudeCliRunner;
196
+ codexRunner;
197
+ openCodeRunner;
620
198
  disposed = false;
621
- constructor(storage, config, logger = null, sdkQueryFactory = sdkQuery) {
199
+ constructor(storage, config, logger = null, sdkQueryFactory = sdkQuery, runners = {}) {
622
200
  this.storage = storage;
623
201
  this.config = config;
624
202
  this.logger = logger;
625
203
  this.sdkQueryFactory = sdkQueryFactory;
204
+ this.claudeCliRunner = runners.claudeCli ?? new ClaudeCliRunner({ language: () => this.config.language });
205
+ this.codexRunner = runners.codex ?? new CodexRunner();
206
+ this.openCodeRunner = runners.opencode ?? new OpenCodeRunner();
626
207
  for (const snapshot of this.storage.loadSessions()) {
627
208
  if ((snapshot.sessionKind ?? "pty") !== "structured")
628
209
  continue;
@@ -706,7 +287,7 @@ export class StructuredSessionManager {
706
287
  clearTimeout(timer);
707
288
  this.streamEmitTimers.clear();
708
289
  const activeSessionIds = new Set([
709
- ...this.pendingChildren.keys(),
290
+ ...this.pendingRunnerExecutions.keys(),
710
291
  ...this.pendingSdkQueries.keys(),
711
292
  ...this.pendingSdkAbort.keys(),
712
293
  ...Array.from(this.sessions.values())
@@ -737,18 +318,14 @@ export class StructuredSessionManager {
737
318
  }
738
319
  catch { /* best-effort shutdown flush */ }
739
320
  }
740
- for (const child of this.pendingChildren.values()) {
741
- try {
742
- child.kill();
743
- }
744
- catch { /* ignore */ }
745
- }
321
+ for (const execution of this.pendingRunnerExecutions.values())
322
+ execution.interrupt();
746
323
  for (const query of this.pendingSdkQueries.values()) {
747
324
  void query.interrupt().catch(() => { });
748
325
  }
749
326
  for (const controller of this.pendingSdkAbort.values())
750
327
  controller.abort();
751
- this.pendingChildren.clear();
328
+ this.pendingRunnerExecutions.clear();
752
329
  this.pendingSdkQueries.clear();
753
330
  this.pendingSdkAbort.clear();
754
331
  this.interruptedWith.clear();
@@ -995,17 +572,16 @@ export class StructuredSessionManager {
995
572
  }
996
573
  }
997
574
  if (session.structuredState?.inFlight) {
998
- const child = this.pendingChildren.get(id);
575
+ const runnerExecution = this.pendingRunnerExecutions.get(id);
999
576
  const sdkAbort = this.pendingSdkAbort.get(id);
1000
577
  const sdkQueryHandle = this.pendingSdkQueries.get(id);
1001
- // ChildProcess.killed only means kill() successfully sent a signal; the
1002
- // process can keep running until its close/error callback releases this
1003
- // exact handle. Treat map ownership as the authoritative in-flight state.
1004
- const childActive = Boolean(child);
578
+ // interrupt() only requests cancellation; completion can settle later.
579
+ // Treat runner-map ownership as the authoritative in-flight state.
580
+ const childActive = Boolean(runnerExecution);
1005
581
  const sdkAlive = Boolean(sdkQueryHandle || (sdkAbort && !sdkAbort.signal.aborted));
1006
582
  if (!childActive && !sdkAlive) {
1007
- if (child)
1008
- this.releasePendingChild(id, child);
583
+ if (runnerExecution)
584
+ this.releasePendingRunnerExecution(id, runnerExecution);
1009
585
  if (sdkAbort)
1010
586
  this.releasePendingSdkAbort(id, sdkAbort);
1011
587
  const recovered = {
@@ -1046,12 +622,7 @@ export class StructuredSessionManager {
1046
622
  else {
1047
623
  this.preserveQueueOnInterrupt.delete(id);
1048
624
  }
1049
- if (childActive && child) {
1050
- try {
1051
- child.kill("SIGTERM");
1052
- }
1053
- catch (_err) { /* ignore */ }
1054
- }
625
+ runnerExecution?.interrupt();
1055
626
  if (sdkQueryHandle) {
1056
627
  void sdkQueryHandle.interrupt().catch(() => { });
1057
628
  }
@@ -1416,10 +987,10 @@ export class StructuredSessionManager {
1416
987
  },
1417
988
  };
1418
989
  this.sessions.set(id, cancelled);
1419
- const child = this.pendingChildren.get(id);
1420
- if (child) {
1421
- child.kill();
1422
- this.releasePendingChild(id, child);
990
+ const runnerExecution = this.pendingRunnerExecutions.get(id);
991
+ if (runnerExecution) {
992
+ runnerExecution.interrupt();
993
+ this.releasePendingRunnerExecution(id, runnerExecution);
1423
994
  }
1424
995
  // SDK runner:先尝试 query.interrupt() 优雅停止,失败再走 abort。
1425
996
  // 两个都清掉避免后续重复操作。
@@ -1439,15 +1010,15 @@ export class StructuredSessionManager {
1439
1010
  return cancelled;
1440
1011
  }
1441
1012
  delete(id) {
1442
- const child = this.pendingChildren.get(id);
1013
+ const runnerExecution = this.pendingRunnerExecutions.get(id);
1443
1014
  const sdkQuery = this.pendingSdkQueries.get(id);
1444
1015
  const sdkAbort = this.pendingSdkAbort.get(id);
1445
- // Invalidate callback ownership before signalling the runner. Abort/kill can
1446
- // synchronously wake listeners in some SDK/ChildProcess implementations.
1016
+ // Invalidate callback ownership before signalling the runner. Cancellation
1017
+ // can synchronously wake listeners in some SDK/adapter implementations.
1447
1018
  this.sessions.delete(id);
1448
- if (child) {
1449
- child.kill();
1450
- this.releasePendingChild(id, child);
1019
+ if (runnerExecution) {
1020
+ runnerExecution.interrupt();
1021
+ this.releasePendingRunnerExecution(id, runnerExecution);
1451
1022
  }
1452
1023
  if (sdkQuery) {
1453
1024
  void sdkQuery.interrupt().catch(() => { });
@@ -1483,10 +1054,10 @@ export class StructuredSessionManager {
1483
1054
  return this.sessions.get(sessionId) ?? null;
1484
1055
  }
1485
1056
  /** Delete a handle only if it still belongs to the execution doing cleanup. */
1486
- releasePendingChild(sessionId, child) {
1487
- if (this.pendingChildren.get(sessionId) !== child)
1057
+ releasePendingRunnerExecution(sessionId, execution) {
1058
+ if (this.pendingRunnerExecutions.get(sessionId) !== execution)
1488
1059
  return false;
1489
- this.pendingChildren.delete(sessionId);
1060
+ this.pendingRunnerExecutions.delete(sessionId);
1490
1061
  return true;
1491
1062
  }
1492
1063
  releasePendingSdkAbort(sessionId, controller) {
@@ -1579,532 +1150,315 @@ export class StructuredSessionManager {
1579
1150
  // ---------------------------------------------------------------------------
1580
1151
  // Streaming codex exec --json execution
1581
1152
  // ---------------------------------------------------------------------------
1582
- runCodexStreaming(sessionId, session, prompt, requestId) {
1583
- return new Promise((resolve, reject) => {
1584
- const args = buildCodexArgs(session);
1585
- const spawnedAt = new Date().toISOString();
1586
- const child = spawn("codex", args, {
1587
- cwd: session.cwd,
1588
- env: buildChildEnv(this.config.inheritEnv !== false),
1589
- stdio: ["pipe", "pipe", "pipe"],
1590
- });
1591
- this.logger?.appendStructuredSpawn(sessionId, {
1592
- kind: "codex-exec",
1593
- provider: "codex",
1594
- pid: child.pid ?? null,
1595
- cwd: session.cwd,
1596
- args,
1597
- prompt: prompt.slice(0, 2048),
1598
- promptLength: prompt.length,
1599
- threadId: session.claudeSessionId,
1600
- spawnedAt,
1601
- });
1602
- this.pendingChildren.set(sessionId, child);
1603
- child.stdin?.end(prompt);
1604
- const turnState = {
1605
- blocks: [],
1606
- result: "",
1607
- sessionId: session.claudeSessionId,
1608
- model: session.selectedModel ?? session.structuredState?.model,
1609
- usage: { outputTokens: 0, estimated: true },
1610
- codexBlockIndex: new Map(),
1611
- codexFileSnapshots: new Map(),
1612
- cwd: session.cwd,
1613
- };
1614
- let lineBuf = "";
1615
- let stderr = "";
1616
- let emitTimer = null;
1617
- let settled = false;
1618
- // codex 把所有错误(包括重试日志和最终失败原因)都通过 stdout 的 NDJSON 事件
1619
- // 输出,stderr 通常是空的。我们在 processLine 里收集这些,然后在 close 中
1620
- // 决定真正的报错文本。
1621
- const codexErrors = [];
1622
- let codexTurnFailed = null;
1623
- const flushEmit = () => {
1624
- if (emitTimer) {
1625
- this.clearStreamEmitTimer(emitTimer);
1626
- emitTimer = null;
1627
- }
1628
- const current = this.currentSessionForRequest(sessionId, requestId);
1629
- if (!current)
1630
- return;
1631
- this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
1632
- };
1633
- const scheduleEmit = () => {
1634
- if (!emitTimer)
1635
- emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
1636
- };
1637
- const syncSnapshot = () => {
1638
- const current = this.currentSessionForRequest(sessionId, requestId);
1639
- if (!current)
1640
- return;
1641
- refreshEstimatedCodexUsage(turnState);
1642
- const inProgressTurn = {
1643
- role: "assistant",
1644
- content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1645
- usage: turnState.usage,
1646
- };
1647
- const msgs = [...(current.messages ?? [])];
1648
- const lastMsg = msgs[msgs.length - 1];
1649
- if (lastMsg && lastMsg.role === "assistant") {
1650
- msgs[msgs.length - 1] = inProgressTurn;
1651
- }
1652
- else {
1653
- msgs.push(inProgressTurn);
1654
- }
1655
- const patched = {
1656
- ...current,
1657
- claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1658
- messages: msgs,
1659
- output: turnState.result || current.output,
1660
- structuredState: {
1661
- ...current.structuredState,
1662
- model: turnState.model ?? current.structuredState?.model,
1663
- },
1664
- };
1665
- this.sessions.set(sessionId, patched);
1666
- this.saveStreamingSnapshot(patched);
1153
+ async runCodexStreaming(sessionId, session, prompt, requestId) {
1154
+ let emitTimer = null;
1155
+ const syncSnapshot = (turnState) => {
1156
+ const current = this.currentSessionForRequest(sessionId, requestId);
1157
+ if (!current)
1158
+ return;
1159
+ const turn = {
1160
+ role: "assistant",
1161
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1162
+ usage: turnState.usage,
1667
1163
  };
1668
- const processLine = (line) => {
1669
- if (!this.isCurrentRequest(sessionId, requestId))
1670
- return;
1671
- const trimmed = line.trim();
1672
- if (!trimmed)
1673
- return;
1674
- let parsed;
1675
- try {
1676
- parsed = JSON.parse(trimmed);
1677
- }
1678
- catch {
1679
- return;
1680
- }
1681
- this.logger?.appendStreamEvent(sessionId, parsed);
1682
- const event = this.unwrapCodexStreamEvent(parsed);
1683
- if (event?.type === "thread.started" && typeof event.thread_id === "string") {
1684
- turnState.sessionId = event.thread_id;
1685
- syncSnapshot();
1686
- return;
1687
- }
1688
- if (event?.type === "item.started" && asRecord(event.item)) {
1689
- this.applyCodexItem(turnState, event.item, "started");
1690
- syncSnapshot();
1691
- scheduleEmit();
1692
- return;
1693
- }
1694
- if (event?.type === "item.updated" && asRecord(event.item)) {
1695
- // codex `item.updated` 重新发送完整 ThreadItem(不是 delta)。
1696
- // 对 text/thinking/TodoWrite 走 codexBlockIndex 替换;对 tool_use
1697
- // 仍然按现有 id 复用,避免重复卡片。
1698
- this.applyCodexItem(turnState, event.item, "updated");
1699
- syncSnapshot();
1700
- scheduleEmit();
1701
- return;
1702
- }
1703
- if (event?.type === "item.completed" && asRecord(event.item)) {
1704
- this.applyCodexItem(turnState, event.item, "completed");
1705
- syncSnapshot();
1706
- scheduleEmit();
1707
- return;
1708
- }
1709
- if (event?.type === "turn.completed") {
1710
- turnState.usage = this.extractCodexUsage(asRecord(event.usage) ?? undefined) ?? turnState.usage;
1711
- syncSnapshot();
1712
- scheduleEmit();
1713
- return;
1714
- }
1715
- if (event?.type === "token_count") {
1716
- const info = asRecord(event.info);
1717
- const lastUsage = asRecord(info?.last_token_usage);
1718
- turnState.usage = this.extractCodexUsage(lastUsage ?? undefined) ?? turnState.usage;
1719
- syncSnapshot();
1720
- scheduleEmit();
1721
- return;
1722
- }
1723
- if (this.applyCodexLooseEvent(turnState, event)) {
1724
- syncSnapshot();
1725
- scheduleEmit();
1726
- return;
1727
- }
1728
- if (event?.type === "error") {
1729
- const message = typeof event.message === "string" ? event.message : "";
1730
- if (message)
1731
- codexErrors.push(message);
1732
- return;
1733
- }
1734
- if (event?.type === "turn.failed") {
1735
- const errObj = (event.error && typeof event.error === "object") ? event.error : null;
1736
- const message = (errObj && typeof errObj.message === "string" && errObj.message)
1737
- || (typeof event.message === "string" ? event.message : "")
1738
- || "codex turn failed";
1739
- codexTurnFailed = message;
1740
- return;
1741
- }
1164
+ const messages = [...(current.messages ?? [])];
1165
+ if (messages[messages.length - 1]?.role === "assistant")
1166
+ messages[messages.length - 1] = turn;
1167
+ else
1168
+ messages.push(turn);
1169
+ const patched = {
1170
+ ...current,
1171
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1172
+ messages,
1173
+ output: turnState.result || current.output,
1174
+ structuredState: {
1175
+ ...current.structuredState,
1176
+ model: turnState.model ?? current.structuredState?.model,
1177
+ },
1742
1178
  };
1743
- child.stdout?.on("data", (chunk) => {
1744
- if (!this.isCurrentRequest(sessionId, requestId))
1745
- return;
1746
- const text = chunk.toString();
1747
- this.logger?.appendStructuredStdout(sessionId, text);
1748
- lineBuf += text;
1749
- const lines = lineBuf.split("\n");
1750
- lineBuf = lines.pop() ?? "";
1751
- for (const line of lines)
1752
- processLine(line);
1753
- });
1754
- child.stderr?.on("data", (chunk) => {
1755
- if (!this.isCurrentRequest(sessionId, requestId))
1756
- return;
1757
- const text = chunk.toString();
1758
- this.logger?.appendStructuredStderr(sessionId, text);
1759
- stderr += text;
1760
- });
1761
- child.on("error", (error) => {
1762
- const released = this.releasePendingChild(sessionId, child);
1763
- if (released)
1764
- this.cancelStreamingCheckpointTimer(sessionId);
1765
- if (emitTimer)
1766
- this.clearStreamEmitTimer(emitTimer);
1767
- if (settled)
1768
- return;
1769
- if (!this.isCurrentRequest(sessionId, requestId)) {
1770
- settled = true;
1771
- resolve();
1772
- return;
1773
- }
1774
- settled = true;
1775
- this.logger?.appendStructuredSpawn(sessionId, {
1776
- kind: "codex-exec-error",
1777
- pid: child.pid ?? null,
1778
- spawnedAt,
1779
- closedAt: new Date().toISOString(),
1780
- spawnError: error.message,
1781
- });
1782
- // spawn 直接失败(最常见是 ENOENT —— PATH 里找不到 codex 可执行文件)。
1783
- // 之前只 reject(error),外层 catch 会把 error.message 直接当 lastError,
1784
- // 用户看到的就是裸的 "spawn codex ENOENT",没法快速反应。这里加一层
1785
- // 包装把上下文(runner 名 + 常见排查建议)拼好。
1786
- const nodeErr = error;
1787
- const hint = nodeErr.code === "ENOENT"
1788
- ? "(PATH 中找不到 codex 可执行文件;请确认 codex 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
1789
- : "";
1790
- reject(new Error(`codex exec 启动失败:${error.message}${hint}`));
1791
- });
1792
- child.on("close", (code, signal) => {
1793
- const released = this.releasePendingChild(sessionId, child);
1794
- if (released)
1795
- this.cancelStreamingCheckpointTimer(sessionId);
1796
- if (settled)
1797
- return;
1798
- if (!this.isCurrentRequest(sessionId, requestId)) {
1799
- if (emitTimer)
1800
- this.clearStreamEmitTimer(emitTimer);
1801
- settled = true;
1802
- resolve();
1803
- return;
1804
- }
1805
- if (lineBuf.trim()) {
1806
- processLine(lineBuf);
1807
- lineBuf = "";
1808
- }
1809
- flushEmit();
1810
- const closedAt = new Date().toISOString();
1811
- this.logger?.appendStructuredSpawn(sessionId, {
1812
- kind: "codex-exec-close",
1813
- pid: child.pid ?? null,
1814
- spawnedAt,
1815
- closedAt,
1816
- exitCode: code,
1817
- stderrTail: stderr.slice(-2048),
1818
- codexErrors,
1819
- codexTurnFailed,
1179
+ this.sessions.set(sessionId, patched);
1180
+ this.saveStreamingSnapshot(patched);
1181
+ };
1182
+ const flushEmit = () => {
1183
+ if (emitTimer)
1184
+ this.clearStreamEmitTimer(emitTimer);
1185
+ emitTimer = null;
1186
+ const current = this.currentSessionForRequest(sessionId, requestId);
1187
+ if (current) {
1188
+ this.emit({
1189
+ type: "output",
1190
+ sessionId,
1191
+ data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}),
1820
1192
  });
1821
- const current = this.currentSessionForRequest(sessionId, requestId);
1822
- if (!current) {
1823
- settled = true;
1824
- resolve();
1825
- return;
1826
- }
1827
- // 主动中断时(interruptedWith 里有新消息),不走失败路径
1828
- const interruptedByUser = this.interruptedWith.has(sessionId);
1829
- const interruptPrompt = this.interruptedWith.get(sessionId);
1830
- // codex 把模型/网络/沙箱等错误写到 stdout 的 NDJSON 流(type: error / turn.failed),
1831
- // 而不是 stderr。我们以 turn.failed message 为准,其次是最后一个 error 事件。
1832
- const codexFailed = codexTurnFailed !== null;
1833
- if ((codexFailed || (code !== 0 && code !== null) || signal) && !interruptedByUser) {
1834
- const errorText = this.formatStructuredExitError("codex exec", code, signal, {
1835
- stderr,
1836
- primary: codexTurnFailed,
1837
- extras: codexErrors,
1838
- });
1839
- const exitForSnapshot = typeof code === "number" ? code : 1;
1840
- const failed = this.finishStructuredFailure(current, exitForSnapshot, errorText, turnState);
1841
- this.sessions.set(sessionId, failed);
1842
- this.saveAuthoritativeSession(failed);
1843
- this.emitStructuredSnapshot(failed);
1844
- this.emitStructuredSnapshot(failed, "ended");
1845
- settled = true;
1846
- reject(new PersistedStructuredRunnerError(errorText));
1847
- return;
1848
- }
1849
- const msgs = this.buildCompletedAssistantMessages(current, turnState);
1850
- const keepRunning = !!interruptPrompt;
1851
- const finished = {
1852
- ...current,
1853
- status: keepRunning ? "running" : "idle",
1854
- exitCode: keepRunning ? null : 0,
1855
- endedAt: keepRunning ? null : new Date().toISOString(),
1856
- output: turnState.result,
1857
- claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1858
- messages: msgs,
1859
- queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
1860
- pendingEscalation: null,
1861
- permissionBlocked: false,
1862
- structuredState: {
1863
- ...current.structuredState,
1864
- model: turnState.model ?? current.structuredState?.model,
1865
- inFlight: false,
1866
- activeRequestId: null,
1867
- lastError: null,
1868
- },
1869
- };
1870
- this.sessions.set(sessionId, finished);
1871
- this.saveAuthoritativeSession(finished);
1872
- this.emitStructuredSnapshot(finished);
1873
- if (!keepRunning) {
1874
- this.emitStructuredSnapshot(finished, "ended");
1875
- }
1876
- if (interruptPrompt) {
1877
- this.interruptedWith.delete(sessionId);
1878
- // 把"保留队列"标记一并清掉——不属于本次 interrupt 的后续轮次会按
1879
- // 默认(清空 queue)行为走,避免 stale flag 影响下一次普通 interrupt。
1880
- // 注意:被保留的 queuedMessages 不需要在这里主动 flush,重发的
1881
- // interruptPrompt 跑完会自然触发 flushNextQueuedMessage。
1882
- this.preserveQueueOnInterrupt.delete(sessionId);
1883
- settled = true;
1884
- resolve();
1885
- setImmediate(() => {
1886
- this.sendMessage(sessionId, interruptPrompt).catch((err) => {
1887
- console.error("[WAND] codex interrupt-and-send failed:", err);
1888
- });
1889
- });
1890
- return;
1891
- }
1892
- settled = true;
1893
- resolve();
1894
- setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
1895
- });
1896
- });
1897
- }
1898
- runOpenCodeStreaming(sessionId, session, prompt, requestId) {
1899
- return new Promise((resolve, reject) => {
1900
- const args = buildOpenCodeArgs(session);
1901
- const spawnedAt = new Date().toISOString();
1902
- const child = spawn("opencode", args, {
1903
- cwd: session.cwd,
1904
- env: buildChildEnv(this.config.inheritEnv !== false),
1905
- stdio: ["pipe", "pipe", "pipe"],
1906
- });
1907
- this.logger?.appendStructuredSpawn(sessionId, {
1908
- kind: "opencode-run",
1909
- provider: "opencode",
1910
- pid: child.pid ?? null,
1911
- cwd: session.cwd,
1912
- args,
1913
- prompt: prompt.slice(0, 2048),
1914
- promptLength: prompt.length,
1915
- sessionId: session.claudeSessionId,
1916
- spawnedAt,
1917
- });
1918
- this.pendingChildren.set(sessionId, child);
1919
- child.stdin?.end(prompt);
1920
- const turnState = {
1921
- blocks: [],
1922
- result: "",
1923
- sessionId: session.claudeSessionId,
1924
- model: session.selectedModel ?? session.structuredState?.model,
1925
- usage: undefined,
1926
- codexBlockIndex: new Map(),
1927
- cwd: session.cwd,
1928
- };
1929
- let lineBuf = "";
1930
- let stderr = "";
1931
- let primaryError = null;
1932
- let emitTimer = null;
1933
- let settled = false;
1934
- const syncSnapshot = () => {
1935
- const current = this.currentSessionForRequest(sessionId, requestId);
1936
- if (!current)
1937
- return;
1938
- const turn = {
1939
- role: "assistant",
1940
- content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1941
- usage: turnState.usage,
1942
- };
1943
- const messages = [...(current.messages ?? [])];
1944
- if (messages[messages.length - 1]?.role === "assistant")
1945
- messages[messages.length - 1] = turn;
1946
- else
1947
- messages.push(turn);
1948
- const patched = {
1949
- ...current,
1950
- claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1951
- messages,
1952
- output: turnState.result || current.output,
1953
- };
1954
- this.sessions.set(sessionId, patched);
1955
- this.saveStreamingSnapshot(patched);
1956
- };
1957
- const flushEmit = () => {
1958
- if (emitTimer)
1959
- this.clearStreamEmitTimer(emitTimer);
1960
- emitTimer = null;
1961
- const current = this.currentSessionForRequest(sessionId, requestId);
1962
- if (current)
1963
- this.emit({ type: "output", sessionId, data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}) });
1964
- };
1965
- const scheduleEmit = () => {
1966
- if (!emitTimer)
1967
- emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
1968
- };
1969
- const processLine = (line) => {
1970
- if (!this.isCurrentRequest(sessionId, requestId))
1971
- return;
1972
- const trimmed = line.trim();
1973
- if (!trimmed)
1974
- return;
1975
- let event;
1976
- try {
1977
- event = JSON.parse(trimmed);
1978
- }
1979
- catch {
1980
- return;
1981
- }
1982
- this.logger?.appendStreamEvent(sessionId, event);
1983
- const error = applyOpenCodeEvent(turnState, event);
1984
- if (error)
1985
- primaryError = error;
1986
- syncSnapshot();
1193
+ }
1194
+ };
1195
+ const scheduleEmit = () => {
1196
+ if (!emitTimer) {
1197
+ emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
1198
+ }
1199
+ };
1200
+ const execution = this.codexRunner.start({
1201
+ session,
1202
+ prompt,
1203
+ env: buildChildEnv(this.config.inheritEnv !== false),
1204
+ }, {
1205
+ isActive: () => this.isCurrentRequest(sessionId, requestId),
1206
+ onStdout: (text) => this.logger?.appendStructuredStdout(sessionId, text),
1207
+ onStderr: (text) => this.logger?.appendStructuredStderr(sessionId, text),
1208
+ onEvent: (event) => this.logger?.appendStreamEvent(sessionId, event),
1209
+ onUpdate: (turnState) => {
1210
+ syncSnapshot(turnState);
1987
1211
  scheduleEmit();
1988
- };
1989
- child.stdout?.on("data", (chunk) => {
1990
- if (!this.isCurrentRequest(sessionId, requestId))
1991
- return;
1992
- const text = chunk.toString();
1993
- this.logger?.appendStructuredStdout(sessionId, text);
1994
- lineBuf += text;
1995
- const lines = lineBuf.split("\n");
1996
- lineBuf = lines.pop() ?? "";
1997
- for (const line of lines)
1998
- processLine(line);
1999
- });
2000
- child.stderr?.on("data", (chunk) => {
2001
- if (!this.isCurrentRequest(sessionId, requestId))
2002
- return;
2003
- const text = chunk.toString();
2004
- this.logger?.appendStructuredStderr(sessionId, text);
2005
- stderr += text;
2006
- });
2007
- child.on("error", (error) => {
2008
- const released = this.releasePendingChild(sessionId, child);
2009
- if (released)
2010
- this.cancelStreamingCheckpointTimer(sessionId);
2011
- if (emitTimer)
2012
- this.clearStreamEmitTimer(emitTimer);
2013
- if (settled)
2014
- return;
2015
- if (!this.isCurrentRequest(sessionId, requestId)) {
2016
- settled = true;
2017
- resolve();
2018
- return;
2019
- }
2020
- settled = true;
2021
- const nodeError = error;
2022
- const hint = nodeError.code === "ENOENT"
2023
- ? "(PATH 中找不到 opencode;请安装 opencode-ai,或重跑 `wand service:install` 刷新服务 PATH)"
2024
- : "";
2025
- reject(new Error(`opencode run 启动失败:${error.message}${hint}`));
2026
- });
2027
- child.on("close", (code, signal) => {
2028
- const released = this.releasePendingChild(sessionId, child);
2029
- if (released)
2030
- this.cancelStreamingCheckpointTimer(sessionId);
2031
- if (settled)
2032
- return;
2033
- if (!this.isCurrentRequest(sessionId, requestId)) {
2034
- if (emitTimer)
2035
- this.clearStreamEmitTimer(emitTimer);
2036
- settled = true;
2037
- resolve();
2038
- return;
2039
- }
2040
- if (lineBuf.trim())
2041
- processLine(lineBuf);
2042
- flushEmit();
2043
- const current = this.currentSessionForRequest(sessionId, requestId);
2044
- if (!current) {
2045
- settled = true;
2046
- resolve();
2047
- return;
2048
- }
2049
- const interruptedByUser = this.interruptedWith.has(sessionId);
2050
- const interruptPrompt = this.interruptedWith.get(sessionId);
2051
- if ((primaryError || (code !== 0 && code !== null) || signal) && !interruptedByUser) {
2052
- const legacyHint = /unknown command|unknown flag|No help topic for 'run'/i.test(stderr)
2053
- ? "\n检测到旧版 OpenCode CLI;请卸载 0.0.x 旧包并安装 `opencode-ai@latest`。"
2054
- : "";
2055
- const errorText = this.formatStructuredExitError("opencode run", code, signal, { stderr, primary: primaryError }) + legacyHint;
2056
- const failed = this.finishStructuredFailure(current, typeof code === "number" ? code : 1, errorText, turnState);
2057
- this.sessions.set(sessionId, failed);
2058
- this.saveAuthoritativeSession(failed);
2059
- this.emitStructuredSnapshot(failed);
2060
- this.emitStructuredSnapshot(failed, "ended");
2061
- settled = true;
2062
- reject(new PersistedStructuredRunnerError(errorText));
2063
- return;
2064
- }
2065
- const messages = this.buildCompletedAssistantMessages(current, turnState);
2066
- const keepRunning = !!interruptPrompt;
2067
- const finished = {
2068
- ...current,
2069
- status: keepRunning ? "running" : "idle",
2070
- exitCode: keepRunning ? null : 0,
2071
- endedAt: keepRunning ? null : new Date().toISOString(),
2072
- output: turnState.result,
2073
- claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2074
- messages,
2075
- queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
2076
- pendingEscalation: null,
2077
- permissionBlocked: false,
2078
- structuredState: {
2079
- ...current.structuredState,
2080
- model: turnState.model ?? current.structuredState?.model,
2081
- inFlight: false,
2082
- activeRequestId: null,
2083
- lastError: null,
2084
- },
2085
- };
2086
- this.sessions.set(sessionId, finished);
2087
- this.saveAuthoritativeSession(finished);
2088
- this.emitStructuredSnapshot(finished);
2089
- if (!keepRunning)
2090
- this.emitStructuredSnapshot(finished, "ended");
2091
- if (interruptPrompt) {
2092
- this.interruptedWith.delete(sessionId);
2093
- this.preserveQueueOnInterrupt.delete(sessionId);
2094
- settled = true;
2095
- resolve();
2096
- setImmediate(() => {
2097
- this.sendMessage(sessionId, interruptPrompt).catch((error) => {
2098
- console.error("[WAND] opencode interrupt-and-send failed:", error);
2099
- });
2100
- });
2101
- return;
2102
- }
2103
- settled = true;
2104
- resolve();
2105
- setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
1212
+ },
1213
+ });
1214
+ this.pendingRunnerExecutions.set(sessionId, execution);
1215
+ this.logger?.appendStructuredSpawn(sessionId, {
1216
+ kind: "codex-exec",
1217
+ provider: "codex",
1218
+ pid: execution.pid,
1219
+ cwd: session.cwd,
1220
+ args: execution.args,
1221
+ prompt: prompt.slice(0, 2048),
1222
+ promptLength: prompt.length,
1223
+ threadId: session.claudeSessionId,
1224
+ spawnedAt: execution.spawnedAt,
1225
+ });
1226
+ let result;
1227
+ try {
1228
+ result = await execution.completion;
1229
+ }
1230
+ finally {
1231
+ const released = this.releasePendingRunnerExecution(sessionId, execution);
1232
+ if (released)
1233
+ this.cancelStreamingCheckpointTimer(sessionId);
1234
+ }
1235
+ if (!this.isCurrentRequest(sessionId, requestId)) {
1236
+ if (emitTimer)
1237
+ this.clearStreamEmitTimer(emitTimer);
1238
+ return;
1239
+ }
1240
+ flushEmit();
1241
+ if (result.spawnError) {
1242
+ const hint = result.spawnError.code === "ENOENT"
1243
+ ? "(PATH 中找不到 codex 可执行文件;请确认 codex 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
1244
+ : "";
1245
+ throw new Error(`codex exec 启动失败:${result.spawnError.message}${hint}`);
1246
+ }
1247
+ this.logger?.appendStructuredSpawn(sessionId, {
1248
+ kind: "codex-exec-close",
1249
+ pid: execution.pid,
1250
+ spawnedAt: execution.spawnedAt,
1251
+ closedAt: new Date().toISOString(),
1252
+ exitCode: result.exitCode,
1253
+ stderrTail: result.stderr.slice(-2048),
1254
+ codexErrors: result.errors,
1255
+ codexTurnFailed: result.primaryError,
1256
+ });
1257
+ const current = this.currentSessionForRequest(sessionId, requestId);
1258
+ if (!current)
1259
+ return;
1260
+ const interruptedByUser = this.interruptedWith.has(sessionId);
1261
+ const interruptPrompt = this.interruptedWith.get(sessionId);
1262
+ if ((result.primaryError || (result.exitCode !== 0 && result.exitCode !== null) || result.signal) && !interruptedByUser) {
1263
+ const errorText = this.formatStructuredExitError("codex exec", result.exitCode, result.signal, { stderr: result.stderr, primary: result.primaryError, extras: result.errors });
1264
+ const failed = this.finishStructuredFailure(current, typeof result.exitCode === "number" ? result.exitCode : 1, errorText, result.state);
1265
+ this.sessions.set(sessionId, failed);
1266
+ this.saveAuthoritativeSession(failed);
1267
+ this.emitStructuredSnapshot(failed);
1268
+ this.emitStructuredSnapshot(failed, "ended");
1269
+ throw new PersistedStructuredRunnerError(errorText);
1270
+ }
1271
+ const messages = this.buildCompletedAssistantMessages(current, result.state);
1272
+ const keepRunning = !!interruptPrompt;
1273
+ const finished = {
1274
+ ...current,
1275
+ status: keepRunning ? "running" : "idle",
1276
+ exitCode: keepRunning ? null : 0,
1277
+ endedAt: keepRunning ? null : new Date().toISOString(),
1278
+ output: result.state.result,
1279
+ claudeSessionId: result.state.sessionId ?? current.claudeSessionId,
1280
+ messages,
1281
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
1282
+ pendingEscalation: null,
1283
+ permissionBlocked: false,
1284
+ structuredState: {
1285
+ ...current.structuredState,
1286
+ model: result.state.model ?? current.structuredState?.model,
1287
+ inFlight: false,
1288
+ activeRequestId: null,
1289
+ lastError: null,
1290
+ },
1291
+ };
1292
+ this.sessions.set(sessionId, finished);
1293
+ this.saveAuthoritativeSession(finished);
1294
+ this.emitStructuredSnapshot(finished);
1295
+ if (!keepRunning)
1296
+ this.emitStructuredSnapshot(finished, "ended");
1297
+ if (interruptPrompt) {
1298
+ this.interruptedWith.delete(sessionId);
1299
+ this.preserveQueueOnInterrupt.delete(sessionId);
1300
+ setImmediate(() => {
1301
+ this.sendMessage(sessionId, interruptPrompt).catch((error) => {
1302
+ console.error("[WAND] codex interrupt-and-send failed:", error);
1303
+ });
2106
1304
  });
1305
+ return;
1306
+ }
1307
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
1308
+ }
1309
+ async runOpenCodeStreaming(sessionId, session, prompt, requestId) {
1310
+ let emitTimer = null;
1311
+ const syncSnapshot = (turnState) => {
1312
+ const current = this.currentSessionForRequest(sessionId, requestId);
1313
+ if (!current)
1314
+ return;
1315
+ const turn = {
1316
+ role: "assistant",
1317
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1318
+ usage: turnState.usage,
1319
+ };
1320
+ const messages = [...(current.messages ?? [])];
1321
+ if (messages[messages.length - 1]?.role === "assistant")
1322
+ messages[messages.length - 1] = turn;
1323
+ else
1324
+ messages.push(turn);
1325
+ const patched = {
1326
+ ...current,
1327
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1328
+ messages,
1329
+ output: turnState.result || current.output,
1330
+ };
1331
+ this.sessions.set(sessionId, patched);
1332
+ this.saveStreamingSnapshot(patched);
1333
+ };
1334
+ const flushEmit = () => {
1335
+ if (emitTimer)
1336
+ this.clearStreamEmitTimer(emitTimer);
1337
+ emitTimer = null;
1338
+ const current = this.currentSessionForRequest(sessionId, requestId);
1339
+ if (current) {
1340
+ this.emit({
1341
+ type: "output",
1342
+ sessionId,
1343
+ data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}),
1344
+ });
1345
+ }
1346
+ };
1347
+ const scheduleEmit = () => {
1348
+ if (!emitTimer) {
1349
+ emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
1350
+ }
1351
+ };
1352
+ const execution = this.openCodeRunner.start({
1353
+ session,
1354
+ prompt,
1355
+ env: buildChildEnv(this.config.inheritEnv !== false),
1356
+ }, {
1357
+ isActive: () => this.isCurrentRequest(sessionId, requestId),
1358
+ onStdout: (text) => this.logger?.appendStructuredStdout(sessionId, text),
1359
+ onStderr: (text) => this.logger?.appendStructuredStderr(sessionId, text),
1360
+ onEvent: (event) => this.logger?.appendStreamEvent(sessionId, event),
1361
+ onUpdate: (turnState) => {
1362
+ syncSnapshot(turnState);
1363
+ scheduleEmit();
1364
+ },
1365
+ });
1366
+ this.pendingRunnerExecutions.set(sessionId, execution);
1367
+ this.logger?.appendStructuredSpawn(sessionId, {
1368
+ kind: "opencode-run",
1369
+ provider: "opencode",
1370
+ pid: execution.pid,
1371
+ cwd: session.cwd,
1372
+ args: execution.args,
1373
+ prompt: prompt.slice(0, 2048),
1374
+ promptLength: prompt.length,
1375
+ sessionId: session.claudeSessionId,
1376
+ spawnedAt: execution.spawnedAt,
2107
1377
  });
1378
+ let result;
1379
+ try {
1380
+ result = await execution.completion;
1381
+ }
1382
+ finally {
1383
+ const released = this.releasePendingRunnerExecution(sessionId, execution);
1384
+ if (released)
1385
+ this.cancelStreamingCheckpointTimer(sessionId);
1386
+ }
1387
+ if (!this.isCurrentRequest(sessionId, requestId)) {
1388
+ if (emitTimer)
1389
+ this.clearStreamEmitTimer(emitTimer);
1390
+ return;
1391
+ }
1392
+ flushEmit();
1393
+ if (result.spawnError) {
1394
+ const hint = result.spawnError.code === "ENOENT"
1395
+ ? "(PATH 中找不到 opencode;请安装 opencode-ai,或重跑 `wand service:install` 刷新服务 PATH)"
1396
+ : "";
1397
+ throw new Error(`opencode run 启动失败:${result.spawnError.message}${hint}`);
1398
+ }
1399
+ this.logger?.appendStructuredSpawn(sessionId, {
1400
+ kind: "opencode-run-close",
1401
+ pid: execution.pid,
1402
+ spawnedAt: execution.spawnedAt,
1403
+ closedAt: new Date().toISOString(),
1404
+ exitCode: result.exitCode,
1405
+ stderrTail: result.stderr.slice(-2048),
1406
+ primaryError: result.primaryError,
1407
+ });
1408
+ const current = this.currentSessionForRequest(sessionId, requestId);
1409
+ if (!current)
1410
+ return;
1411
+ const interruptedByUser = this.interruptedWith.has(sessionId);
1412
+ const interruptPrompt = this.interruptedWith.get(sessionId);
1413
+ if ((result.primaryError || (result.exitCode !== 0 && result.exitCode !== null) || result.signal) && !interruptedByUser) {
1414
+ const legacyHint = /unknown command|unknown flag|No help topic for 'run'/i.test(result.stderr)
1415
+ ? "\n检测到旧版 OpenCode CLI;请卸载 0.0.x 旧包并安装 `opencode-ai@latest`。"
1416
+ : "";
1417
+ const errorText = this.formatStructuredExitError("opencode run", result.exitCode, result.signal, { stderr: result.stderr, primary: result.primaryError }) + legacyHint;
1418
+ const failed = this.finishStructuredFailure(current, typeof result.exitCode === "number" ? result.exitCode : 1, errorText, result.state);
1419
+ this.sessions.set(sessionId, failed);
1420
+ this.saveAuthoritativeSession(failed);
1421
+ this.emitStructuredSnapshot(failed);
1422
+ this.emitStructuredSnapshot(failed, "ended");
1423
+ throw new PersistedStructuredRunnerError(errorText);
1424
+ }
1425
+ const messages = this.buildCompletedAssistantMessages(current, result.state);
1426
+ const keepRunning = !!interruptPrompt;
1427
+ const finished = {
1428
+ ...current,
1429
+ status: keepRunning ? "running" : "idle",
1430
+ exitCode: keepRunning ? null : 0,
1431
+ endedAt: keepRunning ? null : new Date().toISOString(),
1432
+ output: result.state.result,
1433
+ claudeSessionId: result.state.sessionId ?? current.claudeSessionId,
1434
+ messages,
1435
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
1436
+ pendingEscalation: null,
1437
+ permissionBlocked: false,
1438
+ structuredState: {
1439
+ ...current.structuredState,
1440
+ model: result.state.model ?? current.structuredState?.model,
1441
+ inFlight: false,
1442
+ activeRequestId: null,
1443
+ lastError: null,
1444
+ },
1445
+ };
1446
+ this.sessions.set(sessionId, finished);
1447
+ this.saveAuthoritativeSession(finished);
1448
+ this.emitStructuredSnapshot(finished);
1449
+ if (!keepRunning)
1450
+ this.emitStructuredSnapshot(finished, "ended");
1451
+ if (interruptPrompt) {
1452
+ this.interruptedWith.delete(sessionId);
1453
+ this.preserveQueueOnInterrupt.delete(sessionId);
1454
+ setImmediate(() => {
1455
+ this.sendMessage(sessionId, interruptPrompt).catch((error) => {
1456
+ console.error("[WAND] opencode interrupt-and-send failed:", error);
1457
+ });
1458
+ });
1459
+ return;
1460
+ }
1461
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
2108
1462
  }
2109
1463
  // ---------------------------------------------------------------------------
2110
1464
  // Streaming claude -p execution
@@ -2120,597 +1474,181 @@ export class StructuredSessionManager {
2120
1474
  * - Root: --permission-mode acceptEdits + --allowedTools (extends approval
2121
1475
  * outside CWD). stdin is always "ignore" — no ACP bidirectional control.
2122
1476
  */
2123
- runClaudeStreaming(sessionId, session, prompt, requestId) {
2124
- return new Promise((resolve, reject) => {
2125
- const permPolicy = derivePermissionPolicy(session.mode, session.autoApprovePermissions ?? false, session.cwd);
2126
- const isManaged = session.mode === "managed";
2127
- const args = buildClaudeCliArgs(session, {
2128
- permissionPolicy: permPolicy,
2129
- systemPromptParts: buildAppendSystemPromptParts(this.config.language, session.mode),
2130
- });
2131
- // 通过 stdin 传 prompt,避免被 --allowedTools / --disallowedTools 这类
2132
- // variadic 参数贪婪吞掉(commander 的 <tools...> 会一直吃 positional 直到
2133
- // 下一个 flag)。表现为 claude 报 "Input must be provided either through
2134
- // stdin or as a prompt argument when using --print"。
2135
- //
2136
- // 思考深度通过 --effort 传给 Claude CLI;prompt 保持用户原文。
2137
- const spawnedAt = new Date().toISOString();
2138
- const child = spawn("claude", args, {
2139
- cwd: session.cwd,
2140
- env: buildChildEnv(this.config.inheritEnv !== false),
2141
- stdio: ["pipe", "pipe", "pipe"],
2142
- });
2143
- this.logger?.appendStructuredSpawn(sessionId, {
2144
- kind: "claude-print",
2145
- provider: "claude",
2146
- pid: child.pid ?? null,
2147
- cwd: session.cwd,
2148
- args,
2149
- prompt: prompt.slice(0, 2048),
2150
- promptLength: prompt.length,
2151
- claudeSessionId: session.claudeSessionId,
2152
- spawnedAt,
2153
- });
2154
- this.pendingChildren.set(sessionId, child);
2155
- child.stdin?.end(prompt);
2156
- const turnState = {
2157
- blocks: [],
2158
- result: "",
2159
- sessionId: null,
2160
- model: undefined,
2161
- usage: undefined,
2162
- };
2163
- // claude -p --output-format stream-json 在同一条消息流式生成期间会重复
2164
- // emit 同一个 message.id 的 "assistant" 事件,每次 content 略多一些;子
2165
- // agent 流(Task 工具)则会插入若干 parent_tool_use_id 不同的 message.id。
2166
- // 朴素的 push(...content) 会让早期片段被反复合并复制,最终被 compact 出
2167
- // 怪异结果,导致 UI 上 tool_use / 子 agent 输出"显示一下就消失"。
2168
- // 这里按 (message.id) 去重,相同 id 视作同一消息的更新覆盖;tool_result
2169
- // 用单调递增的合成 key 顺序追加。每次事件后用插入顺序重建 turnState.blocks。
2170
- const blocksByKey = new Map();
2171
- const keyOrder = [];
2172
- let toolResultSeq = 0;
2173
- // 本轮 Task tool_use_id → meta map,由父 assistant 消息里的 Task tool_use
2174
- // 填充;子 agent message(parent_tool_use_id 非空)来时用它给每个 block 盖章。
2175
- const taskMetaRegistry = new Map();
2176
- // 估算单个 ContentBlock 的"信息体积"——文字 / thinking / tool input 长度之和。
2177
- // 用于 upsertBlocks 的防御性合并:同一 message.id 重发时,按位置取信息量更大的
2178
- // 那个版本,保证已经吐出的文字 / tool_use input 不会被一条更短的同 id 事件
2179
- // 整段覆盖。
2180
- const blockVolume = (b) => {
2181
- if (!b)
2182
- return 0;
2183
- const anyB = b;
2184
- let total = 0;
2185
- if (typeof anyB.text === "string")
2186
- total += anyB.text.length;
2187
- if (typeof anyB.thinking === "string")
2188
- total += anyB.thinking.length;
2189
- if (typeof anyB.content === "string")
2190
- total += anyB.content.length;
2191
- if (anyB.input) {
2192
- try {
2193
- total += JSON.stringify(anyB.input).length;
2194
- }
2195
- catch (_e) { /* ignore */ }
2196
- }
2197
- return total;
2198
- };
2199
- const upsertBlocks = (key, blocks) => {
2200
- const prev = blocksByKey.get(key);
2201
- if (!prev) {
2202
- keyOrder.push(key);
2203
- blocksByKey.set(key, blocks);
2204
- return;
2205
- }
2206
- // claude -p 在同一 message.id 的多次 assistant 事件有两种观察到的协议:
2207
- // a) **累积模式**:每次 event 的 content = 之前所有 blocks + 0~N 新 block,
2208
- // 同位置类型一致。流式 text/thinking 的逐字增量属于这种。
2209
- // b) **拼接模式**:SDK 把 thinking 和后续的 tool_use 拆成两条 event 给同
2210
- // 一 msg.id 发出,第二条只带 tool_use,**不包含**之前的 thinking。
2211
- // Opus 4.7 + claude-agent-sdk 实际跑下来就是这种。
2212
- //
2213
- // 老逻辑("同 index 类型不一致 → 保留 prev")只对 a) 友好,碰上 b) 会让第
2214
- // 二条事件里的 tool_use 直接被丢掉——表现是 Agent / Read 等 tool_use 永远
2215
- // 不出现在 messages 里,subagent 多角色无法关联 agentType 到父 Task。
2216
- //
2217
- // 先判定 incoming 是不是 prev 的"累积超集"(mode a):长度不短于 prev,
2218
- // 且前 prev.length 个 block 类型逐位一致。是 → 走逐位取大 + 末尾追加。
2219
- let cumulative = blocks.length >= prev.length;
2220
- if (cumulative) {
2221
- for (let i = 0; i < prev.length; i++) {
2222
- const a = prev[i];
2223
- const b = blocks[i];
2224
- if (a && b && a.type !== b.type) {
2225
- cumulative = false;
2226
- break;
2227
- }
2228
- }
2229
- }
2230
- if (cumulative) {
2231
- const merged = [];
2232
- const appendix = [];
2233
- for (let i = 0; i < blocks.length; i++) {
2234
- const a = prev[i];
2235
- const b = blocks[i];
2236
- if (a && !b) {
2237
- merged.push(a);
2238
- continue;
2239
- }
2240
- if (!a && b) {
2241
- merged.push(b);
2242
- continue;
2243
- }
2244
- if (a && b) {
2245
- if (a.type === b.type) {
2246
- // 同类型:取信息量大者,避免短回退覆盖已经累积的内容。
2247
- merged.push(blockVolume(b) >= blockVolume(a) ? b : a);
2248
- }
2249
- else {
2250
- // 类型变了:保留 prev[i],把 incoming block 追加到末尾。
2251
- merged.push(a);
2252
- appendix.push(b);
2253
- }
2254
- }
2255
- }
2256
- for (const b of appendix)
2257
- merged.push(b);
2258
- blocksByKey.set(key, merged);
2259
- return;
2260
- }
2261
- // mode b(拼接/splice):incoming 不是累积超集——SDK 把同一 msg.id 的
2262
- // thinking / text / 多个 tool_use 拆成一条条「只带新 block」的事件发出
2263
- // (新版 claude 连发 4 个 TaskCreate 就是这样)。老逻辑 `blocks.length <
2264
- // prev.length 直接 return` 会把这些单 block 事件整段丢弃,导致 TaskCreate /
2265
- // Agent / Read 等永远进不了 messages。这里保留 prev 全部,按 block 身份
2266
- // 增量合并:tool_use 用 id 去重(已存在则取信息量大的就地更新,否则追加);
2267
- // text / thinking 仅在没有完全相同内容时追加,挡住「短回退」的重复 frame。
2268
- const merged = [...prev];
2269
- const idIndex = new Map();
2270
- merged.forEach((b, i) => {
2271
- const anyB = b;
2272
- if (b.type === "tool_use" && typeof anyB.id === "string")
2273
- idIndex.set(anyB.id, i);
2274
- });
2275
- for (const b of blocks) {
2276
- const anyB = b;
2277
- if (b.type === "tool_use" && typeof anyB.id === "string") {
2278
- const at = idIndex.get(anyB.id);
2279
- if (at !== undefined) {
2280
- if (blockVolume(b) >= blockVolume(merged[at]))
2281
- merged[at] = b;
2282
- }
2283
- else {
2284
- idIndex.set(anyB.id, merged.length);
2285
- merged.push(b);
2286
- }
2287
- continue;
2288
- }
2289
- if (b.type === "tool_result") {
2290
- merged.push(b);
2291
- continue;
2292
- }
2293
- // text / thinking:同类型且文本完全一致视为重复回退,跳过。
2294
- const dup = merged.some((x) => x.type === b.type
2295
- && x.text === anyB.text
2296
- && x.thinking === anyB.thinking);
2297
- if (!dup)
2298
- merged.push(b);
2299
- }
2300
- blocksByKey.set(key, merged);
2301
- };
2302
- const rebuildTurnBlocks = () => {
2303
- const flat = [];
2304
- for (const key of keyOrder) {
2305
- const entry = blocksByKey.get(key);
2306
- if (entry && entry.length > 0)
2307
- flat.push(...entry);
2308
- }
2309
- turnState.blocks = flat;
1477
+ async runClaudeStreaming(sessionId, session, prompt, requestId) {
1478
+ let emitTimer = null;
1479
+ const syncSnapshot = (turnState) => {
1480
+ const current = this.currentSessionForRequest(sessionId, requestId);
1481
+ if (!current)
1482
+ return;
1483
+ const hasAssistantContent = turnState.blocks.length > 0 || !!turnState.result;
1484
+ const messages = [...(current.messages ?? [])];
1485
+ if (hasAssistantContent) {
1486
+ const turn = {
1487
+ role: "assistant",
1488
+ content: this.compactContentBlocks([...turnState.blocks], turnState.result),
1489
+ usage: turnState.usage,
1490
+ };
1491
+ if (messages[messages.length - 1]?.role === "assistant")
1492
+ messages[messages.length - 1] = turn;
1493
+ else
1494
+ messages.push(turn);
1495
+ }
1496
+ const patched = {
1497
+ ...current,
1498
+ claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
1499
+ messages,
1500
+ output: turnState.result || current.output,
1501
+ structuredState: {
1502
+ ...current.structuredState,
1503
+ model: turnState.model ?? current.structuredState?.model,
1504
+ },
2310
1505
  };
2311
- // Line buffer for NDJSON: chunks from stdout may split mid-line.
2312
- let lineBuf = "";
2313
- // Debounce output events to avoid flooding the WebSocket.
2314
- let emitTimer = null;
2315
- let settled = false;
2316
- // 当 Claude 在非托管模式调用 AskUserQuestion 时,stdin 关闭导致它会 hang 等
2317
- // tool_result。我们检测到后主动 kill child,让它顺利退出,UI 把 tool_use 卡片
2318
- // 渲染成可交互选项;用户提交后由 sendMessage() 通过 --resume 续接。
2319
- let killedForAskUserQuestion = false;
2320
- const flushEmit = () => {
2321
- if (emitTimer) {
2322
- this.clearStreamEmitTimer(emitTimer);
2323
- emitTimer = null;
2324
- }
2325
- const current = this.currentSessionForRequest(sessionId, requestId);
2326
- if (!current)
2327
- return;
1506
+ this.sessions.set(sessionId, patched);
1507
+ this.saveStreamingSnapshot(patched, hasAssistantContent ? undefined : { metadata: true });
1508
+ };
1509
+ const flushEmit = () => {
1510
+ if (emitTimer)
1511
+ this.clearStreamEmitTimer(emitTimer);
1512
+ emitTimer = null;
1513
+ const current = this.currentSessionForRequest(sessionId, requestId);
1514
+ if (current) {
2328
1515
  this.emit({
2329
1516
  type: "output",
2330
1517
  sessionId,
2331
1518
  data: buildIncrementalStructuredPayload(current, this.config.cardDefaults ?? {}),
2332
1519
  });
2333
- };
2334
- const scheduleEmit = () => {
2335
- if (!emitTimer) {
2336
- emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
2337
- }
2338
- };
2339
- /** Update the session snapshot with the current in-progress assistant turn. */
2340
- const syncSnapshot = () => {
2341
- const current = this.currentSessionForRequest(sessionId, requestId);
2342
- if (!current)
2343
- return;
2344
- const inProgressTurn = {
2345
- role: "assistant",
2346
- content: this.compactContentBlocks([...turnState.blocks], turnState.result),
2347
- usage: turnState.usage,
2348
- };
2349
- // Replace or append the in-progress assistant turn at the end of messages.
2350
- const msgs = [...(current.messages ?? [])];
2351
- const lastMsg = msgs[msgs.length - 1];
2352
- if (lastMsg && lastMsg.role === "assistant") {
2353
- msgs[msgs.length - 1] = inProgressTurn;
2354
- }
2355
- else {
2356
- msgs.push(inProgressTurn);
2357
- }
2358
- const patched = {
2359
- ...current,
2360
- claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2361
- messages: msgs,
2362
- output: turnState.result || current.output,
2363
- structuredState: {
2364
- ...current.structuredState,
2365
- model: turnState.model ?? current.structuredState?.model,
2366
- },
2367
- };
2368
- this.sessions.set(sessionId, patched);
2369
- // Persist streaming progress so a server restart does not roll back the
2370
- // latest assistant turn to the pre-stream snapshot. Throttled because
2371
- // saveSession serializes the full messages array.
2372
- this.saveStreamingSnapshot(patched);
2373
- };
2374
- // 关键修复:claude -p 的 session_id 出现在 system(init) / assistant / user /
2375
- // result 全部事件里,并非只在最终的 result。若本轮被中断(用户打断、
2376
- // AskUserQuestion 主动 kill、ExitPlanMode 自续接)或进程异常退出而没走到
2377
- // result,旧逻辑只在 result 里取 session_id 会让 claudeSessionId 一直为 null,
2378
- // 下一轮(含排队消息续接)就不带 --resume → 丢掉全部历史上下文。这里从任何带
2379
- // session_id 的事件即时捕获并落库,保证 resume 链不因缺少 result 事件而断裂。
2380
- const captureSessionId = (sid) => {
2381
- if (typeof sid !== "string" || !sid)
2382
- return;
2383
- if (turnState.sessionId === sid)
2384
- return;
2385
- turnState.sessionId = sid;
2386
- const current = this.currentSessionForRequest(sessionId, requestId);
2387
- if (current && current.claudeSessionId !== sid) {
2388
- const patched = { ...current, claudeSessionId: sid };
2389
- this.sessions.set(sessionId, patched);
2390
- this.saveStreamingSnapshot(patched, { metadata: true });
2391
- }
2392
- };
2393
- const processLine = (line) => {
2394
- if (!this.isCurrentRequest(sessionId, requestId))
2395
- return;
2396
- const trimmed = line.trim();
2397
- if (!trimmed)
2398
- return;
2399
- let parsed;
2400
- try {
2401
- parsed = JSON.parse(trimmed);
2402
- }
2403
- catch {
2404
- return;
2405
- }
2406
- this.logger?.appendStreamEvent(sessionId, parsed);
2407
- // 所有事件都可能带顶层 session_id(含 system init);立即捕获,不等 result。
2408
- captureSessionId(parsed?.session_id);
2409
- if (parsed && parsed.type === "assistant" && parsed.message) {
2410
- const extracted = this.extractAssistantMessage(parsed.message);
2411
- // 用 message.id 作为 key:claude -p 流式重发同一条消息时整段覆盖
2412
- // (而不是与早期片段累加),子 agent 的不同消息 id 各占一格、保留
2413
- // 父子完整顺序。没有 id 时退化为合成 key 走追加模式。
2414
- const msgId = typeof parsed.message.id === "string" && parsed.message.id
2415
- ? `assistant:${parsed.message.id}`
2416
- : `assistant:anon:${keyOrder.length}`;
2417
- // parent_tool_use_id 决定父/子 agent。父 message 里的 Task tool_use 登记
2418
- // 到 taskMetaRegistry;子 message 的每个 block 用 __subagent 盖章。
2419
- const parentToolUseId = typeof parsed.parent_tool_use_id === "string" && parsed.parent_tool_use_id
2420
- ? parsed.parent_tool_use_id
2421
- : null;
2422
- if (parentToolUseId === null) {
2423
- captureTaskMeta(extracted.content, taskMetaRegistry);
2424
- }
2425
- const stamped = parentToolUseId === null
2426
- ? stampSelfTask(extracted.content, taskMetaRegistry)
2427
- : tagSubagentBlocks(extracted.content, parentToolUseId, taskMetaRegistry);
2428
- if (stamped.length > 0) {
2429
- upsertBlocks(msgId, stamped);
2430
- rebuildTurnBlocks();
2431
- }
2432
- // NOTE: usage from streaming "assistant" events contains partial/incremental
2433
- // token counts (e.g. output_tokens=1 during streaming) and is NOT accurate.
2434
- // We only use the authoritative usage from the final "result" event.
2435
- syncSnapshot();
2436
- scheduleEmit();
2437
- // 非托管模式下检测 AskUserQuestion:claude -p 的 stdin 被 ignore,无法回传
2438
- // tool_result,进程会 hang 住。主动 SIGTERM 让它退出;后续用户提交答案时由
2439
- // sendMessage() 注入伪造的 tool_result 并通过 --resume 续接。
2440
- if (!isManaged && !killedForAskUserQuestion) {
2441
- const askBlock = extracted.content.find((b) => b.type === "tool_use" && b.name === "AskUserQuestion");
2442
- if (askBlock) {
2443
- killedForAskUserQuestion = true;
2444
- flushEmit();
2445
- try {
2446
- child.kill("SIGTERM");
2447
- }
2448
- catch (_err) { /* ignore */ }
2449
- }
2450
- }
2451
- return;
2452
- }
2453
- if (parsed && parsed.type === "user" && parsed.message && Array.isArray(parsed.message.content)) {
2454
- // tool_result 没有自身 id,按到达顺序用合成 key 追加(永远不被覆盖)。
2455
- const collected = [];
2456
- for (const block of parsed.message.content) {
2457
- if (block && block.type === "tool_result") {
2458
- collected.push({
2459
- type: "tool_result",
2460
- tool_use_id: typeof block.tool_use_id === "string" ? block.tool_use_id : "",
2461
- content: this.normalizeToolResultContent(block.content),
2462
- is_error: block.is_error === true,
2463
- });
2464
- }
2465
- }
2466
- const parentToolUseId = typeof parsed.parent_tool_use_id === "string" && parsed.parent_tool_use_id
2467
- ? parsed.parent_tool_use_id
2468
- : null;
2469
- const stamped = parentToolUseId === null
2470
- ? stampParentTaskResults(collected, taskMetaRegistry)
2471
- : tagSubagentBlocks(collected, parentToolUseId, taskMetaRegistry);
2472
- if (stamped.length > 0) {
2473
- upsertBlocks(`tool_result:${toolResultSeq++}`, stamped);
2474
- rebuildTurnBlocks();
2475
- }
2476
- syncSnapshot();
2477
- scheduleEmit();
2478
- return;
2479
- }
2480
- if (parsed && parsed.type === "result") {
2481
- if (typeof parsed.result === "string") {
2482
- turnState.result = parsed.result.trim();
2483
- }
2484
- // session_id 已由顶部 captureSessionId 统一捕获,这里不再重复赋值。
2485
- turnState.model = this.extractModelName(parsed.modelUsage) ?? turnState.model;
2486
- turnState.usage = this.extractUsage(parsed) ?? turnState.usage;
2487
- syncSnapshot();
2488
- scheduleEmit();
2489
- }
2490
- };
2491
- let stderr = "";
2492
- // 兜底:当 stderr 是空、JSON 也没解析到任何错误事件时,把最后一段非空
2493
- // stdout 文本作为上下文塞给错误信息。claude -p 偶尔会把 fatal error 以
2494
- // 纯文本(非 JSON)打到 stdout 然后非零退出,之前的实现会丢掉这部分。
2495
- let lastRawStdoutChunk = "";
2496
- child.stdout?.on("data", (chunk) => {
2497
- if (!this.isCurrentRequest(sessionId, requestId))
2498
- return;
2499
- const text = chunk.toString();
2500
- this.logger?.appendStructuredStdout(sessionId, text);
2501
- const trimmed = text.trim();
2502
- if (trimmed)
2503
- lastRawStdoutChunk = trimmed.slice(-1024);
2504
- lineBuf += text;
2505
- const lines = lineBuf.split("\n");
2506
- // Keep the last (possibly incomplete) segment in the buffer.
2507
- lineBuf = lines.pop() ?? "";
2508
- for (const line of lines) {
2509
- processLine(line);
2510
- }
2511
- });
2512
- child.stderr?.on("data", (chunk) => {
2513
- if (!this.isCurrentRequest(sessionId, requestId))
2514
- return;
2515
- const text = chunk.toString();
2516
- this.logger?.appendStructuredStderr(sessionId, text);
2517
- stderr += text;
1520
+ }
1521
+ };
1522
+ const scheduleEmit = () => {
1523
+ if (!emitTimer)
1524
+ emitTimer = this.trackStreamEmitTimer(setTimeout(flushEmit, STREAM_EMIT_DEBOUNCE_MS));
1525
+ };
1526
+ const execution = this.claudeCliRunner.start({
1527
+ session,
1528
+ prompt,
1529
+ env: buildChildEnv(this.config.inheritEnv !== false),
1530
+ }, {
1531
+ isActive: () => this.isCurrentRequest(sessionId, requestId),
1532
+ onStdout: (text) => this.logger?.appendStructuredStdout(sessionId, text),
1533
+ onStderr: (text) => this.logger?.appendStructuredStderr(sessionId, text),
1534
+ onEvent: (event) => this.logger?.appendStreamEvent(sessionId, event),
1535
+ onUpdate: (turnState) => {
1536
+ syncSnapshot(turnState);
1537
+ scheduleEmit();
1538
+ },
1539
+ });
1540
+ this.pendingRunnerExecutions.set(sessionId, execution);
1541
+ this.logger?.appendStructuredSpawn(sessionId, {
1542
+ kind: "claude-print",
1543
+ provider: "claude",
1544
+ pid: execution.pid,
1545
+ cwd: session.cwd,
1546
+ args: execution.args,
1547
+ prompt: prompt.slice(0, 2048),
1548
+ promptLength: prompt.length,
1549
+ claudeSessionId: session.claudeSessionId,
1550
+ spawnedAt: execution.spawnedAt,
1551
+ });
1552
+ let result;
1553
+ try {
1554
+ result = await execution.completion;
1555
+ }
1556
+ finally {
1557
+ const released = this.releasePendingRunnerExecution(sessionId, execution);
1558
+ if (released)
1559
+ this.cancelStreamingCheckpointTimer(sessionId);
1560
+ }
1561
+ if (!this.isCurrentRequest(sessionId, requestId)) {
1562
+ if (emitTimer)
1563
+ this.clearStreamEmitTimer(emitTimer);
1564
+ return;
1565
+ }
1566
+ flushEmit();
1567
+ if (result.spawnError) {
1568
+ const hint = result.spawnError.code === "ENOENT"
1569
+ ? "(PATH 中找不到 claude 可执行文件;请确认 claude 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
1570
+ : "";
1571
+ throw new Error(`claude -p 启动失败:${result.spawnError.message}${hint}`);
1572
+ }
1573
+ this.logger?.appendStructuredSpawn(sessionId, {
1574
+ kind: "claude-print-close",
1575
+ pid: execution.pid,
1576
+ spawnedAt: execution.spawnedAt,
1577
+ closedAt: new Date().toISOString(),
1578
+ exitCode: result.exitCode,
1579
+ stderrTail: result.stderr.slice(-2048),
1580
+ });
1581
+ const current = this.currentSessionForRequest(sessionId, requestId);
1582
+ if (!current)
1583
+ return;
1584
+ const interruptedByUser = this.interruptedWith.has(sessionId);
1585
+ const interruptedForQuestion = result.stopReason === "ask-user-question";
1586
+ const failedExit = (result.exitCode !== null && result.exitCode !== 0) || result.signal !== null;
1587
+ if (failedExit && !interruptedByUser && !interruptedForQuestion) {
1588
+ const errorText = this.formatStructuredExitError("claude -p", result.exitCode, result.signal, {
1589
+ stderr: result.stderr,
1590
+ stdoutTail: result.stdoutTail,
2518
1591
  });
2519
- child.on("error", (error) => {
2520
- const released = this.releasePendingChild(sessionId, child);
2521
- if (released)
2522
- this.cancelStreamingCheckpointTimer(sessionId);
2523
- if (emitTimer)
2524
- this.clearStreamEmitTimer(emitTimer);
2525
- if (settled)
2526
- return;
2527
- if (!this.isCurrentRequest(sessionId, requestId)) {
2528
- settled = true;
2529
- resolve();
2530
- return;
2531
- }
2532
- settled = true;
2533
- this.logger?.appendStructuredSpawn(sessionId, {
2534
- kind: "claude-print-error",
2535
- pid: child.pid ?? null,
2536
- spawnedAt,
2537
- closedAt: new Date().toISOString(),
2538
- spawnError: error.message,
1592
+ const failed = this.finishStructuredFailure(current, typeof result.exitCode === "number" ? result.exitCode : 1, errorText, result.state);
1593
+ this.sessions.set(sessionId, failed);
1594
+ this.saveAuthoritativeSession(failed);
1595
+ this.emitStructuredSnapshot(failed);
1596
+ this.emitStructuredSnapshot(failed, "ended");
1597
+ throw new PersistedStructuredRunnerError(errorText);
1598
+ }
1599
+ const messages = this.buildCompletedAssistantMessages(current, result.state);
1600
+ const interruptPrompt = this.interruptedWith.get(sessionId);
1601
+ const keepRunning = interruptedForQuestion || !!interruptPrompt;
1602
+ const finished = {
1603
+ ...current,
1604
+ status: keepRunning ? "running" : "idle",
1605
+ exitCode: keepRunning ? null : 0,
1606
+ endedAt: keepRunning ? null : new Date().toISOString(),
1607
+ output: result.state.result,
1608
+ claudeSessionId: result.state.sessionId ?? current.claudeSessionId,
1609
+ messages,
1610
+ queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
1611
+ pendingEscalation: null,
1612
+ permissionBlocked: false,
1613
+ structuredState: {
1614
+ ...current.structuredState,
1615
+ model: result.state.model ?? current.structuredState?.model,
1616
+ inFlight: false,
1617
+ activeRequestId: null,
1618
+ lastError: null,
1619
+ },
1620
+ };
1621
+ this.sessions.set(sessionId, finished);
1622
+ this.saveAuthoritativeSession(finished);
1623
+ this.emitStructuredSnapshot(finished);
1624
+ if (!keepRunning)
1625
+ this.emitStructuredSnapshot(finished, "ended");
1626
+ if (interruptPrompt) {
1627
+ this.interruptedWith.delete(sessionId);
1628
+ this.preserveQueueOnInterrupt.delete(sessionId);
1629
+ setImmediate(() => {
1630
+ this.sendMessage(sessionId, interruptPrompt).catch((error) => {
1631
+ console.error("[WAND] interrupt-and-send failed:", error);
2539
1632
  });
2540
- // 同 codex 那边:spawn ENOENT 最常见,提示用户去 service:install 刷 PATH。
2541
- const nodeErr = error;
2542
- const hint = nodeErr.code === "ENOENT"
2543
- ? "(PATH 中找不到 claude 可执行文件;请确认 claude 已安装,或重跑 `wand service:install` 刷新服务的 PATH)"
2544
- : "";
2545
- reject(new Error(`claude -p 启动失败:${error.message}${hint}`));
2546
1633
  });
2547
- child.on("close", (code, signal) => {
2548
- const released = this.releasePendingChild(sessionId, child);
2549
- if (released)
2550
- this.cancelStreamingCheckpointTimer(sessionId);
2551
- if (settled)
2552
- return;
2553
- if (!this.isCurrentRequest(sessionId, requestId)) {
2554
- if (emitTimer)
2555
- this.clearStreamEmitTimer(emitTimer);
2556
- settled = true;
2557
- resolve();
2558
- return;
2559
- }
2560
- this.logger?.appendStructuredSpawn(sessionId, {
2561
- kind: "claude-print-close",
2562
- pid: child.pid ?? null,
2563
- spawnedAt,
2564
- closedAt: new Date().toISOString(),
2565
- exitCode: code,
2566
- stderrTail: stderr.slice(-2048),
2567
- });
2568
- // Process any remaining data in the line buffer.
2569
- if (lineBuf.trim()) {
2570
- processLine(lineBuf);
2571
- lineBuf = "";
2572
- }
2573
- // Flush any pending debounced emit before finalizing.
2574
- flushEmit();
2575
- // Finalize the session snapshot.
2576
- const current = this.currentSessionForRequest(sessionId, requestId);
2577
- if (!current) {
2578
- settled = true;
2579
- resolve();
2580
- return;
2581
- }
2582
- // 如果是用户主动中断(interruptedWith 里有新消息),claude -p 收到 SIGTERM 后
2583
- // 可能以非零 exit code 退出(内部 handler 调了 exit(1))。这种情况属于正常
2584
- // 中断流程,不应走失败路径——后续 interruptedWith 逻辑会发送新消息。
2585
- const interruptedByUser = this.interruptedWith.has(sessionId);
2586
- const failedExit = (code !== null && code !== 0) || signal !== null;
2587
- if (failedExit && !interruptedByUser && !killedForAskUserQuestion) {
2588
- const errorText = this.formatStructuredExitError("claude -p", code, signal, {
2589
- stderr,
2590
- // claude -p 没有 codex 那种独立的 turn.failed 事件,所以 primary 留空;
2591
- // 退路是 stderr / stdoutTail。
2592
- stdoutTail: lastRawStdoutChunk,
2593
- });
2594
- const failureTurn = {
2595
- role: "assistant",
2596
- content: [{ type: "text", text: `结构化会话执行失败:${errorText}` }],
2597
- };
2598
- const msgs = [...(current.messages ?? [])];
2599
- const lastMsg = msgs[msgs.length - 1];
2600
- if (lastMsg && lastMsg.role === "assistant") {
2601
- msgs[msgs.length - 1] = failureTurn;
2602
- }
2603
- else {
2604
- msgs.push(failureTurn);
2605
- }
2606
- // 仅 signal 终止时 code 为 null;用 1 占位,让 UI 的"exitCode !== 0"判定也能命中。
2607
- const exitForSnapshot = typeof code === "number" ? code : 1;
2608
- const failed = {
2609
- ...current,
2610
- status: "failed",
2611
- exitCode: exitForSnapshot,
2612
- endedAt: new Date().toISOString(),
2613
- output: errorText,
2614
- claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2615
- messages: msgs,
2616
- pendingEscalation: null,
2617
- permissionBlocked: false,
2618
- structuredState: {
2619
- ...current.structuredState,
2620
- model: turnState.model ?? current.structuredState?.model,
2621
- inFlight: false,
2622
- activeRequestId: null,
2623
- lastError: errorText,
2624
- },
2625
- };
2626
- this.sessions.set(sessionId, failed);
2627
- this.saveAuthoritativeSession(failed);
2628
- this.emitStructuredSnapshot(failed);
2629
- this.emitStructuredSnapshot(failed, "ended");
2630
- settled = true;
2631
- reject(new PersistedStructuredRunnerError(errorText));
2632
- return;
2633
- }
2634
- const msgs = this.buildCompletedAssistantMessages(current, turnState);
2635
- // 被 AskUserQuestion 检测或用户中断主动 kill 时,保持 status="running"
2636
- // 让 UI 不跳到"已停止"。inFlight=false 才能触发后续 sendMessage。
2637
- const interruptPrompt = this.interruptedWith.get(sessionId);
2638
- const keepRunning = killedForAskUserQuestion || !!interruptPrompt;
2639
- const finished = {
2640
- ...current,
2641
- status: keepRunning ? "running" : "idle",
2642
- exitCode: keepRunning ? null : 0,
2643
- endedAt: keepRunning ? null : new Date().toISOString(),
2644
- output: turnState.result,
2645
- claudeSessionId: turnState.sessionId ?? current.claudeSessionId,
2646
- messages: msgs,
2647
- queuedMessages: this.resolveQueuedMessagesAfterInterrupt(sessionId, current, interruptPrompt),
2648
- pendingEscalation: null,
2649
- permissionBlocked: false,
2650
- structuredState: {
2651
- ...current.structuredState,
2652
- model: turnState.model ?? current.structuredState?.model,
2653
- inFlight: false,
2654
- activeRequestId: null,
2655
- lastError: null,
2656
- },
2657
- };
2658
- this.sessions.set(sessionId, finished);
2659
- this.saveAuthoritativeSession(finished);
2660
- this.emitStructuredSnapshot(finished);
2661
- if (!keepRunning) {
2662
- this.emitStructuredSnapshot(finished, "ended");
2663
- }
2664
- // 用户中断当前回复:保存部分回复后立即发送新消息。
2665
- if (interruptPrompt) {
2666
- this.interruptedWith.delete(sessionId);
2667
- // 把"保留队列"标记一并清掉——不属于本次 interrupt 的后续轮次会按
2668
- // 默认(清空 queue)行为走,避免 stale flag 影响下一次普通 interrupt。
2669
- // 注意:被保留的 queuedMessages 不需要在这里主动 flush,重发的
2670
- // interruptPrompt 跑完会自然触发 flushNextQueuedMessage。
2671
- this.preserveQueueOnInterrupt.delete(sessionId);
2672
- settled = true;
2673
- resolve();
2674
- setImmediate(() => {
2675
- this.sendMessage(sessionId, interruptPrompt).catch((err) => {
2676
- console.error("[WAND] interrupt-and-send failed:", err);
2677
- });
2678
- });
2679
- return;
2680
- }
2681
- if (killedForAskUserQuestion) {
2682
- settled = true;
2683
- resolve();
2684
- // An answer can arrive after AskUserQuestion triggered SIGTERM but
2685
- // before close finalized the turn. It was queued while inFlight; now
2686
- // advance it normally so it becomes the matching tool_result.
2687
- if ((finished.queuedMessages?.length ?? 0) > 0) {
2688
- setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
2689
- }
2690
- return;
2691
- }
2692
- // Auto-continue after plan mode exit: when Claude calls ExitPlanMode,
2693
- // the `-p` process exits because stdin is "ignore" and it cannot get
2694
- // user confirmation. Detect this and automatically resume execution
2695
- // so the plan is actually carried out.
2696
- const lastToolUse = [...turnState.blocks].reverse().find((b) => b.type === "tool_use");
2697
- if (lastToolUse && lastToolUse.name === "ExitPlanMode" && turnState.sessionId) {
2698
- settled = true;
2699
- resolve();
2700
- setImmediate(() => {
2701
- this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((err) => {
2702
- console.error("[WAND] Auto-continue after ExitPlanMode failed:", err);
2703
- });
2704
- });
2705
- return;
2706
- }
2707
- settled = true;
2708
- resolve();
2709
- setImmediate(() => {
2710
- void this.flushNextQueuedMessage(sessionId);
1634
+ return;
1635
+ }
1636
+ if (interruptedForQuestion) {
1637
+ if ((finished.queuedMessages?.length ?? 0) > 0) {
1638
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
1639
+ }
1640
+ return;
1641
+ }
1642
+ const lastToolUse = [...result.state.blocks].reverse().find((block) => block.type === "tool_use");
1643
+ if (lastToolUse?.name === "ExitPlanMode" && result.state.sessionId) {
1644
+ setImmediate(() => {
1645
+ this.sendMessage(sessionId, "Plan approved. Proceed with the implementation.").catch((error) => {
1646
+ console.error("[WAND] Auto-continue after ExitPlanMode failed:", error);
2711
1647
  });
2712
1648
  });
2713
- });
1649
+ return;
1650
+ }
1651
+ setImmediate(() => { void this.flushNextQueuedMessage(sessionId); });
2714
1652
  }
2715
1653
  // ---------------------------------------------------------------------------
2716
1654
  // Streaming claude-agent-sdk execution
@@ -2865,7 +1803,7 @@ export class StructuredSessionManager {
2865
1803
  }
2866
1804
  catch { /* partial json */ }
2867
1805
  }
2868
- block = { type: "tool_use", id: sb.id, name: sb.name, input: this.normalizeToolInput(sb.name, input) };
1806
+ block = { type: "tool_use", id: sb.id, name: sb.name, input: normalizeClaudeToolInput(sb.name, input) };
2869
1807
  }
2870
1808
  if (!block)
2871
1809
  continue;
@@ -3010,7 +1948,7 @@ export class StructuredSessionManager {
3010
1948
  // append to it instead of erasing it.
3011
1949
  if (msg.type === "assistant") {
3012
1950
  const assistantMsg = msg;
3013
- const extracted = this.extractAssistantMessage(assistantMsg.message);
1951
+ const extracted = extractClaudeAssistantMessage(assistantMsg.message);
3014
1952
  // 父 assistant 的 Task tool_use → 注册到本轮 taskMeta map;
3015
1953
  // 子 agent 的 message(parent_tool_use_id 非空)→ 给每个 block 盖章。
3016
1954
  const parentToolUseId = assistantMsg.parent_tool_use_id ?? null;
@@ -3088,7 +2026,7 @@ export class StructuredSessionManager {
3088
2026
  turnState.result = resultMsg.result.trim();
3089
2027
  if (typeof resultMsg.session_id === "string")
3090
2028
  turnState.sessionId = resultMsg.session_id;
3091
- turnState.model = this.extractModelName(resultMsg.modelUsage) ?? turnState.model;
2029
+ turnState.model = extractClaudeModelName(resultMsg.modelUsage) ?? turnState.model;
3092
2030
  turnState.usage = this.extractSdkUsage(resultMsg);
3093
2031
  syncSnapshot();
3094
2032
  scheduleEmit();
@@ -3196,36 +2134,6 @@ export class StructuredSessionManager {
3196
2134
  // ---------------------------------------------------------------------------
3197
2135
  // Parsing helpers (unchanged logic, extracted from previous implementation)
3198
2136
  // ---------------------------------------------------------------------------
3199
- extractAssistantMessage(message) {
3200
- const rawContent = Array.isArray(message.content) ? message.content : [];
3201
- const content = [];
3202
- for (const block of rawContent) {
3203
- if (!block || typeof block !== "object")
3204
- continue;
3205
- const typedBlock = block;
3206
- if (typedBlock.type === "text" && typeof typedBlock.text === "string") {
3207
- content.push({ type: "text", text: typedBlock.text });
3208
- continue;
3209
- }
3210
- if (typedBlock.type === "thinking" && typeof typedBlock.thinking === "string") {
3211
- content.push({ type: "thinking", thinking: typedBlock.thinking });
3212
- continue;
3213
- }
3214
- if (typedBlock.type === "tool_use" && typeof typedBlock.id === "string" && typeof typedBlock.name === "string") {
3215
- content.push({
3216
- type: "tool_use",
3217
- id: typedBlock.id,
3218
- name: typedBlock.name,
3219
- description: typeof typedBlock.description === "string" ? typedBlock.description : undefined,
3220
- input: this.normalizeToolInput(typedBlock.name, typedBlock.input),
3221
- });
3222
- }
3223
- }
3224
- return {
3225
- content,
3226
- usage: this.extractUsage({ usage: message.usage }),
3227
- };
3228
- }
3229
2137
  compactContentBlocks(blocks, fallbackResult) {
3230
2138
  const compacted = [];
3231
2139
  for (const block of blocks) {
@@ -3275,618 +2183,9 @@ export class StructuredSessionManager {
3275
2183
  return [];
3276
2184
  return current.queuedMessages;
3277
2185
  }
3278
- normalizeToolInput(name, input) {
3279
- if (!input || typeof input !== "object" || Array.isArray(input)) {
3280
- return {};
3281
- }
3282
- const record = input;
3283
- // `claude -p --output-format stream-json`(默认结构化 runner)有时把数组型工具参数
3284
- // 当成 JSON 字符串吐出来——例如 TodoWrite 的 todos 会是 "[{...}]" 而非真正的数组。
3285
- // 所有客户端(web / iOS / Android)读的都是数组,拿到字符串就解析不出待办,进度条
3286
- // 与 AskUserQuestion 卡片整段消失。这里按工具名把已知的数组字段反序列化回数组,
3287
- // 让线上协议恢复成「block.input.todos = [{content,status,activeForm}]」的契约。
3288
- const arrayFieldsByTool = {
3289
- TodoWrite: "todos",
3290
- AskUserQuestion: "questions",
3291
- };
3292
- const field = typeof name === "string" ? arrayFieldsByTool[name] : undefined;
3293
- if (field && typeof record[field] === "string") {
3294
- try {
3295
- const parsed = JSON.parse(record[field]);
3296
- if (Array.isArray(parsed))
3297
- record[field] = parsed;
3298
- }
3299
- catch {
3300
- /* 保留原字符串:宁可不改也不要丢数据 */
3301
- }
3302
- }
3303
- return record;
3304
- }
3305
2186
  normalizeToolResultContent(content) {
3306
2187
  return normalizeStructuredToolResultContent(content);
3307
2188
  }
3308
- unwrapCodexStreamEvent(parsed) {
3309
- const event = asRecord(parsed);
3310
- if (!event)
3311
- return null;
3312
- const type = getString(event.type);
3313
- if ((type === "response_item" || type === "event_msg") && asRecord(event.payload)) {
3314
- return event.payload;
3315
- }
3316
- return event;
3317
- }
3318
- applyCodexLooseEvent(turnState, event) {
3319
- if (!event)
3320
- return false;
3321
- const type = getString(event.type);
3322
- const supported = new Set([
3323
- "message",
3324
- "agent_message",
3325
- "reasoning",
3326
- "function_call",
3327
- "function_call_output",
3328
- "custom_tool_call",
3329
- "custom_tool_call_output",
3330
- "command_execution",
3331
- "patch_apply_end",
3332
- "file_change",
3333
- "mcp_tool_call",
3334
- "mcp_tool_call_end",
3335
- "web_search_call",
3336
- "web_search_end",
3337
- "web_search",
3338
- "tool_search_call",
3339
- "tool_search_output",
3340
- "collab_tool_call",
3341
- "todo_list",
3342
- ]);
3343
- if (!supported.has(type))
3344
- return false;
3345
- this.applyCodexItem(turnState, event, "completed");
3346
- return true;
3347
- }
3348
- codexFunctionToolUse(item) {
3349
- const rawName = getString(item.name) || "function_call";
3350
- const callId = getString(item.call_id) || getString(item.id) || rawName;
3351
- const args = parseJsonRecord(item.arguments);
3352
- const input = { ...args };
3353
- if (rawName === "exec_command") {
3354
- const command = getString(args.cmd) || getString(args.command);
3355
- if (command)
3356
- input.command = command;
3357
- return {
3358
- type: "tool_use",
3359
- id: callId,
3360
- name: "Bash",
3361
- description: getString(args.workdir) || undefined,
3362
- input,
3363
- };
3364
- }
3365
- if (rawName === "write_stdin") {
3366
- return {
3367
- type: "tool_use",
3368
- id: callId,
3369
- name: "Bash",
3370
- description: "write stdin",
3371
- input: {
3372
- ...input,
3373
- command: `write_stdin ${getString(args.session_id) || getString(args.sessionId) || ""}`.trim(),
3374
- },
3375
- };
3376
- }
3377
- if (rawName === "update_plan" && Array.isArray(args.plan)) {
3378
- const todos = args.plan.map((entry) => {
3379
- const rec = asRecord(entry) ?? {};
3380
- const status = getString(rec.status);
3381
- return {
3382
- content: getString(rec.step),
3383
- activeForm: getString(rec.step),
3384
- status: status === "completed" ? "completed" : status === "in_progress" ? "in_progress" : "pending",
3385
- };
3386
- });
3387
- return {
3388
- type: "tool_use",
3389
- id: callId,
3390
- name: "TodoWrite",
3391
- description: getString(args.explanation) || undefined,
3392
- input: { todos },
3393
- };
3394
- }
3395
- if (rawName === "view_image") {
3396
- const filePath = getString(args.path);
3397
- return {
3398
- type: "tool_use",
3399
- id: callId,
3400
- name: "Read",
3401
- description: "view image",
3402
- input: filePath ? { ...input, file_path: filePath } : input,
3403
- };
3404
- }
3405
- if (rawName === "js") {
3406
- return {
3407
- type: "tool_use",
3408
- id: callId,
3409
- name: "node_repl__js",
3410
- description: getString(args.title) || undefined,
3411
- input,
3412
- };
3413
- }
3414
- return {
3415
- type: "tool_use",
3416
- id: callId,
3417
- name: rawName,
3418
- input,
3419
- };
3420
- }
3421
- codexMcpToolBlocks(item) {
3422
- const callId = getString(item.call_id) || getString(item.id) || "mcp";
3423
- const invocation = asRecord(item.invocation) ?? {};
3424
- const server = getString(invocation.server) || "mcp";
3425
- const tool = getString(invocation.tool) || "tool";
3426
- const args = asRecord(invocation.arguments) ?? {};
3427
- const result = asRecord(item.result);
3428
- const isError = !!result?.Err || getString(item.status) === "failed";
3429
- const ok = asRecord(result?.Ok);
3430
- const content = ok ? this.extractCodexText(ok.content) || JSON.stringify(ok).slice(0, 4096) : this.extractCodexText(result);
3431
- return [
3432
- { type: "tool_use", id: callId, name: `${server}__${tool}`, input: args },
3433
- { type: "tool_result", tool_use_id: callId, content, is_error: isError },
3434
- ];
3435
- }
3436
- extractCodexText(value) {
3437
- if (typeof value === "string")
3438
- return value;
3439
- if (!value || typeof value !== "object")
3440
- return "";
3441
- if (Array.isArray(value)) {
3442
- return value.map((item) => this.extractCodexText(item)).filter(Boolean).join("");
3443
- }
3444
- const record = value;
3445
- for (const key of ["text", "output_text", "message", "content", "summary"]) {
3446
- const extracted = this.extractCodexText(record[key]);
3447
- if (extracted)
3448
- return extracted;
3449
- }
3450
- return "";
3451
- }
3452
- /**
3453
- * Merge one codex `item.*` event into `turnState.blocks`.
3454
- *
3455
- * 三种 phase 行为:
3456
- * - "started": 首次出现的 item,块直接 push(tool_result 走 upsert 配对)。
3457
- * text/thinking/TodoWrite 这种"靠 id 替换"的块记录到
3458
- * codexBlockIndex 里,方便后续 updated/completed 找回原位。
3459
- * - "updated": codex 重发完整 ThreadItem(不是 delta)。已记录过的块就
3460
- * 替换;新块按 started 路径处理。
3461
- * - "completed": 把"in_progress"卡片定型——text 同时更新 turnState.result
3462
- * 以便 result fallback 不为空;tool_use ↔ tool_result 通过
3463
- * 共享 id 配对到一起(包括 file_change 子项的 `${id}#i`)。
3464
- */
3465
- applyCodexItem(turnState, item, phase) {
3466
- const completed = phase === "completed";
3467
- const itemId = typeof item.id === "string" ? item.id : "";
3468
- const itemType = getString(item.type);
3469
- let afterSnapshots;
3470
- if (itemType === "file_change" && itemId) {
3471
- const snapshots = turnState.codexFileSnapshots ??= new Map();
3472
- const rawChanges = Array.isArray(item.changes) ? item.changes : [];
3473
- if (phase === "started") {
3474
- rawChanges.forEach((entry, index) => {
3475
- const filePath = getString(asRecord(entry)?.path);
3476
- const absolutePath = path.isAbsolute(filePath)
3477
- ? filePath
3478
- : path.resolve(turnState.cwd || process.cwd(), filePath);
3479
- snapshots.set(`${itemId}#${index}`, readCodexFileSnapshot(absolutePath));
3480
- });
3481
- }
3482
- else if (completed) {
3483
- afterSnapshots = new Map();
3484
- rawChanges.forEach((entry, index) => {
3485
- const filePath = getString(asRecord(entry)?.path);
3486
- const absolutePath = path.isAbsolute(filePath)
3487
- ? filePath
3488
- : path.resolve(turnState.cwd || process.cwd(), filePath);
3489
- afterSnapshots?.set(`${itemId}#${index}`, readCodexFileSnapshot(absolutePath));
3490
- });
3491
- }
3492
- }
3493
- const blocks = this.extractCodexItemBlock(item, completed, turnState.codexFileSnapshots, afterSnapshots);
3494
- if (blocks.length === 0)
3495
- return;
3496
- const index = turnState.codexBlockIndex ??= new Map();
3497
- for (const block of blocks) {
3498
- // text / thinking / TodoWrite tool_use 的卡片是"按 item id 整体替换"语义,
3499
- // 否则一个 agent_message 在 updated/completed 时会被重复 push 多次。
3500
- const replaceable = block.type === "text"
3501
- || block.type === "thinking"
3502
- || (block.type === "tool_use" && block.name === "TodoWrite");
3503
- if (replaceable && itemId) {
3504
- const existing = index.get(itemId);
3505
- if (existing !== undefined && existing < turnState.blocks.length) {
3506
- turnState.blocks[existing] = block;
3507
- }
3508
- else {
3509
- index.set(itemId, turnState.blocks.length);
3510
- turnState.blocks.push(block);
3511
- }
3512
- if (block.type === "text" && completed) {
3513
- turnState.result = block.text;
3514
- }
3515
- continue;
3516
- }
3517
- // 其它块(tool_use 非 Todo / tool_result / 文件改动的多 sub-id 块)
3518
- // 仍然走原有 upsert:tool_result 按 tool_use_id 配对,其余直接 push。
3519
- this.upsertCodexBlock(turnState.blocks, block);
3520
- }
3521
- if (completed && itemType === "file_change") {
3522
- for (const key of [...(turnState.codexFileSnapshots?.keys() ?? [])]) {
3523
- if (key.startsWith(`${itemId}#`))
3524
- turnState.codexFileSnapshots?.delete(key);
3525
- }
3526
- }
3527
- }
3528
- /**
3529
- * Map a codex `item.{started,updated,completed}` payload into wand's
3530
- * `ContentBlock[]` so the chat UI's existing tool/diff/todo cards just work.
3531
- *
3532
- * Codex `exec --json` emits 8 item.type values (see
3533
- * `codex-rs/exec/src/exec_events.rs`); below they're routed to whatever wand
3534
- * tool name reuses an existing renderer:
3535
- *
3536
- * agent_message → text
3537
- * reasoning → thinking
3538
- * command_execution → tool_use "Bash" + tool_result
3539
- * file_change → one Edit/Write per file; snapshots taken between
3540
- * item.started/completed restore the omitted diff body
3541
- * mcp_tool_call → tool_use named "<server>__<tool>" + tool_result
3542
- * web_search → tool_use "WebSearch" + tool_result (results not in stream)
3543
- * todo_list → tool_use "TodoWrite" (replaced in place on each update)
3544
- * error → text block prefixed with ❌
3545
- *
3546
- * Returns [] when there is nothing to emit yet (e.g. agent_message at
3547
- * `item.started` before any text has been produced).
3548
- *
3549
- * Callers handle in-place replacement for `item.updated` via
3550
- * `turnState.codexBlockIndex`; tool_use ↔ tool_result pairing still goes
3551
- * through `upsertCodexBlock` by matching ids.
3552
- */
3553
- extractCodexItemBlock(item, completed, beforeSnapshots, afterSnapshots) {
3554
- const id = typeof item.id === "string" ? item.id : randomUUID();
3555
- const type = typeof item.type === "string" ? item.type : "unknown";
3556
- if (type === "message") {
3557
- const role = getString(item.role);
3558
- if (role !== "assistant")
3559
- return [];
3560
- const text = this.extractCodexText(item.content);
3561
- return text ? [{ type: "text", text }] : [];
3562
- }
3563
- if (type === "agent_message") {
3564
- const text = this.extractCodexText(item);
3565
- return text ? [{ type: "text", text }] : [];
3566
- }
3567
- if (type === "reasoning") {
3568
- const text = this.extractCodexText(item);
3569
- return text ? [{ type: "thinking", thinking: text }] : [];
3570
- }
3571
- if (type === "command_execution") {
3572
- const command = typeof item.command === "string" ? item.command : "";
3573
- const aggregatedOutput = typeof item.aggregated_output === "string" ? item.aggregated_output : "";
3574
- const exitCode = typeof item.exit_code === "number" ? item.exit_code : null;
3575
- const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
3576
- const input = { command, status };
3577
- if (exitCode !== null)
3578
- input.exit_code = exitCode;
3579
- if (!completed) {
3580
- return [{
3581
- type: "tool_use",
3582
- id,
3583
- name: "Bash",
3584
- description: "running",
3585
- input,
3586
- }];
3587
- }
3588
- // codex 的 status 可能是 declined(sandbox 拒了命令)/ failed(执行失败)—
3589
- // 这时 exit_code 经常是 null,光靠 exitCode !== 0 判 is_error 会漏。
3590
- const isError = status === "failed" || status === "declined"
3591
- || (typeof exitCode === "number" && exitCode !== 0);
3592
- const fallbackText = status === "declined"
3593
- ? "command declined by sandbox"
3594
- : (exitCode === null ? "" : `exit_code: ${exitCode}`);
3595
- return [
3596
- {
3597
- type: "tool_use",
3598
- id,
3599
- name: "Bash",
3600
- description: exitCode === null ? status : `${status} · exit ${exitCode}`,
3601
- input,
3602
- },
3603
- {
3604
- type: "tool_result",
3605
- tool_use_id: id,
3606
- content: aggregatedOutput || fallbackText,
3607
- is_error: isError,
3608
- },
3609
- ];
3610
- }
3611
- if (type === "function_call") {
3612
- const block = this.codexFunctionToolUse(item);
3613
- return block ? [block] : [];
3614
- }
3615
- if (type === "function_call_output") {
3616
- const callId = getString(item.call_id) || id;
3617
- return [{
3618
- type: "tool_result",
3619
- tool_use_id: callId,
3620
- content: this.normalizeToolResultContent(item.output),
3621
- }];
3622
- }
3623
- if (type === "custom_tool_call") {
3624
- const callId = getString(item.call_id) || id;
3625
- const name = getString(item.name) || "custom_tool_call";
3626
- return [{
3627
- type: "tool_use",
3628
- id: callId,
3629
- name,
3630
- description: getString(item.status) || undefined,
3631
- input: {
3632
- input: getString(item.input),
3633
- status: getString(item.status) || (completed ? "completed" : "in_progress"),
3634
- },
3635
- }];
3636
- }
3637
- if (type === "custom_tool_call_output") {
3638
- const callId = getString(item.call_id) || id;
3639
- return [{
3640
- type: "tool_result",
3641
- tool_use_id: callId,
3642
- content: this.normalizeToolResultContent(item.output),
3643
- }];
3644
- }
3645
- if (type === "patch_apply_end") {
3646
- return buildCodexPatchApplyBlocks(item);
3647
- }
3648
- if (type === "file_change") {
3649
- return buildCodexFileChangeBlocks(item, completed, beforeSnapshots, afterSnapshots);
3650
- }
3651
- if (type === "mcp_tool_call_end") {
3652
- return this.codexMcpToolBlocks(item);
3653
- }
3654
- if (type === "mcp_tool_call") {
3655
- const server = typeof item.server === "string" ? item.server : "mcp";
3656
- const tool = typeof item.tool === "string" ? item.tool : "tool";
3657
- const args = item.arguments && typeof item.arguments === "object" ? item.arguments : {};
3658
- const errObj = item.error && typeof item.error === "object" ? item.error : null;
3659
- const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
3660
- const isError = !!errObj || status === "failed";
3661
- const input = { ...args, status };
3662
- if (!completed) {
3663
- return [{
3664
- type: "tool_use",
3665
- id,
3666
- name: `${server}__${tool}`,
3667
- description: status,
3668
- input,
3669
- }];
3670
- }
3671
- let resultText = "";
3672
- if (errObj && typeof errObj.message === "string") {
3673
- resultText = errObj.message;
3674
- }
3675
- else if (item.result && typeof item.result === "object") {
3676
- const resultRec = item.result;
3677
- const inner = this.extractCodexText(resultRec.content);
3678
- resultText = inner || JSON.stringify(resultRec).slice(0, 4096);
3679
- }
3680
- return [
3681
- {
3682
- type: "tool_use",
3683
- id,
3684
- name: `${server}__${tool}`,
3685
- description: status,
3686
- input,
3687
- },
3688
- {
3689
- type: "tool_result",
3690
- tool_use_id: id,
3691
- content: resultText,
3692
- is_error: isError,
3693
- },
3694
- ];
3695
- }
3696
- if (type === "web_search_call") {
3697
- const callId = getString(item.call_id) || id;
3698
- return [{
3699
- type: "tool_use",
3700
- id: callId,
3701
- name: "WebSearch",
3702
- description: getString(item.status) || "searching",
3703
- input: {},
3704
- }];
3705
- }
3706
- if (type === "web_search_end") {
3707
- const callId = getString(item.call_id) || id;
3708
- const action = asRecord(item.action);
3709
- const query = getString(item.query);
3710
- const actionType = getString(action?.type);
3711
- return [
3712
- {
3713
- type: "tool_use",
3714
- id: callId,
3715
- name: "WebSearch",
3716
- description: actionType || "completed",
3717
- input: query ? { query, action: actionType } : { action: actionType },
3718
- },
3719
- {
3720
- type: "tool_result",
3721
- tool_use_id: callId,
3722
- content: query ? `query: ${query}` : "",
3723
- },
3724
- ];
3725
- }
3726
- if (type === "tool_search_call") {
3727
- const callId = getString(item.call_id) || id;
3728
- const args = asRecord(item.arguments) ?? {};
3729
- return [{
3730
- type: "tool_use",
3731
- id: callId,
3732
- name: "tool_search",
3733
- description: getString(item.status) || undefined,
3734
- input: args,
3735
- }];
3736
- }
3737
- if (type === "tool_search_output") {
3738
- const callId = getString(item.call_id) || id;
3739
- return [{
3740
- type: "tool_result",
3741
- tool_use_id: callId,
3742
- content: this.normalizeToolResultContent(item.tools),
3743
- }];
3744
- }
3745
- if (type === "web_search") {
3746
- const query = typeof item.query === "string" ? item.query : "";
3747
- const action = item.action && typeof item.action === "object" ? item.action : null;
3748
- const actionType = action && typeof action.type === "string" ? action.type : "";
3749
- const queries = action && Array.isArray(action.queries)
3750
- ? action.queries.filter((v) => typeof v === "string")
3751
- : [];
3752
- const input = { query };
3753
- if (actionType)
3754
- input.action = actionType;
3755
- if (queries.length > 0)
3756
- input.queries = queries;
3757
- if (!completed) {
3758
- return [{
3759
- type: "tool_use",
3760
- id,
3761
- name: "WebSearch",
3762
- description: actionType || "searching",
3763
- input,
3764
- }];
3765
- }
3766
- return [
3767
- {
3768
- type: "tool_use",
3769
- id,
3770
- name: "WebSearch",
3771
- description: actionType || "completed",
3772
- input,
3773
- },
3774
- {
3775
- type: "tool_result",
3776
- tool_use_id: id,
3777
- // codex 不在 exec 流里回 search 结果,这里给个占位让 UI 卡片完成态。
3778
- content: queries.length > 0 ? queries.map((q) => `query: ${q}`).join("\n") : (query ? `query: ${query}` : ""),
3779
- },
3780
- ];
3781
- }
3782
- if (type === "collab_tool_call") {
3783
- // codex 的子-agent 编排(spawn_agent / send_input / wait / close_agent)。
3784
- // 没有对应 Claude tool,所以名称用 "Codex/<op>" 让 UI 默认 tool 卡渲染时
3785
- // 一眼能看出来是 codex 多 agent 操作。
3786
- const tool = typeof item.tool === "string" ? item.tool : "collab";
3787
- const prompt = typeof item.prompt === "string" ? item.prompt : "";
3788
- const senderId = typeof item.sender_thread_id === "string" ? item.sender_thread_id : "";
3789
- const receiverIds = Array.isArray(item.receiver_thread_ids)
3790
- ? item.receiver_thread_ids.filter((v) => typeof v === "string")
3791
- : [];
3792
- const agentsStates = item.agents_states && typeof item.agents_states === "object"
3793
- ? item.agents_states
3794
- : {};
3795
- const status = typeof item.status === "string" ? item.status : completed ? "completed" : "in_progress";
3796
- const toolName = `Codex/${tool}`;
3797
- const input = { tool };
3798
- if (prompt)
3799
- input.prompt = prompt;
3800
- if (senderId)
3801
- input.sender_thread_id = senderId;
3802
- if (receiverIds.length > 0)
3803
- input.receiver_thread_ids = receiverIds;
3804
- if (Object.keys(agentsStates).length > 0)
3805
- input.agents_states = agentsStates;
3806
- if (!completed) {
3807
- return [{ type: "tool_use", id, name: toolName, input }];
3808
- }
3809
- // 完成态:把每个 receiver agent 的最终状态汇总成可读 result。
3810
- const summaryLines = [];
3811
- for (const [tid, state] of Object.entries(agentsStates)) {
3812
- if (!state || typeof state !== "object")
3813
- continue;
3814
- const rec = state;
3815
- const s = typeof rec.status === "string" ? rec.status : "?";
3816
- const msg = typeof rec.message === "string" && rec.message ? ` — ${rec.message}` : "";
3817
- summaryLines.push(`${tid.slice(0, 8)}: ${s}${msg}`);
3818
- }
3819
- const isError = status === "failed"
3820
- || summaryLines.some((l) => /errored|not_found|interrupted/.test(l));
3821
- const content = summaryLines.length > 0
3822
- ? summaryLines.join("\n")
3823
- : (status === "completed" ? "ok" : status);
3824
- return [
3825
- { type: "tool_use", id, name: toolName, input },
3826
- { type: "tool_result", tool_use_id: id, content, is_error: isError },
3827
- ];
3828
- }
3829
- if (type === "todo_list") {
3830
- // codex 的 todo: { items: [{ text, completed: bool }] }
3831
- // wand UI(renderTodoWrite)读的是 block.input.todos = [{content, status, activeForm}]
3832
- // 这里做形状翻译;in_progress 状态 codex 不区分,全部 pending → completed 二值。
3833
- const rawItems = Array.isArray(item.items) ? item.items : [];
3834
- const todos = rawItems.map((entry) => {
3835
- const rec = (entry && typeof entry === "object") ? entry : {};
3836
- const text = typeof rec.text === "string" ? rec.text : "";
3837
- const done = rec.completed === true;
3838
- return {
3839
- content: text,
3840
- status: done ? "completed" : "pending",
3841
- activeForm: text,
3842
- };
3843
- });
3844
- return [{
3845
- type: "tool_use",
3846
- id,
3847
- name: "TodoWrite",
3848
- input: { todos },
3849
- }];
3850
- }
3851
- if (type === "error") {
3852
- // item-level error(不是 top-level error 事件,那个走 codexErrors / 退出报错路径)
3853
- const message = this.extractCodexText(item) || "codex item error";
3854
- return [{ type: "text", text: `❌ ${message}` }];
3855
- }
3856
- // unknown / 兜底:completed 时尝试取 text 字段免得 silently 丢
3857
- if (completed) {
3858
- const text = this.extractCodexText(item);
3859
- if (text)
3860
- return [{ type: "text", text }];
3861
- }
3862
- return [];
3863
- }
3864
- upsertCodexBlock(blocks, block) {
3865
- // tool_use 按 id 去重——file_change 在 item.started 已经 push 过一份 tool_use,
3866
- // 到 item.completed 还会再发一份相同 id 的(带 status 更新),不去重就出现
3867
- // 两张同名卡片。command_execution 不受影响(它在 completed 只 emit tool_result)。
3868
- if (block.type === "tool_use") {
3869
- const existingIndex = blocks.findIndex((existing) => existing.type === "tool_use" && existing.id === block.id);
3870
- if (existingIndex >= 0) {
3871
- blocks[existingIndex] = block;
3872
- return;
3873
- }
3874
- }
3875
- if (block.type === "tool_result") {
3876
- const toolUseIndex = blocks.findIndex((existing) => existing.type === "tool_use" && existing.id === block.tool_use_id);
3877
- if (toolUseIndex >= 0) {
3878
- const nextIndex = toolUseIndex + 1;
3879
- if (blocks[nextIndex]?.type === "tool_result" && blocks[nextIndex].tool_use_id === block.tool_use_id) {
3880
- blocks[nextIndex] = block;
3881
- }
3882
- else {
3883
- blocks.splice(nextIndex, 0, block);
3884
- }
3885
- return;
3886
- }
3887
- }
3888
- blocks.push(block);
3889
- }
3890
2189
  /**
3891
2190
  * 组装结构化 runner 退出失败时的可读错误字符串。
3892
2191
  *
@@ -3947,33 +2246,6 @@ export class StructuredSessionManager {
3947
2246
  },
3948
2247
  };
3949
2248
  }
3950
- extractModelName(modelUsage) {
3951
- if (!modelUsage)
3952
- return undefined;
3953
- const names = Object.keys(modelUsage);
3954
- return names.length > 0 ? names[0] : undefined;
3955
- }
3956
- extractUsage(source) {
3957
- if (!source || !source.usage || typeof source.usage !== "object") {
3958
- return undefined;
3959
- }
3960
- const usage = source.usage;
3961
- const value = {
3962
- inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : undefined,
3963
- outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : undefined,
3964
- cacheReadInputTokens: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : undefined,
3965
- cacheCreationInputTokens: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : undefined,
3966
- totalCostUsd: typeof source.total_cost_usd === "number" ? source.total_cost_usd : undefined,
3967
- };
3968
- if (value.inputTokens === undefined
3969
- && value.outputTokens === undefined
3970
- && value.cacheReadInputTokens === undefined
3971
- && value.cacheCreationInputTokens === undefined
3972
- && value.totalCostUsd === undefined) {
3973
- return undefined;
3974
- }
3975
- return value;
3976
- }
3977
2249
  /** Extract usage from an SDKResultSuccess message (sdk runner). */
3978
2250
  extractSdkUsage(result) {
3979
2251
  const usage = result?.usage;
@@ -3988,21 +2260,4 @@ export class StructuredSessionManager {
3988
2260
  return undefined;
3989
2261
  return value;
3990
2262
  }
3991
- extractCodexUsage(source) {
3992
- if (!source || typeof source !== "object")
3993
- return undefined;
3994
- const value = {
3995
- inputTokens: typeof source.input_tokens === "number" ? source.input_tokens : undefined,
3996
- outputTokens: typeof source.output_tokens === "number" ? source.output_tokens : undefined,
3997
- cacheReadInputTokens: typeof source.cached_input_tokens === "number" ? source.cached_input_tokens : undefined,
3998
- reasoningOutputTokens: typeof source.reasoning_output_tokens === "number" ? source.reasoning_output_tokens : undefined,
3999
- };
4000
- if (value.inputTokens === undefined
4001
- && value.outputTokens === undefined
4002
- && value.cacheReadInputTokens === undefined
4003
- && value.reasoningOutputTokens === undefined) {
4004
- return undefined;
4005
- }
4006
- return value;
4007
- }
4008
2263
  }