wave-code 1.5.0 → 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 +777 -855
- package/package.json +7 -7
- package/src/cli.tsx +9 -3
- package/src/components/FileSelector.tsx +11 -8
- package/src/components/HelpView.tsx +1 -1
- package/src/components/InputBox.tsx +3 -5
- package/src/components/McpManager.tsx +26 -0
- package/src/components/MessageBlockItem.tsx +14 -2
- package/src/components/RemoteControlView.tsx +1 -1
- package/src/components/RewindCommand.tsx +40 -46
- package/src/contexts/useChat.tsx +61 -49
- 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/useInputManager.ts +27 -25
- package/src/index.ts +36 -4
- package/src/managers/inputHandlers.ts +3 -13
- package/src/managers/inputReducer.ts +44 -13
- package/src/print-cli.ts +199 -49
- package/src/remote/hostIdentity.ts +1 -0
- package/src/remote/imagePreview.ts +182 -0
- package/src/remote/messageWindow.ts +15 -5
- package/src/remote/notificationCoalescer.ts +16 -3
- package/src/remote/relayClient.ts +1 -1
- package/src/remote/remoteControlCliHost.ts +26 -4
- package/src/remote/remoteControlService.ts +70 -19
- package/src/remote/remoteNotifications.ts +5 -4
- package/src/remote/textPreview.ts +155 -0
- package/src/stdio/agentBridge.ts +450 -117
- package/src/stdio/daemonServer.ts +49 -33
- package/src/stdio/hostSlashCommands.ts +9 -4
- package/src/stdio/uiPayload.ts +50 -2
- package/src/utils/agentSelector.ts +29 -0
- package/src/utils/argvCompat.ts +23 -0
- package/src/utils/printOutput.ts +403 -0
- package/src/utils/usageSummary.ts +74 -24
- package/src/utils/worktree.ts +222 -16
- package/src/utils/rewindCheckpoints.ts +0 -14
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -39,11 +39,12 @@ 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,
|
|
46
|
-
getMessageContent,
|
|
47
48
|
PromptHistoryManager,
|
|
48
49
|
AuthService,
|
|
49
50
|
PluginCore,
|
|
@@ -75,6 +76,7 @@ import {
|
|
|
75
76
|
revokeArtifact,
|
|
76
77
|
searchArtifactMembers,
|
|
77
78
|
type ArtifactShareMode,
|
|
79
|
+
type UserImage,
|
|
78
80
|
} from "wave-agent-sdk";
|
|
79
81
|
import {
|
|
80
82
|
type ClientNotificationMethod,
|
|
@@ -87,14 +89,16 @@ import {
|
|
|
87
89
|
} from "./protocol.js";
|
|
88
90
|
import { execFileSync } from "node:child_process";
|
|
89
91
|
import {
|
|
92
|
+
type Dirent,
|
|
90
93
|
mkdirSync,
|
|
91
94
|
existsSync,
|
|
92
95
|
readFileSync,
|
|
93
96
|
writeFileSync,
|
|
94
97
|
readdirSync,
|
|
95
98
|
realpathSync,
|
|
99
|
+
statSync,
|
|
96
100
|
} from "node:fs";
|
|
97
|
-
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
101
|
+
import { mkdir, open, readFile, stat, writeFile } from "node:fs/promises";
|
|
98
102
|
import { homedir, tmpdir } from "node:os";
|
|
99
103
|
import { basename, dirname, extname, join } from "node:path";
|
|
100
104
|
import {
|
|
@@ -102,6 +106,8 @@ import {
|
|
|
102
106
|
deriveWorktreeInfo,
|
|
103
107
|
getWorktreeChanges,
|
|
104
108
|
removeWorktree,
|
|
109
|
+
worktreeCreationBranch,
|
|
110
|
+
type DerivedWorktree,
|
|
105
111
|
} from "../utils/worktree.js";
|
|
106
112
|
import { logger } from "../utils/logger.js";
|
|
107
113
|
import {
|
|
@@ -115,9 +121,10 @@ import {
|
|
|
115
121
|
remoteWire,
|
|
116
122
|
toBackgroundTaskSummaries,
|
|
117
123
|
} from "../remote/remoteNotifications.js";
|
|
118
|
-
import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.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
|
|
@@ -241,8 +262,49 @@ interface SessionContext {
|
|
|
241
262
|
registeredSessionId?: string;
|
|
242
263
|
}
|
|
243
264
|
|
|
265
|
+
/**
|
|
266
|
+
* Entries the file panel's directory tree never shows (spec
|
|
267
|
+
* desktop-file-panel.md「文件面板工作区目录树」scenario 3). A deny-list, not
|
|
268
|
+
* "hide dotfiles": `.github/`, `.env`, `.wave/` are content the user browses
|
|
269
|
+
* for. Only machine noise and VCS internals.
|
|
270
|
+
*/
|
|
271
|
+
const DIR_ENTRY_JUNK = new Set([
|
|
272
|
+
".git",
|
|
273
|
+
".svn",
|
|
274
|
+
".hg",
|
|
275
|
+
".DS_Store",
|
|
276
|
+
"Thumbs.db",
|
|
277
|
+
"desktop.ini",
|
|
278
|
+
]);
|
|
279
|
+
|
|
280
|
+
/** Per-level cap for the tree (scenario 3: the rest collapses into one row). */
|
|
281
|
+
const DIR_ENTRY_MAX_PER_LEVEL = 200;
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Classify one directory entry as dir/file. Symlinks are followed so a linked
|
|
285
|
+
* directory stays browsable; an entry we cannot stat (dangling link, vanished
|
|
286
|
+
* between readdir and stat) returns null and is dropped — better a missing row
|
|
287
|
+
* than a row that fails on every click.
|
|
288
|
+
*/
|
|
289
|
+
function direntType(parentDir: string, dirent: Dirent): "dir" | "file" | null {
|
|
290
|
+
if (dirent.isDirectory()) return "dir";
|
|
291
|
+
if (dirent.isFile()) return "file";
|
|
292
|
+
if (!dirent.isSymbolicLink()) return null;
|
|
293
|
+
try {
|
|
294
|
+
return statSync(join(parentDir, dirent.name)).isDirectory()
|
|
295
|
+
? "dir"
|
|
296
|
+
: "file";
|
|
297
|
+
} catch {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
244
302
|
export class AgentBridge {
|
|
245
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();
|
|
246
308
|
/** Pending approval requests, keyed by requestId. Stored with the resolve +
|
|
247
309
|
* context so a re-attached client can list and respond to them (daemon mode:
|
|
248
310
|
* approvals outlive any single connection). */
|
|
@@ -260,13 +322,13 @@ export class AgentBridge {
|
|
|
260
322
|
* a keystroke pause, so a newer request makes the previous one worthless —
|
|
261
323
|
* superseding it here is what keeps "one in-flight request per editor" true
|
|
262
324
|
* even when the host cannot cancel (spec
|
|
263
|
-
* docs/specs/
|
|
325
|
+
* docs/specs/ide/code-completion.md: 同一时刻只允许一个补全请求在途).
|
|
264
326
|
*/
|
|
265
327
|
private inFlightCompletions = new Map<string, AbortController>();
|
|
266
328
|
/**
|
|
267
329
|
* In-flight next-edit calls, same one-per-session rule and same reason as
|
|
268
330
|
* completions. It also participates in the cross-capability exclusion from
|
|
269
|
-
* `docs/specs/
|
|
331
|
+
* `docs/specs/ide/next-edit-suggestion.md`: completion and next-edit never hold
|
|
270
332
|
* an in-flight request at the same time, so one of them cannot answer into a
|
|
271
333
|
* buffer the other has already rewritten.
|
|
272
334
|
*/
|
|
@@ -370,7 +432,7 @@ export class AgentBridge {
|
|
|
370
432
|
return this.sendMessage(
|
|
371
433
|
p as unknown as {
|
|
372
434
|
text: string;
|
|
373
|
-
images?:
|
|
435
|
+
images?: UserImage[];
|
|
374
436
|
force?: boolean;
|
|
375
437
|
},
|
|
376
438
|
sessionId,
|
|
@@ -393,7 +455,7 @@ export class AgentBridge {
|
|
|
393
455
|
return this.updateQueuedMessage(
|
|
394
456
|
p.id as string,
|
|
395
457
|
p.text as string,
|
|
396
|
-
p.images as
|
|
458
|
+
p.images as UserImage[] | undefined,
|
|
397
459
|
sessionId,
|
|
398
460
|
);
|
|
399
461
|
case "deleteQueuedMessageById":
|
|
@@ -401,10 +463,12 @@ export class AgentBridge {
|
|
|
401
463
|
case "getMessages":
|
|
402
464
|
return this.getMessages(sessionId, {
|
|
403
465
|
since: p.since as string | undefined,
|
|
466
|
+
// 上翻取更早的锚点。漏了这一行,SDK 会退回「首屏」分支——每次都答
|
|
467
|
+
// 同一份内存尾部,页面永远接不上更早的页(真机上表现为上翻到底反复
|
|
468
|
+
// 要同一页、翻不动,压缩之前的历史一条都翻不出来)。
|
|
469
|
+
before: p.before as string | undefined,
|
|
404
470
|
limit: p.limit as number | undefined,
|
|
405
471
|
});
|
|
406
|
-
case "getFullMessageThread":
|
|
407
|
-
return this.getFullMessageThread(sessionId);
|
|
408
472
|
|
|
409
473
|
// ── Remote control (daemon-level) ──
|
|
410
474
|
// The daemon owns the outbound relay socket; the bridge only routes.
|
|
@@ -420,6 +484,19 @@ export class AgentBridge {
|
|
|
420
484
|
return this.getPermissionMode(sessionId);
|
|
421
485
|
case "getPlanFile":
|
|
422
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 : "");
|
|
423
500
|
|
|
424
501
|
// ── MCP ──
|
|
425
502
|
case "getMcpServers":
|
|
@@ -652,6 +729,8 @@ export class AgentBridge {
|
|
|
652
729
|
// ── Filesystem / Git / worktree (global — no session required) ──
|
|
653
730
|
case "listDirectories":
|
|
654
731
|
return this.listDirectories(p.dir as string | undefined);
|
|
732
|
+
case "listDirectoryEntries":
|
|
733
|
+
return this.listDirectoryEntries(p.dir as string | undefined);
|
|
655
734
|
// ── 最近打开的工作目录(spec core/recent-workdirs.md)──
|
|
656
735
|
// 机器级、与会话无关:文件就在这台机器上,谁问就回谁的。桌面端按主机
|
|
657
736
|
// 各问一次,远控 Web 页问它连着的那个进程。
|
|
@@ -660,6 +739,12 @@ export class AgentBridge {
|
|
|
660
739
|
case "rememberRecentWorkdir":
|
|
661
740
|
rememberRecentWorkdir(p.workdir as string);
|
|
662
741
|
return null;
|
|
742
|
+
// 移除(桌面端最近目录项上的「从列表移除」)。页面没有这个入口,所以它
|
|
743
|
+
// 不在远控白名单里;桌面端移除时走自己的 stdio/隧道,落在这台机器上,
|
|
744
|
+
// 否则下一次 `getRecentWorkdirs` 会把条目带回来。
|
|
745
|
+
case "forgetRecentWorkdir":
|
|
746
|
+
forgetRecentWorkdir(p.workdir as string);
|
|
747
|
+
return null;
|
|
663
748
|
case "listGitBranches":
|
|
664
749
|
return this.listGitBranches(p.workdir as string | undefined);
|
|
665
750
|
case "createWorktree":
|
|
@@ -793,7 +878,12 @@ export class AgentBridge {
|
|
|
793
878
|
const options: AgentOptions = {
|
|
794
879
|
callbacks,
|
|
795
880
|
logger,
|
|
796
|
-
|
|
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,
|
|
797
887
|
restoreSessionId: params.restoreSessionId,
|
|
798
888
|
apiKey: params.apiKey,
|
|
799
889
|
baseURL: params.baseURL,
|
|
@@ -819,30 +909,106 @@ export class AgentBridge {
|
|
|
819
909
|
|
|
820
910
|
// Record where the session was created (see SessionEntry.createdWorkdir).
|
|
821
911
|
// A fresh session's initialize workdir IS the creation dir. A from-disk
|
|
822
|
-
// re-attach
|
|
823
|
-
//
|
|
824
|
-
//
|
|
825
|
-
//
|
|
826
|
-
// 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.
|
|
827
916
|
let createdWorkdir: string;
|
|
917
|
+
let transcriptBranch: string | undefined;
|
|
828
918
|
if (params.restoreSessionId) {
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
agent.currentWorkdir;
|
|
919
|
+
const header = await readTranscriptHeader(agent.sessionFilePath);
|
|
920
|
+
createdWorkdir = header.workdir ?? agent.currentWorkdir;
|
|
921
|
+
transcriptBranch = header.gitBranch;
|
|
833
922
|
} else {
|
|
834
923
|
createdWorkdir = params.workdir ?? agent.currentWorkdir;
|
|
835
924
|
}
|
|
836
925
|
|
|
926
|
+
const worktree = deriveWorktreeInfo(createdWorkdir);
|
|
927
|
+
const createdWorktreeBranch = this.creationBranchOf(worktree, {
|
|
928
|
+
transcriptBranch,
|
|
929
|
+
restored: Boolean(params.restoreSessionId),
|
|
930
|
+
});
|
|
931
|
+
|
|
837
932
|
this.sessions.set(agent.sessionId, {
|
|
838
933
|
agent,
|
|
839
934
|
// Keep storedConfig.workdir in sync: updateConfig rebuilds the agent from
|
|
840
935
|
// storedConfig, so the rebuild must stay anchored on the creation dir too.
|
|
841
936
|
storedConfig: { ...params, workdir: createdWorkdir },
|
|
842
937
|
createdWorkdir,
|
|
938
|
+
createdWorktreeBranch,
|
|
843
939
|
});
|
|
844
940
|
|
|
845
|
-
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
|
+
);
|
|
846
1012
|
}
|
|
847
1013
|
|
|
848
1014
|
/**
|
|
@@ -865,19 +1031,27 @@ export class AgentBridge {
|
|
|
865
1031
|
private initializeReply(
|
|
866
1032
|
agent: Agent,
|
|
867
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),
|
|
868
1037
|
): {
|
|
869
1038
|
sessionId: string;
|
|
870
1039
|
currentWorkdir: string;
|
|
871
1040
|
sessionCwd: string;
|
|
1041
|
+
workspaceRoot: string;
|
|
872
1042
|
repoRoot?: string;
|
|
873
1043
|
permissionMode: PermissionMode;
|
|
874
1044
|
latestTotalTokens: number;
|
|
875
1045
|
} {
|
|
876
|
-
const worktree = deriveWorktreeInfo(sessionCwd);
|
|
877
1046
|
return {
|
|
878
1047
|
sessionId: agent.sessionId,
|
|
879
1048
|
currentWorkdir: agent.currentWorkdir,
|
|
880
1049
|
sessionCwd,
|
|
1050
|
+
// Which working tree the session works in right now — differs from
|
|
1051
|
+
// `sessionCwd` only after a mid-session EnterWorktree, and hosts use it
|
|
1052
|
+
// for the surfaces that follow the user's workspace (diff panel, file
|
|
1053
|
+
// tree, `@` search).
|
|
1054
|
+
workspaceRoot: agent.workspaceRoot,
|
|
881
1055
|
...(worktree ? { repoRoot: worktree.repoRoot } : {}),
|
|
882
1056
|
permissionMode: agent.getPermissionMode(),
|
|
883
1057
|
latestTotalTokens: agent.latestTotalTokens,
|
|
@@ -910,16 +1084,35 @@ export class AgentBridge {
|
|
|
910
1084
|
* a removal that threw — the caller only ever needs to know whether the
|
|
911
1085
|
* directory is gone.
|
|
912
1086
|
*
|
|
913
|
-
* The
|
|
914
|
-
*
|
|
915
|
-
*
|
|
916
|
-
*
|
|
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.
|
|
917
1106
|
*/
|
|
918
1107
|
private async destroy(
|
|
919
1108
|
sessionId?: string,
|
|
920
1109
|
params?: { removeWorktree?: boolean },
|
|
921
|
-
): Promise<{
|
|
1110
|
+
): Promise<{
|
|
1111
|
+
worktreeRemoved: boolean;
|
|
1112
|
+
worktreeRemoval: "none" | "pending";
|
|
1113
|
+
}> {
|
|
922
1114
|
let createdWorkdir: string | undefined;
|
|
1115
|
+
let createdWorktreeBranch: string | undefined;
|
|
923
1116
|
if (sessionId) {
|
|
924
1117
|
const entry = this.sessions.get(sessionId);
|
|
925
1118
|
if (entry) {
|
|
@@ -947,15 +1140,43 @@ export class AgentBridge {
|
|
|
947
1140
|
// for the same reason).
|
|
948
1141
|
this.sessions.delete(sessionId);
|
|
949
1142
|
createdWorkdir = entry.createdWorkdir;
|
|
1143
|
+
createdWorktreeBranch = entry.createdWorktreeBranch;
|
|
950
1144
|
await entry.agent.destroy();
|
|
951
1145
|
}
|
|
952
1146
|
}
|
|
953
1147
|
|
|
954
|
-
let
|
|
955
|
-
if (
|
|
956
|
-
|
|
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
|
+
});
|
|
957
1176
|
}
|
|
958
|
-
|
|
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 };
|
|
959
1180
|
}
|
|
960
1181
|
|
|
961
1182
|
/**
|
|
@@ -965,15 +1186,33 @@ export class AgentBridge {
|
|
|
965
1186
|
* both fail, and for a hook-based worktree with no WorktreeRemove hook it
|
|
966
1187
|
* deliberately leaves it in place. Never throws — the caller has already
|
|
967
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.
|
|
968
1196
|
*/
|
|
969
|
-
private async removeSessionWorktree(
|
|
1197
|
+
private async removeSessionWorktree(
|
|
1198
|
+
sessionDir: string,
|
|
1199
|
+
createdBranch?: string,
|
|
1200
|
+
): Promise<boolean> {
|
|
970
1201
|
const worktree = deriveWorktreeInfo(sessionDir);
|
|
971
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
|
+
}
|
|
972
1211
|
try {
|
|
973
1212
|
await removeWorktree({
|
|
974
1213
|
name: "",
|
|
975
1214
|
path: worktree.path,
|
|
976
|
-
branch
|
|
1215
|
+
branch,
|
|
977
1216
|
repoRoot: worktree.repoRoot,
|
|
978
1217
|
hasUncommittedChanges: false,
|
|
979
1218
|
hasNewCommits: false,
|
|
@@ -1220,6 +1459,65 @@ export class AgentBridge {
|
|
|
1220
1459
|
return { resolvedPath, dirs };
|
|
1221
1460
|
}
|
|
1222
1461
|
|
|
1462
|
+
/**
|
|
1463
|
+
* One level of the file panel's directory tree (spec
|
|
1464
|
+
* desktop-file-panel.md「文件面板工作区目录树」scenario 3): direct children
|
|
1465
|
+
* only. Expansion is lazy per level, so this never walks a subtree.
|
|
1466
|
+
*
|
|
1467
|
+
* Serves **both** hosts: the desktop reaches this bridge through the local
|
|
1468
|
+
* stdio client and, for a remote session, through the remote daemon tunnel.
|
|
1469
|
+
* So the tree needs no ssh code of its own, and local vs remote cannot drift
|
|
1470
|
+
* on ordering, filtering or the entry cap. It also keeps the tree on the same
|
|
1471
|
+
* machine view as `searchFiles`, which the panel's filter box uses.
|
|
1472
|
+
*
|
|
1473
|
+
* Ordering, the junk deny-list and the cap live here rather than in the
|
|
1474
|
+
* webview: "dirs first, then files, each by name" is one rule with one owner.
|
|
1475
|
+
* The deny-list is a list of *known junk*, not "hide dotfiles" — `.github/`
|
|
1476
|
+
* and `.env` are real project content the user browses for.
|
|
1477
|
+
*
|
|
1478
|
+
* `total` counts post-filter entries, so `total > entries.length` is exactly
|
|
1479
|
+
* the 「还有 N 项未显示」 row the webview renders. A directory of 50k files
|
|
1480
|
+
* would otherwise be shipped whole on every expansion.
|
|
1481
|
+
*/
|
|
1482
|
+
private listDirectoryEntries(dir?: string): {
|
|
1483
|
+
resolvedPath: string;
|
|
1484
|
+
entries: Array<{ name: string; type: "dir" | "file" }>;
|
|
1485
|
+
total: number;
|
|
1486
|
+
} {
|
|
1487
|
+
if (!dir) {
|
|
1488
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "dir is required");
|
|
1489
|
+
}
|
|
1490
|
+
const expanded =
|
|
1491
|
+
dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
|
|
1492
|
+
let resolvedPath: string;
|
|
1493
|
+
let dirents: Dirent[];
|
|
1494
|
+
try {
|
|
1495
|
+
resolvedPath = realpathSync(expanded);
|
|
1496
|
+
dirents = readdirSync(resolvedPath, { withFileTypes: true });
|
|
1497
|
+
} catch {
|
|
1498
|
+
// Missing and unreadable get the same verdict: the tree cannot descend
|
|
1499
|
+
// into either, and an empty list would read as 「空目录」.
|
|
1500
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
|
|
1501
|
+
}
|
|
1502
|
+
const entries = dirents
|
|
1503
|
+
.filter((e) => !DIR_ENTRY_JUNK.has(e.name))
|
|
1504
|
+
.map((e) => ({ name: e.name, type: direntType(resolvedPath, e) }))
|
|
1505
|
+
.filter(
|
|
1506
|
+
(e): e is { name: string; type: "dir" | "file" } => e.type !== null,
|
|
1507
|
+
)
|
|
1508
|
+
.sort(
|
|
1509
|
+
(a, b) =>
|
|
1510
|
+
// Directories first, then files — the tree's fixed order.
|
|
1511
|
+
(a.type === b.type ? 0 : a.type === "dir" ? -1 : 1) ||
|
|
1512
|
+
a.name.localeCompare(b.name),
|
|
1513
|
+
);
|
|
1514
|
+
return {
|
|
1515
|
+
resolvedPath,
|
|
1516
|
+
entries: entries.slice(0, DIR_ENTRY_MAX_PER_LEVEL),
|
|
1517
|
+
total: entries.length,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1223
1521
|
private listGitBranches(workdir?: string): {
|
|
1224
1522
|
branches: string[];
|
|
1225
1523
|
current: string | null;
|
|
@@ -1231,6 +1529,7 @@ export class AgentBridge {
|
|
|
1231
1529
|
cwd: workdir,
|
|
1232
1530
|
encoding: "utf8" as const,
|
|
1233
1531
|
stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"],
|
|
1532
|
+
windowsHide: true,
|
|
1234
1533
|
};
|
|
1235
1534
|
let branchesRaw: string;
|
|
1236
1535
|
try {
|
|
@@ -1432,12 +1731,18 @@ export class AgentBridge {
|
|
|
1432
1731
|
return getWorktreeChanges(params.path, params.baseBranch);
|
|
1433
1732
|
}
|
|
1434
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
|
+
*/
|
|
1435
1740
|
private async removeWorktreeSession(params: {
|
|
1436
1741
|
path: string;
|
|
1437
1742
|
branch: string;
|
|
1438
1743
|
repoRoot: string;
|
|
1439
1744
|
hookBased?: boolean;
|
|
1440
|
-
}): Promise<{ ok: true }> {
|
|
1745
|
+
}): Promise<{ ok: true; removed: boolean }> {
|
|
1441
1746
|
// Hook-based worktrees are owned by the WorktreeRemove hook; the git-root
|
|
1442
1747
|
// containment check does not apply to them.
|
|
1443
1748
|
if (!params.hookBased) {
|
|
@@ -1454,7 +1759,7 @@ export class AgentBridge {
|
|
|
1454
1759
|
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
1455
1760
|
// branches only log, never throw. Hook-based worktrees go through the
|
|
1456
1761
|
// WorktreeRemove hook inside removeWorktree.
|
|
1457
|
-
await removeWorktree({
|
|
1762
|
+
const removed = await removeWorktree({
|
|
1458
1763
|
name: "",
|
|
1459
1764
|
path: params.path,
|
|
1460
1765
|
branch: params.branch,
|
|
@@ -1464,12 +1769,13 @@ export class AgentBridge {
|
|
|
1464
1769
|
isNew: false,
|
|
1465
1770
|
hookBased: params.hookBased,
|
|
1466
1771
|
});
|
|
1467
|
-
return { ok: true };
|
|
1772
|
+
return { ok: true, removed };
|
|
1468
1773
|
}
|
|
1469
1774
|
|
|
1470
1775
|
private async getSessionInfo(sessionId?: string): Promise<{
|
|
1471
1776
|
sessionId: string;
|
|
1472
1777
|
sessionCwd: string;
|
|
1778
|
+
worktreeBranch?: string;
|
|
1473
1779
|
latestTotalTokens: number;
|
|
1474
1780
|
permissionMode: PermissionMode;
|
|
1475
1781
|
availableTools: string[];
|
|
@@ -1486,6 +1792,11 @@ export class AgentBridge {
|
|
|
1486
1792
|
// destroy --remove-worktree resolves the session's worktree from this
|
|
1487
1793
|
// value, and it must not drift when the session cd'd out of the worktree.
|
|
1488
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
|
+
: {}),
|
|
1489
1800
|
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
1490
1801
|
permissionMode: entry.agent.getPermissionMode(),
|
|
1491
1802
|
availableTools: entry.agent.getAvailableToolNames(),
|
|
@@ -1655,15 +1966,25 @@ export class AgentBridge {
|
|
|
1655
1966
|
private async updateUserSettings(
|
|
1656
1967
|
patch: UserPreferenceSettings,
|
|
1657
1968
|
): Promise<UserPreferenceSettingsView> {
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
);
|
|
1665
|
-
|
|
1666
|
-
|
|
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;
|
|
1667
1988
|
}
|
|
1668
1989
|
|
|
1669
1990
|
/**
|
|
@@ -1681,7 +2002,7 @@ export class AgentBridge {
|
|
|
1681
2002
|
private async sendMessage(
|
|
1682
2003
|
params: {
|
|
1683
2004
|
text: string;
|
|
1684
|
-
images?:
|
|
2005
|
+
images?: UserImage[];
|
|
1685
2006
|
force?: boolean;
|
|
1686
2007
|
},
|
|
1687
2008
|
sessionId?: string,
|
|
@@ -1721,15 +2042,17 @@ export class AgentBridge {
|
|
|
1721
2042
|
|
|
1722
2043
|
/**
|
|
1723
2044
|
* Webview hosts (desktop/vscode/jetbrains) send pasted images as inline
|
|
1724
|
-
* data URLs — there is no local file behind them.
|
|
1725
|
-
*
|
|
1726
|
-
*
|
|
1727
|
-
*
|
|
1728
|
-
*
|
|
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.
|
|
1729
2054
|
*/
|
|
1730
|
-
private persistDataUrlImages(
|
|
1731
|
-
images?: Array<{ path: string; mimeType: string }>,
|
|
1732
|
-
): Array<{ path: string; mimeType: string }> | undefined {
|
|
2055
|
+
private persistDataUrlImages(images?: UserImage[]): UserImage[] | undefined {
|
|
1733
2056
|
if (!images || images.length === 0) return images;
|
|
1734
2057
|
return images.map((img) => {
|
|
1735
2058
|
if (!img.path.startsWith("data:")) return img;
|
|
@@ -1743,7 +2066,7 @@ export class AgentBridge {
|
|
|
1743
2066
|
`wave-image-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${ext}`,
|
|
1744
2067
|
);
|
|
1745
2068
|
writeFileSync(filePath, Buffer.from(match[2], "base64"));
|
|
1746
|
-
return {
|
|
2069
|
+
return { ...img, mimeType, filePath };
|
|
1747
2070
|
} catch (error) {
|
|
1748
2071
|
logger.warn("Failed to persist pasted image to temp file:", error);
|
|
1749
2072
|
return img;
|
|
@@ -1805,43 +2128,33 @@ export class AgentBridge {
|
|
|
1805
2128
|
inputContent: string;
|
|
1806
2129
|
}> {
|
|
1807
2130
|
const entry = this.requireSession(sessionId);
|
|
1808
|
-
|
|
1809
|
-
//
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
const index = messages.map((m) => m.id).lastIndexOf(messageId);
|
|
1813
|
-
if (index === -1) {
|
|
2131
|
+
// 扫转录按 id 定位(同 id 多次出现取最后一次),不读整份线程:这条路上
|
|
2132
|
+
// 需要的只是「在完整线程里的位置」与「那段文本」。
|
|
2133
|
+
const target = await entry.agent.findRewindTarget(messageId);
|
|
2134
|
+
if (!target) {
|
|
1814
2135
|
throw new RpcError(
|
|
1815
2136
|
PROTOCOL_INTERNAL_ERROR,
|
|
1816
2137
|
`Message not found: ${messageId}`,
|
|
1817
2138
|
);
|
|
1818
2139
|
}
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
| { content?: string }
|
|
1822
|
-
| undefined;
|
|
1823
|
-
await entry.agent.truncateHistory(index);
|
|
1824
|
-
return { inputContent: textBlock?.content || "" };
|
|
2140
|
+
await entry.agent.truncateHistory(target.index);
|
|
2141
|
+
return { inputContent: target.content };
|
|
1825
2142
|
}
|
|
1826
2143
|
|
|
1827
2144
|
private async listRewindCheckpoints(sessionId?: string): Promise<{
|
|
1828
2145
|
checkpoints: Array<{ id: string; content: string }>;
|
|
1829
2146
|
}> {
|
|
1830
2147
|
const entry = this.requireSessionForRead(sessionId);
|
|
1831
|
-
|
|
1832
|
-
//
|
|
1833
|
-
//
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
});
|
|
1842
|
-
}
|
|
1843
|
-
}
|
|
1844
|
-
return { checkpoints: Array.from(checkpointMap.values()) };
|
|
2148
|
+
// 检查点表由 SDK 扫转录投影出来(`{id, content, index}`),这里只为页面
|
|
2149
|
+
// 摘掉它用不到的 `index`:回滚是另一次 `rewindToMessage` 调用,页面不需要
|
|
2150
|
+
// 位置。压缩 append-only 的同 id 去重也在 SDK 那侧做完了。
|
|
2151
|
+
const checkpoints = await entry.agent.getRewindCheckpoints();
|
|
2152
|
+
return {
|
|
2153
|
+
checkpoints: checkpoints.map(({ id, content }) => ({
|
|
2154
|
+
id,
|
|
2155
|
+
content: content.replace(/\s+/g, " ").trim(),
|
|
2156
|
+
})),
|
|
2157
|
+
};
|
|
1845
2158
|
}
|
|
1846
2159
|
|
|
1847
2160
|
private deleteQueuedMessage(index: number, sessionId?: string): null {
|
|
@@ -1853,7 +2166,7 @@ export class AgentBridge {
|
|
|
1853
2166
|
private updateQueuedMessage(
|
|
1854
2167
|
id: string,
|
|
1855
2168
|
text: string,
|
|
1856
|
-
images:
|
|
2169
|
+
images: UserImage[] | undefined,
|
|
1857
2170
|
sessionId?: string,
|
|
1858
2171
|
): { ok: boolean } {
|
|
1859
2172
|
const entry = this.requireSession(sessionId);
|
|
@@ -1874,22 +2187,23 @@ export class AgentBridge {
|
|
|
1874
2187
|
* Read a session's display messages.
|
|
1875
2188
|
*
|
|
1876
2189
|
* With no parameters this returns the whole list the agent holds, read once
|
|
1877
|
-
* and cached nowhere
|
|
1878
|
-
*
|
|
2190
|
+
* and cached nowhere. The parameters pick one page instead
|
|
2191
|
+
* (spec ui/message-rendering-system.md「首屏一页,上翻按需取更早」):
|
|
1879
2192
|
*
|
|
1880
|
-
* - `limit` — the newest `limit` messages (
|
|
2193
|
+
* - `limit` — the newest `limit` messages (首屏;远控页按单帧预算给 100).
|
|
2194
|
+
* - `before` — the page just above that id, read from the transcript
|
|
2195
|
+
* (上翻取更早;压缩之前的历史走这条). Comes back with `hasMore`
|
|
2196
|
+
* so the page knows when it reached the start of the session.
|
|
1881
2197
|
* - `since` — everything appended after the given message id (catching up
|
|
1882
2198
|
* after a reconnect).
|
|
1883
2199
|
*
|
|
1884
|
-
*
|
|
1885
|
-
*
|
|
1886
|
-
*
|
|
1887
|
-
*
|
|
1888
|
-
*
|
|
1889
|
-
*
|
|
1890
|
-
*
|
|
1891
|
-
* could detect it otherwise — and that comes back as `reset: true` so the
|
|
1892
|
-
* caller re-pulls instead of guessing.
|
|
2200
|
+
* `since` / `before` are positions, not version numbers: message ids are
|
|
2201
|
+
* stable, so a caller can name its last known message without the list
|
|
2202
|
+
* carrying a sequence number. The one failure that matters is the anchor
|
|
2203
|
+
* having been truncated away (rewind / clear) — there is no seq/version in
|
|
2204
|
+
* the store that could detect it otherwise — and that comes back as
|
|
2205
|
+
* `reset: true` (for `since`) or `anchorLost: true` (for `before`) so the
|
|
2206
|
+
* caller re-pulls / retries instead of guessing.
|
|
1893
2207
|
*
|
|
1894
2208
|
* Ids can repeat (old transcripts re-appended the preserved rounds at each
|
|
1895
2209
|
* compact point), so anchors are resolved to their **last** occurrence:
|
|
@@ -1932,7 +2246,7 @@ export class AgentBridge {
|
|
|
1932
2246
|
threadSummary,
|
|
1933
2247
|
};
|
|
1934
2248
|
}
|
|
1935
|
-
const page = entry.agent.getMessagePage(normalized);
|
|
2249
|
+
const page = await entry.agent.getMessagePage(normalized);
|
|
1936
2250
|
return {
|
|
1937
2251
|
...page,
|
|
1938
2252
|
messages: messagesForUi(page.messages),
|
|
@@ -1941,15 +2255,6 @@ export class AgentBridge {
|
|
|
1941
2255
|
};
|
|
1942
2256
|
}
|
|
1943
2257
|
|
|
1944
|
-
private async getFullMessageThread(sessionId?: string): Promise<{
|
|
1945
|
-
messages: Message[];
|
|
1946
|
-
sessionIds: string[];
|
|
1947
|
-
}> {
|
|
1948
|
-
const entry = this.requireSessionForRead(sessionId);
|
|
1949
|
-
const thread = await entry.agent.getFullMessageThread();
|
|
1950
|
-
return { ...thread, messages: messagesForUi(thread.messages) };
|
|
1951
|
-
}
|
|
1952
|
-
|
|
1953
2258
|
private async compact(
|
|
1954
2259
|
customInstructions: string | undefined,
|
|
1955
2260
|
sessionId?: string,
|
|
@@ -2960,10 +3265,10 @@ export class AgentBridge {
|
|
|
2960
3265
|
ctx.registeredSessionId,
|
|
2961
3266
|
);
|
|
2962
3267
|
},
|
|
2963
|
-
|
|
3268
|
+
onNoticeAdded: (notice) => {
|
|
2964
3269
|
this.emit(
|
|
2965
|
-
"
|
|
2966
|
-
remoteWire.
|
|
3270
|
+
"noticeAdded",
|
|
3271
|
+
remoteWire.noticeAdded(notice),
|
|
2967
3272
|
ctx.registeredSessionId,
|
|
2968
3273
|
);
|
|
2969
3274
|
},
|
|
@@ -3037,6 +3342,16 @@ export class AgentBridge {
|
|
|
3037
3342
|
ctx.registeredSessionId,
|
|
3038
3343
|
);
|
|
3039
3344
|
},
|
|
3345
|
+
onWorkspaceRootChange: (workspaceRoot: string) => {
|
|
3346
|
+
// EnterWorktree / ExitWorktree only: the conversation now works in
|
|
3347
|
+
// another working tree, so the host re-points the surfaces that follow
|
|
3348
|
+
// the user's workspace (a Bash `cd` never lands here).
|
|
3349
|
+
this.emit(
|
|
3350
|
+
"workspaceRootChange",
|
|
3351
|
+
{ workspaceRoot },
|
|
3352
|
+
ctx.registeredSessionId,
|
|
3353
|
+
);
|
|
3354
|
+
},
|
|
3040
3355
|
onMcpServersChange: (servers: McpServerStatus[]) => {
|
|
3041
3356
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
3042
3357
|
},
|
|
@@ -3185,26 +3500,37 @@ async function readTextFileSafe(filePath: string): Promise<string> {
|
|
|
3185
3500
|
}
|
|
3186
3501
|
}
|
|
3187
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
|
+
|
|
3188
3512
|
/**
|
|
3189
|
-
* Read the creation-time
|
|
3513
|
+
* Read the creation-time facts from a session transcript's metadata header —
|
|
3190
3514
|
* the first line `{"type":"metadata","workdir":...,"createdAt":...,"gitBranch":...}`
|
|
3191
3515
|
* the SDK writes once at session creation. The header is append-only, so it
|
|
3192
3516
|
* records where the session was created even after the live working directory
|
|
3193
3517
|
* drifted (in-session `cd`) or after a daemon restart re-anchored the agent at
|
|
3194
|
-
* 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
|
|
3195
3520
|
* header), or unreadable. Only the header line is read (transcripts can grow
|
|
3196
3521
|
* large); a 4KiB read covers any realistic header.
|
|
3197
3522
|
*/
|
|
3198
|
-
async function
|
|
3199
|
-
|
|
3200
|
-
|
|
3201
|
-
|
|
3523
|
+
async function readTranscriptHeader(transcriptPath?: string): Promise<{
|
|
3524
|
+
workdir?: string;
|
|
3525
|
+
gitBranch?: string;
|
|
3526
|
+
}> {
|
|
3527
|
+
if (!transcriptPath) return {};
|
|
3202
3528
|
try {
|
|
3203
3529
|
const handle = await open(transcriptPath, "r");
|
|
3204
3530
|
try {
|
|
3205
3531
|
const buffer = Buffer.alloc(4096);
|
|
3206
3532
|
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
3207
|
-
if (bytesRead === 0) return
|
|
3533
|
+
if (bytesRead === 0) return {};
|
|
3208
3534
|
const newline = buffer.indexOf(0x0a);
|
|
3209
3535
|
const firstLine = buffer
|
|
3210
3536
|
.subarray(0, newline === -1 ? bytesRead : newline)
|
|
@@ -3212,14 +3538,21 @@ async function readTranscriptWorkdir(
|
|
|
3212
3538
|
const header = JSON.parse(firstLine) as {
|
|
3213
3539
|
type?: string;
|
|
3214
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,
|
|
3215
3551
|
};
|
|
3216
|
-
return header.type === "metadata" && typeof header.workdir === "string"
|
|
3217
|
-
? header.workdir
|
|
3218
|
-
: undefined;
|
|
3219
3552
|
} finally {
|
|
3220
3553
|
await handle.close();
|
|
3221
3554
|
}
|
|
3222
3555
|
} catch {
|
|
3223
|
-
return
|
|
3556
|
+
return {};
|
|
3224
3557
|
}
|
|
3225
3558
|
}
|