@prohost/cli 0.15.0 → 0.16.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +265 -1
  3. package/dist/agent/accounts.d.ts +11 -0
  4. package/dist/agent/accounts.js +12 -1
  5. package/dist/agent/api.d.ts +6 -0
  6. package/dist/agent/api.js +5 -0
  7. package/dist/agent/claude.d.ts +6 -0
  8. package/dist/agent/claude.js +2 -2
  9. package/dist/agent/daemon.d.ts +33 -0
  10. package/dist/agent/daemon.js +56 -0
  11. package/dist/agent/prompt.d.ts +12 -0
  12. package/dist/agent/prompt.js +5 -0
  13. package/dist/agent/run.d.ts +110 -1
  14. package/dist/agent/run.js +186 -19
  15. package/dist/codehost/host.d.ts +25 -0
  16. package/dist/codehost/host.js +16 -1
  17. package/dist/codehost/host_stats.d.ts +49 -2
  18. package/dist/codehost/host_stats.js +153 -2
  19. package/dist/codehost/relay.d.ts +10 -0
  20. package/dist/codehost/relay.js +17 -1
  21. package/dist/host/api.d.ts +82 -0
  22. package/dist/host/api.js +122 -0
  23. package/dist/host/capacity.d.ts +41 -0
  24. package/dist/host/capacity.js +48 -0
  25. package/dist/host/command.d.ts +59 -0
  26. package/dist/host/command.js +584 -0
  27. package/dist/host/computer.d.ts +120 -0
  28. package/dist/host/computer.js +291 -0
  29. package/dist/host/events.d.ts +24 -0
  30. package/dist/host/events.js +47 -0
  31. package/dist/host/install.d.ts +152 -0
  32. package/dist/host/install.js +755 -0
  33. package/dist/host/ledger.d.ts +52 -0
  34. package/dist/host/ledger.js +131 -0
  35. package/dist/host/legacy.d.ts +38 -0
  36. package/dist/host/legacy.js +115 -0
  37. package/dist/host/logins.d.ts +109 -0
  38. package/dist/host/logins.js +190 -0
  39. package/dist/host/migrate.d.ts +165 -0
  40. package/dist/host/migrate.js +364 -0
  41. package/dist/host/pair.d.ts +62 -0
  42. package/dist/host/pair.js +219 -0
  43. package/dist/host/peak_memory.d.ts +44 -0
  44. package/dist/host/peak_memory.js +139 -0
  45. package/dist/host/scheduler.d.ts +86 -0
  46. package/dist/host/scheduler.js +186 -0
  47. package/dist/host/service.d.ts +114 -0
  48. package/dist/host/service.js +1027 -0
  49. package/dist/host/session_move.d.ts +48 -0
  50. package/dist/host/session_move.js +106 -0
  51. package/dist/host/skills.d.ts +29 -0
  52. package/dist/host/skills.js +135 -0
  53. package/dist/host/status.d.ts +91 -0
  54. package/dist/host/status.js +130 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.js +47 -3
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. package/package.json +1 -1
@@ -6,11 +6,15 @@
6
6
  * number comes from a stock tool or Node's `os` module; anything that can't be
7
7
  * read is `null`, never 0. macOS sources: `vm_stat` (memory in use),
8
8
  * `sysctl kern.memorystatus_vm_pressure_level` (pressure), `pmset -g batt`
9
- * (battery), `sysctl machdep.cpu.brand_string` (spec). Elsewhere only the
10
- * `os`-module numbers are reported.
9
+ * (battery), `sysctl machdep.cpu.brand_string` (spec). Linux: `/proc/meminfo`
10
+ * (memory in use), `/proc/pressure/memory` (pressure, PSI), and
11
+ * `/sys/class/power_supply` (battery). Windows: `typeperf` performance counters
12
+ * (memory in use, commit charge → pressure) and PowerShell's `Win32_Battery`.
11
13
  */
12
14
  import { execFile } from 'node:child_process';
15
+ import { readdirSync, readFileSync } from 'node:fs';
13
16
  import os from 'node:os';
17
+ import path from 'node:path';
14
18
  const GB = 1024 ** 3;
15
19
  const round1 = (value) => Math.round(value * 10) / 10;
16
20
  /** Bytes in use per `vm_stat`: active + wired + compressed (what Activity Monitor calls used). */
@@ -49,6 +53,96 @@ export function parsePmset(text) {
49
53
  onBattery: source === undefined ? null : source === 'Battery Power',
50
54
  };
51
55
  }
56
+ /**
57
+ * Linux PSI (`/proc/pressure/memory`): the share of the last 10 s some / all
58
+ * runnable tasks stalled on memory. `full` stalls mean the machine is
59
+ * thrashing; sustained `some` stalls mean reclaim is getting in the way.
60
+ */
61
+ export function pressureFromPsi(text) {
62
+ const avg10 = (kind) => {
63
+ const match = new RegExp(`^${kind}\\s.*?avg10=([0-9.]+)`, 'm').exec(text);
64
+ return match ? Number.parseFloat(match[1]) : undefined;
65
+ };
66
+ const some = avg10('some');
67
+ const full = avg10('full');
68
+ if (some === undefined && full === undefined)
69
+ return null;
70
+ if ((full ?? 0) >= 10 || (some ?? 0) >= 50)
71
+ return 'critical';
72
+ if ((full ?? 0) >= 2 || (some ?? 0) >= 10)
73
+ return 'elevated';
74
+ return 'normal';
75
+ }
76
+ /** Bytes in use per `/proc/meminfo`: MemTotal − MemAvailable (what `free` calls used). */
77
+ export function usedFromMeminfo(text) {
78
+ const kb = (label) => {
79
+ const match = new RegExp(`^${label}:\\s+(\\d+) kB`, 'm').exec(text);
80
+ return match ? Number.parseInt(match[1], 10) * 1024 : undefined;
81
+ };
82
+ const total = kb('MemTotal');
83
+ const available = kb('MemAvailable');
84
+ return total !== undefined && available !== undefined ? Math.max(0, total - available) : undefined;
85
+ }
86
+ /** `/sys/class/power_supply/BAT*`: its `capacity` and `status`, and whether any AC supply is online. */
87
+ export function linuxBattery(supplies) {
88
+ const battery = supplies.find((s) => (s.type ?? '').trim() === 'Battery' || /^BAT/i.test(s.name));
89
+ if (!battery)
90
+ return { batteryPct: null, onBattery: null };
91
+ const pct = Number.parseInt((battery.capacity ?? '').trim(), 10);
92
+ const acOnline = supplies.some((s) => (s.type ?? '').trim() === 'Mains' && (s.online ?? '').trim() === '1');
93
+ const status = (battery.status ?? '').trim();
94
+ return {
95
+ batteryPct: Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : null,
96
+ onBattery: status ? status === 'Discharging' && !acOnline : null,
97
+ };
98
+ }
99
+ /**
100
+ * One `typeperf -sc 1` sample of `\Memory\Available MBytes` and
101
+ * `\Memory\% Committed Bytes In Use`: the last CSV row, quoted values.
102
+ */
103
+ export function parseTypeperf(text) {
104
+ const rows = text
105
+ .split(/\r?\n/)
106
+ .map((line) => line.trim())
107
+ .filter((line) => line.startsWith('"'));
108
+ if (rows.length < 2)
109
+ return {};
110
+ const cells = (row) => row.split('","').map((cell) => cell.replace(/^"|"$/g, ''));
111
+ const header = cells(rows[0]);
112
+ const values = cells(rows[rows.length - 1]);
113
+ const read = (needle) => {
114
+ const index = header.findIndex((name) => name.toLowerCase().includes(needle));
115
+ const value = index >= 0 ? Number.parseFloat(values[index] ?? '') : Number.NaN;
116
+ return Number.isFinite(value) ? value : undefined;
117
+ };
118
+ return { availableMb: read('available mbytes'), committedPct: read('% committed bytes in use') };
119
+ }
120
+ /**
121
+ * Windows has no single pressure level, so it is read from what the memory
122
+ * manager is short of: physical memory left, and the commit charge against
123
+ * its limit (past which allocations fail outright).
124
+ */
125
+ export function windowsPressure(sample, totalBytes) {
126
+ const { availableMb, committedPct } = sample;
127
+ if (availableMb === undefined && committedPct === undefined)
128
+ return null;
129
+ const availablePct = availableMb !== undefined && totalBytes ? (availableMb * 1024 * 1024 * 100) / totalBytes : undefined;
130
+ if ((availablePct !== undefined && availablePct < 5) || (committedPct ?? 0) >= 95)
131
+ return 'critical';
132
+ if ((availablePct !== undefined && availablePct < 12) || (committedPct ?? 0) >= 85)
133
+ return 'elevated';
134
+ return 'normal';
135
+ }
136
+ /** PowerShell `Win32_Battery` as `<BatteryStatus>,<EstimatedChargeRemaining>`; 1 = discharging. */
137
+ export function parseWindowsBattery(text) {
138
+ const match = /(\d+)\s*,\s*(\d+)/.exec(text.trim());
139
+ if (!match)
140
+ return { batteryPct: null, onBattery: null };
141
+ return {
142
+ batteryPct: Math.min(100, Number.parseInt(match[2], 10)),
143
+ onBattery: Number.parseInt(match[1], 10) === 1,
144
+ };
145
+ }
52
146
  /** e.g. `Apple M3 Max · 64 GB`. */
53
147
  export function specFrom(brand, totalBytes) {
54
148
  const parts = [brand?.trim(), totalBytes ? `${Math.round(totalBytes / GB)} GB` : undefined].filter(Boolean);
@@ -76,6 +170,25 @@ const defaultRun = (file, args) => new Promise((resolve) => {
76
170
  resolve(error ? undefined : String(stdout));
77
171
  });
78
172
  });
173
+ const defaultReadFile = (file) => {
174
+ try {
175
+ return readFileSync(file, 'utf8');
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ };
181
+ const defaultListDir = (dir) => {
182
+ try {
183
+ return readdirSync(dir);
184
+ }
185
+ catch {
186
+ return [];
187
+ }
188
+ };
189
+ const POWER_SUPPLY_DIR = '/sys/class/power_supply';
190
+ const TYPEPERF_COUNTERS = ['\\Memory\\Available MBytes', '\\Memory\\% Committed Bytes In Use'];
191
+ const WINDOWS_BATTERY_SCRIPT = 'Get-CimInstance Win32_Battery | Select-Object -First 1 | ForEach-Object { "$($_.BatteryStatus),$($_.EstimatedChargeRemaining)" }';
79
192
  /** Samples on demand. Keeps the previous CPU sample so each call reports the interval since the last. */
80
193
  export class HostStatsSampler {
81
194
  options;
@@ -135,6 +248,44 @@ export class HostStatsSampler {
135
248
  this.spec = specFrom(brand || undefined, total);
136
249
  }
137
250
  }
251
+ else if (this.platform === 'linux') {
252
+ const read = this.options.readFile ?? defaultReadFile;
253
+ const meminfo = read('/proc/meminfo');
254
+ used = meminfo ? usedFromMeminfo(meminfo) : undefined;
255
+ if (used === undefined) {
256
+ const free = safe(this.freemem);
257
+ used = total !== undefined && free !== undefined ? total - free : undefined;
258
+ }
259
+ const psi = read('/proc/pressure/memory');
260
+ memoryPressure = psi ? pressureFromPsi(psi) : null;
261
+ const list = this.options.listDir ?? defaultListDir;
262
+ battery = linuxBattery(list(POWER_SUPPLY_DIR).map((name) => {
263
+ const field = (key) => read(path.join(POWER_SUPPLY_DIR, name, key));
264
+ return { name, type: field('type'), capacity: field('capacity'), status: field('status'), online: field('online') };
265
+ }));
266
+ if (this.spec === undefined)
267
+ this.spec = specFrom(safe(() => os.cpus()[0]?.model), total);
268
+ }
269
+ else if (this.platform === 'win32') {
270
+ const [counters, power] = await Promise.all([
271
+ this.run('typeperf', [...TYPEPERF_COUNTERS, '-sc', '1']),
272
+ this.run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_BATTERY_SCRIPT]),
273
+ ]);
274
+ const sample = counters ? parseTypeperf(counters) : {};
275
+ used =
276
+ sample.availableMb !== undefined && total !== undefined
277
+ ? Math.max(0, total - sample.availableMb * 1024 * 1024)
278
+ : undefined;
279
+ if (used === undefined) {
280
+ const free = safe(this.freemem);
281
+ used = total !== undefined && free !== undefined ? total - free : undefined;
282
+ }
283
+ memoryPressure = windowsPressure(sample, total);
284
+ if (power)
285
+ battery = parseWindowsBattery(power);
286
+ if (this.spec === undefined)
287
+ this.spec = specFrom(safe(() => os.cpus()[0]?.model), total);
288
+ }
138
289
  else {
139
290
  const free = safe(this.freemem);
140
291
  used = total !== undefined && free !== undefined ? total - free : undefined;
@@ -91,6 +91,14 @@ export declare function parseViewerVerdict(frame: Record<string, unknown>): View
91
91
  export interface CodeHostRelayOptions {
92
92
  baseUrl: string;
93
93
  apiKey: string;
94
+ /**
95
+ * A second credential for a relay that refuses `apiKey`: `prohost host`
96
+ * authenticates as the computer, and falls back to the adopted agent
97
+ * pairing's key on a server that doesn't take computer keys here yet.
98
+ */
99
+ fallbackApiKey?: string;
100
+ /** No new viewer dials in while this says so (the computer is paused); open streams carry on. */
101
+ dialsPaused?: () => boolean;
94
102
  machineId: string;
95
103
  /**
96
104
  * This home's CLI instance (see `codeHostInstanceId`): its presence and control
@@ -135,6 +143,8 @@ export declare class CodeHostRelay {
135
143
  /** Bumped by {@link dropStreams}: a dial begun before a drop never opens. */
136
144
  private generation;
137
145
  private refused;
146
+ /** Authenticating with {@link CodeHostRelayOptions.fallbackApiKey} after `apiKey` was refused. */
147
+ private usingFallback;
138
148
  private readonly log;
139
149
  private readonly Ctor;
140
150
  private readonly setTimer;
@@ -256,6 +256,8 @@ export class CodeHostRelay {
256
256
  /** Bumped by {@link dropStreams}: a dial begun before a drop never opens. */
257
257
  generation = 0;
258
258
  refused;
259
+ /** Authenticating with {@link CodeHostRelayOptions.fallbackApiKey} after `apiKey` was refused. */
260
+ usingFallback = false;
259
261
  log;
260
262
  Ctor;
261
263
  setTimer;
@@ -416,7 +418,7 @@ export class CodeHostRelay {
416
418
  try {
417
419
  ws.send(JSON.stringify({
418
420
  type: 'auth',
419
- api_key: this.options.apiKey,
421
+ api_key: this.usingFallback ? this.options.fallbackApiKey : this.options.apiKey,
420
422
  machine_id: this.options.machineId,
421
423
  ...(this.options.instanceId ? { instance_id: this.options.instanceId } : {}),
422
424
  machine: this.options.machine,
@@ -481,6 +483,15 @@ export class CodeHostRelay {
481
483
  }
482
484
  case 'auth_error': {
483
485
  const reason = String(frame.reason ?? 'unknown');
486
+ // Only "a valid key, but not a pairing this server knows" — a server from
487
+ // before computer keys. A revoked or invalid key never falls back.
488
+ if (!this.usingFallback && this.options.fallbackApiKey && reason === 'not_paired') {
489
+ // This server doesn't take the first key here: try the second at once.
490
+ this.usingFallback = true;
491
+ this.log(` … code-host relay refused the computer key (${reason}); using the adopted agent key`);
492
+ ws.close();
493
+ return;
494
+ }
484
495
  if (this.refused !== reason)
485
496
  this.log(` ✗ code-host relay refused this machine: ${reason}`);
486
497
  this.refused = reason;
@@ -533,6 +544,11 @@ export class CodeHostRelay {
533
544
  this.dialError(dial.streamId, 'remote_terminal_disabled');
534
545
  return;
535
546
  }
547
+ if (this.options.dialsPaused?.()) {
548
+ // The relay's own word for "not now" (it knows no other).
549
+ this.dialError(dial.streamId, 'remote_terminal_disabled');
550
+ return;
551
+ }
536
552
  const url = relayUrl(this.options.baseUrl, STREAM_PATH);
537
553
  if (!url)
538
554
  return;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * The public API `prohost host` speaks: `/v1/computers/*`.
3
+ *
4
+ * Pairing is unauthenticated (the device code or redeem token is the proof);
5
+ * everything else carries the computer key as `X-API-Key`, the header every
6
+ * public-API call from this CLI uses. A run's own token replaces it for that
7
+ * run's heartbeat, events and completion (see `service.ts`).
8
+ */
9
+ export interface ComputerApiOptions {
10
+ baseUrl: string;
11
+ apiKey?: string;
12
+ fetchImpl?: typeof fetch;
13
+ timeoutMs?: number;
14
+ }
15
+ export interface JsonResponse {
16
+ /** HTTP status, or `0` when no answer arrived. */
17
+ status: number;
18
+ /** The parsed body, when it was a JSON object. */
19
+ body?: Record<string, unknown>;
20
+ /** Why no answer arrived, or a short excerpt of a refusal. */
21
+ error?: string;
22
+ }
23
+ export declare function callJson(options: ComputerApiOptions, path: string, body: unknown, method?: 'POST' | 'GET' | 'DELETE'): Promise<JsonResponse>;
24
+ export declare const PAIRING_START_PATH = "/v1/computers/pairing/start";
25
+ export declare const PAIRING_POLL_PATH = "/v1/computers/pairing/poll";
26
+ export declare const PAIRING_REDEEM_PATH = "/v1/computers/pairing/redeem";
27
+ export declare const PAIRING_ADOPT_PATH = "/v1/computers/pairing/adopt";
28
+ export declare function computerPath(computerId: string): string;
29
+ export type RevokeResult = 'revoked' | 'already_gone' | 'failed';
30
+ /**
31
+ * Remove this computer from its workspace, as the computer (`DELETE
32
+ * /v1/computers/{id}`): its key stops working. A key the server no longer
33
+ * knows (401) or a computer already removed (404 / 410) is already done.
34
+ */
35
+ export declare function revokeComputer(options: ComputerApiOptions, computerId: string): Promise<{
36
+ result: RevokeResult;
37
+ status: number;
38
+ error?: string;
39
+ }>;
40
+ export declare function statusPath(computerId: string): string;
41
+ export declare function eventsPath(computerId: string): string;
42
+ export declare function claimPath(computerId: string, runId: string): string;
43
+ /** One skill file the claim hands over, written into the run's workspace. */
44
+ export interface SkillFile {
45
+ path: string;
46
+ content: string;
47
+ }
48
+ export interface ClaimedSkill {
49
+ key: string;
50
+ version?: string | number;
51
+ files: SkillFile[];
52
+ }
53
+ export type ClaimResult = {
54
+ kind: 'claimed';
55
+ runToken: string;
56
+ expiresAt?: string;
57
+ payload: Record<string, unknown>;
58
+ skills: ClaimedSkill[];
59
+ }
60
+ /** Another computer has it (409): skip it, never run it. */
61
+ | {
62
+ kind: 'elsewhere';
63
+ }
64
+ /** Gone server-side (404 / 410): nothing to run. */
65
+ | {
66
+ kind: 'gone';
67
+ status: number;
68
+ }
69
+ /** No answer, or one worth asking again (429 / 5xx). */
70
+ | {
71
+ kind: 'retry';
72
+ status: number;
73
+ error?: string;
74
+ }
75
+ /** Any other refusal. */
76
+ | {
77
+ kind: 'refused';
78
+ status: number;
79
+ error?: string;
80
+ };
81
+ /** Claim a run for this computer. Exactly one computer ever gets a 200. */
82
+ export declare function claimRun(options: ComputerApiOptions, computerId: string, runId: string): Promise<ClaimResult>;
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The public API `prohost host` speaks: `/v1/computers/*`.
3
+ *
4
+ * Pairing is unauthenticated (the device code or redeem token is the proof);
5
+ * everything else carries the computer key as `X-API-Key`, the header every
6
+ * public-API call from this CLI uses. A run's own token replaces it for that
7
+ * run's heartbeat, events and completion (see `service.ts`).
8
+ */
9
+ import { USER_AGENT } from '../version.js';
10
+ const DEFAULT_TIMEOUT_MS = 30_000;
11
+ export async function callJson(options, path, body, method = 'POST') {
12
+ const fetchImpl = options.fetchImpl ?? fetch;
13
+ const controller = new AbortController();
14
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
15
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
16
+ try {
17
+ const response = await fetchImpl(`${options.baseUrl.replace(/\/+$/, '')}${path}`, {
18
+ method,
19
+ redirect: 'error',
20
+ headers: {
21
+ 'Content-Type': 'application/json',
22
+ 'User-Agent': USER_AGENT,
23
+ ...(options.apiKey ? { 'X-API-Key': options.apiKey } : {}),
24
+ },
25
+ ...(method === 'POST' ? { body: JSON.stringify(body ?? {}) } : {}),
26
+ signal: controller.signal,
27
+ });
28
+ const text = await response.text().catch(() => '');
29
+ let parsed;
30
+ try {
31
+ const value = text ? JSON.parse(text) : undefined;
32
+ if (value && typeof value === 'object' && !Array.isArray(value))
33
+ parsed = value;
34
+ }
35
+ catch {
36
+ parsed = undefined;
37
+ }
38
+ const ok = response.status >= 200 && response.status < 300;
39
+ return { status: response.status, body: parsed, ...(ok ? {} : { error: text.slice(0, 300) }) };
40
+ }
41
+ catch (err) {
42
+ if (controller.signal.aborted)
43
+ return { status: 0, error: `timed out after ${Math.round(timeoutMs / 1000)}s` };
44
+ return { status: 0, error: err instanceof Error ? err.message : String(err) };
45
+ }
46
+ finally {
47
+ clearTimeout(timer);
48
+ }
49
+ }
50
+ export const PAIRING_START_PATH = '/v1/computers/pairing/start';
51
+ export const PAIRING_POLL_PATH = '/v1/computers/pairing/poll';
52
+ export const PAIRING_REDEEM_PATH = '/v1/computers/pairing/redeem';
53
+ export const PAIRING_ADOPT_PATH = '/v1/computers/pairing/adopt';
54
+ export function computerPath(computerId) {
55
+ return `/v1/computers/${encodeURIComponent(computerId)}`;
56
+ }
57
+ /**
58
+ * Remove this computer from its workspace, as the computer (`DELETE
59
+ * /v1/computers/{id}`): its key stops working. A key the server no longer
60
+ * knows (401) or a computer already removed (404 / 410) is already done.
61
+ */
62
+ export async function revokeComputer(options, computerId) {
63
+ const response = await callJson(options, computerPath(computerId), undefined, 'DELETE');
64
+ if (response.status >= 200 && response.status < 300)
65
+ return { result: 'revoked', status: response.status };
66
+ if ([401, 404, 410].includes(response.status))
67
+ return { result: 'already_gone', status: response.status };
68
+ return { result: 'failed', status: response.status, ...(response.error ? { error: response.error } : {}) };
69
+ }
70
+ export function statusPath(computerId) {
71
+ return `/v1/computers/${encodeURIComponent(computerId)}/status`;
72
+ }
73
+ export function eventsPath(computerId) {
74
+ return `/v1/computers/${encodeURIComponent(computerId)}/events`;
75
+ }
76
+ export function claimPath(computerId, runId) {
77
+ return `/v1/computers/${encodeURIComponent(computerId)}/runs/${encodeURIComponent(runId)}/claim`;
78
+ }
79
+ function parseSkills(value) {
80
+ if (!Array.isArray(value))
81
+ return [];
82
+ return value.flatMap((raw) => {
83
+ const skill = raw;
84
+ if (!skill || typeof skill.key !== 'string' || !Array.isArray(skill.files))
85
+ return [];
86
+ const files = skill.files.flatMap((f) => {
87
+ const file = f;
88
+ return file && typeof file.path === 'string' && typeof file.content === 'string'
89
+ ? [{ path: file.path, content: file.content }]
90
+ : [];
91
+ });
92
+ const version = typeof skill.version === 'string' || typeof skill.version === 'number' ? skill.version : undefined;
93
+ return [{ key: skill.key, ...(version !== undefined ? { version } : {}), files }];
94
+ });
95
+ }
96
+ /** Claim a run for this computer. Exactly one computer ever gets a 200. */
97
+ export async function claimRun(options, computerId, runId) {
98
+ const response = await callJson(options, claimPath(computerId, runId), {});
99
+ if (response.status === 200 || response.status === 201) {
100
+ const body = response.body ?? {};
101
+ const token = body.run_token;
102
+ const payload = body.payload;
103
+ if (typeof token !== 'string' || !token || !payload || typeof payload !== 'object' || Array.isArray(payload)) {
104
+ return { kind: 'refused', status: response.status, error: 'claim answer carried no run token or payload' };
105
+ }
106
+ return {
107
+ kind: 'claimed',
108
+ runToken: token,
109
+ ...(typeof body.expires_at === 'string' ? { expiresAt: body.expires_at } : {}),
110
+ payload: payload,
111
+ skills: parseSkills(body.skills),
112
+ };
113
+ }
114
+ if (response.status === 409)
115
+ return { kind: 'elsewhere' };
116
+ if (response.status === 404 || response.status === 410)
117
+ return { kind: 'gone', status: response.status };
118
+ if (response.status === 0 || response.status === 429 || response.status >= 500) {
119
+ return { kind: 'retry', status: response.status, error: response.error };
120
+ }
121
+ return { kind: 'refused', status: response.status, error: response.error };
122
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * How many things this computer works on at once, and when it takes no new work.
3
+ */
4
+ import type { MemoryPressure } from '../codehost/host_stats.js';
5
+ export type Workload = 'coding' | 'chat';
6
+ /** Memory one run takes, by what the employees here mostly do. */
7
+ export declare const RUN_MEMORY_GB: Record<Workload, number>;
8
+ /** Never recommend more than this, however big the machine. */
9
+ export declare const MAX_RECOMMENDED_CAPACITY = 16;
10
+ /** Memory kept for the person using the computer: the larger of these. */
11
+ export declare const RESERVE_MIN_GB = 8;
12
+ export declare const RESERVE_SHARE = 0.25;
13
+ /**
14
+ * The recommended capacity: memory after the reserve, divided by what one run
15
+ * takes; at most half the cores (a run is a CLI plus the tools it starts); at
16
+ * least 1, at most {@link MAX_RECOMMENDED_CAPACITY}.
17
+ */
18
+ export declare function recommendCapacity(hardware: {
19
+ memoryGb: number;
20
+ cores: number;
21
+ workload?: Workload;
22
+ }): number;
23
+ /** What the server's `capacity` (null = use recommended) and the local recommendation come to. */
24
+ export declare function effectiveCapacity(configured: number | null | undefined, recommended: number): number;
25
+ export type HeldReason = 'paused' | 'pressure' | 'battery';
26
+ export interface HoldInputs {
27
+ paused: boolean;
28
+ holdOnPressure: boolean;
29
+ holdOnBattery: boolean;
30
+ memoryPressure: MemoryPressure | null;
31
+ onBattery: boolean | null;
32
+ }
33
+ /**
34
+ * Why new work waits, or `null`. Work already running is never stopped by a
35
+ * hold; it only keeps the next run from starting.
36
+ *
37
+ * Pressure holds only at `critical`: `elevated` is routine on a busy Mac
38
+ * (the compressor is working), and holding on it would idle a computer that is
39
+ * coping. A battery hold needs the OS to say it is on battery — unknown is not.
40
+ */
41
+ export declare function holdReason(inputs: HoldInputs): HeldReason | null;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * How many things this computer works on at once, and when it takes no new work.
3
+ */
4
+ /** Memory one run takes, by what the employees here mostly do. */
5
+ export const RUN_MEMORY_GB = { coding: 4, chat: 1.5 };
6
+ /** Never recommend more than this, however big the machine. */
7
+ export const MAX_RECOMMENDED_CAPACITY = 16;
8
+ /** Memory kept for the person using the computer: the larger of these. */
9
+ export const RESERVE_MIN_GB = 8;
10
+ export const RESERVE_SHARE = 0.25;
11
+ /**
12
+ * The recommended capacity: memory after the reserve, divided by what one run
13
+ * takes; at most half the cores (a run is a CLI plus the tools it starts); at
14
+ * least 1, at most {@link MAX_RECOMMENDED_CAPACITY}.
15
+ */
16
+ export function recommendCapacity(hardware) {
17
+ const memory = Number.isFinite(hardware.memoryGb) ? Math.max(0, hardware.memoryGb) : 0;
18
+ const cores = Number.isFinite(hardware.cores) ? Math.max(1, Math.floor(hardware.cores)) : 1;
19
+ const perRun = RUN_MEMORY_GB[hardware.workload ?? 'chat'];
20
+ const reserve = Math.max(RESERVE_MIN_GB, memory * RESERVE_SHARE);
21
+ const byMemory = Math.floor((memory - reserve) / perRun);
22
+ const byCores = Math.floor(cores / 2);
23
+ return Math.max(1, Math.min(MAX_RECOMMENDED_CAPACITY, byMemory, byCores));
24
+ }
25
+ /** What the server's `capacity` (null = use recommended) and the local recommendation come to. */
26
+ export function effectiveCapacity(configured, recommended) {
27
+ if (typeof configured === 'number' && Number.isInteger(configured) && configured >= 1) {
28
+ return Math.min(configured, 64);
29
+ }
30
+ return recommended;
31
+ }
32
+ /**
33
+ * Why new work waits, or `null`. Work already running is never stopped by a
34
+ * hold; it only keeps the next run from starting.
35
+ *
36
+ * Pressure holds only at `critical`: `elevated` is routine on a busy Mac
37
+ * (the compressor is working), and holding on it would idle a computer that is
38
+ * coping. A battery hold needs the OS to say it is on battery — unknown is not.
39
+ */
40
+ export function holdReason(inputs) {
41
+ if (inputs.paused)
42
+ return 'paused';
43
+ if (inputs.holdOnPressure && inputs.memoryPressure === 'critical')
44
+ return 'pressure';
45
+ if (inputs.holdOnBattery && inputs.onBattery === true)
46
+ return 'battery';
47
+ return null;
48
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `prohost host …` — one background service per computer, serving every AI
3
+ * employee placed on it, plus the terminal sessions opened on it.
4
+ *
5
+ * prohost host pair [--name <n>] [--token <redeem_token>|-] [--expect-account <id>]
6
+ * prohost host unpair
7
+ * prohost host run
8
+ * prohost host install [--label <id>] [--dedicated --service-user <name> [--service-home <dir>]
9
+ * [--claude-token-file <path>]] | uninstall [--label <id>]
10
+ * prohost host status [--json] [--label <id>] | logins [--json] | logins add --runtime claude_code|codex
11
+ * prohost host pause | resume
12
+ * prohost host migrate [--from <home>]… [--home <dir,…>] [--keep-services] [--dry-run] [--json]
13
+ */
14
+ import type { InstallIo } from './install.js';
15
+ import type { LoginInfo } from './logins.js';
16
+ import { runMigrate } from './migrate.js';
17
+ type Flags = Record<string, string | boolean>;
18
+ export interface HostIo {
19
+ env?: NodeJS.ProcessEnv;
20
+ out?: (line: string) => void;
21
+ err?: (line: string) => void;
22
+ stdin?: AsyncIterable<string | Buffer>;
23
+ now?: () => number;
24
+ fetchImpl?: typeof fetch;
25
+ detectLogins?: () => Promise<LoginInfo[]>;
26
+ platform?: NodeJS.Platform;
27
+ /** Seams for `install` / `uninstall` (service manager calls, files). */
28
+ serviceIo?: InstallIo;
29
+ /** Seams for `migrate`. */
30
+ migrate?: Partial<Parameters<typeof runMigrate>[0]>;
31
+ /** Positionals after the subcommand (`logins add`). */
32
+ args?: string[];
33
+ /** Every value of a repeated flag (`--from a --from b`). */
34
+ repeated?: Record<string, string[]>;
35
+ /** Is this an interactive terminal (asks before adding a computer to a workspace)? */
36
+ interactive?: boolean;
37
+ /** Answers a yes/no question on the terminal. */
38
+ ask?: (question: string) => Promise<boolean>;
39
+ /** Runs a sign-in in this terminal (`logins add`); resolves its exit code. */
40
+ signIn?: (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise<number | null>;
41
+ /** Whether the CLI runs as Electron-as-Node (the desktop app's bundled CLI). */
42
+ electron?: boolean;
43
+ /** Where dedicated services publish their state (tests). */
44
+ sharedBase?: string;
45
+ }
46
+ export declare function hardwareFacts(): Record<string, unknown>;
47
+ /**
48
+ * `host status --json`: what the desktop app and scripts read. Always an
49
+ * answer — `{paired: false, …}` before pairing. When this home runs no
50
+ * service but a dedicated one runs on the computer, that one is reported
51
+ * (`service.dedicated`), from the state it publishes for the logged-in user.
52
+ */
53
+ export declare function statusJson(env: NodeJS.ProcessEnv, now?: number, options?: {
54
+ sharedBase?: string;
55
+ label?: string;
56
+ platform?: NodeJS.Platform;
57
+ }): Record<string, unknown>;
58
+ export declare function runHost(subcommand: string | undefined, flags: Flags, printHelp: () => void, io?: HostIo): Promise<number>;
59
+ export {};