appduct 0.10.0 → 0.11.0

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 (230) hide show
  1. package/README.md +121 -51
  2. package/dist/artifact-inspect.d.ts +0 -1
  3. package/dist/bin.d.ts +0 -1
  4. package/dist/bin.js +1250 -2
  5. package/dist/cli/command-options.d.ts +8 -1
  6. package/dist/cli/create-cli.d.ts +0 -1
  7. package/dist/cli/dispatch.d.ts +10 -1
  8. package/dist/cli/envelope.d.ts +19 -0
  9. package/dist/cli/global-flags.d.ts +30 -0
  10. package/dist/cli/open-target.d.ts +25 -8
  11. package/dist/cli/result-types.d.ts +30 -5
  12. package/dist/cli/router.d.ts +78 -0
  13. package/dist/cli/routes/daemon/index.d.ts +9 -0
  14. package/dist/cli/routes/daemon/index.js +848 -0
  15. package/dist/cli/routes/daemon/run.d.ts +3 -0
  16. package/dist/cli/routes/daemon/run.js +3246 -0
  17. package/dist/cli/routes/daemon/start.d.ts +3 -0
  18. package/dist/cli/routes/daemon/start.js +1668 -0
  19. package/dist/cli/routes/daemon/status.d.ts +4 -0
  20. package/dist/cli/routes/daemon/status.js +1676 -0
  21. package/dist/cli/routes/daemon/stop.d.ts +4 -0
  22. package/dist/cli/routes/daemon/stop.js +1646 -0
  23. package/dist/cli/routes/doctor.d.ts +3 -0
  24. package/dist/cli/routes/doctor.js +1075 -0
  25. package/dist/cli/routes/events.d.ts +3 -0
  26. package/dist/cli/routes/events.js +1906 -0
  27. package/dist/cli/routes/init.d.ts +3 -0
  28. package/dist/cli/routes/init.js +1486 -0
  29. package/dist/cli/routes/invoke.d.ts +3 -0
  30. package/dist/cli/routes/invoke.js +1884 -0
  31. package/dist/cli/routes/keygen.d.ts +3 -0
  32. package/dist/cli/routes/keygen.js +928 -0
  33. package/dist/cli/routes/link.d.ts +3 -0
  34. package/dist/cli/routes/link.js +2705 -0
  35. package/dist/cli/routes/ls.d.ts +3 -0
  36. package/dist/cli/routes/ls.js +1659 -0
  37. package/dist/cli/routes/mcp.d.ts +5 -0
  38. package/dist/cli/routes/mcp.js +3917 -0
  39. package/dist/cli/routes/revoke.d.ts +3 -0
  40. package/dist/cli/routes/revoke.js +1675 -0
  41. package/dist/cli/routes/tools.d.ts +3 -0
  42. package/dist/cli/routes/tools.js +1851 -0
  43. package/dist/cli/runner.d.ts +3 -11
  44. package/dist/cli/types.d.ts +9 -6
  45. package/dist/cli/version-guard.d.ts +20 -0
  46. package/dist/cli.d.ts +0 -1
  47. package/dist/client/app-client.d.ts +0 -1
  48. package/dist/client/bootstrap.d.ts +4 -4
  49. package/dist/client/connect.d.ts +0 -1
  50. package/dist/client/errors.d.ts +0 -1
  51. package/dist/client/index.d.ts +0 -1
  52. package/dist/client/index.js +2379 -16
  53. package/dist/commands/daemon/context.d.ts +14 -0
  54. package/dist/commands/daemon/run.d.ts +11 -0
  55. package/dist/commands/daemon/start.d.ts +5 -0
  56. package/dist/commands/daemon/status.d.ts +5 -0
  57. package/dist/commands/daemon/stop.d.ts +5 -0
  58. package/dist/commands/doctor.d.ts +0 -1
  59. package/dist/commands/events.d.ts +0 -1
  60. package/dist/commands/init.d.ts +17 -4
  61. package/dist/commands/invoke.d.ts +0 -1
  62. package/dist/commands/keygen.d.ts +0 -1
  63. package/dist/commands/link.d.ts +1 -2
  64. package/dist/commands/ls.d.ts +0 -1
  65. package/dist/commands/mcp.d.ts +2 -4
  66. package/dist/commands/revoke.d.ts +0 -1
  67. package/dist/commands/tools.d.ts +19 -3
  68. package/dist/daemon/address.d.ts +0 -1
  69. package/dist/daemon/audit.d.ts +6 -11
  70. package/dist/daemon/calls.d.ts +0 -1
  71. package/dist/daemon/config.d.ts +13 -16
  72. package/dist/daemon/daemon.d.ts +0 -1
  73. package/dist/daemon/event-bus.d.ts +0 -1
  74. package/dist/daemon/links.d.ts +0 -1
  75. package/dist/daemon/listener.d.ts +0 -1
  76. package/dist/daemon/log-rotation.d.ts +0 -1
  77. package/dist/daemon/pidfile.d.ts +14 -8
  78. package/dist/daemon/policy.d.ts +0 -1
  79. package/dist/daemon/registry.d.ts +0 -1
  80. package/dist/daemon/rpc-errors.d.ts +16 -0
  81. package/dist/daemon/rpc-server.d.ts +0 -11
  82. package/dist/daemon/sessions.d.ts +0 -1
  83. package/dist/daemon/socket-probe.d.ts +0 -1
  84. package/dist/daemon/state-dir.d.ts +0 -1
  85. package/dist/daemon/timers.d.ts +0 -1
  86. package/dist/daemon/tls.d.ts +0 -1
  87. package/dist/errors.d.ts +0 -1
  88. package/dist/host-certificate.d.ts +0 -1
  89. package/dist/index.d.ts +0 -1
  90. package/dist/index.js +1307 -3
  91. package/dist/key-material.d.ts +0 -1
  92. package/dist/link.d.ts +8 -7
  93. package/dist/mcp/app-tools.d.ts +153 -0
  94. package/dist/mcp/connect-tool.d.ts +14 -8
  95. package/dist/mcp/daemon-tools.d.ts +2 -11
  96. package/dist/mcp/events-tool.d.ts +0 -1
  97. package/dist/mcp/server.d.ts +33 -14
  98. package/dist/native-scheme.d.ts +0 -1
  99. package/dist/output.d.ts +8 -11
  100. package/dist/package-root.d.ts +11 -0
  101. package/dist/package-version.d.ts +0 -1
  102. package/dist/qr-terminal.d.ts +0 -1
  103. package/dist/rpc/client.d.ts +0 -1
  104. package/dist/scheme.d.ts +60 -2
  105. package/dist/spki-pin.d.ts +0 -1
  106. package/package.json +4 -3
  107. package/dist/artifact-inspect.d.ts.map +0 -1
  108. package/dist/artifact-inspect.js +0 -334
  109. package/dist/bin.d.ts.map +0 -1
  110. package/dist/cli/command-options.d.ts.map +0 -1
  111. package/dist/cli/command-options.js +0 -95
  112. package/dist/cli/create-cli.d.ts.map +0 -1
  113. package/dist/cli/create-cli.js +0 -56
  114. package/dist/cli/dispatch.d.ts.map +0 -1
  115. package/dist/cli/dispatch.js +0 -275
  116. package/dist/cli/open-target.d.ts.map +0 -1
  117. package/dist/cli/open-target.js +0 -438
  118. package/dist/cli/result-types.d.ts.map +0 -1
  119. package/dist/cli/result-types.js +0 -6
  120. package/dist/cli/runner.d.ts.map +0 -1
  121. package/dist/cli/runner.js +0 -111
  122. package/dist/cli/types.d.ts.map +0 -1
  123. package/dist/cli/types.js +0 -10
  124. package/dist/cli.d.ts.map +0 -1
  125. package/dist/cli.js +0 -2
  126. package/dist/client/app-client.d.ts.map +0 -1
  127. package/dist/client/app-client.js +0 -223
  128. package/dist/client/bootstrap.d.ts.map +0 -1
  129. package/dist/client/bootstrap.js +0 -108
  130. package/dist/client/connect.d.ts.map +0 -1
  131. package/dist/client/connect.js +0 -38
  132. package/dist/client/errors.d.ts.map +0 -1
  133. package/dist/client/errors.js +0 -40
  134. package/dist/client/index.d.ts.map +0 -1
  135. package/dist/commands/daemon.d.ts +0 -22
  136. package/dist/commands/daemon.d.ts.map +0 -1
  137. package/dist/commands/daemon.js +0 -128
  138. package/dist/commands/doctor.d.ts.map +0 -1
  139. package/dist/commands/doctor.js +0 -42
  140. package/dist/commands/events.d.ts.map +0 -1
  141. package/dist/commands/events.js +0 -76
  142. package/dist/commands/init.d.ts.map +0 -1
  143. package/dist/commands/init.js +0 -231
  144. package/dist/commands/invoke.d.ts.map +0 -1
  145. package/dist/commands/invoke.js +0 -54
  146. package/dist/commands/keygen.d.ts.map +0 -1
  147. package/dist/commands/keygen.js +0 -46
  148. package/dist/commands/link.d.ts.map +0 -1
  149. package/dist/commands/link.js +0 -43
  150. package/dist/commands/ls.d.ts.map +0 -1
  151. package/dist/commands/ls.js +0 -7
  152. package/dist/commands/mcp.d.ts.map +0 -1
  153. package/dist/commands/mcp.js +0 -98
  154. package/dist/commands/revoke.d.ts.map +0 -1
  155. package/dist/commands/revoke.js +0 -7
  156. package/dist/commands/tools.d.ts.map +0 -1
  157. package/dist/commands/tools.js +0 -52
  158. package/dist/daemon/address.d.ts.map +0 -1
  159. package/dist/daemon/address.js +0 -71
  160. package/dist/daemon/audit.d.ts.map +0 -1
  161. package/dist/daemon/audit.js +0 -237
  162. package/dist/daemon/calls.d.ts.map +0 -1
  163. package/dist/daemon/calls.js +0 -158
  164. package/dist/daemon/config.d.ts.map +0 -1
  165. package/dist/daemon/config.js +0 -170
  166. package/dist/daemon/daemon.d.ts.map +0 -1
  167. package/dist/daemon/daemon.js +0 -560
  168. package/dist/daemon/event-bus.d.ts.map +0 -1
  169. package/dist/daemon/event-bus.js +0 -111
  170. package/dist/daemon/links.d.ts.map +0 -1
  171. package/dist/daemon/links.js +0 -118
  172. package/dist/daemon/listener.d.ts.map +0 -1
  173. package/dist/daemon/listener.js +0 -132
  174. package/dist/daemon/log-rotation.d.ts.map +0 -1
  175. package/dist/daemon/log-rotation.js +0 -136
  176. package/dist/daemon/pidfile.d.ts.map +0 -1
  177. package/dist/daemon/pidfile.js +0 -90
  178. package/dist/daemon/policy.d.ts.map +0 -1
  179. package/dist/daemon/policy.js +0 -24
  180. package/dist/daemon/registry.d.ts.map +0 -1
  181. package/dist/daemon/registry.js +0 -31
  182. package/dist/daemon/rpc-server.d.ts.map +0 -1
  183. package/dist/daemon/rpc-server.js +0 -203
  184. package/dist/daemon/sessions.d.ts.map +0 -1
  185. package/dist/daemon/sessions.js +0 -429
  186. package/dist/daemon/socket-probe.d.ts.map +0 -1
  187. package/dist/daemon/socket-probe.js +0 -28
  188. package/dist/daemon/state-dir.d.ts.map +0 -1
  189. package/dist/daemon/state-dir.js +0 -43
  190. package/dist/daemon/timers.d.ts.map +0 -1
  191. package/dist/daemon/timers.js +0 -12
  192. package/dist/daemon/tls.d.ts.map +0 -1
  193. package/dist/daemon/tls.js +0 -79
  194. package/dist/errors.d.ts.map +0 -1
  195. package/dist/errors.js +0 -162
  196. package/dist/host-certificate.d.ts.map +0 -1
  197. package/dist/host-certificate.js +0 -111
  198. package/dist/index.d.ts.map +0 -1
  199. package/dist/key-material.d.ts.map +0 -1
  200. package/dist/key-material.js +0 -42
  201. package/dist/link.d.ts.map +0 -1
  202. package/dist/link.js +0 -117
  203. package/dist/mcp/connect-tool.d.ts.map +0 -1
  204. package/dist/mcp/connect-tool.js +0 -396
  205. package/dist/mcp/daemon-tools.d.ts.map +0 -1
  206. package/dist/mcp/daemon-tools.js +0 -29
  207. package/dist/mcp/events-tool.d.ts.map +0 -1
  208. package/dist/mcp/events-tool.js +0 -322
  209. package/dist/mcp/server.d.ts.map +0 -1
  210. package/dist/mcp/server.js +0 -538
  211. package/dist/mcp/tool-mapping.d.ts +0 -58
  212. package/dist/mcp/tool-mapping.d.ts.map +0 -1
  213. package/dist/mcp/tool-mapping.js +0 -143
  214. package/dist/mcp/tool-namespace.d.ts +0 -40
  215. package/dist/mcp/tool-namespace.d.ts.map +0 -1
  216. package/dist/mcp/tool-namespace.js +0 -65
  217. package/dist/native-scheme.d.ts.map +0 -1
  218. package/dist/native-scheme.js +0 -465
  219. package/dist/output.d.ts.map +0 -1
  220. package/dist/output.js +0 -342
  221. package/dist/package-version.d.ts.map +0 -1
  222. package/dist/package-version.js +0 -47
  223. package/dist/qr-terminal.d.ts.map +0 -1
  224. package/dist/qr-terminal.js +0 -45
  225. package/dist/rpc/client.d.ts.map +0 -1
  226. package/dist/rpc/client.js +0 -819
  227. package/dist/scheme.d.ts.map +0 -1
  228. package/dist/scheme.js +0 -321
  229. package/dist/spki-pin.d.ts.map +0 -1
  230. package/dist/spki-pin.js +0 -14
@@ -1,819 +0,0 @@
1
- /**
2
- * The auto-spawning RPC client library (ARCHITECTURE.md §4, §5) used by the CLI, MCP server, and
3
- * tests to talk to `daemon.sock`. On `ENOENT`/`ECONNREFUSED` it takes an exclusive spawn-lock,
4
- * spawns `daemon run` detached, polls the socket until ready, then retries the request once.
5
- */
6
- import { connect } from "node:net";
7
- import { spawn as spawnChildProcess } from "node:child_process";
8
- import { open, readFile, rm, stat } from "node:fs/promises";
9
- import { dirname, join } from "node:path";
10
- import { fileURLToPath } from "node:url";
11
- import { RPC_METHODS } from "@appduct/shared";
12
- import { ensureDaemonLogMode, resolveDaemonLogMaxBytes, rotateDaemonLogIfNeeded, } from "../daemon/log-rotation.js";
13
- import { isProcessAlive } from "../daemon/pidfile.js";
14
- import { isSocketConnectable } from "../daemon/socket-probe.js";
15
- import { getStateDirPaths } from "../daemon/state-dir.js";
16
- import { DAEMON_VERSION_OVERRIDE_ENV } from "../package-version.js";
17
- const packageRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
18
- const defaultBinPath = join(packageRoot, "bin.js");
19
- export class DaemonRpcError extends Error {
20
- code;
21
- data;
22
- constructor(code, message, data) {
23
- super(message);
24
- this.code = code;
25
- this.data = data;
26
- this.name = "DaemonRpcError";
27
- }
28
- }
29
- /**
30
- * `reason` distinguishes outcomes that look identical to a caller but mean opposite things to the
31
- * version-drift restart (issue #30): `"closed"` is the daemon dropping the connection — expected
32
- * while it tears itself down — whereas `"timeout"` is a daemon that is very much alive and simply
33
- * not answering, which must never be mistaken for "it is gone, spawn another one".
34
- */
35
- export class DaemonUnavailableError extends Error {
36
- reason;
37
- constructor(message, reason = "unreachable") {
38
- super(message);
39
- this.reason = reason;
40
- this.name = "DaemonUnavailableError";
41
- }
42
- }
43
- /** A request that ran out of time against a daemon that never dropped the connection. */
44
- const isRequestTimeoutError = (error) => {
45
- return error instanceof DaemonUnavailableError && error.reason === "timeout";
46
- };
47
- const describeLiveState = (state) => {
48
- const parts = [];
49
- if (state.sessions > 0) {
50
- parts.push(`${state.sessions} connected session(s)`);
51
- }
52
- if (state.pendingLinks > 0) {
53
- parts.push(`${state.pendingLinks} unclaimed link(s)`);
54
- }
55
- return parts.join(" and ");
56
- };
57
- /**
58
- * Raised when the daemon this client reached is running a different Appduct version and could
59
- * not be replaced (issue #30). The common case is live state a restart would destroy: sessions
60
- * (resume tokens are in-daemon memory, so a restart makes the app's resume fail closed with 1008)
61
- * or pending links (a deep link or QR code someone is about to scan). The caller is told rather
62
- * than silently paying that cost.
63
- */
64
- export class DaemonVersionMismatchError extends Error {
65
- daemonVersion;
66
- clientVersion;
67
- sessionCount;
68
- pendingLinkCount;
69
- constructor(daemonVersion, clientVersion, live, message) {
70
- super(message ??
71
- `The running Appduct daemon is version ${daemonVersion}, but this client is version ${clientVersion}. ` +
72
- `It was not restarted automatically because ${describeLiveState(live)} would be lost. ` +
73
- `Run "appduct daemon stop" once that state is expendable, or force it with "--daemon-restart".`);
74
- this.daemonVersion = daemonVersion;
75
- this.clientVersion = clientVersion;
76
- this.name = "DaemonVersionMismatchError";
77
- this.sessionCount = live.sessions;
78
- this.pendingLinkCount = live.pendingLinks;
79
- }
80
- }
81
- /**
82
- * Raised when the daemon *was* restarted and the daemon now answering still reports a different
83
- * version. Retrying would just kill daemon after daemon, so the check stops after one restart and
84
- * reports what it actually observed rather than guessing at a cause.
85
- */
86
- export class DaemonVersionRestartIneffectiveError extends DaemonVersionMismatchError {
87
- constructor(daemonVersion, clientVersion, live) {
88
- super(daemonVersion, clientVersion, live, `The Appduct daemon was restarted to match this client (version ${clientVersion}), but the daemon now answering still reports version ${daemonVersion}. ` +
89
- `Either the old daemon never exited (compare "daemon.pid" with what "appduct daemon status" reports), ` +
90
- `or another process spawned an older daemon in the meantime. ` +
91
- `Run "appduct daemon stop", then retry.`);
92
- this.name = "DaemonVersionRestartIneffectiveError";
93
- }
94
- }
95
- /** Parses `1.2.3`, `1.2.3-beta.1`, `1.2.3+build`; `undefined` for anything else. */
96
- const parseVersion = (value) => {
97
- const match = /^(\d+(?:\.\d+)*)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/u.exec(value.trim());
98
- if (!match) {
99
- return undefined;
100
- }
101
- return {
102
- numbers: match[1].split(".").map(Number),
103
- // Build metadata (`+…`) is dropped, per semver: it never affects precedence, so two versions
104
- // differing only there are the same version and neither restarts nor warns about the other.
105
- prerelease: match[2]?.split("."),
106
- };
107
- };
108
- const isNumericIdentifier = (identifier) => /^\d+$/u.test(identifier);
109
- /**
110
- * Semver §11's prerelease precedence, which a plain string compare gets wrong in both directions:
111
- * numeric identifiers compare as numbers (`rc.2` < `rc.10`, where `"rc.2" > "rc.10"` as strings),
112
- * a numeric identifier always ranks below an alphanumeric one, and when one side runs out of
113
- * identifiers first the shorter one ranks lower (`1.0.0-rc` < `1.0.0-rc.1`).
114
- */
115
- const comparePrerelease = (left, right) => {
116
- const length = Math.max(left.length, right.length);
117
- for (let index = 0; index < length; index += 1) {
118
- const a = left[index];
119
- const b = right[index];
120
- if (a === undefined) {
121
- return -1;
122
- }
123
- if (b === undefined) {
124
- return 1;
125
- }
126
- if (a === b) {
127
- continue;
128
- }
129
- const aNumeric = isNumericIdentifier(a);
130
- const bNumeric = isNumericIdentifier(b);
131
- if (aNumeric && bNumeric) {
132
- return Number(a) < Number(b) ? -1 : 1;
133
- }
134
- if (aNumeric !== bNumeric) {
135
- return aNumeric ? -1 : 1;
136
- }
137
- return a < b ? -1 : 1;
138
- }
139
- return 0;
140
- };
141
- /**
142
- * `-1` when `a` is older, `1` when newer, `0` when they are the same version; `undefined` when
143
- * either side is not a version this can order. A prerelease sorts before its release
144
- * (`1.0.0-rc.1` < `1.0.0`), which is what keeps an `-rc` build from restarting the release it is
145
- * testing.
146
- */
147
- const compareVersions = (a, b) => {
148
- const left = parseVersion(a);
149
- const right = parseVersion(b);
150
- if (!left || !right) {
151
- return undefined;
152
- }
153
- const length = Math.max(left.numbers.length, right.numbers.length);
154
- for (let index = 0; index < length; index += 1) {
155
- const difference = (left.numbers[index] ?? 0) - (right.numbers[index] ?? 0);
156
- if (difference !== 0) {
157
- return difference < 0 ? -1 : 1;
158
- }
159
- }
160
- if (left.prerelease === undefined && right.prerelease === undefined) {
161
- return 0;
162
- }
163
- if (left.prerelease === undefined) {
164
- return 1;
165
- }
166
- if (right.prerelease === undefined) {
167
- return -1;
168
- }
169
- return comparePrerelease(left.prerelease, right.prerelease);
170
- };
171
- /**
172
- * True when two version strings name the same version even though the strings differ — `1.2` and
173
- * `1.2.0`, or two builds differing only in `+metadata`. Comparing the raw strings would report
174
- * drift that does not exist and, worse, print a "the daemon is newer" notice about a daemon that
175
- * is exactly this version.
176
- */
177
- export const isSameVersion = (a, b) => {
178
- return a === b || compareVersions(a, b) === 0;
179
- };
180
- const isConnectionMissingError = (error) => {
181
- const code = error.code;
182
- return code === "ENOENT" || code === "ECONNREFUSED";
183
- };
184
- /**
185
- * True when `error` indicates the daemon is not reachable at all — no listener at the socket
186
- * path (`ENOENT`/`ECONNREFUSED`), or a `DaemonUnavailableError` from a request that timed out or
187
- * had its connection drop mid-flight. Callers use this to decide whether a fallback (e.g. `daemon
188
- * stop`'s SIGTERM-via-pidfile path) applies, as opposed to a `DaemonRpcError` from a daemon that
189
- * is very much running but rejected the request.
190
- */
191
- export const isDaemonUnreachableError = (error) => {
192
- return error instanceof DaemonUnavailableError || isConnectionMissingError(error);
193
- };
194
- let nextRequestId = 1;
195
- const sendRequest = (socketPath, method, params, timeoutMs) => {
196
- return new Promise((resolve, reject) => {
197
- const id = nextRequestId;
198
- nextRequestId += 1;
199
- let socket;
200
- let settled = false;
201
- let buffer = "";
202
- const finish = (fn) => {
203
- if (settled) {
204
- return;
205
- }
206
- settled = true;
207
- clearTimeout(timeout);
208
- socket.destroy();
209
- fn();
210
- };
211
- const timeout = setTimeout(() => {
212
- finish(() => reject(new DaemonUnavailableError(`Timed out waiting for a response to "${method}".`, "timeout")));
213
- }, timeoutMs);
214
- socket = connect(socketPath);
215
- socket.on("error", (error) => {
216
- finish(() => reject(error));
217
- });
218
- socket.on("connect", () => {
219
- socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
220
- });
221
- socket.on("data", (chunk) => {
222
- buffer += chunk.toString("utf8");
223
- let newlineIndex = buffer.indexOf("\n");
224
- while (newlineIndex !== -1) {
225
- const line = buffer.slice(0, newlineIndex);
226
- buffer = buffer.slice(newlineIndex + 1);
227
- newlineIndex = buffer.indexOf("\n");
228
- if (line.length === 0) {
229
- continue;
230
- }
231
- let message;
232
- try {
233
- message = JSON.parse(line);
234
- }
235
- catch {
236
- continue;
237
- }
238
- if (message.id !== id) {
239
- continue;
240
- }
241
- if (message.error) {
242
- finish(() => reject(new DaemonRpcError(message.error.code, message.error.message, message.error.data)));
243
- return;
244
- }
245
- finish(() => resolve(message.result));
246
- return;
247
- }
248
- });
249
- socket.on("close", () => {
250
- finish(() => reject(new DaemonUnavailableError("Connection to the Appduct daemon closed.", "closed")));
251
- });
252
- });
253
- };
254
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
255
- const pollForSocket = async (socketPath, timeoutMs, pollIntervalMs) => {
256
- const deadline = Date.now() + timeoutMs;
257
- while (Date.now() < deadline) {
258
- if (await isSocketConnectable(socketPath)) {
259
- return true;
260
- }
261
- await sleep(pollIntervalMs);
262
- }
263
- return isSocketConnectable(socketPath);
264
- };
265
- const unlinkStaleSocketIfDaemonDead = async (paths) => {
266
- let pid;
267
- try {
268
- pid = Number.parseInt((await readFile(paths.pidFilePath, "utf8")).trim(), 10);
269
- }
270
- catch {
271
- pid = undefined;
272
- }
273
- if (pid === undefined || Number.isNaN(pid) || !isProcessAlive(pid)) {
274
- await rm(paths.socketPath, { force: true });
275
- }
276
- };
277
- const defaultSpawn = async (args, context) => {
278
- // Rotate before the new daemon's log fd is opened (issue #32). This is the only safe moment:
279
- // afterwards the daemon holds the fd for its whole life. `appduct daemon start` spawns
280
- // through this same path, so it rotates too. Reaching here means nothing answered the socket,
281
- // which is *usually* but not always the same as "no daemon is writing this log" — hence the
282
- // pidfile and socket guards inside, which decline to rotate under a daemon that is still there.
283
- const paths = getStateDirPaths(context.stateDir);
284
- await rotateDaemonLogIfNeeded({
285
- logFilePath: context.logFilePath,
286
- maxBytes: await resolveDaemonLogMaxBytes(context.stateDir),
287
- pidFilePath: paths.pidFilePath,
288
- socketPath: paths.socketPath,
289
- });
290
- const logFd = await open(context.logFilePath, "a", 0o600);
291
- try {
292
- // Best-effort, never fatal: this is the auto-spawn path, and a log mode that cannot be
293
- // tightened is not a reason to fail every command that needs a daemon. See the helper.
294
- await ensureDaemonLogMode(context.logFilePath);
295
- const childEnv = { ...process.env, APPDUCT_STATE_DIR: context.stateDir };
296
- // A daemon this client spawns must report its real version, whatever this process inherited.
297
- // Otherwise a stray `APPDUCT_DAEMON_VERSION_OVERRIDE` in the operator's shell would be
298
- // copied into the replacement, which would then mismatch too — an endless restart loop.
299
- delete childEnv[DAEMON_VERSION_OVERRIDE_ENV];
300
- const child = spawnChildProcess(process.execPath, [defaultBinPath, ...args], {
301
- detached: true,
302
- stdio: ["ignore", logFd.fd, logFd.fd],
303
- env: childEnv,
304
- });
305
- child.unref();
306
- }
307
- finally {
308
- await logFd.close();
309
- }
310
- };
311
- /**
312
- * How long a spawn-lock may be held before another process treats it as abandoned. A restart holds
313
- * the lock across a status read, a `daemon.shutdown`, and a wait for the daemon to disappear, so
314
- * the ceiling has to be generous — but a Ctrl-C in that window used to leave the lock behind
315
- * forever, and every later command would then quietly skip spawning and time out on a socket that
316
- * was never going to appear.
317
- */
318
- const SPAWN_LOCK_STALE_MS = 30_000;
319
- /** `false` when the lock was still held when the wait ran out. */
320
- const pollUntilLockReleased = async (lockPath, timeoutMs, pollIntervalMs) => {
321
- const deadline = Date.now() + timeoutMs;
322
- while (Date.now() < deadline) {
323
- if (!(await pathExists(lockPath))) {
324
- return true;
325
- }
326
- await sleep(pollIntervalMs);
327
- }
328
- return !(await pathExists(lockPath));
329
- };
330
- /** The pid the pidfile currently names, or `undefined` when there is no readable one. */
331
- const readPidfilePid = async (paths) => {
332
- try {
333
- const pid = Number.parseInt((await readFile(paths.pidFilePath, "utf8")).trim(), 10);
334
- return Number.isNaN(pid) ? undefined : pid;
335
- }
336
- catch {
337
- return undefined;
338
- }
339
- };
340
- const pathExists = async (target) => {
341
- try {
342
- await stat(target);
343
- return true;
344
- }
345
- catch {
346
- return false;
347
- }
348
- };
349
- /**
350
- * Takes the exclusive spawn-lock, reclaiming one that has been held past
351
- * {@link SPAWN_LOCK_STALE_MS} (its owner died without cleaning up). The takeover is itself racy
352
- * only in the sense that two processes could reclaim the same abandoned lock in the same instant —
353
- * which lands them exactly where the plain double-spawn race already does, and the pidfile's
354
- * `O_EXCL` still decides the winner.
355
- */
356
- const acquireSpawnLock = async (lockPath) => {
357
- for (let attempt = 0; attempt < 2; attempt += 1) {
358
- try {
359
- const lockHandle = await open(lockPath, "wx", 0o600);
360
- await lockHandle.close();
361
- return true;
362
- }
363
- catch (error) {
364
- if (error.code !== "EEXIST") {
365
- throw error;
366
- }
367
- }
368
- let heldForMs;
369
- try {
370
- heldForMs = Date.now() - (await stat(lockPath)).mtimeMs;
371
- }
372
- catch {
373
- // Released between the failed create and the stat — go straight back and take it.
374
- continue;
375
- }
376
- if (heldForMs < SPAWN_LOCK_STALE_MS) {
377
- return false;
378
- }
379
- await rm(lockPath, { force: true });
380
- }
381
- return false;
382
- };
383
- /**
384
- * Takes the exclusive spawn-lock, spawns `daemon run` detached if this caller wins the race
385
- * (the loser just polls), then waits for the socket to become ready.
386
- */
387
- const spawnDaemonAndWait = async (paths, spawn, waitTimeoutMs, pollIntervalMs, options = {}) => {
388
- const acquiredLock = await acquireSpawnLock(paths.spawnLockPath);
389
- let lockWaitTimedOut = false;
390
- if (acquiredLock) {
391
- try {
392
- const shouldSpawn = options.beforeSpawn ? await options.beforeSpawn() : true;
393
- if (shouldSpawn) {
394
- await unlinkStaleSocketIfDaemonDead(paths);
395
- await spawn(["daemon", "run"], { stateDir: paths.root, logFilePath: paths.logFilePath });
396
- }
397
- }
398
- finally {
399
- await rm(paths.spawnLockPath, { force: true });
400
- }
401
- }
402
- else if (options.awaitLockRelease) {
403
- lockWaitTimedOut = !(await pollUntilLockReleased(paths.spawnLockPath, waitTimeoutMs, pollIntervalMs));
404
- }
405
- const ready = await pollForSocket(paths.socketPath, waitTimeoutMs, pollIntervalMs);
406
- if (!ready) {
407
- // A lock still sitting there is the single most useful thing to say here: it means another
408
- // process is (or died) mid-spawn, and this one deliberately did not spawn a second daemon.
409
- const lockHint = (await pathExists(paths.spawnLockPath))
410
- ? ` Another process holds the spawn-lock at "${paths.spawnLockPath}"; if no "appduct daemon" is starting, delete it and retry.`
411
- : "";
412
- throw new DaemonUnavailableError(`Timed out waiting for the Appduct daemon socket at "${paths.socketPath}".${lockHint}`);
413
- }
414
- return { ownedLock: acquiredLock, lockWaitTimedOut };
415
- };
416
- // ---------------------------------------------------------------------------------------------
417
- // Daemon/client version drift (issue #30, ARCHITECTURE.md §4)
418
- // ---------------------------------------------------------------------------------------------
419
- /**
420
- * One in-flight-or-settled check per daemon socket path, so the extra `daemon.status` costs one
421
- * round-trip per process and concurrent callers (e.g. the MCP server's stream plus a first
422
- * `tools.list`) share a single check and a single restart rather than racing each other.
423
- */
424
- const versionChecks = new Map();
425
- /** Test seam: drops the per-process check cache so each test case starts from a clean slate. */
426
- export const resetDaemonVersionChecks = () => {
427
- versionChecks.clear();
428
- };
429
- /**
430
- * Per-call timeout for the `daemon.status` and `daemon.shutdown` requests issued *while the
431
- * spawn-lock is held*. Deliberately much shorter than the general request timeout: everything
432
- * between taking the lock and releasing it is time in which no other process can spawn a daemon,
433
- * and an unresponsive daemon must not stretch that into tens of seconds.
434
- */
435
- const RESTART_REQUEST_TIMEOUT_MS = 3000;
436
- /**
437
- * `daemon.status`, auto-spawning a daemon first when nothing is listening. Returns `undefined`
438
- * when nothing is listening and auto-spawn is off — there is no daemon to drift from, and any
439
- * daemon this process spawns later is by definition this process's own build.
440
- */
441
- const readStatusSpawningIfNeeded = async (context) => {
442
- try {
443
- return await sendRequest(context.paths.socketPath, RPC_METHODS.daemonStatus, {}, context.requestTimeoutMs);
444
- }
445
- catch (error) {
446
- if (!isConnectionMissingError(error)) {
447
- throw error;
448
- }
449
- if (!context.autoSpawn) {
450
- return undefined;
451
- }
452
- await spawnDaemonAndWait(context.paths, context.spawn, context.waitTimeoutMs, context.pollIntervalMs);
453
- return sendRequest(context.paths.socketPath, RPC_METHODS.daemonStatus, {}, context.requestTimeoutMs);
454
- }
455
- };
456
- /**
457
- * `daemon.status` against whatever is listening right now; `undefined` only when nothing is
458
- * listening at all. A *timeout* is not "gone" — it is a daemon that is alive and busy, and
459
- * spawning a second one over it would be the worst possible response — so it aborts the restart
460
- * with a message that says so.
461
- */
462
- const readStatusIfReachable = async (context) => {
463
- try {
464
- return await sendRequest(context.paths.socketPath, RPC_METHODS.daemonStatus, {}, RESTART_REQUEST_TIMEOUT_MS);
465
- }
466
- catch (error) {
467
- if (isConnectionMissingError(error)) {
468
- return undefined;
469
- }
470
- if (isRequestTimeoutError(error)) {
471
- throw new DaemonUnavailableError(`The Appduct daemon at "${context.paths.socketPath}" did not answer "daemon.status" within ${RESTART_REQUEST_TIMEOUT_MS}ms, so it was not replaced. ` +
472
- `It is running but unresponsive — run "appduct daemon stop" and retry.`, "timeout");
473
- }
474
- throw error;
475
- }
476
- };
477
- /**
478
- * True once nothing can answer at the socket *and* the pidfile no longer names a live process.
479
- * Both halves matter: the daemon closes its listener before releasing the pidfile, so a fresh
480
- * daemon spawned on "socket is gone" alone can lose the pidfile's `O_EXCL` race against the
481
- * outgoing one and die with `DaemonAlreadyRunningError`.
482
- */
483
- const isDaemonGone = async (paths) => {
484
- if (await isSocketConnectable(paths.socketPath)) {
485
- return false;
486
- }
487
- let pid;
488
- try {
489
- pid = Number.parseInt((await readFile(paths.pidFilePath, "utf8")).trim(), 10);
490
- }
491
- catch {
492
- return true;
493
- }
494
- return pid === undefined || Number.isNaN(pid) || !isProcessAlive(pid);
495
- };
496
- /**
497
- * What a restart of this daemon would destroy. Defensive about both fields' presence rather than
498
- * indexing them blindly: `pendingLinks` is absent from daemons that predate the field (and drift with an
499
- * older daemon is the whole point of this code path), and a `TypeError` from deep inside the
500
- * version check would be a far worse diagnosis than the drift it was trying to report.
501
- */
502
- const readLiveState = (status) => {
503
- return {
504
- sessions: Array.isArray(status.sessions) ? status.sessions.length : 0,
505
- pendingLinks: typeof status.pendingLinks === "number" ? status.pendingLinks : 0,
506
- };
507
- };
508
- const isSafeToRestart = (live) => {
509
- return live.sessions === 0 && live.pendingLinks === 0;
510
- };
511
- const pollUntilDaemonGone = async (paths, timeoutMs, pollIntervalMs) => {
512
- const deadline = Date.now() + timeoutMs;
513
- while (Date.now() < deadline) {
514
- if (await isDaemonGone(paths)) {
515
- return true;
516
- }
517
- await sleep(pollIntervalMs);
518
- }
519
- return isDaemonGone(paths);
520
- };
521
- /**
522
- * Replaces the running daemon with one spawned from this client's build. Runs under the same
523
- * exclusive spawn-lock as a cold-start spawn, so two upgraded CLIs starting at once produce one
524
- * replacement daemon, not two — and the loser waits for the lock rather than for a socket that
525
- * may still be the outgoing daemon's.
526
- */
527
- const restartDaemon = async (context) => {
528
- // Which daemon was in charge before this attempt. Only used for the lock-contention case below,
529
- // where this process never got to run the replacement itself and has to tell "the other process
530
- // replaced it" apart from "nothing happened at all".
531
- const pidBefore = await readPidfilePid(context.paths);
532
- const outcome = await spawnDaemonAndWait(context.paths, context.spawn, context.waitTimeoutMs, context.pollIntervalMs, {
533
- awaitLockRelease: true,
534
- beforeSpawn: async () => {
535
- // Re-read under the lock: another process may have replaced the daemon while this one was
536
- // queued, and a session or link may have appeared since the status read that decided a
537
- // restart was safe. (Not fully atomic — the daemon has no compare-and-shutdown — but it
538
- // closes the window that a slow lock wait would otherwise leave wide open.)
539
- const status = await readStatusIfReachable(context);
540
- if (status === undefined) {
541
- return true;
542
- }
543
- if (isSameVersion(status.version, context.check.clientVersion)) {
544
- return false;
545
- }
546
- const live = readLiveState(status);
547
- if (!isSafeToRestart(live) && !context.check.forceRestart) {
548
- throw new DaemonVersionMismatchError(status.version, context.check.clientVersion, live);
549
- }
550
- try {
551
- await sendRequest(context.paths.socketPath, RPC_METHODS.daemonShutdown, {}, RESTART_REQUEST_TIMEOUT_MS);
552
- }
553
- catch (error) {
554
- if (isRequestTimeoutError(error)) {
555
- throw new DaemonUnavailableError(`The Appduct daemon at "${context.paths.socketPath}" did not acknowledge "daemon.shutdown" within ${RESTART_REQUEST_TIMEOUT_MS}ms, so it was left running. ` +
556
- `Run "appduct daemon stop" and retry.`, "timeout");
557
- }
558
- // The daemon answers `shutdown` before teardown finishes, so a connection that is
559
- // already gone (or drops mid-reply) is an ordinary outcome; the poll below is what
560
- // actually establishes it left.
561
- if (!isDaemonUnreachableError(error)) {
562
- throw error;
563
- }
564
- }
565
- if (!(await pollUntilDaemonGone(context.paths, context.waitTimeoutMs, context.pollIntervalMs))) {
566
- throw new DaemonUnavailableError(`The Appduct daemon at "${context.paths.socketPath}" did not shut down in time; run "appduct daemon stop" and retry.`);
567
- }
568
- return true;
569
- },
570
- });
571
- if (!outcome.ownedLock && outcome.lockWaitTimedOut && (await readPidfilePid(context.paths)) === pidBefore) {
572
- // Another process has held the spawn-lock past the wait and the daemon is still the same one.
573
- // `pollForSocket` happily connected to it, but nothing was replaced — reporting that as a
574
- // restart that "did not take effect" would blame the wrong thing entirely.
575
- throw new DaemonUnavailableError(`The Appduct daemon at "${context.paths.socketPath}" did not shut down in time: another process has held the spawn-lock at ` +
576
- `"${context.paths.spawnLockPath}" for longer than this command waited. Retry, or run "appduct daemon stop" if nothing is starting a daemon.`);
577
- }
578
- };
579
- const warnOlderClient = (context, daemonVersion) => {
580
- const message = `appduct: the running daemon is version ${daemonVersion}, newer than this client (${context.check.clientVersion}). ` +
581
- `Continuing without restarting it — a newer daemon serves an older client, and replacing it would downgrade whatever started it. ` +
582
- `Run "appduct daemon stop" if you want this client's daemon instead.\n`;
583
- const warn = context.check.onWarning ?? ((text) => void process.stderr.write(text));
584
- warn(message);
585
- };
586
- /**
587
- * Compares the daemon's version with this client's and, at most **once** per process, replaces a
588
- * daemon this client has outgrown.
589
- *
590
- * One restart, never a loop: each restart destroys whatever the daemon was holding, so retrying a
591
- * restart that did not take effect just kills daemon after daemon while reporting a cause that was
592
- * never true. If the replacement still disagrees, that is a different problem with a different
593
- * answer ({@link DaemonVersionRestartIneffectiveError}).
594
- */
595
- const runVersionCheck = async (context) => {
596
- const status = await readStatusSpawningIfNeeded(context);
597
- if (status === undefined) {
598
- return;
599
- }
600
- if (isSameVersion(status.version, context.check.clientVersion)) {
601
- return;
602
- }
603
- // Direction matters. Restarting is only ever right when this client has outgrown the daemon;
604
- // a *newer* daemon already speaks everything this client knows, and replacing it would
605
- // downgrade the daemon out from under whichever newer install started it — two installs on one
606
- // machine would otherwise take turns killing each other's daemon on every command.
607
- if ((compareVersions(context.check.clientVersion, status.version) ?? -1) <= 0) {
608
- warnOlderClient(context, status.version);
609
- return;
610
- }
611
- const live = readLiveState(status);
612
- if (!isSafeToRestart(live) && !context.check.forceRestart) {
613
- throw new DaemonVersionMismatchError(status.version, context.check.clientVersion, live);
614
- }
615
- await restartDaemon(context);
616
- const replacement = await readStatusIfReachable(context);
617
- if (replacement === undefined) {
618
- throw new DaemonUnavailableError(`The Appduct daemon was restarted but nothing is listening at "${context.paths.socketPath}"; check "${context.paths.logFilePath}".`);
619
- }
620
- if (isSameVersion(replacement.version, context.check.clientVersion)) {
621
- return;
622
- }
623
- if ((compareVersions(context.check.clientVersion, replacement.version) ?? -1) <= 0) {
624
- warnOlderClient(context, replacement.version);
625
- return;
626
- }
627
- throw new DaemonVersionRestartIneffectiveError(replacement.version, context.check.clientVersion, readLiveState(replacement));
628
- };
629
- /**
630
- * Runs {@link runVersionCheck} at most once per process per daemon socket. A mismatch that could
631
- * not be resolved stays cached (every later call in this process would reach the same daemon and
632
- * fail the same way); a transient failure does not, so a retry can still succeed.
633
- */
634
- const ensureDaemonVersion = async (paths, options, spawn) => {
635
- const check = options.checkVersion;
636
- if (!check) {
637
- return;
638
- }
639
- const cached = versionChecks.get(paths.socketPath);
640
- if (cached) {
641
- return cached;
642
- }
643
- const pending = runVersionCheck({
644
- paths,
645
- spawn,
646
- autoSpawn: options.autoSpawn ?? true,
647
- requestTimeoutMs: options.requestTimeoutMs ?? 10_000,
648
- waitTimeoutMs: options.spawnWaitTimeoutMs ?? 5000,
649
- pollIntervalMs: options.spawnPollIntervalMs ?? 100,
650
- check,
651
- }).catch((error) => {
652
- if (!(error instanceof DaemonVersionMismatchError)) {
653
- versionChecks.delete(paths.socketPath);
654
- }
655
- throw error;
656
- });
657
- versionChecks.set(paths.socketPath, pending);
658
- return pending;
659
- };
660
- /**
661
- * Runs the version check on its own, without issuing a command RPC — the CLI dispatcher's seam
662
- * (issue #30). Pass `autoSpawn: false` (the default here) when the caller does not want a daemon
663
- * started just to be checked: nothing listening means nothing to drift from.
664
- */
665
- export const ensureDaemonVersionMatches = async (options) => {
666
- const paths = getStateDirPaths(options.stateDir);
667
- await ensureDaemonVersion(paths, { ...options, autoSpawn: options.autoSpawn ?? false }, options.spawn ?? defaultSpawn);
668
- };
669
- /** Calls a daemon RPC method, auto-spawning the daemon on a missing/refused connection. */
670
- export const callDaemon = async (method, params, options) => {
671
- const paths = getStateDirPaths(options.stateDir);
672
- const autoSpawn = options.autoSpawn ?? true;
673
- const spawn = options.spawn ?? defaultSpawn;
674
- const requestTimeoutMs = options.requestTimeoutMs ?? 10_000;
675
- // Runs before the request so this call is answered by a daemon of the right version — never
676
- // half-answered by the outgoing one.
677
- await ensureDaemonVersion(paths, options, spawn);
678
- try {
679
- return await sendRequest(paths.socketPath, method, params, requestTimeoutMs);
680
- }
681
- catch (error) {
682
- if (!autoSpawn || !isConnectionMissingError(error)) {
683
- throw error;
684
- }
685
- await spawnDaemonAndWait(paths, spawn, options.spawnWaitTimeoutMs ?? 5000, options.spawnPollIntervalMs ?? 100);
686
- return sendRequest(paths.socketPath, method, params, requestTimeoutMs);
687
- }
688
- };
689
- /**
690
- * Opens a persistent connection to the daemon (auto-spawning as needed) for use cases that need
691
- * server→client notifications, e.g. `events.subscribe`.
692
- */
693
- export const openDaemonStream = async (options) => {
694
- const paths = getStateDirPaths(options.stateDir);
695
- const autoSpawn = options.autoSpawn ?? true;
696
- const spawn = options.spawn ?? defaultSpawn;
697
- const requestTimeoutMs = options.requestTimeoutMs ?? 10_000;
698
- const connectSocket = () => {
699
- return new Promise((resolve, reject) => {
700
- const socket = connect(paths.socketPath);
701
- socket.once("connect", () => {
702
- socket.off("error", onError);
703
- resolve(socket);
704
- });
705
- const onError = (error) => {
706
- reject(error);
707
- };
708
- socket.once("error", onError);
709
- });
710
- };
711
- // Resolved before the stream's socket is opened, so the connection this stream keeps for its
712
- // whole life belongs to a daemon of the right version and no caller ever observes two daemons.
713
- await ensureDaemonVersion(paths, options, spawn);
714
- let socket;
715
- try {
716
- socket = await connectSocket();
717
- }
718
- catch (error) {
719
- if (!autoSpawn || !isConnectionMissingError(error)) {
720
- throw error;
721
- }
722
- await spawnDaemonAndWait(paths, spawn, options.spawnWaitTimeoutMs ?? 5000, options.spawnPollIntervalMs ?? 100);
723
- socket = await connectSocket();
724
- }
725
- socket.on("error", () => {
726
- // A stream consumer observes loss via `close`; never let a socket error crash the process.
727
- });
728
- const notificationListeners = new Set();
729
- const closeListeners = new Set();
730
- const pendingCalls = new Map();
731
- let buffer = "";
732
- socket.on("data", (chunk) => {
733
- buffer += chunk.toString("utf8");
734
- let newlineIndex = buffer.indexOf("\n");
735
- while (newlineIndex !== -1) {
736
- const line = buffer.slice(0, newlineIndex);
737
- buffer = buffer.slice(newlineIndex + 1);
738
- newlineIndex = buffer.indexOf("\n");
739
- if (line.length === 0) {
740
- continue;
741
- }
742
- let message;
743
- try {
744
- message = JSON.parse(line);
745
- }
746
- catch {
747
- continue;
748
- }
749
- if (message.method === "event") {
750
- for (const listener of notificationListeners) {
751
- try {
752
- listener(message.params);
753
- }
754
- catch {
755
- // A misbehaving subscriber (e.g. a throwing `waitForEvent` match predicate) must never
756
- // crash this loop — it would drop every other listener's notification, and any RPC
757
- // response batched in the same chunk, for the rest of this connection's lifetime.
758
- }
759
- }
760
- continue;
761
- }
762
- const pending = typeof message.id === "number" ? pendingCalls.get(message.id) : undefined;
763
- if (!pending) {
764
- continue;
765
- }
766
- pendingCalls.delete(message.id);
767
- clearTimeout(pending.timeout);
768
- if (message.error) {
769
- pending.reject(new DaemonRpcError(message.error.code, message.error.message, message.error.data));
770
- }
771
- else {
772
- pending.resolve(message.result);
773
- }
774
- }
775
- });
776
- socket.on("close", () => {
777
- for (const [, pending] of pendingCalls) {
778
- clearTimeout(pending.timeout);
779
- pending.reject(new DaemonUnavailableError("Connection to the Appduct daemon closed.", "closed"));
780
- }
781
- pendingCalls.clear();
782
- for (const listener of closeListeners) {
783
- listener();
784
- }
785
- });
786
- return {
787
- call: (method, params, timeoutMs) => {
788
- return new Promise((resolve, reject) => {
789
- const id = nextRequestId;
790
- nextRequestId += 1;
791
- const timeout = setTimeout(() => {
792
- pendingCalls.delete(id);
793
- reject(new DaemonUnavailableError(`Timed out waiting for a response to "${method}".`, "timeout"));
794
- }, timeoutMs ?? requestTimeoutMs);
795
- pendingCalls.set(id, {
796
- resolve: resolve,
797
- reject,
798
- timeout,
799
- });
800
- socket.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`);
801
- });
802
- },
803
- onNotification: (callback) => {
804
- notificationListeners.add(callback);
805
- return () => {
806
- notificationListeners.delete(callback);
807
- };
808
- },
809
- onClose: (callback) => {
810
- closeListeners.add(callback);
811
- return () => {
812
- closeListeners.delete(callback);
813
- };
814
- },
815
- close: () => {
816
- socket.destroy();
817
- },
818
- };
819
- };