@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/README.md +266 -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 +12 -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 +116 -1
  14. package/dist/agent/run.js +217 -29
  15. package/dist/codehost/host.d.ts +25 -0
  16. package/dist/codehost/host.js +16 -1
  17. package/dist/codehost/host_stats.d.ts +49 -2
  18. package/dist/codehost/host_stats.js +153 -2
  19. package/dist/codehost/relay.d.ts +10 -0
  20. package/dist/codehost/relay.js +17 -1
  21. package/dist/host/api.d.ts +82 -0
  22. package/dist/host/api.js +122 -0
  23. package/dist/host/capacity.d.ts +41 -0
  24. package/dist/host/capacity.js +48 -0
  25. package/dist/host/command.d.ts +59 -0
  26. package/dist/host/command.js +597 -0
  27. package/dist/host/computer.d.ts +120 -0
  28. package/dist/host/computer.js +291 -0
  29. package/dist/host/events.d.ts +24 -0
  30. package/dist/host/events.js +47 -0
  31. package/dist/host/install.d.ts +154 -0
  32. package/dist/host/install.js +784 -0
  33. package/dist/host/ledger.d.ts +52 -0
  34. package/dist/host/ledger.js +131 -0
  35. package/dist/host/legacy.d.ts +38 -0
  36. package/dist/host/legacy.js +115 -0
  37. package/dist/host/logins.d.ts +109 -0
  38. package/dist/host/logins.js +190 -0
  39. package/dist/host/migrate.d.ts +165 -0
  40. package/dist/host/migrate.js +364 -0
  41. package/dist/host/pair.d.ts +62 -0
  42. package/dist/host/pair.js +219 -0
  43. package/dist/host/peak_memory.d.ts +44 -0
  44. package/dist/host/peak_memory.js +139 -0
  45. package/dist/host/scheduler.d.ts +86 -0
  46. package/dist/host/scheduler.js +186 -0
  47. package/dist/host/service.d.ts +114 -0
  48. package/dist/host/service.js +1027 -0
  49. package/dist/host/session_move.d.ts +48 -0
  50. package/dist/host/session_move.js +106 -0
  51. package/dist/host/skills.d.ts +29 -0
  52. package/dist/host/skills.js +135 -0
  53. package/dist/host/status.d.ts +91 -0
  54. package/dist/host/status.js +130 -0
  55. package/dist/index.d.ts +4 -0
  56. package/dist/index.js +47 -3
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,92 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.1
10
+
11
+ Review follow-ups to `prohost host` that landed on its branch after #22455 merged:
12
+
13
+ - **Windows dedicated install:** the service home is `C:\ProgramData\Prohost\homes\<account>`, made in
14
+ one step with administrators-only access, given the computer credential (`wx`), and only then
15
+ opened to the account — the account can never swap a path in it while an administrator writes
16
+ there. An existing home is never written into. `--service-home` is refused on Windows (only that folder can
17
+ be prepared safely), and an install over a 0.16.0 Windows dedicated service (its home was in the
18
+ account's profile, under another task name) stops and says how to remove it and carry its
19
+ computer over (`--from-home`), rather than leaving two services on one key.
20
+ - **Claude token scoping:** an adopted Claude harness's sign-in checks add the dedicated install's
21
+ Claude token only when they run its own Claude binary; the account snapshot's Codex checks never
22
+ see it.
23
+ - **Muted threads:** an answer dropped because the thread is muted (`409 ai_muted`) — the final one
24
+ or an early one, Claude or Codex — makes the run forget the conversation's session, so after
25
+ unmuting nothing resumes from a turn nobody saw.
26
+
27
+ ## 0.16.0
28
+
29
+ - **`prohost host` — one service per computer, many employees.** A computer is added once
30
+ (`host pair`: device code, or `--token` for the desktop app's self-add; `--name`,
31
+ `--expect-account`, `$PROHOST_PAIR_RESULT_FILE`) and serves every AI employee placed on it on
32
+ one stream authenticated as the computer (`$PROHOST_HOME/host/computer.json`, 0600).
33
+ - Runs are claimed only when a slot is free (`POST /v1/computers/{id}/runs/{run_id}/claim`,
34
+ exactly once; `409` → skipped) and executed like `agent run` runs, as the employee with the
35
+ run's own token (heartbeat, tool events, reply, a per-run MCP config, completion with
36
+ `peak_mem_mb` / `login_moved`). The claim's skills are written into the employee's workspace
37
+ (`.claude/skills/<key>/`; `.codex/skills/<key>/` plus an `AGENTS.md` list). A run claimed
38
+ before a restart is reported, never re-run. The ledger is keyed by employee and conversation.
39
+ - Run order: someone waiting first, then turns between employees, then oldest; conversations
40
+ take turns; per-employee `max_concurrent` and `keep_one_ready`; terminal sessions never wait
41
+ and count against capacity.
42
+ - Capacity recommended from memory, cores and workload (max(8 GB, 25 %) reserve, ~1.5 GB chat /
43
+ ~4 GB coding, ≤ half the cores, 1–16), configured in ProhostAI. Holds new work when paused,
44
+ under critical memory pressure or on battery (each configurable), with `hold_started` /
45
+ `hold_ended` events. Each run's peak memory is sampled from its process tree.
46
+ - `POST /v1/computers/{id}/status` every 30 s and on change (host stats, slots, AI logins with
47
+ plan and quota windows — never the email — running work, capabilities, `recommended_capacity`,
48
+ `dedicated`); the answer is the computer's config.
49
+ - Opt-in sharing between AI logins of the same kind: a conversation sticks to its login; when
50
+ that login rests, its Claude session (`projects/<project>/<id>.jsonl`) or Codex rollout
51
+ (including a WSL `CODEX_HOME`) is copied to another login and the run resumes with an
52
+ "interrupted" note (`login_resting`, `login_moved`); otherwise the run waits for the reset.
53
+ Claude Code and Codex are never mixed.
54
+ - Terminal sessions (Code threads) are hosted by the same service with the computer credential.
55
+ - `host status --json`, `host logins --json` (plan and the local-only `email_local`),
56
+ `host pause` / `resume`, `$PROHOST_HOME/host/activity.json`.
57
+ - `host install [--dedicated --service-user <name>]` / `uninstall`: launchd (user agent / system
58
+ daemon with `UserName`), systemd (user / system unit with `User=`), Windows Task Scheduler (at
59
+ logon / at boot as the service user via S4U). Stopping or an update drains: no new work, runs
60
+ going finish, then it exits (75 after an update) for the service manager to restart it.
61
+ - `host migrate` adopts existing agent homes (sessions carried over; served with their own
62
+ credential, in their own `--workdir`, until placed on the computer) and the desktop app's
63
+ terminal-hosting home (`ai.prohost.desktop[.dev].code-host`), stops the old launchd agents it
64
+ adopted (`--keep-services`, `--dry-run`, `--json`), and keeps earlier adoptions on a re-run.
65
+ It refuses while the host service runs (the service owns the ledger).
66
+ - One `host run` per home (`host/service.lock`); each employee's runs share one session ledger.
67
+ - For the desktop app: `host install|uninstall --label <id>` (default derived from the home);
68
+ services run the CLI with the executable that launched it, keeping `ELECTRON_RUN_AS_NODE=1`
69
+ and the launching `PATH` under Electron; re-running install leaves a right service alone.
70
+ `host migrate --from <home>` (repeatable, read-only) and computer adoption through an agent
71
+ key (`/v1/computers/pairing/adopt`). `host logins add --runtime …`, `host unpair`.
72
+ `host status --json` always exits 0 and adds `hardware`, `events` (24 h, stable ids) and
73
+ `service`. `host pause` also stops new terminal sessions (open ones carry on).
74
+ - Dedicated: creates the service account (macOS / Linux), copies this home's `computer.json`,
75
+ installs `--claude-token-file` for `CLAUDE_CODE_OAUTH_TOKEN`, logs to the journal /
76
+ root-owned `/Library/Logs/Prohost` / ACL'd ProgramData (never under the service user's
77
+ home), and publishes its state for the logged-in user's `host status` / `host pause`.
78
+ - `host pair` names the approved workspace and, in a terminal without a matching
79
+ `--expect-account`, asks before saving; a declined or mismatched key is revoked.
80
+ - Terminal sessions use the computer key on `/v1/code-hosts/control`, falling back to the
81
+ adopted agent key on a server that refuses it.
82
+ - The send rule: replies carry the agent's `confidence` / `reply_class` (a trailer line it is
83
+ asked for, stripped before sending — a missing or malformed one sends neither, never a
84
+ default); a reply held as a suggestion, or refused because the thread is muted (`ai_muted`),
85
+ is handled, not failed.
86
+ - `claude auth status --json`'s `subscriptionType` is now read (the email only for local display).
87
+ - Host stats on Linux (`/proc/meminfo`, `/proc/pressure/memory`, `/sys/class/power_supply`) and
88
+ Windows (`typeperf` counters, `Win32_Battery`), beside macOS.
89
+ - `prohost agent run` and every other command are unchanged.
90
+ - **`agent install-daemon` re-install no longer fails while the old agent drains.** After
91
+ `bootout` it waits (bounded at 90 s) for `launchctl print` to say the old job has left launchd
92
+ before `bootstrap`; bootstrapping while the old agent finished its in-flight run failed with
93
+ "Input/output error". A bootstrap that still fails says when the old agent had not stopped.
94
+
9
95
  ## 0.15.0
10
96
 
11
97
  - **Code threads on this Mac: scroll back locally, no flashing, no permission prompts.**
package/README.md CHANGED
@@ -1,7 +1,11 @@
1
1
  # `@prohost/cli`
2
2
 
3
- The [ProhostAI](https://prohost.ai) command-line tool. Three things it does:
3
+ The [ProhostAI](https://prohost.ai) command-line tool. Four things it does:
4
4
 
5
+ - **`prohost host`** — add this computer to your workspace once, and it works
6
+ for every AI employee you place on it: Claude Code or Codex, on the AI logins
7
+ signed in here, several things at once, plus the terminal sessions you open
8
+ on it. See [Computers](#computers-prohost-host).
5
9
  - **`prohost agent`** — run your own local agent as a ProhostAI teammate. Someone
6
10
  @-mentions it in the app, it runs on your machine, and its answer is posted
7
11
  back into the conversation.
@@ -41,6 +45,267 @@ The rest of this page is detail on each step.
41
45
 
42
46
  ---
43
47
 
48
+ ## Computers: `prohost host`
49
+
50
+ A computer is added to a workspace once and then serves **every** AI employee
51
+ placed on it — one background service, one connection, many employees, each
52
+ running Claude Code or Codex on the AI logins signed in on this machine. It also
53
+ hosts the terminal sessions people open on it from ProhostAI.
54
+
55
+ ```bash
56
+ npx @prohost/cli host pair # prints a code; enter it in ProhostAI → AI Employees → Computers
57
+ npx @prohost/cli host install # keep it running in the background
58
+ npx @prohost/cli host status # what it is doing
59
+ ```
60
+
61
+ Then pick the computer under **Where it works** when you hire (or edit) an AI
62
+ employee. `prohost agent run` keeps working exactly as before for anything you
63
+ paired that way.
64
+
65
+ ### Commands
66
+
67
+ | Command | What it does |
68
+ | --- | --- |
69
+ | `host pair [--name <n>] [--expect-account <id>]` | Device-code pairing: prints a code and the page to enter it on, waits for approval, stores the computer credential in `$PROHOST_HOME/host/computer.json` (0600). |
70
+ | `host pair --token <redeem_token>` | Redeems a one-time token from the signed-in desktop app instead (`--token -` reads it from stdin; `$PROHOST_REDEEM_TOKEN` also works). The token is never printed. |
71
+ | `host run [--machine <n>] [--timeout <s>] [--idle-timeout <s>] [--workload coding\|chat]` | The service, in the foreground. |
72
+ | `host install [--label <id>] [--dedicated --service-user <name> [--service-home <dir>] [--claude-token-file <path>]]` / `host uninstall [--label <id>]` | The background service for this OS (below). Safe to re-run: nothing that is already right is touched. |
73
+ | `host unpair` | Remove this computer from its workspace (`DELETE /v1/computers/{id}` with its own key), then delete `computer.json`. Exits 0 only when the server removed it, or had already. |
74
+ | `host status [--json] [--label <id>]` | Online, paused, capacity, what is running, AI logins, hardware, recent events. Always exits 0. |
75
+ | `host logins [--json]` | The AI logins found on this machine, with their plan and (locally only) email. |
76
+ | `host logins add --runtime claude_code\|codex [--label <l>]` | A new AI login in its own config directory (`~/.prohost-accounts`), signed in right here (`claude auth login` / `codex login`). |
77
+ | `host pause` / `host resume` | Take no new work and open no new terminal sessions / take them again. Work already going finishes. |
78
+ | `host migrate [--from <home>]… [--home <dir,…>] [--keep-services] [--dry-run] [--json]` | Adopt existing agent homes and the desktop app's terminal-hosting home, and become a computer through their agent keys (below). |
79
+
80
+ `--expect-account` works as it does for `agent pair`: a computer approved in any
81
+ other workspace is refused and nothing is stored. `$PROHOST_PAIR_RESULT_FILE`
82
+ gets `{"version": 1, "outcome", "computer_id", "account": {"id", "name"}, "message"}`
83
+ (outcomes: `paired`, `expired`, `token_rejected`, `workspace_unconfirmed`,
84
+ `declined`, `rate_limited`, `unreachable`, `invalid_arguments`, `failed`), written
85
+ 0600 and atomically, never holding the code, token or key.
86
+
87
+ Once approved, pairing prints the workspace it was approved in. In a terminal,
88
+ unless `--expect-account` already vouched for it, it asks "Add this computer to
89
+ <workspace>? [y/N]" before saving anything; no (or a workspace that doesn't match
90
+ `--expect-account`) revokes the new key on the server and saves nothing. A
91
+ program (no terminal) relies on `--expect-account`.
92
+
93
+ ### How a run works on a computer
94
+
95
+ 1. ProhostAI sends `agent.run_requested` on the computer's stream (signed with
96
+ the pairing's secret; an unsigned frame is refused and never run).
97
+ 2. The run waits its turn (below). When a slot is free and its AI login has
98
+ room, the computer **claims** it (`POST /v1/computers/{id}/runs/{run_id}/claim`).
99
+ Exactly one computer ever gets a claim; a `409` means another computer took
100
+ it, and it is skipped.
101
+ 3. The claim returns the run, a short-lived **run token** and the employee's
102
+ **skills**. The skills are written into the employee's workspace —
103
+ `.claude/skills/<key>/…` for Claude Code; `.codex/skills/<key>/…` plus a
104
+ list in `AGENTS.md` for Codex — and skills no longer granted are removed.
105
+ 4. The run executes exactly like an `agent run` run (same prompt, sessions,
106
+ tool events, reply, completion) but **as the employee, with the run token**:
107
+ the heartbeat, tool events, reply, its MCP config (a file of its own,
108
+ removed afterwards) and its completion all carry that token, never the
109
+ computer's key. The completion adds the run's `peak_mem_mb` and, if it moved,
110
+ `login_moved`.
111
+ 5. A run claimed but never reported (the service stopped mid-run) is reported
112
+ failed on the next start, with its own token — it is never run again.
113
+
114
+ Employees on a computer **skip permission prompts** (Claude
115
+ `--dangerously-skip-permissions`, Codex `--yolo`); message approval rules still
116
+ apply on the server. Each employee has its own home under
117
+ `$PROHOST_HOME/host/employees/<agent_id>/` (its sessions, workspace and
118
+ `extra-mcp.json` if you add one).
119
+
120
+ ### Run order
121
+
122
+ Someone waiting (a mention, a DM, a customer's message) goes first; then the
123
+ employees take turns (the one with the fewest runs going, then the one that
124
+ started longest ago); then oldest first. Runs in one conversation always take
125
+ turns. Terminal sessions never wait — they count against capacity, so they
126
+ shrink what the employees get. Each employee's **Up to N** caps its own runs,
127
+ and **Keep one ready** keeps a slot free for it while it has nothing running.
128
+
129
+ ### Capacity and holds
130
+
131
+ Capacity is how many things the computer works on at once. It is set in
132
+ ProhostAI; until it is, the recommendation is used: memory after a reserve of
133
+ max(8 GB, 25 %), divided by ~1.5 GB per chat run or ~4 GB per coding run
134
+ (`--workload`), at most half the cores, between 1 and 16.
135
+
136
+ New work is **held** (work going is never stopped) while the computer is paused
137
+ (`host pause`, or in ProhostAI), under critical memory pressure, or on battery —
138
+ each hold can be switched off in ProhostAI. Holds are reported as they start and
139
+ end. The service reports every 30 seconds and on any change: CPU, memory, memory
140
+ pressure (macOS `kern.memorystatus_vm_pressure_level`; Linux
141
+ `/proc/pressure/memory`; Windows performance counters through `typeperf`),
142
+ battery, slots, AI logins and what is running with each run's peak memory
143
+ (the agent process and everything it started, sampled every 5 s).
144
+
145
+ ### AI logins and sharing between them
146
+
147
+ A login is a Claude Code or Codex sign-in: `~/.claude`, `~/.codex`, and every
148
+ account in `~/.prohost-accounts` (`prohost agent account add`). The plan comes
149
+ from `claude auth status --json` (`subscriptionType`, e.g. "Claude Max") or
150
+ Codex's rate-limit read ("ChatGPT Pro"). The email in `claude auth status`
151
+ is shown by `host logins` on this machine only; it is never sent.
152
+
153
+ A conversation sticks to the login holding its session. New conversations go to
154
+ the login with the most room. When a login hits its usage limit it **rests**
155
+ until its reset, and ProhostAI is told (`login_resting`). Then:
156
+
157
+ - **Sharing on** (per computer, per kind, in ProhostAI): the conversation's
158
+ session file is copied to another login **of the same kind** — Claude Code's
159
+ `projects/<project>/<session>.jsonl` under the other `CLAUDE_CONFIG_DIR`, or
160
+ Codex's rollout under the other `CODEX_HOME` (a WSL `CODEX_HOME` given as
161
+ `wsl://<distro>/<path>` is reached through `\\wsl.localhost\<distro>\…`) —
162
+ and the run resumes there, told it was interrupted and to check what is
163
+ already done (`login_moved`). Claude Code and Codex are never mixed.
164
+ - **Sharing off, or no other login has room**: a run going is reported failed
165
+ with the limit and its reset time, so ProhostAI runs it again after the reset;
166
+ runs not started yet wait for the earliest reset.
167
+
168
+ ### The background service
169
+
170
+ | | `host install` | `host install --dedicated --service-user <name>` |
171
+ | --- | --- | --- |
172
+ | macOS | LaunchAgent `<label>` (while you are logged in) | LaunchDaemon `/Library/LaunchDaemons/<label>.plist` with `UserName` |
173
+ | Linux | systemd user unit (`loginctl enable-linger` to outlive your session) | system unit in `/etc/systemd/system` with `User=` |
174
+ | Windows | Task Scheduler task at logon | the same task at boot, running as the service user (S4U: no password stored) |
175
+
176
+ The label is `ai.prohost.host` for `~/.prohost` (unit `prohost-host.service`,
177
+ task `\ProhostAI\Host`) and one derived from `$PROHOST_HOME` for any other home,
178
+ so two homes never replace each other's service; `--label` sets it (the desktop
179
+ app passes `ai.prohost.desktop.host` / `ai.prohost.desktop.dev.host`).
180
+
181
+ Dedicated mode keeps working when no one is logged in; install it as root /
182
+ from an administrator prompt. It:
183
+
184
+ - creates the service account if it doesn't exist (macOS: a hidden user, ids
185
+ 401–499; Linux: a system user; on Windows the account must already exist —
186
+ creating one there means choosing a password policy);
187
+ - uses that account's `~/.prohost` (`--service-home` sets another), copies your
188
+ `computer.json` into it if it has none (pair as yourself, then install; under
189
+ sudo "yours" is `$SUDO_USER`'s `~/.prohost`, or `--from-home <dir>`), and
190
+ installs `--claude-token-file` (a `claude setup-token` token) there for every
191
+ Claude run (adopted Claude agents too) and Claude login check as
192
+ `CLAUDE_CODE_OAUTH_TOKEN` (never to Codex, never on a command line, so not to
193
+ terminal sessions either: they would carry it in tmux's arguments) — read
194
+ once, never printed, re-read each time so installing a new one needs no
195
+ restart. Everything in the
196
+ account's home is written as the account (`sudo -u` / `runuser`), never by
197
+ root through a path the account controls (a `--service-home` the account
198
+ can't create is made by root only under a root-owned folder, then handed
199
+ over). On Windows the ProgramData folders are made in one step or, if they
200
+ were there, kept only when Administrators / SYSTEM own them; each is taken over
201
+ top-down (owner Administrators, `/reset`, exactly our grants), checked again
202
+ afterwards (owner, no junction, no other principal), and the token is always a
203
+ new file (`wx`) in
204
+ `C:\ProgramData\Prohost\secrets\<label>` (the account may only read it), and
205
+ the account's service home is `C:\ProgramData\Prohost\homes\<account>`:
206
+ made in one step with administrators-only access, given the credential, and
207
+ only then opened to the account (an existing one is never written into). If your own (personal) service runs on the same computer
208
+ key, install says so and how to remove it;
209
+ - logs where the service account can't redirect it: the journal on Linux,
210
+ `/Library/Logs/Prohost/<label>.log` (in a root-owned folder) on macOS,
211
+ `C:\ProgramData\Prohost\Logs` (ACL: SYSTEM, administrators, the service
212
+ account) on Windows;
213
+ - publishes its state for anyone logged in on the computer, without login
214
+ emails, in a root-owned `/Library/Application Support/Prohost/hosts/<label>`,
215
+ `/var/lib/prohost/hosts/<label>` or `C:\ProgramData\Prohost\hosts\<label>`: the service owns only
216
+ `state.json`, `activity.json` and `events.json` there, and `control.json` is
217
+ the pause switch anyone on the computer may flip. `host status` and
218
+ `host pause` run as the logged-in user find it there, and
219
+ `sudo prohost host uninstall` finds the service by it (install prints the exact
220
+ command with its `--label`).
221
+
222
+ On Windows it is a boot-start task rather than a Windows service, because Node
223
+ cannot answer the Service Control Manager without a native wrapper.
224
+
225
+ Every variant runs `<executable> <cli> host run` with the `PATH` it was installed
226
+ from: Node, or — under the desktop app — the app's Electron binary, with
227
+ `ELECTRON_RUN_AS_NODE=1` (and `PROHOST_CLI_RUN=1`) kept in the service's
228
+ environment so it runs the CLI rather than opening the app. Personal services
229
+ log to `$PROHOST_HOME/host/host.log`. Re-running `host install` changes nothing
230
+ that is already right (the desktop app runs it after every update); a changed
231
+ definition is loaded after the old service drains. Only one `host run`
232
+ runs per `PROHOST_HOME` (`host/service.lock`); a second one refuses to start.
233
+ The systemd unit uses `KillMode=mixed`, so a stop reaches the host first and its
234
+ runs can finish. Re-installing on macOS waits for the previous service to finish
235
+ draining (up to its 10-minute stop timeout) before loading the new one. A dedicated install hands the service user's `PROHOST_HOME` to
236
+ that user (`chown`); on Windows it checks for an elevated prompt first. **Updates drain:**
237
+ when a newer CLI is installed over the running one (or the service is told to
238
+ stop), it takes no new work, lets the runs going finish, then exits (code 75
239
+ after an update) for the service manager to start the new one. A second
240
+ Ctrl-C (or signal) stops the runs going too.
241
+
242
+ ### Migrating an existing setup: `host migrate`
243
+
244
+ `host migrate` brings agents you already run under the host service without
245
+ pairing anything again. When this home has no computer yet, it becomes one
246
+ through an adopted home's agent key (`POST /v1/computers/pairing/adopt`): the
247
+ pairing's creator owns it, no code to enter. `--from <home>` (repeatable) adopts
248
+ only those homes — nothing is discovered, nothing is stopped, the homes are only
249
+ read — which is how the desktop app hands over its own; exit 0 means adopted, or
250
+ nothing to adopt.
251
+
252
+ - **Agent homes** — every home a launchd agent `ai.prohost.agent.*` runs (its
253
+ `--exec`, `--workdir`, `--concurrency`, `--repos`, timeouts and `--safe-tools`
254
+ are read from the plist), `$PROHOST_HOME`, and each `--home` (the desktop
255
+ app passes the homes of the agents it supervises). Their
256
+ conversation sessions are copied to the employee's host home. Until
257
+ ProhostAI places that employee on this computer, `host run` keeps serving it
258
+ with **its own credential**, as `agent run` did; once it is placed, the
259
+ computer serves it and the old connection is stopped.
260
+ - **The terminal-hosting home** — a `--host-only` home (including the desktop
261
+ app's `ai.prohost.desktop[.dev].code-host` agent), or one with Code-thread
262
+ hosting on. The service hosts terminal sessions from it, so open terminals,
263
+ opened folders and the remote switches carry over.
264
+ - **Old launchd agents** it adopted are stopped and removed (`--keep-services`
265
+ keeps them; then stop them yourself, or each employee is answered twice).
266
+
267
+ It cannot adopt: a systemd unit or Windows task written by hand from
268
+ `agent install-daemon` (pass `--home` and stop the unit yourself); an agent the
269
+ desktop app supervises (the app stops it); an `--exec` that is neither Claude
270
+ Code nor Codex (it keeps running on its own credential and is never served as
271
+ the computer); a home whose `agent.json` is missing, unreadable or revoked; the
272
+ sessions of a home that ran in a custom `--workdir`, once the computer serves it
273
+ (Claude Code files sessions by directory, and the host never runs an employee in
274
+ a checkout — those conversations start fresh; until then its own connection
275
+ keeps running in that `--workdir`); `prohost listen` and `prohost usage`
276
+ daemons. Running `host migrate` again keeps what it adopted before, including
277
+ settings read from plists it has since removed. It refuses while the host
278
+ service is running (the service owns the run ledger): stop it, migrate, then
279
+ start it again. `--dry-run` shows what it would do; `--json` prints the report.
280
+
281
+ ### For the desktop app
282
+
283
+ - `host status --json` (always exits 0) →
284
+ `{paired, computer: {id, name, account: {id, name}}, online, paused, slots: {total, busy, held, held_reason}, running: [{run_id, agent: {id, name, avatar_url}, title, started_at, kind}], logins: [{account_key, label, runtime, subscription_type, plan_label, email_local, auth_state, windows}], capacity, recommended_capacity, hardware: {memory_gb, cpu_cores, os, chip}, events: [{id, kind, at, …}], service: {running, pid, adopted, dedicated, activity_file?}, version}`.
285
+ `online` is false when the service has not written its state for 2 minutes.
286
+ `events` are the last 24 h of what the service sent (`login_resting`,
287
+ `login_moved`, `hold_started`, `hold_ended`, with the `/events` fields plus
288
+ `computer_id` / `computer_name`), newest last, each with a stable `id`.
289
+ `service` says whether a service runs for this home (`host/service.lock`), how
290
+ many homes it adopted, and — run as the logged-in user on a dedicated computer —
291
+ that it is the dedicated service (`activity_file` is then its shared copy).
292
+ - `host logins --json` → `{logins: [...same shape as status...]}`.
293
+ - `$PROHOST_HOME/host/activity.json` → `{v: 1, busy, running: [...as in status...], updatedAt}`.
294
+ - `$PROHOST_HOME/host/state.json` is the service's own state (0600); read
295
+ `status --json` rather than this file.
296
+ - `host pause` / `resume` write `$PROHOST_HOME/host/control.json` (and a
297
+ dedicated service's shared `control.json`); the running service picks it up
298
+ within 2 seconds. Paused, it also starts no new terminal session (the start
299
+ is reported failed, "this computer is paused") and lets no new viewer dial
300
+ in; terminals already open, their replies and approvals carry on.
301
+ - `prohost --help` names what this build can do: `ELECTRON_RUN_AS_NODE`
302
+ (services written for the desktop app), `--claude-token-file`, `host unpair`.
303
+ - Terminal sessions authenticate to `/v1/code-hosts/control` with the computer
304
+ key; a server from before computer keys (`not_paired`) is asked again with the
305
+ adopted terminal-hosting home's agent key. A revoked or invalid key never
306
+ falls back.
307
+
308
+
44
309
  ## Bring your own agent
45
310
 
46
311
  Any agent that reads a prompt on stdin and writes its answer to stdout works —
@@ -155,6 +155,16 @@ export interface AuthCheck {
155
155
  state: AuthState;
156
156
  /** What the login itself says it bills to, when it says. */
157
157
  billing?: AccountBilling;
158
+ /**
159
+ * Claude Code's `subscriptionType` (`max`, `pro`, …), lowercased, when the
160
+ * login says. Safe to send: it names a plan, not a person.
161
+ */
162
+ subscriptionType?: string;
163
+ /**
164
+ * The login's email, only when the caller asked for it (`includeEmail`).
165
+ * Local display only (`prohost host logins`): it is never sent anywhere.
166
+ */
167
+ email?: string;
158
168
  }
159
169
  /** Runs one short command with extra env; resolves exit code and stdout. Never rejects. */
160
170
  export type CommandRunner = (command: string, args: string[], env: NodeJS.ProcessEnv) => Promise<{
@@ -174,6 +184,7 @@ export declare const defaultCommandRunner: CommandRunner;
174
184
  export declare function checkAuth(record: AccountRecord, options?: {
175
185
  binary?: string;
176
186
  run?: CommandRunner;
187
+ includeEmail?: boolean;
177
188
  }): Promise<AuthCheck>;
178
189
  /**
179
190
  * Poll until the operator has finished signing in, or until the deadline.
@@ -520,7 +520,13 @@ export async function checkAuth(record, options = {}) {
520
520
  let parsed;
521
521
  try {
522
522
  const json = JSON.parse(stdout.trim());
523
- parsed = { loggedIn: json.loggedIn, authMethod: json.authMethod };
523
+ // The org is never read; the email only when the caller shows it locally.
524
+ parsed = {
525
+ loggedIn: json.loggedIn,
526
+ authMethod: json.authMethod,
527
+ subscriptionType: json.subscriptionType,
528
+ email: options.includeEmail ? json.email : undefined,
529
+ };
524
530
  }
525
531
  catch {
526
532
  return { state: 'unknown' };
@@ -530,9 +536,14 @@ export async function checkAuth(record, options = {}) {
530
536
  if (parsed.loggedIn !== true)
531
537
  return { state: 'unknown' };
532
538
  const method = typeof parsed.authMethod === 'string' ? parsed.authMethod : '';
539
+ const plan = typeof parsed.subscriptionType === 'string' && /^[A-Za-z0-9_-]{1,32}$/.test(parsed.subscriptionType)
540
+ ? parsed.subscriptionType.toLowerCase()
541
+ : undefined;
533
542
  return {
534
543
  state: 'ok',
535
544
  billing: method === 'claude.ai' ? 'subscription' : method ? 'api_key' : undefined,
545
+ ...(plan ? { subscriptionType: plan } : {}),
546
+ ...(typeof parsed.email === 'string' && parsed.email ? { email: parsed.email } : {}),
536
547
  };
537
548
  }
538
549
  const { code, stdout } = await run(options.binary ?? 'codex', ['login', 'status'], env);
@@ -20,9 +20,21 @@ export interface ApiOptions {
20
20
  * {@link SEND_TIMEOUT_MS} for the reply and the completion.
21
21
  */
22
22
  timeoutMs?: number;
23
+ /**
24
+ * Fields a completion carries beside the terminal state, read when it is
25
+ * sent. `prohost host` adds the run's peak memory and any login move here;
26
+ * `agent run` sets none, so its completions are unchanged.
27
+ */
28
+ completionExtra?: () => Record<string, unknown>;
23
29
  }
24
30
  export interface ApiResult {
25
31
  ok: boolean;
32
+ /**
33
+ * A send the server answered without sending (still `ok`: the run is done):
34
+ * saved as a draft for the team (`suggestion`), or dropped because the thread
35
+ * is muted for AI (`muted`).
36
+ */
37
+ withheld?: 'suggestion' | 'muted';
26
38
  /** HTTP status, or ``0`` when no response arrived at all — see ``failure``. */
27
39
  status: number;
28
40
  error?: string;
package/dist/agent/api.js CHANGED
@@ -191,6 +191,11 @@ export async function completeRun(options, path, outcome, model) {
191
191
  }
192
192
  if (model)
193
193
  body.model = model.slice(0, MAX_MODEL_CHARS);
194
+ // Never over the fields above: the terminal state is what the call is for.
195
+ for (const [key, value] of Object.entries(options.completionExtra?.() ?? {})) {
196
+ if (!(key in body) && value !== undefined)
197
+ body[key] = value;
198
+ }
194
199
  const result = await postJson(options, path, body, undefined, false, SEND_TIMEOUT_MS);
195
200
  if (result.status === 409)
196
201
  return { ...result, ok: true };
@@ -314,6 +314,12 @@ export interface McpConfigOptions {
314
314
  */
315
315
  extraServers?: Record<string, Record<string, unknown>>;
316
316
  env?: NodeJS.ProcessEnv;
317
+ /**
318
+ * Write here instead of `$PROHOST_HOME/mcp.json`. `prohost host` gives each
319
+ * run its own file: the key in it is that run's token, and two runs of one
320
+ * employee must never read each other's.
321
+ */
322
+ file?: string;
317
323
  }
318
324
  /**
319
325
  * The config object handed to `--mcp-config`.
@@ -857,8 +857,8 @@ export function buildMcpConfig(options) {
857
857
  */
858
858
  export function writeMcpConfig(options) {
859
859
  const env = options.env ?? process.env;
860
- const dir = prohostHome(env);
861
- const file = mcpConfigPath(env);
860
+ const file = options.file ?? mcpConfigPath(env);
861
+ const dir = path.dirname(file);
862
862
  mkdirSync(dir, { recursive: true, mode: 0o700 });
863
863
  chmodSync(dir, 0o700);
864
864
  // Written beside the target and renamed over it. Every run rewrites this
@@ -30,6 +30,10 @@ export interface DaemonSpec {
30
30
  logPath: string;
31
31
  /** Extra environment launchd must set — launchd inherits almost nothing. */
32
32
  env?: Record<string, string>;
33
+ /** A system daemon only: the user it runs as (`UserName`). */
34
+ userName?: string;
35
+ /** Seconds launchd waits after SIGTERM before SIGKILL (`ExitTimeOut`). */
36
+ exitTimeoutSeconds?: number;
33
37
  }
34
38
  /**
35
39
  * Label for the daemon serving one `$PROHOST_HOME`.
@@ -114,17 +118,46 @@ type Runner = (command: string, args: string[]) => {
114
118
  status: number | null;
115
119
  stderr: string;
116
120
  };
121
+ /**
122
+ * How long a re-install waits for the old job to leave launchd after its
123
+ * `bootout`. Bootout only sends SIGTERM: `agent run` then finishes its
124
+ * in-flight runs ("Finishing the in-flight run…"), and launchd keeps the label
125
+ * until the process exits — SIGKILL after the job's `ExitTimeOut`, 20 s by
126
+ * default (this plist sets none). A `bootstrap` before then fails with
127
+ * "Input/output error". 90 s covers that with room for a busy Mac; the
128
+ * desktop app waits the same way (`desktop/src/main/code-host/launchd.ts`).
129
+ */
130
+ export declare const OLD_JOB_EXIT_TIMEOUT_MS = 90000;
131
+ /**
132
+ * Ask `launchctl print` until the job is gone (any exit but 0) or the bound
133
+ * passes: whether it went. No answer (`status` null) is never "gone". Says
134
+ * once that it is waiting, and boots the job out once more half-way through
135
+ * (a first bootout that failed outright rather than drained).
136
+ */
137
+ export declare function waitUntilUnloaded(run: Runner, target: string, clock: {
138
+ sleep: (ms: number) => void;
139
+ now: () => number;
140
+ timeoutMs: number;
141
+ onWaiting: (line: string) => void;
142
+ }): boolean;
117
143
  /**
118
144
  * Write the plist and hand it to launchd.
119
145
  *
120
146
  * `bootout` first, ignoring its result: bootstrapping a label that is already
121
147
  * loaded fails outright, so a plain re-install of an agent whose `--exec`
122
148
  * changed would otherwise keep running the old command and report success.
149
+ * Then wait (bounded) until launchd has let the old job go: it drains on
150
+ * SIGTERM, and bootstrapping while it does fails.
123
151
  */
124
152
  export declare function installLaunchdDaemon(spec: DaemonSpec, options?: {
125
153
  home?: string;
126
154
  uid?: number;
127
155
  run?: Runner;
156
+ sleep?: (ms: number) => void;
157
+ now?: () => number;
158
+ exitTimeoutMs?: number;
159
+ /** Progress while the old agent stops (default: stderr). */
160
+ onWaiting?: (line: string) => void;
128
161
  }): InstallResult;
129
162
  /**
130
163
  * Unload the daemon and delete its plist. Missing pieces are not an error.
@@ -116,6 +116,11 @@ export function buildLaunchdPlist(spec) {
116
116
  ' <key>StandardErrorPath</key>',
117
117
  ` <string>${xmlEscape(spec.logPath)}</string>`,
118
118
  ];
119
+ if (spec.userName)
120
+ lines.push(' <key>UserName</key>', ` <string>${xmlEscape(spec.userName)}</string>`);
121
+ if (spec.exitTimeoutSeconds !== undefined) {
122
+ lines.push(' <key>ExitTimeOut</key>', ` <integer>${Math.max(0, Math.floor(spec.exitTimeoutSeconds))}</integer>`);
123
+ }
119
124
  if (spec.env && Object.keys(spec.env).length > 0) {
120
125
  lines.push(' <key>EnvironmentVariables</key>', ' <dict>', ...stringEntries(spec.env, ' '), ' </dict>');
121
126
  }
@@ -205,12 +210,56 @@ const defaultRunner = (command, args) => {
205
210
  const result = spawnSync(command, args, { encoding: 'utf8' });
206
211
  return { status: result.status, stderr: result.stderr ?? '' };
207
212
  };
213
+ /**
214
+ * How long a re-install waits for the old job to leave launchd after its
215
+ * `bootout`. Bootout only sends SIGTERM: `agent run` then finishes its
216
+ * in-flight runs ("Finishing the in-flight run…"), and launchd keeps the label
217
+ * until the process exits — SIGKILL after the job's `ExitTimeOut`, 20 s by
218
+ * default (this plist sets none). A `bootstrap` before then fails with
219
+ * "Input/output error". 90 s covers that with room for a busy Mac; the
220
+ * desktop app waits the same way (`desktop/src/main/code-host/launchd.ts`).
221
+ */
222
+ export const OLD_JOB_EXIT_TIMEOUT_MS = 90_000;
223
+ const OLD_JOB_POLL_MS = 500;
224
+ const sleepSync = (ms) => {
225
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
226
+ };
227
+ /**
228
+ * Ask `launchctl print` until the job is gone (any exit but 0) or the bound
229
+ * passes: whether it went. No answer (`status` null) is never "gone". Says
230
+ * once that it is waiting, and boots the job out once more half-way through
231
+ * (a first bootout that failed outright rather than drained).
232
+ */
233
+ export function waitUntilUnloaded(run, target, clock) {
234
+ const startedAt = clock.now();
235
+ const deadline = startedAt + clock.timeoutMs;
236
+ let said = false;
237
+ let resent = false;
238
+ for (;;) {
239
+ const printed = run('launchctl', ['print', target]);
240
+ if (printed.status !== null && printed.status !== 0)
241
+ return true;
242
+ if (clock.now() >= deadline)
243
+ return false;
244
+ if (!said) {
245
+ said = true;
246
+ clock.onWaiting('Waiting for the previous agent to stop (it finishes what it was doing first)…');
247
+ }
248
+ if (!resent && clock.now() - startedAt >= clock.timeoutMs / 2) {
249
+ resent = true;
250
+ run('launchctl', ['bootout', target]);
251
+ }
252
+ clock.sleep(OLD_JOB_POLL_MS);
253
+ }
254
+ }
208
255
  /**
209
256
  * Write the plist and hand it to launchd.
210
257
  *
211
258
  * `bootout` first, ignoring its result: bootstrapping a label that is already
212
259
  * loaded fails outright, so a plain re-install of an agent whose `--exec`
213
260
  * changed would otherwise keep running the old command and report success.
261
+ * Then wait (bounded) until launchd has let the old job go: it drains on
262
+ * SIGTERM, and bootstrapping while it does fails.
214
263
  */
215
264
  export function installLaunchdDaemon(spec, options = {}) {
216
265
  const home = options.home ?? os.homedir();
@@ -227,6 +276,12 @@ export function installLaunchdDaemon(spec, options = {}) {
227
276
  writeFileSync(plistPath, buildLaunchdPlist(spec), { mode: 0o600 });
228
277
  chmodSync(plistPath, 0o600);
229
278
  run('launchctl', ['bootout', `${domain}/${spec.label}`]);
279
+ const oldGone = waitUntilUnloaded(run, `${domain}/${spec.label}`, {
280
+ sleep: options.sleep ?? sleepSync,
281
+ now: options.now ?? Date.now,
282
+ timeoutMs: options.exitTimeoutMs ?? OLD_JOB_EXIT_TIMEOUT_MS,
283
+ onWaiting: options.onWaiting ?? ((line) => process.stderr.write(`${line}\n`)),
284
+ });
230
285
  const bootstrap = run('launchctl', ['bootstrap', domain, plistPath]);
231
286
  if (bootstrap.status !== 0) {
232
287
  return {
@@ -234,6 +289,7 @@ export function installLaunchdDaemon(spec, options = {}) {
234
289
  plistPath,
235
290
  message: `Wrote ${plistPath}, but \`launchctl bootstrap ${domain}\` failed` +
236
291
  `${bootstrap.stderr.trim() ? `: ${bootstrap.stderr.trim()}` : '.'}\n` +
292
+ (oldGone ? '' : ` The previous agent was still stopping after ${Math.round((options.exitTimeoutMs ?? OLD_JOB_EXIT_TIMEOUT_MS) / 1000)}s.\n`) +
237
293
  `Load it by hand with: launchctl bootstrap ${domain} ${plistPath}`,
238
294
  };
239
295
  }