wave-code 1.3.0 → 1.4.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 +1275 -895
- package/package.json +4 -2
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ConfirmationDetails.tsx +17 -3
- package/src/components/InputBox.tsx +2 -0
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +359 -127
- package/src/daemon/commands.ts +13 -4
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +4 -0
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +2 -0
- package/src/managers/inputReducer.ts +1 -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 +97 -0
- package/src/remote/notificationCoalescer.ts +174 -0
- package/src/remote/relayClient.ts +668 -0
- package/src/remote/remoteControlCliHost.ts +391 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +334 -0
- package/src/remote/remoteControlService.ts +799 -0
- package/src/remote/remoteControlSink.ts +77 -0
- package/src/remote/remoteNotifications.ts +194 -0
- package/src/stdio/agentBridge.ts +608 -74
- package/src/stdio/daemonServer.ts +30 -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-cli.ts +11 -2
- 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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This machine's stable identity on the relay — the id it registers itself with.
|
|
3
|
+
*
|
|
4
|
+
* The channel is keyed by the machine. The old scheme let the relay mint that key
|
|
5
|
+
* on first registration and kept it only in `~/.wave/remote-control.json`, so
|
|
6
|
+
* losing that file (reinstall, fresh home, read-only home, another OS user)
|
|
7
|
+
* registered a **second row for the same machine**: the phone then lists one
|
|
8
|
+
* computer twice, and the page URL — the id is in it — changes with it. Deriving
|
|
9
|
+
* the id here instead makes the same machine + same account always name the same
|
|
10
|
+
* row, so registration is idempotent and a lost state file just re-claims it.
|
|
11
|
+
*
|
|
12
|
+
* Why the OS machine id and not the hostname: hostnames are renameable, shared,
|
|
13
|
+
* and only ever a display label here (the `name` field). Why not a MAC address
|
|
14
|
+
* (the intuition is "it should be stable like a MAC address"): modern systems
|
|
15
|
+
* randomize it per network, a machine has several, and containers get a fresh
|
|
16
|
+
* one — none of that is stable enough to key a database row on.
|
|
17
|
+
*
|
|
18
|
+
* When the machine id cannot be read (unsupported platform, permissions, a
|
|
19
|
+
* container hiding it) this yields nothing and registration falls back to the
|
|
20
|
+
* old path — the relay mints the id. Nothing fails; the machine merely stops
|
|
21
|
+
* keeping its identity across a lost state file.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from "node:child_process";
|
|
25
|
+
import { createHash } from "node:crypto";
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import { authService } from "wave-agent-sdk";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Namespace for every derived host id. **Changing it changes the identity of
|
|
31
|
+
* every machine** — each one would register as a fresh row — so it only ever
|
|
32
|
+
* moves together with a deliberate migration. Never add an input to the derived
|
|
33
|
+
* name for the same reason.
|
|
34
|
+
*/
|
|
35
|
+
const HOST_ID_NAMESPACE = "9f0e1c72-1a9a-4e1c-8f4d-2b6d3c5a7e10";
|
|
36
|
+
|
|
37
|
+
/** Reading the platform's machine id must never hang a registration. */
|
|
38
|
+
const READ_TIMEOUT_MS = 2_000;
|
|
39
|
+
|
|
40
|
+
/** RFC 4122 version 5 (SHA-1) UUID over the UTF-8 name. */
|
|
41
|
+
export function uuidv5(namespace: string, name: string): string {
|
|
42
|
+
const namespaceBytes = Buffer.from(namespace.replace(/-/g, ""), "hex");
|
|
43
|
+
const digest = createHash("sha1")
|
|
44
|
+
.update(namespaceBytes)
|
|
45
|
+
.update(name, "utf8")
|
|
46
|
+
.digest();
|
|
47
|
+
const bytes = Buffer.from(digest.subarray(0, 16));
|
|
48
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x50;
|
|
49
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
50
|
+
const hex = bytes.toString("hex");
|
|
51
|
+
return [
|
|
52
|
+
hex.slice(0, 8),
|
|
53
|
+
hex.slice(8, 12),
|
|
54
|
+
hex.slice(12, 16),
|
|
55
|
+
hex.slice(16, 20),
|
|
56
|
+
hex.slice(20, 32),
|
|
57
|
+
].join("-");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The id this machine should register with, or undefined when it cannot be
|
|
62
|
+
* derived. Both inputs are required: without a machine id there is nothing
|
|
63
|
+
* stable to derive from, and without the account id two OS users sharing one
|
|
64
|
+
* machine would derive the *same* id and fight over one row (the row belongs to
|
|
65
|
+
* an account, which is also how the relay scopes them).
|
|
66
|
+
*/
|
|
67
|
+
export function deriveHostId(): string | undefined {
|
|
68
|
+
const machineId = readMachineId();
|
|
69
|
+
const userId = authService.getAuthUser()?.id;
|
|
70
|
+
if (!machineId || !userId) return undefined;
|
|
71
|
+
return uuidv5(HOST_ID_NAMESPACE, `${machineId}\n${userId}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Best-effort read of the platform's machine id; never throws. */
|
|
75
|
+
export function readMachineId(
|
|
76
|
+
platform: string = process.platform,
|
|
77
|
+
): string | undefined {
|
|
78
|
+
try {
|
|
79
|
+
if (platform === "darwin") return readDarwinMachineId();
|
|
80
|
+
if (platform === "win32") return readWindowsMachineId();
|
|
81
|
+
return readLinuxMachineId();
|
|
82
|
+
} catch {
|
|
83
|
+
// An unreadable / missing source is not an error worth surfacing: the
|
|
84
|
+
// caller falls back to letting the relay mint the id.
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Linux: the conventional systemd / dbus files, first one that reads. */
|
|
90
|
+
function readLinuxMachineId(): string | undefined {
|
|
91
|
+
for (const file of ["/etc/machine-id", "/var/lib/dbus/machine-id"]) {
|
|
92
|
+
try {
|
|
93
|
+
const value = fs.readFileSync(file, "utf8").trim();
|
|
94
|
+
if (value) return value;
|
|
95
|
+
} catch {
|
|
96
|
+
// Try the next location — neither file is guaranteed to exist.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** macOS: the platform UUID, which survives reboots and OS updates. */
|
|
103
|
+
function readDarwinMachineId(): string | undefined {
|
|
104
|
+
const output = execFileSync(
|
|
105
|
+
"ioreg",
|
|
106
|
+
["-rd1", "-c", "IOPlatformExpertDevice"],
|
|
107
|
+
{
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
timeout: READ_TIMEOUT_MS,
|
|
110
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
return /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(output)?.[1];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Windows: the registry's machine GUID. */
|
|
117
|
+
function readWindowsMachineId(): string | undefined {
|
|
118
|
+
const output = execFileSync(
|
|
119
|
+
"reg",
|
|
120
|
+
["query", "HKLM\\SOFTWARE\\Microsoft\\Cryptography", "/v", "MachineGuid"],
|
|
121
|
+
{
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
timeout: READ_TIMEOUT_MS,
|
|
124
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
125
|
+
},
|
|
126
|
+
);
|
|
127
|
+
return /MachineGuid\s+REG_SZ\s+(\S+)/.exec(output)?.[1];
|
|
128
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Message-window slicing, shared by every host that answers `getMessages`.
|
|
3
|
+
*
|
|
4
|
+
* Two callers need this and must agree byte for byte: the daemon / stdio
|
|
5
|
+
* bridge (multi-session registry) and the interactive CLI front end (one
|
|
6
|
+
* session in React state). The phone's reconciliation rules — `since` is an
|
|
7
|
+
* anchor, a vanished anchor means "re-pull the tail", ids can repeat so the
|
|
8
|
+
* anchor resolves to its **last** occurrence — are a property of the wire
|
|
9
|
+
* contract, not of either host, so the arithmetic lives here once.
|
|
10
|
+
*
|
|
11
|
+
* Described in docs/specs/ui/remote-control.md (§5 白名单与增量拉取).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { Message } from "wave-agent-sdk";
|
|
15
|
+
|
|
16
|
+
export interface MessageWindow {
|
|
17
|
+
/** Exclusive upper anchor: return the page ending just before this id. */
|
|
18
|
+
before?: string;
|
|
19
|
+
/** Exclusive lower anchor: return what arrived after this id. */
|
|
20
|
+
since?: string;
|
|
21
|
+
limit?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface MessageWindowResult {
|
|
25
|
+
messages: Message[];
|
|
26
|
+
/** Older messages exist before the returned page. */
|
|
27
|
+
hasMore?: boolean;
|
|
28
|
+
/** The `since` anchor is gone — rebuild from the tail. */
|
|
29
|
+
reset?: boolean;
|
|
30
|
+
/** Total display messages held for the session. */
|
|
31
|
+
total?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A malformed window. Hosts translate this into their own RPC error type. */
|
|
35
|
+
export class InvalidMessageWindowError extends Error {}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Slice `all` by `window`. Returns `undefined` when no window was asked for —
|
|
39
|
+
* the caller then answers with the whole list, untouched.
|
|
40
|
+
*
|
|
41
|
+
* The validations are here rather than in either caller because silently
|
|
42
|
+
* widening the read is the one failure mode a caller cannot notice: it asked
|
|
43
|
+
* for a bounded page and got the thread back.
|
|
44
|
+
*/
|
|
45
|
+
export function sliceMessageWindow(
|
|
46
|
+
all: Message[],
|
|
47
|
+
window?: MessageWindow,
|
|
48
|
+
): MessageWindowResult | undefined {
|
|
49
|
+
const before = window?.before;
|
|
50
|
+
const since = window?.since;
|
|
51
|
+
const rawLimit = window?.limit;
|
|
52
|
+
if (rawLimit !== undefined && (!Number.isInteger(rawLimit) || rawLimit < 1)) {
|
|
53
|
+
throw new InvalidMessageWindowError(
|
|
54
|
+
`getMessages limit must be a positive integer, got ${String(rawLimit)}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const limit = rawLimit;
|
|
58
|
+
|
|
59
|
+
if (before === undefined && since === undefined && limit === undefined) {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lastIndexOfId = (id: string): number => {
|
|
64
|
+
for (let i = all.length - 1; i >= 0; i--) {
|
|
65
|
+
if (all[i].id === id) return i;
|
|
66
|
+
}
|
|
67
|
+
return -1;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (since !== undefined) {
|
|
71
|
+
const at = lastIndexOfId(since);
|
|
72
|
+
if (at === -1) {
|
|
73
|
+
return { messages: [], reset: true, total: all.length };
|
|
74
|
+
}
|
|
75
|
+
const later = all.slice(at + 1);
|
|
76
|
+
return {
|
|
77
|
+
messages: limit === undefined ? later : later.slice(-limit),
|
|
78
|
+
hasMore: false,
|
|
79
|
+
total: all.length,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Backward paging; without `before` this is a tail read (`limit` only).
|
|
84
|
+
const end = before === undefined ? all.length : lastIndexOfId(before);
|
|
85
|
+
if (end === -1) {
|
|
86
|
+
// The anchor is gone. Older history may have been truncated along with it,
|
|
87
|
+
// so answer with an empty page: the caller stops paging rather than looping
|
|
88
|
+
// on an anchor that will never resolve.
|
|
89
|
+
return { messages: [], hasMore: false, total: all.length };
|
|
90
|
+
}
|
|
91
|
+
const start = limit === undefined ? 0 : Math.max(0, end - limit);
|
|
92
|
+
return {
|
|
93
|
+
messages: all.slice(start, end),
|
|
94
|
+
hasMore: start > 0,
|
|
95
|
+
total: all.length,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frame coalescer for the remote-control (phone) path.
|
|
3
|
+
*
|
|
4
|
+
* The bridge emits one notification per streamed chunk (see
|
|
5
|
+
* `agentBridge.ts` `onAssistantContentUpdated`), which mirrors the model's
|
|
6
|
+
* frame rate — tens to ~150 frames per second per session. Locally that is
|
|
7
|
+
* free: those frames go out over a unix socket inside the same process. Over a
|
|
8
|
+
* relay it becomes the dominant cost, because the JSON-RPC envelope (~150–250
|
|
9
|
+
* B) is an order of magnitude larger than the delta it carries — and the relay
|
|
10
|
+
* pays that cost per subscriber, per frame.
|
|
11
|
+
*
|
|
12
|
+
* So the remote path alone gets a merge window: notifications whose payload is
|
|
13
|
+
* an accumulating delta are held for at most `windowMs` and concatenated.
|
|
14
|
+
* Everything else is a discrete event — a permission request, a tool block
|
|
15
|
+
* reaching `end`, a status signal — and is written through immediately, after
|
|
16
|
+
* flushing whatever was held, so a phone never observes a reordered stream.
|
|
17
|
+
*
|
|
18
|
+
* This is a delay, never a sample: no delta is dropped, and the merged frame
|
|
19
|
+
* carries exactly the bytes the individual frames would have. The local
|
|
20
|
+
* desktop / IDE path does not go through here at all.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One notification on its way to the relay. */
|
|
24
|
+
export interface RelayFrame {
|
|
25
|
+
method: string;
|
|
26
|
+
params: unknown;
|
|
27
|
+
sessionId?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Merge window for the remote path. Long enough to collapse a streaming burst
|
|
32
|
+
* (the model emits far faster than this), short enough that a human reading the
|
|
33
|
+
* phone cannot tell the difference.
|
|
34
|
+
*/
|
|
35
|
+
export const REMOTE_FRAME_WINDOW_MS = 150;
|
|
36
|
+
|
|
37
|
+
interface MergeSpec {
|
|
38
|
+
/** The params field that accumulates. */
|
|
39
|
+
field: string;
|
|
40
|
+
/** Identity of the accumulating thing, so two streams never interleave. */
|
|
41
|
+
key: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Decide whether a notification is a pure delta that may be merged.
|
|
46
|
+
*
|
|
47
|
+
* Only the three streaming callbacks qualify, and only in their streaming
|
|
48
|
+
* stage: their payloads are documented as deltas ("consumers accumulate"), and
|
|
49
|
+
* `messageOperations.ts` accumulates `parametersChunk` by plain concatenation —
|
|
50
|
+
* the same operation this class performs. A `start` / `running` / `end` frame
|
|
51
|
+
* is a one-time snapshot (or the authoritative full value) and must not be
|
|
52
|
+
* merged with anything.
|
|
53
|
+
*/
|
|
54
|
+
function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
|
|
55
|
+
if (typeof params !== "object" || params === null) return undefined;
|
|
56
|
+
const p = params as Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
if (
|
|
59
|
+
method === "assistantContentUpdated" ||
|
|
60
|
+
method === "assistantReasoningUpdated"
|
|
61
|
+
) {
|
|
62
|
+
if (p.stage !== "streaming" || typeof p.chunk !== "string")
|
|
63
|
+
return undefined;
|
|
64
|
+
if (typeof p.messageId !== "string") return undefined;
|
|
65
|
+
return { field: "chunk", key: `c\u0000${p.messageId}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (method === "toolBlockUpdated") {
|
|
69
|
+
if (p.stage !== "streaming" || typeof p.parametersChunk !== "string") {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
if (typeof p.messageId !== "string" || typeof p.id !== "string") {
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
field: "parametersChunk",
|
|
77
|
+
key: `t\u0000${p.messageId}\u0000${p.id}`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface Pending {
|
|
85
|
+
method: string;
|
|
86
|
+
sessionId?: string;
|
|
87
|
+
/** Params of the first frame of the run, with the delta field zeroed. */
|
|
88
|
+
base: Record<string, unknown>;
|
|
89
|
+
field: string;
|
|
90
|
+
text: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class NotificationCoalescer {
|
|
94
|
+
private pending = new Map<string, Pending>();
|
|
95
|
+
/** Insertion order of live keys, so a flush preserves the original order. */
|
|
96
|
+
private order: string[] = [];
|
|
97
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
98
|
+
|
|
99
|
+
constructor(
|
|
100
|
+
private readonly write: (frames: RelayFrame[]) => void,
|
|
101
|
+
private readonly windowMs: number = REMOTE_FRAME_WINDOW_MS,
|
|
102
|
+
) {}
|
|
103
|
+
|
|
104
|
+
push(method: string, params: unknown, sessionId?: string): void {
|
|
105
|
+
const spec = mergeSpec(method, params);
|
|
106
|
+
if (!spec) {
|
|
107
|
+
// Discrete event: order matters relative to the buffered deltas, so the
|
|
108
|
+
// buffer goes out first.
|
|
109
|
+
this.flush();
|
|
110
|
+
this.write([{ method, params, sessionId }]);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const mapKey = `${sessionId ?? ""}\u0000${method}\u0000${spec.key}`;
|
|
115
|
+
const existing = this.pending.get(mapKey);
|
|
116
|
+
if (existing) {
|
|
117
|
+
existing.text += String(
|
|
118
|
+
(params as Record<string, unknown>)[spec.field] ?? "",
|
|
119
|
+
);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const base = { ...(params as Record<string, unknown>) };
|
|
124
|
+
const text = String(base[spec.field] ?? "");
|
|
125
|
+
delete base[spec.field];
|
|
126
|
+
this.pending.set(mapKey, {
|
|
127
|
+
method,
|
|
128
|
+
sessionId,
|
|
129
|
+
base,
|
|
130
|
+
field: spec.field,
|
|
131
|
+
text,
|
|
132
|
+
});
|
|
133
|
+
this.order.push(mapKey);
|
|
134
|
+
if (this.timer === undefined) {
|
|
135
|
+
this.timer = setTimeout(() => this.flush(), this.windowMs);
|
|
136
|
+
// A pending phone frame must never hold the process open.
|
|
137
|
+
this.timer.unref?.();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Write every buffered run, in the order the runs first appeared. */
|
|
142
|
+
flush(): void {
|
|
143
|
+
if (this.timer !== undefined) {
|
|
144
|
+
clearTimeout(this.timer);
|
|
145
|
+
this.timer = undefined;
|
|
146
|
+
}
|
|
147
|
+
if (this.order.length === 0) return;
|
|
148
|
+
|
|
149
|
+
const keys = this.order;
|
|
150
|
+
const buffered = this.pending;
|
|
151
|
+
this.order = [];
|
|
152
|
+
this.pending = new Map();
|
|
153
|
+
|
|
154
|
+
const frames: RelayFrame[] = keys.map((key) => {
|
|
155
|
+
const item = buffered.get(key)!;
|
|
156
|
+
return {
|
|
157
|
+
method: item.method,
|
|
158
|
+
params: { ...item.base, [item.field]: item.text },
|
|
159
|
+
sessionId: item.sessionId,
|
|
160
|
+
};
|
|
161
|
+
});
|
|
162
|
+
this.write(frames);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Drop everything buffered without writing — used when the socket dies. */
|
|
166
|
+
discard(): void {
|
|
167
|
+
if (this.timer !== undefined) {
|
|
168
|
+
clearTimeout(this.timer);
|
|
169
|
+
this.timer = undefined;
|
|
170
|
+
}
|
|
171
|
+
this.order = [];
|
|
172
|
+
this.pending = new Map();
|
|
173
|
+
}
|
|
174
|
+
}
|