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.
- package/dist/bundle/wave.mjs +1075 -613
- package/package.json +4 -3
- package/src/cli.tsx +9 -0
- package/src/commands/plugin/install.ts +6 -7
- package/src/commands/plugin/uninstall.ts +11 -5
- package/src/components/ChatInterface.tsx +3 -0
- package/src/components/ConfirmationDetails.tsx +36 -15
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/HooksManager.tsx +286 -0
- package/src/components/InputBox.tsx +125 -51
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/MessageBlockItem.tsx +0 -5
- package/src/components/MessageList.tsx +0 -1
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +146 -0
- package/src/components/PluginManagerShell.tsx +3 -3
- package/src/components/PluginManagerTypes.ts +2 -1
- package/src/components/ResumeCommand.tsx +84 -0
- package/src/components/ScrolledContent.tsx +71 -0
- package/src/components/SessionSelector.tsx +184 -6
- package/src/components/SessionSelectorFlow.tsx +246 -0
- package/src/constants/commands.ts +30 -0
- package/src/contexts/useChat.tsx +252 -98
- package/src/daemon/commands.ts +1046 -67
- package/src/daemon-cli.ts +6 -0
- package/src/hooks/useInputManager.ts +30 -0
- package/src/hooks/useLineScroll.ts +58 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/index.ts +156 -16
- package/src/managers/inputHandlers.ts +16 -9
- package/src/managers/inputReducer.ts +39 -9
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/hooksManagerReducer.ts +92 -0
- package/src/reducers/selectorReducer.ts +3 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +932 -133
- package/src/stdio/daemonServer.ts +26 -82
- package/src/stdio/protocol.ts +23 -4
- package/src/stdio-cli.ts +60 -0
- package/src/utils/rewindCheckpoints.ts +2 -2
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/utils/worktree.ts +175 -50
- package/src/components/BangDisplay.tsx +0 -41
- package/src/components/PlanDisplay.tsx +0 -18
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);
|
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
:
|
|
646
|
+
: busy
|
|
233
647
|
? "generating"
|
|
234
648
|
: "idle";
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
959
|
+
fail(`wave daemon wait failed: ${(err as Error).message}`);
|
|
262
960
|
} finally {
|
|
263
961
|
await client?.dispose();
|
|
264
962
|
}
|
|
265
|
-
process.exit(
|
|
963
|
+
process.exit(exitCode);
|
|
266
964
|
}
|
|
267
965
|
|
|
268
966
|
// ── send ───────────────────────────────────────────────────────
|
|
269
967
|
|
|
270
968
|
export interface SendOptions {
|
|
271
|
-
|
|
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
|
-
*
|
|
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
|
|
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).
|
|
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 = {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 ===
|
|
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.
|
|
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(
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|