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,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where remote control dials out to, who this machine is on that channel, and
|
|
3
|
+
* how it gets itself registered there.
|
|
4
|
+
*
|
|
5
|
+
* Three things have to be resolved before a process can subscribe:
|
|
6
|
+
*
|
|
7
|
+
* - **The relay URL.** Derived from the same server URL the rest of the CLI
|
|
8
|
+
* uses for SSO (`authService.getServerUrl()`), so a private deployment that
|
|
9
|
+
* sets `WAVE_SERVER_URL` gets its own relay without a second knob. A relay
|
|
10
|
+
* that lives elsewhere is reachable through `WAVE_REMOTE_CONTROL_URL`, which
|
|
11
|
+
* is the **whole** subscribe URL (host id included), not an origin.
|
|
12
|
+
* - **This machine's identity.** The channel is keyed by the machine
|
|
13
|
+
* (`/v1/hosts/ws/{hostId}/subscribe`) and the id is **derived here** from the
|
|
14
|
+
* OS machine id plus the signed-in account (`hostIdentity.ts`), then sent along
|
|
15
|
+
* with the registration: the relay reuses the row it already holds for that id,
|
|
16
|
+
* or creates one with it. That is what keeps one machine to one row — and one
|
|
17
|
+
* page URL — when a state file is lost.
|
|
18
|
+
* - **The page a phone opens.** `<server>/remote/?host={hostId}`, assembled here
|
|
19
|
+
* because the process holding the channel key is the only one that knows both
|
|
20
|
+
* halves of it — and it is what the QR code encodes.
|
|
21
|
+
*
|
|
22
|
+
* Registration happens **once per machine**. The id and its token are long-lived
|
|
23
|
+
* (90 days, revocable) and the pair is kept in `~/.wave/remote-control.json`,
|
|
24
|
+
* read on every boot and written only when a machine registers. A stored
|
|
25
|
+
* identity always wins over the derivation — it is reused as-is and never
|
|
26
|
+
* re-derived, so no machine moves to a new row when the derivation changes.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as fs from "node:fs";
|
|
30
|
+
import * as os from "node:os";
|
|
31
|
+
import * as path from "node:path";
|
|
32
|
+
import { authService, createAuthAwareFetch } from "wave-agent-sdk";
|
|
33
|
+
import { deriveHostId } from "./hostIdentity.js";
|
|
34
|
+
import { readNearestPackageJson } from "../utils/readPackageJson.js";
|
|
35
|
+
|
|
36
|
+
/** Everything the relay connection needs, resolved fresh at connect time. */
|
|
37
|
+
export interface RemoteControlEndpoint {
|
|
38
|
+
url: string;
|
|
39
|
+
/** The relay-assigned channel id (also the path segment of `url`). */
|
|
40
|
+
hostId: string;
|
|
41
|
+
/** Long-lived host credential. Never logged, never shown. */
|
|
42
|
+
hostToken: string;
|
|
43
|
+
version: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface RelayHostRegistration {
|
|
47
|
+
hostId: string;
|
|
48
|
+
hostToken: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Registration is a one-shot call on a very slow path; it may not hang the daemon. */
|
|
52
|
+
const REGISTER_TIMEOUT_MS = 10_000;
|
|
53
|
+
|
|
54
|
+
const NOT_SIGNED_IN =
|
|
55
|
+
"Not signed in: remote control registers this machine with the relay and needs an SSO credential to do it (run /login).";
|
|
56
|
+
|
|
57
|
+
export function getRemoteControlStatePath(): string {
|
|
58
|
+
return path.join(os.homedir(), ".wave", "remote-control.json");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface RemoteControlStateFile {
|
|
62
|
+
/** Both assigned by the relay; both needed before anything can connect. */
|
|
63
|
+
hostId?: string;
|
|
64
|
+
hostToken?: string;
|
|
65
|
+
/** The label registration sent, so a later `on` does not silently rename. */
|
|
66
|
+
name?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readState(): RemoteControlStateFile {
|
|
70
|
+
try {
|
|
71
|
+
const raw = fs.readFileSync(getRemoteControlStatePath(), "utf8");
|
|
72
|
+
const parsed: unknown = JSON.parse(raw);
|
|
73
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
74
|
+
return parsed as RemoteControlStateFile;
|
|
75
|
+
}
|
|
76
|
+
} catch {
|
|
77
|
+
// Missing or unreadable reads as "not registered yet". That is also what a
|
|
78
|
+
// half-written file must look like: registering again is recoverable (it
|
|
79
|
+
// leaves an orphan row), crashing a daemon that is already serving is not.
|
|
80
|
+
}
|
|
81
|
+
return {};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function writeState(state: RemoteControlStateFile): void {
|
|
85
|
+
const filePath = getRemoteControlStatePath();
|
|
86
|
+
try {
|
|
87
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
88
|
+
// Write-then-rename: a daemon killed mid-write must not leave a truncated
|
|
89
|
+
// file that reads back as "not registered" and registers a second machine.
|
|
90
|
+
const tmp = `${filePath}.${process.pid}.tmp`;
|
|
91
|
+
fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, {
|
|
92
|
+
encoding: "utf8",
|
|
93
|
+
mode: 0o600,
|
|
94
|
+
});
|
|
95
|
+
fs.renameSync(tmp, filePath);
|
|
96
|
+
} catch {
|
|
97
|
+
// A read-only home is not fatal — the machine just registers again next
|
|
98
|
+
// time, which the relay tolerates (it only costs an extra row).
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* RPC base for the relay's REST surface (`/api/relay/hosts`, …): the same
|
|
104
|
+
* deployment that serves the WebSocket channel.
|
|
105
|
+
*
|
|
106
|
+
* The subscribe-URL override wins when set, because a relay reached through it
|
|
107
|
+
* is by definition not the one `getServerUrl()` points at — and registration has
|
|
108
|
+
* to land on the relay the machine will actually connect to.
|
|
109
|
+
*/
|
|
110
|
+
export function getRelayApiBaseUrl(): string {
|
|
111
|
+
const override = process.env.WAVE_REMOTE_CONTROL_URL;
|
|
112
|
+
if (override) {
|
|
113
|
+
try {
|
|
114
|
+
const url = new URL(override);
|
|
115
|
+
url.protocol = url.protocol === "wss:" ? "https:" : "http:";
|
|
116
|
+
return url.origin;
|
|
117
|
+
} catch {
|
|
118
|
+
// Not a URL at all: fall back to the configured server.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return authService.getServerUrl();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface RegisterResponseBody {
|
|
125
|
+
host?: { id?: unknown; name?: unknown; platform?: unknown; status?: unknown };
|
|
126
|
+
hostToken?: unknown;
|
|
127
|
+
error?: unknown;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function asString(value: unknown): string | undefined {
|
|
131
|
+
return typeof value === "string" && value.length > 0 ? value : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* The relay's refusals are not interchangeable: "this account has no enterprise
|
|
136
|
+
* context" and "this account is not an active member" are the same status code
|
|
137
|
+
* and need different things done about them, so the two are kept apart here
|
|
138
|
+
* rather than collapsed into "403".
|
|
139
|
+
*/
|
|
140
|
+
export function describeRegistrationFailure(
|
|
141
|
+
status: number,
|
|
142
|
+
error: string | undefined,
|
|
143
|
+
): string {
|
|
144
|
+
if (error === "Enterprise access required") {
|
|
145
|
+
return "this account has no enterprise context, and remote control is enterprise-only (ops administrators and personal accounts are not eligible).";
|
|
146
|
+
}
|
|
147
|
+
if (error === "You must be an active enterprise member") {
|
|
148
|
+
return "this account is not an active member of the enterprise that runs the relay — ask an administrator to activate the membership, then try again.";
|
|
149
|
+
}
|
|
150
|
+
if (status === 401) {
|
|
151
|
+
return "the sign-in is missing or expired: registering this machine needs a current enterprise member credential (run /login).";
|
|
152
|
+
}
|
|
153
|
+
if (error === "invalid_name" || error === "invalid_platform") {
|
|
154
|
+
return "the relay rejected this machine's name or platform (the name is limited to 100 characters).";
|
|
155
|
+
}
|
|
156
|
+
if (status === 503) {
|
|
157
|
+
return "this deployment has remote control switched off.";
|
|
158
|
+
}
|
|
159
|
+
return `the relay refused to register this machine (${status}${error ? `: ${error}` : ""}).`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Register this machine with the relay and take the identity it hands back.
|
|
164
|
+
*
|
|
165
|
+
* Called through `createAuthAwareFetch` so the credential is the current SSO
|
|
166
|
+
* token — refreshed if it is about to expire, which matters because a daemon can
|
|
167
|
+
* sit idle for hours before someone turns remote control on.
|
|
168
|
+
*/
|
|
169
|
+
export async function registerRelayHost(options: {
|
|
170
|
+
name: string;
|
|
171
|
+
platform?: string;
|
|
172
|
+
/**
|
|
173
|
+
* The id derived from this machine's identity (`deriveHostId`). The relay reuses
|
|
174
|
+
* the row it already has for that id and mints one only when it has none — so a
|
|
175
|
+
* machine that lost its state file comes back as itself instead of leaving a
|
|
176
|
+
* second row behind. Omitted when the identity cannot be derived.
|
|
177
|
+
*/
|
|
178
|
+
id?: string;
|
|
179
|
+
}): Promise<RelayHostRegistration> {
|
|
180
|
+
if (!authService.getSSOToken()) throw new Error(NOT_SIGNED_IN);
|
|
181
|
+
|
|
182
|
+
let response: Response;
|
|
183
|
+
try {
|
|
184
|
+
response = await createAuthAwareFetch(globalThis.fetch)(
|
|
185
|
+
`${getRelayApiBaseUrl()}/api/relay/hosts`,
|
|
186
|
+
{
|
|
187
|
+
method: "POST",
|
|
188
|
+
headers: { "Content-Type": "application/json" },
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
name: options.name,
|
|
191
|
+
platform: options.platform ?? process.platform,
|
|
192
|
+
...(options.id ? { id: options.id } : {}),
|
|
193
|
+
}),
|
|
194
|
+
signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS),
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
throw new Error(
|
|
199
|
+
`cannot reach the relay to register this machine: ${(err as Error).message}`,
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const body = (await response.json().catch(() => undefined)) as
|
|
204
|
+
| RegisterResponseBody
|
|
205
|
+
| undefined;
|
|
206
|
+
if (!response.ok) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
describeRegistrationFailure(response.status, asString(body?.error)),
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const hostId = asString(body?.host?.id);
|
|
213
|
+
const hostToken = asString(body?.hostToken);
|
|
214
|
+
if (!hostId || !hostToken) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
"the relay accepted the registration but returned no host id / token — this machine cannot be trusted to reach it again.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { hostId, hostToken };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* This machine's relay identity: the stored one when there is one, otherwise a
|
|
225
|
+
* fresh registration. Never registers twice for the same machine — and, with the
|
|
226
|
+
* derived id, no longer registers a second row for it either when the state file
|
|
227
|
+
* went missing.
|
|
228
|
+
*
|
|
229
|
+
* `refresh` is the one exception, and it has one caller: the relay answered
|
|
230
|
+
* `4003` with a stale-identity reason, so the stored pair is known to be dead
|
|
231
|
+
* (revoked row / expired token / a file whose id and token never belonged
|
|
232
|
+
* together). Dropping it *before* registering is deliberate — if the
|
|
233
|
+
* registration then fails (an expired sign-in is the common case), the file must
|
|
234
|
+
* not keep pointing at an identity the relay has already refused, or the next
|
|
235
|
+
* attempt would hand the same dead token over and the user would be back to
|
|
236
|
+
* moving that file by hand.
|
|
237
|
+
*/
|
|
238
|
+
export async function ensureRelayHostRegistration(
|
|
239
|
+
name?: string,
|
|
240
|
+
options: { refresh?: boolean } = {},
|
|
241
|
+
): Promise<RelayHostRegistration> {
|
|
242
|
+
const state = readState();
|
|
243
|
+
const storedName = asString(state.name);
|
|
244
|
+
if (!options.refresh) {
|
|
245
|
+
const storedId = asString(state.hostId);
|
|
246
|
+
const storedToken = asString(state.hostToken);
|
|
247
|
+
if (storedId && storedToken) {
|
|
248
|
+
return { hostId: storedId, hostToken: storedToken };
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
writeState(storedName ? { name: storedName } : {});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const label = name?.trim() || storedName || os.hostname();
|
|
255
|
+
const registration = await registerRelayHost({
|
|
256
|
+
name: label,
|
|
257
|
+
id: deriveHostId(),
|
|
258
|
+
});
|
|
259
|
+
writeState({
|
|
260
|
+
hostId: registration.hostId,
|
|
261
|
+
hostToken: registration.hostToken,
|
|
262
|
+
name: label,
|
|
263
|
+
});
|
|
264
|
+
return registration;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* `https://host` → `wss://host`, `http://host` → `ws://host`. An explicit
|
|
269
|
+
* scheme in `WAVE_REMOTE_CONTROL_URL` is used as given.
|
|
270
|
+
*/
|
|
271
|
+
export function buildSubscribeUrl(serverUrl: string, hostId: string): string {
|
|
272
|
+
const parsed = new URL(serverUrl);
|
|
273
|
+
parsed.protocol = parsed.protocol === "http:" ? "ws:" : "wss:";
|
|
274
|
+
parsed.pathname = `/v1/hosts/ws/${encodeURIComponent(hostId)}/subscribe`;
|
|
275
|
+
parsed.search = "";
|
|
276
|
+
parsed.hash = "";
|
|
277
|
+
return parsed.toString();
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* The page a phone should open for this machine's channel.
|
|
282
|
+
*
|
|
283
|
+
* It is derived from the server URL the page is served from — the enterprise
|
|
284
|
+
* deployment hosts it, next to the SSO login it reuses, so the host matches
|
|
285
|
+
* `WAVE_SERVER_URL` by construction and the page reads the signed-in token from
|
|
286
|
+
* its own origin. The channel id is the only thing that has to come from here,
|
|
287
|
+
* and it is not a secret: the relay checks on handshake that the channel belongs
|
|
288
|
+
* to the signed-in account and that the account is an active member of the
|
|
289
|
+
* enterprise. Undefined when that URL cannot be parsed (the channel id is still
|
|
290
|
+
* printable; only the QR needs a URL).
|
|
291
|
+
*/
|
|
292
|
+
export function buildPageUrl(hostId: string): string | undefined {
|
|
293
|
+
try {
|
|
294
|
+
const url = new URL("/remote/", authService.getServerUrl());
|
|
295
|
+
url.searchParams.set("host", hostId);
|
|
296
|
+
return url.toString();
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Resolve the connection target, or say why there isn't one. Called at connect
|
|
304
|
+
* time rather than at daemon boot: a resident daemon outlives the SSO token it
|
|
305
|
+
* was started with, and the user may turn remote control on hours later — and,
|
|
306
|
+
* on a machine that has never paired, this is also where the one registration
|
|
307
|
+
* call happens.
|
|
308
|
+
*/
|
|
309
|
+
export async function resolveRemoteControlEndpoint(
|
|
310
|
+
options: {
|
|
311
|
+
name?: string;
|
|
312
|
+
/** Register again instead of trusting the stored pair (see above). */
|
|
313
|
+
refreshIdentity?: boolean;
|
|
314
|
+
} = {},
|
|
315
|
+
): Promise<{
|
|
316
|
+
endpoint?: RemoteControlEndpoint;
|
|
317
|
+
pageUrl?: string;
|
|
318
|
+
error?: string;
|
|
319
|
+
}> {
|
|
320
|
+
let registration: RelayHostRegistration;
|
|
321
|
+
try {
|
|
322
|
+
registration = await ensureRelayHostRegistration(options.name, {
|
|
323
|
+
refresh: options.refreshIdentity,
|
|
324
|
+
});
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return { error: err instanceof Error ? err.message : String(err) };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const override = process.env.WAVE_REMOTE_CONTROL_URL;
|
|
330
|
+
let url: string;
|
|
331
|
+
if (override) {
|
|
332
|
+
url = override;
|
|
333
|
+
} else {
|
|
334
|
+
try {
|
|
335
|
+
url = buildSubscribeUrl(authService.getServerUrl(), registration.hostId);
|
|
336
|
+
} catch (err) {
|
|
337
|
+
return {
|
|
338
|
+
error: `Cannot build the relay URL from the server URL: ${(err as Error).message}`,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const pageUrl = buildPageUrl(registration.hostId);
|
|
344
|
+
return {
|
|
345
|
+
endpoint: {
|
|
346
|
+
url,
|
|
347
|
+
hostId: registration.hostId,
|
|
348
|
+
hostToken: registration.hostToken,
|
|
349
|
+
version: readNearestPackageJson().version,
|
|
350
|
+
},
|
|
351
|
+
...(pageUrl ? { pageUrl } : {}),
|
|
352
|
+
};
|
|
353
|
+
}
|