@longleash/cli 0.1.0-rc.11 → 0.1.0-rc.12

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.
@@ -12,8 +12,8 @@ import { dirname as dirname4, join as join10, resolve as resolve6 } from "node:p
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
14
  // ../daemon/src/daemon.ts
15
- import { accessSync, constants, mkdirSync as mkdirSync3, existsSync as existsSync6, writeFileSync as writeFileSync2 } from "node:fs";
16
- import { randomBytes as randomBytes6 } from "node:crypto";
15
+ import { accessSync, constants, mkdirSync as mkdirSync3, existsSync as existsSync6, writeFileSync as writeFileSync2, renameSync, rmSync } from "node:fs";
16
+ import { randomBytes as randomBytes7 } from "node:crypto";
17
17
 
18
18
  // ../daemon/src/delegation-handoff.ts
19
19
  async function pauseCurrentSessionOwner(managed, external, session, actor, reason) {
@@ -5425,6 +5425,79 @@ var IdeOpenSessionResultSchema = external_exports.object({
5425
5425
  }
5426
5426
  });
5427
5427
 
5428
+ // ../protocol/src/pairing.ts
5429
+ var VERIFIED_PAIRING_VERSION = 2;
5430
+ var PairingNonce = external_exports.string().regex(/^[A-Za-z0-9_-]{43}$/);
5431
+ var PairingDeviceName = external_exports.string().min(1).max(64).refine(
5432
+ (name) => name.trim().length > 0 && !/[\p{Cc}\p{Cf}]/u.test(name),
5433
+ "Device name must be visible text without control characters"
5434
+ );
5435
+ var PairingTranscript = external_exports.object({
5436
+ v: external_exports.literal(VERIFIED_PAIRING_VERSION),
5437
+ challengeId: external_exports.string().min(1).max(128),
5438
+ attemptId: PairingNonce,
5439
+ phoneNonce: PairingNonce,
5440
+ daemonNonce: PairingNonce,
5441
+ deviceName: PairingDeviceName,
5442
+ expiresAt: external_exports.number().int().positive().safe()
5443
+ }).strict();
5444
+ var utf8 = (value) => new TextEncoder().encode(value);
5445
+ var hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
5446
+ function deriveVerifiedPairing(secret, raw) {
5447
+ PairingNonce.parse(secret);
5448
+ const t = PairingTranscript.parse(raw);
5449
+ const transcript = JSON.stringify([
5450
+ "longleash-pairing-v2",
5451
+ t.v,
5452
+ t.challengeId,
5453
+ t.attemptId,
5454
+ ["phone", t.phoneNonce, t.deviceName],
5455
+ ["daemon", t.daemonNonce],
5456
+ t.expiresAt
5457
+ ]);
5458
+ const salt = sha256(utf8(transcript));
5459
+ const derive = (purpose) => hkdf(sha256, utf8(secret), salt, utf8(`longleash-pairing-v2/${purpose}`), 32);
5460
+ const digits = (BigInt(`0x${hex(derive("display-code"))}`) % 100000000n).toString().padStart(8, "0");
5461
+ return {
5462
+ code: `${digits.slice(0, 4)} ${digits.slice(4)}`,
5463
+ identity: { roomTag: hex(derive("room-tag")), frameKey: derive("frame-key") }
5464
+ };
5465
+ }
5466
+ var PairingBegin = external_exports.object({
5467
+ v: external_exports.literal(2),
5468
+ type: external_exports.literal("pair-begin"),
5469
+ challengeId: external_exports.string().min(1).max(128),
5470
+ phoneNonce: PairingNonce,
5471
+ deviceName: PairingDeviceName
5472
+ }).strict();
5473
+ var PairingDecision = external_exports.object({
5474
+ v: external_exports.literal(2),
5475
+ type: external_exports.enum(["pair-confirm", "pair-reject", "pair-ping"]),
5476
+ attemptId: PairingNonce
5477
+ }).strict();
5478
+ var PairingReply = external_exports.discriminatedUnion("type", [
5479
+ external_exports.object({ v: external_exports.literal(2), type: external_exports.literal("pair-transcript"), transcript: PairingTranscript, remainingMs: external_exports.number().int().min(1).max(3e5) }).strict(),
5480
+ external_exports.object({ v: external_exports.literal(2), type: external_exports.literal("pair-waiting"), attemptId: PairingNonce }).strict(),
5481
+ external_exports.object({
5482
+ v: external_exports.literal(2),
5483
+ type: external_exports.literal("paired"),
5484
+ attemptId: PairingNonce,
5485
+ token: external_exports.string().regex(/^llt_[A-Za-z0-9_-]{43}$/),
5486
+ relaySecret: PairingNonce,
5487
+ deviceId: external_exports.string().min(1).max(128)
5488
+ }).strict(),
5489
+ external_exports.object({ v: external_exports.literal(2), type: external_exports.literal("pair-error"), reason: external_exports.enum([
5490
+ "update-required",
5491
+ "invalid-input",
5492
+ "expired",
5493
+ "competing-attempt",
5494
+ "cancelled",
5495
+ "disconnected",
5496
+ "unavailable"
5497
+ ]) }).strict()
5498
+ ]);
5499
+ var PAIRING_FRAME_LIMIT = 8192;
5500
+
5428
5501
  // ../protocol/src/index.ts
5429
5502
  var PROTOCOL_VERSION = 1;
5430
5503
  var AgentKind = external_exports.enum(["claude", "gemini", "codex", "terminal"]);
@@ -6041,6 +6114,13 @@ var completePairingInput = external_exports.object({
6041
6114
  deviceName: external_exports.string().min(1).max(64),
6042
6115
  publicKey: external_exports.string().min(1).optional()
6043
6116
  });
6117
+ var beginVerifiedInput = external_exports.object({
6118
+ v: external_exports.literal(2),
6119
+ challengeId: external_exports.string().min(1).max(128),
6120
+ secret: PairingNonce,
6121
+ deviceName: PairingDeviceName,
6122
+ phoneNonce: PairingNonce
6123
+ }).strict();
6044
6124
  var id = (prefix) => `${prefix}_${randomBytes(12).toString("base64url")}`;
6045
6125
  var sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
6046
6126
  var hashesMatch = (a, b) => timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
@@ -6050,6 +6130,7 @@ var DeviceRegistry = class {
6050
6130
  challengeTtlMs;
6051
6131
  // Ephemeral by design: a daemon restart voids pending pairing QR codes.
6052
6132
  challenges = /* @__PURE__ */ new Map();
6133
+ pairingResults = /* @__PURE__ */ new Map();
6053
6134
  revokedListeners = /* @__PURE__ */ new Set();
6054
6135
  pairedListeners = /* @__PURE__ */ new Set();
6055
6136
  constructor(path, opts = {}) {
@@ -6072,17 +6153,22 @@ var DeviceRegistry = class {
6072
6153
  this.now = opts.now ?? Date.now;
6073
6154
  this.challengeTtlMs = opts.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS;
6074
6155
  }
6075
- createPairingChallenge() {
6156
+ createPairingChallenge(opts = {}) {
6076
6157
  this.sweepExpiredChallenges();
6158
+ const version = opts.version ?? PAIRING_QR_VERSION;
6159
+ if (version !== 1 && version !== 2) throw new PairingError("invalid-input", "Unsupported pairing version");
6160
+ if (version === 2 && [...this.challenges.values()].filter((c) => c.version === 2).length >= 8) {
6161
+ throw new PairingError("pending-limit", "Too many pending verifications \u2014 finish or wait for expiry");
6162
+ }
6077
6163
  const challengeId = id("chl");
6078
6164
  const secret = randomBytes(32).toString("base64url");
6079
6165
  const expiresAt = this.now() + this.challengeTtlMs;
6080
- this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt });
6166
+ this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt, version });
6081
6167
  return {
6082
6168
  challengeId,
6083
6169
  secret,
6084
6170
  expiresAt,
6085
- qrPayload: JSON.stringify({ v: PAIRING_QR_VERSION, challengeId, secret })
6171
+ qrPayload: JSON.stringify({ v: version, challengeId, secret })
6086
6172
  };
6087
6173
  }
6088
6174
  completePairing(raw) {
@@ -6091,24 +6177,114 @@ var DeviceRegistry = class {
6091
6177
  throw new PairingError("invalid-input", parsed.error.message);
6092
6178
  }
6093
6179
  const input = parsed.data;
6094
- const challenge = this.challenges.get(input.challengeId);
6095
- if (!challenge) {
6096
- throw new PairingError("unknown-challenge", "Unknown or already-used pairing challenge");
6180
+ const challenge = this.authorizeChallenge(input.challengeId, input.secret);
6181
+ if (challenge.version !== 1) {
6182
+ throw new PairingError("verification-required", "This QR requires verification on the phone and laptop");
6097
6183
  }
6098
- if (this.now() > challenge.expiresAt) {
6184
+ this.challenges.delete(input.challengeId);
6185
+ return this.commitDevice(input.deviceName, input.publicKey);
6186
+ }
6187
+ /** Foundation only: transport adapters must authenticate/decrypt BEFORE calling this. */
6188
+ beginVerifiedPairing(raw, owner) {
6189
+ const parsed = beginVerifiedInput.safeParse(raw);
6190
+ if (!parsed.success) throw new PairingError("invalid-input", "Invalid v2 pairing request");
6191
+ const input = parsed.data;
6192
+ const challenge = this.authorizeChallenge(input.challengeId, input.secret);
6193
+ if (challenge.version !== 2) {
6194
+ throw new PairingError("update-required", "Update LongLeash and generate a new verified QR code");
6195
+ }
6196
+ if (challenge.pending) {
6197
+ const previous = challenge.pending.transcript;
6198
+ if (challenge.pending.owner === owner && previous.phoneNonce === input.phoneNonce && previous.deviceName === input.deviceName) {
6199
+ return { ...previous };
6200
+ }
6099
6201
  this.challenges.delete(input.challengeId);
6202
+ throw new PairingError("competing-attempt", "A competing pairing attempt was detected \u2014 generate a new QR code");
6203
+ }
6204
+ const transcript = {
6205
+ v: 2,
6206
+ challengeId: input.challengeId,
6207
+ attemptId: randomBytes(32).toString("base64url"),
6208
+ phoneNonce: input.phoneNonce,
6209
+ daemonNonce: randomBytes(32).toString("base64url"),
6210
+ deviceName: input.deviceName,
6211
+ expiresAt: challenge.expiresAt
6212
+ };
6213
+ const { code } = deriveVerifiedPairing(input.secret, transcript);
6214
+ challenge.pending = { transcript, code, localConfirmed: false, ...owner ? { owner } : {} };
6215
+ return { ...transcript };
6216
+ }
6217
+ getPairingResult(challengeId) {
6218
+ this.sweepExpiredChallenges();
6219
+ const result = this.pairingResults.get(challengeId);
6220
+ return result ? { deviceId: result.deviceId } : null;
6221
+ }
6222
+ hasPairingChallenge(challengeId) {
6223
+ this.sweepExpiredChallenges();
6224
+ return this.challenges.has(challengeId);
6225
+ }
6226
+ /** Local operator cancellation also works before a phone has connected. */
6227
+ cancelPairingChallenge(challengeId) {
6228
+ this.challenges.delete(challengeId);
6229
+ }
6230
+ /** LOCAL ONLY. Never expose the laptop display code through a phone-facing route. */
6231
+ getPairingVerification(challengeId) {
6232
+ this.sweepExpiredChallenges();
6233
+ const pending = this.challenges.get(challengeId)?.pending;
6234
+ if (!pending) return null;
6235
+ const { attemptId, deviceName, expiresAt } = pending.transcript;
6236
+ return { attemptId, code: pending.code, deviceName, expiresAt };
6237
+ }
6238
+ /** LOCAL ONLY: a secret-authenticated loopback/CLI action, never a remote message. */
6239
+ confirmPairingLocally(challengeId, attemptId) {
6240
+ this.requireAttempt(challengeId, attemptId).localConfirmed = true;
6241
+ }
6242
+ /** Call only for an explicit phone 'Codes match' on this attempt's encrypted channel. */
6243
+ finishVerifiedPairing(challengeId, attemptId) {
6244
+ const pending = this.requireAttempt(challengeId, attemptId);
6245
+ if (!pending.localConfirmed) {
6246
+ throw new PairingError("local-confirmation-required", "Compare and confirm the code on your laptop first");
6247
+ }
6248
+ this.challenges.delete(challengeId);
6249
+ const result = this.commitDevice(pending.transcript.deviceName);
6250
+ this.pairingResults.set(challengeId, { deviceId: result.device.deviceId, expiresAt: this.now() + 6e4 });
6251
+ return result;
6252
+ }
6253
+ /** Rejection and transport disconnect share the same terminal transition. */
6254
+ cancelVerifiedPairing(challengeId, attemptId) {
6255
+ this.requireAttempt(challengeId, attemptId);
6256
+ this.challenges.delete(challengeId);
6257
+ }
6258
+ requireAttempt(challengeId, attemptId) {
6259
+ const challenge = this.requireChallenge(challengeId);
6260
+ if (!challenge.pending || challenge.pending.transcript.attemptId !== attemptId) {
6261
+ throw new PairingError("unknown-attempt", "Unknown pairing attempt");
6262
+ }
6263
+ return challenge.pending;
6264
+ }
6265
+ requireChallenge(challengeId) {
6266
+ const challenge = this.challenges.get(challengeId);
6267
+ if (!challenge) throw new PairingError("unknown-challenge", "Unknown or already-used pairing challenge");
6268
+ if (this.now() >= challenge.expiresAt) {
6269
+ this.challenges.delete(challengeId);
6100
6270
  throw new PairingError("expired", "Pairing challenge expired \u2014 generate a new QR code");
6101
6271
  }
6102
- if (!hashesMatch(sha256Hex(input.secret), challenge.secretHash)) {
6272
+ return challenge;
6273
+ }
6274
+ authorizeChallenge(challengeId, secret) {
6275
+ const challenge = this.requireChallenge(challengeId);
6276
+ if (!hashesMatch(sha256Hex(secret), challenge.secretHash)) {
6103
6277
  throw new PairingError("bad-secret", "Wrong pairing secret");
6104
6278
  }
6105
- this.challenges.delete(input.challengeId);
6279
+ return challenge;
6280
+ }
6281
+ commitDevice(deviceName, publicKey) {
6106
6282
  const token = `llt_${randomBytes(32).toString("base64url")}`;
6107
6283
  const relaySecret = randomBytes(32).toString("base64url");
6108
6284
  const device = {
6109
6285
  deviceId: id("dev"),
6110
- name: input.deviceName,
6111
- publicKey: input.publicKey ?? null,
6286
+ name: deviceName,
6287
+ publicKey: publicKey ?? null,
6112
6288
  createdAt: this.now(),
6113
6289
  lastSeenAt: null,
6114
6290
  revokedAt: null
@@ -6176,12 +6352,15 @@ var DeviceRegistry = class {
6176
6352
  return rows.map((row) => this.toDevice(row));
6177
6353
  }
6178
6354
  close() {
6355
+ this.challenges.clear();
6356
+ this.pairingResults.clear();
6179
6357
  this.rawDb.close();
6180
6358
  }
6181
6359
  sweepExpiredChallenges() {
6182
6360
  const now = this.now();
6361
+ for (const [id2, result] of this.pairingResults) if (now >= result.expiresAt) this.pairingResults.delete(id2);
6183
6362
  for (const [challengeId, challenge] of this.challenges) {
6184
- if (now > challenge.expiresAt) this.challenges.delete(challengeId);
6363
+ if (now >= challenge.expiresAt) this.challenges.delete(challengeId);
6185
6364
  }
6186
6365
  }
6187
6366
  toDevice(row) {
@@ -7884,7 +8063,7 @@ import { join as join2 } from "node:path";
7884
8063
  import Fastify from "fastify";
7885
8064
  import fastifyStatic from "@fastify/static";
7886
8065
  import websocket from "@fastify/websocket";
7887
- import { randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
8066
+ import { randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
7888
8067
 
7889
8068
  // ../daemon/src/external.ts
7890
8069
  import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
@@ -8806,6 +8985,139 @@ function transcriptDeltas(line) {
8806
8985
  return out;
8807
8986
  }
8808
8987
 
8988
+ // ../daemon/src/pairing-session.ts
8989
+ import { randomBytes as randomBytes4 } from "node:crypto";
8990
+ function createPairingSession(opts) {
8991
+ const owner = randomBytes4(32).toString("base64url");
8992
+ const rendezvous = derivePairingIdentity(opts.challenge.secret);
8993
+ let transcript = null;
8994
+ let identity = null;
8995
+ let confirmed = false;
8996
+ let disposed = false;
8997
+ let committed = false;
8998
+ let lastSeen = Date.now();
8999
+ let windowAt = Date.now();
9000
+ let frames = 0;
9001
+ let queued = 0;
9002
+ let chain = Promise.resolve();
9003
+ let closing;
9004
+ const dispose = () => {
9005
+ if (disposed) return;
9006
+ disposed = true;
9007
+ clearInterval(timer);
9008
+ clearTimeout(closing);
9009
+ if (transcript && !committed) opts.registry.cancelPairingChallenge(opts.challenge.challengeId);
9010
+ opts.close();
9011
+ };
9012
+ const send = async (body, key = identity) => {
9013
+ const payload = await seal(key ?? await rendezvous, JSON.stringify(body));
9014
+ if (!disposed) opts.send(payload);
9015
+ };
9016
+ const fail = async (reason) => {
9017
+ await send({ v: 2, type: "pair-error", reason });
9018
+ dispose();
9019
+ };
9020
+ const tick = async () => {
9021
+ if (disposed || committed) return;
9022
+ if (Date.now() >= opts.challenge.expiresAt) return fail("expired");
9023
+ if (!opts.registry.hasPairingChallenge(opts.challenge.challengeId)) return fail("cancelled");
9024
+ if (transcript && Date.now() - lastSeen > 3e4) return fail("disconnected");
9025
+ if (!confirmed || !transcript) return;
9026
+ try {
9027
+ const { device, token, relaySecret } = opts.registry.finishVerifiedPairing(opts.challenge.challengeId, transcript.attemptId);
9028
+ committed = true;
9029
+ await send({ v: 2, type: "paired", attemptId: transcript.attemptId, deviceId: device.deviceId, token, relaySecret });
9030
+ clearInterval(timer);
9031
+ closing = setTimeout(dispose, 500);
9032
+ closing.unref?.();
9033
+ } catch (err) {
9034
+ if (err instanceof PairingError && err.reason === "local-confirmation-required") return;
9035
+ await fail("unavailable");
9036
+ }
9037
+ };
9038
+ const processFrame = async (payload) => {
9039
+ if (disposed || committed) return;
9040
+ const text = await open(identity ?? await rendezvous, payload);
9041
+ if (disposed || committed) return;
9042
+ if (text === null) {
9043
+ if (identity) {
9044
+ const competing = await open(await rendezvous, payload);
9045
+ if (competing !== null) {
9046
+ try {
9047
+ const begin = PairingBegin.safeParse(JSON.parse(competing));
9048
+ if (begin.success && begin.data.challengeId === opts.challenge.challengeId) {
9049
+ opts.registry.cancelPairingChallenge(opts.challenge.challengeId);
9050
+ await fail("competing-attempt");
9051
+ }
9052
+ } catch {
9053
+ }
9054
+ }
9055
+ }
9056
+ return;
9057
+ }
9058
+ let raw;
9059
+ try {
9060
+ raw = JSON.parse(text);
9061
+ } catch {
9062
+ return;
9063
+ }
9064
+ if (!transcript) {
9065
+ if (raw?.v === 1) {
9066
+ await send({ v: 1, type: "pair-error", reason: "update-required" });
9067
+ dispose();
9068
+ return;
9069
+ }
9070
+ const parsed2 = PairingBegin.safeParse(raw);
9071
+ if (!parsed2.success || parsed2.data.challengeId !== opts.challenge.challengeId) return;
9072
+ try {
9073
+ transcript = opts.registry.beginVerifiedPairing({
9074
+ v: 2,
9075
+ challengeId: parsed2.data.challengeId,
9076
+ secret: opts.challenge.secret,
9077
+ deviceName: parsed2.data.deviceName,
9078
+ phoneNonce: parsed2.data.phoneNonce
9079
+ }, owner);
9080
+ await send({ v: 2, type: "pair-transcript", transcript, remainingMs: Math.max(1, Math.min(3e5, opts.challenge.expiresAt - Date.now())) }, await rendezvous);
9081
+ identity = deriveVerifiedPairing(opts.challenge.secret, transcript).identity;
9082
+ lastSeen = Date.now();
9083
+ } catch (err) {
9084
+ await fail(err instanceof PairingError && err.reason === "competing-attempt" ? "competing-attempt" : "unavailable");
9085
+ }
9086
+ return;
9087
+ }
9088
+ const parsed = PairingDecision.safeParse(raw);
9089
+ if (!parsed.success || parsed.data.attemptId !== transcript.attemptId) return;
9090
+ lastSeen = Date.now();
9091
+ if (parsed.data.type === "pair-reject") return fail("cancelled");
9092
+ if (parsed.data.type === "pair-confirm") {
9093
+ confirmed = true;
9094
+ await send({ v: 2, type: "pair-waiting", attemptId: transcript.attemptId });
9095
+ await tick();
9096
+ }
9097
+ };
9098
+ const receive = (payload) => {
9099
+ if (disposed || committed) return Promise.resolve();
9100
+ if (Date.now() - windowAt >= 6e4) {
9101
+ windowAt = Date.now();
9102
+ frames = 0;
9103
+ }
9104
+ if (payload.length > PAIRING_FRAME_LIMIT || ++frames > 60 || queued >= 8) {
9105
+ dispose();
9106
+ return Promise.resolve();
9107
+ }
9108
+ queued++;
9109
+ chain = chain.then(() => processFrame(payload)).catch(() => dispose()).finally(() => {
9110
+ queued--;
9111
+ });
9112
+ return chain;
9113
+ };
9114
+ const timer = setInterval(() => {
9115
+ chain = chain.then(tick).catch(() => dispose());
9116
+ }, 250);
9117
+ timer.unref?.();
9118
+ return { receive, dispose, tick };
9119
+ }
9120
+
8809
9121
  // ../daemon/src/relay-link.ts
8810
9122
  import WebSocket from "ws";
8811
9123
  function withRoom(endpoint, roomTag, role) {
@@ -9170,7 +9482,7 @@ var BriefingBuilder = class {
9170
9482
  };
9171
9483
 
9172
9484
  // ../daemon/src/delegations.ts
9173
- import { randomBytes as randomBytes4 } from "node:crypto";
9485
+ import { randomBytes as randomBytes5 } from "node:crypto";
9174
9486
  var DelegationError = class extends Error {
9175
9487
  constructor(reason, message) {
9176
9488
  super(message);
@@ -9180,7 +9492,7 @@ var DelegationError = class extends Error {
9180
9492
  };
9181
9493
  var TARGET_AGENTS = /* @__PURE__ */ new Set(["claude", "codex"]);
9182
9494
  var TERMINAL = /* @__PURE__ */ new Set(["returned", "cancelled", "failed"]);
9183
- var newId3 = () => `del_${randomBytes4(9).toString("base64url")}`;
9495
+ var newId3 = () => `del_${randomBytes5(9).toString("base64url")}`;
9184
9496
  var DelegationStore = class {
9185
9497
  db;
9186
9498
  now;
@@ -10156,6 +10468,10 @@ var LongLeashServer = class {
10156
10468
  delegations = null;
10157
10469
  hookSecret = null;
10158
10470
  localPairing = null;
10471
+ pairingChallenges = /* @__PURE__ */ new Map();
10472
+ pairingSockets = /* @__PURE__ */ new Set();
10473
+ pairingRequests = [];
10474
+ pairingCleanups = /* @__PURE__ */ new Set();
10159
10475
  staticRoot;
10160
10476
  /** Captured at process start so updating files on disk cannot impersonate a daemon restart. */
10161
10477
  expectedBuild;
@@ -10182,6 +10498,7 @@ var LongLeashServer = class {
10182
10498
  ok: true,
10183
10499
  name: "longleash",
10184
10500
  protocol: PROTOCOL_VERSION,
10501
+ pairingVersion: 2,
10185
10502
  // A version is not machine data. Exposing it turns "connection refused / is this old?"
10186
10503
  // from guesswork into a doctor check and prevents testing a daemon that never restarted.
10187
10504
  build: this.expectedBuild
@@ -10257,7 +10574,7 @@ var LongLeashServer = class {
10257
10574
  });
10258
10575
  const localOnly = (request) => {
10259
10576
  const presented = request.headers["x-longleash-hook"];
10260
- return this.hookSecret !== null && typeof presented === "string" && presented.length === this.hookSecret.length && timingSafeEqual2(Buffer.from(presented), Buffer.from(this.hookSecret));
10577
+ return !request.headers.origin && this.hookSecret !== null && typeof presented === "string" && Buffer.byteLength(presented) === Buffer.byteLength(this.hookSecret) && timingSafeEqual2(Buffer.from(presented), Buffer.from(this.hookSecret));
10261
10578
  };
10262
10579
  this.app.get("/devices", async (request, reply) => {
10263
10580
  if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
@@ -10285,13 +10602,82 @@ var LongLeashServer = class {
10285
10602
  });
10286
10603
  this.app.post("/local/pairing", async (request, reply) => {
10287
10604
  if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
10605
+ this.pairingRequests = this.pairingRequests.filter((at) => Date.now() - at < 6e4);
10606
+ if (this.pairingRequests.length >= 10) return reply.code(429).send({ reason: "rate-limited" });
10607
+ this.pairingRequests.push(Date.now());
10288
10608
  if (this.localPairing === null) return reply.code(503).send({ reason: "pairing-unavailable" });
10289
10609
  try {
10290
- return { url: this.localPairing() };
10610
+ const url2 = this.localPairing();
10611
+ const challengeId = new URLSearchParams(new URL(url2).hash.slice(1)).get("c");
10612
+ const challenge = challengeId ? this.pairingChallenges.get(challengeId) : void 0;
10613
+ return { url: url2, version: 2, challengeId, expiresAt: challenge?.expiresAt };
10291
10614
  } catch {
10292
10615
  return reply.code(503).send({ reason: "pairing-failed" });
10293
10616
  }
10294
10617
  });
10618
+ const localPairingInput = external_exports.object({
10619
+ challengeId: external_exports.string().min(1).max(128),
10620
+ attemptId: external_exports.string().regex(/^[A-Za-z0-9_-]{43}$/).optional()
10621
+ }).strict();
10622
+ for (const action of ["status", "confirm", "cancel"]) {
10623
+ this.app.post(`/local/pairing/${action}`, async (request, reply) => {
10624
+ if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
10625
+ const input = localPairingInput.safeParse(request.body);
10626
+ if (!input.success) return reply.code(400).send({ reason: "invalid-input" });
10627
+ const { challengeId, attemptId } = input.data;
10628
+ if (action === "cancel") {
10629
+ this.registry.cancelPairingChallenge(challengeId);
10630
+ return { cancelled: true };
10631
+ }
10632
+ const result = this.registry.getPairingResult(challengeId);
10633
+ if (action === "status" && result) return { state: "paired", deviceId: result.deviceId };
10634
+ if (!this.registry.hasPairingChallenge(challengeId)) return reply.code(410).send({ reason: "ended" });
10635
+ if (action === "status") return { verification: this.registry.getPairingVerification(challengeId) };
10636
+ if (!attemptId) return reply.code(400).send({ reason: "invalid-input" });
10637
+ try {
10638
+ this.registry.confirmPairingLocally(challengeId, attemptId);
10639
+ return { confirmed: true };
10640
+ } catch {
10641
+ return reply.code(409).send({ reason: "attempt-ended" });
10642
+ }
10643
+ });
10644
+ }
10645
+ this.app.get("/pair/v2", { websocket: true }, (socket, request) => {
10646
+ const challengeId = request.query.c ?? "";
10647
+ const challenge = this.pairingChallenges.get(challengeId);
10648
+ if (!challenge || !this.registry.hasPairingChallenge(challengeId) || this.pairingSockets.size >= 16) {
10649
+ socket.close(4403, "Pairing unavailable; generate a fresh QR");
10650
+ return;
10651
+ }
10652
+ this.pairingSockets.add(socket);
10653
+ const session = createPairingSession({
10654
+ registry: this.registry,
10655
+ challenge,
10656
+ send: (payload) => {
10657
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify({ v: 1, type: "frame", payload }));
10658
+ },
10659
+ close: () => socket.close()
10660
+ });
10661
+ this.pairingCleanups.add(session.dispose);
10662
+ socket.on("message", (raw) => {
10663
+ if (String(raw).length > 9e3) {
10664
+ session.dispose();
10665
+ return;
10666
+ }
10667
+ try {
10668
+ const message = JSON.parse(String(raw));
10669
+ if (message.type === "frame" && typeof message.payload === "string") void session.receive(message.payload);
10670
+ } catch {
10671
+ }
10672
+ });
10673
+ socket.on("close", () => {
10674
+ session.dispose();
10675
+ this.pairingSockets.delete(socket);
10676
+ this.pairingCleanups.delete(session.dispose);
10677
+ });
10678
+ socket.on("error", session.dispose);
10679
+ socket.send(JSON.stringify({ v: 2, type: "pair-ready" }));
10680
+ });
10295
10681
  this.app.get("/pair", async (_request, reply) => reply.code(405).send({ reason: "use-post" }));
10296
10682
  this.app.post("/pair", async (request, reply) => {
10297
10683
  const query2 = request.query;
@@ -10397,7 +10783,7 @@ var LongLeashServer = class {
10397
10783
  */
10398
10784
  async rebind(host) {
10399
10785
  const wanted = this.boundPort;
10400
- await new Promise((resolve7) => this.app.server.close(() => resolve7()));
10786
+ if (this.app.server.listening) await new Promise((resolve7) => this.app.server.close(() => resolve7()));
10401
10787
  const tryListen = (port) => new Promise((resolve7, reject) => {
10402
10788
  const onError = (err) => {
10403
10789
  this.app.server.removeListener("listening", onListening);
@@ -10434,6 +10820,21 @@ var LongLeashServer = class {
10434
10820
  attachLocalPairing(create) {
10435
10821
  this.localPairing = create;
10436
10822
  }
10823
+ /** Only transient memory; URLs/secrets must never reach logs or the event database. */
10824
+ registerPairingChallenge(challenge) {
10825
+ for (const [id2, previous] of this.pairingChallenges) {
10826
+ if (previous.expiresAt <= Date.now() || !this.registry.hasPairingChallenge(id2)) this.pairingChallenges.delete(id2);
10827
+ }
10828
+ this.pairingChallenges.set(challenge.challengeId, challenge);
10829
+ const cleanup = () => {
10830
+ clearTimeout(timer);
10831
+ this.pairingChallenges.delete(challenge.challengeId);
10832
+ this.pairingCleanups.delete(cleanup);
10833
+ };
10834
+ const timer = setTimeout(cleanup, Math.max(0, challenge.expiresAt - Date.now()));
10835
+ timer.unref?.();
10836
+ this.pairingCleanups.add(cleanup);
10837
+ }
10437
10838
  /** Persist an event, then fan it out to every subscriber of that session. */
10438
10839
  publish(sessionId, input) {
10439
10840
  const event2 = this.eventLog.append(sessionId, input);
@@ -10454,6 +10855,9 @@ var LongLeashServer = class {
10454
10855
  return this.connections.size;
10455
10856
  }
10456
10857
  async close() {
10858
+ for (const cleanup of this.pairingCleanups) cleanup();
10859
+ for (const socket of this.pairingSockets) socket.terminate();
10860
+ this.pairingChallenges.clear();
10457
10861
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
10458
10862
  this.heartbeatTimer = null;
10459
10863
  this.unsubscribeRevoked?.();
@@ -11061,7 +11465,7 @@ var LongLeashServer = class {
11061
11465
  return;
11062
11466
  }
11063
11467
  if (message.type === "takeOver") {
11064
- const reservationId = `takeover-${randomBytes5(9).toString("base64url")}`;
11468
+ const reservationId = `takeover-${randomBytes6(9).toString("base64url")}`;
11065
11469
  const sessions = this.sessions;
11066
11470
  void (async () => {
11067
11471
  const conflict = this.workspaceConflictMessage(message.sessionId);
@@ -12599,7 +13003,7 @@ var CodexSessionWatcher = class {
12599
13003
  this.timer = null;
12600
13004
  }
12601
13005
  scan(initial = false) {
12602
- const root = this.options.sessionsRoot ?? join6(homedir3(), ".codex", "sessions");
13006
+ const root = this.options.sessionsRoot ?? join6(process.env.CODEX_HOME || join6(homedir3(), ".codex"), "sessions");
12603
13007
  const now = this.options.now?.() ?? Date.now();
12604
13008
  let observed = 0;
12605
13009
  for (const path of jsonlFiles(root)) {
@@ -12611,7 +13015,7 @@ var CodexSessionWatcher = class {
12611
13015
  }
12612
13016
  const previous = this.mtimes.get(path);
12613
13017
  this.mtimes.set(path, modified);
12614
- const changed = previous !== void 0 && modified > previous;
13018
+ const changed = previous === void 0 ? !initial : modified !== previous;
12615
13019
  const recentlyActive = initial && now - modified <= this.options.initialRecentMs;
12616
13020
  if (!changed && !recentlyActive) continue;
12617
13021
  const previousSession = this.known.get(path);
@@ -12851,14 +13255,20 @@ async function startDaemon(options) {
12851
13255
  pauseSession: (session, actor, reason) => pauseCurrentSessionOwner(sessions, external, session, actor, reason)
12852
13256
  });
12853
13257
  server.attachDelegations(delegationManager);
12854
- const hookSecret = randomBytes6(24).toString("base64url");
13258
+ const hookSecret = randomBytes7(24).toString("base64url");
12855
13259
  server.attachExternal(external, hookSecret);
12856
13260
  const { port } = await server.listen();
12857
- writeFileSync2(
12858
- join7(dataDir2, "hook-endpoint.json"),
12859
- JSON.stringify({ url: `http://${options.host}:${port}/hook`, secret: hookSecret }, null, 2) + "\n",
12860
- { mode: 384 }
12861
- );
13261
+ const endpointPath = join7(dataDir2, "hook-endpoint.json");
13262
+ const publishHookEndpoint = (host, boundPort) => {
13263
+ const staged = `${endpointPath}.${randomBytes7(8).toString("hex")}.tmp`;
13264
+ try {
13265
+ writeFileSync2(staged, JSON.stringify({ url: `http://${host}:${boundPort}/hook`, secret: hookSecret }, null, 2) + "\n", { mode: 384 });
13266
+ renameSync(staged, endpointPath);
13267
+ } finally {
13268
+ rmSync(staged, { force: true });
13269
+ }
13270
+ };
13271
+ publishHookEndpoint(options.host, port);
12862
13272
  const stopMaintenance = sessions.startMaintenance();
12863
13273
  let bridge = null;
12864
13274
  if (options.relayUrl !== void 0) {
@@ -12868,6 +13278,11 @@ async function startDaemon(options) {
12868
13278
  }
12869
13279
  return {
12870
13280
  server,
13281
+ rebindLocal: async (host) => {
13282
+ const reboundPort = await server.rebind(host);
13283
+ publishHookEndpoint(host, reboundPort);
13284
+ return reboundPort;
13285
+ },
12871
13286
  sessions,
12872
13287
  registry,
12873
13288
  eventLog,
@@ -12927,7 +13342,62 @@ function save(path, config) {
12927
13342
  }
12928
13343
 
12929
13344
  // ../daemon/src/pairing-host.ts
13345
+ import WebSocket3 from "ws";
13346
+
13347
+ // ../daemon/src/pairing-relay-v2.ts
12930
13348
  import WebSocket2 from "ws";
13349
+ function hostVerifiedPairing(opts) {
13350
+ let socket = null;
13351
+ let session = null;
13352
+ let disposed = false;
13353
+ const dispose = () => {
13354
+ if (disposed) return;
13355
+ disposed = true;
13356
+ clearTimeout(deadline);
13357
+ clearInterval(keepalive);
13358
+ session?.dispose();
13359
+ opts.registry.cancelPairingChallenge(opts.challenge.challengeId);
13360
+ socket?.close();
13361
+ };
13362
+ const deadline = setTimeout(dispose, Math.max(0, opts.challenge.expiresAt - Date.now()));
13363
+ deadline.unref?.();
13364
+ const keepalive = setInterval(() => {
13365
+ if (!opts.registry.hasPairingChallenge(opts.challenge.challengeId)) {
13366
+ dispose();
13367
+ return;
13368
+ }
13369
+ if (socket?.readyState === WebSocket2.OPEN) socket.send(JSON.stringify({ v: 1, type: "ping" }));
13370
+ }, 1e4);
13371
+ keepalive.unref?.();
13372
+ void derivePairingIdentity(opts.challenge.secret).then((identity) => {
13373
+ if (disposed) return;
13374
+ const ws = new WebSocket2(withRoom(opts.relayUrl, identity.roomTag, "host"), { maxPayload: 1e4 });
13375
+ socket = ws;
13376
+ session = createPairingSession({
13377
+ registry: opts.registry,
13378
+ challenge: opts.challenge,
13379
+ send: (payload) => {
13380
+ if (ws.readyState === WebSocket2.OPEN) ws.send(JSON.stringify({ v: 1, type: "frame", payload }));
13381
+ },
13382
+ close: dispose
13383
+ });
13384
+ ws.on("open", () => ws.send(JSON.stringify({ v: 1, type: "join", room: identity.roomTag, role: "host" })));
13385
+ ws.on("close", dispose);
13386
+ ws.on("error", dispose);
13387
+ ws.on("message", (raw) => {
13388
+ if (disposed) return;
13389
+ try {
13390
+ const message = JSON.parse(String(raw));
13391
+ if (message.type === "frame" && typeof message.payload === "string") void session?.receive(message.payload);
13392
+ if (message.type === "peer" && message.role === "guest" && message.event === "left") dispose();
13393
+ } catch {
13394
+ }
13395
+ });
13396
+ }).catch(dispose);
13397
+ return dispose;
13398
+ }
13399
+
13400
+ // ../daemon/src/pairing-host.ts
12931
13401
  var CompletePairing = external_exports.object({
12932
13402
  v: external_exports.literal(1),
12933
13403
  type: external_exports.literal("completePairing"),
@@ -12936,6 +13406,7 @@ var CompletePairing = external_exports.object({
12936
13406
  deviceName: external_exports.string().min(1).max(64)
12937
13407
  });
12938
13408
  function hostPairing(opts) {
13409
+ if (JSON.parse(opts.challenge.qrPayload).v === 2) return hostVerifiedPairing(opts);
12939
13410
  const log = opts.log ?? (() => {
12940
13411
  });
12941
13412
  let socket = null;
@@ -12954,7 +13425,7 @@ function hostPairing(opts) {
12954
13425
  return;
12955
13426
  }
12956
13427
  if (disposed) return;
12957
- const ws = new WebSocket2(withRoom(opts.relayUrl, identity.roomTag, "host"));
13428
+ const ws = new WebSocket3(withRoom(opts.relayUrl, identity.roomTag, "host"));
12958
13429
  socket = ws;
12959
13430
  ws.on("open", () => {
12960
13431
  ws.send(JSON.stringify({ v: 1, type: "join", room: identity.roomTag, role: "host" }));
@@ -12997,7 +13468,7 @@ function hostPairing(opts) {
12997
13468
  reason: err instanceof PairingError ? err.reason : "error"
12998
13469
  };
12999
13470
  }
13000
- if (ws.readyState === WebSocket2.OPEN) {
13471
+ if (ws.readyState === WebSocket3.OPEN) {
13001
13472
  ws.send(JSON.stringify({ v: 1, type: "frame", payload: await seal(identity, JSON.stringify(reply)) }));
13002
13473
  }
13003
13474
  if (succeeded) setTimeout(dispose, 500);
@@ -13010,10 +13481,10 @@ function hostPairing(opts) {
13010
13481
  }
13011
13482
 
13012
13483
  // ../daemon/src/pairing-url.ts
13013
- function pairingUrl(appOrigin, challengeId, secret) {
13484
+ function pairingUrl(appOrigin, challengeId, secret, version = 1) {
13014
13485
  const url2 = new URL(appOrigin);
13015
13486
  url2.search = "";
13016
- url2.hash = new URLSearchParams({ c: challengeId, s: secret }).toString();
13487
+ url2.hash = new URLSearchParams({ c: challengeId, s: secret, ...version === 2 ? { v: "2" } : {} }).toString();
13017
13488
  return url2.toString();
13018
13489
  }
13019
13490
 
@@ -13161,7 +13632,7 @@ function noAddressReason(interfaces = networkInterfaces()) {
13161
13632
  }
13162
13633
 
13163
13634
  // ../daemon/src/instance-lock.ts
13164
- import { closeSync as closeSync3, lstatSync, mkdirSync as mkdirSync5, openSync as openSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
13635
+ import { closeSync as closeSync3, lstatSync, mkdirSync as mkdirSync5, openSync as openSync3, readFileSync as readFileSync5, rmSync as rmSync2, writeFileSync as writeFileSync4 } from "node:fs";
13165
13636
  import { randomUUID } from "node:crypto";
13166
13637
  import { join as join9 } from "node:path";
13167
13638
  var LOCK_NAME = "daemon.lock";
@@ -13195,7 +13666,7 @@ function acquireDaemonInstance(dataDir2) {
13195
13666
  if (processAlive(owner.pid)) {
13196
13667
  throw new Error(`LongLeash already owns this data directory in process ${owner.pid}. Use \`longleash service status\` before starting another instance.`);
13197
13668
  }
13198
- rmSync(path);
13669
+ rmSync2(path);
13199
13670
  create();
13200
13671
  }
13201
13672
  let held = true;
@@ -13206,7 +13677,7 @@ function acquireDaemonInstance(dataDir2) {
13206
13677
  held = false;
13207
13678
  try {
13208
13679
  const owner = JSON.parse(readFileSync5(path, "utf8"));
13209
- if (owner.token === token) rmSync(path);
13680
+ if (owner.token === token) rmSync2(path);
13210
13681
  } catch {
13211
13682
  }
13212
13683
  }
@@ -13357,20 +13828,61 @@ function relayAppOrigin(wsUrl) {
13357
13828
  parsed.pathname = "/";
13358
13829
  return parsed.toString();
13359
13830
  }
13360
- function freshPairingUrl() {
13361
- const challenge = daemon.registry.createPairingChallenge();
13831
+ var foregroundChallenge = null;
13832
+ var displayedAttempt = null;
13833
+ var relayPairings = /* @__PURE__ */ new Set();
13834
+ function freshPairingUrl(foreground = false) {
13835
+ if (foreground && foregroundChallenge) daemon.registry.cancelPairingChallenge(foregroundChallenge);
13836
+ const challenge = daemon.registry.createPairingChallenge({ version: 2 });
13837
+ daemon.server.registerPairingChallenge(challenge);
13838
+ if (foreground) {
13839
+ foregroundChallenge = challenge.challengeId;
13840
+ displayedAttempt = null;
13841
+ }
13362
13842
  if (relay !== null) {
13363
- hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) => daemonLog(`[pair] ${line}`) });
13364
- return pairingUrl(relayAppOrigin(relay.url), challenge.challengeId, challenge.secret);
13843
+ const dispose = hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge });
13844
+ relayPairings.add(dispose);
13845
+ setTimeout(() => {
13846
+ dispose();
13847
+ relayPairings.delete(dispose);
13848
+ }, Math.max(0, challenge.expiresAt - Date.now())).unref?.();
13849
+ return pairingUrl(relayAppOrigin(relay.url), challenge.challengeId, challenge.secret, 2);
13365
13850
  }
13366
13851
  return pairingUrl(
13367
13852
  `http://${servingHost}:${servingPort}/`,
13368
13853
  challenge.challengeId,
13369
- challenge.secret
13854
+ challenge.secret,
13855
+ 2
13370
13856
  );
13371
13857
  }
13372
- daemon.server.attachLocalPairing(freshPairingUrl);
13373
- var url = serviceMode ? null : freshPairingUrl();
13858
+ daemon.server.attachLocalPairing(() => freshPairingUrl());
13859
+ var interactivePairing = !serviceMode && Boolean(process.stdin.isTTY && process.stdout.isTTY);
13860
+ var url = interactivePairing ? freshPairingUrl(true) : null;
13861
+ var pairingWatch = setInterval(() => {
13862
+ if (!foregroundChallenge) return;
13863
+ const result = daemon.registry.getPairingResult(foregroundChallenge);
13864
+ if (result) {
13865
+ console.log(`Paired ${result.deviceId}. Keep the QR private.`);
13866
+ foregroundChallenge = null;
13867
+ displayedAttempt = null;
13868
+ return;
13869
+ }
13870
+ if (!daemon.registry.hasPairingChallenge(foregroundChallenge)) {
13871
+ console.log("Pairing ended. Press n + Enter for a fresh QR.");
13872
+ foregroundChallenge = null;
13873
+ displayedAttempt = null;
13874
+ return;
13875
+ }
13876
+ const verification = daemon.registry.getPairingVerification(foregroundChallenge);
13877
+ if (verification && verification.attemptId !== displayedAttempt) {
13878
+ displayedAttempt = verification.attemptId;
13879
+ console.log(`
13880
+ Compare with your phone: ${verification.code}
13881
+ Type y + Enter only if every digit matches, or x + Enter to reject.
13882
+ `);
13883
+ }
13884
+ }, 250);
13885
+ pairingWatch.unref?.();
13374
13886
  console.log("\n=== LongLeash ===\n");
13375
13887
  var warn = vpnWarning();
13376
13888
  if (warn) console.log(`!!! ${warn}
@@ -13414,23 +13926,28 @@ Listening on ${servedHost}:${daemon.port} (no local network \u2014 relay only)`
13414
13926
  console.log(
13415
13927
  relay !== null ? `Relay: ${relay.url} \u2014 your phone can reach this laptop from anywhere.` : "Relay: not configured (LAN only). Set LONGLEASH_RELAY_URL once to enable remote access \u2014 it is remembered."
13416
13928
  );
13417
- console.log("\nScan this with your phone, then add it to your home screen:\n");
13418
- console.log(terminalQr(url));
13419
- console.log(`
13929
+ if (url) {
13930
+ console.log("\nScan this with your phone, then compare both verification codes. Keep this terminal open:\n");
13931
+ console.log(terminalQr(url));
13932
+ console.log(`
13420
13933
  ${url}
13421
13934
  `);
13422
- if (relay !== null && servedOnLan) {
13423
- console.log(`(LAN fallback for pairing at home: http://${servedHost}:${daemon.port}/#c=\u2026&s=\u2026 \u2014 same code)`);
13424
- }
13935
+ if (relay !== null && servedOnLan) {
13936
+ console.log("Use this same QR for the encrypted relay pairing flow.");
13937
+ }
13938
+ } else console.log("Run longleash pair in an interactive terminal to pair. No QR is printed to redirected output.");
13425
13939
  }
13426
13940
  var NETWORK_WATCH_MS = 5e3;
13941
+ var networkRebindInFlight = false;
13427
13942
  var networkWatch = setInterval(() => {
13943
+ if (networkRebindInFlight) return;
13428
13944
  const nextBest = findCandidates()[0];
13429
13945
  const nextHost = nextBest?.address ?? "127.0.0.1";
13430
13946
  if (nextHost === servingHost) return;
13431
13947
  const was = servingHost;
13432
- servingHost = nextHost;
13433
- void daemon.server.rebind(nextHost).then((port) => {
13948
+ networkRebindInFlight = true;
13949
+ void daemon.rebindLocal(nextHost).then((port) => {
13950
+ servingHost = nextHost;
13434
13951
  servingPort = port;
13435
13952
  console.log(serviceMode ? "\nNetwork changed; the local listener recovered." : `
13436
13953
  Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
@@ -13441,6 +13958,8 @@ Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
13441
13958
  console.log(serviceMode ? "\nNetwork changed but local listener recovery failed; details omitted." : `
13442
13959
  Network changed but rebinding failed (${String(err)}).`);
13443
13960
  console.log(relay !== null ? "The relay is unaffected." : "Restart the daemon to recover.");
13961
+ }).finally(() => {
13962
+ networkRebindInFlight = false;
13444
13963
  });
13445
13964
  }, NETWORK_WATCH_MS);
13446
13965
  networkWatch.unref?.();
@@ -13448,20 +13967,38 @@ if (!serviceMode) console.log("Press n + Enter for a fresh pairing QR, r + Enter
13448
13967
  process.stdin.setEncoding("utf8");
13449
13968
  process.stdin.on("data", (chunk) => {
13450
13969
  const key = chunk.trim().toLowerCase();
13451
- if (key === "n" && !serviceMode) {
13452
- const next = freshPairingUrl();
13970
+ if (key === "n" && interactivePairing) {
13971
+ let next;
13972
+ try {
13973
+ next = freshPairingUrl(true);
13974
+ } catch {
13975
+ console.log("Too many pending QRs. Wait for expiry before trying again.");
13976
+ return;
13977
+ }
13453
13978
  console.log("\nScan this with your phone:\n");
13454
13979
  console.log(terminalQr(next));
13455
13980
  console.log(`
13456
13981
  ${next}
13457
13982
  `);
13458
13983
  }
13984
+ if ((key === "y" || key === "x") && foregroundChallenge && displayedAttempt && interactivePairing) {
13985
+ try {
13986
+ if (key === "y") {
13987
+ daemon.registry.confirmPairingLocally(foregroundChallenge, displayedAttempt);
13988
+ console.log("Laptop confirmed. Choose Codes match on your phone to finish.");
13989
+ } else daemon.registry.cancelPairingChallenge(foregroundChallenge);
13990
+ } catch {
13991
+ console.log("That pairing ended. Press n + Enter for a fresh QR.");
13992
+ }
13993
+ }
13459
13994
  if (key === "r") {
13460
13995
  const active = daemon.registry.listDevices().filter((d) => d.revokedAt === null);
13461
13996
  for (const device of active) daemon.registry.revokeDevice(device.deviceId);
13462
13997
  console.log(`>>> revoked ${active.length} device(s); their live connections are cut`);
13463
13998
  }
13464
13999
  if (key === "q") {
14000
+ clearInterval(pairingWatch);
14001
+ for (const dispose of relayPairings) dispose();
13465
14002
  clearInterval(networkWatch);
13466
14003
  void daemon.stop().then(() => {
13467
14004
  instanceLock.release();
@@ -13470,6 +14007,8 @@ process.stdin.on("data", (chunk) => {
13470
14007
  }
13471
14008
  });
13472
14009
  var shutdown = () => {
14010
+ clearInterval(pairingWatch);
14011
+ for (const dispose of relayPairings) dispose();
13473
14012
  clearInterval(networkWatch);
13474
14013
  void daemon.stop().then(() => {
13475
14014
  instanceLock.release();