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/src/print-cli.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
hasUncommittedChanges,
|
|
5
5
|
hasNewCommits,
|
|
6
6
|
getDefaultRemoteBranch,
|
|
7
|
+
validateWorktreeRemovalPath,
|
|
7
8
|
type WorktreeSession,
|
|
8
9
|
} from "wave-agent-sdk";
|
|
9
10
|
import { displayUsageSummary } from "./utils/usageSummary.js";
|
|
@@ -183,18 +184,18 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
183
184
|
// Display timing information
|
|
184
185
|
displayTimingInfo(startTime, showStats);
|
|
185
186
|
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
// Handle worktree cleanup for print mode
|
|
187
|
+
// Trigger WorktreeRemove hook (before destroy — it needs a live agent) and
|
|
188
|
+
// decide whether the worktree is clean enough to remove
|
|
189
|
+
let cleanWorktree = false;
|
|
190
190
|
if (worktreeSession) {
|
|
191
191
|
const cwd = workdir || worktreeSession.path;
|
|
192
192
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
193
193
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
194
194
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
195
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
195
196
|
|
|
196
|
-
if (
|
|
197
|
-
await
|
|
197
|
+
if (cleanWorktree) {
|
|
198
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
198
199
|
} else {
|
|
199
200
|
process.stdout.write(
|
|
200
201
|
`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
|
|
@@ -202,6 +203,25 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
202
203
|
}
|
|
203
204
|
}
|
|
204
205
|
|
|
206
|
+
// Destroy agent and exit after sendMessage completes
|
|
207
|
+
await agent.destroy();
|
|
208
|
+
|
|
209
|
+
// Handle worktree cleanup for print mode (git removal stays after destroy)
|
|
210
|
+
if (worktreeSession && cleanWorktree) {
|
|
211
|
+
try {
|
|
212
|
+
validateWorktreeRemovalPath(
|
|
213
|
+
worktreeSession.path,
|
|
214
|
+
worktreeSession.repoRoot,
|
|
215
|
+
);
|
|
216
|
+
await removeWorktree(worktreeSession);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
// Never block print-mode exit on worktree cleanup failures
|
|
219
|
+
process.stdout.write(
|
|
220
|
+
`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
205
225
|
process.exit(0);
|
|
206
226
|
} catch (error) {
|
|
207
227
|
console.error("Failed to send message:", error);
|
|
@@ -220,23 +240,40 @@ export async function startPrintCli(options: PrintCliOptions): Promise<void> {
|
|
|
220
240
|
// Display timing information even on error
|
|
221
241
|
displayTimingInfo(startTime, showStats);
|
|
222
242
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// Handle worktree cleanup for print mode even on error
|
|
243
|
+
// Trigger WorktreeRemove hook (before destroy) when the worktree is clean
|
|
244
|
+
let cleanWorktree = false;
|
|
226
245
|
if (worktreeSession) {
|
|
227
246
|
const cwd = workdir || worktreeSession.path;
|
|
228
247
|
const baseBranch = getDefaultRemoteBranch(cwd);
|
|
229
248
|
const hasChanges = hasUncommittedChanges(cwd);
|
|
230
249
|
const hasCommits = hasNewCommits(cwd, baseBranch);
|
|
250
|
+
cleanWorktree = !hasChanges && !hasCommits;
|
|
231
251
|
|
|
232
|
-
if (
|
|
233
|
-
await
|
|
252
|
+
if (cleanWorktree) {
|
|
253
|
+
await agent.triggerWorktreeRemoveHook(worktreeSession.path);
|
|
234
254
|
} else {
|
|
235
255
|
process.stdout.write(
|
|
236
256
|
`\n⚠️ Worktree '${worktreeSession.name}' has changes or commits. Keeping it at: ${worktreeSession.path}\n`,
|
|
237
257
|
);
|
|
238
258
|
}
|
|
239
259
|
}
|
|
260
|
+
|
|
261
|
+
await agent.destroy();
|
|
262
|
+
|
|
263
|
+
// Handle worktree cleanup for print mode even on error
|
|
264
|
+
if (worktreeSession && cleanWorktree) {
|
|
265
|
+
try {
|
|
266
|
+
validateWorktreeRemovalPath(
|
|
267
|
+
worktreeSession.path,
|
|
268
|
+
worktreeSession.repoRoot,
|
|
269
|
+
);
|
|
270
|
+
await removeWorktree(worktreeSession);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
process.stdout.write(
|
|
273
|
+
`\n⚠️ Skipping worktree removal: ${(error as Error).message}\n`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
240
277
|
}
|
|
241
278
|
process.exit(1);
|
|
242
279
|
}
|
package/src/stdio/agentBridge.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
PromptHistoryManager,
|
|
41
41
|
AuthService,
|
|
42
42
|
PluginCore,
|
|
43
|
+
validateWorktreeRemovalPath,
|
|
43
44
|
type SlashCommand,
|
|
44
45
|
} from "wave-agent-sdk";
|
|
45
46
|
import {
|
|
@@ -50,6 +51,7 @@ import {
|
|
|
50
51
|
import { execFileSync } from "node:child_process";
|
|
51
52
|
import { createWorktree, removeWorktree } from "../utils/worktree.js";
|
|
52
53
|
import { logger } from "../utils/logger.js";
|
|
54
|
+
import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
|
|
53
55
|
|
|
54
56
|
export type NotificationEmitter = (
|
|
55
57
|
method: string,
|
|
@@ -116,9 +118,16 @@ interface SessionContext {
|
|
|
116
118
|
|
|
117
119
|
export class AgentBridge {
|
|
118
120
|
private sessions = new Map<string, SessionEntry>();
|
|
121
|
+
/** Pending approval requests, keyed by requestId. Stored with the resolve +
|
|
122
|
+
* context so a re-attached client can list and respond to them (daemon mode:
|
|
123
|
+
* approvals outlive any single connection). */
|
|
119
124
|
private pendingPermissions = new Map<
|
|
120
125
|
string,
|
|
121
|
-
|
|
126
|
+
{
|
|
127
|
+
resolve: (decision: PermissionDecision) => void;
|
|
128
|
+
sessionId?: string;
|
|
129
|
+
context: ToolPermissionContext;
|
|
130
|
+
}
|
|
122
131
|
>();
|
|
123
132
|
private permissionCounter = 0;
|
|
124
133
|
private emit: NotificationEmitter;
|
|
@@ -149,6 +158,8 @@ export class AgentBridge {
|
|
|
149
158
|
return this.listSessions(p.workdir as string | undefined, sessionId);
|
|
150
159
|
case "getSessionInfo":
|
|
151
160
|
return this.getSessionInfo(sessionId);
|
|
161
|
+
case "listPendingPermissions":
|
|
162
|
+
return this.listPendingPermissions();
|
|
152
163
|
case "updateConfig":
|
|
153
164
|
return this.updateConfig(p as unknown as UpdateConfigParams, sessionId);
|
|
154
165
|
|
|
@@ -164,6 +175,8 @@ export class AgentBridge {
|
|
|
164
175
|
);
|
|
165
176
|
case "bang":
|
|
166
177
|
return this.bang(p.command as string, sessionId);
|
|
178
|
+
case "askBtw":
|
|
179
|
+
return this.askBtw(p.question as string, sessionId);
|
|
167
180
|
case "abortMessage":
|
|
168
181
|
return this.abortMessage(sessionId);
|
|
169
182
|
case "clearMessages":
|
|
@@ -351,10 +364,10 @@ export class AgentBridge {
|
|
|
351
364
|
decision: PermissionDecision;
|
|
352
365
|
};
|
|
353
366
|
// requestId is process-level unique; lookup doesn't need sessionId
|
|
354
|
-
const
|
|
355
|
-
if (
|
|
367
|
+
const entry = this.pendingPermissions.get(p.requestId);
|
|
368
|
+
if (entry) {
|
|
356
369
|
this.pendingPermissions.delete(p.requestId);
|
|
357
|
-
resolve(p.decision);
|
|
370
|
+
entry.resolve(p.decision);
|
|
358
371
|
}
|
|
359
372
|
}
|
|
360
373
|
}
|
|
@@ -367,6 +380,21 @@ export class AgentBridge {
|
|
|
367
380
|
permissionMode: PermissionMode;
|
|
368
381
|
latestTotalTokens: number;
|
|
369
382
|
}> {
|
|
383
|
+
// Re-attach (daemon mode): if the target session is already live in this
|
|
384
|
+
// process, reuse it instead of creating a second agent writing to the same
|
|
385
|
+
// transcript. The live agent keeps running across client detach/attach.
|
|
386
|
+
if (params.restoreSessionId) {
|
|
387
|
+
const live = this.sessions.get(params.restoreSessionId);
|
|
388
|
+
if (live) {
|
|
389
|
+
return {
|
|
390
|
+
sessionId: live.agent.sessionId,
|
|
391
|
+
workingDirectory: live.agent.workingDirectory,
|
|
392
|
+
permissionMode: live.agent.getPermissionMode(),
|
|
393
|
+
latestTotalTokens: live.agent.latestTotalTokens,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
370
398
|
const ctx: SessionContext = {};
|
|
371
399
|
const callbacks = this.createCallbacks(ctx);
|
|
372
400
|
|
|
@@ -421,11 +449,70 @@ export class AgentBridge {
|
|
|
421
449
|
return null;
|
|
422
450
|
}
|
|
423
451
|
|
|
452
|
+
/**
|
|
453
|
+
* True when every hosted session has settled: not generating, nothing queued,
|
|
454
|
+
* and no background work (background bash / subagents / workflows) — the same
|
|
455
|
+
* condition `wave -p` waits on before exiting (print-cli.ts). Pending
|
|
456
|
+
* permission approvals keep the owning agent's isLoading true, so they are
|
|
457
|
+
* covered without an explicit check.
|
|
458
|
+
*/
|
|
459
|
+
public isIdle(): boolean {
|
|
460
|
+
for (const entry of this.sessions.values()) {
|
|
461
|
+
const agent = entry.agent;
|
|
462
|
+
if (
|
|
463
|
+
agent.isLoading ||
|
|
464
|
+
agent.hasPendingMessages ||
|
|
465
|
+
agent.hasRunningBackgroundWork
|
|
466
|
+
) {
|
|
467
|
+
return false;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return true;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Destroy every hosted session agent. Each Agent.destroy() saves its
|
|
475
|
+
* transcript, drains in-flight auto-memory extraction, and cleans up
|
|
476
|
+
* background tasks/subagents. Best-effort: one failing destroy must not
|
|
477
|
+
* block the rest of the shutdown.
|
|
478
|
+
*/
|
|
479
|
+
public async destroyAll(): Promise<void> {
|
|
480
|
+
const entries = [...this.sessions.values()];
|
|
481
|
+
this.sessions.clear();
|
|
482
|
+
await Promise.all(
|
|
483
|
+
entries.map((entry) => entry.agent.destroy().catch(() => {})),
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
424
487
|
private async restoreSession(
|
|
425
488
|
restoreId: string,
|
|
426
489
|
sessionId?: string,
|
|
427
490
|
): Promise<null> {
|
|
428
491
|
const entry = this.requireSession(sessionId);
|
|
492
|
+
// Re-attach to a live session: the SDK restore would no-op (target is
|
|
493
|
+
// already current). Emit the current messages so the freshly attached
|
|
494
|
+
// client — whose router registered only after initialize returned and so
|
|
495
|
+
// missed any earlier notifications — gets a snapshot without replay.
|
|
496
|
+
if (entry.agent.sessionId === restoreId) {
|
|
497
|
+
this.emit(
|
|
498
|
+
"messagesChange",
|
|
499
|
+
{ messages: entry.agent.messages },
|
|
500
|
+
entry.agent.sessionId,
|
|
501
|
+
);
|
|
502
|
+
// The re-attached client also missed the loading state that settled
|
|
503
|
+
// before its router registered — replay it or the client's
|
|
504
|
+
// isStreaming/running indicator stays false while the live session
|
|
505
|
+
// keeps generating.
|
|
506
|
+
this.emit(
|
|
507
|
+
"loadingChange",
|
|
508
|
+
{
|
|
509
|
+
loading: entry.agent.isLoading,
|
|
510
|
+
latestTotalTokens: entry.agent.latestTotalTokens,
|
|
511
|
+
},
|
|
512
|
+
entry.agent.sessionId,
|
|
513
|
+
);
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
429
516
|
await entry.agent.restoreSession(restoreId);
|
|
430
517
|
return null;
|
|
431
518
|
}
|
|
@@ -526,6 +613,28 @@ export class AgentBridge {
|
|
|
526
613
|
branch: string;
|
|
527
614
|
repoRoot: string;
|
|
528
615
|
}): Promise<{ ok: true }> {
|
|
616
|
+
// Align with Claude Code v2.1.216+: refuse to remove a worktree whose path
|
|
617
|
+
// is a symlink or resolves outside the repo root. Already-removed (missing)
|
|
618
|
+
// paths pass validation so removal stays idempotent.
|
|
619
|
+
try {
|
|
620
|
+
validateWorktreeRemovalPath(params.path, params.repoRoot);
|
|
621
|
+
} catch (e) {
|
|
622
|
+
throw new RpcError(PROTOCOL_INTERNAL_ERROR, (e as Error).message);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// Trigger the WorktreeRemove hook (before git removal, non-blocking) using
|
|
626
|
+
// the session that runs in this worktree, if it is still registered.
|
|
627
|
+
for (const entry of this.sessions.values()) {
|
|
628
|
+
if (entry.agent.workingDirectory === params.path) {
|
|
629
|
+
try {
|
|
630
|
+
await entry.agent.triggerWorktreeRemoveHook(params.path);
|
|
631
|
+
} catch (e) {
|
|
632
|
+
logger.warn("WorktreeRemove hooks execution failed:", e);
|
|
633
|
+
}
|
|
634
|
+
break;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
529
638
|
// removeWorktree is best-effort/idempotent: already-removed worktrees or
|
|
530
639
|
// branches only log, never throw.
|
|
531
640
|
await removeWorktree({
|
|
@@ -643,6 +752,35 @@ export class AgentBridge {
|
|
|
643
752
|
return null;
|
|
644
753
|
}
|
|
645
754
|
|
|
755
|
+
private async askBtw(question: string, sessionId?: string): Promise<string> {
|
|
756
|
+
const entry = this.requireSession(sessionId);
|
|
757
|
+
return entry.agent.askBtw(
|
|
758
|
+
question,
|
|
759
|
+
undefined,
|
|
760
|
+
// Stream partial content to the client so webview hosts can render the
|
|
761
|
+
// answer incrementally (thinking and content travel on separate
|
|
762
|
+
// channels so the panel can drop thinking text once content starts).
|
|
763
|
+
(content) => {
|
|
764
|
+
if (sessionId) {
|
|
765
|
+
this.emit(
|
|
766
|
+
"btwContent",
|
|
767
|
+
{ question, content, type: "content" },
|
|
768
|
+
sessionId,
|
|
769
|
+
);
|
|
770
|
+
}
|
|
771
|
+
},
|
|
772
|
+
(content) => {
|
|
773
|
+
if (sessionId) {
|
|
774
|
+
this.emit(
|
|
775
|
+
"btwContent",
|
|
776
|
+
{ question, content, type: "thinking" },
|
|
777
|
+
sessionId,
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
|
|
646
784
|
private async abortMessage(sessionId?: string): Promise<null> {
|
|
647
785
|
const entry = this.requireSession(sessionId);
|
|
648
786
|
entry.agent.abortMessage();
|
|
@@ -684,7 +822,7 @@ export class AgentBridge {
|
|
|
684
822
|
const entry = this.requireSession(sessionId);
|
|
685
823
|
const { messages } = await entry.agent.getFullMessageThread();
|
|
686
824
|
const checkpoints = messages
|
|
687
|
-
.filter((m) => m
|
|
825
|
+
.filter((m) => isUserCheckpointMessage(m) && m.id)
|
|
688
826
|
.map((m) => ({
|
|
689
827
|
id: m.id as string,
|
|
690
828
|
content: getMessageContent(m).replace(/\s+/g, " ").trim(),
|
|
@@ -877,7 +1015,11 @@ export class AgentBridge {
|
|
|
877
1015
|
): Promise<PermissionDecision> {
|
|
878
1016
|
const requestId = `perm_${++this.permissionCounter}`;
|
|
879
1017
|
return new Promise<PermissionDecision>((resolve) => {
|
|
880
|
-
this.pendingPermissions.set(requestId,
|
|
1018
|
+
this.pendingPermissions.set(requestId, {
|
|
1019
|
+
resolve,
|
|
1020
|
+
sessionId: ctx.registeredSessionId,
|
|
1021
|
+
context,
|
|
1022
|
+
});
|
|
881
1023
|
this.emit(
|
|
882
1024
|
"permissionRequest",
|
|
883
1025
|
{ requestId, context },
|
|
@@ -886,6 +1028,27 @@ export class AgentBridge {
|
|
|
886
1028
|
});
|
|
887
1029
|
}
|
|
888
1030
|
|
|
1031
|
+
/** Attach snapshot: re-surface approvals that are still pending after a
|
|
1032
|
+
* client disconnected (daemon mode). Responding to any listed requestId
|
|
1033
|
+
* resolves the in-process promise. */
|
|
1034
|
+
private listPendingPermissions(): {
|
|
1035
|
+
requests: Array<{
|
|
1036
|
+
requestId: string;
|
|
1037
|
+
sessionId?: string;
|
|
1038
|
+
context: ToolPermissionContext;
|
|
1039
|
+
}>;
|
|
1040
|
+
} {
|
|
1041
|
+
return {
|
|
1042
|
+
requests: [...this.pendingPermissions.entries()].map(
|
|
1043
|
+
([requestId, entry]) => ({
|
|
1044
|
+
requestId,
|
|
1045
|
+
sessionId: entry.sessionId,
|
|
1046
|
+
context: entry.context,
|
|
1047
|
+
}),
|
|
1048
|
+
),
|
|
1049
|
+
};
|
|
1050
|
+
}
|
|
1051
|
+
|
|
889
1052
|
// ── Auth (global) ────────────────────────────────────────────
|
|
890
1053
|
|
|
891
1054
|
private async getAuthStatus(): Promise<{
|
|
@@ -1067,9 +1230,6 @@ export class AgentBridge {
|
|
|
1067
1230
|
|
|
1068
1231
|
private createCallbacks(ctx: SessionContext): AgentCallbacks {
|
|
1069
1232
|
return {
|
|
1070
|
-
onMessagesChange: (messages: Message[]) => {
|
|
1071
|
-
this.emit("messagesChange", { messages }, ctx.registeredSessionId);
|
|
1072
|
-
},
|
|
1073
1233
|
onUserMessageAdded: () => {
|
|
1074
1234
|
const msg = this.findLastUserMessage(ctx.agent);
|
|
1075
1235
|
if (msg)
|
|
@@ -1089,13 +1249,36 @@ export class AgentBridge {
|
|
|
1089
1249
|
);
|
|
1090
1250
|
},
|
|
1091
1251
|
onAssistantContentUpdated: (params) => {
|
|
1092
|
-
|
|
1252
|
+
// Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
|
|
1253
|
+
this.emit(
|
|
1254
|
+
"assistantContentUpdated",
|
|
1255
|
+
{
|
|
1256
|
+
messageId: params.messageId,
|
|
1257
|
+
chunk: params.chunk,
|
|
1258
|
+
stage: params.stage,
|
|
1259
|
+
},
|
|
1260
|
+
ctx.registeredSessionId,
|
|
1261
|
+
);
|
|
1093
1262
|
},
|
|
1094
1263
|
onAssistantReasoningUpdated: (params) => {
|
|
1095
|
-
this.emit(
|
|
1264
|
+
this.emit(
|
|
1265
|
+
"assistantReasoningUpdated",
|
|
1266
|
+
{
|
|
1267
|
+
messageId: params.messageId,
|
|
1268
|
+
chunk: params.chunk,
|
|
1269
|
+
stage: params.stage,
|
|
1270
|
+
},
|
|
1271
|
+
ctx.registeredSessionId,
|
|
1272
|
+
);
|
|
1096
1273
|
},
|
|
1097
1274
|
onToolBlockUpdated: (params) => {
|
|
1098
|
-
|
|
1275
|
+
// Streaming stages carry only the parametersChunk delta; start/running
|
|
1276
|
+
// (one-time snapshots) and end (authoritative full value) keep
|
|
1277
|
+
// `parameters` (spec: 流式通知纯增量负载).
|
|
1278
|
+
const { parameters, ...rest } = params;
|
|
1279
|
+
void parameters;
|
|
1280
|
+
const wireParams = params.stage === "streaming" ? rest : params;
|
|
1281
|
+
this.emit("toolBlockUpdated", wireParams, ctx.registeredSessionId);
|
|
1099
1282
|
},
|
|
1100
1283
|
onErrorBlockAdded: (error: string) => {
|
|
1101
1284
|
this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
|
|
@@ -1169,14 +1352,26 @@ export class AgentBridge {
|
|
|
1169
1352
|
onMcpServersChange: (servers: McpServerStatus[]) => {
|
|
1170
1353
|
this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
|
|
1171
1354
|
},
|
|
1172
|
-
onAddBangMessage: () => {
|
|
1173
|
-
this.emit(
|
|
1355
|
+
onAddBangMessage: (command, messageId) => {
|
|
1356
|
+
this.emit(
|
|
1357
|
+
"bangMessageAdded",
|
|
1358
|
+
{ command, messageId },
|
|
1359
|
+
ctx.registeredSessionId,
|
|
1360
|
+
);
|
|
1174
1361
|
},
|
|
1175
|
-
onUpdateBangMessage: () => {
|
|
1176
|
-
this.emit(
|
|
1362
|
+
onUpdateBangMessage: (command, output, messageId) => {
|
|
1363
|
+
this.emit(
|
|
1364
|
+
"bangMessageUpdated",
|
|
1365
|
+
{ command, output, messageId },
|
|
1366
|
+
ctx.registeredSessionId,
|
|
1367
|
+
);
|
|
1177
1368
|
},
|
|
1178
|
-
onCompleteBangMessage: () => {
|
|
1179
|
-
this.emit(
|
|
1369
|
+
onCompleteBangMessage: (command, exitCode, messageId) => {
|
|
1370
|
+
this.emit(
|
|
1371
|
+
"bangMessageCompleted",
|
|
1372
|
+
{ command, exitCode, messageId },
|
|
1373
|
+
ctx.registeredSessionId,
|
|
1374
|
+
);
|
|
1180
1375
|
},
|
|
1181
1376
|
onNotificationMessageAdded: (params) => {
|
|
1182
1377
|
const msg = ctx.agent?.messages.find(
|
|
@@ -0,0 +1,212 @@
|
|
|
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
|
+
|
|
19
|
+
import net from "net";
|
|
20
|
+
import * as fs from "fs";
|
|
21
|
+
import { AgentBridge, type AgentBridgeOptions } from "./agentBridge.js";
|
|
22
|
+
import { JsonRpcConnection } from "./jsonRpcConnection.js";
|
|
23
|
+
|
|
24
|
+
export interface DaemonServerOptions {
|
|
25
|
+
socketPath: string;
|
|
26
|
+
bridgeOptions?: AgentBridgeOptions;
|
|
27
|
+
/** Idle grace period before the daemon auto-exits (default 60s). */
|
|
28
|
+
graceMs?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class DaemonServer {
|
|
32
|
+
static readonly DEFAULT_IDLE_GRACE_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
private socketPath: string;
|
|
35
|
+
private server: net.Server | undefined;
|
|
36
|
+
private bridge: AgentBridge;
|
|
37
|
+
private connections = new Set<JsonRpcConnection>();
|
|
38
|
+
private sockets = new Set<net.Socket>();
|
|
39
|
+
private graceMs: number;
|
|
40
|
+
private idleTimer: NodeJS.Timeout | undefined;
|
|
41
|
+
private shuttingDown = false;
|
|
42
|
+
private stopped = false;
|
|
43
|
+
|
|
44
|
+
constructor(options: DaemonServerOptions) {
|
|
45
|
+
this.socketPath = options.socketPath;
|
|
46
|
+
this.graceMs = options.graceMs ?? DaemonServer.DEFAULT_IDLE_GRACE_MS;
|
|
47
|
+
this.bridge = new AgentBridge({
|
|
48
|
+
...options.bridgeOptions,
|
|
49
|
+
// Notifications go to every attached client; a fully detached daemon
|
|
50
|
+
// has none, and the write is dropped silently (the attach snapshot
|
|
51
|
+
// re-syncs state on reconnect).
|
|
52
|
+
emit: (method, params, sessionId) => {
|
|
53
|
+
for (const conn of this.connections) {
|
|
54
|
+
conn.sendNotification(method, params, sessionId);
|
|
55
|
+
}
|
|
56
|
+
// Any session activity can change the idle state — re-evaluate.
|
|
57
|
+
this.evaluateIdle();
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
this.server = net.createServer((socket) => {
|
|
61
|
+
if (this.shuttingDown) {
|
|
62
|
+
socket.destroy();
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const conn = new JsonRpcConnection(socket, socket, this.bridge);
|
|
66
|
+
this.connections.add(conn);
|
|
67
|
+
this.sockets.add(socket);
|
|
68
|
+
// A (re)attached client cancels a pending idle exit.
|
|
69
|
+
this.evaluateIdle();
|
|
70
|
+
socket.on("error", () => {
|
|
71
|
+
// The client (ssh tunnel) can reset the socket mid-detach; the daemon
|
|
72
|
+
// must keep running — 'close' below cleans up the connection.
|
|
73
|
+
});
|
|
74
|
+
socket.on("close", () => {
|
|
75
|
+
this.connections.delete(conn);
|
|
76
|
+
this.sockets.delete(socket);
|
|
77
|
+
// A client detach may leave the daemon idle — re-evaluate.
|
|
78
|
+
this.evaluateIdle();
|
|
79
|
+
});
|
|
80
|
+
conn.start();
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
get agentBridge(): AgentBridge {
|
|
85
|
+
return this.bridge;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Listen on the socket path. Rejects when a live daemon already holds it. A
|
|
90
|
+
* stale socket file left by a crashed daemon would otherwise block the
|
|
91
|
+
* restart with EADDRINUSE, so it is cleaned up first: non-socket files are
|
|
92
|
+
* unlinked outright; socket files are probe-connected — ECONNREFUSED means
|
|
93
|
+
* no listener (stale → unlink and listen), anything else means a live
|
|
94
|
+
* daemon owns the path (reject).
|
|
95
|
+
*/
|
|
96
|
+
start(): Promise<void> {
|
|
97
|
+
return new Promise((resolve, reject) => {
|
|
98
|
+
const server = this.server;
|
|
99
|
+
if (!server) return resolve();
|
|
100
|
+
try {
|
|
101
|
+
const st = fs.statSync(this.socketPath);
|
|
102
|
+
if (st.isSocket()) {
|
|
103
|
+
const probe = net.connect(this.socketPath);
|
|
104
|
+
probe.once("connect", () => {
|
|
105
|
+
probe.destroy();
|
|
106
|
+
reject(
|
|
107
|
+
new Error(`另一个 wave daemon 已在 ${this.socketPath} 监听`),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
probe.once("error", (err: NodeJS.ErrnoException) => {
|
|
111
|
+
probe.destroy();
|
|
112
|
+
if (err.code === "ECONNREFUSED") {
|
|
113
|
+
fs.unlinkSync(this.socketPath);
|
|
114
|
+
this.listen(server, resolve, reject);
|
|
115
|
+
} else {
|
|
116
|
+
reject(err);
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
fs.unlinkSync(this.socketPath);
|
|
122
|
+
} catch {
|
|
123
|
+
// ENOENT — no stale socket, listen directly.
|
|
124
|
+
}
|
|
125
|
+
this.listen(server, resolve, reject);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private listen(
|
|
130
|
+
server: net.Server,
|
|
131
|
+
resolve: () => void,
|
|
132
|
+
reject: (err: Error) => void,
|
|
133
|
+
): void {
|
|
134
|
+
server.once("error", reject);
|
|
135
|
+
server.listen(this.socketPath, () => {
|
|
136
|
+
server.removeListener("error", reject);
|
|
137
|
+
resolve();
|
|
138
|
+
// A freshly started daemon may already be idle (no sessions) — start the
|
|
139
|
+
// idle watch so a zero-session daemon also auto-exits.
|
|
140
|
+
this.evaluateIdle();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
stop(): Promise<void> {
|
|
145
|
+
this.stopped = true;
|
|
146
|
+
this.clearIdleTimer();
|
|
147
|
+
return new Promise((resolve) => {
|
|
148
|
+
const server = this.server;
|
|
149
|
+
if (!server) return resolve();
|
|
150
|
+
this.server = undefined;
|
|
151
|
+
server.close(() => resolve());
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ── Idle auto-exit ────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
private clearIdleTimer(): void {
|
|
158
|
+
if (this.idleTimer) {
|
|
159
|
+
clearTimeout(this.idleTimer);
|
|
160
|
+
this.idleTimer = undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Re-evaluate the idle condition after any state transition: sessions busy
|
|
166
|
+
* (loading / pending messages / background work) or any client attached →
|
|
167
|
+
* cancel the timer. Fully idle + detached → arm the grace timer once; when
|
|
168
|
+
* it fires, shut the daemon down. Evaluation is event-driven (every
|
|
169
|
+
* busy→idle transition emits a notification, every attach/detach fires a
|
|
170
|
+
* connection event), so nothing is polled. The failure mode is
|
|
171
|
+
* conservative: a missed transition just leaves the daemon running.
|
|
172
|
+
*/
|
|
173
|
+
private evaluateIdle(): void {
|
|
174
|
+
// A stopped/shutting-down daemon never (re)arms the idle timer — late
|
|
175
|
+
// socket 'close' events (which fire after server.close resolves) must not
|
|
176
|
+
// resurrect a timer after stop().
|
|
177
|
+
if (this.shuttingDown || this.stopped) return;
|
|
178
|
+
if (this.connections.size > 0 || !this.bridge.isIdle()) {
|
|
179
|
+
this.clearIdleTimer();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
if (this.idleTimer) return;
|
|
183
|
+
this.idleTimer = setTimeout(() => {
|
|
184
|
+
this.idleTimer = undefined;
|
|
185
|
+
void this.shutdown();
|
|
186
|
+
}, this.graceMs);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Destroy the sessions (each agent saves its transcript and drains
|
|
191
|
+
* auto-memory), close the listener, unlink the socket file, then exit.
|
|
192
|
+
* `shuttingDown` guards against re-entry: new connections are refused and
|
|
193
|
+
* further idle evaluations become no-ops.
|
|
194
|
+
*/
|
|
195
|
+
private async shutdown(): Promise<void> {
|
|
196
|
+
if (this.shuttingDown) return;
|
|
197
|
+
this.shuttingDown = true;
|
|
198
|
+
this.clearIdleTimer();
|
|
199
|
+
// Destroy client sockets first so server.close() can complete (an open
|
|
200
|
+
// socket keeps the close callback pending).
|
|
201
|
+
for (const socket of this.sockets) socket.destroy();
|
|
202
|
+
this.sockets.clear();
|
|
203
|
+
await this.bridge.destroyAll();
|
|
204
|
+
await this.stop();
|
|
205
|
+
try {
|
|
206
|
+
fs.unlinkSync(this.socketPath);
|
|
207
|
+
} catch {
|
|
208
|
+
// Already gone — a stale file would be probed/unlinked on next start.
|
|
209
|
+
}
|
|
210
|
+
process.exit(0);
|
|
211
|
+
}
|
|
212
|
+
}
|
package/src/stdio/index.ts
CHANGED