@prohost/cli 0.14.0 → 0.15.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ builds via `prepack`, runs the suite, publishes, and tags `cli-v<version>`.
6
6
 
7
7
  ## Unreleased
8
8
 
9
+ ## 0.15.0
10
+
11
+ - **Code threads on this Mac: scroll back locally, no flashing, no permission prompts.**
12
+ - A viewer can ask for the pane's history (`history` control frame, answered only to viewers
13
+ that said `hello`; `tmux capture-pane`, bounded), so the web terminal scrolls it locally
14
+ instead of putting the shared pane into copy mode (#22333).
15
+ - The window's size changes hands on an explicit takeover (attach, focus-in, `claim`) or after
16
+ the sizing viewer has been idle, not on every keystroke from another viewer; resizes stay
17
+ debounced and no-op resizes are skipped (#22333).
18
+ - Claude Code and Codex threads started by the Mac's owner skip permission prompts
19
+ (`--dangerously-skip-permissions`, `--dangerously-bypass-approvals-and-sandbox`) when the
20
+ start asks for it, only while remote control is on; the host declares
21
+ `code_thread_skip_permissions` beside `remote_terminal`. Claude's one-time bypass warning is
22
+ pre-accepted only in the settings files those windows use (#22332).
23
+
9
24
  ## 0.14.0
10
25
 
11
26
  - **Code threads on this Mac: installed agents, honest shells, one prompt, who sizes.**
package/dist/agent/run.js CHANGED
@@ -52,7 +52,7 @@ import { SessionStore, sessionKeyFor } from './sessionstore.js';
52
52
  import { verifySignature } from './signature.js';
53
53
  import { VENUE_RUN_TOKEN_CAPABILITY, apiForRun, claudeSupportsVenueSessions, claudeVenueCommand, VENUE_PROGRESS, VENUE_SESSION_FEATURE, VENUE_SESSION_FRAME, VENUE_SESSION_OPEN_FRAME, VENUE_GRANT_CHECK_MS, VENUE_EXPIRY_MARGIN_MS, VENUE_GRANT_ENDED_ERROR, VENUE_GRANT_EXPIRED_ERROR, isVenueRun, venueEnv, venueGrantLive, venueGrantRemainingMs, venueRunFromSession, venueWorkdir, } from './venue.js';
54
54
  import { RUN_REPOS_ENV_VAR, WorktreeManager } from './worktrees.js';
55
- import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, RefusedDeliveryError, REMOTE_TERMINAL_CAPABILITY, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, } from '../codehost/host.js';
55
+ import { CODE_THREAD_FOLDERS_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY, CODE_THREADS_CAPABILITY, CodeHost, RefusedDeliveryError, REMOTE_TERMINAL_CAPABILITY, EVENT_CODE_THREAD_APPROVAL_DECIDED, EVENT_CODE_THREAD_INPUT_REQUESTED, EVENT_CODE_THREAD_START_REQUESTED, } from '../codehost/host.js';
56
56
  import { loadSettings as loadCodeHostSettings } from '../codehost/settings.js';
57
57
  import { isWebhookEvent } from '../forward.js';
58
58
  import { DEFAULT_WS_URL } from '../listen.js';
@@ -1418,10 +1418,19 @@ export async function runAgentHarness(options) {
1418
1418
  // With folder starts (`folder` / `separateCopy`): the server sends one only to a socket saying so.
1419
1419
  // With the agents whose binaries resolve here, re-checked per frame: the server offers only those.
1420
1420
  ...(codeHost?.authorized()
1421
- ? [CODE_THREADS_CAPABILITY, CODE_THREAD_FOLDERS_CAPABILITY, codeHost.instanceCapability(), ...(codeHost.agentCapabilities?.() ?? [])]
1421
+ ? [
1422
+ CODE_THREADS_CAPABILITY,
1423
+ CODE_THREAD_FOLDERS_CAPABILITY,
1424
+ codeHost.instanceCapability(),
1425
+ ...(codeHost.agentCapabilities?.() ?? []),
1426
+ ]
1422
1427
  : []),
1423
1428
  // Read on every frame, so `remote off` stops being declared at the next one.
1424
- ...(codeHost?.authorized() && codeHost.remoteTerminalAllowed() ? [REMOTE_TERMINAL_CAPABILITY] : []),
1429
+ // With it, skipping permission prompts (`skipPermissions` on a start): only while remote control
1430
+ // is on, as the host applies it only then; the server records a thread as skipping only so.
1431
+ ...(codeHost?.authorized() && codeHost.remoteTerminalAllowed()
1432
+ ? [REMOTE_TERMINAL_CAPABILITY, CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY]
1433
+ : []),
1425
1434
  ],
1426
1435
  // `--machine` names this machine everywhere, exactly as given: whoever set it never sends the
1427
1436
  // real hostname. Only the real hostname is cut to its short form.
@@ -0,0 +1,80 @@
1
+ /**
2
+ * A viewer's local scroll-back (CONTRACTS-R2 R1): the pane's history as text, so the viewer
3
+ * scrolls it on its own screen instead of driving tmux copy mode over the network (one wheel
4
+ * step per round trip, and copy mode is the PANE's: one viewer's scroll froze everyone's view).
5
+ *
6
+ * - client → server: `{"type":"history","id":<string>,"lines":<int, capped 10000>}`;
7
+ * - server → that client only: `{"type":"history","id","text","cols","rows"}`, the history and
8
+ * visible screen as `tmux capture-pane -p -e -J -S -<lines>` prints them (SGR colours kept,
9
+ * wrapped lines joined), or `{"type":"history","id","error":"<code>"}`.
10
+ *
11
+ * Answered only to a viewer that said `hello` (an older page never sees one), and bounded: a
12
+ * frame never exceeds {@link HISTORY_MAX_FRAME_BYTES}; the oldest lines go first.
13
+ */
14
+ /** Most lines a viewer may ask for. */
15
+ export declare const HISTORY_MAX_LINES = 10000;
16
+ /** Lines asked for when a request names none (or an unusable number). */
17
+ export declare const HISTORY_DEFAULT_LINES = 2000;
18
+ /**
19
+ * Largest answer, as sent (JSON): the relay carries it over Redis and the cloud pipe's upstream
20
+ * socket takes at most 1 MiB, so this stays well under both.
21
+ */
22
+ export declare const HISTORY_MAX_FRAME_BYTES: number;
23
+ /** A request id: what the viewer matches its answer by. */
24
+ export declare const HISTORY_ID_PATTERN: RegExp;
25
+ export type HistoryError = 'alternate_screen' | 'capture_failed' | 'unavailable';
26
+ export interface HistoryRequest {
27
+ type: 'history';
28
+ id: string;
29
+ lines: number;
30
+ }
31
+ export type HistoryFrame = {
32
+ type: 'history';
33
+ id: string;
34
+ text: string;
35
+ cols: number;
36
+ rows: number;
37
+ } | {
38
+ type: 'history';
39
+ id: string;
40
+ error: HistoryError;
41
+ };
42
+ export type HistoryResult = {
43
+ ok: true;
44
+ text: string;
45
+ cols: number;
46
+ rows: number;
47
+ } | {
48
+ ok: false;
49
+ error: HistoryError;
50
+ };
51
+ /** `lines` as asked, within 1..{@link HISTORY_MAX_LINES}. */
52
+ export declare function clampHistoryLines(raw: unknown): number;
53
+ /** A `history` request frame's fields, or `null` when it can't be answered (no usable id). */
54
+ export declare function parseHistoryRequest(frame: Record<string, unknown>): HistoryRequest | null;
55
+ /**
56
+ * `-p` print, `-e` keep the SGR escapes (colours, bold), `-J` join wrapped lines (the viewer
57
+ * wraps them at its own width), `-S -<lines>` from that far back in the history to the screen's end.
58
+ */
59
+ export declare function captureHistoryArgs(name: string, lines: number): string[];
60
+ /** The pane's size, and whether a program (vim, less) holds its alternate screen: `<cols> <rows> <0|1>`. */
61
+ export declare function paneGeometryArgs(name: string): string[];
62
+ export declare function parsePaneGeometry(stdout: string): {
63
+ cols: number;
64
+ rows: number;
65
+ alternate: boolean;
66
+ } | null;
67
+ /**
68
+ * The answer to request `id`, at most `maxBytes` as JSON: whole lines are dropped from the top
69
+ * (the oldest) until it fits. tmux ends its capture with a newline, which is not a line.
70
+ */
71
+ export declare function historyFrame(id: string, result: HistoryResult, maxBytes?: number): HistoryFrame;
72
+ export type HistoryTmux = (args: string[]) => Promise<{
73
+ ok: boolean;
74
+ stdout?: string;
75
+ }>;
76
+ /**
77
+ * Read `lines` of window `name`'s history. A pane in the alternate screen (a full-screen program)
78
+ * has no scroll-back to show: the viewer falls back to sending it the wheel.
79
+ */
80
+ export declare function readHistory(tmux: HistoryTmux, name: string, lines: number): Promise<HistoryResult>;
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A viewer's local scroll-back (CONTRACTS-R2 R1): the pane's history as text, so the viewer
3
+ * scrolls it on its own screen instead of driving tmux copy mode over the network (one wheel
4
+ * step per round trip, and copy mode is the PANE's: one viewer's scroll froze everyone's view).
5
+ *
6
+ * - client → server: `{"type":"history","id":<string>,"lines":<int, capped 10000>}`;
7
+ * - server → that client only: `{"type":"history","id","text","cols","rows"}`, the history and
8
+ * visible screen as `tmux capture-pane -p -e -J -S -<lines>` prints them (SGR colours kept,
9
+ * wrapped lines joined), or `{"type":"history","id","error":"<code>"}`.
10
+ *
11
+ * Answered only to a viewer that said `hello` (an older page never sees one), and bounded: a
12
+ * frame never exceeds {@link HISTORY_MAX_FRAME_BYTES}; the oldest lines go first.
13
+ */
14
+ import { windowTarget } from './tmux.js';
15
+ /** Most lines a viewer may ask for. */
16
+ export const HISTORY_MAX_LINES = 10_000;
17
+ /** Lines asked for when a request names none (or an unusable number). */
18
+ export const HISTORY_DEFAULT_LINES = 2_000;
19
+ /**
20
+ * Largest answer, as sent (JSON): the relay carries it over Redis and the cloud pipe's upstream
21
+ * socket takes at most 1 MiB, so this stays well under both.
22
+ */
23
+ export const HISTORY_MAX_FRAME_BYTES = 768 * 1024;
24
+ /** A request id: what the viewer matches its answer by. */
25
+ export const HISTORY_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
26
+ /** `lines` as asked, within 1..{@link HISTORY_MAX_LINES}. */
27
+ export function clampHistoryLines(raw) {
28
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.trunc(raw) : Number.NaN;
29
+ if (!Number.isFinite(n) || n < 1)
30
+ return HISTORY_DEFAULT_LINES;
31
+ return Math.min(n, HISTORY_MAX_LINES);
32
+ }
33
+ /** A `history` request frame's fields, or `null` when it can't be answered (no usable id). */
34
+ export function parseHistoryRequest(frame) {
35
+ if (frame.type !== 'history' || typeof frame.id !== 'string' || !HISTORY_ID_PATTERN.test(frame.id))
36
+ return null;
37
+ return { type: 'history', id: frame.id, lines: clampHistoryLines(frame.lines) };
38
+ }
39
+ /**
40
+ * `-p` print, `-e` keep the SGR escapes (colours, bold), `-J` join wrapped lines (the viewer
41
+ * wraps them at its own width), `-S -<lines>` from that far back in the history to the screen's end.
42
+ */
43
+ export function captureHistoryArgs(name, lines) {
44
+ return ['capture-pane', '-p', '-e', '-J', '-S', `-${clampHistoryLines(lines)}`, '-t', windowTarget(name)];
45
+ }
46
+ /** The pane's size, and whether a program (vim, less) holds its alternate screen: `<cols> <rows> <0|1>`. */
47
+ export function paneGeometryArgs(name) {
48
+ return ['display-message', '-p', '-t', windowTarget(name), '#{pane_width} #{pane_height} #{alternate_on}'];
49
+ }
50
+ export function parsePaneGeometry(stdout) {
51
+ const match = /^(\d+) (\d+) ([01])$/.exec(stdout.trim());
52
+ if (!match)
53
+ return null;
54
+ const cols = Number(match[1]);
55
+ const rows = Number(match[2]);
56
+ if (cols < 1 || rows < 1)
57
+ return null;
58
+ return { cols, rows, alternate: match[3] === '1' };
59
+ }
60
+ const frameBytes = (frame) => Buffer.byteLength(JSON.stringify(frame), 'utf8');
61
+ /**
62
+ * The answer to request `id`, at most `maxBytes` as JSON: whole lines are dropped from the top
63
+ * (the oldest) until it fits. tmux ends its capture with a newline, which is not a line.
64
+ */
65
+ export function historyFrame(id, result, maxBytes = HISTORY_MAX_FRAME_BYTES) {
66
+ if (!result.ok)
67
+ return { type: 'history', id, error: result.error };
68
+ let lines = result.text.replace(/\r/g, '').replace(/\n$/, '').split('\n');
69
+ const build = () => ({ type: 'history', id, text: lines.join('\n'), cols: result.cols, rows: result.rows });
70
+ let frame = build();
71
+ let size = frameBytes(frame);
72
+ while (size > maxBytes && lines.length > 1) {
73
+ // Proportionally, with a margin, so a big history takes a few passes rather than thousands.
74
+ const keep = Math.max(1, Math.min(lines.length - 1, Math.floor((lines.length * maxBytes) / size) - 1));
75
+ lines = lines.slice(lines.length - keep);
76
+ frame = build();
77
+ size = frameBytes(frame);
78
+ }
79
+ if (size > maxBytes)
80
+ return { type: 'history', id, error: 'capture_failed' };
81
+ return frame;
82
+ }
83
+ /**
84
+ * Read `lines` of window `name`'s history. A pane in the alternate screen (a full-screen program)
85
+ * has no scroll-back to show: the viewer falls back to sending it the wheel.
86
+ */
87
+ export async function readHistory(tmux, name, lines) {
88
+ const geometryRead = await tmux(paneGeometryArgs(name));
89
+ const geometry = geometryRead.ok ? parsePaneGeometry(geometryRead.stdout ?? '') : null;
90
+ if (!geometry)
91
+ return { ok: false, error: 'capture_failed' };
92
+ if (geometry.alternate)
93
+ return { ok: false, error: 'alternate_screen' };
94
+ const capture = await tmux(captureHistoryArgs(name, lines));
95
+ if (!capture.ok)
96
+ return { ok: false, error: 'capture_failed' };
97
+ return { ok: true, text: capture.stdout ?? '', cols: geometry.cols, rows: geometry.rows };
98
+ }
@@ -73,8 +73,14 @@ export declare function hookShimPath(env?: NodeJS.ProcessEnv): string;
73
73
  export declare function claudeSettingsPath(env?: NodeJS.ProcessEnv): string;
74
74
  /** The shim script: env-guarded, then the current CLI's `hook` command. */
75
75
  export declare function hookShimScript(nodePath: string, cliEntrypoint: string): string;
76
- /** The `--settings` document Claude Code is launched with: hooks only. */
77
- export declare function claudeHookSettings(shimPath: string, invocationProtocol?: boolean): Record<string, unknown>;
76
+ /**
77
+ * The `--settings` document Claude Code is launched with: hooks only, and, for a window started
78
+ * without permission prompts (`skipsPermissions`), the owner's acceptance of Claude Code's one-time
79
+ * bypass-mode warning: `skipDangerousModePermissionPrompt` ("whether the user has accepted the
80
+ * bypass permissions mode dialog", Claude Code's settings schema; read from `--settings` too). Only
81
+ * that window's document carries it: every other window launches without bypass mode, as before.
82
+ */
83
+ export declare function claudeHookSettings(shimPath: string, invocationProtocol?: boolean, skipsPermissions?: boolean): Record<string, unknown>;
78
84
  /** Codex `-c notify=[...]` value (TOML array), naming the shim. */
79
85
  export declare function codexNotifyOverride(shimPath: string): string;
80
86
  /** Write the shim and Claude settings. Called on every daemon start. */
@@ -82,6 +88,8 @@ export declare function installHookFiles(nodePath: string, cliEntrypoint: string
82
88
  shim: string;
83
89
  claudeSettings: string;
84
90
  claudeInvocationSettings: string;
91
+ claudeSkipSettings: string;
92
+ claudeInvocationSkipSettings: string;
85
93
  };
86
94
  /** Last non-empty line, clipped — the inbox row's preview. */
87
95
  export declare function previewOf(text: string): string | undefined;
@@ -123,8 +123,14 @@ export function hookShimScript(nodePath, cliEntrypoint) {
123
123
  '',
124
124
  ].join('\n');
125
125
  }
126
- /** The `--settings` document Claude Code is launched with: hooks only. */
127
- export function claudeHookSettings(shimPath, invocationProtocol = false) {
126
+ /**
127
+ * The `--settings` document Claude Code is launched with: hooks only, and, for a window started
128
+ * without permission prompts (`skipsPermissions`), the owner's acceptance of Claude Code's one-time
129
+ * bypass-mode warning: `skipDangerousModePermissionPrompt` ("whether the user has accepted the
130
+ * bypass permissions mode dialog", Claude Code's settings schema; read from `--settings` too). Only
131
+ * that window's document carries it: every other window launches without bypass mode, as before.
132
+ */
133
+ export function claudeHookSettings(shimPath, invocationProtocol = false, skipsPermissions = false) {
128
134
  const hooks = {};
129
135
  for (const name of CLAUDE_HOOK_EVENTS) {
130
136
  // The PermissionRequest hook waits for a decision from ProhostAI (`--wait`), within its own
@@ -154,7 +160,7 @@ export function claudeHookSettings(shimPath, invocationProtocol = false) {
154
160
  hooks.PostToolUse = [{ hooks: [{
155
161
  type: 'command', command: `${shellQuote(shimPath, 'darwin')} claude PostToolUse`, timeout: 10,
156
162
  }] }];
157
- return { hooks };
163
+ return skipsPermissions ? { hooks, skipDangerousModePermissionPrompt: true } : { hooks };
158
164
  }
159
165
  /** Codex `-c notify=[...]` value (TOML array), naming the shim. */
160
166
  export function codexNotifyOverride(shimPath) {
@@ -173,7 +179,12 @@ export function installHookFiles(nodePath, cliEntrypoint, env = process.env) {
173
179
  writePrivateJson(claudeSettings, claudeHookSettings(shim));
174
180
  const claudeInvocationSettings = path.join(codeHostDir(env), 'claude-invocation-settings.json');
175
181
  writePrivateJson(claudeInvocationSettings, claudeHookSettings(shim, true));
176
- return { shim, claudeSettings, claudeInvocationSettings };
182
+ // The same two for a window started without permission prompts (`agentCommandLine`).
183
+ const claudeSkipSettings = path.join(codeHostDir(env), 'claude-settings-skip-permissions.json');
184
+ writePrivateJson(claudeSkipSettings, claudeHookSettings(shim, false, true));
185
+ const claudeInvocationSkipSettings = path.join(codeHostDir(env), 'claude-invocation-settings-skip-permissions.json');
186
+ writePrivateJson(claudeInvocationSkipSettings, claudeHookSettings(shim, true, true));
187
+ return { shim, claudeSettings, claudeInvocationSettings, claudeSkipSettings, claudeInvocationSkipSettings };
177
188
  }
178
189
  // ── Parsing what the agents hand their hooks ───────────────────────────────
179
190
  function asObject(value) {
@@ -45,6 +45,13 @@ export declare const HOST_INSTANCE_CAPABILITY_PREFIX = "host_instance_";
45
45
  * sends a folder start only to a socket that declared it.
46
46
  */
47
47
  export declare const CODE_THREAD_FOLDERS_CAPABILITY = "code_thread_folders";
48
+ /**
49
+ * Declared beside {@link REMOTE_TERMINAL_CAPABILITY} (only while remote control is on) by a host
50
+ * that reads `skipPermissions` in `code_thread.start_requested`: Claude Code and Codex then start
51
+ * without permission prompts (`agentCommandLine`); with remote control off they always ask. The
52
+ * server records a thread as skipping only for a socket that declared it.
53
+ */
54
+ export declare const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = "code_thread_skip_permissions";
48
55
  /** `code_thread.start_requested` data (camelCase per the contract), or `null`. */
49
56
  export declare function parseStartRequest(data: Record<string, unknown>): StartRequest | null;
50
57
  /** `code_thread.input_requested` data, or `null`. */
@@ -56,6 +56,13 @@ export const HOST_INSTANCE_CAPABILITY_PREFIX = 'host_instance_';
56
56
  * sends a folder start only to a socket that declared it.
57
57
  */
58
58
  export const CODE_THREAD_FOLDERS_CAPABILITY = 'code_thread_folders';
59
+ /**
60
+ * Declared beside {@link REMOTE_TERMINAL_CAPABILITY} (only while remote control is on) by a host
61
+ * that reads `skipPermissions` in `code_thread.start_requested`: Claude Code and Codex then start
62
+ * without permission prompts (`agentCommandLine`); with remote control off they always ask. The
63
+ * server records a thread as skipping only for a socket that declared it.
64
+ */
65
+ export const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = 'code_thread_skip_permissions';
59
66
  function text(value) {
60
67
  return typeof value === 'string' ? value : undefined;
61
68
  }
@@ -78,6 +85,8 @@ export function parseStartRequest(data) {
78
85
  branch: text(data.branch) || undefined,
79
86
  agent,
80
87
  ...(folder ? { folder, separateCopy: data.separateCopy === true } : {}),
88
+ // Only an explicit `true` skips the prompts: a server before the choice sends nothing.
89
+ ...(data.skipPermissions === true ? { skipPermissions: true } : {}),
81
90
  };
82
91
  }
83
92
  /** `code_thread.input_requested` data, or `null`. */
@@ -180,6 +189,7 @@ export class CodeHost {
180
189
  checkouts: this.checkouts,
181
190
  events: this.events,
182
191
  hookFiles: { claudeSettings: hookFiles.claudeSettings, claudeInvocationSettings: hookFiles.claudeInvocationSettings,
192
+ claudeSkipSettings: hookFiles.claudeSkipSettings, claudeInvocationSkipSettings: hookFiles.claudeInvocationSkipSettings,
183
193
  hookShim: hookFiles.shim, codexNotify: codexNotifyOverride(hookFiles.shim) },
184
194
  accountEnv: options.accountEnv,
185
195
  agentInstalled: (agent) => resolvesOnPath(AGENT_BINARIES[agent], this.env),
@@ -53,7 +53,12 @@ export interface DialFrame {
53
53
  }
54
54
  /** A `dial` frame, or `null` when it is not one this host can act on. */
55
55
  export declare function parseDial(frame: Record<string, unknown>): DialFrame | null;
56
- /** Pipe one terminal stream over one open socket until either side ends. */
56
+ /**
57
+ * Pipe one terminal stream over one open socket until either side ends.
58
+ *
59
+ * Text controls in: `resize`, `input`, `hello`, `claim`, `history` (answered with the pane's
60
+ * scroll-back, only once the viewer said hello: an older page never sees one), `ping`.
61
+ */
57
62
  export declare function pipeStream(ws: WebSocket, stream: TerminalStream, onClosed: (reason: string) => void): void;
58
63
  /** Whose pairing this is, as the relay's `auth_ok` names it. */
59
64
  export interface PairingIdentity {
@@ -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
  });
@@ -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;
package/dist/version.d.ts CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export declare const CLI_VERSION = "0.14.0";
10
+ export declare const CLI_VERSION = "0.15.0";
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
- export declare const USER_AGENT = "prohost-cli/0.14.0";
12
+ export declare const USER_AGENT = "prohost-cli/0.15.0";
package/dist/version.js CHANGED
@@ -7,6 +7,6 @@
7
7
  * `package.json`, so a release bump that forgets this file fails the suite
8
8
  * instead of shipping a `User-Agent` that lies about which build is calling.
9
9
  */
10
- export const CLI_VERSION = '0.14.0';
10
+ export const CLI_VERSION = '0.15.0';
11
11
  /** Sent on every HTTP request the CLI makes back into ProhostAI. */
12
12
  export const USER_AGENT = `prohost-cli/${CLI_VERSION}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prohost/cli",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Run your own AI agent as a ProhostAI teammate, and stream your account's webhooks to your laptop.",
5
5
  "type": "module",
6
6
  "license": "MIT",