wave-code 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bundle/wave.mjs +1382 -1131
- package/package.json +9 -7
- package/src/cli.tsx +14 -22
- package/src/commands/update.ts +62 -22
- package/src/components/AgentsManager.tsx +3 -3
- package/src/components/App.tsx +5 -0
- package/src/components/BackgroundTaskManager.tsx +76 -54
- package/src/components/BtwDisplay.tsx +71 -15
- package/src/components/CompactDisplay.tsx +1 -6
- package/src/components/ConfirmationDetails.tsx +34 -13
- package/src/components/ConfirmationSelector.tsx +31 -36
- package/src/components/DiffDisplay.tsx +1 -1
- package/src/components/HooksManager.tsx +3 -3
- package/src/components/InputBox.tsx +37 -11
- package/src/components/LoginCommand.tsx +16 -35
- package/src/components/Markdown.tsx +7 -3
- package/src/components/MarketplaceAddForm.tsx +15 -19
- package/src/components/MessageBlockItem.tsx +0 -6
- package/src/components/RemoteControlView.tsx +175 -0
- package/src/components/ScrolledContent.tsx +15 -13
- package/src/components/SkillsManager.tsx +3 -3
- package/src/components/TaskList.tsx +3 -3
- package/src/components/ToolDisplay.tsx +3 -18
- package/src/components/WorkflowManager.tsx +150 -136
- package/src/components/WorkflowRunDetail.tsx +193 -0
- package/src/constants/commands.ts +6 -0
- package/src/contexts/useChat.tsx +308 -154
- package/src/daemon/commands.ts +31 -22
- package/src/daemon-cli.ts +6 -1
- package/src/hooks/useInputManager.ts +25 -39
- package/src/hooks/useLineScroll.ts +51 -5
- package/src/hooks/useRemoteControl.ts +346 -0
- package/src/index.ts +35 -0
- package/src/managers/inputHandlers.ts +5 -0
- package/src/managers/inputReducer.ts +39 -0
- package/src/print-cli.ts +5 -0
- package/src/reducers/workflowManagerReducer.ts +21 -2
- package/src/remote/frameBudget.ts +45 -0
- package/src/remote/hostIdentity.ts +128 -0
- package/src/remote/messageWindow.ts +46 -0
- package/src/remote/notificationCoalescer.ts +192 -0
- package/src/remote/relayClient.ts +690 -0
- package/src/remote/remoteControlCliHost.ts +420 -0
- package/src/remote/remoteControlCommands.ts +197 -0
- package/src/remote/remoteControlConfig.ts +353 -0
- package/src/remote/remoteControlService.ts +822 -0
- package/src/remote/remoteControlSink.ts +78 -0
- package/src/remote/remoteNotifications.ts +200 -0
- package/src/stdio/agentBridge.ts +874 -106
- package/src/stdio/daemonServer.ts +30 -0
- package/src/stdio/hostSlashCommands.ts +209 -0
- package/src/stdio/jsonRpcConnection.ts +11 -2
- package/src/stdio/protocol.ts +14 -115
- package/src/stdio/stdioServer.ts +33 -2
- package/src/stdio/uiPayload.ts +65 -0
- package/src/stdio-cli.ts +11 -2
- package/src/types.ts +7 -0
- package/src/utils/gitCredentialHelper.ts +106 -0
- package/src/utils/logger.ts +5 -2
- package/src/utils/rewindCheckpoints.ts +2 -4
- package/src/utils/workspaceDiff.ts +620 -0
- package/src/utils/worktree.ts +97 -0
- package/src/utils/bracketedPaste.ts +0 -170
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RemoteControlCliHost — serves the **interactive CLI's own session** to a
|
|
3
|
+
* phone.
|
|
4
|
+
*
|
|
5
|
+
* The daemon and each GUI host's `--stdio` child serve their sessions through
|
|
6
|
+
* `AgentBridge`, which owns a registry of them. The front end the user is
|
|
7
|
+
* typing in has no RPC server at all: one session, held in React state. Rather
|
|
8
|
+
* than pretend otherwise, this host implements the same three-method
|
|
9
|
+
* `RemoteControlHost` contract against that single session, so the relay, the
|
|
10
|
+
* white list and the page need no special case.
|
|
11
|
+
*
|
|
12
|
+
* Two consequences of "one session, and it is the one on screen", both
|
|
13
|
+
* deliberate (docs/specs/ui/remote-control.md, 前台 CLI 故事):
|
|
14
|
+
*
|
|
15
|
+
* - Both ways of asking for the session list (`listDaemonSessions`, which the
|
|
16
|
+
* page reads, and `listSessions`) answer with exactly this session. The
|
|
17
|
+
* phone's list is a navigation surface, and there is nothing else to navigate
|
|
18
|
+
* to — scanning disk for older conversations would offer rows that cannot be
|
|
19
|
+
* opened.
|
|
20
|
+
* - The host dies with its process. Nothing here survives the TUI exiting, and
|
|
21
|
+
* that is the point: a session nobody holds cannot be served, only resumed
|
|
22
|
+
* (as a new session).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type {
|
|
26
|
+
BackgroundTaskSummary,
|
|
27
|
+
Message,
|
|
28
|
+
PermissionDecision,
|
|
29
|
+
PermissionMode,
|
|
30
|
+
QueuedMessage,
|
|
31
|
+
SessionMetadata,
|
|
32
|
+
SlashCommand,
|
|
33
|
+
Task,
|
|
34
|
+
ToolPermissionContext,
|
|
35
|
+
} from "wave-agent-sdk";
|
|
36
|
+
import {
|
|
37
|
+
listSessions,
|
|
38
|
+
readRecentWorkdirs,
|
|
39
|
+
rememberRecentWorkdir,
|
|
40
|
+
} from "wave-agent-sdk";
|
|
41
|
+
import {
|
|
42
|
+
hostSlashCommandsFor,
|
|
43
|
+
toSlashCommandInfo,
|
|
44
|
+
} from "../stdio/hostSlashCommands.js";
|
|
45
|
+
import { messagesForUi } from "../stdio/uiPayload.js";
|
|
46
|
+
import {
|
|
47
|
+
InvalidMessageWindowError,
|
|
48
|
+
normalizeMessageWindow,
|
|
49
|
+
type MessageWindow,
|
|
50
|
+
type MessageWindowResult,
|
|
51
|
+
} from "./messageWindow.js";
|
|
52
|
+
import {
|
|
53
|
+
REMOTE_HOST_UNSUPPORTED,
|
|
54
|
+
type RemoteControlHost,
|
|
55
|
+
} from "./remoteControlService.js";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What this host needs from the front end. Supplied by the chat hook, which
|
|
59
|
+
* owns all of it; every getter must return the **current** value (the host is
|
|
60
|
+
* long-lived and the session underneath it is not).
|
|
61
|
+
*/
|
|
62
|
+
export interface RemoteControlCliSession {
|
|
63
|
+
/** Empty until the first turn mints one — there is nothing to serve yet. */
|
|
64
|
+
sessionId(): string;
|
|
65
|
+
/** Where the shell is now (drifts on `cd`) — used for live directory reads. */
|
|
66
|
+
workdir(): string;
|
|
67
|
+
/** Where the session was opened; the row the page groups by. */
|
|
68
|
+
sessionCwd(): string;
|
|
69
|
+
/** One page of the list, sliced in memory. */
|
|
70
|
+
messagePage(window: MessageWindow): Promise<MessageWindowResult>;
|
|
71
|
+
/** The whole list as the agent holds it (it starts at the last compaction). */
|
|
72
|
+
currentThread(): Promise<Message[]>;
|
|
73
|
+
/** Thread totals the session list needs (the window cannot answer them). */
|
|
74
|
+
threadSummary(): {
|
|
75
|
+
messageCount: number;
|
|
76
|
+
firstUserText?: string;
|
|
77
|
+
lastActiveAt?: string;
|
|
78
|
+
};
|
|
79
|
+
contextUsagePercent(): number;
|
|
80
|
+
latestTotalTokens(): number;
|
|
81
|
+
permissionMode(): PermissionMode;
|
|
82
|
+
/** User-set title (the one `/rename` sets); undefined when it has none. */
|
|
83
|
+
customTitle(): string | undefined;
|
|
84
|
+
availableTools(): string[];
|
|
85
|
+
isLoading(): boolean;
|
|
86
|
+
hasRunningBackgroundWork(): boolean;
|
|
87
|
+
hasPendingMessages(): boolean;
|
|
88
|
+
/** The todo list (TaskCreate/TaskUpdate) — a *state*, the page re-reads it. */
|
|
89
|
+
tasks(): Task[];
|
|
90
|
+
isCompacting(): boolean;
|
|
91
|
+
isCommandRunning(): boolean;
|
|
92
|
+
queuedMessages(): QueuedMessage[];
|
|
93
|
+
/**
|
|
94
|
+
* Background tasks (shell / subagent / workflow) as the TUI holds them. Same
|
|
95
|
+
* role as `tasks`: the page's `/tasks` dialog reads the current set on its
|
|
96
|
+
* alignment paths, because the change notification only reports changes.
|
|
97
|
+
*/
|
|
98
|
+
backgroundTasks(): BackgroundTaskSummary[];
|
|
99
|
+
/** Approvals waiting on the user right now (terminal or phone). */
|
|
100
|
+
pendingPermissions(): Array<{
|
|
101
|
+
requestId: string;
|
|
102
|
+
context: ToolPermissionContext;
|
|
103
|
+
}>;
|
|
104
|
+
sendMessage(text: string, force?: boolean): void | Promise<void>;
|
|
105
|
+
abort(): void;
|
|
106
|
+
setPermissionMode(mode: PermissionMode): void;
|
|
107
|
+
/** Resolve a pending approval, whichever side asked for it. */
|
|
108
|
+
respondPermission(requestId: string, decision: PermissionDecision): void;
|
|
109
|
+
/** Set this session's custom title (the value `wave -r` lists). */
|
|
110
|
+
renameSession(title: string): void | Promise<void>;
|
|
111
|
+
/** Compact this conversation, optionally with custom instructions. */
|
|
112
|
+
compact(customInstructions?: string): void | Promise<void>;
|
|
113
|
+
/**
|
|
114
|
+
* The slash commands this session offers (skills / custom commands). The
|
|
115
|
+
* page renders these as a menu; the SDK's own list is exactly what a phone
|
|
116
|
+
* can run, since host-local commands (`/plan`, `/config`, …) live outside it.
|
|
117
|
+
*/
|
|
118
|
+
slashCommands(): SlashCommand[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The JSON-RPC code for a rejected parameter (matches the daemon's answer). */
|
|
122
|
+
const INVALID_PARAMS = -32602;
|
|
123
|
+
|
|
124
|
+
function invalidParams(message: string): Error {
|
|
125
|
+
const err = new Error(message) as Error & { code?: number };
|
|
126
|
+
err.code = INVALID_PARAMS;
|
|
127
|
+
return err;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* "The channel carries this method, this host does not." The white list is
|
|
132
|
+
* channel-wide (a daemon, a GUI's `--stdio` child and this foreground session all
|
|
133
|
+
* read the same set), while the three hosts implement different amounts of it:
|
|
134
|
+
* `AgentBridge` serves a registry of conversations, this host serves the one on
|
|
135
|
+
* screen and is wired to a subset. Answering `-32601` here would tell the page the
|
|
136
|
+
* method is unavailable over remote control at all — i.e. "upgrade the machine" —
|
|
137
|
+
* when the truth is "this machine can, this session cannot".
|
|
138
|
+
*/
|
|
139
|
+
function hostUnsupported(method: string): Error {
|
|
140
|
+
const err = new Error(
|
|
141
|
+
`Not implemented by a foreground CLI session: ${method}`,
|
|
142
|
+
) as Error & { code?: number };
|
|
143
|
+
err.code = REMOTE_HOST_UNSUPPORTED;
|
|
144
|
+
return err;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export class RemoteControlCliHost implements RemoteControlHost {
|
|
148
|
+
constructor(private readonly session: RemoteControlCliSession) {}
|
|
149
|
+
|
|
150
|
+
async handleRequest(method: string, params: unknown): Promise<unknown> {
|
|
151
|
+
const p = (params ?? {}) as Record<string, unknown>;
|
|
152
|
+
switch (method) {
|
|
153
|
+
case "listSessions":
|
|
154
|
+
return this.listSessions();
|
|
155
|
+
case "listDaemonSessions":
|
|
156
|
+
return this.listDaemonSessions();
|
|
157
|
+
case "getSessionInfo":
|
|
158
|
+
return this.getSessionInfo();
|
|
159
|
+
case "getMessages":
|
|
160
|
+
return this.getMessages(p);
|
|
161
|
+
// 最近打开的工作目录是本机的事实,与「这条前台会话」无关:同一个文件
|
|
162
|
+
// AgentBridge 也在读(spec core/recent-workdirs.md)。不实现的话,页面
|
|
163
|
+
// 会落到「这台电脑上的会话不支持这个操作」——白名单放行 ≠ 宿主实现。
|
|
164
|
+
case "getRecentWorkdirs":
|
|
165
|
+
return { dirs: readRecentWorkdirs() };
|
|
166
|
+
case "rememberRecentWorkdir":
|
|
167
|
+
rememberRecentWorkdir(typeof p.workdir === "string" ? p.workdir : "");
|
|
168
|
+
return null;
|
|
169
|
+
case "listPendingPermissions":
|
|
170
|
+
return this.listPendingPermissions();
|
|
171
|
+
case "sendMessage":
|
|
172
|
+
await this.session.sendMessage(
|
|
173
|
+
typeof p.text === "string" ? p.text : "",
|
|
174
|
+
p.force === true,
|
|
175
|
+
);
|
|
176
|
+
return null;
|
|
177
|
+
case "abortMessage":
|
|
178
|
+
this.session.abort();
|
|
179
|
+
return null;
|
|
180
|
+
case "setPermissionMode":
|
|
181
|
+
this.session.setPermissionMode(p.mode as PermissionMode);
|
|
182
|
+
return null;
|
|
183
|
+
case "renameSession": {
|
|
184
|
+
// The daemon's AgentBridge takes the target session from params (a host
|
|
185
|
+
// serves many). This host serves exactly one, so the title is the only
|
|
186
|
+
// input that means anything here — an empty one is a caller bug.
|
|
187
|
+
const title = typeof p.title === "string" ? p.title.trim() : "";
|
|
188
|
+
if (!title) throw invalidParams("renameSession requires a title");
|
|
189
|
+
await this.session.renameSession(title);
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
case "compact":
|
|
193
|
+
await this.session.compact(
|
|
194
|
+
typeof p.customInstructions === "string"
|
|
195
|
+
? p.customInstructions
|
|
196
|
+
: undefined,
|
|
197
|
+
);
|
|
198
|
+
return null;
|
|
199
|
+
case "getSlashCommands":
|
|
200
|
+
return {
|
|
201
|
+
// Narrowed to what the page renders, like the desktop host does: the
|
|
202
|
+
// SDK's SlashCommand carries a `handler` function, which has no
|
|
203
|
+
// business travelling over a relay (and would be dropped silently,
|
|
204
|
+
// leaving the frame's shape to chance). The host-local half comes
|
|
205
|
+
// from the one table, filtered for this entry (spec
|
|
206
|
+
// ui/slash-commands.md「弹窗里的宿主命令只有一份表」) — the same bytes
|
|
207
|
+
// the daemon returns, so the phone sees one list either way.
|
|
208
|
+
commands: [
|
|
209
|
+
...this.session.slashCommands().map(toSlashCommandInfo),
|
|
210
|
+
...hostSlashCommandsFor("remote-page"),
|
|
211
|
+
],
|
|
212
|
+
};
|
|
213
|
+
default:
|
|
214
|
+
// The white list already rejected everything outside it, so anything
|
|
215
|
+
// arriving here is a method the *channel* carries and this host does not
|
|
216
|
+
// (rewind, `clearMessages`, background tasks, `searchFiles`, … — see
|
|
217
|
+
// `REMOTE_HOST_UNSUPPORTED`). Said as exactly that, not as a bad
|
|
218
|
+
// parameter and not as silence: the page turns it into one visible line.
|
|
219
|
+
throw hostUnsupported(method);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
handleNotification(method: string, params: unknown): void {
|
|
224
|
+
if (method !== "permissionResponse") return;
|
|
225
|
+
const p = (params ?? {}) as {
|
|
226
|
+
requestId?: unknown;
|
|
227
|
+
decision?: unknown;
|
|
228
|
+
};
|
|
229
|
+
if (typeof p.requestId !== "string" || !p.decision) return;
|
|
230
|
+
this.session.respondPermission(
|
|
231
|
+
p.requestId,
|
|
232
|
+
p.decision as PermissionDecision,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* One row, one word. The relay turns a transition *into* `requires_action`
|
|
238
|
+
* into a Web Push, so this is what makes a phone ring when the terminal is
|
|
239
|
+
* waiting on an approval.
|
|
240
|
+
*/
|
|
241
|
+
listSessionStatuses(): Array<{
|
|
242
|
+
sessionId: string;
|
|
243
|
+
status: "running" | "idle" | "requires_action";
|
|
244
|
+
}> {
|
|
245
|
+
const sessionId = this.session.sessionId();
|
|
246
|
+
if (!sessionId) return [];
|
|
247
|
+
if (this.session.pendingPermissions().length > 0) {
|
|
248
|
+
return [{ sessionId, status: "requires_action" }];
|
|
249
|
+
}
|
|
250
|
+
const busy =
|
|
251
|
+
this.session.isLoading() || this.session.hasRunningBackgroundWork();
|
|
252
|
+
return [{ sessionId, status: busy ? "running" : "idle" }];
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
private async listSessions(): Promise<{ sessions: SessionMetadata[] }> {
|
|
256
|
+
const sessionId = this.session.sessionId();
|
|
257
|
+
if (!sessionId) return { sessions: [] };
|
|
258
|
+
// Prefer the real record from disk — it carries the title, timestamps and
|
|
259
|
+
// token count the page renders. A session that has not been flushed yet
|
|
260
|
+
// has no record, and an empty list would read on the phone as "this
|
|
261
|
+
// machine has nothing to control", so synthesize the row instead.
|
|
262
|
+
try {
|
|
263
|
+
const persisted = await listSessions(this.session.workdir());
|
|
264
|
+
const mine = persisted.find((entry) => entry.id === sessionId);
|
|
265
|
+
if (mine) return { sessions: [mine] };
|
|
266
|
+
} catch {
|
|
267
|
+
// Unreadable project directory: the synthesized row is still the truth.
|
|
268
|
+
}
|
|
269
|
+
return { sessions: [this.synthesizedMetadata(sessionId)] };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private synthesizedMetadata(sessionId: string): SessionMetadata {
|
|
273
|
+
const now = new Date();
|
|
274
|
+
const firstMessage = this.session.threadSummary().firstUserText;
|
|
275
|
+
const customTitle = this.session.customTitle();
|
|
276
|
+
return {
|
|
277
|
+
id: sessionId,
|
|
278
|
+
sessionType: "main",
|
|
279
|
+
workdir: this.session.workdir(),
|
|
280
|
+
createdAt: now,
|
|
281
|
+
lastActiveAt: now,
|
|
282
|
+
latestTotalTokens: this.session.latestTotalTokens(),
|
|
283
|
+
...(customTitle ? { customTitle } : {}),
|
|
284
|
+
...(firstMessage ? { firstMessage } : {}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The session list the page actually reads. A daemon has a real registry of
|
|
290
|
+
* them (`AgentBridge.listDaemonSessions`); this host holds exactly one, so its
|
|
291
|
+
* registry is that one — same shape, one row, and no disk read (the page can
|
|
292
|
+
* only open sessions that are live in the serving process, and this is the
|
|
293
|
+
* only one that is; see 规格 remote-control「边界情况」).
|
|
294
|
+
*
|
|
295
|
+
* Not `listSessions`: that reads the transcripts and would answer with rows
|
|
296
|
+
* the page cannot open — including this machine's older, unloaded ones.
|
|
297
|
+
*/
|
|
298
|
+
private listDaemonSessions(): {
|
|
299
|
+
sessions: Array<{
|
|
300
|
+
sessionId: string;
|
|
301
|
+
sessionCwd: string;
|
|
302
|
+
isLoading: boolean;
|
|
303
|
+
hasRunningBackgroundWork: boolean;
|
|
304
|
+
hasPendingMessages: boolean;
|
|
305
|
+
messageCount: number;
|
|
306
|
+
/** User-set title; wins over `firstMessage` in the UI. */
|
|
307
|
+
customTitle?: string;
|
|
308
|
+
/** First real user message — the label when no custom title is set. */
|
|
309
|
+
firstMessage?: string;
|
|
310
|
+
/** Last display message's timestamp: the session list's ordering key. */
|
|
311
|
+
lastActiveAt?: string;
|
|
312
|
+
}>;
|
|
313
|
+
} {
|
|
314
|
+
const sessionId = this.session.sessionId();
|
|
315
|
+
if (!sessionId) return { sessions: [] };
|
|
316
|
+
const customTitle = this.session.customTitle();
|
|
317
|
+
const summary = this.session.threadSummary();
|
|
318
|
+
const firstMessage = summary.firstUserText;
|
|
319
|
+
const lastActiveAt = summary.lastActiveAt;
|
|
320
|
+
return {
|
|
321
|
+
sessions: [
|
|
322
|
+
{
|
|
323
|
+
sessionId,
|
|
324
|
+
sessionCwd: this.session.sessionCwd(),
|
|
325
|
+
isLoading: this.session.isLoading(),
|
|
326
|
+
hasRunningBackgroundWork: this.session.hasRunningBackgroundWork(),
|
|
327
|
+
hasPendingMessages: this.session.hasPendingMessages(),
|
|
328
|
+
messageCount: summary.messageCount,
|
|
329
|
+
...(customTitle ? { customTitle } : {}),
|
|
330
|
+
...(firstMessage ? { firstMessage } : {}),
|
|
331
|
+
...(lastActiveAt ? { lastActiveAt } : {}),
|
|
332
|
+
},
|
|
333
|
+
],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
private getSessionInfo(): {
|
|
338
|
+
sessionId: string;
|
|
339
|
+
sessionCwd: string;
|
|
340
|
+
latestTotalTokens: number;
|
|
341
|
+
permissionMode: PermissionMode;
|
|
342
|
+
availableTools: string[];
|
|
343
|
+
tasks: Task[];
|
|
344
|
+
isCompacting: boolean;
|
|
345
|
+
isCommandRunning: boolean;
|
|
346
|
+
queuedMessages: QueuedMessage[];
|
|
347
|
+
backgroundTasks: BackgroundTaskSummary[];
|
|
348
|
+
} {
|
|
349
|
+
return {
|
|
350
|
+
sessionId: this.session.sessionId(),
|
|
351
|
+
sessionCwd: this.session.sessionCwd(),
|
|
352
|
+
latestTotalTokens: this.session.latestTotalTokens(),
|
|
353
|
+
permissionMode: this.session.permissionMode(),
|
|
354
|
+
availableTools: this.session.availableTools(),
|
|
355
|
+
// The session-state family the page re-reads (spec remote-control
|
|
356
|
+
// 「Web 页作为第五种宿主」场景 26): current values, not transitions.
|
|
357
|
+
tasks: this.session.tasks(),
|
|
358
|
+
isCompacting: this.session.isCompacting(),
|
|
359
|
+
isCommandRunning: this.session.isCommandRunning(),
|
|
360
|
+
queuedMessages: this.session.queuedMessages(),
|
|
361
|
+
backgroundTasks: this.session.backgroundTasks(),
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
private async getMessages(p: Record<string, unknown>): Promise<{
|
|
366
|
+
messages: Message[];
|
|
367
|
+
contextUsagePercent: number;
|
|
368
|
+
reset?: boolean;
|
|
369
|
+
}> {
|
|
370
|
+
const contextUsagePercent = this.session.contextUsagePercent();
|
|
371
|
+
const window: MessageWindow = {};
|
|
372
|
+
if (typeof p.since === "string") window.since = p.since;
|
|
373
|
+
if (p.limit !== undefined) window.limit = Number(p.limit);
|
|
374
|
+
|
|
375
|
+
let normalized: MessageWindow | undefined;
|
|
376
|
+
try {
|
|
377
|
+
normalized = normalizeMessageWindow(window);
|
|
378
|
+
} catch (err) {
|
|
379
|
+
if (err instanceof InvalidMessageWindowError)
|
|
380
|
+
throw invalidParams(err.message);
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
// No parameters = the whole list the agent holds (spec
|
|
384
|
+
// ui/message-rendering-system.md「首屏就是整份列表」). A `limit` is a tail
|
|
385
|
+
// slice of the same list, for the phone's frame budget.
|
|
386
|
+
//
|
|
387
|
+
// Both answers are trimmed: the phone renders no tool pictures, and its
|
|
388
|
+
// frame budget cannot carry one
|
|
389
|
+
// (specs/ui/stdio-transport.md「工具图片的字节不下发 UI」).
|
|
390
|
+
if (!normalized) {
|
|
391
|
+
return {
|
|
392
|
+
messages: messagesForUi(await this.session.currentThread()),
|
|
393
|
+
contextUsagePercent,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const page = await this.session.messagePage(normalized);
|
|
397
|
+
return {
|
|
398
|
+
...page,
|
|
399
|
+
messages: messagesForUi(page.messages),
|
|
400
|
+
contextUsagePercent,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
private listPendingPermissions(): {
|
|
405
|
+
requests: Array<{
|
|
406
|
+
requestId: string;
|
|
407
|
+
sessionId?: string;
|
|
408
|
+
context: ToolPermissionContext;
|
|
409
|
+
}>;
|
|
410
|
+
} {
|
|
411
|
+
const sessionId = this.session.sessionId();
|
|
412
|
+
return {
|
|
413
|
+
requests: this.session.pendingPermissions().map((pending) => ({
|
|
414
|
+
requestId: pending.requestId,
|
|
415
|
+
...(sessionId ? { sessionId } : {}),
|
|
416
|
+
context: pending.context,
|
|
417
|
+
})),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wave remote-control` — the operator's handle on the relay connection.
|
|
3
|
+
*
|
|
4
|
+
* These subcommands serve the **daemon**: it is the resident host (it owns
|
|
5
|
+
* sessions across client detach/attach and keeps generating while nothing is
|
|
6
|
+
* attached), and it is the process whose emit fan-out the relay subscribes to.
|
|
7
|
+
* They are therefore a client of the daemon socket, exactly like `wave daemon …`
|
|
8
|
+
* — `on` starts a daemon on demand, `off`/`status` never do.
|
|
9
|
+
*
|
|
10
|
+
* A session that lives in the *foreground* CLI process is out of reach from
|
|
11
|
+
* here; that process serves itself through the TUI's `/remote-control` command
|
|
12
|
+
* (see `remoteControlCliHost.ts`). The switch can be on for both at once without
|
|
13
|
+
* either one covering the other's sessions.
|
|
14
|
+
*
|
|
15
|
+
* The page address a phone opens is assembled from this machine's channel key,
|
|
16
|
+
* which the daemon got by registering with the relay (an HTTP call) — nothing is
|
|
17
|
+
* pushed down the WebSocket for it. So `on` may have to wait for a registration
|
|
18
|
+
* round trip, and everything it learned stays on the daemon, where `status` can
|
|
19
|
+
* pick it up.
|
|
20
|
+
*
|
|
21
|
+
* All subcommands are non-interactive (stdout for results, stderr for
|
|
22
|
+
* diagnostics, exit code set here) — yargs would otherwise fall through to the
|
|
23
|
+
* Ink TUI.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { toString as qrToString } from "qrcode";
|
|
27
|
+
import type { RemoteControlSnapshot } from "wave-agent-sdk";
|
|
28
|
+
import {
|
|
29
|
+
DEFAULT_DAEMON_SOCKET,
|
|
30
|
+
connectDaemonIfRunning,
|
|
31
|
+
connectDaemonOrExit,
|
|
32
|
+
} from "../daemon/commands.js";
|
|
33
|
+
import type { SocketClient } from "../daemon/socketClient.js";
|
|
34
|
+
|
|
35
|
+
/** How long `on` waits for the page address to come back from the daemon. */
|
|
36
|
+
export const PAGE_WAIT_MS = 5_000;
|
|
37
|
+
const PAGE_POLL_MS = 500;
|
|
38
|
+
|
|
39
|
+
function describe(err: unknown): string {
|
|
40
|
+
return err instanceof Error ? err.message : String(err);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The switch lives in settings, so a daemon started before it was turned on
|
|
45
|
+
* cannot serve — and the error it returns says which key to set but not that
|
|
46
|
+
* the running daemon has to be replaced for the change to take effect.
|
|
47
|
+
*/
|
|
48
|
+
function hintFor(message: string): string | undefined {
|
|
49
|
+
if (!message.includes("enableRemoteControl")) return undefined;
|
|
50
|
+
return 'Set "enableRemoteControl": true in ~/.wave/settings.json (or .wave/settings.json in the project), then run `wave daemon restart` — a daemon that was already running keeps its old settings.';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function printEnableHint(message: string): void {
|
|
54
|
+
console.error(message);
|
|
55
|
+
const hint = hintFor(message);
|
|
56
|
+
if (hint) console.error(hint);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Print the page the phone should open, and a QR of that page.
|
|
61
|
+
*
|
|
62
|
+
* The options are Claude Code's bridge-dialog pair (`type: "utf8"`,
|
|
63
|
+
* `errorCorrectionLevel: "L"`) minus its `small: true`, which is inert here:
|
|
64
|
+
* node-qrcode reads `small` only in the *terminal* renderer, and `utf8` is
|
|
65
|
+
* already the compact half-block form (`▀▄█`) with no ANSI — so the code stays
|
|
66
|
+
* copyable out of a terminal. Encoding is the only thing that can fail (and only
|
|
67
|
+
* for absurd input), and the address printed above is the real contract — a
|
|
68
|
+
* missing QR is not worth failing the command.
|
|
69
|
+
*/
|
|
70
|
+
async function printPageUrl(pageUrl: string): Promise<void> {
|
|
71
|
+
console.log("");
|
|
72
|
+
console.log(`Web 页:${pageUrl}`);
|
|
73
|
+
console.log(
|
|
74
|
+
"手机:用相机扫码或在 Safari 中打开,再「添加到主屏幕」(控制中心的扫码器打开的容器加不了主屏)。",
|
|
75
|
+
);
|
|
76
|
+
try {
|
|
77
|
+
console.log(
|
|
78
|
+
await qrToString(pageUrl, {
|
|
79
|
+
type: "utf8",
|
|
80
|
+
errorCorrectionLevel: "L",
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
} catch {
|
|
84
|
+
// Nothing to add: the line above already carries the address.
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Poll the daemon until the registration round trip hands back a page address. */
|
|
89
|
+
async function waitForPageUrl(
|
|
90
|
+
client: SocketClient,
|
|
91
|
+
initial: RemoteControlSnapshot,
|
|
92
|
+
): Promise<string | undefined> {
|
|
93
|
+
const deadline = Date.now() + PAGE_WAIT_MS;
|
|
94
|
+
let snapshot = initial;
|
|
95
|
+
while (Date.now() < deadline) {
|
|
96
|
+
if (snapshot.pageUrl) return snapshot.pageUrl;
|
|
97
|
+
if (snapshot.state === "error") {
|
|
98
|
+
printEnableHint(`远程控制连接失败:${snapshot.message ?? "未知错误"}`);
|
|
99
|
+
process.exit(1);
|
|
100
|
+
}
|
|
101
|
+
await new Promise((resolve) => setTimeout(resolve, PAGE_POLL_MS));
|
|
102
|
+
snapshot = (await client.request(
|
|
103
|
+
"remoteControlStatus",
|
|
104
|
+
{},
|
|
105
|
+
)) as RemoteControlSnapshot;
|
|
106
|
+
}
|
|
107
|
+
return snapshot.pageUrl;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Turn remote control on for this machine and show the page a phone opens.
|
|
112
|
+
* Idempotent: running it while already online re-reads the same address.
|
|
113
|
+
*/
|
|
114
|
+
export async function remoteControlOnCommand(name?: string): Promise<void> {
|
|
115
|
+
let client: SocketClient | undefined;
|
|
116
|
+
try {
|
|
117
|
+
client = await connectDaemonOrExit(DEFAULT_DAEMON_SOCKET);
|
|
118
|
+
const connecting = (await client.request(
|
|
119
|
+
"remoteControlConnect",
|
|
120
|
+
name ? { name } : {},
|
|
121
|
+
)) as RemoteControlSnapshot;
|
|
122
|
+
|
|
123
|
+
if (connecting.state === "error") {
|
|
124
|
+
printEnableHint(`远程控制开启失败:${connecting.message ?? "未知错误"}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const pageUrl = await waitForPageUrl(client, connecting);
|
|
129
|
+
if (!pageUrl) {
|
|
130
|
+
console.error("远程控制已连上,但没能从 daemon 拿到页面地址。");
|
|
131
|
+
process.exit(1);
|
|
132
|
+
}
|
|
133
|
+
await printPageUrl(pageUrl);
|
|
134
|
+
console.log("");
|
|
135
|
+
console.log("关闭远程控制:`wave remote-control off`");
|
|
136
|
+
} catch (err) {
|
|
137
|
+
printEnableHint(`wave remote-control on 失败:${describe(err)}`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
} finally {
|
|
140
|
+
client?.dispose();
|
|
141
|
+
}
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Disconnect. Idempotent, and like `wave daemon stop` it never starts a daemon
|
|
147
|
+
* — with nothing running there is nothing to turn off.
|
|
148
|
+
*/
|
|
149
|
+
export async function remoteControlOffCommand(): Promise<void> {
|
|
150
|
+
const client = await connectDaemonIfRunning(DEFAULT_DAEMON_SOCKET);
|
|
151
|
+
if (!client) {
|
|
152
|
+
console.log("远程控制未开启(wave daemon 未运行)");
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
try {
|
|
156
|
+
await client.request("remoteControlDisconnect", {});
|
|
157
|
+
console.log("远程控制已断开");
|
|
158
|
+
} catch (err) {
|
|
159
|
+
// "Not enabled" lands here too: the daemon has no relay at all, which is
|
|
160
|
+
// the same end state the user asked for, so it is reported as success —
|
|
161
|
+
// with the switch hint on stderr, because turning it on would change that.
|
|
162
|
+
const message = describe(err);
|
|
163
|
+
console.log(`远程控制已关闭(${message})`);
|
|
164
|
+
const hint = hintFor(message);
|
|
165
|
+
if (hint) console.error(hint);
|
|
166
|
+
} finally {
|
|
167
|
+
client.dispose();
|
|
168
|
+
}
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Report the connection state, and the page address when there is one. */
|
|
173
|
+
export async function remoteControlStatusCommand(): Promise<void> {
|
|
174
|
+
const client = await connectDaemonIfRunning(DEFAULT_DAEMON_SOCKET);
|
|
175
|
+
if (!client) {
|
|
176
|
+
console.log("状态:未开启(wave daemon 未运行)");
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
let ok = true;
|
|
180
|
+
try {
|
|
181
|
+
const snapshot = (await client.request(
|
|
182
|
+
"remoteControlStatus",
|
|
183
|
+
{},
|
|
184
|
+
)) as RemoteControlSnapshot;
|
|
185
|
+
console.log(`状态:${snapshot.state}`);
|
|
186
|
+
if (snapshot.message) console.log(`说明:${snapshot.message}`);
|
|
187
|
+
if (snapshot.pageUrl) await printPageUrl(snapshot.pageUrl);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// Unlike `off`, a status we could not read is a real failure: the daemon
|
|
190
|
+
// may be an older build that predates this method.
|
|
191
|
+
ok = false;
|
|
192
|
+
printEnableHint(`读取远程控制状态失败:${describe(err)}`);
|
|
193
|
+
} finally {
|
|
194
|
+
client.dispose();
|
|
195
|
+
}
|
|
196
|
+
process.exit(ok ? 0 : 1);
|
|
197
|
+
}
|