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
@@ -58,28 +58,54 @@ import {
58
58
  type SubagentConfiguration,
59
59
  type SkillMetadata,
60
60
  type PluginReloadResult,
61
+ type RemoteControlSnapshot,
62
+ normalizeEntrypoint,
61
63
  } from "wave-agent-sdk";
62
64
  import {
65
+ type ClientNotificationMethod,
63
66
  type JsonRpcError,
67
+ type RequestMethod,
68
+ type ServerNotificationMethod,
64
69
  INVALID_PARAMS as PROTOCOL_INVALID_PARAMS,
65
70
  INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
66
71
  METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
67
72
  } from "./protocol.js";
68
73
  import { execFileSync } from "node:child_process";
69
- import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
74
+ import {
75
+ mkdirSync,
76
+ existsSync,
77
+ readFileSync,
78
+ writeFileSync,
79
+ readdirSync,
80
+ realpathSync,
81
+ } from "node:fs";
70
82
  import { mkdir, open, readFile, writeFile } from "node:fs/promises";
71
83
  import { homedir, tmpdir } from "node:os";
72
84
  import { basename, dirname, extname, join } from "node:path";
73
85
  import {
74
86
  createWorktree,
87
+ deriveWorktreeInfo,
75
88
  getWorktreeChanges,
76
89
  removeWorktree,
77
90
  } from "../utils/worktree.js";
78
91
  import { logger } from "../utils/logger.js";
92
+ import {
93
+ InvalidMessageWindowError,
94
+ sliceMessageWindow,
95
+ type MessageWindowResult,
96
+ } from "../remote/messageWindow.js";
97
+ import {
98
+ percentOf,
99
+ remoteWire,
100
+ toBackgroundTaskSummaries,
101
+ } from "../remote/remoteNotifications.js";
79
102
  import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
103
+ import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
104
+ import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
105
+ import type { RemoteControlController } from "../remote/remoteControlService.js";
80
106
 
81
107
  export type NotificationEmitter = (
82
- method: string,
108
+ method: ServerNotificationMethod,
83
109
  params: unknown,
84
110
  sessionId?: string,
85
111
  ) => void;
@@ -91,6 +117,17 @@ export interface AgentBridgeOptions {
91
117
  * down its socket and exits. stdio mode leaves it unset, so `shutdown` is
92
118
  * "Method not found" there. */
93
119
  onShutdownRequest?: () => void;
120
+ /** Daemon transport only. Remote control is a daemon-level concern (it owns
121
+ * an outbound socket and a relay session); the bridge owns nothing here and
122
+ * only routes the three `remoteControl*` RPCs to it, exactly as it routes
123
+ * `shutdown` to `onShutdownRequest`. Unset ⇒ those RPCs report that remote
124
+ * control is not enabled, which is the state on every stdio-mode process.
125
+ *
126
+ * Resolved on demand rather than passed as an object: the controller needs
127
+ * this bridge to route the phone's requests back in, so the two are built in
128
+ * a circle. Reading it when an RPC arrives — long after both exist — keeps
129
+ * that circle from having to be broken with a placeholder. */
130
+ remoteControl?: () => RemoteControlController | undefined;
94
131
  }
95
132
 
96
133
  interface InitializeParams {
@@ -110,6 +147,13 @@ interface InitializeParams {
110
147
  mcpServers?: Record<string, McpServerConfig>;
111
148
  worktreeName?: string;
112
149
  isNewWorktree?: boolean;
150
+ /**
151
+ * Which host the session belongs to → the `app.entrypoint` attribute. A raw
152
+ * string: it arrives over the wire, from hosts this process does not compile
153
+ * against (the JetBrains plugin builds its own frames). Narrowed to the known
154
+ * table below before it reaches the SDK.
155
+ */
156
+ entrypoint?: string;
113
157
  }
114
158
 
115
159
  // 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
@@ -189,14 +233,26 @@ export class AgentBridge {
189
233
  }
190
234
  >();
191
235
  private permissionCounter = 0;
236
+ /**
237
+ * In-flight completion calls, keyed by envelope sessionId. Completion answers
238
+ * a keystroke pause, so a newer request makes the previous one worthless —
239
+ * superseding it here is what keeps "one in-flight request per editor" true
240
+ * even when the host cannot cancel (spec
241
+ * docs/specs/ui/code-completion.md: 同一时刻只允许一个补全请求在途).
242
+ */
243
+ private inFlightCompletions = new Map<string, AbortController>();
192
244
  private emit: NotificationEmitter;
193
245
  private onShutdownRequest: (() => void) | undefined;
246
+ private remoteControl:
247
+ | (() => RemoteControlController | undefined)
248
+ | undefined;
194
249
  private pluginCore: PluginCore | undefined;
195
250
  private pluginCoreWorkdir: string | undefined;
196
251
 
197
252
  constructor(options: AgentBridgeOptions) {
198
253
  this.emit = options.emit;
199
254
  this.onShutdownRequest = options.onShutdownRequest;
255
+ this.remoteControl = options.remoteControl;
200
256
  // Mirror the user-level settings env WAVE_SERVER_URL into process.env
201
257
  // before any agent initializes. getAuthStatus (webviewReady →
202
258
  // pushInitialState) can run before the first agent, and AuthService falls
@@ -211,7 +267,7 @@ export class AgentBridge {
211
267
  // ── Public API ────────────────────────────────────────────────
212
268
 
213
269
  async handleRequest(
214
- method: string,
270
+ method: RequestMethod,
215
271
  params: unknown,
216
272
  sessionId?: string,
217
273
  ): Promise<unknown> {
@@ -221,7 +277,9 @@ export class AgentBridge {
221
277
  case "initialize":
222
278
  return this.initialize(p as unknown as InitializeParams);
223
279
  case "destroy":
224
- return this.destroy(sessionId);
280
+ return this.destroy(sessionId, {
281
+ removeWorktree: p.removeWorktree === true,
282
+ });
225
283
  case "restoreSession":
226
284
  return this.restoreSession(p.sessionId as string, sessionId);
227
285
  case "listSessions":
@@ -311,10 +369,21 @@ export class AgentBridge {
311
369
  case "deleteQueuedMessageById":
312
370
  return this.deleteQueuedMessageById(p.id as string, sessionId);
313
371
  case "getMessages":
314
- return this.getMessages(sessionId);
372
+ return this.getMessages(sessionId, {
373
+ before: p.before as string | undefined,
374
+ since: p.since as string | undefined,
375
+ limit: p.limit as number | undefined,
376
+ });
315
377
  case "getFullMessageThread":
316
378
  return this.getFullMessageThread(sessionId);
317
379
 
380
+ // ── Remote control (daemon-level) ──
381
+ // The daemon owns the outbound relay socket; the bridge only routes.
382
+ case "remoteControlStatus":
383
+ case "remoteControlConnect":
384
+ case "remoteControlDisconnect":
385
+ return this.remoteControlRequest(method, p);
386
+
318
387
  // ── Permissions ──
319
388
  case "setPermissionMode":
320
389
  return this.setPermissionMode(p.mode as PermissionMode, sessionId);
@@ -390,6 +459,8 @@ export class AgentBridge {
390
459
  return this.login(p.serverUrl as string | undefined);
391
460
  case "logout":
392
461
  return this.logout();
462
+ case "installGitCredentialHelper":
463
+ return installGitCredentialHelper();
393
464
 
394
465
  // ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
395
466
  // project-level <workdir>/AGENTS.md) ──
@@ -527,7 +598,9 @@ export class AgentBridge {
527
598
  case "stopWorkflowRun":
528
599
  return this.stopWorkflowRun(p.runId as string, sessionId);
529
600
 
530
- // ── Git / worktree (global — no session required) ──
601
+ // ── Filesystem / Git / worktree (global — no session required) ──
602
+ case "listDirectories":
603
+ return this.listDirectories(p.dir as string | undefined);
531
604
  case "listGitBranches":
532
605
  return this.listGitBranches(p.workdir as string | undefined);
533
606
  case "createWorktree":
@@ -542,6 +615,8 @@ export class AgentBridge {
542
615
  return this.getWorktreeChanges(
543
616
  p as unknown as { path?: string; baseBranch?: string },
544
617
  );
618
+ case "getSessionWorktreeChanges":
619
+ return this.getSessionWorktreeChanges(sessionId);
545
620
  case "removeWorktree":
546
621
  return this.removeWorktreeSession(
547
622
  p as unknown as {
@@ -551,16 +626,42 @@ export class AgentBridge {
551
626
  hookBased?: boolean;
552
627
  },
553
628
  );
629
+ case "getWorkspaceDiff":
630
+ return this.getWorkspaceDiff(
631
+ p.workdir as string | undefined,
632
+ p.commit as string | undefined,
633
+ p.scope as string | undefined,
634
+ );
635
+ case "getCompletion":
636
+ return this.getCompletion(
637
+ p as unknown as {
638
+ prefix?: string;
639
+ suffix?: string;
640
+ language?: string;
641
+ filePath?: string;
642
+ },
643
+ sessionId,
644
+ );
554
645
 
555
- default:
646
+ default: {
647
+ // Type-level exhaustiveness, both directions: a union member without a
648
+ // `case` lands here as a non-`never` and fails to compile, and a `case`
649
+ // naming something outside the union fails to compile at the `case`
650
+ // itself. Runtime behaviour is unchanged — `method` can still be an
651
+ // arbitrary string once the transport boundary cast it.
652
+ const _exhaustive: never = method;
653
+ // Kept referenced so the witness isn't an unused-variable lint error;
654
+ // it is never read at runtime.
655
+ void _exhaustive;
556
656
  throw new RpcError(
557
657
  PROTOCOL_METHOD_NOT_FOUND,
558
658
  `Method not found: ${method}`,
559
659
  );
660
+ }
560
661
  }
561
662
  }
562
663
 
563
- handleNotification(method: string, params: unknown): void {
664
+ handleNotification(method: ClientNotificationMethod, params: unknown): void {
564
665
  if (method === "permissionResponse") {
565
666
  const p = params as {
566
667
  requestId: string;
@@ -601,6 +702,16 @@ export class AgentBridge {
601
702
  const ctx: SessionContext = {};
602
703
  const callbacks = this.createCallbacks(ctx);
603
704
 
705
+ const entrypoint = normalizeEntrypoint(params.entrypoint);
706
+ if (params.entrypoint !== undefined && entrypoint === undefined) {
707
+ // A host that ships a value this version does not know (an older client,
708
+ // a hand-written frame) is treated as if it had declared nothing: an
709
+ // unrecognised value must not mint a series of its own.
710
+ logger?.warn(
711
+ `Unknown entrypoint: ${String(params.entrypoint)}, ignoring`,
712
+ );
713
+ }
714
+
604
715
  const options: AgentOptions = {
605
716
  callbacks,
606
717
  logger,
@@ -619,6 +730,7 @@ export class AgentBridge {
619
730
  mcpServers: params.mcpServers,
620
731
  worktreeName: params.worktreeName,
621
732
  isNewWorktree: params.isNewWorktree,
733
+ entrypoint,
622
734
  canUseTool: (context: ToolPermissionContext) =>
623
735
  this.canUseTool(context, ctx),
624
736
  };
@@ -660,7 +772,42 @@ export class AgentBridge {
660
772
  };
661
773
  }
662
774
 
663
- private async destroy(sessionId?: string): Promise<null> {
775
+ /**
776
+ * Destroy one hosted session (idempotent: an unknown id is a no-op).
777
+ *
778
+ * `params.removeWorktree` (optional, default false) additionally removes the
779
+ * git worktree the session was **created** in — the intent the remote page
780
+ * sends when the user confirms 「删除会话与工作树」. Two rules make it safe to
781
+ * offer over the remote channel, where the caller must never be able to name a
782
+ * path:
783
+ *
784
+ * 1. The worktree is resolved here, from `entry.createdWorkdir` — the same
785
+ * anchor `wave daemon destroy --remove-worktree` resolves from (and for the
786
+ * same reason: an in-session `cd` must not move the target). Nothing in the
787
+ * request is trusted as a path, which is why the raw `removeWorktree`
788
+ * method stays off the remote whitelist.
789
+ * 2. It runs **after** `agent.destroy()` resolves. The agent's cwd is the
790
+ * worktree path and destroy flushes the session file (resolving that path
791
+ * via realpath); deleting the directory first makes the save fail ENOENT
792
+ * and drops the trailing messages — the desktop host carries the identical
793
+ * ordering note in `handleDeleteSession`.
794
+ *
795
+ * "Cannot resolve a worktree" is never a reason to fail the delete: the
796
+ * session is still destroyed and the reply says `worktreeRemoved: false`. That
797
+ * covers a plain (non-worktree) session, a worktree that is already gone, and
798
+ * a removal that threw — the caller only ever needs to know whether the
799
+ * directory is gone.
800
+ *
801
+ * The return shape grew from `null` to `{worktreeRemoved}`; callers that pass
802
+ * no params (the CLI's own `wave daemon destroy`) read nothing and are
803
+ * unaffected. The CLI's `--remove-worktree` keeps its own client-side path
804
+ * (getSessionInfo + removeWorktree + destroy) — deliberately unchanged.
805
+ */
806
+ private async destroy(
807
+ sessionId?: string,
808
+ params?: { removeWorktree?: boolean },
809
+ ): Promise<{ worktreeRemoved: boolean }> {
810
+ let createdWorkdir: string | undefined;
664
811
  if (sessionId) {
665
812
  const entry = this.sessions.get(sessionId);
666
813
  if (entry) {
@@ -687,10 +834,71 @@ export class AgentBridge {
687
834
  // half-destroyed agent listed (destroyAll() below clears the map first
688
835
  // for the same reason).
689
836
  this.sessions.delete(sessionId);
837
+ createdWorkdir = entry.createdWorkdir;
690
838
  await entry.agent.destroy();
691
839
  }
692
840
  }
693
- return null;
841
+
842
+ let worktreeRemoved = false;
843
+ if (params?.removeWorktree === true && createdWorkdir) {
844
+ worktreeRemoved = await this.removeSessionWorktree(createdWorkdir);
845
+ }
846
+ return { worktreeRemoved };
847
+ }
848
+
849
+ /**
850
+ * Remove the linked worktree a session was created in. Returns whether the
851
+ * directory is actually gone (not whether we asked): `removeWorktree` is
852
+ * best-effort and leaves the directory behind when git and the fs fallback
853
+ * both fail, and for a hook-based worktree with no WorktreeRemove hook it
854
+ * deliberately leaves it in place. Never throws — the caller has already
855
+ * deleted the session.
856
+ */
857
+ private async removeSessionWorktree(sessionDir: string): Promise<boolean> {
858
+ const worktree = deriveWorktreeInfo(sessionDir);
859
+ if (!worktree) return false;
860
+ try {
861
+ await removeWorktree({
862
+ name: "",
863
+ path: worktree.path,
864
+ branch: worktree.branch,
865
+ repoRoot: worktree.repoRoot,
866
+ hasUncommittedChanges: false,
867
+ hasNewCommits: false,
868
+ isNew: false,
869
+ hookBased: worktree.hookBased,
870
+ });
871
+ } catch (e) {
872
+ logger.warn(
873
+ `removeSessionWorktree: failed to remove ${worktree.path}: ${(e as Error).message}`,
874
+ );
875
+ return false;
876
+ }
877
+ return !existsSync(worktree.path);
878
+ }
879
+
880
+ /**
881
+ * What deleting a session's worktree would throw away (uncommitted files and
882
+ * commits not on the base branch) — the counts the delete confirmation names
883
+ * on the remote page.
884
+ *
885
+ * Session-scoped on purpose: the path comes from the session's own
886
+ * `createdWorkdir`, never from the caller. The remote channel carries this
887
+ * instead of the path-taking `getWorktreeChanges`, for the same reason it
888
+ * carries `destroy {removeWorktree}` instead of `removeWorktree` — a phone may
889
+ * only name a session it was given, not an arbitrary directory on the machine.
890
+ *
891
+ * `null` means "could not inspect" (not a worktree, already deleted, git
892
+ * failed), which the page renders as the generic warning rather than as
893
+ * "clean".
894
+ */
895
+ private async getSessionWorktreeChanges(
896
+ sessionId?: string,
897
+ ): Promise<{ files: number; commits: number } | null> {
898
+ const entry = this.requireSessionForRead(sessionId);
899
+ const worktree = deriveWorktreeInfo(entry.createdWorkdir);
900
+ if (!worktree) return null;
901
+ return getWorktreeChanges(worktree.path);
694
902
  }
695
903
 
696
904
  /**
@@ -713,13 +921,23 @@ export class AgentBridge {
713
921
  ): Promise<null> {
714
922
  const entry = this.requireSessionForRead(sessionId);
715
923
  // 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.
924
+ // already current). Re-send the state the freshly attached client missed —
925
+ // its router registered only after initialize returned.
926
+ //
927
+ // `messagesChange` carries **no payload here, only the fact that the list
928
+ // changed**. Nobody ever read a full snapshot from it: the remote relay
929
+ // needs the emission to produce the phone's signal and then the phone pulls
930
+ // `getMessages({ since })` itself, while every IDE host ignores the method
931
+ // and calls `getMessages` after restore. So building one — a multi-MB list
932
+ // on a long session, serialized on a hot re-attach path — was pure cost, and
933
+ // this is why the bridge sends the signal shape instead.
934
+ //
935
+ // This is also the ONLY place the bridge emits `messagesChange`, and only on
936
+ // this re-attach branch — never as the message stream progresses.
719
937
  if (entry.agent.sessionId === restoreId) {
720
938
  this.emit(
721
939
  "messagesChange",
722
- { messages: entry.agent.displayMessages },
940
+ remoteWire.messagesChangeSignal(),
723
941
  entry.agent.sessionId,
724
942
  );
725
943
  // The re-attached client also missed the loading state that settled
@@ -775,9 +993,14 @@ export class AgentBridge {
775
993
  * usage to report; the host keeps the empty ring until the first push.
776
994
  */
777
995
  private emitContextUsage(agent: Agent): void {
778
- const percent = this.contextUsagePercentOf(agent);
779
- if (percent !== undefined) {
780
- this.emit("contextUsage", { percent }, agent.sessionId);
996
+ const tokens = agent.latestTotalTokens;
997
+ const max = agent.getMaxInputTokens();
998
+ if (tokens > 0 && max > 0) {
999
+ this.emit(
1000
+ "contextUsage",
1001
+ remoteWire.contextUsage(tokens, max),
1002
+ agent.sessionId,
1003
+ );
781
1004
  }
782
1005
  }
783
1006
 
@@ -792,7 +1015,7 @@ export class AgentBridge {
792
1015
  const tokens = agent.latestTotalTokens;
793
1016
  const max = agent.getMaxInputTokens();
794
1017
  if (tokens > 0 && max > 0) {
795
- return Math.min(100, Math.round((tokens / max) * 100));
1018
+ return percentOf(tokens, max);
796
1019
  }
797
1020
  return undefined;
798
1021
  }
@@ -841,6 +1064,50 @@ export class AgentBridge {
841
1064
 
842
1065
  // ── Git / worktree ────────────────────────────────────────────
843
1066
 
1067
+ /**
1068
+ * List the subdirectories of a directory on the machine this CLI runs on —
1069
+ * host-agnostic like `listGitBranches`: whichever wave process receives the
1070
+ * request lists its own filesystem. Serves the new-session directory pickers
1071
+ * (the desktop's remote host browser and the Web page now share this one
1072
+ * implementation; the desktop's ssh shell one-liner retires into it).
1073
+ *
1074
+ * `~` / `~/…` expand against the service process user's home. `resolvedPath`
1075
+ * is the realpath so the picker shows where the user actually is (symlinked
1076
+ * parents normalized). Symlinked subdirectories are NOT listed — the shell
1077
+ * `find -type d` this replaces didn't list them either, and the two paths
1078
+ * must stay interchangeable. A missing or unreadable directory is an explicit
1079
+ * error, never an empty list (an empty list would read as "no subdirs" and
1080
+ * strand the user on a path they can't leave).
1081
+ */
1082
+ private listDirectories(dir?: string): {
1083
+ resolvedPath: string;
1084
+ dirs: string[];
1085
+ } {
1086
+ if (!dir) {
1087
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "dir is required");
1088
+ }
1089
+ const expanded =
1090
+ dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
1091
+ let resolvedPath: string;
1092
+ try {
1093
+ resolvedPath = realpathSync(expanded);
1094
+ } catch {
1095
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
1096
+ }
1097
+ let dirs: string[];
1098
+ try {
1099
+ dirs = readdirSync(resolvedPath, { withFileTypes: true })
1100
+ .filter((e) => e.isDirectory())
1101
+ .map((e) => e.name)
1102
+ .sort((a, b) => a.localeCompare(b));
1103
+ } catch {
1104
+ // realpath succeeded (the path exists) but reading it didn't — same
1105
+ // verdict as missing: the picker cannot descend into either.
1106
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
1107
+ }
1108
+ return { resolvedPath, dirs };
1109
+ }
1110
+
844
1111
  private listGitBranches(workdir?: string): {
845
1112
  branches: string[];
846
1113
  current: string | null;
@@ -887,6 +1154,66 @@ export class AgentBridge {
887
1154
  return { branches, current };
888
1155
  }
889
1156
 
1157
+ /**
1158
+ * Read-only workspace diff for the desktop diff panel. Runs wherever this
1159
+ * CLI runs, so a remote session answers it inside the remote daemon instead
1160
+ * of the desktop shelling out over ssh per git command.
1161
+ *
1162
+ * `scope` selects the viewing range: omitted / "all" = the fork-point base,
1163
+ * "uncommitted" = `HEAD` (the panel's "未提交改动"). A `commit` outranks it.
1164
+ */
1165
+ private getWorkspaceDiff(workdir?: string, commit?: string, scope?: string) {
1166
+ if (!workdir) {
1167
+ throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
1168
+ }
1169
+ return getWorkspaceDiff(workdir, {
1170
+ ...(commit ? { commit } : {}),
1171
+ ...(scope === "uncommitted" ? { uncommitted: true } : {}),
1172
+ });
1173
+ }
1174
+
1175
+ /**
1176
+ * One inline (ghost-text) completion for the editor plugins.
1177
+ *
1178
+ * Session-scoped on purpose: the completion model and the gateway credentials
1179
+ * are resolved inside that session (the CLI owns the model configuration —
1180
+ * the plugin never holds a second source of truth). Read-only, so it keeps
1181
+ * working while `updateConfig` is rebuilding the session.
1182
+ */
1183
+ private async getCompletion(
1184
+ params: {
1185
+ prefix?: string;
1186
+ suffix?: string;
1187
+ language?: string;
1188
+ filePath?: string;
1189
+ },
1190
+ sessionId?: string,
1191
+ ): Promise<{ text: string; model: string }> {
1192
+ const entry = this.requireSessionForRead(sessionId);
1193
+ const key = sessionId as string;
1194
+
1195
+ // Supersede the session's previous request: its answer would land at a
1196
+ // cursor position that no longer exists.
1197
+ this.inFlightCompletions.get(key)?.abort();
1198
+ const controller = new AbortController();
1199
+ this.inFlightCompletions.set(key, controller);
1200
+
1201
+ try {
1202
+ return await entry.agent.getCompletion({
1203
+ prefix: params.prefix ?? "",
1204
+ suffix: params.suffix ?? "",
1205
+ ...(params.language ? { language: params.language } : {}),
1206
+ ...(params.filePath ? { filePath: params.filePath } : {}),
1207
+ abortSignal: controller.signal,
1208
+ });
1209
+ } finally {
1210
+ // Only clear our own slot — a newer request may already own it.
1211
+ if (this.inFlightCompletions.get(key) === controller) {
1212
+ this.inFlightCompletions.delete(key);
1213
+ }
1214
+ }
1215
+ }
1216
+
890
1217
  private async createWorktreeSession(params: {
891
1218
  workdir: string;
892
1219
  baseBranch?: string;
@@ -972,13 +1299,18 @@ export class AgentBridge {
972
1299
  return { ok: true };
973
1300
  }
974
1301
 
975
- private getSessionInfo(sessionId?: string): {
1302
+ private async getSessionInfo(sessionId?: string): Promise<{
976
1303
  sessionId: string;
977
1304
  workingDirectory: string;
978
1305
  latestTotalTokens: number;
979
1306
  permissionMode: PermissionMode;
980
1307
  availableTools: string[];
981
- } {
1308
+ tasks: Task[];
1309
+ isCompacting: boolean;
1310
+ isCommandRunning: boolean;
1311
+ queuedMessages: QueuedMessage[];
1312
+ backgroundTasks: BackgroundTaskSummary[];
1313
+ }> {
982
1314
  const entry = this.requireSessionForRead(sessionId);
983
1315
  return {
984
1316
  sessionId: entry.agent.sessionId,
@@ -989,6 +1321,23 @@ export class AgentBridge {
989
1321
  latestTotalTokens: entry.agent.latestTotalTokens,
990
1322
  permissionMode: entry.agent.getPermissionMode(),
991
1323
  availableTools: entry.agent.getAvailableToolNames(),
1324
+ // The session-state family the remote page re-reads (spec
1325
+ // remote-control「Web 页作为第五种宿主」场景 26): the todo list —
1326
+ // file-backed in the SDK, hence the await — plus compaction, command
1327
+ // running and the outbound queue, all read off the agent itself so the
1328
+ // reply is the current value, never a transition the page may have
1329
+ // missed.
1330
+ tasks: await entry.agent.getTasks(),
1331
+ isCompacting: entry.agent.isCompacting,
1332
+ isCommandRunning: entry.agent.isCommandRunning,
1333
+ queuedMessages: entry.agent.queuedMessages,
1334
+ // Background tasks join the family for the same reason the todo list is
1335
+ // here: the phone's `/tasks` dialog opens on whatever the machine holds, and
1336
+ // `backgroundTasksChange` only reports *changes* — a task that started
1337
+ // before the page attached would otherwise be invisible until it finished.
1338
+ backgroundTasks: toBackgroundTaskSummaries(
1339
+ entry.agent.getBackgroundTasks(),
1340
+ ),
992
1341
  };
993
1342
  }
994
1343
 
@@ -1039,6 +1388,9 @@ export class AgentBridge {
1039
1388
  // Keep worktree context (permission safety) across recreation, but never
1040
1389
  // re-fire WorktreeCreate — the hook ran at initial creation.
1041
1390
  worktreeName: entry.storedConfig.worktreeName,
1391
+ // The declaration outlives the rebuild: an `updateConfig` replaces the
1392
+ // agent, not the host that asked for the session.
1393
+ entrypoint: normalizeEntrypoint(entry.storedConfig.entrypoint),
1042
1394
  canUseTool: (context: ToolPermissionContext) =>
1043
1395
  this.canUseTool(context, ctx),
1044
1396
  };
@@ -1339,15 +1691,64 @@ export class AgentBridge {
1339
1691
  return null;
1340
1692
  }
1341
1693
 
1342
- private getMessages(sessionId?: string): {
1694
+ /**
1695
+ * Read a session's display messages.
1696
+ *
1697
+ * With no window this returns the whole list — that is the contract every
1698
+ * existing host relies on (VS Code / JetBrains / desktop call `getMessages`
1699
+ * with no parameters and must keep seeing the full thread, byte for byte).
1700
+ * The window exists for remote clients on a phone, which only ever want the
1701
+ * tail of a long conversation and must not be handed 13 MB on every cold
1702
+ * start:
1703
+ *
1704
+ * - `limit` — page size.
1705
+ * - `before` — the page that ends immediately before the given message id
1706
+ * (paging *backwards* into history).
1707
+ * - `since` — everything appended after the given message id (catching up
1708
+ * after a reconnect).
1709
+ *
1710
+ * `since` is a position, not a version number: message ids are stable, so a
1711
+ * caller can name its last known message without the list carrying a
1712
+ * sequence number. The one failure that matters is the anchor having been
1713
+ * truncated away (rewind / clear) — there is no seq/version in the store that
1714
+ * could detect it otherwise — and that comes back as `reset: true` so the
1715
+ * caller re-pulls the tail instead of guessing.
1716
+ *
1717
+ * Ids can repeat in the display list (a compacted message is re-added), so
1718
+ * anchors are resolved to their **last** occurrence: "after `since`" means
1719
+ * after the newest copy the caller could have seen.
1720
+ */
1721
+ private getMessages(
1722
+ sessionId?: string,
1723
+ window?: { before?: string; since?: string; limit?: number },
1724
+ ): {
1343
1725
  messages: Message[];
1344
1726
  contextUsagePercent?: number;
1727
+ /** Older messages exist before the returned page. */
1728
+ hasMore?: boolean;
1729
+ /** The `since` anchor is gone — rebuild from the tail. */
1730
+ reset?: boolean;
1731
+ /** Total display messages held for the session. */
1732
+ total?: number;
1345
1733
  } {
1346
1734
  const entry = this.requireSessionForRead(sessionId);
1347
- return {
1348
- messages: entry.agent.displayMessages,
1349
- contextUsagePercent: this.contextUsagePercentOf(entry.agent),
1350
- };
1735
+ const all = entry.agent.displayMessages;
1736
+ const contextUsagePercent = this.contextUsagePercentOf(entry.agent);
1737
+
1738
+ // The slicing is shared with the CLI front end (`RemoteControlCliHost`): the
1739
+ // phone's reconciliation rules belong to the wire contract, not to this
1740
+ // registry, and the two hosts must answer identically.
1741
+ let sliced: MessageWindowResult | undefined;
1742
+ try {
1743
+ sliced = sliceMessageWindow(all, window);
1744
+ } catch (err) {
1745
+ if (err instanceof InvalidMessageWindowError) {
1746
+ throw new RpcError(PROTOCOL_INVALID_PARAMS, err.message);
1747
+ }
1748
+ throw err;
1749
+ }
1750
+ if (!sliced) return { messages: all, contextUsagePercent };
1751
+ return { ...sliced, contextUsagePercent };
1351
1752
  }
1352
1753
 
1353
1754
  private async getFullMessageThread(sessionId?: string): Promise<{
@@ -1654,7 +2055,7 @@ export class AgentBridge {
1654
2055
  });
1655
2056
  this.emit(
1656
2057
  "permissionRequest",
1657
- { requestId, context },
2058
+ remoteWire.permissionRequest(requestId, context),
1658
2059
  ctx.registeredSessionId,
1659
2060
  );
1660
2061
  });
@@ -1701,7 +2102,15 @@ export class AgentBridge {
1701
2102
  * reading only `isLoading` reports "done" in both cases. They are read from the
1702
2103
  * live agent here rather than tracked separately, and mirror it as-is: the
1703
2104
  * 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. */
2105
+ * to also claim a `transitioning` session owns background work it doesn't.
2106
+ *
2107
+ * The three label fields are additive (an older CLI simply ignores them) and
2108
+ * exist for the remote-control page: it lists **this process's** sessions —
2109
+ * the only ones it can actually open — and cannot read the machine's disk, so
2110
+ * the title material and the ordering key have to come off the live agent
2111
+ * rather than from `listSessions`, which reads jsonl and would offer it
2112
+ * sessions that are not loaded here (spec remote-control「Web 页作为第五种宿主」
2113
+ * 场景 2)。 */
1705
2114
  private listDaemonSessions(): {
1706
2115
  sessions: Array<{
1707
2116
  sessionId: string;
@@ -1710,22 +2119,122 @@ export class AgentBridge {
1710
2119
  hasRunningBackgroundWork: boolean;
1711
2120
  hasPendingMessages: boolean;
1712
2121
  messageCount: number;
2122
+ /** User-set title; wins over `firstMessage` in the UI. */
2123
+ customTitle?: string;
2124
+ /** First real user message — the label when no custom title is set. */
2125
+ firstMessage?: string;
2126
+ /** Last display message's timestamp: the session list's ordering key. */
2127
+ lastActiveAt?: string;
2128
+ /**
2129
+ * The linked git worktree this session was created in, when it was.
2130
+ * Present only for worktree sessions — absent means "not a worktree, or
2131
+ * cannot tell", and the remote page reads it as "deleting this session
2132
+ * will not touch a worktree". Change counts are deliberately NOT here:
2133
+ * they cost git calls per row, and the list is rebuilt on every reconnect
2134
+ * / snapshot / page open. The page asks for them on demand instead, via
2135
+ * `getSessionWorktreeChanges`.
2136
+ */
2137
+ worktree?: {
2138
+ path: string;
2139
+ branch: string;
2140
+ repoRoot: string;
2141
+ hookBased: boolean;
2142
+ };
1713
2143
  }>;
1714
2144
  } {
1715
2145
  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
- })),
2146
+ sessions: [...this.sessions.entries()].map(([sessionId, entry]) => {
2147
+ const messages = entry.agent.displayMessages;
2148
+ const customTitle = entry.agent.customTitle;
2149
+ const firstMessage = firstUserText(messages);
2150
+ const lastActiveAt = messages[messages.length - 1]?.timestamp;
2151
+ // Derived from the creation workdir (not the live one) and fs-only —
2152
+ // see `deriveWorktreeInfo` for why this must not shell out to git.
2153
+ const worktree = deriveWorktreeInfo(entry.createdWorkdir);
2154
+ return {
2155
+ sessionId,
2156
+ workingDirectory: entry.createdWorkdir,
2157
+ isLoading: entry.transitioning === true || entry.agent.isLoading,
2158
+ hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
2159
+ hasPendingMessages: entry.agent.hasPendingMessages,
2160
+ messageCount: messages.length,
2161
+ ...(customTitle ? { customTitle } : {}),
2162
+ ...(firstMessage ? { firstMessage } : {}),
2163
+ ...(lastActiveAt ? { lastActiveAt } : {}),
2164
+ ...(worktree ? { worktree } : {}),
2165
+ };
2166
+ }),
1724
2167
  };
1725
2168
  }
1726
2169
 
1727
- // ── Auth (global) ────────────────────────────────────────────
2170
+ /**
2171
+ * Route the `remoteControl*` RPCs to the daemon's controller.
2172
+ *
2173
+ * `connect` / `disconnect` change the relay session; all three return the
2174
+ * same snapshot so a caller can render the result without a second round
2175
+ * trip (and a caller that only wanted to read gets `remoteControlStatus`).
2176
+ *
2177
+ * `connect` carries the optional label the user gave this machine
2178
+ * (`wave remote-control on <name>`), which the relay hands to the phone so
2179
+ * its computer list can tell two of the user's computers apart.
2180
+ */
2181
+ private remoteControlRequest(
2182
+ method: string,
2183
+ params: Record<string, unknown>,
2184
+ ): RemoteControlSnapshot {
2185
+ const controller = this.remoteControl?.();
2186
+ if (!controller) {
2187
+ throw new RpcError(
2188
+ PROTOCOL_INVALID_PARAMS,
2189
+ "Remote control is not enabled on this process (set enableRemoteControl: true).",
2190
+ );
2191
+ }
2192
+ if (method === "remoteControlConnect") {
2193
+ const name = typeof params.name === "string" ? params.name : undefined;
2194
+ controller.connect(name);
2195
+ } else if (method === "remoteControlDisconnect") {
2196
+ controller.disconnect();
2197
+ }
2198
+ return controller.getSnapshot();
2199
+ }
1728
2200
 
2201
+ /**
2202
+ * Per-session status for the remote-control signal.
2203
+ *
2204
+ * The relay forwards bytes without looking inside them, so it can never
2205
+ * discover that somebody is waiting on an approval — and "push to the phone
2206
+ * when a session is stuck on the user" is the whole point of the feature.
2207
+ * The machine therefore reports one word per session, carrying no content:
2208
+ * ids and a status, nothing else.
2209
+ *
2210
+ * `running` includes background work: read from a phone there is no terminal
2211
+ * to glance at, so "something is still in flight" is the useful meaning of
2212
+ * busy rather than the narrower "a model turn is streaming".
2213
+ */
2214
+ listSessionStatuses(): Array<{
2215
+ sessionId: string;
2216
+ status: "running" | "idle" | "requires_action";
2217
+ }> {
2218
+ const awaiting = new Set<string>();
2219
+ for (const entry of this.pendingPermissions.values()) {
2220
+ if (entry.sessionId) awaiting.add(entry.sessionId);
2221
+ }
2222
+ return [...this.sessions.entries()].map(([sessionId, entry]) => {
2223
+ let status: "running" | "idle" | "requires_action" = "idle";
2224
+ if (awaiting.has(sessionId)) {
2225
+ status = "requires_action";
2226
+ } else if (
2227
+ entry.transitioning === true ||
2228
+ entry.agent.isLoading ||
2229
+ entry.agent.hasRunningBackgroundWork
2230
+ ) {
2231
+ status = "running";
2232
+ }
2233
+ return { sessionId, status };
2234
+ });
2235
+ }
2236
+
2237
+ // ── Auth (global) ────────────────────────────────────────────
1729
2238
  private async getAuthStatus(): Promise<{
1730
2239
  isAuthenticated: boolean;
1731
2240
  user: { id: string; email?: string } | undefined;
@@ -2165,26 +2674,29 @@ export class AgentBridge {
2165
2674
  ctx.registeredSessionId,
2166
2675
  );
2167
2676
  },
2677
+ onAssistantMessageDiscarded: (messageId: string) => {
2678
+ // A streamed attempt died mid-body and was re-issued without streaming,
2679
+ // so its half message is void: tell the host to drop it instead of
2680
+ // leaving a partial answer above the real one (spec: 流式响应正文中途
2681
+ // 断连时降级为非流式重发).
2682
+ this.emit(
2683
+ "assistantMessageDiscarded",
2684
+ { messageId },
2685
+ ctx.registeredSessionId,
2686
+ );
2687
+ },
2168
2688
  onAssistantContentUpdated: (params) => {
2169
2689
  // Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
2170
2690
  this.emit(
2171
2691
  "assistantContentUpdated",
2172
- {
2173
- messageId: params.messageId,
2174
- chunk: params.chunk,
2175
- stage: params.stage,
2176
- },
2692
+ remoteWire.assistantContentUpdated(params),
2177
2693
  ctx.registeredSessionId,
2178
2694
  );
2179
2695
  },
2180
2696
  onAssistantReasoningUpdated: (params) => {
2181
2697
  this.emit(
2182
2698
  "assistantReasoningUpdated",
2183
- {
2184
- messageId: params.messageId,
2185
- chunk: params.chunk,
2186
- stage: params.stage,
2187
- },
2699
+ remoteWire.assistantReasoningUpdated(params),
2188
2700
  ctx.registeredSessionId,
2189
2701
  );
2190
2702
  },
@@ -2192,21 +2704,23 @@ export class AgentBridge {
2192
2704
  // Streaming stages carry only the parametersChunk delta; start/running
2193
2705
  // (one-time snapshots) and end (authoritative full value) keep
2194
2706
  // `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);
2707
+ this.emit(
2708
+ "toolBlockUpdated",
2709
+ remoteWire.toolBlockUpdated(params),
2710
+ ctx.registeredSessionId,
2711
+ );
2199
2712
  },
2200
2713
  onErrorBlockAdded: (error: string) => {
2201
- this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
2714
+ this.emit(
2715
+ "errorBlockAdded",
2716
+ remoteWire.errorBlockAdded(error),
2717
+ ctx.registeredSessionId,
2718
+ );
2202
2719
  },
2203
2720
  onLoadingChange: (loading: boolean) => {
2204
2721
  this.emit(
2205
2722
  "loadingChange",
2206
- {
2207
- loading,
2208
- latestTotalTokens: ctx.agent?.latestTotalTokens,
2209
- },
2723
+ remoteWire.loadingChange(loading, ctx.agent?.latestTotalTokens),
2210
2724
  ctx.registeredSessionId,
2211
2725
  );
2212
2726
  },
@@ -2218,8 +2732,11 @@ export class AgentBridge {
2218
2732
  // aligned with the CLI, which reads the same callback (spec 场景 5/6).
2219
2733
  const max = ctx.agent?.getMaxInputTokens() ?? 0;
2220
2734
  if (max > 0) {
2221
- const percent = Math.min(100, Math.round((tokens / max) * 100));
2222
- this.emit("contextUsage", { percent }, ctx.registeredSessionId);
2735
+ this.emit(
2736
+ "contextUsage",
2737
+ remoteWire.contextUsage(tokens, max),
2738
+ ctx.registeredSessionId,
2739
+ );
2223
2740
  }
2224
2741
  },
2225
2742
  onCommandRunningChange: (running: boolean) => {
@@ -2236,21 +2753,9 @@ export class AgentBridge {
2236
2753
  this.emit("tasksChange", { tasks }, ctx.registeredSessionId);
2237
2754
  },
2238
2755
  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
2756
  this.emit(
2252
2757
  "backgroundTasksChange",
2253
- { tasks: summaries },
2758
+ { tasks: toBackgroundTaskSummaries(tasks) },
2254
2759
  ctx.registeredSessionId,
2255
2760
  );
2256
2761
  },
@@ -2269,7 +2774,11 @@ export class AgentBridge {
2269
2774
  ctx.registeredSessionId = newSessionId;
2270
2775
  },
2271
2776
  onPermissionModeChange: (mode: PermissionMode) => {
2272
- this.emit("permissionModeChange", { mode }, ctx.registeredSessionId);
2777
+ this.emit(
2778
+ "permissionModeChange",
2779
+ remoteWire.permissionModeChange(mode),
2780
+ ctx.registeredSessionId,
2781
+ );
2273
2782
  },
2274
2783
  onWorkdirChange: (newCwd: string) => {
2275
2784
  this.emit(
@@ -2282,7 +2791,11 @@ export class AgentBridge {
2282
2791
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
2283
2792
  },
2284
2793
  onNotificationMessageAdded: (params) => {
2285
- const msg = ctx.agent?.displayMessages.find(
2794
+ // The LAST matching message, not the first: one task can notify more
2795
+ // than once under the same task id (a workflow warns about its size
2796
+ // mid-run, then notifies again when it finishes), and this callback
2797
+ // fires for the message that was just appended — the newest one.
2798
+ const matches = (ctx.agent?.displayMessages ?? []).filter(
2286
2799
  (m) =>
2287
2800
  m.role === "user" &&
2288
2801
  m.blocks.some(
@@ -2291,14 +2804,19 @@ export class AgentBridge {
2291
2804
  (b as { taskId: string }).taskId === params.taskId,
2292
2805
  ),
2293
2806
  );
2807
+ const msg = matches[matches.length - 1];
2294
2808
  this.emit(
2295
2809
  "notificationMessageAdded",
2296
2810
  { ...params, message: msg },
2297
2811
  ctx.registeredSessionId,
2298
2812
  );
2299
2813
  },
2300
- onCompactBlockAdded: (content: string) => {
2301
- this.emit("compactBlockAdded", { content }, ctx.registeredSessionId);
2814
+ onCompactBlockAdded: (content: string, message?: Message) => {
2815
+ this.emit(
2816
+ "compactBlockAdded",
2817
+ { content, message },
2818
+ ctx.registeredSessionId,
2819
+ );
2302
2820
  },
2303
2821
  onCompactionStateChange: (isCompacting: boolean) => {
2304
2822
  this.emit(
@@ -2405,6 +2923,22 @@ function isSessionRecoveryError(error: unknown): boolean {
2405
2923
  );
2406
2924
  }
2407
2925
 
2926
+ /**
2927
+ * The label a session list shows when no custom title is set: the first real
2928
+ * user message. Same rule as the webview's `firstUserMessageText` — meta messages
2929
+ * (task notifications and other injected turns) are skipped, because they are not
2930
+ * what the user typed — so the phone labels a conversation the way the IDE hosts
2931
+ * do.
2932
+ */
2933
+ function firstUserText(messages: Message[]): string | undefined {
2934
+ for (const message of messages) {
2935
+ if (message.role !== "user" || message.isMeta) continue;
2936
+ const text = getMessageContent(message).trim();
2937
+ if (text) return text;
2938
+ }
2939
+ return undefined;
2940
+ }
2941
+
2408
2942
  /** Read a text file, returning "" when it does not exist. */
2409
2943
  async function readTextFileSafe(filePath: string): Promise<string> {
2410
2944
  try {