@prohost/cli 0.13.2 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +95 -0
- package/README.md +129 -0
- package/dist/agent/claude.d.ts +28 -0
- package/dist/agent/claude.js +83 -4
- package/dist/agent/command.d.ts +8 -0
- package/dist/agent/command.js +40 -7
- package/dist/agent/daemon.d.ts +3 -1
- package/dist/agent/daemon.js +1 -2
- package/dist/agent/run.d.ts +30 -1
- package/dist/agent/run.js +311 -16
- package/dist/agent/runtime_status.d.ts +99 -3
- package/dist/agent/runtime_status.js +173 -5
- package/dist/agent/worktrees.d.ts +2 -0
- package/dist/agent/worktrees.js +3 -2
- package/dist/codehost/activity.d.ts +64 -0
- package/dist/codehost/activity.js +135 -0
- package/dist/codehost/agents.d.ts +31 -0
- package/dist/codehost/agents.js +57 -0
- package/dist/codehost/checkouts.d.ts +130 -0
- package/dist/codehost/checkouts.js +270 -0
- package/dist/codehost/claude_trust.d.ts +40 -0
- package/dist/codehost/claude_trust.js +129 -0
- package/dist/codehost/command.d.ts +25 -0
- package/dist/codehost/command.js +136 -0
- package/dist/codehost/decision.d.ts +156 -0
- package/dist/codehost/decision.js +553 -0
- package/dist/codehost/events.d.ts +49 -0
- package/dist/codehost/events.js +87 -0
- package/dist/codehost/folders.d.ts +122 -0
- package/dist/codehost/folders.js +317 -0
- package/dist/codehost/hooks.d.ts +178 -0
- package/dist/codehost/hooks.js +726 -0
- package/dist/codehost/host.d.ts +290 -0
- package/dist/codehost/host.js +625 -0
- package/dist/codehost/host_stats.d.ts +71 -0
- package/dist/codehost/host_stats.js +166 -0
- package/dist/codehost/invocations.d.ts +12 -0
- package/dist/codehost/invocations.js +82 -0
- package/dist/codehost/ledger.d.ts +35 -0
- package/dist/codehost/ledger.js +91 -0
- package/dist/codehost/local_server.d.ts +106 -0
- package/dist/codehost/local_server.js +339 -0
- package/dist/codehost/pty.d.ts +38 -0
- package/dist/codehost/pty.js +47 -0
- package/dist/codehost/ready.d.ts +31 -0
- package/dist/codehost/ready.js +109 -0
- package/dist/codehost/relay.d.ts +170 -0
- package/dist/codehost/relay.js +548 -0
- package/dist/codehost/settings.d.ts +52 -0
- package/dist/codehost/settings.js +114 -0
- package/dist/codehost/sizing.d.ts +163 -0
- package/dist/codehost/sizing.js +394 -0
- package/dist/codehost/terminals.d.ts +390 -0
- package/dist/codehost/terminals.js +965 -0
- package/dist/codehost/tmux.d.ts +196 -0
- package/dist/codehost/tmux.js +391 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Mac's Code-thread hosting switches, and the private files the host keeps.
|
|
3
|
+
*
|
|
4
|
+
* Two switches, both off until the machine's owner turns them on here — the
|
|
5
|
+
* server can read them (the heartbeat and the relay socket report them) but has
|
|
6
|
+
* no way to change them:
|
|
7
|
+
*
|
|
8
|
+
* - `enabled` — this machine hosts Code threads: it takes
|
|
9
|
+
* `code_thread.start_requested` / `code_thread.input_requested` from the
|
|
10
|
+
* event stream, keeps their terminals in tmux, and holds the relay's control
|
|
11
|
+
* socket open.
|
|
12
|
+
* - `allowRemoteTerminal` — someone other than a shell on this machine may
|
|
13
|
+
* attach to those terminals and type: a relay viewer, or the desktop app
|
|
14
|
+
* with any viewer token. Off, the desktop app on this Mac still attaches
|
|
15
|
+
* over loopback, but only with a token the relay vouches for as minted for
|
|
16
|
+
* this Mac's own app (`local_server.ts`). A remote terminal is remote code
|
|
17
|
+
* execution on this machine; it is never on by default.
|
|
18
|
+
*
|
|
19
|
+
* Everything lives under `$PROHOST_HOME/code-host/` with owner-only modes.
|
|
20
|
+
*/
|
|
21
|
+
import { randomBytes } from 'node:crypto';
|
|
22
|
+
import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { prohostHome } from '../agent/credentials.js';
|
|
25
|
+
export const DEFAULT_SETTINGS = Object.freeze({
|
|
26
|
+
version: 1,
|
|
27
|
+
enabled: false,
|
|
28
|
+
allowRemoteTerminal: false,
|
|
29
|
+
});
|
|
30
|
+
/** `$PROHOST_HOME/code-host`. */
|
|
31
|
+
export function codeHostDir(env = process.env) {
|
|
32
|
+
return path.join(prohostHome(env), 'code-host');
|
|
33
|
+
}
|
|
34
|
+
export function settingsPath(env = process.env) {
|
|
35
|
+
return path.join(codeHostDir(env), 'settings.json');
|
|
36
|
+
}
|
|
37
|
+
/** Create `dir` (and parents) owner-only. Idempotent. */
|
|
38
|
+
export function ensurePrivateDir(dir) {
|
|
39
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
40
|
+
try {
|
|
41
|
+
chmodSync(dir, 0o700);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* not ours to fix — the files inside are still 0600 */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Write `value` as JSON, atomically (a temp file beside it, then a rename) and
|
|
49
|
+
* owner-only, so a reader never sees half a file or a moment of wider mode.
|
|
50
|
+
*/
|
|
51
|
+
export function writePrivateJson(file, value, mode = 0o600) {
|
|
52
|
+
ensurePrivateDir(path.dirname(file));
|
|
53
|
+
const temp = `${file}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
54
|
+
try {
|
|
55
|
+
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode });
|
|
56
|
+
chmodSync(temp, mode);
|
|
57
|
+
renameSync(temp, file);
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
rmSync(temp, { force: true });
|
|
61
|
+
throw err;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* This home's Code-host instance id: minted once per `PROHOST_HOME`, kept in
|
|
66
|
+
* `code-host/instance.json`. The server binds each Mac thread to the instance
|
|
67
|
+
* that accepted its start (`coding_sessions.host_instance_id`) and sends its
|
|
68
|
+
* later frames to that instance alone: two harnesses on one pairing each keep
|
|
69
|
+
* their own tmux windows.
|
|
70
|
+
*/
|
|
71
|
+
export function codeHostInstanceId(env = process.env) {
|
|
72
|
+
const file = path.join(codeHostDir(env), 'instance.json');
|
|
73
|
+
const known = readJsonObject(file)?.id;
|
|
74
|
+
if (typeof known === 'string' && /^[0-9a-f]{32}$/.test(known))
|
|
75
|
+
return known;
|
|
76
|
+
const id = randomBytes(16).toString('hex');
|
|
77
|
+
writePrivateJson(file, { version: 1, id });
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
/** Parsed JSON object at `file`, or `undefined` for a missing or unreadable one. */
|
|
81
|
+
export function readJsonObject(file) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
84
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
85
|
+
? parsed
|
|
86
|
+
: undefined;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The switches as they are on disk now. A missing, unreadable or malformed file
|
|
94
|
+
* is the default — both off — and only a literal `true` turns a switch on.
|
|
95
|
+
*/
|
|
96
|
+
export function loadSettings(env = process.env) {
|
|
97
|
+
const raw = readJsonObject(settingsPath(env));
|
|
98
|
+
if (!raw || raw.version !== 1)
|
|
99
|
+
return { ...DEFAULT_SETTINGS };
|
|
100
|
+
const enabled = raw.enabled === true;
|
|
101
|
+
return {
|
|
102
|
+
version: 1,
|
|
103
|
+
enabled,
|
|
104
|
+
// Remote typing without hosting is meaningless; never report one without the other.
|
|
105
|
+
allowRemoteTerminal: enabled && raw.allowRemoteTerminal === true,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function saveSettings(settings, env = process.env) {
|
|
109
|
+
writePrivateJson(settingsPath(env), {
|
|
110
|
+
version: 1,
|
|
111
|
+
enabled: settings.enabled,
|
|
112
|
+
allowRemoteTerminal: settings.enabled && settings.allowRemoteTerminal,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who sizes a Code-thread window on this Mac, and what each viewer is told about it.
|
|
3
|
+
*
|
|
4
|
+
* Every viewer of a window — the relay's (a phone, a browser) and the desktop app's — is its own
|
|
5
|
+
* tmux client, and tmux would size the window to whichever was active last (`window-size latest`):
|
|
6
|
+
* a phone opening the thread shrank it for the desktop typing in it. Instead every window is sized
|
|
7
|
+
* with `resize-window` (which makes that window `window-size manual`; never the server's global
|
|
8
|
+
* option, which tmux 3.4 can't start with), from its creation on, and this module picks one viewer
|
|
9
|
+
* per window to size it to (the approved policy, CONTRACTS C3):
|
|
10
|
+
*
|
|
11
|
+
* - a viewer that attaches, types, sends `{"type":"claim"}` (a phone on ready / foreground / touch,
|
|
12
|
+
* the desktop's "Use this window's size"), or reports focus (`ESC [ I`) takes it — never a
|
|
13
|
+
* `watch` viewer, which can't type (it sizes a window only nobody else shows);
|
|
14
|
+
* - when the sizing viewer leaves, the one active most recently among those left takes over;
|
|
15
|
+
* - a handover or resize reaches tmux once it has been quiet {@link HANDOVER_DEBOUNCE_MS}, and only
|
|
16
|
+
* when the size changes: Claude Code reprints its whole transcript on every width change.
|
|
17
|
+
*
|
|
18
|
+
* Copy mode (scrolled back) is the PANE's, so one viewer's scroll freezes everyone's view. The pane
|
|
19
|
+
* leaves it when the sizing viewer changes and when the viewer that scrolled leaves.
|
|
20
|
+
*
|
|
21
|
+
* A viewer that said `{"type":"hello","client":{"kind","label"}}` is sent
|
|
22
|
+
* `{"type":"sizer","kind","label","cols","rows","you","scrolled"}` on attach and whenever the
|
|
23
|
+
* sizing viewer, its size or the pane's copy mode changes; one that never said hello (an older
|
|
24
|
+
* page) is never sent one.
|
|
25
|
+
*
|
|
26
|
+
* It also holds a new shell's first prompt until the first viewer's size has settled: zsh redraws
|
|
27
|
+
* its prompt on every resize, so a shell started detached at tmux's 80x24 and then resized by the
|
|
28
|
+
* page's fit showed one prompt per resize. A held window runs `tmux wait-for <channel>` before its
|
|
29
|
+
* shell (see {@link holdCommand}); the first viewer's resizes going quiet signals it.
|
|
30
|
+
*/
|
|
31
|
+
export type ViewerKind = 'phone' | 'tablet' | 'desktop';
|
|
32
|
+
export declare const VIEWER_KINDS: ReadonlySet<string>;
|
|
33
|
+
/** What a viewer said it is (`hello`). `unknown`: it said hello without a kind this host knows. */
|
|
34
|
+
export interface ViewerClient {
|
|
35
|
+
kind: ViewerKind | 'unknown';
|
|
36
|
+
label: string | null;
|
|
37
|
+
}
|
|
38
|
+
export interface SizerFrame {
|
|
39
|
+
type: 'sizer';
|
|
40
|
+
kind: ViewerKind | 'unknown';
|
|
41
|
+
label: string | null;
|
|
42
|
+
cols: number;
|
|
43
|
+
rows: number;
|
|
44
|
+
/** The viewer this frame is sent to sizes the window. */
|
|
45
|
+
you: boolean;
|
|
46
|
+
/** The pane is in copy mode (someone scrolled back): every viewer's view is frozen on it. */
|
|
47
|
+
scrolled: boolean;
|
|
48
|
+
}
|
|
49
|
+
/** A `hello` frame's `client`, made safe to repeat to another viewer. */
|
|
50
|
+
export declare function parseViewerClient(raw: unknown): ViewerClient;
|
|
51
|
+
/** Window option set on a window created holding its shell; cleared when it is released. */
|
|
52
|
+
export declare const HOLD_OPTION = "@prohost_held";
|
|
53
|
+
/** Quiet time after the first viewer's last resize before a held shell starts. */
|
|
54
|
+
export declare const SETTLE_MS = 250;
|
|
55
|
+
/** A held shell starts at most this long after its first viewer attached, resizes or not. */
|
|
56
|
+
export declare const SETTLE_MAX_MS = 2000;
|
|
57
|
+
/** A handover or resize is applied once quiet this long (each width change makes Claude Code reprint). */
|
|
58
|
+
export declare const HANDOVER_DEBOUNCE_MS = 300;
|
|
59
|
+
/** After input that may enter or leave copy mode, the pane is asked once it has been quiet this long. */
|
|
60
|
+
export declare const MODE_CHECK_MS = 150;
|
|
61
|
+
/** A held shell nobody attached to starts anyway after this long. */
|
|
62
|
+
export declare const HOLD_FALLBACK_MS = 60000;
|
|
63
|
+
export declare function holdChannel(name: string): string;
|
|
64
|
+
/**
|
|
65
|
+
* `command`, run once the window's hold is released. Waits on the tmux server the pane runs in
|
|
66
|
+
* (`$TMUX`); without a reachable tmux the wait fails at once and the shell starts unheld. A
|
|
67
|
+
* signal sent before the wait began is kept by tmux, so the order can't strand it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function holdCommand(name: string, command: string): string;
|
|
70
|
+
/** Signal the hold, only while the window still carries {@link HOLD_OPTION} (tmux keeps a signal nobody waits for). */
|
|
71
|
+
export declare function releaseHoldArgs(name: string): string[];
|
|
72
|
+
export declare function resizeWindowArgs(name: string, cols: number, rows: number): string[];
|
|
73
|
+
/**
|
|
74
|
+
* The size a window created next starts at — only while no client is attached to the server
|
|
75
|
+
* (tmux picks a new window's size from its global `window-size`); {@link resizeWindowArgs} right
|
|
76
|
+
* after `new-window` is what holds whatever is attached.
|
|
77
|
+
*/
|
|
78
|
+
export declare function defaultSizeArgs(cols: number, rows: number): string[];
|
|
79
|
+
export type SizingTmux = (args: string[]) => Promise<{
|
|
80
|
+
ok: boolean;
|
|
81
|
+
stdout?: string;
|
|
82
|
+
}>;
|
|
83
|
+
export interface TerminalSizingOptions {
|
|
84
|
+
tmux: SizingTmux;
|
|
85
|
+
setTimer?: (fn: () => void, ms: number) => () => void;
|
|
86
|
+
now?: () => number;
|
|
87
|
+
defaultSize?: {
|
|
88
|
+
cols: number;
|
|
89
|
+
rows: number;
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/** One viewer's say in its window's size. */
|
|
93
|
+
export interface SizingViewer {
|
|
94
|
+
hello(client: ViewerClient): void;
|
|
95
|
+
resize(cols: number, rows: number): void;
|
|
96
|
+
/** Size the window to this viewer now: it typed, focused, or asked. A `watch` viewer's is ignored. */
|
|
97
|
+
claim(): void;
|
|
98
|
+
/**
|
|
99
|
+
* This viewer's input may have changed the pane's copy mode. `moved`: it can move copy mode (a
|
|
100
|
+
* wheel step, a drag, the prefix key, a keystroke), so this viewer is now who scrolled; else
|
|
101
|
+
* (an emulator report) the mode is only read again.
|
|
102
|
+
*/
|
|
103
|
+
scrolled(moved?: boolean): void;
|
|
104
|
+
leave(): void;
|
|
105
|
+
onSizer(listener: (frame: SizerFrame) => void): void;
|
|
106
|
+
}
|
|
107
|
+
export declare class TerminalSizing {
|
|
108
|
+
private readonly options;
|
|
109
|
+
private readonly windows;
|
|
110
|
+
private nextId;
|
|
111
|
+
private seq;
|
|
112
|
+
private last;
|
|
113
|
+
private readonly setTimer;
|
|
114
|
+
private readonly now;
|
|
115
|
+
constructor(options: TerminalSizingOptions);
|
|
116
|
+
/** The size a window was last sized to: what a window created with nobody watching starts at. */
|
|
117
|
+
lastSize(): {
|
|
118
|
+
cols: number;
|
|
119
|
+
rows: number;
|
|
120
|
+
};
|
|
121
|
+
private state;
|
|
122
|
+
/**
|
|
123
|
+
* A window was just created (and resized to its first size). `held`: it runs
|
|
124
|
+
* {@link holdCommand} and carries {@link HOLD_OPTION}; its first viewer settling releases it, or
|
|
125
|
+
* {@link HOLD_FALLBACK_MS}.
|
|
126
|
+
*/
|
|
127
|
+
created(name: string, options?: {
|
|
128
|
+
held?: boolean;
|
|
129
|
+
}): void;
|
|
130
|
+
/**
|
|
131
|
+
* A window an earlier daemon made, found by `adopt`: it may still hold its shell (that daemon's
|
|
132
|
+
* timer died with it). Released after {@link HOLD_FALLBACK_MS} unless a viewer settles it first;
|
|
133
|
+
* the release is a no-op on a window that isn't held. Once per window per daemon.
|
|
134
|
+
*/
|
|
135
|
+
adopted(name: string): void;
|
|
136
|
+
/** Whether the pane may be in copy mode now (as read, or since input that may have entered it). */
|
|
137
|
+
mayBeScrolled(name: string): boolean;
|
|
138
|
+
/** The pane just left copy mode (its keystroke or a relayed reply cancelled it): every viewer hears. */
|
|
139
|
+
leftCopyMode(name: string): void;
|
|
140
|
+
join(name: string, size: {
|
|
141
|
+
cols: number;
|
|
142
|
+
rows: number;
|
|
143
|
+
watch?: boolean;
|
|
144
|
+
}): SizingViewer;
|
|
145
|
+
/** `viewer` sizes the window from now: copy mode is left (it froze everyone's view), the size follows. */
|
|
146
|
+
private handOver;
|
|
147
|
+
private frameFor;
|
|
148
|
+
/** Only to a viewer that said hello, and only what changed since its last frame. */
|
|
149
|
+
private send;
|
|
150
|
+
private broadcast;
|
|
151
|
+
/** Leave copy mode (if the pane is in it) and tell every viewer. */
|
|
152
|
+
private leaveCopyMode;
|
|
153
|
+
/** Read the pane's copy mode once input has been quiet {@link MODE_CHECK_MS}; tell viewers when it changed. */
|
|
154
|
+
private checkMode;
|
|
155
|
+
/**
|
|
156
|
+
* Size the window to its sizing viewer once the asks have been quiet {@link HANDOVER_DEBOUNCE_MS}:
|
|
157
|
+
* one `resize-window` at a time, the latest size last, none for a size it already has.
|
|
158
|
+
*/
|
|
159
|
+
private apply;
|
|
160
|
+
private pump;
|
|
161
|
+
private settle;
|
|
162
|
+
private release;
|
|
163
|
+
}
|
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who sizes a Code-thread window on this Mac, and what each viewer is told about it.
|
|
3
|
+
*
|
|
4
|
+
* Every viewer of a window — the relay's (a phone, a browser) and the desktop app's — is its own
|
|
5
|
+
* tmux client, and tmux would size the window to whichever was active last (`window-size latest`):
|
|
6
|
+
* a phone opening the thread shrank it for the desktop typing in it. Instead every window is sized
|
|
7
|
+
* with `resize-window` (which makes that window `window-size manual`; never the server's global
|
|
8
|
+
* option, which tmux 3.4 can't start with), from its creation on, and this module picks one viewer
|
|
9
|
+
* per window to size it to (the approved policy, CONTRACTS C3):
|
|
10
|
+
*
|
|
11
|
+
* - a viewer that attaches, types, sends `{"type":"claim"}` (a phone on ready / foreground / touch,
|
|
12
|
+
* the desktop's "Use this window's size"), or reports focus (`ESC [ I`) takes it — never a
|
|
13
|
+
* `watch` viewer, which can't type (it sizes a window only nobody else shows);
|
|
14
|
+
* - when the sizing viewer leaves, the one active most recently among those left takes over;
|
|
15
|
+
* - a handover or resize reaches tmux once it has been quiet {@link HANDOVER_DEBOUNCE_MS}, and only
|
|
16
|
+
* when the size changes: Claude Code reprints its whole transcript on every width change.
|
|
17
|
+
*
|
|
18
|
+
* Copy mode (scrolled back) is the PANE's, so one viewer's scroll freezes everyone's view. The pane
|
|
19
|
+
* leaves it when the sizing viewer changes and when the viewer that scrolled leaves.
|
|
20
|
+
*
|
|
21
|
+
* A viewer that said `{"type":"hello","client":{"kind","label"}}` is sent
|
|
22
|
+
* `{"type":"sizer","kind","label","cols","rows","you","scrolled"}` on attach and whenever the
|
|
23
|
+
* sizing viewer, its size or the pane's copy mode changes; one that never said hello (an older
|
|
24
|
+
* page) is never sent one.
|
|
25
|
+
*
|
|
26
|
+
* It also holds a new shell's first prompt until the first viewer's size has settled: zsh redraws
|
|
27
|
+
* its prompt on every resize, so a shell started detached at tmux's 80x24 and then resized by the
|
|
28
|
+
* page's fit showed one prompt per resize. A held window runs `tmux wait-for <channel>` before its
|
|
29
|
+
* shell (see {@link holdCommand}); the first viewer's resizes going quiet signals it.
|
|
30
|
+
*/
|
|
31
|
+
import { leaveCopyModeArgs, paneInModeArgs, windowTarget } from './tmux.js';
|
|
32
|
+
export const VIEWER_KINDS = new Set(['phone', 'tablet', 'desktop']);
|
|
33
|
+
const MAX_LABEL_CHARS = 40;
|
|
34
|
+
/** A `hello` frame's `client`, made safe to repeat to another viewer. */
|
|
35
|
+
export function parseViewerClient(raw) {
|
|
36
|
+
const client = raw && typeof raw === 'object' ? raw : {};
|
|
37
|
+
const kind = typeof client.kind === 'string' && VIEWER_KINDS.has(client.kind) ? client.kind : 'unknown';
|
|
38
|
+
const label = typeof client.label === 'string'
|
|
39
|
+
? // eslint-disable-next-line no-control-regex
|
|
40
|
+
client.label.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim().slice(0, MAX_LABEL_CHARS)
|
|
41
|
+
: '';
|
|
42
|
+
return { kind, label: label || null };
|
|
43
|
+
}
|
|
44
|
+
/** Window option set on a window created holding its shell; cleared when it is released. */
|
|
45
|
+
export const HOLD_OPTION = '@prohost_held';
|
|
46
|
+
/** Quiet time after the first viewer's last resize before a held shell starts. */
|
|
47
|
+
export const SETTLE_MS = 250;
|
|
48
|
+
/** A held shell starts at most this long after its first viewer attached, resizes or not. */
|
|
49
|
+
export const SETTLE_MAX_MS = 2_000;
|
|
50
|
+
/** A handover or resize is applied once quiet this long (each width change makes Claude Code reprint). */
|
|
51
|
+
export const HANDOVER_DEBOUNCE_MS = 300;
|
|
52
|
+
/** After input that may enter or leave copy mode, the pane is asked once it has been quiet this long. */
|
|
53
|
+
export const MODE_CHECK_MS = 150;
|
|
54
|
+
/** A held shell nobody attached to starts anyway after this long. */
|
|
55
|
+
export const HOLD_FALLBACK_MS = 60_000;
|
|
56
|
+
export function holdChannel(name) {
|
|
57
|
+
return `prohost-hold-${name}`;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* `command`, run once the window's hold is released. Waits on the tmux server the pane runs in
|
|
61
|
+
* (`$TMUX`); without a reachable tmux the wait fails at once and the shell starts unheld. A
|
|
62
|
+
* signal sent before the wait began is kept by tmux, so the order can't strand it.
|
|
63
|
+
*/
|
|
64
|
+
export function holdCommand(name, command) {
|
|
65
|
+
return `tmux wait-for ${holdChannel(name)} 2>/dev/null; ${command}`;
|
|
66
|
+
}
|
|
67
|
+
/** Signal the hold, only while the window still carries {@link HOLD_OPTION} (tmux keeps a signal nobody waits for). */
|
|
68
|
+
export function releaseHoldArgs(name) {
|
|
69
|
+
const target = windowTarget(name);
|
|
70
|
+
return ['if-shell', '-F', '-t', target, `#{${HOLD_OPTION}}`, `wait-for -S ${holdChannel(name)} ; set-option -wu -t ${target} ${HOLD_OPTION}`];
|
|
71
|
+
}
|
|
72
|
+
export function resizeWindowArgs(name, cols, rows) {
|
|
73
|
+
return ['resize-window', '-t', windowTarget(name), '-x', String(cols), '-y', String(rows)];
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The size a window created next starts at — only while no client is attached to the server
|
|
77
|
+
* (tmux picks a new window's size from its global `window-size`); {@link resizeWindowArgs} right
|
|
78
|
+
* after `new-window` is what holds whatever is attached.
|
|
79
|
+
*/
|
|
80
|
+
export function defaultSizeArgs(cols, rows) {
|
|
81
|
+
return ['set-option', '-g', 'default-size', `${cols}x${rows}`];
|
|
82
|
+
}
|
|
83
|
+
function defaultTimer(fn, ms) {
|
|
84
|
+
const timer = setTimeout(fn, ms);
|
|
85
|
+
timer.unref?.();
|
|
86
|
+
return () => clearTimeout(timer);
|
|
87
|
+
}
|
|
88
|
+
const key = (size) => `${size.cols}x${size.rows}`;
|
|
89
|
+
export class TerminalSizing {
|
|
90
|
+
options;
|
|
91
|
+
windows = new Map();
|
|
92
|
+
nextId = 1;
|
|
93
|
+
seq = 0;
|
|
94
|
+
last;
|
|
95
|
+
setTimer;
|
|
96
|
+
now;
|
|
97
|
+
constructor(options) {
|
|
98
|
+
this.options = options;
|
|
99
|
+
this.setTimer = options.setTimer ?? defaultTimer;
|
|
100
|
+
this.now = options.now ?? Date.now;
|
|
101
|
+
this.last = options.defaultSize ?? { cols: 120, rows: 32 };
|
|
102
|
+
}
|
|
103
|
+
/** The size a window was last sized to: what a window created with nobody watching starts at. */
|
|
104
|
+
lastSize() {
|
|
105
|
+
return { ...this.last };
|
|
106
|
+
}
|
|
107
|
+
state(name) {
|
|
108
|
+
let state = this.windows.get(name);
|
|
109
|
+
if (!state) {
|
|
110
|
+
state = { viewers: new Map(), settled: false, scrolled: false, maybeScrolled: false };
|
|
111
|
+
this.windows.set(name, state);
|
|
112
|
+
}
|
|
113
|
+
return state;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* A window was just created (and resized to its first size). `held`: it runs
|
|
117
|
+
* {@link holdCommand} and carries {@link HOLD_OPTION}; its first viewer settling releases it, or
|
|
118
|
+
* {@link HOLD_FALLBACK_MS}.
|
|
119
|
+
*/
|
|
120
|
+
created(name, options = {}) {
|
|
121
|
+
// The same state object: viewers already attached hold it.
|
|
122
|
+
const state = this.state(name);
|
|
123
|
+
state.cancelSettle?.();
|
|
124
|
+
state.cancelFallback?.();
|
|
125
|
+
state.cancelSettle = undefined;
|
|
126
|
+
state.cancelFallback = undefined;
|
|
127
|
+
state.settled = false;
|
|
128
|
+
state.settleDeadline = undefined;
|
|
129
|
+
state.scrolled = false;
|
|
130
|
+
state.maybeScrolled = false;
|
|
131
|
+
// Not taken as applied: the first owner's resize-window confirms it, a no-op (no SIGWINCH)
|
|
132
|
+
// when the window already has that size.
|
|
133
|
+
state.applied = undefined;
|
|
134
|
+
if (options.held)
|
|
135
|
+
state.cancelFallback = this.setTimer(() => void this.release(name, state), HOLD_FALLBACK_MS);
|
|
136
|
+
// Somebody already shows it (a window re-created under them): sized to them, and settled from now.
|
|
137
|
+
if (state.owner !== undefined) {
|
|
138
|
+
this.apply(name, state);
|
|
139
|
+
this.settle(name, state);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A window an earlier daemon made, found by `adopt`: it may still hold its shell (that daemon's
|
|
144
|
+
* timer died with it). Released after {@link HOLD_FALLBACK_MS} unless a viewer settles it first;
|
|
145
|
+
* the release is a no-op on a window that isn't held. Once per window per daemon.
|
|
146
|
+
*/
|
|
147
|
+
adopted(name) {
|
|
148
|
+
if (this.windows.has(name))
|
|
149
|
+
return;
|
|
150
|
+
const state = this.state(name);
|
|
151
|
+
state.cancelFallback = this.setTimer(() => void this.release(name, state), HOLD_FALLBACK_MS);
|
|
152
|
+
}
|
|
153
|
+
/** Whether the pane may be in copy mode now (as read, or since input that may have entered it). */
|
|
154
|
+
mayBeScrolled(name) {
|
|
155
|
+
const state = this.windows.get(name);
|
|
156
|
+
return Boolean(state && (state.scrolled || state.maybeScrolled));
|
|
157
|
+
}
|
|
158
|
+
/** The pane just left copy mode (its keystroke or a relayed reply cancelled it): every viewer hears. */
|
|
159
|
+
leftCopyMode(name) {
|
|
160
|
+
const state = this.windows.get(name);
|
|
161
|
+
if (!state)
|
|
162
|
+
return;
|
|
163
|
+
state.cancelModeCheck?.();
|
|
164
|
+
state.cancelModeCheck = undefined;
|
|
165
|
+
state.maybeScrolled = false;
|
|
166
|
+
state.scroller = undefined;
|
|
167
|
+
if (state.scrolled) {
|
|
168
|
+
state.scrolled = false;
|
|
169
|
+
this.broadcast(state);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
join(name, size) {
|
|
173
|
+
const state = this.state(name);
|
|
174
|
+
const viewer = { id: this.nextId++, cols: size.cols, rows: size.rows, watch: size.watch === true, activeAt: ++this.seq };
|
|
175
|
+
state.viewers.set(viewer.id, viewer);
|
|
176
|
+
// Attaching claims the size, as tmux did (`window-size latest`): a watch viewer only a window
|
|
177
|
+
// nobody else shows.
|
|
178
|
+
const ownerHere = state.owner !== undefined && state.viewers.has(state.owner);
|
|
179
|
+
if (!ownerHere || !viewer.watch)
|
|
180
|
+
this.handOver(name, state, viewer);
|
|
181
|
+
// The first viewer this daemon has seen on it: a shell held for it starts once its size settles
|
|
182
|
+
// (a window an earlier daemon held too: the release is a no-op on a window that isn't).
|
|
183
|
+
if (!state.settled)
|
|
184
|
+
this.settle(name, state);
|
|
185
|
+
let left = false;
|
|
186
|
+
return {
|
|
187
|
+
hello: (client) => {
|
|
188
|
+
if (left)
|
|
189
|
+
return;
|
|
190
|
+
viewer.client = client;
|
|
191
|
+
viewer.lastSent = undefined;
|
|
192
|
+
// The sizing viewer's name changed for everyone; anyone else just learns who sizes it.
|
|
193
|
+
if (state.owner === viewer.id)
|
|
194
|
+
this.broadcast(state);
|
|
195
|
+
else
|
|
196
|
+
this.send(state, viewer);
|
|
197
|
+
},
|
|
198
|
+
resize: (cols, rows) => {
|
|
199
|
+
if (left || (viewer.cols === cols && viewer.rows === rows))
|
|
200
|
+
return;
|
|
201
|
+
viewer.cols = cols;
|
|
202
|
+
viewer.rows = rows;
|
|
203
|
+
if (state.owner !== viewer.id)
|
|
204
|
+
return;
|
|
205
|
+
this.apply(name, state);
|
|
206
|
+
this.broadcast(state);
|
|
207
|
+
if (!state.settled)
|
|
208
|
+
this.settle(name, state);
|
|
209
|
+
},
|
|
210
|
+
claim: () => {
|
|
211
|
+
if (left || state.owner === viewer.id || viewer.watch)
|
|
212
|
+
return;
|
|
213
|
+
this.handOver(name, state, viewer);
|
|
214
|
+
},
|
|
215
|
+
scrolled: (moved = true) => {
|
|
216
|
+
if (left)
|
|
217
|
+
return;
|
|
218
|
+
if (moved) {
|
|
219
|
+
state.scroller = viewer.id;
|
|
220
|
+
state.maybeScrolled = true;
|
|
221
|
+
}
|
|
222
|
+
this.checkMode(name, state);
|
|
223
|
+
},
|
|
224
|
+
leave: () => {
|
|
225
|
+
if (left)
|
|
226
|
+
return;
|
|
227
|
+
left = true;
|
|
228
|
+
viewer.listener = undefined;
|
|
229
|
+
state.viewers.delete(viewer.id);
|
|
230
|
+
// The one who scrolled everyone's view back is gone: nobody is left reading that scrollback.
|
|
231
|
+
if (state.scroller === viewer.id)
|
|
232
|
+
void this.leaveCopyMode(name, state);
|
|
233
|
+
if (state.owner !== viewer.id)
|
|
234
|
+
return;
|
|
235
|
+
let next;
|
|
236
|
+
for (const other of state.viewers.values())
|
|
237
|
+
if (!next || other.activeAt > next.activeAt)
|
|
238
|
+
next = other;
|
|
239
|
+
state.owner = undefined;
|
|
240
|
+
if (next)
|
|
241
|
+
this.handOver(name, state, next);
|
|
242
|
+
},
|
|
243
|
+
onSizer: (listener) => {
|
|
244
|
+
if (left)
|
|
245
|
+
return;
|
|
246
|
+
viewer.listener = listener;
|
|
247
|
+
viewer.lastSent = undefined;
|
|
248
|
+
this.send(state, viewer);
|
|
249
|
+
},
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
/** `viewer` sizes the window from now: copy mode is left (it froze everyone's view), the size follows. */
|
|
253
|
+
handOver(name, state, viewer) {
|
|
254
|
+
viewer.activeAt = ++this.seq;
|
|
255
|
+
const changed = state.owner !== viewer.id;
|
|
256
|
+
state.owner = viewer.id;
|
|
257
|
+
// Someone else's scroll no longer freezes the new sizing viewer's view; its own stays.
|
|
258
|
+
if (changed && (state.scrolled || state.maybeScrolled) && state.scroller !== viewer.id) {
|
|
259
|
+
void this.leaveCopyMode(name, state);
|
|
260
|
+
}
|
|
261
|
+
this.apply(name, state);
|
|
262
|
+
this.broadcast(state);
|
|
263
|
+
}
|
|
264
|
+
frameFor(state, viewer) {
|
|
265
|
+
const owner = state.owner === undefined ? undefined : state.viewers.get(state.owner);
|
|
266
|
+
if (!owner)
|
|
267
|
+
return undefined;
|
|
268
|
+
return {
|
|
269
|
+
type: 'sizer',
|
|
270
|
+
kind: owner.client?.kind ?? 'unknown',
|
|
271
|
+
label: owner.client?.label ?? null,
|
|
272
|
+
cols: owner.cols,
|
|
273
|
+
rows: owner.rows,
|
|
274
|
+
you: owner.id === viewer.id,
|
|
275
|
+
scrolled: state.scrolled,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
/** Only to a viewer that said hello, and only what changed since its last frame. */
|
|
279
|
+
send(state, viewer) {
|
|
280
|
+
if (!viewer.client || !viewer.listener)
|
|
281
|
+
return;
|
|
282
|
+
const frame = this.frameFor(state, viewer);
|
|
283
|
+
if (!frame)
|
|
284
|
+
return;
|
|
285
|
+
const text = JSON.stringify(frame);
|
|
286
|
+
if (text === viewer.lastSent)
|
|
287
|
+
return;
|
|
288
|
+
viewer.lastSent = text;
|
|
289
|
+
try {
|
|
290
|
+
viewer.listener(frame);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
/* a closing socket; its leave follows */
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
broadcast(state) {
|
|
297
|
+
for (const viewer of state.viewers.values())
|
|
298
|
+
this.send(state, viewer);
|
|
299
|
+
}
|
|
300
|
+
/** Leave copy mode (if the pane is in it) and tell every viewer. */
|
|
301
|
+
async leaveCopyMode(name, state) {
|
|
302
|
+
state.cancelModeCheck?.();
|
|
303
|
+
state.cancelModeCheck = undefined;
|
|
304
|
+
const { scroller, maybeScrolled } = state;
|
|
305
|
+
state.maybeScrolled = false;
|
|
306
|
+
state.scroller = undefined;
|
|
307
|
+
// Not left (tmux failed): who scrolled is kept (their leaving still exits it), and what the
|
|
308
|
+
// pane is in is read again, never assumed.
|
|
309
|
+
if (!(await this.options.tmux(leaveCopyModeArgs(name))).ok) {
|
|
310
|
+
state.scroller ??= scroller;
|
|
311
|
+
state.maybeScrolled ||= maybeScrolled;
|
|
312
|
+
this.checkMode(name, state);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
if (state.scrolled) {
|
|
316
|
+
state.scrolled = false;
|
|
317
|
+
this.broadcast(state);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
/** Read the pane's copy mode once input has been quiet {@link MODE_CHECK_MS}; tell viewers when it changed. */
|
|
321
|
+
checkMode(name, state) {
|
|
322
|
+
state.cancelModeCheck?.();
|
|
323
|
+
state.cancelModeCheck = this.setTimer(() => {
|
|
324
|
+
state.cancelModeCheck = undefined;
|
|
325
|
+
void (async () => {
|
|
326
|
+
const read = await this.options.tmux(paneInModeArgs(name));
|
|
327
|
+
if (!read.ok)
|
|
328
|
+
return;
|
|
329
|
+
const scrolled = (read.stdout ?? '').trim() === '1';
|
|
330
|
+
state.maybeScrolled = false;
|
|
331
|
+
if (!scrolled)
|
|
332
|
+
state.scroller = undefined;
|
|
333
|
+
if (scrolled !== state.scrolled) {
|
|
334
|
+
state.scrolled = scrolled;
|
|
335
|
+
this.broadcast(state);
|
|
336
|
+
}
|
|
337
|
+
})();
|
|
338
|
+
}, MODE_CHECK_MS);
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Size the window to its sizing viewer once the asks have been quiet {@link HANDOVER_DEBOUNCE_MS}:
|
|
342
|
+
* one `resize-window` at a time, the latest size last, none for a size it already has.
|
|
343
|
+
*/
|
|
344
|
+
apply(name, state) {
|
|
345
|
+
const owner = state.owner === undefined ? undefined : state.viewers.get(state.owner);
|
|
346
|
+
if (!owner)
|
|
347
|
+
return;
|
|
348
|
+
state.desired = { cols: owner.cols, rows: owner.rows };
|
|
349
|
+
this.last = { ...state.desired };
|
|
350
|
+
state.cancelApply?.();
|
|
351
|
+
state.cancelApply = this.setTimer(() => {
|
|
352
|
+
state.cancelApply = undefined;
|
|
353
|
+
this.pump(name, state);
|
|
354
|
+
}, HANDOVER_DEBOUNCE_MS);
|
|
355
|
+
}
|
|
356
|
+
pump(name, state) {
|
|
357
|
+
if (state.applying)
|
|
358
|
+
return;
|
|
359
|
+
const run = async () => {
|
|
360
|
+
while (state.desired && key(state.desired) !== state.applied) {
|
|
361
|
+
const want = state.desired;
|
|
362
|
+
if (!(await this.options.tmux(resizeWindowArgs(name, want.cols, want.rows))).ok)
|
|
363
|
+
return;
|
|
364
|
+
state.applied = key(want);
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
state.applying = run().finally(() => {
|
|
368
|
+
state.applying = undefined;
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
settle(name, state) {
|
|
372
|
+
const now = this.now();
|
|
373
|
+
state.settleDeadline ??= now + SETTLE_MAX_MS;
|
|
374
|
+
state.cancelSettle?.();
|
|
375
|
+
const wait = Math.max(0, Math.min(SETTLE_MS, state.settleDeadline - now));
|
|
376
|
+
state.cancelSettle = this.setTimer(() => void this.release(name, state), wait);
|
|
377
|
+
}
|
|
378
|
+
async release(name, state) {
|
|
379
|
+
if (state.settled)
|
|
380
|
+
return;
|
|
381
|
+
state.settled = true;
|
|
382
|
+
state.cancelSettle?.();
|
|
383
|
+
state.cancelFallback?.();
|
|
384
|
+
// At its size first: a debounced resize goes now, and the shell's first prompt is drawn once,
|
|
385
|
+
// at the size it keeps.
|
|
386
|
+
if (state.cancelApply) {
|
|
387
|
+
state.cancelApply();
|
|
388
|
+
state.cancelApply = undefined;
|
|
389
|
+
this.pump(name, state);
|
|
390
|
+
}
|
|
391
|
+
await state.applying;
|
|
392
|
+
await this.options.tmux(releaseHoldArgs(name));
|
|
393
|
+
}
|
|
394
|
+
}
|