@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,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move a conversation's session from one AI login to another of the same kind.
|
|
3
|
+
*
|
|
4
|
+
* Used only when sharing between logins is on for this computer and the
|
|
5
|
+
* conversation's login rests (its usage limit). The session file is copied —
|
|
6
|
+
* never moved — so the first login keeps its own history, and the run resumes
|
|
7
|
+
* on the second with a note that it was interrupted.
|
|
8
|
+
*
|
|
9
|
+
* - Claude Code keeps a session at `<config dir>/projects/<project>/<id>.jsonl`
|
|
10
|
+
* (plus a `<id>/` folder for subagents and large tool results), where
|
|
11
|
+
* `<project>` is the working directory with every non-alphanumeric character
|
|
12
|
+
* turned into `-`.
|
|
13
|
+
* - Codex keeps a rollout at `<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-…-<id>.jsonl`.
|
|
14
|
+
* A `CODEX_HOME` inside WSL (`wsl://<distro>/home/…`, or the
|
|
15
|
+
* `\\wsl.localhost\…` / `\\wsl$\…` share) is reached through the share.
|
|
16
|
+
*
|
|
17
|
+
* Claude and Codex are never mixed: a copy between runtimes is refused.
|
|
18
|
+
*/
|
|
19
|
+
import type { AccountRuntime } from '../agent/accounts.js';
|
|
20
|
+
export declare const INTERRUPTED_NOTE: string;
|
|
21
|
+
export type SessionMoveResult = {
|
|
22
|
+
ok: true;
|
|
23
|
+
copied: string[];
|
|
24
|
+
} | {
|
|
25
|
+
ok: false;
|
|
26
|
+
reason: string;
|
|
27
|
+
};
|
|
28
|
+
/** Claude Code's project folder name for a working directory. */
|
|
29
|
+
export declare function claudeProjectSlug(cwd: string): string;
|
|
30
|
+
/**
|
|
31
|
+
* A login directory as this process reaches it. On Windows, a WSL home given
|
|
32
|
+
* as `wsl://<distro>/<path>` becomes `\\wsl.localhost\<distro>\<path>`; the
|
|
33
|
+
* share forms are used as they are.
|
|
34
|
+
*/
|
|
35
|
+
export declare function reachableDir(dir: string, platform?: NodeJS.Platform): string;
|
|
36
|
+
export interface SessionMoveRequest {
|
|
37
|
+
runtime: AccountRuntime;
|
|
38
|
+
/** Runtime of the login being copied to. Must equal `runtime`. */
|
|
39
|
+
targetRuntime: AccountRuntime;
|
|
40
|
+
fromDir: string;
|
|
41
|
+
toDir: string;
|
|
42
|
+
sessionId: string;
|
|
43
|
+
/** The run's working directory (Claude Code names the project after it). */
|
|
44
|
+
cwd: string;
|
|
45
|
+
platform?: NodeJS.Platform;
|
|
46
|
+
}
|
|
47
|
+
/** Copy a session so the target login can resume it. Never overwrites the target's newer copy. */
|
|
48
|
+
export declare function copySession(request: SessionMoveRequest): SessionMoveResult;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Move a conversation's session from one AI login to another of the same kind.
|
|
3
|
+
*
|
|
4
|
+
* Used only when sharing between logins is on for this computer and the
|
|
5
|
+
* conversation's login rests (its usage limit). The session file is copied —
|
|
6
|
+
* never moved — so the first login keeps its own history, and the run resumes
|
|
7
|
+
* on the second with a note that it was interrupted.
|
|
8
|
+
*
|
|
9
|
+
* - Claude Code keeps a session at `<config dir>/projects/<project>/<id>.jsonl`
|
|
10
|
+
* (plus a `<id>/` folder for subagents and large tool results), where
|
|
11
|
+
* `<project>` is the working directory with every non-alphanumeric character
|
|
12
|
+
* turned into `-`.
|
|
13
|
+
* - Codex keeps a rollout at `<CODEX_HOME>/sessions/YYYY/MM/DD/rollout-…-<id>.jsonl`.
|
|
14
|
+
* A `CODEX_HOME` inside WSL (`wsl://<distro>/home/…`, or the
|
|
15
|
+
* `\\wsl.localhost\…` / `\\wsl$\…` share) is reached through the share.
|
|
16
|
+
*
|
|
17
|
+
* Claude and Codex are never mixed: a copy between runtimes is refused.
|
|
18
|
+
*/
|
|
19
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { findCodexRollout } from '../agent/runtime_status.js';
|
|
22
|
+
export const INTERRUPTED_NOTE = 'Note: you were interrupted partway through this work and are continuing it from another sign-in. ' +
|
|
23
|
+
"Check what's already done — messages sent, files changed, tasks updated — before doing anything again.";
|
|
24
|
+
/** Claude Code's project folder name for a working directory. */
|
|
25
|
+
export function claudeProjectSlug(cwd) {
|
|
26
|
+
return cwd.replace(/[^A-Za-z0-9]/g, '-');
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* A login directory as this process reaches it. On Windows, a WSL home given
|
|
30
|
+
* as `wsl://<distro>/<path>` becomes `\\wsl.localhost\<distro>\<path>`; the
|
|
31
|
+
* share forms are used as they are.
|
|
32
|
+
*/
|
|
33
|
+
export function reachableDir(dir, platform = process.platform) {
|
|
34
|
+
const match = /^wsl:\/\/([^/]+)(\/.*)$/.exec(dir);
|
|
35
|
+
if (!match)
|
|
36
|
+
return dir;
|
|
37
|
+
if (platform !== 'win32')
|
|
38
|
+
return match[2];
|
|
39
|
+
return `\\\\wsl.localhost\\${match[1]}${match[2].replace(/\//g, '\\')}`;
|
|
40
|
+
}
|
|
41
|
+
const SESSION_ID = /^[0-9A-Za-z-]{8,64}$/;
|
|
42
|
+
function isDir(p) {
|
|
43
|
+
try {
|
|
44
|
+
return statSync(p).isDirectory();
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Where a Claude session's file is under one config dir: the expected project first, then any. */
|
|
51
|
+
function findClaudeSession(configDir, sessionId, cwd) {
|
|
52
|
+
const projects = path.join(configDir, 'projects');
|
|
53
|
+
const expected = path.join(projects, claudeProjectSlug(cwd), `${sessionId}.jsonl`);
|
|
54
|
+
if (existsSync(expected))
|
|
55
|
+
return expected;
|
|
56
|
+
let names = [];
|
|
57
|
+
try {
|
|
58
|
+
names = readdirSync(projects);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
const candidate = path.join(projects, name, `${sessionId}.jsonl`);
|
|
65
|
+
if (existsSync(candidate))
|
|
66
|
+
return candidate;
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
/** Copy a session so the target login can resume it. Never overwrites the target's newer copy. */
|
|
71
|
+
export function copySession(request) {
|
|
72
|
+
if (request.runtime !== request.targetRuntime) {
|
|
73
|
+
return { ok: false, reason: 'Claude Code and Codex sessions never move between each other' };
|
|
74
|
+
}
|
|
75
|
+
if (!SESSION_ID.test(request.sessionId))
|
|
76
|
+
return { ok: false, reason: 'not a session id' };
|
|
77
|
+
const from = reachableDir(request.fromDir, request.platform);
|
|
78
|
+
const to = reachableDir(request.toDir, request.platform);
|
|
79
|
+
if (path.resolve(from) === path.resolve(to))
|
|
80
|
+
return { ok: false, reason: 'the same login' };
|
|
81
|
+
if (request.runtime === 'claude_code') {
|
|
82
|
+
const file = findClaudeSession(from, request.sessionId, request.cwd);
|
|
83
|
+
if (!file)
|
|
84
|
+
return { ok: false, reason: 'the session file is not in the first login' };
|
|
85
|
+
const project = path.basename(path.dirname(file));
|
|
86
|
+
const targetDir = path.join(to, 'projects', project);
|
|
87
|
+
mkdirSync(targetDir, { recursive: true, mode: 0o700 });
|
|
88
|
+
const copied = [path.join(targetDir, `${request.sessionId}.jsonl`)];
|
|
89
|
+
cpSync(file, copied[0], { force: true });
|
|
90
|
+
const sidecar = path.join(path.dirname(file), request.sessionId);
|
|
91
|
+
if (isDir(sidecar)) {
|
|
92
|
+
const target = path.join(targetDir, request.sessionId);
|
|
93
|
+
cpSync(sidecar, target, { recursive: true, force: true });
|
|
94
|
+
copied.push(target);
|
|
95
|
+
}
|
|
96
|
+
return { ok: true, copied };
|
|
97
|
+
}
|
|
98
|
+
const rollout = findCodexRollout(request.sessionId, { CODEX_HOME: from });
|
|
99
|
+
if (!rollout)
|
|
100
|
+
return { ok: false, reason: 'the rollout is not in the first login' };
|
|
101
|
+
const relative = path.relative(from, rollout);
|
|
102
|
+
const target = path.join(to, relative);
|
|
103
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
104
|
+
cpSync(rollout, target, { force: true });
|
|
105
|
+
return { ok: true, copied: [target] };
|
|
106
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write an employee's skills into its workspace before a run.
|
|
3
|
+
*
|
|
4
|
+
* - Claude Code reads project skills from `.claude/skills/<key>/SKILL.md` in
|
|
5
|
+
* its working directory.
|
|
6
|
+
* - Codex reads project instructions from `AGENTS.md`; the skills go to
|
|
7
|
+
* `.codex/skills/<key>/` and `AGENTS.md` lists them, so the agent opens the
|
|
8
|
+
* one it needs.
|
|
9
|
+
*
|
|
10
|
+
* The claim's list is the employee's whole set: a skill the host wrote before
|
|
11
|
+
* that is no longer granted is removed. Only folders the host wrote (named in
|
|
12
|
+
* its manifest) are ever removed — anything else in the workspace is left alone.
|
|
13
|
+
*/
|
|
14
|
+
import type { AccountRuntime } from '../agent/accounts.js';
|
|
15
|
+
import type { ClaimedSkill } from './api.js';
|
|
16
|
+
export declare function skillsRoot(workspace: string, runtime: AccountRuntime): string;
|
|
17
|
+
/** A file path inside a skill: relative, no `..`, no absolute or drive paths. */
|
|
18
|
+
export declare function safeSkillPath(relative: string): string | undefined;
|
|
19
|
+
export interface SkillsWritten {
|
|
20
|
+
written: string[];
|
|
21
|
+
skipped: Array<{
|
|
22
|
+
key: string;
|
|
23
|
+
reason: string;
|
|
24
|
+
}>;
|
|
25
|
+
removed: string[];
|
|
26
|
+
}
|
|
27
|
+
/** Replace (or add) the host's block in `AGENTS.md`, keeping anything the operator wrote. */
|
|
28
|
+
export declare function agentsMdWithSkills(existing: string, keys: string[]): string;
|
|
29
|
+
export declare function writeSkills(workspace: string, runtime: AccountRuntime, skills: readonly ClaimedSkill[]): SkillsWritten;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write an employee's skills into its workspace before a run.
|
|
3
|
+
*
|
|
4
|
+
* - Claude Code reads project skills from `.claude/skills/<key>/SKILL.md` in
|
|
5
|
+
* its working directory.
|
|
6
|
+
* - Codex reads project instructions from `AGENTS.md`; the skills go to
|
|
7
|
+
* `.codex/skills/<key>/` and `AGENTS.md` lists them, so the agent opens the
|
|
8
|
+
* one it needs.
|
|
9
|
+
*
|
|
10
|
+
* The claim's list is the employee's whole set: a skill the host wrote before
|
|
11
|
+
* that is no longer granted is removed. Only folders the host wrote (named in
|
|
12
|
+
* its manifest) are ever removed — anything else in the workspace is left alone.
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
const SKILL_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
18
|
+
const MANIFEST = '.prohost-skills.json';
|
|
19
|
+
const AGENTS_BEGIN = '<!-- prohost skills: begin -->';
|
|
20
|
+
const AGENTS_END = '<!-- prohost skills: end -->';
|
|
21
|
+
/** One skill's files may not exceed this in total. */
|
|
22
|
+
const MAX_SKILL_BYTES = 2 * 1024 * 1024;
|
|
23
|
+
export function skillsRoot(workspace, runtime) {
|
|
24
|
+
return path.join(workspace, runtime === 'claude_code' ? '.claude' : '.codex', 'skills');
|
|
25
|
+
}
|
|
26
|
+
/** A file path inside a skill: relative, no `..`, no absolute or drive paths. */
|
|
27
|
+
export function safeSkillPath(relative) {
|
|
28
|
+
if (!relative || relative.length > 256 || relative.includes('\0'))
|
|
29
|
+
return undefined;
|
|
30
|
+
const normalized = path.posix.normalize(relative.replace(/\\/g, '/'));
|
|
31
|
+
if (normalized.startsWith('/') || /^[A-Za-z]:/.test(normalized) || normalized === '.')
|
|
32
|
+
return undefined;
|
|
33
|
+
if (normalized.split('/').some((part) => part === '..' || part === ''))
|
|
34
|
+
return undefined;
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
/** Skill key → digest of what the host last wrote for it. */
|
|
38
|
+
function readManifest(root) {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(path.join(root, MANIFEST), 'utf8'));
|
|
41
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
42
|
+
return new Map();
|
|
43
|
+
return new Map(Object.entries(parsed).filter((entry) => SKILL_KEY.test(entry[0]) && typeof entry[1] === 'string'));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return new Map();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
function digest(files) {
|
|
50
|
+
const hash = createHash('sha256');
|
|
51
|
+
for (const file of [...files].sort((a, b) => a.path.localeCompare(b.path))) {
|
|
52
|
+
hash.update(file.path).update('\0').update(file.content).update('\0');
|
|
53
|
+
}
|
|
54
|
+
return hash.digest('hex');
|
|
55
|
+
}
|
|
56
|
+
/** Replace (or add) the host's block in `AGENTS.md`, keeping anything the operator wrote. */
|
|
57
|
+
export function agentsMdWithSkills(existing, keys) {
|
|
58
|
+
const block = keys.length === 0
|
|
59
|
+
? ''
|
|
60
|
+
: [
|
|
61
|
+
AGENTS_BEGIN,
|
|
62
|
+
'## Skills',
|
|
63
|
+
'',
|
|
64
|
+
'You have these skills. Before work a skill covers, read its SKILL.md and follow it:',
|
|
65
|
+
'',
|
|
66
|
+
...keys.map((key) => `- ${key}: .codex/skills/${key}/SKILL.md`),
|
|
67
|
+
AGENTS_END,
|
|
68
|
+
].join('\n');
|
|
69
|
+
const start = existing.indexOf(AGENTS_BEGIN);
|
|
70
|
+
const end = existing.indexOf(AGENTS_END);
|
|
71
|
+
const rest = start !== -1 && end > start ? `${existing.slice(0, start)}${existing.slice(end + AGENTS_END.length)}` : existing;
|
|
72
|
+
const trimmed = rest.trim();
|
|
73
|
+
if (!block)
|
|
74
|
+
return trimmed ? `${trimmed}\n` : '';
|
|
75
|
+
return trimmed ? `${trimmed}\n\n${block}\n` : `${block}\n`;
|
|
76
|
+
}
|
|
77
|
+
export function writeSkills(workspace, runtime, skills) {
|
|
78
|
+
const root = skillsRoot(workspace, runtime);
|
|
79
|
+
mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
80
|
+
const result = { written: [], skipped: [], removed: [] };
|
|
81
|
+
const previous = readManifest(root);
|
|
82
|
+
const keep = new Map();
|
|
83
|
+
for (const skill of skills) {
|
|
84
|
+
if (!SKILL_KEY.test(skill.key)) {
|
|
85
|
+
result.skipped.push({ key: skill.key, reason: 'not a usable skill key' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const files = skill.files.map((file) => ({ path: safeSkillPath(file.path), content: file.content }));
|
|
89
|
+
const bad = files.find((file) => file.path === undefined);
|
|
90
|
+
const bytes = skill.files.reduce((sum, file) => sum + Buffer.byteLength(file.content), 0);
|
|
91
|
+
if (bad || bytes > MAX_SKILL_BYTES) {
|
|
92
|
+
result.skipped.push({ key: skill.key, reason: bad ? 'a file path leaves the skill folder' : 'too large' });
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const dir = path.join(root, skill.key);
|
|
96
|
+
const sum = digest(files);
|
|
97
|
+
keep.set(skill.key, sum);
|
|
98
|
+
// Unchanged since the last run: left as it is, so a run of the same
|
|
99
|
+
// employee already reading it never sees it half rewritten.
|
|
100
|
+
if (previous.get(skill.key) === sum && existsSync(dir))
|
|
101
|
+
continue;
|
|
102
|
+
rmSync(dir, { recursive: true, force: true });
|
|
103
|
+
for (const file of files) {
|
|
104
|
+
const target = path.join(dir, file.path);
|
|
105
|
+
mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
106
|
+
writeFileSync(target, file.content, { mode: 0o600 });
|
|
107
|
+
}
|
|
108
|
+
result.written.push(skill.key);
|
|
109
|
+
}
|
|
110
|
+
for (const key of previous.keys()) {
|
|
111
|
+
if (keep.has(key))
|
|
112
|
+
continue;
|
|
113
|
+
rmSync(path.join(root, key), { recursive: true, force: true });
|
|
114
|
+
result.removed.push(key);
|
|
115
|
+
}
|
|
116
|
+
writeFileSync(path.join(root, MANIFEST), `${JSON.stringify(Object.fromEntries(keep))}\n`, { mode: 0o600 });
|
|
117
|
+
if (runtime === 'codex') {
|
|
118
|
+
const agentsMd = path.join(workspace, 'AGENTS.md');
|
|
119
|
+
let existing = '';
|
|
120
|
+
try {
|
|
121
|
+
existing = readFileSync(agentsMd, 'utf8');
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
existing = '';
|
|
125
|
+
}
|
|
126
|
+
const next = agentsMdWithSkills(existing, [...keep.keys()]);
|
|
127
|
+
if (next !== existing) {
|
|
128
|
+
if (next)
|
|
129
|
+
writeFileSync(agentsMd, next, { mode: 0o600 });
|
|
130
|
+
else
|
|
131
|
+
rmSync(agentsMd, { force: true });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return result;
|
|
135
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the host tells ProhostAI (`POST /v1/computers/{id}/status`), what it
|
|
3
|
+
* keeps locally for `prohost host status` and the desktop app (`state.json`,
|
|
4
|
+
* `activity.json`), and the server's answer (the computer's config).
|
|
5
|
+
*
|
|
6
|
+
* The status body never carries a login's email or config directory: those
|
|
7
|
+
* stay in the local files only.
|
|
8
|
+
*/
|
|
9
|
+
import type { AccountRuntime } from '../agent/accounts.js';
|
|
10
|
+
import type { HostStats } from '../codehost/host_stats.js';
|
|
11
|
+
import type { HeldReason } from './capacity.js';
|
|
12
|
+
import type { LoginInfo } from './logins.js';
|
|
13
|
+
export interface Placement {
|
|
14
|
+
agent_id: string;
|
|
15
|
+
runtime: AccountRuntime;
|
|
16
|
+
max_concurrent: number | null;
|
|
17
|
+
keep_one_ready: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface ComputerConfig {
|
|
20
|
+
name?: string;
|
|
21
|
+
capacity: number | null;
|
|
22
|
+
recommended_capacity: number | null;
|
|
23
|
+
hold_on_pressure: boolean;
|
|
24
|
+
hold_on_battery: boolean;
|
|
25
|
+
ai_employees_enabled: boolean;
|
|
26
|
+
terminal_sessions_enabled: boolean;
|
|
27
|
+
share_logins: Record<AccountRuntime, boolean>;
|
|
28
|
+
paused: boolean;
|
|
29
|
+
placements: Placement[];
|
|
30
|
+
}
|
|
31
|
+
export declare const DEFAULT_CONFIG: ComputerConfig;
|
|
32
|
+
/** The status answer's `config`, read defensively over the previous one. */
|
|
33
|
+
export declare function parseConfig(body: Record<string, unknown> | undefined, previous?: ComputerConfig): ComputerConfig;
|
|
34
|
+
/** One run going on this computer. */
|
|
35
|
+
export interface RunningEntry {
|
|
36
|
+
run_id: string;
|
|
37
|
+
agent_id: string;
|
|
38
|
+
agent_name?: string;
|
|
39
|
+
agent_avatar_url?: string;
|
|
40
|
+
title?: string;
|
|
41
|
+
conversation_key?: string;
|
|
42
|
+
kind: 'employee' | 'terminal';
|
|
43
|
+
started_at: string;
|
|
44
|
+
peak_mem_mb?: number;
|
|
45
|
+
started_by_user_id?: string;
|
|
46
|
+
}
|
|
47
|
+
export interface Slots {
|
|
48
|
+
total: number;
|
|
49
|
+
busy: number;
|
|
50
|
+
held: number;
|
|
51
|
+
held_reason: HeldReason | null;
|
|
52
|
+
}
|
|
53
|
+
export declare function slotsFor(capacity: number, busy: number, held: HeldReason | null): Slots;
|
|
54
|
+
export declare function hostStatsBody(stats: HostStats | undefined): Record<string, unknown>;
|
|
55
|
+
/** A login as the status body names it: no email, no directory. */
|
|
56
|
+
export declare function loginBody(login: LoginInfo, conversations: number): Record<string, unknown>;
|
|
57
|
+
export interface StatusInputs {
|
|
58
|
+
stats: HostStats | undefined;
|
|
59
|
+
slots: Slots;
|
|
60
|
+
logins: readonly LoginInfo[];
|
|
61
|
+
conversationsPerLogin: ReadonlyMap<string, number>;
|
|
62
|
+
running: readonly RunningEntry[];
|
|
63
|
+
cliVersion: string;
|
|
64
|
+
capabilities: readonly string[];
|
|
65
|
+
recommendedCapacity: number;
|
|
66
|
+
/** Running as a dedicated service (keeps working when no one is logged in). */
|
|
67
|
+
dedicated?: boolean;
|
|
68
|
+
}
|
|
69
|
+
/** `POST /v1/computers/{id}/status` body. */
|
|
70
|
+
export declare function buildStatusBody(inputs: StatusInputs): Record<string, unknown>;
|
|
71
|
+
/** A run as `host status --json` and `activity.json` show it. */
|
|
72
|
+
export declare function localRunning(entry: RunningEntry): Record<string, unknown>;
|
|
73
|
+
/** A login as `host status --json` / `host logins --json` show it (local: includes the email). */
|
|
74
|
+
export declare function localLogin(login: LoginInfo): Record<string, unknown>;
|
|
75
|
+
/** `state.json`: what the running service last knew. */
|
|
76
|
+
export interface HostState {
|
|
77
|
+
v: 1;
|
|
78
|
+
pid: number;
|
|
79
|
+
updatedAt: string;
|
|
80
|
+
online: boolean;
|
|
81
|
+
paused: boolean;
|
|
82
|
+
draining: boolean;
|
|
83
|
+
slots: Slots;
|
|
84
|
+
capacity: number;
|
|
85
|
+
recommended_capacity: number;
|
|
86
|
+
running: RunningEntry[];
|
|
87
|
+
logins: LoginInfo[];
|
|
88
|
+
name?: string;
|
|
89
|
+
}
|
|
90
|
+
/** A state file older than this means no service is running. */
|
|
91
|
+
export declare const STATE_STALE_MS: number;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the host tells ProhostAI (`POST /v1/computers/{id}/status`), what it
|
|
3
|
+
* keeps locally for `prohost host status` and the desktop app (`state.json`,
|
|
4
|
+
* `activity.json`), and the server's answer (the computer's config).
|
|
5
|
+
*
|
|
6
|
+
* The status body never carries a login's email or config directory: those
|
|
7
|
+
* stay in the local files only.
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_CONFIG = Object.freeze({
|
|
10
|
+
capacity: null,
|
|
11
|
+
recommended_capacity: null,
|
|
12
|
+
hold_on_pressure: true,
|
|
13
|
+
hold_on_battery: true,
|
|
14
|
+
ai_employees_enabled: true,
|
|
15
|
+
terminal_sessions_enabled: true,
|
|
16
|
+
share_logins: { claude_code: false, codex: false },
|
|
17
|
+
paused: false,
|
|
18
|
+
placements: [],
|
|
19
|
+
});
|
|
20
|
+
function bool(value, fallback) {
|
|
21
|
+
return typeof value === 'boolean' ? value : fallback;
|
|
22
|
+
}
|
|
23
|
+
function count(value) {
|
|
24
|
+
return typeof value === 'number' && Number.isInteger(value) && value >= 0 ? value : null;
|
|
25
|
+
}
|
|
26
|
+
/** The status answer's `config`, read defensively over the previous one. */
|
|
27
|
+
export function parseConfig(body, previous = DEFAULT_CONFIG) {
|
|
28
|
+
const raw = body?.config;
|
|
29
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
30
|
+
return previous;
|
|
31
|
+
const c = raw;
|
|
32
|
+
const share = c.share_logins && typeof c.share_logins === 'object' ? c.share_logins : {};
|
|
33
|
+
const placements = Array.isArray(c.placements)
|
|
34
|
+
? c.placements.flatMap((entry) => {
|
|
35
|
+
const p = entry;
|
|
36
|
+
if (!p || typeof p.agent_id !== 'string' || !p.agent_id)
|
|
37
|
+
return [];
|
|
38
|
+
const runtime = p.runtime === 'codex' ? 'codex' : 'claude_code';
|
|
39
|
+
const max = count(p.max_concurrent);
|
|
40
|
+
return [{ agent_id: p.agent_id, runtime, max_concurrent: max, keep_one_ready: p.keep_one_ready === true }];
|
|
41
|
+
})
|
|
42
|
+
: previous.placements;
|
|
43
|
+
return {
|
|
44
|
+
...(typeof c.name === 'string' ? { name: c.name } : previous.name !== undefined ? { name: previous.name } : {}),
|
|
45
|
+
capacity: c.capacity === null ? null : (count(c.capacity) ?? previous.capacity),
|
|
46
|
+
recommended_capacity: c.recommended_capacity === null ? null : (count(c.recommended_capacity) ?? previous.recommended_capacity),
|
|
47
|
+
hold_on_pressure: bool(c.hold_on_pressure, previous.hold_on_pressure),
|
|
48
|
+
hold_on_battery: bool(c.hold_on_battery, previous.hold_on_battery),
|
|
49
|
+
ai_employees_enabled: bool(c.ai_employees_enabled, previous.ai_employees_enabled),
|
|
50
|
+
terminal_sessions_enabled: bool(c.terminal_sessions_enabled, previous.terminal_sessions_enabled),
|
|
51
|
+
share_logins: {
|
|
52
|
+
claude_code: bool(share.claude_code, previous.share_logins.claude_code),
|
|
53
|
+
codex: bool(share.codex, previous.share_logins.codex),
|
|
54
|
+
},
|
|
55
|
+
paused: bool(c.paused, previous.paused),
|
|
56
|
+
placements,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
export function slotsFor(capacity, busy, held) {
|
|
60
|
+
return { total: capacity, busy, held: held ? Math.max(0, capacity - busy) : 0, held_reason: held };
|
|
61
|
+
}
|
|
62
|
+
export function hostStatsBody(stats) {
|
|
63
|
+
return {
|
|
64
|
+
cpu_pct: stats?.cpuPct ?? null,
|
|
65
|
+
mem_used_gb: stats?.memUsedGb ?? null,
|
|
66
|
+
mem_total_gb: stats?.memTotalGb ?? null,
|
|
67
|
+
memory_pressure: stats?.memoryPressure ?? null,
|
|
68
|
+
on_battery: stats?.onBattery ?? null,
|
|
69
|
+
battery_pct: stats?.batteryPct ?? null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** A login as the status body names it: no email, no directory. */
|
|
73
|
+
export function loginBody(login, conversations) {
|
|
74
|
+
return {
|
|
75
|
+
account_key: login.account_key,
|
|
76
|
+
label: login.label,
|
|
77
|
+
runtime: login.runtime,
|
|
78
|
+
subscription_type: login.subscription_type,
|
|
79
|
+
plan_label: login.plan_label,
|
|
80
|
+
auth_state: login.auth_state,
|
|
81
|
+
quota_windows: login.quota_windows.map((w) => ({ ...w })),
|
|
82
|
+
conversations,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/** `POST /v1/computers/{id}/status` body. */
|
|
86
|
+
export function buildStatusBody(inputs) {
|
|
87
|
+
return {
|
|
88
|
+
host_stats: hostStatsBody(inputs.stats),
|
|
89
|
+
slots: { ...inputs.slots },
|
|
90
|
+
logins: inputs.logins.map((login) => loginBody(login, inputs.conversationsPerLogin.get(login.account_key) ?? 0)),
|
|
91
|
+
running: inputs.running.map((entry) => ({
|
|
92
|
+
run_id: entry.run_id,
|
|
93
|
+
agent_id: entry.agent_id,
|
|
94
|
+
conversation_key: entry.conversation_key ?? null,
|
|
95
|
+
kind: entry.kind,
|
|
96
|
+
started_at: entry.started_at,
|
|
97
|
+
peak_mem_mb: entry.peak_mem_mb ?? null,
|
|
98
|
+
...(entry.started_by_user_id ? { started_by_user_id: entry.started_by_user_id } : {}),
|
|
99
|
+
})),
|
|
100
|
+
cli_version: inputs.cliVersion,
|
|
101
|
+
capabilities: [...inputs.capabilities],
|
|
102
|
+
recommended_capacity: inputs.recommendedCapacity,
|
|
103
|
+
...(inputs.dedicated !== undefined ? { dedicated: inputs.dedicated } : {}),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** A run as `host status --json` and `activity.json` show it. */
|
|
107
|
+
export function localRunning(entry) {
|
|
108
|
+
return {
|
|
109
|
+
run_id: entry.run_id,
|
|
110
|
+
agent: { id: entry.agent_id, name: entry.agent_name ?? null, avatar_url: entry.agent_avatar_url ?? null },
|
|
111
|
+
title: entry.title ?? null,
|
|
112
|
+
started_at: entry.started_at,
|
|
113
|
+
kind: entry.kind,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** A login as `host status --json` / `host logins --json` show it (local: includes the email). */
|
|
117
|
+
export function localLogin(login) {
|
|
118
|
+
return {
|
|
119
|
+
account_key: login.account_key,
|
|
120
|
+
label: login.label,
|
|
121
|
+
runtime: login.runtime,
|
|
122
|
+
subscription_type: login.subscription_type,
|
|
123
|
+
plan_label: login.plan_label,
|
|
124
|
+
email_local: login.email_local ?? null,
|
|
125
|
+
auth_state: login.auth_state,
|
|
126
|
+
windows: login.quota_windows,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
/** A state file older than this means no service is running. */
|
|
130
|
+
export const STATE_STALE_MS = 2 * 60_000;
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* npx @prohost/cli agent run --exec 'claude -p'
|
|
10
10
|
* npx @prohost/cli agent install-daemon --exec 'claude -p'
|
|
11
11
|
* npx @prohost/cli usage connect --key <api-key>
|
|
12
|
+
* npx @prohost/cli host pair
|
|
13
|
+
* npx @prohost/cli host install
|
|
12
14
|
*
|
|
13
15
|
* Parses arguments with a zero-dep, zero-magic flag parser so the published
|
|
14
16
|
* binary stays small and starts fast.
|
|
@@ -20,6 +22,8 @@ interface ParsedArgs {
|
|
|
20
22
|
/** Any further positionals, e.g. ``add work`` in ``prohost agent account add work``. */
|
|
21
23
|
args: string[];
|
|
22
24
|
flags: Record<string, string | boolean>;
|
|
25
|
+
/** Every value a repeated flag was given, in order (`--from a --from b`); `flags` keeps the last. */
|
|
26
|
+
repeated: Record<string, string[]>;
|
|
23
27
|
}
|
|
24
28
|
export declare function parseArgs(argv: readonly string[]): ParsedArgs;
|
|
25
29
|
/**
|
package/dist/index.js
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* npx @prohost/cli agent run --exec 'claude -p'
|
|
10
10
|
* npx @prohost/cli agent install-daemon --exec 'claude -p'
|
|
11
11
|
* npx @prohost/cli usage connect --key <api-key>
|
|
12
|
+
* npx @prohost/cli host pair
|
|
13
|
+
* npx @prohost/cli host install
|
|
12
14
|
*
|
|
13
15
|
* Parses arguments with a zero-dep, zero-magic flag parser so the published
|
|
14
16
|
* binary stays small and starts fast.
|
|
@@ -19,6 +21,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
19
21
|
import { endpointProblem } from './agent/base-url.js';
|
|
20
22
|
import { runAgent } from './agent/command.js';
|
|
21
23
|
import { runHook } from './codehost/hooks.js';
|
|
24
|
+
import { runHost } from './host/command.js';
|
|
22
25
|
import { DEFAULT_WS_URL, runListen } from './listen.js';
|
|
23
26
|
import { runUsage } from './usage/command.js';
|
|
24
27
|
import { CLI_VERSION } from './version.js';
|
|
@@ -27,6 +30,15 @@ function printHelp() {
|
|
|
27
30
|
'prohost — ProhostAI CLI',
|
|
28
31
|
'',
|
|
29
32
|
'Usage:',
|
|
33
|
+
' prohost host pair [--name <name>] [--token <redeem-token>|-] [--expect-account <workspace-id>]',
|
|
34
|
+
' Add this computer to a workspace (prints a code to enter in ProhostAI)',
|
|
35
|
+
' prohost host unpair Remove this computer from its workspace (revokes its key)',
|
|
36
|
+
' prohost host run One service for every AI employee placed on this computer',
|
|
37
|
+
' prohost host install [--label <id>] [--dedicated --service-user <name> [--service-home <dir>]',
|
|
38
|
+
' [--claude-token-file <path>]] | uninstall [--label <id>]',
|
|
39
|
+
' prohost host status [--json] [--label <id>] | pause | resume',
|
|
40
|
+
' prohost host logins [--json] | logins add --runtime claude_code|codex [--label <l>]',
|
|
41
|
+
' prohost host migrate [--from <home>]… [--home <dir,…>] [--keep-services] [--dry-run] [--json]',
|
|
30
42
|
' prohost listen --forward-to <local-url> [--api-key <key>] [--url <ws-url>] [--machine <name>] [--subscription-id <id>]',
|
|
31
43
|
' prohost agent pair --code <pairing-code> [--expect-account <workspace-id>] [--account <label>]',
|
|
32
44
|
' [--webhook-url <https-url>] [--base-url <url>]',
|
|
@@ -47,6 +59,33 @@ function printHelp() {
|
|
|
47
59
|
' prohost usage discover [--dry-run] | start [--once] [--machine <name>] | status',
|
|
48
60
|
' prohost usage install-daemon [--machine <name>] | uninstall-daemon',
|
|
49
61
|
'',
|
|
62
|
+
'host:',
|
|
63
|
+
' A computer is added to a workspace once (`host pair`) and serves every AI employee placed on it,',
|
|
64
|
+
' running Claude Code or Codex on the AI logins signed in here, plus the terminal sessions opened on it.',
|
|
65
|
+
' Employees skip permission prompts (Claude --dangerously-skip-permissions, Codex --yolo); message',
|
|
66
|
+
' approval rules still apply. Capacity is recommended from memory and cores and set in ProhostAI.',
|
|
67
|
+
' --token `host pair`: a one-time token from the signed-in desktop app instead of a code',
|
|
68
|
+
' (`-` reads it from stdin; also $PROHOST_REDEEM_TOKEN).',
|
|
69
|
+
' --dedicated `host install`: keep working when no one is logged in (a system service running as',
|
|
70
|
+
' --service-user, created if missing on macOS / Linux; run it as root / an administrator).',
|
|
71
|
+
' It takes over this home\'s computer.json, logs outside the service user\'s home, and',
|
|
72
|
+
' publishes its status for `host status` run by anyone on the computer.',
|
|
73
|
+
' --claude-token-file `host install --dedicated`: a file holding a `claude setup-token` token, installed',
|
|
74
|
+
' for the service user as CLAUDE_CODE_OAUTH_TOKEN. Read once, never printed.',
|
|
75
|
+
' --from-home `host install --dedicated`: the home whose computer.json the service takes over',
|
|
76
|
+
" (default: $PROHOST_HOME, or $SUDO_USER's ~/.prohost under sudo).",
|
|
77
|
+
' --label `host install` / `uninstall`: the service\'s label (launchd label, systemd unit,',
|
|
78
|
+
' Windows task), e.g. ai.prohost.desktop.host. Default: ai.prohost.host for ~/.prohost,',
|
|
79
|
+
' or one derived from $PROHOST_HOME. Re-running install changes nothing that is right.',
|
|
80
|
+
' Under the ProhostAI desktop app, host install keeps ELECTRON_RUN_AS_NODE=1 in the service it writes',
|
|
81
|
+
' (and the PATH it was launched with), so the service runs the CLI, never the app.',
|
|
82
|
+
' `host unpair` removes this computer from its workspace and deletes its key here.',
|
|
83
|
+
' `host logins add` signs in another Claude Code / Codex login in this terminal.',
|
|
84
|
+
' `host pause` takes no new work until `host resume`; work already going finishes.',
|
|
85
|
+
' `host migrate` adopts existing agent homes and the desktop app\'s terminal-hosting home, keeping',
|
|
86
|
+
' their credentials and becoming a computer through their agent keys (no new pairing). --from <home>',
|
|
87
|
+
' (repeatable) adopts only those homes and never changes them (see the README).',
|
|
88
|
+
'',
|
|
50
89
|
'listen flags:',
|
|
51
90
|
' --forward-to Local URL to POST each webhook payload to (required)',
|
|
52
91
|
' --api-key ProhostAI API key. Defaults to $PROHOST_API_KEY.',
|
|
@@ -152,6 +191,7 @@ function printHelp() {
|
|
|
152
191
|
}
|
|
153
192
|
export function parseArgs(argv) {
|
|
154
193
|
const flags = {};
|
|
194
|
+
const repeated = {};
|
|
155
195
|
let command;
|
|
156
196
|
let subcommand;
|
|
157
197
|
const args = [];
|
|
@@ -187,10 +227,11 @@ export function parseArgs(argv) {
|
|
|
187
227
|
}
|
|
188
228
|
else {
|
|
189
229
|
flags[key] = next;
|
|
230
|
+
(repeated[key] ??= []).push(next);
|
|
190
231
|
i += 2;
|
|
191
232
|
}
|
|
192
233
|
}
|
|
193
|
-
return { command, subcommand, args, flags };
|
|
234
|
+
return { command, subcommand, args, flags, repeated };
|
|
194
235
|
}
|
|
195
236
|
/**
|
|
196
237
|
* The `hook` command's arguments as the hook reads them: its positionals, and
|
|
@@ -201,7 +242,7 @@ export function hookArgs({ subcommand, args, flags }) {
|
|
|
201
242
|
return [subcommand ?? '', ...args, ...(typeof wait === 'string' ? ['--wait', wait] : [])];
|
|
202
243
|
}
|
|
203
244
|
async function main(argv) {
|
|
204
|
-
const { command, subcommand, args, flags } = parseArgs(argv);
|
|
245
|
+
const { command, subcommand, args, flags, repeated } = parseArgs(argv);
|
|
205
246
|
// Checked before the help/no-command branch: `prohost --version` carries no
|
|
206
247
|
// command, which would otherwise print help and exit 1.
|
|
207
248
|
if (flags.version) {
|
|
@@ -223,12 +264,15 @@ async function main(argv) {
|
|
|
223
264
|
if (command === 'agent') {
|
|
224
265
|
return runAgent(subcommand, flags, printHelp, args);
|
|
225
266
|
}
|
|
267
|
+
if (command === 'host') {
|
|
268
|
+
return runHost(subcommand, flags, printHelp, { args, repeated });
|
|
269
|
+
}
|
|
226
270
|
if (command === 'usage') {
|
|
227
271
|
return runUsage(subcommand, flags, printHelp);
|
|
228
272
|
}
|
|
229
273
|
// Run by the Code-thread hook shim (codehost/hooks.ts), not by people; not in --help.
|
|
230
274
|
if (command === 'hook') {
|
|
231
|
-
return runHook(hookArgs({ command, subcommand, args, flags }));
|
|
275
|
+
return runHook(hookArgs({ command, subcommand, args, flags, repeated }));
|
|
232
276
|
}
|
|
233
277
|
if (command !== 'listen') {
|
|
234
278
|
process.stderr.write(`Unknown command: ${command}\n\n`);
|