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