wave-code 1.1.5 → 1.2.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.
@@ -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);
@@ -136,6 +186,70 @@ function sleep(ms: number): Promise<void> {
136
186
  return new Promise((resolve) => setTimeout(resolve, ms));
137
187
  }
138
188
 
189
+ // ── create ─────────────────────────────────────────────────────
190
+
191
+ export interface CreateOptions {
192
+ /** Working directory for the new session (default: current directory). */
193
+ workdir?: string;
194
+ /** Permission mode for the new session (default: bypassPermissions). */
195
+ permissionMode?: string;
196
+ /** Model override for the new session (default: configured model). */
197
+ model?: string;
198
+ /** Create the session in a new git worktree (name auto-generated when omitted). */
199
+ worktree?: string;
200
+ }
201
+
202
+ /**
203
+ * Create a fresh session in the daemon. initialize WITHOUT restoreSessionId
204
+ * always creates a brand-new session (never an attach), so no existence checks
205
+ * are needed. Defaults mirror the daemon's background-task use case: workdir =
206
+ * current directory, permissionMode = bypassPermissions. With --worktree the
207
+ * daemon first creates a git worktree (protocol createWorktree) and the session
208
+ * is created inside it. Prints the new sessionId (first line, for scripts),
209
+ * plus the worktree path/branch when --worktree was used.
210
+ */
211
+ export async function daemonCreateCommand(
212
+ socketPath: string,
213
+ options: CreateOptions = {},
214
+ ): Promise<void> {
215
+ const mode = options.permissionMode ?? "bypassPermissions";
216
+ if (!PERMISSION_MODES.includes(mode as PermissionMode)) {
217
+ fail(
218
+ `Invalid permission mode: ${mode} (options: ${PERMISSION_MODES.join(", ")})`,
219
+ );
220
+ }
221
+ let client: SocketClient | undefined;
222
+ try {
223
+ client = await connectDaemonOrExit(socketPath);
224
+ let workdir = options.workdir ?? process.cwd();
225
+ let worktreePath: string | undefined;
226
+ let worktreeBranch: string | undefined;
227
+ if (options.worktree !== undefined) {
228
+ const wt = (await client.request("createWorktree", {
229
+ workdir,
230
+ name: options.worktree,
231
+ })) as { name: string; path: string; branch: string; repoRoot: string };
232
+ workdir = wt.path;
233
+ worktreePath = wt.path;
234
+ worktreeBranch = wt.branch;
235
+ }
236
+ const result = (await client.request("initialize", {
237
+ workdir,
238
+ permissionMode: mode,
239
+ model: options.model,
240
+ })) as { sessionId: string };
241
+ console.log(result.sessionId);
242
+ if (worktreePath !== undefined) {
243
+ console.log(`Worktree: ${worktreePath} (branch: ${worktreeBranch})`);
244
+ }
245
+ } catch (err) {
246
+ fail(`wave daemon create failed: ${(err as Error).message}`);
247
+ } finally {
248
+ await client?.dispose();
249
+ }
250
+ process.exit(0);
251
+ }
252
+
139
253
  // ── list ───────────────────────────────────────────────────────
140
254
 
141
255
  export async function daemonListCommand(socketPath: string): Promise<void> {
@@ -194,6 +308,96 @@ function summarizeToolInput(context: ToolPermissionContext): string {
194
308
  return text.length > 80 ? `${text.slice(0, 80)}…` : text;
195
309
  }
196
310
 
311
+ /** The questions array of an AskUserQuestion toolInput (SDK schema), if any. */
312
+ function getAskUserQuestions(
313
+ context: ToolPermissionContext,
314
+ ): AskUserQuestion[] | undefined {
315
+ const raw = context.toolInput?.questions;
316
+ return Array.isArray(raw) && raw.length > 0
317
+ ? (raw as AskUserQuestion[])
318
+ : undefined;
319
+ }
320
+
321
+ /**
322
+ * Multi-line full render of an AskUserQuestion tool input, mirroring the
323
+ * desktop ConfirmationDialog layout so the CLI user sees every question and
324
+ * option (spec: daemon-command.md AskUserQuestion 多行完整渲染). Question
325
+ * lines carry the 1-based question number + header; option lines carry the
326
+ * 0-based number that `respond --answer` accepts.
327
+ */
328
+ function renderAskUserQuestions(context: ToolPermissionContext): string {
329
+ const questions = getAskUserQuestions(context);
330
+ if (!questions) return "";
331
+ return questions
332
+ .map((q, qi) => {
333
+ const title = ` Q${qi + 1} [${q.header}] ${q.question}`;
334
+ const options = (q.options ?? []).map(
335
+ (o, oi) =>
336
+ ` ${oi}. ${o.label}${o.description ? ` — ${o.description}` : ""}`,
337
+ );
338
+ return [title, ...options].join("\n");
339
+ })
340
+ .join("\n");
341
+ }
342
+
343
+ /**
344
+ * Parse `respond --answer` for an AskUserQuestion request. Legacy format — a
345
+ * valid JSON object keyed by the full question text (value = the option label,
346
+ * exactly what the desktop dialog would submit) — passes through untouched.
347
+ * Anything else is parsed as comma-separated option numbers: the i-th number
348
+ * answers the i-th question (as numbered Q1..Qn by `wave daemon status`),
349
+ * 0-based like the status rendering, and is mapped to that option's label so
350
+ * the model sees the same answers the GUI would produce.
351
+ */
352
+ function parseAskUserQuestionAnswer(
353
+ raw: string,
354
+ context: ToolPermissionContext,
355
+ ): Record<string, unknown> {
356
+ try {
357
+ const parsed: unknown = JSON.parse(raw);
358
+ if (
359
+ parsed !== null &&
360
+ typeof parsed === "object" &&
361
+ !Array.isArray(parsed)
362
+ ) {
363
+ return parsed as Record<string, unknown>;
364
+ }
365
+ } catch {
366
+ // Not JSON — fall through to per-question option numbers below.
367
+ }
368
+ const questions = getAskUserQuestions(context);
369
+ if (!questions) {
370
+ fail(
371
+ `Cannot parse --answer "${raw}": not a JSON object of {question: answer} and the pending request has no questions to answer by number`,
372
+ );
373
+ }
374
+ const numbers = raw.split(",").map((n) => n.trim());
375
+ if (numbers.length !== questions.length) {
376
+ fail(
377
+ `--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}"`,
378
+ );
379
+ }
380
+ const answers: Record<string, unknown> = {};
381
+ numbers.forEach((n, qi) => {
382
+ const q = questions[qi];
383
+ if (!/^\d+$/.test(n)) {
384
+ fail(
385
+ `--answer contains a non-numeric option number "${n}" for question ${qi + 1} (${q.question})`,
386
+ );
387
+ }
388
+ const options = q.options ?? [];
389
+ const idx = Number(n);
390
+ if (idx >= options.length) {
391
+ fail(
392
+ `Option number ${n} is out of range for question ${qi + 1} (${q.question}): options are numbered 0..${options.length - 1}`,
393
+ );
394
+ }
395
+ const label = options[idx].label;
396
+ answers[q.question] = q.multiSelect ? [label] : label;
397
+ });
398
+ return answers;
399
+ }
400
+
197
401
  export async function daemonStatusCommand(
198
402
  socketPath: string,
199
403
  sessionId: string,
@@ -240,6 +444,14 @@ export async function daemonStatusCommand(
240
444
  console.log("");
241
445
  console.log("Pending approval requests:");
242
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
+ }
454
+ }
243
455
  const params = summarizeToolInput(r.context);
244
456
  console.log(
245
457
  ` ${r.requestId} ${r.context.toolName}${params ? ` ${params}` : ""}`,
@@ -268,27 +480,35 @@ export async function daemonStatusCommand(
268
480
  // ── send ───────────────────────────────────────────────────────
269
481
 
270
482
  export interface SendOptions {
271
- timeout: number; // seconds; 0 = no limit (default 600)
483
+ /** Seconds to wait for the reply; 0 (default) = async dispatch: inject the
484
+ * message and exit immediately without waiting (fire-and-forget). */
485
+ wait: number;
272
486
  }
273
487
 
274
488
  /**
275
- * Send a message and wait for the reply that corresponds to it.
489
+ * Inject a message into a session and, when `--wait <N>` is given, wait for the
490
+ * reply that corresponds to it and print the pure final reply text.
491
+ *
492
+ * Default (no --wait) is async dispatch: the command exits 0 as soon as the
493
+ * message is delivered (the message lands in history on an idle session, or is
494
+ * enqueued when the session is busy) — the sender never blocks on the reply,
495
+ * progress is tracked via `status` (spec: send 默认异步派单).
276
496
  *
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).
497
+ * Completion detection (wait mode): `sendMessage` on an idle session resolves
498
+ * only after the whole turn finishes (InteractionService awaits
499
+ * sendAIMessage), while on a busy session it enqueues and returns immediately —
500
+ * so stopping on a bare `loadingChange:false` would exit early on the PREVIOUS
501
+ * turn's completion when queued behind a busy session. Instead, track the
502
+ * message IDs: `ourUserMessage` is the user message added when OUR turn starts
503
+ * (userMessageAdded), and the reply is the last assistantMessageAdded observed
504
+ * after it. A stale loading:false can then never satisfy the wait condition
505
+ * early (the reply has not been added yet).
286
506
  */
287
507
  export async function daemonSendCommand(
288
508
  socketPath: string,
289
509
  sessionId: string,
290
510
  message: string,
291
- options: SendOptions = { timeout: 600 },
511
+ options: SendOptions = { wait: 0 },
292
512
  ): Promise<void> {
293
513
  // connectDaemonOrExit exits on failure — no client to dispose in that case.
294
514
  const client = await connectDaemonOrExit(socketPath);
@@ -313,15 +533,54 @@ export async function daemonSendCommand(
313
533
  try {
314
534
  initId = (await attachSession(client, sessionId)).sessionId;
315
535
  sent = true;
536
+ } catch (err) {
537
+ client.dispose();
538
+ fail(`wave daemon send failed: ${(err as Error).message}`);
539
+ }
540
+
541
+ if (options.wait <= 0) {
542
+ // Async dispatch mode (default, --wait 0): exit as soon as the message is
543
+ // DELIVERED, not when the turn completes. The sendMessage RPC resolves only
544
+ // after the whole turn on an idle session (InteractionService awaits
545
+ // sendAIMessage) but returns right after enqueueing on a busy session — so
546
+ // delivery is the earlier of our userMessageAdded notification (idle: the
547
+ // message lands in history before the turn starts) and the RPC response
548
+ // itself (busy: enqueued immediately). The daemon keeps running the turn
549
+ // after this client disconnects (attach 语义) — progress is tracked via
550
+ // `status` (spec: send 默认异步派单).
551
+ const sendPromise = client.request(
552
+ "sendMessage",
553
+ { text: message },
554
+ initId,
555
+ );
556
+ const userMessage = new Promise<"userMessageAdded">((resolve) => {
557
+ client.onNotification("userMessageAdded", () => {
558
+ if (ourUserMessageId !== undefined) resolve("userMessageAdded");
559
+ });
560
+ });
561
+ try {
562
+ await Promise.race([sendPromise, userMessage]);
563
+ } catch (err) {
564
+ client.dispose();
565
+ fail(`wave daemon send failed: ${(err as Error).message}`);
566
+ }
567
+ client.dispose();
568
+ console.log(`Sent message to session: ${sessionId}`);
569
+ process.exit(0);
570
+ }
571
+
572
+ try {
316
573
  await client.request("sendMessage", { text: message }, initId);
317
574
  } catch (err) {
318
575
  client.dispose();
319
576
  fail(`wave daemon send failed: ${(err as Error).message}`);
320
577
  }
321
578
 
322
- // Wait for the reply that corresponds to our message.
579
+ // Wait for the reply that corresponds to our message (--wait N mode). N is
580
+ // the seconds bound of the wait; the loop never hangs indefinitely (spec:
581
+ // --wait 兜底避免无限挂起).
323
582
  const started = Date.now();
324
- const timeoutMs = options.timeout === 0 ? Infinity : options.timeout * 1000;
583
+ const timeoutMs = options.wait * 1000;
325
584
  while (!(loading === false && replyMessageId !== undefined)) {
326
585
  if (Date.now() - started > timeoutMs) {
327
586
  // Timeout backstop: the most likely cause is a session waiting on a
@@ -336,9 +595,7 @@ export async function daemonSendCommand(
336
595
  );
337
596
  }
338
597
  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`,
598
+ `Timed out waiting for a reply (${options.wait}s), no assistant reply received`,
342
599
  );
343
600
  }
344
601
  await sleep(200);
@@ -353,7 +610,16 @@ export async function daemonSendCommand(
353
610
  // reach stdout (spec: send 输出纯净性).
354
611
  if (reply) {
355
612
  const content = getMessageContent(reply).replace(/\s+/g, " ").trim();
356
- if (content) console.log(content);
613
+ if (content) {
614
+ console.log(content);
615
+ } else if (reply.blocks.some((b) => b.type === "reasoning")) {
616
+ // Interrupted mid-generation (e.g. `wave daemon abort`): the reply was
617
+ // finalized with reasoning but no text. Surface the interruption
618
+ // instead of silently exiting 0 with no output (spec: 中断需明确提示).
619
+ fail("Message aborted before producing a reply");
620
+ }
621
+ } else {
622
+ fail("No reply received for the message");
357
623
  }
358
624
  } catch (err) {
359
625
  fail(`wave daemon send failed: ${(err as Error).message}`);
@@ -413,15 +679,10 @@ export async function daemonRespondCommand(
413
679
  } else if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
414
680
  if (!options.answer) {
415
681
  fail(
416
- "AskUserQuestion requests require --answer with the answer JSON",
682
+ '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
683
  );
418
684
  }
419
- let answers: unknown;
420
- try {
421
- answers = JSON.parse(options.answer);
422
- } catch {
423
- fail("--answer is not valid JSON");
424
- }
685
+ const answers = parseAskUserQuestionAnswer(options.answer, req.context);
425
686
  decision = { behavior: "allow", message: JSON.stringify(answers) };
426
687
  } else {
427
688
  decision = { behavior: "allow" };
@@ -472,3 +733,198 @@ export async function daemonAbortCommand(
472
733
  }
473
734
  process.exit(0);
474
735
  }
736
+
737
+ // ── destroy ────────────────────────────────────────────────────
738
+
739
+ export interface DestroyOptions {
740
+ /** Also remove the session's git worktree before destroying (protocol removeWorktree). */
741
+ removeWorktree?: boolean;
742
+ }
743
+
744
+ /** Run one git command and return trimmed stdout (rejects on git failure). */
745
+ function runGit(cwd: string, args: string[]): Promise<string> {
746
+ return new Promise((resolve, reject) => {
747
+ execFile("git", args, { cwd }, (err, stdout) => {
748
+ if (err) reject(err);
749
+ else resolve(stdout.trim());
750
+ });
751
+ });
752
+ }
753
+
754
+ /**
755
+ * Resolve the worktree hosting `workingDirectory` for removal, mirroring the
756
+ * values protocol createWorktree returns: worktree path via
757
+ * `git rev-parse --show-toplevel`, branch via `git branch --show-current`, and
758
+ * repoRoot = the MAIN repo root (first entry of `git worktree list` — where
759
+ * git worktree/branch operations run from). Refuses to remove the main working
760
+ * tree: a plain session is not a worktree, and the protocol's path-containment
761
+ * check would pass trivially while removeWorktree's fs.rmSync fallback could
762
+ * delete the whole repository.
763
+ */
764
+ async function resolveWorktreeForRemoval(workingDirectory: string): Promise<{
765
+ path: string;
766
+ branch: string;
767
+ repoRoot: string;
768
+ hookBased: boolean;
769
+ }> {
770
+ let worktreePath: string;
771
+ let branch: string;
772
+ try {
773
+ const [toplevel, current] = await Promise.all([
774
+ runGit(workingDirectory, ["rev-parse", "--show-toplevel"]),
775
+ runGit(workingDirectory, ["branch", "--show-current"]),
776
+ ]);
777
+ worktreePath = toplevel;
778
+ branch = current;
779
+ } catch {
780
+ throw new Error(
781
+ `Cannot remove worktree: ${workingDirectory} is not inside a git repository`,
782
+ );
783
+ }
784
+ const repoRoot = getGitMainRepoRoot(workingDirectory);
785
+ if (path.resolve(worktreePath) === path.resolve(repoRoot)) {
786
+ throw new Error(
787
+ `Refusing to remove the main working tree: ${repoRoot} (not a linked worktree)`,
788
+ );
789
+ }
790
+ // Hook-based worktrees (created via a WorktreeCreate hook) are removed by the
791
+ // WorktreeRemove hook — mirror createWorktree's own hookBased decision so
792
+ // removal never runs `git worktree remove` on a hook-managed worktree.
793
+ const config = loadMergedWaveConfig(repoRoot);
794
+ const hookBased = hasWorktreeCreateHook(config?.hooks);
795
+ return { path: worktreePath, branch, repoRoot, hookBased };
796
+ }
797
+
798
+ /**
799
+ * Destroy a hosted session (protocol destroy, idempotent — a session not in
800
+ * the daemon's in-memory registry is a harmless no-op). Unlike status / send /
801
+ * abort there is no attach step: destroy is a pure registry operation keyed by
802
+ * the envelope sessionId, so unknown sessions succeed without touching disk.
803
+ * With --remove-worktree the session's git worktree is resolved from its
804
+ * workingDirectory (getSessionInfo) and removed via protocol removeWorktree
805
+ * first, then the session is destroyed.
806
+ */
807
+ export async function daemonDestroyCommand(
808
+ socketPath: string,
809
+ sessionId: string,
810
+ options: DestroyOptions = {},
811
+ ): Promise<void> {
812
+ let client: SocketClient | undefined;
813
+ try {
814
+ client = await connectDaemonOrExit(socketPath);
815
+ if (options.removeWorktree) {
816
+ const info = (await client.request(
817
+ "getSessionInfo",
818
+ undefined,
819
+ sessionId,
820
+ )) as { workingDirectory: string };
821
+ const wt = await resolveWorktreeForRemoval(info.workingDirectory);
822
+ await client.request("removeWorktree", {
823
+ path: wt.path,
824
+ branch: wt.branch,
825
+ repoRoot: wt.repoRoot,
826
+ hookBased: wt.hookBased,
827
+ });
828
+ console.log(`Removed worktree: ${wt.path} (branch: ${wt.branch})`);
829
+ }
830
+ await client.request("destroy", undefined, sessionId);
831
+ console.log(`Destroyed session: ${sessionId}`);
832
+ } catch (err) {
833
+ fail(`wave daemon destroy failed: ${(err as Error).message}`);
834
+ } finally {
835
+ await client?.dispose();
836
+ }
837
+ process.exit(0);
838
+ }
839
+
840
+ // ── stop / restart ─────────────────────────────────────────────
841
+
842
+ /** How long stop/restart wait for a running daemon to exit (mutable so tests can shorten it). */
843
+ export const daemonStopTimeout = { ms: 10_000 };
844
+ const DAEMON_STOP_POLL_INTERVAL_MS = 200;
845
+
846
+ /** Connect to the daemon socket without starting one; undefined when it is not running. */
847
+ async function tryConnectDaemon(
848
+ socketPath: string,
849
+ ): Promise<SocketClient | undefined> {
850
+ try {
851
+ return await connectDaemon(socketPath);
852
+ } catch {
853
+ return undefined;
854
+ }
855
+ }
856
+
857
+ /** Poll until the daemon socket stops accepting connections (the daemon exited). */
858
+ async function waitForDaemonExit(socketPath: string): Promise<void> {
859
+ const deadline = Date.now() + daemonStopTimeout.ms;
860
+ while (Date.now() < deadline) {
861
+ const client = await tryConnectDaemon(socketPath);
862
+ if (!client) return;
863
+ client.dispose();
864
+ await sleep(DAEMON_STOP_POLL_INTERVAL_MS);
865
+ }
866
+ throw new Error(
867
+ `daemon did not exit within ${daemonStopTimeout.ms}ms (socket still accepting connections)`,
868
+ );
869
+ }
870
+
871
+ /**
872
+ * Gracefully stop a running daemon: send the `shutdown` RPC (the daemon
873
+ * destroys every session — each agent saves its transcript — then removes its
874
+ * socket file and exits) and wait for the socket to disappear. Never starts a
875
+ * daemon. Returns whether one was running (false = idempotent no-op).
876
+ * Throws when a running daemon fails to exit in time.
877
+ */
878
+ async function stopDaemon(socketPath: string): Promise<boolean> {
879
+ const client = await tryConnectDaemon(socketPath);
880
+ if (!client) return false;
881
+ try {
882
+ // The daemon may drop the socket before responding to the RPC — the wait
883
+ // below is the source of truth, so any request error is fine.
884
+ await client.request("shutdown").catch(() => {});
885
+ } finally {
886
+ client.dispose();
887
+ }
888
+ await waitForDaemonExit(socketPath);
889
+ return true;
890
+ }
891
+
892
+ /**
893
+ * Gracefully stop the daemon (spec: daemon-command.md stop/restart). Sends the
894
+ * `shutdown` RPC so sessions are destroyed and transcripts flushed before the
895
+ * process exits — not a pkill. Idempotent: with no daemon running it prints
896
+ * "Daemon is not running" and exits 0 without starting one.
897
+ */
898
+ export async function daemonStopCommand(socketPath: string): Promise<void> {
899
+ let stopped = false;
900
+ try {
901
+ stopped = await stopDaemon(socketPath);
902
+ } catch (err) {
903
+ fail(`wave daemon stop failed: ${(err as Error).message}`);
904
+ }
905
+ console.log(stopped ? "Daemon stopped" : "Daemon is not running");
906
+ process.exit(0);
907
+ }
908
+
909
+ /**
910
+ * Restart the daemon (spec: daemon-command.md stop/restart — the main scenario
911
+ * is restarting after a CLI upgrade so the fresh daemon runs the new code).
912
+ * A running daemon is gracefully stopped first (shutdown RPC + wait), then a
913
+ * fresh daemon is started on demand from the current CLI; when none is
914
+ * running this just starts one. Exits 1 with a clear error if the daemon
915
+ * cannot be stopped or the fresh one does not come up.
916
+ */
917
+ export async function daemonRestartCommand(socketPath: string): Promise<void> {
918
+ let wasRunning = false;
919
+ try {
920
+ wasRunning = await stopDaemon(socketPath);
921
+ } catch (err) {
922
+ fail(`wave daemon restart failed: ${(err as Error).message}`);
923
+ }
924
+ // connectDaemonOrExit starts a daemon when the socket is absent and retries
925
+ // until it comes up (exit 1 with the spec'd error on timeout).
926
+ const client = await connectDaemonOrExit(socketPath);
927
+ client.dispose();
928
+ console.log(wasRunning ? "Daemon restarted" : "Daemon started");
929
+ process.exit(0);
930
+ }
@@ -62,6 +62,7 @@ export const useInputManager = (
62
62
  onClearMessages,
63
63
  onCompact,
64
64
  onAddDir,
65
+ onPlanCommand,
65
66
  isIdle: isIdleProp,
66
67
  } = callbacks;
67
68
 
@@ -271,12 +272,18 @@ export const useInputManager = (
271
272
  dispatch({ type: "SET_SHOW_MODEL_SELECTOR", payload: true });
272
273
  } else if (command === "workflows") {
273
274
  dispatch({ type: "SET_SHOW_WORKFLOW_MANAGER", payload: true });
275
+ } else if (command === "skills") {
276
+ dispatch({ type: "SET_SHOW_SKILLS_MANAGER", payload: true });
277
+ } else if (command === "hooks") {
278
+ dispatch({ type: "SET_SHOW_HOOKS_MANAGER", payload: true });
274
279
  } else if (command === "clear") {
275
280
  await onClearMessages?.();
276
281
  } else if (command === "compact") {
277
282
  await onCompact?.(effect.args);
278
283
  } else if (command === "add-dir") {
279
284
  await onAddDir?.(effect.args);
285
+ } else if (command === "plan") {
286
+ await onPlanCommand?.(effect.args);
280
287
  }
281
288
  }
282
289
  break;
@@ -306,6 +313,7 @@ export const useInputManager = (
306
313
  onClearMessages,
307
314
  onCompact,
308
315
  onAddDir,
316
+ onPlanCommand,
309
317
  ]);
310
318
 
311
319
  useEffect(() => {
@@ -531,6 +539,10 @@ export const useInputManager = (
531
539
  dispatch({ type: "SET_SHOW_SKILLS_MANAGER", payload: show });
532
540
  }, []);
533
541
 
542
+ const setShowHooksManager = useCallback((show: boolean) => {
543
+ dispatch({ type: "SET_SHOW_HOOKS_MANAGER", payload: show });
544
+ }, []);
545
+
534
546
  const setPermissionMode = useCallback(
535
547
  (mode: PermissionMode) => {
536
548
  dispatch({ type: "SET_PERMISSION_MODE", payload: mode });
@@ -676,6 +688,7 @@ export const useInputManager = (
676
688
  showModelSelector: state.showModelSelector,
677
689
  showWorkflowManager: state.showWorkflowManager,
678
690
  showSkillsManager: state.showSkillsManager,
691
+ showHooksManager: state.showHooksManager,
679
692
  permissionMode: state.permissionMode,
680
693
  attachedImages: state.attachedImages,
681
694
  btwState: state.btwState,
@@ -723,6 +736,7 @@ export const useInputManager = (
723
736
  setShowModelSelector,
724
737
  setShowWorkflowManager,
725
738
  setShowSkillsManager,
739
+ setShowHooksManager,
726
740
  setPermissionMode,
727
741
  setBtwState,
728
742