@paradow/ledge 0.13.0 → 0.14.0

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.
Files changed (28) hide show
  1. package/dist/cli/{attach-PJHKOS4N.js → attach-3U4A44ZE.js} +7 -7
  2. package/dist/cli/{bridge-VQZCDSQE.js → bridge-7JT6P53L.js} +2 -2
  3. package/dist/cli/{chunk-FXI2EE52.js → chunk-2P2SUT7A.js} +2 -2
  4. package/dist/cli/{chunk-YUSY3E47.js → chunk-5DDBCJX2.js} +1 -1
  5. package/dist/cli/{chunk-PPRB4XIX.js → chunk-5LVPB3ET.js} +392 -141
  6. package/dist/cli/{chunk-XIO4OIHR.js → chunk-6A7JPTHV.js} +2 -2
  7. package/dist/cli/{chunk-ZSKCV73V.js → chunk-D3JQV5AA.js} +6 -6
  8. package/dist/cli/{chunk-ZEHTNFPV.js → chunk-FPW7MF5X.js} +2 -2
  9. package/dist/cli/{chunk-7XZNRFWJ.js → chunk-IIXALRFM.js} +2 -2
  10. package/dist/cli/{chunk-SVVQ5KPJ.js → chunk-PFDZ6VYG.js} +1 -1
  11. package/dist/cli/{chunk-FGOFRWIB.js → chunk-QBJEUZYW.js} +1 -1
  12. package/dist/cli/chunk-QUUZSAPI.js +6 -0
  13. package/dist/cli/{chunk-5XFBRTKO.js → chunk-ULWVO3TH.js} +2 -2
  14. package/dist/cli/{chunk-IOASI5FF.js → chunk-UWANRALT.js} +79 -4
  15. package/dist/cli/{daemon-ZUDRXUCP.js → daemon-KHGAO6BC.js} +2123 -187
  16. package/dist/cli/{hook-KKJLIOOM.js → hook-R6OWCFAQ.js} +8 -8
  17. package/dist/cli/index.js +11 -11
  18. package/dist/cli/{launch-4CBL5ZRR.js → launch-PPTQEWYD.js} +7 -7
  19. package/dist/cli/send-74LTIRSN.js +14 -0
  20. package/dist/cli/{setup-TULNBDFB.js → setup-FPQK3NA7.js} +4 -4
  21. package/dist/cli/{status-R7IOK5KL.js → status-QLTB6T3U.js} +3 -3
  22. package/dist/cli/{uninstall-OFSLCP4T.js → uninstall-QC5WEWDH.js} +4 -4
  23. package/dist/cli/{version-JOFQUAE4.js → version-AELAEF6T.js} +1 -1
  24. package/dist/cli/who-5F6CHRA3.js +11 -0
  25. package/package.json +2 -1
  26. package/dist/cli/chunk-LMQADKT7.js +0 -6
  27. package/dist/cli/send-6EGRHCRO.js +0 -14
  28. package/dist/cli/who-NVF6ZMSP.js +0 -11
@@ -3,11 +3,19 @@ import {
3
3
  readFrames
4
4
  } from "./chunk-OLLQBK6N.js";
5
5
  import {
6
+ NO_ALWAYS,
7
+ NO_NOTE,
6
8
  QUESTION_TOOLS,
9
+ Refusal,
10
+ STALE,
7
11
  answeredBy,
12
+ assertDialogIs,
8
13
  bareName,
14
+ bottomLines,
9
15
  crossSession,
10
16
  crossSessionNotices,
17
+ dialogMismatch,
18
+ flatten,
11
19
  formatLedgeMessage,
12
20
  gitInfo,
13
21
  gitSummary,
@@ -17,8 +25,9 @@ import {
17
25
  pendingTool,
18
26
  readPeers,
19
27
  reduce,
20
- repositoryId
21
- } from "./chunk-IOASI5FF.js";
28
+ repositoryId,
29
+ shownCommand
30
+ } from "./chunk-UWANRALT.js";
22
31
  import {
23
32
  PARENT_AGENT_VARS,
24
33
  agentEnv,
@@ -27,28 +36,49 @@ import {
27
36
  claudePlan,
28
37
  claudeToolAnswered,
29
38
  spawnSession
30
- } from "./chunk-5XFBRTKO.js";
39
+ } from "./chunk-ULWVO3TH.js";
31
40
  import {
32
41
  DEFAULT_PROJECTS,
42
+ DEFAULT_RELAY_URL,
33
43
  DOCUMENT_MAX_BYTES,
44
+ DevicePollResponseSchema,
45
+ DeviceStartResponseSchema,
34
46
  EffortSchema,
35
47
  IslandPrefsSchema,
36
48
  MAX_PROPOSALS,
37
49
  MachinesSchema,
50
+ MobileConfigSchema,
38
51
  ModelIdSchema,
39
52
  NOTE_NOT_TYPED,
53
+ PAIR_LABEL_MAX,
54
+ PENDING_BODY_MAX,
55
+ PHONE_PROTOCOL,
40
56
  PROTOCOL_PORT,
57
+ PhoneClientMessageSchema,
41
58
  ProjectsSchema,
59
+ RELAY_CLOSE_SIGNED_OUT,
42
60
  RecentSchema,
61
+ RefusalCodeSchema,
62
+ RelayServerMessageSchema,
43
63
  RouteResultSchema,
64
+ SNAPSHOT_BODY_MAX,
65
+ SealedFrameSchema,
44
66
  SessionSchema,
45
67
  SshAliasSchema,
68
+ THREAD_BUDGET,
69
+ THREAD_ITEMS_MAX,
70
+ THREAD_TEXT_MAX,
71
+ TOOL_TEXT_MAX,
72
+ TOPIC_MAX,
73
+ ToolSchema,
74
+ UNTYPABLE,
46
75
  backendOf,
47
76
  ensureDaemonToken,
48
77
  extractProposal,
49
78
  hostAllowed,
50
79
  isArchived,
51
80
  offers,
81
+ padPlain,
52
82
  parseClientMessage,
53
83
  parseProjectBlock,
54
84
  parseProposalBlock,
@@ -60,30 +90,30 @@ import {
60
90
  proposalRows,
61
91
  requestAuthorized,
62
92
  selectDaemonProtocol
63
- } from "./chunk-PPRB4XIX.js";
93
+ } from "./chunk-5LVPB3ET.js";
64
94
  import {
65
95
  LEDGE_VERSION
66
- } from "./chunk-LMQADKT7.js";
96
+ } from "./chunk-QUUZSAPI.js";
67
97
  import {
68
98
  SERVICE,
69
99
  inDaemonService,
70
100
  tmuxServerAtRisk
71
- } from "./chunk-XIO4OIHR.js";
101
+ } from "./chunk-6A7JPTHV.js";
72
102
  import {
73
103
  realTmux,
74
104
  runFile
75
- } from "./chunk-ZEHTNFPV.js";
105
+ } from "./chunk-FPW7MF5X.js";
76
106
  import {
77
107
  log
78
- } from "./chunk-YUSY3E47.js";
108
+ } from "./chunk-5DDBCJX2.js";
79
109
  import {
80
110
  ensureLedgeDir,
81
111
  paths
82
- } from "./chunk-FGOFRWIB.js";
112
+ } from "./chunk-QBJEUZYW.js";
83
113
 
84
114
  // src/daemon/index.ts
85
115
  import { writeFile as writeFile7, rm as rm5 } from "fs/promises";
86
- import { join as join15 } from "path";
116
+ import { join as join16 } from "path";
87
117
 
88
118
  // src/daemon/omp/owner.ts
89
119
  import { mkdir, readFile, writeFile } from "fs/promises";
@@ -718,72 +748,19 @@ var History = class {
718
748
 
719
749
  // src/daemon/server.ts
720
750
  import { createServer } from "http";
721
- import { homedir as homedir6 } from "os";
722
- import { basename as basename4, join as join12 } from "path";
751
+ import { homedir as homedir7 } from "os";
752
+ import { basename as basename4, join as join13 } from "path";
723
753
  import { WebSocketServer, WebSocket as WebSocket2 } from "ws";
724
754
 
725
- // src/adapters/types.ts
726
- var NO_ALWAYS = 'no "always" option in this dialog, answer in the terminal';
727
- var NO_NOTE = "no place for a note in this dialog, answer in the terminal";
728
- var STALE = "the request changed, look again";
729
- function assertDialogIs(pending, shown) {
730
- if (shown === null) return;
731
- const want = pending.kind === "diff" ? "permission" : pending.kind;
732
- if (shown !== want) throw new Error(STALE);
733
- }
734
- var UNCHECKED = "the request on screen cannot be checked, answer in the terminal";
735
- var COMMAND_TOOLS = /* @__PURE__ */ new Set(["Bash", "shell", "Shell"]);
736
- var compact = (t) => t.replace(/\s+/g, "");
737
- var EDGE = 4;
738
- var WIDE = /[\u1100-\u115f\u2e80-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe30-\ufe4f\uff00-\uff60\uffe0-\uffe6\u{1f300}-\u{1faff}\u{20000}-\u{3fffd}]/u;
739
- var columns = (text) => [...text].reduce((n, c) => n + (WIDE.test(c) ? 2 : 1), 0);
740
- function shownCommand(raw) {
741
- const lines = raw.map((l) => l.trimEnd());
742
- while (lines.length && !lines.at(-1).trim()) lines.pop();
743
- while (lines.length && !lines[0].trim()) lines.shift();
744
- return { lines: lines.map((l) => l.trim()), ends: lines.map(columns) };
745
- }
746
- function commandMismatch({ lines, ends }, asked, width) {
747
- if (!lines.length || lines.at(-1).endsWith("\u2026") || lines.length > 1 && !width) return UNCHECKED;
748
- const edge = (width ?? 0) - EDGE;
749
- const a = asked.trim();
750
- let at = 0;
751
- for (let i = 0; i < lines.length; i++) {
752
- if (!a.startsWith(lines[i], at)) return STALE;
753
- at += lines[i].length;
754
- if (i === lines.length - 1) break;
755
- if (ends[i] + 1 + columns(lines[i + 1].split(/\s/)[0]) <= edge) {
756
- const nl = /^[ \t]*\n[ \t]*/.exec(a.slice(at));
757
- if (!nl) return STALE;
758
- at += nl[0].length;
759
- } else if (a[at] === " ") at++;
760
- else if (ends[i] < edge) return STALE;
761
- }
762
- return at === a.length ? null : STALE;
763
- }
764
- function dialogMismatch(shown, pending, width) {
765
- if (!shown) return UNCHECKED;
766
- if (shown.kind !== (pending.kind === "diff" ? "permission" : pending.kind)) return STALE;
767
- if (pending.kind === "question") {
768
- const screen = compact(shown.text), asked = compact(pending.text);
769
- return !asked || screen === asked || !!screen && (asked.startsWith(screen) || screen.startsWith(asked)) ? null : STALE;
770
- }
771
- if (pending.source === "pane") return shown.text === pending.text ? null : STALE;
772
- if (pending.tool !== void 0 && COMMAND_TOOLS.has(pending.tool)) return shown.command ? commandMismatch(shown.command, pending.text, width) : UNCHECKED;
773
- return null;
774
- }
775
- function bottomLines(pane, n = 5) {
776
- return pane.split("\n").filter((l) => l.trim() !== "").slice(-n).join("\n");
777
- }
778
- var flatten = (text) => text.replace(/\s*[\r\n]+\s*/g, " ").trim();
779
-
780
755
  // src/adapters/claude.ts
781
756
  var OPTION = /^\s*(?:❯\s*)?(\d+)\.\s+(\S.*?)\s*$/;
782
757
  var FOOTER = /^\s*(?:(?:Enter to select\b.*)?Esc to cancel\b|ctrl\+g to edit\b)/;
783
758
  var PLAN_FOOTER = /^\s*ctrl\+g to edit\b/;
759
+ var GUTTER = /^│\s*/;
784
760
  var REVIEW_QUESTION = /^\s*Ready to submit your answers\?$/;
785
761
  var RULE = /^─{10,}$/;
786
762
  var DASHED = /^╌{10,}$/;
763
+ var INPUT_TOP = /^─{10,}(?: \S.* ─+)?$/;
787
764
  var FOOTER_SLACK = 2;
788
765
  var isBlank = (l) => l.trim() === "";
789
766
  var isRule = (l) => RULE.test(l.trim()) || DASHED.test(l.trim());
@@ -798,7 +775,14 @@ function footerIndex(lines) {
798
775
  }
799
776
  function reviewEnd(lines) {
800
777
  let last = lines.length - 1;
801
- while (last >= 0 && isBlank(lines[last])) last--;
778
+ for (let rules = 0; last >= 0; last--) {
779
+ if (isBlank(lines[last])) continue;
780
+ if (rules < FOOTER_SLACK && INPUT_TOP.test(lines[last].trim())) {
781
+ rules++;
782
+ continue;
783
+ }
784
+ break;
785
+ }
802
786
  if (last < 1 || lines[last].match(OPTION)?.[1] !== "2" || lines[last - 1].match(OPTION)?.[1] !== "1") return -1;
803
787
  let q = last - 2;
804
788
  while (q >= 0 && isBlank(lines[q])) q--;
@@ -837,7 +821,7 @@ function parseDialog(pane) {
837
821
  let q = first - 1;
838
822
  while (q >= 0 && isBlank(lines[q])) q--;
839
823
  const questionLines = [];
840
- while (q >= 0 && !isBlank(lines[q]) && !isRule(lines[q]) && questionLines.length < 3) questionLines.unshift(lines[q--].trim());
824
+ while (q >= 0 && !isBlank(lines[q]) && !isRule(lines[q]) && questionLines.length < 3) questionLines.unshift(lines[q--].trim().replace(GUTTER, ""));
841
825
  if (questionLines.length === 0) return null;
842
826
  let top = q;
843
827
  while (top >= 0 && !RULE.test(lines[top].trim())) top--;
@@ -914,7 +898,7 @@ function promptGone(pane, pending) {
914
898
  var STATUS_SLACK = 3;
915
899
  var INPUT_MAX = 12;
916
900
  var isInputRule = (l) => /^─{10,}$/.test(l.trim());
917
- var isInputTop = (l) => /^─{10,}(?: \S.* ─+)?$/.test(l.trim());
901
+ var isInputTop = (l) => INPUT_TOP.test(l.trim());
918
902
  function inputBox(lines) {
919
903
  let end = lines.length;
920
904
  while (end > 0 && isBlank(lines[end - 1])) end--;
@@ -967,7 +951,7 @@ function rowPlan(n, text) {
967
951
  function freeTextPlan(text, pane) {
968
952
  const d = pane ? parseDialog(pane) : null;
969
953
  const i = d?.kind === "question" ? d.options.findIndex((o) => FREE_ROW.test(o)) : -1;
970
- if (i < 0) throw new Error(NO_FREE_ROW);
954
+ if (i < 0) throw new Refusal(NO_FREE_ROW, "free_text");
971
955
  return rowPlan(i + 1, text);
972
956
  }
973
957
  var PLAN_ACCEPT = /\bmanually approve\b/i;
@@ -980,11 +964,11 @@ function alwaysOption(pane) {
980
964
  const d = pane ? parseDialog(pane) : null;
981
965
  const full = d ? d.options.map((o, i2) => `${o} ${d.details[i2] ?? ""}`) : [];
982
966
  const i = d?.kind === "plan" ? full.findIndex((o) => PLAN_ALWAYS.test(o) && !CLEAR_CONTEXT.test(o)) : d?.kind === "permission" ? full.findIndex((o) => ALWAYS.test(o) && !AUTO_MODE.test(o)) : -1;
983
- if (i < 0) throw new Error(NO_ALWAYS);
967
+ if (i < 0) throw new Refusal(NO_ALWAYS, "not_offered");
984
968
  return i + 1;
985
969
  }
986
970
  function notePlan(note, pane) {
987
- if (pane && parseTrust(pane)) throw new Error(NO_NOTE);
971
+ if (pane && parseTrust(pane)) throw new Refusal(NO_NOTE, "not_offered");
988
972
  const text = flatten(note);
989
973
  const d = pane ? parseDialog(pane) : null;
990
974
  const row = d?.kind === "plan" ? d.options.findIndex((o) => PLAN_NOTE_ROW.test(o)) : -1;
@@ -1158,11 +1142,11 @@ var ALWAYS2 = /don['’]t ask again/i;
1158
1142
  function alwaysKeys(d) {
1159
1143
  const i = d?.kind === "approval" ? d.raw.findIndex((o) => ALWAYS2.test(o)) : -1;
1160
1144
  const key = i < 0 ? void 0 : d.raw[i].match(/\(([a-z])\)\s*$/)?.[1];
1161
- if (!key) throw new Error(NO_ALWAYS);
1145
+ if (!key) throw new Refusal(NO_ALWAYS, "not_offered");
1162
1146
  return [{ text: key }];
1163
1147
  }
1164
1148
  function noteKeys(d, note) {
1165
- if (d?.kind === "confirm") throw new Error(NO_NOTE);
1149
+ if (d?.kind === "confirm") throw new Refusal(NO_NOTE, "not_offered");
1166
1150
  const no = d?.kind === "plan" ? optionNumber2(d.options, /^No\b/) : null;
1167
1151
  return [...no ? digit2(no) : [{ keys: ["Escape"] }], { until: atRest2, denied: true }, { text: flatten(note) }, { waitMs: REPLY_ENTER_DELAY_MS }, { keys: ["Enter"] }];
1168
1152
  }
@@ -1183,7 +1167,7 @@ var codexAdapter = {
1183
1167
  }
1184
1168
  if (d?.kind === "confirm") {
1185
1169
  const trust = optionNumber2(d.options, /^Trust all\b|^Yes\b/i);
1186
- if (!trust) throw new Error("no trust option in this dialog, answer in the terminal");
1170
+ if (!trust) throw new Refusal("no trust option in this dialog, answer in the terminal", "not_offered");
1187
1171
  return [{ text: String(trust) }, { keys: ["Enter"] }];
1188
1172
  }
1189
1173
  if (d?.kind === "plan" || pending.kind === "plan") {
@@ -1336,12 +1320,12 @@ function promptStillVisible3(lastLines2) {
1336
1320
  if (end >= 0 && lines[end].trim().startsWith("\u2514") && lines.some((l) => QUESTION_FOOTER2.test(l) || /\(p or Esc\)\s*│?$/.test(l))) return true;
1337
1321
  return end >= 0 && DIALOG_FOOTER.test(lines[end]) && lines.some((l) => DIALOG_ACCEPT.test(l.trim()));
1338
1322
  }
1339
- var compact2 = (s) => s.replace(/\s+/g, "");
1323
+ var compact = (s) => s.replace(/\s+/g, "");
1340
1324
  var typed = (text) => (lastLines2) => {
1341
1325
  const lines = toLines(lastLines2);
1342
- const want = compact2(`\u2192 ${text}`);
1326
+ const want = compact(`\u2192 ${text}`);
1343
1327
  for (let i = lines.length - 1; i >= 0; i--) {
1344
- if (INPUT.test(lines[i]) && compact2(lines.slice(i).join("")).startsWith(want)) return true;
1328
+ if (INPUT.test(lines[i]) && compact(lines.slice(i).join("")).startsWith(want)) return true;
1345
1329
  }
1346
1330
  return false;
1347
1331
  };
@@ -1359,7 +1343,7 @@ var onChoice = (i, what) => (lastLines2) => {
1359
1343
  };
1360
1344
  function answerPlan(q, n) {
1361
1345
  const i = n - 1;
1362
- if (i < 0 || i >= q.options.length) throw new Error(`the question has no option ${n}`);
1346
+ if (i < 0 || i >= q.options.length) throw new Refusal(`the question has no option ${n}`, "not_offered");
1363
1347
  const from = q.cursor < 0 ? 0 : q.cursor;
1364
1348
  const moves = Array(Math.abs(i - from)).fill(i > from ? "Down" : "Up");
1365
1349
  return [
@@ -1381,7 +1365,7 @@ function atRest3(pane) {
1381
1365
  return i >= 0 && !TELL_INSTEAD.test(lines[i]) && !lines.slice(i).some((l) => BUSY3.test(l));
1382
1366
  }
1383
1367
  function notePlan2(lines, note) {
1384
- if (parseTrust2(lines) || parseQuestion(lines)) throw new Error(NO_NOTE);
1368
+ if (parseTrust2(lines) || parseQuestion(lines)) throw new Refusal(NO_NOTE, "not_offered");
1385
1369
  const text = flatten(note);
1386
1370
  const typeIt = [{ text }, { until: typed(text) }, { keys: ["Enter"] }];
1387
1371
  if (lines.some((l) => TELL_INSTEAD.test(l))) return typeIt;
@@ -1398,7 +1382,7 @@ var cursorAdapter = {
1398
1382
  keysFor(decision, pending, pane, note) {
1399
1383
  const lines = pane ? toLines(pane) : [];
1400
1384
  if (pane) assertDialogIs(pending, shownKind(lines));
1401
- if (decision === "always") throw new Error(NO_ALWAYS);
1385
+ if (decision === "always") throw new Refusal(NO_ALWAYS, "not_offered");
1402
1386
  if (decision === "deny" && note) return notePlan2(lines, note);
1403
1387
  if (parseTrust2(lines)) return [{ text: decision === "accept" ? "a" : "q" }];
1404
1388
  const q = parseQuestion(lines);
@@ -1783,7 +1767,7 @@ var OmpRpc = class _OmpRpc {
1783
1767
  request(type, params = {}, timeoutMs = 3e4) {
1784
1768
  if (this.exited || this.closing) return Promise.reject(new RpcError("OMP has ended", "closed"));
1785
1769
  const id = `l${++this.seq}`;
1786
- return new Promise((resolve7, reject) => {
1770
+ return new Promise((resolve8, reject) => {
1787
1771
  const timer = timeoutMs > 0 ? setTimeout(() => {
1788
1772
  this.pending.delete(id);
1789
1773
  reject(new RpcError(`${type}: no answer`, "timeout"));
@@ -1793,7 +1777,7 @@ var OmpRpc = class _OmpRpc {
1793
1777
  reject,
1794
1778
  resolve: (f) => {
1795
1779
  if (f.success === false) return reject(new RpcError(String(f.error ?? `${type} refused`), "refused"));
1796
- resolve7(f.data);
1780
+ resolve8(f.data);
1797
1781
  }
1798
1782
  });
1799
1783
  this.write({ id, type, ...params }).catch((e) => {
@@ -2055,6 +2039,12 @@ function project(f, t, at, helper) {
2055
2039
  t.working = false;
2056
2040
  return [ev({ name: "StopFailure", text: str2(f.error) ?? "the instruction failed" })];
2057
2041
  }
2042
+ if (f.status === "aborted" && f.sessionSettled === false && t.working) {
2043
+ t.working = false;
2044
+ t.reply = void 0;
2045
+ t.sent = false;
2046
+ return [ev({ name: "Stop", interrupt: true })];
2047
+ }
2058
2048
  return [];
2059
2049
  }
2060
2050
  default:
@@ -2121,7 +2111,7 @@ var APPROVAL_MODE = "always-ask";
2121
2111
  var STREAM_MS = 120;
2122
2112
  var ompModelArgs = (model, effort) => [...model ? ["--model", model] : [], ...effort ? ["--thinking", effort] : []];
2123
2113
  function ompArgs(o) {
2124
- return ["--mode", "rpc-ui", "--session-dir", o.dir, "--cwd", o.cwd, "--approval-mode", APPROVAL_MODE, ...ompModelArgs(o.model, o.effort), ...(o.dirs ?? []).flatMap((d) => ["--add-dir", d]), ...o.resumeFile ? ["--resume", o.resumeFile] : []];
2114
+ return ["--mode", "rpc-ui", "--session-dir", o.dir, "--cwd", o.cwd, "--approval-mode", o.approval ?? APPROVAL_MODE, ...ompModelArgs(o.model, o.effort), ...(o.dirs ?? []).flatMap((d) => ["--add-dir", d]), ...o.resumeFile ? ["--resume", o.resumeFile] : []];
2125
2115
  }
2126
2116
  function createOmpHost(deps) {
2127
2117
  const { store } = deps;
@@ -2229,7 +2219,8 @@ function createOmpHost(deps) {
2229
2219
  ...state.sessionId ? { agentSessionId: state.sessionId } : {},
2230
2220
  ...state.sessionFile ? { agentSessionFile: state.sessionFile } : {},
2231
2221
  ...o.model ?? model ? { model: o.model ?? model } : {},
2232
- ...o.effort ?? state.thinkingLevel ? { effort: o.effort ?? state.thinkingLevel } : {}
2222
+ ...o.effort ?? state.thinkingLevel ? { effort: o.effort ?? state.thinkingLevel } : {},
2223
+ ...o.approval && o.approval !== APPROVAL_MODE ? { approval: o.approval } : {}
2233
2224
  };
2234
2225
  attach(id, rpc);
2235
2226
  store.upsert(session);
@@ -2736,12 +2727,12 @@ function loginShellEnv(opts = {}) {
2736
2727
  const shell = opts.shell ?? process.env.SHELL;
2737
2728
  if (!shell) return Promise.resolve({});
2738
2729
  const [bin, ...args] = (opts.command ?? envCommand)(shell);
2739
- return new Promise((resolve7) => {
2730
+ return new Promise((resolve8) => {
2740
2731
  execFile2(bin, args, { timeout: opts.timeoutMs ?? 5e3, maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {
2741
2732
  const env = err ? {} : parseEnv(String(stdout));
2742
- if (env.PATH) return resolve7(env);
2733
+ if (env.PATH) return resolve8(env);
2743
2734
  log(`login shell environment unavailable (${shell}: ${err ? err.message.split("\n")[0] : "printed no PATH"}), launching with the daemon's`);
2744
- resolve7({});
2735
+ resolve8({});
2745
2736
  });
2746
2737
  });
2747
2738
  }
@@ -3579,17 +3570,17 @@ async function readAt(request, path) {
3579
3570
  if (stat3.size > DOCUMENT_MAX_BYTES) return failure(request, "too_large");
3580
3571
  const chunk = Buffer.allocUnsafe(64 * 1024);
3581
3572
  const parts = [];
3582
- let bytes = 0;
3583
- while (bytes <= DOCUMENT_MAX_BYTES) {
3584
- const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, DOCUMENT_MAX_BYTES + 1 - bytes), null);
3573
+ let bytes2 = 0;
3574
+ while (bytes2 <= DOCUMENT_MAX_BYTES) {
3575
+ const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, DOCUMENT_MAX_BYTES + 1 - bytes2), null);
3585
3576
  if (!bytesRead) break;
3586
3577
  parts.push(Buffer.from(chunk.subarray(0, bytesRead)));
3587
- bytes += bytesRead;
3578
+ bytes2 += bytesRead;
3588
3579
  }
3589
- if (bytes > DOCUMENT_MAX_BYTES) return failure(request, "too_large");
3580
+ if (bytes2 > DOCUMENT_MAX_BYTES) return failure(request, "too_large");
3590
3581
  let text;
3591
3582
  try {
3592
- text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, bytes));
3583
+ text = new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(parts, bytes2));
3593
3584
  } catch {
3594
3585
  return failure(request, "invalid_utf8");
3595
3586
  }
@@ -3598,7 +3589,7 @@ async function readAt(request, path) {
3598
3589
  requestId: request.requestId,
3599
3590
  path: await realpath(path),
3600
3591
  text,
3601
- bytes,
3592
+ bytes: bytes2,
3602
3593
  modifiedAt: stat3.mtime.toISOString()
3603
3594
  };
3604
3595
  } catch (e) {
@@ -3676,7 +3667,7 @@ var execRun2 = (env) => async (file, args) => {
3676
3667
  function createModels(deps = {}) {
3677
3668
  const env = deps.env ?? (async () => process.env);
3678
3669
  const run2 = deps.run ?? execRun2(env);
3679
- const readText = deps.readText ?? ((p) => readFile5(p, "utf8").catch(() => ""));
3670
+ const readText2 = deps.readText ?? ((p) => readFile5(p, "utf8").catch(() => ""));
3680
3671
  const now = deps.now ?? Date.now, ttl = deps.ttlMs ?? MODELS_TTL_MS;
3681
3672
  const cache = /* @__PURE__ */ new Map();
3682
3673
  const flying = /* @__PURE__ */ new Map();
@@ -3684,7 +3675,7 @@ function createModels(deps = {}) {
3684
3675
  const e = await env();
3685
3676
  const home = e.HOME ?? homedir5();
3686
3677
  if (tool === "claude") {
3687
- const settings = await readText(join10(e.CLAUDE_CONFIG_DIR ?? join10(home, ".claude"), "settings.json"));
3678
+ const settings = await readText2(join10(e.CLAUDE_CONFIG_DIR ?? join10(home, ".claude"), "settings.json"));
3688
3679
  let model;
3689
3680
  try {
3690
3681
  model = settings ? JSON.parse(settings).model : void 0;
@@ -3700,7 +3691,7 @@ function createModels(deps = {}) {
3700
3691
  if (r.code === null) throw new Error("not installed");
3701
3692
  if (r.code !== 0) throw new Error(r.stderr.trim().split("\n").pop() || `exit ${r.code}`);
3702
3693
  if (tool === "cursor") return parseCursorModels(r.stdout);
3703
- return parseCodexModels(r.stdout, codexConfig(await readText(join10(e.CODEX_HOME ?? join10(home, ".codex"), "config.toml"))));
3694
+ return parseCodexModels(r.stdout, codexConfig(await readText2(join10(e.CODEX_HOME ?? join10(home, ".codex"), "config.toml"))));
3704
3695
  }
3705
3696
  return {
3706
3697
  list(tool) {
@@ -4034,6 +4025,654 @@ function serveSsh(ws, alias, opts = {}) {
4034
4025
  ws.on("error", () => stop());
4035
4026
  }
4036
4027
 
4028
+ // src/mobile/gateway.ts
4029
+ import { hostname } from "os";
4030
+
4031
+ // src/mobile/outbox.ts
4032
+ var QUEUE_MESSAGES_MAX = 300;
4033
+ var QUEUE_BYTES_MAX = 8 * 1048576;
4034
+ var costOf = (padded) => Math.ceil(Buffer.byteLength(padded, "utf8") * 4 / 3) + 256;
4035
+ function createOutbox(opts) {
4036
+ const now = opts.now ?? (() => performance.now());
4037
+ const frames = opts.frames ?? 200;
4038
+ const bytes2 = opts.bytes ?? 4 * 1048576;
4039
+ const windowMs = opts.windowMs ?? 1e4;
4040
+ const queues = /* @__PURE__ */ new Map();
4041
+ const queuedBytes = /* @__PURE__ */ new Map();
4042
+ const turn = [];
4043
+ let window = [];
4044
+ let windowBytes = 0;
4045
+ let running = false;
4046
+ let closed = false;
4047
+ let timer = null;
4048
+ const clear = (deviceId) => {
4049
+ queues.delete(deviceId);
4050
+ queuedBytes.delete(deviceId);
4051
+ const i = turn.indexOf(deviceId);
4052
+ if (i >= 0) turn.splice(i, 1);
4053
+ };
4054
+ const prune = () => {
4055
+ const limit = now() - windowMs;
4056
+ while (window.length > 0 && window[0].at <= limit) windowBytes -= window.shift().cost;
4057
+ };
4058
+ const pump = async () => {
4059
+ if (running || closed || timer !== null) return;
4060
+ running = true;
4061
+ try {
4062
+ while (!closed && turn.length > 0) {
4063
+ prune();
4064
+ const deviceId = turn[0];
4065
+ const queue = queues.get(deviceId);
4066
+ const item = queue[0];
4067
+ const full = window.length >= frames || window.length > 0 && windowBytes + item.cost > bytes2;
4068
+ if (full) {
4069
+ const wait = Math.max(1, window[0].at + windowMs - now());
4070
+ timer = setTimeout(() => {
4071
+ timer = null;
4072
+ void pump();
4073
+ }, wait);
4074
+ return;
4075
+ }
4076
+ queue.shift();
4077
+ queuedBytes.set(deviceId, (queuedBytes.get(deviceId) ?? 0) - item.cost);
4078
+ turn.shift();
4079
+ if (queue.length > 0) turn.push(deviceId);
4080
+ else clear(deviceId);
4081
+ window.push({ at: now(), cost: item.cost });
4082
+ windowBytes += item.cost;
4083
+ let ok = false;
4084
+ try {
4085
+ ok = await opts.send(deviceId, item.text);
4086
+ } catch {
4087
+ ok = false;
4088
+ }
4089
+ if (!ok && !closed) {
4090
+ clear(deviceId);
4091
+ try {
4092
+ opts.onGone(deviceId);
4093
+ } catch (err) {
4094
+ opts.log(`phone outbox: onGone failed (${err instanceof Error ? err.name : "error"})`);
4095
+ }
4096
+ }
4097
+ }
4098
+ } finally {
4099
+ running = false;
4100
+ }
4101
+ };
4102
+ const enqueue = (deviceId, text, key, priority = false) => {
4103
+ const cost = costOf(text);
4104
+ let queue = queues.get(deviceId);
4105
+ const at = key === void 0 ? -1 : queue?.findIndex((q) => q.key === key) ?? -1;
4106
+ if (queue && at >= 0) {
4107
+ queuedBytes.set(deviceId, (queuedBytes.get(deviceId) ?? 0) - queue[at].cost + cost);
4108
+ queue[at] = { key, text, cost, ...queue[at].priority ? { priority: true } : {} };
4109
+ } else {
4110
+ queue ??= [];
4111
+ if (priority) {
4112
+ let i = 0;
4113
+ while (queue[i]?.priority) i++;
4114
+ queue.splice(i, 0, { key, text, cost, priority: true });
4115
+ } else queue.push({ key, text, cost });
4116
+ queues.set(deviceId, queue);
4117
+ queuedBytes.set(deviceId, (queuedBytes.get(deviceId) ?? 0) + cost);
4118
+ if (!turn.includes(deviceId)) turn.push(deviceId);
4119
+ }
4120
+ if (queue.length > QUEUE_MESSAGES_MAX || (queuedBytes.get(deviceId) ?? 0) > QUEUE_BYTES_MAX) {
4121
+ clear(deviceId);
4122
+ opts.log(`phone outbox: queue overflow for device ${deviceId}`);
4123
+ opts.onOverflow(deviceId);
4124
+ return;
4125
+ }
4126
+ void pump();
4127
+ };
4128
+ return {
4129
+ push(deviceId, msg, key) {
4130
+ if (closed) return;
4131
+ const json = JSON.stringify(msg);
4132
+ const padded = padPlain(json);
4133
+ if (padded === null) {
4134
+ opts.log(`phone message too large: ${msg.type} ${Buffer.byteLength(json, "utf8")} bytes`);
4135
+ const err = padPlain(JSON.stringify({ type: "error", code: "too_large", request: msg.type }));
4136
+ enqueue(deviceId, err, `too_large:${msg.type}`, true);
4137
+ return;
4138
+ }
4139
+ enqueue(deviceId, padded, key, msg.type === "operation.result" || msg.type === "error");
4140
+ },
4141
+ drop: clear,
4142
+ close() {
4143
+ closed = true;
4144
+ if (timer) clearTimeout(timer);
4145
+ timer = null;
4146
+ queues.clear();
4147
+ queuedBytes.clear();
4148
+ turn.length = 0;
4149
+ window = [];
4150
+ }
4151
+ };
4152
+ }
4153
+
4154
+ // src/mobile/operations.ts
4155
+ function fingerprint(m) {
4156
+ const f = m;
4157
+ return JSON.stringify([m.type, m.id, f.decision ?? null, f.note ?? null, f.text ?? null, f.expect?.key ?? null]);
4158
+ }
4159
+ var OperationRegistry = class {
4160
+ records = /* @__PURE__ */ new Map();
4161
+ now;
4162
+ ttlMs;
4163
+ max;
4164
+ constructor(o = {}) {
4165
+ this.now = o.now ?? Date.now;
4166
+ this.ttlMs = o.ttlMs ?? 864e5;
4167
+ this.max = o.max ?? 500;
4168
+ }
4169
+ begin(owner, op, fp) {
4170
+ this.expire();
4171
+ const known = this.records.get(op);
4172
+ if (known) {
4173
+ if (known.owner !== owner || known.fingerprint !== fp) return { kind: "conflict" };
4174
+ return { kind: "replay", record: { ...known } };
4175
+ }
4176
+ if (this.records.size >= this.max && !this.evictOldestFinished()) return { kind: "conflict" };
4177
+ const record = { op, owner, fingerprint: fp, status: "received", at: this.now() };
4178
+ this.records.set(op, record);
4179
+ return { kind: "fresh", record: { ...record } };
4180
+ }
4181
+ /** An operation already forgotten (expired) is left forgotten. */
4182
+ finish(op, status, reason) {
4183
+ const r = this.records.get(op);
4184
+ if (!r) return;
4185
+ r.status = status;
4186
+ if (reason) r.reason = reason;
4187
+ else delete r.reason;
4188
+ }
4189
+ get(op) {
4190
+ this.expire();
4191
+ const r = this.records.get(op);
4192
+ return r && { ...r };
4193
+ }
4194
+ expire() {
4195
+ const limit = this.now() - this.ttlMs;
4196
+ for (const [op, r] of this.records) if (r.at < limit) this.records.delete(op);
4197
+ }
4198
+ evictOldestFinished() {
4199
+ for (const [op, r] of this.records) {
4200
+ if (r.status !== "received") {
4201
+ this.records.delete(op);
4202
+ return true;
4203
+ }
4204
+ }
4205
+ return false;
4206
+ }
4207
+ };
4208
+
4209
+ // src/mobile/risk.ts
4210
+ import { homedir as homedir6 } from "os";
4211
+ import { resolve as resolve7, sep as sep2, join as join12 } from "path";
4212
+ var SHELL_TOOLS = /* @__PURE__ */ new Set(["Bash"]);
4213
+ var high = (reason) => ({ risk: "high", reason });
4214
+ var low = (reason) => ({ risk: "low", reason });
4215
+ var inside = (cwd, p) => {
4216
+ const root = resolve7(cwd);
4217
+ const full = resolve7(root, p);
4218
+ return full === root || full.startsWith(root + sep2);
4219
+ };
4220
+ function isSensitive(cwd, p) {
4221
+ if (p.includes("~")) return true;
4222
+ const full = resolve7(cwd, p);
4223
+ const segs = full.split(sep2);
4224
+ if (segs.includes(".git") || segs.some((s) => s.startsWith(".env"))) return true;
4225
+ const home = homedir6();
4226
+ return [join12(home, ".ssh"), join12(home, ".ledge")].some((d) => full === d || full.startsWith(d + sep2));
4227
+ }
4228
+ var PUSH = /\bgit\s+push\b/;
4229
+ var DESTROYS = [/\brm\s+-\w*[rRf]/, /\bgit\s+reset\s+--hard\b/, /\bgit\s+clean\b/, /\bgit\s+branch\s+-D\b/, /\bgit\s+checkout\s+--\s/];
4230
+ var DEPLOYS = [/\bdeploy\b/, /\bterraform\s+apply\b/, /\bkubectl\s+(apply|delete)\b/];
4231
+ var PUBLISHES = /\b(npm|pnpm|yarn)\s+publish\b/;
4232
+ var PIPES_TO_SHELL = /\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(ba|z|da)?sh\b/;
4233
+ var SHELL_SYNTAX = /[;&|<>`'"\\$*?[\]{}\x00-\x1f\x7f]/;
4234
+ var FORBIDDEN_FLAG = /^(--output|--hostname-bin|--ext-diff$|--textconv$|-c$|-exec$|-execdir$|-ok$|-okdir$|-delete$|-fprint|-fls$)/;
4235
+ var RG_FORBIDDEN_FLAG = /^--pre/;
4236
+ var READ_ONLY = /* @__PURE__ */ new Set(["ls", "pwd", "cat", "head", "tail", "wc", "rg", "grep", "find"]);
4237
+ var GIT_READ_ONLY = /* @__PURE__ */ new Set(["status", "diff", "log", "show", "rev-parse"]);
4238
+ var GIT_BRANCH_FLAGS = /* @__PURE__ */ new Set(["-a", "-v", "--list", "--show-current"]);
4239
+ var SCRIPT_WORDS = /* @__PURE__ */ new Set(["pnpm", "npm", "yarn", "npx", "node", "make", "cargo", "go", "bash", "sh"]);
4240
+ function classifyShell(command, cwd) {
4241
+ if (PUSH.test(command)) return high("pushes");
4242
+ if (DESTROYS.some((r) => r.test(command))) return high("destroys");
4243
+ if (DEPLOYS.some((r) => r.test(command))) return high("deploys");
4244
+ if (PUBLISHES.test(command)) return high("publishes");
4245
+ if (PIPES_TO_SHELL.test(command)) return high("pipes_to_shell");
4246
+ if (SHELL_SYNTAX.test(command)) return high("shell_syntax");
4247
+ const words = command.trim().split(/ +/).filter(Boolean);
4248
+ const first = words[0];
4249
+ if (!first || first.includes("=")) return high("unknown");
4250
+ const args = words.slice(1);
4251
+ const beforeDashes = args.includes("--") ? args.slice(0, args.indexOf("--")) : args;
4252
+ if (args.some((a) => FORBIDDEN_FLAG.test(a)) || first === "rg" && beforeDashes.some((a) => RG_FORBIDDEN_FLAG.test(a))) return high("unsafe_flag");
4253
+ const dd = args.indexOf("--");
4254
+ const bad = (v) => v !== "" && (isSensitive(cwd, v) || !inside(cwd, v));
4255
+ const flagBad = (a) => a.includes("/") || a.includes("~") || bad(a.slice(a.indexOf("=") + 1 || a.length));
4256
+ const checked = args.some((a, i) => dd !== -1 && i > dd ? a.startsWith("-") || bad(a) : a.startsWith("-") ? flagBad(a) : bad(a));
4257
+ if (checked) return high("sensitive_path");
4258
+ if (READ_ONLY.has(first)) return low("read_only_command");
4259
+ if (first === "git") {
4260
+ const sub = args[0];
4261
+ if (sub && GIT_READ_ONLY.has(sub)) return low("read_only_command");
4262
+ if (sub === "branch" && args.slice(1).every((a) => GIT_BRANCH_FLAGS.has(a))) return low("read_only_command");
4263
+ }
4264
+ if (SCRIPT_WORDS.has(first) || first.startsWith("python") || first.startsWith("./")) return high("script");
4265
+ return high("unknown");
4266
+ }
4267
+ function classify(input) {
4268
+ const { kind, tool, text, cwd, command } = input;
4269
+ if (kind === "question") return low("question");
4270
+ if (kind === "plan") return high("plan");
4271
+ if (kind === "diff") {
4272
+ const t = text.trim();
4273
+ const paths2 = t === "" || t === "patch" ? [] : t.split(", ").map((p) => p.trim()).filter(Boolean);
4274
+ if (paths2.length === 0 || paths2.some((p) => /[,\n\r]/.test(p))) return high("unknown");
4275
+ if (paths2.some((p) => isSensitive(cwd, p))) return high("sensitive_path");
4276
+ if (paths2.some((p) => !inside(cwd, p))) return high("writes_outside_folder");
4277
+ return low("edit_inside_folder");
4278
+ }
4279
+ if (kind !== "permission") return high("unknown");
4280
+ if (tool === "Read") {
4281
+ const p = text.trim();
4282
+ if (p === "") return high("unknown");
4283
+ if (isSensitive(cwd, p)) return high("sensitive_path");
4284
+ return inside(cwd, p) ? low("read_only_command") : high("unknown");
4285
+ }
4286
+ if (tool === "WebFetch" || tool === "WebSearch") return high("network");
4287
+ if (!tool || !SHELL_TOOLS.has(tool)) return high("unknown");
4288
+ return classifyShell(command ?? "", cwd);
4289
+ }
4290
+ function commandOf(p) {
4291
+ if (p.kind !== "permission" || p.source === "rpc") return void 0;
4292
+ if (p.source === "hook" && !(p.tool && SHELL_TOOLS.has(p.tool))) return void 0;
4293
+ return p.text.trim() === "" ? void 0 : p.text;
4294
+ }
4295
+ function riskOf(s) {
4296
+ const p = s.pending;
4297
+ if (!p) return void 0;
4298
+ const command = commandOf(p);
4299
+ return classify({ kind: p.kind, ...p.tool ? { tool: p.tool } : {}, ...command !== void 0 ? { command } : {}, text: p.text, cwd: s.workdir ?? s.cwd, source: p.source });
4300
+ }
4301
+
4302
+ // src/mobile/project.ts
4303
+ function pickSet(o, keys) {
4304
+ const out = {};
4305
+ for (const k of keys) if (o[k] !== void 0) out[k] = o[k];
4306
+ return out;
4307
+ }
4308
+ function clip(s, max) {
4309
+ if (s.length <= max) return { text: s, cut: false };
4310
+ const end = /[\ud800-\udbff]/.test(s[max - 1] ?? "") ? max - 1 : max;
4311
+ return { text: s.slice(0, end), cut: true };
4312
+ }
4313
+ function toPhonePending(p, key, risk, riskReason, bodyMax) {
4314
+ const body = p.body === void 0 ? void 0 : clip(p.body, bodyMax);
4315
+ return {
4316
+ kind: p.kind,
4317
+ text: p.text,
4318
+ source: p.source,
4319
+ ...body ? { body: body.text } : {},
4320
+ ...pickSet(p, ["tool", "helperId", "parentState"]),
4321
+ ...p.options ? { options: [...p.options] } : {},
4322
+ ...p.optionDetails ? { optionDetails: [...p.optionDetails] } : {},
4323
+ key,
4324
+ risk,
4325
+ riskReason,
4326
+ ...body?.cut ? { cut: true } : {}
4327
+ };
4328
+ }
4329
+ function toPhoneRequest(s, bodyMax) {
4330
+ if (!s.pending) return void 0;
4331
+ const verdict = riskOf(s) ?? { risk: "high", reason: "unknown" };
4332
+ return toPhonePending(s.pending, pendingKey(s.pending), verdict.risk, verdict.reason, bodyMax);
4333
+ }
4334
+ var toPhoneFullRequest = (s) => toPhoneRequest(s, PENDING_BODY_MAX);
4335
+ function toPhoneSession(s, readAt2) {
4336
+ const pending = toPhoneRequest(s, SNAPSHOT_BODY_MAX);
4337
+ return {
4338
+ id: s.id,
4339
+ name: s.name,
4340
+ tool: s.tool,
4341
+ project: s.project,
4342
+ title: s.title,
4343
+ state: s.state,
4344
+ startedAt: s.startedAt,
4345
+ lastActivityAt: s.lastActivityAt,
4346
+ // Topics are already cut to TOPIC_MAX where they are set; clipped again so no source can swell a snapshot.
4347
+ ...s.topic !== void 0 ? { topic: clip(s.topic, TOPIC_MAX).text } : {},
4348
+ ...pickSet(s, ["repo", "branch", "worktree"]),
4349
+ helpers: s.helpers.map((h) => ({ id: h.id, kind: h.kind, task: h.task, state: h.state, ...pickSet(h, ["finishedAt"]) })),
4350
+ // A preview only: the whole reply is in the thread.
4351
+ ...s.lastReply ? { lastReply: { text: clip(s.lastReply.text, TOOL_TEXT_MAX).text, at: s.lastReply.at, ...pickSet(s.lastReply, ["plan"]) } } : {},
4352
+ ...pending ? { pending } : {},
4353
+ unread: !!s.lastReply && s.lastReply.at > readAt2
4354
+ };
4355
+ }
4356
+ function toPhoneItem(item) {
4357
+ let cut = false;
4358
+ const text = (s, max = THREAD_TEXT_MAX) => {
4359
+ const c = clip(s, max);
4360
+ if (c.cut) cut = true;
4361
+ return c.text;
4362
+ };
4363
+ const optText = (s, max = THREAD_TEXT_MAX) => s === void 0 ? {} : { v: text(s, max) };
4364
+ const base = { id: item.id, at: item.at, turn: item.turn, ...pickSet(item, ["helperId"]) };
4365
+ let out;
4366
+ switch (item.kind) {
4367
+ case "prompt":
4368
+ case "message":
4369
+ case "reply":
4370
+ out = { kind: item.kind, ...base, text: text(item.text) };
4371
+ break;
4372
+ case "tool": {
4373
+ const input = optText(item.input, TOOL_TEXT_MAX), output = optText(item.output, TOOL_TEXT_MAX), error = optText(item.error, TOOL_TEXT_MAX);
4374
+ out = {
4375
+ kind: "tool",
4376
+ ...base,
4377
+ tool: item.tool,
4378
+ label: text(item.label),
4379
+ status: item.status,
4380
+ ...input.v !== void 0 ? { input: input.v } : {},
4381
+ ...output.v !== void 0 ? { output: output.v } : {},
4382
+ ...error.v !== void 0 ? { error: error.v } : {},
4383
+ ...pickSet(item, ["durationMs"]),
4384
+ files: item.files.map((f) => ({ path: f.path, ...pickSet(f, ["from"]), change: f.change }))
4385
+ };
4386
+ break;
4387
+ }
4388
+ case "permission":
4389
+ out = { kind: "permission", ...base, tool: item.tool, text: text(item.text), ...pickSet(item, ["decision"]) };
4390
+ break;
4391
+ case "helper":
4392
+ out = { kind: "helper", ...base, task: text(item.task), state: item.state, ...pickSet(item, ["agentType"]) };
4393
+ break;
4394
+ case "sent": {
4395
+ const summary = optText(item.summary);
4396
+ out = {
4397
+ kind: "sent",
4398
+ ...base,
4399
+ to: item.to,
4400
+ text: text(item.text),
4401
+ status: item.status,
4402
+ ...summary.v !== void 0 ? { summary: summary.v } : {},
4403
+ ...pickSet(item, ["peer", "received", "via"])
4404
+ };
4405
+ break;
4406
+ }
4407
+ case "received":
4408
+ out = { kind: "received", ...base, from: item.from, text: text(item.text), ...pickSet(item, ["peer", "sent"]) };
4409
+ break;
4410
+ case "notice": {
4411
+ const summary = optText(item.summary), reason = optText(item.reason);
4412
+ out = {
4413
+ kind: "notice",
4414
+ ...base,
4415
+ notice: item.notice,
4416
+ text: text(item.text),
4417
+ ...pickSet(item, ["name", "peer", "time", "status", "count"]),
4418
+ ...item.recipients ? { recipients: [...item.recipients] } : {},
4419
+ ...summary.v !== void 0 ? { summary: summary.v } : {},
4420
+ ...reason.v !== void 0 ? { reason: reason.v } : {}
4421
+ };
4422
+ break;
4423
+ }
4424
+ }
4425
+ return { item: out, cut };
4426
+ }
4427
+ var bytes = (v) => Buffer.byteLength(JSON.stringify(v), "utf8");
4428
+ function toPhoneThread(items) {
4429
+ const kept = [];
4430
+ const cut = [];
4431
+ let used = 2;
4432
+ for (let i = items.length - 1; i >= 0 && kept.length < THREAD_ITEMS_MAX; i--) {
4433
+ const projected = toPhoneItem(items[i]);
4434
+ const size = bytes(projected.item) + (kept.length > 0 ? 1 : 0);
4435
+ if (used + size > THREAD_BUDGET) break;
4436
+ used += size;
4437
+ kept.push(projected.item);
4438
+ if (projected.cut) cut.push(projected.item.id);
4439
+ }
4440
+ return { items: kept.reverse(), cut: cut.reverse(), more: kept.length < items.length };
4441
+ }
4442
+ function capabilities() {
4443
+ const caps = {};
4444
+ for (const tool of ToolSchema.options) caps[tool] = { freeTextAnswer: tool !== "omp" && !!adapterFor(tool).freeText };
4445
+ return caps;
4446
+ }
4447
+
4448
+ // src/mobile/gateway.ts
4449
+ var REQUEST_NAME = /^[a-z][a-z.]{0,39}$/;
4450
+ function createPhoneGateway(deps) {
4451
+ const { mobile, log: log2 } = deps;
4452
+ const startedAt = new Date(deps.now ? deps.now() : Date.now()).toISOString();
4453
+ const registry = new OperationRegistry();
4454
+ const followers = /* @__PURE__ */ new Map();
4455
+ let stopped2 = false;
4456
+ const follower = (deviceId) => {
4457
+ let f = followers.get(deviceId);
4458
+ if (!f) followers.set(deviceId, f = { subscribed: false, watching: null, reads: /* @__PURE__ */ new Map() });
4459
+ return f;
4460
+ };
4461
+ const forget = (deviceId) => {
4462
+ followers.delete(deviceId);
4463
+ outbox.drop(deviceId);
4464
+ };
4465
+ const approved = (deviceId) => mobile.state().devices.some((d) => d.id === deviceId);
4466
+ const live2 = (id) => {
4467
+ const s = deps.session(id);
4468
+ return s && !isArchived(s) ? s : void 0;
4469
+ };
4470
+ const projected = (deviceId, s) => toPhoneSession(s, followers.get(deviceId)?.reads.get(s.id) ?? startedAt);
4471
+ const snapshot = (deviceId) => ({
4472
+ type: "snapshot",
4473
+ protocol: PHONE_PROTOCOL,
4474
+ sessions: deps.sessions().filter((s) => !isArchived(s)).map((s) => projected(deviceId, s)),
4475
+ capabilities: capabilities(),
4476
+ daemon: { version: deps.version, name: clip(hostname(), 40).text }
4477
+ });
4478
+ const outbox = createOutbox({
4479
+ send: (deviceId, text) => mobile.send(deviceId, text),
4480
+ onGone: (deviceId) => {
4481
+ log2(`phone: device ${deviceId} gone`);
4482
+ followers.delete(deviceId);
4483
+ },
4484
+ onOverflow: (deviceId) => {
4485
+ if (followers.get(deviceId)?.subscribed) outbox.push(deviceId, snapshot(deviceId), "snapshot");
4486
+ },
4487
+ log: log2,
4488
+ ...deps.outbox
4489
+ });
4490
+ const push = (deviceId, msg, key) => {
4491
+ if (!stopped2) outbox.push(deviceId, msg, key);
4492
+ };
4493
+ const result = (deviceId, op, status, reason) => {
4494
+ if (stopped2 || !approved(deviceId)) return;
4495
+ push(deviceId, { type: "operation.result", op, status, ...reason ? { reason } : {} });
4496
+ };
4497
+ const precheck = (msg) => {
4498
+ const s = live2(msg.id);
4499
+ if (!s) return { status: "rejected", reason: "unknown_session" };
4500
+ switch (msg.type) {
4501
+ case "session.decide":
4502
+ if (!s.pending || pendingKey(s.pending) !== msg.expect.key) return { status: "rejected", reason: "stale" };
4503
+ if (msg.decision !== "deny" && riskOf(s)?.risk !== "low") return { status: "rejected", reason: "confirm_required" };
4504
+ return msg.note !== void 0 && UNTYPABLE.test(msg.note) ? { status: "rejected", reason: "untypable" } : void 0;
4505
+ case "session.reply": {
4506
+ if (msg.expect ? !s.pending || pendingKey(s.pending) !== msg.expect.key : !!s.pending) return { status: "rejected", reason: "stale" };
4507
+ const options = s.pending?.kind === "question" ? s.pending.options : void 0;
4508
+ if (options?.length && /^[1-9]$/.test(msg.text) && Number(msg.text) > options.length) return { status: "rejected", reason: "invalid" };
4509
+ return UNTYPABLE.test(msg.text) ? { status: "rejected", reason: "untypable" } : void 0;
4510
+ }
4511
+ case "session.interrupt":
4512
+ return s.tmuxSession || backendOf(s) === "omp-rpc" ? void 0 : { status: "rejected", reason: "read_only" };
4513
+ }
4514
+ };
4515
+ const local = (msg) => {
4516
+ switch (msg.type) {
4517
+ case "session.decide":
4518
+ return { type: "session.decide", id: msg.id, decision: msg.decision, ...msg.note !== void 0 ? { note: msg.note } : {}, expect: { kind: msg.expect.kind, text: msg.expect.text, key: msg.expect.key } };
4519
+ case "session.reply":
4520
+ return { type: "session.reply", id: msg.id, text: msg.text };
4521
+ case "session.interrupt":
4522
+ return { type: "session.interrupt", id: msg.id };
4523
+ }
4524
+ };
4525
+ const outcomeOf = (code) => {
4526
+ if (!code) return { status: "failed" };
4527
+ if (code === "not_taken") return { status: "failed", reason: "not_taken" };
4528
+ const known = RefusalCodeSchema.safeParse(code);
4529
+ return { status: "rejected", reason: known.success ? known.data : "invalid" };
4530
+ };
4531
+ const run2 = async (msg) => {
4532
+ const refused = precheck(msg);
4533
+ if (refused) return refused;
4534
+ const out = local(msg);
4535
+ let error;
4536
+ await deps.dispatch(out, (m) => {
4537
+ if (m.type === "error" && (m.request === void 0 || m.request === out.type)) error ??= { code: m.code };
4538
+ });
4539
+ return error ? outcomeOf(error.code) : { status: "applied" };
4540
+ };
4541
+ const write = async (deviceId, msg) => {
4542
+ const begun = registry.begin(deviceId, msg.op, fingerprint(msg));
4543
+ if (begun.kind === "conflict") return result(deviceId, msg.op, "rejected", "conflict");
4544
+ if (begun.kind === "replay") return result(deviceId, msg.op, begun.record.status, begun.record.reason);
4545
+ let outcome;
4546
+ try {
4547
+ result(deviceId, msg.op, "received");
4548
+ outcome = await run2(msg);
4549
+ } catch (e) {
4550
+ log2(`phone ${msg.type} failed (${e instanceof Error ? e.name : "error"})`);
4551
+ outcome = { status: "failed" };
4552
+ }
4553
+ registry.finish(msg.op, outcome.status, outcome.reason);
4554
+ result(deviceId, msg.op, outcome.status, outcome.reason);
4555
+ };
4556
+ const read = (deviceId, msg) => {
4557
+ switch (msg.type) {
4558
+ case "snapshot.request":
4559
+ follower(deviceId).subscribed = true;
4560
+ return push(deviceId, snapshot(deviceId), "snapshot");
4561
+ case "session.thread": {
4562
+ if (!live2(msg.id)) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
4563
+ return push(deviceId, { type: "session.thread", id: msg.id, ...toPhoneThread(deps.thread(msg.id) ?? []) });
4564
+ }
4565
+ case "session.pending": {
4566
+ const session = live2(msg.id);
4567
+ const pending = session && toPhoneFullRequest(session);
4568
+ if (!pending) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
4569
+ return push(deviceId, { type: "session.pending", id: msg.id, pending });
4570
+ }
4571
+ case "session.watch": {
4572
+ if (msg.id === null) {
4573
+ follower(deviceId).watching = null;
4574
+ return;
4575
+ }
4576
+ if (!live2(msg.id)) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
4577
+ follower(deviceId).watching = msg.id;
4578
+ const lines = deps.preview(msg.id);
4579
+ if (lines) push(deviceId, { type: "session.preview", id: msg.id, lines: [...lines] }, `session.preview:${msg.id}`);
4580
+ return;
4581
+ }
4582
+ case "session.read": {
4583
+ const s = live2(msg.id);
4584
+ if (!s) return push(deviceId, { type: "error", code: "unknown_session", request: msg.type });
4585
+ const at = new Date(msg.at).toISOString();
4586
+ const f = follower(deviceId);
4587
+ if (at > (f.reads.get(msg.id) ?? "")) f.reads.set(msg.id, at);
4588
+ if (!f.subscribed) return;
4589
+ return push(deviceId, { type: "session.updated", session: projected(deviceId, s) }, `session.updated:${s.id}`);
4590
+ }
4591
+ }
4592
+ };
4593
+ const receive = (deviceId, text) => {
4594
+ if (stopped2) return;
4595
+ let raw;
4596
+ try {
4597
+ raw = JSON.parse(text);
4598
+ } catch {
4599
+ raw = void 0;
4600
+ }
4601
+ const parsed = raw === void 0 ? void 0 : PhoneClientMessageSchema.safeParse(raw);
4602
+ if (!parsed?.success) {
4603
+ const type = raw?.type;
4604
+ const request = typeof type === "string" && REQUEST_NAME.test(type) ? type : void 0;
4605
+ log2(`phone message refused: ${request ?? "?"}`);
4606
+ return push(deviceId, { type: "error", code: "invalid", ...request !== void 0 ? { request } : {} });
4607
+ }
4608
+ const msg = parsed.data;
4609
+ if ("op" in msg) {
4610
+ void write(deviceId, msg).catch((e) => log2(`phone ${msg.type} failed (${e instanceof Error ? e.name : "error"})`));
4611
+ return;
4612
+ }
4613
+ try {
4614
+ read(deviceId, msg);
4615
+ } catch (e) {
4616
+ log2(`phone ${msg.type} failed (${e instanceof Error ? e.name : "error"})`);
4617
+ }
4618
+ };
4619
+ const pruneRevoked = () => {
4620
+ if (followers.size === 0) return;
4621
+ const ids = new Set(mobile.state().devices.map((d) => d.id));
4622
+ for (const deviceId of [...followers.keys()]) if (!ids.has(deviceId)) forget(deviceId);
4623
+ };
4624
+ const removed = (id) => {
4625
+ for (const [deviceId, f] of followers) {
4626
+ f.reads.delete(id);
4627
+ if (f.watching === id) f.watching = null;
4628
+ if (f.subscribed) push(deviceId, { type: "session.removed", id });
4629
+ }
4630
+ };
4631
+ const unsubscribeMessage = mobile.onMessage(receive);
4632
+ const unsubscribeChange = mobile.onChange(pruneRevoked);
4633
+ return {
4634
+ forward(m) {
4635
+ if (stopped2 || followers.size === 0) return;
4636
+ try {
4637
+ switch (m.type) {
4638
+ case "session.updated":
4639
+ if (isArchived(m.session)) return removed(m.session.id);
4640
+ for (const [deviceId, f] of followers) {
4641
+ if (f.subscribed) push(deviceId, { type: "session.updated", session: projected(deviceId, m.session) }, `session.updated:${m.session.id}`);
4642
+ }
4643
+ return;
4644
+ case "session.removed":
4645
+ return removed(m.id);
4646
+ case "session.preview":
4647
+ for (const [deviceId, f] of followers) {
4648
+ if (f.watching === m.id) push(deviceId, { type: "session.preview", id: m.id, lines: [...m.lines] }, `session.preview:${m.id}`);
4649
+ }
4650
+ return;
4651
+ case "session.thread.item": {
4652
+ const watchers = [...followers].filter(([, f]) => f.watching === m.id);
4653
+ if (watchers.length === 0) return;
4654
+ const { item, cut } = toPhoneItem(m.item);
4655
+ for (const [deviceId] of watchers) push(deviceId, { type: "session.thread.item", id: m.id, item, ...cut ? { cut: true } : {} });
4656
+ return;
4657
+ }
4658
+ default:
4659
+ return;
4660
+ }
4661
+ } catch (e) {
4662
+ log2(`phone forward of ${m.type} failed (${e instanceof Error ? e.name : "error"})`);
4663
+ }
4664
+ },
4665
+ stop() {
4666
+ if (stopped2) return;
4667
+ stopped2 = true;
4668
+ unsubscribeMessage();
4669
+ unsubscribeChange();
4670
+ outbox.close();
4671
+ followers.clear();
4672
+ }
4673
+ };
4674
+ }
4675
+
4037
4676
  // src/daemon/server.ts
4038
4677
  var CONTINUE_ARGS = { claude: ["--continue"], codex: ["resume", "--last"], cursor: ["--continue"] };
4039
4678
  var RESUME_ARGS = { claude: (id) => ["--resume", id], codex: (id) => ["resume", id], cursor: (id) => ["--resume", id] };
@@ -4052,9 +4691,9 @@ function launchArgs(msg) {
4052
4691
  const effort = msg.effort ? EFFORT_ARGS[msg.tool]?.(msg.effort) ?? [] : [];
4053
4692
  return [...msg.continue ? CONTINUE_ARGS[msg.tool] : [], ...model, ...effort, ...more ?? [], ...prompt ? [...more ? ["--"] : [], prompt] : []];
4054
4693
  }
4055
- var NoteNotTyped = class extends Error {
4694
+ var NoteNotTyped = class extends Refusal {
4056
4695
  constructor() {
4057
- super("denied, but the note could not be typed");
4696
+ super("denied, but the note could not be typed", NOTE_NOT_TYPED);
4058
4697
  }
4059
4698
  };
4060
4699
  var DELETED_TTL_MS = 5 * 60 * 1e3;
@@ -4106,8 +4745,8 @@ async function startServer(opts) {
4106
4745
  const agentEnv2 = opts.agentEnv ?? (async () => ({ ...process.env, ...await loginShellEnv() }));
4107
4746
  const chat = opts.chat ?? createChat({ store, tmux, home: paths().dir, userHome: opts.home, env: agentEnv2, bin: opts.claudeBin, now, projects: () => projects, forge: createForge({ env: agentEnv2 }) });
4108
4747
  const router = createRouter({ env: agentEnv2 });
4109
- const omp = opts.omp ?? createOmpHost({ store, env: agentEnv2, dir: join12(paths().dir, "omp"), now });
4110
- const models = opts.models ?? createModels({ env: agentEnv2, omp: async () => ompChoices(await omp.catalog(opts.home ?? homedir6())) });
4748
+ const omp = opts.omp ?? createOmpHost({ store, env: agentEnv2, dir: join13(paths().dir, "omp"), now });
4749
+ const models = opts.models ?? createModels({ env: agentEnv2, omp: async () => ompChoices(await omp.catalog(opts.home ?? homedir7())) });
4111
4750
  const token = await ensureDaemonToken();
4112
4751
  const httpServer = createServer(unauthorized);
4113
4752
  const sockets = /* @__PURE__ */ new Set();
@@ -4147,13 +4786,17 @@ async function startServer(opts) {
4147
4786
  const islandFile = opts.islandFile ?? paths().island;
4148
4787
  let island = readIsland(islandFile);
4149
4788
  const islandMsg = () => ({ type: "island", prefs: island });
4789
+ const mobile = opts.mobile;
4790
+ const unavailable = !mobile && opts.mobileUnavailable !== void 0 ? { enabled: false, relayUrl: DEFAULT_RELAY_URL, account: null, signin: null, signinError: null, link: { state: "unavailable", error: opts.mobileUnavailable.slice(0, 200) }, devices: [], pairings: [] } : void 0;
4150
4791
  const send = (ws, msg) => {
4151
4792
  if (ws.readyState === WebSocket2.OPEN) ws.send(JSON.stringify(msg));
4152
4793
  };
4153
4794
  const localClients = /* @__PURE__ */ new Set();
4154
4795
  const remoteClients = /* @__PURE__ */ new Set();
4796
+ let gateway;
4155
4797
  const broadcast = (msg) => {
4156
4798
  for (const c of localClients) send(c, msg);
4799
+ gateway?.forward(msg);
4157
4800
  };
4158
4801
  const history = opts.history;
4159
4802
  const unsubscribeHistory = history ? [
@@ -4169,10 +4812,12 @@ async function startServer(opts) {
4169
4812
  if (c.type === "removed") history.forget(c.id);
4170
4813
  })
4171
4814
  ] : [];
4815
+ const unsubscribeMobile = mobile?.onChange(() => broadcast({ type: "mobile", state: mobile.state() }));
4172
4816
  const typing = /* @__PURE__ */ new Set();
4817
+ const replyText = async (id, text) => (await replyTo(id, text))?.message ?? null;
4173
4818
  const mailbox = createMailbox({
4174
4819
  store,
4175
- replyTo,
4820
+ replyTo: replyText,
4176
4821
  typing,
4177
4822
  now,
4178
4823
  relayQueued: (id) => chat.queuedRelays().some((q) => q.relay.id === id),
@@ -4180,7 +4825,7 @@ async function startServer(opts) {
4180
4825
  history?.recordSent(id, m);
4181
4826
  }, mark: (id, rid, status) => history?.markSent(id, rid, status) }
4182
4827
  });
4183
- const relay = createRelay({ chat, store, replyTo, broadcast, now, typing, next: (id) => mailbox.kick(id) });
4828
+ const relay = createRelay({ chat, store, replyTo: replyText, broadcast, now, typing, next: (id) => mailbox.kick(id) });
4184
4829
  const previews = /* @__PURE__ */ new Map();
4185
4830
  const titles = /* @__PURE__ */ new Map();
4186
4831
  const unsubscribe = store.onChange((c) => {
@@ -4196,13 +4841,15 @@ async function startServer(opts) {
4196
4841
  if (c.type === "updated" && c.session.modelNext) void applyModelNext(c.session.id).catch((e) => log(`model change of ${c.session.id} failed: ${e.message}`));
4197
4842
  broadcast(c.type === "updated" ? { type: "session.updated", session: c.session } : { type: "session.removed", id: c.id });
4198
4843
  });
4199
- const sendSnapshot = (ws) => {
4200
- send(ws, { type: "snapshot", sessions: store.all(), recents: store.recents(), launcher, ...opts.daemon ? { daemon: { ...opts.daemon, routing: true, archive: true, models: true, modelSwitch: true, omp: true } } : {} });
4201
- send(ws, machinesMsg());
4202
- send(ws, projectsMsg());
4203
- send(ws, islandMsg());
4204
- for (const [id, lines] of previews) if (store.get(id)) send(ws, { type: "session.preview", id, lines });
4205
- send(ws, { type: "chat.history", turns: chat.history() });
4844
+ const sendSnapshot = (reply) => {
4845
+ reply({ type: "snapshot", sessions: store.all(), recents: store.recents(), launcher, ...opts.daemon ? { daemon: { ...opts.daemon, routing: true, archive: true, models: true, modelSwitch: true, omp: true, ompApproval: true, ...mobile || unavailable ? { mobile: true } : {} } } : {} });
4846
+ reply(machinesMsg());
4847
+ reply(projectsMsg());
4848
+ reply(islandMsg());
4849
+ if (mobile) reply({ type: "mobile", state: mobile.state() });
4850
+ else if (unavailable) reply({ type: "mobile", state: unavailable });
4851
+ for (const [id, lines] of previews) if (store.get(id)) reply({ type: "session.preview", id, lines });
4852
+ reply({ type: "chat.history", turns: chat.history() });
4206
4853
  };
4207
4854
  const preview = (id, lines) => {
4208
4855
  if (!store.get(id)) return;
@@ -4214,7 +4861,7 @@ async function startServer(opts) {
4214
4861
  const UNTIL_LINES = 30;
4215
4862
  const CHECK_LINES = 500;
4216
4863
  async function runPlan(s, plan) {
4217
- if (!s.tmuxSession) throw new Error("session has no terminal (started without ledge)");
4864
+ if (!s.tmuxSession) throw new Refusal("session has no terminal (started without ledge)", "read_only");
4218
4865
  for (const step of plan) {
4219
4866
  if ("text" in step) await tmux.sendText(s.tmuxSession, step.text);
4220
4867
  else if ("waitMs" in step) await new Promise((r) => setTimeout(r, step.waitMs));
@@ -4224,7 +4871,7 @@ async function startServer(opts) {
4224
4871
  for (; ; ) {
4225
4872
  const pane = await tmux.capture(s.tmuxSession, UNTIL_LINES);
4226
4873
  if (pane !== null && step.until(pane)) break;
4227
- if (Date.now() >= end) throw step.denied ? new NoteNotTyped() : new Error(NOT_TAKEN);
4874
+ if (Date.now() >= end) throw step.denied ? new NoteNotTyped() : new Refusal(NOT_TAKEN, "not_taken");
4228
4875
  await new Promise((r) => setTimeout(r, UNTIL_POLL_MS));
4229
4876
  }
4230
4877
  }
@@ -4243,8 +4890,8 @@ async function startServer(opts) {
4243
4890
  }
4244
4891
  async function injectAndVerify(id, planFor, requirePrompt, decision) {
4245
4892
  const s = store.get(id);
4246
- if (!s) throw new Error(`unknown session ${id}`);
4247
- if (inFlight.has(id)) throw new Error("a decision is already in progress");
4893
+ if (!s) throw new Refusal(`unknown session ${id}`, "unknown_session");
4894
+ if (inFlight.has(id)) throw new Refusal("a decision is already in progress", "busy");
4248
4895
  inFlight.add(id);
4249
4896
  try {
4250
4897
  const adapter = adapterFor(s.tool);
@@ -4256,12 +4903,12 @@ async function startServer(opts) {
4256
4903
  let before = null;
4257
4904
  if (requirePrompt) {
4258
4905
  before = s.tmuxSession ? await tmux.capture(s.tmuxSession, DETECT_LINES) : null;
4259
- if (before === null || !adapter.promptStillVisible(bottomLines(before))) throw new Error(NO_PROMPT);
4260
- if (!stillTheSame()) throw new Error(STALE);
4906
+ if (before === null || !adapter.promptStillVisible(bottomLines(before))) throw new Refusal(NO_PROMPT, "no_prompt");
4907
+ if (!stillTheSame()) throw new Refusal(STALE, "stale");
4261
4908
  if (decided) {
4262
4909
  const shown = adapter.detectPrompt(before) ?? adapter.detectPrompt(await tmux.capture(s.tmuxSession, CHECK_LINES) ?? "");
4263
4910
  const refused = dialogMismatch(shown, decided, await tmux.width?.(s.tmuxSession) ?? void 0);
4264
- if (refused) throw new Error(refused);
4911
+ if (refused) throw new Refusal(refused, refused === STALE ? "stale" : "no_prompt");
4265
4912
  }
4266
4913
  }
4267
4914
  const plan = typeof planFor === "function" ? planFor(before ?? "") : planFor;
@@ -4291,7 +4938,7 @@ async function startServer(opts) {
4291
4938
  if (!answer?.byHook && stillTheSame()) store.apply(id, { name: "Decided", at: now(), source: "daemon", ...decision ? { decision } : {} });
4292
4939
  throw e;
4293
4940
  }
4294
- e = new Error(NOT_TAKEN);
4941
+ e = new Refusal(NOT_TAKEN, "not_taken");
4295
4942
  }
4296
4943
  if (answer?.byHook) return true;
4297
4944
  store.apply(id, { name: "InjectFailed", at: now(), source: "daemon", text: e.message });
@@ -4306,17 +4953,19 @@ async function startServer(opts) {
4306
4953
  return true;
4307
4954
  }
4308
4955
  store.apply(id, { name: "InjectFailed", at: now(), source: "daemon", text: NOT_TAKEN });
4309
- throw new Error(NOT_TAKEN);
4956
+ throw new Refusal(NOT_TAKEN, "not_taken");
4310
4957
  } finally {
4311
4958
  inFlight.delete(id);
4312
4959
  awaited.delete(id);
4313
4960
  }
4314
4961
  }
4315
- const READ_ONLY = "session started without ledge, read only";
4962
+ const READ_ONLY2 = "session started without ledge, read only";
4316
4963
  const NO_PROMPT = "no prompt visible in the terminal, open the tab";
4317
4964
  const NOT_TAKEN = "the terminal did not take it";
4318
4965
  const FREE_TEXT = "free-text answers are not supported yet, answer in the terminal";
4319
4966
  const NOT_OFFERED = "this answer is not offered for this request, answer in the terminal";
4967
+ const unknownSession = () => new Refusal("unknown session", "unknown_session");
4968
+ const readOnly = () => new Refusal(READ_ONLY2, "read_only");
4320
4969
  async function endSession(s) {
4321
4970
  if (s.source !== "launcher" || s.state === "exited") return;
4322
4971
  if (backendOf(s) === "omp-rpc") {
@@ -4329,22 +4978,25 @@ async function startServer(opts) {
4329
4978
  }
4330
4979
  async function replyTo(id, text, how = {}) {
4331
4980
  const s = store.get(id);
4332
- if (!s) return "unknown session";
4333
- if (s.source === "hook_only") return READ_ONLY;
4981
+ if (!s) return unknownSession();
4982
+ if (s.source === "hook_only") return readOnly();
4334
4983
  if (backendOf(s) === "omp-rpc") return replyOmp(s, text, how.steer === true);
4335
- if (s.pending && s.pending.kind !== "question") return `a ${s.pending.kind} prompt takes accept or deny, not a reply`;
4336
- if (text.trim() === "") return "empty reply";
4984
+ if (s.pending && s.pending.kind !== "question") return new Refusal(`a ${s.pending.kind} prompt takes accept or deny, not a reply`, "not_offered");
4985
+ if (text.trim() === "") return new Refusal("empty reply", "invalid");
4337
4986
  const pending = s.pending;
4338
4987
  const adapter = adapterFor(s.tool);
4339
- if (pending?.kind === "question" && !/^[1-9]$/.test(text.trim()) && !adapter.freeText) return FREE_TEXT;
4988
+ if (pending?.kind === "question" && !/^[1-9]$/.test(text.trim()) && !adapter.freeText) return new Refusal(FREE_TEXT, "free_text");
4340
4989
  await injectAndVerify(id, (pane) => adapter.replyPlan(text, pending, pane || void 0), !!pending);
4341
4990
  return null;
4342
4991
  }
4343
4992
  async function replyOmp(s, text, steer) {
4344
- if (text.trim() === "") return "empty reply";
4345
- if (s.state === "exited") return "the session has ended";
4346
- if (s.pending) return omp.answer(s.id, text);
4347
- if (!omp.isLive(s.id)) return "the session is not running";
4993
+ if (text.trim() === "") return new Refusal("empty reply", "invalid");
4994
+ if (s.state === "exited") return new Refusal("the session has ended", "unavailable");
4995
+ if (s.pending) {
4996
+ const refused = await omp.answer(s.id, text);
4997
+ return refused === null ? null : new Refusal(refused, "not_offered");
4998
+ }
4999
+ if (!omp.isLive(s.id)) return new Refusal("the session is not running", "unavailable");
4348
5000
  await omp.send(s.id, text, s.state === "working" ? steer ? "steer" : "follow_up" : "prompt");
4349
5001
  return null;
4350
5002
  }
@@ -4390,7 +5042,7 @@ async function startServer(opts) {
4390
5042
  store.upsert({ ...rest, state: "starting", source: "launcher" });
4391
5043
  try {
4392
5044
  const cwd = await resolveFolder(s.cwd, opts.home);
4393
- const spawned = await omp.launch({ id: s.id, name: s.name, cwd, resumeFile: file, ...model ? { model } : {}, ...effort ? { effort } : {} });
5045
+ const spawned = await omp.launch({ id: s.id, name: s.name, cwd, resumeFile: file, ...model ? { model } : {}, ...effort ? { effort } : {}, ...s.approval ? { approval: s.approval } : {} });
4394
5046
  const { topic, topicSource, repo, repoPath, branch, worktree, repoId, project: project2 } = s;
4395
5047
  store.upsert({ ...spawned, ...topic ? { topic } : {}, ...topicSource ? { topicSource } : {}, ...repo ? { repo } : {}, ...repoPath ? { repoPath } : {}, ...branch ? { branch } : {}, ...worktree !== void 0 ? { worktree } : {}, ...repoId ? { repoId } : {}, project: project2 });
4396
5048
  log(`resumed ${spawned.name} (omp) in ${spawned.cwd} as ${spawned.id}`);
@@ -4447,13 +5099,14 @@ async function startServer(opts) {
4447
5099
  const chatLaunches = /* @__PURE__ */ new Set();
4448
5100
  async function launch(req) {
4449
5101
  if (req.tool === "omp") return launchOmp(req);
5102
+ if (req.approval) throw new Error(`launch: ${req.tool} takes no approval mode`);
4450
5103
  return launchTmux({ ...req, tool: req.tool });
4451
5104
  }
4452
5105
  async function launchOmp(req) {
4453
5106
  if (req.continue) throw new Error("launch: OMP resumes an exact conversation, not the latest of a folder");
4454
5107
  const cwd = await resolveFolder(req.cwd, opts.home);
4455
5108
  const dirs = req.dirs?.length ? await Promise.all(req.dirs.map((d) => resolveFolder(d, opts.home))) : void 0;
4456
- const session = await omp.launch({ cwd, ...req.name ? { name: req.name } : {}, ...dirs ? { dirs } : {}, ...req.model ? { model: req.model } : {}, ...req.effort ? { effort: req.effort } : {} });
5109
+ const session = await omp.launch({ cwd, ...req.name ? { name: req.name } : {}, ...dirs ? { dirs } : {}, ...req.model ? { model: req.model } : {}, ...req.effort ? { effort: req.effort } : {}, ...req.approval ? { approval: req.approval } : {} });
4457
5110
  store.touchRecent(session.cwd, session.project, session.startedAt);
4458
5111
  log(`launched ${session.name} (omp) in ${session.cwd} as ${session.id}`);
4459
5112
  const prompt = req.prompt?.trim();
@@ -4477,38 +5130,38 @@ async function startServer(opts) {
4477
5130
  let scanning = null;
4478
5131
  function sharedScan() {
4479
5132
  if (scanning) return scanning;
4480
- const home = opts.home ?? homedir6();
5133
+ const home = opts.home ?? homedir7();
4481
5134
  const extra = extraRoots(home, [...store.recents().map((r) => r.cwd), ...store.all().filter((s) => s.state !== "exited").map((s) => s.cwd)]);
4482
5135
  const walk = (opts.scan ?? scanRepos)({ home, extra }).finally(() => {
4483
5136
  if (scanning === walk) scanning = null;
4484
5137
  });
4485
5138
  return scanning = walk;
4486
5139
  }
4487
- async function handle(ws, msg) {
4488
- const refuse = (message, id) => send(ws, { type: "error", ...id !== void 0 ? { id } : {}, message, request: msg.type });
5140
+ async function handle(msg, reply) {
5141
+ const refuse = (r, id) => reply({ type: "error", ...id !== void 0 ? { id } : {}, message: typeof r === "string" ? r : r.message, request: msg.type, ...typeof r === "string" ? {} : { code: r.code } });
4489
5142
  switch (msg.type) {
4490
5143
  case "document.read": {
4491
5144
  const session = msg.sessionId ? store.get(msg.sessionId) : void 0;
4492
- if (msg.sessionId && !session) return send(ws, { type: "document.error", requestId: msg.requestId, path: msg.path, code: "session_missing" });
4493
- return send(ws, await readDocument(msg, { cwd: session?.cwd, workdir: session?.workdir, home: opts.home ?? process.env.HOME ?? "" }));
5145
+ if (msg.sessionId && !session) return reply({ type: "document.error", requestId: msg.requestId, path: msg.path, code: "session_missing" });
5146
+ return reply(await readDocument(msg, { cwd: session?.cwd, workdir: session?.workdir, home: opts.home ?? process.env.HOME ?? "" }));
4494
5147
  }
4495
5148
  case "repos.inspect":
4496
- return send(ws, { type: "repos.info", requestId: msg.requestId, repos: await inspectRepos(msg.paths, { home: opts.home }) });
5149
+ return reply({ type: "repos.info", requestId: msg.requestId, repos: await inspectRepos(msg.paths, { home: opts.home }) });
4497
5150
  // Lot 11: home, then the parents of the recent and live folders outside it. Answered even when it fails: an `error` for
4498
5151
  // repos.scan reads, to the fleet, as a daemon too old to know it. The remotes it carries are never logged.
4499
5152
  case "repos.scan": {
4500
5153
  try {
4501
- return send(ws, { type: "repos.found", requestId: msg.requestId, ...await sharedScan() });
5154
+ return reply({ type: "repos.found", requestId: msg.requestId, ...await sharedScan() });
4502
5155
  } catch (e) {
4503
5156
  log(`repos.scan failed: ${e.message}`);
4504
- return send(ws, { type: "repos.found", requestId: msg.requestId, repos: [], truncated: false, error: "scan failed" });
5157
+ return reply({ type: "repos.found", requestId: msg.requestId, repos: [], truncated: false, error: "scan failed" });
4505
5158
  }
4506
5159
  }
4507
5160
  // #58: answered even when the agent cannot be asked, so the form falls back to the agent's default and « Other… ».
4508
5161
  case "models.list":
4509
- return send(ws, { type: "models.listing", requestId: msg.requestId, tool: msg.tool, ...await models.list(msg.tool) });
5162
+ return reply({ type: "models.listing", requestId: msg.requestId, tool: msg.tool, ...await models.list(msg.tool) });
4510
5163
  case "snapshot.request":
4511
- return sendSnapshot(ws);
5164
+ return sendSnapshot(reply);
4512
5165
  case "machines.set": {
4513
5166
  writeMachines(msg.machines, machinesFile);
4514
5167
  machines = msg.machines;
@@ -4551,7 +5204,7 @@ async function startServer(opts) {
4551
5204
  return;
4552
5205
  }
4553
5206
  case "session.launch": {
4554
- if (msg.qid === void 0) return send(ws, { type: "session.launched", id: (await launch(msg)).id });
5207
+ if (msg.qid === void 0) return reply({ type: "session.launched", id: (await launch(msg)).id });
4555
5208
  const qid = msg.qid;
4556
5209
  const rows = msg.rows ?? [0];
4557
5210
  const todo = rows.filter((r) => {
@@ -4566,7 +5219,7 @@ async function startServer(opts) {
4566
5219
  const p = chat.proposal(qid, r);
4567
5220
  try {
4568
5221
  const session = await launch({ tool: p.tool, cwd: p.cwd, prompt: p.prompt, model: msg.model, effort: msg.effort });
4569
- send(ws, { type: "session.launched", id: session.id });
5222
+ reply({ type: "session.launched", id: session.id });
4570
5223
  chat.markLaunched(qid, session.id, broadcast, r);
4571
5224
  } catch (e) {
4572
5225
  chat.markFailed(qid, e.message, broadcast, r);
@@ -4582,11 +5235,11 @@ async function startServer(opts) {
4582
5235
  return;
4583
5236
  }
4584
5237
  case "folder.list":
4585
- return send(ws, await listFolder(msg.path, opts.home, msg.requestId));
5238
+ return reply(await listFolder(msg.path, opts.home, msg.requestId));
4586
5239
  case "folder.create": {
4587
5240
  const listing = await createFolder(msg.parent, msg.name, opts.home, msg.requestId);
4588
5241
  if (!listing.error) log(`created folder ${listing.path}`);
4589
- return send(ws, listing);
5242
+ return reply(listing);
4590
5243
  }
4591
5244
  case "session.event": {
4592
5245
  const ev = msg.event;
@@ -4621,9 +5274,9 @@ async function startServer(opts) {
4621
5274
  return;
4622
5275
  }
4623
5276
  case "session.thread":
4624
- return send(ws, { type: "session.thread", id: msg.id, items: history?.items(msg.id) ?? [] });
5277
+ return reply({ type: "session.thread", id: msg.id, items: history?.items(msg.id) ?? [] });
4625
5278
  case "session.rename": {
4626
- if (!store.get(msg.id)) return refuse("unknown session", msg.id);
5279
+ if (!store.get(msg.id)) return refuse(unknownSession(), msg.id);
4627
5280
  store.apply(msg.id, { name: "Renamed", at: now(), source: "daemon", text: msg.topic });
4628
5281
  return;
4629
5282
  }
@@ -4633,15 +5286,15 @@ async function startServer(opts) {
4633
5286
  return;
4634
5287
  case "session.decide": {
4635
5288
  const s = store.get(msg.id);
4636
- if (!s) return refuse("unknown session", msg.id);
4637
- if (s.source === "hook_only") return refuse(READ_ONLY, msg.id);
4638
- if (msg.expect && (s.pending?.kind !== msg.expect.kind || s.pending.text !== msg.expect.text || msg.expect.key !== void 0 && pendingKey(s.pending) !== msg.expect.key)) return refuse(STALE, msg.id);
4639
- if (!s.pending) return refuse("nothing to decide", msg.id);
4640
- if (backendOf(s) === "omp-rpc") return refuse("answer this request with one of its options", msg.id);
5289
+ if (!s) return refuse(unknownSession(), msg.id);
5290
+ if (s.source === "hook_only") return refuse(readOnly(), msg.id);
5291
+ if (msg.expect && (s.pending?.kind !== msg.expect.kind || s.pending.text !== msg.expect.text || msg.expect.key !== void 0 && pendingKey(s.pending) !== msg.expect.key)) return refuse(new Refusal(STALE, "stale"), msg.id);
5292
+ if (!s.pending) return refuse(new Refusal("nothing to decide", "nothing_to_decide"), msg.id);
5293
+ if (backendOf(s) === "omp-rpc") return refuse(new Refusal("answer this request with one of its options", "not_offered"), msg.id);
4641
5294
  const pending = s.pending;
4642
5295
  const adapter = adapterFor(s.tool);
4643
- if (msg.decision === "always" && !offers(s.tool, pending.kind, "always")) return refuse(NOT_OFFERED, msg.id);
4644
- if (msg.note !== void 0 && (msg.decision !== "deny" || !offers(s.tool, pending.kind, "note"))) return refuse(NOT_OFFERED, msg.id);
5296
+ if (msg.decision === "always" && !offers(s.tool, pending.kind, "always")) return refuse(new Refusal(NOT_OFFERED, "not_offered"), msg.id);
5297
+ if (msg.note !== void 0 && (msg.decision !== "deny" || !offers(s.tool, pending.kind, "note"))) return refuse(new Refusal(NOT_OFFERED, "not_offered"), msg.id);
4645
5298
  const { decision, note } = msg;
4646
5299
  await injectAndVerify(msg.id, (pane) => adapter.keysFor(decision, pending, pane, note), true, decision);
4647
5300
  return;
@@ -4661,10 +5314,10 @@ async function startServer(opts) {
4661
5314
  }
4662
5315
  case "session.interrupt": {
4663
5316
  const s = store.get(msg.id);
4664
- if (s?.source === "hook_only") return refuse(READ_ONLY, msg.id);
5317
+ if (s?.source === "hook_only") return refuse(readOnly(), msg.id);
4665
5318
  if (s && backendOf(s) === "omp-rpc") {
4666
5319
  if (!msg.restoreQueue) return void await omp.interrupt(s.id).catch((e) => refuse(e.message, msg.id));
4667
- return void await omp.interruptAndRestore(s.id).then((texts) => send(ws, { type: "session.queue.restored", id: s.id, texts }), (e) => refuse(e.message, msg.id));
5320
+ return void await omp.interruptAndRestore(s.id).then((texts) => reply({ type: "session.queue.restored", id: s.id, texts }), (e) => refuse(e.message, msg.id));
4668
5321
  }
4669
5322
  if (!s?.tmuxSession) return;
4670
5323
  await tmux.sendKeys(s.tmuxSession, adapterFor(s.tool).interruptKeys());
@@ -4673,7 +5326,7 @@ async function startServer(opts) {
4673
5326
  case "session.kill": {
4674
5327
  const s = store.get(msg.id);
4675
5328
  if (!s) return;
4676
- if (s.source === "hook_only") return refuse(READ_ONLY, msg.id);
5329
+ if (s.source === "hook_only") return refuse(readOnly(), msg.id);
4677
5330
  if (backendOf(s) === "omp-rpc") return endSession(s);
4678
5331
  if (s.tmuxSession) await tmux.killSession(s.tmuxSession);
4679
5332
  store.apply(msg.id, { name: "PaneGone", at: now(), source: "daemon" });
@@ -4681,27 +5334,27 @@ async function startServer(opts) {
4681
5334
  }
4682
5335
  case "session.dismiss": {
4683
5336
  const s = store.get(msg.id);
4684
- if (!s) return refuse("unknown session", msg.id);
4685
- if (s.state !== "exited" && s.source !== "hook_only") return refuse("the session is still running, kill it first", msg.id);
5337
+ if (!s) return refuse(unknownSession(), msg.id);
5338
+ if (s.state !== "exited" && s.source !== "hook_only") return refuse(new Refusal("the session is still running, kill it first", "busy"), msg.id);
4686
5339
  store.remove(msg.id);
4687
5340
  return;
4688
5341
  }
4689
5342
  case "session.archive": {
4690
5343
  const s = store.get(msg.id);
4691
- if (!s) return refuse("unknown session", msg.id);
5344
+ if (!s) return refuse(unknownSession(), msg.id);
4692
5345
  await endSession(s);
4693
5346
  store.archive(msg.id, now());
4694
5347
  return;
4695
5348
  }
4696
5349
  case "session.restore": {
4697
- if (!store.get(msg.id)) return refuse("unknown session", msg.id);
5350
+ if (!store.get(msg.id)) return refuse(unknownSession(), msg.id);
4698
5351
  store.unarchive(msg.id, now());
4699
5352
  return;
4700
5353
  }
4701
5354
  case "session.resume": {
4702
5355
  const s = store.get(msg.id);
4703
- if (!s) return refuse("unknown session", msg.id);
4704
- if (s.state !== "exited") return refuse("the session is still running", msg.id);
5356
+ if (!s) return refuse(unknownSession(), msg.id);
5357
+ if (s.state !== "exited") return refuse(new Refusal("the session is still running", "busy"), msg.id);
4705
5358
  const key = resumeKey(s);
4706
5359
  if (!key) return refuse("no conversation to resume", msg.id);
4707
5360
  const refused = await respawn(s, key, s.model, s.effort);
@@ -4711,7 +5364,7 @@ async function startServer(opts) {
4711
5364
  case "session.model": {
4712
5365
  const s = store.get(msg.id);
4713
5366
  if (!s) return refuse("unknown session", msg.id);
4714
- if (s.source === "hook_only") return refuse(READ_ONLY, msg.id);
5367
+ if (s.source === "hook_only") return refuse(READ_ONLY2, msg.id);
4715
5368
  if (s.state === "exited") return refuse("the session has ended", msg.id);
4716
5369
  if (!resumeKey(s)) return refuse("no conversation to resume", msg.id);
4717
5370
  if (backendOf(s) === "omp-rpc" && !omp.isLive(s.id)) return refuse("the session is not running", msg.id);
@@ -4727,7 +5380,7 @@ async function startServer(opts) {
4727
5380
  }
4728
5381
  case "session.delete": {
4729
5382
  const s = store.get(msg.id);
4730
- if (!s) return refuse("unknown session", msg.id);
5383
+ if (!s) return refuse(unknownSession(), msg.id);
4731
5384
  if (s.source === "launcher" || s.state === "exited") markDeleted(msg.id);
4732
5385
  await endSession(s);
4733
5386
  store.remove(msg.id);
@@ -4735,7 +5388,7 @@ async function startServer(opts) {
4735
5388
  }
4736
5389
  // An agent's message to another session: acked at once, typed when the target is free.
4737
5390
  case "session.message":
4738
- return send(ws, mailbox.submit(msg));
5391
+ return reply(mailbox.submit(msg));
4739
5392
  // A chat card's instruction: a refusal reaches the requester as an `error`, the card's state everyone through chat.history.
4740
5393
  case "session.relay":
4741
5394
  return relay.request(msg.qid);
@@ -4744,15 +5397,15 @@ async function startServer(opts) {
4744
5397
  // Broadcast: every overlay shows the same progress, and the one that asked may be the one that reconnects.
4745
5398
  // A busy refusal goes to the asker only: it must not fail the flow the other overlays follow.
4746
5399
  case "update.daemon":
4747
- if (!opts.updater) return send(ws, { type: "update.failed", version: msg.version, reason: "no-systemd" });
4748
- return opts.updater.update(msg.version, broadcast, (m) => send(ws, m));
5400
+ if (!opts.updater) return reply({ type: "update.failed", version: msg.version, reason: "no-systemd" });
5401
+ return opts.updater.update(msg.version, broadcast, reply);
4749
5402
  case "chat.ask":
4750
5403
  return chat.ask(msg.qid, msg.text, broadcast, msg.tool);
4751
5404
  case "chat.route":
4752
5405
  try {
4753
- send(ws, { type: "chat.routed", qid: msg.qid, result: await router.route(msg) });
5406
+ reply({ type: "chat.routed", qid: msg.qid, result: await router.route(msg) });
4754
5407
  } catch (e) {
4755
- send(ws, { type: "chat.error", qid: msg.qid, message: e.message });
5408
+ reply({ type: "chat.error", qid: msg.qid, message: e.message });
4756
5409
  }
4757
5410
  return;
4758
5411
  case "chat.cancel":
@@ -4762,8 +5415,38 @@ async function startServer(opts) {
4762
5415
  return chat.reset(broadcast);
4763
5416
  case "session.focus":
4764
5417
  return;
5418
+ // handled by the overlay plan via wt.exe; the daemon only acknowledges
5419
+ // A refusal of the service (an unknown pairing, a sign-in while disabled) reaches the sender as an `error`.
5420
+ case "mobile.set":
5421
+ case "mobile.signin":
5422
+ case "mobile.signin.cancel":
5423
+ case "mobile.signout":
5424
+ case "mobile.retry":
5425
+ case "mobile.pairing.approve":
5426
+ case "mobile.pairing.deny":
5427
+ case "mobile.device.revoke":
5428
+ if (!mobile) return refuse(new Refusal("mobile access is not available", "unavailable"));
5429
+ return mobile.handle(msg);
4765
5430
  }
4766
5431
  }
5432
+ function dispatch(msg, reply) {
5433
+ return handle(msg, reply).catch((e) => {
5434
+ if (msg.type === "document.read") return reply({ type: "document.error", requestId: msg.requestId, path: msg.path, code: "read_failed" });
5435
+ log(`${msg.type} failed: ${e.message}`);
5436
+ reply({ type: "error", ...msg.type.startsWith("session.") && "id" in msg ? { id: msg.id } : {}, message: e.message, request: msg.type, ...e instanceof Refusal ? { code: e.code } : {} });
5437
+ });
5438
+ }
5439
+ if (mobile) gateway = createPhoneGateway({
5440
+ mobile,
5441
+ sessions: () => store.all(),
5442
+ session: (id) => store.get(id),
5443
+ thread: (id) => history?.items(id),
5444
+ preview: (id) => previews.get(id),
5445
+ dispatch,
5446
+ version: opts.daemon?.version ?? "unknown",
5447
+ log,
5448
+ now
5449
+ });
4767
5450
  wss.on("connection", (ws, req) => {
4768
5451
  ws.on("error", (e) => log(`socket error: ${e.message}`));
4769
5452
  const path = requestPath(req.url);
@@ -4791,7 +5474,8 @@ async function startServer(opts) {
4791
5474
  }
4792
5475
  localClients.add(ws);
4793
5476
  ws.once("close", () => localClients.delete(ws));
4794
- sendSnapshot(ws);
5477
+ const reply = (m) => send(ws, m);
5478
+ sendSnapshot(reply);
4795
5479
  ws.on("message", (data) => {
4796
5480
  let msg;
4797
5481
  let raw;
@@ -4801,13 +5485,10 @@ async function startServer(opts) {
4801
5485
  } catch (e) {
4802
5486
  log(`invalid message: ${e.message}`);
4803
5487
  const { type, id } = raw ?? {};
4804
- return send(ws, { type: "error", message: "invalid message", ...typeof id === "string" && id.length <= 200 ? { id } : {}, ...typeof type === "string" && type.length <= 64 ? { request: type } : {} });
5488
+ const named = typeof type === "string" && type.length <= 64 ? type : void 0;
5489
+ return reply({ type: "error", message: "invalid message", ...named?.startsWith("session.") && typeof id === "string" && id.length <= 200 ? { id } : {}, ...named ? { request: named } : {}, code: "invalid" });
4805
5490
  }
4806
- handle(ws, msg).catch((e) => {
4807
- if (msg.type === "document.read") return send(ws, { type: "document.error", requestId: msg.requestId, path: msg.path, code: "read_failed" });
4808
- log(`${msg.type} failed: ${e.message}`);
4809
- send(ws, { type: "error", id: "id" in msg ? msg.id : void 0, message: e.message, request: msg.type, ...e instanceof NoteNotTyped ? { code: NOTE_NOT_TYPED } : {} });
4810
- });
5491
+ void dispatch(msg, reply);
4811
5492
  });
4812
5493
  });
4813
5494
  return {
@@ -4818,6 +5499,8 @@ async function startServer(opts) {
4818
5499
  close: () => new Promise((res) => {
4819
5500
  unsubscribe();
4820
5501
  for (const u of unsubscribeHistory) u();
5502
+ unsubscribeMobile?.();
5503
+ gateway?.stop();
4821
5504
  relay.close();
4822
5505
  mailbox.close();
4823
5506
  void omp.closeAll();
@@ -4831,11 +5514,11 @@ async function startServer(opts) {
4831
5514
  // src/update/self.ts
4832
5515
  import { existsSync } from "fs";
4833
5516
  import { readFile as readFile6, realpath as realpath2 } from "fs/promises";
4834
- import { homedir as homedir7 } from "os";
4835
- import { dirname as dirname6, join as join13 } from "path";
5517
+ import { homedir as homedir8 } from "os";
5518
+ import { dirname as dirname6, join as join14 } from "path";
4836
5519
  function gitRoot(script, exists = existsSync) {
4837
5520
  for (let dir = dirname6(script); ; dir = dirname6(dir)) {
4838
- if (exists(join13(dir, ".git"))) return dir;
5521
+ if (exists(join14(dir, ".git"))) return dir;
4839
5522
  if (dirname6(dir) === dir) return null;
4840
5523
  }
4841
5524
  }
@@ -4850,15 +5533,15 @@ async function daemonInfo() {
4850
5533
  const cgroup = await readFile6("/proc/self/cgroup", "utf8").catch(() => "");
4851
5534
  const install = installKind(script);
4852
5535
  const root = install === "git" ? gitRoot(script) : null;
4853
- return { version: LEDGE_VERSION, install, systemd: underSystemd(process.env, cgroup), home: homedir7(), ...root ? { manual: manualCommand(root) } : {} };
5536
+ return { version: LEDGE_VERSION, install, systemd: underSystemd(process.env, cgroup), home: homedir8(), ...root ? { manual: manualCommand(root) } : {} };
4854
5537
  }
4855
5538
 
4856
5539
  // src/update/run-update.ts
4857
- import { dirname as dirname7, join as join14 } from "path";
5540
+ import { dirname as dirname7, join as join15 } from "path";
4858
5541
  var PACKAGE = "@paradow/ledge";
4859
5542
  var NPM_TIMEOUT_MS = 3 * 6e4;
4860
5543
  var RESTART_COMMAND = "systemctl --user restart ledge-daemon";
4861
- var npmCommand = (execPath, version) => ({ file: join14(dirname7(execPath), "npm"), args: ["i", "-g", `${PACKAGE}@${version}`] });
5544
+ var npmCommand = (execPath, version) => ({ file: join15(dirname7(execPath), "npm"), args: ["i", "-g", `${PACKAGE}@${version}`] });
4862
5545
  var lastLines = (output, n = 3) => output.split("\n").map((l) => l.trimEnd()).filter(Boolean).slice(-n).join("\n");
4863
5546
  var npmEnv = (execPath, env) => ({ ...env, PATH: `${dirname7(execPath)}:${env.PATH ?? ""}` });
4864
5547
  function createDaemonUpdater(deps) {
@@ -4920,6 +5603,1250 @@ ${output}` } : { code: r.code, output };
4920
5603
  };
4921
5604
  }
4922
5605
 
5606
+ // src/mobile/files.ts
5607
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, renameSync as renameSync4, chmodSync, unlinkSync } from "fs";
5608
+ import { z } from "zod";
5609
+
5610
+ // ../seal/src/errors.ts
5611
+ var SealError = class extends Error {
5612
+ code;
5613
+ constructor(code, message) {
5614
+ super(message ?? code);
5615
+ this.name = "SealError";
5616
+ this.code = code;
5617
+ }
5618
+ };
5619
+
5620
+ // ../seal/src/encoding.ts
5621
+ var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
5622
+ function b64u(bytes2) {
5623
+ let out = "";
5624
+ for (let i = 0; i < bytes2.length; i += 3) {
5625
+ const n = bytes2[i] << 16 | (bytes2[i + 1] ?? 0) << 8 | (bytes2[i + 2] ?? 0);
5626
+ out += ALPHABET[n >> 18 & 63] + ALPHABET[n >> 12 & 63];
5627
+ if (i + 1 < bytes2.length) out += ALPHABET[n >> 6 & 63];
5628
+ if (i + 2 < bytes2.length) out += ALPHABET[n & 63];
5629
+ }
5630
+ return out;
5631
+ }
5632
+ function fromB64u(text) {
5633
+ if (text.length % 4 === 1) throw new SealError("bad_encoding", "invalid base64url length");
5634
+ const out = new Uint8Array(Math.floor(text.length * 3 / 4));
5635
+ let acc = 0;
5636
+ let bits = 0;
5637
+ let o = 0;
5638
+ for (const ch of text) {
5639
+ const v = ALPHABET.indexOf(ch);
5640
+ if (v < 0) throw new SealError("bad_encoding", "invalid base64url character");
5641
+ acc = acc << 6 | v;
5642
+ bits += 6;
5643
+ if (bits >= 8) {
5644
+ bits -= 8;
5645
+ out[o++] = acc >> bits & 255;
5646
+ }
5647
+ }
5648
+ if (acc & (1 << bits) - 1) throw new SealError("bad_encoding", "non-zero trailing bits");
5649
+ return out;
5650
+ }
5651
+
5652
+ // ../seal/src/bytes.ts
5653
+ function concat(...parts) {
5654
+ const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
5655
+ let o = 0;
5656
+ for (const p of parts) {
5657
+ out.set(p, o);
5658
+ o += p.length;
5659
+ }
5660
+ return out;
5661
+ }
5662
+
5663
+ // ../seal/src/identity.ts
5664
+ var subtle = globalThis.crypto.subtle;
5665
+ var ALGO = { name: "ECDH", namedCurve: "P-256" };
5666
+ async function generateIdentity(opts = {}) {
5667
+ const extractable = opts.extractable ?? false;
5668
+ const pair = await subtle.generateKey(ALGO, extractable, ["deriveBits"]);
5669
+ const raw = new Uint8Array(await subtle.exportKey("raw", pair.publicKey));
5670
+ const privateKey = pair.privateKey;
5671
+ return { publicKey: b64u(raw), privateKey };
5672
+ }
5673
+ async function exportIdentity(id) {
5674
+ return b64u(new Uint8Array(await subtle.exportKey("pkcs8", id.privateKey)));
5675
+ }
5676
+ async function importIdentity(pkcs8) {
5677
+ const privateKey = await subtle.importKey("pkcs8", fromB64u(pkcs8), ALGO, true, ["deriveBits"]);
5678
+ const jwk = await subtle.exportKey("jwk", privateKey);
5679
+ if (!jwk.x || !jwk.y) throw new SealError("bad_key", "private key has no public coordinates");
5680
+ const point = concat(new Uint8Array([4]), fromB64u(jwk.x), fromB64u(jwk.y));
5681
+ const publicKey = b64u(point);
5682
+ await importPublicKey(publicKey);
5683
+ return { publicKey, privateKey };
5684
+ }
5685
+ async function importPublicKey(publicKey) {
5686
+ const raw = fromB64u(publicKey);
5687
+ if (raw.length !== 65 || raw[0] !== 4) throw new SealError("bad_key", "not an uncompressed P-256 point");
5688
+ try {
5689
+ return await subtle.importKey("raw", raw, ALGO, true, []);
5690
+ } catch {
5691
+ throw new SealError("bad_key", "point rejected by WebCrypto");
5692
+ }
5693
+ }
5694
+
5695
+ // ../seal/src/channel.ts
5696
+ var subtle2 = globalThis.crypto.subtle;
5697
+ var enc = new TextEncoder();
5698
+ var dec = new TextDecoder("utf-8", { fatal: true });
5699
+ var ECDH = { name: "ECDH", namedCurve: "P-256" };
5700
+ var VERSION = "ledge-seal/1";
5701
+ var SEQ_BYTES = 8;
5702
+ var TAG_BYTES = 16;
5703
+ function seqBytes(seq) {
5704
+ const out = new Uint8Array(SEQ_BYTES);
5705
+ new DataView(out.buffer).setBigUint64(0, BigInt(seq));
5706
+ return out;
5707
+ }
5708
+ async function ecdh(priv, pub) {
5709
+ return new Uint8Array(await subtle2.deriveBits({ name: "ECDH", public: pub }, priv, 256));
5710
+ }
5711
+ async function ephemeralKeys(pkcs8) {
5712
+ if (pkcs8 === void 0) {
5713
+ const eph = await subtle2.generateKey(ECDH, false, ["deriveBits"]);
5714
+ return { privateKey: eph.privateKey, raw: new Uint8Array(await subtle2.exportKey("raw", eph.publicKey)) };
5715
+ }
5716
+ const der = fromB64u(pkcs8);
5717
+ const probe = await subtle2.importKey("pkcs8", der, ECDH, true, ["deriveBits"]);
5718
+ const jwk = await subtle2.exportKey("jwk", probe);
5719
+ if (!jwk.x || !jwk.y) throw new SealError("bad_key", "ephemeral key has no public coordinates");
5720
+ const raw = concat(new Uint8Array([4]), fromB64u(jwk.x), fromB64u(jwk.y));
5721
+ return { privateKey: await subtle2.importKey("pkcs8", der, ECDH, false, ["deriveBits"]), raw };
5722
+ }
5723
+ async function startHandshake(self, role, peerPublicKey, opts = {}) {
5724
+ const peerStatic = await importPublicKey(peerPublicKey);
5725
+ const ss = await ecdh(self.privateKey, peerStatic);
5726
+ const eph = await ephemeralKeys(opts.ephemeralPkcs8);
5727
+ const ephRaw = eph.raw;
5728
+ let ephPrivate = eph.privateKey;
5729
+ return {
5730
+ hello: b64u(ephRaw),
5731
+ async finish(peerHello) {
5732
+ const priv = ephPrivate;
5733
+ ephPrivate = null;
5734
+ if (!priv) throw new SealError("bad_key", "handshake already finished");
5735
+ const peerEph = await importPublicKey(peerHello);
5736
+ const peerEphRaw = fromB64u(peerHello);
5737
+ const ee = await ecdh(priv, peerEph);
5738
+ const selfStaticRaw = fromB64u(self.publicKey);
5739
+ const peerStaticRaw = fromB64u(peerPublicKey);
5740
+ const [pcStatic, deviceStatic, pcEph, deviceEph] = role === "pc" ? [selfStaticRaw, peerStaticRaw, ephRaw, peerEphRaw] : [peerStaticRaw, selfStaticRaw, peerEphRaw, ephRaw];
5741
+ const th = new Uint8Array(
5742
+ await subtle2.digest("SHA-256", concat(enc.encode(VERSION), pcStatic, deviceStatic, pcEph, deviceEph))
5743
+ );
5744
+ const ikm = await subtle2.importKey("raw", concat(ss, ee), "HKDF", false, ["deriveBits"]);
5745
+ const okm = new Uint8Array(
5746
+ await subtle2.deriveBits(
5747
+ { name: "HKDF", hash: "SHA-256", salt: th, info: enc.encode(`${VERSION} keys`) },
5748
+ ikm,
5749
+ 512
5750
+ )
5751
+ );
5752
+ const aes = (raw, usage) => subtle2.importKey("raw", raw, { name: "AES-GCM" }, false, [usage]);
5753
+ const pcToDevice = okm.slice(0, 32);
5754
+ const deviceToPc = okm.slice(32, 64);
5755
+ const sendKey = await aes(role === "pc" ? pcToDevice : deviceToPc, "encrypt");
5756
+ const recvKey = await aes(role === "pc" ? deviceToPc : pcToDevice, "decrypt");
5757
+ let sendSeq = 0;
5758
+ let recvSeq = 0;
5759
+ const params = (seq) => ({
5760
+ name: "AES-GCM",
5761
+ iv: concat(new Uint8Array(4), seq),
5762
+ additionalData: concat(th, seq)
5763
+ });
5764
+ let sealChain = Promise.resolve();
5765
+ let openChain = Promise.resolve();
5766
+ const serial = (chain, task) => {
5767
+ const run2 = chain.then(task);
5768
+ return [run2, run2.catch(() => void 0)];
5769
+ };
5770
+ const sealOne = async (text) => {
5771
+ const seq = seqBytes(sendSeq++);
5772
+ const ct = new Uint8Array(await subtle2.encrypt(params(seq), sendKey, enc.encode(text)));
5773
+ return b64u(concat(seq, ct));
5774
+ };
5775
+ const openOne = async (sealed) => {
5776
+ const frame = fromB64u(sealed);
5777
+ if (frame.length < SEQ_BYTES + TAG_BYTES) throw new SealError("bad_seal", "frame too short");
5778
+ const seq = frame.slice(0, SEQ_BYTES);
5779
+ const got = new DataView(seq.buffer).getBigUint64(0);
5780
+ if (got > BigInt(recvSeq)) throw new SealError("gap", "a frame is missing before this one");
5781
+ if (got < BigInt(recvSeq)) throw new SealError("replay", "frame already seen");
5782
+ let plain;
5783
+ try {
5784
+ plain = await subtle2.decrypt(params(seq), recvKey, frame.slice(SEQ_BYTES));
5785
+ } catch {
5786
+ throw new SealError("bad_seal", "authentication failed");
5787
+ }
5788
+ recvSeq++;
5789
+ try {
5790
+ return dec.decode(plain);
5791
+ } catch {
5792
+ throw new SealError("bad_seal", "payload is not UTF-8");
5793
+ }
5794
+ };
5795
+ return {
5796
+ seal(text) {
5797
+ const [run2, next2] = serial(sealChain, () => sealOne(text));
5798
+ sealChain = next2;
5799
+ return run2;
5800
+ },
5801
+ open(sealed) {
5802
+ const [run2, next2] = serial(openChain, () => openOne(sealed));
5803
+ openChain = next2;
5804
+ return run2;
5805
+ }
5806
+ };
5807
+ }
5808
+ };
5809
+ }
5810
+
5811
+ // ../seal/src/pairing.ts
5812
+ var subtle3 = globalThis.crypto.subtle;
5813
+ var enc2 = new TextEncoder();
5814
+ var COMMIT_DOMAIN = "ledge-pair/1 commit";
5815
+ var CODE_DOMAIN = "ledge-pair/1 code";
5816
+ function randomNonce() {
5817
+ return b64u(globalThis.crypto.getRandomValues(new Uint8Array(32)));
5818
+ }
5819
+ async function sha256(...parts) {
5820
+ return new Uint8Array(await subtle3.digest("SHA-256", concat(...parts)));
5821
+ }
5822
+ async function commit(devicePublicKey, deviceNonce) {
5823
+ return b64u(await sha256(enc2.encode(COMMIT_DOMAIN), fromB64u(devicePublicKey), fromB64u(deviceNonce)));
5824
+ }
5825
+ async function checkCommitment(commitment, devicePublicKey, deviceNonce) {
5826
+ let given;
5827
+ let expected;
5828
+ try {
5829
+ given = fromB64u(commitment);
5830
+ if (fromB64u(deviceNonce).length !== 32) return false;
5831
+ expected = fromB64u(await commit(devicePublicKey, deviceNonce));
5832
+ } catch {
5833
+ return false;
5834
+ }
5835
+ if (given.length !== expected.length) return false;
5836
+ let diff = 0;
5837
+ for (let i = 0; i < expected.length; i++) diff |= given[i] ^ expected[i];
5838
+ return diff === 0;
5839
+ }
5840
+ async function pairingCode(input) {
5841
+ const hash = await sha256(
5842
+ enc2.encode(CODE_DOMAIN),
5843
+ fromB64u(input.pcPublicKey),
5844
+ fromB64u(input.devicePublicKey),
5845
+ fromB64u(input.deviceNonce),
5846
+ fromB64u(input.pcNonce)
5847
+ );
5848
+ const n = new DataView(hash.buffer, hash.byteOffset, 4).getUint32(0, false) % 1e6;
5849
+ const digits = String(n).padStart(6, "0");
5850
+ return `${digits.slice(0, 3)} ${digits.slice(3)}`;
5851
+ }
5852
+
5853
+ // ../seal/src/fingerprint.ts
5854
+ var subtle4 = globalThis.crypto.subtle;
5855
+ var DOMAIN = new TextEncoder().encode("ledge-key/1");
5856
+ async function fingerprint2(publicKey) {
5857
+ await importPublicKey(publicKey);
5858
+ const digest = await subtle4.digest("SHA-256", concat(DOMAIN, fromB64u(publicKey)));
5859
+ return b64u(new Uint8Array(digest)).slice(0, 22);
5860
+ }
5861
+
5862
+ // src/mobile/files.ts
5863
+ var RelaySessionSchema = z.object({ token: z.string().min(1), accountId: z.string().min(1), login: z.string(), relayUrl: z.string() });
5864
+ var ApprovedDeviceSchema = z.object({
5865
+ id: z.string().min(1),
5866
+ publicKey: z.string().min(1),
5867
+ label: z.string(),
5868
+ approvedAt: z.string(),
5869
+ lastPeer: z.string().optional(),
5870
+ lastSeenAt: z.string().optional()
5871
+ });
5872
+ function writePrivate(file, content) {
5873
+ const tmp = `${file}.${process.pid}.tmp`;
5874
+ writeFileSync4(tmp, content, { mode: 384 });
5875
+ chmodSync(tmp, 384);
5876
+ renameSync4(tmp, file);
5877
+ }
5878
+ function readText(file, name) {
5879
+ try {
5880
+ return readFileSync4(file, "utf8");
5881
+ } catch (e) {
5882
+ if (e.code === "ENOENT") return null;
5883
+ throw new Error(`${name} cannot be read (${e.code ?? "error"})`);
5884
+ }
5885
+ }
5886
+ function readJson(file, name, schema) {
5887
+ const raw = readText(file, name);
5888
+ if (raw === null) return null;
5889
+ try {
5890
+ return schema.parse(JSON.parse(raw));
5891
+ } catch {
5892
+ log(`${name} is invalid, ignoring it`);
5893
+ return null;
5894
+ }
5895
+ }
5896
+ var DEFAULT_CONFIG = () => ({ enabled: false, relayUrl: DEFAULT_RELAY_URL });
5897
+ function readMobileConfig() {
5898
+ return readJson(paths().mobile, "mobile.json", MobileConfigSchema) ?? DEFAULT_CONFIG();
5899
+ }
5900
+ function writeMobileConfig(c) {
5901
+ writePrivate(paths().mobile, JSON.stringify(MobileConfigSchema.parse(c), null, 2) + "\n");
5902
+ }
5903
+ function readRelaySession() {
5904
+ const s = readJson(paths().mobileSession, "mobile-session.json", RelaySessionSchema);
5905
+ return s && s.relayUrl === readMobileConfig().relayUrl ? s : null;
5906
+ }
5907
+ function writeRelaySession(s) {
5908
+ writePrivate(paths().mobileSession, JSON.stringify(RelaySessionSchema.parse(s), null, 2) + "\n");
5909
+ }
5910
+ function clearRelaySession() {
5911
+ try {
5912
+ unlinkSync(paths().mobileSession);
5913
+ } catch {
5914
+ }
5915
+ }
5916
+ async function loadPcIdentity() {
5917
+ const file = paths().mobileKey;
5918
+ const stored = readText(file, "mobile-key")?.trim();
5919
+ if (stored) {
5920
+ try {
5921
+ return await importIdentity(stored);
5922
+ } catch {
5923
+ log("mobile-key is invalid, creating a new key");
5924
+ }
5925
+ }
5926
+ const id = await generateIdentity({ extractable: true });
5927
+ writePrivate(file, await exportIdentity(id));
5928
+ return importIdentity(await exportIdentity(id));
5929
+ }
5930
+ function readDevices() {
5931
+ return readJson(paths().devices, "devices.json", z.array(ApprovedDeviceSchema)) ?? [];
5932
+ }
5933
+ function writeDevices(list) {
5934
+ writePrivate(paths().devices, JSON.stringify(z.array(ApprovedDeviceSchema).parse(list), null, 2) + "\n");
5935
+ }
5936
+
5937
+ // src/mobile/link.ts
5938
+ import WebSocket3 from "ws";
5939
+ var BACKOFF_FIRST_MS = 1e3;
5940
+ var BACKOFF_MAX_MS = 6e4;
5941
+ var PING_EVERY_MS = 25e3;
5942
+ var PONG_WITHIN_MS = 1e4;
5943
+ var WELCOME_WITHIN_MS = 15e3;
5944
+ var ERROR_MAX = 200;
5945
+ var realSchedule = (fn, ms) => {
5946
+ const h = setTimeout(fn, ms);
5947
+ return () => clearTimeout(h);
5948
+ };
5949
+ function wsUrl(relayUrl) {
5950
+ const u = new URL("/ws", relayUrl);
5951
+ u.protocol = u.protocol === "http:" ? "ws:" : "wss:";
5952
+ return u.toString();
5953
+ }
5954
+ function createRelayLink(opts) {
5955
+ const now = opts.now ?? Date.now;
5956
+ const random = opts.random ?? Math.random;
5957
+ const schedule = opts.schedule ?? realSchedule;
5958
+ const Impl = opts.WebSocketImpl ?? WebSocket3;
5959
+ const url = wsUrl(opts.relayUrl);
5960
+ let state = "off";
5961
+ let ws = null;
5962
+ let failures = 0;
5963
+ let cancelRetry = null;
5964
+ let cancelPing = null;
5965
+ let cancelDeadline = null;
5966
+ let finished = false;
5967
+ const setState = (s) => {
5968
+ state = s.state;
5969
+ opts.onState(s);
5970
+ };
5971
+ const stopPing = () => {
5972
+ cancelPing?.();
5973
+ cancelPing = null;
5974
+ };
5975
+ const stopDeadline = () => {
5976
+ cancelDeadline?.();
5977
+ cancelDeadline = null;
5978
+ };
5979
+ const armDeadline = (sock, error) => {
5980
+ stopDeadline();
5981
+ cancelDeadline = schedule(() => {
5982
+ cancelDeadline = null;
5983
+ if (ws === sock) fail(error);
5984
+ }, WELCOME_WITHIN_MS);
5985
+ };
5986
+ const drop = () => {
5987
+ stopPing();
5988
+ stopDeadline();
5989
+ const old = ws;
5990
+ ws = null;
5991
+ if (old) {
5992
+ old.removeAllListeners();
5993
+ old.on("error", () => {
5994
+ });
5995
+ old.terminate();
5996
+ }
5997
+ };
5998
+ const nextDelay = () => {
5999
+ const base = Math.min(BACKOFF_FIRST_MS * 2 ** failures, BACKOFF_MAX_MS);
6000
+ failures++;
6001
+ const jittered = base * (1 + (random() * 2 - 1) * 0.2);
6002
+ return Math.round(Math.min(jittered, BACKOFF_MAX_MS));
6003
+ };
6004
+ const fail = (error) => {
6005
+ drop();
6006
+ if (finished) return;
6007
+ const delay2 = nextDelay();
6008
+ const cut = error.slice(0, ERROR_MAX);
6009
+ log(`mobile link: offline (${cut}), retry in ${delay2} ms`);
6010
+ setState({ state: "offline", retryAt: new Date(now() + delay2).toISOString(), error: cut });
6011
+ cancelRetry = schedule(() => {
6012
+ cancelRetry = null;
6013
+ connect();
6014
+ }, delay2);
6015
+ };
6016
+ const signedOut = (reason) => {
6017
+ drop();
6018
+ finished = true;
6019
+ log(`mobile link: signed out (${reason})`);
6020
+ setState({ state: "signed_out", reason });
6021
+ };
6022
+ const armPing = (sock) => {
6023
+ stopPing();
6024
+ cancelPing = schedule(() => {
6025
+ if (ws !== sock) return;
6026
+ sock.ping();
6027
+ cancelPing = schedule(() => {
6028
+ if (ws === sock) fail("no pong from the relay");
6029
+ }, PONG_WITHIN_MS);
6030
+ }, PING_EVERY_MS);
6031
+ };
6032
+ const connect = () => {
6033
+ if (finished) return;
6034
+ setState({ state: "connecting" });
6035
+ const sock = new Impl(url, { headers: { authorization: `Bearer ${opts.token}` } });
6036
+ ws = sock;
6037
+ armDeadline(sock, "no answer to the upgrade from the relay");
6038
+ let lastError = "";
6039
+ sock.on("error", (e) => {
6040
+ lastError = e.message;
6041
+ });
6042
+ sock.on("unexpected-response", (_req, res) => {
6043
+ if (ws !== sock) return;
6044
+ res.resume();
6045
+ if (res.statusCode === 401) signedOut("session_invalid");
6046
+ else fail(`unexpected server response: ${res.statusCode ?? 0}`);
6047
+ });
6048
+ sock.on("open", () => {
6049
+ if (ws !== sock) return;
6050
+ armDeadline(sock, "no welcome from the relay");
6051
+ armPing(sock);
6052
+ });
6053
+ sock.on("pong", () => {
6054
+ if (ws === sock) armPing(sock);
6055
+ });
6056
+ sock.on("message", (data) => {
6057
+ if (ws !== sock) return;
6058
+ let parsed;
6059
+ try {
6060
+ parsed = JSON.parse(data.toString());
6061
+ } catch {
6062
+ parsed = void 0;
6063
+ }
6064
+ const r = RelayServerMessageSchema.safeParse(parsed);
6065
+ if (!r.success) {
6066
+ log("mobile link: ignored an invalid relay frame");
6067
+ return;
6068
+ }
6069
+ if (r.data.type === "welcome") {
6070
+ failures = 0;
6071
+ stopDeadline();
6072
+ if (state !== "online") {
6073
+ log("mobile link: online");
6074
+ setState({ state: "online" });
6075
+ }
6076
+ }
6077
+ opts.onMessage(r.data);
6078
+ });
6079
+ sock.on("close", (code) => {
6080
+ if (ws !== sock) return;
6081
+ if (code === RELAY_CLOSE_SIGNED_OUT) signedOut("other_pc");
6082
+ else fail(lastError || `connection closed (${code})`);
6083
+ });
6084
+ };
6085
+ connect();
6086
+ return {
6087
+ send(m) {
6088
+ if (state !== "online" || !ws || ws.readyState !== WebSocket3.OPEN) return false;
6089
+ ws.send(JSON.stringify(m));
6090
+ return true;
6091
+ },
6092
+ retryNow() {
6093
+ if (finished || state !== "offline") return;
6094
+ cancelRetry?.();
6095
+ cancelRetry = null;
6096
+ connect();
6097
+ },
6098
+ close() {
6099
+ if (state === "off" && finished) return;
6100
+ finished = true;
6101
+ cancelRetry?.();
6102
+ cancelRetry = null;
6103
+ const old = ws;
6104
+ stopPing();
6105
+ stopDeadline();
6106
+ ws = null;
6107
+ if (old) {
6108
+ old.removeAllListeners();
6109
+ old.on("error", () => {
6110
+ });
6111
+ if (old.readyState === WebSocket3.OPEN) old.close(1e3);
6112
+ else old.terminate();
6113
+ }
6114
+ setState({ state: "off" });
6115
+ }
6116
+ };
6117
+ }
6118
+
6119
+ // src/mobile/signin.ts
6120
+ import { setTimeout as delay } from "timers/promises";
6121
+ var CALL_TIMEOUT_MS = 15e3;
6122
+ var ERROR_MAX2 = 200;
6123
+ var Cancelled = class extends Error {
6124
+ };
6125
+ function startSignIn(opts) {
6126
+ const fetchImpl = opts.fetchImpl ?? fetch;
6127
+ const abort = new AbortController();
6128
+ const sleep2 = opts.sleep ?? ((ms) => delay(ms, void 0, { signal: abort.signal }));
6129
+ let cancelled = false;
6130
+ let deviceCode = "";
6131
+ const onCancel = new Promise((_resolve, reject) => {
6132
+ abort.signal.addEventListener("abort", () => reject(new Cancelled()), { once: true });
6133
+ });
6134
+ onCancel.catch(() => {
6135
+ });
6136
+ const guard = (p) => Promise.race([p, onCancel]);
6137
+ const post = async (path, body) => {
6138
+ if (cancelled) throw new Cancelled();
6139
+ const res = await guard(fetchImpl(new URL(path, opts.relayUrl).toString(), {
6140
+ method: "POST",
6141
+ headers: body === void 0 ? {} : { "content-type": "application/json" },
6142
+ ...body === void 0 ? {} : { body: JSON.stringify(body) },
6143
+ signal: AbortSignal.any([abort.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)])
6144
+ }));
6145
+ if (!res.ok) throw new Error(`relay answered ${res.status}`);
6146
+ return guard(res.json());
6147
+ };
6148
+ const run2 = async () => {
6149
+ const start = DeviceStartResponseSchema.parse(await post("/auth/device/start"));
6150
+ deviceCode = start.deviceCode;
6151
+ opts.onCode({
6152
+ userCode: start.userCode,
6153
+ verificationUri: start.verificationUri,
6154
+ expiresAt: new Date(Date.now() + start.expiresIn * 1e3).toISOString()
6155
+ });
6156
+ let interval = start.interval;
6157
+ for (; ; ) {
6158
+ await guard(sleep2(interval * 1e3));
6159
+ const poll = DevicePollResponseSchema.parse(await post("/auth/device/poll", { deviceCode }));
6160
+ switch (poll.status) {
6161
+ case "pending":
6162
+ break;
6163
+ case "slow_down":
6164
+ interval = poll.interval;
6165
+ break;
6166
+ case "ok":
6167
+ return { status: "ok", session: { token: poll.token, accountId: poll.accountId, login: poll.login, relayUrl: opts.relayUrl } };
6168
+ default:
6169
+ return { status: poll.status };
6170
+ }
6171
+ }
6172
+ };
6173
+ const result = run2().catch((e) => {
6174
+ if (cancelled || e instanceof Cancelled) return { status: "cancelled" };
6175
+ let error = e instanceof Error ? e.name === "ZodError" ? "invalid relay answer" : e.message : "sign-in failed";
6176
+ if (deviceCode) error = error.split(deviceCode).join("[redacted]");
6177
+ return { status: "failed", error: error.slice(0, ERROR_MAX2) };
6178
+ });
6179
+ return {
6180
+ result,
6181
+ cancel() {
6182
+ if (cancelled) return;
6183
+ cancelled = true;
6184
+ abort.abort();
6185
+ }
6186
+ };
6187
+ }
6188
+ async function signOut(session, fetchImpl = fetch) {
6189
+ try {
6190
+ const res = await fetchImpl(new URL("/auth/logout", session.relayUrl).toString(), {
6191
+ method: "POST",
6192
+ headers: { authorization: `Bearer ${session.token}` },
6193
+ signal: AbortSignal.timeout(CALL_TIMEOUT_MS)
6194
+ });
6195
+ await res.body?.cancel();
6196
+ } catch {
6197
+ }
6198
+ }
6199
+
6200
+ // src/mobile/pairing.ts
6201
+ var PAIRING_TTL_MS = 5 * 6e4;
6202
+ var MAX_PAIRINGS = 3;
6203
+ var PAIRING_LINGER_MS = 1e4;
6204
+ var DEFAULT_LABEL = "Appareil";
6205
+ var realSchedule2 = (fn, ms) => {
6206
+ const h = setTimeout(fn, ms);
6207
+ return () => clearTimeout(h);
6208
+ };
6209
+ function cleanLabel(raw) {
6210
+ let s = raw.replace(new RegExp("\\p{Cc}", "gu"), "").trim();
6211
+ if (s.length > PAIR_LABEL_MAX) {
6212
+ s = s.slice(0, PAIR_LABEL_MAX);
6213
+ if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1);
6214
+ s = s.trim();
6215
+ }
6216
+ return s || DEFAULT_LABEL;
6217
+ }
6218
+ var isPairFrame = (f) => f.k.startsWith("pair_");
6219
+ function createPairing(opts) {
6220
+ const now = opts.now ?? Date.now;
6221
+ const ttlMs = opts.ttlMs ?? PAIRING_TTL_MS;
6222
+ const schedule = opts.schedule ?? realSchedule2;
6223
+ const entries = /* @__PURE__ */ new Map();
6224
+ let stopped2 = false;
6225
+ const active = (e) => e.state === "waiting" || e.state === "confirm" || e.state === "approved";
6226
+ const remove = (e) => {
6227
+ e.cancelTimer();
6228
+ if (entries.get(e.peer) === e) entries.delete(e.peer);
6229
+ };
6230
+ const settle = (e, state) => {
6231
+ e.cancelTimer();
6232
+ e.state = state;
6233
+ e.cancelTimer = schedule(() => {
6234
+ if (entries.get(e.peer) !== e) return;
6235
+ entries.delete(e.peer);
6236
+ opts.onChange();
6237
+ }, PAIRING_LINGER_MS);
6238
+ opts.onChange();
6239
+ };
6240
+ const onTtl = (e) => {
6241
+ if (entries.get(e.peer) !== e) return;
6242
+ if (e.state === "waiting" || e.state === "confirm") {
6243
+ log(`mobile pairing: expired for peer ${e.peer}`);
6244
+ settle(e, "expired");
6245
+ } else if (e.state === "approved") {
6246
+ remove(e);
6247
+ opts.onChange();
6248
+ }
6249
+ };
6250
+ const byId = (id) => [...entries.values()].find((e) => e.id === id);
6251
+ const onCommit = (peer, frame) => {
6252
+ const old = entries.get(peer);
6253
+ if (old) remove(old);
6254
+ const others = [...entries.values()].filter(active).length;
6255
+ if (others >= MAX_PAIRINGS) {
6256
+ log(`mobile pairing: refused peer ${peer}, ${MAX_PAIRINGS} pairings already pending`);
6257
+ opts.send(peer, { k: "pair_no" });
6258
+ if (old) opts.onChange();
6259
+ return;
6260
+ }
6261
+ const e = {
6262
+ id: b64u(globalThis.crypto.getRandomValues(new Uint8Array(16))),
6263
+ peer,
6264
+ label: cleanLabel(frame.label),
6265
+ commitment: frame.c,
6266
+ pcNonce: randomNonce(),
6267
+ expiresAt: now() + ttlMs,
6268
+ state: "waiting",
6269
+ code: null,
6270
+ device: null,
6271
+ revealing: false,
6272
+ cancelTimer: () => {
6273
+ }
6274
+ };
6275
+ entries.set(peer, e);
6276
+ e.cancelTimer = schedule(() => onTtl(e), ttlMs);
6277
+ opts.send(peer, { k: "pair_offer", pc: opts.pc.publicKey, n: e.pcNonce });
6278
+ opts.onChange();
6279
+ };
6280
+ const onReveal = async (peer, frame) => {
6281
+ const e = entries.get(peer);
6282
+ if (!e || e.state !== "waiting" || e.revealing) return;
6283
+ e.revealing = true;
6284
+ let ok = false;
6285
+ let code = "";
6286
+ let id = "";
6287
+ try {
6288
+ await importPublicKey(frame.d);
6289
+ if (fromB64u(frame.n).length === 32 && await checkCommitment(e.commitment, frame.d, frame.n)) {
6290
+ code = await pairingCode({ pcPublicKey: opts.pc.publicKey, devicePublicKey: frame.d, deviceNonce: frame.n, pcNonce: e.pcNonce });
6291
+ id = await fingerprint2(frame.d);
6292
+ ok = true;
6293
+ }
6294
+ } catch {
6295
+ ok = false;
6296
+ }
6297
+ if (stopped2 || entries.get(peer) !== e || e.state !== "waiting") return;
6298
+ e.revealing = false;
6299
+ if (!ok) {
6300
+ log(`mobile pairing: reveal from peer ${peer} does not match its commitment`);
6301
+ remove(e);
6302
+ opts.send(peer, { k: "pair_no" });
6303
+ opts.onChange();
6304
+ return;
6305
+ }
6306
+ e.code = code;
6307
+ e.device = { publicKey: frame.d, fingerprint: id };
6308
+ e.state = "confirm";
6309
+ opts.onChange();
6310
+ };
6311
+ return {
6312
+ async receive(peer, frame) {
6313
+ if (!isPairFrame(frame)) return false;
6314
+ if (stopped2) return true;
6315
+ if (frame.k === "pair_commit") onCommit(peer, frame);
6316
+ else if (frame.k === "pair_reveal") await onReveal(peer, frame);
6317
+ return true;
6318
+ },
6319
+ approve(id) {
6320
+ const e = byId(id);
6321
+ if (!e || e.state !== "confirm" || !e.device) return;
6322
+ if (now() > e.expiresAt) {
6323
+ settle(e, "expired");
6324
+ return;
6325
+ }
6326
+ opts.approve({
6327
+ id: e.device.fingerprint,
6328
+ publicKey: e.device.publicKey,
6329
+ label: e.label,
6330
+ approvedAt: new Date(now()).toISOString(),
6331
+ lastPeer: e.peer
6332
+ });
6333
+ log(`mobile pairing: approved device ${e.device.fingerprint}`);
6334
+ opts.send(e.peer, { k: "pair_ok" });
6335
+ e.state = "approved";
6336
+ e.cancelTimer();
6337
+ e.expiresAt = now() + ttlMs;
6338
+ e.cancelTimer = schedule(() => onTtl(e), ttlMs);
6339
+ opts.onChange();
6340
+ },
6341
+ deny(id) {
6342
+ const e = byId(id);
6343
+ if (!e || e.state !== "confirm" && e.state !== "waiting") return;
6344
+ opts.send(e.peer, { k: "pair_no" });
6345
+ settle(e, "refused");
6346
+ },
6347
+ linked(deviceId) {
6348
+ const e = [...entries.values()].find((x) => x.state === "approved" && x.device?.fingerprint === deviceId);
6349
+ if (e) settle(e, "linked");
6350
+ },
6351
+ views() {
6352
+ return [...entries.values()].map((e) => ({
6353
+ id: e.id,
6354
+ label: e.label,
6355
+ code: e.code,
6356
+ expiresAt: new Date(e.expiresAt).toISOString(),
6357
+ state: e.state
6358
+ }));
6359
+ },
6360
+ reset() {
6361
+ const dropped = [...entries.values()].filter((e) => e.state === "waiting" || e.state === "confirm");
6362
+ for (const e of dropped) remove(e);
6363
+ if (dropped.length) opts.onChange();
6364
+ },
6365
+ stop() {
6366
+ stopped2 = true;
6367
+ for (const e of entries.values()) e.cancelTimer();
6368
+ entries.clear();
6369
+ }
6370
+ };
6371
+ }
6372
+
6373
+ // src/mobile/channels.ts
6374
+ var READY = '{"t":"ready"}';
6375
+ var REKEY_INTERVAL_MS = 2e3;
6376
+ var REKEY_TABLE_PRUNE_AT = 256;
6377
+ var codeOf = (err) => err instanceof SealError ? err.code : err instanceof Error ? err.name : "error";
6378
+ function createChannels(opts) {
6379
+ const now = opts.now ?? Date.now;
6380
+ const entries = /* @__PURE__ */ new Map();
6381
+ const lastDevice = /* @__PURE__ */ new Map();
6382
+ const lastRekey = /* @__PURE__ */ new Map();
6383
+ const queues = /* @__PURE__ */ new Map();
6384
+ let epoch = 0;
6385
+ const peerGen = /* @__PURE__ */ new Map();
6386
+ let confirmations = 0;
6387
+ let pcFingerprint = null;
6388
+ const approved = (deviceId, publicKey) => opts.devices().find((d) => d.id === deviceId && (publicKey === void 0 || d.publicKey === publicKey));
6389
+ const hasConfirmed = (deviceId) => [...entries.values()].some((e) => e.deviceId === deviceId && e.confirmed);
6390
+ const close = (e, why) => {
6391
+ if (entries.get(e.peer) !== e) return;
6392
+ entries.delete(e.peer);
6393
+ log(`mobile channel: closed for device ${e.deviceId} on peer ${e.peer} (${why})`);
6394
+ if (e.confirmed && !hasConfirmed(e.deviceId)) opts.onClosed(e.deviceId);
6395
+ };
6396
+ const denied = (peer) => opts.send(peer, { k: "denied" });
6397
+ const rekey = (peer) => {
6398
+ const t = now();
6399
+ const last = lastRekey.get(peer);
6400
+ if (last !== void 0 && t - last < REKEY_INTERVAL_MS) return;
6401
+ if (lastRekey.size >= REKEY_TABLE_PRUNE_AT) {
6402
+ for (const [p, at] of lastRekey) if (t - at >= REKEY_INTERVAL_MS) lastRekey.delete(p);
6403
+ }
6404
+ lastRekey.set(peer, t);
6405
+ opts.send(peer, { k: "rekey" });
6406
+ };
6407
+ const onHello = async (peer, frame) => {
6408
+ const device = approved(frame.f);
6409
+ if (!device || await fingerprint2(device.publicKey).catch(() => null) !== device.id) {
6410
+ log(`mobile channel: denied hello from peer ${peer} for fingerprint ${frame.f}`);
6411
+ denied(peer);
6412
+ return;
6413
+ }
6414
+ const e0 = epoch;
6415
+ const g0 = peerGen.get(peer) ?? 0;
6416
+ let channel;
6417
+ let reply;
6418
+ try {
6419
+ const hs = await startHandshake(opts.pc, "pc", device.publicKey);
6420
+ channel = await hs.finish(frame.e);
6421
+ reply = hs.hello;
6422
+ } catch (err) {
6423
+ log(`mobile channel: handshake failed for device ${device.id} on peer ${peer} (${codeOf(err)})`);
6424
+ const old2 = entries.get(peer);
6425
+ if (old2) close(old2, "new hello");
6426
+ if (epoch === e0 && (peerGen.get(peer) ?? 0) === g0) rekey(peer);
6427
+ return;
6428
+ }
6429
+ pcFingerprint ??= fingerprint2(opts.pc.publicKey);
6430
+ const f = await pcFingerprint;
6431
+ if (epoch !== e0 || (peerGen.get(peer) ?? 0) !== g0) return;
6432
+ if (!approved(device.id, device.publicKey)) {
6433
+ log(`mobile channel: denied hello from peer ${peer}, device ${device.id} revoked`);
6434
+ denied(peer);
6435
+ return;
6436
+ }
6437
+ const old = entries.get(peer);
6438
+ if (old) close(old, "new hello");
6439
+ entries.set(peer, { peer, deviceId: device.id, publicKey: device.publicKey, channel, confirmed: false, confirmedSeq: 0 });
6440
+ lastDevice.set(peer, device.id);
6441
+ log(`mobile channel: hello from device ${device.id} on peer ${peer}`);
6442
+ opts.send(peer, { k: "hello", e: reply, f });
6443
+ };
6444
+ const onMsg = async (peer, frame) => {
6445
+ const e = entries.get(peer);
6446
+ if (!e) {
6447
+ const id = lastDevice.get(peer);
6448
+ if (id !== void 0 && !approved(id)) {
6449
+ log(`mobile channel: denied msg from peer ${peer}, device ${id} revoked`);
6450
+ denied(peer);
6451
+ } else {
6452
+ rekey(peer);
6453
+ }
6454
+ return;
6455
+ }
6456
+ if (!approved(e.deviceId, e.publicKey)) {
6457
+ close(e, "revoked");
6458
+ denied(peer);
6459
+ return;
6460
+ }
6461
+ let text;
6462
+ try {
6463
+ text = await e.channel.open(frame.s);
6464
+ } catch (err) {
6465
+ if (err instanceof SealError && err.code === "replay") return;
6466
+ if (entries.get(peer) !== e) return;
6467
+ close(e, codeOf(err));
6468
+ rekey(peer);
6469
+ return;
6470
+ }
6471
+ if (entries.get(peer) !== e) return;
6472
+ if (!approved(e.deviceId, e.publicKey)) {
6473
+ close(e, "revoked");
6474
+ denied(peer);
6475
+ return;
6476
+ }
6477
+ if (e.confirmed) {
6478
+ opts.onMessage(e.deviceId, text);
6479
+ return;
6480
+ }
6481
+ if (text !== READY) {
6482
+ close(e, "no key confirmation");
6483
+ rekey(peer);
6484
+ return;
6485
+ }
6486
+ const sealed = await e.channel.seal(READY);
6487
+ if (entries.get(peer) !== e) return;
6488
+ e.confirmed = true;
6489
+ e.confirmedSeq = ++confirmations;
6490
+ opts.send(peer, { k: "msg", s: sealed });
6491
+ log(`mobile channel: confirmed device ${e.deviceId} on peer ${peer}`);
6492
+ opts.onConfirmed(e.deviceId, peer);
6493
+ };
6494
+ const handle = async (peer, frame) => {
6495
+ if (frame.k === "hello") await onHello(peer, frame);
6496
+ else if (frame.k === "msg") await onMsg(peer, frame);
6497
+ };
6498
+ return {
6499
+ receive(peer, frame) {
6500
+ const run2 = (queues.get(peer) ?? Promise.resolve()).then(() => handle(peer, frame)).catch((err) => log(`mobile channel: error on peer ${peer} (${codeOf(err)})`));
6501
+ queues.set(peer, run2);
6502
+ void run2.then(() => {
6503
+ if (queues.get(peer) === run2) queues.delete(peer);
6504
+ });
6505
+ return run2;
6506
+ },
6507
+ async send(deviceId, text) {
6508
+ let best = null;
6509
+ for (const e2 of entries.values()) {
6510
+ if (e2.deviceId === deviceId && e2.confirmed && (!best || e2.confirmedSeq > best.confirmedSeq)) best = e2;
6511
+ }
6512
+ if (!best || !approved(best.deviceId, best.publicKey)) return false;
6513
+ const e = best;
6514
+ const sealed = await e.channel.seal(text);
6515
+ if (entries.get(e.peer) !== e) return false;
6516
+ opts.send(e.peer, { k: "msg", s: sealed });
6517
+ return true;
6518
+ },
6519
+ drop(peer) {
6520
+ peerGen.set(peer, (peerGen.get(peer) ?? 0) + 1);
6521
+ const e = entries.get(peer);
6522
+ if (e) close(e, "peer gone");
6523
+ },
6524
+ dropDevice(deviceId) {
6525
+ for (const e of [...entries.values()]) if (e.deviceId === deviceId) close(e, "revoked");
6526
+ },
6527
+ reset() {
6528
+ epoch++;
6529
+ peerGen.clear();
6530
+ for (const e of [...entries.values()]) close(e, "relay welcome");
6531
+ },
6532
+ online() {
6533
+ return new Set([...entries.values()].filter((e) => e.confirmed).map((e) => e.deviceId));
6534
+ }
6535
+ };
6536
+ }
6537
+
6538
+ // src/mobile/service.ts
6539
+ var REVOKE_TIMEOUT_MS = 15e3;
6540
+ async function createMobileService(opts = {}) {
6541
+ const now = opts.now ?? Date.now;
6542
+ const fetchImpl = opts.fetchImpl ?? fetch;
6543
+ const pc = await loadPcIdentity();
6544
+ let config = readMobileConfig();
6545
+ let session = readRelaySession();
6546
+ let devices = readDevices();
6547
+ let link = null;
6548
+ let linkState = { state: "off" };
6549
+ let signin = null;
6550
+ let signinError = null;
6551
+ let signinRun = null;
6552
+ let stopped2 = false;
6553
+ const changeListeners = /* @__PURE__ */ new Set();
6554
+ const messageListeners = /* @__PURE__ */ new Set();
6555
+ const changed = () => {
6556
+ if (stopped2) return;
6557
+ for (const fn of changeListeners) {
6558
+ try {
6559
+ fn();
6560
+ } catch (e) {
6561
+ log(`mobile: change listener failed (${e.message})`);
6562
+ }
6563
+ }
6564
+ };
6565
+ const sendFrame = (peer, frame) => {
6566
+ link?.send({ type: "send", to: peer, body: JSON.stringify(frame) });
6567
+ };
6568
+ const saveDevices = (next2) => {
6569
+ writeDevices(next2);
6570
+ devices = next2;
6571
+ };
6572
+ const pairing = createPairing({
6573
+ pc,
6574
+ send: sendFrame,
6575
+ approve(d) {
6576
+ const old = devices.find((x) => x.id === d.id);
6577
+ saveDevices(old ? devices.map((x) => x.id === d.id ? { ...x, label: d.label } : x) : [...devices, d]);
6578
+ },
6579
+ onChange: changed,
6580
+ now,
6581
+ ...opts.schedule ? { schedule: opts.schedule } : {}
6582
+ });
6583
+ const channels = createChannels({
6584
+ pc,
6585
+ devices: () => devices,
6586
+ send: sendFrame,
6587
+ onConfirmed(deviceId, peer) {
6588
+ if (devices.some((d) => d.id === deviceId)) {
6589
+ const at = new Date(now()).toISOString();
6590
+ try {
6591
+ saveDevices(devices.map((d) => d.id === deviceId ? { ...d, lastPeer: peer, lastSeenAt: at } : d));
6592
+ } catch (e) {
6593
+ log(`mobile: devices.json not written (${e.message})`);
6594
+ }
6595
+ }
6596
+ pairing.linked(deviceId);
6597
+ changed();
6598
+ },
6599
+ onMessage(deviceId, text) {
6600
+ for (const fn of messageListeners) {
6601
+ try {
6602
+ fn(deviceId, text);
6603
+ } catch (e) {
6604
+ log(`mobile: message listener failed (${e.message})`);
6605
+ }
6606
+ }
6607
+ },
6608
+ onClosed: () => changed(),
6609
+ now
6610
+ });
6611
+ const onRelayMessage = (m) => {
6612
+ switch (m.type) {
6613
+ case "welcome":
6614
+ channels.reset();
6615
+ pairing.reset();
6616
+ changed();
6617
+ return;
6618
+ case "deliver": {
6619
+ if (m.from === "pc") return;
6620
+ let raw;
6621
+ try {
6622
+ raw = JSON.parse(m.body);
6623
+ } catch {
6624
+ return;
6625
+ }
6626
+ const r = SealedFrameSchema.safeParse(raw);
6627
+ if (!r.success) return;
6628
+ if (r.data.k.startsWith("pair_")) {
6629
+ pairing.receive(m.from, r.data).catch((e) => log(`mobile pairing: error on peer ${m.from} (${e.name})`));
6630
+ } else {
6631
+ void channels.receive(m.from, r.data);
6632
+ }
6633
+ return;
6634
+ }
6635
+ case "presence":
6636
+ if (!m.online) channels.drop(m.peer);
6637
+ return;
6638
+ case "error":
6639
+ if (m.to) channels.drop(m.to);
6640
+ return;
6641
+ }
6642
+ };
6643
+ const closeLink = () => {
6644
+ const old = link;
6645
+ link = null;
6646
+ old?.close();
6647
+ if (old) pairing.reset();
6648
+ linkState = { state: "off" };
6649
+ };
6650
+ const openLink = () => {
6651
+ if (stopped2 || link || !config.enabled || !session) return;
6652
+ const self = { link: null };
6653
+ const created = createRelayLink({
6654
+ relayUrl: session.relayUrl,
6655
+ token: session.token,
6656
+ onState(s) {
6657
+ if (self.link !== null && link !== self.link) return;
6658
+ linkState = s;
6659
+ changed();
6660
+ },
6661
+ onMessage(m) {
6662
+ if (link !== self.link) return;
6663
+ onRelayMessage(m);
6664
+ },
6665
+ now,
6666
+ ...opts.WebSocketImpl ? { WebSocketImpl: opts.WebSocketImpl } : {},
6667
+ ...opts.schedule ? { schedule: opts.schedule } : {}
6668
+ });
6669
+ self.link = created;
6670
+ link = created;
6671
+ };
6672
+ const cancelSignin = () => {
6673
+ signinRun?.cancel();
6674
+ signinRun = null;
6675
+ signin = null;
6676
+ };
6677
+ const startSignin = () => {
6678
+ if (!config.enabled) throw new Error("mobile access is disabled");
6679
+ cancelSignin();
6680
+ signinError = null;
6681
+ const relayUrl = config.relayUrl;
6682
+ const run2 = startSignIn({
6683
+ relayUrl,
6684
+ onCode(c) {
6685
+ if (signinRun !== run2) return;
6686
+ signin = c;
6687
+ changed();
6688
+ },
6689
+ fetchImpl,
6690
+ ...opts.sleep ? { sleep: opts.sleep } : {}
6691
+ });
6692
+ signinRun = run2;
6693
+ void run2.result.then((r) => {
6694
+ if (signinRun !== run2) return;
6695
+ signinRun = null;
6696
+ signin = null;
6697
+ if (r.status === "ok") {
6698
+ writeRelaySession(r.session);
6699
+ session = r.session;
6700
+ log(`mobile: signed in as ${r.session.login}`);
6701
+ closeLink();
6702
+ openLink();
6703
+ } else if (r.status !== "cancelled") {
6704
+ signinError = r.status;
6705
+ log(`mobile: sign-in ended (${r.status === "failed" ? r.error : r.status})`);
6706
+ }
6707
+ changed();
6708
+ }).catch((e) => {
6709
+ log(`mobile: sign-in not saved (${e.message})`);
6710
+ if (signinRun === null) {
6711
+ signinError = "failed";
6712
+ changed();
6713
+ }
6714
+ });
6715
+ };
6716
+ const revokeOnRelay = async (peer) => {
6717
+ if (!session || linkState.state !== "online") return;
6718
+ try {
6719
+ const res = await fetchImpl(new URL("/auth/devices/revoke", session.relayUrl).toString(), {
6720
+ method: "POST",
6721
+ headers: { authorization: `Bearer ${session.token}`, "content-type": "application/json" },
6722
+ body: JSON.stringify({ peer }),
6723
+ signal: AbortSignal.timeout(REVOKE_TIMEOUT_MS)
6724
+ });
6725
+ await res.body?.cancel();
6726
+ if (!res.ok) log(`mobile: relay did not revoke peer ${peer} (status ${res.status})`);
6727
+ else log(`mobile: relay revoked peer ${peer}`);
6728
+ } catch (e) {
6729
+ log(`mobile: relay did not revoke peer ${peer} (${e.name})`);
6730
+ }
6731
+ };
6732
+ const revoke = async (id) => {
6733
+ const d = devices.find((x) => x.id === id);
6734
+ if (!d) return;
6735
+ saveDevices(devices.filter((x) => x.id !== id));
6736
+ log(`mobile: revoked device ${id}`);
6737
+ channels.dropDevice(id);
6738
+ if (d.lastPeer) sendFrame(d.lastPeer, { k: "denied" });
6739
+ changed();
6740
+ if (d.lastPeer) await revokeOnRelay(d.lastPeer);
6741
+ };
6742
+ const pairingIn = (id, states) => {
6743
+ const p = pairing.views().find((v) => v.id === id);
6744
+ if (!p || !states.includes(p.state)) throw new Error("no such pairing waiting for a decision");
6745
+ };
6746
+ openLink();
6747
+ return {
6748
+ state() {
6749
+ const online = linkState.state === "online" ? channels.online() : /* @__PURE__ */ new Set();
6750
+ return {
6751
+ enabled: config.enabled,
6752
+ relayUrl: config.relayUrl,
6753
+ account: session ? { login: session.login, accountId: session.accountId } : null,
6754
+ signin,
6755
+ signinError,
6756
+ link: linkState,
6757
+ devices: devices.map((d) => ({
6758
+ id: d.id,
6759
+ label: d.label,
6760
+ approvedAt: d.approvedAt,
6761
+ online: online.has(d.id),
6762
+ ...d.lastSeenAt ? { lastSeenAt: d.lastSeenAt } : {}
6763
+ })),
6764
+ pairings: pairing.views()
6765
+ };
6766
+ },
6767
+ onChange(fn) {
6768
+ changeListeners.add(fn);
6769
+ return () => {
6770
+ changeListeners.delete(fn);
6771
+ };
6772
+ },
6773
+ async handle(msg) {
6774
+ if (stopped2) throw new Error("mobile access is stopped");
6775
+ switch (msg.type) {
6776
+ case "mobile.set": {
6777
+ const next2 = { enabled: msg.enabled, relayUrl: msg.relayUrl };
6778
+ const moved = next2.relayUrl !== config.relayUrl;
6779
+ writeMobileConfig(next2);
6780
+ config = next2;
6781
+ if (moved) {
6782
+ cancelSignin();
6783
+ clearRelaySession();
6784
+ session = null;
6785
+ closeLink();
6786
+ }
6787
+ if (!config.enabled) {
6788
+ cancelSignin();
6789
+ closeLink();
6790
+ } else {
6791
+ openLink();
6792
+ }
6793
+ changed();
6794
+ return;
6795
+ }
6796
+ case "mobile.signin":
6797
+ startSignin();
6798
+ changed();
6799
+ return;
6800
+ case "mobile.signin.cancel":
6801
+ cancelSignin();
6802
+ changed();
6803
+ return;
6804
+ case "mobile.signout": {
6805
+ cancelSignin();
6806
+ const old = session;
6807
+ session = null;
6808
+ clearRelaySession();
6809
+ closeLink();
6810
+ changed();
6811
+ if (old) await signOut(old, fetchImpl);
6812
+ log("mobile: signed out");
6813
+ return;
6814
+ }
6815
+ case "mobile.retry":
6816
+ link?.retryNow();
6817
+ return;
6818
+ case "mobile.pairing.approve":
6819
+ pairingIn(msg.id, ["confirm"]);
6820
+ if (linkState.state !== "online") throw new Error("the relay is offline");
6821
+ pairing.approve(msg.id);
6822
+ return;
6823
+ case "mobile.pairing.deny":
6824
+ pairingIn(msg.id, ["confirm", "waiting"]);
6825
+ pairing.deny(msg.id);
6826
+ return;
6827
+ case "mobile.device.revoke":
6828
+ return revoke(msg.id);
6829
+ }
6830
+ },
6831
+ send: (deviceId, text) => channels.send(deviceId, text),
6832
+ onMessage(fn) {
6833
+ messageListeners.add(fn);
6834
+ return () => {
6835
+ messageListeners.delete(fn);
6836
+ };
6837
+ },
6838
+ async stop() {
6839
+ if (stopped2) return;
6840
+ cancelSignin();
6841
+ closeLink();
6842
+ pairing.stop();
6843
+ stopped2 = true;
6844
+ changeListeners.clear();
6845
+ messageListeners.clear();
6846
+ }
6847
+ };
6848
+ }
6849
+
4923
6850
  // src/daemon/index.ts
4924
6851
  async function startDaemon(opts = {}) {
4925
6852
  await ensureLedgeDir();
@@ -4937,7 +6864,7 @@ async function startDaemon(opts = {}) {
4937
6864
  }
4938
6865
  for (const s of store.all()) {
4939
6866
  if (backendOf(s) !== "omp-rpc") continue;
4940
- await reapOwner(join15(paths().dir, "omp", s.id)).catch(() => false);
6867
+ await reapOwner(join16(paths().dir, "omp", s.id)).catch(() => false);
4941
6868
  if (s.state === "exited") continue;
4942
6869
  const at = (/* @__PURE__ */ new Date()).toISOString();
4943
6870
  store.apply(s.id, { name: "StopFailure", at, source: "daemon", text: "Ledge restarted: the OMP process ended with it" });
@@ -4955,7 +6882,15 @@ async function startDaemon(opts = {}) {
4955
6882
  if (loaded.clean) {
4956
6883
  for (const id of history.ids()) if (!store.get(id)) history.forget(id);
4957
6884
  } else log(`history cleanup skipped: ${loaded.reason}`);
4958
- const server = await startServer({ store, tmux, port: opts.port, daemon, updater, history });
6885
+ let mobile;
6886
+ let mobileUnavailable;
6887
+ try {
6888
+ mobile = await createMobileService();
6889
+ } catch (e) {
6890
+ log(`mobile access not available: ${e.message}`);
6891
+ mobileUnavailable = e.message.slice(0, 200);
6892
+ }
6893
+ const server = await startServer({ store, tmux, port: opts.port, daemon, updater, history, mobile, mobileUnavailable });
4959
6894
  ompLive = () => server.omp.liveCount();
4960
6895
  const poller = startPoller({ store, tmux, broadcastPreview: server.preview });
4961
6896
  await writeFile7(paths().pid, String(process.pid));
@@ -4966,6 +6901,7 @@ async function startDaemon(opts = {}) {
4966
6901
  process.off("SIGINT", onSignal);
4967
6902
  poller.stop();
4968
6903
  await server.close();
6904
+ await mobile?.stop();
4969
6905
  await store.save();
4970
6906
  await history.flush();
4971
6907
  await rm5(paths().pid, { force: true });