wave-code 1.5.1 → 1.6.1
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/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +735 -686
- package/package.json +8 -10
- package/src/cli.tsx +59 -3
- package/src/components/App.tsx +13 -13
- package/src/components/ChatInterface.tsx +4 -2
- package/src/components/InputBox.tsx +6 -3
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/Notifications.tsx +6 -3
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/contexts/useChat.tsx +88 -43
- package/src/daemon/commands.ts +266 -44
- package/src/daemon/daemonLog.ts +132 -0
- package/src/daemon/socketPath.ts +123 -0
- package/src/daemon-cli.ts +19 -6
- package/src/hooks/useExitSignals.ts +83 -0
- package/src/hooks/useRemoteControl.ts +16 -4
- package/src/index.ts +6 -0
- package/src/print-cli.ts +20 -10
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +5 -4
- package/src/remote/notificationCoalescer.ts +2 -2
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +46 -8
- package/src/remote/remoteControlService.ts +75 -18
- package/src/remote/remoteNotifications.ts +31 -9
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +407 -72
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/startupStage.ts +22 -0
- package/src/stdio/uiPayload.ts +49 -0
- package/src/stdio-cli.ts +9 -1
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +1 -1
- package/src/utils/worktree.ts +222 -16
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -39,7 +39,9 @@ import {
|
|
|
39
39
|
listAllSessions,
|
|
40
40
|
readRecentWorkdirs,
|
|
41
41
|
rememberRecentWorkdir,
|
|
42
|
+
forgetRecentWorkdir,
|
|
42
43
|
setSessionCustomTitle,
|
|
44
|
+
readSessionWorkdir,
|
|
43
45
|
searchFiles,
|
|
44
46
|
generateRandomName,
|
|
45
47
|
getDefaultRemoteBranch,
|
|
@@ -58,6 +60,7 @@ import {
|
|
|
58
60
|
updateUserPreferenceSettings,
|
|
59
61
|
type UserPreferenceSettings,
|
|
60
62
|
type UserPreferenceSettingsView,
|
|
63
|
+
type AccountCredential,
|
|
61
64
|
type SubagentConfiguration,
|
|
62
65
|
type SkillMetadata,
|
|
63
66
|
type PluginReloadResult,
|
|
@@ -74,6 +77,7 @@ import {
|
|
|
74
77
|
revokeArtifact,
|
|
75
78
|
searchArtifactMembers,
|
|
76
79
|
type ArtifactShareMode,
|
|
80
|
+
type UserImage,
|
|
77
81
|
} from "wave-agent-sdk";
|
|
78
82
|
import {
|
|
79
83
|
type ClientNotificationMethod,
|
|
@@ -95,7 +99,7 @@ import {
|
|
|
95
99
|
realpathSync,
|
|
96
100
|
statSync,
|
|
97
101
|
} from "node:fs";
|
|
98
|
-
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
102
|
+
import { mkdir, open, readFile, stat, writeFile } from "node:fs/promises";
|
|
99
103
|
import { homedir, tmpdir } from "node:os";
|
|
100
104
|
import { basename, dirname, extname, join } from "node:path";
|
|
101
105
|
import {
|
|
@@ -103,6 +107,8 @@ import {
|
|
|
103
107
|
deriveWorktreeInfo,
|
|
104
108
|
getWorktreeChanges,
|
|
105
109
|
removeWorktree,
|
|
110
|
+
worktreeCreationBranch,
|
|
111
|
+
type DerivedWorktree,
|
|
106
112
|
} from "../utils/worktree.js";
|
|
107
113
|
import { logger } from "../utils/logger.js";
|
|
108
114
|
import {
|
|
@@ -118,6 +124,8 @@ import {
|
|
|
118
124
|
} from "../remote/remoteNotifications.js";
|
|
119
125
|
import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
|
|
120
126
|
import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
|
|
127
|
+
import { readImagePreview } from "../remote/imagePreview.js";
|
|
128
|
+
import { readTextPreview } from "../remote/textPreview.js";
|
|
121
129
|
import type { RemoteControlController } from "../remote/remoteControlService.js";
|
|
122
130
|
import {
|
|
123
131
|
hostSlashCommandsFor,
|
|
@@ -209,6 +217,20 @@ interface SessionEntry {
|
|
|
209
217
|
* header when the session is re-attached from disk after a daemon restart.
|
|
210
218
|
*/
|
|
211
219
|
createdWorkdir: string;
|
|
220
|
+
/**
|
|
221
|
+
* The branch the worktree in `createdWorkdir` was CREATED on
|
|
222
|
+
* (`worktree-<name>`), recorded once at initialize alongside it. Undefined
|
|
223
|
+
* when the session was not created in a linked git worktree.
|
|
224
|
+
*
|
|
225
|
+
* Removal needs this snapshot because the worktree itself stops being a
|
|
226
|
+
* record of it: `deriveWorktreeInfo` reads the worktree's HEAD, which an
|
|
227
|
+
* in-session `git checkout <other-branch>` moves to a branch that belongs to
|
|
228
|
+
* the user (and may hold commits reachable from nowhere else). Deleting that
|
|
229
|
+
* one is the bug this snapshot prevents, and it is unrecoverable once
|
|
230
|
+
* deleted — `git worktree list` has no memory of the original checkout
|
|
231
|
+
* either (it reports each worktree's current HEAD too).
|
|
232
|
+
*/
|
|
233
|
+
createdWorktreeBranch?: string;
|
|
212
234
|
/**
|
|
213
235
|
* True while `updateConfig` is tearing the session's agent down and rebuilding
|
|
214
236
|
* it from the updated config — the entry stays in the registry (so the session
|
|
@@ -280,6 +302,10 @@ function direntType(parentDir: string, dirent: Dirent): "dir" | "file" | null {
|
|
|
280
302
|
|
|
281
303
|
export class AgentBridge {
|
|
282
304
|
private sessions = new Map<string, SessionEntry>();
|
|
305
|
+
/** 用户偏好写盘的串行队列(`updateUserSettings`):stdio 请求是并发处理的,而写盘
|
|
306
|
+
* 是「同步读 → await 异步写」的读-改-写,两笔并发写会互相覆盖(丢键)。见该方法
|
|
307
|
+
* 的注释与 spec core/agent-config.md「设置页控件的保存时机」场景 9。 */
|
|
308
|
+
private userSettingsWriteQueue: Promise<unknown> = Promise.resolve();
|
|
283
309
|
/** Pending approval requests, keyed by requestId. Stored with the resolve +
|
|
284
310
|
* context so a re-attached client can list and respond to them (daemon mode:
|
|
285
311
|
* approvals outlive any single connection). */
|
|
@@ -297,13 +323,13 @@ export class AgentBridge {
|
|
|
297
323
|
* a keystroke pause, so a newer request makes the previous one worthless —
|
|
298
324
|
* superseding it here is what keeps "one in-flight request per editor" true
|
|
299
325
|
* even when the host cannot cancel (spec
|
|
300
|
-
* docs/specs/
|
|
326
|
+
* docs/specs/ide/code-completion.md: 同一时刻只允许一个补全请求在途).
|
|
301
327
|
*/
|
|
302
328
|
private inFlightCompletions = new Map<string, AbortController>();
|
|
303
329
|
/**
|
|
304
330
|
* In-flight next-edit calls, same one-per-session rule and same reason as
|
|
305
331
|
* completions. It also participates in the cross-capability exclusion from
|
|
306
|
-
* `docs/specs/
|
|
332
|
+
* `docs/specs/ide/next-edit-suggestion.md`: completion and next-edit never hold
|
|
307
333
|
* an in-flight request at the same time, so one of them cannot answer into a
|
|
308
334
|
* buffer the other has already rewritten.
|
|
309
335
|
*/
|
|
@@ -407,7 +433,7 @@ export class AgentBridge {
|
|
|
407
433
|
return this.sendMessage(
|
|
408
434
|
p as unknown as {
|
|
409
435
|
text: string;
|
|
410
|
-
images?:
|
|
436
|
+
images?: UserImage[];
|
|
411
437
|
force?: boolean;
|
|
412
438
|
},
|
|
413
439
|
sessionId,
|
|
@@ -430,7 +456,7 @@ export class AgentBridge {
|
|
|
430
456
|
return this.updateQueuedMessage(
|
|
431
457
|
p.id as string,
|
|
432
458
|
p.text as string,
|
|
433
|
-
p.images as
|
|
459
|
+
p.images as UserImage[] | undefined,
|
|
434
460
|
sessionId,
|
|
435
461
|
);
|
|
436
462
|
case "deleteQueuedMessageById":
|
|
@@ -459,6 +485,19 @@ export class AgentBridge {
|
|
|
459
485
|
return this.getPermissionMode(sessionId);
|
|
460
486
|
case "getPlanFile":
|
|
461
487
|
return this.getPlanFile(sessionId);
|
|
488
|
+
// 远控页按需取图(spec「点消息里的图片路径看原图」):路径来自消息正文、
|
|
489
|
+
// 由用户在页面上点出来。会话在这里只用来确认调用方已经握手到某条活会话;
|
|
490
|
+
// 判型(按解码后的真实容器)与压到单帧预算内都在 `imagePreview.ts` 里做,
|
|
491
|
+
// 前台 CLI 那个宿主共用同一份实现。
|
|
492
|
+
case "getImagePreview":
|
|
493
|
+
this.requireSessionForRead(sessionId);
|
|
494
|
+
return readImagePreview(typeof p.path === "string" ? p.path : "");
|
|
495
|
+
|
|
496
|
+
case "getTextPreview":
|
|
497
|
+
// 点消息里的文件路径看内容(spec remote/remote-web.md「点消息里的文件
|
|
498
|
+
// 路径看内容」):与取图同一条按需读取通道,判型换成「读出来是不是文本」。
|
|
499
|
+
this.requireSessionForRead(sessionId);
|
|
500
|
+
return readTextPreview(typeof p.path === "string" ? p.path : "");
|
|
462
501
|
|
|
463
502
|
// ── MCP ──
|
|
464
503
|
case "getMcpServers":
|
|
@@ -701,6 +740,12 @@ export class AgentBridge {
|
|
|
701
740
|
case "rememberRecentWorkdir":
|
|
702
741
|
rememberRecentWorkdir(p.workdir as string);
|
|
703
742
|
return null;
|
|
743
|
+
// 移除(桌面端最近目录项上的「从列表移除」)。页面没有这个入口,所以它
|
|
744
|
+
// 不在远控白名单里;桌面端移除时走自己的 stdio/隧道,落在这台机器上,
|
|
745
|
+
// 否则下一次 `getRecentWorkdirs` 会把条目带回来。
|
|
746
|
+
case "forgetRecentWorkdir":
|
|
747
|
+
forgetRecentWorkdir(p.workdir as string);
|
|
748
|
+
return null;
|
|
704
749
|
case "listGitBranches":
|
|
705
750
|
return this.listGitBranches(p.workdir as string | undefined);
|
|
706
751
|
case "createWorktree":
|
|
@@ -794,6 +839,25 @@ export class AgentBridge {
|
|
|
794
839
|
if (entry) {
|
|
795
840
|
this.pendingPermissions.delete(p.requestId);
|
|
796
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
|
+
}
|
|
797
861
|
}
|
|
798
862
|
}
|
|
799
863
|
}
|
|
@@ -834,7 +898,12 @@ export class AgentBridge {
|
|
|
834
898
|
const options: AgentOptions = {
|
|
835
899
|
callbacks,
|
|
836
900
|
logger,
|
|
837
|
-
|
|
901
|
+
// Only a restore can name a directory other than the caller's, and the
|
|
902
|
+
// lookup must stay off the fresh-session path: hosts expect
|
|
903
|
+
// `Agent.create` to be under way as soon as `initialize` is entered.
|
|
904
|
+
workdir: params.restoreSessionId
|
|
905
|
+
? await this.restoreWorkdirFor(params, params.restoreSessionId)
|
|
906
|
+
: params.workdir,
|
|
838
907
|
restoreSessionId: params.restoreSessionId,
|
|
839
908
|
apiKey: params.apiKey,
|
|
840
909
|
baseURL: params.baseURL,
|
|
@@ -860,30 +929,106 @@ export class AgentBridge {
|
|
|
860
929
|
|
|
861
930
|
// Record where the session was created (see SessionEntry.createdWorkdir).
|
|
862
931
|
// A fresh session's initialize workdir IS the creation dir. A from-disk
|
|
863
|
-
// re-attach
|
|
864
|
-
//
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
// readable; otherwise the client cwd is the best available anchor.
|
|
932
|
+
// re-attach was already anchored on the session's own directory by
|
|
933
|
+
// `restoreWorkdirFor` when the agent was created just above, so the agent's
|
|
934
|
+
// transcript is the recorded one: the header read here confirms it and
|
|
935
|
+
// supplies the creation branch.
|
|
868
936
|
let createdWorkdir: string;
|
|
937
|
+
let transcriptBranch: string | undefined;
|
|
869
938
|
if (params.restoreSessionId) {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
agent.currentWorkdir;
|
|
939
|
+
const header = await readTranscriptHeader(agent.sessionFilePath);
|
|
940
|
+
createdWorkdir = header.workdir ?? agent.currentWorkdir;
|
|
941
|
+
transcriptBranch = header.gitBranch;
|
|
874
942
|
} else {
|
|
875
943
|
createdWorkdir = params.workdir ?? agent.currentWorkdir;
|
|
876
944
|
}
|
|
877
945
|
|
|
946
|
+
const worktree = deriveWorktreeInfo(createdWorkdir);
|
|
947
|
+
const createdWorktreeBranch = this.creationBranchOf(worktree, {
|
|
948
|
+
transcriptBranch,
|
|
949
|
+
restored: Boolean(params.restoreSessionId),
|
|
950
|
+
});
|
|
951
|
+
|
|
878
952
|
this.sessions.set(agent.sessionId, {
|
|
879
953
|
agent,
|
|
880
954
|
// Keep storedConfig.workdir in sync: updateConfig rebuilds the agent from
|
|
881
955
|
// storedConfig, so the rebuild must stay anchored on the creation dir too.
|
|
882
956
|
storedConfig: { ...params, workdir: createdWorkdir },
|
|
883
957
|
createdWorkdir,
|
|
958
|
+
createdWorktreeBranch,
|
|
884
959
|
});
|
|
885
960
|
|
|
886
|
-
return this.initializeReply(agent, createdWorkdir);
|
|
961
|
+
return this.initializeReply(agent, createdWorkdir, worktree);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* The directory a session being restored belongs to.
|
|
966
|
+
*
|
|
967
|
+
* `initialize`'s `workdir` is the **client's** directory, not the session's.
|
|
968
|
+
* For a fresh session the two are the same by definition, but a from-disk
|
|
969
|
+
* re-attach (daemon restarted — killed / CLI 升级重启 / reboot — then
|
|
970
|
+
* `status`/`send`/`wait` re-hosts the session) sends whatever cwd the
|
|
971
|
+
* re-attaching client happens to run in. Anchoring the agent there silently
|
|
972
|
+
* drops the session's own directory: a `create --worktree` session keeps the
|
|
973
|
+
* cwd of whoever attached first, its next turn (and its Bash tool) runs in the
|
|
974
|
+
* host repo instead of the worktree, and `destroy --remove-worktree` then
|
|
975
|
+
* resolves the wrong tree.
|
|
976
|
+
*
|
|
977
|
+
* The transcript's `metadata` header records the creation dir and is
|
|
978
|
+
* append-only (a Bash `cd`, an EnterWorktree and a re-host from another cwd
|
|
979
|
+
* all leave it alone), so it wins whenever it is readable **and** the
|
|
980
|
+
* directory still exists. A directory that is gone (worktree deleted by hand,
|
|
981
|
+
* project moved) is reported and the caller's directory is used instead —
|
|
982
|
+
* degenerate, but it is the only anchor left and must not abort the attach.
|
|
983
|
+
*/
|
|
984
|
+
private async restoreWorkdirFor(
|
|
985
|
+
params: InitializeParams,
|
|
986
|
+
restoreSessionId: string,
|
|
987
|
+
): Promise<string | undefined> {
|
|
988
|
+
const callerWorkdir = params.workdir ?? process.cwd();
|
|
989
|
+
const recorded = await readSessionWorkdir(restoreSessionId, callerWorkdir);
|
|
990
|
+
if (!recorded || recorded === callerWorkdir) {
|
|
991
|
+
return params.workdir;
|
|
992
|
+
}
|
|
993
|
+
if (!(await isDirectory(recorded))) {
|
|
994
|
+
logger?.warn(
|
|
995
|
+
`Session ${restoreSessionId} was created in ${recorded}, which no longer exists; restoring it in ${callerWorkdir} instead`,
|
|
996
|
+
);
|
|
997
|
+
return params.workdir;
|
|
998
|
+
}
|
|
999
|
+
return recorded;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* The branch the session's worktree was created on — recorded once, at
|
|
1004
|
+
* initialize (see `SessionEntry.createdWorktreeBranch`).
|
|
1005
|
+
*
|
|
1006
|
+
* Only a session created inside a linked git worktree has one: a session in
|
|
1007
|
+
* the main working tree returns undefined, and so does a WorktreeCreate
|
|
1008
|
+
* hook-managed worktree (its branches are not git's).
|
|
1009
|
+
*
|
|
1010
|
+
* Records, best first:
|
|
1011
|
+
*
|
|
1012
|
+
* 1. the transcript header's `gitBranch` — the creation-time record a
|
|
1013
|
+
* re-attached session recovers after a daemon restart;
|
|
1014
|
+
* 2. the path layout `<repoRoot>/.wave/worktrees/<name>`
|
|
1015
|
+
* (`worktreeCreationBranch`) — the same answer, from the one thing that
|
|
1016
|
+
* always survives;
|
|
1017
|
+
* 3. the worktree's HEAD — fresh sessions only, where nothing has had the
|
|
1018
|
+
* chance to check anything else out yet. Never for a re-attach: by then
|
|
1019
|
+
* HEAD may be a branch the user checked out, and that is the branch
|
|
1020
|
+
* removal must leave alone.
|
|
1021
|
+
*/
|
|
1022
|
+
private creationBranchOf(
|
|
1023
|
+
worktree: DerivedWorktree | undefined,
|
|
1024
|
+
options: { transcriptBranch?: string; restored: boolean },
|
|
1025
|
+
): string | undefined {
|
|
1026
|
+
if (!worktree || worktree.hookBased) return undefined;
|
|
1027
|
+
return (
|
|
1028
|
+
options.transcriptBranch ||
|
|
1029
|
+
worktreeCreationBranch(worktree) ||
|
|
1030
|
+
(options.restored ? undefined : worktree.branch || undefined)
|
|
1031
|
+
);
|
|
887
1032
|
}
|
|
888
1033
|
|
|
889
1034
|
/**
|
|
@@ -906,6 +1051,9 @@ export class AgentBridge {
|
|
|
906
1051
|
private initializeReply(
|
|
907
1052
|
agent: Agent,
|
|
908
1053
|
sessionCwd: string,
|
|
1054
|
+
// Derived once by the caller: this reply and the creation-branch snapshot
|
|
1055
|
+
// both need it, and it reads config files.
|
|
1056
|
+
worktree = deriveWorktreeInfo(sessionCwd),
|
|
909
1057
|
): {
|
|
910
1058
|
sessionId: string;
|
|
911
1059
|
currentWorkdir: string;
|
|
@@ -915,7 +1063,6 @@ export class AgentBridge {
|
|
|
915
1063
|
permissionMode: PermissionMode;
|
|
916
1064
|
latestTotalTokens: number;
|
|
917
1065
|
} {
|
|
918
|
-
const worktree = deriveWorktreeInfo(sessionCwd);
|
|
919
1066
|
return {
|
|
920
1067
|
sessionId: agent.sessionId,
|
|
921
1068
|
currentWorkdir: agent.currentWorkdir,
|
|
@@ -957,16 +1104,35 @@ export class AgentBridge {
|
|
|
957
1104
|
* a removal that threw — the caller only ever needs to know whether the
|
|
958
1105
|
* directory is gone.
|
|
959
1106
|
*
|
|
960
|
-
* The
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
1107
|
+
* **The worktree removal is not awaited** (2026-11-04). The reply goes out
|
|
1108
|
+
* once the registry entry is gone and `agent.destroy()` has settled; removing
|
|
1109
|
+
* the directory then runs in the background and the reply says
|
|
1110
|
+
* `worktreeRemoval: "pending"`. Reason: the remote page times every request
|
|
1111
|
+
* out at 20s (`REQUEST_TIMEOUT_MS` in `remote/shim.js`), while deleting a
|
|
1112
|
+
* worktree that contains `node_modules` on Windows is a **minute-scale**
|
|
1113
|
+
* operation — `git worktree remove` cannot delete pnpm's junctions (it exits
|
|
1114
|
+
* 0 and leaves the directory), so it falls back to a recursive `fs.rmSync`.
|
|
1115
|
+
* Awaiting that made every phone-side delete report a timeout even though the
|
|
1116
|
+
* session was already gone. The desktop host has always worked this way
|
|
1117
|
+
* (`handleDeleteSession` fire-and-forgets both steps and refreshes the UI
|
|
1118
|
+
* immediately). The ordering above still holds: the background removal hangs
|
|
1119
|
+
* off `agent.destroy()`'s promise, it is not concurrent with it.
|
|
1120
|
+
*
|
|
1121
|
+
* The return shape grew from `null` to `{worktreeRemoved}` to
|
|
1122
|
+
* `{worktreeRemoved, worktreeRemoval}`; callers that pass no params (the
|
|
1123
|
+
* CLI's own `wave daemon destroy`) read nothing and are unaffected. The CLI's
|
|
1124
|
+
* `--remove-worktree` keeps its own client-side path (getSessionInfo +
|
|
1125
|
+
* removeWorktree + destroy) — deliberately unchanged.
|
|
964
1126
|
*/
|
|
965
1127
|
private async destroy(
|
|
966
1128
|
sessionId?: string,
|
|
967
1129
|
params?: { removeWorktree?: boolean },
|
|
968
|
-
): Promise<{
|
|
1130
|
+
): Promise<{
|
|
1131
|
+
worktreeRemoved: boolean;
|
|
1132
|
+
worktreeRemoval: "none" | "pending";
|
|
1133
|
+
}> {
|
|
969
1134
|
let createdWorkdir: string | undefined;
|
|
1135
|
+
let createdWorktreeBranch: string | undefined;
|
|
970
1136
|
if (sessionId) {
|
|
971
1137
|
const entry = this.sessions.get(sessionId);
|
|
972
1138
|
if (entry) {
|
|
@@ -994,15 +1160,43 @@ export class AgentBridge {
|
|
|
994
1160
|
// for the same reason).
|
|
995
1161
|
this.sessions.delete(sessionId);
|
|
996
1162
|
createdWorkdir = entry.createdWorkdir;
|
|
1163
|
+
createdWorktreeBranch = entry.createdWorktreeBranch;
|
|
997
1164
|
await entry.agent.destroy();
|
|
998
1165
|
}
|
|
999
1166
|
}
|
|
1000
1167
|
|
|
1001
|
-
let
|
|
1002
|
-
if (
|
|
1003
|
-
|
|
1168
|
+
let worktreeRemoval: "none" | "pending" = "none";
|
|
1169
|
+
if (
|
|
1170
|
+
params?.removeWorktree === true &&
|
|
1171
|
+
createdWorkdir &&
|
|
1172
|
+
// fs-only and cheap (no git): deciding "is there a worktree at all" before
|
|
1173
|
+
// the reply is what keeps `none` honest for plain sessions instead of
|
|
1174
|
+
// reporting a removal that was never going to happen.
|
|
1175
|
+
deriveWorktreeInfo(createdWorkdir)
|
|
1176
|
+
) {
|
|
1177
|
+
// Backgrounded, not awaited: see the note above. The `.then` is the
|
|
1178
|
+
// ordering guarantee — `removeSessionWorktree` starts only once
|
|
1179
|
+
// `agent.destroy()` (awaited above) settled.
|
|
1180
|
+
worktreeRemoval = "pending";
|
|
1181
|
+
void this.removeSessionWorktree(createdWorkdir, createdWorktreeBranch)
|
|
1182
|
+
.then((removed) => {
|
|
1183
|
+
if (!removed) {
|
|
1184
|
+
logger.warn(
|
|
1185
|
+
`destroy: session ${sessionId ?? "(none)"} deleted, but its ` +
|
|
1186
|
+
`worktree at ${createdWorkdir} survived removal`,
|
|
1187
|
+
);
|
|
1188
|
+
}
|
|
1189
|
+
})
|
|
1190
|
+
.catch((e: unknown) => {
|
|
1191
|
+
logger.warn(
|
|
1192
|
+
`destroy: background worktree removal failed for ` +
|
|
1193
|
+
`${createdWorkdir}: ${(e as Error).message}`,
|
|
1194
|
+
);
|
|
1195
|
+
});
|
|
1004
1196
|
}
|
|
1005
|
-
|
|
1197
|
+
// Pending means "the directory may still be there when this reply lands, and
|
|
1198
|
+
// that is not a failure" — the caller must not read it as "removal failed".
|
|
1199
|
+
return { worktreeRemoved: false, worktreeRemoval };
|
|
1006
1200
|
}
|
|
1007
1201
|
|
|
1008
1202
|
/**
|
|
@@ -1012,15 +1206,33 @@ export class AgentBridge {
|
|
|
1012
1206
|
* both fail, and for a hook-based worktree with no WorktreeRemove hook it
|
|
1013
1207
|
* deliberately leaves it in place. Never throws — the caller has already
|
|
1014
1208
|
* deleted the session.
|
|
1209
|
+
*
|
|
1210
|
+
* `createdBranch` is the branch the worktree was created on, snapshotted by
|
|
1211
|
+
* the caller. It is **not** `worktree.branch`: that field is the worktree's
|
|
1212
|
+
* current HEAD, which an in-session `git checkout` moves to a branch the user
|
|
1213
|
+
* owns. Falls back to the path layout (`worktreeCreationBranch`) — a
|
|
1214
|
+
* candidate only, `removeWorktree` confirms it against git — and to deleting
|
|
1215
|
+
* no branch at all when neither can name one.
|
|
1015
1216
|
*/
|
|
1016
|
-
private async removeSessionWorktree(
|
|
1217
|
+
private async removeSessionWorktree(
|
|
1218
|
+
sessionDir: string,
|
|
1219
|
+
createdBranch?: string,
|
|
1220
|
+
): Promise<boolean> {
|
|
1017
1221
|
const worktree = deriveWorktreeInfo(sessionDir);
|
|
1018
1222
|
if (!worktree) return false;
|
|
1223
|
+
const branch = createdBranch || worktreeCreationBranch(worktree) || "";
|
|
1224
|
+
if (!branch) {
|
|
1225
|
+
logger.warn(
|
|
1226
|
+
`removeSessionWorktree: cannot tell which branch ${worktree.path} was ` +
|
|
1227
|
+
`created on — keeping every branch instead of deleting the one it ` +
|
|
1228
|
+
`has checked out (${worktree.branch || "detached"})`,
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1019
1231
|
try {
|
|
1020
1232
|
await removeWorktree({
|
|
1021
1233
|
name: "",
|
|
1022
1234
|
path: worktree.path,
|
|
1023
|
-
branch
|
|
1235
|
+
branch,
|
|
1024
1236
|
repoRoot: worktree.repoRoot,
|
|
1025
1237
|
hasUncommittedChanges: false,
|
|
1026
1238
|
hasNewCommits: false,
|
|
@@ -1153,11 +1365,12 @@ export class AgentBridge {
|
|
|
1153
1365
|
*/
|
|
1154
1366
|
private emitContextUsage(agent: Agent): void {
|
|
1155
1367
|
const tokens = agent.latestTotalTokens;
|
|
1156
|
-
const max = agent.
|
|
1368
|
+
const max = agent.getContextWindow();
|
|
1369
|
+
const maxOutput = agent.getMaxOutputTokens?.() ?? 32_000;
|
|
1157
1370
|
if (tokens > 0 && max > 0) {
|
|
1158
1371
|
this.emit(
|
|
1159
1372
|
"contextUsage",
|
|
1160
|
-
remoteWire.contextUsage(tokens, max),
|
|
1373
|
+
remoteWire.contextUsage(tokens, max, maxOutput),
|
|
1161
1374
|
agent.sessionId,
|
|
1162
1375
|
);
|
|
1163
1376
|
}
|
|
@@ -1172,9 +1385,10 @@ export class AgentBridge {
|
|
|
1172
1385
|
*/
|
|
1173
1386
|
private contextUsagePercentOf(agent: Agent): number | undefined {
|
|
1174
1387
|
const tokens = agent.latestTotalTokens;
|
|
1175
|
-
const max = agent.
|
|
1388
|
+
const max = agent.getContextWindow();
|
|
1389
|
+
const maxOutput = agent.getMaxOutputTokens?.() ?? 32_000;
|
|
1176
1390
|
if (tokens > 0 && max > 0) {
|
|
1177
|
-
return percentOf(tokens, max);
|
|
1391
|
+
return percentOf(tokens, max, maxOutput);
|
|
1178
1392
|
}
|
|
1179
1393
|
return undefined;
|
|
1180
1394
|
}
|
|
@@ -1337,6 +1551,7 @@ export class AgentBridge {
|
|
|
1337
1551
|
cwd: workdir,
|
|
1338
1552
|
encoding: "utf8" as const,
|
|
1339
1553
|
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
1554
|
+
windowsHide: true,
|
|
1340
1555
|
};
|
|
1341
1556
|
let branchesRaw: string;
|
|
1342
1557
|
try {
|
|
@@ -1538,12 +1753,18 @@ export class AgentBridge {
|
|
|
1538
1753
|
return getWorktreeChanges(params.path, params.baseBranch);
|
|
1539
1754
|
}
|
|
1540
1755
|
|
|
1756
|
+
/**
|
|
1757
|
+
* `removed` answers "is the directory gone", not "did we ask": removal is
|
|
1758
|
+
* best-effort and leaves the directory (and its branch) behind when git and
|
|
1759
|
+
* the fs fallback both fail — the one thing a caller may not do is print a
|
|
1760
|
+
* success line for that. Callers that ignore it are unaffected.
|
|
1761
|
+
*/
|
|
1541
1762
|
private async removeWorktreeSession(params: {
|
|
1542
1763
|
path: string;
|
|
1543
1764
|
branch: string;
|
|
1544
1765
|
repoRoot: string;
|
|
1545
1766
|
hookBased?: boolean;
|
|
1546
|
-
}): Promise<{ ok: true }> {
|
|
1767
|
+
}): Promise<{ ok: true; removed: boolean }> {
|
|
1547
1768
|
// Hook-based worktrees are owned by the WorktreeRemove hook; the git-root
|
|
1548
1769
|
// containment check does not apply to them.
|
|
1549
1770
|
if (!params.hookBased) {
|
|
@@ -1560,7 +1781,7 @@ export class AgentBridge {
|
|
|
1560
1781
|
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
1561
1782
|
// branches only log, never throw. Hook-based worktrees go through the
|
|
1562
1783
|
// WorktreeRemove hook inside removeWorktree.
|
|
1563
|
-
await removeWorktree({
|
|
1784
|
+
const removed = await removeWorktree({
|
|
1564
1785
|
name: "",
|
|
1565
1786
|
path: params.path,
|
|
1566
1787
|
branch: params.branch,
|
|
@@ -1570,12 +1791,13 @@ export class AgentBridge {
|
|
|
1570
1791
|
isNew: false,
|
|
1571
1792
|
hookBased: params.hookBased,
|
|
1572
1793
|
});
|
|
1573
|
-
return { ok: true };
|
|
1794
|
+
return { ok: true, removed };
|
|
1574
1795
|
}
|
|
1575
1796
|
|
|
1576
1797
|
private async getSessionInfo(sessionId?: string): Promise<{
|
|
1577
1798
|
sessionId: string;
|
|
1578
1799
|
sessionCwd: string;
|
|
1800
|
+
worktreeBranch?: string;
|
|
1579
1801
|
latestTotalTokens: number;
|
|
1580
1802
|
permissionMode: PermissionMode;
|
|
1581
1803
|
availableTools: string[];
|
|
@@ -1592,6 +1814,11 @@ export class AgentBridge {
|
|
|
1592
1814
|
// destroy --remove-worktree resolves the session's worktree from this
|
|
1593
1815
|
// value, and it must not drift when the session cd'd out of the worktree.
|
|
1594
1816
|
sessionCwd: entry.createdWorkdir,
|
|
1817
|
+
// Same reason one level down: the branch to delete is the one the
|
|
1818
|
+
// worktree was CREATED on, not the one it has checked out now.
|
|
1819
|
+
...(entry.createdWorktreeBranch
|
|
1820
|
+
? { worktreeBranch: entry.createdWorktreeBranch }
|
|
1821
|
+
: {}),
|
|
1595
1822
|
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
1596
1823
|
permissionMode: entry.agent.getPermissionMode(),
|
|
1597
1824
|
availableTools: entry.agent.getAvailableToolNames(),
|
|
@@ -1761,15 +1988,25 @@ export class AgentBridge {
|
|
|
1761
1988
|
private async updateUserSettings(
|
|
1762
1989
|
patch: UserPreferenceSettings,
|
|
1763
1990
|
): Promise<UserPreferenceSettingsView> {
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
);
|
|
1771
|
-
|
|
1772
|
-
|
|
1991
|
+
// 串行执行(spec core/agent-config.md「设置页控件的保存时机」场景 9):
|
|
1992
|
+
// `updateUserPreferenceSettings` 是「同步读 → await 异步写」的读-改-写,而 stdio
|
|
1993
|
+
// 请求是并发处理的(`jsonRpcConnection` 的 line 回调不 await `handleLine`)。设置页
|
|
1994
|
+
// 改成「改一下就写一次」之后,快速连拨两个开关就是两笔并发写:后一笔读到的是前
|
|
1995
|
+
// 一笔落盘之前的文件,前一笔的键会被整包覆盖掉。排队即可,代价是一次写盘的时长。
|
|
1996
|
+
const write = this.userSettingsWriteQueue.then(async () => {
|
|
1997
|
+
await updateUserPreferenceSettings(patch);
|
|
1998
|
+
if (!Object.values(patch).every((value) => value === undefined)) {
|
|
1999
|
+
await Promise.all(
|
|
2000
|
+
[...this.sessions.values()].map((entry) =>
|
|
2001
|
+
entry.agent.reloadConfiguration(),
|
|
2002
|
+
),
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
return this.userSettingsView();
|
|
2006
|
+
});
|
|
2007
|
+
// 队列本身不因某一次失败而断掉:错误交给本次调用方(原 promise)回给宿主。
|
|
2008
|
+
this.userSettingsWriteQueue = write.catch(() => undefined);
|
|
2009
|
+
return write;
|
|
1773
2010
|
}
|
|
1774
2011
|
|
|
1775
2012
|
/**
|
|
@@ -1787,7 +2024,7 @@ export class AgentBridge {
|
|
|
1787
2024
|
private async sendMessage(
|
|
1788
2025
|
params: {
|
|
1789
2026
|
text: string;
|
|
1790
|
-
images?:
|
|
2027
|
+
images?: UserImage[];
|
|
1791
2028
|
force?: boolean;
|
|
1792
2029
|
},
|
|
1793
2030
|
sessionId?: string,
|
|
@@ -1827,15 +2064,17 @@ export class AgentBridge {
|
|
|
1827
2064
|
|
|
1828
2065
|
/**
|
|
1829
2066
|
* Webview hosts (desktop/vscode/jetbrains) send pasted images as inline
|
|
1830
|
-
* data URLs — there is no local file behind them.
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
2067
|
+
* data URLs — there is no local file behind them. Write each to a temp file
|
|
2068
|
+
* so the model gets a real path it can reference with tools (aligned with
|
|
2069
|
+
* Claude Code's `[Image source: <path>]` metadata), and **keep the data URL
|
|
2070
|
+
* as the image's value**: the bytes stay in the message block (and therefore
|
|
2071
|
+
* in memory and in the transcript), so any GUI can preview the picture
|
|
2072
|
+
* without asking its host for a file (spec ui/image-pasting.md「已发送图片的
|
|
2073
|
+
* 标签点开就能看」). Real paths pass through untouched; unparseable data URLs
|
|
2074
|
+
* pass through as-is and are skipped by the SDK rather than blocking the
|
|
2075
|
+
* message.
|
|
1835
2076
|
*/
|
|
1836
|
-
private persistDataUrlImages(
|
|
1837
|
-
images?: Array<{ path: string; mimeType: string }>,
|
|
1838
|
-
): Array<{ path: string; mimeType: string }> | undefined {
|
|
2077
|
+
private persistDataUrlImages(images?: UserImage[]): UserImage[] | undefined {
|
|
1839
2078
|
if (!images || images.length === 0) return images;
|
|
1840
2079
|
return images.map((img) => {
|
|
1841
2080
|
if (!img.path.startsWith("data:")) return img;
|
|
@@ -1849,7 +2088,7 @@ export class AgentBridge {
|
|
|
1849
2088
|
`wave-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`,
|
|
1850
2089
|
);
|
|
1851
2090
|
writeFileSync(filePath, Buffer.from(match[2], "base64"));
|
|
1852
|
-
return {
|
|
2091
|
+
return { ...img, mimeType, filePath };
|
|
1853
2092
|
} catch (error) {
|
|
1854
2093
|
logger.warn("Failed to persist pasted image to temp file:", error);
|
|
1855
2094
|
return img;
|
|
@@ -1949,7 +2188,7 @@ export class AgentBridge {
|
|
|
1949
2188
|
private updateQueuedMessage(
|
|
1950
2189
|
id: string,
|
|
1951
2190
|
text: string,
|
|
1952
|
-
images:
|
|
2191
|
+
images: UserImage[] | undefined,
|
|
1953
2192
|
sessionId?: string,
|
|
1954
2193
|
): { ok: boolean } {
|
|
1955
2194
|
const entry = this.requireSession(sessionId);
|
|
@@ -2573,20 +2812,77 @@ export class AgentBridge {
|
|
|
2573
2812
|
}
|
|
2574
2813
|
|
|
2575
2814
|
// ── Auth (global) ────────────────────────────────────────────
|
|
2815
|
+
/**
|
|
2816
|
+
* 认证状态 + **凭据形态**(spec desktop-account.md「账户卡片 · 认证方式」)。
|
|
2817
|
+
*
|
|
2818
|
+
* `isAuthenticated` 只回答「有没有 SSO token」;宿主据此渲染卡片与发送入口会
|
|
2819
|
+
* 把「key 写在 settings.json 里」的人判成没有凭据(卡片显示登录按钮、输入框
|
|
2820
|
+
* 被锁)。故同一次查询里一并给出 `credential`,优先级与 SDK 的
|
|
2821
|
+
* `resolveGatewayConfig` 对齐:企业账号 > 自定义网关 > 无凭据。
|
|
2822
|
+
*/
|
|
2576
2823
|
private async getAuthStatus(): Promise<{
|
|
2577
2824
|
isAuthenticated: boolean;
|
|
2825
|
+
credential: AccountCredential;
|
|
2578
2826
|
user: { id: string; email?: string } | undefined;
|
|
2579
2827
|
serverUrl: string;
|
|
2580
2828
|
}> {
|
|
2581
2829
|
const authService = AuthService.getInstance();
|
|
2582
2830
|
await authService.checkAndRefreshTokenIfNeeded();
|
|
2831
|
+
const isAuthenticated = authService.isSSOAuthenticated();
|
|
2583
2832
|
return {
|
|
2584
|
-
isAuthenticated
|
|
2833
|
+
isAuthenticated,
|
|
2834
|
+
credential: this.resolveCredential(isAuthenticated),
|
|
2585
2835
|
user: authService.getAuthUser(),
|
|
2586
2836
|
serverUrl: authService.getServerUrl(),
|
|
2587
2837
|
};
|
|
2588
2838
|
}
|
|
2589
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
|
+
|
|
2590
2886
|
private async getAccountInfo(): Promise<{
|
|
2591
2887
|
/**
|
|
2592
2888
|
* 计费结论(套餐两根条 + 到期日 + 降级原因),形状见 codechat `GET /api/v1/account` → `billing`
|
|
@@ -3048,10 +3344,10 @@ export class AgentBridge {
|
|
|
3048
3344
|
ctx.registeredSessionId,
|
|
3049
3345
|
);
|
|
3050
3346
|
},
|
|
3051
|
-
|
|
3347
|
+
onNoticeAdded: (notice) => {
|
|
3052
3348
|
this.emit(
|
|
3053
|
-
"
|
|
3054
|
-
remoteWire.
|
|
3349
|
+
"noticeAdded",
|
|
3350
|
+
remoteWire.noticeAdded(notice),
|
|
3055
3351
|
ctx.registeredSessionId,
|
|
3056
3352
|
);
|
|
3057
3353
|
},
|
|
@@ -3068,11 +3364,12 @@ export class AgentBridge {
|
|
|
3068
3364
|
// request (fresh total) and on session restore (the persisted total),
|
|
3069
3365
|
// so the webview button shows real usage without waiting for a turn —
|
|
3070
3366
|
// aligned with the CLI, which reads the same callback (spec 场景 5/6).
|
|
3071
|
-
const max = ctx.agent?.
|
|
3367
|
+
const max = ctx.agent?.getContextWindow() ?? 0;
|
|
3368
|
+
const maxOutput = ctx.agent?.getMaxOutputTokens?.() ?? 32_000;
|
|
3072
3369
|
if (max > 0) {
|
|
3073
3370
|
this.emit(
|
|
3074
3371
|
"contextUsage",
|
|
3075
|
-
remoteWire.contextUsage(tokens, max),
|
|
3372
|
+
remoteWire.contextUsage(tokens, max, maxOutput),
|
|
3076
3373
|
ctx.registeredSessionId,
|
|
3077
3374
|
);
|
|
3078
3375
|
}
|
|
@@ -3138,6 +3435,13 @@ export class AgentBridge {
|
|
|
3138
3435
|
onMcpServersChange: (servers: McpServerStatus[]) => {
|
|
3139
3436
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
3140
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
|
+
},
|
|
3141
3445
|
onNotificationMessageAdded: (params) => {
|
|
3142
3446
|
// The LAST matching message, not the first: one task can notify more
|
|
3143
3447
|
// than once under the same task id (a workflow warns about its size
|
|
@@ -3271,6 +3575,19 @@ function isSessionRecoveryError(error: unknown): boolean {
|
|
|
3271
3575
|
);
|
|
3272
3576
|
}
|
|
3273
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
|
+
|
|
3274
3591
|
/** Read a text file, returning "" when it does not exist. */
|
|
3275
3592
|
async function readTextFileSafe(filePath: string): Promise<string> {
|
|
3276
3593
|
try {
|
|
@@ -3283,26 +3600,37 @@ async function readTextFileSafe(filePath: string): Promise<string> {
|
|
|
3283
3600
|
}
|
|
3284
3601
|
}
|
|
3285
3602
|
|
|
3603
|
+
/** Whether `path` exists and is a directory (a missing path simply answers false). */
|
|
3604
|
+
async function isDirectory(dirPath: string): Promise<boolean> {
|
|
3605
|
+
try {
|
|
3606
|
+
return (await stat(dirPath)).isDirectory();
|
|
3607
|
+
} catch {
|
|
3608
|
+
return false;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
|
|
3286
3612
|
/**
|
|
3287
|
-
* Read the creation-time
|
|
3613
|
+
* Read the creation-time facts from a session transcript's metadata header —
|
|
3288
3614
|
* the first line `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`
|
|
3289
3615
|
* the SDK writes once at session creation. The header is append-only, so it
|
|
3290
3616
|
* records where the session was created even after the live working directory
|
|
3291
3617
|
* drifted (in-session `cd`) or after a daemon restart re-anchored the agent at
|
|
3292
|
-
* a client's cwd
|
|
3618
|
+
* a client's cwd, and its `gitBranch` records the branch that directory was on
|
|
3619
|
+
* at creation. Returns an empty object when the file is missing, legacy (no
|
|
3293
3620
|
* header), or unreadable. Only the header line is read (transcripts can grow
|
|
3294
3621
|
* large); a 4KiB read covers any realistic header.
|
|
3295
3622
|
*/
|
|
3296
|
-
async function
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3623
|
+
async function readTranscriptHeader(transcriptPath?: string): Promise<{
|
|
3624
|
+
workdir?: string;
|
|
3625
|
+
gitBranch?: string;
|
|
3626
|
+
}> {
|
|
3627
|
+
if (!transcriptPath) return {};
|
|
3300
3628
|
try {
|
|
3301
3629
|
const handle = await open(transcriptPath, "r");
|
|
3302
3630
|
try {
|
|
3303
3631
|
const buffer = Buffer.alloc(4096);
|
|
3304
3632
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
3305
|
-
if (bytesRead === 0) return
|
|
3633
|
+
if (bytesRead === 0) return {};
|
|
3306
3634
|
const newline = buffer.indexOf(0x0a);
|
|
3307
3635
|
const firstLine = buffer
|
|
3308
3636
|
.subarray(0, newline === -1 ? bytesRead : newline)
|
|
@@ -3310,14 +3638,21 @@ async function readTranscriptWorkdir(
|
|
|
3310
3638
|
const header = JSON.parse(firstLine) as {
|
|
3311
3639
|
type?: string;
|
|
3312
3640
|
workdir?: unknown;
|
|
3641
|
+
gitBranch?: unknown;
|
|
3642
|
+
};
|
|
3643
|
+
if (header.type !== "metadata") return {};
|
|
3644
|
+
return {
|
|
3645
|
+
workdir:
|
|
3646
|
+
typeof header.workdir === "string" ? header.workdir : undefined,
|
|
3647
|
+
gitBranch:
|
|
3648
|
+
typeof header.gitBranch === "string" && header.gitBranch
|
|
3649
|
+
? header.gitBranch
|
|
3650
|
+
: undefined,
|
|
3313
3651
|
};
|
|
3314
|
-
return header.type === "metadata" && typeof header.workdir === "string"
|
|
3315
|
-
? header.workdir
|
|
3316
|
-
: undefined;
|
|
3317
3652
|
} finally {
|
|
3318
3653
|
await handle.close();
|
|
3319
3654
|
}
|
|
3320
3655
|
} catch {
|
|
3321
|
-
return
|
|
3656
|
+
return {};
|
|
3322
3657
|
}
|
|
3323
3658
|
}
|