wave-code 1.2.0 → 1.4.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 +1738 -893
- package/package.json +6 -3
- package/src/cli.tsx +9 -0
- package/src/commands/plugin/install.ts +6 -7
- package/src/commands/plugin/uninstall.ts +11 -5
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ChatInterface.tsx +1 -0
- package/src/components/ConfirmationDetails.tsx +51 -16
- package/src/components/ConfirmationSelector.tsx +39 -0
- package/src/components/InputBox.tsx +41 -2
- package/src/components/Markdown.tsx +21 -8
- package/src/components/MarketplaceDetail.tsx +10 -27
- package/src/components/MarketplaceView.tsx +17 -3
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/Notifications.tsx +7 -0
- package/src/components/PlanView.tsx +22 -17
- package/src/components/PluginManagerShell.tsx +3 -3
- package/src/components/PluginManagerTypes.ts +2 -1
- package/src/components/ResumeCommand.tsx +84 -0
- package/src/components/ScrolledContent.tsx +71 -0
- package/src/components/SessionSelector.tsx +184 -6
- package/src/components/SessionSelectorFlow.tsx +246 -0
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +24 -0
- package/src/contexts/useChat.tsx +517 -155
- package/src/daemon/commands.ts +582 -50
- package/src/daemon-cli.ts +12 -1
- package/src/hooks/useInputManager.ts +20 -0
- package/src/hooks/usePluginManager.ts +104 -78
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +97 -8
- package/src/managers/inputHandlers.ts +16 -9
- package/src/managers/inputReducer.ts +30 -9
- package/src/print-cli.ts +5 -0
- package/src/reducers/confirmationReducer.ts +20 -1
- package/src/reducers/selectorReducer.ts +3 -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 +97 -0
- package/src/remote/notificationCoalescer.ts +174 -0
- package/src/remote/relayClient.ts +668 -0
- package/src/remote/remoteControlCliHost.ts +391 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +334 -0
- package/src/remote/remoteControlService.ts +799 -0
- package/src/remote/remoteControlSink.ts +77 -0
- package/src/remote/remoteNotifications.ts +194 -0
- package/src/session-selector-cli.tsx +9 -255
- package/src/stdio/agentBridge.ts +1001 -165
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -106
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio-cli.ts +16 -1
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/sessionOwnership.ts +62 -0
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/components/PlanDisplay.tsx +0 -18
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -36,6 +36,8 @@ import {
|
|
|
36
36
|
type PartialHookConfiguration,
|
|
37
37
|
isValidHookEvent,
|
|
38
38
|
listSessions,
|
|
39
|
+
listAllSessions,
|
|
40
|
+
setSessionCustomTitle,
|
|
39
41
|
searchFiles,
|
|
40
42
|
generateRandomName,
|
|
41
43
|
getDefaultRemoteBranch,
|
|
@@ -49,30 +51,61 @@ import {
|
|
|
49
51
|
loadWaveConfigFromFile,
|
|
50
52
|
getUserConfigPaths,
|
|
51
53
|
getProjectConfigPaths,
|
|
54
|
+
readUserPreferenceView,
|
|
55
|
+
updateUserPreferenceSettings,
|
|
56
|
+
type UserPreferenceSettings,
|
|
57
|
+
type UserPreferenceSettingsView,
|
|
52
58
|
type SubagentConfiguration,
|
|
53
59
|
type SkillMetadata,
|
|
60
|
+
type PluginReloadResult,
|
|
61
|
+
type RemoteControlSnapshot,
|
|
62
|
+
normalizeEntrypoint,
|
|
54
63
|
} from "wave-agent-sdk";
|
|
55
64
|
import {
|
|
65
|
+
type ClientNotificationMethod,
|
|
56
66
|
type JsonRpcError,
|
|
67
|
+
type RequestMethod,
|
|
68
|
+
type ServerNotificationMethod,
|
|
57
69
|
INVALID_PARAMS as PROTOCOL_INVALID_PARAMS,
|
|
58
70
|
INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
|
|
59
71
|
METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
|
|
60
72
|
} from "./protocol.js";
|
|
61
73
|
import { execFileSync } from "node:child_process";
|
|
62
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
mkdirSync,
|
|
76
|
+
existsSync,
|
|
77
|
+
readFileSync,
|
|
78
|
+
writeFileSync,
|
|
79
|
+
readdirSync,
|
|
80
|
+
realpathSync,
|
|
81
|
+
} from "node:fs";
|
|
63
82
|
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
64
83
|
import { homedir, tmpdir } from "node:os";
|
|
65
84
|
import { basename, dirname, extname, join } from "node:path";
|
|
66
85
|
import {
|
|
67
86
|
createWorktree,
|
|
87
|
+
deriveWorktreeInfo,
|
|
68
88
|
getWorktreeChanges,
|
|
69
89
|
removeWorktree,
|
|
70
90
|
} from "../utils/worktree.js";
|
|
71
91
|
import { logger } from "../utils/logger.js";
|
|
92
|
+
import {
|
|
93
|
+
InvalidMessageWindowError,
|
|
94
|
+
sliceMessageWindow,
|
|
95
|
+
type MessageWindowResult,
|
|
96
|
+
} from "../remote/messageWindow.js";
|
|
97
|
+
import {
|
|
98
|
+
percentOf,
|
|
99
|
+
remoteWire,
|
|
100
|
+
toBackgroundTaskSummaries,
|
|
101
|
+
} from "../remote/remoteNotifications.js";
|
|
72
102
|
import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
|
|
103
|
+
import { getWorkspaceDiff } from "../utils/workspaceDiff.js";
|
|
104
|
+
import { installGitCredentialHelper } from "../utils/gitCredentialHelper.js";
|
|
105
|
+
import type { RemoteControlController } from "../remote/remoteControlService.js";
|
|
73
106
|
|
|
74
107
|
export type NotificationEmitter = (
|
|
75
|
-
method:
|
|
108
|
+
method: ServerNotificationMethod,
|
|
76
109
|
params: unknown,
|
|
77
110
|
sessionId?: string,
|
|
78
111
|
) => void;
|
|
@@ -84,6 +117,17 @@ export interface AgentBridgeOptions {
|
|
|
84
117
|
* down its socket and exits. stdio mode leaves it unset, so `shutdown` is
|
|
85
118
|
* "Method not found" there. */
|
|
86
119
|
onShutdownRequest?: () => void;
|
|
120
|
+
/** Daemon transport only. Remote control is a daemon-level concern (it owns
|
|
121
|
+
* an outbound socket and a relay session); the bridge owns nothing here and
|
|
122
|
+
* only routes the three `remoteControl*` RPCs to it, exactly as it routes
|
|
123
|
+
* `shutdown` to `onShutdownRequest`. Unset ⇒ those RPCs report that remote
|
|
124
|
+
* control is not enabled, which is the state on every stdio-mode process.
|
|
125
|
+
*
|
|
126
|
+
* Resolved on demand rather than passed as an object: the controller needs
|
|
127
|
+
* this bridge to route the phone's requests back in, so the two are built in
|
|
128
|
+
* a circle. Reading it when an RPC arrives — long after both exist — keeps
|
|
129
|
+
* that circle from having to be broken with a placeholder. */
|
|
130
|
+
remoteControl?: () => RemoteControlController | undefined;
|
|
87
131
|
}
|
|
88
132
|
|
|
89
133
|
interface InitializeParams {
|
|
@@ -95,7 +139,6 @@ interface InitializeParams {
|
|
|
95
139
|
defaultHeaders?: Record<string, string>;
|
|
96
140
|
model?: string;
|
|
97
141
|
fastModel?: string;
|
|
98
|
-
language?: string;
|
|
99
142
|
permissionMode?: PermissionMode;
|
|
100
143
|
tools?: string[];
|
|
101
144
|
allowedTools?: string[];
|
|
@@ -104,11 +147,18 @@ interface InitializeParams {
|
|
|
104
147
|
mcpServers?: Record<string, McpServerConfig>;
|
|
105
148
|
worktreeName?: string;
|
|
106
149
|
isNewWorktree?: boolean;
|
|
107
|
-
/**
|
|
108
|
-
|
|
109
|
-
|
|
150
|
+
/**
|
|
151
|
+
* Which host the session belongs to → the `app.entrypoint` attribute. A raw
|
|
152
|
+
* string: it arrives over the wire, from hosts this process does not compile
|
|
153
|
+
* against (the JetBrains plugin builds its own frames). Narrowed to the known
|
|
154
|
+
* table below before it reaches the SDK.
|
|
155
|
+
*/
|
|
156
|
+
entrypoint?: string;
|
|
110
157
|
}
|
|
111
158
|
|
|
159
|
+
// 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
|
|
160
|
+
// 它们落用户级 ~/.wave/settings.json 经实时重载生效(getUserSettings /
|
|
161
|
+
// updateUserSettings),走覆盖层会永久遮蔽 settings.json 的实时值。
|
|
112
162
|
interface UpdateConfigParams {
|
|
113
163
|
apiKey?: string;
|
|
114
164
|
baseURL?: string;
|
|
@@ -116,10 +166,6 @@ interface UpdateConfigParams {
|
|
|
116
166
|
defaultHeaders?: Record<string, string>;
|
|
117
167
|
model?: string;
|
|
118
168
|
fastModel?: string;
|
|
119
|
-
language?: string;
|
|
120
|
-
/** Settings-page auto-memory toggle/frequency (session-level override). */
|
|
121
|
-
autoMemoryEnabled?: boolean;
|
|
122
|
-
autoMemoryFrequency?: number;
|
|
123
169
|
}
|
|
124
170
|
|
|
125
171
|
interface SearchFilesParams {
|
|
@@ -141,6 +187,24 @@ interface SessionEntry {
|
|
|
141
187
|
* header when the session is re-attached from disk after a daemon restart.
|
|
142
188
|
*/
|
|
143
189
|
createdWorkdir: string;
|
|
190
|
+
/**
|
|
191
|
+
* True while `updateConfig` is tearing the session's agent down and rebuilding
|
|
192
|
+
* it from the updated config — the entry stays in the registry (so the session
|
|
193
|
+
* does not look deleted to a `wait` that is blocked on it) but has no usable
|
|
194
|
+
* agent. Two consequences, both deliberate:
|
|
195
|
+
*
|
|
196
|
+
* - `listDaemonSessions` reports it as `isLoading: true`. The teardown aborts
|
|
197
|
+
* the in-flight turn, so the outgoing agent reads `isLoading: false` while the
|
|
198
|
+
* rebuild is still running; reporting that would let a `wait` print an idle
|
|
199
|
+
* snapshot for a session whose agent is mid-replacement. A waiter that keeps
|
|
200
|
+
* waiting is never wrong here — the rebuild is bounded, and if it fails the
|
|
201
|
+
* entry is dropped, so the waiter then gets a clear "no longer exists".
|
|
202
|
+
* - State-changing RPCs refuse it (`requireSession`); a message sent to the
|
|
203
|
+
* outgoing agent would be silently lost. Read-only RPCs still work
|
|
204
|
+
* (`requireSessionForRead`) and see the outgoing agent's data, which is this
|
|
205
|
+
* same session's transcript.
|
|
206
|
+
*/
|
|
207
|
+
transitioning?: boolean;
|
|
144
208
|
}
|
|
145
209
|
|
|
146
210
|
/**
|
|
@@ -169,14 +233,26 @@ export class AgentBridge {
|
|
|
169
233
|
}
|
|
170
234
|
>();
|
|
171
235
|
private permissionCounter = 0;
|
|
236
|
+
/**
|
|
237
|
+
* In-flight completion calls, keyed by envelope sessionId. Completion answers
|
|
238
|
+
* a keystroke pause, so a newer request makes the previous one worthless —
|
|
239
|
+
* superseding it here is what keeps "one in-flight request per editor" true
|
|
240
|
+
* even when the host cannot cancel (spec
|
|
241
|
+
* docs/specs/ui/code-completion.md: 同一时刻只允许一个补全请求在途).
|
|
242
|
+
*/
|
|
243
|
+
private inFlightCompletions = new Map<string, AbortController>();
|
|
172
244
|
private emit: NotificationEmitter;
|
|
173
245
|
private onShutdownRequest: (() => void) | undefined;
|
|
246
|
+
private remoteControl:
|
|
247
|
+
| (() => RemoteControlController | undefined)
|
|
248
|
+
| undefined;
|
|
174
249
|
private pluginCore: PluginCore | undefined;
|
|
175
250
|
private pluginCoreWorkdir: string | undefined;
|
|
176
251
|
|
|
177
252
|
constructor(options: AgentBridgeOptions) {
|
|
178
253
|
this.emit = options.emit;
|
|
179
254
|
this.onShutdownRequest = options.onShutdownRequest;
|
|
255
|
+
this.remoteControl = options.remoteControl;
|
|
180
256
|
// Mirror the user-level settings env WAVE_SERVER_URL into process.env
|
|
181
257
|
// before any agent initializes. getAuthStatus (webviewReady →
|
|
182
258
|
// pushInitialState) can run before the first agent, and AuthService falls
|
|
@@ -191,7 +267,7 @@ export class AgentBridge {
|
|
|
191
267
|
// ── Public API ────────────────────────────────────────────────
|
|
192
268
|
|
|
193
269
|
async handleRequest(
|
|
194
|
-
method:
|
|
270
|
+
method: RequestMethod,
|
|
195
271
|
params: unknown,
|
|
196
272
|
sessionId?: string,
|
|
197
273
|
): Promise<unknown> {
|
|
@@ -201,11 +277,28 @@ export class AgentBridge {
|
|
|
201
277
|
case "initialize":
|
|
202
278
|
return this.initialize(p as unknown as InitializeParams);
|
|
203
279
|
case "destroy":
|
|
204
|
-
return this.destroy(sessionId
|
|
280
|
+
return this.destroy(sessionId, {
|
|
281
|
+
removeWorktree: p.removeWorktree === true,
|
|
282
|
+
});
|
|
205
283
|
case "restoreSession":
|
|
206
284
|
return this.restoreSession(p.sessionId as string, sessionId);
|
|
207
285
|
case "listSessions":
|
|
208
286
|
return this.listSessions(p.workdir as string | undefined, sessionId);
|
|
287
|
+
case "listAllSessions":
|
|
288
|
+
// Every project on this host (no session required) — the desktop's
|
|
289
|
+
// /resume picker lists sessions the app never created, so it cannot use
|
|
290
|
+
// the per-workdir listSessions.
|
|
291
|
+
return this.listAllSessions();
|
|
292
|
+
case "renameSession":
|
|
293
|
+
// Host-level like listAllSessions: the target session may have no live
|
|
294
|
+
// agent (the desktop renames sessions straight from its sidebar, and a
|
|
295
|
+
// restored one is not running), so the id/workdir come from the caller
|
|
296
|
+
// instead of the bound session.
|
|
297
|
+
return this.renameSession(
|
|
298
|
+
p.sessionId as string,
|
|
299
|
+
p.workdir as string,
|
|
300
|
+
p.title as string,
|
|
301
|
+
);
|
|
209
302
|
case "getSessionInfo":
|
|
210
303
|
return this.getSessionInfo(sessionId);
|
|
211
304
|
case "listPendingPermissions":
|
|
@@ -233,6 +326,15 @@ export class AgentBridge {
|
|
|
233
326
|
case "setModel":
|
|
234
327
|
return this.setModel(p.model as string, sessionId);
|
|
235
328
|
|
|
329
|
+
// ── User preferences (global — user-level ~/.wave/settings.json) ──
|
|
330
|
+
// 设置页保存路径:用户偏好写文件后由 SDK 实时重载在各会话下一轮生效,
|
|
331
|
+
// 不经 updateConfig 当 AgentOptions 覆盖层下发(见 protocol.ts 注释)。
|
|
332
|
+
// 读回的是**生效值**(可能来自 Remote 组织下发/环境变量)+ 每键来源层。
|
|
333
|
+
case "getUserSettings":
|
|
334
|
+
return this.userSettingsView();
|
|
335
|
+
case "updateUserSettings":
|
|
336
|
+
return this.updateUserSettings(p as UserPreferenceSettings);
|
|
337
|
+
|
|
236
338
|
// ── Messages ──
|
|
237
339
|
case "sendMessage":
|
|
238
340
|
return this.sendMessage(
|
|
@@ -267,10 +369,21 @@ export class AgentBridge {
|
|
|
267
369
|
case "deleteQueuedMessageById":
|
|
268
370
|
return this.deleteQueuedMessageById(p.id as string, sessionId);
|
|
269
371
|
case "getMessages":
|
|
270
|
-
return this.getMessages(sessionId
|
|
372
|
+
return this.getMessages(sessionId, {
|
|
373
|
+
before: p.before as string | undefined,
|
|
374
|
+
since: p.since as string | undefined,
|
|
375
|
+
limit: p.limit as number | undefined,
|
|
376
|
+
});
|
|
271
377
|
case "getFullMessageThread":
|
|
272
378
|
return this.getFullMessageThread(sessionId);
|
|
273
379
|
|
|
380
|
+
// ── Remote control (daemon-level) ──
|
|
381
|
+
// The daemon owns the outbound relay socket; the bridge only routes.
|
|
382
|
+
case "remoteControlStatus":
|
|
383
|
+
case "remoteControlConnect":
|
|
384
|
+
case "remoteControlDisconnect":
|
|
385
|
+
return this.remoteControlRequest(method, p);
|
|
386
|
+
|
|
274
387
|
// ── Permissions ──
|
|
275
388
|
case "setPermissionMode":
|
|
276
389
|
return this.setPermissionMode(p.mode as PermissionMode, sessionId);
|
|
@@ -346,6 +459,8 @@ export class AgentBridge {
|
|
|
346
459
|
return this.login(p.serverUrl as string | undefined);
|
|
347
460
|
case "logout":
|
|
348
461
|
return this.logout();
|
|
462
|
+
case "installGitCredentialHelper":
|
|
463
|
+
return installGitCredentialHelper();
|
|
349
464
|
|
|
350
465
|
// ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
|
|
351
466
|
// project-level <workdir>/AGENTS.md) ──
|
|
@@ -376,6 +491,7 @@ export class AgentBridge {
|
|
|
376
491
|
case "uninstallPlugin":
|
|
377
492
|
return this.uninstallPlugin(
|
|
378
493
|
p.pluginId as string,
|
|
494
|
+
p.scope as Scope | undefined,
|
|
379
495
|
p.workdir as string | undefined,
|
|
380
496
|
sessionId,
|
|
381
497
|
);
|
|
@@ -424,11 +540,25 @@ export class AgentBridge {
|
|
|
424
540
|
p.workdir as string | undefined,
|
|
425
541
|
sessionId,
|
|
426
542
|
);
|
|
543
|
+
case "setPluginScope":
|
|
544
|
+
return this.setPluginScope(
|
|
545
|
+
p.pluginId as string,
|
|
546
|
+
p.scope as Scope,
|
|
547
|
+
p.workdir as string | undefined,
|
|
548
|
+
sessionId,
|
|
549
|
+
);
|
|
550
|
+
case "reloadPlugins":
|
|
551
|
+
return this.reloadPlugins();
|
|
427
552
|
case "listMarketplaces":
|
|
428
553
|
return this.listMarketplaces(
|
|
429
554
|
p.workdir as string | undefined,
|
|
430
555
|
sessionId,
|
|
431
556
|
);
|
|
557
|
+
case "refreshMarketplaces":
|
|
558
|
+
return this.refreshMarketplaces(
|
|
559
|
+
p.workdir as string | undefined,
|
|
560
|
+
sessionId,
|
|
561
|
+
);
|
|
432
562
|
case "addMarketplace":
|
|
433
563
|
return this.addMarketplace(
|
|
434
564
|
p.input as string,
|
|
@@ -468,7 +598,9 @@ export class AgentBridge {
|
|
|
468
598
|
case "stopWorkflowRun":
|
|
469
599
|
return this.stopWorkflowRun(p.runId as string, sessionId);
|
|
470
600
|
|
|
471
|
-
// ── Git / worktree (global — no session required) ──
|
|
601
|
+
// ── Filesystem / Git / worktree (global — no session required) ──
|
|
602
|
+
case "listDirectories":
|
|
603
|
+
return this.listDirectories(p.dir as string | undefined);
|
|
472
604
|
case "listGitBranches":
|
|
473
605
|
return this.listGitBranches(p.workdir as string | undefined);
|
|
474
606
|
case "createWorktree":
|
|
@@ -483,6 +615,8 @@ export class AgentBridge {
|
|
|
483
615
|
return this.getWorktreeChanges(
|
|
484
616
|
p as unknown as { path?: string; baseBranch?: string },
|
|
485
617
|
);
|
|
618
|
+
case "getSessionWorktreeChanges":
|
|
619
|
+
return this.getSessionWorktreeChanges(sessionId);
|
|
486
620
|
case "removeWorktree":
|
|
487
621
|
return this.removeWorktreeSession(
|
|
488
622
|
p as unknown as {
|
|
@@ -492,16 +626,42 @@ export class AgentBridge {
|
|
|
492
626
|
hookBased?: boolean;
|
|
493
627
|
},
|
|
494
628
|
);
|
|
629
|
+
case "getWorkspaceDiff":
|
|
630
|
+
return this.getWorkspaceDiff(
|
|
631
|
+
p.workdir as string | undefined,
|
|
632
|
+
p.commit as string | undefined,
|
|
633
|
+
p.scope as string | undefined,
|
|
634
|
+
);
|
|
635
|
+
case "getCompletion":
|
|
636
|
+
return this.getCompletion(
|
|
637
|
+
p as unknown as {
|
|
638
|
+
prefix?: string;
|
|
639
|
+
suffix?: string;
|
|
640
|
+
language?: string;
|
|
641
|
+
filePath?: string;
|
|
642
|
+
},
|
|
643
|
+
sessionId,
|
|
644
|
+
);
|
|
495
645
|
|
|
496
|
-
default:
|
|
646
|
+
default: {
|
|
647
|
+
// Type-level exhaustiveness, both directions: a union member without a
|
|
648
|
+
// `case` lands here as a non-`never` and fails to compile, and a `case`
|
|
649
|
+
// naming something outside the union fails to compile at the `case`
|
|
650
|
+
// itself. Runtime behaviour is unchanged — `method` can still be an
|
|
651
|
+
// arbitrary string once the transport boundary cast it.
|
|
652
|
+
const _exhaustive: never = method;
|
|
653
|
+
// Kept referenced so the witness isn't an unused-variable lint error;
|
|
654
|
+
// it is never read at runtime.
|
|
655
|
+
void _exhaustive;
|
|
497
656
|
throw new RpcError(
|
|
498
657
|
PROTOCOL_METHOD_NOT_FOUND,
|
|
499
658
|
`Method not found: ${method}`,
|
|
500
659
|
);
|
|
660
|
+
}
|
|
501
661
|
}
|
|
502
662
|
}
|
|
503
663
|
|
|
504
|
-
handleNotification(method:
|
|
664
|
+
handleNotification(method: ClientNotificationMethod, params: unknown): void {
|
|
505
665
|
if (method === "permissionResponse") {
|
|
506
666
|
const p = params as {
|
|
507
667
|
requestId: string;
|
|
@@ -542,6 +702,16 @@ export class AgentBridge {
|
|
|
542
702
|
const ctx: SessionContext = {};
|
|
543
703
|
const callbacks = this.createCallbacks(ctx);
|
|
544
704
|
|
|
705
|
+
const entrypoint = normalizeEntrypoint(params.entrypoint);
|
|
706
|
+
if (params.entrypoint !== undefined && entrypoint === undefined) {
|
|
707
|
+
// A host that ships a value this version does not know (an older client,
|
|
708
|
+
// a hand-written frame) is treated as if it had declared nothing: an
|
|
709
|
+
// unrecognised value must not mint a series of its own.
|
|
710
|
+
logger?.warn(
|
|
711
|
+
`Unknown entrypoint: ${String(params.entrypoint)}, ignoring`,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
|
|
545
715
|
const options: AgentOptions = {
|
|
546
716
|
callbacks,
|
|
547
717
|
logger,
|
|
@@ -552,9 +722,6 @@ export class AgentBridge {
|
|
|
552
722
|
defaultHeaders: params.defaultHeaders,
|
|
553
723
|
model: params.model,
|
|
554
724
|
fastModel: params.fastModel,
|
|
555
|
-
language: params.language,
|
|
556
|
-
autoMemoryEnabled: params.autoMemoryEnabled,
|
|
557
|
-
autoMemoryFrequency: params.autoMemoryFrequency,
|
|
558
725
|
permissionMode: params.permissionMode,
|
|
559
726
|
tools: params.tools,
|
|
560
727
|
allowedTools: params.allowedTools,
|
|
@@ -563,6 +730,7 @@ export class AgentBridge {
|
|
|
563
730
|
mcpServers: params.mcpServers,
|
|
564
731
|
worktreeName: params.worktreeName,
|
|
565
732
|
isNewWorktree: params.isNewWorktree,
|
|
733
|
+
entrypoint,
|
|
566
734
|
canUseTool: (context: ToolPermissionContext) =>
|
|
567
735
|
this.canUseTool(context, ctx),
|
|
568
736
|
};
|
|
@@ -604,15 +772,133 @@ export class AgentBridge {
|
|
|
604
772
|
};
|
|
605
773
|
}
|
|
606
774
|
|
|
607
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Destroy one hosted session (idempotent: an unknown id is a no-op).
|
|
777
|
+
*
|
|
778
|
+
* `params.removeWorktree` (optional, default false) additionally removes the
|
|
779
|
+
* git worktree the session was **created** in — the intent the remote page
|
|
780
|
+
* sends when the user confirms 「删除会话与工作树」. Two rules make it safe to
|
|
781
|
+
* offer over the remote channel, where the caller must never be able to name a
|
|
782
|
+
* path:
|
|
783
|
+
*
|
|
784
|
+
* 1. The worktree is resolved here, from `entry.createdWorkdir` — the same
|
|
785
|
+
* anchor `wave daemon destroy --remove-worktree` resolves from (and for the
|
|
786
|
+
* same reason: an in-session `cd` must not move the target). Nothing in the
|
|
787
|
+
* request is trusted as a path, which is why the raw `removeWorktree`
|
|
788
|
+
* method stays off the remote whitelist.
|
|
789
|
+
* 2. It runs **after** `agent.destroy()` resolves. The agent's cwd is the
|
|
790
|
+
* worktree path and destroy flushes the session file (resolving that path
|
|
791
|
+
* via realpath); deleting the directory first makes the save fail ENOENT
|
|
792
|
+
* and drops the trailing messages — the desktop host carries the identical
|
|
793
|
+
* ordering note in `handleDeleteSession`.
|
|
794
|
+
*
|
|
795
|
+
* "Cannot resolve a worktree" is never a reason to fail the delete: the
|
|
796
|
+
* session is still destroyed and the reply says `worktreeRemoved: false`. That
|
|
797
|
+
* covers a plain (non-worktree) session, a worktree that is already gone, and
|
|
798
|
+
* a removal that threw — the caller only ever needs to know whether the
|
|
799
|
+
* directory is gone.
|
|
800
|
+
*
|
|
801
|
+
* The return shape grew from `null` to `{worktreeRemoved}`; callers that pass
|
|
802
|
+
* no params (the CLI's own `wave daemon destroy`) read nothing and are
|
|
803
|
+
* unaffected. The CLI's `--remove-worktree` keeps its own client-side path
|
|
804
|
+
* (getSessionInfo + removeWorktree + destroy) — deliberately unchanged.
|
|
805
|
+
*/
|
|
806
|
+
private async destroy(
|
|
807
|
+
sessionId?: string,
|
|
808
|
+
params?: { removeWorktree?: boolean },
|
|
809
|
+
): Promise<{ worktreeRemoved: boolean }> {
|
|
810
|
+
let createdWorkdir: string | undefined;
|
|
608
811
|
if (sessionId) {
|
|
609
812
|
const entry = this.sessions.get(sessionId);
|
|
610
813
|
if (entry) {
|
|
611
|
-
|
|
814
|
+
if (entry.transitioning) {
|
|
815
|
+
// A rebuild owns this entry (updateConfig): destroying it here would
|
|
816
|
+
// race the rebuild, which would then re-register a replacement for a
|
|
817
|
+
// session the caller just destroyed. Refuse loudly instead — the
|
|
818
|
+
// caller can retry once the reload settles.
|
|
819
|
+
throw new RpcError(
|
|
820
|
+
PROTOCOL_INTERNAL_ERROR,
|
|
821
|
+
`Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
// Drop the registry entry BEFORE awaiting the teardown. Agent.destroy()
|
|
825
|
+
// aborts the in-flight turn, which pushes `loadingChange:false` — the
|
|
826
|
+
// exact notification a finished turn pushes — and the teardown itself is
|
|
827
|
+
// not instant (transcript save, subagent/mcp cleanup, auto-memory drain).
|
|
828
|
+
// A client blocked in `wait` that is woken by that push re-reads the
|
|
829
|
+
// registry for the verdict: with the entry still listed (isLoading now
|
|
830
|
+
// false) it settles as exit 0 on an intermediate snapshot — a false
|
|
831
|
+
// "finished" for a session that is being removed. With the entry gone the
|
|
832
|
+
// existence check wins and it reports "no longer exists" (exit 1).
|
|
833
|
+
// Removed first also means a throwing teardown cannot leave a
|
|
834
|
+
// half-destroyed agent listed (destroyAll() below clears the map first
|
|
835
|
+
// for the same reason).
|
|
612
836
|
this.sessions.delete(sessionId);
|
|
837
|
+
createdWorkdir = entry.createdWorkdir;
|
|
838
|
+
await entry.agent.destroy();
|
|
613
839
|
}
|
|
614
840
|
}
|
|
615
|
-
|
|
841
|
+
|
|
842
|
+
let worktreeRemoved = false;
|
|
843
|
+
if (params?.removeWorktree === true && createdWorkdir) {
|
|
844
|
+
worktreeRemoved = await this.removeSessionWorktree(createdWorkdir);
|
|
845
|
+
}
|
|
846
|
+
return { worktreeRemoved };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Remove the linked worktree a session was created in. Returns whether the
|
|
851
|
+
* directory is actually gone (not whether we asked): `removeWorktree` is
|
|
852
|
+
* best-effort and leaves the directory behind when git and the fs fallback
|
|
853
|
+
* both fail, and for a hook-based worktree with no WorktreeRemove hook it
|
|
854
|
+
* deliberately leaves it in place. Never throws — the caller has already
|
|
855
|
+
* deleted the session.
|
|
856
|
+
*/
|
|
857
|
+
private async removeSessionWorktree(sessionDir: string): Promise<boolean> {
|
|
858
|
+
const worktree = deriveWorktreeInfo(sessionDir);
|
|
859
|
+
if (!worktree) return false;
|
|
860
|
+
try {
|
|
861
|
+
await removeWorktree({
|
|
862
|
+
name: "",
|
|
863
|
+
path: worktree.path,
|
|
864
|
+
branch: worktree.branch,
|
|
865
|
+
repoRoot: worktree.repoRoot,
|
|
866
|
+
hasUncommittedChanges: false,
|
|
867
|
+
hasNewCommits: false,
|
|
868
|
+
isNew: false,
|
|
869
|
+
hookBased: worktree.hookBased,
|
|
870
|
+
});
|
|
871
|
+
} catch (e) {
|
|
872
|
+
logger.warn(
|
|
873
|
+
`removeSessionWorktree: failed to remove ${worktree.path}: ${(e as Error).message}`,
|
|
874
|
+
);
|
|
875
|
+
return false;
|
|
876
|
+
}
|
|
877
|
+
return !existsSync(worktree.path);
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* What deleting a session's worktree would throw away (uncommitted files and
|
|
882
|
+
* commits not on the base branch) — the counts the delete confirmation names
|
|
883
|
+
* on the remote page.
|
|
884
|
+
*
|
|
885
|
+
* Session-scoped on purpose: the path comes from the session's own
|
|
886
|
+
* `createdWorkdir`, never from the caller. The remote channel carries this
|
|
887
|
+
* instead of the path-taking `getWorktreeChanges`, for the same reason it
|
|
888
|
+
* carries `destroy {removeWorktree}` instead of `removeWorktree` — a phone may
|
|
889
|
+
* only name a session it was given, not an arbitrary directory on the machine.
|
|
890
|
+
*
|
|
891
|
+
* `null` means "could not inspect" (not a worktree, already deleted, git
|
|
892
|
+
* failed), which the page renders as the generic warning rather than as
|
|
893
|
+
* "clean".
|
|
894
|
+
*/
|
|
895
|
+
private async getSessionWorktreeChanges(
|
|
896
|
+
sessionId?: string,
|
|
897
|
+
): Promise<{ files: number; commits: number } | null> {
|
|
898
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
899
|
+
const worktree = deriveWorktreeInfo(entry.createdWorkdir);
|
|
900
|
+
if (!worktree) return null;
|
|
901
|
+
return getWorktreeChanges(worktree.path);
|
|
616
902
|
}
|
|
617
903
|
|
|
618
904
|
/**
|
|
@@ -633,15 +919,25 @@ export class AgentBridge {
|
|
|
633
919
|
restoreId: string,
|
|
634
920
|
sessionId?: string,
|
|
635
921
|
): Promise<null> {
|
|
636
|
-
const entry = this.
|
|
922
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
637
923
|
// Re-attach to a live session: the SDK restore would no-op (target is
|
|
638
|
-
// already current).
|
|
639
|
-
//
|
|
640
|
-
//
|
|
924
|
+
// already current). Re-send the state the freshly attached client missed —
|
|
925
|
+
// its router registered only after initialize returned.
|
|
926
|
+
//
|
|
927
|
+
// `messagesChange` carries **no payload here, only the fact that the list
|
|
928
|
+
// changed**. Nobody ever read a full snapshot from it: the remote relay
|
|
929
|
+
// needs the emission to produce the phone's signal and then the phone pulls
|
|
930
|
+
// `getMessages({ since })` itself, while every IDE host ignores the method
|
|
931
|
+
// and calls `getMessages` after restore. So building one — a multi-MB list
|
|
932
|
+
// on a long session, serialized on a hot re-attach path — was pure cost, and
|
|
933
|
+
// this is why the bridge sends the signal shape instead.
|
|
934
|
+
//
|
|
935
|
+
// This is also the ONLY place the bridge emits `messagesChange`, and only on
|
|
936
|
+
// this re-attach branch — never as the message stream progresses.
|
|
641
937
|
if (entry.agent.sessionId === restoreId) {
|
|
642
938
|
this.emit(
|
|
643
939
|
"messagesChange",
|
|
644
|
-
|
|
940
|
+
remoteWire.messagesChangeSignal(),
|
|
645
941
|
entry.agent.sessionId,
|
|
646
942
|
);
|
|
647
943
|
// The re-attached client also missed the loading state that settled
|
|
@@ -665,6 +961,18 @@ export class AgentBridge {
|
|
|
665
961
|
this.emitContextUsage(entry.agent);
|
|
666
962
|
return null;
|
|
667
963
|
}
|
|
964
|
+
// The replay branch above is safe during an `updateConfig` transition (it
|
|
965
|
+
// only re-pushes the outgoing agent's own state, which is this session's
|
|
966
|
+
// transcript). A REAL restore is not: it would load another conversation
|
|
967
|
+
// into the agent that is about to be destroyed and replaced, so the call
|
|
968
|
+
// would be silently thrown away together with the outgoing agent. Refuse it
|
|
969
|
+
// with the same retryable error the mutating RPCs use.
|
|
970
|
+
if (entry.transitioning) {
|
|
971
|
+
throw new RpcError(
|
|
972
|
+
PROTOCOL_INTERNAL_ERROR,
|
|
973
|
+
`Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
668
976
|
await entry.agent.restoreSession(restoreId);
|
|
669
977
|
// A real restore must also re-emit the usage unconditionally: the SDK
|
|
670
978
|
// only fires onLatestTotalTokensChange on a VALUE CHANGE, so restoring a
|
|
@@ -685,9 +993,14 @@ export class AgentBridge {
|
|
|
685
993
|
* usage to report; the host keeps the empty ring until the first push.
|
|
686
994
|
*/
|
|
687
995
|
private emitContextUsage(agent: Agent): void {
|
|
688
|
-
const
|
|
689
|
-
|
|
690
|
-
|
|
996
|
+
const tokens = agent.latestTotalTokens;
|
|
997
|
+
const max = agent.getMaxInputTokens();
|
|
998
|
+
if (tokens > 0 && max > 0) {
|
|
999
|
+
this.emit(
|
|
1000
|
+
"contextUsage",
|
|
1001
|
+
remoteWire.contextUsage(tokens, max),
|
|
1002
|
+
agent.sessionId,
|
|
1003
|
+
);
|
|
691
1004
|
}
|
|
692
1005
|
}
|
|
693
1006
|
|
|
@@ -702,7 +1015,7 @@ export class AgentBridge {
|
|
|
702
1015
|
const tokens = agent.latestTotalTokens;
|
|
703
1016
|
const max = agent.getMaxInputTokens();
|
|
704
1017
|
if (tokens > 0 && max > 0) {
|
|
705
|
-
return
|
|
1018
|
+
return percentOf(tokens, max);
|
|
706
1019
|
}
|
|
707
1020
|
return undefined;
|
|
708
1021
|
}
|
|
@@ -717,8 +1030,84 @@ export class AgentBridge {
|
|
|
717
1030
|
return { sessions };
|
|
718
1031
|
}
|
|
719
1032
|
|
|
1033
|
+
/**
|
|
1034
|
+
* All main sessions across every project directory of this host, newest
|
|
1035
|
+
* first (deduplicated by session id). Each entry carries the session's real
|
|
1036
|
+
* `workdir`, which is what a caller needs to resume it.
|
|
1037
|
+
*/
|
|
1038
|
+
private async listAllSessions(): Promise<{ sessions: SessionMetadata[] }> {
|
|
1039
|
+
const sessions = await listAllSessions();
|
|
1040
|
+
return { sessions };
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Set a session's user-visible title. Works for any session on this host —
|
|
1045
|
+
* the target does not need to be the bound one, or even live.
|
|
1046
|
+
*/
|
|
1047
|
+
private async renameSession(
|
|
1048
|
+
sessionId: string,
|
|
1049
|
+
workdir: string,
|
|
1050
|
+
title: string,
|
|
1051
|
+
): Promise<void> {
|
|
1052
|
+
// A session this host is running keeps the title in memory and writes it
|
|
1053
|
+
// back at its next flush point (exit, compaction, resume, rewind), so a
|
|
1054
|
+
// file-only write here would be clobbered by the stale in-memory value.
|
|
1055
|
+
// Route through the agent; fall back to the file for sessions that are not
|
|
1056
|
+
// bound to this host's process.
|
|
1057
|
+
const bound = this.sessions.get(sessionId)?.agent;
|
|
1058
|
+
if (bound) {
|
|
1059
|
+
await bound.renameSession(title);
|
|
1060
|
+
return;
|
|
1061
|
+
}
|
|
1062
|
+
await setSessionCustomTitle(sessionId, workdir, title);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
720
1065
|
// ── Git / worktree ────────────────────────────────────────────
|
|
721
1066
|
|
|
1067
|
+
/**
|
|
1068
|
+
* List the subdirectories of a directory on the machine this CLI runs on —
|
|
1069
|
+
* host-agnostic like `listGitBranches`: whichever wave process receives the
|
|
1070
|
+
* request lists its own filesystem. Serves the new-session directory pickers
|
|
1071
|
+
* (the desktop's remote host browser and the Web page now share this one
|
|
1072
|
+
* implementation; the desktop's ssh shell one-liner retires into it).
|
|
1073
|
+
*
|
|
1074
|
+
* `~` / `~/…` expand against the service process user's home. `resolvedPath`
|
|
1075
|
+
* is the realpath so the picker shows where the user actually is (symlinked
|
|
1076
|
+
* parents normalized). Symlinked subdirectories are NOT listed — the shell
|
|
1077
|
+
* `find -type d` this replaces didn't list them either, and the two paths
|
|
1078
|
+
* must stay interchangeable. A missing or unreadable directory is an explicit
|
|
1079
|
+
* error, never an empty list (an empty list would read as "no subdirs" and
|
|
1080
|
+
* strand the user on a path they can't leave).
|
|
1081
|
+
*/
|
|
1082
|
+
private listDirectories(dir?: string): {
|
|
1083
|
+
resolvedPath: string;
|
|
1084
|
+
dirs: string[];
|
|
1085
|
+
} {
|
|
1086
|
+
if (!dir) {
|
|
1087
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "dir is required");
|
|
1088
|
+
}
|
|
1089
|
+
const expanded =
|
|
1090
|
+
dir === "~" || dir.startsWith("~/") ? join(homedir(), dir.slice(1)) : dir;
|
|
1091
|
+
let resolvedPath: string;
|
|
1092
|
+
try {
|
|
1093
|
+
resolvedPath = realpathSync(expanded);
|
|
1094
|
+
} catch {
|
|
1095
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
|
|
1096
|
+
}
|
|
1097
|
+
let dirs: string[];
|
|
1098
|
+
try {
|
|
1099
|
+
dirs = readdirSync(resolvedPath, { withFileTypes: true })
|
|
1100
|
+
.filter((e) => e.isDirectory())
|
|
1101
|
+
.map((e) => e.name)
|
|
1102
|
+
.sort((a, b) => a.localeCompare(b));
|
|
1103
|
+
} catch {
|
|
1104
|
+
// realpath succeeded (the path exists) but reading it didn't — same
|
|
1105
|
+
// verdict as missing: the picker cannot descend into either.
|
|
1106
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, `目录不存在或不可读:${dir}`);
|
|
1107
|
+
}
|
|
1108
|
+
return { resolvedPath, dirs };
|
|
1109
|
+
}
|
|
1110
|
+
|
|
722
1111
|
private listGitBranches(workdir?: string): {
|
|
723
1112
|
branches: string[];
|
|
724
1113
|
current: string | null;
|
|
@@ -765,6 +1154,66 @@ export class AgentBridge {
|
|
|
765
1154
|
return { branches, current };
|
|
766
1155
|
}
|
|
767
1156
|
|
|
1157
|
+
/**
|
|
1158
|
+
* Read-only workspace diff for the desktop diff panel. Runs wherever this
|
|
1159
|
+
* CLI runs, so a remote session answers it inside the remote daemon instead
|
|
1160
|
+
* of the desktop shelling out over ssh per git command.
|
|
1161
|
+
*
|
|
1162
|
+
* `scope` selects the viewing range: omitted / "all" = the fork-point base,
|
|
1163
|
+
* "uncommitted" = `HEAD` (the panel's "未提交改动"). A `commit` outranks it.
|
|
1164
|
+
*/
|
|
1165
|
+
private getWorkspaceDiff(workdir?: string, commit?: string, scope?: string) {
|
|
1166
|
+
if (!workdir) {
|
|
1167
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, "workdir is required");
|
|
1168
|
+
}
|
|
1169
|
+
return getWorkspaceDiff(workdir, {
|
|
1170
|
+
...(commit ? { commit } : {}),
|
|
1171
|
+
...(scope === "uncommitted" ? { uncommitted: true } : {}),
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/**
|
|
1176
|
+
* One inline (ghost-text) completion for the editor plugins.
|
|
1177
|
+
*
|
|
1178
|
+
* Session-scoped on purpose: the completion model and the gateway credentials
|
|
1179
|
+
* are resolved inside that session (the CLI owns the model configuration —
|
|
1180
|
+
* the plugin never holds a second source of truth). Read-only, so it keeps
|
|
1181
|
+
* working while `updateConfig` is rebuilding the session.
|
|
1182
|
+
*/
|
|
1183
|
+
private async getCompletion(
|
|
1184
|
+
params: {
|
|
1185
|
+
prefix?: string;
|
|
1186
|
+
suffix?: string;
|
|
1187
|
+
language?: string;
|
|
1188
|
+
filePath?: string;
|
|
1189
|
+
},
|
|
1190
|
+
sessionId?: string,
|
|
1191
|
+
): Promise<{ text: string; model: string }> {
|
|
1192
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1193
|
+
const key = sessionId as string;
|
|
1194
|
+
|
|
1195
|
+
// Supersede the session's previous request: its answer would land at a
|
|
1196
|
+
// cursor position that no longer exists.
|
|
1197
|
+
this.inFlightCompletions.get(key)?.abort();
|
|
1198
|
+
const controller = new AbortController();
|
|
1199
|
+
this.inFlightCompletions.set(key, controller);
|
|
1200
|
+
|
|
1201
|
+
try {
|
|
1202
|
+
return await entry.agent.getCompletion({
|
|
1203
|
+
prefix: params.prefix ?? "",
|
|
1204
|
+
suffix: params.suffix ?? "",
|
|
1205
|
+
...(params.language ? { language: params.language } : {}),
|
|
1206
|
+
...(params.filePath ? { filePath: params.filePath } : {}),
|
|
1207
|
+
abortSignal: controller.signal,
|
|
1208
|
+
});
|
|
1209
|
+
} finally {
|
|
1210
|
+
// Only clear our own slot — a newer request may already own it.
|
|
1211
|
+
if (this.inFlightCompletions.get(key) === controller) {
|
|
1212
|
+
this.inFlightCompletions.delete(key);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
|
|
768
1217
|
private async createWorktreeSession(params: {
|
|
769
1218
|
workdir: string;
|
|
770
1219
|
baseBranch?: string;
|
|
@@ -850,14 +1299,19 @@ export class AgentBridge {
|
|
|
850
1299
|
return { ok: true };
|
|
851
1300
|
}
|
|
852
1301
|
|
|
853
|
-
private getSessionInfo(sessionId?: string): {
|
|
1302
|
+
private async getSessionInfo(sessionId?: string): Promise<{
|
|
854
1303
|
sessionId: string;
|
|
855
1304
|
workingDirectory: string;
|
|
856
1305
|
latestTotalTokens: number;
|
|
857
1306
|
permissionMode: PermissionMode;
|
|
858
1307
|
availableTools: string[];
|
|
859
|
-
|
|
860
|
-
|
|
1308
|
+
tasks: Task[];
|
|
1309
|
+
isCompacting: boolean;
|
|
1310
|
+
isCommandRunning: boolean;
|
|
1311
|
+
queuedMessages: QueuedMessage[];
|
|
1312
|
+
backgroundTasks: BackgroundTaskSummary[];
|
|
1313
|
+
}> {
|
|
1314
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
861
1315
|
return {
|
|
862
1316
|
sessionId: entry.agent.sessionId,
|
|
863
1317
|
// The recorded creation workdir, not the live agent.workingDirectory:
|
|
@@ -867,6 +1321,23 @@ export class AgentBridge {
|
|
|
867
1321
|
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
868
1322
|
permissionMode: entry.agent.getPermissionMode(),
|
|
869
1323
|
availableTools: entry.agent.getAvailableToolNames(),
|
|
1324
|
+
// The session-state family the remote page re-reads (spec
|
|
1325
|
+
// remote-control「Web 页作为第五种宿主」场景 26): the todo list —
|
|
1326
|
+
// file-backed in the SDK, hence the await — plus compaction, command
|
|
1327
|
+
// running and the outbound queue, all read off the agent itself so the
|
|
1328
|
+
// reply is the current value, never a transition the page may have
|
|
1329
|
+
// missed.
|
|
1330
|
+
tasks: await entry.agent.getTasks(),
|
|
1331
|
+
isCompacting: entry.agent.isCompacting,
|
|
1332
|
+
isCommandRunning: entry.agent.isCommandRunning,
|
|
1333
|
+
queuedMessages: entry.agent.queuedMessages,
|
|
1334
|
+
// Background tasks join the family for the same reason the todo list is
|
|
1335
|
+
// here: the phone's `/tasks` dialog opens on whatever the machine holds, and
|
|
1336
|
+
// `backgroundTasksChange` only reports *changes* — a task that started
|
|
1337
|
+
// before the page attached would otherwise be invisible until it finished.
|
|
1338
|
+
backgroundTasks: toBackgroundTaskSummaries(
|
|
1339
|
+
entry.agent.getBackgroundTasks(),
|
|
1340
|
+
),
|
|
870
1341
|
};
|
|
871
1342
|
}
|
|
872
1343
|
|
|
@@ -878,76 +1349,108 @@ export class AgentBridge {
|
|
|
878
1349
|
const currentSessionId = entry.agent.sessionId;
|
|
879
1350
|
// Merge new config into stored config
|
|
880
1351
|
entry.storedConfig = { ...entry.storedConfig, ...params };
|
|
881
|
-
//
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
1352
|
+
// The entry is NOT removed while the agent is replaced: dropping it would
|
|
1353
|
+
// make a `wait` blocked on this session report "no longer exists" (exit 1)
|
|
1354
|
+
// for a session that is alive and merely reloading — a false error, the same
|
|
1355
|
+
// family as the false idle this whole change is about. Instead the entry is
|
|
1356
|
+
// flagged `transitioning` for the whole destroy + recreate, which
|
|
1357
|
+
// `listDaemonSessions` reports as `isLoading: true` (so no reader can see the
|
|
1358
|
+
// "listed + not busy" combination a waiter would settle on) while
|
|
1359
|
+
// state-changing RPCs refuse it (`requireSession`) instead of talking to the
|
|
1360
|
+
// outgoing, dying agent. Read-only RPCs keep working off that agent.
|
|
1361
|
+
entry.transitioning = true;
|
|
885
1362
|
const ctx: SessionContext = {};
|
|
886
|
-
const callbacks = this.createCallbacks(ctx);
|
|
887
|
-
const options: AgentOptions = {
|
|
888
|
-
callbacks,
|
|
889
|
-
workdir: entry.storedConfig.workdir,
|
|
890
|
-
restoreSessionId: currentSessionId,
|
|
891
|
-
apiKey: entry.storedConfig.apiKey,
|
|
892
|
-
baseURL: entry.storedConfig.baseURL,
|
|
893
|
-
defaultHeaders: entry.storedConfig.defaultHeaders,
|
|
894
|
-
model: entry.storedConfig.model,
|
|
895
|
-
fastModel: entry.storedConfig.fastModel,
|
|
896
|
-
language: entry.storedConfig.language,
|
|
897
|
-
autoMemoryEnabled: entry.storedConfig.autoMemoryEnabled,
|
|
898
|
-
autoMemoryFrequency: entry.storedConfig.autoMemoryFrequency,
|
|
899
|
-
permissionMode: entry.storedConfig.permissionMode,
|
|
900
|
-
tools: entry.storedConfig.tools,
|
|
901
|
-
allowedTools: entry.storedConfig.allowedTools,
|
|
902
|
-
disallowedTools: entry.storedConfig.disallowedTools,
|
|
903
|
-
plugins: entry.storedConfig.pluginDirs?.map((p) => ({
|
|
904
|
-
type: "local",
|
|
905
|
-
path: p,
|
|
906
|
-
})),
|
|
907
|
-
mcpServers: entry.storedConfig.mcpServers,
|
|
908
|
-
// Keep worktree context (permission safety) across recreation, but never
|
|
909
|
-
// re-fire WorktreeCreate — the hook ran at initial creation.
|
|
910
|
-
worktreeName: entry.storedConfig.worktreeName,
|
|
911
|
-
canUseTool: (context: ToolPermissionContext) =>
|
|
912
|
-
this.canUseTool(context, ctx),
|
|
913
|
-
};
|
|
914
|
-
|
|
915
|
-
let agent: Agent;
|
|
916
1363
|
try {
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1364
|
+
// Destroy and recreate within the same session slot. The destroy comes
|
|
1365
|
+
// first so the outgoing agent stops writing the transcript before the
|
|
1366
|
+
// replacement loads it.
|
|
1367
|
+
await entry.agent.destroy();
|
|
1368
|
+
|
|
1369
|
+
const callbacks = this.createCallbacks(ctx);
|
|
1370
|
+
const options: AgentOptions = {
|
|
1371
|
+
callbacks,
|
|
1372
|
+
workdir: entry.storedConfig.workdir,
|
|
1373
|
+
restoreSessionId: currentSessionId,
|
|
1374
|
+
apiKey: entry.storedConfig.apiKey,
|
|
1375
|
+
baseURL: entry.storedConfig.baseURL,
|
|
1376
|
+
defaultHeaders: entry.storedConfig.defaultHeaders,
|
|
1377
|
+
model: entry.storedConfig.model,
|
|
1378
|
+
fastModel: entry.storedConfig.fastModel,
|
|
1379
|
+
permissionMode: entry.storedConfig.permissionMode,
|
|
1380
|
+
tools: entry.storedConfig.tools,
|
|
1381
|
+
allowedTools: entry.storedConfig.allowedTools,
|
|
1382
|
+
disallowedTools: entry.storedConfig.disallowedTools,
|
|
1383
|
+
plugins: entry.storedConfig.pluginDirs?.map((p) => ({
|
|
1384
|
+
type: "local",
|
|
1385
|
+
path: p,
|
|
1386
|
+
})),
|
|
1387
|
+
mcpServers: entry.storedConfig.mcpServers,
|
|
1388
|
+
// Keep worktree context (permission safety) across recreation, but never
|
|
1389
|
+
// re-fire WorktreeCreate — the hook ran at initial creation.
|
|
1390
|
+
worktreeName: entry.storedConfig.worktreeName,
|
|
1391
|
+
// The declaration outlives the rebuild: an `updateConfig` replaces the
|
|
1392
|
+
// agent, not the host that asked for the session.
|
|
1393
|
+
entrypoint: normalizeEntrypoint(entry.storedConfig.entrypoint),
|
|
1394
|
+
canUseTool: (context: ToolPermissionContext) =>
|
|
1395
|
+
this.canUseTool(context, ctx),
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
let agent: Agent;
|
|
1399
|
+
try {
|
|
1400
|
+
agent = await Agent.create(options);
|
|
1401
|
+
} catch (createError) {
|
|
1402
|
+
// The old agent is already destroyed. If the session file is missing or
|
|
1403
|
+
// unrecoverable (fresh session, cleared chat, or a never-persisted empty
|
|
1404
|
+
// session), fail soft: recreate WITHOUT restoreSessionId so this session
|
|
1405
|
+
// slot keeps working. Without this the client's sessionId still points at
|
|
1406
|
+
// the destroyed entry and every later request fails with "Session not
|
|
1407
|
+
// found" until the window is reloaded. Non-session errors (bad
|
|
1408
|
+
// baseURL/apiKey/config) must surface unchanged.
|
|
1409
|
+
if (!options.restoreSessionId || !isSessionRecoveryError(createError)) {
|
|
1410
|
+
throw createError;
|
|
1411
|
+
}
|
|
1412
|
+
logger?.warn(
|
|
1413
|
+
`updateConfig: failed to restore session ${currentSessionId}, recreating as a fresh session:`,
|
|
1414
|
+
createError,
|
|
1415
|
+
);
|
|
1416
|
+
agent = await Agent.create({ ...options, restoreSessionId: undefined });
|
|
928
1417
|
}
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1418
|
+
ctx.agent = agent;
|
|
1419
|
+
ctx.registeredSessionId = agent.sessionId;
|
|
1420
|
+
entry.agent = agent;
|
|
1421
|
+
// A recovery-recreated session gets a NEW id: move the registry key with it.
|
|
1422
|
+
// (The entry object is reused, so the flag and the recorded creation workdir
|
|
1423
|
+
// survive either way.) Announced with the OLD id as the envelope — same
|
|
1424
|
+
// contract as `onSessionIdChange` — so a client blocked on this session can
|
|
1425
|
+
// rebind to the new id instead of concluding it disappeared.
|
|
1426
|
+
if (agent.sessionId !== currentSessionId) {
|
|
1427
|
+
this.emit(
|
|
1428
|
+
"sessionIdChange",
|
|
1429
|
+
{ sessionId: agent.sessionId },
|
|
1430
|
+
currentSessionId,
|
|
1431
|
+
);
|
|
1432
|
+
this.sessions.delete(currentSessionId);
|
|
1433
|
+
this.sessions.set(agent.sessionId, entry);
|
|
1434
|
+
}
|
|
1435
|
+
entry.transitioning = false;
|
|
1436
|
+
return { sessionId: agent.sessionId };
|
|
1437
|
+
} catch (teardownError) {
|
|
1438
|
+
// Nothing usable is left: the outgoing agent is torn down (fully or
|
|
1439
|
+
// partially) and no replacement was built. Leaving the entry listed would
|
|
1440
|
+
// be a "half-dead" session — reachable through the registry but with no
|
|
1441
|
+
// working agent — so it is dropped, exactly like a failing `destroy`: a
|
|
1442
|
+
// waiter then reports "no longer exists" (exit 1) instead of hanging or
|
|
1443
|
+
// settling. The error surfaces to the caller unchanged.
|
|
1444
|
+
this.sessions.delete(currentSessionId);
|
|
1445
|
+
throw teardownError;
|
|
934
1446
|
}
|
|
935
|
-
ctx.agent = agent;
|
|
936
|
-
ctx.registeredSessionId = agent.sessionId;
|
|
937
|
-
this.sessions.set(agent.sessionId, {
|
|
938
|
-
agent,
|
|
939
|
-
storedConfig: { ...entry.storedConfig },
|
|
940
|
-
createdWorkdir: entry.createdWorkdir,
|
|
941
|
-
});
|
|
942
|
-
|
|
943
|
-
return { sessionId: agent.sessionId };
|
|
944
1447
|
}
|
|
945
1448
|
|
|
946
1449
|
private getConfiguredModels(sessionId?: string): {
|
|
947
1450
|
models: string[];
|
|
948
1451
|
currentModel: string | undefined;
|
|
949
1452
|
} {
|
|
950
|
-
const entry = this.
|
|
1453
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
951
1454
|
return {
|
|
952
1455
|
models: entry.agent.getConfiguredModels(),
|
|
953
1456
|
currentModel: entry.agent.getModelConfig().model,
|
|
@@ -956,7 +1459,7 @@ export class AgentBridge {
|
|
|
956
1459
|
|
|
957
1460
|
private async setModel(model: string, sessionId?: string): Promise<null> {
|
|
958
1461
|
const entry = this.requireSession(sessionId);
|
|
959
|
-
entry.agent.setModel(model);
|
|
1462
|
+
await entry.agent.setModel(model);
|
|
960
1463
|
// Keep storedConfig in sync: updateConfig recreates the agent from
|
|
961
1464
|
// storedConfig, so without this a later config save would revert the
|
|
962
1465
|
// model chosen here.
|
|
@@ -964,6 +1467,47 @@ export class AgentBridge {
|
|
|
964
1467
|
return null;
|
|
965
1468
|
}
|
|
966
1469
|
|
|
1470
|
+
// ── User preferences ──────────────────────────────────────────
|
|
1471
|
+
|
|
1472
|
+
/**
|
|
1473
|
+
* 设置页保存路径:写用户级 `~/.wave/settings.json` 后,**显式重载本进程内
|
|
1474
|
+
* 全部会话**的实时配置。
|
|
1475
|
+
*
|
|
1476
|
+
* 不能只依赖文件监视:会话启动时该文件(乃至 `~/.wave` 目录)通常还不存在,
|
|
1477
|
+
* 监视要靠在父目录链存在的前提下补发事件;而这个文件正是被本次保存**创建**
|
|
1478
|
+
* 出来的最常见场景(全新安装的第一次保存,spec core/agent-config.md
|
|
1479
|
+
* 「设置实时重载」场景 7)。写完即重载使「保存 ⇒ 下一轮生效」与监视是否就绪
|
|
1480
|
+
* 无关;监视若也命中,重载是幂等的(并发重载按 `reloadInProgress` 去重)。
|
|
1481
|
+
*
|
|
1482
|
+
* 空载荷(无差异保存、只回读)不落盘,也不重载。
|
|
1483
|
+
*
|
|
1484
|
+
* 回包是**生效视图**(同 `getUserSettings`):设置页在保存后仍须看到「被组织
|
|
1485
|
+
* 配置覆盖」的键的生效值与来源,而不是回退成用户文件里的值。
|
|
1486
|
+
*/
|
|
1487
|
+
private async updateUserSettings(
|
|
1488
|
+
patch: UserPreferenceSettings,
|
|
1489
|
+
): Promise<UserPreferenceSettingsView> {
|
|
1490
|
+
await updateUserPreferenceSettings(patch);
|
|
1491
|
+
if (!Object.values(patch).every((value) => value === undefined)) {
|
|
1492
|
+
await Promise.all(
|
|
1493
|
+
[...this.sessions.values()].map((entry) =>
|
|
1494
|
+
entry.agent.reloadConfiguration(),
|
|
1495
|
+
),
|
|
1496
|
+
);
|
|
1497
|
+
}
|
|
1498
|
+
return this.userSettingsView();
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
/**
|
|
1502
|
+
* 用户偏好的**生效视图**:生效值 + 每键来源层(用户级文件 / Remote 组织下发 /
|
|
1503
|
+
* 机器环境变量 / 默认)。设置页据此把「由组织配置管理」的键显示为生效值 +
|
|
1504
|
+
* 置灰(spec core/agent-config.md 边界说明「用户偏好的层与来源」)。
|
|
1505
|
+
*/
|
|
1506
|
+
private userSettingsView(): UserPreferenceSettingsView {
|
|
1507
|
+
const { values, sources } = readUserPreferenceView();
|
|
1508
|
+
return { ...values, preferenceSources: sources };
|
|
1509
|
+
}
|
|
1510
|
+
|
|
967
1511
|
// ── Messages ──────────────────────────────────────────────────
|
|
968
1512
|
|
|
969
1513
|
private async sendMessage(
|
|
@@ -1104,7 +1648,7 @@ export class AgentBridge {
|
|
|
1104
1648
|
private async listRewindCheckpoints(sessionId?: string): Promise<{
|
|
1105
1649
|
checkpoints: Array<{ id: string; content: string }>;
|
|
1106
1650
|
}> {
|
|
1107
|
-
const entry = this.
|
|
1651
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1108
1652
|
const { messages } = await entry.agent.getFullMessageThread();
|
|
1109
1653
|
// 压缩 append-only 后同 id 消息在磁盘完整线程中重复出现(压缩前历史 +
|
|
1110
1654
|
// 压缩后 append 的重复)。按 id 去重并保留最后一次出现(与折叠后的
|
|
@@ -1147,22 +1691,71 @@ export class AgentBridge {
|
|
|
1147
1691
|
return null;
|
|
1148
1692
|
}
|
|
1149
1693
|
|
|
1150
|
-
|
|
1694
|
+
/**
|
|
1695
|
+
* Read a session's display messages.
|
|
1696
|
+
*
|
|
1697
|
+
* With no window this returns the whole list — that is the contract every
|
|
1698
|
+
* existing host relies on (VS Code / JetBrains / desktop call `getMessages`
|
|
1699
|
+
* with no parameters and must keep seeing the full thread, byte for byte).
|
|
1700
|
+
* The window exists for remote clients on a phone, which only ever want the
|
|
1701
|
+
* tail of a long conversation and must not be handed 13 MB on every cold
|
|
1702
|
+
* start:
|
|
1703
|
+
*
|
|
1704
|
+
* - `limit` — page size.
|
|
1705
|
+
* - `before` — the page that ends immediately before the given message id
|
|
1706
|
+
* (paging *backwards* into history).
|
|
1707
|
+
* - `since` — everything appended after the given message id (catching up
|
|
1708
|
+
* after a reconnect).
|
|
1709
|
+
*
|
|
1710
|
+
* `since` is a position, not a version number: message ids are stable, so a
|
|
1711
|
+
* caller can name its last known message without the list carrying a
|
|
1712
|
+
* sequence number. The one failure that matters is the anchor having been
|
|
1713
|
+
* truncated away (rewind / clear) — there is no seq/version in the store that
|
|
1714
|
+
* could detect it otherwise — and that comes back as `reset: true` so the
|
|
1715
|
+
* caller re-pulls the tail instead of guessing.
|
|
1716
|
+
*
|
|
1717
|
+
* Ids can repeat in the display list (a compacted message is re-added), so
|
|
1718
|
+
* anchors are resolved to their **last** occurrence: "after `since`" means
|
|
1719
|
+
* after the newest copy the caller could have seen.
|
|
1720
|
+
*/
|
|
1721
|
+
private getMessages(
|
|
1722
|
+
sessionId?: string,
|
|
1723
|
+
window?: { before?: string; since?: string; limit?: number },
|
|
1724
|
+
): {
|
|
1151
1725
|
messages: Message[];
|
|
1152
1726
|
contextUsagePercent?: number;
|
|
1727
|
+
/** Older messages exist before the returned page. */
|
|
1728
|
+
hasMore?: boolean;
|
|
1729
|
+
/** The `since` anchor is gone — rebuild from the tail. */
|
|
1730
|
+
reset?: boolean;
|
|
1731
|
+
/** Total display messages held for the session. */
|
|
1732
|
+
total?: number;
|
|
1153
1733
|
} {
|
|
1154
|
-
const entry = this.
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1734
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1735
|
+
const all = entry.agent.displayMessages;
|
|
1736
|
+
const contextUsagePercent = this.contextUsagePercentOf(entry.agent);
|
|
1737
|
+
|
|
1738
|
+
// The slicing is shared with the CLI front end (`RemoteControlCliHost`): the
|
|
1739
|
+
// phone's reconciliation rules belong to the wire contract, not to this
|
|
1740
|
+
// registry, and the two hosts must answer identically.
|
|
1741
|
+
let sliced: MessageWindowResult | undefined;
|
|
1742
|
+
try {
|
|
1743
|
+
sliced = sliceMessageWindow(all, window);
|
|
1744
|
+
} catch (err) {
|
|
1745
|
+
if (err instanceof InvalidMessageWindowError) {
|
|
1746
|
+
throw new RpcError(PROTOCOL_INVALID_PARAMS, err.message);
|
|
1747
|
+
}
|
|
1748
|
+
throw err;
|
|
1749
|
+
}
|
|
1750
|
+
if (!sliced) return { messages: all, contextUsagePercent };
|
|
1751
|
+
return { ...sliced, contextUsagePercent };
|
|
1159
1752
|
}
|
|
1160
1753
|
|
|
1161
1754
|
private async getFullMessageThread(sessionId?: string): Promise<{
|
|
1162
1755
|
messages: Message[];
|
|
1163
1756
|
sessionIds: string[];
|
|
1164
1757
|
}> {
|
|
1165
|
-
const entry = this.
|
|
1758
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1166
1759
|
return entry.agent.getFullMessageThread();
|
|
1167
1760
|
}
|
|
1168
1761
|
|
|
@@ -1181,7 +1774,7 @@ export class AgentBridge {
|
|
|
1181
1774
|
taskId: string,
|
|
1182
1775
|
sessionId?: string,
|
|
1183
1776
|
): { output: ReturnType<Agent["getBackgroundTaskOutput"]> } {
|
|
1184
|
-
const entry = this.
|
|
1777
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1185
1778
|
return { output: entry.agent.getBackgroundTaskOutput(taskId) };
|
|
1186
1779
|
}
|
|
1187
1780
|
|
|
@@ -1203,7 +1796,7 @@ export class AgentBridge {
|
|
|
1203
1796
|
private async getWorkflowRuns(
|
|
1204
1797
|
sessionId?: string,
|
|
1205
1798
|
): Promise<{ runs: SerializableWorkflowRun[] }> {
|
|
1206
|
-
const entry = this.
|
|
1799
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1207
1800
|
const runs = await entry.agent.getWorkflowRuns();
|
|
1208
1801
|
return {
|
|
1209
1802
|
runs: runs.map((r) => {
|
|
@@ -1235,7 +1828,7 @@ export class AgentBridge {
|
|
|
1235
1828
|
}
|
|
1236
1829
|
|
|
1237
1830
|
private getPermissionMode(sessionId?: string): { mode: PermissionMode } {
|
|
1238
|
-
const entry = this.
|
|
1831
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1239
1832
|
return { mode: entry.agent.getPermissionMode() };
|
|
1240
1833
|
}
|
|
1241
1834
|
|
|
@@ -1249,7 +1842,7 @@ export class AgentBridge {
|
|
|
1249
1842
|
path: string | null;
|
|
1250
1843
|
content: string | null;
|
|
1251
1844
|
}> {
|
|
1252
|
-
const entry = this.
|
|
1845
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1253
1846
|
const agent = entry.agent;
|
|
1254
1847
|
const planPath =
|
|
1255
1848
|
agent.getPlanFilePath() ?? (await agent.awaitPlanFilePath());
|
|
@@ -1268,7 +1861,7 @@ export class AgentBridge {
|
|
|
1268
1861
|
// ── MCP ───────────────────────────────────────────────────────
|
|
1269
1862
|
|
|
1270
1863
|
private getMcpServers(sessionId?: string): { servers: McpServerStatus[] } {
|
|
1271
|
-
const entry = this.
|
|
1864
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1272
1865
|
return { servers: entry.agent.getMcpServers() };
|
|
1273
1866
|
}
|
|
1274
1867
|
|
|
@@ -1304,7 +1897,7 @@ export class AgentBridge {
|
|
|
1304
1897
|
userPath: string | null;
|
|
1305
1898
|
projectPath: string | null;
|
|
1306
1899
|
} {
|
|
1307
|
-
const entry = this.
|
|
1900
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1308
1901
|
return {
|
|
1309
1902
|
userPath: entry.agent.getUserMcpConfigPath(),
|
|
1310
1903
|
projectPath: entry.agent.getProjectMcpConfigPath(),
|
|
@@ -1314,19 +1907,19 @@ export class AgentBridge {
|
|
|
1314
1907
|
// ── Commands ──────────────────────────────────────────────────
|
|
1315
1908
|
|
|
1316
1909
|
private getSlashCommands(sessionId?: string): { commands: SlashCommand[] } {
|
|
1317
|
-
const entry = this.
|
|
1910
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1318
1911
|
return { commands: entry.agent.getSlashCommands() };
|
|
1319
1912
|
}
|
|
1320
1913
|
|
|
1321
1914
|
private getSubagentConfigurations(sessionId?: string): {
|
|
1322
1915
|
configurations: SubagentConfiguration[];
|
|
1323
1916
|
} {
|
|
1324
|
-
const entry = this.
|
|
1917
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1325
1918
|
return { configurations: entry.agent.getSubagentConfigurations() };
|
|
1326
1919
|
}
|
|
1327
1920
|
|
|
1328
1921
|
private getSkillMetadata(sessionId?: string): { skills: SkillMetadata[] } {
|
|
1329
|
-
const entry = this.
|
|
1922
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1330
1923
|
return { skills: entry.agent.getSkillMetadata() };
|
|
1331
1924
|
}
|
|
1332
1925
|
|
|
@@ -1355,7 +1948,7 @@ export class AgentBridge {
|
|
|
1355
1948
|
hooks: Partial<Record<string, unknown[]>>;
|
|
1356
1949
|
configPath: string | null;
|
|
1357
1950
|
}> {
|
|
1358
|
-
const entry = this.
|
|
1951
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
1359
1952
|
const hooks = await entry.agent.getHooksByScope(scope);
|
|
1360
1953
|
// 回带该 scope 钩子所在 settings.json 的绝对路径(宿主 GUI 打开文件用;
|
|
1361
1954
|
// plugin 钩子来自代码而非配置文件 → null)。
|
|
@@ -1462,7 +2055,7 @@ export class AgentBridge {
|
|
|
1462
2055
|
});
|
|
1463
2056
|
this.emit(
|
|
1464
2057
|
"permissionRequest",
|
|
1465
|
-
|
|
2058
|
+
remoteWire.permissionRequest(requestId, context),
|
|
1466
2059
|
ctx.registeredSessionId,
|
|
1467
2060
|
);
|
|
1468
2061
|
});
|
|
@@ -1492,27 +2085,156 @@ export class AgentBridge {
|
|
|
1492
2085
|
/** Daemon list: expose the in-memory session registry (live sessions only,
|
|
1493
2086
|
* not disk-scanning). Registration order is preserved. The working directory
|
|
1494
2087
|
* shown is the session's recorded creation workdir (stable identity), not the
|
|
1495
|
-
* live value that drifts with in-session `cd`.
|
|
2088
|
+
* live value that drifts with in-session `cd`.
|
|
2089
|
+
*
|
|
2090
|
+
* A session mid-rebuild (`transitioning`, see `SessionEntry`) reports
|
|
2091
|
+
* `isLoading: true`: its outgoing agent has already been aborted by the
|
|
2092
|
+
* teardown, so its own flag reads false while the replacement is still being
|
|
2093
|
+
* built — reporting that would hand a `wait` the "listed + not busy"
|
|
2094
|
+
* combination it settles on. "Not idle" is the honest reading of "this session
|
|
2095
|
+
* has no agent right now", and it can only make a waiter wait.
|
|
2096
|
+
*
|
|
2097
|
+
* `hasRunningBackgroundWork` / `hasPendingMessages` are the other two halves of
|
|
2098
|
+
* the idle predicate `wait` / `status` share with `wave -p` (print mode): a
|
|
2099
|
+
* session whose foreground turn has ended can still own a running background
|
|
2100
|
+
* bash task or subagent, and the last such task flips to `completed` before its
|
|
2101
|
+
* completion notification is enqueued for the main agent's follow-up turn —
|
|
2102
|
+
* reading only `isLoading` reports "done" in both cases. They are read from the
|
|
2103
|
+
* live agent here rather than tracked separately, and mirror it as-is: the
|
|
2104
|
+
* rebuild window above is already covered by `isLoading`, so there is no need
|
|
2105
|
+
* to also claim a `transitioning` session owns background work it doesn't.
|
|
2106
|
+
*
|
|
2107
|
+
* The three label fields are additive (an older CLI simply ignores them) and
|
|
2108
|
+
* exist for the remote-control page: it lists **this process's** sessions —
|
|
2109
|
+
* the only ones it can actually open — and cannot read the machine's disk, so
|
|
2110
|
+
* the title material and the ordering key have to come off the live agent
|
|
2111
|
+
* rather than from `listSessions`, which reads jsonl and would offer it
|
|
2112
|
+
* sessions that are not loaded here (spec remote-control「Web 页作为第五种宿主」
|
|
2113
|
+
* 场景 2)。 */
|
|
1496
2114
|
private listDaemonSessions(): {
|
|
1497
2115
|
sessions: Array<{
|
|
1498
2116
|
sessionId: string;
|
|
1499
2117
|
workingDirectory: string;
|
|
1500
2118
|
isLoading: boolean;
|
|
2119
|
+
hasRunningBackgroundWork: boolean;
|
|
2120
|
+
hasPendingMessages: boolean;
|
|
1501
2121
|
messageCount: number;
|
|
2122
|
+
/** User-set title; wins over `firstMessage` in the UI. */
|
|
2123
|
+
customTitle?: string;
|
|
2124
|
+
/** First real user message — the label when no custom title is set. */
|
|
2125
|
+
firstMessage?: string;
|
|
2126
|
+
/** Last display message's timestamp: the session list's ordering key. */
|
|
2127
|
+
lastActiveAt?: string;
|
|
2128
|
+
/**
|
|
2129
|
+
* The linked git worktree this session was created in, when it was.
|
|
2130
|
+
* Present only for worktree sessions — absent means "not a worktree, or
|
|
2131
|
+
* cannot tell", and the remote page reads it as "deleting this session
|
|
2132
|
+
* will not touch a worktree". Change counts are deliberately NOT here:
|
|
2133
|
+
* they cost git calls per row, and the list is rebuilt on every reconnect
|
|
2134
|
+
* / snapshot / page open. The page asks for them on demand instead, via
|
|
2135
|
+
* `getSessionWorktreeChanges`.
|
|
2136
|
+
*/
|
|
2137
|
+
worktree?: {
|
|
2138
|
+
path: string;
|
|
2139
|
+
branch: string;
|
|
2140
|
+
repoRoot: string;
|
|
2141
|
+
hookBased: boolean;
|
|
2142
|
+
};
|
|
1502
2143
|
}>;
|
|
1503
2144
|
} {
|
|
1504
2145
|
return {
|
|
1505
|
-
sessions: [...this.sessions.entries()].map(([sessionId, entry]) =>
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
2146
|
+
sessions: [...this.sessions.entries()].map(([sessionId, entry]) => {
|
|
2147
|
+
const messages = entry.agent.displayMessages;
|
|
2148
|
+
const customTitle = entry.agent.customTitle;
|
|
2149
|
+
const firstMessage = firstUserText(messages);
|
|
2150
|
+
const lastActiveAt = messages[messages.length - 1]?.timestamp;
|
|
2151
|
+
// Derived from the creation workdir (not the live one) and fs-only —
|
|
2152
|
+
// see `deriveWorktreeInfo` for why this must not shell out to git.
|
|
2153
|
+
const worktree = deriveWorktreeInfo(entry.createdWorkdir);
|
|
2154
|
+
return {
|
|
2155
|
+
sessionId,
|
|
2156
|
+
workingDirectory: entry.createdWorkdir,
|
|
2157
|
+
isLoading: entry.transitioning === true || entry.agent.isLoading,
|
|
2158
|
+
hasRunningBackgroundWork: entry.agent.hasRunningBackgroundWork,
|
|
2159
|
+
hasPendingMessages: entry.agent.hasPendingMessages,
|
|
2160
|
+
messageCount: messages.length,
|
|
2161
|
+
...(customTitle ? { customTitle } : {}),
|
|
2162
|
+
...(firstMessage ? { firstMessage } : {}),
|
|
2163
|
+
...(lastActiveAt ? { lastActiveAt } : {}),
|
|
2164
|
+
...(worktree ? { worktree } : {}),
|
|
2165
|
+
};
|
|
2166
|
+
}),
|
|
1511
2167
|
};
|
|
1512
2168
|
}
|
|
1513
2169
|
|
|
1514
|
-
|
|
2170
|
+
/**
|
|
2171
|
+
* Route the `remoteControl*` RPCs to the daemon's controller.
|
|
2172
|
+
*
|
|
2173
|
+
* `connect` / `disconnect` change the relay session; all three return the
|
|
2174
|
+
* same snapshot so a caller can render the result without a second round
|
|
2175
|
+
* trip (and a caller that only wanted to read gets `remoteControlStatus`).
|
|
2176
|
+
*
|
|
2177
|
+
* `connect` carries the optional label the user gave this machine
|
|
2178
|
+
* (`wave remote-control on <name>`), which the relay hands to the phone so
|
|
2179
|
+
* its computer list can tell two of the user's computers apart.
|
|
2180
|
+
*/
|
|
2181
|
+
private remoteControlRequest(
|
|
2182
|
+
method: string,
|
|
2183
|
+
params: Record<string, unknown>,
|
|
2184
|
+
): RemoteControlSnapshot {
|
|
2185
|
+
const controller = this.remoteControl?.();
|
|
2186
|
+
if (!controller) {
|
|
2187
|
+
throw new RpcError(
|
|
2188
|
+
PROTOCOL_INVALID_PARAMS,
|
|
2189
|
+
"Remote control is not enabled on this process (set enableRemoteControl: true).",
|
|
2190
|
+
);
|
|
2191
|
+
}
|
|
2192
|
+
if (method === "remoteControlConnect") {
|
|
2193
|
+
const name = typeof params.name === "string" ? params.name : undefined;
|
|
2194
|
+
controller.connect(name);
|
|
2195
|
+
} else if (method === "remoteControlDisconnect") {
|
|
2196
|
+
controller.disconnect();
|
|
2197
|
+
}
|
|
2198
|
+
return controller.getSnapshot();
|
|
2199
|
+
}
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Per-session status for the remote-control signal.
|
|
2203
|
+
*
|
|
2204
|
+
* The relay forwards bytes without looking inside them, so it can never
|
|
2205
|
+
* discover that somebody is waiting on an approval — and "push to the phone
|
|
2206
|
+
* when a session is stuck on the user" is the whole point of the feature.
|
|
2207
|
+
* The machine therefore reports one word per session, carrying no content:
|
|
2208
|
+
* ids and a status, nothing else.
|
|
2209
|
+
*
|
|
2210
|
+
* `running` includes background work: read from a phone there is no terminal
|
|
2211
|
+
* to glance at, so "something is still in flight" is the useful meaning of
|
|
2212
|
+
* busy rather than the narrower "a model turn is streaming".
|
|
2213
|
+
*/
|
|
2214
|
+
listSessionStatuses(): Array<{
|
|
2215
|
+
sessionId: string;
|
|
2216
|
+
status: "running" | "idle" | "requires_action";
|
|
2217
|
+
}> {
|
|
2218
|
+
const awaiting = new Set<string>();
|
|
2219
|
+
for (const entry of this.pendingPermissions.values()) {
|
|
2220
|
+
if (entry.sessionId) awaiting.add(entry.sessionId);
|
|
2221
|
+
}
|
|
2222
|
+
return [...this.sessions.entries()].map(([sessionId, entry]) => {
|
|
2223
|
+
let status: "running" | "idle" | "requires_action" = "idle";
|
|
2224
|
+
if (awaiting.has(sessionId)) {
|
|
2225
|
+
status = "requires_action";
|
|
2226
|
+
} else if (
|
|
2227
|
+
entry.transitioning === true ||
|
|
2228
|
+
entry.agent.isLoading ||
|
|
2229
|
+
entry.agent.hasRunningBackgroundWork
|
|
2230
|
+
) {
|
|
2231
|
+
status = "running";
|
|
2232
|
+
}
|
|
2233
|
+
return { sessionId, status };
|
|
2234
|
+
});
|
|
2235
|
+
}
|
|
1515
2236
|
|
|
2237
|
+
// ── Auth (global) ────────────────────────────────────────────
|
|
1516
2238
|
private async getAuthStatus(): Promise<{
|
|
1517
2239
|
isAuthenticated: boolean;
|
|
1518
2240
|
user: { id: string; email?: string } | undefined;
|
|
@@ -1528,7 +2250,12 @@ export class AgentBridge {
|
|
|
1528
2250
|
}
|
|
1529
2251
|
|
|
1530
2252
|
private async getAccountInfo(): Promise<{
|
|
1531
|
-
|
|
2253
|
+
/**
|
|
2254
|
+
* 计费结论(套餐两根条 + 到期日 + 降级原因),形状见 codechat `GET /api/v1/account` → `billing`
|
|
2255
|
+
* (判定出自与 proxy 扣费同一份代码)。本层只转发、不解析;具体形状由 wave-webview-fixtures
|
|
2256
|
+
* 的 AccountBillingInfo 在宿主/卡片侧约束。
|
|
2257
|
+
*/
|
|
2258
|
+
billing?: unknown;
|
|
1532
2259
|
apiQuota?: { limit: number | null; used: number } | null;
|
|
1533
2260
|
}> {
|
|
1534
2261
|
const authService = AuthService.getInstance();
|
|
@@ -1547,7 +2274,7 @@ export class AgentBridge {
|
|
|
1547
2274
|
throw new Error(`Account query failed (${response.status})`);
|
|
1548
2275
|
}
|
|
1549
2276
|
return (await response.json()) as {
|
|
1550
|
-
|
|
2277
|
+
billing?: unknown;
|
|
1551
2278
|
apiQuota?: { limit: number | null; used: number } | null;
|
|
1552
2279
|
};
|
|
1553
2280
|
}
|
|
@@ -1678,6 +2405,7 @@ export class AgentBridge {
|
|
|
1678
2405
|
marketplace: p.marketplace,
|
|
1679
2406
|
installed: p.installed,
|
|
1680
2407
|
version: p.version,
|
|
2408
|
+
latestVersion: p.latestVersion,
|
|
1681
2409
|
enabled: mergedEnabled[pluginId] !== false,
|
|
1682
2410
|
scope: p.scope,
|
|
1683
2411
|
};
|
|
@@ -1699,10 +2427,14 @@ export class AgentBridge {
|
|
|
1699
2427
|
|
|
1700
2428
|
private async uninstallPlugin(
|
|
1701
2429
|
pluginId: string,
|
|
2430
|
+
scope?: Scope,
|
|
1702
2431
|
workdir?: string,
|
|
1703
2432
|
sessionId?: string,
|
|
1704
2433
|
) {
|
|
1705
|
-
await this.getPluginCore(workdir, sessionId).uninstallPlugin(
|
|
2434
|
+
await this.getPluginCore(workdir, sessionId).uninstallPlugin(
|
|
2435
|
+
pluginId,
|
|
2436
|
+
scope,
|
|
2437
|
+
);
|
|
1706
2438
|
return null;
|
|
1707
2439
|
}
|
|
1708
2440
|
|
|
@@ -1727,6 +2459,21 @@ export class AgentBridge {
|
|
|
1727
2459
|
);
|
|
1728
2460
|
}
|
|
1729
2461
|
|
|
2462
|
+
/**
|
|
2463
|
+
* `/reload-plugins`:把磁盘上的插件状态就地换装进**本进程内全部活跃会话**
|
|
2464
|
+
* (spec ecosystem/plugin.md「插件变更的就地重载」)。不重建会话、sessionId
|
|
2465
|
+
* 不变;没有活跃会话时也返回成功——新会话创建时会按磁盘状态自然装载。
|
|
2466
|
+
*
|
|
2467
|
+
* 每个会话读的是同一份磁盘状态,因此取任一份结果汇报即可(各会话的差异只体现
|
|
2468
|
+
* 在各自注册表的可见性上)。失败不回滚,原样回传给宿主提示。
|
|
2469
|
+
*/
|
|
2470
|
+
private async reloadPlugins(): Promise<PluginReloadResult> {
|
|
2471
|
+
const results = await Promise.all(
|
|
2472
|
+
[...this.sessions.values()].map((entry) => entry.agent.reloadPlugins()),
|
|
2473
|
+
);
|
|
2474
|
+
return results[0] ?? { plugins: [], failures: [] };
|
|
2475
|
+
}
|
|
2476
|
+
|
|
1730
2477
|
private async getProjectSettings(
|
|
1731
2478
|
workdir?: string,
|
|
1732
2479
|
sessionId?: string,
|
|
@@ -1839,10 +2586,36 @@ export class AgentBridge {
|
|
|
1839
2586
|
return this.getPluginCore(workdir, sessionId).updatePlugin(pluginId);
|
|
1840
2587
|
}
|
|
1841
2588
|
|
|
2589
|
+
/**
|
|
2590
|
+
* 更换已安装插件的安装作用域(设置页插件市场):清掉各作用域的启用记录后
|
|
2591
|
+
* 在目标作用域启用,因此旧作用域不再保留该插件。
|
|
2592
|
+
*/
|
|
2593
|
+
private async setPluginScope(
|
|
2594
|
+
pluginId: string,
|
|
2595
|
+
scope: Scope,
|
|
2596
|
+
workdir?: string,
|
|
2597
|
+
sessionId?: string,
|
|
2598
|
+
) {
|
|
2599
|
+
return this.getPluginCore(workdir, sessionId).setPluginScope(
|
|
2600
|
+
pluginId,
|
|
2601
|
+
scope,
|
|
2602
|
+
);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
1842
2605
|
private async listMarketplaces(workdir?: string, sessionId?: string) {
|
|
1843
2606
|
return this.getPluginCore(workdir, sessionId).listMarketplaces();
|
|
1844
2607
|
}
|
|
1845
2608
|
|
|
2609
|
+
/**
|
|
2610
|
+
* 刷新各已注册市场的检出(不装插件),供「打开插件市场界面」时后台调用
|
|
2611
|
+
* (spec 插件市场 A-013 场景 5)。市场级错误在 SDK 内静默记录,宿主侧失败
|
|
2612
|
+
* 也不打断界面(场景 9)。
|
|
2613
|
+
*/
|
|
2614
|
+
private async refreshMarketplaces(workdir?: string, sessionId?: string) {
|
|
2615
|
+
await this.getPluginCore(workdir, sessionId).refreshMarketplaces();
|
|
2616
|
+
return null;
|
|
2617
|
+
}
|
|
2618
|
+
|
|
1846
2619
|
private async addMarketplace(
|
|
1847
2620
|
input: string,
|
|
1848
2621
|
scope?: Scope,
|
|
@@ -1867,8 +2640,16 @@ export class AgentBridge {
|
|
|
1867
2640
|
workdir?: string,
|
|
1868
2641
|
sessionId?: string,
|
|
1869
2642
|
) {
|
|
1870
|
-
|
|
1871
|
-
|
|
2643
|
+
// 这个通道只服务 GUI 的「批量更新插件」按钮:按当前清单升级、不拉检出
|
|
2644
|
+
// (清单刷新由打开视图时的 refreshMarketplaces 承担,spec 插件市场 A-013/
|
|
2645
|
+
// 场景 14)。
|
|
2646
|
+
const updated = await this.getPluginCore(
|
|
2647
|
+
workdir,
|
|
2648
|
+
sessionId,
|
|
2649
|
+
).updateMarketplacePlugins(name);
|
|
2650
|
+
// 实际升级的插件数回给宿主:0 = 该市场已是最新(宿主据此提示,
|
|
2651
|
+
// spec 插件市场场景 13)。
|
|
2652
|
+
return { updated };
|
|
1872
2653
|
}
|
|
1873
2654
|
|
|
1874
2655
|
// ── Callbacks → Notifications ─────────────────────────────────
|
|
@@ -1893,26 +2674,29 @@ export class AgentBridge {
|
|
|
1893
2674
|
ctx.registeredSessionId,
|
|
1894
2675
|
);
|
|
1895
2676
|
},
|
|
2677
|
+
onAssistantMessageDiscarded: (messageId: string) => {
|
|
2678
|
+
// A streamed attempt died mid-body and was re-issued without streaming,
|
|
2679
|
+
// so its half message is void: tell the host to drop it instead of
|
|
2680
|
+
// leaving a partial answer above the real one (spec: 流式响应正文中途
|
|
2681
|
+
// 断连时降级为非流式重发).
|
|
2682
|
+
this.emit(
|
|
2683
|
+
"assistantMessageDiscarded",
|
|
2684
|
+
{ messageId },
|
|
2685
|
+
ctx.registeredSessionId,
|
|
2686
|
+
);
|
|
2687
|
+
},
|
|
1896
2688
|
onAssistantContentUpdated: (params) => {
|
|
1897
2689
|
// Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
|
|
1898
2690
|
this.emit(
|
|
1899
2691
|
"assistantContentUpdated",
|
|
1900
|
-
|
|
1901
|
-
messageId: params.messageId,
|
|
1902
|
-
chunk: params.chunk,
|
|
1903
|
-
stage: params.stage,
|
|
1904
|
-
},
|
|
2692
|
+
remoteWire.assistantContentUpdated(params),
|
|
1905
2693
|
ctx.registeredSessionId,
|
|
1906
2694
|
);
|
|
1907
2695
|
},
|
|
1908
2696
|
onAssistantReasoningUpdated: (params) => {
|
|
1909
2697
|
this.emit(
|
|
1910
2698
|
"assistantReasoningUpdated",
|
|
1911
|
-
|
|
1912
|
-
messageId: params.messageId,
|
|
1913
|
-
chunk: params.chunk,
|
|
1914
|
-
stage: params.stage,
|
|
1915
|
-
},
|
|
2699
|
+
remoteWire.assistantReasoningUpdated(params),
|
|
1916
2700
|
ctx.registeredSessionId,
|
|
1917
2701
|
);
|
|
1918
2702
|
},
|
|
@@ -1920,21 +2704,23 @@ export class AgentBridge {
|
|
|
1920
2704
|
// Streaming stages carry only the parametersChunk delta; start/running
|
|
1921
2705
|
// (one-time snapshots) and end (authoritative full value) keep
|
|
1922
2706
|
// `parameters` (spec: 流式通知纯增量负载).
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
2707
|
+
this.emit(
|
|
2708
|
+
"toolBlockUpdated",
|
|
2709
|
+
remoteWire.toolBlockUpdated(params),
|
|
2710
|
+
ctx.registeredSessionId,
|
|
2711
|
+
);
|
|
1927
2712
|
},
|
|
1928
2713
|
onErrorBlockAdded: (error: string) => {
|
|
1929
|
-
this.emit(
|
|
2714
|
+
this.emit(
|
|
2715
|
+
"errorBlockAdded",
|
|
2716
|
+
remoteWire.errorBlockAdded(error),
|
|
2717
|
+
ctx.registeredSessionId,
|
|
2718
|
+
);
|
|
1930
2719
|
},
|
|
1931
2720
|
onLoadingChange: (loading: boolean) => {
|
|
1932
2721
|
this.emit(
|
|
1933
2722
|
"loadingChange",
|
|
1934
|
-
|
|
1935
|
-
loading,
|
|
1936
|
-
latestTotalTokens: ctx.agent?.latestTotalTokens,
|
|
1937
|
-
},
|
|
2723
|
+
remoteWire.loadingChange(loading, ctx.agent?.latestTotalTokens),
|
|
1938
2724
|
ctx.registeredSessionId,
|
|
1939
2725
|
);
|
|
1940
2726
|
},
|
|
@@ -1946,8 +2732,11 @@ export class AgentBridge {
|
|
|
1946
2732
|
// aligned with the CLI, which reads the same callback (spec 场景 5/6).
|
|
1947
2733
|
const max = ctx.agent?.getMaxInputTokens() ?? 0;
|
|
1948
2734
|
if (max > 0) {
|
|
1949
|
-
|
|
1950
|
-
|
|
2735
|
+
this.emit(
|
|
2736
|
+
"contextUsage",
|
|
2737
|
+
remoteWire.contextUsage(tokens, max),
|
|
2738
|
+
ctx.registeredSessionId,
|
|
2739
|
+
);
|
|
1951
2740
|
}
|
|
1952
2741
|
},
|
|
1953
2742
|
onCommandRunningChange: (running: boolean) => {
|
|
@@ -1964,21 +2753,9 @@ export class AgentBridge {
|
|
|
1964
2753
|
this.emit("tasksChange", { tasks }, ctx.registeredSessionId);
|
|
1965
2754
|
},
|
|
1966
2755
|
onBackgroundTasksChange: (tasks: BackgroundTask[]) => {
|
|
1967
|
-
const summaries: BackgroundTaskSummary[] = tasks.map((t) => ({
|
|
1968
|
-
id: t.id,
|
|
1969
|
-
type: t.type,
|
|
1970
|
-
status: t.status,
|
|
1971
|
-
startTime: t.startTime,
|
|
1972
|
-
endTime: t.endTime,
|
|
1973
|
-
command: t.command,
|
|
1974
|
-
description: t.description,
|
|
1975
|
-
exitCode: t.exitCode,
|
|
1976
|
-
runtime: t.runtime,
|
|
1977
|
-
outputPath: t.outputPath,
|
|
1978
|
-
}));
|
|
1979
2756
|
this.emit(
|
|
1980
2757
|
"backgroundTasksChange",
|
|
1981
|
-
{ tasks:
|
|
2758
|
+
{ tasks: toBackgroundTaskSummaries(tasks) },
|
|
1982
2759
|
ctx.registeredSessionId,
|
|
1983
2760
|
);
|
|
1984
2761
|
},
|
|
@@ -1997,7 +2774,11 @@ export class AgentBridge {
|
|
|
1997
2774
|
ctx.registeredSessionId = newSessionId;
|
|
1998
2775
|
},
|
|
1999
2776
|
onPermissionModeChange: (mode: PermissionMode) => {
|
|
2000
|
-
this.emit(
|
|
2777
|
+
this.emit(
|
|
2778
|
+
"permissionModeChange",
|
|
2779
|
+
remoteWire.permissionModeChange(mode),
|
|
2780
|
+
ctx.registeredSessionId,
|
|
2781
|
+
);
|
|
2001
2782
|
},
|
|
2002
2783
|
onWorkdirChange: (newCwd: string) => {
|
|
2003
2784
|
this.emit(
|
|
@@ -2010,7 +2791,11 @@ export class AgentBridge {
|
|
|
2010
2791
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
2011
2792
|
},
|
|
2012
2793
|
onNotificationMessageAdded: (params) => {
|
|
2013
|
-
|
|
2794
|
+
// The LAST matching message, not the first: one task can notify more
|
|
2795
|
+
// than once under the same task id (a workflow warns about its size
|
|
2796
|
+
// mid-run, then notifies again when it finishes), and this callback
|
|
2797
|
+
// fires for the message that was just appended — the newest one.
|
|
2798
|
+
const matches = (ctx.agent?.displayMessages ?? []).filter(
|
|
2014
2799
|
(m) =>
|
|
2015
2800
|
m.role === "user" &&
|
|
2016
2801
|
m.blocks.some(
|
|
@@ -2019,14 +2804,19 @@ export class AgentBridge {
|
|
|
2019
2804
|
(b as { taskId: string }).taskId === params.taskId,
|
|
2020
2805
|
),
|
|
2021
2806
|
);
|
|
2807
|
+
const msg = matches[matches.length - 1];
|
|
2022
2808
|
this.emit(
|
|
2023
2809
|
"notificationMessageAdded",
|
|
2024
2810
|
{ ...params, message: msg },
|
|
2025
2811
|
ctx.registeredSessionId,
|
|
2026
2812
|
);
|
|
2027
2813
|
},
|
|
2028
|
-
onCompactBlockAdded: (content: string) => {
|
|
2029
|
-
this.emit(
|
|
2814
|
+
onCompactBlockAdded: (content: string, message?: Message) => {
|
|
2815
|
+
this.emit(
|
|
2816
|
+
"compactBlockAdded",
|
|
2817
|
+
{ content, message },
|
|
2818
|
+
ctx.registeredSessionId,
|
|
2819
|
+
);
|
|
2030
2820
|
},
|
|
2031
2821
|
onCompactionStateChange: (isCompacting: boolean) => {
|
|
2032
2822
|
this.emit(
|
|
@@ -2042,6 +2832,9 @@ export class AgentBridge {
|
|
|
2042
2832
|
ctx.registeredSessionId,
|
|
2043
2833
|
);
|
|
2044
2834
|
},
|
|
2835
|
+
onPlanFileUpdated: (content: string) => {
|
|
2836
|
+
this.emit("planFileUpdated", { content }, ctx.registeredSessionId);
|
|
2837
|
+
},
|
|
2045
2838
|
};
|
|
2046
2839
|
}
|
|
2047
2840
|
|
|
@@ -2052,7 +2845,34 @@ export class AgentBridge {
|
|
|
2052
2845
|
|
|
2053
2846
|
// ── Utils ─────────────────────────────────────────────────────
|
|
2054
2847
|
|
|
2848
|
+
/**
|
|
2849
|
+
* The session entry for a request that CHANGES session state. A session whose
|
|
2850
|
+
* agent is mid-rebuild (`updateConfig` → `SessionEntry.transitioning`) has no
|
|
2851
|
+
* usable agent: its outgoing one is being torn down, so a message injected into
|
|
2852
|
+
* it would be lost and a config write would be overwritten by the rebuild's own
|
|
2853
|
+
* stored config. Those requests are refused with a retryable error instead.
|
|
2854
|
+
* Read-only requests use `requireSessionForRead` and stay available.
|
|
2855
|
+
*/
|
|
2055
2856
|
private requireSession(sessionId?: string): SessionEntry {
|
|
2857
|
+
const entry = this.requireSessionForRead(sessionId);
|
|
2858
|
+
if (entry.transitioning) {
|
|
2859
|
+
throw new RpcError(
|
|
2860
|
+
PROTOCOL_INTERNAL_ERROR,
|
|
2861
|
+
`Session ${sessionId} is being reconfigured (config reload in progress), retry shortly`,
|
|
2862
|
+
);
|
|
2863
|
+
}
|
|
2864
|
+
return entry;
|
|
2865
|
+
}
|
|
2866
|
+
|
|
2867
|
+
/**
|
|
2868
|
+
* The session entry for a request that only READS. During a rebuild the entry
|
|
2869
|
+
* holds the outgoing agent: its messages are this same session's transcript and
|
|
2870
|
+
* its config is the pre-reload one, both of which are the best answer available
|
|
2871
|
+
* for the few hundred milliseconds the rebuild takes. Refusing reads instead
|
|
2872
|
+
* would turn a config reload into a burst of errors in whatever host happens to
|
|
2873
|
+
* be polling (`getMcpServers`, `getSlashCommands`, …).
|
|
2874
|
+
*/
|
|
2875
|
+
private requireSessionForRead(sessionId?: string): SessionEntry {
|
|
2056
2876
|
if (!sessionId) {
|
|
2057
2877
|
throw new RpcError(
|
|
2058
2878
|
PROTOCOL_INTERNAL_ERROR,
|
|
@@ -2103,6 +2923,22 @@ function isSessionRecoveryError(error: unknown): boolean {
|
|
|
2103
2923
|
);
|
|
2104
2924
|
}
|
|
2105
2925
|
|
|
2926
|
+
/**
|
|
2927
|
+
* The label a session list shows when no custom title is set: the first real
|
|
2928
|
+
* user message. Same rule as the webview's `firstUserMessageText` — meta messages
|
|
2929
|
+
* (task notifications and other injected turns) are skipped, because they are not
|
|
2930
|
+
* what the user typed — so the phone labels a conversation the way the IDE hosts
|
|
2931
|
+
* do.
|
|
2932
|
+
*/
|
|
2933
|
+
function firstUserText(messages: Message[]): string | undefined {
|
|
2934
|
+
for (const message of messages) {
|
|
2935
|
+
if (message.role !== "user" || message.isMeta) continue;
|
|
2936
|
+
const text = getMessageContent(message).trim();
|
|
2937
|
+
if (text) return text;
|
|
2938
|
+
}
|
|
2939
|
+
return undefined;
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2106
2942
|
/** Read a text file, returning "" when it does not exist. */
|
|
2107
2943
|
async function readTextFileSafe(filePath: string): Promise<string> {
|
|
2108
2944
|
try {
|