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

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.
@@ -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
  /**
@@ -107,6 +123,11 @@ export interface HarnessDeps {
107
123
  approvalRenewIntervalMs?: number;
108
124
  /** Injected in tests. */
109
125
  clientFactory?: (url: string) => ControlPlaneClient;
126
+ /**
127
+ * Why a vendor backend cannot be run here, or null. Injected in tests;
128
+ * defaults to `vendorBackendBlocker` (sandbox, or the Claude SDK missing).
129
+ */
130
+ vendorBackendBlocker?: (backend: string, runtime: string) => string | null;
110
131
  /** Injected in tests; defaults to the real `discoverHarnesses`. */
111
132
  discoverHarnesses?: (options: DiscoverHarnessesOptions) => Promise<RunnerHarness[]>;
112
133
  }
@@ -182,6 +203,10 @@ export declare class Harness {
182
203
  private harnessReport;
183
204
  /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
184
205
  private harnessDiscoveryAt;
206
+ /** Epoch ms of the next retry of timed-out checks, or null when none is due. */
207
+ private harnessRetryAt;
208
+ /** Retries scheduled in a row. Back to 0 once nothing is waiting. */
209
+ private harnessRetryAttempts;
185
210
  private harnessDiscoveryInFlight;
186
211
  /**
187
212
  * The `harnessesDigest` the control plane said it has stored, from its last
@@ -319,6 +344,16 @@ export declare class Harness {
319
344
  * returns the running one.
320
345
  */
321
346
  private refreshHarnessDiscovery;
347
+ /**
348
+ * After a pass: check again SOON when a check timed out, instead of leaving
349
+ * "timed out" on screen for a whole interval.
350
+ *
351
+ * Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
352
+ * five-minute pass takes over. A machine that is slow for good must not be
353
+ * probed for ever at a short interval. The counter starts again once
354
+ * nothing is waiting.
355
+ */
356
+ private scheduleHarnessRetry;
322
357
  /**
323
358
  * Attach the cached harness report to a capability report.
324
359
  *
@@ -329,6 +364,25 @@ export declare class Harness {
329
364
  * the same reason — one source, so the two lists cannot disagree.
330
365
  */
331
366
  private applyHarnessReport;
367
+ /**
368
+ * Add Codex, Claude Code and Cursor to `backends` — the list work is routed
369
+ * on — for exactly as long as each is INSTALLED AND SIGNED IN.
370
+ *
371
+ * This is the gate that turns "detected" into "runnable", so it is strict:
372
+ *
373
+ * - only from a discovery report, which a sandbox never has;
374
+ * - only a harness whose check SETTLED as usable. A check that timed out
375
+ * with no earlier answer is not enough to take work on;
376
+ * - only when this runner was given adapters at all, and has what the
377
+ * adapter needs (Claude Code needs the Claude Agent SDK).
378
+ *
379
+ * Recomputed for every capability report from the cached discovery pass, so
380
+ * a harness that is signed out, or uninstalled, leaves `backends` with the
381
+ * report that follows the pass which noticed. Nothing is remembered here.
382
+ *
383
+ * Returns why each harness that is usable was still NOT added, for the UI.
384
+ */
385
+ private addVendorBackends;
332
386
  private harnessDiscoveryEnabled;
333
387
  /**
334
388
  * Whether to start a discovery pass on this heartbeat.
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");
@@ -21,6 +22,7 @@ const mcp_auth_proxy_1 = require("./runtime/mcp-auth-proxy");
21
22
  const session_runner_1 = require("./runtime/session-runner");
22
23
  const conversation_store_1 = require("./store/conversation-store");
23
24
  const sqlite_store_1 = require("./store/sqlite-store");
25
+ const vendor_backends_1 = require("./vendor-backends");
24
26
  const version_1 = require("./version");
25
27
  /** Backoff after a lease poll fails, so an unreachable control plane is not hammered. */
26
28
  const LEASE_ERROR_BACKOFF_MS = 5_000;
@@ -41,6 +43,45 @@ const MODEL_PROBE_REFRESH_MS = 6 * 60 * 60 * 1000;
41
43
  * every heartbeat sends the cached answer.
42
44
  */
43
45
  exports.HARNESS_DISCOVERY_REFRESH_MS = 5 * 60 * 1000;
46
+ /**
47
+ * How long after a pass the checks that TIMED OUT are tried again: three
48
+ * times, each wait twice the one before. See `scheduleHarnessRetry`.
49
+ */
50
+ exports.HARNESS_RETRY_DELAYS_MS = [
51
+ 30_000, 60_000, 120_000
52
+ ];
53
+ /**
54
+ * Put the result of a pass together with what was known before.
55
+ *
56
+ * KEEP THE LAST GOOD STATE. A check that timed out learnt nothing, so when
57
+ * the previous pass DID settle that harness (signed in, signed out, version
58
+ * failed) that answer stays. Only a harness with no settled answer yet is
59
+ * shown as "check timed out, will be tried again".
60
+ *
61
+ * A retry pass returns only the harnesses it probed; the others are kept.
62
+ */
63
+ function mergeHarnessReports(previous, fresh) {
64
+ if (!previous)
65
+ return [...fresh];
66
+ const before = new Map(previous.map((harness) => [harness.name, harness]));
67
+ const after = new Map(fresh.map((harness) => [harness.name, harness]));
68
+ const pick = (name) => {
69
+ const now = after.get(name);
70
+ const then = before.get(name);
71
+ if (!now)
72
+ return then;
73
+ return now.transient && then && then.installed && !then.transient
74
+ ? then
75
+ : now;
76
+ };
77
+ const names = [
78
+ ...previous.map((harness) => harness.name),
79
+ ...fresh.map((harness) => harness.name).filter((name) => !before.has(name))
80
+ ];
81
+ return names
82
+ .map(pick)
83
+ .filter((harness) => harness !== undefined);
84
+ }
44
85
  /** Listed in `capabilities.features` when `capabilities.harnesses` is sent. */
45
86
  exports.HARNESS_DISCOVERY_FEATURE = 'harnessDiscovery';
46
87
  /**
@@ -169,6 +210,10 @@ class Harness {
169
210
  harnessReport = null;
170
211
  /** Epoch ms when the last discovery pass STARTED. Doubles as a cooldown. */
171
212
  harnessDiscoveryAt = 0;
213
+ /** Epoch ms of the next retry of timed-out checks, or null when none is due. */
214
+ harnessRetryAt = null;
215
+ /** Retries scheduled in a row. Back to 0 once nothing is waiting. */
216
+ harnessRetryAttempts = 0;
172
217
  harnessDiscoveryInFlight = null;
173
218
  /**
174
219
  * The `harnessesDigest` the control plane said it has stored, from its last
@@ -325,13 +370,6 @@ class Harness {
325
370
  maxConcurrentSessions: this.options.maxConcurrentSessions,
326
371
  labels: this.options.labels
327
372
  });
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
373
  // Install immediately before model probing can start a backend. The lease
336
374
  // is process-wide and reference-counted, so concurrent Harness instances
337
375
  // share one generic launcher instead of racing to rewrite global PATH.
@@ -347,6 +385,19 @@ class Harness {
347
385
  */
348
386
  await this.refreshModelCatalog(true);
349
387
  this.applyModelCatalog(this.capabilities);
388
+ /*
389
+ * STARTED, NEVER AWAITED. Registration must not wait for a third-party
390
+ * CLI, and a runner that is slow to register looks like a runner that did
391
+ * not come back. The report is attached to the first heartbeat after the
392
+ * pass has finished.
393
+ *
394
+ * STARTED HERE, AFTER THE MODEL PROBE, and not beside it. The first
395
+ * release candidate started both at once: the Pi discovery process and
396
+ * five harness CLIs all cold-starting together, on a machine that was
397
+ * also starting this very process through npx. Two harnesses lost their
398
+ * version check to that and were shown as broken for five minutes.
399
+ */
400
+ void this.refreshHarnessDiscovery();
350
401
  // Attaches the report only if the pass has already finished.
351
402
  this.applyHarnessReport(this.capabilities);
352
403
  log('Harness starting', {
@@ -713,7 +764,10 @@ class Harness {
713
764
  * `force` re-probes regardless of age (used at start-up). Never rejects.
714
765
  */
715
766
  async refreshModelCatalog(force = false) {
716
- const backends = this.capabilities?.backends ?? [];
767
+ // A vendor harness's models come from discovery, not from this probe:
768
+ // asking its adapter would start `codex app-server` or `cursor-agent acp`
769
+ // just to list models that are already known.
770
+ const backends = (this.capabilities?.backends ?? []).filter((backend) => !(0, vendor_backends_1.isVendorBackend)(backend.name));
717
771
  const build = this.deps.getAdapter;
718
772
  if (!build || backends.length === 0) {
719
773
  this.modelCatalog = null;
@@ -795,13 +849,31 @@ class Harness {
795
849
  if (this.harnessDiscoveryInFlight) {
796
850
  return this.harnessDiscoveryInFlight;
797
851
  }
798
- this.harnessDiscoveryAt = Date.now();
852
+ const now = Date.now();
853
+ /*
854
+ * A RETRY PASS probes only the harnesses whose last check timed out. A
855
+ * full pass, when one is due anyway, covers them too.
856
+ */
857
+ const fullPassDue = !this.harnessReport ||
858
+ now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
859
+ const retryNames = (this.harnessReport ?? [])
860
+ .filter((harness) => harness.transient)
861
+ .map((harness) => harness.name);
862
+ const only = !fullPassDue && this.harnessRetryAt !== null && retryNames.length > 0
863
+ ? retryNames
864
+ : undefined;
865
+ if (!only) {
866
+ this.harnessDiscoveryAt = now;
867
+ }
868
+ this.harnessRetryAt = null;
799
869
  const discover = this.deps.discoverHarnesses ?? harness_discovery_1.discoverHarnesses;
800
870
  const pass = (async () => {
801
871
  try {
802
- const report = await discover({
803
- runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name)
872
+ const fresh = await discover({
873
+ runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name),
874
+ ...(only ? { only } : {})
804
875
  });
876
+ const report = mergeHarnessReports(this.harnessReport, fresh);
805
877
  // Same content as before: keep the OLD report, with its
806
878
  // `detectedAt`. A pass that found nothing new must not look like a
807
879
  // change, or the whole list would be sent again every five minutes.
@@ -809,13 +881,20 @@ class Harness {
809
881
  harnessContentKey(report) !== harnessContentKey(this.harnessReport)) {
810
882
  this.harnessReport = report;
811
883
  }
884
+ this.scheduleHarnessRetry(report);
812
885
  log('Harnesses discovered', {
813
886
  installed: report
814
887
  .filter((harness) => harness.installed)
815
888
  .map((harness) => harness.name),
816
889
  usable: report
817
890
  .filter((harness) => harness.usable)
818
- .map((harness) => harness.name)
891
+ .map((harness) => harness.name),
892
+ // Named so a slow machine can be told from a broken harness in
893
+ // the log: these are checked again, the others are settled.
894
+ retrying: report
895
+ .filter((harness) => harness.transient)
896
+ .map((harness) => harness.name),
897
+ ...(only ? { pass: 'retry' } : {})
819
898
  });
820
899
  }
821
900
  catch {
@@ -830,6 +909,29 @@ class Harness {
830
909
  this.harnessDiscoveryInFlight = pass;
831
910
  return pass;
832
911
  }
912
+ /**
913
+ * After a pass: check again SOON when a check timed out, instead of leaving
914
+ * "timed out" on screen for a whole interval.
915
+ *
916
+ * Short and bounded: 30 s, then 60 s, then 120 s, and after that the normal
917
+ * five-minute pass takes over. A machine that is slow for good must not be
918
+ * probed for ever at a short interval. The counter starts again once
919
+ * nothing is waiting.
920
+ */
921
+ scheduleHarnessRetry(report) {
922
+ if (!report.some((harness) => harness.transient)) {
923
+ this.harnessRetryAttempts = 0;
924
+ this.harnessRetryAt = null;
925
+ return;
926
+ }
927
+ const delay = exports.HARNESS_RETRY_DELAYS_MS[this.harnessRetryAttempts];
928
+ if (delay === undefined) {
929
+ this.harnessRetryAt = null;
930
+ return;
931
+ }
932
+ this.harnessRetryAttempts += 1;
933
+ this.harnessRetryAt = Date.now() + delay;
934
+ }
833
935
  /**
834
936
  * Attach the cached harness report to a capability report.
835
937
  *
@@ -843,10 +945,16 @@ class Harness {
843
945
  if (!this.harnessReport) {
844
946
  return;
845
947
  }
948
+ const blocked = this.addVendorBackends(capabilities);
846
949
  const harnesses = this.harnessReport.map((harness) => {
847
950
  const backend = capabilities.backends.find((candidate) => candidate.name === harness.name);
848
951
  if (!backend) {
849
- return { ...harness, runnable: false };
952
+ const reason = blocked.get(harness.name);
953
+ return {
954
+ ...harness,
955
+ runnable: false,
956
+ ...(reason ? { notRunnableReason: reason } : {})
957
+ };
850
958
  }
851
959
  /*
852
960
  * A HARNESS THIS RUNNER RUNS IS NEVER SHOWN WORSE THAN `backends` SAYS.
@@ -865,6 +973,7 @@ class Harness {
865
973
  version: backend.version ?? harness.version ?? null
866
974
  };
867
975
  delete entry.reason;
976
+ delete entry.transient;
868
977
  if (!entry.models && backend.models && backend.models.length > 0) {
869
978
  entry.models = backend.models.map(({ id, name }) => ({ id, name }));
870
979
  entry.modelSource = 'backend';
@@ -891,6 +1000,51 @@ class Harness {
891
1000
  exports.HARNESS_DISCOVERY_FEATURE
892
1001
  ];
893
1002
  }
1003
+ /**
1004
+ * Add Codex, Claude Code and Cursor to `backends` — the list work is routed
1005
+ * on — for exactly as long as each is INSTALLED AND SIGNED IN.
1006
+ *
1007
+ * This is the gate that turns "detected" into "runnable", so it is strict:
1008
+ *
1009
+ * - only from a discovery report, which a sandbox never has;
1010
+ * - only a harness whose check SETTLED as usable. A check that timed out
1011
+ * with no earlier answer is not enough to take work on;
1012
+ * - only when this runner was given adapters at all, and has what the
1013
+ * adapter needs (Claude Code needs the Claude Agent SDK).
1014
+ *
1015
+ * Recomputed for every capability report from the cached discovery pass, so
1016
+ * a harness that is signed out, or uninstalled, leaves `backends` with the
1017
+ * report that follows the pass which noticed. Nothing is remembered here.
1018
+ *
1019
+ * Returns why each harness that is usable was still NOT added, for the UI.
1020
+ */
1021
+ addVendorBackends(capabilities) {
1022
+ const blocked = new Map();
1023
+ if (!this.harnessReport || !this.deps.getAdapter)
1024
+ return blocked;
1025
+ const blocker = this.deps.vendorBackendBlocker ?? vendor_backends_1.vendorBackendBlocker;
1026
+ for (const harness of this.harnessReport) {
1027
+ if (!(0, vendor_backends_1.isVendorBackend)(harness.name))
1028
+ continue;
1029
+ if (!harness.installed || !harness.usable)
1030
+ continue;
1031
+ if (capabilities.backends.some((b) => b.name === harness.name))
1032
+ continue;
1033
+ const reason = blocker(harness.name, this.options.runtime);
1034
+ if (reason) {
1035
+ blocked.set(harness.name, reason);
1036
+ continue;
1037
+ }
1038
+ capabilities.backends.push({
1039
+ name: harness.name,
1040
+ version: harness.version ?? null,
1041
+ ...(harness.models && harness.models.length > 0
1042
+ ? { models: harness.models.map(({ id, name }) => ({ id, name })) }
1043
+ : {})
1044
+ });
1045
+ }
1046
+ return blocked;
1047
+ }
894
1048
  harnessDiscoveryEnabled() {
895
1049
  return (this.options.runtime !== 'sandbox' &&
896
1050
  this.options.harnessDiscovery !== false);
@@ -907,7 +1061,11 @@ class Harness {
907
1061
  if (!this.harnessDiscoveryEnabled() || this.active.size > 0) {
908
1062
  return false;
909
1063
  }
910
- return Date.now() - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
1064
+ const now = Date.now();
1065
+ if (this.harnessRetryAt !== null && now >= this.harnessRetryAt) {
1066
+ return true;
1067
+ }
1068
+ return now - this.harnessDiscoveryAt >= exports.HARNESS_DISCOVERY_REFRESH_MS;
911
1069
  }
912
1070
  /**
913
1071
  * Whether the model catalogue needs a (rare) re-probe.