@design-twin/dtwin 0.0.0-stage → 0.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
package/src/daemon.ts ADDED
@@ -0,0 +1,788 @@
1
+ // daemon.ts — hold the bridge open across many CLI invocations.
2
+ //
3
+ // WHY this shape. A one-shot `figma-pull` pays a full plugin reconnect every run, and only one
4
+ // process can hold port 8787 (server-core.ts exits on EADDRINUSE), so back-to-back pulls are both
5
+ // slow and mutually exclusive. The obvious fix — "a flag that opens the connection and leaves it
6
+ // open" — does NOT work on its own: a long-running CLI has no way to receive further commands, so it
7
+ // would occupy the port while being unreachable, blocking the MCP server and every other CLI call.
8
+ //
9
+ // So: ONE long-lived process owns the bridge (`--serve`), and every other invocation becomes a thin
10
+ // client that forwards its request over a unix socket and exits. Existing commands route through the
11
+ // daemon automatically when one is running, and fall back to opening their own bridge when it isn't —
12
+ // so nothing about the current usage changes, it just gets faster when a daemon is up.
13
+ //
14
+ // A unix socket, not a second TCP port: it gets filesystem permissions for free (no verifyClient
15
+ // equivalent to write), and it cannot be reached from a browser tab the way a loopback port can.
16
+ //
17
+ // Windows: a named pipe, and a handshake in place of the directory. node:net's IPC there is a named pipe,
18
+ // and Bun (libuv underneath) gives it the default security descriptor — Everyone may read it — with no way to
19
+ // narrow it, nor for a client to ask who created the server end: another local user could create the name
20
+ // first and answer with forged replies. Nothing there plays the part of the 0700 directory below. So on
21
+ // win32 the pipe's NAME is derived from the bridge token (daemon-auth.ts pipeName), and before any frame
22
+ // both ends prove they hold that token (daemon-auth.ts, which says what that does and does not stop). Every
23
+ // serve / stop / lookup below takes that path where its address says so (daemonAddress): win32 by default, and anywhere a
24
+ // caller asks (`handshake: true` — the POSIX suites drive the whole exchange that way).
25
+ import fs from "node:fs";
26
+ import net from "node:net";
27
+ import path from "node:path";
28
+ import { AUTH_TIMEOUT_MS, clientHandshake, ipcKey, pipeName, serverHandshake } from "./daemon-auth.ts";
29
+ import type { IpcAuth } from "./daemon-auth.ts";
30
+ import { errMsg, errCode } from "./errmsg.ts";
31
+ import { ifDefined, isRecord } from "./json-util.ts";
32
+ import { abortReason } from "./abort-reason.ts";
33
+ import { deadline, systemClock } from "./clock.ts";
34
+ import { replyShapeError } from "./commands.ts";
35
+ import type { Cmd, Commands } from "./commands.ts";
36
+ import type { ClientRow, ProgressTick } from "./server-core.ts";
37
+ import type { Sent } from "./route.ts";
38
+ import { exitFlushed } from "./stdio.ts";
39
+ import { CONTROL, createServedSession } from "./served-session.ts";
40
+ import type { DaemonActivity, DaemonBridge, DaemonCommandRequest, DaemonControlRequest, DaemonReply, DaemonStatus, SessionFrame } from "./served-session.ts";
41
+ import type { Clock } from "./clock.ts";
42
+
43
+ // ---- the daemon socket protocol (newline-delimited JSON, one request -> one reply per connection). The frames, how a
44
+ // request frame is checked and what the daemon answers are the served session's (served-session.ts), which reads them;
45
+ // this module is the socket around it (serve) and the client end (request / connect / stop / status).
46
+ export { daemonRequestError, isDaemonRequest } from "./served-session.ts";
47
+ export type { DaemonActivity, DaemonBridge, DaemonCommandRequest, DaemonControlRequest, DaemonRequest, DaemonStatus } from "./served-session.ts";
48
+
49
+ /** A client row as relayed over the socket: the one field routing needs (`connId`) is checked, the rest
50
+ * is read as the bridge's own describe() wrote it. */
51
+ function isClientRow(x: unknown): x is ClientRow {
52
+ return isRecord(x) && typeof x.connId === "string";
53
+ }
54
+
55
+ const finiteOrNull = (v: unknown): v is number | null => v === null || (typeof v === "number" && Number.isFinite(v));
56
+ const stringOrNull = (v: unknown): v is string | null => v === null || typeof v === "string";
57
+ /** The tick exactly as server-core's parseTick normalises it (every field present, null when absent).
58
+ * Foreign JSON from another process — possibly another build — so it is checked, never cast. */
59
+ function isProgressTick(x: unknown): x is ProgressTick {
60
+ if (!isRecord(x)) return false;
61
+ const p = x.page;
62
+ const pageOk = p === null || (isRecord(p) && typeof p.index === "number" && Number.isFinite(p.index) && typeof p.of === "number" && Number.isFinite(p.of) && stringOrNull(p.name));
63
+ return stringOrNull(x.phase) && pageOk && finiteOrNull(x.nodes) && finiteOrNull(x.assets);
64
+ }
65
+
66
+ /** Is this frame a progress frame at all: `progress` present and no `ok` (every reply has `ok`). */
67
+ const isProgressShaped = (x: unknown): x is Record<string, unknown> => isRecord(x) && !("ok" in x) && "progress" in x;
68
+
69
+ function parseDaemonReply(x: unknown): DaemonReply | null {
70
+ if (!isRecord(x)) return null;
71
+ if (x.ok === true) return { ok: true, result: x.result, ...(isClientRow(x.client) ? { client: x.client } : {}) };
72
+ if (x.ok === false) return { ok: false, error: typeof x.error === "string" ? x.error : "daemon error" };
73
+ return null;
74
+ }
75
+
76
+ /** What status() hands back: `daemon: true` is the one field every daemon has sent; a daemon from an
77
+ * OLDER bridge may omit the newer ones (see staleness.ts daemonRowStalenessNote), so the rest is Partial. */
78
+ export type DaemonStatusView = Partial<DaemonStatus> & { daemon: true };
79
+
80
+ function isDaemonStatus(x: unknown): x is DaemonStatusView {
81
+ return isRecord(x) && x.daemon === true;
82
+ }
83
+
84
+ export interface ServeOptions {
85
+ log?: (m: string) => void;
86
+ /** The idle window in ms (0 = none): the holder passes its settings' (settings.ts idleMs). */
87
+ idleMs: number;
88
+ signals?: boolean;
89
+ /** Install installCrashHandlers for this process. Defaults to `signals`: a serve() that owns the
90
+ * process's signals owns its lifetime too (`dtwin serve`); a host with its own lifetime (the MCP
91
+ * server passes signals:false) installs its own, and an in-process test passes false. */
92
+ crashHandlers?: boolean;
93
+ /** What the session's idle clock runs on (clock.ts; systemClock when absent) — a suite's fake one steps it. */
94
+ clock?: Clock;
95
+ }
96
+
97
+ /** What a request through the daemon takes beside its frame (the frame carries the command, its budget, target
98
+ * and connect window). Every field optional.
99
+ * `guardMs`: the outer guard for a daemon that stopped answering entirely (route.ts sizes it, daemonGuardMs).
100
+ * While ticks arrive it is a SILENCE budget — each tick re-arms it (request() below says why). Absent: none.
101
+ * `onProgress` opts the request into progress frames and receives each tick, in order, before the promise
102
+ * settles. A daemon older than progress frames sends none; the request still answers.
103
+ * `signal`: the caller giving up. Aborting it closes this request's socket, which the daemon reads as its
104
+ * client leaving — a request still waiting for its plugin is never sent, a sent one gets the plugin a cancel
105
+ * frame (the served session's leave, served-session.ts), which drops it from the plugin's queue or stops it at its next safe point. Any
106
+ * daemon, old or new, treats a closed socket that way. */
107
+ export interface DaemonRequestOptions {
108
+ guardMs?: number | undefined;
109
+ onProgress?: ((tick: ProgressTick) => void) | undefined;
110
+ signal?: AbortSignal | undefined;
111
+ }
112
+
113
+ /** connect()'s answer when a daemon is live: its socket, and the one way to send a command through it — the
114
+ * reply, and the connected file the daemon's bridge used (null from an older daemon). */
115
+ export interface DaemonConnection {
116
+ sock: string;
117
+ request<C extends Cmd>(msg: DaemonCommandRequest<C>, o?: DaemonRequestOptions): Promise<Sent<C>>;
118
+ }
119
+
120
+ // ---- the address: where the daemon is reached, and keyed on what — resolved ONCE, from values
121
+ //
122
+ // The socket answers without a token, so on POSIX its DIRECTORY is the access control: only a directory that
123
+ // this user owns and nobody else can enter is safe. os.tmpdir() alone is not — on macOS it is a
124
+ // per-user 0700 directory, but on Linux it is usually /tmp (mode 1777, shared), where another local
125
+ // user could bind the socket name first: our CLI would then take forged replies from their process,
126
+ // and our `serve` could not remove their file (sticky bit).
127
+ //
128
+ // So, on POSIX: $XDG_RUNTIME_DIR when it is an absolute path to a directory that passes the check
129
+ // below (the XDG Base Directory spec requires it to be owned by the user with mode 0700); otherwise
130
+ // `<tmpdir>/designtwin-<uid>`, created 0700. An existing directory is used only if it is a real
131
+ // directory (not a symlink), owned by this uid, with no group/other permission bits. One that fails is
132
+ // REFUSED, never chmod-ed: we never create it looser than 0700, so a looser one was opened by someone,
133
+ // and whatever it already holds cannot be trusted. Inside a directory that passes, no other user can
134
+ // create, replace or remove an entry, so the socket file in it is ours; it is still checked (owner)
135
+ // before use, as a second line.
136
+ //
137
+ // The address is resolved once (daemonAddress); the directory and the socket file are checked again at
138
+ // EVERY use (checkedSock), because a long-lived process (the MCP server) resolves it once and uses it for
139
+ // hours: a directory someone makes or opens up after the resolution is never trusted unchecked.
140
+ //
141
+ // No POSIX uids (an injected uid of undefined): the tmpdir unchanged, and no mode bits to check.
142
+ // Windows: a named pipe named from the token (daemon-auth.ts pipeName) — no directory, no file, no length
143
+ // or owner check: a pipe is not a path in any filesystem, and goes away with the process that holds it.
144
+ //
145
+ // Length: the kernel limits a Unix socket address to sockaddr_un.sun_path — 108 bytes on Linux, 104 on
146
+ // macOS, each with its terminating NUL. Bun does not hand a longer path to the kernel as it is: on Linux
147
+ // it binds and connects through /proc/self/fd/<dir fd>/<name> (bun-usockets bsd.c), so any length works
148
+ // there. On macOS, only a recent Bun has the equivalent (fchdir into the directory, then the bare name);
149
+ // an older one fails with ENAMETOOLONG — and Node (before the Bun port) silently TRUNCATED the path and bound a
150
+ // different file. So on macOS a path over 103 bytes is refused here, with the fix named
151
+ // (MAX_SOCK_PATH_BYTES), rather than left to whichever of those the running Bun does. It takes a long
152
+ // TMPDIR to get there: a default macOS tmpdir is ~48 bytes, so `<tmpdir>/designtwin-<uid>/designtwin-<port>.sock`
153
+ // is ~84 bytes; /run/user/<uid>/designtwin-<port>.sock (Linux, XDG_RUNTIME_DIR) ~36.
154
+
155
+ /** What a daemon address is resolved from — values only: an entry passes its settings' (settings.ts
156
+ * daemonAddressOf), a suite whichever platform and uid it is checking. Nothing here reads the environment. */
157
+ export interface AddressInput {
158
+ /** the bridge port the daemon serves (keyed by port, so two bridges on two ports get two daemons) */
159
+ port: number;
160
+ /** the bridge token the pipe name and the handshake key on (settings.ts daemonTokenOf); null = none saved */
161
+ token: string | null;
162
+ /** win32 is a named pipe, and handshakes by default */
163
+ platform: NodeJS.Platform;
164
+ /** the owner the socket directory and file must have; undefined = no POSIX uids (Windows) */
165
+ uid: number | undefined;
166
+ /** the base for the fallback socket directory, and where an earlier build's socket lived (os.tmpdir()) */
167
+ tmpdir: string;
168
+ /** XDG_RUNTIME_DIR as the entry read it */
169
+ runtimeDir?: string | undefined;
170
+ /** prove the token before any frame (daemon-auth.ts) — on for win32 unless told otherwise, elsewhere only when
171
+ * asked (the POSIX suites drive the whole exchange that way). Both ends of one daemon must agree: a handshaking
172
+ * client never writes before the hello, and a handshaking server drops a frame that comes first. */
173
+ handshake?: boolean;
174
+ }
175
+
176
+ /** Where a daemon is reached: its socket or pipe and the handshake a connection starts with — or why there is
177
+ * none (daemonAddress). */
178
+ export type DaemonAddress = ReachableAddress | RefusedAddress;
179
+
180
+ export interface ReachableAddress {
181
+ readonly ok: true;
182
+ readonly port: number;
183
+ readonly platform: NodeJS.Platform;
184
+ /** the socket path (POSIX) or the pipe name (win32) */
185
+ readonly sock: string;
186
+ readonly pipe: boolean;
187
+ /** the handshake, or null where the socket's directory is the check */
188
+ readonly auth: IpcAuth | null;
189
+ /** POSIX with uids: the directory checked at every use, and whether this module makes it (the fallback under the
190
+ * tmpdir — never XDG_RUNTIME_DIR, which is the system's) */
191
+ readonly dir: { readonly path: string; readonly uid: number; readonly make: boolean } | null;
192
+ readonly legacy: LegacyPlace | null;
193
+ }
194
+
195
+ /** Where a daemon from an earlier build listened (<tmpdir>/designtwin-<port>.sock) when that is not the current socket,
196
+ * and the owner a socket there must have to be ours. Kept on a refused address too (a current path too long): that
197
+ * old daemon may well be what holds the port. */
198
+ export interface LegacyPlace { readonly sock: string; readonly uid: number }
199
+
200
+ export interface RefusedAddress {
201
+ readonly ok: false;
202
+ readonly port: number;
203
+ readonly why: string;
204
+ /** refused for want of a token (a pipe, or a handshake, with none saved): no daemon of ours can be there */
205
+ readonly noToken: boolean;
206
+ readonly legacy: LegacyPlace | null;
207
+ }
208
+
209
+ /** Why a handshaking daemon cannot be named or served: no token to key it on. */
210
+ export const NO_DAEMON_TOKEN = "no bridge token is saved yet, so the daemon's pipe cannot be named — start a bridge once (any dtwin command that connects to Figma saves one) and try again";
211
+
212
+ /** THE resolution: the daemon's socket (POSIX) or pipe (win32) for `port`, and its handshake — or a refusal with
213
+ * its reason (no token to name a pipe or key a handshake; a socket path too long for the platform). The only
214
+ * filesystem look is whether XDG_RUNTIME_DIR is private now; the directory chosen is checked again at each use. */
215
+ export function daemonAddress(i: AddressInput): DaemonAddress {
216
+ const { port, token, platform, uid } = i;
217
+ const handshake = i.handshake ?? platform === "win32";
218
+ const refuse = (why: string, noToken = false, legacy: LegacyPlace | null = null): RefusedAddress => ({ ok: false, port, why, noToken, legacy });
219
+ if (token === null && (handshake || platform === "win32")) return refuse(NO_DAEMON_TOKEN, true);
220
+ const authFor = (sock: string): IpcAuth | null => (handshake && token !== null ? { key: ipcKey(token), name: sock } : null);
221
+ if (platform === "win32" && token !== null) {
222
+ const sock = pipeName(token, port);
223
+ return { ok: true, port, platform, sock, pipe: true, auth: authFor(sock), dir: null, legacy: null };
224
+ }
225
+ const name = `designtwin-${port}.sock`;
226
+ const xdg = i.runtimeDir;
227
+ const dir = uid === undefined ? null
228
+ : xdg && path.isAbsolute(xdg) && privateDirProblem(xdg, uid) === null ? { path: xdg, uid, make: false }
229
+ : { path: path.join(i.tmpdir, `designtwin-${uid}`), uid, make: true };
230
+ const sock = path.join(dir?.path ?? i.tmpdir, name);
231
+ // No build ever served a daemon on Windows before the pipe; without uids the current location IS the tmpdir.
232
+ const old = path.join(i.tmpdir, name);
233
+ const legacy = dir && old !== sock ? { sock: old, uid: dir.uid } : null;
234
+ const tooLong = sockLengthProblem(sock, platform);
235
+ if (tooLong) return refuse(tooLong, false, legacy);
236
+ return { ok: true, port, platform, sock, pipe: false, auth: authFor(sock), dir, legacy };
237
+ }
238
+
239
+ /** The refusal when something that cannot prove the token holds the daemon's name: not ours, and nothing
240
+ * was sent to it. ONE spelling (serve, and a request whose daemon fails the handshake). */
241
+ export function foreignHolder(sock: string): Error {
242
+ return new Error(`${sock} is held by a process that could not prove it knows your bridge token — not a dtwin daemon of yours, so nothing was sent to it. ` +
243
+ "If it outlives your own dtwin sessions, another user may be squatting the name: `dtwin --rotate-token` moves the daemon to a new one.");
244
+ }
245
+
246
+ /** Why `dir` is not safe to hold this user's socket, or null when it is. */
247
+ export function privateDirProblem(dir: string, uid: number): string | null {
248
+ let st: fs.Stats;
249
+ try { st = fs.lstatSync(dir); } catch (e) { return "cannot stat it (" + errMsg(e) + ")"; }
250
+ if (st.isSymbolicLink()) return "it is a symlink";
251
+ if (!st.isDirectory()) return "it is not a directory";
252
+ if (st.uid !== uid) return `it is owned by uid ${st.uid}, not ${uid}`;
253
+ if ((st.mode & 0o077) !== 0) return `its mode is ${(st.mode & 0o777).toString(8).padStart(4, "0")} (group/other may access it; it must be 0700)`;
254
+ return null;
255
+ }
256
+
257
+ /** What to do about a refused socket location — quoted by every refusal, and by `dtwin doctor`. */
258
+ export const SOCK_FIX = "Remove it (or have its owner remove it), or point XDG_RUNTIME_DIR or TMPDIR at a directory only you can access.";
259
+
260
+ /** Why the existing file at `sock` must not be used (connected to, or removed), or null — also null
261
+ * when there is no file yet. */
262
+ export function sockFileProblem(sock: string, uid: number): string | null {
263
+ let st: fs.Stats;
264
+ try { st = fs.lstatSync(sock); } catch { return null; }
265
+ return st.uid === uid ? null : `it is owned by uid ${st.uid}, not ${uid}`;
266
+ }
267
+
268
+ /** The longest socket path (bytes, without the NUL) `platform` is held to — see "Length" above. Only
269
+ * macOS has one: on Linux Bun reaches any length through /proc/self/fd. */
270
+ export const MAX_SOCK_PATH_BYTES: Partial<Record<NodeJS.Platform, number>> = { darwin: 103 };
271
+
272
+ /** Why `sock` is too long to be a socket path on `platform`, or null. */
273
+ export function sockLengthProblem(sock: string, platform: NodeJS.Platform = process.platform): string | null {
274
+ const max = MAX_SOCK_PATH_BYTES[platform];
275
+ const bytes = Buffer.byteLength(sock);
276
+ if (max === undefined || bytes <= max) return null;
277
+ return `refusing to use ${sock} as the dtwin daemon socket: it is ${bytes} bytes, and a Unix socket path on ${platform === "darwin" ? "macOS" : platform} is limited to ${max}. ` +
278
+ "Point XDG_RUNTIME_DIR or TMPDIR at a shorter directory only you can access.";
279
+ }
280
+
281
+ // The socket of `a`, its directory checked NOW. `make` (serve, assertNoDaemon): the fallback directory is created
282
+ // if it is missing. A lookup (`make` false) creates nothing: a directory that does not exist yet is null — no
283
+ // daemon can be listening in it. Throws the refusal when the directory, or a socket file already in it, is not
284
+ // this user's own.
285
+ function checkedSock(a: ReachableAddress, make: boolean): string | null {
286
+ if (!a.dir) return a.sock;
287
+ const { path: dir, uid } = a.dir;
288
+ if (make && a.dir.make) {
289
+ // mode is narrowed further by the umask, never widened; an existing directory is left as it is and
290
+ // judged by the check below.
291
+ try { fs.mkdirSync(dir, { mode: 0o700 }); } catch (e) { if (errCode(e) !== "EEXIST") throw e; }
292
+ } else if (!make) {
293
+ try { fs.lstatSync(dir); } catch (e) { if (errCode(e) === "ENOENT") return null; }
294
+ }
295
+ const bad = privateDirProblem(dir, uid);
296
+ if (bad) throw new Error(`refusing to use ${dir} for the dtwin daemon socket: ${bad}. ${SOCK_FIX}`);
297
+ const file = sockFileProblem(a.sock, uid);
298
+ if (file) throw new Error(`refusing to use ${a.sock} as the dtwin daemon socket: ${file}. ${SOCK_FIX}`);
299
+ return a.sock;
300
+ }
301
+
302
+ /** The socket to serve at `a` — its directory created if needed, and checked (stop and the lookups create nothing).
303
+ * Throws the address's refusal, or the location's. */
304
+ export function sockPath(a: DaemonAddress): string {
305
+ if (!a.ok) throw new Error(a.why);
306
+ return checkedSock(a, true) ?? a.sock;
307
+ }
308
+
309
+ /** The directory the daemon socket lives in — created if needed, and checked (POSIX: a pipe has none, and throws). */
310
+ export function sockDir(a: DaemonAddress): string {
311
+ if (a.ok && a.pipe) throw new Error(`${a.sock} is a named pipe: it has no socket directory`);
312
+ return path.dirname(sockPath(a));
313
+ }
314
+
315
+ /** Why the daemon at `a` cannot be used, or null when it can — the refusal as a value, without creating anything.
316
+ * Null for a pipe (the handshake is its check) and for want of a token (no daemon of ours can be there). */
317
+ export function sockProblem(a: DaemonAddress): string | null {
318
+ if (!a.ok) return a.noToken ? null : a.why;
319
+ try { checkedSock(a, false); return null; } catch (e) { return errMsg(e); }
320
+ }
321
+
322
+ // A refused location must not take every command down with it. Anyone able to create
323
+ // <tmpdir>/designtwin-<uid> first (a shared /tmp) could otherwise lock this user out of dtwin
324
+ // entirely. So the "is a daemon running?" lookups (connect, status) read a refusal as "no daemon" —
325
+ // the one-shot path then runs exactly as it does without a daemon — and say so ONCE per process, on
326
+ // stderr (stdout is the MCP server's protocol channel, and a CLI's data). No token is no daemon, silently.
327
+ let refusalWarned = false;
328
+ function lookup(a: DaemonAddress): ReachableAddress | null {
329
+ const warn = (why: string): null => {
330
+ if (!refusalWarned) {
331
+ refusalWarned = true;
332
+ console.error("[dtwin] " + why + " Going on without a daemon.");
333
+ }
334
+ return null;
335
+ };
336
+ if (!a.ok) return a.noToken ? null : warn(a.why);
337
+ try { return checkedSock(a, false) === null ? null : a; } catch (e) { return warn(errMsg(e)); }
338
+ }
339
+
340
+ /** The socket of a daemon started by an earlier dtwin build, which listened directly in the tmpdir
341
+ * (<tmpdir>/designtwin-<port>.sock) — when a socket owned by this user is there and it is not the
342
+ * current location; otherwise null. Only ever used to stop that daemon or to name it in an error. */
343
+ export function legacySock(a: DaemonAddress): string | null {
344
+ if (!a.legacy) return null;
345
+ const { sock, uid } = a.legacy;
346
+ let st: fs.Stats;
347
+ try { st = fs.lstatSync(sock); } catch { return null; }
348
+ return st.isSocket() && st.uid === uid ? sock : null;
349
+ }
350
+
351
+ // Newline-delimited JSON. JSON.stringify escapes literal newlines, so a bare "\n" is an unambiguous
352
+ // frame terminator even for a 24MB export — but the frame can arrive in many chunks, so callers must
353
+ // buffer until they see one. Both sides use this to avoid two subtly different reassembly loops.
354
+ // Chunks are strings: every socket fed here has setEncoding("utf8"), whose decoder holds back a
355
+ // multi-byte character split across reads. Only the NEW chunk is searched for "\n", and the pieces
356
+ // of an unfinished frame are joined once, when its newline arrives: appending to one buffer and
357
+ // searching it from the start on every chunk is quadratic in the frame size (a 100 MB export in
358
+ // 64 KB reads spent tens of seconds just being split).
359
+ export function framer(onFrame: (line: string) => void): (chunk: string) => void {
360
+ let parts: string[] = [];
361
+ return (chunk) => {
362
+ let start = 0;
363
+ let i;
364
+ while ((i = chunk.indexOf("\n", start)) >= 0) {
365
+ const tail = chunk.slice(start, i);
366
+ start = i + 1;
367
+ let line = tail;
368
+ if (parts.length) { parts.push(tail); line = parts.join(""); parts = []; }
369
+ if (line) onFrame(line);
370
+ }
371
+ if (start < chunk.length) parts.push(start ? chunk.slice(start) : chunk);
372
+ };
373
+ }
374
+
375
+ // ---------------------------------------------------------------- server
376
+
377
+ /** The refusal `--serve` gives when a daemon already holds this socket. ONE spelling: figma-pull.ts
378
+ * asks before it opens a bridge of its own, and serve() asks again right before listening. */
379
+ export function alreadyRunning(sock: string): Error {
380
+ return new Error(`a dtwin daemon is already running on ${sock} — stop it with --stop`);
381
+ }
382
+
383
+ /** The socket path at `a` — or a throw: the address's refusal, a refused location, a LIVE daemon already answering on
384
+ * it, or (handshaking) something that cannot prove the token holding the name (foreignHolder). */
385
+ export async function assertNoDaemon(a: DaemonAddress): Promise<string> {
386
+ return (await unclaimed(a)).sock;
387
+ }
388
+
389
+ async function unclaimed(a: DaemonAddress): Promise<ReachableAddress> {
390
+ if (!a.ok) throw new Error(a.why);
391
+ const sock = sockPath(a);
392
+ // A socket file left by a crashed daemon is NOT a running daemon. Probing it first (rather than
393
+ // unlinking unconditionally) is what keeps `--serve` from silently stealing a live daemon's socket.
394
+ const held = await probeAs(sock, a.auth);
395
+ if (held === "live") throw alreadyRunning(sock);
396
+ if (held === "foreign") throw foreignHolder(sock);
397
+ return a;
398
+ }
399
+
400
+ // The `exit` hook that removes the socket file when a `signals: false` host (the MCP server) goes
401
+ // away. Registered ONCE per process, over a set of live socket paths: figma-mcp re-serves after every
402
+ // `dtwin stop`, and a listener per serve() call would pile up (and warn) over a long session.
403
+ const exitUnlink = new Set<string>();
404
+ let exitHooked = false;
405
+ function unlinkOnExit(sock: string): () => void {
406
+ exitUnlink.add(sock);
407
+ if (!exitHooked) {
408
+ exitHooked = true;
409
+ process.on("exit", () => { for (const s of exitUnlink) { try { fs.unlinkSync(s); } catch { /* already gone */ } } });
410
+ }
411
+ return () => { exitUnlink.delete(sock); };
412
+ }
413
+
414
+ // Last-resort handlers for the LONG-LIVED processes only (the `dtwin serve` daemon, the MCP server) —
415
+ // never a one-shot CLI run, where the runtime's default (print the stack, exit non-zero) is already right.
416
+ // Both log ONE line through errMsg, so the cause reaches stderr the process's own log format.
417
+ // - uncaughtException: log, run `cleanup` (synchronous only), then exit 1. The Node docs are explicit
418
+ // that carrying on is unsafe: "The correct use of 'uncaughtException' is to perform synchronous
419
+ // cleanup of allocated resources (e.g. file descriptors, handles, etc) before shutting down the
420
+ // process. It is not safe to resume normal operation after 'uncaughtException'."
421
+ // - unhandledRejection: log only. A stray rejected promise (a tool whose caller went away, a late
422
+ // socket write) must not take down the user's whole stdio MCP session or a daemon mid-export with it.
423
+ // Installed at most once per process: figma-mcp re-serves after every `dtwin stop`. Each call's `cleanup` is kept,
424
+ // though (the MCP server installs first, with its own; a serve() that installs too adds the daemon's shutdown), and all
425
+ // of them run, in order, each in its own try — the returned function drops that cleanup again (a stopped daemon).
426
+ // The cleanups are what release the PORT and the socket: Bun.serve's stop() and net.Server's close() stop listening
427
+ // synchronously, so by the time the exit waits on stderr the port is already free for the next bridge. That wait is
428
+ // capped short (CRASH_FLUSH_CAP_MS): a host restarting the server right away must not find the old process still
429
+ // holding 8787 — or still alive — because a reader stopped draining its stderr.
430
+ const CRASH_FLUSH_CAP_MS = 200;
431
+ // The first SIGINT / SIGTERM's wait for stderr (serve() below); a second one does not wait at all.
432
+ const SIGNAL_FLUSH_CAP_MS = 300;
433
+ let crashHandlersInstalled = false;
434
+ const crashCleanups = new Set<() => void>();
435
+ export function installCrashHandlers(log: (m: string) => void, cleanup?: () => void): () => void {
436
+ if (cleanup) crashCleanups.add(cleanup);
437
+ const forget = (): void => { if (cleanup) crashCleanups.delete(cleanup); };
438
+ if (crashHandlersInstalled) return forget;
439
+ crashHandlersInstalled = true;
440
+ process.on("unhandledRejection", (reason) => {
441
+ log("unhandled promise rejection (continuing): " + errMsg(reason));
442
+ });
443
+ process.on("uncaughtException", (e) => {
444
+ // Say why, release (synchronously: the port and socket are free before anything waits), then wait — briefly — for
445
+ // those lines to reach stderr.
446
+ log("uncaught exception — exiting: " + errMsg(e));
447
+ for (const c of [...crashCleanups]) { try { c(); } catch { /* exiting anyway */ } }
448
+ exitFlushed(1, CRASH_FLUSH_CAP_MS);
449
+ });
450
+ return forget;
451
+ }
452
+
453
+ /** serve()'s handle: the socket it listens on, and the served session's own (served-session.ts) — `activity()` (what
454
+ * the socket has seen, which a host reads to decide whether OTHER processes use it), `idleOut(ms)` ((re)arms the idle
455
+ * shutdown with a window of `ms`, 0 disarms it, and checks once AT ONCE) and `shutdown()`. */
456
+ export interface ServeHandle {
457
+ sock: string;
458
+ shutdown: () => void;
459
+ activity(): DaemonActivity;
460
+ idleOut(ms: number): void;
461
+ }
462
+
463
+ // serve() is the socket adapter around the served session (served-session.ts): the net.Server, the listen race and
464
+ // takeover of a stale socket file, the handshake, and the process's signals and crash hooks. What a frame is, what the
465
+ // daemon answers, the idle decision and cancel-on-leave are the session's.
466
+ export function serve(bridge: DaemonBridge, at: DaemonAddress, { log, idleMs: idleWindow, signals = true, crashHandlers = signals, clock }: ServeOptions): Promise<ServeHandle> {
467
+ return unclaimed(at).then(({ sock, pipe, auth }) => {
468
+ // A pipe (win32) has no file: nothing stale to remove before listening, or after. `auth`: every client proves
469
+ // the token before its first frame is read (null: the directory is the check).
470
+ if (!pipe) { try { fs.unlinkSync(sock); } catch { /* nothing to clean up */ } }
471
+
472
+ let forgetExit: (() => void) | null = null;
473
+ let forgetCrash: (() => void) | null = null;
474
+ // The session's shutdown lets the socket go FIRST (release), synchronously: stop listening, remove the socket file,
475
+ // drop the process hooks — then it aborts its requests and closes the bridge.
476
+ const session = createServedSession(bridge, {
477
+ idleMs: idleWindow,
478
+ ...ifDefined("clock", clock),
479
+ ...ifDefined("log", log),
480
+ release: () => {
481
+ try { server.close(); } catch { /* already closed */ }
482
+ if (!pipe) { try { fs.unlinkSync(sock); } catch { /* already gone */ } }
483
+ if (forgetExit) { forgetExit(); forgetExit = null; }
484
+ if (forgetCrash) { forgetCrash(); forgetCrash = null; }
485
+ },
486
+ });
487
+ const shutdown = session.shutdown;
488
+
489
+ const server = net.createServer((conn) => {
490
+ conn.setEncoding("utf8");
491
+ const client = session.connect((frame) => { send(conn, frame); });
492
+ // 'end' counts too: net.Server's default allowHalfOpen:false ends our side as soon as the peer
493
+ // ends its own, so no reply could be written after it anyway.
494
+ conn.on("close", client.leave);
495
+ conn.on("end", client.leave);
496
+ // A client that dies mid-export must not take the daemon with it: the write in send() would emit
497
+ // EPIPE on a dead socket, which is an unhandled 'error' event => process exit.
498
+ conn.on("error", client.leave);
499
+ const onFrames = framer(client.line);
500
+ if (!auth) { conn.on("data", onFrames); return; }
501
+ // Handshaking: until the client has proved the token, its bytes go to the handshake (capped, timed),
502
+ // never to the frame reader — so no __ping, no __status and no request is read, and no activity
503
+ // counted, before that. A failure is a silent destroy: the peer learns only that the pipe closed.
504
+ const hs = serverHandshake(auth, {
505
+ write: (line) => { try { conn.write(line + "\n"); } catch { /* the client went away */ } },
506
+ sink: onFrames,
507
+ fail: () => { try { conn.destroy(); } catch { /* already gone */ } },
508
+ });
509
+ conn.on("data", hs.data);
510
+ conn.on("close", hs.stop);
511
+ });
512
+
513
+ // Ctrl-C / kill must remove the socket file, or the next --serve refuses to start against a
514
+ // socket nobody is listening on. `as const`: process.on's overload wants NodeJS.Signals, not string.
515
+ // shutdown() releases the socket file, the socket and the port synchronously; the exit then waits for the log lines
516
+ // to reach stderr — briefly (SIGNAL_FLUSH_CAP_MS: Ctrl-C must feel instant, and a supervisor's SIGKILL follows its
517
+ // SIGTERM soon after). A SECOND signal is a person (or supervisor) insisting: exit at once, waiting on nothing.
518
+ if (signals) {
519
+ let signalled = false;
520
+ for (const sig of ["SIGINT", "SIGTERM"] as const) process.on(sig, () => {
521
+ if (signalled) { exitFlushed(0, 0); return; }
522
+ signalled = true;
523
+ shutdown();
524
+ exitFlushed(0, SIGNAL_FLUSH_CAP_MS);
525
+ });
526
+ }
527
+ // A host that exits some other way (the MCP server ends when its stdio closes) still must not
528
+ // leave the socket file behind for the next probe to trip over. One process-level hook, and this
529
+ // socket is forgotten again on shutdown (the MCP server re-serves after every `dtwin stop`).
530
+ else if (!pipe) forgetExit = unlinkOnExit(sock);
531
+ // shutdown() is synchronous (closes the servers, unlinks the socket file), so it is exactly the
532
+ // "synchronous cleanup" the Node docs allow an uncaughtException handler before it exits.
533
+ if (crashHandlers) forgetCrash = installCrashHandlers(log ?? ((m) => console.error("[dtwin] " + m)), shutdown);
534
+
535
+ const handle: ServeHandle = { sock, shutdown, activity: session.activity, idleOut: session.idleOut };
536
+ return new Promise<ServeHandle>((resolve, reject) => {
537
+ const onListening = () => {
538
+ if (log) log(`daemon listening on ${sock} (pid ${process.pid}) — stop it with: dtwin --stop`);
539
+ resolve(handle);
540
+ };
541
+ // The probe-then-unlink-then-listen window is a race: another `--serve` can bind between our
542
+ // probe and our listen. EADDRINUSE from listen() is that case — re-probe, and either refuse
543
+ // (it is alive) or take over the stale file it left (it is not), exactly once.
544
+ // A pipe has no stale file to take over: a name that is taken is held by a live process, ours
545
+ // (alreadyRunning) or not (foreignHolder). And ANY listen error there is read as "taken, maybe":
546
+ // Bun has reported a taken pipe name as ERR_INVALID_ARG_TYPE, not EADDRINUSE, so the re-probe
547
+ // decides, and an error with nothing holding the name is passed on as it came.
548
+ const failed = (e: NodeJS.ErrnoException): void => {
549
+ if (!pipe && e.code !== "EADDRINUSE") return reject(e);
550
+ probeAs(sock, auth).then((held) => {
551
+ if (held === "live") return reject(alreadyRunning(sock));
552
+ if (held === "foreign") return reject(foreignHolder(sock));
553
+ if (pipe) return reject(e);
554
+ try { fs.unlinkSync(sock); } catch { /* gone already */ }
555
+ server.once("error", reject);
556
+ server.listen(sock, onListening);
557
+ }, reject);
558
+ };
559
+ server.once("error", failed);
560
+ // A listen that throws instead of emitting (a runtime's pipe-path bug) takes the same road.
561
+ try { server.listen(sock, onListening); } catch (e) { server.removeListener("error", failed); failed(e as NodeJS.ErrnoException); }
562
+ });
563
+ });
564
+ }
565
+
566
+ function send(conn: net.Socket, obj: SessionFrame): void {
567
+ try { conn.write(JSON.stringify(obj) + "\n"); } catch { /* client went away mid-reply */ }
568
+ }
569
+
570
+ // ---------------------------------------------------------------- client
571
+
572
+ // Is there a LIVE daemon? A stale socket file (crashed daemon) answers ECONNREFUSED, and must read as
573
+ // "no daemon" rather than hanging or erroring — otherwise one crash makes every later CLI run fail.
574
+ // `auth` (handshaking, the address's): the __ping goes out only once the daemon has proved the token.
575
+ export function probe(sock: string, timeoutMs = 1500, auth: IpcAuth | null = null): Promise<boolean> {
576
+ return probeAs(sock, auth, timeoutMs).then((held) => held === "live");
577
+ }
578
+
579
+ /** What holds `sock`: "live" — a daemon that answered (and, handshaking, proved the token); "none" —
580
+ * nothing, a stale file, or (not handshaking) something that never answered; "foreign" — handshaking,
581
+ * something accepted the connection but did not prove the token (a squatter, or a mute listener). */
582
+ export type ProbeResult = "live" | "none" | "foreign";
583
+
584
+ export function probeAs(sock: string, auth: IpcAuth | null, timeoutMs = 1500): Promise<ProbeResult> {
585
+ return ask(sock, auth, "__ping", timeoutMs).then((r) => r.held);
586
+ }
587
+
588
+ // One control frame (`__ping`, `__status`) on ONE connection, with the probe's reading of what holds the socket: the
589
+ // daemon's reply when it is "live". A status is its own probe — no second connection after a __ping.
590
+ function ask(sock: string, auth: IpcAuth | null, cmd: "__ping" | "__status", timeoutMs: number): Promise<{ held: ProbeResult; reply: DaemonReply | null }> {
591
+ return new Promise<{ held: ProbeResult; reply: DaemonReply | null }>((resolve) => {
592
+ let done = false;
593
+ let connected = false;
594
+ let stopHs = (): void => {};
595
+ let c: net.Socket | null = null;
596
+ // Handshaking, a peer that accepted and then stalled or failed holds the name: "foreign", not "none".
597
+ const silent = (): ProbeResult => (connected && auth ? "foreign" : "none");
598
+ const t = setTimeout(() => finish(silent()), timeoutMs);
599
+ function finish(v: ProbeResult, reply: DaemonReply | null = null): void {
600
+ if (done) return;
601
+ done = true;
602
+ clearTimeout(t);
603
+ stopHs();
604
+ try { if (v === "live") c?.end(); else c?.destroy(); } catch { /* already gone */ }
605
+ resolve({ held: v, reply });
606
+ }
607
+ try { c = net.createConnection(sock); } catch { return finish("none"); }
608
+ const conn = c;
609
+ conn.on("error", () => finish(silent()));
610
+ conn.setEncoding("utf8");
611
+ const ping = (): void => { try { conn.write(JSON.stringify({ cmd }) + "\n"); } catch { finish(silent()); } };
612
+ // Only a daemon REPLY is a daemon: a handshaking daemon's hello reaching a client that does not handshake
613
+ // (it wrote its __ping first) is not one, and neither is anything else that happens to write a line.
614
+ const onFrame = framer((line) => {
615
+ let parsed: unknown;
616
+ try { parsed = JSON.parse(line) as unknown; } catch { return finish(silent()); }
617
+ const reply = parseDaemonReply(parsed);
618
+ if (reply) finish("live", reply); else finish(silent());
619
+ });
620
+ if (!auth) {
621
+ conn.on("data", onFrame);
622
+ conn.on("connect", () => { connected = true; ping(); });
623
+ return;
624
+ }
625
+ const hs = clientHandshake(auth, {
626
+ write: (l) => { try { conn.write(l + "\n"); } catch { /* the error handler settles it */ } },
627
+ sink: onFrame,
628
+ ready: ping,
629
+ fail: () => finish("foreign"),
630
+ timeoutMs,
631
+ });
632
+ stopHs = hs.stop;
633
+ conn.on("data", hs.data);
634
+ conn.on("connect", () => { connected = true; });
635
+ });
636
+ }
637
+
638
+ // One request through a running daemon. Rejects (rather than falling back) once connected: a daemon
639
+ // that answered __ping and then failed is a real error the caller should see, not a reason to
640
+ // silently open a second bridge that would then hit EADDRINUSE against the daemon itself.
641
+ //
642
+ // `onProgress` opts the request into progress frames (`progress: true` on the wire) and receives each
643
+ // well-formed tick. A progress frame is never the reply: it is dispatched and the reader reads on. A
644
+ // malformed one (a bad tick, or a frame this client never asked for) is DROPPED, not fatal — a tick is
645
+ // advisory, and losing one must never cost the caller its export. A throwing listener is its own
646
+ // failure, as in server-core's relay.
647
+ //
648
+ // Timeout: `timeoutMs` is the outer guard for a daemon that stopped answering entirely (the daemon's
649
+ // own per-command budget, msg.timeoutMs, still bounds the whole command on the bridge side). Every
650
+ // progress frame RE-ARMS it: a tick is proof the daemon and the plugin are both alive. So with progress
651
+ // flowing the guard is a silence budget; without progress it is the same total budget it always was. It is
652
+ // clock.ts's deadline — the rule the bridge's own budgets run on (pending-requests.ts), stepped by the suites.
653
+ //
654
+ // `signal` (optional): aborted → the socket is DESTROYED and the promise rejects at once, with the
655
+ // signal's reason when it is an Error of the caller's own, else "request aborted by the caller" — the
656
+ // same rule as server-core's request. Destroying (not ending) the socket is the cancel: the
657
+ // daemon sees its client go and abandons the request on the wire. Aborted before the call → nothing
658
+ // is connected or sent.
659
+ //
660
+ // `auth` (handshaking): nothing is written until the daemon has proved the token; one that cannot is
661
+ // refused with foreignHolder and never sees the request.
662
+ function request<C extends Cmd>(sock: string, msg: DaemonCommandRequest<C> | DaemonControlRequest, timeoutMs?: number, onProgress?: (tick: ProgressTick) => void, signal?: AbortSignal, auth: IpcAuth | null = null): Promise<{ result: unknown; client: ClientRow | null }> {
663
+ return new Promise<{ result: unknown; client: ClientRow | null }>((resolve, reject) => {
664
+ if (signal?.aborted) return reject(abortReason(signal, "request aborted before it was sent"));
665
+ const c = net.createConnection(sock);
666
+ c.setEncoding("utf8");
667
+ let settled = false;
668
+ let stopHs = (): void => {};
669
+ // Removed on settle, so a long-lived signal shared by many requests never accumulates listeners.
670
+ let onAbort: (() => void) | null = null;
671
+ const unlisten = () => { if (signal && onAbort) signal.removeEventListener("abort", onAbort); onAbort = null; };
672
+ const done = <V>(fn: (v: V) => void, v: V) => { if (!settled) { settled = true; unlisten(); stopHs(); try { c.end(); } catch { /* already gone */ } fn(v); } };
673
+ // The daemon's own per-command budget still applies; this is the outer guard for a daemon that
674
+ // stopped answering entirely. Generous by design — an --all-pages export legitimately runs long.
675
+ // The deadline rule (clock.ts) the bridge's own budgets run on: armed now, re-armed by every tick.
676
+ const guard = timeoutMs ? deadline(systemClock, timeoutMs, () => done(reject, new Error("daemon did not respond within " + Math.round(timeoutMs / 1000) + "s"))) : null;
677
+ const disarm = (): void => { guard?.cancel(); };
678
+ const arm = (): void => { guard?.rearm(); };
679
+ c.on("error", (e) => { disarm(); done(reject, e); });
680
+ c.on("close", () => { disarm(); done(reject, new Error("daemon closed the connection before replying")); });
681
+ const onFrames = framer((line) => {
682
+ if (settled) return;
683
+ let parsed: unknown;
684
+ try { parsed = JSON.parse(line) as unknown; } catch (e) { disarm(); return done(reject, new Error("bad reply frame: " + errMsg(e))); }
685
+ if (isProgressShaped(parsed)) {
686
+ arm(); // activity: the silence budget starts over (see above)
687
+ if (onProgress && isProgressTick(parsed.progress)) {
688
+ try { onProgress(parsed.progress); } catch { /* a listener's failure is its own; the request goes on */ }
689
+ }
690
+ return;
691
+ }
692
+ disarm();
693
+ const r = parseDaemonReply(parsed);
694
+ if (!r) return done(reject, new Error("bad reply frame: not a daemon reply"));
695
+ if (r.ok) done(resolve, { result: r.result, client: r.client ?? null });
696
+ else done(reject, new Error(r.error));
697
+ });
698
+ // `progress: true` only with a listener: a request without one is byte-identical on the wire to
699
+ // what it always was, so no daemon ever has a reason to send it a progress frame.
700
+ const frame = onProgress && !CONTROL.has(msg.cmd) ? { ...msg, progress: true } : msg;
701
+ const send = (): void => { c.write(JSON.stringify(frame) + "\n"); };
702
+ if (!auth) {
703
+ c.on("data", onFrames);
704
+ c.on("connect", send);
705
+ } else {
706
+ const hs = clientHandshake(auth, {
707
+ write: (l) => { try { c.write(l + "\n"); } catch { /* the close handler settles it */ } },
708
+ sink: onFrames,
709
+ ready: send,
710
+ fail: () => { disarm(); done(reject, foreignHolder(sock)); try { c.destroy(); } catch { /* already gone */ } },
711
+ // A request follows a probe that already saw this daemon prove the token, so a slow answer here is a
712
+ // busy daemon, not a stranger: it gets the request's own budget, never less than the handshake's.
713
+ timeoutMs: Math.max(AUTH_TIMEOUT_MS, timeoutMs ?? 0),
714
+ });
715
+ stopHs = hs.stop;
716
+ c.on("data", hs.data);
717
+ }
718
+ if (signal) {
719
+ const sig = signal;
720
+ onAbort = () => {
721
+ if (settled) return;
722
+ disarm();
723
+ done(reject, abortReason(sig, "request aborted by the caller"));
724
+ try { c.destroy(); } catch { /* already gone */ }
725
+ };
726
+ sig.addEventListener("abort", onAbort, { once: true });
727
+ }
728
+ });
729
+ }
730
+
731
+ // The routing decision every CLI command makes: use the daemon if one is live, otherwise report that
732
+ // there isn't one and let the caller open its own bridge exactly as it does without a daemon. The probe proves
733
+ // it is live; each request then opens its own connection (the protocol is one request per connection).
734
+ export async function connect(at: DaemonAddress): Promise<DaemonConnection | null> {
735
+ const a = lookup(at);
736
+ if (!a || !(await probe(a.sock, undefined, a.auth))) return null;
737
+ const { sock, auth } = a;
738
+ // The daemon's reply to a forwarded command is the plugin's own reply. The daemon's bridge already
739
+ // checked it against commands.ts on arrival, but that was another process (and possibly an older
740
+ // build), so it is checked again here — which is what makes the typed reply below honest rather
741
+ // than a guess about what came over the socket.
742
+ const send = async <C extends Cmd>(msg: DaemonCommandRequest<C>, o: DaemonRequestOptions = {}): Promise<Sent<C>> => {
743
+ const r = await request(sock, msg, o.guardMs, o.onProgress, o.signal, auth);
744
+ const bad = replyShapeError(msg.cmd, r.result);
745
+ if (bad) throw new Error(`the daemon relayed an unexpected shape for ${msg.cmd}: ${bad}`);
746
+ return { reply: r.result as Commands[C]["reply"], client: r.client };
747
+ };
748
+ return { sock, request: send };
749
+ }
750
+
751
+ /** Stops the live daemon at `at`: true when one was stopped, false when none was running. A refused socket
752
+ * location throws; no saved token (a pipe, or a handshake) means no daemon of ours to stop: false. */
753
+ export async function stop(at: DaemonAddress): Promise<boolean> {
754
+ if (!at.ok) {
755
+ if (at.noToken) return false;
756
+ throw new Error(at.why);
757
+ }
758
+ const sock = checkedSock(at, false);
759
+ if (sock === null || !(await probe(sock, undefined, at.auth))) return false;
760
+ await request(sock, { cmd: "__shutdown" }, 5000, undefined, undefined, at.auth);
761
+ return true;
762
+ }
763
+
764
+ /** Stops a live daemon from an earlier build (legacySock); returns its socket, or null when there is
765
+ * none. For `--stop` after stop() found nothing at the current location. */
766
+ export async function stopLegacy(at: DaemonAddress): Promise<string | null> {
767
+ const sock = legacySock(at);
768
+ if (!sock || !(await probe(sock))) return null;
769
+ await request(sock, { cmd: "__shutdown" }, 5000);
770
+ return sock;
771
+ }
772
+
773
+ // The status probe's budget: a __status is answered by the daemon itself, never forwarded, but a daemon busy relaying
774
+ // a large export may take a moment to read it — the budget the separate request after a __ping used to get.
775
+ const STATUS_TIMEOUT_MS = 5000;
776
+
777
+ /** The live daemon's own __status, or null when none is running at `at` — one connection: the __status is the probe. */
778
+ export async function status(at: DaemonAddress): Promise<DaemonStatusView | null> {
779
+ const a = lookup(at);
780
+ if (!a) return null;
781
+ const { held, reply } = await ask(a.sock, a.auth, "__status", STATUS_TIMEOUT_MS);
782
+ if (held !== "live" || !reply) return null;
783
+ if (!reply.ok) throw new Error(reply.error);
784
+ // `daemon: true` is checked, the rest is read as optional (a daemon from an OLDER bridge may omit newer
785
+ // fields — see staleness.ts daemonRowStalenessNote).
786
+ if (!isDaemonStatus(reply.result)) throw new Error("the daemon answered __status with an unexpected shape");
787
+ return reply.result;
788
+ }