@prohost/cli 0.13.2 → 0.14.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 (61) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +129 -0
  3. package/dist/agent/claude.d.ts +28 -0
  4. package/dist/agent/claude.js +83 -4
  5. package/dist/agent/command.d.ts +8 -0
  6. package/dist/agent/command.js +40 -7
  7. package/dist/agent/daemon.d.ts +3 -1
  8. package/dist/agent/daemon.js +1 -2
  9. package/dist/agent/run.d.ts +30 -1
  10. package/dist/agent/run.js +311 -16
  11. package/dist/agent/runtime_status.d.ts +99 -3
  12. package/dist/agent/runtime_status.js +173 -5
  13. package/dist/agent/worktrees.d.ts +2 -0
  14. package/dist/agent/worktrees.js +3 -2
  15. package/dist/codehost/activity.d.ts +64 -0
  16. package/dist/codehost/activity.js +135 -0
  17. package/dist/codehost/agents.d.ts +31 -0
  18. package/dist/codehost/agents.js +57 -0
  19. package/dist/codehost/checkouts.d.ts +130 -0
  20. package/dist/codehost/checkouts.js +270 -0
  21. package/dist/codehost/claude_trust.d.ts +40 -0
  22. package/dist/codehost/claude_trust.js +129 -0
  23. package/dist/codehost/command.d.ts +25 -0
  24. package/dist/codehost/command.js +136 -0
  25. package/dist/codehost/decision.d.ts +156 -0
  26. package/dist/codehost/decision.js +553 -0
  27. package/dist/codehost/events.d.ts +49 -0
  28. package/dist/codehost/events.js +87 -0
  29. package/dist/codehost/folders.d.ts +122 -0
  30. package/dist/codehost/folders.js +317 -0
  31. package/dist/codehost/hooks.d.ts +178 -0
  32. package/dist/codehost/hooks.js +726 -0
  33. package/dist/codehost/host.d.ts +290 -0
  34. package/dist/codehost/host.js +625 -0
  35. package/dist/codehost/host_stats.d.ts +71 -0
  36. package/dist/codehost/host_stats.js +166 -0
  37. package/dist/codehost/invocations.d.ts +12 -0
  38. package/dist/codehost/invocations.js +82 -0
  39. package/dist/codehost/ledger.d.ts +35 -0
  40. package/dist/codehost/ledger.js +91 -0
  41. package/dist/codehost/local_server.d.ts +106 -0
  42. package/dist/codehost/local_server.js +339 -0
  43. package/dist/codehost/pty.d.ts +38 -0
  44. package/dist/codehost/pty.js +47 -0
  45. package/dist/codehost/ready.d.ts +31 -0
  46. package/dist/codehost/ready.js +109 -0
  47. package/dist/codehost/relay.d.ts +170 -0
  48. package/dist/codehost/relay.js +548 -0
  49. package/dist/codehost/settings.d.ts +52 -0
  50. package/dist/codehost/settings.js +114 -0
  51. package/dist/codehost/sizing.d.ts +163 -0
  52. package/dist/codehost/sizing.js +394 -0
  53. package/dist/codehost/terminals.d.ts +390 -0
  54. package/dist/codehost/terminals.js +965 -0
  55. package/dist/codehost/tmux.d.ts +196 -0
  56. package/dist/codehost/tmux.js +391 -0
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.js +21 -0
  59. package/dist/version.d.ts +2 -2
  60. package/dist/version.js +1 -1
  61. package/package.json +4 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,101 @@ Versions follow [semver](https://semver.org/). Publishing is automated: merging
4
4
  a version bump to `main` triggers `.github/workflows/npm-publish-cli.yml`, which
5
5
  builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
+ ## Unreleased
8
+
9
+ ## 0.14.0
10
+
11
+ - **Code threads on this Mac: installed agents, honest shells, one prompt, who sizes.**
12
+ The host declares `code_thread_agent_claude_code` / `code_thread_agent_codex` for each
13
+ agent whose binary resolves on the daemon's `PATH` (re-checked per status frame), so a
14
+ `--host-only` Mac offers Claude Code and Codex, not only Shell; a start for one that is
15
+ missing fails saying which binary. A reply into a shell window is refused as the new
16
+ `shell_window` (was `terminal_gone`). New windows start at the size they'll be viewed at
17
+ (`resize-window` right after `new-window`), and a shell's first prompt waits for its first viewer's size to settle
18
+ (`tmux wait-for`), so it shows one prompt instead of one per resize. Each window is
19
+ sized by `resize-window` (so `window-size manual` per window; tmux 3.4 exits at start with
20
+ it in a config) to one viewer: whoever attaches, types, focuses or sends
21
+ `{"type":"claim"}` (never a watch viewer), applied once quiet 300 ms and never for a size it
22
+ already has, since Claude Code reprints its transcript on every width change. A viewer that
23
+ sent `{"type":"hello","client":{"kind","label"}}` is told who that is and whether the pane is
24
+ in copy mode (`{"type":"sizer",...,"scrolled"}`). Copy mode is the pane's, so it is left when
25
+ the sizing viewer changes and when the viewer that scrolled detaches. `scroll-on-clear off`:
26
+ a full-screen redraw no longer leaves a copy of the old screen in scrollback (Claude Code's
27
+ header repeated once per resize). No copy-mode position box and no coloured border at a
28
+ window's edge; typed text and relayed replies leave copy mode first (q / Esc / Ctrl+C there
29
+ still only leave it).
30
+
31
+ - **`runtime_status` reports the machine's short hostname** (`hostname`, up to its first dot). The
32
+ server keeps it against the socket and on a run started while this is the pairing's only socket,
33
+ for ProhostAI staff only. With `--machine`, that name is sent as given (`build.eu` stays whole)
34
+ instead of the real hostname, and one the server can't use as a hostname (`Front desk`) means no machine is recorded. An older server
35
+ ignores it.
36
+ - **`Claude Ultra` plan label.** `get_usage`'s `subscription_type: "ultra"` now reads
37
+ `Claude Ultra`. The value is inferred from `pro` / `max`; no real response has confirmed it yet.
38
+ - **Host Code threads on this Mac (off by default).** `prohost agent
39
+ code-host enable` lets `agent run` take `code_thread.start_requested`,
40
+ `code_thread.input_requested` and `code_thread.approval_decided`: one worktree and one window in a private tmux
41
+ server per thread, running Claude Code, Codex or a shell, re-adopted after a
42
+ restart. Claude Code / Codex hooks are passed on their command lines (never
43
+ installed globally) and post the thread's prompts, turns, permission prompts
44
+ and interrupts with a narrow, expiring token. `prohost agent code-host remote
45
+ on` additionally lets the machine's owner attach and type from ProhostAI
46
+ through the relay (`/v1/code-hosts/*`), or from the desktop app over
47
+ loopback. While hosting, the heartbeat carries optional `host` stats. New
48
+ optional dependency: `node-pty` 1.1.0.
49
+ - **`agent run --host-only`.** Hosts Code threads with no `--exec`: every
50
+ `agent.run_requested` is closed at once as failed ("This computer only hosts
51
+ Code threads; it doesn't answer messages."), nothing is executed, and the
52
+ harness declares `host_only`. Mutually exclusive with `--exec`;
53
+ `install-daemon --host-only` passes it through; listed in `--help`.
54
+ - **`code-host/activity.json` for keep-awake.** While hosting, the daemon
55
+ writes `{"v":1,"busy":bool,"updatedAt":ISO}` (0600, atomic, at least every
56
+ 30 s; removed when hosting stops): busy while any hosted agent is mid-turn
57
+ (Claude Code `UserPromptSubmit` → `Stop`; a goal or typed reply → Codex's
58
+ turn-complete; Esc/Ctrl+C from an attached viewer ends it; 6 h cap per
59
+ turn). Readers treat `updatedAt` older than ~3 min as not busy.
60
+ - **Folder Code threads.** A `code_thread.start_requested` with `folder` runs
61
+ in that folder in place (no worktree, no branch), or with `separateCopy` in a
62
+ worktree and branch of the folder's git checkout. Only a folder this Mac has
63
+ opened, or one inside it (after realpath), starts; anything else is refused.
64
+ `prohost agent code-host folders add|list|remove` keeps the opened folders
65
+ (`code-host/folders.json`); the status frame reports them as
66
+ `code_host_folders` with `code_host_home`, and the host declares
67
+ `code_thread_folders`. A shell in an opened folder starts with `remote off`.
68
+ - **Folder threads, follow-ups.** A separate copy refuses a subfolder its
69
+ branch doesn't have, and one that resolves outside the new worktree (a
70
+ symlink on that branch), instead of starting in the worktree's root or in
71
+ the owner's files. Changes to `folders.json` (`folders add`/`remove`, a
72
+ start moving its folder to the front) take an inter-process lock, so two at
73
+ once never drop a folder. The tmux server and every pane no longer inherit
74
+ `ELECTRON_RUN_AS_NODE` / `PROHOST_CLI_RUN` from a daemon the desktop app runs
75
+ on Electron; the hook shim sets `ELECTRON_RUN_AS_NODE=1` for itself only.
76
+ - **The desktop app attaches locally with `remote off`.** The 127.0.0.1 socket
77
+ and `local.json` are served whenever hosting is on; with the remote switch
78
+ off they admit only a viewer token the relay vouches for as `local`, and
79
+ `remote off` ends every other desktop viewer. `code-host disable` closes the
80
+ socket at once.
81
+
82
+ ## 0.13.3
83
+
84
+ - **Every turn the agent answers in reaches the thread.** When a command the
85
+ agent ran in the background (or one Claude Code moved to the background at
86
+ its two-minute timeout) is still running as the agent ends its turn, Claude
87
+ Code keeps the session alive and wakes it with another turn when the command
88
+ finishes. The CLI posted only the last turn, so a follow-up that said
89
+ `[NO_REPLY]`, or just "nothing in my earlier reply changes", replaced the real
90
+ answer: 3 of the Customer Support Bot's 155 runs lost theirs that way. A turn
91
+ that ends while a background task is still running is now posted as soon as
92
+ it ends ("the review is still running; I'll post the result"), each under an
93
+ idempotency key of its own (`prohost-cli-run-<run>-turn-<n>`). The run's last
94
+ turn is posted as before, under the run's key. A later `[NO_REPLY]` or a
95
+ repeat of the previous answer takes back nothing; a last turn that fails
96
+ (a usage limit) is still reported as the run's failure. Posts go strictly in
97
+ order: one the server refuses is retried under its own key and holds back
98
+ the rest (if it never lands, the run fails as a rejected reply does), and
99
+ nothing more is posted once the run is cancelled. A run that ends in one turn
100
+ is unchanged.
101
+
7
102
  ## 0.13.2
8
103
 
9
104
  - **A run stopped by the Claude usage limit tells ProhostAI when the limit
package/README.md CHANGED
@@ -632,8 +632,137 @@ workspace's `.claude/`: a skill that points it at the repo's PR protocol
632
632
  the `gh pr create` gate hook the fleet uses. Nothing else about the default
633
633
  workspace changes.
634
634
 
635
+ ### Hosting Code threads on this Mac (off by default)
636
+
637
+ ProhostAI can show agent terminals — Claude Code, Codex or a plain shell — as
638
+ **Code** threads in the inbox. This machine can host them, but only once its
639
+ owner says so, here, on this machine. Nothing the server sends can turn it on.
640
+
641
+ ```bash
642
+ prohost agent code-host enable # host Code threads (needs --repos on agent run)
643
+ prohost agent code-host remote on # also let *you* attach and type from ProhostAI
644
+ prohost agent code-host status
645
+ prohost agent code-host remote off # refuses the next viewer at once
646
+ prohost agent code-host disable
647
+
648
+ prohost agent code-host folders add ~/code/app # open a folder for Code threads (the desktop app does this)
649
+ prohost agent code-host folders list [--json]
650
+ prohost agent code-host folders remove ~/code/app
651
+ ```
652
+
653
+ Restart `agent run` (or the daemon) after `enable` / `disable`.
654
+
655
+ **Host only: Code threads without an agent that answers.** `prohost agent run
656
+ --host-only` (or `install-daemon --host-only`) takes no `--exec`. It keeps
657
+ everything hosting needs — the connection, presence, status, the desktop
658
+ app's local socket, `activity.json` — and executes nothing anyone sends: every
659
+ run requested of this agent (a mention, a DM) is closed at once as failed with
660
+ "This computer only hosts Code threads; it doesn't answer messages." It
661
+ declares the `host_only` capability. `--host-only` and `--exec` can't be used
662
+ together, and `prohost --help` lists `--host-only` on builds that have it. A
663
+ host-only agent runs no agent of its own, so it has no account: its Code
664
+ threads run Claude Code or Codex on this Mac's default login, as a terminal you
665
+ opened would (`--account` is refused with `--host-only`, and an account already
666
+ on the pairing is reported as unused at start). The
667
+ desktop app's "This Mac" daemon runs this way.
668
+
669
+ - **Folder threads, in place.** A thread can also run in a folder instead of a
670
+ repository: in that folder itself, with no worktree and no branch, the way a
671
+ terminal you open there would. The desktop app's folder picker adds what you
672
+ pick to this Mac's **opened folders** (`folders add`;
673
+ `$PROHOST_HOME/code-host/folders.json`, `0600`, the 50 most recent, each
674
+ stored as its realpath). A thread started from anywhere else — your phone,
675
+ the web — may only run in one of those folders or a folder inside one,
676
+ checked after resolving symlinks, so a link that points out of an opened
677
+ folder does not count as inside it. Anything else is refused with "This
678
+ folder hasn't been opened on this Mac." A thread that starts moves its
679
+ folder to the front of the list. The list (with whether each is a git
680
+ repository, and your home directory for showing `~/…`) is reported to
681
+ ProhostAI so your phone can offer it; `--repos` is not needed for folders.
682
+ - **Work in a separate copy.** For a folder in a git repository, a thread can
683
+ instead get its own worktree and branch of that checkout, exactly as a
684
+ repository thread does, opened in the same subfolder. Your checkout is never
685
+ switched. A folder outside git can't, and says so; neither can a subfolder
686
+ the branch doesn't have (untracked or ignored) or one that is a link out of
687
+ the copy on that branch — the thread is refused, never started elsewhere.
688
+ - **The goal is optional.** A thread with no goal opens Claude Code or Codex
689
+ with no prompt, or just the shell.
690
+
691
+ - **One worktree and one tmux window per repository thread.** The repository is matched
692
+ against `--repos` by its `origin` remote (else by directory name), checked out
693
+ under `$PROHOST_HOME/worktrees/`, and switched to the thread's branch. The
694
+ windows live in a private tmux server (`tmux -L prohost-<id>`; your own tmux
695
+ is untouched), so they survive the agent restarting and are re-adopted. You
696
+ need `tmux` installed (`brew install tmux`).
697
+ - **Hooks without touching your config.** Claude Code is started with
698
+ `--settings` and Codex with `-c notify=…`, naming a shim that does nothing
699
+ unless `$PROHOST_TERMINAL_ID` is set — your own `claude` sessions never see
700
+ them, and `~/.claude` / `~/.codex` are never written. They report the prompt,
701
+ the end of each turn, permission prompts and Esc/Ctrl+C to the thread, using a
702
+ narrow token the relay issues (`$PROHOST_HOME/code-host/hook.json`, `0600`,
703
+ expires in a day) — never your API key, never a Claude or Codex credential.
704
+ ProhostAI saves the conversation, not your terminal. An Allow / Deny chosen in
705
+ ProhostAI answers the prompt in the terminal (Enter, Down+Enter, Escape) —
706
+ only if that prompt is still the one on screen.
707
+ - **Reply recovery after a permission menu.** New Claude Code windows check
708
+ the installed version before choosing their hooks. Claude Code 2.1.295 or
709
+ newer records tool invocation IDs before permission processing; replies can
710
+ resume when the transcript confirms those calls finished. Recording failures
711
+ block the request. Older or unverifiable versions, already-running windows,
712
+ and subagent permission prompts wait for a new user prompt or a completed
713
+ turn when the menu's outcome is uncertain. Start a new window after upgrading
714
+ to use the invocation hooks.
715
+ Keep the daemon running: it reports verified completion back to the thread,
716
+ including after the waiting hook exits, and retries reports after reconnect.
717
+ - **Replies from ProhostAI go to the agent, never to a shell.** Typing a reply
718
+ or an Allow / Deny into a terminal from ProhostAI needs `remote on`, read at
719
+ every event. It reaches only a running Claude Code or Codex: never a shell
720
+ thread, and never the shell an agent leaves behind when it exits. Starting a
721
+ shell thread for a repository also needs `remote on`; a shell in an opened
722
+ folder starts with it off, where only the desktop app on this Mac can type
723
+ into it. A reply or decision that is redelivered
724
+ after a reconnect is never typed twice. A goal is always passed after `--`,
725
+ so it can't become an option.
726
+ - **A remote terminal is remote code execution on this Mac.** With `remote on`,
727
+ the machine's owner (whoever paired it) — and nobody else — can attach from the
728
+ web, phone or desktop app and type. Off, the relay refuses every viewer and
729
+ this machine refuses every dial. Terminal bytes are relayed, never stored.
730
+ - **The desktop app on this Mac attaches over `127.0.0.1`**, not the relay,
731
+ whenever hosting is on — with `remote off` too. The socket's port and token
732
+ are in `$PROHOST_HOME/code-host/local.json` (`0600`, new every start; removed
733
+ by `disable` at once). Any request with an `Origin` header — every browser —
734
+ is refused, and every attach's viewer token is checked with the relay, which
735
+ vouches for who it is. With `remote off` it admits only a token ProhostAI
736
+ minted for this Mac's own desktop app, which the relay refuses for any
737
+ relayed attach; `remote off` ends every other viewer at once.
738
+ - **Whether an agent is working, for keep-awake.** While hosting, the daemon
739
+ keeps `$PROHOST_HOME/code-host/activity.json` (`0600`, written atomically):
740
+ `{"v":1,"busy":true|false,"updatedAt":"<ISO>"}`. `busy` is true while any
741
+ thread's agent is mid-turn: from Claude Code's prompt to its `Stop`, from a
742
+ goal or a reply (not a `/` command) this Mac types to Codex's turn-complete, ended early by Esc
743
+ or Ctrl+C in an attached terminal, and never for longer than 6 hours per
744
+ turn. The file is rewritten at least every 30 s and removed when hosting
745
+ stops. **A reader must treat an `updatedAt` older than about 3 minutes as
746
+ not busy**, so a daemon that died never keeps the Mac awake. The desktop
747
+ app's "Keep this Mac awake while an agent is working" reads it. A prompt
748
+ typed straight into a Codex terminal isn't seen until that turn ends.
749
+ - **Attaching needs `node-pty`**, an optional dependency with prebuilt macOS
750
+ binaries. Where it doesn't load, threads still run; only attaching answers
751
+ `pty_unavailable`.
752
+ - While hosting, the heartbeat also reports this machine's CPU, memory,
753
+ memory pressure, battery and model (`vm_stat`, `sysctl`, `pmset`) for the
754
+ thread's Machine card.
755
+
635
756
  ### The trust model — read this once
636
757
 
758
+ **`--host-only` is the exception: it runs nothing anyone sends.** No message,
759
+ mention or DM executes a command on a host-only machine; each requested run is
760
+ refused. What it does run are Code-thread terminals, and only as described
761
+ under "Hosting Code threads on this Mac": a thread starts only in a repository
762
+ from `--repos` or a folder this Mac has opened; nothing from ProhostAI types
763
+ into one without `remote on`; with `remote off` only the desktop app on this
764
+ Mac attaches; and only the pairing's owner can attach at all.
765
+
637
766
  `agent run` is a standing instruction to execute a command on your machine when
638
767
  someone in your ProhostAI workspace asks. Be clear-eyed about what that means:
639
768
 
@@ -171,6 +171,13 @@ export interface ClaudeStream {
171
171
  * that isn't Claude Code's at all.
172
172
  */
173
173
  drainToolEvents(): ToolEvent[];
174
+ /**
175
+ * Answers from turns that ended while a background task was still running —
176
+ * turns another can follow — in order, since the last call. Taken, not
177
+ * copied: {@link finish} no longer includes them. Empty for a run that ends
178
+ * in one turn, which is every run that starts no background task.
179
+ */
180
+ takeEarlyAnswers(): string[];
174
181
  /**
175
182
  * Model, Claude Code version and capacity windows the stream announced —
176
183
  * `system/init.{model, claude_code_version}` and the last
@@ -237,6 +244,27 @@ export declare function toolArgsPreview(name: string, input: unknown): string |
237
244
  * vocabulary belongs to Claude Code and grows without asking us, and a reader
238
245
  * that failed on an unknown `type` would turn a Claude Code release into a
239
246
  * fleet of broken laptops.
247
+ *
248
+ * **One invocation, several turns.** A run usually ends in one `result`
249
+ * event. It ends in more when a background task — a Bash
250
+ * `run_in_background`, or a command Claude Code moved to the
251
+ * background at its two-minute timeout — is still running when the agent ends
252
+ * its turn: Claude Code keeps the process alive, wakes the session with the
253
+ * task's notification when it finishes, and runs another turn that ends in a
254
+ * `result` of its own. The agent writes each of those turns as a message to the
255
+ * thread ("the review is still running, I'll post the result when it lands";
256
+ * "nothing in my earlier reply changes"), so each one that answers is a message
257
+ * of its own. Reading only the last event lost the answer three times in 155
258
+ * Customer Support Bot runs: twice to a trailing "nothing else changed", once
259
+ * (2026-10-22, Claude Code 2.1.308) to a trailing `[NO_REPLY]`.
260
+ *
261
+ * A turn that ends while a background task is still running can be followed by
262
+ * another, so it is handed out at once ({@link ClaudeStream.takeEarlyAnswers})
263
+ * for the harness to post while the run goes on. Whatever answered and was not
264
+ * taken is the run's reply from {@link ClaudeStream.finish}. Any answer
265
+ * followed by another turn is early too: it is not the run's last word.
266
+ * `[NO_REPLY]` and a repeat of the answer before take back nothing that was
267
+ * already said; a last turn that failed is still the run's outcome.
240
268
  */
241
269
  export declare function readClaudeStream(): ClaudeStream;
242
270
  /**
@@ -17,6 +17,7 @@
17
17
  import { randomBytes } from 'node:crypto';
18
18
  import { chmodSync, mkdirSync, renameSync, writeFileSync } from 'node:fs';
19
19
  import path from 'node:path';
20
+ import { NO_REPLY_TOKEN, isNoReply } from './contract.js';
20
21
  import { prohostHome } from './credentials.js';
21
22
  import { MCP_SERVER_NAME } from './mcp.js';
22
23
  import { fromClaudeRateLimitInfo } from './runtime_status.js';
@@ -458,13 +459,45 @@ function toolResultText(content) {
458
459
  * vocabulary belongs to Claude Code and grows without asking us, and a reader
459
460
  * that failed on an unknown `type` would turn a Claude Code release into a
460
461
  * fleet of broken laptops.
462
+ *
463
+ * **One invocation, several turns.** A run usually ends in one `result`
464
+ * event. It ends in more when a background task — a Bash
465
+ * `run_in_background`, or a command Claude Code moved to the
466
+ * background at its two-minute timeout — is still running when the agent ends
467
+ * its turn: Claude Code keeps the process alive, wakes the session with the
468
+ * task's notification when it finishes, and runs another turn that ends in a
469
+ * `result` of its own. The agent writes each of those turns as a message to the
470
+ * thread ("the review is still running, I'll post the result when it lands";
471
+ * "nothing in my earlier reply changes"), so each one that answers is a message
472
+ * of its own. Reading only the last event lost the answer three times in 155
473
+ * Customer Support Bot runs: twice to a trailing "nothing else changed", once
474
+ * (2026-10-22, Claude Code 2.1.308) to a trailing `[NO_REPLY]`.
475
+ *
476
+ * A turn that ends while a background task is still running can be followed by
477
+ * another, so it is handed out at once ({@link ClaudeStream.takeEarlyAnswers})
478
+ * for the harness to post while the run goes on. Whatever answered and was not
479
+ * taken is the run's reply from {@link ClaudeStream.finish}. Any answer
480
+ * followed by another turn is early too: it is not the run's last word.
481
+ * `[NO_REPLY]` and a repeat of the answer before take back nothing that was
482
+ * already said; a last turn that failed is still the run's outcome.
461
483
  */
462
484
  export function readClaudeStream() {
463
485
  let buffer = '';
464
486
  let session;
465
487
  let lastActivity;
466
488
  let lastText;
489
+ /** The last `result` event: what decides a run in which no turn answered. */
467
490
  let result;
491
+ /** Answered turns not yet handed out, in order, and whether another turn can follow each. */
492
+ const answers = [];
493
+ /** Whether any turn answered, handed out or not. */
494
+ let answered = false;
495
+ /** The last answer, so a turn that only repeats it is not said twice. */
496
+ let lastAnswer;
497
+ /** Every turn's refused tools, for the run's report. */
498
+ const denials = new Set();
499
+ /** Background tasks running as of the last `background_tasks_changed`. */
500
+ let backgroundTasks = 0;
468
501
  /** The last `assistant` event that was an API error rather than an answer. */
469
502
  let apiError;
470
503
  /** When a `rate_limit_event` said the limit is hit: its reset, epoch seconds. */
@@ -563,8 +596,26 @@ export function readClaudeStream() {
563
596
  // from whichever arrives first — they all name the same session.
564
597
  if (!session)
565
598
  session = text(event.session_id);
599
+ if (event.type === 'system' && event.subtype === 'background_tasks_changed' && Array.isArray(event.tasks)) {
600
+ backgroundTasks = event.tasks.length;
601
+ }
566
602
  if (event.type === 'result') {
603
+ // One per turn: see "One invocation, several turns" above.
567
604
  result = event;
605
+ const turn = outputFromEnvelope(event);
606
+ for (const name of turn.denials)
607
+ denials.add(name);
608
+ // Another turn ended, so no answer before it is the run's last word.
609
+ for (const answer of answers)
610
+ answer.early = true;
611
+ if (turn.ok && !isNoReply(turn.text)) {
612
+ // Kept as written, so a one-turn run's reply is byte for byte what it was.
613
+ const said = turn.text.trim();
614
+ if (said !== lastAnswer)
615
+ answers.push({ text: turn.text, early: backgroundTasks > 0 });
616
+ lastAnswer = said;
617
+ answered = true;
618
+ }
568
619
  return;
569
620
  }
570
621
  if (event.type === 'user') {
@@ -595,6 +646,24 @@ export function readClaudeStream() {
595
646
  else if (written)
596
647
  lastActivity = written.replace(/\s+/g, ' ').trim().slice(0, MAX_ACTIVITY_CHARS);
597
648
  };
649
+ /** The run's outcome from its turns; only meaningful once a `result` arrived. */
650
+ const outcome = (last) => {
651
+ const final = outputFromEnvelope(last);
652
+ // A last turn that failed is the run's outcome even after an answer — a
653
+ // usage limit must still say when to try again. Every answer before it was
654
+ // handed out early the moment this turn ended.
655
+ if (!answered || !final.ok)
656
+ return final;
657
+ const sessionId = final.sessionId ?? session;
658
+ // Everything still unsaid, or — when all of it was handed out early — the
659
+ // token that says this turn adds nothing.
660
+ const text = answers.length === 1
661
+ ? answers[0].text
662
+ : answers.length > 0
663
+ ? answers.map((answer) => answer.text.trim()).join('\n\n')
664
+ : NO_REPLY_TOKEN;
665
+ return { ok: true, text, sessionId, denials: [...denials] };
666
+ };
598
667
  /** A trailing event with no newline after it is still an event. */
599
668
  const flushTail = () => {
600
669
  const tail = buffer.trim();
@@ -628,6 +697,16 @@ export function readClaudeStream() {
628
697
  toolEvents = [];
629
698
  return drained;
630
699
  },
700
+ takeEarlyAnswers: () => {
701
+ // Up to the last turn another can follow: an answer before it is just as
702
+ // surely not the run's last word, and order is the thread's order.
703
+ let through = -1;
704
+ answers.forEach((answer, index) => {
705
+ if (answer.early)
706
+ through = index;
707
+ });
708
+ return answers.splice(0, through + 1).map((answer) => answer.text);
709
+ },
631
710
  observed: () => ({ ...observed }),
632
711
  salvage: () => {
633
712
  const trimmed = lastText?.trim();
@@ -638,9 +717,9 @@ export function readClaudeStream() {
638
717
  error: () => {
639
718
  flushTail();
640
719
  if (result) {
641
- const outcome = outputFromEnvelope(result);
642
- if (!outcome.ok)
643
- return outcome.error;
720
+ const turned = outcome(result);
721
+ if (!turned.ok)
722
+ return turned.error;
644
723
  }
645
724
  return apiError?.text;
646
725
  },
@@ -664,7 +743,7 @@ export function readClaudeStream() {
664
743
  // result.
665
744
  flushTail();
666
745
  if (result)
667
- return outputFromEnvelope(result);
746
+ return outcome(result);
668
747
  // No `result` event: either the command isn't emitting the stream we
669
748
  // asked for, or it died before finishing. Either way the whole-output
670
749
  // parser gives the better error message, and it still understands a
@@ -57,4 +57,12 @@ export declare function parseConcurrency(raw: string | boolean | undefined): num
57
57
  export declare function daemonEnvironment(): Record<string, string>;
58
58
  /** Dispatch an ``agent`` subcommand. Returns the process exit code. */
59
59
  export declare function runAgent(subcommand: string | undefined, flags: Flags, printHelp: () => void, args?: string[]): Promise<number>;
60
+ /**
61
+ * `--exec <command>` or `--host-only`, exactly one: what this harness does with a run. `null`
62
+ * after explaining why not. Host-only runs nothing anyone sends; it only hosts Code threads.
63
+ */
64
+ export declare function execOrHostOnly(flags: Flags, verb: 'run' | 'install-daemon'): {
65
+ exec: string;
66
+ hostOnly: boolean;
67
+ } | null;
60
68
  export {};
@@ -20,6 +20,8 @@ import { CredentialsMissingError, ensureWorkspace, loadCredentials, prohostHome,
20
20
  import { DEFAULT_EXEC_TIMEOUT_MS, DEFAULT_IDLE_TIMEOUT_MS, runAgentHarness } from './run.js';
21
21
  import { MAX_CONCURRENCY } from './scheduler.js';
22
22
  import { ReposFlagError, isCheckout, parseReposFlag } from './worktrees.js';
23
+ import { codeHostCommand } from '../codehost/command.js';
24
+ import { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
23
25
  import { announceUpgradeIfAvailable } from '../upgrade.js';
24
26
  import { DEFAULT_WS_URL } from '../listen.js';
25
27
  function flagString(flags, name) {
@@ -223,10 +225,12 @@ export async function runAgent(subcommand, flags, printHelp, args = []) {
223
225
  return listCommand();
224
226
  if (subcommand === 'workspace')
225
227
  return workspaceCommand(args, flags);
228
+ if (subcommand === 'code-host')
229
+ return codeHostCommand(args, flags);
226
230
  process.stderr.write(subcommand
227
231
  ? `Unknown agent subcommand: ${subcommand}\n\n`
228
232
  : 'Missing agent subcommand — expected `pair`, `run`, `install-daemon`, `uninstall-daemon`, ' +
229
- '`account`, `list`, or `workspace`.\n\n');
233
+ '`account`, `list`, `workspace`, or `code-host`.\n\n');
230
234
  printHelp();
231
235
  return 1;
232
236
  }
@@ -256,12 +260,35 @@ function loadOrExplain(streamOverride) {
256
260
  }
257
261
  return credentials;
258
262
  }
259
- async function installDaemonCommand(flags) {
263
+ /**
264
+ * `--exec <command>` or `--host-only`, exactly one: what this harness does with a run. `null`
265
+ * after explaining why not. Host-only runs nothing anyone sends; it only hosts Code threads.
266
+ */
267
+ export function execOrHostOnly(flags, verb) {
260
268
  const exec = flagString(flags, 'exec');
269
+ const hostOnly = flags['host-only'] === true;
270
+ if (hostOnly && flags.exec !== undefined) {
271
+ process.stderr.write('--host-only and --exec can\'t be used together: a host-only agent runs no command.\n');
272
+ return null;
273
+ }
274
+ if (hostOnly && flags.account !== undefined) {
275
+ process.stderr.write("--host-only and --account can't be used together: a host-only agent runs no agent of its own, " +
276
+ "so its Code threads use this Mac's default Claude Code / Codex login.\n");
277
+ return null;
278
+ }
279
+ if (hostOnly)
280
+ return { exec: '', hostOnly: true };
261
281
  if (!exec) {
262
- process.stderr.write("Missing required flag: --exec. Example: prohost agent install-daemon --exec 'claude -p'\n");
263
- return 1;
282
+ process.stderr.write(`Missing required flag: --exec (or --host-only). Example: prohost agent ${verb} --exec 'claude -p'\n`);
283
+ return null;
264
284
  }
285
+ return { exec, hostOnly: false };
286
+ }
287
+ async function installDaemonCommand(flags) {
288
+ const mode = execOrHostOnly(flags, 'install-daemon');
289
+ if (!mode)
290
+ return 1;
291
+ const { exec } = mode;
265
292
  const credentials = loadOrExplain(flagString(flags, 'url'));
266
293
  if (!credentials)
267
294
  return 1;
@@ -299,6 +326,7 @@ async function installDaemonCommand(flags) {
299
326
  execPath: process.execPath,
300
327
  scriptPath: cliEntrypoint(),
301
328
  exec,
329
+ hostOnly: mode.hostOnly,
302
330
  workdir,
303
331
  timeoutSeconds,
304
332
  idleTimeoutSeconds,
@@ -420,10 +448,14 @@ async function pairCommand(flags) {
420
448
  return 0;
421
449
  }
422
450
  async function runCommand(flags) {
423
- const exec = flagString(flags, 'exec');
424
- if (!exec) {
425
- process.stderr.write("Missing required flag: --exec. Example: prohost agent run --exec 'claude -p'\n");
451
+ const mode = execOrHostOnly(flags, 'run');
452
+ if (!mode)
426
453
  return 1;
454
+ const { exec, hostOnly } = mode;
455
+ if (hostOnly && !loadCodeHostSettings().enabled) {
456
+ // Not fatal: under a supervisor an exit would only restart into the same state.
457
+ process.stderr.write('! --host-only: Code-thread hosting is off on this machine (prohost agent code-host enable), ' +
458
+ 'so this agent will refuse every run and host nothing until it is turned on and the agent restarts.\n');
427
459
  }
428
460
  const credentials = loadOrExplain(flagString(flags, 'url') ?? process.env.PROHOST_WS_URL);
429
461
  if (!credentials)
@@ -465,6 +497,7 @@ async function runCommand(flags) {
465
497
  try {
466
498
  const metrics = await runAgentHarness({
467
499
  exec,
500
+ hostOnly,
468
501
  credentials,
469
502
  url: flagString(flags, 'url') ?? process.env.PROHOST_WS_URL,
470
503
  machine: flagString(flags, 'machine'),
@@ -79,8 +79,10 @@ export interface HarnessInvocation {
79
79
  execPath: string;
80
80
  /** Absolute path of the CLI entrypoint script. */
81
81
  scriptPath: string;
82
- /** The `--exec` command, verbatim. */
82
+ /** The `--exec` command, verbatim. Unused with {@link hostOnly}. */
83
83
  exec: string;
84
+ /** `--host-only` instead of `--exec`: the daemon hosts Code threads and runs nothing. */
85
+ hostOnly?: boolean;
84
86
  workdir: string;
85
87
  timeoutSeconds?: number;
86
88
  /** Seconds of total silence before a run is treated as stalled. */
@@ -171,8 +171,7 @@ export function harnessArguments(invocation) {
171
171
  invocation.scriptPath,
172
172
  'agent',
173
173
  'run',
174
- '--exec',
175
- invocation.exec,
174
+ ...(invocation.hostOnly ? ['--host-only'] : ['--exec', invocation.exec]),
176
175
  '--workdir',
177
176
  invocation.workdir,
178
177
  ];
@@ -28,10 +28,13 @@
28
28
  */
29
29
  import type { spawn } from 'node:child_process';
30
30
  import WebSocket from 'ws';
31
+ import { HarnessAccount } from './account_runtime.js';
31
32
  import type { CommandRunner } from './accounts.js';
32
33
  import type { AgentCredentials } from './credentials.js';
33
34
  import type { RunCapabilities } from './capabilities.js';
34
35
  import type { QuotaProvider } from './runtime_status.js';
36
+ import type { CodeHostLike } from '../codehost/host.js';
37
+ import type { AccountEnvResult, CodeAgent } from '../codehost/terminals.js';
35
38
  /**
36
39
  * Default ceiling on one agent invocation: none.
37
40
  *
@@ -72,9 +75,20 @@ export declare const DEFAULT_IDLE_TIMEOUT_MS = 900000;
72
75
  * uses it to stop a run from sitting on CI after the PR is open.
73
76
  */
74
77
  export declare const PAIRED_RUN_ENV_VAR = "PROHOST_PAIRED_RUN";
78
+ /** What a run sent to a `--host-only` harness is answered with: it never executes. */
79
+ export declare const HOST_ONLY_REFUSAL = "This computer only hosts Code threads; it doesn't answer messages.";
80
+ /** Declared by a `--host-only` harness: it executes no runs (the server may say so, or stop sending them). */
81
+ export declare const HOST_ONLY_CAPABILITY = "host_only";
75
82
  export interface AgentRunOptions {
76
- /** Shell command that runs the agent, e.g. ``claude -p``. */
83
+ /** Shell command that runs the agent, e.g. ``claude -p``. Empty with {@link hostOnly}. */
77
84
  exec: string;
85
+ /**
86
+ * `--host-only`: this harness hosts Code threads (control socket, presence,
87
+ * status, the local server, activity) and executes nothing anyone sends. Every
88
+ * `agent.run_requested` it receives is closed at once as failed with
89
+ * {@link HOST_ONLY_REFUSAL}, so a mention never hangs.
90
+ */
91
+ hostOnly?: boolean;
78
92
  credentials: AgentCredentials;
79
93
  /** Websocket endpoint override. Defaults to the paired value. */
80
94
  url?: string;
@@ -176,6 +190,12 @@ export interface AgentRunOptions {
176
190
  * inject a fake so no extra request lands in their recorded calls.
177
191
  */
178
192
  capabilityProbe?: () => Promise<RunCapabilities>;
193
+ /**
194
+ * The Code-thread host (`codehost/host.ts`). Unset, one is built only when
195
+ * this machine's owner turned hosting on (`prohost agent code-host enable`);
196
+ * `null` never hosts. Tests inject a fake.
197
+ */
198
+ codeHost?: CodeHostLike | null;
179
199
  }
180
200
  export interface AgentRunMetrics {
181
201
  connections: number;
@@ -228,5 +248,14 @@ export declare function codexMcpEntryProvider(options: AgentRunOptions, log: (li
228
248
  * Reconnects on any drop with jittered exponential backoff; an ``auth_error``
229
249
  * is fatal, because retrying a rejected credential just hammers the server.
230
250
  */
251
+ /**
252
+ * The login a Code thread's agent runs on: the account picked for this
253
+ * agent, as its ordinary runs get it. A thread for the other CLI, or an
254
+ * account that is no longer on this machine, is refused rather than run on
255
+ * some other login — it would spend a subscription the agent doesn't report.
256
+ * A harness without accounts (a plain `--exec`) and shell threads use the
257
+ * operator's default login, exactly as everything else they run does.
258
+ */
259
+ export declare function codeThreadAccountEnv(account: HarnessAccount | undefined, agent: CodeAgent): AccountEnvResult;
231
260
  export declare function runAgentHarness(options: AgentRunOptions): Promise<AgentRunMetrics>;
232
261
  export {};