@longleash/cli 0.1.0-rc.10 → 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"]);
@@ -5496,6 +5569,8 @@ var sessionStartedPayload = external_exports.object({
5496
5569
  origin: SessionOrigin.optional(),
5497
5570
  /** Who owns the process now; unlike origin, this changes after a safe handoff. */
5498
5571
  controller: external_exports.enum(["longleash", "external"]).optional(),
5572
+ /** Where the process is controlled now; origin remains its historical birthplace. */
5573
+ surface: SessionOrigin.optional(),
5499
5574
  /** Observation-only means the native surface is visible but has not exposed process control. */
5500
5575
  control: external_exports.enum(["full", "observe"]).optional(),
5501
5576
  /**
@@ -5531,6 +5606,8 @@ var sessionStatusPayload = external_exports.object({
5531
5606
  /** LongLeash's own gate for this session: whether it should page the phone at all. */
5532
5607
  gate: SessionGate.optional(),
5533
5608
  controller: external_exports.enum(["longleash", "external"]).optional(),
5609
+ /** Current UI/process surface after a handoff. */
5610
+ surface: SessionOrigin.optional(),
5534
5611
  /** Observation-only sessions are never presented as remotely stoppable or writable. */
5535
5612
  control: external_exports.enum(["full", "observe"]).optional(),
5536
5613
  /** Current overrides. An empty object explicitly means provider defaults. */
@@ -5547,6 +5624,13 @@ var streamDeltaPayload = external_exports.object({
5547
5624
  kind: external_exports.enum(["text", "tool", "thinking", "user"]),
5548
5625
  text: external_exports.string()
5549
5626
  }).passthrough();
5627
+ var transcriptResetPayload = external_exports.object({
5628
+ reason: external_exports.enum(["provider-snapshot", "recovery"]),
5629
+ blocks: external_exports.array(external_exports.object({
5630
+ kind: external_exports.enum(["text", "tool", "thinking", "user"]),
5631
+ text: external_exports.string().max(12e4)
5632
+ }).strict()).max(80).optional()
5633
+ }).passthrough();
5550
5634
  var QuestionOption = external_exports.object({
5551
5635
  label: external_exports.string().min(1),
5552
5636
  description: external_exports.string().default(""),
@@ -5606,6 +5690,7 @@ function event(type, payload) {
5606
5690
  var SessionEventSchema = external_exports.discriminatedUnion("type", [
5607
5691
  event("session.started", sessionStartedPayload),
5608
5692
  event("session.status", sessionStatusPayload),
5693
+ event("session.transcript.reset", transcriptResetPayload),
5609
5694
  event("stream.delta", streamDeltaPayload),
5610
5695
  event("approval.requested", approvalRequestedPayload),
5611
5696
  event("approval.decided", approvalDecidedPayload),
@@ -5709,6 +5794,20 @@ var takeOverMessage = external_exports.object({
5709
5794
  sessionId: external_exports.string().min(1),
5710
5795
  text: external_exports.string().min(1)
5711
5796
  }).passthrough();
5797
+ var reclaimSessionMessage = external_exports.object({
5798
+ ...clientBase,
5799
+ type: external_exports.literal("reclaimSession"),
5800
+ sessionId: external_exports.string().min(1)
5801
+ }).passthrough();
5802
+ var renameSessionMessage = external_exports.object({
5803
+ ...clientBase,
5804
+ type: external_exports.literal("renameSession"),
5805
+ sessionId: external_exports.string().min(1),
5806
+ title: external_exports.string().trim().min(1).max(80).refine(
5807
+ (value) => value.replace(/[\u0000-\u001f\u007f]/g, "").trim().length > 0,
5808
+ "A session name needs visible text."
5809
+ )
5810
+ }).passthrough();
5712
5811
  var previewDelegationMessage = external_exports.object({
5713
5812
  ...clientBase,
5714
5813
  type: external_exports.literal("previewDelegation"),
@@ -5851,6 +5950,8 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
5851
5950
  pushUnsubscribeMessage,
5852
5951
  pushTestMessage,
5853
5952
  takeOverMessage,
5953
+ reclaimSessionMessage,
5954
+ renameSessionMessage,
5854
5955
  setGateMessage,
5855
5956
  previewDelegationMessage,
5856
5957
  startDelegationMessage,
@@ -5884,7 +5985,12 @@ var EventLog = class {
5884
5985
  type TEXT NOT NULL,
5885
5986
  payload TEXT NOT NULL,
5886
5987
  PRIMARY KEY (session_id, seq)
5887
- )
5988
+ );
5989
+ CREATE TABLE IF NOT EXISTS session_aliases (
5990
+ session_id TEXT PRIMARY KEY,
5991
+ title TEXT NOT NULL,
5992
+ updated_at INTEGER NOT NULL
5993
+ );
5888
5994
  `);
5889
5995
  this.now = opts.now ?? Date.now;
5890
5996
  this.insertStmt = this.rawDb.prepare(
@@ -5956,6 +6062,16 @@ var EventLog = class {
5956
6062
  latestTimestamp(sessionId) {
5957
6063
  return this.latestTsStmt.get(sessionId).ts;
5958
6064
  }
6065
+ aliasFor(sessionId) {
6066
+ const row = this.rawDb.prepare("SELECT title FROM session_aliases WHERE session_id = ?").get(sessionId);
6067
+ return row?.title;
6068
+ }
6069
+ setAlias(sessionId, title) {
6070
+ this.rawDb.prepare(
6071
+ `INSERT INTO session_aliases (session_id, title, updated_at) VALUES (?, ?, ?)
6072
+ ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at`
6073
+ ).run(sessionId, title, this.now());
6074
+ }
5959
6075
  pruneBefore(sessionId, uptoExclusive) {
5960
6076
  this.pruneStmt.run(sessionId, uptoExclusive);
5961
6077
  }
@@ -5998,6 +6114,13 @@ var completePairingInput = external_exports.object({
5998
6114
  deviceName: external_exports.string().min(1).max(64),
5999
6115
  publicKey: external_exports.string().min(1).optional()
6000
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();
6001
6124
  var id = (prefix) => `${prefix}_${randomBytes(12).toString("base64url")}`;
6002
6125
  var sha256Hex = (value) => createHash("sha256").update(value).digest("hex");
6003
6126
  var hashesMatch = (a, b) => timingSafeEqual(Buffer.from(a, "hex"), Buffer.from(b, "hex"));
@@ -6007,6 +6130,7 @@ var DeviceRegistry = class {
6007
6130
  challengeTtlMs;
6008
6131
  // Ephemeral by design: a daemon restart voids pending pairing QR codes.
6009
6132
  challenges = /* @__PURE__ */ new Map();
6133
+ pairingResults = /* @__PURE__ */ new Map();
6010
6134
  revokedListeners = /* @__PURE__ */ new Set();
6011
6135
  pairedListeners = /* @__PURE__ */ new Set();
6012
6136
  constructor(path, opts = {}) {
@@ -6029,17 +6153,22 @@ var DeviceRegistry = class {
6029
6153
  this.now = opts.now ?? Date.now;
6030
6154
  this.challengeTtlMs = opts.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS;
6031
6155
  }
6032
- createPairingChallenge() {
6156
+ createPairingChallenge(opts = {}) {
6033
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
+ }
6034
6163
  const challengeId = id("chl");
6035
6164
  const secret = randomBytes(32).toString("base64url");
6036
6165
  const expiresAt = this.now() + this.challengeTtlMs;
6037
- this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt });
6166
+ this.challenges.set(challengeId, { secretHash: sha256Hex(secret), expiresAt, version });
6038
6167
  return {
6039
6168
  challengeId,
6040
6169
  secret,
6041
6170
  expiresAt,
6042
- qrPayload: JSON.stringify({ v: PAIRING_QR_VERSION, challengeId, secret })
6171
+ qrPayload: JSON.stringify({ v: version, challengeId, secret })
6043
6172
  };
6044
6173
  }
6045
6174
  completePairing(raw) {
@@ -6048,24 +6177,114 @@ var DeviceRegistry = class {
6048
6177
  throw new PairingError("invalid-input", parsed.error.message);
6049
6178
  }
6050
6179
  const input = parsed.data;
6051
- const challenge = this.challenges.get(input.challengeId);
6052
- if (!challenge) {
6053
- 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");
6054
6183
  }
6055
- 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
+ }
6056
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);
6057
6270
  throw new PairingError("expired", "Pairing challenge expired \u2014 generate a new QR code");
6058
6271
  }
6059
- 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)) {
6060
6277
  throw new PairingError("bad-secret", "Wrong pairing secret");
6061
6278
  }
6062
- this.challenges.delete(input.challengeId);
6279
+ return challenge;
6280
+ }
6281
+ commitDevice(deviceName, publicKey) {
6063
6282
  const token = `llt_${randomBytes(32).toString("base64url")}`;
6064
6283
  const relaySecret = randomBytes(32).toString("base64url");
6065
6284
  const device = {
6066
6285
  deviceId: id("dev"),
6067
- name: input.deviceName,
6068
- publicKey: input.publicKey ?? null,
6286
+ name: deviceName,
6287
+ publicKey: publicKey ?? null,
6069
6288
  createdAt: this.now(),
6070
6289
  lastSeenAt: null,
6071
6290
  revokedAt: null
@@ -6133,12 +6352,15 @@ var DeviceRegistry = class {
6133
6352
  return rows.map((row) => this.toDevice(row));
6134
6353
  }
6135
6354
  close() {
6355
+ this.challenges.clear();
6356
+ this.pairingResults.clear();
6136
6357
  this.rawDb.close();
6137
6358
  }
6138
6359
  sweepExpiredChallenges() {
6139
6360
  const now = this.now();
6361
+ for (const [id2, result] of this.pairingResults) if (now >= result.expiresAt) this.pairingResults.delete(id2);
6140
6362
  for (const [challengeId, challenge] of this.challenges) {
6141
- if (now > challenge.expiresAt) this.challenges.delete(challengeId);
6363
+ if (now >= challenge.expiresAt) this.challenges.delete(challengeId);
6142
6364
  }
6143
6365
  }
6144
6366
  toDevice(row) {
@@ -7109,6 +7331,26 @@ var SessionManager = class {
7109
7331
  };
7110
7332
  });
7111
7333
  }
7334
+ /** Find the stable LongLeash card that already owns this provider conversation id. */
7335
+ sessionIdForAgentSession(agentSessionId) {
7336
+ const row = this.approvals.rawDb.prepare("SELECT session_id FROM sessions WHERE agent_session_id = ? ORDER BY started_at DESC LIMIT 1").get(agentSessionId);
7337
+ return row?.session_id;
7338
+ }
7339
+ /** Persist a human label independently of whatever title a provider derives later. */
7340
+ renameSession(sessionId, title, emit = true) {
7341
+ const changed = this.approvals.rawDb.prepare("UPDATE sessions SET title = ? WHERE session_id = ?").run(title, sessionId).changes;
7342
+ if (changed === 0) return false;
7343
+ const live = this.sessions.get(sessionId);
7344
+ if (live !== void 0) live.title = title;
7345
+ this.eventLog.setAlias(sessionId, title);
7346
+ if (emit) {
7347
+ this.emit(sessionId, {
7348
+ type: "session.status",
7349
+ payload: { status: live?.status ?? "waiting", live: live !== void 0, title }
7350
+ });
7351
+ }
7352
+ return true;
7353
+ }
7112
7354
  /**
7113
7355
  * Adopt a finished TERMINAL session so a phone reply can wake it: the
7114
7356
  * conversation lives in Claude Code's own storage, keyed by its resume id,
@@ -7821,7 +8063,7 @@ import { join as join2 } from "node:path";
7821
8063
  import Fastify from "fastify";
7822
8064
  import fastifyStatic from "@fastify/static";
7823
8065
  import websocket from "@fastify/websocket";
7824
- import { randomBytes as randomBytes5, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
8066
+ import { randomBytes as randomBytes6, timingSafeEqual as timingSafeEqual2 } from "node:crypto";
7825
8067
 
7826
8068
  // ../daemon/src/external.ts
7827
8069
  import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs";
@@ -7880,6 +8122,7 @@ var ExternalSessions = class {
7880
8122
  stopTimeoutMs;
7881
8123
  onEnded;
7882
8124
  workspace;
8125
+ resolveSessionId;
7883
8126
  unsubscribeWorkspace;
7884
8127
  constructor(opts) {
7885
8128
  this.eventLog = opts.eventLog;
@@ -7899,6 +8142,7 @@ var ExternalSessions = class {
7899
8142
  this.waitForExit = opts.waitForExit ?? ((pid, timeoutMs) => this.pollForExit(pid, timeoutMs));
7900
8143
  this.onEnded = opts.onEnded;
7901
8144
  this.workspace = opts.workspace;
8145
+ this.resolveSessionId = opts.resolveSessionId;
7902
8146
  this.unsubscribeWorkspace = this.workspace?.subscribe(() => this.reconcileWorkspace());
7903
8147
  for (const orphan of this.approvals.closeOrphans(
7904
8148
  "The daemon restarted; this was answered in the terminal."
@@ -7922,6 +8166,38 @@ var ExternalSessions = class {
7922
8166
  hasLiveSession(sessionId) {
7923
8167
  return [...this.sessions.values()].some((session) => session.sessionId === sessionId);
7924
8168
  }
8169
+ renameSession(sessionId, title) {
8170
+ const entry = [...this.sessions.entries()].find(([, session2]) => session2.sessionId === sessionId);
8171
+ if (entry === void 0) return false;
8172
+ const [agentSessionId, session] = entry;
8173
+ session.title = title;
8174
+ session.named = true;
8175
+ this.eventLog.setAlias(sessionId, title);
8176
+ this.persist(agentSessionId, session);
8177
+ this.emit(sessionId, {
8178
+ type: "session.status",
8179
+ payload: {
8180
+ status: session.status,
8181
+ live: true,
8182
+ title,
8183
+ controller: "external",
8184
+ control: session.observedOnly ? "observe" : "full",
8185
+ surface: session.surface
8186
+ }
8187
+ });
8188
+ return true;
8189
+ }
8190
+ /**
8191
+ * End a native writer so the same conversation can be parked on the phone.
8192
+ * Observation-only sessions have no authenticated PID and are never remotely terminated.
8193
+ */
8194
+ async reclaimToPhone(externalSessionId, decidedBy) {
8195
+ const session = [...this.sessions.values()].find((item) => item.sessionId === externalSessionId);
8196
+ if (session === void 0) return "not-found";
8197
+ if (session.observedOnly) return "observe-only";
8198
+ if (session.pid === null) return "unverified";
8199
+ return await this.stop(externalSessionId, decidedBy) ? "reclaimed" : "unverified";
8200
+ }
7925
8201
  /**
7926
8202
  * Take back the sessions a previous run was watching.
7927
8203
  *
@@ -7950,7 +8226,7 @@ var ExternalSessions = class {
7950
8226
  transcriptPath: row.transcriptPath,
7951
8227
  status: "running",
7952
8228
  startedAt: row.startedAt,
7953
- title: row.title,
8229
+ title: this.eventLog.aliasFor(row.sessionId) ?? row.title,
7954
8230
  pid: row.pid,
7955
8231
  named: true,
7956
8232
  permissionMode: null,
@@ -7968,7 +8244,13 @@ var ExternalSessions = class {
7968
8244
  session.timer.unref?.();
7969
8245
  this.emit(session.sessionId, {
7970
8246
  type: "session.status",
7971
- payload: { status: "running", live: true }
8247
+ payload: {
8248
+ status: "running",
8249
+ live: true,
8250
+ controller: "external",
8251
+ control: "full",
8252
+ surface: session.surface
8253
+ }
7972
8254
  });
7973
8255
  }
7974
8256
  }
@@ -7984,22 +8266,35 @@ var ExternalSessions = class {
7984
8266
  /** Adopt a Codex transcript observed after startup, including sessions older than the hooks. */
7985
8267
  observeCodexSession(info) {
7986
8268
  const session = this.ensure(info.sessionId, info.cwd, info.transcriptPath, "codex", info.surface, true);
7987
- if (info.title !== void 0 && info.title !== session.title) {
8269
+ const alias = this.eventLog.aliasFor(session.sessionId);
8270
+ const nextTitle = alias ?? info.title;
8271
+ if (nextTitle !== void 0 && nextTitle !== session.title) {
7988
8272
  session.named = true;
7989
- session.title = info.title;
8273
+ session.title = nextTitle;
7990
8274
  this.persist(info.sessionId, session);
7991
8275
  }
8276
+ if (session.observedOnly && session.observedFingerprint === void 0) {
8277
+ session.observedFingerprint = JSON.stringify(info.snapshot);
8278
+ this.emit(session.sessionId, {
8279
+ type: "session.transcript.reset",
8280
+ payload: { reason: "provider-snapshot", blocks: info.snapshot }
8281
+ });
8282
+ }
7992
8283
  this.emit(session.sessionId, {
7993
8284
  type: "session.status",
7994
8285
  payload: {
7995
8286
  status: session.status,
7996
8287
  live: true,
7997
8288
  controller: "external",
7998
- control: "observe",
7999
- ...info.title === void 0 ? {} : { title: info.title }
8289
+ control: session.observedOnly ? "observe" : "full",
8290
+ ...nextTitle === void 0 ? {} : { title: nextTitle }
8000
8291
  }
8001
8292
  });
8002
- this.claimWorkspace(session);
8293
+ if (session.observedOnly) {
8294
+ if (session.workspaceConflict !== void 0) delete session.workspaceConflict;
8295
+ } else {
8296
+ this.claimWorkspace(session);
8297
+ }
8003
8298
  }
8004
8299
  /**
8005
8300
  * Stop a terminal session from the phone — for real. The recorded pid is only
@@ -8415,14 +8710,26 @@ var ExternalSessions = class {
8415
8710
  this.persist(claudeSessionId, existing);
8416
8711
  this.emit(existing.sessionId, {
8417
8712
  type: "session.status",
8418
- payload: { status: existing.status, live: true, controller: "external", control: "full" }
8713
+ payload: {
8714
+ status: existing.status,
8715
+ live: true,
8716
+ controller: "external",
8717
+ control: "full",
8718
+ surface: existing.surface
8719
+ }
8419
8720
  });
8721
+ if (existing.timer === null) {
8722
+ existing.timer = setInterval(() => this.drain(existing), this.pollMs);
8723
+ existing.timer.unref?.();
8724
+ this.drain(existing);
8725
+ }
8420
8726
  }
8421
8727
  return existing;
8422
8728
  }
8423
- const sessionId = `ext_${claudeSessionId}`;
8729
+ const sessionId = this.resolveSessionId?.(claudeSessionId) ?? `ext_${claudeSessionId}`;
8424
8730
  const replay = this.eventLog.replay(sessionId, 0);
8425
8731
  const hasHistory = replay.gap === false ? replay.events.length > 0 : true;
8732
+ const alias = this.eventLog.aliasFor(sessionId);
8426
8733
  const session = {
8427
8734
  sessionId,
8428
8735
  agent,
@@ -8431,9 +8738,9 @@ var ExternalSessions = class {
8431
8738
  transcriptPath,
8432
8739
  status: "running",
8433
8740
  startedAt: this.now(),
8434
- title: `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
8741
+ title: alias ?? `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
8435
8742
  pid: null,
8436
- named: false,
8743
+ named: alias !== void 0,
8437
8744
  permissionMode: null,
8438
8745
  gate: "ask",
8439
8746
  offset: (tailOnly || hasHistory) && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
@@ -8454,6 +8761,7 @@ var ExternalSessions = class {
8454
8761
  origin: surface,
8455
8762
  controller: "external",
8456
8763
  control: tailOnly ? "observe" : "full",
8764
+ surface,
8457
8765
  resumeId: claudeSessionId
8458
8766
  }
8459
8767
  });
@@ -8464,7 +8772,8 @@ var ExternalSessions = class {
8464
8772
  status: "running",
8465
8773
  live: true,
8466
8774
  controller: "external",
8467
- control: tailOnly ? "observe" : "full"
8775
+ control: tailOnly ? "observe" : "full",
8776
+ surface
8468
8777
  }
8469
8778
  });
8470
8779
  session.timer = setInterval(() => this.drain(session), this.pollMs);
@@ -8474,7 +8783,9 @@ var ExternalSessions = class {
8474
8783
  }
8475
8784
  /** Retry claims after startup reconciliation released dead managed-session ownership. */
8476
8785
  reconcileWorkspace() {
8477
- for (const session of this.sessions.values()) this.claimWorkspace(session);
8786
+ for (const session of this.sessions.values()) {
8787
+ if (!session.observedOnly) this.claimWorkspace(session);
8788
+ }
8478
8789
  }
8479
8790
  claimWorkspace(session) {
8480
8791
  if (this.workspace === void 0 || session.cwd.trim() === "") return;
@@ -8674,6 +8985,139 @@ function transcriptDeltas(line) {
8674
8985
  return out;
8675
8986
  }
8676
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
+
8677
9121
  // ../daemon/src/relay-link.ts
8678
9122
  import WebSocket from "ws";
8679
9123
  function withRoom(endpoint, roomTag, role) {
@@ -9038,7 +9482,7 @@ var BriefingBuilder = class {
9038
9482
  };
9039
9483
 
9040
9484
  // ../daemon/src/delegations.ts
9041
- import { randomBytes as randomBytes4 } from "node:crypto";
9485
+ import { randomBytes as randomBytes5 } from "node:crypto";
9042
9486
  var DelegationError = class extends Error {
9043
9487
  constructor(reason, message) {
9044
9488
  super(message);
@@ -9048,7 +9492,7 @@ var DelegationError = class extends Error {
9048
9492
  };
9049
9493
  var TARGET_AGENTS = /* @__PURE__ */ new Set(["claude", "codex"]);
9050
9494
  var TERMINAL = /* @__PURE__ */ new Set(["returned", "cancelled", "failed"]);
9051
- var newId3 = () => `del_${randomBytes4(9).toString("base64url")}`;
9495
+ var newId3 = () => `del_${randomBytes5(9).toString("base64url")}`;
9052
9496
  var DelegationStore = class {
9053
9497
  db;
9054
9498
  now;
@@ -10024,6 +10468,10 @@ var LongLeashServer = class {
10024
10468
  delegations = null;
10025
10469
  hookSecret = null;
10026
10470
  localPairing = null;
10471
+ pairingChallenges = /* @__PURE__ */ new Map();
10472
+ pairingSockets = /* @__PURE__ */ new Set();
10473
+ pairingRequests = [];
10474
+ pairingCleanups = /* @__PURE__ */ new Set();
10027
10475
  staticRoot;
10028
10476
  /** Captured at process start so updating files on disk cannot impersonate a daemon restart. */
10029
10477
  expectedBuild;
@@ -10050,6 +10498,7 @@ var LongLeashServer = class {
10050
10498
  ok: true,
10051
10499
  name: "longleash",
10052
10500
  protocol: PROTOCOL_VERSION,
10501
+ pairingVersion: 2,
10053
10502
  // A version is not machine data. Exposing it turns "connection refused / is this old?"
10054
10503
  // from guesswork into a doctor check and prevents testing a daemon that never restarted.
10055
10504
  build: this.expectedBuild
@@ -10125,7 +10574,7 @@ var LongLeashServer = class {
10125
10574
  });
10126
10575
  const localOnly = (request) => {
10127
10576
  const presented = request.headers["x-longleash-hook"];
10128
- 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));
10129
10578
  };
10130
10579
  this.app.get("/devices", async (request, reply) => {
10131
10580
  if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
@@ -10153,13 +10602,82 @@ var LongLeashServer = class {
10153
10602
  });
10154
10603
  this.app.post("/local/pairing", async (request, reply) => {
10155
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());
10156
10608
  if (this.localPairing === null) return reply.code(503).send({ reason: "pairing-unavailable" });
10157
10609
  try {
10158
- 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 };
10159
10614
  } catch {
10160
10615
  return reply.code(503).send({ reason: "pairing-failed" });
10161
10616
  }
10162
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
+ });
10163
10681
  this.app.get("/pair", async (_request, reply) => reply.code(405).send({ reason: "use-post" }));
10164
10682
  this.app.post("/pair", async (request, reply) => {
10165
10683
  const query2 = request.query;
@@ -10265,7 +10783,7 @@ var LongLeashServer = class {
10265
10783
  */
10266
10784
  async rebind(host) {
10267
10785
  const wanted = this.boundPort;
10268
- await new Promise((resolve7) => this.app.server.close(() => resolve7()));
10786
+ if (this.app.server.listening) await new Promise((resolve7) => this.app.server.close(() => resolve7()));
10269
10787
  const tryListen = (port) => new Promise((resolve7, reject) => {
10270
10788
  const onError = (err) => {
10271
10789
  this.app.server.removeListener("listening", onListening);
@@ -10302,6 +10820,21 @@ var LongLeashServer = class {
10302
10820
  attachLocalPairing(create) {
10303
10821
  this.localPairing = create;
10304
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
+ }
10305
10838
  /** Persist an event, then fan it out to every subscriber of that session. */
10306
10839
  publish(sessionId, input) {
10307
10840
  const event2 = this.eventLog.append(sessionId, input);
@@ -10322,6 +10855,9 @@ var LongLeashServer = class {
10322
10855
  return this.connections.size;
10323
10856
  }
10324
10857
  async close() {
10858
+ for (const cleanup of this.pairingCleanups) cleanup();
10859
+ for (const socket of this.pairingSockets) socket.terminate();
10860
+ this.pairingChallenges.clear();
10325
10861
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
10326
10862
  this.heartbeatTimer = null;
10327
10863
  this.unsubscribeRevoked?.();
@@ -10412,6 +10948,9 @@ var LongLeashServer = class {
10412
10948
  }
10413
10949
  /** Tell the client what it may do, so it never has to guess a project path. */
10414
10950
  sendHello(connection) {
10951
+ const bySession = new Map(
10952
+ [...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []].map((session) => [session.sessionId, session])
10953
+ );
10415
10954
  this.sendTo(connection, {
10416
10955
  v: PROTOCOL_VERSION,
10417
10956
  type: "hello",
@@ -10419,8 +10958,9 @@ var LongLeashServer = class {
10419
10958
  roots: this.sessions?.listAllowedRoots() ?? [],
10420
10959
  // Everything the daemon knows about, so a reloaded phone rebuilds instead of showing
10421
10960
  // nothing — including sessions the human started in a terminal.
10422
- sessions: [...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []].map((session) => ({
10961
+ sessions: [...bySession.values()].map((session) => ({
10423
10962
  ...session,
10963
+ title: this.eventLog.aliasFor(session.sessionId) ?? session.title,
10424
10964
  lastActivityAt: this.eventLog.latestTimestamp(session.sessionId) || session.startedAt
10425
10965
  })).sort((left, right) => left.startedAt - right.startedAt || left.sessionId.localeCompare(right.sessionId)),
10426
10966
  capabilities: {
@@ -10794,8 +11334,60 @@ var LongLeashServer = class {
10794
11334
  });
10795
11335
  return;
10796
11336
  }
11337
+ if (message.type === "renameSession") {
11338
+ const title = message.title.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
11339
+ const externalRenamed = this.external?.renameSession(message.sessionId, title) === true;
11340
+ const managedRenamed = this.sessions.renameSession(message.sessionId, title, !externalRenamed);
11341
+ const renamed = externalRenamed || managedRenamed;
11342
+ this.log(`rename ${message.sessionId} -> ${renamed ? "renamed" : "unknown"}`);
11343
+ this.sendTo(connection, {
11344
+ v: PROTOCOL_VERSION,
11345
+ type: "ack",
11346
+ of: "renameSession",
11347
+ sessionId: message.sessionId,
11348
+ outcome: renamed ? "renamed" : "unknown-session"
11349
+ });
11350
+ if (!renamed) {
11351
+ this.sendTo(connection, {
11352
+ v: PROTOCOL_VERSION,
11353
+ type: "error",
11354
+ code: "unknown-session",
11355
+ message: "This conversation is no longer known to the laptop, so its name was not changed."
11356
+ });
11357
+ }
11358
+ return;
11359
+ }
11360
+ if (message.type === "reclaimSession") {
11361
+ const sessionManager = this.sessions;
11362
+ void (async () => {
11363
+ const outcome = await this.external?.reclaimToPhone(message.sessionId, connection.deviceId) ?? "not-found";
11364
+ let reopened = false;
11365
+ if (outcome === "reclaimed") {
11366
+ reopened = await sessionManager.resumeSession(message.sessionId, connection.deviceId);
11367
+ }
11368
+ const success = outcome === "reclaimed" && reopened;
11369
+ this.log(`reclaim ${message.sessionId} -> ${success ? "phone-ready" : outcome}`);
11370
+ this.sendTo(connection, {
11371
+ v: PROTOCOL_VERSION,
11372
+ type: "ack",
11373
+ of: "reclaimSession",
11374
+ sessionId: message.sessionId,
11375
+ outcome: success ? "phone-ready" : outcome
11376
+ });
11377
+ if (!success) {
11378
+ const detail = outcome === "observe-only" ? "This VS Code conversation is visible, but its lifecycle hook has not provided a verified process handle. Stop it in VS Code; LongLeash will make the same conversation reopenable when SessionEnd arrives." : outcome === "unverified" ? "LongLeash could not verify that the Terminal or VS Code writer exited, so it kept control there. Nothing was started on the phone." : outcome === "reclaimed" ? "The native writer ended, but this provider did not expose a recoverable conversation point. Nothing new was started." : "This native session is no longer connected to the laptop daemon.";
11379
+ this.sendTo(connection, {
11380
+ v: PROTOCOL_VERSION,
11381
+ type: "error",
11382
+ code: "cannot-reclaim",
11383
+ message: detail
11384
+ });
11385
+ }
11386
+ })();
11387
+ return;
11388
+ }
10797
11389
  if (message.type === "setGate") {
10798
- const applied = this.external !== null && message.sessionId.startsWith("ext_") ? this.external.setGate(message.sessionId, message.gate) : false;
11390
+ const applied = this.external !== null && this.external.hasLiveSession(message.sessionId) ? this.external.setGate(message.sessionId, message.gate) : false;
10799
11391
  this.log(`gate ${message.sessionId} -> ${message.gate}${applied ? "" : " (unknown session)"}`);
10800
11392
  this.sendTo(connection, {
10801
11393
  v: PROTOCOL_VERSION,
@@ -10873,7 +11465,7 @@ var LongLeashServer = class {
10873
11465
  return;
10874
11466
  }
10875
11467
  if (message.type === "takeOver") {
10876
- const reservationId = `takeover-${randomBytes5(9).toString("base64url")}`;
11468
+ const reservationId = `takeover-${randomBytes6(9).toString("base64url")}`;
10877
11469
  const sessions = this.sessions;
10878
11470
  void (async () => {
10879
11471
  const conflict = this.workspaceConflictMessage(message.sessionId);
@@ -10933,7 +11525,7 @@ var LongLeashServer = class {
10933
11525
  return;
10934
11526
  }
10935
11527
  if (message.type === "stopSession") {
10936
- if (message.sessionId.startsWith("ext_") && this.external !== null) {
11528
+ if (this.external?.hasLiveSession(message.sessionId) === true) {
10937
11529
  const sessions = this.sessions;
10938
11530
  void this.external.stop(message.sessionId, connection.deviceId).then((stopped) => {
10939
11531
  if (stopped) {
@@ -12325,8 +12917,11 @@ function inside(root, candidate) {
12325
12917
  }
12326
12918
  function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
12327
12919
  let size;
12920
+ let activityAt;
12328
12921
  try {
12329
- size = statSync2(path).size;
12922
+ const stats = statSync2(path);
12923
+ size = stats.size;
12924
+ activityAt = stats.mtimeMs;
12330
12925
  } catch {
12331
12926
  return null;
12332
12927
  }
@@ -12345,6 +12940,7 @@ function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
12345
12940
  const lines = tailText.split("\n");
12346
12941
  if (tailOffset > 0) lines.shift();
12347
12942
  let title;
12943
+ const snapshot = [];
12348
12944
  for (let index = lines.length - 1; index >= 0; index -= 1) {
12349
12945
  const line = lines[index];
12350
12946
  if (!line?.includes('"role":"user"')) continue;
@@ -12359,8 +12955,31 @@ function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
12359
12955
  } catch {
12360
12956
  }
12361
12957
  }
12958
+ for (const line of lines) {
12959
+ if (line.length === 0 || line.length > 1e6) continue;
12960
+ try {
12961
+ for (const delta of transcriptDeltas(JSON.parse(line))) {
12962
+ if (delta.type !== "stream.delta" || delta.payload.text.trim() === "") continue;
12963
+ snapshot.push({ kind: delta.payload.kind, text: delta.payload.text });
12964
+ }
12965
+ } catch {
12966
+ }
12967
+ }
12968
+ let retained = snapshot.slice(-80);
12969
+ let characters = retained.reduce((sum, block) => sum + block.text.length, 0);
12970
+ while (retained.length > 1 && characters > 12e4) {
12971
+ characters -= retained.shift()?.text.length ?? 0;
12972
+ }
12362
12973
  if (title === void 0) title = titleFrom(latestUserText(tailText) ?? "") ?? void 0;
12363
- return { sessionId, cwd, transcriptPath: path, surface, ...title === void 0 ? {} : { title } };
12974
+ return {
12975
+ sessionId,
12976
+ cwd,
12977
+ transcriptPath: path,
12978
+ surface,
12979
+ activityAt,
12980
+ snapshot: retained,
12981
+ ...title === void 0 ? {} : { title }
12982
+ };
12364
12983
  } catch {
12365
12984
  return null;
12366
12985
  }
@@ -12384,7 +13003,7 @@ var CodexSessionWatcher = class {
12384
13003
  this.timer = null;
12385
13004
  }
12386
13005
  scan(initial = false) {
12387
- const root = this.options.sessionsRoot ?? join6(homedir3(), ".codex", "sessions");
13006
+ const root = this.options.sessionsRoot ?? join6(process.env.CODEX_HOME || join6(homedir3(), ".codex"), "sessions");
12388
13007
  const now = this.options.now?.() ?? Date.now();
12389
13008
  let observed = 0;
12390
13009
  for (const path of jsonlFiles(root)) {
@@ -12396,7 +13015,7 @@ var CodexSessionWatcher = class {
12396
13015
  }
12397
13016
  const previous = this.mtimes.get(path);
12398
13017
  this.mtimes.set(path, modified);
12399
- const changed = previous !== void 0 && modified > previous;
13018
+ const changed = previous === void 0 ? !initial : modified !== previous;
12400
13019
  const recentlyActive = initial && now - modified <= this.options.initialRecentMs;
12401
13020
  if (!changed && !recentlyActive) continue;
12402
13021
  const previousSession = this.known.get(path);
@@ -12593,6 +13212,7 @@ async function startDaemon(options) {
12593
13212
  registry: sessionRegistry,
12594
13213
  onEvent: mirror,
12595
13214
  workspace,
13215
+ resolveSessionId: (agentSessionId) => sessions.sessionIdForAgentSession(agentSessionId),
12596
13216
  // A live socket means the app is open and can answer in seconds. A push REGISTRATION is
12597
13217
  // permanent and proves nothing about whether anyone is looking — treating it as presence
12598
13218
  // is what froze the keyboard for two minutes with the phone in a drawer.
@@ -12617,7 +13237,7 @@ async function startDaemon(options) {
12617
13237
  external.startSweeping();
12618
13238
  const delegationStore = new DelegationStore(approvals.rawDb);
12619
13239
  workspace.reconcile({
12620
- activeSessionIds: external.listSessions().map((session) => session.sessionId),
13240
+ activeSessionIds: external.listSessions().filter((session) => session.control === "full").map((session) => session.sessionId),
12621
13241
  validReservationIds: delegationStore.list().filter((record) => record.status === "starting").map((record) => record.delegationId)
12622
13242
  });
12623
13243
  external.reconcileWorkspace();
@@ -12635,14 +13255,20 @@ async function startDaemon(options) {
12635
13255
  pauseSession: (session, actor, reason) => pauseCurrentSessionOwner(sessions, external, session, actor, reason)
12636
13256
  });
12637
13257
  server.attachDelegations(delegationManager);
12638
- const hookSecret = randomBytes6(24).toString("base64url");
13258
+ const hookSecret = randomBytes7(24).toString("base64url");
12639
13259
  server.attachExternal(external, hookSecret);
12640
13260
  const { port } = await server.listen();
12641
- writeFileSync2(
12642
- join7(dataDir2, "hook-endpoint.json"),
12643
- JSON.stringify({ url: `http://${options.host}:${port}/hook`, secret: hookSecret }, null, 2) + "\n",
12644
- { mode: 384 }
12645
- );
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);
12646
13272
  const stopMaintenance = sessions.startMaintenance();
12647
13273
  let bridge = null;
12648
13274
  if (options.relayUrl !== void 0) {
@@ -12652,6 +13278,11 @@ async function startDaemon(options) {
12652
13278
  }
12653
13279
  return {
12654
13280
  server,
13281
+ rebindLocal: async (host) => {
13282
+ const reboundPort = await server.rebind(host);
13283
+ publishHookEndpoint(host, reboundPort);
13284
+ return reboundPort;
13285
+ },
12655
13286
  sessions,
12656
13287
  registry,
12657
13288
  eventLog,
@@ -12711,7 +13342,62 @@ function save(path, config) {
12711
13342
  }
12712
13343
 
12713
13344
  // ../daemon/src/pairing-host.ts
13345
+ import WebSocket3 from "ws";
13346
+
13347
+ // ../daemon/src/pairing-relay-v2.ts
12714
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
12715
13401
  var CompletePairing = external_exports.object({
12716
13402
  v: external_exports.literal(1),
12717
13403
  type: external_exports.literal("completePairing"),
@@ -12720,6 +13406,7 @@ var CompletePairing = external_exports.object({
12720
13406
  deviceName: external_exports.string().min(1).max(64)
12721
13407
  });
12722
13408
  function hostPairing(opts) {
13409
+ if (JSON.parse(opts.challenge.qrPayload).v === 2) return hostVerifiedPairing(opts);
12723
13410
  const log = opts.log ?? (() => {
12724
13411
  });
12725
13412
  let socket = null;
@@ -12738,7 +13425,7 @@ function hostPairing(opts) {
12738
13425
  return;
12739
13426
  }
12740
13427
  if (disposed) return;
12741
- const ws = new WebSocket2(withRoom(opts.relayUrl, identity.roomTag, "host"));
13428
+ const ws = new WebSocket3(withRoom(opts.relayUrl, identity.roomTag, "host"));
12742
13429
  socket = ws;
12743
13430
  ws.on("open", () => {
12744
13431
  ws.send(JSON.stringify({ v: 1, type: "join", room: identity.roomTag, role: "host" }));
@@ -12781,7 +13468,7 @@ function hostPairing(opts) {
12781
13468
  reason: err instanceof PairingError ? err.reason : "error"
12782
13469
  };
12783
13470
  }
12784
- if (ws.readyState === WebSocket2.OPEN) {
13471
+ if (ws.readyState === WebSocket3.OPEN) {
12785
13472
  ws.send(JSON.stringify({ v: 1, type: "frame", payload: await seal(identity, JSON.stringify(reply)) }));
12786
13473
  }
12787
13474
  if (succeeded) setTimeout(dispose, 500);
@@ -12794,10 +13481,10 @@ function hostPairing(opts) {
12794
13481
  }
12795
13482
 
12796
13483
  // ../daemon/src/pairing-url.ts
12797
- function pairingUrl(appOrigin, challengeId, secret) {
13484
+ function pairingUrl(appOrigin, challengeId, secret, version = 1) {
12798
13485
  const url2 = new URL(appOrigin);
12799
13486
  url2.search = "";
12800
- url2.hash = new URLSearchParams({ c: challengeId, s: secret }).toString();
13487
+ url2.hash = new URLSearchParams({ c: challengeId, s: secret, ...version === 2 ? { v: "2" } : {} }).toString();
12801
13488
  return url2.toString();
12802
13489
  }
12803
13490
 
@@ -12945,7 +13632,7 @@ function noAddressReason(interfaces = networkInterfaces()) {
12945
13632
  }
12946
13633
 
12947
13634
  // ../daemon/src/instance-lock.ts
12948
- 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";
12949
13636
  import { randomUUID } from "node:crypto";
12950
13637
  import { join as join9 } from "node:path";
12951
13638
  var LOCK_NAME = "daemon.lock";
@@ -12979,7 +13666,7 @@ function acquireDaemonInstance(dataDir2) {
12979
13666
  if (processAlive(owner.pid)) {
12980
13667
  throw new Error(`LongLeash already owns this data directory in process ${owner.pid}. Use \`longleash service status\` before starting another instance.`);
12981
13668
  }
12982
- rmSync(path);
13669
+ rmSync2(path);
12983
13670
  create();
12984
13671
  }
12985
13672
  let held = true;
@@ -12990,7 +13677,7 @@ function acquireDaemonInstance(dataDir2) {
12990
13677
  held = false;
12991
13678
  try {
12992
13679
  const owner = JSON.parse(readFileSync5(path, "utf8"));
12993
- if (owner.token === token) rmSync(path);
13680
+ if (owner.token === token) rmSync2(path);
12994
13681
  } catch {
12995
13682
  }
12996
13683
  }
@@ -13141,20 +13828,61 @@ function relayAppOrigin(wsUrl) {
13141
13828
  parsed.pathname = "/";
13142
13829
  return parsed.toString();
13143
13830
  }
13144
- function freshPairingUrl() {
13145
- 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
+ }
13146
13842
  if (relay !== null) {
13147
- hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) => daemonLog(`[pair] ${line}`) });
13148
- 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);
13149
13850
  }
13150
13851
  return pairingUrl(
13151
13852
  `http://${servingHost}:${servingPort}/`,
13152
13853
  challenge.challengeId,
13153
- challenge.secret
13854
+ challenge.secret,
13855
+ 2
13154
13856
  );
13155
13857
  }
13156
- daemon.server.attachLocalPairing(freshPairingUrl);
13157
- 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?.();
13158
13886
  console.log("\n=== LongLeash ===\n");
13159
13887
  var warn = vpnWarning();
13160
13888
  if (warn) console.log(`!!! ${warn}
@@ -13198,23 +13926,28 @@ Listening on ${servedHost}:${daemon.port} (no local network \u2014 relay only)`
13198
13926
  console.log(
13199
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."
13200
13928
  );
13201
- console.log("\nScan this with your phone, then add it to your home screen:\n");
13202
- console.log(terminalQr(url));
13203
- 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(`
13204
13933
  ${url}
13205
13934
  `);
13206
- if (relay !== null && servedOnLan) {
13207
- console.log(`(LAN fallback for pairing at home: http://${servedHost}:${daemon.port}/#c=\u2026&s=\u2026 \u2014 same code)`);
13208
- }
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.");
13209
13939
  }
13210
13940
  var NETWORK_WATCH_MS = 5e3;
13941
+ var networkRebindInFlight = false;
13211
13942
  var networkWatch = setInterval(() => {
13943
+ if (networkRebindInFlight) return;
13212
13944
  const nextBest = findCandidates()[0];
13213
13945
  const nextHost = nextBest?.address ?? "127.0.0.1";
13214
13946
  if (nextHost === servingHost) return;
13215
13947
  const was = servingHost;
13216
- servingHost = nextHost;
13217
- void daemon.server.rebind(nextHost).then((port) => {
13948
+ networkRebindInFlight = true;
13949
+ void daemon.rebindLocal(nextHost).then((port) => {
13950
+ servingHost = nextHost;
13218
13951
  servingPort = port;
13219
13952
  console.log(serviceMode ? "\nNetwork changed; the local listener recovered." : `
13220
13953
  Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
@@ -13225,6 +13958,8 @@ Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
13225
13958
  console.log(serviceMode ? "\nNetwork changed but local listener recovery failed; details omitted." : `
13226
13959
  Network changed but rebinding failed (${String(err)}).`);
13227
13960
  console.log(relay !== null ? "The relay is unaffected." : "Restart the daemon to recover.");
13961
+ }).finally(() => {
13962
+ networkRebindInFlight = false;
13228
13963
  });
13229
13964
  }, NETWORK_WATCH_MS);
13230
13965
  networkWatch.unref?.();
@@ -13232,20 +13967,38 @@ if (!serviceMode) console.log("Press n + Enter for a fresh pairing QR, r + Enter
13232
13967
  process.stdin.setEncoding("utf8");
13233
13968
  process.stdin.on("data", (chunk) => {
13234
13969
  const key = chunk.trim().toLowerCase();
13235
- if (key === "n" && !serviceMode) {
13236
- 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
+ }
13237
13978
  console.log("\nScan this with your phone:\n");
13238
13979
  console.log(terminalQr(next));
13239
13980
  console.log(`
13240
13981
  ${next}
13241
13982
  `);
13242
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
+ }
13243
13994
  if (key === "r") {
13244
13995
  const active = daemon.registry.listDevices().filter((d) => d.revokedAt === null);
13245
13996
  for (const device of active) daemon.registry.revokeDevice(device.deviceId);
13246
13997
  console.log(`>>> revoked ${active.length} device(s); their live connections are cut`);
13247
13998
  }
13248
13999
  if (key === "q") {
14000
+ clearInterval(pairingWatch);
14001
+ for (const dispose of relayPairings) dispose();
13249
14002
  clearInterval(networkWatch);
13250
14003
  void daemon.stop().then(() => {
13251
14004
  instanceLock.release();
@@ -13254,6 +14007,8 @@ process.stdin.on("data", (chunk) => {
13254
14007
  }
13255
14008
  });
13256
14009
  var shutdown = () => {
14010
+ clearInterval(pairingWatch);
14011
+ for (const dispose of relayPairings) dispose();
13257
14012
  clearInterval(networkWatch);
13258
14013
  void daemon.stop().then(() => {
13259
14014
  instanceLock.release();