@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.
- package/CHANGELOG.md +83 -0
- package/README.md +265 -1
- package/dist/agent/accounts.d.ts +11 -0
- package/dist/agent/accounts.js +12 -1
- package/dist/agent/api.d.ts +6 -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 +110 -1
- package/dist/agent/run.js +198 -22
- package/dist/codehost/history.d.ts +80 -0
- package/dist/codehost/history.js +98 -0
- package/dist/codehost/hooks.d.ts +10 -2
- package/dist/codehost/hooks.js +15 -4
- package/dist/codehost/host.d.ts +32 -0
- package/dist/codehost/host.js +26 -1
- package/dist/codehost/host_stats.d.ts +49 -2
- package/dist/codehost/host_stats.js +153 -2
- package/dist/codehost/relay.d.ts +16 -1
- package/dist/codehost/relay.js +69 -3
- package/dist/codehost/sizing.d.ts +22 -4
- package/dist/codehost/sizing.js +46 -6
- package/dist/codehost/terminals.d.ts +33 -2
- package/dist/codehost/terminals.js +92 -30
- package/dist/codehost/tmux.d.ts +6 -1
- package/dist/codehost/tmux.js +3 -0
- 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 +584 -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 +152 -0
- package/dist/host/install.js +755 -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,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prohost host migrate` — bring this machine's existing agent homes and the
|
|
3
|
+
* desktop app's terminal-hosting home under the host service, keeping their
|
|
4
|
+
* credentials: nothing is paired again.
|
|
5
|
+
*
|
|
6
|
+
* What it adopts:
|
|
7
|
+
*
|
|
8
|
+
* - **Agent homes** (`agent.json` + `sessions.json`): every home a launchd
|
|
9
|
+
* agent `ai.prohost.agent.*` or the desktop app's `ai.prohost.desktop[.dev].code-host`
|
|
10
|
+
* runs (macOS; its `--exec`, `--workdir`,
|
|
11
|
+
* `--concurrency`, `--repos`, timeouts and `--safe-tools` are read from the
|
|
12
|
+
* plist), `$PROHOST_HOME` itself, and each `--home` given. Each one's
|
|
13
|
+
* conversation sessions are copied into the employee's host home, so
|
|
14
|
+
* conversations continue where they were. Until ProhostAI places that
|
|
15
|
+
* employee on this computer, `host run` keeps serving it with its own
|
|
16
|
+
* credential exactly as `agent run` did; once it is placed, the computer
|
|
17
|
+
* serves it and the old connection goes quiet.
|
|
18
|
+
* - **The terminal-hosting home** — a home whose daemon runs `--host-only`,
|
|
19
|
+
* or whose Code-thread hosting is on (`code-host/settings.json`). The host
|
|
20
|
+
* service hosts terminal sessions from that home, so its open terminals,
|
|
21
|
+
* opened folders and switches carry over (`code_host_home`).
|
|
22
|
+
* - **Old launchd agents** it adopted are stopped and removed (`--keep-services`
|
|
23
|
+
* leaves them), so one service — not two — answers for each employee.
|
|
24
|
+
*
|
|
25
|
+
* What it cannot adopt:
|
|
26
|
+
*
|
|
27
|
+
* - A systemd unit or Windows task written by hand from `agent install-daemon`
|
|
28
|
+
* (only launchd agents are found by themselves): pass `--home <dir>`, and
|
|
29
|
+
* stop the old unit yourself.
|
|
30
|
+
* - An agent the desktop app supervises itself: the desktop stops it.
|
|
31
|
+
* - Its `--exec` when it isn't Claude Code or Codex: such a home keeps running
|
|
32
|
+
* under its own credential, and is never served as the computer.
|
|
33
|
+
* - A home whose `agent.json` is missing or unreadable, or whose pairing was
|
|
34
|
+
* revoked (it is listed, not adopted).
|
|
35
|
+
* - Sessions of a home that ran in a custom `--workdir` (a code checkout),
|
|
36
|
+
* once the computer serves it: Claude Code files sessions by working
|
|
37
|
+
* directory, and the host never writes skills into a checkout, so those
|
|
38
|
+
* conversations start a fresh session there. (Until it is placed, its own
|
|
39
|
+
* connection keeps running in that `--workdir`.)
|
|
40
|
+
*
|
|
41
|
+
* Running it again keeps what an earlier run adopted (including settings read
|
|
42
|
+
* from a plist it has since removed) and adds what is new.
|
|
43
|
+
* - `prohost listen` and `prohost usage` daemons, which are not agents.
|
|
44
|
+
*/
|
|
45
|
+
import type { MachineFacts } from './computer.js';
|
|
46
|
+
import type { ServiceRunner } from './install.js';
|
|
47
|
+
export interface AdoptedHome {
|
|
48
|
+
home: string;
|
|
49
|
+
agent_id: string;
|
|
50
|
+
agent_name: string;
|
|
51
|
+
/** The `--exec` it ran with, when known; `null` for a host-only home. */
|
|
52
|
+
exec: string | null;
|
|
53
|
+
host_only: boolean;
|
|
54
|
+
/** The working directory it ran in (`--workdir`, else its own workspace): its own connection keeps it. */
|
|
55
|
+
workdir?: string;
|
|
56
|
+
/**
|
|
57
|
+
* Where the computer runs it once placed: the old workspace, when that was
|
|
58
|
+
* the home's own (its sessions are filed under it). Never a code checkout.
|
|
59
|
+
*/
|
|
60
|
+
hosted_workdir?: string;
|
|
61
|
+
concurrency?: number;
|
|
62
|
+
repos?: string[];
|
|
63
|
+
timeout_seconds?: number;
|
|
64
|
+
idle_timeout_seconds?: number;
|
|
65
|
+
safe_tools?: boolean;
|
|
66
|
+
/** The launchd agent it ran under, if any. */
|
|
67
|
+
launchd?: {
|
|
68
|
+
label: string;
|
|
69
|
+
plist: string;
|
|
70
|
+
};
|
|
71
|
+
sessions_copied: number;
|
|
72
|
+
}
|
|
73
|
+
export interface Adopted {
|
|
74
|
+
v: 1;
|
|
75
|
+
adopted_at: string;
|
|
76
|
+
homes: AdoptedHome[];
|
|
77
|
+
code_host_home: string | null;
|
|
78
|
+
}
|
|
79
|
+
export declare function loadAdopted(env?: NodeJS.ProcessEnv): Adopted | undefined;
|
|
80
|
+
/** `ProgramArguments` and `EnvironmentVariables` out of an agent plist this CLI wrote. */
|
|
81
|
+
export declare function parseAgentPlist(xml: string): {
|
|
82
|
+
args: string[];
|
|
83
|
+
env: Record<string, string>;
|
|
84
|
+
};
|
|
85
|
+
/** The `agent run` flags out of a daemon's arguments. */
|
|
86
|
+
export declare function harnessFlags(args: readonly string[]): {
|
|
87
|
+
exec?: string;
|
|
88
|
+
hostOnly: boolean;
|
|
89
|
+
workdir?: string;
|
|
90
|
+
concurrency?: number;
|
|
91
|
+
repos?: string[];
|
|
92
|
+
timeoutSeconds?: number;
|
|
93
|
+
idleTimeoutSeconds?: number;
|
|
94
|
+
safeTools: boolean;
|
|
95
|
+
};
|
|
96
|
+
interface Candidate {
|
|
97
|
+
home: string;
|
|
98
|
+
/** Named by the caller (`--home` / `--from`), not found by discovery. */
|
|
99
|
+
explicit?: boolean;
|
|
100
|
+
args?: string[];
|
|
101
|
+
launchd?: {
|
|
102
|
+
label: string;
|
|
103
|
+
plist: string;
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/** Homes worth looking at: launchd agents' homes, `$PROHOST_HOME`, and each `--home`. */
|
|
107
|
+
export declare function findCandidates(options: {
|
|
108
|
+
env?: NodeJS.ProcessEnv;
|
|
109
|
+
homes?: string[];
|
|
110
|
+
/** `--from`: only these homes (and earlier adoptions) — no discovery. */
|
|
111
|
+
only?: string[];
|
|
112
|
+
launchAgentsDir?: string;
|
|
113
|
+
platform?: NodeJS.Platform;
|
|
114
|
+
}): Candidate[];
|
|
115
|
+
export interface MigrateReport {
|
|
116
|
+
/** Set when nothing was done: the host service is running (it owns the ledger). */
|
|
117
|
+
refused?: string;
|
|
118
|
+
adopted: AdoptedHome[];
|
|
119
|
+
skipped: Array<{
|
|
120
|
+
home: string;
|
|
121
|
+
reason: string;
|
|
122
|
+
}>;
|
|
123
|
+
codeHostHome: string | null;
|
|
124
|
+
servicesStopped: string[];
|
|
125
|
+
notes: string[];
|
|
126
|
+
}
|
|
127
|
+
export declare function runMigrate(options: {
|
|
128
|
+
env?: NodeJS.ProcessEnv;
|
|
129
|
+
homes?: string[];
|
|
130
|
+
/** `--from`: adopt only these homes (repeatable); nothing is discovered. */
|
|
131
|
+
only?: string[];
|
|
132
|
+
keepServices?: boolean;
|
|
133
|
+
dryRun?: boolean;
|
|
134
|
+
launchAgentsDir?: string;
|
|
135
|
+
platform?: NodeJS.Platform;
|
|
136
|
+
uid?: number;
|
|
137
|
+
run?: ServiceRunner;
|
|
138
|
+
}): MigrateReport;
|
|
139
|
+
export type ComputerAdoption = {
|
|
140
|
+
status: 'already_paired';
|
|
141
|
+
} | {
|
|
142
|
+
status: 'adopted';
|
|
143
|
+
computer_id: string;
|
|
144
|
+
account: {
|
|
145
|
+
id: string;
|
|
146
|
+
name: string | null;
|
|
147
|
+
};
|
|
148
|
+
} | {
|
|
149
|
+
status: 'not_adopted';
|
|
150
|
+
reason: string;
|
|
151
|
+
};
|
|
152
|
+
/**
|
|
153
|
+
* Get this machine's computer credential from one of the adopted homes' agent
|
|
154
|
+
* keys (`POST /v1/computers/pairing/adopt`) — no pairing code, no approval:
|
|
155
|
+
* the pairing's creator already owns the machine. Tried home by home until one
|
|
156
|
+
* is accepted; nothing is saved unless one is.
|
|
157
|
+
*/
|
|
158
|
+
export declare function adoptComputer(options: {
|
|
159
|
+
env?: NodeJS.ProcessEnv;
|
|
160
|
+
report: MigrateReport;
|
|
161
|
+
fetchImpl?: typeof fetch;
|
|
162
|
+
facts?: MachineFacts;
|
|
163
|
+
name?: string;
|
|
164
|
+
}): Promise<ComputerAdoption>;
|
|
165
|
+
export {};
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prohost host migrate` — bring this machine's existing agent homes and the
|
|
3
|
+
* desktop app's terminal-hosting home under the host service, keeping their
|
|
4
|
+
* credentials: nothing is paired again.
|
|
5
|
+
*
|
|
6
|
+
* What it adopts:
|
|
7
|
+
*
|
|
8
|
+
* - **Agent homes** (`agent.json` + `sessions.json`): every home a launchd
|
|
9
|
+
* agent `ai.prohost.agent.*` or the desktop app's `ai.prohost.desktop[.dev].code-host`
|
|
10
|
+
* runs (macOS; its `--exec`, `--workdir`,
|
|
11
|
+
* `--concurrency`, `--repos`, timeouts and `--safe-tools` are read from the
|
|
12
|
+
* plist), `$PROHOST_HOME` itself, and each `--home` given. Each one's
|
|
13
|
+
* conversation sessions are copied into the employee's host home, so
|
|
14
|
+
* conversations continue where they were. Until ProhostAI places that
|
|
15
|
+
* employee on this computer, `host run` keeps serving it with its own
|
|
16
|
+
* credential exactly as `agent run` did; once it is placed, the computer
|
|
17
|
+
* serves it and the old connection goes quiet.
|
|
18
|
+
* - **The terminal-hosting home** — a home whose daemon runs `--host-only`,
|
|
19
|
+
* or whose Code-thread hosting is on (`code-host/settings.json`). The host
|
|
20
|
+
* service hosts terminal sessions from that home, so its open terminals,
|
|
21
|
+
* opened folders and switches carry over (`code_host_home`).
|
|
22
|
+
* - **Old launchd agents** it adopted are stopped and removed (`--keep-services`
|
|
23
|
+
* leaves them), so one service — not two — answers for each employee.
|
|
24
|
+
*
|
|
25
|
+
* What it cannot adopt:
|
|
26
|
+
*
|
|
27
|
+
* - A systemd unit or Windows task written by hand from `agent install-daemon`
|
|
28
|
+
* (only launchd agents are found by themselves): pass `--home <dir>`, and
|
|
29
|
+
* stop the old unit yourself.
|
|
30
|
+
* - An agent the desktop app supervises itself: the desktop stops it.
|
|
31
|
+
* - Its `--exec` when it isn't Claude Code or Codex: such a home keeps running
|
|
32
|
+
* under its own credential, and is never served as the computer.
|
|
33
|
+
* - A home whose `agent.json` is missing or unreadable, or whose pairing was
|
|
34
|
+
* revoked (it is listed, not adopted).
|
|
35
|
+
* - Sessions of a home that ran in a custom `--workdir` (a code checkout),
|
|
36
|
+
* once the computer serves it: Claude Code files sessions by working
|
|
37
|
+
* directory, and the host never writes skills into a checkout, so those
|
|
38
|
+
* conversations start a fresh session there. (Until it is placed, its own
|
|
39
|
+
* connection keeps running in that `--workdir`.)
|
|
40
|
+
*
|
|
41
|
+
* Running it again keeps what an earlier run adopted (including settings read
|
|
42
|
+
* from a plist it has since removed) and adds what is new.
|
|
43
|
+
* - `prohost listen` and `prohost usage` daemons, which are not agents.
|
|
44
|
+
*/
|
|
45
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
46
|
+
import os from 'node:os';
|
|
47
|
+
import path from 'node:path';
|
|
48
|
+
import { loadCredentials, prohostHome } from '../agent/credentials.js';
|
|
49
|
+
import { LAUNCHD_LABEL_PREFIX } from '../agent/daemon.js';
|
|
50
|
+
import { resolveAgentAccount } from '../agent/accounts.js';
|
|
51
|
+
import { PAIRING_ADOPT_PATH, callJson } from './api.js';
|
|
52
|
+
import { machineFacts } from './computer.js';
|
|
53
|
+
import { credentialsFrom } from './pair.js';
|
|
54
|
+
import { isCodexExec } from '../agent/codex.js';
|
|
55
|
+
import { SessionStore } from '../agent/sessionstore.js';
|
|
56
|
+
import { HostLedger } from './ledger.js';
|
|
57
|
+
import { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
|
|
58
|
+
import { acquireHostLock, adoptedPath, employeeHome, readObject, saveComputer, tryLoadComputer, writePrivateFile, } from './computer.js';
|
|
59
|
+
import { defaultServiceRunner } from './install.js';
|
|
60
|
+
export function loadAdopted(env = process.env) {
|
|
61
|
+
const raw = readObject(adoptedPath(env));
|
|
62
|
+
if (!raw || raw.v !== 1 || !Array.isArray(raw.homes))
|
|
63
|
+
return undefined;
|
|
64
|
+
return raw;
|
|
65
|
+
}
|
|
66
|
+
function unescapeXml(value) {
|
|
67
|
+
return value
|
|
68
|
+
.replace(/</g, '<')
|
|
69
|
+
.replace(/>/g, '>')
|
|
70
|
+
.replace(/"/g, '"')
|
|
71
|
+
.replace(/'/g, "'")
|
|
72
|
+
.replace(/&/g, '&');
|
|
73
|
+
}
|
|
74
|
+
/** `ProgramArguments` and `EnvironmentVariables` out of an agent plist this CLI wrote. */
|
|
75
|
+
export function parseAgentPlist(xml) {
|
|
76
|
+
const argsBlock = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(xml)?.[1] ?? '';
|
|
77
|
+
const args = [...argsBlock.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unescapeXml(m[1]));
|
|
78
|
+
const envBlock = /<key>EnvironmentVariables<\/key>\s*<dict>([\s\S]*?)<\/dict>/.exec(xml)?.[1] ?? '';
|
|
79
|
+
const env = {};
|
|
80
|
+
for (const m of envBlock.matchAll(/<key>([\s\S]*?)<\/key>\s*<string>([\s\S]*?)<\/string>/g)) {
|
|
81
|
+
env[unescapeXml(m[1])] = unescapeXml(m[2]);
|
|
82
|
+
}
|
|
83
|
+
return { args, env };
|
|
84
|
+
}
|
|
85
|
+
/** The `agent run` flags out of a daemon's arguments. */
|
|
86
|
+
export function harnessFlags(args) {
|
|
87
|
+
const value = (flag) => {
|
|
88
|
+
const index = args.indexOf(flag);
|
|
89
|
+
return index >= 0 && index + 1 < args.length ? args[index + 1] : undefined;
|
|
90
|
+
};
|
|
91
|
+
const number = (flag) => {
|
|
92
|
+
const raw = value(flag);
|
|
93
|
+
const parsed = raw === undefined ? Number.NaN : Number(raw);
|
|
94
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
95
|
+
};
|
|
96
|
+
const repos = value('--repos');
|
|
97
|
+
return {
|
|
98
|
+
...(value('--exec') !== undefined ? { exec: value('--exec') } : {}),
|
|
99
|
+
hostOnly: args.includes('--host-only'),
|
|
100
|
+
...(value('--workdir') !== undefined ? { workdir: value('--workdir') } : {}),
|
|
101
|
+
...(number('--concurrency') !== undefined ? { concurrency: number('--concurrency') } : {}),
|
|
102
|
+
...(repos ? { repos: repos.split(',').filter(Boolean) } : {}),
|
|
103
|
+
...(number('--timeout') !== undefined ? { timeoutSeconds: number('--timeout') } : {}),
|
|
104
|
+
...(number('--idle-timeout') !== undefined ? { idleTimeoutSeconds: number('--idle-timeout') } : {}),
|
|
105
|
+
safeTools: args.includes('--safe-tools') || args.includes('--no-skip-permissions'),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/** The desktop app's terminal-hosting agent: `ai.prohost.desktop[.dev].code-host`. */
|
|
109
|
+
const DESKTOP_CODE_HOST_PLIST = /^ai\.prohost\.desktop(\.dev)?\.code-host\.plist$/;
|
|
110
|
+
function isAgentPlist(name) {
|
|
111
|
+
return (name.startsWith(`${LAUNCHD_LABEL_PREFIX}.`) && name.endsWith('.plist')) || DESKTOP_CODE_HOST_PLIST.test(name);
|
|
112
|
+
}
|
|
113
|
+
/** Homes worth looking at: launchd agents' homes, `$PROHOST_HOME`, and each `--home`. */
|
|
114
|
+
export function findCandidates(options) {
|
|
115
|
+
const env = options.env ?? process.env;
|
|
116
|
+
const out = [];
|
|
117
|
+
const seen = new Set();
|
|
118
|
+
const add = (candidate) => {
|
|
119
|
+
const key = path.resolve(candidate.home);
|
|
120
|
+
const existing = out.find((c) => path.resolve(c.home) === key);
|
|
121
|
+
if (existing) {
|
|
122
|
+
if (!existing.args && candidate.args)
|
|
123
|
+
Object.assign(existing, candidate);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
seen.add(key);
|
|
127
|
+
out.push(candidate);
|
|
128
|
+
};
|
|
129
|
+
if (options.only) {
|
|
130
|
+
for (const home of options.only)
|
|
131
|
+
add({ home: path.resolve(home), explicit: true });
|
|
132
|
+
for (const home of options.homes ?? [])
|
|
133
|
+
add({ home: path.resolve(home), explicit: true });
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
if ((options.platform ?? process.platform) === 'darwin') {
|
|
137
|
+
const dir = options.launchAgentsDir ?? path.join(env.HOME || os.homedir(), 'Library', 'LaunchAgents');
|
|
138
|
+
let names = [];
|
|
139
|
+
try {
|
|
140
|
+
names = readdirSync(dir).sort();
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
names = [];
|
|
144
|
+
}
|
|
145
|
+
for (const name of names) {
|
|
146
|
+
if (!isAgentPlist(name))
|
|
147
|
+
continue;
|
|
148
|
+
const plist = path.join(dir, name);
|
|
149
|
+
let parsed;
|
|
150
|
+
try {
|
|
151
|
+
parsed = parseAgentPlist(readFileSync(plist, 'utf8'));
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (!parsed.args.includes('agent') || !parsed.args.includes('run'))
|
|
157
|
+
continue;
|
|
158
|
+
add({
|
|
159
|
+
home: parsed.env.PROHOST_HOME || path.join(env.HOME || os.homedir(), '.prohost'),
|
|
160
|
+
args: parsed.args,
|
|
161
|
+
launchd: { label: name.slice(0, -'.plist'.length), plist },
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
add({ home: prohostHome(env) });
|
|
166
|
+
for (const home of options.homes ?? [])
|
|
167
|
+
add({ home: path.resolve(home), explicit: true });
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
/** The AI login an adopted agent ran on (its `account`, else the default), for a Claude or Codex `--exec`. */
|
|
171
|
+
function loginKeyFor(exec, account, env) {
|
|
172
|
+
if (!exec)
|
|
173
|
+
return undefined;
|
|
174
|
+
try {
|
|
175
|
+
return resolveAgentAccount(account, isCodexExec(exec) ? 'codex' : 'claude_code', env)?.key;
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return undefined;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Copy a home's conversation sessions into the employee's host home; the
|
|
183
|
+
* host's own entries win. Each copied conversation sticks to the login it ran
|
|
184
|
+
* on (`login`), so its first hosted turn resumes where the transcript is.
|
|
185
|
+
*/
|
|
186
|
+
function copySessions(fromHome, agentId, env, login) {
|
|
187
|
+
const from = path.join(fromHome, 'sessions.json');
|
|
188
|
+
let entries = [];
|
|
189
|
+
try {
|
|
190
|
+
const parsed = JSON.parse(readFileSync(from, 'utf8'));
|
|
191
|
+
entries = Array.isArray(parsed) ? parsed : [];
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
const target = SessionStore.load({ ...env, PROHOST_HOME: employeeHome(agentId, env) });
|
|
197
|
+
const ledger = login ? HostLedger.load(env) : undefined;
|
|
198
|
+
let copied = 0;
|
|
199
|
+
for (const entry of entries) {
|
|
200
|
+
if (typeof entry?.key !== 'string' || typeof entry.session_id !== 'string')
|
|
201
|
+
continue;
|
|
202
|
+
if (!entry.key.startsWith(`${agentId}/`))
|
|
203
|
+
continue;
|
|
204
|
+
if (target.get(entry.key))
|
|
205
|
+
continue;
|
|
206
|
+
target.remember(entry.key, entry.session_id);
|
|
207
|
+
if (ledger && login && !ledger.conversationLogin(entry.key))
|
|
208
|
+
ledger.setConversationLogin(entry.key, login);
|
|
209
|
+
copied += 1;
|
|
210
|
+
}
|
|
211
|
+
return copied;
|
|
212
|
+
}
|
|
213
|
+
export function runMigrate(options) {
|
|
214
|
+
const env = options.env ?? process.env;
|
|
215
|
+
const run = options.run ?? defaultServiceRunner;
|
|
216
|
+
const report = { adopted: [], skipped: [], codeHostHome: null, servicesStopped: [], notes: [] };
|
|
217
|
+
// The running service is the ledger's only writer: migrating beside it could
|
|
218
|
+
// drop a run it just claimed. Stop it first (a dry run changes nothing).
|
|
219
|
+
const lock = options.dryRun ? undefined : acquireHostLock(env);
|
|
220
|
+
if (lock && !lock.ok) {
|
|
221
|
+
report.refused = `the host service is running (pid ${lock.heldBy}); stop it first (prohost host uninstall, or quit it), then migrate`;
|
|
222
|
+
return report;
|
|
223
|
+
}
|
|
224
|
+
try {
|
|
225
|
+
return migrateLocked(options, env, run, report);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
if (lock?.ok)
|
|
229
|
+
lock.release();
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
function migrateLocked(options, env, run, report) {
|
|
233
|
+
const earlier = loadAdopted(env);
|
|
234
|
+
const previous = new Map((earlier?.homes ?? []).map((home) => [path.resolve(home.home), home]));
|
|
235
|
+
// Homes adopted before are looked at again, so their settings are kept.
|
|
236
|
+
const candidates = findCandidates({ ...options, homes: [...(options.homes ?? []), ...[...previous.values()].map((h) => h.home)] });
|
|
237
|
+
for (const candidate of candidates) {
|
|
238
|
+
if (!existsSync(path.join(candidate.home, 'agent.json'))) {
|
|
239
|
+
if (candidate.args)
|
|
240
|
+
report.skipped.push({ home: candidate.home, reason: 'no agent.json' });
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
let credentials;
|
|
244
|
+
try {
|
|
245
|
+
credentials = loadCredentials({ ...env, PROHOST_HOME: candidate.home });
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
report.skipped.push({ home: candidate.home, reason: err instanceof Error ? err.message : String(err) });
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const agentId = credentials.agent?.id;
|
|
252
|
+
if (typeof agentId !== 'string' || !/^[A-Za-z0-9-]{1,64}$/.test(agentId)) {
|
|
253
|
+
report.skipped.push({ home: candidate.home, reason: 'agent.json names no employee' });
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
const flags = harnessFlags(candidate.args ?? []);
|
|
257
|
+
const codeHosting = loadCodeHostSettings({ ...env, PROHOST_HOME: candidate.home }).enabled;
|
|
258
|
+
// A home that hosts terminals and runs no known agent is the terminal-hosting home.
|
|
259
|
+
const hostOnly = flags.hostOnly || (!flags.exec && codeHosting && (candidate.args !== undefined || candidate.explicit === true));
|
|
260
|
+
const ownWorkspace = path.join(candidate.home, 'workspace');
|
|
261
|
+
// Read from the plist this run, else kept from an earlier adoption (whose plist is gone).
|
|
262
|
+
const before = candidate.args ? undefined : previous.get(path.resolve(candidate.home));
|
|
263
|
+
const workdir = flags.workdir ?? before?.workdir ?? ownWorkspace;
|
|
264
|
+
const ownOnly = path.resolve(workdir) === path.resolve(ownWorkspace);
|
|
265
|
+
if (!ownOnly && !before) {
|
|
266
|
+
report.notes.push(`${credentials.agent.name}: ran in ${workdir}; once placed on this computer its conversations start fresh sessions`);
|
|
267
|
+
}
|
|
268
|
+
const adopted = before
|
|
269
|
+
? { ...before, agent_id: agentId, agent_name: credentials.agent.name, sessions_copied: 0 }
|
|
270
|
+
: {
|
|
271
|
+
home: candidate.home,
|
|
272
|
+
agent_id: agentId,
|
|
273
|
+
agent_name: credentials.agent.name,
|
|
274
|
+
exec: hostOnly ? null : (flags.exec ?? null),
|
|
275
|
+
host_only: hostOnly,
|
|
276
|
+
workdir,
|
|
277
|
+
...(ownOnly ? { hosted_workdir: ownWorkspace } : {}),
|
|
278
|
+
...(flags.concurrency !== undefined ? { concurrency: flags.concurrency } : {}),
|
|
279
|
+
...(flags.repos ? { repos: flags.repos } : {}),
|
|
280
|
+
...(flags.timeoutSeconds !== undefined ? { timeout_seconds: flags.timeoutSeconds } : {}),
|
|
281
|
+
...(flags.idleTimeoutSeconds !== undefined ? { idle_timeout_seconds: flags.idleTimeoutSeconds } : {}),
|
|
282
|
+
...(flags.safeTools ? { safe_tools: true } : {}),
|
|
283
|
+
...(candidate.launchd ? { launchd: candidate.launchd } : {}),
|
|
284
|
+
sessions_copied: 0,
|
|
285
|
+
};
|
|
286
|
+
if ((adopted.host_only || codeHosting) && report.codeHostHome === null)
|
|
287
|
+
report.codeHostHome = candidate.home;
|
|
288
|
+
if (!adopted.host_only && !options.dryRun) {
|
|
289
|
+
adopted.sessions_copied = copySessions(candidate.home, agentId, env, loginKeyFor(adopted.exec, credentials.account, env));
|
|
290
|
+
}
|
|
291
|
+
if (!adopted.host_only && !adopted.exec) {
|
|
292
|
+
report.notes.push(`${credentials.agent.name}: no --exec found; it is served only once placed on this computer`);
|
|
293
|
+
}
|
|
294
|
+
report.adopted.push(adopted);
|
|
295
|
+
}
|
|
296
|
+
report.codeHostHome ??= earlier?.code_host_home ?? null;
|
|
297
|
+
if (options.dryRun)
|
|
298
|
+
return report;
|
|
299
|
+
if (!options.keepServices) {
|
|
300
|
+
const uid = options.uid ?? process.getuid?.() ?? 0;
|
|
301
|
+
for (const home of report.adopted) {
|
|
302
|
+
if (!home.launchd)
|
|
303
|
+
continue;
|
|
304
|
+
run('launchctl', ['bootout', `gui/${uid}/${home.launchd.label}`]);
|
|
305
|
+
rmSync(home.launchd.plist, { force: true });
|
|
306
|
+
report.servicesStopped.push(home.launchd.label);
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
writePrivateFile(adoptedPath(env), {
|
|
310
|
+
v: 1,
|
|
311
|
+
adopted_at: new Date().toISOString(),
|
|
312
|
+
homes: report.adopted,
|
|
313
|
+
code_host_home: report.codeHostHome,
|
|
314
|
+
});
|
|
315
|
+
const computer = tryLoadComputer(env);
|
|
316
|
+
if (computer && report.codeHostHome && computer.code_host_home !== report.codeHostHome) {
|
|
317
|
+
saveComputer({ ...computer, code_host_home: report.codeHostHome }, env);
|
|
318
|
+
}
|
|
319
|
+
return report;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Get this machine's computer credential from one of the adopted homes' agent
|
|
323
|
+
* keys (`POST /v1/computers/pairing/adopt`) — no pairing code, no approval:
|
|
324
|
+
* the pairing's creator already owns the machine. Tried home by home until one
|
|
325
|
+
* is accepted; nothing is saved unless one is.
|
|
326
|
+
*/
|
|
327
|
+
export async function adoptComputer(options) {
|
|
328
|
+
const env = options.env ?? process.env;
|
|
329
|
+
if (tryLoadComputer(env))
|
|
330
|
+
return { status: 'already_paired' };
|
|
331
|
+
let facts;
|
|
332
|
+
try {
|
|
333
|
+
facts = options.facts ?? machineFacts({ env, name: options.name });
|
|
334
|
+
}
|
|
335
|
+
catch (err) {
|
|
336
|
+
return { status: 'not_adopted', reason: err instanceof Error ? err.message : String(err) };
|
|
337
|
+
}
|
|
338
|
+
const homes = [...options.report.adopted].sort((a, b) => Number(a.host_only) - Number(b.host_only));
|
|
339
|
+
let reason = 'no adopted home holds an agent key';
|
|
340
|
+
for (const home of homes) {
|
|
341
|
+
let credentials;
|
|
342
|
+
try {
|
|
343
|
+
credentials = loadCredentials({ ...env, PROHOST_HOME: home.home });
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const response = await callJson({ baseUrl: credentials.base_url, apiKey: credentials.api_key, fetchImpl: options.fetchImpl }, PAIRING_ADOPT_PATH, facts);
|
|
349
|
+
if (response.status === 200 || response.status === 201) {
|
|
350
|
+
const stored = credentialsFrom(response.body, credentials.base_url, facts);
|
|
351
|
+
if (typeof stored === 'string')
|
|
352
|
+
return { status: 'not_adopted', reason: stored };
|
|
353
|
+
saveComputer({ ...stored, ...(options.report.codeHostHome ? { code_host_home: options.report.codeHostHome } : {}) }, env);
|
|
354
|
+
return { status: 'adopted', computer_id: stored.computer_id, account: stored.account };
|
|
355
|
+
}
|
|
356
|
+
reason =
|
|
357
|
+
response.status === 0
|
|
358
|
+
? `could not reach ProhostAI (${response.error ?? 'no answer'})`
|
|
359
|
+
: response.status === 404
|
|
360
|
+
? 'ProhostAI did not adopt this machine from these pairings (already has a key, or not yours to manage) — add it with: prohost host pair'
|
|
361
|
+
: `adopt answered HTTP ${response.status}`;
|
|
362
|
+
}
|
|
363
|
+
return { status: 'not_adopted', reason };
|
|
364
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prohost host pair` — add this computer to a workspace.
|
|
3
|
+
*
|
|
4
|
+
* Two ways in:
|
|
5
|
+
*
|
|
6
|
+
* - **Device code** (default). The CLI asks for a code, prints it with the
|
|
7
|
+
* page to enter it on, and polls until someone in the workspace approves it
|
|
8
|
+
* (`/v1/computers/pairing/start` then `/poll`). Nothing secret is printed:
|
|
9
|
+
* the code is only good for the person who approves it, for 15 minutes.
|
|
10
|
+
* - **Redeem token** (`--token`). The signed-in desktop app asks the server
|
|
11
|
+
* for a one-time token and hands it here (`/v1/computers/pairing/redeem`).
|
|
12
|
+
* The token is a secret and is never printed.
|
|
13
|
+
*
|
|
14
|
+
* Either way the answer is the computer credential, stored in
|
|
15
|
+
* `$PROHOST_HOME/host/computer.json` (0600). `--expect-account` refuses — and
|
|
16
|
+
* does not store — a credential for any other workspace.
|
|
17
|
+
*
|
|
18
|
+
* A program that runs this reads `$PROHOST_PAIR_RESULT_FILE` (the same
|
|
19
|
+
* variable `agent pair` writes), never the output: `{version: 1, outcome,
|
|
20
|
+
* computer_id, account: {id, name}, message}`.
|
|
21
|
+
*/
|
|
22
|
+
import type { ProhostAccount } from '../agent/credentials.js';
|
|
23
|
+
import type { ComputerCredentials, MachineFacts } from './computer.js';
|
|
24
|
+
export declare const HOST_PAIR_OUTCOMES: readonly ["paired", "expired", "token_rejected", "workspace_unconfirmed", "declined", "rate_limited", "unreachable", "invalid_arguments", "failed"];
|
|
25
|
+
export type HostPairOutcome = (typeof HOST_PAIR_OUTCOMES)[number];
|
|
26
|
+
export interface HostPairResult {
|
|
27
|
+
version: 1;
|
|
28
|
+
outcome: HostPairOutcome;
|
|
29
|
+
computer_id: string | null;
|
|
30
|
+
account: ProhostAccount | null;
|
|
31
|
+
/** For display only; may hold server-chosen text (the workspace name). */
|
|
32
|
+
message: string;
|
|
33
|
+
}
|
|
34
|
+
export interface HostPairOptions {
|
|
35
|
+
baseUrl: string;
|
|
36
|
+
name?: string;
|
|
37
|
+
token?: string;
|
|
38
|
+
expectAccount?: string;
|
|
39
|
+
env?: NodeJS.ProcessEnv;
|
|
40
|
+
fetchImpl?: typeof fetch;
|
|
41
|
+
sleep?: (ms: number) => Promise<void>;
|
|
42
|
+
now?: () => number;
|
|
43
|
+
log?: (line: string) => void;
|
|
44
|
+
/** Tests pin what pairing reports about the machine. */
|
|
45
|
+
facts?: MachineFacts;
|
|
46
|
+
signal?: AbortSignal;
|
|
47
|
+
/**
|
|
48
|
+
* Asked before anything is saved when no `--expect-account` vouched for the
|
|
49
|
+
* workspace (an interactive terminal): "Add this computer to <workspace>?".
|
|
50
|
+
* Unset (a program running it), only `--expect-account` decides.
|
|
51
|
+
*/
|
|
52
|
+
confirm?: (account: ProhostAccount) => Promise<boolean>;
|
|
53
|
+
}
|
|
54
|
+
/** Write the result file, atomically and 0600, when `$PROHOST_PAIR_RESULT_FILE` asks for one. */
|
|
55
|
+
export declare function writeHostPairResult(value: HostPairResult, env?: NodeJS.ProcessEnv, warn?: (line: string) => void): void;
|
|
56
|
+
/** Turn a 200 pairing answer into what is stored, or say what is missing. */
|
|
57
|
+
export declare function credentialsFrom(body: Record<string, unknown> | undefined, baseUrl: string, facts: MachineFacts): ComputerCredentials | string;
|
|
58
|
+
/**
|
|
59
|
+
* Pair this computer. Never throws for an outcome; the result says what
|
|
60
|
+
* happened and is what the command prints and writes to the result file.
|
|
61
|
+
*/
|
|
62
|
+
export declare function runHostPair(options: HostPairOptions): Promise<HostPairResult>;
|