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

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,10 @@ 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
+ /** Where the process is controlled now; origin remains its historical birthplace. */
5500
+ surface: SessionOrigin.optional(),
5501
+ /** Observation-only means the native surface is visible but has not exposed process control. */
5502
+ control: external_exports.enum(["full", "observe"]).optional(),
5496
5503
  /**
5497
5504
  * The agent's own conversation id. Carried to the phone so a person can pick the
5498
5505
  * SAME conversation back up at their keyboard — `claude --resume <id>` in the
@@ -5526,6 +5533,10 @@ var sessionStatusPayload = external_exports.object({
5526
5533
  /** LongLeash's own gate for this session: whether it should page the phone at all. */
5527
5534
  gate: SessionGate.optional(),
5528
5535
  controller: external_exports.enum(["longleash", "external"]).optional(),
5536
+ /** Current UI/process surface after a handoff. */
5537
+ surface: SessionOrigin.optional(),
5538
+ /** Observation-only sessions are never presented as remotely stoppable or writable. */
5539
+ control: external_exports.enum(["full", "observe"]).optional(),
5529
5540
  /** Current overrides. An empty object explicitly means provider defaults. */
5530
5541
  settings: SessionSettings.optional(),
5531
5542
  /** A second writer was observed in this checkout; text accompanies color for accessibility. */
@@ -5540,6 +5551,13 @@ var streamDeltaPayload = external_exports.object({
5540
5551
  kind: external_exports.enum(["text", "tool", "thinking", "user"]),
5541
5552
  text: external_exports.string()
5542
5553
  }).passthrough();
5554
+ var transcriptResetPayload = external_exports.object({
5555
+ reason: external_exports.enum(["provider-snapshot", "recovery"]),
5556
+ blocks: external_exports.array(external_exports.object({
5557
+ kind: external_exports.enum(["text", "tool", "thinking", "user"]),
5558
+ text: external_exports.string().max(12e4)
5559
+ }).strict()).max(80).optional()
5560
+ }).passthrough();
5543
5561
  var QuestionOption = external_exports.object({
5544
5562
  label: external_exports.string().min(1),
5545
5563
  description: external_exports.string().default(""),
@@ -5599,6 +5617,7 @@ function event(type, payload) {
5599
5617
  var SessionEventSchema = external_exports.discriminatedUnion("type", [
5600
5618
  event("session.started", sessionStartedPayload),
5601
5619
  event("session.status", sessionStatusPayload),
5620
+ event("session.transcript.reset", transcriptResetPayload),
5602
5621
  event("stream.delta", streamDeltaPayload),
5603
5622
  event("approval.requested", approvalRequestedPayload),
5604
5623
  event("approval.decided", approvalDecidedPayload),
@@ -5614,7 +5633,9 @@ var subscribeMessage = external_exports.object({
5614
5633
  ...clientBase,
5615
5634
  type: external_exports.literal("subscribe"),
5616
5635
  sessionId: external_exports.string().min(1),
5617
- fromCursor: external_exports.number().int().nonnegative()
5636
+ fromCursor: external_exports.number().int().nonnegative(),
5637
+ /** Optional replay barrier. New clients use the echoed id to paint catch-up only once. */
5638
+ syncId: external_exports.string().min(1).max(120).optional()
5618
5639
  }).passthrough();
5619
5640
  var decisionMessage = external_exports.object({
5620
5641
  ...clientBase,
@@ -5700,6 +5721,20 @@ var takeOverMessage = external_exports.object({
5700
5721
  sessionId: external_exports.string().min(1),
5701
5722
  text: external_exports.string().min(1)
5702
5723
  }).passthrough();
5724
+ var reclaimSessionMessage = external_exports.object({
5725
+ ...clientBase,
5726
+ type: external_exports.literal("reclaimSession"),
5727
+ sessionId: external_exports.string().min(1)
5728
+ }).passthrough();
5729
+ var renameSessionMessage = external_exports.object({
5730
+ ...clientBase,
5731
+ type: external_exports.literal("renameSession"),
5732
+ sessionId: external_exports.string().min(1),
5733
+ title: external_exports.string().trim().min(1).max(80).refine(
5734
+ (value) => value.replace(/[\u0000-\u001f\u007f]/g, "").trim().length > 0,
5735
+ "A session name needs visible text."
5736
+ )
5737
+ }).passthrough();
5703
5738
  var previewDelegationMessage = external_exports.object({
5704
5739
  ...clientBase,
5705
5740
  type: external_exports.literal("previewDelegation"),
@@ -5842,6 +5877,8 @@ var ClientMessageSchema = external_exports.discriminatedUnion("type", [
5842
5877
  pushUnsubscribeMessage,
5843
5878
  pushTestMessage,
5844
5879
  takeOverMessage,
5880
+ reclaimSessionMessage,
5881
+ renameSessionMessage,
5845
5882
  setGateMessage,
5846
5883
  previewDelegationMessage,
5847
5884
  startDelegationMessage,
@@ -5859,6 +5896,7 @@ var EventLog = class {
5859
5896
  insertStmt;
5860
5897
  maxSeqStmt;
5861
5898
  minSeqStmt;
5899
+ latestTsStmt;
5862
5900
  selectFromStmt;
5863
5901
  pruneStmt;
5864
5902
  constructor(path, opts = {}) {
@@ -5874,7 +5912,12 @@ var EventLog = class {
5874
5912
  type TEXT NOT NULL,
5875
5913
  payload TEXT NOT NULL,
5876
5914
  PRIMARY KEY (session_id, seq)
5877
- )
5915
+ );
5916
+ CREATE TABLE IF NOT EXISTS session_aliases (
5917
+ session_id TEXT PRIMARY KEY,
5918
+ title TEXT NOT NULL,
5919
+ updated_at INTEGER NOT NULL
5920
+ );
5878
5921
  `);
5879
5922
  this.now = opts.now ?? Date.now;
5880
5923
  this.insertStmt = this.rawDb.prepare(
@@ -5886,6 +5929,9 @@ var EventLog = class {
5886
5929
  this.minSeqStmt = this.rawDb.prepare(
5887
5930
  "SELECT COALESCE(MIN(seq), 0) AS seq FROM events WHERE session_id = ?"
5888
5931
  );
5932
+ this.latestTsStmt = this.rawDb.prepare(
5933
+ "SELECT COALESCE(MAX(ts), 0) AS ts FROM events WHERE session_id = ?"
5934
+ );
5889
5935
  this.selectFromStmt = this.rawDb.prepare(
5890
5936
  "SELECT * FROM events WHERE session_id = ? AND seq > ? ORDER BY seq ASC"
5891
5937
  );
@@ -5940,6 +5986,19 @@ var EventLog = class {
5940
5986
  latestSeq(sessionId) {
5941
5987
  return this.maxSeqStmt.get(sessionId).seq;
5942
5988
  }
5989
+ latestTimestamp(sessionId) {
5990
+ return this.latestTsStmt.get(sessionId).ts;
5991
+ }
5992
+ aliasFor(sessionId) {
5993
+ const row = this.rawDb.prepare("SELECT title FROM session_aliases WHERE session_id = ?").get(sessionId);
5994
+ return row?.title;
5995
+ }
5996
+ setAlias(sessionId, title) {
5997
+ this.rawDb.prepare(
5998
+ `INSERT INTO session_aliases (session_id, title, updated_at) VALUES (?, ?, ?)
5999
+ ON CONFLICT(session_id) DO UPDATE SET title = excluded.title, updated_at = excluded.updated_at`
6000
+ ).run(sessionId, title, this.now());
6001
+ }
5943
6002
  pruneBefore(sessionId, uptoExclusive) {
5944
6003
  this.pruneStmt.run(sessionId, uptoExclusive);
5945
6004
  }
@@ -6668,13 +6727,13 @@ function workspaceFromRow(row) {
6668
6727
  }
6669
6728
  var newId = (prefix) => `${prefix}_${randomBytes2(9).toString("base64url")}`;
6670
6729
  function settlesWithin(promise, timeoutMs) {
6671
- return new Promise((resolve6) => {
6730
+ return new Promise((resolve7) => {
6672
6731
  let settled = false;
6673
6732
  const finish = (value) => {
6674
6733
  if (settled) return;
6675
6734
  settled = true;
6676
6735
  clearTimeout(timer);
6677
- resolve6(value);
6736
+ resolve7(value);
6678
6737
  };
6679
6738
  const timer = setTimeout(() => finish(false), timeoutMs);
6680
6739
  timer.unref?.();
@@ -7093,6 +7152,26 @@ var SessionManager = class {
7093
7152
  };
7094
7153
  });
7095
7154
  }
7155
+ /** Find the stable LongLeash card that already owns this provider conversation id. */
7156
+ sessionIdForAgentSession(agentSessionId) {
7157
+ const row = this.approvals.rawDb.prepare("SELECT session_id FROM sessions WHERE agent_session_id = ? ORDER BY started_at DESC LIMIT 1").get(agentSessionId);
7158
+ return row?.session_id;
7159
+ }
7160
+ /** Persist a human label independently of whatever title a provider derives later. */
7161
+ renameSession(sessionId, title, emit = true) {
7162
+ const changed = this.approvals.rawDb.prepare("UPDATE sessions SET title = ? WHERE session_id = ?").run(title, sessionId).changes;
7163
+ if (changed === 0) return false;
7164
+ const live = this.sessions.get(sessionId);
7165
+ if (live !== void 0) live.title = title;
7166
+ this.eventLog.setAlias(sessionId, title);
7167
+ if (emit) {
7168
+ this.emit(sessionId, {
7169
+ type: "session.status",
7170
+ payload: { status: live?.status ?? "waiting", live: live !== void 0, title }
7171
+ });
7172
+ }
7173
+ return true;
7174
+ }
7096
7175
  /**
7097
7176
  * Adopt a finished TERMINAL session so a phone reply can wake it: the
7098
7177
  * conversation lives in Claude Code's own storage, keyed by its resume id,
@@ -7609,7 +7688,7 @@ var SessionManager = class {
7609
7688
  this.approvals.rawDb.prepare("DELETE FROM session_deliveries WHERE delivery_id = ?").run(deliveryId);
7610
7689
  }
7611
7690
  releasePending(session, message) {
7612
- for (const [approvalId, resolve6] of session.waiting) {
7691
+ for (const [approvalId, resolve7] of session.waiting) {
7613
7692
  const decided = this.approvals.decide(approvalId, "denied", "system:session-ended", message);
7614
7693
  if (decided) {
7615
7694
  this.emit(session.sessionId, {
@@ -7622,7 +7701,7 @@ var SessionManager = class {
7622
7701
  }
7623
7702
  });
7624
7703
  }
7625
- resolve6({ behavior: "deny", message });
7704
+ resolve7({ behavior: "deny", message });
7626
7705
  }
7627
7706
  session.waiting.clear();
7628
7707
  }
@@ -7643,9 +7722,9 @@ var SessionManager = class {
7643
7722
  waitForApproval(sessionId) {
7644
7723
  const alreadyPending = this.approvals.listPending().some((a) => a.sessionId === sessionId);
7645
7724
  if (alreadyPending) return Promise.resolve();
7646
- return new Promise((resolve6) => {
7725
+ return new Promise((resolve7) => {
7647
7726
  const waiters = this.approvalWaiters.get(sessionId) ?? [];
7648
- waiters.push(resolve6);
7727
+ waiters.push(resolve7);
7649
7728
  this.approvalWaiters.set(sessionId, waiters);
7650
7729
  });
7651
7730
  }
@@ -7694,8 +7773,8 @@ var SessionManager = class {
7694
7773
  outsideRoot
7695
7774
  }
7696
7775
  });
7697
- const decision = new Promise((resolve6) => {
7698
- waiting.set(approvalId, resolve6);
7776
+ const decision = new Promise((resolve7) => {
7777
+ waiting.set(approvalId, resolve7);
7699
7778
  });
7700
7779
  for (const waiter of this.approvalWaiters.get(sessionId) ?? []) waiter();
7701
7780
  this.approvalWaiters.delete(sessionId);
@@ -7864,6 +7943,7 @@ var ExternalSessions = class {
7864
7943
  stopTimeoutMs;
7865
7944
  onEnded;
7866
7945
  workspace;
7946
+ resolveSessionId;
7867
7947
  unsubscribeWorkspace;
7868
7948
  constructor(opts) {
7869
7949
  this.eventLog = opts.eventLog;
@@ -7883,6 +7963,7 @@ var ExternalSessions = class {
7883
7963
  this.waitForExit = opts.waitForExit ?? ((pid, timeoutMs) => this.pollForExit(pid, timeoutMs));
7884
7964
  this.onEnded = opts.onEnded;
7885
7965
  this.workspace = opts.workspace;
7966
+ this.resolveSessionId = opts.resolveSessionId;
7886
7967
  this.unsubscribeWorkspace = this.workspace?.subscribe(() => this.reconcileWorkspace());
7887
7968
  for (const orphan of this.approvals.closeOrphans(
7888
7969
  "The daemon restarted; this was answered in the terminal."
@@ -7906,6 +7987,38 @@ var ExternalSessions = class {
7906
7987
  hasLiveSession(sessionId) {
7907
7988
  return [...this.sessions.values()].some((session) => session.sessionId === sessionId);
7908
7989
  }
7990
+ renameSession(sessionId, title) {
7991
+ const entry = [...this.sessions.entries()].find(([, session2]) => session2.sessionId === sessionId);
7992
+ if (entry === void 0) return false;
7993
+ const [agentSessionId, session] = entry;
7994
+ session.title = title;
7995
+ session.named = true;
7996
+ this.eventLog.setAlias(sessionId, title);
7997
+ this.persist(agentSessionId, session);
7998
+ this.emit(sessionId, {
7999
+ type: "session.status",
8000
+ payload: {
8001
+ status: session.status,
8002
+ live: true,
8003
+ title,
8004
+ controller: "external",
8005
+ control: session.observedOnly ? "observe" : "full",
8006
+ surface: session.surface
8007
+ }
8008
+ });
8009
+ return true;
8010
+ }
8011
+ /**
8012
+ * End a native writer so the same conversation can be parked on the phone.
8013
+ * Observation-only sessions have no authenticated PID and are never remotely terminated.
8014
+ */
8015
+ async reclaimToPhone(externalSessionId, decidedBy) {
8016
+ const session = [...this.sessions.values()].find((item) => item.sessionId === externalSessionId);
8017
+ if (session === void 0) return "not-found";
8018
+ if (session.observedOnly) return "observe-only";
8019
+ if (session.pid === null) return "unverified";
8020
+ return await this.stop(externalSessionId, decidedBy) ? "reclaimed" : "unverified";
8021
+ }
7909
8022
  /**
7910
8023
  * Take back the sessions a previous run was watching.
7911
8024
  *
@@ -7934,7 +8047,7 @@ var ExternalSessions = class {
7934
8047
  transcriptPath: row.transcriptPath,
7935
8048
  status: "running",
7936
8049
  startedAt: row.startedAt,
7937
- title: row.title,
8050
+ title: this.eventLog.aliasFor(row.sessionId) ?? row.title,
7938
8051
  pid: row.pid,
7939
8052
  named: true,
7940
8053
  permissionMode: null,
@@ -7943,7 +8056,8 @@ var ExternalSessions = class {
7943
8056
  offset: existsSync(row.transcriptPath) ? statSync(row.transcriptPath).size : 0,
7944
8057
  remainder: "",
7945
8058
  timer: null,
7946
- suspended: false
8059
+ suspended: false,
8060
+ observedOnly: false
7947
8061
  };
7948
8062
  this.sessions.set(row.agentSessionId, session);
7949
8063
  this.claimWorkspace(session);
@@ -7951,7 +8065,13 @@ var ExternalSessions = class {
7951
8065
  session.timer.unref?.();
7952
8066
  this.emit(session.sessionId, {
7953
8067
  type: "session.status",
7954
- payload: { status: "running", live: true }
8068
+ payload: {
8069
+ status: "running",
8070
+ live: true,
8071
+ controller: "external",
8072
+ control: "full",
8073
+ surface: session.surface
8074
+ }
7955
8075
  });
7956
8076
  }
7957
8077
  }
@@ -7964,6 +8084,39 @@ var ExternalSessions = class {
7964
8084
  }
7965
8085
  this.claimWorkspace(session);
7966
8086
  }
8087
+ /** Adopt a Codex transcript observed after startup, including sessions older than the hooks. */
8088
+ observeCodexSession(info) {
8089
+ const session = this.ensure(info.sessionId, info.cwd, info.transcriptPath, "codex", info.surface, true);
8090
+ const alias = this.eventLog.aliasFor(session.sessionId);
8091
+ const nextTitle = alias ?? info.title;
8092
+ if (nextTitle !== void 0 && nextTitle !== session.title) {
8093
+ session.named = true;
8094
+ session.title = nextTitle;
8095
+ this.persist(info.sessionId, session);
8096
+ }
8097
+ if (session.observedOnly && session.observedFingerprint === void 0) {
8098
+ session.observedFingerprint = JSON.stringify(info.snapshot);
8099
+ this.emit(session.sessionId, {
8100
+ type: "session.transcript.reset",
8101
+ payload: { reason: "provider-snapshot", blocks: info.snapshot }
8102
+ });
8103
+ }
8104
+ this.emit(session.sessionId, {
8105
+ type: "session.status",
8106
+ payload: {
8107
+ status: session.status,
8108
+ live: true,
8109
+ controller: "external",
8110
+ control: session.observedOnly ? "observe" : "full",
8111
+ ...nextTitle === void 0 ? {} : { title: nextTitle }
8112
+ }
8113
+ });
8114
+ if (session.observedOnly) {
8115
+ if (session.workspaceConflict !== void 0) delete session.workspaceConflict;
8116
+ } else {
8117
+ this.claimWorkspace(session);
8118
+ }
8119
+ }
7967
8120
  /**
7968
8121
  * Stop a terminal session from the phone — for real. The recorded pid is only
7969
8122
  * trusted after re-verifying it still belongs to a claude process, because PIDs
@@ -7973,6 +8126,7 @@ var ExternalSessions = class {
7973
8126
  const entry = [...this.sessions.entries()].find(([, s]) => s.sessionId === externalSessionId);
7974
8127
  if (!entry) return false;
7975
8128
  const [claudeSessionId, session] = entry;
8129
+ if (session.observedOnly) return false;
7976
8130
  if (session.pid === null || !this.isClaudeProcess(session.pid)) {
7977
8131
  this.emit(session.sessionId, {
7978
8132
  type: "stream.delta",
@@ -8055,7 +8209,7 @@ var ExternalSessions = class {
8055
8209
  const deadline = Date.now() + timeoutMs;
8056
8210
  while (Date.now() < deadline) {
8057
8211
  if (!this.isClaudeProcess(pid)) return true;
8058
- await new Promise((resolve6) => setTimeout(resolve6, 50));
8212
+ await new Promise((resolve7) => setTimeout(resolve7, 50));
8059
8213
  }
8060
8214
  return !this.isClaudeProcess(pid);
8061
8215
  }
@@ -8063,7 +8217,7 @@ var ExternalSessions = class {
8063
8217
  * A tool wants to run in the terminal. Returns what the hook should tell
8064
8218
  * Claude Code — and "ask" always means "behave as if we were never here".
8065
8219
  */
8066
- preToolUse(claudeSessionId, cwd, transcriptPath, toolName, toolInput, permissionMode, opts = {}) {
8220
+ preToolUse(claudeSessionId, cwd, transcriptPath, toolName, toolInput, permissionMode2, opts = {}) {
8067
8221
  const { dedupeKey, agent = "claude", surface = "terminal", abandoned, enforceWorkspace } = opts;
8068
8222
  const session = this.ensure(claudeSessionId, cwd, transcriptPath, agent, surface);
8069
8223
  if (enforceWorkspace === true && session.workspaceConflict !== void 0) {
@@ -8080,11 +8234,11 @@ var ExternalSessions = class {
8080
8234
  if (session.gate === "auto") {
8081
8235
  return Promise.resolve({ decision: "allow", reason: "Auto-approved: you muted this session." });
8082
8236
  }
8083
- if (permissionMode !== void 0 && permissionMode !== session.permissionMode) {
8084
- session.permissionMode = permissionMode;
8237
+ if (permissionMode2 !== void 0 && permissionMode2 !== session.permissionMode) {
8238
+ session.permissionMode = permissionMode2;
8085
8239
  this.emit(session.sessionId, {
8086
8240
  type: "session.status",
8087
- payload: { status: session.status, live: true, permissionMode }
8241
+ payload: { status: session.status, live: true, permissionMode: permissionMode2 }
8088
8242
  });
8089
8243
  }
8090
8244
  const who = this.audience();
@@ -8119,7 +8273,7 @@ var ExternalSessions = class {
8119
8273
  ...questions === null ? {} : { questions }
8120
8274
  }
8121
8275
  });
8122
- const pending = new Promise((resolve6) => {
8276
+ const pending = new Promise((resolve7) => {
8123
8277
  const timer = setTimeout(() => {
8124
8278
  this.waiting.delete(approvalId);
8125
8279
  this.approvals.decide(
@@ -8137,11 +8291,11 @@ var ExternalSessions = class {
8137
8291
  reply: "Answered in the terminal instead."
8138
8292
  }
8139
8293
  });
8140
- resolve6({ decision: "ask", reason: "No answer from the phone; deciding in the terminal." });
8294
+ resolve7({ decision: "ask", reason: "No answer from the phone; deciding in the terminal." });
8141
8295
  }, holdMs);
8142
8296
  timer.unref?.();
8143
8297
  this.waiting.set(approvalId, {
8144
- resolve: resolve6,
8298
+ resolve: resolve7,
8145
8299
  timer,
8146
8300
  sessionId: session.sessionId,
8147
8301
  ...questions === null ? {} : { questions }
@@ -8258,7 +8412,7 @@ var ExternalSessions = class {
8258
8412
  /** Mute or unmute a session from the phone. Returns false for a session it does not know. */
8259
8413
  setGate(externalSessionId, gate) {
8260
8414
  const session = [...this.sessions.values()].find((s) => s.sessionId === externalSessionId);
8261
- if (!session) return false;
8415
+ if (!session || session.observedOnly) return false;
8262
8416
  session.gate = gate;
8263
8417
  this.emit(session.sessionId, {
8264
8418
  type: "session.status",
@@ -8311,11 +8465,13 @@ var ExternalSessions = class {
8311
8465
  resumeId: claudeSessionId,
8312
8466
  gate: session.gate,
8313
8467
  controller: "external",
8468
+ control: session.observedOnly ? "observe" : "full",
8314
8469
  ...session.workspaceConflict === void 0 ? {} : { workspaceConflict: session.workspaceConflict }
8315
8470
  }));
8316
8471
  }
8317
8472
  /** Write this session down so a restart can take it back. */
8318
8473
  persist(agentSessionId, session) {
8474
+ if (session.observedOnly) return;
8319
8475
  this.registry?.remember({
8320
8476
  agentSessionId,
8321
8477
  sessionId: session.sessionId,
@@ -8367,12 +8523,34 @@ var ExternalSessions = class {
8367
8523
  this.sessions.clear();
8368
8524
  }
8369
8525
  /* ------------------------------------------------------------------ internals */
8370
- ensure(claudeSessionId, cwd, transcriptPath, agent = "claude", surface = "terminal") {
8526
+ ensure(claudeSessionId, cwd, transcriptPath, agent = "claude", surface = "terminal", tailOnly = false) {
8371
8527
  const existing = this.sessions.get(claudeSessionId);
8372
- if (existing) return existing;
8373
- const sessionId = `ext_${claudeSessionId}`;
8528
+ if (existing) {
8529
+ if (!tailOnly && existing.observedOnly) {
8530
+ existing.observedOnly = false;
8531
+ this.persist(claudeSessionId, existing);
8532
+ this.emit(existing.sessionId, {
8533
+ type: "session.status",
8534
+ payload: {
8535
+ status: existing.status,
8536
+ live: true,
8537
+ controller: "external",
8538
+ control: "full",
8539
+ surface: existing.surface
8540
+ }
8541
+ });
8542
+ if (existing.timer === null) {
8543
+ existing.timer = setInterval(() => this.drain(existing), this.pollMs);
8544
+ existing.timer.unref?.();
8545
+ this.drain(existing);
8546
+ }
8547
+ }
8548
+ return existing;
8549
+ }
8550
+ const sessionId = this.resolveSessionId?.(claudeSessionId) ?? `ext_${claudeSessionId}`;
8374
8551
  const replay = this.eventLog.replay(sessionId, 0);
8375
8552
  const hasHistory = replay.gap === false ? replay.events.length > 0 : true;
8553
+ const alias = this.eventLog.aliasFor(sessionId);
8376
8554
  const session = {
8377
8555
  sessionId,
8378
8556
  agent,
@@ -8381,15 +8559,16 @@ var ExternalSessions = class {
8381
8559
  transcriptPath,
8382
8560
  status: "running",
8383
8561
  startedAt: this.now(),
8384
- title: `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
8562
+ title: alias ?? `${basename3(cwd)} \u2014 ${surface === "vscode" ? "VS Code" : AGENT_LABEL[agent]}`,
8385
8563
  pid: null,
8386
- named: false,
8564
+ named: alias !== void 0,
8387
8565
  permissionMode: null,
8388
8566
  gate: "ask",
8389
- offset: hasHistory && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
8567
+ offset: (tailOnly || hasHistory) && existsSync(transcriptPath) ? statSync(transcriptPath).size : 0,
8390
8568
  remainder: "",
8391
8569
  timer: null,
8392
- suspended: false
8570
+ suspended: false,
8571
+ observedOnly: tailOnly
8393
8572
  };
8394
8573
  this.sessions.set(claudeSessionId, session);
8395
8574
  this.persist(claudeSessionId, session);
@@ -8402,13 +8581,21 @@ var ExternalSessions = class {
8402
8581
  title: session.title,
8403
8582
  origin: surface,
8404
8583
  controller: "external",
8584
+ control: tailOnly ? "observe" : "full",
8585
+ surface,
8405
8586
  resumeId: claudeSessionId
8406
8587
  }
8407
8588
  });
8408
8589
  }
8409
8590
  this.emit(sessionId, {
8410
8591
  type: "session.status",
8411
- payload: { status: "running", live: true, controller: "external" }
8592
+ payload: {
8593
+ status: "running",
8594
+ live: true,
8595
+ controller: "external",
8596
+ control: tailOnly ? "observe" : "full",
8597
+ surface
8598
+ }
8412
8599
  });
8413
8600
  session.timer = setInterval(() => this.drain(session), this.pollMs);
8414
8601
  session.timer.unref?.();
@@ -8417,7 +8604,9 @@ var ExternalSessions = class {
8417
8604
  }
8418
8605
  /** Retry claims after startup reconciliation released dead managed-session ownership. */
8419
8606
  reconcileWorkspace() {
8420
- for (const session of this.sessions.values()) this.claimWorkspace(session);
8607
+ for (const session of this.sessions.values()) {
8608
+ if (!session.observedOnly) this.claimWorkspace(session);
8609
+ }
8421
8610
  }
8422
8611
  claimWorkspace(session) {
8423
8612
  if (this.workspace === void 0 || session.cwd.trim() === "") return;
@@ -8636,6 +8825,9 @@ var RelayLink = class {
8636
8825
  reconnectTimer = null;
8637
8826
  keepaliveTimer = null;
8638
8827
  keepaliveMs;
8828
+ /** WebCrypto promises may resolve out of order; preserve the server's event order explicitly. */
8829
+ outbound = Promise.resolve();
8830
+ inbound = Promise.resolve();
8639
8831
  constructor(opts) {
8640
8832
  this.opts = opts;
8641
8833
  this.log = opts.log ?? (() => {
@@ -8672,10 +8864,12 @@ var RelayLink = class {
8672
8864
  const socket = this.socket;
8673
8865
  const identity = this.identity;
8674
8866
  if (this.currentStatus !== "connected" || !socket || !identity) return;
8675
- void seal(identity, plaintext).then((payload) => {
8676
- if (socket.readyState === WebSocket.OPEN) {
8867
+ this.outbound = this.outbound.then(async () => {
8868
+ const payload = await seal(identity, plaintext);
8869
+ if (this.socket === socket && socket.readyState === WebSocket.OPEN) {
8677
8870
  socket.send(JSON.stringify({ v: 1, type: "frame", payload }));
8678
8871
  }
8872
+ }).catch(() => {
8679
8873
  });
8680
8874
  }
8681
8875
  async connect() {
@@ -8714,9 +8908,12 @@ var RelayLink = class {
8714
8908
  return;
8715
8909
  }
8716
8910
  if (message.type === "frame" && typeof message.payload === "string") {
8717
- void open(identity, message.payload).then((plaintext) => {
8911
+ const payload = message.payload;
8912
+ this.inbound = this.inbound.then(async () => {
8913
+ const plaintext = await open(identity, payload);
8718
8914
  if (plaintext !== null) this.opts.onMessage(plaintext);
8719
8915
  else this.log("dropped a relay frame that failed to open");
8916
+ }).catch(() => {
8720
8917
  });
8721
8918
  return;
8722
8919
  }
@@ -9958,6 +10155,7 @@ var LongLeashServer = class {
9958
10155
  external = null;
9959
10156
  delegations = null;
9960
10157
  hookSecret = null;
10158
+ localPairing = null;
9961
10159
  staticRoot;
9962
10160
  /** Captured at process start so updating files on disk cannot impersonate a daemon restart. */
9963
10161
  expectedBuild;
@@ -10085,6 +10283,15 @@ var LongLeashServer = class {
10085
10283
  this.log(`device ${deviceId} revoked \u2014 its sessions are closed and its relay room is shut`);
10086
10284
  return { revoked: true, deviceId };
10087
10285
  });
10286
+ this.app.post("/local/pairing", async (request, reply) => {
10287
+ if (!localOnly(request)) return reply.code(401).send({ reason: "unauthorized" });
10288
+ if (this.localPairing === null) return reply.code(503).send({ reason: "pairing-unavailable" });
10289
+ try {
10290
+ return { url: this.localPairing() };
10291
+ } catch {
10292
+ return reply.code(503).send({ reason: "pairing-failed" });
10293
+ }
10294
+ });
10088
10295
  this.app.get("/pair", async (_request, reply) => reply.code(405).send({ reason: "use-post" }));
10089
10296
  this.app.post("/pair", async (request, reply) => {
10090
10297
  const query2 = request.query;
@@ -10190,15 +10397,15 @@ var LongLeashServer = class {
10190
10397
  */
10191
10398
  async rebind(host) {
10192
10399
  const wanted = this.boundPort;
10193
- await new Promise((resolve6) => this.app.server.close(() => resolve6()));
10194
- const tryListen = (port) => new Promise((resolve6, reject) => {
10400
+ await new Promise((resolve7) => this.app.server.close(() => resolve7()));
10401
+ const tryListen = (port) => new Promise((resolve7, reject) => {
10195
10402
  const onError = (err) => {
10196
10403
  this.app.server.removeListener("listening", onListening);
10197
10404
  reject(err);
10198
10405
  };
10199
10406
  const onListening = () => {
10200
10407
  this.app.server.removeListener("error", onError);
10201
- resolve6();
10408
+ resolve7();
10202
10409
  };
10203
10410
  this.app.server.once("error", onError);
10204
10411
  this.app.server.once("listening", onListening);
@@ -10223,6 +10430,10 @@ var LongLeashServer = class {
10223
10430
  this.external = external;
10224
10431
  this.hookSecret = hookSecret;
10225
10432
  }
10433
+ /** A laptop-local CLI may request a transient QR without persisting its secret in service logs. */
10434
+ attachLocalPairing(create) {
10435
+ this.localPairing = create;
10436
+ }
10226
10437
  /** Persist an event, then fan it out to every subscriber of that session. */
10227
10438
  publish(sessionId, input) {
10228
10439
  const event2 = this.eventLog.append(sessionId, input);
@@ -10333,6 +10544,9 @@ var LongLeashServer = class {
10333
10544
  }
10334
10545
  /** Tell the client what it may do, so it never has to guess a project path. */
10335
10546
  sendHello(connection) {
10547
+ const bySession = new Map(
10548
+ [...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []].map((session) => [session.sessionId, session])
10549
+ );
10336
10550
  this.sendTo(connection, {
10337
10551
  v: PROTOCOL_VERSION,
10338
10552
  type: "hello",
@@ -10340,7 +10554,11 @@ var LongLeashServer = class {
10340
10554
  roots: this.sessions?.listAllowedRoots() ?? [],
10341
10555
  // Everything the daemon knows about, so a reloaded phone rebuilds instead of showing
10342
10556
  // nothing — including sessions the human started in a terminal.
10343
- sessions: [...this.sessions?.listSessions() ?? [], ...this.external?.listSessions() ?? []],
10557
+ sessions: [...bySession.values()].map((session) => ({
10558
+ ...session,
10559
+ title: this.eventLog.aliasFor(session.sessionId) ?? session.title,
10560
+ lastActivityAt: this.eventLog.latestTimestamp(session.sessionId) || session.startedAt
10561
+ })).sort((left, right) => left.startedAt - right.startedAt || left.sessionId.localeCompare(right.sessionId)),
10344
10562
  capabilities: {
10345
10563
  startSession: this.sessions !== null,
10346
10564
  stopSession: this.sessions !== null,
@@ -10418,6 +10636,15 @@ var LongLeashServer = class {
10418
10636
  return;
10419
10637
  }
10420
10638
  for (const event2 of replay.events) this.sendTo(connection, event2);
10639
+ if (message.syncId !== void 0) {
10640
+ this.sendTo(connection, {
10641
+ v: PROTOCOL_VERSION,
10642
+ type: "sync.complete",
10643
+ sessionId: message.sessionId,
10644
+ syncId: message.syncId,
10645
+ cursor: this.eventLog.latestSeq(message.sessionId)
10646
+ });
10647
+ }
10421
10648
  return;
10422
10649
  }
10423
10650
  if (message.type === "pushSubscribe") {
@@ -10703,8 +10930,60 @@ var LongLeashServer = class {
10703
10930
  });
10704
10931
  return;
10705
10932
  }
10933
+ if (message.type === "renameSession") {
10934
+ const title = message.title.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
10935
+ const externalRenamed = this.external?.renameSession(message.sessionId, title) === true;
10936
+ const managedRenamed = this.sessions.renameSession(message.sessionId, title, !externalRenamed);
10937
+ const renamed = externalRenamed || managedRenamed;
10938
+ this.log(`rename ${message.sessionId} -> ${renamed ? "renamed" : "unknown"}`);
10939
+ this.sendTo(connection, {
10940
+ v: PROTOCOL_VERSION,
10941
+ type: "ack",
10942
+ of: "renameSession",
10943
+ sessionId: message.sessionId,
10944
+ outcome: renamed ? "renamed" : "unknown-session"
10945
+ });
10946
+ if (!renamed) {
10947
+ this.sendTo(connection, {
10948
+ v: PROTOCOL_VERSION,
10949
+ type: "error",
10950
+ code: "unknown-session",
10951
+ message: "This conversation is no longer known to the laptop, so its name was not changed."
10952
+ });
10953
+ }
10954
+ return;
10955
+ }
10956
+ if (message.type === "reclaimSession") {
10957
+ const sessionManager = this.sessions;
10958
+ void (async () => {
10959
+ const outcome = await this.external?.reclaimToPhone(message.sessionId, connection.deviceId) ?? "not-found";
10960
+ let reopened = false;
10961
+ if (outcome === "reclaimed") {
10962
+ reopened = await sessionManager.resumeSession(message.sessionId, connection.deviceId);
10963
+ }
10964
+ const success = outcome === "reclaimed" && reopened;
10965
+ this.log(`reclaim ${message.sessionId} -> ${success ? "phone-ready" : outcome}`);
10966
+ this.sendTo(connection, {
10967
+ v: PROTOCOL_VERSION,
10968
+ type: "ack",
10969
+ of: "reclaimSession",
10970
+ sessionId: message.sessionId,
10971
+ outcome: success ? "phone-ready" : outcome
10972
+ });
10973
+ if (!success) {
10974
+ 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.";
10975
+ this.sendTo(connection, {
10976
+ v: PROTOCOL_VERSION,
10977
+ type: "error",
10978
+ code: "cannot-reclaim",
10979
+ message: detail
10980
+ });
10981
+ }
10982
+ })();
10983
+ return;
10984
+ }
10706
10985
  if (message.type === "setGate") {
10707
- const applied = this.external !== null && message.sessionId.startsWith("ext_") ? this.external.setGate(message.sessionId, message.gate) : false;
10986
+ const applied = this.external !== null && this.external.hasLiveSession(message.sessionId) ? this.external.setGate(message.sessionId, message.gate) : false;
10708
10987
  this.log(`gate ${message.sessionId} -> ${message.gate}${applied ? "" : " (unknown session)"}`);
10709
10988
  this.sendTo(connection, {
10710
10989
  v: PROTOCOL_VERSION,
@@ -10721,6 +11000,12 @@ var LongLeashServer = class {
10721
11000
  try {
10722
11001
  const liveExternal = this.external?.hasLiveSession(message.sessionId) === true;
10723
11002
  const externalSession = liveExternal ? this.external?.listSessions().find((item) => item.sessionId === message.sessionId) : void 0;
11003
+ if (externalSession?.control === "observe") {
11004
+ throw new SessionError(
11005
+ "session-busy",
11006
+ "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."
11007
+ );
11008
+ }
10724
11009
  if (externalSession?.agent === "codex" && message.settings.thinking !== void 0) {
10725
11010
  throw new SessionError(
10726
11011
  "unsupported-setting",
@@ -10836,7 +11121,7 @@ var LongLeashServer = class {
10836
11121
  return;
10837
11122
  }
10838
11123
  if (message.type === "stopSession") {
10839
- if (message.sessionId.startsWith("ext_") && this.external !== null) {
11124
+ if (this.external?.hasLiveSession(message.sessionId) === true) {
10840
11125
  const sessions = this.sessions;
10841
11126
  void this.external.stop(message.sessionId, connection.deviceId).then((stopped) => {
10842
11127
  if (stopped) {
@@ -11044,6 +11329,11 @@ function normalizeRelayUrl(raw) {
11044
11329
 
11045
11330
  // ../daemon/src/adapters/claude.ts
11046
11331
  import { query } from "@anthropic-ai/claude-agent-sdk";
11332
+ function permissionMode(mode) {
11333
+ if (mode === "auto") return "auto";
11334
+ if (mode === "plan") return "plan";
11335
+ return "default";
11336
+ }
11047
11337
  function summarizeToolInput(input) {
11048
11338
  if (input && typeof input === "object") {
11049
11339
  const record = input;
@@ -11077,8 +11367,8 @@ function createClaudeAgentFactory(options = {}) {
11077
11367
  };
11078
11368
  }
11079
11369
  if (closed) return;
11080
- await new Promise((resolve6) => {
11081
- wake = resolve6;
11370
+ await new Promise((resolve7) => {
11371
+ wake = resolve7;
11082
11372
  });
11083
11373
  }
11084
11374
  }
@@ -11101,6 +11391,7 @@ function createClaudeAgentFactory(options = {}) {
11101
11391
  ...request.settings?.model === void 0 && options.model === void 0 ? {} : { model: request.settings?.model ?? options.model },
11102
11392
  ...request.settings?.effort === void 0 ? {} : { effort: request.settings.effort },
11103
11393
  ...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" } },
11394
+ permissionMode: permissionMode(request.settings?.mode),
11104
11395
  ...options.allowedTools === void 0 ? {} : { allowedTools: options.allowedTools },
11105
11396
  ...options.isolateFromUserSettings ? { settingSources: [] } : {},
11106
11397
  // Reopening a closed conversation: Claude replays its own transcript, so the agent
@@ -11143,6 +11434,7 @@ function createClaudeAgentFactory(options = {}) {
11143
11434
  });
11144
11435
  let activeSettings = { ...request.settings ?? {} };
11145
11436
  const applySettings = async (settings) => {
11437
+ await run.setPermissionMode(permissionMode(settings.mode));
11146
11438
  await run.setModel(settings.model);
11147
11439
  await run.applyFlagSettings({ effortLevel: settings.effort ?? null });
11148
11440
  if (settings.thinking === void 0) {
@@ -11301,6 +11593,32 @@ var CodexRun = class {
11301
11593
  log;
11302
11594
  /** Per-turn overrides; updating this affects the next turn without replacing the thread. */
11303
11595
  settings;
11596
+ approvalPolicy() {
11597
+ if (this.settings.mode === "auto") return "on-request";
11598
+ if (this.settings.mode === "manual" || this.settings.mode === "plan") return "untrusted";
11599
+ return this.opts.approvalPolicy ?? "untrusted";
11600
+ }
11601
+ sandbox() {
11602
+ if (this.settings.mode === "plan") return "read-only";
11603
+ if (this.settings.mode === "manual" || this.settings.mode === "auto") return "workspace-write";
11604
+ return this.opts.sandbox ?? "workspace-write";
11605
+ }
11606
+ sandboxPolicy() {
11607
+ const sandbox = this.sandbox();
11608
+ if (sandbox === "read-only") return { type: "readOnly", networkAccess: false };
11609
+ if (sandbox === "danger-full-access") return { type: "dangerFullAccess" };
11610
+ return { type: "workspaceWrite", networkAccess: false, writableRoots: [this.request.cwd] };
11611
+ }
11612
+ providerText(text) {
11613
+ if (this.settings.mode !== "plan") return text;
11614
+ return [
11615
+ "<longleash-plan-mode>",
11616
+ "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.",
11617
+ "</longleash-plan-mode>",
11618
+ "",
11619
+ text
11620
+ ].join("\n");
11621
+ }
11304
11622
  handle() {
11305
11623
  return {
11306
11624
  events: this.iterate(),
@@ -11309,7 +11627,7 @@ var CodexRun = class {
11309
11627
  if (this.threadId !== null && this.turnId !== null) {
11310
11628
  void this.call("turn/interrupt", { threadId: this.threadId, turnId: this.turnId }).catch(() => {
11311
11629
  });
11312
- await new Promise((resolve6) => setTimeout(resolve6, INTERRUPT_GRACE_MS));
11630
+ await new Promise((resolve7) => setTimeout(resolve7, INTERRUPT_GRACE_MS));
11313
11631
  }
11314
11632
  this.child.kill("SIGTERM");
11315
11633
  this.finish();
@@ -11335,15 +11653,15 @@ var CodexRun = class {
11335
11653
  this.request.resume === void 0 ? {
11336
11654
  cwd: this.request.cwd,
11337
11655
  ...this.settings.model === void 0 ? {} : { model: this.settings.model },
11338
- approvalPolicy: this.opts.approvalPolicy ?? "untrusted",
11339
- sandbox: this.opts.sandbox ?? "workspace-write",
11656
+ approvalPolicy: this.approvalPolicy(),
11657
+ sandbox: this.sandbox(),
11340
11658
  config: MANAGED_THREAD_CONFIG
11341
11659
  } : {
11342
11660
  threadId: this.request.resume,
11343
11661
  cwd: this.request.cwd,
11344
11662
  ...this.settings.model === void 0 ? {} : { model: this.settings.model },
11345
- approvalPolicy: this.opts.approvalPolicy ?? "untrusted",
11346
- sandbox: this.opts.sandbox ?? "workspace-write",
11663
+ approvalPolicy: this.approvalPolicy(),
11664
+ sandbox: this.sandbox(),
11347
11665
  config: MANAGED_THREAD_CONFIG
11348
11666
  }
11349
11667
  );
@@ -11365,9 +11683,11 @@ var CodexRun = class {
11365
11683
  try {
11366
11684
  await this.call("turn/start", {
11367
11685
  threadId: this.threadId,
11368
- input: [{ type: "text", text }],
11686
+ input: [{ type: "text", text: this.providerText(text) }],
11369
11687
  ...this.settings.model === void 0 ? {} : { model: this.settings.model },
11370
- ...this.settings.effort === void 0 ? {} : { effort: this.settings.effort }
11688
+ ...this.settings.effort === void 0 ? {} : { effort: this.settings.effort },
11689
+ approvalPolicy: this.approvalPolicy(),
11690
+ sandboxPolicy: this.sandboxPolicy()
11371
11691
  });
11372
11692
  } catch (error) {
11373
11693
  if (fatal) throw error;
@@ -11527,8 +11847,8 @@ var CodexRun = class {
11527
11847
  // -------------------------------------------------------------------- plumbing
11528
11848
  call(method, params) {
11529
11849
  const id2 = this.nextId++;
11530
- const promise = new Promise((resolve6, reject) => {
11531
- this.pending.set(id2, { resolve: resolve6, reject });
11850
+ const promise = new Promise((resolve7, reject) => {
11851
+ this.pending.set(id2, { resolve: resolve7, reject });
11532
11852
  });
11533
11853
  this.write({ jsonrpc: "2.0", id: id2, method, params });
11534
11854
  return promise;
@@ -11580,8 +11900,8 @@ var CodexRun = class {
11580
11900
  if (this.failure !== null) throw this.failure;
11581
11901
  return;
11582
11902
  }
11583
- await new Promise((resolve6) => {
11584
- this.waiter = resolve6;
11903
+ await new Promise((resolve7) => {
11904
+ this.waiter = resolve7;
11585
11905
  });
11586
11906
  }
11587
11907
  }
@@ -11763,8 +12083,8 @@ var FolderIndex = class {
11763
12083
  for (const root of this.roots) {
11764
12084
  if (path === root) return basename4(root);
11765
12085
  if (path.startsWith(root + sep4)) {
11766
- const relative2 = path.slice(root.length + 1);
11767
- return `${basename4(root)}/${relative2}`;
12086
+ const relative3 = path.slice(root.length + 1);
12087
+ return `${basename4(root)}/${relative3}`;
11768
12088
  }
11769
12089
  }
11770
12090
  return path;
@@ -12170,13 +12490,210 @@ function roleName2(role) {
12170
12490
  return role.slice(0, 1).toUpperCase() + role.slice(1);
12171
12491
  }
12172
12492
 
12493
+ // ../daemon/src/codex-session-watch.ts
12494
+ import { closeSync as closeSync2, openSync as openSync2, readSync as readSync2, readdirSync as readdirSync3, statSync as statSync2 } from "node:fs";
12495
+ import { homedir as homedir3 } from "node:os";
12496
+ import { isAbsolute as isAbsolute2, join as join6, relative as relative2, resolve as resolve4 } from "node:path";
12497
+ var MAX_HEAD = 256 * 1024;
12498
+ var MAX_TAIL = 1024 * 1024;
12499
+ var MAX_INITIAL_TAIL = 64 * 1024 * 1024;
12500
+ function readSlice(path, offset, length) {
12501
+ const fd = openSync2(path, "r");
12502
+ try {
12503
+ const buffer = Buffer.alloc(length);
12504
+ const read = readSync2(fd, buffer, 0, length, offset);
12505
+ return buffer.subarray(0, read).toString("utf8");
12506
+ } finally {
12507
+ closeSync2(fd);
12508
+ }
12509
+ }
12510
+ function inside(root, candidate) {
12511
+ const rel = relative2(resolve4(root), resolve4(candidate));
12512
+ return rel === "" || !rel.startsWith("..") && !isAbsolute2(rel);
12513
+ }
12514
+ function inspectCodexTranscript(path, roots2, maxTailBytes = MAX_INITIAL_TAIL) {
12515
+ let size;
12516
+ let activityAt;
12517
+ try {
12518
+ const stats = statSync2(path);
12519
+ size = stats.size;
12520
+ activityAt = stats.mtimeMs;
12521
+ } catch {
12522
+ return null;
12523
+ }
12524
+ try {
12525
+ const firstLine = readSlice(path, 0, Math.min(size, MAX_HEAD)).split("\n")[0];
12526
+ if (!firstLine) return null;
12527
+ const meta = JSON.parse(firstLine);
12528
+ if (meta.type !== "session_meta" || !meta.payload) return null;
12529
+ const sessionId = typeof meta.payload.session_id === "string" ? meta.payload.session_id : typeof meta.payload.id === "string" ? meta.payload.id : null;
12530
+ const cwd = typeof meta.payload.cwd === "string" ? meta.payload.cwd : null;
12531
+ if (sessionId === null || cwd === null || !roots2.some((root) => inside(root, cwd))) return null;
12532
+ if (meta.payload.source !== "vscode" && meta.payload.originator !== "codex_vscode") return null;
12533
+ const surface = "vscode";
12534
+ const tailOffset = Math.max(0, size - maxTailBytes);
12535
+ const tailText = readSlice(path, tailOffset, size - tailOffset);
12536
+ const lines = tailText.split("\n");
12537
+ if (tailOffset > 0) lines.shift();
12538
+ let title;
12539
+ const snapshot = [];
12540
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
12541
+ const line = lines[index];
12542
+ if (!line?.includes('"role":"user"')) continue;
12543
+ try {
12544
+ const record = JSON.parse(line);
12545
+ if (record.type !== "response_item" || record.payload?.type !== "message" || record.payload.role !== "user") continue;
12546
+ const content = record.payload.content;
12547
+ if (!Array.isArray(content)) continue;
12548
+ const text = content.filter((item) => !!item && typeof item === "object" && item.type === "input_text" && typeof item.text === "string").map((item) => item.text).join("\n");
12549
+ title = titleFrom(text) ?? void 0;
12550
+ if (title !== void 0) break;
12551
+ } catch {
12552
+ }
12553
+ }
12554
+ for (const line of lines) {
12555
+ if (line.length === 0 || line.length > 1e6) continue;
12556
+ try {
12557
+ for (const delta of transcriptDeltas(JSON.parse(line))) {
12558
+ if (delta.type !== "stream.delta" || delta.payload.text.trim() === "") continue;
12559
+ snapshot.push({ kind: delta.payload.kind, text: delta.payload.text });
12560
+ }
12561
+ } catch {
12562
+ }
12563
+ }
12564
+ let retained = snapshot.slice(-80);
12565
+ let characters = retained.reduce((sum, block) => sum + block.text.length, 0);
12566
+ while (retained.length > 1 && characters > 12e4) {
12567
+ characters -= retained.shift()?.text.length ?? 0;
12568
+ }
12569
+ if (title === void 0) title = titleFrom(latestUserText(tailText) ?? "") ?? void 0;
12570
+ return {
12571
+ sessionId,
12572
+ cwd,
12573
+ transcriptPath: path,
12574
+ surface,
12575
+ activityAt,
12576
+ snapshot: retained,
12577
+ ...title === void 0 ? {} : { title }
12578
+ };
12579
+ } catch {
12580
+ return null;
12581
+ }
12582
+ }
12583
+ var CodexSessionWatcher = class {
12584
+ options;
12585
+ mtimes = /* @__PURE__ */ new Map();
12586
+ known = /* @__PURE__ */ new Map();
12587
+ timer = null;
12588
+ constructor(options) {
12589
+ this.options = { pollMs: 3e3, initialRecentMs: 5 * 6e4, ...options };
12590
+ }
12591
+ start() {
12592
+ this.scan(true);
12593
+ this.timer = setInterval(() => this.scan(false), this.options.pollMs);
12594
+ this.timer.unref?.();
12595
+ return () => this.stop();
12596
+ }
12597
+ stop() {
12598
+ if (this.timer !== null) clearInterval(this.timer);
12599
+ this.timer = null;
12600
+ }
12601
+ scan(initial = false) {
12602
+ const root = this.options.sessionsRoot ?? join6(homedir3(), ".codex", "sessions");
12603
+ const now = this.options.now?.() ?? Date.now();
12604
+ let observed = 0;
12605
+ for (const path of jsonlFiles(root)) {
12606
+ let modified;
12607
+ try {
12608
+ modified = statSync2(path).mtimeMs;
12609
+ } catch {
12610
+ continue;
12611
+ }
12612
+ const previous = this.mtimes.get(path);
12613
+ this.mtimes.set(path, modified);
12614
+ const changed = previous !== void 0 && modified > previous;
12615
+ const recentlyActive = initial && now - modified <= this.options.initialRecentMs;
12616
+ if (!changed && !recentlyActive) continue;
12617
+ const previousSession = this.known.get(path);
12618
+ const inspected = inspectCodexTranscript(
12619
+ path,
12620
+ this.options.roots,
12621
+ previousSession === void 0 ? MAX_INITIAL_TAIL : MAX_TAIL
12622
+ );
12623
+ const session = inspected === null ? null : inspected.title === void 0 && previousSession?.title !== void 0 ? { ...inspected, title: previousSession.title } : inspected;
12624
+ if (session === null) continue;
12625
+ this.known.set(path, session);
12626
+ this.options.onSession(session);
12627
+ observed += 1;
12628
+ }
12629
+ return observed;
12630
+ }
12631
+ };
12632
+ function jsonStringAt(source, quoteAt) {
12633
+ if (source[quoteAt] !== '"') return null;
12634
+ let escaped = false;
12635
+ for (let index = quoteAt + 1; index < source.length; index += 1) {
12636
+ const char = source[index];
12637
+ if (escaped) {
12638
+ escaped = false;
12639
+ continue;
12640
+ }
12641
+ if (char === "\\") {
12642
+ escaped = true;
12643
+ continue;
12644
+ }
12645
+ if (char === '"') {
12646
+ try {
12647
+ return JSON.parse(source.slice(quoteAt, index + 1));
12648
+ } catch {
12649
+ return null;
12650
+ }
12651
+ }
12652
+ }
12653
+ return null;
12654
+ }
12655
+ function latestUserText(source) {
12656
+ let before = source.length;
12657
+ while (before > 0) {
12658
+ const roleAt = source.lastIndexOf('"role":"user"', before);
12659
+ if (roleAt < 0) return null;
12660
+ const textKey = source.indexOf('"text":', roleAt);
12661
+ const nextRole = source.indexOf('"role":', roleAt + 1);
12662
+ if (textKey >= 0 && (nextRole < 0 || textKey < nextRole)) {
12663
+ const quoteAt = source.indexOf('"', textKey + 7);
12664
+ const text = quoteAt < 0 ? null : jsonStringAt(source, quoteAt);
12665
+ if (text !== null && titleFrom(text) !== null) return text;
12666
+ }
12667
+ before = roleAt;
12668
+ }
12669
+ return null;
12670
+ }
12671
+ function jsonlFiles(root) {
12672
+ const out = [];
12673
+ const visit = (directory) => {
12674
+ let entries;
12675
+ try {
12676
+ entries = readdirSync3(directory, { withFileTypes: true });
12677
+ } catch {
12678
+ return;
12679
+ }
12680
+ for (const entry of entries) {
12681
+ const path = join6(directory, entry.name);
12682
+ if (entry.isDirectory()) visit(path);
12683
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
12684
+ }
12685
+ };
12686
+ visit(root);
12687
+ return out;
12688
+ }
12689
+
12173
12690
  // ../daemon/src/daemon.ts
12174
12691
  var DEFAULT_READ_ONLY_TOOLS = ["Read", "Glob", "Grep"];
12175
12692
  function executableOnPath(command) {
12176
12693
  for (const directory of (process.env.PATH ?? "").split(delimiter)) {
12177
12694
  if (directory === "") continue;
12178
12695
  try {
12179
- accessSync(join6(directory, command), constants.X_OK);
12696
+ accessSync(join7(directory, command), constants.X_OK);
12180
12697
  return true;
12181
12698
  } catch {
12182
12699
  }
@@ -12184,20 +12701,20 @@ function executableOnPath(command) {
12184
12701
  return false;
12185
12702
  }
12186
12703
  async function startDaemon(options) {
12187
- const dataDir2 = options.dataDir ?? join6(homedir3(), ".longleash");
12704
+ const dataDir2 = options.dataDir ?? join7(homedir4(), ".longleash");
12188
12705
  mkdirSync3(dataDir2, { recursive: true });
12189
- const roots2 = options.allowedRoots.map((root) => resolve4(root));
12706
+ const roots2 = options.allowedRoots.map((root) => resolve5(root));
12190
12707
  for (const root of roots2) {
12191
12708
  if (!existsSync6(root)) throw new Error(`Allowed root does not exist: ${root}`);
12192
12709
  }
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"));
12710
+ const eventLog = new EventLog(join7(dataDir2, "events.db"));
12711
+ const registry = new DeviceRegistry(join7(dataDir2, "devices.db"));
12712
+ const approvals = new ApprovalStore(join7(dataDir2, "approvals.db"));
12196
12713
  const workspace = new WorkspaceLeaseManager(approvals.rawDb);
12197
- const worktrees = new WorktreeManager(join6(dataDir2, "worktrees"));
12714
+ const worktrees = new WorktreeManager(join7(dataDir2, "worktrees"));
12198
12715
  const push = new PushNotifier({
12199
- dbPath: join6(dataDir2, "push.db"),
12200
- keysPath: join6(dataDir2, "vapid.json"),
12716
+ dbPath: join7(dataDir2, "push.db"),
12717
+ keysPath: join7(dataDir2, "vapid.json"),
12201
12718
  // Who a push service may contact about this sender: the relay origin when
12202
12719
  // one exists, else a placeholder mailto (LAN-only installs never push far).
12203
12720
  subject: process.env.LONGLEASH_PUSH_SUBJECT ?? (options.relayUrl !== void 0 ? options.relayUrl.replace(/^ws(s?):\/\//, "http$1://").replace(/\/ws\/?$/, "") : "mailto:longleash@localhost.invalid"),
@@ -12283,14 +12800,15 @@ async function startDaemon(options) {
12283
12800
  server.attachSessions(sessions);
12284
12801
  server.attachFolders(new FolderIndex(roots2));
12285
12802
  server.attachPush(push);
12286
- const externalApprovals = new ApprovalStore(join6(dataDir2, "approvals-external.db"));
12287
- const sessionRegistry = new SessionRegistry(join6(dataDir2, "live-sessions.db"));
12803
+ const externalApprovals = new ApprovalStore(join7(dataDir2, "approvals-external.db"));
12804
+ const sessionRegistry = new SessionRegistry(join7(dataDir2, "live-sessions.db"));
12288
12805
  const external = new ExternalSessions({
12289
12806
  eventLog,
12290
12807
  approvals: externalApprovals,
12291
12808
  registry: sessionRegistry,
12292
12809
  onEvent: mirror,
12293
12810
  workspace,
12811
+ resolveSessionId: (agentSessionId) => sessions.sessionIdForAgentSession(agentSessionId),
12294
12812
  // A live socket means the app is open and can answer in seconds. A push REGISTRATION is
12295
12813
  // permanent and proves nothing about whether anyone is looking — treating it as presence
12296
12814
  // is what froze the keyboard for two minutes with the phone in a drawer.
@@ -12307,10 +12825,15 @@ async function startDaemon(options) {
12307
12825
  agentSessionId: info.claudeSessionId
12308
12826
  })
12309
12827
  });
12828
+ const codexWatcher = new CodexSessionWatcher({
12829
+ roots: roots2,
12830
+ onSession: (session) => external.observeCodexSession(session)
12831
+ });
12832
+ codexWatcher.start();
12310
12833
  external.startSweeping();
12311
12834
  const delegationStore = new DelegationStore(approvals.rawDb);
12312
12835
  workspace.reconcile({
12313
- activeSessionIds: external.listSessions().map((session) => session.sessionId),
12836
+ activeSessionIds: external.listSessions().filter((session) => session.control === "full").map((session) => session.sessionId),
12314
12837
  validReservationIds: delegationStore.list().filter((record) => record.status === "starting").map((record) => record.delegationId)
12315
12838
  });
12316
12839
  external.reconcileWorkspace();
@@ -12332,7 +12855,7 @@ async function startDaemon(options) {
12332
12855
  server.attachExternal(external, hookSecret);
12333
12856
  const { port } = await server.listen();
12334
12857
  writeFileSync2(
12335
- join6(dataDir2, "hook-endpoint.json"),
12858
+ join7(dataDir2, "hook-endpoint.json"),
12336
12859
  JSON.stringify({ url: `http://${options.host}:${port}/hook`, secret: hookSecret }, null, 2) + "\n",
12337
12860
  { mode: 384 }
12338
12861
  );
@@ -12355,6 +12878,7 @@ async function startDaemon(options) {
12355
12878
  posture: readPermissionPosture(),
12356
12879
  stop: async () => {
12357
12880
  stopMaintenance();
12881
+ codexWatcher.stop();
12358
12882
  bridge?.stop();
12359
12883
  await sessions.shutdown();
12360
12884
  awake.release();
@@ -12372,9 +12896,9 @@ async function startDaemon(options) {
12372
12896
 
12373
12897
  // ../daemon/src/config.ts
12374
12898
  import { mkdirSync as mkdirSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
12375
- import { dirname as dirname3, join as join7 } from "node:path";
12899
+ import { dirname as dirname3, join as join8 } from "node:path";
12376
12900
  function resolveRelayUrl(given, dataDir2) {
12377
- const path = join7(dataDir2, "config.json");
12901
+ const path = join8(dataDir2, "config.json");
12378
12902
  if (given !== void 0 && given.trim().toLowerCase() === "off") {
12379
12903
  const config = load(path);
12380
12904
  delete config.relayUrl;
@@ -12636,19 +13160,115 @@ function noAddressReason(interfaces = networkInterfaces()) {
12636
13160
  return `No address a phone could dial (found: ${addresses.join(", ")}).`;
12637
13161
  }
12638
13162
 
13163
+ // ../daemon/src/instance-lock.ts
13164
+ import { closeSync as closeSync3, lstatSync, mkdirSync as mkdirSync5, openSync as openSync3, readFileSync as readFileSync5, rmSync, writeFileSync as writeFileSync4 } from "node:fs";
13165
+ import { randomUUID } from "node:crypto";
13166
+ import { join as join9 } from "node:path";
13167
+ var LOCK_NAME = "daemon.lock";
13168
+ var LOCK_KIND = "longleash-daemon";
13169
+ function acquireDaemonInstance(dataDir2) {
13170
+ mkdirSync5(dataDir2, { recursive: true, mode: 448 });
13171
+ const path = join9(dataDir2, LOCK_NAME);
13172
+ const token = randomUUID();
13173
+ const create = () => {
13174
+ const fd = openSync3(path, "wx", 384);
13175
+ try {
13176
+ writeFileSync4(fd, `${JSON.stringify({ kind: LOCK_KIND, pid: process.pid, token, startedAt: Date.now() })}
13177
+ `);
13178
+ } finally {
13179
+ closeSync3(fd);
13180
+ }
13181
+ };
13182
+ try {
13183
+ create();
13184
+ } catch (error) {
13185
+ if (error.code !== "EEXIST") throw error;
13186
+ if (lstatSync(path).isSymbolicLink()) throw new Error(`Refusing symlinked daemon lock: ${path}`);
13187
+ let owner = {};
13188
+ try {
13189
+ owner = JSON.parse(readFileSync5(path, "utf8"));
13190
+ } catch {
13191
+ }
13192
+ if (owner.kind !== LOCK_KIND || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) {
13193
+ throw new Error(`Unrecognized daemon lock requires manual inspection: ${path}`);
13194
+ }
13195
+ if (processAlive(owner.pid)) {
13196
+ throw new Error(`LongLeash already owns this data directory in process ${owner.pid}. Use \`longleash service status\` before starting another instance.`);
13197
+ }
13198
+ rmSync(path);
13199
+ create();
13200
+ }
13201
+ let held = true;
13202
+ return {
13203
+ path,
13204
+ release() {
13205
+ if (!held) return;
13206
+ held = false;
13207
+ try {
13208
+ const owner = JSON.parse(readFileSync5(path, "utf8"));
13209
+ if (owner.token === token) rmSync(path);
13210
+ } catch {
13211
+ }
13212
+ }
13213
+ };
13214
+ }
13215
+ function processAlive(pid) {
13216
+ try {
13217
+ process.kill(pid, 0);
13218
+ return true;
13219
+ } catch (error) {
13220
+ return error.code === "EPERM";
13221
+ }
13222
+ }
13223
+
13224
+ // ../daemon/src/service-log.ts
13225
+ function persistentServiceLogLine(line) {
13226
+ if (/codex\s+(?:<-|->)/i.test(line)) return null;
13227
+ const stamp = line.match(/^\[[^\]]+\]\s*/)?.[0] ?? "";
13228
+ const message = line.slice(stamp.length);
13229
+ const started2 = message.match(/^(claude|codex) session ([A-Za-z0-9_-]{1,32}) started in .+ \((terminal|vscode|phone)\)$/i);
13230
+ if (started2) return `${stamp}${started2[1].toLowerCase()} session ${started2[2]} started (${started2[3].toLowerCase()})`;
13231
+ if (/^\? .+ in [A-Za-z0-9_-]+ .* -> /i.test(message)) return `${stamp}tool decision recorded`;
13232
+ if (/^(decision|gate|resume|takeOver|stop|session)\s/i.test(message)) return `${stamp}session lifecycle state changed`;
13233
+ if (/^(delegation|start) refused:/i.test(message)) return `${stamp}session request refused; details are available on the paired device`;
13234
+ if (/^(relay|relay link|relay room|dropped a relay frame)/i.test(message)) return `${stamp}relay connection state changed`;
13235
+ if (/^(device|dropping unresponsive connection)/i.test(message)) return `${stamp}device connection state changed`;
13236
+ if (/^push:/i.test(message) || /^push (subscription|test)/i.test(message)) return `${stamp}push delivery state changed`;
13237
+ if (/^(keeping this Mac awake|all agents idle)/i.test(message)) return `${stamp}${message}`;
13238
+ if (/^rebound to /i.test(message)) return `${stamp}local listener rebound`;
13239
+ return `${stamp}service activity recorded; details omitted`;
13240
+ }
13241
+
12639
13242
  // ../daemon/bin/longleashd.ts
12640
13243
  var here = dirname4(fileURLToPath(import.meta.url));
12641
- var APP_DIR = resolve5(here, "../../app/dist");
13244
+ var APP_DIR = resolve6(here, "../../app/dist");
13245
+ var serviceMode = process.env.LONGLEASH_SERVICE === "1";
13246
+ if (serviceMode) {
13247
+ const fatal = () => {
13248
+ console.error("LongLeash service stopped unexpectedly; details omitted. Run `longleash doctor`.");
13249
+ process.exit(1);
13250
+ };
13251
+ process.on("uncaughtException", fatal);
13252
+ process.on("unhandledRejection", fatal);
13253
+ }
13254
+ function daemonLog(line) {
13255
+ if (!serviceMode) {
13256
+ console.log(line);
13257
+ return;
13258
+ }
13259
+ const safe = persistentServiceLogLine(line);
13260
+ if (safe !== null) console.log(safe);
13261
+ }
12642
13262
  var roots = process.argv.slice(2).filter((arg) => !arg.startsWith("-"));
12643
13263
  if (roots.length === 0) {
12644
13264
  console.error("\nUsage: longleashd <project-directory> [more directories\u2026]");
12645
13265
  console.error("Agents may only ever work inside the directories you list.\n");
12646
13266
  process.exit(1);
12647
13267
  }
12648
- var dataDir = process.env.LONGLEASH_DATA ?? join8(homedir4(), ".longleash");
13268
+ var dataDir = process.env.LONGLEASH_DATA ?? join10(homedir5(), ".longleash");
12649
13269
  var relay = resolveRelayUrl(process.env.LONGLEASH_RELAY_URL, dataDir);
12650
13270
  if (relay?.source === "remembered") {
12651
- console.log(`Relay: ${relay.url} (remembered \u2014 LONGLEASH_RELAY_URL=off forgets it)`);
13271
+ console.log(serviceMode ? "Relay configuration loaded." : `Relay: ${relay.url} (remembered \u2014 LONGLEASH_RELAY_URL=off forgets it)`);
12652
13272
  }
12653
13273
  var candidates = findCandidates();
12654
13274
  var best = candidates[0];
@@ -12698,15 +13318,17 @@ async function boot(host, port) {
12698
13318
  allowedRoots: roots,
12699
13319
  host,
12700
13320
  port,
12701
- ...existsSync7(join8(APP_DIR, "index.html")) ? { staticRoot: APP_DIR } : {},
13321
+ ...existsSync7(join10(APP_DIR, "index.html")) ? { staticRoot: APP_DIR } : {},
12702
13322
  denyOutsideRoot: process.env.LONGLEASH_STRICT !== "0",
12703
13323
  // LONGLEASH_ASK_EVERYTHING=1 pre-approves nothing, so even reading a file comes to your phone.
12704
13324
  ...process.env.LONGLEASH_ASK_EVERYTHING === "1" ? { allowedTools: [] } : {},
12705
13325
  dataDir,
12706
- log: (line) => console.log(line),
13326
+ log: daemonLog,
12707
13327
  ...relay === null ? {} : { relayUrl: relay.url }
12708
13328
  });
12709
13329
  }
13330
+ var instanceLock = acquireDaemonInstance(dataDir);
13331
+ process.once("exit", () => instanceLock.release());
12710
13332
  var started = await (async () => {
12711
13333
  const firstPort = holder === "other" ? 0 : wantedPort;
12712
13334
  try {
@@ -12738,7 +13360,7 @@ function relayAppOrigin(wsUrl) {
12738
13360
  function freshPairingUrl() {
12739
13361
  const challenge = daemon.registry.createPairingChallenge();
12740
13362
  if (relay !== null) {
12741
- hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) => console.log(`[pair] ${line}`) });
13363
+ hostPairing({ registry: daemon.registry, relayUrl: relay.url, challenge, log: (line) => daemonLog(`[pair] ${line}`) });
12742
13364
  return pairingUrl(relayAppOrigin(relay.url), challenge.challengeId, challenge.secret);
12743
13365
  }
12744
13366
  return pairingUrl(
@@ -12747,15 +13369,16 @@ function freshPairingUrl() {
12747
13369
  challenge.secret
12748
13370
  );
12749
13371
  }
12750
- var url = freshPairingUrl();
13372
+ daemon.server.attachLocalPairing(freshPairingUrl);
13373
+ var url = serviceMode ? null : freshPairingUrl();
12751
13374
  console.log("\n=== LongLeash ===\n");
12752
13375
  var warn = vpnWarning();
12753
13376
  if (warn) console.log(`!!! ${warn}
12754
13377
  `);
12755
- if (!existsSync7(join8(APP_DIR, "index.html"))) {
13378
+ if (!existsSync7(join10(APP_DIR, "index.html"))) {
12756
13379
  console.log("The web app is not built yet \u2014 run `pnpm --filter @longleash/app build` first.\n");
12757
13380
  }
12758
- if (daemon.posture.gateWeakened) {
13381
+ if (daemon.posture.gateWeakened && !serviceMode) {
12759
13382
  console.log(`Note: your Claude Code settings pre-approve ${daemon.posture.allowRuleCount} pattern(s),`);
12760
13383
  console.log("e.g. " + daemon.posture.examples.join(", "));
12761
13384
  console.log("Those commands run WITHOUT asking your phone. They still appear in the activity");
@@ -12776,23 +13399,29 @@ if (preApproved.length > 0) {
12776
13399
  } else {
12777
13400
  console.log("");
12778
13401
  }
12779
- console.log("Agents may work only in:");
12780
- for (const root of roots) console.log(` ${resolve5(root)}`);
12781
- console.log(
12782
- servedOnLan && best ? `
13402
+ if (serviceMode) {
13403
+ console.log(`Background service ready with ${roots.length} allowed project root(s).`);
13404
+ console.log(`Connectivity: ${relay !== null ? "encrypted relay enabled" : "LAN only"}.`);
13405
+ console.log("Pairing secrets are never written to service logs. Run `longleash pair` in a terminal.\n");
13406
+ } else {
13407
+ console.log("Agents may work only in:");
13408
+ for (const root of roots) console.log(` ${resolve6(root)}`);
13409
+ console.log(
13410
+ servedOnLan && best ? `
12783
13411
  Listening on ${servedHost}:${daemon.port} (${best.iface}, ${best.label})` : `
12784
13412
  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(`
13413
+ );
13414
+ console.log(
13415
+ relay !== null ? `Relay: ${relay.url} \u2014 your phone can reach this laptop from anywhere.` : "Relay: not configured (LAN only). Set LONGLEASH_RELAY_URL once to enable remote access \u2014 it is remembered."
13416
+ );
13417
+ console.log("\nScan this with your phone, then add it to your home screen:\n");
13418
+ console.log(terminalQr(url));
13419
+ console.log(`
12792
13420
  ${url}
12793
13421
  `);
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)`);
13422
+ if (relay !== null && servedOnLan) {
13423
+ console.log(`(LAN fallback for pairing at home: http://${servedHost}:${daemon.port}/#c=\u2026&s=\u2026 \u2014 same code)`);
13424
+ }
12796
13425
  }
12797
13426
  var NETWORK_WATCH_MS = 5e3;
12798
13427
  var networkWatch = setInterval(() => {
@@ -12803,23 +13432,23 @@ var networkWatch = setInterval(() => {
12803
13432
  servingHost = nextHost;
12804
13433
  void daemon.server.rebind(nextHost).then((port) => {
12805
13434
  servingPort = port;
12806
- console.log(`
13435
+ console.log(serviceMode ? "\nNetwork changed; the local listener recovered." : `
12807
13436
  Network changed: ${was} -> ${nextHost}. Now serving ${nextHost}:${port}.`);
12808
13437
  console.log(
12809
13438
  nextBest ? "Press n + Enter for a pairing QR on this network." : "No local network now \u2014 the relay carries everything."
12810
13439
  );
12811
13440
  }).catch((err) => {
12812
- console.log(`
13441
+ console.log(serviceMode ? "\nNetwork changed but local listener recovery failed; details omitted." : `
12813
13442
  Network changed but rebinding failed (${String(err)}).`);
12814
13443
  console.log(relay !== null ? "The relay is unaffected." : "Restart the daemon to recover.");
12815
13444
  });
12816
13445
  }, NETWORK_WATCH_MS);
12817
13446
  networkWatch.unref?.();
12818
- console.log("Press n + Enter for a fresh pairing QR, r + Enter to revoke every device, q + Enter to quit.\n");
13447
+ if (!serviceMode) console.log("Press n + Enter for a fresh pairing QR, r + Enter to revoke every device, q + Enter to quit.\n");
12819
13448
  process.stdin.setEncoding("utf8");
12820
13449
  process.stdin.on("data", (chunk) => {
12821
13450
  const key = chunk.trim().toLowerCase();
12822
- if (key === "n") {
13451
+ if (key === "n" && !serviceMode) {
12823
13452
  const next = freshPairingUrl();
12824
13453
  console.log("\nScan this with your phone:\n");
12825
13454
  console.log(terminalQr(next));
@@ -12834,12 +13463,18 @@ process.stdin.on("data", (chunk) => {
12834
13463
  }
12835
13464
  if (key === "q") {
12836
13465
  clearInterval(networkWatch);
12837
- void daemon.stop().then(() => process.exit(0));
13466
+ void daemon.stop().then(() => {
13467
+ instanceLock.release();
13468
+ process.exit(0);
13469
+ });
12838
13470
  }
12839
13471
  });
12840
13472
  var shutdown = () => {
12841
13473
  clearInterval(networkWatch);
12842
- void daemon.stop().then(() => process.exit(0));
13474
+ void daemon.stop().then(() => {
13475
+ instanceLock.release();
13476
+ process.exit(0);
13477
+ });
12843
13478
  };
12844
13479
  process.on("SIGINT", shutdown);
12845
13480
  process.on("SIGTERM", shutdown);