@longleash/cli 0.1.0-rc.1 → 0.1.0-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,8 @@ var __export = (target, all) => {
7
7
 
8
8
  // ../daemon/bin/longleashd.ts
9
9
  import { existsSync as existsSync7 } from "node:fs";
10
- import { homedir as homedir4 } from "node:os";
11
- import { dirname as dirname4, join as join8, resolve as resolve5 } from "node:path";
10
+ import { homedir as homedir5 } from "node:os";
11
+ import { dirname as dirname4, join as join10, resolve as resolve6 } from "node:path";
12
12
  import { fileURLToPath } from "node:url";
13
13
 
14
14
  // ../daemon/src/daemon.ts
@@ -44,8 +44,8 @@ async function pauseCurrentSessionOwner(managed, external, session, actor, reaso
44
44
  }
45
45
 
46
46
  // ../daemon/src/daemon.ts
47
- import { homedir as homedir3 } from "node:os";
48
- import { delimiter, join as join6, resolve as resolve4 } from "node:path";
47
+ import { homedir as homedir4 } from "node:os";
48
+ import { delimiter, join as join7, resolve as resolve5 } from "node:path";
49
49
 
50
50
  // ../daemon/src/eventlog.ts
51
51
  import Database from "better-sqlite3";
@@ -5432,7 +5432,10 @@ var SessionOrigin = external_exports.enum(["phone", "daemon", "terminal", "vscod
5432
5432
  var WorkspaceMode = external_exports.enum(["auto", "shared", "isolated"]);
5433
5433
  var AgentEffort = external_exports.enum(["low", "medium", "high", "xhigh", "max"]);
5434
5434
  var ThinkingMode = external_exports.enum(["adaptive", "disabled", "fixed"]);
5435
+ var SessionMode = external_exports.enum(["manual", "auto", "plan"]);
5435
5436
  var SessionSettings = external_exports.object({
5437
+ /** Omitted means LongLeash's conservative manual mode. */
5438
+ mode: SessionMode.optional(),
5436
5439
  /** Omitted means the provider's current default; model ids never enter a shell command. */
5437
5440
  model: external_exports.string().trim().min(1).max(120).optional(),
5438
5441
  effort: AgentEffort.optional(),
@@ -5493,6 +5496,8 @@ var sessionStartedPayload = external_exports.object({
5493
5496
  origin: SessionOrigin.optional(),
5494
5497
  /** Who owns the process now; unlike origin, this changes after a safe handoff. */
5495
5498
  controller: external_exports.enum(["longleash", "external"]).optional(),
5499
+ /** Observation-only means the native surface is visible but has not exposed process control. */
5500
+ control: external_exports.enum(["full", "observe"]).optional(),
5496
5501
  /**
5497
5502
  * The agent's own conversation id. Carried to the phone so a person can pick the
5498
5503
  * SAME conversation back up at their keyboard — `claude --resume <id>` in the
@@ -5526,6 +5531,8 @@ var sessionStatusPayload = external_exports.object({
5526
5531
  /** LongLeash's own gate for this session: whether it should page the phone at all. */
5527
5532
  gate: SessionGate.optional(),
5528
5533
  controller: external_exports.enum(["longleash", "external"]).optional(),
5534
+ /** Observation-only sessions are never presented as remotely stoppable or writable. */
5535
+ control: external_exports.enum(["full", "observe"]).optional(),
5529
5536
  /** Current overrides. An empty object explicitly means provider defaults. */
5530
5537
  settings: SessionSettings.optional(),
5531
5538
  /** A second writer was observed in this checkout; text accompanies color for accessibility. */
@@ -5614,7 +5621,9 @@ var subscribeMessage = external_exports.object({
5614
5621
  ...clientBase,
5615
5622
  type: external_exports.literal("subscribe"),
5616
5623
  sessionId: external_exports.string().min(1),
5617
- fromCursor: external_exports.number().int().nonnegative()
5624
+ fromCursor: external_exports.number().int().nonnegative(),
5625
+ /** Optional replay barrier. New clients use the echoed id to paint catch-up only once. */
5626
+ syncId: external_exports.string().min(1).max(120).optional()
5618
5627
  }).passthrough();
5619
5628
  var decisionMessage = external_exports.object({
5620
5629
  ...clientBase,
@@ -5859,6 +5868,7 @@ var EventLog = class {
5859
5868
  insertStmt;
5860
5869
  maxSeqStmt;
5861
5870
  minSeqStmt;
5871
+ latestTsStmt;
5862
5872
  selectFromStmt;
5863
5873
  pruneStmt;
5864
5874
  constructor(path, opts = {}) {
@@ -5886,6 +5896,9 @@ var EventLog = class {
5886
5896
  this.minSeqStmt = this.rawDb.prepare(
5887
5897
  "SELECT COALESCE(MIN(seq), 0) AS seq FROM events WHERE session_id = ?"
5888
5898
  );
5899
+ this.latestTsStmt = this.rawDb.prepare(
5900
+ "SELECT COALESCE(MAX(ts), 0) AS ts FROM events WHERE session_id = ?"
5901
+ );
5889
5902
  this.selectFromStmt = this.rawDb.prepare(
5890
5903
  "SELECT * FROM events WHERE session_id = ? AND seq > ? ORDER BY seq ASC"
5891
5904
  );
@@ -5940,6 +5953,9 @@ var EventLog = class {
5940
5953
  latestSeq(sessionId) {
5941
5954
  return this.maxSeqStmt.get(sessionId).seq;
5942
5955
  }
5956
+ latestTimestamp(sessionId) {
5957
+ return this.latestTsStmt.get(sessionId).ts;
5958
+ }
5943
5959
  pruneBefore(sessionId, uptoExclusive) {
5944
5960
  this.pruneStmt.run(sessionId, uptoExclusive);
5945
5961
  }
@@ -6668,13 +6684,13 @@ function workspaceFromRow(row) {
6668
6684
  }
6669
6685
  var newId = (prefix) => `${prefix}_${randomBytes2(9).toString("base64url")}`;
6670
6686
  function settlesWithin(promise, timeoutMs) {
6671
- return new Promise((resolve6) => {
6687
+ return new Promise((resolve7) => {
6672
6688
  let settled = false;
6673
6689
  const finish = (value) => {
6674
6690
  if (settled) return;
6675
6691
  settled = true;
6676
6692
  clearTimeout(timer);
6677
- resolve6(value);
6693
+ resolve7(value);
6678
6694
  };
6679
6695
  const timer = setTimeout(() => finish(false), timeoutMs);
6680
6696
  timer.unref?.();
@@ -7609,7 +7625,7 @@ var SessionManager = class {
7609
7625
  this.approvals.rawDb.prepare("DELETE FROM session_deliveries WHERE delivery_id = ?").run(deliveryId);
7610
7626
  }
7611
7627
  releasePending(session, message) {
7612
- for (const [approvalId, resolve6] of session.waiting) {
7628
+ for (const [approvalId, resolve7] of session.waiting) {
7613
7629
  const decided = this.approvals.decide(approvalId, "denied", "system:session-ended", message);
7614
7630
  if (decided) {
7615
7631
  this.emit(session.sessionId, {
@@ -7622,7 +7638,7 @@ var SessionManager = class {
7622
7638
  }
7623
7639
  });
7624
7640
  }
7625
- resolve6({ behavior: "deny", message });
7641
+ resolve7({ behavior: "deny", message });
7626
7642
  }
7627
7643
  session.waiting.clear();
7628
7644
  }
@@ -7643,9 +7659,9 @@ var SessionManager = class {
7643
7659
  waitForApproval(sessionId) {
7644
7660
  const alreadyPending = this.approvals.listPending().some((a) => a.sessionId === sessionId);
7645
7661
  if (alreadyPending) return Promise.resolve();
7646
- return new Promise((resolve6) => {
7662
+ return new Promise((resolve7) => {
7647
7663
  const waiters = this.approvalWaiters.get(sessionId) ?? [];
7648
- waiters.push(resolve6);
7664
+ waiters.push(resolve7);
7649
7665
  this.approvalWaiters.set(sessionId, waiters);
7650
7666
  });
7651
7667
  }
@@ -7694,8 +7710,8 @@ var SessionManager = class {
7694
7710
  outsideRoot
7695
7711
  }
7696
7712
  });
7697
- const decision = new Promise((resolve6) => {
7698
- waiting.set(approvalId, resolve6);
7713
+ const decision = new Promise((resolve7) => {
7714
+ waiting.set(approvalId, resolve7);
7699
7715
  });
7700
7716
  for (const waiter of this.approvalWaiters.get(sessionId) ?? []) waiter();
7701
7717
  this.approvalWaiters.delete(sessionId);
@@ -7943,7 +7959,8 @@ var ExternalSessions = class {
7943
7959
  offset: existsSync(row.transcriptPath) ? statSync(row.transcriptPath).size : 0,
7944
7960
  remainder: "",
7945
7961
  timer: null,
7946
- suspended: false
7962
+ suspended: false,
7963
+ observedOnly: false
7947
7964
  };
7948
7965
  this.sessions.set(row.agentSessionId, session);
7949
7966
  this.claimWorkspace(session);
@@ -7964,6 +7981,26 @@ var ExternalSessions = class {
7964
7981
  }
7965
7982
  this.claimWorkspace(session);
7966
7983
  }
7984
+ /** Adopt a Codex transcript observed after startup, including sessions older than the hooks. */
7985
+ observeCodexSession(info) {
7986
+ const session = this.ensure(info.sessionId, info.cwd, info.transcriptPath, "codex", info.surface, true);
7987
+ if (info.title !== void 0 && info.title !== session.title) {
7988
+ session.named = true;
7989
+ session.title = info.title;
7990
+ this.persist(info.sessionId, session);
7991
+ }
7992
+ this.emit(session.sessionId, {
7993
+ type: "session.status",
7994
+ payload: {
7995
+ status: session.status,
7996
+ live: true,
7997
+ controller: "external",
7998
+ control: "observe",
7999
+ ...info.title === void 0 ? {} : { title: info.title }
8000
+ }
8001
+ });
8002
+ this.claimWorkspace(session);
8003
+ }
7967
8004
  /**
7968
8005
  * Stop a terminal session from the phone — for real. The recorded pid is only
7969
8006
  * trusted after re-verifying it still belongs to a claude process, because PIDs
@@ -7973,6 +8010,7 @@ var ExternalSessions = class {
7973
8010
  const entry = [...this.sessions.entries()].find(([, s]) => s.sessionId === externalSessionId);
7974
8011
  if (!entry) return false;
7975
8012
  const [claudeSessionId, session] = entry;
8013
+ if (session.observedOnly) return false;
7976
8014
  if (session.pid === null || !this.isClaudeProcess(session.pid)) {
7977
8015
  this.emit(session.sessionId, {
7978
8016
  type: "stream.delta",
@@ -8055,7 +8093,7 @@ var ExternalSessions = class {
8055
8093
  const deadline = Date.now() + timeoutMs;
8056
8094
  while (Date.now() < deadline) {
8057
8095
  if (!this.isClaudeProcess(pid)) return true;
8058
- await new Promise((resolve6) => setTimeout(resolve6, 50));
8096
+ await new Promise((resolve7) => setTimeout(resolve7, 50));
8059
8097
  }
8060
8098
  return !this.isClaudeProcess(pid);
8061
8099
  }
@@ -8063,7 +8101,7 @@ var ExternalSessions = class {
8063
8101
  * A tool wants to run in the terminal. Returns what the hook should tell
8064
8102
  * Claude Code — and "ask" always means "behave as if we were never here".
8065
8103
  */
8066
- preToolUse(claudeSessionId, cwd, transcriptPath, toolName, toolInput, permissionMode, opts = {}) {
8104
+ preToolUse(claudeSessionId, cwd, transcriptPath, toolName, toolInput, permissionMode2, opts = {}) {
8067
8105
  const { dedupeKey, agent = "claude", surface = "terminal", abandoned, enforceWorkspace } = opts;
8068
8106
  const session = this.ensure(claudeSessionId, cwd, transcriptPath, agent, surface);
8069
8107
  if (enforceWorkspace === true && session.workspaceConflict !== void 0) {
@@ -8080,11 +8118,11 @@ var ExternalSessions = class {
8080
8118
  if (session.gate === "auto") {
8081
8119
  return Promise.resolve({ decision: "allow", reason: "Auto-approved: you muted this session." });
8082
8120
  }
8083
- if (permissionMode !== void 0 && permissionMode !== session.permissionMode) {
8084
- session.permissionMode = permissionMode;
8121
+ if (permissionMode2 !== void 0 && permissionMode2 !== session.permissionMode) {
8122
+ session.permissionMode = permissionMode2;
8085
8123
  this.emit(session.sessionId, {
8086
8124
  type: "session.status",
8087
- payload: { status: session.status, live: true, permissionMode }
8125
+ payload: { status: session.status, live: true, permissionMode: permissionMode2 }
8088
8126
  });
8089
8127
  }
8090
8128
  const who = this.audience();
@@ -8119,7 +8157,7 @@ var ExternalSessions = class {
8119
8157
  ...questions === null ? {} : { questions }
8120
8158
  }
8121
8159
  });
8122
- const pending = new Promise((resolve6) => {
8160
+ const pending = new Promise((resolve7) => {
8123
8161
  const timer = setTimeout(() => {
8124
8162
  this.waiting.delete(approvalId);
8125
8163
  this.approvals.decide(
@@ -8137,11 +8175,11 @@ var ExternalSessions = class {
8137
8175
  reply: "Answered in the terminal instead."
8138
8176
  }
8139
8177
  });
8140
- resolve6({ decision: "ask", reason: "No answer from the phone; deciding in the terminal." });
8178
+ resolve7({ decision: "ask", reason: "No answer from the phone; deciding in the terminal." });
8141
8179
  }, holdMs);
8142
8180
  timer.unref?.();
8143
8181
  this.waiting.set(approvalId, {
8144
- resolve: resolve6,
8182
+ resolve: resolve7,
8145
8183
  timer,
8146
8184
  sessionId: session.sessionId,
8147
8185
  ...questions === null ? {} : { questions }
@@ -8258,7 +8296,7 @@ var ExternalSessions = class {
8258
8296
  /** Mute or unmute a session from the phone. Returns false for a session it does not know. */
8259
8297
  setGate(externalSessionId, gate) {
8260
8298
  const session = [...this.sessions.values()].find((s) => s.sessionId === externalSessionId);
8261
- if (!session) return false;
8299
+ if (!session || session.observedOnly) return false;
8262
8300
  session.gate = gate;
8263
8301
  this.emit(session.sessionId, {
8264
8302
  type: "session.status",
@@ -8311,11 +8349,13 @@ var ExternalSessions = class {
8311
8349
  resumeId: claudeSessionId,
8312
8350
  gate: session.gate,
8313
8351
  controller: "external",
8352
+ control: session.observedOnly ? "observe" : "full",
8314
8353
  ...session.workspaceConflict === void 0 ? {} : { workspaceConflict: session.workspaceConflict }
8315
8354
  }));
8316
8355
  }
8317
8356
  /** Write this session down so a restart can take it back. */
8318
8357
  persist(agentSessionId, session) {
8358
+ if (session.observedOnly) return;
8319
8359
  this.registry?.remember({
8320
8360
  agentSessionId,
8321
8361
  sessionId: session.sessionId,
@@ -8367,9 +8407,19 @@ var ExternalSessions = class {
8367
8407
  this.sessions.clear();
8368
8408
  }
8369
8409
  /* ------------------------------------------------------------------ internals */
8370
- ensure(claudeSessionId, cwd, transcriptPath, agent = "claude", surface = "terminal") {
8410
+ ensure(claudeSessionId, cwd, transcriptPath, agent = "claude", surface = "terminal", tailOnly = false) {
8371
8411
  const existing = this.sessions.get(claudeSessionId);
8372
- if (existing) return existing;
8412
+ if (existing) {
8413
+ if (!tailOnly && existing.observedOnly) {
8414
+ existing.observedOnly = false;
8415
+ this.persist(claudeSessionId, existing);
8416
+ this.emit(existing.sessionId, {
8417
+ type: "session.status",
8418
+ payload: { status: existing.status, live: true, controller: "external", control: "full" }
8419
+ });
8420
+ }
8421
+ return existing;
8422
+ }
8373
8423
  const sessionId = `ext_${claudeSessionId}`;
8374
8424
  const replay = this.eventLog.replay(sessionId, 0);
8375
8425
  const hasHistory = replay.gap === false ? replay.events.length > 0 : true;
@@ -8386,10 +8436,11 @@ var ExternalSessions = class {
8386
8436
  named: false,
8387
8437
  permissionMode: null,
8388
8438
  gate: "ask",
8389
- offset: hasHistory && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
8439
+ offset: (tailOnly || hasHistory) && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
8390
8440
  remainder: "",
8391
8441
  timer: null,
8392
- suspended: false
8442
+ suspended: false,
8443
+ observedOnly: tailOnly
8393
8444
  };
8394
8445
  this.sessions.set(claudeSessionId, session);
8395
8446
  this.persist(claudeSessionId, session);
@@ -8402,13 +8453,19 @@ var ExternalSessions = class {
8402
8453
  title: session.title,
8403
8454
  origin: surface,
8404
8455
  controller: "external",
8456
+ control: tailOnly ? "observe" : "full",
8405
8457
  resumeId: claudeSessionId
8406
8458
  }
8407
8459
  });
8408
8460
  }
8409
8461
  this.emit(sessionId, {
8410
8462
  type: "session.status",
8411
- payload: { status: "running", live: true, controller: "external" }
8463
+ payload: {
8464
+ status: "running",
8465
+ live: true,
8466
+ controller: "external",
8467
+ control: tailOnly ? "observe" : "full"
8468
+ }
8412
8469
  });
8413
8470
  session.timer = setInterval(() => this.drain(session), this.pollMs);
8414
8471
  session.timer.unref?.();
@@ -8636,6 +8693,9 @@ var RelayLink = class {
8636
8693
  reconnectTimer = null;
8637
8694
  keepaliveTimer = null;
8638
8695
  keepaliveMs;
8696
+ /** WebCrypto promises may resolve out of order; preserve the server's event order explicitly. */
8697
+ outbound = Promise.resolve();
8698
+ inbound = Promise.resolve();
8639
8699
  constructor(opts) {
8640
8700
  this.opts = opts;
8641
8701
  this.log = opts.log ?? (() => {
@@ -8672,10 +8732,12 @@ var RelayLink = class {
8672
8732
  const socket = this.socket;
8673
8733
  const identity = this.identity;
8674
8734
  if (this.currentStatus !== "connected" || !socket || !identity) return;
8675
- void seal(identity, plaintext).then((payload) => {
8676
- if (socket.readyState === WebSocket.OPEN) {
8735
+ this.outbound = this.outbound.then(async () => {
8736
+ const payload = await seal(identity, plaintext);
8737
+ if (this.socket === socket && socket.readyState === WebSocket.OPEN) {
8677
8738
  socket.send(JSON.stringify({ v: 1, type: "frame", payload }));
8678
8739
  }
8740
+ }).catch(() => {
8679
8741
  });
8680
8742
  }
8681
8743
  async connect() {
@@ -8714,9 +8776,12 @@ var RelayLink = class {
8714
8776
  return;
8715
8777
  }
8716
8778
  if (message.type === "frame" && typeof message.payload === "string") {
8717
- void open(identity, message.payload).then((plaintext) => {
8779
+ const payload = message.payload;
8780
+ this.inbound = this.inbound.then(async () => {
8781
+ const plaintext = await open(identity, payload);
8718
8782
  if (plaintext !== null) this.opts.onMessage(plaintext);
8719
8783
  else this.log("dropped a relay frame that failed to open");
8784
+ }).catch(() => {
8720
8785
  });
8721
8786
  return;
8722
8787
  }
@@ -9958,6 +10023,7 @@ var LongLeashServer = class {
9958
10023
  external = null;
9959
10024
  delegations = null;
9960
10025
  hookSecret = null;
10026
+ localPairing = null;
9961
10027
  staticRoot;
9962
10028
  /** Captured at process start so updating files on disk cannot impersonate a daemon restart. */
9963
10029
  expectedBuild;
@@ -10085,6 +10151,15 @@ var LongLeashServer = class {
10085
10151
  this.log(`device ${deviceId} revoked \u2014 its sessions are closed and its relay room is shut`);
10086
10152
  return { revoked: true, deviceId };
10087
10153
  });
10154
+ this.app.post("/local/pairing", async (request, reply) => {
10155
+ if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
10156
+ if (this.localPairing === null) return reply.code(503).send({ reason: "pairing-unavailable" });
10157
+ try {
10158
+ return { url: this.localPairing() };
10159
+ } catch {
10160
+ return reply.code(503).send({ reason: "pairing-failed" });
10161
+ }
10162
+ });
10088
10163
  this.app.get("/pair", async (_request, reply) => reply.code(405).send({ reason: "use-post" }));
10089
10164
  this.app.post("/pair", async (request, reply) => {
10090
10165
  const query2 = request.query;
@@ -10190,15 +10265,15 @@ var LongLeashServer = class {
10190
10265
  */
10191
10266
  async rebind(host) {
10192
10267
  const wanted = this.boundPort;
10193
- await new Promise((resolve6) => this.app.server.close(() => resolve6()));
10194
- const tryListen = (port) => new Promise((resolve6, reject) => {
10268
+ await new Promise((resolve7) => this.app.server.close(() => resolve7()));
10269
+ const tryListen = (port) => new Promise((resolve7, reject) => {
10195
10270
  const onError = (err) => {
10196
10271
  this.app.server.removeListener("listening", onListening);
10197
10272
  reject(err);
10198
10273
  };
10199
10274
  const onListening = () => {
10200
10275
  this.app.server.removeListener("error", onError);
10201
- resolve6();
10276
+ resolve7();
10202
10277
  };
10203
10278
  this.app.server.once("error", onError);
10204
10279
  this.app.server.once("listening", onListening);
@@ -10223,6 +10298,10 @@ var LongLeashServer = class {
10223
10298
  this.external = external;
10224
10299
  this.hookSecret = hookSecret;
10225
10300
  }
10301
+ /** A laptop-local CLI may request a transient QR without persisting its secret in service logs. */
10302
+ attachLocalPairing(create) {
10303
+ this.localPairing = create;
10304
+ }
10226
10305
  /** Persist an event, then fan it out to every subscriber of that session. */
10227
10306
  publish(sessionId, input) {
10228
10307
  const event2 = this.eventLog.append(sessionId, input);
@@ -10340,7 +10419,10 @@ var LongLeashServer = class {
10340
10419
  roots: this.sessions?.listAllowedRoots() ?? [],
10341
10420
  // Everything the daemon knows about, so a reloaded phone rebuilds instead of showing
10342
10421
  // nothing — including sessions the human started in a terminal.
10343
- sessions: [...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []],
10422
+ sessions: [...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []].map((session) => ({
10423
+ ...session,
10424
+ lastActivityAt: this.eventLog.latestTimestamp(session.sessionId) || session.startedAt
10425
+ })).sort((left, right) => left.startedAt - right.startedAt || left.sessionId.localeCompare(right.sessionId)),
10344
10426
  capabilities: {
10345
10427
  startSession: this.sessions !== null,
10346
10428
  stopSession: this.sessions !== null,
@@ -10418,6 +10500,15 @@ var LongLeashServer = class {
10418
10500
  return;
10419
10501
  }
10420
10502
  for (const event2 of replay.events) this.sendTo(connection, event2);
10503
+ if (message.syncId !== void 0) {
10504
+ this.sendTo(connection, {
10505
+ v: PROTOCOL_VERSION,
10506
+ type: "sync.complete",
10507
+ sessionId: message.sessionId,
10508
+ syncId: message.syncId,
10509
+ cursor: this.eventLog.latestSeq(message.sessionId)
10510
+ });
10511
+ }
10421
10512
  return;
10422
10513
  }
10423
10514
  if (message.type === "pushSubscribe") {
@@ -10721,6 +10812,12 @@ var LongLeashServer = class {
10721
10812
  try {
10722
10813
  const liveExternal = this.external?.hasLiveSession(message.sessionId) === true;
10723
10814
  const externalSession = liveExternal ? this.external?.listSessions().find((item) => item.sessionId === message.sessionId) : void 0;
10815
+ if (externalSession?.control === "observe") {
10816
+ throw new SessionError(
10817
+ "session-busy",
10818
+ "This Codex conversation is visible through its active VS Code transcript, but its process has not exposed a control hook yet. Continue or stop it in VS Code; LongLeash changed nothing."
10819
+ );
10820
+ }
10724
10821
  if (externalSession?.agent === "codex" && message.settings.thinking !== void 0) {
10725
10822
  throw new SessionError(
10726
10823
  "unsupported-setting",
@@ -11044,6 +11141,11 @@ function normalizeRelayUrl(raw) {
11044
11141
 
11045
11142
  // ../daemon/src/adapters/claude.ts
11046
11143
  import { query } from "@anthropic-ai/claude-agent-sdk";
11144
+ function permissionMode(mode) {
11145
+ if (mode === "auto") return "auto";
11146
+ if (mode === "plan") return "plan";
11147
+ return "default";
11148
+ }
11047
11149
  function summarizeToolInput(input) {
11048
11150
  if (input && typeof input === "object") {
11049
11151
  const record = input;
@@ -11077,8 +11179,8 @@ function createClaudeAgentFactory(options = {}) {
11077
11179
  };
11078
11180
  }
11079
11181
  if (closed) return;
11080
- await new Promise((resolve6) => {
11081
- wake = resolve6;
11182
+ await new Promise((resolve7) => {
11183
+ wake = resolve7;
11082
11184
  });
11083
11185
  }
11084
11186
  }
@@ -11101,6 +11203,7 @@ function createClaudeAgentFactory(options = {}) {
11101
11203
  ...request.settings?.model === void 0 && options.model === void 0 ? {} : { model: request.settings?.model ?? options.model },
11102
11204
  ...request.settings?.effort === void 0 ? {} : { effort: request.settings.effort },
11103
11205
  ...request.settings?.thinking === void 0 ? {} : request.settings.thinking.mode === "fixed" ? { thinking: { type: "enabled", budgetTokens: request.settings.thinking.budgetTokens } } : request.settings.thinking.mode === "adaptive" ? { thinking: { type: "adaptive" } } : { thinking: { type: "disabled" } },
11206
+ permissionMode: permissionMode(request.settings?.mode),
11104
11207
  ...options.allowedTools === void 0 ? {} : { allowedTools: options.allowedTools },
11105
11208
  ...options.isolateFromUserSettings ? { settingSources: [] } : {},
11106
11209
  // Reopening a closed conversation: Claude replays its own transcript, so the agent
@@ -11143,6 +11246,7 @@ function createClaudeAgentFactory(options = {}) {
11143
11246
  });
11144
11247
  let activeSettings = { ...request.settings ?? {} };
11145
11248
  const applySettings = async (settings) => {
11249
+ await run.setPermissionMode(permissionMode(settings.mode));
11146
11250
  await run.setModel(settings.model);
11147
11251
  await run.applyFlagSettings({ effortLevel: settings.effort ?? null });
11148
11252
  if (settings.thinking === void 0) {
@@ -11301,6 +11405,32 @@ var CodexRun = class {
11301
11405
  log;
11302
11406
  /** Per-turn overrides; updating this affects the next turn without replacing the thread. */
11303
11407
  settings;
11408
+ approvalPolicy() {
11409
+ if (this.settings.mode === "auto") return "on-request";
11410
+ if (this.settings.mode === "manual" || this.settings.mode === "plan") return "untrusted";
11411
+ return this.opts.approvalPolicy ?? "untrusted";
11412
+ }
11413
+ sandbox() {
11414
+ if (this.settings.mode === "plan") return "read-only";
11415
+ if (this.settings.mode === "manual" || this.settings.mode === "auto") return "workspace-write";
11416
+ return this.opts.sandbox ?? "workspace-write";
11417
+ }
11418
+ sandboxPolicy() {
11419
+ const sandbox = this.sandbox();
11420
+ if (sandbox === "read-only") return { type: "readOnly", networkAccess: false };
11421
+ if (sandbox === "danger-full-access") return { type: "dangerFullAccess" };
11422
+ return { type: "workspaceWrite", networkAccess: false, writableRoots: [this.request.cwd] };
11423
+ }
11424
+ providerText(text) {
11425
+ if (this.settings.mode !== "plan") return text;
11426
+ return [
11427
+ "<longleash-plan-mode>",
11428
+ "Plan only. Investigate and explain a concrete implementation approach, but do not modify files or run commands that change the workspace. Ask for clarification when a decision is required.",
11429
+ "</longleash-plan-mode>",
11430
+ "",
11431
+ text
11432
+ ].join("\n");
11433
+ }
11304
11434
  handle() {
11305
11435
  return {
11306
11436
  events: this.iterate(),
@@ -11309,7 +11439,7 @@ var CodexRun = class {
11309
11439
  if (this.threadId !== null && this.turnId !== null) {
11310
11440
  void this.call("turn/interrupt", { threadId: this.threadId, turnId: this.turnId }).catch(() => {
11311
11441
  });
11312
- await new Promise((resolve6) => setTimeout(resolve6, INTERRUPT_GRACE_MS));
11442
+ await new Promise((resolve7) => setTimeout(resolve7, INTERRUPT_GRACE_MS));
11313
11443
  }
11314
11444
  this.child.kill("SIGTERM");
11315
11445
  this.finish();
@@ -11335,15 +11465,15 @@ var CodexRun = class {
11335
11465
  this.request.resume === void 0 ? {
11336
11466
  cwd: this.request.cwd,
11337
11467
  ...this.settings.model === void 0 ? {} : { model: this.settings.model },
11338
- approvalPolicy: this.opts.approvalPolicy ?? "untrusted",
11339
- sandbox: this.opts.sandbox ?? "workspace-write",
11468
+ approvalPolicy: this.approvalPolicy(),
11469
+ sandbox: this.sandbox(),
11340
11470
  config: MANAGED_THREAD_CONFIG
11341
11471
  } : {
11342
11472
  threadId: this.request.resume,
11343
11473
  cwd: this.request.cwd,
11344
11474
  ...this.settings.model === void 0 ? {} : { model: this.settings.model },
11345
- approvalPolicy: this.opts.approvalPolicy ?? "untrusted",
11346
- sandbox: this.opts.sandbox ?? "workspace-write",
11475
+ approvalPolicy: this.approvalPolicy(),
11476
+ sandbox: this.sandbox(),
11347
11477
  config: MANAGED_THREAD_CONFIG
11348
11478
  }
11349
11479
  );
@@ -11365,9 +11495,11 @@ var CodexRun = class {
11365
11495
  try {
11366
11496
  await this.call("turn/start", {
11367
11497
  threadId: this.threadId,
11368
- input: [{ type: "text", text }],
11498
+ input: [{ type: "text", text: this.providerText(text) }],
11369
11499
  ...this.settings.model === void 0 ? {} : { model: this.settings.model },
11370
- ...this.settings.effort === void 0 ? {} : { effort: this.settings.effort }
11500
+ ...this.settings.effort === void 0 ? {} : { effort: this.settings.effort },
11501
+ approvalPolicy: this.approvalPolicy(),
11502
+ sandboxPolicy: this.sandboxPolicy()
11371
11503
  });
11372
11504
  } catch (error) {
11373
11505
  if (fatal) throw error;
@@ -11527,8 +11659,8 @@ var CodexRun = class {
11527
11659
  // -------------------------------------------------------------------- plumbing
11528
11660
  call(method, params) {
11529
11661
  const id2 = this.nextId++;
11530
- const promise = new Promise((resolve6, reject) => {
11531
- this.pending.set(id2, { resolve: resolve6, reject });
11662
+ const promise = new Promise((resolve7, reject) => {
11663
+ this.pending.set(id2, { resolve: resolve7, reject });
11532
11664
  });
11533
11665
  this.write({ jsonrpc: "2.0", id: id2, method, params });
11534
11666
  return promise;
@@ -11580,8 +11712,8 @@ var CodexRun = class {
11580
11712
  if (this.failure !== null) throw this.failure;
11581
11713
  return;
11582
11714
  }
11583
- await new Promise((resolve6) => {
11584
- this.waiter = resolve6;
11715
+ await new Promise((resolve7) => {
11716
+ this.waiter = resolve7;
11585
11717
  });
11586
11718
  }
11587
11719
  }
@@ -11763,8 +11895,8 @@ var FolderIndex = class {
11763
11895
  for (const root of this.roots) {
11764
11896
  if (path === root) return basename4(root);
11765
11897
  if (path.startsWith(root + sep4)) {
11766
- const relative2 = path.slice(root.length + 1);
11767
- return `${basename4(root)}/${relative2}`;
11898
+ const relative3 = path.slice(root.length + 1);
11899
+ return `${basename4(root)}/${relative3}`;
11768
11900
  }
11769
11901
  }
11770
11902
  return path;
@@ -12170,13 +12302,183 @@ function roleName2(role) {
12170
12302
  return role.slice(0, 1).toUpperCase() + role.slice(1);
12171
12303
  }
12172
12304
 
12305
+ // ../daemon/src/codex-session-watch.ts
12306
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
12307
+ import { homedir as homedir3 } from "node:os";
12308
+ import { isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4 } from "node:path";
12309
+ var MAX_HEAD = 256 * 1024;
12310
+ var MAX_TAIL = 1024 * 1024;
12311
+ var MAX_INITIAL_TAIL = 64 * 1024 * 1024;
12312
+ function readSlice(path, offset, length) {
12313
+ const fd = openSync2(path, "r");
12314
+ try {
12315
+ const buffer = Buffer.alloc(length);
12316
+ const read = readSync2(fd, buffer, 0, length, offset);
12317
+ return buffer.subarray(0, read).toString("utf8");
12318
+ } finally {
12319
+ closeSync2(fd);
12320
+ }
12321
+ }
12322
+ function inside(root, candidate) {
12323
+ const rel = relative2(resolve4(root), resolve4(candidate));
12324
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
12325
+ }
12326
+ function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
12327
+ let size;
12328
+ try {
12329
+ size = statSync2(path).size;
12330
+ } catch {
12331
+ return null;
12332
+ }
12333
+ try {
12334
+ const firstLine = readSlice(path, 0, Math.min(size, MAX_HEAD)).split("\n")[0];
12335
+ if (!firstLine) return null;
12336
+ const meta = JSON.parse(firstLine);
12337
+ if (meta.type !== "session_meta" || !meta.payload) return null;
12338
+ const sessionId = typeof meta.payload.session_id === "string" ? meta.payload.session_id : typeof meta.payload.id === "string" ? meta.payload.id : null;
12339
+ const cwd = typeof meta.payload.cwd === "string" ? meta.payload.cwd : null;
12340
+ if (sessionId === null || cwd === null || !roots2.some((root) => inside(root, cwd))) return null;
12341
+ if (meta.payload.source !== "vscode" && meta.payload.originator !== "codex_vscode") return null;
12342
+ const surface = "vscode";
12343
+ const tailOffset = Math.max(0, size - maxTailBytes);
12344
+ const tailText = readSlice(path, tailOffset, size - tailOffset);
12345
+ const lines = tailText.split("\n");
12346
+ if (tailOffset > 0) lines.shift();
12347
+ let title;
12348
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
12349
+ const line = lines[index];
12350
+ if (!line?.includes('"role":"user"')) continue;
12351
+ try {
12352
+ const record = JSON.parse(line);
12353
+ if (record.type !== "response_item" || record.payload?.type !== "message" || record.payload.role !== "user") continue;
12354
+ const content = record.payload.content;
12355
+ if (!Array.isArray(content)) continue;
12356
+ const text = content.filter((item) => !!item && typeof item === "object" && item.type === "input_text" && typeof item.text === "string").map((item) => item.text).join("\n");
12357
+ title = titleFrom(text) ?? void 0;
12358
+ if (title !== void 0) break;
12359
+ } catch {
12360
+ }
12361
+ }
12362
+ if (title === void 0) title = titleFrom(latestUserText(tailText) ?? "") ?? void 0;
12363
+ return { sessionId, cwd, transcriptPath: path, surface, ...title === void 0 ? {} : { title } };
12364
+ } catch {
12365
+ return null;
12366
+ }
12367
+ }
12368
+ var CodexSessionWatcher = class {
12369
+ options;
12370
+ mtimes = /* @__PURE__ */ new Map();
12371
+ known = /* @__PURE__ */ new Map();
12372
+ timer = null;
12373
+ constructor(options) {
12374
+ this.options = { pollMs: 3e3, initialRecentMs: 5 * 6e4, ...options };
12375
+ }
12376
+ start() {
12377
+ this.scan(true);
12378
+ this.timer = setInterval(() => this.scan(false), this.options.pollMs);
12379
+ this.timer.unref?.();
12380
+ return () => this.stop();
12381
+ }
12382
+ stop() {
12383
+ if (this.timer !== null) clearInterval(this.timer);
12384
+ this.timer = null;
12385
+ }
12386
+ scan(initial = false) {
12387
+ const root = this.options.sessionsRoot ?? join6(homedir3(), ".codex", "sessions");
12388
+ const now = this.options.now?.() ?? Date.now();
12389
+ let observed = 0;
12390
+ for (const path of jsonlFiles(root)) {
12391
+ let modified;
12392
+ try {
12393
+ modified = statSync2(path).mtimeMs;
12394
+ } catch {
12395
+ continue;
12396
+ }
12397
+ const previous = this.mtimes.get(path);
12398
+ this.mtimes.set(path, modified);
12399
+ const changed = previous !== void 0 && modified > previous;
12400
+ const recentlyActive = initial && now - modified <= this.options.initialRecentMs;
12401
+ if (!changed && !recentlyActive) continue;
12402
+ const previousSession = this.known.get(path);
12403
+ const inspected = inspectCodexTranscript(
12404
+ path,
12405
+ this.options.roots,
12406
+ previousSession === void 0 ? MAX_INITIAL_TAIL : MAX_TAIL
12407
+ );
12408
+ const session = inspected === null ? null : inspected.title === void 0 && previousSession?.title !== void 0 ? { ...inspected, title: previousSession.title } : inspected;
12409
+ if (session === null) continue;
12410
+ this.known.set(path, session);
12411
+ this.options.onSession(session);
12412
+ observed += 1;
12413
+ }
12414
+ return observed;
12415
+ }
12416
+ };
12417
+ function jsonStringAt(source, quoteAt) {
12418
+ if (source[quoteAt] !== '"') return null;
12419
+ let escaped = false;
12420
+ for (let index = quoteAt + 1; index < source.length; index += 1) {
12421
+ const char = source[index];
12422
+ if (escaped) {
12423
+ escaped = false;
12424
+ continue;
12425
+ }
12426
+ if (char === "\\") {
12427
+ escaped = true;
12428
+ continue;
12429
+ }
12430
+ if (char === '"') {
12431
+ try {
12432
+ return JSON.parse(source.slice(quoteAt, index + 1));
12433
+ } catch {
12434
+ return null;
12435
+ }
12436
+ }
12437
+ }
12438
+ return null;
12439
+ }
12440
+ function latestUserText(source) {
12441
+ let before = source.length;
12442
+ while (before > 0) {
12443
+ const roleAt = source.lastIndexOf('"role":"user"', before);
12444
+ if (roleAt < 0) return null;
12445
+ const textKey = source.indexOf('"text":', roleAt);
12446
+ const nextRole = source.indexOf('"role":', roleAt + 1);
12447
+ if (textKey >= 0 && (nextRole < 0 || textKey < nextRole)) {
12448
+ const quoteAt = source.indexOf('"', textKey + 7);
12449
+ const text = quoteAt < 0 ? null : jsonStringAt(source, quoteAt);
12450
+ if (text !== null && titleFrom(text) !== null) return text;
12451
+ }
12452
+ before = roleAt;
12453
+ }
12454
+ return null;
12455
+ }
12456
+ function jsonlFiles(root) {
12457
+ const out = [];
12458
+ const visit = (directory) => {
12459
+ let entries;
12460
+ try {
12461
+ entries = readdirSync3(directory, { withFileTypes: true });
12462
+ } catch {
12463
+ return;
12464
+ }
12465
+ for (const entry of entries) {
12466
+ const path = join6(directory, entry.name);
12467
+ if (entry.isDirectory()) visit(path);
12468
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
12469
+ }
12470
+ };
12471
+ visit(root);
12472
+ return out;
12473
+ }
12474
+
12173
12475
  // ../daemon/src/daemon.ts
12174
12476
  var DEFAULT_READ_ONLY_TOOLS = ["Read", "Glob", "Grep"];
12175
12477
  function executableOnPath(command) {
12176
12478
  for (const directory of (process.env.PATH ?? "").split(delimiter)) {
12177
12479
  if (directory === "") continue;
12178
12480
  try {
12179
- accessSync(join6(directory, command), constants.X_OK);
12481
+ accessSync(join7(directory, command), constants.X_OK);
12180
12482
  return true;
12181
12483
  } catch {
12182
12484
  }
@@ -12184,20 +12486,20 @@ function executableOnPath(command) {
12184
12486
  return false;
12185
12487
  }
12186
12488
  async function startDaemon(options) {
12187
- const dataDir2 = options.dataDir ?? join6(homedir3(), ".longleash");
12489
+ const dataDir2 = options.dataDir ?? join7(homedir4(), ".longleash");
12188
12490
  mkdirSync3(dataDir2, { recursive: true });
12189
- const roots2 = options.allowedRoots.map((root) => resolve4(root));
12491
+ const roots2 = options.allowedRoots.map((root) => resolve5(root));
12190
12492
  for (const root of roots2) {
12191
12493
  if (!existsSync6(root)) throw new Error(`Allowed root does not exist: ${root}`);
12192
12494
  }
12193
- const eventLog = new EventLog(join6(dataDir2, "events.db"));
12194
- const registry = new DeviceRegistry(join6(dataDir2, "devices.db"));
12195
- const approvals = new ApprovalStore(join6(dataDir2, "approvals.db"));
12495
+ const eventLog = new EventLog(join7(dataDir2, "events.db"));
12496
+ const registry = new DeviceRegistry(join7(dataDir2, "devices.db"));
12497
+ const approvals = new ApprovalStore(join7(dataDir2, "approvals.db"));
12196
12498
  const workspace = new WorkspaceLeaseManager(approvals.rawDb);
12197
- const worktrees = new WorktreeManager(join6(dataDir2, "worktrees"));
12499
+ const worktrees = new WorktreeManager(join7(dataDir2, "worktrees"));
12198
12500
  const push = new PushNotifier({
12199
- dbPath: join6(dataDir2, "push.db"),
12200
- keysPath: join6(dataDir2, "vapid.json"),
12501
+ dbPath: join7(dataDir2, "push.db"),
12502
+ keysPath: join7(dataDir2, "vapid.json"),
12201
12503
  // Who a push service may contact about this sender: the relay origin when
12202
12504
  // one exists, else a placeholder mailto (LAN-only installs never push far).
12203
12505
  subject: process.env.LONGLEASH_PUSH_SUBJECT ?? (options.relayUrl !== void 0 ? options.relayUrl.replace(/^ws(s?):\/\//, "http$1://").replace(/\/ws\/?$/, "") : "mailto:longleash@localhost.invalid"),
@@ -12283,8 +12585,8 @@ async function startDaemon(options) {
12283
12585
  server.attachSessions(sessions);
12284
12586
  server.attachFolders(new FolderIndex(roots2));
12285
12587
  server.attachPush(push);
12286
- const externalApprovals = new ApprovalStore(join6(dataDir2, "approvals-external.db"));
12287
- const sessionRegistry = new SessionRegistry(join6(dataDir2, "live-sessions.db"));
12588
+ const externalApprovals = new ApprovalStore(join7(dataDir2, "approvals-external.db"));
12589
+ const sessionRegistry = new SessionRegistry(join7(dataDir2, "live-sessions.db"));
12288
12590
  const external = new ExternalSessions({
12289
12591
  eventLog,
12290
12592
  approvals: externalApprovals,
@@ -12307,6 +12609,11 @@ async function startDaemon(options) {
12307
12609
  agentSessionId: info.claudeSessionId
12308
12610
  })
12309
12611
  });
12612
+ const codexWatcher = new CodexSessionWatcher({
12613
+ roots: roots2,
12614
+ onSession: (session) => external.observeCodexSession(session)
12615
+ });
12616
+ codexWatcher.start();
12310
12617
  external.startSweeping();
12311
12618
  const delegationStore = new DelegationStore(approvals.rawDb);
12312
12619
  workspace.reconcile({
@@ -12332,7 +12639,7 @@ async function startDaemon(options) {
12332
12639
  server.attachExternal(external, hookSecret);
12333
12640
  const { port } = await server.listen();
12334
12641
  writeFileSync2(
12335
- join6(dataDir2, "hook-endpoint.json"),
12642
+ join7(dataDir2, "hook-endpoint.json"),
12336
12643
  JSON.stringify({ url: `http://${options.host}:${port}/hook`, secret: hookSecret }, null, 2) + "\n",
12337
12644
  { mode: 384 }
12338
12645
  );
@@ -12355,6 +12662,7 @@ async function startDaemon(options) {
12355
12662
  posture: readPermissionPosture(),
12356
12663
  stop: async () => {
12357
12664
  stopMaintenance();
12665
+ codexWatcher.stop();
12358
12666
  bridge?.stop();
12359
12667
  await sessions.shutdown();
12360
12668
  awake.release();
@@ -12372,9 +12680,9 @@ async function startDaemon(options) {
12372
12680
 
12373
12681
  // ../daemon/src/config.ts
12374
12682
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
12375
- import { dirname as dirname3, join as join7 } from "node:path";
12683
+ import { dirname as dirname3, join as join8 } from "node:path";
12376
12684
  function resolveRelayUrl(given, dataDir2) {
12377
- const path = join7(dataDir2, "config.json");
12685
+ const path = join8(dataDir2, "config.json");
12378
12686
  if (given !== void 0 && given.trim().toLowerCase() === "off") {
12379
12687
  const config = load(path);
12380
12688
  delete config.relayUrl;
@@ -12636,19 +12944,115 @@ function noAddressReason(interfaces = networkInterfaces()) {
12636
12944
  return `No address a phone could dial (found: ${addresses.join(", ")}).`;
12637
12945
  }
12638
12946
 
12947
+ // ../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";
12949
+ import { randomUUID } from "node:crypto";
12950
+ import { join as join9 } from "node:path";
12951
+ var LOCK_NAME = "daemon.lock";
12952
+ var LOCK_KIND = "longleash-daemon";
12953
+ function acquireDaemonInstance(dataDir2) {
12954
+ mkdirSync5(dataDir2, { recursive: true, mode: 448 });
12955
+ const path = join9(dataDir2, LOCK_NAME);
12956
+ const token = randomUUID();
12957
+ const create = () => {
12958
+ const fd = openSync3(path, "wx", 384);
12959
+ try {
12960
+ writeFileSync4(fd, `${JSON.stringify({ kind: LOCK_KIND, pid: process.pid, token, startedAt: Date.now() })}
12961
+ `);
12962
+ } finally {
12963
+ closeSync3(fd);
12964
+ }
12965
+ };
12966
+ try {
12967
+ create();
12968
+ } catch (error) {
12969
+ if (error.code !== "EEXIST") throw error;
12970
+ if (lstatSync(path).isSymbolicLink()) throw new Error(`Refusing symlinked daemon lock: ${path}`);
12971
+ let owner = {};
12972
+ try {
12973
+ owner = JSON.parse(readFileSync5(path, "utf8"));
12974
+ } catch {
12975
+ }
12976
+ if (owner.kind !== LOCK_KIND || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
12977
+ throw new Error(`Unrecognized daemon lock requires manual inspection: ${path}`);
12978
+ }
12979
+ if (processAlive(owner.pid)) {
12980
+ throw new Error(`LongLeash already owns this data directory in process ${owner.pid}. Use \`longleash service status\` before starting another instance.`);
12981
+ }
12982
+ rmSync(path);
12983
+ create();
12984
+ }
12985
+ let held = true;
12986
+ return {
12987
+ path,
12988
+ release() {
12989
+ if (!held) return;
12990
+ held = false;
12991
+ try {
12992
+ const owner = JSON.parse(readFileSync5(path, "utf8"));
12993
+ if (owner.token === token) rmSync(path);
12994
+ } catch {
12995
+ }
12996
+ }
12997
+ };
12998
+ }
12999
+ function processAlive(pid) {
13000
+ try {
13001
+ process.kill(pid, 0);
13002
+ return true;
13003
+ } catch (error) {
13004
+ return error.code === "EPERM";
13005
+ }
13006
+ }
13007
+
13008
+ // ../daemon/src/service-log.ts
13009
+ function persistentServiceLogLine(line) {
13010
+ if (/codex\s+(?:<-|->)/i.test(line)) return null;
13011
+ const stamp = line.match(/^\[[^\]]+\]\s*/)?.[0] ?? "";
13012
+ const message = line.slice(stamp.length);
13013
+ const started2 = message.match(/^(claude|codex) session ([A-Za-z0-9_-]{1,32}) started in .+ \((terminal|vscode|phone)\)$/i);
13014
+ if (started2) return `${stamp}${started2[1].toLowerCase()} session ${started2[2]} started (${started2[3].toLowerCase()})`;
13015
+ if (/^\? .+ in [A-Za-z0-9_-]+ .* -> /i.test(message)) return `${stamp}tool decision recorded`;
13016
+ if (/^(decision|gate|resume|takeOver|stop|session)\s/i.test(message)) return `${stamp}session lifecycle state changed`;
13017
+ if (/^(delegation|start) refused:/i.test(message)) return `${stamp}session request refused; details are available on the paired device`;
13018
+ if (/^(relay|relay link|relay room|dropped a relay frame)/i.test(message)) return `${stamp}relay connection state changed`;
13019
+ if (/^(device|dropping unresponsive connection)/i.test(message)) return `${stamp}device connection state changed`;
13020
+ if (/^push:/i.test(message) || /^push (subscription|test)/i.test(message)) return `${stamp}push delivery state changed`;
13021
+ if (/^(keeping this Mac awake|all agents idle)/i.test(message)) return `${stamp}${message}`;
13022
+ if (/^rebound to /i.test(message)) return `${stamp}local listener rebound`;
13023
+ return `${stamp}service activity recorded; details omitted`;
13024
+ }
13025
+
12639
13026
  // ../daemon/bin/longleashd.ts
12640
13027
  var here = dirname4(fileURLToPath(import.meta.url));
12641
- var APP_DIR = resolve5(here, "../../app/dist");
13028
+ var APP_DIR = resolve6(here, "../../app/dist");
13029
+ var serviceMode = process.env.LONGLEASH_SERVICE === "1";
13030
+ if (serviceMode) {
13031
+ const fatal = () => {
13032
+ console.error("LongLeash service stopped unexpectedly; details omitted. Run `longleash doctor`.");
13033
+ process.exit(1);
13034
+ };
13035
+ process.on("uncaughtException", fatal);
13036
+ process.on("unhandledRejection", fatal);
13037
+ }
13038
+ function daemonLog(line) {
13039
+ if (!serviceMode) {
13040
+ console.log(line);
13041
+ return;
13042
+ }
13043
+ const safe = persistentServiceLogLine(line);
13044
+ if (safe !== null) console.log(safe);
13045
+ }
12642
13046
  var roots = process.argv.slice(2).filter((arg) => !arg.startsWith("-"));
12643
13047
  if (roots.length === 0) {
12644
13048
  console.error("\nUsage: longleashd <project-directory> [more directories\u2026]");
12645
13049
  console.error("Agents may only ever work inside the directories you list.\n");
12646
13050
  process.exit(1);
12647
13051
  }
12648
- var dataDir = process.env.LONGLEASH_DATA ?? join8(homedir4(), ".longleash");
13052
+ var dataDir = process.env.LONGLEASH_DATA ?? join10(homedir5(), ".longleash");
12649
13053
  var relay = resolveRelayUrl(process.env.LONGLEASH_RELAY_URL, dataDir);
12650
13054
  if (relay?.source === "remembered") {
12651
- console.log(`Relay: ${relay.url} (remembered \u2014 LONGLEASH_RELAY_URL=off forgets it)`);
13055
+ console.log(serviceMode ? "Relay configuration loaded." : `Relay: ${relay.url} (remembered \u2014 LONGLEASH_RELAY_URL=off forgets it)`);
12652
13056
  }
12653
13057
  var candidates = findCandidates();
12654
13058
  var best = candidates[0];
@@ -12698,15 +13102,17 @@ async function boot(host, port) {
12698
13102
  allowedRoots: roots,
12699
13103
  host,
12700
13104
  port,
12701
- ...existsSync7(join8(APP_DIR, "index.html")) ? { staticRoot: APP_DIR } : {},
13105
+ ...existsSync7(join10(APP_DIR, "index.html")) ? { staticRoot: APP_DIR } : {},
12702
13106
  denyOutsideRoot: process.env.LONGLEASH_STRICT !== "0",
12703
13107
  // LONGLEASH_ASK_EVERYTHING=1 pre-approves nothing, so even reading a file comes to your phone.
12704
13108
  ...process.env.LONGLEASH_ASK_EVERYTHING === "1" ? { allowedTools: [] } : {},
12705
13109
  dataDir,
12706
- log: (line) => console.log(line),
13110
+ log: daemonLog,
12707
13111
  ...relay === null ? {} : { relayUrl: relay.url }
12708
13112
  });
12709
13113
  }
13114
+ var instanceLock = acquireDaemonInstance(dataDir);
13115
+ process.once("exit", () => instanceLock.release());
12710
13116
  var started = await (async () => {
12711
13117
  const firstPort = holder === "other" ? 0 : wantedPort;
12712
13118
  try {
@@ -12738,7 +13144,7 @@ function relayAppOrigin(wsUrl) {
12738
13144
  function freshPairingUrl() {
12739
13145
  const challenge = daemon.registry.createPairingChallenge();
12740
13146
  if (relay !== null) {
12741
- hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) => console.log(`[pair] ${line}`) });
13147
+ hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) => daemonLog(`[pair] ${line}`) });
12742
13148
  return pairingUrl(relayAppOrigin(relay.url), challenge.challengeId, challenge.secret);
12743
13149
  }
12744
13150
  return pairingUrl(
@@ -12747,15 +13153,16 @@ function freshPairingUrl() {
12747
13153
  challenge.secret
12748
13154
  );
12749
13155
  }
12750
- var url = freshPairingUrl();
13156
+ daemon.server.attachLocalPairing(freshPairingUrl);
13157
+ var url = serviceMode ? null : freshPairingUrl();
12751
13158
  console.log("\n=== LongLeash ===\n");
12752
13159
  var warn = vpnWarning();
12753
13160
  if (warn) console.log(`!!! ${warn}
12754
13161
  `);
12755
- if (!existsSync7(join8(APP_DIR, "index.html"))) {
13162
+ if (!existsSync7(join10(APP_DIR, "index.html"))) {
12756
13163
  console.log("The web app is not built yet \u2014 run `pnpm --filter @longleash/app build` first.\n");
12757
13164
  }
12758
- if (daemon.posture.gateWeakened) {
13165
+ if (daemon.posture.gateWeakened && !serviceMode) {
12759
13166
  console.log(`Note: your Claude Code settings pre-approve ${daemon.posture.allowRuleCount} pattern(s),`);
12760
13167
  console.log("e.g. " + daemon.posture.examples.join(", "));
12761
13168
  console.log("Those commands run WITHOUT asking your phone. They still appear in the activity");
@@ -12776,23 +13183,29 @@ if (preApproved.length > 0) {
12776
13183
  } else {
12777
13184
  console.log("");
12778
13185
  }
12779
- console.log("Agents may work only in:");
12780
- for (const root of roots) console.log(` ${resolve5(root)}`);
12781
- console.log(
12782
- servedOnLan && best ? `
13186
+ if (serviceMode) {
13187
+ console.log(`Background service ready with ${roots.length} allowed project root(s).`);
13188
+ console.log(`Connectivity: ${relay !== null ? "encrypted relay enabled" : "LAN only"}.`);
13189
+ console.log("Pairing secrets are never written to service logs. Run `longleash pair` in a terminal.\n");
13190
+ } else {
13191
+ console.log("Agents may work only in:");
13192
+ for (const root of roots) console.log(` ${resolve6(root)}`);
13193
+ console.log(
13194
+ servedOnLan && best ? `
12783
13195
  Listening on ${servedHost}:${daemon.port} (${best.iface}, ${best.label})` : `
12784
13196
  Listening on ${servedHost}:${daemon.port} (no local network \u2014 relay only)`
12785
- );
12786
- console.log(
12787
- 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."
12788
- );
12789
- console.log("\nScan this with your phone, then add it to your home screen:\n");
12790
- console.log(terminalQr(url));
12791
- console.log(`
13197
+ );
13198
+ console.log(
13199
+ 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
+ );
13201
+ console.log("\nScan this with your phone, then add it to your home screen:\n");
13202
+ console.log(terminalQr(url));
13203
+ console.log(`
12792
13204
  ${url}
12793
13205
  `);
12794
- if (relay !== null && servedOnLan) {
12795
- console.log(`(LAN fallback for pairing at home: http://${servedHost}:${daemon.port}/#c=\u2026&s=\u2026 \u2014 same code)`);
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
+ }
12796
13209
  }
12797
13210
  var NETWORK_WATCH_MS = 5e3;
12798
13211
  var networkWatch = setInterval(() => {
@@ -12803,23 +13216,23 @@ var networkWatch = setInterval(() => {
12803
13216
  servingHost = nextHost;
12804
13217
  void daemon.server.rebind(nextHost).then((port) => {
12805
13218
  servingPort = port;
12806
- console.log(`
13219
+ console.log(serviceMode ? "\nNetwork changed; the local listener recovered." : `
12807
13220
  Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
12808
13221
  console.log(
12809
13222
  nextBest ? "Press n + Enter for a pairing QR on this network." : "No local network now \u2014 the relay carries everything."
12810
13223
  );
12811
13224
  }).catch((err) => {
12812
- console.log(`
13225
+ console.log(serviceMode ? "\nNetwork changed but local listener recovery failed; details omitted." : `
12813
13226
  Network changed but rebinding failed (${String(err)}).`);
12814
13227
  console.log(relay !== null ? "The relay is unaffected." : "Restart the daemon to recover.");
12815
13228
  });
12816
13229
  }, NETWORK_WATCH_MS);
12817
13230
  networkWatch.unref?.();
12818
- console.log("Press n + Enter for a fresh pairing QR, r + Enter to revoke every device, q + Enter to quit.\n");
13231
+ if (!serviceMode) console.log("Press n + Enter for a fresh pairing QR, r + Enter to revoke every device, q + Enter to quit.\n");
12819
13232
  process.stdin.setEncoding("utf8");
12820
13233
  process.stdin.on("data", (chunk) => {
12821
13234
  const key = chunk.trim().toLowerCase();
12822
- if (key === "n") {
13235
+ if (key === "n" && !serviceMode) {
12823
13236
  const next = freshPairingUrl();
12824
13237
  console.log("\nScan this with your phone:\n");
12825
13238
  console.log(terminalQr(next));
@@ -12834,12 +13247,18 @@ process.stdin.on("data", (chunk) => {
12834
13247
  }
12835
13248
  if (key === "q") {
12836
13249
  clearInterval(networkWatch);
12837
- void daemon.stop().then(() => process.exit(0));
13250
+ void daemon.stop().then(() => {
13251
+ instanceLock.release();
13252
+ process.exit(0);
13253
+ });
12838
13254
  }
12839
13255
  });
12840
13256
  var shutdown = () => {
12841
13257
  clearInterval(networkWatch);
12842
- void daemon.stop().then(() => process.exit(0));
13258
+ void daemon.stop().then(() => {
13259
+ instanceLock.release();
13260
+ process.exit(0);
13261
+ });
12843
13262
  };
12844
13263
  process.on("SIGINT", shutdown);
12845
13264
  process.on("SIGTERM", shutdown);