@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.
- package/dist/cli/{attach-PJHKOS4N.js → attach-3U4A44ZE.js} +7 -7
- package/dist/cli/{bridge-VQZCDSQE.js → bridge-7JT6P53L.js} +2 -2
- package/dist/cli/{chunk-FXI2EE52.js → chunk-2P2SUT7A.js} +2 -2
- package/dist/cli/{chunk-YUSY3E47.js → chunk-5DDBCJX2.js} +1 -1
- package/dist/cli/{chunk-PPRB4XIX.js → chunk-5LVPB3ET.js} +392 -141
- package/dist/cli/{chunk-XIO4OIHR.js → chunk-6A7JPTHV.js} +2 -2
- package/dist/cli/{chunk-ZSKCV73V.js → chunk-D3JQV5AA.js} +6 -6
- package/dist/cli/{chunk-ZEHTNFPV.js → chunk-FPW7MF5X.js} +2 -2
- package/dist/cli/{chunk-7XZNRFWJ.js → chunk-IIXALRFM.js} +2 -2
- package/dist/cli/{chunk-SVVQ5KPJ.js → chunk-PFDZ6VYG.js} +1 -1
- package/dist/cli/{chunk-FGOFRWIB.js → chunk-QBJEUZYW.js} +1 -1
- package/dist/cli/chunk-QUUZSAPI.js +6 -0
- package/dist/cli/{chunk-5XFBRTKO.js → chunk-ULWVO3TH.js} +2 -2
- package/dist/cli/{chunk-IOASI5FF.js → chunk-UWANRALT.js} +79 -4
- package/dist/cli/{daemon-ZUDRXUCP.js → daemon-KHGAO6BC.js} +2123 -187
- package/dist/cli/{hook-KKJLIOOM.js → hook-R6OWCFAQ.js} +8 -8
- package/dist/cli/index.js +11 -11
- package/dist/cli/{launch-4CBL5ZRR.js → launch-PPTQEWYD.js} +7 -7
- package/dist/cli/send-74LTIRSN.js +14 -0
- package/dist/cli/{setup-TULNBDFB.js → setup-FPQK3NA7.js} +4 -4
- package/dist/cli/{status-R7IOK5KL.js → status-QLTB6T3U.js} +3 -3
- package/dist/cli/{uninstall-OFSLCP4T.js → uninstall-QC5WEWDH.js} +4 -4
- package/dist/cli/{version-JOFQUAE4.js → version-AELAEF6T.js} +1 -1
- package/dist/cli/who-5F6CHRA3.js +11 -0
- package/package.json +2 -1
- package/dist/cli/chunk-LMQADKT7.js +0 -6
- package/dist/cli/send-6EGRHCRO.js +0 -14
- 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
|
-
|
|
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-
|
|
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-
|
|
93
|
+
} from "./chunk-5LVPB3ET.js";
|
|
64
94
|
import {
|
|
65
95
|
LEDGE_VERSION
|
|
66
|
-
} from "./chunk-
|
|
96
|
+
} from "./chunk-QUUZSAPI.js";
|
|
67
97
|
import {
|
|
68
98
|
SERVICE,
|
|
69
99
|
inDaemonService,
|
|
70
100
|
tmuxServerAtRisk
|
|
71
|
-
} from "./chunk-
|
|
101
|
+
} from "./chunk-6A7JPTHV.js";
|
|
72
102
|
import {
|
|
73
103
|
realTmux,
|
|
74
104
|
runFile
|
|
75
|
-
} from "./chunk-
|
|
105
|
+
} from "./chunk-FPW7MF5X.js";
|
|
76
106
|
import {
|
|
77
107
|
log
|
|
78
|
-
} from "./chunk-
|
|
108
|
+
} from "./chunk-5DDBCJX2.js";
|
|
79
109
|
import {
|
|
80
110
|
ensureLedgeDir,
|
|
81
111
|
paths
|
|
82
|
-
} from "./chunk-
|
|
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
|
|
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
|
|
722
|
-
import { basename as basename4, join as
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1323
|
+
var compact = (s) => s.replace(/\s+/g, "");
|
|
1340
1324
|
var typed = (text) => (lastLines2) => {
|
|
1341
1325
|
const lines = toLines(lastLines2);
|
|
1342
|
-
const want =
|
|
1326
|
+
const want = compact(`\u2192 ${text}`);
|
|
1343
1327
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1344
|
-
if (INPUT.test(lines[i]) &&
|
|
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
|
|
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
|
|
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
|
|
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((
|
|
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
|
-
|
|
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((
|
|
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
|
|
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
|
-
|
|
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
|
|
3583
|
-
while (
|
|
3584
|
-
const { bytesRead } = await file.read(chunk, 0, Math.min(chunk.length, DOCUMENT_MAX_BYTES + 1 -
|
|
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
|
-
|
|
3578
|
+
bytes2 += bytesRead;
|
|
3588
3579
|
}
|
|
3589
|
-
if (
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
4110
|
-
const models = opts.models ?? createModels({ env: agentEnv2, omp: async () => ompChoices(await omp.catalog(opts.home ??
|
|
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 = (
|
|
4200
|
-
|
|
4201
|
-
|
|
4202
|
-
|
|
4203
|
-
|
|
4204
|
-
|
|
4205
|
-
|
|
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
|
|
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
|
|
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
|
|
4247
|
-
if (inFlight.has(id)) throw new
|
|
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
|
|
4260
|
-
if (!stillTheSame()) throw new
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
4333
|
-
if (s.source === "hook_only") return
|
|
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)
|
|
4347
|
-
|
|
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 ??
|
|
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(
|
|
4488
|
-
const refuse = (
|
|
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
|
|
4493
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
4637
|
-
if (s.source === "hook_only") return refuse(
|
|
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(
|
|
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) =>
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
4748
|
-
return opts.updater.update(msg.version, broadcast,
|
|
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
|
-
|
|
5406
|
+
reply({ type: "chat.routed", qid: msg.qid, result: await router.route(msg) });
|
|
4754
5407
|
} catch (e) {
|
|
4755
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
4835
|
-
import { dirname as dirname6, join as
|
|
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(
|
|
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:
|
|
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
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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 });
|