@instafy/cli 0.4.1 → 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,6 +8477,7 @@ 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
  };
@@ -8499,7 +8600,7 @@ async function readOne(rawUrl, options, ctx) {
8499
8600
  status: "skipped",
8500
8601
  code,
8501
8602
  message,
8502
- 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]
8503
8604
  });
8504
8605
  let sibling = wwwSibling(requestedUrl);
8505
8606
  const fail = (code, message, extra2 = {}) => ({
@@ -8531,15 +8632,24 @@ async function readOne(rawUrl, options, ctx) {
8531
8632
  return present(page, cached, true, options, ctx.guard);
8532
8633
  }
8533
8634
  }
8534
- if (earlier.loads >= LIMITS.loadsPerTurn) {
8535
- return skip("turn_limit", `this turn already loaded ${earlier.loads} pages (limit ${LIMITS.loadsPerTurn})`);
8536
- }
8537
- if (earlier.hostLoads >= LIMITS.loadsPerHost) {
8538
- 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
+ }
8539
8648
  }
8540
8649
  if (ctx.deadline - ctx.now() < 1e3) return skip("time_limit", "this call reached its time limit before this URL");
8541
8650
  const redirects = [];
8542
8651
  const chainSites = /* @__PURE__ */ new Set();
8652
+ const loadId = randomUUID2();
8543
8653
  let current = new URL(requested);
8544
8654
  let response = null;
8545
8655
  for (let hop = 0; ; hop += 1) {
@@ -8564,7 +8674,21 @@ async function readOne(rawUrl, options, ctx) {
8564
8674
  const robots = await robotsVerdict(current, ctx);
8565
8675
  if ("failed" in robots) return fail(robots.failed, via + robots.message, hopExtra);
8566
8676
  if (robots.refused) return stop("robots", via + robots.refused, host2, hopExtra);
8567
- 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)) {
8568
8692
  const now = ctx.now();
8569
8693
  const reservation = await ctx.turn.update((state) => {
8570
8694
  if (hop === 0 && state.loads >= LIMITS.loadsPerTurn) {
@@ -8775,6 +8899,7 @@ async function webRead(options, deps = {}) {
8775
8899
  now,
8776
8900
  sleep: deps.sleep ?? realSleep,
8777
8901
  turn,
8902
+ sharedBudget: sharedResearchBudget(env, deps.budgetFetch),
8778
8903
  cache: new Cache(cacheDir === null ? null : apart(cacheDir)),
8779
8904
  guard,
8780
8905
  viewer,
@@ -8782,32 +8907,38 @@ async function webRead(options, deps = {}) {
8782
8907
  robotsMemo: /* @__PURE__ */ new Map()
8783
8908
  };
8784
8909
  const pages = [];
8785
- for (const url of urls) {
8786
- const page = await readOne(url, { maxChars, links, fresh: Boolean(options.fresh), terms }, ctx);
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
+ }
8787
8926
  if (page.status === "failed" && unanswered(page.code, page)) {
8788
8927
  const key = unansweredKey(new URL(url));
8789
- await turn.update((state2) => {
8790
- (state2.unanswered ??= {})[key] = now();
8928
+ await turn.update((state) => {
8929
+ (state.unanswered ??= {})[key] = now();
8791
8930
  });
8792
8931
  }
8793
8932
  pages.push(page);
8794
8933
  }
8795
- const state = await turn.update((s) => ({ loads: s.loads, hosts: Object.fromEntries(Object.entries(s.hosts).map(([h, r]) => [h, r.loads])) }));
8934
+ const budget = interruptedBudget ? lastKnownBudget(interruptedBudget, warnings) : ctx.sharedBudget ? await finalSharedBudget(ctx.sharedBudget, deadline - now(), warnings) : await localBudget(turn, scope.label);
8796
8935
  return {
8797
8936
  ok: true,
8798
8937
  viewer,
8799
8938
  userAgent: USER_AGENT,
8800
8939
  notice: NOTICE,
8801
8940
  pages,
8802
- budget: {
8803
- scope: scope.label,
8804
- loadsUsed: state.loads,
8805
- loadsLeft: Math.max(0, LIMITS.loadsPerTurn - state.loads),
8806
- loadsPerTurn: LIMITS.loadsPerTurn,
8807
- loadsPerHost: LIMITS.loadsPerHost,
8808
- hostSpacingSeconds: LIMITS.hostSpacingMs / 1e3,
8809
- hosts: state.hosts
8810
- },
8941
+ budget,
8811
8942
  guards: {
8812
8943
  linkOnly: {
8813
8944
  sites: guard.list.sites.length,
@@ -8821,6 +8952,63 @@ async function webRead(options, deps = {}) {
8821
8952
  warnings
8822
8953
  };
8823
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
+ }
8824
9012
  function clamp(value, min, max) {
8825
9013
  return Math.min(max, Math.max(min, Math.trunc(value)));
8826
9014
  }
@@ -8848,13 +9036,13 @@ function formatWebRead(report) {
8848
9036
  }
8849
9037
  out.push("");
8850
9038
  }
8851
- 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}.`);
8852
9040
  for (const warning of report.warnings) out.push(`warning: ${warning}`);
8853
9041
  out.push(report.notice);
8854
9042
  return out.join("\n");
8855
9043
  }
8856
9044
  async function runWebReadCli(urls, opts, io = { out: console.log, err: console.error }, deps = {}) {
8857
- const whole = (raw, name, min, max) => {
9045
+ const whole2 = (raw, name, min, max) => {
8858
9046
  if (raw === void 0) return void 0;
8859
9047
  const n = Number(raw);
8860
9048
  if (!Number.isInteger(n) || n < min || n > max) throw new WebReadUsageError(`${name} must be a whole number from ${min} to ${max}.`);
@@ -8865,8 +9053,8 @@ async function runWebReadCli(urls, opts, io = { out: console.log, err: console.e
8865
9053
  {
8866
9054
  urls,
8867
9055
  find: opts.find,
8868
- maxChars: whole(opts.maxChars, "--max-chars", LIMITS.minChars, LIMITS.maxChars),
8869
- 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),
8870
9058
  fresh: opts.fresh
8871
9059
  },
8872
9060
  deps
@@ -8876,7 +9064,7 @@ async function runWebReadCli(urls, opts, io = { out: console.log, err: console.e
8876
9064
  } catch (error) {
8877
9065
  const usage = error instanceof WebReadUsageError;
8878
9066
  const code = usage ? WEB_READ_EXIT_USAGE : WEB_READ_EXIT_INTERNAL;
8879
- 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)}`;
8880
9068
  if (opts.json) io.out(JSON.stringify({ ok: false, exitCode: code, error: message }));
8881
9069
  else io.err(message);
8882
9070
  return code;
@@ -8887,10 +9075,13 @@ function registerWebCommand(program2) {
8887
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)"
8888
9076
  );
8889
9077
  web.command("read").description(
8890
- `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.`
8891
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) => {
8892
9080
  process.exitCode = await runWebReadCli(urls, opts);
8893
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
+ });
8894
9085
  return web;
8895
9086
  }
8896
9087
 
@@ -9314,6 +9505,18 @@ async function readPageDescription(frame) {
9314
9505
  const raw = await frame.evaluate(() => document.querySelector('meta[name="description" i]')?.getAttribute("content") ?? null).catch(() => null);
9315
9506
  return cleanDescription(raw);
9316
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
+ }
9317
9520
  async function pageThumbnail(page, context) {
9318
9521
  const cdp = await context.newCDPSession(page);
9319
9522
  try {
@@ -9519,10 +9722,19 @@ async function checkViewport(browser, target, vp, siteOrigin, outDir, timeoutMs,
9519
9722
  (ours(u) ? errors : warnings).push(`HTTP ${s}: ${shortUrl(u)}`);
9520
9723
  });
9521
9724
  await page.goto(target.pageUrl, { waitUntil: "load", timeout: timeoutMs });
9522
- await page.waitForLoadState("networkidle", { timeout: Math.min(8e3, timeoutMs) }).catch(() => {
9523
- warnings.push("the network was still busy after 8 s (screenshot taken anyway)");
9524
- });
9525
- 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();
9526
9738
  let framed = "ok";
9527
9739
  let snap = { text: 0, media: 0, elements: 0, title: "" };
9528
9740
  if (refused) framed = "refused";
@@ -9557,7 +9769,7 @@ async function checkViewport(browser, target, vp, siteOrigin, outDir, timeoutMs,
9557
9769
  let thumbnail;
9558
9770
  if (takeThumbnail) {
9559
9771
  const [firstUnstopped] = unstopped;
9560
- 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)}` }));
9561
9773
  }
9562
9774
  return {
9563
9775
  name: vp.name,
@@ -9767,6 +9979,8 @@ function thumbnailBudgetMs(env = process.env) {
9767
9979
  }
9768
9980
  var NETWORK_IDLE_MS = 6e3;
9769
9981
  var SETTLE_MS = 1e3;
9982
+ var READY_SETTLE_MS = 300;
9983
+ var SHOT_RESERVE_MS = 3e3;
9770
9984
  var MAX_THUMBNAIL_FILE_BYTES = 512 * 1024;
9771
9985
  var MAX_HTML_BYTES = 512 * 1024;
9772
9986
  var MIN_SIDE = 64;
@@ -9923,7 +10137,8 @@ function errorLine(error) {
9923
10137
  async function captureSiteThumbnail(options) {
9924
10138
  const env = options.env ?? process.env;
9925
10139
  const budget = options.budgetMs ?? thumbnailBudgetMs(env);
9926
- const deadline = Date.now() + budget;
10140
+ const began = Date.now();
10141
+ let deadline = began + budget;
9927
10142
  const host = options.host === "::1" ? "[::1]" : "127.0.0.1";
9928
10143
  const siteOrigin = `http://${host}:${options.port}`;
9929
10144
  const left = () => Math.max(0, deadline - Date.now());
@@ -9985,9 +10200,18 @@ async function captureSiteThumbnail(options) {
9985
10200
  if (response && response.status() >= 400) {
9986
10201
  return { ok: false, status: "skipped", reason: `the page answered HTTP ${response.status()}` };
9987
10202
  }
9988
- await page.waitForLoadState("networkidle", { timeout: Math.max(1, Math.min(NETWORK_IDLE_MS, left())) }).catch(() => {
9989
- });
9990
- 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
+ }
9991
10215
  const description = await readPageDescription(page.mainFrame());
9992
10216
  const shot = await pageThumbnail(page, context);
9993
10217
  if ("skipped" in shot) return { ok: false, status: "skipped", reason: shot.skipped };
@@ -10002,7 +10226,16 @@ async function captureSiteThumbnail(options) {
10002
10226
  };
10003
10227
  })();
10004
10228
  const outOfTime = new Promise((resolve) => {
10005
- 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();
10006
10239
  });
10007
10240
  try {
10008
10241
  return await Promise.race([work.catch((error) => ({ ok: false, status: "failed", reason: errorLine(error) })), outOfTime]);
@@ -10026,6 +10259,12 @@ var MAX_PATH_LENGTH2 = 512;
10026
10259
  var THUMBNAIL_UPLOAD_TIMEOUT_MS = 15e3;
10027
10260
  var STATE_LOCK_WAIT_MS = 12e4;
10028
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;
10029
10268
  function instafyDir() {
10030
10269
  return path13.join(os7.homedir(), ".instafy");
10031
10270
  }
@@ -10249,6 +10488,12 @@ async function controllerRequest(params) {
10249
10488
  PREVIEW_EXIT_PORT_NOT_ALLOWED
10250
10489
  );
10251
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
+ }
10252
10497
  const message = extractControllerErrorMessage(text2) ?? response.statusText;
10253
10498
  throw new Error(`Preview request failed (${response.status}): ${message}`);
10254
10499
  }
@@ -10457,12 +10702,12 @@ function listeningSocketInodes(procRoot, port) {
10457
10702
  continue;
10458
10703
  }
10459
10704
  for (const line of text2.split("\n").slice(1)) {
10460
- const fields = line.trim().split(/\s+/);
10461
- if (fields.length < 10) continue;
10462
- const local = fields[1] ?? "";
10705
+ const fields2 = line.trim().split(/\s+/);
10706
+ if (fields2.length < 10) continue;
10707
+ const local = fields2[1] ?? "";
10463
10708
  const portHex = local.slice(local.lastIndexOf(":") + 1);
10464
- if (fields[3] === "0A" && Number.parseInt(portHex, 16) === port && fields[9] !== "0") {
10465
- inodes.add(fields[9]);
10709
+ if (fields2[3] === "0A" && Number.parseInt(portHex, 16) === port && fields2[9] !== "0") {
10710
+ inodes.add(fields2[9]);
10466
10711
  }
10467
10712
  }
10468
10713
  }
@@ -10638,8 +10883,159 @@ async function startPreviewSite(params) {
10638
10883
  await new Promise((resolve) => setTimeout(resolve, START_POLL_MS));
10639
10884
  }
10640
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
+ }
10641
11037
  async function createAndStartClient(params) {
10642
- 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;
10643
11039
  const conversationId = opts.conversationId?.trim() || void 0;
10644
11040
  const created = await controllerRequest({
10645
11041
  controllerUrl,
@@ -10655,10 +11051,12 @@ async function createAndStartClient(params) {
10655
11051
  title,
10656
11052
  ...opts.summary ? { summary: opts.summary } : {},
10657
11053
  metadata: { source: "instafy-cli" },
10658
- ...serve ? { serve } : {}
11054
+ ...serve ? { serve } : {},
11055
+ ...opts.replace ? { replace: true } : {}
10659
11056
  },
10660
11057
  retryCommand,
10661
- notFoundMeansUnavailable: true
11058
+ notFoundMeansUnavailable: true,
11059
+ ...portPicked ? { portTakenHint: PICKED_PORT_TAKEN_HINT } : {}
10662
11060
  });
10663
11061
  const grant = created.body && !Array.isArray(created.body) ? created.body : {};
10664
11062
  const previewId = stringField(grant, "previewId");
@@ -10672,6 +11070,7 @@ async function createAndStartClient(params) {
10672
11070
  const studioOrigin = parseStudioOrigin(grant["studioOrigin"]);
10673
11071
  const state = readStateFile2();
10674
11072
  const current = state.previews.find((preview) => preview.previewId === previewId) ?? null;
11073
+ const siteRecord = siteRecordFor(site, state.previews, port, projectId2);
10675
11074
  let pid = null;
10676
11075
  let binary = current?.binary;
10677
11076
  try {
@@ -10735,47 +11134,84 @@ ${started.logTail}` : "";
10735
11134
  startedAt: current?.pid === pid ? current.startedAt : (/* @__PURE__ */ new Date()).toISOString(),
10736
11135
  ...binary ? { binary } : {},
10737
11136
  ...hostSuffix ? { hostSuffix } : {},
10738
- ...studioOrigin ? { studioOrigin } : {}
11137
+ ...studioOrigin ? { studioOrigin } : {},
11138
+ ...siteRecord
10739
11139
  };
10740
11140
  upsertPreviewState(entry);
10741
11141
  return { grant, previewId, entry, current };
10742
11142
  }
10743
11143
  async function previewOpen(opts) {
10744
- const port = parsePreviewPort(opts.port);
11144
+ const explicitPort = opts.port === void 0 || String(opts.port).trim() === "" ? null : parsePreviewPort(opts.port);
10745
11145
  const title = parsePreviewTitle(opts.title);
10746
11146
  const sitePath = parsePreviewPath(opts.path);
10747
11147
  const summary = parsePreviewSummary(opts.summary);
10748
- 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
+ }
10749
11162
  const projectId2 = resolveProject({ project: opts.project });
10750
- const retryCommand = `instafy preview open --port ${port} --title ${shellQuote2(title)}`;
11163
+ const retryCommand = `instafy preview open${explicitPort === null ? "" : ` --port ${explicitPort}`} --title ${shellQuote2(title)}`;
10751
11164
  const { auth, controllerUrl } = resolveAuth(opts, retryCommand);
10752
11165
  const serverUrl = cleanUrl(controllerUrl);
10753
11166
  const log = (message) => console.error(kleur8.cyan(message));
10754
- const explicitServe = opts.start && opts.serveCmd?.trim() ? resolvePreviewServe({ port, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd }).serve : null;
10755
- let probe = await probeLocalSite(port, sitePath, void 0, RATHOLE_LOCAL_HOSTS);
10756
- let started = null;
10757
- if (!probe.listening && opts.start && probe.status === null && !probe.timedOut) {
10758
- const ipv6 = await probeLocalSite(port, sitePath, void 0, ["::1"]);
10759
- if (ipv6.status === null && !ipv6.timedOut) {
10760
- if (!explicitServe) {
10761
- throw new PreviewCommandError(
10762
- `Nothing answers on 127.0.0.1:${port}, and --start needs --serve-cmd "<command>" to start the site.`,
10763
- PREVIEW_EXIT_NOT_LISTENING
10764
- );
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);
10765
11186
  }
10766
- const root = resolveWorkspaceRoot(process.env, projectId2);
10767
- started = await startPreviewSite({ port, sitePath, serve: explicitServe, root, timeoutMs: opts.startTimeoutMs });
10768
- log(`Started the site (pid ${started.pid}); log: ${displayPath(started.logFile)}`);
10769
- 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;
10770
11213
  }
10771
- }
10772
- if (!probe.listening) {
10773
- throw await notListeningError(port, sitePath, probe);
10774
- }
10775
- const { serve, note: restartNote } = resolvePreviewServe({ port, serveCmd: opts.serveCmd, projectId: projectId2, cwd: opts.cwd });
10776
- const { grant, previewId, entry } = await withStateLock(
10777
- () => createAndStartClient({ opts: { ...opts, summary }, port, title, sitePath, projectId: projectId2, retryCommand, auth, controllerUrl, serverUrl, log, serve })
10778
- );
11214
+ });
10779
11215
  let ready;
10780
11216
  try {
10781
11217
  ready = await controllerRequest({
@@ -10819,7 +11255,7 @@ async function previewOpen(opts) {
10819
11255
  viewerHostSuffix: parseViewerHostSuffix(grant["viewerHostSuffix"]) ?? null,
10820
11256
  restartable,
10821
11257
  ...restartable ? {} : { restartNote: restartNote ?? "the server did not record a start command" },
10822
- ...opts.start ? { startedPid: started?.pid ?? null, logFile: started?.logFile ?? null } : {},
11258
+ ...opts.start ? { startedPid: site?.started?.pid ?? null, logFile: site?.started?.logFile ?? null } : {},
10823
11259
  thumbnail: thumbnail.status,
10824
11260
  ...thumbnail.reason ? { thumbnailReason: thumbnail.reason } : {}
10825
11261
  };
@@ -10914,9 +11350,13 @@ async function previewClose(opts) {
10914
11350
  if (!previewId) {
10915
11351
  throw new Error("Preview id is required. Use `instafy preview list` to find it.");
10916
11352
  }
11353
+ let siteStopped = false;
10917
11354
  const entry = await withStateLock(async () => {
10918
11355
  const removed = removePreviewState(previewId);
10919
- if (removed) await stopClient(removed);
11356
+ if (removed) {
11357
+ await stopClient(removed);
11358
+ siteStopped = await stopSite(removed.startedPid, removed.startedCommand);
11359
+ }
10920
11360
  return removed;
10921
11361
  });
10922
11362
  const projectId2 = entry?.projectId ?? resolveProject({ project: opts.project });
@@ -10927,11 +11367,14 @@ async function previewClose(opts) {
10927
11367
  );
10928
11368
  await closePreviewOnServer(controllerUrl, auth, projectId2, previewId);
10929
11369
  } catch (error) {
10930
- 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
+ }
10931
11374
  throw error;
10932
11375
  }
10933
11376
  removeWorkdir(previewId);
10934
- return { ok: true, previewId };
11377
+ return { ok: true, previewId, siteStopped };
10935
11378
  }
10936
11379
  function printPreviewOpen(result, json) {
10937
11380
  if (json) {
@@ -10939,7 +11382,7 @@ function printPreviewOpen(result, json) {
10939
11382
  return;
10940
11383
  }
10941
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)");
10942
- 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.`);
10943
11386
  if (result.thumbnail === "uploaded") {
10944
11387
  console.log(kleur8.gray("The card shows a picture of the site; `instafy preview check` takes a new one after a change."));
10945
11388
  } else {
@@ -10948,7 +11391,7 @@ function printPreviewOpen(result, json) {
10948
11391
  if (result.startedPid) {
10949
11392
  console.log(kleur8.gray(`Started the site in the background (pid ${result.startedPid}); log: ${displayPath(result.logFile ?? "")}`));
10950
11393
  } else if (result.startedPid === null) {
10951
- 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."));
10952
11395
  }
10953
11396
  if (result.restartable) {
10954
11397
  console.log(kleur8.gray("Open starts the site again when the process that started it exits, or the space sleeps."));
@@ -11115,7 +11558,7 @@ init_config();
11115
11558
 
11116
11559
  // src/git-wrapper.ts
11117
11560
  import { spawnSync as spawnSync5 } from "node:child_process";
11118
- import { randomUUID as randomUUID2 } from "node:crypto";
11561
+ import { randomUUID as randomUUID3 } from "node:crypto";
11119
11562
  import fs13 from "node:fs";
11120
11563
  import path14 from "node:path";
11121
11564
  import kleur10 from "kleur";
@@ -11329,10 +11772,10 @@ function hideEmbeddedGitDirs(context, pathSpecs) {
11329
11772
  fs13.mkdirSync(stagingBase, { recursive: true });
11330
11773
  const renames = [];
11331
11774
  for (const candidate of candidates) {
11332
- let hidden = path14.join(stagingBase, randomUUID2());
11775
+ let hidden = path14.join(stagingBase, randomUUID3());
11333
11776
  for (let attempt2 = 0; attempt2 < 8; attempt2 += 1) {
11334
11777
  if (!pathExists(hidden)) break;
11335
- hidden = path14.join(stagingBase, randomUUID2());
11778
+ hidden = path14.join(stagingBase, randomUUID3());
11336
11779
  }
11337
11780
  fs13.renameSync(candidate, hidden);
11338
11781
  renames.push({ original: candidate, hidden });
@@ -11888,19 +12331,324 @@ function notSavedSentences(report, options) {
11888
12331
  sentences.push(report.recoveryRef ? `Not saved: ${trimmed} (kept at ${report.recoveryRef})` : `Not saved: ${trimmed}`);
11889
12332
  }
11890
12333
  }
11891
- return sentences;
11892
- }
11893
- function saveReportJson(report, notSaved) {
11894
- return {
11895
- rev: report.rev,
11896
- baseRev: report.baseRev,
11897
- gitSyncStatus: report.gitSyncStatus,
11898
- recoveryRef: report.recoveryRef,
11899
- conflictedPaths: report.conflictedPaths,
11900
- rejectedPaths: report.rejectedPaths,
11901
- unpushedRefs: report.unpushedRefs,
11902
- notSaved
11903
- };
12334
+ return sentences;
12335
+ }
12336
+ function saveReportJson(report, notSaved) {
12337
+ return {
12338
+ rev: report.rev,
12339
+ baseRev: report.baseRev,
12340
+ gitSyncStatus: report.gitSyncStatus,
12341
+ recoveryRef: report.recoveryRef,
12342
+ conflictedPaths: report.conflictedPaths,
12343
+ rejectedPaths: report.rejectedPaths,
12344
+ unpushedRefs: report.unpushedRefs,
12345
+ notSaved
12346
+ };
12347
+ }
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.");
11904
12652
  }
11905
12653
 
11906
12654
  // src/web-link.ts
@@ -11971,9 +12719,9 @@ function registerWebLinkCommand(web) {
11971
12719
  // src/web-search.ts
11972
12720
  var redaction2 = __toESM(require_redaction(), 1);
11973
12721
  import dns3 from "node:dns";
11974
- import fs14 from "node:fs";
12722
+ import fs15 from "node:fs";
11975
12723
  import https2 from "node:https";
11976
- import net5 from "node:net";
12724
+ import net6 from "node:net";
11977
12725
  import path15 from "node:path";
11978
12726
  import zlib2 from "node:zlib";
11979
12727
 
@@ -12180,7 +12928,7 @@ function compileProvider(id, raw) {
12180
12928
  if (url.protocol !== "https:") return `${id}: request.url must be https`;
12181
12929
  if (url.username || url.password || url.port && url.port !== "443" || url.hash) return `${id}: request.url must be a plain https address`;
12182
12930
  const host = langInHost ? url.hostname.toLowerCase().replace(/^xx\./, "") : url.hostname.toLowerCase();
12183
- 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`;
12184
12932
  const never = NEVER_HOSTS.find((entry) => host === entry.host || host.endsWith(`.${entry.host}`));
12185
12933
  if (never) return `${id}: ${never.host} is never used (${never.why})`;
12186
12934
  if (/[{}]/.test(decodeURIComponent(url.href))) return `${id}: request.url may not hold placeholders`;
@@ -12303,9 +13051,9 @@ function compileProvider(id, raw) {
12303
13051
  }
12304
13052
  function resultLink(value) {
12305
13053
  if (typeof value === "string" && value.startsWith("https://")) {
12306
- const fields = [...value.matchAll(/\{([\w-]+(?:\.[\w-]+)*)\}/g)];
13054
+ const fields2 = [...value.matchAll(/\{([\w-]+(?:\.[\w-]+)*)\}/g)];
12307
13055
  const rest = value.replace(/\{([\w-]+(?:\.[\w-]+)*)\}/g, "");
12308
- if (!fields.length || /[{}]/.test(rest)) return void 0;
13056
+ if (!fields2.length || /[{}]/.test(rest)) return void 0;
12309
13057
  try {
12310
13058
  new URL(value.replace(/\{[^}]*\}/g, "x"));
12311
13059
  } catch {
@@ -12363,8 +13111,8 @@ function loadSearchProviders(bundled = BUNDLED_SEARCH_PROVIDERS, copies = []) {
12363
13111
  return { providers, chains, off: [...off].filter((id) => id in bundled.providers).sort(), keyEnvs: [...keyEnvs], warnings };
12364
13112
  }
12365
13113
  function fill(template, vars) {
12366
- const whole = /^\{([a-z]+)\}$/.exec(template);
12367
- if (whole && whole[1] === "count") return vars.count;
13114
+ const whole2 = /^\{([a-z]+)\}$/.exec(template);
13115
+ if (whole2 && whole2[1] === "count") return vars.count;
12368
13116
  let missing = false;
12369
13117
  const out = template.replace(/\{\{|\}\}|\{([a-z]+)(?::([a-z]+))?\}/g, (match, name, encoder) => {
12370
13118
  if (!name) return match[0];
@@ -12453,7 +13201,7 @@ function sendOnce(request, target, timeoutMs) {
12453
13201
  port: target.port,
12454
13202
  method: request.method,
12455
13203
  path: `${request.url.pathname}${request.url.search}`,
12456
- servername: net5.isIP(hostname) ? void 0 : hostname,
13204
+ servername: net6.isIP(hostname) ? void 0 : hostname,
12457
13205
  agent: false,
12458
13206
  maxHeaderSize: 64 * 1024,
12459
13207
  headers: { Host: request.url.host, ...request.headers, ...payload ? { "Content-Length": String(payload.length) } : {} }
@@ -12503,8 +13251,8 @@ function at(value, field) {
12503
13251
  }
12504
13252
  return current;
12505
13253
  }
12506
- function firstText(item, fields) {
12507
- for (const field of fields) {
13254
+ function firstText(item, fields2) {
13255
+ for (const field of fields2) {
12508
13256
  const value = at(item, field);
12509
13257
  if (typeof value === "string" && value.trim()) return value;
12510
13258
  }
@@ -12520,10 +13268,10 @@ function withoutTags(text2) {
12520
13268
  return out;
12521
13269
  }
12522
13270
  function cleanText(raw, max) {
12523
- 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) => {
12524
13272
  if (ENTITIES2[name.toLowerCase()]) return ENTITIES2[name.toLowerCase()];
12525
13273
  const code = name[0] === "#" ? name[1]?.toLowerCase() === "x" ? parseInt(name.slice(2), 16) : parseInt(name.slice(1), 10) : NaN;
12526
- 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;
12527
13275
  }).replace(/</g, "\u2039").replace(/[\u0000-\u001f\u007f-\u009f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff]/g, " ").replace(/\s+/g, " ").trim();
12528
13276
  const redacted = redaction2.redactText(text2).text;
12529
13277
  return redacted.length > max ? `${redacted.slice(0, max - 1)}\u2026` : redacted;
@@ -12540,7 +13288,7 @@ function resultAddress(raw) {
12540
13288
  if (url.username || url.password) return null;
12541
13289
  if (url.port && url.port !== "80" && url.port !== "443") return null;
12542
13290
  const host = url.hostname.replace(/^\[|\]$/g, "");
12543
- if (net5.isIP(host) ? isPrivateAddress(host) : isLocalHostName(host)) return null;
13291
+ if (net6.isIP(host) ? isPrivateAddress(host) : isLocalHostName(host)) return null;
12544
13292
  url.hash = "";
12545
13293
  return url;
12546
13294
  }
@@ -12692,10 +13440,10 @@ var SearchState = class {
12692
13440
  /** Removes turn and day files older than two days. */
12693
13441
  prune() {
12694
13442
  try {
12695
- for (const name of fs14.readdirSync(this.dir)) {
13443
+ for (const name of fs15.readdirSync(this.dir)) {
12696
13444
  if (!/^(?:search-turn|day)-[\w-]+\.json$/.test(name)) continue;
12697
13445
  const file = path15.join(this.dir, name);
12698
- 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 });
12699
13447
  }
12700
13448
  } catch {
12701
13449
  }
@@ -13043,229 +13791,6 @@ function registerWebSearchCommand(web) {
13043
13791
  return web;
13044
13792
  }
13045
13793
 
13046
- // src/api.ts
13047
- init_config();
13048
- init_errors();
13049
- init_controller_fetch();
13050
- import fs15 from "node:fs";
13051
- function normalizeUrl5(raw) {
13052
- const value = (raw ?? "").trim();
13053
- if (!value) return "http://127.0.0.1:8788";
13054
- return value.replace(/\/$/, "");
13055
- }
13056
- function normalizeToken6(raw) {
13057
- if (!raw) return null;
13058
- const trimmed = raw.trim();
13059
- return trimmed.length ? trimmed : null;
13060
- }
13061
- function resolveBearerTokenWithSource(options) {
13062
- const cwd = process.cwd();
13063
- const profile = resolveActiveProfileName({ cwd });
13064
- const stored = resolveConfiguredAccessToken({ profile, cwd });
13065
- const explicit = normalizeToken6(options.accessToken);
13066
- if (explicit) {
13067
- return { token: explicit, source: "explicit", profile, runtimeControllerUrl: null };
13068
- }
13069
- const runtimeCredential = resolveRuntimeControllerCredential();
13070
- if (runtimeCredential) {
13071
- return {
13072
- token: runtimeCredential.token,
13073
- source: "env",
13074
- profile: null,
13075
- runtimeControllerUrl: runtimeCredential.controllerUrl
13076
- };
13077
- }
13078
- const envKeys = [
13079
- "INSTAFY_ACCESS_TOKEN",
13080
- "SUPABASE_ACCESS_TOKEN"
13081
- ];
13082
- for (const key of envKeys) {
13083
- const value = normalizeToken6(process.env[key]);
13084
- if (value) {
13085
- return { token: value, source: "env", profile, runtimeControllerUrl: null };
13086
- }
13087
- }
13088
- if (stored) {
13089
- return { token: stored, source: "config", profile, runtimeControllerUrl: null };
13090
- }
13091
- return { token: null, source: "none", profile, runtimeControllerUrl: null };
13092
- }
13093
- function parseKeyValue(raw) {
13094
- const trimmed = raw.trim();
13095
- if (!trimmed) {
13096
- throw new Error("Key/value pair cannot be empty");
13097
- }
13098
- const equals = trimmed.indexOf("=");
13099
- if (equals === -1) {
13100
- return { key: trimmed, value: "" };
13101
- }
13102
- const key = trimmed.slice(0, equals).trim();
13103
- const value = trimmed.slice(equals + 1).trim();
13104
- if (!key) {
13105
- throw new Error(`Invalid pair "${raw}" (missing key)`);
13106
- }
13107
- return { key, value };
13108
- }
13109
- function parseHeader(raw) {
13110
- const trimmed = raw.trim();
13111
- if (!trimmed) {
13112
- throw new Error("Header cannot be empty");
13113
- }
13114
- const colon = trimmed.indexOf(":");
13115
- if (colon !== -1) {
13116
- const key = trimmed.slice(0, colon).trim();
13117
- const value = trimmed.slice(colon + 1).trim();
13118
- if (!key) {
13119
- throw new Error(`Invalid header "${raw}" (missing name)`);
13120
- }
13121
- return { key, value };
13122
- }
13123
- return parseKeyValue(trimmed);
13124
- }
13125
- function parseJsonBody(options) {
13126
- if (options.jsonFile) {
13127
- const raw = fs15.readFileSync(options.jsonFile, "utf8");
13128
- const parsed = JSON.parse(raw);
13129
- return JSON.stringify(parsed);
13130
- }
13131
- if (options.json) {
13132
- const parsed = JSON.parse(options.json);
13133
- return JSON.stringify(parsed);
13134
- }
13135
- return void 0;
13136
- }
13137
- function buildRequestUrl(options, runtimeControllerUrl) {
13138
- const selectedControllerUrl = options.controllerUrl ?? runtimeControllerUrl;
13139
- const base = new URL(
13140
- `${normalizeUrl5(resolveControllerUrl({ controllerUrl: selectedControllerUrl ?? null }))}/`
13141
- );
13142
- if (base.protocol !== "http:" && base.protocol !== "https:") {
13143
- throw new Error("The configured controller URL must use http or https.");
13144
- }
13145
- if (base.username || base.password) {
13146
- throw new Error("The configured controller URL must not contain embedded credentials.");
13147
- }
13148
- if (runtimeControllerUrl) {
13149
- resolveRuntimeBoundControllerUrl(
13150
- { controllerUrl: runtimeControllerUrl },
13151
- base.toString()
13152
- );
13153
- }
13154
- const rawPath = options.path.trim();
13155
- if (!rawPath) {
13156
- throw new Error("Path is required");
13157
- }
13158
- const isAbsoluteReference = /^[a-z][a-z\d+.-]*:/i.test(rawPath) || rawPath.startsWith("//");
13159
- const url = new URL(
13160
- isAbsoluteReference ? rawPath : rawPath.startsWith("/") ? rawPath : `/${rawPath}`,
13161
- base
13162
- );
13163
- if (url.protocol !== "http:" && url.protocol !== "https:") {
13164
- throw new Error("Controller API URLs must use http or https.");
13165
- }
13166
- if (url.username || url.password) {
13167
- throw new Error("Controller API URLs must not contain embedded credentials.");
13168
- }
13169
- const crossOrigin = url.origin !== base.origin;
13170
- if (crossOrigin && !options.allowCrossOrigin) {
13171
- throw new Error(
13172
- "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."
13173
- );
13174
- }
13175
- for (const pair of options.query ?? []) {
13176
- const { key, value } = parseKeyValue(pair);
13177
- url.searchParams.set(key, value);
13178
- }
13179
- return { url, crossOrigin };
13180
- }
13181
- function maybePrettyPrintJson(text2, pretty) {
13182
- if (!pretty) return text2;
13183
- if (!text2.trim()) return text2;
13184
- try {
13185
- return JSON.stringify(JSON.parse(text2), null, 2);
13186
- } catch {
13187
- return text2;
13188
- }
13189
- }
13190
- async function executeControllerApiRequest(options, bodyOverride) {
13191
- const resolved = resolveBearerTokenWithSource(options);
13192
- const { url, crossOrigin } = buildRequestUrl(options, resolved.runtimeControllerUrl);
13193
- const bearer = resolved.token;
13194
- if (crossOrigin && (!bearer || resolved.source !== "explicit")) {
13195
- throw new Error(
13196
- "Cross-origin controller API requests require --allow-cross-origin and an explicit --access-token."
13197
- );
13198
- }
13199
- const headers = new Headers();
13200
- headers.set("accept", "application/json");
13201
- for (const header of options.headers ?? []) {
13202
- const { key, value } = parseHeader(header);
13203
- headers.set(key, value);
13204
- }
13205
- const body = bodyOverride ?? parseJsonBody(options);
13206
- if (body !== void 0 && !headers.has("content-type")) {
13207
- headers.set("content-type", "application/json");
13208
- }
13209
- const init = {
13210
- method: options.method,
13211
- headers,
13212
- body,
13213
- redirect: "error"
13214
- };
13215
- const response = bearer ? (await fetchWithControllerAuth({
13216
- url: url.toString(),
13217
- init,
13218
- accessToken: bearer,
13219
- tokenSource: resolved.source,
13220
- profile: resolved.profile,
13221
- cwd: process.cwd()
13222
- })).response : await fetch(url, init);
13223
- const responseText = await response.text().catch(() => "");
13224
- const contentType = response.headers.get("content-type") ?? "";
13225
- const isJson = contentType.includes("application/json") || contentType.includes("+json");
13226
- if (!response.ok) {
13227
- if (response.status === 401 || response.status === 403) {
13228
- throw formatAuthRejectedError({
13229
- status: response.status,
13230
- responseBody: responseText,
13231
- retryCommand: "instafy login",
13232
- advancedHint: "pass --access-token, or set INSTAFY_ACCESS_TOKEN / SUPABASE_ACCESS_TOKEN"
13233
- });
13234
- }
13235
- const formattedBody = isJson ? maybePrettyPrintJson(responseText, true) : responseText;
13236
- const prefix = `Request failed (${response.status} ${response.statusText})`;
13237
- const suffix = formattedBody.trim() ? `: ${formattedBody}` : "";
13238
- throw new Error(`${prefix}${suffix}`);
13239
- }
13240
- return { response, responseText, isJson };
13241
- }
13242
- async function requestControllerApiJson(options) {
13243
- const body = options.jsonBody === void 0 ? void 0 : JSON.stringify(options.jsonBody);
13244
- const { responseText, isJson } = await executeControllerApiRequest(
13245
- {
13246
- method: options.method,
13247
- path: options.path,
13248
- controllerUrl: options.controllerUrl,
13249
- accessToken: options.accessToken,
13250
- serviceToken: options.serviceToken,
13251
- query: options.query,
13252
- headers: options.headers,
13253
- allowCrossOrigin: options.allowCrossOrigin
13254
- },
13255
- body
13256
- );
13257
- if (!isJson) {
13258
- throw new Error("Expected JSON response from controller API");
13259
- }
13260
- return JSON.parse(responseText);
13261
- }
13262
- async function requestControllerApi(options) {
13263
- const { responseText, isJson } = await executeControllerApiRequest(options);
13264
- const pretty = options.pretty !== false;
13265
- const formattedBody = isJson ? maybePrettyPrintJson(responseText, pretty) : responseText;
13266
- if (formattedBody) console.log(formattedBody);
13267
- }
13268
-
13269
13794
  // src/conversations.ts
13270
13795
  init_project_manifest();
13271
13796
  import kleur11 from "kleur";
@@ -14816,10 +15341,10 @@ function projectId(explicit) {
14816
15341
  if (!value?.trim()) throw new Error("No space configured. Pass --space, set SPACE_ID, or run `instafy space init`.");
14817
15342
  return uuid(value, "Space ID");
14818
15343
  }
14819
- function object(value, fields, label) {
15344
+ function object2(value, fields2, label) {
14820
15345
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
14821
15346
  const record2 = value;
14822
- 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.`);
14823
15348
  return record2;
14824
15349
  }
14825
15350
  function text(value, label, max) {
@@ -14874,7 +15399,7 @@ async function readProposal(file) {
14874
15399
  } catch {
14875
15400
  throw new Error("Recommendation file must contain valid JSON.");
14876
15401
  }
14877
- const input3 = object(parsed, ["key", "title", "reason", "prompt", "message", "evidence"], "Recommendation");
15402
+ const input3 = object2(parsed, ["key", "title", "reason", "prompt", "message", "evidence"], "Recommendation");
14878
15403
  const key = text(input3.key, "Recommendation key", 120);
14879
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.");
14880
15405
  if (!Array.isArray(input3.evidence) || input3.evidence.length < 1 || input3.evidence.length > 8) {
@@ -14887,7 +15412,7 @@ async function readProposal(file) {
14887
15412
  prompt: text(input3.prompt, "Recommendation prompt", 4e3),
14888
15413
  ...input3.message === void 0 ? {} : { message: text(input3.message, "Recommendation message", 4e3) },
14889
15414
  evidence: input3.evidence.map((value) => {
14890
- const entry = object(value, ["conversationId", "messageId"], "Evidence");
15415
+ const entry = object2(value, ["conversationId", "messageId"], "Evidence");
14891
15416
  return {
14892
15417
  conversationId: uuid(entry.conversationId, "Evidence conversationId"),
14893
15418
  ...entry.messageId === void 0 ? {} : { messageId: uuid(entry.messageId, "Evidence messageId") }
@@ -16440,7 +16965,7 @@ async function diagnosticsRunResult(options) {
16440
16965
 
16441
16966
  // src/support.ts
16442
16967
  import fs19 from "node:fs";
16443
- import { randomUUID as randomUUID3 } from "node:crypto";
16968
+ import { randomUUID as randomUUID4 } from "node:crypto";
16444
16969
  import path20 from "node:path";
16445
16970
  init_project_manifest();
16446
16971
  var MAX_SUMMARY_CHARS = 500;
@@ -16733,7 +17258,7 @@ async function supportReport(options) {
16733
17258
  printJson3(preview);
16734
17259
  return;
16735
17260
  }
16736
- const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID3();
17261
+ const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID4();
16737
17262
  if (!UUID2.test(clientRequestId)) {
16738
17263
  throw new Error("--client-request-id must be a UUID.");
16739
17264
  }
@@ -16948,7 +17473,7 @@ async function supportReply(options) {
16948
17473
  `A follow-up must be at most ${MAX_MESSAGE_CHARS} characters and ${MAX_MESSAGE_BYTES} bytes.`
16949
17474
  );
16950
17475
  }
16951
- const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID3();
17476
+ const clientRequestId = cleanText4(options.clientRequestId) ?? randomUUID4();
16952
17477
  if (!UUID2.test(clientRequestId)) {
16953
17478
  throw new Error("--client-request-id must be a UUID.");
16954
17479
  }
@@ -18392,19 +18917,43 @@ function exitWithPreviewError(error) {
18392
18917
  var previewCommand = program.command("preview").description("Look at sites running in this space, and show them in Studio as a private preview card");
18393
18918
  failWithGroupHelp(previewCommand);
18394
18919
  registerPreviewCheckCommand(previewCommand, { hasOpenPreview: hasOpenPreviewFor, uploadThumbnail: uploadCheckThumbnail });
18395
- 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
+ });
18396
18942
  var previewOpenCommand = previewCommand.command("open").description(
18397
- "Open a private preview of a local port (exit 2: nothing listening, 3: port not allowed, 5: previews not available)"
18398
- ).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");
18399
18945
  addSpaceOption(previewOpenCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18400
18946
  addServerUrlOptions(previewOpenCommand);
18401
18947
  addAccessTokenOptions(previewOpenCommand, "Instafy access token (defaults to the job token, then the saved `instafy login` token)");
18402
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(
18403
18949
  "--serve-cmd <command>",
18404
- "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"
18405
18951
  ).option(
18406
18952
  "--start",
18407
- "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"
18408
18957
  ).option("--json", "Output JSON").action(async (opts) => {
18409
18958
  try {
18410
18959
  const result = await previewOpen({
@@ -18418,6 +18967,7 @@ previewOpenCommand.option("--conversation <id>", "Conversation for the card (onl
18418
18967
  ratholeBin: opts.ratholeBin,
18419
18968
  serveCmd: opts.serveCmd,
18420
18969
  start: Boolean(opts.start),
18970
+ replace: Boolean(opts.replace),
18421
18971
  summary: opts.summary,
18422
18972
  thumbnail: opts.thumbnail !== false,
18423
18973
  thumbnailFile: typeof opts.thumbnail === "string" ? opts.thumbnail : void 0
@@ -18443,7 +18993,7 @@ previewListCommand.option("--json", "Output JSON").action(async (opts) => {
18443
18993
  exitWithPreviewError(error);
18444
18994
  }
18445
18995
  });
18446
- 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`)");
18447
18997
  addSpaceOption(previewCloseCommand, "Space UUID (defaults to .instafy/space.json or SPACE_ID)");
18448
18998
  addServerUrlOptions(previewCloseCommand);
18449
18999
  addAccessTokenOptions(previewCloseCommand, "Instafy access token (defaults to the job token, then the saved `instafy login` token)");
@@ -18459,7 +19009,7 @@ previewCloseCommand.option("--json", "Output JSON").action(async (previewId, opt
18459
19009
  console.log(JSON.stringify(result, null, 2));
18460
19010
  return;
18461
19011
  }
18462
- console.log(kleur20.green(`Preview closed: ${result.previewId}`));
19012
+ console.log(kleur20.green(`Preview closed: ${result.previewId}${result.siteStopped ? "; its site was stopped" : ""}`));
18463
19013
  } catch (error) {
18464
19014
  exitWithPreviewError(error);
18465
19015
  }