wave-code 1.3.0 → 1.5.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/dist/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -37,6 +37,8 @@ import {
|
|
|
37
37
|
isValidHookEvent,
|
|
38
38
|
listSessions,
|
|
39
39
|
listAllSessions,
|
|
40
|
+
readRecentWorkdirs,
|
|
41
|
+
rememberRecentWorkdir,
|
|
40
42
|
setSessionCustomTitle,
|
|
41
43
|
searchFiles,
|
|
42
44
|
generateRandomName,
|
|
@@ -46,7 +48,9 @@ import {
|
|
|
46
48
|
AuthService,
|
|
47
49
|
PluginCore,
|
|
48
50
|
validateWorktreeRemovalPath,
|
|
49
|
-
type
|
|
51
|
+
type HostSurface,
|
|
52
|
+
type SlashCommandInfo,
|
|
53
|
+
type ConfiguredModel,
|
|
50
54
|
loadUserConfigEnv,
|
|
51
55
|
loadWaveConfigFromFile,
|
|
52
56
|
getUserConfigPaths,
|
|
@@ -58,28 +62,72 @@ import {
|
|
|
58
62
|
type SubagentConfiguration,
|
|
59
63
|
type SkillMetadata,
|
|
60
64
|
type PluginReloadResult,
|
|
65
|
+
type RemoteControlSnapshot,
|
|
66
|
+
type CompletionSnippet,
|
|
67
|
+
type NextEditExcerpt,
|
|
68
|
+
type NextEditHistoryEntry,
|
|
69
|
+
type NextEditRegion,
|
|
70
|
+
type NextEditSuggestion,
|
|
71
|
+
normalizeEntrypoint,
|
|
72
|
+
fetchArtifactGalleryList,
|
|
73
|
+
fetchArtifactShareView,
|
|
74
|
+
saveArtifactShare,
|
|
75
|
+
revokeArtifact,
|
|
76
|
+
searchArtifactMembers,
|
|
77
|
+
type ArtifactShareMode,
|
|
61
78
|
} from "wave-agent-sdk";
|
|
62
79
|
import {
|
|
80
|
+
type ClientNotificationMethod,
|
|
63
81
|
type JsonRpcError,
|
|
82
|
+
type RequestMethod,
|
|
83
|
+
type ServerNotificationMethod,
|
|
64
84
|
INVALID_PARAMS as PROTOCOL_INVALID_PARAMS,
|
|
65
85
|
INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
|
|
66
86
|
METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
|
|
67
87
|
} from "./protocol.js";
|
|
68
88
|
import { execFileSync } from "node:child_process";
|
|
69
|
-
import {
|
|
89
|
+
import {
|
|
90
|
+
mkdirSync,
|
|
91
|
+
existsSync,
|
|
92
|
+
readFileSync,
|
|
93
|
+
writeFileSync,
|
|
94
|
+
readdirSync,
|
|
95
|
+
realpathSync,
|
|
96
|
+
} from "node:fs";
|
|
70
97
|
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
71
98
|
import { homedir, tmpdir } from "node:os";
|
|
72
99
|
import { basename, dirname, extname, join } from "node:path";
|
|
73
100
|
import {
|
|
74
101
|
createWorktree,
|
|
102
|
+
deriveWorktreeInfo,
|
|
75
103
|
getWorktreeChanges,
|
|
76
104
|
removeWorktree,
|
|
77
105
|
} from "../utils/worktree.js";
|
|
78
106
|
import { logger } from "../utils/logger.js";
|
|
107
|
+
import {
|
|
108
|
+
InvalidMessageWindowError,
|
|
109
|
+
normalizeMessageWindow,
|
|
110
|
+
type MessageWindow,
|
|
111
|
+
type MessageWindowResult,
|
|
112
|
+
} from "../remote/messageWindow.js";
|
|
113
|
+
import {
|
|
114
|
+
percentOf,
|
|
115
|
+
remoteWire,
|
|
116
|
+
toBackgroundTaskSummaries,
|
|
117
|
+
} from "../remote/remoteNotifications.js";
|
|
79
118
|
import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
|
|
119
|
+
import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
|
|
120
|
+
import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
|
|
121
|
+
import type { RemoteControlController } from "../remote/remoteControlService.js";
|
|
122
|
+
import {
|
|
123
|
+
hostSlashCommandsFor,
|
|
124
|
+
parseHostSurface,
|
|
125
|
+
toSlashCommandInfo,
|
|
126
|
+
} from "./hostSlashCommands.js";
|
|
127
|
+
import { messageForUi, messagesForUi } from "./uiPayload.js";
|
|
80
128
|
|
|
81
129
|
export type NotificationEmitter = (
|
|
82
|
-
method:
|
|
130
|
+
method: ServerNotificationMethod,
|
|
83
131
|
params: unknown,
|
|
84
132
|
sessionId?: string,
|
|
85
133
|
) => void;
|
|
@@ -91,6 +139,17 @@ export interface AgentBridgeOptions {
|
|
|
91
139
|
* down its socket and exits. stdio mode leaves it unset, so `shutdown` is
|
|
92
140
|
* "Method not found" there. */
|
|
93
141
|
onShutdownRequest?: () => void;
|
|
142
|
+
/** Daemon transport only. Remote control is a daemon-level concern (it owns
|
|
143
|
+
* an outbound socket and a relay session); the bridge owns nothing here and
|
|
144
|
+
* only routes the three `remoteControl*` RPCs to it, exactly as it routes
|
|
145
|
+
* `shutdown` to `onShutdownRequest`. Unset ⇒ those RPCs report that remote
|
|
146
|
+
* control is not enabled, which is the state on every stdio-mode process.
|
|
147
|
+
*
|
|
148
|
+
* Resolved on demand rather than passed as an object: the controller needs
|
|
149
|
+
* this bridge to route the phone's requests back in, so the two are built in
|
|
150
|
+
* a circle. Reading it when an RPC arrives — long after both exist — keeps
|
|
151
|
+
* that circle from having to be broken with a placeholder. */
|
|
152
|
+
remoteControl?: () => RemoteControlController | undefined;
|
|
94
153
|
}
|
|
95
154
|
|
|
96
155
|
interface InitializeParams {
|
|
@@ -110,6 +169,13 @@ interface InitializeParams {
|
|
|
110
169
|
mcpServers?: Record<string, McpServerConfig>;
|
|
111
170
|
worktreeName?: string;
|
|
112
171
|
isNewWorktree?: boolean;
|
|
172
|
+
/**
|
|
173
|
+
* Which host the session belongs to → the `app.entrypoint` attribute. A raw
|
|
174
|
+
* string: it arrives over the wire, from hosts this process does not compile
|
|
175
|
+
* against (the JetBrains plugin builds its own frames). Narrowed to the known
|
|
176
|
+
* table below before it reaches the SDK.
|
|
177
|
+
*/
|
|
178
|
+
entrypoint?: string;
|
|
113
179
|
}
|
|
114
180
|
|
|
115
181
|
// 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
|
|
@@ -135,7 +201,7 @@ interface SessionEntry {
|
|
|
135
201
|
storedConfig: Partial<InitializeParams>;
|
|
136
202
|
/**
|
|
137
203
|
* The workdir the session was CREATED in — an immutable identity, unlike the
|
|
138
|
-
* live `agent.
|
|
204
|
+
* live `agent.currentWorkdir`, which drifts as the session `cd`s (bash tool
|
|
139
205
|
* / worktree switches). Hosted-session consumers (destroy --remove-worktree's
|
|
140
206
|
* worktree resolution, list) read this instead of the live value so a session
|
|
141
207
|
* that moved out of its worktree into the main repo is still cleaned up at
|
|
@@ -189,14 +255,34 @@ export class AgentBridge {
|
|
|
189
255
|
}
|
|
190
256
|
>();
|
|
191
257
|
private permissionCounter = 0;
|
|
258
|
+
/**
|
|
259
|
+
* In-flight completion calls, keyed by envelope sessionId. Completion answers
|
|
260
|
+
* a keystroke pause, so a newer request makes the previous one worthless —
|
|
261
|
+
* superseding it here is what keeps "one in-flight request per editor" true
|
|
262
|
+
* even when the host cannot cancel (spec
|
|
263
|
+
* docs/specs/ui/code-completion.md: 同一时刻只允许一个补全请求在途).
|
|
264
|
+
*/
|
|
265
|
+
private inFlightCompletions = new Map<string, AbortController>();
|
|
266
|
+
/**
|
|
267
|
+
* In-flight next-edit calls, same one-per-session rule and same reason as
|
|
268
|
+
* completions. It also participates in the cross-capability exclusion from
|
|
269
|
+
* `docs/specs/ui/next-edit-suggestion.md`: completion and next-edit never hold
|
|
270
|
+
* an in-flight request at the same time, so one of them cannot answer into a
|
|
271
|
+
* buffer the other has already rewritten.
|
|
272
|
+
*/
|
|
273
|
+
private inFlightNextEdits = new Map<string, AbortController>();
|
|
192
274
|
private emit: NotificationEmitter;
|
|
193
275
|
private onShutdownRequest: (() => void) | undefined;
|
|
276
|
+
private remoteControl:
|
|
277
|
+
| (() => RemoteControlController | undefined)
|
|
278
|
+
| undefined;
|
|
194
279
|
private pluginCore: PluginCore | undefined;
|
|
195
280
|
private pluginCoreWorkdir: string | undefined;
|
|
196
281
|
|
|
197
282
|
constructor(options: AgentBridgeOptions) {
|
|
198
283
|
this.emit = options.emit;
|
|
199
284
|
this.onShutdownRequest = options.onShutdownRequest;
|
|
285
|
+
this.remoteControl = options.remoteControl;
|
|
200
286
|
// Mirror the user-level settings env WAVE_SERVER_URL into process.env
|
|
201
287
|
// before any agent initializes. getAuthStatus (webviewReady →
|
|
202
288
|
// pushInitialState) can run before the first agent, and AuthService falls
|
|
@@ -211,7 +297,7 @@ export class AgentBridge {
|
|
|
211
297
|
// ── Public API ────────────────────────────────────────────────
|
|
212
298
|
|
|
213
299
|
async handleRequest(
|
|
214
|
-
method:
|
|
300
|
+
method: RequestMethod,
|
|
215
301
|
params: unknown,
|
|
216
302
|
sessionId?: string,
|
|
217
303
|
): Promise<unknown> {
|
|
@@ -221,7 +307,9 @@ export class AgentBridge {
|
|
|
221
307
|
case "initialize":
|
|
222
308
|
return this.initialize(p as unknown as InitializeParams);
|
|
223
309
|
case "destroy":
|
|
224
|
-
return this.destroy(sessionId
|
|
310
|
+
return this.destroy(sessionId, {
|
|
311
|
+
removeWorktree: p.removeWorktree === true,
|
|
312
|
+
});
|
|
225
313
|
case "restoreSession":
|
|
226
314
|
return this.restoreSession(p.sessionId as string, sessionId);
|
|
227
315
|
case "listSessions":
|
|
@@ -311,10 +399,20 @@ export class AgentBridge {
|
|
|
311
399
|
case "deleteQueuedMessageById":
|
|
312
400
|
return this.deleteQueuedMessageById(p.id as string, sessionId);
|
|
313
401
|
case "getMessages":
|
|
314
|
-
return this.getMessages(sessionId
|
|
402
|
+
return this.getMessages(sessionId, {
|
|
403
|
+
since: p.since as string | undefined,
|
|
404
|
+
limit: p.limit as number | undefined,
|
|
405
|
+
});
|
|
315
406
|
case "getFullMessageThread":
|
|
316
407
|
return this.getFullMessageThread(sessionId);
|
|
317
408
|
|
|
409
|
+
// ── Remote control (daemon-level) ──
|
|
410
|
+
// The daemon owns the outbound relay socket; the bridge only routes.
|
|
411
|
+
case "remoteControlStatus":
|
|
412
|
+
case "remoteControlConnect":
|
|
413
|
+
case "remoteControlDisconnect":
|
|
414
|
+
return this.remoteControlRequest(method, p);
|
|
415
|
+
|
|
318
416
|
// ── Permissions ──
|
|
319
417
|
case "setPermissionMode":
|
|
320
418
|
return this.setPermissionMode(p.mode as PermissionMode, sessionId);
|
|
@@ -341,7 +439,7 @@ export class AgentBridge {
|
|
|
341
439
|
|
|
342
440
|
// ── Commands ──
|
|
343
441
|
case "getSlashCommands":
|
|
344
|
-
return this.getSlashCommands(sessionId);
|
|
442
|
+
return this.getSlashCommands(sessionId, parseHostSurface(p.surface));
|
|
345
443
|
case "getSubagentConfigurations":
|
|
346
444
|
return this.getSubagentConfigurations(sessionId);
|
|
347
445
|
case "getSkillMetadata":
|
|
@@ -390,6 +488,30 @@ export class AgentBridge {
|
|
|
390
488
|
return this.login(p.serverUrl as string | undefined);
|
|
391
489
|
case "logout":
|
|
392
490
|
return this.logout();
|
|
491
|
+
case "installGitCredentialHelper":
|
|
492
|
+
return installGitCredentialHelper();
|
|
493
|
+
|
|
494
|
+
// ── 创作集(global — 账号级读写,无 session 要求)──
|
|
495
|
+
// 桌面端整页与右侧预览面板共用这一组;HTTP 与 token 只留在本进程
|
|
496
|
+
// (services/artifactGallery.ts),宿主不直连网络。
|
|
497
|
+
case "artifactGalleryList":
|
|
498
|
+
return fetchArtifactGalleryList();
|
|
499
|
+
case "artifactShareView":
|
|
500
|
+
return fetchArtifactShareView(p.slug as string);
|
|
501
|
+
case "artifactShareSave":
|
|
502
|
+
return saveArtifactShare({
|
|
503
|
+
slug: p.slug as string,
|
|
504
|
+
mode: p.mode as ArtifactShareMode,
|
|
505
|
+
userIds: p.userIds as string[] | undefined,
|
|
506
|
+
pinnedVersion: p.pinnedVersion as number | null | undefined,
|
|
507
|
+
});
|
|
508
|
+
case "artifactRevoke":
|
|
509
|
+
return revokeArtifact(p.slug as string);
|
|
510
|
+
case "artifactMembersSearch":
|
|
511
|
+
return searchArtifactMembers(
|
|
512
|
+
(p.search as string | undefined) ?? "",
|
|
513
|
+
p.limit as number | undefined,
|
|
514
|
+
);
|
|
393
515
|
|
|
394
516
|
// ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
|
|
395
517
|
// project-level <workdir>/AGENTS.md) ──
|
|
@@ -527,7 +649,17 @@ export class AgentBridge {
|
|
|
527
649
|
case "stopWorkflowRun":
|
|
528
650
|
return this.stopWorkflowRun(p.runId as string, sessionId);
|
|
529
651
|
|
|
530
|
-
// ── Git / worktree (global — no session required) ──
|
|
652
|
+
// ── Filesystem / Git / worktree (global — no session required) ──
|
|
653
|
+
case "listDirectories":
|
|
654
|
+
return this.listDirectories(p.dir as string | undefined);
|
|
655
|
+
// ── 最近打开的工作目录(spec core/recent-workdirs.md)──
|
|
656
|
+
// 机器级、与会话无关:文件就在这台机器上,谁问就回谁的。桌面端按主机
|
|
657
|
+
// 各问一次,远控 Web 页问它连着的那个进程。
|
|
658
|
+
case "getRecentWorkdirs":
|
|
659
|
+
return { dirs: readRecentWorkdirs() };
|
|
660
|
+
case "rememberRecentWorkdir":
|
|
661
|
+
rememberRecentWorkdir(p.workdir as string);
|
|
662
|
+
return null;
|
|
531
663
|
case "listGitBranches":
|
|
532
664
|
return this.listGitBranches(p.workdir as string | undefined);
|
|
533
665
|
case "createWorktree":
|
|
@@ -542,6 +674,8 @@ export class AgentBridge {
|
|
|
542
674
|
return this.getWorktreeChanges(
|
|
543
675
|
p as unknown as { path?: string; baseBranch?: string },
|
|
544
676
|
);
|
|
677
|
+
case "getSessionWorktreeChanges":
|
|
678
|
+
return this.getSessionWorktreeChanges(sessionId);
|
|
545
679
|
case "removeWorktree":
|
|
546
680
|
return this.removeWorktreeSession(
|
|
547
681
|
p as unknown as {
|
|
@@ -551,16 +685,64 @@ export class AgentBridge {
|
|
|
551
685
|
hookBased?: boolean;
|
|
552
686
|
},
|
|
553
687
|
);
|
|
688
|
+
case "getWorkspaceDiff":
|
|
689
|
+
return this.getWorkspaceDiff(
|
|
690
|
+
p.workdir as string | undefined,
|
|
691
|
+
p.commit as string | undefined,
|
|
692
|
+
p.scope as string | undefined,
|
|
693
|
+
);
|
|
694
|
+
case "getCompletion":
|
|
695
|
+
return this.getCompletion(
|
|
696
|
+
p as unknown as {
|
|
697
|
+
prefix?: string;
|
|
698
|
+
suffix?: string;
|
|
699
|
+
language?: string;
|
|
700
|
+
filePath?: string;
|
|
701
|
+
snippets?: CompletionSnippet[];
|
|
702
|
+
},
|
|
703
|
+
sessionId,
|
|
704
|
+
);
|
|
705
|
+
case "reportCompletionOutcome":
|
|
706
|
+
return this.reportCompletionOutcome(
|
|
707
|
+
p.outcome as "displayed" | "accepted",
|
|
708
|
+
sessionId,
|
|
709
|
+
);
|
|
710
|
+
case "reportNextEditOutcome":
|
|
711
|
+
return this.reportNextEditOutcome(
|
|
712
|
+
p.outcome as "displayed" | "accepted",
|
|
713
|
+
sessionId,
|
|
714
|
+
);
|
|
715
|
+
case "getNextEdit":
|
|
716
|
+
return this.getNextEdit(
|
|
717
|
+
p as unknown as {
|
|
718
|
+
edits?: NextEditHistoryEntry[];
|
|
719
|
+
region?: NextEditRegion;
|
|
720
|
+
excerpt?: NextEditExcerpt;
|
|
721
|
+
language?: string;
|
|
722
|
+
filePath?: string;
|
|
723
|
+
},
|
|
724
|
+
sessionId,
|
|
725
|
+
);
|
|
554
726
|
|
|
555
|
-
default:
|
|
727
|
+
default: {
|
|
728
|
+
// Type-level exhaustiveness, both directions: a union member without a
|
|
729
|
+
// `case` lands here as a non-`never` and fails to compile, and a `case`
|
|
730
|
+
// naming something outside the union fails to compile at the `case`
|
|
731
|
+
// itself. Runtime behaviour is unchanged — `method` can still be an
|
|
732
|
+
// arbitrary string once the transport boundary cast it.
|
|
733
|
+
const _exhaustive: never = method;
|
|
734
|
+
// Kept referenced so the witness isn't an unused-variable lint error;
|
|
735
|
+
// it is never read at runtime.
|
|
736
|
+
void _exhaustive;
|
|
556
737
|
throw new RpcError(
|
|
557
738
|
PROTOCOL_METHOD_NOT_FOUND,
|
|
558
739
|
`Method not found: ${method}`,
|
|
559
740
|
);
|
|
741
|
+
}
|
|
560
742
|
}
|
|
561
743
|
}
|
|
562
744
|
|
|
563
|
-
handleNotification(method:
|
|
745
|
+
handleNotification(method: ClientNotificationMethod, params: unknown): void {
|
|
564
746
|
if (method === "permissionResponse") {
|
|
565
747
|
const p = params as {
|
|
566
748
|
requestId: string;
|
|
@@ -579,7 +761,9 @@ export class AgentBridge {
|
|
|
579
761
|
|
|
580
762
|
private async initialize(params: InitializeParams): Promise<{
|
|
581
763
|
sessionId: string;
|
|
582
|
-
|
|
764
|
+
currentWorkdir: string;
|
|
765
|
+
sessionCwd: string;
|
|
766
|
+
repoRoot?: string;
|
|
583
767
|
permissionMode: PermissionMode;
|
|
584
768
|
latestTotalTokens: number;
|
|
585
769
|
}> {
|
|
@@ -589,18 +773,23 @@ export class AgentBridge {
|
|
|
589
773
|
if (params.restoreSessionId) {
|
|
590
774
|
const live = this.sessions.get(params.restoreSessionId);
|
|
591
775
|
if (live) {
|
|
592
|
-
return
|
|
593
|
-
sessionId: live.agent.sessionId,
|
|
594
|
-
workingDirectory: live.agent.workingDirectory,
|
|
595
|
-
permissionMode: live.agent.getPermissionMode(),
|
|
596
|
-
latestTotalTokens: live.agent.latestTotalTokens,
|
|
597
|
-
};
|
|
776
|
+
return this.initializeReply(live.agent, live.createdWorkdir);
|
|
598
777
|
}
|
|
599
778
|
}
|
|
600
779
|
|
|
601
780
|
const ctx: SessionContext = {};
|
|
602
781
|
const callbacks = this.createCallbacks(ctx);
|
|
603
782
|
|
|
783
|
+
const entrypoint = normalizeEntrypoint(params.entrypoint);
|
|
784
|
+
if (params.entrypoint !== undefined && entrypoint === undefined) {
|
|
785
|
+
// A host that ships a value this version does not know (an older client,
|
|
786
|
+
// a hand-written frame) is treated as if it had declared nothing: an
|
|
787
|
+
// unrecognised value must not mint a series of its own.
|
|
788
|
+
logger?.warn(
|
|
789
|
+
`Unknown entrypoint: ${String(params.entrypoint)}, ignoring`,
|
|
790
|
+
);
|
|
791
|
+
}
|
|
792
|
+
|
|
604
793
|
const options: AgentOptions = {
|
|
605
794
|
callbacks,
|
|
606
795
|
logger,
|
|
@@ -619,6 +808,7 @@ export class AgentBridge {
|
|
|
619
808
|
mcpServers: params.mcpServers,
|
|
620
809
|
worktreeName: params.worktreeName,
|
|
621
810
|
isNewWorktree: params.isNewWorktree,
|
|
811
|
+
entrypoint,
|
|
622
812
|
canUseTool: (context: ToolPermissionContext) =>
|
|
623
813
|
this.canUseTool(context, ctx),
|
|
624
814
|
};
|
|
@@ -639,9 +829,9 @@ export class AgentBridge {
|
|
|
639
829
|
createdWorkdir =
|
|
640
830
|
(await readTranscriptWorkdir(agent.sessionFilePath)) ??
|
|
641
831
|
params.workdir ??
|
|
642
|
-
agent.
|
|
832
|
+
agent.currentWorkdir;
|
|
643
833
|
} else {
|
|
644
|
-
createdWorkdir = params.workdir ?? agent.
|
|
834
|
+
createdWorkdir = params.workdir ?? agent.currentWorkdir;
|
|
645
835
|
}
|
|
646
836
|
|
|
647
837
|
this.sessions.set(agent.sessionId, {
|
|
@@ -652,15 +842,84 @@ export class AgentBridge {
|
|
|
652
842
|
createdWorkdir,
|
|
653
843
|
});
|
|
654
844
|
|
|
845
|
+
return this.initializeReply(agent, createdWorkdir);
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/**
|
|
849
|
+
* The `initialize` reply.
|
|
850
|
+
*
|
|
851
|
+
* The current cwd and the session root travel as two separate facts on
|
|
852
|
+
* purpose. A re-attached session may have bash-`cd`'d away from where it was
|
|
853
|
+
* created, so `agent.currentWorkdir` answers "where is it now" while
|
|
854
|
+
* `createdWorkdir` answers "which project does this conversation belong to".
|
|
855
|
+
* Hosts key durable things (sidebar grouping, project-scoped config, `@file`
|
|
856
|
+
* scope) off the latter — the desktop used to derive both from one
|
|
857
|
+
* `workingDirectory` field and moved a resumed conversation into a
|
|
858
|
+
* subdirectory group.
|
|
859
|
+
*
|
|
860
|
+
* `repoRoot` closes the worktree case: a session created inside a linked git
|
|
861
|
+
* worktree belongs to the repo it was forked from. It is derived the same way
|
|
862
|
+
* `listDaemonSessions` derives each row's `worktree`, i.e. from the
|
|
863
|
+
* filesystem, without shelling out to git.
|
|
864
|
+
*/
|
|
865
|
+
private initializeReply(
|
|
866
|
+
agent: Agent,
|
|
867
|
+
sessionCwd: string,
|
|
868
|
+
): {
|
|
869
|
+
sessionId: string;
|
|
870
|
+
currentWorkdir: string;
|
|
871
|
+
sessionCwd: string;
|
|
872
|
+
repoRoot?: string;
|
|
873
|
+
permissionMode: PermissionMode;
|
|
874
|
+
latestTotalTokens: number;
|
|
875
|
+
} {
|
|
876
|
+
const worktree = deriveWorktreeInfo(sessionCwd);
|
|
655
877
|
return {
|
|
656
878
|
sessionId: agent.sessionId,
|
|
657
|
-
|
|
879
|
+
currentWorkdir: agent.currentWorkdir,
|
|
880
|
+
sessionCwd,
|
|
881
|
+
...(worktree ? { repoRoot: worktree.repoRoot } : {}),
|
|
658
882
|
permissionMode: agent.getPermissionMode(),
|
|
659
883
|
latestTotalTokens: agent.latestTotalTokens,
|
|
660
884
|
};
|
|
661
885
|
}
|
|
662
886
|
|
|
663
|
-
|
|
887
|
+
/**
|
|
888
|
+
* Destroy one hosted session (idempotent: an unknown id is a no-op).
|
|
889
|
+
*
|
|
890
|
+
* `params.removeWorktree` (optional, default false) additionally removes the
|
|
891
|
+
* git worktree the session was **created** in — the intent the remote page
|
|
892
|
+
* sends when the user confirms 「删除会话与工作树」. Two rules make it safe to
|
|
893
|
+
* offer over the remote channel, where the caller must never be able to name a
|
|
894
|
+
* path:
|
|
895
|
+
*
|
|
896
|
+
* 1. The worktree is resolved here, from `entry.createdWorkdir` — the same
|
|
897
|
+
* anchor `wave daemon destroy --remove-worktree` resolves from (and for the
|
|
898
|
+
* same reason: an in-session `cd` must not move the target). Nothing in the
|
|
899
|
+
* request is trusted as a path, which is why the raw `removeWorktree`
|
|
900
|
+
* method stays off the remote whitelist.
|
|
901
|
+
* 2. It runs **after** `agent.destroy()` resolves. The agent's cwd is the
|
|
902
|
+
* worktree path and destroy flushes the session file (resolving that path
|
|
903
|
+
* via realpath); deleting the directory first makes the save fail ENOENT
|
|
904
|
+
* and drops the trailing messages — the desktop host carries the identical
|
|
905
|
+
* ordering note in `handleDeleteSession`.
|
|
906
|
+
*
|
|
907
|
+
* "Cannot resolve a worktree" is never a reason to fail the delete: the
|
|
908
|
+
* session is still destroyed and the reply says `worktreeRemoved: false`. That
|
|
909
|
+
* covers a plain (non-worktree) session, a worktree that is already gone, and
|
|
910
|
+
* a removal that threw — the caller only ever needs to know whether the
|
|
911
|
+
* directory is gone.
|
|
912
|
+
*
|
|
913
|
+
* The return shape grew from `null` to `{worktreeRemoved}`; callers that pass
|
|
914
|
+
* no params (the CLI's own `wave daemon destroy`) read nothing and are
|
|
915
|
+
* unaffected. The CLI's `--remove-worktree` keeps its own client-side path
|
|
916
|
+
* (getSessionInfo + removeWorktree + destroy) — deliberately unchanged.
|
|
917
|
+
*/
|
|
918
|
+
private async destroy(
|
|
919
|
+
sessionId?: string,
|
|
920
|
+
params?: { removeWorktree?: boolean },
|
|
921
|
+
): Promise<{ worktreeRemoved: boolean }> {
|
|
922
|
+
let createdWorkdir: string | undefined;
|
|
664
923
|
if (sessionId) {
|
|
665
924
|
const entry = this.sessions.get(sessionId);
|
|
666
925
|
if (entry) {
|
|
@@ -687,10 +946,71 @@ export class AgentBridge {
|
|
|
687
946
|
// half-destroyed agent listed (destroyAll() below clears the map first
|
|
688
947
|
// for the same reason).
|
|
689
948
|
this.sessions.delete(sessionId);
|
|
949
|
+
createdWorkdir = entry.createdWorkdir;
|
|
690
950
|
await entry.agent.destroy();
|
|
691
951
|
}
|
|
692
952
|
}
|
|
693
|
-
|
|
953
|
+
|
|
954
|
+
let worktreeRemoved = false;
|
|
955
|
+
if (params?.removeWorktree === true && createdWorkdir) {
|
|
956
|
+
worktreeRemoved = await this.removeSessionWorktree(createdWorkdir);
|
|
957
|
+
}
|
|
958
|
+
return { worktreeRemoved };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* Remove the linked worktree a session was created in. Returns whether the
|
|
963
|
+
* directory is actually gone (not whether we asked): `removeWorktree` is
|
|
964
|
+
* best-effort and leaves the directory behind when git and the fs fallback
|
|
965
|
+
* both fail, and for a hook-based worktree with no WorktreeRemove hook it
|
|
966
|
+
* deliberately leaves it in place. Never throws — the caller has already
|
|
967
|
+
* deleted the session.
|
|
968
|
+
*/
|
|
969
|
+
private async removeSessionWorktree(sessionDir: string): Promise<boolean> {
|
|
970
|
+
const worktree = deriveWorktreeInfo(sessionDir);
|
|
971
|
+
if (!worktree) return false;
|
|
972
|
+
try {
|
|
973
|
+
await removeWorktree({
|
|
974
|
+
name: "",
|
|
975
|
+
path: worktree.path,
|
|
976
|
+
branch: worktree.branch,
|
|
977
|
+
repoRoot: worktree.repoRoot,
|
|
978
|
+
hasUncommittedChanges: false,
|
|
979
|
+
hasNewCommits: false,
|
|
980
|
+
isNew: false,
|
|
981
|
+
hookBased: worktree.hookBased,
|
|
982
|
+
});
|
|
983
|
+
} catch (e) {
|
|
984
|
+
logger.warn(
|
|
985
|
+
`removeSessionWorktree: failed to remove ${worktree.path}: ${(e as Error).message}`,
|
|
986
|
+
);
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
return !existsSync(worktree.path);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* What deleting a session's worktree would throw away (uncommitted files and
|
|
994
|
+
* commits not on the base branch) — the counts the delete confirmation names
|
|
995
|
+
* on the remote page.
|
|
996
|
+
*
|
|
997
|
+
* Session-scoped on purpose: the path comes from the session's own
|
|
998
|
+
* `createdWorkdir`, never from the caller. The remote channel carries this
|
|
999
|
+
* instead of the path-taking `getWorktreeChanges`, for the same reason it
|
|
1000
|
+
* carries `destroy {removeWorktree}` instead of `removeWorktree` — a phone may
|
|
1001
|
+
* only name a session it was given, not an arbitrary directory on the machine.
|
|
1002
|
+
*
|
|
1003
|
+
* `null` means "could not inspect" (not a worktree, already deleted, git
|
|
1004
|
+
* failed), which the page renders as the generic warning rather than as
|
|
1005
|
+
* "clean".
|
|
1006
|
+
*/
|
|
1007
|
+
private async getSessionWorktreeChanges(
|
|
1008
|
+
sessionId?: string,
|
|
1009
|
+
): Promise<{ files: number; commits: number } | null> {
|
|
1010
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1011
|
+
const worktree = deriveWorktreeInfo(entry.createdWorkdir);
|
|
1012
|
+
if (!worktree) return null;
|
|
1013
|
+
return getWorktreeChanges(worktree.path);
|
|
694
1014
|
}
|
|
695
1015
|
|
|
696
1016
|
/**
|
|
@@ -713,13 +1033,23 @@ export class AgentBridge {
|
|
|
713
1033
|
): Promise<null> {
|
|
714
1034
|
const entry = this.requireSessionForRead(sessionId);
|
|
715
1035
|
// Re-attach to a live session: the SDK restore would no-op (target is
|
|
716
|
-
// already current).
|
|
717
|
-
//
|
|
718
|
-
//
|
|
1036
|
+
// already current). Re-send the state the freshly attached client missed —
|
|
1037
|
+
// its router registered only after initialize returned.
|
|
1038
|
+
//
|
|
1039
|
+
// `messagesChange` carries **no payload here, only the fact that the list
|
|
1040
|
+
// changed**. Nobody ever read a full snapshot from it: the remote relay
|
|
1041
|
+
// needs the emission to produce the phone's signal and then the phone pulls
|
|
1042
|
+
// `getMessages({ since })` itself, while every IDE host ignores the method
|
|
1043
|
+
// and calls `getMessages` after restore. So building one — a multi-MB list
|
|
1044
|
+
// on a long session, serialized on a hot re-attach path — was pure cost, and
|
|
1045
|
+
// this is why the bridge sends the signal shape instead.
|
|
1046
|
+
//
|
|
1047
|
+
// This is also the ONLY place the bridge emits `messagesChange`, and only on
|
|
1048
|
+
// this re-attach branch — never as the message stream progresses.
|
|
719
1049
|
if (entry.agent.sessionId === restoreId) {
|
|
720
1050
|
this.emit(
|
|
721
1051
|
"messagesChange",
|
|
722
|
-
|
|
1052
|
+
remoteWire.messagesChangeSignal(),
|
|
723
1053
|
entry.agent.sessionId,
|
|
724
1054
|
);
|
|
725
1055
|
// The re-attached client also missed the loading state that settled
|
|
@@ -775,9 +1105,14 @@ export class AgentBridge {
|
|
|
775
1105
|
* usage to report; the host keeps the empty ring until the first push.
|
|
776
1106
|
*/
|
|
777
1107
|
private emitContextUsage(agent: Agent): void {
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
1108
|
+
const tokens = agent.latestTotalTokens;
|
|
1109
|
+
const max = agent.getMaxInputTokens();
|
|
1110
|
+
if (tokens > 0 && max > 0) {
|
|
1111
|
+
this.emit(
|
|
1112
|
+
"contextUsage",
|
|
1113
|
+
remoteWire.contextUsage(tokens, max),
|
|
1114
|
+
agent.sessionId,
|
|
1115
|
+
);
|
|
781
1116
|
}
|
|
782
1117
|
}
|
|
783
1118
|
|
|
@@ -792,7 +1127,7 @@ export class AgentBridge {
|
|
|
792
1127
|
const tokens = agent.latestTotalTokens;
|
|
793
1128
|
const max = agent.getMaxInputTokens();
|
|
794
1129
|
if (tokens > 0 && max > 0) {
|
|
795
|
-
return
|
|
1130
|
+
return percentOf(tokens, max);
|
|
796
1131
|
}
|
|
797
1132
|
return undefined;
|
|
798
1133
|
}
|
|
@@ -841,6 +1176,50 @@ export class AgentBridge {
|
|
|
841
1176
|
|
|
842
1177
|
// ── Git / worktree ────────────────────────────────────────────
|
|
843
1178
|
|
|
1179
|
+
/**
|
|
1180
|
+
* List the subdirectories of a directory on the machine this CLI runs on —
|
|
1181
|
+
* host-agnostic like `listGitBranches`: whichever wave process receives the
|
|
1182
|
+
* request lists its own filesystem. Serves the new-session directory pickers
|
|
1183
|
+
* (the desktop's remote host browser and the Web page now share this one
|
|
1184
|
+
* implementation; the desktop's ssh shell one-liner retires into it).
|
|
1185
|
+
*
|
|
1186
|
+
* `~` / `~/…` expand against the service process user's home. `resolvedPath`
|
|
1187
|
+
* is the realpath so the picker shows where the user actually is (symlinked
|
|
1188
|
+
* parents normalized). Symlinked subdirectories are NOT listed — the shell
|
|
1189
|
+
* `find -type d` this replaces didn't list them either, and the two paths
|
|
1190
|
+
* must stay interchangeable. A missing or unreadable directory is an explicit
|
|
1191
|
+
* error, never an empty list (an empty list would read as "no subdirs" and
|
|
1192
|
+
* strand the user on a path they can't leave).
|
|
1193
|
+
*/
|
|
1194
|
+
private listDirectories(dir?: string): {
|
|
1195
|
+
resolvedPath: string;
|
|
1196
|
+
dirs: string[];
|
|
1197
|
+
} {
|
|
1198
|
+
if (!dir) {
|
|
1199
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "dir is required");
|
|
1200
|
+
}
|
|
1201
|
+
const expanded =
|
|
1202
|
+
dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
|
|
1203
|
+
let resolvedPath: string;
|
|
1204
|
+
try {
|
|
1205
|
+
resolvedPath = realpathSync(expanded);
|
|
1206
|
+
} catch {
|
|
1207
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
|
|
1208
|
+
}
|
|
1209
|
+
let dirs: string[];
|
|
1210
|
+
try {
|
|
1211
|
+
dirs = readdirSync(resolvedPath, { withFileTypes: true })
|
|
1212
|
+
.filter((e) => e.isDirectory())
|
|
1213
|
+
.map((e) => e.name)
|
|
1214
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1215
|
+
} catch {
|
|
1216
|
+
// realpath succeeded (the path exists) but reading it didn't — same
|
|
1217
|
+
// verdict as missing: the picker cannot descend into either.
|
|
1218
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
|
|
1219
|
+
}
|
|
1220
|
+
return { resolvedPath, dirs };
|
|
1221
|
+
}
|
|
1222
|
+
|
|
844
1223
|
private listGitBranches(workdir?: string): {
|
|
845
1224
|
branches: string[];
|
|
846
1225
|
current: string | null;
|
|
@@ -887,6 +1266,122 @@ export class AgentBridge {
|
|
|
887
1266
|
return { branches, current };
|
|
888
1267
|
}
|
|
889
1268
|
|
|
1269
|
+
/**
|
|
1270
|
+
* Read-only workspace diff for the desktop diff panel. Runs wherever this
|
|
1271
|
+
* CLI runs, so a remote session answers it inside the remote daemon instead
|
|
1272
|
+
* of the desktop shelling out over ssh per git command.
|
|
1273
|
+
*
|
|
1274
|
+
* `scope` selects the viewing range: omitted / "all" = the fork-point base,
|
|
1275
|
+
* "uncommitted" = `HEAD` (the panel's "未提交改动"). A `commit` outranks it.
|
|
1276
|
+
*/
|
|
1277
|
+
private getWorkspaceDiff(workdir?: string, commit?: string, scope?: string) {
|
|
1278
|
+
if (!workdir) {
|
|
1279
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
1280
|
+
}
|
|
1281
|
+
return getWorkspaceDiff(workdir, {
|
|
1282
|
+
...(commit ? { commit } : {}),
|
|
1283
|
+
...(scope === "uncommitted" ? { uncommitted: true } : {}),
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* One inline (ghost-text) completion for the editor plugins.
|
|
1289
|
+
*
|
|
1290
|
+
* Session-scoped on purpose: the completion model and the gateway credentials
|
|
1291
|
+
* are resolved inside that session (the CLI owns the model configuration —
|
|
1292
|
+
* the plugin never holds a second source of truth). Read-only, so it keeps
|
|
1293
|
+
* working while `updateConfig` is rebuilding the session.
|
|
1294
|
+
*/
|
|
1295
|
+
private async getCompletion(
|
|
1296
|
+
params: {
|
|
1297
|
+
prefix?: string;
|
|
1298
|
+
suffix?: string;
|
|
1299
|
+
language?: string;
|
|
1300
|
+
filePath?: string;
|
|
1301
|
+
snippets?: CompletionSnippet[];
|
|
1302
|
+
},
|
|
1303
|
+
sessionId?: string,
|
|
1304
|
+
): Promise<{ text: string; model: string }> {
|
|
1305
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1306
|
+
const key = sessionId as string;
|
|
1307
|
+
|
|
1308
|
+
// Supersede the session's previous request: its answer would land at a
|
|
1309
|
+
// cursor position that no longer exists. A next-edit suggestion in flight is
|
|
1310
|
+
// dropped too — both rewrite the same buffer (spec ui/next-edit-suggestion.md).
|
|
1311
|
+
this.inFlightCompletions.get(key)?.abort();
|
|
1312
|
+
this.inFlightNextEdits.get(key)?.abort();
|
|
1313
|
+
const controller = new AbortController();
|
|
1314
|
+
this.inFlightCompletions.set(key, controller);
|
|
1315
|
+
|
|
1316
|
+
try {
|
|
1317
|
+
return await entry.agent.getCompletion({
|
|
1318
|
+
prefix: params.prefix ?? "",
|
|
1319
|
+
suffix: params.suffix ?? "",
|
|
1320
|
+
...(params.language ? { language: params.language } : {}),
|
|
1321
|
+
...(params.filePath ? { filePath: params.filePath } : {}),
|
|
1322
|
+
// `undefined` (host cannot collect snippets) and `[]` (tried, found
|
|
1323
|
+
// nothing) stay distinguishable here and both mean "no snippets" to
|
|
1324
|
+
// the model; only an absent field is left out of the call.
|
|
1325
|
+
...(params.snippets ? { snippets: params.snippets } : {}),
|
|
1326
|
+
abortSignal: controller.signal,
|
|
1327
|
+
});
|
|
1328
|
+
} finally {
|
|
1329
|
+
// Only clear our own slot — a newer request may already own it.
|
|
1330
|
+
if (this.inFlightCompletions.get(key) === controller) {
|
|
1331
|
+
this.inFlightCompletions.delete(key);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* One next-edit suggestion for the editor plugins (spec
|
|
1338
|
+
* ui/next-edit-suggestion.md).
|
|
1339
|
+
*
|
|
1340
|
+
* Session-scoped for the same reason as completion: the model, the switch and
|
|
1341
|
+
* the credentials all live in that session. Read-only, so a suggestion still
|
|
1342
|
+
* resolves while `updateConfig` rebuilds the session.
|
|
1343
|
+
*/
|
|
1344
|
+
private async getNextEdit(
|
|
1345
|
+
params: {
|
|
1346
|
+
edits?: NextEditHistoryEntry[];
|
|
1347
|
+
region?: NextEditRegion;
|
|
1348
|
+
excerpt?: NextEditExcerpt;
|
|
1349
|
+
language?: string;
|
|
1350
|
+
filePath?: string;
|
|
1351
|
+
},
|
|
1352
|
+
sessionId?: string,
|
|
1353
|
+
): Promise<{ suggestion: NextEditSuggestion | null; model: string }> {
|
|
1354
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1355
|
+
const key = sessionId as string;
|
|
1356
|
+
|
|
1357
|
+
// A region and its excerpt are the whole basis of the answer; without them
|
|
1358
|
+
// there is nothing to predict into, so this is a no-op rather than an error.
|
|
1359
|
+
if (!params.region || !params.excerpt) {
|
|
1360
|
+
return { suggestion: null, model: "" };
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// Same exclusion as the completion path, from this side.
|
|
1364
|
+
this.inFlightNextEdits.get(key)?.abort();
|
|
1365
|
+
this.inFlightCompletions.get(key)?.abort();
|
|
1366
|
+
const controller = new AbortController();
|
|
1367
|
+
this.inFlightNextEdits.set(key, controller);
|
|
1368
|
+
|
|
1369
|
+
try {
|
|
1370
|
+
return await entry.agent.getNextEdit({
|
|
1371
|
+
edits: params.edits ?? [],
|
|
1372
|
+
region: params.region,
|
|
1373
|
+
excerpt: params.excerpt,
|
|
1374
|
+
...(params.language ? { language: params.language } : {}),
|
|
1375
|
+
...(params.filePath ? { filePath: params.filePath } : {}),
|
|
1376
|
+
abortSignal: controller.signal,
|
|
1377
|
+
});
|
|
1378
|
+
} finally {
|
|
1379
|
+
if (this.inFlightNextEdits.get(key) === controller) {
|
|
1380
|
+
this.inFlightNextEdits.delete(key);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
890
1385
|
private async createWorktreeSession(params: {
|
|
891
1386
|
workdir: string;
|
|
892
1387
|
baseBranch?: string;
|
|
@@ -972,23 +1467,45 @@ export class AgentBridge {
|
|
|
972
1467
|
return { ok: true };
|
|
973
1468
|
}
|
|
974
1469
|
|
|
975
|
-
private getSessionInfo(sessionId?: string): {
|
|
1470
|
+
private async getSessionInfo(sessionId?: string): Promise<{
|
|
976
1471
|
sessionId: string;
|
|
977
|
-
|
|
1472
|
+
sessionCwd: string;
|
|
978
1473
|
latestTotalTokens: number;
|
|
979
1474
|
permissionMode: PermissionMode;
|
|
980
1475
|
availableTools: string[];
|
|
981
|
-
|
|
1476
|
+
tasks: Task[];
|
|
1477
|
+
isCompacting: boolean;
|
|
1478
|
+
isCommandRunning: boolean;
|
|
1479
|
+
queuedMessages: QueuedMessage[];
|
|
1480
|
+
backgroundTasks: BackgroundTaskSummary[];
|
|
1481
|
+
}> {
|
|
982
1482
|
const entry = this.requireSessionForRead(sessionId);
|
|
983
1483
|
return {
|
|
984
1484
|
sessionId: entry.agent.sessionId,
|
|
985
|
-
// The recorded creation workdir, not the live agent.
|
|
1485
|
+
// The recorded creation workdir, not the live agent.currentWorkdir:
|
|
986
1486
|
// destroy --remove-worktree resolves the session's worktree from this
|
|
987
1487
|
// value, and it must not drift when the session cd'd out of the worktree.
|
|
988
|
-
|
|
1488
|
+
sessionCwd: entry.createdWorkdir,
|
|
989
1489
|
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
990
1490
|
permissionMode: entry.agent.getPermissionMode(),
|
|
991
1491
|
availableTools: entry.agent.getAvailableToolNames(),
|
|
1492
|
+
// The session-state family the remote page re-reads (spec
|
|
1493
|
+
// remote-control「Web 页作为第五种宿主」场景 26): the todo list —
|
|
1494
|
+
// file-backed in the SDK, hence the await — plus compaction, command
|
|
1495
|
+
// running and the outbound queue, all read off the agent itself so the
|
|
1496
|
+
// reply is the current value, never a transition the page may have
|
|
1497
|
+
// missed.
|
|
1498
|
+
tasks: await entry.agent.getTasks(),
|
|
1499
|
+
isCompacting: entry.agent.isCompacting,
|
|
1500
|
+
isCommandRunning: entry.agent.isCommandRunning,
|
|
1501
|
+
queuedMessages: entry.agent.queuedMessages,
|
|
1502
|
+
// Background tasks join the family for the same reason the todo list is
|
|
1503
|
+
// here: the phone's `/tasks` dialog opens on whatever the machine holds, and
|
|
1504
|
+
// `backgroundTasksChange` only reports *changes* — a task that started
|
|
1505
|
+
// before the page attached would otherwise be invisible until it finished.
|
|
1506
|
+
backgroundTasks: toBackgroundTaskSummaries(
|
|
1507
|
+
entry.agent.getBackgroundTasks(),
|
|
1508
|
+
),
|
|
992
1509
|
};
|
|
993
1510
|
}
|
|
994
1511
|
|
|
@@ -1039,6 +1556,9 @@ export class AgentBridge {
|
|
|
1039
1556
|
// Keep worktree context (permission safety) across recreation, but never
|
|
1040
1557
|
// re-fire WorktreeCreate — the hook ran at initial creation.
|
|
1041
1558
|
worktreeName: entry.storedConfig.worktreeName,
|
|
1559
|
+
// The declaration outlives the rebuild: an `updateConfig` replaces the
|
|
1560
|
+
// agent, not the host that asked for the session.
|
|
1561
|
+
entrypoint: normalizeEntrypoint(entry.storedConfig.entrypoint),
|
|
1042
1562
|
canUseTool: (context: ToolPermissionContext) =>
|
|
1043
1563
|
this.canUseTool(context, ctx),
|
|
1044
1564
|
};
|
|
@@ -1095,7 +1615,7 @@ export class AgentBridge {
|
|
|
1095
1615
|
}
|
|
1096
1616
|
|
|
1097
1617
|
private getConfiguredModels(sessionId?: string): {
|
|
1098
|
-
models:
|
|
1618
|
+
models: ConfiguredModel[];
|
|
1099
1619
|
currentModel: string | undefined;
|
|
1100
1620
|
} {
|
|
1101
1621
|
const entry = this.requireSessionForRead(sessionId);
|
|
@@ -1167,20 +1687,31 @@ export class AgentBridge {
|
|
|
1167
1687
|
sessionId?: string,
|
|
1168
1688
|
): Promise<null> {
|
|
1169
1689
|
const entry = this.requireSession(sessionId);
|
|
1170
|
-
if (params.force) {
|
|
1171
|
-
entry.agent.abortMessage();
|
|
1172
|
-
}
|
|
1173
1690
|
// Save prompt to history (mirrors VSCE chatSession.ts:236-242)
|
|
1174
1691
|
try {
|
|
1175
1692
|
await PromptHistoryManager.addEntry(
|
|
1176
1693
|
params.text,
|
|
1177
1694
|
entry.agent.sessionId,
|
|
1178
1695
|
{},
|
|
1179
|
-
entry.agent.
|
|
1696
|
+
entry.agent.currentWorkdir,
|
|
1180
1697
|
);
|
|
1181
1698
|
} catch {
|
|
1182
1699
|
// Best-effort; don't block message sending on history save failure
|
|
1183
1700
|
}
|
|
1701
|
+
if (params.force) {
|
|
1702
|
+
// "Send now" for one queued item: interrupt the running turn but keep the
|
|
1703
|
+
// rest of the queue — the host removes the picked item separately, and
|
|
1704
|
+
// everything it did not touch must still be delivered afterwards.
|
|
1705
|
+
//
|
|
1706
|
+
// The abort must be immediately followed by `sendMessage` with nothing
|
|
1707
|
+
// awaited in between: the abort resets the loading flag, which is the gate
|
|
1708
|
+
// a queued item waits behind, so any await here lets the *next* queued
|
|
1709
|
+
// message start a turn first — the picked one would then be enqueued at
|
|
1710
|
+
// the tail instead of going out now (realhost smoke: with the history
|
|
1711
|
+
// write above the abort, a `deleteQueuedMessageById` landing in that gap
|
|
1712
|
+
// started the remaining message immediately).
|
|
1713
|
+
entry.agent.abortMessage({ keepQueue: true });
|
|
1714
|
+
}
|
|
1184
1715
|
await entry.agent.sendMessage(
|
|
1185
1716
|
params.text,
|
|
1186
1717
|
this.persistDataUrlImages(params.images),
|
|
@@ -1339,14 +1870,74 @@ export class AgentBridge {
|
|
|
1339
1870
|
return null;
|
|
1340
1871
|
}
|
|
1341
1872
|
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1873
|
+
/**
|
|
1874
|
+
* Read a session's display messages.
|
|
1875
|
+
*
|
|
1876
|
+
* With no parameters this returns the whole list the agent holds, read once
|
|
1877
|
+
* and cached nowhere (spec ui/message-rendering-system.md「首屏就是整份列表,
|
|
1878
|
+
* 不分页」). Two parameters narrow it, both for the remote page:
|
|
1879
|
+
*
|
|
1880
|
+
* - `limit` — the newest `limit` messages (that page's frame budget).
|
|
1881
|
+
* - `since` — everything appended after the given message id (catching up
|
|
1882
|
+
* after a reconnect).
|
|
1883
|
+
*
|
|
1884
|
+
* The list starts at the last compaction, so its earliest entry is the
|
|
1885
|
+
* earliest any UI can reach; there is nothing older to read back.
|
|
1886
|
+
*
|
|
1887
|
+
* `since` is a position, not a version number: message ids are stable, so a
|
|
1888
|
+
* caller can name its last known message without the list carrying a
|
|
1889
|
+
* sequence number. The one failure that matters is the anchor having been
|
|
1890
|
+
* truncated away (rewind / clear) — there is no seq/version in the store that
|
|
1891
|
+
* could detect it otherwise — and that comes back as `reset: true` so the
|
|
1892
|
+
* caller re-pulls instead of guessing.
|
|
1893
|
+
*
|
|
1894
|
+
* Ids can repeat (old transcripts re-appended the preserved rounds at each
|
|
1895
|
+
* compact point), so anchors are resolved to their **last** occurrence:
|
|
1896
|
+
* "after `since`" means after the newest copy the caller could have seen.
|
|
1897
|
+
*/
|
|
1898
|
+
private async getMessages(
|
|
1899
|
+
sessionId?: string,
|
|
1900
|
+
window?: MessageWindow,
|
|
1901
|
+
): Promise<MessageWindowResult> {
|
|
1346
1902
|
const entry = this.requireSessionForRead(sessionId);
|
|
1903
|
+
const contextUsagePercent = this.contextUsagePercentOf(entry.agent);
|
|
1904
|
+
|
|
1905
|
+
// The validation is shared with the CLI front end (`RemoteControlCliHost`):
|
|
1906
|
+
// the phone's reconciliation rules belong to the wire contract, not to this
|
|
1907
|
+
// registry, and the two hosts must answer identically.
|
|
1908
|
+
let normalized: MessageWindow | undefined;
|
|
1909
|
+
try {
|
|
1910
|
+
normalized = normalizeMessageWindow(window);
|
|
1911
|
+
} catch (err) {
|
|
1912
|
+
if (err instanceof InvalidMessageWindowError) {
|
|
1913
|
+
throw new RpcError(PROTOCOL_INVALID_PARAMS, err.message);
|
|
1914
|
+
}
|
|
1915
|
+
throw err;
|
|
1916
|
+
}
|
|
1917
|
+
// The totals ride along with the window: the list starts at the last
|
|
1918
|
+
// compaction, so this reply is the only place a host can learn the
|
|
1919
|
+
// thread's size and its first user message (the session-title fallback).
|
|
1920
|
+
const threadSummary = entry.agent.getThreadSummary();
|
|
1921
|
+
// Trimmed on the way out, never in the agent's own list: no UI renders tool
|
|
1922
|
+
// pictures, and the agent has to keep them for the next request
|
|
1923
|
+
// (specs/ui/stdio-transport.md「工具图片的字节不下发 UI」). The trim is
|
|
1924
|
+
// copy-on-write, so a message with no picture keeps its identity.
|
|
1925
|
+
if (!normalized) {
|
|
1926
|
+
return {
|
|
1927
|
+
// `Agent.messages` already hands back a copy, so this reply is a
|
|
1928
|
+
// single-pass view for the host that asked once — do not copy again
|
|
1929
|
+
// (a long session makes this O(n) per call).
|
|
1930
|
+
messages: messagesForUi(entry.agent.messages),
|
|
1931
|
+
contextUsagePercent,
|
|
1932
|
+
threadSummary,
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
const page = entry.agent.getMessagePage(normalized);
|
|
1347
1936
|
return {
|
|
1348
|
-
|
|
1349
|
-
|
|
1937
|
+
...page,
|
|
1938
|
+
messages: messagesForUi(page.messages),
|
|
1939
|
+
contextUsagePercent,
|
|
1940
|
+
threadSummary,
|
|
1350
1941
|
};
|
|
1351
1942
|
}
|
|
1352
1943
|
|
|
@@ -1355,7 +1946,8 @@ export class AgentBridge {
|
|
|
1355
1946
|
sessionIds: string[];
|
|
1356
1947
|
}> {
|
|
1357
1948
|
const entry = this.requireSessionForRead(sessionId);
|
|
1358
|
-
|
|
1949
|
+
const thread = await entry.agent.getFullMessageThread();
|
|
1950
|
+
return { ...thread, messages: messagesForUi(thread.messages) };
|
|
1359
1951
|
}
|
|
1360
1952
|
|
|
1361
1953
|
private async compact(
|
|
@@ -1431,6 +2023,43 @@ export class AgentBridge {
|
|
|
1431
2023
|
return { mode: entry.agent.getPermissionMode() };
|
|
1432
2024
|
}
|
|
1433
2025
|
|
|
2026
|
+
/**
|
|
2027
|
+
* Records what the editor host saw happen to the last suggestion —
|
|
2028
|
+
* `displayed` when it rendered the ghost text, `accepted` when the user took
|
|
2029
|
+
* it. The outcome is the only thing the host reports: the model and the
|
|
2030
|
+
* language stay on this side, derived when the completion was produced.
|
|
2031
|
+
*/
|
|
2032
|
+
private reportCompletionOutcome(
|
|
2033
|
+
outcome: "displayed" | "accepted",
|
|
2034
|
+
sessionId?: string,
|
|
2035
|
+
): { ok: true } {
|
|
2036
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
2037
|
+
if (outcome !== "displayed" && outcome !== "accepted") {
|
|
2038
|
+
throw new RpcError(
|
|
2039
|
+
PROTOCOL_INTERNAL_ERROR,
|
|
2040
|
+
"outcome must be 'displayed' or 'accepted'",
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
entry.agent.reportCompletionOutcome(outcome);
|
|
2044
|
+
return { ok: true };
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** The next-edit twin: same validation, its own metric series in the SDK. */
|
|
2048
|
+
private reportNextEditOutcome(
|
|
2049
|
+
outcome: "displayed" | "accepted",
|
|
2050
|
+
sessionId?: string,
|
|
2051
|
+
): { ok: true } {
|
|
2052
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
2053
|
+
if (outcome !== "displayed" && outcome !== "accepted") {
|
|
2054
|
+
throw new RpcError(
|
|
2055
|
+
PROTOCOL_INTERNAL_ERROR,
|
|
2056
|
+
"outcome must be 'displayed' or 'accepted'",
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
entry.agent.reportNextEditOutcome(outcome);
|
|
2060
|
+
return { ok: true };
|
|
2061
|
+
}
|
|
2062
|
+
|
|
1434
2063
|
/**
|
|
1435
2064
|
* Returns the current plan file path and its contents for the session.
|
|
1436
2065
|
* When the path generation is still in flight (e.g. the host just switched
|
|
@@ -1505,9 +2134,28 @@ export class AgentBridge {
|
|
|
1505
2134
|
|
|
1506
2135
|
// ── Commands ──────────────────────────────────────────────────
|
|
1507
2136
|
|
|
1508
|
-
|
|
2137
|
+
/**
|
|
2138
|
+
* The popup's rows: the agent's own commands (skills / custom / plugin) plus
|
|
2139
|
+
* the host-local table for the front end that asked.
|
|
2140
|
+
*
|
|
2141
|
+
* `surface` is optional on purpose: a client that predates the field asks
|
|
2142
|
+
* without it and gets exactly what it got before (the SDK half), instead of
|
|
2143
|
+
* being handed rows it has no handling for. Unknown values are dropped the
|
|
2144
|
+
* same way by `parseHostSurface`.
|
|
2145
|
+
*/
|
|
2146
|
+
private getSlashCommands(
|
|
2147
|
+
sessionId?: string,
|
|
2148
|
+
surface?: HostSurface,
|
|
2149
|
+
): { commands: SlashCommandInfo[] } {
|
|
1509
2150
|
const entry = this.requireSessionForRead(sessionId);
|
|
1510
|
-
|
|
2151
|
+
const agentCommands = entry.agent
|
|
2152
|
+
.getSlashCommands()
|
|
2153
|
+
.map((command) => toSlashCommandInfo(command));
|
|
2154
|
+
return {
|
|
2155
|
+
commands: surface
|
|
2156
|
+
? [...agentCommands, ...hostSlashCommandsFor(surface)]
|
|
2157
|
+
: agentCommands,
|
|
2158
|
+
};
|
|
1511
2159
|
}
|
|
1512
2160
|
|
|
1513
2161
|
private getSubagentConfigurations(sessionId?: string): {
|
|
@@ -1654,7 +2302,7 @@ export class AgentBridge {
|
|
|
1654
2302
|
});
|
|
1655
2303
|
this.emit(
|
|
1656
2304
|
"permissionRequest",
|
|
1657
|
-
|
|
2305
|
+
remoteWire.permissionRequest(requestId, context),
|
|
1658
2306
|
ctx.registeredSessionId,
|
|
1659
2307
|
);
|
|
1660
2308
|
});
|
|
@@ -1701,31 +2349,142 @@ export class AgentBridge {
|
|
|
1701
2349
|
* reading only `isLoading` reports "done" in both cases. They are read from the
|
|
1702
2350
|
* live agent here rather than tracked separately, and mirror it as-is: the
|
|
1703
2351
|
* rebuild window above is already covered by `isLoading`, so there is no need
|
|
1704
|
-
* to also claim a `transitioning` session owns background work it doesn't.
|
|
2352
|
+
* to also claim a `transitioning` session owns background work it doesn't.
|
|
2353
|
+
*
|
|
2354
|
+
* The three label fields are additive (an older CLI simply ignores them) and
|
|
2355
|
+
* exist for the remote-control page: it lists **this process's** sessions —
|
|
2356
|
+
* the only ones it can actually open — and cannot read the machine's disk, so
|
|
2357
|
+
* the title material and the ordering key have to come off the live agent
|
|
2358
|
+
* rather than from `listSessions`, which reads jsonl and would offer it
|
|
2359
|
+
* sessions that are not loaded here (spec remote-control「Web 页作为第五种宿主」
|
|
2360
|
+
* 场景 2)。 */
|
|
1705
2361
|
private listDaemonSessions(): {
|
|
1706
2362
|
sessions: Array<{
|
|
1707
2363
|
sessionId: string;
|
|
1708
|
-
|
|
2364
|
+
sessionCwd: string;
|
|
1709
2365
|
isLoading: boolean;
|
|
1710
2366
|
hasRunningBackgroundWork: boolean;
|
|
1711
2367
|
hasPendingMessages: boolean;
|
|
1712
2368
|
messageCount: number;
|
|
2369
|
+
/** User-set title; wins over `firstMessage` in the UI. */
|
|
2370
|
+
customTitle?: string;
|
|
2371
|
+
/** First real user message — the label when no custom title is set. */
|
|
2372
|
+
firstMessage?: string;
|
|
2373
|
+
/** Last display message's timestamp: the session list's ordering key. */
|
|
2374
|
+
lastActiveAt?: string;
|
|
2375
|
+
/**
|
|
2376
|
+
* The linked git worktree this session was created in, when it was.
|
|
2377
|
+
* Present only for worktree sessions — absent means "not a worktree, or
|
|
2378
|
+
* cannot tell", and the remote page reads it as "deleting this session
|
|
2379
|
+
* will not touch a worktree". Change counts are deliberately NOT here:
|
|
2380
|
+
* they cost git calls per row, and the list is rebuilt on every reconnect
|
|
2381
|
+
* / snapshot / page open. The page asks for them on demand instead, via
|
|
2382
|
+
* `getSessionWorktreeChanges`.
|
|
2383
|
+
*/
|
|
2384
|
+
worktree?: {
|
|
2385
|
+
path: string;
|
|
2386
|
+
branch: string;
|
|
2387
|
+
repoRoot: string;
|
|
2388
|
+
hookBased: boolean;
|
|
2389
|
+
};
|
|
1713
2390
|
}>;
|
|
1714
2391
|
} {
|
|
1715
2392
|
return {
|
|
1716
|
-
sessions: [...this.sessions.entries()].map(([sessionId, entry]) =>
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
2393
|
+
sessions: [...this.sessions.entries()].map(([sessionId, entry]) => {
|
|
2394
|
+
const customTitle = entry.agent.customTitle;
|
|
2395
|
+
// Totals come from the agent's counters, not from `displayMessages`:
|
|
2396
|
+
// that is only the resident tail now, so its length and its first
|
|
2397
|
+
// message are no longer the thread's.
|
|
2398
|
+
const summary = entry.agent.getThreadSummary();
|
|
2399
|
+
const firstMessage = summary.firstUserText;
|
|
2400
|
+
const lastActiveAt = summary.lastActiveAt;
|
|
2401
|
+
// Derived from the creation workdir (not the live one) and fs-only —
|
|
2402
|
+
// see `deriveWorktreeInfo` for why this must not shell out to git.
|
|
2403
|
+
const worktree = deriveWorktreeInfo(entry.createdWorkdir);
|
|
2404
|
+
return {
|
|
2405
|
+
sessionId,
|
|
2406
|
+
sessionCwd: entry.createdWorkdir,
|
|
2407
|
+
isLoading: entry.transitioning === true || entry.agent.isLoading,
|
|
2408
|
+
hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
|
|
2409
|
+
hasPendingMessages: entry.agent.hasPendingMessages,
|
|
2410
|
+
messageCount: summary.messageCount,
|
|
2411
|
+
...(customTitle ? { customTitle } : {}),
|
|
2412
|
+
...(firstMessage ? { firstMessage } : {}),
|
|
2413
|
+
...(lastActiveAt ? { lastActiveAt } : {}),
|
|
2414
|
+
...(worktree ? { worktree } : {}),
|
|
2415
|
+
};
|
|
2416
|
+
}),
|
|
1724
2417
|
};
|
|
1725
2418
|
}
|
|
1726
2419
|
|
|
1727
|
-
|
|
2420
|
+
/**
|
|
2421
|
+
* Route the `remoteControl*` RPCs to the daemon's controller.
|
|
2422
|
+
*
|
|
2423
|
+
* `connect` / `disconnect` change the relay session; all three return the
|
|
2424
|
+
* same snapshot so a caller can render the result without a second round
|
|
2425
|
+
* trip (and a caller that only wanted to read gets `remoteControlStatus`).
|
|
2426
|
+
*
|
|
2427
|
+
* `connect` carries the optional label the user gave this machine
|
|
2428
|
+
* (`wave remote-control on <name>`), which the relay hands to the phone so
|
|
2429
|
+
* its computer list can tell two of the user's computers apart.
|
|
2430
|
+
*/
|
|
2431
|
+
private remoteControlRequest(
|
|
2432
|
+
method: string,
|
|
2433
|
+
params: Record<string, unknown>,
|
|
2434
|
+
): RemoteControlSnapshot {
|
|
2435
|
+
const controller = this.remoteControl?.();
|
|
2436
|
+
if (!controller) {
|
|
2437
|
+
throw new RpcError(
|
|
2438
|
+
PROTOCOL_INVALID_PARAMS,
|
|
2439
|
+
"Remote control is not enabled on this process (set enableRemoteControl: true).",
|
|
2440
|
+
);
|
|
2441
|
+
}
|
|
2442
|
+
if (method === "remoteControlConnect") {
|
|
2443
|
+
const name = typeof params.name === "string" ? params.name : undefined;
|
|
2444
|
+
controller.connect(name);
|
|
2445
|
+
} else if (method === "remoteControlDisconnect") {
|
|
2446
|
+
controller.disconnect();
|
|
2447
|
+
}
|
|
2448
|
+
return controller.getSnapshot();
|
|
2449
|
+
}
|
|
1728
2450
|
|
|
2451
|
+
/**
|
|
2452
|
+
* Per-session status for the remote-control signal.
|
|
2453
|
+
*
|
|
2454
|
+
* The relay forwards bytes without looking inside them, so it can never
|
|
2455
|
+
* discover that somebody is waiting on an approval — and "push to the phone
|
|
2456
|
+
* when a session is stuck on the user" is the whole point of the feature.
|
|
2457
|
+
* The machine therefore reports one word per session, carrying no content:
|
|
2458
|
+
* ids and a status, nothing else.
|
|
2459
|
+
*
|
|
2460
|
+
* `running` includes background work: read from a phone there is no terminal
|
|
2461
|
+
* to glance at, so "something is still in flight" is the useful meaning of
|
|
2462
|
+
* busy rather than the narrower "a model turn is streaming".
|
|
2463
|
+
*/
|
|
2464
|
+
listSessionStatuses(): Array<{
|
|
2465
|
+
sessionId: string;
|
|
2466
|
+
status: "running" | "idle" | "requires_action";
|
|
2467
|
+
}> {
|
|
2468
|
+
const awaiting = new Set<string>();
|
|
2469
|
+
for (const entry of this.pendingPermissions.values()) {
|
|
2470
|
+
if (entry.sessionId) awaiting.add(entry.sessionId);
|
|
2471
|
+
}
|
|
2472
|
+
return [...this.sessions.entries()].map(([sessionId, entry]) => {
|
|
2473
|
+
let status: "running" | "idle" | "requires_action" = "idle";
|
|
2474
|
+
if (awaiting.has(sessionId)) {
|
|
2475
|
+
status = "requires_action";
|
|
2476
|
+
} else if (
|
|
2477
|
+
entry.transitioning === true ||
|
|
2478
|
+
entry.agent.isLoading ||
|
|
2479
|
+
entry.agent.hasRunningBackgroundWork
|
|
2480
|
+
) {
|
|
2481
|
+
status = "running";
|
|
2482
|
+
}
|
|
2483
|
+
return { sessionId, status };
|
|
2484
|
+
});
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2487
|
+
// ── Auth (global) ────────────────────────────────────────────
|
|
1729
2488
|
private async getAuthStatus(): Promise<{
|
|
1730
2489
|
isAuthenticated: boolean;
|
|
1731
2490
|
user: { id: string; email?: string } | undefined;
|
|
@@ -1811,7 +2570,7 @@ export class AgentBridge {
|
|
|
1811
2570
|
return { content: await readTextFileSafe(path), path };
|
|
1812
2571
|
}
|
|
1813
2572
|
const resolvedWorkdir =
|
|
1814
|
-
workdir || (agent?.
|
|
2573
|
+
workdir || (agent?.currentWorkdir as string | undefined);
|
|
1815
2574
|
if (!resolvedWorkdir) {
|
|
1816
2575
|
throw new RpcError(
|
|
1817
2576
|
PROTOCOL_INVALID_PARAMS,
|
|
@@ -1850,7 +2609,7 @@ export class AgentBridge {
|
|
|
1850
2609
|
return { ok: true };
|
|
1851
2610
|
}
|
|
1852
2611
|
const resolvedWorkdir =
|
|
1853
|
-
workdir || (agent?.
|
|
2612
|
+
workdir || (agent?.currentWorkdir as string | undefined);
|
|
1854
2613
|
if (!resolvedWorkdir) {
|
|
1855
2614
|
throw new RpcError(
|
|
1856
2615
|
PROTOCOL_INVALID_PARAMS,
|
|
@@ -2152,7 +2911,7 @@ export class AgentBridge {
|
|
|
2152
2911
|
if (msg)
|
|
2153
2912
|
this.emit(
|
|
2154
2913
|
"userMessageAdded",
|
|
2155
|
-
{ message: msg },
|
|
2914
|
+
{ message: messageForUi(msg) },
|
|
2156
2915
|
ctx.registeredSessionId,
|
|
2157
2916
|
);
|
|
2158
2917
|
},
|
|
@@ -2161,30 +2920,33 @@ export class AgentBridge {
|
|
|
2161
2920
|
if (msg)
|
|
2162
2921
|
this.emit(
|
|
2163
2922
|
"assistantMessageAdded",
|
|
2164
|
-
{ message: msg },
|
|
2923
|
+
{ message: messageForUi(msg) },
|
|
2165
2924
|
ctx.registeredSessionId,
|
|
2166
2925
|
);
|
|
2167
2926
|
},
|
|
2927
|
+
onAssistantMessageDiscarded: (messageId: string) => {
|
|
2928
|
+
// A streamed attempt died mid-body and was re-issued without streaming,
|
|
2929
|
+
// so its half message is void: tell the host to drop it instead of
|
|
2930
|
+
// leaving a partial answer above the real one (spec: 流式响应正文中途
|
|
2931
|
+
// 断连时降级为非流式重发).
|
|
2932
|
+
this.emit(
|
|
2933
|
+
"assistantMessageDiscarded",
|
|
2934
|
+
{ messageId },
|
|
2935
|
+
ctx.registeredSessionId,
|
|
2936
|
+
);
|
|
2937
|
+
},
|
|
2168
2938
|
onAssistantContentUpdated: (params) => {
|
|
2169
2939
|
// Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
|
|
2170
2940
|
this.emit(
|
|
2171
2941
|
"assistantContentUpdated",
|
|
2172
|
-
|
|
2173
|
-
messageId: params.messageId,
|
|
2174
|
-
chunk: params.chunk,
|
|
2175
|
-
stage: params.stage,
|
|
2176
|
-
},
|
|
2942
|
+
remoteWire.assistantContentUpdated(params),
|
|
2177
2943
|
ctx.registeredSessionId,
|
|
2178
2944
|
);
|
|
2179
2945
|
},
|
|
2180
2946
|
onAssistantReasoningUpdated: (params) => {
|
|
2181
2947
|
this.emit(
|
|
2182
2948
|
"assistantReasoningUpdated",
|
|
2183
|
-
|
|
2184
|
-
messageId: params.messageId,
|
|
2185
|
-
chunk: params.chunk,
|
|
2186
|
-
stage: params.stage,
|
|
2187
|
-
},
|
|
2949
|
+
remoteWire.assistantReasoningUpdated(params),
|
|
2188
2950
|
ctx.registeredSessionId,
|
|
2189
2951
|
);
|
|
2190
2952
|
},
|
|
@@ -2192,21 +2954,23 @@ export class AgentBridge {
|
|
|
2192
2954
|
// Streaming stages carry only the parametersChunk delta; start/running
|
|
2193
2955
|
// (one-time snapshots) and end (authoritative full value) keep
|
|
2194
2956
|
// `parameters` (spec: 流式通知纯增量负载).
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2957
|
+
this.emit(
|
|
2958
|
+
"toolBlockUpdated",
|
|
2959
|
+
remoteWire.toolBlockUpdated(params),
|
|
2960
|
+
ctx.registeredSessionId,
|
|
2961
|
+
);
|
|
2199
2962
|
},
|
|
2200
2963
|
onErrorBlockAdded: (error: string) => {
|
|
2201
|
-
this.emit(
|
|
2964
|
+
this.emit(
|
|
2965
|
+
"errorBlockAdded",
|
|
2966
|
+
remoteWire.errorBlockAdded(error),
|
|
2967
|
+
ctx.registeredSessionId,
|
|
2968
|
+
);
|
|
2202
2969
|
},
|
|
2203
2970
|
onLoadingChange: (loading: boolean) => {
|
|
2204
2971
|
this.emit(
|
|
2205
2972
|
"loadingChange",
|
|
2206
|
-
|
|
2207
|
-
loading,
|
|
2208
|
-
latestTotalTokens: ctx.agent?.latestTotalTokens,
|
|
2209
|
-
},
|
|
2973
|
+
remoteWire.loadingChange(loading, ctx.agent?.latestTotalTokens),
|
|
2210
2974
|
ctx.registeredSessionId,
|
|
2211
2975
|
);
|
|
2212
2976
|
},
|
|
@@ -2218,8 +2982,11 @@ export class AgentBridge {
|
|
|
2218
2982
|
// aligned with the CLI, which reads the same callback (spec 场景 5/6).
|
|
2219
2983
|
const max = ctx.agent?.getMaxInputTokens() ?? 0;
|
|
2220
2984
|
if (max > 0) {
|
|
2221
|
-
|
|
2222
|
-
|
|
2985
|
+
this.emit(
|
|
2986
|
+
"contextUsage",
|
|
2987
|
+
remoteWire.contextUsage(tokens, max),
|
|
2988
|
+
ctx.registeredSessionId,
|
|
2989
|
+
);
|
|
2223
2990
|
}
|
|
2224
2991
|
},
|
|
2225
2992
|
onCommandRunningChange: (running: boolean) => {
|
|
@@ -2236,21 +3003,9 @@ export class AgentBridge {
|
|
|
2236
3003
|
this.emit("tasksChange", { tasks }, ctx.registeredSessionId);
|
|
2237
3004
|
},
|
|
2238
3005
|
onBackgroundTasksChange: (tasks: BackgroundTask[]) => {
|
|
2239
|
-
const summaries: BackgroundTaskSummary[] = tasks.map((t) => ({
|
|
2240
|
-
id: t.id,
|
|
2241
|
-
type: t.type,
|
|
2242
|
-
status: t.status,
|
|
2243
|
-
startTime: t.startTime,
|
|
2244
|
-
endTime: t.endTime,
|
|
2245
|
-
command: t.command,
|
|
2246
|
-
description: t.description,
|
|
2247
|
-
exitCode: t.exitCode,
|
|
2248
|
-
runtime: t.runtime,
|
|
2249
|
-
outputPath: t.outputPath,
|
|
2250
|
-
}));
|
|
2251
3006
|
this.emit(
|
|
2252
3007
|
"backgroundTasksChange",
|
|
2253
|
-
{ tasks:
|
|
3008
|
+
{ tasks: toBackgroundTaskSummaries(tasks) },
|
|
2254
3009
|
ctx.registeredSessionId,
|
|
2255
3010
|
);
|
|
2256
3011
|
},
|
|
@@ -2269,7 +3024,11 @@ export class AgentBridge {
|
|
|
2269
3024
|
ctx.registeredSessionId = newSessionId;
|
|
2270
3025
|
},
|
|
2271
3026
|
onPermissionModeChange: (mode: PermissionMode) => {
|
|
2272
|
-
this.emit(
|
|
3027
|
+
this.emit(
|
|
3028
|
+
"permissionModeChange",
|
|
3029
|
+
remoteWire.permissionModeChange(mode),
|
|
3030
|
+
ctx.registeredSessionId,
|
|
3031
|
+
);
|
|
2273
3032
|
},
|
|
2274
3033
|
onWorkdirChange: (newCwd: string) => {
|
|
2275
3034
|
this.emit(
|
|
@@ -2282,7 +3041,11 @@ export class AgentBridge {
|
|
|
2282
3041
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
2283
3042
|
},
|
|
2284
3043
|
onNotificationMessageAdded: (params) => {
|
|
2285
|
-
|
|
3044
|
+
// The LAST matching message, not the first: one task can notify more
|
|
3045
|
+
// than once under the same task id (a workflow warns about its size
|
|
3046
|
+
// mid-run, then notifies again when it finishes), and this callback
|
|
3047
|
+
// fires for the message that was just appended — the newest one.
|
|
3048
|
+
const matches = (ctx.agent?.messages ?? []).filter(
|
|
2286
3049
|
(m) =>
|
|
2287
3050
|
m.role === "user" &&
|
|
2288
3051
|
m.blocks.some(
|
|
@@ -2291,14 +3054,19 @@ export class AgentBridge {
|
|
|
2291
3054
|
(b as { taskId: string }).taskId === params.taskId,
|
|
2292
3055
|
),
|
|
2293
3056
|
);
|
|
3057
|
+
const msg = matches[matches.length - 1];
|
|
2294
3058
|
this.emit(
|
|
2295
3059
|
"notificationMessageAdded",
|
|
2296
|
-
{ ...params, message: msg },
|
|
3060
|
+
{ ...params, message: msg ? messageForUi(msg) : undefined },
|
|
2297
3061
|
ctx.registeredSessionId,
|
|
2298
3062
|
);
|
|
2299
3063
|
},
|
|
2300
|
-
onCompactBlockAdded: (content: string) => {
|
|
2301
|
-
this.emit(
|
|
3064
|
+
onCompactBlockAdded: (content: string, message?: Message) => {
|
|
3065
|
+
this.emit(
|
|
3066
|
+
"compactBlockAdded",
|
|
3067
|
+
{ content, message: message ? messageForUi(message) : undefined },
|
|
3068
|
+
ctx.registeredSessionId,
|
|
3069
|
+
);
|
|
2302
3070
|
},
|
|
2303
3071
|
onCompactionStateChange: (isCompacting: boolean) => {
|
|
2304
3072
|
this.emit(
|
|
@@ -2373,7 +3141,7 @@ export class AgentBridge {
|
|
|
2373
3141
|
|
|
2374
3142
|
private getSessionWorkdir(sessionId?: string): string | undefined {
|
|
2375
3143
|
if (!sessionId) return undefined;
|
|
2376
|
-
return this.sessions.get(sessionId)?.agent.
|
|
3144
|
+
return this.sessions.get(sessionId)?.agent.currentWorkdir;
|
|
2377
3145
|
}
|
|
2378
3146
|
}
|
|
2379
3147
|
|