wave-code 1.0.0 → 1.0.2

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.
Files changed (72) hide show
  1. package/dist/cli.js +20 -1
  2. package/dist/components/App.js +7 -0
  3. package/dist/components/BtwDisplay.js +13 -3
  4. package/dist/components/ChatInterface.js +25 -8
  5. package/dist/components/InputBox.d.ts +1 -3
  6. package/dist/components/InputBox.js +12 -9
  7. package/dist/components/LoadingIndicator.d.ts +1 -2
  8. package/dist/components/LoadingIndicator.js +2 -2
  9. package/dist/components/LoginCommand.js +4 -2
  10. package/dist/components/Markdown.js +13 -16
  11. package/dist/components/Notifications.d.ts +7 -0
  12. package/dist/components/Notifications.js +9 -0
  13. package/dist/components/StatusLine.d.ts +0 -4
  14. package/dist/components/StatusLine.js +6 -10
  15. package/dist/components/TaskList.js +2 -1
  16. package/dist/components/ToolDisplay.d.ts +1 -0
  17. package/dist/components/ToolDisplay.js +17 -9
  18. package/dist/constants/commands.js +0 -6
  19. package/dist/contexts/useChat.d.ts +4 -6
  20. package/dist/contexts/useChat.js +253 -110
  21. package/dist/daemon-cli.d.ts +10 -0
  22. package/dist/daemon-cli.js +15 -0
  23. package/dist/hooks/useInputManager.js +99 -22
  24. package/dist/index.js +10 -0
  25. package/dist/managers/inputHandlers.js +50 -22
  26. package/dist/managers/inputReducer.d.ts +12 -2
  27. package/dist/managers/inputReducer.js +57 -9
  28. package/dist/stdio/agentBridge.d.ts +23 -0
  29. package/dist/stdio/agentBridge.js +134 -16
  30. package/dist/stdio/daemonServer.d.ts +67 -0
  31. package/dist/stdio/daemonServer.js +191 -0
  32. package/dist/stdio/index.d.ts +2 -0
  33. package/dist/stdio/index.js +2 -0
  34. package/dist/stdio/jsonRpcConnection.d.ts +30 -0
  35. package/dist/stdio/jsonRpcConnection.js +127 -0
  36. package/dist/stdio/protocol.d.ts +2 -2
  37. package/dist/stdio/stdioServer.d.ts +2 -7
  38. package/dist/stdio/stdioServer.js +9 -100
  39. package/dist/utils/bracketedPaste.d.ts +39 -0
  40. package/dist/utils/bracketedPaste.js +122 -0
  41. package/dist/utils/markdownTable.d.ts +34 -0
  42. package/dist/utils/markdownTable.js +302 -0
  43. package/dist/utils/throttle.d.ts +3 -3
  44. package/package.json +4 -2
  45. package/src/cli.tsx +20 -1
  46. package/src/components/App.tsx +5 -0
  47. package/src/components/BtwDisplay.tsx +36 -12
  48. package/src/components/ChatInterface.tsx +30 -15
  49. package/src/components/InputBox.tsx +25 -24
  50. package/src/components/LoadingIndicator.tsx +1 -4
  51. package/src/components/LoginCommand.tsx +4 -2
  52. package/src/components/Markdown.tsx +15 -18
  53. package/src/components/Notifications.tsx +31 -0
  54. package/src/components/StatusLine.tsx +17 -44
  55. package/src/components/TaskList.tsx +2 -1
  56. package/src/components/ToolDisplay.tsx +17 -6
  57. package/src/constants/commands.ts +0 -6
  58. package/src/contexts/useChat.tsx +326 -140
  59. package/src/daemon-cli.ts +17 -0
  60. package/src/hooks/useInputManager.ts +108 -22
  61. package/src/index.ts +12 -0
  62. package/src/managers/inputHandlers.ts +49 -22
  63. package/src/managers/inputReducer.ts +66 -11
  64. package/src/stdio/agentBridge.ts +196 -17
  65. package/src/stdio/daemonServer.ts +212 -0
  66. package/src/stdio/index.ts +2 -0
  67. package/src/stdio/jsonRpcConnection.ts +160 -0
  68. package/src/stdio/protocol.ts +5 -2
  69. package/src/stdio/stdioServer.ts +14 -120
  70. package/src/utils/bracketedPaste.ts +170 -0
  71. package/src/utils/markdownTable.ts +359 -0
  72. package/src/utils/throttle.ts +8 -8
@@ -118,9 +118,16 @@ interface SessionContext {
118
118
 
119
119
  export class AgentBridge {
120
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). */
121
124
  private pendingPermissions = new Map<
122
125
  string,
123
- (decision: PermissionDecision) => void
126
+ {
127
+ resolve: (decision: PermissionDecision) => void;
128
+ sessionId?: string;
129
+ context: ToolPermissionContext;
130
+ }
124
131
  >();
125
132
  private permissionCounter = 0;
126
133
  private emit: NotificationEmitter;
@@ -151,6 +158,8 @@ export class AgentBridge {
151
158
  return this.listSessions(p.workdir as string | undefined, sessionId);
152
159
  case "getSessionInfo":
153
160
  return this.getSessionInfo(sessionId);
161
+ case "listPendingPermissions":
162
+ return this.listPendingPermissions();
154
163
  case "updateConfig":
155
164
  return this.updateConfig(p as unknown as UpdateConfigParams, sessionId);
156
165
 
@@ -166,6 +175,8 @@ export class AgentBridge {
166
175
  );
167
176
  case "bang":
168
177
  return this.bang(p.command as string, sessionId);
178
+ case "askBtw":
179
+ return this.askBtw(p.question as string, sessionId);
169
180
  case "abortMessage":
170
181
  return this.abortMessage(sessionId);
171
182
  case "clearMessages":
@@ -353,10 +364,10 @@ export class AgentBridge {
353
364
  decision: PermissionDecision;
354
365
  };
355
366
  // requestId is process-level unique; lookup doesn't need sessionId
356
- const resolve = this.pendingPermissions.get(p.requestId);
357
- if (resolve) {
367
+ const entry = this.pendingPermissions.get(p.requestId);
368
+ if (entry) {
358
369
  this.pendingPermissions.delete(p.requestId);
359
- resolve(p.decision);
370
+ entry.resolve(p.decision);
360
371
  }
361
372
  }
362
373
  }
@@ -369,6 +380,21 @@ export class AgentBridge {
369
380
  permissionMode: PermissionMode;
370
381
  latestTotalTokens: number;
371
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
+
372
398
  const ctx: SessionContext = {};
373
399
  const callbacks = this.createCallbacks(ctx);
374
400
 
@@ -423,11 +449,70 @@ export class AgentBridge {
423
449
  return null;
424
450
  }
425
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
+
426
487
  private async restoreSession(
427
488
  restoreId: string,
428
489
  sessionId?: string,
429
490
  ): Promise<null> {
430
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
+ }
431
516
  await entry.agent.restoreSession(restoreId);
432
517
  return null;
433
518
  }
@@ -667,6 +752,35 @@ export class AgentBridge {
667
752
  return null;
668
753
  }
669
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
+
670
784
  private async abortMessage(sessionId?: string): Promise<null> {
671
785
  const entry = this.requireSession(sessionId);
672
786
  entry.agent.abortMessage();
@@ -901,7 +1015,11 @@ export class AgentBridge {
901
1015
  ): Promise<PermissionDecision> {
902
1016
  const requestId = `perm_${++this.permissionCounter}`;
903
1017
  return new Promise<PermissionDecision>((resolve) => {
904
- this.pendingPermissions.set(requestId, resolve);
1018
+ this.pendingPermissions.set(requestId, {
1019
+ resolve,
1020
+ sessionId: ctx.registeredSessionId,
1021
+ context,
1022
+ });
905
1023
  this.emit(
906
1024
  "permissionRequest",
907
1025
  { requestId, context },
@@ -910,6 +1028,27 @@ export class AgentBridge {
910
1028
  });
911
1029
  }
912
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
+
913
1052
  // ── Auth (global) ────────────────────────────────────────────
914
1053
 
915
1054
  private async getAuthStatus(): Promise<{
@@ -918,6 +1057,14 @@ export class AgentBridge {
918
1057
  serverUrl: string;
919
1058
  }> {
920
1059
  const authService = AuthService.getInstance();
1060
+ // A stale-but-refreshable token still means "logged in" — the daemon may
1061
+ // have started with an expired access token (hourly expiry) and only
1062
+ // refreshes lazily on the first API call. Without this proactive refresh a
1063
+ // fresh client querying right after daemon start gets a false
1064
+ // isAuthenticated and e.g. the desktop welcome page keeps showing the
1065
+ // login button for an authenticated host. Mirrors the refresh that
1066
+ // createAuthAwareFetch does before every real request.
1067
+ await authService.checkAndRefreshTokenIfNeeded();
921
1068
  return {
922
1069
  isAuthenticated: authService.isSSOAuthenticated(),
923
1070
  user: authService.getAuthUser(),
@@ -1091,9 +1238,6 @@ export class AgentBridge {
1091
1238
 
1092
1239
  private createCallbacks(ctx: SessionContext): AgentCallbacks {
1093
1240
  return {
1094
- onMessagesChange: (messages: Message[]) => {
1095
- this.emit("messagesChange", { messages }, ctx.registeredSessionId);
1096
- },
1097
1241
  onUserMessageAdded: () => {
1098
1242
  const msg = this.findLastUserMessage(ctx.agent);
1099
1243
  if (msg)
@@ -1113,13 +1257,36 @@ export class AgentBridge {
1113
1257
  );
1114
1258
  },
1115
1259
  onAssistantContentUpdated: (params) => {
1116
- this.emit("assistantContentUpdated", params, ctx.registeredSessionId);
1260
+ // Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
1261
+ this.emit(
1262
+ "assistantContentUpdated",
1263
+ {
1264
+ messageId: params.messageId,
1265
+ chunk: params.chunk,
1266
+ stage: params.stage,
1267
+ },
1268
+ ctx.registeredSessionId,
1269
+ );
1117
1270
  },
1118
1271
  onAssistantReasoningUpdated: (params) => {
1119
- this.emit("assistantReasoningUpdated", params, ctx.registeredSessionId);
1272
+ this.emit(
1273
+ "assistantReasoningUpdated",
1274
+ {
1275
+ messageId: params.messageId,
1276
+ chunk: params.chunk,
1277
+ stage: params.stage,
1278
+ },
1279
+ ctx.registeredSessionId,
1280
+ );
1120
1281
  },
1121
1282
  onToolBlockUpdated: (params) => {
1122
- this.emit("toolBlockUpdated", params, ctx.registeredSessionId);
1283
+ // Streaming stages carry only the parametersChunk delta; start/running
1284
+ // (one-time snapshots) and end (authoritative full value) keep
1285
+ // `parameters` (spec: 流式通知纯增量负载).
1286
+ const { parameters, ...rest } = params;
1287
+ void parameters;
1288
+ const wireParams = params.stage === "streaming" ? rest : params;
1289
+ this.emit("toolBlockUpdated", wireParams, ctx.registeredSessionId);
1123
1290
  },
1124
1291
  onErrorBlockAdded: (error: string) => {
1125
1292
  this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
@@ -1193,14 +1360,26 @@ export class AgentBridge {
1193
1360
  onMcpServersChange: (servers: McpServerStatus[]) => {
1194
1361
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
1195
1362
  },
1196
- onAddBangMessage: () => {
1197
- this.emit("bangMessageAdded", {}, ctx.registeredSessionId);
1363
+ onAddBangMessage: (command, messageId) => {
1364
+ this.emit(
1365
+ "bangMessageAdded",
1366
+ { command, messageId },
1367
+ ctx.registeredSessionId,
1368
+ );
1198
1369
  },
1199
- onUpdateBangMessage: () => {
1200
- this.emit("bangMessageUpdated", {}, ctx.registeredSessionId);
1370
+ onUpdateBangMessage: (command, output, messageId) => {
1371
+ this.emit(
1372
+ "bangMessageUpdated",
1373
+ { command, output, messageId },
1374
+ ctx.registeredSessionId,
1375
+ );
1201
1376
  },
1202
- onCompleteBangMessage: () => {
1203
- this.emit("bangMessageCompleted", {}, ctx.registeredSessionId);
1377
+ onCompleteBangMessage: (command, exitCode, messageId) => {
1378
+ this.emit(
1379
+ "bangMessageCompleted",
1380
+ { command, exitCode, messageId },
1381
+ ctx.registeredSessionId,
1382
+ );
1204
1383
  },
1205
1384
  onNotificationMessageAdded: (params) => {
1206
1385
  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
+ }
@@ -1,4 +1,6 @@
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 {
3
5
  AgentBridge,
4
6
  type AgentBridgeOptions,
@@ -0,0 +1,160 @@
1
+ /**
2
+ * JsonRpcConnection — one JSON-RPC connection over a Readable/Writable pair,
3
+ * dispatching to a shared AgentBridge.
4
+ *
5
+ * Shared by StdioServer (process stdin/stdout) and DaemonServer (each unix
6
+ * socket connection). The AgentBridge owns all session/agent state, so any
7
+ * number of connections can share it — a detached client loses nothing and
8
+ * every session keeps running on the daemon side.
9
+ */
10
+
11
+ import readline from "readline";
12
+ import type { Readable, Writable } from "stream";
13
+ import { AgentBridge } from "./agentBridge.js";
14
+ import {
15
+ type JsonRpcRequest,
16
+ type JsonRpcResponse,
17
+ type JsonRpcNotification,
18
+ PARSE_ERROR,
19
+ INVALID_REQUEST,
20
+ INTERNAL_ERROR,
21
+ isRequest,
22
+ isNotification,
23
+ } from "./protocol.js";
24
+
25
+ export class JsonRpcConnection {
26
+ private rl: readline.Interface | undefined;
27
+ private started = false;
28
+
29
+ constructor(
30
+ private input: Readable,
31
+ private output: Writable,
32
+ private bridge: AgentBridge,
33
+ ) {}
34
+
35
+ start(): void {
36
+ if (this.started) return;
37
+ this.started = true;
38
+
39
+ this.rl = readline.createInterface({
40
+ input: this.input,
41
+ crlfDelay: Infinity,
42
+ });
43
+
44
+ // readline re-emits input errors on the Interface; error handling on the
45
+ // underlying stream is the owner's job (the daemon keeps running on a
46
+ // client reset), so swallow them here.
47
+ this.rl.on("error", () => {});
48
+
49
+ this.rl.on("line", (line: string) => {
50
+ this.handleLine(line).catch((err) => {
51
+ // Should never reach here — handleLine catches internally
52
+ this.sendResponse(null, undefined, {
53
+ code: INTERNAL_ERROR,
54
+ message: `Unhandled error: ${(err as Error).message}`,
55
+ });
56
+ });
57
+ });
58
+
59
+ this.rl.on("close", () => {
60
+ this.started = false;
61
+ });
62
+ }
63
+
64
+ stop(): void {
65
+ this.rl?.close();
66
+ this.rl = undefined;
67
+ this.started = false;
68
+ }
69
+
70
+ async handleLine(line: string): Promise<void> {
71
+ const trimmed = line.trim();
72
+ if (!trimmed) return;
73
+
74
+ let msg: unknown;
75
+ try {
76
+ msg = JSON.parse(trimmed);
77
+ } catch {
78
+ this.sendResponse(null, undefined, {
79
+ code: PARSE_ERROR,
80
+ message: "Parse error: invalid JSON",
81
+ });
82
+ return;
83
+ }
84
+
85
+ if (isRequest(msg)) {
86
+ await this.handleRequest(msg);
87
+ } else if (isNotification(msg)) {
88
+ this.handleNotification(msg);
89
+ } else {
90
+ // Echo back the id if the message has one, otherwise null
91
+ const id =
92
+ typeof msg === "object" &&
93
+ msg !== null &&
94
+ "id" in msg &&
95
+ (typeof (msg as { id: unknown }).id === "number" ||
96
+ typeof (msg as { id: unknown }).id === "string")
97
+ ? (msg as { id: number | string }).id
98
+ : null;
99
+ this.sendResponse(id, undefined, {
100
+ code: INVALID_REQUEST,
101
+ message: "Invalid request: must have 'method' field",
102
+ });
103
+ }
104
+ }
105
+
106
+ private async handleRequest(msg: JsonRpcRequest): Promise<void> {
107
+ try {
108
+ const result = await this.bridge.handleRequest(
109
+ msg.method,
110
+ msg.params,
111
+ msg.sessionId,
112
+ );
113
+ this.sendResponse(msg.id, result);
114
+ } catch (err) {
115
+ const code =
116
+ err && typeof err === "object" && "code" in err
117
+ ? (err as { code: number }).code
118
+ : INTERNAL_ERROR;
119
+ const message = err instanceof Error ? err.message : String(err);
120
+ this.sendResponse(msg.id, undefined, { code, message });
121
+ }
122
+ }
123
+
124
+ private handleNotification(msg: JsonRpcNotification): void {
125
+ try {
126
+ this.bridge.handleNotification(msg.method, msg.params);
127
+ } catch (err) {
128
+ // Notifications don't get responses, but we log to stderr
129
+ process.stderr.write(
130
+ `Error handling notification ${msg.method}: ${(err as Error).message}\n`,
131
+ );
132
+ }
133
+ }
134
+
135
+ // ── Output helpers ────────────────────────────────────────────
136
+
137
+ sendResponse(
138
+ id: number | string | null,
139
+ result?: unknown,
140
+ error?: { code: number; message: string },
141
+ ): void {
142
+ const response: JsonRpcResponse = { id };
143
+ if (error) {
144
+ response.error = error;
145
+ } else {
146
+ response.result = result ?? null;
147
+ }
148
+ this.write(response);
149
+ }
150
+
151
+ sendNotification(method: string, params?: unknown, sessionId?: string): void {
152
+ const notification: JsonRpcNotification = { method, params };
153
+ if (sessionId) notification.sessionId = sessionId;
154
+ this.write(notification);
155
+ }
156
+
157
+ private write(obj: JsonRpcResponse | JsonRpcNotification): void {
158
+ this.output.write(JSON.stringify(obj) + "\n");
159
+ }
160
+ }