skydive-cli 0.5.0-beta.9 → 0.6.0-beta.10

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 (39) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +61 -13
  3. package/dist/js/api-BFQ4PQDA.mjs +315 -0
  4. package/dist/js/{billing-blocked-Dgu5-oDy.mjs → billing-blocked-SE6tySLd.mjs} +1 -1
  5. package/dist/js/bin.mjs +674 -307
  6. package/dist/js/{boot-Q-Kh3nn5.mjs → boot-BEvwMeO6.mjs} +4708 -1005
  7. package/dist/js/chunk-BbwQpWto.mjs +33 -0
  8. package/dist/js/{client-DabRpc_T.mjs → client-B-5eaVyt.mjs} +437 -39
  9. package/dist/js/{client-c4c5MmgN.mjs → client-Btq6bMzX.mjs} +108 -2
  10. package/dist/js/client-DebOAhwd.mjs +5 -0
  11. package/dist/js/{daemon-D21wQ7DI.mjs → daemon-CLc3zjcv.mjs} +238 -50
  12. package/dist/js/daemon-ClXSvjcv.mjs +7 -0
  13. package/dist/js/{daemon-client-DPUNjhBB.mjs → daemon-client-DSIb7j28.mjs} +1 -1
  14. package/dist/js/daemon-client-pYWyRTyi.mjs +8 -0
  15. package/dist/js/dist-CRtjM7ba.mjs +1750 -0
  16. package/dist/js/forward-C-f04uyE.mjs +208 -0
  17. package/dist/js/{profiler-BkCV__ao.mjs → install-Bg_9I2-2.mjs} +545 -215
  18. package/dist/js/launcher.mjs +49 -0
  19. package/dist/js/localhost-cert-Bn-UBUmj.mjs +67 -0
  20. package/dist/js/{print-BpuyEfWX.mjs → print-CPBx8690.mjs} +257 -35
  21. package/dist/js/{print-Wakr3GJd.mjs → print-CT1G1LeA.mjs} +3 -3
  22. package/dist/js/{print-share-CKLPmsg0.mjs → print-share-DW5pHVDz.mjs} +9 -3
  23. package/dist/js/raw-pty-Ci2qFR9F.mjs +5 -0
  24. package/dist/js/{raw-pty-DY4KelZW.mjs → raw-pty-D5PhKZSl.mjs} +1 -1
  25. package/dist/js/{rest-I3imNduB.mjs → rest-B2bynGwY.mjs} +153 -19
  26. package/dist/js/rest-DejyWmRu.mjs +6 -0
  27. package/dist/js/tls-cert-BpCaD5AT.mjs +4 -0
  28. package/dist/js/tls-cert-Rua2oV7n.mjs +67 -0
  29. package/package.json +15 -6
  30. package/dist/js/api-DG5W6iwx.mjs +0 -131
  31. package/dist/js/client-BuU34IVE.mjs +0 -5
  32. package/dist/js/daemon-LSDSvMaC.mjs +0 -6
  33. package/dist/js/daemon-client-CUSq-Wuh.mjs +0 -7
  34. package/dist/js/forward-18QoL5dO.mjs +0 -68
  35. package/dist/js/raw-pty-DmdUf4_w.mjs +0 -5
  36. package/dist/js/rest-D29qNkto.mjs +0 -6
  37. /package/dist/js/{billing-blocked-2wju4gC_.mjs → billing-blocked-D3l5kJlX.mjs} +0 -0
  38. /package/dist/js/{http-error-DzyrsLAZ.mjs → http-error-BF2NZZE3.mjs} +0 -0
  39. /package/dist/js/{output-DYzzdXYV.mjs → output-C9mb3sUB.mjs} +0 -0
@@ -1,6 +1,108 @@
1
1
  #!/usr/bin/env node
2
2
  import { WebSocket } from "ws";
3
3
 
4
+ //#region ../sandbox-stream-protocol/src/fs.ts
5
+ /** fs frame type bytes. Disjoint from FRAME.* in ./index.ts. */
6
+ const FS_FRAME = {
7
+ REQ: 32,
8
+ RES: 33
9
+ };
10
+ /** Filesystem operations the channel supports. One byte on the wire. */
11
+ const FS_OP = {
12
+ LIST: 1,
13
+ STAT: 2,
14
+ READ: 3,
15
+ WRITE: 4,
16
+ MKDIR: 5,
17
+ RENAME: 6,
18
+ REMOVE: 7,
19
+ EXISTS: 8
20
+ };
21
+ /** Reply status. OK carries a result; ERR carries a message in `json.message`. */
22
+ const FS_STATUS = {
23
+ OK: 0,
24
+ ERR: 1
25
+ };
26
+ const FS_MAX_BLOB_BYTES = 8 * 1024 * 1024;
27
+ const OP_TO_CODE = {
28
+ list: FS_OP.LIST,
29
+ stat: FS_OP.STAT,
30
+ read: FS_OP.READ,
31
+ write: FS_OP.WRITE,
32
+ mkdir: FS_OP.MKDIR,
33
+ rename: FS_OP.RENAME,
34
+ remove: FS_OP.REMOVE,
35
+ exists: FS_OP.EXISTS
36
+ };
37
+ const CODE_TO_OP = new Map(Object.entries(OP_TO_CODE).map(([op, code]) => [code, op]));
38
+ const textEncoder = new TextEncoder();
39
+ const textDecoder = new TextDecoder();
40
+ function frame(type, reqId, byte2, json, blob) {
41
+ const jsonBytes = textEncoder.encode(JSON.stringify(json ?? {}));
42
+ const out = new Uint8Array(10 + jsonBytes.length + blob.length);
43
+ const dv = new DataView(out.buffer);
44
+ out[0] = type;
45
+ dv.setUint32(1, reqId >>> 0);
46
+ out[5] = byte2;
47
+ dv.setUint32(6, jsonBytes.length);
48
+ out.set(jsonBytes, 10);
49
+ out.set(blob, 10 + jsonBytes.length);
50
+ return out;
51
+ }
52
+ const EMPTY = new Uint8Array(0);
53
+ /** client → server: encode an fs request. `blob` is the write payload, or null. */
54
+ function encodeFsRequest(reqId, req, blob) {
55
+ return frame(FS_FRAME.REQ, reqId, OP_TO_CODE[req.op], req, blob ?? EMPTY);
56
+ }
57
+ /**
58
+ * Decode a server fs reply frame. Returns null for a malformed frame so a peer
59
+ * on a newer protocol can't crash the client.
60
+ */
61
+ function decodeFsResponse(frameBytes) {
62
+ const parsed = parseFrame(FS_FRAME.RES, frameBytes);
63
+ if (!parsed) return null;
64
+ if (parsed.byte2 === FS_STATUS.ERR) {
65
+ const message = typeof parsed.json.message === "string" ? parsed.json.message : "fs operation failed";
66
+ return {
67
+ reqId: parsed.reqId,
68
+ status: "error",
69
+ message
70
+ };
71
+ }
72
+ return {
73
+ reqId: parsed.reqId,
74
+ status: "ok",
75
+ result: parsed.json,
76
+ blob: parsed.blob
77
+ };
78
+ }
79
+ function parseFrame(expectedType, frameBytes) {
80
+ if (frameBytes.length < 10) return null;
81
+ if (frameBytes[0] !== expectedType) return null;
82
+ const dv = new DataView(frameBytes.buffer, frameBytes.byteOffset, frameBytes.byteLength);
83
+ const reqId = dv.getUint32(1);
84
+ const byte2 = frameBytes[5] ?? 0;
85
+ const jsonLen = dv.getUint32(6);
86
+ const jsonStart = 10;
87
+ const jsonEnd = jsonStart + jsonLen;
88
+ if (jsonEnd > frameBytes.length) return null;
89
+ let json;
90
+ try {
91
+ const parsed = jsonLen ? JSON.parse(textDecoder.decode(frameBytes.subarray(jsonStart, jsonEnd))) : {};
92
+ if (typeof parsed !== "object" || parsed === null) return null;
93
+ json = parsed;
94
+ } catch {
95
+ return null;
96
+ }
97
+ return {
98
+ reqId,
99
+ byte2,
100
+ json,
101
+ blob: frameBytes.subarray(jsonEnd)
102
+ };
103
+ }
104
+
105
+ //#endregion
4
106
  //#region ../sandbox-stream-protocol/src/index.ts
5
107
  const SANDBOX_STREAM_PATH = "/api/v1/sandbox/stream";
6
108
  const FRAME = {
@@ -19,11 +121,15 @@ function streamSpecToQuery(spec) {
19
121
  cols: String(spec.cols),
20
122
  rows: String(spec.rows)
21
123
  };
22
- return {
124
+ if (spec.mode === "exec") return {
23
125
  agentId: spec.agentId,
24
126
  mode: "exec",
25
127
  command: spec.command
26
128
  };
129
+ return {
130
+ agentId: spec.agentId,
131
+ mode: "fs"
132
+ };
27
133
  }
28
134
  function withType(type, payload) {
29
135
  const frame = new Uint8Array(1 + payload.length);
@@ -166,4 +272,4 @@ function toBuffer(data) {
166
272
  }
167
273
 
168
274
  //#endregion
169
- export { SandboxStream as t };
275
+ export { encodeFsRequest as a, decodeFsResponse as i, SANDBOX_STREAM_PATH as n, streamSpecToQuery as r, SandboxStream as t };
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { t as PortalClient } from "./client-B-5eaVyt.mjs";
3
+ import "./api-BFQ4PQDA.mjs";
4
+
5
+ export { PortalClient };
@@ -1,50 +1,14 @@
1
1
  #!/usr/bin/env node
2
- import { n as isRecord, t as PortalClient } from "./client-DabRpc_T.mjs";
2
+ import { i as isRecord, n as isNewerBuild, r as portalDaemonBuild, t as PortalClient } from "./client-B-5eaVyt.mjs";
3
+ import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
3
4
  import os from "node:os";
4
5
  import path from "node:path";
5
6
  import { z } from "zod";
6
7
  import { spawn } from "node:child_process";
7
8
  import { createHash } from "node:crypto";
8
9
  import { connect, createServer } from "node:net";
9
- import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
10
+ import { access, appendFile, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
10
11
 
11
- //#region ../portal-daemon/src/build-stamp.ts
12
- /**
13
- * This build's stamp: the unix commit time (seconds) of the source it was
14
- * compiled from. Empty string when unstamped (a dev source run without the
15
- * env override).
16
- *
17
- * Why a commit time and not a package version: the daemon ships inside two
18
- * independently-versioned installers (the skydive CLI and the desktop app),
19
- * so their semvers are not comparable — but both build from this repo, so
20
- * the commit time of the built tree is one monotonic clock they share.
21
- */
22
- function portalDaemonBuild() {
23
- return "1786597531";
24
- }
25
- /**
26
- * Whether a client carrying `mine` should replace a running daemon carrying
27
- * `theirs` (newest build wins):
28
- *
29
- * - an unstamped client never takes over — it can't prove it's newer;
30
- * - a stamped client replaces an unstamped daemon — every stamped build
31
- * postdates stamping, so the unstamped daemon is older by construction;
32
- * - otherwise strictly greater wins; equal keeps the incumbent, so two
33
- * identical builds never bounce the daemon between them.
34
- */
35
- function isNewerBuild(mine, theirs) {
36
- const mineAt = parseStamp(mine);
37
- if (mineAt === null) return false;
38
- const theirsAt = parseStamp(theirs);
39
- if (theirsAt === null) return true;
40
- return mineAt > theirsAt;
41
- }
42
- function parseStamp(stamp) {
43
- if (!/^[0-9]+$/.test(stamp)) return null;
44
- return Number(stamp);
45
- }
46
-
47
- //#endregion
48
12
  //#region ../portal-daemon/src/local-protocol.ts
49
13
  /**
50
14
  * Local IPC between the portal DAEMON and the `skydive` CLI processes attached
@@ -219,6 +183,20 @@ const clientStatusSchema = z.object({ t: z.literal("status") });
219
183
  * exits — cutting live exec/tunnel connections, not just refusing new ones.
220
184
  */
221
185
  const clientShutdownSchema = z.object({ t: z.literal("shutdown") });
186
+ /**
187
+ * `shutdown_handoff` — a newer daemon taking over (self-update / newest-build-
188
+ * wins) asks the incumbent to step down WITHOUT dropping the portal connection
189
+ * first. The incumbent frees the control socket and disconnects attached CLIs
190
+ * (they reconnect to the new daemon in ~500ms, unchanged), but KEEPS its
191
+ * outbound portal WebSocket — and thus its presence claim — alive until the
192
+ * new daemon has dialled out and claimed the machine's presence slot, at which
193
+ * point the server supersedes the incumbent's socket and it exits. This is a
194
+ * make-before-break handoff: the presence key is owned by one daemon or the
195
+ * other for the entire takeover, so `exec`/`file write` never sees a
196
+ * `registered but not connected` gap mid-upgrade. A bounded fallback timeout
197
+ * ensures the incumbent still exits if the successor never comes up.
198
+ */
199
+ const clientShutdownHandoffSchema = z.object({ t: z.literal("shutdown_handoff") });
222
200
  const clientMessageSchema = z.discriminatedUnion("t", [
223
201
  clientHelloSchema,
224
202
  clientBindSchema,
@@ -229,7 +207,8 @@ const clientMessageSchema = z.discriminatedUnion("t", [
229
207
  clientDeclineSchema,
230
208
  clientByeSchema,
231
209
  clientStatusSchema,
232
- clientShutdownSchema
210
+ clientShutdownSchema,
211
+ clientShutdownHandoffSchema
233
212
  ]);
234
213
  /**
235
214
  * `state` — the shared portal status, pushed to every attached client so each
@@ -356,6 +335,22 @@ function parseDaemonMessage(line) {
356
335
  */
357
336
  /** Grace period after the last client detaches before the daemon exits. */
358
337
  const IDLE_SHUTDOWN_MS = 3e4;
338
+ /**
339
+ * Cadence of the socket-ownership watch (see checkSocketOwnership). The
340
+ * singleton is enforced ONLY through the socket path, so this bounds how long
341
+ * a daemon can keep dialing after losing the path — the window in which a
342
+ * spawner can create a duplicate dialer. The server-side connect flap guard
343
+ * caps the damage inside that window.
344
+ */
345
+ const SOCKET_WATCH_TICK_MS = 3e4;
346
+ /**
347
+ * Upper bound an incumbent daemon waits, during a graceful handoff, for the
348
+ * successor to dial out and claim presence before giving up and releasing the
349
+ * slot itself. Comfortably above a normal successor boot + WS dial (a few
350
+ * hundred ms to a couple of seconds), and short enough that a failed successor
351
+ * doesn't strand the machine as reachable-but-dead for long.
352
+ */
353
+ const HANDOFF_MAX_WAIT_MS = 1e4;
359
354
  var PortalDaemon = class {
360
355
  appUrl;
361
356
  paths;
@@ -365,21 +360,30 @@ var PortalDaemon = class {
365
360
  conns = /* @__PURE__ */ new Set();
366
361
  cwds = /* @__PURE__ */ new Map();
367
362
  declined = /* @__PURE__ */ new Set();
363
+ persistedMachineName = null;
368
364
  fallbackCwd;
369
365
  idleTimer = null;
366
+ handingOff = false;
370
367
  sessionToken = null;
371
368
  deviceToken = null;
372
- constructor(appUrl) {
369
+ socketWatch = null;
370
+ socketId = null;
371
+ socketCheckRunning = false;
372
+ socketWatchTickMs;
373
+ exit;
374
+ constructor(appUrl, options) {
373
375
  this.appUrl = canonicalPortalAppUrl(appUrl);
374
376
  this.paths = daemonPaths(this.appUrl);
375
377
  this.fallbackCwd = process.env.HOME ?? process.cwd();
378
+ this.socketWatchTickMs = options?.socketWatchTickMs ?? SOCKET_WATCH_TICK_MS;
379
+ this.exit = options?.exit ?? (() => process.exit(0));
376
380
  }
377
381
  /** Start listening. Rejects if the socket is already held by another daemon. */
378
382
  async listen() {
379
383
  await mkdir(this.paths.dir, { recursive: true });
380
384
  await this.loadState();
381
385
  await this.clearStaleSocket();
382
- return new Promise((resolve, reject) => {
386
+ await new Promise((resolve, reject) => {
383
387
  const server = createServer((socket) => this.onClientConnect(socket));
384
388
  server.on("error", reject);
385
389
  server.listen(this.paths.socketPath, () => {
@@ -388,6 +392,102 @@ var PortalDaemon = class {
388
392
  resolve();
389
393
  });
390
394
  });
395
+ await this.recordSocketIdentity();
396
+ this.startSocketWatch();
397
+ }
398
+ /**
399
+ * Remember which socket FILE we bound (dev+ino), so the watch can tell "our
400
+ * file is still there" from "someone re-created the path" — the path alone
401
+ * can't: a duplicate daemon's fresh socket lives at the identical path.
402
+ */
403
+ async recordSocketIdentity() {
404
+ const s = await stat(this.paths.socketPath);
405
+ this.socketId = {
406
+ dev: s.dev,
407
+ ino: s.ino
408
+ };
409
+ }
410
+ /**
411
+ * The singleton is enforced ONLY through the socket path — status, stop, and
412
+ * newest-build takeover all reach a daemon by connecting to it. If the file
413
+ * vanishes under us (the OS tmp cleaner purges /var/folders temp items that
414
+ * haven't been touched in days; the daemon holds the bound fd and never
415
+ * notices), this daemon keeps its portal connection and its attached clients
416
+ * but becomes unreachable and unmanageable: every new `skydive` spawn finds
417
+ * no socket and starts a DUPLICATE daemon, and the two fight a supersede war
418
+ * over the machine's presence slot (the 2026-08-25 flap loop). So the daemon
419
+ * re-verifies its claim on a timer: re-bind if the path is free, stand down
420
+ * if another daemon beat us to it.
421
+ */
422
+ startSocketWatch() {
423
+ this.socketWatch = setInterval(() => {
424
+ if (this.socketCheckRunning) return;
425
+ this.socketCheckRunning = true;
426
+ this.checkSocketOwnership().catch((error) => this.logError("socket ownership check failed", error)).finally(() => {
427
+ this.socketCheckRunning = false;
428
+ });
429
+ }, this.socketWatchTickMs);
430
+ this.socketWatch.unref();
431
+ }
432
+ stopSocketWatch() {
433
+ if (this.socketWatch) {
434
+ clearInterval(this.socketWatch);
435
+ this.socketWatch = null;
436
+ }
437
+ }
438
+ async checkSocketOwnership() {
439
+ if (!this.server || this.handingOff) return;
440
+ const current = await stat(this.paths.socketPath).catch(() => null);
441
+ if (current && this.socketId && current.dev === this.socketId.dev && current.ino === this.socketId.ino) return;
442
+ if (current) {
443
+ this.logInfo("control socket path is owned by another daemon; standing down");
444
+ this.standDown();
445
+ return;
446
+ }
447
+ try {
448
+ await this.rebindSocket();
449
+ this.logInfo("control socket file vanished (tmp cleaner?); re-bound");
450
+ } catch (error) {
451
+ this.logError("control socket re-bind failed; standing down", error);
452
+ this.standDown();
453
+ }
454
+ }
455
+ /**
456
+ * Re-create the socket file at our path. The old server object keeps serving
457
+ * its ESTABLISHED connections (close() only stops accepting), so attached
458
+ * clients ride through the swap; only the listener moves to the new file.
459
+ */
460
+ async rebindSocket() {
461
+ const old = this.server;
462
+ this.server = null;
463
+ old?.close();
464
+ await mkdir(this.paths.dir, { recursive: true });
465
+ await new Promise((resolve, reject) => {
466
+ const server = createServer((socket) => this.onClientConnect(socket));
467
+ server.on("error", reject);
468
+ server.listen(this.paths.socketPath, () => {
469
+ this.server = server;
470
+ resolve();
471
+ });
472
+ });
473
+ await this.recordSocketIdentity();
474
+ }
475
+ /**
476
+ * This daemon lost the singleton claim: tear everything down and exit.
477
+ * Attached clients see their control socket close and re-attach through the
478
+ * path — reaching whichever daemon owns it now (or spawning a fresh one) —
479
+ * so the machine converges on a single dialer instead of a supersede war.
480
+ */
481
+ standDown() {
482
+ this.stopSocketWatch();
483
+ for (const conn of this.conns) try {
484
+ conn.socket.destroy();
485
+ } catch (_error) {}
486
+ this.conns.clear();
487
+ this.client?.dispose();
488
+ this.server?.close();
489
+ this.server = null;
490
+ this.exit();
391
491
  }
392
492
  async clearStaleSocket() {
393
493
  if (!await pathExists(this.paths.socketPath)) return;
@@ -476,6 +576,9 @@ var PortalDaemon = class {
476
576
  case "shutdown":
477
577
  this.forceShutdown();
478
578
  return;
579
+ case "shutdown_handoff":
580
+ this.gracefulHandoffShutdown();
581
+ return;
479
582
  default: return msg;
480
583
  }
481
584
  }
@@ -499,12 +602,29 @@ var PortalDaemon = class {
499
602
  deviceToken: this.deviceToken
500
603
  }),
501
604
  resolveCwd: (conversationId) => this.resolveCwd(conversationId),
605
+ tlsCertSource: defaultTlsCertSource(process.env, (msg) => this.logInfo(msg)),
606
+ persistedMachineName: this.persistedMachineName,
607
+ onMachineName: (name, source) => this.onMachineName(name, source),
502
608
  onState: (state) => {
503
609
  this.lastState = state;
504
610
  this.broadcastState();
505
611
  }
506
612
  });
507
613
  }
614
+ /**
615
+ * The identity resolved on connect. Persist the name so a later scutil
616
+ * failure reuses it instead of adopting the volatile hostname, and log the
617
+ * source — a `hostname-fallback` after we'd previously registered under
618
+ * scutil is the tell that a flap just orphaned this machine's grants.
619
+ */
620
+ onMachineName(name, source) {
621
+ if (source === "hostname-fallback" && this.persistedMachineName) this.logInfo(`portal identity WARNING: fell back to hostname "${name}" but had previously registered as "${this.persistedMachineName}" — grants may be orphaned`);
622
+ else this.logInfo(`portal identity: "${name}" (source=${source})`);
623
+ if (name && name !== this.persistedMachineName) {
624
+ this.persistedMachineName = name;
625
+ this.persistState();
626
+ }
627
+ }
508
628
  /** The cwd an exec for `conversationId` runs in. */
509
629
  resolveCwd(conversationId) {
510
630
  if (conversationId) {
@@ -538,21 +658,30 @@ var PortalDaemon = class {
538
658
  /** Append a line to the daemon log file (best-effort, for post-hoc debugging). */
539
659
  logError(context, error) {
540
660
  const message = error instanceof Error ? error.message : String(error);
541
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${context}: ${message}\n`;
661
+ this.logLine(`${context}: ${message}`);
662
+ }
663
+ /** Append an informational line to the daemon log file (best-effort). */
664
+ logInfo(message) {
665
+ this.logLine(message);
666
+ }
667
+ logLine(message) {
668
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${message}\n`;
542
669
  appendFile(this.paths.logPath, line).catch((_error) => {});
543
670
  }
544
671
  /** Close the listener without exiting the process (tests own the process). */
545
672
  stopForTest() {
546
673
  this.clearIdleTimer();
674
+ this.stopSocketWatch();
547
675
  this.client?.dispose();
548
676
  this.server?.close();
549
677
  this.server = null;
550
678
  }
551
679
  shutdown() {
552
680
  if (this.conns.size > 0) return;
681
+ this.stopSocketWatch();
553
682
  this.client?.dispose();
554
683
  this.server?.close();
555
- process.exit(0);
684
+ this.exit();
556
685
  }
557
686
  /** Snapshot for a `status` control request. */
558
687
  statusResult() {
@@ -581,13 +710,39 @@ var PortalDaemon = class {
581
710
  * their live exec/tunnel relays), drops the portal connection, and exits.
582
711
  */
583
712
  forceShutdown() {
713
+ this.standDown();
714
+ }
715
+ /**
716
+ * Graceful takeover step-down (a newer daemon is replacing us). Unlike
717
+ * `forceShutdown`, this does NOT drop the portal connection up front: doing so
718
+ * would let this machine's presence key expire (10s TTL) before the successor
719
+ * dials out and re-claims it, which is the `registered but not connected` gap
720
+ * that made `exec`/`file write` fail mid-self-update.
721
+ *
722
+ * Order matters. We free the control socket and disconnect attached CLIs
723
+ * first, because the successor cannot bind the singleton socket (and thus
724
+ * cannot start its own portal client) until we release it. But we KEEP our
725
+ * outbound portal WebSocket — and its presence claim — alive across that
726
+ * window. When the successor connects and `claim()`s the slot, the server
727
+ * supersedes us and closes our socket; `beginHandoff` resolves on that close
728
+ * and we exit. A bounded fallback inside `beginHandoff` still exits us if the
729
+ * successor never comes up, so we never hold a dead slot forever.
730
+ */
731
+ async gracefulHandoffShutdown() {
732
+ if (this.handingOff) return;
733
+ this.handingOff = true;
734
+ this.stopSocketWatch();
584
735
  for (const conn of this.conns) try {
585
736
  conn.socket.destroy();
586
737
  } catch (_error) {}
587
738
  this.conns.clear();
588
- this.client?.dispose();
589
739
  this.server?.close();
590
- process.exit(0);
740
+ this.server = null;
741
+ if (this.client) {
742
+ await this.client.beginHandoff(HANDOFF_MAX_WAIT_MS);
743
+ this.client.dispose();
744
+ }
745
+ this.exit();
591
746
  }
592
747
  async loadState() {
593
748
  try {
@@ -598,6 +753,7 @@ var PortalDaemon = class {
598
753
  if (Array.isArray(parsed.declined)) {
599
754
  for (const id of parsed.declined) if (typeof id === "string") this.declined.add(id);
600
755
  }
756
+ if (typeof parsed.machineName === "string" && parsed.machineName) this.persistedMachineName = parsed.machineName;
601
757
  }
602
758
  } catch (_error) {}
603
759
  }
@@ -605,7 +761,8 @@ var PortalDaemon = class {
605
761
  const state = {
606
762
  version: LOCAL_PROTOCOL_VERSION,
607
763
  cwds: Object.fromEntries(this.cwds),
608
- declined: [...this.declined]
764
+ declined: [...this.declined],
765
+ machineName: this.persistedMachineName ?? void 0
609
766
  };
610
767
  writeFile(this.paths.statePath, JSON.stringify(state)).catch((error) => {
611
768
  this.logError("persistState failed", error);
@@ -635,7 +792,7 @@ async function ensureDaemonRunning(appUrl) {
635
792
  if (await isDaemonListening(socketPath)) {
636
793
  const status = await queryDaemonStatus(appUrl);
637
794
  if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
638
- if (await stopDaemon(appUrl) === "failed") return;
795
+ if (await stopDaemonForHandoff(appUrl) === "failed") return;
639
796
  }
640
797
  const entry = process.argv[1];
641
798
  const args = entry ? [
@@ -669,7 +826,7 @@ function runPortalDaemon(argv) {
669
826
  * CLI's re-exec entry above and the standalone `main.ts` entry.
670
827
  */
671
828
  function startPortalDaemon(appUrl) {
672
- return new PortalDaemon(appUrl).listen();
829
+ return new PortalDaemon(appUrl, null).listen();
673
830
  }
674
831
  async function isDaemonListening(socketPath) {
675
832
  if (!await pathExists(socketPath)) return false;
@@ -730,6 +887,37 @@ async function queryDaemonStatus(appUrl) {
730
887
  });
731
888
  }
732
889
  /**
890
+ * Ask a running incumbent daemon to step down for a make-before-break handoff
891
+ * (see the daemon's `gracefulHandoffShutdown`). Sends `shutdown_handoff` and
892
+ * waits for the incumbent to release the CONTROL SOCKET — at which point the
893
+ * successor can bind it and start its own portal client. Crucially, the
894
+ * incumbent keeps its portal connection (and presence) alive past this point,
895
+ * so the successor's subsequent `claim()` supersedes it with no presence gap.
896
+ *
897
+ * Returns `stopped` once the socket is free, `failed` if it never freed (the
898
+ * caller should then leave the incumbent alone rather than fight it — same
899
+ * fallback semantics as `stopDaemon`), or `not-running` if nothing was there.
900
+ * Unlike `stopDaemon` this never SIGKILLs: the incumbent is intentionally still
901
+ * alive (holding presence) after it frees the socket, so killing it by pid is
902
+ * exactly the gap this path exists to avoid.
903
+ */
904
+ async function stopDaemonForHandoff(appUrl) {
905
+ const { socketPath } = daemonPaths(appUrl);
906
+ if (!await isDaemonListening(socketPath)) return "not-running";
907
+ const sock = await connectControl(socketPath);
908
+ if (!sock) return "failed";
909
+ sock.write(encodeLine({ t: "shutdown_handoff" }));
910
+ for (let i = 0; i < 50; i += 1) {
911
+ await sleep(100);
912
+ if (!await isDaemonListening(socketPath)) {
913
+ sock.destroy();
914
+ return "stopped";
915
+ }
916
+ }
917
+ sock.destroy();
918
+ return "failed";
919
+ }
920
+ /**
733
921
  * Hard-stop a running daemon. Preferred path: send `shutdown` over the control
734
922
  * socket so it drains clients and exits cleanly. If the socket is unresponsive
735
923
  * (a wedged daemon), fall back to SIGTERM then SIGKILL by the pid the status
@@ -781,4 +969,4 @@ function sleep(ms) {
781
969
  }
782
970
 
783
971
  //#endregion
784
- export { runPortalDaemon as a, LOCAL_PROTOCOL_VERSION as c, encodeLine as d, makeLineParser as f, queryDaemonStatus as i, PORTAL_DAEMON_FLAG as l, ensureDaemonRunning as n, startPortalDaemon as o, parseDaemonMessage as p, isDaemonListening as r, stopDaemon as s, PortalDaemon as t, daemonPaths as u };
972
+ export { runPortalDaemon as a, stopDaemonForHandoff as c, daemonPaths as d, encodeLine as f, queryDaemonStatus as i, LOCAL_PROTOCOL_VERSION as l, parseDaemonMessage as m, ensureDaemonRunning as n, startPortalDaemon as o, makeLineParser as p, isDaemonListening as r, stopDaemon as s, PortalDaemon as t, PORTAL_DAEMON_FLAG as u };
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+ import "./client-B-5eaVyt.mjs";
3
+ import "./tls-cert-Rua2oV7n.mjs";
4
+ import "./api-BFQ4PQDA.mjs";
5
+ import { a as runPortalDaemon, c as stopDaemonForHandoff, i as queryDaemonStatus, n as ensureDaemonRunning, o as startPortalDaemon, r as isDaemonListening, s as stopDaemon, t as PortalDaemon } from "./daemon-CLc3zjcv.mjs";
6
+
7
+ export { runPortalDaemon };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { c as LOCAL_PROTOCOL_VERSION, d as encodeLine, f as makeLineParser, n as ensureDaemonRunning, p as parseDaemonMessage, u as daemonPaths } from "./daemon-D21wQ7DI.mjs";
2
+ import { d as daemonPaths, f as encodeLine, l as LOCAL_PROTOCOL_VERSION, m as parseDaemonMessage, n as ensureDaemonRunning, p as makeLineParser } from "./daemon-CLc3zjcv.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { connect } from "node:net";
5
5
 
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import "./client-B-5eaVyt.mjs";
3
+ import "./tls-cert-Rua2oV7n.mjs";
4
+ import "./api-BFQ4PQDA.mjs";
5
+ import "./daemon-CLc3zjcv.mjs";
6
+ import { t as PortalDaemonClient } from "./daemon-client-DSIb7j28.mjs";
7
+
8
+ export { PortalDaemonClient };