@peakflo/agent-harness 0.2.10-rc.3 → 0.2.11-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
@@ -52,6 +52,28 @@ Optional flags include `--health-port <port>`, `--runtime <desktop|self-hosted>`
52
52
  `--workspace-root <path>`, and `--github-proxy-port <port>`. Run
53
53
  `npx @peakflo/agent-harness@rc --help` for the full list.
54
54
 
55
+ ## Connect your own agent in one command
56
+
57
+ The Welcome window in Spaces shows one command for Codex, Claude Code, or
58
+ Cursor, for example:
59
+
60
+ ```sh
61
+ npx --yes @peakflo/agent-harness@<version> connect claude-code --connection <id>
62
+ ```
63
+
64
+ `connect` checks that the vendor CLI is installed and signed in, opens your
65
+ browser so you can approve this computer (you must be signed in as the same
66
+ user and company), registers the computer as your personal runner for that
67
+ vendor only, and installs a background service for it. The command holds no
68
+ secret: `--connection` is a public id, and the one-time enrolment token is
69
+ fetched only after the approval, with a PKCE verifier that never leaves the
70
+ process. If port 7777 or 7780 is in use, `connect` picks free ports.
71
+
72
+ Each vendor gets its own service and workspace under
73
+ `~/.pf-agent-harness/connect/<vendor>`. Check or remove one with
74
+ `service status --vendor <vendor>` or `service uninstall --vendor <vendor>`.
75
+ `connect` needs macOS or Linux.
76
+
55
77
  ## Run after you close the terminal
56
78
 
57
79
  The background service supports macOS launchd user agents and Linux systemd
package/dist/cli.js CHANGED
@@ -45,12 +45,16 @@ const version_1 = require("./version");
45
45
  const USAGE = `pf-agent-harness — headless Peakflo coding-agent harness
46
46
 
47
47
  Usage:
48
+ pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]
49
+ one command from the Welcome window: approve
50
+ this computer in the browser, connect the agent
51
+ as your personal agent, run it in the background
48
52
  pf-agent-harness start [options]
49
53
  pf-agent-harness version
50
54
  pf-agent-harness service install [start options]
51
- pf-agent-harness service status
52
- pf-agent-harness service repair
53
- pf-agent-harness service uninstall
55
+ pf-agent-harness service status [--vendor <name>]
56
+ pf-agent-harness service repair [--vendor <name>]
57
+ pf-agent-harness service uninstall [--vendor <name>]
54
58
 
55
59
  Options:
56
60
  --control-plane <url> workflow-api base url (env PF_CONTROL_PLANE_URL)
@@ -150,6 +154,33 @@ async function main() {
150
154
  });
151
155
  return;
152
156
  }
157
+ if (command === 'connect') {
158
+ const floor = (0, node_floor_1.checkNodeFloor)({
159
+ current: process.versions.node,
160
+ floor: (0, version_1.getNodeFloor)(),
161
+ runtime: 'desktop',
162
+ env: process.env
163
+ });
164
+ if (floor.action === 'refuse') {
165
+ process.stderr.write(`${floor.message}\n`);
166
+ process.exit(1);
167
+ }
168
+ const { defaultConnectDeps, parseConnectArgs, runConnect } = await Promise.resolve().then(() => __importStar(require('./connect/connect')));
169
+ try {
170
+ await runConnect(parseConnectArgs(argv.slice(1), process.env), defaultConnectDeps({
171
+ nodePath: process.execPath,
172
+ cliPath: __filename,
173
+ env: process.env
174
+ }));
175
+ }
176
+ catch (error) {
177
+ // A message for a person, not a stack. Never includes the token.
178
+ process.stderr.write(`\n✗ ${error instanceof Error ? error.message : String(error)}\n`);
179
+ process.exit(1);
180
+ }
181
+ // Loopback servers and timers are closed; exit promptly anyway.
182
+ process.exit(0);
183
+ }
153
184
  if (command !== 'start') {
154
185
  process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
155
186
  process.exitCode = 2;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The 127.0.0.1 receiver for the `connect` approval.
3
+ *
4
+ * After the user approves in the browser, the approval page sends
5
+ * `?state=<nonce>&code=<one-time code>` here. The code is useless alone: the
6
+ * exchange also needs the PKCE verifier, which never leaves this process. The
7
+ * `state` nonce is compared in constant time, so another page in the browser
8
+ * cannot feed this process a different approval. No CORS headers are sent.
9
+ */
10
+ export declare const CALLBACK_PORT_START = 48690;
11
+ export declare const CALLBACK_PORT_END = 48710;
12
+ export declare const APPROVAL_TIMEOUT_MS: number;
13
+ export declare class ApprovalCallback {
14
+ readonly state: string;
15
+ port: number;
16
+ private server;
17
+ private resolve;
18
+ private timer;
19
+ private used;
20
+ start(): Promise<number>;
21
+ private listen;
22
+ wait(timeoutMs?: number): Promise<string>;
23
+ private clearTimer;
24
+ stop(): void;
25
+ }
@@ -0,0 +1,122 @@
1
+ "use strict";
2
+ /**
3
+ * The 127.0.0.1 receiver for the `connect` approval.
4
+ *
5
+ * After the user approves in the browser, the approval page sends
6
+ * `?state=<nonce>&code=<one-time code>` here. The code is useless alone: the
7
+ * exchange also needs the PKCE verifier, which never leaves this process. The
8
+ * `state` nonce is compared in constant time, so another page in the browser
9
+ * cannot feed this process a different approval. No CORS headers are sent.
10
+ */
11
+ Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.ApprovalCallback = exports.APPROVAL_TIMEOUT_MS = exports.CALLBACK_PORT_END = exports.CALLBACK_PORT_START = void 0;
13
+ const node_crypto_1 = require("node:crypto");
14
+ const node_http_1 = require("node:http");
15
+ exports.CALLBACK_PORT_START = 48690;
16
+ exports.CALLBACK_PORT_END = 48710;
17
+ exports.APPROVAL_TIMEOUT_MS = 10 * 60 * 1000;
18
+ const CODE = /^[A-Za-z0-9_-]{43}$/;
19
+ function page(title, detail) {
20
+ return `<!doctype html><html><head><meta charset="utf-8"><title>${title}</title>
21
+ <style>body{font:15px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
22
+ .c{text-align:center;max-width:28rem;padding:2rem}h1{font-size:1.2rem;margin:0 0 .5rem}p{color:#666;margin:0}</style>
23
+ </head><body><div class="c"><h1>${title}</h1><p>${detail}</p></div></body></html>`;
24
+ }
25
+ function matches(expected, actual) {
26
+ if (actual === null)
27
+ return false;
28
+ const a = Buffer.from(expected);
29
+ const b = Buffer.from(actual);
30
+ return a.length === b.length && (0, node_crypto_1.timingSafeEqual)(a, b);
31
+ }
32
+ class ApprovalCallback {
33
+ state = (0, node_crypto_1.randomBytes)(24).toString('base64url');
34
+ port = 0;
35
+ server = null;
36
+ resolve = null;
37
+ timer = null;
38
+ used = false;
39
+ async start() {
40
+ for (let port = exports.CALLBACK_PORT_START; port <= exports.CALLBACK_PORT_END; port += 1) {
41
+ try {
42
+ await this.listen(port);
43
+ this.port = port;
44
+ return port;
45
+ }
46
+ catch {
47
+ continue;
48
+ }
49
+ }
50
+ throw new Error(`No free local port for the approval in ${exports.CALLBACK_PORT_START}-${exports.CALLBACK_PORT_END}.`);
51
+ }
52
+ listen(port) {
53
+ return new Promise((resolve, reject) => {
54
+ const server = (0, node_http_1.createServer)((req, res) => {
55
+ const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
56
+ // DNS-rebinding guard: only a request addressed to this loopback port.
57
+ const host = req.headers.host ?? '';
58
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
59
+ res.writeHead(421);
60
+ res.end();
61
+ return;
62
+ }
63
+ if (req.method !== 'GET' || url.pathname !== '/connect/callback') {
64
+ res.writeHead(404);
65
+ res.end();
66
+ return;
67
+ }
68
+ if (this.used) {
69
+ res.writeHead(410, { 'Content-Type': 'text/html; charset=utf-8' });
70
+ res.end(page('Already approved', 'You can close this tab.'));
71
+ return;
72
+ }
73
+ const code = url.searchParams.get('code');
74
+ if (!matches(this.state, url.searchParams.get('state'))) {
75
+ res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' });
76
+ res.end(page('Approval refused', 'Run the connect command again.'));
77
+ return;
78
+ }
79
+ if (!code || !CODE.test(code)) {
80
+ res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
81
+ res.end(page('Approval incomplete', 'Run the connect command again.'));
82
+ return;
83
+ }
84
+ res.writeHead(200, {
85
+ 'Content-Type': 'text/html; charset=utf-8',
86
+ 'Cache-Control': 'no-store',
87
+ 'Referrer-Policy': 'no-referrer'
88
+ });
89
+ res.end(page('Approved', 'Finishing setup in your terminal. You can close this tab.'));
90
+ this.used = true;
91
+ this.clearTimer();
92
+ this.resolve?.(code);
93
+ this.resolve = null;
94
+ });
95
+ server.on('error', reject);
96
+ // 127.0.0.1 only: never reachable from another machine.
97
+ server.listen(port, '127.0.0.1', () => {
98
+ this.server = server;
99
+ resolve();
100
+ });
101
+ });
102
+ }
103
+ wait(timeoutMs = exports.APPROVAL_TIMEOUT_MS) {
104
+ return new Promise((resolve, reject) => {
105
+ this.resolve = resolve;
106
+ this.timer = setTimeout(() => reject(new Error('Timed out waiting for approval in the browser. Run the command again.')), timeoutMs);
107
+ this.timer.unref?.();
108
+ });
109
+ }
110
+ clearTimer() {
111
+ if (this.timer)
112
+ clearTimeout(this.timer);
113
+ this.timer = null;
114
+ }
115
+ stop() {
116
+ this.clearTimer();
117
+ this.server?.close();
118
+ this.server = null;
119
+ this.resolve = null;
120
+ }
121
+ }
122
+ exports.ApprovalCallback = ApprovalCallback;
@@ -0,0 +1,95 @@
1
+ /**
2
+ * `pf-agent-harness connect <vendor> --connection <id> [--control-plane <url>]`
3
+ *
4
+ * ONE COMMAND, copied from the Welcome window, that:
5
+ * 1. checks the vendor CLI is installed and signed in;
6
+ * 2. asks the signed-in user to approve this computer in the browser
7
+ * (loopback + PKCE + `state`; see workflow-api `byo-connect.ts`);
8
+ * 3. enrols this computer as the user's PERSONAL runner for that vendor only;
9
+ * 4. installs and starts a background service for it, choosing free local
10
+ * ports when 7777/7780 are taken;
11
+ * 5. prints one clear success line.
12
+ *
13
+ * The command carries no secret. The enrolment token exists only in memory
14
+ * here, between the exchange and the registration call, and is never printed,
15
+ * written to disk, or placed in argv. The service starts from the saved
16
+ * runner credential, exactly like `service install` after `start`.
17
+ */
18
+ import { type VendorBackend } from '../vendor-backends';
19
+ export declare const DEFAULT_CONTROL_PLANE = "https://api.peakflo.ai";
20
+ /** Network text shown in a terminal: no control characters, bounded. */
21
+ export declare function printable(text: string): string;
22
+ /** A failure with a message written for the person at the terminal. */
23
+ export declare class ConnectError extends Error {
24
+ }
25
+ export declare const VENDOR_SETUP: Record<VendorBackend, {
26
+ label: string;
27
+ install: string;
28
+ login: string;
29
+ }>;
30
+ export interface ConnectArgs {
31
+ vendor: VendorBackend;
32
+ connection: string;
33
+ controlPlane: string;
34
+ appUrl?: string;
35
+ openBrowser: boolean;
36
+ }
37
+ export declare const CONNECT_USAGE = "Usage: pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]\n\nCopy this command from the Welcome window in Spaces. It approves this computer\nin your browser, connects the agent as your personal agent, and keeps it\nrunning in the background.";
38
+ export declare function parseConnectArgs(argv: string[], env?: NodeJS.ProcessEnv): ConnectArgs;
39
+ /** The machine name the approval page shows. Must match the API's rule. */
40
+ export declare function machineName(raw: string): string;
41
+ export interface VendorCheck {
42
+ installed: boolean;
43
+ usable: boolean;
44
+ /** A check that timed out; the runner keeps checking in the background. */
45
+ transient: boolean;
46
+ reason?: string;
47
+ }
48
+ export interface ServicePorts {
49
+ healthPort: number;
50
+ proxyPort: number;
51
+ }
52
+ export interface ConnectDeps {
53
+ fetchImpl: typeof fetch;
54
+ log: (message: string) => void;
55
+ openBrowser: (url: string) => void;
56
+ hostname: () => string;
57
+ home: string;
58
+ platform: NodeJS.Platform;
59
+ checkVendor: (vendor: VendorBackend) => Promise<VendorCheck>;
60
+ createCallback: () => {
61
+ state: string;
62
+ start(): Promise<number>;
63
+ wait(): Promise<string>;
64
+ stop(): void;
65
+ };
66
+ register: (input: {
67
+ controlPlane: string;
68
+ enrolmentToken: string;
69
+ vendor: VendorBackend;
70
+ workspaceRoot: string;
71
+ }) => Promise<{
72
+ runnerId: string;
73
+ }>;
74
+ /** Start args of an installed service, or null. `undefined` = default one. */
75
+ installedStartArgs: (instance?: VendorBackend) => string[] | null;
76
+ portOccupied: (port: number) => Promise<boolean>;
77
+ /** A copy of the harness outside the npx cache, so the service survives. */
78
+ stableInstall: () => {
79
+ nodePath: string;
80
+ cliPath: string;
81
+ };
82
+ installService: (instance: VendorBackend, startArgs: string[], paths: {
83
+ nodePath: string;
84
+ cliPath: string;
85
+ }) => Promise<void>;
86
+ waitForHealth: (port: number) => Promise<boolean>;
87
+ }
88
+ /** Reuse this vendor's previous ports; otherwise pick free ones. */
89
+ export declare function choosePorts(vendor: VendorBackend, deps: Pick<ConnectDeps, 'installedStartArgs' | 'portOccupied'>): Promise<ServicePorts>;
90
+ export declare function runConnect(args: ConnectArgs, deps: ConnectDeps): Promise<void>;
91
+ export declare function defaultConnectDeps(context: {
92
+ nodePath: string;
93
+ cliPath: string;
94
+ env: NodeJS.ProcessEnv;
95
+ }): ConnectDeps;
@@ -0,0 +1,427 @@
1
+ "use strict";
2
+ /**
3
+ * `pf-agent-harness connect <vendor> --connection <id> [--control-plane <url>]`
4
+ *
5
+ * ONE COMMAND, copied from the Welcome window, that:
6
+ * 1. checks the vendor CLI is installed and signed in;
7
+ * 2. asks the signed-in user to approve this computer in the browser
8
+ * (loopback + PKCE + `state`; see workflow-api `byo-connect.ts`);
9
+ * 3. enrols this computer as the user's PERSONAL runner for that vendor only;
10
+ * 4. installs and starts a background service for it, choosing free local
11
+ * ports when 7777/7780 are taken;
12
+ * 5. prints one clear success line.
13
+ *
14
+ * The command carries no secret. The enrolment token exists only in memory
15
+ * here, between the exchange and the registration call, and is never printed,
16
+ * written to disk, or placed in argv. The service starts from the saved
17
+ * runner credential, exactly like `service install` after `start`.
18
+ */
19
+ Object.defineProperty(exports, "__esModule", { value: true });
20
+ exports.CONNECT_USAGE = exports.VENDOR_SETUP = exports.ConnectError = exports.DEFAULT_CONTROL_PLANE = void 0;
21
+ exports.printable = printable;
22
+ exports.parseConnectArgs = parseConnectArgs;
23
+ exports.machineName = machineName;
24
+ exports.choosePorts = choosePorts;
25
+ exports.runConnect = runConnect;
26
+ exports.defaultConnectDeps = defaultConnectDeps;
27
+ const node_child_process_1 = require("node:child_process");
28
+ const node_crypto_1 = require("node:crypto");
29
+ const node_fs_1 = require("node:fs");
30
+ const node_net_1 = require("node:net");
31
+ const node_os_1 = require("node:os");
32
+ const node_path_1 = require("node:path");
33
+ const approval_callback_1 = require("./approval-callback");
34
+ const capabilities_1 = require("../capabilities");
35
+ const control_plane_1 = require("../control-plane");
36
+ const credential_store_1 = require("../credential-store");
37
+ const browser_enrol_1 = require("../enrol/browser-enrol");
38
+ const harness_discovery_1 = require("../harness-discovery");
39
+ const service_1 = require("../service");
40
+ const vendor_backends_1 = require("../vendor-backends");
41
+ const version_1 = require("../version");
42
+ exports.DEFAULT_CONTROL_PLANE = 'https://api.peakflo.ai';
43
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
44
+ const HEALTH_PORTS = [7777, 7778, 7779, ...range(7790, 7799)];
45
+ const PROXY_PORTS = range(7780, 7789);
46
+ function range(from, to) {
47
+ return Array.from({ length: to - from + 1 }, (_, index) => from + index);
48
+ }
49
+ /** Network text shown in a terminal: no control characters, bounded. */
50
+ function printable(text) {
51
+ // eslint-disable-next-line no-control-regex
52
+ return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, ' ').slice(0, 300);
53
+ }
54
+ /** A failure with a message written for the person at the terminal. */
55
+ class ConnectError extends Error {
56
+ }
57
+ exports.ConnectError = ConnectError;
58
+ exports.VENDOR_SETUP = {
59
+ 'claude-code': {
60
+ label: 'Claude Code',
61
+ install: 'npm install -g @anthropic-ai/claude-code',
62
+ login: 'claude auth login'
63
+ },
64
+ codex: {
65
+ label: 'Codex',
66
+ install: 'npm install -g @openai/codex',
67
+ login: 'codex login'
68
+ },
69
+ cursor: {
70
+ label: 'Cursor',
71
+ install: 'curl https://cursor.com/install -fsS | bash',
72
+ login: 'cursor-agent login'
73
+ }
74
+ };
75
+ exports.CONNECT_USAGE = `Usage: pf-agent-harness connect <codex|claude-code|cursor> --connection <id> [--control-plane <url>]
76
+
77
+ Copy this command from the Welcome window in Spaces. It approves this computer
78
+ in your browser, connects the agent as your personal agent, and keeps it
79
+ running in the background.`;
80
+ function flag(argv, name) {
81
+ const inline = argv.find((arg) => arg.startsWith(`--${name}=`));
82
+ if (inline)
83
+ return inline.slice(name.length + 3);
84
+ const at = argv.indexOf(`--${name}`);
85
+ if (at < 0)
86
+ return undefined;
87
+ const value = argv[at + 1];
88
+ return value && !value.startsWith('--') ? value : undefined;
89
+ }
90
+ function checkUrl(value, name) {
91
+ let url;
92
+ try {
93
+ url = new URL(value);
94
+ }
95
+ catch {
96
+ throw new ConnectError(`The ${name} URL is invalid.`);
97
+ }
98
+ const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname);
99
+ if (url.username ||
100
+ url.password ||
101
+ url.search ||
102
+ url.hash ||
103
+ !(url.protocol === 'https:' || (url.protocol === 'http:' && loopback)))
104
+ throw new ConnectError(`The ${name} URL must use HTTPS (or HTTP on this computer).`);
105
+ return url.origin + url.pathname.replace(/\/+$/, '');
106
+ }
107
+ function parseConnectArgs(argv, env = {}) {
108
+ const known = new Set([
109
+ '--connection',
110
+ '--control-plane',
111
+ '--app-url',
112
+ '--no-browser'
113
+ ]);
114
+ for (let index = 1; index < argv.length; index += 1) {
115
+ const name = argv[index].split('=')[0];
116
+ if (!known.has(name))
117
+ throw new ConnectError(`Unknown option: ${name}\n\n${exports.CONNECT_USAGE}`);
118
+ if (name !== '--no-browser' && !argv[index].includes('='))
119
+ index += 1;
120
+ }
121
+ const vendor = argv[0];
122
+ if (!vendor || !(0, vendor_backends_1.isVendorBackend)(vendor))
123
+ throw new ConnectError(`Choose codex, claude-code, or cursor.\n\n${exports.CONNECT_USAGE}`);
124
+ // `--token` is refused on purpose: this command never takes a secret.
125
+ const connection = flag(argv, 'connection');
126
+ if (!connection || !UUID.test(connection))
127
+ throw new ConnectError(`Copy the command from the Welcome window: it includes --connection <id>.\n\n${exports.CONNECT_USAGE}`);
128
+ const appUrl = flag(argv, 'app-url') ?? env.PF_APP_URL;
129
+ return {
130
+ vendor,
131
+ connection,
132
+ controlPlane: checkUrl(flag(argv, 'control-plane') ??
133
+ env.PF_CONTROL_PLANE_URL ??
134
+ exports.DEFAULT_CONTROL_PLANE, 'control plane'),
135
+ ...(appUrl ? { appUrl: checkUrl(appUrl, 'app') } : {}),
136
+ openBrowser: !argv.includes('--no-browser')
137
+ };
138
+ }
139
+ /** The machine name the approval page shows. Must match the API's rule. */
140
+ function machineName(raw) {
141
+ const cleaned = raw
142
+ .replace(/[^A-Za-z0-9._ '-]/g, '-')
143
+ .replace(/^[^A-Za-z0-9]+/, '')
144
+ .slice(0, 64);
145
+ return cleaned || 'computer';
146
+ }
147
+ function argNumber(args, name) {
148
+ if (!args)
149
+ return null;
150
+ const at = args.indexOf(name);
151
+ const value = at >= 0 ? Number(args[at + 1]) : NaN;
152
+ return Number.isInteger(value) ? value : null;
153
+ }
154
+ /** Reuse this vendor's previous ports; otherwise pick free ones. */
155
+ async function choosePorts(vendor, deps) {
156
+ const prior = deps.installedStartArgs(vendor);
157
+ const priorHealth = argNumber(prior, '--health-port');
158
+ const priorProxy = argNumber(prior, '--github-proxy-port');
159
+ if (prior && priorHealth && priorProxy)
160
+ return { healthPort: priorHealth, proxyPort: priorProxy };
161
+ // Ports other services of ours were given, even if they are stopped now.
162
+ const reserved = new Set();
163
+ for (const other of [undefined, 'codex', 'claude-code', 'cursor']) {
164
+ if (other === vendor)
165
+ continue;
166
+ const args = deps.installedStartArgs(other);
167
+ if (!args)
168
+ continue;
169
+ reserved.add(argNumber(args, '--health-port') ?? 7777);
170
+ reserved.add(argNumber(args, '--github-proxy-port') ?? 7780);
171
+ }
172
+ const pick = async (candidates, what) => {
173
+ for (const port of candidates) {
174
+ if (reserved.has(port))
175
+ continue;
176
+ if (!(await deps.portOccupied(port))) {
177
+ reserved.add(port);
178
+ return port;
179
+ }
180
+ }
181
+ throw new ConnectError(`No free local ${what} port (${candidates[0]}-${candidates.at(-1)}). Close other runners and try again.`);
182
+ };
183
+ return {
184
+ healthPort: priorHealth ?? (await pick(HEALTH_PORTS, 'health')),
185
+ proxyPort: priorProxy ?? (await pick(PROXY_PORTS, 'proxy'))
186
+ };
187
+ }
188
+ async function postJson(fetchImpl, url, body) {
189
+ let response;
190
+ try {
191
+ response = await fetchImpl(url, {
192
+ method: 'POST',
193
+ headers: { 'Content-Type': 'application/json' },
194
+ body: JSON.stringify(body)
195
+ });
196
+ }
197
+ catch {
198
+ throw new ConnectError(`Could not reach ${new URL(url).origin}. Check your internet connection and try again.`);
199
+ }
200
+ const json = (await response.json().catch(() => ({})));
201
+ return { status: response.status, json };
202
+ }
203
+ async function runConnect(args, deps) {
204
+ const { vendor, connection, controlPlane } = args;
205
+ const setup = exports.VENDOR_SETUP[vendor];
206
+ const log = deps.log;
207
+ if (deps.platform !== 'darwin' && deps.platform !== 'linux')
208
+ throw new ConnectError('connect needs macOS or Linux. On Windows, use "Ask my agent to do it" in the Welcome window.');
209
+ const machine = machineName(deps.hostname());
210
+ log(`Connecting ${setup.label} on ${machine}…`);
211
+ /* 1. The vendor CLI must be installed and signed in. */
212
+ const check = await deps.checkVendor(vendor);
213
+ if (!check.installed)
214
+ throw new ConnectError(`${setup.label} is not installed on this computer.\n` +
215
+ ` Install it: ${setup.install}\n` +
216
+ ` Sign in: ${setup.login}\n` +
217
+ 'Then run this command again.');
218
+ if (!check.usable && !check.transient)
219
+ throw new ConnectError(`${setup.label} is installed but not signed in.\n` +
220
+ ` Sign in: ${setup.login}\n` +
221
+ 'Then run this command again.');
222
+ log(check.usable
223
+ ? `✓ ${setup.label} is installed and signed in.`
224
+ : `! ${setup.label} is installed; its sign-in check is slow and will be retried in the background.`);
225
+ /* 2. Browser approval, bound to this process by `state` and PKCE. */
226
+ const verifier = (0, node_crypto_1.randomBytes)(32).toString('base64url');
227
+ const challenge = (0, node_crypto_1.createHash)('sha256')
228
+ .update(verifier, 'utf8')
229
+ .digest('base64url');
230
+ const callback = deps.createCallback();
231
+ let code;
232
+ try {
233
+ const port = await callback.start();
234
+ const appOrigin = args.appUrl
235
+ ? new URL(args.appUrl).origin
236
+ : (0, browser_enrol_1.deriveAppOrigin)(controlPlane);
237
+ const query = new URLSearchParams({
238
+ connection,
239
+ vendor,
240
+ machine,
241
+ port: String(port),
242
+ state: callback.state,
243
+ challenge
244
+ });
245
+ const approvalUrl = `${appOrigin}/connect-computer?${query.toString()}`;
246
+ log('');
247
+ log('Approve this computer in your browser:');
248
+ log(` ${approvalUrl}`);
249
+ log('Waiting for approval… (Ctrl-C to cancel)');
250
+ const waiting = callback.wait();
251
+ // Never leave the timeout rejection unhandled if opening the browser throws.
252
+ waiting.catch(() => undefined);
253
+ if (args.openBrowser)
254
+ deps.openBrowser(approvalUrl);
255
+ code = await waiting;
256
+ }
257
+ finally {
258
+ callback.stop();
259
+ }
260
+ /* 3. Exchange the approval for a one-time enrolment token. */
261
+ const exchange = await postJson(deps.fetchImpl, `${controlPlane}/api/onboarding/byo/connect/${connection}/exchange`, { code, codeVerifier: verifier });
262
+ const enrolmentToken = exchange.json.enrolmentToken;
263
+ if (exchange.status !== 200 ||
264
+ typeof enrolmentToken !== 'string' ||
265
+ exchange.json.vendor !== vendor)
266
+ throw new ConnectError(typeof exchange.json.error === 'string'
267
+ ? printable(exchange.json.error)
268
+ : `The approval could not be used (${exchange.status}). Get a new command from the Welcome window.`);
269
+ const company = typeof exchange.json.companyName === 'string'
270
+ ? exchange.json.companyName
271
+ : 'your company';
272
+ log(`✓ Approved for ${company}.`);
273
+ /* 4. Enrol: a personal runner for this vendor only. */
274
+ const workspaceRoot = (0, node_path_1.join)(deps.home, '.pf-agent-harness', 'connect', vendor, 'workspaces');
275
+ const ports = await choosePorts(vendor, deps);
276
+ await deps.register({ controlPlane, enrolmentToken, vendor, workspaceRoot });
277
+ log('✓ This computer is registered as your personal runner.');
278
+ /* 5. Background service, started from the saved credential. */
279
+ log('Setting up the background service…');
280
+ const paths = deps.stableInstall();
281
+ if (paths.cliPath.includes('/_npx/'))
282
+ log("! The service runs from npm's npx cache. If it stops after a cache cleanup, run this command again.");
283
+ const startArgs = [
284
+ '--control-plane',
285
+ controlPlane,
286
+ ...(args.appUrl ? ['--app-url', args.appUrl] : []),
287
+ '--runtime',
288
+ 'desktop',
289
+ '--vendor-harnesses',
290
+ vendor,
291
+ '--workspace-root',
292
+ workspaceRoot,
293
+ '--health-port',
294
+ String(ports.healthPort),
295
+ '--github-proxy-port',
296
+ String(ports.proxyPort)
297
+ ];
298
+ await deps.installService(vendor, startArgs, paths);
299
+ log('✓ Background service installed. It starts when you log in.');
300
+ const healthy = await deps.waitForHealth(ports.healthPort);
301
+ if (!healthy)
302
+ log(`! The service has not answered yet (it may still be starting, or a local port was taken). Check it with: npx @peakflo/agent-harness service status --vendor ${vendor}`);
303
+ log('');
304
+ log(`✓ ${setup.label} is connected to ${company} on ${machine}. Return to the Welcome window.`);
305
+ }
306
+ /* -------------------------------------------------------------------------- */
307
+ /* Real dependencies */
308
+ /* -------------------------------------------------------------------------- */
309
+ function openInBrowser(url) {
310
+ const command = process.platform === 'darwin' ? 'open' : 'xdg-open';
311
+ try {
312
+ const child = (0, node_child_process_1.spawn)(command, [url], { stdio: 'ignore', detached: true });
313
+ child.on('error', () => undefined);
314
+ child.unref();
315
+ }
316
+ catch {
317
+ /* the printed url is the fallback */
318
+ }
319
+ }
320
+ function portInUse(port) {
321
+ return new Promise((resolve) => {
322
+ const server = (0, node_net_1.createServer)();
323
+ server.once('error', () => resolve(true));
324
+ server.listen(port, '127.0.0.1', () => server.close(() => resolve(false)));
325
+ });
326
+ }
327
+ function defaultConnectDeps(context) {
328
+ const home = (0, node_os_1.homedir)();
329
+ const manager = (instance) => new service_1.ServiceManager({ ...context, instance });
330
+ return {
331
+ fetchImpl: fetch,
332
+ log: (message) => process.stdout.write(`${message}\n`),
333
+ openBrowser: openInBrowser,
334
+ hostname: node_os_1.hostname,
335
+ home,
336
+ platform: process.platform,
337
+ checkVendor: async (vendor) => {
338
+ const [entry] = await (0, harness_discovery_1.discoverHarnesses)({ only: [vendor] });
339
+ return {
340
+ installed: Boolean(entry?.installed),
341
+ usable: Boolean(entry?.usable),
342
+ transient: Boolean(entry?.transient),
343
+ reason: entry?.reason
344
+ };
345
+ },
346
+ createCallback: () => new approval_callback_1.ApprovalCallback(),
347
+ register: async ({ controlPlane, enrolmentToken, vendor, workspaceRoot }) => {
348
+ const capabilities = await (0, capabilities_1.probeCapabilities)({
349
+ maxConcurrentSessions: 10
350
+ });
351
+ capabilities.vendorHarnesses = [vendor];
352
+ const client = new control_plane_1.ControlPlaneClient(controlPlane);
353
+ const response = await client.register({
354
+ enrolmentToken,
355
+ capabilities,
356
+ runtime: 'desktop',
357
+ degraded: false
358
+ });
359
+ const runnerId = client.getRunnerId();
360
+ if (!client.isRegistered() || !runnerId || !response.runnerCredential)
361
+ throw new ConnectError(`The control plane did not accept this computer: ${response.reason ?? 'no runner credential'}.`);
362
+ new credential_store_1.FileCredentialStore(workspaceRoot).save({
363
+ runnerId,
364
+ tenantId: response.tenantId ?? null,
365
+ credential: response.runnerCredential,
366
+ credentialExpiresAt: response.credentialExpiresAt ?? null,
367
+ savedAt: new Date().toISOString()
368
+ });
369
+ return { runnerId };
370
+ },
371
+ installedStartArgs: (instance) => manager(instance).installedStartArgs(),
372
+ portOccupied: portInUse,
373
+ stableInstall: () => {
374
+ const inNpxCache = context.cliPath.includes('/_npx/') ||
375
+ context.cliPath.includes('/.npm/_npx/');
376
+ if (!inNpxCache)
377
+ return { nodePath: context.nodePath, cliPath: context.cliPath };
378
+ // npx may delete its cache at any time; the service must not depend on it.
379
+ const prefix = (0, node_path_1.join)(home, '.pf-agent-harness', 'app');
380
+ const cliPath = (0, node_path_1.join)(prefix, 'node_modules', '@peakflo', 'agent-harness', 'dist', 'cli.js');
381
+ try {
382
+ (0, node_child_process_1.execFileSync)('npm', [
383
+ 'install',
384
+ '--prefix',
385
+ prefix,
386
+ '--no-audit',
387
+ '--no-fund',
388
+ '--omit=dev',
389
+ `@peakflo/agent-harness@${(0, version_1.getHarnessVersion)()}`
390
+ ],
391
+ // A slow network must not look like a hang for ever.
392
+ { stdio: ['ignore', 'ignore', 'pipe'], timeout: 180_000 });
393
+ if ((0, node_fs_1.existsSync)(cliPath))
394
+ return { nodePath: context.nodePath, cliPath };
395
+ }
396
+ catch {
397
+ /* fall back to the npx copy, with service install's warning */
398
+ }
399
+ return { nodePath: context.nodePath, cliPath: context.cliPath };
400
+ },
401
+ installService: async (instance, startArgs, paths) => {
402
+ await new service_1.ServiceManager({
403
+ ...context,
404
+ ...paths,
405
+ instance,
406
+ // service install prints its own lines; connect prints its own summary.
407
+ output: () => undefined
408
+ }).install(startArgs);
409
+ },
410
+ waitForHealth: async (port) => {
411
+ for (let attempt = 0; attempt < 45; attempt += 1) {
412
+ try {
413
+ const response = await fetch(`http://127.0.0.1:${port}/health`, {
414
+ signal: AbortSignal.timeout(2000)
415
+ });
416
+ if (response.ok)
417
+ return true;
418
+ }
419
+ catch {
420
+ /* not up yet */
421
+ }
422
+ await new Promise((resolve) => setTimeout(resolve, 1000));
423
+ }
424
+ return false;
425
+ }
426
+ };
427
+ }
package/dist/service.d.ts CHANGED
@@ -17,7 +17,20 @@ export interface ServiceContext {
17
17
  output?: (message: string) => void;
18
18
  sleep?: (ms: number) => void;
19
19
  portOccupied?: (port: number) => Promise<boolean>;
20
+ /**
21
+ * A per-vendor service from `connect` (`ai.peakflo.agent-harness.<vendor>`),
22
+ * so Claude Code, Codex and Cursor can each run as their own personal
23
+ * runner on one machine. Unset means the original single service.
24
+ */
25
+ instance?: string;
20
26
  }
27
+ /** Service names for an instance. Only vendor names are accepted. */
28
+ export declare function serviceNames(instance?: string): {
29
+ label: string;
30
+ unit: string;
31
+ stateFile: string;
32
+ logDir: string;
33
+ };
21
34
  export declare function permanentServiceExitCode(platform: Platform, linuxCode: 2 | 78): number;
22
35
  /** Only failures that a restart cannot repair belong in this list. */
23
36
  export declare function permanentStartupFailure(error: unknown): string | null;
@@ -25,7 +38,7 @@ export declare function permanentStartupFailure(error: unknown): string | null;
25
38
  export declare function isServiceStart(argv: string[]): boolean;
26
39
  /** Parse only documented start options. A token can never enter service state. */
27
40
  export declare function serviceStartArgs(input: string[], env: NodeJS.ProcessEnv, home: string): string[];
28
- export declare function renderLaunchAgent(config: ServiceConfig, logDir: string): string;
41
+ export declare function renderLaunchAgent(config: ServiceConfig, logDir: string, label?: string): string;
29
42
  export declare function renderSystemdUnit(config: ServiceConfig): string;
30
43
  export declare function recordServiceFailure(reason: string, home?: string): void;
31
44
  export declare function clearServiceFailure(home?: string): void;
@@ -38,12 +51,17 @@ export declare class ServiceManager {
38
51
  private readonly stateFile;
39
52
  private readonly serviceFile;
40
53
  private readonly logDir;
54
+ private readonly label;
55
+ private readonly unit;
41
56
  constructor(context: ServiceContext);
57
+ /** Settings of the installed service, or null when there is none. */
58
+ installedStartArgs(): string[] | null;
42
59
  private supported;
43
60
  private readConfig;
44
61
  private checkCredential;
45
62
  private writeAndStart;
46
63
  install(input: string[]): Promise<void>;
64
+ private vendorFlag;
47
65
  private checkNodeVersion;
48
66
  repair(): void;
49
67
  assertNoDuplicateRunner(options: {
@@ -53,5 +71,5 @@ export declare class ServiceManager {
53
71
  status(): void;
54
72
  uninstall(): void;
55
73
  }
56
- export declare function runServiceCommand(args: string[], context: ServiceContext): Promise<void>;
74
+ export declare function runServiceCommand(input: string[], context: ServiceContext): Promise<void>;
57
75
  export {};
package/dist/service.js CHANGED
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ServiceManager = void 0;
4
+ exports.serviceNames = serviceNames;
4
5
  exports.permanentServiceExitCode = permanentServiceExitCode;
5
6
  exports.permanentStartupFailure = permanentStartupFailure;
6
7
  exports.isServiceStart = isServiceStart;
@@ -37,6 +38,24 @@ const VALUE_FLAGS = new Set([
37
38
  '--vendor-harnesses'
38
39
  ]);
39
40
  const SWITCH_FLAGS = new Set(['--no-harness-discovery']);
41
+ /** Service names for an instance. Only vendor names are accepted. */
42
+ function serviceNames(instance) {
43
+ if (instance === undefined)
44
+ return {
45
+ label: LABEL,
46
+ unit: UNIT,
47
+ stateFile: 'service.json',
48
+ logDir: 'logs'
49
+ };
50
+ if (!(0, vendor_backends_1.isVendorBackend)(instance))
51
+ throw new Error('Use codex, claude-code, or cursor as the service name.');
52
+ return {
53
+ label: `${LABEL}.${instance}`,
54
+ unit: `pf-agent-harness-${instance}.service`,
55
+ stateFile: `service-${instance}.json`,
56
+ logDir: (0, node_path_1.join)('logs', instance)
57
+ };
58
+ }
40
59
  const LAST_ERROR_FILE = 'service-last-error';
41
60
  function permanentServiceExitCode(platform, linuxCode) {
42
61
  return platform === 'darwin' ? 0 : linuxCode;
@@ -176,12 +195,12 @@ function serviceStartArgs(input, env, home) {
176
195
  result.push(flag);
177
196
  return result;
178
197
  }
179
- function renderLaunchAgent(config, logDir) {
198
+ function renderLaunchAgent(config, logDir, label = LABEL) {
180
199
  const args = [config.nodePath, config.cliPath, 'start', ...config.startArgs];
181
200
  const array = args
182
201
  .map((arg) => ` <string>${xml(arg)}</string>`)
183
202
  .join('\n');
184
- return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${LABEL}</string>\n <key>ProgramArguments</key><array>\n${array}\n </array>\n <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n <key>ThrottleInterval</key><integer>30</integer>\n <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(config.path)}</string></dict>\n <key>StandardOutPath</key><string>${xml((0, node_path_1.join)(logDir, 'stdout.log'))}</string>\n <key>StandardErrorPath</key><string>${xml((0, node_path_1.join)(logDir, 'stderr.log'))}</string>\n</dict>\n</plist>\n`;
203
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n<plist version="1.0">\n<dict>\n <key>Label</key><string>${xml(label)}</string>\n <key>ProgramArguments</key><array>\n${array}\n </array>\n <key>RunAtLoad</key><true/>\n <key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>\n <key>ThrottleInterval</key><integer>30</integer>\n <key>EnvironmentVariables</key><dict><key>PATH</key><string>${xml(config.path)}</string></dict>\n <key>StandardOutPath</key><string>${xml((0, node_path_1.join)(logDir, 'stdout.log'))}</string>\n <key>StandardErrorPath</key><string>${xml((0, node_path_1.join)(logDir, 'stderr.log'))}</string>\n</dict>\n</plist>\n`;
185
204
  }
186
205
  function renderSystemdUnit(config) {
187
206
  const command = [
@@ -230,6 +249,8 @@ class ServiceManager {
230
249
  stateFile;
231
250
  serviceFile;
232
251
  logDir;
252
+ label;
253
+ unit;
233
254
  constructor(context) {
234
255
  this.context = context;
235
256
  this.home = context.home ?? (0, node_os_1.homedir)();
@@ -237,12 +258,26 @@ class ServiceManager {
237
258
  this.run = context.run ?? defaultRun;
238
259
  this.output =
239
260
  context.output ?? ((message) => process.stdout.write(`${message}\n`));
240
- this.stateFile = (0, node_path_1.join)(this.home, '.pf-agent-harness', 'service.json');
241
- this.logDir = (0, node_path_1.join)(this.home, '.pf-agent-harness', 'logs');
261
+ const names = serviceNames(context.instance);
262
+ this.label = names.label;
263
+ this.unit = names.unit;
264
+ this.stateFile = (0, node_path_1.join)(this.home, '.pf-agent-harness', names.stateFile);
265
+ this.logDir = (0, node_path_1.join)(this.home, '.pf-agent-harness', names.logDir);
242
266
  this.serviceFile =
243
267
  this.platform === 'darwin'
244
- ? (0, node_path_1.join)(this.home, 'Library', 'LaunchAgents', `${LABEL}.plist`)
245
- : (0, node_path_1.join)(this.home, '.config', 'systemd', 'user', UNIT);
268
+ ? (0, node_path_1.join)(this.home, 'Library', 'LaunchAgents', `${this.label}.plist`)
269
+ : (0, node_path_1.join)(this.home, '.config', 'systemd', 'user', this.unit);
270
+ }
271
+ /** Settings of the installed service, or null when there is none. */
272
+ installedStartArgs() {
273
+ if (!(0, node_fs_1.existsSync)(this.stateFile))
274
+ return null;
275
+ try {
276
+ return this.readConfig().startArgs;
277
+ }
278
+ catch {
279
+ return null;
280
+ }
246
281
  }
247
282
  supported() {
248
283
  if (this.platform !== 'darwin' && this.platform !== 'linux') {
@@ -280,10 +315,10 @@ class ServiceManager {
280
315
  (0, node_fs_1.mkdirSync)(this.logDir, { recursive: true, mode: 0o700 });
281
316
  writePrivate(this.stateFile, `${JSON.stringify(config, null, 2)}\n`);
282
317
  if (this.platform === 'darwin') {
283
- writePrivate(this.serviceFile, renderLaunchAgent(config, this.logDir));
318
+ writePrivate(this.serviceFile, renderLaunchAgent(config, this.logDir, this.label));
284
319
  const domain = `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}`;
285
320
  try {
286
- this.run('launchctl', ['bootout', `${domain}/${LABEL}`]);
321
+ this.run('launchctl', ['bootout', `${domain}/${this.label}`]);
287
322
  }
288
323
  catch {
289
324
  /* absent */
@@ -306,15 +341,15 @@ class ServiceManager {
306
341
  else {
307
342
  writePrivate(this.serviceFile, renderSystemdUnit(config));
308
343
  this.run('systemctl', ['--user', 'daemon-reload']);
309
- this.run('systemctl', ['--user', 'enable', UNIT]);
344
+ this.run('systemctl', ['--user', 'enable', this.unit]);
310
345
  try {
311
- this.run('systemctl', ['--user', 'reset-failed', UNIT]);
346
+ this.run('systemctl', ['--user', 'reset-failed', this.unit]);
312
347
  }
313
348
  catch {
314
349
  // A new unit may have no failed state to clear. Restart gives the
315
350
  // operator the actionable error if systemd cannot start it.
316
351
  }
317
- this.run('systemctl', ['--user', 'restart', UNIT]);
352
+ this.run('systemctl', ['--user', 'restart', this.unit]);
318
353
  }
319
354
  }
320
355
  async install(input) {
@@ -339,12 +374,12 @@ class ServiceManager {
339
374
  const info = this.platform === 'darwin'
340
375
  ? this.run('launchctl', [
341
376
  'print',
342
- `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${LABEL}`
377
+ `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${this.label}`
343
378
  ])
344
379
  : this.run('systemctl', [
345
380
  '--user',
346
381
  'show',
347
- UNIT,
382
+ this.unit,
348
383
  '--property=ActiveState',
349
384
  '--property=MainPID'
350
385
  ]);
@@ -378,10 +413,13 @@ class ServiceManager {
378
413
  if (this.context.nodePath.includes('/.nvm/versions/node/')) {
379
414
  this.output('Warning: this Node.js path can change after an nvm upgrade. Run service repair after upgrades.');
380
415
  }
381
- this.output('After a Node.js or harness upgrade, run: pf-agent-harness service repair');
416
+ this.output(`After a Node.js or harness upgrade, run: pf-agent-harness service repair${this.vendorFlag()}`);
382
417
  if (this.platform === 'linux')
383
418
  this.output('To run after logout, ask your system administrator to run: loginctl enable-linger $USER');
384
419
  }
420
+ vendorFlag() {
421
+ return this.context.instance ? ` --vendor ${this.context.instance}` : '';
422
+ }
385
423
  checkNodeVersion() {
386
424
  const floor = (0, node_floor_1.checkNodeFloor)({
387
425
  current: process.versions.node,
@@ -416,12 +454,12 @@ class ServiceManager {
416
454
  this.platform === 'darwin'
417
455
  ? this.run('launchctl', [
418
456
  'print',
419
- `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${LABEL}`
457
+ `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}/${this.label}`
420
458
  ])
421
459
  : this.run('systemctl', [
422
460
  '--user',
423
461
  'show',
424
- UNIT,
462
+ this.unit,
425
463
  '--property=ActiveState',
426
464
  '--property=MainPID'
427
465
  ]);
@@ -463,7 +501,10 @@ class ServiceManager {
463
501
  try {
464
502
  if (this.platform === 'darwin') {
465
503
  const domain = `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}`;
466
- const info = this.run('launchctl', ['print', `${domain}/${LABEL}`]);
504
+ const info = this.run('launchctl', [
505
+ 'print',
506
+ `${domain}/${this.label}`
507
+ ]);
467
508
  const running = /\bstate = running\b/.test(info);
468
509
  const exit = info.match(/last exit code = (\d+)/)?.[1];
469
510
  this.output(`Status: ${running ? 'running' : 'stopped'}. Last error: ${!running && savedError ? savedError : exit && exit !== '0' ? `exit ${exit}` : 'none'}.`);
@@ -472,7 +513,7 @@ class ServiceManager {
472
513
  const info = this.run('systemctl', [
473
514
  '--user',
474
515
  'show',
475
- UNIT,
516
+ this.unit,
476
517
  '--property=ActiveState',
477
518
  '--property=Result',
478
519
  '--property=ExecMainStatus'
@@ -492,14 +533,14 @@ class ServiceManager {
492
533
  if (this.platform === 'darwin') {
493
534
  const domain = `gui/${this.context.uid ?? process.getuid?.() ?? (0, node_os_1.userInfo)().uid}`;
494
535
  try {
495
- this.run('launchctl', ['bootout', `${domain}/${LABEL}`]);
536
+ this.run('launchctl', ['bootout', `${domain}/${this.label}`]);
496
537
  }
497
538
  catch {
498
539
  /* absent */
499
540
  }
500
541
  }
501
542
  else if ((0, node_fs_1.existsSync)(this.serviceFile)) {
502
- this.run('systemctl', ['--user', 'disable', '--now', UNIT]);
543
+ this.run('systemctl', ['--user', 'disable', '--now', this.unit]);
503
544
  }
504
545
  (0, node_fs_1.rmSync)(this.serviceFile, { force: true });
505
546
  (0, node_fs_1.rmSync)(this.stateFile, { force: true });
@@ -510,12 +551,23 @@ class ServiceManager {
510
551
  }
511
552
  }
512
553
  exports.ServiceManager = ServiceManager;
513
- async function runServiceCommand(args, context) {
514
- const manager = new ServiceManager(context);
554
+ async function runServiceCommand(input, context) {
555
+ // `--vendor <name>` selects a per-vendor service installed by `connect`.
556
+ const args = [...input];
557
+ const at = args.findIndex((arg) => arg === '--vendor' || arg.startsWith('--vendor='));
558
+ let instance = context.instance;
559
+ if (at > 0 && args[0] !== 'install') {
560
+ const raw = args[at];
561
+ instance = raw.includes('=')
562
+ ? raw.slice(raw.indexOf('=') + 1)
563
+ : args[at + 1];
564
+ args.splice(at, raw.includes('=') ? 1 : 2);
565
+ }
566
+ const manager = new ServiceManager({ ...context, instance });
515
567
  switch (args[0]) {
516
568
  case 'help':
517
569
  case '--help':
518
- (context.output ?? ((message) => process.stdout.write(`${message}\n`)))('Use service install [start options], service status, service repair, or service uninstall.');
570
+ (context.output ?? ((message) => process.stdout.write(`${message}\n`)))('Use service install [start options], service status, service repair, or service uninstall. Add --vendor <name> for a service made by connect.');
519
571
  break;
520
572
  case 'install':
521
573
  await manager.install(args.slice(1));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@peakflo/agent-harness",
3
- "version": "0.2.10-rc.3",
3
+ "version": "0.2.11-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",