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