@prohost/cli 0.6.0 → 0.7.0

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.
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Ships what the agent is doing, tool call by tool call, while it does it.
3
+ *
4
+ * The heartbeat carries one line every sixty seconds — "using Bash" — which is
5
+ * enough to prove a run is alive and nothing like enough to show what it is up
6
+ * to. Newer servers advertise a second path on the run event (`events.path`),
7
+ * which takes the tool-call lifecycle in small batches and renders it in the
8
+ * thread exactly as an in-product AI employee's tool calls are rendered.
9
+ *
10
+ * Three properties this module is written around:
11
+ *
12
+ * * **It never costs the run.** Posting is best-effort bookkeeping: a server
13
+ * that answers `404` (built before the path existed), a network that drops,
14
+ * a batch that is rejected — each costs at most that batch, never the reply
15
+ * and never the completion. Nothing in here throws.
16
+ * * **Batches, not chatter.** Events are collected for about a second and sent
17
+ * together, capped at the server's per-request limit, so a tool-heavy run
18
+ * is a trickle of requests rather than one per tool.
19
+ * * **Nothing is left behind.** {@link ToolEventQueue.flush} is awaited before
20
+ * the reply is posted and before the run is completed, so the timeline is
21
+ * whole by the time the thread reads "done".
22
+ */
23
+ import type { ApiOptions } from './api.js';
24
+ import type { ToolEvent } from './contract.js';
25
+ import type { Scheduler } from './heartbeat.js';
26
+ /**
27
+ * How long events are collected before a batch goes out.
28
+ *
29
+ * Long enough that a `tool_use` and its `tool_result` a few hundred
30
+ * milliseconds later travel together (and land on the same timeline row in one
31
+ * write), short enough that the thread shows a tool within a moment of it
32
+ * starting.
33
+ */
34
+ export declare const TOOL_EVENT_FLUSH_DELAY_MS = 1000;
35
+ /**
36
+ * Cap on events waiting to be sent.
37
+ *
38
+ * A run that fires tools faster than the server accepts them — or against a
39
+ * server that is briefly unreachable — must not grow without bound for hours.
40
+ * The oldest are dropped: the server keeps the newest rows anyway, and an end
41
+ * whose start was dropped still lands as a settled row.
42
+ */
43
+ export declare const MAX_QUEUED_TOOL_EVENTS = 500;
44
+ /** What a run's stream reader hands its tool events to. */
45
+ export interface ToolEventSink {
46
+ /** Queue events for the next batch. Never throws; empty input is a no-op. */
47
+ push(events: ToolEvent[]): void;
48
+ /** Send everything queued, now, and wait for it. Never rejects. */
49
+ flush(): Promise<void>;
50
+ }
51
+ export interface ToolEventQueueOptions {
52
+ api: ApiOptions;
53
+ /**
54
+ * Where the batches go — the run event's `events.path`. `undefined` means
55
+ * the server advertised none (an older server, or a dry run), and every
56
+ * event is dropped on the floor without a request being made.
57
+ */
58
+ path?: string;
59
+ log: (line: string) => void;
60
+ /** Defaults to {@link TOOL_EVENT_FLUSH_DELAY_MS}. */
61
+ delayMs?: number;
62
+ /** Defaults to an unref'd `setTimeout`. Tests drive the cadence by hand. */
63
+ scheduler?: Scheduler;
64
+ /** Prefix for log lines, e.g. `run 01JQ8Z4K2M`. */
65
+ label?: string;
66
+ }
67
+ /**
68
+ * Debounced, batched, fail-soft delivery of one run's tool events.
69
+ *
70
+ * One instance per run. Delivery stops for good — and the queue empties — on
71
+ * the first answer that says the server will never take these events: a `404`
72
+ * (no such endpoint, or a run this credential no longer owns) or a `409` (the
73
+ * run is already closed). A transient failure drops the batch it happened on
74
+ * AND everything queued behind it — one request deadline per flush, never
75
+ * ten — and leaves the queue live for the next push; a run's timeline is
76
+ * worth a retry of nothing.
77
+ */
78
+ export declare class ToolEventQueue implements ToolEventSink {
79
+ private readonly options;
80
+ private queue;
81
+ private pending;
82
+ private inflight;
83
+ private disabled;
84
+ private warned;
85
+ private readonly delayMs;
86
+ private readonly scheduler;
87
+ private readonly label;
88
+ constructor(options: ToolEventQueueOptions);
89
+ /** Events waiting to be sent. Exposed for assertions. */
90
+ queued(): number;
91
+ /** Whether this queue will still make requests. Exposed for assertions. */
92
+ active(): boolean;
93
+ push(events: ToolEvent[]): void;
94
+ flush(): Promise<void>;
95
+ /**
96
+ * Stop for good. Called once the run has been reported, so a straggling
97
+ * timer can't post events for a run the server has already closed.
98
+ */
99
+ stop(): void;
100
+ private arm;
101
+ private drain;
102
+ /** One line per run for transient trouble, not one per batch. */
103
+ private warnOnce;
104
+ }
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Ships what the agent is doing, tool call by tool call, while it does it.
3
+ *
4
+ * The heartbeat carries one line every sixty seconds — "using Bash" — which is
5
+ * enough to prove a run is alive and nothing like enough to show what it is up
6
+ * to. Newer servers advertise a second path on the run event (`events.path`),
7
+ * which takes the tool-call lifecycle in small batches and renders it in the
8
+ * thread exactly as an in-product AI employee's tool calls are rendered.
9
+ *
10
+ * Three properties this module is written around:
11
+ *
12
+ * * **It never costs the run.** Posting is best-effort bookkeeping: a server
13
+ * that answers `404` (built before the path existed), a network that drops,
14
+ * a batch that is rejected — each costs at most that batch, never the reply
15
+ * and never the completion. Nothing in here throws.
16
+ * * **Batches, not chatter.** Events are collected for about a second and sent
17
+ * together, capped at the server's per-request limit, so a tool-heavy run
18
+ * is a trickle of requests rather than one per tool.
19
+ * * **Nothing is left behind.** {@link ToolEventQueue.flush} is awaited before
20
+ * the reply is posted and before the run is completed, so the timeline is
21
+ * whole by the time the thread reads "done".
22
+ */
23
+ import { describeFailure, postToolEvents } from './api.js';
24
+ import { MAX_TOOL_EVENTS_PER_BATCH } from './contract.js';
25
+ /**
26
+ * How long events are collected before a batch goes out.
27
+ *
28
+ * Long enough that a `tool_use` and its `tool_result` a few hundred
29
+ * milliseconds later travel together (and land on the same timeline row in one
30
+ * write), short enough that the thread shows a tool within a moment of it
31
+ * starting.
32
+ */
33
+ export const TOOL_EVENT_FLUSH_DELAY_MS = 1_000;
34
+ /**
35
+ * Cap on events waiting to be sent.
36
+ *
37
+ * A run that fires tools faster than the server accepts them — or against a
38
+ * server that is briefly unreachable — must not grow without bound for hours.
39
+ * The oldest are dropped: the server keeps the newest rows anyway, and an end
40
+ * whose start was dropped still lands as a settled row.
41
+ */
42
+ export const MAX_QUEUED_TOOL_EVENTS = 500;
43
+ const defaultScheduler = (run, ms) => {
44
+ const timer = setTimeout(run, ms);
45
+ // Never the reason a process refuses to exit — same rule as the heartbeat.
46
+ timer.unref?.();
47
+ return { cancel: () => clearTimeout(timer) };
48
+ };
49
+ /**
50
+ * Debounced, batched, fail-soft delivery of one run's tool events.
51
+ *
52
+ * One instance per run. Delivery stops for good — and the queue empties — on
53
+ * the first answer that says the server will never take these events: a `404`
54
+ * (no such endpoint, or a run this credential no longer owns) or a `409` (the
55
+ * run is already closed). A transient failure drops the batch it happened on
56
+ * AND everything queued behind it — one request deadline per flush, never
57
+ * ten — and leaves the queue live for the next push; a run's timeline is
58
+ * worth a retry of nothing.
59
+ */
60
+ export class ToolEventQueue {
61
+ options;
62
+ queue = [];
63
+ pending;
64
+ inflight;
65
+ disabled;
66
+ warned = false;
67
+ delayMs;
68
+ scheduler;
69
+ label;
70
+ constructor(options) {
71
+ this.options = options;
72
+ this.disabled = !options.path;
73
+ this.delayMs = options.delayMs ?? TOOL_EVENT_FLUSH_DELAY_MS;
74
+ this.scheduler = options.scheduler ?? defaultScheduler;
75
+ this.label = options.label ?? 'run';
76
+ }
77
+ /** Events waiting to be sent. Exposed for assertions. */
78
+ queued() {
79
+ return this.queue.length;
80
+ }
81
+ /** Whether this queue will still make requests. Exposed for assertions. */
82
+ active() {
83
+ return !this.disabled;
84
+ }
85
+ push(events) {
86
+ if (this.disabled || events.length === 0)
87
+ return;
88
+ this.queue.push(...events);
89
+ if (this.queue.length > MAX_QUEUED_TOOL_EVENTS) {
90
+ this.queue.splice(0, this.queue.length - MAX_QUEUED_TOOL_EVENTS);
91
+ }
92
+ this.arm();
93
+ }
94
+ async flush() {
95
+ this.pending?.cancel();
96
+ this.pending = undefined;
97
+ // A batch already on the wire finishes first; anything queued behind it
98
+ // goes out in the drain that follows.
99
+ if (this.inflight)
100
+ await this.inflight;
101
+ if (this.disabled || this.queue.length === 0)
102
+ return;
103
+ this.inflight = this.drain().finally(() => {
104
+ this.inflight = undefined;
105
+ });
106
+ await this.inflight;
107
+ }
108
+ /**
109
+ * Stop for good. Called once the run has been reported, so a straggling
110
+ * timer can't post events for a run the server has already closed.
111
+ */
112
+ stop() {
113
+ this.disabled = true;
114
+ this.queue = [];
115
+ this.pending?.cancel();
116
+ this.pending = undefined;
117
+ }
118
+ arm() {
119
+ if (this.disabled || this.pending || this.inflight)
120
+ return;
121
+ this.pending = this.scheduler(() => {
122
+ this.pending = undefined;
123
+ void this.flush();
124
+ }, this.delayMs);
125
+ }
126
+ async drain() {
127
+ const path = this.options.path;
128
+ if (!path)
129
+ return;
130
+ while (this.queue.length > 0 && !this.disabled) {
131
+ const batch = this.queue.splice(0, MAX_TOOL_EVENTS_PER_BATCH);
132
+ let result;
133
+ try {
134
+ result = await postToolEvents(this.options.api, path, batch);
135
+ }
136
+ catch (err) {
137
+ this.warnOnce(`threw: ${err instanceof Error ? err.message : String(err)}`);
138
+ this.queue = [];
139
+ return;
140
+ }
141
+ if (result.ok)
142
+ continue;
143
+ if (result.status === 404) {
144
+ // Said once, then silence — this is every older server's answer.
145
+ this.disabled = true;
146
+ this.queue = [];
147
+ this.options.log(` ◦ ${this.label} tool events cannot be reported (HTTP 404) — this ProhostAI ` +
148
+ 'server has no tool-events endpoint, or no longer knows this run. The thread ' +
149
+ 'will show only the heartbeat line.');
150
+ return;
151
+ }
152
+ if (result.status === 409) {
153
+ // The run is over server-side; nothing left to narrate.
154
+ this.disabled = true;
155
+ this.queue = [];
156
+ return;
157
+ }
158
+ // Transient, by assumption — but not retried, and not pushed through
159
+ // either. The final flush is awaited before the reply goes out, so a
160
+ // dead endpoint with ten batches queued behind it would otherwise hold
161
+ // the finished agent for ten request deadlines. Drop what is queued,
162
+ // keep the queue live for whatever comes next.
163
+ this.warnOnce(`failed (${describeFailure(result)})${result.status !== 0 && result.error ? ` ${result.error}` : ''}`);
164
+ this.queue = [];
165
+ return;
166
+ }
167
+ }
168
+ /** One line per run for transient trouble, not one per batch. */
169
+ warnOnce(detail) {
170
+ if (this.warned)
171
+ return;
172
+ this.warned = true;
173
+ this.options.log(` ! reporting tool events for ${this.label} ${detail} — continuing without them`);
174
+ }
175
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * `prohost agent list` — every paired agent on this machine, and the accounts
3
+ * they can run on.
4
+ *
5
+ * An agent is an agent home (`$PROHOST_HOME`). They are found in three places:
6
+ * the default `~/.prohost`, the `$PROHOST_HOME` of this shell, and every home a
7
+ * `ai.prohost.agent.*` launchd plist pins — which is how agents installed as
8
+ * daemons from other shells are found at all.
9
+ *
10
+ * Read-only, and nothing secret is printed: an agent's name, its account
11
+ * label, and whether its daemon / a harness is running.
12
+ */
13
+ import type { CommandRunner } from './accounts.js';
14
+ export interface AgentHomeInfo {
15
+ home: string;
16
+ /** Agent name from `agent.json`; `undefined` when the home is not paired. */
17
+ name?: string;
18
+ account: string;
19
+ /** From the daemon's `--exec`, when a daemon names one. */
20
+ exec?: string;
21
+ daemon: 'running' | 'installed' | 'not installed';
22
+ /** A live `agent run` holding the home's run lock. */
23
+ runPid?: number;
24
+ }
25
+ /** `PROHOST_HOME` and the `--exec` value from one of our plists. */
26
+ export declare function parsePlist(xml: string): {
27
+ home?: string;
28
+ exec?: string;
29
+ };
30
+ export interface ListOptions {
31
+ env?: NodeJS.ProcessEnv;
32
+ /** `~/Library/LaunchAgents`, overridable for tests. */
33
+ launchAgentsDir?: string;
34
+ /** Returns whether launchd reports the label as running. */
35
+ daemonRunning?: (label: string) => boolean;
36
+ run?: CommandRunner;
37
+ }
38
+ /** Every agent home this machine knows about, de-duplicated by resolved path. */
39
+ export declare function findAgentHomes(options?: ListOptions): AgentHomeInfo[];
40
+ /** Render `agent list`. */
41
+ export declare function renderAgentList(options?: ListOptions): Promise<string[]>;
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `prohost agent list` — every paired agent on this machine, and the accounts
3
+ * they can run on.
4
+ *
5
+ * An agent is an agent home (`$PROHOST_HOME`). They are found in three places:
6
+ * the default `~/.prohost`, the `$PROHOST_HOME` of this shell, and every home a
7
+ * `ai.prohost.agent.*` launchd plist pins — which is how agents installed as
8
+ * daemons from other shells are found at all.
9
+ *
10
+ * Read-only, and nothing secret is printed: an agent's name, its account
11
+ * label, and whether its daemon / a harness is running.
12
+ */
13
+ import { spawnSync } from 'node:child_process';
14
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ import { DEFAULT_ACCOUNT_LABEL, checkAuth, loadAccounts, runtimeName } from './accounts.js';
18
+ import { tryLoadCredentials } from './credentials.js';
19
+ import { LAUNCHD_LABEL_PREFIX, launchdLabel } from './daemon.js';
20
+ import { pidAlive, readRunLock } from './lock.js';
21
+ function xmlUnescape(value) {
22
+ return value
23
+ .replace(/&lt;/g, '<')
24
+ .replace(/&gt;/g, '>')
25
+ .replace(/&quot;/g, '"')
26
+ .replace(/&apos;/g, "'")
27
+ .replace(/&amp;/g, '&');
28
+ }
29
+ /** `PROHOST_HOME` and the `--exec` value from one of our plists. */
30
+ export function parsePlist(xml) {
31
+ const home = /<key>PROHOST_HOME<\/key>\s*<string>([^<]*)<\/string>/.exec(xml)?.[1];
32
+ const args = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? '';
33
+ const words = [...args.matchAll(/<string>([^<]*)<\/string>/g)].map((m) => xmlUnescape(m[1] ?? ''));
34
+ const execIndex = words.indexOf('--exec');
35
+ return {
36
+ home: home === undefined ? undefined : xmlUnescape(home),
37
+ exec: execIndex >= 0 ? words[execIndex + 1] : undefined,
38
+ };
39
+ }
40
+ function defaultDaemonRunning(label) {
41
+ if (os.platform() !== 'darwin')
42
+ return false;
43
+ const uid = process.getuid?.() ?? 0;
44
+ const result = spawnSync('launchctl', ['print', `gui/${uid}/${label}`], { encoding: 'utf8', timeout: 5_000 });
45
+ return result.status === 0 && /^\s*state = running$/m.test(result.stdout ?? '');
46
+ }
47
+ /** Every agent home this machine knows about, de-duplicated by resolved path. */
48
+ export function findAgentHomes(options = {}) {
49
+ const env = options.env ?? process.env;
50
+ const userHome = env.HOME || os.homedir();
51
+ const plistDir = options.launchAgentsDir ?? path.join(userHome, 'Library', 'LaunchAgents');
52
+ const daemonRunning = options.daemonRunning ?? defaultDaemonRunning;
53
+ const homes = new Map();
54
+ const add = (home, extra = {}) => {
55
+ const key = path.resolve(home);
56
+ const known = homes.get(key);
57
+ homes.set(key, { exec: extra.exec ?? known?.exec, plist: Boolean(extra.plist || known?.plist) });
58
+ };
59
+ add(path.join(userHome, '.prohost'));
60
+ if (env.PROHOST_HOME)
61
+ add(env.PROHOST_HOME);
62
+ let plists = [];
63
+ try {
64
+ plists = readdirSync(plistDir).filter((f) => f.startsWith(`${LAUNCHD_LABEL_PREFIX}.`) && f.endsWith('.plist'));
65
+ }
66
+ catch {
67
+ plists = [];
68
+ }
69
+ for (const file of plists) {
70
+ try {
71
+ const parsed = parsePlist(readFileSync(path.join(plistDir, file), 'utf8'));
72
+ if (parsed.home)
73
+ add(parsed.home, { exec: parsed.exec, plist: true });
74
+ }
75
+ catch {
76
+ /* unreadable plist — skip */
77
+ }
78
+ }
79
+ const found = [];
80
+ for (const [home, meta] of homes) {
81
+ const credentials = tryLoadCredentials({ ...env, PROHOST_HOME: home });
82
+ // The default home is listed only when something is actually there.
83
+ if (!credentials && !meta.plist && !existsSync(home))
84
+ continue;
85
+ const label = launchdLabel(home);
86
+ const lock = readRunLock({ ...env, PROHOST_HOME: home });
87
+ found.push({
88
+ home,
89
+ name: credentials?.agent?.name,
90
+ account: credentials?.account ?? DEFAULT_ACCOUNT_LABEL,
91
+ exec: meta.exec,
92
+ daemon: meta.plist ? (daemonRunning(label) ? 'running' : 'installed') : 'not installed',
93
+ runPid: lock && pidAlive(lock.pid) ? lock.pid : undefined,
94
+ });
95
+ }
96
+ return found;
97
+ }
98
+ function authText(check) {
99
+ return check.state === 'ok' ? 'signed in' : check.state === 'expired' ? 'signed out' : 'unknown';
100
+ }
101
+ /** Render `agent list`. */
102
+ export async function renderAgentList(options = {}) {
103
+ const env = options.env ?? process.env;
104
+ const lines = [];
105
+ const agents = findAgentHomes(options);
106
+ lines.push('Agents');
107
+ if (agents.length === 0)
108
+ lines.push(' (none paired on this machine — prohost agent pair --code <code>)');
109
+ for (const agent of agents) {
110
+ const running = agent.runPid ? `running (pid ${agent.runPid})` : `daemon ${agent.daemon}`;
111
+ lines.push(` ${agent.name ?? '(not paired)'} · account ${agent.account} · ${running}`);
112
+ lines.push(` home ${agent.home}${agent.exec ? ` · exec ${agent.exec}` : ''}`);
113
+ }
114
+ const accounts = loadAccounts(env);
115
+ lines.push('', 'Accounts');
116
+ if (accounts.length === 0) {
117
+ lines.push(' (only the default logins — prohost agent account add <label> --runtime claude|codex)');
118
+ }
119
+ const checks = await Promise.all(accounts.map((a) => checkAuth(a, { run: options.run })));
120
+ accounts.forEach((account, i) => {
121
+ const users = agents.filter((a) => a.account === account.label).map((a) => a.name ?? a.home);
122
+ lines.push(` ${account.label} · ${runtimeName(account.runtime)} · ${account.billing} · ${authText(checks[i])}` +
123
+ (users.length > 0 ? ` · used by ${users.join(', ')}` : ''));
124
+ });
125
+ return lines;
126
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * One `agent run` per agent home.
3
+ *
4
+ * Two harnesses on the same `$PROHOST_HOME` share one credential, one run
5
+ * ledger and one session store, and both receive every run — so both execute
6
+ * it. That happens easily: a daemon is installed and the operator also starts
7
+ * `agent run` in a terminal to watch it. `$PROHOST_HOME/run.lock` makes the
8
+ * second one refuse to start, with the pid it lost to.
9
+ *
10
+ * Created with `O_EXCL`, holding the owner's pid and start time. A lock whose
11
+ * pid is no longer alive was left by a crash (or `kill -9`) and is taken over.
12
+ */
13
+ export interface LockHolder {
14
+ pid: number;
15
+ started_at: string;
16
+ }
17
+ export type LockResult = {
18
+ ok: true;
19
+ release: () => void;
20
+ } | {
21
+ ok: false;
22
+ holder: LockHolder;
23
+ };
24
+ export declare function runLockPath(env?: NodeJS.ProcessEnv): string;
25
+ /** Whether a pid names a live process. EPERM means it exists but isn't ours. */
26
+ export declare function pidAlive(pid: number): boolean;
27
+ /** The current holder, if the lock file exists and is readable. */
28
+ export declare function readRunLock(env?: NodeJS.ProcessEnv): LockHolder | undefined;
29
+ /**
30
+ * Take the home's run lock, or report who holds it.
31
+ *
32
+ * @param isAlive Liveness check seam for tests.
33
+ */
34
+ export declare function acquireRunLock(env?: NodeJS.ProcessEnv, options?: {
35
+ pid?: number;
36
+ isAlive?: (pid: number) => boolean;
37
+ }): LockResult;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * One `agent run` per agent home.
3
+ *
4
+ * Two harnesses on the same `$PROHOST_HOME` share one credential, one run
5
+ * ledger and one session store, and both receive every run — so both execute
6
+ * it. That happens easily: a daemon is installed and the operator also starts
7
+ * `agent run` in a terminal to watch it. `$PROHOST_HOME/run.lock` makes the
8
+ * second one refuse to start, with the pid it lost to.
9
+ *
10
+ * Created with `O_EXCL`, holding the owner's pid and start time. A lock whose
11
+ * pid is no longer alive was left by a crash (or `kill -9`) and is taken over.
12
+ */
13
+ import { openSync, closeSync, readFileSync, rmSync, writeSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import { prohostHome } from './credentials.js';
16
+ export function runLockPath(env = process.env) {
17
+ return path.join(prohostHome(env), 'run.lock');
18
+ }
19
+ /** Whether a pid names a live process. EPERM means it exists but isn't ours. */
20
+ export function pidAlive(pid) {
21
+ if (!Number.isInteger(pid) || pid <= 0)
22
+ return false;
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch (err) {
28
+ return err.code === 'EPERM';
29
+ }
30
+ }
31
+ /** The current holder, if the lock file exists and is readable. */
32
+ export function readRunLock(env = process.env) {
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(runLockPath(env), 'utf8'));
35
+ if (typeof parsed.pid !== 'number')
36
+ return undefined;
37
+ return { pid: parsed.pid, started_at: String(parsed.started_at ?? '') };
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
43
+ /**
44
+ * Take the home's run lock, or report who holds it.
45
+ *
46
+ * @param isAlive Liveness check seam for tests.
47
+ */
48
+ export function acquireRunLock(env = process.env, options = {}) {
49
+ const file = runLockPath(env);
50
+ const pid = options.pid ?? process.pid;
51
+ const isAlive = options.isAlive ?? pidAlive;
52
+ // Two tries: the second only after removing a lock whose owner is dead.
53
+ for (let attempt = 0; attempt < 2; attempt += 1) {
54
+ let fd;
55
+ try {
56
+ fd = openSync(file, 'wx', 0o600);
57
+ }
58
+ catch (err) {
59
+ if (err.code !== 'EEXIST')
60
+ throw err;
61
+ const holder = readRunLock(env);
62
+ if (holder && holder.pid !== pid && isAlive(holder.pid))
63
+ return { ok: false, holder };
64
+ // Stale (dead owner, or unreadable half-write): clear it and retry once.
65
+ rmSync(file, { force: true });
66
+ continue;
67
+ }
68
+ const holder = { pid, started_at: new Date().toISOString() };
69
+ try {
70
+ writeSync(fd, `${JSON.stringify(holder)}\n`);
71
+ }
72
+ finally {
73
+ closeSync(fd);
74
+ }
75
+ let released = false;
76
+ return {
77
+ ok: true,
78
+ release: () => {
79
+ if (released)
80
+ return;
81
+ released = true;
82
+ // Only remove it if it is still ours — a takeover after a false
83
+ // "dead" reading must not be undone by the original owner exiting.
84
+ if (readRunLock(env)?.pid === pid)
85
+ rmSync(file, { force: true });
86
+ },
87
+ };
88
+ }
89
+ const holder = readRunLock(env);
90
+ return { ok: false, holder: holder ?? { pid: 0, started_at: '' } };
91
+ }
@@ -24,6 +24,7 @@
24
24
  */
25
25
  import type { spawn } from 'node:child_process';
26
26
  import WebSocket from 'ws';
27
+ import type { CommandRunner } from './accounts.js';
27
28
  import type { AgentCredentials } from './credentials.js';
28
29
  import type { QuotaProvider } from './runtime_status.js';
29
30
  /**
@@ -110,7 +111,7 @@ export interface AgentRunOptions {
110
111
  * The second argument is the exact cwd the eventual Codex process receives;
111
112
  * Codex discovers project-local config from it.
112
113
  */
113
- probeImpl?: (command: string, cwd?: string) => {
114
+ probeImpl?: (command: string, cwd?: string, env?: NodeJS.ProcessEnv) => {
114
115
  status: number | null;
115
116
  stdout: string;
116
117
  };
@@ -138,6 +139,11 @@ export interface AgentRunOptions {
138
139
  * Tests inject a fake so no real `claude` / `codex` is spawned.
139
140
  */
140
141
  quotaProvider?: QuotaProvider | null;
142
+ /**
143
+ * Runs the agent CLI's sign-in check for each account (`claude auth status`,
144
+ * `codex login status`). Tests inject a fake so no real CLI is asked.
145
+ */
146
+ accountCommandRunner?: CommandRunner;
141
147
  }
142
148
  export interface AgentRunMetrics {
143
149
  connections: number;
@@ -181,7 +187,7 @@ interface PreparedMcp {
181
187
  * extras file is the operator's live grant list. Fresh reads make both a new
182
188
  * configured server and a revoked extra visible to the very next run.
183
189
  */
184
- export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (line: string) => void, workdir?: string): () => PreparedMcp;
190
+ export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (line: string) => void, workdir?: string, accountEnv?: () => NodeJS.ProcessEnv): () => PreparedMcp;
185
191
  /**
186
192
  * Run the harness until the abort signal fires (or ``maxConnections`` is hit).
187
193
  *