@prohost/cli 0.15.0 → 0.16.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/CHANGELOG.md +86 -0
- package/README.md +266 -1
- package/dist/agent/accounts.d.ts +11 -0
- package/dist/agent/accounts.js +12 -1
- package/dist/agent/api.d.ts +12 -0
- package/dist/agent/api.js +5 -0
- package/dist/agent/claude.d.ts +6 -0
- package/dist/agent/claude.js +2 -2
- package/dist/agent/daemon.d.ts +33 -0
- package/dist/agent/daemon.js +56 -0
- package/dist/agent/prompt.d.ts +12 -0
- package/dist/agent/prompt.js +5 -0
- package/dist/agent/run.d.ts +116 -1
- package/dist/agent/run.js +217 -29
- package/dist/codehost/host.d.ts +25 -0
- package/dist/codehost/host.js +16 -1
- package/dist/codehost/host_stats.d.ts +49 -2
- package/dist/codehost/host_stats.js +153 -2
- package/dist/codehost/relay.d.ts +10 -0
- package/dist/codehost/relay.js +17 -1
- package/dist/host/api.d.ts +82 -0
- package/dist/host/api.js +122 -0
- package/dist/host/capacity.d.ts +41 -0
- package/dist/host/capacity.js +48 -0
- package/dist/host/command.d.ts +59 -0
- package/dist/host/command.js +597 -0
- package/dist/host/computer.d.ts +120 -0
- package/dist/host/computer.js +291 -0
- package/dist/host/events.d.ts +24 -0
- package/dist/host/events.js +47 -0
- package/dist/host/install.d.ts +154 -0
- package/dist/host/install.js +784 -0
- package/dist/host/ledger.d.ts +52 -0
- package/dist/host/ledger.js +131 -0
- package/dist/host/legacy.d.ts +38 -0
- package/dist/host/legacy.js +115 -0
- package/dist/host/logins.d.ts +109 -0
- package/dist/host/logins.js +190 -0
- package/dist/host/migrate.d.ts +165 -0
- package/dist/host/migrate.js +364 -0
- package/dist/host/pair.d.ts +62 -0
- package/dist/host/pair.js +219 -0
- package/dist/host/peak_memory.d.ts +44 -0
- package/dist/host/peak_memory.js +139 -0
- package/dist/host/scheduler.d.ts +86 -0
- package/dist/host/scheduler.js +186 -0
- package/dist/host/service.d.ts +114 -0
- package/dist/host/service.js +1027 -0
- package/dist/host/session_move.d.ts +48 -0
- package/dist/host/session_move.js +106 -0
- package/dist/host/skills.d.ts +29 -0
- package/dist/host/skills.js +135 -0
- package/dist/host/status.d.ts +91 -0
- package/dist/host/status.js +130 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +47 -3
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/codehost/host.js
CHANGED
|
@@ -24,7 +24,7 @@ import { LocalTerminalServer } from './local_server.js';
|
|
|
24
24
|
import { loadNodePty } from './pty.js';
|
|
25
25
|
import { CodeHostRelay } from './relay.js';
|
|
26
26
|
import { codeHostDir, codeHostInstanceId, ensurePrivateDir, loadSettings } from './settings.js';
|
|
27
|
-
import { CodeTerminals, execTmux } from './terminals.js';
|
|
27
|
+
import { CodeTerminals, execTmux, isDefinitive } from './terminals.js';
|
|
28
28
|
import { APPROVAL_DECISIONS, isValidId, socketNameFor, TMUX_CONF } from './tmux.js';
|
|
29
29
|
export const EVENT_CODE_THREAD_START_REQUESTED = 'code_thread.start_requested';
|
|
30
30
|
export const EVENT_CODE_THREAD_INPUT_REQUESTED = 'code_thread.input_requested';
|
|
@@ -204,6 +204,11 @@ export class CodeHost {
|
|
|
204
204
|
remoteTerminalAllowed() {
|
|
205
205
|
return this.settings().allowRemoteTerminal;
|
|
206
206
|
}
|
|
207
|
+
liveTerminals() {
|
|
208
|
+
return this.terminals
|
|
209
|
+
.list()
|
|
210
|
+
.map((record) => ({ terminalId: record.terminalId, codeThreadId: record.codeThreadId, agent: record.agent }));
|
|
211
|
+
}
|
|
207
212
|
instanceCapability() {
|
|
208
213
|
return `${HOST_INSTANCE_CAPABILITY_PREFIX}${this.instanceId}`;
|
|
209
214
|
}
|
|
@@ -243,6 +248,8 @@ export class CodeHost {
|
|
|
243
248
|
this.relay = new CodeHostRelay({
|
|
244
249
|
baseUrl: this.options.baseUrl,
|
|
245
250
|
apiKey: this.options.apiKey,
|
|
251
|
+
...(this.options.fallbackApiKey ? { fallbackApiKey: this.options.fallbackApiKey } : {}),
|
|
252
|
+
...(this.options.paused ? { dialsPaused: this.options.paused } : {}),
|
|
246
253
|
machineId: this.options.machineId,
|
|
247
254
|
instanceId: this.instanceId,
|
|
248
255
|
machine: this.options.machine,
|
|
@@ -527,6 +534,14 @@ export class CodeHost {
|
|
|
527
534
|
* Throws {@link UnreportedOutcomeError} when the outcome never reached the
|
|
528
535
|
* server, so the harness asks for the delivery again rather than ack it.
|
|
529
536
|
*/
|
|
537
|
+
async failStart(data, reason) {
|
|
538
|
+
const request = parseStartRequest(data);
|
|
539
|
+
if (!request)
|
|
540
|
+
return true;
|
|
541
|
+
const status = await this.events.post(request.codeThreadId, event('failed', { reason }, Date.now()));
|
|
542
|
+
// Retryable answers (401 while the token renews, 408, 429, 5xx) leave the delivery to be retried.
|
|
543
|
+
return isDefinitive(status);
|
|
544
|
+
}
|
|
530
545
|
async handleStart(data) {
|
|
531
546
|
const request = parseStartRequest(data);
|
|
532
547
|
if (!request)
|
|
@@ -6,8 +6,10 @@
|
|
|
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).
|
|
10
|
-
* `
|
|
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 os from 'node:os';
|
|
13
15
|
export type MemoryPressure = 'normal' | 'elevated' | 'critical';
|
|
@@ -31,6 +33,47 @@ export declare function parsePmset(text: string): {
|
|
|
31
33
|
batteryPct: number | null;
|
|
32
34
|
onBattery: boolean | null;
|
|
33
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* Linux PSI (`/proc/pressure/memory`): the share of the last 10 s some / all
|
|
38
|
+
* runnable tasks stalled on memory. `full` stalls mean the machine is
|
|
39
|
+
* thrashing; sustained `some` stalls mean reclaim is getting in the way.
|
|
40
|
+
*/
|
|
41
|
+
export declare function pressureFromPsi(text: string): MemoryPressure | null;
|
|
42
|
+
/** Bytes in use per `/proc/meminfo`: MemTotal − MemAvailable (what `free` calls used). */
|
|
43
|
+
export declare function usedFromMeminfo(text: string): number | undefined;
|
|
44
|
+
/** `/sys/class/power_supply/BAT*`: its `capacity` and `status`, and whether any AC supply is online. */
|
|
45
|
+
export declare function linuxBattery(supplies: Array<{
|
|
46
|
+
name: string;
|
|
47
|
+
type?: string;
|
|
48
|
+
capacity?: string;
|
|
49
|
+
status?: string;
|
|
50
|
+
online?: string;
|
|
51
|
+
}>): {
|
|
52
|
+
batteryPct: number | null;
|
|
53
|
+
onBattery: boolean | null;
|
|
54
|
+
};
|
|
55
|
+
/**
|
|
56
|
+
* One `typeperf -sc 1` sample of `\Memory\Available MBytes` and
|
|
57
|
+
* `\Memory\% Committed Bytes In Use`: the last CSV row, quoted values.
|
|
58
|
+
*/
|
|
59
|
+
export declare function parseTypeperf(text: string): {
|
|
60
|
+
availableMb?: number;
|
|
61
|
+
committedPct?: number;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Windows has no single pressure level, so it is read from what the memory
|
|
65
|
+
* manager is short of: physical memory left, and the commit charge against
|
|
66
|
+
* its limit (past which allocations fail outright).
|
|
67
|
+
*/
|
|
68
|
+
export declare function windowsPressure(sample: {
|
|
69
|
+
availableMb?: number;
|
|
70
|
+
committedPct?: number;
|
|
71
|
+
}, totalBytes: number | undefined): MemoryPressure | null;
|
|
72
|
+
/** PowerShell `Win32_Battery` as `<BatteryStatus>,<EstimatedChargeRemaining>`; 1 = discharging. */
|
|
73
|
+
export declare function parseWindowsBattery(text: string): {
|
|
74
|
+
batteryPct: number | null;
|
|
75
|
+
onBattery: boolean | null;
|
|
76
|
+
};
|
|
34
77
|
/** e.g. `Apple M3 Max · 64 GB`. */
|
|
35
78
|
export declare function specFrom(brand: string | undefined, totalBytes: number | undefined): string | null;
|
|
36
79
|
interface CpuSample {
|
|
@@ -51,6 +94,10 @@ export interface HostStatsSamplerOptions {
|
|
|
51
94
|
busy: number;
|
|
52
95
|
total: number;
|
|
53
96
|
} | undefined;
|
|
97
|
+
/** Reads a small system file (`/proc`, `/sys`); `undefined` when it can't. */
|
|
98
|
+
readFile?: (file: string) => string | undefined;
|
|
99
|
+
/** Lists a directory (`/sys/class/power_supply`); empty when it can't. */
|
|
100
|
+
listDir?: (dir: string) => string[];
|
|
54
101
|
}
|
|
55
102
|
/** Samples on demand. Keeps the previous CPU sample so each call reports the interval since the last. */
|
|
56
103
|
export declare class HostStatsSampler {
|
|
@@ -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).
|
|
10
|
-
* `
|
|
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;
|
package/dist/codehost/relay.d.ts
CHANGED
|
@@ -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;
|
package/dist/codehost/relay.js
CHANGED
|
@@ -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>;
|
package/dist/host/api.js
ADDED
|
@@ -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
|
+
}
|