@prohost/cli 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +265 -1
  3. package/dist/agent/accounts.d.ts +11 -0
  4. package/dist/agent/accounts.js +12 -1
  5. package/dist/agent/api.d.ts +6 -0
  6. package/dist/agent/api.js +5 -0
  7. package/dist/agent/claude.d.ts +6 -0
  8. package/dist/agent/claude.js +2 -2
  9. package/dist/agent/daemon.d.ts +33 -0
  10. package/dist/agent/daemon.js +56 -0
  11. package/dist/agent/prompt.d.ts +12 -0
  12. package/dist/agent/prompt.js +5 -0
  13. package/dist/agent/run.d.ts +110 -1
  14. package/dist/agent/run.js +198 -22
  15. package/dist/codehost/history.d.ts +80 -0
  16. package/dist/codehost/history.js +98 -0
  17. package/dist/codehost/hooks.d.ts +10 -2
  18. package/dist/codehost/hooks.js +15 -4
  19. package/dist/codehost/host.d.ts +32 -0
  20. package/dist/codehost/host.js +26 -1
  21. package/dist/codehost/host_stats.d.ts +49 -2
  22. package/dist/codehost/host_stats.js +153 -2
  23. package/dist/codehost/relay.d.ts +16 -1
  24. package/dist/codehost/relay.js +69 -3
  25. package/dist/codehost/sizing.d.ts +22 -4
  26. package/dist/codehost/sizing.js +46 -6
  27. package/dist/codehost/terminals.d.ts +33 -2
  28. package/dist/codehost/terminals.js +92 -30
  29. package/dist/codehost/tmux.d.ts +6 -1
  30. package/dist/codehost/tmux.js +3 -0
  31. package/dist/host/api.d.ts +82 -0
  32. package/dist/host/api.js +122 -0
  33. package/dist/host/capacity.d.ts +41 -0
  34. package/dist/host/capacity.js +48 -0
  35. package/dist/host/command.d.ts +59 -0
  36. package/dist/host/command.js +584 -0
  37. package/dist/host/computer.d.ts +120 -0
  38. package/dist/host/computer.js +291 -0
  39. package/dist/host/events.d.ts +24 -0
  40. package/dist/host/events.js +47 -0
  41. package/dist/host/install.d.ts +152 -0
  42. package/dist/host/install.js +755 -0
  43. package/dist/host/ledger.d.ts +52 -0
  44. package/dist/host/ledger.js +131 -0
  45. package/dist/host/legacy.d.ts +38 -0
  46. package/dist/host/legacy.js +115 -0
  47. package/dist/host/logins.d.ts +109 -0
  48. package/dist/host/logins.js +190 -0
  49. package/dist/host/migrate.d.ts +165 -0
  50. package/dist/host/migrate.js +364 -0
  51. package/dist/host/pair.d.ts +62 -0
  52. package/dist/host/pair.js +219 -0
  53. package/dist/host/peak_memory.d.ts +44 -0
  54. package/dist/host/peak_memory.js +139 -0
  55. package/dist/host/scheduler.d.ts +86 -0
  56. package/dist/host/scheduler.js +186 -0
  57. package/dist/host/service.d.ts +114 -0
  58. package/dist/host/service.js +1027 -0
  59. package/dist/host/session_move.d.ts +48 -0
  60. package/dist/host/session_move.js +106 -0
  61. package/dist/host/skills.d.ts +29 -0
  62. package/dist/host/skills.js +135 -0
  63. package/dist/host/status.d.ts +91 -0
  64. package/dist/host/status.js +130 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +47 -3
  67. package/dist/version.d.ts +2 -2
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -6,6 +6,89 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.16.0
10
+
11
+ - **`prohost host` — one service per computer, many employees.** A computer is added once
12
+ (`host pair`: device code, or `--token` for the desktop app's self-add; `--name`,
13
+ `--expect-account`, `$PROHOST_PAIR_RESULT_FILE`) and serves every AI employee placed on it on
14
+ one stream authenticated as the computer (`$PROHOST_HOME/host/computer.json`, 0600).
15
+ - Runs are claimed only when a slot is free (`POST /v1/computers/{id}/runs/{run_id}/claim`,
16
+ exactly once; `409` → skipped) and executed like `agent run` runs, as the employee with the
17
+ run's own token (heartbeat, tool events, reply, a per-run MCP config, completion with
18
+ `peak_mem_mb` / `login_moved`). The claim's skills are written into the employee's workspace
19
+ (`.claude/skills/<key>/`; `.codex/skills/<key>/` plus an `AGENTS.md` list). A run claimed
20
+ before a restart is reported, never re-run. The ledger is keyed by employee and conversation.
21
+ - Run order: someone waiting first, then turns between employees, then oldest; conversations
22
+ take turns; per-employee `max_concurrent` and `keep_one_ready`; terminal sessions never wait
23
+ and count against capacity.
24
+ - Capacity recommended from memory, cores and workload (max(8 GB, 25 %) reserve, ~1.5 GB chat /
25
+ ~4 GB coding, ≤ half the cores, 1–16), configured in ProhostAI. Holds new work when paused,
26
+ under critical memory pressure or on battery (each configurable), with `hold_started` /
27
+ `hold_ended` events. Each run's peak memory is sampled from its process tree.
28
+ - `POST /v1/computers/{id}/status` every 30 s and on change (host stats, slots, AI logins with
29
+ plan and quota windows — never the email — running work, capabilities, `recommended_capacity`,
30
+ `dedicated`); the answer is the computer's config.
31
+ - Opt-in sharing between AI logins of the same kind: a conversation sticks to its login; when
32
+ that login rests, its Claude session (`projects/<project>/<id>.jsonl`) or Codex rollout
33
+ (including a WSL `CODEX_HOME`) is copied to another login and the run resumes with an
34
+ "interrupted" note (`login_resting`, `login_moved`); otherwise the run waits for the reset.
35
+ Claude Code and Codex are never mixed.
36
+ - Terminal sessions (Code threads) are hosted by the same service with the computer credential.
37
+ - `host status --json`, `host logins --json` (plan and the local-only `email_local`),
38
+ `host pause` / `resume`, `$PROHOST_HOME/host/activity.json`.
39
+ - `host install [--dedicated --service-user <name>]` / `uninstall`: launchd (user agent / system
40
+ daemon with `UserName`), systemd (user / system unit with `User=`), Windows Task Scheduler (at
41
+ logon / at boot as the service user via S4U). Stopping or an update drains: no new work, runs
42
+ going finish, then it exits (75 after an update) for the service manager to restart it.
43
+ - `host migrate` adopts existing agent homes (sessions carried over; served with their own
44
+ credential, in their own `--workdir`, until placed on the computer) and the desktop app's
45
+ terminal-hosting home (`ai.prohost.desktop[.dev].code-host`), stops the old launchd agents it
46
+ adopted (`--keep-services`, `--dry-run`, `--json`), and keeps earlier adoptions on a re-run.
47
+ It refuses while the host service runs (the service owns the ledger).
48
+ - One `host run` per home (`host/service.lock`); each employee's runs share one session ledger.
49
+ - For the desktop app: `host install|uninstall --label <id>` (default derived from the home);
50
+ services run the CLI with the executable that launched it, keeping `ELECTRON_RUN_AS_NODE=1`
51
+ and the launching `PATH` under Electron; re-running install leaves a right service alone.
52
+ `host migrate --from <home>` (repeatable, read-only) and computer adoption through an agent
53
+ key (`/v1/computers/pairing/adopt`). `host logins add --runtime …`, `host unpair`.
54
+ `host status --json` always exits 0 and adds `hardware`, `events` (24 h, stable ids) and
55
+ `service`. `host pause` also stops new terminal sessions (open ones carry on).
56
+ - Dedicated: creates the service account (macOS / Linux), copies this home's `computer.json`,
57
+ installs `--claude-token-file` for `CLAUDE_CODE_OAUTH_TOKEN`, logs to the journal /
58
+ root-owned `/Library/Logs/Prohost` / ACL'd ProgramData (never under the service user's
59
+ home), and publishes its state for the logged-in user's `host status` / `host pause`.
60
+ - `host pair` names the approved workspace and, in a terminal without a matching
61
+ `--expect-account`, asks before saving; a declined or mismatched key is revoked.
62
+ - Terminal sessions use the computer key on `/v1/code-hosts/control`, falling back to the
63
+ adopted agent key on a server that refuses it.
64
+ - The send rule: replies carry the agent's `confidence` / `reply_class` (a trailer line it is
65
+ asked for, stripped before sending — a missing or malformed one sends neither, never a
66
+ default); a reply held as a suggestion, or refused because the thread is muted (`ai_muted`),
67
+ is handled, not failed.
68
+ - `claude auth status --json`'s `subscriptionType` is now read (the email only for local display).
69
+ - Host stats on Linux (`/proc/meminfo`, `/proc/pressure/memory`, `/sys/class/power_supply`) and
70
+ Windows (`typeperf` counters, `Win32_Battery`), beside macOS.
71
+ - `prohost agent run` and every other command are unchanged.
72
+ - **`agent install-daemon` re-install no longer fails while the old agent drains.** After
73
+ `bootout` it waits (bounded at 90 s) for `launchctl print` to say the old job has left launchd
74
+ before `bootstrap`; bootstrapping while the old agent finished its in-flight run failed with
75
+ "Input/output error". A bootstrap that still fails says when the old agent had not stopped.
76
+
77
+ ## 0.15.0
78
+
79
+ - **Code threads on this Mac: scroll back locally, no flashing, no permission prompts.**
80
+ - A viewer can ask for the pane's history (`history` control frame, answered only to viewers
81
+ that said `hello`; `tmux capture-pane`, bounded), so the web terminal scrolls it locally
82
+ instead of putting the shared pane into copy mode (#22333).
83
+ - The window's size changes hands on an explicit takeover (attach, focus-in, `claim`) or after
84
+ the sizing viewer has been idle, not on every keystroke from another viewer; resizes stay
85
+ debounced and no-op resizes are skipped (#22333).
86
+ - Claude Code and Codex threads started by the Mac's owner skip permission prompts
87
+ (`--dangerously-skip-permissions`, `--dangerously-bypass-approvals-and-sandbox`) when the
88
+ start asks for it, only while remote control is on; the host declares
89
+ `code_thread_skip_permissions` beside `remote_terminal`. Claude's one-time bypass warning is
90
+ pre-accepted only in the settings files those windows use (#22332).
91
+
9
92
  ## 0.14.0
10
93
 
11
94
  - **Code threads on this Mac: installed agents, honest shells, one prompt, who sizes.**
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,266 @@ 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 home is made private and given the credential only when the
206
+ install creates it. If your own (personal) service runs on the same computer
207
+ key, install says so and how to remove it;
208
+ - logs where the service account can't redirect it: the journal on Linux,
209
+ `/Library/Logs/Prohost/<label>.log` (in a root-owned folder) on macOS,
210
+ `C:\ProgramData\Prohost\Logs` (ACL: SYSTEM, administrators, the service
211
+ account) on Windows;
212
+ - publishes its state for anyone logged in on the computer, without login
213
+ emails, in a root-owned `/Library/Application Support/Prohost/hosts/<label>`,
214
+ `/var/lib/prohost/hosts/<label>` or `C:\ProgramData\Prohost\hosts\<label>`: the service owns only
215
+ `state.json`, `activity.json` and `events.json` there, and `control.json` is
216
+ the pause switch anyone on the computer may flip. `host status` and
217
+ `host pause` run as the logged-in user find it there, and
218
+ `sudo prohost host uninstall` finds the service by it (install prints the exact
219
+ command with its `--label`).
220
+
221
+ On Windows it is a boot-start task rather than a Windows service, because Node
222
+ cannot answer the Service Control Manager without a native wrapper.
223
+
224
+ Every variant runs `<executable> <cli> host run` with the `PATH` it was installed
225
+ from: Node, or — under the desktop app — the app's Electron binary, with
226
+ `ELECTRON_RUN_AS_NODE=1` (and `PROHOST_CLI_RUN=1`) kept in the service's
227
+ environment so it runs the CLI rather than opening the app. Personal services
228
+ log to `$PROHOST_HOME/host/host.log`. Re-running `host install` changes nothing
229
+ that is already right (the desktop app runs it after every update); a changed
230
+ definition is loaded after the old service drains. Only one `host run`
231
+ runs per `PROHOST_HOME` (`host/service.lock`); a second one refuses to start.
232
+ The systemd unit uses `KillMode=mixed`, so a stop reaches the host first and its
233
+ runs can finish. Re-installing on macOS waits for the previous service to finish
234
+ draining (up to its 10-minute stop timeout) before loading the new one. A dedicated install hands the service user's `PROHOST_HOME` to
235
+ that user (`chown`); on Windows it checks for an elevated prompt first. **Updates drain:**
236
+ when a newer CLI is installed over the running one (or the service is told to
237
+ stop), it takes no new work, lets the runs going finish, then exits (code 75
238
+ after an update) for the service manager to start the new one. A second
239
+ Ctrl-C (or signal) stops the runs going too.
240
+
241
+ ### Migrating an existing setup: `host migrate`
242
+
243
+ `host migrate` brings agents you already run under the host service without
244
+ pairing anything again. When this home has no computer yet, it becomes one
245
+ through an adopted home's agent key (`POST /v1/computers/pairing/adopt`): the
246
+ pairing's creator owns it, no code to enter. `--from <home>` (repeatable) adopts
247
+ only those homes — nothing is discovered, nothing is stopped, the homes are only
248
+ read — which is how the desktop app hands over its own; exit 0 means adopted, or
249
+ nothing to adopt.
250
+
251
+ - **Agent homes** — every home a launchd agent `ai.prohost.agent.*` runs (its
252
+ `--exec`, `--workdir`, `--concurrency`, `--repos`, timeouts and `--safe-tools`
253
+ are read from the plist), `$PROHOST_HOME`, and each `--home` (the desktop
254
+ app passes the homes of the agents it supervises). Their
255
+ conversation sessions are copied to the employee's host home. Until
256
+ ProhostAI places that employee on this computer, `host run` keeps serving it
257
+ with **its own credential**, as `agent run` did; once it is placed, the
258
+ computer serves it and the old connection is stopped.
259
+ - **The terminal-hosting home** — a `--host-only` home (including the desktop
260
+ app's `ai.prohost.desktop[.dev].code-host` agent), or one with Code-thread
261
+ hosting on. The service hosts terminal sessions from it, so open terminals,
262
+ opened folders and the remote switches carry over.
263
+ - **Old launchd agents** it adopted are stopped and removed (`--keep-services`
264
+ keeps them; then stop them yourself, or each employee is answered twice).
265
+
266
+ It cannot adopt: a systemd unit or Windows task written by hand from
267
+ `agent install-daemon` (pass `--home` and stop the unit yourself); an agent the
268
+ desktop app supervises (the app stops it); an `--exec` that is neither Claude
269
+ Code nor Codex (it keeps running on its own credential and is never served as
270
+ the computer); a home whose `agent.json` is missing, unreadable or revoked; the
271
+ sessions of a home that ran in a custom `--workdir`, once the computer serves it
272
+ (Claude Code files sessions by directory, and the host never runs an employee in
273
+ a checkout — those conversations start fresh; until then its own connection
274
+ keeps running in that `--workdir`); `prohost listen` and `prohost usage`
275
+ daemons. Running `host migrate` again keeps what it adopted before, including
276
+ settings read from plists it has since removed. It refuses while the host
277
+ service is running (the service owns the run ledger): stop it, migrate, then
278
+ start it again. `--dry-run` shows what it would do; `--json` prints the report.
279
+
280
+ ### For the desktop app
281
+
282
+ - `host status --json` (always exits 0) →
283
+ `{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}`.
284
+ `online` is false when the service has not written its state for 2 minutes.
285
+ `events` are the last 24 h of what the service sent (`login_resting`,
286
+ `login_moved`, `hold_started`, `hold_ended`, with the `/events` fields plus
287
+ `computer_id` / `computer_name`), newest last, each with a stable `id`.
288
+ `service` says whether a service runs for this home (`host/service.lock`), how
289
+ many homes it adopted, and — run as the logged-in user on a dedicated computer —
290
+ that it is the dedicated service (`activity_file` is then its shared copy).
291
+ - `host logins --json` → `{logins: [...same shape as status...]}`.
292
+ - `$PROHOST_HOME/host/activity.json` → `{v: 1, busy, running: [...as in status...], updatedAt}`.
293
+ - `$PROHOST_HOME/host/state.json` is the service's own state (0600); read
294
+ `status --json` rather than this file.
295
+ - `host pause` / `resume` write `$PROHOST_HOME/host/control.json` (and a
296
+ dedicated service's shared `control.json`); the running service picks it up
297
+ within 2 seconds. Paused, it also starts no new terminal session (the start
298
+ is reported failed, "this computer is paused") and lets no new viewer dial
299
+ in; terminals already open, their replies and approvals carry on.
300
+ - `prohost --help` names what this build can do: `ELECTRON_RUN_AS_NODE`
301
+ (services written for the desktop app), `--claude-token-file`, `host unpair`.
302
+ - Terminal sessions authenticate to `/v1/code-hosts/control` with the computer
303
+ key; a server from before computer keys (`not_paired`) is asked again with the
304
+ adopted terminal-hosting home's agent key. A revoked or invalid key never
305
+ falls back.
306
+
307
+
44
308
  ## Bring your own agent
45
309
 
46
310
  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,6 +20,12 @@ 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;
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
  }
@@ -99,6 +99,18 @@ export interface PromptContext {
99
99
  source: string;
100
100
  reason: string;
101
101
  }>;
102
+ /**
103
+ * Said right after the opening line: `prohost host` resumed this run on
104
+ * another AI login after the first one hit its usage limit, so the turn may
105
+ * be half done.
106
+ */
107
+ resumeNote?: string;
108
+ /**
109
+ * Ask the agent to end a customer-visible reply with its confidence line
110
+ * (`[[confidence=0.9 reply_class=general]]`): the server sends it only when
111
+ * confident enough and its class needs no approval, else saves a draft.
112
+ */
113
+ askConfidence?: boolean;
102
114
  }
103
115
  /** Build the stdin prompt for one run. */
104
116
  export declare function buildPrompt(run: AgentRunRequest, context: PromptContext): string;