@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
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Pure tmux helpers for the Mac terminal host — the same shape as the cloud
3
+ * sandbox's terminal server (`shared/sandbox/template_coding/terminal-server/lib.js`),
4
+ * ported here rather than imported: the CLI ships on npm on its own.
5
+ *
6
+ * Same names as the sandbox — session `prohost`, viewers attached as grouped
7
+ * sessions so each keeps its own current window — with two differences a
8
+ * shared machine needs:
9
+ *
10
+ * - **Its own tmux server** (`tmux -L prohost-<home digest>`). The operator's own tmux, its
11
+ * sessions and its config are never touched, and nothing here can be
12
+ * attached to by accident from their terminal.
13
+ * - **A window per thread**, not one `agent` window per sandbox: `a-<terminal>`
14
+ * runs the agent, `s-<terminal>` is a shell in the same worktree, made the
15
+ * first time someone asks for it.
16
+ *
17
+ * tmux owns the processes, so a thread outlives the daemon: a restarted daemon
18
+ * finds its windows again with {@link listWindowsArgs} and the `@prohost_*`
19
+ * window options set at creation.
20
+ */
21
+ import type { ViewerClient } from './sizing.js';
22
+ /** Prefix of `tmux -L <socket>`: this host's private tmux server (one per `$PROHOST_HOME`). */
23
+ export declare const TMUX_SOCKET_PREFIX = "prohost";
24
+ export declare const TMUX_SESSION = "prohost";
25
+ /** The window the session is created with, so it exists while no thread runs. */
26
+ export declare const HOME_WINDOW = "home";
27
+ export type TerminalWindow = 'agent' | 'shell';
28
+ export declare const TERMINAL_WINDOWS: ReadonlySet<string>;
29
+ /** Terminal and thread ids: what tmux names, env values and URL paths can all carry as-is. */
30
+ export declare const ID_PATTERN: RegExp;
31
+ export declare const DEFAULT_COLS = 120;
32
+ export declare const DEFAULT_ROWS = 32;
33
+ export declare const MAX_DIM = 1000;
34
+ /**
35
+ * The private server's config, written beside the settings and passed with
36
+ * `-f`. Mirrors `shared/sandbox/template_coding/tmux.conf`, except for sizing
37
+ * (each window is made `window-size manual` and sized by the host, see
38
+ * `sizing.ts`), the hidden window-edge border and copy-mode position box, and
39
+ * `scroll-on-clear off` (Mac-only for now: the sandbox config is rebuilt with
40
+ * its template).
41
+ */
42
+ export declare const TMUX_CONF = "# Written by `prohost agent run` for this machine's Code-thread terminals.\n# A private tmux server (tmux -L prohost-\u2026); your own tmux is not affected.\nset -g status off\n# Esc must reach Claude Code immediately (it interrupts a turn).\nset -s escape-time 10\nset -g default-terminal \"tmux-256color\"\nset -g history-limit 50000\n# Claude Code clears the whole screen and redraws it on every width change; with this on\n# (tmux's default) each old screen was kept in scrollback, so its header showed up once per\n# resize. A redraw longer than the screen still scrolls into history, so the host also keeps\n# width changes few (`sizing.ts`). -q: tmux before 3.3 has no such option.\nset -gq scroll-on-clear off\nset -g mouse on\n# Three lines per wheel step, as in a native terminal; tmux's five makes a\n# trackpad jump. Both copy-mode tables: mode-keys follows $EDITOR.\nbind -T copy-mode WheelUpPane select-pane \\; send-keys -X -N 3 scroll-up\nbind -T copy-mode WheelDownPane select-pane \\; send-keys -X -N 3 scroll-down\nbind -T copy-mode-vi WheelUpPane select-pane \\; send-keys -X -N 3 scroll-up\nbind -T copy-mode-vi WheelDownPane select-pane \\; send-keys -X -N 3 scroll-down\n# A program in the pane may not set anyone's clipboard through tmux: OSC 52\n# from the pane is ignored. Pinned rather than left to the default, which was\n# `on` before tmux 2.6. tmux still offers its own copies to the attached\n# terminal as OSC 52; whether a viewer writes them to the system clipboard is\n# that viewer's choice (the web terminal's gated clipboard addon, #21271).\n# The default drag binding is kept on purpose: it leaves copy mode on mouse-up,\n# so the next keystroke (a person's, or a relayed reply's) reaches the agent.\nset -s set-clipboard external\n# Windows are sized by the host, never by whichever viewer was active last: it\n# picks one viewer per window (the first to show it, then whoever types, focuses\n# or asks) and resizes the window to it (`sizing.ts`); `resize-window` makes\n# that window `window-size manual`. A new window is resized at once to the\n# size it will be viewed at. Not manual globally: tmux 3.4's server exits at\n# start with that in its config.\nset -g window-size latest\n# A viewer bigger than the window (someone smaller sizes it) sees blank space\n# beyond it, not tmux's dotted fill. -q: tmux before 3.3 has no such option.\nset -gq fill-character \" \"\n# ...and no border at the window's edge: tmux draws one there in the active\n# border's green. Spaces in the terminal's own colours instead.\nset -g pane-border-style default\nset -g pane-active-border-style default\nset -gq pane-border-lines spaces\nsetw -g aggressive-resize on\n# Scrolling back (copy mode) shows no \"[4/120]\" position box over the agent's\n# screen. -q: an older tmux has no such option (and shows the box).\nset -gq copy-mode-position-format \"\"\n# Window names are an API (the host targets them by name) \u2014 nothing may rename them.\nsetw -g automatic-rename off\nsetw -g allow-rename off\nset -g set-titles off\nset -g focus-events on\n";
43
+ export declare function isValidId(value: unknown): value is string;
44
+ /** tmux window name for one of a thread's windows. */
45
+ export declare function windowName(terminalId: string, window: TerminalWindow): string;
46
+ /** `prohost:<window>` — an exact target, never a prefix match. */
47
+ export declare function windowTarget(name: string): string;
48
+ /**
49
+ * The private server's socket name for one `$PROHOST_HOME`. Two agent homes on
50
+ * one machine (the desktop app's and a terminal's) must not manage one
51
+ * server's windows: each hook would read the other's token.
52
+ */
53
+ export declare function socketNameFor(home: string): string;
54
+ /** Arguments every invocation starts with. */
55
+ export declare function baseArgs(socket: string, confPath: string): string[];
56
+ export declare function hasSessionArgs(): string[];
57
+ export declare function newSessionArgs(cwd: string): string[];
58
+ export interface NewWindowSpec {
59
+ name: string;
60
+ cwd: string;
61
+ env: Record<string, string>;
62
+ /** A shell command line; tmux runs it with `sh -c`. */
63
+ command: string;
64
+ }
65
+ export declare function newWindowArgs(spec: NewWindowSpec): string[];
66
+ /** Window options that let a restarted daemon re-adopt a thread. */
67
+ export interface WindowMeta {
68
+ terminalId: string;
69
+ codeThreadId: string;
70
+ agent: string;
71
+ cwd: string;
72
+ /**
73
+ * Started by a build that tracks Claude Code's trust with the ready mark (`ready.ts`): such a
74
+ * window is past its dialog only once a hook wrote the mark, never by adoption.
75
+ */
76
+ readyMark?: boolean;
77
+ }
78
+ export declare function setWindowMetaArgs(name: string, meta: WindowMeta): string[][];
79
+ export declare function listWindowsArgs(): string[];
80
+ export interface ListedWindow {
81
+ name: string;
82
+ meta?: WindowMeta;
83
+ /** The window's first pane's process (the agent's command), when tmux says. */
84
+ panePid?: number;
85
+ }
86
+ /** Parse {@link listWindowsArgs} output; windows without valid metadata keep only their name. */
87
+ export declare function parseListWindows(stdout: string): ListedWindow[];
88
+ /** A fresh, unguessable name for one viewer's grouped session. */
89
+ export declare function viewSessionName(): string;
90
+ /**
91
+ * argv (after {@link baseArgs}) that attaches one viewer to window `name`.
92
+ *
93
+ * A grouped session (`new-session -t prohost`), as in the sandbox: it shares
94
+ * every window but keeps its own current window, so one viewer on a shell
95
+ * never moves another off the agent. `destroy-unattached` reaps it when the
96
+ * viewer goes. `-u`: the viewer is xterm.js, which is always UTF-8 (see
97
+ * {@link utf8Env}).
98
+ */
99
+ export declare function attachArgs(name: string, viewName: string): string[];
100
+ /** What tmux and its panes run under when the daemon's own env has no UTF-8 locale. */
101
+ export declare const UTF8_CTYPE = "C.UTF-8";
102
+ /**
103
+ * `env` with a UTF-8 character locale, for every tmux this host runs.
104
+ *
105
+ * A daemon started by launchd (or any GUI parent) usually has no LANG at all,
106
+ * and a tmux client without a UTF-8 locale draws every non-ASCII cell as `_` —
107
+ * Claude Code's ⏺ bullets, its logo, an em dash. The server that client starts
108
+ * passes the same locale to the agent and shell panes. Only the character set
109
+ * is set (`LC_CTYPE`): the operator's language and formats stay theirs.
110
+ */
111
+ export declare function utf8Env<T extends Record<string, string | undefined>>(env: T): T;
112
+ /**
113
+ * What only this daemon's own process needs, never a Code thread's terminal. The desktop app runs
114
+ * the CLI on its bundled Electron (`ELECTRON_RUN_AS_NODE=1`, `PROHOST_CLI_RUN=1`): passed on to the
115
+ * tmux server, every shell and agent pane would inherit them, and any Electron app started there
116
+ * (`electron .`, a dev build) would start as plain Node instead. The hook shim sets the one it needs.
117
+ */
118
+ export declare const DAEMON_ONLY_ENV: readonly ["ELECTRON_RUN_AS_NODE", "PROHOST_CLI_RUN"];
119
+ /** {@link utf8Env}, without {@link DAEMON_ONLY_ENV}: the env every tmux this host runs gets. */
120
+ export declare function paneEnv<T extends Record<string, string | undefined>>(env: T): T;
121
+ export declare function killSessionArgs(viewName: string): string[];
122
+ /**
123
+ * Text relayed from a composer, as tmux commands: C0 control characters other
124
+ * than newline and tab are dropped — a reply is text, never a key sequence —
125
+ * and multi-line text goes in as one bracketed paste so the agent sees one
126
+ * message rather than one per line.
127
+ *
128
+ * Returns the commands to run in order; an entry with `stdin` feeds that text
129
+ * to tmux on standard input (`load-buffer -`). Enter is sent last, separately,
130
+ * so the agent submits what was pasted.
131
+ */
132
+ export declare function sendTextCommands(name: string, rawText: string): {
133
+ args: string[];
134
+ stdin?: string;
135
+ }[];
136
+ export type ApprovalDecision = 'allow' | 'allow_session' | 'deny';
137
+ export declare const APPROVAL_DECISIONS: ReadonlySet<string>;
138
+ export declare function clampDim(raw: unknown, fallback: number): number;
139
+ /**
140
+ * A text control frame. Input arrives as binary or `input`; `hello` says what the viewer is and
141
+ * `claim` asks to size the window to it (see `sizing.ts`). Anything else is ignored.
142
+ */
143
+ export type ControlFrame = {
144
+ type: 'resize';
145
+ cols: number;
146
+ rows: number;
147
+ } | {
148
+ type: 'input';
149
+ data: string;
150
+ } | {
151
+ type: 'hello';
152
+ client: ViewerClient;
153
+ } | {
154
+ type: 'claim';
155
+ } | {
156
+ type: 'ping';
157
+ } | {
158
+ type: 'pong';
159
+ };
160
+ export declare function parseControl(text: string): ControlFrame | null;
161
+ /**
162
+ * Whether a viewer's keystrokes stop the agent's turn: a lone Esc (Claude
163
+ * Code's interrupt) or a Ctrl+C anywhere in the chunk. Esc as the first byte of
164
+ * a longer sequence is an arrow key or a terminal report, not an interrupt.
165
+ */
166
+ export declare function isInterruptInput(data: Uint8Array): boolean;
167
+ /** Whether `data` holds anything a person typed: what is left once the emulator's own reports are taken out. */
168
+ export declare function isHumanInput(data: Uint8Array): boolean;
169
+ /** A focus-in report (`focus-events on`): the viewer's terminal just got focus, which claims the window's size. */
170
+ export declare function hasFocusIn(data: Uint8Array): boolean;
171
+ /**
172
+ * Whether `data` may have put the pane in copy mode: a mouse report (a wheel step up or a drag
173
+ * enters it, `mouse on`) or tmux's prefix key (C-b [). Typed input that follows leaves it first.
174
+ */
175
+ export declare function mayEnterCopyMode(data: Uint8Array): boolean;
176
+ /**
177
+ * Text a person typed (or pasted) for the program in the pane: a chunk that holds a printable
178
+ * character and isn't an escape sequence (arrows, PgUp, function keys move through copy mode) —
179
+ * except a bracketed paste (`ESC [200~`…), which is text.
180
+ */
181
+ export declare function isTypedText(data: Uint8Array): boolean;
182
+ /**
183
+ * A gesture that scrolls (or drags in) the pane — what makes a viewer "the one who scrolled": a wheel
184
+ * step or a drag (an SGR or X10 mouse report with button 32 and up), or the prefix key. A plain
185
+ * click is not one.
186
+ */
187
+ export declare function isScrollGesture(data: Uint8Array): boolean;
188
+ /**
189
+ * The keys a person presses to leave copy mode (q, Esc, Ctrl+C): typed while the pane was in it,
190
+ * they only leave it, as they did before — never reach the agent as a keystroke or an interrupt.
191
+ */
192
+ export declare function isCopyModeExitKey(data: Uint8Array): boolean;
193
+ /** `1` when the window's pane is in a mode (copy mode, after a scroll back). */
194
+ export declare function paneInModeArgs(name: string): string[];
195
+ /** Leave copy mode, if the pane is in it, before relayed text is typed (it would be read as copy-mode keys). */
196
+ export declare function leaveCopyModeArgs(name: string): string[];
@@ -0,0 +1,391 @@
1
+ /**
2
+ * Pure tmux helpers for the Mac terminal host — the same shape as the cloud
3
+ * sandbox's terminal server (`shared/sandbox/template_coding/terminal-server/lib.js`),
4
+ * ported here rather than imported: the CLI ships on npm on its own.
5
+ *
6
+ * Same names as the sandbox — session `prohost`, viewers attached as grouped
7
+ * sessions so each keeps its own current window — with two differences a
8
+ * shared machine needs:
9
+ *
10
+ * - **Its own tmux server** (`tmux -L prohost-<home digest>`). The operator's own tmux, its
11
+ * sessions and its config are never touched, and nothing here can be
12
+ * attached to by accident from their terminal.
13
+ * - **A window per thread**, not one `agent` window per sandbox: `a-<terminal>`
14
+ * runs the agent, `s-<terminal>` is a shell in the same worktree, made the
15
+ * first time someone asks for it.
16
+ *
17
+ * tmux owns the processes, so a thread outlives the daemon: a restarted daemon
18
+ * finds its windows again with {@link listWindowsArgs} and the `@prohost_*`
19
+ * window options set at creation.
20
+ */
21
+ import crypto from 'node:crypto';
22
+ import { parseViewerClient } from './sizing.js';
23
+ /** Prefix of `tmux -L <socket>`: this host's private tmux server (one per `$PROHOST_HOME`). */
24
+ export const TMUX_SOCKET_PREFIX = 'prohost';
25
+ export const TMUX_SESSION = 'prohost';
26
+ /** The window the session is created with, so it exists while no thread runs. */
27
+ export const HOME_WINDOW = 'home';
28
+ export const TERMINAL_WINDOWS = new Set(['agent', 'shell']);
29
+ /** Terminal and thread ids: what tmux names, env values and URL paths can all carry as-is. */
30
+ export const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
31
+ export const DEFAULT_COLS = 120;
32
+ export const DEFAULT_ROWS = 32;
33
+ export const MAX_DIM = 1000;
34
+ /**
35
+ * The private server's config, written beside the settings and passed with
36
+ * `-f`. Mirrors `shared/sandbox/template_coding/tmux.conf`, except for sizing
37
+ * (each window is made `window-size manual` and sized by the host, see
38
+ * `sizing.ts`), the hidden window-edge border and copy-mode position box, and
39
+ * `scroll-on-clear off` (Mac-only for now: the sandbox config is rebuilt with
40
+ * its template).
41
+ */
42
+ export const TMUX_CONF = `# Written by \`prohost agent run\` for this machine's Code-thread terminals.
43
+ # A private tmux server (tmux -L ${TMUX_SOCKET_PREFIX}-…); your own tmux is not affected.
44
+ set -g status off
45
+ # Esc must reach Claude Code immediately (it interrupts a turn).
46
+ set -s escape-time 10
47
+ set -g default-terminal "tmux-256color"
48
+ set -g history-limit 50000
49
+ # Claude Code clears the whole screen and redraws it on every width change; with this on
50
+ # (tmux's default) each old screen was kept in scrollback, so its header showed up once per
51
+ # resize. A redraw longer than the screen still scrolls into history, so the host also keeps
52
+ # width changes few (\`sizing.ts\`). -q: tmux before 3.3 has no such option.
53
+ set -gq scroll-on-clear off
54
+ set -g mouse on
55
+ # Three lines per wheel step, as in a native terminal; tmux's five makes a
56
+ # trackpad jump. Both copy-mode tables: mode-keys follows $EDITOR.
57
+ bind -T copy-mode WheelUpPane select-pane \\; send-keys -X -N 3 scroll-up
58
+ bind -T copy-mode WheelDownPane select-pane \\; send-keys -X -N 3 scroll-down
59
+ bind -T copy-mode-vi WheelUpPane select-pane \\; send-keys -X -N 3 scroll-up
60
+ bind -T copy-mode-vi WheelDownPane select-pane \\; send-keys -X -N 3 scroll-down
61
+ # A program in the pane may not set anyone's clipboard through tmux: OSC 52
62
+ # from the pane is ignored. Pinned rather than left to the default, which was
63
+ # \`on\` before tmux 2.6. tmux still offers its own copies to the attached
64
+ # terminal as OSC 52; whether a viewer writes them to the system clipboard is
65
+ # that viewer's choice (the web terminal's gated clipboard addon, #21271).
66
+ # The default drag binding is kept on purpose: it leaves copy mode on mouse-up,
67
+ # so the next keystroke (a person's, or a relayed reply's) reaches the agent.
68
+ set -s set-clipboard external
69
+ # Windows are sized by the host, never by whichever viewer was active last: it
70
+ # picks one viewer per window (the first to show it, then whoever types, focuses
71
+ # or asks) and resizes the window to it (\`sizing.ts\`); \`resize-window\` makes
72
+ # that window \`window-size manual\`. A new window is resized at once to the
73
+ # size it will be viewed at. Not manual globally: tmux 3.4's server exits at
74
+ # start with that in its config.
75
+ set -g window-size latest
76
+ # A viewer bigger than the window (someone smaller sizes it) sees blank space
77
+ # beyond it, not tmux's dotted fill. -q: tmux before 3.3 has no such option.
78
+ set -gq fill-character " "
79
+ # ...and no border at the window's edge: tmux draws one there in the active
80
+ # border's green. Spaces in the terminal's own colours instead.
81
+ set -g pane-border-style default
82
+ set -g pane-active-border-style default
83
+ set -gq pane-border-lines spaces
84
+ setw -g aggressive-resize on
85
+ # Scrolling back (copy mode) shows no "[4/120]" position box over the agent's
86
+ # screen. -q: an older tmux has no such option (and shows the box).
87
+ set -gq copy-mode-position-format ""
88
+ # Window names are an API (the host targets them by name) — nothing may rename them.
89
+ setw -g automatic-rename off
90
+ setw -g allow-rename off
91
+ set -g set-titles off
92
+ set -g focus-events on
93
+ `;
94
+ export function isValidId(value) {
95
+ return typeof value === 'string' && ID_PATTERN.test(value);
96
+ }
97
+ /** tmux window name for one of a thread's windows. */
98
+ export function windowName(terminalId, window) {
99
+ if (!isValidId(terminalId))
100
+ throw new Error('invalid terminal id');
101
+ return `${window === 'agent' ? 'a' : 's'}-${terminalId}`;
102
+ }
103
+ /** `prohost:<window>` — an exact target, never a prefix match. */
104
+ export function windowTarget(name) {
105
+ // `=` makes tmux match the name exactly: `a-1` must not resolve to `a-10`.
106
+ return `${TMUX_SESSION}:=${name}`;
107
+ }
108
+ /**
109
+ * The private server's socket name for one `$PROHOST_HOME`. Two agent homes on
110
+ * one machine (the desktop app's and a terminal's) must not manage one
111
+ * server's windows: each hook would read the other's token.
112
+ */
113
+ export function socketNameFor(home) {
114
+ return `${TMUX_SOCKET_PREFIX}-${crypto.createHash('sha256').update(home).digest('hex').slice(0, 10)}`;
115
+ }
116
+ /** Arguments every invocation starts with. */
117
+ export function baseArgs(socket, confPath) {
118
+ return ['-L', socket, '-f', confPath];
119
+ }
120
+ export function hasSessionArgs() {
121
+ return ['has-session', '-t', `=${TMUX_SESSION}`];
122
+ }
123
+ export function newSessionArgs(cwd) {
124
+ return ['new-session', '-d', '-s', TMUX_SESSION, '-n', HOME_WINDOW, '-c', cwd];
125
+ }
126
+ export function newWindowArgs(spec) {
127
+ const env = Object.entries(spec.env).flatMap(([key, value]) => ['-e', `${key}=${value}`]);
128
+ return ['new-window', '-d', '-t', `${TMUX_SESSION}:`, '-n', spec.name, '-c', spec.cwd, ...env, spec.command];
129
+ }
130
+ export function setWindowMetaArgs(name, meta) {
131
+ const target = windowTarget(name);
132
+ return [
133
+ ['set-option', '-w', '-t', target, '@prohost_terminal', meta.terminalId],
134
+ ['set-option', '-w', '-t', target, '@prohost_thread', meta.codeThreadId],
135
+ ['set-option', '-w', '-t', target, '@prohost_agent', meta.agent],
136
+ ['set-option', '-w', '-t', target, '@prohost_cwd', meta.cwd],
137
+ ...(meta.readyMark ? [['set-option', '-w', '-t', target, '@prohost_ready_mark', '1']] : []),
138
+ ];
139
+ }
140
+ const LIST_FORMAT = '#{window_name}\t#{@prohost_terminal}\t#{@prohost_thread}\t#{@prohost_agent}\t#{@prohost_cwd}' +
141
+ '\t#{@prohost_ready_mark}\t#{pane_pid}';
142
+ export function listWindowsArgs() {
143
+ return ['list-windows', '-t', `=${TMUX_SESSION}`, '-F', LIST_FORMAT];
144
+ }
145
+ /** Parse {@link listWindowsArgs} output; windows without valid metadata keep only their name. */
146
+ export function parseListWindows(stdout) {
147
+ const windows = [];
148
+ for (const line of stdout.split('\n')) {
149
+ if (!line.trim())
150
+ continue;
151
+ const [name = '', terminalId = '', codeThreadId = '', agent = '', cwd = '', readyMark = '', pid = ''] = line.split('\t');
152
+ if (!name)
153
+ continue;
154
+ const meta = isValidId(terminalId) && isValidId(codeThreadId) && agent && cwd
155
+ ? {
156
+ terminalId,
157
+ codeThreadId,
158
+ agent,
159
+ cwd,
160
+ ...(readyMark === '1' ? { readyMark: true } : {}),
161
+ }
162
+ : undefined;
163
+ const panePid = /^\d+$/.test(pid) ? Number(pid) : undefined;
164
+ windows.push({ name, ...(meta ? { meta } : {}), ...(panePid ? { panePid } : {}) });
165
+ }
166
+ return windows;
167
+ }
168
+ /** A fresh, unguessable name for one viewer's grouped session. */
169
+ export function viewSessionName() {
170
+ return `view-${crypto.randomBytes(6).toString('hex')}`;
171
+ }
172
+ /**
173
+ * argv (after {@link baseArgs}) that attaches one viewer to window `name`.
174
+ *
175
+ * A grouped session (`new-session -t prohost`), as in the sandbox: it shares
176
+ * every window but keeps its own current window, so one viewer on a shell
177
+ * never moves another off the agent. `destroy-unattached` reaps it when the
178
+ * viewer goes. `-u`: the viewer is xterm.js, which is always UTF-8 (see
179
+ * {@link utf8Env}).
180
+ */
181
+ export function attachArgs(name, viewName) {
182
+ return [
183
+ '-u',
184
+ 'new-session',
185
+ '-t',
186
+ `=${TMUX_SESSION}`,
187
+ '-s',
188
+ viewName,
189
+ ';',
190
+ 'set-option',
191
+ 'destroy-unattached',
192
+ 'on',
193
+ ';',
194
+ 'select-window',
195
+ '-t',
196
+ `${viewName}:=${name}`,
197
+ ];
198
+ }
199
+ /** What tmux and its panes run under when the daemon's own env has no UTF-8 locale. */
200
+ export const UTF8_CTYPE = 'C.UTF-8';
201
+ /**
202
+ * `env` with a UTF-8 character locale, for every tmux this host runs.
203
+ *
204
+ * A daemon started by launchd (or any GUI parent) usually has no LANG at all,
205
+ * and a tmux client without a UTF-8 locale draws every non-ASCII cell as `_` —
206
+ * Claude Code's ⏺ bullets, its logo, an em dash. The server that client starts
207
+ * passes the same locale to the agent and shell panes. Only the character set
208
+ * is set (`LC_CTYPE`): the operator's language and formats stay theirs.
209
+ */
210
+ export function utf8Env(env) {
211
+ const ctype = env.LC_ALL || env.LC_CTYPE || env.LANG || '';
212
+ if (/utf-?8/i.test(ctype))
213
+ return env;
214
+ const out = { ...env, LC_CTYPE: UTF8_CTYPE };
215
+ // A non-UTF-8 LC_ALL would outrank it.
216
+ delete out.LC_ALL;
217
+ return out;
218
+ }
219
+ /**
220
+ * What only this daemon's own process needs, never a Code thread's terminal. The desktop app runs
221
+ * the CLI on its bundled Electron (`ELECTRON_RUN_AS_NODE=1`, `PROHOST_CLI_RUN=1`): passed on to the
222
+ * tmux server, every shell and agent pane would inherit them, and any Electron app started there
223
+ * (`electron .`, a dev build) would start as plain Node instead. The hook shim sets the one it needs.
224
+ */
225
+ export const DAEMON_ONLY_ENV = ['ELECTRON_RUN_AS_NODE', 'PROHOST_CLI_RUN'];
226
+ /** {@link utf8Env}, without {@link DAEMON_ONLY_ENV}: the env every tmux this host runs gets. */
227
+ export function paneEnv(env) {
228
+ const out = { ...env };
229
+ for (const key of DAEMON_ONLY_ENV)
230
+ delete out[key];
231
+ return utf8Env(out);
232
+ }
233
+ export function killSessionArgs(viewName) {
234
+ return ['kill-session', '-t', `=${viewName}`];
235
+ }
236
+ /**
237
+ * Text relayed from a composer, as tmux commands: C0 control characters other
238
+ * than newline and tab are dropped — a reply is text, never a key sequence —
239
+ * and multi-line text goes in as one bracketed paste so the agent sees one
240
+ * message rather than one per line.
241
+ *
242
+ * Returns the commands to run in order; an entry with `stdin` feeds that text
243
+ * to tmux on standard input (`load-buffer -`). Enter is sent last, separately,
244
+ * so the agent submits what was pasted.
245
+ */
246
+ export function sendTextCommands(name, rawText) {
247
+ // eslint-disable-next-line no-control-regex
248
+ const text = rawText.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, '');
249
+ const target = windowTarget(name);
250
+ if (!text.trim())
251
+ return [];
252
+ if (!text.includes('\n')) {
253
+ return [
254
+ { args: ['send-keys', '-t', target, '-l', '--', text] },
255
+ { args: ['send-keys', '-t', target, 'Enter'] },
256
+ ];
257
+ }
258
+ const buffer = `prohost-${crypto.randomBytes(4).toString('hex')}`;
259
+ return [
260
+ { args: ['load-buffer', '-b', buffer, '-'], stdin: text },
261
+ // -p: bracketed paste when the application asked for it; -d: delete the buffer after.
262
+ { args: ['paste-buffer', '-p', '-d', '-b', buffer, '-t', target] },
263
+ { args: ['send-keys', '-t', target, 'Enter'] },
264
+ ];
265
+ }
266
+ export const APPROVAL_DECISIONS = new Set(['allow', 'allow_session', 'deny']);
267
+ export function clampDim(raw, fallback) {
268
+ const n = typeof raw === 'number' ? Math.trunc(raw) : Number.parseInt(String(raw ?? ''), 10);
269
+ if (!Number.isFinite(n) || n < 1)
270
+ return fallback;
271
+ return Math.min(n, MAX_DIM);
272
+ }
273
+ export function parseControl(text) {
274
+ let frame;
275
+ try {
276
+ frame = JSON.parse(text);
277
+ }
278
+ catch {
279
+ return null;
280
+ }
281
+ if (!frame || typeof frame !== 'object')
282
+ return null;
283
+ const f = frame;
284
+ if (f.type === 'resize') {
285
+ const cols = clampDim(f.cols, 0);
286
+ const rows = clampDim(f.rows, 0);
287
+ return cols && rows ? { type: 'resize', cols, rows } : null;
288
+ }
289
+ if (f.type === 'input' && typeof f.data === 'string')
290
+ return { type: 'input', data: f.data };
291
+ if (f.type === 'hello')
292
+ return { type: 'hello', client: parseViewerClient(f.client) };
293
+ if (f.type === 'claim')
294
+ return { type: 'claim' };
295
+ if (f.type === 'ping' || f.type === 'pong')
296
+ return { type: f.type };
297
+ return null;
298
+ }
299
+ /**
300
+ * Whether a viewer's keystrokes stop the agent's turn: a lone Esc (Claude
301
+ * Code's interrupt) or a Ctrl+C anywhere in the chunk. Esc as the first byte of
302
+ * a longer sequence is an arrow key or a terminal report, not an interrupt.
303
+ */
304
+ export function isInterruptInput(data) {
305
+ if (data.length === 1 && data[0] === 0x1b)
306
+ return true;
307
+ return data.includes(0x03);
308
+ }
309
+ /**
310
+ * What a terminal emulator sends on its own, never a person: focus in/out
311
+ * (`focus-events on`), cursor-position and status replies, device-attribute
312
+ * and mode reports, window-size reports, mouse reports (`mouse on`: a click
313
+ * or a wheel step is reported, not typed), and OSC/DCS replies such as a
314
+ * colour query's answer. Several can arrive in one chunk. The same set as
315
+ * the web terminal's `AUTOMATIC_TERMINAL_SEQUENCE` (`web/src/features/coding/
316
+ * utils/terminalKeys.ts`); xterm.js hands each report to `onData` whole.
317
+ */
318
+ const AUTOMATIC_REPORTS = new RegExp([
319
+ /\x1b\[[IO]/, // focus in / out
320
+ /\x1b\[\??\d+(?:;\d+){1,2}R/, // cursor position report, and xterm's DEC form (DECXCPR)
321
+ /\x1b\[\??\d*n/, // device status report reply
322
+ /\x1b\[[?>=][\d;]*c/, // primary / secondary / tertiary device attributes
323
+ /\x1b\[\??[\d;]*\$y/, // mode report (DECRPM)
324
+ /\x1b\[\d+(?:;\d+)*t/, // window size / position report
325
+ /\x1b\[<\d+;\d+;\d+[Mm]/, // SGR mouse report
326
+ /\x1b\[M[\s\S]{3}/, // X10 mouse report
327
+ /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/, // OSC reply (colours, clipboard query)
328
+ /\x1bP[^\x1b]*\x1b\\/, // DCS reply (terminal version, setting query)
329
+ ]
330
+ .map((pattern) => pattern.source)
331
+ .join('|'), 'g');
332
+ /** Whether `data` holds anything a person typed: what is left once the emulator's own reports are taken out. */
333
+ export function isHumanInput(data) {
334
+ return Buffer.from(data).toString('latin1').replace(AUTOMATIC_REPORTS, '').length > 0;
335
+ }
336
+ /** A focus-in report (`focus-events on`): the viewer's terminal just got focus, which claims the window's size. */
337
+ export function hasFocusIn(data) {
338
+ return Buffer.from(data).toString('latin1').includes('\x1b[I');
339
+ }
340
+ /**
341
+ * Whether `data` may have put the pane in copy mode: a mouse report (a wheel step up or a drag
342
+ * enters it, `mouse on`) or tmux's prefix key (C-b [). Typed input that follows leaves it first.
343
+ */
344
+ export function mayEnterCopyMode(data) {
345
+ return data.includes(0x02) || /\x1b\[<\d+;\d+;\d+[Mm]|\x1b\[M/.test(Buffer.from(data).toString('latin1'));
346
+ }
347
+ /**
348
+ * Text a person typed (or pasted) for the program in the pane: a chunk that holds a printable
349
+ * character and isn't an escape sequence (arrows, PgUp, function keys move through copy mode) —
350
+ * except a bracketed paste (`ESC [200~`…), which is text.
351
+ */
352
+ export function isTypedText(data) {
353
+ if (data.length === 0)
354
+ return false;
355
+ if (data[0] === 0x1b && !Buffer.from(data).toString('latin1').startsWith('\x1b[200~'))
356
+ return false;
357
+ return data.some((byte) => byte >= 0x20 && byte !== 0x7f);
358
+ }
359
+ /**
360
+ * A gesture that scrolls (or drags in) the pane — what makes a viewer "the one who scrolled": a wheel
361
+ * step or a drag (an SGR or X10 mouse report with button 32 and up), or the prefix key. A plain
362
+ * click is not one.
363
+ */
364
+ export function isScrollGesture(data) {
365
+ if (data.includes(0x02))
366
+ return true;
367
+ const text = Buffer.from(data).toString('latin1');
368
+ for (const match of text.matchAll(/\x1b\[<(\d+);\d+;\d+[Mm]/g))
369
+ if (Number(match[1]) >= 32)
370
+ return true;
371
+ for (const match of text.matchAll(/\x1b\[M([\s\S])[\s\S]{2}/g))
372
+ if (match[1].charCodeAt(0) - 32 >= 32)
373
+ return true;
374
+ return false;
375
+ }
376
+ /**
377
+ * The keys a person presses to leave copy mode (q, Esc, Ctrl+C): typed while the pane was in it,
378
+ * they only leave it, as they did before — never reach the agent as a keystroke or an interrupt.
379
+ */
380
+ export function isCopyModeExitKey(data) {
381
+ return data.length === 1 && (data[0] === 0x71 || data[0] === 0x1b || data[0] === 0x03);
382
+ }
383
+ /** `1` when the window's pane is in a mode (copy mode, after a scroll back). */
384
+ export function paneInModeArgs(name) {
385
+ return ['display-message', '-p', '-t', windowTarget(name), '#{pane_in_mode}'];
386
+ }
387
+ /** Leave copy mode, if the pane is in it, before relayed text is typed (it would be read as copy-mode keys). */
388
+ export function leaveCopyModeArgs(name) {
389
+ const target = windowTarget(name);
390
+ return ['if-shell', '-F', '-t', target, '#{pane_in_mode}', `send-keys -X -t ${target} cancel`];
391
+ }
package/dist/index.d.ts CHANGED
@@ -22,6 +22,11 @@ interface ParsedArgs {
22
22
  flags: Record<string, string | boolean>;
23
23
  }
24
24
  export declare function parseArgs(argv: readonly string[]): ParsedArgs;
25
+ /**
26
+ * The `hook` command's arguments as the hook reads them: its positionals, and
27
+ * the PermissionRequest hook's `--wait` (which `parseArgs` keeps as a flag).
28
+ */
29
+ export declare function hookArgs({ subcommand, args, flags }: ParsedArgs): string[];
25
30
  declare function main(argv: readonly string[]): Promise<number>;
26
31
  export { main };
27
32
  /**
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ import process from 'node:process';
18
18
  import { fileURLToPath } from 'node:url';
19
19
  import { endpointProblem } from './agent/base-url.js';
20
20
  import { runAgent } from './agent/command.js';
21
+ import { runHook } from './codehost/hooks.js';
21
22
  import { DEFAULT_WS_URL, runListen } from './listen.js';
22
23
  import { runUsage } from './usage/command.js';
23
24
  import { CLI_VERSION } from './version.js';
@@ -31,12 +32,17 @@ function printHelp() {
31
32
  ' [--webhook-url <https-url>] [--base-url <url>]',
32
33
  " prohost agent run --exec '<command>' [--concurrency <n>] [--repos <checkout,…>] [--idle-timeout <seconds>]",
33
34
  ' [--timeout <seconds>] [--dry-run] [--workdir <dir>]',
35
+ ' prohost agent run --host-only [--repos <checkout,…>] [--machine <name>] Host Code threads only; run nothing',
34
36
  " prohost agent install-daemon --exec '<command>' [--workdir <dir>] [--account <label>] Keep the agent running",
37
+ ' prohost agent install-daemon --host-only [--machine <name>]',
35
38
  ' prohost agent uninstall-daemon',
36
39
  ' prohost agent account add <label> --runtime claude|codex [--billing subscription|api_key]',
37
40
  ' prohost agent account list | remove <label>',
38
41
  ' prohost agent list Every paired agent on this machine, and its accounts',
39
42
  ' prohost agent workspace init --developer --repo <checkout> [--workdir <dir>]',
43
+ ' prohost agent code-host status | enable [--allow-remote-terminal] | remote on|off | disable',
44
+ ' Host Code threads (agent terminals) on this Mac; off by default',
45
+ ' prohost agent code-host folders add <path> | list [--json] | remove <path>',
40
46
  ' prohost usage connect [--key -] [--base-url <url>] Report AI usage without pairing an agent (prompts for the key; --key - reads it from stdin)',
41
47
  ' prohost usage discover [--dry-run] | start [--once] [--machine <name>] | status',
42
48
  ' prohost usage install-daemon [--machine <name>] | uninstall-daemon',
@@ -73,6 +79,9 @@ function printHelp() {
73
79
  ' its own `git worktree` of every one under $PROHOST_HOME/worktrees, kept for',
74
80
  ' the life of the thread and removed after 14 idle days if it is clean. The',
75
81
  ' agent is told to work there and to leave the originals alone.',
82
+ ' --host-only Instead of --exec: host Code threads on this machine (terminals you open',
83
+ ' from ProhostAI) and execute nothing anyone sends. Every run requested of',
84
+ ' this agent — a mention, a DM — is closed at once as refused.',
76
85
  ' --dry-run Print the reply that would be posted; write nothing.',
77
86
  ' --workdir Working directory for the agent command. Defaults to $PROHOST_HOME/workspace.',
78
87
  ' Avoid pointing it at a code checkout: agents read CLAUDE.md, .claude/settings.json',
@@ -183,6 +192,14 @@ export function parseArgs(argv) {
183
192
  }
184
193
  return { command, subcommand, args, flags };
185
194
  }
195
+ /**
196
+ * The `hook` command's arguments as the hook reads them: its positionals, and
197
+ * the PermissionRequest hook's `--wait` (which `parseArgs` keeps as a flag).
198
+ */
199
+ export function hookArgs({ subcommand, args, flags }) {
200
+ const wait = flags.wait;
201
+ return [subcommand ?? '', ...args, ...(typeof wait === 'string' ? ['--wait', wait] : [])];
202
+ }
186
203
  async function main(argv) {
187
204
  const { command, subcommand, args, flags } = parseArgs(argv);
188
205
  // Checked before the help/no-command branch: `prohost --version` carries no
@@ -209,6 +226,10 @@ async function main(argv) {
209
226
  if (command === 'usage') {
210
227
  return runUsage(subcommand, flags, printHelp);
211
228
  }
229
+ // Run by the Code-thread hook shim (codehost/hooks.ts), not by people; not in --help.
230
+ if (command === 'hook') {
231
+ return runHook(hookArgs({ command, subcommand, args, flags }));
232
+ }
212
233
  if (command !== 'listen') {
213
234
  process.stderr.write(`Unknown command: ${command}\n\n`);
214
235
  printHelp();