wave-code 0.19.9 → 1.0.1
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/cli.js +20 -1
- package/dist/components/App.js +7 -0
- package/dist/components/BtwDisplay.js +13 -3
- package/dist/components/ChatInterface.js +21 -6
- package/dist/components/HelpView.js +6 -0
- package/dist/components/InputBox.d.ts +1 -2
- package/dist/components/InputBox.js +16 -5
- package/dist/components/LoadingIndicator.d.ts +1 -2
- package/dist/components/LoadingIndicator.js +2 -2
- package/dist/components/Markdown.js +13 -16
- package/dist/components/MessageList.d.ts +2 -1
- package/dist/components/MessageList.js +2 -2
- package/dist/components/RewindCommand.js +4 -2
- package/dist/components/StatusLine.d.ts +0 -2
- package/dist/components/StatusLine.js +6 -6
- package/dist/components/TaskList.js +2 -1
- package/dist/components/ToolDisplay.d.ts +1 -0
- package/dist/components/ToolDisplay.js +17 -9
- package/dist/constants/commands.js +0 -6
- package/dist/contexts/useChat.d.ts +3 -5
- package/dist/contexts/useChat.js +242 -82
- package/dist/daemon-cli.d.ts +10 -0
- package/dist/daemon-cli.js +15 -0
- package/dist/hooks/useInputManager.d.ts +1 -0
- package/dist/hooks/useInputManager.js +120 -40
- package/dist/index.js +10 -0
- package/dist/managers/inputHandlers.js +55 -30
- package/dist/managers/inputReducer.d.ts +22 -22
- package/dist/managers/inputReducer.js +361 -177
- package/dist/print-cli.js +36 -10
- package/dist/stdio/agentBridge.d.ts +23 -0
- package/dist/stdio/agentBridge.js +151 -18
- package/dist/stdio/daemonServer.d.ts +67 -0
- package/dist/stdio/daemonServer.js +191 -0
- package/dist/stdio/index.d.ts +2 -0
- package/dist/stdio/index.js +2 -0
- package/dist/stdio/jsonRpcConnection.d.ts +30 -0
- package/dist/stdio/jsonRpcConnection.js +127 -0
- package/dist/stdio/protocol.d.ts +2 -2
- package/dist/stdio/stdioServer.d.ts +2 -7
- package/dist/stdio/stdioServer.js +9 -100
- package/dist/utils/bracketedPaste.d.ts +39 -0
- package/dist/utils/bracketedPaste.js +122 -0
- package/dist/utils/markdownTable.d.ts +34 -0
- package/dist/utils/markdownTable.js +302 -0
- package/dist/utils/rewindCheckpoints.d.ts +8 -0
- package/dist/utils/rewindCheckpoints.js +15 -0
- package/dist/utils/throttle.d.ts +3 -3
- package/dist/utils/worktree.d.ts +8 -0
- package/dist/utils/worktree.js +32 -1
- package/package.json +4 -2
- package/src/cli.tsx +20 -1
- package/src/components/App.tsx +5 -0
- package/src/components/BtwDisplay.tsx +36 -12
- package/src/components/ChatInterface.tsx +26 -11
- package/src/components/HelpView.tsx +6 -0
- package/src/components/InputBox.tsx +21 -10
- package/src/components/LoadingIndicator.tsx +1 -4
- package/src/components/Markdown.tsx +15 -18
- package/src/components/MessageList.tsx +6 -0
- package/src/components/RewindCommand.tsx +4 -2
- package/src/components/StatusLine.tsx +0 -10
- package/src/components/TaskList.tsx +2 -1
- package/src/components/ToolDisplay.tsx +17 -6
- package/src/constants/commands.ts +0 -6
- package/src/contexts/useChat.tsx +310 -95
- package/src/daemon-cli.ts +17 -0
- package/src/hooks/useInputManager.ts +135 -43
- package/src/index.ts +12 -0
- package/src/managers/inputHandlers.ts +55 -32
- package/src/managers/inputReducer.ts +442 -214
- package/src/print-cli.ts +48 -11
- package/src/stdio/agentBridge.ts +213 -18
- package/src/stdio/daemonServer.ts +212 -0
- package/src/stdio/index.ts +2 -0
- package/src/stdio/jsonRpcConnection.ts +160 -0
- package/src/stdio/protocol.ts +5 -2
- package/src/stdio/stdioServer.ts +14 -120
- package/src/utils/bracketedPaste.ts +170 -0
- package/src/utils/markdownTable.ts +359 -0
- package/src/utils/rewindCheckpoints.ts +15 -0
- package/src/utils/throttle.ts +8 -8
- package/src/utils/worktree.ts +50 -1
package/dist/print-cli.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Agent, hasUncommittedChanges, hasNewCommits, getDefaultRemoteBranch, } from "wave-agent-sdk";
|
|
1
|
+
import { Agent, hasUncommittedChanges, hasNewCommits, getDefaultRemoteBranch, validateWorktreeRemovalPath, } from "wave-agent-sdk";
|
|
2
2
|
import { displayUsageSummary } from "./utils/usageSummary.js";
|
|
3
3
|
import { removeWorktree } from "./utils/worktree.js";
|
|
4
4
|
function displayTimingInfo(startTime, showStats) {
|
|
@@ -129,21 +129,35 @@ export async function startPrintCli(options) {
|
|
|
129
129
|
}
|
|
130
130
|
// Display timing information
|
|
131
131
|
displayTimingInfo(startTime, showStats);
|
|
132
|
-
//
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
// Trigger WorktreeRemove hook (before destroy — it needs a live agent) and
|
|
133
|
+
// decide whether the worktree is clean enough to remove
|
|
134
|
+
let cleanWorktree = false;
|
|
135
135
|
if (worktreeSession) {
|
|
136
136
|
const cwd = workdir || worktreeSession.path;
|
|
137
137
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
138
138
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
139
139
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
140
|
-
|
|
141
|
-
|
|
140
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
141
|
+
if (cleanWorktree) {
|
|
142
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
142
143
|
}
|
|
143
144
|
else {
|
|
144
145
|
process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
|
|
145
146
|
}
|
|
146
147
|
}
|
|
148
|
+
// Destroy agent and exit after sendMessage completes
|
|
149
|
+
await agent.destroy();
|
|
150
|
+
// Handle worktree cleanup for print mode (git removal stays after destroy)
|
|
151
|
+
if (worktreeSession && cleanWorktree) {
|
|
152
|
+
try {
|
|
153
|
+
validateWorktreeRemovalPath(worktreeSession.path, worktreeSession.repoRoot);
|
|
154
|
+
await removeWorktree(worktreeSession);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
// Never block print-mode exit on worktree cleanup failures
|
|
158
|
+
process.stdout.write(`\n⚠️ Skipping worktree removal: ${error.message}\n`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
147
161
|
process.exit(0);
|
|
148
162
|
}
|
|
149
163
|
catch (error) {
|
|
@@ -162,20 +176,32 @@ export async function startPrintCli(options) {
|
|
|
162
176
|
}
|
|
163
177
|
// Display timing information even on error
|
|
164
178
|
displayTimingInfo(startTime, showStats);
|
|
165
|
-
|
|
166
|
-
|
|
179
|
+
// Trigger WorktreeRemove hook (before destroy) when the worktree is clean
|
|
180
|
+
let cleanWorktree = false;
|
|
167
181
|
if (worktreeSession) {
|
|
168
182
|
const cwd = workdir || worktreeSession.path;
|
|
169
183
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
170
184
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
171
185
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
187
|
+
if (cleanWorktree) {
|
|
188
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
174
189
|
}
|
|
175
190
|
else {
|
|
176
191
|
process.stdout.write(`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`);
|
|
177
192
|
}
|
|
178
193
|
}
|
|
194
|
+
await agent.destroy();
|
|
195
|
+
// Handle worktree cleanup for print mode even on error
|
|
196
|
+
if (worktreeSession && cleanWorktree) {
|
|
197
|
+
try {
|
|
198
|
+
validateWorktreeRemovalPath(worktreeSession.path, worktreeSession.repoRoot);
|
|
199
|
+
await removeWorktree(worktreeSession);
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
process.stdout.write(`\n⚠️ Skipping worktree removal: ${error.message}\n`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
179
205
|
}
|
|
180
206
|
process.exit(1);
|
|
181
207
|
}
|
|
@@ -21,6 +21,9 @@ export interface AgentBridgeOptions {
|
|
|
21
21
|
}
|
|
22
22
|
export declare class AgentBridge {
|
|
23
23
|
private sessions;
|
|
24
|
+
/** Pending approval requests, keyed by requestId. Stored with the resolve +
|
|
25
|
+
* context so a re-attached client can list and respond to them (daemon mode:
|
|
26
|
+
* approvals outlive any single connection). */
|
|
24
27
|
private pendingPermissions;
|
|
25
28
|
private permissionCounter;
|
|
26
29
|
private emit;
|
|
@@ -31,6 +34,21 @@ export declare class AgentBridge {
|
|
|
31
34
|
handleNotification(method: string, params: unknown): void;
|
|
32
35
|
private initialize;
|
|
33
36
|
private destroy;
|
|
37
|
+
/**
|
|
38
|
+
* True when every hosted session has settled: not generating, nothing queued,
|
|
39
|
+
* and no background work (background bash / subagents / workflows) — the same
|
|
40
|
+
* condition `wave -p` waits on before exiting (print-cli.ts). Pending
|
|
41
|
+
* permission approvals keep the owning agent's isLoading true, so they are
|
|
42
|
+
* covered without an explicit check.
|
|
43
|
+
*/
|
|
44
|
+
isIdle(): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Destroy every hosted session agent. Each Agent.destroy() saves its
|
|
47
|
+
* transcript, drains in-flight auto-memory extraction, and cleans up
|
|
48
|
+
* background tasks/subagents. Best-effort: one failing destroy must not
|
|
49
|
+
* block the rest of the shutdown.
|
|
50
|
+
*/
|
|
51
|
+
destroyAll(): Promise<void>;
|
|
34
52
|
private restoreSession;
|
|
35
53
|
private listSessions;
|
|
36
54
|
private listGitBranches;
|
|
@@ -40,6 +58,7 @@ export declare class AgentBridge {
|
|
|
40
58
|
private updateConfig;
|
|
41
59
|
private sendMessage;
|
|
42
60
|
private bang;
|
|
61
|
+
private askBtw;
|
|
43
62
|
private abortMessage;
|
|
44
63
|
private clearMessages;
|
|
45
64
|
private rewindToMessage;
|
|
@@ -64,6 +83,10 @@ export declare class AgentBridge {
|
|
|
64
83
|
private getPromptHistory;
|
|
65
84
|
private searchPromptHistory;
|
|
66
85
|
private canUseTool;
|
|
86
|
+
/** Attach snapshot: re-surface approvals that are still pending after a
|
|
87
|
+
* client disconnected (daemon mode). Responding to any listed requestId
|
|
88
|
+
* resolves the in-process promise. */
|
|
89
|
+
private listPendingPermissions;
|
|
67
90
|
private getAuthStatus;
|
|
68
91
|
private login;
|
|
69
92
|
private logout;
|
|
@@ -14,14 +14,18 @@
|
|
|
14
14
|
* - Implement the canUseTool permission flow over the stdio protocol
|
|
15
15
|
* - Handle config updates by destroying and recreating the Agent
|
|
16
16
|
*/
|
|
17
|
-
import { Agent, listSessions, searchFiles, generateRandomName, getDefaultRemoteBranch, getMessageContent, PromptHistoryManager, AuthService, PluginCore, } from "wave-agent-sdk";
|
|
17
|
+
import { Agent, listSessions, searchFiles, generateRandomName, getDefaultRemoteBranch, getMessageContent, PromptHistoryManager, AuthService, PluginCore, validateWorktreeRemovalPath, } from "wave-agent-sdk";
|
|
18
18
|
import { INTERNAL_ERROR as PROTOCOL_INTERNAL_ERROR, METHOD_NOT_FOUND as PROTOCOL_METHOD_NOT_FOUND, } from "./protocol.js";
|
|
19
19
|
import { execFileSync } from "node:child_process";
|
|
20
20
|
import { createWorktree, removeWorktree } from "../utils/worktree.js";
|
|
21
21
|
import { logger } from "../utils/logger.js";
|
|
22
|
+
import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
|
|
22
23
|
export class AgentBridge {
|
|
23
24
|
constructor(options) {
|
|
24
25
|
this.sessions = new Map();
|
|
26
|
+
/** Pending approval requests, keyed by requestId. Stored with the resolve +
|
|
27
|
+
* context so a re-attached client can list and respond to them (daemon mode:
|
|
28
|
+
* approvals outlive any single connection). */
|
|
25
29
|
this.pendingPermissions = new Map();
|
|
26
30
|
this.permissionCounter = 0;
|
|
27
31
|
this.emit = options.emit;
|
|
@@ -41,6 +45,8 @@ export class AgentBridge {
|
|
|
41
45
|
return this.listSessions(p.workdir, sessionId);
|
|
42
46
|
case "getSessionInfo":
|
|
43
47
|
return this.getSessionInfo(sessionId);
|
|
48
|
+
case "listPendingPermissions":
|
|
49
|
+
return this.listPendingPermissions();
|
|
44
50
|
case "updateConfig":
|
|
45
51
|
return this.updateConfig(p, sessionId);
|
|
46
52
|
// ── Messages ──
|
|
@@ -48,6 +54,8 @@ export class AgentBridge {
|
|
|
48
54
|
return this.sendMessage(p, sessionId);
|
|
49
55
|
case "bang":
|
|
50
56
|
return this.bang(p.command, sessionId);
|
|
57
|
+
case "askBtw":
|
|
58
|
+
return this.askBtw(p.question, sessionId);
|
|
51
59
|
case "abortMessage":
|
|
52
60
|
return this.abortMessage(sessionId);
|
|
53
61
|
case "clearMessages":
|
|
@@ -146,15 +154,29 @@ export class AgentBridge {
|
|
|
146
154
|
if (method === "permissionResponse") {
|
|
147
155
|
const p = params;
|
|
148
156
|
// requestId is process-level unique; lookup doesn't need sessionId
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
157
|
+
const entry = this.pendingPermissions.get(p.requestId);
|
|
158
|
+
if (entry) {
|
|
151
159
|
this.pendingPermissions.delete(p.requestId);
|
|
152
|
-
resolve(p.decision);
|
|
160
|
+
entry.resolve(p.decision);
|
|
153
161
|
}
|
|
154
162
|
}
|
|
155
163
|
}
|
|
156
164
|
// ── Lifecycle ─────────────────────────────────────────────────
|
|
157
165
|
async initialize(params) {
|
|
166
|
+
// Re-attach (daemon mode): if the target session is already live in this
|
|
167
|
+
// process, reuse it instead of creating a second agent writing to the same
|
|
168
|
+
// transcript. The live agent keeps running across client detach/attach.
|
|
169
|
+
if (params.restoreSessionId) {
|
|
170
|
+
const live = this.sessions.get(params.restoreSessionId);
|
|
171
|
+
if (live) {
|
|
172
|
+
return {
|
|
173
|
+
sessionId: live.agent.sessionId,
|
|
174
|
+
workingDirectory: live.agent.workingDirectory,
|
|
175
|
+
permissionMode: live.agent.getPermissionMode(),
|
|
176
|
+
latestTotalTokens: live.agent.latestTotalTokens,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
}
|
|
158
180
|
const ctx = {};
|
|
159
181
|
const callbacks = this.createCallbacks(ctx);
|
|
160
182
|
const options = {
|
|
@@ -202,8 +224,53 @@ export class AgentBridge {
|
|
|
202
224
|
}
|
|
203
225
|
return null;
|
|
204
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* True when every hosted session has settled: not generating, nothing queued,
|
|
229
|
+
* and no background work (background bash / subagents / workflows) — the same
|
|
230
|
+
* condition `wave -p` waits on before exiting (print-cli.ts). Pending
|
|
231
|
+
* permission approvals keep the owning agent's isLoading true, so they are
|
|
232
|
+
* covered without an explicit check.
|
|
233
|
+
*/
|
|
234
|
+
isIdle() {
|
|
235
|
+
for (const entry of this.sessions.values()) {
|
|
236
|
+
const agent = entry.agent;
|
|
237
|
+
if (agent.isLoading ||
|
|
238
|
+
agent.hasPendingMessages ||
|
|
239
|
+
agent.hasRunningBackgroundWork) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Destroy every hosted session agent. Each Agent.destroy() saves its
|
|
247
|
+
* transcript, drains in-flight auto-memory extraction, and cleans up
|
|
248
|
+
* background tasks/subagents. Best-effort: one failing destroy must not
|
|
249
|
+
* block the rest of the shutdown.
|
|
250
|
+
*/
|
|
251
|
+
async destroyAll() {
|
|
252
|
+
const entries = [...this.sessions.values()];
|
|
253
|
+
this.sessions.clear();
|
|
254
|
+
await Promise.all(entries.map((entry) => entry.agent.destroy().catch(() => { })));
|
|
255
|
+
}
|
|
205
256
|
async restoreSession(restoreId, sessionId) {
|
|
206
257
|
const entry = this.requireSession(sessionId);
|
|
258
|
+
// Re-attach to a live session: the SDK restore would no-op (target is
|
|
259
|
+
// already current). Emit the current messages so the freshly attached
|
|
260
|
+
// client — whose router registered only after initialize returned and so
|
|
261
|
+
// missed any earlier notifications — gets a snapshot without replay.
|
|
262
|
+
if (entry.agent.sessionId === restoreId) {
|
|
263
|
+
this.emit("messagesChange", { messages: entry.agent.messages }, entry.agent.sessionId);
|
|
264
|
+
// The re-attached client also missed the loading state that settled
|
|
265
|
+
// before its router registered — replay it or the client's
|
|
266
|
+
// isStreaming/running indicator stays false while the live session
|
|
267
|
+
// keeps generating.
|
|
268
|
+
this.emit("loadingChange", {
|
|
269
|
+
loading: entry.agent.isLoading,
|
|
270
|
+
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
271
|
+
}, entry.agent.sessionId);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
207
274
|
await entry.agent.restoreSession(restoreId);
|
|
208
275
|
return null;
|
|
209
276
|
}
|
|
@@ -268,6 +335,28 @@ export class AgentBridge {
|
|
|
268
335
|
}
|
|
269
336
|
}
|
|
270
337
|
async removeWorktreeSession(params) {
|
|
338
|
+
// Align with Claude Code v2.1.216+: refuse to remove a worktree whose path
|
|
339
|
+
// is a symlink or resolves outside the repo root. Already-removed (missing)
|
|
340
|
+
// paths pass validation so removal stays idempotent.
|
|
341
|
+
try {
|
|
342
|
+
validateWorktreeRemovalPath(params.path, params.repoRoot);
|
|
343
|
+
}
|
|
344
|
+
catch (e) {
|
|
345
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, e.message);
|
|
346
|
+
}
|
|
347
|
+
// Trigger the WorktreeRemove hook (before git removal, non-blocking) using
|
|
348
|
+
// the session that runs in this worktree, if it is still registered.
|
|
349
|
+
for (const entry of this.sessions.values()) {
|
|
350
|
+
if (entry.agent.workingDirectory === params.path) {
|
|
351
|
+
try {
|
|
352
|
+
await entry.agent.triggerWorktreeRemoveHook(params.path);
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
logger.warn("WorktreeRemove hooks execution failed:", e);
|
|
356
|
+
}
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
271
360
|
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
272
361
|
// branches only log, never throw.
|
|
273
362
|
await removeWorktree({
|
|
@@ -355,6 +444,22 @@ export class AgentBridge {
|
|
|
355
444
|
await entry.agent.bang(command);
|
|
356
445
|
return null;
|
|
357
446
|
}
|
|
447
|
+
async askBtw(question, sessionId) {
|
|
448
|
+
const entry = this.requireSession(sessionId);
|
|
449
|
+
return entry.agent.askBtw(question, undefined,
|
|
450
|
+
// Stream partial content to the client so webview hosts can render the
|
|
451
|
+
// answer incrementally (thinking and content travel on separate
|
|
452
|
+
// channels so the panel can drop thinking text once content starts).
|
|
453
|
+
(content) => {
|
|
454
|
+
if (sessionId) {
|
|
455
|
+
this.emit("btwContent", { question, content, type: "content" }, sessionId);
|
|
456
|
+
}
|
|
457
|
+
}, (content) => {
|
|
458
|
+
if (sessionId) {
|
|
459
|
+
this.emit("btwContent", { question, content, type: "thinking" }, sessionId);
|
|
460
|
+
}
|
|
461
|
+
});
|
|
462
|
+
}
|
|
358
463
|
async abortMessage(sessionId) {
|
|
359
464
|
const entry = this.requireSession(sessionId);
|
|
360
465
|
entry.agent.abortMessage();
|
|
@@ -381,7 +486,7 @@ export class AgentBridge {
|
|
|
381
486
|
const entry = this.requireSession(sessionId);
|
|
382
487
|
const { messages } = await entry.agent.getFullMessageThread();
|
|
383
488
|
const checkpoints = messages
|
|
384
|
-
.filter((m) => m
|
|
489
|
+
.filter((m) => isUserCheckpointMessage(m) && m.id)
|
|
385
490
|
.map((m) => ({
|
|
386
491
|
id: m.id,
|
|
387
492
|
content: getMessageContent(m).replace(/\s+/g, " ").trim(),
|
|
@@ -499,10 +604,26 @@ export class AgentBridge {
|
|
|
499
604
|
canUseTool(context, ctx) {
|
|
500
605
|
const requestId = `perm_${++this.permissionCounter}`;
|
|
501
606
|
return new Promise((resolve) => {
|
|
502
|
-
this.pendingPermissions.set(requestId,
|
|
607
|
+
this.pendingPermissions.set(requestId, {
|
|
608
|
+
resolve,
|
|
609
|
+
sessionId: ctx.registeredSessionId,
|
|
610
|
+
context,
|
|
611
|
+
});
|
|
503
612
|
this.emit("permissionRequest", { requestId, context }, ctx.registeredSessionId);
|
|
504
613
|
});
|
|
505
614
|
}
|
|
615
|
+
/** Attach snapshot: re-surface approvals that are still pending after a
|
|
616
|
+
* client disconnected (daemon mode). Responding to any listed requestId
|
|
617
|
+
* resolves the in-process promise. */
|
|
618
|
+
listPendingPermissions() {
|
|
619
|
+
return {
|
|
620
|
+
requests: [...this.pendingPermissions.entries()].map(([requestId, entry]) => ({
|
|
621
|
+
requestId,
|
|
622
|
+
sessionId: entry.sessionId,
|
|
623
|
+
context: entry.context,
|
|
624
|
+
})),
|
|
625
|
+
};
|
|
626
|
+
}
|
|
506
627
|
// ── Auth (global) ────────────────────────────────────────────
|
|
507
628
|
async getAuthStatus() {
|
|
508
629
|
const authService = AuthService.getInstance();
|
|
@@ -604,9 +725,6 @@ export class AgentBridge {
|
|
|
604
725
|
// ── Callbacks → Notifications ─────────────────────────────────
|
|
605
726
|
createCallbacks(ctx) {
|
|
606
727
|
return {
|
|
607
|
-
onMessagesChange: (messages) => {
|
|
608
|
-
this.emit("messagesChange", { messages }, ctx.registeredSessionId);
|
|
609
|
-
},
|
|
610
728
|
onUserMessageAdded: () => {
|
|
611
729
|
const msg = this.findLastUserMessage(ctx.agent);
|
|
612
730
|
if (msg)
|
|
@@ -618,13 +736,28 @@ export class AgentBridge {
|
|
|
618
736
|
this.emit("assistantMessageAdded", { message: msg }, ctx.registeredSessionId);
|
|
619
737
|
},
|
|
620
738
|
onAssistantContentUpdated: (params) => {
|
|
621
|
-
|
|
739
|
+
// Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
|
|
740
|
+
this.emit("assistantContentUpdated", {
|
|
741
|
+
messageId: params.messageId,
|
|
742
|
+
chunk: params.chunk,
|
|
743
|
+
stage: params.stage,
|
|
744
|
+
}, ctx.registeredSessionId);
|
|
622
745
|
},
|
|
623
746
|
onAssistantReasoningUpdated: (params) => {
|
|
624
|
-
this.emit("assistantReasoningUpdated",
|
|
747
|
+
this.emit("assistantReasoningUpdated", {
|
|
748
|
+
messageId: params.messageId,
|
|
749
|
+
chunk: params.chunk,
|
|
750
|
+
stage: params.stage,
|
|
751
|
+
}, ctx.registeredSessionId);
|
|
625
752
|
},
|
|
626
753
|
onToolBlockUpdated: (params) => {
|
|
627
|
-
|
|
754
|
+
// Streaming stages carry only the parametersChunk delta; start/running
|
|
755
|
+
// (one-time snapshots) and end (authoritative full value) keep
|
|
756
|
+
// `parameters` (spec: 流式通知纯增量负载).
|
|
757
|
+
const { parameters, ...rest } = params;
|
|
758
|
+
void parameters;
|
|
759
|
+
const wireParams = params.stage === "streaming" ? rest : params;
|
|
760
|
+
this.emit("toolBlockUpdated", wireParams, ctx.registeredSessionId);
|
|
628
761
|
},
|
|
629
762
|
onErrorBlockAdded: (error) => {
|
|
630
763
|
this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
|
|
@@ -682,14 +815,14 @@ export class AgentBridge {
|
|
|
682
815
|
onMcpServersChange: (servers) => {
|
|
683
816
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
684
817
|
},
|
|
685
|
-
onAddBangMessage: () => {
|
|
686
|
-
this.emit("bangMessageAdded", {}, ctx.registeredSessionId);
|
|
818
|
+
onAddBangMessage: (command, messageId) => {
|
|
819
|
+
this.emit("bangMessageAdded", { command, messageId }, ctx.registeredSessionId);
|
|
687
820
|
},
|
|
688
|
-
onUpdateBangMessage: () => {
|
|
689
|
-
this.emit("bangMessageUpdated", {}, ctx.registeredSessionId);
|
|
821
|
+
onUpdateBangMessage: (command, output, messageId) => {
|
|
822
|
+
this.emit("bangMessageUpdated", { command, output, messageId }, ctx.registeredSessionId);
|
|
690
823
|
},
|
|
691
|
-
onCompleteBangMessage: () => {
|
|
692
|
-
this.emit("bangMessageCompleted", {}, ctx.registeredSessionId);
|
|
824
|
+
onCompleteBangMessage: (command, exitCode, messageId) => {
|
|
825
|
+
this.emit("bangMessageCompleted", { command, exitCode, messageId }, ctx.registeredSessionId);
|
|
693
826
|
},
|
|
694
827
|
onNotificationMessageAdded: (params) => {
|
|
695
828
|
const msg = ctx.agent?.messages.find((m) => m.role === "user" &&
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DaemonServer — JSON-RPC server over a unix socket for remote background
|
|
3
|
+
* sessions (spec: docs/specs/ui/desktop-app.md 「SSH 远程后台会话」).
|
|
4
|
+
*
|
|
5
|
+
* The desktop app launches `wave --daemon <socket>` on the remote host via
|
|
6
|
+
* nohup/setsid, then tunnels the socket back with `ssh -L`. All connections
|
|
7
|
+
* share one AgentBridge, so sessions and pending tool permissions survive
|
|
8
|
+
* client detach/attach — the daemon keeps running (and generating) while no
|
|
9
|
+
* desktop is connected. The daemon never exits on a client disconnect; it
|
|
10
|
+
* only exits when killed (app quit / 删除会话 / remote reboot).
|
|
11
|
+
*
|
|
12
|
+
* Idle auto-exit (spec: 「远程 daemon 空闲自动退出」): once every session has
|
|
13
|
+
* settled and no client is connected, the daemon mirrors `wave -p`'s exit
|
|
14
|
+
* semantics — after a grace period it destroys the sessions (saving their
|
|
15
|
+
* transcripts), closes the socket, and exits, so the remote process doesn't
|
|
16
|
+
* linger forever after background work completes.
|
|
17
|
+
*/
|
|
18
|
+
import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
|
|
19
|
+
export interface DaemonServerOptions {
|
|
20
|
+
socketPath: string;
|
|
21
|
+
bridgeOptions?: AgentBridgeOptions;
|
|
22
|
+
/** Idle grace period before the daemon auto-exits (default 60s). */
|
|
23
|
+
graceMs?: number;
|
|
24
|
+
}
|
|
25
|
+
export declare class DaemonServer {
|
|
26
|
+
static readonly DEFAULT_IDLE_GRACE_MS = 60000;
|
|
27
|
+
private socketPath;
|
|
28
|
+
private server;
|
|
29
|
+
private bridge;
|
|
30
|
+
private connections;
|
|
31
|
+
private sockets;
|
|
32
|
+
private graceMs;
|
|
33
|
+
private idleTimer;
|
|
34
|
+
private shuttingDown;
|
|
35
|
+
private stopped;
|
|
36
|
+
constructor(options: DaemonServerOptions);
|
|
37
|
+
get agentBridge(): AgentBridge;
|
|
38
|
+
/**
|
|
39
|
+
* Listen on the socket path. Rejects when a live daemon already holds it. A
|
|
40
|
+
* stale socket file left by a crashed daemon would otherwise block the
|
|
41
|
+
* restart with EADDRINUSE, so it is cleaned up first: non-socket files are
|
|
42
|
+
* unlinked outright; socket files are probe-connected — ECONNREFUSED means
|
|
43
|
+
* no listener (stale → unlink and listen), anything else means a live
|
|
44
|
+
* daemon owns the path (reject).
|
|
45
|
+
*/
|
|
46
|
+
start(): Promise<void>;
|
|
47
|
+
private listen;
|
|
48
|
+
stop(): Promise<void>;
|
|
49
|
+
private clearIdleTimer;
|
|
50
|
+
/**
|
|
51
|
+
* Re-evaluate the idle condition after any state transition: sessions busy
|
|
52
|
+
* (loading / pending messages / background work) or any client attached →
|
|
53
|
+
* cancel the timer. Fully idle + detached → arm the grace timer once; when
|
|
54
|
+
* it fires, shut the daemon down. Evaluation is event-driven (every
|
|
55
|
+
* busy→idle transition emits a notification, every attach/detach fires a
|
|
56
|
+
* connection event), so nothing is polled. The failure mode is
|
|
57
|
+
* conservative: a missed transition just leaves the daemon running.
|
|
58
|
+
*/
|
|
59
|
+
private evaluateIdle;
|
|
60
|
+
/**
|
|
61
|
+
* Destroy the sessions (each agent saves its transcript and drains
|
|
62
|
+
* auto-memory), close the listener, unlink the socket file, then exit.
|
|
63
|
+
* `shuttingDown` guards against re-entry: new connections are refused and
|
|
64
|
+
* further idle evaluations become no-ops.
|
|
65
|
+
*/
|
|
66
|
+
private shutdown;
|
|
67
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DaemonServer — JSON-RPC server over a unix socket for remote background
|
|
3
|
+
* sessions (spec: docs/specs/ui/desktop-app.md 「SSH 远程后台会话」).
|
|
4
|
+
*
|
|
5
|
+
* The desktop app launches `wave --daemon <socket>` on the remote host via
|
|
6
|
+
* nohup/setsid, then tunnels the socket back with `ssh -L`. All connections
|
|
7
|
+
* share one AgentBridge, so sessions and pending tool permissions survive
|
|
8
|
+
* client detach/attach — the daemon keeps running (and generating) while no
|
|
9
|
+
* desktop is connected. The daemon never exits on a client disconnect; it
|
|
10
|
+
* only exits when killed (app quit / 删除会话 / remote reboot).
|
|
11
|
+
*
|
|
12
|
+
* Idle auto-exit (spec: 「远程 daemon 空闲自动退出」): once every session has
|
|
13
|
+
* settled and no client is connected, the daemon mirrors `wave -p`'s exit
|
|
14
|
+
* semantics — after a grace period it destroys the sessions (saving their
|
|
15
|
+
* transcripts), closes the socket, and exits, so the remote process doesn't
|
|
16
|
+
* linger forever after background work completes.
|
|
17
|
+
*/
|
|
18
|
+
import net from "net";
|
|
19
|
+
import * as fs from "fs";
|
|
20
|
+
import { AgentBridge } from "./agentBridge.js";
|
|
21
|
+
import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
22
|
+
export class DaemonServer {
|
|
23
|
+
constructor(options) {
|
|
24
|
+
this.connections = new Set();
|
|
25
|
+
this.sockets = new Set();
|
|
26
|
+
this.shuttingDown = false;
|
|
27
|
+
this.stopped = false;
|
|
28
|
+
this.socketPath = options.socketPath;
|
|
29
|
+
this.graceMs = options.graceMs ?? DaemonServer.DEFAULT_IDLE_GRACE_MS;
|
|
30
|
+
this.bridge = new AgentBridge({
|
|
31
|
+
...options.bridgeOptions,
|
|
32
|
+
// Notifications go to every attached client; a fully detached daemon
|
|
33
|
+
// has none, and the write is dropped silently (the attach snapshot
|
|
34
|
+
// re-syncs state on reconnect).
|
|
35
|
+
emit: (method, params, sessionId) => {
|
|
36
|
+
for (const conn of this.connections) {
|
|
37
|
+
conn.sendNotification(method, params, sessionId);
|
|
38
|
+
}
|
|
39
|
+
// Any session activity can change the idle state — re-evaluate.
|
|
40
|
+
this.evaluateIdle();
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
this.server = net.createServer((socket) => {
|
|
44
|
+
if (this.shuttingDown) {
|
|
45
|
+
socket.destroy();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const conn = new JsonRpcConnection(socket, socket, this.bridge);
|
|
49
|
+
this.connections.add(conn);
|
|
50
|
+
this.sockets.add(socket);
|
|
51
|
+
// A (re)attached client cancels a pending idle exit.
|
|
52
|
+
this.evaluateIdle();
|
|
53
|
+
socket.on("error", () => {
|
|
54
|
+
// The client (ssh tunnel) can reset the socket mid-detach; the daemon
|
|
55
|
+
// must keep running — 'close' below cleans up the connection.
|
|
56
|
+
});
|
|
57
|
+
socket.on("close", () => {
|
|
58
|
+
this.connections.delete(conn);
|
|
59
|
+
this.sockets.delete(socket);
|
|
60
|
+
// A client detach may leave the daemon idle — re-evaluate.
|
|
61
|
+
this.evaluateIdle();
|
|
62
|
+
});
|
|
63
|
+
conn.start();
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
get agentBridge() {
|
|
67
|
+
return this.bridge;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Listen on the socket path. Rejects when a live daemon already holds it. A
|
|
71
|
+
* stale socket file left by a crashed daemon would otherwise block the
|
|
72
|
+
* restart with EADDRINUSE, so it is cleaned up first: non-socket files are
|
|
73
|
+
* unlinked outright; socket files are probe-connected — ECONNREFUSED means
|
|
74
|
+
* no listener (stale → unlink and listen), anything else means a live
|
|
75
|
+
* daemon owns the path (reject).
|
|
76
|
+
*/
|
|
77
|
+
start() {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
const server = this.server;
|
|
80
|
+
if (!server)
|
|
81
|
+
return resolve();
|
|
82
|
+
try {
|
|
83
|
+
const st = fs.statSync(this.socketPath);
|
|
84
|
+
if (st.isSocket()) {
|
|
85
|
+
const probe = net.connect(this.socketPath);
|
|
86
|
+
probe.once("connect", () => {
|
|
87
|
+
probe.destroy();
|
|
88
|
+
reject(new Error(`另一个 wave daemon 已在 ${this.socketPath} 监听`));
|
|
89
|
+
});
|
|
90
|
+
probe.once("error", (err) => {
|
|
91
|
+
probe.destroy();
|
|
92
|
+
if (err.code === "ECONNREFUSED") {
|
|
93
|
+
fs.unlinkSync(this.socketPath);
|
|
94
|
+
this.listen(server, resolve, reject);
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
reject(err);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
fs.unlinkSync(this.socketPath);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// ENOENT — no stale socket, listen directly.
|
|
106
|
+
}
|
|
107
|
+
this.listen(server, resolve, reject);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
listen(server, resolve, reject) {
|
|
111
|
+
server.once("error", reject);
|
|
112
|
+
server.listen(this.socketPath, () => {
|
|
113
|
+
server.removeListener("error", reject);
|
|
114
|
+
resolve();
|
|
115
|
+
// A freshly started daemon may already be idle (no sessions) — start the
|
|
116
|
+
// idle watch so a zero-session daemon also auto-exits.
|
|
117
|
+
this.evaluateIdle();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
stop() {
|
|
121
|
+
this.stopped = true;
|
|
122
|
+
this.clearIdleTimer();
|
|
123
|
+
return new Promise((resolve) => {
|
|
124
|
+
const server = this.server;
|
|
125
|
+
if (!server)
|
|
126
|
+
return resolve();
|
|
127
|
+
this.server = undefined;
|
|
128
|
+
server.close(() => resolve());
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
// ── Idle auto-exit ────────────────────────────────────────────
|
|
132
|
+
clearIdleTimer() {
|
|
133
|
+
if (this.idleTimer) {
|
|
134
|
+
clearTimeout(this.idleTimer);
|
|
135
|
+
this.idleTimer = undefined;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Re-evaluate the idle condition after any state transition: sessions busy
|
|
140
|
+
* (loading / pending messages / background work) or any client attached →
|
|
141
|
+
* cancel the timer. Fully idle + detached → arm the grace timer once; when
|
|
142
|
+
* it fires, shut the daemon down. Evaluation is event-driven (every
|
|
143
|
+
* busy→idle transition emits a notification, every attach/detach fires a
|
|
144
|
+
* connection event), so nothing is polled. The failure mode is
|
|
145
|
+
* conservative: a missed transition just leaves the daemon running.
|
|
146
|
+
*/
|
|
147
|
+
evaluateIdle() {
|
|
148
|
+
// A stopped/shutting-down daemon never (re)arms the idle timer — late
|
|
149
|
+
// socket 'close' events (which fire after server.close resolves) must not
|
|
150
|
+
// resurrect a timer after stop().
|
|
151
|
+
if (this.shuttingDown || this.stopped)
|
|
152
|
+
return;
|
|
153
|
+
if (this.connections.size > 0 || !this.bridge.isIdle()) {
|
|
154
|
+
this.clearIdleTimer();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (this.idleTimer)
|
|
158
|
+
return;
|
|
159
|
+
this.idleTimer = setTimeout(() => {
|
|
160
|
+
this.idleTimer = undefined;
|
|
161
|
+
void this.shutdown();
|
|
162
|
+
}, this.graceMs);
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Destroy the sessions (each agent saves its transcript and drains
|
|
166
|
+
* auto-memory), close the listener, unlink the socket file, then exit.
|
|
167
|
+
* `shuttingDown` guards against re-entry: new connections are refused and
|
|
168
|
+
* further idle evaluations become no-ops.
|
|
169
|
+
*/
|
|
170
|
+
async shutdown() {
|
|
171
|
+
if (this.shuttingDown)
|
|
172
|
+
return;
|
|
173
|
+
this.shuttingDown = true;
|
|
174
|
+
this.clearIdleTimer();
|
|
175
|
+
// Destroy client sockets first so server.close() can complete (an open
|
|
176
|
+
// socket keeps the close callback pending).
|
|
177
|
+
for (const socket of this.sockets)
|
|
178
|
+
socket.destroy();
|
|
179
|
+
this.sockets.clear();
|
|
180
|
+
await this.bridge.destroyAll();
|
|
181
|
+
await this.stop();
|
|
182
|
+
try {
|
|
183
|
+
fs.unlinkSync(this.socketPath);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
// Already gone — a stale file would be probed/unlinked on next start.
|
|
187
|
+
}
|
|
188
|
+
process.exit(0);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
DaemonServer.DEFAULT_IDLE_GRACE_MS = 60000;
|
package/dist/stdio/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { StdioServer, type StdioServerOptions } from "./stdioServer.js";
|
|
2
|
+
export { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
3
|
+
export { DaemonServer, type DaemonServerOptions } from "./daemonServer.js";
|
|
2
4
|
export { AgentBridge, type AgentBridgeOptions, RpcError, } from "./agentBridge.js";
|
|
3
5
|
export * from "./protocol.js";
|