wave-code 1.0.7 → 1.0.8

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.
@@ -31,7 +31,6 @@ import {
31
31
  extractLatestTotalTokens,
32
32
  } from "wave-agent-sdk";
33
33
  import { logger } from "../utils/logger.js";
34
- import { throttle } from "../utils/throttle.js";
35
34
  import { displayUsageSummary } from "../utils/usageSummary.js";
36
35
  import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
37
36
 
@@ -112,6 +111,7 @@ export interface ChatContextType {
112
111
  hidePersistentOption?: boolean;
113
112
  planContent?: string;
114
113
  permissionMode?: PermissionMode;
114
+ warning?: string;
115
115
  };
116
116
  showConfirmation: (
117
117
  toolName: string,
@@ -120,6 +120,7 @@ export interface ChatContextType {
120
120
  hidePersistentOption?: boolean,
121
121
  planContent?: string,
122
122
  permissionMode?: PermissionMode,
123
+ warning?: string,
123
124
  ) => Promise<PermissionDecision>;
124
125
  hideConfirmation: () => void;
125
126
  handleConfirmationDecision: (decision: PermissionDecision) => void;
@@ -239,6 +240,99 @@ function createStreamingWindowThrottle(
239
240
  return throttled;
240
241
  }
241
242
 
243
+ /**
244
+ * Per-tool window-concat throttle for pure-delta tool parameter streaming:
245
+ * `parametersChunk` deltas are accumulated independently per tool block id
246
+ * within the cooldown window, so interleaved multi-tool streams lose no delta
247
+ * (a plain throttle's single last-args slot would drop every earlier tool's
248
+ * deltas, leaving the first tool without streaming parameters). `start` /
249
+ * `running` apply immediately (one-shot snapshots); `end` flushes pending
250
+ * streaming deltas first, then applies the authoritative parameters/result.
251
+ */
252
+ export function createToolStreamingThrottle(
253
+ fn: (params: ToolBlockUpdateCallbackParams) => void,
254
+ wait: number,
255
+ ): {
256
+ (params: ToolBlockUpdateCallbackParams): void;
257
+ cancel: () => void;
258
+ flush: () => void;
259
+ } {
260
+ let timer: NodeJS.Timeout | null = null;
261
+ let pending: { messageId: string; chunks: Map<string, string> } | null = null;
262
+
263
+ const fire = () => {
264
+ if (pending && pending.chunks.size > 0) {
265
+ const { messageId, chunks } = pending;
266
+ pending = null;
267
+ for (const [id, chunk] of chunks) {
268
+ fn({ messageId, id, parametersChunk: chunk, stage: "streaming" });
269
+ }
270
+ }
271
+ };
272
+
273
+ const throttled = (params: ToolBlockUpdateCallbackParams) => {
274
+ if (params.stage === "end") {
275
+ // Flush any deltas still pending inside the cooldown window first
276
+ if (timer) {
277
+ clearTimeout(timer);
278
+ timer = null;
279
+ }
280
+ fire();
281
+ fn(params);
282
+ return;
283
+ }
284
+ if (params.stage === "streaming") {
285
+ if (!pending) {
286
+ pending = { messageId: params.messageId, chunks: new Map() };
287
+ }
288
+ const prev = pending.chunks.get(params.id) || "";
289
+ pending.chunks.set(params.id, prev + (params.parametersChunk || ""));
290
+ if (!timer) {
291
+ timer = setTimeout(() => {
292
+ timer = null;
293
+ fire();
294
+ }, wait);
295
+ }
296
+ return;
297
+ }
298
+ // start / running — one-shot snapshots applied immediately. Drop this
299
+ // tool's buffered streaming deltas first: start/running carry the
300
+ // authoritative parameters, and a pending timer would otherwise fire late
301
+ // with a stale `streaming` event, regressing this tool block's stage back
302
+ // to streaming (yellow dot -> gray) mid-execution. Other tools' in-flight
303
+ // chunks are kept so interleaved multi-tool streaming still accumulates.
304
+ if (pending) {
305
+ pending.chunks.delete(params.id);
306
+ if (pending.chunks.size === 0) {
307
+ pending = null;
308
+ if (timer) {
309
+ clearTimeout(timer);
310
+ timer = null;
311
+ }
312
+ }
313
+ }
314
+ fn(params);
315
+ };
316
+
317
+ throttled.cancel = () => {
318
+ if (timer) {
319
+ clearTimeout(timer);
320
+ timer = null;
321
+ }
322
+ pending = null;
323
+ };
324
+
325
+ throttled.flush = () => {
326
+ if (timer) {
327
+ clearTimeout(timer);
328
+ timer = null;
329
+ }
330
+ fire();
331
+ };
332
+
333
+ return throttled;
334
+ }
335
+
242
336
  export const ChatProvider: React.FC<ChatProviderProps> = ({
243
337
  children,
244
338
  bypassPermissions,
@@ -345,8 +439,13 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
345
439
 
346
440
  const throttledToolBlockUpdate = useMemo(
347
441
  () =>
348
- throttle((params: ToolBlockUpdateCallbackParams) => {
349
- const { messageId, id: toolBlockId, ...updates } = params;
442
+ createToolStreamingThrottle((params) => {
443
+ const {
444
+ messageId,
445
+ id: toolBlockId,
446
+ parametersChunk,
447
+ ...updates
448
+ } = params;
350
449
  setMessages((prev) =>
351
450
  prev.map((m) => {
352
451
  if (m.id !== messageId) return m;
@@ -363,7 +462,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
363
462
  id: toolBlockId,
364
463
  name: updates.name || "",
365
464
  stage: updates.stage || "start",
366
- parameters: updates.parameters || "",
465
+ parameters:
466
+ (updates.parameters || "") + (parametersChunk || ""),
367
467
  result: updates.result || "",
368
468
  ...updates,
369
469
  },
@@ -374,7 +474,18 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
374
474
  ...m,
375
475
  blocks: m.blocks.map((b, idx) =>
376
476
  idx === toolBlockIndex && b.type === "tool"
377
- ? { ...b, ...updates }
477
+ ? {
478
+ ...b,
479
+ ...updates,
480
+ // Streaming carries only the delta; append it to the
481
+ // accumulated parameters. start/running/end carry the
482
+ // authoritative value and replace wholesale.
483
+ parameters: parametersChunk
484
+ ? (b.parameters || "") + parametersChunk
485
+ : updates.parameters !== undefined
486
+ ? updates.parameters
487
+ : b.parameters,
488
+ }
378
489
  : b,
379
490
  ),
380
491
  };
@@ -443,6 +554,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
443
554
  hidePersistentOption?: boolean;
444
555
  planContent?: string;
445
556
  permissionMode?: PermissionMode;
557
+ warning?: string;
446
558
  }
447
559
  | undefined
448
560
  >();
@@ -454,6 +566,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
454
566
  hidePersistentOption?: boolean;
455
567
  planContent?: string;
456
568
  permissionMode?: PermissionMode;
569
+ warning?: string;
457
570
  resolver: (decision: PermissionDecision) => void;
458
571
  reject: () => void;
459
572
  }>
@@ -465,6 +578,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
465
578
  hidePersistentOption?: boolean;
466
579
  planContent?: string;
467
580
  permissionMode?: PermissionMode;
581
+ warning?: string;
468
582
  resolver: (decision: PermissionDecision) => void;
469
583
  reject: () => void;
470
584
  } | null>(null);
@@ -506,6 +620,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
506
620
  hidePersistentOption?: boolean,
507
621
  planContent?: string,
508
622
  permissionMode?: PermissionMode,
623
+ warning?: string,
509
624
  ): Promise<PermissionDecision> => {
510
625
  return new Promise<PermissionDecision>((resolve, reject) => {
511
626
  const queueItem = {
@@ -515,6 +630,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
515
630
  hidePersistentOption,
516
631
  planContent,
517
632
  permissionMode,
633
+ warning,
518
634
  resolver: resolve,
519
635
  reject,
520
636
  };
@@ -723,6 +839,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
723
839
  context.hidePersistentOption,
724
840
  context.planContent,
725
841
  context.permissionMode,
842
+ context.warning,
726
843
  );
727
844
  } catch {
728
845
  // If confirmation was cancelled or failed, deny the operation
@@ -1070,6 +1187,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1070
1187
  hidePersistentOption: next.hidePersistentOption,
1071
1188
  planContent: next.planContent,
1072
1189
  permissionMode: next.permissionMode,
1190
+ warning: next.warning,
1073
1191
  });
1074
1192
  setIsConfirmationVisible(true);
1075
1193
  setConfirmationQueue((prev) => prev.slice(1));
@@ -0,0 +1,444 @@
1
+ /**
2
+ * `wave daemon` client subcommands — talk to the wave daemon's unix socket
3
+ * (JSON-RPC over newline-delimited JSON) to list hosted sessions, inspect
4
+ * progress, inject messages and respond to pending permission requests.
5
+ *
6
+ * All subcommands are non-interactive: results go to stdout, diagnostics to
7
+ * stderr, and every handler calls process.exit() itself (yargs would fall
8
+ * through to the TUI otherwise). Every command connects to the fixed default
9
+ * socket `~/.wave/daemon.sock` — the daemon only runs on remote hosts, so no
10
+ * `--socket` override is offered (spec: daemon-command.md).
11
+ *
12
+ * Attach semantics: `initialize {workdir, restoreSessionId}` + `restoreSession`
13
+ * re-attach to a live session in the daemon's in-memory registry, or reload a
14
+ * transcript from disk under the current working directory. A session that is
15
+ * nowhere (live registry or disk) silently starts a FRESH session under a
16
+ * different id — the only reliable existence check is the `restoreSession`
17
+ * rejection ("Session not found: <id>"), after which the junk fresh session
18
+ * must be destroyed via the envelope sessionId returned by `initialize`.
19
+ */
20
+
21
+ import net from "node:net";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import {
25
+ ASK_USER_QUESTION_TOOL_NAME,
26
+ ENTER_PLAN_MODE_TOOL_NAME,
27
+ EXIT_PLAN_MODE_TOOL_NAME,
28
+ getMessageContent,
29
+ type Message,
30
+ type PermissionDecision,
31
+ type PermissionMode,
32
+ type ToolPermissionContext,
33
+ } from "wave-agent-sdk";
34
+ import { SocketClient } from "./socketClient.js";
35
+
36
+ /** Fixed default daemon socket (spec: 默认 socket 固定,无 --socket 覆盖). */
37
+ export const DEFAULT_DAEMON_SOCKET = path.join(
38
+ os.homedir(),
39
+ ".wave",
40
+ "daemon.sock",
41
+ );
42
+
43
+ const PERMISSION_MODES: PermissionMode[] = [
44
+ "default",
45
+ "bypassPermissions",
46
+ "acceptEdits",
47
+ "plan",
48
+ "dontAsk",
49
+ ];
50
+
51
+ // ── Connection helpers ─────────────────────────────────────────
52
+
53
+ function connectDaemon(socketPath: string): Promise<SocketClient> {
54
+ return new Promise((resolve, reject) => {
55
+ const socket = net.createConnection(socketPath);
56
+ socket.once("connect", () => resolve(new SocketClient(socket)));
57
+ socket.once("error", (err) => {
58
+ socket.destroy();
59
+ reject(err);
60
+ });
61
+ });
62
+ }
63
+
64
+ /** Connect or fail fast with the spec'd error; daemon idle-exits after 60s. */
65
+ async function connectDaemonOrExit(socketPath: string): Promise<SocketClient> {
66
+ try {
67
+ return await connectDaemon(socketPath);
68
+ } catch (err) {
69
+ const code = (err as NodeJS.ErrnoException).code;
70
+ console.error(
71
+ `无法连接 daemon socket ${socketPath}:daemon 未运行?(daemon 空闲 60 秒自动退出)` +
72
+ (code ? ` (${code})` : ""),
73
+ );
74
+ process.exit(1);
75
+ }
76
+ }
77
+
78
+ function fail(message: string): never {
79
+ console.error(message);
80
+ process.exit(1);
81
+ }
82
+
83
+ interface DaemonSessionEntry {
84
+ sessionId: string;
85
+ workingDirectory: string;
86
+ isLoading: boolean;
87
+ messageCount: number;
88
+ }
89
+
90
+ interface PendingPermission {
91
+ requestId: string;
92
+ sessionId?: string;
93
+ context: ToolPermissionContext;
94
+ }
95
+
96
+ /**
97
+ * Attach to a session; returns the initialized sessionId + working directory.
98
+ * Exits (nonzero) with the spec'd error when the session exists neither in the
99
+ * daemon registry nor on disk, destroying the fresh session that `initialize`
100
+ * silently created.
101
+ */
102
+ async function attachSession(
103
+ client: SocketClient,
104
+ sessionId: string,
105
+ ): Promise<{ sessionId: string; workingDirectory: string }> {
106
+ const init = (await client.request("initialize", {
107
+ workdir: process.cwd(),
108
+ restoreSessionId: sessionId,
109
+ })) as { sessionId: string; workingDirectory: string };
110
+ const initId = init.sessionId;
111
+ try {
112
+ await client.request("restoreSession", { sessionId }, initId);
113
+ } catch (err) {
114
+ if ((err as Error).message.includes("Session not found")) {
115
+ // initialize silently started a junk fresh session — remove it from the
116
+ // registry so the failed attach leaves no trace (spec: 会话不存在错误).
117
+ await client.request("destroy", undefined, initId).catch(() => {});
118
+ fail(`会话不存在或未被该 daemon 托管:${sessionId}`);
119
+ }
120
+ throw err;
121
+ }
122
+ return init;
123
+ }
124
+
125
+ async function listPendingPermissions(
126
+ client: SocketClient,
127
+ ): Promise<PendingPermission[]> {
128
+ const result = (await client.request("listPendingPermissions")) as {
129
+ requests: PendingPermission[];
130
+ };
131
+ return result.requests ?? [];
132
+ }
133
+
134
+ function sleep(ms: number): Promise<void> {
135
+ return new Promise((resolve) => setTimeout(resolve, ms));
136
+ }
137
+
138
+ // ── list ───────────────────────────────────────────────────────
139
+
140
+ export async function daemonListCommand(socketPath: string): Promise<void> {
141
+ let client: SocketClient | undefined;
142
+ try {
143
+ client = await connectDaemonOrExit(socketPath);
144
+ const result = (await client.request("listDaemonSessions")) as {
145
+ sessions: DaemonSessionEntry[];
146
+ };
147
+ const sessions = result.sessions ?? [];
148
+
149
+ if (sessions.length > 0) {
150
+ const rows = sessions.map((s) => ({
151
+ sessionId: s.sessionId,
152
+ status: s.isLoading ? "生成中" : "空闲",
153
+ messageCount: String(s.messageCount),
154
+ workingDirectory: s.workingDirectory,
155
+ }));
156
+ const width = (key: keyof (typeof rows)[number]) =>
157
+ Math.max(...rows.map((r) => r[key].length), key.length);
158
+ const pad = (value: string, w: number) => value.padEnd(w);
159
+
160
+ console.log(
161
+ `${pad("会话", width("sessionId"))} ${pad("状态", width("status"))} ${pad("消息数", width("messageCount"))} 工作目录`,
162
+ );
163
+ for (const r of rows) {
164
+ console.log(
165
+ `${pad(r.sessionId, width("sessionId"))} ${pad(r.status, width("status"))} ${pad(r.messageCount, width("messageCount"))} ${r.workingDirectory}`,
166
+ );
167
+ }
168
+ } else {
169
+ // Daemon idle-exit is normal — an empty registry is not an error.
170
+ console.log("无会话");
171
+ }
172
+ } catch (err) {
173
+ fail(`wave daemon list 失败:${(err as Error).message}`);
174
+ } finally {
175
+ await client?.dispose();
176
+ }
177
+ // Exits outside the try so the success path's exit is never re-wrapped by the
178
+ // error handler above.
179
+ process.exit(0);
180
+ }
181
+
182
+ // ── status ─────────────────────────────────────────────────────
183
+
184
+ function summarizeToolInput(context: ToolPermissionContext): string {
185
+ const input = context.toolInput;
186
+ if (!input || Object.keys(input).length === 0) return "";
187
+ let text: string;
188
+ try {
189
+ text = JSON.stringify(input);
190
+ } catch {
191
+ text = "";
192
+ }
193
+ return text.length > 80 ? `${text.slice(0, 80)}…` : text;
194
+ }
195
+
196
+ export async function daemonStatusCommand(
197
+ socketPath: string,
198
+ sessionId: string,
199
+ lines = 20,
200
+ ): Promise<void> {
201
+ let client: SocketClient | undefined;
202
+ try {
203
+ client = await connectDaemonOrExit(socketPath);
204
+
205
+ // Subscribe BEFORE initialize/restoreSession so the replayed loadingChange
206
+ // snapshot is captured (spec: 依据重放的 loadingChange 快照显示状态).
207
+ let loading = false;
208
+ client.onNotification("loadingChange", (params) => {
209
+ loading = (params as { loading: boolean }).loading;
210
+ });
211
+
212
+ const init = await attachSession(client, sessionId);
213
+ const initId = init.sessionId;
214
+
215
+ // listPendingPermissions is the authoritative "waiting for approval" signal
216
+ // (spec: 单凭消息无法区分等审批与执行中,须结合 listPendingPermissions).
217
+ const pending = (await listPendingPermissions(client)).filter(
218
+ (r) => r.sessionId === initId || r.sessionId === sessionId,
219
+ );
220
+ const messages = (await client.request(
221
+ "getMessages",
222
+ undefined,
223
+ initId,
224
+ )) as {
225
+ messages: Message[];
226
+ };
227
+
228
+ const status =
229
+ pending.length > 0 ? "等待审批" : loading ? "生成中" : "空闲";
230
+ console.log(`会话: ${initId}`);
231
+ console.log(`工作目录: ${init.workingDirectory}`);
232
+ console.log(`状态: ${status}`);
233
+
234
+ if (pending.length > 0) {
235
+ console.log("");
236
+ console.log("待审批请求:");
237
+ for (const r of pending) {
238
+ const params = summarizeToolInput(r.context);
239
+ console.log(
240
+ ` ${r.requestId} ${r.context.toolName}${params ? ` ${params}` : ""}`,
241
+ );
242
+ }
243
+ }
244
+
245
+ const recent = messages.messages.slice(-lines);
246
+ if (recent.length > 0) {
247
+ console.log("");
248
+ console.log(`最近消息 (${recent.length}):`);
249
+ for (const m of recent) {
250
+ const text = getMessageContent(m).replace(/\s+/g, " ").trim();
251
+ if (!text) continue; // tool-only messages carry no readable text
252
+ console.log(` [${m.role}] ${text}`);
253
+ }
254
+ }
255
+ } catch (err) {
256
+ fail(`wave daemon status 失败:${(err as Error).message}`);
257
+ } finally {
258
+ await client?.dispose();
259
+ }
260
+ process.exit(0);
261
+ }
262
+
263
+ // ── send ───────────────────────────────────────────────────────
264
+
265
+ export interface SendOptions {
266
+ timeout: number; // seconds; 0 = no limit (default 600)
267
+ }
268
+
269
+ /**
270
+ * Send a message and wait for the reply that corresponds to it.
271
+ *
272
+ * Completion detection: `sendMessage` on an idle session resolves only after
273
+ * the whole turn finishes (InteractionService awaits sendAIMessage), while on a
274
+ * busy session it enqueues and returns immediately — so stopping on a bare
275
+ * `loadingChange:false` would exit early on the PREVIOUS turn's completion when
276
+ * queued behind a busy session. Instead, track the message IDs: `ourUserMessage`
277
+ * is the user message added when OUR turn starts (userMessageAdded), and the
278
+ * reply is the last assistantMessageAdded observed after it. A stale
279
+ * loading:false can then never satisfy the wait condition early (the reply has
280
+ * not been added yet).
281
+ */
282
+ export async function daemonSendCommand(
283
+ socketPath: string,
284
+ sessionId: string,
285
+ message: string,
286
+ options: SendOptions = { timeout: 600 },
287
+ ): Promise<void> {
288
+ // connectDaemonOrExit exits on failure — no client to dispose in that case.
289
+ const client = await connectDaemonOrExit(socketPath);
290
+
291
+ let loading = false;
292
+ let sent = false;
293
+ let ourUserMessageId: string | undefined;
294
+ let replyMessageId: string | undefined;
295
+ client.onNotification("userMessageAdded", (params) => {
296
+ if (!sent) return; // ignore messages added during attach
297
+ ourUserMessageId = (params as { message: Message }).message.id;
298
+ });
299
+ client.onNotification("assistantMessageAdded", (params) => {
300
+ if (!sent || ourUserMessageId === undefined) return; // not our turn yet
301
+ replyMessageId = (params as { message: Message }).message.id;
302
+ });
303
+ client.onNotification("loadingChange", (params) => {
304
+ loading = (params as { loading: boolean }).loading;
305
+ });
306
+
307
+ let initId: string;
308
+ try {
309
+ initId = (await attachSession(client, sessionId)).sessionId;
310
+ sent = true;
311
+ await client.request("sendMessage", { text: message }, initId);
312
+ } catch (err) {
313
+ client.dispose();
314
+ fail(`wave daemon send 失败:${(err as Error).message}`);
315
+ }
316
+
317
+ // Wait for the reply that corresponds to our message.
318
+ const started = Date.now();
319
+ const timeoutMs = options.timeout === 0 ? Infinity : options.timeout * 1000;
320
+ while (!(loading === false && replyMessageId !== undefined)) {
321
+ if (Date.now() - started > timeoutMs) {
322
+ // Timeout backstop: the most likely cause is a session waiting on a
323
+ // permission approval — point the user at respond (spec: 不无限期挂起).
324
+ const pending = (await listPendingPermissions(client)).filter(
325
+ (r) => r.sessionId === sessionId || r.sessionId === initId,
326
+ );
327
+ client.dispose();
328
+ if (pending.length > 0) {
329
+ fail(
330
+ `会话等待权限审批,请通过 \`wave daemon respond ${sessionId} ${pending[0].requestId}\` 处理后重试`,
331
+ );
332
+ }
333
+ fail(
334
+ options.timeout === 0
335
+ ? "等待回复超时"
336
+ : `等待回复超时(${options.timeout} 秒),未收到助手回复`,
337
+ );
338
+ }
339
+ await sleep(200);
340
+ }
341
+
342
+ try {
343
+ const result = (await client.request("getMessages", undefined, initId)) as {
344
+ messages: Message[];
345
+ };
346
+ const reply = result.messages.find((m) => m.id === replyMessageId);
347
+ // Pure final-reply text only; streaming deltas / subagent internals never
348
+ // reach stdout (spec: send 输出纯净性).
349
+ if (reply) {
350
+ const content = getMessageContent(reply).replace(/\s+/g, " ").trim();
351
+ if (content) console.log(content);
352
+ }
353
+ } catch (err) {
354
+ fail(`wave daemon send 失败:${(err as Error).message}`);
355
+ } finally {
356
+ client.dispose();
357
+ }
358
+ process.exit(0);
359
+ }
360
+
361
+ // ── respond ────────────────────────────────────────────────────
362
+
363
+ export interface RespondOptions {
364
+ allow?: boolean;
365
+ deny?: boolean;
366
+ reason?: string;
367
+ answer?: string;
368
+ rule?: string;
369
+ mode?: string;
370
+ }
371
+
372
+ export async function daemonRespondCommand(
373
+ socketPath: string,
374
+ sessionId: string,
375
+ requestId: string,
376
+ options: RespondOptions,
377
+ ): Promise<void> {
378
+ if (!!options.allow === !!options.deny) {
379
+ fail("请指定 --allow 或 --deny(二选一)");
380
+ }
381
+ let client: SocketClient | undefined;
382
+ try {
383
+ client = await connectDaemonOrExit(socketPath);
384
+
385
+ // The server silently ignores permissionResponse for unknown requestIds —
386
+ // validate first so the user is never misled into thinking approval landed.
387
+ const pending = await listPendingPermissions(client);
388
+ const req = pending.find((r) => r.requestId === requestId);
389
+ if (!req) {
390
+ fail("该请求不存在或已处理");
391
+ }
392
+ if (req.sessionId && req.sessionId !== sessionId) {
393
+ // Cross-check before notifying; never touch another session's request.
394
+ fail("会话不存在或未被该 daemon 托管");
395
+ }
396
+
397
+ let decision: PermissionDecision;
398
+ if (options.deny) {
399
+ decision = { behavior: "deny", message: options.reason };
400
+ } else {
401
+ // Per-tool auto-completion, mirroring the desktop ConfirmationDialog
402
+ // semantics (spec: 决策并非单一 allow/deny,须按工具智能补全).
403
+ const toolName = req.context.toolName;
404
+ if (toolName === ENTER_PLAN_MODE_TOOL_NAME) {
405
+ decision = { behavior: "allow", newPermissionMode: "plan" };
406
+ } else if (toolName === EXIT_PLAN_MODE_TOOL_NAME) {
407
+ decision = { behavior: "allow", newPermissionMode: "default" };
408
+ } else if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
409
+ if (!options.answer) {
410
+ fail("AskUserQuestion 请求需要 --answer 提供答案 JSON");
411
+ }
412
+ let answers: unknown;
413
+ try {
414
+ answers = JSON.parse(options.answer);
415
+ } catch {
416
+ fail("--answer 不是合法的 JSON");
417
+ }
418
+ decision = { behavior: "allow", message: JSON.stringify(answers) };
419
+ } else {
420
+ decision = { behavior: "allow" };
421
+ }
422
+
423
+ if (options.rule) decision.newPermissionRule = options.rule;
424
+ if (options.mode) {
425
+ if (!PERMISSION_MODES.includes(options.mode as PermissionMode)) {
426
+ fail(
427
+ `无效的权限模式:${options.mode}(可选:${PERMISSION_MODES.join("、")})`,
428
+ );
429
+ }
430
+ decision.newPermissionMode = options.mode as PermissionMode;
431
+ }
432
+ }
433
+
434
+ // Mirror desktop stdioAgent.sendPermissionResponse: envelope sessionId
435
+ // present, decision built from the pending request's tool.
436
+ client.notify("permissionResponse", { requestId, decision }, sessionId);
437
+ console.log(`已处理审批请求:${requestId}`);
438
+ } catch (err) {
439
+ fail(`wave daemon respond 失败:${(err as Error).message}`);
440
+ } finally {
441
+ await client?.dispose();
442
+ }
443
+ process.exit(0);
444
+ }