frizz 0.7.7 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/README.md +15 -45
  2. package/dist/claude-agent-broker.js +7 -4
  3. package/dist/dev-child.js +422 -273
  4. package/dist/frizz.js +1003 -398
  5. package/package.json +1 -1
  6. package/runtime/cc-worker/.claude-plugin/plugin.json +1 -1
  7. package/runtime/cc-worker/DECISIONS.md +31 -2
  8. package/runtime/cc-worker/agents/high.md +7 -0
  9. package/runtime/cc-worker/agents/low.md +7 -0
  10. package/runtime/cc-worker/agents/max.md +7 -0
  11. package/runtime/cc-worker/agents/medium.md +7 -0
  12. package/runtime/cc-worker/agents/xhigh.md +7 -0
  13. package/runtime/cc-worker/bin/frizz-mcp.mjs +3 -2
  14. package/runtime/cc-worker/hooks/agent-dispatch.mjs +7 -6
  15. package/runtime/cc-worker/hooks/hooks.json +0 -2
  16. package/runtime/cc-worker/hooks/scratchpad.mjs +5 -3
  17. package/runtime/cc-worker/hooks/session-seed.mjs +17 -17
  18. package/runtime/cc-worker/skills/gh/SKILL.md +5 -19
  19. package/web-dist/assets/{TerminalPane-Cb8jOFP9.js → TerminalPane-BIBHC61j.js} +1 -1
  20. package/web-dist/assets/{abnfDiagram-VRR7QNED-Dtbg2eZ0.js → abnfDiagram-VRR7QNED-CcFQGFPf.js} +1 -1
  21. package/web-dist/assets/architecture-TIHT7OUA-BZEZt7jZ.js +1 -0
  22. package/web-dist/assets/{architectureDiagram-ZJ3FMSHR-DzQjWW7u.js → architectureDiagram-ZJ3FMSHR-DErIQOIs.js} +1 -1
  23. package/web-dist/assets/{blockDiagram-677ZJIJ3-BJnpkLMt.js → blockDiagram-677ZJIJ3-CpsUqur1.js} +1 -1
  24. package/web-dist/assets/{c4Diagram-LMCZKHZV-COdatsYu.js → c4Diagram-LMCZKHZV-D2dHmCEJ.js} +1 -1
  25. package/web-dist/assets/channel-DdFRPviT.js +1 -0
  26. package/web-dist/assets/{chunk-32BRIVSS-CORMw4Vm.js → chunk-32BRIVSS-aOCEfxRW.js} +1 -1
  27. package/web-dist/assets/{chunk-52WLFC77-C7lEadTg.js → chunk-52WLFC77-2iw4nJPC.js} +1 -1
  28. package/web-dist/assets/{chunk-C7G6YPKG-DZy4F97l.js → chunk-C7G6YPKG-BPD3iHVE.js} +1 -1
  29. package/web-dist/assets/{chunk-EX3LRPZG-HynlfV9p.js → chunk-EX3LRPZG-DrefHift.js} +1 -1
  30. package/web-dist/assets/{chunk-FWX5IMBZ-BbwKNhKm.js → chunk-FWX5IMBZ-By940EHT.js} +2 -2
  31. package/web-dist/assets/{chunk-HOUHSVGY-lIhOZSmL.js → chunk-HOUHSVGY-B1Cqvz7e.js} +1 -1
  32. package/web-dist/assets/{chunk-ICXQ74PX-WGxaMO8c.js → chunk-ICXQ74PX-1LkVpWuI.js} +1 -1
  33. package/web-dist/assets/{chunk-MOJQB5TN-B-cx0f3u.js → chunk-MOJQB5TN-Ip1cy64r.js} +1 -1
  34. package/web-dist/assets/{chunk-OGEWGWER-DJceVwZH.js → chunk-OGEWGWER-CNjVyu8F.js} +1 -1
  35. package/web-dist/assets/{chunk-PUDLZKDR-CGwHZns8.js → chunk-PUDLZKDR-9HZR6DAU.js} +1 -1
  36. package/web-dist/assets/{chunk-Q4XR5HBZ-PTSWtmk1.js → chunk-Q4XR5HBZ-vtWwOCuQ.js} +1 -1
  37. package/web-dist/assets/{chunk-V7JOEXUC-VNxHZPcf.js → chunk-V7JOEXUC-DC6YaHG0.js} +1 -1
  38. package/web-dist/assets/{chunk-VAUOI2AC-ZXYCz_Aw.js → chunk-VAUOI2AC-CP-xe7SD.js} +1 -1
  39. package/web-dist/assets/{chunk-VR4S4FIN-9WCtmFJp.js → chunk-VR4S4FIN-wbxDFptH.js} +1 -1
  40. package/web-dist/assets/{chunk-WYO6CB5R-V4EBjpmt.js → chunk-WYO6CB5R-dibXmwpK.js} +1 -1
  41. package/web-dist/assets/{chunk-ZGVPDNZ5-CPW0Z0ae.js → chunk-ZGVPDNZ5-_y_itJHG.js} +1 -1
  42. package/web-dist/assets/classDiagram-OUVF2IWQ-Bf3duLb8.js +1 -0
  43. package/web-dist/assets/classDiagram-v2-EOCWNBFH-Bf3duLb8.js +1 -0
  44. package/web-dist/assets/{cynefin-VYW2F7L2-E7PklfJD.js → cynefin-VYW2F7L2-D1RYokZP.js} +1 -1
  45. package/web-dist/assets/{cynefinDiagram-TSTJHNR4-JIXAPjDf.js → cynefinDiagram-TSTJHNR4-qofqoy3T.js} +1 -1
  46. package/web-dist/assets/{dagre-VKFMJZFB-DjXrdcNx.js → dagre-VKFMJZFB-CgHLoGFk.js} +1 -1
  47. package/web-dist/assets/{diagram-FQU43EPY--i9i0qhm.js → diagram-FQU43EPY-JEAs4LOb.js} +1 -1
  48. package/web-dist/assets/{diagram-G47NLZAW-DxgcENZ_.js → diagram-G47NLZAW--tLd4Lf7.js} +1 -1
  49. package/web-dist/assets/{diagram-NH7WQ7WH-CUy9Nr90.js → diagram-NH7WQ7WH-DPbw9CJU.js} +1 -1
  50. package/web-dist/assets/{diagram-OA4YK3LP-DbTbUaCb.js → diagram-OA4YK3LP-CDrb7nWs.js} +1 -1
  51. package/web-dist/assets/{diagram-WEI45ONY-2gk6xhOq.js → diagram-WEI45ONY-DJswZe3f.js} +1 -1
  52. package/web-dist/assets/{ebnfDiagram-CCIWWBDH-uE2iDETg.js → ebnfDiagram-CCIWWBDH-BP8-PZMF.js} +1 -1
  53. package/web-dist/assets/{erDiagram-Q63AITRT-dztd4MUw.js → erDiagram-Q63AITRT-BQXuebf5.js} +1 -1
  54. package/web-dist/assets/eventmodeling-45OFAUF4-DOLxRQQ5.js +1 -0
  55. package/web-dist/assets/flowDiagram-23GEKE2U-DoK1yb0H.js +1 -0
  56. package/web-dist/assets/{ganttDiagram-NO4QXBWP-DAds6Aht.js → ganttDiagram-NO4QXBWP-DotMb935.js} +1 -1
  57. package/web-dist/assets/{gitGraph-TEB2WS4Q-Ci0XVvIa.js → gitGraph-TEB2WS4Q-B1vbYyKQ.js} +1 -1
  58. package/web-dist/assets/{gitGraphDiagram-IHSO6WYX-C6FULyt8.js → gitGraphDiagram-IHSO6WYX-BjDXKk4m.js} +1 -1
  59. package/web-dist/assets/index-CmrDpsD8.css +1 -0
  60. package/web-dist/assets/{index-CNy5uOpI.js → index-Cs0TDGMu.js} +124 -124
  61. package/web-dist/assets/{info-DKCQHKI2-9N77dOiw.js → info-DKCQHKI2-xLTLqQju.js} +1 -1
  62. package/web-dist/assets/{infoDiagram-FWYZ7A6U-BymP3PgG.js → infoDiagram-FWYZ7A6U-BFfYUsrC.js} +1 -1
  63. package/web-dist/assets/{ishikawaDiagram-FXEZZL3T-CU_kQBz1.js → ishikawaDiagram-FXEZZL3T-45a7VMwm.js} +1 -1
  64. package/web-dist/assets/{journeyDiagram-5HDEW3XC-DZN15HSd.js → journeyDiagram-5HDEW3XC-CEBUpu59.js} +1 -1
  65. package/web-dist/assets/{kanban-definition-HUTT4EX6-J4wR5g_3.js → kanban-definition-HUTT4EX6-RwKtDFzG.js} +1 -1
  66. package/web-dist/assets/{line-SRzGpiDr.js → line-DStcMIry.js} +1 -1
  67. package/web-dist/assets/{mermaid-parser.core-CGBscNGo.js → mermaid-parser.core-B0ekgQMA.js} +3 -3
  68. package/web-dist/assets/{mermaid.core-xNdwXb1r.js → mermaid.core-w1hcQaPy.js} +3 -3
  69. package/web-dist/assets/{mindmap-definition-LN4V7U3C-gR2dbzrj.js → mindmap-definition-LN4V7U3C-D_n38Imb.js} +1 -1
  70. package/web-dist/assets/{packet-7NZHBO7P-DrR4W6I3.js → packet-7NZHBO7P-CstGeBOX.js} +1 -1
  71. package/web-dist/assets/{pegDiagram-2B236MQR-DN7R-_t4.js → pegDiagram-2B236MQR-DvowVyTO.js} +1 -1
  72. package/web-dist/assets/{pie-RZYD4A2V-C8VjQAFZ.js → pie-RZYD4A2V-BOP2j_HH.js} +1 -1
  73. package/web-dist/assets/{pieDiagram-ENE6RG2P-5w64zdD1.js → pieDiagram-ENE6RG2P-CKRqu_2f.js} +1 -1
  74. package/web-dist/assets/{quadrantDiagram-ABIIQ3AL-B4rbezq_.js → quadrantDiagram-ABIIQ3AL-zSArSS8R.js} +1 -1
  75. package/web-dist/assets/{radar-I7S5WNFK-CTbbPB8m.js → radar-I7S5WNFK-BqRdNIBw.js} +1 -1
  76. package/web-dist/assets/{railroad-3IZDKUUU-CY1er15b.js → railroad-3IZDKUUU-BaKcl_LU.js} +1 -1
  77. package/web-dist/assets/railroad-abnf-AHOZXSZD-itqDLg1p.js +1 -0
  78. package/web-dist/assets/railroad-ebnf-EBAXGLYW-ze7rLVCi.js +1 -0
  79. package/web-dist/assets/railroad-peg-LSFZ7HO6-DKsYkEuG.js +1 -0
  80. package/web-dist/assets/{railroadDiagram-RFXS5EU6-Bx2HJ3yT.js → railroadDiagram-RFXS5EU6-BrXTQge9.js} +1 -1
  81. package/web-dist/assets/{requirementDiagram-TGXJPOKE-BLK-Y2rx.js → requirementDiagram-TGXJPOKE-DBBNi_SS.js} +1 -1
  82. package/web-dist/assets/{sankeyDiagram-HTMAVEWB-Dk-8Miv-.js → sankeyDiagram-HTMAVEWB-BwFi7xFy.js} +1 -1
  83. package/web-dist/assets/{sequenceDiagram-DBY2YBRQ-DL2alfR-.js → sequenceDiagram-DBY2YBRQ-CeivqMeT.js} +1 -1
  84. package/web-dist/assets/{stateDiagram-2N3HPSRC-Bka44wf1.js → stateDiagram-2N3HPSRC-C6NZsnFm.js} +1 -1
  85. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-B3YRnI4W.js +1 -0
  86. package/web-dist/assets/{swimlanes-5IMT3BWC-e0GbMwIr.js → swimlanes-5IMT3BWC-C0MubXTG.js} +1 -1
  87. package/web-dist/assets/swimlanesDiagram-G3AALYLV-CXva4Ic9.js +8 -0
  88. package/web-dist/assets/{timeline-definition-FHXFAJF6-CT3GFq89.js → timeline-definition-FHXFAJF6-CtxwSvFu.js} +1 -1
  89. package/web-dist/assets/{treeView-QDETBFTQ-BJauJ0bm.js → treeView-QDETBFTQ-CXcwSjey.js} +1 -1
  90. package/web-dist/assets/{treemap-6X3UGDF4-DCX7A-nx.js → treemap-6X3UGDF4-Uspgr-d1.js} +1 -1
  91. package/web-dist/assets/{vennDiagram-L72KCM5P-D4dm2iTR.js → vennDiagram-L72KCM5P-tZamQQAQ.js} +1 -1
  92. package/web-dist/assets/{wardley-OPB4EBWU-DklTDSId.js → wardley-OPB4EBWU-0_79c19P.js} +1 -1
  93. package/web-dist/assets/{wardleyDiagram-EHGQE667-CikP5hBd.js → wardleyDiagram-EHGQE667-uYmUsP66.js} +1 -1
  94. package/web-dist/assets/{xychartDiagram-FW5EYKEG-FXuOAFpz.js → xychartDiagram-FW5EYKEG-BsOU7Wf7.js} +1 -1
  95. package/web-dist/index.html +2 -2
  96. package/runtime/cc-worker/agents/fable-high.md +0 -8
  97. package/runtime/cc-worker/agents/fable-low.md +0 -8
  98. package/runtime/cc-worker/agents/fable-max.md +0 -8
  99. package/runtime/cc-worker/agents/fable-medium.md +0 -8
  100. package/runtime/cc-worker/agents/fable-xhigh.md +0 -8
  101. package/runtime/cc-worker/agents/haiku.md +0 -7
  102. package/runtime/cc-worker/agents/opus-high.md +0 -8
  103. package/runtime/cc-worker/agents/opus-low.md +0 -8
  104. package/runtime/cc-worker/agents/opus-max.md +0 -8
  105. package/runtime/cc-worker/agents/opus-medium.md +0 -8
  106. package/runtime/cc-worker/agents/opus-xhigh.md +0 -8
  107. package/runtime/cc-worker/agents/sonnet-high.md +0 -8
  108. package/runtime/cc-worker/agents/sonnet-low.md +0 -8
  109. package/runtime/cc-worker/agents/sonnet-max.md +0 -8
  110. package/runtime/cc-worker/agents/sonnet-medium.md +0 -8
  111. package/runtime/cc-worker/agents/sonnet-xhigh.md +0 -8
  112. package/runtime/cc-worker/bin/browser-mcp-tools.json +0 -1192
  113. package/runtime/cc-worker/bin/browser-mcp.mjs +0 -730
  114. package/runtime/cc-worker/hooks/precompact-instructions.mjs +0 -133
  115. package/runtime/cc-worker/skills/waits/SKILL.md +0 -84
  116. package/web-dist/assets/architecture-TIHT7OUA-CTxVpl9_.js +0 -1
  117. package/web-dist/assets/channel-COS0Ygbp.js +0 -1
  118. package/web-dist/assets/classDiagram-OUVF2IWQ-Df5TLnrN.js +0 -1
  119. package/web-dist/assets/classDiagram-v2-EOCWNBFH-Df5TLnrN.js +0 -1
  120. package/web-dist/assets/eventmodeling-45OFAUF4-DPVPOEaJ.js +0 -1
  121. package/web-dist/assets/flowDiagram-23GEKE2U-BMIMgVYd.js +0 -1
  122. package/web-dist/assets/index-D6LsViUY.css +0 -1
  123. package/web-dist/assets/railroad-abnf-AHOZXSZD-DfEpkrtJ.js +0 -1
  124. package/web-dist/assets/railroad-ebnf-EBAXGLYW-Csunejgu.js +0 -1
  125. package/web-dist/assets/railroad-peg-LSFZ7HO6-BgRslbM_.js +0 -1
  126. package/web-dist/assets/stateDiagram-v2-6OUMAXLB-6KcScVPX.js +0 -1
  127. package/web-dist/assets/swimlanesDiagram-G3AALYLV-jaJhlDbE.js +0 -8
package/dist/frizz.js CHANGED
@@ -140,52 +140,9 @@ function isTrustedLocalHttpRequest(headers, expectedPort, allowMissingOrigin = f
140
140
  function allowedLocalCorsOrigin(origin, expectedPort, policy) {
141
141
  return parseLocalHttpOrigin(origin, expectedPort, policy) ? origin : void 0;
142
142
  }
143
- function normalizeBindHost(value) {
144
- const trimmed = value.trim();
145
- if (!trimmed) throw new Error("--host requires an address");
146
- const bare = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
147
- const lower = bare.toLowerCase();
148
- if (lower === "localhost") return LOOPBACK_BIND_HOST;
149
- if (isIP(bare) === 0) {
150
- throw new Error(`invalid --host address: ${value} (use an IP address such as 0.0.0.0, :: or 127.0.0.1)`);
151
- }
152
- return bare;
153
- }
154
143
  function bindHostIsExposed(host) {
155
144
  return !LOOPBACK_BIND_HOSTS.has(host.trim().toLowerCase());
156
145
  }
157
- function normalizePublicOrigin(value) {
158
- const trimmed = value.trim();
159
- const invalid = (why) => new Error(`invalid --public-origin: ${value} (${why})`);
160
- if (!trimmed) throw new Error("--public-origin requires a URL");
161
- let url;
162
- try {
163
- url = new URL(trimmed);
164
- } catch {
165
- throw invalid("use a full URL such as https://frizz.example.com");
166
- }
167
- if (url.protocol !== "https:" && url.protocol !== "http:") throw invalid("only http and https are supported");
168
- if (url.username || url.password) throw invalid("an origin carries no credentials");
169
- if (url.pathname !== "/" || url.search || url.hash) throw invalid("an origin is a scheme and host only, with no path");
170
- if (!url.hostname) throw invalid("no hostname");
171
- return url.origin;
172
- }
173
- function machineHostNames(hostname) {
174
- const full = hostname.trim().toLowerCase().replace(/\.$/, "");
175
- if (!full || full === "localhost" || isIP(full) !== 0) return [];
176
- const short = full.split(".")[0];
177
- return [.../* @__PURE__ */ new Set([full, short, `${short}.local`])].filter((name) => name !== "localhost" && name !== "localhost.local");
178
- }
179
- function normalizeAllowedHosts(values) {
180
- const seen = /* @__PURE__ */ new Set();
181
- for (const value of values) {
182
- for (const entry of value.split(",")) {
183
- const host = entry.trim().toLowerCase();
184
- if (host) seen.add(host);
185
- }
186
- }
187
- return [...seen];
188
- }
189
146
  function rejectWebSocketUpgrade(socket, status = 403, reason = "Forbidden") {
190
147
  const body = `${reason}
191
148
  `;
@@ -206,7 +163,7 @@ function rejectWebSocketUpgrade(socket, status = 403, reason = "Forbidden") {
206
163
  }
207
164
  }
208
165
  }
209
- var LOCAL_HOSTNAMES, FORWARDED_HEADERS, LOOPBACK_BIND_HOST, ALL_INTERFACES_BIND_HOST, LOOPBACK_BIND_HOSTS;
166
+ var LOCAL_HOSTNAMES, FORWARDED_HEADERS, LOOPBACK_BIND_HOST, LOOPBACK_BIND_HOSTS;
210
167
  var init_local_origin = __esm({
211
168
  "packages/server/src/local-origin.ts"() {
212
169
  "use strict";
@@ -219,7 +176,6 @@ var init_local_origin = __esm({
219
176
  "x-forwarded-proto"
220
177
  ];
221
178
  LOOPBACK_BIND_HOST = "127.0.0.1";
222
- ALL_INTERFACES_BIND_HOST = "0.0.0.0";
223
179
  LOOPBACK_BIND_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "::1", "localhost", "[::1]"]);
224
180
  }
225
181
  });
@@ -12747,7 +12703,7 @@ var init_src = __esm({
12747
12703
  questions: external_exports.array(AskQuestion)
12748
12704
  });
12749
12705
  AwaitingHint = external_exports.object({
12750
- kind: external_exports.enum(["shell", "agent", "timer", "pr", "for"]),
12706
+ kind: external_exports.enum(["shell", "agent", "timer", "pr", "for", "title"]),
12751
12707
  value: external_exports.string()
12752
12708
  });
12753
12709
  RETIRED_AWAITING_KINDS = ["watch", "pr-watch", "human", "ci", "session", "shell", "agent", "timer", "pr", "reason"];
@@ -13324,6 +13280,14 @@ var init_src = __esm({
13324
13280
  // Default action for a vetted non-image local path in agent markdown. Image clicks always use the
13325
13281
  // OS default viewer so screenshots retain their expected behavior.
13326
13282
  localFileOpener: LocalFileOpener.optional(),
13283
+ // The token count at which a NEW Claude worker auto-compacts its conversation. Frizz requests the
13284
+ // 1M context window on every dispatch (resolveClaudeLaunchModel), so without this a worker grows
13285
+ // toward 1M before it ever compacts — and every turn past 200K re-sends up to 5x the conversation a
13286
+ // 200K TUI session would, which is the single largest reason a Frizz thread spends quota faster than
13287
+ // the TUI (measured 2026-08-26). Reaches the worker as CLAUDE_CODE_AUTO_COMPACT_WINDOW, which Claude
13288
+ // Code caps to the model's real window. Optional so an old blob parses; defaultSettings pins 500_000
13289
+ // (maintainer 2026-08-26: "a default compaction window of 500k by default").
13290
+ autoCompactWindow: external_exports.number().int().positive().optional(),
13327
13291
  // The GitHub batch-dispatch prompt template (the picker's per-item worker prompt). Optional: when
13328
13292
  // unset OR blank the server falls back to its exported DEFAULT_GITHUB_PROMPT. Substitution tokens
13329
13293
  // the server fills: {repo} {n} {title} {url} {labels} {body}. The leading `THREAD: <slug>` tag is
@@ -17842,9 +17806,9 @@ var init_wrapper = __esm({
17842
17806
 
17843
17807
  // packages/server/src/frizz-paths.ts
17844
17808
  import { existsSync as existsSync2 } from "node:fs";
17845
- import { homedir as homedir4, tmpdir } from "node:os";
17809
+ import { homedir as homedir5, tmpdir } from "node:os";
17846
17810
  import { dirname as dirname4, join as join5 } from "node:path";
17847
- function legacyFrizzRoot(home = homedir4()) {
17811
+ function legacyFrizzRoot(home = homedir5()) {
17848
17812
  return join5(home, LEGACY_DIR_NAME);
17849
17813
  }
17850
17814
  function xdg(env, name) {
@@ -17886,7 +17850,7 @@ function xdgRoots(env, home) {
17886
17850
  function frizzPaths(options2 = {}) {
17887
17851
  const env = options2.env ?? process.env;
17888
17852
  const platform = options2.platform ?? process.platform;
17889
- const home = options2.home ?? homedir4();
17853
+ const home = options2.home ?? homedir5();
17890
17854
  const exists = options2.exists ?? existsSync2;
17891
17855
  const legacyRoot = legacyFrizzRoot(home);
17892
17856
  if (exists(legacyRoot)) {
@@ -17925,22 +17889,22 @@ import {
17925
17889
  mkdirSync as mkdirSync4,
17926
17890
  openSync,
17927
17891
  readdirSync,
17928
- rmSync,
17892
+ rmSync as rmSync2,
17929
17893
  statSync as statSync2,
17930
17894
  symlinkSync,
17931
17895
  writeFileSync as writeFileSync4,
17932
17896
  writeSync
17933
17897
  } from "node:fs";
17934
- import { homedir as homedir5 } from "node:os";
17898
+ import { homedir as homedir6 } from "node:os";
17935
17899
  import { join as join6 } from "node:path";
17936
- function defaultLogRoot(stateDir, home = homedir5()) {
17900
+ function defaultLogRoot(stateDir, home = homedir6()) {
17937
17901
  return stateDir ? join6(stateDir, "logs") : join6(frizzPaths({ home }).state, "logs");
17938
17902
  }
17939
17903
  function runStamp(at) {
17940
17904
  const pad = (value) => String(value).padStart(2, "0");
17941
17905
  return `${at.getFullYear()}-${pad(at.getMonth() + 1)}-${pad(at.getDate())}T${pad(at.getHours())}-${pad(at.getMinutes())}-${pad(at.getSeconds())}`;
17942
17906
  }
17943
- function runLogPath(stateDir, at = /* @__PURE__ */ new Date(), pid = process.pid, home = homedir5(), env = process.env) {
17907
+ function runLogPath(stateDir, at = /* @__PURE__ */ new Date(), pid = process.pid, home = homedir6(), env = process.env) {
17944
17908
  const name = `frizz-${runStamp(at)}-${pid}.log`;
17945
17909
  const override = env[LOG_PATH_ENV]?.trim();
17946
17910
  if (override) return override.endsWith(".log") ? override : join6(override, name);
@@ -17952,7 +17916,7 @@ function latestLogPath(dir) {
17952
17916
  function linkLatest(dir, target2) {
17953
17917
  const link = latestLogPath(dir);
17954
17918
  try {
17955
- rmSync(link, { force: true });
17919
+ rmSync2(link, { force: true });
17956
17920
  symlinkSync(target2, link);
17957
17921
  } catch {
17958
17922
  try {
@@ -17980,7 +17944,7 @@ function pruneRunLogs(dir, keep = RETAINED_RUNS, days = RETAINED_DAYS, now = Dat
17980
17944
  const stale = dated.filter((entry, index) => index >= keep || entry.at < cutoff);
17981
17945
  for (const entry of stale) {
17982
17946
  try {
17983
- rmSync(join6(dir, entry.name), { force: true });
17947
+ rmSync2(join6(dir, entry.name), { force: true });
17984
17948
  } catch {
17985
17949
  }
17986
17950
  }
@@ -18252,11 +18216,11 @@ import {
18252
18216
  readFileSync as readFileSync6,
18253
18217
  realpathSync as realpathSync2,
18254
18218
  renameSync,
18255
- rmSync as rmSync2,
18219
+ rmSync as rmSync3,
18256
18220
  statSync as statSync3,
18257
18221
  writeFileSync as writeFileSync5
18258
18222
  } from "node:fs";
18259
- import { homedir as homedir6 } from "node:os";
18223
+ import { homedir as homedir7 } from "node:os";
18260
18224
  import { dirname as dirname5, join as join8, resolve } from "node:path";
18261
18225
  import { setTimeout as delay } from "node:timers/promises";
18262
18226
  function errorCode2(error) {
@@ -18272,7 +18236,7 @@ function canonicalHome(home) {
18272
18236
  return resolve(home);
18273
18237
  }
18274
18238
  }
18275
- function globalLaunchLockPath(home = homedir6()) {
18239
+ function globalLaunchLockPath(home = homedir7()) {
18276
18240
  return join8(frizzPaths({ home: canonicalHome(home) }).state, GLOBAL_LOCK_NAME);
18277
18241
  }
18278
18242
  function namedLaunchLockPath(home, name) {
@@ -18356,7 +18320,7 @@ function quarantineLock(lockPath) {
18356
18320
  return false;
18357
18321
  }
18358
18322
  try {
18359
- rmSync2(quarantine2, { recursive: true, force: true });
18323
+ rmSync3(quarantine2, { recursive: true, force: true });
18360
18324
  } catch {
18361
18325
  }
18362
18326
  return true;
@@ -18402,7 +18366,7 @@ function tryAcquireLock(lockPath, adapter) {
18402
18366
  const quarantine2 = `${lockPath}.release-${process.pid}-${owner.token}`;
18403
18367
  try {
18404
18368
  renameSync(lockPath, quarantine2);
18405
- rmSync2(quarantine2, { recursive: true, force: true });
18369
+ rmSync3(quarantine2, { recursive: true, force: true });
18406
18370
  } catch {
18407
18371
  }
18408
18372
  };
@@ -18410,10 +18374,10 @@ function tryAcquireLock(lockPath, adapter) {
18410
18374
  function tryAcquireGlobalLaunchLock(home, adapter) {
18411
18375
  return tryAcquireLock(globalLaunchLockPath(home), adapter);
18412
18376
  }
18413
- function portReservationPath(port, home = homedir6()) {
18377
+ function portReservationPath(port, home = homedir7()) {
18414
18378
  return join8(frizzPaths({ home: canonicalHome(home) }).state, PORT_LOCK_DIR, `${port}.lock`);
18415
18379
  }
18416
- function tryReservePort(port, home = homedir6(), adapter = defaultProcessPlatformAdapter) {
18380
+ function tryReservePort(port, home = homedir7(), adapter = defaultProcessPlatformAdapter) {
18417
18381
  const lockPath = portReservationPath(port, home);
18418
18382
  const release = tryAcquireLock(lockPath, adapter);
18419
18383
  if (release) return release;
@@ -18426,7 +18390,7 @@ function lockTimeoutError(observation) {
18426
18390
  observation.ownerPid === void 0 ? "another Frizz launch still owns the global launch lock" : `another Frizz launch (pid ${observation.ownerPid}) still owns the global launch lock`
18427
18391
  );
18428
18392
  }
18429
- async function acquireGlobalLaunchLock(home = homedir6(), timeoutMs = 1e4, adapter = defaultProcessPlatformAdapter) {
18393
+ async function acquireGlobalLaunchLock(home = homedir7(), timeoutMs = 1e4, adapter = defaultProcessPlatformAdapter) {
18430
18394
  const deadline = Date.now() + Math.max(0, timeoutMs);
18431
18395
  let observation = { stale: false };
18432
18396
  for (; ; ) {
@@ -18594,7 +18558,7 @@ function resolvedIdentity(worktree) {
18594
18558
  }
18595
18559
  return { ...worktree, id: worktreeId };
18596
18560
  }
18597
- function resolveGitProjectIdentity(dir, home = homedir6()) {
18561
+ function resolveGitProjectIdentity(dir, home = homedir7()) {
18598
18562
  let worktree = resolveGitWorktree(dir);
18599
18563
  const existing = resolvedIdentity(worktree);
18600
18564
  if (existing) return existing;
@@ -18668,7 +18632,7 @@ import {
18668
18632
  openSync as openSync3,
18669
18633
  readFileSync as readFileSync7,
18670
18634
  renameSync as renameSync2,
18671
- rmSync as rmSync3,
18635
+ rmSync as rmSync4,
18672
18636
  statSync as statSync4,
18673
18637
  writeFileSync as writeFileSync6
18674
18638
  } from "node:fs";
@@ -18715,7 +18679,7 @@ function atomicJson(path, value) {
18715
18679
  }
18716
18680
  }
18717
18681
  try {
18718
- rmSync3(temp, { force: true });
18682
+ rmSync4(temp, { force: true });
18719
18683
  } catch {
18720
18684
  }
18721
18685
  throw error;
@@ -18729,7 +18693,7 @@ function quarantine(path, suffix) {
18729
18693
  return false;
18730
18694
  }
18731
18695
  try {
18732
- rmSync3(moved, { recursive: true, force: true });
18696
+ rmSync4(moved, { recursive: true, force: true });
18733
18697
  } catch {
18734
18698
  }
18735
18699
  return true;
@@ -19116,7 +19080,7 @@ var init_project_launch = __esm({
19116
19080
  });
19117
19081
 
19118
19082
  // packages/server/src/boot-progress.ts
19119
- import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync4, writeFileSync as writeFileSync7 } from "node:fs";
19083
+ import { mkdirSync as mkdirSync7, readFileSync as readFileSync8, renameSync as renameSync3, rmSync as rmSync5, writeFileSync as writeFileSync7 } from "node:fs";
19120
19084
  import { basename as basename3, dirname as dirname7, join as join10 } from "node:path";
19121
19085
  function bootProgressPath(stateDir) {
19122
19086
  return join10(stateDir, BOOT_PROGRESS_NAME);
@@ -19140,8 +19104,8 @@ var init_boot_progress = __esm({
19140
19104
 
19141
19105
  // packages/server/src/project-root.ts
19142
19106
  import { createHash as createHash3, randomUUID as randomUUID4 } from "node:crypto";
19143
- import { closeSync as closeSync3, existsSync as existsSync3, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync9, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync5, writeFileSync as writeFileSync8 } from "node:fs";
19144
- import { homedir as homedir7 } from "node:os";
19107
+ import { closeSync as closeSync3, existsSync as existsSync3, fsyncSync as fsyncSync3, mkdirSync as mkdirSync8, openSync as openSync4, readFileSync as readFileSync9, realpathSync as realpathSync3, renameSync as renameSync4, rmSync as rmSync6, writeFileSync as writeFileSync8 } from "node:fs";
19108
+ import { homedir as homedir8 } from "node:os";
19145
19109
  import { dirname as dirname8, join as join11, parse, resolve as resolve2 } from "node:path";
19146
19110
  function projectIdPath(root) {
19147
19111
  return join11(root, FRIZZ_DIR, ID_FILE);
@@ -19188,7 +19152,7 @@ function writeProjectIdFile(root, id) {
19188
19152
  }
19189
19153
  }
19190
19154
  try {
19191
- rmSync5(temp, { force: true });
19155
+ rmSync6(temp, { force: true });
19192
19156
  } catch {
19193
19157
  }
19194
19158
  throw error;
@@ -19198,7 +19162,7 @@ function writeProjectIdFile(root, id) {
19198
19162
  function projectRootLockName(root) {
19199
19163
  return `identity-path-${createHash3("sha256").update(root).digest("hex")}.lock`;
19200
19164
  }
19201
- function ensureProjectIdFile(root, home = homedir7(), seed) {
19165
+ function ensureProjectIdFile(root, home = homedir8(), seed) {
19202
19166
  const existing = readProjectIdFile(root);
19203
19167
  if (existing) return existing;
19204
19168
  const release = acquireNamedLaunchLockSync(home, projectRootLockName(root));
@@ -19220,7 +19184,7 @@ function isNotAGitWorktree(error) {
19220
19184
  function hasAny(dir, names) {
19221
19185
  return names.some((name) => existsSync3(join11(dir, name)));
19222
19186
  }
19223
- function isHomeDirectory(dir, home = homedir7()) {
19187
+ function isHomeDirectory(dir, home = homedir8()) {
19224
19188
  const canonical = (value) => {
19225
19189
  try {
19226
19190
  return realpathSync3(resolve2(value));
@@ -19249,7 +19213,7 @@ function isExistingProjectRoot(dir) {
19249
19213
  function hasProjectMarker(dir) {
19250
19214
  return hasAny(dir, REPO_MARKERS) || hasAny(dir, PROJECT_MARKERS);
19251
19215
  }
19252
- function discoverProjectRoot(cwd = process.cwd(), home = homedir7()) {
19216
+ function discoverProjectRoot(cwd = process.cwd(), home = homedir8()) {
19253
19217
  let dir;
19254
19218
  try {
19255
19219
  dir = resolve2(cwd);
@@ -19293,13 +19257,13 @@ var init_project_root = __esm({
19293
19257
  });
19294
19258
 
19295
19259
  // packages/server/src/project-registry.ts
19296
- import { closeSync as closeSync4, existsSync as existsSync4, fsyncSync as fsyncSync4, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync10, readdirSync as readdirSync2, realpathSync as realpathSync4, renameSync as renameSync5, rmSync as rmSync6, writeFileSync as writeFileSync9 } from "node:fs";
19297
- import { homedir as homedir8 } from "node:os";
19260
+ import { closeSync as closeSync4, existsSync as existsSync4, fsyncSync as fsyncSync4, mkdirSync as mkdirSync9, openSync as openSync5, readFileSync as readFileSync10, readdirSync as readdirSync2, realpathSync as realpathSync4, renameSync as renameSync5, rmSync as rmSync7, writeFileSync as writeFileSync9 } from "node:fs";
19261
+ import { homedir as homedir9 } from "node:os";
19298
19262
  import { basename as basename4, dirname as dirname9, join as join12 } from "node:path";
19299
- function registryPath(home = homedir8()) {
19263
+ function registryPath(home = homedir9()) {
19300
19264
  return join12(frizzPaths({ home }).data, "registry.json");
19301
19265
  }
19302
- function readRegistry(home = homedir8()) {
19266
+ function readRegistry(home = homedir9()) {
19303
19267
  let raw;
19304
19268
  try {
19305
19269
  raw = readFileSync10(registryPath(home), "utf8");
@@ -19319,7 +19283,7 @@ function readRegistry(home = homedir8()) {
19319
19283
  return { ...EMPTY, projects: [] };
19320
19284
  }
19321
19285
  }
19322
- function writeRegistry(registry, home = homedir8()) {
19286
+ function writeRegistry(registry, home = homedir9()) {
19323
19287
  const path = registryPath(home);
19324
19288
  mkdirSync9(dirname9(path), { recursive: true });
19325
19289
  const temp = `${path}.${process.pid}.tmp`;
@@ -19340,7 +19304,7 @@ function writeRegistry(registry, home = homedir8()) {
19340
19304
  }
19341
19305
  }
19342
19306
  try {
19343
- rmSync6(temp, { force: true });
19307
+ rmSync7(temp, { force: true });
19344
19308
  } catch {
19345
19309
  }
19346
19310
  throw error;
@@ -19375,7 +19339,7 @@ function canonicalPath(dir) {
19375
19339
  return dir;
19376
19340
  }
19377
19341
  }
19378
- function registerProject(input, home = homedir8()) {
19342
+ function registerProject(input, home = homedir9()) {
19379
19343
  input = { ...input, dir: canonicalPath(input.dir) };
19380
19344
  const registry = readRegistry(home);
19381
19345
  const at = (input.now ?? (() => /* @__PURE__ */ new Date()))().toISOString();
@@ -19410,7 +19374,7 @@ function registerProject(input, home = homedir8()) {
19410
19374
  writeRegistry(registry, home);
19411
19375
  return { entry, action: "created" };
19412
19376
  }
19413
- function listProjects(home = homedir8()) {
19377
+ function listProjects(home = homedir9()) {
19414
19378
  const byRecency = (a, b) => a.lastOpenedAt < b.lastOpenedAt ? 1 : a.lastOpenedAt > b.lastOpenedAt ? -1 : 0;
19415
19379
  return readRegistry(home).projects.map((p) => ({ ...p, stale: !existsSync4(p.path) })).sort((a, b) => {
19416
19380
  if (a.order === void 0 && b.order === void 0) return byRecency(a, b);
@@ -19419,7 +19383,7 @@ function listProjects(home = homedir8()) {
19419
19383
  return a.order - b.order;
19420
19384
  });
19421
19385
  }
19422
- function findByPath(path, home = homedir8()) {
19386
+ function findByPath(path, home = homedir9()) {
19423
19387
  const canonical = canonicalPath(path);
19424
19388
  return readRegistry(home).projects.find((p) => p.path === canonical || p.path === path);
19425
19389
  }
@@ -19475,7 +19439,7 @@ var init_project_registry = __esm({
19475
19439
  // src/production.ts
19476
19440
  init_local_origin();
19477
19441
  import { readFileSync as readFileSync13 } from "node:fs";
19478
- import { homedir as homedir10 } from "node:os";
19442
+ import { homedir as homedir11 } from "node:os";
19479
19443
  import { fileURLToPath as fileURLToPath2 } from "node:url";
19480
19444
  import { join as join15 } from "node:path";
19481
19445
 
@@ -19723,7 +19687,7 @@ function openCdpSession(browserPath, dataPath) {
19723
19687
  cmdPipe.on("error", failAll);
19724
19688
  resPipe.on("error", failAll);
19725
19689
  child.on("exit", () => failAll(new Error("chrome exited before the CDP handshake completed")));
19726
- const call = (method, params = {}, timeoutMs = 2e4) => new Promise((resolve5, reject) => {
19690
+ const call2 = (method, params = {}, timeoutMs = 2e4) => new Promise((resolve5, reject) => {
19727
19691
  const id = ++seq;
19728
19692
  pending.set(id, { resolve: resolve5, reject });
19729
19693
  cmdPipe.write(JSON.stringify({ id, method, params }) + "\0");
@@ -19735,7 +19699,7 @@ function openCdpSession(browserPath, dataPath) {
19735
19699
  }, timeoutMs).unref();
19736
19700
  });
19737
19701
  const close = async () => {
19738
- await call("Browser.close").catch(() => {
19702
+ await call2("Browser.close").catch(() => {
19739
19703
  });
19740
19704
  if (child.exitCode !== null || child.signalCode !== null) return;
19741
19705
  await new Promise((resolve5) => {
@@ -19750,7 +19714,7 @@ function openCdpSession(browserPath, dataPath) {
19750
19714
  });
19751
19715
  });
19752
19716
  };
19753
- return { call, close, kill: () => child.kill() };
19717
+ return { call: call2, close, kill: () => child.kill() };
19754
19718
  }
19755
19719
  async function cdpInstallPwa(browserPath, url, dataPath) {
19756
19720
  const manifestId = manifestIdFor(url);
@@ -19935,8 +19899,41 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
19935
19899
  import { dirname, join as join2 } from "node:path";
19936
19900
  var CODE_BYTES = 16;
19937
19901
  var SESSION_BYTES = 32;
19902
+ var SESSION_ID_BYTES = 6;
19938
19903
  var DEFAULT_CODE_TTL_MS = 5 * 6e4;
19939
19904
  var DEFAULT_SESSION_TTL_MS = 365 * 24 * 60 * 6e4;
19905
+ function memorySessionDirectory(seed = []) {
19906
+ const records = new Map(seed.map((r) => [r.id, r]));
19907
+ return {
19908
+ record: (r) => void records.set(r.id, r),
19909
+ isRevoked: (id) => records.get(id)?.revokedAt !== void 0,
19910
+ // Reverse FIRST, then sort. Array.sort is stable, so two devices that redeemed in the same
19911
+ // millisecond — which two taps on one page reliably are — keep newest-inserted-first instead of
19912
+ // falling back to insertion order and reading as oldest first.
19913
+ list: () => [...records.values()].reverse().sort((a, b) => b.createdAt - a.createdAt),
19914
+ revoke(id) {
19915
+ const found = records.get(id);
19916
+ if (!found || found.revokedAt !== void 0) return false;
19917
+ found.revokedAt = Date.now();
19918
+ return true;
19919
+ },
19920
+ revokeAll() {
19921
+ let n = 0;
19922
+ for (const r of records.values()) if (r.revokedAt === void 0) {
19923
+ r.revokedAt = Date.now();
19924
+ n++;
19925
+ }
19926
+ return n;
19927
+ }
19928
+ };
19929
+ }
19930
+ function describeDevice(userAgent) {
19931
+ const ua = userAgent ?? "";
19932
+ if (!ua.trim()) return "unknown device";
19933
+ const os = /iPhone/i.test(ua) ? "iPhone" : /iPad/i.test(ua) ? "iPad" : /Android/i.test(ua) ? "Android" : /Mac OS X|Macintosh/i.test(ua) ? "macOS" : /Windows/i.test(ua) ? "Windows" : /Linux/i.test(ua) ? "Linux" : "unknown device";
19934
+ const browser = /Edg\//i.test(ua) ? "Edge" : /OPR\/|Opera/i.test(ua) ? "Opera" : /Firefox\//i.test(ua) ? "Firefox" : /Chrome\//i.test(ua) ? "Chrome" : /Safari\//i.test(ua) ? "Safari" : "";
19935
+ return browser ? `${browser} on ${os}` : os;
19936
+ }
19940
19937
  function defaultRandomToken(bytes) {
19941
19938
  return randomBytes(bytes).toString("base64url");
19942
19939
  }
@@ -19945,13 +19942,15 @@ function secretsMatch(a, b) {
19945
19942
  const right = Buffer.from(b);
19946
19943
  return left.length === right.length && timingSafeEqual(left, right);
19947
19944
  }
19948
- var AccessStore = class {
19945
+ var AccessStore = class _AccessStore {
19949
19946
  codes = /* @__PURE__ */ new Map();
19950
19947
  options;
19948
+ sessions;
19951
19949
  /** Signing key for sessions. Persisted by the caller; a fresh one signs every device out. */
19952
19950
  signingKey;
19953
19951
  constructor(options2 = {}) {
19954
19952
  this.signingKey = options2.signingKey ?? randomBytes(32);
19953
+ this.sessions = options2.sessions ?? memorySessionDirectory();
19955
19954
  this.options = {
19956
19955
  codeTtlMs: options2.codeTtlMs ?? DEFAULT_CODE_TTL_MS,
19957
19956
  sessionTtlMs: options2.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS,
@@ -19980,7 +19979,7 @@ var AccessStore = class {
19980
19979
  * atomicity is what makes "single-use" true rather than aspirational — it is the first thing the
19981
19980
  * tests attack.
19982
19981
  */
19983
- redeem(code) {
19982
+ redeem(code, device) {
19984
19983
  if (!code) return { ok: false, reason: "unknown" };
19985
19984
  const stored = this.codes.get(code);
19986
19985
  if (!stored || !secretsMatch(stored.code, code)) return { ok: false, reason: "unknown" };
@@ -19993,18 +19992,32 @@ var AccessStore = class {
19993
19992
  stored.consumedAt = now;
19994
19993
  this.sweep();
19995
19994
  this.options.onConsumed?.(code);
19996
- return { ok: true, session: this.mintSession(now), expiresAt: now + this.options.sessionTtlMs };
19995
+ const minted = this.mintSession(now);
19996
+ this.sessions.record({ id: minted.id, label: device ?? "unknown device", createdAt: now });
19997
+ return { ok: true, session: minted.session, expiresAt: now + this.options.sessionTtlMs, id: minted.id };
19997
19998
  }
19998
19999
  /**
19999
- * A session is `<expiry>.<hmac>` — stateless, so it needs no server-side table, and signed with a key
20000
- * the caller persists so it survives a restart. Self-describing expiry means a stale cookie is
20001
- * rejected without a lookup, and rotating the key revokes every outstanding session at once.
20000
+ * A session is `<expiry>.<id>.<nonce>.<hmac>` — signed with a key the caller persists so it survives
20001
+ * a restart, and self-describing enough that a stale cookie is rejected without any lookup.
20002
+ *
20003
+ * The `id` is what makes ONE device signable-out. It is short because an operator types it into
20004
+ * `frizz --sign-out <id>`, and it is inside the signed payload so it cannot be swapped for another
20005
+ * device's. Rotating the key still revokes everything at once; this is the scalpel beside that axe.
20006
+ *
20007
+ * A session minted before ids existed has a two-part payload and still verifies — it simply has no id
20008
+ * to revoke individually. Upgrading must not sign every device out.
20002
20009
  */
20003
20010
  mintSession(now) {
20004
20011
  const expiresAt = now + this.options.sessionTtlMs;
20012
+ const id = this.options.randomToken(SESSION_ID_BYTES);
20005
20013
  const nonce = this.options.randomToken(SESSION_BYTES);
20006
- const payload = `${expiresAt}.${nonce}`;
20007
- return `${payload}.${this.sign(payload)}`;
20014
+ const payload = `${expiresAt}.${id}.${nonce}`;
20015
+ return { session: `${payload}.${this.sign(payload)}`, id };
20016
+ }
20017
+ /** The id inside a session payload, or null for a legacy two-part one. */
20018
+ static idOf(payload) {
20019
+ const parts = payload.split(".");
20020
+ return parts.length >= 3 ? parts[1] ?? null : null;
20008
20021
  }
20009
20022
  sign(payload) {
20010
20023
  return createHmac("sha256", this.signingKey).update(payload).digest("base64url");
@@ -20018,7 +20031,9 @@ var AccessStore = class {
20018
20031
  const signature = session.slice(cut + 1);
20019
20032
  if (!secretsMatch(signature, this.sign(payload))) return false;
20020
20033
  const expiresAt = Number(payload.slice(0, payload.indexOf(".")));
20021
- return Number.isFinite(expiresAt) && this.options.now() < expiresAt;
20034
+ if (!Number.isFinite(expiresAt) || this.options.now() >= expiresAt) return false;
20035
+ const id = _AccessStore.idOf(payload);
20036
+ return !(id && this.sessions.isRevoked(id));
20022
20037
  }
20023
20038
  /**
20024
20039
  * Drop codes past their expiry so a long-lived board does not accumulate them forever.
@@ -20040,6 +20055,46 @@ var AccessStore = class {
20040
20055
  return [...this.codes.values()].filter((stored) => stored.consumedAt === void 0).map(({ code, createdAt, expiresAt }) => ({ code, createdAt, expiresAt }));
20041
20056
  }
20042
20057
  };
20058
+ function fileSessionDirectory(stateDir, now = Date.now) {
20059
+ const path = join2(stateDir, "sessions.json");
20060
+ let records = [];
20061
+ try {
20062
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
20063
+ if (Array.isArray(parsed)) {
20064
+ records = parsed.filter(
20065
+ (r) => !!r && typeof r.id === "string" && typeof r.createdAt === "number"
20066
+ );
20067
+ }
20068
+ } catch {
20069
+ }
20070
+ const inner = memorySessionDirectory(records);
20071
+ const flush = () => {
20072
+ try {
20073
+ mkdirSync(dirname(path), { recursive: true });
20074
+ writeFileSync(path, JSON.stringify(inner.list()), { mode: 384 });
20075
+ } catch (error) {
20076
+ if (process.env.FRIZZ_DEBUG_SESSIONS) console.error("[sessions] flush failed:", error);
20077
+ }
20078
+ };
20079
+ return {
20080
+ record(r) {
20081
+ inner.record(r);
20082
+ flush();
20083
+ },
20084
+ isRevoked: (id) => inner.isRevoked(id),
20085
+ list: () => inner.list(),
20086
+ revoke(id) {
20087
+ const ok = inner.revoke(id);
20088
+ if (ok) flush();
20089
+ return ok;
20090
+ },
20091
+ revokeAll() {
20092
+ const n = inner.revokeAll();
20093
+ if (n > 0) flush();
20094
+ return n;
20095
+ }
20096
+ };
20097
+ }
20043
20098
  function loadOrCreateSessionKey(stateDir) {
20044
20099
  const path = join2(stateDir, "session-key");
20045
20100
  try {
@@ -21746,6 +21801,7 @@ var SUPERVISOR_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/restart`;
21746
21801
  var SUPERVISOR_UPDATE_RESTART_PATH = `${SUPERVISOR_CONTROL_PREFIX}/update-restart`;
21747
21802
  var SUPERVISOR_STATUS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/status`;
21748
21803
  var SUPERVISOR_ACCESS_CODE_PATH = `${SUPERVISOR_CONTROL_PREFIX}/access-code`;
21804
+ var SUPERVISOR_SESSIONS_PATH = `${SUPERVISOR_CONTROL_PREFIX}/sessions`;
21749
21805
  var SUPERVISOR_CONTROL_PROTOCOL = 1;
21750
21806
  function responseJson(res, status, value) {
21751
21807
  res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
@@ -21786,7 +21842,7 @@ function unlistedHostPage(name) {
21786
21842
  }
21787
21843
  function isControlRequest(req) {
21788
21844
  const url = new URL(req.url ?? "/", "http://frizz.invalid");
21789
- return url.pathname === SUPERVISOR_RESTART_PATH || url.pathname === SUPERVISOR_UPDATE_RESTART_PATH || url.pathname === SUPERVISOR_STATUS_PATH || url.pathname === SUPERVISOR_ACCESS_CODE_PATH;
21845
+ return url.pathname === SUPERVISOR_RESTART_PATH || url.pathname === SUPERVISOR_UPDATE_RESTART_PATH || url.pathname === SUPERVISOR_STATUS_PATH || url.pathname === SUPERVISOR_ACCESS_CODE_PATH || url.pathname === SUPERVISOR_SESSIONS_PATH;
21790
21846
  }
21791
21847
  function isLocalImageRequest(req) {
21792
21848
  if (req.method !== "GET" && req.method !== "HEAD") return false;
@@ -21794,6 +21850,22 @@ function isLocalImageRequest(req) {
21794
21850
  return LOCAL_IMAGE_PATH.test(pathname);
21795
21851
  }
21796
21852
  var LOCAL_IMAGE_PATH = new RegExp(`^${FRIZZ_ROUTE_PREFIX}(?:/[^/]+)?/local-image$`);
21853
+ async function readJsonBody(req, limit = 64 * 1024) {
21854
+ const chunks = [];
21855
+ let size = 0;
21856
+ for await (const chunk of req) {
21857
+ size += chunk.byteLength;
21858
+ if (size > limit) return null;
21859
+ chunks.push(chunk);
21860
+ }
21861
+ if (chunks.length === 0) return {};
21862
+ try {
21863
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
21864
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
21865
+ } catch {
21866
+ return null;
21867
+ }
21868
+ }
21797
21869
  var RestartSupervisorProxy = class {
21798
21870
  server = null;
21799
21871
  restartInFlight = null;
@@ -21808,6 +21880,8 @@ var RestartSupervisorProxy = class {
21808
21880
  * judged here first.
21809
21881
  */
21810
21882
  policy;
21883
+ /** The origin a tunnel or proxy serves this board at; undefined while loopback-only. Live: see setPublicOrigin. */
21884
+ publicOrigin;
21811
21885
  /**
21812
21886
  * Codes and sessions for the public origin, or null when no public origin is declared (loopback-only
21813
21887
  * boards are never gated, so there is nothing to store).
@@ -21818,14 +21892,28 @@ var RestartSupervisorProxy = class {
21818
21892
  constructor(options2) {
21819
21893
  this.options = options2;
21820
21894
  this.host = options2.host ?? LOOPBACK_BIND_HOST;
21821
- this.access = options2.publicOrigin ? new AccessStore({
21895
+ this.publicOrigin = options2.publicOrigin;
21896
+ this.access = new AccessStore({
21822
21897
  onConsumed: () => options2.onCodeConsumed?.(),
21823
- ...options2.sessionKey ? { signingKey: options2.sessionKey } : {}
21824
- }) : null;
21825
- this.policy = {
21898
+ ...options2.sessionKey ? { signingKey: options2.sessionKey } : {},
21899
+ ...options2.sessionDirectory ? { sessions: options2.sessionDirectory } : {}
21900
+ });
21901
+ this.policy = this.buildPolicy();
21902
+ }
21903
+ /**
21904
+ * Change the public origin on a running board — what the R pane does when the operator picks or
21905
+ * clears a remote setup. Requests already in flight finish under the policy they started with; the
21906
+ * next one is judged by the new origin. Sessions survive the change.
21907
+ */
21908
+ setPublicOrigin(origin) {
21909
+ this.publicOrigin = origin;
21910
+ this.policy = this.buildPolicy();
21911
+ }
21912
+ buildPolicy() {
21913
+ return {
21826
21914
  exposed: bindHostIsExposed(this.host),
21827
- allowedHosts: options2.allowedHosts ?? [],
21828
- ...options2.publicOrigin ? { publicOrigin: options2.publicOrigin } : {}
21915
+ allowedHosts: this.options.allowedHosts ?? [],
21916
+ ...this.publicOrigin ? { publicOrigin: this.publicOrigin } : {}
21829
21917
  };
21830
21918
  }
21831
21919
  /**
@@ -21835,11 +21923,11 @@ var RestartSupervisorProxy = class {
21835
21923
  * asking at once, must not silently break the first QR somebody is still walking towards.
21836
21924
  */
21837
21925
  issueAccessCode() {
21838
- return this.access?.issue() ?? null;
21926
+ return this.publicOrigin ? this.access.issue() : null;
21839
21927
  }
21840
21928
  /** The full URL to show as a link or a QR. */
21841
21929
  accessUrl(code) {
21842
- const origin = this.options.publicOrigin;
21930
+ const origin = this.publicOrigin;
21843
21931
  return origin ? `${origin}/?${ACCESS_CODE_PARAM}=${encodeURIComponent(code)}` : null;
21844
21932
  }
21845
21933
  async listen() {
@@ -21980,6 +22068,42 @@ var RestartSupervisorProxy = class {
21980
22068
  responseJson(res, 200, { url, expiresAt: link.expiresAt });
21981
22069
  return;
21982
22070
  }
22071
+ if (pathname === SUPERVISOR_SESSIONS_PATH) {
22072
+ if (this.arrivedPublicly(req)) {
22073
+ res.writeHead(403);
22074
+ res.end("Forbidden");
22075
+ return;
22076
+ }
22077
+ if (!this.publicOrigin) {
22078
+ responseJson(res, 409, { error: "this board has no public origin, so nothing is signed in" });
22079
+ return;
22080
+ }
22081
+ if (req.method === "GET") {
22082
+ responseJson(res, 200, { sessions: this.access.sessions.list() });
22083
+ return;
22084
+ }
22085
+ if (req.method !== "POST") {
22086
+ res.writeHead(405, { allow: "GET, POST" });
22087
+ res.end();
22088
+ return;
22089
+ }
22090
+ const body = await readJsonBody(req);
22091
+ const id = typeof body?.id === "string" ? body.id : void 0;
22092
+ if (body?.all === true) {
22093
+ responseJson(res, 200, { signedOut: this.access.sessions.revokeAll() });
22094
+ return;
22095
+ }
22096
+ if (!id) {
22097
+ responseJson(res, 400, { error: "name a device id, or pass all: true" });
22098
+ return;
22099
+ }
22100
+ if (!this.access.sessions.revoke(id)) {
22101
+ responseJson(res, 404, { error: `no signed-in device has the id ${id}` });
22102
+ return;
22103
+ }
22104
+ responseJson(res, 200, { signedOut: 1 });
22105
+ return;
22106
+ }
21983
22107
  if (pathname === SUPERVISOR_STATUS_PATH && req.method === "GET") {
21984
22108
  responseJson(res, 200, { protocol: SUPERVISOR_CONTROL_PROTOCOL, ...this.status() });
21985
22109
  return;
@@ -22036,7 +22160,7 @@ var RestartSupervisorProxy = class {
22036
22160
  * a tunnelled board stays bound to 127.0.0.1 while being reachable from the internet by name.
22037
22161
  */
22038
22162
  arrivedPublicly(req) {
22039
- const declared = this.options.publicOrigin;
22163
+ const declared = this.publicOrigin;
22040
22164
  if (!declared) return false;
22041
22165
  const host = parseLocalHost(req.headers.host, this.options.port, this.policy);
22042
22166
  return !!host && !LOOPBACK_HOSTNAMES.has(host.hostname);
@@ -22056,7 +22180,7 @@ var RestartSupervisorProxy = class {
22056
22180
  * staying in somebody's URL bar.
22057
22181
  */
22058
22182
  sessionAccepted(req) {
22059
- if (!this.access) return true;
22183
+ if (!this.publicOrigin) return true;
22060
22184
  return this.access.verifySession(readCookie(req.headers.cookie, SESSION_COOKIE));
22061
22185
  }
22062
22186
  /**
@@ -22067,7 +22191,7 @@ var RestartSupervisorProxy = class {
22067
22191
  const url = new URL(req.url ?? "/", "http://frizz.invalid");
22068
22192
  const code = url.searchParams.get(ACCESS_CODE_PARAM);
22069
22193
  if (code === null) return false;
22070
- const redeemed = this.access?.redeem(code);
22194
+ const redeemed = this.access.redeem(code, describeDevice(req.headers["user-agent"]));
22071
22195
  if (!redeemed?.ok) {
22072
22196
  res.writeHead(401, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
22073
22197
  res.end(unauthorizedPage(redeemed?.reason));
@@ -22171,66 +22295,78 @@ var RestartSupervisorProxy = class {
22171
22295
  }
22172
22296
  };
22173
22297
 
22174
- // src/access-pane.ts
22175
- var ALT_SCREEN_ON = "\x1B[?1049h";
22176
- var ALT_SCREEN_OFF = "\x1B[?1049l";
22177
- var HIDE_CURSOR = "\x1B[?25l";
22178
- var SHOW_CURSOR = "\x1B[?25h";
22179
- var CLEAR = "\x1B[2J\x1B[H";
22180
- var DIM = "\x1B[2m";
22181
- var RESET2 = "\x1B[0m";
22182
- var OPEN_KEYS = /* @__PURE__ */ new Set(["l", "L"]);
22183
- var CTRL_C = "";
22184
- function secondsUntil(expiresAt, now) {
22185
- return Math.max(0, Math.ceil((expiresAt - now) / 1e3));
22298
+ // src/sessions-cli.ts
22299
+ var ago = (at, now = Date.now()) => {
22300
+ const seconds = Math.max(0, Math.round((now - at) / 1e3));
22301
+ if (seconds < 90) return "just now";
22302
+ const minutes = Math.round(seconds / 60);
22303
+ if (minutes < 90) return `${minutes}m ago`;
22304
+ const hours = Math.round(minutes / 60);
22305
+ if (hours < 36) return `${hours}h ago`;
22306
+ return `${Math.round(hours / 24)}d ago`;
22307
+ };
22308
+ async function call(port, init) {
22309
+ const response = await fetch(`http://127.0.0.1:${port}${SUPERVISOR_SESSIONS_PATH}`, {
22310
+ ...init,
22311
+ headers: { origin: `http://127.0.0.1:${port}`, ...init?.body ? { "content-type": "application/json" } : {}, ...init?.headers }
22312
+ }).catch(() => void 0);
22313
+ if (!response) {
22314
+ console.error("frizz: the running board did not answer");
22315
+ process.exit(1);
22316
+ }
22317
+ const body = await response.json().catch(() => void 0);
22318
+ if (!response.ok) {
22319
+ console.error(`frizz: ${body?.error ?? `the board answered ${response.status}`}`);
22320
+ process.exit(1);
22321
+ }
22322
+ return body;
22186
22323
  }
22187
- function installAccessPane(options2) {
22324
+ async function listSessions(port) {
22325
+ const body = await call(port);
22326
+ const sessions = body?.sessions ?? [];
22327
+ const live = sessions.filter((s) => s.revokedAt === void 0);
22328
+ if (live.length === 0) {
22329
+ console.log("No device has redeemed an access link.");
22330
+ process.exit(0);
22331
+ }
22332
+ const width = Math.max(...live.map((s) => s.id.length));
22333
+ for (const session of live) {
22334
+ console.log(` ${session.id.padEnd(width)} ${session.label} ${ago(session.createdAt)}`);
22335
+ }
22336
+ console.log(`
22337
+ frizz --sign-out <id> sign one out
22338
+ frizz --sign-out all sign every device out`);
22339
+ process.exit(0);
22340
+ }
22341
+ async function signOutSession(port, target2) {
22342
+ const all = target2 === "all";
22343
+ const body = await call(port, {
22344
+ method: "POST",
22345
+ body: JSON.stringify(all ? { all: true } : { id: target2 })
22346
+ });
22347
+ const count = Number(body?.signedOut ?? 0);
22348
+ if (all) {
22349
+ console.log(count === 0 ? "No device was signed in." : `Signed out ${count} device${count === 1 ? "" : "s"}.`);
22350
+ } else {
22351
+ console.log(`Signed out ${target2}.`);
22352
+ }
22353
+ process.exit(0);
22354
+ }
22355
+
22356
+ // src/pane-host.ts
22357
+ var CTRL_C = "";
22358
+ function installPaneHost(options2) {
22188
22359
  const input = options2.input ?? process.stdin;
22189
22360
  const output = options2.output ?? process.stdout;
22190
- const now = options2.now ?? Date.now;
22191
22361
  const onInterrupt = options2.onInterrupt ?? (() => process.kill(process.pid, "SIGINT"));
22192
22362
  if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function") return null;
22193
- let open = false;
22363
+ let active = null;
22194
22364
  let disposed = false;
22195
- let shown = null;
22196
- let consumed = false;
22197
- const paint = () => {
22198
- if (!shown) return;
22199
- const remaining = secondsUntil(shown.expiresAt, now());
22200
- const status = consumed ? "This link has been used. Press L for another." : remaining === 0 ? "This link has expired. Press L for another." : `Single use, expires in ${remaining}s.`;
22201
- output.write(CLEAR);
22202
- output.write("\n");
22203
- for (const row of renderQrLines(shown.url)) output.write(` ${row}
22204
- `);
22205
- output.write(`
22206
- ${shown.url}
22207
- `);
22208
- output.write(`
22209
- ${DIM}${status} Press any other key to close.${RESET2}
22210
- `);
22211
- };
22212
- let ticker;
22213
- const close = () => {
22214
- if (!open) return;
22215
- open = false;
22216
- shown = null;
22217
- consumed = false;
22218
- if (ticker) clearInterval(ticker);
22219
- ticker = void 0;
22220
- output.write(ALT_SCREEN_OFF);
22221
- output.write(SHOW_CURSOR);
22222
- };
22223
- const openPane = () => {
22224
- const link = options2.issue();
22225
- if (!link) return;
22226
- shown = link;
22227
- consumed = false;
22228
- open = true;
22229
- output.write(ALT_SCREEN_ON);
22230
- output.write(HIDE_CURSOR);
22231
- paint();
22232
- ticker = setInterval(paint, 1e3);
22233
- ticker.unref?.();
22365
+ const closeActive = () => {
22366
+ if (!active) return;
22367
+ const pane = active;
22368
+ active = null;
22369
+ pane.close();
22234
22370
  };
22235
22371
  const onKey = (chunk) => {
22236
22372
  const key = chunk.toString();
@@ -22239,16 +22375,17 @@ function installAccessPane(options2) {
22239
22375
  onInterrupt();
22240
22376
  return;
22241
22377
  }
22242
- if (open) {
22243
- close();
22378
+ if (active) {
22379
+ if (active.key(key) === "close") closeActive();
22244
22380
  return;
22245
22381
  }
22246
- if (OPEN_KEYS.has(key)) openPane();
22382
+ const pane = options2.bindings[key];
22383
+ if (pane && pane.open()) active = pane;
22247
22384
  };
22248
22385
  const restore = () => {
22249
22386
  if (disposed) return;
22250
22387
  disposed = true;
22251
- close();
22388
+ closeActive();
22252
22389
  input.off("data", onKey);
22253
22390
  try {
22254
22391
  input.setRawMode?.(false);
@@ -22262,9 +22399,70 @@ function installAccessPane(options2) {
22262
22399
  input.setEncoding("utf8");
22263
22400
  input.on("data", onKey);
22264
22401
  process.on("exit", restore);
22402
+ return { dispose: restore, isOpen: () => active !== null };
22403
+ }
22404
+
22405
+ // src/access-pane.ts
22406
+ var ALT_SCREEN_ON = "\x1B[?1049h";
22407
+ var ALT_SCREEN_OFF = "\x1B[?1049l";
22408
+ var HIDE_CURSOR = "\x1B[?25l";
22409
+ var SHOW_CURSOR = "\x1B[?25h";
22410
+ var CLEAR = "\x1B[2J\x1B[H";
22411
+ var DIM = "\x1B[2m";
22412
+ var RESET2 = "\x1B[0m";
22413
+ function secondsUntil(expiresAt, now) {
22414
+ return Math.max(0, Math.ceil((expiresAt - now) / 1e3));
22415
+ }
22416
+ function createAccessPane(options2) {
22417
+ const output = options2.output ?? process.stdout;
22418
+ const now = options2.now ?? Date.now;
22419
+ let open = false;
22420
+ let shown = null;
22421
+ let consumed = false;
22422
+ let ticker;
22423
+ const paint = () => {
22424
+ if (!shown) return;
22425
+ const remaining = secondsUntil(shown.expiresAt, now());
22426
+ const status = consumed ? "This link has been used. Press L for another." : remaining === 0 ? "This link has expired. Press L for another." : `Single use, expires in ${remaining}s.`;
22427
+ output.write(CLEAR);
22428
+ output.write("\n");
22429
+ for (const row of renderQrLines(shown.url)) output.write(` ${row}
22430
+ `);
22431
+ output.write(`
22432
+ ${shown.url}
22433
+ `);
22434
+ output.write(`
22435
+ ${DIM}${status} Press any other key to close.${RESET2}
22436
+ `);
22437
+ };
22265
22438
  return {
22266
- dispose: restore,
22267
- markConsumed: () => {
22439
+ open() {
22440
+ const link = options2.issue();
22441
+ if (!link) return false;
22442
+ shown = link;
22443
+ consumed = false;
22444
+ open = true;
22445
+ output.write(ALT_SCREEN_ON);
22446
+ output.write(HIDE_CURSOR);
22447
+ paint();
22448
+ ticker = setInterval(paint, 1e3);
22449
+ ticker.unref?.();
22450
+ return true;
22451
+ },
22452
+ key() {
22453
+ return "close";
22454
+ },
22455
+ close() {
22456
+ if (!open) return;
22457
+ open = false;
22458
+ shown = null;
22459
+ consumed = false;
22460
+ if (ticker) clearInterval(ticker);
22461
+ ticker = void 0;
22462
+ output.write(ALT_SCREEN_OFF);
22463
+ output.write(SHOW_CURSOR);
22464
+ },
22465
+ markConsumed() {
22268
22466
  if (!open) return;
22269
22467
  consumed = true;
22270
22468
  paint();
@@ -22272,10 +22470,13 @@ function installAccessPane(options2) {
22272
22470
  };
22273
22471
  }
22274
22472
 
22473
+ // src/remote-controller.ts
22474
+ import { homedir as homedir4 } from "node:os";
22475
+
22275
22476
  // src/cloud.ts
22276
22477
  init_src();
22277
22478
  import { spawn as spawn2 } from "node:child_process";
22278
- import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
22479
+ import { existsSync, mkdirSync as mkdirSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3, rmSync } from "node:fs";
22279
22480
  import { createInterface } from "node:readline/promises";
22280
22481
  import { homedir as homedir3 } from "node:os";
22281
22482
  import { dirname as dirname3, join as join4 } from "node:path";
@@ -22604,6 +22805,7 @@ var b64url = (bytes) => {
22604
22805
  for (const byte of view) binary += String.fromCharCode(byte);
22605
22806
  return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
22606
22807
  };
22808
+ var SETTLED_MS = 1e4;
22607
22809
  function defaultBackoff(attempt, random = Math.random) {
22608
22810
  const base = Math.min(3e4, 500 * 2 ** Math.min(attempt, 6));
22609
22811
  return Math.round(base / 2 + random() * (base / 2));
@@ -22653,8 +22855,11 @@ function connectRelay(options2) {
22653
22855
  const url = `${options2.relayOrigin.replace(/^http/, "ws").replace(/\/$/, "")}/_relay/connect?h=${encoded}`;
22654
22856
  const next = makeSocket(url);
22655
22857
  socket = next;
22858
+ let settled;
22656
22859
  next.addEventListener("open", () => {
22657
- attempt = 0;
22860
+ settled = setTimer(() => {
22861
+ attempt = 0;
22862
+ }, SETTLED_MS);
22658
22863
  options2.onStatus?.("connected");
22659
22864
  });
22660
22865
  next.addEventListener("message", (event) => {
@@ -22695,6 +22900,10 @@ function connectRelay(options2) {
22695
22900
  });
22696
22901
  const dropped = () => {
22697
22902
  if (socket !== next) return;
22903
+ if (settled !== void 0) {
22904
+ clearTimer(settled);
22905
+ settled = void 0;
22906
+ }
22698
22907
  socket = null;
22699
22908
  for (const session of nested.values()) session.close();
22700
22909
  nested.clear();
@@ -22724,6 +22933,14 @@ function connectRelay(options2) {
22724
22933
  }
22725
22934
 
22726
22935
  // src/cloud.ts
22936
+ function isExternalConfig(config) {
22937
+ return config.serve === "external";
22938
+ }
22939
+ function describeCloudConfig(config) {
22940
+ if (isClaimedConfig(config)) return "frizz.sh";
22941
+ if (isExternalConfig(config)) return config.provider === "tailscale" ? "Tailscale" : "your proxy";
22942
+ return "Cloudflare Tunnel";
22943
+ }
22727
22944
  var REGISTRAR_IS_LIVE = true;
22728
22945
  function isClaimedConfig(config) {
22729
22946
  return typeof config.claim === "string" && config.claim.length > 0;
@@ -22731,6 +22948,22 @@ function isClaimedConfig(config) {
22731
22948
  function isRelayConfig(config) {
22732
22949
  return isClaimedConfig(config) && config.serve === "relay";
22733
22950
  }
22951
+ var CLAIM_ZONE = "frizz.sh";
22952
+ function zoneClaimLabel(hostname) {
22953
+ const suffix = `.${CLAIM_ZONE}`;
22954
+ if (!hostname.endsWith(suffix)) return null;
22955
+ const label = hostname.slice(0, -suffix.length);
22956
+ return label.length > 0 && !label.includes(".") ? label : null;
22957
+ }
22958
+ async function reconcileCloudConfig(config, port, home = homedir3(), onNotice, origin, github = githubCli) {
22959
+ if (isRelayConfig(config)) return config;
22960
+ const label = config.claim ?? zoneClaimLabel(config.hostname);
22961
+ if (!label) return config;
22962
+ onNotice?.(
22963
+ isClaimedConfig(config) ? `${config.hostname} is served by the Frizz relay now; moving this board off its tunnel` : `${config.hostname} is inside ${CLAIM_ZONE}, where names are claimed, not tunnelled; claiming ${label}`
22964
+ );
22965
+ return establishCloudConfig(label, port, home, origin, github);
22966
+ }
22734
22967
  function cloudConfigPath(home = homedir3()) {
22735
22968
  return join4(home, ".frizz", "cloud.json");
22736
22969
  }
@@ -22740,12 +22973,14 @@ function readCloudConfig(home = homedir3()) {
22740
22973
  if (typeof parsed.hostname !== "string" || !parsed.hostname) return null;
22741
22974
  const claimed = typeof parsed.claim === "string" && parsed.claim.length > 0;
22742
22975
  const named = typeof parsed.tunnel === "string" && parsed.tunnel.length > 0;
22743
- if (!claimed && !named) return null;
22976
+ const external = parsed.serve === "external";
22977
+ if (!claimed && !named && !external) return null;
22744
22978
  return {
22745
22979
  hostname: parsed.hostname,
22746
22980
  ...named ? { tunnel: parsed.tunnel } : {},
22747
22981
  ...claimed ? { claim: parsed.claim } : {},
22748
- ...parsed.serve === "relay" || parsed.serve === "tunnel" ? { serve: parsed.serve } : {},
22982
+ ...parsed.serve === "relay" || parsed.serve === "tunnel" || external ? { serve: parsed.serve } : {},
22983
+ ...external && (parsed.provider === "tailscale" || parsed.provider === "other") ? { provider: parsed.provider } : {},
22749
22984
  ...parsed.config ? { config: parsed.config } : {}
22750
22985
  };
22751
22986
  } catch {
@@ -22769,6 +23004,9 @@ function writeTunnelToken(token, home = homedir3()) {
22769
23004
  writeFileSync3(path, `${token}
22770
23005
  `, { mode: 384 });
22771
23006
  }
23007
+ function deleteCloudConfig(home = homedir3()) {
23008
+ rmSync(cloudConfigPath(home), { force: true });
23009
+ }
22772
23010
  function writeCloudConfig(config, home = homedir3()) {
22773
23011
  const path = cloudConfigPath(home);
22774
23012
  mkdirSync3(dirname3(path), { recursive: true });
@@ -22782,19 +23020,6 @@ function normalizeHostname(raw) {
22782
23020
  }
22783
23021
  return trimmed.toLowerCase();
22784
23022
  }
22785
- async function promptForCloudName() {
22786
- if (!process.stdin.isTTY) {
22787
- throw new Error(
22788
- "--cloud needs a saved name when there is no terminal to ask \u2014 run it once interactively first"
22789
- );
22790
- }
22791
- const rl = createInterface({ input: process.stdin, output: process.stdout });
22792
- try {
22793
- return (await rl.question("Name for this board \u2014 a word claims <name>.frizz.sh, or paste a hostname you already run a tunnel for: ")).trim();
22794
- } finally {
22795
- rl.close();
22796
- }
22797
- }
22798
23023
  async function promptForTunnelName(hostname, fallback) {
22799
23024
  if (!process.stdin.isTTY) return fallback;
22800
23025
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -22808,8 +23033,8 @@ async function promptForTunnelName(hostname, fallback) {
22808
23033
  async function establishCloudConfig(answer, port, home = homedir3(), origin, github = githubCli) {
22809
23034
  if (answer.includes(".")) {
22810
23035
  const hostname = normalizeHostname(answer);
22811
- const tunnel2 = await promptForTunnelName(hostname, hostname.split(".")[0]);
22812
- return { hostname, tunnel: tunnel2 };
23036
+ const tunnel = await promptForTunnelName(hostname, hostname.split(".")[0]);
23037
+ return { hostname, tunnel };
22813
23038
  }
22814
23039
  if (!REGISTRAR_IS_LIVE && !origin && !process.env.FRIZZ_REGISTRAR) {
22815
23040
  throw new Error(
@@ -22912,6 +23137,421 @@ function startTunnel(config, onExit, onError, home = homedir3(), runToken) {
22912
23137
  };
22913
23138
  }
22914
23139
 
23140
+ // src/remote-controller.ts
23141
+ function createRemoteController(options2) {
23142
+ const home = options2.home ?? homedir4();
23143
+ let config = null;
23144
+ let transport = null;
23145
+ const stopTransport = () => {
23146
+ transport?.stop();
23147
+ transport = null;
23148
+ };
23149
+ const startTransport = async (next, justClaimed) => {
23150
+ if (isExternalConfig(next)) return;
23151
+ if (isRelayConfig(next)) {
23152
+ if (!justClaimed) {
23153
+ await resolveRunToken(next, options2.port, home, (message) => {
23154
+ options2.log.warn("relay", message);
23155
+ options2.say(message);
23156
+ });
23157
+ }
23158
+ transport = await startRelay(next, options2.port, home, (message) => options2.log.info("relay", message));
23159
+ options2.log.info("relay", `serving ${next.hostname} through the Frizz relay`);
23160
+ return;
23161
+ }
23162
+ const runToken = isClaimedConfig(next) ? justClaimed ? readTunnelToken(home) : await resolveRunToken(next, options2.port, home, (message) => {
23163
+ options2.log.warn("tunnel", message);
23164
+ options2.say(message);
23165
+ }) : null;
23166
+ transport = startTunnel(
23167
+ next,
23168
+ (code) => {
23169
+ if (code === 0 || code === null) return;
23170
+ options2.log.error("tunnel", `cloudflared exited with code ${code}; the public hostname is now unreachable`);
23171
+ },
23172
+ (message) => {
23173
+ options2.log.error("tunnel", message);
23174
+ options2.say(message);
23175
+ },
23176
+ home,
23177
+ runToken ?? void 0
23178
+ );
23179
+ options2.log.info("tunnel", `running cloudflared for ${next.hostname}`);
23180
+ };
23181
+ const originOf = (next) => next ? `https://${next.hostname}` : void 0;
23182
+ return {
23183
+ current: () => config,
23184
+ origin: () => originOf(config),
23185
+ async serveSaved() {
23186
+ const saved = readCloudConfig(home);
23187
+ if (!saved) return;
23188
+ const reconciled = await reconcileCloudConfig(saved, options2.port, home, (message) => {
23189
+ options2.log.warn("cloud", message);
23190
+ options2.say(message);
23191
+ });
23192
+ const justClaimed = reconciled !== saved;
23193
+ if (justClaimed) writeCloudConfig(reconciled, home);
23194
+ await startTransport(reconciled, justClaimed);
23195
+ config = reconciled;
23196
+ options2.host.setPublicOrigin(originOf(reconciled));
23197
+ },
23198
+ async apply(next, applyOptions = {}) {
23199
+ stopTransport();
23200
+ options2.host.setPublicOrigin(void 0);
23201
+ if (!next) {
23202
+ deleteCloudConfig(home);
23203
+ config = null;
23204
+ return;
23205
+ }
23206
+ writeCloudConfig(next, home);
23207
+ try {
23208
+ await startTransport(next, applyOptions.justClaimed ?? false);
23209
+ } catch (error) {
23210
+ config = null;
23211
+ throw error;
23212
+ }
23213
+ config = next;
23214
+ options2.host.setPublicOrigin(originOf(next));
23215
+ },
23216
+ stop: stopTransport
23217
+ };
23218
+ }
23219
+
23220
+ // src/remote-detect.ts
23221
+ import { execFile as execFile3 } from "node:child_process";
23222
+ var PROBE_TIMEOUT_MS = 4e3;
23223
+ function run2(command, args2) {
23224
+ return new Promise((resolve5) => {
23225
+ execFile3(command, args2, { encoding: "utf8", timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
23226
+ resolve5(error ? null : stdout);
23227
+ });
23228
+ });
23229
+ }
23230
+ async function probeGithub() {
23231
+ const version = await run2("gh", ["--version"]);
23232
+ if (version === null) return { login: null, installed: false };
23233
+ const login = await run2("gh", ["api", "user", "--jq", ".login"]);
23234
+ return { login: login?.trim() || null, installed: true };
23235
+ }
23236
+ async function probeCloudflared() {
23237
+ const out = await run2("cloudflared", ["--version"]);
23238
+ const version = out?.match(/\d+\.\d+\.\d+/)?.[0] ?? (out ? out.trim() : null);
23239
+ return { version };
23240
+ }
23241
+ async function probeTailscale() {
23242
+ const out = await run2("tailscale", ["status", "--json"]);
23243
+ if (out === null) return { installed: await run2("tailscale", ["--version"]) !== null, dnsName: null };
23244
+ try {
23245
+ const status = JSON.parse(out);
23246
+ const dnsName = status.Self?.DNSName?.replace(/\.$/, "") || null;
23247
+ return { installed: true, dnsName };
23248
+ } catch {
23249
+ return { installed: true, dnsName: null };
23250
+ }
23251
+ }
23252
+
23253
+ // src/remote-pane.ts
23254
+ var CHOICES = [
23255
+ { kind: "frizz", title: "frizz.sh name", blurb: "<name>.frizz.sh \u2014 nothing to install; needs the GitHub CLI" },
23256
+ { kind: "cloudflare", title: "Cloudflare Tunnel", blurb: "a domain you own on Cloudflare; cloudflared on this machine" },
23257
+ { kind: "tailscale", title: "Tailscale", blurb: "your tailnet; tailscale serve does the TLS" },
23258
+ { kind: "other", title: "Something else", blurb: "any proxy or tunnel you run \u2014 tell Frizz its address" },
23259
+ { kind: "off", title: "Off", blurb: "loopback only" }
23260
+ ];
23261
+ var ENTER = /* @__PURE__ */ new Set(["\r", "\n"]);
23262
+ var BACKSPACE = /* @__PURE__ */ new Set(["\x7F", "\b"]);
23263
+ var ESC = "\x1B";
23264
+ var UP = "\x1B[A";
23265
+ var DOWN = "\x1B[B";
23266
+ var TAB = " ";
23267
+ var SHIFT_TAB = "\x1B[Z";
23268
+ function kindOf(config) {
23269
+ if (!config) return "off";
23270
+ if (isClaimedConfig(config)) return "frizz";
23271
+ if (isExternalConfig(config)) return config.provider === "tailscale" ? "tailscale" : "other";
23272
+ return "cloudflare";
23273
+ }
23274
+ function wrap(text, width = 74) {
23275
+ const lines = [];
23276
+ for (const paragraph of text.split("\n")) {
23277
+ let line = "";
23278
+ for (const word of paragraph.split(" ")) {
23279
+ if (line && line.length + 1 + word.length > width) {
23280
+ lines.push(line);
23281
+ line = word;
23282
+ } else line = line ? `${line} ${word}` : word;
23283
+ }
23284
+ lines.push(line);
23285
+ }
23286
+ return lines;
23287
+ }
23288
+ function createRemotePane(options2) {
23289
+ const output = options2.output ?? process.stdout;
23290
+ let open = false;
23291
+ let screen = { name: "menu", index: 0 };
23292
+ let github = null;
23293
+ let cloudflared = null;
23294
+ let tailscale = null;
23295
+ const write = (lines) => {
23296
+ if (!open) return;
23297
+ output.write(CLEAR);
23298
+ output.write("\n");
23299
+ for (const line of lines) output.write(` ${line}
23300
+ `);
23301
+ };
23302
+ const check = (ok, text) => ok ? `${text} \u2713` : text;
23303
+ const paint = () => {
23304
+ if (!open) return;
23305
+ const s = screen;
23306
+ if (s.name === "menu") {
23307
+ const current = kindOf(options2.current());
23308
+ write([
23309
+ "Reach this board from anywhere",
23310
+ "",
23311
+ ...wrap(
23312
+ "Frizz binds 127.0.0.1 and has no login. Reaching it from another device means something in front of it does the authenticating. Whatever you pick here is remembered; from then on a plain `npx frizz` serves it."
23313
+ ),
23314
+ "",
23315
+ ...CHOICES.map((choice, index) => {
23316
+ const marker = index === s.index ? "\u276F" : " ";
23317
+ const title = choice.title.padEnd(18);
23318
+ const now = choice.kind === current ? ` ${DIM}(current)${RESET2}` : "";
23319
+ return `${marker} ${title}${DIM}${choice.blurb}${RESET2}${now}`;
23320
+ }),
23321
+ "",
23322
+ `${DIM}\u2191\u2193 move \xB7 enter choose \xB7 esc back${RESET2}`
23323
+ ]);
23324
+ return;
23325
+ }
23326
+ if (s.name === "form") {
23327
+ const head = formHead(s.kind);
23328
+ const fields = s.fields.map((field, index) => {
23329
+ const focused = index === s.focus;
23330
+ const shown = field.value || (field.placeholder ? `${DIM}${field.placeholder}${RESET2}` : "");
23331
+ const cursor = focused ? "\u2588" : "";
23332
+ return `${field.label.padEnd(10)} ${shown}${cursor}`;
23333
+ });
23334
+ write([...head, "", ...fields, ...s.note ? ["", s.note] : [], "", `${DIM}enter ${s.kind === "frizz" ? "claim" : "save"} \xB7 tab next field \xB7 esc back${RESET2}`]);
23335
+ return;
23336
+ }
23337
+ if (s.name === "busy") {
23338
+ write([s.message, "", `${DIM}working\u2026${RESET2}`]);
23339
+ return;
23340
+ }
23341
+ if (s.name === "done") {
23342
+ const lines = [s.message, ""];
23343
+ if (s.link) {
23344
+ for (const row of renderQrLines(s.link.url)) lines.push(row);
23345
+ lines.push("", s.link.url, "", `${DIM}Scan to sign in on a phone. Single use, expires in 5 minutes. Press L later for another.${RESET2}`);
23346
+ }
23347
+ lines.push("", `${DIM}press any key to return${RESET2}`);
23348
+ write(lines);
23349
+ return;
23350
+ }
23351
+ write([`Could not apply that: ${s.message}`, "", `${DIM}press any key to go back${RESET2}`]);
23352
+ };
23353
+ const formHead = (kind) => {
23354
+ if (kind === "frizz") {
23355
+ const gh = github === "pending" || github === null ? `${DIM}GitHub CLI checking\u2026${RESET2}` : !github.installed ? "GitHub CLI not installed \u2014 see https://cli.github.com, then `gh auth login`" : github.login ? check(true, `GitHub CLI signed in as ${github.login}`) : "GitHub CLI not signed in \u2014 run `gh auth login` in another terminal";
23356
+ return [
23357
+ "frizz.sh name",
23358
+ "",
23359
+ ...wrap(
23360
+ "Claims <name>.frizz.sh for your GitHub account. The board dials out to frizz.sh \u2014 no port, no DNS record, no tunnel binary. One name per account; a name nobody runs for 30 days is released."
23361
+ ),
23362
+ ...options2.sandbox ? ["", ...wrap("This is a sandbox, but a claim is real: it binds this machine's one name to your account, and your real board keeps it. Only the setup saved here is thrown away.")] : [],
23363
+ "",
23364
+ gh
23365
+ ];
23366
+ }
23367
+ if (kind === "cloudflare") {
23368
+ const cf = cloudflared === "pending" || cloudflared === null ? `${DIM}cloudflared checking\u2026${RESET2}` : cloudflared.version ? check(true, `cloudflared found, ${cloudflared.version}`) : "cloudflared not found \u2014 install it from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
23369
+ return [
23370
+ "Cloudflare Tunnel",
23371
+ "",
23372
+ "Needs a domain on Cloudflare and cloudflared on this machine.",
23373
+ cf,
23374
+ "",
23375
+ "Create the tunnel and its DNS record once, in another terminal:",
23376
+ "",
23377
+ " cloudflared tunnel login",
23378
+ " cloudflared tunnel create my-board",
23379
+ " cloudflared tunnel route dns my-board board.example.com",
23380
+ "",
23381
+ ...wrap(
23382
+ `Then name them here. Frizz writes ~/.cloudflared/frizz.yml (ingress to http://127.0.0.1:${options2.port}) and runs the tunnel beside the board, so the two share a lifetime.`
23383
+ )
23384
+ ];
23385
+ }
23386
+ if (kind === "tailscale") {
23387
+ const ts = tailscale === "pending" || tailscale === null ? `${DIM}tailscale checking\u2026${RESET2}` : !tailscale.installed ? "tailscale not found \u2014 install it from https://tailscale.com/download" : tailscale.dnsName ? check(true, `tailscale found, this machine is ${tailscale.dnsName}`) : "tailscale found, but the daemon did not answer \u2014 is it signed in?";
23388
+ return [
23389
+ "Tailscale",
23390
+ "",
23391
+ ...wrap("Serves the board on your tailnet. Tailscale terminates TLS and only devices signed into the tailnet can reach the name."),
23392
+ ts,
23393
+ "",
23394
+ "Run once, in another terminal:",
23395
+ "",
23396
+ ` tailscale serve --bg ${options2.port}`,
23397
+ "",
23398
+ "Tailscale answers at the address below \u2014 that is the origin Frizz accepts."
23399
+ ];
23400
+ }
23401
+ return [
23402
+ "Something else",
23403
+ "",
23404
+ ...wrap(
23405
+ `Terminate TLS wherever you like, proxy to http://127.0.0.1:${options2.port}, and give Frizz the exact origin a browser will show \u2014 scheme and host, no path. Frizz answers only to that Host and prints a single-use sign-in link.`
23406
+ )
23407
+ ];
23408
+ };
23409
+ const openForm = (kind) => {
23410
+ const current = options2.current();
23411
+ const same = kindOf(current) === kind ? current : null;
23412
+ let fields;
23413
+ if (kind === "frizz") {
23414
+ github = "pending";
23415
+ void options2.probes.github().then((result) => {
23416
+ github = result;
23417
+ if (screen.name === "form" && screen.kind === "frizz" && !screen.fields[0].value && result.login) {
23418
+ screen.fields[0].placeholder = result.login;
23419
+ }
23420
+ paint();
23421
+ });
23422
+ fields = [{ label: "Name", value: same?.claim ?? "" }];
23423
+ } else if (kind === "cloudflare") {
23424
+ cloudflared = "pending";
23425
+ void options2.probes.cloudflared().then((result) => {
23426
+ cloudflared = result;
23427
+ paint();
23428
+ });
23429
+ fields = [
23430
+ { label: "Hostname", value: same?.hostname ?? "", placeholder: "board.example.com" },
23431
+ { label: "Tunnel", value: same?.tunnel ?? "", placeholder: "my-board" }
23432
+ ];
23433
+ } else if (kind === "tailscale") {
23434
+ tailscale = "pending";
23435
+ void options2.probes.tailscale().then((result) => {
23436
+ tailscale = result;
23437
+ if (screen.name === "form" && screen.kind === "tailscale" && !screen.fields[0].value && result.dnsName) {
23438
+ screen.fields[0].placeholder = `https://${result.dnsName}`;
23439
+ }
23440
+ paint();
23441
+ });
23442
+ fields = [{ label: "Origin", value: same ? `https://${same.hostname}` : "", placeholder: "https://mac-mini.your-tailnet.ts.net" }];
23443
+ } else {
23444
+ fields = [{ label: "Origin", value: same ? `https://${same.hostname}` : "", placeholder: "https://board.example.com" }];
23445
+ }
23446
+ screen = { name: "form", kind, fields, focus: 0 };
23447
+ paint();
23448
+ };
23449
+ const valueOf = (field) => field.value || field.placeholder || "";
23450
+ const submit = async (form) => {
23451
+ const back = form;
23452
+ try {
23453
+ let next;
23454
+ let justClaimed = false;
23455
+ if (form.kind === "frizz") {
23456
+ const name = valueOf(form.fields[0]).trim();
23457
+ if (!name) throw new Error("a name is needed");
23458
+ screen = { name: "busy", message: `claiming ${name}.frizz.sh\u2026` };
23459
+ paint();
23460
+ next = await options2.claim(name);
23461
+ justClaimed = true;
23462
+ } else if (form.kind === "cloudflare") {
23463
+ const hostname = normalizeHostname(valueOf(form.fields[0]));
23464
+ const tunnel = valueOf(form.fields[1]).trim();
23465
+ if (!tunnel) throw new Error("the tunnel's name is needed");
23466
+ next = { hostname, tunnel };
23467
+ } else {
23468
+ const hostname = normalizeHostname(valueOf(form.fields[0]));
23469
+ next = { hostname, serve: "external", provider: form.kind === "tailscale" ? "tailscale" : "other" };
23470
+ }
23471
+ screen = { name: "busy", message: `serving ${next.hostname}\u2026` };
23472
+ paint();
23473
+ await options2.apply(next, { justClaimed });
23474
+ options2.onChanged?.(next);
23475
+ screen = { name: "done", message: `Serving https://${next.hostname} (${describeCloudConfig(next)}).`, link: options2.issueLink(), config: next };
23476
+ } catch (error) {
23477
+ screen = { name: "error", message: error instanceof Error ? error.message : String(error), back };
23478
+ }
23479
+ paint();
23480
+ };
23481
+ const turnOff = async () => {
23482
+ screen = { name: "busy", message: "back to loopback only\u2026" };
23483
+ paint();
23484
+ try {
23485
+ await options2.apply(null);
23486
+ options2.onChanged?.(null);
23487
+ screen = { name: "done", message: "Loopback only. This board is reachable from this machine alone.", link: null, config: null };
23488
+ } catch (error) {
23489
+ screen = { name: "error", message: error instanceof Error ? error.message : String(error), back: { name: "menu", index: CHOICES.length - 1 } };
23490
+ }
23491
+ paint();
23492
+ };
23493
+ return {
23494
+ open() {
23495
+ open = true;
23496
+ const current = kindOf(options2.current());
23497
+ screen = { name: "menu", index: Math.max(0, CHOICES.findIndex((choice) => choice.kind === current)) };
23498
+ output.write(ALT_SCREEN_ON);
23499
+ output.write(HIDE_CURSOR);
23500
+ paint();
23501
+ return true;
23502
+ },
23503
+ key(key) {
23504
+ const s = screen;
23505
+ if (s.name === "busy") return "keep";
23506
+ if (s.name === "done") return "close";
23507
+ if (s.name === "error") {
23508
+ screen = s.back;
23509
+ paint();
23510
+ return "keep";
23511
+ }
23512
+ if (s.name === "menu") {
23513
+ if (key === ESC || key === "q") return "close";
23514
+ if (key === UP || key === "k") s.index = (s.index + CHOICES.length - 1) % CHOICES.length;
23515
+ else if (key === DOWN || key === "j") s.index = (s.index + 1) % CHOICES.length;
23516
+ else if (/^[1-5]$/.test(key)) s.index = Number(key) - 1;
23517
+ else if (ENTER.has(key)) {
23518
+ const choice = CHOICES[s.index];
23519
+ if (choice.kind === "off") void turnOff();
23520
+ else openForm(choice.kind);
23521
+ return "keep";
23522
+ }
23523
+ paint();
23524
+ return "keep";
23525
+ }
23526
+ if (key === ESC) {
23527
+ screen = { name: "menu", index: CHOICES.findIndex((choice) => choice.kind === s.kind) };
23528
+ paint();
23529
+ return "keep";
23530
+ }
23531
+ const field = s.fields[s.focus];
23532
+ if (ENTER.has(key)) {
23533
+ if (s.focus < s.fields.length - 1 && valueOf(field)) s.focus += 1;
23534
+ else void submit(s);
23535
+ } else if (key === TAB || key === DOWN) s.focus = (s.focus + 1) % s.fields.length;
23536
+ else if (key === SHIFT_TAB || key === UP) s.focus = (s.focus + s.fields.length - 1) % s.fields.length;
23537
+ else if (BACKSPACE.has(key)) field.value = field.value.slice(0, -1);
23538
+ else if (key.length === 1 && key >= " ") field.value += key;
23539
+ else if (!key.startsWith(ESC)) field.value += key.replace(/[\r\n\t]/g, "");
23540
+ paint();
23541
+ return "keep";
23542
+ },
23543
+ close() {
23544
+ if (!open) return;
23545
+ open = false;
23546
+ output.write(ALT_SCREEN_OFF);
23547
+ output.write(SHOW_CURSOR);
23548
+ }
23549
+ };
23550
+ }
23551
+
23552
+ // src/production.ts
23553
+ init_local_origin();
23554
+
22915
23555
  // src/readout.ts
22916
23556
  var SPINNER = ["\u280B", "\u2819", "\u2839", "\u2838", "\u283C", "\u2834", "\u2826", "\u2827", "\u2807", "\u280F"];
22917
23557
  var SYNC_BEGIN = "\x1B[?2026h";
@@ -23258,33 +23898,27 @@ init_src();
23258
23898
  init_project_registry();
23259
23899
  import { execFileSync as execFileSync3 } from "node:child_process";
23260
23900
  import { createServer as createServer2 } from "node:net";
23261
- import { mkdirSync as mkdirSync10, readFileSync as readFileSync11, realpathSync as realpathSync5, writeFileSync as writeFileSync10 } from "node:fs";
23262
- import { homedir as homedir9, hostname as osHostname, networkInterfaces } from "node:os";
23901
+ import { existsSync as existsSync5, mkdirSync as mkdirSync10, mkdtempSync, readdirSync as readdirSync3, readFileSync as readFileSync11, realpathSync as realpathSync5, rmSync as rmSync8, symlinkSync as symlinkSync2, writeFileSync as writeFileSync10 } from "node:fs";
23902
+ import { homedir as homedir10, tmpdir as tmpdir2 } from "node:os";
23263
23903
  import { basename as basename5, join as join13, resolve as resolve3 } from "node:path";
23264
23904
  import { setTimeout as delay2 } from "node:timers/promises";
23265
23905
  var PORT_SCAN_COUNT = 100;
23266
23906
  var LAUNCH_TIMEOUT_MS = 3e4;
23267
23907
  var LAUNCH_HARD_TIMEOUT_MS = 10 * 6e4;
23268
- function looksLikeBindHost(value) {
23269
- if (value === void 0 || value.startsWith("-")) return false;
23270
- try {
23271
- normalizeBindHost(value);
23272
- return true;
23273
- } catch {
23274
- return false;
23275
- }
23908
+ var RETIRED_NETWORK_FLAGS = ["--host", "--allowed-host", "--public-origin", "--cloud"];
23909
+ function retiredFlagMessage(flag) {
23910
+ return `${flag} was retired \u2014 how a board is reached is set up from the running board now: press R in its terminal to pick a frizz.sh name, a Cloudflare Tunnel, Tailscale or a proxy of your own. The choice is remembered, so a plain launch serves it.`;
23276
23911
  }
23277
23912
  function parseCliArgs(argv) {
23278
23913
  const args2 = new Set(argv);
23279
- let upCommand = false;
23280
23914
  let rawPort;
23281
- let rawHost;
23282
- let rawPublicOrigin;
23283
- const rawAllowedHosts = [];
23915
+ let rawSignOut;
23284
23916
  const consumed = /* @__PURE__ */ new Set();
23285
23917
  for (let index = 0; index < argv.length; index++) {
23286
23918
  const arg = argv[index];
23287
23919
  if (consumed.has(index)) continue;
23920
+ const retired = arg === "up" ? "up" : RETIRED_NETWORK_FLAGS.find((flag) => arg === flag || arg.startsWith(`${flag}=`));
23921
+ if (retired) throw new Error(retiredFlagMessage(retired));
23288
23922
  if (arg === "--port") {
23289
23923
  rawPort = argv[++index];
23290
23924
  if (rawPort === void 0 || rawPort.startsWith("-"))
@@ -23296,49 +23930,21 @@ function parseCliArgs(argv) {
23296
23930
  rawPort = arg.slice("--port=".length);
23297
23931
  continue;
23298
23932
  }
23299
- if (arg === "--host") {
23300
- if (looksLikeBindHost(argv[index + 1])) {
23301
- rawHost = argv[++index];
23302
- consumed.add(index);
23303
- } else {
23304
- rawHost = ALL_INTERFACES_BIND_HOST;
23305
- }
23306
- continue;
23307
- }
23308
- if (arg.startsWith("--host=")) {
23309
- rawHost = arg.slice("--host=".length);
23310
- continue;
23311
- }
23312
- if (arg === "--allowed-host") {
23313
- const value = argv[++index];
23314
- if (value === void 0 || value.startsWith("-"))
23315
- throw new Error("--allowed-host requires a value");
23316
- consumed.add(index);
23317
- rawAllowedHosts.push(value);
23318
- continue;
23319
- }
23320
- if (arg.startsWith("--allowed-host=")) {
23321
- rawAllowedHosts.push(arg.slice("--allowed-host=".length));
23322
- continue;
23323
- }
23324
- if (arg === "--public-origin") {
23325
- const value = argv[++index];
23933
+ if (arg === "--sign-out") {
23934
+ const value = argv[index + 1];
23326
23935
  if (value === void 0 || value.startsWith("-"))
23327
- throw new Error("--public-origin requires a value");
23936
+ throw new Error("--sign-out requires a device id, or `all` to sign out every device");
23937
+ index++;
23328
23938
  consumed.add(index);
23329
- rawPublicOrigin = value;
23939
+ rawSignOut = value;
23330
23940
  continue;
23331
23941
  }
23332
- if (arg.startsWith("--public-origin=")) {
23333
- rawPublicOrigin = arg.slice("--public-origin=".length);
23942
+ if (arg.startsWith("--sign-out=")) {
23943
+ rawSignOut = arg.slice("--sign-out=".length);
23944
+ if (!rawSignOut) throw new Error("--sign-out requires a device id, or `all` to sign out every device");
23334
23945
  continue;
23335
23946
  }
23336
23947
  if (arg.startsWith("-")) continue;
23337
- if (arg === "up") {
23338
- upCommand = true;
23339
- consumed.add(index);
23340
- continue;
23341
- }
23342
23948
  throw new Error(
23343
23949
  `unexpected argument: ${arg} \u2014 Frizz takes no repository path, because one server serves every project on this machine. cd into the directory you want and run it with no arguments.`
23344
23950
  );
@@ -23349,9 +23955,6 @@ function parseCliArgs(argv) {
23349
23955
  if (!Number.isInteger(port) || port < 1 || port > 65535)
23350
23956
  throw new Error(`invalid --port value: ${rawPort}`);
23351
23957
  }
23352
- if ((upCommand || args2.has("--cloud")) && rawPublicOrigin !== void 0)
23353
- throw new Error("choose either up (the saved public hostname) or --public-origin, not both");
23354
- const host = rawHost === void 0 ? void 0 : normalizeBindHost(rawHost);
23355
23958
  const known = /* @__PURE__ */ new Set([
23356
23959
  "--app",
23357
23960
  "--no-app",
@@ -23360,15 +23963,14 @@ function parseCliArgs(argv) {
23360
23963
  "--stop",
23361
23964
  "--status",
23362
23965
  "--link",
23363
- "--cloud",
23966
+ "--sandbox",
23967
+ "--sessions",
23968
+ "--sign-out",
23364
23969
  "--help",
23365
23970
  "-h",
23366
23971
  "--dev",
23367
23972
  "--prod",
23368
23973
  "--port",
23369
- "--host",
23370
- "--allowed-host",
23371
- "--public-origin",
23372
23974
  "--debug"
23373
23975
  ]);
23374
23976
  for (let index = 0; index < argv.length; index++) {
@@ -23378,13 +23980,23 @@ function parseCliArgs(argv) {
23378
23980
  index++;
23379
23981
  continue;
23380
23982
  }
23381
- if (arg.startsWith("--port=") || arg.startsWith("--host=") || arg.startsWith("--allowed-host=") || arg.startsWith("--public-origin=")) continue;
23983
+ if (arg === "--sign-out") {
23984
+ index++;
23985
+ continue;
23986
+ }
23987
+ if (arg.startsWith("--port=") || arg.startsWith("--sign-out=")) continue;
23382
23988
  if (!known.has(arg)) throw new Error(`unknown option: ${arg}`);
23383
23989
  }
23384
23990
  if (args2.has("--detach"))
23385
23991
  throw new Error("--detach is no longer available; frizz-dev always runs in the foreground");
23386
23992
  if (args2.has("--app") && args2.has("--no-app"))
23387
23993
  throw new Error("choose either --app or --no-app");
23994
+ if (args2.has("--sandbox")) {
23995
+ for (const query of ["--stop", "--status", "--link", "--sessions", "--sign-out"]) {
23996
+ if (args2.has(query) || argv.some((arg) => arg.startsWith(`${query}=`)))
23997
+ throw new Error(`${query} asks the running board, and --sandbox starts a fresh disposable one \u2014 run ${query} without --sandbox`);
23998
+ }
23999
+ }
23388
24000
  return {
23389
24001
  noApp: args2.has("--no-app"),
23390
24002
  appMode: args2.has("--app"),
@@ -23392,65 +24004,71 @@ function parseCliArgs(argv) {
23392
24004
  foreground: true,
23393
24005
  stop: args2.has("--stop"),
23394
24006
  link: args2.has("--link"),
23395
- cloud: upCommand || args2.has("--cloud"),
24007
+ sandbox: args2.has("--sandbox"),
24008
+ sessions: args2.has("--sessions"),
24009
+ ...rawSignOut !== void 0 ? { signOut: rawSignOut } : {},
23396
24010
  status: args2.has("--status"),
23397
24011
  help: args2.has("--help") || args2.has("-h"),
23398
24012
  dev: args2.has("--dev"),
23399
24013
  debug: args2.has("--debug"),
23400
- port,
23401
- host,
23402
- allowedHosts: normalizeAllowedHosts(rawAllowedHosts),
23403
- ...rawPublicOrigin === void 0 ? {} : { publicOrigin: normalizePublicOrigin(rawPublicOrigin) }
23404
- };
23405
- }
23406
- var EXPOSED_WARNING = "Anyone who can reach this address can run commands on this machine as you. Frizz has no login \u2014 only do this on a network you trust.";
23407
- var REUSED_NETWORK_FLAGS_WARNING = "This joined the Frizz already running on this machine, so --host/--public-origin were ignored. Stop it first (frizz --stop) and relaunch with the flag to change how the board is reached.";
23408
- var PUBLIC_ORIGIN_WARNING = "Frizz has no login of its own, so whatever sits in front of this origin IS the access control. Require authentication there (Cloudflare Access, Tailscale) \u2014 an unauthenticated tunnel lets anyone with the URL run commands on this machine as you.";
23409
- function resolveBindSelection(options2, env = process.env, machineName = osHostname()) {
23410
- const fromEnv = env.FRIZZ_HOST?.trim();
23411
- const host = options2.host ?? (fromEnv ? normalizeBindHost(fromEnv) : LOOPBACK_BIND_HOST);
23412
- const exposed = bindHostIsExposed(host);
23413
- const ownNames = exposed ? machineHostNames(machineName) : [];
23414
- const allowedHosts = normalizeAllowedHosts([
23415
- ...options2.allowedHosts,
23416
- ...env.FRIZZ_ALLOWED_HOSTS ? [env.FRIZZ_ALLOWED_HOSTS] : [],
23417
- ...ownNames
23418
- ]);
23419
- const publicOriginRaw = options2.publicOrigin ?? env.FRIZZ_PUBLIC_ORIGIN?.trim();
23420
- const publicOrigin = publicOriginRaw ? normalizePublicOrigin(publicOriginRaw) : void 0;
23421
- return {
23422
- host,
23423
- exposed,
23424
- allowedHosts,
23425
- ...ownNames.length > 0 ? { hostname: ownNames[0] } : {},
23426
- ...publicOrigin ? { publicOrigin } : {}
24014
+ port
23427
24015
  };
23428
24016
  }
23429
24017
  function boardAddress(url) {
23430
24018
  return url.includes("?") || url.endsWith("/") ? url : `${url}/`;
23431
24019
  }
23432
- function networkUrls(port, host, interfaces = networkInterfaces, hostname) {
23433
- if (!bindHostIsExposed(host)) return [];
23434
- const urls = hostname ? [`http://${hostname}:${port}`] : [];
23435
- const wildcard = host === ALL_INTERFACES_BIND_HOST || host === "::";
23436
- const push = (address, family) => {
23437
- if (address.startsWith("fe80:")) return;
23438
- urls.push(family === "IPv6" ? `http://[${address}]:${port}` : `http://${address}:${port}`);
24020
+ function prepareSandbox(env = process.env, realHome = homedir10()) {
24021
+ const home = mkdtempSync(join13(tmpdir2(), "frizz-sandbox-"));
24022
+ shareCredentials(realHome, home);
24023
+ env.HOME = home;
24024
+ env.USERPROFILE = home;
24025
+ const project = join13(home, "sandbox");
24026
+ mkdirSync10(project, { recursive: true });
24027
+ writeFileSync10(join13(project, "README.md"), "# frizz sandbox\n\nA throwaway project; everything here is deleted when the sandbox exits.\n");
24028
+ try {
24029
+ execFileSync3("git", ["init", "-q"], { cwd: project, stdio: "ignore" });
24030
+ } catch {
24031
+ throw new Error("--sandbox mints a throwaway git repository and git is not available \u2014 install git, or play in a repo of your own with a temp HOME");
24032
+ }
24033
+ process.chdir(project);
24034
+ return { home, project };
24035
+ }
24036
+ function shareCredentials(realHome, home) {
24037
+ const link = (from, to) => {
24038
+ try {
24039
+ symlinkSync2(from, to);
24040
+ } catch {
24041
+ }
23439
24042
  };
23440
- if (!wildcard) {
23441
- push(host, host.includes(":") ? "IPv6" : "IPv4");
23442
- return urls;
24043
+ mkdirSync10(join13(realHome, ".frizz"), { recursive: true });
24044
+ mkdirSync10(join13(home, ".frizz"), { recursive: true });
24045
+ link(join13(realHome, ".frizz", "identity.key"), join13(home, ".frizz", "identity.key"));
24046
+ if (existsSync5(join13(realHome, ".config", "gh"))) {
24047
+ mkdirSync10(join13(home, ".config"), { recursive: true });
24048
+ link(join13(realHome, ".config", "gh"), join13(home, ".config", "gh"));
23443
24049
  }
23444
- for (const entries of Object.values(interfaces())) {
23445
- for (const entry of entries ?? []) {
23446
- if (entry.internal) continue;
23447
- if (host === ALL_INTERFACES_BIND_HOST && entry.family !== "IPv4") continue;
23448
- push(entry.address, entry.family);
24050
+ const cloudflared = join13(realHome, ".cloudflared");
24051
+ if (existsSync5(cloudflared)) {
24052
+ mkdirSync10(join13(home, ".cloudflared"), { recursive: true });
24053
+ for (const entry of readdirSync3(cloudflared)) {
24054
+ if (entry === "cert.pem" || entry.endsWith(".json")) link(join13(cloudflared, entry), join13(home, ".cloudflared", entry));
23449
24055
  }
23450
24056
  }
23451
- return urls;
24057
+ for (const entry of [".claude", ".claude.json", ".codex"]) {
24058
+ if (existsSync5(join13(realHome, entry))) link(join13(realHome, entry), join13(home, entry));
24059
+ }
24060
+ if (process.platform === "darwin" && existsSync5(join13(realHome, "Library", "Keychains"))) {
24061
+ mkdirSync10(join13(home, "Library"), { recursive: true });
24062
+ link(join13(realHome, "Library", "Keychains"), join13(home, "Library", "Keychains"));
24063
+ }
24064
+ }
24065
+ function cleanupSandbox(home) {
24066
+ try {
24067
+ rmSync8(home, { recursive: true, force: true });
24068
+ } catch {
24069
+ }
23452
24070
  }
23453
- function resolveLaunchIntent(cwd = process.cwd(), home = homedir9(), env = process.env) {
24071
+ function resolveLaunchIntent(cwd = process.cwd(), home = homedir10(), env = process.env) {
23454
24072
  const candidate = realpathSync5(discoverProjectRoot(cwd, home));
23455
24073
  const known = isExistingProjectRoot(candidate) || findByPath(candidate, home) !== void 0;
23456
24074
  if ((known || hasProjectMarker(candidate)) && !isHomeDirectory(candidate, home))
@@ -23470,7 +24088,7 @@ function mostRecentProject(home, env) {
23470
24088
  }
23471
24089
  return void 0;
23472
24090
  }
23473
- function resolveWorkspace(cwd = process.cwd(), home = homedir9(), env = process.env) {
24091
+ function resolveWorkspace(cwd = process.cwd(), home = homedir10(), env = process.env) {
23474
24092
  let gitRoot;
23475
24093
  try {
23476
24094
  gitRoot = execFileSync3("git", ["rev-parse", "--show-toplevel"], {
@@ -23704,7 +24322,7 @@ init_project_launch();
23704
24322
  init_project_launch();
23705
24323
  import { fork } from "node:child_process";
23706
24324
  import { randomUUID as randomUUID5 } from "node:crypto";
23707
- import { existsSync as existsSync5, mkdirSync as mkdirSync11, readFileSync as readFileSync12 } from "node:fs";
24325
+ import { existsSync as existsSync6, mkdirSync as mkdirSync11, readFileSync as readFileSync12 } from "node:fs";
23708
24326
  import { createServer as createNetServer } from "node:net";
23709
24327
  import { basename as basename6, dirname as dirname10, extname as extname2, isAbsolute as isAbsolute3, relative, resolve as resolve4, sep as sep2 } from "node:path";
23710
24328
  import { fileURLToPath } from "node:url";
@@ -23745,12 +24363,15 @@ function devCrashRetryDelay(attempt) {
23745
24363
  return Math.min(DEV_CRASH_RETRY_BASE_MS * 2 ** exponent, DEV_CRASH_RETRY_MAX_MS);
23746
24364
  }
23747
24365
  var SUPERVISOR_ESCALATE_GRACE_MS = 500;
24366
+ var SUPERVISOR_DRAIN_DEADLINE_MS = CHILD_STOP_TIMEOUT_MS + 5e3;
23748
24367
  function stripPrefix(line) {
23749
24368
  return line.startsWith("[frizz] ") ? line.slice("[frizz] ".length) : line;
23750
24369
  }
23751
24370
  function createSupervisorShutdownHandler(options2) {
23752
24371
  const now = options2.now ?? (() => Date.now());
23753
24372
  const escalateAfterMs = options2.escalateAfterMs ?? SUPERVISOR_ESCALATE_GRACE_MS;
24373
+ const drainDeadlineMs = options2.drainDeadlineMs ?? SUPERVISOR_DRAIN_DEADLINE_MS;
24374
+ const scheduleForce = options2.scheduleForce ?? ((callback, delayMs) => setTimeout(callback, delayMs));
23754
24375
  let startedAt = 0;
23755
24376
  let stopping = false;
23756
24377
  let decided = false;
@@ -23764,20 +24385,27 @@ function createSupervisorShutdownHandler(options2) {
23764
24385
  }
23765
24386
  options2.exit(code);
23766
24387
  };
24388
+ const abandon = (reason) => {
24389
+ options2.error?.(`[frizz] ${reason} \u2014 abandoning the graceful drain`);
24390
+ try {
24391
+ options2.force?.();
24392
+ } catch (error) {
24393
+ options2.error?.(`[frizz] forced supervisor stop failed: ${error instanceof Error ? error.message : error}`);
24394
+ }
24395
+ decide(1);
24396
+ };
23767
24397
  return () => {
23768
24398
  if (stopping) {
23769
24399
  if (decided || now() - startedAt < escalateAfterMs) return;
23770
- options2.error?.("[frizz] second stop signal \u2014 abandoning the graceful drain");
23771
- try {
23772
- options2.force?.();
23773
- } catch (error) {
23774
- options2.error?.(`[frizz] forced supervisor stop failed: ${error instanceof Error ? error.message : error}`);
23775
- }
23776
- decide(1);
24400
+ abandon("second stop signal");
23777
24401
  return;
23778
24402
  }
23779
24403
  stopping = true;
23780
24404
  startedAt = now();
24405
+ options2.onStop?.();
24406
+ scheduleForce(() => {
24407
+ if (!decided) abandon(`graceful stop did not finish in ${drainDeadlineMs}ms`);
24408
+ }, drainDeadlineMs).unref?.();
23781
24409
  void options2.close().then(
23782
24410
  () => decide(0),
23783
24411
  (error) => {
@@ -23987,6 +24615,7 @@ var Supervisor = class {
23987
24615
  publicOrigin: opts.publicOrigin,
23988
24616
  onCodeConsumed: opts.onCodeConsumed,
23989
24617
  sessionKey: opts.sessionKey,
24618
+ sessionDirectory: opts.sessionDirectory,
23990
24619
  childPort: () => this.childPort,
23991
24620
  restart: () => this.restartFromBrowser(),
23992
24621
  updateRestart: this.updateRestart ? () => this.updateFromBrowser() : void 0,
@@ -24012,6 +24641,9 @@ var Supervisor = class {
24012
24641
  return this.boot;
24013
24642
  }
24014
24643
  /** Mint a single-use access link for the public origin, or null when none is declared. */
24644
+ setPublicOrigin(origin) {
24645
+ this.publicProxy.setPublicOrigin(origin);
24646
+ }
24015
24647
  issueAccessLink() {
24016
24648
  const code = this.publicProxy.issueAccessCode();
24017
24649
  if (!code) return null;
@@ -24027,7 +24659,7 @@ var Supervisor = class {
24027
24659
  await this.publicProxy.listen();
24028
24660
  if (this.watchEnabled) {
24029
24661
  const settled = await Promise.allSettled(
24030
- this.roots.filter(existsSync5).map((root) => this.watchSubscribe(root, (err, events) => this.onWatch(err, events), {
24662
+ this.roots.filter(existsSync6).map((root) => this.watchSubscribe(root, (err, events) => this.onWatch(err, events), {
24031
24663
  ignore: DEV_WATCH_IGNORE
24032
24664
  }))
24033
24665
  );
@@ -24082,7 +24714,7 @@ var Supervisor = class {
24082
24714
  if (this.closed) return;
24083
24715
  this.reloadLauncher ||= reloadLauncher;
24084
24716
  if (this.debounce) clearTimeout(this.debounce);
24085
- const run2 = () => {
24717
+ const run3 = () => {
24086
24718
  this.debounce = null;
24087
24719
  const scope = this.reloadLauncher ? "control plane + launcher" : "control plane";
24088
24720
  this.logLine(
@@ -24091,8 +24723,8 @@ var Supervisor = class {
24091
24723
  this.writeStatus("restarting", reason);
24092
24724
  void this.restart();
24093
24725
  };
24094
- if (immediate) run2();
24095
- else this.debounce = setTimeout(run2, delayMs);
24726
+ if (immediate) run3();
24727
+ else this.debounce = setTimeout(run3, delayMs);
24096
24728
  }
24097
24729
  async restart() {
24098
24730
  if (this.restartRunning) {
@@ -24479,7 +25111,7 @@ async function startDevSupervisor(opts) {
24479
25111
  }
24480
25112
 
24481
25113
  // src/production-update.ts
24482
- import { execFile as execFile3, spawn as spawn3 } from "node:child_process";
25114
+ import { execFile as execFile4, spawn as spawn3 } from "node:child_process";
24483
25115
  var PRODUCTION_REEXEC_FLAG = "--_frizz-production-reexec";
24484
25116
  function compareReleaseVersions(a, b) {
24485
25117
  const parse2 = (value) => {
@@ -24528,7 +25160,7 @@ function handoffToRegistrySuccessor(plan, request, adapter) {
24528
25160
  var npmRegistryReleaseAdapter = {
24529
25161
  latestVersion(packageName) {
24530
25162
  return new Promise((resolveVersion, reject) => {
24531
- execFile3("npm", ["view", `${packageName}@latest`, "version", "--json"], { encoding: "utf8" }, (error, stdout) => {
25163
+ execFile4("npm", ["view", `${packageName}@latest`, "version", "--json"], { encoding: "utf8" }, (error, stdout) => {
24532
25164
  if (error) return reject(new Error(`could not check npm for ${packageName}: ${error.message}`));
24533
25165
  try {
24534
25166
  const parsed = JSON.parse(stdout);
@@ -24649,11 +25281,7 @@ if (options.help) {
24649
25281
  console.log(
24650
25282
  `Frizz production launcher
24651
25283
 
24652
- Usage: npx ${PACKAGE_NAME} [up] [options]
24653
-
24654
- Commands:
24655
- up serve at the saved public hostname and run its tunnel; asked once,
24656
- then remembered
25284
+ Usage: npx ${PACKAGE_NAME} [options]
24657
25285
 
24658
25286
  Run it in the directory you want to work in. One server serves EVERY project on this machine,
24659
25287
  each at its own /project/<name> URL, so a second run joins the one already going. Runs the
@@ -24663,45 +25291,30 @@ for a source checkout.
24663
25291
  Options:
24664
25292
  --no-app print the URL without opening a browser
24665
25293
  --port <port> request a fixed port for a new workspace server
24666
- --host [address] serve on a network address instead of loopback (bare --host means 0.0.0.0)
24667
- --allowed-host <name> with --host, also accept this DNS name as the board's address (repeatable)
24668
- --public-origin <url> serve behind a proxy/tunnel reachable at this exact origin
24669
- --cloud alias of up, for saved commands
25294
+ --sandbox a disposable Frizz to try things in: throwaway home and project, its
25295
+ own port, deleted when this terminal closes; credentials (gh,
25296
+ cloudflared, Claude, Codex, the machine's frizz.sh key) are shared
24670
25297
  --link print a fresh single-use access link for the running board
25298
+ --sessions list the devices signed in to the running board
25299
+ --sign-out <id|all> sign one device out, or every one of them
24671
25300
  --debug stream the full event feed to the terminal instead of the compact readout
24672
25301
  -h, --help show this help
24673
25302
 
24674
- Environment:
24675
- FRIZZ_HOST same as --host
24676
- FRIZZ_ALLOWED_HOSTS same as --allowed-host, comma separated
24677
- FRIZZ_PUBLIC_ORIGIN same as --public-origin
24678
-
24679
- --host puts a board that can run shell commands as you on the network, and Frizz has no login: anyone
24680
- who reaches the port controls it. Only do this on a network you trust. An IP address and this
24681
- machine's own hostname work as-is; any other DNS name must be listed with --allowed-host ("*"
24682
- allows any).
24683
25303
 
24684
- --public-origin serves the board through a tunnel or reverse proxy without putting it on the LAN
24685
- at all \u2014 Frizz stays on loopback and the tunnel dials it. It prints a SINGLE-USE access link, and
24686
- shows it as a QR so you can scan it from a phone; press L for a fresh one at any time. Scanning it
24687
- trades the code for a session cookie, so the link itself stops working the moment it is used.`
25304
+ To reach the board from a phone or another machine, press R in the terminal running it: a short
25305
+ walkthrough sets up a frizz.sh name, a Cloudflare Tunnel, Tailscale, or a proxy of your own, and
25306
+ remembers the choice, so a plain launch serves it from then on. The board stays on loopback and
25307
+ shows a single-use sign-in link as a QR; press L for a fresh one, or run --link from another shell.`
24688
25308
  );
24689
25309
  process.exit(0);
24690
25310
  }
24691
25311
  if (options.dev || rawArgs.includes("--prod")) fail("--dev and --prod are not available from the registry launcher");
24692
- var cloudConfig = options.cloud ? readCloudConfig() : null;
24693
- if (cloudConfig) options.publicOrigin = `https://${cloudConfig.hostname}`;
24694
- var tunnel = null;
25312
+ var sandbox = options.sandbox ? prepareSandbox() : null;
25313
+ if (sandbox) process.on("exit", () => cleanupSandbox(sandbox.home));
25314
+ var remote = null;
25315
+ var paneHost = null;
24695
25316
  var accessPane = null;
24696
25317
  var activeAccessLink = null;
24697
- var justClaimed = false;
24698
- var bind = (() => {
24699
- try {
24700
- return resolveBindSelection(options, process.env);
24701
- } catch (error) {
24702
- return fail(error);
24703
- }
24704
- })();
24705
25318
  var launchIntent;
24706
25319
  var workspace = (() => {
24707
25320
  try {
@@ -24726,6 +25339,15 @@ var workspace = (() => {
24726
25339
  }
24727
25340
  })();
24728
25341
  process.chdir(workspace.root);
25342
+ if (options.sessions || options.signOut !== void 0) {
25343
+ const running = liveWorkspaceOwner(workspace.stateDir, workspaceLaunchTarget(workspace));
25344
+ if (!running?.port) {
25345
+ console.error("frizz: no board is running for this workspace");
25346
+ process.exit(1);
25347
+ }
25348
+ if (options.signOut !== void 0) await signOutSession(running.port, options.signOut);
25349
+ await listSessions(running.port);
25350
+ }
24729
25351
  if (options.link) {
24730
25352
  const running = liveWorkspaceOwner(workspace.stateDir, workspaceLaunchTarget(workspace));
24731
25353
  if (!running?.port) {
@@ -24770,7 +25392,7 @@ function ownSlug() {
24770
25392
  try {
24771
25393
  return registerProject(
24772
25394
  { dir: workspace.root, id: target.projectId, remoteOwner: resolveProjectLabel(workspace.root)?.split("/")[0] },
24773
- homedir10()
25395
+ homedir11()
24774
25396
  ).entry?.slug;
24775
25397
  } catch {
24776
25398
  return void 0;
@@ -24821,27 +25443,20 @@ async function openOrPrint(port, reused, path = "") {
24821
25443
  } else {
24822
25444
  readout?.settle("browser", "done", url);
24823
25445
  }
24824
- const network = reused ? [] : networkUrls(port, bind.host, void 0, bind.hostname);
24825
- const publicOrigin = reused ? void 0 : bind.publicOrigin;
24826
- const networkFlagsIgnored = reused && (bind.publicOrigin !== void 0 || bindHostIsExposed(bind.host));
24827
- const warnings = [
24828
- ...network.length > 0 ? [EXPOSED_WARNING] : [],
24829
- ...publicOrigin ? [PUBLIC_ORIGIN_WARNING] : [],
24830
- ...networkFlagsIgnored ? [REUSED_NETWORK_FLAGS_WARNING] : []
24831
- ];
25446
+ const publicOrigin = reused ? void 0 : remote?.origin();
25447
+ const warnings = [];
25448
+ if (sandbox) warnings.push(`Sandbox: everything here is throwaway (${sandbox.home}) and is deleted when this terminal closes.`);
24832
25449
  if (!readout) {
24833
25450
  console.log(`${reused ? "reusing" : "started"} Frizz ${PACKAGE_VERSION} for ${workspace.root}`);
24834
25451
  console.log(url);
24835
- for (const address of network) console.log(address);
24836
25452
  if (publicOrigin) console.log(activeAccessLink?.url ?? `${publicOrigin}/`);
24837
25453
  for (const warning of warnings) console.log(warning);
24838
25454
  return;
24839
25455
  }
24840
- const home = homedir10();
25456
+ const home = homedir11();
24841
25457
  readout.ready(
24842
25458
  [
24843
25459
  { label: "Local", value: boardAddress(url), accent: true },
24844
- ...network.map((address) => ({ label: "Network", value: `${address}/`, accent: true })),
24845
25460
  ...publicOrigin ? [{ label: "Public", value: activeAccessLink?.url ?? `${publicOrigin}/`, accent: true }] : [],
24846
25461
  { label: "Project", value: `${workspace.name} \u2014 ${tildePath(workspace.root, home)}` },
24847
25462
  ...logger.file ? [{ label: "Logs", value: tildePath(logger.file, home) }] : []
@@ -24849,10 +25464,11 @@ async function openOrPrint(port, reused, path = "") {
24849
25464
  reused ? (
24850
25465
  // This launch owns nothing and exits immediately, so ctrl-c would not stop what it reopened.
24851
25466
  "reopened the server already running for this project \xB7 stop it from the terminal that started it"
24852
- ) : options.debug ? void 0 : "press ctrl-c to stop \xB7 run with --debug for the full event feed",
25467
+ ) : options.debug ? void 0 : publicOrigin ? "press L for a fresh sign-in link \xB7 R to change how this board is reached \xB7 ctrl-c to stop" : "press R to reach this board from a phone or another machine \xB7 ctrl-c to stop",
24853
25468
  {
24854
25469
  ...reused ? { status: `already running on port ${port}` } : {},
24855
- ...warnings.length > 0 ? { warning: warnings.join(" ") } : {}
25470
+ ...warnings.length > 0 ? { warning: warnings.join(" ") } : {},
25471
+ ...activeAccessLink && !reused ? { qr: renderQrLines(activeAccessLink.url) } : {}
24856
25472
  }
24857
25473
  );
24858
25474
  }
@@ -24889,21 +25505,17 @@ async function runSupervisor(port, token) {
24889
25505
  void refreshUpdateAvailable();
24890
25506
  const updateAvailablePoll = setInterval(() => void refreshUpdateAvailable(), 30 * 60 * 1e3);
24891
25507
  updateAvailablePoll.unref();
24892
- if (options.cloud && !cloudConfig) {
24893
- cloudConfig = await establishCloudConfig(await promptForCloudName(), port);
24894
- writeCloudConfig(cloudConfig);
24895
- bind.publicOrigin = `https://${cloudConfig.hostname}`;
24896
- justClaimed = true;
24897
- }
24898
25508
  const supervisor = await startDevSupervisor({
24899
25509
  port,
24900
- host: bind.host,
24901
- allowedHosts: bind.allowedHosts,
24902
- ...bind.publicOrigin ? { publicOrigin: bind.publicOrigin } : {},
25510
+ host: LOOPBACK_BIND_HOST,
25511
+ allowedHosts: [],
24903
25512
  // A spent code repaints the open QR pane as stale, so nobody photographs a dead link.
24904
25513
  onCodeConsumed: () => accessPane?.markConsumed(),
24905
25514
  // Persisted beside the project's other state, so a restart does not sign every device out.
24906
- ...bind.publicOrigin ? { sessionKey: loadOrCreateSessionKey(workspace.stateDir) } : {},
25515
+ // Always, not only when public: the origin can be switched on later (press R), and a phone signed
25516
+ // in through one name stays signed in through the next.
25517
+ sessionKey: loadOrCreateSessionKey(workspace.stateDir),
25518
+ sessionDirectory: fileSessionDirectory(workspace.stateDir),
24907
25519
  cwd: workspace.root,
24908
25520
  stateDir: workspace.stateDir,
24909
25521
  launchTarget: target,
@@ -24945,49 +25557,42 @@ async function runSupervisor(port, token) {
24945
25557
  process.exit(0);
24946
25558
  }
24947
25559
  });
24948
- activeAccessLink = bind.publicOrigin ? supervisor.issueAccessLink() : null;
24949
- if (cloudConfig) {
24950
- if (isRelayConfig(cloudConfig)) {
24951
- if (!justClaimed) {
24952
- await resolveRunToken(cloudConfig, port, homedir10(), (message) => {
24953
- logger.warn("relay", message);
24954
- console.error(`frizz: ${message}`);
24955
- });
24956
- }
24957
- tunnel = await startRelay(cloudConfig, port, homedir10(), (message) => logger.info("relay", message));
24958
- logger.info("relay", `serving ${cloudConfig.hostname} through the Frizz relay`);
24959
- } else {
24960
- const runToken = justClaimed ? readTunnelToken() : await resolveRunToken(cloudConfig, port, homedir10(), (message) => {
24961
- logger.warn("tunnel", message);
24962
- console.error(`frizz: ${message}`);
24963
- });
24964
- tunnel = startTunnel(
24965
- cloudConfig,
24966
- (code) => {
24967
- if (code === 0 || code === null) return;
24968
- logger.error("tunnel", `cloudflared exited with code ${code}; the public hostname is now unreachable`);
24969
- },
24970
- (message) => {
24971
- logger.error("tunnel", message);
24972
- console.error(`frizz: ${message}`);
24973
- },
24974
- homedir10(),
24975
- runToken ?? void 0
24976
- );
24977
- logger.info("tunnel", `running cloudflared for ${cloudConfig.hostname}`);
24978
- }
25560
+ remote = createRemoteController({ host: supervisor, port, log: logger, say: (message) => console.error(`frizz: ${message}`) });
25561
+ try {
25562
+ await remote.serveSaved();
25563
+ } catch (error) {
25564
+ const message = error instanceof Error ? error.message : String(error);
25565
+ logger.error("remote", message);
25566
+ console.error(`frizz: the saved remote setup could not start: ${message}`);
24979
25567
  }
24980
- if (bind.publicOrigin) accessPane = installAccessPane({ issue: () => supervisor.issueAccessLink() });
25568
+ activeAccessLink = remote.origin() ? supervisor.issueAccessLink() : null;
25569
+ accessPane = createAccessPane({ issue: () => supervisor.issueAccessLink() });
25570
+ const remotePane = createRemotePane({
25571
+ port,
25572
+ current: () => remote?.current() ?? null,
25573
+ apply: (next, applyOptions) => remote.apply(next, applyOptions),
25574
+ claim: (name) => establishCloudConfig(name, port),
25575
+ issueLink: () => supervisor.issueAccessLink(),
25576
+ probes: { github: probeGithub, cloudflared: probeCloudflared, tailscale: probeTailscale },
25577
+ onChanged: (config) => logger.info("remote", config ? `reached at https://${config.hostname}` : "loopback only"),
25578
+ sandbox: sandbox !== null
25579
+ });
25580
+ paneHost = installPaneHost({ bindings: { l: accessPane, L: accessPane, r: remotePane, R: remotePane } });
24981
25581
  const stop = createSupervisorShutdownHandler({
24982
25582
  close: () => supervisor.close(),
24983
25583
  force: () => supervisor.forceStop(),
24984
25584
  release: () => owner.release(),
25585
+ // Acknowledge the first signal on the spot; the drain that follows is bounded but not instant.
25586
+ onStop: () => {
25587
+ logger.info("launcher", "stop signal received; draining the control plane");
25588
+ readout?.notice("progress", "Stopping", "draining the control plane \u2014 press ctrl-c again to force");
25589
+ },
24985
25590
  exit: (code) => {
24986
25591
  logger.info("launcher", `stopped with code ${code}`);
24987
- accessPane?.dispose();
24988
- tunnel?.stop();
25592
+ paneHost?.dispose();
25593
+ remote?.stop();
24989
25594
  const suffix = logger.file ? `
24990
- log: ${tildePath(logger.file, homedir10())}` : "";
25595
+ log: ${tildePath(logger.file, homedir11())}` : "";
24991
25596
  process.stdout.write(
24992
25597
  code === 0 ? `
24993
25598
  Frizz stopped. Running agents keep going \u2014 they are detached daemons.${suffix}
@@ -25031,7 +25636,7 @@ try {
25031
25636
  let release = await acquireGlobalLaunchLock();
25032
25637
  let portReservation;
25033
25638
  try {
25034
- const allocation = await allocatePort(options.port, readPreferredPort(workspace.stateDir), { host: bind.host });
25639
+ const allocation = await allocatePort(options.port, readPreferredPort(workspace.stateDir), { host: LOOPBACK_BIND_HOST });
25035
25640
  const port = allocation.port;
25036
25641
  portReservation = allocation.release;
25037
25642
  const running = runSupervisor(port, claim.lease.token);