wave-code 1.0.0 → 1.0.1

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.
Files changed (70) hide show
  1. package/dist/cli.js +20 -1
  2. package/dist/components/App.js +7 -0
  3. package/dist/components/BtwDisplay.js +13 -3
  4. package/dist/components/ChatInterface.js +21 -6
  5. package/dist/components/InputBox.d.ts +0 -3
  6. package/dist/components/InputBox.js +11 -9
  7. package/dist/components/LoadingIndicator.d.ts +1 -2
  8. package/dist/components/LoadingIndicator.js +2 -2
  9. package/dist/components/Markdown.js +13 -16
  10. package/dist/components/MessageList.d.ts +2 -1
  11. package/dist/components/MessageList.js +2 -2
  12. package/dist/components/StatusLine.d.ts +0 -2
  13. package/dist/components/StatusLine.js +6 -6
  14. package/dist/components/TaskList.js +2 -1
  15. package/dist/components/ToolDisplay.d.ts +1 -0
  16. package/dist/components/ToolDisplay.js +17 -9
  17. package/dist/constants/commands.js +0 -6
  18. package/dist/contexts/useChat.d.ts +3 -5
  19. package/dist/contexts/useChat.js +242 -82
  20. package/dist/daemon-cli.d.ts +10 -0
  21. package/dist/daemon-cli.js +15 -0
  22. package/dist/hooks/useInputManager.js +99 -22
  23. package/dist/index.js +10 -0
  24. package/dist/managers/inputHandlers.js +50 -22
  25. package/dist/managers/inputReducer.d.ts +12 -2
  26. package/dist/managers/inputReducer.js +57 -9
  27. package/dist/stdio/agentBridge.d.ts +23 -0
  28. package/dist/stdio/agentBridge.js +126 -16
  29. package/dist/stdio/daemonServer.d.ts +67 -0
  30. package/dist/stdio/daemonServer.js +191 -0
  31. package/dist/stdio/index.d.ts +2 -0
  32. package/dist/stdio/index.js +2 -0
  33. package/dist/stdio/jsonRpcConnection.d.ts +30 -0
  34. package/dist/stdio/jsonRpcConnection.js +127 -0
  35. package/dist/stdio/protocol.d.ts +2 -2
  36. package/dist/stdio/stdioServer.d.ts +2 -7
  37. package/dist/stdio/stdioServer.js +9 -100
  38. package/dist/utils/bracketedPaste.d.ts +39 -0
  39. package/dist/utils/bracketedPaste.js +122 -0
  40. package/dist/utils/markdownTable.d.ts +34 -0
  41. package/dist/utils/markdownTable.js +302 -0
  42. package/dist/utils/throttle.d.ts +3 -3
  43. package/package.json +4 -2
  44. package/src/cli.tsx +20 -1
  45. package/src/components/App.tsx +5 -0
  46. package/src/components/BtwDisplay.tsx +36 -12
  47. package/src/components/ChatInterface.tsx +25 -12
  48. package/src/components/InputBox.tsx +10 -18
  49. package/src/components/LoadingIndicator.tsx +1 -4
  50. package/src/components/Markdown.tsx +15 -18
  51. package/src/components/MessageList.tsx +6 -0
  52. package/src/components/StatusLine.tsx +0 -10
  53. package/src/components/TaskList.tsx +2 -1
  54. package/src/components/ToolDisplay.tsx +17 -6
  55. package/src/constants/commands.ts +0 -6
  56. package/src/contexts/useChat.tsx +310 -95
  57. package/src/daemon-cli.ts +17 -0
  58. package/src/hooks/useInputManager.ts +108 -22
  59. package/src/index.ts +12 -0
  60. package/src/managers/inputHandlers.ts +49 -22
  61. package/src/managers/inputReducer.ts +66 -11
  62. package/src/stdio/agentBridge.ts +188 -17
  63. package/src/stdio/daemonServer.ts +212 -0
  64. package/src/stdio/index.ts +2 -0
  65. package/src/stdio/jsonRpcConnection.ts +160 -0
  66. package/src/stdio/protocol.ts +5 -2
  67. package/src/stdio/stdioServer.ts +14 -120
  68. package/src/utils/bracketedPaste.ts +170 -0
  69. package/src/utils/markdownTable.ts +359 -0
  70. package/src/utils/throttle.ts +8 -8
@@ -33,15 +33,27 @@ export const handleSubmit = async (state, dispatch, callbacks, attachedImagesOve
33
33
  const question = contentWithPlaceholders.startsWith("/btw ")
34
34
  ? contentWithPlaceholders.substring(5).trim()
35
35
  : "";
36
- const payload = {
37
- isActive: true,
38
- question,
39
- isLoading: question !== "",
40
- };
41
- dispatch({
42
- type: "SET_BTW_STATE",
43
- payload,
44
- });
36
+ if (question) {
37
+ dispatch({
38
+ type: "SET_BTW_STATE",
39
+ payload: {
40
+ question,
41
+ isLoading: true,
42
+ answer: undefined,
43
+ },
44
+ });
45
+ }
46
+ else {
47
+ // Bare /btw — show usage (aligned with Claude Code)
48
+ dispatch({
49
+ type: "SET_BTW_STATE",
50
+ payload: {
51
+ question: "",
52
+ isLoading: false,
53
+ answer: "Usage: /btw <your question>",
54
+ },
55
+ });
56
+ }
45
57
  dispatch({ type: "CLEAR_INPUT" });
46
58
  dispatch({ type: "RESET_HISTORY_NAVIGATION" });
47
59
  dispatch({ type: "CLEAR_LONG_TEXT_MAP" });
@@ -261,6 +273,18 @@ export const handleCommandSelect = (state, dispatch, callbacks, command) => {
261
273
  else if (command === "status") {
262
274
  dispatch({ type: "SET_SHOW_STATUS_COMMAND", payload: true });
263
275
  }
276
+ else if (command === "btw") {
277
+ // Bare /btw executed via the command selector — show usage
278
+ // (aligned with Claude Code's empty-args message).
279
+ dispatch({
280
+ type: "SET_BTW_STATE",
281
+ payload: {
282
+ question: "",
283
+ isLoading: false,
284
+ answer: "Usage: /btw <your question>",
285
+ },
286
+ });
287
+ }
264
288
  else if (command === "plugin") {
265
289
  dispatch({ type: "SET_SHOW_PLUGIN_MANAGER", payload: true });
266
290
  }
@@ -276,9 +300,6 @@ export const handleCommandSelect = (state, dispatch, callbacks, command) => {
276
300
  else if (command === "compact") {
277
301
  await callbacks.onCompact?.();
278
302
  }
279
- else if (command === "goal") {
280
- await callbacks.onGoalCommand?.();
281
- }
282
303
  }
283
304
  })();
284
305
  dispatch({ type: "CANCEL_COMMAND_SELECTOR" });
@@ -507,16 +528,23 @@ export const handleNormalInput = async (state, dispatch, callbacks, input, key,
507
528
  return false;
508
529
  };
509
530
  export const handleInput = async (state, dispatch, callbacks, input, key, clearImages) => {
510
- // Handle ESC to dismiss btw answer
511
- if (key.escape && state.btwState.question && !state.btwState.isLoading) {
512
- dispatch({
513
- type: "SET_BTW_STATE",
514
- payload: {
515
- question: "",
516
- answer: undefined,
517
- isLoading: false,
518
- },
519
- });
531
+ // /btw overlay handling (mirrors inputReducer's HANDLE_KEY block; this
532
+ // handler is not wired, kept in sync for consistency). Active while a
533
+ // question is displayed, or the bare-/btw usage message shows. Only
534
+ // Escape dismisses; every other key is ignored.
535
+ if (state.btwState.question || state.btwState.answer) {
536
+ if (key.escape) {
537
+ dispatch({
538
+ type: "SET_BTW_STATE",
539
+ payload: {
540
+ question: "",
541
+ answer: undefined,
542
+ isLoading: false,
543
+ },
544
+ });
545
+ return true;
546
+ }
547
+ // Any other key while the overlay is up is ignored
520
548
  return true;
521
549
  }
522
550
  if (key.escape) {
@@ -10,6 +10,15 @@ export interface BtwState {
10
10
  answer?: string;
11
11
  isLoading: boolean;
12
12
  }
13
+ /**
14
+ * True while the /btw overlay is up (a question is on display, loading or
15
+ * answered). App's Ctrl+C exit handler checks this so Ctrl+C does not quit
16
+ * the app while the overlay owns the keys. Synced from useInputManager via
17
+ * an effect.
18
+ */
19
+ export declare const btwOverlayActiveRef: {
20
+ current: boolean;
21
+ };
13
22
  export declare const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
14
23
  export type PendingEffect = {
15
24
  type: "SEND_MESSAGE";
@@ -30,6 +39,8 @@ export type PendingEffect = {
30
39
  } | {
31
40
  type: "ASK_BTW";
32
41
  question: string;
42
+ } | {
43
+ type: "ABORT_BTW";
33
44
  } | {
34
45
  type: "PERMISSION_MODE_CHANGE";
35
46
  mode: PermissionMode;
@@ -67,10 +78,9 @@ export interface InputManagerCallbacks {
67
78
  onAbortMessage?: () => void;
68
79
  onBackgroundCurrentTask?: () => void;
69
80
  onPermissionModeChange?: (mode: PermissionMode) => void;
70
- onAskBtw?: (question: string) => Promise<string>;
81
+ onAskBtw?: (question: string, abortSignal?: AbortSignal, onContent?: (content: string) => void) => Promise<string>;
71
82
  onClearMessages?: () => Promise<void>;
72
83
  onCompact?: (instructions?: string) => Promise<void>;
73
- onGoalCommand?: (args?: string) => Promise<void>;
74
84
  sessionId?: string;
75
85
  workdir?: string;
76
86
  getFullMessageThread?: () => Promise<{
@@ -1,5 +1,12 @@
1
1
  import { getAtSelectorPosition, getSlashSelectorPosition, getWordEnd, SELECTOR_TRIGGERS, getProjectedState, } from "../utils/inputUtils.js";
2
2
  import { AVAILABLE_COMMANDS } from "../constants/commands.js";
3
+ /**
4
+ * True while the /btw overlay is up (a question is on display, loading or
5
+ * answered). App's Ctrl+C exit handler checks this so Ctrl+C does not quit
6
+ * the app while the overlay owns the keys. Synced from useInputManager via
7
+ * an effect.
8
+ */
9
+ export const btwOverlayActiveRef = { current: false };
3
10
  export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
4
11
  export const initialState = {
5
12
  inputText: "",
@@ -91,7 +98,7 @@ function insertTextWithPlaceholder(textToInsert, state) {
91
98
  /**
92
99
  * Submit the current input text: extract [Image #N] references, route /btw
93
100
  * and CLI-internal slash commands, otherwise send as a message. Returns null
94
- * when there is nothing to submit (empty text, bare /btw).
101
+ * when there is nothing to submit (empty text).
95
102
  */
96
103
  function submitInput(state) {
97
104
  if (!state.inputText.trim()) {
@@ -112,8 +119,20 @@ function submitInput(state) {
112
119
  if (contentWithPlaceholders.startsWith("/btw ")) {
113
120
  const question = contentWithPlaceholders.substring(5).trim();
114
121
  if (!question) {
115
- // Bare /btw with no question text — ignore
116
- return null;
122
+ // "/btw " with no question text — show usage (aligned with Claude Code)
123
+ return {
124
+ ...state,
125
+ inputText: "",
126
+ cursorPosition: 0,
127
+ historyIndex: -1,
128
+ longTextMap: {},
129
+ attachedImages: [],
130
+ btwState: {
131
+ question: "",
132
+ isLoading: false,
133
+ answer: "Usage: /btw <your question>",
134
+ },
135
+ };
117
136
  }
118
137
  return {
119
138
  ...state,
@@ -131,8 +150,20 @@ function submitInput(state) {
131
150
  };
132
151
  }
133
152
  if (contentWithPlaceholders === "/btw") {
134
- // Bare /btw — ignore
135
- return null;
153
+ // Bare /btw — show usage (aligned with Claude Code)
154
+ return {
155
+ ...state,
156
+ inputText: "",
157
+ cursorPosition: 0,
158
+ historyIndex: -1,
159
+ longTextMap: {},
160
+ attachedImages: [],
161
+ btwState: {
162
+ question: "",
163
+ isLoading: false,
164
+ answer: "Usage: /btw <your question>",
165
+ },
166
+ };
136
167
  }
137
168
  // Check if the content is a CLI-internal slash command (help, tasks,
138
169
  // etc.) that should be executed locally rather than sent as a message.
@@ -593,10 +624,22 @@ export function inputReducer(state, action) {
593
624
  }
594
625
  return state;
595
626
  }
596
- // 1. Escape Handling
597
- if (key.escape) {
598
- // Dismiss btw answer
599
- if (state.btwState.question && !state.btwState.isLoading) {
627
+ // 1. /btw overlay handling (active while a question is displayed, or
628
+ // the bare-/btw usage message). Only Escape dismisses (or aborts the
629
+ // in-flight side question while loading); every other key is ignored.
630
+ if (state.btwState.question || state.btwState.answer) {
631
+ if (key.escape) {
632
+ if (state.btwState.isLoading) {
633
+ return {
634
+ ...state,
635
+ btwState: {
636
+ question: "",
637
+ answer: undefined,
638
+ isLoading: false,
639
+ },
640
+ pendingEffect: { type: "ABORT_BTW" },
641
+ };
642
+ }
600
643
  return {
601
644
  ...state,
602
645
  btwState: {
@@ -606,6 +649,11 @@ export function inputReducer(state, action) {
606
649
  },
607
650
  };
608
651
  }
652
+ // Any other key while the overlay is up is ignored
653
+ return state;
654
+ }
655
+ // 1. Escape Handling
656
+ if (key.escape) {
609
657
  if (state.showFileSelector) {
610
658
  return {
611
659
  ...state,
@@ -21,6 +21,9 @@ export interface AgentBridgeOptions {
21
21
  }
22
22
  export declare class AgentBridge {
23
23
  private sessions;
24
+ /** Pending approval requests, keyed by requestId. Stored with the resolve +
25
+ * context so a re-attached client can list and respond to them (daemon mode:
26
+ * approvals outlive any single connection). */
24
27
  private pendingPermissions;
25
28
  private permissionCounter;
26
29
  private emit;
@@ -31,6 +34,21 @@ export declare class AgentBridge {
31
34
  handleNotification(method: string, params: unknown): void;
32
35
  private initialize;
33
36
  private destroy;
37
+ /**
38
+ * True when every hosted session has settled: not generating, nothing queued,
39
+ * and no background work (background bash / subagents / workflows) — the same
40
+ * condition `wave -p` waits on before exiting (print-cli.ts). Pending
41
+ * permission approvals keep the owning agent's isLoading true, so they are
42
+ * covered without an explicit check.
43
+ */
44
+ isIdle(): boolean;
45
+ /**
46
+ * Destroy every hosted session agent. Each Agent.destroy() saves its
47
+ * transcript, drains in-flight auto-memory extraction, and cleans up
48
+ * background tasks/subagents. Best-effort: one failing destroy must not
49
+ * block the rest of the shutdown.
50
+ */
51
+ destroyAll(): Promise<void>;
34
52
  private restoreSession;
35
53
  private listSessions;
36
54
  private listGitBranches;
@@ -40,6 +58,7 @@ export declare class AgentBridge {
40
58
  private updateConfig;
41
59
  private sendMessage;
42
60
  private bang;
61
+ private askBtw;
43
62
  private abortMessage;
44
63
  private clearMessages;
45
64
  private rewindToMessage;
@@ -64,6 +83,10 @@ export declare class AgentBridge {
64
83
  private getPromptHistory;
65
84
  private searchPromptHistory;
66
85
  private canUseTool;
86
+ /** Attach snapshot: re-surface approvals that are still pending after a
87
+ * client disconnected (daemon mode). Responding to any listed requestId
88
+ * resolves the in-process promise. */
89
+ private listPendingPermissions;
67
90
  private getAuthStatus;
68
91
  private login;
69
92
  private logout;
@@ -23,6 +23,9 @@ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
23
23
  export class AgentBridge {
24
24
  constructor(options) {
25
25
  this.sessions = new Map();
26
+ /** Pending approval requests, keyed by requestId. Stored with the resolve +
27
+ * context so a re-attached client can list and respond to them (daemon mode:
28
+ * approvals outlive any single connection). */
26
29
  this.pendingPermissions = new Map();
27
30
  this.permissionCounter = 0;
28
31
  this.emit = options.emit;
@@ -42,6 +45,8 @@ export class AgentBridge {
42
45
  return this.listSessions(p.workdir, sessionId);
43
46
  case "getSessionInfo":
44
47
  return this.getSessionInfo(sessionId);
48
+ case "listPendingPermissions":
49
+ return this.listPendingPermissions();
45
50
  case "updateConfig":
46
51
  return this.updateConfig(p, sessionId);
47
52
  // ── Messages ──
@@ -49,6 +54,8 @@ export class AgentBridge {
49
54
  return this.sendMessage(p, sessionId);
50
55
  case "bang":
51
56
  return this.bang(p.command, sessionId);
57
+ case "askBtw":
58
+ return this.askBtw(p.question, sessionId);
52
59
  case "abortMessage":
53
60
  return this.abortMessage(sessionId);
54
61
  case "clearMessages":
@@ -147,15 +154,29 @@ export class AgentBridge {
147
154
  if (method === "permissionResponse") {
148
155
  const p = params;
149
156
  // requestId is process-level unique; lookup doesn't need sessionId
150
- const resolve = this.pendingPermissions.get(p.requestId);
151
- if (resolve) {
157
+ const entry = this.pendingPermissions.get(p.requestId);
158
+ if (entry) {
152
159
  this.pendingPermissions.delete(p.requestId);
153
- resolve(p.decision);
160
+ entry.resolve(p.decision);
154
161
  }
155
162
  }
156
163
  }
157
164
  // ── Lifecycle ─────────────────────────────────────────────────
158
165
  async initialize(params) {
166
+ // Re-attach (daemon mode): if the target session is already live in this
167
+ // process, reuse it instead of creating a second agent writing to the same
168
+ // transcript. The live agent keeps running across client detach/attach.
169
+ if (params.restoreSessionId) {
170
+ const live = this.sessions.get(params.restoreSessionId);
171
+ if (live) {
172
+ return {
173
+ sessionId: live.agent.sessionId,
174
+ workingDirectory: live.agent.workingDirectory,
175
+ permissionMode: live.agent.getPermissionMode(),
176
+ latestTotalTokens: live.agent.latestTotalTokens,
177
+ };
178
+ }
179
+ }
159
180
  const ctx = {};
160
181
  const callbacks = this.createCallbacks(ctx);
161
182
  const options = {
@@ -203,8 +224,53 @@ export class AgentBridge {
203
224
  }
204
225
  return null;
205
226
  }
227
+ /**
228
+ * True when every hosted session has settled: not generating, nothing queued,
229
+ * and no background work (background bash / subagents / workflows) — the same
230
+ * condition `wave -p` waits on before exiting (print-cli.ts). Pending
231
+ * permission approvals keep the owning agent's isLoading true, so they are
232
+ * covered without an explicit check.
233
+ */
234
+ isIdle() {
235
+ for (const entry of this.sessions.values()) {
236
+ const agent = entry.agent;
237
+ if (agent.isLoading ||
238
+ agent.hasPendingMessages ||
239
+ agent.hasRunningBackgroundWork) {
240
+ return false;
241
+ }
242
+ }
243
+ return true;
244
+ }
245
+ /**
246
+ * Destroy every hosted session agent. Each Agent.destroy() saves its
247
+ * transcript, drains in-flight auto-memory extraction, and cleans up
248
+ * background tasks/subagents. Best-effort: one failing destroy must not
249
+ * block the rest of the shutdown.
250
+ */
251
+ async destroyAll() {
252
+ const entries = [...this.sessions.values()];
253
+ this.sessions.clear();
254
+ await Promise.all(entries.map((entry) => entry.agent.destroy().catch(() => { })));
255
+ }
206
256
  async restoreSession(restoreId, sessionId) {
207
257
  const entry = this.requireSession(sessionId);
258
+ // Re-attach to a live session: the SDK restore would no-op (target is
259
+ // already current). Emit the current messages so the freshly attached
260
+ // client — whose router registered only after initialize returned and so
261
+ // missed any earlier notifications — gets a snapshot without replay.
262
+ if (entry.agent.sessionId === restoreId) {
263
+ this.emit("messagesChange", { messages: entry.agent.messages }, entry.agent.sessionId);
264
+ // The re-attached client also missed the loading state that settled
265
+ // before its router registered — replay it or the client's
266
+ // isStreaming/running indicator stays false while the live session
267
+ // keeps generating.
268
+ this.emit("loadingChange", {
269
+ loading: entry.agent.isLoading,
270
+ latestTotalTokens: entry.agent.latestTotalTokens,
271
+ }, entry.agent.sessionId);
272
+ return null;
273
+ }
208
274
  await entry.agent.restoreSession(restoreId);
209
275
  return null;
210
276
  }
@@ -378,6 +444,22 @@ export class AgentBridge {
378
444
  await entry.agent.bang(command);
379
445
  return null;
380
446
  }
447
+ async askBtw(question, sessionId) {
448
+ const entry = this.requireSession(sessionId);
449
+ return entry.agent.askBtw(question, undefined,
450
+ // Stream partial content to the client so webview hosts can render the
451
+ // answer incrementally (thinking and content travel on separate
452
+ // channels so the panel can drop thinking text once content starts).
453
+ (content) => {
454
+ if (sessionId) {
455
+ this.emit("btwContent", { question, content, type: "content" }, sessionId);
456
+ }
457
+ }, (content) => {
458
+ if (sessionId) {
459
+ this.emit("btwContent", { question, content, type: "thinking" }, sessionId);
460
+ }
461
+ });
462
+ }
381
463
  async abortMessage(sessionId) {
382
464
  const entry = this.requireSession(sessionId);
383
465
  entry.agent.abortMessage();
@@ -522,10 +604,26 @@ export class AgentBridge {
522
604
  canUseTool(context, ctx) {
523
605
  const requestId = `perm_${++this.permissionCounter}`;
524
606
  return new Promise((resolve) => {
525
- this.pendingPermissions.set(requestId, resolve);
607
+ this.pendingPermissions.set(requestId, {
608
+ resolve,
609
+ sessionId: ctx.registeredSessionId,
610
+ context,
611
+ });
526
612
  this.emit("permissionRequest", { requestId, context }, ctx.registeredSessionId);
527
613
  });
528
614
  }
615
+ /** Attach snapshot: re-surface approvals that are still pending after a
616
+ * client disconnected (daemon mode). Responding to any listed requestId
617
+ * resolves the in-process promise. */
618
+ listPendingPermissions() {
619
+ return {
620
+ requests: [...this.pendingPermissions.entries()].map(([requestId, entry]) => ({
621
+ requestId,
622
+ sessionId: entry.sessionId,
623
+ context: entry.context,
624
+ })),
625
+ };
626
+ }
529
627
  // ── Auth (global) ────────────────────────────────────────────
530
628
  async getAuthStatus() {
531
629
  const authService = AuthService.getInstance();
@@ -627,9 +725,6 @@ export class AgentBridge {
627
725
  // ── Callbacks → Notifications ─────────────────────────────────
628
726
  createCallbacks(ctx) {
629
727
  return {
630
- onMessagesChange: (messages) => {
631
- this.emit("messagesChange", { messages }, ctx.registeredSessionId);
632
- },
633
728
  onUserMessageAdded: () => {
634
729
  const msg = this.findLastUserMessage(ctx.agent);
635
730
  if (msg)
@@ -641,13 +736,28 @@ export class AgentBridge {
641
736
  this.emit("assistantMessageAdded", { message: msg }, ctx.registeredSessionId);
642
737
  },
643
738
  onAssistantContentUpdated: (params) => {
644
- this.emit("assistantContentUpdated", params, ctx.registeredSessionId);
739
+ // Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
740
+ this.emit("assistantContentUpdated", {
741
+ messageId: params.messageId,
742
+ chunk: params.chunk,
743
+ stage: params.stage,
744
+ }, ctx.registeredSessionId);
645
745
  },
646
746
  onAssistantReasoningUpdated: (params) => {
647
- this.emit("assistantReasoningUpdated", params, ctx.registeredSessionId);
747
+ this.emit("assistantReasoningUpdated", {
748
+ messageId: params.messageId,
749
+ chunk: params.chunk,
750
+ stage: params.stage,
751
+ }, ctx.registeredSessionId);
648
752
  },
649
753
  onToolBlockUpdated: (params) => {
650
- this.emit("toolBlockUpdated", params, ctx.registeredSessionId);
754
+ // Streaming stages carry only the parametersChunk delta; start/running
755
+ // (one-time snapshots) and end (authoritative full value) keep
756
+ // `parameters` (spec: 流式通知纯增量负载).
757
+ const { parameters, ...rest } = params;
758
+ void parameters;
759
+ const wireParams = params.stage === "streaming" ? rest : params;
760
+ this.emit("toolBlockUpdated", wireParams, ctx.registeredSessionId);
651
761
  },
652
762
  onErrorBlockAdded: (error) => {
653
763
  this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
@@ -705,14 +815,14 @@ export class AgentBridge {
705
815
  onMcpServersChange: (servers) => {
706
816
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
707
817
  },
708
- onAddBangMessage: () => {
709
- this.emit("bangMessageAdded", {}, ctx.registeredSessionId);
818
+ onAddBangMessage: (command, messageId) => {
819
+ this.emit("bangMessageAdded", { command, messageId }, ctx.registeredSessionId);
710
820
  },
711
- onUpdateBangMessage: () => {
712
- this.emit("bangMessageUpdated", {}, ctx.registeredSessionId);
821
+ onUpdateBangMessage: (command, output, messageId) => {
822
+ this.emit("bangMessageUpdated", { command, output, messageId }, ctx.registeredSessionId);
713
823
  },
714
- onCompleteBangMessage: () => {
715
- this.emit("bangMessageCompleted", {}, ctx.registeredSessionId);
824
+ onCompleteBangMessage: (command, exitCode, messageId) => {
825
+ this.emit("bangMessageCompleted", { command, exitCode, messageId }, ctx.registeredSessionId);
716
826
  },
717
827
  onNotificationMessageAdded: (params) => {
718
828
  const msg = ctx.agent?.messages.find((m) => m.role === "user" &&
@@ -0,0 +1,67 @@
1
+ /**
2
+ * DaemonServer — JSON-RPC server over a unix socket for remote background
3
+ * sessions (spec: docs/specs/ui/desktop-app.md 「SSH 远程后台会话」).
4
+ *
5
+ * The desktop app launches `wave --daemon <socket>` on the remote host via
6
+ * nohup/setsid, then tunnels the socket back with `ssh -L`. All connections
7
+ * share one AgentBridge, so sessions and pending tool permissions survive
8
+ * client detach/attach — the daemon keeps running (and generating) while no
9
+ * desktop is connected. The daemon never exits on a client disconnect; it
10
+ * only exits when killed (app quit / 删除会话 / remote reboot).
11
+ *
12
+ * Idle auto-exit (spec: 「远程 daemon 空闲自动退出」): once every session has
13
+ * settled and no client is connected, the daemon mirrors `wave -p`'s exit
14
+ * semantics — after a grace period it destroys the sessions (saving their
15
+ * transcripts), closes the socket, and exits, so the remote process doesn't
16
+ * linger forever after background work completes.
17
+ */
18
+ import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
19
+ export interface DaemonServerOptions {
20
+ socketPath: string;
21
+ bridgeOptions?: AgentBridgeOptions;
22
+ /** Idle grace period before the daemon auto-exits (default 60s). */
23
+ graceMs?: number;
24
+ }
25
+ export declare class DaemonServer {
26
+ static readonly DEFAULT_IDLE_GRACE_MS = 60000;
27
+ private socketPath;
28
+ private server;
29
+ private bridge;
30
+ private connections;
31
+ private sockets;
32
+ private graceMs;
33
+ private idleTimer;
34
+ private shuttingDown;
35
+ private stopped;
36
+ constructor(options: DaemonServerOptions);
37
+ get agentBridge(): AgentBridge;
38
+ /**
39
+ * Listen on the socket path. Rejects when a live daemon already holds it. A
40
+ * stale socket file left by a crashed daemon would otherwise block the
41
+ * restart with EADDRINUSE, so it is cleaned up first: non-socket files are
42
+ * unlinked outright; socket files are probe-connected — ECONNREFUSED means
43
+ * no listener (stale → unlink and listen), anything else means a live
44
+ * daemon owns the path (reject).
45
+ */
46
+ start(): Promise<void>;
47
+ private listen;
48
+ stop(): Promise<void>;
49
+ private clearIdleTimer;
50
+ /**
51
+ * Re-evaluate the idle condition after any state transition: sessions busy
52
+ * (loading / pending messages / background work) or any client attached →
53
+ * cancel the timer. Fully idle + detached → arm the grace timer once; when
54
+ * it fires, shut the daemon down. Evaluation is event-driven (every
55
+ * busy→idle transition emits a notification, every attach/detach fires a
56
+ * connection event), so nothing is polled. The failure mode is
57
+ * conservative: a missed transition just leaves the daemon running.
58
+ */
59
+ private evaluateIdle;
60
+ /**
61
+ * Destroy the sessions (each agent saves its transcript and drains
62
+ * auto-memory), close the listener, unlink the socket file, then exit.
63
+ * `shuttingDown` guards against re-entry: new connections are refused and
64
+ * further idle evaluations become no-ops.
65
+ */
66
+ private shutdown;
67
+ }