@prohost/cli 0.13.3 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +129 -0
  3. package/dist/agent/command.d.ts +8 -0
  4. package/dist/agent/command.js +40 -7
  5. package/dist/agent/daemon.d.ts +3 -1
  6. package/dist/agent/daemon.js +1 -2
  7. package/dist/agent/run.d.ts +30 -1
  8. package/dist/agent/run.js +221 -9
  9. package/dist/agent/runtime_status.d.ts +99 -3
  10. package/dist/agent/runtime_status.js +173 -5
  11. package/dist/agent/worktrees.d.ts +2 -0
  12. package/dist/agent/worktrees.js +3 -2
  13. package/dist/codehost/activity.d.ts +64 -0
  14. package/dist/codehost/activity.js +135 -0
  15. package/dist/codehost/agents.d.ts +31 -0
  16. package/dist/codehost/agents.js +57 -0
  17. package/dist/codehost/checkouts.d.ts +130 -0
  18. package/dist/codehost/checkouts.js +270 -0
  19. package/dist/codehost/claude_trust.d.ts +40 -0
  20. package/dist/codehost/claude_trust.js +129 -0
  21. package/dist/codehost/command.d.ts +25 -0
  22. package/dist/codehost/command.js +136 -0
  23. package/dist/codehost/decision.d.ts +156 -0
  24. package/dist/codehost/decision.js +553 -0
  25. package/dist/codehost/events.d.ts +49 -0
  26. package/dist/codehost/events.js +87 -0
  27. package/dist/codehost/folders.d.ts +122 -0
  28. package/dist/codehost/folders.js +317 -0
  29. package/dist/codehost/hooks.d.ts +178 -0
  30. package/dist/codehost/hooks.js +726 -0
  31. package/dist/codehost/host.d.ts +290 -0
  32. package/dist/codehost/host.js +625 -0
  33. package/dist/codehost/host_stats.d.ts +71 -0
  34. package/dist/codehost/host_stats.js +166 -0
  35. package/dist/codehost/invocations.d.ts +12 -0
  36. package/dist/codehost/invocations.js +82 -0
  37. package/dist/codehost/ledger.d.ts +35 -0
  38. package/dist/codehost/ledger.js +91 -0
  39. package/dist/codehost/local_server.d.ts +106 -0
  40. package/dist/codehost/local_server.js +339 -0
  41. package/dist/codehost/pty.d.ts +38 -0
  42. package/dist/codehost/pty.js +47 -0
  43. package/dist/codehost/ready.d.ts +31 -0
  44. package/dist/codehost/ready.js +109 -0
  45. package/dist/codehost/relay.d.ts +170 -0
  46. package/dist/codehost/relay.js +548 -0
  47. package/dist/codehost/settings.d.ts +52 -0
  48. package/dist/codehost/settings.js +114 -0
  49. package/dist/codehost/sizing.d.ts +163 -0
  50. package/dist/codehost/sizing.js +394 -0
  51. package/dist/codehost/terminals.d.ts +390 -0
  52. package/dist/codehost/terminals.js +965 -0
  53. package/dist/codehost/tmux.d.ts +196 -0
  54. package/dist/codehost/tmux.js +391 -0
  55. package/dist/index.d.ts +5 -0
  56. package/dist/index.js +21 -0
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. 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
+ }