skydive-cli 0.5.0-beta.2 → 0.5.0-beta.21

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,11 +1,15 @@
1
1
  #!/usr/bin/env node
2
- import { t as HttpError } from "./http-error-DzyrsLAZ.mjs";
2
+ import { a as mintPortalDeviceToken, c as unifyLegacyCliGrants, i as grantPortalAccess, n as fetchPortalDevices, r as findThisDevice } from "./api-DG5W6iwx.mjs";
3
3
  import os from "node:os";
4
4
  import { z } from "zod";
5
- import { execFile, spawn } from "node:child_process";
6
- import { WebSocket } from "ws";
5
+ import * as childProcess from "node:child_process";
6
+ import { spawn } from "node:child_process";
7
+ import net from "node:net";
8
+ import { WebSocket, createWebSocketStream } from "ws";
7
9
 
8
10
  //#region ../portal-daemon/src/machine.ts
11
+ const SCUTIL_TIMEOUT_MS = 2e3;
12
+ const SCUTIL_ATTEMPTS = 3;
9
13
  /**
10
14
  * Identity this machine registers under on the portal.
11
15
  *
@@ -14,38 +18,74 @@ import { WebSocket } from "ws";
14
18
  * row, so a grant applies to the machine no matter which surface the user
15
19
  * shared from. The name must therefore match what the desktop app registers —
16
20
  * on macOS that is `scutil --get LocalHostName` (the Rust client's own
17
- * derivation, stabler than the network-assigned hostname), with the plain
18
- * hostname as the fallback for other platforms or a failed read.
21
+ * derivation, stabler than the network-assigned hostname).
22
+ *
23
+ * The machine name is a durable IDENTITY, not a fresh network reading: every
24
+ * grant hangs off the device row keyed by this name, so if it changes the
25
+ * device flips to a brand-new row with ZERO grants and every agent re-prompts
26
+ * across every open CLI. `os.hostname()` is NOT durable — macOS rewrites it
27
+ * from DHCP/DNS per network — so it must never be allowed to silently replace
28
+ * a name we already registered under. Resolution order:
29
+ *
30
+ * 1. `scutil --get LocalHostName` (retried) — the stable, correct source.
31
+ * 2. `previous` — the name we last successfully registered under, persisted
32
+ * by the daemon. Preferred over the volatile hostname so a transient
33
+ * `scutil` failure keeps our existing identity (and grants) intact.
34
+ * 3. `os.hostname()` — last resort for a machine that has NEVER resolved a
35
+ * name (first run, non-macOS, scutil permanently unavailable).
19
36
  *
20
37
  * Historically the CLI suffixed `-cli` to keep a device distinct from the
21
38
  * desktop's; `unifyLegacyCliGrants` migrates grants off those rows.
22
39
  */
23
- async function resolveMachineIdentity() {
24
- const fallback = fallbackHost();
25
- if (process.platform !== "darwin") return {
26
- machineName: fallback,
27
- friendlyName: fallback
40
+ async function resolveMachineIdentity(previous) {
41
+ const persisted = previous?.trim() || null;
42
+ if (process.platform !== "darwin") {
43
+ const machineName = persisted ?? fallbackHost();
44
+ return {
45
+ machineName,
46
+ friendlyName: machineName,
47
+ machineNameSource: persisted ? "persisted" : "hostname-fallback"
48
+ };
49
+ }
50
+ const local = await scutilRead("LocalHostName");
51
+ if (local) return {
52
+ machineName: local,
53
+ friendlyName: await scutilRead("ComputerName") ?? local,
54
+ machineNameSource: "scutil"
55
+ };
56
+ if (persisted) return {
57
+ machineName: persisted,
58
+ friendlyName: persisted,
59
+ machineNameSource: "persisted"
28
60
  };
29
- const local = await scutilRead("LocalHostName") ?? fallback;
61
+ const fallback = fallbackHost();
30
62
  return {
31
- machineName: local,
32
- friendlyName: await scutilRead("ComputerName") ?? local
63
+ machineName: fallback,
64
+ friendlyName: fallback,
65
+ machineNameSource: "hostname-fallback"
33
66
  };
34
67
  }
35
68
  function fallbackHost() {
36
69
  return (os.hostname() || "machine").trim().replace(/\.local$/i, "") || "machine";
37
70
  }
38
71
  function scutilRead(key) {
39
- return new Promise((resolve) => {
40
- execFile("/usr/sbin/scutil", ["--get", key], { timeout: 2e3 }, (error, stdout) => {
41
- if (error) {
42
- resolve(null);
43
- return;
44
- }
45
- const value = stdout.trim();
46
- resolve(value.length > 0 ? value : null);
72
+ return attempt(0);
73
+ function attempt(n) {
74
+ return new Promise((resolve) => {
75
+ childProcess.execFile("/usr/sbin/scutil", ["--get", key], { timeout: SCUTIL_TIMEOUT_MS }, (error, stdout) => {
76
+ if (error) {
77
+ if (n + 1 < SCUTIL_ATTEMPTS) {
78
+ resolve(attempt(n + 1));
79
+ return;
80
+ }
81
+ resolve(null);
82
+ return;
83
+ }
84
+ const value = stdout.trim();
85
+ resolve(value.length > 0 ? value : null);
86
+ });
47
87
  });
48
- });
88
+ }
49
89
  }
50
90
  const INHERITED_ENV = [
51
91
  "HOME",
@@ -134,6 +174,18 @@ const ctrlMessageSchema = z.discriminatedUnion("t", [
134
174
  conversationId: z.string().nullable().optional()
135
175
  }),
136
176
  z.object({ t: z.literal("stdin_eof") }),
177
+ z.object({
178
+ t: z.literal("reverse_listen"),
179
+ listenPort: z.number().int().positive(),
180
+ targetPort: z.number().int().positive(),
181
+ dialOrigin: z.string().min(1),
182
+ token: z.string().min(1)
183
+ }),
184
+ z.object({ t: z.literal("reverse_listening") }),
185
+ z.object({
186
+ t: z.literal("reverse_token"),
187
+ token: z.string().min(1)
188
+ }),
137
189
  z.object({ t: z.literal("pause") }),
138
190
  z.object({ t: z.literal("resume") }),
139
191
  z.object({ t: z.literal("cancel") }),
@@ -169,6 +221,62 @@ function decodeFrame(frame) {
169
221
  };
170
222
  }
171
223
 
224
+ //#endregion
225
+ //#region ../portal-daemon/src/reverse-listener.ts
226
+ /**
227
+ * The desktop half of an agent-initiated expose tunnel (`platform portal
228
+ * expose`): listen on the local loopback and pipe each accepted TCP
229
+ * connection to the agent sandbox's daemon (`/portal/tcp`) through the
230
+ * agent-webserver edge Worker — the same per-connection dial-and-pipe as the
231
+ * user-initiated `skydive portal forward`, just started from a directive
232
+ * instead of a terminal. Tunnel bytes never touch the directive stream that
233
+ * created this listener; it only anchors the lifetime and carries the token.
234
+ *
235
+ * `target()` is read per connection so a token refresh (or any future
236
+ * retarget) applies to the next dial without touching established pipes.
237
+ */
238
+ var ReverseListener = class {
239
+ server = null;
240
+ conns = /* @__PURE__ */ new Set();
241
+ constructor(opts) {
242
+ this.opts = opts;
243
+ }
244
+ start() {
245
+ const server = net.createServer((sock) => {
246
+ this.conns.add(sock);
247
+ sock.once("close", () => this.conns.delete(sock));
248
+ sock.pause();
249
+ const { dialOrigin, targetPort, token } = this.opts.target();
250
+ const ws = new WebSocket(`${dialOrigin.replace(/^http/, "ws")}/portal/tcp?port=${targetPort}`, { headers: { authorization: `Bearer ${token}` } });
251
+ ws.on("open", () => {
252
+ const stream = createWebSocketStream(ws);
253
+ stream.on("error", () => sock.destroy());
254
+ sock.on("error", () => stream.destroy());
255
+ sock.pipe(stream).pipe(sock);
256
+ sock.resume();
257
+ });
258
+ ws.on("error", (err) => {
259
+ this.opts.log(`expose: tunnel connect failed: ${err.message}`);
260
+ sock.destroy();
261
+ });
262
+ ws.on("unexpected-response", (_req, res) => {
263
+ this.opts.log(`expose: tunnel rejected (HTTP ${res.statusCode ?? "?"})`);
264
+ res.destroy();
265
+ sock.destroy();
266
+ });
267
+ });
268
+ server.once("error", (err) => this.opts.onListening(err));
269
+ server.listen(this.opts.listenPort, "127.0.0.1", () => this.opts.onListening(null));
270
+ this.server = server;
271
+ }
272
+ stop() {
273
+ this.server?.close();
274
+ this.server = null;
275
+ for (const sock of this.conns) sock.destroy();
276
+ this.conns.clear();
277
+ }
278
+ };
279
+
172
280
  //#endregion
173
281
  //#region ../portal-daemon/src/exec.ts
174
282
  /**
@@ -181,6 +289,7 @@ function decodeFrame(frame) {
181
289
  */
182
290
  var JobManager = class {
183
291
  jobs = /* @__PURE__ */ new Map();
292
+ reverse = /* @__PURE__ */ new Map();
184
293
  constructor(opts) {
185
294
  this.opts = opts;
186
295
  }
@@ -200,12 +309,35 @@ var JobManager = class {
200
309
  job.child.kill("SIGKILL");
201
310
  }
202
311
  this.jobs.clear();
312
+ for (const rev of this.reverse.values()) {
313
+ rev.settled = true;
314
+ rev.listener.stop();
315
+ }
316
+ this.reverse.clear();
203
317
  }
204
318
  handleCtrl(id, msg) {
205
319
  switch (msg.t) {
206
320
  case "open":
207
321
  this.startJob(id, msg.argv, msg.env, msg.conversationId ?? null);
208
322
  return;
323
+ case "reverse_listen":
324
+ this.startReverse(id, {
325
+ listenPort: msg.listenPort,
326
+ target: {
327
+ dialOrigin: msg.dialOrigin,
328
+ targetPort: msg.targetPort,
329
+ token: msg.token
330
+ }
331
+ });
332
+ return;
333
+ case "reverse_token": {
334
+ const rev = this.reverse.get(id);
335
+ if (rev) rev.target = {
336
+ ...rev.target,
337
+ token: msg.token
338
+ };
339
+ return;
340
+ }
209
341
  case "stdin_eof":
210
342
  this.jobs.get(id)?.child.stdin.end();
211
343
  return;
@@ -217,12 +349,51 @@ var JobManager = class {
217
349
  return;
218
350
  case "cancel":
219
351
  this.jobs.get(id)?.child.kill("SIGKILL");
352
+ this.stopReverse(id);
220
353
  return;
221
354
  case "close":
222
- case "error": return;
355
+ case "error":
356
+ case "reverse_listening": return;
223
357
  default: return msg;
224
358
  }
225
359
  }
360
+ startReverse(id, { listenPort, target }) {
361
+ const rev = {
362
+ settled: false,
363
+ target,
364
+ seq: 0,
365
+ listener: new ReverseListener({
366
+ listenPort,
367
+ target: () => rev.target,
368
+ log: (msg) => {
369
+ if (rev.settled) return;
370
+ this.opts.send(encodeData(id, STREAM.stderr, rev.seq, Buffer.from(msg)));
371
+ rev.seq = rev.seq + 1 >>> 0;
372
+ },
373
+ onListening: (err) => {
374
+ if (err) {
375
+ rev.settled = true;
376
+ this.reverse.delete(id);
377
+ this.opts.send(encodeCtrl(id, {
378
+ t: "error",
379
+ message: `reverse listen failed: ${err.message}`
380
+ }));
381
+ return;
382
+ }
383
+ this.opts.send(encodeCtrl(id, { t: "reverse_listening" }));
384
+ }
385
+ })
386
+ };
387
+ this.reverse.set(id, rev);
388
+ rev.listener.start();
389
+ }
390
+ stopReverse(id) {
391
+ const rev = this.reverse.get(id);
392
+ if (!rev) return;
393
+ rev.settled = true;
394
+ this.reverse.delete(id);
395
+ rev.listener.stop();
396
+ }
226
397
  setPaused(id, paused) {
227
398
  const job = this.jobs.get(id);
228
399
  if (!job) return;
@@ -296,111 +467,6 @@ var JobManager = class {
296
467
  }
297
468
  };
298
469
 
299
- //#endregion
300
- //#region ../portal-daemon/src/api.ts
301
- /**
302
- * The portal's session-authed REST surface, shared by `PortalClient` (the
303
- * TUI/`portal open` connection) and the `skydive portal` management
304
- * commands, so the endpoint contracts and response schemas live in exactly
305
- * one place.
306
- */
307
- const deviceSchema = z.object({
308
- id: z.string(),
309
- machineName: z.string(),
310
- friendlyName: z.string(),
311
- connected: z.boolean(),
312
- lastSeen: z.string().nullable(),
313
- grantedAgentIds: z.array(z.string())
314
- });
315
- const devicesResponseSchema = z.object({
316
- devices: z.array(deviceSchema),
317
- agents: z.array(z.object({
318
- id: z.string(),
319
- name: z.string()
320
- }))
321
- });
322
- const deviceTokenSchema = z.object({ token: z.string().min(1) });
323
- async function portalFetch(auth, path, init) {
324
- const res = await fetch(`${auth.appUrl}${path}`, {
325
- method: init.method,
326
- headers: {
327
- authorization: `Bearer ${auth.sessionToken}`,
328
- accept: "application/json",
329
- ...init.body ? { "content-type": "application/json" } : {}
330
- },
331
- ...init.body ? { body: init.body } : {}
332
- });
333
- if (!res.ok) {
334
- const body = await res.text().catch(() => "");
335
- throw new HttpError(res.status, body);
336
- }
337
- return res.json();
338
- }
339
- async function fetchPortalDevices(auth) {
340
- const json = await portalFetch(auth, "/api/v1/portal/devices", { method: "GET" });
341
- return devicesResponseSchema.parse(json);
342
- }
343
- const registerResponseSchema = z.object({ device: z.object({ id: z.string() }) });
344
- /**
345
- * Register this machine's device row without connecting. Connecting registers
346
- * as a side effect; this covers granting an agent on a machine that has never
347
- * shared yet (the grant references the device row).
348
- */
349
- async function registerPortalDevice(auth, { machineName, friendlyName }) {
350
- const json = await portalFetch(auth, "/api/v1/portal/devices", {
351
- method: "POST",
352
- body: JSON.stringify({
353
- machineName,
354
- friendlyName
355
- })
356
- });
357
- return registerResponseSchema.parse(json).device;
358
- }
359
- /** Short-lived token the machine presents when dialing the portal WebSocket. */
360
- async function mintPortalDeviceToken(auth) {
361
- const json = await portalFetch(auth, "/api/v1/portal/device-token", { method: "POST" });
362
- return deviceTokenSchema.parse(json).token;
363
- }
364
- async function grantPortalAccess(auth, { deviceId, agentId }) {
365
- await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants`, {
366
- method: "POST",
367
- body: JSON.stringify({ agentId })
368
- });
369
- }
370
- async function revokePortalAccess(auth, { deviceId, agentId }) {
371
- await portalFetch(auth, `/api/v1/portal/devices/${encodeURIComponent(deviceId)}/grants/${encodeURIComponent(agentId)}`, { method: "DELETE" });
372
- }
373
- /**
374
- * The device row for a given machine identity. Matching is by `machineName`
375
- * equality — the stable handle the machine registers under, not the display
376
- * label.
377
- */
378
- function findThisDevice(devices, machineName) {
379
- return devices.find((device) => device.machineName === machineName) ?? null;
380
- }
381
- /**
382
- * One-time grant migration onto the merged device. Earlier CLI builds
383
- * registered a separate `<machineName>-cli` device, so a user's existing
384
- * approvals hang off that row; the merged device would start with zero grants
385
- * and every already-authorized agent would ask again. Copy any grant the
386
- * merged device is missing (the grant endpoint upserts, so re-runs are
387
- * no-ops). The legacy row is left in place — an old CLI build may still
388
- * connect under it. Returns how many grants were copied.
389
- */
390
- async function unifyLegacyCliGrants(auth, machineName) {
391
- const { devices } = await fetchPortalDevices(auth);
392
- const merged = findThisDevice(devices, machineName);
393
- const legacy = findThisDevice(devices, `${machineName}-cli`);
394
- if (!merged || !legacy) return 0;
395
- const have = new Set(merged.grantedAgentIds);
396
- const missing = legacy.grantedAgentIds.filter((id) => !have.has(id));
397
- for (const agentId of missing) await grantPortalAccess(auth, {
398
- deviceId: merged.id,
399
- agentId
400
- });
401
- return missing.length;
402
- }
403
-
404
470
  //#endregion
405
471
  //#region ../portal-daemon/src/client.ts
406
472
  const INITIAL_BACKOFF_MS = 500;
@@ -469,13 +535,19 @@ var PortalClient = class {
469
535
  this.ws?.close();
470
536
  this.ws = null;
471
537
  }
472
- /** Grant one agent access to this machine (default-deny; user-initiated). */
473
- async grantAgent(agentId) {
538
+ /**
539
+ * Grant one agent access to this machine (default-deny; user-initiated).
540
+ * `conversationId` is the conversation whose run asked (null when the grant
541
+ * is not answering an in-chat request) — the server uses it to wake the
542
+ * waiting agent.
543
+ */
544
+ async grantAgent(agentId, conversationId) {
474
545
  const auth = this.sessionAuth();
475
546
  if (!auth) throw new Error("granting needs a signed-in CLI session on this machine (the desktop connection alone cannot manage grants)");
476
547
  await grantPortalAccess(auth, {
477
548
  deviceId: await this.ensureDeviceId(),
478
- agentId
549
+ agentId,
550
+ conversationId
479
551
  });
480
552
  this.granted.add(agentId);
481
553
  this.emit();
@@ -517,9 +589,10 @@ var PortalClient = class {
517
589
  }
518
590
  async connectLoop() {
519
591
  if (!this.machineName) {
520
- const identity = await resolveMachineIdentity();
592
+ const identity = await resolveMachineIdentity(this.opts.persistedMachineName);
521
593
  this.machineName = identity.machineName;
522
594
  this.friendlyName = identity.friendlyName;
595
+ this.opts.onMachineName(identity.machineName, identity.machineNameSource);
523
596
  }
524
597
  let backoff = INITIAL_BACKOFF_MS;
525
598
  while (this.enabled && !this.disposed) {
@@ -617,4 +690,4 @@ function sleep(ms) {
617
690
  }
618
691
 
619
692
  //#endregion
620
- export { registerPortalDevice as a, resolveMachineIdentity as c, grantPortalAccess as i, fetchPortalDevices as n, revokePortalAccess as o, findThisDevice as r, isRecord as s, PortalClient as t };
693
+ export { isRecord as n, resolveMachineIdentity as r, PortalClient as t };
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env node
2
+ import { t as PortalClient } from "./client-DbqRBquD.mjs";
3
+ import "./api-DG5W6iwx.mjs";
4
+
5
+ export { PortalClient };
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import "./client-Dd5sMXPv.mjs";
3
- import { a as runPortalDaemon, i as queryDaemonStatus, n as ensureDaemonRunning, o as startPortalDaemon, r as isDaemonListening, s as stopDaemon, t as PortalDaemon } from "./daemon-Bq93vOIk.mjs";
2
+ import "./client-DbqRBquD.mjs";
3
+ import { a as runPortalDaemon, i as queryDaemonStatus, n as ensureDaemonRunning, o as startPortalDaemon, r as isDaemonListening, s as stopDaemon, t as PortalDaemon } from "./daemon-Dj9tGT12.mjs";
4
+ import "./api-DG5W6iwx.mjs";
4
5
 
5
6
  export { runPortalDaemon };
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { s as isRecord, t as PortalClient } from "./client-Dd5sMXPv.mjs";
2
+ import { n as isRecord, t as PortalClient } from "./client-DbqRBquD.mjs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { z } from "zod";
@@ -8,6 +8,43 @@ import { createHash } from "node:crypto";
8
8
  import { connect, createServer } from "node:net";
9
9
  import { access, appendFile, mkdir, readFile, unlink, writeFile } from "node:fs/promises";
10
10
 
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 "1786659341";
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
11
48
  //#region ../portal-daemon/src/local-protocol.ts
12
49
  /**
13
50
  * Local IPC between the portal DAEMON and the `skydive` CLI processes attached
@@ -138,10 +175,18 @@ const clientCwdSchema = z.object({
138
175
  t: z.literal("cwd"),
139
176
  cwd: z.string().min(1)
140
177
  });
141
- /** `grant` — authorize one agent to reach this machine (user-initiated). */
178
+ /**
179
+ * `grant` — authorize one agent to reach this machine (user-initiated).
180
+ * `conversationId` names the conversation whose run asked for access (the
181
+ * in-chat approval card); the grant carries it upstream so the server wakes
182
+ * the waiting agent. Defaults to null on parse so hellos from CLI builds that
183
+ * predate the field keep granting against a newer daemon — they just skip the
184
+ * wake, same as before the field existed.
185
+ */
142
186
  const clientGrantSchema = z.object({
143
187
  t: z.literal("grant"),
144
- agentId: z.string().min(1)
188
+ agentId: z.string().min(1),
189
+ conversationId: z.string().nullish().default(null)
145
190
  });
146
191
  /**
147
192
  * `decline` — the user answered no to an agent's request for this machine
@@ -220,6 +265,7 @@ const daemonStatusResultSchema = z.object({
220
265
  v: z.number(),
221
266
  pid: z.number(),
222
267
  appUrl: z.string(),
268
+ build: z.string().default(""),
223
269
  portal: z.object({
224
270
  status: z.enum([
225
271
  "off",
@@ -319,6 +365,7 @@ var PortalDaemon = class {
319
365
  conns = /* @__PURE__ */ new Set();
320
366
  cwds = /* @__PURE__ */ new Map();
321
367
  declined = /* @__PURE__ */ new Set();
368
+ persistedMachineName = null;
322
369
  fallbackCwd;
323
370
  idleTimer = null;
324
371
  sessionToken = null;
@@ -417,7 +464,7 @@ var PortalDaemon = class {
417
464
  case "grant":
418
465
  this.declined.delete(msg.agentId);
419
466
  this.ensureClient();
420
- this.client?.grantAgent(msg.agentId).catch((error) => {
467
+ this.client?.grantAgent(msg.agentId, msg.conversationId).catch((error) => {
421
468
  this.logError("grantAgent failed", error);
422
469
  });
423
470
  return;
@@ -453,12 +500,28 @@ var PortalDaemon = class {
453
500
  deviceToken: this.deviceToken
454
501
  }),
455
502
  resolveCwd: (conversationId) => this.resolveCwd(conversationId),
503
+ persistedMachineName: this.persistedMachineName,
504
+ onMachineName: (name, source) => this.onMachineName(name, source),
456
505
  onState: (state) => {
457
506
  this.lastState = state;
458
507
  this.broadcastState();
459
508
  }
460
509
  });
461
510
  }
511
+ /**
512
+ * The identity resolved on connect. Persist the name so a later scutil
513
+ * failure reuses it instead of adopting the volatile hostname, and log the
514
+ * source — a `hostname-fallback` after we'd previously registered under
515
+ * scutil is the tell that a flap just orphaned this machine's grants.
516
+ */
517
+ onMachineName(name, source) {
518
+ 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`);
519
+ else this.logInfo(`portal identity: "${name}" (source=${source})`);
520
+ if (name && name !== this.persistedMachineName) {
521
+ this.persistedMachineName = name;
522
+ this.persistState();
523
+ }
524
+ }
462
525
  /** The cwd an exec for `conversationId` runs in. */
463
526
  resolveCwd(conversationId) {
464
527
  if (conversationId) {
@@ -492,7 +555,14 @@ var PortalDaemon = class {
492
555
  /** Append a line to the daemon log file (best-effort, for post-hoc debugging). */
493
556
  logError(context, error) {
494
557
  const message = error instanceof Error ? error.message : String(error);
495
- const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${context}: ${message}\n`;
558
+ this.logLine(`${context}: ${message}`);
559
+ }
560
+ /** Append an informational line to the daemon log file (best-effort). */
561
+ logInfo(message) {
562
+ this.logLine(message);
563
+ }
564
+ logLine(message) {
565
+ const line = `${(/* @__PURE__ */ new Date()).toISOString()} ${message}\n`;
496
566
  appendFile(this.paths.logPath, line).catch((_error) => {});
497
567
  }
498
568
  /** Close the listener without exiting the process (tests own the process). */
@@ -516,6 +586,7 @@ var PortalDaemon = class {
516
586
  v: LOCAL_PROTOCOL_VERSION,
517
587
  pid: process.pid,
518
588
  appUrl: this.appUrl,
589
+ build: portalDaemonBuild(),
519
590
  portal: {
520
591
  status: portal?.status ?? "off",
521
592
  machineName: portal?.machineName ?? "",
@@ -551,6 +622,7 @@ var PortalDaemon = class {
551
622
  if (Array.isArray(parsed.declined)) {
552
623
  for (const id of parsed.declined) if (typeof id === "string") this.declined.add(id);
553
624
  }
625
+ if (typeof parsed.machineName === "string" && parsed.machineName) this.persistedMachineName = parsed.machineName;
554
626
  }
555
627
  } catch (_error) {}
556
628
  }
@@ -558,7 +630,8 @@ var PortalDaemon = class {
558
630
  const state = {
559
631
  version: LOCAL_PROTOCOL_VERSION,
560
632
  cwds: Object.fromEntries(this.cwds),
561
- declined: [...this.declined]
633
+ declined: [...this.declined],
634
+ machineName: this.persistedMachineName ?? void 0
562
635
  };
563
636
  writeFile(this.paths.statePath, JSON.stringify(state)).catch((error) => {
564
637
  this.logError("persistState failed", error);
@@ -573,10 +646,23 @@ var PortalDaemon = class {
573
646
  * before dispatching a normal command — the same pattern the update-check
574
647
  * worker uses, so it survives bundling (import.meta.url points at the bundle,
575
648
  * not a standalone daemon module).
649
+ *
650
+ * Newest build wins: the daemon is embedded in two independently-updated
651
+ * installers (the skydive CLI and the desktop app), and whoever spawned first
652
+ * would otherwise hold the socket forever — a stale desktop daemon serving a
653
+ * newer CLI indefinitely. So when a daemon is already listening, compare its
654
+ * build stamp to ours: if ours is strictly newer, ask it to shut down (it
655
+ * drains attached clients, who reconnect within ~500ms) and spawn from this
656
+ * binary. Both spawners apply the same rule, so a host converges on the
657
+ * newest installed build with at most one bounce.
576
658
  */
577
659
  async function ensureDaemonRunning(appUrl) {
578
660
  const { socketPath } = daemonPaths(appUrl);
579
- if (await isDaemonListening(socketPath)) return;
661
+ if (await isDaemonListening(socketPath)) {
662
+ const status = await queryDaemonStatus(appUrl);
663
+ if (!status || !isNewerBuild(portalDaemonBuild(), status.build)) return;
664
+ if (await stopDaemon(appUrl) === "failed") return;
665
+ }
580
666
  const entry = process.argv[1];
581
667
  const args = entry ? [
582
668
  entry,