@instafy/cli 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1424,21 +1424,21 @@ var require_redaction = __commonJS({
1424
1424
  return TEXT_MASK;
1425
1425
  });
1426
1426
  }
1427
- text2 = text2.replace(digitCodePattern, (match, offset, whole) => {
1428
- const before = whole.slice(Math.max(0, offset - CODE_NEARBY_CHARACTERS), offset);
1427
+ text2 = text2.replace(digitCodePattern, (match, offset, whole2) => {
1428
+ const before = whole2.slice(Math.max(0, offset - CODE_NEARBY_CHARACTERS), offset);
1429
1429
  if (!codeWordPattern.test(before)) return match;
1430
1430
  masked += 1;
1431
1431
  matches += 1;
1432
1432
  return TEXT_MASK;
1433
1433
  });
1434
1434
  opaqueCandidatePattern.lastIndex = 0;
1435
- text2 = text2.replace(opaqueCandidatePattern, (match, offset, whole) => {
1435
+ text2 = text2.replace(opaqueCandidatePattern, (match, offset, whole2) => {
1436
1436
  const hexLike = uuidPattern.test(match) || longHexPattern.test(match);
1437
1437
  if (!hexLike && !looksRandom3(match)) return match;
1438
1438
  if (!strict && !credentialContext) {
1439
- if (hexLike && identifierLabelPattern.test(whole.slice(Math.max(0, offset - 16), offset))) return match;
1439
+ if (hexLike && identifierLabelPattern.test(whole2.slice(Math.max(0, offset - 16), offset))) return match;
1440
1440
  const window = hexLike ? HEX_NEARBY_CHARACTERS : NEARBY_CHARACTERS;
1441
- const nearby = whole.slice(Math.max(0, offset - window), offset) + " " + whole.slice(offset + match.length, offset + match.length + window);
1441
+ const nearby = whole2.slice(Math.max(0, offset - window), offset) + " " + whole2.slice(offset + match.length, offset + match.length + window);
1442
1442
  if (!credentialProximityPattern.test(nearby)) return match;
1443
1443
  }
1444
1444
  count(match);
@@ -1461,8 +1461,8 @@ var require_redaction = __commonJS({
1461
1461
  return value.length >= 16 && (looksRandom3(value, 16) || uuidPattern.test(value) || /^[0-9a-f]{16,}$/i.test(value));
1462
1462
  }
1463
1463
  function redactPairs(value, onMask) {
1464
- return value.replace(/([^&#?=/]+)=([^&#]*)/g, (whole, key, rawValue) => {
1465
- if (!sensitiveUrlValue(key, rawValue)) return whole;
1464
+ return value.replace(/([^&#?=/]+)=([^&#]*)/g, (whole2, key, rawValue) => {
1465
+ if (!sensitiveUrlValue(key, rawValue)) return whole2;
1466
1466
  onMask(decodeComponent(rawValue));
1467
1467
  return `${key}=${URL_MASK}`;
1468
1468
  });
@@ -1807,8 +1807,8 @@ var require_linkOnly = __commonJS({
1807
1807
  if (url.username || url.password || url.port || url.hash) return { error: "has user info, a port or a fragment" };
1808
1808
  if (url.hostname.includes(MARK) || !hostOnSite(hostOfUrl2(url), patterns)) return { error: "is not on the site's own hosts" };
1809
1809
  const placeholderIn = (text2) => {
1810
- const whole = new RegExp(`^${MARK}(\\d+)x$`).exec(text2);
1811
- if (whole) return { name: names[Number(whole[1])] };
1810
+ const whole2 = new RegExp(`^${MARK}(\\d+)x$`).exec(text2);
1811
+ if (whole2) return { name: names[Number(whole2[1])] };
1812
1812
  return text2.includes(MARK) ? void 0 : null;
1813
1813
  };
1814
1814
  const segments = [];
@@ -1965,8 +1965,8 @@ var require_linkOnly = __commonJS({
1965
1965
  for (const run of decoded(text2).split(HOST_BREAK)) {
1966
1966
  if (names.size > MAX_EMBEDDED_NAMES) break;
1967
1967
  if (!run) continue;
1968
- const whole = embeddedName(run);
1969
- if (whole) names.add(whole);
1968
+ const whole2 = embeddedName(run);
1969
+ if (whole2) names.add(whole2);
1970
1970
  for (const piece of run.split(NOT_NAME_CHARACTER)) {
1971
1971
  const name = piece && piece !== run ? embeddedName(piece) : null;
1972
1972
  if (name) names.add(name);
@@ -6795,7 +6795,9 @@ async function tailTunnelLogs(options) {
6795
6795
 
6796
6796
  // src/preview.ts
6797
6797
  import { spawn as spawn6 } from "node:child_process";
6798
+ import { createHash as createHash2 } from "node:crypto";
6798
6799
  import fs12 from "node:fs";
6800
+ import net5 from "node:net";
6799
6801
  import os7 from "node:os";
6800
6802
  import path13 from "node:path";
6801
6803
  import kleur8 from "kleur";
@@ -7110,6 +7112,7 @@ var MAX_HEADER_BYTES = 128 * 1024;
7110
7112
 
7111
7113
  // src/web-read.ts
7112
7114
  var redaction = __toESM(require_redaction(), 1);
7115
+ import { randomUUID as randomUUID2 } from "node:crypto";
7113
7116
  import dns2 from "node:dns";
7114
7117
  import http3 from "node:http";
7115
7118
  import https from "node:https";
@@ -8110,12 +8113,109 @@ function loadWebFixtures(folder) {
8110
8113
  };
8111
8114
  }
8112
8115
 
8116
+ // src/web-budget.ts
8117
+ var ResearchBudgetError = class extends Error {
8118
+ };
8119
+ function invalidResponse() {
8120
+ throw new ResearchBudgetError("The controller returned an invalid research allowance. No further pages were loaded.");
8121
+ }
8122
+ function object(value) {
8123
+ return value !== null && typeof value === "object" && !Array.isArray(value);
8124
+ }
8125
+ function whole(value, min, max) {
8126
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max;
8127
+ }
8128
+ function readBudget(value) {
8129
+ if (!object(value) || value.scope !== "this task" || !whole(value.loadsPerTurn, 1, 200) || !whole(value.loadsPerHost, 1, value.loadsPerTurn) || !whole(value.loadsUsed, 0, value.loadsPerTurn) || value.loadsLeft !== value.loadsPerTurn - value.loadsUsed || value.hostSpacingSeconds !== 2 || !object(value.hosts) || Object.keys(value.hosts).length > 1200) invalidResponse();
8130
+ for (const entry of Object.values(value.hosts)) {
8131
+ if (!object(entry) || !whole(entry.loads, 0, value.loadsPerHost) || entry.lastAt !== void 0 && (typeof entry.lastAt !== "number" || !Number.isFinite(entry.lastAt))) invalidResponse();
8132
+ }
8133
+ return value;
8134
+ }
8135
+ function sharedResearchBudget(env, fetcher = globalThis.fetch) {
8136
+ const enabled = env.INSTAFY_RESEARCH_SHARED?.trim();
8137
+ const rawUrl = env.INSTAFY_RESEARCH_CONTROLLER_URL?.trim();
8138
+ const token = env.INSTAFY_RESEARCH_ACCESS_TOKEN?.trim();
8139
+ if (enabled === void 0 && rawUrl === void 0 && token === void 0) return null;
8140
+ if (enabled !== "1" || !rawUrl || !token || /[\r\n]/.test(token)) {
8141
+ throw new ResearchBudgetError("This task requires a shared research allowance, but its runtime binding is incomplete. No pages were loaded.");
8142
+ }
8143
+ let url;
8144
+ try {
8145
+ url = new URL(rawUrl);
8146
+ if (!/^https?:$/.test(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== "/") throw new Error();
8147
+ } catch {
8148
+ throw new ResearchBudgetError("The task's research controller binding is invalid. No pages were loaded.");
8149
+ }
8150
+ return new SharedResearchBudget(new URL("/agent/research-budget", url.origin), token, fetcher);
8151
+ }
8152
+ var SharedResearchBudget = class {
8153
+ constructor(endpoint, token, fetcher) {
8154
+ this.endpoint = endpoint;
8155
+ this.token = token;
8156
+ this.fetcher = fetcher;
8157
+ this.current = null;
8158
+ }
8159
+ async request(body, remainingMs = 5e3) {
8160
+ const deadline = Date.now() + Math.max(1, Math.min(5e3, body?.remainingMs ?? remainingMs));
8161
+ for (let attempt2 = 0; attempt2 < 2; attempt2 += 1) {
8162
+ const abort = new AbortController();
8163
+ const timer = setTimeout(() => abort.abort(), Math.max(1, deadline - Date.now()));
8164
+ try {
8165
+ let response;
8166
+ try {
8167
+ response = await this.fetcher(this.endpoint.href, {
8168
+ method: body ? "POST" : "GET",
8169
+ headers: {
8170
+ Authorization: `Bearer ${this.token}`,
8171
+ Accept: "application/json",
8172
+ ...body ? { "Content-Type": "application/json" } : {}
8173
+ },
8174
+ ...body ? { body: JSON.stringify(body) } : {},
8175
+ redirect: "error",
8176
+ signal: abort.signal
8177
+ });
8178
+ } catch {
8179
+ if (attempt2 === 0 && !abort.signal.aborted && Date.now() < deadline) continue;
8180
+ throw new ResearchBudgetError("The task's shared research allowance is unavailable. No further pages were loaded; do not switch to a local allowance.");
8181
+ }
8182
+ if (!response.ok) {
8183
+ throw new ResearchBudgetError(`The controller refused the shared research allowance (HTTP ${response.status}). No further pages were loaded.`);
8184
+ }
8185
+ let value;
8186
+ try {
8187
+ value = await response.json();
8188
+ } catch {
8189
+ invalidResponse();
8190
+ }
8191
+ if (!object(value)) invalidResponse();
8192
+ return value;
8193
+ } finally {
8194
+ clearTimeout(timer);
8195
+ }
8196
+ }
8197
+ throw new ResearchBudgetError("The task's shared research allowance is unavailable.");
8198
+ }
8199
+ async status(remainingMs = 5e3) {
8200
+ const response = await this.request(void 0, remainingMs);
8201
+ this.current = readBudget(response.budget);
8202
+ return this.current;
8203
+ }
8204
+ async reserve(loadId, requestId, host, remainingMs) {
8205
+ const response = await this.request({ loadId, requestId, host, remainingMs: Math.max(0, Math.min(6e5, Math.trunc(remainingMs))) });
8206
+ const budget = readBudget(response.budget);
8207
+ if (typeof response.allowed !== "boolean" || !whole(response.waitMs, 0, 6e5) || (response.allowed ? response.code !== void 0 : !["turn_limit", "host_limit", "time_limit"].includes(String(response.code)))) invalidResponse();
8208
+ this.current = budget;
8209
+ return { allowed: response.allowed, ...response.code ? { code: response.code } : {}, waitMs: response.waitMs, budget };
8210
+ }
8211
+ };
8212
+
8113
8213
  // src/web-read.ts
8114
8214
  var ROBOTS_AGENTS = ["*", "instafy-agent", ...AI_AGENTS];
8115
8215
  var LIMITS = Object.freeze({
8116
8216
  urlsPerCall: 6,
8117
- loadsPerTurn: 12,
8118
- loadsPerHost: 4,
8217
+ loadsPerTurn: 100,
8218
+ loadsPerHost: 25,
8119
8219
  hostSpacingMs: 2e3,
8120
8220
  callMs: 6e4,
8121
8221
  pageTimeoutMs: 2e4,
@@ -8377,9 +8477,31 @@ var TELL = {
8377
8477
  http_error: "The site answered with an error. Use another source rather than retrying.",
8378
8478
  unreachable: "The site did not answer. Use another source rather than retrying.",
8379
8479
  timeout: "The site did not answer in time. Use another source rather than retrying.",
8480
+ budget_unavailable: "Research stopped because its shared allowance could not be checked. Keep the evidence already collected; further fresh reads require controller approval.",
8380
8481
  needs_browser: "This page builds its text with JavaScript, so a plain read sees almost nothing. Use another source, or ask for a visible browser turn for this page.",
8381
8482
  no_reuse: `This site's terms bar reusing its content. Summarize it in your own words, cite "seen <date> on <host>" and link back; never copy its reviews, photos or price lists, and keep its content out of anything published or shared by public link (links only there).`
8382
8483
  };
8484
+ function wwwSibling(url) {
8485
+ const host = url.hostname.toLowerCase().replace(/\.+$/, "");
8486
+ if (!host || net3.isIP(host.replace(/^\[|\]$/g, ""))) return null;
8487
+ let other;
8488
+ if (host.startsWith("www.")) other = host.slice(4);
8489
+ else {
8490
+ const labels = host.split(".");
8491
+ const bare = labels.length === 2 || labels.length === 3 && labels[2].length === 2 && labels[1].length <= 3;
8492
+ if (!bare) return null;
8493
+ other = `www.${host}`;
8494
+ }
8495
+ if (!other.includes(".")) return null;
8496
+ const sibling = new URL(url.href);
8497
+ sibling.hostname = other;
8498
+ sibling.hash = "";
8499
+ return sibling;
8500
+ }
8501
+ function unanswered(code, page) {
8502
+ return (code === "unreachable" || code === "timeout") && !page.redirects?.length;
8503
+ }
8504
+ var unansweredKey = (url) => sha256(`unanswered:${url.hostname.toLowerCase().replace(/\.+$/, "")}`);
8383
8505
  var HOST_WIDE_STOPS = /* @__PURE__ */ new Set(["rate_limited", "captcha"]);
8384
8506
  function stripHash(url) {
8385
8507
  const copy = new URL(url.href);
@@ -8478,15 +8600,16 @@ async function readOne(rawUrl, options, ctx) {
8478
8600
  status: "skipped",
8479
8601
  code,
8480
8602
  message,
8481
- tell: TELL[code]
8603
+ tell: ctx.sharedBudget?.current && code !== "time_limit" ? code === "turn_limit" ? `This task's shared allowance of ${ctx.sharedBudget.current.loadsPerTurn} page loads is used. Work with the evidence already collected, or ask the person.` : `This task has used its ${ctx.sharedBudget.current.loadsPerHost} page loads for this site. Use another source.` : TELL[code]
8482
8604
  });
8605
+ let sibling = wwwSibling(requestedUrl);
8483
8606
  const fail = (code, message, extra2 = {}) => ({
8484
8607
  ...page,
8485
8608
  ...extra2,
8486
8609
  status: "failed",
8487
8610
  code,
8488
8611
  message,
8489
- tell: TELL[code]
8612
+ tell: sibling && unanswered(code, extra2) ? `The site did not answer at ${requestedUrl.hostname}. Some sites answer only with or without "www.": read ${redaction.redactUrl(sibling.href).text} once instead, and if that fails too, use another source.` : TELL[code]
8490
8613
  });
8491
8614
  const firstHost = requestedUrl.hostname.toLowerCase();
8492
8615
  const firstSite = siteKey(firstHost);
@@ -8494,8 +8617,10 @@ async function readOne(rawUrl, options, ctx) {
8494
8617
  const earlier = await ctx.turn.update((state) => ({
8495
8618
  stop: state.stops[firstKeys.exact] ?? state.stops[firstKeys.path] ?? state.hostStops[firstSite] ?? null,
8496
8619
  loads: state.loads,
8497
- hostLoads: state.hosts[firstSite]?.loads ?? 0
8620
+ hostLoads: state.hosts[firstSite]?.loads ?? 0,
8621
+ siblingUnanswered: sibling ? Boolean(state.unanswered?.[unansweredKey(sibling)]) : false
8498
8622
  }));
8623
+ if (earlier.siblingUnanswered) sibling = null;
8499
8624
  const remembered = earlier.stop ?? ctx.cache.get("stops", firstKeys.exact, LIMITS.stopTtlMs, ctx.now()) ?? ctx.cache.get("stops", firstKeys.path, LIMITS.stopTtlMs, ctx.now());
8500
8625
  if (remembered) {
8501
8626
  return { ...page, status: "stopped", code: remembered.code, message: remembered.message, tell: TELL[remembered.code], previouslyStopped: true };
@@ -8507,15 +8632,24 @@ async function readOne(rawUrl, options, ctx) {
8507
8632
  return present(page, cached, true, options, ctx.guard);
8508
8633
  }
8509
8634
  }
8510
- if (earlier.loads >= LIMITS.loadsPerTurn) {
8511
- return skip("turn_limit", `this turn already loaded ${earlier.loads} pages (limit ${LIMITS.loadsPerTurn})`);
8512
- }
8513
- if (earlier.hostLoads >= LIMITS.loadsPerHost) {
8514
- return skip("host_limit", `this turn already read ${firstSite} ${earlier.hostLoads} times (limit ${LIMITS.loadsPerHost})`);
8635
+ if (ctx.sharedBudget) {
8636
+ if (ctx.deadline - ctx.now() < 1e3) return skip("time_limit", "this call reached its time limit before this URL");
8637
+ const budget = await ctx.sharedBudget.status(ctx.deadline - ctx.now());
8638
+ if (budget.loadsLeft === 0) return skip("turn_limit", `this task already loaded ${budget.loadsUsed} pages (limit ${budget.loadsPerTurn})`);
8639
+ const used = budget.hosts[firstSite]?.loads ?? 0;
8640
+ if (used >= budget.loadsPerHost) return skip("host_limit", `this task already read ${firstSite} ${used} times (limit ${budget.loadsPerHost})`);
8641
+ } else {
8642
+ if (earlier.loads >= LIMITS.loadsPerTurn) {
8643
+ return skip("turn_limit", `this turn already loaded ${earlier.loads} pages (limit ${LIMITS.loadsPerTurn})`);
8644
+ }
8645
+ if (earlier.hostLoads >= LIMITS.loadsPerHost) {
8646
+ return skip("host_limit", `this turn already read ${firstSite} ${earlier.hostLoads} times (limit ${LIMITS.loadsPerHost})`);
8647
+ }
8515
8648
  }
8516
8649
  if (ctx.deadline - ctx.now() < 1e3) return skip("time_limit", "this call reached its time limit before this URL");
8517
8650
  const redirects = [];
8518
8651
  const chainSites = /* @__PURE__ */ new Set();
8652
+ const loadId = randomUUID2();
8519
8653
  let current = new URL(requested);
8520
8654
  let response = null;
8521
8655
  for (let hop = 0; ; hop += 1) {
@@ -8540,7 +8674,21 @@ async function readOne(rawUrl, options, ctx) {
8540
8674
  const robots = await robotsVerdict(current, ctx);
8541
8675
  if ("failed" in robots) return fail(robots.failed, via + robots.message, hopExtra);
8542
8676
  if (robots.refused) return stop("robots", via + robots.refused, host2, hopExtra);
8543
- if (!chainSites.has(site)) {
8677
+ if (ctx.sharedBudget) {
8678
+ const remainingMs = ctx.deadline - ctx.now();
8679
+ if (remainingMs < 1e3) return skip("time_limit", `${via}this call reached its time limit before reserving a load`, hopExtra);
8680
+ const reservation = await ctx.sharedBudget.reserve(loadId, randomUUID2(), site, remainingMs - 1e3);
8681
+ if (!reservation.allowed) {
8682
+ const code = reservation.code;
8683
+ const budget = reservation.budget;
8684
+ const message = code === "turn_limit" ? `this task already loaded ${budget.loadsUsed} pages (limit ${budget.loadsPerTurn})` : code === "host_limit" ? `this task already read ${site} ${budget.hosts[site]?.loads ?? 0} times (limit ${budget.loadsPerHost})` : `no time left in this call for the next paced read of ${host2}`;
8685
+ return skip(code, via + message, hopExtra);
8686
+ }
8687
+ if (reservation.waitMs + 1e3 > ctx.deadline - ctx.now()) {
8688
+ return skip("time_limit", `${via}this call reached its time limit while reserving the next paced read`, hopExtra);
8689
+ }
8690
+ if (reservation.waitMs > 0) await ctx.sleep(reservation.waitMs);
8691
+ } else if (!chainSites.has(site)) {
8544
8692
  const now = ctx.now();
8545
8693
  const reservation = await ctx.turn.update((state) => {
8546
8694
  if (hop === 0 && state.loads >= LIMITS.loadsPerTurn) {
@@ -8751,6 +8899,7 @@ async function webRead(options, deps = {}) {
8751
8899
  now,
8752
8900
  sleep: deps.sleep ?? realSleep,
8753
8901
  turn,
8902
+ sharedBudget: sharedResearchBudget(env, deps.budgetFetch),
8754
8903
  cache: new Cache(cacheDir === null ? null : apart(cacheDir)),
8755
8904
  guard,
8756
8905
  viewer,
@@ -8758,23 +8907,38 @@ async function webRead(options, deps = {}) {
8758
8907
  robotsMemo: /* @__PURE__ */ new Map()
8759
8908
  };
8760
8909
  const pages = [];
8761
- for (const url of urls) pages.push(await readOne(url, { maxChars, links, fresh: Boolean(options.fresh), terms }, ctx));
8762
- const state = await turn.update((s) => ({ loads: s.loads, hosts: Object.fromEntries(Object.entries(s.hosts).map(([h, r]) => [h, r.loads])) }));
8910
+ let interruptedBudget = null;
8911
+ for (const [index, url] of urls.entries()) {
8912
+ let page;
8913
+ try {
8914
+ page = await readOne(url, { maxChars, links, fresh: Boolean(options.fresh), terms }, ctx);
8915
+ } catch (error) {
8916
+ if (!(error instanceof ResearchBudgetError) || !ctx.sharedBudget?.current || !pages.length) throw error;
8917
+ interruptedBudget = ctx.sharedBudget.current;
8918
+ pages.push(...urls.slice(index).map((remaining) => ({
8919
+ ...basePage(remaining, new Date(now()).toISOString(), viewer),
8920
+ code: "budget_unavailable",
8921
+ message: error.message,
8922
+ tell: TELL.budget_unavailable
8923
+ })));
8924
+ break;
8925
+ }
8926
+ if (page.status === "failed" && unanswered(page.code, page)) {
8927
+ const key = unansweredKey(new URL(url));
8928
+ await turn.update((state) => {
8929
+ (state.unanswered ??= {})[key] = now();
8930
+ });
8931
+ }
8932
+ pages.push(page);
8933
+ }
8934
+ const budget = interruptedBudget ? lastKnownBudget(interruptedBudget, warnings) : ctx.sharedBudget ? await finalSharedBudget(ctx.sharedBudget, deadline - now(), warnings) : await localBudget(turn, scope.label);
8763
8935
  return {
8764
8936
  ok: true,
8765
8937
  viewer,
8766
8938
  userAgent: USER_AGENT,
8767
8939
  notice: NOTICE,
8768
8940
  pages,
8769
- budget: {
8770
- scope: scope.label,
8771
- loadsUsed: state.loads,
8772
- loadsLeft: Math.max(0, LIMITS.loadsPerTurn - state.loads),
8773
- loadsPerTurn: LIMITS.loadsPerTurn,
8774
- loadsPerHost: LIMITS.loadsPerHost,
8775
- hostSpacingSeconds: LIMITS.hostSpacingMs / 1e3,
8776
- hosts: state.hosts
8777
- },
8941
+ budget,
8778
8942
  guards: {
8779
8943
  linkOnly: {
8780
8944
  sites: guard.list.sites.length,
@@ -8788,6 +8952,63 @@ async function webRead(options, deps = {}) {
8788
8952
  warnings
8789
8953
  };
8790
8954
  }
8955
+ function reportBudget(budget) {
8956
+ return { ...budget, hosts: Object.fromEntries(Object.entries(budget.hosts).map(([host, value]) => [host, value.loads])) };
8957
+ }
8958
+ function lastKnownBudget(budget, warnings) {
8959
+ warnings.push("The shared research allowance could not be refreshed. Budget counts are the last validated controller snapshot and may have changed; another fresh read still requires controller approval.");
8960
+ return { ...reportBudget(budget), lastKnown: true };
8961
+ }
8962
+ async function finalSharedBudget(shared, remainingMs, warnings) {
8963
+ try {
8964
+ if (remainingMs <= 0) throw new ResearchBudgetError("The call deadline elapsed before the shared research allowance could be refreshed.");
8965
+ return reportBudget(await shared.status(remainingMs));
8966
+ } catch (error) {
8967
+ if (!(error instanceof ResearchBudgetError) || !shared.current) throw error;
8968
+ return lastKnownBudget(shared.current, warnings);
8969
+ }
8970
+ }
8971
+ async function localBudget(turn, scope) {
8972
+ return turn.update((state) => ({
8973
+ scope,
8974
+ loadsUsed: state.loads,
8975
+ loadsLeft: Math.max(0, LIMITS.loadsPerTurn - state.loads),
8976
+ loadsPerTurn: LIMITS.loadsPerTurn,
8977
+ loadsPerHost: LIMITS.loadsPerHost,
8978
+ hostSpacingSeconds: LIMITS.hostSpacingMs / 1e3,
8979
+ hosts: Object.fromEntries(Object.entries(state.hosts).map(([host, value]) => [host, value.loads]))
8980
+ }));
8981
+ }
8982
+ async function webBudget(deps = {}) {
8983
+ const env = deps.env ?? process.env;
8984
+ const cwd = deps.cwd ?? process.cwd();
8985
+ const shared = sharedResearchBudget(env, deps.budgetFetch);
8986
+ if (shared) return { budget: reportBudget(await shared.status()) };
8987
+ const fixtureFolder = webFixturesFolder(env, cwd);
8988
+ const fixtures = fixtureFolder ? loadWebFixtures(fixtureFolder) : null;
8989
+ const dirs = webDirs("web-read", env, cwd);
8990
+ const stateDir = deps.stateDir ?? dirs.stateDir;
8991
+ const scope = turnScope(env);
8992
+ const turn = new TurnStore(
8993
+ fixtures ? path11.join(stateDir, "fixtures", sha256(fixtures.folder).slice(0, 16)) : stateDir,
8994
+ scope,
8995
+ deps.now ?? Date.now
8996
+ );
8997
+ return { budget: await localBudget(turn, scope.label) };
8998
+ }
8999
+ async function runWebBudgetCli(opts, io = { out: console.log, err: console.error }, deps = {}) {
9000
+ try {
9001
+ const report = await webBudget(deps);
9002
+ const b = report.budget;
9003
+ io.out(opts.json ? JSON.stringify(report, null, 1) : `${b.loadsLeft} page loads left in ${b.scope} (${b.loadsUsed} of ${b.loadsPerTurn} used; ${b.loadsPerHost} per site, ${b.hostSpacingSeconds} s apart).`);
9004
+ return WEB_READ_EXIT_OK;
9005
+ } catch (error) {
9006
+ const message = error instanceof Error ? error.message : String(error);
9007
+ if (opts.json) io.out(JSON.stringify({ ok: false, exitCode: WEB_READ_EXIT_INTERNAL, error: message }));
9008
+ else io.err(message);
9009
+ return WEB_READ_EXIT_INTERNAL;
9010
+ }
9011
+ }
8791
9012
  function clamp(value, min, max) {
8792
9013
  return Math.min(max, Math.max(min, Math.trunc(value)));
8793
9014
  }
@@ -8815,13 +9036,13 @@ function formatWebRead(report) {
8815
9036
  }
8816
9037
  out.push("");
8817
9038
  }
8818
- out.push(`${report.budget.loadsUsed} of ${report.budget.loadsPerTurn} page loads used in ${report.budget.scope}.`);
9039
+ out.push(`${report.budget.lastKnown ? "Last known controller count: " : ""}${report.budget.loadsUsed} of ${report.budget.loadsPerTurn} page loads used in ${report.budget.scope}.`);
8819
9040
  for (const warning of report.warnings) out.push(`warning: ${warning}`);
8820
9041
  out.push(report.notice);
8821
9042
  return out.join("\n");
8822
9043
  }
8823
9044
  async function runWebReadCli(urls, opts, io = { out: console.log, err: console.error }, deps = {}) {
8824
- const whole = (raw, name, min, max) => {
9045
+ const whole2 = (raw, name, min, max) => {
8825
9046
  if (raw === void 0) return void 0;
8826
9047
  const n = Number(raw);
8827
9048
  if (!Number.isInteger(n) || n < min || n > max) throw new WebReadUsageError(`${name} must be a whole number from ${min} to ${max}.`);
@@ -8832,8 +9053,8 @@ async function runWebReadCli(urls, opts, io = { out: console.log, err: console.e
8832
9053
  {
8833
9054
  urls,
8834
9055
  find: opts.find,
8835
- maxChars: whole(opts.maxChars, "--max-chars", LIMITS.minChars, LIMITS.maxChars),
8836
- links: whole(opts.links, "--links", 0, LIMITS.maxLinks),
9056
+ maxChars: whole2(opts.maxChars, "--max-chars", LIMITS.minChars, LIMITS.maxChars),
9057
+ links: whole2(opts.links, "--links", 0, LIMITS.maxLinks),
8837
9058
  fresh: opts.fresh
8838
9059
  },
8839
9060
  deps
@@ -8843,7 +9064,7 @@ async function runWebReadCli(urls, opts, io = { out: console.log, err: console.e
8843
9064
  } catch (error) {
8844
9065
  const usage = error instanceof WebReadUsageError;
8845
9066
  const code = usage ? WEB_READ_EXIT_USAGE : WEB_READ_EXIT_INTERNAL;
8846
- const message = usage || error instanceof LinkOnlyListUnavailableError || error instanceof WebFixturesError ? error.message : `web read failed, nothing was read: ${error instanceof Error ? error.message : String(error)}`;
9067
+ const message = usage || error instanceof LinkOnlyListUnavailableError || error instanceof WebFixturesError || error instanceof ResearchBudgetError ? error.message : `web read failed: ${error instanceof Error ? error.message : String(error)}`;
8847
9068
  if (opts.json) io.out(JSON.stringify({ ok: false, exitCode: code, error: message }));
8848
9069
  else io.err(message);
8849
9070
  return code;
@@ -8854,10 +9075,13 @@ function registerWebCommand(program2) {
8854
9075
  "Read public web pages as untrusted data with their source (robots.txt, link-only sites and stop rules enforced), search for links to read (Octo's turns only), and give the person links to link-only sites (guided mode)"
8855
9076
  );
8856
9077
  web.command("read").description(
8857
- `Read 1-6 public pages over plain HTTP and print their text, links and structured data with provenance (exit 2: nothing read, 64: bad options). Per turn: ${LIMITS.loadsPerTurn} loads, ${LIMITS.loadsPerHost} per site, ${LIMITS.hostSpacingMs / 1e3} s apart. Stopped URLs (login, CAPTCHA, paywall, 401/403/429, download, secret screen, robots.txt, link-only, private address) are never read another way.`
9078
+ `Read 1-6 public pages over plain HTTP and print their text, links and structured data with provenance (exit 2: nothing read, 64: bad options). Uses the task's shared allowance; standalone: ${LIMITS.loadsPerTurn} loads, ${LIMITS.loadsPerHost} per site, ${LIMITS.hostSpacingMs / 1e3} s apart. Stopped URLs (login, CAPTCHA, paywall, 401/403/429, download, secret screen, robots.txt, link-only, private address) are never read another way.`
8858
9079
  ).argument("<url...>", "http(s) URLs to read").option("--find <terms>", 'Return only the passages that mention these words ("quoted phrases" kept whole)').option("--max-chars <n>", `Most characters of text per page (default ${LIMITS.defaultChars}, at most ${LIMITS.maxChars})`).option("--links <n>", `Most links per page (default ${LIMITS.defaultLinks}, at most ${LIMITS.maxLinks})`).option("--fresh", "Read again even if this workspace's cache has the page (never re-reads a stopped URL)").option("--json", "Output JSON").action(async (urls, opts) => {
8859
9080
  process.exitCode = await runWebReadCli(urls, opts);
8860
9081
  });
9082
+ web.command("budget").description("Show this task's remaining shared page allowance without spending it (or the standalone session allowance)").option("--json", "Output JSON").action(async (opts) => {
9083
+ process.exitCode = await runWebBudgetCli(opts);
9084
+ });
8861
9085
  return web;
8862
9086
  }
8863
9087
 
@@ -9281,6 +9505,18 @@ async function readPageDescription(frame) {
9281
9505
  const raw = await frame.evaluate(() => document.querySelector('meta[name="description" i]')?.getAttribute("content") ?? null).catch(() => null);
9282
9506
  return cleanDescription(raw);
9283
9507
  }
9508
+ var READY_MARK_ATTRIBUTE = "data-instafy-ready";
9509
+ var READY_MARK_WAIT_MS = 3e4;
9510
+ async function waitForReadyMark(frame, timeoutMs) {
9511
+ const mark = await frame.evaluate((name) => document.documentElement?.getAttribute(name) ?? null, READY_MARK_ATTRIBUTE).catch(() => null);
9512
+ if (mark === null) return "none";
9513
+ if (mark === "true") return "ready";
9514
+ if (timeoutMs <= 0) return "late";
9515
+ return frame.waitForFunction((name) => document.documentElement?.getAttribute(name) === "true", READY_MARK_ATTRIBUTE, {
9516
+ timeout: timeoutMs,
9517
+ polling: 100
9518
+ }).then(() => "ready").catch(() => "late");
9519
+ }
9284
9520
  async function pageThumbnail(page, context) {
9285
9521
  const cdp = await context.newCDPSession(page);
9286
9522
  try {
@@ -9486,10 +9722,19 @@ async function checkViewport(browser, target, vp, siteOrigin, outDir, timeoutMs,
9486
9722
  (ours(u) ? errors : warnings).push(`HTTP ${s}: ${shortUrl(u)}`);
9487
9723
  });
9488
9724
  await page.goto(target.pageUrl, { waitUntil: "load", timeout: timeoutMs });
9489
- await page.waitForLoadState("networkidle", { timeout: Math.min(8e3, timeoutMs) }).catch(() => {
9490
- warnings.push("the network was still busy after 8 s (screenshot taken anyway)");
9491
- });
9492
- const frame = target.framed ? page.frames().find((f) => f !== page.mainFrame() && isSiteUrl(String(f.url()), siteOrigin)) : page.mainFrame();
9725
+ const siteFrame = () => target.framed ? page.frames().find((f) => f !== page.mainFrame() && isSiteUrl(String(f.url()), siteOrigin)) : page.mainFrame();
9726
+ const readiness = await waitForReadyMark(
9727
+ siteFrame() ?? page.mainFrame(),
9728
+ Math.max(0, Math.min(READY_MARK_WAIT_MS, timeoutMs) - (Date.now() - t0))
9729
+ );
9730
+ if (readiness === "none") {
9731
+ await page.waitForLoadState("networkidle", { timeout: Math.min(8e3, timeoutMs) }).catch(() => {
9732
+ warnings.push("the network was still busy after 8 s (screenshot taken anyway)");
9733
+ });
9734
+ } else if (readiness === "late") {
9735
+ warnings.push(`the page still said it was drawing (${READY_MARK_ATTRIBUTE}) after ${Math.round((Date.now() - t0) / 1e3)} s; screenshot taken anyway`);
9736
+ }
9737
+ const frame = siteFrame();
9493
9738
  let framed = "ok";
9494
9739
  let snap = { text: 0, media: 0, elements: 0, title: "" };
9495
9740
  if (refused) framed = "refused";
@@ -9524,7 +9769,7 @@ async function checkViewport(browser, target, vp, siteOrigin, outDir, timeoutMs,
9524
9769
  let thumbnail;
9525
9770
  if (takeThumbnail) {
9526
9771
  const [firstUnstopped] = unstopped;
9527
- thumbnail = firstUnstopped ? { skipped: `a redirect led to ${firstUnstopped[0].slice(0, 200)} (${firstUnstopped[1]}), which a picture never shows` } : blank.blank ? { skipped: "the page looked blank" } : await pageThumbnail(page, context).then(async (shot) => "image" in shot ? { image: shot.image, description: await readPageDescription(page.mainFrame()) } : shot).catch((error) => ({ skipped: `the picture could not be taken: ${firstLine(error)}` }));
9772
+ thumbnail = firstUnstopped ? { skipped: `a redirect led to ${firstUnstopped[0].slice(0, 200)} (${firstUnstopped[1]}), which a picture never shows` } : blank.blank ? { skipped: "the page looked blank" } : readiness === "late" ? { skipped: "the page never said it was ready, so the card keeps its picture" } : await pageThumbnail(page, context).then(async (shot) => "image" in shot ? { image: shot.image, description: await readPageDescription(page.mainFrame()) } : shot).catch((error) => ({ skipped: `the picture could not be taken: ${firstLine(error)}` }));
9528
9773
  }
9529
9774
  return {
9530
9775
  name: vp.name,
@@ -9734,6 +9979,8 @@ function thumbnailBudgetMs(env = process.env) {
9734
9979
  }
9735
9980
  var NETWORK_IDLE_MS = 6e3;
9736
9981
  var SETTLE_MS = 1e3;
9982
+ var READY_SETTLE_MS = 300;
9983
+ var SHOT_RESERVE_MS = 3e3;
9737
9984
  var MAX_THUMBNAIL_FILE_BYTES = 512 * 1024;
9738
9985
  var MAX_HTML_BYTES = 512 * 1024;
9739
9986
  var MIN_SIDE = 64;
@@ -9890,7 +10137,8 @@ function errorLine(error) {
9890
10137
  async function captureSiteThumbnail(options) {
9891
10138
  const env = options.env ?? process.env;
9892
10139
  const budget = options.budgetMs ?? thumbnailBudgetMs(env);
9893
- const deadline = Date.now() + budget;
10140
+ const began = Date.now();
10141
+ let deadline = began + budget;
9894
10142
  const host = options.host === "::1" ? "[::1]" : "127.0.0.1";
9895
10143
  const siteOrigin = `http://${host}:${options.port}`;
9896
10144
  const left = () => Math.max(0, deadline - Date.now());
@@ -9952,9 +10200,18 @@ async function captureSiteThumbnail(options) {
9952
10200
  if (response && response.status() >= 400) {
9953
10201
  return { ok: false, status: "skipped", reason: `the page answered HTTP ${response.status()}` };
9954
10202
  }
9955
- await page.waitForLoadState("networkidle", { timeout: Math.max(1, Math.min(NETWORK_IDLE_MS, left())) }).catch(() => {
9956
- });
9957
- await page.waitForTimeout(Math.min(SETTLE_MS, left()));
10203
+ const mark = await waitForReadyMark(page.mainFrame(), 0);
10204
+ if (mark === "none") {
10205
+ await page.waitForLoadState("networkidle", { timeout: Math.max(1, Math.min(NETWORK_IDLE_MS, left())) }).catch(() => {
10206
+ });
10207
+ await page.waitForTimeout(Math.min(SETTLE_MS, left()));
10208
+ } else {
10209
+ deadline = Math.max(deadline, began + (options.readyWaitMs ?? READY_MARK_WAIT_MS));
10210
+ if (mark === "late" && await waitForReadyMark(page.mainFrame(), Math.max(0, left() - SHOT_RESERVE_MS)) !== "ready") {
10211
+ return { ok: false, status: "skipped", reason: `the page still said it was drawing after ${Math.round((Date.now() - began) / 1e3)} s` };
10212
+ }
10213
+ await page.waitForTimeout(Math.min(READY_SETTLE_MS, left()));
10214
+ }
9958
10215
  const description = await readPageDescription(page.mainFrame());
9959
10216
  const shot = await pageThumbnail(page, context);
9960
10217
  if ("skipped" in shot) return { ok: false, status: "skipped", reason: shot.skipped };
@@ -9969,7 +10226,16 @@ async function captureSiteThumbnail(options) {
9969
10226
  };
9970
10227
  })();
9971
10228
  const outOfTime = new Promise((resolve) => {
9972
- timer = setTimeout(() => resolve({ ok: false, status: "failed", reason: `the picture took longer than ${Math.round(budget / 1e3)} s` }), budget);
10229
+ const arm = () => {
10230
+ timer = setTimeout(() => {
10231
+ if (Date.now() < deadline) {
10232
+ arm();
10233
+ return;
10234
+ }
10235
+ resolve({ ok: false, status: "failed", reason: `the picture took longer than ${Math.round((deadline - began) / 1e3)} s` });
10236
+ }, Math.max(0, deadline - Date.now()));
10237
+ };
10238
+ arm();
9973
10239
  });
9974
10240
  try {
9975
10241
  return await Promise.race([work.catch((error) => ({ ok: false, status: "failed", reason: errorLine(error) })), outOfTime]);
@@ -9993,6 +10259,12 @@ var MAX_PATH_LENGTH2 = 512;
9993
10259
  var THUMBNAIL_UPLOAD_TIMEOUT_MS = 15e3;
9994
10260
  var STATE_LOCK_WAIT_MS = 12e4;
9995
10261
  var STATE_LOCK_STALE_MS = 10 * 6e4;
10262
+ var PORT_PLACEHOLDER = "{port}";
10263
+ var PORT_TAKEN_HINT = "Run it with --start and without --port, so this site gets a free port of its own, or pass --replace to show this site on that port instead.";
10264
+ var PICKED_PORT_TAKEN_HINT = "Pass --replace if this site replaces that one (this same site under a new title, for example); otherwise give this site a title of its own.";
10265
+ var AUTO_PORT_FIRST = 2e4;
10266
+ var AUTO_PORT_COUNT = 1e4;
10267
+ var AUTO_PORT_TRIES = 64;
9996
10268
  function instafyDir() {
9997
10269
  return path13.join(os7.homedir(), ".instafy");
9998
10270
  }
@@ -10216,6 +10488,12 @@ async function controllerRequest(params) {
10216
10488
  PREVIEW_EXIT_PORT_NOT_ALLOWED
10217
10489
  );
10218
10490
  }
10491
+ if (response.status === 409 && code === "port_in_use") {
10492
+ throw new PreviewCommandError(
10493
+ `${extractControllerErrorMessage(text2) ?? "Another site's preview holds this port."} ${params.portTakenHint ?? PORT_TAKEN_HINT}`,
10494
+ PREVIEW_EXIT_PORT_NOT_ALLOWED
10495
+ );
10496
+ }
10219
10497
  const message = extractControllerErrorMessage(text2) ?? response.statusText;
10220
10498
  throw new Error(`Preview request failed (${response.status}): ${message}`);
10221
10499
  }
@@ -10424,12 +10702,12 @@ function listeningSocketInodes(procRoot, port) {
10424
10702
  continue;
10425
10703
  }
10426
10704
  for (const line of text2.split("\n").slice(1)) {
10427
- const fields = line.trim().split(/\s+/);
10428
- if (fields.length < 10) continue;
10429
- const local = fields[1] ?? "";
10705
+ const fields2 = line.trim().split(/\s+/);
10706
+ if (fields2.length < 10) continue;
10707
+ const local = fields2[1] ?? "";
10430
10708
  const portHex = local.slice(local.lastIndexOf(":") + 1);
10431
- if (fields[3] === "0A" && Number.parseInt(portHex, 16) === port && fields[9] !== "0") {
10432
- inodes.add(fields[9]);
10709
+ if (fields2[3] === "0A" && Number.parseInt(portHex, 16) === port && fields2[9] !== "0") {
10710
+ inodes.add(fields2[9]);
10433
10711
  }
10434
10712
  }
10435
10713
  }
@@ -10605,8 +10883,159 @@ async function startPreviewSite(params) {
10605
10883
  await new Promise((resolve) => setTimeout(resolve, START_POLL_MS));
10606
10884
  }
10607
10885
  }
10886
+ function withPort(argv, port) {
10887
+ return argv.map((arg) => arg.split(PORT_PLACEHOLDER).join(String(port)));
10888
+ }
10889
+ function sameArgv(a, b) {
10890
+ return a.length === b.length && a.every((arg, index) => arg === b[index]);
10891
+ }
10892
+ function sameStart(a, b) {
10893
+ return Boolean(a && Array.isArray(a.argv) && a.cwd === b.cwd && sameArgv(a.argv, b.argv));
10894
+ }
10895
+ function preferredSitePort(start) {
10896
+ const digest2 = createHash2("sha256").update(`${start.cwd}\0${start.argv.join("\0")}`).digest();
10897
+ return AUTO_PORT_FIRST + digest2.readUInt32BE(0) % AUTO_PORT_COUNT;
10898
+ }
10899
+ async function portIsFree(port) {
10900
+ for (const host of ["127.0.0.1", "0.0.0.0", "::1", "::"]) {
10901
+ const result = await new Promise((resolve) => {
10902
+ const server = net5.createServer();
10903
+ server.once(
10904
+ "error",
10905
+ (error) => resolve(error.code === "EADDRNOTAVAIL" || error.code === "EAFNOSUPPORT" ? "absent" : "taken")
10906
+ );
10907
+ server.listen({ port, host, ...host === "::" ? { ipv6Only: false } : {} }, () => server.close(() => resolve("free")));
10908
+ });
10909
+ if (result === "taken") return false;
10910
+ }
10911
+ return true;
10912
+ }
10913
+ function startedSiteRuns(entry) {
10914
+ return Boolean(entry.startedPid && entry.startedPid > 0 && entry.startedCommand) && readProcessCommandLine(entry.startedPid) === entry.startedCommand;
10915
+ }
10916
+ async function siteOnPort(params) {
10917
+ const { port, start, root, projectId: projectId2, previews, procRoot } = params;
10918
+ if (await portIsFree(port)) return "none";
10919
+ if (previews.some(
10920
+ (entry) => entry.projectId === projectId2 && entry.localPort === port && sameStart(entry.start, start) && startedSiteRuns(entry)
10921
+ )) {
10922
+ return "same";
10923
+ }
10924
+ const listening = detectListeningProcess(port, procRoot);
10925
+ if (listening && workspaceRelativeDir(root, listening.cwd) === start.cwd && sameArgv(listening.argv, withPort(start.argv, port))) {
10926
+ return "same";
10927
+ }
10928
+ return "other";
10929
+ }
10930
+ function portTakenError(port, previews, projectId2) {
10931
+ const holder = previews.find((entry) => entry.projectId === projectId2 && entry.localPort === port && entry.title);
10932
+ const what = holder ? `Port ${port} already shows '${holder.title}'.` : `Port ${port} is in use by another server.`;
10933
+ return new PreviewCommandError(`${what} Leave out --port: --start picks a free port for this site.`, PREVIEW_EXIT_PORT_NOT_ALLOWED);
10934
+ }
10935
+ async function startOrFindSite(params) {
10936
+ const { explicitPort, start, root, projectId: projectId2, title, sitePath, procRoot, log } = params;
10937
+ const { previews } = readStateFile2();
10938
+ const onPort = (port) => siteOnPort({ port, start, root, projectId: projectId2, previews, procRoot });
10939
+ const launch = async (port) => {
10940
+ const serve = { argv: withPort(start.argv, port), cwd: start.cwd };
10941
+ const started = await startPreviewSite({ port, sitePath, serve, root, timeoutMs: params.timeoutMs });
10942
+ log(`Started the site on port ${port} (pid ${started.pid}); log: ${displayPath(started.logFile)}`);
10943
+ return { port, start, started: { ...started, command: readProcessCommandLine(started.pid) } };
10944
+ };
10945
+ if (explicitPort !== null) {
10946
+ const found = await onPort(explicitPort);
10947
+ if (found === "same") return { port: explicitPort, start, started: null };
10948
+ if (found === "other") throw portTakenError(explicitPort, previews, projectId2);
10949
+ return launch(explicitPort);
10950
+ }
10951
+ const tried = /* @__PURE__ */ new Set();
10952
+ const known = previews.find((entry) => entry.projectId === projectId2 && sameStart(entry.start, start));
10953
+ if (known) {
10954
+ tried.add(known.localPort);
10955
+ const found = await onPort(known.localPort);
10956
+ if (found === "same") return { port: known.localPort, start, started: null };
10957
+ if (found === "none") return launch(known.localPort);
10958
+ }
10959
+ const reserved = reservedPreviewPorts();
10960
+ const held = new Set(
10961
+ previews.filter((entry) => entry.projectId !== projectId2 || !sameStart(entry.start, start)).map((entry) => entry.localPort)
10962
+ );
10963
+ for (const [port, holder] of await params.openPorts()) {
10964
+ if (holder !== title) held.add(port);
10965
+ }
10966
+ const first = preferredSitePort(start);
10967
+ for (let index = 0; index < AUTO_PORT_TRIES; index += 1) {
10968
+ const port = AUTO_PORT_FIRST + (first - AUTO_PORT_FIRST + index) % AUTO_PORT_COUNT;
10969
+ if (tried.has(port) || reserved.has(port) || held.has(port)) continue;
10970
+ tried.add(port);
10971
+ const found = await onPort(port);
10972
+ if (found === "same") return { port, start, started: null };
10973
+ if (found === "none") return launch(port);
10974
+ }
10975
+ throw new PreviewCommandError(
10976
+ `No free port was found for the site: ${tried.size} ports from ${first} are taken. Stop a site this space no longer needs (instafy preview close <previewId>), then retry.`,
10977
+ PREVIEW_EXIT_NOT_LISTENING
10978
+ );
10979
+ }
10980
+ async function stopSite(pid, command) {
10981
+ if (!pid || pid <= 0 || !command) return false;
10982
+ const ours = () => readProcessCommandLine(pid) === command;
10983
+ if (!ours()) return false;
10984
+ const signal = (name) => {
10985
+ try {
10986
+ process.kill(-pid, name);
10987
+ } catch {
10988
+ try {
10989
+ process.kill(pid, name);
10990
+ } catch {
10991
+ }
10992
+ }
10993
+ };
10994
+ signal("SIGTERM");
10995
+ if (!await waitForProcessExit(pid, 4e3) && ours()) signal("SIGKILL");
10996
+ return true;
10997
+ }
10998
+ function siteRecordFor(site, previews, port, projectId2) {
10999
+ if (site?.started) {
11000
+ return { start: site.start, startedPid: site.started.pid, ...site.started.command ? { startedCommand: site.started.command } : {} };
11001
+ }
11002
+ const earlier = previews.find(
11003
+ (entry) => entry.projectId === projectId2 && entry.localPort === port && (!site || sameStart(entry.start, site.start)) && startedSiteRuns(entry)
11004
+ );
11005
+ if (earlier) return { start: earlier.start, startedPid: earlier.startedPid, startedCommand: earlier.startedCommand };
11006
+ return site ? { start: site.start } : {};
11007
+ }
11008
+ async function openPreviewPorts(params) {
11009
+ const ports = /* @__PURE__ */ new Map();
11010
+ let rows = [];
11011
+ try {
11012
+ const listed = await controllerRequest({
11013
+ controllerUrl: params.controllerUrl,
11014
+ auth: params.auth,
11015
+ method: "GET",
11016
+ route: previewsRoute(params.projectId),
11017
+ retryCommand: "instafy preview list",
11018
+ notFoundMeansUnavailable: true,
11019
+ timeoutMs: 1e4
11020
+ });
11021
+ rows = Array.isArray(listed.body) ? listed.body : [];
11022
+ } catch (error) {
11023
+ if (error instanceof PreviewCommandError && error.exitCode === PREVIEW_EXIT_UNAVAILABLE) throw error;
11024
+ return ports;
11025
+ }
11026
+ const runtimeId = readEnv("RUNTIME_ID");
11027
+ for (const raw of rows) {
11028
+ if (!raw || typeof raw !== "object") continue;
11029
+ const record2 = raw;
11030
+ const onRuntime = stringField(record2, "runtimeId");
11031
+ if (runtimeId && onRuntime && onRuntime !== runtimeId) continue;
11032
+ const port = numberField(record2, "port");
11033
+ if (port) ports.set(port, stringField(record2, "title") ?? "");
11034
+ }
11035
+ return ports;
11036
+ }
10608
11037
  async function createAndStartClient(params) {
10609
- const { opts, port, title, sitePath, projectId: projectId2, retryCommand, auth, controllerUrl, serverUrl, log, serve } = params;
11038
+ const { opts, port, title, sitePath, projectId: projectId2, retryCommand, auth, controllerUrl, serverUrl, log, serve, site, portPicked } = params;
10610
11039
  const conversationId = opts.conversationId?.trim() || void 0;
10611
11040
  const created = await controllerRequest({
10612
11041
  controllerUrl,
@@ -10622,10 +11051,12 @@ async function createAndStartClient(params) {
10622
11051
  title,
10623
11052
  ...opts.summary ? { summary: opts.summary } : {},
10624
11053
  metadata: { source: "instafy-cli" },
10625
- ...serve ? { serve } : {}
11054
+ ...serve ? { serve } : {},
11055
+ ...opts.replace ? { replace: true } : {}
10626
11056
  },
10627
11057
  retryCommand,
10628
- notFoundMeansUnavailable: true
11058
+ notFoundMeansUnavailable: true,
11059
+ ...portPicked ? { portTakenHint: PICKED_PORT_TAKEN_HINT } : {}
10629
11060
  });
10630
11061
  const grant = created.body && !Array.isArray(created.body) ? created.body : {};
10631
11062
  const previewId = stringField(grant, "previewId");
@@ -10639,6 +11070,7 @@ async function createAndStartClient(params) {
10639
11070
  const studioOrigin = parseStudioOrigin(grant["studioOrigin"]);
10640
11071
  const state = readStateFile2();
10641
11072
  const current = state.previews.find((preview) => preview.previewId === previewId) ?? null;
11073
+ const siteRecord = siteRecordFor(site, state.previews, port, projectId2);
10642
11074
  let pid = null;
10643
11075
  let binary = current?.binary;
10644
11076
  try {
@@ -10702,47 +11134,84 @@ ${started.logTail}` : "";
10702
11134
  startedAt: current?.pid === pid ? current.startedAt : (/* @__PURE__ */ new Date()).toISOString(),
10703
11135
  ...binary ? { binary } : {},
10704
11136
  ...hostSuffix ? { hostSuffix } : {},
10705
- ...studioOrigin ? { studioOrigin } : {}
11137
+ ...studioOrigin ? { studioOrigin } : {},
11138
+ ...siteRecord
10706
11139
  };
10707
11140
  upsertPreviewState(entry);
10708
11141
  return { grant, previewId, entry, current };
10709
11142
  }
10710
11143
  async function previewOpen(opts) {
10711
- const port = parsePreviewPort(opts.port);
11144
+ const explicitPort = opts.port === void 0 || String(opts.port).trim() === "" ? null : parsePreviewPort(opts.port);
10712
11145
  const title = parsePreviewTitle(opts.title);
10713
11146
  const sitePath = parsePreviewPath(opts.path);
10714
11147
  const summary = parsePreviewSummary(opts.summary);
10715
- if (opts.serveCmd?.trim()) parseExplicitServeCommand(opts.serveCmd);
11148
+ const serveArgv = opts.serveCmd?.trim() ? parseExplicitServeCommand(opts.serveCmd) : null;
11149
+ if (opts.start && !serveArgv) {
11150
+ throw new PreviewCommandError('--start needs --serve-cmd "<command>" to start the site.', PREVIEW_EXIT_NOT_LISTENING);
11151
+ }
11152
+ if (explicitPort === null) {
11153
+ if (!opts.start) {
11154
+ throw new Error(`A port is required: --port <port>. Or start the site with --start and --serve-cmd, which picks a free port.`);
11155
+ }
11156
+ if (!serveArgv.some((arg) => arg.includes(PORT_PLACEHOLDER))) {
11157
+ throw new Error(
11158
+ `--serve-cmd needs ${PORT_PLACEHOLDER} where the site's port goes, e.g. --serve-cmd "python3 -m http.server ${PORT_PLACEHOLDER} --bind 127.0.0.1": --start picks the port.`
11159
+ );
11160
+ }
11161
+ }
10716
11162
  const projectId2 = resolveProject({ project: opts.project });
10717
- const retryCommand = `instafy preview open --port ${port} --title ${shellQuote2(title)}`;
11163
+ const retryCommand = `instafy preview open${explicitPort === null ? "" : ` --port ${explicitPort}`} --title ${shellQuote2(title)}`;
10718
11164
  const { auth, controllerUrl } = resolveAuth(opts, retryCommand);
10719
11165
  const serverUrl = cleanUrl(controllerUrl);
10720
11166
  const log = (message) => console.error(kleur8.cyan(message));
10721
- const explicitServe = opts.start && opts.serveCmd?.trim() ? resolvePreviewServe({ port, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd }).serve : null;
10722
- let probe = await probeLocalSite(port, sitePath, void 0, RATHOLE_LOCAL_HOSTS);
10723
- let started = null;
10724
- if (!probe.listening && opts.start && probe.status === null && !probe.timedOut) {
10725
- const ipv6 = await probeLocalSite(port, sitePath, void 0, ["::1"]);
10726
- if (ipv6.status === null && !ipv6.timedOut) {
10727
- if (!explicitServe) {
10728
- throw new PreviewCommandError(
10729
- `Nothing answers on 127.0.0.1:${port}, and --start needs --serve-cmd "<command>" to start the site.`,
10730
- PREVIEW_EXIT_NOT_LISTENING
10731
- );
11167
+ const start = opts.start ? resolvePreviewServe({ port: explicitPort ?? 0, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd }).serve : null;
11168
+ const { grant, previewId, entry, port, site, serve, restartNote } = await withStateLock(async () => {
11169
+ const site2 = start ? await startOrFindSite({
11170
+ explicitPort,
11171
+ start,
11172
+ root: resolveWorkspaceRoot(process.env, projectId2),
11173
+ projectId: projectId2,
11174
+ title,
11175
+ sitePath,
11176
+ timeoutMs: opts.startTimeoutMs,
11177
+ procRoot: opts.procRoot,
11178
+ openPorts: () => openPreviewPorts({ controllerUrl, auth, projectId: projectId2 }),
11179
+ log
11180
+ }) : null;
11181
+ const port2 = site2?.port ?? explicitPort;
11182
+ try {
11183
+ const probe = await probeLocalSite(port2, sitePath, void 0, RATHOLE_LOCAL_HOSTS);
11184
+ if (!probe.listening) {
11185
+ throw await notListeningError(port2, sitePath, probe);
10732
11186
  }
10733
- const root = resolveWorkspaceRoot(process.env, projectId2);
10734
- started = await startPreviewSite({ port, sitePath, serve: explicitServe, root, timeoutMs: opts.startTimeoutMs });
10735
- log(`Started the site (pid ${started.pid}); log: ${displayPath(started.logFile)}`);
10736
- probe = await probeLocalSite(port, sitePath, void 0, RATHOLE_LOCAL_HOSTS);
11187
+ const { serve: serve2, note } = resolvePreviewServe({
11188
+ port: port2,
11189
+ serveCmd: opts.serveCmd?.split(PORT_PLACEHOLDER).join(String(port2)),
11190
+ projectId: projectId2,
11191
+ cwd: opts.cwd,
11192
+ procRoot: opts.procRoot
11193
+ });
11194
+ const created = await createAndStartClient({
11195
+ opts: { ...opts, summary },
11196
+ port: port2,
11197
+ title,
11198
+ sitePath,
11199
+ projectId: projectId2,
11200
+ retryCommand,
11201
+ auth,
11202
+ controllerUrl,
11203
+ serverUrl,
11204
+ log,
11205
+ serve: serve2,
11206
+ site: site2,
11207
+ portPicked: explicitPort === null
11208
+ });
11209
+ return { ...created, port: port2, site: site2, serve: serve2, restartNote: note };
11210
+ } catch (error) {
11211
+ if (site2?.started) await stopSite(site2.started.pid, site2.started.command ?? readProcessCommandLine(site2.started.pid));
11212
+ throw error;
10737
11213
  }
10738
- }
10739
- if (!probe.listening) {
10740
- throw await notListeningError(port, sitePath, probe);
10741
- }
10742
- const { serve, note: restartNote } = resolvePreviewServe({ port, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd });
10743
- const { grant, previewId, entry } = await withStateLock(
10744
- () => createAndStartClient({ opts: { ...opts, summary }, port, title, sitePath, projectId: projectId2, retryCommand, auth, controllerUrl, serverUrl, log, serve })
10745
- );
11214
+ });
10746
11215
  let ready;
10747
11216
  try {
10748
11217
  ready = await controllerRequest({
@@ -10786,7 +11255,7 @@ async function previewOpen(opts) {
10786
11255
  viewerHostSuffix: parseViewerHostSuffix(grant["viewerHostSuffix"]) ?? null,
10787
11256
  restartable,
10788
11257
  ...restartable ? {} : { restartNote: restartNote ?? "the server did not record a start command" },
10789
- ...opts.start ? { startedPid: started?.pid ?? null, logFile: started?.logFile ?? null } : {},
11258
+ ...opts.start ? { startedPid: site?.started?.pid ?? null, logFile: site?.started?.logFile ?? null } : {},
10790
11259
  thumbnail: thumbnail.status,
10791
11260
  ...thumbnail.reason ? { thumbnailReason: thumbnail.reason } : {}
10792
11261
  };
@@ -10881,9 +11350,13 @@ async function previewClose(opts) {
10881
11350
  if (!previewId) {
10882
11351
  throw new Error("Preview id is required. Use `instafy preview list` to find it.");
10883
11352
  }
11353
+ let siteStopped = false;
10884
11354
  const entry = await withStateLock(async () => {
10885
11355
  const removed = removePreviewState(previewId);
10886
- if (removed) await stopClient(removed);
11356
+ if (removed) {
11357
+ await stopClient(removed);
11358
+ siteStopped = await stopSite(removed.startedPid, removed.startedCommand);
11359
+ }
10887
11360
  return removed;
10888
11361
  });
10889
11362
  const projectId2 = entry?.projectId ?? resolveProject({ project: opts.project });
@@ -10894,11 +11367,14 @@ async function previewClose(opts) {
10894
11367
  );
10895
11368
  await closePreviewOnServer(controllerUrl, auth, projectId2, previewId);
10896
11369
  } catch (error) {
10897
- if (entry) await withStateLock(async () => upsertPreviewState({ ...entry, pid: -1 }));
11370
+ if (entry) {
11371
+ const { startedPid: _pid, startedCommand: _command, ...rest } = entry;
11372
+ await withStateLock(async () => upsertPreviewState({ ...siteStopped ? rest : entry, pid: -1 }));
11373
+ }
10898
11374
  throw error;
10899
11375
  }
10900
11376
  removeWorkdir(previewId);
10901
- return { ok: true, previewId };
11377
+ return { ok: true, previewId, siteStopped };
10902
11378
  }
10903
11379
  function printPreviewOpen(result, json) {
10904
11380
  if (json) {
@@ -10906,7 +11382,7 @@ function printPreviewOpen(result, json) {
10906
11382
  return;
10907
11383
  }
10908
11384
  const label = result.status === "live" ? kleur8.green("Ready to preview") : result.status === "not_responding" ? kleur8.yellow("Not responding yet") : kleur8.yellow("Preview opened (the server did not report whether the site answers)");
10909
- console.log(`${label}: ${result.title} (port ${result.port}). Open it from the card in the conversation.`);
11385
+ console.log(`${label}: ${result.title} (port ${result.port}). Its card is in the conversation.`);
10910
11386
  if (result.thumbnail === "uploaded") {
10911
11387
  console.log(kleur8.gray("The card shows a picture of the site; `instafy preview check` takes a new one after a change."));
10912
11388
  } else {
@@ -10915,7 +11391,7 @@ function printPreviewOpen(result, json) {
10915
11391
  if (result.startedPid) {
10916
11392
  console.log(kleur8.gray(`Started the site in the background (pid ${result.startedPid}); log: ${displayPath(result.logFile ?? "")}`));
10917
11393
  } else if (result.startedPid === null) {
10918
- console.log(kleur8.gray("The site was already running; nothing was started."));
11394
+ console.log(kleur8.gray("This site was already running on its port; nothing was started."));
10919
11395
  }
10920
11396
  if (result.restartable) {
10921
11397
  console.log(kleur8.gray("Open starts the site again when the process that started it exits, or the space sleeps."));
@@ -11082,7 +11558,7 @@ init_config();
11082
11558
 
11083
11559
  // src/git-wrapper.ts
11084
11560
  import { spawnSync as spawnSync5 } from "node:child_process";
11085
- import { randomUUID as randomUUID2 } from "node:crypto";
11561
+ import { randomUUID as randomUUID3 } from "node:crypto";
11086
11562
  import fs13 from "node:fs";
11087
11563
  import path14 from "node:path";
11088
11564
  import kleur10 from "kleur";
@@ -11296,10 +11772,10 @@ function hideEmbeddedGitDirs(context, pathSpecs) {
11296
11772
  fs13.mkdirSync(stagingBase, { recursive: true });
11297
11773
  const renames = [];
11298
11774
  for (const candidate of candidates) {
11299
- let hidden = path14.join(stagingBase, randomUUID2());
11775
+ let hidden = path14.join(stagingBase, randomUUID3());
11300
11776
  for (let attempt2 = 0; attempt2 < 8; attempt2 += 1) {
11301
11777
  if (!pathExists(hidden)) break;
11302
- hidden = path14.join(stagingBase, randomUUID2());
11778
+ hidden = path14.join(stagingBase, randomUUID3());
11303
11779
  }
11304
11780
  fs13.renameSync(candidate, hidden);
11305
11781
  renames.push({ original: candidate, hidden });
@@ -11870,6 +12346,311 @@ function saveReportJson(report, notSaved) {
11870
12346
  };
11871
12347
  }
11872
12348
 
12349
+ // src/api.ts
12350
+ init_config();
12351
+ init_errors();
12352
+ init_controller_fetch();
12353
+ import fs14 from "node:fs";
12354
+ function normalizeUrl5(raw) {
12355
+ const value = (raw ?? "").trim();
12356
+ if (!value) return "http://127.0.0.1:8788";
12357
+ return value.replace(/\/$/, "");
12358
+ }
12359
+ function normalizeToken6(raw) {
12360
+ if (!raw) return null;
12361
+ const trimmed = raw.trim();
12362
+ return trimmed.length ? trimmed : null;
12363
+ }
12364
+ function resolveBearerTokenWithSource(options) {
12365
+ const cwd = process.cwd();
12366
+ const profile = resolveActiveProfileName({ cwd });
12367
+ const stored = resolveConfiguredAccessToken({ profile, cwd });
12368
+ const explicit = normalizeToken6(options.accessToken);
12369
+ if (explicit) {
12370
+ return { token: explicit, source: "explicit", profile, runtimeControllerUrl: null };
12371
+ }
12372
+ const runtimeCredential = resolveRuntimeControllerCredential();
12373
+ if (runtimeCredential) {
12374
+ return {
12375
+ token: runtimeCredential.token,
12376
+ source: "env",
12377
+ profile: null,
12378
+ runtimeControllerUrl: runtimeCredential.controllerUrl
12379
+ };
12380
+ }
12381
+ const envKeys = [
12382
+ "INSTAFY_ACCESS_TOKEN",
12383
+ "SUPABASE_ACCESS_TOKEN"
12384
+ ];
12385
+ for (const key of envKeys) {
12386
+ const value = normalizeToken6(process.env[key]);
12387
+ if (value) {
12388
+ return { token: value, source: "env", profile, runtimeControllerUrl: null };
12389
+ }
12390
+ }
12391
+ if (stored) {
12392
+ return { token: stored, source: "config", profile, runtimeControllerUrl: null };
12393
+ }
12394
+ return { token: null, source: "none", profile, runtimeControllerUrl: null };
12395
+ }
12396
+ function parseKeyValue(raw) {
12397
+ const trimmed = raw.trim();
12398
+ if (!trimmed) {
12399
+ throw new Error("Key/value pair cannot be empty");
12400
+ }
12401
+ const equals = trimmed.indexOf("=");
12402
+ if (equals === -1) {
12403
+ return { key: trimmed, value: "" };
12404
+ }
12405
+ const key = trimmed.slice(0, equals).trim();
12406
+ const value = trimmed.slice(equals + 1).trim();
12407
+ if (!key) {
12408
+ throw new Error(`Invalid pair "${raw}" (missing key)`);
12409
+ }
12410
+ return { key, value };
12411
+ }
12412
+ function parseHeader(raw) {
12413
+ const trimmed = raw.trim();
12414
+ if (!trimmed) {
12415
+ throw new Error("Header cannot be empty");
12416
+ }
12417
+ const colon = trimmed.indexOf(":");
12418
+ if (colon !== -1) {
12419
+ const key = trimmed.slice(0, colon).trim();
12420
+ const value = trimmed.slice(colon + 1).trim();
12421
+ if (!key) {
12422
+ throw new Error(`Invalid header "${raw}" (missing name)`);
12423
+ }
12424
+ return { key, value };
12425
+ }
12426
+ return parseKeyValue(trimmed);
12427
+ }
12428
+ function parseJsonBody(options) {
12429
+ if (options.jsonFile) {
12430
+ const raw = fs14.readFileSync(options.jsonFile, "utf8");
12431
+ const parsed = JSON.parse(raw);
12432
+ return JSON.stringify(parsed);
12433
+ }
12434
+ if (options.json) {
12435
+ const parsed = JSON.parse(options.json);
12436
+ return JSON.stringify(parsed);
12437
+ }
12438
+ return void 0;
12439
+ }
12440
+ function buildRequestUrl(options, runtimeControllerUrl) {
12441
+ const selectedControllerUrl = options.controllerUrl ?? runtimeControllerUrl;
12442
+ const base = new URL(
12443
+ `${normalizeUrl5(resolveControllerUrl({ controllerUrl: selectedControllerUrl ?? null }))}/`
12444
+ );
12445
+ if (base.protocol !== "http:" && base.protocol !== "https:") {
12446
+ throw new Error("The configured controller URL must use http or https.");
12447
+ }
12448
+ if (base.username || base.password) {
12449
+ throw new Error("The configured controller URL must not contain embedded credentials.");
12450
+ }
12451
+ if (runtimeControllerUrl) {
12452
+ resolveRuntimeBoundControllerUrl(
12453
+ { controllerUrl: runtimeControllerUrl },
12454
+ base.toString()
12455
+ );
12456
+ }
12457
+ const rawPath = options.path.trim();
12458
+ if (!rawPath) {
12459
+ throw new Error("Path is required");
12460
+ }
12461
+ const isAbsoluteReference = /^[a-z][a-z\d+.-]*:/i.test(rawPath) || rawPath.startsWith("//");
12462
+ const url = new URL(
12463
+ isAbsoluteReference ? rawPath : rawPath.startsWith("/") ? rawPath : `/${rawPath}`,
12464
+ base
12465
+ );
12466
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
12467
+ throw new Error("Controller API URLs must use http or https.");
12468
+ }
12469
+ if (url.username || url.password) {
12470
+ throw new Error("Controller API URLs must not contain embedded credentials.");
12471
+ }
12472
+ const crossOrigin = url.origin !== base.origin;
12473
+ if (crossOrigin && !options.allowCrossOrigin) {
12474
+ throw new Error(
12475
+ "Refusing a cross-origin controller API request. Set --server-url to that origin and use a relative path, or pass --allow-cross-origin only when you explicitly intend to send credentials and request data there."
12476
+ );
12477
+ }
12478
+ for (const pair of options.query ?? []) {
12479
+ const { key, value } = parseKeyValue(pair);
12480
+ url.searchParams.set(key, value);
12481
+ }
12482
+ return { url, crossOrigin };
12483
+ }
12484
+ function maybePrettyPrintJson(text2, pretty) {
12485
+ if (!pretty) return text2;
12486
+ if (!text2.trim()) return text2;
12487
+ try {
12488
+ return JSON.stringify(JSON.parse(text2), null, 2);
12489
+ } catch {
12490
+ return text2;
12491
+ }
12492
+ }
12493
+ async function executeControllerApiRequest(options, bodyOverride) {
12494
+ const resolved = resolveBearerTokenWithSource(options);
12495
+ const { url, crossOrigin } = buildRequestUrl(options, resolved.runtimeControllerUrl);
12496
+ const bearer = resolved.token;
12497
+ if (crossOrigin && (!bearer || resolved.source !== "explicit")) {
12498
+ throw new Error(
12499
+ "Cross-origin controller API requests require --allow-cross-origin and an explicit --access-token."
12500
+ );
12501
+ }
12502
+ const headers = new Headers();
12503
+ headers.set("accept", "application/json");
12504
+ for (const header of options.headers ?? []) {
12505
+ const { key, value } = parseHeader(header);
12506
+ headers.set(key, value);
12507
+ }
12508
+ const body = bodyOverride ?? parseJsonBody(options);
12509
+ if (body !== void 0 && !headers.has("content-type")) {
12510
+ headers.set("content-type", "application/json");
12511
+ }
12512
+ const init = {
12513
+ method: options.method,
12514
+ headers,
12515
+ body,
12516
+ redirect: "error"
12517
+ };
12518
+ const response = bearer ? (await fetchWithControllerAuth({
12519
+ url: url.toString(),
12520
+ init,
12521
+ accessToken: bearer,
12522
+ tokenSource: resolved.source,
12523
+ profile: resolved.profile,
12524
+ cwd: process.cwd()
12525
+ })).response : await fetch(url, init);
12526
+ const responseText = await response.text().catch(() => "");
12527
+ const contentType = response.headers.get("content-type") ?? "";
12528
+ const isJson = contentType.includes("application/json") || contentType.includes("+json");
12529
+ if (!response.ok) {
12530
+ if (response.status === 401 || response.status === 403) {
12531
+ throw formatAuthRejectedError({
12532
+ status: response.status,
12533
+ responseBody: responseText,
12534
+ retryCommand: "instafy login",
12535
+ advancedHint: "pass --access-token, or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN"
12536
+ });
12537
+ }
12538
+ const formattedBody = isJson ? maybePrettyPrintJson(responseText, true) : responseText;
12539
+ const prefix = `Request failed (${response.status} ${response.statusText})`;
12540
+ const suffix = formattedBody.trim() ? `: ${formattedBody}` : "";
12541
+ throw new Error(`${prefix}${suffix}`);
12542
+ }
12543
+ return { response, responseText, isJson };
12544
+ }
12545
+ async function requestControllerApiJson(options) {
12546
+ const body = options.jsonBody === void 0 ? void 0 : JSON.stringify(options.jsonBody);
12547
+ const { responseText, isJson } = await executeControllerApiRequest(
12548
+ {
12549
+ method: options.method,
12550
+ path: options.path,
12551
+ controllerUrl: options.controllerUrl,
12552
+ accessToken: options.accessToken,
12553
+ serviceToken: options.serviceToken,
12554
+ query: options.query,
12555
+ headers: options.headers,
12556
+ allowCrossOrigin: options.allowCrossOrigin
12557
+ },
12558
+ body
12559
+ );
12560
+ if (!isJson) {
12561
+ throw new Error("Expected JSON response from controller API");
12562
+ }
12563
+ return JSON.parse(responseText);
12564
+ }
12565
+ async function requestControllerApi(options) {
12566
+ const { responseText, isJson } = await executeControllerApiRequest(options);
12567
+ const pretty = options.pretty !== false;
12568
+ const formattedBody = isJson ? maybePrettyPrintJson(responseText, pretty) : responseText;
12569
+ if (formattedBody) console.log(formattedBody);
12570
+ }
12571
+
12572
+ // src/research-settings.ts
12573
+ init_project_manifest();
12574
+ var fields = [
12575
+ { key: "maxPageLoads", label: "Fresh pages per task" },
12576
+ { key: "maxPageLoadsPerSite", label: "Fresh pages per site per task" }
12577
+ ];
12578
+ function integer(raw, label) {
12579
+ if (!/^[1-9]\d*$/.test(raw.trim()) || !Number.isSafeInteger(Number(raw))) {
12580
+ throw new Error(`${label} must be a positive whole number.`);
12581
+ }
12582
+ return Number(raw);
12583
+ }
12584
+ async function researchSettings(options) {
12585
+ const patch = {};
12586
+ for (const field of fields) {
12587
+ if (options[field.key] !== void 0) patch[field.key] = integer(options[field.key], field.label);
12588
+ }
12589
+ if (options.interactive) {
12590
+ if (options.json || Object.keys(patch).length) throw new Error("Use --interactive without --json or setting flags.");
12591
+ if (!process.stdin.isTTY || !process.stdout.isTTY || process.env.CI === "true") {
12592
+ throw new Error("--interactive requires a terminal. Use --max-page-loads and --max-page-loads-per-site instead.");
12593
+ }
12594
+ }
12595
+ const project = [options.project, process.env.SPACE_ID, process.env.INSTAFY_SPACE_ID, process.env.PROJECT_ID, process.env.INSTAFY_PROJECT_ID].map((value) => value?.trim()).find(Boolean) ?? findProjectManifest(process.cwd()).manifest?.spaceId;
12596
+ if (!project) throw new Error("No space configured. Pass --space, set SPACE_ID, or run `instafy space init`.");
12597
+ const request = {
12598
+ path: `/projects/${encodeURIComponent(project)}/research-settings`,
12599
+ controllerUrl: options.controllerUrl,
12600
+ accessToken: options.accessToken
12601
+ };
12602
+ let settings = await requestControllerApiJson({ ...request, method: "GET" });
12603
+ if ((options.interactive || Object.keys(patch).length) && !settings.canManage) {
12604
+ throw new Error("Only space managers can change research settings.");
12605
+ }
12606
+ for (const field of fields) {
12607
+ if (!settings.limits?.[field.key] || !Number.isInteger(settings[field.key])) {
12608
+ throw new Error("This controller does not support research settings. Update it before changing the budget.");
12609
+ }
12610
+ }
12611
+ if (options.interactive) {
12612
+ const { isCancel, text: text2 } = await import("@clack/prompts");
12613
+ console.log("The parent and its helpers share one budget. Changes apply to new tasks; running tasks retain their allowance.");
12614
+ for (const field of fields) {
12615
+ const bounds = settings.limits[field.key];
12616
+ const entered = await text2({
12617
+ message: `${field.label} (${bounds.min}\u2013${bounds.max}; default ${bounds.default})`,
12618
+ defaultValue: String(settings[field.key]),
12619
+ placeholder: String(settings[field.key]),
12620
+ validate(raw) {
12621
+ try {
12622
+ const value2 = integer(raw || String(settings[field.key]), field.label);
12623
+ if (value2 < bounds.min || value2 > bounds.max) return `Enter a whole number from ${bounds.min} to ${bounds.max}.`;
12624
+ if (field.key === "maxPageLoadsPerSite" && value2 > (patch.maxPageLoads ?? settings.maxPageLoads)) return "The per-site limit cannot exceed the task limit.";
12625
+ } catch (error) {
12626
+ return error.message;
12627
+ }
12628
+ }
12629
+ });
12630
+ if (isCancel(entered)) throw new Error("Cancelled. No settings were changed.");
12631
+ const value = integer(String(entered), field.label);
12632
+ if (value !== settings[field.key]) patch[field.key] = value;
12633
+ }
12634
+ }
12635
+ for (const field of fields) {
12636
+ const value = patch[field.key];
12637
+ const bounds = settings.limits[field.key];
12638
+ if (value !== void 0 && (value < bounds.min || value > bounds.max)) throw new Error(`${field.label} must be between ${bounds.min} and ${bounds.max}.`);
12639
+ }
12640
+ if ((patch.maxPageLoadsPerSite ?? settings.maxPageLoadsPerSite) > (patch.maxPageLoads ?? settings.maxPageLoads)) {
12641
+ throw new Error("The per-site limit cannot exceed the task limit.");
12642
+ }
12643
+ if (Object.keys(patch).length) settings = await requestControllerApiJson({ ...request, method: "PATCH", jsonBody: patch });
12644
+ if (options.json) {
12645
+ console.log(JSON.stringify(settings, null, 2));
12646
+ return;
12647
+ }
12648
+ console.log(`Research budget for space ${project}`);
12649
+ for (const field of fields) console.log(`${field.label}: ${settings[field.key]}`);
12650
+ console.log("Shared by the parent and all helpers, including continued worker jobs. Cached reads are free. Changes apply to new tasks.");
12651
+ if (!settings.canManage) console.log("Read-only: only space managers can change this budget.");
12652
+ }
12653
+
11873
12654
  // src/web-link.ts
11874
12655
  var linkOnly2 = __toESM(require_linkOnly(), 1);
11875
12656
  var WEB_LINK_EXIT_OK = 0;
@@ -11938,9 +12719,9 @@ function registerWebLinkCommand(web) {
11938
12719
  // src/web-search.ts
11939
12720
  var redaction2 = __toESM(require_redaction(), 1);
11940
12721
  import dns3 from "node:dns";
11941
- import fs14 from "node:fs";
12722
+ import fs15 from "node:fs";
11942
12723
  import https2 from "node:https";
11943
- import net5 from "node:net";
12724
+ import net6 from "node:net";
11944
12725
  import path15 from "node:path";
11945
12726
  import zlib2 from "node:zlib";
11946
12727
 
@@ -12147,7 +12928,7 @@ function compileProvider(id, raw) {
12147
12928
  if (url.protocol !== "https:") return `${id}: request.url must be https`;
12148
12929
  if (url.username || url.password || url.port && url.port !== "443" || url.hash) return `${id}: request.url must be a plain https address`;
12149
12930
  const host = langInHost ? url.hostname.toLowerCase().replace(/^xx\./, "") : url.hostname.toLowerCase();
12150
- if (net5.isIP(host.replace(/^\[|\]$/g, "")) || isLocalHostName(host)) return `${id}: request.url must name a public host`;
12931
+ if (net6.isIP(host.replace(/^\[|\]$/g, "")) || isLocalHostName(host)) return `${id}: request.url must name a public host`;
12151
12932
  const never = NEVER_HOSTS.find((entry) => host === entry.host || host.endsWith(`.${entry.host}`));
12152
12933
  if (never) return `${id}: ${never.host} is never used (${never.why})`;
12153
12934
  if (/[{}]/.test(decodeURIComponent(url.href))) return `${id}: request.url may not hold placeholders`;
@@ -12270,9 +13051,9 @@ function compileProvider(id, raw) {
12270
13051
  }
12271
13052
  function resultLink(value) {
12272
13053
  if (typeof value === "string" && value.startsWith("https://")) {
12273
- const fields = [...value.matchAll(/\{([\w-]+(?:\.[\w-]+)*)\}/g)];
13054
+ const fields2 = [...value.matchAll(/\{([\w-]+(?:\.[\w-]+)*)\}/g)];
12274
13055
  const rest = value.replace(/\{([\w-]+(?:\.[\w-]+)*)\}/g, "");
12275
- if (!fields.length || /[{}]/.test(rest)) return void 0;
13056
+ if (!fields2.length || /[{}]/.test(rest)) return void 0;
12276
13057
  try {
12277
13058
  new URL(value.replace(/\{[^}]*\}/g, "x"));
12278
13059
  } catch {
@@ -12330,8 +13111,8 @@ function loadSearchProviders(bundled = BUNDLED_SEARCH_PROVIDERS, copies = []) {
12330
13111
  return { providers, chains, off: [...off].filter((id) => id in bundled.providers).sort(), keyEnvs: [...keyEnvs], warnings };
12331
13112
  }
12332
13113
  function fill(template, vars) {
12333
- const whole = /^\{([a-z]+)\}$/.exec(template);
12334
- if (whole && whole[1] === "count") return vars.count;
13114
+ const whole2 = /^\{([a-z]+)\}$/.exec(template);
13115
+ if (whole2 && whole2[1] === "count") return vars.count;
12335
13116
  let missing = false;
12336
13117
  const out = template.replace(/\{\{|\}\}|\{([a-z]+)(?::([a-z]+))?\}/g, (match, name, encoder) => {
12337
13118
  if (!name) return match[0];
@@ -12420,7 +13201,7 @@ function sendOnce(request, target, timeoutMs) {
12420
13201
  port: target.port,
12421
13202
  method: request.method,
12422
13203
  path: `${request.url.pathname}${request.url.search}`,
12423
- servername: net5.isIP(hostname) ? void 0 : hostname,
13204
+ servername: net6.isIP(hostname) ? void 0 : hostname,
12424
13205
  agent: false,
12425
13206
  maxHeaderSize: 64 * 1024,
12426
13207
  headers: { Host: request.url.host, ...request.headers, ...payload ? { "Content-Length": String(payload.length) } : {} }
@@ -12470,8 +13251,8 @@ function at(value, field) {
12470
13251
  }
12471
13252
  return current;
12472
13253
  }
12473
- function firstText(item, fields) {
12474
- for (const field of fields) {
13254
+ function firstText(item, fields2) {
13255
+ for (const field of fields2) {
12475
13256
  const value = at(item, field);
12476
13257
  if (typeof value === "string" && value.trim()) return value;
12477
13258
  }
@@ -12487,10 +13268,10 @@ function withoutTags(text2) {
12487
13268
  return out;
12488
13269
  }
12489
13270
  function cleanText(raw, max) {
12490
- const text2 = withoutTags(raw).replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,6}|#39);/gi, (whole, name) => {
13271
+ const text2 = withoutTags(raw).replace(/&(#x[0-9a-f]{1,6}|#\d{1,7}|[a-z]{2,6}|#39);/gi, (whole2, name) => {
12491
13272
  if (ENTITIES2[name.toLowerCase()]) return ENTITIES2[name.toLowerCase()];
12492
13273
  const code = name[0] === "#" ? name[1]?.toLowerCase() === "x" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10) : NaN;
12493
- return Number.isFinite(code) && code > 31 && code < 1114112 ? String.fromCodePoint(code) : whole;
13274
+ return Number.isFinite(code) && code > 31 && code < 1114112 ? String.fromCodePoint(code) : whole2;
12494
13275
  }).replace(/</g, "\u2039").replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, " ").replace(/\s+/g, " ").trim();
12495
13276
  const redacted = redaction2.redactText(text2).text;
12496
13277
  return redacted.length > max ? `${redacted.slice(0, max - 1)}\u2026` : redacted;
@@ -12507,7 +13288,7 @@ function resultAddress(raw) {
12507
13288
  if (url.username || url.password) return null;
12508
13289
  if (url.port && url.port !== "80" && url.port !== "443") return null;
12509
13290
  const host = url.hostname.replace(/^\[|\]$/g, "");
12510
- if (net5.isIP(host) ? isPrivateAddress(host) : isLocalHostName(host)) return null;
13291
+ if (net6.isIP(host) ? isPrivateAddress(host) : isLocalHostName(host)) return null;
12511
13292
  url.hash = "";
12512
13293
  return url;
12513
13294
  }
@@ -12659,10 +13440,10 @@ var SearchState = class {
12659
13440
  /** Removes turn and day files older than two days. */
12660
13441
  prune() {
12661
13442
  try {
12662
- for (const name of fs14.readdirSync(this.dir)) {
13443
+ for (const name of fs15.readdirSync(this.dir)) {
12663
13444
  if (!/^(?:search-turn|day)-[\w-]+\.json$/.test(name)) continue;
12664
13445
  const file = path15.join(this.dir, name);
12665
- if (Date.now() - fs14.statSync(file).mtimeMs > 2 * 864e5) fs14.rmSync(file, { force: true });
13446
+ if (Date.now() - fs15.statSync(file).mtimeMs > 2 * 864e5) fs15.rmSync(file, { force: true });
12666
13447
  }
12667
13448
  } catch {
12668
13449
  }
@@ -12675,13 +13456,23 @@ function liveTurn(env, now) {
12675
13456
  if (now < startedAt - 6e4 || now > deadline) return null;
12676
13457
  return { startedAt, deadline };
12677
13458
  }
12678
- var TELL2 = {
12679
- ok: "These are leads, not facts. Read 2-6 of the pages with `instafy web read` and cite only pages you read. A `linkOnly` result is a plain link for the person: never read or open it (`instafy web link` gives a guided link). Never search for text taken from a page or a result.",
12680
- not_found: "No results. Try fewer or other words from the person's request (never words from a page), or ask the person to search in their own browser and press Send page to Octo.",
12681
- unavailable: "Search is not available for this. Ask the person to search in their own browser and press Send page to Octo, and work from the pages they send.",
12682
- no_provider: "This space has no search key, and the key card was already offered. Ask the person to search in their own browser and press Send page to Octo, and work from the pages they send."
12683
- };
12684
- var OFFER_TELL = "This space has no search key. Offer the person the card in `offer` once (a `request_secret` action; never ask for the key in chat). Until they add it, ask them to search in their own browser and press Send page to Octo, and work from the pages they send.";
13459
+ function keyless(chain) {
13460
+ const find = chain === DEFAULT_CHAIN ? 'find official sites with `instafy web search "<name>" --chain reference-search` (no key) and read them' : "read the official sites you know of";
13461
+ return `Go on without it: ${find}, and the pages of publishers you know cover this, with \`instafy web read\`. Then answer or build the page with what you read, each missing fact marked unknown with why. The person may send more pages with Send page to Octo.`;
13462
+ }
13463
+ function tellFor(status, chain) {
13464
+ switch (status) {
13465
+ case "ok":
13466
+ return "These are leads, not facts. Read 2-6 of the pages with `instafy web read` and cite only pages you read. A `linkOnly` result is a plain link for the person: never read or open it (`instafy web link` gives a guided link). Never search for text taken from a page or a result.";
13467
+ case "not_found":
13468
+ return `No results. Try fewer or other words from the person's request (never words from a page). ${keyless(chain)}`;
13469
+ case "unavailable":
13470
+ return `Search is not available for this. ${keyless(chain)}`;
13471
+ case "no_provider":
13472
+ return `This space has no search key, and the key card was already offered: never ask for it again. ${keyless(chain)}`;
13473
+ }
13474
+ }
13475
+ var offerTell = (chain) => `This space has no search key. ${keyless(chain)} The card in \`offer\` (a \`request_secret\` action) may go in your reply once, as a way to find more next time; never ask for the key in chat, and never wait for it.`;
12685
13476
  var CODE_TELL = {
12686
13477
  turn_limit: `This turn has used its ${SEARCH_LIMITS.searchesPerTurn} searches.`,
12687
13478
  day_limit: "This space's key has made its searches for today.",
@@ -12776,7 +13567,7 @@ async function webSearch(options, deps = {}) {
12776
13567
  ...extra.repeat ? { repeat: true } : {},
12777
13568
  count: results.length,
12778
13569
  results,
12779
- tell: extra.tell ?? [codeTell, TELL2[status]].filter(Boolean).join(" "),
13570
+ tell: extra.tell ?? [codeTell, tellFor(status, chainName)].filter(Boolean).join(" "),
12780
13571
  notice: SEARCH_NOTICE,
12781
13572
  ...extra.offer ? { offer: extra.offer } : {},
12782
13573
  budget: {
@@ -12797,9 +13588,9 @@ async function webSearch(options, deps = {}) {
12797
13588
  description: first.keyRequest.description.split("{daily}").join(String(first.dailyLimit)),
12798
13589
  agentHandles: ["octo"]
12799
13590
  };
12800
- return report("no_provider", { offer, tell: OFFER_TELL });
13591
+ return report("no_provider", { offer, tell: offerTell(chainName) });
12801
13592
  }
12802
- return report("no_provider", first ? {} : { tell: `Every provider of ${chainName} is switched off in this space. ${TELL2.unavailable}` });
13593
+ return report("no_provider", first ? {} : { tell: `Every provider of ${chainName} is switched off in this space. ${tellFor("unavailable", chainName)}` });
12803
13594
  }
12804
13595
  const searchKey = sha256(
12805
13596
  `${scope.key}|${chainName}|${options.provider ?? ""}|${query.toLowerCase()}|${site ?? ""}|${lang ?? ""}|${country ?? ""}|${count}`
@@ -13000,229 +13791,6 @@ function registerWebSearchCommand(web) {
13000
13791
  return web;
13001
13792
  }
13002
13793
 
13003
- // src/api.ts
13004
- init_config();
13005
- init_errors();
13006
- init_controller_fetch();
13007
- import fs15 from "node:fs";
13008
- function normalizeUrl5(raw) {
13009
- const value = (raw ?? "").trim();
13010
- if (!value) return "http://127.0.0.1:8788";
13011
- return value.replace(/\/$/, "");
13012
- }
13013
- function normalizeToken6(raw) {
13014
- if (!raw) return null;
13015
- const trimmed = raw.trim();
13016
- return trimmed.length ? trimmed : null;
13017
- }
13018
- function resolveBearerTokenWithSource(options) {
13019
- const cwd = process.cwd();
13020
- const profile = resolveActiveProfileName({ cwd });
13021
- const stored = resolveConfiguredAccessToken({ profile, cwd });
13022
- const explicit = normalizeToken6(options.accessToken);
13023
- if (explicit) {
13024
- return { token: explicit, source: "explicit", profile, runtimeControllerUrl: null };
13025
- }
13026
- const runtimeCredential = resolveRuntimeControllerCredential();
13027
- if (runtimeCredential) {
13028
- return {
13029
- token: runtimeCredential.token,
13030
- source: "env",
13031
- profile: null,
13032
- runtimeControllerUrl: runtimeCredential.controllerUrl
13033
- };
13034
- }
13035
- const envKeys = [
13036
- "INSTAFY_ACCESS_TOKEN",
13037
- "SUPABASE_ACCESS_TOKEN"
13038
- ];
13039
- for (const key of envKeys) {
13040
- const value = normalizeToken6(process.env[key]);
13041
- if (value) {
13042
- return { token: value, source: "env", profile, runtimeControllerUrl: null };
13043
- }
13044
- }
13045
- if (stored) {
13046
- return { token: stored, source: "config", profile, runtimeControllerUrl: null };
13047
- }
13048
- return { token: null, source: "none", profile, runtimeControllerUrl: null };
13049
- }
13050
- function parseKeyValue(raw) {
13051
- const trimmed = raw.trim();
13052
- if (!trimmed) {
13053
- throw new Error("Key/value pair cannot be empty");
13054
- }
13055
- const equals = trimmed.indexOf("=");
13056
- if (equals === -1) {
13057
- return { key: trimmed, value: "" };
13058
- }
13059
- const key = trimmed.slice(0, equals).trim();
13060
- const value = trimmed.slice(equals + 1).trim();
13061
- if (!key) {
13062
- throw new Error(`Invalid pair "${raw}" (missing key)`);
13063
- }
13064
- return { key, value };
13065
- }
13066
- function parseHeader(raw) {
13067
- const trimmed = raw.trim();
13068
- if (!trimmed) {
13069
- throw new Error("Header cannot be empty");
13070
- }
13071
- const colon = trimmed.indexOf(":");
13072
- if (colon !== -1) {
13073
- const key = trimmed.slice(0, colon).trim();
13074
- const value = trimmed.slice(colon + 1).trim();
13075
- if (!key) {
13076
- throw new Error(`Invalid header "${raw}" (missing name)`);
13077
- }
13078
- return { key, value };
13079
- }
13080
- return parseKeyValue(trimmed);
13081
- }
13082
- function parseJsonBody(options) {
13083
- if (options.jsonFile) {
13084
- const raw = fs15.readFileSync(options.jsonFile, "utf8");
13085
- const parsed = JSON.parse(raw);
13086
- return JSON.stringify(parsed);
13087
- }
13088
- if (options.json) {
13089
- const parsed = JSON.parse(options.json);
13090
- return JSON.stringify(parsed);
13091
- }
13092
- return void 0;
13093
- }
13094
- function buildRequestUrl(options, runtimeControllerUrl) {
13095
- const selectedControllerUrl = options.controllerUrl ?? runtimeControllerUrl;
13096
- const base = new URL(
13097
- `${normalizeUrl5(resolveControllerUrl({ controllerUrl: selectedControllerUrl ?? null }))}/`
13098
- );
13099
- if (base.protocol !== "http:" && base.protocol !== "https:") {
13100
- throw new Error("The configured controller URL must use http or https.");
13101
- }
13102
- if (base.username || base.password) {
13103
- throw new Error("The configured controller URL must not contain embedded credentials.");
13104
- }
13105
- if (runtimeControllerUrl) {
13106
- resolveRuntimeBoundControllerUrl(
13107
- { controllerUrl: runtimeControllerUrl },
13108
- base.toString()
13109
- );
13110
- }
13111
- const rawPath = options.path.trim();
13112
- if (!rawPath) {
13113
- throw new Error("Path is required");
13114
- }
13115
- const isAbsoluteReference = /^[a-z][a-z\d+.-]*:/i.test(rawPath) || rawPath.startsWith("//");
13116
- const url = new URL(
13117
- isAbsoluteReference ? rawPath : rawPath.startsWith("/") ? rawPath : `/${rawPath}`,
13118
- base
13119
- );
13120
- if (url.protocol !== "http:" && url.protocol !== "https:") {
13121
- throw new Error("Controller API URLs must use http or https.");
13122
- }
13123
- if (url.username || url.password) {
13124
- throw new Error("Controller API URLs must not contain embedded credentials.");
13125
- }
13126
- const crossOrigin = url.origin !== base.origin;
13127
- if (crossOrigin && !options.allowCrossOrigin) {
13128
- throw new Error(
13129
- "Refusing a cross-origin controller API request. Set --server-url to that origin and use a relative path, or pass --allow-cross-origin only when you explicitly intend to send credentials and request data there."
13130
- );
13131
- }
13132
- for (const pair of options.query ?? []) {
13133
- const { key, value } = parseKeyValue(pair);
13134
- url.searchParams.set(key, value);
13135
- }
13136
- return { url, crossOrigin };
13137
- }
13138
- function maybePrettyPrintJson(text2, pretty) {
13139
- if (!pretty) return text2;
13140
- if (!text2.trim()) return text2;
13141
- try {
13142
- return JSON.stringify(JSON.parse(text2), null, 2);
13143
- } catch {
13144
- return text2;
13145
- }
13146
- }
13147
- async function executeControllerApiRequest(options, bodyOverride) {
13148
- const resolved = resolveBearerTokenWithSource(options);
13149
- const { url, crossOrigin } = buildRequestUrl(options, resolved.runtimeControllerUrl);
13150
- const bearer = resolved.token;
13151
- if (crossOrigin && (!bearer || resolved.source !== "explicit")) {
13152
- throw new Error(
13153
- "Cross-origin controller API requests require --allow-cross-origin and an explicit --access-token."
13154
- );
13155
- }
13156
- const headers = new Headers();
13157
- headers.set("accept", "application/json");
13158
- for (const header of options.headers ?? []) {
13159
- const { key, value } = parseHeader(header);
13160
- headers.set(key, value);
13161
- }
13162
- const body = bodyOverride ?? parseJsonBody(options);
13163
- if (body !== void 0 && !headers.has("content-type")) {
13164
- headers.set("content-type", "application/json");
13165
- }
13166
- const init = {
13167
- method: options.method,
13168
- headers,
13169
- body,
13170
- redirect: "error"
13171
- };
13172
- const response = bearer ? (await fetchWithControllerAuth({
13173
- url: url.toString(),
13174
- init,
13175
- accessToken: bearer,
13176
- tokenSource: resolved.source,
13177
- profile: resolved.profile,
13178
- cwd: process.cwd()
13179
- })).response : await fetch(url, init);
13180
- const responseText = await response.text().catch(() => "");
13181
- const contentType = response.headers.get("content-type") ?? "";
13182
- const isJson = contentType.includes("application/json") || contentType.includes("+json");
13183
- if (!response.ok) {
13184
- if (response.status === 401 || response.status === 403) {
13185
- throw formatAuthRejectedError({
13186
- status: response.status,
13187
- responseBody: responseText,
13188
- retryCommand: "instafy login",
13189
- advancedHint: "pass --access-token, or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN"
13190
- });
13191
- }
13192
- const formattedBody = isJson ? maybePrettyPrintJson(responseText, true) : responseText;
13193
- const prefix = `Request failed (${response.status} ${response.statusText})`;
13194
- const suffix = formattedBody.trim() ? `: ${formattedBody}` : "";
13195
- throw new Error(`${prefix}${suffix}`);
13196
- }
13197
- return { response, responseText, isJson };
13198
- }
13199
- async function requestControllerApiJson(options) {
13200
- const body = options.jsonBody === void 0 ? void 0 : JSON.stringify(options.jsonBody);
13201
- const { responseText, isJson } = await executeControllerApiRequest(
13202
- {
13203
- method: options.method,
13204
- path: options.path,
13205
- controllerUrl: options.controllerUrl,
13206
- accessToken: options.accessToken,
13207
- serviceToken: options.serviceToken,
13208
- query: options.query,
13209
- headers: options.headers,
13210
- allowCrossOrigin: options.allowCrossOrigin
13211
- },
13212
- body
13213
- );
13214
- if (!isJson) {
13215
- throw new Error("Expected JSON response from controller API");
13216
- }
13217
- return JSON.parse(responseText);
13218
- }
13219
- async function requestControllerApi(options) {
13220
- const { responseText, isJson } = await executeControllerApiRequest(options);
13221
- const pretty = options.pretty !== false;
13222
- const formattedBody = isJson ? maybePrettyPrintJson(responseText, pretty) : responseText;
13223
- if (formattedBody) console.log(formattedBody);
13224
- }
13225
-
13226
13794
  // src/conversations.ts
13227
13795
  init_project_manifest();
13228
13796
  import kleur11 from "kleur";
@@ -14773,10 +15341,10 @@ function projectId(explicit) {
14773
15341
  if (!value?.trim()) throw new Error("No space configured. Pass --space, set SPACE_ID, or run `instafy space init`.");
14774
15342
  return uuid(value, "Space ID");
14775
15343
  }
14776
- function object(value, fields, label) {
15344
+ function object2(value, fields2, label) {
14777
15345
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
14778
15346
  const record2 = value;
14779
- if (Object.keys(record2).some((key) => !fields.includes(key))) throw new Error(`${label} contains an unsupported field.`);
15347
+ if (Object.keys(record2).some((key) => !fields2.includes(key))) throw new Error(`${label} contains an unsupported field.`);
14780
15348
  return record2;
14781
15349
  }
14782
15350
  function text(value, label, max) {
@@ -14831,7 +15399,7 @@ async function readProposal(file) {
14831
15399
  } catch {
14832
15400
  throw new Error("Recommendation file must contain valid JSON.");
14833
15401
  }
14834
- const input3 = object(parsed, ["key", "title", "reason", "prompt", "message", "evidence"], "Recommendation");
15402
+ const input3 = object2(parsed, ["key", "title", "reason", "prompt", "message", "evidence"], "Recommendation");
14835
15403
  const key = text(input3.key, "Recommendation key", 120);
14836
15404
  if (!/^[a-z0-9][a-z0-9_-]{0,119}$/.test(key)) throw new Error("Recommendation key must be a lowercase slug using letters, digits, hyphens or underscores.");
14837
15405
  if (!Array.isArray(input3.evidence) || input3.evidence.length < 1 || input3.evidence.length > 8) {
@@ -14844,7 +15412,7 @@ async function readProposal(file) {
14844
15412
  prompt: text(input3.prompt, "Recommendation prompt", 4e3),
14845
15413
  ...input3.message === void 0 ? {} : { message: text(input3.message, "Recommendation message", 4e3) },
14846
15414
  evidence: input3.evidence.map((value) => {
14847
- const entry = object(value, ["conversationId", "messageId"], "Evidence");
15415
+ const entry = object2(value, ["conversationId", "messageId"], "Evidence");
14848
15416
  return {
14849
15417
  conversationId: uuid(entry.conversationId, "Evidence conversationId"),
14850
15418
  ...entry.messageId === void 0 ? {} : { messageId: uuid(entry.messageId, "Evidence messageId") }
@@ -16397,7 +16965,7 @@ async function diagnosticsRunResult(options) {
16397
16965
 
16398
16966
  // src/support.ts
16399
16967
  import fs19 from "node:fs";
16400
- import { randomUUID as randomUUID3 } from "node:crypto";
16968
+ import { randomUUID as randomUUID4 } from "node:crypto";
16401
16969
  import path20 from "node:path";
16402
16970
  init_project_manifest();
16403
16971
  var MAX_SUMMARY_CHARS = 500;
@@ -16690,7 +17258,7 @@ async function supportReport(options) {
16690
17258
  printJson3(preview);
16691
17259
  return;
16692
17260
  }
16693
- const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID3();
17261
+ const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID4();
16694
17262
  if (!UUID2.test(clientRequestId)) {
16695
17263
  throw new Error("--client-request-id must be a UUID.");
16696
17264
  }
@@ -16905,7 +17473,7 @@ async function supportReply(options) {
16905
17473
  `A follow-up must be at most ${MAX_MESSAGE_CHARS} characters and ${MAX_MESSAGE_BYTES} bytes.`
16906
17474
  );
16907
17475
  }
16908
- const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID3();
17476
+ const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID4();
16909
17477
  if (!UUID2.test(clientRequestId)) {
16910
17478
  throw new Error("--client-request-id must be a UUID.");
16911
17479
  }
@@ -18349,19 +18917,43 @@ function exitWithPreviewError(error) {
18349
18917
  var previewCommand = program.command("preview").description("Look at sites running in this space, and show them in Studio as a private preview card");
18350
18918
  failWithGroupHelp(previewCommand);
18351
18919
  registerPreviewCheckCommand(previewCommand, { hasOpenPreview: hasOpenPreviewFor, uploadThumbnail: uploadCheckThumbnail });
18352
- failWithGroupHelp(registerWebSearchCommand(registerWebLinkCommand(registerWebCommand(program))));
18920
+ var webCommand = registerWebSearchCommand(registerWebLinkCommand(registerWebCommand(program)));
18921
+ failWithGroupHelp(webCommand);
18922
+ var webSettingsCommand = webCommand.command("settings").description("Inspect or change the space's shared research budget for new tasks");
18923
+ addSpaceOption(webSettingsCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18924
+ addServerUrlOptions(webSettingsCommand);
18925
+ addAccessTokenOptions(webSettingsCommand, "Instafy access token");
18926
+ webSettingsCommand.option("--max-page-loads <n>", "Fresh pages allowed across the parent and all helpers").option("--max-page-loads-per-site <n>", "Fresh pages allowed from one site across the task").option("--interactive", "Interactively edit the shared research budget").option("--json", "Output JSON (noninteractive)").action(async (opts) => {
18927
+ try {
18928
+ await researchSettings({
18929
+ project: resolveSpaceIdOption(opts),
18930
+ controllerUrl: opts.serverUrl,
18931
+ accessToken: opts.accessToken,
18932
+ maxPageLoads: opts.maxPageLoads,
18933
+ maxPageLoadsPerSite: opts.maxPageLoadsPerSite,
18934
+ interactive: opts.interactive,
18935
+ json: opts.json
18936
+ });
18937
+ } catch (error) {
18938
+ console.error(kleur20.red(String(error)));
18939
+ process.exit(1);
18940
+ }
18941
+ });
18353
18942
  var previewOpenCommand = previewCommand.command("open").description(
18354
- "Open a private preview of a local port (exit 2: nothing listening, 3: port not allowed, 5: previews not available)"
18355
- ).requiredOption("--port <port>", "Local port the site listens on (1024-65535)").requiredOption("--title <title>", "Card title (at most 120 characters)").option("--path <path>", "Path the preview opens at (default /)").option("--summary <text>", `One line of facts under the card's title, e.g. "Trip \xB7 3 days \xB7 14 places" (at most 80 characters); default: the page's own meta description`).option("--thumbnail <file>", "Use this picture on the card (JPEG, PNG or WebP, at most 512 KiB and 2000\xD72000) instead of taking one").option("--no-thumbnail", "Put no picture of the site on the card");
18943
+ "Open a private preview of a local port (exit 2: nothing listening, 3: port not allowed or another site's, 5: previews not available)"
18944
+ ).option("--port <port>", "Local port the site listens on (1024-65535); with --start, leave it out and a free one is picked").requiredOption("--title <title>", "Card title (at most 120 characters)").option("--path <path>", "Path the preview opens at (default /)").option("--summary <text>", `One line of facts under the card's title, e.g. "Trip \xB7 3 days \xB7 14 places" (at most 80 characters); default: the page's own meta description`).option("--thumbnail <file>", "Use this picture on the card (JPEG, PNG or WebP, at most 512 KiB and 2000\xD72000) instead of taking one").option("--no-thumbnail", "Put no picture of the site on the card");
18356
18945
  addSpaceOption(previewOpenCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18357
18946
  addServerUrlOptions(previewOpenCommand);
18358
18947
  addAccessTokenOptions(previewOpenCommand, "Instafy access token (defaults to the job token, then the saved `instafy login` token)");
18359
18948
  previewOpenCommand.option("--conversation <id>", "Conversation for the card (only needed without a job token)").option("--rathole-bin <path>", "Path to rathole binary (or set RATHOLE_BIN)").option(
18360
18949
  "--serve-cmd <command>",
18361
- "How to start the site again when the process that started it exits, or the space sleeps, run from the current directory (one command, no shell); default: read from the process listening on the port"
18950
+ "How to start the site, and to start it again when the process that started it exits or the space sleeps, run from the current directory (one command, no shell); {port} in it is the site's port; default: read from the process listening on the port"
18362
18951
  ).option(
18363
18952
  "--start",
18364
- "Start --serve-cmd in the background, in its own session, without Instafy credentials, logging to .instafy/agent/logs, and wait for the port (nothing is started when the port already answers)"
18953
+ "Start --serve-cmd in the background, in its own session, without Instafy credentials, logging to .instafy/agent/logs, and wait for the port. Without --port it picks a free port (this same site's earlier one, when it has one) and puts it in {port}. A port where this same site already runs is used as it is; one where another site runs is refused"
18954
+ ).option(
18955
+ "--replace",
18956
+ "Show this site on the port even when the port's open preview shows another site (another title or folder); without it that open is refused"
18365
18957
  ).option("--json", "Output JSON").action(async (opts) => {
18366
18958
  try {
18367
18959
  const result = await previewOpen({
@@ -18375,6 +18967,7 @@ previewOpenCommand.option("--conversation <id>", "Conversation for the card (onl
18375
18967
  ratholeBin: opts.ratholeBin,
18376
18968
  serveCmd: opts.serveCmd,
18377
18969
  start: Boolean(opts.start),
18970
+ replace: Boolean(opts.replace),
18378
18971
  summary: opts.summary,
18379
18972
  thumbnail: opts.thumbnail !== false,
18380
18973
  thumbnailFile: typeof opts.thumbnail === "string" ? opts.thumbnail : void 0
@@ -18400,7 +18993,7 @@ previewListCommand.option("--json", "Output JSON").action(async (opts) => {
18400
18993
  exitWithPreviewError(error);
18401
18994
  }
18402
18995
  });
18403
- var previewCloseCommand = previewCommand.command("close").description("Close a preview: stop its local client and revoke it").argument("<previewId>", "Preview ID (from `instafy preview list`)");
18996
+ var previewCloseCommand = previewCommand.command("close").description("Close a preview: stop its local client and the site `open --start` started for it, and revoke it").argument("<previewId>", "Preview ID (from `instafy preview list`)");
18404
18997
  addSpaceOption(previewCloseCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18405
18998
  addServerUrlOptions(previewCloseCommand);
18406
18999
  addAccessTokenOptions(previewCloseCommand, "Instafy access token (defaults to the job token, then the saved `instafy login` token)");
@@ -18416,7 +19009,7 @@ previewCloseCommand.option("--json", "Output JSON").action(async (previewId, opt
18416
19009
  console.log(JSON.stringify(result, null, 2));
18417
19010
  return;
18418
19011
  }
18419
- console.log(kleur20.green(`Preview closed: ${result.previewId}`));
19012
+ console.log(kleur20.green(`Preview closed: ${result.previewId}${result.siteStopped ? "; its site was stopped" : ""}`));
18420
19013
  } catch (error) {
18421
19014
  exitWithPreviewError(error);
18422
19015
  }