wave-code 1.3.0 → 1.5.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/dist/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
|
@@ -18,10 +18,19 @@ import net from "net";
|
|
|
18
18
|
import * as fs from "fs";
|
|
19
19
|
import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
|
|
20
20
|
import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
21
|
+
import type { RemoteControlSink } from "../remote/remoteControlSink.js";
|
|
21
22
|
|
|
22
23
|
export interface DaemonServerOptions {
|
|
23
24
|
socketPath: string;
|
|
24
25
|
bridgeOptions?: AgentBridgeOptions;
|
|
26
|
+
/**
|
|
27
|
+
* Built with the bridge (which the daemon owns privately) so the sink can
|
|
28
|
+
* route inbound phone requests back through it. Its notifications are wired
|
|
29
|
+
* up here; connecting is the user's call, not ours. Unset means remote
|
|
30
|
+
* control is off for this daemon — the `remoteControl*` RPCs then report
|
|
31
|
+
* exactly that through the bridge's own error.
|
|
32
|
+
*/
|
|
33
|
+
createRemoteSink?: (bridge: AgentBridge) => RemoteControlSink;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
export class DaemonServer {
|
|
@@ -30,11 +39,17 @@ export class DaemonServer {
|
|
|
30
39
|
private bridge: AgentBridge;
|
|
31
40
|
private connections = new Set<JsonRpcConnection>();
|
|
32
41
|
private sockets = new Set<net.Socket>();
|
|
42
|
+
private remoteSink: RemoteControlSink | undefined;
|
|
33
43
|
|
|
34
44
|
constructor(options: DaemonServerOptions) {
|
|
35
45
|
this.socketPath = options.socketPath;
|
|
46
|
+
let remoteSink: RemoteControlSink | undefined;
|
|
36
47
|
this.bridge = new AgentBridge({
|
|
37
48
|
...options.bridgeOptions,
|
|
49
|
+
// The sink is handed the bridge it must route phone requests back
|
|
50
|
+
// through, so it can only be built after it; reading it lazily keeps the
|
|
51
|
+
// two from having to be constructed in a circle.
|
|
52
|
+
remoteControl: () => remoteSink,
|
|
38
53
|
// Notifications go to every attached client; a fully detached daemon
|
|
39
54
|
// has none, and the write is dropped silently (the attach snapshot
|
|
40
55
|
// re-syncs state on reconnect).
|
|
@@ -42,11 +57,20 @@ export class DaemonServer {
|
|
|
42
57
|
for (const conn of this.connections) {
|
|
43
58
|
conn.sendNotification(method, params, sessionId);
|
|
44
59
|
}
|
|
60
|
+
// The phone must never be able to take the local clients down with
|
|
61
|
+
// it, so a remote-path failure is contained here rather than thrown.
|
|
62
|
+
try {
|
|
63
|
+
this.remoteSink?.sendNotification(method, params, sessionId);
|
|
64
|
+
} catch {
|
|
65
|
+
// Dropped frame; the phone re-syncs on reconnect.
|
|
66
|
+
}
|
|
45
67
|
},
|
|
46
68
|
// The `shutdown` RPC (wave daemon stop/restart) destroys every session in
|
|
47
69
|
// the bridge, then hands off here to tear the process down.
|
|
48
70
|
onShutdownRequest: () => this.shutdown(),
|
|
49
71
|
});
|
|
72
|
+
remoteSink = options.createRemoteSink?.(this.bridge);
|
|
73
|
+
this.remoteSink = remoteSink;
|
|
50
74
|
this.server = net.createServer((socket) => {
|
|
51
75
|
const conn = new JsonRpcConnection(socket, socket, this.bridge);
|
|
52
76
|
this.connections.add(conn);
|
|
@@ -124,6 +148,8 @@ export class DaemonServer {
|
|
|
124
148
|
|
|
125
149
|
stop(): Promise<void> {
|
|
126
150
|
return new Promise((resolve) => {
|
|
151
|
+
this.remoteSink?.stop();
|
|
152
|
+
this.remoteSink = undefined;
|
|
127
153
|
const server = this.server;
|
|
128
154
|
if (!server) return resolve();
|
|
129
155
|
this.server = undefined;
|
|
@@ -140,6 +166,10 @@ export class DaemonServer {
|
|
|
140
166
|
* teardown.
|
|
141
167
|
*/
|
|
142
168
|
private shutdown(): void {
|
|
169
|
+
// Drop the relay link explicitly: the process is about to exit, and leaving
|
|
170
|
+
// the socket to the OS would have the relay hold a dead subscription.
|
|
171
|
+
this.remoteSink?.stop();
|
|
172
|
+
this.remoteSink = undefined;
|
|
143
173
|
for (const socket of this.sockets) socket.destroy();
|
|
144
174
|
this.sockets.clear();
|
|
145
175
|
this.server?.close();
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The **one** table of host-local slash commands (spec ui/slash-commands.md
|
|
3
|
+
* 「弹窗里的宿主命令只有一份表——由服务端下发并按入口过滤」).
|
|
4
|
+
*
|
|
5
|
+
* These are the commands a front end handles itself — it opens a dialog, a
|
|
6
|
+
* settings page or a panel, or it intercepts the text before it reaches the
|
|
7
|
+
* agent. They are not registered with the SDK, so they cannot come from
|
|
8
|
+
* `agent.getSlashCommands()`; before this table existed each host carried its
|
|
9
|
+
* own copy (desktop 17, VS Code 18, JetBrains 17 — differing on `/clear` and
|
|
10
|
+
* `/config` only) and the phone page carried none, which is why the same
|
|
11
|
+
* command was visible on the computer and had to be typed by hand on the phone.
|
|
12
|
+
*
|
|
13
|
+
* It lives here, next to `AgentBridge`, because that is the one place every
|
|
14
|
+
* front end already talks to: the daemon and each GUI's `--stdio` child serve
|
|
15
|
+
* the popup from it, the phone's requests land on it, and JetBrains — which
|
|
16
|
+
* cannot import TypeScript at all — reads it as JSON over the same RPC.
|
|
17
|
+
*
|
|
18
|
+
* **What each entry claims**: `surfaces` is the set of front ends that really
|
|
19
|
+
* implement it. It is a capability statement, not a label: an entry listed for
|
|
20
|
+
* a surface that has no handling behind it produces exactly the defect spec
|
|
21
|
+
* ui/remote-control.md 场景 24 forbids — a row in the popup that does nothing
|
|
22
|
+
* when picked. `hostSlashCommands.test.ts` pins each surface's set, and the
|
|
23
|
+
* remote-page set is also cross-checked there against the remote allowlist,
|
|
24
|
+
* which is the other half of the same promise (popup lists it ⇒ picking it
|
|
25
|
+
* works).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { HostSurface, SlashCommandInfo } from "wave-agent-sdk/types";
|
|
29
|
+
|
|
30
|
+
/** Which front ends a command is offered to. */
|
|
31
|
+
type Surfaces = readonly HostSurface[];
|
|
32
|
+
|
|
33
|
+
interface HostSlashCommandSpec {
|
|
34
|
+
id: string;
|
|
35
|
+
description: string;
|
|
36
|
+
/** Absent = insert `/name ` and let the user finish (only `/btw` today). */
|
|
37
|
+
runOnSelect?: boolean;
|
|
38
|
+
surfaces: Surfaces;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Everyone's entries — `/clear` being the one that is not quite everyone's. */
|
|
42
|
+
const ALL: Surfaces = ["desktop", "vscode", "jetbrains", "remote-page"];
|
|
43
|
+
|
|
44
|
+
/** The GUIs that own a settings page / marketplace / panel — not the phone. */
|
|
45
|
+
const GUIS: Surfaces = ["desktop", "vscode", "jetbrains"];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The table, in popup order. Order matters: the popup appends these after the
|
|
49
|
+
* SDK's own commands and renders them as given, so the machine also decides
|
|
50
|
+
* where a command sits. This is the order the two GUI hosts already used.
|
|
51
|
+
*/
|
|
52
|
+
export const HOST_SLASH_COMMANDS: readonly HostSlashCommandSpec[] = [
|
|
53
|
+
// ── 需要宿主界面的:只有桌面端与 IDE 有承载它们的页面 ──
|
|
54
|
+
{
|
|
55
|
+
id: "config",
|
|
56
|
+
description: "打开配置设置",
|
|
57
|
+
runOnSelect: true,
|
|
58
|
+
// JB 的设置页在自己的菜单里,没有这条入口;远控页没有设置页。
|
|
59
|
+
surfaces: ["desktop", "vscode"],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: "plugin",
|
|
63
|
+
description: "打开插件市场",
|
|
64
|
+
runOnSelect: true,
|
|
65
|
+
surfaces: GUIS,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "reload-plugins",
|
|
69
|
+
description: "就地重载插件(不重启对话)",
|
|
70
|
+
runOnSelect: true,
|
|
71
|
+
surfaces: GUIS,
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "mcp",
|
|
75
|
+
description: "打开 MCP 服务器管理",
|
|
76
|
+
runOnSelect: true,
|
|
77
|
+
surfaces: GUIS,
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
// ── 页面也能执行的:背后是会话内的方法,不需要编辑器与文件系统 ──
|
|
81
|
+
{
|
|
82
|
+
id: "status",
|
|
83
|
+
description: "查看当前状态",
|
|
84
|
+
runOnSelect: true,
|
|
85
|
+
surfaces: ALL,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: "remote-control",
|
|
89
|
+
description: "远程控制(用手机接管本机会话)",
|
|
90
|
+
runOnSelect: true,
|
|
91
|
+
// 远控页不列它:那一页就是二维码指向的东西,给自己开一个入口是自我指涉。
|
|
92
|
+
surfaces: GUIS,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
id: "clear",
|
|
96
|
+
description: "清除对话历史并重置会话",
|
|
97
|
+
runOnSelect: true,
|
|
98
|
+
// 桌面端整端移除了 `/clear`(2026-09-05 拍板,见 clear-command.md 与
|
|
99
|
+
// ui/remote-control.md 场景 24)。
|
|
100
|
+
surfaces: ["vscode", "jetbrains", "remote-page"],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: "compact",
|
|
104
|
+
description: "手动压缩对话历史",
|
|
105
|
+
runOnSelect: true,
|
|
106
|
+
surfaces: ALL,
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
id: "tasks",
|
|
110
|
+
description: "查看后台任务",
|
|
111
|
+
runOnSelect: true,
|
|
112
|
+
surfaces: ALL,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: "workflows",
|
|
116
|
+
description: "查看工作流运行",
|
|
117
|
+
runOnSelect: true,
|
|
118
|
+
surfaces: ALL,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "agents",
|
|
122
|
+
description: "查看可用 agents",
|
|
123
|
+
runOnSelect: true,
|
|
124
|
+
surfaces: GUIS,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
id: "skills",
|
|
128
|
+
description: "查看可用技能",
|
|
129
|
+
runOnSelect: true,
|
|
130
|
+
surfaces: GUIS,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
id: "hooks",
|
|
134
|
+
description: "查看已配置钩子",
|
|
135
|
+
runOnSelect: true,
|
|
136
|
+
surfaces: GUIS,
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
id: "rewind",
|
|
140
|
+
description: "回退到之前的用户消息",
|
|
141
|
+
runOnSelect: true,
|
|
142
|
+
surfaces: ALL,
|
|
143
|
+
},
|
|
144
|
+
// `/model` 不在这里:模型入口自 2026-10-21 起是输入卡片底栏的选择器(见 spec
|
|
145
|
+
// ui/model-selection.md),桌面 / IDE / 远控页都不再有这条命令;CLI 的 TUI 没有
|
|
146
|
+
// 底栏,因此 `/model` 只留在它自己的命令表里(packages/code 的 AVAILABLE_COMMANDS)。
|
|
147
|
+
{
|
|
148
|
+
id: "resume",
|
|
149
|
+
description: "恢复历史对话",
|
|
150
|
+
runOnSelect: true,
|
|
151
|
+
// 页面的会话列表只报进程内的活会话,磁盘历史会话在那边不渲染(场景 24)。
|
|
152
|
+
surfaces: GUIS,
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
id: "btw",
|
|
156
|
+
description: "旁路提问(不进入聊天记录)",
|
|
157
|
+
// 不标 `runOnSelect`:选中后只把 `/btw ` 插进输入框,等用户补问题
|
|
158
|
+
// (spec ui/btw-command.md)。
|
|
159
|
+
surfaces: ALL,
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
id: "plan",
|
|
163
|
+
description: "启用规划模式或查看当前方案",
|
|
164
|
+
runOnSelect: true,
|
|
165
|
+
// 页面上没有计划预览面板,计划正文渲染在 ExitPlanMode 的审批卡里。
|
|
166
|
+
surfaces: GUIS,
|
|
167
|
+
},
|
|
168
|
+
];
|
|
169
|
+
|
|
170
|
+
/** The table as one surface sees it, already in wire shape. */
|
|
171
|
+
export function hostSlashCommandsFor(surface: HostSurface): SlashCommandInfo[] {
|
|
172
|
+
return HOST_SLASH_COMMANDS.filter((command) =>
|
|
173
|
+
command.surfaces.includes(surface),
|
|
174
|
+
).map((command) => ({
|
|
175
|
+
id: command.id,
|
|
176
|
+
name: command.id,
|
|
177
|
+
description: command.description,
|
|
178
|
+
...(command.runOnSelect ? { runOnSelect: true } : {}),
|
|
179
|
+
}));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* An agent command as the popup's wire shape — `handler` and `immediate` do
|
|
184
|
+
* not cross the wire, and `skillSource` is kept only when it is actually set
|
|
185
|
+
* (the webview reads "has a source tag" off its presence).
|
|
186
|
+
*/
|
|
187
|
+
export function toSlashCommandInfo(command: {
|
|
188
|
+
id: string;
|
|
189
|
+
name: string;
|
|
190
|
+
description: string;
|
|
191
|
+
skillSource?: SlashCommandInfo["skillSource"];
|
|
192
|
+
}): SlashCommandInfo {
|
|
193
|
+
return {
|
|
194
|
+
id: command.id,
|
|
195
|
+
name: command.name,
|
|
196
|
+
description: command.description,
|
|
197
|
+
...(command.skillSource ? { skillSource: command.skillSource } : {}),
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Narrow the `surface` field a caller sent, or `undefined` when absent/unknown. */
|
|
202
|
+
export function parseHostSurface(value: unknown): HostSurface | undefined {
|
|
203
|
+
return value === "desktop" ||
|
|
204
|
+
value === "vscode" ||
|
|
205
|
+
value === "jetbrains" ||
|
|
206
|
+
value === "remote-page"
|
|
207
|
+
? value
|
|
208
|
+
: undefined;
|
|
209
|
+
}
|
|
@@ -12,9 +12,11 @@ import readline from "readline";
|
|
|
12
12
|
import type { Readable, Writable } from "stream";
|
|
13
13
|
import { AgentBridge } from "./agentBridge.js";
|
|
14
14
|
import {
|
|
15
|
+
type ClientNotificationMethod,
|
|
15
16
|
type JsonRpcRequest,
|
|
16
17
|
type JsonRpcResponse,
|
|
17
18
|
type JsonRpcNotification,
|
|
19
|
+
type RequestMethod,
|
|
18
20
|
PARSE_ERROR,
|
|
19
21
|
INVALID_REQUEST,
|
|
20
22
|
INTERNAL_ERROR,
|
|
@@ -123,8 +125,12 @@ export class JsonRpcConnection {
|
|
|
123
125
|
|
|
124
126
|
private async handleRequest(msg: JsonRpcRequest): Promise<void> {
|
|
125
127
|
try {
|
|
128
|
+
// The boundary and the only cast: JSON-RPC `method` is untrusted input.
|
|
129
|
+
// Anything outside the union still reaches the bridge and is answered by
|
|
130
|
+
// its `default:` branch with -32601 — the type stops at this line, and no
|
|
131
|
+
// runtime allow-list is duplicated here.
|
|
126
132
|
const result = await this.bridge.handleRequest(
|
|
127
|
-
msg.method,
|
|
133
|
+
msg.method as RequestMethod,
|
|
128
134
|
msg.params,
|
|
129
135
|
msg.sessionId,
|
|
130
136
|
);
|
|
@@ -141,7 +147,10 @@ export class JsonRpcConnection {
|
|
|
141
147
|
|
|
142
148
|
private handleNotification(msg: JsonRpcNotification): void {
|
|
143
149
|
try {
|
|
144
|
-
this.bridge.handleNotification(
|
|
150
|
+
this.bridge.handleNotification(
|
|
151
|
+
msg.method as ClientNotificationMethod,
|
|
152
|
+
msg.params,
|
|
153
|
+
);
|
|
145
154
|
} catch (err) {
|
|
146
155
|
// Notifications don't get responses, but we log to stderr
|
|
147
156
|
process.stderr.write(
|
package/src/stdio/protocol.ts
CHANGED
|
@@ -44,121 +44,20 @@ export const METHOD_NOT_FOUND = -32601;
|
|
|
44
44
|
export const INVALID_PARAMS = -32602;
|
|
45
45
|
export const INTERNAL_ERROR = -32603;
|
|
46
46
|
|
|
47
|
-
// ──
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
| "clearMessages"
|
|
62
|
-
| "rewindToMessage"
|
|
63
|
-
| "listRewindCheckpoints"
|
|
64
|
-
| "deleteQueuedMessage"
|
|
65
|
-
| "updateQueuedMessage"
|
|
66
|
-
| "deleteQueuedMessageById"
|
|
67
|
-
| "getMessages"
|
|
68
|
-
| "getFullMessageThread"
|
|
69
|
-
| "setPermissionMode"
|
|
70
|
-
| "getPermissionMode"
|
|
71
|
-
| "getPlanFile"
|
|
72
|
-
| "getMcpServers"
|
|
73
|
-
| "connectMcpServer"
|
|
74
|
-
| "disconnectMcpServer"
|
|
75
|
-
| "removeMcpServer"
|
|
76
|
-
| "getMcpConfigPaths"
|
|
77
|
-
| "getSlashCommands"
|
|
78
|
-
| "getSubagentConfigurations"
|
|
79
|
-
| "getSkillMetadata"
|
|
80
|
-
| "deleteSkill"
|
|
81
|
-
| "deleteSubagent"
|
|
82
|
-
| "getHooksByScope"
|
|
83
|
-
| "deleteHook"
|
|
84
|
-
| "searchFiles"
|
|
85
|
-
| "writeArtifactFile"
|
|
86
|
-
| "getPromptHistory"
|
|
87
|
-
| "searchPromptHistory"
|
|
88
|
-
| "updateConfig"
|
|
89
|
-
| "getConfiguredModels"
|
|
90
|
-
| "setModel"
|
|
91
|
-
// User preferences (global — read/write user-level ~/.wave/settings.json,
|
|
92
|
-
// 设置页保存路径,见 docs/specs/core/agent-config.md「设置实时重载」)
|
|
93
|
-
| "getUserSettings"
|
|
94
|
-
| "updateUserSettings"
|
|
95
|
-
// Permissions (daemon attach: re-surface pending approvals after reconnect)
|
|
96
|
-
| "listPendingPermissions"
|
|
97
|
-
// Daemon (global — list in-memory session registry, no session required)
|
|
98
|
-
| "listDaemonSessions"
|
|
99
|
-
// Auth
|
|
100
|
-
| "getAuthStatus"
|
|
101
|
-
| "getAccountInfo"
|
|
102
|
-
| "login"
|
|
103
|
-
| "logout"
|
|
104
|
-
// Memory files (user-level ~/.wave/AGENTS.md and project-level <workdir>/AGENTS.md)
|
|
105
|
-
| "getAgentsContent"
|
|
106
|
-
| "setAgentsContent"
|
|
107
|
-
// Plugins
|
|
108
|
-
| "listPlugins"
|
|
109
|
-
| "installPlugin"
|
|
110
|
-
| "uninstallPlugin"
|
|
111
|
-
| "enablePlugin"
|
|
112
|
-
| "disablePlugin"
|
|
113
|
-
| "updatePlugin"
|
|
114
|
-
| "setPluginScope"
|
|
115
|
-
| "reloadPlugins"
|
|
116
|
-
| "listMarketplaces"
|
|
117
|
-
| "addMarketplace"
|
|
118
|
-
| "removeMarketplace"
|
|
119
|
-
| "updateMarketplace"
|
|
120
|
-
| "compact"
|
|
121
|
-
| "getBackgroundTaskOutput"
|
|
122
|
-
| "stopBackgroundTask"
|
|
123
|
-
| "getWorkflowRuns"
|
|
124
|
-
| "stopWorkflowRun"
|
|
125
|
-
// Git / worktree (global — no session required)
|
|
126
|
-
| "listGitBranches"
|
|
127
|
-
| "createWorktree"
|
|
128
|
-
| "getWorktreeChanges"
|
|
129
|
-
| "removeWorktree";
|
|
130
|
-
|
|
131
|
-
// ── Client → Server notification methods ────────────────────────
|
|
132
|
-
|
|
133
|
-
export type ClientNotificationMethod = "permissionResponse";
|
|
134
|
-
|
|
135
|
-
// ── Server → Client notification methods ────────────────────────
|
|
136
|
-
|
|
137
|
-
export type ServerNotificationMethod =
|
|
138
|
-
| "userMessageAdded"
|
|
139
|
-
| "assistantMessageAdded"
|
|
140
|
-
| "assistantContentUpdated"
|
|
141
|
-
| "assistantReasoningUpdated"
|
|
142
|
-
| "toolBlockUpdated"
|
|
143
|
-
| "errorBlockAdded"
|
|
144
|
-
| "loadingChange"
|
|
145
|
-
| "commandRunningChange"
|
|
146
|
-
| "queuedMessagesChange"
|
|
147
|
-
| "tasksChange"
|
|
148
|
-
| "sessionIdChange"
|
|
149
|
-
| "permissionModeChange"
|
|
150
|
-
| "mcpServersChange"
|
|
151
|
-
| "workdirChange"
|
|
152
|
-
| "notificationMessageAdded"
|
|
153
|
-
| "permissionRequest"
|
|
154
|
-
| "authUrl"
|
|
155
|
-
| "compactBlockAdded"
|
|
156
|
-
| "compactionStateChange"
|
|
157
|
-
| "compactionContentUpdate"
|
|
158
|
-
| "planFileUpdated"
|
|
159
|
-
| "backgroundTasksChange"
|
|
160
|
-
| "btwContent"
|
|
161
|
-
| "contextUsage";
|
|
47
|
+
// ── Method / notification lists ────────────────────────────────
|
|
48
|
+
// Declared once, in the SDK (`src/types/stdioProtocol.ts`), because the RPC
|
|
49
|
+
// client lives there too and both sides of the wire must read the same union.
|
|
50
|
+
// Re-exported here so this package has one module to import from.
|
|
51
|
+
//
|
|
52
|
+
// The compiler — not a source-scanning test — is what keeps them honest: the
|
|
53
|
+
// bridge's `default:` branch is `never`-checked, and `emit` / `RpcClient` are
|
|
54
|
+
// typed with these unions, so a method added on one side without the other does
|
|
55
|
+
// not build.
|
|
56
|
+
export type {
|
|
57
|
+
ClientNotificationMethod,
|
|
58
|
+
RequestMethod,
|
|
59
|
+
ServerNotificationMethod,
|
|
60
|
+
} from "wave-agent-sdk";
|
|
162
61
|
|
|
163
62
|
// ── Helper: is this a request (has id)? ─────────────────────────
|
|
164
63
|
|
package/src/stdio/stdioServer.ts
CHANGED
|
@@ -9,23 +9,47 @@
|
|
|
9
9
|
import type { Readable, Writable } from "stream";
|
|
10
10
|
import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
|
|
11
11
|
import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
12
|
+
import type { RemoteControlSink } from "../remote/remoteControlSink.js";
|
|
12
13
|
|
|
13
14
|
export interface StdioServerOptions {
|
|
14
15
|
input?: Readable;
|
|
15
16
|
output?: Writable;
|
|
16
17
|
bridgeOptions?: AgentBridgeOptions;
|
|
18
|
+
/**
|
|
19
|
+
* Same seam as `DaemonServerOptions.createRemoteSink`, for the other server
|
|
20
|
+
* that owns sessions: the `--stdio` child a GUI host spawns. Unset means
|
|
21
|
+
* remote control is off for this child — the `remoteControl*` RPCs then
|
|
22
|
+
* report exactly that through the bridge's own error.
|
|
23
|
+
*/
|
|
24
|
+
createRemoteSink?: (bridge: AgentBridge) => RemoteControlSink;
|
|
17
25
|
}
|
|
18
26
|
|
|
19
27
|
export class StdioServer {
|
|
20
28
|
private bridge: AgentBridge;
|
|
21
29
|
private conn: JsonRpcConnection;
|
|
30
|
+
private remoteSink: RemoteControlSink | undefined;
|
|
22
31
|
|
|
23
32
|
constructor(options: StdioServerOptions = {}) {
|
|
33
|
+
let remoteSink: RemoteControlSink | undefined;
|
|
24
34
|
this.bridge = new AgentBridge({
|
|
25
35
|
...options.bridgeOptions,
|
|
26
|
-
|
|
27
|
-
|
|
36
|
+
// The sink is handed the bridge it must route phone requests back
|
|
37
|
+
// through, so it can only be built after it; reading it lazily keeps the
|
|
38
|
+
// two from having to be constructed in a circle.
|
|
39
|
+
remoteControl: () => remoteSink,
|
|
40
|
+
emit: (method, params, sessionId) => {
|
|
41
|
+
this.sendNotification(method, params, sessionId);
|
|
42
|
+
// The phone must never be able to take the host's own client down with
|
|
43
|
+
// it, so a remote-path failure is contained here rather than thrown.
|
|
44
|
+
try {
|
|
45
|
+
this.remoteSink?.sendNotification(method, params, sessionId);
|
|
46
|
+
} catch {
|
|
47
|
+
// Dropped frame; the phone re-syncs on reconnect.
|
|
48
|
+
}
|
|
49
|
+
},
|
|
28
50
|
});
|
|
51
|
+
remoteSink = options.createRemoteSink?.(this.bridge);
|
|
52
|
+
this.remoteSink = remoteSink;
|
|
29
53
|
this.conn = new JsonRpcConnection(
|
|
30
54
|
options.input ?? process.stdin,
|
|
31
55
|
options.output ?? process.stdout,
|
|
@@ -43,6 +67,8 @@ export class StdioServer {
|
|
|
43
67
|
}
|
|
44
68
|
|
|
45
69
|
stop(): void {
|
|
70
|
+
this.remoteSink?.stop();
|
|
71
|
+
this.remoteSink = undefined;
|
|
46
72
|
this.conn.stop();
|
|
47
73
|
}
|
|
48
74
|
|
|
@@ -52,9 +78,14 @@ export class StdioServer {
|
|
|
52
78
|
* auto-memory first; a timeout bounds it so a stuck agent can't orphan the
|
|
53
79
|
* exit. This is the real-world "parent died" path: without it the process
|
|
54
80
|
* lingers forever whenever an agent keeps event-loop handles alive.
|
|
81
|
+
*
|
|
82
|
+
* The relay link is dropped explicitly before the exit: leaving the socket to
|
|
83
|
+
* the OS would have the relay hold a subscription for a process that is gone.
|
|
55
84
|
*/
|
|
56
85
|
private async onConnectionClosed(): Promise<void> {
|
|
57
86
|
const SHUTDOWN_TIMEOUT_MS = 5_000;
|
|
87
|
+
this.remoteSink?.stop();
|
|
88
|
+
this.remoteSink = undefined;
|
|
58
89
|
await Promise.race([
|
|
59
90
|
this.bridge.destroyAll(),
|
|
60
91
|
new Promise((resolve) => setTimeout(resolve, SHUTDOWN_TIMEOUT_MS)),
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload trimming for everything this process sends **to a UI**
|
|
3
|
+
* (docs/specs/ui/stdio-transport.md「工具图片的字节不下发 UI」).
|
|
4
|
+
*
|
|
5
|
+
* Tool images arrive as base64 on `ToolBlock.images` (Read on a picture, MCP
|
|
6
|
+
* tools returning image content). No UI renders them — the webview's only read
|
|
7
|
+
* of the field sits in a branch that never runs for a tool block, the CLI shows
|
|
8
|
+
* a count — while one screenshot can be well over a megabyte of base64, and a
|
|
9
|
+
* remote-hosted page cannot even receive a frame that large. So every message
|
|
10
|
+
* leaving this process for a host goes through here: notifications,
|
|
11
|
+
* `getMessages`, `getFullMessageThread`, and the remote relay that is fed by
|
|
12
|
+
* the same frames.
|
|
13
|
+
*
|
|
14
|
+
* **The agent's own copy is untouched.** The SDK still holds the bytes, the
|
|
15
|
+
* next request still sends them to the model, and the transcript on disk keeps
|
|
16
|
+
* them. This is a wire cut, not a memory model: stripping `images` where the
|
|
17
|
+
* SDK writes the block would break the next turn, so do not move it there.
|
|
18
|
+
*
|
|
19
|
+
* Copies are shallow and made only where the field is actually present — the
|
|
20
|
+
* common message (no images) keeps its identity and costs nothing.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
Message,
|
|
25
|
+
ToolBlock,
|
|
26
|
+
ToolBlockUpdateCallbackParams,
|
|
27
|
+
} from "wave-agent-sdk";
|
|
28
|
+
|
|
29
|
+
/** A message whose tool blocks carry no image bytes. */
|
|
30
|
+
export function messageForUi(message: Message): Message {
|
|
31
|
+
const { blocks } = message;
|
|
32
|
+
if (!Array.isArray(blocks)) return message;
|
|
33
|
+
const trimmed = blocks.map((block) =>
|
|
34
|
+
block.type === "tool" && hasImages(block) ? withoutImages(block) : block,
|
|
35
|
+
);
|
|
36
|
+
const changed = trimmed.some((block, index) => block !== blocks[index]);
|
|
37
|
+
return changed ? { ...message, blocks: trimmed } : message;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** `messageForUi` over a page / list, preserving the array when nothing changed. */
|
|
41
|
+
export function messagesForUi(messages: Message[]): Message[] {
|
|
42
|
+
const trimmed = messages.map(messageForUi);
|
|
43
|
+
const changed = trimmed.some((message, index) => message !== messages[index]);
|
|
44
|
+
return changed ? trimmed : messages;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A `toolBlockUpdated` frame whose block carries no image bytes. */
|
|
48
|
+
export function toolBlockUpdateForUi(
|
|
49
|
+
params: ToolBlockUpdateCallbackParams,
|
|
50
|
+
): ToolBlockUpdateCallbackParams {
|
|
51
|
+
if (!params.images || params.images.length === 0) return params;
|
|
52
|
+
const { images, ...rest } = params;
|
|
53
|
+
void images;
|
|
54
|
+
return rest;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function hasImages(block: ToolBlock): boolean {
|
|
58
|
+
return Array.isArray(block.images) && block.images.length > 0;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function withoutImages(block: ToolBlock): ToolBlock {
|
|
62
|
+
const { images, ...rest } = block;
|
|
63
|
+
void images;
|
|
64
|
+
return rest;
|
|
65
|
+
}
|
package/src/stdio-cli.ts
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* the client sends an "initialize" request.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { ensureRuntimeDeps } from "wave-agent-sdk";
|
|
9
|
+
import { ensureRuntimeDeps, setTelemetryStdoutProtected } from "wave-agent-sdk";
|
|
10
10
|
import { StdioServer } from "./stdio/stdioServer.js";
|
|
11
|
+
import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
|
|
11
12
|
import { logger } from "./utils/logger.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -55,6 +56,10 @@ export function guardStdoutForJsonRpc(): void {
|
|
|
55
56
|
|
|
56
57
|
export async function startStdioCli(): Promise<void> {
|
|
57
58
|
guardStdoutForJsonRpc();
|
|
59
|
+
// stdout is the JSON-RPC channel here: console telemetry exporters would
|
|
60
|
+
// print onto it (OTel's console exporters use console.dir, which
|
|
61
|
+
// guardStdoutForJsonRpc does not cover) — strip them at config resolution.
|
|
62
|
+
setTelemetryStdoutProtected(true);
|
|
58
63
|
// Install the on-demand runtime dependencies (image codec + ripgrep) before
|
|
59
64
|
// answering any RPC (see cli.tsx). Blocking here is what closes the race: the
|
|
60
65
|
// host's `initialize` — and with it the webview's initial state — only lands
|
|
@@ -69,7 +74,11 @@ export async function startStdioCli(): Promise<void> {
|
|
|
69
74
|
crashHandler("unhandledRejection", reason),
|
|
70
75
|
);
|
|
71
76
|
|
|
72
|
-
const server = new StdioServer(
|
|
77
|
+
const server = new StdioServer({
|
|
78
|
+
// This child owns the sessions its host shows, so it — not a daemon on the
|
|
79
|
+
// same machine — is what serves a paired phone.
|
|
80
|
+
createRemoteSink: createRemoteControlSinkFactory(process.cwd()),
|
|
81
|
+
});
|
|
73
82
|
server.start();
|
|
74
83
|
|
|
75
84
|
// Keep the process alive — readline on stdin handles the event loop.
|
package/src/types.ts
CHANGED
|
@@ -18,4 +18,11 @@ export interface BaseAppProps {
|
|
|
18
18
|
model?: string;
|
|
19
19
|
/** Optional MCP server configs to pass to Agent.create() */
|
|
20
20
|
mcpServers?: Record<string, McpServerConfig>;
|
|
21
|
+
/**
|
|
22
|
+
* Erases what ink has drawn without touching the terminal scrollback (ink's
|
|
23
|
+
* `instance.clear()`, injected by the CLI entry where the ink instance lives).
|
|
24
|
+
* Actions that must leave the previous conversation scrollable above — `/clear`
|
|
25
|
+
* — use it instead of a hard erase.
|
|
26
|
+
*/
|
|
27
|
+
onClearOutput?: () => void;
|
|
21
28
|
}
|