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.
- package/dist/bundle/wave.mjs +713 -842
- package/package.json +7 -7
- package/src/cli.tsx +14 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +22 -15
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -13
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +117 -3
- package/src/contexts/useChat.tsx +87 -165
- package/src/daemon/commands.ts +18 -18
- package/src/hooks/useInputManager.ts +24 -42
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +23 -1
- package/src/managers/inputHandlers.ts +4 -1
- package/src/managers/inputReducer.ts +39 -1
- package/src/remote/messageWindow.ts +18 -69
- package/src/remote/notificationCoalescer.ts +21 -3
- package/src/remote/relayClient.ts +48 -26
- package/src/remote/remoteControlCliHost.ts +72 -43
- package/src/remote/remoteControlConfig.ts +25 -6
- package/src/remote/remoteControlService.ts +93 -70
- package/src/remote/remoteControlSink.ts +4 -3
- package/src/remote/remoteNotifications.ts +8 -2
- package/src/stdio/agentBridge.ts +325 -91
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/uiPayload.ts +65 -0
- package/src/types.ts +7 -0
- 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).
|
|
16
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
*
|
|
39
|
-
* the caller then answers with the whole list,
|
|
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
|
|
46
|
-
all: Message[],
|
|
31
|
+
export function normalizeMessageWindow(
|
|
47
32
|
window?: MessageWindow,
|
|
48
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
|
48
|
-
*
|
|
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
|
|
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* —
|
|
185
|
+
* the *reason* — the close code cannot say why.
|
|
161
186
|
*/
|
|
162
|
-
onAuthError?: (info: { error: string
|
|
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
|
|
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
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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 {
|