@peakflo/agent-harness 0.2.9-rc.1 → 0.2.9-rc.2

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/README.md CHANGED
@@ -4,7 +4,9 @@ Run a Peakflo coding agent on your machine.
4
4
 
5
5
  ## Quick start
6
6
 
7
- Use Node.js 22.19 or newer and install Git. Install the Pi coding agent once:
7
+ Use Node.js 22.19 or newer and install Git. The runner checks the Node.js
8
+ version when it starts and stops with one line if it is older (see
9
+ "Troubleshooting"). Install the Pi coding agent once:
8
10
 
9
11
  ```sh
10
12
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent
@@ -50,7 +52,15 @@ this runner".
50
52
 
51
53
  **When it looks.** Once after start, without delaying registration, and then
52
54
  every five minutes while no turn is running. A harness you install or sign in
53
- to later appears without a restart.
55
+ to later appears without a restart. At most two of these programs run at the
56
+ same time.
57
+
58
+ **A slow machine.** Some of these programs take several seconds to start. When
59
+ one does not answer in time, Peakflo shows it as "Installed — check timed out,
60
+ will retry", not as unavailable. The runner checks it again after 30 seconds,
61
+ then 60, then 120, and after that with the normal five-minute pass. A state
62
+ that was already known (signed in, or not signed in) is kept when a later
63
+ check times out.
54
64
 
55
65
  **What it runs.** To do this the runner starts those programs, as your user,
56
66
  with `--version` and a status or model-list command. It looks in the absolute
@@ -71,6 +81,27 @@ about what a third-party program prints.
71
81
  `PF_HARNESS_DISCOVERY=0`. The runner then starts none of these programs and
72
82
  reports no list. Running turns with Pi is not affected.
73
83
 
84
+ ## Troubleshooting
85
+
86
+ **`npx @peakflo/agent-harness` fails with a 404.** Your `~/.npmrc` maps the
87
+ `@peakflo` scope to another registry (for example GitHub Packages). Name the
88
+ public registry for this one command:
89
+
90
+ ```sh
91
+ npx --@peakflo:registry=https://registry.npmjs.org @peakflo/agent-harness@latest start
92
+ ```
93
+
94
+ **"pf-agent-harness needs Node.js 22.19 or newer".** Switch to a newer
95
+ Node.js, for example `nvm install 22 && nvm use 22`, and start again. npm only
96
+ prints an `EBADENGINE` warning for an older Node.js and installs anyway; the
97
+ runner is what stops. The requirement comes from Pi, which the runner starts
98
+ with the same Node.js. To start on an older Node.js at your own risk, set
99
+ `PF_ALLOW_UNSUPPORTED_NODE=1`.
100
+
101
+ **"Durable store unavailable, running in memory".** The runner could not
102
+ create or open its database under `--workspace-root`. It creates that
103
+ directory itself when it is missing, so check that the path is writable.
104
+
74
105
  ## Releasing
75
106
 
76
107
  `.github/scripts/agent-harness-release.mjs` publishes this package
package/dist/cli.js CHANGED
@@ -38,6 +38,7 @@ const adapters_1 = require("./adapters");
38
38
  const contract_1 = require("./contract");
39
39
  const credential_store_1 = require("./credential-store");
40
40
  const harness_1 = require("./harness");
41
+ const node_floor_1 = require("./node-floor");
41
42
  const options_1 = require("./options");
42
43
  const version_1 = require("./version");
43
44
  const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
@@ -81,6 +82,9 @@ Options:
81
82
  default for desktop/self-hosted; never runs
82
83
  in a sandbox.
83
84
 
85
+ Needs Node.js 22.19 or newer; start stops with one line on an older one.
86
+ PF_ALLOW_UNSUPPORTED_NODE=1 starts anyway, at your own risk.
87
+
84
88
  In a managed sandbox pf-bootstrap supplies these through the environment, so
85
89
  the enrolment token never appears in /proc/<pid>/cmdline.
86
90
  `;
@@ -144,6 +148,26 @@ async function main() {
144
148
  process.exitCode = 2;
145
149
  return;
146
150
  }
151
+ /*
152
+ * THE NODE.JS FLOOR — checked first, before the browser sign-in, so nobody
153
+ * signs in and picks an organisation only to be told to change Node. See
154
+ * `node-floor.ts` for where the floor comes from and why a sandbox only
155
+ * warns.
156
+ */
157
+ const nodeFloor = (0, node_floor_1.checkNodeFloor)({
158
+ current: process.versions.node,
159
+ floor: (0, version_1.getNodeFloor)(),
160
+ runtime: readOptionValue(argv.slice(1), 'runtime') ??
161
+ process.env.PF_RUNNER_RUNTIME,
162
+ env: process.env
163
+ });
164
+ if (nodeFloor.action === 'refuse') {
165
+ process.stderr.write(`${nodeFloor.message}\n`);
166
+ process.exit(1);
167
+ }
168
+ if (nodeFloor.action === 'warn') {
169
+ process.stderr.write(`Warning: ${nodeFloor.message}\n`);
170
+ }
147
171
  /*
148
172
  * NO TOKEN? OFFER TO SIGN IN, BUT ONLY WHERE THAT CAN POSSIBLY WORK.
149
173
  *
@@ -29,21 +29,44 @@
29
29
  * is local and is handed to each child process.
30
30
  */
31
31
  import type { RunnerHarness, RunnerHarnessModel } from './types';
32
- /** One command: `--version`, a status check, or a model listing. */
33
- export declare const DISCOVERY_COMMAND_TIMEOUT_MS = 8000;
34
- /** Everything for one harness. Harnesses run concurrently, so this is also the bound for the whole pass. */
35
- export declare const DISCOVERY_HARNESS_TIMEOUT_MS = 20000;
32
+ /**
33
+ * One command: `--version`, a status check, or a model listing.
34
+ *
35
+ * MEASURED, NOT GUESSED. On the Mac where the first release candidate was
36
+ * tried, with two runners and other work going on, `cursor-agent --version`
37
+ * took 9.4 s cold and 3.7 s warm ALONE, and 6-10 s when five harnesses were
38
+ * probed at once; `opencode --version` took 3-5 s alone and 7-9 s beside the
39
+ * others. The first limit was 8 s, so both lost on a busy machine and were
40
+ * shown as broken. The limit is now well above the slowest start seen — and
41
+ * the pass no longer starts every CLI at once (`DISCOVERY_CONCURRENCY`),
42
+ * which is what made each of them slow.
43
+ */
44
+ export declare const DISCOVERY_COMMAND_TIMEOUT_MS = 20000;
45
+ /**
46
+ * The budget for finding out a harness's STATE: its version and whether it is
47
+ * signed in. The model listing is NOT inside this budget — it has one command
48
+ * timeout of its own and can never change the state (see `probeHarness`).
49
+ */
50
+ export declare const DISCOVERY_HARNESS_TIMEOUT_MS = 45000;
51
+ /**
52
+ * How many harnesses are probed at the same time. Each harness runs its
53
+ * commands one after another, so this is also the most CLI processes
54
+ * discovery ever has running. Two, not five: these CLIs are heavy to start
55
+ * (most are bundled Node programs), and five cold starts at once roughly
56
+ * doubled the start time of each on the test machine.
57
+ */
58
+ export declare const DISCOVERY_CONCURRENCY = 2;
36
59
  /**
37
60
  * Every sentence a `reason` can be. A closed set on purpose: text printed by a
38
61
  * CLI can hold an account email or a key, and a constant cannot.
39
62
  */
40
63
  export declare const REASONS: {
41
64
  readonly notInstalled: "Not installed on this machine.";
42
- readonly versionTimedOut: "The version check timed out.";
65
+ readonly versionTimedOut: "Installed. The version check timed out and will be tried again.";
43
66
  readonly versionFailed: "The version check failed.";
44
- readonly discoveryTimedOut: "Discovery timed out.";
67
+ readonly discoveryTimedOut: "Installed. The check timed out and will be tried again.";
45
68
  readonly discoveryFailed: "Discovery failed.";
46
- readonly signInTimedOut: "The sign-in check timed out.";
69
+ readonly signInTimedOut: "Installed. The sign-in check timed out and will be tried again.";
47
70
  readonly signInUnknown: "Sign-in could not be confirmed.";
48
71
  readonly codexSignIn: "Not signed in. Run `codex login` on this machine.";
49
72
  readonly claudeSignIn: "Not signed in. Run `claude auth login` on this machine.";
@@ -81,6 +104,13 @@ export interface DiscoverHarnessesOptions {
81
104
  runnableBackends?: readonly string[];
82
105
  commandTimeoutMs?: number;
83
106
  harnessTimeoutMs?: number;
107
+ /** Harnesses probed at once. Defaults to `DISCOVERY_CONCURRENCY`. */
108
+ concurrency?: number;
109
+ /**
110
+ * Probe ONLY these harnesses and return only their entries. Used to check
111
+ * again the ones whose last check timed out, without starting the others.
112
+ */
113
+ only?: readonly string[];
84
114
  deps?: Partial<DiscoveryDeps>;
85
115
  }
86
116
  /**
@@ -137,6 +167,7 @@ export declare const KNOWN_HARNESSES: ReadonlyArray<{
137
167
  * Look for every known harness. ALWAYS resolves, with one entry per known
138
168
  * harness in a fixed order — a harness that is missing is reported as
139
169
  * `installed: false`, so the reader can tell "not installed" from "this runner
140
- * does not report harnesses".
170
+ * does not report harnesses". With `only`, just those harnesses are probed and
171
+ * returned.
141
172
  */
142
173
  export declare function discoverHarnesses(options?: DiscoverHarnessesOptions): Promise<RunnerHarness[]>;
@@ -30,7 +30,7 @@
30
30
  * is local and is handed to each child process.
31
31
  */
32
32
  Object.defineProperty(exports, "__esModule", { value: true });
33
- exports.KNOWN_HARNESSES = exports.CURSOR_STATIC_MODELS = exports.CODEX_STATIC_MODELS = exports.CLAUDE_STATIC_MODELS = exports.REASONS = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = void 0;
33
+ exports.KNOWN_HARNESSES = exports.CURSOR_STATIC_MODELS = exports.CODEX_STATIC_MODELS = exports.CLAUDE_STATIC_MODELS = exports.REASONS = exports.DISCOVERY_CONCURRENCY = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = void 0;
34
34
  exports.discoverySearchPath = discoverySearchPath;
35
35
  exports.discoveryChildEnv = discoveryChildEnv;
36
36
  exports.sanitizeHarnessModels = sanitizeHarnessModels;
@@ -45,10 +45,33 @@ const node_child_process_1 = require("node:child_process");
45
45
  const node_fs_1 = require("node:fs");
46
46
  const node_os_1 = require("node:os");
47
47
  const node_path_1 = require("node:path");
48
- /** One command: `--version`, a status check, or a model listing. */
49
- exports.DISCOVERY_COMMAND_TIMEOUT_MS = 8_000;
50
- /** Everything for one harness. Harnesses run concurrently, so this is also the bound for the whole pass. */
51
- exports.DISCOVERY_HARNESS_TIMEOUT_MS = 20_000;
48
+ /**
49
+ * One command: `--version`, a status check, or a model listing.
50
+ *
51
+ * MEASURED, NOT GUESSED. On the Mac where the first release candidate was
52
+ * tried, with two runners and other work going on, `cursor-agent --version`
53
+ * took 9.4 s cold and 3.7 s warm ALONE, and 6-10 s when five harnesses were
54
+ * probed at once; `opencode --version` took 3-5 s alone and 7-9 s beside the
55
+ * others. The first limit was 8 s, so both lost on a busy machine and were
56
+ * shown as broken. The limit is now well above the slowest start seen — and
57
+ * the pass no longer starts every CLI at once (`DISCOVERY_CONCURRENCY`),
58
+ * which is what made each of them slow.
59
+ */
60
+ exports.DISCOVERY_COMMAND_TIMEOUT_MS = 20_000;
61
+ /**
62
+ * The budget for finding out a harness's STATE: its version and whether it is
63
+ * signed in. The model listing is NOT inside this budget — it has one command
64
+ * timeout of its own and can never change the state (see `probeHarness`).
65
+ */
66
+ exports.DISCOVERY_HARNESS_TIMEOUT_MS = 45_000;
67
+ /**
68
+ * How many harnesses are probed at the same time. Each harness runs its
69
+ * commands one after another, so this is also the most CLI processes
70
+ * discovery ever has running. Two, not five: these CLIs are heavy to start
71
+ * (most are bundled Node programs), and five cold starts at once roughly
72
+ * doubled the start time of each on the test machine.
73
+ */
74
+ exports.DISCOVERY_CONCURRENCY = 2;
52
75
  const MAX_MODELS_PER_HARNESS = 200;
53
76
  const MAX_OUTPUT_BYTES = 1024 * 1024;
54
77
  /**
@@ -57,11 +80,16 @@ const MAX_OUTPUT_BYTES = 1024 * 1024;
57
80
  */
58
81
  exports.REASONS = {
59
82
  notInstalled: 'Not installed on this machine.',
60
- versionTimedOut: 'The version check timed out.',
83
+ /*
84
+ * The three "timed out" sentences describe a CHECK that did not finish, not
85
+ * the harness. Each goes out with `transient: true`, and the runner checks
86
+ * again shortly.
87
+ */
88
+ versionTimedOut: 'Installed. The version check timed out and will be tried again.',
61
89
  versionFailed: 'The version check failed.',
62
- discoveryTimedOut: 'Discovery timed out.',
90
+ discoveryTimedOut: 'Installed. The check timed out and will be tried again.',
63
91
  discoveryFailed: 'Discovery failed.',
64
- signInTimedOut: 'The sign-in check timed out.',
92
+ signInTimedOut: 'Installed. The sign-in check timed out and will be tried again.',
65
93
  signInUnknown: 'Sign-in could not be confirmed.',
66
94
  codexSignIn: 'Not signed in. Run `codex login` on this machine.',
67
95
  claudeSignIn: 'Not signed in. Run `claude auth login` on this machine.',
@@ -325,6 +353,11 @@ function parseVersion(output) {
325
353
  const match = cleanLine(output).match(/(\d+\.\d+[\w.+-]*)/);
326
354
  return match ? match[1].slice(0, 64) : null;
327
355
  }
356
+ const SIGN_IN_TIMED_OUT = {
357
+ usable: false,
358
+ reason: exports.REASONS.signInTimedOut,
359
+ transient: true
360
+ };
328
361
  function hasEnv(env, names) {
329
362
  return names.some((name) => Boolean(env[name]));
330
363
  }
@@ -405,7 +438,7 @@ const CODEX = {
405
438
  // Exit code only. The reply names the sign-in method, which is not needed.
406
439
  const status = await run(['login', 'status']);
407
440
  if (status.timedOut)
408
- return { usable: false, reason: exports.REASONS.signInTimedOut };
441
+ return SIGN_IN_TIMED_OUT;
409
442
  // The exit code decides; the text is only tested for a plain "not
410
443
  // logged in", in case a version exits 0 while saying so. Nothing is kept.
411
444
  return status.code === 0 &&
@@ -446,7 +479,7 @@ const CLAUDE = {
446
479
  }
447
480
  const status = await run(['auth', 'status']);
448
481
  if (status.timedOut)
449
- return { usable: false, reason: exports.REASONS.signInTimedOut };
482
+ return SIGN_IN_TIMED_OUT;
450
483
  /*
451
484
  * The reply is JSON that also holds the account email and organisation.
452
485
  * ONE boolean is read from it and the rest is dropped here.
@@ -528,7 +561,7 @@ const CURSOR = {
528
561
  return { usable: true };
529
562
  const status = await run(['status']);
530
563
  if (status.timedOut)
531
- return { usable: false, reason: exports.REASONS.signInTimedOut };
564
+ return SIGN_IN_TIMED_OUT;
532
565
  // The reply names the account. It is tested here and never stored.
533
566
  const text = `${status.stdout}\n${status.stderr}`;
534
567
  // The negative test comes first and wins.
@@ -601,10 +634,7 @@ exports.KNOWN_HARNESSES = [
601
634
  OPENCODE
602
635
  ].map(({ name, label }) => ({ name, label }));
603
636
  const SPECS = [CODEX, CLAUDE, CURSOR, PI, OPENCODE];
604
- /* -------------------------------------------------------------------------- */
605
- /* The pass */
606
- /* -------------------------------------------------------------------------- */
607
- async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
637
+ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired, progress) {
608
638
  let path = null;
609
639
  for (const command of spec.commands) {
610
640
  const found = deps.resolveCommand(command);
@@ -623,9 +653,9 @@ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
623
653
  path: resolved,
624
654
  deps,
625
655
  /*
626
- * NOTHING IS STARTED AFTER THE HARNESS DEADLINE. The report has already
627
- * been sent without this harness by then, so a sign-in check or a model
628
- * listing started now would be a process nobody is waiting for.
656
+ * NOTHING IS STARTED AFTER THE STATE DEADLINE. The report has already
657
+ * been sent without this harness by then, so a sign-in check started now
658
+ * would be a process nobody is waiting for.
629
659
  */
630
660
  run: (args) => isExpired()
631
661
  ? Promise.resolve({
@@ -638,7 +668,10 @@ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
638
668
  };
639
669
  const version = await context.run(['--version']);
640
670
  if (version.timedOut) {
671
+ // The binary EXISTS and did not answer in time. That is a slow machine,
672
+ // not a broken harness, so it is reported as "will be tried again".
641
673
  entry.reason = exports.REASONS.versionTimedOut;
674
+ entry.transient = true;
642
675
  return;
643
676
  }
644
677
  if (version.code !== 0) {
@@ -652,22 +685,57 @@ async function probeHarness(spec, entry, deps, commandTimeoutMs, isExpired) {
652
685
  entry.usable = auth.usable;
653
686
  if (!auth.usable && auth.reason)
654
687
  entry.reason = auth.reason;
655
- const listed = await spec.listModels(context, auth.usable);
656
- if (listed && listed.models.length > 0) {
657
- entry.models = listed.models;
658
- entry.modelSource = listed.source;
688
+ if (!auth.usable && auth.transient)
689
+ entry.transient = true;
690
+ /*
691
+ * THE STATE IS SETTLED HERE, and the state deadline stops counting.
692
+ *
693
+ * The model listing below is extra information. It gets one command timeout
694
+ * of its own and its result can only ADD models: a listing that is slow or
695
+ * fails leaves `usable` exactly as the sign-in check found it. Before this,
696
+ * the listing shared one 20 s budget with the two checks above, and a slow
697
+ * `cursor-agent models` or `opencode models` turned a signed-in harness
698
+ * into "timed out".
699
+ */
700
+ progress.stateKnown = true;
701
+ progress.onStateKnown?.();
702
+ try {
703
+ const listed = await spec.listModels(context, auth.usable);
704
+ if (listed && listed.models.length > 0) {
705
+ entry.models = listed.models;
706
+ entry.modelSource = listed.source;
707
+ }
708
+ }
709
+ catch {
710
+ // No models. The state above stands.
659
711
  }
660
712
  }
713
+ /** Run `task` for every item, at most `limit` at a time, keeping the order. */
714
+ async function mapWithLimit(items, limit, task) {
715
+ const results = new Array(items.length);
716
+ let next = 0;
717
+ const worker = async () => {
718
+ while (next < items.length) {
719
+ const index = next++;
720
+ results[index] = await task(items[index]);
721
+ }
722
+ };
723
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
724
+ return results;
725
+ }
661
726
  /**
662
727
  * Look for every known harness. ALWAYS resolves, with one entry per known
663
728
  * harness in a fixed order — a harness that is missing is reported as
664
729
  * `installed: false`, so the reader can tell "not installed" from "this runner
665
- * does not report harnesses".
730
+ * does not report harnesses". With `only`, just those harnesses are probed and
731
+ * returned.
666
732
  */
667
733
  async function discoverHarnesses(options = {}) {
668
734
  const commandTimeoutMs = options.commandTimeoutMs ?? exports.DISCOVERY_COMMAND_TIMEOUT_MS;
669
735
  const harnessTimeoutMs = options.harnessTimeoutMs ?? exports.DISCOVERY_HARNESS_TIMEOUT_MS;
670
736
  const runnable = new Set(options.runnableBackends ?? []);
737
+ const only = options.only ? new Set(options.only) : null;
738
+ const specs = only ? SPECS.filter((spec) => only.has(spec.name)) : SPECS;
671
739
  let deps = null;
672
740
  let detectedAt = new Date().toISOString();
673
741
  try {
@@ -678,10 +746,11 @@ async function discoverHarnesses(options = {}) {
678
746
  // `os.homedir()` throws on some locked-down hosts. Report every harness
679
747
  // as not found rather than fail the runner.
680
748
  }
681
- return Promise.all(SPECS.map(async (spec) => {
749
+ const resolvedDeps = deps;
750
+ return mapWithLimit(specs, options.concurrency ?? exports.DISCOVERY_CONCURRENCY, async (spec) => {
682
751
  /*
683
752
  * The entry is filled in as the probe goes, so a deadline or a throw
684
- * part-way still reports what was already learnt (found, and where).
753
+ * part-way still reports what was already learnt.
685
754
  */
686
755
  const entry = {
687
756
  name: spec.name,
@@ -692,36 +761,52 @@ async function discoverHarnesses(options = {}) {
692
761
  runnable: false,
693
762
  detectedAt
694
763
  };
695
- if (!deps) {
764
+ if (!resolvedDeps) {
696
765
  entry.reason = exports.REASONS.discoveryFailed;
697
766
  return entry;
698
767
  }
699
768
  let timer = null;
700
769
  let expired = false;
701
- const work = probeHarness(spec, entry, deps, commandTimeoutMs, () => expired).then(() => 'done', () => 'failed');
770
+ const progress = { stateKnown: false };
702
771
  const deadline = new Promise((resolve) => {
703
772
  timer = setTimeout(() => {
704
773
  expired = true;
705
774
  resolve('expired');
706
775
  }, harnessTimeoutMs);
707
776
  timer.unref();
777
+ // The deadline is for the STATE only. Once that is known the model
778
+ // listing runs on its own command timeout.
779
+ progress.onStateKnown = () => {
780
+ if (timer)
781
+ clearTimeout(timer);
782
+ timer = null;
783
+ };
708
784
  });
785
+ const work = probeHarness(spec, entry, resolvedDeps, commandTimeoutMs, () => expired, progress).then(() => 'done', () => 'failed');
709
786
  const outcome = await Promise.race([work, deadline]);
710
787
  if (timer)
711
788
  clearTimeout(timer);
712
789
  // A copy, so a probe still running after its deadline cannot change
713
790
  // what was reported.
714
791
  const report = { ...entry };
715
- if (outcome !== 'done') {
792
+ if (outcome === 'expired') {
793
+ // Found, and the checks did not finish in time: "will be tried again".
794
+ report.usable = false;
795
+ report.reason = exports.REASONS.discoveryTimedOut;
796
+ if (report.installed)
797
+ report.transient = true;
798
+ delete report.models;
799
+ delete report.modelSource;
800
+ }
801
+ else if (outcome === 'failed' && !progress.stateKnown) {
716
802
  report.usable = false;
717
- report.reason = expired
718
- ? exports.REASONS.discoveryTimedOut
719
- : exports.REASONS.discoveryFailed;
803
+ report.reason = exports.REASONS.discoveryFailed;
804
+ delete report.transient;
720
805
  delete report.models;
721
806
  delete report.modelSource;
722
807
  }
723
808
  // Runnable needs both: the runner drives it AND the binary is there.
724
809
  report.runnable = report.installed && runnable.has(spec.name);
725
810
  return report;
726
- }));
811
+ });
727
812
  }
package/dist/harness.d.ts CHANGED
@@ -14,6 +14,22 @@ import { HarnessOptions, HealthReport, RunnerHarness } from './types';
14
14
  * every heartbeat sends the cached answer.
15
15
  */
16
16
  export declare const HARNESS_DISCOVERY_REFRESH_MS: number;
17
+ /**
18
+ * How long after a pass the checks that TIMED OUT are tried again: three
19
+ * times, each wait twice the one before. See `scheduleHarnessRetry`.
20
+ */
21
+ export declare const HARNESS_RETRY_DELAYS_MS: readonly number[];
22
+ /**
23
+ * Put the result of a pass together with what was known before.
24
+ *
25
+ * KEEP THE LAST GOOD STATE. A check that timed out learnt nothing, so when
26
+ * the previous pass DID settle that harness (signed in, signed out, version
27
+ * failed) that answer stays. Only a harness with no settled answer yet is
28
+ * shown as "check timed out, will be tried again".
29
+ *
30
+ * A retry pass returns only the harnesses it probed; the others are kept.
31
+ */
32
+ export declare function mergeHarnessReports(previous: readonly RunnerHarness[] | null, fresh: readonly RunnerHarness[]): RunnerHarness[];
17
33
  /** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
18
34
  export declare const HARNESS_DISCOVERY_FEATURE = "harnessDiscovery";
19
35
  /**
@@ -182,6 +198,10 @@ export declare class Harness {
182
198
  private harnessReport;
183
199
  /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
184
200
  private harnessDiscoveryAt;
201
+ /** Epoch ms of the next retry of timed-out checks, or null when none is due. */
202
+ private harnessRetryAt;
203
+ /** Retries scheduled in a row. Back to 0 once nothing is waiting. */
204
+ private harnessRetryAttempts;
185
205
  private harnessDiscoveryInFlight;
186
206
  /**
187
207
  * The `harnessesDigest` the control plane said it has stored, from its last
@@ -319,6 +339,16 @@ export declare class Harness {
319
339
  * returns the running one.
320
340
  */
321
341
  private refreshHarnessDiscovery;
342
+ /**
343
+ * After a pass: check again SOON when a check timed out, instead of leaving
344
+ * "timed out" on screen for a whole interval.
345
+ *
346
+ * Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
347
+ * five-minute pass takes over. A machine that is slow for good must not be
348
+ * probed for ever at a short interval. The counter starts again once
349
+ * nothing is waiting.
350
+ */
351
+ private scheduleHarnessRetry;
322
352
  /**
323
353
  * Attach the cached harness report to a capability report.
324
354
  *
package/dist/harness.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = exports.HARNESS_DISCOVERY_FEATURE = exports.HARNESS_DISCOVERY_REFRESH_MS = void 0;
3
+ exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = exports.HARNESS_DISCOVERY_FEATURE = exports.HARNESS_RETRY_DELAYS_MS = exports.HARNESS_DISCOVERY_REFRESH_MS = void 0;
4
+ exports.mergeHarnessReports = mergeHarnessReports;
4
5
  exports.harnessContentKey = harnessContentKey;
5
6
  const node_crypto_1 = require("node:crypto");
6
7
  const node_path_1 = require("node:path");
@@ -41,6 +42,45 @@ const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
41
42
  * every heartbeat sends the cached answer.
42
43
  */
43
44
  exports.HARNESS_DISCOVERY_REFRESH_MS = 5 * 60 * 1000;
45
+ /**
46
+ * How long after a pass the checks that TIMED OUT are tried again: three
47
+ * times, each wait twice the one before. See `scheduleHarnessRetry`.
48
+ */
49
+ exports.HARNESS_RETRY_DELAYS_MS = [
50
+ 30_000, 60_000, 120_000
51
+ ];
52
+ /**
53
+ * Put the result of a pass together with what was known before.
54
+ *
55
+ * KEEP THE LAST GOOD STATE. A check that timed out learnt nothing, so when
56
+ * the previous pass DID settle that harness (signed in, signed out, version
57
+ * failed) that answer stays. Only a harness with no settled answer yet is
58
+ * shown as "check timed out, will be tried again".
59
+ *
60
+ * A retry pass returns only the harnesses it probed; the others are kept.
61
+ */
62
+ function mergeHarnessReports(previous, fresh) {
63
+ if (!previous)
64
+ return [...fresh];
65
+ const before = new Map(previous.map((harness) => [harness.name, harness]));
66
+ const after = new Map(fresh.map((harness) => [harness.name, harness]));
67
+ const pick = (name) => {
68
+ const now = after.get(name);
69
+ const then = before.get(name);
70
+ if (!now)
71
+ return then;
72
+ return now.transient && then && then.installed && !then.transient
73
+ ? then
74
+ : now;
75
+ };
76
+ const names = [
77
+ ...previous.map((harness) => harness.name),
78
+ ...fresh.map((harness) => harness.name).filter((name) => !before.has(name))
79
+ ];
80
+ return names
81
+ .map(pick)
82
+ .filter((harness) => harness !== undefined);
83
+ }
44
84
  /** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
45
85
  exports.HARNESS_DISCOVERY_FEATURE = 'harnessDiscovery';
46
86
  /**
@@ -169,6 +209,10 @@ class Harness {
169
209
  harnessReport = null;
170
210
  /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
171
211
  harnessDiscoveryAt = 0;
212
+ /** Epoch ms of the next retry of timed-out checks, or null when none is due. */
213
+ harnessRetryAt = null;
214
+ /** Retries scheduled in a row. Back to 0 once nothing is waiting. */
215
+ harnessRetryAttempts = 0;
172
216
  harnessDiscoveryInFlight = null;
173
217
  /**
174
218
  * The `harnessesDigest` the control plane said it has stored, from its last
@@ -325,13 +369,6 @@ class Harness {
325
369
  maxConcurrentSessions: this.options.maxConcurrentSessions,
326
370
  labels: this.options.labels
327
371
  });
328
- /*
329
- * STARTED, NEVER AWAITED. Registration must not wait for a third-party
330
- * CLI: a pass can take as long as its slowest harness (20 s), and a runner
331
- * that is slow to register looks like a runner that did not come back. The
332
- * report is attached to the first heartbeat after the pass has finished.
333
- */
334
- void this.refreshHarnessDiscovery();
335
372
  // Install immediately before model probing can start a backend. The lease
336
373
  // is process-wide and reference-counted, so concurrent Harness instances
337
374
  // share one generic launcher instead of racing to rewrite global PATH.
@@ -347,6 +384,19 @@ class Harness {
347
384
  */
348
385
  await this.refreshModelCatalog(true);
349
386
  this.applyModelCatalog(this.capabilities);
387
+ /*
388
+ * STARTED, NEVER AWAITED. Registration must not wait for a third-party
389
+ * CLI, and a runner that is slow to register looks like a runner that did
390
+ * not come back. The report is attached to the first heartbeat after the
391
+ * pass has finished.
392
+ *
393
+ * STARTED HERE, AFTER THE MODEL PROBE, and not beside it. The first
394
+ * release candidate started both at once: the Pi discovery process and
395
+ * five harness CLIs all cold-starting together, on a machine that was
396
+ * also starting this very process through npx. Two harnesses lost their
397
+ * version check to that and were shown as broken for five minutes.
398
+ */
399
+ void this.refreshHarnessDiscovery();
350
400
  // Attaches the report only if the pass has already finished.
351
401
  this.applyHarnessReport(this.capabilities);
352
402
  log('Harness starting', {
@@ -795,13 +845,31 @@ class Harness {
795
845
  if (this.harnessDiscoveryInFlight) {
796
846
  return this.harnessDiscoveryInFlight;
797
847
  }
798
- this.harnessDiscoveryAt = Date.now();
848
+ const now = Date.now();
849
+ /*
850
+ * A RETRY PASS probes only the harnesses whose last check timed out. A
851
+ * full pass, when one is due anyway, covers them too.
852
+ */
853
+ const fullPassDue = !this.harnessReport ||
854
+ now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
855
+ const retryNames = (this.harnessReport ?? [])
856
+ .filter((harness) => harness.transient)
857
+ .map((harness) => harness.name);
858
+ const only = !fullPassDue && this.harnessRetryAt !== null && retryNames.length > 0
859
+ ? retryNames
860
+ : undefined;
861
+ if (!only) {
862
+ this.harnessDiscoveryAt = now;
863
+ }
864
+ this.harnessRetryAt = null;
799
865
  const discover = this.deps.discoverHarnesses ?? harness_discovery_1.discoverHarnesses;
800
866
  const pass = (async () => {
801
867
  try {
802
- const report = await discover({
803
- runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name)
868
+ const fresh = await discover({
869
+ runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name),
870
+ ...(only ? { only } : {})
804
871
  });
872
+ const report = mergeHarnessReports(this.harnessReport, fresh);
805
873
  // Same content as before: keep the OLD report, with its
806
874
  // `detectedAt`. A pass that found nothing new must not look like a
807
875
  // change, or the whole list would be sent again every five minutes.
@@ -809,13 +877,20 @@ class Harness {
809
877
  harnessContentKey(report) !== harnessContentKey(this.harnessReport)) {
810
878
  this.harnessReport = report;
811
879
  }
880
+ this.scheduleHarnessRetry(report);
812
881
  log('Harnesses discovered', {
813
882
  installed: report
814
883
  .filter((harness) => harness.installed)
815
884
  .map((harness) => harness.name),
816
885
  usable: report
817
886
  .filter((harness) => harness.usable)
818
- .map((harness) => harness.name)
887
+ .map((harness) => harness.name),
888
+ // Named so a slow machine can be told from a broken harness in
889
+ // the log: these are checked again, the others are settled.
890
+ retrying: report
891
+ .filter((harness) => harness.transient)
892
+ .map((harness) => harness.name),
893
+ ...(only ? { pass: 'retry' } : {})
819
894
  });
820
895
  }
821
896
  catch {
@@ -830,6 +905,29 @@ class Harness {
830
905
  this.harnessDiscoveryInFlight = pass;
831
906
  return pass;
832
907
  }
908
+ /**
909
+ * After a pass: check again SOON when a check timed out, instead of leaving
910
+ * "timed out" on screen for a whole interval.
911
+ *
912
+ * Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
913
+ * five-minute pass takes over. A machine that is slow for good must not be
914
+ * probed for ever at a short interval. The counter starts again once
915
+ * nothing is waiting.
916
+ */
917
+ scheduleHarnessRetry(report) {
918
+ if (!report.some((harness) => harness.transient)) {
919
+ this.harnessRetryAttempts = 0;
920
+ this.harnessRetryAt = null;
921
+ return;
922
+ }
923
+ const delay = exports.HARNESS_RETRY_DELAYS_MS[this.harnessRetryAttempts];
924
+ if (delay === undefined) {
925
+ this.harnessRetryAt = null;
926
+ return;
927
+ }
928
+ this.harnessRetryAttempts += 1;
929
+ this.harnessRetryAt = Date.now() + delay;
930
+ }
833
931
  /**
834
932
  * Attach the cached harness report to a capability report.
835
933
  *
@@ -865,6 +963,7 @@ class Harness {
865
963
  version: backend.version ?? harness.version ?? null
866
964
  };
867
965
  delete entry.reason;
966
+ delete entry.transient;
868
967
  if (!entry.models && backend.models && backend.models.length > 0) {
869
968
  entry.models = backend.models.map(({ id, name }) => ({ id, name }));
870
969
  entry.modelSource = 'backend';
@@ -907,7 +1006,11 @@ class Harness {
907
1006
  if (!this.harnessDiscoveryEnabled() || this.active.size > 0) {
908
1007
  return false;
909
1008
  }
910
- return Date.now() - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
1009
+ const now = Date.now();
1010
+ if (this.harnessRetryAt !== null && now >= this.harnessRetryAt) {
1011
+ return true;
1012
+ }
1013
+ return now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
911
1014
  }
912
1015
  /**
913
1016
  * Whether the model catalogue needs a (rare) re-probe.
@@ -0,0 +1,46 @@
1
+ /**
2
+ * THE NODE.JS FLOOR, CHECKED AT START.
3
+ *
4
+ * `package.json` says `engines.node: ">=22.19"`. npm treats that as advice: on
5
+ * an older Node it prints EBADENGINE and installs anyway, and `npx` then starts
6
+ * the runner as if nothing was wrong. So the floor was a line in a manifest
7
+ * that nothing enforced.
8
+ *
9
+ * WHERE THE FLOOR COMES FROM. Not from this package's own code — that needs
10
+ * `node:sqlite` without a flag (Node 22.13). It comes from Pi, the one backend
11
+ * this runner runs turns with: `@earendil-works/pi-coding-agent` declares
12
+ * `engines.node >=22.19.0`, and Pi's launcher is `#!/usr/bin/env node`, so Pi
13
+ * normally runs on the same Node as the runner. Pi 1.1.0 was seen to start on
14
+ * 22.14 (`--version`, `--list-models`), and nobody has shown that a full turn
15
+ * works there. An upstream requirement that is not proven false is kept.
16
+ *
17
+ * WHAT HAPPENS BELOW THE FLOOR. A desktop or self-hosted runner refuses to
18
+ * start, with one line that says what to do. `PF_ALLOW_UNSUPPORTED_NODE=1`
19
+ * starts it anyway, with a one-line warning, for someone who knows their
20
+ * setup works. A SANDBOX only warns: its Node is whatever the image was built
21
+ * with, nobody is at a terminal to read the line, and a refusal there would
22
+ * take a cloud runner out of service for a requirement of a tool the image
23
+ * already carries.
24
+ *
25
+ * PURE: no process state is read here, so every branch can be tested.
26
+ */
27
+ /** Parse `22.19`, `v22.19.0`, `>=22.19` into numbers. Null when it is none of those. */
28
+ export declare function parseNodeVersion(text: string): number[] | null;
29
+ export declare function isNodeVersionAtLeast(current: string, floor: string): boolean;
30
+ export type NodeFloorVerdict = {
31
+ action: 'start';
32
+ } | {
33
+ action: 'warn';
34
+ message: string;
35
+ } | {
36
+ action: 'refuse';
37
+ message: string;
38
+ };
39
+ export declare function checkNodeFloor(input: {
40
+ /** `process.versions.node`, e.g. `22.14.0`. */
41
+ current: string;
42
+ /** `engines.node` of this package, e.g. `>=22.19`. Null when unreadable. */
43
+ floor: string | null;
44
+ runtime: string | undefined;
45
+ env: NodeJS.ProcessEnv;
46
+ }): NodeFloorVerdict;
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ /**
3
+ * THE NODE.JS FLOOR, CHECKED AT START.
4
+ *
5
+ * `package.json` says `engines.node: ">=22.19"`. npm treats that as advice: on
6
+ * an older Node it prints EBADENGINE and installs anyway, and `npx` then starts
7
+ * the runner as if nothing was wrong. So the floor was a line in a manifest
8
+ * that nothing enforced.
9
+ *
10
+ * WHERE THE FLOOR COMES FROM. Not from this package's own code — that needs
11
+ * `node:sqlite` without a flag (Node 22.13). It comes from Pi, the one backend
12
+ * this runner runs turns with: `@earendil-works/pi-coding-agent` declares
13
+ * `engines.node >=22.19.0`, and Pi's launcher is `#!/usr/bin/env node`, so Pi
14
+ * normally runs on the same Node as the runner. Pi 1.1.0 was seen to start on
15
+ * 22.14 (`--version`, `--list-models`), and nobody has shown that a full turn
16
+ * works there. An upstream requirement that is not proven false is kept.
17
+ *
18
+ * WHAT HAPPENS BELOW THE FLOOR. A desktop or self-hosted runner refuses to
19
+ * start, with one line that says what to do. `PF_ALLOW_UNSUPPORTED_NODE=1`
20
+ * starts it anyway, with a one-line warning, for someone who knows their
21
+ * setup works. A SANDBOX only warns: its Node is whatever the image was built
22
+ * with, nobody is at a terminal to read the line, and a refusal there would
23
+ * take a cloud runner out of service for a requirement of a tool the image
24
+ * already carries.
25
+ *
26
+ * PURE: no process state is read here, so every branch can be tested.
27
+ */
28
+ Object.defineProperty(exports, "__esModule", { value: true });
29
+ exports.parseNodeVersion = parseNodeVersion;
30
+ exports.isNodeVersionAtLeast = isNodeVersionAtLeast;
31
+ exports.checkNodeFloor = checkNodeFloor;
32
+ /** Parse `22.19`, `v22.19.0`, `>=22.19` into numbers. Null when it is none of those. */
33
+ function parseNodeVersion(text) {
34
+ const match = text.trim().match(/^(?:>=\s*)?v?(\d+)(?:\.(\d+))?(?:\.(\d+))?/);
35
+ if (!match)
36
+ return null;
37
+ return [match[1], match[2] ?? '0', match[3] ?? '0'].map(Number);
38
+ }
39
+ function isNodeVersionAtLeast(current, floor) {
40
+ const have = parseNodeVersion(current);
41
+ const need = parseNodeVersion(floor);
42
+ // A floor or a version that cannot be read is not a reason to refuse.
43
+ if (!have || !need)
44
+ return true;
45
+ for (let index = 0; index < 3; index++) {
46
+ if (have[index] > need[index])
47
+ return true;
48
+ if (have[index] < need[index])
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+ function checkNodeFloor(input) {
54
+ if (!input.floor || isNodeVersionAtLeast(input.current, input.floor)) {
55
+ return { action: 'start' };
56
+ }
57
+ const need = (parseNodeVersion(input.floor) ?? []).slice(0, 2).join('.');
58
+ const problem = `pf-agent-harness needs Node.js ${need} or newer; this is Node.js ${input.current}.`;
59
+ const allowed = /^(1|true|yes|on)$/i.test((input.env.PF_ALLOW_UNSUPPORTED_NODE ?? '').trim());
60
+ if (allowed) {
61
+ return {
62
+ action: 'warn',
63
+ message: `${problem} Starting anyway because PF_ALLOW_UNSUPPORTED_NODE is set.`
64
+ };
65
+ }
66
+ if (input.runtime === 'sandbox') {
67
+ return {
68
+ action: 'warn',
69
+ message: `${problem} Starting anyway in a sandbox.`
70
+ };
71
+ }
72
+ return {
73
+ action: 'refuse',
74
+ message: `${problem} Switch to a newer Node.js (for example \`nvm install 22 && nvm use 22\`) and start again.`
75
+ };
76
+ }
@@ -27,6 +27,7 @@
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.MemoryConversationStore = exports.SqliteConversationStore = void 0;
29
29
  exports.openConversationStore = openConversationStore;
30
+ const sqlite_store_1 = require("./sqlite-store");
30
31
  const SCHEMA = `
31
32
  CREATE TABLE IF NOT EXISTS model_messages (
32
33
  session_id TEXT NOT NULL,
@@ -292,6 +293,7 @@ function openConversationStore(path) {
292
293
  };
293
294
  }
294
295
  try {
296
+ (0, sqlite_store_1.ensureStoreDirectory)(path);
295
297
  const db = new sqlite.DatabaseSync(path);
296
298
  return { store: new SqliteConversationStore(db), durable: true };
297
299
  }
@@ -106,6 +106,23 @@ export interface OpenStoreResult {
106
106
  /** Why durability was unavailable. Reported so a degraded fleet is visible. */
107
107
  reason?: string;
108
108
  }
109
+ /**
110
+ * Make the directory a store file goes in, when it is not there yet.
111
+ *
112
+ * sqlite creates the FILE and never the directory: a `--workspace-root` that
113
+ * does not exist yet made every open fail with "unable to open database
114
+ * file", and the runner then ran in memory and reported itself degraded —
115
+ * for a path it was about to create anyway on the first session. Private
116
+ * mode, because the stores hold transcripts and the model's conversation.
117
+ *
118
+ * A directory that ALREADY EXISTS is left exactly as it is, its mode included:
119
+ * `mkdir` with `recursive` does not touch one, and changing the permissions
120
+ * of a directory somebody chose is not this function's call.
121
+ *
122
+ * Throws when the directory cannot be made; the caller turns that into the
123
+ * same visible memory fallback as any other open failure.
124
+ */
125
+ export declare function ensureStoreDirectory(path: string): void;
109
126
  /**
110
127
  * Open the durable store, or say plainly why it could not be opened.
111
128
  *
@@ -32,7 +32,10 @@
32
32
  */
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
34
  exports.MemoryTranscriptStore = exports.SqliteTranscriptStore = void 0;
35
+ exports.ensureStoreDirectory = ensureStoreDirectory;
35
36
  exports.openTranscriptStore = openTranscriptStore;
37
+ const node_fs_1 = require("node:fs");
38
+ const node_path_1 = require("node:path");
36
39
  const SCHEMA = `
37
40
  CREATE TABLE IF NOT EXISTS transcript_parts (
38
41
  session_id TEXT NOT NULL,
@@ -309,6 +312,28 @@ class MemoryTranscriptStore {
309
312
  }
310
313
  }
311
314
  exports.MemoryTranscriptStore = MemoryTranscriptStore;
315
+ /**
316
+ * Make the directory a store file goes in, when it is not there yet.
317
+ *
318
+ * sqlite creates the FILE and never the directory: a `--workspace-root` that
319
+ * does not exist yet made every open fail with "unable to open database
320
+ * file", and the runner then ran in memory and reported itself degraded —
321
+ * for a path it was about to create anyway on the first session. Private
322
+ * mode, because the stores hold transcripts and the model's conversation.
323
+ *
324
+ * A directory that ALREADY EXISTS is left exactly as it is, its mode included:
325
+ * `mkdir` with `recursive` does not touch one, and changing the permissions
326
+ * of a directory somebody chose is not this function's call.
327
+ *
328
+ * Throws when the directory cannot be made; the caller turns that into the
329
+ * same visible memory fallback as any other open failure.
330
+ */
331
+ function ensureStoreDirectory(path) {
332
+ // sqlite's in-memory and temporary databases have no directory.
333
+ if (path === ':memory:' || path === '')
334
+ return;
335
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
336
+ }
312
337
  /**
313
338
  * Open the durable store, or say plainly why it could not be opened.
314
339
  *
@@ -333,6 +358,7 @@ function openTranscriptStore(path) {
333
358
  };
334
359
  }
335
360
  try {
361
+ ensureStoreDirectory(path);
336
362
  const db = new sqlite.DatabaseSync(path);
337
363
  return { store: new SqliteTranscriptStore(db), durable: true };
338
364
  }
package/dist/types.d.ts CHANGED
@@ -283,6 +283,14 @@ export interface RunnerHarness {
283
283
  * account name or a token.
284
284
  */
285
285
  reason?: string;
286
+ /**
287
+ * The check of this harness DID NOT FINISH (it timed out), so `usable:
288
+ * false` here is not a statement about the harness. The runner checks again
289
+ * shortly. Only ever sent with `installed: true, usable: false`. A reader
290
+ * that does not know this field shows the entry as not usable with its
291
+ * reason, which says the same thing in words.
292
+ */
293
+ transient?: boolean;
286
294
  /** This runner can run a turn with it: it is also listed in `backends`. */
287
295
  runnable: boolean;
288
296
  models?: RunnerHarnessModel[];
package/dist/version.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** The version of THIS package, as installed. */
2
2
  export declare function getHarnessVersion(): string;
3
+ /** `engines.node` of THIS package (e.g. `>=22.19`), or null when unreadable. */
4
+ export declare function getNodeFloor(): string | null;
3
5
  /**
4
6
  * The version of the SANDBOX TOOLS layer (gh, @peculiar/x509, ...) currently
5
7
  * installed, or null when nothing was ever synced — a self-hosted runner or
package/dist/version.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getHarnessVersion = getHarnessVersion;
4
+ exports.getNodeFloor = getNodeFloor;
4
5
  exports.getInstalledSandboxToolsVersion = getInstalledSandboxToolsVersion;
5
6
  exports.getAttemptedSandboxToolsVersion = getAttemptedSandboxToolsVersion;
6
7
  exports.getPiVersion = getPiVersion;
@@ -23,6 +24,16 @@ function getHarnessVersion() {
23
24
  }
24
25
  return cachedHarnessVersion;
25
26
  }
27
+ /** `engines.node` of THIS package (e.g. `>=22.19`), or null when unreadable. */
28
+ function getNodeFloor() {
29
+ try {
30
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', 'package.json'), 'utf8'));
31
+ return typeof pkg.engines?.node === 'string' ? pkg.engines.node : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
26
37
  /**
27
38
  * The version of the SANDBOX TOOLS layer (gh, @peculiar/x509, ...) currently
28
39
  * installed, or null when nothing was ever synced — a self-hosted runner or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.9-rc.1",
3
+ "version": "0.2.9-rc.2",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",