@prohost/cli 0.13.3 → 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 (59) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +129 -0
  3. package/dist/agent/command.d.ts +8 -0
  4. package/dist/agent/command.js +40 -7
  5. package/dist/agent/daemon.d.ts +3 -1
  6. package/dist/agent/daemon.js +1 -2
  7. package/dist/agent/run.d.ts +30 -1
  8. package/dist/agent/run.js +221 -9
  9. package/dist/agent/runtime_status.d.ts +99 -3
  10. package/dist/agent/runtime_status.js +173 -5
  11. package/dist/agent/worktrees.d.ts +2 -0
  12. package/dist/agent/worktrees.js +3 -2
  13. package/dist/codehost/activity.d.ts +64 -0
  14. package/dist/codehost/activity.js +135 -0
  15. package/dist/codehost/agents.d.ts +31 -0
  16. package/dist/codehost/agents.js +57 -0
  17. package/dist/codehost/checkouts.d.ts +130 -0
  18. package/dist/codehost/checkouts.js +270 -0
  19. package/dist/codehost/claude_trust.d.ts +40 -0
  20. package/dist/codehost/claude_trust.js +129 -0
  21. package/dist/codehost/command.d.ts +25 -0
  22. package/dist/codehost/command.js +136 -0
  23. package/dist/codehost/decision.d.ts +156 -0
  24. package/dist/codehost/decision.js +553 -0
  25. package/dist/codehost/events.d.ts +49 -0
  26. package/dist/codehost/events.js +87 -0
  27. package/dist/codehost/folders.d.ts +122 -0
  28. package/dist/codehost/folders.js +317 -0
  29. package/dist/codehost/hooks.d.ts +178 -0
  30. package/dist/codehost/hooks.js +726 -0
  31. package/dist/codehost/host.d.ts +290 -0
  32. package/dist/codehost/host.js +625 -0
  33. package/dist/codehost/host_stats.d.ts +71 -0
  34. package/dist/codehost/host_stats.js +166 -0
  35. package/dist/codehost/invocations.d.ts +12 -0
  36. package/dist/codehost/invocations.js +82 -0
  37. package/dist/codehost/ledger.d.ts +35 -0
  38. package/dist/codehost/ledger.js +91 -0
  39. package/dist/codehost/local_server.d.ts +106 -0
  40. package/dist/codehost/local_server.js +339 -0
  41. package/dist/codehost/pty.d.ts +38 -0
  42. package/dist/codehost/pty.js +47 -0
  43. package/dist/codehost/ready.d.ts +31 -0
  44. package/dist/codehost/ready.js +109 -0
  45. package/dist/codehost/relay.d.ts +170 -0
  46. package/dist/codehost/relay.js +548 -0
  47. package/dist/codehost/settings.d.ts +52 -0
  48. package/dist/codehost/settings.js +114 -0
  49. package/dist/codehost/sizing.d.ts +163 -0
  50. package/dist/codehost/sizing.js +394 -0
  51. package/dist/codehost/terminals.d.ts +390 -0
  52. package/dist/codehost/terminals.js +965 -0
  53. package/dist/codehost/tmux.d.ts +196 -0
  54. package/dist/codehost/tmux.js +391 -0
  55. package/dist/index.d.ts +5 -0
  56. package/dist/index.js +21 -0
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. package/package.json +4 -1
@@ -0,0 +1,390 @@
1
+ /**
2
+ * The Mac's Code-thread terminals: one tmux window per thread, attached
3
+ * through a pty per viewer.
4
+ *
5
+ * - {@link CodeTerminals.start} handles `code_thread.start_requested`: a
6
+ * worktree for the thread, then a window running the agent (`claude`,
7
+ * `codex` or a shell) with `PROHOST_TERMINAL_ID` / `PROHOST_CODE_THREAD_ID`
8
+ * in its environment — what makes the hooks fire for it and for nothing
9
+ * else. When the agent exits, the window becomes a shell in the worktree; it
10
+ * never just disappears under a viewer.
11
+ * - {@link CodeTerminals.input} handles `code_thread.input_requested`: the text
12
+ * typed into a composer that isn't attached to the terminal, sent to the
13
+ * pane as one paste plus Enter.
14
+ * - {@link CodeTerminals.attach} gives a viewer (relay or desktop) a byte
15
+ * stream to one of a thread's windows.
16
+ * - {@link CodeTerminals.adopt} finds the threads a previous daemon started:
17
+ * tmux owns their processes, so a daemon restart costs nothing.
18
+ *
19
+ * Every start and input is idempotent under redelivery: a window that exists
20
+ * is the thread already started.
21
+ */
22
+ import { execFile } from 'node:child_process';
23
+ import type { InstalledAgent } from './agents.js';
24
+ import type { CheckoutResult } from './checkouts.js';
25
+ import type { CodeThreadEventSink } from './events.js';
26
+ import type { PtySpawn } from './pty.js';
27
+ import { TerminalSizing } from './sizing.js';
28
+ import type { SizerFrame, ViewerClient } from './sizing.js';
29
+ import type { ApprovalDecision, TerminalWindow } from './tmux.js';
30
+ export declare const CODE_AGENTS: readonly ["claude_code", "codex", "shell"];
31
+ export type CodeAgent = (typeof CODE_AGENTS)[number];
32
+ export declare const GOAL_ENV = "PROHOST_CODE_THREAD_GOAL";
33
+ /** Window option set when the agent exits and its window becomes a shell. */
34
+ export declare const EXITED_OPTION = "@prohost_exited";
35
+ /**
36
+ * Window option that marks a relayed reply as possibly sitting in the agent's
37
+ * input unsubmitted. It is set before the reply's first keystroke reaches the
38
+ * pane and cleared only once its Enter went through, so a reply stopped half
39
+ * way — a revocation or `remote off` between the steps, a tmux error, the
40
+ * daemon dying — leaves it set. Relayed text is refused while it is set, so
41
+ * the next reply can't append to the leftover and submit both; the owner
42
+ * typing in a terminal attached to that window (where they see the input box)
43
+ * clears it.
44
+ */
45
+ export declare const STAGED_OPTION = "@prohost_staged";
46
+ export interface TmuxResult {
47
+ ok: boolean;
48
+ stdout: string;
49
+ stderr: string;
50
+ /**
51
+ * Failed with an unknown outcome: the command was killed (a timeout) after it
52
+ * started, so it may have done its work. A clean failure (tmux exited with an
53
+ * error, or never started) did nothing.
54
+ */
55
+ uncertain?: boolean;
56
+ }
57
+ /** Runs `tmux <args>` (the base args are added by the caller); optional stdin, and a per-call timeout. */
58
+ export type TmuxRunner = (args: string[], stdin?: string, timeoutMs?: number) => Promise<TmuxResult>;
59
+ export declare function execTmux(binary?: string): TmuxRunner;
60
+ /** The login a thread's agent runs on, or why it can't run here. */
61
+ export type AccountEnvResult = {
62
+ ok: true;
63
+ env: NodeJS.ProcessEnv;
64
+ } | {
65
+ ok: false;
66
+ reason: string;
67
+ };
68
+ export interface StartRequest {
69
+ codeThreadId: string;
70
+ terminalId: string;
71
+ goal: string;
72
+ repo: string;
73
+ branch?: string;
74
+ agent: CodeAgent;
75
+ /**
76
+ * A folder start: the folder the thread runs in (absolute or `~/…`, as the server was sent it).
77
+ * Absent for a repository thread. Only a folder this Mac has opened, or one inside it, starts.
78
+ */
79
+ folder?: string;
80
+ /** With {@link folder}: a worktree and branch of the folder's git checkout instead of in place. */
81
+ separateCopy?: boolean;
82
+ }
83
+ /**
84
+ * Why `input` did not get a reply through, when the server should hear it (it
85
+ * hands the reply back on the thread):
86
+ * - `remote_terminal_disabled`: nothing typed; the owner's switch was off by
87
+ * the time the queued job ran.
88
+ * - `reply_interrupted`: a reply stopped partway (some of it may sit in the
89
+ * input box, unsent), or one refused because an earlier one did.
90
+ *
91
+ * A decision is never typed: the prompt's own hook answers it (`decision.ts`).
92
+ */
93
+ export interface TypingOutcome {
94
+ /**
95
+ * `shell_window`: the thread's window is a shell (a shell thread, or an agent that exited into
96
+ * one), which composer text is never typed into: it would run as a command.
97
+ */
98
+ refused?: 'remote_terminal_disabled' | 'reply_interrupted' | 'terminal_gone' | 'trust_prompt' | 'shell_window';
99
+ /**
100
+ * Nothing reached the pane, and the failure was local and may pass (a tmux
101
+ * command failed): the delivery is worth sending again, not handing back.
102
+ */
103
+ retry?: boolean;
104
+ }
105
+ export interface InputRequest {
106
+ codeThreadId: string;
107
+ terminalId: string;
108
+ text: string;
109
+ }
110
+ export interface ApprovalRequest {
111
+ codeThreadId: string;
112
+ terminalId: string;
113
+ approvalId: string;
114
+ decision: ApprovalDecision;
115
+ }
116
+ export interface TerminalRecord {
117
+ terminalId: string;
118
+ codeThreadId: string;
119
+ agent: string;
120
+ cwd: string;
121
+ windows: TerminalWindow[];
122
+ /** Started by a build with the ready mark (`WindowMeta.readyMark`): never adopted past trust. */
123
+ readyMark?: boolean;
124
+ }
125
+ export type AttachFailure = 'unknown_terminal' | 'pty_unavailable' | 'busy' | 'invalid_window';
126
+ export interface AttachRequest {
127
+ terminalId: string;
128
+ window: TerminalWindow;
129
+ cols?: number;
130
+ rows?: number;
131
+ /** `watch` streams output and drops every keystroke. */
132
+ mode?: 'type' | 'watch';
133
+ }
134
+ /** One viewer's live connection to one window. */
135
+ export interface TerminalStream {
136
+ onData(listener: (chunk: Buffer) => void): void;
137
+ onExit(listener: () => void): void;
138
+ write(data: Uint8Array): void;
139
+ resize(cols: number, rows: number): void;
140
+ close(): void;
141
+ /** The viewer said what it is (`hello`): from now on it is told who sizes the window (`sizing.ts`). */
142
+ hello?(client: ViewerClient): void;
143
+ /** Size the window to this viewer (`claim`). */
144
+ claim?(): void;
145
+ /** Who sizes the window, for a viewer that said hello. */
146
+ onSizer?(listener: (frame: SizerFrame) => void): void;
147
+ }
148
+ export type AttachResult = {
149
+ ok: true;
150
+ stream: TerminalStream;
151
+ } | {
152
+ ok: false;
153
+ reason: AttachFailure;
154
+ };
155
+ /**
156
+ * `reported`: the server answered the start / failed event definitively
157
+ * (2xx, or a 4xx it will keep giving). False means no answer — no token yet,
158
+ * offline, a 5xx — and the harness asks for the delivery again instead of
159
+ * acknowledging an outcome the server never heard.
160
+ */
161
+ export type StartResult = {
162
+ ok: true;
163
+ adopted: boolean;
164
+ reported: boolean;
165
+ } | {
166
+ ok: false;
167
+ reason: string;
168
+ reported: boolean;
169
+ };
170
+ /**
171
+ * Whether an event post's status settles it (see {@link StartResult}): the
172
+ * server applied it (2xx) or will refuse it the same way every time (404, 409,
173
+ * 422…). A 401 / 403 is the token — expired, or minted before a reconnect —
174
+ * and the next control-socket connect brings a fresh one, so like no answer
175
+ * at all (0, 408, 429, 5xx) it is retried.
176
+ */
177
+ export declare function isDefinitive(status: number): boolean;
178
+ /** The shell command line a thread's agent window runs. */
179
+ export declare function agentCommandLine(agent: CodeAgent, options: {
180
+ claudeSettings: string;
181
+ claudeInvocationSettings?: string;
182
+ hookShim?: string;
183
+ codexNotify: string;
184
+ hasGoal: boolean;
185
+ }): string;
186
+ export interface CodeTerminalsOptions {
187
+ /** `$PROHOST_HOME` for this host; exported to every window. */
188
+ home: string;
189
+ socket: string;
190
+ confPath: string;
191
+ tmux: TmuxRunner;
192
+ spawnPty: () => PtySpawn | undefined;
193
+ checkouts: {
194
+ prepare(repo: string, terminalId: string, branch?: string): Promise<CheckoutResult>;
195
+ /** A folder start (`CodeThreadCheckouts.prepareFolder`); without it, folder starts are refused. */
196
+ prepareFolder?(folder: string, terminalId: string, options: {
197
+ separateCopy?: boolean;
198
+ branch?: string;
199
+ }): Promise<CheckoutResult>;
200
+ /** The folder start's window is open (`CodeThreadCheckouts.folderStarted`). */
201
+ folderStarted?(folder: string): void;
202
+ };
203
+ events: CodeThreadEventSink;
204
+ hookFiles: {
205
+ claudeSettings: string;
206
+ claudeInvocationSettings?: string;
207
+ hookShim?: string;
208
+ codexNotify: string;
209
+ };
210
+ /**
211
+ * The login this agent runs on (`CLAUDE_CONFIG_DIR` / `CODEX_HOME` of the
212
+ * account picked for it), read at each start — the same environment its
213
+ * ordinary runs get, so a thread spends the subscription the agent reports.
214
+ */
215
+ accountEnv?: (agent: CodeAgent) => AccountEnvResult;
216
+ /**
217
+ * Whether this machine may act on ProhostAI's behalf right now (the relay
218
+ * has not refused the pairing; the host is running). Read immediately
219
+ * before every side effect — opening a window, typing, pressing keys — so
220
+ * a delivery that arrives after a revocation does nothing.
221
+ */
222
+ authorized?: () => boolean;
223
+ /**
224
+ * Whether typing into a terminal on ProhostAI's behalf is allowed right now
225
+ * (the owner's remote-terminal switch, read from disk). Checked inside the
226
+ * queued job and before every step of it — a reply or decision that waited
227
+ * behind another never types after `remote off`.
228
+ */
229
+ remoteControlAllowed?: () => boolean;
230
+ log?: (line: string) => void;
231
+ now?: () => number;
232
+ tmuxBinary?: string;
233
+ sleep?: (ms: number) => Promise<void>;
234
+ /** Seams for tests: Claude Code's workspace trust (`claude_trust.ts`). */
235
+ trustWorkspace?: (workspace: string, env: NodeJS.ProcessEnv) => boolean | Promise<boolean>;
236
+ trusts?: (workspace: string, env: NodeJS.ProcessEnv) => boolean;
237
+ /** How many times (a second apart) to look for Claude Code's trust dialog after launch. */
238
+ trustWatchAttempts?: number;
239
+ /** Seam for tests: whether a terminal's Claude Code is past its trust dialog (`ready.ts`). */
240
+ ready?: (terminalId: string) => boolean | undefined;
241
+ /** Seams for tests: an older window's Claude session (`ready.ts`) and its pane's process. */
242
+ claudeSessionSince?: (cwd: string, configDir: string, sinceMs: number) => boolean;
243
+ paneStarts?: (pids: number[]) => Promise<Map<number, number>>;
244
+ /** Seam for tests: the daemon's own environment, under the launch env (`claudeLaunchEnv`). */
245
+ processEnv?: NodeJS.ProcessEnv;
246
+ /**
247
+ * Whether the agent's binary resolves here (`agents.ts`): a start for one that doesn't is
248
+ * refused with why, rather than opening a window that says `command not found`. Unset: not checked.
249
+ */
250
+ agentInstalled?: (agent: InstalledAgent) => boolean;
251
+ /** Seam for tests: the timers who-sizes-the-window settles with (`sizing.ts`). */
252
+ setTimer?: (fn: () => void, ms: number) => () => void;
253
+ }
254
+ /** What the thread says while Claude Code waits on its trust dialog. */
255
+ export declare const TRUST_PROMPT_NOTE = "Claude Code is asking whether to trust this folder before it starts. Answer in the terminal.";
256
+ export declare class CodeTerminals {
257
+ private readonly options;
258
+ private readonly records;
259
+ private readonly starting;
260
+ /**
261
+ * Older windows are adopted past trust by one pass that succeeds once per daemon start
262
+ * (`adoptReadiness`); a failed pass is forgotten and tried again by the next `adopt()`, or by a
263
+ * reply it would have let through. The pass in flight or done, so every `adopt()` (a concurrent
264
+ * one too) returns only once it has finished.
265
+ */
266
+ private readinessAdoption;
267
+ private readonly lastInterrupt;
268
+ /** Per terminal: the tail of its typing queue (see {@link serialize}). */
269
+ private readonly typing;
270
+ private openPtys;
271
+ /** Who sizes each window, and what its viewers are told (`sizing.ts`). */
272
+ readonly sizing: TerminalSizing;
273
+ private readonly log;
274
+ private readonly now;
275
+ private readonly sleep;
276
+ constructor(options: CodeTerminalsOptions);
277
+ private isReady;
278
+ private isAuthorized;
279
+ private canType;
280
+ private tmux;
281
+ /** Threads this host knows about right now. */
282
+ list(): TerminalRecord[];
283
+ /** Ptys open right now. Exposed for assertions. */
284
+ openPtyCount(): number;
285
+ /** Re-read the windows tmux holds. Returns how many threads are known after. */
286
+ /**
287
+ * Run `work` after every earlier typing job for this terminal. A paste and
288
+ * its Enter (or an approval's keys) are one unit: two deliveries for one
289
+ * terminal never interleave into "first, second, Enter, Enter".
290
+ */
291
+ private serialize;
292
+ /**
293
+ * Mark a thread's worktree lane as in use (`.last-used`, the marker every
294
+ * {@link WorktreeManager} prunes by), so a long-lived terminal's checkout is
295
+ * never removed as idle while its window lives — whichever lane's prepare
296
+ * happens to run the prune.
297
+ */
298
+ private touchLane;
299
+ /** Refresh the lanes of threads whose windows tmux still has (adopt reconciles first). */
300
+ private touchLiveLanes;
301
+ /**
302
+ * The hourly keeper: re-read tmux, then mark only the threads it still holds
303
+ * as in use. A window or server that went away is dropped by the re-read, so
304
+ * its lane is left to age out and be pruned as usual.
305
+ */
306
+ refreshLiveLanes(): Promise<void>;
307
+ /**
308
+ * Apply the config just written to a server that is already running: tmux
309
+ * reads `-f` only when a server starts, and the persistent session keeps an
310
+ * upgraded host's old server (and its old settings) alive. With no server
311
+ * there is nothing to apply, and the next one starts with the file.
312
+ */
313
+ reloadConfig(): Promise<boolean>;
314
+ adopt(): Promise<number>;
315
+ /**
316
+ * The environment a Claude Code thread starts with: what `startOnce` hands its window, and so
317
+ * the only source of where that window's Claude Code keeps its sessions (`adoptReadiness`).
318
+ */
319
+ private claudeLaunchEnv;
320
+ /**
321
+ * Older windows, all at once: one `ps` for every candidate's start (bounded, never one call per
322
+ * window), then the evidence check for each (`adoptReadiness`).
323
+ */
324
+ private adoptReadinessAll;
325
+ /**
326
+ * A Claude window from a build before the ready mark (an upgrade): its Claude Code may be long
327
+ * past trust, but no hook runs to say so until a reply types, which the missing mark refuses.
328
+ * Only such a window (no `readyMark` in its meta: this build's windows get the mark from their
329
+ * first hook, never from here), by a pass that succeeds once per daemon start (and is tried
330
+ * again after a failure; each try judges afresh), and only on evidence tied to it: a
331
+ * transcript in that window's Claude config dir (`claudeLaunchEnv`), naming its exact folder,
332
+ * written since the window's process started (`claudeSessionSince`). A window still at its
333
+ * trust dialog has none and stays refused. Never judged from the screen.
334
+ */
335
+ private adoptReadiness;
336
+ private ensureSession;
337
+ start(request: StartRequest): Promise<StartResult>;
338
+ private startOnce;
339
+ /**
340
+ * A worktree this host made for the thread opens without Claude Code's trust dialog: no hook
341
+ * fires for it, so the thread would read `Working` while it waits for someone at the Mac.
342
+ * Only a worktree under this home's own `worktrees/`, never a checkout it was handed.
343
+ */
344
+ private trustOwnWorktree;
345
+ /**
346
+ * Watch a new Claude Code window for its trust dialog, only to tell the thread it waits on it
347
+ * (needs-you, not Working). It never decides whether a reply types: the ready mark does.
348
+ */
349
+ private watchTrustPrompt;
350
+ private known;
351
+ /**
352
+ * Whether the thread's agent window runs an agent that composer text and
353
+ * approval keys may reach. A Claude Code or Codex that exited left a shell
354
+ * behind (see {@link agentCommandLine}); a `shell` thread is a shell from
355
+ * the start. Neither ever takes relayed text.
356
+ */
357
+ private agentRunning;
358
+ /** Send composer text to the thread's agent. False when the thread isn't here or its agent has exited. */
359
+ input(request: InputRequest, outcome?: TypingOutcome): Promise<boolean>;
360
+ private inputNow;
361
+ /**
362
+ * A reply stopped after a step that can reach the agent's input box, before
363
+ * its Enter went through. Nothing is pressed to take it back: no keystroke
364
+ * from out here can tell the reply apart from what was in the box already —
365
+ * a draft typed before, or one the agent put back — so clearing it would
366
+ * erase that too. The window stays staged until the owner types in a
367
+ * terminal attached to it, where they see the input box. The delivery is reported unhandled (acked 204), never
368
+ * retried blind.
369
+ */
370
+ private leaveStaged;
371
+ /**
372
+ * Mark a window just created with {@link holdCommand} as held, so its release signals it. When
373
+ * the mark can't be set, nothing would ever signal it: its shell is released now, unheld.
374
+ */
375
+ private markHeld;
376
+ /** The thread's shell window, made at `size` (its first viewer's) the first time someone asks. */
377
+ private ensureShellWindow;
378
+ private noteInterrupt;
379
+ attach(request: AttachRequest): Promise<AttachResult>;
380
+ }
381
+ /**
382
+ * When each of `pids` started, from one `ps -o pid=,etime=` (bounded, asynchronous: the daemon
383
+ * keeps serving meanwhile). From elapsed time, which no timezone can shift, a second early since
384
+ * `etime` drops the part of the current second already gone. A pid `ps` doesn't list is left out
385
+ * (`ps` exits 1 with nothing on stderr when none of them run). Rejects on any other outcome (it
386
+ * timed out, was killed, couldn't start, exited with another status, or wrote an error such as a
387
+ * bad option's usage, which also exits 1), so the caller tries again rather than taking "none
388
+ * started".
389
+ */
390
+ export declare function paneStarts(pids: number[], now?: number, run?: typeof execFile): Promise<Map<number, number>>;