@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 +15 -0
- package/dist/agent/run.js +12 -3
- package/dist/codehost/history.d.ts +80 -0
- package/dist/codehost/history.js +98 -0
- package/dist/codehost/hooks.d.ts +10 -2
- package/dist/codehost/hooks.js +15 -4
- package/dist/codehost/host.d.ts +7 -0
- package/dist/codehost/host.js +10 -0
- package/dist/codehost/relay.d.ts +6 -1
- package/dist/codehost/relay.js +52 -2
- package/dist/codehost/sizing.d.ts +22 -4
- package/dist/codehost/sizing.js +46 -6
- package/dist/codehost/terminals.d.ts +33 -2
- package/dist/codehost/terminals.js +92 -30
- package/dist/codehost/tmux.d.ts +6 -1
- package/dist/codehost/tmux.js +3 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
-
? [
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/codehost/hooks.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
77
|
-
|
|
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;
|
package/dist/codehost/hooks.js
CHANGED
|
@@ -123,8 +123,14 @@ export function hookShimScript(nodePath, cliEntrypoint) {
|
|
|
123
123
|
'',
|
|
124
124
|
].join('\n');
|
|
125
125
|
}
|
|
126
|
-
/**
|
|
127
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/codehost/host.d.ts
CHANGED
|
@@ -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`. */
|
package/dist/codehost/host.js
CHANGED
|
@@ -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),
|
package/dist/codehost/relay.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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 {
|
package/dist/codehost/relay.js
CHANGED
|
@@ -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
|
-
/**
|
|
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,
|
|
12
|
-
* the desktop's "Use this window's size"), or reports focus (`ESC [ I
|
|
13
|
-
* `watch` viewer, which can't type (it sizes a window only
|
|
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
|
|
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;
|
package/dist/codehost/sizing.js
CHANGED
|
@@ -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,
|
|
12
|
-
* the desktop's "Use this window's size"), or reports focus (`ESC [ I
|
|
13
|
-
* `watch` viewer, which can't type (it sizes a window only
|
|
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 = {
|
|
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 ||
|
|
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
|
|
347
|
-
* (needs-you, not Working)
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
|
109
|
-
`else PROHOST_INVOCATION_PROTOCOL=0
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
|
500
|
-
* (needs-you, not Working)
|
|
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
|
-
|
|
504
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
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
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
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
|
|
888
|
-
sizer
|
|
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
|
}
|
package/dist/codehost/tmux.d.ts
CHANGED
|
@@ -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`)
|
|
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
|
} | {
|
package/dist/codehost/tmux.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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}`;
|