@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,120 @@
1
+ /**
2
+ * `prohost host`'s files, and the computer credential.
3
+ *
4
+ * Everything the host service keeps lives under `$PROHOST_HOME/host`:
5
+ *
6
+ * - `computer.json` — the computer credential from `host pair` (0600).
7
+ * - `state.json` — what the running service last saw (slots, runs, logins),
8
+ * for `host status` and the desktop app; local only, 0600.
9
+ * - `activity.json` — busy / running, for the menu bar.
10
+ * - `control.json` — `host pause` / `resume`, read by the running service.
11
+ * - `adopted.json` — what `host migrate` adopted.
12
+ * - `employees/<agent_id>/` — one home per employee placed here: its
13
+ * sessions, its workspace (and the skills written into it).
14
+ * - `runs.json` — the run ledger, keyed by employee and conversation.
15
+ */
16
+ import os from 'node:os';
17
+ import type { ProhostAccount } from '../agent/credentials.js';
18
+ export declare function hostDir(env?: NodeJS.ProcessEnv): string;
19
+ export declare function computerPath(env?: NodeJS.ProcessEnv): string;
20
+ export declare function statePath(env?: NodeJS.ProcessEnv): string;
21
+ export declare function hostActivityPath(env?: NodeJS.ProcessEnv): string;
22
+ export declare function controlPath(env?: NodeJS.ProcessEnv): string;
23
+ export declare function adoptedPath(env?: NodeJS.ProcessEnv): string;
24
+ export declare function hostLedgerPath(env?: NodeJS.ProcessEnv): string;
25
+ export declare function hostLogPath(env?: NodeJS.ProcessEnv): string;
26
+ export declare function hostLockPath(env?: NodeJS.ProcessEnv): string;
27
+ /**
28
+ * Hold this home's host service lock: one `host run` per home. A second one
29
+ * would load the same ledger and report the first one's live runs as
30
+ * unfinished. A lock whose process is gone is taken over. Returns a release
31
+ * function, or the pid that holds it.
32
+ */
33
+ export declare function acquireHostLock(env?: NodeJS.ProcessEnv, pid?: number, isAlive?: (pid: number) => boolean, now?: () => number): {
34
+ ok: true;
35
+ release: () => void;
36
+ } | {
37
+ ok: false;
38
+ heldBy: number;
39
+ };
40
+ /** One employee's home: its sessions and workspace. The id is checked before it names a path. */
41
+ export declare function employeeHome(agentId: string, env?: NodeJS.ProcessEnv): string;
42
+ /** Write JSON atomically (temp file + rename) and owner-only. */
43
+ export declare function writePrivateFile(file: string, value: unknown): void;
44
+ /**
45
+ * Write JSON readable by everyone on the computer: a dedicated service's
46
+ * state for the logged-in user (never a secret, never an email). Written in
47
+ * place — the folder is root's, only the file is the service's — so a reader
48
+ * can catch it mid-write: read it with {@link readSharedObject}.
49
+ */
50
+ export declare function writeSharedFile(file: string, value: unknown): void;
51
+ /** {@link readObject} for a file written in place: a read caught mid-write is tried once more. */
52
+ export declare function readSharedObject(file: string): Record<string, unknown> | undefined;
53
+ /** The JSON object at `file`, or `undefined` for a missing, unreadable or non-object file. */
54
+ export declare function readObject(file: string): Record<string, unknown> | undefined;
55
+ /** The computer credential, as `host pair` stored it. */
56
+ export interface ComputerCredentials {
57
+ version: 1;
58
+ computer_id: string;
59
+ account: ProhostAccount;
60
+ api_key: string;
61
+ webhook_secret: string | null;
62
+ /** The event subscription the stream is pinned to; sent on the socket's auth frame. */
63
+ subscription_id?: string | null;
64
+ ws_url: string;
65
+ mcp_url: string;
66
+ /** The public API base the credential belongs to. */
67
+ base_url: string;
68
+ /** The id this machine paired with (see `machineFacts`). */
69
+ machine_id: string;
70
+ name: string;
71
+ paired_at: string;
72
+ /**
73
+ * The `PROHOST_HOME` whose Code-thread host this service runs — the
74
+ * desktop app's, when `host migrate` adopted it — so its terminals, folders
75
+ * and switches carry over. Unset: the service's own home.
76
+ */
77
+ code_host_home?: string;
78
+ }
79
+ export declare class ComputerMissingError extends Error {
80
+ }
81
+ export declare function saveComputer(credentials: ComputerCredentials, env?: NodeJS.ProcessEnv): string;
82
+ export declare function loadComputer(env?: NodeJS.ProcessEnv): ComputerCredentials;
83
+ /** The `claude setup-token` token a dedicated install put in this home (`host/claude-oauth-token`). */
84
+ export declare function readClaudeToken(env?: NodeJS.ProcessEnv): string | undefined;
85
+ /**
86
+ * The environment that gives a Claude Code process the installed token —
87
+ * passed to Claude runs, Claude terminal sessions and the Claude login checks
88
+ * only, never to the host's own environment (a Codex run must not see it).
89
+ * Re-read each time, so a rotated token applies without a restart.
90
+ */
91
+ export declare function claudeTokenEnv(env?: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
92
+ /** Delete the computer credential (after the server removed the computer). */
93
+ export declare function clearComputer(env?: NodeJS.ProcessEnv): void;
94
+ export declare function tryLoadComputer(env?: NodeJS.ProcessEnv): ComputerCredentials | null;
95
+ export type ComputerOs = 'macos' | 'windows' | 'linux';
96
+ /** What pairing tells the server about this machine. Never the user's name or email. */
97
+ export interface MachineFacts {
98
+ name: string;
99
+ hostname: string;
100
+ machine_id: string;
101
+ os: ComputerOs;
102
+ os_version: string;
103
+ arch: string;
104
+ chip: string | null;
105
+ memory_gb: number;
106
+ cpu_cores: number;
107
+ }
108
+ export declare function computerOs(platform?: NodeJS.Platform): ComputerOs;
109
+ /** A friendly default name: "This Mac" on a Mac is the UI's job; here it is the short hostname. */
110
+ export declare function defaultComputerName(hostname?: string): string;
111
+ export declare function machineFacts(options?: {
112
+ env?: NodeJS.ProcessEnv;
113
+ name?: string;
114
+ platform?: NodeJS.Platform;
115
+ hostname?: string;
116
+ release?: string;
117
+ arch?: string;
118
+ cpus?: () => os.CpuInfo[];
119
+ totalmem?: () => number;
120
+ }): MachineFacts;
@@ -0,0 +1,291 @@
1
+ /**
2
+ * `prohost host`'s files, and the computer credential.
3
+ *
4
+ * Everything the host service keeps lives under `$PROHOST_HOME/host`:
5
+ *
6
+ * - `computer.json` — the computer credential from `host pair` (0600).
7
+ * - `state.json` — what the running service last saw (slots, runs, logins),
8
+ * for `host status` and the desktop app; local only, 0600.
9
+ * - `activity.json` — busy / running, for the menu bar.
10
+ * - `control.json` — `host pause` / `resume`, read by the running service.
11
+ * - `adopted.json` — what `host migrate` adopted.
12
+ * - `employees/<agent_id>/` — one home per employee placed here: its
13
+ * sessions, its workspace (and the skills written into it).
14
+ * - `runs.json` — the run ledger, keyed by employee and conversation.
15
+ */
16
+ import { chmodSync, existsSync, linkSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
17
+ import os from 'node:os';
18
+ import path from 'node:path';
19
+ import { machineId } from '../agent/accounts.js';
20
+ import { prohostHome } from '../agent/credentials.js';
21
+ export function hostDir(env = process.env) {
22
+ return path.join(prohostHome(env), 'host');
23
+ }
24
+ export function computerPath(env = process.env) {
25
+ return path.join(hostDir(env), 'computer.json');
26
+ }
27
+ export function statePath(env = process.env) {
28
+ return path.join(hostDir(env), 'state.json');
29
+ }
30
+ export function hostActivityPath(env = process.env) {
31
+ return path.join(hostDir(env), 'activity.json');
32
+ }
33
+ export function controlPath(env = process.env) {
34
+ return path.join(hostDir(env), 'control.json');
35
+ }
36
+ export function adoptedPath(env = process.env) {
37
+ return path.join(hostDir(env), 'adopted.json');
38
+ }
39
+ export function hostLedgerPath(env = process.env) {
40
+ return path.join(hostDir(env), 'runs.json');
41
+ }
42
+ export function hostLogPath(env = process.env) {
43
+ return path.join(hostDir(env), 'host.log');
44
+ }
45
+ export function hostLockPath(env = process.env) {
46
+ return path.join(hostDir(env), 'service.lock');
47
+ }
48
+ function alive(pid) {
49
+ try {
50
+ process.kill(pid, 0);
51
+ return true;
52
+ }
53
+ catch (err) {
54
+ // EPERM: it exists, it just isn't ours to signal.
55
+ return err.code === 'EPERM';
56
+ }
57
+ }
58
+ /**
59
+ * Hold this home's host service lock: one `host run` per home. A second one
60
+ * would load the same ledger and report the first one's live runs as
61
+ * unfinished. A lock whose process is gone is taken over. Returns a release
62
+ * function, or the pid that holds it.
63
+ */
64
+ export function acquireHostLock(env = process.env, pid = process.pid, isAlive = alive, now = Date.now) {
65
+ const file = hostLockPath(env);
66
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
67
+ // Written whole beside the lock, then hard-linked into place: the link is
68
+ // atomic and fails if a lock exists, so a lock is never seen half written.
69
+ const mine = `${file}.${pid}.${Math.random().toString(16).slice(2)}`;
70
+ writeFileSync(mine, `${JSON.stringify({ pid, started_at: new Date(now()).toISOString() })}\n`, { mode: 0o600 });
71
+ try {
72
+ for (let attempt = 0; attempt < 3; attempt += 1) {
73
+ try {
74
+ linkSync(mine, file);
75
+ return {
76
+ ok: true,
77
+ release: () => {
78
+ if (readObject(file)?.pid === pid)
79
+ rmSync(file, { force: true });
80
+ },
81
+ };
82
+ }
83
+ catch (err) {
84
+ if (err.code !== 'EEXIST')
85
+ throw err;
86
+ }
87
+ const holder = readObject(file)?.pid;
88
+ // Our own pid holding it is a service already running in this process.
89
+ if (typeof holder === 'number' && (holder === pid || isAlive(holder)))
90
+ return { ok: false, heldBy: holder };
91
+ if (typeof holder !== 'number') {
92
+ // Unreadable: give a writer a moment before calling it abandoned.
93
+ let age = Number.POSITIVE_INFINITY;
94
+ try {
95
+ age = now() - statSync(file).mtimeMs;
96
+ }
97
+ catch {
98
+ continue;
99
+ }
100
+ if (age < STALE_LOCK_GRACE_MS)
101
+ return { ok: false, heldBy: -1 };
102
+ }
103
+ // Abandoned. Only one start at a time may clear it (`mkdir` is atomic),
104
+ // and it looks again once it may: a lock that is live by then stays.
105
+ const reclaim = `${file}.reclaim`;
106
+ try {
107
+ mkdirSync(reclaim);
108
+ writeFileSync(path.join(reclaim, 'pid'), String(pid), { mode: 0o600 });
109
+ }
110
+ catch {
111
+ // Another start is clearing it. Its mutex is broken only once that
112
+ // start is dead — a paused one (a sleeping laptop) still holds it.
113
+ let reclaimer;
114
+ let age = 0;
115
+ try {
116
+ reclaimer = Number.parseInt(readFileSync(path.join(reclaim, 'pid'), 'utf8'), 10);
117
+ }
118
+ catch {
119
+ reclaimer = undefined;
120
+ }
121
+ try {
122
+ age = now() - statSync(reclaim).mtimeMs;
123
+ }
124
+ catch {
125
+ continue;
126
+ }
127
+ const dead = reclaimer !== undefined && Number.isFinite(reclaimer)
128
+ ? reclaimer !== pid && !isAlive(reclaimer)
129
+ : age > STALE_LOCK_GRACE_MS;
130
+ if (!dead)
131
+ return { ok: false, heldBy: -1 };
132
+ rmSync(reclaim, { recursive: true, force: true });
133
+ continue;
134
+ }
135
+ try {
136
+ // Gone already (another start cleared it and may be linking its own
137
+ // right now): nothing to clear — removing now could take theirs.
138
+ if (!existsSync(file))
139
+ continue;
140
+ const current = readObject(file)?.pid;
141
+ if (typeof current === 'number' && (current === pid || isAlive(current)))
142
+ return { ok: false, heldBy: current };
143
+ // Still the abandoned lock: while it exists nobody can link a new one,
144
+ // and only a holder of this mutex removes it, so this is the one judged.
145
+ rmSync(file, { force: true });
146
+ }
147
+ finally {
148
+ rmSync(reclaim, { recursive: true, force: true });
149
+ }
150
+ }
151
+ return { ok: false, heldBy: -1 };
152
+ }
153
+ finally {
154
+ rmSync(mine, { force: true });
155
+ }
156
+ }
157
+ /** An unreadable lock younger than this may still be being written. */
158
+ const STALE_LOCK_GRACE_MS = 10_000;
159
+ const SAFE_ID = /^[A-Za-z0-9-]{1,64}$/;
160
+ /** One employee's home: its sessions and workspace. The id is checked before it names a path. */
161
+ export function employeeHome(agentId, env = process.env) {
162
+ if (!SAFE_ID.test(agentId))
163
+ throw new Error(`not an employee id: ${JSON.stringify(agentId)}`);
164
+ return path.join(hostDir(env), 'employees', agentId);
165
+ }
166
+ /** Write JSON atomically (temp file + rename) and owner-only. */
167
+ export function writePrivateFile(file, value) {
168
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
169
+ const temp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
170
+ try {
171
+ writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
172
+ chmodSync(temp, 0o600);
173
+ renameSync(temp, file);
174
+ }
175
+ catch (err) {
176
+ rmSync(temp, { force: true });
177
+ throw err;
178
+ }
179
+ }
180
+ /**
181
+ * Write JSON readable by everyone on the computer: a dedicated service's
182
+ * state for the logged-in user (never a secret, never an email). Written in
183
+ * place — the folder is root's, only the file is the service's — so a reader
184
+ * can catch it mid-write: read it with {@link readSharedObject}.
185
+ */
186
+ export function writeSharedFile(file, value) {
187
+ // In place: the folder is root's (a dedicated install made it), the file the service's.
188
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o644 });
189
+ }
190
+ /** {@link readObject} for a file written in place: a read caught mid-write is tried once more. */
191
+ export function readSharedObject(file) {
192
+ const first = readObject(file);
193
+ if (first !== undefined || !existsSync(file))
194
+ return first;
195
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
196
+ return readObject(file);
197
+ }
198
+ /** The JSON object at `file`, or `undefined` for a missing, unreadable or non-object file. */
199
+ export function readObject(file) {
200
+ try {
201
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
202
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
203
+ ? parsed
204
+ : undefined;
205
+ }
206
+ catch {
207
+ return undefined;
208
+ }
209
+ }
210
+ export class ComputerMissingError extends Error {
211
+ }
212
+ export function saveComputer(credentials, env = process.env) {
213
+ const file = computerPath(env);
214
+ writePrivateFile(file, credentials);
215
+ return file;
216
+ }
217
+ export function loadComputer(env = process.env) {
218
+ const file = computerPath(env);
219
+ const raw = readObject(file);
220
+ if (!raw)
221
+ throw new ComputerMissingError(`This computer isn't added yet (no ${file}). Run: prohost host pair`);
222
+ if (typeof raw.api_key !== 'string' || !raw.api_key || typeof raw.computer_id !== 'string' || !raw.computer_id) {
223
+ throw new ComputerMissingError(`${file} has no computer key. Add the computer again: prohost host pair`);
224
+ }
225
+ return raw;
226
+ }
227
+ /** The `claude setup-token` token a dedicated install put in this home (`host/claude-oauth-token`). */
228
+ export function readClaudeToken(env = process.env) {
229
+ try {
230
+ // Windows dedicated installs keep it in an administrators' folder instead.
231
+ const file = env.PROHOST_CLAUDE_TOKEN_FILE || path.join(hostDir(env), 'claude-oauth-token');
232
+ const token = readFileSync(file, 'utf8').trim();
233
+ return token || undefined;
234
+ }
235
+ catch {
236
+ return undefined;
237
+ }
238
+ }
239
+ /**
240
+ * The environment that gives a Claude Code process the installed token —
241
+ * passed to Claude runs, Claude terminal sessions and the Claude login checks
242
+ * only, never to the host's own environment (a Codex run must not see it).
243
+ * Re-read each time, so a rotated token applies without a restart.
244
+ */
245
+ export function claudeTokenEnv(env = process.env) {
246
+ const token = readClaudeToken(env);
247
+ return token ? { CLAUDE_CODE_OAUTH_TOKEN: token } : {};
248
+ }
249
+ /** Delete the computer credential (after the server removed the computer). */
250
+ export function clearComputer(env = process.env) {
251
+ rmSync(computerPath(env), { force: true });
252
+ }
253
+ export function tryLoadComputer(env = process.env) {
254
+ try {
255
+ return loadComputer(env);
256
+ }
257
+ catch {
258
+ return null;
259
+ }
260
+ }
261
+ export function computerOs(platform = process.platform) {
262
+ if (platform === 'darwin')
263
+ return 'macos';
264
+ if (platform === 'win32')
265
+ return 'windows';
266
+ return 'linux';
267
+ }
268
+ /** A friendly default name: "This Mac" on a Mac is the UI's job; here it is the short hostname. */
269
+ export function defaultComputerName(hostname = os.hostname()) {
270
+ const short = hostname.split('.')[0]?.trim();
271
+ return short || 'My computer';
272
+ }
273
+ export function machineFacts(options = {}) {
274
+ const env = options.env ?? process.env;
275
+ const id = machineId(env);
276
+ if (!id)
277
+ throw new Error(`could not store this machine's id under ${env.PROHOST_ACCOUNTS_ROOT ?? '~/.prohost-accounts'}`);
278
+ const hostname = options.hostname ?? os.hostname();
279
+ const cpus = (options.cpus ?? os.cpus)();
280
+ return {
281
+ name: (options.name ?? '').trim() || defaultComputerName(hostname),
282
+ hostname,
283
+ machine_id: id,
284
+ os: computerOs(options.platform),
285
+ os_version: options.release ?? os.release(),
286
+ arch: options.arch ?? process.arch,
287
+ chip: cpus[0]?.model?.trim() || null,
288
+ memory_gb: Math.round((options.totalmem ?? os.totalmem)() / 1024 ** 3),
289
+ cpu_cores: cpus.length,
290
+ };
291
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The host's own record of the events it sent (`login_resting`, `login_moved`,
3
+ * `hold_started`, …) for the last 24 hours, so the desktop app can raise a
4
+ * notification for each once (`host status --json` → `events`, deduped by `id`).
5
+ */
6
+ export declare const EVENT_RETENTION_MS: number;
7
+ export declare const MAX_EVENTS = 200;
8
+ export interface HostEvent {
9
+ id: string;
10
+ kind: string;
11
+ /** ISO time it was sent. */
12
+ at: string;
13
+ [field: string]: unknown;
14
+ }
15
+ export declare function journalPath(env?: NodeJS.ProcessEnv): string;
16
+ /** Events in a file, newest last, from the last 24 h. */
17
+ export declare function readEvents(file: string, now?: number): HostEvent[];
18
+ export declare class EventJournal {
19
+ private readonly now;
20
+ private readonly file;
21
+ constructor(env?: NodeJS.ProcessEnv, now?: () => number);
22
+ add(event: Record<string, unknown>): HostEvent;
23
+ list(): HostEvent[];
24
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The host's own record of the events it sent (`login_resting`, `login_moved`,
3
+ * `hold_started`, …) for the last 24 hours, so the desktop app can raise a
4
+ * notification for each once (`host status --json` → `events`, deduped by `id`).
5
+ */
6
+ import { randomUUID } from 'node:crypto';
7
+ import path from 'node:path';
8
+ import { hostDir, readObject, writePrivateFile } from './computer.js';
9
+ export const EVENT_RETENTION_MS = 24 * 60 * 60_000;
10
+ export const MAX_EVENTS = 200;
11
+ export function journalPath(env = process.env) {
12
+ return path.join(hostDir(env), 'events.json');
13
+ }
14
+ /** Events in a file, newest last, from the last 24 h. */
15
+ export function readEvents(file, now = Date.now()) {
16
+ const raw = readObject(file)?.events;
17
+ if (!Array.isArray(raw))
18
+ return [];
19
+ return raw.filter((e) => !!e &&
20
+ typeof e === 'object' &&
21
+ typeof e.id === 'string' &&
22
+ typeof e.kind === 'string' &&
23
+ typeof e.at === 'string' &&
24
+ now - Date.parse(e.at) < EVENT_RETENTION_MS);
25
+ }
26
+ export class EventJournal {
27
+ now;
28
+ file;
29
+ constructor(env = process.env, now = Date.now) {
30
+ this.now = now;
31
+ this.file = journalPath(env);
32
+ }
33
+ add(event) {
34
+ const entry = { ...event, id: randomUUID(), kind: String(event.kind), at: new Date(this.now()).toISOString() };
35
+ const events = [...this.list(), entry].slice(-MAX_EVENTS);
36
+ try {
37
+ writePrivateFile(this.file, { v: 1, events });
38
+ }
39
+ catch {
40
+ /* a convenience: the event itself went to ProhostAI */
41
+ }
42
+ return entry;
43
+ }
44
+ list() {
45
+ return readEvents(this.file, this.now());
46
+ }
47
+ }
@@ -0,0 +1,152 @@
1
+ /**
2
+ * `prohost host install` / `uninstall` — the background service, per OS,
3
+ * installed by the CLI itself.
4
+ *
5
+ * | | Personal (default) | Dedicated (`--dedicated --service-user <name>`) |
6
+ * | --- | --- | --- |
7
+ * | macOS | LaunchAgent `ai.prohost.host` (runs while you're logged in) | LaunchDaemon in `/Library/LaunchDaemons`, `UserName` = the service user |
8
+ * | Linux | systemd user unit `prohost-host.service` | system unit in `/etc/systemd/system`, `User=` the service user |
9
+ * | Windows | Task Scheduler task at logon | Task Scheduler task at boot running as the service user (S4U: no password stored) |
10
+ *
11
+ * Dedicated mode keeps working when no one is logged in; it must be run as
12
+ * root / an administrator. On Windows it is a boot-start scheduled task rather
13
+ * than a Windows service: Node cannot answer the Service Control Manager
14
+ * without a native wrapper, and an S4U task gives the same "runs whether or
15
+ * not anyone is logged on" without storing a password.
16
+ *
17
+ * Every variant runs `node <cli> host run` with this shell's `PATH` and the
18
+ * service's `PROHOST_HOME`, restarts it when it exits (an update drains and
19
+ * exits on purpose), and appends to `$PROHOST_HOME/host/host.log`.
20
+ */
21
+ export declare const LAUNCHD_HOST_LABEL = "ai.prohost.host";
22
+ export declare const SYSTEMD_HOST_UNIT = "prohost-host.service";
23
+ export declare const WINDOWS_TASK_NAME = "\\ProhostAI\\Host";
24
+ /** launchd / systemd wait this long after SIGTERM: the service drains (finishes runs going) first. */
25
+ export declare const STOP_TIMEOUT_SECONDS = 600;
26
+ /**
27
+ * How long a macOS re-install waits for the previous service to leave launchd
28
+ * before bootstrapping the new one: its whole drain, plus a margin. Bootstrapping
29
+ * sooner fails, and would leave the computer with no service once the old one exits.
30
+ */
31
+ export declare const RESTART_WAIT_MS: number;
32
+ /** Set in a dedicated service's environment: where it publishes its state for the logged-in user. */
33
+ export declare const SHARED_DIR_ENV = "PROHOST_HOST_SHARED_DIR";
34
+ /** The `claude setup-token` token a dedicated install hands the service user (read by `host run`). */
35
+ export declare const CLAUDE_TOKEN_FILE = "claude-oauth-token";
36
+ export type ServiceRunner = (command: string, args: string[], input?: string) => {
37
+ status: number | null;
38
+ stdout: string;
39
+ stderr: string;
40
+ };
41
+ export declare const defaultServiceRunner: ServiceRunner;
42
+ export interface ServiceSpec {
43
+ platform: NodeJS.Platform;
44
+ dedicated: boolean;
45
+ /** Dedicated only: the account the service runs as. */
46
+ serviceUser?: string;
47
+ /** The executable that runs the CLI: Node, or the desktop app's Electron binary (see `electron`). */
48
+ nodePath: string;
49
+ scriptPath: string;
50
+ /** `PROHOST_HOME` for the service. */
51
+ home: string;
52
+ /** `PATH` for the service (the launching process's). */
53
+ pathEnv: string;
54
+ /** The installing user's home (personal mode file locations). */
55
+ userHome: string;
56
+ /**
57
+ * The service's label (launchd label; systemd unit and Windows task are named
58
+ * after it). Default {@link LAUNCHD_HOST_LABEL} for the default home, else one
59
+ * derived from the home (see {@link defaultLabel}), so two homes never replace
60
+ * each other's service.
61
+ */
62
+ label?: string;
63
+ /**
64
+ * `nodePath` is Electron running as Node (the desktop app's bundled CLI): the
65
+ * service keeps `ELECTRON_RUN_AS_NODE=1`, or launching it would open the app.
66
+ */
67
+ electron?: boolean;
68
+ }
69
+ /** The label for a home: the plain one for the default home, else one with a short digest of the home. */
70
+ export declare function defaultLabel(home: string, defaultHome: string): string;
71
+ export declare function labelOf(spec: Pick<ServiceSpec, 'label'>): string;
72
+ export declare function systemdUnitOf(spec: Pick<ServiceSpec, 'label'>): string;
73
+ export declare function windowsTaskOf(spec: Pick<ServiceSpec, 'label'>): string;
74
+ export declare function serviceArgs(spec: ServiceSpec): string[];
75
+ /** Where a dedicated service publishes its state, read by `host status` as the logged-in user. */
76
+ export declare function sharedBase(platform: NodeJS.Platform): string;
77
+ export declare function sharedDirFor(spec: Pick<ServiceSpec, 'platform' | 'label'>): string;
78
+ /**
79
+ * Where the service logs. Personal: `$PROHOST_HOME/host/host.log`. Dedicated:
80
+ * never under the service user's home — the service manager opens the log as
81
+ * root, and a link planted there would point root at any file. macOS: a
82
+ * root-owned `/Library/Logs/Prohost`; Windows: an ACL'd `ProgramData` folder;
83
+ * Linux: the journal (`undefined`: no file).
84
+ */
85
+ export declare function logPathFor(spec: ServiceSpec): string | undefined;
86
+ /** The environment the service runs with. */
87
+ export declare function serviceEnv(spec: ServiceSpec): Record<string, string>;
88
+ /** Where the service definition lives. */
89
+ export declare function serviceFilePath(spec: ServiceSpec): string;
90
+ export declare function buildHostPlist(spec: ServiceSpec): string;
91
+ export declare function buildHostSystemdUnit(spec: ServiceSpec): string;
92
+ /** The `.cmd` the Windows task runs: sets the environment, then the CLI, logging to its log file. */
93
+ export declare function buildWindowsWrapper(spec: ServiceSpec): string;
94
+ export declare function windowsWrapperPath(spec: ServiceSpec): string;
95
+ /** Where a Windows dedicated install keeps its task definition and wrapper: administrators write, the account reads. */
96
+ export declare function windowsServiceDir(spec: Pick<ServiceSpec, 'label'>): string;
97
+ /** Task Scheduler XML: at logon (personal) or at boot as the service user via S4U (dedicated). */
98
+ export declare function buildWindowsTaskXml(spec: ServiceSpec, currentUser: string): string;
99
+ export interface InstallOutcome {
100
+ ok: boolean;
101
+ message: string;
102
+ file?: string;
103
+ }
104
+ export interface InstallIo {
105
+ run?: ServiceRunner;
106
+ uid?: number;
107
+ isAdmin?: boolean;
108
+ currentUser?: string;
109
+ writeFile?: (file: string, content: string | Buffer, mode: number, flag?: 'w' | 'wx') => void;
110
+ readFile?: (file: string) => string | undefined;
111
+ /** Not a plain file of its own: a link, a folder, or a hard link (`lstat`). */
112
+ isLink?: (file: string) => boolean;
113
+ /** A symbolic link or junction (`lstat`). */
114
+ isSymlink?: (file: string) => boolean;
115
+ /** Make one folder (its parent exists): `exists` when it already did — never check-then-make. */
116
+ mkdirOnce?: (dir: string) => 'created' | 'exists';
117
+ /** Owner and link-ness of a path (`lstat`). */
118
+ ownerOf?: (file: string) => {
119
+ uid: number;
120
+ link: boolean;
121
+ } | undefined;
122
+ removeFile?: (file: string) => void;
123
+ exists?: (file: string) => boolean;
124
+ mkdir?: (dir: string, mode: number) => void;
125
+ /** Seams for waiting on a re-install's previous service to stop (macOS). */
126
+ sleep?: (ms: number) => void;
127
+ now?: () => number;
128
+ }
129
+ /** What a dedicated install sets up for the service user, beyond the service itself. */
130
+ export interface DedicatedSetup {
131
+ /** A 0600 file holding a `claude setup-token` token: installed for the service user, never printed. */
132
+ claudeTokenFile?: string;
133
+ /** The invoking user's `PROHOST_HOME`: its computer credential is copied into the service home if that has none. */
134
+ adoptFrom?: string;
135
+ }
136
+ /** Validate a spec before anything is written. */
137
+ export declare function specProblem(spec: ServiceSpec, io?: InstallIo, setup?: DedicatedSetup): string | undefined;
138
+ /** Where a Windows dedicated install keeps the Claude token: an administrators' folder the account may only read. */
139
+ export declare function windowsTokenPath(spec: Pick<ServiceSpec, 'label'>): string;
140
+ /**
141
+ * Create or take over the ProgramData folders a Windows dedicated install
142
+ * uses, top-down: each one is checked for a junction, owned by
143
+ * Administrators, and given exactly its ACL, before the next one inside it.
144
+ */
145
+ /** The principals `icacls <dir>` lists (`BUILTIN\\Administrators:(OI)(CI)(F)` → `Administrators`). */
146
+ export declare function aclPrincipals(icaclsOutput: string, dir: string): string[];
147
+ /** The files a dedicated service publishes in its shared folder, and the pause switch. */
148
+ export declare const SHARED_FILES: readonly ["state.json", "activity.json", "events.json"];
149
+ export declare function installService(spec: ServiceSpec, io?: InstallIo, setup?: DedicatedSetup): InstallOutcome;
150
+ export declare function uninstallService(spec: ServiceSpec, io?: InstallIo): InstallOutcome;
151
+ /** Where a dedicated service user's `PROHOST_HOME` is by default. */
152
+ export declare function serviceUserHome(platform: NodeJS.Platform, user: string): string;