wave-code 1.0.7 → 1.0.8
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/components/ChatInterface.js +1 -1
- package/dist/components/ConfirmationDetails.d.ts +1 -0
- package/dist/components/ConfirmationDetails.js +5 -3
- package/dist/components/RewindCommand.js +11 -4
- package/dist/contexts/useChat.d.ts +17 -2
- package/dist/contexts/useChat.js +99 -7
- package/dist/daemon/commands.d.ts +49 -0
- package/dist/daemon/commands.js +341 -0
- package/dist/daemon/jsonRpcClient.d.ts +38 -0
- package/dist/daemon/jsonRpcClient.js +129 -0
- package/dist/daemon/socketClient.d.ts +13 -0
- package/dist/daemon/socketClient.js +26 -0
- package/dist/index.js +88 -0
- package/dist/stdio/agentBridge.d.ts +12 -0
- package/dist/stdio/agentBridge.js +63 -10
- package/dist/stdio/protocol.d.ts +1 -1
- package/package.json +2 -2
- package/src/components/ChatInterface.tsx +2 -0
- package/src/components/ConfirmationDetails.tsx +6 -0
- package/src/components/RewindCommand.tsx +10 -4
- package/src/contexts/useChat.tsx +123 -5
- package/src/daemon/commands.ts +444 -0
- package/src/daemon/jsonRpcClient.ts +158 -0
- package/src/daemon/socketClient.ts +34 -0
- package/src/index.ts +130 -0
- package/src/stdio/agentBridge.ts +76 -10
- package/src/stdio/protocol.ts +2 -0
|
@@ -57,5 +57,5 @@ export const ChatInterface = () => {
|
|
|
57
57
|
]);
|
|
58
58
|
if (!sessionId)
|
|
59
59
|
return null;
|
|
60
|
-
return (_jsxs(Box, { ref: chatInterfaceRef, flexDirection: "column", children: [_jsx(MessageList, { messages: displayMessages, isExpanded: isExpanded, version: version, workdir: workdir, forceStatic: forceStatic }, remountKey), !isConfirmationVisible && !isExpanded && (_jsxs(_Fragment, { children: [(isLoading || isCommandRunning || isCompacting) && (_jsx(LoadingIndicator, { isLoading: isLoading, isCommandRunning: isCommandRunning, isCompacting: isCompacting, latestTotalTokens: latestTotalTokens })), _jsx(TaskList, {}), _jsx(QueuedMessageList, {}), _jsx(InputBox, { isLoading: isLoading, isCommandRunning: isCommandRunning, isCompacting: isCompacting, sendMessage: sendMessage, abortMessage: abortMessage, mcpServers: mcpServers, connectMcpServer: connectMcpServer, disconnectMcpServer: disconnectMcpServer, slashCommands: slashCommands, hasSlashCommand: hasSlashCommand, latestTotalTokens: latestTotalTokens, maxInputTokens: maxInputTokens, showLoginHint: showLoginHint })] })), isConfirmationVisible && (_jsxs(_Fragment, { children: [forceStatic ? (_jsx(Static, { items: [{ key: "confirmation-details" }], children: () => (_jsx(ConfirmationDetails, { toolName: confirmingTool.name, toolInput: confirmingTool.input, planContent: confirmingTool.planContent, isExpanded: isExpanded }, "confirmation-details")) })) : (_jsx(ConfirmationDetails, { toolName: confirmingTool.name, toolInput: confirmingTool.input, planContent: confirmingTool.planContent, isExpanded: isExpanded })), _jsx(ConfirmationSelector, { toolName: confirmingTool.name, toolInput: confirmingTool.input, suggestedPrefix: confirmingTool.suggestedPrefix, hidePersistentOption: confirmingTool.hidePersistentOption, permissionMode: confirmingTool.permissionMode, isExpanded: isExpanded, onDecision: handleConfirmationDecision, onCancel: handleConfirmationCancel })] }))] }));
|
|
60
|
+
return (_jsxs(Box, { ref: chatInterfaceRef, flexDirection: "column", children: [_jsx(MessageList, { messages: displayMessages, isExpanded: isExpanded, version: version, workdir: workdir, forceStatic: forceStatic }, remountKey), !isConfirmationVisible && !isExpanded && (_jsxs(_Fragment, { children: [(isLoading || isCommandRunning || isCompacting) && (_jsx(LoadingIndicator, { isLoading: isLoading, isCommandRunning: isCommandRunning, isCompacting: isCompacting, latestTotalTokens: latestTotalTokens })), _jsx(TaskList, {}), _jsx(QueuedMessageList, {}), _jsx(InputBox, { isLoading: isLoading, isCommandRunning: isCommandRunning, isCompacting: isCompacting, sendMessage: sendMessage, abortMessage: abortMessage, mcpServers: mcpServers, connectMcpServer: connectMcpServer, disconnectMcpServer: disconnectMcpServer, slashCommands: slashCommands, hasSlashCommand: hasSlashCommand, latestTotalTokens: latestTotalTokens, maxInputTokens: maxInputTokens, showLoginHint: showLoginHint })] })), isConfirmationVisible && (_jsxs(_Fragment, { children: [forceStatic ? (_jsx(Static, { items: [{ key: "confirmation-details" }], children: () => (_jsx(ConfirmationDetails, { toolName: confirmingTool.name, toolInput: confirmingTool.input, planContent: confirmingTool.planContent, warning: confirmingTool.warning, isExpanded: isExpanded }, "confirmation-details")) })) : (_jsx(ConfirmationDetails, { toolName: confirmingTool.name, toolInput: confirmingTool.input, planContent: confirmingTool.planContent, warning: confirmingTool.warning, isExpanded: isExpanded })), _jsx(ConfirmationSelector, { toolName: confirmingTool.name, toolInput: confirmingTool.input, suggestedPrefix: confirmingTool.suggestedPrefix, hidePersistentOption: confirmingTool.hidePersistentOption, permissionMode: confirmingTool.permissionMode, isExpanded: isExpanded, onDecision: handleConfirmationDecision, onCancel: handleConfirmationCancel })] }))] }));
|
|
61
61
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from "ink";
|
|
3
|
-
import { BASH_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "wave-agent-sdk";
|
|
3
|
+
import { BASH_TOOL_NAME, EDIT_TOOL_NAME, WRITE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, ARTIFACT_TOOL_NAME, } from "wave-agent-sdk";
|
|
4
4
|
import { DiffDisplay } from "./DiffDisplay.js";
|
|
5
5
|
import { PlanDisplay } from "./PlanDisplay.js";
|
|
6
6
|
import { highlightToAnsi } from "../utils/highlightUtils.js";
|
|
@@ -22,14 +22,16 @@ const getActionDescription = (toolName, toolInput) => {
|
|
|
22
22
|
return "Enter plan mode for complex task planning";
|
|
23
23
|
case ASK_USER_QUESTION_TOOL_NAME:
|
|
24
24
|
return "Answer questions to clarify intent";
|
|
25
|
+
case ARTIFACT_TOOL_NAME:
|
|
26
|
+
return `Publish file: ${toolInput.file_path || "unknown file"}`;
|
|
25
27
|
default:
|
|
26
28
|
return "Execute operation";
|
|
27
29
|
}
|
|
28
30
|
};
|
|
29
|
-
export const ConfirmationDetails = ({ toolName, toolInput, planContent, isExpanded = false, }) => {
|
|
31
|
+
export const ConfirmationDetails = ({ toolName, toolInput, planContent, warning, isExpanded = false, }) => {
|
|
30
32
|
const startLineNumber = toolInput?.startLineNumber ??
|
|
31
33
|
(toolName === WRITE_TOOL_NAME ? 1 : undefined);
|
|
32
|
-
const content = (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "yellow", borderBottom: false, borderLeft: false, borderRight: false, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Tool: ", toolName] }), _jsx(Text, { color: "yellow", children: getActionDescription(toolName, toolInput) }), _jsx(DiffDisplay, { toolName: toolName, parameters: JSON.stringify(toolInput), startLineNumber: startLineNumber }), toolName !== WRITE_TOOL_NAME &&
|
|
34
|
+
const content = (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "yellow", borderBottom: false, borderLeft: false, borderRight: false, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Tool: ", toolName] }), _jsx(Text, { color: "yellow", children: getActionDescription(toolName, toolInput) }), warning && _jsxs(Text, { color: "red", children: ["\u26A0 ", warning] }), _jsx(DiffDisplay, { toolName: toolName, parameters: JSON.stringify(toolInput), startLineNumber: startLineNumber }), toolName !== WRITE_TOOL_NAME &&
|
|
33
35
|
toolName !== EDIT_TOOL_NAME &&
|
|
34
36
|
toolName !== EXIT_PLAN_MODE_TOOL_NAME &&
|
|
35
37
|
toolName !== ENTER_PLAN_MODE_TOOL_NAME &&
|
|
@@ -16,10 +16,17 @@ export const RewindCommand = ({ messages: initialMessages, onSelect, onCancel, g
|
|
|
16
16
|
}
|
|
17
17
|
}, [getFullMessageThread]);
|
|
18
18
|
// Filter user messages as checkpoints, excluding meta messages and
|
|
19
|
-
// system-generated user-role messages (task notifications, hook injections)
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
// system-generated user-role messages (task notifications, hook injections).
|
|
20
|
+
// Compaction is append-only: the same message id appears twice on the full
|
|
21
|
+
// thread (pre-compact history + post-compact append), so dedupe by id and
|
|
22
|
+
// keep the last occurrence (matching the folded view the user sees).
|
|
23
|
+
const checkpointMap = new Map();
|
|
24
|
+
messages.forEach((msg, index) => {
|
|
25
|
+
if (!isUserCheckpointMessage(msg))
|
|
26
|
+
return;
|
|
27
|
+
checkpointMap.set(msg.id ?? `index:${index}`, { msg, index });
|
|
28
|
+
});
|
|
29
|
+
const checkpoints = Array.from(checkpointMap.values());
|
|
23
30
|
const MAX_VISIBLE_ITEMS = 3;
|
|
24
31
|
const [state, dispatch] = useReducer(rewindSelectorReducer, {
|
|
25
32
|
selectedIndex: checkpoints.length - 1,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import type { Message, McpServerStatus, BackgroundTask, Task, SlashCommand, SubagentConfiguration, PermissionDecision, PermissionMode, QueuedMessage, WorkflowRun } from "wave-agent-sdk";
|
|
2
|
+
import type { Message, McpServerStatus, BackgroundTask, Task, SlashCommand, SubagentConfiguration, PermissionDecision, PermissionMode, QueuedMessage, WorkflowRun, ToolBlockUpdateCallbackParams } from "wave-agent-sdk";
|
|
3
3
|
import { BaseAppProps } from "../types.js";
|
|
4
4
|
export interface ChatContextType {
|
|
5
5
|
messages: Message[];
|
|
@@ -58,8 +58,9 @@ export interface ChatContextType {
|
|
|
58
58
|
hidePersistentOption?: boolean;
|
|
59
59
|
planContent?: string;
|
|
60
60
|
permissionMode?: PermissionMode;
|
|
61
|
+
warning?: string;
|
|
61
62
|
};
|
|
62
|
-
showConfirmation: (toolName: string, toolInput?: Record<string, unknown>, suggestedPrefix?: string, hidePersistentOption?: boolean, planContent?: string, permissionMode?: PermissionMode) => Promise<PermissionDecision>;
|
|
63
|
+
showConfirmation: (toolName: string, toolInput?: Record<string, unknown>, suggestedPrefix?: string, hidePersistentOption?: boolean, planContent?: string, permissionMode?: PermissionMode, warning?: string) => Promise<PermissionDecision>;
|
|
63
64
|
hideConfirmation: () => void;
|
|
64
65
|
handleConfirmationDecision: (decision: PermissionDecision) => void;
|
|
65
66
|
handleConfirmationCancel: () => void;
|
|
@@ -83,4 +84,18 @@ export declare const useChat: () => ChatContextType;
|
|
|
83
84
|
export interface ChatProviderProps extends BaseAppProps {
|
|
84
85
|
children: React.ReactNode;
|
|
85
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Per-tool window-concat throttle for pure-delta tool parameter streaming:
|
|
89
|
+
* `parametersChunk` deltas are accumulated independently per tool block id
|
|
90
|
+
* within the cooldown window, so interleaved multi-tool streams lose no delta
|
|
91
|
+
* (a plain throttle's single last-args slot would drop every earlier tool's
|
|
92
|
+
* deltas, leaving the first tool without streaming parameters). `start` /
|
|
93
|
+
* `running` apply immediately (one-shot snapshots); `end` flushes pending
|
|
94
|
+
* streaming deltas first, then applies the authoritative parameters/result.
|
|
95
|
+
*/
|
|
96
|
+
export declare function createToolStreamingThrottle(fn: (params: ToolBlockUpdateCallbackParams) => void, wait: number): {
|
|
97
|
+
(params: ToolBlockUpdateCallbackParams): void;
|
|
98
|
+
cancel: () => void;
|
|
99
|
+
flush: () => void;
|
|
100
|
+
};
|
|
86
101
|
export declare const ChatProvider: React.FC<ChatProviderProps>;
|
package/dist/contexts/useChat.js
CHANGED
|
@@ -4,7 +4,6 @@ import { useInput, useStdout } from "ink";
|
|
|
4
4
|
import { useAppConfig } from "./useAppConfig.js";
|
|
5
5
|
import { Agent, OPERATION_CANCELLED_BY_USER, extractLatestTotalTokens, } from "wave-agent-sdk";
|
|
6
6
|
import { logger } from "../utils/logger.js";
|
|
7
|
-
import { throttle } from "../utils/throttle.js";
|
|
8
7
|
import { displayUsageSummary } from "../utils/usageSummary.js";
|
|
9
8
|
import { expandLongTextPlaceholders } from "../managers/inputHandlers.js";
|
|
10
9
|
const ChatContext = createContext(null);
|
|
@@ -75,6 +74,86 @@ function createStreamingWindowThrottle(fn, wait) {
|
|
|
75
74
|
};
|
|
76
75
|
return throttled;
|
|
77
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* Per-tool window-concat throttle for pure-delta tool parameter streaming:
|
|
79
|
+
* `parametersChunk` deltas are accumulated independently per tool block id
|
|
80
|
+
* within the cooldown window, so interleaved multi-tool streams lose no delta
|
|
81
|
+
* (a plain throttle's single last-args slot would drop every earlier tool's
|
|
82
|
+
* deltas, leaving the first tool without streaming parameters). `start` /
|
|
83
|
+
* `running` apply immediately (one-shot snapshots); `end` flushes pending
|
|
84
|
+
* streaming deltas first, then applies the authoritative parameters/result.
|
|
85
|
+
*/
|
|
86
|
+
export function createToolStreamingThrottle(fn, wait) {
|
|
87
|
+
let timer = null;
|
|
88
|
+
let pending = null;
|
|
89
|
+
const fire = () => {
|
|
90
|
+
if (pending && pending.chunks.size > 0) {
|
|
91
|
+
const { messageId, chunks } = pending;
|
|
92
|
+
pending = null;
|
|
93
|
+
for (const [id, chunk] of chunks) {
|
|
94
|
+
fn({ messageId, id, parametersChunk: chunk, stage: "streaming" });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
const throttled = (params) => {
|
|
99
|
+
if (params.stage === "end") {
|
|
100
|
+
// Flush any deltas still pending inside the cooldown window first
|
|
101
|
+
if (timer) {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
timer = null;
|
|
104
|
+
}
|
|
105
|
+
fire();
|
|
106
|
+
fn(params);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (params.stage === "streaming") {
|
|
110
|
+
if (!pending) {
|
|
111
|
+
pending = { messageId: params.messageId, chunks: new Map() };
|
|
112
|
+
}
|
|
113
|
+
const prev = pending.chunks.get(params.id) || "";
|
|
114
|
+
pending.chunks.set(params.id, prev + (params.parametersChunk || ""));
|
|
115
|
+
if (!timer) {
|
|
116
|
+
timer = setTimeout(() => {
|
|
117
|
+
timer = null;
|
|
118
|
+
fire();
|
|
119
|
+
}, wait);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
// start / running — one-shot snapshots applied immediately. Drop this
|
|
124
|
+
// tool's buffered streaming deltas first: start/running carry the
|
|
125
|
+
// authoritative parameters, and a pending timer would otherwise fire late
|
|
126
|
+
// with a stale `streaming` event, regressing this tool block's stage back
|
|
127
|
+
// to streaming (yellow dot -> gray) mid-execution. Other tools' in-flight
|
|
128
|
+
// chunks are kept so interleaved multi-tool streaming still accumulates.
|
|
129
|
+
if (pending) {
|
|
130
|
+
pending.chunks.delete(params.id);
|
|
131
|
+
if (pending.chunks.size === 0) {
|
|
132
|
+
pending = null;
|
|
133
|
+
if (timer) {
|
|
134
|
+
clearTimeout(timer);
|
|
135
|
+
timer = null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
fn(params);
|
|
140
|
+
};
|
|
141
|
+
throttled.cancel = () => {
|
|
142
|
+
if (timer) {
|
|
143
|
+
clearTimeout(timer);
|
|
144
|
+
timer = null;
|
|
145
|
+
}
|
|
146
|
+
pending = null;
|
|
147
|
+
};
|
|
148
|
+
throttled.flush = () => {
|
|
149
|
+
if (timer) {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
timer = null;
|
|
152
|
+
}
|
|
153
|
+
fire();
|
|
154
|
+
};
|
|
155
|
+
return throttled;
|
|
156
|
+
}
|
|
78
157
|
export const ChatProvider = ({ children, bypassPermissions, permissionMode: initialPermissionMode, pluginDirs, additionalDirectories, tools, allowedTools, disallowedTools, workdir, worktreeSession, originalCwd, version, model, mcpServers, }) => {
|
|
79
158
|
const { restoreSessionId, continueLastSession } = useAppConfig();
|
|
80
159
|
const { stdout } = useStdout();
|
|
@@ -142,8 +221,8 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
142
221
|
};
|
|
143
222
|
}));
|
|
144
223
|
}, 500), []);
|
|
145
|
-
const throttledToolBlockUpdate = useMemo(() =>
|
|
146
|
-
const { messageId, id: toolBlockId, ...updates } = params;
|
|
224
|
+
const throttledToolBlockUpdate = useMemo(() => createToolStreamingThrottle((params) => {
|
|
225
|
+
const { messageId, id: toolBlockId, parametersChunk, ...updates } = params;
|
|
147
226
|
setMessages((prev) => prev.map((m) => {
|
|
148
227
|
if (m.id !== messageId)
|
|
149
228
|
return m;
|
|
@@ -158,7 +237,7 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
158
237
|
id: toolBlockId,
|
|
159
238
|
name: updates.name || "",
|
|
160
239
|
stage: updates.stage || "start",
|
|
161
|
-
parameters: updates.parameters || "",
|
|
240
|
+
parameters: (updates.parameters || "") + (parametersChunk || ""),
|
|
162
241
|
result: updates.result || "",
|
|
163
242
|
...updates,
|
|
164
243
|
},
|
|
@@ -168,7 +247,18 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
168
247
|
return {
|
|
169
248
|
...m,
|
|
170
249
|
blocks: m.blocks.map((b, idx) => idx === toolBlockIndex && b.type === "tool"
|
|
171
|
-
? {
|
|
250
|
+
? {
|
|
251
|
+
...b,
|
|
252
|
+
...updates,
|
|
253
|
+
// Streaming carries only the delta; append it to the
|
|
254
|
+
// accumulated parameters. start/running/end carry the
|
|
255
|
+
// authoritative value and replace wholesale.
|
|
256
|
+
parameters: parametersChunk
|
|
257
|
+
? (b.parameters || "") + parametersChunk
|
|
258
|
+
: updates.parameters !== undefined
|
|
259
|
+
? updates.parameters
|
|
260
|
+
: b.parameters,
|
|
261
|
+
}
|
|
172
262
|
: b),
|
|
173
263
|
};
|
|
174
264
|
}));
|
|
@@ -238,7 +328,7 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
238
328
|
}
|
|
239
329
|
}, []);
|
|
240
330
|
// Permission confirmation methods with queue support
|
|
241
|
-
const showConfirmation = useCallback(async (toolName, toolInput, suggestedPrefix, hidePersistentOption, planContent, permissionMode) => {
|
|
331
|
+
const showConfirmation = useCallback(async (toolName, toolInput, suggestedPrefix, hidePersistentOption, planContent, permissionMode, warning) => {
|
|
242
332
|
return new Promise((resolve, reject) => {
|
|
243
333
|
const queueItem = {
|
|
244
334
|
toolName,
|
|
@@ -247,6 +337,7 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
247
337
|
hidePersistentOption,
|
|
248
338
|
planContent,
|
|
249
339
|
permissionMode,
|
|
340
|
+
warning,
|
|
250
341
|
resolver: resolve,
|
|
251
342
|
reject,
|
|
252
343
|
};
|
|
@@ -426,7 +517,7 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
426
517
|
// Create the permission callback inside the try block to access showConfirmation
|
|
427
518
|
const permissionCallback = async (context) => {
|
|
428
519
|
try {
|
|
429
|
-
return await showConfirmation(context.toolName, context.toolInput, context.suggestedPrefix, context.hidePersistentOption, context.planContent, context.permissionMode);
|
|
520
|
+
return await showConfirmation(context.toolName, context.toolInput, context.suggestedPrefix, context.hidePersistentOption, context.planContent, context.permissionMode, context.warning);
|
|
430
521
|
}
|
|
431
522
|
catch {
|
|
432
523
|
// If confirmation was cancelled or failed, deny the operation
|
|
@@ -718,6 +809,7 @@ export const ChatProvider = ({ children, bypassPermissions, permissionMode: init
|
|
|
718
809
|
hidePersistentOption: next.hidePersistentOption,
|
|
719
810
|
planContent: next.planContent,
|
|
720
811
|
permissionMode: next.permissionMode,
|
|
812
|
+
warning: next.warning,
|
|
721
813
|
});
|
|
722
814
|
setIsConfirmationVisible(true);
|
|
723
815
|
setConfirmationQueue((prev) => prev.slice(1));
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wave daemon` client subcommands — talk to the wave daemon's unix socket
|
|
3
|
+
* (JSON-RPC over newline-delimited JSON) to list hosted sessions, inspect
|
|
4
|
+
* progress, inject messages and respond to pending permission requests.
|
|
5
|
+
*
|
|
6
|
+
* All subcommands are non-interactive: results go to stdout, diagnostics to
|
|
7
|
+
* stderr, and every handler calls process.exit() itself (yargs would fall
|
|
8
|
+
* through to the TUI otherwise). Every command connects to the fixed default
|
|
9
|
+
* socket `~/.wave/daemon.sock` — the daemon only runs on remote hosts, so no
|
|
10
|
+
* `--socket` override is offered (spec: daemon-command.md).
|
|
11
|
+
*
|
|
12
|
+
* Attach semantics: `initialize {workdir, restoreSessionId}` + `restoreSession`
|
|
13
|
+
* re-attach to a live session in the daemon's in-memory registry, or reload a
|
|
14
|
+
* transcript from disk under the current working directory. A session that is
|
|
15
|
+
* nowhere (live registry or disk) silently starts a FRESH session under a
|
|
16
|
+
* different id — the only reliable existence check is the `restoreSession`
|
|
17
|
+
* rejection ("Session not found: <id>"), after which the junk fresh session
|
|
18
|
+
* must be destroyed via the envelope sessionId returned by `initialize`.
|
|
19
|
+
*/
|
|
20
|
+
/** Fixed default daemon socket (spec: 默认 socket 固定,无 --socket 覆盖). */
|
|
21
|
+
export declare const DEFAULT_DAEMON_SOCKET: string;
|
|
22
|
+
export declare function daemonListCommand(socketPath: string): Promise<void>;
|
|
23
|
+
export declare function daemonStatusCommand(socketPath: string, sessionId: string, lines?: number): Promise<void>;
|
|
24
|
+
export interface SendOptions {
|
|
25
|
+
timeout: number;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Send a message and wait for the reply that corresponds to it.
|
|
29
|
+
*
|
|
30
|
+
* Completion detection: `sendMessage` on an idle session resolves only after
|
|
31
|
+
* the whole turn finishes (InteractionService awaits sendAIMessage), while on a
|
|
32
|
+
* busy session it enqueues and returns immediately — so stopping on a bare
|
|
33
|
+
* `loadingChange:false` would exit early on the PREVIOUS turn's completion when
|
|
34
|
+
* queued behind a busy session. Instead, track the message IDs: `ourUserMessage`
|
|
35
|
+
* is the user message added when OUR turn starts (userMessageAdded), and the
|
|
36
|
+
* reply is the last assistantMessageAdded observed after it. A stale
|
|
37
|
+
* loading:false can then never satisfy the wait condition early (the reply has
|
|
38
|
+
* not been added yet).
|
|
39
|
+
*/
|
|
40
|
+
export declare function daemonSendCommand(socketPath: string, sessionId: string, message: string, options?: SendOptions): Promise<void>;
|
|
41
|
+
export interface RespondOptions {
|
|
42
|
+
allow?: boolean;
|
|
43
|
+
deny?: boolean;
|
|
44
|
+
reason?: string;
|
|
45
|
+
answer?: string;
|
|
46
|
+
rule?: string;
|
|
47
|
+
mode?: string;
|
|
48
|
+
}
|
|
49
|
+
export declare function daemonRespondCommand(socketPath: string, sessionId: string, requestId: string, options: RespondOptions): Promise<void>;
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `wave daemon` client subcommands — talk to the wave daemon's unix socket
|
|
3
|
+
* (JSON-RPC over newline-delimited JSON) to list hosted sessions, inspect
|
|
4
|
+
* progress, inject messages and respond to pending permission requests.
|
|
5
|
+
*
|
|
6
|
+
* All subcommands are non-interactive: results go to stdout, diagnostics to
|
|
7
|
+
* stderr, and every handler calls process.exit() itself (yargs would fall
|
|
8
|
+
* through to the TUI otherwise). Every command connects to the fixed default
|
|
9
|
+
* socket `~/.wave/daemon.sock` — the daemon only runs on remote hosts, so no
|
|
10
|
+
* `--socket` override is offered (spec: daemon-command.md).
|
|
11
|
+
*
|
|
12
|
+
* Attach semantics: `initialize {workdir, restoreSessionId}` + `restoreSession`
|
|
13
|
+
* re-attach to a live session in the daemon's in-memory registry, or reload a
|
|
14
|
+
* transcript from disk under the current working directory. A session that is
|
|
15
|
+
* nowhere (live registry or disk) silently starts a FRESH session under a
|
|
16
|
+
* different id — the only reliable existence check is the `restoreSession`
|
|
17
|
+
* rejection ("Session not found: <id>"), after which the junk fresh session
|
|
18
|
+
* must be destroyed via the envelope sessionId returned by `initialize`.
|
|
19
|
+
*/
|
|
20
|
+
import net from "node:net";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { ASK_USER_QUESTION_TOOL_NAME, ENTER_PLAN_MODE_TOOL_NAME, EXIT_PLAN_MODE_TOOL_NAME, getMessageContent, } from "wave-agent-sdk";
|
|
24
|
+
import { SocketClient } from "./socketClient.js";
|
|
25
|
+
/** Fixed default daemon socket (spec: 默认 socket 固定,无 --socket 覆盖). */
|
|
26
|
+
export const DEFAULT_DAEMON_SOCKET = path.join(os.homedir(), ".wave", "daemon.sock");
|
|
27
|
+
const PERMISSION_MODES = [
|
|
28
|
+
"default",
|
|
29
|
+
"bypassPermissions",
|
|
30
|
+
"acceptEdits",
|
|
31
|
+
"plan",
|
|
32
|
+
"dontAsk",
|
|
33
|
+
];
|
|
34
|
+
// ── Connection helpers ─────────────────────────────────────────
|
|
35
|
+
function connectDaemon(socketPath) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
const socket = net.createConnection(socketPath);
|
|
38
|
+
socket.once("connect", () => resolve(new SocketClient(socket)));
|
|
39
|
+
socket.once("error", (err) => {
|
|
40
|
+
socket.destroy();
|
|
41
|
+
reject(err);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Connect or fail fast with the spec'd error; daemon idle-exits after 60s. */
|
|
46
|
+
async function connectDaemonOrExit(socketPath) {
|
|
47
|
+
try {
|
|
48
|
+
return await connectDaemon(socketPath);
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
const code = err.code;
|
|
52
|
+
console.error(`无法连接 daemon socket ${socketPath}:daemon 未运行?(daemon 空闲 60 秒自动退出)` +
|
|
53
|
+
(code ? ` (${code})` : ""));
|
|
54
|
+
process.exit(1);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
function fail(message) {
|
|
58
|
+
console.error(message);
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Attach to a session; returns the initialized sessionId + working directory.
|
|
63
|
+
* Exits (nonzero) with the spec'd error when the session exists neither in the
|
|
64
|
+
* daemon registry nor on disk, destroying the fresh session that `initialize`
|
|
65
|
+
* silently created.
|
|
66
|
+
*/
|
|
67
|
+
async function attachSession(client, sessionId) {
|
|
68
|
+
const init = (await client.request("initialize", {
|
|
69
|
+
workdir: process.cwd(),
|
|
70
|
+
restoreSessionId: sessionId,
|
|
71
|
+
}));
|
|
72
|
+
const initId = init.sessionId;
|
|
73
|
+
try {
|
|
74
|
+
await client.request("restoreSession", { sessionId }, initId);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
if (err.message.includes("Session not found")) {
|
|
78
|
+
// initialize silently started a junk fresh session — remove it from the
|
|
79
|
+
// registry so the failed attach leaves no trace (spec: 会话不存在错误).
|
|
80
|
+
await client.request("destroy", undefined, initId).catch(() => { });
|
|
81
|
+
fail(`会话不存在或未被该 daemon 托管:${sessionId}`);
|
|
82
|
+
}
|
|
83
|
+
throw err;
|
|
84
|
+
}
|
|
85
|
+
return init;
|
|
86
|
+
}
|
|
87
|
+
async function listPendingPermissions(client) {
|
|
88
|
+
const result = (await client.request("listPendingPermissions"));
|
|
89
|
+
return result.requests ?? [];
|
|
90
|
+
}
|
|
91
|
+
function sleep(ms) {
|
|
92
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
93
|
+
}
|
|
94
|
+
// ── list ───────────────────────────────────────────────────────
|
|
95
|
+
export async function daemonListCommand(socketPath) {
|
|
96
|
+
let client;
|
|
97
|
+
try {
|
|
98
|
+
client = await connectDaemonOrExit(socketPath);
|
|
99
|
+
const result = (await client.request("listDaemonSessions"));
|
|
100
|
+
const sessions = result.sessions ?? [];
|
|
101
|
+
if (sessions.length > 0) {
|
|
102
|
+
const rows = sessions.map((s) => ({
|
|
103
|
+
sessionId: s.sessionId,
|
|
104
|
+
status: s.isLoading ? "生成中" : "空闲",
|
|
105
|
+
messageCount: String(s.messageCount),
|
|
106
|
+
workingDirectory: s.workingDirectory,
|
|
107
|
+
}));
|
|
108
|
+
const width = (key) => Math.max(...rows.map((r) => r[key].length), key.length);
|
|
109
|
+
const pad = (value, w) => value.padEnd(w);
|
|
110
|
+
console.log(`${pad("会话", width("sessionId"))} ${pad("状态", width("status"))} ${pad("消息数", width("messageCount"))} 工作目录`);
|
|
111
|
+
for (const r of rows) {
|
|
112
|
+
console.log(`${pad(r.sessionId, width("sessionId"))} ${pad(r.status, width("status"))} ${pad(r.messageCount, width("messageCount"))} ${r.workingDirectory}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
// Daemon idle-exit is normal — an empty registry is not an error.
|
|
117
|
+
console.log("无会话");
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
fail(`wave daemon list 失败:${err.message}`);
|
|
122
|
+
}
|
|
123
|
+
finally {
|
|
124
|
+
await client?.dispose();
|
|
125
|
+
}
|
|
126
|
+
// Exits outside the try so the success path's exit is never re-wrapped by the
|
|
127
|
+
// error handler above.
|
|
128
|
+
process.exit(0);
|
|
129
|
+
}
|
|
130
|
+
// ── status ─────────────────────────────────────────────────────
|
|
131
|
+
function summarizeToolInput(context) {
|
|
132
|
+
const input = context.toolInput;
|
|
133
|
+
if (!input || Object.keys(input).length === 0)
|
|
134
|
+
return "";
|
|
135
|
+
let text;
|
|
136
|
+
try {
|
|
137
|
+
text = JSON.stringify(input);
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
text = "";
|
|
141
|
+
}
|
|
142
|
+
return text.length > 80 ? `${text.slice(0, 80)}…` : text;
|
|
143
|
+
}
|
|
144
|
+
export async function daemonStatusCommand(socketPath, sessionId, lines = 20) {
|
|
145
|
+
let client;
|
|
146
|
+
try {
|
|
147
|
+
client = await connectDaemonOrExit(socketPath);
|
|
148
|
+
// Subscribe BEFORE initialize/restoreSession so the replayed loadingChange
|
|
149
|
+
// snapshot is captured (spec: 依据重放的 loadingChange 快照显示状态).
|
|
150
|
+
let loading = false;
|
|
151
|
+
client.onNotification("loadingChange", (params) => {
|
|
152
|
+
loading = params.loading;
|
|
153
|
+
});
|
|
154
|
+
const init = await attachSession(client, sessionId);
|
|
155
|
+
const initId = init.sessionId;
|
|
156
|
+
// listPendingPermissions is the authoritative "waiting for approval" signal
|
|
157
|
+
// (spec: 单凭消息无法区分等审批与执行中,须结合 listPendingPermissions).
|
|
158
|
+
const pending = (await listPendingPermissions(client)).filter((r) => r.sessionId === initId || r.sessionId === sessionId);
|
|
159
|
+
const messages = (await client.request("getMessages", undefined, initId));
|
|
160
|
+
const status = pending.length > 0 ? "等待审批" : loading ? "生成中" : "空闲";
|
|
161
|
+
console.log(`会话: ${initId}`);
|
|
162
|
+
console.log(`工作目录: ${init.workingDirectory}`);
|
|
163
|
+
console.log(`状态: ${status}`);
|
|
164
|
+
if (pending.length > 0) {
|
|
165
|
+
console.log("");
|
|
166
|
+
console.log("待审批请求:");
|
|
167
|
+
for (const r of pending) {
|
|
168
|
+
const params = summarizeToolInput(r.context);
|
|
169
|
+
console.log(` ${r.requestId} ${r.context.toolName}${params ? ` ${params}` : ""}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const recent = messages.messages.slice(-lines);
|
|
173
|
+
if (recent.length > 0) {
|
|
174
|
+
console.log("");
|
|
175
|
+
console.log(`最近消息 (${recent.length}):`);
|
|
176
|
+
for (const m of recent) {
|
|
177
|
+
const text = getMessageContent(m).replace(/\s+/g, " ").trim();
|
|
178
|
+
if (!text)
|
|
179
|
+
continue; // tool-only messages carry no readable text
|
|
180
|
+
console.log(` [${m.role}] ${text}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
fail(`wave daemon status 失败:${err.message}`);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
await client?.dispose();
|
|
189
|
+
}
|
|
190
|
+
process.exit(0);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Send a message and wait for the reply that corresponds to it.
|
|
194
|
+
*
|
|
195
|
+
* Completion detection: `sendMessage` on an idle session resolves only after
|
|
196
|
+
* the whole turn finishes (InteractionService awaits sendAIMessage), while on a
|
|
197
|
+
* busy session it enqueues and returns immediately — so stopping on a bare
|
|
198
|
+
* `loadingChange:false` would exit early on the PREVIOUS turn's completion when
|
|
199
|
+
* queued behind a busy session. Instead, track the message IDs: `ourUserMessage`
|
|
200
|
+
* is the user message added when OUR turn starts (userMessageAdded), and the
|
|
201
|
+
* reply is the last assistantMessageAdded observed after it. A stale
|
|
202
|
+
* loading:false can then never satisfy the wait condition early (the reply has
|
|
203
|
+
* not been added yet).
|
|
204
|
+
*/
|
|
205
|
+
export async function daemonSendCommand(socketPath, sessionId, message, options = { timeout: 600 }) {
|
|
206
|
+
// connectDaemonOrExit exits on failure — no client to dispose in that case.
|
|
207
|
+
const client = await connectDaemonOrExit(socketPath);
|
|
208
|
+
let loading = false;
|
|
209
|
+
let sent = false;
|
|
210
|
+
let ourUserMessageId;
|
|
211
|
+
let replyMessageId;
|
|
212
|
+
client.onNotification("userMessageAdded", (params) => {
|
|
213
|
+
if (!sent)
|
|
214
|
+
return; // ignore messages added during attach
|
|
215
|
+
ourUserMessageId = params.message.id;
|
|
216
|
+
});
|
|
217
|
+
client.onNotification("assistantMessageAdded", (params) => {
|
|
218
|
+
if (!sent || ourUserMessageId === undefined)
|
|
219
|
+
return; // not our turn yet
|
|
220
|
+
replyMessageId = params.message.id;
|
|
221
|
+
});
|
|
222
|
+
client.onNotification("loadingChange", (params) => {
|
|
223
|
+
loading = params.loading;
|
|
224
|
+
});
|
|
225
|
+
let initId;
|
|
226
|
+
try {
|
|
227
|
+
initId = (await attachSession(client, sessionId)).sessionId;
|
|
228
|
+
sent = true;
|
|
229
|
+
await client.request("sendMessage", { text: message }, initId);
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
client.dispose();
|
|
233
|
+
fail(`wave daemon send 失败:${err.message}`);
|
|
234
|
+
}
|
|
235
|
+
// Wait for the reply that corresponds to our message.
|
|
236
|
+
const started = Date.now();
|
|
237
|
+
const timeoutMs = options.timeout === 0 ? Infinity : options.timeout * 1000;
|
|
238
|
+
while (!(loading === false && replyMessageId !== undefined)) {
|
|
239
|
+
if (Date.now() - started > timeoutMs) {
|
|
240
|
+
// Timeout backstop: the most likely cause is a session waiting on a
|
|
241
|
+
// permission approval — point the user at respond (spec: 不无限期挂起).
|
|
242
|
+
const pending = (await listPendingPermissions(client)).filter((r) => r.sessionId === sessionId || r.sessionId === initId);
|
|
243
|
+
client.dispose();
|
|
244
|
+
if (pending.length > 0) {
|
|
245
|
+
fail(`会话等待权限审批,请通过 \`wave daemon respond ${sessionId} ${pending[0].requestId}\` 处理后重试`);
|
|
246
|
+
}
|
|
247
|
+
fail(options.timeout === 0
|
|
248
|
+
? "等待回复超时"
|
|
249
|
+
: `等待回复超时(${options.timeout} 秒),未收到助手回复`);
|
|
250
|
+
}
|
|
251
|
+
await sleep(200);
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const result = (await client.request("getMessages", undefined, initId));
|
|
255
|
+
const reply = result.messages.find((m) => m.id === replyMessageId);
|
|
256
|
+
// Pure final-reply text only; streaming deltas / subagent internals never
|
|
257
|
+
// reach stdout (spec: send 输出纯净性).
|
|
258
|
+
if (reply) {
|
|
259
|
+
const content = getMessageContent(reply).replace(/\s+/g, " ").trim();
|
|
260
|
+
if (content)
|
|
261
|
+
console.log(content);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
fail(`wave daemon send 失败:${err.message}`);
|
|
266
|
+
}
|
|
267
|
+
finally {
|
|
268
|
+
client.dispose();
|
|
269
|
+
}
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
|
272
|
+
export async function daemonRespondCommand(socketPath, sessionId, requestId, options) {
|
|
273
|
+
if (!!options.allow === !!options.deny) {
|
|
274
|
+
fail("请指定 --allow 或 --deny(二选一)");
|
|
275
|
+
}
|
|
276
|
+
let client;
|
|
277
|
+
try {
|
|
278
|
+
client = await connectDaemonOrExit(socketPath);
|
|
279
|
+
// The server silently ignores permissionResponse for unknown requestIds —
|
|
280
|
+
// validate first so the user is never misled into thinking approval landed.
|
|
281
|
+
const pending = await listPendingPermissions(client);
|
|
282
|
+
const req = pending.find((r) => r.requestId === requestId);
|
|
283
|
+
if (!req) {
|
|
284
|
+
fail("该请求不存在或已处理");
|
|
285
|
+
}
|
|
286
|
+
if (req.sessionId && req.sessionId !== sessionId) {
|
|
287
|
+
// Cross-check before notifying; never touch another session's request.
|
|
288
|
+
fail("会话不存在或未被该 daemon 托管");
|
|
289
|
+
}
|
|
290
|
+
let decision;
|
|
291
|
+
if (options.deny) {
|
|
292
|
+
decision = { behavior: "deny", message: options.reason };
|
|
293
|
+
}
|
|
294
|
+
else {
|
|
295
|
+
// Per-tool auto-completion, mirroring the desktop ConfirmationDialog
|
|
296
|
+
// semantics (spec: 决策并非单一 allow/deny,须按工具智能补全).
|
|
297
|
+
const toolName = req.context.toolName;
|
|
298
|
+
if (toolName === ENTER_PLAN_MODE_TOOL_NAME) {
|
|
299
|
+
decision = { behavior: "allow", newPermissionMode: "plan" };
|
|
300
|
+
}
|
|
301
|
+
else if (toolName === EXIT_PLAN_MODE_TOOL_NAME) {
|
|
302
|
+
decision = { behavior: "allow", newPermissionMode: "default" };
|
|
303
|
+
}
|
|
304
|
+
else if (toolName === ASK_USER_QUESTION_TOOL_NAME) {
|
|
305
|
+
if (!options.answer) {
|
|
306
|
+
fail("AskUserQuestion 请求需要 --answer 提供答案 JSON");
|
|
307
|
+
}
|
|
308
|
+
let answers;
|
|
309
|
+
try {
|
|
310
|
+
answers = JSON.parse(options.answer);
|
|
311
|
+
}
|
|
312
|
+
catch {
|
|
313
|
+
fail("--answer 不是合法的 JSON");
|
|
314
|
+
}
|
|
315
|
+
decision = { behavior: "allow", message: JSON.stringify(answers) };
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
decision = { behavior: "allow" };
|
|
319
|
+
}
|
|
320
|
+
if (options.rule)
|
|
321
|
+
decision.newPermissionRule = options.rule;
|
|
322
|
+
if (options.mode) {
|
|
323
|
+
if (!PERMISSION_MODES.includes(options.mode)) {
|
|
324
|
+
fail(`无效的权限模式:${options.mode}(可选:${PERMISSION_MODES.join("、")})`);
|
|
325
|
+
}
|
|
326
|
+
decision.newPermissionMode = options.mode;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
// Mirror desktop stdioAgent.sendPermissionResponse: envelope sessionId
|
|
330
|
+
// present, decision built from the pending request's tool.
|
|
331
|
+
client.notify("permissionResponse", { requestId, decision }, sessionId);
|
|
332
|
+
console.log(`已处理审批请求:${requestId}`);
|
|
333
|
+
}
|
|
334
|
+
catch (err) {
|
|
335
|
+
fail(`wave daemon respond 失败:${err.message}`);
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
await client?.dispose();
|
|
339
|
+
}
|
|
340
|
+
process.exit(0);
|
|
341
|
+
}
|