wave-code 1.2.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 (64) hide show
  1. package/dist/bundle/wave.mjs +1738 -893
  2. package/package.json +6 -3
  3. package/src/cli.tsx +9 -0
  4. package/src/commands/plugin/install.ts +6 -7
  5. package/src/commands/plugin/uninstall.ts +11 -5
  6. package/src/commands/update.ts +62 -22
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/ChatInterface.tsx +1 -0
  9. package/src/components/ConfirmationDetails.tsx +51 -16
  10. package/src/components/ConfirmationSelector.tsx +39 -0
  11. package/src/components/InputBox.tsx +41 -2
  12. package/src/components/Markdown.tsx +21 -8
  13. package/src/components/MarketplaceDetail.tsx +10 -27
  14. package/src/components/MarketplaceView.tsx +17 -3
  15. package/src/components/MessageBlockItem.tsx +0 -6
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +22 -17
  18. package/src/components/PluginManagerShell.tsx +3 -3
  19. package/src/components/PluginManagerTypes.ts +2 -1
  20. package/src/components/ResumeCommand.tsx +84 -0
  21. package/src/components/ScrolledContent.tsx +71 -0
  22. package/src/components/SessionSelector.tsx +184 -6
  23. package/src/components/SessionSelectorFlow.tsx +246 -0
  24. package/src/components/WorkflowManager.tsx +38 -138
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +24 -0
  27. package/src/contexts/useChat.tsx +517 -155
  28. package/src/daemon/commands.ts +582 -50
  29. package/src/daemon-cli.ts +12 -1
  30. package/src/hooks/useInputManager.ts +20 -0
  31. package/src/hooks/usePluginManager.ts +104 -78
  32. package/src/hooks/useRemoteControl.ts +324 -0
  33. package/src/index.ts +97 -8
  34. package/src/managers/inputHandlers.ts +16 -9
  35. package/src/managers/inputReducer.ts +30 -9
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/confirmationReducer.ts +20 -1
  38. package/src/reducers/selectorReducer.ts +3 -0
  39. package/src/reducers/workflowManagerReducer.ts +21 -2
  40. package/src/remote/frameBudget.ts +45 -0
  41. package/src/remote/hostIdentity.ts +128 -0
  42. package/src/remote/messageWindow.ts +97 -0
  43. package/src/remote/notificationCoalescer.ts +174 -0
  44. package/src/remote/relayClient.ts +668 -0
  45. package/src/remote/remoteControlCliHost.ts +391 -0
  46. package/src/remote/remoteControlCommands.ts +197 -0
  47. package/src/remote/remoteControlConfig.ts +334 -0
  48. package/src/remote/remoteControlService.ts +799 -0
  49. package/src/remote/remoteControlSink.ts +77 -0
  50. package/src/remote/remoteNotifications.ts +194 -0
  51. package/src/session-selector-cli.tsx +9 -255
  52. package/src/stdio/agentBridge.ts +1001 -165
  53. package/src/stdio/daemonServer.ts +30 -0
  54. package/src/stdio/jsonRpcConnection.ts +11 -2
  55. package/src/stdio/protocol.ts +14 -106
  56. package/src/stdio/stdioServer.ts +33 -2
  57. package/src/stdio-cli.ts +16 -1
  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/sessionOwnership.ts +62 -0
  62. package/src/utils/workspaceDiff.ts +620 -0
  63. package/src/utils/worktree.ts +97 -0
  64. package/src/components/PlanDisplay.tsx +0 -18
@@ -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) {
@@ -135,15 +137,82 @@ interface DaemonSessionEntry {
135
137
  sessionId: string;
136
138
  workingDirectory: string;
137
139
  isLoading: boolean;
140
+ /**
141
+ * The other two halves of the idle predicate `wait` / `status` share with
142
+ * `wave -p` (print mode). A session can have ended its foreground turn while a
143
+ * background bash task or subagent is still running, and the last such task
144
+ * flips to `completed` before its completion notification is enqueued for the
145
+ * main agent's follow-up turn — `isLoading` alone reports "done" in both cases.
146
+ *
147
+ * Optional because the daemon is long-lived: upgrading the CLI does not
148
+ * restart it, so this client can talk to an older daemon that does not report
149
+ * them. An absent field reads as `false`, which degrades to the old
150
+ * `isLoading`-only predicate instead of misreading.
151
+ */
152
+ hasRunningBackgroundWork?: boolean;
153
+ hasPendingMessages?: boolean;
138
154
  messageCount: number;
139
155
  }
140
156
 
157
+ /**
158
+ * "This session is idle", with the same three conditions `wave -p` (print mode)
159
+ * waits on: no turn running, no background task / subagent running, and nothing
160
+ * queued. The third one closes the TOCTOU window where the last background task
161
+ * has already flipped to `completed` (so the first two read false) but its
162
+ * completion notification has not been consumed yet and is about to start a
163
+ * follow-up turn. Shared by `wait` and `status` so the two commands cannot
164
+ * disagree about whether a session is idle.
165
+ */
166
+ function isIdleSession(entry: DaemonSessionEntry): boolean {
167
+ return (
168
+ !entry.isLoading &&
169
+ entry.hasRunningBackgroundWork !== true &&
170
+ entry.hasPendingMessages !== true
171
+ );
172
+ }
173
+
141
174
  interface PendingPermission {
142
175
  requestId: string;
143
176
  sessionId?: string;
144
177
  context: ToolPermissionContext;
145
178
  }
146
179
 
180
+ /**
181
+ * Session-scoped notification filter for the `wave daemon` subcommands.
182
+ *
183
+ * The daemon broadcasts every session-scoped notification to EVERY attached
184
+ * client (`DaemonServer`'s emit loops over all connections) and only annotates
185
+ * the envelope with the owning `sessionId` — demultiplexing is the client's job,
186
+ * which is how the hosts do it (the SDK/JB `NotificationRouter` drops
187
+ * notifications for sessions it has not registered). These subcommands instead
188
+ * applied every push to their own local state, so another session's turn ending
189
+ * (`loadingChange:false`) settled a `wait` that was watching a still-generating
190
+ * session: exit code 0 with a `Status: idle` snapshot, and a foreign
191
+ * `loadingChange:true` satisfied `--from-busy` for a session that never went busy.
192
+ *
193
+ * A notification without a `sessionId` is a global one (e.g. `authUrl`), never
194
+ * another session's state, so it passes.
195
+ *
196
+ * The filter is bound to a session only once `initialize` returns, but the
197
+ * subscription must be installed BEFORE it — the attach replay that carries the
198
+ * settle-time snapshot is emitted from inside `restoreSession`
199
+ * (spec: 订阅早于 initialize/restoreSession 以接收重放). Until `bind` runs
200
+ * nothing matches, which is correct: no session of ours can push yet. See
201
+ * `attachSession`'s `onInitialized` for that hand-off point.
202
+ */
203
+ function sessionNotificationFilter(): {
204
+ bind: (sessionId: string) => void;
205
+ accepts: (sessionId: string | undefined) => boolean;
206
+ } {
207
+ let attached: string | undefined;
208
+ return {
209
+ bind: (sessionId) => {
210
+ attached = sessionId;
211
+ },
212
+ accepts: (sessionId) => sessionId === undefined || sessionId === attached,
213
+ };
214
+ }
215
+
147
216
  /**
148
217
  * Attach to a session; returns the initialized sessionId + working directory.
149
218
  * Exits (nonzero) with the spec'd error when the session exists neither in the
@@ -153,12 +222,27 @@ interface PendingPermission {
153
222
  async function attachSession(
154
223
  client: SocketClient,
155
224
  sessionId: string,
225
+ options: {
226
+ /**
227
+ * Called with the sessionId `initialize` resolved — after `initialize`,
228
+ * before `restoreSession`. That is the only moment a caller's notification
229
+ * filter can learn which session this connection is attached to: the
230
+ * subscription itself is installed earlier, the attach replay arrives later
231
+ * (see `sessionNotificationFilter`).
232
+ */
233
+ onInitialized?: (sessionId: string) => void;
234
+ } = {},
156
235
  ): Promise<{ sessionId: string; workingDirectory: string }> {
157
236
  const init = (await client.request("initialize", {
158
237
  workdir: process.cwd(),
159
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",
160
243
  })) as { sessionId: string; workingDirectory: string };
161
244
  const initId = init.sessionId;
245
+ options.onInitialized?.(initId);
162
246
  try {
163
247
  await client.request("restoreSession", { sessionId }, initId);
164
248
  } catch (err) {
@@ -182,10 +266,52 @@ async function listPendingPermissions(
182
266
  return result.requests ?? [];
183
267
  }
184
268
 
269
+ /**
270
+ * The daemon's in-memory registry entry for `sessionId`, or undefined when the
271
+ * session is no longer hosted.
272
+ *
273
+ * The registry (not notifications) is the authority on both facts the waiter
274
+ * needs: existence and whether the session is still busy. `destroy` drops
275
+ * the entry, and the only notifications that come with it are side effects of
276
+ * the agent teardown — `loadingChange:false` (destroy aborts, which clears the
277
+ * loading flag) plus a few no-op state re-emits. A `loading:false` push is
278
+ * indistinguishable from "the turn finished", so it can never be read as "the
279
+ * session is gone"; and it happens once, during the destroy, so a waiter that
280
+ * does not act on it is left with no further event and blocks forever.
281
+ *
282
+ * The same reasoning applies to the busy flags themselves: a pushed
283
+ * `loadingChange` can be a transient (an aborted turn re-dispatches a queued one
284
+ * a millisecond later) and — since the daemon broadcasts to every client, see
285
+ * `sessionNotificationFilter` — it can even describe a different session. So
286
+ * `wait` / `status` read the idle predicate (`isIdleSession`, three flags) from
287
+ * here on every wake and treat the pushes as pure wake-up signals.
288
+ */
289
+ async function findHostedSession(
290
+ client: SocketClient,
291
+ sessionId: string,
292
+ ): Promise<DaemonSessionEntry | undefined> {
293
+ const result = (await client.request("listDaemonSessions")) as {
294
+ sessions: DaemonSessionEntry[];
295
+ };
296
+ return (result.sessions ?? []).find((s) => s.sessionId === sessionId);
297
+ }
298
+
185
299
  function sleep(ms: number): Promise<void> {
186
300
  return new Promise((resolve) => setTimeout(resolve, ms));
187
301
  }
188
302
 
303
+ /** sleep() that can be cancelled, so a lost race never leaves a live timer behind. */
304
+ function sleepCancellable(ms: number): {
305
+ promise: Promise<void>;
306
+ cancel: () => void;
307
+ } {
308
+ let timer: ReturnType<typeof setTimeout>;
309
+ const promise = new Promise<void>((resolve) => {
310
+ timer = setTimeout(resolve, ms);
311
+ });
312
+ return { promise, cancel: () => clearTimeout(timer) };
313
+ }
314
+
189
315
  // ── create ─────────────────────────────────────────────────────
190
316
 
191
317
  export interface CreateOptions {
@@ -237,6 +363,9 @@ export async function daemonCreateCommand(
237
363
  workdir,
238
364
  permissionMode: mode,
239
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",
240
369
  })) as { sessionId: string };
241
370
  console.log(result.sessionId);
242
371
  if (worktreePath !== undefined) {
@@ -264,7 +393,10 @@ export async function daemonListCommand(socketPath: string): Promise<void> {
264
393
  if (sessions.length > 0) {
265
394
  const rows = sessions.map((s) => ({
266
395
  sessionId: s.sessionId,
267
- status: s.isLoading ? "generating" : "idle",
396
+ // Same idle predicate as `status` / `wait` (`isIdleSession`), so the
397
+ // three cannot disagree about a session that ended its turn but still
398
+ // owns running background work.
399
+ status: isIdleSession(s) ? "idle" : "generating",
268
400
  messageCount: String(s.messageCount),
269
401
  workingDirectory: s.workingDirectory,
270
402
  }));
@@ -398,23 +530,101 @@ function parseAskUserQuestionAnswer(
398
530
  return answers;
399
531
  }
400
532
 
533
+ interface SessionSnapshot {
534
+ sessionId: string;
535
+ workingDirectory: string;
536
+ /** `idle` / `generating` / `waiting for approval`. */
537
+ status: string;
538
+ pending: PendingPermission[];
539
+ messages: Message[];
540
+ /** Trailing messages to render; 0 renders no message text at all. */
541
+ lines: number;
542
+ }
543
+
544
+ /**
545
+ * Render the session snapshot shared by `status` and `wait` (they must be
546
+ * byte-compatible so `msg=$(wave daemon wait <id>)` yields exactly what
547
+ * `wave daemon status <id>` prints): the header, the `Status:` line, the pending
548
+ * approval list (AskUserQuestion rendered in full, see renderAskUserQuestions)
549
+ * and the last `lines` messages. `lines <= 0` renders no message text at all —
550
+ * the status-line-only shape for monitors. Guards against 0 because
551
+ * `Array.prototype.slice(-0)` means `slice(0)`, i.e. the WHOLE history.
552
+ */
553
+ function renderSessionSnapshot(snapshot: SessionSnapshot): void {
554
+ console.log(`Session: ${snapshot.sessionId}`);
555
+ console.log(`Working directory: ${snapshot.workingDirectory}`);
556
+ console.log(`Status: ${snapshot.status}`);
557
+
558
+ if (snapshot.pending.length > 0) {
559
+ console.log("");
560
+ console.log("Pending approval requests:");
561
+ for (const r of snapshot.pending) {
562
+ if (r.context.toolName === ASK_USER_QUESTION_TOOL_NAME) {
563
+ const questions = renderAskUserQuestions(r.context);
564
+ if (questions) {
565
+ console.log(` ${r.requestId} ${r.context.toolName}`);
566
+ console.log(questions);
567
+ continue;
568
+ }
569
+ }
570
+ const params = summarizeToolInput(r.context);
571
+ console.log(
572
+ ` ${r.requestId} ${r.context.toolName}${params ? ` ${params}` : ""}`,
573
+ );
574
+ }
575
+ }
576
+
577
+ const recent =
578
+ snapshot.lines > 0 ? snapshot.messages.slice(-snapshot.lines) : [];
579
+ if (recent.length > 0) {
580
+ console.log("");
581
+ console.log(`Recent messages (${recent.length}):`);
582
+ for (const m of recent) {
583
+ const text = getMessageContent(m).replace(/\s+/g, " ").trim();
584
+ if (!text) continue; // tool-only messages carry no readable text
585
+ console.log(` [${m.role}] ${text}`);
586
+ }
587
+ }
588
+ }
589
+
590
+ /**
591
+ * `wave daemon status <sessionId> [--lines N]`.
592
+ *
593
+ * `lines` is the number of trailing messages to render (default 1 — the last
594
+ * message alone, so the default output stays bounded even when one message is a
595
+ * multi-thousand-character report). `lines <= 0` renders no message text at all
596
+ * (just the session header + `Status:` line) — the polling shape for monitors
597
+ * that only watch the status. A one-shot snapshot that always returns
598
+ * immediately; blocking on a state change is `daemonWaitCommand`.
599
+ *
600
+ * `Status:` uses the same idle predicate `wait` blocks on (`isIdleSession`):
601
+ * `generating` covers "the turn is running" AND "the turn ended but background
602
+ * work is still running", so `status` never says `idle` for a session a `wait`
603
+ * would keep blocking on.
604
+ */
401
605
  export async function daemonStatusCommand(
402
606
  socketPath: string,
403
607
  sessionId: string,
404
- lines = 20,
608
+ lines = 1,
405
609
  ): Promise<void> {
406
610
  let client: SocketClient | undefined;
407
611
  try {
408
612
  client = await connectDaemonOrExit(socketPath);
409
613
 
410
614
  // Subscribe BEFORE initialize/restoreSession so the replayed loadingChange
411
- // snapshot is captured (spec: 依据重放的 loadingChange 快照显示状态).
615
+ // snapshot is captured (spec: 依据重放的 loadingChange 快照显示状态); drop
616
+ // other sessions' pushes so the snapshot reports THIS session (a foreign
617
+ // turn's loadingChange used to flip this flag).
412
618
  let loading = false;
413
- client.onNotification("loadingChange", (params) => {
619
+ const filter = sessionNotificationFilter();
620
+ client.onNotification("loadingChange", (params, sessionId) => {
621
+ if (!filter.accepts(sessionId)) return;
414
622
  loading = (params as { loading: boolean }).loading;
415
623
  });
416
624
 
417
- const init = await attachSession(client, sessionId);
625
+ const init = await attachSession(client, sessionId, {
626
+ onInitialized: filter.bind,
627
+ });
418
628
  const initId = init.sessionId;
419
629
 
420
630
  // listPendingPermissions is the authoritative "waiting for approval" signal
@@ -422,6 +632,14 @@ export async function daemonStatusCommand(
422
632
  const pending = (await listPendingPermissions(client)).filter(
423
633
  (r) => r.sessionId === initId || r.sessionId === sessionId,
424
634
  );
635
+ // The idle decision uses the SAME predicate `wait` blocks on (`isIdleSession`,
636
+ // three flags) so the two commands cannot disagree: a session whose turn ended
637
+ // while background work keeps running (or with a completion notification still
638
+ // queued) is not idle, and reporting idle here would contradict a `wait` still
639
+ // blocked on it. The registry answers authoritatively; the replayed
640
+ // `loadingChange` snapshot above is the fallback for a session that vanished
641
+ // between the attach and this read.
642
+ const hosted = await findHostedSession(client, initId);
425
643
  const messages = (await client.request(
426
644
  "getMessages",
427
645
  undefined,
@@ -430,51 +648,328 @@ export async function daemonStatusCommand(
430
648
  messages: Message[];
431
649
  };
432
650
 
651
+ const busy = hosted ? !isIdleSession(hosted) : loading;
433
652
  const status =
434
653
  pending.length > 0
435
654
  ? "waiting for approval"
436
- : loading
655
+ : busy
437
656
  ? "generating"
438
657
  : "idle";
439
- console.log(`Session: ${initId}`);
440
- console.log(`Working directory: ${init.workingDirectory}`);
441
- console.log(`Status: ${status}`);
442
-
443
- if (pending.length > 0) {
444
- console.log("");
445
- console.log("Pending approval requests:");
446
- for (const r of pending) {
447
- if (r.context.toolName === ASK_USER_QUESTION_TOOL_NAME) {
448
- const questions = renderAskUserQuestions(r.context);
449
- if (questions) {
450
- console.log(` ${r.requestId} ${r.context.toolName}`);
451
- console.log(questions);
452
- continue;
453
- }
658
+ renderSessionSnapshot({
659
+ sessionId: initId,
660
+ workingDirectory: init.workingDirectory,
661
+ status,
662
+ pending,
663
+ messages: messages.messages,
664
+ lines,
665
+ });
666
+ } catch (err) {
667
+ fail(`wave daemon status failed: ${(err as Error).message}`);
668
+ } finally {
669
+ await client?.dispose();
670
+ }
671
+ process.exit(0);
672
+ }
673
+
674
+ // ── wait ───────────────────────────────────────────────────────
675
+
676
+ export interface WaitOptions {
677
+ /** Recent messages to render on exit (default 1; 0 = status line only). */
678
+ lines?: number;
679
+ /** Require at least one observed busy phase before accepting idle. */
680
+ fromBusy?: boolean;
681
+ /** Seconds to wait before giving up; undefined waits forever. */
682
+ timeout?: number;
683
+ }
684
+
685
+ /**
686
+ * How often the wait re-asks the daemon registry (`listDaemonSessions`) and the
687
+ * pending-approval list (`listPendingPermissions`) — the facts that cannot be
688
+ * trusted to a push. Approvals push nothing at all (spec: 单凭消息无法区分等审批与执行中);
689
+ * a destroyed session pushes only `loadingChange:false`, which is what a
690
+ * finished turn looks like; and the loading flag itself is re-read from the
691
+ * registry rather than taken from the push. This is "go ask the daemon about
692
+ * the facts", not the "poll the status every N seconds" pattern the push-driven
693
+ * wait exists to replace (spec: `wait` 的退出码是契约): a wake-up still comes
694
+ * from our own session's `loadingChange`, and this tick is only the backstop for
695
+ * pushes that never arrive. A local socket round-trip is cheap but busy-polling
696
+ * it is still wrong — never faster than 1s. Mutable so tests can shorten it.
697
+ */
698
+ export const waitPollInterval = { ms: 2_000 };
699
+
700
+ /**
701
+ * `wave daemon wait <sessionId> [--lines N] [--from-busy] [--timeout <秒>]`.
702
+ *
703
+ * Block until the session settles, print the final snapshot — byte-compatible
704
+ * with `wave daemon status <sessionId> --lines N`, so
705
+ * `msg=$(wave daemon wait <id>)` captures the report itself — and exit:
706
+ * `0` = idle, `3` = waiting for permission approval (returned immediately,
707
+ * never waited out), `1` = error (unreachable daemon / unknown session /
708
+ * `--timeout` elapsed). stdout carries the snapshot only; progress goes to
709
+ * stderr.
710
+ *
711
+ * Idle is decided from the daemon registry, woken by pushes. The
712
+ * `loadingChange` / `backgroundTasksChange` subscriptions (installed BEFORE
713
+ * initialize/restoreSession, so the attach replay is not missed) are filtered to
714
+ * THIS session and used purely as wake-ups: the loop then re-reads the
715
+ * authoritative idle predicate (`isIdleSession`) from the registry it already
716
+ * queries for the existence check — the daemon is never polled for status on a
717
+ * timer. Reading the flags from the registry rather than from a notification
718
+ * matters because a pushed `loadingChange` can be a transient (an aborted turn
719
+ * re-dispatches a queued one a millisecond later) and, since the daemon
720
+ * broadcasts to every client, it can even describe another session
721
+ * (`sessionNotificationFilter`).
722
+ *
723
+ * "Idle" is the same three conditions `wave -p` (print mode) waits on — no turn
724
+ * running, no background task / subagent running, nothing queued — because
725
+ * `isLoading` alone reports "done" for a session whose foreground turn ended
726
+ * while the background work it launched is still going (the turn-end clears the
727
+ * flag; the background task's completion notification only arrives later and
728
+ * starts a follow-up turn). `backgroundTasksChange` is therefore a wake source
729
+ * of its own: without it, "the background task started/finished" would only be
730
+ * noticed on the 2s backstop tick.
731
+ *
732
+ * Decision order is existence → pending (3) → idle (0) → timeout (1).
733
+ * Existence comes first because a destroyed session's only announcement is
734
+ * `loadingChange:false` (destroy aborts the turn), which is indistinguishable
735
+ * from a finish: read as idle it reports a false "done" for a session that is
736
+ * gone, and a waiter that does not act on it gets no further event at all and
737
+ * hangs (which is what happened before this check existed).
738
+ *
739
+ * A session that gets RE-KEYED while we block on it is followed, not mourned:
740
+ * `clearMessages` / `initializeFromSession` / a recovery-recreating
741
+ * `updateConfig` move the daemon registry entry to a new sessionId and announce
742
+ * it with `sessionIdChange` (envelope = the id we know, payload = the new one),
743
+ * so the waiter rebinds its filter and its id and keeps watching the same
744
+ * session — a re-key is not a destroy (spec: 换键不等于销毁).
745
+ */
746
+ export async function daemonWaitCommand(
747
+ socketPath: string,
748
+ sessionId: string,
749
+ options: WaitOptions = {},
750
+ ): Promise<void> {
751
+ const lines = options.lines ?? 1;
752
+ const fromBusy = options.fromBusy ?? false;
753
+ const timeoutMs =
754
+ options.timeout !== undefined ? options.timeout * 1000 : undefined;
755
+
756
+ let client: SocketClient | undefined;
757
+ let exitCode = 0;
758
+ try {
759
+ const socket = await connectDaemonOrExit(socketPath);
760
+ client = socket;
761
+
762
+ let sawBusy = false;
763
+ // Push-driven wake: the deferred is re-armed on every bump and the loop
764
+ // re-reads the registry after every await, so no state change can be
765
+ // missed. Foreign sessions are filtered out so a crowded daemon cannot turn
766
+ // their pushes into wake-ups (spec: 只认本会话的推送).
767
+ let resolveWake: (() => void) | undefined;
768
+ let wakePromise = new Promise<void>((resolve) => {
769
+ resolveWake = resolve;
770
+ });
771
+ const bump = () => {
772
+ const resolve = resolveWake;
773
+ wakePromise = new Promise<void>((r) => {
774
+ resolveWake = r;
775
+ });
776
+ resolve?.();
777
+ };
778
+ const filter = sessionNotificationFilter();
779
+ socket.onNotification("loadingChange", (params, sid) => {
780
+ if (!filter.accepts(sid)) return;
781
+ // The payload is only a hint that OUR session changed: `--from-busy`
782
+ // accepts it as one way to observe the busy phase, while the idle
783
+ // decision below comes from the registry.
784
+ if ((params as { loading: boolean }).loading) sawBusy = true;
785
+ bump();
786
+ });
787
+ // Background tasks starting or finishing change the idle predicate without
788
+ // touching `isLoading` (a foreground turn can end while its background bash
789
+ // task / subagent keeps running), so this push is a wake source too —
790
+ // otherwise "it finished" would only be seen on the low-frequency backstop
791
+ // tick. Payload is ignored: the registry is re-read, exactly as above.
792
+ socket.onNotification("backgroundTasksChange", (_params, sid) => {
793
+ if (!filter.accepts(sid)) return;
794
+ bump();
795
+ });
796
+
797
+ // A session can be RE-KEYED while we block on it — `clearMessages` (which
798
+ // mints a fresh id) or `initializeFromSession` with another id moves the
799
+ // daemon registry entry to a new sessionId; `updateConfig` does the same when
800
+ // its recreate cannot restore the transcript. The re-key is announced (this
801
+ // notification, envelope = the id we know, payload = the new one) and the
802
+ // registry key moves in the same tick, so a waiter that keeps using the old id
803
+ // both misses it in `listDaemonSessions` (⇒ false "no longer exists", exit 1,
804
+ // for a session that is alive) and stops receiving its pushes. Follow the
805
+ // rename instead: same session, new id — nothing about it was destroyed.
806
+ // `watch.id` is the id this waiter tracks: empty until the attach below binds
807
+ // it, then the live id, re-pointed by every rename we see.
808
+ const watch = { id: "" };
809
+ socket.onNotification("sessionIdChange", (params, sid) => {
810
+ const next = (params as { sessionId?: string }).sessionId;
811
+ // Envelope-less renames are fired while an agent is being CREATED (its
812
+ // context has no id yet): they belong to the attach handshake below, whose
813
+ // reply carries the final id, so there is nothing to follow here. A rename
814
+ // seen before we know our id would mean the daemon re-keyed the session mid
815
+ // handshake — the attach itself gives up on that (its `restoreSession` still
816
+ // carries the pre-rename id), which is a separate, pre-existing gap.
817
+ if (sid === undefined || !next || !watch.id) return;
818
+ if (!filter.accepts(sid)) return; // another session's rename
819
+ watch.id = next;
820
+ filter.bind(next);
821
+ bump();
822
+ });
823
+
824
+ const init = await attachSession(socket, sessionId, {
825
+ onInitialized: (id) => {
826
+ watch.id = id;
827
+ filter.bind(id);
828
+ },
829
+ });
830
+
831
+ // Read against the tracked session, retrying when a re-key lands while the
832
+ // read is in flight. The daemon announces the rename and moves the registry
833
+ // entry in one tick, but a request sent moments earlier under the OLD id is
834
+ // answered only afterwards — and its answer then describes an id that no
835
+ // longer exists: a miss for a session that is very much alive ("no longer
836
+ // exists" / exit 1) or a "Session not found" from the settle read. An answer
837
+ // that a re-key invalidated is not evidence, so ask again on the new id. The
838
+ // loop only repeats when a rename notification actually arrived, so it cannot
839
+ // spin without the daemon re-keying the session over and over.
840
+ const read = async <T>(fn: (id: string) => Promise<T>): Promise<T> => {
841
+ for (;;) {
842
+ const id = watch.id;
843
+ try {
844
+ const result = await fn(id);
845
+ if (watch.id !== id) continue;
846
+ return result;
847
+ } catch (err) {
848
+ if (watch.id !== id) continue;
849
+ throw err;
454
850
  }
455
- const params = summarizeToolInput(r.context);
456
- console.log(
457
- ` ${r.requestId} ${r.context.toolName}${params ? ` ${params}` : ""}`,
458
- );
459
851
  }
460
- }
852
+ };
853
+
854
+ // listPendingPermissions is the authoritative "waiting for approval" signal
855
+ // (spec: 单凭消息无法区分等审批与执行中,须结合 listPendingPermissions).
856
+ const pendingForSession = (): Promise<PendingPermission[]> =>
857
+ read(async (id) => {
858
+ const all = await listPendingPermissions(socket);
859
+ return all.filter(
860
+ (r) =>
861
+ r.sessionId === id ||
862
+ r.sessionId === init.sessionId ||
863
+ r.sessionId === sessionId,
864
+ );
865
+ });
461
866
 
462
- const recent = messages.messages.slice(-lines);
463
- if (recent.length > 0) {
464
- console.log("");
465
- console.log(`Recent messages (${recent.length}):`);
466
- for (const m of recent) {
467
- const text = getMessageContent(m).replace(/\s+/g, " ").trim();
468
- if (!text) continue; // tool-only messages carry no readable text
469
- console.log(` [${m.role}] ${text}`);
867
+ const settle = async (status: string, pending: PendingPermission[]) => {
868
+ const result = (await read(
869
+ (id) =>
870
+ socket.request("getMessages", undefined, id) as Promise<{
871
+ messages: Message[];
872
+ }>,
873
+ )) as {
874
+ messages: Message[];
875
+ };
876
+ renderSessionSnapshot({
877
+ sessionId: watch.id,
878
+ workingDirectory: init.workingDirectory,
879
+ status,
880
+ pending,
881
+ messages: result.messages ?? [],
882
+ lines,
883
+ });
884
+ };
885
+
886
+ const deadline =
887
+ timeoutMs !== undefined ? Date.now() + timeoutMs : undefined;
888
+ let announced = false;
889
+ while (true) {
890
+ // Session existence FIRST (spec: 阻塞等待会话空闲 场景 10). A session
891
+ // destroyed by another client — `wave daemon destroy <id>` while this
892
+ // daemon keeps running — announces itself only as `loadingChange:false`
893
+ // (destroy aborts the turn, which clears the loading flag) plus a few
894
+ // no-op state re-emits. That is not a usable signal: `loading:false` is
895
+ // exactly what "the turn finished" looks like, so a waiter that does not
896
+ // act on the wake is left with no further event and blocks forever —
897
+ // which is what happened before this check existed. Ask the registry
898
+ // instead: this query also carries the authoritative idle predicate
899
+ // (`isIdleSession`) the decision below uses. It rides the same
900
+ // low-frequency fallback tick as the approval check — that tick is the
901
+ // backstop, while the ordinary wake-ups are our own session's
902
+ // `loadingChange` / `backgroundTasksChange`. The order matters: the
903
+ // destroy's `loadingChange:false` describes a session whose registry entry
904
+ // is about to disappear, so deciding idle first could report a settled
905
+ // session that no longer exists — a false success is worse than hanging.
906
+ // A miss is never an unfollowed rename: the daemon writes
907
+ // `sessionIdChange` and moves the registry entry in the same tick, and a
908
+ // read answered under an id a rename superseded is retried rather than
909
+ // trusted (`read` above). What is left is a session that really is gone.
910
+ const hosted = await read((id) => findHostedSession(socket, id));
911
+ if (!hosted) {
912
+ fail(
913
+ `wave daemon wait failed: Session ${sessionId} no longer exists (destroyed while waiting)`,
914
+ );
915
+ }
916
+ const idle = isIdleSession(hosted);
917
+ // A busy reading from the registry is an observed busy phase too:
918
+ // `--from-busy` must not depend on having caught a `loadingChange:true`
919
+ // push (a turn that started before this waiter attached pushes none, and
920
+ // the replay is only as fresh as the moment of the attach). "Busy" is the
921
+ // complement of the idle predicate, so a session that is only running
922
+ // background work (or has a completion notification still queued) counts as
923
+ // observed busy — otherwise `--from-busy` would wait for a busy phase that
924
+ // has already happened and never accept the idle that follows.
925
+ if (!idle) sawBusy = true;
926
+ // Approvals beat idle, and the check runs BEFORE the idle decision — a
927
+ // session frozen on an approval keeps `loading: true` (so it would never
928
+ // settle) and one that just turned `loading: false` must not be mistaken
929
+ // for finished before the pending list is consulted.
930
+ const pending = await pendingForSession();
931
+ if (pending.length > 0) {
932
+ await settle("waiting for approval", pending);
933
+ exitCode = 3;
934
+ break;
470
935
  }
936
+ // Idle comes from the registry, never from a single push: already idle at
937
+ // call time settles immediately — never hanging beats winning the race.
938
+ // --from-busy holds out for an observed busy phase first: right after an
939
+ // async `send` the replayed snapshot can still be a stale loading:false
940
+ // (the turn has not started yet).
941
+ if (idle && (!fromBusy || sawBusy)) {
942
+ await settle("idle", []);
943
+ break;
944
+ }
945
+ if (deadline !== undefined && Date.now() >= deadline) {
946
+ fail(
947
+ `Timed out waiting for session ${sessionId} to become idle (waited ${options.timeout}s); it is still busy (generating, or running background work)`,
948
+ );
949
+ }
950
+ if (!announced) {
951
+ announced = true;
952
+ console.error(`Waiting for session ${watch.id} to become idle…`);
953
+ }
954
+ const tick = sleepCancellable(waitPollInterval.ms);
955
+ const timer =
956
+ deadline !== undefined
957
+ ? sleepCancellable(Math.max(0, deadline - Date.now()))
958
+ : undefined;
959
+ await Promise.race([
960
+ wakePromise,
961
+ tick.promise,
962
+ ...(timer ? [timer.promise] : []),
963
+ ]);
964
+ tick.cancel();
965
+ timer?.cancel();
471
966
  }
472
967
  } catch (err) {
473
- fail(`wave daemon status failed: ${(err as Error).message}`);
968
+ fail(`wave daemon wait failed: ${(err as Error).message}`);
474
969
  } finally {
475
970
  await client?.dispose();
476
971
  }
477
- process.exit(0);
972
+ process.exit(exitCode);
478
973
  }
479
974
 
480
975
  // ── send ───────────────────────────────────────────────────────
@@ -503,6 +998,11 @@ export interface SendOptions {
503
998
  * (userMessageAdded), and the reply is the last assistantMessageAdded observed
504
999
  * after it. A stale loading:false can then never satisfy the wait condition
505
1000
  * early (the reply has not been added yet).
1001
+ *
1002
+ * The tracked session id follows a re-key (`sessionIdChange`) for the same reason
1003
+ * `wait` does: the reply arrives on the NEW id, so a waiter still bound to the old
1004
+ * one would drop the pushes that complete it and report a false timeout, and its
1005
+ * `getMessages` would come back "Session not found".
506
1006
  */
507
1007
  export async function daemonSendCommand(
508
1008
  socketPath: string,
@@ -517,21 +1017,48 @@ export async function daemonSendCommand(
517
1017
  let sent = false;
518
1018
  let ourUserMessageId: string | undefined;
519
1019
  let replyMessageId: string | undefined;
520
- client.onNotification("userMessageAdded", (params) => {
1020
+ // Only THIS session's pushes may move the state below: the daemon broadcasts
1021
+ // every session's notifications to every client, so an unfiltered
1022
+ // `userMessageAdded` / `assistantMessageAdded` / `loadingChange` from a
1023
+ // concurrent session could report "sent" before our message landed and pair
1024
+ // the reply tracking with another conversation's messages
1025
+ // (see sessionNotificationFilter).
1026
+ const filter = sessionNotificationFilter();
1027
+ // `watch.id` is the session this send is attached to — empty until the attach
1028
+ // binds it, then the live id, re-pointed when the daemon re-keys the session
1029
+ // (see the matching handler in daemonWaitCommand): completion is detected from
1030
+ // THIS session's pushes, so a waiter left on the pre-rename id would drop them
1031
+ // and report a false `--wait` timeout for a turn that is still running.
1032
+ const watch = { id: "" };
1033
+ client.onNotification("sessionIdChange", (params, sessionId) => {
1034
+ const next = (params as { sessionId?: string }).sessionId;
1035
+ if (sessionId === undefined || !next || !watch.id) return;
1036
+ if (!filter.accepts(sessionId)) return;
1037
+ watch.id = next;
1038
+ filter.bind(next);
1039
+ });
1040
+ client.onNotification("userMessageAdded", (params, sessionId) => {
1041
+ if (!filter.accepts(sessionId)) return;
521
1042
  if (!sent) return; // ignore messages added during attach
522
1043
  ourUserMessageId = (params as { message: Message }).message.id;
523
1044
  });
524
- client.onNotification("assistantMessageAdded", (params) => {
1045
+ client.onNotification("assistantMessageAdded", (params, sessionId) => {
1046
+ if (!filter.accepts(sessionId)) return;
525
1047
  if (!sent || ourUserMessageId === undefined) return; // not our turn yet
526
1048
  replyMessageId = (params as { message: Message }).message.id;
527
1049
  });
528
- client.onNotification("loadingChange", (params) => {
1050
+ client.onNotification("loadingChange", (params, sessionId) => {
1051
+ if (!filter.accepts(sessionId)) return;
529
1052
  loading = (params as { loading: boolean }).loading;
530
1053
  });
531
1054
 
532
- let initId: string;
533
1055
  try {
534
- initId = (await attachSession(client, sessionId)).sessionId;
1056
+ await attachSession(client, sessionId, {
1057
+ onInitialized: (id) => {
1058
+ watch.id = id;
1059
+ filter.bind(id);
1060
+ },
1061
+ });
535
1062
  sent = true;
536
1063
  } catch (err) {
537
1064
  client.dispose();
@@ -551,10 +1078,11 @@ export async function daemonSendCommand(
551
1078
  const sendPromise = client.request(
552
1079
  "sendMessage",
553
1080
  { text: message },
554
- initId,
1081
+ watch.id,
555
1082
  );
556
1083
  const userMessage = new Promise<"userMessageAdded">((resolve) => {
557
- client.onNotification("userMessageAdded", () => {
1084
+ client.onNotification("userMessageAdded", (params, sessionId) => {
1085
+ if (!filter.accepts(sessionId)) return;
558
1086
  if (ourUserMessageId !== undefined) resolve("userMessageAdded");
559
1087
  });
560
1088
  });
@@ -570,7 +1098,7 @@ export async function daemonSendCommand(
570
1098
  }
571
1099
 
572
1100
  try {
573
- await client.request("sendMessage", { text: message }, initId);
1101
+ await client.request("sendMessage", { text: message }, watch.id);
574
1102
  } catch (err) {
575
1103
  client.dispose();
576
1104
  fail(`wave daemon send failed: ${(err as Error).message}`);
@@ -586,7 +1114,7 @@ export async function daemonSendCommand(
586
1114
  // Timeout backstop: the most likely cause is a session waiting on a
587
1115
  // permission approval — point the user at respond (spec: 不无限期挂起).
588
1116
  const pending = (await listPendingPermissions(client)).filter(
589
- (r) => r.sessionId === sessionId || r.sessionId === initId,
1117
+ (r) => r.sessionId === sessionId || r.sessionId === watch.id,
590
1118
  );
591
1119
  client.dispose();
592
1120
  if (pending.length > 0) {
@@ -602,7 +1130,11 @@ export async function daemonSendCommand(
602
1130
  }
603
1131
 
604
1132
  try {
605
- const result = (await client.request("getMessages", undefined, initId)) as {
1133
+ const result = (await client.request(
1134
+ "getMessages",
1135
+ undefined,
1136
+ watch.id,
1137
+ )) as {
606
1138
  messages: Message[];
607
1139
  };
608
1140
  const reply = result.messages.find((m) => m.id === replyMessageId);
@@ -844,7 +1376,7 @@ export const daemonStopTimeout = { ms: 10_000 };
844
1376
  const DAEMON_STOP_POLL_INTERVAL_MS = 200;
845
1377
 
846
1378
  /** Connect to the daemon socket without starting one; undefined when it is not running. */
847
- async function tryConnectDaemon(
1379
+ export async function connectDaemonIfRunning(
848
1380
  socketPath: string,
849
1381
  ): Promise<SocketClient | undefined> {
850
1382
  try {
@@ -858,7 +1390,7 @@ async function tryConnectDaemon(
858
1390
  async function waitForDaemonExit(socketPath: string): Promise<void> {
859
1391
  const deadline = Date.now() + daemonStopTimeout.ms;
860
1392
  while (Date.now() < deadline) {
861
- const client = await tryConnectDaemon(socketPath);
1393
+ const client = await connectDaemonIfRunning(socketPath);
862
1394
  if (!client) return;
863
1395
  client.dispose();
864
1396
  await sleep(DAEMON_STOP_POLL_INTERVAL_MS);
@@ -876,7 +1408,7 @@ async function waitForDaemonExit(socketPath: string): Promise<void> {
876
1408
  * Throws when a running daemon fails to exit in time.
877
1409
  */
878
1410
  async function stopDaemon(socketPath: string): Promise<boolean> {
879
- const client = await tryConnectDaemon(socketPath);
1411
+ const client = await connectDaemonIfRunning(socketPath);
880
1412
  if (!client) return false;
881
1413
  try {
882
1414
  // The daemon may drop the socket before responding to the RPC — the wait