wave-code 1.2.0 → 1.4.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.
Files changed (64) hide show
  1. package/dist/bundle/wave.mjs +1738 -893
  2. package/package.json +6 -3
  3. package/src/cli.tsx +9 -0
  4. package/src/commands/plugin/install.ts +6 -7
  5. package/src/commands/plugin/uninstall.ts +11 -5
  6. package/src/commands/update.ts +62 -22
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/ChatInterface.tsx +1 -0
  9. package/src/components/ConfirmationDetails.tsx +51 -16
  10. package/src/components/ConfirmationSelector.tsx +39 -0
  11. package/src/components/InputBox.tsx +41 -2
  12. package/src/components/Markdown.tsx +21 -8
  13. package/src/components/MarketplaceDetail.tsx +10 -27
  14. package/src/components/MarketplaceView.tsx +17 -3
  15. package/src/components/MessageBlockItem.tsx +0 -6
  16. package/src/components/Notifications.tsx +7 -0
  17. package/src/components/PlanView.tsx +22 -17
  18. package/src/components/PluginManagerShell.tsx +3 -3
  19. package/src/components/PluginManagerTypes.ts +2 -1
  20. package/src/components/ResumeCommand.tsx +84 -0
  21. package/src/components/ScrolledContent.tsx +71 -0
  22. package/src/components/SessionSelector.tsx +184 -6
  23. package/src/components/SessionSelectorFlow.tsx +246 -0
  24. package/src/components/WorkflowManager.tsx +38 -138
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +24 -0
  27. package/src/contexts/useChat.tsx +517 -155
  28. package/src/daemon/commands.ts +582 -50
  29. package/src/daemon-cli.ts +12 -1
  30. package/src/hooks/useInputManager.ts +20 -0
  31. package/src/hooks/usePluginManager.ts +104 -78
  32. package/src/hooks/useRemoteControl.ts +324 -0
  33. package/src/index.ts +97 -8
  34. package/src/managers/inputHandlers.ts +16 -9
  35. package/src/managers/inputReducer.ts +30 -9
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/confirmationReducer.ts +20 -1
  38. package/src/reducers/selectorReducer.ts +3 -0
  39. package/src/reducers/workflowManagerReducer.ts +21 -2
  40. package/src/remote/frameBudget.ts +45 -0
  41. package/src/remote/hostIdentity.ts +128 -0
  42. package/src/remote/messageWindow.ts +97 -0
  43. package/src/remote/notificationCoalescer.ts +174 -0
  44. package/src/remote/relayClient.ts +668 -0
  45. package/src/remote/remoteControlCliHost.ts +391 -0
  46. package/src/remote/remoteControlCommands.ts +197 -0
  47. package/src/remote/remoteControlConfig.ts +334 -0
  48. package/src/remote/remoteControlService.ts +799 -0
  49. package/src/remote/remoteControlSink.ts +77 -0
  50. package/src/remote/remoteNotifications.ts +194 -0
  51. package/src/session-selector-cli.tsx +9 -255
  52. package/src/stdio/agentBridge.ts +1001 -165
  53. package/src/stdio/daemonServer.ts +30 -0
  54. package/src/stdio/jsonRpcConnection.ts +11 -2
  55. package/src/stdio/protocol.ts +14 -106
  56. package/src/stdio/stdioServer.ts +33 -2
  57. package/src/stdio-cli.ts +16 -1
  58. package/src/utils/gitCredentialHelper.ts +106 -0
  59. package/src/utils/logger.ts +5 -2
  60. package/src/utils/rewindCheckpoints.ts +2 -4
  61. package/src/utils/sessionOwnership.ts +62 -0
  62. package/src/utils/workspaceDiff.ts +620 -0
  63. package/src/utils/worktree.ts +97 -0
  64. package/src/components/PlanDisplay.tsx +0 -18
@@ -5,10 +5,13 @@ import React, {
5
5
  useRef,
6
6
  useEffect,
7
7
  useState,
8
- useMemo,
9
8
  } from "react";
10
9
  import { useInput, useStdout } from "ink";
11
10
  import { useAppConfig } from "./useAppConfig.js";
11
+ import {
12
+ useRemoteControl,
13
+ type RemoteControlApi,
14
+ } from "../hooks/useRemoteControl.js";
12
15
  import type {
13
16
  Message,
14
17
  McpServerStatus,
@@ -31,14 +34,67 @@ import {
31
34
  type WorktreeSession,
32
35
  OPERATION_CANCELLED_BY_USER,
33
36
  extractLatestTotalTokens,
37
+ PLUGIN_CHANGE_PENDING_MESSAGE,
38
+ PLUGIN_RELOADED_MESSAGE,
34
39
  } from "wave-agent-sdk";
40
+ import { toString as qrToString } from "qrcode";
35
41
  import { logger } from "../utils/logger.js";
36
42
  import { displayUsageSummary } from "../utils/usageSummary.js";
37
43
  import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
44
+ import { remoteNotifications } from "../remote/remoteNotifications.js";
45
+ import type { RemoteNotify } from "../remote/remoteNotifications.js";
38
46
  import { readFile } from "node:fs/promises";
39
47
 
40
48
  import { BaseAppProps } from "../types.js";
41
49
 
50
+ /** How long `/remote-control` waits for the relay's registration round trip. */
51
+ const REMOTE_PAGE_WAIT_MS = 5_000;
52
+ const REMOTE_PAGE_POLL_MS = 250;
53
+
54
+ /**
55
+ * Wait for the page address. It is assembled from this machine's channel key
56
+ * after the relay registers it (an HTTP call) — nothing is pushed down the
57
+ * WebSocket — so the first snapshot may not carry it yet.
58
+ */
59
+ async function waitForRemotePageUrl(
60
+ api: RemoteControlApi,
61
+ ): Promise<string | undefined> {
62
+ const deadline = Date.now() + REMOTE_PAGE_WAIT_MS;
63
+ while (Date.now() < deadline) {
64
+ const snapshot = api.getSnapshot();
65
+ if (snapshot.pageUrl) return snapshot.pageUrl;
66
+ if (snapshot.state === "error") return undefined;
67
+ await new Promise((resolve) => setTimeout(resolve, REMOTE_PAGE_POLL_MS));
68
+ }
69
+ return api.getSnapshot().pageUrl;
70
+ }
71
+
72
+ /**
73
+ * The result block a TUI session shows: the same address and the same QR that
74
+ * `wave remote-control on` prints, because that pair is the whole contract with
75
+ * the phone — the two entry points must not diverge on how it is rendered.
76
+ */
77
+ async function describeRemotePage(pageUrl: string): Promise<string> {
78
+ const lines = [
79
+ "Remote control is on — this terminal's session is now served to your phone.",
80
+ `Page: ${pageUrl}`,
81
+ ];
82
+ try {
83
+ lines.push(
84
+ "",
85
+ await qrToString(pageUrl, {
86
+ type: "utf8",
87
+ errorCorrectionLevel: "L",
88
+ }),
89
+ );
90
+ } catch {
91
+ // The address above already carries the contract; a missing QR is not worth
92
+ // failing the command over.
93
+ }
94
+ lines.push("", "Turn it off with /remote-control off");
95
+ return lines.join("\n");
96
+ }
97
+
42
98
  // Main Chat Context
43
99
  export interface PlanViewData {
44
100
  path?: string;
@@ -77,6 +133,17 @@ export interface ChatContextType {
77
133
  clearMessages: () => Promise<void>;
78
134
  compact: (instructions?: string) => Promise<void>;
79
135
  addDir: (args?: string) => Promise<void>;
136
+ /**
137
+ * Set the current session's custom title (`/rename <title>`). A blank title
138
+ * only prints the usage hint — no AI-generated titles (spec: session-management.md).
139
+ */
140
+ renameSession: (args?: string) => Promise<void>;
141
+ /**
142
+ * Switch to another conversation in-process (`/resume`). Pass `resumeWorkdir`
143
+ * when the target session lives in a sibling worktree of the same repo — the
144
+ * session then moves into that directory too.
145
+ */
146
+ resumeSession: (sessionId: string, resumeWorkdir?: string) => Promise<void>;
80
147
  abortMessage: () => void;
81
148
  recallQueuedMessage: () => QueuedMessage | null;
82
149
  removeQueuedMessageById: (id: string) => boolean;
@@ -119,6 +186,19 @@ export interface ChatContextType {
119
186
  // Permission functionality
120
187
  permissionMode: PermissionMode;
121
188
  setPermissionMode: (mode: PermissionMode) => void;
189
+ /**
190
+ * Remote control for **this** foreground session. The process the user is
191
+ * typing in is the one that dials out — not the daemon, which cannot reach a
192
+ * session that only exists here.
193
+ */
194
+ remoteControl: RemoteControlApi;
195
+ /**
196
+ * `/remote-control [on|off|status] [name]` — the TUI-side switch for the hook
197
+ * above. Its CLI namesake talks to the daemon instead; the two are not
198
+ * interchangeable, because a session that lives in this process is reachable
199
+ * only from this process.
200
+ */
201
+ remoteControlCommand: (args?: string) => Promise<void>;
122
202
  // /plan overlay state + handler
123
203
  planView: PlanViewData | null;
124
204
  setPlanView: (view: PlanViewData | null) => void;
@@ -131,6 +211,7 @@ export interface ChatContextType {
131
211
  input?: Record<string, unknown>;
132
212
  suggestedPrefix?: string;
133
213
  hidePersistentOption?: boolean;
214
+ outsideSafeZoneDirectory?: string;
134
215
  planContent?: string;
135
216
  permissionMode?: PermissionMode;
136
217
  warning?: string;
@@ -143,6 +224,7 @@ export interface ChatContextType {
143
224
  planContent?: string,
144
225
  permissionMode?: PermissionMode,
145
226
  warning?: string,
227
+ outsideSafeZoneDirectory?: string,
146
228
  ) => Promise<PermissionDecision>;
147
229
  hideConfirmation: () => void;
148
230
  handleConfirmationDecision: (decision: PermissionDecision) => void;
@@ -164,8 +246,10 @@ export interface ChatContextType {
164
246
  workingDirectory: string;
165
247
  version?: string;
166
248
  workdir?: string;
167
- // Agent recreation (e.g. after plugin install)
168
- recreateAgent: () => void;
249
+ // Plugin in-place reload (docs/specs/ecosystem/plugin.md「插件变更的就地重载」)
250
+ reloadPlugins: () => Promise<void>;
251
+ notifyPluginChange: () => void;
252
+ pluginHint: string | null;
169
253
  }
170
254
 
171
255
  const ChatContext = createContext<ChatContextType | null>(null);
@@ -332,81 +416,6 @@ const applyToolBlockUpdate = (
332
416
  });
333
417
  };
334
418
 
335
- /**
336
- * Single throttled updater entry for ALL message-state updates. The one
337
- * 500ms window-concat window replaces the previous three-channel throttles
338
- * (content/reasoning/tool), so every message-state update — including the
339
- * tool `running` stage's high-frequency `shortResult`/`result` updates from
340
- * bash — coalesces into the same window.
341
- *
342
- * Semantics: leading edge applies immediately and opens a window; updates
343
- * arriving within the window are queued in arrival order (FIFO) and applied
344
- * at the trailing edge as ONE composed updater, so no update is lost and no
345
- * update is reordered. `flush` applies queued updates immediately (used by
346
- * end signals and one-shot structural updates); `cancel` drops queued
347
- * updates (used by refreshMessages after pulling the authoritative snapshot —
348
- * the queued updates are already contained in it).
349
- *
350
- * The FIFO queue structurally replaces the old tool throttle's "drop a
351
- * tool's buffered streaming deltas when running arrives" logic: a running
352
- * updater is queued BEHIND the streaming chunks that preceded it and applies
353
- * after them, so a stale streaming event can never flush after running and
354
- * regress the stage (yellow dot -> gray). See
355
- * docs/specs/core/stream-content-updates.md.
356
- */
357
- export function createThrottledUpdater<T>(
358
- apply: (updater: (prev: T) => T) => void,
359
- wait: number,
360
- ): {
361
- (updater: (prev: T) => T): void;
362
- cancel: () => void;
363
- flush: () => void;
364
- } {
365
- let timer: NodeJS.Timeout | null = null;
366
- let queued: Array<(prev: T) => T> = [];
367
-
368
- const fire = () => {
369
- if (queued.length === 0) return;
370
- const batch = queued;
371
- queued = [];
372
- // Compose the whole batch into a single updater applied in FIFO order —
373
- // one state commit per trailing edge, no update dropped, no reordering.
374
- apply((prev) => batch.reduce((acc, upd) => upd(acc), prev));
375
- };
376
-
377
- const throttled = (updater: (prev: T) => T) => {
378
- if (timer) {
379
- queued.push(updater);
380
- return;
381
- }
382
- // Leading edge: apply immediately, then open a window whose trailing
383
- // edge only carries updates arriving within the window.
384
- apply(updater);
385
- timer = setTimeout(() => {
386
- timer = null;
387
- fire();
388
- }, wait);
389
- };
390
-
391
- throttled.cancel = () => {
392
- if (timer) {
393
- clearTimeout(timer);
394
- timer = null;
395
- }
396
- queued = [];
397
- };
398
-
399
- throttled.flush = () => {
400
- if (timer) {
401
- clearTimeout(timer);
402
- timer = null;
403
- }
404
- fire();
405
- };
406
-
407
- return throttled;
408
- }
409
-
410
419
  export const ChatProvider: React.FC<ChatProviderProps> = ({
411
420
  children,
412
421
  bypassPermissions,
@@ -437,26 +446,16 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
437
446
  const [latestTotalTokens, setLatestTotalTokens] = useState(0);
438
447
  const [maxInputTokens, setMaxInputTokens] = useState(200000);
439
448
 
440
- // Single throttled entry for ALL message-state updates — one 500ms
441
- // window-concat window shared by every callback (content/reasoning deltas,
442
- // tool parametersChunk, tool `running` stage shortResult/result, one-shot
443
- // structural updates). The tool `running` stage used to bypass throttling
444
- // (bash shortResult height changes per chunk → layout flicker); routing it
445
- // through the same window as streaming deltas coalesces it to ≤1 render per
446
- // window. See createThrottledUpdater + docs/specs/core/stream-content-updates.md.
447
- const updateMessages = useMemo(
448
- () =>
449
- createThrottledUpdater<Message[]>((updater) => setMessages(updater), 500),
450
- [],
451
- );
449
+ // All message-state updates apply IMMEDIATELY via setMessages — no data-layer
450
+ // throttling (removed 2026-10-15 after observing fully-immediate rendering in
451
+ // a real terminal with no flicker: Ink's log-update only rewrites the dynamic
452
+ // zone tail and its built-in maxFps:30 coalesces short-result line-count
453
+ // oscillation). Arrival order = application order. See
454
+ // docs/specs/core/stream-content-updates.md (2026-10-15 会议).
452
455
 
453
456
  useEffect(() => {
454
457
  isExpandedRef.current = isExpanded;
455
- if (isExpanded) {
456
- // Cancel pending throttled updates so the frozen expanded view isn't overwritten
457
- updateMessages.cancel();
458
- }
459
- }, [isExpanded, updateMessages]);
458
+ }, [isExpanded]);
460
459
 
461
460
  const [isLoading, setIsLoading] = useState(false);
462
461
  const [sessionId, setSessionId] = useState("");
@@ -512,6 +511,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
512
511
  input?: Record<string, unknown>;
513
512
  suggestedPrefix?: string;
514
513
  hidePersistentOption?: boolean;
514
+ outsideSafeZoneDirectory?: string;
515
515
  planContent?: string;
516
516
  permissionMode?: PermissionMode;
517
517
  warning?: string;
@@ -520,10 +520,12 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
520
520
  >();
521
521
  const [confirmationQueue, setConfirmationQueue] = useState<
522
522
  Array<{
523
+ requestId: string;
523
524
  toolName: string;
524
525
  toolInput?: Record<string, unknown>;
525
526
  suggestedPrefix?: string;
526
527
  hidePersistentOption?: boolean;
528
+ outsideSafeZoneDirectory?: string;
527
529
  planContent?: string;
528
530
  permissionMode?: PermissionMode;
529
531
  warning?: string;
@@ -532,10 +534,12 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
532
534
  }>
533
535
  >([]);
534
536
  const [currentConfirmation, setCurrentConfirmation] = useState<{
537
+ requestId: string;
535
538
  toolName: string;
536
539
  toolInput?: Record<string, unknown>;
537
540
  suggestedPrefix?: string;
538
541
  hidePersistentOption?: boolean;
542
+ outsideSafeZoneDirectory?: string;
539
543
  planContent?: string;
540
544
  permissionMode?: PermissionMode;
541
545
  warning?: string;
@@ -543,6 +547,37 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
543
547
  reject: () => void;
544
548
  } | null>(null);
545
549
 
550
+ /**
551
+ * The same approvals, keyed by id, so a phone can answer them.
552
+ *
553
+ * The queue above is the terminal's view of "what am I waiting on"; this map
554
+ * is the phone's. Both resolve the same promise, so whichever side answers
555
+ * first wins — that is the whole of the concurrency rule (spec: 前台 CLI
556
+ * 故事场景 5), and it needs no arbitration beyond "delete on settle".
557
+ */
558
+ const remotePendingRef = useRef<
559
+ Map<
560
+ string,
561
+ {
562
+ context: ToolPermissionContext;
563
+ settle: (decision: PermissionDecision) => void;
564
+ }
565
+ >
566
+ >(new Map());
567
+ const [remotePending, setRemotePending] = useState<
568
+ Array<{ requestId: string; context: ToolPermissionContext }>
569
+ >([]);
570
+ const permissionCounterRef = useRef(0);
571
+
572
+ const syncRemotePending = useCallback(() => {
573
+ setRemotePending(
574
+ [...remotePendingRef.current.entries()].map(([requestId, entry]) => ({
575
+ requestId,
576
+ context: entry.context,
577
+ })),
578
+ );
579
+ }, []);
580
+
546
581
  // Remount state
547
582
  const [remountKey, setRemountKey] = useState(0);
548
583
 
@@ -560,6 +595,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
560
595
 
561
596
  const agentRef = useRef<Agent | null>(null);
562
597
 
598
+ /**
599
+ * The notifier the remote path's content-carrying frames go out through (see
600
+ * `remoteNotifications`). A ref because the agent callbacks are built in
601
+ * `initializeAgent` below, before this component has the hook that owns the
602
+ * connection.
603
+ */
604
+ const remoteNotifyRef = useRef<RemoteNotify | undefined>(undefined);
605
+
563
606
  // Full-list refresh — one-shot pull from the agent, used only for structural
564
607
  // changes (compact/clear/rewind/collapse/init). Streaming updates flow through
565
608
  // the incremental callbacks in initializeAgent below.
@@ -568,19 +611,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
568
611
  // Pull the full UI display stream (keeps pre-compaction history);
569
612
  // `agent.messages` would only expose the folded API context.
570
613
  const msgs = agentRef.current.displayMessages.map(snapshotMessage);
571
- // Snapshot-safe: the full-list replacement makes the SDK state
572
- // authoritative. Any update still queued inside the 500ms throttle
573
- // window was applied to the SDK before this pull, so it is already
574
- // contained in `msgs` — dropping it prevents the trailing-edge flush
575
- // from re-appending the pre-refresh chunk on top of the snapshot
576
- // (first-word duplication). Updates arriving after the pull start fresh
577
- // windows and append on top of the snapshot. See
578
- // docs/specs/core/stream-content-updates.md.
579
- updateMessages.cancel();
614
+ // The snapshot makes the SDK state authoritative: with no throttle
615
+ // window there is nothing pending to cancel — every pre-pull update is
616
+ // already applied and contained in `msgs`, and post-pull deltas append
617
+ // on top of the snapshot. See docs/specs/core/stream-content-updates.md.
580
618
  setMessages(msgs);
581
619
  setLatestTotalTokens(extractLatestTotalTokens(msgs));
582
620
  }
583
- }, [updateMessages]);
621
+ }, []);
584
622
 
585
623
  // Permission confirmation methods with queue support
586
624
  const showConfirmation = useCallback(
@@ -592,25 +630,57 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
592
630
  planContent?: string,
593
631
  permissionMode?: PermissionMode,
594
632
  warning?: string,
633
+ outsideSafeZoneDirectory?: string,
595
634
  ): Promise<PermissionDecision> => {
635
+ const requestId = `cli_perm_${++permissionCounterRef.current}`;
596
636
  return new Promise<PermissionDecision>((resolve, reject) => {
637
+ const context: ToolPermissionContext = {
638
+ toolName,
639
+ ...(toolInput ? { toolInput } : {}),
640
+ ...(suggestedPrefix ? { suggestedPrefix } : {}),
641
+ ...(hidePersistentOption !== undefined
642
+ ? { hidePersistentOption }
643
+ : {}),
644
+ ...(outsideSafeZoneDirectory ? { outsideSafeZoneDirectory } : {}),
645
+ ...(planContent ? { planContent } : {}),
646
+ // Required on the context, and the phone renders it: which mode this
647
+ // request is being judged under.
648
+ permissionMode: permissionMode ?? "default",
649
+ ...(warning ? { warning } : {}),
650
+ };
651
+ // Settling through the map is what keeps the phone's list honest: an
652
+ // approval answered at the terminal must vanish from the phone too.
653
+ const settle = (decision: PermissionDecision) => {
654
+ remotePendingRef.current.delete(requestId);
655
+ syncRemotePending();
656
+ resolve(decision);
657
+ };
658
+ const cancel = () => {
659
+ remotePendingRef.current.delete(requestId);
660
+ syncRemotePending();
661
+ reject();
662
+ };
663
+ remotePendingRef.current.set(requestId, { context, settle });
664
+ syncRemotePending();
597
665
  const queueItem = {
666
+ requestId,
598
667
  toolName,
599
668
  toolInput,
600
669
  suggestedPrefix,
601
670
  hidePersistentOption,
671
+ outsideSafeZoneDirectory,
602
672
  planContent,
603
673
  permissionMode,
604
674
  warning,
605
- resolver: resolve,
606
- reject,
675
+ resolver: settle,
676
+ reject: cancel,
607
677
  };
608
678
 
609
679
  setConfirmationQueue((prev) => [...prev, queueItem]);
610
680
  // processNextConfirmation will be called via useEffect
611
681
  });
612
682
  },
613
- [],
683
+ [syncRemotePending],
614
684
  );
615
685
 
616
686
  // Initialize AI manager
@@ -619,10 +689,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
619
689
  const effectiveRestoreSessionId =
620
690
  restoreSessionIdOverride ?? restoreSessionId;
621
691
 
692
+ // The remote path's copy of the payload-carrying frames (see
693
+ // `remoteNotifications`: the streaming trio plus the error block), emitted
694
+ // *beside* the local state updates — never instead of them, and outside the
695
+ // `isExpanded` guards below, because the TUI's own view mode is not the
696
+ // phone's. Built only while a sink exists, so a process with remote control
697
+ // off allocates nothing per chunk.
698
+ const remoteNotify = remoteNotifyRef.current;
699
+ const remoteStream = remoteNotify
700
+ ? remoteNotifications(remoteNotify)
701
+ : undefined;
702
+
622
703
  const callbacks: AgentCallbacks = {
623
704
  // ── Incremental message updates (no full-list pushes) ──────
624
- // All of these funnel through the single throttled `updateMessages`
625
- // entry, so every message-state update shares one 500ms window.
705
+ // Every update applies immediately via setMessages — no data-layer
706
+ // throttling (removed 2026-10-15, see docs/specs/core/stream-content-updates.md).
707
+ // Arrival order = application order; terminal write frequency is
708
+ // bounded by Ink's built-in maxFps:30.
626
709
  onUserMessageAdded: () => {
627
710
  if (isExpandedRef.current || !agentRef.current) return;
628
711
  const msgs = agentRef.current.messages;
@@ -632,12 +715,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
632
715
  // the batch flush, so evaluating snapshotMessage inside the updater
633
716
  // would read the SDK message after its in-place mutations.
634
717
  const snapshot = snapshotMessage(last);
635
- updateMessages((prev) =>
718
+ // One-shot structural update — applied directly (a window would
719
+ // only delay the message card, no coalescing value).
720
+ setMessages((prev) =>
636
721
  prev.some((m) => m.id === last.id) ? prev : [...prev, snapshot],
637
722
  );
638
- // One-shot structural update — flush immediately so the message
639
- // card appears at once (queuing it gains no coalescing).
640
- updateMessages.flush();
641
723
  },
642
724
  onAssistantMessageAdded: (messageId: string) => {
643
725
  if (isExpandedRef.current || !agentRef.current) return;
@@ -647,29 +729,43 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
647
729
  // fires this callback BEFORE the first delta writes into the shared
648
730
  // message, so capturing here copies the pre-mutation (empty) blocks.
649
731
  const snapshot = snapshotMessage(msg);
650
- updateMessages((prev) =>
732
+ setMessages((prev) =>
651
733
  prev.some((m) => m.id === messageId) ? prev : [...prev, snapshot],
652
734
  );
653
- updateMessages.flush();
735
+ },
736
+ onAssistantMessageDiscarded: (messageId: string) => {
737
+ // The streamed attempt died mid-body and was re-issued without
738
+ // streaming; its half message is void (spec: 流式响应正文中途断连时
739
+ // 降级为非流式重发).
740
+ if (isExpandedRef.current) return;
741
+ setMessages((prev) => prev.filter((m) => m.id !== messageId));
654
742
  },
655
743
  onAssistantContentUpdated: (params) => {
744
+ remoteStream?.onAssistantContentUpdated(params);
656
745
  if (isExpandedRef.current) return;
657
- updateMessages((prev) => applyContentDelta(prev, params));
658
- if (params.stage === "end") updateMessages.flush();
746
+ // Text delta: height grows monotonically → per-delta application.
747
+ // No flush on end: this channel has no pending updates, and the
748
+ // tool-running window must not be cut short by a text end.
749
+ setMessages((prev) => applyContentDelta(prev, params));
659
750
  },
660
751
  onAssistantReasoningUpdated: (params) => {
752
+ remoteStream?.onAssistantReasoningUpdated(params);
661
753
  if (isExpandedRef.current) return;
662
- updateMessages((prev) => applyReasoningDelta(prev, params));
663
- if (params.stage === "end") updateMessages.flush();
754
+ setMessages((prev) => applyReasoningDelta(prev, params));
664
755
  },
665
756
  onToolBlockUpdated: (params) => {
757
+ remoteStream?.onToolBlockUpdated(params);
666
758
  if (isExpandedRef.current) return;
667
- updateMessages((prev) => applyToolBlockUpdate(prev, params));
668
- if (params.stage === "end") updateMessages.flush();
759
+ // All stages (start/streaming/running/end) apply directly —
760
+ // `end` carries the authoritative final values.
761
+ setMessages((prev) => applyToolBlockUpdate(prev, params));
669
762
  },
670
763
  onErrorBlockAdded: (error: string) => {
764
+ remoteStream?.onErrorBlockAdded(error);
671
765
  if (isExpandedRef.current) return;
672
- updateMessages((prev) => {
766
+ // One-shot structural update — applied directly (errors must not be
767
+ // delayed by the tool-running window).
768
+ setMessages((prev) => {
673
769
  // Append to the LAST message only if it is an assistant message
674
770
  // (the current turn's in-flight reply). If the last message is a
675
771
  // user message, the error belongs BELOW it — create a new
@@ -697,9 +793,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
697
793
  },
698
794
  ];
699
795
  });
700
- // One-shot structural update — flush immediately (errors must not be
701
- // delayed by a streaming window).
702
- updateMessages.flush();
703
796
  },
704
797
  onLatestTotalTokensChange: (tokens) => {
705
798
  setLatestTotalTokens(tokens);
@@ -775,6 +868,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
775
868
  context.planContent,
776
869
  context.permissionMode,
777
870
  context.warning,
871
+ context.outsideSafeZoneDirectory,
778
872
  );
779
873
  } catch {
780
874
  // If confirmation was cancelled or failed, deny the operation
@@ -805,6 +899,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
805
899
  isNewWorktree: worktreeSession?.isNew,
806
900
  model,
807
901
  mcpServers,
902
+ // This session was created from the interactive CLI → `app.entrypoint`.
903
+ entrypoint: "cli",
808
904
  });
809
905
 
810
906
  agentRef.current = agent;
@@ -887,37 +983,54 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
887
983
  originalCwd,
888
984
  model,
889
985
  initialPermissionMode,
890
- updateMessages,
891
986
  mcpServers,
892
987
  ],
893
988
  );
894
989
 
895
- // Recreate agent (e.g. after plugin install) — destroys current agent and reinitializes
896
- const recreateAgent = useCallback(() => {
897
- const currentSessionId = agentRef.current?.sessionId;
898
- if (agentRef.current) {
899
- try {
900
- agentRef.current.destroy();
901
- } catch {
902
- // Ignore destroy errors
903
- }
990
+ // 插件变更落盘后只置一个「待应用」信号并提示一次,不再重建 Agent;真正换装由
991
+ // 用户敲 /reload-plugins 触发,在当前会话内完成(docs/specs/ecosystem/plugin.md
992
+ // 「插件变更的就地重载」:会…不重建、不打断回合、不新建会话)。
993
+ const [pluginHint, setPluginHint] = useState<string | null>(null);
994
+ const pluginHintTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
995
+
996
+ const showPluginHint = useCallback((message: string) => {
997
+ setPluginHint(message);
998
+ if (pluginHintTimer.current) {
999
+ clearTimeout(pluginHintTimer.current);
904
1000
  }
905
- agentRef.current = null;
906
- setMessages([]);
907
- setMcpServerStatuses([]);
908
- setSlashCommands([]);
909
- setAgentDefinitions([]);
910
- setSkills([]);
911
- setSessionId("");
912
- setIsLoading(false);
913
- setLatestTotalTokens(0);
914
- setMaxInputTokens(200000);
915
- setIsCommandRunning(false);
916
- setIsCompacting(false);
917
- if (currentSessionId) {
918
- initializeAgent(currentSessionId);
1001
+ // 一次性提示:数秒后自行消失,不留常驻标识(spec「插件变更提示」)。
1002
+ pluginHintTimer.current = setTimeout(() => setPluginHint(null), 8000);
1003
+ }, []);
1004
+
1005
+ const notifyPluginChange = useCallback(() => {
1006
+ showPluginHint(PLUGIN_CHANGE_PENDING_MESSAGE);
1007
+ }, [showPluginHint]);
1008
+
1009
+ const reloadPlugins = useCallback(async () => {
1010
+ const agent = agentRef.current;
1011
+ if (!agent) return;
1012
+ try {
1013
+ const result = await agent.reloadPlugins();
1014
+ // 换装后本进程各会话的能力已变,UI 侧的命令 / 子代理 / 技能列表同步重读。
1015
+ setSlashCommands(agent.getSlashCommands?.() || []);
1016
+ setAgentDefinitions(agent.getSubagentConfigurations?.() || []);
1017
+ setSkills(agent.getSkillMetadata?.() || []);
1018
+ if (result.failures.length > 0) {
1019
+ // 失败不回滚:如实提示,会话继续可用(spec 场景 11)。
1020
+ showPluginHint(
1021
+ `${PLUGIN_RELOADED_MESSAGE}部分插件加载失败:${result.failures
1022
+ .map((failure) => `${failure.path}: ${failure.error}`)
1023
+ .join(";")}`,
1024
+ );
1025
+ } else {
1026
+ showPluginHint(PLUGIN_RELOADED_MESSAGE);
1027
+ }
1028
+ } catch (error) {
1029
+ showPluginHint(
1030
+ `插件重载失败:${error instanceof Error ? error.message : String(error)}`,
1031
+ );
919
1032
  }
920
- }, [initializeAgent]);
1033
+ }, [showPluginHint]);
921
1034
 
922
1035
  // Run initial agent creation
923
1036
  useEffect(() => {
@@ -927,7 +1040,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
927
1040
  // Cleanup on unmount
928
1041
  useEffect(() => {
929
1042
  return () => {
930
- updateMessages.cancel();
931
1043
  if (agentRef.current) {
932
1044
  try {
933
1045
  // Display usage summary before cleanup
@@ -941,7 +1053,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
941
1053
  agentRef.current.destroy();
942
1054
  }
943
1055
  };
944
- }, [updateMessages]);
1056
+ }, []);
945
1057
 
946
1058
  // Send message function (including judgment logic)
947
1059
  const sendMessage = useCallback(
@@ -1061,6 +1173,90 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1061
1173
  );
1062
1174
  }, []);
1063
1175
 
1176
+ // /rename: set the current conversation's custom title. The title is stored
1177
+ // as a reserved `custom-title` entry appended to the session's own JSONL
1178
+ // file, so `wave -r` and every GUI host read the same value. A blank title
1179
+ // only prints the usage hint — no AI-generated titles (spec:
1180
+ // session-management.md "不做 AI 自动标题").
1181
+ const renameSession = useCallback(async (args?: string) => {
1182
+ const agent = agentRef.current;
1183
+ if (!agent) return;
1184
+
1185
+ const title = (args ?? "").trim();
1186
+ const showResult = (content: string) => {
1187
+ setMessages((prev) => [
1188
+ ...prev,
1189
+ {
1190
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
1191
+ role: "assistant",
1192
+ timestamp: new Date().toISOString(),
1193
+ blocks: [{ type: "text", content, stage: "end" }],
1194
+ },
1195
+ ]);
1196
+ };
1197
+
1198
+ if (!title) {
1199
+ showResult("Usage: /rename <title>");
1200
+ return;
1201
+ }
1202
+
1203
+ try {
1204
+ await agent.renameSession(title);
1205
+ showResult(`Conversation renamed to "${title}".`);
1206
+ } catch (error) {
1207
+ showResult(
1208
+ `Failed to rename conversation: ${
1209
+ error instanceof Error ? error.message : String(error)
1210
+ }`,
1211
+ );
1212
+ }
1213
+ }, []);
1214
+
1215
+ // /resume: switch to another conversation without leaving the process. The
1216
+ // session id and its on-disk project directory move together, so the next
1217
+ // message is appended to the target's own transcript. A failed restore leaves
1218
+ // the current session untouched and only surfaces a notice.
1219
+ const resumeSession = useCallback(
1220
+ async (sessionId: string, resumeWorkdir?: string) => {
1221
+ const agent = agentRef.current;
1222
+ if (!agent) return;
1223
+
1224
+ try {
1225
+ await agent.restoreSession(
1226
+ sessionId,
1227
+ resumeWorkdir ? { workdir: resumeWorkdir } : undefined,
1228
+ );
1229
+ } catch (error) {
1230
+ // UI-only notice (same display path as /add-dir): nothing was switched,
1231
+ // so nothing about the failed target is persisted.
1232
+ setMessages((prev) => [
1233
+ ...prev,
1234
+ {
1235
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
1236
+ role: "assistant",
1237
+ timestamp: new Date().toISOString(),
1238
+ blocks: [
1239
+ {
1240
+ type: "error",
1241
+ content: `Failed to resume conversation: ${
1242
+ error instanceof Error ? error.message : String(error)
1243
+ }`,
1244
+ },
1245
+ ],
1246
+ },
1247
+ ]);
1248
+ return;
1249
+ }
1250
+
1251
+ setSessionId(agent.sessionId);
1252
+ setWorkingDirectory(agent.workingDirectory);
1253
+ setIsLoading(agent.isLoading);
1254
+ refreshMessages();
1255
+ forceRemount();
1256
+ },
1257
+ [forceRemount, refreshMessages],
1258
+ );
1259
+
1064
1260
  // Unified interrupt method, interrupt both AI messages and command execution
1065
1261
  const abortMessage = useCallback(() => {
1066
1262
  agentRef.current?.abortMessage();
@@ -1170,6 +1366,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1170
1366
  input: next.toolInput,
1171
1367
  suggestedPrefix: next.suggestedPrefix,
1172
1368
  hidePersistentOption: next.hidePersistentOption,
1369
+ outsideSafeZoneDirectory: next.outsideSafeZoneDirectory,
1173
1370
  planContent: next.planContent,
1174
1371
  permissionMode: next.permissionMode,
1175
1372
  warning: next.warning,
@@ -1208,6 +1405,165 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1208
1405
  hideConfirmation();
1209
1406
  }, [currentConfirmation, hideConfirmation]);
1210
1407
 
1408
+ // Mirrors for the remote-control host: it outlives any single render, so it
1409
+ // must read the confirmation showing *now* rather than the one captured when
1410
+ // it was built.
1411
+ const currentConfirmationRef = useRef<typeof currentConfirmation>(null);
1412
+ useEffect(() => {
1413
+ currentConfirmationRef.current = currentConfirmation;
1414
+ }, [currentConfirmation]);
1415
+ const hideConfirmationRef = useRef(hideConfirmation);
1416
+ useEffect(() => {
1417
+ hideConfirmationRef.current = hideConfirmation;
1418
+ }, [hideConfirmation]);
1419
+
1420
+ /**
1421
+ * Answer an approval from the phone. It settles the same promise the terminal
1422
+ * is waiting on, so this advances the session exactly as pressing the key
1423
+ * would — and if that request is the one on screen, the dialog goes away.
1424
+ */
1425
+ const respondPermission = useCallback(
1426
+ (requestId: string, decision: PermissionDecision) => {
1427
+ const entry = remotePendingRef.current.get(requestId);
1428
+ if (!entry) return;
1429
+ entry.settle(decision);
1430
+ if (currentConfirmationRef.current?.requestId === requestId) {
1431
+ hideConfirmationRef.current();
1432
+ }
1433
+ },
1434
+ [],
1435
+ );
1436
+
1437
+ const getAvailableTools = useCallback(
1438
+ () => agentRef.current?.getAvailableToolNames() ?? [],
1439
+ [],
1440
+ );
1441
+
1442
+ // Serve this foreground session to a phone, if the switch allows it. The hook
1443
+ // owns the outbound connection; it reads current state through a ref, so
1444
+ // passing the values here (rather than wiring callbacks into every setter) is
1445
+ // what keeps this the whole of the integration.
1446
+ const remoteControl = useRemoteControl({
1447
+ workdir: workdir ?? process.cwd(),
1448
+ sessionId,
1449
+ messages,
1450
+ isLoading,
1451
+ permissionMode,
1452
+ latestTotalTokens,
1453
+ maxInputTokens,
1454
+ // 手机侧会话列表的标题材料:`customTitle` 不走 React state(agent 自己存着,
1455
+ // 重命名后即刻生效),所以按「取当前值」的函数递给门面。
1456
+ customTitle: () => agentRef.current?.customTitle,
1457
+ hasRunningBackgroundWork: backgroundTasks.length > 0,
1458
+ hasPendingMessages: queuedMessages.length > 0,
1459
+ // 场景 26 的会话状态族:远控页按状态重读 `getSessionInfo`,这四个是它的
1460
+ // 当前值(回调本就把它们写进 state,这里原样递给门面即可)。
1461
+ tasks,
1462
+ backgroundTasks,
1463
+ isCompacting,
1464
+ isCommandRunning,
1465
+ queuedMessages,
1466
+ pending: remotePending,
1467
+ availableTools: getAvailableTools,
1468
+ sendMessage: (text) => sendMessage(text),
1469
+ abort: () => agentRef.current?.abortMessage(),
1470
+ setPermissionMode,
1471
+ respondPermission,
1472
+ // 会话自身的维护动作(重命名 / 压缩 / 指令清单):页面能直接用,且都由这一份
1473
+ // 会话状态来答,所以给的是「取当前值」的函数。重命名走 agent 而非 /rename 命令,
1474
+ // 是为了让失败以 RPC 错误回给页面(TUI 那条路径把错误渲染成一条消息)。
1475
+ renameSession: (title) => agentRef.current?.renameSession(title),
1476
+ compact: (instructions) => compact(instructions),
1477
+ slashCommands: () => agentRef.current?.getSlashCommands() ?? [],
1478
+ });
1479
+
1480
+ // `initializeAgent` reads this ref when it runs, which is always in an effect
1481
+ // — after this render. `notify` is stable (the hook builds it once, over a sink
1482
+ // it creates once), so assigning it during render is enough to have it in place
1483
+ // before anything can call the agent.
1484
+ remoteNotifyRef.current = remoteControl.notify;
1485
+
1486
+ /**
1487
+ * `/remote-control [on|off|status] [name]` — offer this terminal's session to
1488
+ * a phone. It is deliberately *not* a front end for `wave remote-control`:
1489
+ * that subcommand drives the daemon, and this session exists only in this
1490
+ * process, so the daemon could not serve it (spec: remote-control.md,
1491
+ * "前台 CLI 会话也能被手机接管").
1492
+ */
1493
+ const remoteControlCommand = useCallback(
1494
+ async (args?: string) => {
1495
+ const showResult = (content: string) => {
1496
+ setMessages((prev) => [
1497
+ ...prev,
1498
+ {
1499
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`,
1500
+ role: "assistant",
1501
+ timestamp: new Date().toISOString(),
1502
+ blocks: [{ type: "text", content, stage: "end" }],
1503
+ },
1504
+ ]);
1505
+ };
1506
+
1507
+ const parts = (args ?? "").trim().split(/\s+/).filter(Boolean);
1508
+ const action = parts[0]?.toLowerCase() ?? "";
1509
+ if (
1510
+ action &&
1511
+ action !== "on" &&
1512
+ action !== "off" &&
1513
+ action !== "status"
1514
+ ) {
1515
+ showResult("Usage: /remote-control [on|off|status] [name]");
1516
+ return;
1517
+ }
1518
+
1519
+ if (!remoteControl.enabled) {
1520
+ showResult(
1521
+ 'Remote control is unavailable in this process. Set "enableRemoteControl": true in ~/.wave/settings.json (or .wave/settings.json in the project), then restart the CLI — a process that is already running keeps the settings it started with.',
1522
+ );
1523
+ return;
1524
+ }
1525
+
1526
+ if (action === "off") {
1527
+ remoteControl.disconnect();
1528
+ showResult(
1529
+ "Remote control is off — this terminal's session is no longer served.",
1530
+ );
1531
+ return;
1532
+ }
1533
+
1534
+ const current = remoteControl.getSnapshot();
1535
+ const serving =
1536
+ current.state === "online" || current.state === "connecting";
1537
+ // A bare `/remote-control` means "start serving" the first time and
1538
+ // "what is it doing?" afterwards, so a second one cannot silently dial
1539
+ // again and cannot hide an address the user still needs.
1540
+ if (action === "status" || (!action && serving)) {
1541
+ showResult(
1542
+ current.pageUrl
1543
+ ? await describeRemotePage(current.pageUrl)
1544
+ : `Remote control status: ${current.state}${
1545
+ current.message ? ` — ${current.message}` : ""
1546
+ }`,
1547
+ );
1548
+ return;
1549
+ }
1550
+
1551
+ remoteControl.connect(parts[1]);
1552
+ const pageUrl = await waitForRemotePageUrl(remoteControl);
1553
+ const after = remoteControl.getSnapshot();
1554
+ if (!pageUrl) {
1555
+ showResult(
1556
+ after.state === "error"
1557
+ ? `Remote control failed: ${after.message ?? "unknown error"}`
1558
+ : "Connected to the relay, but no page address came back yet — run /remote-control status in a moment.",
1559
+ );
1560
+ return;
1561
+ }
1562
+ showResult(await describeRemotePage(pageUrl));
1563
+ },
1564
+ [remoteControl],
1565
+ );
1566
+
1211
1567
  const backgroundCurrentTask = useCallback(() => {
1212
1568
  agentRef.current?.backgroundCurrentTask();
1213
1569
  }, []);
@@ -1257,7 +1613,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1257
1613
 
1258
1614
  const setModel = useCallback((model: string) => {
1259
1615
  if (agentRef.current) {
1260
- agentRef.current.setModel(model);
1616
+ void agentRef.current.setModel(model);
1261
1617
  setCurrentModelState(model);
1262
1618
  }
1263
1619
  }, []);
@@ -1307,6 +1663,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1307
1663
  clearMessages,
1308
1664
  compact,
1309
1665
  addDir,
1666
+ renameSession,
1667
+ resumeSession,
1310
1668
  abortMessage,
1311
1669
  recallQueuedMessage,
1312
1670
  removeQueuedMessageById,
@@ -1334,6 +1692,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1334
1692
  hooks,
1335
1693
  permissionMode,
1336
1694
  setPermissionMode,
1695
+ remoteControl,
1696
+ remoteControlCommand,
1337
1697
  planView,
1338
1698
  setPlanView,
1339
1699
  handlePlanCommand,
@@ -1355,7 +1715,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1355
1715
  workingDirectory,
1356
1716
  version,
1357
1717
  workdir,
1358
- recreateAgent,
1718
+ reloadPlugins,
1719
+ notifyPluginChange,
1720
+ pluginHint,
1359
1721
  };
1360
1722
 
1361
1723
  return (