@prohost/cli 0.14.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 (69) hide show
  1. package/CHANGELOG.md +83 -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 +198 -22
  15. package/dist/codehost/history.d.ts +80 -0
  16. package/dist/codehost/history.js +98 -0
  17. package/dist/codehost/hooks.d.ts +10 -2
  18. package/dist/codehost/hooks.js +15 -4
  19. package/dist/codehost/host.d.ts +32 -0
  20. package/dist/codehost/host.js +26 -1
  21. package/dist/codehost/host_stats.d.ts +49 -2
  22. package/dist/codehost/host_stats.js +153 -2
  23. package/dist/codehost/relay.d.ts +16 -1
  24. package/dist/codehost/relay.js +69 -3
  25. package/dist/codehost/sizing.d.ts +22 -4
  26. package/dist/codehost/sizing.js +46 -6
  27. package/dist/codehost/terminals.d.ts +33 -2
  28. package/dist/codehost/terminals.js +92 -30
  29. package/dist/codehost/tmux.d.ts +6 -1
  30. package/dist/codehost/tmux.js +3 -0
  31. package/dist/host/api.d.ts +82 -0
  32. package/dist/host/api.js +122 -0
  33. package/dist/host/capacity.d.ts +41 -0
  34. package/dist/host/capacity.js +48 -0
  35. package/dist/host/command.d.ts +59 -0
  36. package/dist/host/command.js +584 -0
  37. package/dist/host/computer.d.ts +120 -0
  38. package/dist/host/computer.js +291 -0
  39. package/dist/host/events.d.ts +24 -0
  40. package/dist/host/events.js +47 -0
  41. package/dist/host/install.d.ts +152 -0
  42. package/dist/host/install.js +755 -0
  43. package/dist/host/ledger.d.ts +52 -0
  44. package/dist/host/ledger.js +131 -0
  45. package/dist/host/legacy.d.ts +38 -0
  46. package/dist/host/legacy.js +115 -0
  47. package/dist/host/logins.d.ts +109 -0
  48. package/dist/host/logins.js +190 -0
  49. package/dist/host/migrate.d.ts +165 -0
  50. package/dist/host/migrate.js +364 -0
  51. package/dist/host/pair.d.ts +62 -0
  52. package/dist/host/pair.js +219 -0
  53. package/dist/host/peak_memory.d.ts +44 -0
  54. package/dist/host/peak_memory.js +139 -0
  55. package/dist/host/scheduler.d.ts +86 -0
  56. package/dist/host/scheduler.js +186 -0
  57. package/dist/host/service.d.ts +114 -0
  58. package/dist/host/service.js +1027 -0
  59. package/dist/host/session_move.d.ts +48 -0
  60. package/dist/host/session_move.js +106 -0
  61. package/dist/host/skills.d.ts +29 -0
  62. package/dist/host/skills.js +135 -0
  63. package/dist/host/status.d.ts +91 -0
  64. package/dist/host/status.js +130 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +47 -3
  67. package/dist/version.d.ts +2 -2
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
@@ -0,0 +1,584 @@
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 os from 'node:os';
15
+ import path from 'node:path';
16
+ import process from 'node:process';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { spawn } from 'node:child_process';
19
+ import { createInterface } from 'node:readline';
20
+ import { baseUrlProblem } from '../agent/base-url.js';
21
+ import { DEFAULT_API_BASE_URL } from '../agent/contract.js';
22
+ import { prohostHome } from '../agent/credentials.js';
23
+ import { claudeQuotaProvider, codexQuotaProvider } from '../agent/runtime_status.js';
24
+ import { readPipedKey } from '../usage/key-input.js';
25
+ import { CLI_VERSION } from '../version.js';
26
+ import { recommendCapacity } from './capacity.js';
27
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
28
+ import { addAccount, isValidLabel, loadAccounts, parseAccountRuntime, accountEnv } from '../agent/accounts.js';
29
+ import { revokeComputer } from './api.js';
30
+ import { readSharedObject, acquireHostLock, clearComputer, computerOs, controlPath, hostLockPath, readObject, statePath, tryLoadComputer, writePrivateFile, } from './computer.js';
31
+ import { journalPath, readEvents } from './events.js';
32
+ import { LAUNCHD_HOST_LABEL, defaultLabel, installService, serviceFilePath, serviceUserHome, sharedBase, sharedDirFor, uninstallService, } from './install.js';
33
+ import { LegacyHarnesses } from './legacy.js';
34
+ import { detectLogins } from './logins.js';
35
+ import { adoptComputer, loadAdopted, runMigrate } from './migrate.js';
36
+ import { runHostPair, writeHostPairResult } from './pair.js';
37
+ import { RESTART_EXIT_CODE, runHostService } from './service.js';
38
+ import { STATE_STALE_MS, localLogin, localRunning, slotsFor } from './status.js';
39
+ function str(flags, key) {
40
+ return typeof flags[key] === 'string' ? flags[key] : undefined;
41
+ }
42
+ function scriptPath() {
43
+ return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'index.js');
44
+ }
45
+ /** A process with this pid is running. */
46
+ function alive(pid) {
47
+ if (typeof pid !== 'number' || pid <= 0)
48
+ return false;
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ return err.code === 'EPERM';
55
+ }
56
+ }
57
+ /** The shared folders of dedicated services on this computer (`--label` picks one). */
58
+ function sharedDirs(base, label) {
59
+ if (label)
60
+ return [path.join(base, label)];
61
+ try {
62
+ return readdirSync(base)
63
+ .map((name) => path.join(base, name))
64
+ .filter((dir) => existsSync(path.join(dir, 'state.json')) || existsSync(path.join(dir, 'control.json')));
65
+ }
66
+ catch {
67
+ return [];
68
+ }
69
+ }
70
+ /** The freshest dedicated service's state, as the logged-in user sees it. */
71
+ function dedicatedState(base, now, label) {
72
+ let best;
73
+ for (const dir of sharedDirs(base, label)) {
74
+ const state = readSharedObject(path.join(dir, 'state.json'));
75
+ const at = typeof state?.updatedAt === 'string' ? Date.parse(state.updatedAt) : Number.NaN;
76
+ if (!state || !(now - at < STATE_STALE_MS))
77
+ continue;
78
+ if (!best || at > Date.parse(String(best.state.updatedAt)))
79
+ best = { dir, state };
80
+ }
81
+ return best;
82
+ }
83
+ export function hardwareFacts() {
84
+ const cpus = os.cpus();
85
+ return {
86
+ memory_gb: Math.round(os.totalmem() / 1024 ** 3),
87
+ cpu_cores: cpus.length,
88
+ os: computerOs(),
89
+ chip: cpus[0]?.model?.trim() || null,
90
+ };
91
+ }
92
+ /**
93
+ * `host status --json`: what the desktop app and scripts read. Always an
94
+ * answer — `{paired: false, …}` before pairing. When this home runs no
95
+ * service but a dedicated one runs on the computer, that one is reported
96
+ * (`service.dedicated`), from the state it publishes for the logged-in user.
97
+ */
98
+ export function statusJson(env, now = Date.now(), options = {}) {
99
+ const computer = tryLoadComputer(env);
100
+ const own = readObject(statePath(env));
101
+ const ownFresh = own !== undefined && now - Date.parse(own.updatedAt) < STATE_STALE_MS;
102
+ // `--label` asks for that service, even when this home runs one of its own.
103
+ const shared = ownFresh && !options.label
104
+ ? undefined
105
+ : dedicatedState(options.sharedBase ?? sharedBase(options.platform ?? process.platform), now, options.label);
106
+ const state = shared ? shared.state : own;
107
+ const fresh = ownFresh || shared !== undefined;
108
+ const sharedComputer = shared?.state.computer;
109
+ const paused = readObject(controlPath(env))?.paused === true ||
110
+ (shared !== undefined && readObject(path.join(shared.dir, 'control.json'))?.paused === true) ||
111
+ (fresh && state?.paused === true);
112
+ const recommended = state?.recommended_capacity ?? recommendCapacity({ memoryGb: os.totalmem() / 1024 ** 3, cores: os.cpus().length });
113
+ const capacity = state?.capacity ?? recommended;
114
+ const lock = readObject(hostLockPath(env));
115
+ const events = shared ? readEvents(path.join(shared.dir, 'events.json'), now) : readEvents(journalPath(env), now);
116
+ return {
117
+ // Reporting the dedicated service: its own computer, never this home's (they can differ).
118
+ paired: shared ? sharedComputer !== undefined : computer !== null,
119
+ computer: shared
120
+ ? (sharedComputer ?? null)
121
+ : computer
122
+ ? { id: computer.computer_id, name: (fresh && state?.name) || computer.name, account: computer.account }
123
+ : null,
124
+ online: Boolean(fresh && state?.online),
125
+ paused,
126
+ slots: fresh && state ? state.slots : slotsFor(capacity, 0, paused ? 'paused' : null),
127
+ running: fresh && state ? state.running.map(localRunning) : [],
128
+ logins: (state?.logins ?? []).map(localLogin),
129
+ capacity,
130
+ recommended_capacity: recommended,
131
+ hardware: hardwareFacts(),
132
+ events,
133
+ service: {
134
+ running: shared !== undefined || alive(lock?.pid),
135
+ pid: shared ? (shared.state.pid ?? null) : alive(lock?.pid) ? lock?.pid : null,
136
+ adopted: loadAdopted(env)?.homes.length ?? 0,
137
+ dedicated: shared !== undefined,
138
+ ...(shared ? { activity_file: path.join(shared.dir, 'activity.json') } : {}),
139
+ },
140
+ version: CLI_VERSION,
141
+ };
142
+ }
143
+ function printStatus(json, out) {
144
+ const computer = json.computer;
145
+ if (!computer) {
146
+ out('This computer is not added to a workspace yet. Run: prohost host pair');
147
+ return;
148
+ }
149
+ const slots = json.slots;
150
+ out(`${computer.name}${computer.account.name ? ` · ${computer.account.name}` : ''}`);
151
+ out(` ${json.online ? 'online' : 'offline'}${json.paused ? ' · paused' : ''}${slots.held_reason && !json.paused ? ` · holding (${slots.held_reason})` : ''}`);
152
+ out(` works on up to ${String(json.capacity)} things at once (recommended ${String(json.recommended_capacity)}); ${slots.busy} going`);
153
+ for (const run of json.running) {
154
+ out(` • ${run.kind === 'terminal' ? 'terminal session' : (run.agent.name ?? 'AI employee')}${run.title ? ` — ${run.title}` : ''}`);
155
+ }
156
+ }
157
+ function printLogins(logins, out) {
158
+ if (logins.length === 0) {
159
+ out('No AI logins found. Sign in with `claude auth login` or `codex login`.');
160
+ return;
161
+ }
162
+ for (const login of logins) {
163
+ const runtime = login.runtime === 'claude_code' ? 'Claude Code' : 'Codex';
164
+ const plan = login.plan_label ? ` · ${login.plan_label}` : '';
165
+ const who = login.email_local ? ` · ${login.email_local}` : '';
166
+ out(`${login.label} (${runtime}${plan}${who}) — ${login.auth_state === 'ok' ? 'signed in' : login.auth_state}`);
167
+ }
168
+ }
169
+ function defaultDetect(env) {
170
+ return () => detectLogins({
171
+ env,
172
+ includeEmail: true,
173
+ quota: (record) => {
174
+ const accountEnv = () => record.dir ? (record.runtime === 'claude_code' ? { CLAUDE_CONFIG_DIR: record.dir } : { CODEX_HOME: record.dir }) : {};
175
+ return record.runtime === 'claude_code'
176
+ ? claudeQuotaProvider({ binary: 'claude', env: accountEnv })
177
+ : codexQuotaProvider({ binary: 'codex', clientVersion: CLI_VERSION, env: accountEnv });
178
+ },
179
+ });
180
+ }
181
+ /** Ask on the terminal; only an explicit yes is yes. */
182
+ function askYesNo(question) {
183
+ return new Promise((resolve) => {
184
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
185
+ rl.question(question, (answer) => {
186
+ rl.close();
187
+ resolve(/^y(es)?$/i.test(answer.trim()));
188
+ });
189
+ });
190
+ }
191
+ /** The personal service a home would have: its LaunchAgent / systemd user unit, by the home's own label. */
192
+ function personalServiceOf(platform, home, sudoUser) {
193
+ if (platform === 'win32')
194
+ return undefined;
195
+ const userHome = sudoUser ? path.dirname(homeOfUser(platform, sudoUser)) : os.homedir();
196
+ const spec = {
197
+ platform,
198
+ dedicated: false,
199
+ nodePath: '',
200
+ scriptPath: '',
201
+ home,
202
+ pathEnv: '',
203
+ userHome,
204
+ label: defaultLabel(home, path.join(userHome, '.prohost')),
205
+ };
206
+ return { label: spec.label, file: serviceFilePath(spec) };
207
+ }
208
+ /** `~/.prohost` of another account (the one that ran sudo). */
209
+ function homeOfUser(platform, user) {
210
+ if (platform === 'darwin')
211
+ return path.join('/Users', user, '.prohost');
212
+ try {
213
+ const entry = readFileSync('/etc/passwd', 'utf8')
214
+ .split('\n')
215
+ .find((line) => line.startsWith(`${user}:`));
216
+ const home = entry?.split(':')[5];
217
+ if (home)
218
+ return path.join(home, '.prohost');
219
+ }
220
+ catch {
221
+ /* fall through */
222
+ }
223
+ return path.join('/home', user, '.prohost');
224
+ }
225
+ /** `claude auth login` / `codex login`, in this terminal, as the new login. */
226
+ const defaultSignIn = (command, args, env) => new Promise((resolve) => {
227
+ const child = spawn(command, args, { stdio: 'inherit', env: { ...process.env, ...env } });
228
+ child.on('error', () => resolve(null));
229
+ child.on('exit', (code) => resolve(code));
230
+ });
231
+ /** The next free label for a runtime's new login: `claude-2`, `codex-3`, … */
232
+ function nextLabel(runtime, env) {
233
+ const base = runtime === 'claude_code' ? 'claude' : 'codex';
234
+ const taken = new Set(loadAccounts(env).map((a) => a.label));
235
+ for (let n = 2;; n += 1)
236
+ if (!taken.has(`${base}-${n}`))
237
+ return `${base}-${n}`;
238
+ }
239
+ /**
240
+ * `host logins add --runtime claude_code|codex [--label <l>]`: a new AI login
241
+ * in its own config directory, signed in right here in this terminal.
242
+ */
243
+ async function addLogin(flags, env, out, err, io) {
244
+ const runtime = parseAccountRuntime(str(flags, 'runtime'));
245
+ if (!runtime) {
246
+ err('host logins add needs --runtime claude_code or --runtime codex.');
247
+ return 1;
248
+ }
249
+ const label = str(flags, 'label') ?? nextLabel(runtime, env);
250
+ if (!isValidLabel(label)) {
251
+ err(`"${label}" is not a usable label (up to 32 letters, digits, "-" or "_").`);
252
+ return 1;
253
+ }
254
+ let record;
255
+ try {
256
+ record = addAccount({ label, runtime }, env);
257
+ }
258
+ catch (e) {
259
+ err(`✗ ${e instanceof Error ? e.message : String(e)}`);
260
+ return 1;
261
+ }
262
+ out(`Signing in a new ${runtime === 'claude_code' ? 'Claude Code' : 'Codex'} login ("${label}")…`);
263
+ const [command, ...args] = runtime === 'claude_code' ? ['claude', 'auth', 'login'] : ['codex', 'login'];
264
+ const code = await (io.signIn ?? defaultSignIn)(command, args, accountEnv(record));
265
+ if (code !== 0) {
266
+ err(`✗ The sign-in did not finish (exit ${String(code)}). The login "${label}" is kept: sign it in later with the same command.`);
267
+ return 1;
268
+ }
269
+ out(`✓ Added "${label}". It shows in prohost host logins.`);
270
+ return 0;
271
+ }
272
+ export async function runHost(subcommand, flags, printHelp, io = {}) {
273
+ const env = io.env ?? process.env;
274
+ const out = io.out ?? ((line) => process.stdout.write(`${line}\n`));
275
+ const err = io.err ?? ((line) => process.stderr.write(`${line}\n`));
276
+ const platform = io.platform ?? process.platform;
277
+ switch (subcommand) {
278
+ case 'pair': {
279
+ const baseUrl = str(flags, 'base-url') ?? env.PROHOST_BASE_URL ?? DEFAULT_API_BASE_URL;
280
+ const problem = baseUrlProblem(baseUrl);
281
+ if (problem) {
282
+ const result = { version: 1, outcome: 'invalid_arguments', computer_id: null, account: null, message: `--base-url rejected: ${problem}` };
283
+ writeHostPairResult(result, env, err);
284
+ err(`✗ ${result.message}`);
285
+ return 1;
286
+ }
287
+ let token = str(flags, 'token') ?? env.PROHOST_REDEEM_TOKEN;
288
+ if (flags.token === true) {
289
+ const result = { version: 1, outcome: 'invalid_arguments', computer_id: null, account: null, message: '--token needs a value (or - to read it from stdin).' };
290
+ writeHostPairResult(result, env, err);
291
+ err(`✗ ${result.message}`);
292
+ return 1;
293
+ }
294
+ if (token === '-')
295
+ token = (await readPipedKey((io.stdin ?? process.stdin))) ?? '';
296
+ const controller = new AbortController();
297
+ const onSig = () => controller.abort();
298
+ process.once('SIGINT', onSig);
299
+ try {
300
+ const result = await runHostPair({
301
+ baseUrl,
302
+ name: str(flags, 'name'),
303
+ ...(token !== undefined ? { token } : {}),
304
+ expectAccount: str(flags, 'expect-account') ?? env.PROHOST_EXPECT_ACCOUNT,
305
+ env,
306
+ // A person at a terminal confirms the workspace; a program relies on --expect-account.
307
+ ...((io.interactive ?? Boolean(process.stdin.isTTY && process.stdout.isTTY))
308
+ ? {
309
+ confirm: (account) => (io.ask ?? askYesNo)(`Add this computer to ${account.name ?? account.id}? [y/N] `),
310
+ }
311
+ : {}),
312
+ fetchImpl: io.fetchImpl,
313
+ log: out,
314
+ signal: controller.signal,
315
+ });
316
+ writeHostPairResult(result, env, err);
317
+ (result.outcome === 'paired' ? out : err)(`${result.outcome === 'paired' ? '✓' : '✗'} ${result.message}`);
318
+ if (result.outcome === 'paired') {
319
+ out(' Next: prohost host install (keeps it running in the background)');
320
+ }
321
+ return result.outcome === 'paired' ? 0 : 1;
322
+ }
323
+ finally {
324
+ process.off('SIGINT', onSig);
325
+ }
326
+ }
327
+ case 'run': {
328
+ const computer = tryLoadComputer(env);
329
+ const adopted = loadAdopted(env);
330
+ if (!computer && !adopted?.homes.length) {
331
+ err('This computer is not added to a workspace yet. Run: prohost host pair');
332
+ return 1;
333
+ }
334
+ const drain = new AbortController();
335
+ const hard = new AbortController();
336
+ const onSignal = () => {
337
+ if (drain.signal.aborted) {
338
+ out('Stopping now.');
339
+ hard.abort();
340
+ return;
341
+ }
342
+ out('Finishing the runs going, then stopping (press again to stop now).');
343
+ drain.abort();
344
+ };
345
+ process.on('SIGINT', onSignal);
346
+ process.on('SIGTERM', onSignal);
347
+ const legacy = adopted ? new LegacyHarnesses(adopted.homes, { env, log: out, machine: str(flags, 'machine') }) : undefined;
348
+ try {
349
+ if (!computer) {
350
+ const lock = acquireHostLock(env);
351
+ if (!lock.ok) {
352
+ err(`Another prohost host service (pid ${lock.heldBy}) is already running for this computer.`);
353
+ return 1;
354
+ }
355
+ try {
356
+ legacy.sync(new Set(), false);
357
+ await new Promise((resolve) => drain.signal.addEventListener('abort', () => resolve(), { once: true }));
358
+ return 0;
359
+ }
360
+ finally {
361
+ lock.release();
362
+ }
363
+ }
364
+ const codeHostHome = computer.code_host_home ?? adopted?.code_host_home ?? undefined;
365
+ const withCodeHost = codeHostHome ? { ...computer, code_host_home: codeHostHome } : computer;
366
+ legacy?.sync(new Set(), true);
367
+ const timeout = Number(str(flags, 'timeout'));
368
+ const idle = Number(str(flags, 'idle-timeout'));
369
+ const workload = str(flags, 'workload');
370
+ const result = await runHostService({
371
+ computer: withCodeHost,
372
+ env,
373
+ log: out,
374
+ signal: drain.signal,
375
+ hardStop: hard.signal,
376
+ ...(str(flags, 'machine') ? { machine: str(flags, 'machine') } : {}),
377
+ ...(Number.isFinite(timeout) && str(flags, 'timeout') ? { timeoutMs: timeout * 1000 } : {}),
378
+ ...(Number.isFinite(idle) && str(flags, 'idle-timeout') ? { idleTimeoutMs: idle * 1000 } : {}),
379
+ ...(workload === 'coding' || workload === 'chat' ? { workload: workload } : {}),
380
+ allowUnverified: flags['allow-unverified'] === true,
381
+ workdirFor: (agentId) => adopted?.homes.find((home) => home.agent_id === agentId && !home.host_only)?.hosted_workdir,
382
+ onConfig: (config) => legacy?.sync(new Set(config.placements.map((p) => p.agent_id)), true),
383
+ });
384
+ if (result.fatal)
385
+ return 1;
386
+ return result.restart ? RESTART_EXIT_CODE : 0;
387
+ }
388
+ finally {
389
+ await legacy?.stop();
390
+ process.off('SIGINT', onSignal);
391
+ process.off('SIGTERM', onSignal);
392
+ }
393
+ }
394
+ case 'install':
395
+ case 'uninstall': {
396
+ const dedicated = flags.dedicated === true;
397
+ const serviceUser = str(flags, 'service-user');
398
+ const ownHome = prohostHome(env);
399
+ const home = dedicated
400
+ ? (str(flags, 'service-home') ?? (serviceUser ? serviceUserHome(platform, serviceUser) : ownHome))
401
+ : ownHome;
402
+ const userHome = env.HOME || os.homedir();
403
+ const spec = {
404
+ platform,
405
+ dedicated,
406
+ ...(serviceUser ? { serviceUser } : {}),
407
+ // The executable that runs this CLI now — Node, or the desktop app's Electron.
408
+ nodePath: process.execPath,
409
+ scriptPath: scriptPath(),
410
+ home,
411
+ pathEnv: env.PATH ?? '',
412
+ userHome,
413
+ label: str(flags, 'label') ?? defaultLabel(home, path.join(userHome, '.prohost')),
414
+ electron: io.electron ?? (Boolean(process.versions.electron) || env.ELECTRON_RUN_AS_NODE === '1'),
415
+ };
416
+ const tokenFile = str(flags, 'claude-token-file');
417
+ if (flags['claude-token-file'] === true) {
418
+ err('--claude-token-file needs a path.');
419
+ return 1;
420
+ }
421
+ if (subcommand === 'uninstall') {
422
+ // A dedicated service is labelled after its own home: without --label,
423
+ // every one this computer publishes state for is looked at too.
424
+ const labels = new Set([spec.label]);
425
+ const admin = platform === 'win32' ? dedicated : (io.serviceIo?.uid ?? process.getuid?.() ?? 1) === 0;
426
+ if (!str(flags, 'label') && (admin || dedicated)) {
427
+ for (const dir of sharedDirs(io.sharedBase ?? sharedBase(platform)))
428
+ labels.add(path.basename(dir));
429
+ }
430
+ const outcomes = [...labels].map((label) => uninstallService({ ...spec, label }, io.serviceIo));
431
+ const removed = outcomes.filter((o) => !o.message.startsWith('No host service'));
432
+ const shown = removed.length > 0 ? removed : outcomes.slice(0, 1);
433
+ for (const o of shown)
434
+ (o.ok ? out : err)(o.message);
435
+ return shown.every((o) => o.ok) ? 0 : 1;
436
+ }
437
+ // The invoking user's home: under sudo, $PROHOST_HOME and $HOME are root's.
438
+ const adoptFrom = str(flags, 'from-home') ??
439
+ (env.SUDO_USER && !env.PROHOST_HOME ? homeOfUser(platform, env.SUDO_USER) : ownHome);
440
+ const outcome = installService(spec, io.serviceIo, {
441
+ ...(tokenFile ? { claudeTokenFile: tokenFile } : {}),
442
+ ...(dedicated ? { adoptFrom } : {}),
443
+ });
444
+ (outcome.ok ? out : err)(outcome.message);
445
+ if (outcome.ok && !tryLoadComputer({ ...env, PROHOST_HOME: home })) {
446
+ out(dedicated
447
+ ? ` ! ${home} has no computer yet (none in ${adoptFrom}): pair as yourself, then run this again — or pass --from-home <your PROHOST_HOME>.`
448
+ : ` This home (${home}) has no computer yet: run prohost host pair.`);
449
+ }
450
+ if (outcome.ok && dedicated) {
451
+ out(` Its status, for anyone on this computer: ${sharedDirFor(spec)}`);
452
+ out(` Remove it with: sudo prohost host uninstall --label ${spec.label}`);
453
+ // The personal service of the home it took the computer from runs on the same key.
454
+ const personal = personalServiceOf(platform, adoptFrom, env.SUDO_USER);
455
+ if (personal && (io.serviceIo?.exists ?? existsSync)(personal.file)) {
456
+ out(` ! Your own service (${personal.label}) still runs this computer too — two services on one key. ` +
457
+ `Remove it as yourself: prohost host uninstall${personal.label === LAUNCHD_HOST_LABEL ? '' : ` --label ${personal.label}`}`);
458
+ }
459
+ }
460
+ return outcome.ok ? 0 : 1;
461
+ }
462
+ case 'unpair': {
463
+ const computer = tryLoadComputer(env);
464
+ if (!computer) {
465
+ out('This computer is not added to a workspace. Nothing to remove.');
466
+ return 0;
467
+ }
468
+ const revoked = await revokeComputer({ baseUrl: computer.base_url, apiKey: computer.api_key, fetchImpl: io.fetchImpl }, computer.computer_id);
469
+ if (revoked.result === 'failed') {
470
+ err(`✗ ProhostAI did not remove this computer (${revoked.status === 0 ? revoked.error ?? 'no answer' : `HTTP ${revoked.status}`}). ` +
471
+ 'Its key is kept; try again, or remove it on its page in ProhostAI.');
472
+ return 1;
473
+ }
474
+ clearComputer(env);
475
+ out(revoked.result === 'revoked' ? `✓ Removed "${computer.name}" from its workspace.` : '✓ This computer was already removed; its key is deleted here.');
476
+ out(' If its background service is installed, remove it too: prohost host uninstall');
477
+ return 0;
478
+ }
479
+ case 'status': {
480
+ const label = str(flags, 'label');
481
+ const json = statusJson(env, (io.now ?? Date.now)(), {
482
+ platform,
483
+ ...(io.sharedBase ? { sharedBase: io.sharedBase } : {}),
484
+ ...(label ? { label } : {}),
485
+ });
486
+ if (flags.json === true)
487
+ out(JSON.stringify(json, null, 2));
488
+ else
489
+ printStatus(json, out);
490
+ return 0;
491
+ }
492
+ case 'logins': {
493
+ if (io.args?.[0] === 'add')
494
+ return addLogin(flags, env, out, err, io);
495
+ const logins = await (io.detectLogins ?? defaultDetect(env))();
496
+ if (flags.json === true)
497
+ out(JSON.stringify({ logins: logins.map(localLogin) }, null, 2));
498
+ else
499
+ printLogins(logins, out);
500
+ return 0;
501
+ }
502
+ case 'pause':
503
+ case 'resume': {
504
+ const paused = subcommand === 'pause';
505
+ const control = { v: 1, paused, updated_at: new Date().toISOString() };
506
+ writePrivateFile(controlPath(env), control);
507
+ // A dedicated service on this computer reads its own shared switch (writable by everyone on it).
508
+ const label = str(flags, 'label');
509
+ for (const dir of sharedDirs(io.sharedBase ?? sharedBase(platform), label)) {
510
+ try {
511
+ writeFileSync(path.join(dir, 'control.json'), `${JSON.stringify(control)}\n`);
512
+ }
513
+ catch (e) {
514
+ err(` ! could not ${subcommand} the service in ${dir}: ${e instanceof Error ? e.message : String(e)}`);
515
+ }
516
+ }
517
+ out(paused
518
+ ? '⏸ Paused: this computer takes no new work. Work already going finishes. Resume with: prohost host resume'
519
+ : '▶ Resumed: this computer takes new work again.');
520
+ return 0;
521
+ }
522
+ case 'migrate': {
523
+ const homes = str(flags, 'home')?.split(',').map((h) => h.trim()).filter(Boolean);
524
+ const from = (io.repeated?.from ?? (str(flags, 'from') ? [str(flags, 'from')] : []))
525
+ .flatMap((h) => h.split(','))
526
+ .map((h) => h.trim())
527
+ .filter(Boolean);
528
+ if (flags.from === true) {
529
+ err('--from needs a home directory.');
530
+ return 1;
531
+ }
532
+ const report = runMigrate({
533
+ env,
534
+ ...(homes ? { homes } : {}),
535
+ ...(from.length > 0 ? { only: from } : {}),
536
+ keepServices: flags['keep-services'] === true,
537
+ dryRun: flags['dry-run'] === true,
538
+ ...io.migrate,
539
+ });
540
+ // The computer credential, from an adopted agent key: no pairing again.
541
+ const computerAdoption = report.refused || flags['dry-run'] === true || report.adopted.length === 0
542
+ ? undefined
543
+ : await adoptComputer({ env, report, fetchImpl: io.fetchImpl });
544
+ if (flags.json === true) {
545
+ out(JSON.stringify({ ...report, ...(computerAdoption ? { computer: computerAdoption } : {}) }, null, 2));
546
+ return report.refused ? 1 : 0;
547
+ }
548
+ if (report.refused) {
549
+ err(`✗ Not migrated: ${report.refused}.`);
550
+ return 1;
551
+ }
552
+ if (computerAdoption?.status === 'adopted') {
553
+ out(`✓ This computer is now "${computerAdoption.account.name ?? computerAdoption.account.id}"'s computer (no new pairing).`);
554
+ }
555
+ else if (computerAdoption?.status === 'not_adopted') {
556
+ out(` · Not added as a computer yet: ${computerAdoption.reason}`);
557
+ }
558
+ if (report.adopted.length === 0)
559
+ out('Nothing to adopt: no agent home with an agent.json was found.');
560
+ for (const home of report.adopted) {
561
+ out(`✓ ${home.agent_name} (${home.home})${home.host_only ? ' — terminal sessions' : ''}` +
562
+ (home.sessions_copied ? ` — ${home.sessions_copied} conversation sessions carried over` : ''));
563
+ }
564
+ if (report.codeHostHome)
565
+ out(` Terminal sessions are hosted from ${report.codeHostHome}.`);
566
+ for (const label of report.servicesStopped)
567
+ out(` Stopped and removed the old ${label} launchd agent.`);
568
+ for (const skipped of report.skipped)
569
+ err(` ! ${skipped.home}: ${skipped.reason}`);
570
+ for (const note of report.notes)
571
+ out(` · ${note}`);
572
+ if (flags['dry-run'] === true)
573
+ out('(dry run: nothing was changed)');
574
+ else
575
+ out('Next: prohost host install (or restart it, if it is already installed)');
576
+ return 0;
577
+ }
578
+ default:
579
+ if (subcommand)
580
+ err(`Unknown host command: ${subcommand}\n`);
581
+ printHelp();
582
+ return 1;
583
+ }
584
+ }