wave-code 1.5.1 → 1.6.0
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/bin/wave-code.js +2 -0
- package/dist/bundle/wave.mjs +697 -662
- package/package.json +7 -7
- package/src/cli.tsx +9 -3
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/contexts/useChat.tsx +47 -33
- 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/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 +26 -4
- package/src/remote/remoteControlService.ts +67 -16
- package/src/remote/remoteNotifications.ts +5 -4
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +300 -65
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/uiPayload.ts +49 -0
- 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,
|
|
@@ -74,6 +76,7 @@ import {
|
|
|
74
76
|
revokeArtifact,
|
|
75
77
|
searchArtifactMembers,
|
|
76
78
|
type ArtifactShareMode,
|
|
79
|
+
type UserImage,
|
|
77
80
|
} from "wave-agent-sdk";
|
|
78
81
|
import {
|
|
79
82
|
type ClientNotificationMethod,
|
|
@@ -95,7 +98,7 @@ import {
|
|
|
95
98
|
realpathSync,
|
|
96
99
|
statSync,
|
|
97
100
|
} from "node:fs";
|
|
98
|
-
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
101
|
+
import { mkdir, open, readFile, stat, writeFile } from "node:fs/promises";
|
|
99
102
|
import { homedir, tmpdir } from "node:os";
|
|
100
103
|
import { basename, dirname, extname, join } from "node:path";
|
|
101
104
|
import {
|
|
@@ -103,6 +106,8 @@ import {
|
|
|
103
106
|
deriveWorktreeInfo,
|
|
104
107
|
getWorktreeChanges,
|
|
105
108
|
removeWorktree,
|
|
109
|
+
worktreeCreationBranch,
|
|
110
|
+
type DerivedWorktree,
|
|
106
111
|
} from "../utils/worktree.js";
|
|
107
112
|
import { logger } from "../utils/logger.js";
|
|
108
113
|
import {
|
|
@@ -118,6 +123,8 @@ import {
|
|
|
118
123
|
} from "../remote/remoteNotifications.js";
|
|
119
124
|
import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
|
|
120
125
|
import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
|
|
126
|
+
import { readImagePreview } from "../remote/imagePreview.js";
|
|
127
|
+
import { readTextPreview } from "../remote/textPreview.js";
|
|
121
128
|
import type { RemoteControlController } from "../remote/remoteControlService.js";
|
|
122
129
|
import {
|
|
123
130
|
hostSlashCommandsFor,
|
|
@@ -209,6 +216,20 @@ interface SessionEntry {
|
|
|
209
216
|
* header when the session is re-attached from disk after a daemon restart.
|
|
210
217
|
*/
|
|
211
218
|
createdWorkdir: string;
|
|
219
|
+
/**
|
|
220
|
+
* The branch the worktree in `createdWorkdir` was CREATED on
|
|
221
|
+
* (`worktree-<name>`), recorded once at initialize alongside it. Undefined
|
|
222
|
+
* when the session was not created in a linked git worktree.
|
|
223
|
+
*
|
|
224
|
+
* Removal needs this snapshot because the worktree itself stops being a
|
|
225
|
+
* record of it: `deriveWorktreeInfo` reads the worktree's HEAD, which an
|
|
226
|
+
* in-session `git checkout <other-branch>` moves to a branch that belongs to
|
|
227
|
+
* the user (and may hold commits reachable from nowhere else). Deleting that
|
|
228
|
+
* one is the bug this snapshot prevents, and it is unrecoverable once
|
|
229
|
+
* deleted — `git worktree list` has no memory of the original checkout
|
|
230
|
+
* either (it reports each worktree's current HEAD too).
|
|
231
|
+
*/
|
|
232
|
+
createdWorktreeBranch?: string;
|
|
212
233
|
/**
|
|
213
234
|
* True while `updateConfig` is tearing the session's agent down and rebuilding
|
|
214
235
|
* it from the updated config — the entry stays in the registry (so the session
|
|
@@ -280,6 +301,10 @@ function direntType(parentDir: string, dirent: Dirent): "dir" | "file" | null {
|
|
|
280
301
|
|
|
281
302
|
export class AgentBridge {
|
|
282
303
|
private sessions = new Map<string, SessionEntry>();
|
|
304
|
+
/** 用户偏好写盘的串行队列(`updateUserSettings`):stdio 请求是并发处理的,而写盘
|
|
305
|
+
* 是「同步读 → await 异步写」的读-改-写,两笔并发写会互相覆盖(丢键)。见该方法
|
|
306
|
+
* 的注释与 spec core/agent-config.md「设置页控件的保存时机」场景 9。 */
|
|
307
|
+
private userSettingsWriteQueue: Promise<unknown> = Promise.resolve();
|
|
283
308
|
/** Pending approval requests, keyed by requestId. Stored with the resolve +
|
|
284
309
|
* context so a re-attached client can list and respond to them (daemon mode:
|
|
285
310
|
* approvals outlive any single connection). */
|
|
@@ -297,13 +322,13 @@ export class AgentBridge {
|
|
|
297
322
|
* a keystroke pause, so a newer request makes the previous one worthless —
|
|
298
323
|
* superseding it here is what keeps "one in-flight request per editor" true
|
|
299
324
|
* even when the host cannot cancel (spec
|
|
300
|
-
* docs/specs/
|
|
325
|
+
* docs/specs/ide/code-completion.md: 同一时刻只允许一个补全请求在途).
|
|
301
326
|
*/
|
|
302
327
|
private inFlightCompletions = new Map<string, AbortController>();
|
|
303
328
|
/**
|
|
304
329
|
* In-flight next-edit calls, same one-per-session rule and same reason as
|
|
305
330
|
* completions. It also participates in the cross-capability exclusion from
|
|
306
|
-
* `docs/specs/
|
|
331
|
+
* `docs/specs/ide/next-edit-suggestion.md`: completion and next-edit never hold
|
|
307
332
|
* an in-flight request at the same time, so one of them cannot answer into a
|
|
308
333
|
* buffer the other has already rewritten.
|
|
309
334
|
*/
|
|
@@ -407,7 +432,7 @@ export class AgentBridge {
|
|
|
407
432
|
return this.sendMessage(
|
|
408
433
|
p as unknown as {
|
|
409
434
|
text: string;
|
|
410
|
-
images?:
|
|
435
|
+
images?: UserImage[];
|
|
411
436
|
force?: boolean;
|
|
412
437
|
},
|
|
413
438
|
sessionId,
|
|
@@ -430,7 +455,7 @@ export class AgentBridge {
|
|
|
430
455
|
return this.updateQueuedMessage(
|
|
431
456
|
p.id as string,
|
|
432
457
|
p.text as string,
|
|
433
|
-
p.images as
|
|
458
|
+
p.images as UserImage[] | undefined,
|
|
434
459
|
sessionId,
|
|
435
460
|
);
|
|
436
461
|
case "deleteQueuedMessageById":
|
|
@@ -459,6 +484,19 @@ export class AgentBridge {
|
|
|
459
484
|
return this.getPermissionMode(sessionId);
|
|
460
485
|
case "getPlanFile":
|
|
461
486
|
return this.getPlanFile(sessionId);
|
|
487
|
+
// 远控页按需取图(spec「点消息里的图片路径看原图」):路径来自消息正文、
|
|
488
|
+
// 由用户在页面上点出来。会话在这里只用来确认调用方已经握手到某条活会话;
|
|
489
|
+
// 判型(按解码后的真实容器)与压到单帧预算内都在 `imagePreview.ts` 里做,
|
|
490
|
+
// 前台 CLI 那个宿主共用同一份实现。
|
|
491
|
+
case "getImagePreview":
|
|
492
|
+
this.requireSessionForRead(sessionId);
|
|
493
|
+
return readImagePreview(typeof p.path === "string" ? p.path : "");
|
|
494
|
+
|
|
495
|
+
case "getTextPreview":
|
|
496
|
+
// 点消息里的文件路径看内容(spec remote/remote-web.md「点消息里的文件
|
|
497
|
+
// 路径看内容」):与取图同一条按需读取通道,判型换成「读出来是不是文本」。
|
|
498
|
+
this.requireSessionForRead(sessionId);
|
|
499
|
+
return readTextPreview(typeof p.path === "string" ? p.path : "");
|
|
462
500
|
|
|
463
501
|
// ── MCP ──
|
|
464
502
|
case "getMcpServers":
|
|
@@ -701,6 +739,12 @@ export class AgentBridge {
|
|
|
701
739
|
case "rememberRecentWorkdir":
|
|
702
740
|
rememberRecentWorkdir(p.workdir as string);
|
|
703
741
|
return null;
|
|
742
|
+
// 移除(桌面端最近目录项上的「从列表移除」)。页面没有这个入口,所以它
|
|
743
|
+
// 不在远控白名单里;桌面端移除时走自己的 stdio/隧道,落在这台机器上,
|
|
744
|
+
// 否则下一次 `getRecentWorkdirs` 会把条目带回来。
|
|
745
|
+
case "forgetRecentWorkdir":
|
|
746
|
+
forgetRecentWorkdir(p.workdir as string);
|
|
747
|
+
return null;
|
|
704
748
|
case "listGitBranches":
|
|
705
749
|
return this.listGitBranches(p.workdir as string | undefined);
|
|
706
750
|
case "createWorktree":
|
|
@@ -834,7 +878,12 @@ export class AgentBridge {
|
|
|
834
878
|
const options: AgentOptions = {
|
|
835
879
|
callbacks,
|
|
836
880
|
logger,
|
|
837
|
-
|
|
881
|
+
// Only a restore can name a directory other than the caller's, and the
|
|
882
|
+
// lookup must stay off the fresh-session path: hosts expect
|
|
883
|
+
// `Agent.create` to be under way as soon as `initialize` is entered.
|
|
884
|
+
workdir: params.restoreSessionId
|
|
885
|
+
? await this.restoreWorkdirFor(params, params.restoreSessionId)
|
|
886
|
+
: params.workdir,
|
|
838
887
|
restoreSessionId: params.restoreSessionId,
|
|
839
888
|
apiKey: params.apiKey,
|
|
840
889
|
baseURL: params.baseURL,
|
|
@@ -860,30 +909,106 @@ export class AgentBridge {
|
|
|
860
909
|
|
|
861
910
|
// Record where the session was created (see SessionEntry.createdWorkdir).
|
|
862
911
|
// 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.
|
|
912
|
+
// re-attach was already anchored on the session's own directory by
|
|
913
|
+
// `restoreWorkdirFor` when the agent was created just above, so the agent's
|
|
914
|
+
// transcript is the recorded one: the header read here confirms it and
|
|
915
|
+
// supplies the creation branch.
|
|
868
916
|
let createdWorkdir: string;
|
|
917
|
+
let transcriptBranch: string | undefined;
|
|
869
918
|
if (params.restoreSessionId) {
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
agent.currentWorkdir;
|
|
919
|
+
const header = await readTranscriptHeader(agent.sessionFilePath);
|
|
920
|
+
createdWorkdir = header.workdir ?? agent.currentWorkdir;
|
|
921
|
+
transcriptBranch = header.gitBranch;
|
|
874
922
|
} else {
|
|
875
923
|
createdWorkdir = params.workdir ?? agent.currentWorkdir;
|
|
876
924
|
}
|
|
877
925
|
|
|
926
|
+
const worktree = deriveWorktreeInfo(createdWorkdir);
|
|
927
|
+
const createdWorktreeBranch = this.creationBranchOf(worktree, {
|
|
928
|
+
transcriptBranch,
|
|
929
|
+
restored: Boolean(params.restoreSessionId),
|
|
930
|
+
});
|
|
931
|
+
|
|
878
932
|
this.sessions.set(agent.sessionId, {
|
|
879
933
|
agent,
|
|
880
934
|
// Keep storedConfig.workdir in sync: updateConfig rebuilds the agent from
|
|
881
935
|
// storedConfig, so the rebuild must stay anchored on the creation dir too.
|
|
882
936
|
storedConfig: { ...params, workdir: createdWorkdir },
|
|
883
937
|
createdWorkdir,
|
|
938
|
+
createdWorktreeBranch,
|
|
884
939
|
});
|
|
885
940
|
|
|
886
|
-
return this.initializeReply(agent, createdWorkdir);
|
|
941
|
+
return this.initializeReply(agent, createdWorkdir, worktree);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The directory a session being restored belongs to.
|
|
946
|
+
*
|
|
947
|
+
* `initialize`'s `workdir` is the **client's** directory, not the session's.
|
|
948
|
+
* For a fresh session the two are the same by definition, but a from-disk
|
|
949
|
+
* re-attach (daemon restarted — killed / CLI 升级重启 / reboot — then
|
|
950
|
+
* `status`/`send`/`wait` re-hosts the session) sends whatever cwd the
|
|
951
|
+
* re-attaching client happens to run in. Anchoring the agent there silently
|
|
952
|
+
* drops the session's own directory: a `create --worktree` session keeps the
|
|
953
|
+
* cwd of whoever attached first, its next turn (and its Bash tool) runs in the
|
|
954
|
+
* host repo instead of the worktree, and `destroy --remove-worktree` then
|
|
955
|
+
* resolves the wrong tree.
|
|
956
|
+
*
|
|
957
|
+
* The transcript's `metadata` header records the creation dir and is
|
|
958
|
+
* append-only (a Bash `cd`, an EnterWorktree and a re-host from another cwd
|
|
959
|
+
* all leave it alone), so it wins whenever it is readable **and** the
|
|
960
|
+
* directory still exists. A directory that is gone (worktree deleted by hand,
|
|
961
|
+
* project moved) is reported and the caller's directory is used instead —
|
|
962
|
+
* degenerate, but it is the only anchor left and must not abort the attach.
|
|
963
|
+
*/
|
|
964
|
+
private async restoreWorkdirFor(
|
|
965
|
+
params: InitializeParams,
|
|
966
|
+
restoreSessionId: string,
|
|
967
|
+
): Promise<string | undefined> {
|
|
968
|
+
const callerWorkdir = params.workdir ?? process.cwd();
|
|
969
|
+
const recorded = await readSessionWorkdir(restoreSessionId, callerWorkdir);
|
|
970
|
+
if (!recorded || recorded === callerWorkdir) {
|
|
971
|
+
return params.workdir;
|
|
972
|
+
}
|
|
973
|
+
if (!(await isDirectory(recorded))) {
|
|
974
|
+
logger?.warn(
|
|
975
|
+
`Session ${restoreSessionId} was created in ${recorded}, which no longer exists; restoring it in ${callerWorkdir} instead`,
|
|
976
|
+
);
|
|
977
|
+
return params.workdir;
|
|
978
|
+
}
|
|
979
|
+
return recorded;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
/**
|
|
983
|
+
* The branch the session's worktree was created on — recorded once, at
|
|
984
|
+
* initialize (see `SessionEntry.createdWorktreeBranch`).
|
|
985
|
+
*
|
|
986
|
+
* Only a session created inside a linked git worktree has one: a session in
|
|
987
|
+
* the main working tree returns undefined, and so does a WorktreeCreate
|
|
988
|
+
* hook-managed worktree (its branches are not git's).
|
|
989
|
+
*
|
|
990
|
+
* Records, best first:
|
|
991
|
+
*
|
|
992
|
+
* 1. the transcript header's `gitBranch` — the creation-time record a
|
|
993
|
+
* re-attached session recovers after a daemon restart;
|
|
994
|
+
* 2. the path layout `<repoRoot>/.wave/worktrees/<name>`
|
|
995
|
+
* (`worktreeCreationBranch`) — the same answer, from the one thing that
|
|
996
|
+
* always survives;
|
|
997
|
+
* 3. the worktree's HEAD — fresh sessions only, where nothing has had the
|
|
998
|
+
* chance to check anything else out yet. Never for a re-attach: by then
|
|
999
|
+
* HEAD may be a branch the user checked out, and that is the branch
|
|
1000
|
+
* removal must leave alone.
|
|
1001
|
+
*/
|
|
1002
|
+
private creationBranchOf(
|
|
1003
|
+
worktree: DerivedWorktree | undefined,
|
|
1004
|
+
options: { transcriptBranch?: string; restored: boolean },
|
|
1005
|
+
): string | undefined {
|
|
1006
|
+
if (!worktree || worktree.hookBased) return undefined;
|
|
1007
|
+
return (
|
|
1008
|
+
options.transcriptBranch ||
|
|
1009
|
+
worktreeCreationBranch(worktree) ||
|
|
1010
|
+
(options.restored ? undefined : worktree.branch || undefined)
|
|
1011
|
+
);
|
|
887
1012
|
}
|
|
888
1013
|
|
|
889
1014
|
/**
|
|
@@ -906,6 +1031,9 @@ export class AgentBridge {
|
|
|
906
1031
|
private initializeReply(
|
|
907
1032
|
agent: Agent,
|
|
908
1033
|
sessionCwd: string,
|
|
1034
|
+
// Derived once by the caller: this reply and the creation-branch snapshot
|
|
1035
|
+
// both need it, and it reads config files.
|
|
1036
|
+
worktree = deriveWorktreeInfo(sessionCwd),
|
|
909
1037
|
): {
|
|
910
1038
|
sessionId: string;
|
|
911
1039
|
currentWorkdir: string;
|
|
@@ -915,7 +1043,6 @@ export class AgentBridge {
|
|
|
915
1043
|
permissionMode: PermissionMode;
|
|
916
1044
|
latestTotalTokens: number;
|
|
917
1045
|
} {
|
|
918
|
-
const worktree = deriveWorktreeInfo(sessionCwd);
|
|
919
1046
|
return {
|
|
920
1047
|
sessionId: agent.sessionId,
|
|
921
1048
|
currentWorkdir: agent.currentWorkdir,
|
|
@@ -957,16 +1084,35 @@ export class AgentBridge {
|
|
|
957
1084
|
* a removal that threw — the caller only ever needs to know whether the
|
|
958
1085
|
* directory is gone.
|
|
959
1086
|
*
|
|
960
|
-
* The
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
*
|
|
1087
|
+
* **The worktree removal is not awaited** (2026-11-04). The reply goes out
|
|
1088
|
+
* once the registry entry is gone and `agent.destroy()` has settled; removing
|
|
1089
|
+
* the directory then runs in the background and the reply says
|
|
1090
|
+
* `worktreeRemoval: "pending"`. Reason: the remote page times every request
|
|
1091
|
+
* out at 20s (`REQUEST_TIMEOUT_MS` in `remote/shim.js`), while deleting a
|
|
1092
|
+
* worktree that contains `node_modules` on Windows is a **minute-scale**
|
|
1093
|
+
* operation — `git worktree remove` cannot delete pnpm's junctions (it exits
|
|
1094
|
+
* 0 and leaves the directory), so it falls back to a recursive `fs.rmSync`.
|
|
1095
|
+
* Awaiting that made every phone-side delete report a timeout even though the
|
|
1096
|
+
* session was already gone. The desktop host has always worked this way
|
|
1097
|
+
* (`handleDeleteSession` fire-and-forgets both steps and refreshes the UI
|
|
1098
|
+
* immediately). The ordering above still holds: the background removal hangs
|
|
1099
|
+
* off `agent.destroy()`'s promise, it is not concurrent with it.
|
|
1100
|
+
*
|
|
1101
|
+
* The return shape grew from `null` to `{worktreeRemoved}` to
|
|
1102
|
+
* `{worktreeRemoved, worktreeRemoval}`; callers that pass no params (the
|
|
1103
|
+
* CLI's own `wave daemon destroy`) read nothing and are unaffected. The CLI's
|
|
1104
|
+
* `--remove-worktree` keeps its own client-side path (getSessionInfo +
|
|
1105
|
+
* removeWorktree + destroy) — deliberately unchanged.
|
|
964
1106
|
*/
|
|
965
1107
|
private async destroy(
|
|
966
1108
|
sessionId?: string,
|
|
967
1109
|
params?: { removeWorktree?: boolean },
|
|
968
|
-
): Promise<{
|
|
1110
|
+
): Promise<{
|
|
1111
|
+
worktreeRemoved: boolean;
|
|
1112
|
+
worktreeRemoval: "none" | "pending";
|
|
1113
|
+
}> {
|
|
969
1114
|
let createdWorkdir: string | undefined;
|
|
1115
|
+
let createdWorktreeBranch: string | undefined;
|
|
970
1116
|
if (sessionId) {
|
|
971
1117
|
const entry = this.sessions.get(sessionId);
|
|
972
1118
|
if (entry) {
|
|
@@ -994,15 +1140,43 @@ export class AgentBridge {
|
|
|
994
1140
|
// for the same reason).
|
|
995
1141
|
this.sessions.delete(sessionId);
|
|
996
1142
|
createdWorkdir = entry.createdWorkdir;
|
|
1143
|
+
createdWorktreeBranch = entry.createdWorktreeBranch;
|
|
997
1144
|
await entry.agent.destroy();
|
|
998
1145
|
}
|
|
999
1146
|
}
|
|
1000
1147
|
|
|
1001
|
-
let
|
|
1002
|
-
if (
|
|
1003
|
-
|
|
1148
|
+
let worktreeRemoval: "none" | "pending" = "none";
|
|
1149
|
+
if (
|
|
1150
|
+
params?.removeWorktree === true &&
|
|
1151
|
+
createdWorkdir &&
|
|
1152
|
+
// fs-only and cheap (no git): deciding "is there a worktree at all" before
|
|
1153
|
+
// the reply is what keeps `none` honest for plain sessions instead of
|
|
1154
|
+
// reporting a removal that was never going to happen.
|
|
1155
|
+
deriveWorktreeInfo(createdWorkdir)
|
|
1156
|
+
) {
|
|
1157
|
+
// Backgrounded, not awaited: see the note above. The `.then` is the
|
|
1158
|
+
// ordering guarantee — `removeSessionWorktree` starts only once
|
|
1159
|
+
// `agent.destroy()` (awaited above) settled.
|
|
1160
|
+
worktreeRemoval = "pending";
|
|
1161
|
+
void this.removeSessionWorktree(createdWorkdir, createdWorktreeBranch)
|
|
1162
|
+
.then((removed) => {
|
|
1163
|
+
if (!removed) {
|
|
1164
|
+
logger.warn(
|
|
1165
|
+
`destroy: session ${sessionId ?? "(none)"} deleted, but its ` +
|
|
1166
|
+
`worktree at ${createdWorkdir} survived removal`,
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
})
|
|
1170
|
+
.catch((e: unknown) => {
|
|
1171
|
+
logger.warn(
|
|
1172
|
+
`destroy: background worktree removal failed for ` +
|
|
1173
|
+
`${createdWorkdir}: ${(e as Error).message}`,
|
|
1174
|
+
);
|
|
1175
|
+
});
|
|
1004
1176
|
}
|
|
1005
|
-
|
|
1177
|
+
// Pending means "the directory may still be there when this reply lands, and
|
|
1178
|
+
// that is not a failure" — the caller must not read it as "removal failed".
|
|
1179
|
+
return { worktreeRemoved: false, worktreeRemoval };
|
|
1006
1180
|
}
|
|
1007
1181
|
|
|
1008
1182
|
/**
|
|
@@ -1012,15 +1186,33 @@ export class AgentBridge {
|
|
|
1012
1186
|
* both fail, and for a hook-based worktree with no WorktreeRemove hook it
|
|
1013
1187
|
* deliberately leaves it in place. Never throws — the caller has already
|
|
1014
1188
|
* deleted the session.
|
|
1189
|
+
*
|
|
1190
|
+
* `createdBranch` is the branch the worktree was created on, snapshotted by
|
|
1191
|
+
* the caller. It is **not** `worktree.branch`: that field is the worktree's
|
|
1192
|
+
* current HEAD, which an in-session `git checkout` moves to a branch the user
|
|
1193
|
+
* owns. Falls back to the path layout (`worktreeCreationBranch`) — a
|
|
1194
|
+
* candidate only, `removeWorktree` confirms it against git — and to deleting
|
|
1195
|
+
* no branch at all when neither can name one.
|
|
1015
1196
|
*/
|
|
1016
|
-
private async removeSessionWorktree(
|
|
1197
|
+
private async removeSessionWorktree(
|
|
1198
|
+
sessionDir: string,
|
|
1199
|
+
createdBranch?: string,
|
|
1200
|
+
): Promise<boolean> {
|
|
1017
1201
|
const worktree = deriveWorktreeInfo(sessionDir);
|
|
1018
1202
|
if (!worktree) return false;
|
|
1203
|
+
const branch = createdBranch || worktreeCreationBranch(worktree) || "";
|
|
1204
|
+
if (!branch) {
|
|
1205
|
+
logger.warn(
|
|
1206
|
+
`removeSessionWorktree: cannot tell which branch ${worktree.path} was ` +
|
|
1207
|
+
`created on — keeping every branch instead of deleting the one it ` +
|
|
1208
|
+
`has checked out (${worktree.branch || "detached"})`,
|
|
1209
|
+
);
|
|
1210
|
+
}
|
|
1019
1211
|
try {
|
|
1020
1212
|
await removeWorktree({
|
|
1021
1213
|
name: "",
|
|
1022
1214
|
path: worktree.path,
|
|
1023
|
-
branch
|
|
1215
|
+
branch,
|
|
1024
1216
|
repoRoot: worktree.repoRoot,
|
|
1025
1217
|
hasUncommittedChanges: false,
|
|
1026
1218
|
hasNewCommits: false,
|
|
@@ -1337,6 +1529,7 @@ export class AgentBridge {
|
|
|
1337
1529
|
cwd: workdir,
|
|
1338
1530
|
encoding: "utf8" as const,
|
|
1339
1531
|
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
1532
|
+
windowsHide: true,
|
|
1340
1533
|
};
|
|
1341
1534
|
let branchesRaw: string;
|
|
1342
1535
|
try {
|
|
@@ -1538,12 +1731,18 @@ export class AgentBridge {
|
|
|
1538
1731
|
return getWorktreeChanges(params.path, params.baseBranch);
|
|
1539
1732
|
}
|
|
1540
1733
|
|
|
1734
|
+
/**
|
|
1735
|
+
* `removed` answers "is the directory gone", not "did we ask": removal is
|
|
1736
|
+
* best-effort and leaves the directory (and its branch) behind when git and
|
|
1737
|
+
* the fs fallback both fail — the one thing a caller may not do is print a
|
|
1738
|
+
* success line for that. Callers that ignore it are unaffected.
|
|
1739
|
+
*/
|
|
1541
1740
|
private async removeWorktreeSession(params: {
|
|
1542
1741
|
path: string;
|
|
1543
1742
|
branch: string;
|
|
1544
1743
|
repoRoot: string;
|
|
1545
1744
|
hookBased?: boolean;
|
|
1546
|
-
}): Promise<{ ok: true }> {
|
|
1745
|
+
}): Promise<{ ok: true; removed: boolean }> {
|
|
1547
1746
|
// Hook-based worktrees are owned by the WorktreeRemove hook; the git-root
|
|
1548
1747
|
// containment check does not apply to them.
|
|
1549
1748
|
if (!params.hookBased) {
|
|
@@ -1560,7 +1759,7 @@ export class AgentBridge {
|
|
|
1560
1759
|
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
1561
1760
|
// branches only log, never throw. Hook-based worktrees go through the
|
|
1562
1761
|
// WorktreeRemove hook inside removeWorktree.
|
|
1563
|
-
await removeWorktree({
|
|
1762
|
+
const removed = await removeWorktree({
|
|
1564
1763
|
name: "",
|
|
1565
1764
|
path: params.path,
|
|
1566
1765
|
branch: params.branch,
|
|
@@ -1570,12 +1769,13 @@ export class AgentBridge {
|
|
|
1570
1769
|
isNew: false,
|
|
1571
1770
|
hookBased: params.hookBased,
|
|
1572
1771
|
});
|
|
1573
|
-
return { ok: true };
|
|
1772
|
+
return { ok: true, removed };
|
|
1574
1773
|
}
|
|
1575
1774
|
|
|
1576
1775
|
private async getSessionInfo(sessionId?: string): Promise<{
|
|
1577
1776
|
sessionId: string;
|
|
1578
1777
|
sessionCwd: string;
|
|
1778
|
+
worktreeBranch?: string;
|
|
1579
1779
|
latestTotalTokens: number;
|
|
1580
1780
|
permissionMode: PermissionMode;
|
|
1581
1781
|
availableTools: string[];
|
|
@@ -1592,6 +1792,11 @@ export class AgentBridge {
|
|
|
1592
1792
|
// destroy --remove-worktree resolves the session's worktree from this
|
|
1593
1793
|
// value, and it must not drift when the session cd'd out of the worktree.
|
|
1594
1794
|
sessionCwd: entry.createdWorkdir,
|
|
1795
|
+
// Same reason one level down: the branch to delete is the one the
|
|
1796
|
+
// worktree was CREATED on, not the one it has checked out now.
|
|
1797
|
+
...(entry.createdWorktreeBranch
|
|
1798
|
+
? { worktreeBranch: entry.createdWorktreeBranch }
|
|
1799
|
+
: {}),
|
|
1595
1800
|
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
1596
1801
|
permissionMode: entry.agent.getPermissionMode(),
|
|
1597
1802
|
availableTools: entry.agent.getAvailableToolNames(),
|
|
@@ -1761,15 +1966,25 @@ export class AgentBridge {
|
|
|
1761
1966
|
private async updateUserSettings(
|
|
1762
1967
|
patch: UserPreferenceSettings,
|
|
1763
1968
|
): Promise<UserPreferenceSettingsView> {
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
);
|
|
1771
|
-
|
|
1772
|
-
|
|
1969
|
+
// 串行执行(spec core/agent-config.md「设置页控件的保存时机」场景 9):
|
|
1970
|
+
// `updateUserPreferenceSettings` 是「同步读 → await 异步写」的读-改-写,而 stdio
|
|
1971
|
+
// 请求是并发处理的(`jsonRpcConnection` 的 line 回调不 await `handleLine`)。设置页
|
|
1972
|
+
// 改成「改一下就写一次」之后,快速连拨两个开关就是两笔并发写:后一笔读到的是前
|
|
1973
|
+
// 一笔落盘之前的文件,前一笔的键会被整包覆盖掉。排队即可,代价是一次写盘的时长。
|
|
1974
|
+
const write = this.userSettingsWriteQueue.then(async () => {
|
|
1975
|
+
await updateUserPreferenceSettings(patch);
|
|
1976
|
+
if (!Object.values(patch).every((value) => value === undefined)) {
|
|
1977
|
+
await Promise.all(
|
|
1978
|
+
[...this.sessions.values()].map((entry) =>
|
|
1979
|
+
entry.agent.reloadConfiguration(),
|
|
1980
|
+
),
|
|
1981
|
+
);
|
|
1982
|
+
}
|
|
1983
|
+
return this.userSettingsView();
|
|
1984
|
+
});
|
|
1985
|
+
// 队列本身不因某一次失败而断掉:错误交给本次调用方(原 promise)回给宿主。
|
|
1986
|
+
this.userSettingsWriteQueue = write.catch(() => undefined);
|
|
1987
|
+
return write;
|
|
1773
1988
|
}
|
|
1774
1989
|
|
|
1775
1990
|
/**
|
|
@@ -1787,7 +2002,7 @@ export class AgentBridge {
|
|
|
1787
2002
|
private async sendMessage(
|
|
1788
2003
|
params: {
|
|
1789
2004
|
text: string;
|
|
1790
|
-
images?:
|
|
2005
|
+
images?: UserImage[];
|
|
1791
2006
|
force?: boolean;
|
|
1792
2007
|
},
|
|
1793
2008
|
sessionId?: string,
|
|
@@ -1827,15 +2042,17 @@ export class AgentBridge {
|
|
|
1827
2042
|
|
|
1828
2043
|
/**
|
|
1829
2044
|
* Webview hosts (desktop/vscode/jetbrains) send pasted images as inline
|
|
1830
|
-
* data URLs — there is no local file behind them.
|
|
1831
|
-
*
|
|
1832
|
-
*
|
|
1833
|
-
*
|
|
1834
|
-
*
|
|
2045
|
+
* data URLs — there is no local file behind them. Write each to a temp file
|
|
2046
|
+
* so the model gets a real path it can reference with tools (aligned with
|
|
2047
|
+
* Claude Code's `[Image source: <path>]` metadata), and **keep the data URL
|
|
2048
|
+
* as the image's value**: the bytes stay in the message block (and therefore
|
|
2049
|
+
* in memory and in the transcript), so any GUI can preview the picture
|
|
2050
|
+
* without asking its host for a file (spec ui/image-pasting.md「已发送图片的
|
|
2051
|
+
* 标签点开就能看」). Real paths pass through untouched; unparseable data URLs
|
|
2052
|
+
* pass through as-is and are skipped by the SDK rather than blocking the
|
|
2053
|
+
* message.
|
|
1835
2054
|
*/
|
|
1836
|
-
private persistDataUrlImages(
|
|
1837
|
-
images?: Array<{ path: string; mimeType: string }>,
|
|
1838
|
-
): Array<{ path: string; mimeType: string }> | undefined {
|
|
2055
|
+
private persistDataUrlImages(images?: UserImage[]): UserImage[] | undefined {
|
|
1839
2056
|
if (!images || images.length === 0) return images;
|
|
1840
2057
|
return images.map((img) => {
|
|
1841
2058
|
if (!img.path.startsWith("data:")) return img;
|
|
@@ -1849,7 +2066,7 @@ export class AgentBridge {
|
|
|
1849
2066
|
`wave-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`,
|
|
1850
2067
|
);
|
|
1851
2068
|
writeFileSync(filePath, Buffer.from(match[2], "base64"));
|
|
1852
|
-
return {
|
|
2069
|
+
return { ...img, mimeType, filePath };
|
|
1853
2070
|
} catch (error) {
|
|
1854
2071
|
logger.warn("Failed to persist pasted image to temp file:", error);
|
|
1855
2072
|
return img;
|
|
@@ -1949,7 +2166,7 @@ export class AgentBridge {
|
|
|
1949
2166
|
private updateQueuedMessage(
|
|
1950
2167
|
id: string,
|
|
1951
2168
|
text: string,
|
|
1952
|
-
images:
|
|
2169
|
+
images: UserImage[] | undefined,
|
|
1953
2170
|
sessionId?: string,
|
|
1954
2171
|
): { ok: boolean } {
|
|
1955
2172
|
const entry = this.requireSession(sessionId);
|
|
@@ -3048,10 +3265,10 @@ export class AgentBridge {
|
|
|
3048
3265
|
ctx.registeredSessionId,
|
|
3049
3266
|
);
|
|
3050
3267
|
},
|
|
3051
|
-
|
|
3268
|
+
onNoticeAdded: (notice) => {
|
|
3052
3269
|
this.emit(
|
|
3053
|
-
"
|
|
3054
|
-
remoteWire.
|
|
3270
|
+
"noticeAdded",
|
|
3271
|
+
remoteWire.noticeAdded(notice),
|
|
3055
3272
|
ctx.registeredSessionId,
|
|
3056
3273
|
);
|
|
3057
3274
|
},
|
|
@@ -3283,26 +3500,37 @@ async function readTextFileSafe(filePath: string): Promise<string> {
|
|
|
3283
3500
|
}
|
|
3284
3501
|
}
|
|
3285
3502
|
|
|
3503
|
+
/** Whether `path` exists and is a directory (a missing path simply answers false). */
|
|
3504
|
+
async function isDirectory(dirPath: string): Promise<boolean> {
|
|
3505
|
+
try {
|
|
3506
|
+
return (await stat(dirPath)).isDirectory();
|
|
3507
|
+
} catch {
|
|
3508
|
+
return false;
|
|
3509
|
+
}
|
|
3510
|
+
}
|
|
3511
|
+
|
|
3286
3512
|
/**
|
|
3287
|
-
* Read the creation-time
|
|
3513
|
+
* Read the creation-time facts from a session transcript's metadata header —
|
|
3288
3514
|
* the first line `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`
|
|
3289
3515
|
* the SDK writes once at session creation. The header is append-only, so it
|
|
3290
3516
|
* records where the session was created even after the live working directory
|
|
3291
3517
|
* drifted (in-session `cd`) or after a daemon restart re-anchored the agent at
|
|
3292
|
-
* a client's cwd
|
|
3518
|
+
* a client's cwd, and its `gitBranch` records the branch that directory was on
|
|
3519
|
+
* at creation. Returns an empty object when the file is missing, legacy (no
|
|
3293
3520
|
* header), or unreadable. Only the header line is read (transcripts can grow
|
|
3294
3521
|
* large); a 4KiB read covers any realistic header.
|
|
3295
3522
|
*/
|
|
3296
|
-
async function
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3523
|
+
async function readTranscriptHeader(transcriptPath?: string): Promise<{
|
|
3524
|
+
workdir?: string;
|
|
3525
|
+
gitBranch?: string;
|
|
3526
|
+
}> {
|
|
3527
|
+
if (!transcriptPath) return {};
|
|
3300
3528
|
try {
|
|
3301
3529
|
const handle = await open(transcriptPath, "r");
|
|
3302
3530
|
try {
|
|
3303
3531
|
const buffer = Buffer.alloc(4096);
|
|
3304
3532
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
3305
|
-
if (bytesRead === 0) return
|
|
3533
|
+
if (bytesRead === 0) return {};
|
|
3306
3534
|
const newline = buffer.indexOf(0x0a);
|
|
3307
3535
|
const firstLine = buffer
|
|
3308
3536
|
.subarray(0, newline === -1 ? bytesRead : newline)
|
|
@@ -3310,14 +3538,21 @@ async function readTranscriptWorkdir(
|
|
|
3310
3538
|
const header = JSON.parse(firstLine) as {
|
|
3311
3539
|
type?: string;
|
|
3312
3540
|
workdir?: unknown;
|
|
3541
|
+
gitBranch?: unknown;
|
|
3542
|
+
};
|
|
3543
|
+
if (header.type !== "metadata") return {};
|
|
3544
|
+
return {
|
|
3545
|
+
workdir:
|
|
3546
|
+
typeof header.workdir === "string" ? header.workdir : undefined,
|
|
3547
|
+
gitBranch:
|
|
3548
|
+
typeof header.gitBranch === "string" && header.gitBranch
|
|
3549
|
+
? header.gitBranch
|
|
3550
|
+
: undefined,
|
|
3313
3551
|
};
|
|
3314
|
-
return header.type === "metadata" && typeof header.workdir === "string"
|
|
3315
|
-
? header.workdir
|
|
3316
|
-
: undefined;
|
|
3317
3552
|
} finally {
|
|
3318
3553
|
await handle.close();
|
|
3319
3554
|
}
|
|
3320
3555
|
} catch {
|
|
3321
|
-
return
|
|
3556
|
+
return {};
|
|
3322
3557
|
}
|
|
3323
3558
|
}
|