wave-code 1.1.4 → 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.
- package/dist/bundle/wave.mjs +546 -525
- package/package.json +2 -2
- package/src/commands/plugin/install.ts +5 -5
- package/src/components/ChatInterface.tsx +2 -0
- package/src/components/HooksManager.tsx +286 -0
- package/src/components/InputBox.tsx +86 -49
- package/src/components/MessageBlockItem.tsx +0 -5
- package/src/components/MessageList.tsx +0 -1
- package/src/components/PlanView.tsx +141 -0
- package/src/constants/commands.ts +12 -0
- package/src/contexts/useChat.tsx +94 -70
- package/src/daemon/commands.ts +486 -30
- package/src/hooks/useInputManager.ts +14 -0
- package/src/hooks/useLineScroll.ts +58 -0
- package/src/index.ts +94 -8
- package/src/managers/inputHandlers.ts +2 -0
- package/src/managers/inputReducer.ts +10 -0
- package/src/reducers/hooksManagerReducer.ts +92 -0
- package/src/stdio/agentBridge.ts +552 -55
- package/src/stdio/daemonServer.ts +26 -82
- package/src/stdio/protocol.ts +14 -4
- package/src/stdio-cli.ts +54 -0
- package/src/utils/rewindCheckpoints.ts +2 -2
- package/src/utils/worktree.ts +175 -50
- package/src/components/BangDisplay.tsx +0 -41
package/src/daemon/commands.ts
CHANGED
|
@@ -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
|
|
4
|
-
* progress, inject messages, respond to pending
|
|
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
|
-
/**
|
|
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}:
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
278
|
-
* the whole turn finishes (InteractionService awaits
|
|
279
|
-
* busy session it enqueues and returns immediately —
|
|
280
|
-
* `loadingChange:false` would exit early on the PREVIOUS
|
|
281
|
-
* queued behind a busy session. Instead, track the
|
|
282
|
-
* is the user message added when OUR turn starts
|
|
283
|
-
* reply is the last assistantMessageAdded observed
|
|
284
|
-
* loading:false can then never satisfy the wait condition
|
|
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 = {
|
|
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.
|
|
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.
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|