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
@@ -23,6 +23,9 @@ import { isUserCheckpointMessage } from "../utils/rewindCheckpoints.js";
23
23
  export class AgentBridge {
24
24
  constructor(options) {
25
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). */
26
29
  this.pendingPermissions = new Map();
27
30
  this.permissionCounter = 0;
28
31
  this.emit = options.emit;
@@ -42,6 +45,8 @@ export class AgentBridge {
42
45
  return this.listSessions(p.workdir, sessionId);
43
46
  case "getSessionInfo":
44
47
  return this.getSessionInfo(sessionId);
48
+ case "listPendingPermissions":
49
+ return this.listPendingPermissions();
45
50
  case "updateConfig":
46
51
  return this.updateConfig(p, sessionId);
47
52
  // ── Messages ──
@@ -49,6 +54,8 @@ export class AgentBridge {
49
54
  return this.sendMessage(p, sessionId);
50
55
  case "bang":
51
56
  return this.bang(p.command, sessionId);
57
+ case "askBtw":
58
+ return this.askBtw(p.question, sessionId);
52
59
  case "abortMessage":
53
60
  return this.abortMessage(sessionId);
54
61
  case "clearMessages":
@@ -147,15 +154,29 @@ export class AgentBridge {
147
154
  if (method === "permissionResponse") {
148
155
  const p = params;
149
156
  // requestId is process-level unique; lookup doesn't need sessionId
150
- const resolve = this.pendingPermissions.get(p.requestId);
151
- if (resolve) {
157
+ const entry = this.pendingPermissions.get(p.requestId);
158
+ if (entry) {
152
159
  this.pendingPermissions.delete(p.requestId);
153
- resolve(p.decision);
160
+ entry.resolve(p.decision);
154
161
  }
155
162
  }
156
163
  }
157
164
  // ── Lifecycle ─────────────────────────────────────────────────
158
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
+ }
159
180
  const ctx = {};
160
181
  const callbacks = this.createCallbacks(ctx);
161
182
  const options = {
@@ -203,8 +224,53 @@ export class AgentBridge {
203
224
  }
204
225
  return null;
205
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
+ }
206
256
  async restoreSession(restoreId, sessionId) {
207
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
+ }
208
274
  await entry.agent.restoreSession(restoreId);
209
275
  return null;
210
276
  }
@@ -378,6 +444,22 @@ export class AgentBridge {
378
444
  await entry.agent.bang(command);
379
445
  return null;
380
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
+ }
381
463
  async abortMessage(sessionId) {
382
464
  const entry = this.requireSession(sessionId);
383
465
  entry.agent.abortMessage();
@@ -522,13 +604,37 @@ export class AgentBridge {
522
604
  canUseTool(context, ctx) {
523
605
  const requestId = `perm_${++this.permissionCounter}`;
524
606
  return new Promise((resolve) => {
525
- this.pendingPermissions.set(requestId, resolve);
607
+ this.pendingPermissions.set(requestId, {
608
+ resolve,
609
+ sessionId: ctx.registeredSessionId,
610
+ context,
611
+ });
526
612
  this.emit("permissionRequest", { requestId, context }, ctx.registeredSessionId);
527
613
  });
528
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
+ }
529
627
  // ── Auth (global) ────────────────────────────────────────────
530
628
  async getAuthStatus() {
531
629
  const authService = AuthService.getInstance();
630
+ // A stale-but-refreshable token still means "logged in" — the daemon may
631
+ // have started with an expired access token (hourly expiry) and only
632
+ // refreshes lazily on the first API call. Without this proactive refresh a
633
+ // fresh client querying right after daemon start gets a false
634
+ // isAuthenticated and e.g. the desktop welcome page keeps showing the
635
+ // login button for an authenticated host. Mirrors the refresh that
636
+ // createAuthAwareFetch does before every real request.
637
+ await authService.checkAndRefreshTokenIfNeeded();
532
638
  return {
533
639
  isAuthenticated: authService.isSSOAuthenticated(),
534
640
  user: authService.getAuthUser(),
@@ -627,9 +733,6 @@ export class AgentBridge {
627
733
  // ── Callbacks → Notifications ─────────────────────────────────
628
734
  createCallbacks(ctx) {
629
735
  return {
630
- onMessagesChange: (messages) => {
631
- this.emit("messagesChange", { messages }, ctx.registeredSessionId);
632
- },
633
736
  onUserMessageAdded: () => {
634
737
  const msg = this.findLastUserMessage(ctx.agent);
635
738
  if (msg)
@@ -641,13 +744,28 @@ export class AgentBridge {
641
744
  this.emit("assistantMessageAdded", { message: msg }, ctx.registeredSessionId);
642
745
  },
643
746
  onAssistantContentUpdated: (params) => {
644
- this.emit("assistantContentUpdated", params, ctx.registeredSessionId);
747
+ // Wire carries only the delta; consumers accumulate (spec: 流式通知纯增量负载).
748
+ this.emit("assistantContentUpdated", {
749
+ messageId: params.messageId,
750
+ chunk: params.chunk,
751
+ stage: params.stage,
752
+ }, ctx.registeredSessionId);
645
753
  },
646
754
  onAssistantReasoningUpdated: (params) => {
647
- this.emit("assistantReasoningUpdated", params, ctx.registeredSessionId);
755
+ this.emit("assistantReasoningUpdated", {
756
+ messageId: params.messageId,
757
+ chunk: params.chunk,
758
+ stage: params.stage,
759
+ }, ctx.registeredSessionId);
648
760
  },
649
761
  onToolBlockUpdated: (params) => {
650
- this.emit("toolBlockUpdated", params, ctx.registeredSessionId);
762
+ // Streaming stages carry only the parametersChunk delta; start/running
763
+ // (one-time snapshots) and end (authoritative full value) keep
764
+ // `parameters` (spec: 流式通知纯增量负载).
765
+ const { parameters, ...rest } = params;
766
+ void parameters;
767
+ const wireParams = params.stage === "streaming" ? rest : params;
768
+ this.emit("toolBlockUpdated", wireParams, ctx.registeredSessionId);
651
769
  },
652
770
  onErrorBlockAdded: (error) => {
653
771
  this.emit("errorBlockAdded", { error }, ctx.registeredSessionId);
@@ -705,14 +823,14 @@ export class AgentBridge {
705
823
  onMcpServersChange: (servers) => {
706
824
  this.emit("mcpServersChange", { servers }, ctx.registeredSessionId);
707
825
  },
708
- onAddBangMessage: () => {
709
- this.emit("bangMessageAdded", {}, ctx.registeredSessionId);
826
+ onAddBangMessage: (command, messageId) => {
827
+ this.emit("bangMessageAdded", { command, messageId }, ctx.registeredSessionId);
710
828
  },
711
- onUpdateBangMessage: () => {
712
- this.emit("bangMessageUpdated", {}, ctx.registeredSessionId);
829
+ onUpdateBangMessage: (command, output, messageId) => {
830
+ this.emit("bangMessageUpdated", { command, output, messageId }, ctx.registeredSessionId);
713
831
  },
714
- onCompleteBangMessage: () => {
715
- this.emit("bangMessageCompleted", {}, ctx.registeredSessionId);
832
+ onCompleteBangMessage: (command, exitCode, messageId) => {
833
+ this.emit("bangMessageCompleted", { command, exitCode, messageId }, ctx.registeredSessionId);
716
834
  },
717
835
  onNotificationMessageAdded: (params) => {
718
836
  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;
@@ -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";
@@ -1,3 +1,5 @@
1
1
  export { StdioServer } from "./stdioServer.js";
2
+ export { JsonRpcConnection } from "./jsonRpcConnection.js";
3
+ export { DaemonServer } from "./daemonServer.js";
2
4
  export { AgentBridge, RpcError, } from "./agentBridge.js";
3
5
  export * from "./protocol.js";
@@ -0,0 +1,30 @@
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
+ import type { Readable, Writable } from "stream";
11
+ import { AgentBridge } from "./agentBridge.js";
12
+ export declare class JsonRpcConnection {
13
+ private input;
14
+ private output;
15
+ private bridge;
16
+ private rl;
17
+ private started;
18
+ constructor(input: Readable, output: Writable, bridge: AgentBridge);
19
+ start(): void;
20
+ stop(): void;
21
+ handleLine(line: string): Promise<void>;
22
+ private handleRequest;
23
+ private handleNotification;
24
+ sendResponse(id: number | string | null, result?: unknown, error?: {
25
+ code: number;
26
+ message: string;
27
+ }): void;
28
+ sendNotification(method: string, params?: unknown, sessionId?: string): void;
29
+ private write;
30
+ }
@@ -0,0 +1,127 @@
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
+ import readline from "readline";
11
+ import { PARSE_ERROR, INVALID_REQUEST, INTERNAL_ERROR, isRequest, isNotification, } from "./protocol.js";
12
+ export class JsonRpcConnection {
13
+ constructor(input, output, bridge) {
14
+ this.input = input;
15
+ this.output = output;
16
+ this.bridge = bridge;
17
+ this.started = false;
18
+ }
19
+ start() {
20
+ if (this.started)
21
+ return;
22
+ this.started = true;
23
+ this.rl = readline.createInterface({
24
+ input: this.input,
25
+ crlfDelay: Infinity,
26
+ });
27
+ // readline re-emits input errors on the Interface; error handling on the
28
+ // underlying stream is the owner's job (the daemon keeps running on a
29
+ // client reset), so swallow them here.
30
+ this.rl.on("error", () => { });
31
+ this.rl.on("line", (line) => {
32
+ this.handleLine(line).catch((err) => {
33
+ // Should never reach here — handleLine catches internally
34
+ this.sendResponse(null, undefined, {
35
+ code: INTERNAL_ERROR,
36
+ message: `Unhandled error: ${err.message}`,
37
+ });
38
+ });
39
+ });
40
+ this.rl.on("close", () => {
41
+ this.started = false;
42
+ });
43
+ }
44
+ stop() {
45
+ this.rl?.close();
46
+ this.rl = undefined;
47
+ this.started = false;
48
+ }
49
+ async handleLine(line) {
50
+ const trimmed = line.trim();
51
+ if (!trimmed)
52
+ return;
53
+ let msg;
54
+ try {
55
+ msg = JSON.parse(trimmed);
56
+ }
57
+ catch {
58
+ this.sendResponse(null, undefined, {
59
+ code: PARSE_ERROR,
60
+ message: "Parse error: invalid JSON",
61
+ });
62
+ return;
63
+ }
64
+ if (isRequest(msg)) {
65
+ await this.handleRequest(msg);
66
+ }
67
+ else if (isNotification(msg)) {
68
+ this.handleNotification(msg);
69
+ }
70
+ else {
71
+ // Echo back the id if the message has one, otherwise null
72
+ const id = typeof msg === "object" &&
73
+ msg !== null &&
74
+ "id" in msg &&
75
+ (typeof msg.id === "number" ||
76
+ typeof msg.id === "string")
77
+ ? msg.id
78
+ : null;
79
+ this.sendResponse(id, undefined, {
80
+ code: INVALID_REQUEST,
81
+ message: "Invalid request: must have 'method' field",
82
+ });
83
+ }
84
+ }
85
+ async handleRequest(msg) {
86
+ try {
87
+ const result = await this.bridge.handleRequest(msg.method, msg.params, msg.sessionId);
88
+ this.sendResponse(msg.id, result);
89
+ }
90
+ catch (err) {
91
+ const code = err && typeof err === "object" && "code" in err
92
+ ? err.code
93
+ : INTERNAL_ERROR;
94
+ const message = err instanceof Error ? err.message : String(err);
95
+ this.sendResponse(msg.id, undefined, { code, message });
96
+ }
97
+ }
98
+ handleNotification(msg) {
99
+ try {
100
+ this.bridge.handleNotification(msg.method, msg.params);
101
+ }
102
+ catch (err) {
103
+ // Notifications don't get responses, but we log to stderr
104
+ process.stderr.write(`Error handling notification ${msg.method}: ${err.message}\n`);
105
+ }
106
+ }
107
+ // ── Output helpers ────────────────────────────────────────────
108
+ sendResponse(id, result, error) {
109
+ const response = { id };
110
+ if (error) {
111
+ response.error = error;
112
+ }
113
+ else {
114
+ response.result = result ?? null;
115
+ }
116
+ this.write(response);
117
+ }
118
+ sendNotification(method, params, sessionId) {
119
+ const notification = { method, params };
120
+ if (sessionId)
121
+ notification.sessionId = sessionId;
122
+ this.write(notification);
123
+ }
124
+ write(obj) {
125
+ this.output.write(JSON.stringify(obj) + "\n");
126
+ }
127
+ }