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.
- package/README.md +3 -2
- package/dist/bundle/wave.mjs +656 -642
- package/package.json +5 -7
- package/src/cli.tsx +50 -0
- package/src/components/App.tsx +13 -13
- package/src/components/ChatInterface.tsx +4 -2
- package/src/components/CommandSelector.tsx +6 -3
- package/src/components/FileSelector.tsx +19 -8
- package/src/components/InputBox.tsx +6 -3
- package/src/components/Notifications.tsx +6 -3
- package/src/contexts/useChat.tsx +41 -10
- package/src/hooks/useExitSignals.ts +83 -0
- package/src/hooks/useRemoteControl.ts +16 -4
- package/src/managers/inputReducer.ts +1 -1
- package/src/remote/relayClient.ts +122 -43
- package/src/remote/remoteControlCliHost.ts +20 -4
- package/src/remote/remoteControlService.ts +35 -2
- package/src/remote/remoteNotifications.ts +26 -5
- package/src/stdio/agentBridge.ts +107 -7
- package/src/stdio/startupStage.ts +22 -0
- package/src/stdio-cli.ts +9 -1
- package/src/utils/oneLine.ts +10 -0
|
@@ -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();
|
|
@@ -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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
...
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
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: (
|
|
99
|
-
|
|
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 }),
|
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
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?.
|
|
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
|
-
|
|
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();
|