unbrowse 2.12.2 → 2.12.7

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 (64) hide show
  1. package/README.md +8 -44
  2. package/dist/cli.js +514 -20723
  3. package/package.json +4 -10
  4. package/runtime-src/api/routes.ts +15 -801
  5. package/runtime-src/auth/index.ts +32 -142
  6. package/runtime-src/capture/index.ts +101 -436
  7. package/runtime-src/cli.ts +371 -956
  8. package/runtime-src/client/index.ts +29 -622
  9. package/runtime-src/execution/index.ts +85 -345
  10. package/runtime-src/graph/index.ts +10 -128
  11. package/runtime-src/intent-match.ts +27 -27
  12. package/runtime-src/kuri/client.ts +82 -543
  13. package/runtime-src/orchestrator/index.ts +462 -2246
  14. package/runtime-src/reverse-engineer/index.ts +22 -220
  15. package/runtime-src/runtime/local-server.ts +16 -149
  16. package/runtime-src/runtime/paths.ts +5 -9
  17. package/runtime-src/runtime/setup.ts +1 -52
  18. package/runtime-src/server.ts +11 -6
  19. package/runtime-src/transform/schema-hints.ts +358 -0
  20. package/runtime-src/types/skill.ts +2 -49
  21. package/runtime-src/verification/index.ts +0 -15
  22. package/runtime-src/version.ts +13 -13
  23. package/vendor/kuri/darwin-arm64/kuri +0 -0
  24. package/vendor/kuri/darwin-x64/kuri +0 -0
  25. package/vendor/kuri/linux-arm64/kuri +0 -0
  26. package/vendor/kuri/linux-x64/kuri +0 -0
  27. package/bin/unbrowse-wrapper.mjs +0 -39
  28. package/bin/unbrowse.js +0 -38
  29. package/runtime-src/analytics-session.ts +0 -33
  30. package/runtime-src/api/browse-index.ts +0 -254
  31. package/runtime-src/api/browse-session.ts +0 -179
  32. package/runtime-src/api/browse-submit.ts +0 -455
  33. package/runtime-src/auth/runtime.ts +0 -116
  34. package/runtime-src/browser/index.ts +0 -635
  35. package/runtime-src/browser/types.ts +0 -41
  36. package/runtime-src/capture/prefetch.ts +0 -122
  37. package/runtime-src/capture/rsc.ts +0 -45
  38. package/runtime-src/cli/shortcuts.ts +0 -273
  39. package/runtime-src/client/graph-client.ts +0 -99
  40. package/runtime-src/execution/robots.ts +0 -167
  41. package/runtime-src/execution/search-forms.ts +0 -188
  42. package/runtime-src/graph/planner.ts +0 -411
  43. package/runtime-src/graph/session.ts +0 -294
  44. package/runtime-src/graph/trace-store.ts +0 -136
  45. package/runtime-src/indexer/index.ts +0 -480
  46. package/runtime-src/orchestrator/browser-agent.ts +0 -374
  47. package/runtime-src/orchestrator/dag-advisor.ts +0 -59
  48. package/runtime-src/orchestrator/dag-feedback.ts +0 -256
  49. package/runtime-src/orchestrator/first-pass-action.ts +0 -362
  50. package/runtime-src/orchestrator/passive-publish.ts +0 -152
  51. package/runtime-src/orchestrator/timing-economics.ts +0 -80
  52. package/runtime-src/payments/cascade.ts +0 -137
  53. package/runtime-src/payments/index.ts +0 -268
  54. package/runtime-src/payments/wallet.ts +0 -33
  55. package/runtime-src/reverse-engineer/description-prompt.ts +0 -132
  56. package/runtime-src/router.ts +0 -17
  57. package/runtime-src/runtime/browser-access.ts +0 -11
  58. package/runtime-src/runtime/browser-host.ts +0 -48
  59. package/runtime-src/runtime/lifecycle.ts +0 -17
  60. package/runtime-src/runtime/supervisor.ts +0 -69
  61. package/runtime-src/single-binary.ts +0 -141
  62. package/runtime-src/telemetry.ts +0 -253
  63. package/runtime-src/verification/matrix.ts +0 -30
  64. package/scripts/postinstall.mjs +0 -81
@@ -6,8 +6,7 @@ import { nanoid } from "nanoid";
6
6
  import { inferEndpointSemantic } from "../graph/index.js";
7
7
  import { writeDebugTrace } from "../debug-trace.js";
8
8
  import { buildQueryBindingMap } from "../template-params.js";
9
- import { buildDescriptionPrompt, groundedDescription, extractResponseKeys } from "./description-prompt.js";
10
- import { isRscPayload, extractRscDataEndpoints } from "../capture/rsc.js";
9
+
11
10
  const SKIP_EXTENSIONS = /\.(js|mjs|css|png|jpg|jpeg|gif|svg|ico|woff|woff2|ttf|map|webp|html|avif)([?#]|$)/i;
12
11
  const SKIP_JS_BUNDLES = /\/(boq-|_\/mss\/|og\/_\/js\/|_\/scs\/)/i;
13
12
  const SKIP_PATHS = /\/_next\/static\/|\/_next\/data\/|\/_next\/image|\/static\/chunks\/|\/static\/media\/|\/cdn-cgi\//i;
@@ -55,19 +54,6 @@ const STRIP_HEADER_PREFIXES = [
55
54
  "x-firebase-", // Firebase auth headers
56
55
  ];
57
56
 
58
- // Browser-captured headers that are not secrets themselves, but are still
59
- // required to replay authenticated requests after publish-time header redaction.
60
- const REPLAY_HEADER_PREFIXES = [
61
- "x-li-",
62
- ];
63
- const REPLAY_HEADER_EXACT = new Set([
64
- "accept",
65
- "csrf-token",
66
- "origin",
67
- "x-requested-with",
68
- "x-restli-protocol-version",
69
- ]);
70
-
71
57
  // Headers known to be safe (non-sensitive) — used by the catch-all filter below
72
58
  const SAFE_HEADERS = new Set([
73
59
  "accept", "accept-encoding", "accept-language", "cache-control",
@@ -178,36 +164,23 @@ function buildEndpointDescription(
178
164
  sampleResponse: unknown,
179
165
  ): string {
180
166
  const url = new URL(req.url);
181
-
182
- // Build param descriptors from the flattened sample request so the
183
- // description is grounded in the actual parameters observed at capture time.
184
- const params: Array<{ name: string; in: string; example?: string }> = [];
185
- for (const [key, value] of Object.entries(sampleRequest)) {
186
- // Determine whether the param came from the query string or the body.
187
- const inKind = url.searchParams.has(key) ? "query"
188
- : url.pathname.includes(`{${key}}`) ? "path"
189
- : "body";
190
- const example = value != null && typeof value !== "object" ? String(value) : undefined;
191
- params.push({ name: key, in: inKind, ...(example ? { example } : {}) });
192
- }
193
-
194
- const responseKeys = extractResponseKeys(sampleResponse);
195
-
196
- const ctx = {
197
- url_template: req.url,
198
- method: req.method,
199
- params,
200
- sample_response_keys: responseKeys.length > 0 ? responseKeys : undefined,
201
- domain: url.hostname,
202
- };
203
-
204
- // Build the grounding prompt (available for optional LLM polish in
205
- // backend/services/descriptions.ts) and the deterministic description.
206
- const _prompt = buildDescriptionPrompt(ctx);
207
-
208
- // Use the grounded description builder from description-prompt.ts so
209
- // every description references real params and response fields.
210
- return groundedDescription(ctx);
167
+ const pathTail = url.pathname.split("/").filter(Boolean).slice(-2).join(" ");
168
+ const requestKeys = Object.keys(sampleRequest).slice(0, 4);
169
+ const response = summarizeResponseExample(sampleResponse);
170
+ const action = requestKeys.some((key) => /^(q|query|search|term)$/i.test(key)) || /search|find|lookup/.test(url.pathname)
171
+ ? "Searches"
172
+ : /status|health|incident|maintenance/.test(url.pathname)
173
+ ? "Returns status for"
174
+ : url.pathname.match(/\{[^}]+\}|\/[0-9A-Za-z_-]{4,}(\/|$)/)
175
+ ? "Returns details for"
176
+ : "Returns";
177
+ const subjectSource = new Set(["response", "data", "result", "results", "item", "items"]).has(response.subject.toLowerCase())
178
+ ? inferPathSubject(url.pathname)
179
+ : response.subject;
180
+ const subject = titleCase(subjectSource === "response" ? (pathTail || url.hostname) : subjectSource);
181
+ const fieldText = response.fields.length > 0 ? ` with ${response.fields.join(", ")}` : "";
182
+ const inputText = requestKeys.length > 0 ? ` using ${requestKeys.join(", ")}` : "";
183
+ return `${action} ${subject}${fieldText}${inputText}`;
211
184
  }
212
185
 
213
186
  function looksLikeAdResponse(body: string | undefined): boolean {
@@ -408,8 +381,8 @@ function inferCsrfPlan(req: RawRequest, parsedBody?: unknown): CsrfPlan | undefi
408
381
  Object.entries(req.request_headers).map(([key, value]) => [key.toLowerCase(), value]),
409
382
  );
410
383
  const cookies = parseCookieHeader(headers["cookie"]);
411
- const csrfCookieNames = Object.keys(cookies).filter((name) => /^(ct0|csrf_token|_csrf|csrftoken|xsrf-token|_xsrf|jsessionid)$/i.test(name));
412
- const headerName = ["x-csrf-token", "x-xsrf-token", "x-csrftoken", "csrf-token"].find((name) => typeof headers[name] === "string" && headers[name].length > 0);
384
+ const csrfCookieNames = Object.keys(cookies).filter((name) => /^(ct0|csrf_token|_csrf|csrftoken|xsrf-token|_xsrf)$/i.test(name));
385
+ const headerName = ["x-csrf-token", "x-xsrf-token", "x-csrftoken"].find((name) => typeof headers[name] === "string" && headers[name].length > 0);
413
386
  if (headerName && csrfCookieNames.length > 0) {
414
387
  return {
415
388
  source: "cookie",
@@ -575,8 +548,6 @@ function scoreRequest(req: RawRequest): number {
575
548
  // Penalise Next.js RSC navigation requests — framework wire format, not data
576
549
  if (req.url.includes("_rsc=")) score -= 3;
577
550
  if (ct.includes("text/x-component")) score -= 10; // RSC wire format
578
- // #227: Structural RSC body detection — catches payloads without URL/content-type hints
579
- if (isRscPayload(req.response_body ?? "")) score -= 15;
580
551
  // Penalise on-domain noise (framework plumbing, recaptcha, consent, ad bids)
581
552
  try { if (ON_DOMAIN_NOISE.test(new URL(req.url).pathname)) score -= 15; } catch {}
582
553
  // Reward rich JSON responses (data endpoints have deep objects, noise has shallow)
@@ -629,46 +600,7 @@ export function extractEndpoints(requests: RawRequest[], wsMessages?: CapturedWs
629
600
  continue;
630
601
  }
631
602
  if (!hasAdmissibleParsedBody(req.response_body)) {
632
- // API endpoints may have large/truncated/missing response bodies.
633
- // Admit them anyway if the URL pattern is clearly an API endpoint.
634
- const urlPath = (() => { try { return new URL(req.url).pathname; } catch { return ""; } })();
635
- const isApiUrl = /\/(api|graphql)\b/i.test(urlPath) || /\.(json)(\?|$)/.test(req.url);
636
-
637
- // For GraphQL: extract operationName from request body or URL
638
- let graphqlOpName: string | undefined;
639
- if (/graphql/i.test(req.url)) {
640
- if (req.request_body) {
641
- try {
642
- const body = JSON.parse(req.request_body);
643
- graphqlOpName = body.operationName ?? body.query?.match(/(?:query|mutation)\s+(\w+)/)?.[1];
644
- } catch { /* not JSON */ }
645
- }
646
- // Also try extracting from URL query (GET GraphQL endpoints encode operationName in URL)
647
- if (!graphqlOpName) {
648
- const urlMatch = req.url.match(/\/graphql\/\w+\/(\w+)/);
649
- if (urlMatch) graphqlOpName = urlMatch[1];
650
- }
651
- }
652
-
653
- // For .json endpoints: use the last path segment as description
654
- const jsonEndpointName = /\.(json)(\?|$)/.test(req.url) ? urlPath.split("/").pop()?.replace(".json", "") : undefined;
655
-
656
- if (!isApiUrl) {
657
- traceRows.push({ url: req.url, method: req.method, score, kept: false, reason: "body_not_json_or_html" });
658
- continue;
659
- }
660
-
661
- // Inject a synthetic response body so downstream processing works
662
- const syntheticName = graphqlOpName ?? jsonEndpointName ?? "api_endpoint";
663
- req.response_body = JSON.stringify({ data: { __typename: syntheticName } });
664
- req.response_headers = { ...req.response_headers, "content-type": "application/json" };
665
- }
666
- // #227: Reject React Server Components wire format payloads — they are framework
667
- // rendering wire format, not data APIs. Use the proper RSC parser instead of
668
- // relying solely on URL heuristics (_rsc=) or content-type (text/x-component).
669
- if (isRscPayload(req.response_body ?? "")) {
670
- const rscUrls = extractRscDataEndpoints(req.response_body ?? "");
671
- traceRows.push({ url: req.url, method: req.method, score, kept: false, reason: "rsc_payload", rsc_embedded_urls: rscUrls.length > 0 ? rscUrls : undefined });
603
+ traceRows.push({ url: req.url, method: req.method, score, kept: false, reason: "body_not_json_or_html" });
672
604
  continue;
673
605
  }
674
606
  if (affinityDomains.size > 0) {
@@ -689,16 +621,6 @@ export function extractEndpoints(requests: RawRequest[], wsMessages?: CapturedWs
689
621
  }
690
622
  scored.sort((a, b) => b.score - a.score);
691
623
 
692
- // For passive captures (no context page URL), pre-compute path templates across
693
- // all candidate paths so individual endpoints can be annotated without needing
694
- // collapseEndpoints' sibling grouping.
695
- const minedTemplateMap = !context?.pageUrl
696
- ? minePathTemplates(scored.map(({ req }) => {
697
- try { return new URL(req.url).pathname; } catch { return ""; }
698
- }).filter(Boolean))
699
- : new Map<string, string>();
700
-
701
-
702
624
  for (const { req } of scored) {
703
625
  const normalized = normalizeUrl(req.url);
704
626
  const key = `${req.method}:${normalized}`;
@@ -827,12 +749,6 @@ export function extractEndpoints(requests: RawRequest[], wsMessages?: CapturedWs
827
749
  action_kind: endpoint.semantic?.action_kind,
828
750
  resource_kind: endpoint.semantic?.resource_kind,
829
751
  });
830
- // Annotate with mined template when available (passive capture, no page context)
831
- try {
832
- const pathname = new URL(req.url).pathname;
833
- const mined = minedTemplateMap.get(pathname);
834
- if (mined) endpoint._minedTemplate = mined;
835
- } catch { /* ignore bad URLs */ }
836
752
  endpoints.push(endpoint);
837
753
  }
838
754
 
@@ -981,15 +897,6 @@ function isSensitiveHeader(name: string): boolean {
981
897
  return false;
982
898
  }
983
899
 
984
- function isReplayCriticalHeader(name: string, value: string): boolean {
985
- const lower = name.toLowerCase();
986
- if (REPLAY_HEADER_EXACT.has(lower)) {
987
- if (lower !== "accept") return true;
988
- return /application\/vnd\./i.test(value);
989
- }
990
- return REPLAY_HEADER_PREFIXES.some((prefix) => lower.startsWith(prefix));
991
- }
992
-
993
900
  function sanitizeHeaders(headers: Record<string, string>): Record<string, string> {
994
901
  return Object.fromEntries(
995
902
  Object.entries(headers ?? {}).filter(([k]) => {
@@ -1011,7 +918,7 @@ export function extractAuthHeaders(requests: RawRequest[]): Record<string, strin
1011
918
  for (const [k, v] of Object.entries(req.request_headers)) {
1012
919
  const lower = k.toLowerCase();
1013
920
  if (lower === "cookie" || lower === "content-length" || lower === "host") continue;
1014
- if ((isSensitiveHeader(k) || isReplayCriticalHeader(k, v)) && !authHeaders[lower]) {
921
+ if (isSensitiveHeader(k) && !authHeaders[lower]) {
1015
922
  authHeaders[lower] = v;
1016
923
  }
1017
924
  }
@@ -1147,25 +1054,6 @@ function templatizePathSegments(
1147
1054
  pathParams[paramName] = oSeg;
1148
1055
  continue;
1149
1056
  }
1150
-
1151
- // Pattern 3: Context-diff — endpoint URL segment differs from page URL at same position.
1152
- // If the page URL has a different value at this position, this segment is likely an entity
1153
- // that should be parameterized. e.g. page=/r/singularity/ endpoint=/r/programming/.json
1154
- if (context?.pageUrl) {
1155
- try {
1156
- const contextSegments = new URL(context.pageUrl).pathname.split("/");
1157
- const contextSeg = contextSegments[i];
1158
- if (contextSeg && contextSeg !== tSeg &&
1159
- !contextSeg.includes(".") &&
1160
- contextSeg.length >= 2 && contextSeg.length <= 40 &&
1161
- !/^(api|v\d+|www|en|es|fr|de|latest|search|i)$/i.test(contextSeg)) {
1162
- const paramName = inferParamName(tSegments, i, "slug", usedNames);
1163
- tSegments[i] = `{${paramName}}`;
1164
- pathParams[paramName] = contextSeg; // use context URL value as default
1165
- continue;
1166
- }
1167
- } catch { /* skip */ }
1168
- }
1169
1057
  }
1170
1058
 
1171
1059
  return { url: `${tOrigin}${tSegments.join("/")}`, pathParams };
@@ -1210,18 +1098,6 @@ function tryParseBody(body: string): Record<string, unknown> | undefined {
1210
1098
  * Used by collapseEndpoints to avoid merging distinct API actions
1211
1099
  * like /relationships/connectionsSummary + /relationships/invitationsSummary.
1212
1100
  */
1213
- /** Compute Shannon entropy (bits per character) for a string. */
1214
- function computeEntropy(s: string): number {
1215
- const freq = new Map<string, number>();
1216
- for (const ch of s) freq.set(ch, (freq.get(ch) ?? 0) + 1);
1217
- let h = 0;
1218
- for (const count of freq.values()) {
1219
- const p = count / s.length;
1220
- h -= p * Math.log2(p);
1221
- }
1222
- return h;
1223
- }
1224
-
1225
1101
  function looksLikeEntityId(segment: string): boolean {
1226
1102
  if (segment.startsWith("{")) return true;
1227
1103
  // UUID (with or without dashes)
@@ -1236,12 +1112,6 @@ function looksLikeEntityId(segment: string): boolean {
1236
1112
  if (/^[A-Z]{1,5}(\.[A-Z])?$/.test(segment)) return true;
1237
1113
  // Comma-separated lists
1238
1114
  if (segment.includes(",")) return true;
1239
- // Base64-encoded IDs: mixed case with = padding
1240
- if (/^[A-Za-z0-9+/]{6,}={1,2}$/.test(segment)) return true;
1241
-
1242
- // High-entropy strings are likely encoded IDs (tokens, hashes, opaque cursors, etc.)
1243
- const entropy = computeEntropy(segment);
1244
- if (entropy > 3.5 && segment.length > 5) return true;
1245
1115
 
1246
1116
  // === NOT an entity ID — these are action/resource names ===
1247
1117
  // camelCase: lowercase letter followed by uppercase (e.g., connectionsSummary)
@@ -1251,9 +1121,6 @@ function looksLikeEntityId(segment: string): boolean {
1251
1121
  // Pure lowercase alphabetic word 3+ chars (REST resource: "connections", "settings")
1252
1122
  if (/^[a-z]{3,}$/.test(segment)) return false;
1253
1123
 
1254
- // Low-entropy strings are likely readable names, not IDs
1255
- if (entropy < 2.5 && segment.length > 3) return false;
1256
-
1257
1124
  // Ambiguous — allow collapsing (conservative)
1258
1125
  return true;
1259
1126
  }
@@ -1272,71 +1139,6 @@ function looksLikeEntityId(segment: string): boolean {
1272
1139
  * Only collapses when the majority (>50%) of varying segments look like entity
1273
1140
  * IDs, NOT distinct action/resource names (camelCase, REST words).
1274
1141
  */
1275
-
1276
- /**
1277
- * Mine path templates from a batch of URL paths that lack a context page URL.
1278
- * Builds a prefix trie and identifies positions where enough distinct children
1279
- * look like entity IDs, replacing them with `{id}` placeholders.
1280
- *
1281
- * @param paths - Array of URL pathnames (e.g. "/api/users/123/posts")
1282
- * @param maxChildren - Minimum distinct values at a position to trigger wildcarding (default 4)
1283
- * @returns Map from original path to templated path (only paths that changed are included)
1284
- */
1285
- export function minePathTemplates(
1286
- paths: string[],
1287
- maxChildren = 4,
1288
- ): Map<string, string> {
1289
- // Build a prefix trie: prefix → Map<segment, count>
1290
- const trie = new Map<string, Map<string, number>>();
1291
-
1292
- for (const path of paths) {
1293
- const segments = path.split("/").filter(Boolean);
1294
- for (let i = 0; i < segments.length; i++) {
1295
- const prefix = "/" + segments.slice(0, i).join("/");
1296
- const children = trie.get(prefix) ?? new Map<string, number>();
1297
- const seg = segments[i];
1298
- children.set(seg, (children.get(seg) ?? 0) + 1);
1299
- trie.set(prefix, children);
1300
- }
1301
- }
1302
-
1303
- // Identify wildcard prefixes: positions where distinct children >= maxChildren
1304
- // AND more than 50% of those children look like entity IDs.
1305
- const wildcardPrefixes = new Set<string>();
1306
- for (const [prefix, children] of trie) {
1307
- if (children.size < maxChildren) continue;
1308
- const segs = Array.from(children.keys());
1309
- const entityCount = segs.filter((s) => looksLikeEntityId(s)).length;
1310
- if (entityCount / segs.length > 0.5) {
1311
- wildcardPrefixes.add(prefix);
1312
- }
1313
- }
1314
-
1315
- if (wildcardPrefixes.size === 0) return new Map();
1316
-
1317
- // Build original → template map for paths that contain wildcarded positions.
1318
- const result = new Map<string, string>();
1319
- for (const path of paths) {
1320
- const segments = path.split("/").filter(Boolean);
1321
- const templated: string[] = [];
1322
- let changed = false;
1323
- for (let i = 0; i < segments.length; i++) {
1324
- const prefix = "/" + segments.slice(0, i).join("/");
1325
- if (wildcardPrefixes.has(prefix)) {
1326
- templated.push("{id}");
1327
- changed = true;
1328
- } else {
1329
- templated.push(segments[i]);
1330
- }
1331
- }
1332
- if (changed) {
1333
- result.set(path, "/" + templated.join("/"));
1334
- }
1335
- }
1336
-
1337
- return result;
1338
- }
1339
-
1340
1142
  function collapseEndpoints(endpoints: EndpointDescriptor[]): EndpointDescriptor[] {
1341
1143
  // Group by method + origin + all-but-last path segment
1342
1144
  const groups = new Map<string, EndpointDescriptor[]>();
@@ -2,15 +2,12 @@ import { openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import path from "node:path";
3
3
  import { spawn } from "node:child_process";
4
4
  import { ensureDir, getPackageRoot, getServerAutostartLogFile, getServerPidFile, resolveSiblingEntrypoint, runtimeArgsForEntrypoint } from "./paths.js";
5
- import { LocalSupervisor } from "./supervisor.js";
6
5
 
7
6
  type PidState = {
8
7
  pid: number;
9
8
  base_url: string;
10
9
  started_at: string;
11
10
  entrypoint: string;
12
- version?: string;
13
- restart_count?: number;
14
11
  };
15
12
 
16
13
  async function isServerHealthy(baseUrl: string, timeoutMs = 2_000): Promise<boolean> {
@@ -63,34 +60,21 @@ function deriveListenEnv(baseUrl: string): Record<string, string> {
63
60
  return { HOST: host, PORT: port, UNBROWSE_URL: baseUrl };
64
61
  }
65
62
 
66
- /** Read package version from the nearest package.json. */
67
- function getVersion(metaUrl: string): string {
68
- try {
69
- const root = getPackageRoot(metaUrl);
70
- const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf-8"));
71
- return pkg.version ?? "unknown";
72
- } catch {
73
- return "unknown";
74
- }
75
- }
63
+ export async function ensureLocalServer(baseUrl: string, noAutoStart: boolean, metaUrl: string): Promise<void> {
64
+ if (await isServerHealthy(baseUrl)) return;
76
65
 
77
- function isCompiledBinary(): boolean {
78
- return !!(process.versions.bun && !process.argv[1]?.match(/\.(ts|js|mjs)$/));
79
- }
66
+ const pidFile = getServerPidFile(baseUrl);
67
+ const existing = readPidState(pidFile);
68
+ if (existing?.pid && isPidAlive(existing.pid)) {
69
+ if (await waitForHealthy(baseUrl, 15_000)) return;
70
+ } else if (existing) {
71
+ clearStalePidFile(pidFile);
72
+ }
80
73
 
81
- const MAX_RESTART_ATTEMPTS = 3;
82
- const RESTART_BACKOFF_MS = 2_000;
74
+ if (noAutoStart) {
75
+ throw new Error("Server not running and auto-start disabled (--no-auto-start).");
76
+ }
83
77
 
84
- /**
85
- * Spawn the local server as a detached child process.
86
- * Returns the PidState written to the pid file.
87
- */
88
- function spawnServer(
89
- baseUrl: string,
90
- metaUrl: string,
91
- pidFile: string,
92
- restartCount = 0,
93
- ): PidState {
94
78
  const entrypoint = resolveSiblingEntrypoint(metaUrl, "index");
95
79
  const packageRoot = getPackageRoot(metaUrl);
96
80
  const logFile = getServerAutostartLogFile();
@@ -110,130 +94,13 @@ function spawnServer(
110
94
  });
111
95
  child.unref();
112
96
 
113
- const state: PidState = {
97
+ writeFileSync(pidFile, JSON.stringify({
114
98
  pid: child.pid!,
115
99
  base_url: baseUrl,
116
100
  started_at: new Date().toISOString(),
117
101
  entrypoint,
118
- version: getVersion(metaUrl),
119
- restart_count: restartCount,
120
- };
121
- writeFileSync(pidFile, JSON.stringify(state, null, 2));
122
- return state;
123
- }
124
-
125
- /** Shared supervisor instance used for health checking. */
126
- const supervisor = new LocalSupervisor();
127
-
128
- export { supervisor };
129
-
130
- export async function ensureLocalServer(baseUrl: string, noAutoStart: boolean, metaUrl: string): Promise<void> {
131
- if (await isServerHealthy(baseUrl)) {
132
- // Server already healthy — ensure supervisor state reflects this
133
- if (!supervisor.isRunning()) await supervisor.start();
134
- return;
135
- }
136
-
137
- const pidFile = getServerPidFile(baseUrl);
138
- const existing = readPidState(pidFile);
139
-
140
- if (existing?.pid && isPidAlive(existing.pid)) {
141
- // Process alive but not healthy — wait then try supervisor restart
142
- if (await waitForHealthy(baseUrl, 15_000)) {
143
- if (!supervisor.isRunning()) await supervisor.start();
144
- return;
145
- }
146
- // Still unhealthy after wait — kill stale process and restart
147
- try { process.kill(existing.pid, "SIGTERM"); } catch { /* ignore */ }
148
- await new Promise((r) => setTimeout(r, 1_000));
149
- clearStalePidFile(pidFile);
150
- if (supervisor.isRunning()) await supervisor.stop();
151
- } else if (existing) {
152
- clearStalePidFile(pidFile);
153
- }
154
-
155
- if (noAutoStart) {
156
- throw new Error("Server not running and auto-start disabled (--no-auto-start).");
157
- }
158
-
159
- // Single-binary mode: start server inline instead of spawning a child
160
- if (isCompiledBinary()) {
161
- const { startUnbrowseServer, installServerExitCleanup } = await import("../server.js");
162
- installServerExitCleanup(pidFile);
163
- const server = await startUnbrowseServer({ pidFile, scheduleVerification: true });
164
- console.log(`[unbrowse] server started inline on http://${server.host}:${server.port}`);
165
- await supervisor.start();
166
- return;
167
- }
168
-
169
- // Spawn with supervisor retry
170
- for (let attempt = 0; attempt <= MAX_RESTART_ATTEMPTS; attempt++) {
171
- spawnServer(baseUrl, metaUrl, pidFile, attempt);
172
-
173
- if (await waitForHealthy(baseUrl, 30_000)) {
174
- await supervisor.start();
175
- return;
176
- }
177
-
178
- // Failed to start — clear and retry with backoff
179
- const state = readPidState(pidFile);
180
- if (state?.pid) {
181
- try { process.kill(state.pid, "SIGTERM"); } catch { /* ignore */ }
182
- }
183
- clearStalePidFile(pidFile);
184
-
185
- if (attempt < MAX_RESTART_ATTEMPTS) {
186
- const backoff = RESTART_BACKOFF_MS * (attempt + 1);
187
- await new Promise((r) => setTimeout(r, backoff));
188
- }
189
- }
190
-
191
- const logFile = getServerAutostartLogFile();
192
- throw new Error(`Server failed to start after ${MAX_RESTART_ATTEMPTS + 1} attempts. Check ${logFile}`);
193
- }
194
-
195
- /**
196
- * Check if the running server version matches the installed CLI version.
197
- * Returns null if no server is running, or { running, installed, needs_restart }.
198
- */
199
- export function checkServerVersion(baseUrl: string, metaUrl: string): { running: string; installed: string; needs_restart: boolean } | null {
200
- const pidFile = getServerPidFile(baseUrl);
201
- const state = readPidState(pidFile);
202
- if (!state) return null;
203
- const installed = getVersion(metaUrl);
204
- return {
205
- running: state.version ?? "unknown",
206
- installed,
207
- needs_restart: state.version !== installed,
208
- };
209
- }
210
-
211
- /**
212
- * Stop the running server gracefully.
213
- * Returns true if a server was stopped, false if none was running.
214
- */
215
- export function stopServer(baseUrl: string): boolean {
216
- const pidFile = getServerPidFile(baseUrl);
217
- const state = readPidState(pidFile);
218
- if (!state?.pid) return false;
219
- try {
220
- process.kill(state.pid, "SIGTERM");
221
- clearStalePidFile(pidFile);
222
- // Synchronously mark supervisor as stopped (fire-and-forget the async stop)
223
- if (supervisor.isRunning()) void supervisor.stop();
224
- return true;
225
- } catch {
226
- clearStalePidFile(pidFile);
227
- return false;
228
- }
229
- }
102
+ }, null, 2));
230
103
 
231
- /**
232
- * Restart the local server (stop + start).
233
- * Used after CLI upgrades to pick up new code.
234
- */
235
- export async function restartServer(baseUrl: string, metaUrl: string): Promise<void> {
236
- stopServer(baseUrl);
237
- await new Promise((r) => setTimeout(r, 1_000));
238
- await ensureLocalServer(baseUrl, false, metaUrl);
104
+ if (await waitForHealthy(baseUrl, 30_000)) return;
105
+ throw new Error(`Server failed to start. Check ${logFile}`);
239
106
  }
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, realpathSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { createRequire } from "node:module";
5
- import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { fileURLToPath } from "node:url";
6
6
 
7
7
  export function getModuleDir(metaUrl: string): string {
8
8
  return path.dirname(fileURLToPath(metaUrl));
@@ -10,13 +10,9 @@ export function getModuleDir(metaUrl: string): string {
10
10
 
11
11
  export function getPackageRoot(metaUrl: string): string {
12
12
  if (process.env.UNBROWSE_PACKAGE_ROOT) return process.env.UNBROWSE_PACKAGE_ROOT;
13
- let dir = getModuleDir(metaUrl);
14
- const root = path.parse(dir).root;
15
- while (dir !== root) {
16
- if (existsSync(path.join(dir, "package.json"))) return dir;
17
- dir = path.dirname(dir);
18
- }
19
- return getModuleDir(metaUrl);
13
+ const dir = getModuleDir(metaUrl);
14
+ const base = path.basename(dir);
15
+ return base === "src" || base === "dist" ? path.dirname(dir) : dir;
20
16
  }
21
17
 
22
18
  export function resolveSiblingEntrypoint(metaUrl: string, basename: string): string {
@@ -32,7 +28,7 @@ export function runtimeArgsForEntrypoint(metaUrl: string, entrypoint: string): s
32
28
  const req = createRequire(metaUrl);
33
29
  const tsxPkg = req.resolve("tsx/package.json");
34
30
  const tsxLoader = path.join(path.dirname(tsxPkg), "dist", "loader.mjs");
35
- if (existsSync(tsxLoader)) return ["--import", pathToFileURL(tsxLoader).href, entrypoint];
31
+ if (existsSync(tsxLoader)) return ["--import", tsxLoader, entrypoint];
36
32
  } catch {
37
33
  // fall through to bare specifier
38
34
  }
@@ -4,9 +4,6 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { ensureDir } from "./paths.js";
6
6
  import { findKuriBinary, getKuriSourceCandidates } from "../kuri/client.js";
7
- import { detectHostEnvironment, type HostEnvironment } from "./browser-host.js";
8
- import { log } from "../logger.js";
9
- import { checkWalletConfigured, type WalletCheckResult } from "../payments/wallet.js";
10
7
 
11
8
  export type SetupScope = "auto" | "global" | "project" | "off";
12
9
 
@@ -16,7 +13,6 @@ export type SetupReport = {
16
13
  release: string;
17
14
  arch: string;
18
15
  };
19
- host_environment: HostEnvironment;
20
16
  package_managers: {
21
17
  npm: boolean;
22
18
  npx: boolean;
@@ -32,9 +28,7 @@ export type SetupReport = {
32
28
  detected: boolean;
33
29
  action: "installed" | "updated" | "skipped" | "not-detected";
34
30
  scope: "global" | "project" | "off";
35
- };
36
- wallet: WalletCheckResult & {
37
- message: string;
31
+ command_file?: string;
38
32
  };
39
33
  };
40
34
 
@@ -185,52 +179,9 @@ export async function runSetup(options?: {
185
179
  installBrowser?: boolean;
186
180
  }): Promise<SetupReport> {
187
181
  const cwd = options?.cwd || process.cwd();
188
-
189
- const hostEnv = detectHostEnvironment();
190
- log("setup", `detected host environment: ${hostEnv}`);
191
-
192
182
  const browser = options?.installBrowser === false
193
183
  ? { installed: false, action: "skipped" as const }
194
184
  : await ensureBrowserEngineInstalled();
195
- const walletCheck = checkWalletConfigured();
196
- const skipWalletSetup = process.env.UNBROWSE_SKIP_WALLET_SETUP === "1";
197
- const lobsterInstalled = hasBinary("lobstercash") ||
198
- existsSync(path.join(os.homedir(), ".agents", "skills", "lobstercash", "SKILL.md"));
199
-
200
- // Auto-setup lobster.cash wallet if skill is installed but wallet not configured
201
- if (!skipWalletSetup && !walletCheck.configured && lobsterInstalled) {
202
- console.log("[unbrowse] lobster.cash skill detected but wallet not configured — running wallet setup...");
203
- try {
204
- execFileSync("npx", ["@crossmint/lobster-cli", "setup"], {
205
- stdio: "inherit",
206
- timeout: 60_000,
207
- });
208
- // Re-check after setup
209
- const recheck = checkWalletConfigured();
210
- if (recheck.configured) {
211
- console.log(`[unbrowse] wallet configured (${recheck.provider})`);
212
- }
213
- } catch {
214
- console.warn("[unbrowse] lobster.cash wallet setup failed or was skipped — continuing without wallet");
215
- }
216
- }
217
-
218
- // Re-check wallet state after potential setup
219
- const finalWalletCheck = checkWalletConfigured();
220
- const wallet = {
221
- ...finalWalletCheck,
222
- lobster_installed: lobsterInstalled,
223
- message: finalWalletCheck.configured
224
- ? `Wallet configured (${finalWalletCheck.provider}). This address is the contributor truth: it is synced onto your agent profile, used for contributor payouts when your routes earn, and used for paid-route spending.`
225
- : lobsterInstalled
226
- ? "lobster.cash installed but wallet not paired. Pair it now so this wallet address becomes your contributor payout target and your paid-route spending wallet. Run: lobstercash setup"
227
- : "No wallet configured. Install/pair a wallet so your contributor payouts have a destination address and premium-route spending can clear automatically. Without it you stay in free indexing mode only.",
228
- install_hint: finalWalletCheck.configured
229
- ? undefined
230
- : lobsterInstalled
231
- ? "lobstercash setup"
232
- : "npx skills add https://github.com/Crossmint/lobstercash-cli-skills --global --yes",
233
- };
234
185
 
235
186
  return {
236
187
  os: {
@@ -238,10 +189,8 @@ export async function runSetup(options?: {
238
189
  release: os.release(),
239
190
  arch: process.arch,
240
191
  },
241
- host_environment: hostEnv,
242
192
  package_managers: detectPackageManagers(),
243
193
  browser_engine: browser,
244
194
  opencode: writeOpenCodeCommand(options?.opencode ?? "auto", cwd),
245
- wallet,
246
195
  };
247
196
  }