wave-code 1.3.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 +1275 -895
- package/package.json +4 -2
- package/src/commands/update.ts +62 -22
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/ConfirmationDetails.tsx +17 -3
- package/src/components/InputBox.tsx +2 -0
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/WorkflowManager.tsx +38 -138
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +359 -127
- package/src/daemon/commands.ts +13 -4
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +4 -0
- package/src/hooks/useRemoteControl.ts +324 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +2 -0
- package/src/managers/inputReducer.ts +1 -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 +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/stdio/agentBridge.ts +608 -74
- package/src/stdio/daemonServer.ts +30 -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-cli.ts +11 -2
- 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/stdio/agentBridge.ts
CHANGED
|
@@ -58,28 +58,54 @@ import {
|
|
|
58
58
|
type SubagentConfiguration,
|
|
59
59
|
type SkillMetadata,
|
|
60
60
|
type PluginReloadResult,
|
|
61
|
+
type RemoteControlSnapshot,
|
|
62
|
+
normalizeEntrypoint,
|
|
61
63
|
} from "wave-agent-sdk";
|
|
62
64
|
import {
|
|
65
|
+
type ClientNotificationMethod,
|
|
63
66
|
type JsonRpcError,
|
|
67
|
+
type RequestMethod,
|
|
68
|
+
type ServerNotificationMethod,
|
|
64
69
|
INVALID_PARAMS as PROTOCOL_INVALID_PARAMS,
|
|
65
70
|
INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR,
|
|
66
71
|
METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND,
|
|
67
72
|
} from "./protocol.js";
|
|
68
73
|
import { execFileSync } from "node:child_process";
|
|
69
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
mkdirSync,
|
|
76
|
+
existsSync,
|
|
77
|
+
readFileSync,
|
|
78
|
+
writeFileSync,
|
|
79
|
+
readdirSync,
|
|
80
|
+
realpathSync,
|
|
81
|
+
} from "node:fs";
|
|
70
82
|
import { mkdir, open, readFile, writeFile } from "node:fs/promises";
|
|
71
83
|
import { homedir, tmpdir } from "node:os";
|
|
72
84
|
import { basename, dirname, extname, join } from "node:path";
|
|
73
85
|
import {
|
|
74
86
|
createWorktree,
|
|
87
|
+
deriveWorktreeInfo,
|
|
75
88
|
getWorktreeChanges,
|
|
76
89
|
removeWorktree,
|
|
77
90
|
} from "../utils/worktree.js";
|
|
78
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";
|
|
79
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";
|
|
80
106
|
|
|
81
107
|
export type NotificationEmitter = (
|
|
82
|
-
method:
|
|
108
|
+
method: ServerNotificationMethod,
|
|
83
109
|
params: unknown,
|
|
84
110
|
sessionId?: string,
|
|
85
111
|
) => void;
|
|
@@ -91,6 +117,17 @@ export interface AgentBridgeOptions {
|
|
|
91
117
|
* down its socket and exits. stdio mode leaves it unset, so `shutdown` is
|
|
92
118
|
* "Method not found" there. */
|
|
93
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;
|
|
94
131
|
}
|
|
95
132
|
|
|
96
133
|
interface InitializeParams {
|
|
@@ -110,6 +147,13 @@ interface InitializeParams {
|
|
|
110
147
|
mcpServers?: Record<string, McpServerConfig>;
|
|
111
148
|
worktreeName?: string;
|
|
112
149
|
isNewWorktree?: boolean;
|
|
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;
|
|
113
157
|
}
|
|
114
158
|
|
|
115
159
|
// 会话级覆盖项:用户偏好(语言 / 上下文长度 / 自动记忆开关与频率)不在此层——
|
|
@@ -189,14 +233,26 @@ export class AgentBridge {
|
|
|
189
233
|
}
|
|
190
234
|
>();
|
|
191
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>();
|
|
192
244
|
private emit: NotificationEmitter;
|
|
193
245
|
private onShutdownRequest: (() => void) | undefined;
|
|
246
|
+
private remoteControl:
|
|
247
|
+
| (() => RemoteControlController | undefined)
|
|
248
|
+
| undefined;
|
|
194
249
|
private pluginCore: PluginCore | undefined;
|
|
195
250
|
private pluginCoreWorkdir: string | undefined;
|
|
196
251
|
|
|
197
252
|
constructor(options: AgentBridgeOptions) {
|
|
198
253
|
this.emit = options.emit;
|
|
199
254
|
this.onShutdownRequest = options.onShutdownRequest;
|
|
255
|
+
this.remoteControl = options.remoteControl;
|
|
200
256
|
// Mirror the user-level settings env WAVE_SERVER_URL into process.env
|
|
201
257
|
// before any agent initializes. getAuthStatus (webviewReady →
|
|
202
258
|
// pushInitialState) can run before the first agent, and AuthService falls
|
|
@@ -211,7 +267,7 @@ export class AgentBridge {
|
|
|
211
267
|
// ── Public API ────────────────────────────────────────────────
|
|
212
268
|
|
|
213
269
|
async handleRequest(
|
|
214
|
-
method:
|
|
270
|
+
method: RequestMethod,
|
|
215
271
|
params: unknown,
|
|
216
272
|
sessionId?: string,
|
|
217
273
|
): Promise<unknown> {
|
|
@@ -221,7 +277,9 @@ export class AgentBridge {
|
|
|
221
277
|
case "initialize":
|
|
222
278
|
return this.initialize(p as unknown as InitializeParams);
|
|
223
279
|
case "destroy":
|
|
224
|
-
return this.destroy(sessionId
|
|
280
|
+
return this.destroy(sessionId, {
|
|
281
|
+
removeWorktree: p.removeWorktree === true,
|
|
282
|
+
});
|
|
225
283
|
case "restoreSession":
|
|
226
284
|
return this.restoreSession(p.sessionId as string, sessionId);
|
|
227
285
|
case "listSessions":
|
|
@@ -311,10 +369,21 @@ export class AgentBridge {
|
|
|
311
369
|
case "deleteQueuedMessageById":
|
|
312
370
|
return this.deleteQueuedMessageById(p.id as string, sessionId);
|
|
313
371
|
case "getMessages":
|
|
314
|
-
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
|
+
});
|
|
315
377
|
case "getFullMessageThread":
|
|
316
378
|
return this.getFullMessageThread(sessionId);
|
|
317
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
|
+
|
|
318
387
|
// ── Permissions ──
|
|
319
388
|
case "setPermissionMode":
|
|
320
389
|
return this.setPermissionMode(p.mode as PermissionMode, sessionId);
|
|
@@ -390,6 +459,8 @@ export class AgentBridge {
|
|
|
390
459
|
return this.login(p.serverUrl as string | undefined);
|
|
391
460
|
case "logout":
|
|
392
461
|
return this.logout();
|
|
462
|
+
case "installGitCredentialHelper":
|
|
463
|
+
return installGitCredentialHelper();
|
|
393
464
|
|
|
394
465
|
// ── Memory files (settings UI — user-level ~/.wave/AGENTS.md and
|
|
395
466
|
// project-level <workdir>/AGENTS.md) ──
|
|
@@ -527,7 +598,9 @@ export class AgentBridge {
|
|
|
527
598
|
case "stopWorkflowRun":
|
|
528
599
|
return this.stopWorkflowRun(p.runId as string, sessionId);
|
|
529
600
|
|
|
530
|
-
// ── 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);
|
|
531
604
|
case "listGitBranches":
|
|
532
605
|
return this.listGitBranches(p.workdir as string | undefined);
|
|
533
606
|
case "createWorktree":
|
|
@@ -542,6 +615,8 @@ export class AgentBridge {
|
|
|
542
615
|
return this.getWorktreeChanges(
|
|
543
616
|
p as unknown as { path?: string; baseBranch?: string },
|
|
544
617
|
);
|
|
618
|
+
case "getSessionWorktreeChanges":
|
|
619
|
+
return this.getSessionWorktreeChanges(sessionId);
|
|
545
620
|
case "removeWorktree":
|
|
546
621
|
return this.removeWorktreeSession(
|
|
547
622
|
p as unknown as {
|
|
@@ -551,16 +626,42 @@ export class AgentBridge {
|
|
|
551
626
|
hookBased?: boolean;
|
|
552
627
|
},
|
|
553
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
|
+
);
|
|
554
645
|
|
|
555
|
-
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;
|
|
556
656
|
throw new RpcError(
|
|
557
657
|
PROTOCOL_METHOD_NOT_FOUND,
|
|
558
658
|
`Method not found: ${method}`,
|
|
559
659
|
);
|
|
660
|
+
}
|
|
560
661
|
}
|
|
561
662
|
}
|
|
562
663
|
|
|
563
|
-
handleNotification(method:
|
|
664
|
+
handleNotification(method: ClientNotificationMethod, params: unknown): void {
|
|
564
665
|
if (method === "permissionResponse") {
|
|
565
666
|
const p = params as {
|
|
566
667
|
requestId: string;
|
|
@@ -601,6 +702,16 @@ export class AgentBridge {
|
|
|
601
702
|
const ctx: SessionContext = {};
|
|
602
703
|
const callbacks = this.createCallbacks(ctx);
|
|
603
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
|
+
|
|
604
715
|
const options: AgentOptions = {
|
|
605
716
|
callbacks,
|
|
606
717
|
logger,
|
|
@@ -619,6 +730,7 @@ export class AgentBridge {
|
|
|
619
730
|
mcpServers: params.mcpServers,
|
|
620
731
|
worktreeName: params.worktreeName,
|
|
621
732
|
isNewWorktree: params.isNewWorktree,
|
|
733
|
+
entrypoint,
|
|
622
734
|
canUseTool: (context: ToolPermissionContext) =>
|
|
623
735
|
this.canUseTool(context, ctx),
|
|
624
736
|
};
|
|
@@ -660,7 +772,42 @@ export class AgentBridge {
|
|
|
660
772
|
};
|
|
661
773
|
}
|
|
662
774
|
|
|
663
|
-
|
|
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;
|
|
664
811
|
if (sessionId) {
|
|
665
812
|
const entry = this.sessions.get(sessionId);
|
|
666
813
|
if (entry) {
|
|
@@ -687,10 +834,71 @@ export class AgentBridge {
|
|
|
687
834
|
// half-destroyed agent listed (destroyAll() below clears the map first
|
|
688
835
|
// for the same reason).
|
|
689
836
|
this.sessions.delete(sessionId);
|
|
837
|
+
createdWorkdir = entry.createdWorkdir;
|
|
690
838
|
await entry.agent.destroy();
|
|
691
839
|
}
|
|
692
840
|
}
|
|
693
|
-
|
|
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);
|
|
694
902
|
}
|
|
695
903
|
|
|
696
904
|
/**
|
|
@@ -713,13 +921,23 @@ export class AgentBridge {
|
|
|
713
921
|
): Promise<null> {
|
|
714
922
|
const entry = this.requireSessionForRead(sessionId);
|
|
715
923
|
// Re-attach to a live session: the SDK restore would no-op (target is
|
|
716
|
-
// already current).
|
|
717
|
-
//
|
|
718
|
-
//
|
|
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.
|
|
719
937
|
if (entry.agent.sessionId === restoreId) {
|
|
720
938
|
this.emit(
|
|
721
939
|
"messagesChange",
|
|
722
|
-
|
|
940
|
+
remoteWire.messagesChangeSignal(),
|
|
723
941
|
entry.agent.sessionId,
|
|
724
942
|
);
|
|
725
943
|
// The re-attached client also missed the loading state that settled
|
|
@@ -775,9 +993,14 @@ export class AgentBridge {
|
|
|
775
993
|
* usage to report; the host keeps the empty ring until the first push.
|
|
776
994
|
*/
|
|
777
995
|
private emitContextUsage(agent: Agent): void {
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
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
|
+
);
|
|
781
1004
|
}
|
|
782
1005
|
}
|
|
783
1006
|
|
|
@@ -792,7 +1015,7 @@ export class AgentBridge {
|
|
|
792
1015
|
const tokens = agent.latestTotalTokens;
|
|
793
1016
|
const max = agent.getMaxInputTokens();
|
|
794
1017
|
if (tokens > 0 && max > 0) {
|
|
795
|
-
return
|
|
1018
|
+
return percentOf(tokens, max);
|
|
796
1019
|
}
|
|
797
1020
|
return undefined;
|
|
798
1021
|
}
|
|
@@ -841,6 +1064,50 @@ export class AgentBridge {
|
|
|
841
1064
|
|
|
842
1065
|
// ── Git / worktree ────────────────────────────────────────────
|
|
843
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
|
+
|
|
844
1111
|
private listGitBranches(workdir?: string): {
|
|
845
1112
|
branches: string[];
|
|
846
1113
|
current: string | null;
|
|
@@ -887,6 +1154,66 @@ export class AgentBridge {
|
|
|
887
1154
|
return { branches, current };
|
|
888
1155
|
}
|
|
889
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
|
+
|
|
890
1217
|
private async createWorktreeSession(params: {
|
|
891
1218
|
workdir: string;
|
|
892
1219
|
baseBranch?: string;
|
|
@@ -972,13 +1299,18 @@ export class AgentBridge {
|
|
|
972
1299
|
return { ok: true };
|
|
973
1300
|
}
|
|
974
1301
|
|
|
975
|
-
private getSessionInfo(sessionId?: string): {
|
|
1302
|
+
private async getSessionInfo(sessionId?: string): Promise<{
|
|
976
1303
|
sessionId: string;
|
|
977
1304
|
workingDirectory: string;
|
|
978
1305
|
latestTotalTokens: number;
|
|
979
1306
|
permissionMode: PermissionMode;
|
|
980
1307
|
availableTools: string[];
|
|
981
|
-
|
|
1308
|
+
tasks: Task[];
|
|
1309
|
+
isCompacting: boolean;
|
|
1310
|
+
isCommandRunning: boolean;
|
|
1311
|
+
queuedMessages: QueuedMessage[];
|
|
1312
|
+
backgroundTasks: BackgroundTaskSummary[];
|
|
1313
|
+
}> {
|
|
982
1314
|
const entry = this.requireSessionForRead(sessionId);
|
|
983
1315
|
return {
|
|
984
1316
|
sessionId: entry.agent.sessionId,
|
|
@@ -989,6 +1321,23 @@ export class AgentBridge {
|
|
|
989
1321
|
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
990
1322
|
permissionMode: entry.agent.getPermissionMode(),
|
|
991
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
|
+
),
|
|
992
1341
|
};
|
|
993
1342
|
}
|
|
994
1343
|
|
|
@@ -1039,6 +1388,9 @@ export class AgentBridge {
|
|
|
1039
1388
|
// Keep worktree context (permission safety) across recreation, but never
|
|
1040
1389
|
// re-fire WorktreeCreate — the hook ran at initial creation.
|
|
1041
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),
|
|
1042
1394
|
canUseTool: (context: ToolPermissionContext) =>
|
|
1043
1395
|
this.canUseTool(context, ctx),
|
|
1044
1396
|
};
|
|
@@ -1339,15 +1691,64 @@ export class AgentBridge {
|
|
|
1339
1691
|
return null;
|
|
1340
1692
|
}
|
|
1341
1693
|
|
|
1342
|
-
|
|
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
|
+
): {
|
|
1343
1725
|
messages: Message[];
|
|
1344
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;
|
|
1345
1733
|
} {
|
|
1346
1734
|
const entry = this.requireSessionForRead(sessionId);
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
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 };
|
|
1351
1752
|
}
|
|
1352
1753
|
|
|
1353
1754
|
private async getFullMessageThread(sessionId?: string): Promise<{
|
|
@@ -1654,7 +2055,7 @@ export class AgentBridge {
|
|
|
1654
2055
|
});
|
|
1655
2056
|
this.emit(
|
|
1656
2057
|
"permissionRequest",
|
|
1657
|
-
|
|
2058
|
+
remoteWire.permissionRequest(requestId, context),
|
|
1658
2059
|
ctx.registeredSessionId,
|
|
1659
2060
|
);
|
|
1660
2061
|
});
|
|
@@ -1701,7 +2102,15 @@ export class AgentBridge {
|
|
|
1701
2102
|
* reading only `isLoading` reports "done" in both cases. They are read from the
|
|
1702
2103
|
* live agent here rather than tracked separately, and mirror it as-is: the
|
|
1703
2104
|
* 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.
|
|
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)。 */
|
|
1705
2114
|
private listDaemonSessions(): {
|
|
1706
2115
|
sessions: Array<{
|
|
1707
2116
|
sessionId: string;
|
|
@@ -1710,22 +2119,122 @@ export class AgentBridge {
|
|
|
1710
2119
|
hasRunningBackgroundWork: boolean;
|
|
1711
2120
|
hasPendingMessages: boolean;
|
|
1712
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
|
+
};
|
|
1713
2143
|
}>;
|
|
1714
2144
|
} {
|
|
1715
2145
|
return {
|
|
1716
|
-
sessions: [...this.sessions.entries()].map(([sessionId, entry]) =>
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
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
|
+
}),
|
|
1724
2167
|
};
|
|
1725
2168
|
}
|
|
1726
2169
|
|
|
1727
|
-
|
|
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
|
+
}
|
|
1728
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
|
+
}
|
|
2236
|
+
|
|
2237
|
+
// ── Auth (global) ────────────────────────────────────────────
|
|
1729
2238
|
private async getAuthStatus(): Promise<{
|
|
1730
2239
|
isAuthenticated: boolean;
|
|
1731
2240
|
user: { id: string; email?: string } | undefined;
|
|
@@ -2165,26 +2674,29 @@ export class AgentBridge {
|
|
|
2165
2674
|
ctx.registeredSessionId,
|
|
2166
2675
|
);
|
|
2167
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
|
+
},
|
|
2168
2688
|
onAssistantContentUpdated: (params) => {
|
|
2169
2689
|
// Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
|
|
2170
2690
|
this.emit(
|
|
2171
2691
|
"assistantContentUpdated",
|
|
2172
|
-
|
|
2173
|
-
messageId: params.messageId,
|
|
2174
|
-
chunk: params.chunk,
|
|
2175
|
-
stage: params.stage,
|
|
2176
|
-
},
|
|
2692
|
+
remoteWire.assistantContentUpdated(params),
|
|
2177
2693
|
ctx.registeredSessionId,
|
|
2178
2694
|
);
|
|
2179
2695
|
},
|
|
2180
2696
|
onAssistantReasoningUpdated: (params) => {
|
|
2181
2697
|
this.emit(
|
|
2182
2698
|
"assistantReasoningUpdated",
|
|
2183
|
-
|
|
2184
|
-
messageId: params.messageId,
|
|
2185
|
-
chunk: params.chunk,
|
|
2186
|
-
stage: params.stage,
|
|
2187
|
-
},
|
|
2699
|
+
remoteWire.assistantReasoningUpdated(params),
|
|
2188
2700
|
ctx.registeredSessionId,
|
|
2189
2701
|
);
|
|
2190
2702
|
},
|
|
@@ -2192,21 +2704,23 @@ export class AgentBridge {
|
|
|
2192
2704
|
// Streaming stages carry only the parametersChunk delta; start/running
|
|
2193
2705
|
// (one-time snapshots) and end (authoritative full value) keep
|
|
2194
2706
|
// `parameters` (spec: 流式通知纯增量负载).
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2707
|
+
this.emit(
|
|
2708
|
+
"toolBlockUpdated",
|
|
2709
|
+
remoteWire.toolBlockUpdated(params),
|
|
2710
|
+
ctx.registeredSessionId,
|
|
2711
|
+
);
|
|
2199
2712
|
},
|
|
2200
2713
|
onErrorBlockAdded: (error: string) => {
|
|
2201
|
-
this.emit(
|
|
2714
|
+
this.emit(
|
|
2715
|
+
"errorBlockAdded",
|
|
2716
|
+
remoteWire.errorBlockAdded(error),
|
|
2717
|
+
ctx.registeredSessionId,
|
|
2718
|
+
);
|
|
2202
2719
|
},
|
|
2203
2720
|
onLoadingChange: (loading: boolean) => {
|
|
2204
2721
|
this.emit(
|
|
2205
2722
|
"loadingChange",
|
|
2206
|
-
|
|
2207
|
-
loading,
|
|
2208
|
-
latestTotalTokens: ctx.agent?.latestTotalTokens,
|
|
2209
|
-
},
|
|
2723
|
+
remoteWire.loadingChange(loading, ctx.agent?.latestTotalTokens),
|
|
2210
2724
|
ctx.registeredSessionId,
|
|
2211
2725
|
);
|
|
2212
2726
|
},
|
|
@@ -2218,8 +2732,11 @@ export class AgentBridge {
|
|
|
2218
2732
|
// aligned with the CLI, which reads the same callback (spec 场景 5/6).
|
|
2219
2733
|
const max = ctx.agent?.getMaxInputTokens() ?? 0;
|
|
2220
2734
|
if (max > 0) {
|
|
2221
|
-
|
|
2222
|
-
|
|
2735
|
+
this.emit(
|
|
2736
|
+
"contextUsage",
|
|
2737
|
+
remoteWire.contextUsage(tokens, max),
|
|
2738
|
+
ctx.registeredSessionId,
|
|
2739
|
+
);
|
|
2223
2740
|
}
|
|
2224
2741
|
},
|
|
2225
2742
|
onCommandRunningChange: (running: boolean) => {
|
|
@@ -2236,21 +2753,9 @@ export class AgentBridge {
|
|
|
2236
2753
|
this.emit("tasksChange", { tasks }, ctx.registeredSessionId);
|
|
2237
2754
|
},
|
|
2238
2755
|
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
2756
|
this.emit(
|
|
2252
2757
|
"backgroundTasksChange",
|
|
2253
|
-
{ tasks:
|
|
2758
|
+
{ tasks: toBackgroundTaskSummaries(tasks) },
|
|
2254
2759
|
ctx.registeredSessionId,
|
|
2255
2760
|
);
|
|
2256
2761
|
},
|
|
@@ -2269,7 +2774,11 @@ export class AgentBridge {
|
|
|
2269
2774
|
ctx.registeredSessionId = newSessionId;
|
|
2270
2775
|
},
|
|
2271
2776
|
onPermissionModeChange: (mode: PermissionMode) => {
|
|
2272
|
-
this.emit(
|
|
2777
|
+
this.emit(
|
|
2778
|
+
"permissionModeChange",
|
|
2779
|
+
remoteWire.permissionModeChange(mode),
|
|
2780
|
+
ctx.registeredSessionId,
|
|
2781
|
+
);
|
|
2273
2782
|
},
|
|
2274
2783
|
onWorkdirChange: (newCwd: string) => {
|
|
2275
2784
|
this.emit(
|
|
@@ -2282,7 +2791,11 @@ export class AgentBridge {
|
|
|
2282
2791
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
2283
2792
|
},
|
|
2284
2793
|
onNotificationMessageAdded: (params) => {
|
|
2285
|
-
|
|
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(
|
|
2286
2799
|
(m) =>
|
|
2287
2800
|
m.role === "user" &&
|
|
2288
2801
|
m.blocks.some(
|
|
@@ -2291,14 +2804,19 @@ export class AgentBridge {
|
|
|
2291
2804
|
(b as { taskId: string }).taskId === params.taskId,
|
|
2292
2805
|
),
|
|
2293
2806
|
);
|
|
2807
|
+
const msg = matches[matches.length - 1];
|
|
2294
2808
|
this.emit(
|
|
2295
2809
|
"notificationMessageAdded",
|
|
2296
2810
|
{ ...params, message: msg },
|
|
2297
2811
|
ctx.registeredSessionId,
|
|
2298
2812
|
);
|
|
2299
2813
|
},
|
|
2300
|
-
onCompactBlockAdded: (content: string) => {
|
|
2301
|
-
this.emit(
|
|
2814
|
+
onCompactBlockAdded: (content: string, message?: Message) => {
|
|
2815
|
+
this.emit(
|
|
2816
|
+
"compactBlockAdded",
|
|
2817
|
+
{ content, message },
|
|
2818
|
+
ctx.registeredSessionId,
|
|
2819
|
+
);
|
|
2302
2820
|
},
|
|
2303
2821
|
onCompactionStateChange: (isCompacting: boolean) => {
|
|
2304
2822
|
this.emit(
|
|
@@ -2405,6 +2923,22 @@ function isSessionRecoveryError(error: unknown): boolean {
|
|
|
2405
2923
|
);
|
|
2406
2924
|
}
|
|
2407
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
|
+
|
|
2408
2942
|
/** Read a text file, returning "" when it does not exist. */
|
|
2409
2943
|
async function readTextFileSafe(filePath: string): Promise<string> {
|
|
2410
2944
|
try {
|