wave-code 1.3.0 → 1.5.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 (63) hide show
  1. package/dist/bundle/wave.mjs +1382 -1131
  2. package/package.json +9 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/commands/update.ts +62 -22
  5. package/src/components/AgentsManager.tsx +3 -3
  6. package/src/components/App.tsx +5 -0
  7. package/src/components/BackgroundTaskManager.tsx +76 -54
  8. package/src/components/BtwDisplay.tsx +71 -15
  9. package/src/components/CompactDisplay.tsx +1 -6
  10. package/src/components/ConfirmationDetails.tsx +34 -13
  11. package/src/components/ConfirmationSelector.tsx +31 -36
  12. package/src/components/DiffDisplay.tsx +1 -1
  13. package/src/components/HooksManager.tsx +3 -3
  14. package/src/components/InputBox.tsx +37 -11
  15. package/src/components/LoginCommand.tsx +16 -35
  16. package/src/components/Markdown.tsx +7 -3
  17. package/src/components/MarketplaceAddForm.tsx +15 -19
  18. package/src/components/MessageBlockItem.tsx +0 -6
  19. package/src/components/RemoteControlView.tsx +175 -0
  20. package/src/components/ScrolledContent.tsx +15 -13
  21. package/src/components/SkillsManager.tsx +3 -3
  22. package/src/components/TaskList.tsx +3 -3
  23. package/src/components/ToolDisplay.tsx +3 -18
  24. package/src/components/WorkflowManager.tsx +150 -136
  25. package/src/components/WorkflowRunDetail.tsx +193 -0
  26. package/src/constants/commands.ts +6 -0
  27. package/src/contexts/useChat.tsx +308 -154
  28. package/src/daemon/commands.ts +31 -22
  29. package/src/daemon-cli.ts +6 -1
  30. package/src/hooks/useInputManager.ts +25 -39
  31. package/src/hooks/useLineScroll.ts +51 -5
  32. package/src/hooks/useRemoteControl.ts +346 -0
  33. package/src/index.ts +35 -0
  34. package/src/managers/inputHandlers.ts +5 -0
  35. package/src/managers/inputReducer.ts +39 -0
  36. package/src/print-cli.ts +5 -0
  37. package/src/reducers/workflowManagerReducer.ts +21 -2
  38. package/src/remote/frameBudget.ts +45 -0
  39. package/src/remote/hostIdentity.ts +128 -0
  40. package/src/remote/messageWindow.ts +46 -0
  41. package/src/remote/notificationCoalescer.ts +192 -0
  42. package/src/remote/relayClient.ts +690 -0
  43. package/src/remote/remoteControlCliHost.ts +420 -0
  44. package/src/remote/remoteControlCommands.ts +197 -0
  45. package/src/remote/remoteControlConfig.ts +353 -0
  46. package/src/remote/remoteControlService.ts +822 -0
  47. package/src/remote/remoteControlSink.ts +78 -0
  48. package/src/remote/remoteNotifications.ts +200 -0
  49. package/src/stdio/agentBridge.ts +874 -106
  50. package/src/stdio/daemonServer.ts +30 -0
  51. package/src/stdio/hostSlashCommands.ts +209 -0
  52. package/src/stdio/jsonRpcConnection.ts +11 -2
  53. package/src/stdio/protocol.ts +14 -115
  54. package/src/stdio/stdioServer.ts +33 -2
  55. package/src/stdio/uiPayload.ts +65 -0
  56. package/src/stdio-cli.ts +11 -2
  57. package/src/types.ts +7 -0
  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/workspaceDiff.ts +620 -0
  62. package/src/utils/worktree.ts +97 -0
  63. package/src/utils/bracketedPaste.ts +0 -170
@@ -37,6 +37,8 @@ import {
37
37
  isValidHookEvent,
38
38
  listSessions,
39
39
  listAllSessions,
40
+ readRecentWorkdirs,
41
+ rememberRecentWorkdir,
40
42
  setSessionCustomTitle,
41
43
  searchFiles,
42
44
  generateRandomName,
@@ -46,7 +48,9 @@ import {
46
48
  AuthService,
47
49
  PluginCore,
48
50
  validateWorktreeRemovalPath,
49
- type SlashCommand,
51
+ type HostSurface,
52
+ type SlashCommandInfo,
53
+ type ConfiguredModel,
50
54
  loadUserConfigEnv,
51
55
  loadWaveConfigFromFile,
52
56
  getUserConfigPaths,
@@ -58,28 +62,72 @@ import {
58
62
  type SubagentConfiguration,
59
63
  type SkillMetadata,
60
64
  type PluginReloadResult,
65
+ type RemoteControlSnapshot,
66
+ type CompletionSnippet,
67
+ type NextEditExcerpt,
68
+ type NextEditHistoryEntry,
69
+ type NextEditRegion,
70
+ type NextEditSuggestion,
71
+ normalizeEntrypoint,
72
+ fetchArtifactGalleryList,
73
+ fetchArtifactShareView,
74
+ saveArtifactShare,
75
+ revokeArtifact,
76
+ searchArtifactMembers,
77
+ type ArtifactShareMode,
61
78
  } from "wave-agent-sdk";
62
79
  import {
80
+ type ClientNotificationMethod,
63
81
  type JsonRpcError,
82
+ type RequestMethod,
83
+ type ServerNotificationMethod,
64
84
  INVALID_PARAMS as PROTOCOL_INVALID_PARAMS,
65
85
  INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
66
86
  METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
67
87
  } from "./protocol.js";
68
88
  import { execFileSync } from "node:child_process";
69
- import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
89
+ import {
90
+ mkdirSync,
91
+ existsSync,
92
+ readFileSync,
93
+ writeFileSync,
94
+ readdirSync,
95
+ realpathSync,
96
+ } from "node:fs";
70
97
  import { mkdir, open, readFile, writeFile } from "node:fs/promises";
71
98
  import { homedir, tmpdir } from "node:os";
72
99
  import { basename, dirname, extname, join } from "node:path";
73
100
  import {
74
101
  createWorktree,
102
+ deriveWorktreeInfo,
75
103
  getWorktreeChanges,
76
104
  removeWorktree,
77
105
  } from "../utils/worktree.js";
78
106
  import { logger } from "../utils/logger.js";
107
+ import {
108
+ InvalidMessageWindowError,
109
+ normalizeMessageWindow,
110
+ type MessageWindow,
111
+ type MessageWindowResult,
112
+ } from "../remote/messageWindow.js";
113
+ import {
114
+ percentOf,
115
+ remoteWire,
116
+ toBackgroundTaskSummaries,
117
+ } from "../remote/remoteNotifications.js";
79
118
  import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
119
+ import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
120
+ import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
121
+ import type { RemoteControlController } from "../remote/remoteControlService.js";
122
+ import {
123
+ hostSlashCommandsFor,
124
+ parseHostSurface,
125
+ toSlashCommandInfo,
126
+ } from "./hostSlashCommands.js";
127
+ import { messageForUi, messagesForUi } from "./uiPayload.js";
80
128
 
81
129
  export type NotificationEmitter = (
82
- method: string,
130
+ method: ServerNotificationMethod,
83
131
  params: unknown,
84
132
  sessionId?: string,
85
133
  ) => void;
@@ -91,6 +139,17 @@ export interface AgentBridgeOptions {
91
139
  * down its socket and exits. stdio mode leaves it unset, so `shutdown` is
92
140
  * "Method not found" there. */
93
141
  onShutdownRequest?: () => void;
142
+ /** Daemon transport only. Remote control is a daemon-level concern (it owns
143
+ * an outbound socket and a relay session); the bridge owns nothing here and
144
+ * only routes the three `remoteControl*` RPCs to it, exactly as it routes
145
+ * `shutdown` to `onShutdownRequest`. Unset ⇒ those RPCs report that remote
146
+ * control is not enabled, which is the state on every stdio-mode process.
147
+ *
148
+ * Resolved on demand rather than passed as an object: the controller needs
149
+ * this bridge to route the phone's requests back in, so the two are built in
150
+ * a circle. Reading it when an RPC arrives — long after both exist — keeps
151
+ * that circle from having to be broken with a placeholder. */
152
+ remoteControl?: () => RemoteControlController | undefined;
94
153
  }
95
154
 
96
155
  interface InitializeParams {
@@ -110,6 +169,13 @@ interface InitializeParams {
110
169
  mcpServers?: Record<string, McpServerConfig>;
111
170
  worktreeName?: string;
112
171
  isNewWorktree?: boolean;
172
+ /**
173
+ * Which host the session belongs to → the `app.entrypoint` attribute. A raw
174
+ * string: it arrives over the wire, from hosts this process does not compile
175
+ * against (the JetBrains plugin builds its own frames). Narrowed to the known
176
+ * table below before it reaches the SDK.
177
+ */
178
+ entrypoint?: string;
113
179
  }
114
180
 
115
181
  // 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
@@ -135,7 +201,7 @@ interface SessionEntry {
135
201
  storedConfig: Partial<InitializeParams>;
136
202
  /**
137
203
  * The workdir the session was CREATED in — an immutable identity, unlike the
138
- * live `agent.workingDirectory`, which drifts as the session `cd`s (bash tool
204
+ * live `agent.currentWorkdir`, which drifts as the session `cd`s (bash tool
139
205
  * / worktree switches). Hosted-session consumers (destroy --remove-worktree's
140
206
  * worktree resolution, list) read this instead of the live value so a session
141
207
  * that moved out of its worktree into the main repo is still cleaned up at
@@ -189,14 +255,34 @@ export class AgentBridge {
189
255
  }
190
256
  >();
191
257
  private permissionCounter = 0;
258
+ /**
259
+ * In-flight completion calls, keyed by envelope sessionId. Completion answers
260
+ * a keystroke pause, so a newer request makes the previous one worthless —
261
+ * superseding it here is what keeps "one in-flight request per editor" true
262
+ * even when the host cannot cancel (spec
263
+ * docs/specs/ui/code-completion.md: 同一时刻只允许一个补全请求在途).
264
+ */
265
+ private inFlightCompletions = new Map<string, AbortController>();
266
+ /**
267
+ * In-flight next-edit calls, same one-per-session rule and same reason as
268
+ * completions. It also participates in the cross-capability exclusion from
269
+ * `docs/specs/ui/next-edit-suggestion.md`: completion and next-edit never hold
270
+ * an in-flight request at the same time, so one of them cannot answer into a
271
+ * buffer the other has already rewritten.
272
+ */
273
+ private inFlightNextEdits = new Map<string, AbortController>();
192
274
  private emit: NotificationEmitter;
193
275
  private onShutdownRequest: (() => void) | undefined;
276
+ private remoteControl:
277
+ | (() => RemoteControlController | undefined)
278
+ | undefined;
194
279
  private pluginCore: PluginCore | undefined;
195
280
  private pluginCoreWorkdir: string | undefined;
196
281
 
197
282
  constructor(options: AgentBridgeOptions) {
198
283
  this.emit = options.emit;
199
284
  this.onShutdownRequest = options.onShutdownRequest;
285
+ this.remoteControl = options.remoteControl;
200
286
  // Mirror the user-level settings env WAVE_SERVER_URL into process.env
201
287
  // before any agent initializes. getAuthStatus (webviewReady →
202
288
  // pushInitialState) can run before the first agent, and AuthService falls
@@ -211,7 +297,7 @@ export class AgentBridge {
211
297
  // ── Public API ────────────────────────────────────────────────
212
298
 
213
299
  async handleRequest(
214
- method: string,
300
+ method: RequestMethod,
215
301
  params: unknown,
216
302
  sessionId?: string,
217
303
  ): Promise<unknown> {
@@ -221,7 +307,9 @@ export class AgentBridge {
221
307
  case "initialize":
222
308
  return this.initialize(p as unknown as InitializeParams);
223
309
  case "destroy":
224
- return this.destroy(sessionId);
310
+ return this.destroy(sessionId, {
311
+ removeWorktree: p.removeWorktree === true,
312
+ });
225
313
  case "restoreSession":
226
314
  return this.restoreSession(p.sessionId as string, sessionId);
227
315
  case "listSessions":
@@ -311,10 +399,20 @@ export class AgentBridge {
311
399
  case "deleteQueuedMessageById":
312
400
  return this.deleteQueuedMessageById(p.id as string, sessionId);
313
401
  case "getMessages":
314
- return this.getMessages(sessionId);
402
+ return this.getMessages(sessionId, {
403
+ since: p.since as string | undefined,
404
+ limit: p.limit as number | undefined,
405
+ });
315
406
  case "getFullMessageThread":
316
407
  return this.getFullMessageThread(sessionId);
317
408
 
409
+ // ── Remote control (daemon-level) ──
410
+ // The daemon owns the outbound relay socket; the bridge only routes.
411
+ case "remoteControlStatus":
412
+ case "remoteControlConnect":
413
+ case "remoteControlDisconnect":
414
+ return this.remoteControlRequest(method, p);
415
+
318
416
  // ── Permissions ──
319
417
  case "setPermissionMode":
320
418
  return this.setPermissionMode(p.mode as PermissionMode, sessionId);
@@ -341,7 +439,7 @@ export class AgentBridge {
341
439
 
342
440
  // ── Commands ──
343
441
  case "getSlashCommands":
344
- return this.getSlashCommands(sessionId);
442
+ return this.getSlashCommands(sessionId, parseHostSurface(p.surface));
345
443
  case "getSubagentConfigurations":
346
444
  return this.getSubagentConfigurations(sessionId);
347
445
  case "getSkillMetadata":
@@ -390,6 +488,30 @@ export class AgentBridge {
390
488
  return this.login(p.serverUrl as string | undefined);
391
489
  case "logout":
392
490
  return this.logout();
491
+ case "installGitCredentialHelper":
492
+ return installGitCredentialHelper();
493
+
494
+ // ── 创作集(global — 账号级读写,无 session 要求)──
495
+ // 桌面端整页与右侧预览面板共用这一组;HTTP 与 token 只留在本进程
496
+ // (services/artifactGallery.ts),宿主不直连网络。
497
+ case "artifactGalleryList":
498
+ return fetchArtifactGalleryList();
499
+ case "artifactShareView":
500
+ return fetchArtifactShareView(p.slug as string);
501
+ case "artifactShareSave":
502
+ return saveArtifactShare({
503
+ slug: p.slug as string,
504
+ mode: p.mode as ArtifactShareMode,
505
+ userIds: p.userIds as string[] | undefined,
506
+ pinnedVersion: p.pinnedVersion as number | null | undefined,
507
+ });
508
+ case "artifactRevoke":
509
+ return revokeArtifact(p.slug as string);
510
+ case "artifactMembersSearch":
511
+ return searchArtifactMembers(
512
+ (p.search as string | undefined) ?? "",
513
+ p.limit as number | undefined,
514
+ );
393
515
 
394
516
  // ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
395
517
  // project-level <workdir>/AGENTS.md) ──
@@ -527,7 +649,17 @@ export class AgentBridge {
527
649
  case "stopWorkflowRun":
528
650
  return this.stopWorkflowRun(p.runId as string, sessionId);
529
651
 
530
- // ── Git / worktree (global — no session required) ──
652
+ // ── Filesystem / Git / worktree (global — no session required) ──
653
+ case "listDirectories":
654
+ return this.listDirectories(p.dir as string | undefined);
655
+ // ── 最近打开的工作目录(spec core/recent-workdirs.md)──
656
+ // 机器级、与会话无关:文件就在这台机器上,谁问就回谁的。桌面端按主机
657
+ // 各问一次,远控 Web 页问它连着的那个进程。
658
+ case "getRecentWorkdirs":
659
+ return { dirs: readRecentWorkdirs() };
660
+ case "rememberRecentWorkdir":
661
+ rememberRecentWorkdir(p.workdir as string);
662
+ return null;
531
663
  case "listGitBranches":
532
664
  return this.listGitBranches(p.workdir as string | undefined);
533
665
  case "createWorktree":
@@ -542,6 +674,8 @@ export class AgentBridge {
542
674
  return this.getWorktreeChanges(
543
675
  p as unknown as { path?: string; baseBranch?: string },
544
676
  );
677
+ case "getSessionWorktreeChanges":
678
+ return this.getSessionWorktreeChanges(sessionId);
545
679
  case "removeWorktree":
546
680
  return this.removeWorktreeSession(
547
681
  p as unknown as {
@@ -551,16 +685,64 @@ export class AgentBridge {
551
685
  hookBased?: boolean;
552
686
  },
553
687
  );
688
+ case "getWorkspaceDiff":
689
+ return this.getWorkspaceDiff(
690
+ p.workdir as string | undefined,
691
+ p.commit as string | undefined,
692
+ p.scope as string | undefined,
693
+ );
694
+ case "getCompletion":
695
+ return this.getCompletion(
696
+ p as unknown as {
697
+ prefix?: string;
698
+ suffix?: string;
699
+ language?: string;
700
+ filePath?: string;
701
+ snippets?: CompletionSnippet[];
702
+ },
703
+ sessionId,
704
+ );
705
+ case "reportCompletionOutcome":
706
+ return this.reportCompletionOutcome(
707
+ p.outcome as "displayed" | "accepted",
708
+ sessionId,
709
+ );
710
+ case "reportNextEditOutcome":
711
+ return this.reportNextEditOutcome(
712
+ p.outcome as "displayed" | "accepted",
713
+ sessionId,
714
+ );
715
+ case "getNextEdit":
716
+ return this.getNextEdit(
717
+ p as unknown as {
718
+ edits?: NextEditHistoryEntry[];
719
+ region?: NextEditRegion;
720
+ excerpt?: NextEditExcerpt;
721
+ language?: string;
722
+ filePath?: string;
723
+ },
724
+ sessionId,
725
+ );
554
726
 
555
- default:
727
+ default: {
728
+ // Type-level exhaustiveness, both directions: a union member without a
729
+ // `case` lands here as a non-`never` and fails to compile, and a `case`
730
+ // naming something outside the union fails to compile at the `case`
731
+ // itself. Runtime behaviour is unchanged — `method` can still be an
732
+ // arbitrary string once the transport boundary cast it.
733
+ const _exhaustive: never = method;
734
+ // Kept referenced so the witness isn't an unused-variable lint error;
735
+ // it is never read at runtime.
736
+ void _exhaustive;
556
737
  throw new RpcError(
557
738
  PROTOCOL_METHOD_NOT_FOUND,
558
739
  `Method not found: ${method}`,
559
740
  );
741
+ }
560
742
  }
561
743
  }
562
744
 
563
- handleNotification(method: string, params: unknown): void {
745
+ handleNotification(method: ClientNotificationMethod, params: unknown): void {
564
746
  if (method === "permissionResponse") {
565
747
  const p = params as {
566
748
  requestId: string;
@@ -579,7 +761,9 @@ export class AgentBridge {
579
761
 
580
762
  private async initialize(params: InitializeParams): Promise<{
581
763
  sessionId: string;
582
- workingDirectory: string;
764
+ currentWorkdir: string;
765
+ sessionCwd: string;
766
+ repoRoot?: string;
583
767
  permissionMode: PermissionMode;
584
768
  latestTotalTokens: number;
585
769
  }> {
@@ -589,18 +773,23 @@ export class AgentBridge {
589
773
  if (params.restoreSessionId) {
590
774
  const live = this.sessions.get(params.restoreSessionId);
591
775
  if (live) {
592
- return {
593
- sessionId: live.agent.sessionId,
594
- workingDirectory: live.agent.workingDirectory,
595
- permissionMode: live.agent.getPermissionMode(),
596
- latestTotalTokens: live.agent.latestTotalTokens,
597
- };
776
+ return this.initializeReply(live.agent, live.createdWorkdir);
598
777
  }
599
778
  }
600
779
 
601
780
  const ctx: SessionContext = {};
602
781
  const callbacks = this.createCallbacks(ctx);
603
782
 
783
+ const entrypoint = normalizeEntrypoint(params.entrypoint);
784
+ if (params.entrypoint !== undefined && entrypoint === undefined) {
785
+ // A host that ships a value this version does not know (an older client,
786
+ // a hand-written frame) is treated as if it had declared nothing: an
787
+ // unrecognised value must not mint a series of its own.
788
+ logger?.warn(
789
+ `Unknown entrypoint: ${String(params.entrypoint)}, ignoring`,
790
+ );
791
+ }
792
+
604
793
  const options: AgentOptions = {
605
794
  callbacks,
606
795
  logger,
@@ -619,6 +808,7 @@ export class AgentBridge {
619
808
  mcpServers: params.mcpServers,
620
809
  worktreeName: params.worktreeName,
621
810
  isNewWorktree: params.isNewWorktree,
811
+ entrypoint,
622
812
  canUseTool: (context: ToolPermissionContext) =>
623
813
  this.canUseTool(context, ctx),
624
814
  };
@@ -639,9 +829,9 @@ export class AgentBridge {
639
829
  createdWorkdir =
640
830
  (await readTranscriptWorkdir(agent.sessionFilePath)) ??
641
831
  params.workdir ??
642
- agent.workingDirectory;
832
+ agent.currentWorkdir;
643
833
  } else {
644
- createdWorkdir = params.workdir ?? agent.workingDirectory;
834
+ createdWorkdir = params.workdir ?? agent.currentWorkdir;
645
835
  }
646
836
 
647
837
  this.sessions.set(agent.sessionId, {
@@ -652,15 +842,84 @@ export class AgentBridge {
652
842
  createdWorkdir,
653
843
  });
654
844
 
845
+ return this.initializeReply(agent, createdWorkdir);
846
+ }
847
+
848
+ /**
849
+ * The `initialize` reply.
850
+ *
851
+ * The current cwd and the session root travel as two separate facts on
852
+ * purpose. A re-attached session may have bash-`cd`'d away from where it was
853
+ * created, so `agent.currentWorkdir` answers "where is it now" while
854
+ * `createdWorkdir` answers "which project does this conversation belong to".
855
+ * Hosts key durable things (sidebar grouping, project-scoped config, `@file`
856
+ * scope) off the latter — the desktop used to derive both from one
857
+ * `workingDirectory` field and moved a resumed conversation into a
858
+ * subdirectory group.
859
+ *
860
+ * `repoRoot` closes the worktree case: a session created inside a linked git
861
+ * worktree belongs to the repo it was forked from. It is derived the same way
862
+ * `listDaemonSessions` derives each row's `worktree`, i.e. from the
863
+ * filesystem, without shelling out to git.
864
+ */
865
+ private initializeReply(
866
+ agent: Agent,
867
+ sessionCwd: string,
868
+ ): {
869
+ sessionId: string;
870
+ currentWorkdir: string;
871
+ sessionCwd: string;
872
+ repoRoot?: string;
873
+ permissionMode: PermissionMode;
874
+ latestTotalTokens: number;
875
+ } {
876
+ const worktree = deriveWorktreeInfo(sessionCwd);
655
877
  return {
656
878
  sessionId: agent.sessionId,
657
- workingDirectory: agent.workingDirectory,
879
+ currentWorkdir: agent.currentWorkdir,
880
+ sessionCwd,
881
+ ...(worktree ? { repoRoot: worktree.repoRoot } : {}),
658
882
  permissionMode: agent.getPermissionMode(),
659
883
  latestTotalTokens: agent.latestTotalTokens,
660
884
  };
661
885
  }
662
886
 
663
- private async destroy(sessionId?: string): Promise<null> {
887
+ /**
888
+ * Destroy one hosted session (idempotent: an unknown id is a no-op).
889
+ *
890
+ * `params.removeWorktree` (optional, default false) additionally removes the
891
+ * git worktree the session was **created** in — the intent the remote page
892
+ * sends when the user confirms 「删除会话与工作树」. Two rules make it safe to
893
+ * offer over the remote channel, where the caller must never be able to name a
894
+ * path:
895
+ *
896
+ * 1. The worktree is resolved here, from `entry.createdWorkdir` — the same
897
+ * anchor `wave daemon destroy --remove-worktree` resolves from (and for the
898
+ * same reason: an in-session `cd` must not move the target). Nothing in the
899
+ * request is trusted as a path, which is why the raw `removeWorktree`
900
+ * method stays off the remote whitelist.
901
+ * 2. It runs **after** `agent.destroy()` resolves. The agent's cwd is the
902
+ * worktree path and destroy flushes the session file (resolving that path
903
+ * via realpath); deleting the directory first makes the save fail ENOENT
904
+ * and drops the trailing messages — the desktop host carries the identical
905
+ * ordering note in `handleDeleteSession`.
906
+ *
907
+ * "Cannot resolve a worktree" is never a reason to fail the delete: the
908
+ * session is still destroyed and the reply says `worktreeRemoved: false`. That
909
+ * covers a plain (non-worktree) session, a worktree that is already gone, and
910
+ * a removal that threw — the caller only ever needs to know whether the
911
+ * directory is gone.
912
+ *
913
+ * The return shape grew from `null` to `{worktreeRemoved}`; callers that pass
914
+ * no params (the CLI's own `wave daemon destroy`) read nothing and are
915
+ * unaffected. The CLI's `--remove-worktree` keeps its own client-side path
916
+ * (getSessionInfo + removeWorktree + destroy) — deliberately unchanged.
917
+ */
918
+ private async destroy(
919
+ sessionId?: string,
920
+ params?: { removeWorktree?: boolean },
921
+ ): Promise<{ worktreeRemoved: boolean }> {
922
+ let createdWorkdir: string | undefined;
664
923
  if (sessionId) {
665
924
  const entry = this.sessions.get(sessionId);
666
925
  if (entry) {
@@ -687,10 +946,71 @@ export class AgentBridge {
687
946
  // half-destroyed agent listed (destroyAll() below clears the map first
688
947
  // for the same reason).
689
948
  this.sessions.delete(sessionId);
949
+ createdWorkdir = entry.createdWorkdir;
690
950
  await entry.agent.destroy();
691
951
  }
692
952
  }
693
- return null;
953
+
954
+ let worktreeRemoved = false;
955
+ if (params?.removeWorktree === true && createdWorkdir) {
956
+ worktreeRemoved = await this.removeSessionWorktree(createdWorkdir);
957
+ }
958
+ return { worktreeRemoved };
959
+ }
960
+
961
+ /**
962
+ * Remove the linked worktree a session was created in. Returns whether the
963
+ * directory is actually gone (not whether we asked): `removeWorktree` is
964
+ * best-effort and leaves the directory behind when git and the fs fallback
965
+ * both fail, and for a hook-based worktree with no WorktreeRemove hook it
966
+ * deliberately leaves it in place. Never throws — the caller has already
967
+ * deleted the session.
968
+ */
969
+ private async removeSessionWorktree(sessionDir: string): Promise<boolean> {
970
+ const worktree = deriveWorktreeInfo(sessionDir);
971
+ if (!worktree) return false;
972
+ try {
973
+ await removeWorktree({
974
+ name: "",
975
+ path: worktree.path,
976
+ branch: worktree.branch,
977
+ repoRoot: worktree.repoRoot,
978
+ hasUncommittedChanges: false,
979
+ hasNewCommits: false,
980
+ isNew: false,
981
+ hookBased: worktree.hookBased,
982
+ });
983
+ } catch (e) {
984
+ logger.warn(
985
+ `removeSessionWorktree: failed to remove ${worktree.path}: ${(e as Error).message}`,
986
+ );
987
+ return false;
988
+ }
989
+ return !existsSync(worktree.path);
990
+ }
991
+
992
+ /**
993
+ * What deleting a session's worktree would throw away (uncommitted files and
994
+ * commits not on the base branch) — the counts the delete confirmation names
995
+ * on the remote page.
996
+ *
997
+ * Session-scoped on purpose: the path comes from the session's own
998
+ * `createdWorkdir`, never from the caller. The remote channel carries this
999
+ * instead of the path-taking `getWorktreeChanges`, for the same reason it
1000
+ * carries `destroy {removeWorktree}` instead of `removeWorktree` — a phone may
1001
+ * only name a session it was given, not an arbitrary directory on the machine.
1002
+ *
1003
+ * `null` means "could not inspect" (not a worktree, already deleted, git
1004
+ * failed), which the page renders as the generic warning rather than as
1005
+ * "clean".
1006
+ */
1007
+ private async getSessionWorktreeChanges(
1008
+ sessionId?: string,
1009
+ ): Promise<{ files: number; commits: number } | null> {
1010
+ const entry = this.requireSessionForRead(sessionId);
1011
+ const worktree = deriveWorktreeInfo(entry.createdWorkdir);
1012
+ if (!worktree) return null;
1013
+ return getWorktreeChanges(worktree.path);
694
1014
  }
695
1015
 
696
1016
  /**
@@ -713,13 +1033,23 @@ export class AgentBridge {
713
1033
  ): Promise<null> {
714
1034
  const entry = this.requireSessionForRead(sessionId);
715
1035
  // Re-attach to a live session: the SDK restore would no-op (target is
716
- // already current). Emit the current messages so the freshly attached
717
- // client — whose router registered only after initialize returned and so
718
- // missed any earlier notifications — gets a snapshot without replay.
1036
+ // already current). Re-send the state the freshly attached client missed —
1037
+ // its router registered only after initialize returned.
1038
+ //
1039
+ // `messagesChange` carries **no payload here, only the fact that the list
1040
+ // changed**. Nobody ever read a full snapshot from it: the remote relay
1041
+ // needs the emission to produce the phone's signal and then the phone pulls
1042
+ // `getMessages({ since })` itself, while every IDE host ignores the method
1043
+ // and calls `getMessages` after restore. So building one — a multi-MB list
1044
+ // on a long session, serialized on a hot re-attach path — was pure cost, and
1045
+ // this is why the bridge sends the signal shape instead.
1046
+ //
1047
+ // This is also the ONLY place the bridge emits `messagesChange`, and only on
1048
+ // this re-attach branch — never as the message stream progresses.
719
1049
  if (entry.agent.sessionId === restoreId) {
720
1050
  this.emit(
721
1051
  "messagesChange",
722
- { messages: entry.agent.displayMessages },
1052
+ remoteWire.messagesChangeSignal(),
723
1053
  entry.agent.sessionId,
724
1054
  );
725
1055
  // The re-attached client also missed the loading state that settled
@@ -775,9 +1105,14 @@ export class AgentBridge {
775
1105
  * usage to report; the host keeps the empty ring until the first push.
776
1106
  */
777
1107
  private emitContextUsage(agent: Agent): void {
778
- const percent = this.contextUsagePercentOf(agent);
779
- if (percent !== undefined) {
780
- this.emit("contextUsage", { percent }, agent.sessionId);
1108
+ const tokens = agent.latestTotalTokens;
1109
+ const max = agent.getMaxInputTokens();
1110
+ if (tokens > 0 && max > 0) {
1111
+ this.emit(
1112
+ "contextUsage",
1113
+ remoteWire.contextUsage(tokens, max),
1114
+ agent.sessionId,
1115
+ );
781
1116
  }
782
1117
  }
783
1118
 
@@ -792,7 +1127,7 @@ export class AgentBridge {
792
1127
  const tokens = agent.latestTotalTokens;
793
1128
  const max = agent.getMaxInputTokens();
794
1129
  if (tokens > 0 && max > 0) {
795
- return Math.min(100, Math.round((tokens / max) * 100));
1130
+ return percentOf(tokens, max);
796
1131
  }
797
1132
  return undefined;
798
1133
  }
@@ -841,6 +1176,50 @@ export class AgentBridge {
841
1176
 
842
1177
  // ── Git / worktree ────────────────────────────────────────────
843
1178
 
1179
+ /**
1180
+ * List the subdirectories of a directory on the machine this CLI runs on —
1181
+ * host-agnostic like `listGitBranches`: whichever wave process receives the
1182
+ * request lists its own filesystem. Serves the new-session directory pickers
1183
+ * (the desktop's remote host browser and the Web page now share this one
1184
+ * implementation; the desktop's ssh shell one-liner retires into it).
1185
+ *
1186
+ * `~` / `~/…` expand against the service process user's home. `resolvedPath`
1187
+ * is the realpath so the picker shows where the user actually is (symlinked
1188
+ * parents normalized). Symlinked subdirectories are NOT listed — the shell
1189
+ * `find -type d` this replaces didn't list them either, and the two paths
1190
+ * must stay interchangeable. A missing or unreadable directory is an explicit
1191
+ * error, never an empty list (an empty list would read as "no subdirs" and
1192
+ * strand the user on a path they can't leave).
1193
+ */
1194
+ private listDirectories(dir?: string): {
1195
+ resolvedPath: string;
1196
+ dirs: string[];
1197
+ } {
1198
+ if (!dir) {
1199
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "dir is required");
1200
+ }
1201
+ const expanded =
1202
+ dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
1203
+ let resolvedPath: string;
1204
+ try {
1205
+ resolvedPath = realpathSync(expanded);
1206
+ } catch {
1207
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
1208
+ }
1209
+ let dirs: string[];
1210
+ try {
1211
+ dirs = readdirSync(resolvedPath, { withFileTypes: true })
1212
+ .filter((e) => e.isDirectory())
1213
+ .map((e) => e.name)
1214
+ .sort((a, b) => a.localeCompare(b));
1215
+ } catch {
1216
+ // realpath succeeded (the path exists) but reading it didn't — same
1217
+ // verdict as missing: the picker cannot descend into either.
1218
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
1219
+ }
1220
+ return { resolvedPath, dirs };
1221
+ }
1222
+
844
1223
  private listGitBranches(workdir?: string): {
845
1224
  branches: string[];
846
1225
  current: string | null;
@@ -887,6 +1266,122 @@ export class AgentBridge {
887
1266
  return { branches, current };
888
1267
  }
889
1268
 
1269
+ /**
1270
+ * Read-only workspace diff for the desktop diff panel. Runs wherever this
1271
+ * CLI runs, so a remote session answers it inside the remote daemon instead
1272
+ * of the desktop shelling out over ssh per git command.
1273
+ *
1274
+ * `scope` selects the viewing range: omitted / "all" = the fork-point base,
1275
+ * "uncommitted" = `HEAD` (the panel's "未提交改动"). A `commit` outranks it.
1276
+ */
1277
+ private getWorkspaceDiff(workdir?: string, commit?: string, scope?: string) {
1278
+ if (!workdir) {
1279
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
1280
+ }
1281
+ return getWorkspaceDiff(workdir, {
1282
+ ...(commit ? { commit } : {}),
1283
+ ...(scope === "uncommitted" ? { uncommitted: true } : {}),
1284
+ });
1285
+ }
1286
+
1287
+ /**
1288
+ * One inline (ghost-text) completion for the editor plugins.
1289
+ *
1290
+ * Session-scoped on purpose: the completion model and the gateway credentials
1291
+ * are resolved inside that session (the CLI owns the model configuration —
1292
+ * the plugin never holds a second source of truth). Read-only, so it keeps
1293
+ * working while `updateConfig` is rebuilding the session.
1294
+ */
1295
+ private async getCompletion(
1296
+ params: {
1297
+ prefix?: string;
1298
+ suffix?: string;
1299
+ language?: string;
1300
+ filePath?: string;
1301
+ snippets?: CompletionSnippet[];
1302
+ },
1303
+ sessionId?: string,
1304
+ ): Promise<{ text: string; model: string }> {
1305
+ const entry = this.requireSessionForRead(sessionId);
1306
+ const key = sessionId as string;
1307
+
1308
+ // Supersede the session's previous request: its answer would land at a
1309
+ // cursor position that no longer exists. A next-edit suggestion in flight is
1310
+ // dropped too — both rewrite the same buffer (spec ui/next-edit-suggestion.md).
1311
+ this.inFlightCompletions.get(key)?.abort();
1312
+ this.inFlightNextEdits.get(key)?.abort();
1313
+ const controller = new AbortController();
1314
+ this.inFlightCompletions.set(key, controller);
1315
+
1316
+ try {
1317
+ return await entry.agent.getCompletion({
1318
+ prefix: params.prefix ?? "",
1319
+ suffix: params.suffix ?? "",
1320
+ ...(params.language ? { language: params.language } : {}),
1321
+ ...(params.filePath ? { filePath: params.filePath } : {}),
1322
+ // `undefined` (host cannot collect snippets) and `[]` (tried, found
1323
+ // nothing) stay distinguishable here and both mean "no snippets" to
1324
+ // the model; only an absent field is left out of the call.
1325
+ ...(params.snippets ? { snippets: params.snippets } : {}),
1326
+ abortSignal: controller.signal,
1327
+ });
1328
+ } finally {
1329
+ // Only clear our own slot — a newer request may already own it.
1330
+ if (this.inFlightCompletions.get(key) === controller) {
1331
+ this.inFlightCompletions.delete(key);
1332
+ }
1333
+ }
1334
+ }
1335
+
1336
+ /**
1337
+ * One next-edit suggestion for the editor plugins (spec
1338
+ * ui/next-edit-suggestion.md).
1339
+ *
1340
+ * Session-scoped for the same reason as completion: the model, the switch and
1341
+ * the credentials all live in that session. Read-only, so a suggestion still
1342
+ * resolves while `updateConfig` rebuilds the session.
1343
+ */
1344
+ private async getNextEdit(
1345
+ params: {
1346
+ edits?: NextEditHistoryEntry[];
1347
+ region?: NextEditRegion;
1348
+ excerpt?: NextEditExcerpt;
1349
+ language?: string;
1350
+ filePath?: string;
1351
+ },
1352
+ sessionId?: string,
1353
+ ): Promise<{ suggestion: NextEditSuggestion | null; model: string }> {
1354
+ const entry = this.requireSessionForRead(sessionId);
1355
+ const key = sessionId as string;
1356
+
1357
+ // A region and its excerpt are the whole basis of the answer; without them
1358
+ // there is nothing to predict into, so this is a no-op rather than an error.
1359
+ if (!params.region || !params.excerpt) {
1360
+ return { suggestion: null, model: "" };
1361
+ }
1362
+
1363
+ // Same exclusion as the completion path, from this side.
1364
+ this.inFlightNextEdits.get(key)?.abort();
1365
+ this.inFlightCompletions.get(key)?.abort();
1366
+ const controller = new AbortController();
1367
+ this.inFlightNextEdits.set(key, controller);
1368
+
1369
+ try {
1370
+ return await entry.agent.getNextEdit({
1371
+ edits: params.edits ?? [],
1372
+ region: params.region,
1373
+ excerpt: params.excerpt,
1374
+ ...(params.language ? { language: params.language } : {}),
1375
+ ...(params.filePath ? { filePath: params.filePath } : {}),
1376
+ abortSignal: controller.signal,
1377
+ });
1378
+ } finally {
1379
+ if (this.inFlightNextEdits.get(key) === controller) {
1380
+ this.inFlightNextEdits.delete(key);
1381
+ }
1382
+ }
1383
+ }
1384
+
890
1385
  private async createWorktreeSession(params: {
891
1386
  workdir: string;
892
1387
  baseBranch?: string;
@@ -972,23 +1467,45 @@ export class AgentBridge {
972
1467
  return { ok: true };
973
1468
  }
974
1469
 
975
- private getSessionInfo(sessionId?: string): {
1470
+ private async getSessionInfo(sessionId?: string): Promise<{
976
1471
  sessionId: string;
977
- workingDirectory: string;
1472
+ sessionCwd: string;
978
1473
  latestTotalTokens: number;
979
1474
  permissionMode: PermissionMode;
980
1475
  availableTools: string[];
981
- } {
1476
+ tasks: Task[];
1477
+ isCompacting: boolean;
1478
+ isCommandRunning: boolean;
1479
+ queuedMessages: QueuedMessage[];
1480
+ backgroundTasks: BackgroundTaskSummary[];
1481
+ }> {
982
1482
  const entry = this.requireSessionForRead(sessionId);
983
1483
  return {
984
1484
  sessionId: entry.agent.sessionId,
985
- // The recorded creation workdir, not the live agent.workingDirectory:
1485
+ // The recorded creation workdir, not the live agent.currentWorkdir:
986
1486
  // destroy --remove-worktree resolves the session's worktree from this
987
1487
  // value, and it must not drift when the session cd'd out of the worktree.
988
- workingDirectory: entry.createdWorkdir,
1488
+ sessionCwd: entry.createdWorkdir,
989
1489
  latestTotalTokens: entry.agent.latestTotalTokens,
990
1490
  permissionMode: entry.agent.getPermissionMode(),
991
1491
  availableTools: entry.agent.getAvailableToolNames(),
1492
+ // The session-state family the remote page re-reads (spec
1493
+ // remote-control「Web 页作为第五种宿主」场景 26): the todo list —
1494
+ // file-backed in the SDK, hence the await — plus compaction, command
1495
+ // running and the outbound queue, all read off the agent itself so the
1496
+ // reply is the current value, never a transition the page may have
1497
+ // missed.
1498
+ tasks: await entry.agent.getTasks(),
1499
+ isCompacting: entry.agent.isCompacting,
1500
+ isCommandRunning: entry.agent.isCommandRunning,
1501
+ queuedMessages: entry.agent.queuedMessages,
1502
+ // Background tasks join the family for the same reason the todo list is
1503
+ // here: the phone's `/tasks` dialog opens on whatever the machine holds, and
1504
+ // `backgroundTasksChange` only reports *changes* — a task that started
1505
+ // before the page attached would otherwise be invisible until it finished.
1506
+ backgroundTasks: toBackgroundTaskSummaries(
1507
+ entry.agent.getBackgroundTasks(),
1508
+ ),
992
1509
  };
993
1510
  }
994
1511
 
@@ -1039,6 +1556,9 @@ export class AgentBridge {
1039
1556
  // Keep worktree context (permission safety) across recreation, but never
1040
1557
  // re-fire WorktreeCreate — the hook ran at initial creation.
1041
1558
  worktreeName: entry.storedConfig.worktreeName,
1559
+ // The declaration outlives the rebuild: an `updateConfig` replaces the
1560
+ // agent, not the host that asked for the session.
1561
+ entrypoint: normalizeEntrypoint(entry.storedConfig.entrypoint),
1042
1562
  canUseTool: (context: ToolPermissionContext) =>
1043
1563
  this.canUseTool(context, ctx),
1044
1564
  };
@@ -1095,7 +1615,7 @@ export class AgentBridge {
1095
1615
  }
1096
1616
 
1097
1617
  private getConfiguredModels(sessionId?: string): {
1098
- models: string[];
1618
+ models: ConfiguredModel[];
1099
1619
  currentModel: string | undefined;
1100
1620
  } {
1101
1621
  const entry = this.requireSessionForRead(sessionId);
@@ -1167,20 +1687,31 @@ export class AgentBridge {
1167
1687
  sessionId?: string,
1168
1688
  ): Promise<null> {
1169
1689
  const entry = this.requireSession(sessionId);
1170
- if (params.force) {
1171
- entry.agent.abortMessage();
1172
- }
1173
1690
  // Save prompt to history (mirrors VSCE chatSession.ts:236-242)
1174
1691
  try {
1175
1692
  await PromptHistoryManager.addEntry(
1176
1693
  params.text,
1177
1694
  entry.agent.sessionId,
1178
1695
  {},
1179
- entry.agent.workingDirectory,
1696
+ entry.agent.currentWorkdir,
1180
1697
  );
1181
1698
  } catch {
1182
1699
  // Best-effort; don't block message sending on history save failure
1183
1700
  }
1701
+ if (params.force) {
1702
+ // "Send now" for one queued item: interrupt the running turn but keep the
1703
+ // rest of the queue — the host removes the picked item separately, and
1704
+ // everything it did not touch must still be delivered afterwards.
1705
+ //
1706
+ // The abort must be immediately followed by `sendMessage` with nothing
1707
+ // awaited in between: the abort resets the loading flag, which is the gate
1708
+ // a queued item waits behind, so any await here lets the *next* queued
1709
+ // message start a turn first — the picked one would then be enqueued at
1710
+ // the tail instead of going out now (realhost smoke: with the history
1711
+ // write above the abort, a `deleteQueuedMessageById` landing in that gap
1712
+ // started the remaining message immediately).
1713
+ entry.agent.abortMessage({ keepQueue: true });
1714
+ }
1184
1715
  await entry.agent.sendMessage(
1185
1716
  params.text,
1186
1717
  this.persistDataUrlImages(params.images),
@@ -1339,14 +1870,74 @@ export class AgentBridge {
1339
1870
  return null;
1340
1871
  }
1341
1872
 
1342
- private getMessages(sessionId?: string): {
1343
- messages: Message[];
1344
- contextUsagePercent?: number;
1345
- } {
1873
+ /**
1874
+ * Read a session's display messages.
1875
+ *
1876
+ * With no parameters this returns the whole list the agent holds, read once
1877
+ * and cached nowhere (spec ui/message-rendering-system.md「首屏就是整份列表,
1878
+ * 不分页」). Two parameters narrow it, both for the remote page:
1879
+ *
1880
+ * - `limit` — the newest `limit` messages (that page's frame budget).
1881
+ * - `since` — everything appended after the given message id (catching up
1882
+ * after a reconnect).
1883
+ *
1884
+ * The list starts at the last compaction, so its earliest entry is the
1885
+ * earliest any UI can reach; there is nothing older to read back.
1886
+ *
1887
+ * `since` is a position, not a version number: message ids are stable, so a
1888
+ * caller can name its last known message without the list carrying a
1889
+ * sequence number. The one failure that matters is the anchor having been
1890
+ * truncated away (rewind / clear) — there is no seq/version in the store that
1891
+ * could detect it otherwise — and that comes back as `reset: true` so the
1892
+ * caller re-pulls instead of guessing.
1893
+ *
1894
+ * Ids can repeat (old transcripts re-appended the preserved rounds at each
1895
+ * compact point), so anchors are resolved to their **last** occurrence:
1896
+ * "after `since`" means after the newest copy the caller could have seen.
1897
+ */
1898
+ private async getMessages(
1899
+ sessionId?: string,
1900
+ window?: MessageWindow,
1901
+ ): Promise<MessageWindowResult> {
1346
1902
  const entry = this.requireSessionForRead(sessionId);
1903
+ const contextUsagePercent = this.contextUsagePercentOf(entry.agent);
1904
+
1905
+ // The validation is shared with the CLI front end (`RemoteControlCliHost`):
1906
+ // the phone's reconciliation rules belong to the wire contract, not to this
1907
+ // registry, and the two hosts must answer identically.
1908
+ let normalized: MessageWindow | undefined;
1909
+ try {
1910
+ normalized = normalizeMessageWindow(window);
1911
+ } catch (err) {
1912
+ if (err instanceof InvalidMessageWindowError) {
1913
+ throw new RpcError(PROTOCOL_INVALID_PARAMS, err.message);
1914
+ }
1915
+ throw err;
1916
+ }
1917
+ // The totals ride along with the window: the list starts at the last
1918
+ // compaction, so this reply is the only place a host can learn the
1919
+ // thread's size and its first user message (the session-title fallback).
1920
+ const threadSummary = entry.agent.getThreadSummary();
1921
+ // Trimmed on the way out, never in the agent's own list: no UI renders tool
1922
+ // pictures, and the agent has to keep them for the next request
1923
+ // (specs/ui/stdio-transport.md「工具图片的字节不下发 UI」). The trim is
1924
+ // copy-on-write, so a message with no picture keeps its identity.
1925
+ if (!normalized) {
1926
+ return {
1927
+ // `Agent.messages` already hands back a copy, so this reply is a
1928
+ // single-pass view for the host that asked once — do not copy again
1929
+ // (a long session makes this O(n) per call).
1930
+ messages: messagesForUi(entry.agent.messages),
1931
+ contextUsagePercent,
1932
+ threadSummary,
1933
+ };
1934
+ }
1935
+ const page = entry.agent.getMessagePage(normalized);
1347
1936
  return {
1348
- messages: entry.agent.displayMessages,
1349
- contextUsagePercent: this.contextUsagePercentOf(entry.agent),
1937
+ ...page,
1938
+ messages: messagesForUi(page.messages),
1939
+ contextUsagePercent,
1940
+ threadSummary,
1350
1941
  };
1351
1942
  }
1352
1943
 
@@ -1355,7 +1946,8 @@ export class AgentBridge {
1355
1946
  sessionIds: string[];
1356
1947
  }> {
1357
1948
  const entry = this.requireSessionForRead(sessionId);
1358
- return entry.agent.getFullMessageThread();
1949
+ const thread = await entry.agent.getFullMessageThread();
1950
+ return { ...thread, messages: messagesForUi(thread.messages) };
1359
1951
  }
1360
1952
 
1361
1953
  private async compact(
@@ -1431,6 +2023,43 @@ export class AgentBridge {
1431
2023
  return { mode: entry.agent.getPermissionMode() };
1432
2024
  }
1433
2025
 
2026
+ /**
2027
+ * Records what the editor host saw happen to the last suggestion —
2028
+ * `displayed` when it rendered the ghost text, `accepted` when the user took
2029
+ * it. The outcome is the only thing the host reports: the model and the
2030
+ * language stay on this side, derived when the completion was produced.
2031
+ */
2032
+ private reportCompletionOutcome(
2033
+ outcome: "displayed" | "accepted",
2034
+ sessionId?: string,
2035
+ ): { ok: true } {
2036
+ const entry = this.requireSessionForRead(sessionId);
2037
+ if (outcome !== "displayed" && outcome !== "accepted") {
2038
+ throw new RpcError(
2039
+ PROTOCOL_INTERNAL_ERROR,
2040
+ "outcome must be 'displayed' or 'accepted'",
2041
+ );
2042
+ }
2043
+ entry.agent.reportCompletionOutcome(outcome);
2044
+ return { ok: true };
2045
+ }
2046
+
2047
+ /** The next-edit twin: same validation, its own metric series in the SDK. */
2048
+ private reportNextEditOutcome(
2049
+ outcome: "displayed" | "accepted",
2050
+ sessionId?: string,
2051
+ ): { ok: true } {
2052
+ const entry = this.requireSessionForRead(sessionId);
2053
+ if (outcome !== "displayed" && outcome !== "accepted") {
2054
+ throw new RpcError(
2055
+ PROTOCOL_INTERNAL_ERROR,
2056
+ "outcome must be 'displayed' or 'accepted'",
2057
+ );
2058
+ }
2059
+ entry.agent.reportNextEditOutcome(outcome);
2060
+ return { ok: true };
2061
+ }
2062
+
1434
2063
  /**
1435
2064
  * Returns the current plan file path and its contents for the session.
1436
2065
  * When the path generation is still in flight (e.g. the host just switched
@@ -1505,9 +2134,28 @@ export class AgentBridge {
1505
2134
 
1506
2135
  // ── Commands ──────────────────────────────────────────────────
1507
2136
 
1508
- private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
2137
+ /**
2138
+ * The popup's rows: the agent's own commands (skills / custom / plugin) plus
2139
+ * the host-local table for the front end that asked.
2140
+ *
2141
+ * `surface` is optional on purpose: a client that predates the field asks
2142
+ * without it and gets exactly what it got before (the SDK half), instead of
2143
+ * being handed rows it has no handling for. Unknown values are dropped the
2144
+ * same way by `parseHostSurface`.
2145
+ */
2146
+ private getSlashCommands(
2147
+ sessionId?: string,
2148
+ surface?: HostSurface,
2149
+ ): { commands: SlashCommandInfo[] } {
1509
2150
  const entry = this.requireSessionForRead(sessionId);
1510
- return { commands: entry.agent.getSlashCommands() };
2151
+ const agentCommands = entry.agent
2152
+ .getSlashCommands()
2153
+ .map((command) => toSlashCommandInfo(command));
2154
+ return {
2155
+ commands: surface
2156
+ ? [...agentCommands, ...hostSlashCommandsFor(surface)]
2157
+ : agentCommands,
2158
+ };
1511
2159
  }
1512
2160
 
1513
2161
  private getSubagentConfigurations(sessionId?: string): {
@@ -1654,7 +2302,7 @@ export class AgentBridge {
1654
2302
  });
1655
2303
  this.emit(
1656
2304
  "permissionRequest",
1657
- { requestId, context },
2305
+ remoteWire.permissionRequest(requestId, context),
1658
2306
  ctx.registeredSessionId,
1659
2307
  );
1660
2308
  });
@@ -1701,31 +2349,142 @@ export class AgentBridge {
1701
2349
  * reading only `isLoading` reports "done" in both cases. They are read from the
1702
2350
  * live agent here rather than tracked separately, and mirror it as-is: the
1703
2351
  * rebuild window above is already covered by `isLoading`, so there is no need
1704
- * to also claim a `transitioning` session owns background work it doesn't. */
2352
+ * to also claim a `transitioning` session owns background work it doesn't.
2353
+ *
2354
+ * The three label fields are additive (an older CLI simply ignores them) and
2355
+ * exist for the remote-control page: it lists **this process's** sessions —
2356
+ * the only ones it can actually open — and cannot read the machine's disk, so
2357
+ * the title material and the ordering key have to come off the live agent
2358
+ * rather than from `listSessions`, which reads jsonl and would offer it
2359
+ * sessions that are not loaded here (spec remote-control「Web 页作为第五种宿主」
2360
+ * 场景 2)。 */
1705
2361
  private listDaemonSessions(): {
1706
2362
  sessions: Array<{
1707
2363
  sessionId: string;
1708
- workingDirectory: string;
2364
+ sessionCwd: string;
1709
2365
  isLoading: boolean;
1710
2366
  hasRunningBackgroundWork: boolean;
1711
2367
  hasPendingMessages: boolean;
1712
2368
  messageCount: number;
2369
+ /** User-set title; wins over `firstMessage` in the UI. */
2370
+ customTitle?: string;
2371
+ /** First real user message — the label when no custom title is set. */
2372
+ firstMessage?: string;
2373
+ /** Last display message's timestamp: the session list's ordering key. */
2374
+ lastActiveAt?: string;
2375
+ /**
2376
+ * The linked git worktree this session was created in, when it was.
2377
+ * Present only for worktree sessions — absent means "not a worktree, or
2378
+ * cannot tell", and the remote page reads it as "deleting this session
2379
+ * will not touch a worktree". Change counts are deliberately NOT here:
2380
+ * they cost git calls per row, and the list is rebuilt on every reconnect
2381
+ * / snapshot / page open. The page asks for them on demand instead, via
2382
+ * `getSessionWorktreeChanges`.
2383
+ */
2384
+ worktree?: {
2385
+ path: string;
2386
+ branch: string;
2387
+ repoRoot: string;
2388
+ hookBased: boolean;
2389
+ };
1713
2390
  }>;
1714
2391
  } {
1715
2392
  return {
1716
- sessions: [...this.sessions.entries()].map(([sessionId, entry]) => ({
1717
- sessionId,
1718
- workingDirectory: entry.createdWorkdir,
1719
- isLoading: entry.transitioning === true || entry.agent.isLoading,
1720
- hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
1721
- hasPendingMessages: entry.agent.hasPendingMessages,
1722
- messageCount: entry.agent.displayMessages.length,
1723
- })),
2393
+ sessions: [...this.sessions.entries()].map(([sessionId, entry]) => {
2394
+ const customTitle = entry.agent.customTitle;
2395
+ // Totals come from the agent's counters, not from `displayMessages`:
2396
+ // that is only the resident tail now, so its length and its first
2397
+ // message are no longer the thread's.
2398
+ const summary = entry.agent.getThreadSummary();
2399
+ const firstMessage = summary.firstUserText;
2400
+ const lastActiveAt = summary.lastActiveAt;
2401
+ // Derived from the creation workdir (not the live one) and fs-only —
2402
+ // see `deriveWorktreeInfo` for why this must not shell out to git.
2403
+ const worktree = deriveWorktreeInfo(entry.createdWorkdir);
2404
+ return {
2405
+ sessionId,
2406
+ sessionCwd: entry.createdWorkdir,
2407
+ isLoading: entry.transitioning === true || entry.agent.isLoading,
2408
+ hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
2409
+ hasPendingMessages: entry.agent.hasPendingMessages,
2410
+ messageCount: summary.messageCount,
2411
+ ...(customTitle ? { customTitle } : {}),
2412
+ ...(firstMessage ? { firstMessage } : {}),
2413
+ ...(lastActiveAt ? { lastActiveAt } : {}),
2414
+ ...(worktree ? { worktree } : {}),
2415
+ };
2416
+ }),
1724
2417
  };
1725
2418
  }
1726
2419
 
1727
- // ── Auth (global) ────────────────────────────────────────────
2420
+ /**
2421
+ * Route the `remoteControl*` RPCs to the daemon's controller.
2422
+ *
2423
+ * `connect` / `disconnect` change the relay session; all three return the
2424
+ * same snapshot so a caller can render the result without a second round
2425
+ * trip (and a caller that only wanted to read gets `remoteControlStatus`).
2426
+ *
2427
+ * `connect` carries the optional label the user gave this machine
2428
+ * (`wave remote-control on <name>`), which the relay hands to the phone so
2429
+ * its computer list can tell two of the user's computers apart.
2430
+ */
2431
+ private remoteControlRequest(
2432
+ method: string,
2433
+ params: Record<string, unknown>,
2434
+ ): RemoteControlSnapshot {
2435
+ const controller = this.remoteControl?.();
2436
+ if (!controller) {
2437
+ throw new RpcError(
2438
+ PROTOCOL_INVALID_PARAMS,
2439
+ "Remote control is not enabled on this process (set enableRemoteControl: true).",
2440
+ );
2441
+ }
2442
+ if (method === "remoteControlConnect") {
2443
+ const name = typeof params.name === "string" ? params.name : undefined;
2444
+ controller.connect(name);
2445
+ } else if (method === "remoteControlDisconnect") {
2446
+ controller.disconnect();
2447
+ }
2448
+ return controller.getSnapshot();
2449
+ }
1728
2450
 
2451
+ /**
2452
+ * Per-session status for the remote-control signal.
2453
+ *
2454
+ * The relay forwards bytes without looking inside them, so it can never
2455
+ * discover that somebody is waiting on an approval — and "push to the phone
2456
+ * when a session is stuck on the user" is the whole point of the feature.
2457
+ * The machine therefore reports one word per session, carrying no content:
2458
+ * ids and a status, nothing else.
2459
+ *
2460
+ * `running` includes background work: read from a phone there is no terminal
2461
+ * to glance at, so "something is still in flight" is the useful meaning of
2462
+ * busy rather than the narrower "a model turn is streaming".
2463
+ */
2464
+ listSessionStatuses(): Array<{
2465
+ sessionId: string;
2466
+ status: "running" | "idle" | "requires_action";
2467
+ }> {
2468
+ const awaiting = new Set<string>();
2469
+ for (const entry of this.pendingPermissions.values()) {
2470
+ if (entry.sessionId) awaiting.add(entry.sessionId);
2471
+ }
2472
+ return [...this.sessions.entries()].map(([sessionId, entry]) => {
2473
+ let status: "running" | "idle" | "requires_action" = "idle";
2474
+ if (awaiting.has(sessionId)) {
2475
+ status = "requires_action";
2476
+ } else if (
2477
+ entry.transitioning === true ||
2478
+ entry.agent.isLoading ||
2479
+ entry.agent.hasRunningBackgroundWork
2480
+ ) {
2481
+ status = "running";
2482
+ }
2483
+ return { sessionId, status };
2484
+ });
2485
+ }
2486
+
2487
+ // ── Auth (global) ────────────────────────────────────────────
1729
2488
  private async getAuthStatus(): Promise<{
1730
2489
  isAuthenticated: boolean;
1731
2490
  user: { id: string; email?: string } | undefined;
@@ -1811,7 +2570,7 @@ export class AgentBridge {
1811
2570
  return { content: await readTextFileSafe(path), path };
1812
2571
  }
1813
2572
  const resolvedWorkdir =
1814
- workdir || (agent?.workingDirectory as string | undefined);
2573
+ workdir || (agent?.currentWorkdir as string | undefined);
1815
2574
  if (!resolvedWorkdir) {
1816
2575
  throw new RpcError(
1817
2576
  PROTOCOL_INVALID_PARAMS,
@@ -1850,7 +2609,7 @@ export class AgentBridge {
1850
2609
  return { ok: true };
1851
2610
  }
1852
2611
  const resolvedWorkdir =
1853
- workdir || (agent?.workingDirectory as string | undefined);
2612
+ workdir || (agent?.currentWorkdir as string | undefined);
1854
2613
  if (!resolvedWorkdir) {
1855
2614
  throw new RpcError(
1856
2615
  PROTOCOL_INVALID_PARAMS,
@@ -2152,7 +2911,7 @@ export class AgentBridge {
2152
2911
  if (msg)
2153
2912
  this.emit(
2154
2913
  "userMessageAdded",
2155
- { message: msg },
2914
+ { message: messageForUi(msg) },
2156
2915
  ctx.registeredSessionId,
2157
2916
  );
2158
2917
  },
@@ -2161,30 +2920,33 @@ export class AgentBridge {
2161
2920
  if (msg)
2162
2921
  this.emit(
2163
2922
  "assistantMessageAdded",
2164
- { message: msg },
2923
+ { message: messageForUi(msg) },
2165
2924
  ctx.registeredSessionId,
2166
2925
  );
2167
2926
  },
2927
+ onAssistantMessageDiscarded: (messageId: string) => {
2928
+ // A streamed attempt died mid-body and was re-issued without streaming,
2929
+ // so its half message is void: tell the host to drop it instead of
2930
+ // leaving a partial answer above the real one (spec: 流式响应正文中途
2931
+ // 断连时降级为非流式重发).
2932
+ this.emit(
2933
+ "assistantMessageDiscarded",
2934
+ { messageId },
2935
+ ctx.registeredSessionId,
2936
+ );
2937
+ },
2168
2938
  onAssistantContentUpdated: (params) => {
2169
2939
  // Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
2170
2940
  this.emit(
2171
2941
  "assistantContentUpdated",
2172
- {
2173
- messageId: params.messageId,
2174
- chunk: params.chunk,
2175
- stage: params.stage,
2176
- },
2942
+ remoteWire.assistantContentUpdated(params),
2177
2943
  ctx.registeredSessionId,
2178
2944
  );
2179
2945
  },
2180
2946
  onAssistantReasoningUpdated: (params) => {
2181
2947
  this.emit(
2182
2948
  "assistantReasoningUpdated",
2183
- {
2184
- messageId: params.messageId,
2185
- chunk: params.chunk,
2186
- stage: params.stage,
2187
- },
2949
+ remoteWire.assistantReasoningUpdated(params),
2188
2950
  ctx.registeredSessionId,
2189
2951
  );
2190
2952
  },
@@ -2192,21 +2954,23 @@ export class AgentBridge {
2192
2954
  // Streaming stages carry only the parametersChunk delta; start/running
2193
2955
  // (one-time snapshots) and end (authoritative full value) keep
2194
2956
  // `parameters` (spec: 流式通知纯增量负载).
2195
- const { parameters, ...rest } = params;
2196
- void parameters;
2197
- const wireParams = params.stage === "streaming" ? rest : params;
2198
- this.emit("toolBlockUpdated", wireParams, ctx.registeredSessionId);
2957
+ this.emit(
2958
+ "toolBlockUpdated",
2959
+ remoteWire.toolBlockUpdated(params),
2960
+ ctx.registeredSessionId,
2961
+ );
2199
2962
  },
2200
2963
  onErrorBlockAdded: (error: string) => {
2201
- this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
2964
+ this.emit(
2965
+ "errorBlockAdded",
2966
+ remoteWire.errorBlockAdded(error),
2967
+ ctx.registeredSessionId,
2968
+ );
2202
2969
  },
2203
2970
  onLoadingChange: (loading: boolean) => {
2204
2971
  this.emit(
2205
2972
  "loadingChange",
2206
- {
2207
- loading,
2208
- latestTotalTokens: ctx.agent?.latestTotalTokens,
2209
- },
2973
+ remoteWire.loadingChange(loading, ctx.agent?.latestTotalTokens),
2210
2974
  ctx.registeredSessionId,
2211
2975
  );
2212
2976
  },
@@ -2218,8 +2982,11 @@ export class AgentBridge {
2218
2982
  // aligned with the CLI, which reads the same callback (spec 场景 5/6).
2219
2983
  const max = ctx.agent?.getMaxInputTokens() ?? 0;
2220
2984
  if (max > 0) {
2221
- const percent = Math.min(100, Math.round((tokens / max) * 100));
2222
- this.emit("contextUsage", { percent }, ctx.registeredSessionId);
2985
+ this.emit(
2986
+ "contextUsage",
2987
+ remoteWire.contextUsage(tokens, max),
2988
+ ctx.registeredSessionId,
2989
+ );
2223
2990
  }
2224
2991
  },
2225
2992
  onCommandRunningChange: (running: boolean) => {
@@ -2236,21 +3003,9 @@ export class AgentBridge {
2236
3003
  this.emit("tasksChange", { tasks }, ctx.registeredSessionId);
2237
3004
  },
2238
3005
  onBackgroundTasksChange: (tasks: BackgroundTask[]) => {
2239
- const summaries: BackgroundTaskSummary[] = tasks.map((t) => ({
2240
- id: t.id,
2241
- type: t.type,
2242
- status: t.status,
2243
- startTime: t.startTime,
2244
- endTime: t.endTime,
2245
- command: t.command,
2246
- description: t.description,
2247
- exitCode: t.exitCode,
2248
- runtime: t.runtime,
2249
- outputPath: t.outputPath,
2250
- }));
2251
3006
  this.emit(
2252
3007
  "backgroundTasksChange",
2253
- { tasks: summaries },
3008
+ { tasks: toBackgroundTaskSummaries(tasks) },
2254
3009
  ctx.registeredSessionId,
2255
3010
  );
2256
3011
  },
@@ -2269,7 +3024,11 @@ export class AgentBridge {
2269
3024
  ctx.registeredSessionId = newSessionId;
2270
3025
  },
2271
3026
  onPermissionModeChange: (mode: PermissionMode) => {
2272
- this.emit("permissionModeChange", { mode }, ctx.registeredSessionId);
3027
+ this.emit(
3028
+ "permissionModeChange",
3029
+ remoteWire.permissionModeChange(mode),
3030
+ ctx.registeredSessionId,
3031
+ );
2273
3032
  },
2274
3033
  onWorkdirChange: (newCwd: string) => {
2275
3034
  this.emit(
@@ -2282,7 +3041,11 @@ export class AgentBridge {
2282
3041
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
2283
3042
  },
2284
3043
  onNotificationMessageAdded: (params) => {
2285
- const msg = ctx.agent?.displayMessages.find(
3044
+ // The LAST matching message, not the first: one task can notify more
3045
+ // than once under the same task id (a workflow warns about its size
3046
+ // mid-run, then notifies again when it finishes), and this callback
3047
+ // fires for the message that was just appended — the newest one.
3048
+ const matches = (ctx.agent?.messages ?? []).filter(
2286
3049
  (m) =>
2287
3050
  m.role === "user" &&
2288
3051
  m.blocks.some(
@@ -2291,14 +3054,19 @@ export class AgentBridge {
2291
3054
  (b as { taskId: string }).taskId === params.taskId,
2292
3055
  ),
2293
3056
  );
3057
+ const msg = matches[matches.length - 1];
2294
3058
  this.emit(
2295
3059
  "notificationMessageAdded",
2296
- { ...params, message: msg },
3060
+ { ...params, message: msg ? messageForUi(msg) : undefined },
2297
3061
  ctx.registeredSessionId,
2298
3062
  );
2299
3063
  },
2300
- onCompactBlockAdded: (content: string) => {
2301
- this.emit("compactBlockAdded", { content }, ctx.registeredSessionId);
3064
+ onCompactBlockAdded: (content: string, message?: Message) => {
3065
+ this.emit(
3066
+ "compactBlockAdded",
3067
+ { content, message: message ? messageForUi(message) : undefined },
3068
+ ctx.registeredSessionId,
3069
+ );
2302
3070
  },
2303
3071
  onCompactionStateChange: (isCompacting: boolean) => {
2304
3072
  this.emit(
@@ -2373,7 +3141,7 @@ export class AgentBridge {
2373
3141
 
2374
3142
  private getSessionWorkdir(sessionId?: string): string | undefined {
2375
3143
  if (!sessionId) return undefined;
2376
- return this.sessions.get(sessionId)?.agent.workingDirectory;
3144
+ return this.sessions.get(sessionId)?.agent.currentWorkdir;
2377
3145
  }
2378
3146
  }
2379
3147