wave-code 1.6.1 → 1.6.3
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 +575 -753
- package/package.json +4 -4
- package/src/components/CommandSelector.tsx +6 -3
- package/src/components/FileSelector.tsx +19 -8
- package/src/managers/inputReducer.ts +1 -1
- package/src/remote/relayClient.ts +122 -43
- package/src/remote/remoteControlService.ts +27 -0
- package/src/utils/oneLine.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wave-code",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
4
4
|
"description": "CLI-based code assistant powered by AI, built with React and Ink",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"@types/react": "^19.3.0",
|
|
37
37
|
"@types/semver": "^7.7.1",
|
|
38
38
|
"@types/yargs": "^17.0.0",
|
|
39
|
-
"@vitest/coverage-v8": "^
|
|
39
|
+
"@vitest/coverage-v8": "^5.1.0",
|
|
40
40
|
"chalk": "^5.6.2",
|
|
41
41
|
"diff": "^8.0.2",
|
|
42
42
|
"esbuild": "^0.28.1",
|
|
@@ -53,11 +53,11 @@
|
|
|
53
53
|
"semver": "^7.7.4",
|
|
54
54
|
"string-width": "^8.2.2",
|
|
55
55
|
"tsx": "^4.20.4",
|
|
56
|
-
"vitest": "^
|
|
56
|
+
"vitest": "^5.1.0",
|
|
57
57
|
"wrap-ansi": "^10.0.0",
|
|
58
58
|
"yargs": "^17.7.2",
|
|
59
59
|
"zod": "^3.23.8",
|
|
60
|
-
"wave-agent-sdk": "1.6.
|
|
60
|
+
"wave-agent-sdk": "1.6.3"
|
|
61
61
|
},
|
|
62
62
|
"engines": {
|
|
63
63
|
"node": ">=22"
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
selectorReducer,
|
|
7
7
|
type SelectorState,
|
|
8
8
|
} from "../reducers/selectorReducer.js";
|
|
9
|
+
import { toOneLine } from "../utils/oneLine.js";
|
|
9
10
|
|
|
10
11
|
export interface CommandSelectorProps {
|
|
11
12
|
searchQuery: string;
|
|
@@ -140,9 +141,11 @@ export const CommandSelector: React.FC<CommandSelectorProps> = ({
|
|
|
140
141
|
{isSelected ? "▶ " : " "}/{command.id}
|
|
141
142
|
</Text>
|
|
142
143
|
{isSelected && (
|
|
143
|
-
<Box marginLeft={4}>
|
|
144
|
-
|
|
145
|
-
|
|
144
|
+
<Box marginLeft={4} minHeight={1}>
|
|
145
|
+
{/* One line, always: the slot keeps the menu height stable
|
|
146
|
+
whatever the description's length. */}
|
|
147
|
+
<Text color="gray" dimColor wrap="truncate">
|
|
148
|
+
{toOneLine(command.description ?? "")}
|
|
146
149
|
</Text>
|
|
147
150
|
</Box>
|
|
148
151
|
)}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
selectorReducer,
|
|
6
6
|
type SelectorState,
|
|
7
7
|
} from "../reducers/selectorReducer.js";
|
|
8
|
+
import { toOneLine } from "../utils/oneLine.js";
|
|
8
9
|
|
|
9
10
|
export type { FileSelectorItem };
|
|
10
11
|
|
|
@@ -119,15 +120,25 @@ export const FileSelector: React.FC<FileSelectorProps> = ({
|
|
|
119
120
|
|
|
120
121
|
return (
|
|
121
122
|
<Box key={fileItem.path}>
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
123
|
+
{/* The name keeps its width; the description gives way. */}
|
|
124
|
+
<Box flexShrink={0}>
|
|
125
|
+
<Text
|
|
126
|
+
color={isSelected ? "black" : "white"}
|
|
127
|
+
backgroundColor={isSelected ? "cyan" : undefined}
|
|
128
|
+
>
|
|
129
|
+
{isSelected ? "▶ " : " "}
|
|
130
|
+
{fileItem.agentName
|
|
131
|
+
? `@${fileItem.agentName}`
|
|
132
|
+
: fileItem.path}
|
|
133
|
+
</Text>
|
|
134
|
+
</Box>
|
|
129
135
|
{fileItem.agentDescription && (
|
|
130
|
-
<
|
|
136
|
+
<Box flexShrink={1}>
|
|
137
|
+
<Text dimColor wrap="truncate">
|
|
138
|
+
{" "}
|
|
139
|
+
{toOneLine(fileItem.agentDescription)}
|
|
140
|
+
</Text>
|
|
141
|
+
</Box>
|
|
131
142
|
)}
|
|
132
143
|
</Box>
|
|
133
144
|
);
|
|
@@ -39,7 +39,7 @@ export interface FileSelectorItem {
|
|
|
39
39
|
type: "file" | "directory" | "agent";
|
|
40
40
|
/** 子代理名(`type === "agent"` 时有值),也是插入正文的名字。 */
|
|
41
41
|
agentName?: string;
|
|
42
|
-
/** 子代理定义的 description
|
|
42
|
+
/** 子代理定义的 description,接在名字后面同一行显示;超宽截断成一行。 */
|
|
43
43
|
agentDescription?: string;
|
|
44
44
|
}
|
|
45
45
|
|
|
@@ -137,14 +137,38 @@ export function isStaleIdentityError(
|
|
|
137
137
|
return error !== undefined && RELAY_STALE_IDENTITY_ERRORS.includes(error);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
-
/**
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
export const
|
|
140
|
+
/**
|
|
141
|
+
* Retry cadence: exponential from 2s, doubling, capped at 60s.
|
|
142
|
+
*
|
|
143
|
+
* 2s to start keeps the first retry where the old flat cadence put it (a laptop
|
|
144
|
+
* waking from sleep should not wait out a grown backoff); the 60s ceiling is
|
|
145
|
+
* what stops a genuinely offline relay from being dialled every second.
|
|
146
|
+
*/
|
|
147
|
+
export const RELAY_RETRY_BASE_DELAY_MS = 2_000;
|
|
148
|
+
export const RELAY_RETRY_MAX_DELAY_MS = 60_000;
|
|
149
|
+
/**
|
|
150
|
+
* How long a dropped link keeps trying before it gives up and reports `error`.
|
|
151
|
+
*
|
|
152
|
+
* This is the number that decides whether the daemon survives a deploy, and it
|
|
153
|
+
* used to be about eleven seconds (six dials, flat 2s) — shorter than any
|
|
154
|
+
* realistic restart, so a routine relay release killed every connected machine
|
|
155
|
+
* for good (2026-11-09: the relay was deployed at 13:50, the host stopped
|
|
156
|
+
* retrying seconds later, and remote control stayed dead for seven hours until
|
|
157
|
+
* someone ran `wave remote-control on` by hand). Ten minutes covers a slow
|
|
158
|
+
* release (image pull, readiness probe, load-balancer drain) with margin while
|
|
159
|
+
* still being a bound: a link that has not come back in ten minutes is much
|
|
160
|
+
* more likely facing a real outage than a rollout, and stopping is what makes
|
|
161
|
+
* `status` say something a human can act on.
|
|
162
|
+
*/
|
|
163
|
+
export const RELAY_RETRY_WINDOW_MS = 10 * 60 * 1000;
|
|
164
|
+
/**
|
|
165
|
+
* Share of a scheduled wait that is randomized away ("equal jitter": the delay
|
|
166
|
+
* lands in `[50%, 100%]` of the schedule). A relay restart drops every machine
|
|
167
|
+
* at the same instant, and an unjittered schedule makes them all dial in
|
|
168
|
+
* lockstep afterwards; half-random rather than fully-random keeps the backoff
|
|
169
|
+
* growing instead of letting a still-restarting relay be dialled immediately.
|
|
170
|
+
*/
|
|
171
|
+
export const RELAY_RETRY_JITTER = 0.5;
|
|
148
172
|
/** Idle keepalive. Browsers cannot send WebSocket control pings, and the relay
|
|
149
173
|
* faces the phone anyway, so this is an application-level frame. */
|
|
150
174
|
export const RELAY_PING_INTERVAL_MS = 30_000;
|
|
@@ -202,7 +226,23 @@ export interface RelayClientOptions {
|
|
|
202
226
|
* nothing about that. Nothing is retried from here either way.
|
|
203
227
|
*/
|
|
204
228
|
onStaleIdentity?: (info: { error: string }) => void;
|
|
205
|
-
/**
|
|
229
|
+
/**
|
|
230
|
+
* A dial attempt is over and another is scheduled — reported so the caller can
|
|
231
|
+
* say *why* the link is down, how long the wait is, and how much of the retry
|
|
232
|
+
* window is left before it gives up.
|
|
233
|
+
*/
|
|
234
|
+
onRetry?: (info: {
|
|
235
|
+
/** What ended the attempt (`relay connection lost (1006)`, …). */
|
|
236
|
+
reason: string;
|
|
237
|
+
/** Failed attempts in a row since the last completed handshake. */
|
|
238
|
+
attempt: number;
|
|
239
|
+
/** How long until the next dial. */
|
|
240
|
+
delayMs: number;
|
|
241
|
+
/** How much of `RELAY_RETRY_WINDOW_MS` is still ahead. */
|
|
242
|
+
remainingMs: number;
|
|
243
|
+
}) => void;
|
|
244
|
+
/** The connection is gone for good — `4003`, a takeover, or the retry window
|
|
245
|
+
* running out (`RELAY_RETRY_WINDOW_MS`). */
|
|
206
246
|
onFatal?: (reason: string) => void;
|
|
207
247
|
}
|
|
208
248
|
|
|
@@ -211,9 +251,17 @@ export class RelayClient {
|
|
|
211
251
|
private socket: RelaySocket | undefined;
|
|
212
252
|
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
213
253
|
private pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
254
|
+
/** Failed dials in a row; drives the backoff curve. Reset by a completed
|
|
255
|
+
* handshake, never by a socket that merely opened (a relay that accepts TCP
|
|
256
|
+
* and closes immediately would otherwise pin the delay at its first step). */
|
|
214
257
|
private attempts = 0;
|
|
215
|
-
private unavailableAttempts = 0;
|
|
216
258
|
private stopped = false;
|
|
259
|
+
/**
|
|
260
|
+
* When the current outage started — the first drop since the last completed
|
|
261
|
+
* handshake. `undefined` while the link is healthy, which is what makes the
|
|
262
|
+
* retry window a per-outage budget rather than a lifetime one.
|
|
263
|
+
*/
|
|
264
|
+
private outageStartedAt: number | undefined;
|
|
217
265
|
/** Why the relay refused us, kept so the `4003` that follows can say more. */
|
|
218
266
|
private authError: { error: string } | undefined;
|
|
219
267
|
/**
|
|
@@ -375,8 +423,10 @@ export class RelayClient {
|
|
|
375
423
|
socket.onopen = () => {
|
|
376
424
|
if (this.socket !== socket) return;
|
|
377
425
|
this.state = "open";
|
|
378
|
-
|
|
379
|
-
|
|
426
|
+
// Neither the attempt counter nor the retry window is cleared here: an
|
|
427
|
+
// open socket that never completes the handshake is a failing dial, not a
|
|
428
|
+
// working link. `auth_ok` is where both reset (a relay that accepts TCP
|
|
429
|
+
// and closes at once would otherwise keep the backoff at its first step).
|
|
380
430
|
// The relay authenticates the runner before it will fan anything out, and
|
|
381
431
|
// it reads the version from the top level of this same frame. Ten seconds
|
|
382
432
|
// without it and the relay closes with `auth_timeout` (4003). This is the
|
|
@@ -432,10 +482,11 @@ export class RelayClient {
|
|
|
432
482
|
if (code === RELAY_CLOSE_UNAVAILABLE && reason === RELAY_CLOSE_REPLACED) {
|
|
433
483
|
// Another process on this machine dialled in and the relay handed the
|
|
434
484
|
// channel over to it. Retrying would take the channel back, and then the
|
|
435
|
-
// other side would retry too: the two would replace each other
|
|
436
|
-
//
|
|
437
|
-
//
|
|
438
|
-
// again from here is an explicit user
|
|
485
|
+
// other side would retry too: the two would replace each other for as
|
|
486
|
+
// long as both stay inside the retry window — and every successful
|
|
487
|
+
// handshake starts a fresh one, so neither would ever run out. So this
|
|
488
|
+
// side steps down for good; dialling again from here is an explicit user
|
|
489
|
+
// action and takes the channel back.
|
|
439
490
|
this.stopped = true;
|
|
440
491
|
this.state = "closed";
|
|
441
492
|
this.options.onFatal?.(
|
|
@@ -462,31 +513,58 @@ export class RelayClient {
|
|
|
462
513
|
return;
|
|
463
514
|
}
|
|
464
515
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
516
|
+
// Everything else — a relay restart, a network blip, sleep/wake, a `4001`
|
|
517
|
+
// "host not ready yet" — is transient and shares one budget: the retry
|
|
518
|
+
// window (see `RELAY_RETRY_WINDOW_MS`). 4001 used to have a smaller budget
|
|
519
|
+
// of its own, but it is the *same* condition from here: the relay exists and
|
|
520
|
+
// will serve this host once it is ready.
|
|
521
|
+
this.scheduleRetry(
|
|
522
|
+
code === undefined
|
|
523
|
+
? "relay connection lost"
|
|
524
|
+
: `relay connection lost (${code})`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Back off, then dial again — until the retry window runs out.
|
|
530
|
+
*
|
|
531
|
+
* The window is per outage: it starts at the first drop after a completed
|
|
532
|
+
* handshake and is cleared by the next `auth_ok`, so a link that comes back
|
|
533
|
+
* and drops again gets the full ten minutes each time rather than spending a
|
|
534
|
+
* lifetime allowance.
|
|
535
|
+
*/
|
|
536
|
+
private scheduleRetry(reason: string): void {
|
|
537
|
+
const now = Date.now();
|
|
538
|
+
this.outageStartedAt ??= now;
|
|
539
|
+
const elapsed = now - this.outageStartedAt;
|
|
540
|
+
if (elapsed >= RELAY_RETRY_WINDOW_MS) {
|
|
541
|
+
this.stopped = true;
|
|
542
|
+
this.state = "closed";
|
|
543
|
+
this.options.onFatal?.(
|
|
544
|
+
`${reason}; gave up after ${String(Math.round(RELAY_RETRY_WINDOW_MS / 60_000))}m`,
|
|
545
|
+
);
|
|
546
|
+
return;
|
|
483
547
|
}
|
|
484
548
|
|
|
549
|
+
this.attempts += 1;
|
|
550
|
+
const ceiling = Math.min(
|
|
551
|
+
RELAY_RETRY_BASE_DELAY_MS * 2 ** Math.min(this.attempts - 1, 16),
|
|
552
|
+
RELAY_RETRY_MAX_DELAY_MS,
|
|
553
|
+
);
|
|
554
|
+
const delayMs = Math.round(
|
|
555
|
+
ceiling * (1 - RELAY_RETRY_JITTER + Math.random() * RELAY_RETRY_JITTER),
|
|
556
|
+
);
|
|
485
557
|
this.state = "closed";
|
|
558
|
+
this.options.onRetry?.({
|
|
559
|
+
reason,
|
|
560
|
+
attempt: this.attempts,
|
|
561
|
+
delayMs,
|
|
562
|
+
remainingMs: RELAY_RETRY_WINDOW_MS - elapsed,
|
|
563
|
+
});
|
|
486
564
|
this.retryTimer = setTimeout(() => {
|
|
487
565
|
this.retryTimer = undefined;
|
|
488
566
|
if (!this.stopped) this.connect();
|
|
489
|
-
},
|
|
567
|
+
}, delayMs);
|
|
490
568
|
this.retryTimer.unref?.();
|
|
491
569
|
}
|
|
492
570
|
|
|
@@ -510,15 +588,12 @@ export class RelayClient {
|
|
|
510
588
|
} catch {
|
|
511
589
|
// Already gone.
|
|
512
590
|
}
|
|
591
|
+
// A phone that reads slowly is not evidence of an outage, so this drop
|
|
592
|
+
// neither grows the backoff nor spends the retry window: the link itself is
|
|
593
|
+
// fine, only its reader is behind.
|
|
513
594
|
this.attempts = 0;
|
|
514
|
-
this.
|
|
515
|
-
|
|
516
|
-
this.retryTimer = setTimeout(() => {
|
|
517
|
-
this.retryTimer = undefined;
|
|
518
|
-
if (!this.stopped) this.connect();
|
|
519
|
-
}, RELAY_RETRY_DELAY_MS);
|
|
520
|
-
this.retryTimer.unref?.();
|
|
521
|
-
}
|
|
595
|
+
this.outageStartedAt = undefined;
|
|
596
|
+
this.scheduleRetry("the subscriber fell behind and the link was dropped");
|
|
522
597
|
}
|
|
523
598
|
|
|
524
599
|
/**
|
|
@@ -532,6 +607,10 @@ export class RelayClient {
|
|
|
532
607
|
case "auth_ok":
|
|
533
608
|
this.authError = undefined;
|
|
534
609
|
this.authed = true;
|
|
610
|
+
// The link works, so this is where the schedule starts over: the next
|
|
611
|
+
// drop gets a fresh backoff curve and a fresh ten minutes.
|
|
612
|
+
this.attempts = 0;
|
|
613
|
+
this.outageStartedAt = undefined;
|
|
535
614
|
// Before anything the callback sends: parked frames are older, and the
|
|
536
615
|
// phone has to see them in the order they were produced.
|
|
537
616
|
this.flushOutbox();
|
|
@@ -482,6 +482,18 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
482
482
|
if (generation !== this.generation) return;
|
|
483
483
|
this.refreshIdentity(info.error);
|
|
484
484
|
},
|
|
485
|
+
onRetry: (info) => {
|
|
486
|
+
if (generation !== this.generation) return;
|
|
487
|
+
// A drop the client is already healing is `connecting`, never `error`:
|
|
488
|
+
// there is nothing for the user to fix yet, and calling it an error
|
|
489
|
+
// sends them hunting for a fault that is fixing itself. What they
|
|
490
|
+
// cannot guess is the deadline — how long this keeps trying before it
|
|
491
|
+
// does give up — so that is what the message carries.
|
|
492
|
+
this.setStatus({
|
|
493
|
+
state: "connecting",
|
|
494
|
+
message: `${info.reason}; retrying in ${formatDelay(info.delayMs)} (attempt ${String(info.attempt)}, ${formatWindowLeft(info.remainingMs)} left)`,
|
|
495
|
+
});
|
|
496
|
+
},
|
|
485
497
|
onFatal: (message) => {
|
|
486
498
|
if (generation !== this.generation) return;
|
|
487
499
|
this.setStatus({ state: "error", message });
|
|
@@ -824,6 +836,21 @@ export class RemoteControlService implements RemoteControlController {
|
|
|
824
836
|
}
|
|
825
837
|
}
|
|
826
838
|
|
|
839
|
+
/** `2s` / `60s` — a wait the user reads, rounded to what the schedule says. */
|
|
840
|
+
function formatDelay(ms: number): string {
|
|
841
|
+
if (ms < 60_000) return `${String(Math.round(ms / 1000))}s`;
|
|
842
|
+
return `${String(Math.round(ms / 60_000))}m`;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
/**
|
|
846
|
+
* How much of the retry window is still ahead, rounded **up**: reporting `0m
|
|
847
|
+
* left` while a dial is still scheduled would read as "already gave up".
|
|
848
|
+
*/
|
|
849
|
+
function formatWindowLeft(ms: number): string {
|
|
850
|
+
if (ms >= 60_000) return `${String(Math.ceil(ms / 60_000))}m`;
|
|
851
|
+
return `${String(Math.max(0, Math.round(ms / 1000)))}s`;
|
|
852
|
+
}
|
|
853
|
+
|
|
827
854
|
function describeRpcError(err: unknown): { code: number; message: string } {
|
|
828
855
|
const code =
|
|
829
856
|
err && typeof err === "object" && "code" in err
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collapse every run of whitespace (newlines included) into a single space.
|
|
3
|
+
*
|
|
4
|
+
* Terminal pickers render one row per entry, so text that arrives with hard
|
|
5
|
+
* line breaks must be flattened *before* it is truncated — a skill description
|
|
6
|
+
* carrying a `TRIGGER when:` block would otherwise turn one row into six and
|
|
7
|
+
* make the whole menu grow, shrink and jitter as the selection moves.
|
|
8
|
+
*/
|
|
9
|
+
export const toOneLine = (text: string): string =>
|
|
10
|
+
text.replace(/\s+/g, " ").trim();
|