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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wave-code",
3
- "version": "1.6.1",
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": "^4.1.7",
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": "^4.1.7",
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.1"
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
- <Text color="gray" dimColor>
145
- {command.description}
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
- <Text
123
- color={isSelected ? "black" : "white"}
124
- backgroundColor={isSelected ? "cyan" : undefined}
125
- >
126
- {isSelected ? "▶ " : " "}
127
- {fileItem.agentName ? `@${fileItem.agentName}` : fileItem.path}
128
- </Text>
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
- <Text dimColor> {fileItem.agentDescription}</Text>
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
- /** Retry cadence. Flat 2s: the relay is a well-known host, not a flaky peer —
141
- * exponential backoff would only delay a laptop waking from sleep. */
142
- export const RELAY_RETRY_DELAY_MS = 2_000;
143
- /** Attempts for an ordinary drop (relay restart, network blip). */
144
- export const RELAY_MAX_ATTEMPTS = 5;
145
- /** Separate, smaller budget for `4001`: the host announced itself but had no
146
- * session ready. Retrying a real outage five times buys nothing. */
147
- export const RELAY_MAX_UNAVAILABLE_ATTEMPTS = 3;
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
- /** The connection is gone for good — `4003`, or the retry budget ran out. */
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
- this.attempts = 0;
379
- this.unavailableAttempts = 0;
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 every two
436
- // seconds, forever — every successful connect resets the retry budget, so
437
- // neither ever exceeds it. So this side steps down for good; dialling
438
- // again from here is an explicit user action and takes the channel back.
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
- if (code === RELAY_CLOSE_UNAVAILABLE) {
466
- this.unavailableAttempts += 1;
467
- if (this.unavailableAttempts > RELAY_MAX_UNAVAILABLE_ATTEMPTS) {
468
- this.stopped = true;
469
- this.state = "closed";
470
- this.options.onFatal?.("relay reported the host unavailable (4001)");
471
- return;
472
- }
473
- } else {
474
- this.attempts += 1;
475
- if (this.attempts > RELAY_MAX_ATTEMPTS) {
476
- this.stopped = true;
477
- this.state = "closed";
478
- this.options.onFatal?.(
479
- `relay connection lost${code === undefined ? "" : ` (${code})`}`,
480
- );
481
- return;
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
- }, RELAY_RETRY_DELAY_MS);
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.state = "closed";
515
- if (!this.stopped) {
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();