@venturewild/workspace 0.6.3 → 0.6.5

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 (54) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +112 -112
  3. package/package.json +85 -85
  4. package/server/bin/wild-workspace.mjs +1096 -1096
  5. package/server/src/account.mjs +114 -114
  6. package/server/src/agent-login.mjs +146 -146
  7. package/server/src/agent-readiness.mjs +200 -200
  8. package/server/src/agent.mjs +468 -468
  9. package/server/src/bazaar/core.mjs +974 -974
  10. package/server/src/bazaar/index.mjs +88 -88
  11. package/server/src/bazaar/mcp-server.mjs +429 -429
  12. package/server/src/bazaar/mock-tickup.mjs +97 -97
  13. package/server/src/bazaar/preview-server.mjs +95 -95
  14. package/server/src/bazaar/seed-recipes/customer-feedback-form/know-how.md +23 -23
  15. package/server/src/bazaar/seed-recipes/customer-feedback-form/recipe.json +24 -24
  16. package/server/src/bazaar/seed-recipes/landing-page-launch/know-how.md +29 -29
  17. package/server/src/bazaar/seed-recipes/landing-page-launch/recipe.json +25 -25
  18. package/server/src/bazaar/seed-recipes/personal-portfolio/know-how.md +21 -21
  19. package/server/src/bazaar/seed-recipes/personal-portfolio/recipe.json +24 -24
  20. package/server/src/bazaar/seed-recipes/receipt-sorter/know-how.md +31 -31
  21. package/server/src/bazaar/seed-recipes/receipt-sorter/recipe.json +25 -25
  22. package/server/src/bazaar/seed-recipes/tickup-hr-matching/know-how.md +79 -79
  23. package/server/src/bazaar/seed-recipes/tickup-hr-matching/recipe.json +40 -40
  24. package/server/src/canvas/core.mjs +446 -446
  25. package/server/src/canvas/index.mjs +42 -42
  26. package/server/src/canvas/mcp-server.mjs +253 -253
  27. package/server/src/canvas-rails.mjs +108 -108
  28. package/server/src/config.mjs +404 -404
  29. package/server/src/daemon-bin.mjs +110 -110
  30. package/server/src/daemon-supervisor.mjs +285 -285
  31. package/server/src/doctor.mjs +375 -375
  32. package/server/src/inbox.mjs +86 -86
  33. package/server/src/index.mjs +3332 -3332
  34. package/server/src/listings-rails.mjs +156 -156
  35. package/server/src/logpaths.mjs +98 -98
  36. package/server/src/observability.mjs +45 -45
  37. package/server/src/operator.mjs +92 -92
  38. package/server/src/pairing.mjs +137 -137
  39. package/server/src/service.mjs +515 -515
  40. package/server/src/session-reporter.mjs +201 -201
  41. package/server/src/settings.mjs +145 -145
  42. package/server/src/share.mjs +182 -182
  43. package/server/src/skills.mjs +213 -213
  44. package/server/src/supervisor.mjs +647 -647
  45. package/server/src/support-consent.mjs +133 -133
  46. package/server/src/sync.mjs +248 -248
  47. package/server/src/transcript.mjs +121 -121
  48. package/server/src/turn-mcp.mjs +46 -46
  49. package/server/src/usage.mjs +405 -405
  50. package/server/src/workspace-registry.mjs +295 -295
  51. package/server/src/workspaces.mjs +145 -145
  52. package/web/dist/assets/index-nEl9swiQ.js +131 -0
  53. package/web/dist/index.html +1 -1
  54. package/web/dist/assets/index-DVflHhYJ.js +0 -131
@@ -1,285 +1,285 @@
1
- // Lifecycle owner for the bmo-sync daemon.
2
- //
3
- // The daemon is bmo-sync's sync engine running as its own process (a browser
4
- // app can't embed the Rust library the way wild-terminal's Tauri build did).
5
- // It does ALL the syncing; wild-workspace only displays its status and tells
6
- // it what to pair. This module is the one extra responsibility wild-workspace
7
- // takes on: pressing the daemon's power button.
8
- //
9
- // Why wild-workspace owns the lifecycle: on a locked-down (enterprise-managed)
10
- // machine the daemon cannot register itself as an OS service — logon-triggered
11
- // scheduled tasks are blocked without admin. So the app the user actually runs
12
- // starts it instead.
13
- //
14
- // The daemon is spawned DETACHED + window-hidden: it keeps running (and so
15
- // keeps syncing) after wild-workspace — server and browser both — has closed,
16
- // and it never flashes a console window. It is deliberately NOT stopped when
17
- // the server stops. The one gap this leaves — sync paused between a reboot and
18
- // the next `wild-workspace` launch — is covered by bmo-sync's offline-resume.
19
-
20
- import { spawn } from 'node:child_process';
21
- import {
22
- openSync,
23
- closeSync,
24
- mkdirSync,
25
- readFileSync,
26
- writeFileSync,
27
- unlinkSync,
28
- } from 'node:fs';
29
- import path from 'node:path';
30
- import os from 'node:os';
31
- import { resolveDaemonBinary } from './daemon-bin.mjs';
32
-
33
- const DEFAULT_HTTP_BASE = 'http://127.0.0.1:8320';
34
-
35
- export class DaemonSupervisor {
36
- /**
37
- * @param {object} [opts]
38
- * @param {string} [opts.httpBase] the daemon's local HTTP origin.
39
- * @param {string} [opts.globalDir] where the pid + log files live. A
40
- * machine-global dir (`~/.wild-workspace`) — the daemon is one per machine,
41
- * not one per workspace, so this is deliberately NOT the per-workspace
42
- * `.wild-workspace/` data dir.
43
- * @param {Function} [opts.resolveBinary] daemon-binary resolver (test seam).
44
- * @param {Function} [opts.spawnImpl] child_process.spawn (test seam).
45
- * @param {Function} [opts.fetchImpl] global fetch (test seam).
46
- * @param {Function} [opts.killImpl] process.kill (test seam).
47
- * @param {NodeJS.ProcessEnv} [opts.env]
48
- */
49
- constructor({
50
- httpBase = DEFAULT_HTTP_BASE,
51
- globalDir = path.join(os.homedir(), '.wild-workspace'),
52
- resolveBinary = resolveDaemonBinary,
53
- spawnImpl = spawn,
54
- fetchImpl = globalThis.fetch,
55
- killImpl = (pid, sig) => process.kill(pid, sig),
56
- env = process.env,
57
- // b-ii: when the install is logged in (account.json present), these are
58
- // injected into the daemon's spawn env so it opens the proxy link
59
- // (`BMO_DAEMON_ACCOUNT_TOKEN`) against the right relay
60
- // (`BMO_DAEMON_SERVER_URL`). Absent → daemon syncs only, no proxy link.
61
- accountToken = null,
62
- serverUrl = null,
63
- } = {}) {
64
- this.httpBase = httpBase.replace(/\/+$/, '');
65
- this.globalDir = globalDir;
66
- this.pidFile = path.join(globalDir, 'daemon.pid');
67
- this.logFile = path.join(globalDir, 'daemon.log');
68
- // Serializes concurrent ensureRunning() spawns (server + supervisor) so only
69
- // one daemon is started — prevents the duplicate-daemon :8320 bind conflict.
70
- this.spawnLockFile = path.join(globalDir, 'daemon-spawn.lock');
71
- this.resolveBinary = resolveBinary;
72
- this.spawnImpl = spawnImpl;
73
- this.fetchImpl = fetchImpl;
74
- this.killImpl = killImpl;
75
- this.env = env;
76
- this.accountToken = accountToken;
77
- this.serverUrl = serverUrl;
78
- }
79
-
80
- /** Probe the daemon's /health endpoint. Never throws. */
81
- async health() {
82
- try {
83
- const res = await this.fetchImpl(`${this.httpBase}/health`, {
84
- signal: AbortSignal.timeout(2000),
85
- });
86
- return { running: !!res.ok };
87
- } catch {
88
- return { running: false };
89
- }
90
- }
91
-
92
- /**
93
- * Start the daemon unless it is already up. Idempotent and best-effort —
94
- * the result is reported, never thrown.
95
- *
96
- * Concurrency-safe: the daemon is ensured by BOTH the server (its DaemonBridge)
97
- * and the always-on supervisor (daemonTick). At boot neither sees the daemon up
98
- * yet, so without a guard they'd BOTH spawn → two daemons fighting over :8320
99
- * (`Address already in use`) and both opening proxy links → the relay evicts one
100
- * → reconnect churn. An atomic cross-process spawn lock serializes the decision:
101
- * the winner re-checks health then spawns; the loser waits for the winner's
102
- * daemon instead of spawning its own.
103
- * @returns {Promise<{started:boolean, alreadyRunning?:boolean, pid?:number,
104
- * error?:string}>}
105
- */
106
- async ensureRunning() {
107
- if ((await this.health()).running) {
108
- return { started: false, alreadyRunning: true };
109
- }
110
- if (!this.acquireSpawnLock()) {
111
- // Another process (server bridge / always-on supervisor) is spawning —
112
- // wait for ITS daemon rather than racing in a second one.
113
- const up = await this.waitForHealthy(4000);
114
- return up ? { started: false, alreadyRunning: true } : { started: false, error: 'spawn-in-progress' };
115
- }
116
- try {
117
- // Re-check under the lock: the other caller may have just brought it up.
118
- if ((await this.health()).running) {
119
- return { started: false, alreadyRunning: true };
120
- }
121
- return this.spawnDaemon();
122
- } finally {
123
- this.releaseSpawnLock();
124
- }
125
- }
126
-
127
- /** Is a pid alive? EPERM ("exists, not ours") still counts as alive. */
128
- pidAlive(pid) {
129
- try { this.killImpl(pid, 0); return true; } catch (e) { return !!(e && e.code === 'EPERM'); }
130
- }
131
-
132
- /**
133
- * Atomic, cross-process spawn lock (shared globalDir, so it serializes the
134
- * server and the supervisor). Returns true iff we hold it. A stale lock whose
135
- * recorded pid is dead is taken over — a crash mid-spawn can't wedge it.
136
- */
137
- acquireSpawnLock() {
138
- try { mkdirSync(this.globalDir, { recursive: true }); } catch { /* surfaced below */ }
139
- try {
140
- writeFileSync(this.spawnLockFile, String(process.pid), { flag: 'wx' }); // atomic exclusive create
141
- return true;
142
- } catch {
143
- let holder = null;
144
- try { holder = Number(readFileSync(this.spawnLockFile, 'utf8').trim()); } catch { /* unreadable */ }
145
- if (holder && this.pidAlive(holder)) return false; // a live caller is spawning
146
- try { writeFileSync(this.spawnLockFile, String(process.pid)); return true; } catch { return false; }
147
- }
148
- }
149
-
150
- releaseSpawnLock() {
151
- try {
152
- if (Number(readFileSync(this.spawnLockFile, 'utf8').trim()) === process.pid) unlinkSync(this.spawnLockFile);
153
- } catch { /* already gone */ }
154
- }
155
-
156
- /** Spawn the daemon detached + window-hidden, logging to `daemon.log`. */
157
- spawnDaemon() {
158
- const bin = this.resolveBinary({ env: this.env });
159
- // `null` = an explicit override path that doesn't exist; `path` = nothing
160
- // concrete found, only the bare name as a PATH last-resort. In neither case
161
- // is there a real binary to launch — refuse rather than ENOENT later.
162
- if (!bin || bin.source === 'path') {
163
- return { started: false, error: 'daemon-binary-not-found' };
164
- }
165
-
166
- try {
167
- mkdirSync(this.globalDir, { recursive: true });
168
- } catch {
169
- /* fall through — openSync below will surface a real problem */
170
- }
171
-
172
- // A real fd (not 'ignore') so the daemon's stdout/stderr land in a log the
173
- // user can read — and so its eprintln writes hit a valid handle.
174
- let logFd = 'ignore';
175
- try {
176
- logFd = openSync(this.logFile, 'a');
177
- } catch {
178
- /* can't open the log — run anyway with output discarded */
179
- }
180
-
181
- let child;
182
- try {
183
- child = this.spawnImpl(bin.path, [], {
184
- detached: true, // outlive the wild-workspace server
185
- windowsHide: true, // no console window — the whole point
186
- stdio: ['ignore', logFd, logFd],
187
- // b-ii proxy link: inject the account token + relay URL when the
188
- // install is logged in. Object-spreading a falsy value is a no-op,
189
- // so an unauthenticated install spawns with a clean inherited env.
190
- env: {
191
- ...this.env,
192
- ...(this.accountToken && { BMO_DAEMON_ACCOUNT_TOKEN: this.accountToken }),
193
- ...(this.serverUrl && { BMO_DAEMON_SERVER_URL: this.serverUrl }),
194
- },
195
- });
196
- } catch (err) {
197
- if (typeof logFd === 'number') {
198
- try { closeSync(logFd); } catch {}
199
- }
200
- return { started: false, error: String(err?.message || err) };
201
- }
202
-
203
- // The parent must drop its own copy of the log fd + its handle on the
204
- // child, or the server process can't exit cleanly.
205
- if (typeof logFd === 'number') {
206
- try { closeSync(logFd); } catch {}
207
- }
208
- child.unref();
209
-
210
- try {
211
- writeFileSync(this.pidFile, String(child.pid));
212
- } catch {
213
- /* pid file is best-effort — `stop` falls back to a health probe */
214
- }
215
- return { started: true, pid: child.pid, binary: bin.path, source: bin.source };
216
- }
217
-
218
- /** The pid recorded by the last spawn, or null. */
219
- readPid() {
220
- try {
221
- const pid = Number(readFileSync(this.pidFile, 'utf8').trim());
222
- return Number.isInteger(pid) && pid > 0 ? pid : null;
223
- } catch {
224
- return null;
225
- }
226
- }
227
-
228
- /** Stop the daemon (best-effort) by signalling the recorded pid. */
229
- async stop() {
230
- const pid = this.readPid();
231
- if (!pid) {
232
- const running = (await this.health()).running;
233
- return { stopped: false, reason: running ? 'no-pid-file' : 'not-running' };
234
- }
235
- try {
236
- this.killImpl(pid, 'SIGTERM');
237
- } catch (err) {
238
- if (err?.code === 'ESRCH') {
239
- // already gone — tidy the stale pid file
240
- try { unlinkSync(this.pidFile); } catch {}
241
- return { stopped: false, reason: 'not-running' };
242
- }
243
- return { stopped: false, reason: String(err?.message || err) };
244
- }
245
- try { unlinkSync(this.pidFile); } catch {}
246
- return { stopped: true, pid };
247
- }
248
-
249
- /**
250
- * Recycle the daemon so it loads a freshly-resolved binary (after an
251
- * auto-update). Stop the running process, wait for it to release its API port,
252
- * then spawn again. Returns the `spawnDaemon` result.
253
- */
254
- async recycle() {
255
- await this.stop();
256
- // Wait for the old process to exit + free :PORT, else the new one can't bind.
257
- const deadline = Date.now() + 5000;
258
- while (Date.now() < deadline && (await this.health()).running) {
259
- await new Promise((r) => setTimeout(r, 200));
260
- }
261
- return this.spawnDaemon();
262
- }
263
-
264
- /** Combined status for `wild-workspace daemon status`. */
265
- async status() {
266
- const { running } = await this.health();
267
- return {
268
- running,
269
- pid: this.readPid(),
270
- httpBase: this.httpBase,
271
- pidFile: this.pidFile,
272
- logFile: this.logFile,
273
- };
274
- }
275
-
276
- /** Poll /health until the daemon answers or the deadline passes. */
277
- async waitForHealthy(timeoutMs = 4000) {
278
- const deadline = Date.now() + timeoutMs;
279
- while (Date.now() < deadline) {
280
- if ((await this.health()).running) return true;
281
- await new Promise((r) => setTimeout(r, 250));
282
- }
283
- return false;
284
- }
285
- }
1
+ // Lifecycle owner for the bmo-sync daemon.
2
+ //
3
+ // The daemon is bmo-sync's sync engine running as its own process (a browser
4
+ // app can't embed the Rust library the way wild-terminal's Tauri build did).
5
+ // It does ALL the syncing; wild-workspace only displays its status and tells
6
+ // it what to pair. This module is the one extra responsibility wild-workspace
7
+ // takes on: pressing the daemon's power button.
8
+ //
9
+ // Why wild-workspace owns the lifecycle: on a locked-down (enterprise-managed)
10
+ // machine the daemon cannot register itself as an OS service — logon-triggered
11
+ // scheduled tasks are blocked without admin. So the app the user actually runs
12
+ // starts it instead.
13
+ //
14
+ // The daemon is spawned DETACHED + window-hidden: it keeps running (and so
15
+ // keeps syncing) after wild-workspace — server and browser both — has closed,
16
+ // and it never flashes a console window. It is deliberately NOT stopped when
17
+ // the server stops. The one gap this leaves — sync paused between a reboot and
18
+ // the next `wild-workspace` launch — is covered by bmo-sync's offline-resume.
19
+
20
+ import { spawn } from 'node:child_process';
21
+ import {
22
+ openSync,
23
+ closeSync,
24
+ mkdirSync,
25
+ readFileSync,
26
+ writeFileSync,
27
+ unlinkSync,
28
+ } from 'node:fs';
29
+ import path from 'node:path';
30
+ import os from 'node:os';
31
+ import { resolveDaemonBinary } from './daemon-bin.mjs';
32
+
33
+ const DEFAULT_HTTP_BASE = 'http://127.0.0.1:8320';
34
+
35
+ export class DaemonSupervisor {
36
+ /**
37
+ * @param {object} [opts]
38
+ * @param {string} [opts.httpBase] the daemon's local HTTP origin.
39
+ * @param {string} [opts.globalDir] where the pid + log files live. A
40
+ * machine-global dir (`~/.wild-workspace`) — the daemon is one per machine,
41
+ * not one per workspace, so this is deliberately NOT the per-workspace
42
+ * `.wild-workspace/` data dir.
43
+ * @param {Function} [opts.resolveBinary] daemon-binary resolver (test seam).
44
+ * @param {Function} [opts.spawnImpl] child_process.spawn (test seam).
45
+ * @param {Function} [opts.fetchImpl] global fetch (test seam).
46
+ * @param {Function} [opts.killImpl] process.kill (test seam).
47
+ * @param {NodeJS.ProcessEnv} [opts.env]
48
+ */
49
+ constructor({
50
+ httpBase = DEFAULT_HTTP_BASE,
51
+ globalDir = path.join(os.homedir(), '.wild-workspace'),
52
+ resolveBinary = resolveDaemonBinary,
53
+ spawnImpl = spawn,
54
+ fetchImpl = globalThis.fetch,
55
+ killImpl = (pid, sig) => process.kill(pid, sig),
56
+ env = process.env,
57
+ // b-ii: when the install is logged in (account.json present), these are
58
+ // injected into the daemon's spawn env so it opens the proxy link
59
+ // (`BMO_DAEMON_ACCOUNT_TOKEN`) against the right relay
60
+ // (`BMO_DAEMON_SERVER_URL`). Absent → daemon syncs only, no proxy link.
61
+ accountToken = null,
62
+ serverUrl = null,
63
+ } = {}) {
64
+ this.httpBase = httpBase.replace(/\/+$/, '');
65
+ this.globalDir = globalDir;
66
+ this.pidFile = path.join(globalDir, 'daemon.pid');
67
+ this.logFile = path.join(globalDir, 'daemon.log');
68
+ // Serializes concurrent ensureRunning() spawns (server + supervisor) so only
69
+ // one daemon is started — prevents the duplicate-daemon :8320 bind conflict.
70
+ this.spawnLockFile = path.join(globalDir, 'daemon-spawn.lock');
71
+ this.resolveBinary = resolveBinary;
72
+ this.spawnImpl = spawnImpl;
73
+ this.fetchImpl = fetchImpl;
74
+ this.killImpl = killImpl;
75
+ this.env = env;
76
+ this.accountToken = accountToken;
77
+ this.serverUrl = serverUrl;
78
+ }
79
+
80
+ /** Probe the daemon's /health endpoint. Never throws. */
81
+ async health() {
82
+ try {
83
+ const res = await this.fetchImpl(`${this.httpBase}/health`, {
84
+ signal: AbortSignal.timeout(2000),
85
+ });
86
+ return { running: !!res.ok };
87
+ } catch {
88
+ return { running: false };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Start the daemon unless it is already up. Idempotent and best-effort —
94
+ * the result is reported, never thrown.
95
+ *
96
+ * Concurrency-safe: the daemon is ensured by BOTH the server (its DaemonBridge)
97
+ * and the always-on supervisor (daemonTick). At boot neither sees the daemon up
98
+ * yet, so without a guard they'd BOTH spawn → two daemons fighting over :8320
99
+ * (`Address already in use`) and both opening proxy links → the relay evicts one
100
+ * → reconnect churn. An atomic cross-process spawn lock serializes the decision:
101
+ * the winner re-checks health then spawns; the loser waits for the winner's
102
+ * daemon instead of spawning its own.
103
+ * @returns {Promise<{started:boolean, alreadyRunning?:boolean, pid?:number,
104
+ * error?:string}>}
105
+ */
106
+ async ensureRunning() {
107
+ if ((await this.health()).running) {
108
+ return { started: false, alreadyRunning: true };
109
+ }
110
+ if (!this.acquireSpawnLock()) {
111
+ // Another process (server bridge / always-on supervisor) is spawning —
112
+ // wait for ITS daemon rather than racing in a second one.
113
+ const up = await this.waitForHealthy(4000);
114
+ return up ? { started: false, alreadyRunning: true } : { started: false, error: 'spawn-in-progress' };
115
+ }
116
+ try {
117
+ // Re-check under the lock: the other caller may have just brought it up.
118
+ if ((await this.health()).running) {
119
+ return { started: false, alreadyRunning: true };
120
+ }
121
+ return this.spawnDaemon();
122
+ } finally {
123
+ this.releaseSpawnLock();
124
+ }
125
+ }
126
+
127
+ /** Is a pid alive? EPERM ("exists, not ours") still counts as alive. */
128
+ pidAlive(pid) {
129
+ try { this.killImpl(pid, 0); return true; } catch (e) { return !!(e && e.code === 'EPERM'); }
130
+ }
131
+
132
+ /**
133
+ * Atomic, cross-process spawn lock (shared globalDir, so it serializes the
134
+ * server and the supervisor). Returns true iff we hold it. A stale lock whose
135
+ * recorded pid is dead is taken over — a crash mid-spawn can't wedge it.
136
+ */
137
+ acquireSpawnLock() {
138
+ try { mkdirSync(this.globalDir, { recursive: true }); } catch { /* surfaced below */ }
139
+ try {
140
+ writeFileSync(this.spawnLockFile, String(process.pid), { flag: 'wx' }); // atomic exclusive create
141
+ return true;
142
+ } catch {
143
+ let holder = null;
144
+ try { holder = Number(readFileSync(this.spawnLockFile, 'utf8').trim()); } catch { /* unreadable */ }
145
+ if (holder && this.pidAlive(holder)) return false; // a live caller is spawning
146
+ try { writeFileSync(this.spawnLockFile, String(process.pid)); return true; } catch { return false; }
147
+ }
148
+ }
149
+
150
+ releaseSpawnLock() {
151
+ try {
152
+ if (Number(readFileSync(this.spawnLockFile, 'utf8').trim()) === process.pid) unlinkSync(this.spawnLockFile);
153
+ } catch { /* already gone */ }
154
+ }
155
+
156
+ /** Spawn the daemon detached + window-hidden, logging to `daemon.log`. */
157
+ spawnDaemon() {
158
+ const bin = this.resolveBinary({ env: this.env });
159
+ // `null` = an explicit override path that doesn't exist; `path` = nothing
160
+ // concrete found, only the bare name as a PATH last-resort. In neither case
161
+ // is there a real binary to launch — refuse rather than ENOENT later.
162
+ if (!bin || bin.source === 'path') {
163
+ return { started: false, error: 'daemon-binary-not-found' };
164
+ }
165
+
166
+ try {
167
+ mkdirSync(this.globalDir, { recursive: true });
168
+ } catch {
169
+ /* fall through — openSync below will surface a real problem */
170
+ }
171
+
172
+ // A real fd (not 'ignore') so the daemon's stdout/stderr land in a log the
173
+ // user can read — and so its eprintln writes hit a valid handle.
174
+ let logFd = 'ignore';
175
+ try {
176
+ logFd = openSync(this.logFile, 'a');
177
+ } catch {
178
+ /* can't open the log — run anyway with output discarded */
179
+ }
180
+
181
+ let child;
182
+ try {
183
+ child = this.spawnImpl(bin.path, [], {
184
+ detached: true, // outlive the wild-workspace server
185
+ windowsHide: true, // no console window — the whole point
186
+ stdio: ['ignore', logFd, logFd],
187
+ // b-ii proxy link: inject the account token + relay URL when the
188
+ // install is logged in. Object-spreading a falsy value is a no-op,
189
+ // so an unauthenticated install spawns with a clean inherited env.
190
+ env: {
191
+ ...this.env,
192
+ ...(this.accountToken && { BMO_DAEMON_ACCOUNT_TOKEN: this.accountToken }),
193
+ ...(this.serverUrl && { BMO_DAEMON_SERVER_URL: this.serverUrl }),
194
+ },
195
+ });
196
+ } catch (err) {
197
+ if (typeof logFd === 'number') {
198
+ try { closeSync(logFd); } catch {}
199
+ }
200
+ return { started: false, error: String(err?.message || err) };
201
+ }
202
+
203
+ // The parent must drop its own copy of the log fd + its handle on the
204
+ // child, or the server process can't exit cleanly.
205
+ if (typeof logFd === 'number') {
206
+ try { closeSync(logFd); } catch {}
207
+ }
208
+ child.unref();
209
+
210
+ try {
211
+ writeFileSync(this.pidFile, String(child.pid));
212
+ } catch {
213
+ /* pid file is best-effort — `stop` falls back to a health probe */
214
+ }
215
+ return { started: true, pid: child.pid, binary: bin.path, source: bin.source };
216
+ }
217
+
218
+ /** The pid recorded by the last spawn, or null. */
219
+ readPid() {
220
+ try {
221
+ const pid = Number(readFileSync(this.pidFile, 'utf8').trim());
222
+ return Number.isInteger(pid) && pid > 0 ? pid : null;
223
+ } catch {
224
+ return null;
225
+ }
226
+ }
227
+
228
+ /** Stop the daemon (best-effort) by signalling the recorded pid. */
229
+ async stop() {
230
+ const pid = this.readPid();
231
+ if (!pid) {
232
+ const running = (await this.health()).running;
233
+ return { stopped: false, reason: running ? 'no-pid-file' : 'not-running' };
234
+ }
235
+ try {
236
+ this.killImpl(pid, 'SIGTERM');
237
+ } catch (err) {
238
+ if (err?.code === 'ESRCH') {
239
+ // already gone — tidy the stale pid file
240
+ try { unlinkSync(this.pidFile); } catch {}
241
+ return { stopped: false, reason: 'not-running' };
242
+ }
243
+ return { stopped: false, reason: String(err?.message || err) };
244
+ }
245
+ try { unlinkSync(this.pidFile); } catch {}
246
+ return { stopped: true, pid };
247
+ }
248
+
249
+ /**
250
+ * Recycle the daemon so it loads a freshly-resolved binary (after an
251
+ * auto-update). Stop the running process, wait for it to release its API port,
252
+ * then spawn again. Returns the `spawnDaemon` result.
253
+ */
254
+ async recycle() {
255
+ await this.stop();
256
+ // Wait for the old process to exit + free :PORT, else the new one can't bind.
257
+ const deadline = Date.now() + 5000;
258
+ while (Date.now() < deadline && (await this.health()).running) {
259
+ await new Promise((r) => setTimeout(r, 200));
260
+ }
261
+ return this.spawnDaemon();
262
+ }
263
+
264
+ /** Combined status for `wild-workspace daemon status`. */
265
+ async status() {
266
+ const { running } = await this.health();
267
+ return {
268
+ running,
269
+ pid: this.readPid(),
270
+ httpBase: this.httpBase,
271
+ pidFile: this.pidFile,
272
+ logFile: this.logFile,
273
+ };
274
+ }
275
+
276
+ /** Poll /health until the daemon answers or the deadline passes. */
277
+ async waitForHealthy(timeoutMs = 4000) {
278
+ const deadline = Date.now() + timeoutMs;
279
+ while (Date.now() < deadline) {
280
+ if ((await this.health()).running) return true;
281
+ await new Promise((r) => setTimeout(r, 250));
282
+ }
283
+ return false;
284
+ }
285
+ }