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

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,25 @@ 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
+ /** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
18
+ export declare const HARNESS_DISCOVERY_FEATURE = "harnessDiscovery";
19
+ /**
20
+ * A short tag for WHAT a harness list says, ignoring WHEN it was found.
21
+ * Two passes that found the same thing give the same tag.
22
+ */
23
+ export declare function harnessContentKey(harnesses: readonly RunnerHarness[]): string;
7
24
  /**
8
25
  * The control plane refused this harness's PROTOCOL or VERSION. FINAL.
9
26
  *
@@ -90,6 +107,8 @@ export interface HarnessDeps {
90
107
  approvalRenewIntervalMs?: number;
91
108
  /** Injected in tests. */
92
109
  clientFactory?: (url: string) => ControlPlaneClient;
110
+ /** Injected in tests; defaults to the real `discoverHarnesses`. */
111
+ discoverHarnesses?: (options: DiscoverHarnessesOptions) => Promise<RunnerHarness[]>;
93
112
  }
94
113
  /**
95
114
  * THE HARNESS.
@@ -153,6 +172,23 @@ export declare class Harness {
153
172
  private modelCatalog;
154
173
  /** Epoch ms of the last successful (or attempted) model probe. */
155
174
  private modelProbeAt;
175
+ /**
176
+ * The harnesses found on this machine, from the last discovery pass.
177
+ *
178
+ * Cached for the same reason the model catalogue is: a pass starts several
179
+ * CLIs, and the heartbeat must not. NULL on a sandbox runner, which never
180
+ * runs discovery, and until the first pass has finished.
181
+ */
182
+ private harnessReport;
183
+ /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
184
+ private harnessDiscoveryAt;
185
+ private harnessDiscoveryInFlight;
186
+ /**
187
+ * The `harnessesDigest` the control plane said it has stored, from its last
188
+ * heartbeat answer. Null until it says so, and for ever with an older
189
+ * control plane.
190
+ */
191
+ private ackedHarnessesDigest;
156
192
  /**
157
193
  * Empty gateway for the model probe. The probe builds a FRESH adapter and
158
194
  * must not read a live turn's credential; a null config is "no gateway",
@@ -272,6 +308,37 @@ export declare class Harness {
272
308
  * an empty-but-meaningful list) when the catalogue is empty.
273
309
  */
274
310
  private applyModelCatalog;
311
+ /**
312
+ * Look for the installed harnesses again and cache the answer.
313
+ *
314
+ * NEVER ON A SANDBOX. A cloud sandbox image ships exactly what the control
315
+ * plane put in it, so there is nothing to discover, and its capability
316
+ * report must stay what it was before this existed.
317
+ *
318
+ * Never rejects. One pass at a time: a second call while a pass is running
319
+ * returns the running one.
320
+ */
321
+ private refreshHarnessDiscovery;
322
+ /**
323
+ * Attach the cached harness report to a capability report.
324
+ *
325
+ * `runnable` is decided HERE, against the backends of THIS report, and not
326
+ * taken from the cache: the backend list is re-probed on every heartbeat
327
+ * and the harness list only every few minutes, so a cached `runnable` could
328
+ * contradict `backends`. Pi's models are copied from its backend entry for
329
+ * the same reason — one source, so the two lists cannot disagree.
330
+ */
331
+ private applyHarnessReport;
332
+ private harnessDiscoveryEnabled;
333
+ /**
334
+ * Whether to start a discovery pass on this heartbeat.
335
+ *
336
+ * NEVER WHILE A TURN IS RUNNING — the same rule as `modelsNeedRefresh`. A
337
+ * pass starts up to a dozen CLI processes; a turn in progress should not
338
+ * compete with them for the machine, and nothing is lost by waiting: the
339
+ * next idle heartbeat starts the pass.
340
+ */
341
+ private harnessesNeedRefresh;
275
342
  /**
276
343
  * Whether the model catalogue needs a (rare) re-probe.
277
344
  *
package/dist/harness.js CHANGED
@@ -1,6 +1,8 @@
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_DISCOVERY_REFRESH_MS = void 0;
4
+ exports.harnessContentKey = harnessContentKey;
5
+ const node_crypto_1 = require("node:crypto");
4
6
  const node_path_1 = require("node:path");
5
7
  const model_gateway_1 = require("./adapters/model-gateway");
6
8
  const capabilities_1 = require("./capabilities");
@@ -9,6 +11,7 @@ const control_plane_1 = require("./control-plane");
9
11
  const credential_store_1 = require("./credential-store");
10
12
  const github_command_guards_1 = require("./github-command-guards");
11
13
  const github_workspace_1 = require("./github-workspace");
14
+ const harness_discovery_1 = require("./harness-discovery");
12
15
  const health_1 = require("./health");
13
16
  const model_probe_1 = require("./model-probe");
14
17
  const ports_default_1 = require("./ports-default");
@@ -29,6 +32,28 @@ const LEASE_MIN_EMPTY_POLL_MS = 250;
29
32
  * process. Every six hours is a bounded refresh of a slowly changing catalog.
30
33
  */
31
34
  const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
35
+ /**
36
+ * How often the installed harnesses are looked for again.
37
+ *
38
+ * Five minutes: short enough that a harness installed or signed in to after
39
+ * start shows up without a restart or a re-enrol, and long enough that the
40
+ * heartbeat (30 s) does not start a handful of CLIs every time. Between passes
41
+ * every heartbeat sends the cached answer.
42
+ */
43
+ exports.HARNESS_DISCOVERY_REFRESH_MS = 5 * 60 * 1000;
44
+ /** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
45
+ exports.HARNESS_DISCOVERY_FEATURE = 'harnessDiscovery';
46
+ /**
47
+ * A short tag for WHAT a harness list says, ignoring WHEN it was found.
48
+ * Two passes that found the same thing give the same tag.
49
+ */
50
+ function harnessContentKey(harnesses) {
51
+ const content = harnesses.map(({ detectedAt: _detectedAt, ...rest }) => rest);
52
+ return (0, node_crypto_1.createHash)('sha256')
53
+ .update(JSON.stringify(content))
54
+ .digest('hex')
55
+ .slice(0, 32);
56
+ }
32
57
  /**
33
58
  * The control plane refused this harness's PROTOCOL or VERSION. FINAL.
34
59
  *
@@ -134,6 +159,23 @@ class Harness {
134
159
  modelCatalog = null;
135
160
  /** Epoch ms of the last successful (or attempted) model probe. */
136
161
  modelProbeAt = 0;
162
+ /**
163
+ * The harnesses found on this machine, from the last discovery pass.
164
+ *
165
+ * Cached for the same reason the model catalogue is: a pass starts several
166
+ * CLIs, and the heartbeat must not. NULL on a sandbox runner, which never
167
+ * runs discovery, and until the first pass has finished.
168
+ */
169
+ harnessReport = null;
170
+ /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
171
+ harnessDiscoveryAt = 0;
172
+ harnessDiscoveryInFlight = null;
173
+ /**
174
+ * The `harnessesDigest` the control plane said it has stored, from its last
175
+ * heartbeat answer. Null until it says so, and for ever with an older
176
+ * control plane.
177
+ */
178
+ ackedHarnessesDigest = null;
137
179
  /**
138
180
  * Empty gateway for the model probe. The probe builds a FRESH adapter and
139
181
  * must not read a live turn's credential; a null config is "no gateway",
@@ -283,6 +325,13 @@ class Harness {
283
325
  maxConcurrentSessions: this.options.maxConcurrentSessions,
284
326
  labels: this.options.labels
285
327
  });
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();
286
335
  // Install immediately before model probing can start a backend. The lease
287
336
  // is process-wide and reference-counted, so concurrent Harness instances
288
337
  // share one generic launcher instead of racing to rewrite global PATH.
@@ -298,6 +347,8 @@ class Harness {
298
347
  */
299
348
  await this.refreshModelCatalog(true);
300
349
  this.applyModelCatalog(this.capabilities);
350
+ // Attaches the report only if the pass has already finished.
351
+ this.applyHarnessReport(this.capabilities);
301
352
  log('Harness starting', {
302
353
  harnessVersion: (0, version_1.getHarnessVersion)(),
303
354
  healthPort: this.options.healthPort,
@@ -517,6 +568,10 @@ class Harness {
517
568
  if (response.heartbeatIntervalMs) {
518
569
  this.heartbeatIntervalMs = response.heartbeatIntervalMs;
519
570
  }
571
+ this.ackedHarnessesDigest =
572
+ typeof response.harnessesDigest === 'string'
573
+ ? response.harnessesDigest
574
+ : null;
520
575
  this.observeHarnessPin(response.harnessVersion);
521
576
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
522
577
  this.applyCommandsSafely(response.sessionCommands ?? []);
@@ -723,6 +778,137 @@ class Harness {
723
778
  }
724
779
  }
725
780
  }
781
+ /**
782
+ * Look for the installed harnesses again and cache the answer.
783
+ *
784
+ * NEVER ON A SANDBOX. A cloud sandbox image ships exactly what the control
785
+ * plane put in it, so there is nothing to discover, and its capability
786
+ * report must stay what it was before this existed.
787
+ *
788
+ * Never rejects. One pass at a time: a second call while a pass is running
789
+ * returns the running one.
790
+ */
791
+ refreshHarnessDiscovery() {
792
+ if (!this.harnessDiscoveryEnabled()) {
793
+ return Promise.resolve();
794
+ }
795
+ if (this.harnessDiscoveryInFlight) {
796
+ return this.harnessDiscoveryInFlight;
797
+ }
798
+ this.harnessDiscoveryAt = Date.now();
799
+ const discover = this.deps.discoverHarnesses ?? harness_discovery_1.discoverHarnesses;
800
+ const pass = (async () => {
801
+ try {
802
+ const report = await discover({
803
+ runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name)
804
+ });
805
+ // Same content as before: keep the OLD report, with its
806
+ // `detectedAt`. A pass that found nothing new must not look like a
807
+ // change, or the whole list would be sent again every five minutes.
808
+ if (!this.harnessReport ||
809
+ harnessContentKey(report) !== harnessContentKey(this.harnessReport)) {
810
+ this.harnessReport = report;
811
+ }
812
+ log('Harnesses discovered', {
813
+ installed: report
814
+ .filter((harness) => harness.installed)
815
+ .map((harness) => harness.name),
816
+ usable: report
817
+ .filter((harness) => harness.usable)
818
+ .map((harness) => harness.name)
819
+ });
820
+ }
821
+ catch {
822
+ // discoverHarnesses never rejects by construction. If a regression
823
+ // makes it reject, keep the last good answer instead of failing the
824
+ // start-up or the heartbeat.
825
+ }
826
+ finally {
827
+ this.harnessDiscoveryInFlight = null;
828
+ }
829
+ })();
830
+ this.harnessDiscoveryInFlight = pass;
831
+ return pass;
832
+ }
833
+ /**
834
+ * Attach the cached harness report to a capability report.
835
+ *
836
+ * `runnable` is decided HERE, against the backends of THIS report, and not
837
+ * taken from the cache: the backend list is re-probed on every heartbeat
838
+ * and the harness list only every few minutes, so a cached `runnable` could
839
+ * contradict `backends`. Pi's models are copied from its backend entry for
840
+ * the same reason — one source, so the two lists cannot disagree.
841
+ */
842
+ applyHarnessReport(capabilities) {
843
+ if (!this.harnessReport) {
844
+ return;
845
+ }
846
+ const harnesses = this.harnessReport.map((harness) => {
847
+ const backend = capabilities.backends.find((candidate) => candidate.name === harness.name);
848
+ if (!backend) {
849
+ return { ...harness, runnable: false };
850
+ }
851
+ /*
852
+ * A HARNESS THIS RUNNER RUNS IS NEVER SHOWN WORSE THAN `backends` SAYS.
853
+ *
854
+ * The backend probe runs on every heartbeat and is what work is routed
855
+ * on. The cached discovery pass is older and looks in other places, so
856
+ * on its own it could say "not installed" or "version check timed out"
857
+ * for a Pi that is running turns right now. Where the two disagree, the
858
+ * backend probe is right.
859
+ */
860
+ const entry = {
861
+ ...harness,
862
+ installed: true,
863
+ usable: true,
864
+ runnable: true,
865
+ version: backend.version ?? harness.version ?? null
866
+ };
867
+ delete entry.reason;
868
+ if (!entry.models && backend.models && backend.models.length > 0) {
869
+ entry.models = backend.models.map(({ id, name }) => ({ id, name }));
870
+ entry.modelSource = 'backend';
871
+ }
872
+ return entry;
873
+ });
874
+ const digest = harnessContentKey(harnesses);
875
+ capabilities.harnessesDigest = digest;
876
+ /*
877
+ * THE LIST IS SENT ONLY WHEN THE CONTROL PLANE DOES NOT HAVE IT.
878
+ *
879
+ * It is about 20 KB on a machine with every harness, and it changes
880
+ * rarely. A control plane that understands the digest answers each
881
+ * heartbeat with the digest it has stored; while that equals ours, the
882
+ * list is left out and only the digest goes. An older control plane never
883
+ * answers with a digest, so it is sent the list every time — which it
884
+ * needs, because it replaces `capabilities` as a whole.
885
+ */
886
+ if (this.ackedHarnessesDigest !== digest) {
887
+ capabilities.harnesses = harnesses;
888
+ }
889
+ capabilities.features = [
890
+ ...(capabilities.features ?? []).filter((feature) => feature !== exports.HARNESS_DISCOVERY_FEATURE),
891
+ exports.HARNESS_DISCOVERY_FEATURE
892
+ ];
893
+ }
894
+ harnessDiscoveryEnabled() {
895
+ return (this.options.runtime !== 'sandbox' &&
896
+ this.options.harnessDiscovery !== false);
897
+ }
898
+ /**
899
+ * Whether to start a discovery pass on this heartbeat.
900
+ *
901
+ * NEVER WHILE A TURN IS RUNNING — the same rule as `modelsNeedRefresh`. A
902
+ * pass starts up to a dozen CLI processes; a turn in progress should not
903
+ * compete with them for the machine, and nothing is lost by waiting: the
904
+ * next idle heartbeat starts the pass.
905
+ */
906
+ harnessesNeedRefresh() {
907
+ if (!this.harnessDiscoveryEnabled() || this.active.size > 0) {
908
+ return false;
909
+ }
910
+ return Date.now() - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
911
+ }
726
912
  /**
727
913
  * Whether the model catalogue needs a (rare) re-probe.
728
914
  *
@@ -765,7 +951,13 @@ class Harness {
765
951
  labels: this.options.labels
766
952
  });
767
953
  this.applyModelCatalog(capabilities);
954
+ this.applyHarnessReport(capabilities);
768
955
  this.capabilities = capabilities;
956
+ // Not awaited: the answer is attached to a LATER heartbeat. Only the
957
+ // cached report is ever sent, so a slow CLI cannot delay a heartbeat.
958
+ if (this.harnessesNeedRefresh()) {
959
+ void this.refreshHarnessDiscovery();
960
+ }
769
961
  /*
770
962
  * THE MODEL CATALOGUE IS NOT RE-PROBED HERE, EVER. Only the CLI probes
771
963
  * run on a heartbeat; Pi discovery starts an RPC process, and doing that
@@ -791,6 +983,10 @@ class Harness {
791
983
  if (response.heartbeatIntervalMs) {
792
984
  this.heartbeatIntervalMs = response.heartbeatIntervalMs;
793
985
  }
986
+ this.ackedHarnessesDigest =
987
+ typeof response.harnessesDigest === 'string'
988
+ ? response.harnessesDigest
989
+ : null;
794
990
  this.observeHarnessPin(response.harnessVersion);
795
991
  this.observeSandboxToolsPin(response.sandboxToolsVersion);
796
992
  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; } });
package/dist/options.js CHANGED
@@ -150,6 +150,17 @@ function parseBoolean(raw, fallback, name) {
150
150
  return false;
151
151
  throw new OptionsError(`Invalid ${name}: '${raw}'. Expected true or false.`);
152
152
  }
153
+ /** An on/off variable: 1/true/on/yes or 0/false/off/no. A typo fails the start. */
154
+ function parseSwitch(raw, fallback, name) {
155
+ if (raw === undefined || raw.trim() === '')
156
+ return fallback;
157
+ const value = raw.trim().toLowerCase();
158
+ if (['1', 'true', 'on', 'yes'].includes(value))
159
+ return true;
160
+ if (['0', 'false', 'off', 'no'].includes(value))
161
+ return false;
162
+ throw new OptionsError(`Invalid ${name}: '${raw}'. Expected 1 or 0.`);
163
+ }
153
164
  /**
154
165
  * Flags win over environment variables, so a local developer can override a
155
166
  * value that pf-bootstrap injected.
@@ -207,6 +218,11 @@ function parseOptions(argv, env) {
207
218
  defaultWorkspaceRoot(),
208
219
  runtime,
209
220
  maxConcurrentSessions: parseConcurrency(readFlag(argv, 'max-sessions') ?? env.PF_MAX_CONCURRENT_SESSIONS, runtime),
210
- labels: parseLabels(readFlag(argv, 'labels') ?? env.PF_RUNNER_LABELS)
221
+ labels: parseLabels(readFlag(argv, 'labels') ?? env.PF_RUNNER_LABELS),
222
+ // The flag wins over the environment, so one start can turn it off
223
+ // without editing a service file.
224
+ harnessDiscovery: readBoolFlag(argv, 'no-harness-discovery')
225
+ ? false
226
+ : parseSwitch(env.PF_HARNESS_DISCOVERY, true, 'harness discovery flag (PF_HARNESS_DISCOVERY)')
211
227
  };
212
228
  }
package/dist/types.d.ts CHANGED
@@ -88,6 +88,13 @@ export interface HarnessOptions {
88
88
  maxConcurrentSessions: number;
89
89
  /** Free-form routing labels, e.g. ['eu-only', 'has-vpn']. */
90
90
  labels?: string[];
91
+ /**
92
+ * Look for the coding harnesses installed on this machine and report them.
93
+ * Defaults to true. `--no-harness-discovery` or `PF_HARNESS_DISCOVERY=0`
94
+ * turns it off: no harness CLI is started and no list is reported. A
95
+ * sandbox runner never runs discovery, whatever this says.
96
+ */
97
+ harnessDiscovery?: boolean;
91
98
  }
92
99
  /**
93
100
  * Every request body in this contract carries the protocol version, so the
@@ -223,6 +230,65 @@ export interface RunnerCapabilities {
223
230
  * serves `knowledge_files_list` / `knowledge_file_download`.
224
231
  */
225
232
  features?: string[];
233
+ /**
234
+ * Coding harnesses FOUND on this host, whether or not this runner can drive
235
+ * them. Reported by `harness-discovery.ts`.
236
+ *
237
+ * DETECTION IS NOT TURN SUPPORT. `backends` stays the only list the control
238
+ * plane routes on; an entry here with `runnable: false` is information for a
239
+ * person ("Codex is installed on this machine") and never a promise.
240
+ *
241
+ * OPTIONAL AND ADDITIVE, so no protocol bump: an older control plane stores
242
+ * the field and ignores it, and an older harness simply never sends it.
243
+ * Absent on a sandbox runner, which skips discovery altogether.
244
+ */
245
+ harnesses?: RunnerHarness[];
246
+ /**
247
+ * A short tag for the content of `harnesses`. Sent on EVERY capability
248
+ * report of a runner that runs discovery, also when the list itself is left
249
+ * out because the control plane already has it (see
250
+ * `RunnerHeartbeatResponse.harnessesDigest`).
251
+ */
252
+ harnessesDigest?: string;
253
+ }
254
+ /** Where a harness's model list came from. */
255
+ export type RunnerHarnessModelSource =
256
+ /** Asked the harness CLI (`cursor-agent models`, `opencode models`). */
257
+ 'cli'
258
+ /** Read from the harness's own model cache file. */
259
+ | 'config'
260
+ /** A list compiled into this harness build, as 20x does. */
261
+ | 'static'
262
+ /** The runnable backend's own probe (`backends[].models`). */
263
+ | 'backend';
264
+ export interface RunnerHarnessModel {
265
+ id: string;
266
+ name: string;
267
+ /** The model the harness would use when none is named. */
268
+ default?: boolean;
269
+ }
270
+ /** One known coding harness, as found on this host. */
271
+ export interface RunnerHarness {
272
+ /** 'codex' | 'claude-code' | 'cursor' | 'pi' | 'opencode' — the 20x names. */
273
+ name: string;
274
+ /** Display name, e.g. 'Claude Code'. */
275
+ label: string;
276
+ installed: boolean;
277
+ version?: string | null;
278
+ /** Installed, answering, and signed in. */
279
+ usable: boolean;
280
+ /**
281
+ * Why `usable` is false. ALWAYS one of a fixed set of sentences written in
282
+ * `harness-discovery.ts` — never text a CLI printed, so it cannot carry an
283
+ * account name or a token.
284
+ */
285
+ reason?: string;
286
+ /** This runner can run a turn with it: it is also listed in `backends`. */
287
+ runnable: boolean;
288
+ models?: RunnerHarnessModel[];
289
+ modelSource?: RunnerHarnessModelSource;
290
+ /** ISO time of the discovery pass that first found this state. */
291
+ detectedAt: string;
226
292
  }
227
293
  export interface RunnerBackend {
228
294
  /** 'opencode' | 'claude-code' | 'codex' | 'acp' | ... — an open vocabulary. */
@@ -319,6 +385,15 @@ export interface RunnerHeartbeatResponse {
319
385
  */
320
386
  action?: 'none' | 'drain' | 'restart';
321
387
  heartbeatIntervalMs?: number;
388
+ /**
389
+ * The `capabilities.harnessesDigest` the control plane has stored after
390
+ * this heartbeat. When it equals the runner's own, the runner leaves the
391
+ * (large) `harnesses` list out of the next heartbeat. ABSENT from an older
392
+ * control plane, and the runner then sends the list every time, which is
393
+ * the only thing that is correct there: an older control plane replaces
394
+ * `capabilities` as a whole on each heartbeat.
395
+ */
396
+ harnessesDigest?: string;
322
397
  /**
323
398
  * The outbound-only answer to "how does anything reach a running session?".
324
399
  * The control plane cannot dial in, so an abort or a user's answer to a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.8",
3
+ "version": "0.2.9-rc.1",
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",