wave-code 1.3.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 (42) hide show
  1. package/dist/bundle/wave.mjs +1275 -895
  2. package/package.json +4 -2
  3. package/src/commands/update.ts +62 -22
  4. package/src/components/BackgroundTaskManager.tsx +76 -54
  5. package/src/components/ConfirmationDetails.tsx +17 -3
  6. package/src/components/InputBox.tsx +2 -0
  7. package/src/components/MessageBlockItem.tsx +0 -6
  8. package/src/components/WorkflowManager.tsx +38 -138
  9. package/src/components/WorkflowRunDetail.tsx +193 -0
  10. package/src/constants/commands.ts +6 -0
  11. package/src/contexts/useChat.tsx +359 -127
  12. package/src/daemon/commands.ts +13 -4
  13. package/src/daemon-cli.ts +6 -1
  14. package/src/hooks/useInputManager.ts +4 -0
  15. package/src/hooks/useRemoteControl.ts +324 -0
  16. package/src/index.ts +35 -0
  17. package/src/managers/inputHandlers.ts +2 -0
  18. package/src/managers/inputReducer.ts +1 -0
  19. package/src/print-cli.ts +5 -0
  20. package/src/reducers/workflowManagerReducer.ts +21 -2
  21. package/src/remote/frameBudget.ts +45 -0
  22. package/src/remote/hostIdentity.ts +128 -0
  23. package/src/remote/messageWindow.ts +97 -0
  24. package/src/remote/notificationCoalescer.ts +174 -0
  25. package/src/remote/relayClient.ts +668 -0
  26. package/src/remote/remoteControlCliHost.ts +391 -0
  27. package/src/remote/remoteControlCommands.ts +197 -0
  28. package/src/remote/remoteControlConfig.ts +334 -0
  29. package/src/remote/remoteControlService.ts +799 -0
  30. package/src/remote/remoteControlSink.ts +77 -0
  31. package/src/remote/remoteNotifications.ts +194 -0
  32. package/src/stdio/agentBridge.ts +608 -74
  33. package/src/stdio/daemonServer.ts +30 -0
  34. package/src/stdio/jsonRpcConnection.ts +11 -2
  35. package/src/stdio/protocol.ts +14 -115
  36. package/src/stdio/stdioServer.ts +33 -2
  37. package/src/stdio-cli.ts +11 -2
  38. package/src/utils/gitCredentialHelper.ts +106 -0
  39. package/src/utils/logger.ts +5 -2
  40. package/src/utils/rewindCheckpoints.ts +2 -4
  41. package/src/utils/workspaceDiff.ts +620 -0
  42. package/src/utils/worktree.ts +97 -0
@@ -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,
@@ -34,13 +37,64 @@ import {
34
37
  PLUGIN_CHANGE_PENDING_MESSAGE,
35
38
  PLUGIN_RELOADED_MESSAGE,
36
39
  } from "wave-agent-sdk";
40
+ import { toString as qrToString } from "qrcode";
37
41
  import { logger } from "../utils/logger.js";
38
42
  import { displayUsageSummary } from "../utils/usageSummary.js";
39
43
  import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
44
+ import { remoteNotifications } from "../remote/remoteNotifications.js";
45
+ import type { RemoteNotify } from "../remote/remoteNotifications.js";
40
46
  import { readFile } from "node:fs/promises";
41
47
 
42
48
  import { BaseAppProps } from "../types.js";
43
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
+
44
98
  // Main Chat Context
45
99
  export interface PlanViewData {
46
100
  path?: string;
@@ -132,6 +186,19 @@ export interface ChatContextType {
132
186
  // Permission functionality
133
187
  permissionMode: PermissionMode;
134
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>;
135
202
  // /plan overlay state + handler
136
203
  planView: PlanViewData | null;
137
204
  setPlanView: (view: PlanViewData | null) => void;
@@ -349,81 +416,6 @@ const applyToolBlockUpdate = (
349
416
  });
350
417
  };
351
418
 
352
- /**
353
- * Single throttled updater entry for ALL message-state updates. The one
354
- * 500ms window-concat window replaces the previous three-channel throttles
355
- * (content/reasoning/tool), so every message-state update — including the
356
- * tool `running` stage's high-frequency `shortResult`/`result` updates from
357
- * bash — coalesces into the same window.
358
- *
359
- * Semantics: leading edge applies immediately and opens a window; updates
360
- * arriving within the window are queued in arrival order (FIFO) and applied
361
- * at the trailing edge as ONE composed updater, so no update is lost and no
362
- * update is reordered. `flush` applies queued updates immediately (used by
363
- * end signals and one-shot structural updates); `cancel` drops queued
364
- * updates (used by refreshMessages after pulling the authoritative snapshot —
365
- * the queued updates are already contained in it).
366
- *
367
- * The FIFO queue structurally replaces the old tool throttle's "drop a
368
- * tool's buffered streaming deltas when running arrives" logic: a running
369
- * updater is queued BEHIND the streaming chunks that preceded it and applies
370
- * after them, so a stale streaming event can never flush after running and
371
- * regress the stage (yellow dot -> gray). See
372
- * docs/specs/core/stream-content-updates.md.
373
- */
374
- export function createThrottledUpdater<T>(
375
- apply: (updater: (prev: T) => T) => void,
376
- wait: number,
377
- ): {
378
- (updater: (prev: T) => T): void;
379
- cancel: () => void;
380
- flush: () => void;
381
- } {
382
- let timer: NodeJS.Timeout | null = null;
383
- let queued: Array<(prev: T) => T> = [];
384
-
385
- const fire = () => {
386
- if (queued.length === 0) return;
387
- const batch = queued;
388
- queued = [];
389
- // Compose the whole batch into a single updater applied in FIFO order —
390
- // one state commit per trailing edge, no update dropped, no reordering.
391
- apply((prev) => batch.reduce((acc, upd) => upd(acc), prev));
392
- };
393
-
394
- const throttled = (updater: (prev: T) => T) => {
395
- if (timer) {
396
- queued.push(updater);
397
- return;
398
- }
399
- // Leading edge: apply immediately, then open a window whose trailing
400
- // edge only carries updates arriving within the window.
401
- apply(updater);
402
- timer = setTimeout(() => {
403
- timer = null;
404
- fire();
405
- }, wait);
406
- };
407
-
408
- throttled.cancel = () => {
409
- if (timer) {
410
- clearTimeout(timer);
411
- timer = null;
412
- }
413
- queued = [];
414
- };
415
-
416
- throttled.flush = () => {
417
- if (timer) {
418
- clearTimeout(timer);
419
- timer = null;
420
- }
421
- fire();
422
- };
423
-
424
- return throttled;
425
- }
426
-
427
419
  export const ChatProvider: React.FC<ChatProviderProps> = ({
428
420
  children,
429
421
  bypassPermissions,
@@ -454,26 +446,16 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
454
446
  const [latestTotalTokens, setLatestTotalTokens] = useState(0);
455
447
  const [maxInputTokens, setMaxInputTokens] = useState(200000);
456
448
 
457
- // Single throttled entry for ALL message-state updates — one 500ms
458
- // window-concat window shared by every callback (content/reasoning deltas,
459
- // tool parametersChunk, tool `running` stage shortResult/result, one-shot
460
- // structural updates). The tool `running` stage used to bypass throttling
461
- // (bash shortResult height changes per chunk → layout flicker); routing it
462
- // through the same window as streaming deltas coalesces it to ≤1 render per
463
- // window. See createThrottledUpdater + docs/specs/core/stream-content-updates.md.
464
- const updateMessages = useMemo(
465
- () =>
466
- createThrottledUpdater<Message[]>((updater) => setMessages(updater), 500),
467
- [],
468
- );
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 会议).
469
455
 
470
456
  useEffect(() => {
471
457
  isExpandedRef.current = isExpanded;
472
- if (isExpanded) {
473
- // Cancel pending throttled updates so the frozen expanded view isn't overwritten
474
- updateMessages.cancel();
475
- }
476
- }, [isExpanded, updateMessages]);
458
+ }, [isExpanded]);
477
459
 
478
460
  const [isLoading, setIsLoading] = useState(false);
479
461
  const [sessionId, setSessionId] = useState("");
@@ -538,6 +520,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
538
520
  >();
539
521
  const [confirmationQueue, setConfirmationQueue] = useState<
540
522
  Array<{
523
+ requestId: string;
541
524
  toolName: string;
542
525
  toolInput?: Record<string, unknown>;
543
526
  suggestedPrefix?: string;
@@ -551,6 +534,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
551
534
  }>
552
535
  >([]);
553
536
  const [currentConfirmation, setCurrentConfirmation] = useState<{
537
+ requestId: string;
554
538
  toolName: string;
555
539
  toolInput?: Record<string, unknown>;
556
540
  suggestedPrefix?: string;
@@ -563,6 +547,37 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
563
547
  reject: () => void;
564
548
  } | null>(null);
565
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
+
566
581
  // Remount state
567
582
  const [remountKey, setRemountKey] = useState(0);
568
583
 
@@ -580,6 +595,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
580
595
 
581
596
  const agentRef = useRef<Agent | null>(null);
582
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
+
583
606
  // Full-list refresh — one-shot pull from the agent, used only for structural
584
607
  // changes (compact/clear/rewind/collapse/init). Streaming updates flow through
585
608
  // the incremental callbacks in initializeAgent below.
@@ -588,19 +611,14 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
588
611
  // Pull the full UI display stream (keeps pre-compaction history);
589
612
  // `agent.messages` would only expose the folded API context.
590
613
  const msgs = agentRef.current.displayMessages.map(snapshotMessage);
591
- // Snapshot-safe: the full-list replacement makes the SDK state
592
- // authoritative. Any update still queued inside the 500ms throttle
593
- // window was applied to the SDK before this pull, so it is already
594
- // contained in `msgs` — dropping it prevents the trailing-edge flush
595
- // from re-appending the pre-refresh chunk on top of the snapshot
596
- // (first-word duplication). Updates arriving after the pull start fresh
597
- // windows and append on top of the snapshot. See
598
- // docs/specs/core/stream-content-updates.md.
599
- 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.
600
618
  setMessages(msgs);
601
619
  setLatestTotalTokens(extractLatestTotalTokens(msgs));
602
620
  }
603
- }, [updateMessages]);
621
+ }, []);
604
622
 
605
623
  // Permission confirmation methods with queue support
606
624
  const showConfirmation = useCallback(
@@ -614,8 +632,38 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
614
632
  warning?: string,
615
633
  outsideSafeZoneDirectory?: string,
616
634
  ): Promise<PermissionDecision> => {
635
+ const requestId = `cli_perm_${++permissionCounterRef.current}`;
617
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();
618
665
  const queueItem = {
666
+ requestId,
619
667
  toolName,
620
668
  toolInput,
621
669
  suggestedPrefix,
@@ -624,15 +672,15 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
624
672
  planContent,
625
673
  permissionMode,
626
674
  warning,
627
- resolver: resolve,
628
- reject,
675
+ resolver: settle,
676
+ reject: cancel,
629
677
  };
630
678
 
631
679
  setConfirmationQueue((prev) => [...prev, queueItem]);
632
680
  // processNextConfirmation will be called via useEffect
633
681
  });
634
682
  },
635
- [],
683
+ [syncRemotePending],
636
684
  );
637
685
 
638
686
  // Initialize AI manager
@@ -641,10 +689,23 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
641
689
  const effectiveRestoreSessionId =
642
690
  restoreSessionIdOverride ?? restoreSessionId;
643
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
+
644
703
  const callbacks: AgentCallbacks = {
645
704
  // ── Incremental message updates (no full-list pushes) ──────
646
- // All of these funnel through the single throttled `updateMessages`
647
- // 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.
648
709
  onUserMessageAdded: () => {
649
710
  if (isExpandedRef.current || !agentRef.current) return;
650
711
  const msgs = agentRef.current.messages;
@@ -654,12 +715,11 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
654
715
  // the batch flush, so evaluating snapshotMessage inside the updater
655
716
  // would read the SDK message after its in-place mutations.
656
717
  const snapshot = snapshotMessage(last);
657
- updateMessages((prev) =>
718
+ // One-shot structural update — applied directly (a window would
719
+ // only delay the message card, no coalescing value).
720
+ setMessages((prev) =>
658
721
  prev.some((m) => m.id === last.id) ? prev : [...prev, snapshot],
659
722
  );
660
- // One-shot structural update — flush immediately so the message
661
- // card appears at once (queuing it gains no coalescing).
662
- updateMessages.flush();
663
723
  },
664
724
  onAssistantMessageAdded: (messageId: string) => {
665
725
  if (isExpandedRef.current || !agentRef.current) return;
@@ -669,29 +729,43 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
669
729
  // fires this callback BEFORE the first delta writes into the shared
670
730
  // message, so capturing here copies the pre-mutation (empty) blocks.
671
731
  const snapshot = snapshotMessage(msg);
672
- updateMessages((prev) =>
732
+ setMessages((prev) =>
673
733
  prev.some((m) => m.id === messageId) ? prev : [...prev, snapshot],
674
734
  );
675
- 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));
676
742
  },
677
743
  onAssistantContentUpdated: (params) => {
744
+ remoteStream?.onAssistantContentUpdated(params);
678
745
  if (isExpandedRef.current) return;
679
- updateMessages((prev) => applyContentDelta(prev, params));
680
- 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));
681
750
  },
682
751
  onAssistantReasoningUpdated: (params) => {
752
+ remoteStream?.onAssistantReasoningUpdated(params);
683
753
  if (isExpandedRef.current) return;
684
- updateMessages((prev) => applyReasoningDelta(prev, params));
685
- if (params.stage === "end") updateMessages.flush();
754
+ setMessages((prev) => applyReasoningDelta(prev, params));
686
755
  },
687
756
  onToolBlockUpdated: (params) => {
757
+ remoteStream?.onToolBlockUpdated(params);
688
758
  if (isExpandedRef.current) return;
689
- updateMessages((prev) => applyToolBlockUpdate(prev, params));
690
- 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));
691
762
  },
692
763
  onErrorBlockAdded: (error: string) => {
764
+ remoteStream?.onErrorBlockAdded(error);
693
765
  if (isExpandedRef.current) return;
694
- updateMessages((prev) => {
766
+ // One-shot structural update — applied directly (errors must not be
767
+ // delayed by the tool-running window).
768
+ setMessages((prev) => {
695
769
  // Append to the LAST message only if it is an assistant message
696
770
  // (the current turn's in-flight reply). If the last message is a
697
771
  // user message, the error belongs BELOW it — create a new
@@ -719,9 +793,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
719
793
  },
720
794
  ];
721
795
  });
722
- // One-shot structural update — flush immediately (errors must not be
723
- // delayed by a streaming window).
724
- updateMessages.flush();
725
796
  },
726
797
  onLatestTotalTokensChange: (tokens) => {
727
798
  setLatestTotalTokens(tokens);
@@ -828,6 +899,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
828
899
  isNewWorktree: worktreeSession?.isNew,
829
900
  model,
830
901
  mcpServers,
902
+ // This session was created from the interactive CLI → `app.entrypoint`.
903
+ entrypoint: "cli",
831
904
  });
832
905
 
833
906
  agentRef.current = agent;
@@ -910,7 +983,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
910
983
  originalCwd,
911
984
  model,
912
985
  initialPermissionMode,
913
- updateMessages,
914
986
  mcpServers,
915
987
  ],
916
988
  );
@@ -968,7 +1040,6 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
968
1040
  // Cleanup on unmount
969
1041
  useEffect(() => {
970
1042
  return () => {
971
- updateMessages.cancel();
972
1043
  if (agentRef.current) {
973
1044
  try {
974
1045
  // Display usage summary before cleanup
@@ -982,7 +1053,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
982
1053
  agentRef.current.destroy();
983
1054
  }
984
1055
  };
985
- }, [updateMessages]);
1056
+ }, []);
986
1057
 
987
1058
  // Send message function (including judgment logic)
988
1059
  const sendMessage = useCallback(
@@ -1334,6 +1405,165 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1334
1405
  hideConfirmation();
1335
1406
  }, [currentConfirmation, hideConfirmation]);
1336
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
+
1337
1567
  const backgroundCurrentTask = useCallback(() => {
1338
1568
  agentRef.current?.backgroundCurrentTask();
1339
1569
  }, []);
@@ -1462,6 +1692,8 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
1462
1692
  hooks,
1463
1693
  permissionMode,
1464
1694
  setPermissionMode,
1695
+ remoteControl,
1696
+ remoteControlCommand,
1465
1697
  planView,
1466
1698
  setPlanView,
1467
1699
  handlePlanCommand,