@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/README.md CHANGED
@@ -4,7 +4,9 @@ Run a Peakflo coding agent on your machine.
4
4
 
5
5
  ## Quick start
6
6
 
7
- Use Node.js 22.19 or newer and install Git. Install the Pi coding agent once:
7
+ Use Node.js 22.19 or newer and install Git. The runner checks the Node.js
8
+ version when it starts and stops with one line if it is older (see
9
+ "Troubleshooting"). Install the Pi coding agent once:
8
10
 
9
11
  ```sh
10
12
  npm install -g --ignore-scripts @earendil-works/pi-coding-agent
@@ -36,6 +38,70 @@ Optional flags include `--health-port <port>`, `--runtime <desktop|self-hosted>`
36
38
  `--workspace-root <path>`, and `--github-proxy-port <port>`. Run
37
39
  `npx @peakflo/agent-harness@rc --help` for the full list.
38
40
 
41
+ ## Harnesses found on your machine
42
+
43
+ On a desktop or self-hosted machine the runner looks for Codex (`codex`),
44
+ Claude Code (`claude`), Cursor (`cursor-agent`), Pi (`pi`) and OpenCode
45
+ (`opencode`). For each one it reports whether it is installed, its version,
46
+ whether it is signed in, and the models it offers. Peakflo shows this list in
47
+ the agent editor and in the chat details.
48
+
49
+ Finding a harness does not mean the runner can use it. The runner runs turns
50
+ with Pi only; the other harnesses are shown as "Detected, not yet runnable by
51
+ this runner".
52
+
53
+ **When it looks.** Once after start, without delaying registration, and then
54
+ every five minutes while no turn is running. A harness you install or sign in
55
+ to later appears without a restart. At most two of these programs run at the
56
+ same time.
57
+
58
+ **A slow machine.** Some of these programs take several seconds to start. When
59
+ one does not answer in time, Peakflo shows it as "Installed — check timed out,
60
+ will retry", not as unavailable. The runner checks it again after 30 seconds,
61
+ then 60, then 120, and after that with the normal five-minute pass. A state
62
+ that was already known (signed in, or not signed in) is kept when a later
63
+ check times out.
64
+
65
+ **What it runs.** To do this the runner starts those programs, as your user,
66
+ with `--version` and a status or model-list command. It looks in the absolute
67
+ directories of your `PATH` and in the usual install directories
68
+ (`~/.local/bin`, `~/.opencode/bin`, `/opt/homebrew/bin`, `/usr/local/bin`,
69
+ `~/.npm-global/bin`, `~/.volta/bin`, `~/.bun/bin`, and the directory of the
70
+ running `node`). Relative `PATH` entries are ignored. The programs are started
71
+ without the runner's own `PF_*` variables, so they do not receive the
72
+ enrolment token.
73
+
74
+ **What it sends.** Names, versions, a signed-in yes/no with a fixed reason,
75
+ and model names. It does not send file paths, account names, tokens, API keys
76
+ or the content of credential files. Model names that look like an email
77
+ address or a key are dropped; that check is a safety net, not a guarantee
78
+ about what a third-party program prints.
79
+
80
+ **Turning it off.** Start with `--no-harness-discovery`, or set
81
+ `PF_HARNESS_DISCOVERY=0`. The runner then starts none of these programs and
82
+ reports no list. Running turns with Pi is not affected.
83
+
84
+ ## Troubleshooting
85
+
86
+ **`npx @peakflo/agent-harness` fails with a 404.** Your `~/.npmrc` maps the
87
+ `@peakflo` scope to another registry (for example GitHub Packages). Name the
88
+ public registry for this one command:
89
+
90
+ ```sh
91
+ npx --@peakflo:registry=https://registry.npmjs.org @peakflo/agent-harness@latest start
92
+ ```
93
+
94
+ **"pf-agent-harness needs Node.js 22.19 or newer".** Switch to a newer
95
+ Node.js, for example `nvm install 22 && nvm use 22`, and start again. npm only
96
+ prints an `EBADENGINE` warning for an older Node.js and installs anyway; the
97
+ runner is what stops. The requirement comes from Pi, which the runner starts
98
+ with the same Node.js. To start on an older Node.js at your own risk, set
99
+ `PF_ALLOW_UNSUPPORTED_NODE=1`.
100
+
101
+ **"Durable store unavailable, running in memory".** The runner could not
102
+ create or open its database under `--workspace-root`. It creates that
103
+ directory itself when it is missing, so check that the path is writable.
104
+
39
105
  ## Releasing
40
106
 
41
107
  `.github/scripts/agent-harness-release.mjs` publishes this package
package/dist/cli.js CHANGED
@@ -38,6 +38,7 @@ const adapters_1 = require("./adapters");
38
38
  const contract_1 = require("./contract");
39
39
  const credential_store_1 = require("./credential-store");
40
40
  const harness_1 = require("./harness");
41
+ const node_floor_1 = require("./node-floor");
41
42
  const options_1 = require("./options");
42
43
  const version_1 = require("./version");
43
44
  const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
@@ -75,6 +76,14 @@ Options:
75
76
  --max-sessions <n> concurrent sessions (env PF_MAX_CONCURRENT_SESSIONS,
76
77
  default 10 for desktop/self-hosted; sandbox is configured by control plane)
77
78
  --labels <a,b> routing labels (env PF_RUNNER_LABELS)
79
+ --no-harness-discovery do not look for Codex, Claude Code, Cursor,
80
+ Pi and OpenCode on this machine, and report
81
+ none (env PF_HARNESS_DISCOVERY=0). On by
82
+ default for desktop/self-hosted; never runs
83
+ in a sandbox.
84
+
85
+ Needs Node.js 22.19 or newer; start stops with one line on an older one.
86
+ PF_ALLOW_UNSUPPORTED_NODE=1 starts anyway, at your own risk.
78
87
 
79
88
  In a managed sandbox pf-bootstrap supplies these through the environment, so
80
89
  the enrolment token never appears in /proc/<pid>/cmdline.
@@ -139,6 +148,26 @@ async function main() {
139
148
  process.exitCode = 2;
140
149
  return;
141
150
  }
151
+ /*
152
+ * THE NODE.JS FLOOR — checked first, before the browser sign-in, so nobody
153
+ * signs in and picks an organisation only to be told to change Node. See
154
+ * `node-floor.ts` for where the floor comes from and why a sandbox only
155
+ * warns.
156
+ */
157
+ const nodeFloor = (0, node_floor_1.checkNodeFloor)({
158
+ current: process.versions.node,
159
+ floor: (0, version_1.getNodeFloor)(),
160
+ runtime: readOptionValue(argv.slice(1), 'runtime') ??
161
+ process.env.PF_RUNNER_RUNTIME,
162
+ env: process.env
163
+ });
164
+ if (nodeFloor.action === 'refuse') {
165
+ process.stderr.write(`${nodeFloor.message}\n`);
166
+ process.exit(1);
167
+ }
168
+ if (nodeFloor.action === 'warn') {
169
+ process.stderr.write(`Warning: ${nodeFloor.message}\n`);
170
+ }
142
171
  /*
143
172
  * NO TOKEN? OFFER TO SIGN IN, BUT ONLY WHERE THAT CAN POSSIBLY WORK.
144
173
  *
@@ -0,0 +1,173 @@
1
+ /**
2
+ * HARNESS DISCOVERY — which coding harnesses are installed on this machine.
3
+ *
4
+ * Modelled on 20x, where the harness code came from:
5
+ * - `src/main/agent-installer/detect.js` finds the binaries (`claude`,
6
+ * `codex`, `cursor-agent`, `opencode`, `pi`) with `--version`, after it
7
+ * puts the well-known install directories on PATH;
8
+ * - `src/renderer/src/types/index.ts` holds the static model lists the 20x
9
+ * agent form shows for Claude Code, Codex and Cursor.
10
+ *
11
+ * WHAT THIS IS NOT. It is not the list of backends this runner can run.
12
+ * `capabilities.backends` stays that list and the control plane routes on it.
13
+ * An entry here says "this CLI is on the machine", and `runnable` says whether
14
+ * the runner can also drive it. Detection never implies turn support.
15
+ *
16
+ * RULES THIS MODULE KEEPS:
17
+ *
18
+ * 1. NEVER THROWS, NEVER REJECTS. One broken CLI must not hide the others, so
19
+ * every harness is probed on its own, inside its own try/catch and its own
20
+ * deadline.
21
+ * 2. TIME-LIMITED. Each command has a timeout and each harness has a total
22
+ * deadline. A CLI that hangs is killed and reported as timed out.
23
+ * 3. NO SECRETS LEAVE THIS FILE. Sign-in is decided from an exit code, one
24
+ * boolean of a status reply, or the existence of an environment variable or
25
+ * a file. No status text is kept. Every `reason` is a constant from
26
+ * `REASONS` below, never something a CLI printed. Model ids and names are
27
+ * the only CLI text that is reported, and each one is checked first.
28
+ * 4. NO GLOBAL SIDE EFFECTS. `process.env.PATH` is not changed; the search path
29
+ * is local and is handed to each child process.
30
+ */
31
+ import type { RunnerHarness, RunnerHarnessModel } from './types';
32
+ /**
33
+ * One command: `--version`, a status check, or a model listing.
34
+ *
35
+ * MEASURED, NOT GUESSED. On the Mac where the first release candidate was
36
+ * tried, with two runners and other work going on, `cursor-agent --version`
37
+ * took 9.4 s cold and 3.7 s warm ALONE, and 6-10 s when five harnesses were
38
+ * probed at once; `opencode --version` took 3-5 s alone and 7-9 s beside the
39
+ * others. The first limit was 8 s, so both lost on a busy machine and were
40
+ * shown as broken. The limit is now well above the slowest start seen — and
41
+ * the pass no longer starts every CLI at once (`DISCOVERY_CONCURRENCY`),
42
+ * which is what made each of them slow.
43
+ */
44
+ export declare const DISCOVERY_COMMAND_TIMEOUT_MS = 20000;
45
+ /**
46
+ * The budget for finding out a harness's STATE: its version and whether it is
47
+ * signed in. The model listing is NOT inside this budget — it has one command
48
+ * timeout of its own and can never change the state (see `probeHarness`).
49
+ */
50
+ export declare const DISCOVERY_HARNESS_TIMEOUT_MS = 45000;
51
+ /**
52
+ * How many harnesses are probed at the same time. Each harness runs its
53
+ * commands one after another, so this is also the most CLI processes
54
+ * discovery ever has running. Two, not five: these CLIs are heavy to start
55
+ * (most are bundled Node programs), and five cold starts at once roughly
56
+ * doubled the start time of each on the test machine.
57
+ */
58
+ export declare const DISCOVERY_CONCURRENCY = 2;
59
+ /**
60
+ * Every sentence a `reason` can be. A closed set on purpose: text printed by a
61
+ * CLI can hold an account email or a key, and a constant cannot.
62
+ */
63
+ export declare const REASONS: {
64
+ readonly notInstalled: "Not installed on this machine.";
65
+ readonly versionTimedOut: "Installed. The version check timed out and will be tried again.";
66
+ readonly versionFailed: "The version check failed.";
67
+ readonly discoveryTimedOut: "Installed. The check timed out and will be tried again.";
68
+ readonly discoveryFailed: "Discovery failed.";
69
+ readonly signInTimedOut: "Installed. The sign-in check timed out and will be tried again.";
70
+ readonly signInUnknown: "Sign-in could not be confirmed.";
71
+ readonly codexSignIn: "Not signed in. Run `codex login` on this machine.";
72
+ readonly claudeSignIn: "Not signed in. Run `claude auth login` on this machine.";
73
+ readonly cursorSignIn: "Not signed in. Run `cursor-agent login` on this machine.";
74
+ };
75
+ /** 20x `CLAUDE_MODELS`. Claude Code has no command that lists models. */
76
+ export declare const CLAUDE_STATIC_MODELS: ReadonlyArray<RunnerHarnessModel>;
77
+ /** 20x `CODEX_MODELS`. Used only when Codex's own model cache cannot be read. */
78
+ export declare const CODEX_STATIC_MODELS: ReadonlyArray<RunnerHarnessModel>;
79
+ /** 20x `CURSOR_MODELS`. Used only when `cursor-agent models` gives nothing. */
80
+ export declare const CURSOR_STATIC_MODELS: ReadonlyArray<RunnerHarnessModel>;
81
+ export interface DiscoveryExecResult {
82
+ /** Exit code, or null when the process did not exit by itself. */
83
+ code: number | null;
84
+ stdout: string;
85
+ stderr: string;
86
+ timedOut: boolean;
87
+ }
88
+ /** Everything that touches the machine. Replaced in tests. */
89
+ export interface DiscoveryDeps {
90
+ /** Run one command. MUST NOT reject. */
91
+ exec(command: string, args: string[], timeoutMs: number): Promise<DiscoveryExecResult>;
92
+ /** Absolute path of a command, or null when it is not installed. */
93
+ resolveCommand(command: string): string | null;
94
+ realpath(path: string): string;
95
+ /** File text, or null when it cannot be read. */
96
+ readFile(path: string): string | null;
97
+ exists(path: string): boolean;
98
+ env: NodeJS.ProcessEnv;
99
+ home: string;
100
+ now(): Date;
101
+ }
102
+ export interface DiscoverHarnessesOptions {
103
+ /** Backend names this runner can run turns with (`capabilities.backends`). */
104
+ runnableBackends?: readonly string[];
105
+ commandTimeoutMs?: number;
106
+ harnessTimeoutMs?: number;
107
+ /** Harnesses probed at once. Defaults to `DISCOVERY_CONCURRENCY`. */
108
+ concurrency?: number;
109
+ /**
110
+ * Probe ONLY these harnesses and return only their entries. Used to check
111
+ * again the ones whose last check timed out, without starting the others.
112
+ */
113
+ only?: readonly string[];
114
+ deps?: Partial<DiscoveryDeps>;
115
+ }
116
+ /**
117
+ * Where to look for a harness binary: PATH first, then the directories the
118
+ * harness installers use. The well-known directories come from 20x
119
+ * `detect.js` (`ensureAgentPaths` and `getPiCommandCandidates`). The directory
120
+ * of the running `node` is added because a runner started by launchd or
121
+ * systemd has a short PATH, and an npm-installed CLI sits beside that `node`.
122
+ */
123
+ export declare function discoverySearchPath(env: NodeJS.ProcessEnv, home: string, platform?: NodeJS.Platform): string[];
124
+ /**
125
+ * The environment a harness CLI is started with.
126
+ *
127
+ * EVERY `PF_*` VARIABLE IS REMOVED. Those are this runner's own settings and
128
+ * include `PF_ENROLMENT_TOKEN`; a third-party CLI has no use for them and
129
+ * must not be handed them. The rest is kept, because each CLI finds its own
130
+ * login through it (`HOME`, `CODEX_HOME`, `ANTHROPIC_API_KEY`, ...).
131
+ */
132
+ export declare function discoveryChildEnv(env: NodeJS.ProcessEnv, searchPath: readonly string[]): NodeJS.ProcessEnv;
133
+ /**
134
+ * Keep only entries that look like a model. Anything that looks like a secret
135
+ * is dropped whole: a model list has no reason to hold one, so the safe
136
+ * reading of such a line is that it is not a model.
137
+ */
138
+ export declare function sanitizeHarnessModels(models: ReadonlyArray<{
139
+ id: unknown;
140
+ name?: unknown;
141
+ default?: unknown;
142
+ }>): RunnerHarnessModel[];
143
+ /** The first version-like token, as 20x `detect.js` reads it. */
144
+ export declare function parseVersion(output: string): string | null;
145
+ export declare function saysSignedOut(text: string): boolean;
146
+ /**
147
+ * Codex keeps the models its account can use in `models_cache.json`. This is a
148
+ * model cache, not a credential file; only `slug`, `display_name`,
149
+ * `visibility` and `priority` are read from it.
150
+ */
151
+ export declare function parseCodexModelCache(text: string | null): RunnerHarnessModel[];
152
+ /** The top-level `model = "..."` of `config.toml`. Nothing else is read. */
153
+ export declare function parseCodexDefaultModel(text: string | null): string | null;
154
+ /**
155
+ * `cursor-agent models` prints one `id - Name` line per model and marks the
156
+ * default with `(current, default)` or `(default)`.
157
+ */
158
+ export declare function parseCursorModels(output: string): RunnerHarnessModel[];
159
+ /** `opencode models` prints one `provider/model` id per line. */
160
+ export declare function parseOpencodeModels(output: string): RunnerHarnessModel[];
161
+ /** Every harness this build knows how to look for, in display order. */
162
+ export declare const KNOWN_HARNESSES: ReadonlyArray<{
163
+ name: string;
164
+ label: string;
165
+ }>;
166
+ /**
167
+ * Look for every known harness. ALWAYS resolves, with one entry per known
168
+ * harness in a fixed order — a harness that is missing is reported as
169
+ * `installed: false`, so the reader can tell "not installed" from "this runner
170
+ * does not report harnesses". With `only`, just those harnesses are probed and
171
+ * returned.
172
+ */
173
+ export declare function discoverHarnesses(options?: DiscoverHarnessesOptions): Promise<RunnerHarness[]>;