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.
Files changed (63) hide show
  1. package/dist/bundle/wave.mjs +1382 -1131
  2. package/package.json +9 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/commands/update.ts +62 -22
  5. package/src/components/AgentsManager.tsx +3 -3
  6. package/src/components/App.tsx +5 -0
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/BtwDisplay.tsx +71 -15
  9. package/src/components/CompactDisplay.tsx +1 -6
  10. package/src/components/ConfirmationDetails.tsx +34 -13
  11. package/src/components/ConfirmationSelector.tsx +31 -36
  12. package/src/components/DiffDisplay.tsx +1 -1
  13. package/src/components/HooksManager.tsx +3 -3
  14. package/src/components/InputBox.tsx +37 -11
  15. package/src/components/LoginCommand.tsx +16 -35
  16. package/src/components/Markdown.tsx +7 -3
  17. package/src/components/MarketplaceAddForm.tsx +15 -19
  18. package/src/components/MessageBlockItem.tsx +0 -6
  19. package/src/components/RemoteControlView.tsx +175 -0
  20. package/src/components/ScrolledContent.tsx +15 -13
  21. package/src/components/SkillsManager.tsx +3 -3
  22. package/src/components/TaskList.tsx +3 -3
  23. package/src/components/ToolDisplay.tsx +3 -18
  24. package/src/components/WorkflowManager.tsx +150 -136
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +6 -0
  27. package/src/contexts/useChat.tsx +308 -154
  28. package/src/daemon/commands.ts +31 -22
  29. package/src/daemon-cli.ts +6 -1
  30. package/src/hooks/useInputManager.ts +25 -39
  31. package/src/hooks/useLineScroll.ts +51 -5
  32. package/src/hooks/useRemoteControl.ts +346 -0
  33. package/src/index.ts +35 -0
  34. package/src/managers/inputHandlers.ts +5 -0
  35. package/src/managers/inputReducer.ts +39 -0
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/workflowManagerReducer.ts +21 -2
  38. package/src/remote/frameBudget.ts +45 -0
  39. package/src/remote/hostIdentity.ts +128 -0
  40. package/src/remote/messageWindow.ts +46 -0
  41. package/src/remote/notificationCoalescer.ts +192 -0
  42. package/src/remote/relayClient.ts +690 -0
  43. package/src/remote/remoteControlCliHost.ts +420 -0
  44. package/src/remote/remoteControlCommands.ts +197 -0
  45. package/src/remote/remoteControlConfig.ts +353 -0
  46. package/src/remote/remoteControlService.ts +822 -0
  47. package/src/remote/remoteControlSink.ts +78 -0
  48. package/src/remote/remoteNotifications.ts +200 -0
  49. package/src/stdio/agentBridge.ts +874 -106
  50. package/src/stdio/daemonServer.ts +30 -0
  51. package/src/stdio/hostSlashCommands.ts +209 -0
  52. package/src/stdio/jsonRpcConnection.ts +11 -2
  53. package/src/stdio/protocol.ts +14 -115
  54. package/src/stdio/stdioServer.ts +33 -2
  55. package/src/stdio/uiPayload.ts +65 -0
  56. package/src/stdio-cli.ts +11 -2
  57. package/src/types.ts +7 -0
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/workspaceDiff.ts +620 -0
  62. package/src/utils/worktree.ts +97 -0
  63. 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
+ }