@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
|
@@ -0,0 +1,1027 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prohost host run` — one service for every AI employee placed on this computer.
|
|
3
|
+
*
|
|
4
|
+
* It holds one event stream, authenticated as the computer. For each
|
|
5
|
+
* `agent.run_requested` frame it:
|
|
6
|
+
*
|
|
7
|
+
* 1. verifies the signature and queues the run (see `scheduler.ts` for the order);
|
|
8
|
+
* 2. when a slot is free and the run's AI login has room, **claims** it
|
|
9
|
+
* (`POST /v1/computers/{id}/runs/{run_id}/claim`) — exactly one computer
|
|
10
|
+
* gets it; a 409 means another did, and it is skipped;
|
|
11
|
+
* 3. writes the employee's skills into its workspace and runs it the way
|
|
12
|
+
* `agent run` would (`executeHostedRun`), as the employee, with the run's
|
|
13
|
+
* own token for the heartbeat, tool events, reply and completion.
|
|
14
|
+
*
|
|
15
|
+
* Every 30 s (and on any change) it reports the machine — memory, CPU,
|
|
16
|
+
* pressure, battery, slots, AI logins, what's running — and the answer is the
|
|
17
|
+
* computer's config: capacity, holds, sharing between logins, placements.
|
|
18
|
+
*
|
|
19
|
+
* Terminal sessions (Code threads) are hosted by the same service with the
|
|
20
|
+
* computer credential, when hosting is on for the code-host home.
|
|
21
|
+
*
|
|
22
|
+
* Stopping drains: no new run starts, runs going finish, then it returns. A
|
|
23
|
+
* newer CLI on disk (an update) drains the same way and exits for the service
|
|
24
|
+
* manager to restart it.
|
|
25
|
+
*/
|
|
26
|
+
import { mkdirSync, rmSync, statSync } from 'node:fs';
|
|
27
|
+
import os from 'node:os';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { fileURLToPath } from 'node:url';
|
|
30
|
+
import { spawn as nodeSpawn } from 'node:child_process';
|
|
31
|
+
import WebSocket from 'ws';
|
|
32
|
+
import { completeRun, describeFailure } from '../agent/api.js';
|
|
33
|
+
import { defaultCommandRunner } from '../agent/accounts.js';
|
|
34
|
+
import { claudeQuotaProvider, codexQuotaProvider } from '../agent/runtime_status.js';
|
|
35
|
+
import { EVENT_AGENT_RUN_REQUESTED, eventData, heartbeatPathFor, parseRunRequest, } from '../agent/contract.js';
|
|
36
|
+
import { ensureWorkspace, loadCredentials } from '../agent/credentials.js';
|
|
37
|
+
import { HeartbeatLoop } from '../agent/heartbeat.js';
|
|
38
|
+
import { executeHostedRun } from '../agent/run.js';
|
|
39
|
+
import { SessionStore, sessionKeyFor } from '../agent/sessionstore.js';
|
|
40
|
+
import { verifySignature } from '../agent/signature.js';
|
|
41
|
+
import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, REMOTE_TERMINAL_CAPABILITY, RefusedDeliveryError, } from '../codehost/host.js';
|
|
42
|
+
import { HostStatsSampler } from '../codehost/host_stats.js';
|
|
43
|
+
import { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
|
|
44
|
+
import { isWebhookEvent } from '../forward.js';
|
|
45
|
+
import { ServerLiveness, enableTcpKeepAlive, terminateSocket } from '../liveness.js';
|
|
46
|
+
import { CLI_VERSION, USER_AGENT } from '../version.js';
|
|
47
|
+
import { callJson, claimRun, eventsPath, statusPath } from './api.js';
|
|
48
|
+
import { effectiveCapacity, holdReason, recommendCapacity } from './capacity.js';
|
|
49
|
+
import { acquireHostLock, claudeTokenEnv, controlPath, employeeHome, hostActivityPath, readObject, statePath, writePrivateFile, writeSharedFile, } from './computer.js';
|
|
50
|
+
import { EventJournal } from './events.js';
|
|
51
|
+
import { SHARED_DIR_ENV } from './install.js';
|
|
52
|
+
import { HostLedger } from './ledger.js';
|
|
53
|
+
import { LoginPool, detectLogins } from './logins.js';
|
|
54
|
+
import { PeakMemory } from './peak_memory.js';
|
|
55
|
+
import { HostScheduler, laneOf, someoneWaiting } from './scheduler.js';
|
|
56
|
+
import { INTERRUPTED_NOTE, copySession } from './session_move.js';
|
|
57
|
+
import { writeSkills } from './skills.js';
|
|
58
|
+
import { DEFAULT_CONFIG, buildStatusBody, localRunning, parseConfig, slotsFor, } from './status.js';
|
|
59
|
+
/** Set by a dedicated install (`host install --dedicated`): reported as `dedicated` in the status. */
|
|
60
|
+
export const DEDICATED_ENV = 'PROHOST_HOST_DEDICATED';
|
|
61
|
+
/** What a terminal start is answered with while the computer is paused. */
|
|
62
|
+
export const PAUSED_START_REASON = 'This computer is paused: it opens no new terminal sessions until it is resumed.';
|
|
63
|
+
/** Declared in every status body: this is a `prohost host` service. */
|
|
64
|
+
export const COMPUTER_HOST_CAPABILITY = 'computer_host_v1';
|
|
65
|
+
export const STATUS_INTERVAL_MS = 30_000;
|
|
66
|
+
/** Status on change goes out at most this often. */
|
|
67
|
+
export const STATUS_MIN_SPACING_MS = 3_000;
|
|
68
|
+
export const LOGIN_REFRESH_MS = 15 * 60_000;
|
|
69
|
+
export const CONTROL_POLL_MS = 2_000;
|
|
70
|
+
export const UPDATE_CHECK_MS = 60_000;
|
|
71
|
+
/** A claim with no answer is tried again this many times before the run is left to the server. */
|
|
72
|
+
export const MAX_CLAIM_ATTEMPTS = 5;
|
|
73
|
+
/** How many times one run may move to another login after a usage limit. */
|
|
74
|
+
export const MAX_LOGIN_MOVES = 3;
|
|
75
|
+
/** Exit code that asks a service manager to restart (a newer CLI is installed). */
|
|
76
|
+
export const RESTART_EXIT_CODE = 75;
|
|
77
|
+
const ACK_ACCEPTED = 200;
|
|
78
|
+
const ACK_IGNORED = 204;
|
|
79
|
+
const ACK_REFUSED = 409;
|
|
80
|
+
const ACK_REJECTED = 401;
|
|
81
|
+
const ACK_RETRY = 503;
|
|
82
|
+
const RECONNECT_MAX_DELAY_MS = 30_000;
|
|
83
|
+
/** An employee id names a folder: nothing else gets in. */
|
|
84
|
+
const SAFE_ID = /^[A-Za-z0-9-]{1,64}$/;
|
|
85
|
+
function timestamp() {
|
|
86
|
+
return new Date().toISOString().slice(11, 19);
|
|
87
|
+
}
|
|
88
|
+
function defaultSleep(ms) {
|
|
89
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
90
|
+
}
|
|
91
|
+
/** The CLI's own entry file: when it changes on disk, an update was installed. */
|
|
92
|
+
export function entrypointStamp(entry = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js')) {
|
|
93
|
+
const read = () => {
|
|
94
|
+
try {
|
|
95
|
+
return statSync(entry).mtimeMs;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const initial = read();
|
|
102
|
+
return () => {
|
|
103
|
+
const now = read();
|
|
104
|
+
return initial !== undefined && now !== undefined && now !== initial;
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
export async function runHostService(options) {
|
|
108
|
+
// One service per home: a second would read the same ledger and report the
|
|
109
|
+
// first one's live runs as unfinished.
|
|
110
|
+
const lock = acquireHostLock(options.env ?? process.env);
|
|
111
|
+
if (!lock.ok) {
|
|
112
|
+
const fatal = `another prohost host service (pid ${lock.heldBy}) is already running for this computer`;
|
|
113
|
+
(options.log ?? ((line) => process.stdout.write(`${line}\n`)))(`${timestamp()} ✗ ${fatal}`);
|
|
114
|
+
return { restart: false, runsStarted: 0, runsSkipped: 0, fatal };
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return await serveHost(options);
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
lock.release();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function serveHost(options) {
|
|
124
|
+
const env = options.env ?? process.env;
|
|
125
|
+
const log = options.log ?? ((line) => process.stdout.write(`${line}\n`));
|
|
126
|
+
const sleep = options.sleepImpl ?? defaultSleep;
|
|
127
|
+
const now = options.now ?? Date.now;
|
|
128
|
+
const computer = options.computer;
|
|
129
|
+
const machine = options.machine ?? os.hostname();
|
|
130
|
+
const execute = options.executeRun ?? executeHostedRun;
|
|
131
|
+
const Ctor = options.webSocketCtor ?? WebSocket;
|
|
132
|
+
const computerApi = { baseUrl: computer.base_url, apiKey: computer.api_key, fetchImpl: options.fetchImpl };
|
|
133
|
+
const result = { restart: false, runsStarted: 0, runsSkipped: 0 };
|
|
134
|
+
const ledger = HostLedger.load(env, log);
|
|
135
|
+
/** One session ledger per employee, shared by its runs. */
|
|
136
|
+
const sessionStores = new Map();
|
|
137
|
+
const sessionsFor = (agentId) => {
|
|
138
|
+
let store = sessionStores.get(agentId);
|
|
139
|
+
if (!store) {
|
|
140
|
+
store = SessionStore.load({ ...env, PROHOST_HOME: employeeHome(agentId, env) }, log);
|
|
141
|
+
sessionStores.set(agentId, store);
|
|
142
|
+
}
|
|
143
|
+
return store;
|
|
144
|
+
};
|
|
145
|
+
const pool = new LoginPool({ get: (lane) => ledger.conversationLogin(lane), set: (lane, key) => ledger.setConversationLogin(lane, key) }, now);
|
|
146
|
+
const scheduler = new HostScheduler();
|
|
147
|
+
const active = new Map();
|
|
148
|
+
const peak = options.peak ?? new PeakMemory();
|
|
149
|
+
const stats = options.stats ?? new HostStatsSampler({ slots: () => ({ busy: active.size, total: capacity() }) });
|
|
150
|
+
const hardware = options.hardware ?? { memoryGb: os.totalmem() / 1024 ** 3, cores: os.cpus().length };
|
|
151
|
+
const heartbeats = new HeartbeatLoop({ api: computerApi, log, intervalMs: options.heartbeatIntervalMs });
|
|
152
|
+
const background = [];
|
|
153
|
+
const announcedRests = new Map();
|
|
154
|
+
let config = { ...DEFAULT_CONFIG };
|
|
155
|
+
/** A dedicated service publishes its state here for the logged-in user, and reads `control.json` here too. */
|
|
156
|
+
const sharedDir = env[SHARED_DIR_ENV] || undefined;
|
|
157
|
+
const readPaused = () => readObject(controlPath(env))?.paused === true ||
|
|
158
|
+
(sharedDir !== undefined && readObject(path.join(sharedDir, 'control.json'))?.paused === true);
|
|
159
|
+
const journal = new EventJournal(env, now);
|
|
160
|
+
let localPaused = readPaused();
|
|
161
|
+
let held = null;
|
|
162
|
+
let online = false;
|
|
163
|
+
let draining = false;
|
|
164
|
+
let fatal;
|
|
165
|
+
let socket;
|
|
166
|
+
const recommended = () => recommendCapacity({ memoryGb: hardware.memoryGb, cores: hardware.cores, workload: options.workload });
|
|
167
|
+
function capacity() {
|
|
168
|
+
return effectiveCapacity(config.capacity, config.recommended_capacity ?? recommended());
|
|
169
|
+
}
|
|
170
|
+
log(`${timestamp()} ⚙ ${computer.name} — serving the AI employees placed here; ` +
|
|
171
|
+
'they run WITHOUT permission prompts (Claude --dangerously-skip-permissions, Codex --yolo). ' +
|
|
172
|
+
'Message approval rules still apply.');
|
|
173
|
+
/** The terminal-hosting home's own agent key (`host migrate`), if it has one. */
|
|
174
|
+
const agentKeyFallback = (() => {
|
|
175
|
+
if (!computer.code_host_home)
|
|
176
|
+
return undefined;
|
|
177
|
+
try {
|
|
178
|
+
return loadCredentials({ ...env, PROHOST_HOME: computer.code_host_home }).api_key;
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
})();
|
|
184
|
+
// ── Terminal sessions ────────────────────────────────────────────────────
|
|
185
|
+
const codeHostEnv = computer.code_host_home
|
|
186
|
+
? { ...process.env, ...env, PROHOST_HOME: computer.code_host_home }
|
|
187
|
+
: { ...process.env, ...env };
|
|
188
|
+
const codeHost = options.codeHost !== undefined
|
|
189
|
+
? (options.codeHost ?? undefined)
|
|
190
|
+
: loadCodeHostSettings(codeHostEnv).enabled
|
|
191
|
+
? buildCodeHost()
|
|
192
|
+
: undefined;
|
|
193
|
+
function buildCodeHost() {
|
|
194
|
+
try {
|
|
195
|
+
return new CodeHost({
|
|
196
|
+
env: codeHostEnv,
|
|
197
|
+
baseUrl: computer.base_url,
|
|
198
|
+
apiKey: computer.api_key,
|
|
199
|
+
machineId: computer.machine_id,
|
|
200
|
+
machine,
|
|
201
|
+
clientVersion: CLI_VERSION,
|
|
202
|
+
accountId: computer.account.id,
|
|
203
|
+
repos: [],
|
|
204
|
+
cliEntrypoint: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js'),
|
|
205
|
+
log,
|
|
206
|
+
slots: () => ({ busy: active.size + terminals(), total: capacity() }),
|
|
207
|
+
// The adopted agent key, for a server that doesn't take the computer key on the relay yet.
|
|
208
|
+
...(agentKeyFallback ? { fallbackApiKey: agentKeyFallback } : {}),
|
|
209
|
+
// Paused: no new terminal sessions (nobody types as a signed-out owner).
|
|
210
|
+
paused: () => localPaused || config.paused,
|
|
211
|
+
onRemoteChanged: () => statusChanged(),
|
|
212
|
+
onCodeHostChanged: () => statusChanged(),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
log(`${timestamp()} ! terminal sessions unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (codeHost) {
|
|
221
|
+
await codeHost.start().catch((err) => {
|
|
222
|
+
log(`${timestamp()} ! terminal sessions did not start: ${err instanceof Error ? err.message : String(err)}`);
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
/** Open terminal sessions — counted even after terminal sessions are switched off: they still run. */
|
|
226
|
+
function terminals() {
|
|
227
|
+
return codeHost?.liveTerminals?.().length ?? 0;
|
|
228
|
+
}
|
|
229
|
+
function capabilities() {
|
|
230
|
+
const caps = [COMPUTER_HOST_CAPABILITY];
|
|
231
|
+
if (codeHost?.authorized() && config.terminal_sessions_enabled) {
|
|
232
|
+
caps.push(CODE_THREADS_CAPABILITY, CODE_THREAD_FOLDERS_CAPABILITY, codeHost.instanceCapability());
|
|
233
|
+
caps.push(...(codeHost.agentCapabilities?.() ?? []));
|
|
234
|
+
if (codeHost.remoteTerminalAllowed())
|
|
235
|
+
caps.push(REMOTE_TERMINAL_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY);
|
|
236
|
+
}
|
|
237
|
+
return caps;
|
|
238
|
+
}
|
|
239
|
+
// ── What is running ──────────────────────────────────────────────────────
|
|
240
|
+
function runningEntries() {
|
|
241
|
+
const runs = [...active.values()].map((run) => ({ ...run.entry, peak_mem_mb: peak.peakMb(run.entry.run_id) }));
|
|
242
|
+
const terms = (codeHost?.liveTerminals?.() ?? []).map((t) => ({
|
|
243
|
+
run_id: t.terminalId,
|
|
244
|
+
agent_id: t.agent,
|
|
245
|
+
conversation_key: `code_thread:${t.codeThreadId}`,
|
|
246
|
+
kind: 'terminal',
|
|
247
|
+
started_at: '',
|
|
248
|
+
}));
|
|
249
|
+
return [...runs, ...terms];
|
|
250
|
+
}
|
|
251
|
+
/** Runs being claimed right now, and the login each will run on. */
|
|
252
|
+
const claiming = new Map();
|
|
253
|
+
function runsOnLogin(accountKey) {
|
|
254
|
+
let n = 0;
|
|
255
|
+
for (const run of active.values())
|
|
256
|
+
if (run.accountKey === accountKey)
|
|
257
|
+
n += 1;
|
|
258
|
+
for (const key of claiming.values())
|
|
259
|
+
if (key === accountKey)
|
|
260
|
+
n += 1;
|
|
261
|
+
return n;
|
|
262
|
+
}
|
|
263
|
+
// ── Holds ────────────────────────────────────────────────────────────────
|
|
264
|
+
function updateHold() {
|
|
265
|
+
const latest = stats.latest();
|
|
266
|
+
const next = holdReason({
|
|
267
|
+
paused: localPaused || config.paused,
|
|
268
|
+
holdOnPressure: config.hold_on_pressure,
|
|
269
|
+
holdOnBattery: config.hold_on_battery,
|
|
270
|
+
memoryPressure: latest?.memoryPressure ?? null,
|
|
271
|
+
onBattery: latest?.onBattery ?? null,
|
|
272
|
+
});
|
|
273
|
+
if (next === held)
|
|
274
|
+
return;
|
|
275
|
+
if (held)
|
|
276
|
+
sendEvent({ kind: 'hold_ended', reason: held });
|
|
277
|
+
if (next)
|
|
278
|
+
sendEvent({ kind: 'hold_started', reason: next });
|
|
279
|
+
log(next ? `${timestamp()} ⏸ holding new work (${next})` : `${timestamp()} ▶ taking new work again`);
|
|
280
|
+
held = next;
|
|
281
|
+
statusChanged();
|
|
282
|
+
pump();
|
|
283
|
+
}
|
|
284
|
+
// ── Events and status ────────────────────────────────────────────────────
|
|
285
|
+
function sendEvent(event) {
|
|
286
|
+
// Kept locally too (24 h), for the desktop's notifications (`host status --json`).
|
|
287
|
+
journal.add({ ...event, computer_id: computer.computer_id, computer_name: config.name ?? computer.name });
|
|
288
|
+
if (sharedDir) {
|
|
289
|
+
try {
|
|
290
|
+
writeSharedFile(path.join(sharedDir, 'events.json'), { v: 1, events: journal.list() });
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* the shared copy is a convenience */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
background.push(callJson(computerApi, eventsPath(computer.computer_id), event).then((response) => {
|
|
297
|
+
if (response.status < 200 || response.status >= 300) {
|
|
298
|
+
log(`${timestamp()} ! event ${String(event.kind)} not accepted (${response.status || response.error})`);
|
|
299
|
+
}
|
|
300
|
+
}));
|
|
301
|
+
}
|
|
302
|
+
let lastStatusAt = 0;
|
|
303
|
+
let statusTimer;
|
|
304
|
+
let statusInFlight;
|
|
305
|
+
let statusFailures = 0;
|
|
306
|
+
function statusChanged() {
|
|
307
|
+
writeLocalState();
|
|
308
|
+
if (statusTimer)
|
|
309
|
+
return;
|
|
310
|
+
const wait = Math.max(0, lastStatusAt + STATUS_MIN_SPACING_MS - now());
|
|
311
|
+
statusTimer = setTimeout(() => {
|
|
312
|
+
statusTimer = undefined;
|
|
313
|
+
void sendStatus();
|
|
314
|
+
}, wait);
|
|
315
|
+
statusTimer.unref?.();
|
|
316
|
+
}
|
|
317
|
+
async function sendStatus() {
|
|
318
|
+
if (statusInFlight)
|
|
319
|
+
return statusInFlight;
|
|
320
|
+
statusInFlight = (async () => {
|
|
321
|
+
lastStatusAt = now();
|
|
322
|
+
const body = buildStatusBody({
|
|
323
|
+
stats: stats.latest(),
|
|
324
|
+
slots: slotsFor(capacity(), active.size + terminals(), held),
|
|
325
|
+
logins: pool.all(),
|
|
326
|
+
conversationsPerLogin: ledger.conversationsPerLogin(),
|
|
327
|
+
running: runningEntries(),
|
|
328
|
+
cliVersion: CLI_VERSION,
|
|
329
|
+
capabilities: capabilities(),
|
|
330
|
+
recommendedCapacity: recommended(),
|
|
331
|
+
dedicated: env[DEDICATED_ENV] === '1',
|
|
332
|
+
});
|
|
333
|
+
const response = await callJson(computerApi, statusPath(computer.computer_id), body);
|
|
334
|
+
if (response.status >= 200 && response.status < 300) {
|
|
335
|
+
if (statusFailures > 0)
|
|
336
|
+
log(`${timestamp()} ● status reaching ProhostAI again`);
|
|
337
|
+
statusFailures = 0;
|
|
338
|
+
const before = JSON.stringify(config);
|
|
339
|
+
config = parseConfig(response.body, config);
|
|
340
|
+
options.onConfig?.(config);
|
|
341
|
+
if (JSON.stringify(config) !== before) {
|
|
342
|
+
updateHold();
|
|
343
|
+
pump();
|
|
344
|
+
}
|
|
345
|
+
writeLocalState();
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (response.status === 401 || response.status === 403 || response.status === 410) {
|
|
349
|
+
fatal = `ProhostAI refused this computer's key (HTTP ${response.status}) — it was removed or its key revoked. Add it again: prohost host pair`;
|
|
350
|
+
socket?.close();
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
statusFailures += 1;
|
|
354
|
+
if (statusFailures === 1)
|
|
355
|
+
log(`${timestamp()} ! status not accepted (${response.status || response.error})`);
|
|
356
|
+
})().finally(() => {
|
|
357
|
+
statusInFlight = undefined;
|
|
358
|
+
});
|
|
359
|
+
return statusInFlight;
|
|
360
|
+
}
|
|
361
|
+
function writeLocalState() {
|
|
362
|
+
const running = runningEntries();
|
|
363
|
+
const state = {
|
|
364
|
+
v: 1,
|
|
365
|
+
pid: process.pid,
|
|
366
|
+
updatedAt: new Date(now()).toISOString(),
|
|
367
|
+
online,
|
|
368
|
+
paused: localPaused || config.paused,
|
|
369
|
+
draining,
|
|
370
|
+
slots: slotsFor(capacity(), active.size + terminals(), held),
|
|
371
|
+
capacity: capacity(),
|
|
372
|
+
recommended_capacity: config.recommended_capacity ?? recommended(),
|
|
373
|
+
running,
|
|
374
|
+
logins: [...pool.all()],
|
|
375
|
+
...(config.name ? { name: config.name } : {}),
|
|
376
|
+
};
|
|
377
|
+
const activity = { v: 1, busy: running.length > 0, running: running.map(localRunning), updatedAt: state.updatedAt };
|
|
378
|
+
try {
|
|
379
|
+
writePrivateFile(statePath(env), state);
|
|
380
|
+
writePrivateFile(hostActivityPath(env), activity);
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
/* a read-only home costs the menu bar its numbers, not the runs */
|
|
384
|
+
}
|
|
385
|
+
if (sharedDir) {
|
|
386
|
+
// A dedicated service, for the person logged in: no login emails in this copy.
|
|
387
|
+
try {
|
|
388
|
+
writeSharedFile(path.join(sharedDir, 'state.json'), {
|
|
389
|
+
...state,
|
|
390
|
+
home: env.PROHOST_HOME ?? null,
|
|
391
|
+
computer: { id: computer.computer_id, name: config.name ?? computer.name, account: computer.account },
|
|
392
|
+
logins: state.logins.map(({ email_local: _email, ...login }) => login),
|
|
393
|
+
});
|
|
394
|
+
writeSharedFile(path.join(sharedDir, 'activity.json'), activity);
|
|
395
|
+
}
|
|
396
|
+
catch {
|
|
397
|
+
/* the shared copy is a convenience */
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
// ── Logins ───────────────────────────────────────────────────────────────
|
|
402
|
+
const refreshLogins = options.detectLogins ??
|
|
403
|
+
(() => detectLogins({
|
|
404
|
+
env,
|
|
405
|
+
// A dedicated install's Claude token signs Claude in: its checks see it, nothing else does.
|
|
406
|
+
run: (command, args, runEnv) => (options.accountCommandRunner ?? defaultCommandRunner)(command, args, command === 'claude' ? { ...runEnv, ...claudeTokenEnv(env) } : runEnv),
|
|
407
|
+
includeEmail: true,
|
|
408
|
+
quota: (record) => {
|
|
409
|
+
const accountEnv = () => ({
|
|
410
|
+
...(record.dir ? (record.runtime === 'claude_code' ? { CLAUDE_CONFIG_DIR: record.dir } : { CODEX_HOME: record.dir }) : {}),
|
|
411
|
+
...(record.runtime === 'claude_code' ? claudeTokenEnv(env) : {}),
|
|
412
|
+
});
|
|
413
|
+
return record.runtime === 'claude_code'
|
|
414
|
+
? claudeQuotaProvider({ binary: 'claude', env: accountEnv })
|
|
415
|
+
: codexQuotaProvider({ binary: 'codex', clientVersion: CLI_VERSION, env: accountEnv });
|
|
416
|
+
},
|
|
417
|
+
}));
|
|
418
|
+
async function loadLogins() {
|
|
419
|
+
try {
|
|
420
|
+
pool.update(await refreshLogins());
|
|
421
|
+
}
|
|
422
|
+
catch (err) {
|
|
423
|
+
log(`${timestamp()} ! could not read the AI logins: ${err instanceof Error ? err.message : String(err)}`);
|
|
424
|
+
}
|
|
425
|
+
announceRests();
|
|
426
|
+
statusChanged();
|
|
427
|
+
pump();
|
|
428
|
+
}
|
|
429
|
+
function waitingFor(runtime) {
|
|
430
|
+
return scheduler.queued().filter((q) => runtimeOf(q.agentId, q.data) === runtime).length;
|
|
431
|
+
}
|
|
432
|
+
function announceRests() {
|
|
433
|
+
for (const login of pool.all()) {
|
|
434
|
+
const until = pool.restingUntil(login.account_key);
|
|
435
|
+
if (until === undefined) {
|
|
436
|
+
announcedRests.delete(login.account_key);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (announcedRests.get(login.account_key) === until)
|
|
440
|
+
continue;
|
|
441
|
+
announcedRests.set(login.account_key, until);
|
|
442
|
+
sendEvent({
|
|
443
|
+
kind: 'login_resting',
|
|
444
|
+
runtime: login.runtime,
|
|
445
|
+
account_key: login.account_key,
|
|
446
|
+
label: login.label,
|
|
447
|
+
resets_at: new Date(until).toISOString(),
|
|
448
|
+
waiting: waitingFor(login.runtime),
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
// ── Scheduling ───────────────────────────────────────────────────────────
|
|
453
|
+
function runtimeOf(agentId, data) {
|
|
454
|
+
const placed = config.placements.find((p) => p.agent_id === agentId);
|
|
455
|
+
if (placed)
|
|
456
|
+
return placed.runtime;
|
|
457
|
+
return data.runtime === 'codex' ? 'codex' : 'claude_code';
|
|
458
|
+
}
|
|
459
|
+
function limits() {
|
|
460
|
+
return new Map(config.placements.map((p) => [p.agent_id, { maxConcurrent: p.max_concurrent, keepOneReady: p.keep_one_ready }]));
|
|
461
|
+
}
|
|
462
|
+
function conversationOf(lane) {
|
|
463
|
+
return lane.startsWith('run:') ? undefined : lane;
|
|
464
|
+
}
|
|
465
|
+
function pump() {
|
|
466
|
+
if (draining || fatal)
|
|
467
|
+
return;
|
|
468
|
+
if (!config.ai_employees_enabled) {
|
|
469
|
+
// Switched off for this computer: queued runs were never claimed, so
|
|
470
|
+
// ProhostAI hands them elsewhere; none of them starts here.
|
|
471
|
+
for (const queued of [...scheduler.queued()])
|
|
472
|
+
scheduler.remove(queued.runId);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
for (;;) {
|
|
476
|
+
const next = scheduler.take({ capacity: capacity(), held: held !== null, terminals: terminals(), employees: limits() }, (run) => {
|
|
477
|
+
if (run.notBefore > now())
|
|
478
|
+
return false;
|
|
479
|
+
const pick = pool.pick(runtimeOf(run.agentId, run.data), conversationOf(run.lane), config.share_logins[runtimeOf(run.agentId, run.data)], runsOnLogin);
|
|
480
|
+
return pick.kind === 'use' || pick.kind === 'move';
|
|
481
|
+
});
|
|
482
|
+
if (!next)
|
|
483
|
+
break;
|
|
484
|
+
background.push(startRun(next));
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
// A queued run waiting on a backoff or a login reset is looked at again.
|
|
488
|
+
const pumpTimer = setInterval(() => pump(), 5_000);
|
|
489
|
+
pumpTimer.unref?.();
|
|
490
|
+
async function startRun(queued) {
|
|
491
|
+
const runtime = runtimeOf(queued.agentId, queued.data);
|
|
492
|
+
const conversation = conversationOf(queued.lane);
|
|
493
|
+
const pick = pool.pick(runtime, conversation, config.share_logins[runtime], runsOnLogin);
|
|
494
|
+
if (pick.kind !== 'use' && pick.kind !== 'move') {
|
|
495
|
+
scheduler.requeue(queued);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
const login = pick.kind === 'use' ? pick.login : pick.to;
|
|
499
|
+
if (!config.ai_employees_enabled || draining) {
|
|
500
|
+
scheduler.finish(queued.runId);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
// Counted on its login from now, so the next run picked spreads to another.
|
|
504
|
+
claiming.set(queued.runId, login.account_key);
|
|
505
|
+
const claim = await claimRun(computerApi, computer.computer_id, queued.runId).finally(() => {
|
|
506
|
+
claiming.delete(queued.runId);
|
|
507
|
+
});
|
|
508
|
+
if (claim.kind !== 'claimed') {
|
|
509
|
+
if (claim.kind === 'retry' && queued.claimAttempts + 1 < MAX_CLAIM_ATTEMPTS && !draining) {
|
|
510
|
+
scheduler.requeue({ ...queued, claimAttempts: queued.claimAttempts + 1, notBefore: now() + 2_000 * 2 ** queued.claimAttempts });
|
|
511
|
+
}
|
|
512
|
+
else {
|
|
513
|
+
scheduler.finish(queued.runId);
|
|
514
|
+
result.runsSkipped += 1;
|
|
515
|
+
const why = claim.kind === 'elsewhere'
|
|
516
|
+
? 'another computer has it'
|
|
517
|
+
: claim.kind === 'gone'
|
|
518
|
+
? 'it is no longer waiting'
|
|
519
|
+
: `the claim was refused (${claim.status}${'error' in claim && claim.error ? `: ${claim.error}` : ''})`;
|
|
520
|
+
log(`${timestamp()} ◦ run ${queued.runId.slice(0, 12)} skipped — ${why}`);
|
|
521
|
+
}
|
|
522
|
+
pump();
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const run = parseRunRequest({ agent_id: queued.agentId, ...claim.payload });
|
|
526
|
+
if (!run) {
|
|
527
|
+
scheduler.finish(queued.runId);
|
|
528
|
+
log(`${timestamp()} ✗ run ${queued.runId.slice(0, 12)} was claimed but its payload is unusable — not running it`);
|
|
529
|
+
pump();
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// The frame's employee (checked when it arrived) names the home, whatever the payload says.
|
|
533
|
+
const agentId = queued.agentId;
|
|
534
|
+
ledger.claimed({
|
|
535
|
+
run_id: run.run_id,
|
|
536
|
+
agent_id: agentId,
|
|
537
|
+
lane: queued.lane,
|
|
538
|
+
account_key: login.account_key,
|
|
539
|
+
completion_path: run.completion_path,
|
|
540
|
+
run_token: claim.runToken,
|
|
541
|
+
});
|
|
542
|
+
if (pick.kind === 'move')
|
|
543
|
+
moveSession(run, agentId, pick.from, pick.to);
|
|
544
|
+
pool.stick(conversation, login.account_key);
|
|
545
|
+
result.runsStarted += 1;
|
|
546
|
+
const controller = new AbortController();
|
|
547
|
+
const activeRun = {
|
|
548
|
+
controller,
|
|
549
|
+
accountKey: login.account_key,
|
|
550
|
+
entry: {
|
|
551
|
+
run_id: run.run_id,
|
|
552
|
+
agent_id: agentId,
|
|
553
|
+
...(run.agent_name ? { agent_name: run.agent_name } : {}),
|
|
554
|
+
...(typeof claim.payload.agent_avatar_url === 'string' ? { agent_avatar_url: claim.payload.agent_avatar_url } : {}),
|
|
555
|
+
...(typeof claim.payload.title === 'string'
|
|
556
|
+
? { title: claim.payload.title }
|
|
557
|
+
: run.trigger_text
|
|
558
|
+
? { title: run.trigger_text.slice(0, 80) }
|
|
559
|
+
: {}),
|
|
560
|
+
...(conversation ? { conversation_key: conversation } : {}),
|
|
561
|
+
kind: 'employee',
|
|
562
|
+
started_at: new Date(now()).toISOString(),
|
|
563
|
+
...(run.trigger_user_id ? { started_by_user_id: run.trigger_user_id } : {}),
|
|
564
|
+
},
|
|
565
|
+
};
|
|
566
|
+
active.set(run.run_id, activeRun);
|
|
567
|
+
statusChanged();
|
|
568
|
+
let moved = pick.kind === 'move' ? { from: pick.from.account_key, to: pick.to.account_key } : undefined;
|
|
569
|
+
const runApi = {
|
|
570
|
+
baseUrl: computer.base_url,
|
|
571
|
+
apiKey: claim.runToken,
|
|
572
|
+
fetchImpl: options.fetchImpl,
|
|
573
|
+
completionExtra: () => ({
|
|
574
|
+
...(peak.peakMb(run.run_id) !== undefined ? { peak_mem_mb: peak.peakMb(run.run_id) } : {}),
|
|
575
|
+
...(moved ? { login_moved: moved } : {}),
|
|
576
|
+
}),
|
|
577
|
+
};
|
|
578
|
+
heartbeats.track(run.run_id, {
|
|
579
|
+
path: heartbeatPathFor(run),
|
|
580
|
+
api: runApi,
|
|
581
|
+
progress: () => activeRun.progress,
|
|
582
|
+
onCancelRequested: () => {
|
|
583
|
+
activeRun.disposition = 'cancelled';
|
|
584
|
+
controller.abort();
|
|
585
|
+
},
|
|
586
|
+
onResolved: () => {
|
|
587
|
+
activeRun.disposition = 'resolved';
|
|
588
|
+
controller.abort();
|
|
589
|
+
},
|
|
590
|
+
});
|
|
591
|
+
const stopHard = () => controller.abort();
|
|
592
|
+
options.hardStop?.addEventListener('abort', stopHard, { once: true });
|
|
593
|
+
const home = employeeHome(agentId, env);
|
|
594
|
+
const homeEnv = { ...env, PROHOST_HOME: home };
|
|
595
|
+
const runDir = path.join(home, 'runs', run.run_id);
|
|
596
|
+
let outcome = 'failed';
|
|
597
|
+
try {
|
|
598
|
+
const workspace = workspaceFor(agentId, homeEnv);
|
|
599
|
+
try {
|
|
600
|
+
const written = writeSkills(workspace, runtime, claim.skills);
|
|
601
|
+
for (const skipped of written.skipped)
|
|
602
|
+
log(` ! skill ${skipped.key} not written: ${skipped.reason}`);
|
|
603
|
+
}
|
|
604
|
+
catch (err) {
|
|
605
|
+
log(` ! could not write skills for run ${run.run_id.slice(0, 12)}: ${String(err)}`);
|
|
606
|
+
}
|
|
607
|
+
const control = {
|
|
608
|
+
signal: controller.signal,
|
|
609
|
+
disposition: () => activeRun.disposition,
|
|
610
|
+
report: (activity) => {
|
|
611
|
+
activeRun.progress = activity;
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
let current = login;
|
|
615
|
+
let note = pick.kind === 'move' ? INTERRUPTED_NOTE : undefined;
|
|
616
|
+
for (let moves = 0;; moves += 1) {
|
|
617
|
+
const executed = await execute(run, runOptions(run, claim.runToken, agentId, runtime, current, homeEnv, runDir, note), control, { lane: queued.lane, parallel: capacity() > 1 }, runApi);
|
|
618
|
+
outcome = executed.outcome;
|
|
619
|
+
if (executed.outcome !== 'usage_limited')
|
|
620
|
+
break;
|
|
621
|
+
const until = pool.rest(current.account_key, executed.retryAfter ? Date.parse(executed.retryAfter) : undefined);
|
|
622
|
+
announceRests();
|
|
623
|
+
const next = config.share_logins[runtime] && moves < MAX_LOGIN_MOVES
|
|
624
|
+
? pool.pick(runtime, conversation, true, runsOnLogin)
|
|
625
|
+
: undefined;
|
|
626
|
+
const target = next?.kind === 'move' ? next.to : next?.kind === 'use' && next.login.account_key !== current.account_key ? next.login : undefined;
|
|
627
|
+
if (!target) {
|
|
628
|
+
outcome = await completeLimited(run, runApi, runtime, until);
|
|
629
|
+
break;
|
|
630
|
+
}
|
|
631
|
+
moveSession(run, agentId, current, target);
|
|
632
|
+
moved = { from: moved?.from ?? current.account_key, to: target.account_key };
|
|
633
|
+
sendEvent({ kind: 'login_moved', run_id: run.run_id, from: current.account_key, to: target.account_key });
|
|
634
|
+
ledger.setAccount(run.run_id, target.account_key);
|
|
635
|
+
pool.stick(conversation, target.account_key);
|
|
636
|
+
activeRun.accountKey = target.account_key;
|
|
637
|
+
current = target;
|
|
638
|
+
note = INTERRUPTED_NOTE;
|
|
639
|
+
log(`${timestamp()} ↪ run ${run.run_id.slice(0, 12)} continues on ${target.label}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
catch (err) {
|
|
643
|
+
// Never reported by the run itself: report it here, with its own token,
|
|
644
|
+
// and keep it in the ledger for the next start if that can't land.
|
|
645
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
646
|
+
log(`${timestamp()} ✗ run ${run.run_id.slice(0, 12)} failed in the host: ${message}`);
|
|
647
|
+
outcome = await completeFailed(run, runApi, { ok: false, error: `the computer could not run this: ${message}` }, 'the failure');
|
|
648
|
+
}
|
|
649
|
+
finally {
|
|
650
|
+
options.hardStop?.removeEventListener('abort', stopHard);
|
|
651
|
+
heartbeats.untrack(run.run_id);
|
|
652
|
+
peak.finish(run.run_id);
|
|
653
|
+
rmSync(runDir, { recursive: true, force: true });
|
|
654
|
+
if (outcome !== 'unreported' && outcome !== 'usage_limited')
|
|
655
|
+
ledger.completed(run.run_id);
|
|
656
|
+
active.delete(run.run_id);
|
|
657
|
+
scheduler.finish(run.run_id);
|
|
658
|
+
statusChanged();
|
|
659
|
+
pump();
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
/** Where an employee's runs work: its adopted workspace, else its own under its host home. */
|
|
663
|
+
function workspaceFor(agentId, homeEnv) {
|
|
664
|
+
const adopted = options.workdirFor?.(agentId);
|
|
665
|
+
if (adopted) {
|
|
666
|
+
mkdirSync(adopted, { recursive: true, mode: 0o700 });
|
|
667
|
+
return adopted;
|
|
668
|
+
}
|
|
669
|
+
return ensureWorkspace(homeEnv);
|
|
670
|
+
}
|
|
671
|
+
function moveSession(run, agentId, from, to) {
|
|
672
|
+
const homeEnv = { ...env, PROHOST_HOME: employeeHome(agentId, env) };
|
|
673
|
+
const sessionId = sessionsFor(agentId).get(sessionKeyFor(run));
|
|
674
|
+
if (!sessionId)
|
|
675
|
+
return;
|
|
676
|
+
try {
|
|
677
|
+
const copied = copySession({
|
|
678
|
+
runtime: from.runtime,
|
|
679
|
+
targetRuntime: to.runtime,
|
|
680
|
+
fromDir: from.dir,
|
|
681
|
+
toDir: to.dir,
|
|
682
|
+
sessionId,
|
|
683
|
+
cwd: workspaceFor(agentId, homeEnv),
|
|
684
|
+
});
|
|
685
|
+
if (!copied.ok)
|
|
686
|
+
log(` ! the conversation's session was not copied to ${to.label}: ${copied.reason}`);
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
log(` ! copying the session to ${to.label} failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/** No other login has room: the run is reported failed with the limit, so ProhostAI asks again after the reset. */
|
|
693
|
+
function completeLimited(run, api, runtime, until) {
|
|
694
|
+
const name = runtime === 'codex' ? 'Codex' : 'Claude';
|
|
695
|
+
return completeFailed(run, api, { ok: false, error: `${name} usage limit reached on ${machine}`, usageLimit: { retryAfter: new Date(until).toISOString() } }, 'the usage limit');
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Report a run failed with its own token, retrying what may be transient.
|
|
699
|
+
* `unreported` keeps it in the ledger, so the next start reports it.
|
|
700
|
+
*/
|
|
701
|
+
async function completeFailed(run, api, outcome, what) {
|
|
702
|
+
for (let attempt = 0; attempt < 4; attempt += 1) {
|
|
703
|
+
const completed = await completeRun(api, run.completion_path, outcome);
|
|
704
|
+
if (completed.ok)
|
|
705
|
+
return 'failed';
|
|
706
|
+
if (completed.status !== 0 && completed.status !== 429 && completed.status < 500) {
|
|
707
|
+
// Refused, not lost: kept in the ledger like any completion that did
|
|
708
|
+
// not land (as `agent run` does), and tried once more on the next start.
|
|
709
|
+
log(` ! could not report ${what} (${describeFailure(completed)})`);
|
|
710
|
+
return 'unreported';
|
|
711
|
+
}
|
|
712
|
+
await sleep(1_000 * 3 ** attempt);
|
|
713
|
+
}
|
|
714
|
+
return 'unreported';
|
|
715
|
+
}
|
|
716
|
+
function runOptions(run, runToken, agentId, runtime, login, homeEnv, runDir, resumeNote) {
|
|
717
|
+
const spawnImpl = options.spawnImpl;
|
|
718
|
+
return {
|
|
719
|
+
exec: runtime === 'codex' ? 'codex exec' : 'claude -p',
|
|
720
|
+
credentials: {
|
|
721
|
+
version: 2,
|
|
722
|
+
base_url: computer.base_url,
|
|
723
|
+
ws_url: computer.ws_url,
|
|
724
|
+
// The run's own token: it acts as the employee, for this run only.
|
|
725
|
+
api_key: runToken,
|
|
726
|
+
webhook_secret: computer.webhook_secret,
|
|
727
|
+
subscription_id: null,
|
|
728
|
+
stream_only: true,
|
|
729
|
+
mcp_url: computer.mcp_url,
|
|
730
|
+
events: [],
|
|
731
|
+
agent: { id: agentId, name: run.agent_name ?? 'AI employee', user_id: run.agent_user_id ?? '' },
|
|
732
|
+
paired_at: computer.paired_at,
|
|
733
|
+
account: login.label,
|
|
734
|
+
prohost_account: computer.account,
|
|
735
|
+
},
|
|
736
|
+
env: homeEnv,
|
|
737
|
+
cwd: workspaceFor(agentId, homeEnv),
|
|
738
|
+
machine,
|
|
739
|
+
timeoutMs: options.timeoutMs,
|
|
740
|
+
idleTimeoutMs: options.idleTimeoutMs,
|
|
741
|
+
onLine: log,
|
|
742
|
+
fetchImpl: options.fetchImpl,
|
|
743
|
+
sleepImpl: options.sleepImpl,
|
|
744
|
+
probeImpl: options.probeImpl,
|
|
745
|
+
accountCommandRunner: options.accountCommandRunner,
|
|
746
|
+
capabilityProbe: options.capabilityProbe,
|
|
747
|
+
mcpConfigFile: path.join(runDir, 'mcp.json'),
|
|
748
|
+
deferUsageLimit: true,
|
|
749
|
+
quietStartup: true,
|
|
750
|
+
replyConfidence: true,
|
|
751
|
+
// The Claude token (dedicated install) for Claude runs only.
|
|
752
|
+
...(runtime === 'claude_code' ? { extraEnv: claudeTokenEnv(env) } : {}),
|
|
753
|
+
sessions: sessionsFor(agentId),
|
|
754
|
+
...(resumeNote ? { resumeNote } : {}),
|
|
755
|
+
spawnImpl: ((command, args, spawnOptions) => {
|
|
756
|
+
const real = spawnImpl ?? nodeSpawn;
|
|
757
|
+
const child = real(command, args, spawnOptions);
|
|
758
|
+
peak.addProcess(run.run_id, child.pid);
|
|
759
|
+
return child;
|
|
760
|
+
}),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
// ── Recovery: runs claimed before a restart and never reported ──────────
|
|
764
|
+
for (const stale of ledger.unfinished()) {
|
|
765
|
+
if (!stale.run_token || !stale.completion_path) {
|
|
766
|
+
ledger.completed(stale.run_id);
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
log(`${timestamp()} ↻ run ${stale.run_id.slice(0, 12)} was claimed before a restart; reporting it without re-running it`);
|
|
770
|
+
const api = { baseUrl: computer.base_url, apiKey: stale.run_token, fetchImpl: options.fetchImpl };
|
|
771
|
+
background.push(completeRun(api, stale.completion_path, { ok: false, error: 'the computer restarted before this run was reported' }).then((completed) => {
|
|
772
|
+
if (completed.ok || (completed.status >= 400 && completed.status < 500 && completed.status !== 429)) {
|
|
773
|
+
ledger.completed(stale.run_id);
|
|
774
|
+
}
|
|
775
|
+
}));
|
|
776
|
+
}
|
|
777
|
+
// ── Timers ──────────────────────────────────────────────────────────────
|
|
778
|
+
const statusLoop = setInterval(() => {
|
|
779
|
+
void stats.sample().then(() => {
|
|
780
|
+
updateHold();
|
|
781
|
+
void sendStatus();
|
|
782
|
+
});
|
|
783
|
+
}, options.statusIntervalMs ?? STATUS_INTERVAL_MS);
|
|
784
|
+
statusLoop.unref?.();
|
|
785
|
+
const loginLoop = setInterval(() => void loadLogins(), LOGIN_REFRESH_MS);
|
|
786
|
+
loginLoop.unref?.();
|
|
787
|
+
const controlLoop = setInterval(() => {
|
|
788
|
+
const paused = readPaused();
|
|
789
|
+
if (paused !== localPaused) {
|
|
790
|
+
localPaused = paused;
|
|
791
|
+
log(`${timestamp()} ${paused ? '⏸ paused on this computer' : '▶ resumed on this computer'}`);
|
|
792
|
+
updateHold();
|
|
793
|
+
}
|
|
794
|
+
}, CONTROL_POLL_MS);
|
|
795
|
+
controlLoop.unref?.();
|
|
796
|
+
const updateAvailable = options.updateAvailable ?? entrypointStamp();
|
|
797
|
+
const updateLoop = setInterval(() => {
|
|
798
|
+
if (!draining && updateAvailable()) {
|
|
799
|
+
log(`${timestamp()} ⟳ a newer prohost CLI is installed — finishing the runs going, then restarting`);
|
|
800
|
+
result.restart = true;
|
|
801
|
+
beginDrain();
|
|
802
|
+
}
|
|
803
|
+
}, options.updateCheckMs ?? UPDATE_CHECK_MS);
|
|
804
|
+
updateLoop.unref?.();
|
|
805
|
+
let drainResolve = () => { };
|
|
806
|
+
const drained = new Promise((resolve) => {
|
|
807
|
+
drainResolve = resolve;
|
|
808
|
+
});
|
|
809
|
+
function beginDrain() {
|
|
810
|
+
if (draining)
|
|
811
|
+
return;
|
|
812
|
+
draining = true;
|
|
813
|
+
// Queued runs were never claimed: ProhostAI hands them to another computer.
|
|
814
|
+
for (const queued of [...scheduler.queued()])
|
|
815
|
+
scheduler.remove(queued.runId);
|
|
816
|
+
statusChanged();
|
|
817
|
+
socket?.close();
|
|
818
|
+
drainResolve();
|
|
819
|
+
}
|
|
820
|
+
if (options.signal?.aborted)
|
|
821
|
+
beginDrain();
|
|
822
|
+
options.signal?.addEventListener('abort', () => beginDrain(), { once: true });
|
|
823
|
+
await stats.sample().catch(() => undefined);
|
|
824
|
+
updateHold();
|
|
825
|
+
void loadLogins();
|
|
826
|
+
await sendStatus();
|
|
827
|
+
// ── The event stream ─────────────────────────────────────────────────────
|
|
828
|
+
let connections = 0;
|
|
829
|
+
let attempt = 0;
|
|
830
|
+
while (!draining && !fatal && (options.maxConnections === undefined || connections < options.maxConnections)) {
|
|
831
|
+
connections += 1;
|
|
832
|
+
const authed = await connectOnce();
|
|
833
|
+
online = false;
|
|
834
|
+
writeLocalState();
|
|
835
|
+
if (draining || fatal)
|
|
836
|
+
break;
|
|
837
|
+
attempt = authed ? 1 : attempt + 1;
|
|
838
|
+
const delay = Math.floor(Math.random() * Math.min(RECONNECT_MAX_DELAY_MS, 1_000 * 2 ** Math.max(0, attempt - 1)));
|
|
839
|
+
log(`${timestamp()} … disconnected; reconnecting in ${(delay / 1000).toFixed(1)}s`);
|
|
840
|
+
await Promise.race([sleep(delay), drained]);
|
|
841
|
+
}
|
|
842
|
+
// Drain: nothing new starts; the runs going finish.
|
|
843
|
+
draining = true;
|
|
844
|
+
for (const queued of [...scheduler.queued()])
|
|
845
|
+
scheduler.remove(queued.runId);
|
|
846
|
+
await codeHost?.stop().catch(() => undefined);
|
|
847
|
+
// A real timer, never the injectable sleep: this waits on runs, not on a backoff.
|
|
848
|
+
while (active.size > 0)
|
|
849
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
850
|
+
await Promise.all(background);
|
|
851
|
+
clearInterval(statusLoop);
|
|
852
|
+
clearInterval(loginLoop);
|
|
853
|
+
clearInterval(controlLoop);
|
|
854
|
+
clearInterval(updateLoop);
|
|
855
|
+
clearInterval(pumpTimer);
|
|
856
|
+
if (statusTimer)
|
|
857
|
+
clearTimeout(statusTimer);
|
|
858
|
+
heartbeats.stop();
|
|
859
|
+
peak.stop();
|
|
860
|
+
online = false;
|
|
861
|
+
writeLocalState();
|
|
862
|
+
if (fatal) {
|
|
863
|
+
log(`${timestamp()} ✗ ${fatal}`);
|
|
864
|
+
result.fatal = fatal;
|
|
865
|
+
}
|
|
866
|
+
return result;
|
|
867
|
+
function connectOnce() {
|
|
868
|
+
const ws = new Ctor(computer.ws_url, { headers: { 'User-Agent': USER_AGENT }, handshakeTimeout: 30_000 });
|
|
869
|
+
socket = ws;
|
|
870
|
+
let authed = false;
|
|
871
|
+
return new Promise((resolve) => {
|
|
872
|
+
const liveness = new ServerLiveness({
|
|
873
|
+
timeoutMs: options.livenessTimeoutMs,
|
|
874
|
+
onSilence: () => {
|
|
875
|
+
log(`${timestamp()} … no word from ProhostAI — reconnecting`);
|
|
876
|
+
terminateSocket(ws);
|
|
877
|
+
},
|
|
878
|
+
});
|
|
879
|
+
ws.on('open', () => {
|
|
880
|
+
enableTcpKeepAlive(ws);
|
|
881
|
+
liveness.start();
|
|
882
|
+
ws.send(JSON.stringify({
|
|
883
|
+
type: 'auth',
|
|
884
|
+
api_key: computer.api_key,
|
|
885
|
+
machine,
|
|
886
|
+
machine_id: computer.machine_id,
|
|
887
|
+
client_version: CLI_VERSION,
|
|
888
|
+
...(computer.subscription_id ? { subscription_id: computer.subscription_id } : {}),
|
|
889
|
+
}));
|
|
890
|
+
});
|
|
891
|
+
ws.on('message', (raw) => {
|
|
892
|
+
let frame;
|
|
893
|
+
try {
|
|
894
|
+
frame = JSON.parse(raw.toString());
|
|
895
|
+
}
|
|
896
|
+
catch {
|
|
897
|
+
liveness.observe(undefined);
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
const f = (frame ?? {});
|
|
901
|
+
liveness.observe(f.type);
|
|
902
|
+
if (f.type === 'auth_ok') {
|
|
903
|
+
authed = true;
|
|
904
|
+
online = true;
|
|
905
|
+
log(`${timestamp()} ● connected as ${computer.name} — waiting for work`);
|
|
906
|
+
statusChanged();
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
if (f.type === 'auth_error') {
|
|
910
|
+
fatal = `auth_error: ${String(f.reason ?? 'unknown')}. Add this computer again: prohost host pair`;
|
|
911
|
+
ws.close();
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (f.type === 'ping') {
|
|
915
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (!isWebhookEvent(frame))
|
|
919
|
+
return;
|
|
920
|
+
const ack = (status, reason) => {
|
|
921
|
+
try {
|
|
922
|
+
ws.send(JSON.stringify({ type: 'ack', delivery_id: frame.delivery_id, status, ...(reason ? { reason } : {}) }));
|
|
923
|
+
}
|
|
924
|
+
catch {
|
|
925
|
+
/* best-effort */
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
const verified = options.allowUnverified === true || verifySignature(computer.webhook_secret, frame.payload, frame.signature);
|
|
929
|
+
if (frame.event === EVENT_AGENT_RUN_REQUESTED) {
|
|
930
|
+
if (!verified) {
|
|
931
|
+
log(`${timestamp()} ✗ rejected ${frame.event} — signature did not verify; not running it`);
|
|
932
|
+
ack(ACK_REJECTED);
|
|
933
|
+
return;
|
|
934
|
+
}
|
|
935
|
+
ack(...acceptRun(eventData(frame.payload)));
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (frame.event === EVENT_CODE_THREAD_START_REQUESTED ||
|
|
939
|
+
frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED ||
|
|
940
|
+
frame.event === EVENT_CODE_THREAD_APPROVAL_DECIDED) {
|
|
941
|
+
if (!codeHost || !config.terminal_sessions_enabled) {
|
|
942
|
+
ack(ACK_IGNORED);
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (!verified) {
|
|
946
|
+
log(`${timestamp()} ✗ rejected ${frame.event} — signature did not verify`);
|
|
947
|
+
ack(ACK_REJECTED);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
const data = eventData(frame.payload);
|
|
951
|
+
if (frame.event === EVENT_CODE_THREAD_START_REQUESTED && (localPaused || config.paused) && codeHost.failStart) {
|
|
952
|
+
// Paused: the start is reported failed, so the thread doesn't wait in `starting`.
|
|
953
|
+
background.push(codeHost
|
|
954
|
+
.failStart(data, PAUSED_START_REASON)
|
|
955
|
+
.then((reported) => ack(reported ? ACK_ACCEPTED : ACK_RETRY))
|
|
956
|
+
.catch(() => ack(ACK_RETRY)));
|
|
957
|
+
return;
|
|
958
|
+
}
|
|
959
|
+
if (!codeHost.admits(frame.event, data)) {
|
|
960
|
+
const refusal = codeHost.refusal(frame.event);
|
|
961
|
+
if (refusal)
|
|
962
|
+
ack(ACK_REFUSED, refusal);
|
|
963
|
+
else
|
|
964
|
+
ack(ACK_IGNORED);
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
const starting = frame.event === EVENT_CODE_THREAD_START_REQUESTED;
|
|
968
|
+
const handled = starting
|
|
969
|
+
? codeHost.handleStart(data)
|
|
970
|
+
: frame.event === EVENT_CODE_THREAD_INPUT_REQUESTED
|
|
971
|
+
? codeHost.handleInput(data, frame.delivery_id)
|
|
972
|
+
: codeHost.handleApproval(data, frame.delivery_id);
|
|
973
|
+
background.push(handled
|
|
974
|
+
.then((ok) => {
|
|
975
|
+
ack(starting || ok ? ACK_ACCEPTED : ACK_IGNORED);
|
|
976
|
+
statusChanged();
|
|
977
|
+
})
|
|
978
|
+
.catch((err) => {
|
|
979
|
+
if (err instanceof RefusedDeliveryError)
|
|
980
|
+
ack(ACK_REFUSED, err.reason);
|
|
981
|
+
else
|
|
982
|
+
ack(ACK_RETRY);
|
|
983
|
+
}));
|
|
984
|
+
return;
|
|
985
|
+
}
|
|
986
|
+
ack(ACK_IGNORED);
|
|
987
|
+
});
|
|
988
|
+
ws.on('close', () => {
|
|
989
|
+
liveness.stop();
|
|
990
|
+
if (socket === ws)
|
|
991
|
+
socket = undefined;
|
|
992
|
+
resolve(authed);
|
|
993
|
+
});
|
|
994
|
+
ws.on('error', (err) => {
|
|
995
|
+
if (!authed)
|
|
996
|
+
log(`${timestamp()} ✗ connection error: ${err.message}`);
|
|
997
|
+
});
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
/** Queue a run frame; the answer is the ack. */
|
|
1001
|
+
function acceptRun(data) {
|
|
1002
|
+
const runId = typeof data.run_id === 'string' ? data.run_id : '';
|
|
1003
|
+
const agentId = typeof data.agent_id === 'string' ? data.agent_id : '';
|
|
1004
|
+
if (!runId || !SAFE_ID.test(agentId))
|
|
1005
|
+
return [ACK_IGNORED];
|
|
1006
|
+
if (typeof data.computer_id === 'string' && data.computer_id !== computer.computer_id)
|
|
1007
|
+
return [ACK_IGNORED];
|
|
1008
|
+
if (scheduler.has(runId) || active.has(runId) || ledger.get(runId))
|
|
1009
|
+
return [ACK_ACCEPTED];
|
|
1010
|
+
if (draining)
|
|
1011
|
+
return [ACK_RETRY, 'draining'];
|
|
1012
|
+
if (!config.ai_employees_enabled)
|
|
1013
|
+
return [ACK_REFUSED, 'ai_employees_disabled'];
|
|
1014
|
+
scheduler.add({
|
|
1015
|
+
runId,
|
|
1016
|
+
agentId,
|
|
1017
|
+
lane: laneOf(data, runId),
|
|
1018
|
+
waiting: someoneWaiting(data),
|
|
1019
|
+
queuedAt: now(),
|
|
1020
|
+
data,
|
|
1021
|
+
claimAttempts: 0,
|
|
1022
|
+
notBefore: 0,
|
|
1023
|
+
});
|
|
1024
|
+
pump();
|
|
1025
|
+
return [ACK_ACCEPTED];
|
|
1026
|
+
}
|
|
1027
|
+
}
|