wave-code 1.5.0 → 1.6.0
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/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +777 -855
- package/package.json +7 -7
- package/src/cli.tsx +9 -3
- package/src/components/FileSelector.tsx +11 -8
- package/src/components/HelpView.tsx +1 -1
- package/src/components/InputBox.tsx +3 -5
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/components/RewindCommand.tsx +40 -46
- package/src/contexts/useChat.tsx +61 -49
- package/src/daemon/commands.ts +266 -44
- package/src/daemon/daemonLog.ts +132 -0
- package/src/daemon/socketPath.ts +123 -0
- package/src/daemon-cli.ts +19 -6
- package/src/hooks/useInputManager.ts +27 -25
- package/src/index.ts +36 -4
- package/src/managers/inputHandlers.ts +3 -13
- package/src/managers/inputReducer.ts +44 -13
- package/src/print-cli.ts +199 -49
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +15 -5
- package/src/remote/notificationCoalescer.ts +16 -3
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +26 -4
- package/src/remote/remoteControlService.ts +70 -19
- package/src/remote/remoteNotifications.ts +5 -4
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +450 -117
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/uiPayload.ts +50 -2
- package/src/utils/agentSelector.ts +29 -0
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +403 -0
- package/src/utils/usageSummary.ts +74 -24
- package/src/utils/worktree.ts +222 -16
- package/src/utils/rewindCheckpoints.ts +0 -14
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DaemonServer — JSON-RPC server over a
|
|
3
|
-
* sessions (spec:
|
|
2
|
+
* DaemonServer — JSON-RPC server over a local socket (a unix domain socket on
|
|
3
|
+
* POSIX, a Windows named pipe) for remote background sessions (spec:
|
|
4
|
+
* docs/specs/desktop/desktop-remote.md 「SSH 远程后台会话」).
|
|
4
5
|
*
|
|
5
6
|
* The desktop app launches `wave --daemon <socket>` on the remote host via
|
|
6
7
|
* nohup/setsid, then tunnels the socket back with `ssh -L`. All connections
|
|
@@ -18,6 +19,10 @@ import net from "net";
|
|
|
18
19
|
import * as fs from "fs";
|
|
19
20
|
import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
|
|
20
21
|
import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
22
|
+
import {
|
|
23
|
+
describeListenError,
|
|
24
|
+
isWindowsPipeName,
|
|
25
|
+
} from "../daemon/socketPath.js";
|
|
21
26
|
import type { RemoteControlSink } from "../remote/remoteControlSink.js";
|
|
22
27
|
|
|
23
28
|
export interface DaemonServerOptions {
|
|
@@ -98,37 +103,43 @@ export class DaemonServer {
|
|
|
98
103
|
* unlinked outright; socket files are probe-connected — ECONNREFUSED means
|
|
99
104
|
* no listener (stale → unlink and listen), anything else means a live
|
|
100
105
|
* daemon owns the path (reject).
|
|
106
|
+
*
|
|
107
|
+
* A Windows named pipe has no file to go stale: it disappears with its
|
|
108
|
+
* listener, so the probe/unlink pass is skipped and a name already in use
|
|
109
|
+
* surfaces as EADDRINUSE from `listen` (translated in `listen`).
|
|
101
110
|
*/
|
|
102
111
|
start(): Promise<void> {
|
|
103
112
|
return new Promise((resolve, reject) => {
|
|
104
113
|
const server = this.server;
|
|
105
114
|
if (!server) return resolve();
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
probe.
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
probe.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
115
|
+
if (!isWindowsPipeName(this.socketPath)) {
|
|
116
|
+
try {
|
|
117
|
+
const st = fs.statSync(this.socketPath);
|
|
118
|
+
if (st.isSocket()) {
|
|
119
|
+
const probe = net.connect(this.socketPath);
|
|
120
|
+
probe.once("connect", () => {
|
|
121
|
+
probe.destroy();
|
|
122
|
+
reject(
|
|
123
|
+
new Error(
|
|
124
|
+
`Another wave daemon is already listening on ${this.socketPath}`,
|
|
125
|
+
),
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
probe.once("error", (err: NodeJS.ErrnoException) => {
|
|
129
|
+
probe.destroy();
|
|
130
|
+
if (err.code === "ECONNREFUSED") {
|
|
131
|
+
fs.unlinkSync(this.socketPath);
|
|
132
|
+
this.listen(server, resolve, reject);
|
|
133
|
+
} else {
|
|
134
|
+
reject(err);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
fs.unlinkSync(this.socketPath);
|
|
140
|
+
} catch {
|
|
141
|
+
// ENOENT — no stale socket, listen directly.
|
|
128
142
|
}
|
|
129
|
-
fs.unlinkSync(this.socketPath);
|
|
130
|
-
} catch {
|
|
131
|
-
// ENOENT — no stale socket, listen directly.
|
|
132
143
|
}
|
|
133
144
|
this.listen(server, resolve, reject);
|
|
134
145
|
});
|
|
@@ -139,9 +150,11 @@ export class DaemonServer {
|
|
|
139
150
|
resolve: () => void,
|
|
140
151
|
reject: (err: Error) => void,
|
|
141
152
|
): void {
|
|
142
|
-
|
|
153
|
+
const onError = (err: NodeJS.ErrnoException) =>
|
|
154
|
+
reject(describeListenError(this.socketPath, err));
|
|
155
|
+
server.once("error", onError);
|
|
143
156
|
server.listen(this.socketPath, () => {
|
|
144
|
-
server.removeListener("error",
|
|
157
|
+
server.removeListener("error", onError);
|
|
145
158
|
resolve();
|
|
146
159
|
});
|
|
147
160
|
}
|
|
@@ -174,10 +187,13 @@ export class DaemonServer {
|
|
|
174
187
|
this.sockets.clear();
|
|
175
188
|
this.server?.close();
|
|
176
189
|
this.server = undefined;
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
190
|
+
// A pipe name disappears with its listener; only a file socket needs unlinking.
|
|
191
|
+
if (!isWindowsPipeName(this.socketPath)) {
|
|
192
|
+
try {
|
|
193
|
+
fs.unlinkSync(this.socketPath);
|
|
194
|
+
} catch {
|
|
195
|
+
// Already gone — a stale file is probed/unlinked on next start.
|
|
196
|
+
}
|
|
181
197
|
}
|
|
182
198
|
try {
|
|
183
199
|
process.exit(0);
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* **What each entry claims**: `surfaces` is the set of front ends that really
|
|
19
19
|
* implement it. It is a capability statement, not a label: an entry listed for
|
|
20
20
|
* a surface that has no handling behind it produces exactly the defect spec
|
|
21
|
-
*
|
|
21
|
+
* remote/remote-web.md 场景 21 forbids — a row in the popup that does nothing
|
|
22
22
|
* when picked. `hostSlashCommands.test.ts` pins each surface's set, and the
|
|
23
23
|
* remote-page set is also cross-checked there against the remote allowlist,
|
|
24
24
|
* which is the other half of the same promise (popup lists it ⇒ picking it
|
|
@@ -96,7 +96,7 @@ export const HOST_SLASH_COMMANDS: readonly HostSlashCommandSpec[] = [
|
|
|
96
96
|
description: "清除对话历史并重置会话",
|
|
97
97
|
runOnSelect: true,
|
|
98
98
|
// 桌面端整端移除了 `/clear`(2026-09-05 拍板,见 clear-command.md 与
|
|
99
|
-
//
|
|
99
|
+
// remote/remote-web.md 场景 21)。
|
|
100
100
|
surfaces: ["vscode", "jetbrains", "remote-page"],
|
|
101
101
|
},
|
|
102
102
|
{
|
|
@@ -148,8 +148,13 @@ export const HOST_SLASH_COMMANDS: readonly HostSlashCommandSpec[] = [
|
|
|
148
148
|
id: "resume",
|
|
149
149
|
description: "恢复历史对话",
|
|
150
150
|
runOnSelect: true,
|
|
151
|
-
//
|
|
152
|
-
|
|
151
|
+
// 桌面端 / IDE 与 daemon、GUI 的 `--stdio` 子进程都能执行它;**前台 CLI 那
|
|
152
|
+
// 个承接进程不能**(它只有一条会话槽位,恢复磁盘会话等于把用户正看着的那条
|
|
153
|
+
// 换掉)——所以这一条是表第一次出现「入口一致、宿主再收窄」:这里给的是
|
|
154
|
+
// `remote-page` 的上限,`RemoteControlCliHost` 在回包前删掉它(见
|
|
155
|
+
// `remoteControlCliHost.ts` 的 `getSlashCommands`)。桌面侧栏与远控页的取数
|
|
156
|
+
// 不同:侧栏只报进程内活会话,`/resume` 才是磁盘历史会话的入口。
|
|
157
|
+
surfaces: ALL,
|
|
153
158
|
},
|
|
154
159
|
{
|
|
155
160
|
id: "btw",
|
package/src/stdio/uiPayload.ts
CHANGED
|
@@ -8,14 +8,19 @@
|
|
|
8
8
|
* a count — while one screenshot can be well over a megabyte of base64, and a
|
|
9
9
|
* remote-hosted page cannot even receive a frame that large. So every message
|
|
10
10
|
* leaving this process for a host goes through here: notifications,
|
|
11
|
-
* `getMessages
|
|
12
|
-
* the same frames.
|
|
11
|
+
* `getMessages` and the remote relay that is fed by the same frames.
|
|
13
12
|
*
|
|
14
13
|
* **The agent's own copy is untouched.** The SDK still holds the bytes, the
|
|
15
14
|
* next request still sends them to the model, and the transcript on disk keeps
|
|
16
15
|
* them. This is a wire cut, not a memory model: stripping `images` where the
|
|
17
16
|
* SDK writes the block would break the next turn, so do not move it there.
|
|
18
17
|
*
|
|
18
|
+
* **User pasted images are different.** Their bytes live in the message block
|
|
19
|
+
* (spec ui/image-pasting.md「已发送图片的标签点开就能看」) so every GUI host can
|
|
20
|
+
* preview them without asking anybody for a file — those go out to local hosts
|
|
21
|
+
* untouched, and only the remote Web page gets them cut (`messageForRemote`,
|
|
22
|
+
* spec remote/remote-channel.md「远端通道只开一个白名单」场景 10).
|
|
23
|
+
*
|
|
19
24
|
* Copies are shallow and made only where the field is actually present — the
|
|
20
25
|
* common message (no images) keeps its identity and costs nothing.
|
|
21
26
|
*/
|
|
@@ -54,6 +59,49 @@ export function toolBlockUpdateForUi(
|
|
|
54
59
|
return rest;
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* The remote Web page's copy of a message: `messageForUi`, plus user image
|
|
64
|
+
* bytes blanked out (docs/specs/remote/remote-channel.md「远端通道只开一个白名单」
|
|
65
|
+
* 场景 10).
|
|
66
|
+
*
|
|
67
|
+
* Image bytes stay in the block for GUI hosts (they preview them in place), but
|
|
68
|
+
* they must not go over the relay: one screenshot is commonly 1–3 MB of base64,
|
|
69
|
+
* far past the 512 KiB frame budget, and the page could not use the bytes even
|
|
70
|
+
* if they arrived. The array keeps its **length and order** — the `[imageN]`
|
|
71
|
+
* tags in the text block are positional — so the page still shows a tag per
|
|
72
|
+
* image, just not a clickable one. `imagePaths` goes too: it is a path on the
|
|
73
|
+
* session host, which the page cannot read.
|
|
74
|
+
*/
|
|
75
|
+
export function messageForRemote(message: Message): Message {
|
|
76
|
+
const forUi = messageForUi(message);
|
|
77
|
+
const { blocks } = forUi;
|
|
78
|
+
if (!Array.isArray(blocks)) return forUi;
|
|
79
|
+
let changed = false;
|
|
80
|
+
const blanked = blocks.map((block) => {
|
|
81
|
+
if (block.type !== "image") return block;
|
|
82
|
+
const { imagePaths, ...rest } = block;
|
|
83
|
+
if (!imagePaths && !rest.imageUrls?.length) return block;
|
|
84
|
+
changed = true;
|
|
85
|
+
return { ...rest, imageUrls: (rest.imageUrls ?? []).map(() => "") };
|
|
86
|
+
});
|
|
87
|
+
return changed ? { ...forUi, blocks: blanked } : forUi;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* `messageForRemote` over the `getMessages` answer — the shape the remote
|
|
92
|
+
* service actually holds (an untyped JSON result). Anything that is not a
|
|
93
|
+
* message list passes through untouched.
|
|
94
|
+
*/
|
|
95
|
+
export function resultForRemote(result: unknown): unknown {
|
|
96
|
+
if (!result || typeof result !== "object") return result;
|
|
97
|
+
const { messages, ...rest } = result as { messages?: unknown };
|
|
98
|
+
if (!Array.isArray(messages)) return result;
|
|
99
|
+
return {
|
|
100
|
+
...rest,
|
|
101
|
+
messages: messages.map((message) => messageForRemote(message as Message)),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
57
105
|
function hasImages(block: ToolBlock): boolean {
|
|
58
106
|
return Array.isArray(block.images) && block.images.length > 0;
|
|
59
107
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { SubagentConfiguration } from "wave-agent-sdk";
|
|
2
|
+
import type { FileSelectorItem } from "../managers/inputReducer.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* `@` 候选里的子代理条目(spec `multi-agent/subagent.md`「用户故事:显式子代理调用」)。
|
|
6
|
+
*
|
|
7
|
+
* 判据与 webview 同一份:条目带 `agentName`,点击后插入 `@agent:<名称>`;
|
|
8
|
+
* 名字/描述的过滤也在调用方做一次,和文件搜索互不影响。子代理列表来自
|
|
9
|
+
* `Agent.getSubagentConfigurations()`(已过 vision 门),本函数只做整形。
|
|
10
|
+
*/
|
|
11
|
+
export const toAgentSelectorItems = (
|
|
12
|
+
configurations: SubagentConfiguration[] | undefined,
|
|
13
|
+
filterText: string,
|
|
14
|
+
): FileSelectorItem[] => {
|
|
15
|
+
const filter = filterText.toLowerCase();
|
|
16
|
+
return (configurations ?? [])
|
|
17
|
+
.filter(
|
|
18
|
+
(configuration) =>
|
|
19
|
+
!filter ||
|
|
20
|
+
configuration.name.toLowerCase().includes(filter) ||
|
|
21
|
+
(configuration.description ?? "").toLowerCase().includes(filter),
|
|
22
|
+
)
|
|
23
|
+
.map((configuration) => ({
|
|
24
|
+
path: `agent:${configuration.name}`,
|
|
25
|
+
type: "agent" as const,
|
|
26
|
+
agentName: configuration.name,
|
|
27
|
+
agentDescription: configuration.description,
|
|
28
|
+
}));
|
|
29
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep yargs' `hideBin()` slicing argv the way a plain `node script.js` run
|
|
3
|
+
* does, even when the CLI is executed by the Electron runtime.
|
|
4
|
+
*
|
|
5
|
+
* Electron RUN_AS_NODE (the desktop / vscode bundled runtime, `process.execPath`
|
|
6
|
+
* + `ELECTRON_RUN_AS_NODE=1`) leaves `process.versions.electron` set, so
|
|
7
|
+
* `hideBin()` mistakes the process for a *packaged* Electron app, slices argv
|
|
8
|
+
* from index 0 instead of 1, and keeps the script path as a positional
|
|
9
|
+
* argument — yargs then prints its help and exits 1 with
|
|
10
|
+
* `Unknown argument: <script>`, i.e. the host sees a CLI that dies on launch.
|
|
11
|
+
* `process.defaultApp` is exactly the signal `hideBin()` reads as "plain node".
|
|
12
|
+
*
|
|
13
|
+
* `bin/wave-code.js` carries the same guard for the shim entry; this module
|
|
14
|
+
* covers the bundle entry (`dist/bundle/wave.mjs`), which a host runs directly
|
|
15
|
+
* whenever `WAVE_CLI_PATH` points at the workspace build — the documented dev
|
|
16
|
+
* setup — instead of the shim.
|
|
17
|
+
*/
|
|
18
|
+
export function ensurePlainNodeArgv(): void {
|
|
19
|
+
if (process.versions.electron && process.env.ELECTRON_RUN_AS_NODE === "1") {
|
|
20
|
+
// `defaultApp` is an Electron-only property, absent from @types/node.
|
|
21
|
+
(process as NodeJS.Process & { defaultApp?: boolean }).defaultApp = true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 打印模式的结构化输出(spec: docs/specs/ui/print-mode.md「结构化输出」)。
|
|
3
|
+
*
|
|
4
|
+
* 一个采集器喂两种格式,所以 stdout 上的事件与最后的 result 不可能对不上:
|
|
5
|
+
* - `json`:只在结束时写一条 envelope;
|
|
6
|
+
* - `stream-json`:按行写事件流,末行是同构的 result 事件。
|
|
7
|
+
*
|
|
8
|
+
* **payload 语义照 stdio 通知那套**(`remote/remoteNotifications.ts` 的 `remoteWire`):
|
|
9
|
+
* 内容事件是增量不是累积快照、工具事件不带图片字节、`tool/end` 不带完整工具输出。
|
|
10
|
+
* **信封不复用 JSON-RPC**:`-p` 是单会话一次运行,`method` + 每行重复 sessionId 对管道
|
|
11
|
+
* 是噪音,且 stdio 没有「运行结束」与 usage 事件——那两块换哪个信封都得新造。
|
|
12
|
+
*
|
|
13
|
+
* envelope 骨架对齐 Claude Code 的 `--output-format json`(常用字段同名、错误版同样不带
|
|
14
|
+
* `result` 而带 `errors`),但**数值口径不对齐**:我们给 OpenAI 口径的 token 数
|
|
15
|
+
* (`prompt_tokens` 已含 cache_read,不等于 CC 的 `input_tokens`),也没有价目表,
|
|
16
|
+
* 所以不输出 `total_cost_usd` / `costUSD`,而不是填 0 假装有值。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
PermissionMode,
|
|
21
|
+
ToolBlockUpdateCallbackParams,
|
|
22
|
+
Usage,
|
|
23
|
+
} from "wave-agent-sdk";
|
|
24
|
+
import {
|
|
25
|
+
calculateTokenSummary,
|
|
26
|
+
sumUsage,
|
|
27
|
+
type TokenSummary,
|
|
28
|
+
type UsageTotals,
|
|
29
|
+
} from "./usageSummary.js";
|
|
30
|
+
|
|
31
|
+
export type OutputFormat = "text" | "json" | "stream-json";
|
|
32
|
+
|
|
33
|
+
/** 一次工具调用的序列记录。不带工具输出——一次 `Read` 就够撑爆 stdout。 */
|
|
34
|
+
export interface ToolCallRecord {
|
|
35
|
+
id: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
/** 参数对象(JSON 解析结果);参数不是合法 JSON 时为原始字符串。 */
|
|
38
|
+
input?: unknown;
|
|
39
|
+
success?: boolean;
|
|
40
|
+
error?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* stdout 上的 result envelope。
|
|
45
|
+
*
|
|
46
|
+
* 成功版有 `result`、失败版没有(与 Claude Code 一致);`errors` 仅在非空时出现,
|
|
47
|
+
* 成功版里它是工具级错误的去处,属于对 CC 的**超集**——按 CC 口径解析的消费方
|
|
48
|
+
* 不会因此读错。
|
|
49
|
+
*/
|
|
50
|
+
export interface PrintResult {
|
|
51
|
+
type: "result";
|
|
52
|
+
subtype: "success" | "error_during_execution";
|
|
53
|
+
is_error: boolean;
|
|
54
|
+
result?: string;
|
|
55
|
+
session_id: string;
|
|
56
|
+
num_turns: number;
|
|
57
|
+
duration_ms: number;
|
|
58
|
+
usage: UsageTotals;
|
|
59
|
+
model_usage: Record<string, TokenSummary>;
|
|
60
|
+
tool_calls: ToolCallRecord[];
|
|
61
|
+
errors?: string[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 首事件 `system/init` 的取值来源:建好 agent 后立刻快照一次。 */
|
|
65
|
+
export interface PrintSessionInit {
|
|
66
|
+
sessionId: string;
|
|
67
|
+
model?: string;
|
|
68
|
+
cwd: string;
|
|
69
|
+
permissionMode: PermissionMode;
|
|
70
|
+
tools: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface PrintResultInput {
|
|
74
|
+
sessionId: string;
|
|
75
|
+
durationMs: number;
|
|
76
|
+
usages: Usage[];
|
|
77
|
+
/** 运行本身失败(建 agent / 发消息抛错),不是工具级失败。 */
|
|
78
|
+
isError?: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PrintOutputOptions {
|
|
82
|
+
format: OutputFormat;
|
|
83
|
+
/** stdout 写入口,便于单测注入。 */
|
|
84
|
+
write?: (chunk: string) => void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class PrintOutput {
|
|
88
|
+
readonly format: OutputFormat;
|
|
89
|
+
private readonly writeStdout: (chunk: string) => void;
|
|
90
|
+
|
|
91
|
+
private readonly assistantTexts: string[] = [];
|
|
92
|
+
private readonly toolCalls = new Map<string, ToolCallRecord>();
|
|
93
|
+
private readonly errors: string[] = [];
|
|
94
|
+
private numTurns = 0;
|
|
95
|
+
/**
|
|
96
|
+
* init 之前写出的事件先存这里,`init()` 时先写 init 再按序补上。
|
|
97
|
+
*
|
|
98
|
+
* `Agent.create()` 期间就可能落事件:初始化阶段加的错误块会走
|
|
99
|
+
* `onNoticeAdded`(`level: "error"`)(真机:`-r <非法 session id>` 时 `initializationService`
|
|
100
|
+
* 抛错前先落了一个错误块),而 init 只能等 agent 建好才有 session/model 可报。
|
|
101
|
+
* 不缓冲的话首行会是 error,按首行判类型的消费方就判错了。
|
|
102
|
+
*/
|
|
103
|
+
private pendingBeforeInit: string[] = [];
|
|
104
|
+
private initWritten = false;
|
|
105
|
+
|
|
106
|
+
constructor(options: PrintOutputOptions) {
|
|
107
|
+
this.format = options.format;
|
|
108
|
+
this.writeStdout =
|
|
109
|
+
options.write ?? ((chunk: string) => void process.stdout.write(chunk));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
get structured(): boolean {
|
|
113
|
+
return this.format !== "text";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** 首事件(只有 stream-json 有)。必须在创建阶段的输出之后、运行输出之前调用。 */
|
|
117
|
+
init(snapshot: PrintSessionInit): void {
|
|
118
|
+
if (this.format !== "stream-json") return;
|
|
119
|
+
this.initWritten = true;
|
|
120
|
+
this.writeStdout(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
type: "system",
|
|
123
|
+
subtype: "init",
|
|
124
|
+
session_id: snapshot.sessionId,
|
|
125
|
+
model: snapshot.model,
|
|
126
|
+
cwd: snapshot.cwd,
|
|
127
|
+
permission_mode: snapshot.permissionMode,
|
|
128
|
+
tools: snapshot.tools,
|
|
129
|
+
}) + "\n",
|
|
130
|
+
);
|
|
131
|
+
for (const line of this.pendingBeforeInit) this.writeStdout(line);
|
|
132
|
+
this.pendingBeforeInit = [];
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** 人读输出:text 模式写 stdout,结构化模式丢弃(信息已在事件里)。 */
|
|
136
|
+
human(text: string): void {
|
|
137
|
+
if (this.structured) return;
|
|
138
|
+
this.writeStdout(text);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 诊断信息:text 模式跟随人读输出,结构化模式转 stderr,绝不污染 stdout。 */
|
|
142
|
+
diag(text: string): void {
|
|
143
|
+
if (this.structured) {
|
|
144
|
+
process.stderr.write(text);
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
this.writeStdout(text);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
assistantMessageAdded(): void {
|
|
151
|
+
this.numTurns += 1;
|
|
152
|
+
this.assistantTexts.push("");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** 流式降级的半截消息作废:文本与轮次都不计入结果。 */
|
|
156
|
+
assistantMessageDiscarded(): void {
|
|
157
|
+
if (this.assistantTexts.length === 0) return;
|
|
158
|
+
this.assistantTexts.pop();
|
|
159
|
+
this.numTurns -= 1;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
reasoningDelta(messageId: string, delta: string): void {
|
|
163
|
+
if (this.format !== "stream-json" || !delta) return;
|
|
164
|
+
this.writeEvent({
|
|
165
|
+
type: "assistant",
|
|
166
|
+
subtype: "reasoning_delta",
|
|
167
|
+
message_id: messageId,
|
|
168
|
+
delta,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
contentDelta(messageId: string, delta: string): void {
|
|
173
|
+
if (!delta) return;
|
|
174
|
+
// 有的路径可能不走 assistantMessageAdded(例如恢复会话后直接出内容),
|
|
175
|
+
// 那就补一个槽位,别把文本丢掉。
|
|
176
|
+
if (this.assistantTexts.length === 0) this.assistantTexts.push("");
|
|
177
|
+
this.assistantTexts[this.assistantTexts.length - 1] += delta;
|
|
178
|
+
if (this.format !== "stream-json") return;
|
|
179
|
+
this.writeEvent({
|
|
180
|
+
type: "assistant",
|
|
181
|
+
subtype: "content_delta",
|
|
182
|
+
message_id: messageId,
|
|
183
|
+
delta,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* 工具事件。
|
|
189
|
+
*
|
|
190
|
+
* 只看两个阶段:
|
|
191
|
+
* - `running`:执行开始,参数已完整。**按 id 去重**——`onShortResultUpdate`
|
|
192
|
+
* 会带着 `running` 再发一次,不去重就会重复记一条调用。
|
|
193
|
+
* - `end`:拿到最终成功/失败。权限拒绝的路径**只有 end 没有 running**,
|
|
194
|
+
* 所以条目必须在两个阶段都能建立,否则被拒的工具会从序列里消失。
|
|
195
|
+
*/
|
|
196
|
+
toolUpdate(params: ToolBlockUpdateCallbackParams): void {
|
|
197
|
+
const { id, stage } = params;
|
|
198
|
+
if (!id) return;
|
|
199
|
+
|
|
200
|
+
if (stage === "running") {
|
|
201
|
+
if (this.toolCalls.has(id)) return;
|
|
202
|
+
const record = this.upsertToolCall(id, params);
|
|
203
|
+
if (this.format === "stream-json") {
|
|
204
|
+
this.writeEvent({
|
|
205
|
+
type: "tool",
|
|
206
|
+
subtype: "start",
|
|
207
|
+
message_id: params.messageId,
|
|
208
|
+
tool_use_id: id,
|
|
209
|
+
name: record.name,
|
|
210
|
+
input: record.input,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (stage === "end") {
|
|
217
|
+
const record = this.upsertToolCall(id, params);
|
|
218
|
+
record.success = params.success;
|
|
219
|
+
if (params.error) record.error = params.error;
|
|
220
|
+
if (this.format === "stream-json") {
|
|
221
|
+
this.writeEvent({
|
|
222
|
+
type: "tool",
|
|
223
|
+
subtype: "end",
|
|
224
|
+
message_id: params.messageId,
|
|
225
|
+
tool_use_id: id,
|
|
226
|
+
name: record.name,
|
|
227
|
+
success: record.success,
|
|
228
|
+
error: record.error,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// start / streaming:参数还在流里(start 无完整参数、streaming 是增量片段),
|
|
233
|
+
// 都不构成一条可读的工具调用记录。
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** 错误块:进 `errors`,不改 `is_error`——工具失败不等于本次运行失败。 */
|
|
237
|
+
error(message: string): void {
|
|
238
|
+
this.errors.push(message);
|
|
239
|
+
if (this.format !== "stream-json") return;
|
|
240
|
+
this.writeEvent({ type: "error", message });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** 生成 envelope;结构化模式下同时写出去(json 单条 / stream-json 末行)。 */
|
|
244
|
+
result(input: PrintResultInput): PrintResult {
|
|
245
|
+
const errors = [...this.errors];
|
|
246
|
+
const resultText = this.finalText();
|
|
247
|
+
// 除了「抛错」,还有一类运行失败不会抛出:模型不可用 / 鉴权失败这类请求级
|
|
248
|
+
// 错误只落一个错误块,循环正常收尾。判据取「有错且一句话正文都没产出」——
|
|
249
|
+
// 压缩失败、hook 报错这类可恢复错误后面还跟着回答,不会被算成失败。
|
|
250
|
+
const isError =
|
|
251
|
+
(input.isError ?? false) || (errors.length > 0 && resultText === "");
|
|
252
|
+
const envelope: PrintResult = {
|
|
253
|
+
type: "result",
|
|
254
|
+
subtype: isError ? "error_during_execution" : "success",
|
|
255
|
+
is_error: isError,
|
|
256
|
+
session_id: input.sessionId,
|
|
257
|
+
num_turns: this.numTurns,
|
|
258
|
+
duration_ms: input.durationMs,
|
|
259
|
+
usage: sumUsage(input.usages),
|
|
260
|
+
model_usage: calculateTokenSummary(input.usages),
|
|
261
|
+
tool_calls: [...this.toolCalls.values()],
|
|
262
|
+
...(errors.length > 0 ? { errors } : {}),
|
|
263
|
+
// 失败版没有 result(对齐 CC 的错误版);成功版即使为空也给空串
|
|
264
|
+
...(isError ? {} : { result: resultText }),
|
|
265
|
+
};
|
|
266
|
+
if (this.structured) this.writeEvent(envelope, false);
|
|
267
|
+
return envelope;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** 最后一条助手消息的文本;`result` 取它(不是整个会话)。 */
|
|
271
|
+
private finalText(): string {
|
|
272
|
+
return this.assistantTexts[this.assistantTexts.length - 1] ?? "";
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
private upsertToolCall(
|
|
276
|
+
id: string,
|
|
277
|
+
params: ToolBlockUpdateCallbackParams,
|
|
278
|
+
): ToolCallRecord {
|
|
279
|
+
let record = this.toolCalls.get(id);
|
|
280
|
+
if (!record) {
|
|
281
|
+
record = {
|
|
282
|
+
id,
|
|
283
|
+
name: params.name,
|
|
284
|
+
input: parseToolInput(params.parameters),
|
|
285
|
+
};
|
|
286
|
+
this.toolCalls.set(id, record);
|
|
287
|
+
return record;
|
|
288
|
+
}
|
|
289
|
+
if (record.name === undefined && params.name) record.name = params.name;
|
|
290
|
+
if (record.input === undefined && params.parameters) {
|
|
291
|
+
record.input = parseToolInput(params.parameters);
|
|
292
|
+
}
|
|
293
|
+
return record;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private writeEvent(event: object, bufferable = true): void {
|
|
297
|
+
const line = JSON.stringify(event) + "\n";
|
|
298
|
+
// 只有 stream-json 有 init,也只有它需要等 init 排在前面。
|
|
299
|
+
if (this.format === "stream-json" && !this.initWritten) {
|
|
300
|
+
if (bufferable) {
|
|
301
|
+
this.pendingBeforeInit.push(line);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
// 不会再等 init 了(建 agent 就失败):把押后的事件按序放出来,别丢。
|
|
305
|
+
for (const pending of this.pendingBeforeInit) this.writeStdout(pending);
|
|
306
|
+
this.pendingBeforeInit = [];
|
|
307
|
+
}
|
|
308
|
+
this.writeStdout(line);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** 参数详情:能解析就给对象,解析不了保留原始串(不为难消费方猜)。 */
|
|
313
|
+
function parseToolInput(parameters?: string): unknown {
|
|
314
|
+
if (!parameters) return undefined;
|
|
315
|
+
try {
|
|
316
|
+
return JSON.parse(parameters);
|
|
317
|
+
} catch {
|
|
318
|
+
return parameters;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ── stdout 守卫 ────────────────────────────────────────────────
|
|
323
|
+
// 手写路由只管得住我们自己的写点;依赖库里的零散 `console.log`、漏删的调试输出照样
|
|
324
|
+
// 会撕开 NDJSON 流,而按行解析的消费方一旦遇到脏行就整个崩掉。所以结构化模式下把
|
|
325
|
+
// stdout 整体包一层:合法的 JSON 行放行,其余整行转 stderr 并带标记(与 Claude Code
|
|
326
|
+
// 的 streamJsonStdoutGuard 同思路,范围只限本次运行)。
|
|
327
|
+
|
|
328
|
+
export const STDOUT_GUARD_MARKER = "[stdout-guard]";
|
|
329
|
+
|
|
330
|
+
let originalWrite: typeof process.stdout.write | null = null;
|
|
331
|
+
let pendingLine = "";
|
|
332
|
+
|
|
333
|
+
function isJsonLine(line: string): boolean {
|
|
334
|
+
// 空行在 NDJSON 里是容忍的(末尾换行、分隔空行),不当作脏数据
|
|
335
|
+
if (line.length === 0) return true;
|
|
336
|
+
try {
|
|
337
|
+
JSON.parse(line);
|
|
338
|
+
return true;
|
|
339
|
+
} catch {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** 安装守卫。重复调用是空操作。 */
|
|
345
|
+
export function installStdoutGuard(): void {
|
|
346
|
+
if (originalWrite) return;
|
|
347
|
+
originalWrite = process.stdout.write.bind(
|
|
348
|
+
process.stdout,
|
|
349
|
+
) as typeof process.stdout.write;
|
|
350
|
+
|
|
351
|
+
process.stdout.write = function (
|
|
352
|
+
chunk: string | Uint8Array,
|
|
353
|
+
encodingOrCb?: BufferEncoding | ((err?: Error) => void),
|
|
354
|
+
cb?: (err?: Error) => void,
|
|
355
|
+
): boolean {
|
|
356
|
+
const text =
|
|
357
|
+
typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf-8");
|
|
358
|
+
pendingLine += text;
|
|
359
|
+
let wrote = true;
|
|
360
|
+
let newlineIndex: number;
|
|
361
|
+
while ((newlineIndex = pendingLine.indexOf("\n")) !== -1) {
|
|
362
|
+
const line = pendingLine.slice(0, newlineIndex);
|
|
363
|
+
pendingLine = pendingLine.slice(newlineIndex + 1);
|
|
364
|
+
if (isJsonLine(line)) {
|
|
365
|
+
wrote = originalWrite!(line + "\n") && wrote;
|
|
366
|
+
} else {
|
|
367
|
+
process.stderr.write(`${STDOUT_GUARD_MARKER} ${line}\n`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
const callback = typeof encodingOrCb === "function" ? encodingOrCb : cb;
|
|
371
|
+
// 回调按写入完成报,即便某行被转到 stderr——调用方的意图(写出去)已被执行
|
|
372
|
+
if (callback) queueMicrotask(() => callback());
|
|
373
|
+
return wrote;
|
|
374
|
+
} as typeof process.stdout.write;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** 卸载守卫并处理缓冲区残余(退出前调用):合法放行,非法转 stderr。 */
|
|
378
|
+
export function uninstallStdoutGuard(): void {
|
|
379
|
+
if (!originalWrite) return;
|
|
380
|
+
const rest = pendingLine;
|
|
381
|
+
pendingLine = "";
|
|
382
|
+
if (rest.length > 0) {
|
|
383
|
+
if (isJsonLine(rest)) {
|
|
384
|
+
originalWrite(rest + "\n");
|
|
385
|
+
} else {
|
|
386
|
+
process.stderr.write(`${STDOUT_GUARD_MARKER} ${rest}\n`);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
process.stdout.write = originalWrite;
|
|
390
|
+
originalWrite = null;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* 等 stdout 排空再退出。
|
|
395
|
+
*
|
|
396
|
+
* `process.exit()` 会截断管道上还没 flush 的写——消费方读到的末行可能少一截。
|
|
397
|
+
* 用一次空写拿回调,回调即「此前排队的写都出完了」(Node 在管道上的语义)。
|
|
398
|
+
*/
|
|
399
|
+
export function drainStdout(): Promise<void> {
|
|
400
|
+
return new Promise<void>((resolve) => {
|
|
401
|
+
process.stdout.write("", () => resolve());
|
|
402
|
+
});
|
|
403
|
+
}
|