@peakflo/agent-harness 0.0.0-stage → 0.2.2-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.
Files changed (85) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/adapters/acp-adapter.d.ts +177 -0
  4. package/dist/adapters/acp-adapter.js +1732 -0
  5. package/dist/adapters/claude-code-adapter.d.ts +171 -0
  6. package/dist/adapters/claude-code-adapter.js +1918 -0
  7. package/dist/adapters/codex-adapter.d.ts +67 -0
  8. package/dist/adapters/codex-adapter.js +681 -0
  9. package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
  10. package/dist/adapters/codex-app-server-adapter.js +1309 -0
  11. package/dist/adapters/coding-agent-adapter.d.ts +289 -0
  12. package/dist/adapters/coding-agent-adapter.js +31 -0
  13. package/dist/adapters/index.d.ts +17 -0
  14. package/dist/adapters/index.js +35 -0
  15. package/dist/adapters/model-gateway.d.ts +84 -0
  16. package/dist/adapters/model-gateway.js +270 -0
  17. package/dist/adapters/opencode-adapter.d.ts +403 -0
  18. package/dist/adapters/opencode-adapter.js +2710 -0
  19. package/dist/adapters/opencode-config.d.ts +41 -0
  20. package/dist/adapters/opencode-config.js +147 -0
  21. package/dist/adapters/opencode-server-policy.d.ts +82 -0
  22. package/dist/adapters/opencode-server-policy.js +148 -0
  23. package/dist/adapters/pi-adapter.d.ts +168 -0
  24. package/dist/adapters/pi-adapter.js +1468 -0
  25. package/dist/capabilities.d.ts +58 -0
  26. package/dist/capabilities.js +161 -0
  27. package/dist/cli.d.ts +2 -0
  28. package/dist/cli.js +231 -0
  29. package/dist/contract.d.ts +273 -0
  30. package/dist/contract.js +357 -0
  31. package/dist/control-plane.d.ts +178 -0
  32. package/dist/control-plane.js +431 -0
  33. package/dist/credential-store.d.ts +110 -0
  34. package/dist/credential-store.js +181 -0
  35. package/dist/enrol/browser-enrol.d.ts +85 -0
  36. package/dist/enrol/browser-enrol.js +387 -0
  37. package/dist/github-command-guards.d.ts +20 -0
  38. package/dist/github-command-guards.js +330 -0
  39. package/dist/github-proxy-certificates.d.ts +25 -0
  40. package/dist/github-proxy-certificates.js +145 -0
  41. package/dist/github-proxy.d.ts +37 -0
  42. package/dist/github-proxy.js +476 -0
  43. package/dist/github-workspace.d.ts +55 -0
  44. package/dist/github-workspace.js +537 -0
  45. package/dist/harness.d.ts +333 -0
  46. package/dist/harness.js +1414 -0
  47. package/dist/health.d.ts +45 -0
  48. package/dist/health.js +116 -0
  49. package/dist/index.d.ts +53 -0
  50. package/dist/index.js +123 -0
  51. package/dist/model-probe.d.ts +96 -0
  52. package/dist/model-probe.js +195 -0
  53. package/dist/options.d.ts +15 -0
  54. package/dist/options.js +212 -0
  55. package/dist/ports-default.d.ts +212 -0
  56. package/dist/ports-default.js +333 -0
  57. package/dist/ports.d.ts +436 -0
  58. package/dist/ports.js +27 -0
  59. package/dist/runtime/attachments.d.ts +92 -0
  60. package/dist/runtime/attachments.js +377 -0
  61. package/dist/runtime/child-stream-guards.d.ts +45 -0
  62. package/dist/runtime/child-stream-guards.js +111 -0
  63. package/dist/runtime/conversation.d.ts +87 -0
  64. package/dist/runtime/conversation.js +156 -0
  65. package/dist/runtime/lease-renewal.d.ts +169 -0
  66. package/dist/runtime/lease-renewal.js +433 -0
  67. package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
  68. package/dist/runtime/mcp-auth-proxy.js +300 -0
  69. package/dist/runtime/permission-mode.d.ts +116 -0
  70. package/dist/runtime/permission-mode.js +146 -0
  71. package/dist/runtime/projection.d.ts +71 -0
  72. package/dist/runtime/projection.js +211 -0
  73. package/dist/runtime/session-runner.d.ts +520 -0
  74. package/dist/runtime/session-runner.js +2122 -0
  75. package/dist/shared/reasoning-effort.d.ts +11 -0
  76. package/dist/shared/reasoning-effort.js +20 -0
  77. package/dist/store/conversation-store.d.ts +100 -0
  78. package/dist/store/conversation-store.js +305 -0
  79. package/dist/store/sqlite-store.d.ts +120 -0
  80. package/dist/store/sqlite-store.js +346 -0
  81. package/dist/types.d.ts +952 -0
  82. package/dist/types.js +55 -0
  83. package/dist/version.d.ts +32 -0
  84. package/dist/version.js +77 -0
  85. package/package.json +53 -4
@@ -0,0 +1,58 @@
1
+ /**
2
+ * THE CAPABILITY PROBE.
3
+ *
4
+ * Ported from 20x `src/main/agent-installer/detect.js`, which has been probing
5
+ * these same binaries in production since long before this package existed.
6
+ * The detection logic — PATH repair, `--version`, the version-extracting
7
+ * regular expression, the swallow-everything failure mode — is reproduced
8
+ * rather than reinvented, because every line of it encodes a platform quirk
9
+ * somebody already hit.
10
+ *
11
+ * WHAT CHANGED IN THE PORT, and why:
12
+ *
13
+ * 1. It reports the WIRE shape (`RunnerCapabilities`) rather than the desktop's
14
+ * installer-shaped record. The control plane routes work on this, so it must
15
+ * speak the contract's vocabulary, not the installer's.
16
+ *
17
+ * 2. Probes run with a hard timeout and never reject. A capability probe that
18
+ * can fail is a runner that can fail to register, and "docker is missing" is
19
+ * not a reason to refuse to run an agent that does not need docker.
20
+ *
21
+ * 3. The result is NOT cached across calls. A host is upgraded in place — a
22
+ * backend can be installed and docker can appear without the runner ever
23
+ * restarting — which is exactly why the contract refreshes capabilities on
24
+ * every heartbeat rather than trusting the registration snapshot.
25
+ */
26
+ import { RunnerCapabilities } from './types';
27
+ export interface ProbeResult {
28
+ installed: boolean;
29
+ version: string | null;
30
+ }
31
+ /**
32
+ * Run one command and pull a version out of its stdout. NEVER REJECTS.
33
+ *
34
+ * `shell: true` on Windows is load-bearing and not a style choice — without it
35
+ * `.cmd`/`.bat` wrappers, which is how npm installs binaries on Windows, do not
36
+ * resolve at all and every backend reports as missing.
37
+ */
38
+ export declare function probe(command: string, args?: string[]): Promise<ProbeResult>;
39
+ export interface ProbeCapabilitiesOptions {
40
+ /**
41
+ * How many sessions this host will run at once.
42
+ *
43
+ * DEFAULTS TO 1, and that default is a security position rather than a
44
+ * performance one: cross-session isolation on a shared customer VM is the
45
+ * customer's duty, and 1 is the only value that needs no such assumption.
46
+ */
47
+ maxConcurrentSessions?: number;
48
+ /** Free-form routing labels, e.g. ['eu-only', 'has-vpn']. */
49
+ labels?: string[];
50
+ }
51
+ /**
52
+ * Everything the control plane needs to decide whether to send work here.
53
+ *
54
+ * All probes run CONCURRENTLY. Serially this would cost six process spawns at
55
+ * up to 10s each on a host where nothing is installed, and it runs on every
56
+ * heartbeat.
57
+ */
58
+ export declare function probeCapabilities(options?: ProbeCapabilitiesOptions): Promise<RunnerCapabilities>;
@@ -0,0 +1,161 @@
1
+ "use strict";
2
+ /**
3
+ * THE CAPABILITY PROBE.
4
+ *
5
+ * Ported from 20x `src/main/agent-installer/detect.js`, which has been probing
6
+ * these same binaries in production since long before this package existed.
7
+ * The detection logic — PATH repair, `--version`, the version-extracting
8
+ * regular expression, the swallow-everything failure mode — is reproduced
9
+ * rather than reinvented, because every line of it encodes a platform quirk
10
+ * somebody already hit.
11
+ *
12
+ * WHAT CHANGED IN THE PORT, and why:
13
+ *
14
+ * 1. It reports the WIRE shape (`RunnerCapabilities`) rather than the desktop's
15
+ * installer-shaped record. The control plane routes work on this, so it must
16
+ * speak the contract's vocabulary, not the installer's.
17
+ *
18
+ * 2. Probes run with a hard timeout and never reject. A capability probe that
19
+ * can fail is a runner that can fail to register, and "docker is missing" is
20
+ * not a reason to refuse to run an agent that does not need docker.
21
+ *
22
+ * 3. The result is NOT cached across calls. A host is upgraded in place — a
23
+ * backend can be installed and docker can appear without the runner ever
24
+ * restarting — which is exactly why the contract refreshes capabilities on
25
+ * every heartbeat rather than trusting the registration snapshot.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.probe = probe;
29
+ exports.probeCapabilities = probeCapabilities;
30
+ const node_child_process_1 = require("node:child_process");
31
+ const node_fs_1 = require("node:fs");
32
+ const node_os_1 = require("node:os");
33
+ const node_path_1 = require("node:path");
34
+ const version_1 = require("./version");
35
+ const PROBE_TIMEOUT_MS = 10_000;
36
+ /**
37
+ * The coding-agent backends this harness knows how to drive, and the command
38
+ * that reports each one's version.
39
+ *
40
+ * The NAMES are the contract's open backend vocabulary and must match what the
41
+ * control plane puts in `LeasedAgent.backend`. The COMMANDS are what 20x's
42
+ * installer already probes.
43
+ */
44
+ const BACKEND_PROBES = [
45
+ { name: 'pi', command: 'pi' }
46
+ ];
47
+ /**
48
+ * Put the well-known install directories on PATH before probing.
49
+ *
50
+ * Straight from detect.js. Without it a backend installed by its own installer
51
+ * is invisible until the process restarts — on the desktop that meant "restart
52
+ * the app after installing", and on a runner it would mean a host that silently
53
+ * reports a backend it actually has.
54
+ */
55
+ function ensureAgentPaths() {
56
+ const home = (0, node_os_1.homedir)();
57
+ const sep = process.platform === 'win32' ? ';' : ':';
58
+ const segments = (process.env.PATH || '').split(sep);
59
+ const knownDirs = [
60
+ (0, node_path_1.join)(home, '.local', 'bin') // Linux binary installs
61
+ ];
62
+ for (const dir of knownDirs) {
63
+ if ((0, node_fs_1.existsSync)(dir) &&
64
+ !segments.includes(dir) &&
65
+ !segments.includes(`${dir}/`)) {
66
+ process.env.PATH = `${dir}${sep}${process.env.PATH}`;
67
+ }
68
+ }
69
+ }
70
+ /**
71
+ * Run one command and pull a version out of its stdout. NEVER REJECTS.
72
+ *
73
+ * `shell: true` on Windows is load-bearing and not a style choice — without it
74
+ * `.cmd`/`.bat` wrappers, which is how npm installs binaries on Windows, do not
75
+ * resolve at all and every backend reports as missing.
76
+ */
77
+ function probe(command, args = ['--version']) {
78
+ return new Promise((resolve) => {
79
+ let settled = false;
80
+ const finish = (result) => {
81
+ if (!settled) {
82
+ settled = true;
83
+ resolve(result);
84
+ }
85
+ };
86
+ try {
87
+ (0, node_child_process_1.execFile)(command, args, {
88
+ timeout: PROBE_TIMEOUT_MS,
89
+ shell: process.platform === 'win32',
90
+ windowsHide: true
91
+ }, (error, stdout) => {
92
+ if (error) {
93
+ finish({ installed: false, version: null });
94
+ return;
95
+ }
96
+ const raw = String(stdout).trim();
97
+ // Same expression as detect.js: pick the first version-like token, and
98
+ // fall back to the first line when a tool prints something exotic.
99
+ const match = raw.match(/(\d+\.\d+[\w.-]*)/);
100
+ finish({
101
+ installed: true,
102
+ version: match ? match[1] : raw.split('\n')[0] || null
103
+ });
104
+ });
105
+ }
106
+ catch {
107
+ // execFile can throw synchronously (EACCES on a non-executable path).
108
+ finish({ installed: false, version: null });
109
+ }
110
+ });
111
+ }
112
+ /**
113
+ * `os.hostname()` throws on some locked-down containers. A host that cannot
114
+ * name itself is still a perfectly good runner, so this must not be fatal.
115
+ */
116
+ function safeHostname() {
117
+ try {
118
+ return (0, node_os_1.hostname)() || 'unknown';
119
+ }
120
+ catch {
121
+ return 'unknown';
122
+ }
123
+ }
124
+ /**
125
+ * Everything the control plane needs to decide whether to send work here.
126
+ *
127
+ * All probes run CONCURRENTLY. Serially this would cost six process spawns at
128
+ * up to 10s each on a host where nothing is installed, and it runs on every
129
+ * heartbeat.
130
+ */
131
+ async function probeCapabilities(options = {}) {
132
+ ensureAgentPaths();
133
+ const [git, docker, ...backendResults] = await Promise.all([
134
+ probe('git'),
135
+ probe('docker'),
136
+ ...BACKEND_PROBES.map((b) => probe(b.command))
137
+ ]);
138
+ const backends = BACKEND_PROBES.map((b, index) => ({
139
+ name: b.name,
140
+ result: backendResults[index]
141
+ }))
142
+ // Only INSTALLED backends are reported. A backend listed with a null
143
+ // version would be indistinguishable from one that is present but whose
144
+ // version could not be read, and the control plane routes on this list.
145
+ .filter((entry) => entry.result?.installed)
146
+ .map((entry) => ({ name: entry.name, version: entry.result.version }));
147
+ const sandboxToolsVersion = (0, version_1.getInstalledSandboxToolsVersion)();
148
+ return {
149
+ os: process.platform,
150
+ arch: process.arch,
151
+ harnessVersion: (0, version_1.getHarnessVersion)(),
152
+ ...(sandboxToolsVersion ? { sandboxToolsVersion } : {}),
153
+ nodeVersion: process.version,
154
+ hostname: safeHostname(),
155
+ backends,
156
+ git: git.installed,
157
+ docker: docker.installed,
158
+ maxConcurrentSessions: options.maxConcurrentSessions ?? 1,
159
+ labels: options.labels
160
+ };
161
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
4
+ if (k2 === undefined) k2 = k;
5
+ var desc = Object.getOwnPropertyDescriptor(m, k);
6
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
7
+ desc = { enumerable: true, get: function() { return m[k]; } };
8
+ }
9
+ Object.defineProperty(o, k2, desc);
10
+ }) : (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ o[k2] = m[k];
13
+ }));
14
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
15
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
16
+ }) : function(o, v) {
17
+ o["default"] = v;
18
+ });
19
+ var __importStar = (this && this.__importStar) || (function () {
20
+ var ownKeys = function(o) {
21
+ ownKeys = Object.getOwnPropertyNames || function (o) {
22
+ var ar = [];
23
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
24
+ return ar;
25
+ };
26
+ return ownKeys(o);
27
+ };
28
+ return function (mod) {
29
+ if (mod && mod.__esModule) return mod;
30
+ var result = {};
31
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
32
+ __setModuleDefault(result, mod);
33
+ return result;
34
+ };
35
+ })();
36
+ Object.defineProperty(exports, "__esModule", { value: true });
37
+ const adapters_1 = require("./adapters");
38
+ const contract_1 = require("./contract");
39
+ const credential_store_1 = require("./credential-store");
40
+ const harness_1 = require("./harness");
41
+ const options_1 = require("./options");
42
+ const version_1 = require("./version");
43
+ const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
44
+
45
+ Usage:
46
+ pf-agent-harness start [options]
47
+ pf-agent-harness version
48
+
49
+ Options:
50
+ --control-plane <url> workflow-api base url (env PF_CONTROL_PLANE_URL)
51
+ --token <token> enrolment token (env PF_ENROLMENT_TOKEN)
52
+ Omit it at a terminal and the runner opens your
53
+ browser to sign in, then asks which organisation
54
+ to join — the same shape 20x uses.
55
+ --login force the browser sign-in even without a TTY
56
+ --app-url <url> where the web app lives (env PF_APP_URL);
57
+ derived from --control-plane when omitted
58
+ --label <name> runner name shown in the UI (env PF_RUNNER_LABEL,
59
+ default this machine's hostname)
60
+ --health-port <port> loopback health port (env PF_HARNESS_HEALTH_PORT, default 7777)
61
+ --github-proxy-port <port> loopback GitHub proxy (env PF_GITHUB_PROXY_PORT, default 7780)
62
+ --worktree-wip-commit <bool> save dirty work in a WIP commit after each turn
63
+ (env PF_WORKTREE_WIP_COMMIT; default true in
64
+ sandbox, false otherwise)
65
+ --heartbeat-interval-ms <ms> heartbeat cadence, 1000-3600000
66
+ (env PF_HARNESS_HEARTBEAT_INTERVAL_MS,
67
+ default 30000)
68
+ --degraded started from the warm fallback copy
69
+ --degraded-reason <text> why the fallback was used
70
+ --workspace-root <path> session workspaces (env PF_WORKSPACE_ROOT;
71
+ defaults under ~/.pf-agent-harness, or
72
+ /var/pf/workspaces in a managed sandbox)
73
+ --runtime <mode> sandbox | self-hosted | desktop
74
+ (env PF_RUNNER_RUNTIME, default desktop)
75
+ --max-sessions <n> concurrent sessions (env PF_MAX_CONCURRENT_SESSIONS,
76
+ default 10 for desktop/self-hosted; sandbox is configured by control plane)
77
+ --labels <a,b> routing labels (env PF_RUNNER_LABELS)
78
+
79
+ In a managed sandbox pf-bootstrap supplies these through the environment, so
80
+ the enrolment token never appears in /proc/<pid>/cmdline.
81
+ `;
82
+ /**
83
+ * Which adapter drives which backend.
84
+ *
85
+ * PI ONLY IN THIS BUILD, even though the legacy adapters remain exported for
86
+ * extracted and are exported from the package. The others are wired by a HOST
87
+ * that has verified their prerequisites: Claude Code needs
88
+ * `@anthropic-ai/claude-agent-sdk` (an optional dependency, absent from the
89
+ * sandbox image), and Codex and ACP need binaries the image does not ship.
90
+ * Advertising a backend the host cannot actually run would make the control
91
+ * plane route work to a runner that must then fail it.
92
+ */
93
+ function resolveAdapter(backend, ports) {
94
+ if (backend === 'pi') {
95
+ // The adapter reads the per-turn gateway holder directly. If the lease has
96
+ // no model token SessionRunner refuses before this constructor is reached.
97
+ return new adapters_1.PiAdapter(ports.modelGateway);
98
+ }
99
+ throw new Error(`Backend "${backend}" is not wired in this build. ` +
100
+ 'Only "pi" is available; the other adapters are exported for a ' +
101
+ 'host to wire once it has verified their prerequisites.');
102
+ }
103
+ /**
104
+ * Read `--flag value` from argv. A tiny duplicate of the reader in options.ts,
105
+ * because the login decision has to be made BEFORE `parseOptions` runs — that
106
+ * is the very call that throws when the token is missing.
107
+ */
108
+ function readOptionValue(argv, flag) {
109
+ // `--flag=value` FIRST. Without this, `--token=abc` matched no `--token`
110
+ // argument, so a runner started with a token at a terminal fell through to
111
+ // the browser sign-in — asking a human to authorise something they had
112
+ // already authorised, and burning the token they supplied.
113
+ const inline = argv.find((arg) => arg.startsWith(`--${flag}=`));
114
+ if (inline)
115
+ return inline.slice(`--${flag}=`.length) || undefined;
116
+ const index = argv.indexOf(`--${flag}`);
117
+ if (index === -1)
118
+ return undefined;
119
+ const value = argv[index + 1];
120
+ return value && !value.startsWith('--') ? value : undefined;
121
+ }
122
+ async function main() {
123
+ const argv = process.argv.slice(2);
124
+ const command = argv[0];
125
+ if (!command || command === 'help' || command === '--help') {
126
+ process.stdout.write(USAGE);
127
+ return;
128
+ }
129
+ if (command === 'version' || command === '--version') {
130
+ process.stdout.write(`${JSON.stringify({
131
+ harnessVersion: (0, version_1.getHarnessVersion)(),
132
+ nodeVersion: process.version,
133
+ piVersion: (0, version_1.getPiVersion)()
134
+ })}\n`);
135
+ return;
136
+ }
137
+ if (command !== 'start') {
138
+ process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
139
+ process.exitCode = 2;
140
+ return;
141
+ }
142
+ /*
143
+ * NO TOKEN? OFFER TO SIGN IN, BUT ONLY WHERE THAT CAN POSSIBLY WORK.
144
+ *
145
+ * The interactive flow needs a human at a terminal and a browser on this
146
+ * machine. A managed sandbox has neither, and pf-bootstrap always supplies
147
+ * the token through the environment — so a sandbox missing its token must
148
+ * FAIL FAST with the original error rather than sit for ten minutes waiting
149
+ * for a browser that will never open. That would turn a loud, recyclable
150
+ * start-up failure into a silent hang, which is the worse of the two.
151
+ *
152
+ * Hence: only when stdin and stdout are both a TTY, or when `--login` asks
153
+ * for it explicitly.
154
+ */
155
+ const startArgs = argv.slice(1);
156
+ const wantsLogin = startArgs.includes('--login');
157
+ // `--token=x` counts as a token, hence readOptionValue rather than indexOf.
158
+ const hasToken = Boolean(readOptionValue(startArgs, 'token') ?? process.env.PF_ENROLMENT_TOKEN);
159
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
160
+ if (!hasToken && (wantsLogin || interactive)) {
161
+ const { enrolViaBrowser } = await Promise.resolve().then(() => __importStar(require('./enrol/browser-enrol')));
162
+ const token = await enrolViaBrowser({
163
+ controlPlaneUrl: readOptionValue(startArgs, 'control-plane') ??
164
+ process.env.PF_CONTROL_PLANE_URL ??
165
+ '',
166
+ runtime: readOptionValue(startArgs, 'runtime') ??
167
+ process.env.PF_RUNNER_RUNTIME ??
168
+ 'desktop',
169
+ appUrl: readOptionValue(startArgs, 'app-url') ?? process.env.PF_APP_URL,
170
+ label: readOptionValue(startArgs, 'label') ?? process.env.PF_RUNNER_LABEL
171
+ });
172
+ // Handed on through the environment rather than argv, for the same reason
173
+ // pf-bootstrap does: argv is world-readable via /proc/<pid>/cmdline.
174
+ process.env.PF_ENROLMENT_TOKEN = token;
175
+ }
176
+ const options = (0, options_1.parseOptions)(startArgs, process.env);
177
+ const handedOffCredential = (0, credential_store_1.takeHandedOffCredential)(process.env);
178
+ const harness = new harness_1.Harness(options, {
179
+ getAdapter: (backend, ports) => resolveAdapter(backend, ports),
180
+ ...(handedOffCredential
181
+ ? { credentialStore: new credential_store_1.MemoryCredentialStore(handedOffCredential) }
182
+ : {}),
183
+ onUpgradeRequested: (request) => {
184
+ if (typeof process.send === 'function') {
185
+ process.send({ type: 'pf-harness-upgrade', ...request });
186
+ }
187
+ }
188
+ });
189
+ let shuttingDown = false;
190
+ const shutdown = (signal) => {
191
+ if (shuttingDown) {
192
+ return;
193
+ }
194
+ shuttingDown = true;
195
+ process.stderr.write(`Received ${signal}, shutting down\n`);
196
+ void harness.stop().then(() => process.exit(0));
197
+ };
198
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
199
+ process.on('SIGINT', () => shutdown('SIGINT'));
200
+ await harness.start();
201
+ // BLOCK ON THE LEASE LOOP, and let it decide when the process is done.
202
+ //
203
+ // The Phase 0 skeleton relied on the health socket keeping the event loop
204
+ // alive, which meant nothing could ever end the process deliberately — a
205
+ // refused protocol version would have hung instead of exiting. `wait()`
206
+ // resolves on a clean drain and rejects when the control plane refused this
207
+ // build, so the exit code carries the reason.
208
+ try {
209
+ await harness.wait();
210
+ }
211
+ catch (error) {
212
+ if (error instanceof harness_1.UpgradeRequestedError) {
213
+ process.exit(contract_1.HARNESS_UPGRADE_EXIT_CODE);
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ main().catch((error) => {
219
+ // process.exit, NOT process.exitCode. `exitCode` only takes effect when the
220
+ // event loop drains on its own, and a start-up failure can leave a listening
221
+ // socket or a pending timer behind, which would keep the process alive for
222
+ // ever. A sandbox that hangs is worse than one that dies: the control plane
223
+ // cannot recycle it, so the version gate would never take effect.
224
+ if (error instanceof options_1.OptionsError) {
225
+ process.stderr.write(`${error.message}\n\n${USAGE}`);
226
+ process.exit(2);
227
+ }
228
+ const message = error instanceof Error ? error.stack : String(error);
229
+ process.stderr.write(`pf-agent-harness failed to start:\n${message}\n`);
230
+ process.exit(1);
231
+ });