wave-code 1.6.0 → 1.6.2

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.
@@ -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();
@@ -249,10 +249,19 @@ export class RemoteControlCliHost implements RemoteControlHost {
249
249
  decision?: unknown;
250
250
  };
251
251
  if (typeof p.requestId !== "string" || !p.decision) return;
252
- this.session.respondPermission(
253
- p.requestId,
254
- p.decision as PermissionDecision,
255
- );
252
+ const decision = p.decision as PermissionDecision;
253
+ this.session.respondPermission(p.requestId, decision);
254
+ // 不带反馈的拒绝 = 用户在页面上关掉了确认卡(关闭键 / Esc),本轮就此结束:
255
+ // 与桌面端 / VS Code / JetBrains 的 `denied → abortMessage()`、以及 Claude
256
+ // Code 的 `cancelAndAbort`(无 feedback 直接 abort 整轮)同一条语义。少了这
257
+ // 一步,手机上关掉提问弹窗后模型会拿着「用户拒绝了操作」继续往下做,而同一
258
+ // 张卡在桌面端关掉就停(spec ui/confirm-ui.md 场景 2 / 5、remote-web.md
259
+ // 场景 24)。**带 message 的拒绝不中断**——那是用户给出理由后拒绝这一次工具
260
+ // 调用,模型要照反馈继续或改问。
261
+ const rejectionFeedback = decision.message?.trim();
262
+ if (decision.behavior === "deny" && !rejectionFeedback) {
263
+ this.session.abort();
264
+ }
256
265
  }
257
266
 
258
267
  /**
@@ -388,10 +397,17 @@ export class RemoteControlCliHost implements RemoteControlHost {
388
397
  messages: Message[];
389
398
  contextUsagePercent: number;
390
399
  reset?: boolean;
400
+ anchorLost?: boolean;
401
+ hasMore?: boolean;
391
402
  }> {
392
403
  const contextUsagePercent = this.session.contextUsagePercent();
393
404
  const window: MessageWindow = {};
394
405
  if (typeof p.since === "string") window.since = p.since;
406
+ // 上翻取更早的锚点(spec ui/message-rendering-system.md「首屏一页,上翻按需
407
+ // 取更早」,远控页自 2026-11-06 起也翻)。少这一行,`{before, limit}` 会被当成
408
+ // 「只要一页尾巴」——回的是同一段尾部、`hasMore` 仍是 true,页面每次滚到顶都
409
+ // 白要一次,永远翻不动。
410
+ if (typeof p.before === "string") window.before = p.before;
395
411
  if (p.limit !== undefined) window.limit = Number(p.limit);
396
412
 
397
413
  let normalized: MessageWindow | undefined;
@@ -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 });
@@ -696,7 +708,7 @@ export class RemoteControlService implements RemoteControlController {
696
708
  // Not even one message fits: there is nothing to send that the phone
697
709
  // could render, and an empty list would only make it re-ask forever.
698
710
  if (kept > 0) {
699
- this.send(pageFrame(replyId, page, kept));
711
+ this.send(pageFrame(replyId, page, kept, kept < page.messages.length));
700
712
  return;
701
713
  }
702
714
  }
@@ -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
@@ -859,13 +886,19 @@ function pageFrame(
859
886
  replyId: number | string,
860
887
  page: MessagePage,
861
888
  count: number,
889
+ droppedEarlier = false,
862
890
  ): RelayEnvelope {
891
+ // 裁掉的是**最早**那几条,所以只要真裁过,「更早的还在」就必然成立——哪怕
892
+ // 宿主那一页说的 `hasMore: false`(上翻取到这条会话最早那一页时正是如此)。
893
+ // 不在这里改这个字段,页面会照着 false 把上翻开关关掉,被裁掉的几条就再也
894
+ // 翻不回来了;页大小因此只能是「大概几条」,**条数永远不是终止判据**。
895
+ const rest = droppedEarlier ? { ...page.rest, hasMore: true } : page.rest;
863
896
  return {
864
897
  id: replyId,
865
898
  method: "result",
866
899
  params: {
867
900
  result: {
868
- ...page.rest,
901
+ ...rest,
869
902
  messages: page.messages.slice(-count),
870
903
  },
871
904
  },
@@ -28,6 +28,7 @@ import type {
28
28
  ToolBlockUpdateCallbackParams,
29
29
  ToolPermissionContext,
30
30
  } from "wave-agent-sdk";
31
+ import { getAutoCompactThreshold } from "wave-agent-sdk";
31
32
  import { toolBlockUpdateForUi } from "../stdio/uiPayload.js";
32
33
 
33
34
  /**
@@ -45,9 +46,25 @@ type StreamDelta = Parameters<
45
46
  * push and its `getSessionInfo` read). 0 when the cap is unknown, so a caller
46
47
  * that has one reads a number rather than a special case.
47
48
  */
48
- export function percentOf(tokens: number, maxInputTokens: number): number {
49
- if (!maxInputTokens || maxInputTokens <= 0) return 0;
50
- return Math.min(100, Math.round((tokens / maxInputTokens) * 100));
49
+ export function percentOf(
50
+ tokens: number,
51
+ contextWindow: number,
52
+ maxOutputTokens = 32_000,
53
+ ): number {
54
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) return 0;
55
+ const calculatedThreshold = getAutoCompactThreshold(
56
+ contextWindow,
57
+ Number.isFinite(maxOutputTokens) && maxOutputTokens > 0
58
+ ? maxOutputTokens
59
+ : 32_000,
60
+ );
61
+ // Some host tests auto-mock the SDK barrel wholesale. Keep the wire helper
62
+ // usable in that environment without changing production's shared formula.
63
+ const threshold =
64
+ Number.isFinite(calculatedThreshold) && calculatedThreshold > 0
65
+ ? calculatedThreshold
66
+ : contextWindow;
67
+ return Math.min(100, Math.round((tokens / threshold) * 100));
51
68
  }
52
69
 
53
70
  /** The shapes both hosts put on the wire, keyed by the method they ride under. */
@@ -95,8 +112,12 @@ export const remoteWire = {
95
112
  ...(latestTotalTokens === undefined ? {} : { latestTotalTokens }),
96
113
  }),
97
114
 
98
- contextUsage: (latestTotalTokens: number, maxInputTokens: number) => ({
99
- percent: percentOf(latestTotalTokens, maxInputTokens),
115
+ contextUsage: (
116
+ latestTotalTokens: number,
117
+ contextWindow: number,
118
+ maxOutputTokens = 32_000,
119
+ ) => ({
120
+ percent: percentOf(latestTotalTokens, contextWindow, maxOutputTokens),
100
121
  }),
101
122
 
102
123
  permissionModeChange: (mode: PermissionMode) => ({ mode }),
@@ -60,6 +60,7 @@ import {
60
60
  updateUserPreferenceSettings,
61
61
  type UserPreferenceSettings,
62
62
  type UserPreferenceSettingsView,
63
+ type AccountCredential,
63
64
  type SubagentConfiguration,
64
65
  type SkillMetadata,
65
66
  type PluginReloadResult,
@@ -838,6 +839,25 @@ export class AgentBridge {
838
839
  if (entry) {
839
840
  this.pendingPermissions.delete(p.requestId);
840
841
  entry.resolve(p.decision);
842
+ // 不带反馈的拒绝 = 用户关掉了确认卡(关闭键 / Esc),本轮在用户这边就
843
+ // 结束:与桌面端 / VS Code / JetBrains 的 `denied → abortMessage()` 是
844
+ // 同一条语义,也与 Claude Code 的 `cancelAndAbort`(无 feedback 直接
845
+ // abort 整轮)一致。**带 message 的拒绝不中断**——那是用户给出理由后
846
+ // 拒绝这一次工具调用,模型要照反馈继续或改问(spec
847
+ // ui/confirm-ui.md 场景 2 / 5、remote/remote-web.md 场景 24)。
848
+ // 中断本身不阻塞回包,失败(会话正被重建 / 已销毁)也不影响这次拒绝
849
+ // 已经生效,因此静默。
850
+ const rejectionFeedback = p.decision?.message?.trim();
851
+ if (
852
+ p.decision?.behavior === "deny" &&
853
+ !rejectionFeedback &&
854
+ entry.sessionId
855
+ ) {
856
+ const session = this.sessions.get(entry.sessionId);
857
+ if (session && !session.transitioning) {
858
+ void session.agent.abortMessage();
859
+ }
860
+ }
841
861
  }
842
862
  }
843
863
  }
@@ -1345,11 +1365,12 @@ export class AgentBridge {
1345
1365
  */
1346
1366
  private emitContextUsage(agent: Agent): void {
1347
1367
  const tokens = agent.latestTotalTokens;
1348
- const max = agent.getMaxInputTokens();
1368
+ const max = agent.getContextWindow();
1369
+ const maxOutput = agent.getMaxOutputTokens?.() ?? 32_000;
1349
1370
  if (tokens > 0 && max > 0) {
1350
1371
  this.emit(
1351
1372
  "contextUsage",
1352
- remoteWire.contextUsage(tokens, max),
1373
+ remoteWire.contextUsage(tokens, max, maxOutput),
1353
1374
  agent.sessionId,
1354
1375
  );
1355
1376
  }
@@ -1364,9 +1385,10 @@ export class AgentBridge {
1364
1385
  */
1365
1386
  private contextUsagePercentOf(agent: Agent): number | undefined {
1366
1387
  const tokens = agent.latestTotalTokens;
1367
- const max = agent.getMaxInputTokens();
1388
+ const max = agent.getContextWindow();
1389
+ const maxOutput = agent.getMaxOutputTokens?.() ?? 32_000;
1368
1390
  if (tokens > 0 && max > 0) {
1369
- return percentOf(tokens, max);
1391
+ return percentOf(tokens, max, maxOutput);
1370
1392
  }
1371
1393
  return undefined;
1372
1394
  }
@@ -2790,20 +2812,77 @@ export class AgentBridge {
2790
2812
  }
2791
2813
 
2792
2814
  // ── Auth (global) ────────────────────────────────────────────
2815
+ /**
2816
+ * 认证状态 + **凭据形态**(spec desktop-account.md「账户卡片 · 认证方式」)。
2817
+ *
2818
+ * `isAuthenticated` 只回答「有没有 SSO token」;宿主据此渲染卡片与发送入口会
2819
+ * 把「key 写在 settings.json 里」的人判成没有凭据(卡片显示登录按钮、输入框
2820
+ * 被锁)。故同一次查询里一并给出 `credential`,优先级与 SDK 的
2821
+ * `resolveGatewayConfig` 对齐:企业账号 > 自定义网关 > 无凭据。
2822
+ */
2793
2823
  private async getAuthStatus(): Promise<{
2794
2824
  isAuthenticated: boolean;
2825
+ credential: AccountCredential;
2795
2826
  user: { id: string; email?: string } | undefined;
2796
2827
  serverUrl: string;
2797
2828
  }> {
2798
2829
  const authService = AuthService.getInstance();
2799
2830
  await authService.checkAndRefreshTokenIfNeeded();
2831
+ const isAuthenticated = authService.isSSOAuthenticated();
2800
2832
  return {
2801
- isAuthenticated: authService.isSSOAuthenticated(),
2833
+ isAuthenticated,
2834
+ credential: this.resolveCredential(isAuthenticated),
2802
2835
  user: authService.getAuthUser(),
2803
2836
  serverUrl: authService.getServerUrl(),
2804
2837
  };
2805
2838
  }
2806
2839
 
2840
+ /**
2841
+ * 凭据形态。**判据只有这一处**——消费方(宿主 / webview)不得自己读配置推断。
2842
+ *
2843
+ * 网关层的取值顺序与 `resolveGatewayConfig` 一致:
2844
+ * - 无 SSO:先问一个活着的会话的 `getGatewayConfig()`(与真实请求同源,已合并
2845
+ * 用户 / 项目 / 远端三层 `env`),没有会话时退回「用户级 settings.json 的
2846
+ * `env`(`loadUserConfigEnv`)→ OS 环境变量」。
2847
+ * - 有 SSO:**不能**用 `getGatewayConfig()`(它此时返回 SSO 凭据),回落的那份
2848
+ * 网关只能从配置层读——卡片要靠它给「退出登录」项写后果。
2849
+ *
2850
+ * 注意 `setEnvironmentVars` 只把 `WAVE_SERVER_URL` 镜像进 `process.env`,网关
2851
+ * 凭据留在会话快照里,所以只看 `process.env` 会把配好的人判成无凭据。
2852
+ */
2853
+ private resolveCredential(isAuthenticated: boolean): AccountCredential {
2854
+ const gateway = isAuthenticated
2855
+ ? this.configuredGatewayCredential()
2856
+ : (this.sessionGatewayCredential() ?? this.configuredGatewayCredential());
2857
+ if (isAuthenticated) {
2858
+ // 企业账号生效中;`gateway` 仅表示「退出登录后会落到的那份」。
2859
+ return gateway ? { kind: "sso", gateway } : { kind: "sso" };
2860
+ }
2861
+ return gateway ? { kind: "gateway", gateway } : { kind: "none" };
2862
+ }
2863
+
2864
+ /** 活着的会话真正解析出来的网关配置(无 SSO 时它就是要发出去的那一份)。 */
2865
+ private sessionGatewayCredential(): { host: string | null } | null {
2866
+ for (const entry of this.sessions.values()) {
2867
+ const gateway = entry.agent.getGatewayConfig();
2868
+ if (gateway.apiKey) return { host: hostOfBaseUrl(gateway.baseURL) };
2869
+ }
2870
+ return null;
2871
+ }
2872
+
2873
+ /**
2874
+ * 配置层的网关凭据:用户级 settings.json 的 `env` 优先于 OS 环境变量(与
2875
+ * `resolveGatewayConfig` 的 `env(settings.json)> process.env` 同序)。
2876
+ */
2877
+ private configuredGatewayCredential(): { host: string | null } | null {
2878
+ const userEnv = loadUserConfigEnv();
2879
+ const pick = (key: "WAVE_API_KEY" | "WAVE_BASE_URL"): string | undefined =>
2880
+ userEnv[key]?.trim() || process.env[key]?.trim() || undefined;
2881
+ const apiKey = pick("WAVE_API_KEY");
2882
+ if (!apiKey) return null;
2883
+ return { host: hostOfBaseUrl(pick("WAVE_BASE_URL")) };
2884
+ }
2885
+
2807
2886
  private async getAccountInfo(): Promise<{
2808
2887
  /**
2809
2888
  * 计费结论(套餐两根条 + 到期日 + 降级原因),形状见 codechat `GET /api/v1/account` → `billing`
@@ -3285,11 +3364,12 @@ export class AgentBridge {
3285
3364
  // request (fresh total) and on session restore (the persisted total),
3286
3365
  // so the webview button shows real usage without waiting for a turn —
3287
3366
  // aligned with the CLI, which reads the same callback (spec 场景 5/6).
3288
- const max = ctx.agent?.getMaxInputTokens() ?? 0;
3367
+ const max = ctx.agent?.getContextWindow() ?? 0;
3368
+ const maxOutput = ctx.agent?.getMaxOutputTokens?.() ?? 32_000;
3289
3369
  if (max > 0) {
3290
3370
  this.emit(
3291
3371
  "contextUsage",
3292
- remoteWire.contextUsage(tokens, max),
3372
+ remoteWire.contextUsage(tokens, max, maxOutput),
3293
3373
  ctx.registeredSessionId,
3294
3374
  );
3295
3375
  }
@@ -3355,6 +3435,13 @@ export class AgentBridge {
3355
3435
  onMcpServersChange: (servers: McpServerStatus[]) => {
3356
3436
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
3357
3437
  },
3438
+ onQuotaHeaders: (headers: Record<string, string>) => {
3439
+ // 额度响应头(codechat proxy 每次模型调用都发,spec:
3440
+ // desktop/desktop-account.md「额度响应头」):桌面端宿主据此刷新账户卡片的
3441
+ // 用量读数,是 60s 轮询删除后的主要刷新源。原样透传 —— 字段解析与映射都
3442
+ // 在消费方。不进 remoteWire:远程页不渲染账户卡片。
3443
+ this.emit("quotaHeaders", { headers }, ctx.registeredSessionId);
3444
+ },
3358
3445
  onNotificationMessageAdded: (params) => {
3359
3446
  // The LAST matching message, not the first: one task can notify more
3360
3447
  // than once under the same task id (a workflow warns about its size
@@ -3488,6 +3575,19 @@ function isSessionRecoveryError(error: unknown): boolean {
3488
3575
  );
3489
3576
  }
3490
3577
 
3578
+ /**
3579
+ * `baseURL` → 网关主机名(`api.example.com`)。缺省 / 不可解析一律 null(卡片读
3580
+ * 「默认端点」)——这里只做展示,不做任何可用性判断。
3581
+ */
3582
+ function hostOfBaseUrl(baseURL: string | undefined): string | null {
3583
+ if (!baseURL) return null;
3584
+ try {
3585
+ return new URL(baseURL).host || null;
3586
+ } catch {
3587
+ return null;
3588
+ }
3589
+ }
3590
+
3491
3591
  /** Read a text file, returning "" when it does not exist. */
3492
3592
  async function readTextFileSafe(filePath: string): Promise<string> {
3493
3593
  try {
@@ -0,0 +1,22 @@
1
+ /**
2
+ * 首次启动的进度上报(spec `ui/stdio-transport.md`「首次启动的进度文案」)。
3
+ *
4
+ * 走的是同一条 stdout 上的 JSON-RPC 通知通道(一条一行),只是没有 `sessionId`
5
+ * ——这些阶段发生在任何会话存在之前。宿主侧 `NotificationRouter` 的全局处理器
6
+ * 收下它,把它变成加载动画下方那一行文案。
7
+ *
8
+ * **旁路**:通知发不出去(stdout 已关、宿主已经退出)不是启动错误,写失败一律
9
+ * 吞掉;上报也不参与任何完成条件,没有宿主消费时启动照旧。
10
+ */
11
+
12
+ import type { StartupStage } from "wave-agent-sdk";
13
+
14
+ export function reportStartupStage(stage: StartupStage): void {
15
+ try {
16
+ process.stdout.write(
17
+ `${JSON.stringify({ method: "startupStage", params: { stage } })}\n`,
18
+ );
19
+ } catch {
20
+ // 见文件头:上报失败不影响启动。
21
+ }
22
+ }
package/src/stdio-cli.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { ensureRuntimeDeps, setTelemetryStdoutProtected } from "wave-agent-sdk";
10
10
  import { StdioServer } from "./stdio/stdioServer.js";
11
+ import { reportStartupStage } from "./stdio/startupStage.js";
11
12
  import { createRemoteControlSinkFactory } from "./remote/remoteControlSink.js";
12
13
  import { logger } from "./utils/logger.js";
13
14
 
@@ -64,7 +65,11 @@ export async function startStdioCli(): Promise<void> {
64
65
  // answering any RPC (see cli.tsx). Blocking here is what closes the race: the
65
66
  // host's `initialize` — and with it the webview's initial state — only lands
66
67
  // once a pasted image can be handled. Failures are non-fatal.
67
- await ensureRuntimeDeps();
68
+ //
69
+ // The ~25MB this can pull is the longest silent stretch of a first launch, so
70
+ // it is reported (a warm cache reports nothing: `ensureRuntimeDeps` only
71
+ // fires `onStage` when something is actually missing).
72
+ await ensureRuntimeDeps({ onStage: reportStartupStage });
68
73
  // Registered here (stdio mode only): the interactive CLI keeps Node's
69
74
  // default behavior so the terminal shows the crash stack directly.
70
75
  process.on("uncaughtException", (error) =>
@@ -79,6 +84,9 @@ export async function startStdioCli(): Promise<void> {
79
84
  // same machine — is what serves a paired phone.
80
85
  createRemoteSink: createRemoteControlSinkFactory(process.cwd()),
81
86
  });
87
+ // Last startup stage: from here the process is up and what the user is waiting
88
+ // for is `initialize` (and, on the restore path, the transcript replay).
89
+ reportStartupStage("startingSession");
82
90
  server.start();
83
91
 
84
92
  // Keep the process alive — readline on stdin handles the event loop.
@@ -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();