@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/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
  }
@@ -27,6 +27,7 @@
27
27
  Object.defineProperty(exports, "__esModule", { value: true });
28
28
  exports.MemoryConversationStore = exports.SqliteConversationStore = void 0;
29
29
  exports.openConversationStore = openConversationStore;
30
+ const sqlite_store_1 = require("./sqlite-store");
30
31
  const SCHEMA = `
31
32
  CREATE TABLE IF NOT EXISTS model_messages (
32
33
  session_id TEXT NOT NULL,
@@ -292,6 +293,7 @@ function openConversationStore(path) {
292
293
  };
293
294
  }
294
295
  try {
296
+ (0, sqlite_store_1.ensureStoreDirectory)(path);
295
297
  const db = new sqlite.DatabaseSync(path);
296
298
  return { store: new SqliteConversationStore(db), durable: true };
297
299
  }
@@ -106,6 +106,23 @@ export interface OpenStoreResult {
106
106
  /** Why durability was unavailable. Reported so a degraded fleet is visible. */
107
107
  reason?: string;
108
108
  }
109
+ /**
110
+ * Make the directory a store file goes in, when it is not there yet.
111
+ *
112
+ * sqlite creates the FILE and never the directory: a `--workspace-root` that
113
+ * does not exist yet made every open fail with "unable to open database
114
+ * file", and the runner then ran in memory and reported itself degraded —
115
+ * for a path it was about to create anyway on the first session. Private
116
+ * mode, because the stores hold transcripts and the model's conversation.
117
+ *
118
+ * A directory that ALREADY EXISTS is left exactly as it is, its mode included:
119
+ * `mkdir` with `recursive` does not touch one, and changing the permissions
120
+ * of a directory somebody chose is not this function's call.
121
+ *
122
+ * Throws when the directory cannot be made; the caller turns that into the
123
+ * same visible memory fallback as any other open failure.
124
+ */
125
+ export declare function ensureStoreDirectory(path: string): void;
109
126
  /**
110
127
  * Open the durable store, or say plainly why it could not be opened.
111
128
  *
@@ -32,7 +32,10 @@
32
32
  */
33
33
  Object.defineProperty(exports, "__esModule", { value: true });
34
34
  exports.MemoryTranscriptStore = exports.SqliteTranscriptStore = void 0;
35
+ exports.ensureStoreDirectory = ensureStoreDirectory;
35
36
  exports.openTranscriptStore = openTranscriptStore;
37
+ const node_fs_1 = require("node:fs");
38
+ const node_path_1 = require("node:path");
36
39
  const SCHEMA = `
37
40
  CREATE TABLE IF NOT EXISTS transcript_parts (
38
41
  session_id TEXT NOT NULL,
@@ -309,6 +312,28 @@ class MemoryTranscriptStore {
309
312
  }
310
313
  }
311
314
  exports.MemoryTranscriptStore = MemoryTranscriptStore;
315
+ /**
316
+ * Make the directory a store file goes in, when it is not there yet.
317
+ *
318
+ * sqlite creates the FILE and never the directory: a `--workspace-root` that
319
+ * does not exist yet made every open fail with "unable to open database
320
+ * file", and the runner then ran in memory and reported itself degraded —
321
+ * for a path it was about to create anyway on the first session. Private
322
+ * mode, because the stores hold transcripts and the model's conversation.
323
+ *
324
+ * A directory that ALREADY EXISTS is left exactly as it is, its mode included:
325
+ * `mkdir` with `recursive` does not touch one, and changing the permissions
326
+ * of a directory somebody chose is not this function's call.
327
+ *
328
+ * Throws when the directory cannot be made; the caller turns that into the
329
+ * same visible memory fallback as any other open failure.
330
+ */
331
+ function ensureStoreDirectory(path) {
332
+ // sqlite's in-memory and temporary databases have no directory.
333
+ if (path === ':memory:' || path === '')
334
+ return;
335
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(path), { recursive: true, mode: 0o700 });
336
+ }
312
337
  /**
313
338
  * Open the durable store, or say plainly why it could not be opened.
314
339
  *
@@ -333,6 +358,7 @@ function openTranscriptStore(path) {
333
358
  };
334
359
  }
335
360
  try {
361
+ ensureStoreDirectory(path);
336
362
  const db = new sqlite.DatabaseSync(path);
337
363
  return { store: new SqliteTranscriptStore(db), durable: true };
338
364
  }
package/dist/types.d.ts CHANGED
@@ -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,73 @@ 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
+ /**
287
+ * The check of this harness DID NOT FINISH (it timed out), so `usable:
288
+ * false` here is not a statement about the harness. The runner checks again
289
+ * shortly. Only ever sent with `installed: true, usable: false`. A reader
290
+ * that does not know this field shows the entry as not usable with its
291
+ * reason, which says the same thing in words.
292
+ */
293
+ transient?: boolean;
294
+ /** This runner can run a turn with it: it is also listed in `backends`. */
295
+ runnable: boolean;
296
+ models?: RunnerHarnessModel[];
297
+ modelSource?: RunnerHarnessModelSource;
298
+ /** ISO time of the discovery pass that first found this state. */
299
+ detectedAt: string;
226
300
  }
227
301
  export interface RunnerBackend {
228
302
  /** 'opencode' | 'claude-code' | 'codex' | 'acp' | ... — an open vocabulary. */
@@ -319,6 +393,15 @@ export interface RunnerHeartbeatResponse {
319
393
  */
320
394
  action?: 'none' | 'drain' | 'restart';
321
395
  heartbeatIntervalMs?: number;
396
+ /**
397
+ * The `capabilities.harnessesDigest` the control plane has stored after
398
+ * this heartbeat. When it equals the runner's own, the runner leaves the
399
+ * (large) `harnesses` list out of the next heartbeat. ABSENT from an older
400
+ * control plane, and the runner then sends the list every time, which is
401
+ * the only thing that is correct there: an older control plane replaces
402
+ * `capabilities` as a whole on each heartbeat.
403
+ */
404
+ harnessesDigest?: string;
322
405
  /**
323
406
  * The outbound-only answer to "how does anything reach a running session?".
324
407
  * The control plane cannot dial in, so an abort or a user's answer to a
package/dist/version.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** The version of THIS package, as installed. */
2
2
  export declare function getHarnessVersion(): string;
3
+ /** `engines.node` of THIS package (e.g. `>=22.19`), or null when unreadable. */
4
+ export declare function getNodeFloor(): string | null;
3
5
  /**
4
6
  * The version of the SANDBOX TOOLS layer (gh, @peculiar/x509, ...) currently
5
7
  * installed, or null when nothing was ever synced — a self-hosted runner or
package/dist/version.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.getHarnessVersion = getHarnessVersion;
4
+ exports.getNodeFloor = getNodeFloor;
4
5
  exports.getInstalledSandboxToolsVersion = getInstalledSandboxToolsVersion;
5
6
  exports.getAttemptedSandboxToolsVersion = getAttemptedSandboxToolsVersion;
6
7
  exports.getPiVersion = getPiVersion;
@@ -23,6 +24,16 @@ function getHarnessVersion() {
23
24
  }
24
25
  return cachedHarnessVersion;
25
26
  }
27
+ /** `engines.node` of THIS package (e.g. `>=22.19`), or null when unreadable. */
28
+ function getNodeFloor() {
29
+ try {
30
+ const pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', 'package.json'), 'utf8'));
31
+ return typeof pkg.engines?.node === 'string' ? pkg.engines.node : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
26
37
  /**
27
38
  * The version of the SANDBOX TOOLS layer (gh, @peculiar/x509, ...) currently
28
39
  * installed, or null when nothing was ever synced — a self-hosted runner or
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.8",
3
+ "version": "0.2.9-rc.2",
4
4
  "description": "Headless Peakflo coding-agent harness. One codebase, three hosting modes: a Daytona cloud sandbox, a customer VM, and the 20x desktop.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",