@prohost/cli 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +265 -1
  3. package/dist/agent/accounts.d.ts +11 -0
  4. package/dist/agent/accounts.js +12 -1
  5. package/dist/agent/api.d.ts +6 -0
  6. package/dist/agent/api.js +5 -0
  7. package/dist/agent/claude.d.ts +6 -0
  8. package/dist/agent/claude.js +2 -2
  9. package/dist/agent/daemon.d.ts +33 -0
  10. package/dist/agent/daemon.js +56 -0
  11. package/dist/agent/prompt.d.ts +12 -0
  12. package/dist/agent/prompt.js +5 -0
  13. package/dist/agent/run.d.ts +110 -1
  14. package/dist/agent/run.js +198 -22
  15. package/dist/codehost/history.d.ts +80 -0
  16. package/dist/codehost/history.js +98 -0
  17. package/dist/codehost/hooks.d.ts +10 -2
  18. package/dist/codehost/hooks.js +15 -4
  19. package/dist/codehost/host.d.ts +32 -0
  20. package/dist/codehost/host.js +26 -1
  21. package/dist/codehost/host_stats.d.ts +49 -2
  22. package/dist/codehost/host_stats.js +153 -2
  23. package/dist/codehost/relay.d.ts +16 -1
  24. package/dist/codehost/relay.js +69 -3
  25. package/dist/codehost/sizing.d.ts +22 -4
  26. package/dist/codehost/sizing.js +46 -6
  27. package/dist/codehost/terminals.d.ts +33 -2
  28. package/dist/codehost/terminals.js +92 -30
  29. package/dist/codehost/tmux.d.ts +6 -1
  30. package/dist/codehost/tmux.js +3 -0
  31. package/dist/host/api.d.ts +82 -0
  32. package/dist/host/api.js +122 -0
  33. package/dist/host/capacity.d.ts +41 -0
  34. package/dist/host/capacity.js +48 -0
  35. package/dist/host/command.d.ts +59 -0
  36. package/dist/host/command.js +584 -0
  37. package/dist/host/computer.d.ts +120 -0
  38. package/dist/host/computer.js +291 -0
  39. package/dist/host/events.d.ts +24 -0
  40. package/dist/host/events.js +47 -0
  41. package/dist/host/install.d.ts +152 -0
  42. package/dist/host/install.js +755 -0
  43. package/dist/host/ledger.d.ts +52 -0
  44. package/dist/host/ledger.js +131 -0
  45. package/dist/host/legacy.d.ts +38 -0
  46. package/dist/host/legacy.js +115 -0
  47. package/dist/host/logins.d.ts +109 -0
  48. package/dist/host/logins.js +190 -0
  49. package/dist/host/migrate.d.ts +165 -0
  50. package/dist/host/migrate.js +364 -0
  51. package/dist/host/pair.d.ts +62 -0
  52. package/dist/host/pair.js +219 -0
  53. package/dist/host/peak_memory.d.ts +44 -0
  54. package/dist/host/peak_memory.js +139 -0
  55. package/dist/host/scheduler.d.ts +86 -0
  56. package/dist/host/scheduler.js +186 -0
  57. package/dist/host/service.d.ts +114 -0
  58. package/dist/host/service.js +1027 -0
  59. package/dist/host/session_move.d.ts +48 -0
  60. package/dist/host/session_move.js +106 -0
  61. package/dist/host/skills.d.ts +29 -0
  62. package/dist/host/skills.js +135 -0
  63. package/dist/host/status.d.ts +91 -0
  64. package/dist/host/status.js +130 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +47 -3
  67. package/dist/version.d.ts +2 -2
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
@@ -22,6 +22,7 @@ import { randomBytes } from 'node:crypto';
22
22
  import WebSocket from 'ws';
23
23
  import { endpointProblem } from '../agent/base-url.js';
24
24
  import { USER_AGENT } from '../version.js';
25
+ import { historyFrame } from './history.js';
25
26
  import { isValidId, parseControl, TERMINAL_WINDOWS } from './tmux.js';
26
27
  export const CONTROL_PATH = '/v1/code-hosts/control';
27
28
  export const STREAM_PATH = '/v1/code-hosts/stream';
@@ -77,9 +78,52 @@ export function parseDial(frame) {
77
78
  mode: frame.mode === 'watch' ? 'watch' : 'type',
78
79
  };
79
80
  }
80
- /** Pipe one terminal stream over one open socket until either side ends. */
81
+ /**
82
+ * Pipe one terminal stream over one open socket until either side ends.
83
+ *
84
+ * Text controls in: `resize`, `input`, `hello`, `claim`, `history` (answered with the pane's
85
+ * scroll-back, only once the viewer said hello: an older page never sees one), `ping`.
86
+ */
81
87
  export function pipeStream(ws, stream, onClosed) {
82
88
  let done = false;
89
+ let greeted = false;
90
+ /** One capture at a time per viewer: a burst of asks is answered by the one after the last. */
91
+ let reading;
92
+ let queued;
93
+ const sendText = (frame) => {
94
+ if (done || ws.readyState !== WebSocket.OPEN)
95
+ return;
96
+ if (ws.bufferedAmount > MAX_STREAM_BUFFERED_BYTES) {
97
+ finish('slow_consumer');
98
+ return;
99
+ }
100
+ try {
101
+ ws.send(JSON.stringify(frame));
102
+ }
103
+ catch {
104
+ /* closing anyway */
105
+ }
106
+ };
107
+ const answerHistory = (request) => {
108
+ if (reading) {
109
+ // An ask still waiting is superseded: the viewer only uses the latest answer.
110
+ if (queued)
111
+ sendText({ type: 'history', id: queued.id, error: 'unavailable' });
112
+ queued = request;
113
+ return;
114
+ }
115
+ const read = stream.history
116
+ ? stream.history(request.lines).catch(() => ({ ok: false, error: 'capture_failed' }))
117
+ : Promise.resolve({ ok: false, error: 'unavailable' });
118
+ reading = read.then((result) => {
119
+ sendText(historyFrame(request.id, result));
120
+ reading = undefined;
121
+ const next = queued;
122
+ queued = undefined;
123
+ if (next && !done)
124
+ answerHistory(next);
125
+ });
126
+ };
83
127
  const finish = (reason) => {
84
128
  if (done)
85
129
  return;
@@ -128,10 +172,16 @@ export function pipeStream(ws, stream, onClosed) {
128
172
  stream.resize(control.cols, control.rows);
129
173
  else if (control.type === 'input')
130
174
  stream.write(Buffer.from(control.data, 'utf8'));
131
- else if (control.type === 'hello')
175
+ else if (control.type === 'hello') {
176
+ greeted = true;
132
177
  stream.hello?.(control.client);
178
+ }
133
179
  else if (control.type === 'claim')
134
180
  stream.claim?.();
181
+ else if (control.type === 'history') {
182
+ if (greeted)
183
+ answerHistory(control);
184
+ }
135
185
  else if (control.type === 'ping')
136
186
  ws.send(JSON.stringify({ type: 'pong' }));
137
187
  });
@@ -206,6 +256,8 @@ export class CodeHostRelay {
206
256
  /** Bumped by {@link dropStreams}: a dial begun before a drop never opens. */
207
257
  generation = 0;
208
258
  refused;
259
+ /** Authenticating with {@link CodeHostRelayOptions.fallbackApiKey} after `apiKey` was refused. */
260
+ usingFallback = false;
209
261
  log;
210
262
  Ctor;
211
263
  setTimer;
@@ -366,7 +418,7 @@ export class CodeHostRelay {
366
418
  try {
367
419
  ws.send(JSON.stringify({
368
420
  type: 'auth',
369
- api_key: this.options.apiKey,
421
+ api_key: this.usingFallback ? this.options.fallbackApiKey : this.options.apiKey,
370
422
  machine_id: this.options.machineId,
371
423
  ...(this.options.instanceId ? { instance_id: this.options.instanceId } : {}),
372
424
  machine: this.options.machine,
@@ -431,6 +483,15 @@ export class CodeHostRelay {
431
483
  }
432
484
  case 'auth_error': {
433
485
  const reason = String(frame.reason ?? 'unknown');
486
+ // Only "a valid key, but not a pairing this server knows" — a server from
487
+ // before computer keys. A revoked or invalid key never falls back.
488
+ if (!this.usingFallback && this.options.fallbackApiKey && reason === 'not_paired') {
489
+ // This server doesn't take the first key here: try the second at once.
490
+ this.usingFallback = true;
491
+ this.log(` … code-host relay refused the computer key (${reason}); using the adopted agent key`);
492
+ ws.close();
493
+ return;
494
+ }
434
495
  if (this.refused !== reason)
435
496
  this.log(` ✗ code-host relay refused this machine: ${reason}`);
436
497
  this.refused = reason;
@@ -483,6 +544,11 @@ export class CodeHostRelay {
483
544
  this.dialError(dial.streamId, 'remote_terminal_disabled');
484
545
  return;
485
546
  }
547
+ if (this.options.dialsPaused?.()) {
548
+ // The relay's own word for "not now" (it knows no other).
549
+ this.dialError(dial.streamId, 'remote_terminal_disabled');
550
+ return;
551
+ }
486
552
  const url = relayUrl(this.options.baseUrl, STREAM_PATH);
487
553
  if (!url)
488
554
  return;
@@ -8,9 +8,13 @@
8
8
  * option, which tmux 3.4 can't start with), from its creation on, and this module picks one viewer
9
9
  * per window to size it to (the approved policy, CONTRACTS C3):
10
10
  *
11
- * - a viewer that attaches, types, sends `{"type":"claim"}` (a phone on ready / foreground / touch,
12
- * the desktop's "Use this window's size"), or reports focus (`ESC [ I`) takes it — never a
13
- * `watch` viewer, which can't type (it sizes a window only nobody else shows);
11
+ * - a viewer that attaches, sends `{"type":"claim"}` (a phone on ready / foreground / touch / before
12
+ * it types, the desktop's "Use this window's size"), or reports focus (`ESC [ I`: its window came
13
+ * to the front) takes it — never a `watch` viewer, which can't type (it sizes a window only
14
+ * nobody else shows);
15
+ * - typing alone takes it only from a sizing viewer idle {@link IDLE_TAKEOVER_MS} (CONTRACTS-R2
16
+ * R2): two viewers typing in turn no longer resize the window, and redraw Claude Code's whole
17
+ * transcript, on every keystroke;
14
18
  * - when the sizing viewer leaves, the one active most recently among those left takes over;
15
19
  * - a handover or resize reaches tmux once it has been quiet {@link HANDOVER_DEBOUNCE_MS}, and only
16
20
  * when the size changes: Claude Code reprints its whole transcript on every width change.
@@ -60,6 +64,8 @@ export declare const HANDOVER_DEBOUNCE_MS = 300;
60
64
  export declare const MODE_CHECK_MS = 150;
61
65
  /** A held shell nobody attached to starts anyway after this long. */
62
66
  export declare const HOLD_FALLBACK_MS = 60000;
67
+ /** Typing takes the window's size from a sizing viewer that hasn't typed or claimed for this long. */
68
+ export declare const IDLE_TAKEOVER_MS = 30000;
63
69
  export declare function holdChannel(name: string): string;
64
70
  /**
65
71
  * `command`, run once the window's hold is released. Waits on the tmux server the pane runs in
@@ -93,14 +99,21 @@ export interface TerminalSizingOptions {
93
99
  export interface SizingViewer {
94
100
  hello(client: ViewerClient): void;
95
101
  resize(cols: number, rows: number): void;
96
- /** Size the window to this viewer now: it typed, focused, or asked. A `watch` viewer's is ignored. */
102
+ /** Size the window to this viewer now: it attached, focused, or asked. A `watch` viewer's is ignored. */
97
103
  claim(): void;
104
+ /**
105
+ * This viewer typed: it sizes the window only if nobody else does, or the one who does has been
106
+ * idle {@link IDLE_TAKEOVER_MS}. A `watch` viewer's is ignored.
107
+ */
108
+ typed(): void;
98
109
  /**
99
110
  * This viewer's input may have changed the pane's copy mode. `moved`: it can move copy mode (a
100
111
  * wheel step, a drag, the prefix key, a keystroke), so this viewer is now who scrolled; else
101
112
  * (an emulator report) the mode is only read again.
102
113
  */
103
114
  scrolled(moved?: boolean): void;
115
+ /** This viewer's input is what scrolled the pane back (it may move through its own copy mode). */
116
+ isScroller(): boolean;
104
117
  leave(): void;
105
118
  onSizer(listener: (frame: SizerFrame) => void): void;
106
119
  }
@@ -161,3 +174,8 @@ export declare class TerminalSizing {
161
174
  private settle;
162
175
  private release;
163
176
  }
177
+ /**
178
+ * What a chunk of a viewer's input says about who sizes the window: a focus-in report (its window
179
+ * came to the front) claims it; anything a person typed only takes it from an idle sizing viewer.
180
+ */
181
+ export declare function noteInputTakeover(viewer: Pick<SizingViewer, 'claim' | 'typed'>, data: Uint8Array): void;
@@ -8,9 +8,13 @@
8
8
  * option, which tmux 3.4 can't start with), from its creation on, and this module picks one viewer
9
9
  * per window to size it to (the approved policy, CONTRACTS C3):
10
10
  *
11
- * - a viewer that attaches, types, sends `{"type":"claim"}` (a phone on ready / foreground / touch,
12
- * the desktop's "Use this window's size"), or reports focus (`ESC [ I`) takes it — never a
13
- * `watch` viewer, which can't type (it sizes a window only nobody else shows);
11
+ * - a viewer that attaches, sends `{"type":"claim"}` (a phone on ready / foreground / touch / before
12
+ * it types, the desktop's "Use this window's size"), or reports focus (`ESC [ I`: its window came
13
+ * to the front) takes it — never a `watch` viewer, which can't type (it sizes a window only
14
+ * nobody else shows);
15
+ * - typing alone takes it only from a sizing viewer idle {@link IDLE_TAKEOVER_MS} (CONTRACTS-R2
16
+ * R2): two viewers typing in turn no longer resize the window, and redraw Claude Code's whole
17
+ * transcript, on every keystroke;
14
18
  * - when the sizing viewer leaves, the one active most recently among those left takes over;
15
19
  * - a handover or resize reaches tmux once it has been quiet {@link HANDOVER_DEBOUNCE_MS}, and only
16
20
  * when the size changes: Claude Code reprints its whole transcript on every width change.
@@ -28,7 +32,7 @@
28
32
  * page's fit showed one prompt per resize. A held window runs `tmux wait-for <channel>` before its
29
33
  * shell (see {@link holdCommand}); the first viewer's resizes going quiet signals it.
30
34
  */
31
- import { leaveCopyModeArgs, paneInModeArgs, windowTarget } from './tmux.js';
35
+ import { hasFocusIn, isHumanInput, leaveCopyModeArgs, paneInModeArgs, windowTarget } from './tmux.js';
32
36
  export const VIEWER_KINDS = new Set(['phone', 'tablet', 'desktop']);
33
37
  const MAX_LABEL_CHARS = 40;
34
38
  /** A `hello` frame's `client`, made safe to repeat to another viewer. */
@@ -53,6 +57,8 @@ export const HANDOVER_DEBOUNCE_MS = 300;
53
57
  export const MODE_CHECK_MS = 150;
54
58
  /** A held shell nobody attached to starts anyway after this long. */
55
59
  export const HOLD_FALLBACK_MS = 60_000;
60
+ /** Typing takes the window's size from a sizing viewer that hasn't typed or claimed for this long. */
61
+ export const IDLE_TAKEOVER_MS = 30_000;
56
62
  export function holdChannel(name) {
57
63
  return `prohost-hold-${name}`;
58
64
  }
@@ -171,7 +177,14 @@ export class TerminalSizing {
171
177
  }
172
178
  join(name, size) {
173
179
  const state = this.state(name);
174
- const viewer = { id: this.nextId++, cols: size.cols, rows: size.rows, watch: size.watch === true, activeAt: ++this.seq };
180
+ const viewer = {
181
+ id: this.nextId++,
182
+ cols: size.cols,
183
+ rows: size.rows,
184
+ watch: size.watch === true,
185
+ activeAt: ++this.seq,
186
+ inputAt: this.now(),
187
+ };
175
188
  state.viewers.set(viewer.id, viewer);
176
189
  // Attaching claims the size, as tmux did (`window-size latest`): a watch viewer only a window
177
190
  // nobody else shows.
@@ -208,7 +221,23 @@ export class TerminalSizing {
208
221
  this.settle(name, state);
209
222
  },
210
223
  claim: () => {
211
- if (left || state.owner === viewer.id || viewer.watch)
224
+ if (left || viewer.watch)
225
+ return;
226
+ viewer.inputAt = this.now();
227
+ if (state.owner === viewer.id)
228
+ return;
229
+ this.handOver(name, state, viewer);
230
+ },
231
+ typed: () => {
232
+ if (left || viewer.watch)
233
+ return;
234
+ const now = this.now();
235
+ viewer.inputAt = now;
236
+ if (state.owner === viewer.id)
237
+ return;
238
+ const owner = state.owner === undefined ? undefined : state.viewers.get(state.owner);
239
+ // The sizing viewer is at it too: its size stays until it goes quiet, or this one asks.
240
+ if (owner && now - owner.inputAt < IDLE_TAKEOVER_MS)
212
241
  return;
213
242
  this.handOver(name, state, viewer);
214
243
  },
@@ -221,6 +250,7 @@ export class TerminalSizing {
221
250
  }
222
251
  this.checkMode(name, state);
223
252
  },
253
+ isScroller: () => !left && state.scroller === viewer.id,
224
254
  leave: () => {
225
255
  if (left)
226
256
  return;
@@ -392,3 +422,13 @@ export class TerminalSizing {
392
422
  await this.options.tmux(releaseHoldArgs(name));
393
423
  }
394
424
  }
425
+ /**
426
+ * What a chunk of a viewer's input says about who sizes the window: a focus-in report (its window
427
+ * came to the front) claims it; anything a person typed only takes it from an idle sizing viewer.
428
+ */
429
+ export function noteInputTakeover(viewer, data) {
430
+ if (hasFocusIn(data))
431
+ viewer.claim();
432
+ else if (isHumanInput(data))
433
+ viewer.typed();
434
+ }
@@ -23,6 +23,7 @@ import { execFile } from 'node:child_process';
23
23
  import type { InstalledAgent } from './agents.js';
24
24
  import type { CheckoutResult } from './checkouts.js';
25
25
  import type { CodeThreadEventSink } from './events.js';
26
+ import type { HistoryResult } from './history.js';
26
27
  import type { PtySpawn } from './pty.js';
27
28
  import { TerminalSizing } from './sizing.js';
28
29
  import type { SizerFrame, ViewerClient } from './sizing.js';
@@ -79,6 +80,11 @@ export interface StartRequest {
79
80
  folder?: string;
80
81
  /** With {@link folder}: a worktree and branch of the folder's git checkout instead of in place. */
81
82
  separateCopy?: boolean;
83
+ /**
84
+ * Claude Code / Codex start without their permission prompts (see {@link agentCommandLine}):
85
+ * nothing asks, so no `permission_request` reaches the inbox. Absent (an older server): they ask.
86
+ */
87
+ skipPermissions?: boolean;
82
88
  }
83
89
  /**
84
90
  * Why `input` did not get a reply through, when the server should hear it (it
@@ -144,6 +150,8 @@ export interface TerminalStream {
144
150
  claim?(): void;
145
151
  /** Who sizes the window, for a viewer that said hello. */
146
152
  onSizer?(listener: (frame: SizerFrame) => void): void;
153
+ /** The pane's last `lines` of history and its screen, as text with colours (`history.ts`). */
154
+ history?(lines: number): Promise<HistoryResult>;
147
155
  }
148
156
  export type AttachResult = {
149
157
  ok: true;
@@ -175,13 +183,28 @@ export type StartResult = {
175
183
  * at all (0, 408, 429, 5xx) it is retried.
176
184
  */
177
185
  export declare function isDefinitive(status: number): boolean;
186
+ /** Claude Code's flag for a thread started with `skipPermissions`: no permission prompt, ever. */
187
+ export declare const CLAUDE_SKIP_PERMISSIONS_FLAG = "--dangerously-skip-permissions";
188
+ /**
189
+ * Codex's (`codex --help`, 0.146): no approval prompt and no sandbox, as Claude Code's flag. Its
190
+ * `-a never` alone would keep the sandbox, where a command needing the network fails instead of asking.
191
+ */
192
+ export declare const CODEX_SKIP_PERMISSIONS_FLAG = "--dangerously-bypass-approvals-and-sandbox";
178
193
  /** The shell command line a thread's agent window runs. */
179
194
  export declare function agentCommandLine(agent: CodeAgent, options: {
180
195
  claudeSettings: string;
181
196
  claudeInvocationSettings?: string;
197
+ /**
198
+ * With {@link skipPermissions}: the same `--settings` documents that also record the owner's
199
+ * acceptance of Claude Code's bypass-mode warning (`hooks.ts`); without them that warning shows.
200
+ */
201
+ claudeSkipSettings?: string;
202
+ claudeInvocationSkipSettings?: string;
182
203
  hookShim?: string;
183
204
  codexNotify: string;
184
205
  hasGoal: boolean;
206
+ /** Start the agent without its permission prompts (a shell has none). */
207
+ skipPermissions?: boolean;
185
208
  }): string;
186
209
  export interface CodeTerminalsOptions {
187
210
  /** `$PROHOST_HOME` for this host; exported to every window. */
@@ -204,6 +227,9 @@ export interface CodeTerminalsOptions {
204
227
  hookFiles: {
205
228
  claudeSettings: string;
206
229
  claudeInvocationSettings?: string;
230
+ /** The skip-prompts twins of the two documents above (see {@link agentCommandLine}). */
231
+ claudeSkipSettings?: string;
232
+ claudeInvocationSkipSettings?: string;
207
233
  hookShim?: string;
208
234
  codexNotify: string;
209
235
  };
@@ -253,6 +279,8 @@ export interface CodeTerminalsOptions {
253
279
  }
254
280
  /** What the thread says while Claude Code waits on its trust dialog. */
255
281
  export declare const TRUST_PROMPT_NOTE = "Claude Code is asking whether to trust this folder before it starts. Answer in the terminal.";
282
+ /** What the thread says while Claude Code waits on that warning. */
283
+ export declare const BYPASS_PROMPT_NOTE = "Claude Code asks once to confirm it may run commands without asking. Answer in the terminal.";
256
284
  export declare class CodeTerminals {
257
285
  private readonly options;
258
286
  private readonly records;
@@ -343,8 +371,11 @@ export declare class CodeTerminals {
343
371
  */
344
372
  private trustOwnWorktree;
345
373
  /**
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.
374
+ * Watch a new Claude Code window for its first-run screens, only to tell the thread it waits on
375
+ * one (needs-you, not Working): the folder's trust dialog, and, for a thread that skips permission
376
+ * prompts, Claude Code's one-time bypass warning (it comes up after the trust dialog is answered,
377
+ * so it is looked for longer once that was said). Each is said once. It never decides whether a
378
+ * reply types: the ready mark does.
348
379
  */
349
380
  private watchTrustPrompt;
350
381
  private known;
@@ -29,11 +29,13 @@ import { markTurn } from './activity.js';
29
29
  import { missingAgentReason } from './agents.js';
30
30
  import { claudeConfigDir, claudeTrusts, trustClaudeWorkspace } from './claude_trust.js';
31
31
  import { event } from './events.js';
32
+ import { readHistory } from './history.js';
32
33
  import { CODE_THREAD_ID_ENV, TERMINAL_ID_ENV } from './hooks.js';
33
34
  import { MAX_OPEN_PTYS } from './pty.js';
34
35
  import { claudeSessionSince, isReady, markReady } from './ready.js';
35
36
  import { defaultSizeArgs, HOLD_OPTION, holdChannel, holdCommand, resizeWindowArgs, TerminalSizing } from './sizing.js';
36
- import { attachArgs, baseArgs, clampDim, DEFAULT_COLS, DEFAULT_ROWS, hasFocusIn, hasSessionArgs, isCopyModeExitKey, isHumanInput, isTypedText, isInterruptInput, isScrollGesture, leaveCopyModeArgs, mayEnterCopyMode, paneInModeArgs, isValidId, killSessionArgs, listWindowsArgs, newSessionArgs, newWindowArgs, parseListWindows, sendTextCommands, setWindowMetaArgs, TERMINAL_WINDOWS, DAEMON_ONLY_ENV, paneEnv, viewSessionName, windowName, windowTarget, } from './tmux.js';
37
+ import { noteInputTakeover } from './sizing.js';
38
+ import { attachArgs, baseArgs, clampDim, DEFAULT_COLS, DEFAULT_ROWS, hasSessionArgs, isCopyModeExitKey, isHumanInput, isTypedText, isInterruptInput, isScrollGesture, leaveCopyModeArgs, mayEnterCopyMode, paneInModeArgs, isValidId, killSessionArgs, listWindowsArgs, newSessionArgs, newWindowArgs, parseListWindows, sendTextCommands, setWindowMetaArgs, TERMINAL_WINDOWS, DAEMON_ONLY_ENV, paneEnv, viewSessionName, windowName, windowTarget, } from './tmux.js';
37
39
  export const CODE_AGENTS = ['claude_code', 'codex', 'shell'];
38
40
  export const GOAL_ENV = 'PROHOST_CODE_THREAD_GOAL';
39
41
  /** Window option set when the agent exits and its window becomes a shell. */
@@ -60,7 +62,8 @@ const PASTE_SETTLE_MS = 150;
60
62
  export function execTmux(binary = 'tmux') {
61
63
  return (args, stdin, timeoutMs = 10_000) => new Promise((resolve) => {
62
64
  // The first call starts the server, whose panes inherit this env.
63
- const options = { timeout: timeoutMs, maxBuffer: 1024 * 1024, env: paneEnv(process.env) };
65
+ // Room for a viewer's scroll-back (`capture-pane`, up to 10,000 lines with their colours).
66
+ const options = { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024, env: paneEnv(process.env) };
64
67
  const child = execFile(binary, args, options, (error, stdout, stderr) => {
65
68
  resolve({
66
69
  ok: error === null,
@@ -86,12 +89,20 @@ export function isDefinitive(status) {
86
89
  return true;
87
90
  return status >= 400 && status < 500 && ![401, 403, 408, 429].includes(status);
88
91
  }
92
+ /** Claude Code's flag for a thread started with `skipPermissions`: no permission prompt, ever. */
93
+ export const CLAUDE_SKIP_PERMISSIONS_FLAG = '--dangerously-skip-permissions';
94
+ /**
95
+ * Codex's (`codex --help`, 0.146): no approval prompt and no sandbox, as Claude Code's flag. Its
96
+ * `-a never` alone would keep the sandbox, where a command needing the network fails instead of asking.
97
+ */
98
+ export const CODEX_SKIP_PERMISSIONS_FLAG = '--dangerously-bypass-approvals-and-sandbox';
89
99
  /** The shell command line a thread's agent window runs. */
90
100
  export function agentCommandLine(agent, options) {
91
101
  // `--` first: a goal is the agent's prompt, never an option, however it
92
102
  // starts (`--dangerously-skip-permissions`, or a word Codex would read as a
93
103
  // subcommand).
94
104
  const goal = options.hasGoal ? ` -- "$${GOAL_ENV}"` : '';
105
+ const skip = options.skipPermissions === true;
95
106
  const shell = 'exec "${SHELL:-/bin/zsh}" -l';
96
107
  if (agent === 'shell')
97
108
  return shell;
@@ -101,22 +112,52 @@ export function agentCommandLine(agent, options) {
101
112
  // (`input` and `approve` refuse a window carrying the mark).
102
113
  const after = `tmux set-option -w ${EXITED_OPTION} 1 2>/dev/null; ` +
103
114
  `printf '\\n[${label} exited — this window is now a shell in the thread worktree]\\n'; ${shell}`;
104
- if (agent === 'codex')
105
- return `codex -c ${shellQuote(options.codexNotify, 'darwin')}${goal}; ${after}`;
106
- if (options.claudeInvocationSettings && options.hookShim) {
115
+ if (agent === 'codex') {
116
+ const flags = skip ? ` ${CODEX_SKIP_PERMISSIONS_FLAG}` : '';
117
+ return `codex${flags} -c ${shellQuote(options.codexNotify, 'darwin')}${goal}; ${after}`;
118
+ }
119
+ const claude = `command claude${skip ? ` ${CLAUDE_SKIP_PERMISSIONS_FLAG}` : ''} --settings`;
120
+ // A window that skips prompts reads the documents that also accept Claude Code's bypass warning.
121
+ const settings = (skip && options.claudeSkipSettings) || options.claudeSettings;
122
+ const invocationSettings = (skip && options.claudeInvocationSkipSettings) || options.claudeInvocationSettings;
123
+ if (invocationSettings && options.hookShim) {
107
124
  const check = `${shellQuote(options.hookShim, 'darwin')} claude CheckInvocationProtocol`;
108
- return `if ${check}; then PROHOST_INVOCATION_PROTOCOL=1 command claude --settings ${shellQuote(options.claudeInvocationSettings, 'darwin')}${goal}; ` +
109
- `else PROHOST_INVOCATION_PROTOCOL=0 command claude --settings ${shellQuote(options.claudeSettings, 'darwin')}${goal}; fi; ${after}`;
125
+ return `if ${check}; then PROHOST_INVOCATION_PROTOCOL=1 ${claude} ${shellQuote(invocationSettings, 'darwin')}${goal}; ` +
126
+ `else PROHOST_INVOCATION_PROTOCOL=0 ${claude} ${shellQuote(settings, 'darwin')}${goal}; fi; ${after}`;
110
127
  }
111
- return `PROHOST_INVOCATION_PROTOCOL=0 command claude --settings ${shellQuote(options.claudeSettings, 'darwin')}${goal}; ${after}`;
128
+ return `PROHOST_INVOCATION_PROTOCOL=0 ${claude} ${shellQuote(settings, 'darwin')}${goal}; ${after}`;
129
+ }
130
+ /**
131
+ * A phrase as a first-run screen shows it: Claude Code wraps its own text inside a bordered box (a
132
+ * narrow window breaks it anywhere, and `capture-pane -J` joins only tmux's wraps), so words may be
133
+ * apart by any whitespace or the box's border.
134
+ */
135
+ function screenPhrase(...phrases) {
136
+ const gap = String.raw `[\s│┃|]+`;
137
+ const escape = (word) => word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
138
+ return new RegExp(phrases.map((phrase) => phrase.split(' ').map(escape).join(gap)).join('|'), 'i');
112
139
  }
113
140
  /** Claude Code's workspace trust dialog, as its first screen shows it. */
114
- const TRUST_SCREEN = /Is this a project you created or one you trust|Do you trust the files in this folder/i;
141
+ const TRUST_SCREEN = screenPhrase('Is this a project you created or one you trust', 'Do you trust the files in this folder');
115
142
  /** How many times after launch the host looks for that dialog, and how far apart. */
116
143
  const TRUST_WATCH_ATTEMPTS = 30;
144
+ /**
145
+ * A thread that skips prompts, once the trust dialog was said: how long (polls) it still looks for
146
+ * the bypass warning, which comes up only after that dialog is answered, however long that takes.
147
+ */
148
+ const BYPASS_WATCH_ATTEMPTS = 600;
117
149
  const TRUST_POLL_MS = 1_000;
118
150
  /** What the thread says while Claude Code waits on its trust dialog. */
119
151
  export const TRUST_PROMPT_NOTE = 'Claude Code is asking whether to trust this folder before it starts. Answer in the terminal.';
152
+ /**
153
+ * Claude Code's one-time warning before its first `--dangerously-skip-permissions` run on a login
154
+ * that never accepted it. A skipping window's `--settings` document accepts it (`hooks.ts`,
155
+ * `skipDangerousModePermissionPrompt`); this is the fallback for a Claude Code that shows it anyway:
156
+ * the thread says it waits on the person.
157
+ */
158
+ const BYPASS_SCREEN = screenPhrase('In Bypass Permissions mode, Claude Code will not ask');
159
+ /** What the thread says while Claude Code waits on that warning. */
160
+ export const BYPASS_PROMPT_NOTE = 'Claude Code asks once to confirm it may run commands without asking. Answer in the terminal.';
120
161
  /**
121
162
  * The environment a Code thread's Claude Code starts with, beyond its account's:
122
163
  * no Claude in Chrome. A thread runs where nobody may be watching, and the
@@ -428,7 +469,10 @@ export class CodeTerminals {
428
469
  // to), not tmux's 80x24: every resize after it makes a shell redraw its prompt, and a TUI its screen.
429
470
  const size = this.sizing.lastSize();
430
471
  await this.tmux(defaultSizeArgs(size.cols, size.rows));
431
- const command = agentCommandLine(request.agent, { ...this.options.hookFiles, hasGoal: goal.length > 0 });
472
+ // Never with remote control off: then nothing from ProhostAI may run a command here unasked
473
+ // (a goal is the agent's first instruction). The server only asks; the Mac decides.
474
+ const skips = request.skipPermissions === true && request.agent !== 'shell' && this.canType();
475
+ const command = agentCommandLine(request.agent, { ...this.options.hookFiles, hasGoal: goal.length > 0, skipPermissions: skips });
432
476
  // A shell thread's shell waits for its first viewer's size to settle (`sizing.ts`): one prompt.
433
477
  const held = request.agent === 'shell';
434
478
  const created = await this.tmux(newWindowArgs({
@@ -471,7 +515,7 @@ export class CodeTerminals {
471
515
  // Claude Code may ask first (not pre-trusted, or the record didn't hold), and no hook says
472
516
  // so: every new Claude window is watched, and the thread is told when it waits on it.
473
517
  if (request.agent === 'claude_code')
474
- void this.watchTrustPrompt(request.codeThreadId, request.terminalId, name);
518
+ void this.watchTrustPrompt(request.codeThreadId, request.terminalId, name, skips);
475
519
  return { ok: true, adopted: false, reported };
476
520
  }
477
521
  /**
@@ -496,22 +540,38 @@ export class CodeTerminals {
496
540
  return trusted;
497
541
  }
498
542
  /**
499
- * Watch a new Claude Code window for its trust dialog, only to tell the thread it waits on it
500
- * (needs-you, not Working). It never decides whether a reply types: the ready mark does.
543
+ * Watch a new Claude Code window for its first-run screens, only to tell the thread it waits on
544
+ * one (needs-you, not Working): the folder's trust dialog, and, for a thread that skips permission
545
+ * prompts, Claude Code's one-time bypass warning (it comes up after the trust dialog is answered,
546
+ * so it is looked for longer once that was said). Each is said once. It never decides whether a
547
+ * reply types: the ready mark does.
501
548
  */
502
- async watchTrustPrompt(codeThreadId, terminalId, name) {
503
- for (let attempt = 0; attempt < (this.options.trustWatchAttempts ?? TRUST_WATCH_ATTEMPTS); attempt += 1) {
504
- // A hook ran: it is past the dialog, whatever the screen shows.
549
+ async watchTrustPrompt(codeThreadId, terminalId, name, skips = false) {
550
+ const screens = [
551
+ { screen: TRUST_SCREEN, note: TRUST_PROMPT_NOTE, log: 'asks whether to trust its folder' },
552
+ ...(skips
553
+ ? [{ screen: BYPASS_SCREEN, note: BYPASS_PROMPT_NOTE, log: 'asks to confirm running without permission prompts' }]
554
+ : []),
555
+ ];
556
+ const said = new Set();
557
+ let attempts = this.options.trustWatchAttempts ?? TRUST_WATCH_ATTEMPTS;
558
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
559
+ // A hook ran: it is past every first-run screen, whatever the screen shows.
505
560
  if (this.isReady(terminalId) !== false)
506
561
  return;
507
562
  const pane = await this.tmux(['capture-pane', '-p', '-J', '-t', windowTarget(name)]);
508
563
  if (!pane.ok)
509
564
  return;
510
- // Re-checked after the capture: a hook that ran meanwhile means the dialog was just answered.
511
- if (TRUST_SCREEN.test(pane.stdout) && this.isReady(terminalId) === false) {
512
- this.log(` ! code thread ${codeThreadId.slice(0, 12)}: Claude Code asks whether to trust its folder`);
513
- await this.options.events.post(codeThreadId, event('stop', { finalText: TRUST_PROMPT_NOTE, question: true }, this.now()));
514
- return;
565
+ const shown = screens.find((entry) => !said.has(entry.note) && entry.screen.test(pane.stdout));
566
+ // Re-checked after the capture: a hook that ran meanwhile means the screen was just answered.
567
+ if (shown && this.isReady(terminalId) === false) {
568
+ this.log(` ! code thread ${codeThreadId.slice(0, 12)}: Claude Code ${shown.log}`);
569
+ await this.options.events.post(codeThreadId, event('stop', { finalText: shown.note, question: true }, this.now()));
570
+ said.add(shown.note);
571
+ if (said.size === screens.length)
572
+ return;
573
+ // The next screen waits on this one's answer: look for it from now, for longer.
574
+ attempts = attempt + 1 + Math.max(attempts, BYPASS_WATCH_ATTEMPTS);
515
575
  }
516
576
  await this.sleep(TRUST_POLL_MS);
517
577
  }
@@ -852,17 +912,18 @@ export class CodeTerminals {
852
912
  write: (data) => {
853
913
  if (closed || watchOnly || data.length === 0)
854
914
  return;
855
- // The person typing (or focusing this terminal) sizes the window (`sizing.ts`).
856
- const claims = isHumanInput(data) || hasFocusIn(data);
857
915
  if (held) {
858
916
  held.push(data);
859
917
  return;
860
918
  }
861
919
  // Navigation keys (arrows, paging, other escape sequences and control keys) still move
862
- // through scrollback; only text leaves copy mode on its way to the agent.
863
- if (!this.sizing.mayBeScrolled(name) || !(isTypedText(data) || isCopyModeExitKey(data))) {
864
- if (claims)
865
- sizer.claim();
920
+ // through the scrollback of the viewer who scrolled; only text leaves copy mode on its way
921
+ // to the agent. Anything another viewer types leaves someone else's copy mode first.
922
+ const scrolled = this.sizing.mayBeScrolled(name);
923
+ const othersScroll = scrolled && !sizer.isScroller() && isHumanInput(data);
924
+ if (!scrolled || (!othersScroll && !(isTypedText(data) || isCopyModeExitKey(data)))) {
925
+ // Focusing this terminal sizes the window to it; typing does once the sizing viewer is idle (`sizing.ts`).
926
+ noteInputTakeover(sizer, data);
866
927
  deliver(data);
867
928
  return;
868
929
  }
@@ -878,14 +939,14 @@ export class CodeTerminals {
878
939
  const left = read.ok && (!inMode || (await this.tmux(['send-keys', '-X', '-t', windowTarget(name), 'cancel'])).ok);
879
940
  if (left)
880
941
  this.sizing.leftCopyMode(name);
881
- sizer.claim();
942
+ noteInputTakeover(sizer, data);
882
943
  const pending = held ?? [];
883
944
  held = undefined;
884
945
  pending.forEach((chunk, index) => {
885
946
  if (index === 0 && inMode && isCopyModeExitKey(chunk))
886
947
  return;
887
- if (index > 0 && (isHumanInput(chunk) || hasFocusIn(chunk)))
888
- sizer.claim();
948
+ if (index > 0)
949
+ noteInputTakeover(sizer, chunk);
889
950
  deliver(chunk);
890
951
  });
891
952
  })();
@@ -912,6 +973,7 @@ export class CodeTerminals {
912
973
  sizer.claim();
913
974
  },
914
975
  onSizer: (listener) => sizer.onSizer(listener),
976
+ history: (lines) => closed ? Promise.resolve({ ok: false, error: 'unavailable' }) : readHistory((args) => this.tmux(args), name, lines),
915
977
  };
916
978
  return { ok: true, stream };
917
979
  }
@@ -138,7 +138,8 @@ export declare const APPROVAL_DECISIONS: ReadonlySet<string>;
138
138
  export declare function clampDim(raw: unknown, fallback: number): number;
139
139
  /**
140
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.
141
+ * `claim` asks to size the window to it (see `sizing.ts`); `history` asks for the pane's
142
+ * scroll-back as text (`history.ts`). Anything else is ignored.
142
143
  */
143
144
  export type ControlFrame = {
144
145
  type: 'resize';
@@ -152,6 +153,10 @@ export type ControlFrame = {
152
153
  client: ViewerClient;
153
154
  } | {
154
155
  type: 'claim';
156
+ } | {
157
+ type: 'history';
158
+ id: string;
159
+ lines: number;
155
160
  } | {
156
161
  type: 'ping';
157
162
  } | {
@@ -19,6 +19,7 @@
19
19
  * window options set at creation.
20
20
  */
21
21
  import crypto from 'node:crypto';
22
+ import { parseHistoryRequest } from './history.js';
22
23
  import { parseViewerClient } from './sizing.js';
23
24
  /** Prefix of `tmux -L <socket>`: this host's private tmux server (one per `$PROHOST_HOME`). */
24
25
  export const TMUX_SOCKET_PREFIX = 'prohost';
@@ -292,6 +293,8 @@ export function parseControl(text) {
292
293
  return { type: 'hello', client: parseViewerClient(f.client) };
293
294
  if (f.type === 'claim')
294
295
  return { type: 'claim' };
296
+ if (f.type === 'history')
297
+ return parseHistoryRequest(f);
295
298
  if (f.type === 'ping' || f.type === 'pong')
296
299
  return { type: f.type };
297
300
  return null;