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.
Files changed (42) hide show
  1. package/dist/bundle/wave.mjs +1275 -895
  2. package/package.json +4 -2
  3. package/src/commands/update.ts +62 -22
  4. package/src/components/BackgroundTaskManager.tsx +76 -54
  5. package/src/components/ConfirmationDetails.tsx +17 -3
  6. package/src/components/InputBox.tsx +2 -0
  7. package/src/components/MessageBlockItem.tsx +0 -6
  8. package/src/components/WorkflowManager.tsx +38 -138
  9. package/src/components/WorkflowRunDetail.tsx +193 -0
  10. package/src/constants/commands.ts +6 -0
  11. package/src/contexts/useChat.tsx +359 -127
  12. package/src/daemon/commands.ts +13 -4
  13. package/src/daemon-cli.ts +6 -1
  14. package/src/hooks/useInputManager.ts +4 -0
  15. package/src/hooks/useRemoteControl.ts +324 -0
  16. package/src/index.ts +35 -0
  17. package/src/managers/inputHandlers.ts +2 -0
  18. package/src/managers/inputReducer.ts +1 -0
  19. package/src/print-cli.ts +5 -0
  20. package/src/reducers/workflowManagerReducer.ts +21 -2
  21. package/src/remote/frameBudget.ts +45 -0
  22. package/src/remote/hostIdentity.ts +128 -0
  23. package/src/remote/messageWindow.ts +97 -0
  24. package/src/remote/notificationCoalescer.ts +174 -0
  25. package/src/remote/relayClient.ts +668 -0
  26. package/src/remote/remoteControlCliHost.ts +391 -0
  27. package/src/remote/remoteControlCommands.ts +197 -0
  28. package/src/remote/remoteControlConfig.ts +334 -0
  29. package/src/remote/remoteControlService.ts +799 -0
  30. package/src/remote/remoteControlSink.ts +77 -0
  31. package/src/remote/remoteNotifications.ts +194 -0
  32. package/src/stdio/agentBridge.ts +608 -74
  33. package/src/stdio/daemonServer.ts +30 -0
  34. package/src/stdio/jsonRpcConnection.ts +11 -2
  35. package/src/stdio/protocol.ts +14 -115
  36. package/src/stdio/stdioServer.ts +33 -2
  37. package/src/stdio-cli.ts +11 -2
  38. package/src/utils/gitCredentialHelper.ts +106 -0
  39. package/src/utils/logger.ts +5 -2
  40. package/src/utils/rewindCheckpoints.ts +2 -4
  41. package/src/utils/workspaceDiff.ts +620 -0
  42. package/src/utils/worktree.ts +97 -0
@@ -103,7 +103,9 @@ function startDaemon(socketPath: string): void {
103
103
  * connection (or daemonStartTimeout.ms elapses). Exits (nonzero) with the
104
104
  * spec'd error when the daemon cannot be started/reached.
105
105
  */
106
- async function connectDaemonOrExit(socketPath: string): Promise<SocketClient> {
106
+ export async function connectDaemonOrExit(
107
+ socketPath: string,
108
+ ): Promise<SocketClient> {
107
109
  try {
108
110
  return await connectDaemon(socketPath);
109
111
  } catch (err) {
@@ -234,6 +236,10 @@ async function attachSession(
234
236
  const init = (await client.request("initialize", {
235
237
  workdir: process.cwd(),
236
238
  restoreSessionId: sessionId,
239
+ // A daemon restart leaves the session on disk and re-hosts it here; the
240
+ // re-hosting client is the CLI, and the session file carries no record of
241
+ // its original creator (see the entrypoint story in the telemetry spec).
242
+ entrypoint: "cli",
237
243
  })) as { sessionId: string; workingDirectory: string };
238
244
  const initId = init.sessionId;
239
245
  options.onInitialized?.(initId);
@@ -357,6 +363,9 @@ export async function daemonCreateCommand(
357
363
  workdir,
358
364
  permissionMode: mode,
359
365
  model: options.model,
366
+ // The daemon hosts sessions built from several front ends; this one is
367
+ // being created by the CLI (`wave daemon create`).
368
+ entrypoint: "cli",
360
369
  })) as { sessionId: string };
361
370
  console.log(result.sessionId);
362
371
  if (worktreePath !== undefined) {
@@ -1367,7 +1376,7 @@ export const daemonStopTimeout = { ms: 10_000 };
1367
1376
  const DAEMON_STOP_POLL_INTERVAL_MS = 200;
1368
1377
 
1369
1378
  /** Connect to the daemon socket without starting one; undefined when it is not running. */
1370
- async function tryConnectDaemon(
1379
+ export async function connectDaemonIfRunning(
1371
1380
  socketPath: string,
1372
1381
  ): Promise<SocketClient | undefined> {
1373
1382
  try {
@@ -1381,7 +1390,7 @@ async function tryConnectDaemon(
1381
1390
  async function waitForDaemonExit(socketPath: string): Promise<void> {
1382
1391
  const deadline = Date.now() + daemonStopTimeout.ms;
1383
1392
  while (Date.now() < deadline) {
1384
- const client = await tryConnectDaemon(socketPath);
1393
+ const client = await connectDaemonIfRunning(socketPath);
1385
1394
  if (!client) return;
1386
1395
  client.dispose();
1387
1396
  await sleep(DAEMON_STOP_POLL_INTERVAL_MS);
@@ -1399,7 +1408,7 @@ async function waitForDaemonExit(socketPath: string): Promise<void> {
1399
1408
  * Throws when a running daemon fails to exit in time.
1400
1409
  */
1401
1410
  async function stopDaemon(socketPath: string): Promise<boolean> {
1402
- const client = await tryConnectDaemon(socketPath);
1411
+ const client = await connectDaemonIfRunning(socketPath);
1403
1412
  if (!client) return false;
1404
1413
  try {
1405
1414
  // The daemon may drop the socket before responding to the RPC — the wait
package/src/daemon-cli.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { ensureRuntimeDeps } from "wave-agent-sdk";
12
12
  import { DaemonServer } from "./stdio/daemonServer.js";
13
+ import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
13
14
 
14
15
  export async function startDaemonCli(socketPath: string): Promise<void> {
15
16
  // Install the on-demand runtime dependencies (image codec + ripgrep) before
@@ -17,7 +18,11 @@ export async function startDaemonCli(socketPath: string): Promise<void> {
17
18
  // install belongs here — and it must land before the first client attaches,
18
19
  // since the host reports "daemon ready" on this socket.
19
20
  await ensureRuntimeDeps();
20
- const server = new DaemonServer({ socketPath });
21
+
22
+ // The resident daemon owns the remote sessions, so it is the process that
23
+ // serves a paired phone. Off (undefined) unless the switch says otherwise.
24
+ const createRemoteSink = createRemoteControlSinkFactory(process.cwd());
25
+ const server = new DaemonServer({ socketPath, createRemoteSink });
21
26
  await server.start();
22
27
  // Ready — any error that follows goes to the daemon log via stderr.
23
28
  }
@@ -64,6 +64,7 @@ export const useInputManager = (
64
64
  onReloadPlugins,
65
65
  onAddDir,
66
66
  onRenameSession,
67
+ onRemoteControlCommand,
67
68
  onPlanCommand,
68
69
  isIdle: isIdleProp,
69
70
  } = callbacks;
@@ -290,6 +291,8 @@ export const useInputManager = (
290
291
  await onAddDir?.(effect.args);
291
292
  } else if (command === "rename") {
292
293
  await onRenameSession?.(effect.args);
294
+ } else if (command === "remote-control") {
295
+ await onRemoteControlCommand?.(effect.args);
293
296
  } else if (command === "plan") {
294
297
  await onPlanCommand?.(effect.args);
295
298
  }
@@ -323,6 +326,7 @@ export const useInputManager = (
323
326
  onReloadPlugins,
324
327
  onAddDir,
325
328
  onRenameSession,
329
+ onRemoteControlCommand,
326
330
  onPlanCommand,
327
331
  ]);
328
332
 
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Wires the interactive CLI's own session to the remote-control relay.
3
+ *
4
+ * The daemon serves its sessions by fanning notifications into a
5
+ * `RemoteControlSink`; the CLI has no notification fan-out at all — every
6
+ * surface it has is React state. So this hook supplies that fan-out, under the
7
+ * same method names and payloads a `--stdio` / daemon process uses, which is what
8
+ * lets the page treat the two hosts identically — including the remote channel's
9
+ * 150 ms merge window, which only bites if the frames it merges are the same
10
+ * ones.
11
+ *
12
+ * Two halves, and both have to be the same shape as the bridge's:
13
+ *
14
+ * - the callbacks that carry content, which the front end hands over from its own
15
+ * agent callbacks (`remoteNotifications` → `notify`);
16
+ * - the coarse state this hook watches itself (`messagesChange` as a content-free
17
+ * structural signal, loading, permission mode, context usage, approvals). The
18
+ * page answers a signal by pulling with `getMessages({ since })`, so no payload
19
+ * ever rides the signal.
20
+ */
21
+
22
+ import { useCallback, useEffect, useRef, useState } from "react";
23
+ import type {
24
+ BackgroundTask,
25
+ Message,
26
+ PermissionDecision,
27
+ PermissionMode,
28
+ QueuedMessage,
29
+ RemoteControlSnapshot,
30
+ RemoteControlStatus,
31
+ SlashCommand,
32
+ Task,
33
+ ToolPermissionContext,
34
+ } from "wave-agent-sdk";
35
+ import {
36
+ RemoteControlCliHost,
37
+ type RemoteControlCliSession,
38
+ } from "../remote/remoteControlCliHost.js";
39
+ import {
40
+ percentOf,
41
+ remoteWire,
42
+ toBackgroundTaskSummaries,
43
+ } from "../remote/remoteNotifications.js";
44
+ import type { RemoteNotify } from "../remote/remoteNotifications.js";
45
+ import { createRemoteControlSinkFactory } from "../remote/remoteControlSink.js";
46
+ import type { RemoteControlSink } from "../remote/remoteControlSink.js";
47
+
48
+ export interface UseRemoteControlOptions {
49
+ workdir: string;
50
+ sessionId: string;
51
+ messages: Message[];
52
+ isLoading: boolean;
53
+ permissionMode: PermissionMode;
54
+ latestTotalTokens: number;
55
+ maxInputTokens: number;
56
+ /**
57
+ * The title `/rename` sets, read live off the agent — the page's session list
58
+ * labels the row with it (and the page itself may be the one that renamed it).
59
+ */
60
+ customTitle: () => string | undefined;
61
+ hasRunningBackgroundWork: boolean;
62
+ hasPendingMessages: boolean;
63
+ /**
64
+ * The session-state family the page re-reads off `getSessionInfo` (spec
65
+ * remote-control「Web 页作为第五种宿主」场景 26) — current values, so the
66
+ * long-lived host answers reads even though the session underneath it is
67
+ * replaced.
68
+ */
69
+ tasks: Task[];
70
+ /** Background tasks as the TUI holds them — read off `getSessionInfo`. */
71
+ backgroundTasks: BackgroundTask[];
72
+ isCompacting: boolean;
73
+ isCommandRunning: boolean;
74
+ queuedMessages: QueuedMessage[];
75
+ /** Approvals waiting on the user right now. */
76
+ pending: Array<{ requestId: string; context: ToolPermissionContext }>;
77
+ availableTools: () => string[];
78
+ sendMessage: (text: string, force?: boolean) => void | Promise<void>;
79
+ abort: () => void;
80
+ setPermissionMode: (mode: PermissionMode) => void;
81
+ respondPermission: (requestId: string, decision: PermissionDecision) => void;
82
+ /** Rename the conversation (the title `wave -r` lists). */
83
+ renameSession: (title: string) => void | Promise<void>;
84
+ /** Compact the conversation; errors must propagate (the page shows them). */
85
+ compact: (customInstructions?: string) => void | Promise<void>;
86
+ /** Slash commands the page may offer (SDK commands: skills / custom). */
87
+ slashCommands: () => SlashCommand[];
88
+ }
89
+
90
+ export interface RemoteControlApi {
91
+ /** The `enableRemoteControl` switch was on when this process started. */
92
+ enabled: boolean;
93
+ status: RemoteControlStatus;
94
+ getSnapshot: () => RemoteControlSnapshot;
95
+ /** Dials out. `name` labels this machine on the phone. */
96
+ connect: (name?: string) => void;
97
+ disconnect: () => void;
98
+ /**
99
+ * Send one notification to the phone under the method name a server host
100
+ * (`AgentBridge`) would use for the same thing. The front end calls it from its
101
+ * content-carrying agent callbacks (see `remoteNotifications`) — that is what
102
+ * makes the two hosts' fan-out identical, and therefore what makes the remote
103
+ * channel's merge window apply to this host too.
104
+ */
105
+ notify: RemoteNotify;
106
+ }
107
+
108
+ /** The GUI dialog polls once a second for the same reason: there is no push. */
109
+ const STATUS_POLL_MS = 1000;
110
+
111
+ export function useRemoteControl(
112
+ options: UseRemoteControlOptions,
113
+ ): RemoteControlApi {
114
+ // Every getter below must read the *current* value: the host outlives any
115
+ // single render, and the session underneath it is replaced on /resume.
116
+ const latest = useRef(options);
117
+ latest.current = options;
118
+
119
+ const [sink] = useState<RemoteControlSink | undefined>(() => {
120
+ // The switch is read once, here, exactly as a server process reads it once
121
+ // at startup — hence "restart the CLI" in the failure hint.
122
+ const factory = createRemoteControlSinkFactory(options.workdir);
123
+ if (!factory) return undefined;
124
+ return factory(new RemoteControlCliHost(adapterOf(latest)));
125
+ });
126
+
127
+ const [status, setStatus] = useState<RemoteControlStatus>(
128
+ () => sink?.getSnapshot() ?? { state: "off" },
129
+ );
130
+
131
+ // ── Outbound: state changes the phone renders ─────────────────
132
+ //
133
+ // `sendNotification` drops everything while the link is down, which is what
134
+ // we want: a phone that reconnects re-reads the tail anyway.
135
+ const sessionId = options.sessionId;
136
+ const { messages, isLoading, permissionMode, latestTotalTokens } = options;
137
+
138
+ const notify = useCallback<RemoteNotify>(
139
+ (method, params) => {
140
+ sink?.sendNotification(method, params, latest.current.sessionId);
141
+ },
142
+ [sink],
143
+ );
144
+
145
+ // A structural notice, never a payload: the phone answers it by pulling the
146
+ // tail with `getMessages({ since })`, the same path a cold start takes and the
147
+ // only one that can carry a 13 MB list. What counts as "structurally changed"
148
+ // is `useStructureKey`'s business below.
149
+ const structureKey = useStructureKey(messages, sink !== undefined);
150
+ useEffect(() => {
151
+ sink?.sendNotification(
152
+ "messagesChange",
153
+ remoteWire.messagesChangeSignal(),
154
+ sessionId,
155
+ );
156
+ }, [sink, structureKey, sessionId]);
157
+
158
+ useEffect(() => {
159
+ sink?.sendNotification(
160
+ "loadingChange",
161
+ remoteWire.loadingChange(isLoading),
162
+ sessionId,
163
+ );
164
+ }, [sink, isLoading, sessionId]);
165
+
166
+ useEffect(() => {
167
+ sink?.sendNotification(
168
+ "permissionModeChange",
169
+ remoteWire.permissionModeChange(permissionMode),
170
+ sessionId,
171
+ );
172
+ }, [sink, permissionMode, sessionId]);
173
+
174
+ useEffect(() => {
175
+ if (!sink || !sessionId) return;
176
+ sink.sendNotification(
177
+ "contextUsage",
178
+ remoteWire.contextUsage(latestTotalTokens, options.maxInputTokens),
179
+ sessionId,
180
+ );
181
+ }, [sink, sessionId, latestTotalTokens, options.maxInputTokens]);
182
+
183
+ // Approvals are announced once, when they appear. The page answers with
184
+ // `permissionResponse`, which the host routes back into the same promise the
185
+ // terminal is waiting on — first answer wins, as the spec requires.
186
+ const announced = useRef<Set<string>>(new Set());
187
+ useEffect(() => {
188
+ const pending = latest.current.pending;
189
+ if (!sink || pending.length === 0) return;
190
+ for (const request of pending) {
191
+ if (announced.current.has(request.requestId)) continue;
192
+ announced.current.add(request.requestId);
193
+ sink.sendNotification(
194
+ "permissionRequest",
195
+ remoteWire.permissionRequest(request.requestId, request.context),
196
+ sessionId,
197
+ );
198
+ }
199
+ const alive = new Set(pending.map((request) => request.requestId));
200
+ for (const id of [...announced.current]) {
201
+ if (!alive.has(id)) announced.current.delete(id);
202
+ }
203
+ }, [sink, options.pending, sessionId]);
204
+
205
+ // ── Status the command view renders ───────────────────────────
206
+
207
+ useEffect(() => {
208
+ if (!sink) return;
209
+ const timer = setInterval(() => {
210
+ setStatus(sink.getSnapshot());
211
+ }, STATUS_POLL_MS);
212
+ timer.unref?.();
213
+ return () => clearInterval(timer);
214
+ }, [sink]);
215
+
216
+ const connect = useCallback(
217
+ (name?: string) => {
218
+ sink?.connect(name);
219
+ setStatus(sink?.getSnapshot() ?? { state: "off" });
220
+ },
221
+ [sink],
222
+ );
223
+
224
+ const disconnect = useCallback(() => {
225
+ sink?.disconnect();
226
+ setStatus(sink?.getSnapshot() ?? { state: "off" });
227
+ }, [sink]);
228
+
229
+ const getSnapshot = useCallback(
230
+ () => sink?.getSnapshot() ?? { state: "off" as const },
231
+ [sink],
232
+ );
233
+
234
+ // The process is going away: drop the outbound connection rather than let
235
+ // the relay keep a subscription for a session that no longer exists.
236
+ useEffect(() => {
237
+ return () => {
238
+ sink?.stop();
239
+ };
240
+ }, [sink]);
241
+
242
+ return {
243
+ enabled: sink !== undefined,
244
+ status,
245
+ getSnapshot,
246
+ connect,
247
+ disconnect,
248
+ notify,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * The id composition of the display list, in order — what "the list changed"
254
+ * means for the remote path — as a key that is stable while the composition is.
255
+ *
256
+ * Ids are stable across a streamed message's growth and across in-place edits of
257
+ * its blocks, so comparing this key is what separates a structural change (a
258
+ * message appeared, was discarded, or the list was truncated/replaced) from a
259
+ * delta landing in a message the phone already has. Anchored on the ids rather
260
+ * than on "the messages state changed" so no call site has to be trusted to
261
+ * remember to announce a structural change — watching `messages` instead is what
262
+ * turned every model chunk into a signal plus a phone round trip.
263
+ *
264
+ * This runs once per render, and a streamed reply renders once per chunk, so the
265
+ * two shortcuts are the point: with no sink there is nobody to signal, and an
266
+ * unchanged id list returns the previous key rather than building a new string.
267
+ *
268
+ * The trigger stops at the ids on purpose: `since` is an **exclusive** anchor, so
269
+ * a signal can only ever deliver entries appended after the phone's own last
270
+ * message — a change made *inside* that message needs a payload-carrying
271
+ * notification of its own (see `remoteNotifications`).
272
+ */
273
+ function useStructureKey(messages: Message[], enabled: boolean): string {
274
+ const last = useRef<{ ids: string[]; key: string }>({ ids: [], key: "" });
275
+ if (!enabled) return "";
276
+ const previous = last.current;
277
+ if (
278
+ previous.ids.length === messages.length &&
279
+ messages.every((message, index) => message.id === previous.ids[index])
280
+ ) {
281
+ return previous.key;
282
+ }
283
+ const ids = messages.map((message) => message.id);
284
+ last.current = { ids, key: ids.join("\n") };
285
+ return last.current.key;
286
+ }
287
+
288
+ /** The single-session adapter the host is built on. */
289
+ function adapterOf(latest: {
290
+ current: UseRemoteControlOptions;
291
+ }): RemoteControlCliSession {
292
+ return {
293
+ sessionId: () => latest.current.sessionId,
294
+ workdir: () => latest.current.workdir,
295
+ displayMessages: () => latest.current.messages,
296
+ contextUsagePercent: () =>
297
+ percentOf(
298
+ latest.current.latestTotalTokens,
299
+ latest.current.maxInputTokens,
300
+ ),
301
+ latestTotalTokens: () => latest.current.latestTotalTokens,
302
+ permissionMode: () => latest.current.permissionMode,
303
+ customTitle: () => latest.current.customTitle(),
304
+ availableTools: () => latest.current.availableTools(),
305
+ isLoading: () => latest.current.isLoading,
306
+ hasRunningBackgroundWork: () => latest.current.hasRunningBackgroundWork,
307
+ hasPendingMessages: () => latest.current.hasPendingMessages,
308
+ tasks: () => latest.current.tasks,
309
+ backgroundTasks: () =>
310
+ toBackgroundTaskSummaries(latest.current.backgroundTasks),
311
+ isCompacting: () => latest.current.isCompacting,
312
+ isCommandRunning: () => latest.current.isCommandRunning,
313
+ queuedMessages: () => latest.current.queuedMessages,
314
+ pendingPermissions: () => latest.current.pending,
315
+ sendMessage: (text, force) => latest.current.sendMessage(text, force),
316
+ abort: () => latest.current.abort(),
317
+ setPermissionMode: (mode) => latest.current.setPermissionMode(mode),
318
+ respondPermission: (requestId, decision) =>
319
+ latest.current.respondPermission(requestId, decision),
320
+ renameSession: (title) => latest.current.renameSession(title),
321
+ compact: (customInstructions) => latest.current.compact(customInstructions),
322
+ slashCommands: () => latest.current.slashCommands(),
323
+ };
324
+ }
package/src/index.ts CHANGED
@@ -537,6 +537,41 @@ export async function main() {
537
537
  .demandCommand(1, "Please specify a daemon subcommand");
538
538
  },
539
539
  )
540
+ .command(
541
+ "remote-control [action] [name]",
542
+ "Serve this machine's sessions to a paired phone (via the wave daemon)",
543
+ (yargs) => {
544
+ return yargs
545
+ .positional("action", {
546
+ describe: "on | off | status",
547
+ type: "string",
548
+ default: "status",
549
+ })
550
+ .positional("name", {
551
+ describe: "Label for this machine, shown on the phone",
552
+ type: "string",
553
+ });
554
+ },
555
+ async (argv) => {
556
+ const {
557
+ remoteControlOnCommand,
558
+ remoteControlOffCommand,
559
+ remoteControlStatusCommand,
560
+ } = await import("./remote/remoteControlCommands.js");
561
+ const action = (argv.action as string | undefined) ?? "status";
562
+ if (action === "on") {
563
+ await remoteControlOnCommand(argv.name as string | undefined);
564
+ } else if (action === "off") {
565
+ await remoteControlOffCommand();
566
+ } else if (action === "status") {
567
+ await remoteControlStatusCommand();
568
+ } else {
569
+ console.error(`Unknown remote-control action: ${action}`);
570
+ console.error("Usage: wave remote-control [on|off|status] [name]");
571
+ process.exit(1);
572
+ }
573
+ },
574
+ )
540
575
  .command(
541
576
  "update",
542
577
  "Update WAVE Code to the latest version",
@@ -405,6 +405,8 @@ export const handleCommandSelect = (
405
405
  await callbacks.onAddDir?.();
406
406
  } else if (command === "rename") {
407
407
  await callbacks.onRenameSession?.();
408
+ } else if (command === "remote-control") {
409
+ await callbacks.onRemoteControlCommand?.();
408
410
  }
409
411
  }
410
412
  })();
@@ -123,6 +123,7 @@ export interface InputManagerCallbacks {
123
123
  onReloadPlugins?: () => Promise<void>;
124
124
  onAddDir?: (args?: string) => Promise<void>;
125
125
  onRenameSession?: (args?: string) => Promise<void>;
126
+ onRemoteControlCommand?: (args?: string) => Promise<void>;
126
127
  onPlanCommand?: (args?: string) => Promise<void> | void;
127
128
  sessionId?: string;
128
129
  workdir?: string;
package/src/print-cli.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  hasNewCommits,
6
6
  getDefaultRemoteBranch,
7
7
  validateWorktreeRemovalPath,
8
+ setTelemetryStdoutProtected,
8
9
  type WorktreeSession,
9
10
  } from "wave-agent-sdk";
10
11
  import { displayUsageSummary } from "./utils/usageSummary.js";
@@ -35,6 +36,9 @@ function displayTimingInfo(startTime: Date, showStats: boolean): void {
35
36
  }
36
37
 
37
38
  export async function startPrintCli(options: PrintCliOptions): Promise<void> {
39
+ // stdout carries the printed result here; console telemetry exporters would
40
+ // mix structured telemetry into it — strip them at config resolution.
41
+ setTelemetryStdoutProtected(true);
38
42
  const startTime = new Date();
39
43
  const {
40
44
  restoreSessionId,
@@ -135,6 +139,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
135
139
  isNewWorktree: worktreeSession?.isNew,
136
140
  model,
137
141
  mcpServers,
142
+ entrypoint: "cli",
138
143
  // 保持流式模式以获得更好的命令行用户体验
139
144
  });
140
145
 
@@ -29,8 +29,27 @@ export function workflowManagerReducer(
29
29
  action: WorkflowManagerAction,
30
30
  ): WorkflowManagerState {
31
31
  switch (action.type) {
32
- case "SET_RUNS":
33
- return { ...state, runs: action.runs };
32
+ case "SET_RUNS": {
33
+ // Newest first (spec multi-agent/workflow「工作流管理对话框(IDE 与 CLI)」场景
34
+ // 1): the run just started is the one to look at. The host hands them over
35
+ // in its own map order (creation order, plus whatever a session restore
36
+ // appended), which puts it at the bottom.
37
+ const runs = [...action.runs].sort((a, b) => b.startTime - a.startTime);
38
+ // The sort moves rows when a run appears at the top, so an index-stable
39
+ // selection would silently follow the position instead of the run.
40
+ const previous = state.runs[state.selectedIndex];
41
+ const keptIndex = previous
42
+ ? runs.findIndex((r) => r.runId === previous.runId)
43
+ : -1;
44
+ return {
45
+ ...state,
46
+ runs,
47
+ selectedIndex:
48
+ keptIndex >= 0
49
+ ? keptIndex
50
+ : Math.min(state.selectedIndex, Math.max(0, runs.length - 1)),
51
+ };
52
+ }
34
53
  case "SELECT_INDEX":
35
54
  return { ...state, selectedIndex: action.index };
36
55
  case "MOVE_UP":
@@ -0,0 +1,45 @@
1
+ /**
2
+ * One-frame budget for the frames this machine hands to the relay.
3
+ *
4
+ * Two relay-side limits stand behind every frame, and neither is ours to raise:
5
+ * `maxPayload` (4 MiB by default) makes the relay close the connection with
6
+ * `1009`, and — lower, and far easier to hit — the per-subscriber buffer
7
+ * (1 MiB by default) makes it **drop that one frame** and ask for a resync.
8
+ * The second failure is silent: no error, just a page the phone never sees, and
9
+ * a phone that answers the resync by asking for the same page again.
10
+ *
11
+ * Hence the budget lives here, at half of that buffer, leaving room for the
12
+ * frames already in flight. It is deliberately **not** derived from the relay's
13
+ * number at runtime: the promise "nothing we send exceeds what the relay will
14
+ * forward" has to hold without knowing which relay build is on the other side.
15
+ */
16
+ export const REMOTE_MAX_FRAME_BYTES = 512 * 1024;
17
+
18
+ /** Serialized size of a frame, exactly as it goes on the wire. */
19
+ export function frameBytes(frame: unknown): number {
20
+ const json = JSON.stringify(frame);
21
+ return json === undefined ? 0 : Buffer.byteLength(json, "utf8");
22
+ }
23
+
24
+ /**
25
+ * How many trailing items of a list still fit under the budget.
26
+ *
27
+ * The candidate list is always a page of messages, so the newest ones are the
28
+ * ones worth keeping: a phone that asked for the tail is better served by the
29
+ * last 80 messages than by the first 80. `fits` must be monotonic in the count
30
+ * — it is, for a suffix of a JSON array — so this is a binary search rather
31
+ * than one `stringify` per candidate length.
32
+ */
33
+ export function trailingCountThatFits(
34
+ total: number,
35
+ fits: (count: number) => boolean,
36
+ ): number {
37
+ let low = 0;
38
+ let high = total;
39
+ while (low < high) {
40
+ const mid = Math.ceil((low + high) / 2);
41
+ if (fits(mid)) low = mid;
42
+ else high = mid - 1;
43
+ }
44
+ return low;
45
+ }