@prohost/cli 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +265 -1
- package/dist/agent/accounts.d.ts +11 -0
- package/dist/agent/accounts.js +12 -1
- package/dist/agent/api.d.ts +6 -0
- package/dist/agent/api.js +5 -0
- package/dist/agent/claude.d.ts +6 -0
- package/dist/agent/claude.js +2 -2
- package/dist/agent/daemon.d.ts +33 -0
- package/dist/agent/daemon.js +56 -0
- package/dist/agent/prompt.d.ts +12 -0
- package/dist/agent/prompt.js +5 -0
- package/dist/agent/run.d.ts +110 -1
- package/dist/agent/run.js +198 -22
- 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 +32 -0
- package/dist/codehost/host.js +26 -1
- package/dist/codehost/host_stats.d.ts +49 -2
- package/dist/codehost/host_stats.js +153 -2
- package/dist/codehost/relay.d.ts +16 -1
- package/dist/codehost/relay.js +69 -3
- 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/host/api.d.ts +82 -0
- package/dist/host/api.js +122 -0
- package/dist/host/capacity.d.ts +41 -0
- package/dist/host/capacity.js +48 -0
- package/dist/host/command.d.ts +59 -0
- package/dist/host/command.js +584 -0
- package/dist/host/computer.d.ts +120 -0
- package/dist/host/computer.js +291 -0
- package/dist/host/events.d.ts +24 -0
- package/dist/host/events.js +47 -0
- package/dist/host/install.d.ts +152 -0
- package/dist/host/install.js +755 -0
- package/dist/host/ledger.d.ts +52 -0
- package/dist/host/ledger.js +131 -0
- package/dist/host/legacy.d.ts +38 -0
- package/dist/host/legacy.js +115 -0
- package/dist/host/logins.d.ts +109 -0
- package/dist/host/logins.js +190 -0
- package/dist/host/migrate.d.ts +165 -0
- package/dist/host/migrate.js +364 -0
- package/dist/host/pair.d.ts +62 -0
- package/dist/host/pair.js +219 -0
- package/dist/host/peak_memory.d.ts +44 -0
- package/dist/host/peak_memory.js +139 -0
- package/dist/host/scheduler.d.ts +86 -0
- package/dist/host/scheduler.js +186 -0
- package/dist/host/service.d.ts +114 -0
- package/dist/host/service.js +1027 -0
- package/dist/host/session_move.d.ts +48 -0
- package/dist/host/session_move.js +106 -0
- package/dist/host/skills.d.ts +29 -0
- package/dist/host/skills.js +135 -0
- package/dist/host/status.d.ts +91 -0
- package/dist/host/status.js +130 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +47 -3
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
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
|
});
|
|
@@ -206,6 +256,8 @@ export class CodeHostRelay {
|
|
|
206
256
|
/** Bumped by {@link dropStreams}: a dial begun before a drop never opens. */
|
|
207
257
|
generation = 0;
|
|
208
258
|
refused;
|
|
259
|
+
/** Authenticating with {@link CodeHostRelayOptions.fallbackApiKey} after `apiKey` was refused. */
|
|
260
|
+
usingFallback = false;
|
|
209
261
|
log;
|
|
210
262
|
Ctor;
|
|
211
263
|
setTimer;
|
|
@@ -366,7 +418,7 @@ export class CodeHostRelay {
|
|
|
366
418
|
try {
|
|
367
419
|
ws.send(JSON.stringify({
|
|
368
420
|
type: 'auth',
|
|
369
|
-
api_key: this.options.apiKey,
|
|
421
|
+
api_key: this.usingFallback ? this.options.fallbackApiKey : this.options.apiKey,
|
|
370
422
|
machine_id: this.options.machineId,
|
|
371
423
|
...(this.options.instanceId ? { instance_id: this.options.instanceId } : {}),
|
|
372
424
|
machine: this.options.machine,
|
|
@@ -431,6 +483,15 @@ export class CodeHostRelay {
|
|
|
431
483
|
}
|
|
432
484
|
case 'auth_error': {
|
|
433
485
|
const reason = String(frame.reason ?? 'unknown');
|
|
486
|
+
// Only "a valid key, but not a pairing this server knows" — a server from
|
|
487
|
+
// before computer keys. A revoked or invalid key never falls back.
|
|
488
|
+
if (!this.usingFallback && this.options.fallbackApiKey && reason === 'not_paired') {
|
|
489
|
+
// This server doesn't take the first key here: try the second at once.
|
|
490
|
+
this.usingFallback = true;
|
|
491
|
+
this.log(` … code-host relay refused the computer key (${reason}); using the adopted agent key`);
|
|
492
|
+
ws.close();
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
434
495
|
if (this.refused !== reason)
|
|
435
496
|
this.log(` ✗ code-host relay refused this machine: ${reason}`);
|
|
436
497
|
this.refused = reason;
|
|
@@ -483,6 +544,11 @@ export class CodeHostRelay {
|
|
|
483
544
|
this.dialError(dial.streamId, 'remote_terminal_disabled');
|
|
484
545
|
return;
|
|
485
546
|
}
|
|
547
|
+
if (this.options.dialsPaused?.()) {
|
|
548
|
+
// The relay's own word for "not now" (it knows no other).
|
|
549
|
+
this.dialError(dial.streamId, 'remote_terminal_disabled');
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
486
552
|
const url = relayUrl(this.options.baseUrl, STREAM_PATH);
|
|
487
553
|
if (!url)
|
|
488
554
|
return;
|
|
@@ -8,9 +8,13 @@
|
|
|
8
8
|
* option, which tmux 3.4 can't start with), from its creation on, and this module picks one viewer
|
|
9
9
|
* per window to size it to (the approved policy, CONTRACTS C3):
|
|
10
10
|
*
|
|
11
|
-
* - a viewer that attaches,
|
|
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;
|