sootsim 0.0.1 → 0.0.3

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 (122) hide show
  1. package/README.md +12 -0
  2. package/dist-cli/bin.js +16 -10
  3. package/dist-cli/chunks/agent-D5NBV32O.js +61 -0
  4. package/dist-cli/chunks/agent-wrapper-Y7I5QGHM.js +15 -0
  5. package/dist-cli/chunks/assert-EJ7DQS2H.js +47 -0
  6. package/dist-cli/chunks/auto-bootstrap-Q7GNLISM.js +2 -0
  7. package/dist-cli/chunks/{chunk-7X6OPSRD.js → chunk-2FPPPJE5.js} +2 -2
  8. package/dist-cli/chunks/{chunk-G5MR66EB.js → chunk-3K6VDPVD.js} +2 -2
  9. package/dist-cli/chunks/{chunk-PWXPA745.js → chunk-3SLEIN6B.js} +1 -1
  10. package/dist-cli/chunks/chunk-3WPAEUOO.js +1 -0
  11. package/dist-cli/chunks/chunk-44CBTM22.js +2 -0
  12. package/dist-cli/chunks/chunk-46LRF7PH.js +5 -0
  13. package/dist-cli/chunks/chunk-4RYT6AQV.js +16 -0
  14. package/dist-cli/chunks/chunk-5AG24UFX.js +119 -0
  15. package/dist-cli/chunks/chunk-5IPP4HAW.js +5 -0
  16. package/dist-cli/chunks/{chunk-J2S3OCWA.js → chunk-AFTHIY3L.js} +1 -1
  17. package/dist-cli/chunks/chunk-BU3TZP4Y.js +11 -0
  18. package/dist-cli/chunks/chunk-BYLX2DO4.js +27 -0
  19. package/dist-cli/chunks/chunk-CPMW2QLM.js +1 -0
  20. package/dist-cli/chunks/{chunk-YCETS3B3.js → chunk-CQ6PX2EU.js} +2 -2
  21. package/dist-cli/chunks/chunk-D4JFMCXD.js +2 -0
  22. package/dist-cli/chunks/chunk-EEBR5YP5.js +62 -0
  23. package/dist-cli/chunks/chunk-EQ7G3UHS.js +4 -0
  24. package/dist-cli/chunks/{chunk-64TOMNZX.js → chunk-FTRI7SVV.js} +2 -2
  25. package/dist-cli/chunks/{chunk-GPVPHE2B.js → chunk-H3JVJXOC.js} +2 -2
  26. package/dist-cli/chunks/chunk-LV5U7TI4.js +1 -0
  27. package/dist-cli/chunks/chunk-NKJLTISU.js +4 -0
  28. package/dist-cli/chunks/chunk-O2HBPZW5.js +22 -0
  29. package/dist-cli/chunks/{chunk-KSACMDXK.js → chunk-OG5CKIPC.js} +2 -2
  30. package/dist-cli/chunks/{chunk-E522F5JW.js → chunk-P5C3UASK.js} +1 -1
  31. package/dist-cli/chunks/chunk-REYWQVAH.js +2 -0
  32. package/dist-cli/chunks/chunk-RLS6PHBW.js +4 -0
  33. package/dist-cli/chunks/chunk-SUZR2SZZ.js +34 -0
  34. package/dist-cli/chunks/{chunk-OROM7DZI.js → chunk-USRNDVQ3.js} +1 -1
  35. package/dist-cli/chunks/{chunk-JSF5LPNT.js → chunk-UZL5ZZ4E.js} +5 -5
  36. package/dist-cli/chunks/{chunk-QOBRRY5X.js → chunk-VI3VW5BL.js} +1 -1
  37. package/dist-cli/chunks/chunk-WUYJFYOW.js +2 -0
  38. package/dist-cli/chunks/chunk-X2W4IRXK.js +3 -0
  39. package/dist-cli/chunks/chunk-XJBPH4JR.js +308 -0
  40. package/dist-cli/chunks/chunk-ZSRMXBGK.js +2 -0
  41. package/dist-cli/chunks/{compat-MRN2ORY5.js → compat-5KSMOWLB.js} +4 -4
  42. package/dist-cli/chunks/{config-CO5IYWUY.js → config-NJB6PQHU.js} +5 -5
  43. package/dist-cli/chunks/control-2F3AGZAO.js +2 -0
  44. package/dist-cli/chunks/{daemon-G4XVRFHM.js → daemon-MLG65V4S.js} +2 -2
  45. package/dist-cli/chunks/{debug-ZNSZTWT6.js → debug-QVOBTTLP.js} +4 -4
  46. package/dist-cli/chunks/demo-app-registry-XRYNJ4GC.js +2 -0
  47. package/dist-cli/chunks/{detox-JEGYNTYV.js → detox-ZZSNZL4T.js} +2 -2
  48. package/dist-cli/chunks/{device-BS34FAFM.js → device-PQB3YGHN.js} +2 -2
  49. package/dist-cli/chunks/drivers-GWDQEGWD.js +2 -0
  50. package/dist-cli/chunks/electron-JB26VHOO.js +15 -0
  51. package/dist-cli/chunks/flow-7JRQXMFV.js +2 -0
  52. package/dist-cli/chunks/{hints-7Z656W4H.js → hints-IGYDXXDS.js} +2 -2
  53. package/dist-cli/chunks/home-paths-CEGSGQTD.js +2 -0
  54. package/dist-cli/chunks/{inspect-NAHXP2M5.js → inspect-DSU6ELRM.js} +153 -165
  55. package/dist-cli/chunks/install-K6IJKADG.js +65 -0
  56. package/dist-cli/chunks/{install-desktop-PYIZIH67.js → install-desktop-SC3LNFFF.js} +8 -4
  57. package/dist-cli/chunks/install-dev-desktop-4DP3UY2X.js +100 -0
  58. package/dist-cli/chunks/keys-R5LAPAAL.js +19 -0
  59. package/dist-cli/chunks/launch-K3WJV4QA.js +16 -0
  60. package/dist-cli/chunks/{login-Z5Z54HUJ.js → login-A23PYJAW.js} +5 -5
  61. package/dist-cli/chunks/{logout-T2QDYGCB.js → logout-AJ24PH5O.js} +2 -2
  62. package/dist-cli/chunks/{maestro-4AXTS7OE.js → maestro-YALWKKGU.js} +2 -2
  63. package/dist-cli/chunks/{preview-NMGWHWMX.js → preview-D35EEONY.js} +2 -2
  64. package/dist-cli/chunks/{profile-6RGJA4FR.js → profile-MAF7NM5Q.js} +3 -3
  65. package/dist-cli/chunks/record-ZCPQNGFW.js +37 -0
  66. package/dist-cli/chunks/runtime-Z2WIXYUN.js +25 -0
  67. package/dist-cli/chunks/{screenshot-R3GCCSCI.js → screenshot-NQVZYC3C.js} +3 -3
  68. package/dist-cli/chunks/screenshot-mode-E45D2ZFH.js +17 -0
  69. package/dist-cli/chunks/{screenshots-4UQJE4NC.js → screenshots-I4SQI4DA.js} +2 -2
  70. package/dist-cli/chunks/server-ZUXKJRR5.js +29 -0
  71. package/dist-cli/chunks/{skills-2PPKPL4B.js → skills-N4U63E5W.js} +2 -2
  72. package/dist-cli/chunks/store-4A6X4GBJ.js +2 -0
  73. package/dist-cli/chunks/{test-5LFKOQ4M.js → test-VBD6N3AR.js} +3 -3
  74. package/dist-cli/chunks/upload-Y6FZ5XF2.js +2 -0
  75. package/dist-cli/chunks/{whoami-H6FW34JS.js → whoami-4K6JGMWH.js} +2 -2
  76. package/dist-lib/agent-daemon-client.cjs +414 -0
  77. package/dist-lib/agent-events.cjs +48 -0
  78. package/dist-lib/agent-sessions.cjs +692 -0
  79. package/dist-lib/attached-projects.cjs +448 -0
  80. package/dist-lib/auth/shared-session.cjs +174 -0
  81. package/dist-lib/backend-origin.cjs +70 -0
  82. package/dist-lib/bridge-constants.cjs +32 -0
  83. package/dist-lib/cli-constants.cjs +32 -0
  84. package/dist-lib/config.cjs +88 -0
  85. package/dist-lib/dev-bundle-resolution.cjs +236 -0
  86. package/dist-lib/home-paths.cjs +234 -0
  87. package/dist-lib/host/bridge-host.cjs +3458 -0
  88. package/dist-lib/index.cjs +361 -0
  89. package/dist-lib/metro.cjs +215 -0
  90. package/dist-lib/render-mode.cjs +54 -0
  91. package/dist-lib/vite-base.cjs +4217 -0
  92. package/dist-lib/vite.cjs +178 -0
  93. package/package.json +80 -13
  94. package/scripts/postinstall.cjs +70 -0
  95. package/dist-cli/chunks/bridge-host-2EY7Z4AO.js +0 -2
  96. package/dist-cli/chunks/chunk-3C3ZH7PP.js +0 -4
  97. package/dist-cli/chunks/chunk-3R4ZZESY.js +0 -119
  98. package/dist-cli/chunks/chunk-74XPLOV4.js +0 -2
  99. package/dist-cli/chunks/chunk-7LMDCMSI.js +0 -8
  100. package/dist-cli/chunks/chunk-A2CZQIWO.js +0 -1
  101. package/dist-cli/chunks/chunk-CKZ376AY.js +0 -322
  102. package/dist-cli/chunks/chunk-E5UBZEYR.js +0 -2
  103. package/dist-cli/chunks/chunk-HOIHCO7S.js +0 -3
  104. package/dist-cli/chunks/chunk-KQWZZ56P.js +0 -2
  105. package/dist-cli/chunks/chunk-KSB6MSZ4.js +0 -34
  106. package/dist-cli/chunks/chunk-KXYKAYYB.js +0 -51
  107. package/dist-cli/chunks/chunk-MBFP2LVH.js +0 -3
  108. package/dist-cli/chunks/chunk-MPSZ5EWF.js +0 -16
  109. package/dist-cli/chunks/chunk-X2U72K7X.js +0 -1
  110. package/dist-cli/chunks/control-Y7TKKB6D.js +0 -2
  111. package/dist-cli/chunks/dev-ZUKCZQEX.js +0 -25
  112. package/dist-cli/chunks/dev-checkout-IEZVVTCN.js +0 -2
  113. package/dist-cli/chunks/drivers-46PFFIDF.js +0 -2
  114. package/dist-cli/chunks/electron-P2KOPX2S.js +0 -15
  115. package/dist-cli/chunks/flow-VVOF6UNC.js +0 -2
  116. package/dist-cli/chunks/install-EPUJX4AT.js +0 -67
  117. package/dist-cli/chunks/record-IE27Z2GA.js +0 -37
  118. package/dist-cli/chunks/screenshot-mode-SZQDNGYE.js +0 -17
  119. package/dist-cli/chunks/server-AN2G5KO4.js +0 -21
  120. package/dist-cli/chunks/store-PU5ES4YQ.js +0 -2
  121. package/dist-cli/chunks/upload-BYNPC54C.js +0 -2
  122. package/dist-cli/chunks/vite-plugin-5AEUUBKP.js +0 -9
@@ -0,0 +1,3458 @@
1
+ /*! sootsim v0.0.3 | (c) 2026 Tamagui LLC | Proprietary — see LICENSE */
2
+ let __sootsim_import_meta_url = ''; try { __sootsim_import_meta_url = require('url').pathToFileURL(__filename).href; } catch {}
3
+ "use strict";
4
+ var __create = Object.create;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
7
+ var __getOwnPropNames = Object.getOwnPropertyNames;
8
+ var __getProtoOf = Object.getPrototypeOf;
9
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
10
+ var __export = (target, all) => {
11
+ for (var name in all)
12
+ __defProp(target, name, { get: all[name], enumerable: true });
13
+ };
14
+ var __copyProps = (to, from, except, desc) => {
15
+ if (from && typeof from === "object" || typeof from === "function") {
16
+ for (let key of __getOwnPropNames(from))
17
+ if (!__hasOwnProp.call(to, key) && key !== except)
18
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
19
+ }
20
+ return to;
21
+ };
22
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
23
+ // If the importer is in node compatibility mode or this is not an ESM
24
+ // file that has been converted to a CommonJS file using a Babel-
25
+ // compatible transform (i.e. "__esModule" has not been set), then set
26
+ // "default" to the CommonJS "module.exports" for node compatibility.
27
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
28
+ mod
29
+ ));
30
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
31
+
32
+ // src/host/bridge-host.ts
33
+ var bridge_host_exports = {};
34
+ __export(bridge_host_exports, {
35
+ SootSimBridgeHost: () => SootSimBridgeHost
36
+ });
37
+ module.exports = __toCommonJS(bridge_host_exports);
38
+ var import_child_process2 = require("child_process");
39
+ var import_fs2 = __toESM(require("fs"), 1);
40
+ var import_http2 = require("http");
41
+ var import_path2 = __toESM(require("path"), 1);
42
+ var import_ws = require("ws");
43
+
44
+ // src/bridge-constants.ts
45
+ var DEFAULT_SOOTSIM_BRIDGE_PORT = 7668;
46
+
47
+ // src/home-paths.ts
48
+ var import_fs = __toESM(require("fs"), 1);
49
+ var import_os = require("os");
50
+ var import_path = __toESM(require("path"), 1);
51
+ var SOOTSIM_HOME_ENV = "SOOTSIM_HOME";
52
+ var ACTIVE_RUNTIME_FILE = "active";
53
+ var DAEMON_LOCKFILE = "daemon.json";
54
+ var DAEMON_HEARTBEAT_STALE_MS = 3e4;
55
+ function sootsimHomeDir() {
56
+ const override = process.env[SOOTSIM_HOME_ENV];
57
+ if (override && override.length > 0) return import_path.default.resolve(override);
58
+ return import_path.default.join((0, import_os.homedir)(), ".sootsim");
59
+ }
60
+ function runtimesDir() {
61
+ return import_path.default.join(sootsimHomeDir(), "runtimes");
62
+ }
63
+ function runtimeDir(version) {
64
+ return import_path.default.join(runtimesDir(), version);
65
+ }
66
+ function activeRuntimeFile() {
67
+ return import_path.default.join(runtimesDir(), ACTIVE_RUNTIME_FILE);
68
+ }
69
+ function cacheDir() {
70
+ return import_path.default.join(sootsimHomeDir(), "cache");
71
+ }
72
+ function daemonLockfilePath() {
73
+ return import_path.default.join(sootsimHomeDir(), DAEMON_LOCKFILE);
74
+ }
75
+ function ensureSootsimHome() {
76
+ import_fs.default.mkdirSync(sootsimHomeDir(), { recursive: true });
77
+ import_fs.default.mkdirSync(runtimesDir(), { recursive: true });
78
+ import_fs.default.mkdirSync(cacheDir(), { recursive: true });
79
+ }
80
+ function readActiveRuntime() {
81
+ try {
82
+ const value = import_fs.default.readFileSync(activeRuntimeFile(), "utf8").trim();
83
+ return value.length > 0 ? value : null;
84
+ } catch {
85
+ return null;
86
+ }
87
+ }
88
+ function writeActiveRuntime(version) {
89
+ import_fs.default.mkdirSync(runtimesDir(), { recursive: true });
90
+ import_fs.default.writeFileSync(activeRuntimeFile(), `${version}
91
+ `, "utf8");
92
+ }
93
+ function listInstalledRuntimes() {
94
+ try {
95
+ return import_fs.default.readdirSync(runtimesDir(), { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort(compareSemver);
96
+ } catch {
97
+ return [];
98
+ }
99
+ }
100
+ function compareSemver(a, b) {
101
+ const parse = (v) => {
102
+ const hyphen = v.indexOf("-");
103
+ const core = hyphen >= 0 ? v.slice(0, hyphen) : v;
104
+ const pre = hyphen >= 0 ? v.slice(hyphen + 1) : "";
105
+ const parts = core.split(".").map((n) => Number.parseInt(n, 10));
106
+ if (parts.some((n) => !Number.isFinite(n))) return [[Number.POSITIVE_INFINITY], v];
107
+ return [parts, pre];
108
+ };
109
+ const [an, ap] = parse(a);
110
+ const [bn, bp] = parse(b);
111
+ for (let i = 0; i < Math.max(an.length, bn.length); i++) {
112
+ const av = an[i] ?? 0;
113
+ const bv = bn[i] ?? 0;
114
+ if (av !== bv) return av - bv;
115
+ }
116
+ if (ap === bp) return 0;
117
+ if (!ap) return 1;
118
+ if (!bp) return -1;
119
+ return ap < bp ? -1 : 1;
120
+ }
121
+ function activeRuntimeDir() {
122
+ const version = readActiveRuntime();
123
+ if (!version) return null;
124
+ const dir = runtimeDir(version);
125
+ try {
126
+ if (import_fs.default.statSync(dir).isDirectory()) return dir;
127
+ } catch {
128
+ }
129
+ return null;
130
+ }
131
+ var DAEMON_LOCKFILE_MAX_BYTES = 16 * 1024;
132
+ function readDaemonLockfile() {
133
+ try {
134
+ const fd = import_fs.default.openSync(daemonLockfilePath(), "r");
135
+ try {
136
+ const buf = Buffer.alloc(DAEMON_LOCKFILE_MAX_BYTES);
137
+ const bytesRead = import_fs.default.readSync(fd, buf, 0, DAEMON_LOCKFILE_MAX_BYTES, 0);
138
+ const raw = buf.subarray(0, bytesRead).toString("utf8");
139
+ const parsed = JSON.parse(raw);
140
+ if (parsed && parsed.schema === 1 && typeof parsed.pid === "number" && typeof parsed.bridgePort === "number" && typeof parsed.runtimePort === "number" && typeof parsed.startedAt === "number" && typeof parsed.heartbeatAt === "number") {
141
+ return parsed;
142
+ }
143
+ return null;
144
+ } finally {
145
+ import_fs.default.closeSync(fd);
146
+ }
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+ function isDaemonLockfileFresh(lock, now = Date.now()) {
152
+ if (!lock) return false;
153
+ if (now - lock.heartbeatAt > DAEMON_HEARTBEAT_STALE_MS) return false;
154
+ try {
155
+ process.kill(lock.pid, 0);
156
+ return true;
157
+ } catch {
158
+ return false;
159
+ }
160
+ }
161
+ function writeDaemonLockfile(data) {
162
+ ensureSootsimHome();
163
+ const tmp = `${daemonLockfilePath()}.tmp`;
164
+ import_fs.default.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}
165
+ `, "utf8");
166
+ import_fs.default.renameSync(tmp, daemonLockfilePath());
167
+ }
168
+ function claimDaemonLockfile(data) {
169
+ ensureSootsimHome();
170
+ const existing = readDaemonLockfile();
171
+ if (existing && isDaemonLockfileFresh(existing) && existing.pid !== data.pid) {
172
+ return false;
173
+ }
174
+ writeDaemonLockfile(data);
175
+ return true;
176
+ }
177
+ function removeDaemonLockfile() {
178
+ try {
179
+ import_fs.default.unlinkSync(daemonLockfilePath());
180
+ } catch {
181
+ }
182
+ }
183
+
184
+ // src/host/agent-host.ts
185
+ var import_node_fs4 = __toESM(require("node:fs"), 1);
186
+ var import_node_path4 = __toESM(require("node:path"), 1);
187
+
188
+ // scripts/dev-server-scanner.ts
189
+ var import_child_process = require("child_process");
190
+ var import_http = __toESM(require("http"), 1);
191
+ var import_net = __toESM(require("net"), 1);
192
+ var import_util = require("util");
193
+
194
+ // src/config.ts
195
+ var SOOTSIM_CONFIG_QUERY_PARAM = "sootsimConfig";
196
+ function hasOwnKeys(value) {
197
+ return !!value && Object.keys(value).length > 0;
198
+ }
199
+ function hasSootSimConfig(config) {
200
+ if (!config) return false;
201
+ return hasOwnKeys(config.modules) || hasOwnKeys(config.turboModules) || hasOwnKeys(config.env) || hasOwnKeys(config.settings) || hasOwnKeys(config.initialState);
202
+ }
203
+ function applySootSimConfigToUrl(url, config) {
204
+ const parsed = new URL(url);
205
+ if (hasSootSimConfig(config)) {
206
+ parsed.searchParams.set(SOOTSIM_CONFIG_QUERY_PARAM, JSON.stringify(config));
207
+ } else {
208
+ parsed.searchParams.delete(SOOTSIM_CONFIG_QUERY_PARAM);
209
+ }
210
+ return parsed.toString();
211
+ }
212
+
213
+ // src/native-dev-bundle-url.ts
214
+ function isAbsoluteHttpUrl(url) {
215
+ return /^https?:\/\//i.test(url);
216
+ }
217
+ function isNativeDevBundlePath(pathname) {
218
+ return pathname.endsWith(".bundle");
219
+ }
220
+ function normalizeNativeDevBundleUrl(bundleUrl) {
221
+ try {
222
+ const isAbsolute = isAbsoluteHttpUrl(bundleUrl);
223
+ const parsed = new URL(bundleUrl, "http://soot.local");
224
+ if (!isNativeDevBundlePath(parsed.pathname)) return bundleUrl;
225
+ if (!parsed.searchParams.has("dev")) parsed.searchParams.set("dev", "true");
226
+ if (!parsed.searchParams.has("minify")) {
227
+ parsed.searchParams.set("minify", "false");
228
+ }
229
+ if (isAbsolute) return parsed.toString();
230
+ return `${parsed.pathname}${parsed.search}${parsed.hash}`;
231
+ } catch {
232
+ return bundleUrl;
233
+ }
234
+ }
235
+
236
+ // scripts/demo-app-registry.ts
237
+ var import_node_fs = require("node:fs");
238
+ var import_node_os = require("node:os");
239
+ var import_node_path = require("node:path");
240
+ var HOME = (0, import_node_os.homedir)();
241
+ function findWorkspaceRoot(startDir) {
242
+ let dir = startDir;
243
+ while (true) {
244
+ if ((0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "pnpm-workspace.yaml")) || (0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "turbo.json")) || (0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "nx.json")) || (0, import_node_fs.existsSync)((0, import_node_path.join)(dir, "lerna.json"))) {
245
+ return dir;
246
+ }
247
+ const packageJsonPath = (0, import_node_path.join)(dir, "package.json");
248
+ if ((0, import_node_fs.existsSync)(packageJsonPath)) {
249
+ try {
250
+ const pkg = JSON.parse((0, import_node_fs.readFileSync)(packageJsonPath, "utf8"));
251
+ if (pkg.workspaces) return dir;
252
+ } catch {
253
+ }
254
+ }
255
+ const parent = (0, import_node_path.dirname)(dir);
256
+ if (parent === dir) return null;
257
+ dir = parent;
258
+ }
259
+ }
260
+ function resolveWorkspaceScriptPath(workspaceRelativePath, packageRelativePath) {
261
+ const workspaceRoot = findWorkspaceRoot(process.cwd());
262
+ const candidates = [
263
+ workspaceRoot ? (0, import_node_path.resolve)(workspaceRoot, workspaceRelativePath) : null,
264
+ (0, import_node_path.resolve)(process.cwd(), workspaceRelativePath),
265
+ (0, import_node_path.resolve)(process.cwd(), packageRelativePath)
266
+ ].filter((candidate) => Boolean(candidate));
267
+ for (const candidate of candidates) {
268
+ if ((0, import_node_fs.existsSync)(candidate)) return candidate;
269
+ }
270
+ return candidates[0] ?? (0, import_node_path.resolve)(process.cwd(), workspaceRelativePath);
271
+ }
272
+ var getExpensifyProxyScript = () => resolveWorkspaceScriptPath(
273
+ "packages/sootsim-engine/scripts/expensify-web-proxy.ts",
274
+ "scripts/expensify-web-proxy.ts"
275
+ );
276
+ var EXPENSIFY_NATIVE_PROXY_ENV = {
277
+ USE_NGROK: "true",
278
+ NGROK_URL: "http://localhost:9000/",
279
+ SECURE_NGROK_URL: "http://localhost:9000/"
280
+ };
281
+ var UNISWAP_REPO_DIR = (0, import_node_path.join)(HOME, "github/uniswap-interface");
282
+ var UNISWAP_APP_DIR = (0, import_node_path.join)(UNISWAP_REPO_DIR, "apps/mobile");
283
+ var UNISWAP_ENV_LOCAL_FILE = (0, import_node_path.join)(UNISWAP_REPO_DIR, ".env.defaults.local");
284
+ var UNISWAP_PLACEHOLDER = "stored-in-.env.local";
285
+ var UNISWAP_DEMO_ENV_MARKER = "# sootsim demo env overrides";
286
+ var UNISWAP_FORCE_UPGRADE_HOOK_FILE = (0, import_node_path.join)(
287
+ UNISWAP_REPO_DIR,
288
+ "packages/uniswap/src/features/forceUpgrade/hooks/useForceUpgradeStatus.ts"
289
+ );
290
+ var UNISWAP_FORCE_UPGRADE_NOTIFICATION_FILE = (0, import_node_path.join)(
291
+ UNISWAP_REPO_DIR,
292
+ "apps/mobile/src/notification-service/data-sources/createForceUpgradeNotificationDataSource.ts"
293
+ );
294
+ var UNISWAP_FORCE_UPGRADE_PATCH_MARKER = "SOOTSIM_DEMO_DISABLE_FORCE_UPGRADE";
295
+ function parseEnvFile(filePath) {
296
+ if (!(0, import_node_fs.existsSync)(filePath)) return {};
297
+ const env = {};
298
+ const source = (0, import_node_fs.readFileSync)(filePath, "utf8");
299
+ for (const rawLine of source.split(/\r?\n/)) {
300
+ const line = rawLine.trim();
301
+ if (!line || line.startsWith("#")) continue;
302
+ const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
303
+ if (!match) continue;
304
+ let value = match[2].trim();
305
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
306
+ value = value.slice(1, -1);
307
+ }
308
+ env[match[1]] = value;
309
+ }
310
+ return env;
311
+ }
312
+ function isUsableUniswapEnvValue(value) {
313
+ if (!value) return false;
314
+ const trimmed = value.trim();
315
+ if (!trimmed) return false;
316
+ if (trimmed.includes(UNISWAP_PLACEHOLDER)) return false;
317
+ if (trimmed === "TRADING_API_KEY" || trimmed === "UNISWAP_API_KEY") return false;
318
+ return true;
319
+ }
320
+ function pickEnvValue(sources, keys) {
321
+ for (const source of sources) {
322
+ for (const key of keys) {
323
+ const value = source[key];
324
+ if (isUsableUniswapEnvValue(value)) return value.trim();
325
+ }
326
+ }
327
+ return void 0;
328
+ }
329
+ function resolveUniswapDemoEnv() {
330
+ const localEnv = parseEnvFile(UNISWAP_ENV_LOCAL_FILE);
331
+ const webEnv = parseEnvFile((0, import_node_path.join)(UNISWAP_REPO_DIR, "apps/web/.env"));
332
+ const sources = [
333
+ process.env,
334
+ localEnv,
335
+ webEnv
336
+ ];
337
+ const env = {};
338
+ const bindings = [
339
+ ["AMPLITUDE_PROXY_URL_OVERRIDE", ["REACT_APP_AMPLITUDE_PROXY_URL"]],
340
+ ["QUICKNODE_ENDPOINT_NAME", ["REACT_APP_QUICKNODE_ENDPOINT_NAME"]],
341
+ ["QUICKNODE_ENDPOINT_TOKEN", ["REACT_APP_QUICKNODE_ENDPOINT_TOKEN"]],
342
+ ["INFURA_KEY", ["REACT_APP_INFURA_KEY"]],
343
+ ["STATSIG_API_KEY", ["REACT_APP_STATSIG_API_KEY"]],
344
+ ["STATSIG_PROXY_URL_OVERRIDE", ["REACT_APP_STATSIG_PROXY_URL"]],
345
+ ["WALLETCONNECT_PROJECT_ID", ["REACT_APP_WALLET_CONNECT_PROJECT_ID"]],
346
+ ["WALLETCONNECT_PROJECT_ID_BETA", ["REACT_APP_WALLET_CONNECT_PROJECT_ID"]],
347
+ ["WALLETCONNECT_PROJECT_ID_DEV", ["REACT_APP_WALLET_CONNECT_PROJECT_ID"]],
348
+ ["TRADING_API_KEY", ["REACT_APP_TRADING_API_KEY"]],
349
+ ["UNISWAP_API_KEY", []]
350
+ ];
351
+ for (const [target, aliases] of bindings) {
352
+ const value = pickEnvValue(sources, [target, ...aliases]);
353
+ if (!value) continue;
354
+ env[target] = value;
355
+ for (const alias of aliases) {
356
+ env[alias] = value;
357
+ }
358
+ }
359
+ const hasPrivateGatewayKeys = isUsableUniswapEnvValue(env.TRADING_API_KEY) && isUsableUniswapEnvValue(env.UNISWAP_API_KEY);
360
+ if (!hasPrivateGatewayKeys) {
361
+ const publicGraphqlUrl = pickEnvValue(sources, ["GRAPHQL_URL_OVERRIDE", "REACT_APP_AWS_API_ENDPOINT"]) || "https://interface.gateway.uniswap.org/v1/graphql";
362
+ env.API_BASE_URL_OVERRIDE = "https://interface.gateway.uniswap.org";
363
+ env.API_BASE_URL_V2_OVERRIDE = "https://interface.gateway.uniswap.org/v2";
364
+ env.GRAPHQL_URL_OVERRIDE = publicGraphqlUrl;
365
+ env.TRADING_API_URL_OVERRIDE = "https://trading-api-labs.interface.gateway.uniswap.org";
366
+ env.FOR_API_URL_OVERRIDE = "https://for.interface.gateway.uniswap.org/v2/FOR.v1.FORService";
367
+ }
368
+ return env;
369
+ }
370
+ function ensureUniswapDemoEnvLocal() {
371
+ const existingSource = (0, import_node_fs.existsSync)(UNISWAP_ENV_LOCAL_FILE) ? (0, import_node_fs.readFileSync)(UNISWAP_ENV_LOCAL_FILE, "utf8") : "";
372
+ if (existingSource && !existingSource.includes(UNISWAP_DEMO_ENV_MARKER)) {
373
+ return;
374
+ }
375
+ const env = resolveUniswapDemoEnv();
376
+ const lines = [UNISWAP_DEMO_ENV_MARKER];
377
+ for (const [key, value] of Object.entries(env).sort(([a], [b]) => a.localeCompare(b))) {
378
+ lines.push(`${key}=${JSON.stringify(value)}`);
379
+ }
380
+ lines.push("");
381
+ (0, import_node_fs.writeFileSync)(UNISWAP_ENV_LOCAL_FILE, `${lines.join("\n")}
382
+ `);
383
+ }
384
+ function ensureUniswapForceUpgradePatched() {
385
+ const hookNeedle = `export function useForceUpgradeStatus(): ForceUpgradeStatus {
386
+ `;
387
+ const hookPatch = ` // sootsim demo: bypass the force-upgrade gate during local engine demos.
388
+ return 'not-required'
389
+
390
+ `;
391
+ const hookLegacyPatch = ` // sootsim demo: bypass the force-upgrade gate during local engine demos.
392
+ if (process.env.${UNISWAP_FORCE_UPGRADE_PATCH_MARKER} === 'true') {
393
+ return 'not-required'
394
+ }
395
+
396
+ `;
397
+ const notificationNeedle = ` const getForceUpgradeStatus = (): ForceUpgradeStatus => {
398
+ `;
399
+ const notificationPatch = ` // sootsim demo: bypass the force-upgrade gate during local engine demos.
400
+ return 'not-required'
401
+
402
+ `;
403
+ const notificationLegacyPatch = ` // sootsim demo: bypass the force-upgrade gate during local engine demos.
404
+ if (process.env.${UNISWAP_FORCE_UPGRADE_PATCH_MARKER} === 'true') {
405
+ return 'not-required'
406
+ }
407
+
408
+ `;
409
+ const patchWithMigration = (filePath, needle, patch, legacyPatch) => {
410
+ const source = (0, import_node_fs.readFileSync)(filePath, "utf8");
411
+ if (source.includes(patch)) return;
412
+ if (source.includes(legacyPatch)) {
413
+ (0, import_node_fs.writeFileSync)(filePath, source.replace(legacyPatch, patch));
414
+ return;
415
+ }
416
+ if (!source.includes(needle)) {
417
+ throw new Error(
418
+ `uniswap demo patch failed: expected snippet not found in ${filePath}`
419
+ );
420
+ }
421
+ (0, import_node_fs.writeFileSync)(filePath, source.replace(needle, `${needle}${patch}`));
422
+ };
423
+ patchWithMigration(
424
+ UNISWAP_FORCE_UPGRADE_HOOK_FILE,
425
+ hookNeedle,
426
+ hookPatch,
427
+ hookLegacyPatch
428
+ );
429
+ patchWithMigration(
430
+ UNISWAP_FORCE_UPGRADE_NOTIFICATION_FILE,
431
+ notificationNeedle,
432
+ notificationPatch,
433
+ notificationLegacyPatch
434
+ );
435
+ }
436
+ var ARTSY_DIR = (0, import_node_path.join)(HOME, "github/eigen");
437
+ var ARTSY_KEYS_FILE = (0, import_node_path.join)(ARTSY_DIR, "keys.shared.json");
438
+ var ARTSY_METAFLAGS_FILE = (0, import_node_path.join)(ARTSY_DIR, "metaflags.json");
439
+ var ARTSY_RELAY_SENTINEL = (0, import_node_path.join)(ARTSY_DIR, "src/__generated__/.relay-complete");
440
+ function readArtsyKeysPayload() {
441
+ if (!(0, import_node_fs.existsSync)(ARTSY_KEYS_FILE)) return void 0;
442
+ try {
443
+ const parsed = JSON.parse((0, import_node_fs.readFileSync)(ARTSY_KEYS_FILE, "utf8"));
444
+ if (!parsed || typeof parsed !== "object") return void 0;
445
+ const secure = parsed.secure && typeof parsed.secure === "object" ? parsed.secure : void 0;
446
+ const publicKeys = parsed.public && typeof parsed.public === "object" ? parsed.public : void 0;
447
+ if (!secure && !publicKeys) return void 0;
448
+ return { secure, public: publicKeys };
449
+ } catch {
450
+ return void 0;
451
+ }
452
+ }
453
+ function resolveArtsyRuntimeConfig() {
454
+ const email = process.env.SOOTSIM_ARTSY_EMAIL ?? process.env.MAESTRO_TEST_EMAIL;
455
+ const password = process.env.SOOTSIM_ARTSY_PASSWORD ?? process.env.MAESTRO_TEST_PASSWORD;
456
+ const keys = readArtsyKeysPayload();
457
+ const env = {};
458
+ if (email && password) {
459
+ env.SOOTSIM_LAUNCH_ARGUMENTS = JSON.stringify({
460
+ email,
461
+ password,
462
+ useMaestroInit: true
463
+ });
464
+ }
465
+ if (keys) {
466
+ env.SOOTSIM_REACT_NATIVE_KEYS_JSON = JSON.stringify(keys);
467
+ }
468
+ if (Object.keys(env).length === 0) return void 0;
469
+ return {
470
+ env
471
+ };
472
+ }
473
+ function ensureArtsySetup() {
474
+ if (!(0, import_node_fs.existsSync)(ARTSY_DIR)) return;
475
+ const { execSync } = require("node:child_process");
476
+ const yarnRelease = (0, import_node_path.join)(ARTSY_DIR, ".yarn/releases/yarn-4.10.3.cjs");
477
+ if ((0, import_node_fs.existsSync)(yarnRelease)) {
478
+ const src = (0, import_node_fs.readFileSync)(yarnRelease, "utf8");
479
+ const needle = '["clone","-c core.autocrlf=false",';
480
+ if (src.includes(needle)) {
481
+ (0, import_node_fs.writeFileSync)(
482
+ yarnRelease,
483
+ src.replace(needle, '["clone","-c","core.autocrlf=false",')
484
+ );
485
+ }
486
+ }
487
+ if (!(0, import_node_fs.existsSync)((0, import_node_path.join)(ARTSY_DIR, "node_modules/.yarn-state.yml"))) {
488
+ execSync("yarn install", {
489
+ cwd: ARTSY_DIR,
490
+ stdio: "inherit",
491
+ env: { ...process.env, YARN_CHECKSUM_BEHAVIOR: "update" }
492
+ });
493
+ }
494
+ if (!(0, import_node_fs.existsSync)(ARTSY_KEYS_FILE) || !(0, import_node_fs.existsSync)(ARTSY_METAFLAGS_FILE)) {
495
+ try {
496
+ execSync("yarn setup:oss", { cwd: ARTSY_DIR, stdio: "inherit" });
497
+ } catch {
498
+ if (!(0, import_node_fs.existsSync)(ARTSY_KEYS_FILE) || !(0, import_node_fs.existsSync)(ARTSY_METAFLAGS_FILE)) {
499
+ throw new Error("artsy demo: setup:oss did not create keys/metaflags");
500
+ }
501
+ }
502
+ }
503
+ const rnlaPkgJson = (0, import_node_path.join)(
504
+ ARTSY_DIR,
505
+ "node_modules/react-native-launch-arguments/package.json"
506
+ );
507
+ if ((0, import_node_fs.existsSync)(rnlaPkgJson)) {
508
+ const raw = (0, import_node_fs.readFileSync)(rnlaPkgJson, "utf8");
509
+ if (raw.includes('"dist/index.js"')) {
510
+ (0, import_node_fs.writeFileSync)(rnlaPkgJson, raw.replace('"dist/index.js"', '"src/index.ts"'));
511
+ }
512
+ }
513
+ if (!(0, import_node_fs.existsSync)(ARTSY_RELAY_SENTINEL)) {
514
+ execSync("yarn relay", { cwd: ARTSY_DIR, stdio: "inherit" });
515
+ (0, import_node_fs.writeFileSync)(ARTSY_RELAY_SENTINEL, "");
516
+ }
517
+ }
518
+ var APPS = [
519
+ {
520
+ name: "bluesky",
521
+ label: "Bluesky",
522
+ dir: (0, import_node_path.join)(HOME, "github/bluesky"),
523
+ preferredPort: 8082,
524
+ framework: "expo",
525
+ command: (p) => ({ cmd: `npx expo start --port ${p}` }),
526
+ credentials: {
527
+ envVars: ["SOOTSIM_BLUESKY_PASSWORD"],
528
+ known: { HANDLE: "natew.bsky.social" }
529
+ }
530
+ },
531
+ {
532
+ name: "3pc",
533
+ label: "3PunchConvo",
534
+ dir: (0, import_node_path.join)(HOME, "lightstrike-labs/three-punch-convo-app/apps/one"),
535
+ preferredPort: 8081,
536
+ framework: "one",
537
+ command: (p) => ({ cmd: "npx one dev", env: { ONE_PORT: String(p) } })
538
+ },
539
+ {
540
+ name: "uniswap",
541
+ label: "Uniswap",
542
+ dir: UNISWAP_APP_DIR,
543
+ preferredPort: 8085,
544
+ framework: "expo",
545
+ prepare: () => {
546
+ ensureUniswapDemoEnvLocal();
547
+ ensureUniswapForceUpgradePatched();
548
+ },
549
+ command: (p) => ({
550
+ cmd: `npx expo start --clear --port ${p}`,
551
+ // prefer the real local mobile env when present, otherwise fall back
552
+ // to Uniswap's checked-in public web RPC settings so demo boots cleanly.
553
+ env: resolveUniswapDemoEnv()
554
+ })
555
+ },
556
+ {
557
+ name: "takeout",
558
+ label: "Takeout",
559
+ dir: (0, import_node_path.join)(HOME, "takeout"),
560
+ preferredPort: 8086,
561
+ framework: "one",
562
+ command: (p) => ({ cmd: "npx one dev", env: { ONE_PORT: String(p) } })
563
+ },
564
+ {
565
+ name: "expensify",
566
+ label: "Expensify",
567
+ dir: (0, import_node_path.join)(HOME, "github/expensify"),
568
+ preferredPort: 8087,
569
+ framework: "rock",
570
+ runtimeConfig: {
571
+ env: EXPENSIFY_NATIVE_PROXY_ENV
572
+ },
573
+ sidecars: [
574
+ {
575
+ name: "web-proxy",
576
+ port: 9e3,
577
+ readyPath: "/api/Ping",
578
+ command: () => ({
579
+ cmd: `bun ${JSON.stringify(getExpensifyProxyScript())}`,
580
+ env: EXPENSIFY_NATIVE_PROXY_ENV
581
+ })
582
+ }
583
+ ],
584
+ command: (p) => ({
585
+ cmd: `fnm exec --using=20.20.0 npx rock start --port ${p} --no-interactive`,
586
+ env: EXPENSIFY_NATIVE_PROXY_ENV
587
+ })
588
+ },
589
+ {
590
+ name: "artsy",
591
+ label: "Artsy",
592
+ dir: ARTSY_DIR,
593
+ preferredPort: 8088,
594
+ framework: "expo",
595
+ runtimeConfig: resolveArtsyRuntimeConfig(),
596
+ prepare: () => {
597
+ ensureArtsySetup();
598
+ },
599
+ command: (p) => ({
600
+ // eigen's `yarn start` wraps `react-native start` with a relay watcher
601
+ // via concurrently; for the demo we run relay once in prepare and
602
+ // invoke the metro server directly so --port is respected.
603
+ cmd: `npx react-native start --port ${p}`
604
+ }),
605
+ credentials: {
606
+ envVars: ["SOOTSIM_ARTSY_EMAIL", "SOOTSIM_ARTSY_PASSWORD"],
607
+ note: "auto-login reuses Artsy\u2019s built-in Maestro launch-arguments hook"
608
+ }
609
+ }
610
+ ];
611
+
612
+ // scripts/dev-server-scanner.ts
613
+ var execP = (0, import_util.promisify)(import_child_process.exec);
614
+ var TIMEOUT_MS = 250;
615
+ var TCP_GATE_MS = 120;
616
+ function tcpPing(port, timeout = TCP_GATE_MS) {
617
+ return new Promise((resolve2) => {
618
+ const sock = new import_net.default.Socket();
619
+ let settled = false;
620
+ const done = (ok) => {
621
+ if (settled) return;
622
+ settled = true;
623
+ sock.destroy();
624
+ resolve2(ok);
625
+ };
626
+ sock.setTimeout(timeout);
627
+ sock.once("connect", () => done(true));
628
+ sock.once("timeout", () => done(false));
629
+ sock.once("error", () => done(false));
630
+ sock.connect(port, "localhost");
631
+ });
632
+ }
633
+ function httpGet(port, path6, method = "GET", timeout = TIMEOUT_MS, headers = {}) {
634
+ return new Promise((resolve2) => {
635
+ const req = import_http.default.request(
636
+ { hostname: "localhost", port, path: path6, method, timeout, headers },
637
+ (res) => {
638
+ let body = "";
639
+ res.on("data", (c) => body += c.toString());
640
+ res.on("end", () => resolve2({ statusCode: res.statusCode || 0, body }));
641
+ }
642
+ );
643
+ req.on("error", () => resolve2(null));
644
+ req.setTimeout(timeout, () => {
645
+ req.destroy();
646
+ resolve2(null);
647
+ });
648
+ req.end();
649
+ });
650
+ }
651
+ var FALLBACK_PORTS = [
652
+ 8081,
653
+ 8082,
654
+ 8083,
655
+ 8084,
656
+ 8085,
657
+ 8086,
658
+ 3e3,
659
+ 3001,
660
+ 19e3
661
+ ].map((port) => ({ port, pid: 0 }));
662
+ function acceptPort(port, excluded) {
663
+ if (port <= 0 || port >= 2e4) return false;
664
+ if (excluded.has(port)) return false;
665
+ if (port >= 5170 && port <= 5200) return false;
666
+ return true;
667
+ }
668
+ async function discoverListeningProcesses(excludePorts = []) {
669
+ const excluded = new Set(excludePorts);
670
+ try {
671
+ const { stdout } = await execP(
672
+ `lsof -iTCP -sTCP:LISTEN -P -n 2>/dev/null | grep -E '^(node|bun)'`,
673
+ { encoding: "utf8", timeout: 2e3 }
674
+ );
675
+ if (stdout.trim()) {
676
+ const seen = /* @__PURE__ */ new Map();
677
+ for (const line of stdout.trim().split("\n")) {
678
+ const parts = line.trim().split(/\s+/);
679
+ if (parts.length < 9) continue;
680
+ const pid = Number(parts[1]);
681
+ const addr = parts[8];
682
+ const m = addr.match(/:(\d+)$/);
683
+ if (!m) continue;
684
+ const port = Number(m[1]);
685
+ if (!acceptPort(port, excluded)) continue;
686
+ if (!seen.has(port)) seen.set(port, pid);
687
+ }
688
+ if (seen.size > 0) {
689
+ return [...seen.entries()].map(([port, pid]) => ({ port, pid }));
690
+ }
691
+ }
692
+ } catch {
693
+ }
694
+ try {
695
+ const { stdout } = await execP(`ss -tlnp 2>/dev/null | grep -E '"(node|bun)"'`, {
696
+ encoding: "utf8",
697
+ timeout: 2e3
698
+ });
699
+ if (stdout.trim()) {
700
+ const seen = /* @__PURE__ */ new Map();
701
+ for (const line of stdout.trim().split("\n")) {
702
+ const portMatch = line.match(/:(\d+)\s/);
703
+ const pidMatch = line.match(/pid=(\d+)/);
704
+ if (!portMatch) continue;
705
+ const port = Number(portMatch[1]);
706
+ const pid = pidMatch ? Number(pidMatch[1]) : 0;
707
+ if (!acceptPort(port, excluded)) continue;
708
+ if (!seen.has(port)) seen.set(port, pid);
709
+ }
710
+ if (seen.size > 0) {
711
+ return [...seen.entries()].map(([port, pid]) => ({ port, pid }));
712
+ }
713
+ }
714
+ } catch {
715
+ }
716
+ return FALLBACK_PORTS.filter((p) => acceptPort(p.port, excluded));
717
+ }
718
+ var cwdByPid = /* @__PURE__ */ new Map();
719
+ async function resolveProcessCwd(pid) {
720
+ if (pid <= 0) return null;
721
+ const cached = cwdByPid.get(pid);
722
+ if (cached) return cached;
723
+ try {
724
+ const { stdout } = await execP(`lsof -p ${pid} -a -d cwd -Fn 2>/dev/null`, {
725
+ encoding: "utf8",
726
+ timeout: 1500
727
+ });
728
+ for (const line of stdout.split("\n")) {
729
+ if (line.startsWith("n") && line.length > 1) {
730
+ const cwd = line.slice(1).trim();
731
+ if (cwd) {
732
+ cwdByPid.set(pid, cwd);
733
+ return cwd;
734
+ }
735
+ }
736
+ }
737
+ } catch {
738
+ }
739
+ return null;
740
+ }
741
+ function makeResult(port, framework) {
742
+ return {
743
+ port,
744
+ framework,
745
+ bundleUrl: withRuntimeConfig(
746
+ port,
747
+ `http://localhost:${port}/index.bundle?platform=ios&dev=true&hot=true&minify=false`
748
+ ),
749
+ hmrUrl: `ws://localhost:${port}/hot`,
750
+ lastSeen: Date.now()
751
+ };
752
+ }
753
+ function withRuntimeConfig(port, bundleUrl) {
754
+ const knownApp = APPS.find((app) => app.preferredPort === port);
755
+ const configured = knownApp?.runtimeConfig ? applySootSimConfigToUrl(bundleUrl, knownApp.runtimeConfig) : bundleUrl;
756
+ return normalizeNativeDevBundleUrl(configured);
757
+ }
758
+ function isDirectOneBundleUrl(bundleUrl) {
759
+ return bundleUrl.includes("/node_modules/one/metro-entry.bundle");
760
+ }
761
+ function applyManifest(result, manifestRes, buildIconProxyUrl) {
762
+ if (!manifestRes) return result;
763
+ try {
764
+ const manifest = JSON.parse(manifestRes.body);
765
+ const client = manifest?.extra?.expoClient || manifest?.extra || {};
766
+ if (client.name) result.projectName = client.name;
767
+ if (client.ios?.bundleIdentifier) result.bundleId = client.ios.bundleIdentifier;
768
+ if (result.framework === "metro" && client.sdkVersion) result.framework = "expo";
769
+ const launchUrl = manifest?.launchAsset?.url;
770
+ if (launchUrl && !result.patched && !isDirectOneBundleUrl(result.bundleUrl)) {
771
+ result.bundleUrl = withRuntimeConfig(result.port, launchUrl);
772
+ }
773
+ const rawIconUrl = client.iconUrl || client.ios?.iconUrl || client.icon || client.ios?.icon;
774
+ if (rawIconUrl) {
775
+ result.iconPath = rawIconUrl;
776
+ if (buildIconProxyUrl) {
777
+ if (rawIconUrl.startsWith("http")) {
778
+ result.iconUrl = buildIconProxyUrl(rawIconUrl);
779
+ } else {
780
+ const cleanPath = rawIconUrl.replace(/^\.\//, "");
781
+ result.iconUrl = buildIconProxyUrl(
782
+ `http://localhost:${result.port}/assets/${cleanPath}`
783
+ );
784
+ }
785
+ } else {
786
+ result.iconUrl = rawIconUrl.startsWith("http") ? rawIconUrl : `http://localhost:${result.port}/assets/${rawIconUrl.replace(/^\.\//, "")}`;
787
+ }
788
+ }
789
+ } catch {
790
+ }
791
+ return result;
792
+ }
793
+ var knownNonPatched = /* @__PURE__ */ new Set();
794
+ var knownNonExpo = /* @__PURE__ */ new Set();
795
+ async function probePort(port, buildIconProxyUrl) {
796
+ if (!await tcpPing(port)) return null;
797
+ const onePath = `/node_modules/one/metro-entry.bundle?platform=ios&dev=true`;
798
+ const [sootsimRes, statusRes, oneRes, manifestRes, expoRes] = await Promise.all([
799
+ knownNonPatched.has(port) ? Promise.resolve(null) : httpGet(port, "/__soot/"),
800
+ httpGet(port, "/status"),
801
+ httpGet(port, onePath, "HEAD"),
802
+ httpGet(port, "/", "GET", TIMEOUT_MS, { "expo-platform": "ios" }),
803
+ knownNonExpo.has(port) ? Promise.resolve(null) : httpGet(port, "/_expo/status")
804
+ ]);
805
+ if (expoRes && expoRes.statusCode === 200) {
806
+ knownNonExpo.delete(port);
807
+ } else if (!knownNonExpo.has(port)) {
808
+ knownNonExpo.add(port);
809
+ }
810
+ if (oneRes && oneRes.statusCode > 0 && oneRes.statusCode < 400) {
811
+ knownNonPatched.add(port);
812
+ return applyManifest(
813
+ {
814
+ port,
815
+ framework: "one",
816
+ bundleUrl: withRuntimeConfig(
817
+ port,
818
+ `http://localhost:${port}${onePath}&minify=false`
819
+ ),
820
+ hmrUrl: `ws://localhost:${port}/hot`,
821
+ lastSeen: Date.now()
822
+ },
823
+ manifestRes,
824
+ buildIconProxyUrl
825
+ );
826
+ }
827
+ if (statusRes && statusRes.body.includes("packager-status:running")) {
828
+ knownNonPatched.add(port);
829
+ return applyManifest(
830
+ makeResult(port, expoRes && expoRes.statusCode === 200 ? "expo" : "metro"),
831
+ manifestRes,
832
+ buildIconProxyUrl
833
+ );
834
+ }
835
+ if (manifestRes) {
836
+ try {
837
+ const manifest = JSON.parse(manifestRes.body);
838
+ const client = manifest?.extra?.expoClient || {};
839
+ if (client.name) {
840
+ const launchUrl = manifest?.launchAsset?.url || `http://localhost:${port}/index.bundle?platform=ios&dev=true&hot=true&minify=false`;
841
+ knownNonPatched.add(port);
842
+ return applyManifest(
843
+ {
844
+ port,
845
+ framework: "one",
846
+ bundleUrl: withRuntimeConfig(port, launchUrl),
847
+ hmrUrl: `ws://localhost:${port}/hot`,
848
+ lastSeen: Date.now()
849
+ },
850
+ manifestRes,
851
+ buildIconProxyUrl
852
+ );
853
+ }
854
+ } catch {
855
+ }
856
+ }
857
+ if (sootsimRes && sootsimRes.statusCode === 200 && sootsimRes.body.includes("sootsim-patched")) {
858
+ knownNonPatched.delete(port);
859
+ return applyManifest(
860
+ {
861
+ port,
862
+ framework: "one",
863
+ bundleUrl: withRuntimeConfig(port, `http://localhost:${port}/__soot/bundle.js`),
864
+ hmrUrl: `ws://localhost:${port}/hot`,
865
+ lastSeen: Date.now(),
866
+ patched: true
867
+ },
868
+ manifestRes,
869
+ buildIconProxyUrl
870
+ );
871
+ }
872
+ knownNonPatched.add(port);
873
+ return null;
874
+ }
875
+ function isSootSelfServer(server) {
876
+ const projectName = server.projectName?.trim().toLowerCase();
877
+ if (projectName === "soot" || projectName === "sootsim") return true;
878
+ const bundleId = server.bundleId?.trim().toLowerCase();
879
+ if (bundleId?.startsWith("dev.soot")) return true;
880
+ return false;
881
+ }
882
+ var portCache = /* @__PURE__ */ new Map();
883
+ var NEGATIVE_CACHE_TTL_MS = 3e4;
884
+ var WEAK_RESULT_CACHE_TTL_MS = 1500;
885
+ function isWeakCachedResult(result) {
886
+ if (!result) return true;
887
+ if (result.framework === "metro" || result.framework === "unknown") return true;
888
+ return false;
889
+ }
890
+ function hasCurrentRuntimeConfig(result) {
891
+ if (!result) return true;
892
+ return withRuntimeConfig(result.port, result.bundleUrl) === result.bundleUrl;
893
+ }
894
+ function __shouldReuseScannerCacheEntry(entry, pid, now = Date.now()) {
895
+ if (pid === 0) return false;
896
+ if (entry.pid !== pid) return false;
897
+ if (!hasCurrentRuntimeConfig(entry.result)) return false;
898
+ const ageMs = now - entry.cachedAt;
899
+ if (entry.result === null && ageMs >= NEGATIVE_CACHE_TTL_MS) return false;
900
+ if (isWeakCachedResult(entry.result) && ageMs >= WEAK_RESULT_CACHE_TTL_MS) return false;
901
+ return true;
902
+ }
903
+ async function scanDevServers(opts = {}) {
904
+ const processes = await discoverListeningProcesses(opts.excludePorts);
905
+ const currentPorts = new Set(processes.map((p) => p.port));
906
+ for (const p of [...portCache.keys()]) {
907
+ if (!currentPorts.has(p)) portCache.delete(p);
908
+ }
909
+ for (const p of [...knownNonPatched]) {
910
+ if (!currentPorts.has(p)) knownNonPatched.delete(p);
911
+ }
912
+ for (const p of [...knownNonExpo]) {
913
+ if (!currentPorts.has(p)) knownNonExpo.delete(p);
914
+ }
915
+ const results = [];
916
+ const toProbe = [];
917
+ for (const { port, pid } of processes) {
918
+ const cached = portCache.get(port);
919
+ if (cached && __shouldReuseScannerCacheEntry(cached, pid)) {
920
+ if (cached.result) results.push(cached.result);
921
+ continue;
922
+ }
923
+ if (cached && cached.pid !== pid) {
924
+ knownNonPatched.delete(port);
925
+ knownNonExpo.delete(port);
926
+ }
927
+ toProbe.push({ port, pid });
928
+ }
929
+ if (toProbe.length > 0) {
930
+ const probed = await Promise.all(
931
+ toProbe.map((p) => probePort(p.port, opts.buildIconProxyUrl))
932
+ );
933
+ probed.forEach((result, i) => {
934
+ const { port, pid } = toProbe[i];
935
+ if (pid !== 0) portCache.set(port, { pid, result, cachedAt: Date.now() });
936
+ if (result) results.push(result);
937
+ });
938
+ }
939
+ const pidByPort = /* @__PURE__ */ new Map();
940
+ for (const { port, pid } of processes) {
941
+ if (pid > 0) pidByPort.set(port, pid);
942
+ }
943
+ await Promise.all(
944
+ results.map(async (result) => {
945
+ const pid = pidByPort.get(result.port);
946
+ if (!pid) return;
947
+ result.pid = pid;
948
+ const cwd = await resolveProcessCwd(pid);
949
+ if (cwd) result.cwd = cwd;
950
+ })
951
+ );
952
+ const livePids = new Set(pidByPort.values());
953
+ for (const pid of [...cwdByPid.keys()]) {
954
+ if (!livePids.has(pid)) cwdByPid.delete(pid);
955
+ }
956
+ return results.filter((r) => !isSootSelfServer(r));
957
+ }
958
+
959
+ // src/agent-sessions.ts
960
+ var import_node_child_process = require("node:child_process");
961
+ var import_node_crypto2 = require("node:crypto");
962
+ var import_node_fs3 = __toESM(require("node:fs"), 1);
963
+ var import_node_path3 = __toESM(require("node:path"), 1);
964
+ var import_node_readline = __toESM(require("node:readline"), 1);
965
+
966
+ // src/agent-events.ts
967
+ function isAgentEvent(value) {
968
+ if (!value || typeof value !== "object") return false;
969
+ const v = value;
970
+ return typeof v.type === "string" && typeof v.ts === "number";
971
+ }
972
+ function parseAgentEventLine(line) {
973
+ const trimmed = line.trim();
974
+ if (!trimmed) return null;
975
+ try {
976
+ const parsed = JSON.parse(trimmed);
977
+ return isAgentEvent(parsed) ? parsed : null;
978
+ } catch {
979
+ return null;
980
+ }
981
+ }
982
+
983
+ // src/agent-prompt.ts
984
+ var ENVELOPE_MARKER = "sootsim-agent-prompt-v1";
985
+ function cleanPromptText(value) {
986
+ return typeof value === "string" ? value.trim() : "";
987
+ }
988
+ function encodeAgentPromptEnvelope(input) {
989
+ const text = cleanPromptText(input.text);
990
+ if (!text) return "";
991
+ const displayText = cleanPromptText(input.displayText);
992
+ const inspectSummary = cleanPromptText(input.inspectSummary);
993
+ const inspectTrace = cleanPromptText(input.inspectTrace);
994
+ const needsEnvelope = !!inspectSummary || !!inspectTrace || /[\r\n]/.test(text) || !!displayText && displayText !== text;
995
+ if (!needsEnvelope) return text;
996
+ return JSON.stringify({
997
+ __sootsimAgentPrompt: ENVELOPE_MARKER,
998
+ text,
999
+ displayText,
1000
+ inspectSummary,
1001
+ inspectTrace
1002
+ });
1003
+ }
1004
+
1005
+ // src/attached-projects.ts
1006
+ var import_node_crypto = require("node:crypto");
1007
+ var import_node_fs2 = __toESM(require("node:fs"), 1);
1008
+ var import_node_os2 = __toESM(require("node:os"), 1);
1009
+ var import_node_path2 = __toESM(require("node:path"), 1);
1010
+ var overrideDir = null;
1011
+ function userDataDir() {
1012
+ if (overrideDir) return overrideDir;
1013
+ const fromEnv = process.env.SOOTSIM_USER_DATA_DIR;
1014
+ if (fromEnv) return fromEnv;
1015
+ try {
1016
+ const electron = require("electron");
1017
+ if (electron.app?.getPath) return electron.app.getPath("userData");
1018
+ } catch {
1019
+ }
1020
+ return platformDefaultUserDataDir();
1021
+ }
1022
+ function platformDefaultUserDataDir() {
1023
+ const home = import_node_os2.default.homedir();
1024
+ if (process.platform === "darwin") {
1025
+ return import_node_path2.default.join(home, "Library", "Application Support", "sootsim");
1026
+ }
1027
+ if (process.platform === "win32") {
1028
+ return import_node_path2.default.join(process.env.APPDATA || home, "sootsim");
1029
+ }
1030
+ const xdg = process.env.XDG_CONFIG_HOME || import_node_path2.default.join(home, ".config");
1031
+ return import_node_path2.default.join(xdg, "sootsim");
1032
+ }
1033
+ function getUserDataDir() {
1034
+ return userDataDir();
1035
+ }
1036
+ function storeFile() {
1037
+ return import_node_path2.default.join(userDataDir(), "attached-projects.json");
1038
+ }
1039
+ function cloneEmpty() {
1040
+ return {
1041
+ version: 1,
1042
+ attachedProjects: [],
1043
+ previewAttachments: [],
1044
+ agentSessions: []
1045
+ };
1046
+ }
1047
+ function loadStore() {
1048
+ const file = storeFile();
1049
+ let raw;
1050
+ try {
1051
+ raw = import_node_fs2.default.readFileSync(file, "utf8");
1052
+ } catch (err) {
1053
+ if (err.code === "ENOENT") return cloneEmpty();
1054
+ throw err;
1055
+ }
1056
+ try {
1057
+ const parsed = JSON.parse(raw);
1058
+ if (!parsed || typeof parsed !== "object") throw new Error("not an object");
1059
+ return {
1060
+ version: 1,
1061
+ attachedProjects: Array.isArray(parsed.attachedProjects) ? parsed.attachedProjects : [],
1062
+ previewAttachments: Array.isArray(parsed.previewAttachments) ? parsed.previewAttachments : [],
1063
+ agentSessions: Array.isArray(parsed.agentSessions) ? parsed.agentSessions : []
1064
+ };
1065
+ } catch (err) {
1066
+ const quarantine = `${file}.corrupt-${Date.now()}`;
1067
+ try {
1068
+ import_node_fs2.default.renameSync(file, quarantine);
1069
+ console.warn(
1070
+ `[sootsim] attached-projects.json was unparseable; quarantined to ${quarantine}. original error: ${err.message}`
1071
+ );
1072
+ } catch {
1073
+ }
1074
+ return cloneEmpty();
1075
+ }
1076
+ }
1077
+ function writeStore(store) {
1078
+ const file = storeFile();
1079
+ import_node_fs2.default.mkdirSync(import_node_path2.default.dirname(file), { recursive: true });
1080
+ const tmp = `${file}.tmp-${process.pid}-${Date.now()}`;
1081
+ const fd = import_node_fs2.default.openSync(tmp, "w", 384);
1082
+ try {
1083
+ import_node_fs2.default.writeFileSync(fd, JSON.stringify(store, null, 2));
1084
+ import_node_fs2.default.fsyncSync(fd);
1085
+ } finally {
1086
+ import_node_fs2.default.closeSync(fd);
1087
+ }
1088
+ import_node_fs2.default.renameSync(tmp, file);
1089
+ }
1090
+ function mutateStore(fn) {
1091
+ const store = loadStore();
1092
+ fn(store);
1093
+ writeStore(store);
1094
+ return store;
1095
+ }
1096
+ function projectIdForCwd(cwd) {
1097
+ return (0, import_node_crypto.createHash)("sha256").update(import_node_path2.default.resolve(cwd)).digest("hex").slice(0, 16);
1098
+ }
1099
+ function newSessionId() {
1100
+ return `s_${(0, import_node_crypto.randomBytes)(10).toString("hex")}`;
1101
+ }
1102
+ function upsertProject(input) {
1103
+ const cwd = import_node_path2.default.resolve(input.cwd);
1104
+ const id = projectIdForCwd(cwd);
1105
+ let result;
1106
+ mutateStore((store) => {
1107
+ const existing = store.attachedProjects.find((p) => p.id === id);
1108
+ if (existing) {
1109
+ const merged = {
1110
+ ...existing,
1111
+ ...input,
1112
+ id,
1113
+ cwd,
1114
+ sourceRoots: input.sourceRoots ?? existing.sourceRoots,
1115
+ knownBundleUrls: input.knownBundleUrls ?? existing.knownBundleUrls,
1116
+ pinnedSourceResolutions: input.pinnedSourceResolutions ?? existing.pinnedSourceResolutions,
1117
+ telemetry: input.telemetry ?? existing.telemetry,
1118
+ updatedAt: Date.now(),
1119
+ createdAt: existing.createdAt
1120
+ };
1121
+ const idx = store.attachedProjects.indexOf(existing);
1122
+ store.attachedProjects[idx] = merged;
1123
+ result = merged;
1124
+ return;
1125
+ }
1126
+ const now = Date.now();
1127
+ const created = {
1128
+ id,
1129
+ name: input.name ?? import_node_path2.default.basename(cwd),
1130
+ cwd,
1131
+ repoRoot: input.repoRoot,
1132
+ sourceRoots: input.sourceRoots ?? [cwd],
1133
+ framework: input.framework ?? "unknown",
1134
+ bundleId: input.bundleId,
1135
+ knownBundleUrls: input.knownBundleUrls ?? [],
1136
+ preferredProvider: input.preferredProvider ?? "codex",
1137
+ preferredTransport: input.preferredTransport ?? "tmux",
1138
+ editorOpenCommand: input.editorOpenCommand,
1139
+ moshiWebhookToken: input.moshiWebhookToken,
1140
+ pinnedSourceResolutions: input.pinnedSourceResolutions ?? {},
1141
+ isolateDiscovery: input.isolateDiscovery,
1142
+ git: input.git,
1143
+ telemetry: input.telemetry ?? { lastOpened: 0, runsCompleted: 0 },
1144
+ createdAt: now,
1145
+ updatedAt: now
1146
+ };
1147
+ store.attachedProjects.push(created);
1148
+ result = created;
1149
+ });
1150
+ return result;
1151
+ }
1152
+ function findProjectById(id) {
1153
+ return loadStore().attachedProjects.find((p) => p.id === id) ?? null;
1154
+ }
1155
+ function listProjects() {
1156
+ return loadStore().attachedProjects;
1157
+ }
1158
+ var COST_HISTORY_MAX_AGE_MS = 14 * 24 * 60 * 60 * 1e3;
1159
+ function recordTurnTelemetry(projectId, input = {}) {
1160
+ mutateStore((store) => {
1161
+ const project = store.attachedProjects.find((p) => p.id === projectId);
1162
+ if (!project) return;
1163
+ const ts = input.ts ?? Date.now();
1164
+ project.telemetry.runsCompleted = (project.telemetry.runsCompleted ?? 0) + 1;
1165
+ if (typeof input.usd === "number" && Number.isFinite(input.usd) && input.usd >= 0) {
1166
+ const history = project.telemetry.costHistory ?? [];
1167
+ const cutoff = ts - COST_HISTORY_MAX_AGE_MS;
1168
+ const trimmed = history.filter((e) => e.ts >= cutoff);
1169
+ trimmed.push({ ts, usd: input.usd });
1170
+ project.telemetry.costHistory = trimmed;
1171
+ }
1172
+ project.updatedAt = ts;
1173
+ });
1174
+ }
1175
+ function deleteProject(id) {
1176
+ mutateStore((store) => {
1177
+ store.attachedProjects = store.attachedProjects.filter((p) => p.id !== id);
1178
+ store.agentSessions = store.agentSessions.filter((s) => s.projectId !== id);
1179
+ store.previewAttachments = store.previewAttachments.filter(
1180
+ (pa) => pa.projectId !== id
1181
+ );
1182
+ });
1183
+ }
1184
+ function upsertSession(input) {
1185
+ let result;
1186
+ mutateStore((store) => {
1187
+ if (input.id) {
1188
+ const existing = store.agentSessions.find((s) => s.id === input.id);
1189
+ if (existing) {
1190
+ const merged = {
1191
+ ...existing,
1192
+ ...input,
1193
+ lastSeenAt: Date.now()
1194
+ };
1195
+ const idx = store.agentSessions.indexOf(existing);
1196
+ store.agentSessions[idx] = merged;
1197
+ result = merged;
1198
+ return;
1199
+ }
1200
+ }
1201
+ const project = store.attachedProjects.find((p) => p.id === input.projectId);
1202
+ if (!project) {
1203
+ throw new Error(`upsertSession: no AttachedProject with id=${input.projectId}`);
1204
+ }
1205
+ const now = Date.now();
1206
+ const created = {
1207
+ id: input.id ?? newSessionId(),
1208
+ projectId: input.projectId,
1209
+ provider: input.provider,
1210
+ transport: input.transport ?? project.preferredTransport,
1211
+ cwd: input.cwd ?? project.cwd,
1212
+ claudeSessionUuid: input.claudeSessionUuid,
1213
+ tmuxSessionName: input.tmuxSessionName,
1214
+ wrapperPid: input.wrapperPid,
1215
+ status: input.status ?? "idle",
1216
+ needsAttention: input.needsAttention ?? false,
1217
+ lastPrompt: input.lastPrompt,
1218
+ lastSummary: input.lastSummary,
1219
+ lastTurnFiles: input.lastTurnFiles,
1220
+ currentlyEditing: input.currentlyEditing,
1221
+ lastSeenAt: now,
1222
+ createdAt: now
1223
+ };
1224
+ store.agentSessions.push(created);
1225
+ result = created;
1226
+ });
1227
+ return result;
1228
+ }
1229
+ function findSessionById(id) {
1230
+ return loadStore().agentSessions.find((s) => s.id === id) ?? null;
1231
+ }
1232
+ function listSessions(projectId) {
1233
+ const all = loadStore().agentSessions;
1234
+ return projectId ? all.filter((s) => s.projectId === projectId) : all;
1235
+ }
1236
+ function updateSessionStatus(id, patch) {
1237
+ mutateStore((store) => {
1238
+ const existing = store.agentSessions.find((s) => s.id === id);
1239
+ if (!existing) return;
1240
+ const idx = store.agentSessions.indexOf(existing);
1241
+ store.agentSessions[idx] = {
1242
+ ...existing,
1243
+ ...patch,
1244
+ id: existing.id,
1245
+ projectId: existing.projectId,
1246
+ createdAt: existing.createdAt,
1247
+ lastSeenAt: Date.now()
1248
+ };
1249
+ });
1250
+ }
1251
+ async function seedFromDemoAppRegistry() {
1252
+ const existing = loadStore().attachedProjects;
1253
+ if (existing.length > 0) return;
1254
+ let APPS2;
1255
+ try {
1256
+ const mod = await import("sootsim/scripts/demo-app-registry");
1257
+ APPS2 = mod.APPS;
1258
+ } catch (err) {
1259
+ console.warn(
1260
+ "[sootsim] seedFromDemoAppRegistry: could not load demo registry:",
1261
+ err.message
1262
+ );
1263
+ return;
1264
+ }
1265
+ if (!Array.isArray(APPS2)) return;
1266
+ const apps = APPS2;
1267
+ mutateStore((store) => {
1268
+ for (const app of apps) {
1269
+ if (!import_node_fs2.default.existsSync(app.dir)) continue;
1270
+ const cwd = import_node_path2.default.resolve(app.dir);
1271
+ const id = projectIdForCwd(cwd);
1272
+ if (store.attachedProjects.some((p) => p.id === id)) continue;
1273
+ const now = Date.now();
1274
+ store.attachedProjects.push({
1275
+ id,
1276
+ name: app.label,
1277
+ cwd,
1278
+ sourceRoots: [cwd],
1279
+ framework: app.framework,
1280
+ knownBundleUrls: [`http://localhost:${app.preferredPort}/index.bundle`],
1281
+ preferredProvider: "codex",
1282
+ preferredTransport: "tmux",
1283
+ pinnedSourceResolutions: {},
1284
+ telemetry: { lastOpened: 0, runsCompleted: 0 },
1285
+ createdAt: now,
1286
+ updatedAt: now
1287
+ });
1288
+ }
1289
+ });
1290
+ }
1291
+
1292
+ // src/agent-sessions.ts
1293
+ function sessionDir(sessionId) {
1294
+ return import_node_path3.default.join(getUserDataDir(), "sessions", sessionId);
1295
+ }
1296
+ function promptFifoPath(sessionId) {
1297
+ return import_node_path3.default.join(sessionDir(sessionId), "prompt.in");
1298
+ }
1299
+ function eventsFifoPath(sessionId) {
1300
+ return import_node_path3.default.join(sessionDir(sessionId), "events.out");
1301
+ }
1302
+ function transcriptPath(sessionId) {
1303
+ return import_node_path3.default.join(getUserDataDir(), "transcripts", `${sessionId}.log`);
1304
+ }
1305
+ function pidIsAlive(pid, sessionId) {
1306
+ if (!pid) return false;
1307
+ try {
1308
+ process.kill(pid, 0);
1309
+ } catch {
1310
+ return false;
1311
+ }
1312
+ if (sessionId) {
1313
+ if (!import_node_fs3.default.existsSync(sessionDir(sessionId))) return false;
1314
+ }
1315
+ return true;
1316
+ }
1317
+ function resolveSootsimInvocation() {
1318
+ if (process.env.SOOTSIM_BIN) {
1319
+ return { cmd: process.env.SOOTSIM_BIN, prefixArgs: [] };
1320
+ }
1321
+ if (process.versions.electron) {
1322
+ const resourcesPath = process.resourcesPath;
1323
+ if (resourcesPath) {
1324
+ const candidates = [
1325
+ import_node_path3.default.join(resourcesPath, "bin", "sootsim"),
1326
+ import_node_path3.default.join(resourcesPath, "bin", `sootsim-${process.platform}-${process.arch}`)
1327
+ ];
1328
+ for (const c of candidates) {
1329
+ if (import_node_fs3.default.existsSync(c)) return { cmd: c, prefixArgs: [] };
1330
+ }
1331
+ }
1332
+ }
1333
+ const workspace = tryWorkspaceSootsim();
1334
+ if (workspace) return workspace;
1335
+ const argv0 = process.argv[0];
1336
+ const argv1 = process.argv[1];
1337
+ if (argv1 && /\.(ts|tsx|mjs|cjs|js)$/.test(argv1)) {
1338
+ return { cmd: argv0, prefixArgs: [argv1] };
1339
+ }
1340
+ if (!argv1 || argv1.includes("/.bin/")) {
1341
+ throw new Error(
1342
+ "sootsim CLI not found. set SOOTSIM_BIN to the path of the sootsim binary, or build the workspace CLI via `bun run --cwd packages/sootsim build:cli`."
1343
+ );
1344
+ }
1345
+ return { cmd: argv0, prefixArgs: [] };
1346
+ }
1347
+ function tryWorkspaceSootsim() {
1348
+ try {
1349
+ const sootsimDir = resolveSootsimPackageDir();
1350
+ if (!sootsimDir) return null;
1351
+ const binaryName = `sootsim-${process.platform}-${process.arch}`;
1352
+ const distBinary = import_node_path3.default.join(sootsimDir, "dist-bin", binaryName);
1353
+ if (import_node_fs3.default.existsSync(distBinary)) return { cmd: distBinary, prefixArgs: [] };
1354
+ const distBin = import_node_path3.default.join(sootsimDir, "dist-cli", "bin.js");
1355
+ if (import_node_fs3.default.existsSync(distBin)) {
1356
+ try {
1357
+ const src = import_node_path3.default.join(sootsimDir, "cli", "commands", "agent-wrapper.ts");
1358
+ if (import_node_fs3.default.existsSync(src)) {
1359
+ const srcMtime = import_node_fs3.default.statSync(src).mtimeMs;
1360
+ const buildMtime = import_node_fs3.default.statSync(distBin).mtimeMs;
1361
+ if (buildMtime < srcMtime) {
1362
+ console.warn(
1363
+ `[sootsim] dist-cli/bin.js is older than agent-wrapper.ts \u2014 rebuild with \`bun run --cwd packages/sootsim build:cli\` (watch:cli:binary builds dist-bin/ instead).`
1364
+ );
1365
+ }
1366
+ }
1367
+ } catch {
1368
+ }
1369
+ return { cmd: process.execPath, prefixArgs: [distBin] };
1370
+ }
1371
+ return null;
1372
+ } catch {
1373
+ return null;
1374
+ }
1375
+ }
1376
+ function resolveSootsimPackageDir() {
1377
+ try {
1378
+ const resolved = require.resolve("sootsim/package.json");
1379
+ return import_node_path3.default.dirname(resolved);
1380
+ } catch {
1381
+ }
1382
+ const here = fileFromImportMeta();
1383
+ if (!here) return null;
1384
+ let cur = import_node_path3.default.dirname(here);
1385
+ for (let i = 0; i < 8; i++) {
1386
+ const pkg = import_node_path3.default.join(cur, "package.json");
1387
+ try {
1388
+ if (import_node_fs3.default.existsSync(pkg)) {
1389
+ const parsed = JSON.parse(import_node_fs3.default.readFileSync(pkg, "utf8"));
1390
+ if (parsed.name === "sootsim") return cur;
1391
+ }
1392
+ } catch {
1393
+ }
1394
+ const parent = import_node_path3.default.dirname(cur);
1395
+ if (parent === cur) break;
1396
+ cur = parent;
1397
+ }
1398
+ return null;
1399
+ }
1400
+ function fileFromImportMeta() {
1401
+ try {
1402
+ const url = __sootsim_import_meta_url;
1403
+ if (!url || !url.startsWith("file://")) return null;
1404
+ return decodeURIComponent(url.slice("file://".length));
1405
+ } catch {
1406
+ return null;
1407
+ }
1408
+ }
1409
+ async function withStartLock(projectId, provider, fn) {
1410
+ const lockDir = import_node_path3.default.join(getUserDataDir(), "locks");
1411
+ import_node_fs3.default.mkdirSync(lockDir, { recursive: true });
1412
+ try {
1413
+ import_node_fs3.default.chmodSync(lockDir, 448);
1414
+ } catch {
1415
+ }
1416
+ const lockPath = import_node_path3.default.join(lockDir, `start-${projectId}-${provider}.lock`);
1417
+ const deadline = Date.now() + 4e3;
1418
+ let fd = null;
1419
+ while (fd === null) {
1420
+ try {
1421
+ fd = import_node_fs3.default.openSync(
1422
+ lockPath,
1423
+ import_node_fs3.constants.O_WRONLY | import_node_fs3.constants.O_CREAT | import_node_fs3.constants.O_EXCL,
1424
+ 384
1425
+ );
1426
+ } catch (err) {
1427
+ if (err.code !== "EEXIST") throw err;
1428
+ try {
1429
+ const stale = Number(import_node_fs3.default.readFileSync(lockPath, "utf8").trim());
1430
+ if (stale && !isProcessAlive(stale)) {
1431
+ import_node_fs3.default.unlinkSync(lockPath);
1432
+ continue;
1433
+ }
1434
+ } catch {
1435
+ }
1436
+ if (Date.now() > deadline) {
1437
+ throw new Error(
1438
+ `another start is in progress for project=${projectId} provider=${provider} (lock: ${lockPath})`
1439
+ );
1440
+ }
1441
+ await new Promise((r) => setTimeout(r, 50));
1442
+ }
1443
+ }
1444
+ try {
1445
+ import_node_fs3.default.writeFileSync(fd, String(process.pid));
1446
+ return await fn();
1447
+ } finally {
1448
+ try {
1449
+ import_node_fs3.default.closeSync(fd);
1450
+ } catch {
1451
+ }
1452
+ try {
1453
+ import_node_fs3.default.unlinkSync(lockPath);
1454
+ } catch {
1455
+ }
1456
+ }
1457
+ }
1458
+ function isProcessAlive(pid) {
1459
+ try {
1460
+ process.kill(pid, 0);
1461
+ return true;
1462
+ } catch {
1463
+ return false;
1464
+ }
1465
+ }
1466
+ function mkfifoSync(p) {
1467
+ const parent = import_node_path3.default.dirname(p);
1468
+ import_node_fs3.default.mkdirSync(parent, { recursive: true });
1469
+ try {
1470
+ import_node_fs3.default.chmodSync(parent, 448);
1471
+ } catch {
1472
+ }
1473
+ if (import_node_fs3.default.existsSync(p)) {
1474
+ try {
1475
+ const stat = import_node_fs3.default.statSync(p);
1476
+ if (stat.isFIFO()) {
1477
+ try {
1478
+ import_node_fs3.default.chmodSync(p, 384);
1479
+ } catch {
1480
+ }
1481
+ return;
1482
+ }
1483
+ import_node_fs3.default.unlinkSync(p);
1484
+ } catch {
1485
+ import_node_fs3.default.unlinkSync(p);
1486
+ }
1487
+ }
1488
+ const result = (0, import_node_child_process.spawnSync)("mkfifo", ["-m", "600", p]);
1489
+ if (result.status !== 0) {
1490
+ throw new Error(
1491
+ `mkfifo(${p}) failed: ${result.stderr?.toString().trim() || "unknown error"}`
1492
+ );
1493
+ }
1494
+ }
1495
+ var AgentSessionError = class extends Error {
1496
+ code;
1497
+ constructor(code, message) {
1498
+ super(message);
1499
+ this.code = code;
1500
+ }
1501
+ };
1502
+ async function startSession(opts) {
1503
+ const project = findProjectById(opts.projectId);
1504
+ if (!project) {
1505
+ throw new AgentSessionError("NO_PROJECT", `no project with id=${opts.projectId}`);
1506
+ }
1507
+ const provider = opts.provider || project.preferredProvider || "codex";
1508
+ return withStartLock(project.id, provider, async () => {
1509
+ const existingLive = listSessions(project.id).find(
1510
+ (s) => s.provider === provider && s.status !== "ended" && pidIsAlive(s.wrapperPid, s.id)
1511
+ );
1512
+ if (existingLive) {
1513
+ throw new AgentSessionError(
1514
+ "ALREADY_RUNNING",
1515
+ `session already running for project=${project.id} provider=${provider} (session ${existingLive.id}, pid ${existingLive.wrapperPid}). end it first with \`sootsim agent end <sessionId>\`.`
1516
+ );
1517
+ }
1518
+ const claudeSessionUuid = provider === "claude" ? (0, import_node_crypto2.randomUUID)() : void 0;
1519
+ const session = upsertSession({
1520
+ projectId: project.id,
1521
+ provider,
1522
+ transport: "pty",
1523
+ cwd: project.cwd,
1524
+ status: "idle",
1525
+ claudeSessionUuid
1526
+ });
1527
+ const promptIn = promptFifoPath(session.id);
1528
+ const eventsOut = eventsFifoPath(session.id);
1529
+ const transcript = transcriptPath(session.id);
1530
+ mkfifoSync(promptIn);
1531
+ mkfifoSync(eventsOut);
1532
+ const transcriptDir = import_node_path3.default.dirname(transcript);
1533
+ import_node_fs3.default.mkdirSync(transcriptDir, { recursive: true });
1534
+ try {
1535
+ import_node_fs3.default.chmodSync(transcriptDir, 448);
1536
+ } catch {
1537
+ }
1538
+ const { cmd, prefixArgs } = resolveSootsimInvocation();
1539
+ const wrapperArgs = [
1540
+ ...prefixArgs,
1541
+ "agent-wrapper",
1542
+ "--session-id",
1543
+ session.id,
1544
+ "--project-id",
1545
+ project.id,
1546
+ "--provider",
1547
+ provider,
1548
+ "--cwd",
1549
+ project.cwd,
1550
+ "--prompt-in",
1551
+ promptIn,
1552
+ "--events-out",
1553
+ eventsOut,
1554
+ "--transcript",
1555
+ transcript
1556
+ ];
1557
+ if (opts.codexBin) wrapperArgs.push("--codex-bin", opts.codexBin);
1558
+ if (opts.claudeBin) wrapperArgs.push("--claude-bin", opts.claudeBin);
1559
+ if (claudeSessionUuid) {
1560
+ wrapperArgs.push("--claude-session-uuid", claudeSessionUuid);
1561
+ }
1562
+ const child = (0, import_node_child_process.spawn)(cmd, wrapperArgs, {
1563
+ detached: true,
1564
+ stdio: "ignore",
1565
+ env: {
1566
+ ...process.env,
1567
+ SOOTSIM_USER_DATA_DIR: getUserDataDir()
1568
+ }
1569
+ });
1570
+ child.unref();
1571
+ const readyTimeout = opts.readyTimeoutMs ?? 6e3;
1572
+ const boot = await waitForFirstEvent(
1573
+ eventsOut,
1574
+ (e) => e.type === "ready" || e.type === "error",
1575
+ readyTimeout
1576
+ );
1577
+ if (!boot || boot.type === "error") {
1578
+ if (child.pid) {
1579
+ try {
1580
+ process.kill(child.pid, "SIGTERM");
1581
+ } catch {
1582
+ }
1583
+ }
1584
+ try {
1585
+ import_node_fs3.default.rmSync(sessionDir(session.id), { recursive: true, force: true });
1586
+ } catch {
1587
+ }
1588
+ updateSessionStatus(session.id, { status: "ended" });
1589
+ const reason = boot && boot.type === "error" ? boot.message : `no ready event within ${readyTimeout}ms`;
1590
+ throw new AgentSessionError("WRAPPER_FAILED", reason);
1591
+ }
1592
+ updateSessionStatus(session.id, {
1593
+ wrapperPid: child.pid,
1594
+ status: "idle"
1595
+ });
1596
+ const updated = findSessionById(session.id);
1597
+ return { session: updated, wrapperPid: child.pid };
1598
+ });
1599
+ }
1600
+ async function sendPrompt(sessionId, prompt) {
1601
+ const session = findSessionById(sessionId);
1602
+ if (!session) {
1603
+ throw new AgentSessionError("NO_SESSION", `no session with id=${sessionId}`);
1604
+ }
1605
+ if (!pidIsAlive(session.wrapperPid, sessionId)) {
1606
+ updateSessionStatus(sessionId, { status: "ended" });
1607
+ throw new AgentSessionError(
1608
+ "NOT_ALIVE",
1609
+ `session wrapper is not alive (pid=${session.wrapperPid}). start a new session.`
1610
+ );
1611
+ }
1612
+ const fifo = promptFifoPath(sessionId);
1613
+ if (!import_node_fs3.default.existsSync(fifo)) {
1614
+ throw new AgentSessionError("NO_FIFO", `prompt FIFO missing: ${fifo}`);
1615
+ }
1616
+ const fd = import_node_fs3.default.openSync(fifo, import_node_fs3.constants.O_WRONLY);
1617
+ try {
1618
+ const wireText = encodeAgentPromptEnvelope(prompt);
1619
+ if (!wireText) {
1620
+ throw new AgentSessionError("EMPTY_PROMPT", "prompt text is empty");
1621
+ }
1622
+ import_node_fs3.default.writeSync(fd, wireText + "\n");
1623
+ } finally {
1624
+ import_node_fs3.default.closeSync(fd);
1625
+ }
1626
+ updateSessionStatus(sessionId, {
1627
+ lastPrompt: prompt.displayText ?? prompt.text,
1628
+ status: "working"
1629
+ });
1630
+ }
1631
+ async function endSession(sessionId) {
1632
+ const session = findSessionById(sessionId);
1633
+ if (!session) {
1634
+ throw new AgentSessionError("NO_SESSION", `no session with id=${sessionId}`);
1635
+ }
1636
+ if (pidIsAlive(session.wrapperPid, sessionId)) {
1637
+ try {
1638
+ process.kill(session.wrapperPid, "SIGTERM");
1639
+ } catch {
1640
+ }
1641
+ }
1642
+ const dir = sessionDir(sessionId);
1643
+ const base = getUserDataDir();
1644
+ if (dir.startsWith(base)) {
1645
+ try {
1646
+ import_node_fs3.default.rmSync(dir, { recursive: true, force: true });
1647
+ } catch {
1648
+ }
1649
+ }
1650
+ updateSessionStatus(sessionId, { status: "ended", wrapperPid: void 0 });
1651
+ }
1652
+ function subscribeEvents(sessionId, onEvent) {
1653
+ const fifo = eventsFifoPath(sessionId);
1654
+ if (!import_node_fs3.default.existsSync(fifo)) {
1655
+ throw new AgentSessionError("NO_FIFO", `events FIFO missing: ${fifo}`);
1656
+ }
1657
+ const fd = import_node_fs3.default.openSync(fifo, import_node_fs3.constants.O_RDWR);
1658
+ const stream = import_node_fs3.default.createReadStream("", { fd, autoClose: true });
1659
+ const rl = import_node_readline.default.createInterface({ input: stream, crlfDelay: Infinity });
1660
+ rl.on("line", (line) => {
1661
+ const event = parseAgentEventLine(line);
1662
+ if (event) onEvent(event);
1663
+ });
1664
+ let closed = false;
1665
+ return () => {
1666
+ if (closed) return;
1667
+ closed = true;
1668
+ try {
1669
+ rl.close();
1670
+ } catch {
1671
+ }
1672
+ try {
1673
+ stream.destroy();
1674
+ } catch {
1675
+ }
1676
+ };
1677
+ }
1678
+ async function waitForFirstEvent(fifo, predicate, timeoutMs) {
1679
+ const fd = import_node_fs3.default.openSync(fifo, import_node_fs3.constants.O_RDWR | import_node_fs3.constants.O_NONBLOCK);
1680
+ const buf = Buffer.alloc(8192);
1681
+ let leftover = "";
1682
+ const deadline = Date.now() + timeoutMs;
1683
+ try {
1684
+ while (Date.now() < deadline) {
1685
+ let n = 0;
1686
+ try {
1687
+ n = import_node_fs3.default.readSync(fd, buf, 0, buf.length, null);
1688
+ } catch (err) {
1689
+ if (err.code !== "EAGAIN") throw err;
1690
+ n = 0;
1691
+ }
1692
+ if (n > 0) {
1693
+ leftover += buf.subarray(0, n).toString("utf8");
1694
+ let idx;
1695
+ while ((idx = leftover.indexOf("\n")) >= 0) {
1696
+ const line = leftover.slice(0, idx);
1697
+ leftover = leftover.slice(idx + 1);
1698
+ const event = parseAgentEventLine(line);
1699
+ if (event && predicate(event)) return event;
1700
+ }
1701
+ } else {
1702
+ await new Promise((r) => setTimeout(r, 30));
1703
+ }
1704
+ }
1705
+ return null;
1706
+ } finally {
1707
+ import_node_fs3.default.closeSync(fd);
1708
+ }
1709
+ }
1710
+
1711
+ // src/host/agent-host.ts
1712
+ var WS_OPEN = 1;
1713
+ function defaultExcludePorts() {
1714
+ return [
1715
+ Number(process.env.VITE_PORT_WEB || process.env.PORT || 3e3),
1716
+ Number(process.env.VITE_PORT_ZERO || 7849),
1717
+ Number(process.env.VITE_PORT_POSTGRES || 7432),
1718
+ Number(process.env.VITE_PORT_R2 || 9500)
1719
+ ].filter((p) => Number.isFinite(p) && p > 0);
1720
+ }
1721
+ var AgentHost = class {
1722
+ // live fan-out, keyed by sessionId. refcount tracks how many sockets have
1723
+ // subscribed; the FIFO reader stays open as long as refcount > 0.
1724
+ subscriptions = /* @__PURE__ */ new Map();
1725
+ // per-socket subscription set so ws.close can clean up without scanning
1726
+ // every session.
1727
+ sessionsBySocket = /* @__PURE__ */ new Map();
1728
+ // every connected socket (regardless of role) — gets session-status
1729
+ // pushes so a CLI `sootsim agent sessions --watch` can react to state
1730
+ // changes driven by another client.
1731
+ allSockets = /* @__PURE__ */ new Set();
1732
+ // agent wrappers also emit prompt-received from the FIFO reader, but the
1733
+ // shell/daemon already knows the user-facing display text when send-prompt
1734
+ // is accepted. emit the friendly prompt immediately and suppress the raw
1735
+ // wrapper echoes that follow a moment later.
1736
+ pendingPromptEchoes = /* @__PURE__ */ new Map();
1737
+ // accepted prompts serialize inside the wrapper, so a second send while the
1738
+ // session is already working becomes queued follow-up work. keep a small
1739
+ // in-memory count so session status stays `working` until that backlog
1740
+ // truly drains.
1741
+ pendingTurns = /* @__PURE__ */ new Map();
1742
+ opts;
1743
+ constructor(opts = {}) {
1744
+ this.opts = opts;
1745
+ }
1746
+ registerSocket(ws) {
1747
+ this.allSockets.add(ws);
1748
+ }
1749
+ unregisterSocket(ws) {
1750
+ const sessions = this.sessionsBySocket.get(ws);
1751
+ if (sessions) {
1752
+ for (const sessionId of sessions) {
1753
+ this.decrementSubscription(sessionId);
1754
+ }
1755
+ this.sessionsBySocket.delete(ws);
1756
+ }
1757
+ this.allSockets.delete(ws);
1758
+ }
1759
+ /** handle an agent:* message. returns true iff the message was recognized
1760
+ * as an agent message (so the caller knows to stop dispatching). */
1761
+ async handleMessage(ws, msg) {
1762
+ const type = msg?.type;
1763
+ if (typeof type !== "string" || !type.startsWith("agent:")) return false;
1764
+ const id = msg.id;
1765
+ try {
1766
+ const result = await this.dispatch(ws, type, msg);
1767
+ this.respond(ws, id, result);
1768
+ } catch (err) {
1769
+ if (err instanceof AgentSessionError) {
1770
+ this.respondError(ws, id, err.message, err.code);
1771
+ } else {
1772
+ this.respondError(ws, id, err instanceof Error ? err.message : String(err));
1773
+ }
1774
+ }
1775
+ return true;
1776
+ }
1777
+ /** run once on daemon boot. idempotent — `seedFromDemoAppRegistry` no-ops
1778
+ * when the store already has projects. */
1779
+ async seedOnBoot() {
1780
+ try {
1781
+ await seedFromDemoAppRegistry();
1782
+ } catch (err) {
1783
+ process.stderr.write(
1784
+ `[sootsim-agent] seedFromDemoAppRegistry failed: ${err instanceof Error ? err.message : String(err)}
1785
+ `
1786
+ );
1787
+ }
1788
+ }
1789
+ /** terminate every subscription and drop every socket reference. called
1790
+ * by the bridge host during shutdown. */
1791
+ close() {
1792
+ for (const sub of this.subscriptions.values()) {
1793
+ try {
1794
+ sub.unsubscribe();
1795
+ } catch {
1796
+ }
1797
+ }
1798
+ this.subscriptions.clear();
1799
+ this.sessionsBySocket.clear();
1800
+ this.allSockets.clear();
1801
+ }
1802
+ // --- dispatch ---
1803
+ async dispatch(ws, type, msg) {
1804
+ switch (type) {
1805
+ case "agent:list-projects":
1806
+ return listProjects();
1807
+ case "agent:upsert-project":
1808
+ return upsertProject(msg.input ?? {});
1809
+ case "agent:delete-project":
1810
+ deleteProject(String(msg.projectId));
1811
+ return { ok: true };
1812
+ case "agent:auto-attach-for-url":
1813
+ return this.autoAttachForUrl(msg.input ?? {});
1814
+ case "agent:list-sessions":
1815
+ return listSessions(msg.projectId ? String(msg.projectId) : void 0);
1816
+ case "agent:start-session":
1817
+ return this.doStartSession(msg.input ?? {});
1818
+ case "agent:send-prompt": {
1819
+ const sessionId = String(msg.sessionId);
1820
+ const session = findSessionById(sessionId);
1821
+ if (!session) {
1822
+ throw new AgentSessionError("NO_SESSION", `no session: ${sessionId}`);
1823
+ }
1824
+ const prompt = this.normalizePromptEnvelope(msg);
1825
+ await sendPrompt(sessionId, prompt);
1826
+ return this.notePromptAccepted(sessionId, prompt, session.status === "working");
1827
+ }
1828
+ case "agent:end-session":
1829
+ this.dropSessionFanout(String(msg.sessionId));
1830
+ await endSession(String(msg.sessionId));
1831
+ const ended = findSessionById(String(msg.sessionId));
1832
+ if (ended) {
1833
+ this.broadcastSessionStatus(ended);
1834
+ }
1835
+ return { ok: true };
1836
+ case "agent:get-transcript":
1837
+ return this.getTranscript(String(msg.sessionId));
1838
+ case "agent:get-paths":
1839
+ return this.getPaths();
1840
+ case "agent:subscribe-events":
1841
+ return this.subscribeSocket(ws, String(msg.sessionId));
1842
+ case "agent:unsubscribe-events":
1843
+ return this.unsubscribeSocket(ws, String(msg.sessionId));
1844
+ default:
1845
+ throw new AgentSessionError("UNKNOWN_AGENT_MSG", `unknown agent message: ${type}`);
1846
+ }
1847
+ }
1848
+ // --- operation impls ---
1849
+ async doStartSession(input) {
1850
+ const project = findProjectById(input.projectId);
1851
+ if (!project) {
1852
+ throw new AgentSessionError("NO_PROJECT", `no project: ${input.projectId}`);
1853
+ }
1854
+ const result = await startSession(input);
1855
+ this.broadcastSessionStatus(result.session);
1856
+ return result;
1857
+ }
1858
+ async autoAttachForUrl(input) {
1859
+ const bundleUrl = input.bundleUrl ?? "";
1860
+ const targetPort = (() => {
1861
+ try {
1862
+ return new URL(bundleUrl).port || null;
1863
+ } catch {
1864
+ return null;
1865
+ }
1866
+ })();
1867
+ if (!targetPort) return { project: null };
1868
+ const excludePorts = this.opts.getExcludePorts?.() ?? defaultExcludePorts();
1869
+ const servers = await scanDevServers({ excludePorts });
1870
+ const match = servers.find((s) => String(s.port) === targetPort);
1871
+ if (!match || !match.cwd) return { project: null };
1872
+ const existing = listProjects().find((p) => p.cwd === match.cwd) ?? null;
1873
+ const knownBundleUrls = Array.from(
1874
+ /* @__PURE__ */ new Set([...existing?.knownBundleUrls ?? [], match.bundleUrl, bundleUrl])
1875
+ );
1876
+ const project = upsertProject({
1877
+ cwd: match.cwd,
1878
+ name: match.projectName ?? import_node_path4.default.basename(match.cwd),
1879
+ preferredProvider: input.provider ?? existing?.preferredProvider,
1880
+ sourceRoots: existing?.sourceRoots ?? [match.cwd],
1881
+ knownBundleUrls,
1882
+ framework: existing?.framework ?? mapFrameworkToProjectFramework(match.framework),
1883
+ bundleId: match.bundleId ?? existing?.bundleId
1884
+ });
1885
+ return { project };
1886
+ }
1887
+ getTranscript(sessionId) {
1888
+ const p = transcriptPath(sessionId);
1889
+ if (!import_node_fs4.default.existsSync(p)) {
1890
+ return { error: "transcript not found", code: "NO_TRANSCRIPT" };
1891
+ }
1892
+ return import_node_fs4.default.readFileSync(p, "utf8");
1893
+ }
1894
+ getPaths() {
1895
+ const dir = getUserDataDir();
1896
+ return {
1897
+ userDataDir: dir,
1898
+ storeFile: import_node_path4.default.join(dir, "attached-projects.json"),
1899
+ sessionsDir: import_node_path4.default.join(dir, "sessions"),
1900
+ transcriptsDir: import_node_path4.default.join(dir, "transcripts")
1901
+ };
1902
+ }
1903
+ // --- subscription management ---
1904
+ subscribeSocket(ws, sessionId) {
1905
+ let sockets = this.sessionsBySocket.get(ws);
1906
+ if (!sockets) {
1907
+ sockets = /* @__PURE__ */ new Set();
1908
+ this.sessionsBySocket.set(ws, sockets);
1909
+ }
1910
+ if (sockets.has(sessionId)) {
1911
+ return { ok: true, refCount: this.subscriptions.get(sessionId)?.refCount ?? 1 };
1912
+ }
1913
+ sockets.add(sessionId);
1914
+ const existing = this.subscriptions.get(sessionId);
1915
+ if (existing) {
1916
+ existing.refCount++;
1917
+ return { ok: true, refCount: existing.refCount };
1918
+ }
1919
+ const unsubscribe = subscribeEvents(sessionId, (event) => {
1920
+ const coalesced = this.coalescePromptEcho(sessionId, event);
1921
+ if (coalesced) {
1922
+ this.applySessionEvent(sessionId, coalesced);
1923
+ this.fanOutEvent(sessionId, coalesced);
1924
+ }
1925
+ if (event.type === "turn-completed") {
1926
+ const session = findSessionById(sessionId);
1927
+ if (session) {
1928
+ try {
1929
+ recordTurnTelemetry(session.projectId, {
1930
+ usd: event.costUsd,
1931
+ ts: event.ts
1932
+ });
1933
+ } catch (err) {
1934
+ process.stderr.write(
1935
+ `[sootsim-agent] recordTurnTelemetry failed: ${err instanceof Error ? err.message : String(err)}
1936
+ `
1937
+ );
1938
+ }
1939
+ }
1940
+ }
1941
+ });
1942
+ this.subscriptions.set(sessionId, { unsubscribe, refCount: 1 });
1943
+ return { ok: true, refCount: 1 };
1944
+ }
1945
+ unsubscribeSocket(ws, sessionId) {
1946
+ const sockets = this.sessionsBySocket.get(ws);
1947
+ if (!sockets || !sockets.has(sessionId)) return { ok: true, refCount: 0 };
1948
+ sockets.delete(sessionId);
1949
+ return this.decrementSubscription(sessionId);
1950
+ }
1951
+ decrementSubscription(sessionId) {
1952
+ const existing = this.subscriptions.get(sessionId);
1953
+ if (!existing) return { ok: true, refCount: 0 };
1954
+ existing.refCount--;
1955
+ if (existing.refCount <= 0) {
1956
+ try {
1957
+ existing.unsubscribe();
1958
+ } catch {
1959
+ }
1960
+ this.subscriptions.delete(sessionId);
1961
+ return { ok: true, refCount: 0 };
1962
+ }
1963
+ return { ok: true, refCount: existing.refCount };
1964
+ }
1965
+ /** end-session tears the FIFO down, so drop our reader before it
1966
+ * disappears regardless of remaining subscriber refcount. */
1967
+ dropSessionFanout(sessionId) {
1968
+ const existing = this.subscriptions.get(sessionId);
1969
+ if (existing) {
1970
+ try {
1971
+ existing.unsubscribe();
1972
+ } catch {
1973
+ }
1974
+ this.subscriptions.delete(sessionId);
1975
+ }
1976
+ for (const sockets of this.sessionsBySocket.values()) {
1977
+ sockets.delete(sessionId);
1978
+ }
1979
+ this.clearPromptTracking(sessionId);
1980
+ }
1981
+ // --- wire pushes + responses ---
1982
+ normalizePromptEnvelope(msg) {
1983
+ if (msg?.prompt && typeof msg.prompt === "object") {
1984
+ const prompt = msg.prompt;
1985
+ return {
1986
+ text: String(prompt.text ?? ""),
1987
+ ...typeof prompt.displayText === "string" ? { displayText: prompt.displayText } : {},
1988
+ ...typeof prompt.inspectSummary === "string" ? { inspectSummary: prompt.inspectSummary } : {},
1989
+ ...typeof prompt.inspectTrace === "string" ? { inspectTrace: prompt.inspectTrace } : {}
1990
+ };
1991
+ }
1992
+ return {
1993
+ text: String(msg?.text ?? ""),
1994
+ ...typeof msg?.displayText === "string" ? { displayText: msg.displayText } : {},
1995
+ ...typeof msg?.inspectSummary === "string" ? { inspectSummary: msg.inspectSummary } : {},
1996
+ ...typeof msg?.inspectTrace === "string" ? { inspectTrace: msg.inspectTrace } : {}
1997
+ };
1998
+ }
1999
+ notePromptAccepted(sessionId, prompt, assumeQueued) {
2000
+ const now = Date.now();
2001
+ const echoes = this.pendingPromptEchoes.get(sessionId) ?? [];
2002
+ echoes.push({ sentAt: now });
2003
+ this.pendingPromptEchoes.set(sessionId, echoes);
2004
+ const pendingTurns = Math.max(this.pendingTurns.get(sessionId) ?? 0, assumeQueued ? 1 : 0) + 1;
2005
+ this.pendingTurns.set(sessionId, pendingTurns);
2006
+ const promptText = prompt.displayText ?? prompt.text;
2007
+ this.patchSession(sessionId, {
2008
+ lastPrompt: promptText,
2009
+ status: "working",
2010
+ needsAttention: false
2011
+ });
2012
+ this.fanOutEvent(sessionId, {
2013
+ type: "prompt-received",
2014
+ text: promptText,
2015
+ ...prompt.inspectSummary ? { inspectSummary: prompt.inspectSummary } : {},
2016
+ ...prompt.inspectTrace ? { inspectTrace: prompt.inspectTrace } : {},
2017
+ ts: now
2018
+ });
2019
+ return {
2020
+ ok: true,
2021
+ queued: pendingTurns > 1,
2022
+ pendingTurns,
2023
+ queueDepth: Math.max(0, pendingTurns - 1)
2024
+ };
2025
+ }
2026
+ applySessionEvent(sessionId, event) {
2027
+ switch (event.type) {
2028
+ case "prompt-received":
2029
+ case "turn-started":
2030
+ this.patchSession(sessionId, {
2031
+ status: "working",
2032
+ needsAttention: false
2033
+ });
2034
+ return;
2035
+ case "turn-completed": {
2036
+ const pendingTurns = this.consumeSettledTurn(sessionId);
2037
+ this.patchSession(sessionId, {
2038
+ status: pendingTurns > 0 ? "working" : "idle",
2039
+ needsAttention: false,
2040
+ lastTurnFiles: event.filesTouched,
2041
+ currentlyEditing: void 0
2042
+ });
2043
+ return;
2044
+ }
2045
+ case "approval-needed":
2046
+ this.patchSession(sessionId, {
2047
+ status: "needs-attention",
2048
+ needsAttention: true
2049
+ });
2050
+ return;
2051
+ case "error": {
2052
+ const pendingTurns = this.consumeSettledTurn(sessionId);
2053
+ this.patchSession(sessionId, {
2054
+ status: pendingTurns > 0 ? "working" : "needs-attention",
2055
+ needsAttention: pendingTurns <= 0,
2056
+ currentlyEditing: void 0
2057
+ });
2058
+ return;
2059
+ }
2060
+ case "exited":
2061
+ this.clearPromptTracking(sessionId);
2062
+ this.patchSession(sessionId, {
2063
+ status: "ended",
2064
+ needsAttention: false,
2065
+ wrapperPid: void 0,
2066
+ currentlyEditing: void 0
2067
+ });
2068
+ return;
2069
+ case "ready":
2070
+ case "turn-reasoning":
2071
+ case "turn-message":
2072
+ case "turn-plan":
2073
+ case "tool-call":
2074
+ case "file-edited":
2075
+ case "file-diff-delta":
2076
+ return;
2077
+ }
2078
+ }
2079
+ patchSession(sessionId, patch) {
2080
+ updateSessionStatus(sessionId, patch);
2081
+ const updated = findSessionById(sessionId);
2082
+ if (updated) {
2083
+ this.broadcastSessionStatus(updated);
2084
+ }
2085
+ }
2086
+ coalescePromptEcho(sessionId, event) {
2087
+ if (event.type !== "prompt-received") return event;
2088
+ const pending = this.pendingPromptEchoes.get(sessionId);
2089
+ if (!pending || pending.length === 0) return event;
2090
+ while (pending.length > 0 && Date.now() - pending[0].sentAt > 15e3) {
2091
+ pending.shift();
2092
+ }
2093
+ if (pending.length === 0) {
2094
+ this.pendingPromptEchoes.delete(sessionId);
2095
+ return event;
2096
+ }
2097
+ pending.shift();
2098
+ if (pending.length === 0) {
2099
+ this.pendingPromptEchoes.delete(sessionId);
2100
+ } else {
2101
+ this.pendingPromptEchoes.set(sessionId, pending);
2102
+ }
2103
+ return null;
2104
+ }
2105
+ consumeSettledTurn(sessionId) {
2106
+ const pendingTurns = Math.max(0, (this.pendingTurns.get(sessionId) ?? 1) - 1);
2107
+ if (pendingTurns > 0) {
2108
+ this.pendingTurns.set(sessionId, pendingTurns);
2109
+ } else {
2110
+ this.pendingTurns.delete(sessionId);
2111
+ }
2112
+ return pendingTurns;
2113
+ }
2114
+ clearPromptTracking(sessionId) {
2115
+ this.pendingPromptEchoes.delete(sessionId);
2116
+ this.pendingTurns.delete(sessionId);
2117
+ }
2118
+ fanOutEvent(sessionId, event) {
2119
+ const payload = JSON.stringify({ type: "agent:event", sessionId, event });
2120
+ for (const [ws, sessions] of this.sessionsBySocket) {
2121
+ if (!sessions.has(sessionId)) continue;
2122
+ if (ws.readyState !== WS_OPEN) continue;
2123
+ try {
2124
+ ws.send(payload);
2125
+ } catch {
2126
+ }
2127
+ }
2128
+ }
2129
+ broadcastSessionStatus(session) {
2130
+ const payload = JSON.stringify({ type: "agent:session-status", session });
2131
+ for (const ws of this.allSockets) {
2132
+ if (ws.readyState !== WS_OPEN) continue;
2133
+ try {
2134
+ ws.send(payload);
2135
+ } catch {
2136
+ }
2137
+ }
2138
+ }
2139
+ respond(ws, id, result) {
2140
+ if (ws.readyState !== WS_OPEN) return;
2141
+ try {
2142
+ ws.send(JSON.stringify({ id, result }));
2143
+ } catch {
2144
+ }
2145
+ }
2146
+ respondError(ws, id, error, code) {
2147
+ if (ws.readyState !== WS_OPEN) return;
2148
+ try {
2149
+ ws.send(JSON.stringify({ id, error, ...code ? { code } : {} }));
2150
+ } catch {
2151
+ }
2152
+ }
2153
+ };
2154
+ function mapFrameworkToProjectFramework(fw) {
2155
+ if (fw === "expo") return "expo";
2156
+ if (fw === "one" || fw === "vxrn") return "one";
2157
+ return "unknown";
2158
+ }
2159
+
2160
+ // src/host/bridge-host.ts
2161
+ var WRITE_COMMAND_TYPES = /* @__PURE__ */ new Set(["tap", "keyboard", "close"]);
2162
+ function shouldAcquireLease(msg) {
2163
+ if (!msg || typeof msg.type !== "string") return false;
2164
+ if (msg.acquireLock === true) return true;
2165
+ if (msg.readOnly === true) return false;
2166
+ return WRITE_COMMAND_TYPES.has(msg.type);
2167
+ }
2168
+ var DAEMON_HEARTBEAT_INTERVAL_MS = 5e3;
2169
+ var HTTP_MIME_TYPES = {
2170
+ ".html": "text/html; charset=utf-8",
2171
+ ".js": "application/javascript",
2172
+ ".cjs": "application/javascript",
2173
+ ".mjs": "application/javascript",
2174
+ ".css": "text/css; charset=utf-8",
2175
+ ".json": "application/json; charset=utf-8",
2176
+ ".png": "image/png",
2177
+ ".jpg": "image/jpeg",
2178
+ ".jpeg": "image/jpeg",
2179
+ ".gif": "image/gif",
2180
+ ".svg": "image/svg+xml",
2181
+ ".webp": "image/webp",
2182
+ ".avif": "image/avif",
2183
+ ".ico": "image/x-icon",
2184
+ ".wasm": "application/wasm",
2185
+ ".ttf": "font/ttf",
2186
+ ".otf": "font/otf",
2187
+ ".woff": "font/woff",
2188
+ ".woff2": "font/woff2",
2189
+ ".map": "application/json",
2190
+ ".txt": "text/plain; charset=utf-8"
2191
+ };
2192
+ var SootSimBridgeHost = class _SootSimBridgeHost {
2193
+ port;
2194
+ openUrlHandler;
2195
+ httpServer = null;
2196
+ wss = null;
2197
+ nextCommandId = 1;
2198
+ nextBrowserId = 1;
2199
+ browsers = /* @__PURE__ */ new Map();
2200
+ primaryBrowserId = null;
2201
+ pendingCommands = /* @__PURE__ */ new Map();
2202
+ cliBySentId = /* @__PURE__ */ new Map();
2203
+ cliBrowserBySocket = /* @__PURE__ */ new Map();
2204
+ cliLastCommandAt = /* @__PURE__ */ new Map();
2205
+ cliSessionKeyBySocket = /* @__PURE__ */ new Map();
2206
+ cliLabelBySocket = /* @__PURE__ */ new Map();
2207
+ restorableBrowsers = /* @__PURE__ */ new Map();
2208
+ nextCliFallbackId = 1;
2209
+ cliIdleTimer = null;
2210
+ agentHost;
2211
+ static CLI_IDLE_TIMEOUT_MS = 6e4;
2212
+ static CLI_LEASE_TTL_MS = 6e5;
2213
+ static USER_ACTIVE_LEASE_TTL_MS = 8e3;
2214
+ static BROWSER_RECONNECT_TTL_MS = 3e4;
2215
+ preferredPort;
2216
+ portFallbackCount;
2217
+ shouldWriteLockfile;
2218
+ effectivePort = 0;
2219
+ startedAt = 0;
2220
+ heartbeatTimer = null;
2221
+ activeRuntimeVersion = null;
2222
+ activeRuntimeDirPath = null;
2223
+ constructor(opts = {}) {
2224
+ this.preferredPort = opts.port || DEFAULT_SOOTSIM_BRIDGE_PORT;
2225
+ this.port = this.preferredPort;
2226
+ this.shouldWriteLockfile = opts.writeLockfile === true;
2227
+ const defaultFallback = this.shouldWriteLockfile ? 1 : 10;
2228
+ this.portFallbackCount = Math.max(1, opts.portFallbackCount ?? defaultFallback);
2229
+ this.openUrlHandler = opts.openUrl;
2230
+ this.agentHost = new AgentHost({ getExcludePorts: opts.agentScanExcludes });
2231
+ }
2232
+ /** expose the agent host so tests and embedders can inspect state or
2233
+ * inject behavior. not part of the public WS protocol. */
2234
+ getAgentHost() {
2235
+ return this.agentHost;
2236
+ }
2237
+ /** synchronous wrapper around startAsync for callers that don't care
2238
+ * about port fallback outcomes. returns immediately; actual binding
2239
+ * happens on the event loop. callers that need to know the bound port
2240
+ * should await startAsync() instead. */
2241
+ start(options) {
2242
+ void this.startAsync(options);
2243
+ }
2244
+ async startAsync(options) {
2245
+ if (this.httpServer || this.wss) return this.effectivePort;
2246
+ this.refreshActiveRuntime();
2247
+ for (let attempt = 0; attempt < this.portFallbackCount; attempt++) {
2248
+ const candidate = this.preferredPort + attempt;
2249
+ try {
2250
+ await this.bindOnce(candidate, options?.silent === true);
2251
+ this.effectivePort = candidate;
2252
+ this.port = candidate;
2253
+ this.startedAt = Date.now();
2254
+ if (attempt > 0 && !options?.silent) {
2255
+ process.stderr.write(
2256
+ `ws bridge bound to port ${candidate} (preferred ${this.preferredPort} was taken)
2257
+ `
2258
+ );
2259
+ }
2260
+ this.afterBind();
2261
+ return candidate;
2262
+ } catch (err) {
2263
+ const e = err;
2264
+ if (e?.code !== "EADDRINUSE") {
2265
+ throw err;
2266
+ }
2267
+ if (!options?.silent) {
2268
+ process.stderr.write(
2269
+ `ws bridge port ${candidate} already in use, trying ${candidate + 1}
2270
+ `
2271
+ );
2272
+ }
2273
+ }
2274
+ }
2275
+ throw new Error(
2276
+ `could not bind ws bridge after ${this.portFallbackCount} attempts starting at ${this.preferredPort}`
2277
+ );
2278
+ }
2279
+ bindOnce(port, _silent) {
2280
+ return new Promise((resolve2, reject) => {
2281
+ const server = (0, import_http2.createServer)((req, res) => this.handleHttpRequest(req, res));
2282
+ let settled = false;
2283
+ const onError = (err) => {
2284
+ if (settled) return;
2285
+ settled = true;
2286
+ try {
2287
+ server.close();
2288
+ } catch {
2289
+ }
2290
+ this.httpServer = null;
2291
+ this.wss = null;
2292
+ reject(err);
2293
+ };
2294
+ server.once("error", onError);
2295
+ server.listen(port, "127.0.0.1", () => {
2296
+ if (settled) return;
2297
+ settled = true;
2298
+ server.removeListener("error", onError);
2299
+ server.on("error", (err) => {
2300
+ process.stderr.write(`ws bridge http error: ${String(err)}
2301
+ `);
2302
+ });
2303
+ this.httpServer = server;
2304
+ this.wss = new import_ws.WebSocketServer({ server });
2305
+ this.wireWebSocketServer();
2306
+ resolve2();
2307
+ });
2308
+ });
2309
+ }
2310
+ /** attach the WS connection handler to the current wss. called from
2311
+ * bindOnce() after WebSocketServer is freshly created. */
2312
+ wireWebSocketServer() {
2313
+ if (!this.wss) return;
2314
+ this.wss.on("connection", (ws, req) => {
2315
+ const origin = req.headers.origin;
2316
+ const role = origin ? "browser" : "cli";
2317
+ let browser = null;
2318
+ this.agentHost.registerSocket(ws);
2319
+ if (role === "browser") {
2320
+ browser = {
2321
+ id: `tab-${this.nextBrowserId++}`,
2322
+ ws,
2323
+ origin,
2324
+ connectedAt: Date.now(),
2325
+ lastSeenAt: Date.now(),
2326
+ lastActiveAt: 0,
2327
+ recentActions: []
2328
+ };
2329
+ this.browsers.set(browser.id, browser);
2330
+ if (this.shouldPromoteBrowser(browser)) {
2331
+ this.primaryBrowserId = browser.id;
2332
+ }
2333
+ this.broadcastBrowserAssignments();
2334
+ this.broadcastBrowserClientStates();
2335
+ } else {
2336
+ const fallbackKey = `ws-${this.nextCliFallbackId++}`;
2337
+ this.cliSessionKeyBySocket.set(ws, fallbackKey);
2338
+ }
2339
+ ws.on("message", (data) => {
2340
+ let msg;
2341
+ try {
2342
+ msg = JSON.parse(data.toString());
2343
+ } catch {
2344
+ return;
2345
+ }
2346
+ if (!msg || typeof msg !== "object") return;
2347
+ if (typeof msg.type === "string" && msg.type.startsWith("agent:")) {
2348
+ void this.agentHost.handleMessage(ws, msg);
2349
+ return;
2350
+ }
2351
+ if (msg.type === "runtime:list") {
2352
+ const versions = listInstalledRuntimes();
2353
+ const active = this.getActiveRuntime();
2354
+ const reply = {
2355
+ type: "runtime:list:ok",
2356
+ id: msg.id,
2357
+ installed: versions,
2358
+ active: active.version,
2359
+ activeRuntimeDir: active.runtimeDir
2360
+ };
2361
+ try {
2362
+ ws.send(JSON.stringify(reply));
2363
+ } catch {
2364
+ }
2365
+ return;
2366
+ }
2367
+ if (msg.type === "runtime:use") {
2368
+ const version = typeof msg.version === "string" ? msg.version : "";
2369
+ const installed = listInstalledRuntimes();
2370
+ if (!installed.includes(version)) {
2371
+ try {
2372
+ ws.send(
2373
+ JSON.stringify({
2374
+ type: "runtime:use:error",
2375
+ id: msg.id,
2376
+ error: `runtime ${version || "(missing)"} is not installed`
2377
+ })
2378
+ );
2379
+ } catch {
2380
+ }
2381
+ return;
2382
+ }
2383
+ const result = this.setActiveRuntime(version);
2384
+ try {
2385
+ ws.send(
2386
+ JSON.stringify({
2387
+ type: "runtime:use:ok",
2388
+ id: msg.id,
2389
+ version: result.version,
2390
+ runtimeDir: result.runtimeDir
2391
+ })
2392
+ );
2393
+ } catch {
2394
+ }
2395
+ return;
2396
+ }
2397
+ if (msg.type === "runtime:get") {
2398
+ const active = this.getActiveRuntime();
2399
+ try {
2400
+ ws.send(
2401
+ JSON.stringify({
2402
+ type: "runtime:get:ok",
2403
+ id: msg.id,
2404
+ active: active.version,
2405
+ activeRuntimeDir: active.runtimeDir
2406
+ })
2407
+ );
2408
+ } catch {
2409
+ }
2410
+ return;
2411
+ }
2412
+ if (role === "browser") {
2413
+ if (browser) {
2414
+ browser.lastSeenAt = Date.now();
2415
+ }
2416
+ if (msg.type === "bridge:register" && browser) {
2417
+ const registration = msg;
2418
+ const restored = this.tryRestoreBrowserId(browser, registration.browserId);
2419
+ browser.url = registration.url;
2420
+ browser.title = registration.title;
2421
+ browser.userAgent = registration.userAgent;
2422
+ if (restored) {
2423
+ this.broadcastBrowserAssignments();
2424
+ this.broadcastBrowserClientStates();
2425
+ }
2426
+ return;
2427
+ }
2428
+ if (msg.type === "bridge:user-focus-state" && browser) {
2429
+ const focusState = msg;
2430
+ this.updateUserFocusLease(browser, focusState.focused === true);
2431
+ return;
2432
+ }
2433
+ if (msg.type === "bridge:user-interact" && browser) {
2434
+ this.updateUserActivity(browser);
2435
+ return;
2436
+ }
2437
+ if (msg.type === "bridge:open-path") {
2438
+ const filePath = typeof msg.path === "string" ? msg.path : "";
2439
+ const line = typeof msg.line === "number" && Number.isFinite(msg.line) ? msg.line : void 0;
2440
+ const column = typeof msg.column === "number" && Number.isFinite(msg.column) ? msg.column : void 0;
2441
+ if (filePath) {
2442
+ void this.openPathInEditor(filePath, line, column);
2443
+ }
2444
+ return;
2445
+ }
2446
+ if (msg.type === "bridge:boot-clients" && browser) {
2447
+ const booted = [];
2448
+ for (const [cliWs, attachedBrowserId] of this.cliBrowserBySocket) {
2449
+ if (attachedBrowserId === browser.id) {
2450
+ booted.push(cliWs);
2451
+ }
2452
+ }
2453
+ for (const cliWs of booted) {
2454
+ this.cliBrowserBySocket.delete(cliWs);
2455
+ try {
2456
+ cliWs.close(1e3, "booted by browser");
2457
+ } catch {
2458
+ }
2459
+ }
2460
+ const hadLease = !!browser.cliLease;
2461
+ browser.cliLease = void 0;
2462
+ if (booted.length > 0 || hadLease) {
2463
+ process.stderr.write(
2464
+ `sootsim booted ${booted.length} cli client(s)${hadLease ? " + cleared lease" : ""} from [${browser.id}]
2465
+ `
2466
+ );
2467
+ this.recordBrowserAction(browser.id, "browser booted cli clients");
2468
+ this.broadcastBrowserClientStates();
2469
+ }
2470
+ return;
2471
+ }
2472
+ const internalPending = this.pendingCommands.get(msg.id);
2473
+ if (internalPending) {
2474
+ this.pendingCommands.delete(msg.id);
2475
+ if (msg.error) internalPending.reject(new Error(msg.error));
2476
+ else internalPending.resolve(msg.result);
2477
+ return;
2478
+ }
2479
+ const entry = this.cliBySentId.get(msg.id);
2480
+ if (entry) {
2481
+ this.cliBySentId.delete(msg.id);
2482
+ if (entry.ws.readyState === import_ws.WebSocket.OPEN) {
2483
+ const otherCliCount = this.getOtherCliSessionCount(
2484
+ entry.ws,
2485
+ entry.browserId
2486
+ );
2487
+ const response = otherCliCount > 0 ? { ...msg, id: entry.originalId, _otherCliCount: otherCliCount } : { ...msg, id: entry.originalId };
2488
+ entry.ws.send(JSON.stringify(response));
2489
+ }
2490
+ }
2491
+ return;
2492
+ }
2493
+ void (async () => {
2494
+ this.cliLastCommandAt.set(ws, Date.now());
2495
+ try {
2496
+ if (msg.type === "bridge:bye") {
2497
+ const hadBrowser = this.cliBrowserBySocket.delete(ws);
2498
+ this.cliLastCommandAt.delete(ws);
2499
+ this.cliSessionKeyBySocket.delete(ws);
2500
+ this.cliLabelBySocket.delete(ws);
2501
+ for (const [sentId2, entry] of this.cliBySentId) {
2502
+ if (entry.ws === ws) this.cliBySentId.delete(sentId2);
2503
+ }
2504
+ if (hadBrowser) this.broadcastBrowserClientStates();
2505
+ return;
2506
+ }
2507
+ if (msg.type === "bridge:hello") {
2508
+ const key = typeof msg.cliSessionKey === "string" && msg.cliSessionKey.trim() ? msg.cliSessionKey.trim() : this.cliSessionKeyBySocket.get(ws) || `ws-${this.nextCliFallbackId++}`;
2509
+ this.cliSessionKeyBySocket.set(ws, key);
2510
+ if (typeof msg.cliLabel === "string" && msg.cliLabel.trim()) {
2511
+ this.cliLabelBySocket.set(ws, msg.cliLabel.trim());
2512
+ }
2513
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2514
+ ws.send(
2515
+ JSON.stringify({
2516
+ id: msg.id,
2517
+ result: {
2518
+ cliSessionKey: key,
2519
+ leaseTtlMs: _SootSimBridgeHost.CLI_LEASE_TTL_MS,
2520
+ leasing: true
2521
+ }
2522
+ })
2523
+ );
2524
+ }
2525
+ return;
2526
+ }
2527
+ if (msg.type === "bridge:list-browsers") {
2528
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2529
+ ws.send(
2530
+ JSON.stringify({
2531
+ id: msg.id,
2532
+ result: this.listBrowsers()
2533
+ })
2534
+ );
2535
+ }
2536
+ return;
2537
+ }
2538
+ if (msg.type === "bridge:open") {
2539
+ if (typeof msg.url !== "string" || !msg.url) {
2540
+ throw new Error("bridge:open requires a url");
2541
+ }
2542
+ await this.openUrl(msg.url);
2543
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2544
+ ws.send(
2545
+ JSON.stringify({
2546
+ id: msg.id,
2547
+ result: { ok: true, url: msg.url }
2548
+ })
2549
+ );
2550
+ }
2551
+ return;
2552
+ }
2553
+ if (msg.type === "bridge:claim") {
2554
+ const targetBrowser2 = await this.waitForBrowser(msg.browserId);
2555
+ const outcome = this.tryAcquireLease(ws, targetBrowser2, {
2556
+ force: msg.force === true
2557
+ });
2558
+ if (!outcome.granted) {
2559
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2560
+ ws.send(
2561
+ JSON.stringify({
2562
+ id: msg.id,
2563
+ error: `tab ${targetBrowser2.id} is locked by another cli`,
2564
+ _locked: outcome.lock
2565
+ })
2566
+ );
2567
+ }
2568
+ return;
2569
+ }
2570
+ this.setCliBrowserTarget(ws, targetBrowser2.id);
2571
+ this.recordBrowserAction(
2572
+ targetBrowser2.id,
2573
+ outcome.bootedCount > 0 ? `cli force-claimed tab (booted ${outcome.bootedCount})` : "cli claimed tab"
2574
+ );
2575
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2576
+ ws.send(
2577
+ JSON.stringify({
2578
+ id: msg.id,
2579
+ result: {
2580
+ browserId: targetBrowser2.id,
2581
+ lockedBy: outcome.lease.cliSessionKey,
2582
+ lockExpiresAt: outcome.lease.expiresAt,
2583
+ bootedCount: outcome.bootedCount
2584
+ }
2585
+ })
2586
+ );
2587
+ }
2588
+ return;
2589
+ }
2590
+ const targetBrowser = await this.waitForBrowser(msg.browserId);
2591
+ if (shouldAcquireLease(msg)) {
2592
+ const outcome = this.tryAcquireLease(ws, targetBrowser);
2593
+ if (!outcome.granted) {
2594
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2595
+ ws.send(
2596
+ JSON.stringify({
2597
+ id: msg.id,
2598
+ error: `tab ${targetBrowser.id} is locked by another cli \u2014 use \`sootsim claim ${targetBrowser.id} --force\` or \`sootsim open --new\``,
2599
+ _locked: outcome.lock
2600
+ })
2601
+ );
2602
+ }
2603
+ return;
2604
+ }
2605
+ } else {
2606
+ this.ensureCliSessionKey(ws);
2607
+ }
2608
+ this.setCliBrowserTarget(ws, targetBrowser.id);
2609
+ this.recordBrowserAction(targetBrowser.id, this.describeForwardedCommand(msg));
2610
+ const sentId = this.nextCommandId++;
2611
+ this.cliBySentId.set(sentId, {
2612
+ browserId: targetBrowser.id,
2613
+ ws,
2614
+ originalId: msg.id
2615
+ });
2616
+ const { browserId: _browserId, ...forwarded } = msg;
2617
+ targetBrowser.ws.send(JSON.stringify({ ...forwarded, id: sentId }));
2618
+ } catch (err) {
2619
+ if (ws.readyState === import_ws.WebSocket.OPEN) {
2620
+ ws.send(
2621
+ JSON.stringify({
2622
+ id: msg.id,
2623
+ error: err instanceof Error ? err.message : String(err)
2624
+ })
2625
+ );
2626
+ }
2627
+ }
2628
+ })();
2629
+ });
2630
+ ws.on("close", () => {
2631
+ this.agentHost.unregisterSocket(ws);
2632
+ if (role === "browser" && browser) {
2633
+ this.rememberDisconnectedBrowser(browser);
2634
+ if (this.primaryBrowserId === browser.id) {
2635
+ this.primaryBrowserId = this.getOpenBrowser()?.id ?? null;
2636
+ }
2637
+ for (const [id, pending] of this.pendingCommands) {
2638
+ if (pending.browserId !== browser.id) continue;
2639
+ pending.reject(new Error("browser disconnected"));
2640
+ this.pendingCommands.delete(id);
2641
+ }
2642
+ for (const [sentId, entry] of this.cliBySentId) {
2643
+ if (entry.browserId !== browser.id) continue;
2644
+ if (entry.ws.readyState === import_ws.WebSocket.OPEN) {
2645
+ entry.ws.send(
2646
+ JSON.stringify({
2647
+ id: entry.originalId,
2648
+ error: "browser disconnected before responding"
2649
+ })
2650
+ );
2651
+ }
2652
+ this.cliBySentId.delete(sentId);
2653
+ }
2654
+ this.broadcastBrowserAssignments();
2655
+ this.broadcastBrowserClientStates();
2656
+ } else if (role === "cli") {
2657
+ const detached = this.cliBrowserBySocket.delete(ws);
2658
+ this.cliLastCommandAt.delete(ws);
2659
+ this.cliSessionKeyBySocket.delete(ws);
2660
+ this.cliLabelBySocket.delete(ws);
2661
+ for (const [sentId, entry] of this.cliBySentId) {
2662
+ if (entry.ws === ws) this.cliBySentId.delete(sentId);
2663
+ }
2664
+ if (detached) {
2665
+ this.broadcastBrowserClientStates();
2666
+ }
2667
+ }
2668
+ });
2669
+ });
2670
+ }
2671
+ /** after a successful bind: start the cli idle sweep, write the daemon
2672
+ * lockfile (if this host owns it), seed the agent host, kick off the
2673
+ * heartbeat loop. idempotent across rebinds because close() tears down
2674
+ * every timer and the lockfile. */
2675
+ afterBind() {
2676
+ process.stderr.write(`ws bridge listening on port ${this.port}
2677
+ `);
2678
+ this.cliIdleTimer = setInterval(
2679
+ () => this.sweepIdleCliClients(),
2680
+ 3e4
2681
+ );
2682
+ this.cliIdleTimer.unref();
2683
+ if (this.shouldWriteLockfile) {
2684
+ try {
2685
+ ensureSootsimHome();
2686
+ const claimed = claimDaemonLockfile(this.buildLockfileSnapshot());
2687
+ if (!claimed) {
2688
+ throw new Error(
2689
+ "another sootsim daemon wrote the lockfile during startup \u2014 aborting"
2690
+ );
2691
+ }
2692
+ } catch (err) {
2693
+ process.stderr.write(
2694
+ `ws bridge failed to claim daemon lockfile: ${String(err)}
2695
+ `
2696
+ );
2697
+ throw err;
2698
+ }
2699
+ this.heartbeatTimer = setInterval(() => {
2700
+ try {
2701
+ this.writeLockfileSnapshot();
2702
+ } catch {
2703
+ }
2704
+ }, DAEMON_HEARTBEAT_INTERVAL_MS);
2705
+ this.heartbeatTimer.unref();
2706
+ }
2707
+ void this.agentHost.seedOnBoot();
2708
+ }
2709
+ buildLockfileSnapshot() {
2710
+ return {
2711
+ schema: 1,
2712
+ pid: process.pid,
2713
+ platform: process.platform,
2714
+ bridgePort: this.effectivePort,
2715
+ runtimePort: this.effectivePort,
2716
+ activeRuntime: this.activeRuntimeVersion,
2717
+ activeRuntimeDir: this.activeRuntimeDirPath,
2718
+ startedAt: this.startedAt,
2719
+ heartbeatAt: Date.now()
2720
+ };
2721
+ }
2722
+ writeLockfileSnapshot() {
2723
+ writeDaemonLockfile(this.buildLockfileSnapshot());
2724
+ }
2725
+ refreshActiveRuntime() {
2726
+ this.activeRuntimeVersion = readActiveRuntime();
2727
+ this.activeRuntimeDirPath = activeRuntimeDir();
2728
+ }
2729
+ /** update the active runtime on disk + in memory. the caller guarantees
2730
+ * the version directory exists. pushes a runtime:changed message to all
2731
+ * connected browsers so electron (or any renderer) can reload. */
2732
+ setActiveRuntime(version) {
2733
+ writeActiveRuntime(version);
2734
+ this.refreshActiveRuntime();
2735
+ if (this.shouldWriteLockfile && this.httpServer) {
2736
+ try {
2737
+ this.writeLockfileSnapshot();
2738
+ } catch {
2739
+ }
2740
+ }
2741
+ const payload = JSON.stringify({
2742
+ type: "runtime:changed",
2743
+ version,
2744
+ runtimeDir: this.activeRuntimeDirPath
2745
+ });
2746
+ for (const browser of this.browsers.values()) {
2747
+ if (browser.ws.readyState === import_ws.WebSocket.OPEN) {
2748
+ try {
2749
+ browser.ws.send(payload);
2750
+ } catch {
2751
+ }
2752
+ }
2753
+ }
2754
+ return { version, runtimeDir: this.activeRuntimeDirPath };
2755
+ }
2756
+ getActiveRuntime() {
2757
+ return {
2758
+ version: this.activeRuntimeVersion,
2759
+ runtimeDir: this.activeRuntimeDirPath
2760
+ };
2761
+ }
2762
+ /** last-ditch lockfile cleanup. safe to call from a synchronous
2763
+ * `process.on('exit', ...)` handler since it only does a fs.unlinkSync. */
2764
+ removeLockfile() {
2765
+ if (!this.shouldWriteLockfile) return;
2766
+ try {
2767
+ removeDaemonLockfile();
2768
+ } catch {
2769
+ }
2770
+ }
2771
+ /** minimal HTTP request handler attached to the same node http server
2772
+ * that hosts the WS upgrade. handles:
2773
+ * GET /healthz json status for supervisors / curl
2774
+ * GET / + everything serves from the active runtime dist, SPA fallback
2775
+ * non-upgrade routes that don't match serve index.html (SPA behavior) so
2776
+ * electron's webContents can navigate freely inside the runtime. */
2777
+ handleHttpRequest(req, res) {
2778
+ const method = (req.method || "GET").toUpperCase();
2779
+ if (method !== "GET" && method !== "HEAD") {
2780
+ res.writeHead(405, { Allow: "GET, HEAD" });
2781
+ res.end("method not allowed");
2782
+ return;
2783
+ }
2784
+ const url = new URL(req.url || "/", "http://localhost");
2785
+ if (url.pathname === "/__bundle-proxy") {
2786
+ const target = url.searchParams.get("url");
2787
+ if (!target) {
2788
+ res.writeHead(400, { "Content-Type": "text/plain" });
2789
+ res.end("bundle-proxy: missing url query param");
2790
+ return;
2791
+ }
2792
+ let parsedTarget;
2793
+ try {
2794
+ parsedTarget = new URL(target);
2795
+ } catch {
2796
+ res.writeHead(400, { "Content-Type": "text/plain" });
2797
+ res.end("bundle-proxy: invalid url");
2798
+ return;
2799
+ }
2800
+ const host = parsedTarget.hostname;
2801
+ const isLoopback = host === "localhost" || host === "127.0.0.1" || host === "::1" || host.endsWith(".localhost");
2802
+ if (!isLoopback) {
2803
+ res.writeHead(403, { "Content-Type": "text/plain" });
2804
+ res.end("bundle-proxy: only loopback targets allowed");
2805
+ return;
2806
+ }
2807
+ void (async () => {
2808
+ try {
2809
+ const upstream = await fetch(parsedTarget.toString(), {
2810
+ redirect: "follow"
2811
+ });
2812
+ const headers = {};
2813
+ const ct = upstream.headers.get("content-type");
2814
+ if (ct) headers["Content-Type"] = ct;
2815
+ headers["Cache-Control"] = "no-store";
2816
+ res.writeHead(upstream.status, headers);
2817
+ if (!upstream.body) {
2818
+ res.end();
2819
+ return;
2820
+ }
2821
+ const reader = upstream.body.getReader();
2822
+ while (true) {
2823
+ const { done, value } = await reader.read();
2824
+ if (done) break;
2825
+ res.write(Buffer.from(value));
2826
+ }
2827
+ res.end();
2828
+ } catch (err) {
2829
+ res.writeHead(502, { "Content-Type": "text/plain" });
2830
+ res.end(
2831
+ `bundle-proxy: upstream fetch failed: ${err instanceof Error ? err.message : String(err)}`
2832
+ );
2833
+ }
2834
+ })();
2835
+ return;
2836
+ }
2837
+ if (url.pathname === "/healthz") {
2838
+ res.writeHead(200, {
2839
+ "Content-Type": "application/json",
2840
+ "Cache-Control": "no-store"
2841
+ });
2842
+ res.end(
2843
+ JSON.stringify({
2844
+ ok: true,
2845
+ pid: process.pid,
2846
+ platform: process.platform,
2847
+ bridgePort: this.effectivePort,
2848
+ runtimePort: this.effectivePort,
2849
+ activeRuntime: this.activeRuntimeVersion,
2850
+ startedAt: this.startedAt,
2851
+ uptimeMs: this.startedAt > 0 ? Date.now() - this.startedAt : 0
2852
+ })
2853
+ );
2854
+ return;
2855
+ }
2856
+ this.refreshActiveRuntime();
2857
+ const baseDir = this.activeRuntimeDirPath;
2858
+ if (!baseDir) {
2859
+ res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" });
2860
+ res.end(
2861
+ "sootsim: no active runtime installed. run `sootsim runtime install` to fetch one."
2862
+ );
2863
+ return;
2864
+ }
2865
+ let rel = url.pathname;
2866
+ if (rel === "/runtime" || rel === "/runtime/") rel = "/";
2867
+ else if (rel.startsWith("/runtime/")) rel = rel.slice("/runtime".length);
2868
+ if (rel === "" || rel === "/") rel = "/index.html";
2869
+ if (rel.includes("\0")) {
2870
+ res.writeHead(400);
2871
+ res.end("bad request");
2872
+ return;
2873
+ }
2874
+ if (process.platform !== "win32" && rel.includes("\\")) {
2875
+ res.writeHead(400);
2876
+ res.end("bad request");
2877
+ return;
2878
+ }
2879
+ for (const segment of rel.split("/")) {
2880
+ if (segment === "..") {
2881
+ res.writeHead(403);
2882
+ res.end("forbidden");
2883
+ return;
2884
+ }
2885
+ }
2886
+ const resolved = import_path2.default.resolve(baseDir, "." + rel);
2887
+ const baseWithSep = baseDir.endsWith(import_path2.default.sep) ? baseDir : baseDir + import_path2.default.sep;
2888
+ if (!resolved.startsWith(baseWithSep) && resolved !== baseDir) {
2889
+ res.writeHead(403);
2890
+ res.end("forbidden");
2891
+ return;
2892
+ }
2893
+ import_fs2.default.realpath(resolved, (realErr, realResolved) => {
2894
+ const servePath = realErr ? resolved : realResolved;
2895
+ const servePathWithSep = servePath.endsWith(import_path2.default.sep) ? servePath : servePath + import_path2.default.sep;
2896
+ if (!realErr) {
2897
+ const realBaseWithSep = (() => {
2898
+ try {
2899
+ const rb = import_fs2.default.realpathSync(baseDir);
2900
+ return rb.endsWith(import_path2.default.sep) ? rb : rb + import_path2.default.sep;
2901
+ } catch {
2902
+ return baseWithSep;
2903
+ }
2904
+ })();
2905
+ if (!servePathWithSep.startsWith(realBaseWithSep) && servePath + import_path2.default.sep !== realBaseWithSep) {
2906
+ res.writeHead(403);
2907
+ res.end("forbidden");
2908
+ return;
2909
+ }
2910
+ }
2911
+ import_fs2.default.stat(servePath, (err, stats) => {
2912
+ if (err || !stats?.isFile()) {
2913
+ const ext2 = import_path2.default.extname(rel).toLowerCase();
2914
+ if (ext2 && ext2 !== ".html") {
2915
+ res.writeHead(404);
2916
+ res.end("not found");
2917
+ return;
2918
+ }
2919
+ const indexPath = import_path2.default.join(baseDir, "index.html");
2920
+ import_fs2.default.readFile(indexPath, (err2, data) => {
2921
+ if (err2) {
2922
+ res.writeHead(404);
2923
+ res.end("not found");
2924
+ return;
2925
+ }
2926
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
2927
+ if (method === "HEAD") {
2928
+ res.end();
2929
+ return;
2930
+ }
2931
+ res.end(data);
2932
+ });
2933
+ return;
2934
+ }
2935
+ const ext = import_path2.default.extname(servePath).toLowerCase();
2936
+ const contentType = HTTP_MIME_TYPES[ext] || "application/octet-stream";
2937
+ res.writeHead(200, { "Content-Type": contentType });
2938
+ if (method === "HEAD") {
2939
+ res.end();
2940
+ return;
2941
+ }
2942
+ const stream = import_fs2.default.createReadStream(servePath);
2943
+ stream.pipe(res);
2944
+ stream.on("error", () => {
2945
+ try {
2946
+ res.end();
2947
+ } catch {
2948
+ }
2949
+ });
2950
+ });
2951
+ });
2952
+ }
2953
+ sweepIdleCliClients() {
2954
+ const now = Date.now();
2955
+ let swept = false;
2956
+ for (const [ws, browserId] of this.cliBrowserBySocket) {
2957
+ const lastCommand = this.cliLastCommandAt.get(ws) ?? 0;
2958
+ if (now - lastCommand < _SootSimBridgeHost.CLI_IDLE_TIMEOUT_MS) continue;
2959
+ this.cliBrowserBySocket.delete(ws);
2960
+ this.cliLastCommandAt.delete(ws);
2961
+ for (const [sentId, entry] of this.cliBySentId) {
2962
+ if (entry.ws === ws) this.cliBySentId.delete(sentId);
2963
+ }
2964
+ try {
2965
+ ws.close(1e3, "idle timeout");
2966
+ } catch {
2967
+ }
2968
+ swept = true;
2969
+ }
2970
+ if (swept) {
2971
+ this.broadcastBrowserClientStates();
2972
+ }
2973
+ this.sweepRestorableBrowsers(now);
2974
+ }
2975
+ listBrowsers() {
2976
+ return Array.from(this.browsers.values()).sort((a, b) => {
2977
+ if (a.id === this.primaryBrowserId) return -1;
2978
+ if (b.id === this.primaryBrowserId) return 1;
2979
+ return a.connectedAt - b.connectedAt;
2980
+ }).map((browser) => this.describeBrowser(browser));
2981
+ }
2982
+ async sendCommand(cmd) {
2983
+ const browser = await this.waitForBrowser(cmd.browserId);
2984
+ const id = this.nextCommandId++;
2985
+ return new Promise((resolve2, reject) => {
2986
+ const timeout = setTimeout(() => {
2987
+ this.pendingCommands.delete(id);
2988
+ this.broadcastBrowserClientStates();
2989
+ reject(new Error("command timed out after 30s"));
2990
+ }, 3e4);
2991
+ this.pendingCommands.set(id, {
2992
+ browserId: browser.id,
2993
+ resolve: (value) => {
2994
+ clearTimeout(timeout);
2995
+ this.pendingCommands.delete(id);
2996
+ this.broadcastBrowserClientStates();
2997
+ resolve2(value);
2998
+ },
2999
+ reject: (error) => {
3000
+ clearTimeout(timeout);
3001
+ this.pendingCommands.delete(id);
3002
+ this.broadcastBrowserClientStates();
3003
+ reject(error);
3004
+ }
3005
+ });
3006
+ this.broadcastBrowserClientStates();
3007
+ const { browserId: _browserId, ...forwarded } = cmd;
3008
+ browser.ws.send(JSON.stringify({ ...forwarded, id }));
3009
+ });
3010
+ }
3011
+ async evaluate(code, browserId) {
3012
+ return this.sendCommand({ type: "evaluate", code, browserId });
3013
+ }
3014
+ async focusBrowser(browserId) {
3015
+ return this.sendCommand({ type: "focus", browserId });
3016
+ }
3017
+ async closeBrowser(browserId) {
3018
+ return this.sendCommand({ type: "close", browserId });
3019
+ }
3020
+ async openPathInEditor(filePath, line, column) {
3021
+ const loc = line != null ? `:${line}${column != null ? `:${column}` : ""}` : "";
3022
+ const target = `${filePath}${loc}`;
3023
+ const trySpawn = (cmd, args) => new Promise((resolve2) => {
3024
+ try {
3025
+ const child = (0, import_child_process2.spawn)(cmd, args, { detached: true, stdio: "ignore" });
3026
+ let settled = false;
3027
+ child.on("error", () => {
3028
+ if (settled) return;
3029
+ settled = true;
3030
+ resolve2(false);
3031
+ });
3032
+ child.on("spawn", () => {
3033
+ if (settled) return;
3034
+ settled = true;
3035
+ child.unref();
3036
+ resolve2(true);
3037
+ });
3038
+ } catch {
3039
+ resolve2(false);
3040
+ }
3041
+ });
3042
+ const envEditor = process.env.REACT_EDITOR || process.env.EDITOR;
3043
+ if (envEditor) {
3044
+ const parts = envEditor.split(" ").filter(Boolean);
3045
+ if (parts.length && await trySpawn(parts[0], [...parts.slice(1), "-g", target]))
3046
+ return;
3047
+ }
3048
+ if (await trySpawn("cursor", ["-g", target])) return;
3049
+ if (await trySpawn("code", ["-g", target])) return;
3050
+ await this.openUrl(filePath);
3051
+ }
3052
+ async openUrl(url) {
3053
+ if (this.openUrlHandler) {
3054
+ await this.openUrlHandler(url);
3055
+ return;
3056
+ }
3057
+ if (process.platform === "darwin") {
3058
+ const child2 = (0, import_child_process2.spawn)("open", ["-g", url], { detached: true, stdio: "ignore" });
3059
+ child2.unref();
3060
+ return;
3061
+ }
3062
+ if (process.platform === "win32") {
3063
+ const child2 = (0, import_child_process2.spawn)("cmd", ["/c", "start", "", url], {
3064
+ detached: true,
3065
+ stdio: "ignore"
3066
+ });
3067
+ child2.unref();
3068
+ return;
3069
+ }
3070
+ const child = (0, import_child_process2.spawn)("xdg-open", [url], { detached: true, stdio: "ignore" });
3071
+ child.unref();
3072
+ }
3073
+ async close() {
3074
+ if (this.cliIdleTimer) {
3075
+ clearInterval(this.cliIdleTimer);
3076
+ this.cliIdleTimer = null;
3077
+ }
3078
+ if (this.heartbeatTimer) {
3079
+ clearInterval(this.heartbeatTimer);
3080
+ this.heartbeatTimer = null;
3081
+ }
3082
+ if (this.shouldWriteLockfile) {
3083
+ try {
3084
+ removeDaemonLockfile();
3085
+ } catch {
3086
+ }
3087
+ }
3088
+ this.effectivePort = 0;
3089
+ this.startedAt = 0;
3090
+ this.agentHost.close();
3091
+ for (const [id, pending] of this.pendingCommands) {
3092
+ pending.reject(new Error("server closing"));
3093
+ this.pendingCommands.delete(id);
3094
+ }
3095
+ for (const browser of this.browsers.values()) {
3096
+ browser.ws.close();
3097
+ }
3098
+ this.browsers.clear();
3099
+ this.primaryBrowserId = null;
3100
+ const wss = this.wss;
3101
+ const httpServer = this.httpServer;
3102
+ this.wss = null;
3103
+ this.httpServer = null;
3104
+ if (wss) {
3105
+ try {
3106
+ wss.close();
3107
+ } catch {
3108
+ }
3109
+ }
3110
+ if (httpServer) {
3111
+ try {
3112
+ httpServer.close();
3113
+ } catch {
3114
+ }
3115
+ }
3116
+ }
3117
+ describeBrowser(browser) {
3118
+ let readyState;
3119
+ try {
3120
+ readyState = browser.ws.readyState;
3121
+ } catch {
3122
+ readyState = import_ws.WebSocket.CLOSED;
3123
+ }
3124
+ const lease = this.getActiveLease(browser);
3125
+ return {
3126
+ id: browser.id,
3127
+ origin: browser.origin,
3128
+ url: browser.url,
3129
+ title: browser.title,
3130
+ userAgent: browser.userAgent,
3131
+ connectedAt: browser.connectedAt,
3132
+ lastSeenAt: browser.lastSeenAt,
3133
+ lastActiveAt: browser.lastActiveAt || void 0,
3134
+ isPrimary: browser.id === this.primaryBrowserId,
3135
+ readyState: readyState === import_ws.WebSocket.OPEN ? "open" : readyState === import_ws.WebSocket.CLOSING ? "closing" : "closed",
3136
+ attachedCliCount: this.getAttachedCliCount(browser.id),
3137
+ lockedBy: lease ? lease.cliLabel || lease.cliSessionKey : void 0,
3138
+ lockedByKind: lease ? lease.kind : void 0,
3139
+ lockExpiresAt: lease ? lease.expiresAt : void 0,
3140
+ userFocused: browser.userFocused || void 0
3141
+ };
3142
+ }
3143
+ getActiveLease(browser) {
3144
+ const lease = browser.cliLease;
3145
+ if (!lease) return null;
3146
+ if (Date.now() >= lease.expiresAt) {
3147
+ browser.cliLease = void 0;
3148
+ return null;
3149
+ }
3150
+ return lease;
3151
+ }
3152
+ tryAcquireLease(ws, browser, opts = {}) {
3153
+ const cliSessionKey = this.cliSessionKeyBySocket.get(ws) ?? (() => {
3154
+ const fallback = `ws-${this.nextCliFallbackId++}`;
3155
+ this.cliSessionKeyBySocket.set(ws, fallback);
3156
+ return fallback;
3157
+ })();
3158
+ const cliLabel = this.cliLabelBySocket.get(ws);
3159
+ const now = Date.now();
3160
+ const existing = this.getActiveLease(browser);
3161
+ const ownerMatches = existing && existing.cliSessionKey === cliSessionKey;
3162
+ let bootedCount = 0;
3163
+ if (existing && !ownerMatches && !opts.force) {
3164
+ return {
3165
+ granted: false,
3166
+ lease: existing,
3167
+ lock: {
3168
+ by: existing.cliLabel || existing.cliSessionKey,
3169
+ expiresInMs: Math.max(0, existing.expiresAt - now)
3170
+ },
3171
+ bootedCount: 0
3172
+ };
3173
+ }
3174
+ if (existing && !ownerMatches && opts.force) {
3175
+ for (const [cliWs, attachedBrowserId] of this.cliBrowserBySocket) {
3176
+ if (attachedBrowserId !== browser.id) continue;
3177
+ const otherKey = this.cliSessionKeyBySocket.get(cliWs);
3178
+ if (otherKey && otherKey !== cliSessionKey) {
3179
+ this.cliBrowserBySocket.delete(cliWs);
3180
+ try {
3181
+ cliWs.close(1e3, "lease claimed by another cli");
3182
+ } catch {
3183
+ }
3184
+ bootedCount++;
3185
+ }
3186
+ }
3187
+ }
3188
+ const lease = {
3189
+ kind: "cli",
3190
+ cliSessionKey,
3191
+ cliLabel,
3192
+ expiresAt: now + _SootSimBridgeHost.CLI_LEASE_TTL_MS
3193
+ };
3194
+ browser.cliLease = lease;
3195
+ return { granted: true, lease, bootedCount };
3196
+ }
3197
+ // user focus is advisory: we track it on the browser record so list/UI can
3198
+ // show "focused" alongside any cli lease, but focus alone never creates a
3199
+ // blocking lease. the old 15s user-focus lease meant clicking on the tab
3200
+ // locked out agent inspect calls for 15s — the opposite of what you want
3201
+ // when debugging something the user is actively looking at. use
3202
+ // updateUserActivity() to lock on real interaction instead.
3203
+ updateUserFocusLease(browser, focused) {
3204
+ const next = focused;
3205
+ if (browser.userFocused === next) return;
3206
+ browser.userFocused = next;
3207
+ this.broadcastBrowserClientStates();
3208
+ }
3209
+ // called when the browser reports a real user interaction (pointerdown,
3210
+ // keydown, wheel, touch). creates or refreshes a short `user-active` lease
3211
+ // that keeps agent writes from trampling a user who is driving the tab.
3212
+ // reads still pass through — shouldAcquireLease only blocks on writes.
3213
+ updateUserActivity(browser) {
3214
+ const existing = this.getActiveLease(browser);
3215
+ if (existing && existing.kind === "cli") {
3216
+ return;
3217
+ }
3218
+ browser.cliLease = {
3219
+ kind: "user-active",
3220
+ cliSessionKey: "__user-active__",
3221
+ cliLabel: "active user",
3222
+ expiresAt: Date.now() + _SootSimBridgeHost.USER_ACTIVE_LEASE_TTL_MS
3223
+ };
3224
+ this.broadcastBrowserClientStates();
3225
+ }
3226
+ ensureCliSessionKey(ws) {
3227
+ const existing = this.cliSessionKeyBySocket.get(ws);
3228
+ if (existing) return existing;
3229
+ const fallback = `ws-${this.nextCliFallbackId++}`;
3230
+ this.cliSessionKeyBySocket.set(ws, fallback);
3231
+ return fallback;
3232
+ }
3233
+ getOpenBrowser(browserId) {
3234
+ if (browserId) {
3235
+ const browser = this.browsers.get(browserId);
3236
+ if (browser?.ws.readyState === import_ws.WebSocket.OPEN) return browser;
3237
+ return null;
3238
+ }
3239
+ const primary = this.primaryBrowserId != null ? this.browsers.get(this.primaryBrowserId) : null;
3240
+ if (primary?.ws.readyState === import_ws.WebSocket.OPEN) return primary;
3241
+ for (const browser of this.browsers.values()) {
3242
+ if (browser.ws.readyState === import_ws.WebSocket.OPEN) return browser;
3243
+ }
3244
+ return null;
3245
+ }
3246
+ async waitForBrowser(browserId, options = {}) {
3247
+ const attempts = options.attempts ?? 10;
3248
+ const intervalMs = options.intervalMs ?? 200;
3249
+ for (let attempt = 0; attempt < attempts; attempt++) {
3250
+ const browser = this.getOpenBrowser(browserId);
3251
+ if (browser) return browser;
3252
+ await new Promise((resolve2) => setTimeout(resolve2, intervalMs));
3253
+ }
3254
+ throw new Error(
3255
+ browserId ? `no browser connected with id ${browserId}` : "no browser connected"
3256
+ );
3257
+ }
3258
+ shouldPromoteBrowser(browser) {
3259
+ const current = this.primaryBrowserId ? this.browsers.get(this.primaryBrowserId) : null;
3260
+ const isPrimaryCandidate = browser.origin?.includes(":5173");
3261
+ const currentIsPrimary = current?.origin?.includes(":5173");
3262
+ return !current || current.ws.readyState !== import_ws.WebSocket.OPEN || !!isPrimaryCandidate || !currentIsPrimary;
3263
+ }
3264
+ broadcastBrowserAssignments() {
3265
+ for (const browser of this.browsers.values()) {
3266
+ if (browser.ws.readyState !== import_ws.WebSocket.OPEN) continue;
3267
+ browser.ws.send(
3268
+ JSON.stringify({
3269
+ type: "bridge:welcome",
3270
+ browserId: browser.id,
3271
+ isPrimary: browser.id === this.primaryBrowserId
3272
+ })
3273
+ );
3274
+ }
3275
+ }
3276
+ broadcastBrowserClientStates() {
3277
+ for (const browser of this.browsers.values()) {
3278
+ if (browser.ws.readyState !== import_ws.WebSocket.OPEN) continue;
3279
+ const lease = this.getActiveLease(browser);
3280
+ const message = {
3281
+ type: "bridge:client-state",
3282
+ attachedCliCount: this.getAttachedCliCount(browser.id),
3283
+ activeAgentCommandCount: this.getActiveAgentCommandCount(browser.id),
3284
+ recentActions: browser.recentActions,
3285
+ lockedBy: lease ? lease.cliLabel || lease.cliSessionKey : void 0,
3286
+ lockedByKind: lease ? lease.kind : void 0,
3287
+ lockExpiresAt: lease ? lease.expiresAt : void 0,
3288
+ userFocused: browser.userFocused || void 0
3289
+ };
3290
+ browser.ws.send(JSON.stringify(message));
3291
+ }
3292
+ }
3293
+ setCliBrowserTarget(ws, browserId) {
3294
+ const prevBrowserId = this.cliBrowserBySocket.get(ws);
3295
+ if (prevBrowserId === browserId) return;
3296
+ this.cliBrowserBySocket.set(ws, browserId);
3297
+ this.recordBrowserAction(
3298
+ browserId,
3299
+ prevBrowserId ? "cli switched tabs" : "cli connected",
3300
+ false
3301
+ );
3302
+ this.broadcastBrowserClientStates();
3303
+ }
3304
+ recordBrowserAction(browserId, label, broadcast = true) {
3305
+ const normalized = label?.trim();
3306
+ if (!normalized) return;
3307
+ const browser = this.browsers.get(browserId);
3308
+ if (!browser) return;
3309
+ const now = Date.now();
3310
+ browser.lastActiveAt = now;
3311
+ browser.recentActions = [
3312
+ { label: normalized, at: now },
3313
+ ...browser.recentActions.filter((entry) => entry.label !== normalized)
3314
+ ].slice(0, 4);
3315
+ if (broadcast) this.broadcastBrowserClientStates();
3316
+ }
3317
+ describeForwardedCommand(msg) {
3318
+ switch (msg?.type) {
3319
+ case "evaluate":
3320
+ return "evaluated page state";
3321
+ case "screenshot":
3322
+ return "captured screenshot";
3323
+ case "tap":
3324
+ return "sent tap event";
3325
+ case "keyboard":
3326
+ return msg?.action === "type" ? "typed text" : "used keyboard";
3327
+ case "tree":
3328
+ return "dumped tree";
3329
+ case "focus":
3330
+ return "focused tab";
3331
+ case "close":
3332
+ return "requested close";
3333
+ default:
3334
+ return typeof msg?.type === "string" ? msg.type : null;
3335
+ }
3336
+ }
3337
+ // count distinct cli session keys attached to a browser, not raw sockets.
3338
+ // a single agent firing sequential cli commands opens a new ws per call —
3339
+ // counting sockets would report phantom peers until idle cleanup catches up.
3340
+ getAttachedCliCount(browserId) {
3341
+ const keys = /* @__PURE__ */ new Set();
3342
+ for (const [ws, attachedBrowserId] of this.cliBrowserBySocket) {
3343
+ if (attachedBrowserId !== browserId) continue;
3344
+ if (ws.readyState !== import_ws.WebSocket.OPEN) continue;
3345
+ const key = this.cliSessionKeyBySocket.get(ws);
3346
+ keys.add(key ?? `ws-unknown-${keys.size}`);
3347
+ }
3348
+ return keys.size;
3349
+ }
3350
+ // count distinct session keys attached to this browser other than `selfWs`.
3351
+ // used to warn a cli that other agents/sessions are also targeting the tab.
3352
+ getOtherCliSessionCount(selfWs, browserId) {
3353
+ const selfKey = this.cliSessionKeyBySocket.get(selfWs);
3354
+ const keys = /* @__PURE__ */ new Set();
3355
+ for (const [ws, attachedBrowserId] of this.cliBrowserBySocket) {
3356
+ if (attachedBrowserId !== browserId) continue;
3357
+ if (ws.readyState !== import_ws.WebSocket.OPEN) continue;
3358
+ const key = this.cliSessionKeyBySocket.get(ws);
3359
+ if (key && key === selfKey) continue;
3360
+ keys.add(key ?? `ws-unknown-${keys.size}`);
3361
+ }
3362
+ return keys.size;
3363
+ }
3364
+ getActiveAgentCommandCount(browserId) {
3365
+ let count = 0;
3366
+ for (const pending of this.pendingCommands.values()) {
3367
+ if (pending.browserId === browserId) count++;
3368
+ }
3369
+ return count;
3370
+ }
3371
+ tryRestoreBrowserId(browser, requestedId) {
3372
+ const nextId = requestedId?.trim();
3373
+ if (!nextId || nextId === browser.id) return false;
3374
+ const existing = this.browsers.get(nextId);
3375
+ if (existing && existing !== browser && existing.ws.readyState === import_ws.WebSocket.OPEN) {
3376
+ return false;
3377
+ }
3378
+ const restorable = this.getRestorableBrowserState(nextId);
3379
+ const prevId = browser.id;
3380
+ this.browsers.delete(prevId);
3381
+ browser.id = nextId;
3382
+ if (restorable) {
3383
+ browser.recentActions = restorable.recentActions.map((entry) => ({ ...entry }));
3384
+ browser.lastActiveAt = restorable.lastActiveAt;
3385
+ browser.cliLease = restorable.cliLease ? { ...restorable.cliLease } : void 0;
3386
+ this.restorableBrowsers.delete(nextId);
3387
+ }
3388
+ this.browsers.set(browser.id, browser);
3389
+ if (this.primaryBrowserId === prevId) {
3390
+ this.primaryBrowserId = browser.id;
3391
+ }
3392
+ for (const [ws, attachedBrowserId] of this.cliBrowserBySocket) {
3393
+ if (attachedBrowserId === prevId) {
3394
+ this.cliBrowserBySocket.set(ws, browser.id);
3395
+ }
3396
+ }
3397
+ return true;
3398
+ }
3399
+ rememberDisconnectedBrowser(browser) {
3400
+ const lease = this.getActiveLease(browser);
3401
+ this.restorableBrowsers.set(browser.id, {
3402
+ recentActions: browser.recentActions.map((entry) => ({ ...entry })),
3403
+ lastActiveAt: browser.lastActiveAt,
3404
+ cliLease: lease && lease.kind === "cli" ? { ...lease } : void 0,
3405
+ expiresAt: Date.now() + _SootSimBridgeHost.BROWSER_RECONNECT_TTL_MS
3406
+ });
3407
+ this.browsers.delete(browser.id);
3408
+ }
3409
+ getRestorableBrowserState(browserId) {
3410
+ const snapshot = this.restorableBrowsers.get(browserId);
3411
+ if (!snapshot) return null;
3412
+ if (snapshot.expiresAt <= Date.now()) {
3413
+ this.restorableBrowsers.delete(browserId);
3414
+ return null;
3415
+ }
3416
+ if (snapshot.cliLease && snapshot.cliLease.expiresAt <= Date.now()) {
3417
+ snapshot.cliLease = void 0;
3418
+ }
3419
+ return snapshot;
3420
+ }
3421
+ sweepRestorableBrowsers(now = Date.now()) {
3422
+ for (const [browserId, snapshot] of this.restorableBrowsers) {
3423
+ if (snapshot.expiresAt > now) continue;
3424
+ this.restorableBrowsers.delete(browserId);
3425
+ for (const [cliWs, attachedBrowserId] of this.cliBrowserBySocket) {
3426
+ if (attachedBrowserId === browserId) {
3427
+ this.cliBrowserBySocket.delete(cliWs);
3428
+ }
3429
+ }
3430
+ }
3431
+ }
3432
+ resetServerState() {
3433
+ if (this.cliIdleTimer) {
3434
+ clearInterval(this.cliIdleTimer);
3435
+ this.cliIdleTimer = null;
3436
+ }
3437
+ const wss = this.wss;
3438
+ const httpServer = this.httpServer;
3439
+ this.wss = null;
3440
+ this.httpServer = null;
3441
+ if (wss) {
3442
+ try {
3443
+ wss.close();
3444
+ } catch {
3445
+ }
3446
+ }
3447
+ if (httpServer) {
3448
+ try {
3449
+ httpServer.close();
3450
+ } catch {
3451
+ }
3452
+ }
3453
+ }
3454
+ };
3455
+ // Annotate the CommonJS export names for ESM import in node:
3456
+ 0 && (module.exports = {
3457
+ SootSimBridgeHost
3458
+ });