wave-code 1.1.5 → 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 (46) hide show
  1. package/dist/bundle/wave.mjs +1075 -613
  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 +3 -0
  7. package/src/components/ConfirmationDetails.tsx +36 -15
  8. package/src/components/ConfirmationSelector.tsx +39 -0
  9. package/src/components/HooksManager.tsx +286 -0
  10. package/src/components/InputBox.tsx +125 -51
  11. package/src/components/Markdown.tsx +21 -8
  12. package/src/components/MarketplaceDetail.tsx +10 -27
  13. package/src/components/MarketplaceView.tsx +17 -3
  14. package/src/components/MessageBlockItem.tsx +0 -5
  15. package/src/components/MessageList.tsx +0 -1
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +146 -0
  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/constants/commands.ts +30 -0
  25. package/src/contexts/useChat.tsx +252 -98
  26. package/src/daemon/commands.ts +1046 -67
  27. package/src/daemon-cli.ts +6 -0
  28. package/src/hooks/useInputManager.ts +30 -0
  29. package/src/hooks/useLineScroll.ts +58 -0
  30. package/src/hooks/usePluginManager.ts +104 -78
  31. package/src/index.ts +156 -16
  32. package/src/managers/inputHandlers.ts +16 -9
  33. package/src/managers/inputReducer.ts +39 -9
  34. package/src/reducers/confirmationReducer.ts +20 -1
  35. package/src/reducers/hooksManagerReducer.ts +92 -0
  36. package/src/reducers/selectorReducer.ts +3 -0
  37. package/src/session-selector-cli.tsx +9 -255
  38. package/src/stdio/agentBridge.ts +932 -133
  39. package/src/stdio/daemonServer.ts +26 -82
  40. package/src/stdio/protocol.ts +23 -4
  41. package/src/stdio-cli.ts +60 -0
  42. package/src/utils/rewindCheckpoints.ts +2 -2
  43. package/src/utils/sessionOwnership.ts +62 -0
  44. package/src/utils/worktree.ts +175 -50
  45. package/src/components/BangDisplay.tsx +0 -41
  46. package/src/components/PlanDisplay.tsx +0 -18
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * `wave daemon` client subcommands — talk to the wave daemon's unix socket
3
- * (JSON-RPC over newline-delimited JSON) to list hosted sessions, inspect
4
- * progress, inject messages, respond to pending permission requests and abort
5
- * in-flight message generation.
3
+ * (JSON-RPC over newline-delimited JSON) to create/destroy sessions, list
4
+ * hosted sessions, inspect progress, inject messages, respond to pending
5
+ * permission requests, abort in-flight message generation, and stop/restart
6
+ * the daemon itself. When the daemon is not running (stopped / killed /
7
+ * machine reboot), every subcommand except `stop` starts one on demand
8
+ * (detached --daemon spawn, the local equivalent of the remote nohup
9
+ * launcher) and retries the connection — once started the daemon stays
10
+ * resident (it does not exit on idle), so a subcommand usually finds it up.
11
+ * `stop` never auto-starts: with no daemon running it is an idempotent no-op.
6
12
  *
7
13
  * All subcommands are non-interactive: results go to stdout, diagnostics to
8
14
  * stderr, and every handler calls process.exit() itself (yargs would fall
@@ -19,6 +25,7 @@
19
25
  * must be destroyed via the envelope sessionId returned by `initialize`.
20
26
  */
21
27
 
28
+ import { execFile, spawn } from "node:child_process";
22
29
  import net from "node:net";
23
30
  import os from "node:os";
24
31
  import path from "node:path";
@@ -26,7 +33,11 @@ import {
26
33
  ASK_USER_QUESTION_TOOL_NAME,
27
34
  ENTER_PLAN_MODE_TOOL_NAME,
28
35
  EXIT_PLAN_MODE_TOOL_NAME,
36
+ getGitMainRepoRoot,
29
37
  getMessageContent,
38
+ hasWorktreeCreateHook,
39
+ loadMergedWaveConfig,
40
+ type AskUserQuestion,
30
41
  type Message,
31
42
  type PermissionDecision,
32
43
  type PermissionMode,
@@ -49,6 +60,10 @@ const PERMISSION_MODES: PermissionMode[] = [
49
60
  "dontAsk",
50
61
  ];
51
62
 
63
+ /** How long to wait for an auto-started daemon's socket to come up (mutable so tests can shorten it). */
64
+ export const daemonStartTimeout = { ms: 10_000 };
65
+ const DAEMON_POLL_INTERVAL_MS = 500;
66
+
52
67
  // ── Connection helpers ─────────────────────────────────────────
53
68
 
54
69
  function connectDaemon(socketPath: string): Promise<SocketClient> {
@@ -62,14 +77,49 @@ function connectDaemon(socketPath: string): Promise<SocketClient> {
62
77
  });
63
78
  }
64
79
 
65
- /** Connect or fail fast with the spec'd error; daemon idle-exits after 60s. */
80
+ /**
81
+ * Start a wave daemon on demand, detached — the local equivalent of the remote
82
+ * nohup launcher `nohup <wave> --daemon <socket> </dev/null >/dev/null 2>&1 &`
83
+ * (spec: daemon-command.md 按需即用). Re-execs the current wave CLI (`node
84
+ * <this script> --daemon <socket>`) with no stdio and unrefs the child so the
85
+ * client exits without waiting; the daemon cleans stale socket files itself on
86
+ * start.
87
+ */
88
+ function startDaemon(socketPath: string): void {
89
+ const entry = process.argv[1];
90
+ if (!entry) {
91
+ fail("Cannot start the wave daemon: unknown CLI entry path");
92
+ }
93
+ const child = spawn(process.execPath, [entry, "--daemon", socketPath], {
94
+ detached: true,
95
+ stdio: "ignore",
96
+ });
97
+ child.unref();
98
+ }
99
+
100
+ /**
101
+ * Connect or start the daemon on demand: try the socket, and when the daemon is
102
+ * not running, launch one detached and retry until its socket accepts a
103
+ * connection (or daemonStartTimeout.ms elapses). Exits (nonzero) with the
104
+ * spec'd error when the daemon cannot be started/reached.
105
+ */
66
106
  async function connectDaemonOrExit(socketPath: string): Promise<SocketClient> {
67
107
  try {
68
108
  return await connectDaemon(socketPath);
69
109
  } catch (err) {
70
110
  const code = (err as NodeJS.ErrnoException).code;
111
+ startDaemon(socketPath);
112
+ const deadline = Date.now() + daemonStartTimeout.ms;
113
+ while (Date.now() < deadline) {
114
+ await sleep(DAEMON_POLL_INTERVAL_MS);
115
+ try {
116
+ return await connectDaemon(socketPath);
117
+ } catch {
118
+ // Daemon still coming up — keep polling.
119
+ }
120
+ }
71
121
  console.error(
72
- `Cannot connect to daemon socket ${socketPath}: is the daemon running? (it auto-exits after 60s idle)` +
122
+ `Cannot connect to daemon socket ${socketPath}: started a daemon but it did not come up` +
73
123
  (code ? ` (${code})` : ""),
74
124
  );
75
125
  process.exit(1);
@@ -85,15 +135,82 @@ interface DaemonSessionEntry {
85
135
  sessionId: string;
86
136
  workingDirectory: string;
87
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;
88
152
  messageCount: number;
89
153
  }
90
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
+
91
172
  interface PendingPermission {
92
173
  requestId: string;
93
174
  sessionId?: string;
94
175
  context: ToolPermissionContext;
95
176
  }
96
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
+
97
214
  /**
98
215
  * Attach to a session; returns the initialized sessionId + working directory.
99
216
  * Exits (nonzero) with the spec'd error when the session exists neither in the
@@ -103,12 +220,23 @@ interface PendingPermission {
103
220
  async function attachSession(
104
221
  client: SocketClient,
105
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
+ } = {},
106
233
  ): Promise<{ sessionId: string; workingDirectory: string }> {
107
234
  const init = (await client.request("initialize", {
108
235
  workdir: process.cwd(),
109
236
  restoreSessionId: sessionId,
110
237
  })) as { sessionId: string; workingDirectory: string };
111
238
  const initId = init.sessionId;
239
+ options.onInitialized?.(initId);
112
240
  try {
113
241
  await client.request("restoreSession", { sessionId }, initId);
114
242
  } catch (err) {
@@ -132,10 +260,116 @@ async function listPendingPermissions(
132
260
  return result.requests ?? [];
133
261
  }
134
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
+
135
293
  function sleep(ms: number): Promise<void> {
136
294
  return new Promise((resolve) => setTimeout(resolve, ms));
137
295
  }
138
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
+
309
+ // ── create ─────────────────────────────────────────────────────
310
+
311
+ export interface CreateOptions {
312
+ /** Working directory for the new session (default: current directory). */
313
+ workdir?: string;
314
+ /** Permission mode for the new session (default: bypassPermissions). */
315
+ permissionMode?: string;
316
+ /** Model override for the new session (default: configured model). */
317
+ model?: string;
318
+ /** Create the session in a new git worktree (name auto-generated when omitted). */
319
+ worktree?: string;
320
+ }
321
+
322
+ /**
323
+ * Create a fresh session in the daemon. initialize WITHOUT restoreSessionId
324
+ * always creates a brand-new session (never an attach), so no existence checks
325
+ * are needed. Defaults mirror the daemon's background-task use case: workdir =
326
+ * current directory, permissionMode = bypassPermissions. With --worktree the
327
+ * daemon first creates a git worktree (protocol createWorktree) and the session
328
+ * is created inside it. Prints the new sessionId (first line, for scripts),
329
+ * plus the worktree path/branch when --worktree was used.
330
+ */
331
+ export async function daemonCreateCommand(
332
+ socketPath: string,
333
+ options: CreateOptions = {},
334
+ ): Promise<void> {
335
+ const mode = options.permissionMode ?? "bypassPermissions";
336
+ if (!PERMISSION_MODES.includes(mode as PermissionMode)) {
337
+ fail(
338
+ `Invalid permission mode: ${mode} (options: ${PERMISSION_MODES.join(", ")})`,
339
+ );
340
+ }
341
+ let client: SocketClient | undefined;
342
+ try {
343
+ client = await connectDaemonOrExit(socketPath);
344
+ let workdir = options.workdir ?? process.cwd();
345
+ let worktreePath: string | undefined;
346
+ let worktreeBranch: string | undefined;
347
+ if (options.worktree !== undefined) {
348
+ const wt = (await client.request("createWorktree", {
349
+ workdir,
350
+ name: options.worktree,
351
+ })) as { name: string; path: string; branch: string; repoRoot: string };
352
+ workdir = wt.path;
353
+ worktreePath = wt.path;
354
+ worktreeBranch = wt.branch;
355
+ }
356
+ const result = (await client.request("initialize", {
357
+ workdir,
358
+ permissionMode: mode,
359
+ model: options.model,
360
+ })) as { sessionId: string };
361
+ console.log(result.sessionId);
362
+ if (worktreePath !== undefined) {
363
+ console.log(`Worktree: ${worktreePath} (branch: ${worktreeBranch})`);
364
+ }
365
+ } catch (err) {
366
+ fail(`wave daemon create failed: ${(err as Error).message}`);
367
+ } finally {
368
+ await client?.dispose();
369
+ }
370
+ process.exit(0);
371
+ }
372
+
139
373
  // ── list ───────────────────────────────────────────────────────
140
374
 
141
375
  export async function daemonListCommand(socketPath: string): Promise<void> {
@@ -150,7 +384,10 @@ export async function daemonListCommand(socketPath: string): Promise<void> {
150
384
  if (sessions.length > 0) {
151
385
  const rows = sessions.map((s) => ({
152
386
  sessionId: s.sessionId,
153
- 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",
154
391
  messageCount: String(s.messageCount),
155
392
  workingDirectory: s.workingDirectory,
156
393
  }));
@@ -194,23 +431,191 @@ function summarizeToolInput(context: ToolPermissionContext): string {
194
431
  return text.length > 80 ? `${text.slice(0, 80)}…` : text;
195
432
  }
196
433
 
434
+ /** The questions array of an AskUserQuestion toolInput (SDK schema), if any. */
435
+ function getAskUserQuestions(
436
+ context: ToolPermissionContext,
437
+ ): AskUserQuestion[] | undefined {
438
+ const raw = context.toolInput?.questions;
439
+ return Array.isArray(raw) && raw.length > 0
440
+ ? (raw as AskUserQuestion[])
441
+ : undefined;
442
+ }
443
+
444
+ /**
445
+ * Multi-line full render of an AskUserQuestion tool input, mirroring the
446
+ * desktop ConfirmationDialog layout so the CLI user sees every question and
447
+ * option (spec: daemon-command.md AskUserQuestion 多行完整渲染). Question
448
+ * lines carry the 1-based question number + header; option lines carry the
449
+ * 0-based number that `respond --answer` accepts.
450
+ */
451
+ function renderAskUserQuestions(context: ToolPermissionContext): string {
452
+ const questions = getAskUserQuestions(context);
453
+ if (!questions) return "";
454
+ return questions
455
+ .map((q, qi) => {
456
+ const title = ` Q${qi + 1} [${q.header}] ${q.question}`;
457
+ const options = (q.options ?? []).map(
458
+ (o, oi) =>
459
+ ` ${oi}. ${o.label}${o.description ? ` — ${o.description}` : ""}`,
460
+ );
461
+ return [title, ...options].join("\n");
462
+ })
463
+ .join("\n");
464
+ }
465
+
466
+ /**
467
+ * Parse `respond --answer` for an AskUserQuestion request. Legacy format — a
468
+ * valid JSON object keyed by the full question text (value = the option label,
469
+ * exactly what the desktop dialog would submit) — passes through untouched.
470
+ * Anything else is parsed as comma-separated option numbers: the i-th number
471
+ * answers the i-th question (as numbered Q1..Qn by `wave daemon status`),
472
+ * 0-based like the status rendering, and is mapped to that option's label so
473
+ * the model sees the same answers the GUI would produce.
474
+ */
475
+ function parseAskUserQuestionAnswer(
476
+ raw: string,
477
+ context: ToolPermissionContext,
478
+ ): Record<string, unknown> {
479
+ try {
480
+ const parsed: unknown = JSON.parse(raw);
481
+ if (
482
+ parsed !== null &&
483
+ typeof parsed === "object" &&
484
+ !Array.isArray(parsed)
485
+ ) {
486
+ return parsed as Record<string, unknown>;
487
+ }
488
+ } catch {
489
+ // Not JSON — fall through to per-question option numbers below.
490
+ }
491
+ const questions = getAskUserQuestions(context);
492
+ if (!questions) {
493
+ fail(
494
+ `Cannot parse --answer "${raw}": not a JSON object of {question: answer} and the pending request has no questions to answer by number`,
495
+ );
496
+ }
497
+ const numbers = raw.split(",").map((n) => n.trim());
498
+ if (numbers.length !== questions.length) {
499
+ fail(
500
+ `--answer must give one option number per question (${questions.length} question${questions.length > 1 ? "s" : ""}, comma-separated, matching the numbering in \`wave daemon status\`): got ${numbers.length} number${numbers.length > 1 ? "s" : ""} in "${raw}"`,
501
+ );
502
+ }
503
+ const answers: Record<string, unknown> = {};
504
+ numbers.forEach((n, qi) => {
505
+ const q = questions[qi];
506
+ if (!/^\d+$/.test(n)) {
507
+ fail(
508
+ `--answer contains a non-numeric option number "${n}" for question ${qi + 1} (${q.question})`,
509
+ );
510
+ }
511
+ const options = q.options ?? [];
512
+ const idx = Number(n);
513
+ if (idx >= options.length) {
514
+ fail(
515
+ `Option number ${n} is out of range for question ${qi + 1} (${q.question}): options are numbered 0..${options.length - 1}`,
516
+ );
517
+ }
518
+ const label = options[idx].label;
519
+ answers[q.question] = q.multiSelect ? [label] : label;
520
+ });
521
+ return answers;
522
+ }
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
+ */
197
596
  export async function daemonStatusCommand(
198
597
  socketPath: string,
199
598
  sessionId: string,
200
- lines = 20,
599
+ lines = 1,
201
600
  ): Promise<void> {
202
601
  let client: SocketClient | undefined;
203
602
  try {
204
603
  client = await connectDaemonOrExit(socketPath);
205
604
 
206
605
  // Subscribe BEFORE initialize/restoreSession so the replayed loadingChange
207
- // 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).
208
609
  let loading = false;
209
- client.onNotification("loadingChange", (params) => {
610
+ const filter = sessionNotificationFilter();
611
+ client.onNotification("loadingChange", (params, sessionId) => {
612
+ if (!filter.accepts(sessionId)) return;
210
613
  loading = (params as { loading: boolean }).loading;
211
614
  });
212
615
 
213
- const init = await attachSession(client, sessionId);
616
+ const init = await attachSession(client, sessionId, {
617
+ onInitialized: filter.bind,
618
+ });
214
619
  const initId = init.sessionId;
215
620
 
216
621
  // listPendingPermissions is the authoritative "waiting for approval" signal
@@ -218,6 +623,14 @@ export async function daemonStatusCommand(
218
623
  const pending = (await listPendingPermissions(client)).filter(
219
624
  (r) => r.sessionId === initId || r.sessionId === sessionId,
220
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);
221
634
  const messages = (await client.request(
222
635
  "getMessages",
223
636
  undefined,
@@ -226,69 +639,367 @@ export async function daemonStatusCommand(
226
639
  messages: Message[];
227
640
  };
228
641
 
642
+ const busy = hosted ? !isIdleSession(hosted) : loading;
229
643
  const status =
230
644
  pending.length > 0
231
645
  ? "waiting for approval"
232
- : loading
646
+ : busy
233
647
  ? "generating"
234
648
  : "idle";
235
- console.log(`Session: ${initId}`);
236
- console.log(`Working directory: ${init.workingDirectory}`);
237
- console.log(`Status: ${status}`);
238
-
239
- if (pending.length > 0) {
240
- console.log("");
241
- console.log("Pending approval requests:");
242
- for (const r of pending) {
243
- const params = summarizeToolInput(r.context);
244
- console.log(
245
- ` ${r.requestId} ${r.context.toolName}${params ? ` ${params}` : ""}`,
246
- );
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;
841
+ }
247
842
  }
248
- }
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
+ });
857
+
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
+ };
249
876
 
250
- const recent = messages.messages.slice(-lines);
251
- if (recent.length > 0) {
252
- console.log("");
253
- console.log(`Recent messages (${recent.length}):`);
254
- for (const m of recent) {
255
- const text = getMessageContent(m).replace(/\s+/g, " ").trim();
256
- if (!text) continue; // tool-only messages carry no readable text
257
- console.log(` [${m.role}] ${text}`);
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;
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…`);
258
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();
259
957
  }
260
958
  } catch (err) {
261
- fail(`wave daemon status failed: ${(err as Error).message}`);
959
+ fail(`wave daemon wait failed: ${(err as Error).message}`);
262
960
  } finally {
263
961
  await client?.dispose();
264
962
  }
265
- process.exit(0);
963
+ process.exit(exitCode);
266
964
  }
267
965
 
268
966
  // ── send ───────────────────────────────────────────────────────
269
967
 
270
968
  export interface SendOptions {
271
- timeout: number; // seconds; 0 = no limit (default 600)
969
+ /** Seconds to wait for the reply; 0 (default) = async dispatch: inject the
970
+ * message and exit immediately without waiting (fire-and-forget). */
971
+ wait: number;
272
972
  }
273
973
 
274
974
  /**
275
- * Send a message and wait for the reply that corresponds to it.
975
+ * Inject a message into a session and, when `--wait <N>` is given, wait for the
976
+ * reply that corresponds to it and print the pure final reply text.
977
+ *
978
+ * Default (no --wait) is async dispatch: the command exits 0 as soon as the
979
+ * message is delivered (the message lands in history on an idle session, or is
980
+ * enqueued when the session is busy) — the sender never blocks on the reply,
981
+ * progress is tracked via `status` (spec: send 默认异步派单).
276
982
  *
277
- * Completion detection: `sendMessage` on an idle session resolves only after
278
- * the whole turn finishes (InteractionService awaits sendAIMessage), while on a
279
- * busy session it enqueues and returns immediately — so stopping on a bare
280
- * `loadingChange:false` would exit early on the PREVIOUS turn's completion when
281
- * queued behind a busy session. Instead, track the message IDs: `ourUserMessage`
282
- * is the user message added when OUR turn starts (userMessageAdded), and the
283
- * reply is the last assistantMessageAdded observed after it. A stale
284
- * loading:false can then never satisfy the wait condition early (the reply has
285
- * not been added yet).
983
+ * Completion detection (wait mode): `sendMessage` on an idle session resolves
984
+ * only after the whole turn finishes (InteractionService awaits
985
+ * sendAIMessage), while on a busy session it enqueues and returns immediately —
986
+ * so stopping on a bare `loadingChange:false` would exit early on the PREVIOUS
987
+ * turn's completion when queued behind a busy session. Instead, track the
988
+ * message IDs: `ourUserMessage` is the user message added when OUR turn starts
989
+ * (userMessageAdded), and the reply is the last assistantMessageAdded observed
990
+ * after it. A stale loading:false can then never satisfy the wait condition
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".
286
997
  */
287
998
  export async function daemonSendCommand(
288
999
  socketPath: string,
289
1000
  sessionId: string,
290
1001
  message: string,
291
- options: SendOptions = { timeout: 600 },
1002
+ options: SendOptions = { wait: 0 },
292
1003
  ): Promise<void> {
293
1004
  // connectDaemonOrExit exits on failure — no client to dispose in that case.
294
1005
  const client = await connectDaemonOrExit(socketPath);
@@ -297,37 +1008,104 @@ export async function daemonSendCommand(
297
1008
  let sent = false;
298
1009
  let ourUserMessageId: string | undefined;
299
1010
  let replyMessageId: string | undefined;
300
- 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;
301
1033
  if (!sent) return; // ignore messages added during attach
302
1034
  ourUserMessageId = (params as { message: Message }).message.id;
303
1035
  });
304
- client.onNotification("assistantMessageAdded", (params) => {
1036
+ client.onNotification("assistantMessageAdded", (params, sessionId) => {
1037
+ if (!filter.accepts(sessionId)) return;
305
1038
  if (!sent || ourUserMessageId === undefined) return; // not our turn yet
306
1039
  replyMessageId = (params as { message: Message }).message.id;
307
1040
  });
308
- client.onNotification("loadingChange", (params) => {
1041
+ client.onNotification("loadingChange", (params, sessionId) => {
1042
+ if (!filter.accepts(sessionId)) return;
309
1043
  loading = (params as { loading: boolean }).loading;
310
1044
  });
311
1045
 
312
- let initId: string;
313
1046
  try {
314
- initId = (await attachSession(client, sessionId)).sessionId;
1047
+ await attachSession(client, sessionId, {
1048
+ onInitialized: (id) => {
1049
+ watch.id = id;
1050
+ filter.bind(id);
1051
+ },
1052
+ });
315
1053
  sent = true;
316
- await client.request("sendMessage", { text: message }, initId);
317
1054
  } catch (err) {
318
1055
  client.dispose();
319
1056
  fail(`wave daemon send failed: ${(err as Error).message}`);
320
1057
  }
321
1058
 
322
- // Wait for the reply that corresponds to our message.
1059
+ if (options.wait <= 0) {
1060
+ // Async dispatch mode (default, --wait 0): exit as soon as the message is
1061
+ // DELIVERED, not when the turn completes. The sendMessage RPC resolves only
1062
+ // after the whole turn on an idle session (InteractionService awaits
1063
+ // sendAIMessage) but returns right after enqueueing on a busy session — so
1064
+ // delivery is the earlier of our userMessageAdded notification (idle: the
1065
+ // message lands in history before the turn starts) and the RPC response
1066
+ // itself (busy: enqueued immediately). The daemon keeps running the turn
1067
+ // after this client disconnects (attach 语义) — progress is tracked via
1068
+ // `status` (spec: send 默认异步派单).
1069
+ const sendPromise = client.request(
1070
+ "sendMessage",
1071
+ { text: message },
1072
+ watch.id,
1073
+ );
1074
+ const userMessage = new Promise<"userMessageAdded">((resolve) => {
1075
+ client.onNotification("userMessageAdded", (params, sessionId) => {
1076
+ if (!filter.accepts(sessionId)) return;
1077
+ if (ourUserMessageId !== undefined) resolve("userMessageAdded");
1078
+ });
1079
+ });
1080
+ try {
1081
+ await Promise.race([sendPromise, userMessage]);
1082
+ } catch (err) {
1083
+ client.dispose();
1084
+ fail(`wave daemon send failed: ${(err as Error).message}`);
1085
+ }
1086
+ client.dispose();
1087
+ console.log(`Sent message to session: ${sessionId}`);
1088
+ process.exit(0);
1089
+ }
1090
+
1091
+ try {
1092
+ await client.request("sendMessage", { text: message }, watch.id);
1093
+ } catch (err) {
1094
+ client.dispose();
1095
+ fail(`wave daemon send failed: ${(err as Error).message}`);
1096
+ }
1097
+
1098
+ // Wait for the reply that corresponds to our message (--wait N mode). N is
1099
+ // the seconds bound of the wait; the loop never hangs indefinitely (spec:
1100
+ // --wait 兜底避免无限挂起).
323
1101
  const started = Date.now();
324
- const timeoutMs = options.timeout === 0 ? Infinity : options.timeout * 1000;
1102
+ const timeoutMs = options.wait * 1000;
325
1103
  while (!(loading === false && replyMessageId !== undefined)) {
326
1104
  if (Date.now() - started > timeoutMs) {
327
1105
  // Timeout backstop: the most likely cause is a session waiting on a
328
1106
  // permission approval — point the user at respond (spec: 不无限期挂起).
329
1107
  const pending = (await listPendingPermissions(client)).filter(
330
- (r) => r.sessionId === sessionId || r.sessionId === initId,
1108
+ (r) => r.sessionId === sessionId || r.sessionId === watch.id,
331
1109
  );
332
1110
  client.dispose();
333
1111
  if (pending.length > 0) {
@@ -336,16 +1114,18 @@ export async function daemonSendCommand(
336
1114
  );
337
1115
  }
338
1116
  fail(
339
- options.timeout === 0
340
- ? "Timed out waiting for a reply"
341
- : `Timed out waiting for a reply (${options.timeout}s), no assistant reply received`,
1117
+ `Timed out waiting for a reply (${options.wait}s), no assistant reply received`,
342
1118
  );
343
1119
  }
344
1120
  await sleep(200);
345
1121
  }
346
1122
 
347
1123
  try {
348
- const result = (await client.request("getMessages", undefined, initId)) as {
1124
+ const result = (await client.request(
1125
+ "getMessages",
1126
+ undefined,
1127
+ watch.id,
1128
+ )) as {
349
1129
  messages: Message[];
350
1130
  };
351
1131
  const reply = result.messages.find((m) => m.id === replyMessageId);
@@ -353,7 +1133,16 @@ export async function daemonSendCommand(
353
1133
  // reach stdout (spec: send 输出纯净性).
354
1134
  if (reply) {
355
1135
  const content = getMessageContent(reply).replace(/\s+/g, " ").trim();
356
- if (content) console.log(content);
1136
+ if (content) {
1137
+ console.log(content);
1138
+ } else if (reply.blocks.some((b) => b.type === "reasoning")) {
1139
+ // Interrupted mid-generation (e.g. `wave daemon abort`): the reply was
1140
+ // finalized with reasoning but no text. Surface the interruption
1141
+ // instead of silently exiting 0 with no output (spec: 中断需明确提示).
1142
+ fail("Message aborted before producing a reply");
1143
+ }
1144
+ } else {
1145
+ fail("No reply received for the message");
357
1146
  }
358
1147
  } catch (err) {
359
1148
  fail(`wave daemon send failed: ${(err as Error).message}`);
@@ -413,15 +1202,10 @@ export async function daemonRespondCommand(
413
1202
  } else if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
414
1203
  if (!options.answer) {
415
1204
  fail(
416
- "AskUserQuestion requests require --answer with the answer JSON",
1205
+ 'AskUserQuestion requests require --answer: a JSON object of {question: answer}, or option numbers per question (e.g. "0" or "1,0", see the numbering in `wave daemon status`)',
417
1206
  );
418
1207
  }
419
- let answers: unknown;
420
- try {
421
- answers = JSON.parse(options.answer);
422
- } catch {
423
- fail("--answer is not valid JSON");
424
- }
1208
+ const answers = parseAskUserQuestionAnswer(options.answer, req.context);
425
1209
  decision = { behavior: "allow", message: JSON.stringify(answers) };
426
1210
  } else {
427
1211
  decision = { behavior: "allow" };
@@ -472,3 +1256,198 @@ export async function daemonAbortCommand(
472
1256
  }
473
1257
  process.exit(0);
474
1258
  }
1259
+
1260
+ // ── destroy ────────────────────────────────────────────────────
1261
+
1262
+ export interface DestroyOptions {
1263
+ /** Also remove the session's git worktree before destroying (protocol removeWorktree). */
1264
+ removeWorktree?: boolean;
1265
+ }
1266
+
1267
+ /** Run one git command and return trimmed stdout (rejects on git failure). */
1268
+ function runGit(cwd: string, args: string[]): Promise<string> {
1269
+ return new Promise((resolve, reject) => {
1270
+ execFile("git", args, { cwd }, (err, stdout) => {
1271
+ if (err) reject(err);
1272
+ else resolve(stdout.trim());
1273
+ });
1274
+ });
1275
+ }
1276
+
1277
+ /**
1278
+ * Resolve the worktree hosting `workingDirectory` for removal, mirroring the
1279
+ * values protocol createWorktree returns: worktree path via
1280
+ * `git rev-parse --show-toplevel`, branch via `git branch --show-current`, and
1281
+ * repoRoot = the MAIN repo root (first entry of `git worktree list` — where
1282
+ * git worktree/branch operations run from). Refuses to remove the main working
1283
+ * tree: a plain session is not a worktree, and the protocol's path-containment
1284
+ * check would pass trivially while removeWorktree's fs.rmSync fallback could
1285
+ * delete the whole repository.
1286
+ */
1287
+ async function resolveWorktreeForRemoval(workingDirectory: string): Promise<{
1288
+ path: string;
1289
+ branch: string;
1290
+ repoRoot: string;
1291
+ hookBased: boolean;
1292
+ }> {
1293
+ let worktreePath: string;
1294
+ let branch: string;
1295
+ try {
1296
+ const [toplevel, current] = await Promise.all([
1297
+ runGit(workingDirectory, ["rev-parse", "--show-toplevel"]),
1298
+ runGit(workingDirectory, ["branch", "--show-current"]),
1299
+ ]);
1300
+ worktreePath = toplevel;
1301
+ branch = current;
1302
+ } catch {
1303
+ throw new Error(
1304
+ `Cannot remove worktree: ${workingDirectory} is not inside a git repository`,
1305
+ );
1306
+ }
1307
+ const repoRoot = getGitMainRepoRoot(workingDirectory);
1308
+ if (path.resolve(worktreePath) === path.resolve(repoRoot)) {
1309
+ throw new Error(
1310
+ `Refusing to remove the main working tree: ${repoRoot} (not a linked worktree)`,
1311
+ );
1312
+ }
1313
+ // Hook-based worktrees (created via a WorktreeCreate hook) are removed by the
1314
+ // WorktreeRemove hook — mirror createWorktree's own hookBased decision so
1315
+ // removal never runs `git worktree remove` on a hook-managed worktree.
1316
+ const config = loadMergedWaveConfig(repoRoot);
1317
+ const hookBased = hasWorktreeCreateHook(config?.hooks);
1318
+ return { path: worktreePath, branch, repoRoot, hookBased };
1319
+ }
1320
+
1321
+ /**
1322
+ * Destroy a hosted session (protocol destroy, idempotent — a session not in
1323
+ * the daemon's in-memory registry is a harmless no-op). Unlike status / send /
1324
+ * abort there is no attach step: destroy is a pure registry operation keyed by
1325
+ * the envelope sessionId, so unknown sessions succeed without touching disk.
1326
+ * With --remove-worktree the session's git worktree is resolved from its
1327
+ * workingDirectory (getSessionInfo) and removed via protocol removeWorktree
1328
+ * first, then the session is destroyed.
1329
+ */
1330
+ export async function daemonDestroyCommand(
1331
+ socketPath: string,
1332
+ sessionId: string,
1333
+ options: DestroyOptions = {},
1334
+ ): Promise<void> {
1335
+ let client: SocketClient | undefined;
1336
+ try {
1337
+ client = await connectDaemonOrExit(socketPath);
1338
+ if (options.removeWorktree) {
1339
+ const info = (await client.request(
1340
+ "getSessionInfo",
1341
+ undefined,
1342
+ sessionId,
1343
+ )) as { workingDirectory: string };
1344
+ const wt = await resolveWorktreeForRemoval(info.workingDirectory);
1345
+ await client.request("removeWorktree", {
1346
+ path: wt.path,
1347
+ branch: wt.branch,
1348
+ repoRoot: wt.repoRoot,
1349
+ hookBased: wt.hookBased,
1350
+ });
1351
+ console.log(`Removed worktree: ${wt.path} (branch: ${wt.branch})`);
1352
+ }
1353
+ await client.request("destroy", undefined, sessionId);
1354
+ console.log(`Destroyed session: ${sessionId}`);
1355
+ } catch (err) {
1356
+ fail(`wave daemon destroy failed: ${(err as Error).message}`);
1357
+ } finally {
1358
+ await client?.dispose();
1359
+ }
1360
+ process.exit(0);
1361
+ }
1362
+
1363
+ // ── stop / restart ─────────────────────────────────────────────
1364
+
1365
+ /** How long stop/restart wait for a running daemon to exit (mutable so tests can shorten it). */
1366
+ export const daemonStopTimeout = { ms: 10_000 };
1367
+ const DAEMON_STOP_POLL_INTERVAL_MS = 200;
1368
+
1369
+ /** Connect to the daemon socket without starting one; undefined when it is not running. */
1370
+ async function tryConnectDaemon(
1371
+ socketPath: string,
1372
+ ): Promise<SocketClient | undefined> {
1373
+ try {
1374
+ return await connectDaemon(socketPath);
1375
+ } catch {
1376
+ return undefined;
1377
+ }
1378
+ }
1379
+
1380
+ /** Poll until the daemon socket stops accepting connections (the daemon exited). */
1381
+ async function waitForDaemonExit(socketPath: string): Promise<void> {
1382
+ const deadline = Date.now() + daemonStopTimeout.ms;
1383
+ while (Date.now() < deadline) {
1384
+ const client = await tryConnectDaemon(socketPath);
1385
+ if (!client) return;
1386
+ client.dispose();
1387
+ await sleep(DAEMON_STOP_POLL_INTERVAL_MS);
1388
+ }
1389
+ throw new Error(
1390
+ `daemon did not exit within ${daemonStopTimeout.ms}ms (socket still accepting connections)`,
1391
+ );
1392
+ }
1393
+
1394
+ /**
1395
+ * Gracefully stop a running daemon: send the `shutdown` RPC (the daemon
1396
+ * destroys every session — each agent saves its transcript — then removes its
1397
+ * socket file and exits) and wait for the socket to disappear. Never starts a
1398
+ * daemon. Returns whether one was running (false = idempotent no-op).
1399
+ * Throws when a running daemon fails to exit in time.
1400
+ */
1401
+ async function stopDaemon(socketPath: string): Promise<boolean> {
1402
+ const client = await tryConnectDaemon(socketPath);
1403
+ if (!client) return false;
1404
+ try {
1405
+ // The daemon may drop the socket before responding to the RPC — the wait
1406
+ // below is the source of truth, so any request error is fine.
1407
+ await client.request("shutdown").catch(() => {});
1408
+ } finally {
1409
+ client.dispose();
1410
+ }
1411
+ await waitForDaemonExit(socketPath);
1412
+ return true;
1413
+ }
1414
+
1415
+ /**
1416
+ * Gracefully stop the daemon (spec: daemon-command.md stop/restart). Sends the
1417
+ * `shutdown` RPC so sessions are destroyed and transcripts flushed before the
1418
+ * process exits — not a pkill. Idempotent: with no daemon running it prints
1419
+ * "Daemon is not running" and exits 0 without starting one.
1420
+ */
1421
+ export async function daemonStopCommand(socketPath: string): Promise<void> {
1422
+ let stopped = false;
1423
+ try {
1424
+ stopped = await stopDaemon(socketPath);
1425
+ } catch (err) {
1426
+ fail(`wave daemon stop failed: ${(err as Error).message}`);
1427
+ }
1428
+ console.log(stopped ? "Daemon stopped" : "Daemon is not running");
1429
+ process.exit(0);
1430
+ }
1431
+
1432
+ /**
1433
+ * Restart the daemon (spec: daemon-command.md stop/restart — the main scenario
1434
+ * is restarting after a CLI upgrade so the fresh daemon runs the new code).
1435
+ * A running daemon is gracefully stopped first (shutdown RPC + wait), then a
1436
+ * fresh daemon is started on demand from the current CLI; when none is
1437
+ * running this just starts one. Exits 1 with a clear error if the daemon
1438
+ * cannot be stopped or the fresh one does not come up.
1439
+ */
1440
+ export async function daemonRestartCommand(socketPath: string): Promise<void> {
1441
+ let wasRunning = false;
1442
+ try {
1443
+ wasRunning = await stopDaemon(socketPath);
1444
+ } catch (err) {
1445
+ fail(`wave daemon restart failed: ${(err as Error).message}`);
1446
+ }
1447
+ // connectDaemonOrExit starts a daemon when the socket is absent and retries
1448
+ // until it comes up (exit 1 with the spec'd error on timeout).
1449
+ const client = await connectDaemonOrExit(socketPath);
1450
+ client.dispose();
1451
+ console.log(wasRunning ? "Daemon restarted" : "Daemon started");
1452
+ process.exit(0);
1453
+ }