@factiii/runner 0.12.1 → 0.13.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
@@ -3984,7 +3984,7 @@ var require_has_flag = __commonJS({
3984
3984
  var require_supports_color = __commonJS({
3985
3985
  "../../node_modules/debug/node_modules/supports-color/index.js"(exports2, module2) {
3986
3986
  "use strict";
3987
- var os7 = require("os");
3987
+ var os8 = require("os");
3988
3988
  var hasFlag = require_has_flag();
3989
3989
  var env = process.env;
3990
3990
  var forceColor;
@@ -4022,7 +4022,7 @@ var require_supports_color = __commonJS({
4022
4022
  }
4023
4023
  const min = forceColor ? 1 : 0;
4024
4024
  if (process.platform === "win32") {
4025
- const osRelease = os7.release().split(".");
4025
+ const osRelease = os8.release().split(".");
4026
4026
  if (Number(process.versions.node.split(".")[0]) >= 8 && Number(osRelease[0]) >= 10 && Number(osRelease[2]) >= 10586) {
4027
4027
  return Number(osRelease[2]) >= 14931 ? 3 : 2;
4028
4028
  }
@@ -12928,7 +12928,8 @@ function hostSpacePaths(spaceDir) {
12928
12928
  state: import_path.default.join(spaceDir, "state"),
12929
12929
  backups: import_path.default.join(spaceDir, "backups"),
12930
12930
  builds: import_path.default.join(spaceDir, "builds"),
12931
- bin: import_path.default.join(spaceDir, "bin")
12931
+ bin: import_path.default.join(spaceDir, "bin"),
12932
+ scratchpad: import_path.default.join(spaceDir, "scratchpad")
12932
12933
  };
12933
12934
  }
12934
12935
  function worktreeFor(paths, workName) {
@@ -13885,6 +13886,7 @@ function hostTarget(spaceDir, extraEnv) {
13885
13886
  const env = {};
13886
13887
  const ports = readSpacePorts(spaceDir);
13887
13888
  if (ports.length) env.FACTIII_PORTS = ports.join(",");
13889
+ env.SCRATCHPAD = paths.scratchpad;
13888
13890
  return { ...env, ...extraEnv?.() };
13889
13891
  },
13890
13892
  agentEnv() {
@@ -14145,6 +14147,9 @@ function createEventSink(send, spaceSlug, notify) {
14145
14147
  emitBareFsChange(postId, change) {
14146
14148
  send(channel(`bare-fs:${postId}`), change);
14147
14149
  },
14150
+ emitScratchpad(payload) {
14151
+ send(channel("scratchpad"), payload);
14152
+ },
14148
14153
  emitActivityUpdate(activity) {
14149
14154
  send(channel("activity-update"), activity);
14150
14155
  }
@@ -14161,6 +14166,18 @@ var import_promises5 = __toESM(require("fs/promises"));
14161
14166
  var import_os2 = __toESM(require("os"));
14162
14167
  var import_path7 = __toESM(require("path"));
14163
14168
 
14169
+ // ../../shared/all/domains/products.ts
14170
+ var TRONS_PER_USD = 1e6;
14171
+ var TRONS_PER_CENT = TRONS_PER_USD / 100;
14172
+ var FOUNDERS_REFUND_WINDOW_DAYS = 60;
14173
+ var FOUNDERS_REFUND_POLICY = `Refundable within ${FOUNDERS_REFUND_WINDOW_DAYS} days of purchase \u2014 after that, all sales are final.`;
14174
+
14175
+ // ../../shared/all/domains/autoTopUp.ts
14176
+ var DAY_MS = 24 * 60 * 60 * 1e3;
14177
+ var MONTH_MS = 30 * DAY_MS;
14178
+ var CLAIM_STALE_MS = 5 * 60 * 1e3;
14179
+ var MIN_THRESHOLD_TRONS = TRONS_PER_CENT;
14180
+
14164
14181
  // ../../shared/all/domains/posts.ts
14165
14182
  var postDataSort = /* @__PURE__ */ ((postDataSort2) => {
14166
14183
  postDataSort2["DAILY"] = "DAILY";
@@ -14363,7 +14380,11 @@ var checkPurchaseSchema = external_exports.object({
14363
14380
  productId: external_exports.string(),
14364
14381
  type: external_exports.enum(["STRIPE", "GOOGLE", "APPLE"]),
14365
14382
  receivedPrice: external_exports.number().int().min(0, "Received price must be a positive integer in cents"),
14366
- currency: external_exports.string().length(3).default("USD")
14383
+ currency: external_exports.string().length(3).default("USD"),
14384
+ // Explicit consent to keep this card for auto top-up. Card-network rules
14385
+ // require the buyer to agree before a card may be charged while they are not
14386
+ // present, so this must come from a checkbox they ticked — never a default.
14387
+ saveCardForAutoTopUp: external_exports.boolean().default(false)
14367
14388
  });
14368
14389
  var creditPurchaseSchema = external_exports.object({
14369
14390
  orderId: external_exports.string(),
@@ -14666,6 +14687,19 @@ var skillFrontmatterSchema = external_exports.object({
14666
14687
  }).strict();
14667
14688
  var skillAgentSchema = external_exports.enum(["claude", "codex"]);
14668
14689
  var skillScopeSchema = external_exports.enum(["global", "repo"]);
14690
+ var autoTopUpSchema = external_exports.object({
14691
+ enabled: external_exports.boolean(),
14692
+ productId: external_exports.number().int().positive(),
14693
+ thresholdTrons: external_exports.number().int().min(MIN_THRESHOLD_TRONS),
14694
+ dailyCapCents: external_exports.number().int().min(0),
14695
+ monthlyCapCents: external_exports.number().int().min(0),
14696
+ // Alerts. `alertBalanceTrons` is null when the low-balance warning is off;
14697
+ // it works independently of `enabled`, since with auto top-up off it is the
14698
+ // only safety net there is.
14699
+ alertOnTopUp: external_exports.boolean(),
14700
+ alertBalanceTrons: external_exports.number().int().min(MIN_THRESHOLD_TRONS).nullable(),
14701
+ alertNearCap: external_exports.boolean()
14702
+ });
14669
14703
 
14670
14704
  // ../../shared/all/helpers/board-agent-core/host-deploy.ts
14671
14705
  var import_child_process3 = require("child_process");
@@ -17984,6 +18018,7 @@ var TerminalModeEngine = class {
17984
18018
  paths.worktrees,
17985
18019
  paths.repo,
17986
18020
  paths.workspace,
18021
+ paths.scratchpad,
17987
18022
  `${paths.root}/drops`
17988
18023
  ];
17989
18024
  const watcher = new WorkspaceWatcher(
@@ -19825,6 +19860,204 @@ async function getDriveRoot(accessToken) {
19825
19860
  };
19826
19861
  }
19827
19862
 
19863
+ // ../../shared/all/helpers/scratchpad.ts
19864
+ var SCRATCHPAD_MAX_BYTES = 12e6;
19865
+ var SCRATCHPAD_MAX_ITEMS = 200;
19866
+ var SCRATCHPAD_CHUNK_BYTES = 512 * 1024;
19867
+ var SCRATCHPAD_MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
19868
+ var KIND_BY_EXT = {
19869
+ png: "image",
19870
+ jpg: "image",
19871
+ jpeg: "image",
19872
+ gif: "image",
19873
+ webp: "image",
19874
+ avif: "image",
19875
+ bmp: "image",
19876
+ svg: "image",
19877
+ mp4: "video",
19878
+ webm: "video",
19879
+ mov: "video",
19880
+ m4v: "video",
19881
+ mp3: "audio",
19882
+ wav: "audio",
19883
+ m4a: "audio",
19884
+ ogg: "audio",
19885
+ aac: "audio",
19886
+ flac: "audio",
19887
+ pdf: "pdf",
19888
+ html: "html",
19889
+ htm: "html",
19890
+ md: "markdown",
19891
+ markdown: "markdown",
19892
+ url: "link"
19893
+ };
19894
+ function scratchpadExt(name) {
19895
+ const dot = name.lastIndexOf(".");
19896
+ if (dot <= 0 || dot === name.length - 1) return "";
19897
+ return name.slice(dot + 1).toLowerCase();
19898
+ }
19899
+ function scratchpadKind(name) {
19900
+ return KIND_BY_EXT[scratchpadExt(name)] ?? "text";
19901
+ }
19902
+ function isSafeScratchpadName(name) {
19903
+ if (!name || name.length > 255) return false;
19904
+ if (name.startsWith(".")) return false;
19905
+ return /^[A-Za-z0-9][A-Za-z0-9._ -]*$/.test(name);
19906
+ }
19907
+
19908
+ // ../../shared/all/helpers/board-agent-core/scratchpad-store.ts
19909
+ function shEscape2(s) {
19910
+ return `'${s.replace(/'/g, `'\\''`)}'`;
19911
+ }
19912
+ var STAT_META = `stat -f '%m %z' "$f" 2>/dev/null || stat -c '%Y %s' "$f" 2>/dev/null`;
19913
+ var TOO_LARGE = "Scratchpad item is too large to open here.";
19914
+ var ScratchpadStore = class {
19915
+ /**
19916
+ * @param target Resolved per call, never held: the space's exec target is
19917
+ * rebuilt as the space provisions, and a captured one would go
19918
+ * on pointing at the tree it was built for.
19919
+ * @param emit Tells connected clients the directory moved. Carries a
19920
+ * timestamp only: the panel refetches the whole listing.
19921
+ */
19922
+ constructor(target, emit) {
19923
+ this.target = target;
19924
+ this.emit = emit;
19925
+ this.watcher = null;
19926
+ }
19927
+ dir() {
19928
+ return this.target().paths.scratchpad;
19929
+ }
19930
+ /**
19931
+ * Everything on the scratchpad, newest first.
19932
+ *
19933
+ * Dotfiles are skipped: the panel is a display surface, and `.DS_Store` is
19934
+ * not something anyone drew. So are subdirectories: the scratchpad is flat,
19935
+ * which is what lets a name be an id everywhere else in this file.
19936
+ */
19937
+ async list() {
19938
+ const dir = this.dir();
19939
+ this.startWatching();
19940
+ const out = await this.target().run([
19941
+ "sh",
19942
+ "-c",
19943
+ `mkdir -p ${shEscape2(dir)} && cd ${shEscape2(dir)} && for f in *; do
19944
+ [ -f "$f" ] || continue
19945
+ meta=$(${STAT_META}) || continue
19946
+ echo "$meta $f"
19947
+ done | LC_ALL=C sort -rn | head -n ${SCRATCHPAD_MAX_ITEMS}`
19948
+ ]);
19949
+ const items = [];
19950
+ for (const line of out.split("\n")) {
19951
+ const match = /^(\d+) (\d+) (.+)$/.exec(line);
19952
+ if (!match) continue;
19953
+ const [, mtime, size, name] = match;
19954
+ if (!isSafeScratchpadName(name)) continue;
19955
+ const bytes = Number(size);
19956
+ items.push({
19957
+ name,
19958
+ kind: scratchpadKind(name),
19959
+ size: bytes,
19960
+ // stat reports seconds; every other timestamp on the wire is ms.
19961
+ modifiedAt: Number(mtime) * 1e3,
19962
+ tooLarge: bytes > SCRATCHPAD_MAX_BYTES
19963
+ });
19964
+ }
19965
+ return items;
19966
+ }
19967
+ /**
19968
+ * One item's bytes, base64. Capped: past SCRATCHPAD_MAX_BYTES the panel is
19969
+ * told to show the path instead, so a 4GB screen recording cannot pin the
19970
+ * data channel for minutes.
19971
+ *
19972
+ * One shell, not a size check and then a read: the panel opens every card at
19973
+ * once, so a second spawn per item is a doubled cost on the whole listing -
19974
+ * and a file that changed between the two calls would have been read past
19975
+ * the cap anyway.
19976
+ */
19977
+ async read(name) {
19978
+ const file = shEscape2(this.resolve(name));
19979
+ const content = await this.target().sh(
19980
+ // Unquoted `$(wc -c)` on purpose: BSD wc pads its count with spaces,
19981
+ // which `[ -gt ]` will not parse. `base64 -w 0` is GNU-only (BSD
19982
+ // spells it -b), so unwrap with tr rather than asking it not to wrap.
19983
+ `[ -f ${file} ] || exit 1
19984
+ if [ $(wc -c < ${file}) -gt ${SCRATCHPAD_MAX_BYTES} ]; then
19985
+ echo '${TOO_LARGE}' >&2; exit 1
19986
+ fi
19987
+ base64 < ${file} | tr -d '
19988
+ '`
19989
+ ).catch((err) => {
19990
+ const text = err instanceof Error ? err.message : "";
19991
+ throw new Error(
19992
+ text.includes(TOO_LARGE) ? TOO_LARGE : "Could not read that item."
19993
+ );
19994
+ });
19995
+ return { content: content.trim(), encoding: "base64" };
19996
+ }
19997
+ /**
19998
+ * One byte range of an item, base64. What a download is built out of.
19999
+ *
20000
+ * No size cap here, unlike `read`: the cap exists because a preview holds
20001
+ * the whole file in one message, and a range does not. `tail | head` rather
20002
+ * than `dd`, which needs a GNU-only flag to promise a full block over a pipe.
20003
+ */
20004
+ async readChunk(name, offset, length) {
20005
+ if (!Number.isInteger(offset) || offset < 0) {
20006
+ throw new Error("Invalid scratchpad offset.");
20007
+ }
20008
+ if (!Number.isInteger(length) || length <= 0 || length > SCRATCHPAD_CHUNK_BYTES) {
20009
+ throw new Error("Invalid scratchpad chunk length.");
20010
+ }
20011
+ const file = shEscape2(this.resolve(name));
20012
+ const content = await this.target().sh(
20013
+ // `tail -c +N` counts from 1, so the first byte is offset 0 plus one.
20014
+ `[ -f ${file} ] || exit 1
20015
+ tail -c +${offset + 1} ${file} | head -c ${length} | base64 | tr -d '
20016
+ '`
20017
+ ).catch(() => {
20018
+ throw new Error("Could not read that item.");
20019
+ });
20020
+ return { content: content.trim(), encoding: "base64" };
20021
+ }
20022
+ async remove(name) {
20023
+ await this.target().run(["rm", "-f", "--", this.resolve(name)]);
20024
+ }
20025
+ /** Files only, and only at the top level: whatever put a directory in there
20026
+ * is not something this panel drew, and `rm -rf` on an agent-writable path
20027
+ * is a worse tool than this job needs. */
20028
+ async clear() {
20029
+ const dir = shEscape2(this.dir());
20030
+ await this.target().run([
20031
+ "sh",
20032
+ "-c",
20033
+ `mkdir -p ${dir} && cd ${dir} && find . -maxdepth 1 -type f -delete`
20034
+ ]);
20035
+ }
20036
+ destroy() {
20037
+ const watcher = this.watcher;
20038
+ this.watcher = null;
20039
+ if (watcher) void watcher.close();
20040
+ }
20041
+ /** Idempotent: every panel that opens calls `list`, and they must not stack
20042
+ * watchers on one directory. */
20043
+ startWatching() {
20044
+ if (this.watcher) return;
20045
+ const watcher = new WorkspaceWatcher(this.dir(), () => {
20046
+ this.emit({ at: Date.now() });
20047
+ });
20048
+ this.watcher = watcher;
20049
+ watcher.start();
20050
+ }
20051
+ /** Absolute path for a name the agent chose. Flat by construction: the name
20052
+ * is validated, never joined with anything a caller supplied. */
20053
+ resolve(name) {
20054
+ if (!isSafeScratchpadName(name)) {
20055
+ throw new Error("Invalid scratchpad item name.");
20056
+ }
20057
+ return `${this.dir()}/${name}`;
20058
+ }
20059
+ };
20060
+
19828
20061
  // ../../shared/all/helpers/board-agent-core/space-core.ts
19829
20062
  var import_promises10 = require("fs/promises");
19830
20063
  var import_path14 = __toESM(require("path"));
@@ -20340,7 +20573,7 @@ var SpaceCore = class {
20340
20573
  status("Preparing workspace\u2026");
20341
20574
  const { paths } = target;
20342
20575
  await target.sh(
20343
- `mkdir -p ${paths.root} ${paths.worktrees} ${paths.state} ${paths.backups} ${paths.builds} ${paths.bin}`
20576
+ `mkdir -p ${paths.root} ${paths.worktrees} ${paths.state} ${paths.backups} ${paths.builds} ${paths.bin} ${paths.scratchpad}`
20344
20577
  );
20345
20578
  await allocateSpacePorts(this.spaceDir());
20346
20579
  await this.ensureSecretsBridge().catch(() => void 0);
@@ -20683,6 +20916,10 @@ var BoardAgentEngine = class _BoardAgentEngine {
20683
20916
  this.emitter = options.emitter;
20684
20917
  this.claude = new ClaudeModeEngine(this.core, this.emitter);
20685
20918
  this.terminal = new TerminalModeEngine(this.core, this.emitter);
20919
+ this.scratchpad = new ScratchpadStore(
20920
+ () => this.core.target(),
20921
+ (payload) => this.emitter.emitScratchpad(payload)
20922
+ );
20686
20923
  this.gate.subscribe(() => this.projectGateOntoRun());
20687
20924
  }
20688
20925
  /** Everything the runner does for this space that isn't a card session, so
@@ -21659,6 +21896,22 @@ var BoardAgentEngine = class _BoardAgentEngine {
21659
21896
  bareListExposed(p) {
21660
21897
  return this.terminal.bareListExposed(p);
21661
21898
  }
21899
+ // ── Scratchpad (→ the space's display surface) ──
21900
+ scratchpadList() {
21901
+ return this.scratchpad.list();
21902
+ }
21903
+ scratchpadRead(p) {
21904
+ return this.scratchpad.read(p.name);
21905
+ }
21906
+ scratchpadReadChunk(p) {
21907
+ return this.scratchpad.readChunk(p.name, p.offset, p.length);
21908
+ }
21909
+ scratchpadDelete(p) {
21910
+ return this.scratchpad.remove(p.name);
21911
+ }
21912
+ scratchpadClear() {
21913
+ return this.scratchpad.clear();
21914
+ }
21662
21915
  // ── Cross-mode coordinators ──
21663
21916
  async cardExecute(payload) {
21664
21917
  const {
@@ -21988,6 +22241,7 @@ ${c.content.trim() || "(no description)"}`
21988
22241
  void this.codexLogin?.cancel();
21989
22242
  this.claude.destroy();
21990
22243
  this.terminal.destroy();
22244
+ this.scratchpad.destroy();
21991
22245
  this.core.destroy();
21992
22246
  }
21993
22247
  /** Route one action/payload to the right engine method, for both transports.
@@ -22318,6 +22572,24 @@ ${c.content.trim() || "(no description)"}`
22318
22572
  return await this.bareListExposed(
22319
22573
  payload
22320
22574
  );
22575
+ case "scratchpadList":
22576
+ return await this.scratchpadList();
22577
+ case "scratchpadRead":
22578
+ return await this.scratchpadRead(
22579
+ payload
22580
+ );
22581
+ case "scratchpadReadChunk":
22582
+ return await this.scratchpadReadChunk(
22583
+ payload
22584
+ );
22585
+ case "scratchpadDelete":
22586
+ await this.scratchpadDelete(
22587
+ payload
22588
+ );
22589
+ return void 0;
22590
+ case "scratchpadClear":
22591
+ await this.scratchpadClear();
22592
+ return void 0;
22321
22593
  case "secureStatus":
22322
22594
  return this.secureStatus();
22323
22595
  default:
@@ -22329,11 +22601,58 @@ ${c.content.trim() || "(no description)"}`
22329
22601
  // ../../shared/all/helpers/board-agent-core/index-service.ts
22330
22602
  var import_child_process5 = require("child_process");
22331
22603
  var import_crypto7 = require("crypto");
22332
- var import_fs5 = require("fs");
22604
+ var import_fs6 = require("fs");
22333
22605
  var import_promises11 = __toESM(require("fs/promises"));
22334
22606
  var import_net3 = require("net");
22335
- var import_path16 = require("path");
22607
+ var import_path17 = require("path");
22336
22608
  var import_util7 = require("util");
22609
+
22610
+ // ../../shared/all/helpers/board-agent-core/node-version.ts
22611
+ var import_fs5 = require("fs");
22612
+ var import_os4 = __toESM(require("os"));
22613
+ var import_path16 = require("path");
22614
+ var MIN_INDEX_NODE_MAJOR = 20;
22615
+ var MAX_INDEX_NODE_MAJOR = 24;
22616
+ var NODE_OVERRIDE_ENV = "FACTIII_INDEX_NODE";
22617
+ function nodeMajor(version) {
22618
+ return Number(version.replace(/^v/, "").split(".")[0]);
22619
+ }
22620
+ function indexNodeSupported(version = process.versions.node) {
22621
+ const major = nodeMajor(version);
22622
+ return !Number.isNaN(major) && major >= MIN_INDEX_NODE_MAJOR && major <= MAX_INDEX_NODE_MAJOR;
22623
+ }
22624
+ function candidateNodes() {
22625
+ const paths = [];
22626
+ const home = import_os4.default.homedir();
22627
+ const nvmRoot = (0, import_path16.join)(home, ".nvm", "versions", "node");
22628
+ try {
22629
+ const versions = (0, import_fs5.readdirSync)(nvmRoot).filter((v) => indexNodeSupported(v)).sort((a, b) => nodeMajor(b) - nodeMajor(a));
22630
+ for (const v of versions) paths.push((0, import_path16.join)(nvmRoot, v, "bin", "node"));
22631
+ } catch {
22632
+ }
22633
+ for (const prefix of ["/opt/homebrew/opt", "/usr/local/opt"]) {
22634
+ for (let major = MAX_INDEX_NODE_MAJOR; major >= MIN_INDEX_NODE_MAJOR; major -= 1) {
22635
+ paths.push((0, import_path16.join)(prefix, `node@${major}`, "bin", "node"));
22636
+ }
22637
+ }
22638
+ return paths;
22639
+ }
22640
+ function findIndexNode() {
22641
+ const override = process.env[NODE_OVERRIDE_ENV];
22642
+ if (override && (0, import_fs5.existsSync)(override)) return override;
22643
+ if (indexNodeSupported()) return process.execPath;
22644
+ return candidateNodes().find((p) => (0, import_fs5.existsSync)(p)) ?? null;
22645
+ }
22646
+ function unsupportedNodeMessage() {
22647
+ return `The OneDrive index needs Node ${MIN_INDEX_NODE_MAJOR}-${MAX_INDEX_NODE_MAJOR} (pinned 24.11.1); this host runs Node ${process.versions.node} and has no supported Node installed. It compiles better-sqlite3 from source, and that build fails on Node ${MAX_INDEX_NODE_MAJOR + 1}+.
22648
+
22649
+ Install one: nvm install 24.11.1
22650
+ or point the runner at an existing one: export ${NODE_OVERRIDE_ENV}=/path/to/node
22651
+
22652
+ Everything else on this runner works meanwhile \u2014 only OneDrive search is off.`;
22653
+ }
22654
+
22655
+ // ../../shared/all/helpers/board-agent-core/index-service.ts
22337
22656
  var execFileAsync4 = (0, import_util7.promisify)(import_child_process5.execFile);
22338
22657
  var IndexService = class {
22339
22658
  constructor(opts) {
@@ -22386,27 +22705,25 @@ var IndexService = class {
22386
22705
  await this.waitReady(this.port);
22387
22706
  return;
22388
22707
  }
22389
- await this.installDeps();
22708
+ const nodeBin = findIndexNode();
22709
+ if (!nodeBin) throw new Error(unsupportedNodeMessage());
22710
+ await this.installDeps(nodeBin);
22390
22711
  const port = await this.stablePort();
22391
22712
  const log = await this.openLog();
22392
- this.proc = (0, import_child_process5.spawn)(
22393
- process.execPath,
22394
- [(0, import_path16.join)(this.opts.indexDir, "index.js")],
22395
- {
22396
- cwd: this.opts.indexDir,
22397
- env: {
22398
- ...process.env,
22399
- NODE_ENV: "production",
22400
- PORT: String(port),
22401
- CONTROL_TOKEN: this.opts.controlToken,
22402
- EMBED_URL: this.opts.embedUrl(),
22403
- INDEX_DATA_DIR: this.opts.dataDir
22404
- },
22405
- stdio: log ? ["ignore", log, log] : "ignore",
22406
- // Own process group so stop() reaps it; nothing else reaps orphans here.
22407
- detached: true
22408
- }
22409
- );
22713
+ this.proc = (0, import_child_process5.spawn)(nodeBin, [(0, import_path17.join)(this.opts.indexDir, "index.js")], {
22714
+ cwd: this.opts.indexDir,
22715
+ env: {
22716
+ ...process.env,
22717
+ NODE_ENV: "production",
22718
+ PORT: String(port),
22719
+ CONTROL_TOKEN: this.opts.controlToken,
22720
+ EMBED_URL: this.opts.embedUrl(),
22721
+ INDEX_DATA_DIR: this.opts.dataDir
22722
+ },
22723
+ stdio: log ? ["ignore", log, log] : "ignore",
22724
+ // Own process group so stop() reaps it; nothing else reaps orphans here.
22725
+ detached: true
22726
+ });
22410
22727
  this.proc.on("exit", () => {
22411
22728
  this.proc = null;
22412
22729
  this.ready = null;
@@ -22420,31 +22737,58 @@ var IndexService = class {
22420
22737
  async openLog() {
22421
22738
  try {
22422
22739
  await import_promises11.default.mkdir(this.opts.dataDir, { recursive: true });
22423
- return (0, import_fs5.openSync)((0, import_path16.join)(this.opts.dataDir, "index.log"), "a");
22740
+ return (0, import_fs6.openSync)((0, import_path17.join)(this.opts.dataDir, "index.log"), "a");
22424
22741
  } catch {
22425
22742
  return null;
22426
22743
  }
22427
22744
  }
22428
- /** Install the index's native deps against THIS node on first start, so a
22429
- * platform needing node-gyp fails here rather than at query time. */
22430
- async installDeps() {
22745
+ /** Install the index's native deps against the node that will RUN it, so a
22746
+ * platform needing node-gyp fails here rather than at query time.
22747
+ *
22748
+ * The deps are compiled per ABI, so they are only reusable under the node
22749
+ * that built them. The stamp records which ABI is on disk and forces a
22750
+ * rebuild when the interpreter changes — otherwise a host that switches
22751
+ * node versions loads a binding built for the old one and dies with
22752
+ * "compiled against a different Node.js version". */
22753
+ async installDeps(nodeBin) {
22431
22754
  const dir = this.opts.indexDir;
22432
- if ((0, import_fs5.existsSync)((0, import_path16.join)(dir, "node_modules"))) return;
22755
+ const { stdout } = await execFileAsync4(nodeBin, [
22756
+ "-p",
22757
+ "process.versions.modules"
22758
+ ]);
22759
+ const abi = stdout.trim();
22760
+ const stamp = (0, import_path17.join)(dir, ".node-abi");
22761
+ if ((0, import_fs6.existsSync)((0, import_path17.join)(dir, "node_modules"))) {
22762
+ const built = await import_promises11.default.readFile(stamp, "utf-8").catch(() => "");
22763
+ if (built.trim() === abi) return;
22764
+ await import_promises11.default.rm((0, import_path17.join)(dir, "node_modules"), { recursive: true, force: true });
22765
+ }
22433
22766
  await import_promises11.default.copyFile(
22434
- (0, import_path16.join)(dir, "image-package.json"),
22435
- (0, import_path16.join)(dir, "package.json")
22767
+ (0, import_path17.join)(dir, "image-package.json"),
22768
+ (0, import_path17.join)(dir, "package.json")
22436
22769
  );
22437
22770
  await execFileAsync4(
22438
22771
  "npm",
22439
22772
  ["install", "--omit=dev", "--no-audit", "--no-fund"],
22440
- { cwd: dir }
22773
+ {
22774
+ cwd: dir,
22775
+ // npm resolves node-gyp's target from the node running it, so the
22776
+ // chosen interpreter has to lead PATH or the build targets the wrong ABI.
22777
+ env: {
22778
+ ...process.env,
22779
+ PATH: `${(0, import_path17.dirname)(nodeBin)}:${process.env.PATH ?? ""}`
22780
+ }
22781
+ }
22441
22782
  );
22783
+ await import_promises11.default.writeFile(stamp, `${abi}
22784
+ `, "utf-8").catch(() => {
22785
+ });
22442
22786
  }
22443
22787
  /** The port to listen on, kept stable across restarts. The URL goes into the
22444
22788
  * CLIs' persistent MCP config, so a fresh port each start would leave every
22445
22789
  * registration pointing at a dead one. */
22446
22790
  async stablePort() {
22447
- const file = (0, import_path16.join)(this.opts.dataDir, ".port");
22791
+ const file = (0, import_path17.join)(this.opts.dataDir, ".port");
22448
22792
  const saved = Number(await import_promises11.default.readFile(file, "utf-8").catch(() => ""));
22449
22793
  if (Number.isInteger(saved) && saved > 0 && await isFree(saved)) {
22450
22794
  return saved;
@@ -22619,25 +22963,25 @@ async function pairWithBrowser(serverUrl) {
22619
22963
  }
22620
22964
 
22621
22965
  // src/config.ts
22622
- var import_fs9 = __toESM(require("fs"));
22623
- var import_os6 = __toESM(require("os"));
22624
- var import_path21 = __toESM(require("path"));
22966
+ var import_fs10 = __toESM(require("fs"));
22967
+ var import_os7 = __toESM(require("os"));
22968
+ var import_path22 = __toESM(require("path"));
22625
22969
 
22626
22970
  // src/secureFile.ts
22627
22971
  var import_crypto10 = __toESM(require("crypto"));
22628
- var import_fs8 = __toESM(require("fs"));
22629
- var import_path20 = __toESM(require("path"));
22972
+ var import_fs9 = __toESM(require("fs"));
22973
+ var import_path21 = __toESM(require("path"));
22630
22974
 
22631
22975
  // src/keychain.ts
22632
22976
  var import_child_process7 = require("child_process");
22633
22977
  var import_crypto8 = __toESM(require("crypto"));
22634
- var import_fs6 = __toESM(require("fs"));
22635
- var import_os4 = __toESM(require("os"));
22636
- var import_path17 = __toESM(require("path"));
22978
+ var import_fs7 = __toESM(require("fs"));
22979
+ var import_os5 = __toESM(require("os"));
22980
+ var import_path18 = __toESM(require("path"));
22637
22981
  var SERVICE = "factiii-runner";
22638
22982
  var ACCOUNT = "config-encryption-key";
22639
- var DPAPI_KEY_FILE = import_path17.default.join(
22640
- import_os4.default.homedir(),
22983
+ var DPAPI_KEY_FILE = import_path18.default.join(
22984
+ import_os5.default.homedir(),
22641
22985
  ".factiii-runner",
22642
22986
  "config-key.dpapi"
22643
22987
  );
@@ -22717,7 +23061,7 @@ function linuxWrite(key) {
22717
23061
  function winRead() {
22718
23062
  let wrapped;
22719
23063
  try {
22720
- wrapped = import_fs6.default.readFileSync(DPAPI_KEY_FILE, "utf-8").trim();
23064
+ wrapped = import_fs7.default.readFileSync(DPAPI_KEY_FILE, "utf-8").trim();
22721
23065
  } catch {
22722
23066
  return { state: "missing" };
22723
23067
  }
@@ -22742,8 +23086,8 @@ function winWrite(key) {
22742
23086
  "-Command",
22743
23087
  `ConvertTo-SecureString -String '${key.toString("base64")}' -AsPlainText -Force | ConvertFrom-SecureString`
22744
23088
  ]);
22745
- import_fs6.default.mkdirSync(import_path17.default.dirname(DPAPI_KEY_FILE), { recursive: true });
22746
- import_fs6.default.writeFileSync(DPAPI_KEY_FILE, `${out.trim()}
23089
+ import_fs7.default.mkdirSync(import_path18.default.dirname(DPAPI_KEY_FILE), { recursive: true });
23090
+ import_fs7.default.writeFileSync(DPAPI_KEY_FILE, `${out.trim()}
22747
23091
  `, { mode: 384 });
22748
23092
  }
22749
23093
  function keychainBackend() {
@@ -22905,22 +23249,22 @@ function keychainStatus() {
22905
23249
 
22906
23250
  // src/secureStore.ts
22907
23251
  var import_crypto9 = __toESM(require("crypto"));
22908
- var import_fs7 = __toESM(require("fs"));
22909
- var import_path19 = __toESM(require("path"));
23252
+ var import_fs8 = __toESM(require("fs"));
23253
+ var import_path20 = __toESM(require("path"));
22910
23254
 
22911
23255
  // src/paths.ts
22912
- var import_os5 = __toESM(require("os"));
22913
- var import_path18 = __toESM(require("path"));
22914
- var CONFIG_DIR = import_path18.default.join(import_os5.default.homedir(), ".factiii-runner");
23256
+ var import_os6 = __toESM(require("os"));
23257
+ var import_path19 = __toESM(require("path"));
23258
+ var CONFIG_DIR = import_path19.default.join(import_os6.default.homedir(), ".factiii-runner");
22915
23259
  function safeSlug(spaceSlug) {
22916
23260
  return spaceSlug.replace(/[^a-zA-Z0-9_.-]/g, "_");
22917
23261
  }
22918
23262
  function spaceDirPath(spaceSlug) {
22919
- return import_path18.default.join(CONFIG_DIR, safeSlug(spaceSlug));
23263
+ return import_path19.default.join(CONFIG_DIR, safeSlug(spaceSlug));
22920
23264
  }
22921
23265
 
22922
23266
  // src/secureStore.ts
22923
- var VAULT_KEY_FILE = import_path19.default.join(CONFIG_DIR, "vault-key.json");
23267
+ var VAULT_KEY_FILE = import_path20.default.join(CONFIG_DIR, "vault-key.json");
22924
23268
  var SCRYPT_N2 = 1 << 15;
22925
23269
  var MIN_PASSWORD_LENGTH = 8;
22926
23270
  var SecureStoreError = class extends Error {
@@ -22993,7 +23337,7 @@ function unseal(box, key) {
22993
23337
  function readVaultFile() {
22994
23338
  try {
22995
23339
  const parsed = JSON.parse(
22996
- import_fs7.default.readFileSync(VAULT_KEY_FILE, "utf-8")
23340
+ import_fs8.default.readFileSync(VAULT_KEY_FILE, "utf-8")
22997
23341
  );
22998
23342
  return parsed.v === 1 ? parsed : null;
22999
23343
  } catch {
@@ -23001,11 +23345,11 @@ function readVaultFile() {
23001
23345
  }
23002
23346
  }
23003
23347
  function writeVaultFile(file) {
23004
- import_fs7.default.mkdirSync(CONFIG_DIR, { recursive: true });
23348
+ import_fs8.default.mkdirSync(CONFIG_DIR, { recursive: true });
23005
23349
  const tmp = `${VAULT_KEY_FILE}.${process.pid}.tmp`;
23006
- import_fs7.default.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}
23350
+ import_fs8.default.writeFileSync(tmp, `${JSON.stringify(file, null, 2)}
23007
23351
  `, { mode: 384 });
23008
- import_fs7.default.renameSync(tmp, VAULT_KEY_FILE);
23352
+ import_fs8.default.renameSync(tmp, VAULT_KEY_FILE);
23009
23353
  }
23010
23354
  var unlockedDek = null;
23011
23355
  function createVault(machineKey) {
@@ -23242,15 +23586,15 @@ function parseEnvelope(raw) {
23242
23586
  }
23243
23587
  }
23244
23588
  function atomicWrite(filePath, body) {
23245
- import_fs8.default.mkdirSync(import_path20.default.dirname(filePath), { recursive: true });
23589
+ import_fs9.default.mkdirSync(import_path21.default.dirname(filePath), { recursive: true });
23246
23590
  const tmp = `${filePath}.${process.pid}.tmp`;
23247
- import_fs8.default.writeFileSync(tmp, body, { mode: 384 });
23248
- import_fs8.default.renameSync(tmp, filePath);
23591
+ import_fs9.default.writeFileSync(tmp, body, { mode: 384 });
23592
+ import_fs9.default.renameSync(tmp, filePath);
23249
23593
  }
23250
23594
  function readSecureBuffer(filePath) {
23251
23595
  let raw;
23252
23596
  try {
23253
- raw = import_fs8.default.readFileSync(filePath, "utf-8");
23597
+ raw = import_fs9.default.readFileSync(filePath, "utf-8");
23254
23598
  } catch {
23255
23599
  return null;
23256
23600
  }
@@ -23307,7 +23651,7 @@ function writeSecureJson(filePath, value2) {
23307
23651
  function readBootJson(filePath) {
23308
23652
  let raw;
23309
23653
  try {
23310
- raw = import_fs8.default.readFileSync(filePath, "utf-8");
23654
+ raw = import_fs9.default.readFileSync(filePath, "utf-8");
23311
23655
  } catch {
23312
23656
  return null;
23313
23657
  }
@@ -23357,8 +23701,8 @@ function writeBootJson(filePath, value2) {
23357
23701
  }
23358
23702
 
23359
23703
  // src/config.ts
23360
- var CONFIG_DIR2 = import_path21.default.join(import_os6.default.homedir(), ".factiii-runner");
23361
- var CONFIG_FILE = import_path21.default.join(CONFIG_DIR2, "config.json");
23704
+ var CONFIG_DIR2 = import_path22.default.join(import_os7.default.homedir(), ".factiii-runner");
23705
+ var CONFIG_FILE = import_path22.default.join(CONFIG_DIR2, "config.json");
23362
23706
  function readRunnerConfig() {
23363
23707
  return readBootJson(CONFIG_FILE);
23364
23708
  }
@@ -23366,7 +23710,7 @@ function writeRunnerConfig(config) {
23366
23710
  writeBootJson(CONFIG_FILE, config);
23367
23711
  }
23368
23712
  function configExists() {
23369
- return import_fs9.default.existsSync(CONFIG_FILE);
23713
+ return import_fs10.default.existsSync(CONFIG_FILE);
23370
23714
  }
23371
23715
 
23372
23716
  // src/promptPassword.ts
@@ -23459,7 +23803,54 @@ function currentVersion() {
23459
23803
  return "0.0.0";
23460
23804
  }
23461
23805
  }
23806
+ function hostSatisfiesTarget() {
23807
+ let engines = "";
23808
+ try {
23809
+ const res = (0, import_node_child_process.spawnSync)(
23810
+ "npm",
23811
+ ["view", `${PACKAGE_NAME}@latest`, "engines.node"],
23812
+ { encoding: "utf-8", shell: process.platform === "win32" }
23813
+ );
23814
+ if (res.status !== 0) return { ok: true, reason: "" };
23815
+ engines = (res.stdout ?? "").trim();
23816
+ } catch {
23817
+ return { ok: true, reason: "" };
23818
+ }
23819
+ if (!engines) return { ok: true, reason: "" };
23820
+ const major = Number(process.versions.node.split(".")[0]);
23821
+ if (Number.isNaN(major)) return { ok: true, reason: "" };
23822
+ const min = /(?:>=|\^|~)\s*v?(\d+)/.exec(engines);
23823
+ if (min && major < Number(min[1])) {
23824
+ return {
23825
+ ok: false,
23826
+ reason: `it needs Node ${engines} and this host runs Node ${process.versions.node}`
23827
+ };
23828
+ }
23829
+ const lessThan = /<\s*v?(\d+)/.exec(engines);
23830
+ if (lessThan && major >= Number(lessThan[1])) {
23831
+ return {
23832
+ ok: false,
23833
+ reason: `it needs Node ${engines} and this host runs Node ${process.versions.node}`
23834
+ };
23835
+ }
23836
+ const atMost = /<=\s*v?(\d+)/.exec(engines);
23837
+ if (atMost && major > Number(atMost[1])) {
23838
+ return {
23839
+ ok: false,
23840
+ reason: `it needs Node ${engines} and this host runs Node ${process.versions.node}`
23841
+ };
23842
+ }
23843
+ return { ok: true, reason: "" };
23844
+ }
23462
23845
  function installLatest() {
23846
+ const fit = hostSatisfiesTarget();
23847
+ if (!fit.ok) {
23848
+ console.error(
23849
+ `[update] refusing to install ${PACKAGE_NAME}@latest: ${fit.reason}.
23850
+ [update] Staying on ${currentVersion()}. Install a supported Node (nvm install 24.11.1) and restart the runner to update.`
23851
+ );
23852
+ return false;
23853
+ }
23463
23854
  console.log(`[update] installing ${PACKAGE_NAME}@latest...`);
23464
23855
  const res = (0, import_node_child_process.spawnSync)("npm", ["install", "-g", `${PACKAGE_NAME}@latest`], {
23465
23856
  stdio: "inherit",
@@ -23480,9 +23871,9 @@ var import_node_child_process2 = require("node:child_process");
23480
23871
 
23481
23872
  // src/daemon.ts
23482
23873
  var import_crypto11 = require("crypto");
23483
- var import_fs11 = __toESM(require("fs"));
23874
+ var import_fs12 = __toESM(require("fs"));
23484
23875
  var import_node_datachannel = require("node-datachannel");
23485
- var import_path23 = __toESM(require("path"));
23876
+ var import_path24 = __toESM(require("path"));
23486
23877
 
23487
23878
  // ../../node_modules/engine.io-client/build/esm-debug/transports/polling-xhr.node.js
23488
23879
  var XMLHttpRequestModule = __toESM(require_XMLHttpRequest(), 1);
@@ -26981,18 +27372,18 @@ Object.assign(lookup, {
26981
27372
  });
26982
27373
 
26983
27374
  // src/agent-adapter.ts
26984
- var import_fs10 = __toESM(require("fs"));
26985
- var import_path22 = __toESM(require("path"));
27375
+ var import_fs11 = __toESM(require("fs"));
27376
+ var import_path23 = __toESM(require("path"));
26986
27377
  function credsDir(spaceSlug) {
26987
- return import_path22.default.join(spaceDirPath(spaceSlug), ".creds");
27378
+ return import_path23.default.join(spaceDirPath(spaceSlug), ".creds");
26988
27379
  }
26989
27380
  function githubPath(spaceSlug) {
26990
- return import_path22.default.join(credsDir(spaceSlug), "github.json");
27381
+ return import_path23.default.join(credsDir(spaceSlug), "github.json");
26991
27382
  }
26992
27383
  function connectionPath(spaceSlug) {
26993
- return import_path22.default.join(credsDir(spaceSlug), "onedrive.json");
27384
+ return import_path23.default.join(credsDir(spaceSlug), "onedrive.json");
26994
27385
  }
26995
- var RUNNER_CONFIG_PATH = import_path22.default.join(CONFIG_DIR, "runner-config.json");
27386
+ var RUNNER_CONFIG_PATH = import_path23.default.join(CONFIG_DIR, "runner-config.json");
26996
27387
  function writeJson(filePath, value2) {
26997
27388
  writeSecureJson(filePath, value2);
26998
27389
  }
@@ -27008,14 +27399,14 @@ function fillDefaults(config) {
27008
27399
  }
27009
27400
  function configDirNames() {
27010
27401
  try {
27011
- return import_fs10.default.readdirSync(CONFIG_DIR, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
27402
+ return import_fs11.default.readdirSync(CONFIG_DIR, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
27012
27403
  } catch {
27013
27404
  return [];
27014
27405
  }
27015
27406
  }
27016
27407
  function provisionedSpaceSlugs() {
27017
27408
  return configDirNames().filter(
27018
- (name) => import_fs10.default.existsSync(import_path22.default.join(CONFIG_DIR, name, "bin"))
27409
+ (name) => import_fs11.default.existsSync(import_path23.default.join(CONFIG_DIR, name, "bin"))
27019
27410
  );
27020
27411
  }
27021
27412
  var migrated = false;
@@ -27029,7 +27420,7 @@ function migrateToSpaceFolders() {
27029
27420
  }
27030
27421
  }
27031
27422
  function readLegacy(filePath) {
27032
- if (!import_fs10.default.existsSync(filePath)) return { readable: true, value: null };
27423
+ if (!import_fs11.default.existsSync(filePath)) return { readable: true, value: null };
27033
27424
  const value2 = readSecureJson(filePath);
27034
27425
  return { readable: value2 !== null, value: value2 };
27035
27426
  }
@@ -27047,14 +27438,14 @@ function migrateOnce() {
27047
27438
  const consumed = [];
27048
27439
  let strayFiles = [];
27049
27440
  try {
27050
- strayFiles = import_fs10.default.readdirSync(CONFIG_DIR).filter((f) => f.startsWith("space-") && f.endsWith(".json")).filter((f) => !f.endsWith(".onedrive.json"));
27441
+ strayFiles = import_fs11.default.readdirSync(CONFIG_DIR).filter((f) => f.startsWith("space-") && f.endsWith(".json")).filter((f) => !f.endsWith(".onedrive.json"));
27051
27442
  } catch {
27052
27443
  strayFiles = [];
27053
27444
  }
27054
27445
  for (const name of strayFiles) {
27055
27446
  const slug = name.slice("space-".length, -".json".length);
27056
27447
  const parsed = readLegacy(
27057
- import_path22.default.join(CONFIG_DIR, name)
27448
+ import_path23.default.join(CONFIG_DIR, name)
27058
27449
  );
27059
27450
  if (!parsed.readable) continue;
27060
27451
  if (parsed.value) strays[slug] = parsed.value;
@@ -27067,7 +27458,7 @@ function migrateOnce() {
27067
27458
  ...provisionedSpaceSlugs()
27068
27459
  ]);
27069
27460
  for (const slug of slugs) {
27070
- if (import_fs10.default.existsSync(githubPath(slug))) continue;
27461
+ if (import_fs11.default.existsSync(githubPath(slug))) continue;
27071
27462
  const over = boards[slug] ?? strays[slug] ?? {};
27072
27463
  if (!Object.keys(over).length && !Object.keys(base).length) continue;
27073
27464
  const merged = fillDefaults({
@@ -27078,32 +27469,32 @@ function migrateOnce() {
27078
27469
  githubToken: over.githubToken || base.githubToken,
27079
27470
  provider: over.provider || base.provider
27080
27471
  });
27081
- import_fs10.default.mkdirSync(credsDir(slug), { recursive: true });
27472
+ import_fs11.default.mkdirSync(credsDir(slug), { recursive: true });
27082
27473
  writeJson(githubPath(slug), merged);
27083
27474
  }
27084
27475
  let odFiles = [];
27085
27476
  try {
27086
- odFiles = import_fs10.default.readdirSync(CONFIG_DIR).filter((f) => f.startsWith("space-") && f.endsWith(".onedrive.json"));
27477
+ odFiles = import_fs11.default.readdirSync(CONFIG_DIR).filter((f) => f.startsWith("space-") && f.endsWith(".onedrive.json"));
27087
27478
  } catch {
27088
27479
  odFiles = [];
27089
27480
  }
27090
27481
  for (const name of odFiles) {
27091
27482
  const slug = name.slice("space-".length, -".onedrive.json".length);
27092
- if (import_fs10.default.existsSync(connectionPath(slug))) {
27483
+ if (import_fs11.default.existsSync(connectionPath(slug))) {
27093
27484
  consumed.push(name);
27094
27485
  continue;
27095
27486
  }
27096
- const conn = readLegacy(import_path22.default.join(CONFIG_DIR, name));
27487
+ const conn = readLegacy(import_path23.default.join(CONFIG_DIR, name));
27097
27488
  if (!conn.readable) continue;
27098
27489
  if (conn.value) {
27099
- import_fs10.default.mkdirSync(credsDir(slug), { recursive: true });
27490
+ import_fs11.default.mkdirSync(credsDir(slug), { recursive: true });
27100
27491
  writeJson(connectionPath(slug), conn.value);
27101
27492
  }
27102
27493
  consumed.push(name);
27103
27494
  }
27104
27495
  for (const name of [...consumed, "runner-config.json"]) {
27105
27496
  try {
27106
- import_fs10.default.rmSync(import_path22.default.join(CONFIG_DIR, name), { force: true });
27497
+ import_fs11.default.rmSync(import_path23.default.join(CONFIG_DIR, name), { force: true });
27107
27498
  } catch {
27108
27499
  }
27109
27500
  }
@@ -27115,14 +27506,14 @@ function encryptStrayPlaintextConfigs() {
27115
27506
  const OURS = /^(config|runner-config|space-.*)\.json(\.bak-\d+)?$/;
27116
27507
  let names = [];
27117
27508
  try {
27118
- names = import_fs10.default.readdirSync(CONFIG_DIR).filter((f) => OURS.test(f));
27509
+ names = import_fs11.default.readdirSync(CONFIG_DIR).filter((f) => OURS.test(f));
27119
27510
  } catch {
27120
27511
  return;
27121
27512
  }
27122
27513
  for (const name of names) {
27123
- const filePath = import_path22.default.join(CONFIG_DIR, name);
27514
+ const filePath = import_path23.default.join(CONFIG_DIR, name);
27124
27515
  try {
27125
- const raw = import_fs10.default.readFileSync(filePath, "utf-8");
27516
+ const raw = import_fs11.default.readFileSync(filePath, "utf-8");
27126
27517
  if (!raw.startsWith("{")) continue;
27127
27518
  writeSecureJson(filePath, JSON.parse(raw));
27128
27519
  } catch {
@@ -27145,7 +27536,7 @@ async function getConfiguredConnections() {
27145
27536
  for (const slug of listConfiguredSlugs()) {
27146
27537
  if (kinds.has("git") && kinds.has("onedrive")) break;
27147
27538
  if (readBoard(slug).githubToken) kinds.add("git");
27148
- if (import_fs10.default.existsSync(connectionPath(slug))) kinds.add("onedrive");
27539
+ if (import_fs11.default.existsSync(connectionPath(slug))) kinds.add("onedrive");
27149
27540
  }
27150
27541
  } catch {
27151
27542
  return [];
@@ -27157,7 +27548,7 @@ function listConfiguredSlugs() {
27157
27548
  try {
27158
27549
  migrateToSpaceFolders();
27159
27550
  return configDirNames().filter(
27160
- (slug) => import_fs10.default.existsSync(githubPath(slug)) || import_fs10.default.existsSync(connectionPath(slug))
27551
+ (slug) => import_fs11.default.existsSync(githubPath(slug)) || import_fs11.default.existsSync(connectionPath(slug))
27161
27552
  );
27162
27553
  } catch {
27163
27554
  return [];
@@ -27176,7 +27567,7 @@ var localConfigProvider = {
27176
27567
  // which is what makes disconnect stick.
27177
27568
  writeConfig(spaceSlug, config) {
27178
27569
  migrateToSpaceFolders();
27179
- import_fs10.default.mkdirSync(credsDir(spaceSlug), { recursive: true });
27570
+ import_fs11.default.mkdirSync(credsDir(spaceSlug), { recursive: true });
27180
27571
  writeJson(githubPath(spaceSlug), config);
27181
27572
  },
27182
27573
  readOneDriveConnection(spaceSlug) {
@@ -27190,7 +27581,7 @@ var localConfigProvider = {
27190
27581
  writeOneDriveConnection(spaceSlug, connection) {
27191
27582
  const filePath = connectionPath(spaceSlug);
27192
27583
  if (!connection) {
27193
- import_fs10.default.rmSync(filePath, { force: true });
27584
+ import_fs11.default.rmSync(filePath, { force: true });
27194
27585
  return;
27195
27586
  }
27196
27587
  writeJson(filePath, connection);
@@ -27213,9 +27604,10 @@ var localConfigProvider = {
27213
27604
  "builds",
27214
27605
  "secrets",
27215
27606
  "claude",
27216
- "codex"
27607
+ "codex",
27608
+ "scratchpad"
27217
27609
  ]) {
27218
- import_fs10.default.mkdirSync(import_path22.default.join(dir, sub), { recursive: true });
27610
+ import_fs11.default.mkdirSync(import_path23.default.join(dir, sub), { recursive: true });
27219
27611
  }
27220
27612
  return dir;
27221
27613
  }
@@ -27389,15 +27781,15 @@ async function startDaemon(config) {
27389
27781
  }
27390
27782
  );
27391
27783
  const indexCandidates = [
27392
- import_path23.default.join(__dirname, "..", "index"),
27393
- import_path23.default.join(__dirname, "..", "..", "index", "image")
27784
+ import_path24.default.join(__dirname, "..", "index"),
27785
+ import_path24.default.join(__dirname, "..", "..", "index", "image")
27394
27786
  ];
27395
- const indexDir = indexCandidates.find((p) => import_fs11.default.existsSync(p)) ?? indexCandidates[0];
27787
+ const indexDir = indexCandidates.find((p) => import_fs12.default.existsSync(p)) ?? indexCandidates[0];
27396
27788
  const odIndex = new IndexService({
27397
27789
  controlToken: (0, import_crypto11.createHash)("sha256").update(config.authToken).digest("hex"),
27398
27790
  embedUrl: () => config.serverUrl,
27399
27791
  indexDir,
27400
- dataDir: import_path23.default.join(CONFIG_DIR, "index-data")
27792
+ dataDir: import_path24.default.join(CONFIG_DIR, "index-data")
27401
27793
  });
27402
27794
  void odIndex.ensureRunning().catch((err) => {
27403
27795
  console.error(
@@ -28013,27 +28405,24 @@ ${err.fix}`);
28013
28405
  console.error("");
28014
28406
  process.exit(1);
28015
28407
  }
28016
- var MAX_SUPPORTED_NODE_MAJOR = 24;
28017
- function assertSupportedNode() {
28018
- const major = Number(process.versions.node.split(".")[0]);
28019
- if (Number.isNaN(major) || major <= MAX_SUPPORTED_NODE_MAJOR) return;
28020
- console.error(
28408
+ function warnUnsupportedNode() {
28409
+ if (indexNodeSupported()) return;
28410
+ const alternative = findIndexNode();
28411
+ console.warn(
28021
28412
  `
28022
- factiii-runner needs Node 20\u2013${MAX_SUPPORTED_NODE_MAJOR} (pinned 24.11.1); you are on Node ${process.versions.node}.
28023
- The bundled OneDrive index compiles better-sqlite3 from source, and that build fails on Node 25+.
28024
-
28025
- Switch with nvm: nvm install 24.11.1 && nvm use 24.11.1
28026
- then relaunch: factiii-runner start
28027
- `
28413
+ Node ${process.versions.node} is outside the range the OneDrive index supports (${MIN_INDEX_NODE_MAJOR}-${MAX_INDEX_NODE_MAJOR}, pinned 24.11.1).` + (alternative ? `
28414
+ The index will run under ${alternative}. Everything else is unaffected.
28415
+ ` : `
28416
+ OneDrive search will be unavailable until a supported Node is installed (nvm install 24.11.1). Everything else works.
28417
+ `)
28028
28418
  );
28029
- process.exit(1);
28030
28419
  }
28031
28420
  import_node_dns.default.setDefaultResultOrder("ipv4first");
28032
28421
  var program2 = new Command();
28033
28422
  program2.name("factiii-runner").description("Factiii Runner - run Board AI agents on remote servers").version(currentVersion());
28034
28423
  program2.command("setup").description("Verify the host toolchain and pair this runner").option("-s, --server <url>", "API server URL", "https://api.factiii.com").action(async (opts) => {
28035
28424
  try {
28036
- assertSupportedNode();
28425
+ warnUnsupportedNode();
28037
28426
  await setup(opts.server);
28038
28427
  } catch (err) {
28039
28428
  console.error(
@@ -28062,7 +28451,7 @@ program2.command("connect").description("Pair this runner with a Factiii account
28062
28451
  }
28063
28452
  });
28064
28453
  program2.command("start").description("Start the runner daemon").action(async () => {
28065
- assertSupportedNode();
28454
+ warnUnsupportedNode();
28066
28455
  if (!configExists()) {
28067
28456
  console.error('No config found. Run "factiii-runner setup" first.');
28068
28457
  process.exit(1);
@@ -28108,8 +28497,7 @@ program2.command("status").description("Show current runner configuration").acti
28108
28497
  console.log(`Token: \xB7\xB7\xB7\xB7${config.authToken.slice(-4)}`);
28109
28498
  });
28110
28499
  program2.command("doctor").description("Show where each host tool resolves, for a failed environment").action(async () => {
28111
- const nodeMajor = Number(process.versions.node.split(".")[0]);
28112
- const nodeNote = nodeMajor > MAX_SUPPORTED_NODE_MAJOR ? " (UNSUPPORTED \u2014 use 24.11.1)" : "";
28500
+ const nodeNote = indexNodeSupported() ? "" : ` (OneDrive index needs ${MIN_INDEX_NODE_MAJOR}-${MAX_INDEX_NODE_MAJOR}; ${findIndexNode() ?? "no supported node found"})`;
28113
28501
  console.log(`${"node".padEnd(14)} ${process.execPath} v${process.versions.node}${nodeNote}`);
28114
28502
  const report = await reportToolchain(hostTarget(CONFIG_DIR));
28115
28503
  for (const tool of report.tools) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@factiii/runner",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Factiii Runner, run Board AI agents on a machine you control. Pairs with the Factiii web/mobile clients over WebRTC.",
5
5
  "license": "ISC",
6
6
  "keywords": [
@@ -0,0 +1,98 @@
1
+ ---
2
+ name: scratchpad
3
+ description: Show the user something, or hand them a file, through the board's Scratchpad panel - by writing to $SCRATCHPAD. Anything you put there renders beside the terminal (image, screenshot, chart, video, audio, page, link, text) and can be saved to their machine or phone. TRIGGER when the user asks to see, show, preview, display or look at something; when you produce an image/diagram/chart/screenshot/recording; when you have a URL worth opening; when an answer is easier to look at than to read in a terminal; and whenever you need to GIVE the user a file - an export, a report, a bundle, a CSV, a log, a dump, anything they asked you to generate, save, download or send them. Writing it to $SCRATCHPAD is how a file gets off the runner and to the user.
4
+ ---
5
+
6
+ # scratchpad
7
+
8
+ The terminal you are running in has a panel beside it. Anything you put in the
9
+ `$SCRATCHPAD` directory shows up there, drawn rather than printed: images
10
+ render, video and audio play, links become clickable, text and markdown are
11
+ shown as they are.
12
+
13
+ ```bash
14
+ cp chart.png "$SCRATCHPAD/"
15
+ ```
16
+
17
+ That is the whole interface. There is no command to run, nothing to install,
18
+ and nothing to tell the panel: it is watching the directory and updates on
19
+ its own within a second.
20
+
21
+ ## What renders as what
22
+
23
+ The **extension decides**, so name the file for what it is:
24
+
25
+ | Write | You get |
26
+ |---|---|
27
+ | `.png .jpg .gif .webp .svg` | the image, click-to-open-full-size |
28
+ | `.mp4 .webm .mov` | a video player |
29
+ | `.mp3 .wav .m4a .ogg` | an audio player |
30
+ | `.html` | the page itself, rendered in a sandboxed frame |
31
+ | `.md` `.txt` and anything unlisted | the text as-is |
32
+ | `.url` | a clickable link (put the URL on the first line) |
33
+
34
+ A link:
35
+
36
+ ```bash
37
+ echo "https://example.com/the-thing" > "$SCRATCHPAD/preview.url"
38
+ ```
39
+
40
+ A note, when the answer is a shape rather than a sentence:
41
+
42
+ ```bash
43
+ cat > "$SCRATCHPAD/rollout-plan.md" <<'MD'
44
+ # Rollout
45
+ 1. staging, behind the flag
46
+ 2. 10% of prod for a day
47
+ 3. everyone
48
+ MD
49
+ ```
50
+
51
+ ## Handing a file to the user
52
+
53
+ Every item on the panel has a Save button, and the panel pulls the file off the
54
+ runner for them. So the scratchpad is not only for looking at: it is how you
55
+ give someone a file.
56
+
57
+ ```bash
58
+ cp coverage-report.csv "$SCRATCHPAD/"
59
+ zip -qr "$SCRATCHPAD/failing-run-logs.zip" logs/
60
+ pg_dump "$DATABASE_URL" > "$SCRATCHPAD/schema-before-migration.sql"
61
+ ```
62
+
63
+ A file meant for keeping does not have to render well. A `.zip` shows as
64
+ nothing much and a `.csv` as raw text, which is fine, because the user is going
65
+ to save it rather than read it in a narrow column. Name it for what it is so
66
+ they know what they are saving, and say in one line that it is there.
67
+
68
+ Nothing else you can do reaches the user's disk. If you write a file into the
69
+ workspace and tell them where it is, they have to go and get it themselves,
70
+ which on a phone means they cannot.
71
+
72
+ ## Rules
73
+
74
+ - **Flat, plain names.** Letters, numbers, dots, dashes, spaces and
75
+ underscores; no subdirectories, and never a leading dot. A name the panel
76
+ cannot show is a drawing the user never sees.
77
+ - **Newest is shown first.** Rewriting a file under the same name replaces its
78
+ card and moves it to the top, which is how you update a drawing rather than
79
+ pile up `chart-2.png`, `chart-3.png`.
80
+ - **Under 12MB to be shown inline.** A bigger file is still listed, and the
81
+ user can download it from the panel, but it will not render on the stage.
82
+ Trim a recording if you want it watched rather than saved.
83
+ - **The board is shared.** One scratchpad per board, not per session, and it
84
+ survives the session that drew on it. Clean up what has gone stale:
85
+ `rm "$SCRATCHPAD/old-thing.png"`.
86
+
87
+ ## When to reach for it
88
+
89
+ Use it whenever the answer is easier to look at than to read: a screenshot of
90
+ the change you just made, a generated chart, a diagram, a recorded repro, a
91
+ preview URL you just exposed, a table too wide for an 80-column shell.
92
+
93
+ And use it for anything the user is meant to end up holding: the export they
94
+ asked for, the report you generated, the logs from the run that failed, the
95
+ backup you just took.
96
+
97
+ Do not narrate it into the terminal as well. Write the file and say, in one
98
+ line, what you put on the scratchpad - the user is looking at the panel.