skydive-cli 0.5.0 → 0.6.0-beta.100

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.
@@ -1,13 +1,13 @@
1
1
  #!/usr/bin/env node
2
- import { i as isRecord, n as isNewerBuild, r as portalDaemonBuild, t as PortalClient } from "./client--k9cjfkX.mjs";
2
+ import { a as portalDaemonBuild, i as isNewerBuild, n as PortalClient, o as isRecord } from "./client-HdxGBXPK.mjs";
3
3
  import { t as defaultTlsCertSource } from "./tls-cert-Rua2oV7n.mjs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
+ import { access, appendFile, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
6
7
  import { z } from "zod";
7
8
  import { spawn } from "node:child_process";
8
9
  import { createHash } from "node:crypto";
9
10
  import { connect, createServer } from "node:net";
10
- import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
11
11
 
12
12
  //#region ../portal-daemon/src/local-protocol.ts
13
13
  /**
@@ -66,6 +66,18 @@ function canonicalPortalAppUrl(appUrl) {
66
66
  if (host === "skydive.com" || host === "www.skydive.com" || host === "api.skydive.com") return "https://api.skydive.com";
67
67
  return trimmed;
68
68
  }
69
+ /** Prefix every Windows named pipe lives under (the pipe filesystem root). */
70
+ const WINDOWS_PIPE_PREFIX = "\\\\.\\pipe\\";
71
+ /**
72
+ * Whether a daemon control-socket path is a Windows named pipe rather than a
73
+ * Unix-domain-socket FILE. Pipes have no on-disk file: they cannot be
74
+ * `stat`ed, `unlink`ed, or purged by a tmp cleaner, and the OS frees the name
75
+ * the moment the owning server closes — so every file-based lifecycle step
76
+ * (stale-file cleanup, the socket-ownership watch) must be skipped for them.
77
+ */
78
+ function isNamedPipePath(socketPath) {
79
+ return socketPath.startsWith(WINDOWS_PIPE_PREFIX);
80
+ }
69
81
  /**
70
82
  * Per-user, per-app daemon socket + state paths. Scoped by OS user and a hash
71
83
  * of the CANONICAL appUrl so two logins or two deployments (prod vs a PR
@@ -73,17 +85,42 @@ function canonicalPortalAppUrl(appUrl) {
73
85
  * every alias of one deployment (see {@link canonicalPortalAppUrl}) shares
74
86
  * one. Kept under the OS temp dir because Unix socket paths are
75
87
  * length-limited (~104 bytes on macOS).
88
+ *
89
+ * On Windows a `node:net` server cannot listen on a filesystem path at all —
90
+ * binding the tmp-dir path fails with EACCES and the daemon dies silently
91
+ * (ANY-7285's Windows facet) — so the socket is a named pipe under
92
+ * `\\.\pipe\`, keyed by the same (user, appUrl-hash) pair. State and log
93
+ * files stay under the temp dir on every platform.
94
+ *
95
+ * Darwin `sockaddr_un.sun_path` is 104 bytes including NUL (103 usable).
96
+ * `os.tmpdir()` on macOS is `$TMPDIR` (`/var/folders/<id>/T`, often ~50
97
+ * chars). Adding `skydive-portal-<user>-<hash>/daemon.sock` overruns that
98
+ * cap: bind() fails, the sidecar handshake still reports the long path, and
99
+ * connect() returns ENOENT (or Go's "path must be shorter than SUN_LEN").
100
+ * Stay in `os.tmpdir()` (do not relocate to `/tmp`) and shorten the leaf
101
+ * when the composed path would blow the cap. Windows named pipes have no
102
+ * sun_path, so they keep the long leaf.
76
103
  */
77
- function daemonPaths(appUrl) {
78
- const user = safeSegment(os.userInfo().username || "user");
79
- const app = createHash("sha256").update(canonicalPortalAppUrl(appUrl)).digest("hex").slice(0, 12);
80
- const dir = path.join(os.tmpdir(), `skydive-portal-${user}-${app}`);
81
- return {
104
+ const SUN_PATH_MAX = 103;
105
+ function daemonPaths(appUrl, platform = process.platform) {
106
+ const parent = os.tmpdir();
107
+ const key = `skydive-portal-${safeSegment(os.userInfo().username || "user")}-${createHash("sha256").update(canonicalPortalAppUrl(appUrl)).digest("hex").slice(0, 12)}`;
108
+ const dir = path.join(parent, key);
109
+ const paths = {
82
110
  dir,
83
- socketPath: path.join(dir, "daemon.sock"),
111
+ socketPath: platform === "win32" ? `${WINDOWS_PIPE_PREFIX}${key}` : path.join(dir, "daemon.sock"),
84
112
  statePath: path.join(dir, "state.json"),
85
113
  logPath: path.join(dir, "daemon.log")
86
114
  };
115
+ if (platform === "win32" || Buffer.byteLength(paths.socketPath) <= SUN_PATH_MAX) return paths;
116
+ const short = createHash("sha256").update(`${os.userInfo().username || "user"}\0${canonicalPortalAppUrl(appUrl)}`).digest("hex").slice(0, 12);
117
+ const shortDir = path.join(parent, `skydive-p-${short}`);
118
+ return {
119
+ dir: shortDir,
120
+ socketPath: path.join(shortDir, "d.sock"),
121
+ statePath: path.join(shortDir, "state.json"),
122
+ logPath: path.join(shortDir, "daemon.log")
123
+ };
87
124
  }
88
125
  function safeSegment(value) {
89
126
  return value.replace(/[^a-zA-Z0-9_-]/g, "").slice(0, 24) || "user";
@@ -336,6 +373,24 @@ function parseDaemonMessage(line) {
336
373
  /** Grace period after the last client detaches before the daemon exits. */
337
374
  const IDLE_SHUTDOWN_MS = 3e4;
338
375
  /**
376
+ * Cadence of the socket-ownership watch (see checkSocketOwnership). The
377
+ * singleton is enforced ONLY through the socket path, so this bounds how long
378
+ * a daemon can keep dialing after losing the path — the window in which a
379
+ * spawner can create a duplicate dialer. The server-side connect flap guard
380
+ * caps the damage inside that window.
381
+ */
382
+ const SOCKET_WATCH_TICK_MS = 3e4;
383
+ /**
384
+ * Grace window after a server stand-down: the check fires the instant the
385
+ * close lands, but the WINNER's socket bind may still be in flight — a check
386
+ * run at t=0 can see our own still-good socket, conclude we are the owner, and
387
+ * redial into the supersede war the stand-down exists to end. On a
388
+ * stand-down-triggered check, wait this out once and re-stat before
389
+ * concluding ownership. The periodic watch tick runs grace-free (nothing just
390
+ * displaced us there).
391
+ */
392
+ const STAND_DOWN_GRACE_MS = 1500;
393
+ /**
339
394
  * Upper bound an incumbent daemon waits, during a graceful handoff, for the
340
395
  * successor to dial out and claim presence before giving up and releasing the
341
396
  * slot itself. Comfortably above a normal successor boot + WS dial (a few
@@ -358,17 +413,26 @@ var PortalDaemon = class {
358
413
  handingOff = false;
359
414
  sessionToken = null;
360
415
  deviceToken = null;
361
- constructor(appUrl) {
416
+ socketWatch = null;
417
+ socketId = null;
418
+ socketCheckRunning = false;
419
+ socketWatchTickMs;
420
+ standDownGraceMs;
421
+ exit;
422
+ constructor(appUrl, options) {
362
423
  this.appUrl = canonicalPortalAppUrl(appUrl);
363
424
  this.paths = daemonPaths(this.appUrl);
364
425
  this.fallbackCwd = process.env.HOME ?? process.cwd();
426
+ this.socketWatchTickMs = options?.socketWatchTickMs ?? SOCKET_WATCH_TICK_MS;
427
+ this.standDownGraceMs = options?.standDownGraceMs ?? STAND_DOWN_GRACE_MS;
428
+ this.exit = options?.exit ?? (() => process.exit(0));
365
429
  }
366
430
  /** Start listening. Rejects if the socket is already held by another daemon. */
367
431
  async listen() {
368
432
  await mkdir(this.paths.dir, { recursive: true });
369
433
  await this.loadState();
370
434
  await this.clearStaleSocket();
371
- return new Promise((resolve, reject) => {
435
+ await new Promise((resolve, reject) => {
372
436
  const server = createServer((socket) => this.onClientConnect(socket));
373
437
  server.on("error", reject);
374
438
  server.listen(this.paths.socketPath, () => {
@@ -377,8 +441,128 @@ var PortalDaemon = class {
377
441
  resolve();
378
442
  });
379
443
  });
444
+ if (!isNamedPipePath(this.paths.socketPath)) {
445
+ await this.recordSocketIdentity();
446
+ this.startSocketWatch();
447
+ }
448
+ }
449
+ /**
450
+ * Remember which socket FILE we bound (dev+ino), so the watch can tell "our
451
+ * file is still there" from "someone re-created the path" — the path alone
452
+ * can't: a duplicate daemon's fresh socket lives at the identical path.
453
+ */
454
+ async recordSocketIdentity() {
455
+ const s = await stat(this.paths.socketPath);
456
+ this.socketId = {
457
+ dev: s.dev,
458
+ ino: s.ino
459
+ };
460
+ }
461
+ /**
462
+ * The singleton is enforced ONLY through the socket path — status, stop, and
463
+ * newest-build takeover all reach a daemon by connecting to it. If the file
464
+ * vanishes under us (the OS tmp cleaner purges /var/folders temp items that
465
+ * haven't been touched in days; the daemon holds the bound fd and never
466
+ * notices), this daemon keeps its portal connection and its attached clients
467
+ * but becomes unreachable and unmanageable: every new `skydive` spawn finds
468
+ * no socket and starts a DUPLICATE daemon, and the two fight a supersede war
469
+ * over the machine's presence slot (the 2026-08-25 flap loop). So the daemon
470
+ * re-verifies its claim on a timer: re-bind if the path is free, stand down
471
+ * if another daemon beat us to it.
472
+ */
473
+ startSocketWatch() {
474
+ this.socketWatch = setInterval(() => {
475
+ this.runOwnershipCheck();
476
+ }, this.socketWatchTickMs);
477
+ this.socketWatch.unref();
478
+ }
479
+ /** One socket-ownership pass, shared by the watch timer (grace-free) and the
480
+ * client's 'stand-down' state (with grace — see STAND_DOWN_GRACE_MS).
481
+ */
482
+ runOwnershipCheck(graceMs = 0) {
483
+ if (this.socketCheckRunning) return;
484
+ this.socketCheckRunning = true;
485
+ this.checkSocketOwnership(graceMs).catch((error) => this.logError("socket ownership check failed", error)).finally(() => {
486
+ this.socketCheckRunning = false;
487
+ });
488
+ }
489
+ stopSocketWatch() {
490
+ if (this.socketWatch) {
491
+ clearInterval(this.socketWatch);
492
+ this.socketWatch = null;
493
+ }
494
+ }
495
+ async checkSocketOwnership(graceMs = 0) {
496
+ if (!this.server || this.handingOff) return;
497
+ const current = await stat(this.paths.socketPath).catch(() => null);
498
+ if (current && this.socketId && current.dev === this.socketId.dev && current.ino === this.socketId.ino) {
499
+ if (graceMs > 0) {
500
+ await new Promise((r) => setTimeout(r, graceMs));
501
+ const recheck = await stat(this.paths.socketPath).catch(() => null);
502
+ const socketId = this.socketId;
503
+ if (recheck && socketId && !(recheck.dev === socketId.dev && recheck.ino === socketId.ino)) {
504
+ this.logInfo("control socket taken over during stand-down grace; standing down");
505
+ this.standDown();
506
+ return;
507
+ }
508
+ }
509
+ return;
510
+ }
511
+ if (current) {
512
+ this.logInfo("control socket path is owned by another daemon; standing down");
513
+ this.standDown();
514
+ return;
515
+ }
516
+ try {
517
+ await this.rebindSocket();
518
+ this.logInfo("control socket file vanished (tmp cleaner?); re-bound");
519
+ } catch (error) {
520
+ this.logError("control socket re-bind failed; standing down", error);
521
+ this.standDown();
522
+ }
523
+ }
524
+ /**
525
+ * Re-create the socket file at our path. The old server object keeps serving
526
+ * its ESTABLISHED connections (close() only stops accepting), so attached
527
+ * clients ride through the swap; only the listener moves to the new file.
528
+ */
529
+ async rebindSocket() {
530
+ const old = this.server;
531
+ this.server = null;
532
+ old?.close();
533
+ await mkdir(this.paths.dir, { recursive: true });
534
+ await new Promise((resolve, reject) => {
535
+ const server = createServer((socket) => this.onClientConnect(socket));
536
+ server.on("error", reject);
537
+ server.listen(this.paths.socketPath, () => {
538
+ this.server = server;
539
+ resolve();
540
+ });
541
+ });
542
+ await this.recordSocketIdentity();
543
+ }
544
+ /**
545
+ * This daemon lost the singleton claim: tear everything down and exit.
546
+ * Attached clients see their control socket close and re-attach through the
547
+ * path — reaching whichever daemon owns it now (or spawning a fresh one) —
548
+ * so the machine converges on a single dialer instead of a supersede war.
549
+ */
550
+ standDown() {
551
+ this.stopSocketWatch();
552
+ for (const conn of this.conns) try {
553
+ conn.socket.destroy();
554
+ } catch (_error) {}
555
+ this.conns.clear();
556
+ this.client?.dispose();
557
+ this.server?.close();
558
+ this.server = null;
559
+ this.exit();
380
560
  }
381
561
  async clearStaleSocket() {
562
+ if (isNamedPipePath(this.paths.socketPath)) {
563
+ if (await isDaemonListening(this.paths.socketPath)) throw new Error("daemon already running");
564
+ return;
565
+ }
382
566
  if (!await pathExists(this.paths.socketPath)) return;
383
567
  if (await new Promise((resolve) => {
384
568
  const probe = connect(this.paths.socketPath);
@@ -494,13 +678,23 @@ var PortalDaemon = class {
494
678
  tlsCertSource: defaultTlsCertSource(process.env, (msg) => this.logInfo(msg)),
495
679
  persistedMachineName: this.persistedMachineName,
496
680
  onMachineName: (name, source) => this.onMachineName(name, source),
497
- onState: (state) => {
498
- this.lastState = state;
499
- this.broadcastState();
500
- }
681
+ onState: (state) => this.handleClientState(state)
501
682
  });
502
683
  }
503
684
  /**
685
+ * Client state transition. A server stand-down ('stand-down' status — the
686
+ * superseded/flap-cooldown close, distinguishable from an ordinary close
687
+ * through the status) runs the ownership check NOW with grace, so a
688
+ * duplicate that already lost the control socket yields within one server
689
+ * round trip instead of waiting for the next watch tick and fighting
690
+ * (supersede war).
691
+ */
692
+ handleClientState(state) {
693
+ if (state.status === "stand-down") this.runOwnershipCheck(this.standDownGraceMs);
694
+ this.lastState = state;
695
+ this.broadcastState();
696
+ }
697
+ /**
504
698
  * The identity resolved on connect. Persist the name so a later scutil
505
699
  * failure reuses it instead of adopting the volatile hostname, and log the
506
700
  * source — a `hostname-fallback` after we'd previously registered under
@@ -560,15 +754,17 @@ var PortalDaemon = class {
560
754
  /** Close the listener without exiting the process (tests own the process). */
561
755
  stopForTest() {
562
756
  this.clearIdleTimer();
757
+ this.stopSocketWatch();
563
758
  this.client?.dispose();
564
759
  this.server?.close();
565
760
  this.server = null;
566
761
  }
567
762
  shutdown() {
568
763
  if (this.conns.size > 0) return;
764
+ this.stopSocketWatch();
569
765
  this.client?.dispose();
570
766
  this.server?.close();
571
- process.exit(0);
767
+ this.exit();
572
768
  }
573
769
  /** Snapshot for a `status` control request. */
574
770
  statusResult() {
@@ -580,7 +776,7 @@ var PortalDaemon = class {
580
776
  appUrl: this.appUrl,
581
777
  build: portalDaemonBuild(),
582
778
  portal: {
583
- status: portal?.status ?? "off",
779
+ status: portal?.status === "stand-down" ? "connecting" : portal?.status ?? "off",
584
780
  machineName: portal?.machineName ?? "",
585
781
  friendlyName: portal?.friendlyName ?? "",
586
782
  error: portal?.error ?? null,
@@ -597,13 +793,7 @@ var PortalDaemon = class {
597
793
  * their live exec/tunnel relays), drops the portal connection, and exits.
598
794
  */
599
795
  forceShutdown() {
600
- for (const conn of this.conns) try {
601
- conn.socket.destroy();
602
- } catch (_error) {}
603
- this.conns.clear();
604
- this.client?.dispose();
605
- this.server?.close();
606
- process.exit(0);
796
+ this.standDown();
607
797
  }
608
798
  /**
609
799
  * Graceful takeover step-down (a newer daemon is replacing us). Unlike
@@ -624,6 +814,7 @@ var PortalDaemon = class {
624
814
  async gracefulHandoffShutdown() {
625
815
  if (this.handingOff) return;
626
816
  this.handingOff = true;
817
+ this.stopSocketWatch();
627
818
  for (const conn of this.conns) try {
628
819
  conn.socket.destroy();
629
820
  } catch (_error) {}
@@ -634,7 +825,7 @@ var PortalDaemon = class {
634
825
  await this.client.beginHandoff(HANDOFF_MAX_WAIT_MS);
635
826
  this.client.dispose();
636
827
  }
637
- process.exit(0);
828
+ this.exit();
638
829
  }
639
830
  async loadState() {
640
831
  try {
@@ -678,13 +869,19 @@ var PortalDaemon = class {
678
869
  * drains attached clients, who reconnect within ~500ms) and spawn from this
679
870
  * binary. Both spawners apply the same rule, so a host converges on the
680
871
  * newest installed build with at most one bounce.
872
+ *
873
+ * Returns whether a daemon is actually listening when it's done. A spawned
874
+ * daemon that never binds (its process died on startup — e.g. the Windows
875
+ * EACCES bind failure this used to swallow) resolves `false` so callers can
876
+ * SAY so, instead of silently handing the caller a socket nobody serves and
877
+ * leaving the reconnect loop to spin forever with no visible error.
681
878
  */
682
879
  async function ensureDaemonRunning(appUrl) {
683
880
  const { socketPath } = daemonPaths(appUrl);
684
881
  if (await isDaemonListening(socketPath)) {
685
882
  const status = await queryDaemonStatus(appUrl);
686
- if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
687
- if (await stopDaemonForHandoff(appUrl) === "failed") return;
883
+ if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return true;
884
+ if (await stopDaemonForHandoff(appUrl) === "failed") return true;
688
885
  }
689
886
  const entry = process.argv[1];
690
887
  const args = entry ? [
@@ -698,9 +895,10 @@ async function ensureDaemonRunning(appUrl) {
698
895
  env: process.env
699
896
  }).unref();
700
897
  for (let i = 0; i < 50; i += 1) {
701
- if (await isDaemonListening(socketPath)) return;
898
+ if (await isDaemonListening(socketPath)) return true;
702
899
  await sleep(100);
703
900
  }
901
+ return false;
704
902
  }
705
903
  /**
706
904
  * Entry the CLI calls when launched with PORTAL_DAEMON_FLAG: become the daemon.
@@ -718,10 +916,10 @@ function runPortalDaemon(argv) {
718
916
  * CLI's re-exec entry above and the standalone `main.ts` entry.
719
917
  */
720
918
  function startPortalDaemon(appUrl) {
721
- return new PortalDaemon(appUrl).listen();
919
+ return new PortalDaemon(appUrl, null).listen();
722
920
  }
723
921
  async function isDaemonListening(socketPath) {
724
- if (!await pathExists(socketPath)) return false;
922
+ if (!isNamedPipePath(socketPath) && !await pathExists(socketPath)) return false;
725
923
  return new Promise((resolve) => {
726
924
  const probe = connect(socketPath);
727
925
  probe.on("connect", () => {
@@ -848,7 +1046,7 @@ async function stopDaemon(appUrl) {
848
1046
  function stateMessage(state, declined) {
849
1047
  return {
850
1048
  t: "state",
851
- status: state.status,
1049
+ status: state.status === "stand-down" ? "connecting" : state.status,
852
1050
  machineName: state.machineName,
853
1051
  friendlyName: state.friendlyName,
854
1052
  error: state.error,
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+ import "./client-HdxGBXPK.mjs";
3
+ import "./tls-cert-Rua2oV7n.mjs";
4
+ import "./api-BFQ4PQDA.mjs";
5
+ import "./daemon-B8c3Z1Te.mjs";
6
+ import { t as PortalDaemonClient } from "./daemon-client-xaS4xE-L.mjs";
7
+
8
+ export { PortalDaemonClient };
@@ -1,14 +1,21 @@
1
1
  #!/usr/bin/env node
2
- import { d as daemonPaths, f as encodeLine, l as LOCAL_PROTOCOL_VERSION, m as parseDaemonMessage, n as ensureDaemonRunning, p as makeLineParser } from "./daemon-Do1jU2UF.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-B8c3Z1Te.mjs";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { connect } from "node:net";
5
5
 
6
6
  //#region ../portal-daemon/src/daemon-client.ts
7
+ /** Reconnect delay when an established daemon connection drops. */
8
+ const RECONNECT_DELAY_MS = 500;
9
+ /** Base delay between daemon SPAWN retries (doubles per consecutive failure). */
10
+ const START_RETRY_BASE_MS = 2e3;
11
+ /** Consecutive failed spawns before start() stops respawning. */
12
+ const MAX_START_ATTEMPTS = 3;
7
13
  var PortalDaemonClient = class {
8
14
  sessionId = randomUUID();
9
15
  socket = null;
10
16
  disposed = false;
11
17
  reconnectTimer = null;
18
+ startFailures = 0;
12
19
  lastBind = null;
13
20
  fallbackCwd = null;
14
21
  wantEnabled = false;
@@ -21,7 +28,21 @@ var PortalDaemonClient = class {
21
28
  /** Spawn the daemon if needed and attach. Idempotent. */
22
29
  async start() {
23
30
  if (this.disposed) return;
24
- await ensureDaemonRunning(this.opts.appUrl);
31
+ if (!await ensureDaemonRunning(this.opts.appUrl)) {
32
+ this.startFailures += 1;
33
+ const exhausted = this.startFailures >= MAX_START_ATTEMPTS;
34
+ this.opts.onState({
35
+ status: "error",
36
+ machineName: "",
37
+ friendlyName: "",
38
+ error: exhausted ? "portal daemon failed to start repeatedly; toggle sharing to retry" : "portal daemon failed to start (control socket never came up)",
39
+ grantedAgentIds: [],
40
+ declinedAgentIds: []
41
+ });
42
+ if (!exhausted) this.scheduleReconnect(START_RETRY_BASE_MS * 2 ** (this.startFailures - 1));
43
+ return;
44
+ }
45
+ this.startFailures = 0;
25
46
  this.connect();
26
47
  }
27
48
  connect() {
@@ -74,12 +95,12 @@ var PortalDaemonClient = class {
74
95
  socket.on("close", onClose);
75
96
  socket.on("error", onClose);
76
97
  }
77
- scheduleReconnect() {
98
+ scheduleReconnect(delayMs = RECONNECT_DELAY_MS) {
78
99
  if (this.disposed || this.reconnectTimer) return;
79
100
  this.reconnectTimer = setTimeout(() => {
80
101
  this.reconnectTimer = null;
81
102
  this.start();
82
- }, 500);
103
+ }, delayMs);
83
104
  this.reconnectTimer.unref();
84
105
  }
85
106
  onMessage(msg) {
@@ -109,6 +130,10 @@ var PortalDaemonClient = class {
109
130
  /** Turn machine sharing on (the daemon connects the portal). */
110
131
  enable() {
111
132
  this.wantEnabled = true;
133
+ if (this.startFailures >= MAX_START_ATTEMPTS) {
134
+ this.startFailures = 0;
135
+ this.start();
136
+ }
112
137
  this.send({ t: "enable" });
113
138
  }
114
139
  /** Turn machine sharing off. */
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { c as version$1 } from "./rest-B9__Zsuk.mjs";
3
- import { w as getConfigPath } from "./print-D_UEjdSw.mjs";
2
+ import { c as version$1 } from "./rest-YroXUA7G.mjs";
3
+ import { D as getConfigPath } from "./print-B_jFXZ1W.mjs";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { err, ok } from "neverthrow";
@@ -1547,6 +1547,51 @@ function formatMb(bytes) {
1547
1547
  return `${(bytes / MB).toFixed(1)} MB`;
1548
1548
  }
1549
1549
 
1550
+ //#endregion
1551
+ //#region src/chat/tui/autowrap.ts
1552
+ /**
1553
+ * Why the TUI turns the terminal's autowrap off for its lifetime.
1554
+ *
1555
+ * OpenTUI's incremental cell-diff emitter positions every run with an
1556
+ * absolute cursor move — it never relies on the terminal to advance the
1557
+ * cursor — but it leaves the terminal's autowrap (DECAWM) enabled. When a
1558
+ * run reaches the last column, the host terminal wraps its cursor to column
1559
+ * 1 of the next row and paints the run's tail there, into cells owned by a
1560
+ * different element. The diff buffer then records the *intended* cells, so
1561
+ * those stray bytes are never repainted and unrelated text smears together
1562
+ * until a full repaint (a resize). Filed upstream with a repro and root
1563
+ * cause: https://github.com/anomalyco/opentui/issues/1187 — unfixed there,
1564
+ * the consumer-side fix (verified in numux#115) is to disable DECAWM while
1565
+ * the renderer runs. Pane content still wraps, inside OpenTUI's own grid.
1566
+ */
1567
+ const AUTOWRAP_OFF = "\x1B[?7l";
1568
+ const AUTOWRAP_ON = "\x1B[?7h";
1569
+ /** Escape hatch: if any surface turns out to depend on host autowrap. */
1570
+ const keepAutowrap = () => Boolean(process.env["SKYDIVE_TUI_AUTOWRAP"]);
1571
+ let disabledBy = false;
1572
+ /**
1573
+ * Disable host autowrap so the diff emitter's runs can never wrap onto the
1574
+ * next row. No-op when stdout is not a terminal (piped output, tests) and
1575
+ * under `SKYDIVE_TUI_AUTOWRAP`.
1576
+ */
1577
+ function disableTerminalAutowrap(stdout = process.stdout) {
1578
+ if (!stdout.isTTY || keepAutowrap() || disabledBy) return;
1579
+ disabledBy = true;
1580
+ stdout.write(AUTOWRAP_OFF);
1581
+ }
1582
+ /**
1583
+ * Restore host autowrap. The terminal mode is a property of the host, so
1584
+ * every teardown path must run this even when the renderer cannot finish
1585
+ * its own teardown: the renderer's `onDestroy` hook covers renderer-driven
1586
+ * exits, and the forced-fallback reset in `hard-exit.ts` carries the
1587
+ * restore for a teardown that never got there. Idempotent.
1588
+ */
1589
+ function restoreTerminalAutowrap(stdout = process.stdout) {
1590
+ if (!disabledBy) return;
1591
+ disabledBy = false;
1592
+ stdout.write(AUTOWRAP_ON);
1593
+ }
1594
+
1550
1595
  //#endregion
1551
1596
  //#region src/crash/install.ts
1552
1597
  let installed = false;
@@ -1566,6 +1611,7 @@ function installCrashHandler(opts = {}) {
1566
1611
  if (installed) return;
1567
1612
  installed = true;
1568
1613
  process.on("uncaughtException", (error) => {
1614
+ restoreTerminalAutowrap();
1569
1615
  const memory = safeMemory();
1570
1616
  printFatalNotice(writeCrashReport(buildCrashReport({
1571
1617
  reason: "uncaughtException",
@@ -1594,4 +1640,4 @@ function printFatalNotice(file, memory) {
1594
1640
  }
1595
1641
 
1596
1642
  //#endregion
1597
- export { installAgentAlias as A, resolveWorkspaceId as B, themeVersion as C, machineOsFromPlatform as D, buildImportSeedPrompt as E, detectShell as F, ensureActiveOrganization as I, getActiveWorkspaceId as L, takenAliasNames as M, SUPPORTED_SHELLS as N, aliasActivationHint as O, defaultInstallEnv as P, getSessionIdentity as R, themeModeFromColorFgBg as S, themesForMode as T, setActiveWorkspace as V, monoTheme as _, WORDMARK as a, themeForMode as b, getAutomaticLogsDir as c, profilingEnabled as d, record as f, findTheme as g, applyTheme as h, MARK_CELLS as i, slugifyAliasName as j, dedupeAliasName as k, maybeStartProfiling as l, DEFAULT_THEME_ID as m, buildCrashReport as n, brandHelpArt as o, writeArtifact as p, writeCrashReport as r, splashFitsWidth as s, installCrashHandler as t, profileDir as u, noColorRequested as v, themes as w, themeMode as x, theme as y, listWorkspaces as z };
1643
+ export { aliasActivationHint as A, getSessionIdentity as B, themeMode as C, themesForMode as D, themes as E, SUPPORTED_SHELLS as F, resolveWorkspaceId as H, defaultInstallEnv as I, detectShell as L, installAgentAlias as M, slugifyAliasName as N, buildImportSeedPrompt as O, takenAliasNames as P, ensureActiveOrganization as R, themeForMode as S, themeVersion as T, setActiveWorkspace as U, listWorkspaces as V, applyTheme as _, writeCrashReport as a, noColorRequested as b, brandHelpArt as c, maybeStartProfiling as d, profileDir as f, DEFAULT_THEME_ID as g, writeArtifact as h, buildCrashReport as i, dedupeAliasName as j, machineOsFromPlatform as k, splashFitsWidth as l, record as m, disableTerminalAutowrap as n, MARK_CELLS as o, profilingEnabled as p, restoreTerminalAutowrap as r, WORDMARK as s, installCrashHandler as t, getAutomaticLogsDir as u, findTheme as v, themeModeFromColorFgBg as w, theme as x, monoTheme as y, getActiveWorkspaceId as z };