@prohost/cli 0.6.0 → 0.8.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.
@@ -14,6 +14,7 @@
14
14
  * - payload shape — `shared/events/payloads/agent_runs.py`
15
15
  * - `POST /v1/agent-runs/{id}/complete` — `api_public/routers/agent_runs.py`
16
16
  * - `POST /v1/agent-runs/{id}/heartbeat` — `api_public/routers/agent_runs.py`
17
+ * - `POST /v1/agent-runs/{id}/events` — `api_public/routers/agent_runs.py`
17
18
  * - `POST /v1/agent-pairing/redeem` — `api_public/routers/agent_pairing.py`
18
19
  * - `POST /v1/conversations/{id}/messages` — `api_public/routers/conversations.py`
19
20
  */
@@ -27,6 +28,29 @@ export const EVENT_MESSAGE_TEAM_CHAT = 'message.team_chat';
27
28
  /** Terminal statuses accepted by the run-completion endpoint. */
28
29
  export const RUN_STATUS_SUCCEEDED = 'succeeded';
29
30
  export const RUN_STATUS_FAILED = 'failed';
31
+ /**
32
+ * Completion `outcome` for a run that deliberately posted nothing.
33
+ *
34
+ * Sent with `status: succeeded`: declining is a finished run, not a failure.
35
+ * The server records it the way an in-product `[NO_REPLY]` run is recorded and
36
+ * clears the thinking indicator as a "chose silence" rather than implying a
37
+ * reply landed. A server that predates the field ignores it, so it needs no
38
+ * version negotiation.
39
+ */
40
+ export const RUN_OUTCOME_SILENT = 'silent';
41
+ /**
42
+ * What the agent writes as its WHOLE answer to say "no reply".
43
+ *
44
+ * The same token the in-product brain uses, so an agent's instructions read the
45
+ * same on either runtime. Matched on the trimmed output, case-insensitively —
46
+ * and never posted: the literal token landing in a thread is the one outcome
47
+ * that is worse than either replying or not.
48
+ */
49
+ export const NO_REPLY_TOKEN = '[NO_REPLY]';
50
+ /** Whether the agent's output is the no-reply token and nothing else. */
51
+ export function isNoReply(output) {
52
+ return output.trim().toUpperCase() === NO_REPLY_TOKEN;
53
+ }
30
54
  /** Server-side cap on the `error` field of a completion (`MAX_ERROR_CHARS`). */
31
55
  export const MAX_ERROR_CHARS = 1000;
32
56
  /** Server-side cap on the `model` field of a completion (`MAX_MODEL_CHARS`). */
@@ -48,6 +72,13 @@ export const RUN_ERROR_CANCELLED_BY_USER = 'cancelled_by_user';
48
72
  * enormous block of text can't turn a keep-alive into a payload.
49
73
  */
50
74
  export const MAX_PROGRESS_CHARS = 200;
75
+ /**
76
+ * Server-side cap on events per `POST /v1/agent-runs/{id}/events` call
77
+ * (`shared.agents.external_tool_events.MAX_EVENTS_PER_REQUEST`). The run event
78
+ * also carries it as `events.max_batch`; this is the value for a payload that
79
+ * omits it, and the ceiling either way.
80
+ */
81
+ export const MAX_TOOL_EVENTS_PER_BATCH = 50;
51
82
  /** `POST` — exchange a one-time pairing code for a scoped credential. */
52
83
  export const REDEEM_PATH = '/v1/agent-pairing/redeem';
53
84
  /** `POST` — send a message into a conversation as the paired agent. */
@@ -117,6 +148,8 @@ export const MAX_ATTACHMENTS = 10;
117
148
  * quietly stop half-way through with nothing to explain it.
118
149
  */
119
150
  export const MAX_SYSTEM_PROMPT_CHARS = 20_000;
151
+ /** Ceiling on `trigger.brief` — the server's `MAX_TRIGGER_BRIEF_CHARS`. */
152
+ export const MAX_TRIGGER_BRIEF_CHARS = 32_000;
120
153
  /**
121
154
  * Fallback for a server that predates `reply_instructions.body_field`.
122
155
  *
@@ -221,6 +254,16 @@ function systemPrompt(value) {
221
254
  return undefined;
222
255
  return text.length > MAX_SYSTEM_PROMPT_CHARS ? text.slice(0, MAX_SYSTEM_PROMPT_CHARS) : text;
223
256
  }
257
+ /**
258
+ * Bound the server's work order. Same backstop as {@link systemPrompt}, at the
259
+ * server's own ceiling for the field (`MAX_TRIGGER_BRIEF_CHARS`).
260
+ */
261
+ function brief(value) {
262
+ const text = str(value);
263
+ if (!text)
264
+ return undefined;
265
+ return text.length > MAX_TRIGGER_BRIEF_CHARS ? text.slice(0, MAX_TRIGGER_BRIEF_CHARS) : text;
266
+ }
224
267
  function parseMessage(entry) {
225
268
  const attachments = attachmentList(entry.attachments);
226
269
  // Not `str()`: an image-only message carries an empty string here and is a
@@ -265,6 +308,7 @@ export function parseRunRequest(data) {
265
308
  const replyInstructions = obj(data.reply_instructions);
266
309
  const completion = obj(data.completion);
267
310
  const heartbeat = obj(data.heartbeat);
311
+ const events = obj(data.events);
268
312
  const context = obj(data.context);
269
313
  const declaredSurface = str(replyInstructions.surface);
270
314
  const conversationId = str(data.conversation_id) ?? str(replyInstructions.conversation_id);
@@ -295,6 +339,11 @@ export function parseRunRequest(data) {
295
339
  trigger_text: str(trigger.text_excerpt),
296
340
  trigger_attachments: attachmentList(trigger.attachments ?? data.trigger_attachments),
297
341
  trigger_user_id: str(trigger.user_id),
342
+ trigger_brief: brief(trigger.brief),
343
+ // Only an explicit `false` opts out. Absent, null, or anything malformed
344
+ // keeps today's behaviour — an agent that stays quiet when it was asked
345
+ // something is a worse failure than one that answers an aside.
346
+ reply_expected: data.reply_expected !== false,
298
347
  trigger_type: str(trigger.type),
299
348
  trigger_entity_id: str(trigger.entity_id),
300
349
  reply_surface: surface,
@@ -310,6 +359,8 @@ export function parseRunRequest(data) {
310
359
  // No fallback of its own: an absent block is not an error, it is an older
311
360
  // server, and `heartbeatPathFor` already knows what to do about that.
312
361
  heartbeat_path: str(heartbeat.path),
362
+ // Same shape, same reasoning, and no fallback at all — see the field.
363
+ events_path: str(events.path),
313
364
  // Accepted both at the top level and nested under `context`, because the
314
365
  // server can move them without a CLI release either way — and a published
315
366
  // CLI cannot be redeployed to follow.
@@ -38,6 +38,11 @@ export interface AgentCredentials {
38
38
  events: string[];
39
39
  agent: StoredAgent;
40
40
  paired_at: string;
41
+ /**
42
+ * Label of the brain account (Claude Code / Codex login) this agent runs on
43
+ * — see `accounts.ts`. Absent means the CLI's own default login.
44
+ */
45
+ account?: string;
41
46
  }
42
47
  /**
43
48
  * On-disk credential schema version.
@@ -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;