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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The seam between a session holder and the outbound relay that serves a
|
|
3
|
+
* paired phone.
|
|
4
|
+
*
|
|
5
|
+
* Every process that holds sessions needs it, for the same reason and with the
|
|
6
|
+
* same shape:
|
|
7
|
+
*
|
|
8
|
+
* - `--daemon <socket>` — the resident process on a remote host;
|
|
9
|
+
* - `--stdio` — the child each GUI host (desktop / VS Code / JetBrains) spawns
|
|
10
|
+
* and drives over JSON-RPC;
|
|
11
|
+
* - the interactive CLI itself — the session the user is sitting in front of,
|
|
12
|
+
* which lives in that process's React state and nowhere else.
|
|
13
|
+
*
|
|
14
|
+
* Whichever one holds the sessions has to be the one serving them: a phone
|
|
15
|
+
* pointed at another process would show a different session set than the user
|
|
16
|
+
* is looking at. So the sink is built where the sessions are, never shared
|
|
17
|
+
* across processes — and, for the CLI, that means built on a `RemoteControlHost`
|
|
18
|
+
* the front end implements rather than on an `AgentBridge` (which the CLI has
|
|
19
|
+
* none of).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { isRemoteControlEnabled } from "wave-agent-sdk";
|
|
23
|
+
import { resolveRemoteControlEndpoint } from "./remoteControlConfig.js";
|
|
24
|
+
import {
|
|
25
|
+
RemoteControlService,
|
|
26
|
+
type RemoteControlController,
|
|
27
|
+
type RemoteControlHost,
|
|
28
|
+
} from "./remoteControlService.js";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A second fan-out target for a server's notifications, beside its local
|
|
32
|
+
* clients (unix-socket clients for the daemon, the host's pipe for `--stdio`).
|
|
33
|
+
*
|
|
34
|
+
* Its lifecycle is deliberately decoupled from the client set: the daemon is
|
|
35
|
+
* resident and a paired phone keeps watching while no desktop is attached —
|
|
36
|
+
* that is the point of serving from the daemon rather than from a GUI host.
|
|
37
|
+
*/
|
|
38
|
+
export interface RemoteNotificationSink {
|
|
39
|
+
/** Same signature as `JsonRpcConnection.sendNotification`. */
|
|
40
|
+
sendNotification(method: string, params: unknown, sessionId?: string): void;
|
|
41
|
+
stop(): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The two halves of a remote-control sink: the server fans notifications into
|
|
46
|
+
* it, and the bridge routes the three `remoteControl*` RPCs to it.
|
|
47
|
+
*/
|
|
48
|
+
export interface RemoteControlSink
|
|
49
|
+
extends RemoteNotificationSink,
|
|
50
|
+
RemoteControlController {}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build the sink factory a session holder takes, or `undefined` when remote
|
|
54
|
+
* control is switched off for this process.
|
|
55
|
+
*
|
|
56
|
+
* The switch is read **once**, here, at process start: a machine that was
|
|
57
|
+
* already running keeps its old answer, which is why the CLI hint says to
|
|
58
|
+
* restart (the daemon, or the TUI itself) after changing it. `workdir` decides
|
|
59
|
+
* which settings files are merged — the user-level `~/.wave/settings.json` is
|
|
60
|
+
* always consulted, the workdir's own `.wave/settings.json` only when the
|
|
61
|
+
* process was started there (a GUI host's `--stdio` child inherits the app's
|
|
62
|
+
* cwd, not the project's, so user-level is the reliable place to set it).
|
|
63
|
+
*/
|
|
64
|
+
export function createRemoteControlSinkFactory(
|
|
65
|
+
workdir: string,
|
|
66
|
+
): ((host: RemoteControlHost) => RemoteControlSink) | undefined {
|
|
67
|
+
// Two-step by design: this switch (on unless a settings file or a remote
|
|
68
|
+
// push turns it off) says the machine *may* serve its sessions to a paired
|
|
69
|
+
// phone, and `wave remote-control on` / `/remote-control` is what actually
|
|
70
|
+
// dials out. The sink is therefore built but not connected.
|
|
71
|
+
if (!isRemoteControlEnabled(workdir)) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
return (host: RemoteControlHost): RemoteControlSink =>
|
|
75
|
+
new RemoteControlService(host, {
|
|
76
|
+
resolveEndpoint: resolveRemoteControlEndpoint,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single table of notification **shapes**, shared by the two serving paths.
|
|
3
|
+
*
|
|
4
|
+
* `AgentBridge` (a daemon or a GUI-spawned `--stdio` child) puts the agent's
|
|
5
|
+
* callbacks straight onto the wire; the foreground TUI has no notification
|
|
6
|
+
* fan-out at all (`useRemoteControl` supplies it from its own React state). They
|
|
7
|
+
* are two different hosts — one has a session registry, the other a component
|
|
8
|
+
* tree — so each still decides *which* callbacks it emits. What must not exist in
|
|
9
|
+
* two copies is the **contract**: a method name and the fields of its payload.
|
|
10
|
+
* Both hosts build every frame they share from `remoteWire` below, so a payload
|
|
11
|
+
* change lands once and the phone cannot tell which process served it (spec:
|
|
12
|
+
* remote-control「前台 CLI 会话也能被手机接管」场景 10/11).
|
|
13
|
+
*
|
|
14
|
+
* What is deliberately *not* here: the families only a bridge emits (session
|
|
15
|
+
* registry reads, `tasksChange`, `queuedMessagesChange`, `commandRunningChange`,
|
|
16
|
+
* the compaction / plan / mcp changes). Nothing mirrors those, so there is no
|
|
17
|
+
* shape to keep in sync — and the foreground host is not required to invent one:
|
|
18
|
+
* the page reads that state off `getSessionInfo` on its alignment paths (spec
|
|
19
|
+
* 同故事场景 11).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
AgentCallbacks,
|
|
24
|
+
BackgroundTask,
|
|
25
|
+
BackgroundTaskSummary,
|
|
26
|
+
PermissionMode,
|
|
27
|
+
ToolBlockUpdateCallbackParams,
|
|
28
|
+
ToolPermissionContext,
|
|
29
|
+
} from "wave-agent-sdk";
|
|
30
|
+
import { toolBlockUpdateForUi } from "../stdio/uiPayload.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A content delta exactly as the SDK callback hands it over — derived rather
|
|
34
|
+
* than re-declared, so a field added there cannot leave this table describing a
|
|
35
|
+
* shape the agent does not produce.
|
|
36
|
+
*/
|
|
37
|
+
type StreamDelta = Parameters<
|
|
38
|
+
NonNullable<AgentCallbacks["onAssistantContentUpdated"]>
|
|
39
|
+
>[0];
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Context-window usage as the percentage the ring renders — one rule for every
|
|
43
|
+
* producer (the bridge's change push and its `getMessages` read, the CLI host's
|
|
44
|
+
* push and its `getSessionInfo` read). 0 when the cap is unknown, so a caller
|
|
45
|
+
* that has one reads a number rather than a special case.
|
|
46
|
+
*/
|
|
47
|
+
export function percentOf(tokens: number, maxInputTokens: number): number {
|
|
48
|
+
if (!maxInputTokens || maxInputTokens <= 0) return 0;
|
|
49
|
+
return Math.min(100, Math.round((tokens / maxInputTokens) * 100));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The shapes both hosts put on the wire, keyed by the method they ride under. */
|
|
53
|
+
export const remoteWire = {
|
|
54
|
+
/**
|
|
55
|
+
* A pure-delta frame carries the delta and nothing else — no accumulated value,
|
|
56
|
+
* so a notification's size is independent of how long the stream has been
|
|
57
|
+
* running (spec: stdio-transport「流式通知纯增量负载」). Explicit pick rather than
|
|
58
|
+
* a spread, so a field added to the SDK callback does not silently join the wire.
|
|
59
|
+
*/
|
|
60
|
+
assistantContentUpdated: (params: StreamDelta) => ({
|
|
61
|
+
messageId: params.messageId,
|
|
62
|
+
chunk: params.chunk,
|
|
63
|
+
stage: params.stage,
|
|
64
|
+
}),
|
|
65
|
+
|
|
66
|
+
assistantReasoningUpdated: (params: StreamDelta) => ({
|
|
67
|
+
messageId: params.messageId,
|
|
68
|
+
chunk: params.chunk,
|
|
69
|
+
stage: params.stage,
|
|
70
|
+
}),
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* `streaming` frames carry only the delta (`parametersChunk`), while `start` /
|
|
74
|
+
* `running` (one-time snapshots) and `end` (the authoritative full value) keep
|
|
75
|
+
* `parameters`. The coalescer's merge rule is keyed on exactly this shape, so
|
|
76
|
+
* this pick is what makes the 150 ms window behave the same in both hosts.
|
|
77
|
+
*
|
|
78
|
+
* `images` is cut on every stage: tool pictures are base64 that no UI renders
|
|
79
|
+
* (specs/ui/stdio-transport.md「工具图片的字节不下发 UI」), and one of them can
|
|
80
|
+
* be larger than a remote frame may be.
|
|
81
|
+
*/
|
|
82
|
+
toolBlockUpdated: (params: ToolBlockUpdateCallbackParams) => {
|
|
83
|
+
const trimmed = toolBlockUpdateForUi(params);
|
|
84
|
+
if (trimmed.stage !== "streaming") return trimmed;
|
|
85
|
+
const { parameters, ...rest } = trimmed;
|
|
86
|
+
void parameters;
|
|
87
|
+
return rest;
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
errorBlockAdded: (error: string) => ({ error }),
|
|
91
|
+
|
|
92
|
+
loadingChange: (loading: boolean, latestTotalTokens?: number) => ({
|
|
93
|
+
loading,
|
|
94
|
+
...(latestTotalTokens === undefined ? {} : { latestTotalTokens }),
|
|
95
|
+
}),
|
|
96
|
+
|
|
97
|
+
contextUsage: (latestTotalTokens: number, maxInputTokens: number) => ({
|
|
98
|
+
percent: percentOf(latestTotalTokens, maxInputTokens),
|
|
99
|
+
}),
|
|
100
|
+
|
|
101
|
+
permissionModeChange: (mode: PermissionMode) => ({ mode }),
|
|
102
|
+
|
|
103
|
+
permissionRequest: (requestId: string, context: ToolPermissionContext) => ({
|
|
104
|
+
requestId,
|
|
105
|
+
context,
|
|
106
|
+
}),
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* `messagesChange` has exactly one shape now — content-free, on **every** path
|
|
110
|
+
* (spec「messagesChange 只有一种形态」): the bridge's re-attach push, the
|
|
111
|
+
* foreground CLI's structural notice and the relay's fan-out all carry `{}`.
|
|
112
|
+
* The phone answers it with `getMessages({ since })`; no producer attaches the
|
|
113
|
+
* list.
|
|
114
|
+
*/
|
|
115
|
+
messagesChangeSignal: () => ({}),
|
|
116
|
+
} as const;
|
|
117
|
+
|
|
118
|
+
/** The methods this table defines — the only ones a mirroring host may send. */
|
|
119
|
+
export type RemoteMethod = keyof typeof remoteWire;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A background task (shell / subagent / workflow) as it may travel: the
|
|
123
|
+
* serializable half of `BackgroundTask`.
|
|
124
|
+
*
|
|
125
|
+
* A shape, hence this table's business — the two hosts each own the *list* (the
|
|
126
|
+
* bridge reads it off the agent on every snapshot, the foreground TUI off its own
|
|
127
|
+
* state) and both have to describe a task the same way, or the `/tasks` dialog
|
|
128
|
+
* renders two different things depending on which process served the page. The
|
|
129
|
+
* fields dropped here are the reasons it exists: `process` (a `ChildProcess`) and
|
|
130
|
+
* `onStop` (a function) cannot go over a relay, and the full `stdout`/`stderr` are
|
|
131
|
+
* fetched per task instead (`getBackgroundTaskOutput`).
|
|
132
|
+
*/
|
|
133
|
+
export function toBackgroundTaskSummaries(
|
|
134
|
+
tasks: BackgroundTask[],
|
|
135
|
+
): BackgroundTaskSummary[] {
|
|
136
|
+
return tasks.map((task) => ({
|
|
137
|
+
id: task.id,
|
|
138
|
+
type: task.type,
|
|
139
|
+
status: task.status,
|
|
140
|
+
startTime: task.startTime,
|
|
141
|
+
endTime: task.endTime,
|
|
142
|
+
command: task.command,
|
|
143
|
+
description: task.description,
|
|
144
|
+
exitCode: task.exitCode,
|
|
145
|
+
runtime: task.runtime,
|
|
146
|
+
outputPath: task.outputPath,
|
|
147
|
+
}));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* What `RemoteControlSink.sendNotification` expects of a frame, minus the
|
|
152
|
+
* session it belongs to (the sink already knows). The method is narrowed to the
|
|
153
|
+
* table's keys so a caller cannot mirror a family that has no agreed shape.
|
|
154
|
+
*/
|
|
155
|
+
export type RemoteNotify = (method: RemoteMethod, params: unknown) => void;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The callbacks whose **payload** a foreground host has to mirror, mapped onto
|
|
159
|
+
* the remote path under the bridge's method names.
|
|
160
|
+
*
|
|
161
|
+
* One rule decides membership: **can a pull get the change back?** A streaming
|
|
162
|
+
* delta grows a reply the phone is already holding, and an error block is
|
|
163
|
+
* appended *inside* the last message — in both cases the `since` anchor is that
|
|
164
|
+
* very message, so a signal would be a round trip that returns nothing and the
|
|
165
|
+
* change would never reach the phone (spec remote-control「前台 CLI 会话也能被手机
|
|
166
|
+
* 接管」场景 10/11). Structural changes (the list's id composition) and the state
|
|
167
|
+
* the page reads off `getSessionInfo` travel on the hook's own signals instead.
|
|
168
|
+
*/
|
|
169
|
+
export function remoteNotifications(
|
|
170
|
+
notify: RemoteNotify,
|
|
171
|
+
): Required<
|
|
172
|
+
Pick<
|
|
173
|
+
AgentCallbacks,
|
|
174
|
+
| "onAssistantContentUpdated"
|
|
175
|
+
| "onAssistantReasoningUpdated"
|
|
176
|
+
| "onToolBlockUpdated"
|
|
177
|
+
| "onErrorBlockAdded"
|
|
178
|
+
>
|
|
179
|
+
> {
|
|
180
|
+
return {
|
|
181
|
+
onAssistantContentUpdated: (params) => {
|
|
182
|
+
notify(
|
|
183
|
+
"assistantContentUpdated",
|
|
184
|
+
remoteWire.assistantContentUpdated(params),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
onAssistantReasoningUpdated: (params) => {
|
|
188
|
+
notify(
|
|
189
|
+
"assistantReasoningUpdated",
|
|
190
|
+
remoteWire.assistantReasoningUpdated(params),
|
|
191
|
+
);
|
|
192
|
+
},
|
|
193
|
+
onToolBlockUpdated: (params) => {
|
|
194
|
+
notify("toolBlockUpdated", remoteWire.toolBlockUpdated(params));
|
|
195
|
+
},
|
|
196
|
+
onErrorBlockAdded: (error: string) => {
|
|
197
|
+
notify("errorBlockAdded", remoteWire.errorBlockAdded(error));
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
}
|