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
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wires the interactive CLI's own session to the remote-control relay.
|
|
3
|
+
*
|
|
4
|
+
* The daemon serves its sessions by fanning notifications into a
|
|
5
|
+
* `RemoteControlSink`; the CLI has no notification fan-out at all — every
|
|
6
|
+
* surface it has is React state. So this hook supplies that fan-out, under the
|
|
7
|
+
* same method names and payloads a `--stdio` / daemon process uses, which is what
|
|
8
|
+
* lets the page treat the two hosts identically — including the remote channel's
|
|
9
|
+
* 150 ms merge window, which only bites if the frames it merges are the same
|
|
10
|
+
* ones.
|
|
11
|
+
*
|
|
12
|
+
* Two halves, and both have to be the same shape as the bridge's:
|
|
13
|
+
*
|
|
14
|
+
* - the callbacks that carry content, which the front end hands over from its own
|
|
15
|
+
* agent callbacks (`remoteNotifications` → `notify`);
|
|
16
|
+
* - the coarse state this hook watches itself (`messagesChange` as a content-free
|
|
17
|
+
* structural signal, loading, permission mode, context usage, approvals). The
|
|
18
|
+
* page answers a signal by pulling with `getMessages({ since })`, so no payload
|
|
19
|
+
* ever rides the signal.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
23
|
+
import type {
|
|
24
|
+
BackgroundTask,
|
|
25
|
+
Message,
|
|
26
|
+
PermissionDecision,
|
|
27
|
+
PermissionMode,
|
|
28
|
+
QueuedMessage,
|
|
29
|
+
RemoteControlSnapshot,
|
|
30
|
+
RemoteControlStatus,
|
|
31
|
+
SlashCommand,
|
|
32
|
+
Task,
|
|
33
|
+
ToolPermissionContext,
|
|
34
|
+
} from "wave-agent-sdk";
|
|
35
|
+
import {
|
|
36
|
+
RemoteControlCliHost,
|
|
37
|
+
type RemoteControlCliSession,
|
|
38
|
+
} from "../remote/remoteControlCliHost.js";
|
|
39
|
+
import {
|
|
40
|
+
percentOf,
|
|
41
|
+
remoteWire,
|
|
42
|
+
toBackgroundTaskSummaries,
|
|
43
|
+
} from "../remote/remoteNotifications.js";
|
|
44
|
+
import type { RemoteNotify } from "../remote/remoteNotifications.js";
|
|
45
|
+
import { createRemoteControlSinkFactory } from "../remote/remoteControlSink.js";
|
|
46
|
+
import type { RemoteControlSink } from "../remote/remoteControlSink.js";
|
|
47
|
+
|
|
48
|
+
export interface UseRemoteControlOptions {
|
|
49
|
+
workdir: string;
|
|
50
|
+
sessionId: string;
|
|
51
|
+
messages: Message[];
|
|
52
|
+
isLoading: boolean;
|
|
53
|
+
permissionMode: PermissionMode;
|
|
54
|
+
latestTotalTokens: number;
|
|
55
|
+
maxInputTokens: number;
|
|
56
|
+
/**
|
|
57
|
+
* The title `/rename` sets, read live off the agent — the page's session list
|
|
58
|
+
* labels the row with it (and the page itself may be the one that renamed it).
|
|
59
|
+
*/
|
|
60
|
+
customTitle: () => string | undefined;
|
|
61
|
+
hasRunningBackgroundWork: boolean;
|
|
62
|
+
hasPendingMessages: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* The session-state family the page re-reads off `getSessionInfo` (spec
|
|
65
|
+
* remote-control「Web 页作为第五种宿主」场景 26) — current values, so the
|
|
66
|
+
* long-lived host answers reads even though the session underneath it is
|
|
67
|
+
* replaced.
|
|
68
|
+
*/
|
|
69
|
+
tasks: Task[];
|
|
70
|
+
/** Background tasks as the TUI holds them — read off `getSessionInfo`. */
|
|
71
|
+
backgroundTasks: BackgroundTask[];
|
|
72
|
+
isCompacting: boolean;
|
|
73
|
+
isCommandRunning: boolean;
|
|
74
|
+
queuedMessages: QueuedMessage[];
|
|
75
|
+
/** Approvals waiting on the user right now. */
|
|
76
|
+
pending: Array<{ requestId: string; context: ToolPermissionContext }>;
|
|
77
|
+
availableTools: () => string[];
|
|
78
|
+
sendMessage: (text: string, force?: boolean) => void | Promise<void>;
|
|
79
|
+
abort: () => void;
|
|
80
|
+
setPermissionMode: (mode: PermissionMode) => void;
|
|
81
|
+
respondPermission: (requestId: string, decision: PermissionDecision) => void;
|
|
82
|
+
/** Rename the conversation (the title `wave -r` lists). */
|
|
83
|
+
renameSession: (title: string) => void | Promise<void>;
|
|
84
|
+
/** Compact the conversation; errors must propagate (the page shows them). */
|
|
85
|
+
compact: (customInstructions?: string) => void | Promise<void>;
|
|
86
|
+
/** Slash commands the page may offer (SDK commands: skills / custom). */
|
|
87
|
+
slashCommands: () => SlashCommand[];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RemoteControlApi {
|
|
91
|
+
/** The `enableRemoteControl` switch was on when this process started. */
|
|
92
|
+
enabled: boolean;
|
|
93
|
+
status: RemoteControlStatus;
|
|
94
|
+
getSnapshot: () => RemoteControlSnapshot;
|
|
95
|
+
/** Dials out. `name` labels this machine on the phone. */
|
|
96
|
+
connect: (name?: string) => void;
|
|
97
|
+
disconnect: () => void;
|
|
98
|
+
/**
|
|
99
|
+
* Send one notification to the phone under the method name a server host
|
|
100
|
+
* (`AgentBridge`) would use for the same thing. The front end calls it from its
|
|
101
|
+
* content-carrying agent callbacks (see `remoteNotifications`) — that is what
|
|
102
|
+
* makes the two hosts' fan-out identical, and therefore what makes the remote
|
|
103
|
+
* channel's merge window apply to this host too.
|
|
104
|
+
*/
|
|
105
|
+
notify: RemoteNotify;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The GUI dialog polls once a second for the same reason: there is no push. */
|
|
109
|
+
const STATUS_POLL_MS = 1000;
|
|
110
|
+
|
|
111
|
+
export function useRemoteControl(
|
|
112
|
+
options: UseRemoteControlOptions,
|
|
113
|
+
): RemoteControlApi {
|
|
114
|
+
// Every getter below must read the *current* value: the host outlives any
|
|
115
|
+
// single render, and the session underneath it is replaced on /resume.
|
|
116
|
+
const latest = useRef(options);
|
|
117
|
+
latest.current = options;
|
|
118
|
+
|
|
119
|
+
const [sink] = useState<RemoteControlSink | undefined>(() => {
|
|
120
|
+
// The switch is read once, here, exactly as a server process reads it once
|
|
121
|
+
// at startup — hence "restart the CLI" in the failure hint.
|
|
122
|
+
const factory = createRemoteControlSinkFactory(options.workdir);
|
|
123
|
+
if (!factory) return undefined;
|
|
124
|
+
return factory(new RemoteControlCliHost(adapterOf(latest)));
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
const [status, setStatus] = useState<RemoteControlStatus>(
|
|
128
|
+
() => sink?.getSnapshot() ?? { state: "off" },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
// ── Outbound: state changes the phone renders ─────────────────
|
|
132
|
+
//
|
|
133
|
+
// `sendNotification` drops everything while the link is down, which is what
|
|
134
|
+
// we want: a phone that reconnects re-reads the tail anyway.
|
|
135
|
+
const sessionId = options.sessionId;
|
|
136
|
+
const { messages, isLoading, permissionMode, latestTotalTokens } = options;
|
|
137
|
+
|
|
138
|
+
const notify = useCallback<RemoteNotify>(
|
|
139
|
+
(method, params) => {
|
|
140
|
+
sink?.sendNotification(method, params, latest.current.sessionId);
|
|
141
|
+
},
|
|
142
|
+
[sink],
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
// A structural notice, never a payload: the phone answers it by pulling the
|
|
146
|
+
// tail with `getMessages({ since })`, the same path a cold start takes and the
|
|
147
|
+
// only one that can carry a 13 MB list. What counts as "structurally changed"
|
|
148
|
+
// is `useStructureKey`'s business below.
|
|
149
|
+
const structureKey = useStructureKey(messages, sink !== undefined);
|
|
150
|
+
useEffect(() => {
|
|
151
|
+
sink?.sendNotification(
|
|
152
|
+
"messagesChange",
|
|
153
|
+
remoteWire.messagesChangeSignal(),
|
|
154
|
+
sessionId,
|
|
155
|
+
);
|
|
156
|
+
}, [sink, structureKey, sessionId]);
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
sink?.sendNotification(
|
|
160
|
+
"loadingChange",
|
|
161
|
+
remoteWire.loadingChange(isLoading),
|
|
162
|
+
sessionId,
|
|
163
|
+
);
|
|
164
|
+
}, [sink, isLoading, sessionId]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => {
|
|
167
|
+
sink?.sendNotification(
|
|
168
|
+
"permissionModeChange",
|
|
169
|
+
remoteWire.permissionModeChange(permissionMode),
|
|
170
|
+
sessionId,
|
|
171
|
+
);
|
|
172
|
+
}, [sink, permissionMode, sessionId]);
|
|
173
|
+
|
|
174
|
+
useEffect(() => {
|
|
175
|
+
if (!sink || !sessionId) return;
|
|
176
|
+
sink.sendNotification(
|
|
177
|
+
"contextUsage",
|
|
178
|
+
remoteWire.contextUsage(latestTotalTokens, options.maxInputTokens),
|
|
179
|
+
sessionId,
|
|
180
|
+
);
|
|
181
|
+
}, [sink, sessionId, latestTotalTokens, options.maxInputTokens]);
|
|
182
|
+
|
|
183
|
+
// Approvals are announced once, when they appear. The page answers with
|
|
184
|
+
// `permissionResponse`, which the host routes back into the same promise the
|
|
185
|
+
// terminal is waiting on — first answer wins, as the spec requires.
|
|
186
|
+
const announced = useRef<Set<string>>(new Set());
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
const pending = latest.current.pending;
|
|
189
|
+
if (!sink || pending.length === 0) return;
|
|
190
|
+
for (const request of pending) {
|
|
191
|
+
if (announced.current.has(request.requestId)) continue;
|
|
192
|
+
announced.current.add(request.requestId);
|
|
193
|
+
sink.sendNotification(
|
|
194
|
+
"permissionRequest",
|
|
195
|
+
remoteWire.permissionRequest(request.requestId, request.context),
|
|
196
|
+
sessionId,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
const alive = new Set(pending.map((request) => request.requestId));
|
|
200
|
+
for (const id of [...announced.current]) {
|
|
201
|
+
if (!alive.has(id)) announced.current.delete(id);
|
|
202
|
+
}
|
|
203
|
+
}, [sink, options.pending, sessionId]);
|
|
204
|
+
|
|
205
|
+
// ── Status the command view renders ───────────────────────────
|
|
206
|
+
|
|
207
|
+
useEffect(() => {
|
|
208
|
+
if (!sink) return;
|
|
209
|
+
const timer = setInterval(() => {
|
|
210
|
+
setStatus(sink.getSnapshot());
|
|
211
|
+
}, STATUS_POLL_MS);
|
|
212
|
+
timer.unref?.();
|
|
213
|
+
return () => clearInterval(timer);
|
|
214
|
+
}, [sink]);
|
|
215
|
+
|
|
216
|
+
const connect = useCallback(
|
|
217
|
+
(name?: string) => {
|
|
218
|
+
sink?.connect(name);
|
|
219
|
+
setStatus(sink?.getSnapshot() ?? { state: "off" });
|
|
220
|
+
},
|
|
221
|
+
[sink],
|
|
222
|
+
);
|
|
223
|
+
|
|
224
|
+
const disconnect = useCallback(() => {
|
|
225
|
+
sink?.disconnect();
|
|
226
|
+
setStatus(sink?.getSnapshot() ?? { state: "off" });
|
|
227
|
+
}, [sink]);
|
|
228
|
+
|
|
229
|
+
const getSnapshot = useCallback(
|
|
230
|
+
() => sink?.getSnapshot() ?? { state: "off" as const },
|
|
231
|
+
[sink],
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
// The process is going away: drop the outbound connection rather than let
|
|
235
|
+
// the relay keep a subscription for a session that no longer exists.
|
|
236
|
+
useEffect(() => {
|
|
237
|
+
return () => {
|
|
238
|
+
sink?.stop();
|
|
239
|
+
};
|
|
240
|
+
}, [sink]);
|
|
241
|
+
|
|
242
|
+
return {
|
|
243
|
+
enabled: sink !== undefined,
|
|
244
|
+
status,
|
|
245
|
+
getSnapshot,
|
|
246
|
+
connect,
|
|
247
|
+
disconnect,
|
|
248
|
+
notify,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The id composition of the display list, in order — what "the list changed"
|
|
254
|
+
* means for the remote path — as a key that is stable while the composition is.
|
|
255
|
+
*
|
|
256
|
+
* Ids are stable across a streamed message's growth and across in-place edits of
|
|
257
|
+
* its blocks, so comparing this key is what separates a structural change (a
|
|
258
|
+
* message appeared, was discarded, or the list was truncated/replaced) from a
|
|
259
|
+
* delta landing in a message the phone already has. Anchored on the ids rather
|
|
260
|
+
* than on "the messages state changed" so no call site has to be trusted to
|
|
261
|
+
* remember to announce a structural change — watching `messages` instead is what
|
|
262
|
+
* turned every model chunk into a signal plus a phone round trip.
|
|
263
|
+
*
|
|
264
|
+
* This runs once per render, and a streamed reply renders once per chunk, so the
|
|
265
|
+
* two shortcuts are the point: with no sink there is nobody to signal, and an
|
|
266
|
+
* unchanged id list returns the previous key rather than building a new string.
|
|
267
|
+
*
|
|
268
|
+
* The trigger stops at the ids on purpose: `since` is an **exclusive** anchor, so
|
|
269
|
+
* a signal can only ever deliver entries appended after the phone's own last
|
|
270
|
+
* message — a change made *inside* that message needs a payload-carrying
|
|
271
|
+
* notification of its own (see `remoteNotifications`).
|
|
272
|
+
*/
|
|
273
|
+
function useStructureKey(messages: Message[], enabled: boolean): string {
|
|
274
|
+
const last = useRef<{ ids: string[]; key: string }>({ ids: [], key: "" });
|
|
275
|
+
if (!enabled) return "";
|
|
276
|
+
const previous = last.current;
|
|
277
|
+
if (
|
|
278
|
+
previous.ids.length === messages.length &&
|
|
279
|
+
messages.every((message, index) => message.id === previous.ids[index])
|
|
280
|
+
) {
|
|
281
|
+
return previous.key;
|
|
282
|
+
}
|
|
283
|
+
const ids = messages.map((message) => message.id);
|
|
284
|
+
last.current = { ids, key: ids.join("\n") };
|
|
285
|
+
return last.current.key;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** The single-session adapter the host is built on. */
|
|
289
|
+
function adapterOf(latest: {
|
|
290
|
+
current: UseRemoteControlOptions;
|
|
291
|
+
}): RemoteControlCliSession {
|
|
292
|
+
return {
|
|
293
|
+
sessionId: () => latest.current.sessionId,
|
|
294
|
+
workdir: () => latest.current.workdir,
|
|
295
|
+
displayMessages: () => latest.current.messages,
|
|
296
|
+
contextUsagePercent: () =>
|
|
297
|
+
percentOf(
|
|
298
|
+
latest.current.latestTotalTokens,
|
|
299
|
+
latest.current.maxInputTokens,
|
|
300
|
+
),
|
|
301
|
+
latestTotalTokens: () => latest.current.latestTotalTokens,
|
|
302
|
+
permissionMode: () => latest.current.permissionMode,
|
|
303
|
+
customTitle: () => latest.current.customTitle(),
|
|
304
|
+
availableTools: () => latest.current.availableTools(),
|
|
305
|
+
isLoading: () => latest.current.isLoading,
|
|
306
|
+
hasRunningBackgroundWork: () => latest.current.hasRunningBackgroundWork,
|
|
307
|
+
hasPendingMessages: () => latest.current.hasPendingMessages,
|
|
308
|
+
tasks: () => latest.current.tasks,
|
|
309
|
+
backgroundTasks: () =>
|
|
310
|
+
toBackgroundTaskSummaries(latest.current.backgroundTasks),
|
|
311
|
+
isCompacting: () => latest.current.isCompacting,
|
|
312
|
+
isCommandRunning: () => latest.current.isCommandRunning,
|
|
313
|
+
queuedMessages: () => latest.current.queuedMessages,
|
|
314
|
+
pendingPermissions: () => latest.current.pending,
|
|
315
|
+
sendMessage: (text, force) => latest.current.sendMessage(text, force),
|
|
316
|
+
abort: () => latest.current.abort(),
|
|
317
|
+
setPermissionMode: (mode) => latest.current.setPermissionMode(mode),
|
|
318
|
+
respondPermission: (requestId, decision) =>
|
|
319
|
+
latest.current.respondPermission(requestId, decision),
|
|
320
|
+
renameSession: (title) => latest.current.renameSession(title),
|
|
321
|
+
compact: (customInstructions) => latest.current.compact(customInstructions),
|
|
322
|
+
slashCommands: () => latest.current.slashCommands(),
|
|
323
|
+
};
|
|
324
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -218,18 +218,30 @@ export async function main() {
|
|
|
218
218
|
)
|
|
219
219
|
.command(
|
|
220
220
|
"uninstall <plugin>",
|
|
221
|
-
"Uninstall a plugin",
|
|
221
|
+
"Uninstall a plugin from one scope (default: the scope it is enabled in for the current directory)",
|
|
222
222
|
(yargs) => {
|
|
223
|
-
return yargs
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
return yargs
|
|
224
|
+
.positional("plugin", {
|
|
225
|
+
describe: "Plugin to uninstall (format: name@marketplace)",
|
|
226
|
+
type: "string",
|
|
227
|
+
})
|
|
228
|
+
.option("scope", {
|
|
229
|
+
alias: "s",
|
|
230
|
+
describe: "Scope to uninstall from",
|
|
231
|
+
choices: ["user", "project", "local"],
|
|
232
|
+
type: "string",
|
|
233
|
+
});
|
|
227
234
|
},
|
|
228
235
|
async (argv) => {
|
|
229
236
|
const { uninstallPluginCommand } = await import(
|
|
230
237
|
"./commands/plugin/uninstall.js"
|
|
231
238
|
);
|
|
232
|
-
await uninstallPluginCommand(
|
|
239
|
+
await uninstallPluginCommand(
|
|
240
|
+
argv as {
|
|
241
|
+
plugin: string;
|
|
242
|
+
scope?: Scope;
|
|
243
|
+
},
|
|
244
|
+
);
|
|
233
245
|
},
|
|
234
246
|
)
|
|
235
247
|
.command(
|
|
@@ -312,8 +324,9 @@ export async function main() {
|
|
|
312
324
|
type: "string",
|
|
313
325
|
})
|
|
314
326
|
.option("lines", {
|
|
315
|
-
describe:
|
|
316
|
-
|
|
327
|
+
describe:
|
|
328
|
+
"Number of recent messages to show (0 = status line only)",
|
|
329
|
+
default: 1,
|
|
317
330
|
type: "number",
|
|
318
331
|
});
|
|
319
332
|
},
|
|
@@ -327,6 +340,47 @@ export async function main() {
|
|
|
327
340
|
);
|
|
328
341
|
},
|
|
329
342
|
)
|
|
343
|
+
.command(
|
|
344
|
+
"wait <sessionId>",
|
|
345
|
+
"Block until a session goes idle (or waits for approval), then print its final snapshot (exit 0 = idle, 3 = waiting for approval, 1 = error/timeout). Idle = no turn running, no background task/subagent running, nothing queued (same as `wave -p`); a session holding a long-running background task never goes idle, so give it --timeout",
|
|
346
|
+
(yargs) => {
|
|
347
|
+
return yargs
|
|
348
|
+
.positional("sessionId", {
|
|
349
|
+
describe: "Session ID hosted by the daemon",
|
|
350
|
+
type: "string",
|
|
351
|
+
})
|
|
352
|
+
.option("lines", {
|
|
353
|
+
describe:
|
|
354
|
+
"Number of recent messages to print on exit (0 = status line only)",
|
|
355
|
+
default: 1,
|
|
356
|
+
type: "number",
|
|
357
|
+
})
|
|
358
|
+
.option("from-busy", {
|
|
359
|
+
describe:
|
|
360
|
+
"Wait until the session has been observed busy at least once before accepting idle (removes the stale-snapshot race right after an async `send`; a running background task also counts as busy)",
|
|
361
|
+
default: false,
|
|
362
|
+
type: "boolean",
|
|
363
|
+
})
|
|
364
|
+
.option("timeout", {
|
|
365
|
+
describe:
|
|
366
|
+
"Seconds to wait before giving up (default: wait forever) — required for sessions with a long-running background task, which never go idle",
|
|
367
|
+
type: "number",
|
|
368
|
+
});
|
|
369
|
+
},
|
|
370
|
+
async (argv) => {
|
|
371
|
+
const { daemonWaitCommand, DEFAULT_DAEMON_SOCKET } =
|
|
372
|
+
await import("./daemon/commands.js");
|
|
373
|
+
await daemonWaitCommand(
|
|
374
|
+
DEFAULT_DAEMON_SOCKET,
|
|
375
|
+
argv.sessionId as string,
|
|
376
|
+
{
|
|
377
|
+
lines: argv.lines as number,
|
|
378
|
+
fromBusy: argv.fromBusy as boolean,
|
|
379
|
+
timeout: argv.timeout as number | undefined,
|
|
380
|
+
},
|
|
381
|
+
);
|
|
382
|
+
},
|
|
383
|
+
)
|
|
330
384
|
.command(
|
|
331
385
|
"send <sessionId> <message>",
|
|
332
386
|
"Inject a message into a session (async by default; --wait N to get the reply)",
|
|
@@ -483,6 +537,41 @@ export async function main() {
|
|
|
483
537
|
.demandCommand(1, "Please specify a daemon subcommand");
|
|
484
538
|
},
|
|
485
539
|
)
|
|
540
|
+
.command(
|
|
541
|
+
"remote-control [action] [name]",
|
|
542
|
+
"Serve this machine's sessions to a paired phone (via the wave daemon)",
|
|
543
|
+
(yargs) => {
|
|
544
|
+
return yargs
|
|
545
|
+
.positional("action", {
|
|
546
|
+
describe: "on | off | status",
|
|
547
|
+
type: "string",
|
|
548
|
+
default: "status",
|
|
549
|
+
})
|
|
550
|
+
.positional("name", {
|
|
551
|
+
describe: "Label for this machine, shown on the phone",
|
|
552
|
+
type: "string",
|
|
553
|
+
});
|
|
554
|
+
},
|
|
555
|
+
async (argv) => {
|
|
556
|
+
const {
|
|
557
|
+
remoteControlOnCommand,
|
|
558
|
+
remoteControlOffCommand,
|
|
559
|
+
remoteControlStatusCommand,
|
|
560
|
+
} = await import("./remote/remoteControlCommands.js");
|
|
561
|
+
const action = (argv.action as string | undefined) ?? "status";
|
|
562
|
+
if (action === "on") {
|
|
563
|
+
await remoteControlOnCommand(argv.name as string | undefined);
|
|
564
|
+
} else if (action === "off") {
|
|
565
|
+
await remoteControlOffCommand();
|
|
566
|
+
} else if (action === "status") {
|
|
567
|
+
await remoteControlStatusCommand();
|
|
568
|
+
} else {
|
|
569
|
+
console.error(`Unknown remote-control action: ${action}`);
|
|
570
|
+
console.error("Usage: wave remote-control [on|off|status] [name]");
|
|
571
|
+
process.exit(1);
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
)
|
|
486
575
|
.command(
|
|
487
576
|
"update",
|
|
488
577
|
"Update WAVE Code to the latest version",
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
InputAction,
|
|
7
7
|
InputManagerCallbacks,
|
|
8
8
|
isPasteImageKey,
|
|
9
|
+
permissionModeCycle,
|
|
9
10
|
} from "./inputReducer.js";
|
|
10
11
|
|
|
11
12
|
export const expandLongTextPlaceholders = (
|
|
@@ -136,15 +137,10 @@ export const cyclePermissionMode = (
|
|
|
136
137
|
dispatch: React.Dispatch<InputAction>,
|
|
137
138
|
callbacks: Partial<InputManagerCallbacks>,
|
|
138
139
|
) => {
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
"plan",
|
|
144
|
-
];
|
|
145
|
-
const currentIndex = modes.indexOf(currentMode);
|
|
146
|
-
const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % modes.length;
|
|
147
|
-
const nextMode = modes[nextIndex];
|
|
140
|
+
const currentIndex = permissionModeCycle.indexOf(currentMode);
|
|
141
|
+
const nextIndex =
|
|
142
|
+
currentIndex === -1 ? 0 : (currentIndex + 1) % permissionModeCycle.length;
|
|
143
|
+
const nextMode = permissionModeCycle[nextIndex];
|
|
148
144
|
|
|
149
145
|
callbacks.logger?.debug("Cycling permission mode", {
|
|
150
146
|
from: currentMode,
|
|
@@ -376,6 +372,8 @@ export const handleCommandSelect = (
|
|
|
376
372
|
dispatch({ type: "SET_SHOW_HOOKS_MANAGER", payload: true });
|
|
377
373
|
} else if (command === "rewind") {
|
|
378
374
|
dispatch({ type: "SET_SHOW_REWIND_MANAGER", payload: true });
|
|
375
|
+
} else if (command === "resume") {
|
|
376
|
+
dispatch({ type: "SET_SHOW_RESUME_SELECTOR", payload: true });
|
|
379
377
|
} else if (command === "help") {
|
|
380
378
|
dispatch({ type: "SET_SHOW_HELP", payload: true });
|
|
381
379
|
} else if (command === "status") {
|
|
@@ -401,8 +399,14 @@ export const handleCommandSelect = (
|
|
|
401
399
|
await callbacks.onClearMessages?.();
|
|
402
400
|
} else if (command === "compact") {
|
|
403
401
|
await callbacks.onCompact?.();
|
|
402
|
+
} else if (command === "reload-plugins") {
|
|
403
|
+
await callbacks.onReloadPlugins?.();
|
|
404
404
|
} else if (command === "add-dir") {
|
|
405
405
|
await callbacks.onAddDir?.();
|
|
406
|
+
} else if (command === "rename") {
|
|
407
|
+
await callbacks.onRenameSession?.();
|
|
408
|
+
} else if (command === "remote-control") {
|
|
409
|
+
await callbacks.onRemoteControlCommand?.();
|
|
406
410
|
}
|
|
407
411
|
}
|
|
408
412
|
})();
|
|
@@ -756,6 +760,7 @@ export const handleInput = async (
|
|
|
756
760
|
state.showBackgroundTaskManager ||
|
|
757
761
|
state.showMcpManager ||
|
|
758
762
|
state.showRewindManager ||
|
|
763
|
+
state.showResumeSelector ||
|
|
759
764
|
state.showHelp ||
|
|
760
765
|
state.showStatusCommand ||
|
|
761
766
|
state.showPluginManager ||
|
|
@@ -780,6 +785,7 @@ export const handleInput = async (
|
|
|
780
785
|
state.showBackgroundTaskManager ||
|
|
781
786
|
state.showMcpManager ||
|
|
782
787
|
state.showRewindManager ||
|
|
788
|
+
state.showResumeSelector ||
|
|
783
789
|
state.showHelp ||
|
|
784
790
|
state.showStatusCommand ||
|
|
785
791
|
state.showPluginManager ||
|
|
@@ -790,6 +796,7 @@ export const handleInput = async (
|
|
|
790
796
|
state.showBackgroundTaskManager ||
|
|
791
797
|
state.showMcpManager ||
|
|
792
798
|
state.showRewindManager ||
|
|
799
|
+
state.showResumeSelector ||
|
|
793
800
|
state.showHelp ||
|
|
794
801
|
state.showStatusCommand ||
|
|
795
802
|
state.showPluginManager ||
|
|
@@ -37,6 +37,18 @@ export const btwOverlayActiveRef: { current: boolean } = { current: false };
|
|
|
37
37
|
|
|
38
38
|
export const ESC_DOUBLE_PRESS_TIMEOUT_MS = 1000;
|
|
39
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Shift+Tab 循环的权限模式顺序(对齐 Claude Code:plan 之后才进入
|
|
42
|
+
* bypassPermissions)。单独导出,避免 handler 与 reducer 两条路径各写一份
|
|
43
|
+
* 而漂移。
|
|
44
|
+
*/
|
|
45
|
+
export const permissionModeCycle: PermissionMode[] = [
|
|
46
|
+
"default",
|
|
47
|
+
"acceptEdits",
|
|
48
|
+
"plan",
|
|
49
|
+
"bypassPermissions",
|
|
50
|
+
];
|
|
51
|
+
|
|
40
52
|
/**
|
|
41
53
|
* Paste-image shortcut: Ctrl+V everywhere, plus Alt+V on Windows — Windows
|
|
42
54
|
* terminals reserve Ctrl+V for their own system paste, so the key never
|
|
@@ -108,7 +120,10 @@ export interface InputManagerCallbacks {
|
|
|
108
120
|
) => Promise<string>;
|
|
109
121
|
onClearMessages?: () => Promise<void>;
|
|
110
122
|
onCompact?: (instructions?: string) => Promise<void>;
|
|
123
|
+
onReloadPlugins?: () => Promise<void>;
|
|
111
124
|
onAddDir?: (args?: string) => Promise<void>;
|
|
125
|
+
onRenameSession?: (args?: string) => Promise<void>;
|
|
126
|
+
onRemoteControlCommand?: (args?: string) => Promise<void>;
|
|
112
127
|
onPlanCommand?: (args?: string) => Promise<void> | void;
|
|
113
128
|
sessionId?: string;
|
|
114
129
|
workdir?: string;
|
|
@@ -142,6 +157,7 @@ export interface InputState {
|
|
|
142
157
|
showMcpManager: boolean;
|
|
143
158
|
showAgentsManager: boolean;
|
|
144
159
|
showRewindManager: boolean;
|
|
160
|
+
showResumeSelector: boolean;
|
|
145
161
|
showHelp: boolean;
|
|
146
162
|
showStatusCommand: boolean;
|
|
147
163
|
showLoginCommand: boolean;
|
|
@@ -182,6 +198,7 @@ export const initialState: InputState = {
|
|
|
182
198
|
showMcpManager: false,
|
|
183
199
|
showAgentsManager: false,
|
|
184
200
|
showRewindManager: false,
|
|
201
|
+
showResumeSelector: false,
|
|
185
202
|
showHelp: false,
|
|
186
203
|
showStatusCommand: false,
|
|
187
204
|
showLoginCommand: false,
|
|
@@ -465,6 +482,7 @@ export type InputAction =
|
|
|
465
482
|
| { type: "SET_SHOW_MCP_MANAGER"; payload: boolean }
|
|
466
483
|
| { type: "SET_SHOW_AGENTS_MANAGER"; payload: boolean }
|
|
467
484
|
| { type: "SET_SHOW_REWIND_MANAGER"; payload: boolean }
|
|
485
|
+
| { type: "SET_SHOW_RESUME_SELECTOR"; payload: boolean }
|
|
468
486
|
| { type: "SET_SHOW_HELP"; payload: boolean }
|
|
469
487
|
| { type: "SET_SHOW_STATUS_COMMAND"; payload: boolean }
|
|
470
488
|
| { type: "SET_SHOW_LOGIN_COMMAND"; payload: boolean }
|
|
@@ -667,6 +685,12 @@ export function inputReducer(
|
|
|
667
685
|
showRewindManager: action.payload,
|
|
668
686
|
selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
|
|
669
687
|
};
|
|
688
|
+
case "SET_SHOW_RESUME_SELECTOR":
|
|
689
|
+
return {
|
|
690
|
+
...state,
|
|
691
|
+
showResumeSelector: action.payload,
|
|
692
|
+
selectorJustUsed: !action.payload ? true : state.selectorJustUsed,
|
|
693
|
+
};
|
|
670
694
|
case "SET_SHOW_HELP":
|
|
671
695
|
return {
|
|
672
696
|
...state,
|
|
@@ -1076,6 +1100,7 @@ export function inputReducer(
|
|
|
1076
1100
|
state.showMcpManager ||
|
|
1077
1101
|
state.showAgentsManager ||
|
|
1078
1102
|
state.showRewindManager ||
|
|
1103
|
+
state.showResumeSelector ||
|
|
1079
1104
|
state.showHelp ||
|
|
1080
1105
|
state.showStatusCommand ||
|
|
1081
1106
|
state.showLoginCommand ||
|
|
@@ -1123,16 +1148,12 @@ export function inputReducer(
|
|
|
1123
1148
|
|
|
1124
1149
|
// 3. Special Shortcuts
|
|
1125
1150
|
if (key.tab && key.shift) {
|
|
1126
|
-
const
|
|
1127
|
-
"default",
|
|
1128
|
-
"acceptEdits",
|
|
1129
|
-
"bypassPermissions",
|
|
1130
|
-
"plan",
|
|
1131
|
-
];
|
|
1132
|
-
const currentIndex = modes.indexOf(state.permissionMode);
|
|
1151
|
+
const currentIndex = permissionModeCycle.indexOf(state.permissionMode);
|
|
1133
1152
|
const nextIndex =
|
|
1134
|
-
currentIndex === -1
|
|
1135
|
-
|
|
1153
|
+
currentIndex === -1
|
|
1154
|
+
? 0
|
|
1155
|
+
: (currentIndex + 1) % permissionModeCycle.length;
|
|
1156
|
+
const nextMode = permissionModeCycle[nextIndex];
|
|
1136
1157
|
return {
|
|
1137
1158
|
...state,
|
|
1138
1159
|
permissionMode: nextMode,
|
package/src/print-cli.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
hasNewCommits,
|
|
6
6
|
getDefaultRemoteBranch,
|
|
7
7
|
validateWorktreeRemovalPath,
|
|
8
|
+
setTelemetryStdoutProtected,
|
|
8
9
|
type WorktreeSession,
|
|
9
10
|
} from "wave-agent-sdk";
|
|
10
11
|
import { displayUsageSummary } from "./utils/usageSummary.js";
|
|
@@ -35,6 +36,9 @@ function displayTimingInfo(startTime: Date, showStats: boolean): void {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
39
|
+
// stdout carries the printed result here; console telemetry exporters would
|
|
40
|
+
// mix structured telemetry into it — strip them at config resolution.
|
|
41
|
+
setTelemetryStdoutProtected(true);
|
|
38
42
|
const startTime = new Date();
|
|
39
43
|
const {
|
|
40
44
|
restoreSessionId,
|
|
@@ -135,6 +139,7 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
135
139
|
isNewWorktree: worktreeSession?.isNew,
|
|
136
140
|
model,
|
|
137
141
|
mcpServers,
|
|
142
|
+
entrypoint: "cli",
|
|
138
143
|
// 保持流式模式以获得更好的命令行用户体验
|
|
139
144
|
});
|
|
140
145
|
|