@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.
- package/dist/build-info.json +4 -4
- package/dist/distribution-manager.d.ts +50 -0
- package/dist/distribution-manager.js +276 -0
- package/dist/server.js +10 -420
- package/dist/structured-claude-adapter.d.ts +10 -0
- package/dist/structured-claude-adapter.js +116 -0
- package/dist/structured-claude-protocol.d.ts +34 -0
- package/dist/structured-claude-protocol.js +246 -0
- package/dist/structured-codex-adapter.d.ts +5 -0
- package/dist/structured-codex-adapter.js +94 -0
- package/dist/structured-codex-protocol.d.ts +78 -0
- package/dist/structured-codex-protocol.js +995 -0
- package/dist/structured-content.d.ts +5 -0
- package/dist/structured-content.js +17 -0
- package/dist/structured-opencode-adapter.d.ts +10 -7
- package/dist/structured-opencode-adapter.js +103 -0
- package/dist/structured-runner.d.ts +42 -0
- package/dist/structured-runner.js +1 -0
- package/dist/structured-session-manager.d.ts +13 -78
- package/dist/structured-session-manager.js +507 -2252
- package/package.json +1 -1
|
@@ -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 {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
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
|
-
|
|
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.
|
|
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
|
|
741
|
-
|
|
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.
|
|
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
|
|
575
|
+
const runnerExecution = this.pendingRunnerExecutions.get(id);
|
|
999
576
|
const sdkAbort = this.pendingSdkAbort.get(id);
|
|
1000
577
|
const sdkQueryHandle = this.pendingSdkQueries.get(id);
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
|
|
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 (
|
|
1008
|
-
this.
|
|
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
|
-
|
|
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
|
|
1420
|
-
if (
|
|
1421
|
-
|
|
1422
|
-
this.
|
|
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
|
|
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.
|
|
1446
|
-
// synchronously wake listeners in some SDK/
|
|
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 (
|
|
1449
|
-
|
|
1450
|
-
this.
|
|
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
|
-
|
|
1487
|
-
if (this.
|
|
1057
|
+
releasePendingRunnerExecution(sessionId, execution) {
|
|
1058
|
+
if (this.pendingRunnerExecutions.get(sessionId) !== execution)
|
|
1488
1059
|
return false;
|
|
1489
|
-
this.
|
|
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
|
-
|
|
1584
|
-
|
|
1585
|
-
const
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
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
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
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
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
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
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
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
|
-
|
|
2125
|
-
|
|
2126
|
-
const
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
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
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
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
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
this.
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
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
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
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
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
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:
|
|
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 =
|
|
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 =
|
|
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
|
}
|