@peakflo/agent-harness 0.2.17 → 0.2.18-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/README.md CHANGED
@@ -269,6 +269,48 @@ Cursor reports an account limit as a normal reply ("Upgrade your plan to
269
269
  continue"). The runner turns a reply that is only that sentence into a failed
270
270
  turn that says it comes from the Cursor account on the runner.
271
271
 
272
+ ## Runner presence and Claude Code paths
273
+
274
+ Heartbeats use the last completed capability report. A slow CLI version probe
275
+ runs in the background and cannot delay the liveness request. Only one such
276
+ probe runs at a time. A later heartbeat carries the completed report.
277
+
278
+ The CLI search includes `~/.local/bin`, `~/.claude/local`, Homebrew, npm, pnpm,
279
+ Volta, Bun and installed NVM Node versions. If Claude is still missing, the
280
+ runner reads PATH from a bounded login-shell check. It keeps only absolute PATH
281
+ entries. It does not retain shell output. Runner credentials are removed from
282
+ the shell environment.
283
+
284
+ Set an absolute executable path to override discovery and the Claude adapter:
285
+
286
+ ```sh
287
+ pf-agent-harness start --stored-credential --claude-path /absolute/path/to/claude
288
+ ```
289
+
290
+ `PF_CLAUDE_PATH=/absolute/path/to/claude` is the equivalent environment option.
291
+ `service install --claude-path /absolute/path/to/claude` saves the path in the
292
+ service configuration. Keep the other service install options for the existing
293
+ connection. `service repair --claude-path /absolute/path/to/claude` updates an
294
+ existing service. Select `--vendor claude-code` for a service made by Connect.
295
+ An invalid override fails discovery; it does not select a different executable.
296
+
297
+ Read-only checks for a Claude Code service made by Connect:
298
+
299
+ ```sh
300
+ pf-agent-harness service status --vendor claude-code
301
+ command -v claude
302
+ claude --version
303
+ ```
304
+
305
+ On macOS, this service writes logs to
306
+ `~/.pf-agent-harness/logs/claude-code/stdout.log` and `stderr.log`. On Linux,
307
+ use `journalctl --user -u pf-agent-harness-claude-code.service`. Do not share
308
+ tokens, credential files or unfiltered logs.
309
+
310
+ Release note: this fix needs a new `@peakflo/agent-harness` release and an
311
+ owner-controlled local service update. No sandbox image change is required.
312
+ A running service is not updated by these source changes.
313
+
272
314
  ## Troubleshooting
273
315
 
274
316
  **`npx @peakflo/agent-harness` fails with a 404.** Your `~/.npmrc` maps the
@@ -68,4 +68,6 @@ export declare function vendorInstanceId(stateDir: string): string | null;
68
68
  */
69
69
  export declare function createAdapterResolver(runtime: string,
70
70
  /** The runner's own state directory (its workspace root). */
71
- stateDir?: string): (backend: string, ports: AdapterPorts) => CodingAgentAdapter;
71
+ stateDir?: string, options?: {
72
+ claudePath?: string;
73
+ }): (backend: string, ports: AdapterPorts) => CodingAgentAdapter;
@@ -290,14 +290,14 @@ function vendorInstanceId(stateDir) {
290
290
  */
291
291
  function createAdapterResolver(runtime,
292
292
  /** The runner's own state directory (its workspace root). */
293
- stateDir) {
293
+ stateDir, options = {}) {
294
294
  const instanceId = stateDir ? vendorInstanceId(stateDir) : null;
295
295
  const build = (backend) => {
296
296
  switch (backend) {
297
297
  case 'codex':
298
298
  return new adapters_1.CodexAppServerAdapter();
299
299
  case 'claude-code':
300
- return new adapters_1.ClaudeCodeAdapter();
300
+ return new adapters_1.ClaudeCodeAdapter(options.claudePath);
301
301
  case 'cursor':
302
302
  return new adapters_1.AcpAdapter('cursor');
303
303
  default:
@@ -26,6 +26,7 @@ export declare enum ClaudeSystemSubtype {
26
26
  THINKING_TOKENS = "thinking_tokens"
27
27
  }
28
28
  export declare class ClaudeCodeAdapter implements CodingAgentAdapter {
29
+ private readonly executableOverride?;
29
30
  private sessions;
30
31
  private sdkLoading;
31
32
  private claudeExecutablePath;
@@ -36,7 +37,7 @@ export declare class ClaudeCodeAdapter implements CodingAgentAdapter {
36
37
  * latency of the fixed-interval polling heartbeat.
37
38
  */
38
39
  onDataAvailable?: (sessionId: string) => void;
39
- constructor();
40
+ constructor(executableOverride?: string | undefined);
40
41
  /**
41
42
  * Find the Claude CLI executable path
42
43
  */
@@ -48,8 +48,8 @@ exports.assertClaudeIsolatedConfigVersion = assertClaudeIsolatedConfigVersion;
48
48
  exports.verifyClaudeIsolatedConfigVersion = verifyClaudeIsolatedConfigVersion;
49
49
  /**
50
50
  * Extracted verbatim from 20x `src/main/adapters/claude-code-adapter.ts`.
51
- * CHANGED: nothing. The only import it has is `./coding-agent-adapter`, which
52
- * sits in the same directory here too. `@anthropic-ai/claude-agent-sdk` is an
51
+ * CLI resolution is shared with runner discovery. The remaining adapter
52
+ * behavior follows the desktop implementation. `@anthropic-ai/claude-agent-sdk` is an
53
53
  * optional dependency (dynamically imported) and is declared ambiently in
54
54
  * `./optional-agent-sdks.d.ts` so `tsc` succeeds when it is not installed.
55
55
  * Source formatting (no semicolons) preserved.
@@ -57,11 +57,10 @@ exports.verifyClaudeIsolatedConfigVersion = verifyClaudeIsolatedConfigVersion;
57
57
  const node_os_1 = require("node:os");
58
58
  const node_path_1 = require("node:path");
59
59
  const coding_agent_adapter_1 = require("./coding-agent-adapter");
60
+ const harness_path_1 = require("../harness-path");
60
61
  const permission_mode_1 = require("../runtime/permission-mode");
61
62
  const vendor_environment_1 = require("./vendor-environment");
62
63
  let ClaudeAgentSDK = null;
63
- /** Module-level cache for the resolved Claude executable path (persists across adapter instances) */
64
- let resolvedClaudeExecutablePath = null;
65
64
  const MINIMUM_ISOLATED_CONFIG_VERSION = [2, 1, 144];
66
65
  function assertClaudeIsolatedConfigVersion(version) {
67
66
  const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:\s|$)/);
@@ -120,6 +119,7 @@ const TERMINAL_TASK_STATUSES = new Set(['completed', 'failed', 'killed', 'stoppe
120
119
  */
121
120
  const MAX_BACKGROUND_TASK_AGE_MS = 60 * 60 * 1000; // 60 minutes
122
121
  class ClaudeCodeAdapter {
122
+ executableOverride;
123
123
  sessions = new Map();
124
124
  sdkLoading = null;
125
125
  claudeExecutablePath = null;
@@ -130,7 +130,8 @@ class ClaudeCodeAdapter {
130
130
  * latency of the fixed-interval polling heartbeat.
131
131
  */
132
132
  onDataAvailable;
133
- constructor() {
133
+ constructor(executableOverride) {
134
+ this.executableOverride = executableOverride;
134
135
  this.sdkLoading = this.loadSDK();
135
136
  }
136
137
  /**
@@ -140,84 +141,13 @@ class ClaudeCodeAdapter {
140
141
  if (this.claudeExecutablePath) {
141
142
  return this.claudeExecutablePath;
142
143
  }
143
- // Check module-level cache (persists across adapter instances)
144
- if (resolvedClaudeExecutablePath) {
145
- this.claudeExecutablePath = resolvedClaudeExecutablePath;
146
- return this.claudeExecutablePath;
147
- }
148
- const isWin = process.platform === 'win32';
149
- try {
150
- const { execFile } = await Promise.resolve().then(() => __importStar(require('child_process')));
151
- const { promisify } = await Promise.resolve().then(() => __importStar(require('util')));
152
- const execFileAsync = promisify(execFile);
153
- // Try to find claude in PATH
154
- const whichCmd = isWin ? 'where' : 'which';
155
- const binaryName = 'claude';
156
- const { stdout } = await execFileAsync(whichCmd, [binaryName]);
157
- let found = stdout.trim().split(/\r?\n/)[0];
158
- // On Windows, the SDK spawns the executable directly without shell,
159
- // so .cmd files fail with EINVAL. Resolve .cmd → the underlying cli.js
160
- // so the SDK uses `node cli.js` instead.
161
- if (isWin) {
162
- found = await this.resolveWindowsCmdToJs(found);
163
- }
164
- this.claudeExecutablePath = found;
165
- resolvedClaudeExecutablePath = found;
166
- console.log(`[ClaudeCodeAdapter] Found claude executable at: ${this.claudeExecutablePath}`);
167
- return this.claudeExecutablePath;
168
- }
169
- catch {
170
- // Common installation locations — must cover Homebrew, npm globals,
171
- // pnpm globals, Volta, and NVM paths so the binary is found even when
172
- // the shell PATH wasn't fully resolved (packaged macOS GUI app).
173
- const home = process.env.HOME || process.env.USERPROFILE || '';
174
- const commonPaths = isWin
175
- ? [
176
- `${home}\\AppData\\Roaming\\npm\\node_modules\\@anthropic-ai\\claude-code\\cli.js`,
177
- `${home}\\AppData\\Roaming\\npm\\claude.cmd`,
178
- `${home}\\AppData\\Roaming\\npm\\claude.exe`,
179
- `${home}\\.local\\bin\\claude.cmd`,
180
- `${home}\\.local\\bin\\claude.exe`
181
- ]
182
- : [
183
- '/usr/local/bin/claude',
184
- '/opt/homebrew/bin/claude',
185
- `${home}/.local/bin/claude`,
186
- `${home}/.npm-global/bin/claude`,
187
- `${home}/Library/pnpm/claude`,
188
- `${home}/.volta/bin/claude`,
189
- ];
190
- // Dynamically detect NVM-managed npm global bin (avoids hardcoding a Node version)
191
- if (!isWin && home) {
192
- try {
193
- const { join } = await Promise.resolve().then(() => __importStar(require('path')));
194
- const { readdirSync } = await Promise.resolve().then(() => __importStar(require('fs')));
195
- const nvmDir = join(home, '.nvm', 'versions', 'node');
196
- const versions = readdirSync(nvmDir);
197
- versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
198
- for (const v of versions) {
199
- commonPaths.push(join(nvmDir, v, 'bin', 'claude'));
200
- }
201
- }
202
- catch {
203
- // NVM not installed — skip
204
- }
205
- }
206
- const { existsSync } = await Promise.resolve().then(() => __importStar(require('fs')));
207
- for (const path of commonPaths) {
208
- if (existsSync(path)) {
209
- let resolved = path;
210
- if (isWin && path.endsWith('.cmd')) {
211
- resolved = await this.resolveWindowsCmdToJs(path);
212
- }
213
- this.claudeExecutablePath = resolved;
214
- resolvedClaudeExecutablePath = resolved;
215
- console.log(`[ClaudeCodeAdapter] Found claude executable at: ${this.claudeExecutablePath}`);
216
- return this.claudeExecutablePath;
217
- }
218
- }
219
- throw new Error('Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code');
144
+ const found = await (0, harness_path_1.resolveClaudeCommand)(this.executableOverride);
145
+ if (!found) {
146
+ throw new Error('Claude Code not found. Install Claude Code or set --claude-path to its absolute executable path.');
220
147
  }
148
+ this.claudeExecutablePath = process.platform === 'win32'
149
+ ? await this.resolveWindowsCmdToJs(found) : found;
150
+ return this.claudeExecutablePath;
221
151
  }
222
152
  /**
223
153
  * On Windows, .cmd wrapper files can't be spawned directly by the SDK
@@ -262,6 +192,8 @@ class ClaudeCodeAdapter {
262
192
  ANTHROPIC_API_KEY: useApiKey ? config.apiKeys?.anthropic ?? process.env.ANTHROPIC_API_KEY : undefined,
263
193
  CLAUDE_CODE_OAUTH_TOKEN: useApiKey ? undefined : process.env.CLAUDE_CODE_OAUTH_TOKEN
264
194
  });
195
+ // Keep the installed GitHub guard first, and add service-safe CLI/Node paths.
196
+ env.PATH = (0, harness_path_1.harnessSearchPath)(env, (0, node_os_1.homedir)()).join(node_path_1.delimiter);
265
197
  if (useApiKey)
266
198
  delete env.CLAUDE_CODE_OAUTH_TOKEN;
267
199
  else
@@ -30,6 +30,7 @@ const HOST_KEYS = [
30
30
  'PATHEXT'
31
31
  ];
32
32
  const SESSION_KEYS = [
33
+ 'PATH',
33
34
  'HTTPS_PROXY',
34
35
  'https_proxy',
35
36
  'NO_PROXY',
package/dist/cli.js CHANGED
@@ -87,6 +87,7 @@ Options:
87
87
  --max-sessions <n> concurrent sessions (env PF_MAX_CONCURRENT_SESSIONS,
88
88
  default 10 for desktop/self-hosted; sandbox is configured by control plane)
89
89
  --labels <a,b> routing labels (env PF_RUNNER_LABELS)
90
+ --claude-path <path> absolute Claude CLI path (PF_CLAUDE_PATH)
90
91
  --vendor-harnesses <list> allow codex,claude-code,cursor on this machine
91
92
  (env PF_VENDOR_HARNESSES; default none)
92
93
  --no-harness-discovery do not look for Codex, Claude Code, Cursor,
@@ -265,7 +266,7 @@ async function main() {
265
266
  env: process.env
266
267
  }).assertNoDuplicateRunner(options);
267
268
  const handedOffCredential = (0, credential_store_1.takeHandedOffCredential)(startEnv);
268
- const resolveAdapter = (0, adapter_resolver_1.createAdapterResolver)(options.runtime, options.workspaceRoot);
269
+ const resolveAdapter = (0, adapter_resolver_1.createAdapterResolver)(options.runtime, options.workspaceRoot, { claudePath: options.claudePath });
269
270
  const harness = new harness_1.Harness(options, {
270
271
  getAdapter: (backend, ports) => resolveAdapter(backend, ports),
271
272
  ...(handedOffCredential
@@ -28,6 +28,7 @@
28
28
  * 4. NO GLOBAL SIDE EFFECTS. `process.env.PATH` is not changed; the search path
29
29
  * is local and is handed to each child process.
30
30
  */
31
+ import { harnessSearchPath } from './harness-path';
31
32
  import type { RunnerHarness, RunnerHarnessModel } from './types';
32
33
  /**
33
34
  * One command: `--version`, a status check, or a model listing.
@@ -112,6 +113,8 @@ export interface DiscoverHarnessesOptions {
112
113
  */
113
114
  only?: readonly string[];
114
115
  deps?: Partial<DiscoveryDeps>;
116
+ /** Operator-selected Claude executable; also used by the turn adapter. */
117
+ claudePath?: string;
115
118
  }
116
119
  /**
117
120
  * Where to look for a harness binary: PATH first, then the directories the
@@ -120,7 +123,7 @@ export interface DiscoverHarnessesOptions {
120
123
  * of the running `node` is added because a runner started by launchd or
121
124
  * systemd has a short PATH, and an npm-installed CLI sits beside that `node`.
122
125
  */
123
- export declare function discoverySearchPath(env: NodeJS.ProcessEnv, home: string, platform?: NodeJS.Platform): string[];
126
+ export declare const discoverySearchPath: typeof harnessSearchPath;
124
127
  /**
125
128
  * The environment a harness CLI is started with.
126
129
  *
@@ -30,8 +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_CONCURRENCY = exports.DISCOVERY_HARNESS_TIMEOUT_MS = exports.DISCOVERY_COMMAND_TIMEOUT_MS = void 0;
34
- exports.discoverySearchPath = discoverySearchPath;
33
+ exports.KNOWN_HARNESSES = exports.discoverySearchPath = 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;
35
34
  exports.discoveryChildEnv = discoveryChildEnv;
36
35
  exports.sanitizeHarnessModels = sanitizeHarnessModels;
37
36
  exports.parseVersion = parseVersion;
@@ -45,6 +44,7 @@ const node_child_process_1 = require("node:child_process");
45
44
  const node_fs_1 = require("node:fs");
46
45
  const node_os_1 = require("node:os");
47
46
  const node_path_1 = require("node:path");
47
+ const harness_path_1 = require("./harness-path");
48
48
  /**
49
49
  * One command: `--version`, a status check, or a model listing.
50
50
  *
@@ -142,57 +142,7 @@ exports.CURSOR_STATIC_MODELS = [
142
142
  * of the running `node` is added because a runner started by launchd or
143
143
  * systemd has a short PATH, and an npm-installed CLI sits beside that `node`.
144
144
  */
145
- function discoverySearchPath(env, home, platform = process.platform) {
146
- /*
147
- * ABSOLUTE ENTRIES ONLY. A relative entry (`.`, `node_modules/.bin`) means
148
- * "whatever is in the directory the runner was started from", and a file
149
- * named `codex` there is not the user's Codex.
150
- */
151
- const fromEnv = (env.PATH ?? env.Path ?? '')
152
- .split(node_path_1.delimiter)
153
- .filter((entry) => entry !== '' && (0, node_path_1.isAbsolute)(entry));
154
- const known = platform === 'win32'
155
- ? [
156
- (0, node_path_1.join)(home, 'AppData', 'Roaming', 'npm'),
157
- (0, node_path_1.join)(home, '.local', 'bin'),
158
- (0, node_path_1.join)(home, '.opencode', 'bin')
159
- ]
160
- : [
161
- (0, node_path_1.join)(home, '.local', 'bin'),
162
- (0, node_path_1.join)(home, '.opencode', 'bin'),
163
- '/opt/homebrew/bin',
164
- '/usr/local/bin',
165
- (0, node_path_1.join)(home, '.npm-global', 'bin'),
166
- (0, node_path_1.join)(home, '.volta', 'bin'),
167
- (0, node_path_1.join)(home, '.bun', 'bin')
168
- ];
169
- return [...new Set([...fromEnv, ...known, (0, node_path_1.dirname)(process.execPath)])];
170
- }
171
- function isExecutableFile(path) {
172
- try {
173
- if (!(0, node_fs_1.statSync)(path).isFile())
174
- return false;
175
- (0, node_fs_1.accessSync)(path, node_fs_1.constants.X_OK);
176
- return true;
177
- }
178
- catch {
179
- return false;
180
- }
181
- }
182
- function defaultResolveCommand(command, searchPath, env) {
183
- // npm installs `.cmd` wrappers on Windows, so the bare name is not a file.
184
- const extensions = process.platform === 'win32'
185
- ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
186
- : [''];
187
- for (const dir of searchPath) {
188
- for (const extension of extensions) {
189
- const candidate = (0, node_path_1.join)(dir, `${command}${extension}`);
190
- if (isExecutableFile(candidate))
191
- return candidate;
192
- }
193
- }
194
- return null;
195
- }
145
+ exports.discoverySearchPath = harness_path_1.harnessSearchPath;
196
146
  function defaultExec(command, args, timeoutMs, env) {
197
147
  return new Promise((resolve) => {
198
148
  let settled = false;
@@ -275,14 +225,24 @@ function discoveryChildEnv(env, searchPath) {
275
225
  child.PATH = searchPath.join(node_path_1.delimiter);
276
226
  return child;
277
227
  }
278
- function resolveDeps(overrides = {}) {
279
- const env = overrides.env ?? process.env;
228
+ async function resolveDeps(overrides = {}, claudePath) {
229
+ const env = {
230
+ ...(overrides.env ?? process.env),
231
+ ...(claudePath ? { PF_CLAUDE_PATH: claudePath } : {})
232
+ };
233
+ if (!overrides.env && !env.SHELL)
234
+ env.SHELL = (0, harness_path_1.defaultLoginShell)();
280
235
  const home = overrides.home ?? (0, node_os_1.homedir)();
281
- const searchPath = discoverySearchPath(env, home);
236
+ const searchPath = (0, exports.discoverySearchPath)(env, home);
237
+ if (!overrides.resolveCommand &&
238
+ !(0, harness_path_1.resolveHarnessCommand)('claude', searchPath, env) &&
239
+ !env.PF_CLAUDE_PATH) {
240
+ searchPath.push(...(await (0, harness_path_1.loginShellSearchPath)(env)));
241
+ }
282
242
  const childEnv = discoveryChildEnv(env, searchPath);
283
243
  return {
284
244
  exec: (command, args, timeoutMs) => defaultExec(command, args, timeoutMs, childEnv),
285
- resolveCommand: (command) => defaultResolveCommand(command, searchPath, env),
245
+ resolveCommand: (command) => (0, harness_path_1.resolveHarnessCommand)(command, searchPath, env),
286
246
  realpath: (path) => (0, node_fs_1.realpathSync)(path),
287
247
  readFile: (path) => {
288
248
  try {
@@ -739,7 +699,7 @@ async function discoverHarnesses(options = {}) {
739
699
  let deps = null;
740
700
  let detectedAt = new Date().toISOString();
741
701
  try {
742
- deps = resolveDeps(options.deps);
702
+ deps = await resolveDeps(options.deps, options.claudePath);
743
703
  detectedAt = deps.now().toISOString();
744
704
  }
745
705
  catch {
@@ -0,0 +1,10 @@
1
+ export declare const LOGIN_PATH_TIMEOUT_MS = 2000;
2
+ /** launchd can omit SHELL even though the account has a configured login shell. */
3
+ export declare function defaultLoginShell(): string | undefined;
4
+ export declare function harnessSearchPath(env: NodeJS.ProcessEnv, home: string, platform?: NodeJS.Platform): string[];
5
+ /** Only PATH is retained. Shell startup output and runner credentials are never returned. */
6
+ export declare function loginShellSearchPath(env: NodeJS.ProcessEnv, timeoutMs?: number): Promise<string[]>;
7
+ export declare function executableFile(path: string): boolean;
8
+ export declare function resolveHarnessCommand(command: string, searchPath: readonly string[], env: NodeJS.ProcessEnv): string | null;
9
+ /** Shared by discovery and the adapter, so a detected CLI is the CLI that runs. */
10
+ export declare function resolveClaudeCommand(explicitPath?: string): Promise<string | null>;
@@ -0,0 +1,174 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOGIN_PATH_TIMEOUT_MS = void 0;
4
+ exports.defaultLoginShell = defaultLoginShell;
5
+ exports.harnessSearchPath = harnessSearchPath;
6
+ exports.loginShellSearchPath = loginShellSearchPath;
7
+ exports.executableFile = executableFile;
8
+ exports.resolveHarnessCommand = resolveHarnessCommand;
9
+ exports.resolveClaudeCommand = resolveClaudeCommand;
10
+ const node_child_process_1 = require("node:child_process");
11
+ const node_fs_1 = require("node:fs");
12
+ const node_os_1 = require("node:os");
13
+ const node_path_1 = require("node:path");
14
+ exports.LOGIN_PATH_TIMEOUT_MS = 2_000;
15
+ const PATH_MARKER = '__PF_HARNESS_PATH__';
16
+ /** launchd can omit SHELL even though the account has a configured login shell. */
17
+ function defaultLoginShell() {
18
+ if (process.platform === 'win32')
19
+ return undefined;
20
+ try {
21
+ return (0, node_os_1.userInfo)().shell ?? '/bin/sh';
22
+ }
23
+ catch {
24
+ return process.platform === 'darwin' ? '/bin/zsh' : '/bin/sh';
25
+ }
26
+ }
27
+ function harnessSearchPath(env, home, platform = process.platform) {
28
+ const inherited = (env.PATH ?? env.Path ?? '')
29
+ .split(node_path_1.delimiter)
30
+ .filter(node_path_1.isAbsolute);
31
+ const known = platform === 'win32'
32
+ ? [
33
+ (0, node_path_1.join)(home, 'AppData', 'Roaming', 'npm'),
34
+ (0, node_path_1.join)(home, '.local', 'bin'),
35
+ (0, node_path_1.join)(home, '.opencode', 'bin')
36
+ ]
37
+ : [
38
+ (0, node_path_1.join)(home, '.local', 'bin'),
39
+ (0, node_path_1.join)(home, '.claude', 'local'),
40
+ (0, node_path_1.join)(home, '.opencode', 'bin'),
41
+ '/opt/homebrew/bin',
42
+ '/usr/local/bin',
43
+ (0, node_path_1.join)(home, '.npm-global', 'bin'),
44
+ (0, node_path_1.join)(home, 'Library', 'pnpm'),
45
+ (0, node_path_1.join)(home, '.volta', 'bin'),
46
+ (0, node_path_1.join)(home, '.bun', 'bin')
47
+ ];
48
+ if (platform !== 'win32') {
49
+ try {
50
+ const nvm = (0, node_path_1.join)(home, '.nvm', 'versions', 'node');
51
+ const versions = (0, node_fs_1.readdirSync)(nvm).filter((name) => /^v\d+[.\d]*$/.test(name));
52
+ versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
53
+ known.push(...versions.slice(0, 32).map((version) => (0, node_path_1.join)(nvm, version, 'bin')));
54
+ }
55
+ catch {
56
+ /* NVM is optional. */
57
+ }
58
+ }
59
+ return [...new Set([...inherited, ...known, (0, node_path_1.dirname)(process.execPath)])];
60
+ }
61
+ /** Only PATH is retained. Shell startup output and runner credentials are never returned. */
62
+ function loginShellSearchPath(env, timeoutMs = exports.LOGIN_PATH_TIMEOUT_MS) {
63
+ const shell = env.SHELL;
64
+ if (!shell ||
65
+ !(0, node_path_1.isAbsolute)(shell) ||
66
+ !['zsh', 'bash', 'sh'].includes((0, node_path_1.basename)(shell))) {
67
+ return Promise.resolve([]);
68
+ }
69
+ const childEnv = Object.fromEntries(Object.entries(env).filter(([key]) => !/^PF_/i.test(key)));
70
+ return new Promise((resolve) => {
71
+ let settled = false;
72
+ let child = null;
73
+ let timer = null;
74
+ const killProbe = () => {
75
+ try {
76
+ if (process.platform !== 'win32' && child?.pid) {
77
+ process.kill(-child.pid, 'SIGKILL');
78
+ }
79
+ else
80
+ child?.kill('SIGKILL');
81
+ }
82
+ catch {
83
+ /* The shell can already have exited. */
84
+ }
85
+ };
86
+ const finish = (paths) => {
87
+ if (settled)
88
+ return;
89
+ settled = true;
90
+ if (timer)
91
+ clearTimeout(timer);
92
+ resolve(paths);
93
+ };
94
+ timer = setTimeout(() => {
95
+ killProbe();
96
+ finish([]);
97
+ }, timeoutMs + 100);
98
+ timer.unref();
99
+ try {
100
+ let output = '';
101
+ let bytes = 0;
102
+ child = (0, node_child_process_1.spawn)(shell, ['-ilc', `printf '\\n${PATH_MARKER}%s\\n' "$PATH"`], {
103
+ env: childEnv,
104
+ detached: process.platform !== 'win32',
105
+ stdio: ['ignore', 'pipe', 'ignore']
106
+ });
107
+ child.stdout?.on('data', (chunk) => {
108
+ bytes += chunk.length;
109
+ if (bytes > 128 * 1024) {
110
+ killProbe();
111
+ finish([]);
112
+ }
113
+ else
114
+ output += chunk.toString();
115
+ });
116
+ child.on('close', (code) => {
117
+ if (code !== 0)
118
+ return finish([]);
119
+ const line = output
120
+ .split('\n')
121
+ .find((entry) => entry.startsWith(PATH_MARKER));
122
+ finish((line?.slice(PATH_MARKER.length) ?? '')
123
+ .split(node_path_1.delimiter)
124
+ .filter(node_path_1.isAbsolute));
125
+ });
126
+ child.on('error', () => finish([]));
127
+ }
128
+ catch {
129
+ finish([]);
130
+ }
131
+ });
132
+ }
133
+ function executableFile(path) {
134
+ try {
135
+ if (!(0, node_fs_1.statSync)(path).isFile())
136
+ return false;
137
+ (0, node_fs_1.accessSync)(path, node_fs_1.constants.X_OK);
138
+ return true;
139
+ }
140
+ catch {
141
+ return false;
142
+ }
143
+ }
144
+ function resolveHarnessCommand(command, searchPath, env) {
145
+ if (command === 'claude' && env.PF_CLAUDE_PATH) {
146
+ const explicit = env.PF_CLAUDE_PATH;
147
+ // An invalid explicit choice must not silently select a different binary.
148
+ return (0, node_path_1.isAbsolute)(explicit) && executableFile(explicit) ? explicit : null;
149
+ }
150
+ const extensions = process.platform === 'win32'
151
+ ? (env.PATHEXT ?? '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean)
152
+ : [''];
153
+ for (const dir of searchPath) {
154
+ for (const extension of extensions) {
155
+ const candidate = (0, node_path_1.join)(dir, `${command}${extension}`);
156
+ if (executableFile(candidate))
157
+ return candidate;
158
+ }
159
+ }
160
+ return null;
161
+ }
162
+ /** Shared by discovery and the adapter, so a detected CLI is the CLI that runs. */
163
+ async function resolveClaudeCommand(explicitPath) {
164
+ const env = {
165
+ ...process.env,
166
+ SHELL: process.env.SHELL ?? defaultLoginShell(),
167
+ ...(explicitPath ? { PF_CLAUDE_PATH: explicitPath } : {})
168
+ };
169
+ const paths = harnessSearchPath(env, (0, node_os_1.homedir)());
170
+ const found = resolveHarnessCommand('claude', paths, env);
171
+ if (found || env.PF_CLAUDE_PATH)
172
+ return found;
173
+ return resolveHarnessCommand('claude', [...paths, ...(await loginShellSearchPath(env))], env);
174
+ }
package/dist/harness.d.ts CHANGED
@@ -233,6 +233,7 @@ export declare class Harness {
233
233
  private heartbeatIntervalMs;
234
234
  private leaseWaitSeconds;
235
235
  private heartbeatTimer;
236
+ private capabilityProbeInFlight;
236
237
  /** Fires when the runner credential should be rotated. Durable runtimes re-persist the new one. */
237
238
  private rotateTimer;
238
239
  private readonly credentialStore;
@@ -429,6 +430,7 @@ export declare class Harness {
429
430
  */
430
431
  private modelsNeedRefresh;
431
432
  private scheduleHeartbeat;
433
+ private refreshCapabilities;
432
434
  private sendHeartbeat;
433
435
  /** A bad command must not stop the others or fail a transcript ingest. */
434
436
  private applyCommandsSafely;
package/dist/harness.js CHANGED
@@ -252,6 +252,7 @@ class Harness {
252
252
  heartbeatIntervalMs;
253
253
  leaseWaitSeconds = contract_1.DEFAULT_LEASE_WAIT_SECONDS;
254
254
  heartbeatTimer = null;
255
+ capabilityProbeInFlight = null;
255
256
  /** Fires when the runner credential should be rotated. Durable runtimes re-persist the new one. */
256
257
  rotateTimer = null;
257
258
  credentialStore;
@@ -928,6 +929,9 @@ class Harness {
928
929
  try {
929
930
  const fresh = await discover({
930
931
  runnableBackends: (this.capabilities?.backends ?? []).map((backend) => backend.name),
932
+ ...(this.options.claudePath
933
+ ? { claudePath: this.options.claudePath }
934
+ : {}),
931
935
  ...(only ? { only } : {})
932
936
  });
933
937
  const report = mergeHarnessReports(this.harnessReport, fresh);
@@ -1168,16 +1172,37 @@ class Harness {
1168
1172
  // Never hold the event loop open on the heartbeat alone.
1169
1173
  this.heartbeatTimer.unref();
1170
1174
  }
1175
+ refreshCapabilities() {
1176
+ if (this.capabilityProbeInFlight || this.stopping)
1177
+ return;
1178
+ const pass = (async () => {
1179
+ try {
1180
+ const capabilities = await (0, capabilities_1.probeCapabilities)({
1181
+ maxConcurrentSessions: this.options.maxConcurrentSessions,
1182
+ labels: this.options.labels
1183
+ });
1184
+ if (!this.stopping)
1185
+ this.capabilities = capabilities;
1186
+ }
1187
+ catch {
1188
+ // A failed capability refresh must not hide an otherwise healthy runner.
1189
+ log('Capability refresh failed; keeping the last report');
1190
+ }
1191
+ })();
1192
+ this.capabilityProbeInFlight = pass;
1193
+ void pass.finally(() => {
1194
+ if (this.capabilityProbeInFlight === pass)
1195
+ this.capabilityProbeInFlight = null;
1196
+ });
1197
+ }
1171
1198
  async sendHeartbeat() {
1172
1199
  try {
1173
- // Capabilities are RE-PROBED, not cached from registration. A host is
1174
- // upgraded in place: docker can appear and a backend can be installed
1175
- // without the runner ever restarting, and the control plane routes work
1176
- // on this answer.
1177
- const capabilities = await (0, capabilities_1.probeCapabilities)({
1178
- maxConcurrentSessions: this.options.maxConcurrentSessions,
1179
- labels: this.options.labels
1180
- });
1200
+ // CLI version probes can hang. Keep the last completed report and send
1201
+ // liveness now; one background pass updates the following heartbeat.
1202
+ const capabilities = this.capabilities;
1203
+ if (!capabilities)
1204
+ throw new Error('Runner has no initial capabilities.');
1205
+ this.refreshCapabilities();
1181
1206
  this.applyModelCatalog(capabilities);
1182
1207
  this.applyHarnessReport(capabilities);
1183
1208
  this.capabilities = capabilities;
package/dist/options.js CHANGED
@@ -219,7 +219,13 @@ function parseOptions(argv, env) {
219
219
  // Resolved before the object literal, because the workspace-root DEFAULT
220
220
  // depends on it.
221
221
  const runtime = parseRuntime(readFlag(argv, 'runtime') ?? env.PF_RUNNER_RUNTIME);
222
+ const claudePath = readFlag(argv, 'claude-path') ?? env.PF_CLAUDE_PATH;
223
+ if (claudePath !== undefined &&
224
+ (!(0, node_path_1.isAbsolute)(claudePath) || /[\r\n\0]/.test(claudePath))) {
225
+ throw new OptionsError('Claude path must be an absolute executable path.');
226
+ }
222
227
  return {
228
+ ...(claudePath ? { claudePath } : {}),
223
229
  controlPlaneUrl,
224
230
  enrolmentToken: enrolmentToken ?? '',
225
231
  healthPort: parsePort(readFlag(argv, 'health-port') ?? env.PF_HARNESS_HEALTH_PORT, DEFAULT_HEALTH_PORT),
package/dist/service.d.ts CHANGED
@@ -63,7 +63,7 @@ export declare class ServiceManager {
63
63
  install(input: string[]): Promise<void>;
64
64
  private vendorFlag;
65
65
  private checkNodeVersion;
66
- repair(): void;
66
+ repair(input?: string[]): void;
67
67
  assertNoDuplicateRunner(options: {
68
68
  workspaceRoot: string;
69
69
  healthPort: number;
package/dist/service.js CHANGED
@@ -35,7 +35,8 @@ const VALUE_FLAGS = new Set([
35
35
  '--runtime',
36
36
  '--max-sessions',
37
37
  '--labels',
38
- '--vendor-harnesses'
38
+ '--vendor-harnesses',
39
+ '--claude-path'
39
40
  ]);
40
41
  const SWITCH_FLAGS = new Set(['--no-harness-discovery']);
41
42
  /** Service names for an instance. Only vendor names are accepted. */
@@ -172,6 +173,12 @@ function serviceStartArgs(input, env, home) {
172
173
  if (env.PF_VENDOR_HARNESSES && !values.has('--vendor-harnesses')) {
173
174
  values.set('--vendor-harnesses', safe(env.PF_VENDOR_HARNESSES));
174
175
  }
176
+ const claudePath = values.get('--claude-path') ?? env.PF_CLAUDE_PATH;
177
+ if (claudePath !== undefined) {
178
+ if (!(0, node_path_1.isAbsolute)(claudePath))
179
+ throw new Error('Claude path must be absolute.');
180
+ values.set('--claude-path', safe(claudePath));
181
+ }
175
182
  const vendorNames = values.get('--vendor-harnesses');
176
183
  if (vendorNames !== undefined) {
177
184
  const names = vendorNames
@@ -430,13 +437,36 @@ class ServiceManager {
430
437
  if (floor.action === 'refuse')
431
438
  throw new Error(floor.message);
432
439
  }
433
- repair() {
440
+ repair(input = []) {
434
441
  this.supported();
435
442
  this.checkNodeVersion();
436
443
  const prior = this.readConfig();
437
- this.checkCredential(prior.startArgs);
444
+ let claudePath = this.context.env.PF_CLAUDE_PATH;
445
+ for (let index = 0; index < input.length; index++) {
446
+ const flag = input[index];
447
+ if (flag === '--claude-path')
448
+ claudePath = input[++index];
449
+ else if (flag.startsWith('--claude-path='))
450
+ claudePath = flag.slice(14);
451
+ else
452
+ throw new Error('Service repair accepts only --claude-path.');
453
+ if (!claudePath)
454
+ throw new Error('Missing value for --claude-path.');
455
+ }
456
+ const startArgs = [...prior.startArgs];
457
+ if (claudePath !== undefined) {
458
+ if (!(0, node_path_1.isAbsolute)(claudePath))
459
+ throw new Error('Claude path must be absolute.');
460
+ const at = startArgs.indexOf('--claude-path');
461
+ if (at >= 0)
462
+ startArgs.splice(at, 2);
463
+ startArgs.push('--claude-path', safe(claudePath));
464
+ }
465
+ (0, options_1.parseOptions)(startArgs, {});
466
+ this.checkCredential(startArgs);
438
467
  this.writeAndStart({
439
468
  ...prior,
469
+ startArgs,
440
470
  nodePath: this.context.nodePath,
441
471
  cliPath: this.context.cliPath,
442
472
  path: this.context.env.PATH ?? prior.path
@@ -576,7 +606,7 @@ async function runServiceCommand(input, context) {
576
606
  manager.status();
577
607
  break;
578
608
  case 'repair':
579
- manager.repair();
609
+ manager.repair(args.slice(1));
580
610
  break;
581
611
  case 'uninstall':
582
612
  manager.uninstall();
package/dist/types.d.ts CHANGED
@@ -38,6 +38,8 @@
38
38
  * Do not "improve" this by adding a `--token` flag.
39
39
  */
40
40
  export interface HarnessOptions {
41
+ /** Absolute operator-selected Claude executable. Never a shell command. */
42
+ claudePath?: string;
41
43
  /** workflow-api base url, e.g. https://stage-api.peakflo.ai */
42
44
  controlPlaneUrl: string;
43
45
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.17",
3
+ "version": "0.2.18-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",