@peakflo/agent-harness 0.2.8 → 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/dist/harness.d.ts CHANGED
@@ -2,8 +2,41 @@ import type { CodingAgentAdapter } from './adapters';
2
2
  import { SessionModelGateway } from './adapters/model-gateway';
3
3
  import { ControlPlaneClient } from './control-plane';
4
4
  import { CredentialStore } from './credential-store';
5
+ import { type DiscoverHarnessesOptions } from './harness-discovery';
5
6
  import { ArtifactInspector, EventSink, Notifier, SecretProvider, Telemetry, ConversationStore, TranscriptStore, WorkspaceProvider } from './ports';
6
- import { HarnessOptions, HealthReport } from './types';
7
+ import { HarnessOptions, HealthReport, RunnerHarness } from './types';
8
+ /**
9
+ * How often the installed harnesses are looked for again.
10
+ *
11
+ * Five minutes: short enough that a harness installed or signed in to after
12
+ * start shows up without a restart or a re-enrol, and long enough that the
13
+ * heartbeat (30 s) does not start a handful of CLIs every time. Between passes
14
+ * every heartbeat sends the cached answer.
15
+ */
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[];
33
+ /** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
34
+ export declare const HARNESS_DISCOVERY_FEATURE = "harnessDiscovery";
35
+ /**
36
+ * A short tag for WHAT a harness list says, ignoring WHEN it was found.
37
+ * Two passes that found the same thing give the same tag.
38
+ */
39
+ export declare function harnessContentKey(harnesses: readonly RunnerHarness[]): string;
7
40
  /**
8
41
  * The control plane refused this harness's PROTOCOL or VERSION. FINAL.
9
42
  *
@@ -90,6 +123,8 @@ export interface HarnessDeps {
90
123
  approvalRenewIntervalMs?: number;
91
124
  /** Injected in tests. */
92
125
  clientFactory?: (url: string) => ControlPlaneClient;
126
+ /** Injected in tests; defaults to the real `discoverHarnesses`. */
127
+ discoverHarnesses?: (options: DiscoverHarnessesOptions) => Promise<RunnerHarness[]>;
93
128
  }
94
129
  /**
95
130
  * THE HARNESS.
@@ -153,6 +188,27 @@ export declare class Harness {
153
188
  private modelCatalog;
154
189
  /** Epoch ms of the last successful (or attempted) model probe. */
155
190
  private modelProbeAt;
191
+ /**
192
+ * The harnesses found on this machine, from the last discovery pass.
193
+ *
194
+ * Cached for the same reason the model catalogue is: a pass starts several
195
+ * CLIs, and the heartbeat must not. NULL on a sandbox runner, which never
196
+ * runs discovery, and until the first pass has finished.
197
+ */
198
+ private harnessReport;
199
+ /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
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;
205
+ private harnessDiscoveryInFlight;
206
+ /**
207
+ * The `harnessesDigest` the control plane said it has stored, from its last
208
+ * heartbeat answer. Null until it says so, and for ever with an older
209
+ * control plane.
210
+ */
211
+ private ackedHarnessesDigest;
156
212
  /**
157
213
  * Empty gateway for the model probe. The probe builds a FRESH adapter and
158
214
  * must not read a live turn's credential; a null config is "no gateway",
@@ -272,6 +328,47 @@ export declare class Harness {
272
328
  * an empty-but-meaningful list) when the catalogue is empty.
273
329
  */
274
330
  private applyModelCatalog;
331
+ /**
332
+ * Look for the installed harnesses again and cache the answer.
333
+ *
334
+ * NEVER ON A SANDBOX. A cloud sandbox image ships exactly what the control
335
+ * plane put in it, so there is nothing to discover, and its capability
336
+ * report must stay what it was before this existed.
337
+ *
338
+ * Never rejects. One pass at a time: a second call while a pass is running
339
+ * returns the running one.
340
+ */
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;
352
+ /**
353
+ * Attach the cached harness report to a capability report.
354
+ *
355
+ * `runnable` is decided HERE, against the backends of THIS report, and not
356
+ * taken from the cache: the backend list is re-probed on every heartbeat
357
+ * and the harness list only every few minutes, so a cached `runnable` could
358
+ * contradict `backends`. Pi's models are copied from its backend entry for
359
+ * the same reason — one source, so the two lists cannot disagree.
360
+ */
361
+ private applyHarnessReport;
362
+ private harnessDiscoveryEnabled;
363
+ /**
364
+ * Whether to start a discovery pass on this heartbeat.
365
+ *
366
+ * NEVER WHILE A TURN IS RUNNING — the same rule as `modelsNeedRefresh`. A
367
+ * pass starts up to a dozen CLI processes; a turn in progress should not
368
+ * compete with them for the machine, and nothing is lost by waiting: the
369
+ * next idle heartbeat starts the pass.
370
+ */
371
+ private harnessesNeedRefresh;
275
372
  /**
276
373
  * Whether the model catalogue needs a (rare) re-probe.
277
374
  *
package/dist/harness.js CHANGED
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Harness = exports.UpgradeRequestedError = exports.VersionRefusedError = 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;
5
+ exports.harnessContentKey = harnessContentKey;
6
+ const node_crypto_1 = require("node:crypto");
4
7
  const node_path_1 = require("node:path");
5
8
  const model_gateway_1 = require("./adapters/model-gateway");
6
9
  const capabilities_1 = require("./capabilities");
@@ -9,6 +12,7 @@ const control_plane_1 = require("./control-plane");
9
12
  const credential_store_1 = require("./credential-store");
10
13
  const github_command_guards_1 = require("./github-command-guards");
11
14
  const github_workspace_1 = require("./github-workspace");
15
+ const harness_discovery_1 = require("./harness-discovery");
12
16
  const health_1 = require("./health");
13
17
  const model_probe_1 = require("./model-probe");
14
18
  const ports_default_1 = require("./ports-default");
@@ -29,6 +33,67 @@ const LEASE_MIN_EMPTY_POLL_MS = 250;
29
33
  * process. Every six hours is a bounded refresh of a slowly changing catalog.
30
34
  */
31
35
  const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
36
+ /**
37
+ * How often the installed harnesses are looked for again.
38
+ *
39
+ * Five minutes: short enough that a harness installed or signed in to after
40
+ * start shows up without a restart or a re-enrol, and long enough that the
41
+ * heartbeat (30 s) does not start a handful of CLIs every time. Between passes
42
+ * every heartbeat sends the cached answer.
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
+ }
84
+ /** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
85
+ exports.HARNESS_DISCOVERY_FEATURE = 'harnessDiscovery';
86
+ /**
87
+ * A short tag for WHAT a harness list says, ignoring WHEN it was found.
88
+ * Two passes that found the same thing give the same tag.
89
+ */
90
+ function harnessContentKey(harnesses) {
91
+ const content = harnesses.map(({ detectedAt: _detectedAt, ...rest }) => rest);
92
+ return (0, node_crypto_1.createHash)('sha256')
93
+ .update(JSON.stringify(content))
94
+ .digest('hex')
95
+ .slice(0, 32);
96
+ }
32
97
  /**
33
98
  * The control plane refused this harness's PROTOCOL or VERSION. FINAL.
34
99
  *
@@ -134,6 +199,27 @@ class Harness {
134
199
  modelCatalog = null;
135
200
  /** Epoch ms of the last successful (or attempted) model probe. */
136
201
  modelProbeAt = 0;
202
+ /**
203
+ * The harnesses found on this machine, from the last discovery pass.
204
+ *
205
+ * Cached for the same reason the model catalogue is: a pass starts several
206
+ * CLIs, and the heartbeat must not. NULL on a sandbox runner, which never
207
+ * runs discovery, and until the first pass has finished.
208
+ */
209
+ harnessReport = null;
210
+ /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
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;
216
+ harnessDiscoveryInFlight = null;
217
+ /**
218
+ * The `harnessesDigest` the control plane said it has stored, from its last
219
+ * heartbeat answer. Null until it says so, and for ever with an older
220
+ * control plane.
221
+ */
222
+ ackedHarnessesDigest = null;
137
223
  /**
138
224
  * Empty gateway for the model probe. The probe builds a FRESH adapter and
139
225
  * must not read a live turn's credential; a null config is "no gateway",
@@ -298,6 +384,21 @@ class Harness {
298
384
  */
299
385
  await this.refreshModelCatalog(true);
300
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();
400
+ // Attaches the report only if the pass has already finished.
401
+ this.applyHarnessReport(this.capabilities);
301
402
  log('Harness starting', {
302
403
  harnessVersion: (0, version_1.getHarnessVersion)(),
303
404
  healthPort: this.options.healthPort,
@@ -517,6 +618,10 @@ class Harness {
517
618
  if (response.heartbeatIntervalMs) {
518
619
  this.heartbeatIntervalMs = response.heartbeatIntervalMs;
519
620
  }
621
+ this.ackedHarnessesDigest =
622
+ typeof response.harnessesDigest === 'string'
623
+ ? response.harnessesDigest
624
+ : null;
520
625
  this.observeHarnessPin(response.harnessVersion);
521
626
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
522
627
  this.applyCommandsSafely(response.sessionCommands ?? []);
@@ -723,6 +828,190 @@ class Harness {
723
828
  }
724
829
  }
725
830
  }
831
+ /**
832
+ * Look for the installed harnesses again and cache the answer.
833
+ *
834
+ * NEVER ON A SANDBOX. A cloud sandbox image ships exactly what the control
835
+ * plane put in it, so there is nothing to discover, and its capability
836
+ * report must stay what it was before this existed.
837
+ *
838
+ * Never rejects. One pass at a time: a second call while a pass is running
839
+ * returns the running one.
840
+ */
841
+ refreshHarnessDiscovery() {
842
+ if (!this.harnessDiscoveryEnabled()) {
843
+ return Promise.resolve();
844
+ }
845
+ if (this.harnessDiscoveryInFlight) {
846
+ return this.harnessDiscoveryInFlight;
847
+ }
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;
865
+ const discover = this.deps.discoverHarnesses ?? harness_discovery_1.discoverHarnesses;
866
+ const pass = (async () => {
867
+ try {
868
+ const fresh = await discover({
869
+ runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name),
870
+ ...(only ? { only } : {})
871
+ });
872
+ const report = mergeHarnessReports(this.harnessReport, fresh);
873
+ // Same content as before: keep the OLD report, with its
874
+ // `detectedAt`. A pass that found nothing new must not look like a
875
+ // change, or the whole list would be sent again every five minutes.
876
+ if (!this.harnessReport ||
877
+ harnessContentKey(report) !== harnessContentKey(this.harnessReport)) {
878
+ this.harnessReport = report;
879
+ }
880
+ this.scheduleHarnessRetry(report);
881
+ log('Harnesses discovered', {
882
+ installed: report
883
+ .filter((harness) => harness.installed)
884
+ .map((harness) => harness.name),
885
+ usable: report
886
+ .filter((harness) => harness.usable)
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' } : {})
894
+ });
895
+ }
896
+ catch {
897
+ // discoverHarnesses never rejects by construction. If a regression
898
+ // makes it reject, keep the last good answer instead of failing the
899
+ // start-up or the heartbeat.
900
+ }
901
+ finally {
902
+ this.harnessDiscoveryInFlight = null;
903
+ }
904
+ })();
905
+ this.harnessDiscoveryInFlight = pass;
906
+ return pass;
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
+ }
931
+ /**
932
+ * Attach the cached harness report to a capability report.
933
+ *
934
+ * `runnable` is decided HERE, against the backends of THIS report, and not
935
+ * taken from the cache: the backend list is re-probed on every heartbeat
936
+ * and the harness list only every few minutes, so a cached `runnable` could
937
+ * contradict `backends`. Pi's models are copied from its backend entry for
938
+ * the same reason — one source, so the two lists cannot disagree.
939
+ */
940
+ applyHarnessReport(capabilities) {
941
+ if (!this.harnessReport) {
942
+ return;
943
+ }
944
+ const harnesses = this.harnessReport.map((harness) => {
945
+ const backend = capabilities.backends.find((candidate) => candidate.name === harness.name);
946
+ if (!backend) {
947
+ return { ...harness, runnable: false };
948
+ }
949
+ /*
950
+ * A HARNESS THIS RUNNER RUNS IS NEVER SHOWN WORSE THAN `backends` SAYS.
951
+ *
952
+ * The backend probe runs on every heartbeat and is what work is routed
953
+ * on. The cached discovery pass is older and looks in other places, so
954
+ * on its own it could say "not installed" or "version check timed out"
955
+ * for a Pi that is running turns right now. Where the two disagree, the
956
+ * backend probe is right.
957
+ */
958
+ const entry = {
959
+ ...harness,
960
+ installed: true,
961
+ usable: true,
962
+ runnable: true,
963
+ version: backend.version ?? harness.version ?? null
964
+ };
965
+ delete entry.reason;
966
+ delete entry.transient;
967
+ if (!entry.models && backend.models && backend.models.length > 0) {
968
+ entry.models = backend.models.map(({ id, name }) => ({ id, name }));
969
+ entry.modelSource = 'backend';
970
+ }
971
+ return entry;
972
+ });
973
+ const digest = harnessContentKey(harnesses);
974
+ capabilities.harnessesDigest = digest;
975
+ /*
976
+ * THE LIST IS SENT ONLY WHEN THE CONTROL PLANE DOES NOT HAVE IT.
977
+ *
978
+ * It is about 20 KB on a machine with every harness, and it changes
979
+ * rarely. A control plane that understands the digest answers each
980
+ * heartbeat with the digest it has stored; while that equals ours, the
981
+ * list is left out and only the digest goes. An older control plane never
982
+ * answers with a digest, so it is sent the list every time — which it
983
+ * needs, because it replaces `capabilities` as a whole.
984
+ */
985
+ if (this.ackedHarnessesDigest !== digest) {
986
+ capabilities.harnesses = harnesses;
987
+ }
988
+ capabilities.features = [
989
+ ...(capabilities.features ?? []).filter((feature) => feature !== exports.HARNESS_DISCOVERY_FEATURE),
990
+ exports.HARNESS_DISCOVERY_FEATURE
991
+ ];
992
+ }
993
+ harnessDiscoveryEnabled() {
994
+ return (this.options.runtime !== 'sandbox' &&
995
+ this.options.harnessDiscovery !== false);
996
+ }
997
+ /**
998
+ * Whether to start a discovery pass on this heartbeat.
999
+ *
1000
+ * NEVER WHILE A TURN IS RUNNING — the same rule as `modelsNeedRefresh`. A
1001
+ * pass starts up to a dozen CLI processes; a turn in progress should not
1002
+ * compete with them for the machine, and nothing is lost by waiting: the
1003
+ * next idle heartbeat starts the pass.
1004
+ */
1005
+ harnessesNeedRefresh() {
1006
+ if (!this.harnessDiscoveryEnabled() || this.active.size > 0) {
1007
+ return false;
1008
+ }
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;
1014
+ }
726
1015
  /**
727
1016
  * Whether the model catalogue needs a (rare) re-probe.
728
1017
  *
@@ -765,7 +1054,13 @@ class Harness {
765
1054
  labels: this.options.labels
766
1055
  });
767
1056
  this.applyModelCatalog(capabilities);
1057
+ this.applyHarnessReport(capabilities);
768
1058
  this.capabilities = capabilities;
1059
+ // Not awaited: the answer is attached to a LATER heartbeat. Only the
1060
+ // cached report is ever sent, so a slow CLI cannot delay a heartbeat.
1061
+ if (this.harnessesNeedRefresh()) {
1062
+ void this.refreshHarnessDiscovery();
1063
+ }
769
1064
  /*
770
1065
  * THE MODEL CATALOGUE IS NOT RE-PROBED HERE, EVER. Only the CLI probes
771
1066
  * run on a heartbeat; Pi discovery starts an RPC process, and doing that
@@ -791,6 +1086,10 @@ class Harness {
791
1086
  if (response.heartbeatIntervalMs) {
792
1087
  this.heartbeatIntervalMs = response.heartbeatIntervalMs;
793
1088
  }
1089
+ this.ackedHarnessesDigest =
1090
+ typeof response.harnessesDigest === 'string'
1091
+ ? response.harnessesDigest
1092
+ : null;
794
1093
  this.observeHarnessPin(response.harnessVersion);
795
1094
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
796
1095
  this.applyCommandsSafely(response.sessionCommands ?? []);
package/dist/index.d.ts CHANGED
@@ -24,6 +24,15 @@ export type { ProbeCapabilitiesOptions, ProbeResult } from './capabilities';
24
24
  */
25
25
  export { flattenProviderModels, probeBackendModels, probeModelCatalog } from './model-probe';
26
26
  export type { ModelCatalog, ModelProbeAdapterFactory, ProviderRecord } from './model-probe';
27
+ /**
28
+ * Harness discovery — which coding harnesses (Codex, Claude Code, Cursor, Pi,
29
+ * OpenCode) are installed on this machine, whether each is signed in, and the
30
+ * models each offers. Reported as `capabilities.harnesses`, apart from
31
+ * `capabilities.backends`: finding a harness does not mean this runner can
32
+ * run a turn with it.
33
+ */
34
+ export { discoverHarnesses, KNOWN_HARNESSES, DISCOVERY_COMMAND_TIMEOUT_MS, DISCOVERY_HARNESS_TIMEOUT_MS } from './harness-discovery';
35
+ export type { DiscoverHarnessesOptions, DiscoveryDeps, DiscoveryExecResult } from './harness-discovery';
27
36
  export type { ArtifactInspector, EventSink, HarnessPorts, InspectedArtifact, Notifier, SecretProvider, StoredPart, StoredPartInput, TaskMcpProvider, TaskMcpScope, Telemetry, TokenInjector, TranscriptStore, WorkspaceProvider, WorkspaceRequest } from './ports';
28
37
  export { CompositeEventSink, DirectoryWorkspaceProvider, NoArtifactInspector, NoSecretProvider, NoopTelemetry, NullEventSink, ServerNotifier, SessionTokenInjector, StaticTaskMcpProvider } from './ports-default';
29
38
  export type { DirectoryWorkspaceOptions } from './ports-default';
package/dist/index.js CHANGED
@@ -23,8 +23,8 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
23
23
  for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
24
24
  };
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
- exports.MIN_SUPPORTED_PROTOCOL_VERSION = exports.MAX_LEASE_WAIT_SECONDS = exports.MAX_INGEST_PART_BYTES = exports.MAX_INGEST_PARTS = exports.HARNESS_UPGRADE_EXIT_CODE = exports.FINAL_HTTP_STATUSES = exports.EPHEMERAL_PART_TYPES = exports.DEFAULT_MAX_ATTEMPTS = exports.DEFAULT_LEASE_WAIT_SECONDS = exports.DEFAULT_LEASE_TTL_MS = exports.DEFAULT_HEARTBEAT_INTERVAL_MS = exports.DEFAULT_BASE_DELAY_MS = exports.CONTRACT_ERROR_CODES = exports.openTranscriptStore = exports.SqliteTranscriptStore = exports.MemoryTranscriptStore = exports.writeToChildStdin = exports.isBenignStreamError = exports.guardStream = exports.guardChildStreams = exports.toIngestPart = exports.projectParts = exports.SessionRunner = exports.McpAuthProxy = exports.GitHubWorkspaceProvider = exports.StaticTaskMcpProvider = exports.SessionTokenInjector = exports.ServerNotifier = exports.NullEventSink = exports.NoopTelemetry = exports.NoSecretProvider = exports.NoArtifactInspector = exports.DirectoryWorkspaceProvider = exports.CompositeEventSink = exports.probeModelCatalog = exports.probeBackendModels = exports.flattenProviderModels = exports.probeCapabilities = exports.probe = exports.getPiVersion = exports.getHarnessVersion = exports.parseOptions = exports.OptionsError = exports.HealthServer = exports.ProtocolRefusedError = exports.ControlPlaneError = exports.ControlPlaneClient = exports.VersionRefusedError = exports.UpgradeRequestedError = exports.Harness = void 0;
27
- exports.isProtocolSupported = exports.isPersistablePart = exports.isFinalStatus = exports.isEphemeralPartType = exports.ingestPartByteLength = exports.ROUTES = exports.PROTOCOL_VERSION_HEADER = exports.PROTOCOL_VERSION = exports.PERSISTED_PART_TYPES = exports.MISSED_HEARTBEATS_BEFORE_OFFLINE = void 0;
26
+ exports.HARNESS_UPGRADE_EXIT_CODE = exports.FINAL_HTTP_STATUSES = exports.EPHEMERAL_PART_TYPES = exports.DEFAULT_MAX_ATTEMPTS = exports.DEFAULT_LEASE_WAIT_SECONDS = exports.DEFAULT_LEASE_TTL_MS = exports.DEFAULT_HEARTBEAT_INTERVAL_MS = exports.DEFAULT_BASE_DELAY_MS = exports.CONTRACT_ERROR_CODES = exports.openTranscriptStore = exports.SqliteTranscriptStore = exports.MemoryTranscriptStore = exports.writeToChildStdin = exports.isBenignStreamError = exports.guardStream = exports.guardChildStreams = exports.toIngestPart = exports.projectParts = exports.SessionRunner = exports.McpAuthProxy = exports.GitHubWorkspaceProvider = exports.StaticTaskMcpProvider = exports.SessionTokenInjector = exports.ServerNotifier = exports.NullEventSink = exports.NoopTelemetry = exports.NoSecretProvider = exports.NoArtifactInspector = exports.DirectoryWorkspaceProvider = exports.CompositeEventSink = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = exports.KNOWN_HARNESSES = exports.discoverHarnesses = exports.probeModelCatalog = exports.probeBackendModels = exports.flattenProviderModels = exports.probeCapabilities = exports.probe = exports.getPiVersion = exports.getHarnessVersion = exports.parseOptions = exports.OptionsError = exports.HealthServer = exports.ProtocolRefusedError = exports.ControlPlaneError = exports.ControlPlaneClient = exports.VersionRefusedError = exports.UpgradeRequestedError = exports.Harness = void 0;
27
+ exports.isProtocolSupported = exports.isPersistablePart = exports.isFinalStatus = exports.isEphemeralPartType = exports.ingestPartByteLength = exports.ROUTES = exports.PROTOCOL_VERSION_HEADER = exports.PROTOCOL_VERSION = exports.PERSISTED_PART_TYPES = exports.MISSED_HEARTBEATS_BEFORE_OFFLINE = exports.MIN_SUPPORTED_PROTOCOL_VERSION = exports.MAX_LEASE_WAIT_SECONDS = exports.MAX_INGEST_PART_BYTES = exports.MAX_INGEST_PARTS = void 0;
28
28
  /* ---- The process ---------------------------------------------------------- */
29
29
  var harness_1 = require("./harness");
30
30
  Object.defineProperty(exports, "Harness", { enumerable: true, get: function () { return harness_1.Harness; } });
@@ -55,6 +55,18 @@ var model_probe_1 = require("./model-probe");
55
55
  Object.defineProperty(exports, "flattenProviderModels", { enumerable: true, get: function () { return model_probe_1.flattenProviderModels; } });
56
56
  Object.defineProperty(exports, "probeBackendModels", { enumerable: true, get: function () { return model_probe_1.probeBackendModels; } });
57
57
  Object.defineProperty(exports, "probeModelCatalog", { enumerable: true, get: function () { return model_probe_1.probeModelCatalog; } });
58
+ /**
59
+ * Harness discovery — which coding harnesses (Codex, Claude Code, Cursor, Pi,
60
+ * OpenCode) are installed on this machine, whether each is signed in, and the
61
+ * models each offers. Reported as `capabilities.harnesses`, apart from
62
+ * `capabilities.backends`: finding a harness does not mean this runner can
63
+ * run a turn with it.
64
+ */
65
+ var harness_discovery_1 = require("./harness-discovery");
66
+ Object.defineProperty(exports, "discoverHarnesses", { enumerable: true, get: function () { return harness_discovery_1.discoverHarnesses; } });
67
+ Object.defineProperty(exports, "KNOWN_HARNESSES", { enumerable: true, get: function () { return harness_discovery_1.KNOWN_HARNESSES; } });
68
+ Object.defineProperty(exports, "DISCOVERY_COMMAND_TIMEOUT_MS", { enumerable: true, get: function () { return harness_discovery_1.DISCOVERY_COMMAND_TIMEOUT_MS; } });
69
+ Object.defineProperty(exports, "DISCOVERY_HARNESS_TIMEOUT_MS", { enumerable: true, get: function () { return harness_discovery_1.DISCOVERY_HARNESS_TIMEOUT_MS; } });
58
70
  var ports_default_1 = require("./ports-default");
59
71
  Object.defineProperty(exports, "CompositeEventSink", { enumerable: true, get: function () { return ports_default_1.CompositeEventSink; } });
60
72
  Object.defineProperty(exports, "DirectoryWorkspaceProvider", { enumerable: true, get: function () { return ports_default_1.DirectoryWorkspaceProvider; } });
@@ -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
+ }