wave-code 1.4.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 (41) hide show
  1. package/dist/bundle/wave.mjs +713 -842
  2. package/package.json +7 -7
  3. package/src/cli.tsx +14 -22
  4. package/src/components/AgentsManager.tsx +3 -3
  5. package/src/components/App.tsx +5 -0
  6. package/src/components/BtwDisplay.tsx +71 -15
  7. package/src/components/CompactDisplay.tsx +1 -6
  8. package/src/components/ConfirmationDetails.tsx +22 -15
  9. package/src/components/ConfirmationSelector.tsx +31 -36
  10. package/src/components/DiffDisplay.tsx +1 -1
  11. package/src/components/HooksManager.tsx +3 -3
  12. package/src/components/InputBox.tsx +37 -13
  13. package/src/components/LoginCommand.tsx +16 -35
  14. package/src/components/Markdown.tsx +7 -3
  15. package/src/components/MarketplaceAddForm.tsx +15 -19
  16. package/src/components/RemoteControlView.tsx +175 -0
  17. package/src/components/ScrolledContent.tsx +15 -13
  18. package/src/components/SkillsManager.tsx +3 -3
  19. package/src/components/TaskList.tsx +3 -3
  20. package/src/components/ToolDisplay.tsx +3 -18
  21. package/src/components/WorkflowManager.tsx +117 -3
  22. package/src/contexts/useChat.tsx +87 -165
  23. package/src/daemon/commands.ts +18 -18
  24. package/src/hooks/useInputManager.ts +24 -42
  25. package/src/hooks/useLineScroll.ts +51 -5
  26. package/src/hooks/useRemoteControl.ts +23 -1
  27. package/src/managers/inputHandlers.ts +4 -1
  28. package/src/managers/inputReducer.ts +39 -1
  29. package/src/remote/messageWindow.ts +18 -69
  30. package/src/remote/notificationCoalescer.ts +21 -3
  31. package/src/remote/relayClient.ts +48 -26
  32. package/src/remote/remoteControlCliHost.ts +72 -43
  33. package/src/remote/remoteControlConfig.ts +25 -6
  34. package/src/remote/remoteControlService.ts +93 -70
  35. package/src/remote/remoteControlSink.ts +4 -3
  36. package/src/remote/remoteNotifications.ts +8 -2
  37. package/src/stdio/agentBridge.ts +325 -91
  38. package/src/stdio/hostSlashCommands.ts +209 -0
  39. package/src/stdio/uiPayload.ts +65 -0
  40. package/src/types.ts +7 -0
  41. package/src/utils/bracketedPaste.ts +0 -170
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { useEffect, useState } from "react";
2
2
  import { useInput } from "ink";
3
3
 
4
4
  export interface UseLineScrollOptions {
@@ -6,33 +6,68 @@ export interface UseLineScrollOptions {
6
6
  totalLines: number;
7
7
  /** Number of rows visible at once (computed from the available height). */
8
8
  visibleCount: number;
9
+ /**
10
+ * Rows moved by one ↑/↓ (or Ctrl+P/Ctrl+N) press. Callers that leave it out
11
+ * never see the arrow keys handled at all — the overlay keeps only the page
12
+ * keys, as the /plan and confirmation overlays do.
13
+ */
14
+ lineStep?: number;
15
+ /**
16
+ * Resets the viewport to the top when this value changes. Only needed by
17
+ * callers that swap the content of a mounted overlay (the /btw answer of the
18
+ * next question); a component that is remounted per content set gets the
19
+ * reset for free.
20
+ */
21
+ resetKey?: unknown;
9
22
  }
10
23
 
11
24
  /**
12
25
  * Fixed-height line scrolling shared by overlays whose content may exceed the
13
26
  * terminal: keeps a scroll offset clamped to the visible window and handles
14
27
  * PgUp/PgDn (full page) plus Ctrl+u/Ctrl+d (half page, aligned with Claude
15
- * Code's modal pager keys). Returns the clamped offset and the ↑/↓ indicator
16
- * flags so callers can render "N more" lines.
28
+ * Code's modal pager keys). With `lineStep`, ↑/↓ and Ctrl+P/Ctrl+N move that
29
+ * many rows — the /btw viewport's keys. Returns the clamped offset, the ↑/↓
30
+ * indicator flags so callers can render "N more" lines, and `canScroll` (the
31
+ * content overflows the viewport).
32
+ *
33
+ * The handlers act only while the content overflows: a viewport with nothing to
34
+ * scroll must not react to keys the surrounding UI still owns.
17
35
  *
18
36
  * Scroll position starts at the top on mount; callers that display a new
19
- * content set remount this hook (e.g. an overlay component keyed by content),
20
- * which resets the offset naturally.
37
+ * content set either remount this hook (e.g. an overlay component keyed by
38
+ * content) or pass `resetKey`, which resets the offset naturally.
21
39
  */
22
40
  export function useLineScroll({
23
41
  totalLines,
24
42
  visibleCount,
43
+ lineStep,
44
+ resetKey,
25
45
  }: UseLineScrollOptions): {
26
46
  scrollOffset: number;
27
47
  hasMoreAbove: boolean;
28
48
  hasMoreBelow: boolean;
49
+ canScroll: boolean;
29
50
  } {
30
51
  const [scrollOffset, setScrollOffset] = useState(0);
31
52
 
32
53
  const maxScroll = Math.max(0, totalLines - visibleCount);
33
54
  const halfPage = Math.max(1, Math.ceil(visibleCount / 2));
55
+ const canScroll = totalLines > visibleCount;
56
+
57
+ useEffect(() => {
58
+ setScrollOffset(0);
59
+ }, [resetKey]);
34
60
 
35
61
  useInput((input, key) => {
62
+ // Nothing to scroll: leave every key to the surrounding UI instead of
63
+ // acting on it. The check lives inside the handler rather than in
64
+ // `useInput`'s `isActive`: the row count is measured one commit after the
65
+ // mount frame, and a handler subscribed only once the overflow is known
66
+ // would drop the keypresses that arrive in between (which is exactly when a
67
+ // user, seeing the content clipped, starts pressing PgDn/↓).
68
+ if (!canScroll) {
69
+ return;
70
+ }
36
71
  if (key.pageUp) {
37
72
  setScrollOffset((offset) => Math.max(0, offset - visibleCount));
38
73
  }
@@ -47,6 +82,16 @@ export function useLineScroll({
47
82
  if (key.ctrl && input === "d") {
48
83
  setScrollOffset((offset) => Math.min(maxScroll, offset + halfPage));
49
84
  }
85
+ if (lineStep) {
86
+ // ↑/↓ scroll the viewport line by line; Ctrl+P/Ctrl+N are the same
87
+ // keys for terminals that swallow the arrows.
88
+ if (key.upArrow || (key.ctrl && input === "p")) {
89
+ setScrollOffset((offset) => Math.max(0, offset - lineStep));
90
+ }
91
+ if (key.downArrow || (key.ctrl && input === "n")) {
92
+ setScrollOffset((offset) => Math.min(maxScroll, offset + lineStep));
93
+ }
94
+ }
50
95
  });
51
96
 
52
97
  const clampedOffset = Math.min(scrollOffset, maxScroll);
@@ -54,5 +99,6 @@ export function useLineScroll({
54
99
  scrollOffset: clampedOffset,
55
100
  hasMoreAbove: clampedOffset > 0,
56
101
  hasMoreBelow: clampedOffset + visibleCount < totalLines,
102
+ canScroll,
57
103
  };
58
104
  }
@@ -23,6 +23,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
23
23
  import type {
24
24
  BackgroundTask,
25
25
  Message,
26
+ MessageWindow,
27
+ MessageWindowResult,
26
28
  PermissionDecision,
27
29
  PermissionMode,
28
30
  QueuedMessage,
@@ -46,7 +48,14 @@ import { createRemoteControlSinkFactory } from "../remote/remoteControlSink.js";
46
48
  import type { RemoteControlSink } from "../remote/remoteControlSink.js";
47
49
 
48
50
  export interface UseRemoteControlOptions {
51
+ /** Where the shell is *now* — follows a Bash `cd`; the page's `@file` joins use it. */
49
52
  workdir: string;
53
+ /**
54
+ * Where the session was opened (the agent's `sessionWorkdir`): the directory
55
+ * the page's session row keys on. Read through a function because the agent
56
+ * outlives individual renders and does not exist yet on the first one.
57
+ */
58
+ sessionCwd: () => string;
50
59
  sessionId: string;
51
60
  messages: Message[];
52
61
  isLoading: boolean;
@@ -85,6 +94,16 @@ export interface UseRemoteControlOptions {
85
94
  compact: (customInstructions?: string) => void | Promise<void>;
86
95
  /** Slash commands the page may offer (SDK commands: skills / custom). */
87
96
  slashCommands: () => SlashCommand[];
97
+ /** One page of the display thread — read from the transcript when it reaches back. */
98
+ messagePage: (window: MessageWindow) => Promise<MessageWindowResult>;
99
+ /** The whole thread in one shot (no window asked for); nothing is cached. */
100
+ currentThread: () => Promise<Message[]>;
101
+ /** Thread totals for the session list (the resident window cannot answer). */
102
+ threadSummary: () => {
103
+ messageCount: number;
104
+ firstUserText?: string;
105
+ lastActiveAt?: string;
106
+ };
88
107
  }
89
108
 
90
109
  export interface RemoteControlApi {
@@ -292,7 +311,10 @@ function adapterOf(latest: {
292
311
  return {
293
312
  sessionId: () => latest.current.sessionId,
294
313
  workdir: () => latest.current.workdir,
295
- displayMessages: () => latest.current.messages,
314
+ sessionCwd: () => latest.current.sessionCwd(),
315
+ messagePage: (window) => latest.current.messagePage(window),
316
+ currentThread: () => latest.current.currentThread(),
317
+ threadSummary: () => latest.current.threadSummary(),
296
318
  contextUsagePercent: () =>
297
319
  percentOf(
298
320
  latest.current.latestTotalTokens,
@@ -406,7 +406,7 @@ export const handleCommandSelect = (
406
406
  } else if (command === "rename") {
407
407
  await callbacks.onRenameSession?.();
408
408
  } else if (command === "remote-control") {
409
- await callbacks.onRemoteControlCommand?.();
409
+ dispatch({ type: "SET_SHOW_REMOTE_CONTROL", payload: true });
410
410
  }
411
411
  }
412
412
  })();
@@ -763,6 +763,7 @@ export const handleInput = async (
763
763
  state.showResumeSelector ||
764
764
  state.showHelp ||
765
765
  state.showStatusCommand ||
766
+ state.showRemoteControl ||
766
767
  state.showPluginManager ||
767
768
  state.showModelSelector ||
768
769
  state.showWorkflowManager
@@ -788,6 +789,7 @@ export const handleInput = async (
788
789
  state.showResumeSelector ||
789
790
  state.showHelp ||
790
791
  state.showStatusCommand ||
792
+ state.showRemoteControl ||
791
793
  state.showPluginManager ||
792
794
  state.showModelSelector ||
793
795
  state.showWorkflowManager
@@ -799,6 +801,7 @@ export const handleInput = async (
799
801
  state.showResumeSelector ||
800
802
  state.showHelp ||
801
803
  state.showStatusCommand ||
804
+ state.showRemoteControl ||
802
805
  state.showPluginManager ||
803
806
  state.showModelSelector ||
804
807
  state.showWorkflowManager
@@ -123,7 +123,6 @@ export interface InputManagerCallbacks {
123
123
  onReloadPlugins?: () => Promise<void>;
124
124
  onAddDir?: (args?: string) => Promise<void>;
125
125
  onRenameSession?: (args?: string) => Promise<void>;
126
- onRemoteControlCommand?: (args?: string) => Promise<void>;
127
126
  onPlanCommand?: (args?: string) => Promise<void> | void;
128
127
  sessionId?: string;
129
128
  workdir?: string;
@@ -160,6 +159,7 @@ export interface InputState {
160
159
  showResumeSelector: boolean;
161
160
  showHelp: boolean;
162
161
  showStatusCommand: boolean;
162
+ showRemoteControl: boolean;
163
163
  showLoginCommand: boolean;
164
164
  showPluginManager: boolean;
165
165
  showModelSelector: boolean;
@@ -201,6 +201,7 @@ export const initialState: InputState = {
201
201
  showResumeSelector: false,
202
202
  showHelp: false,
203
203
  showStatusCommand: false,
204
+ showRemoteControl: false,
204
205
  showLoginCommand: false,
205
206
  showPluginManager: false,
206
207
  showModelSelector: false,
@@ -339,6 +340,24 @@ function deleteTokenBefore(
339
340
  * when there is nothing to submit (empty text).
340
341
  */
341
342
  function submitInput(state: InputState): InputState | null {
343
+ const submitted = buildSubmitState(state);
344
+ if (!submitted) return null;
345
+ // A submit consumes the text, so the popups anchored to it have to go too.
346
+ // Enter reaches here instead of `SELECT_COMMAND` — which is what used to
347
+ // close the selector — so submitting `/workflows` left the popup painted
348
+ // above the panel the command had just opened.
349
+ return {
350
+ ...submitted,
351
+ showCommandSelector: false,
352
+ slashPosition: -1,
353
+ commandSearchQuery: "",
354
+ showFileSelector: false,
355
+ atPosition: -1,
356
+ fileSearchQuery: "",
357
+ };
358
+ }
359
+
360
+ function buildSubmitState(state: InputState): InputState | null {
342
361
  if (!state.inputText.trim()) {
343
362
  return null;
344
363
  }
@@ -485,6 +504,7 @@ export type InputAction =
485
504
  | { type: "SET_SHOW_RESUME_SELECTOR"; payload: boolean }
486
505
  | { type: "SET_SHOW_HELP"; payload: boolean }
487
506
  | { type: "SET_SHOW_STATUS_COMMAND"; payload: boolean }
507
+ | { type: "SET_SHOW_REMOTE_CONTROL"; payload: boolean }
488
508
  | { type: "SET_SHOW_LOGIN_COMMAND"; payload: boolean }
489
509
  | { type: "SET_SHOW_PLUGIN_MANAGER"; payload: boolean }
490
510
  | { type: "SET_SHOW_MODEL_SELECTOR"; payload: boolean }
@@ -703,6 +723,12 @@ export function inputReducer(
703
723
  showStatusCommand: action.payload,
704
724
  selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
705
725
  };
726
+ case "SET_SHOW_REMOTE_CONTROL":
727
+ return {
728
+ ...state,
729
+ showRemoteControl: action.payload,
730
+ selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
731
+ };
706
732
  case "SET_SHOW_LOGIN_COMMAND":
707
733
  return {
708
734
  ...state,
@@ -753,6 +779,17 @@ export function inputReducer(
753
779
  inputText: "",
754
780
  cursorPosition: 0,
755
781
  historyIndex: -1,
782
+ // A cleared input leaves no `/` or `@` for a popup to anchor to, but
783
+ // this action used to reset only the text. Submitting `/workflows` goes
784
+ // through `handleSubmit` rather than `SELECT_COMMAND`, so the selector
785
+ // stayed up and painted a stale "Command Selector (filtering: …)" above
786
+ // the panel the command had just opened.
787
+ showCommandSelector: false,
788
+ slashPosition: -1,
789
+ commandSearchQuery: "",
790
+ showFileSelector: false,
791
+ atPosition: -1,
792
+ fileSearchQuery: "",
756
793
  };
757
794
  case "ADD_IMAGE_AND_INSERT_PLACEHOLDER": {
758
795
  const newImage: AttachedImage = {
@@ -1103,6 +1140,7 @@ export function inputReducer(
1103
1140
  state.showResumeSelector ||
1104
1141
  state.showHelp ||
1105
1142
  state.showStatusCommand ||
1143
+ state.showRemoteControl ||
1106
1144
  state.showLoginCommand ||
1107
1145
  state.showPluginManager ||
1108
1146
  state.showModelSelector ||
@@ -1,52 +1,36 @@
1
1
  /**
2
- * Message-window slicing, shared by every host that answers `getMessages`.
2
+ * `getMessages` parameter validation, shared by every host that answers it.
3
3
  *
4
4
  * Two callers need this and must agree byte for byte: the daemon / stdio
5
5
  * bridge (multi-session registry) and the interactive CLI front end (one
6
6
  * session in React state). The phone's reconciliation rules — `since` is an
7
- * anchor, a vanished anchor means "re-pull the tail", ids can repeat so the
8
- * anchor resolves to its **last** occurrence — are a property of the wire
7
+ * anchor, a vanished anchor means "re-pull the whole list", ids can repeat so
8
+ * the anchor resolves to its **last** occurrence — are a property of the wire
9
9
  * contract, not of either host, so the arithmetic lives here once.
10
10
  *
11
+ * The list itself lives in the SDK: it starts at the last compaction, and the
12
+ * remote page takes a `limit`-sized tail of it because of the relay's frame
13
+ * budget. What is left here is the one rule a misreading caller cannot notice
14
+ * on its own — a malformed parameter must fail instead of silently widening
15
+ * into a full read.
16
+ *
11
17
  * Described in docs/specs/ui/remote-control.md (§5 白名单与增量拉取).
12
18
  */
13
19
 
14
- import type { Message } from "wave-agent-sdk";
15
-
16
- export interface MessageWindow {
17
- /** Exclusive upper anchor: return the page ending just before this id. */
18
- before?: string;
19
- /** Exclusive lower anchor: return what arrived after this id. */
20
- since?: string;
21
- limit?: number;
22
- }
20
+ import type { MessageWindow } from "wave-agent-sdk";
23
21
 
24
- export interface MessageWindowResult {
25
- messages: Message[];
26
- /** Older messages exist before the returned page. */
27
- hasMore?: boolean;
28
- /** The `since` anchor is gone — rebuild from the tail. */
29
- reset?: boolean;
30
- /** Total display messages held for the session. */
31
- total?: number;
32
- }
22
+ export type { MessageWindow, MessageWindowResult } from "wave-agent-sdk";
33
23
 
34
24
  /** A malformed window. Hosts translate this into their own RPC error type. */
35
25
  export class InvalidMessageWindowError extends Error {}
36
26
 
37
27
  /**
38
- * Slice `all` by `window`. Returns `undefined` when no window was asked for —
39
- * the caller then answers with the whole list, untouched.
40
- *
41
- * The validations are here rather than in either caller because silently
42
- * widening the read is the one failure mode a caller cannot notice: it asked
43
- * for a bounded page and got the thread back.
28
+ * Validate `window` and hand it on. Returns `undefined` when nothing was asked
29
+ * for — the caller then answers with the whole list, read once.
44
30
  */
45
- export function sliceMessageWindow(
46
- all: Message[],
31
+ export function normalizeMessageWindow(
47
32
  window?: MessageWindow,
48
- ): MessageWindowResult | undefined {
49
- const before = window?.before;
33
+ ): MessageWindow | undefined {
50
34
  const since = window?.since;
51
35
  const rawLimit = window?.limit;
52
36
  if (rawLimit !== undefined && (!Number.isInteger(rawLimit) || rawLimit < 1)) {
@@ -54,44 +38,9 @@ export function sliceMessageWindow(
54
38
  `getMessages limit must be a positive integer, got ${String(rawLimit)}`,
55
39
  );
56
40
  }
57
- const limit = rawLimit;
58
-
59
- if (before === undefined && since === undefined && limit === undefined) {
60
- return undefined;
61
- }
62
-
63
- const lastIndexOfId = (id: string): number => {
64
- for (let i = all.length - 1; i >= 0; i--) {
65
- if (all[i].id === id) return i;
66
- }
67
- return -1;
68
- };
69
-
70
- if (since !== undefined) {
71
- const at = lastIndexOfId(since);
72
- if (at === -1) {
73
- return { messages: [], reset: true, total: all.length };
74
- }
75
- const later = all.slice(at + 1);
76
- return {
77
- messages: limit === undefined ? later : later.slice(-limit),
78
- hasMore: false,
79
- total: all.length,
80
- };
81
- }
82
-
83
- // Backward paging; without `before` this is a tail read (`limit` only).
84
- const end = before === undefined ? all.length : lastIndexOfId(before);
85
- if (end === -1) {
86
- // The anchor is gone. Older history may have been truncated along with it,
87
- // so answer with an empty page: the caller stops paging rather than looping
88
- // on an anchor that will never resolve.
89
- return { messages: [], hasMore: false, total: all.length };
90
- }
91
- const start = limit === undefined ? 0 : Math.max(0, end - limit);
41
+ if (since === undefined && rawLimit === undefined) return undefined;
92
42
  return {
93
- messages: all.slice(start, end),
94
- hasMore: start > 0,
95
- total: all.length,
43
+ ...(since !== undefined ? { since } : {}),
44
+ ...(rawLimit !== undefined ? { limit: rawLimit } : {}),
96
45
  };
97
46
  }
@@ -44,12 +44,13 @@ interface MergeSpec {
44
44
  /**
45
45
  * Decide whether a notification is a pure delta that may be merged.
46
46
  *
47
- * Only the three streaming callbacks qualify, and only in their streaming
48
- * stage: their payloads are documented as deltas ("consumers accumulate"), and
47
+ * Only the streaming callbacks qualify, and only in their streaming stage:
48
+ * their payloads are documented as deltas ("consumers accumulate"), and
49
49
  * `messageOperations.ts` accumulates `parametersChunk` by plain concatenation —
50
50
  * the same operation this class performs. A `start` / `running` / `end` frame
51
51
  * is a one-time snapshot (or the authoritative full value) and must not be
52
- * merged with anything.
52
+ * merged with anything. `btwContent` (the `/btw` answer stream) is the fourth:
53
+ * one field, no stages, a delta every time.
53
54
  */
54
55
  function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
55
56
  if (typeof params !== "object" || params === null) return undefined;
@@ -78,6 +79,23 @@ function mergeSpec(method: string, params: unknown): MergeSpec | undefined {
78
79
  };
79
80
  }
80
81
 
82
+ // `/btw` side question (`btwContent`, spec ui/remote-control.md「Web 页也能用
83
+ // /btw 旁路提问」场景 4). Same family as the three above: `content` is a pure
84
+ // delta the panel accumulates, so the merged frame carries exactly the bytes
85
+ // the individual ones would. The key carries the question **and** the channel
86
+ // (`content` / `thinking`) — the panel drops thinking once content starts, so
87
+ // merging the two together would resurrect text it had already discarded, and
88
+ // two questions in flight must never interleave.
89
+ if (method === "btwContent") {
90
+ if (typeof p.content !== "string" || typeof p.question !== "string") {
91
+ return undefined;
92
+ }
93
+ return {
94
+ field: "content",
95
+ key: `b\u0000${p.question}\u0000${typeof p.type === "string" ? p.type : ""}`,
96
+ };
97
+ }
98
+
81
99
  return undefined;
82
100
  }
83
101
 
@@ -61,7 +61,7 @@ export interface RelayCredential {
61
61
  /** The control frames the relay sends us. Not forwarded, not JSON-RPC. */
62
62
  export type RelayControlFrame =
63
63
  | { type: "auth_ok"; role?: string; hostId?: string }
64
- | { type: "auth_error"; error: string; minVersion?: string }
64
+ | { type: "auth_error"; error: string }
65
65
  | { type: "peer_disconnected"; hostId?: string }
66
66
  | { type: "resync_required"; hostId?: string }
67
67
  | { type: "ping" }
@@ -103,6 +103,8 @@ export const defaultRelaySocketFactory: RelaySocketFactory = (url) => {
103
103
  /** Close codes the relay uses to tell us how to react. */
104
104
  export const RELAY_CLOSE_UNAVAILABLE = 4001; // transient — the host is not ready yet
105
105
  export const RELAY_CLOSE_UNAUTHORIZED = 4003; // permanent — do not come back
106
+ // (the one exception is a *stale stored identity*, which the credential's owner
107
+ // can replace by registering again: see `RELAY_STALE_IDENTITY_ERRORS`)
106
108
  export const RELAY_CLOSE_BACKPRESSURE = 4008; // we gave up on a stuck socket
107
109
  /**
108
110
  * The `4001` reason the relay sends when another process on this machine dialled
@@ -112,6 +114,29 @@ export const RELAY_CLOSE_BACKPRESSURE = 4008; // we gave up on a stuck socket
112
114
  */
113
115
  export const RELAY_CLOSE_REPLACED = "replaced";
114
116
 
117
+ /**
118
+ * The `auth_error` reasons that mean the **identity this machine stored** no
119
+ * longer exists on the relay: the row was revoked, the token aged out, or the
120
+ * local file holds an id/token pair that never belonged together. All of them
121
+ * are fixed the same way — register again, and the relay hands back the same row
122
+ * (the id is derived from the machine, so registration is idempotent) — which is
123
+ * exactly why they are separated from the rest of `4003`. The others
124
+ * (`organization_mismatch`, `unauthorized`, and anything a relay adds later) are
125
+ * about *this account, this organization*: registering again cannot change any
126
+ * of them, and treating them as stale would only hide the real reason behind a
127
+ * pointless round trip.
128
+ */
129
+ export const RELAY_STALE_IDENTITY_ERRORS: readonly string[] = [
130
+ "invalid_host_token",
131
+ "unknown_host",
132
+ ];
133
+
134
+ export function isStaleIdentityError(
135
+ error: string | undefined,
136
+ ): error is string {
137
+ return error !== undefined && RELAY_STALE_IDENTITY_ERRORS.includes(error);
138
+ }
139
+
115
140
  /** Retry cadence. Flat 2s: the relay is a well-known host, not a flaky peer —
116
141
  * exponential backoff would only delay a laptop waking from sleep. */
117
142
  export const RELAY_RETRY_DELAY_MS = 2_000;
@@ -157,9 +182,9 @@ export interface RelayClientOptions {
157
182
  onAuthenticated?: (info: { role?: string; hostId?: string }) => void;
158
183
  /**
159
184
  * `auth_error`. The relay closes with `4003` right after, so this only adds
160
- * the *reason* — and, for `host_version_unsupported`, the floor to report.
185
+ * the *reason* — the close code cannot say why.
161
186
  */
162
- onAuthError?: (info: { error: string; minVersion?: string }) => void;
187
+ onAuthError?: (info: { error: string }) => void;
163
188
  /** The other end of this channel is gone (the phone, or the machine). */
164
189
  onPeerDisconnected?: () => void;
165
190
  /**
@@ -169,6 +194,14 @@ export interface RelayClientOptions {
169
194
  onResyncRequired?: () => void;
170
195
  /** A frame from the relay (phone request or notification). */
171
196
  onFrame?: (frame: RelayEnvelope) => void;
197
+ /**
198
+ * `4003` whose reason says the stored identity is stale (see
199
+ * `RELAY_STALE_IDENTITY_ERRORS`). Reported **instead of** `onFatal` because it
200
+ * is not the end of the road: replacing the credential means an HTTP
201
+ * registration, which only the credential's owner can do — this class knows
202
+ * nothing about that. Nothing is retried from here either way.
203
+ */
204
+ onStaleIdentity?: (info: { error: string }) => void;
172
205
  /** The connection is gone for good — `4003`, or the retry budget ran out. */
173
206
  onFatal?: (reason: string) => void;
174
207
  }
@@ -182,7 +215,7 @@ export class RelayClient {
182
215
  private unavailableAttempts = 0;
183
216
  private stopped = false;
184
217
  /** Why the relay refused us, kept so the `4003` that follows can say more. */
185
- private authError: { error: string; minVersion?: string } | undefined;
218
+ private authError: { error: string } | undefined;
186
219
  /**
187
220
  * `auth_ok` has arrived. Until it does the relay parses *every* frame as the
188
221
  * auth frame, and one it cannot parse closes the connection for good
@@ -414,6 +447,13 @@ export class RelayClient {
414
447
  if (code === RELAY_CLOSE_UNAUTHORIZED) {
415
448
  this.stopped = true;
416
449
  this.state = "closed";
450
+ const error = this.authError?.error;
451
+ if (isStaleIdentityError(error)) {
452
+ // No reconnect from here: the fix is a registration round trip, not a
453
+ // retry (dialling again with the same dead token can only repeat this).
454
+ this.options.onStaleIdentity?.({ error });
455
+ return;
456
+ }
417
457
  this.options.onFatal?.(
418
458
  this.authError
419
459
  ? `relay rejected the credential (4003): ${this.authError.error}`
@@ -501,23 +541,8 @@ export class RelayClient {
501
541
  });
502
542
  return;
503
543
  case "auth_error":
504
- this.authError = {
505
- error: frame.error,
506
- ...(frame.minVersion !== undefined
507
- ? { minVersion: frame.minVersion }
508
- : {}),
509
- };
544
+ this.authError = { error: frame.error };
510
545
  this.options.onAuthError?.(this.authError);
511
- if (frame.error === "host_version_unsupported") {
512
- // Retrying cannot change this build, and the `4003` that follows
513
- // would report it as "bad credential" — the wrong advice. So the
514
- // decision is taken here, where the floor is still on the wire.
515
- this.fail(
516
- `this wave build is older than the relay requires` +
517
- `${frame.minVersion ? ` (minimum ${frame.minVersion})` : ""}` +
518
- ` — upgrade wave on this machine`,
519
- );
520
- }
521
546
  return;
522
547
  case "peer_disconnected":
523
548
  this.options.onPeerDisconnected?.();
@@ -608,12 +633,9 @@ export function parseRelayControlFrame(
608
633
  case "auth_error": {
609
634
  const error = asString(frame.error);
610
635
  if (error === undefined) return undefined;
611
- const minVersion = asString(frame.minVersion);
612
- return {
613
- type: "auth_error",
614
- error,
615
- ...(minVersion !== undefined ? { minVersion } : {}),
616
- };
636
+ // Any `minVersion` an older relay still sends is dropped here: the
637
+ // version floor is no longer part of the contract.
638
+ return { type: "auth_error", error };
617
639
  }
618
640
  case "peer_disconnected":
619
641
  return {