@prohost/cli 0.13.3 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +90 -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 +230 -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/history.d.ts +80 -0
  30. package/dist/codehost/history.js +98 -0
  31. package/dist/codehost/hooks.d.ts +186 -0
  32. package/dist/codehost/hooks.js +737 -0
  33. package/dist/codehost/host.d.ts +297 -0
  34. package/dist/codehost/host.js +635 -0
  35. package/dist/codehost/host_stats.d.ts +71 -0
  36. package/dist/codehost/host_stats.js +166 -0
  37. package/dist/codehost/invocations.d.ts +12 -0
  38. package/dist/codehost/invocations.js +82 -0
  39. package/dist/codehost/ledger.d.ts +35 -0
  40. package/dist/codehost/ledger.js +91 -0
  41. package/dist/codehost/local_server.d.ts +106 -0
  42. package/dist/codehost/local_server.js +339 -0
  43. package/dist/codehost/pty.d.ts +38 -0
  44. package/dist/codehost/pty.js +47 -0
  45. package/dist/codehost/ready.d.ts +31 -0
  46. package/dist/codehost/ready.js +109 -0
  47. package/dist/codehost/relay.d.ts +175 -0
  48. package/dist/codehost/relay.js +598 -0
  49. package/dist/codehost/settings.d.ts +52 -0
  50. package/dist/codehost/settings.js +114 -0
  51. package/dist/codehost/sizing.d.ts +181 -0
  52. package/dist/codehost/sizing.js +434 -0
  53. package/dist/codehost/terminals.d.ts +421 -0
  54. package/dist/codehost/terminals.js +1027 -0
  55. package/dist/codehost/tmux.d.ts +201 -0
  56. package/dist/codehost/tmux.js +394 -0
  57. package/dist/index.d.ts +5 -0
  58. package/dist/index.js +21 -0
  59. package/dist/version.d.ts +2 -2
  60. package/dist/version.js +1 -1
  61. package/package.json +4 -1
@@ -0,0 +1,434 @@
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, 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;
18
+ * - when the sizing viewer leaves, the one active most recently among those left takes over;
19
+ * - a handover or resize reaches tmux once it has been quiet {@link HANDOVER_DEBOUNCE_MS}, and only
20
+ * when the size changes: Claude Code reprints its whole transcript on every width change.
21
+ *
22
+ * Copy mode (scrolled back) is the PANE's, so one viewer's scroll freezes everyone's view. The pane
23
+ * leaves it when the sizing viewer changes and when the viewer that scrolled leaves.
24
+ *
25
+ * A viewer that said `{"type":"hello","client":{"kind","label"}}` is sent
26
+ * `{"type":"sizer","kind","label","cols","rows","you","scrolled"}` on attach and whenever the
27
+ * sizing viewer, its size or the pane's copy mode changes; one that never said hello (an older
28
+ * page) is never sent one.
29
+ *
30
+ * It also holds a new shell's first prompt until the first viewer's size has settled: zsh redraws
31
+ * its prompt on every resize, so a shell started detached at tmux's 80x24 and then resized by the
32
+ * page's fit showed one prompt per resize. A held window runs `tmux wait-for <channel>` before its
33
+ * shell (see {@link holdCommand}); the first viewer's resizes going quiet signals it.
34
+ */
35
+ import { hasFocusIn, isHumanInput, leaveCopyModeArgs, paneInModeArgs, windowTarget } from './tmux.js';
36
+ export const VIEWER_KINDS = new Set(['phone', 'tablet', 'desktop']);
37
+ const MAX_LABEL_CHARS = 40;
38
+ /** A `hello` frame's `client`, made safe to repeat to another viewer. */
39
+ export function parseViewerClient(raw) {
40
+ const client = raw && typeof raw === 'object' ? raw : {};
41
+ const kind = typeof client.kind === 'string' && VIEWER_KINDS.has(client.kind) ? client.kind : 'unknown';
42
+ const label = typeof client.label === 'string'
43
+ ? // eslint-disable-next-line no-control-regex
44
+ client.label.replace(/[\u0000-\u001f\u007f-\u009f]/g, '').trim().slice(0, MAX_LABEL_CHARS)
45
+ : '';
46
+ return { kind, label: label || null };
47
+ }
48
+ /** Window option set on a window created holding its shell; cleared when it is released. */
49
+ export const HOLD_OPTION = '@prohost_held';
50
+ /** Quiet time after the first viewer's last resize before a held shell starts. */
51
+ export const SETTLE_MS = 250;
52
+ /** A held shell starts at most this long after its first viewer attached, resizes or not. */
53
+ export const SETTLE_MAX_MS = 2_000;
54
+ /** A handover or resize is applied once quiet this long (each width change makes Claude Code reprint). */
55
+ export const HANDOVER_DEBOUNCE_MS = 300;
56
+ /** After input that may enter or leave copy mode, the pane is asked once it has been quiet this long. */
57
+ export const MODE_CHECK_MS = 150;
58
+ /** A held shell nobody attached to starts anyway after this long. */
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;
62
+ export function holdChannel(name) {
63
+ return `prohost-hold-${name}`;
64
+ }
65
+ /**
66
+ * `command`, run once the window's hold is released. Waits on the tmux server the pane runs in
67
+ * (`$TMUX`); without a reachable tmux the wait fails at once and the shell starts unheld. A
68
+ * signal sent before the wait began is kept by tmux, so the order can't strand it.
69
+ */
70
+ export function holdCommand(name, command) {
71
+ return `tmux wait-for ${holdChannel(name)} 2>/dev/null; ${command}`;
72
+ }
73
+ /** Signal the hold, only while the window still carries {@link HOLD_OPTION} (tmux keeps a signal nobody waits for). */
74
+ export function releaseHoldArgs(name) {
75
+ const target = windowTarget(name);
76
+ return ['if-shell', '-F', '-t', target, `#{${HOLD_OPTION}}`, `wait-for -S ${holdChannel(name)} ; set-option -wu -t ${target} ${HOLD_OPTION}`];
77
+ }
78
+ export function resizeWindowArgs(name, cols, rows) {
79
+ return ['resize-window', '-t', windowTarget(name), '-x', String(cols), '-y', String(rows)];
80
+ }
81
+ /**
82
+ * The size a window created next starts at — only while no client is attached to the server
83
+ * (tmux picks a new window's size from its global `window-size`); {@link resizeWindowArgs} right
84
+ * after `new-window` is what holds whatever is attached.
85
+ */
86
+ export function defaultSizeArgs(cols, rows) {
87
+ return ['set-option', '-g', 'default-size', `${cols}x${rows}`];
88
+ }
89
+ function defaultTimer(fn, ms) {
90
+ const timer = setTimeout(fn, ms);
91
+ timer.unref?.();
92
+ return () => clearTimeout(timer);
93
+ }
94
+ const key = (size) => `${size.cols}x${size.rows}`;
95
+ export class TerminalSizing {
96
+ options;
97
+ windows = new Map();
98
+ nextId = 1;
99
+ seq = 0;
100
+ last;
101
+ setTimer;
102
+ now;
103
+ constructor(options) {
104
+ this.options = options;
105
+ this.setTimer = options.setTimer ?? defaultTimer;
106
+ this.now = options.now ?? Date.now;
107
+ this.last = options.defaultSize ?? { cols: 120, rows: 32 };
108
+ }
109
+ /** The size a window was last sized to: what a window created with nobody watching starts at. */
110
+ lastSize() {
111
+ return { ...this.last };
112
+ }
113
+ state(name) {
114
+ let state = this.windows.get(name);
115
+ if (!state) {
116
+ state = { viewers: new Map(), settled: false, scrolled: false, maybeScrolled: false };
117
+ this.windows.set(name, state);
118
+ }
119
+ return state;
120
+ }
121
+ /**
122
+ * A window was just created (and resized to its first size). `held`: it runs
123
+ * {@link holdCommand} and carries {@link HOLD_OPTION}; its first viewer settling releases it, or
124
+ * {@link HOLD_FALLBACK_MS}.
125
+ */
126
+ created(name, options = {}) {
127
+ // The same state object: viewers already attached hold it.
128
+ const state = this.state(name);
129
+ state.cancelSettle?.();
130
+ state.cancelFallback?.();
131
+ state.cancelSettle = undefined;
132
+ state.cancelFallback = undefined;
133
+ state.settled = false;
134
+ state.settleDeadline = undefined;
135
+ state.scrolled = false;
136
+ state.maybeScrolled = false;
137
+ // Not taken as applied: the first owner's resize-window confirms it, a no-op (no SIGWINCH)
138
+ // when the window already has that size.
139
+ state.applied = undefined;
140
+ if (options.held)
141
+ state.cancelFallback = this.setTimer(() => void this.release(name, state), HOLD_FALLBACK_MS);
142
+ // Somebody already shows it (a window re-created under them): sized to them, and settled from now.
143
+ if (state.owner !== undefined) {
144
+ this.apply(name, state);
145
+ this.settle(name, state);
146
+ }
147
+ }
148
+ /**
149
+ * A window an earlier daemon made, found by `adopt`: it may still hold its shell (that daemon's
150
+ * timer died with it). Released after {@link HOLD_FALLBACK_MS} unless a viewer settles it first;
151
+ * the release is a no-op on a window that isn't held. Once per window per daemon.
152
+ */
153
+ adopted(name) {
154
+ if (this.windows.has(name))
155
+ return;
156
+ const state = this.state(name);
157
+ state.cancelFallback = this.setTimer(() => void this.release(name, state), HOLD_FALLBACK_MS);
158
+ }
159
+ /** Whether the pane may be in copy mode now (as read, or since input that may have entered it). */
160
+ mayBeScrolled(name) {
161
+ const state = this.windows.get(name);
162
+ return Boolean(state && (state.scrolled || state.maybeScrolled));
163
+ }
164
+ /** The pane just left copy mode (its keystroke or a relayed reply cancelled it): every viewer hears. */
165
+ leftCopyMode(name) {
166
+ const state = this.windows.get(name);
167
+ if (!state)
168
+ return;
169
+ state.cancelModeCheck?.();
170
+ state.cancelModeCheck = undefined;
171
+ state.maybeScrolled = false;
172
+ state.scroller = undefined;
173
+ if (state.scrolled) {
174
+ state.scrolled = false;
175
+ this.broadcast(state);
176
+ }
177
+ }
178
+ join(name, size) {
179
+ const state = this.state(name);
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
+ };
188
+ state.viewers.set(viewer.id, viewer);
189
+ // Attaching claims the size, as tmux did (`window-size latest`): a watch viewer only a window
190
+ // nobody else shows.
191
+ const ownerHere = state.owner !== undefined && state.viewers.has(state.owner);
192
+ if (!ownerHere || !viewer.watch)
193
+ this.handOver(name, state, viewer);
194
+ // The first viewer this daemon has seen on it: a shell held for it starts once its size settles
195
+ // (a window an earlier daemon held too: the release is a no-op on a window that isn't).
196
+ if (!state.settled)
197
+ this.settle(name, state);
198
+ let left = false;
199
+ return {
200
+ hello: (client) => {
201
+ if (left)
202
+ return;
203
+ viewer.client = client;
204
+ viewer.lastSent = undefined;
205
+ // The sizing viewer's name changed for everyone; anyone else just learns who sizes it.
206
+ if (state.owner === viewer.id)
207
+ this.broadcast(state);
208
+ else
209
+ this.send(state, viewer);
210
+ },
211
+ resize: (cols, rows) => {
212
+ if (left || (viewer.cols === cols && viewer.rows === rows))
213
+ return;
214
+ viewer.cols = cols;
215
+ viewer.rows = rows;
216
+ if (state.owner !== viewer.id)
217
+ return;
218
+ this.apply(name, state);
219
+ this.broadcast(state);
220
+ if (!state.settled)
221
+ this.settle(name, state);
222
+ },
223
+ claim: () => {
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)
241
+ return;
242
+ this.handOver(name, state, viewer);
243
+ },
244
+ scrolled: (moved = true) => {
245
+ if (left)
246
+ return;
247
+ if (moved) {
248
+ state.scroller = viewer.id;
249
+ state.maybeScrolled = true;
250
+ }
251
+ this.checkMode(name, state);
252
+ },
253
+ isScroller: () => !left && state.scroller === viewer.id,
254
+ leave: () => {
255
+ if (left)
256
+ return;
257
+ left = true;
258
+ viewer.listener = undefined;
259
+ state.viewers.delete(viewer.id);
260
+ // The one who scrolled everyone's view back is gone: nobody is left reading that scrollback.
261
+ if (state.scroller === viewer.id)
262
+ void this.leaveCopyMode(name, state);
263
+ if (state.owner !== viewer.id)
264
+ return;
265
+ let next;
266
+ for (const other of state.viewers.values())
267
+ if (!next || other.activeAt > next.activeAt)
268
+ next = other;
269
+ state.owner = undefined;
270
+ if (next)
271
+ this.handOver(name, state, next);
272
+ },
273
+ onSizer: (listener) => {
274
+ if (left)
275
+ return;
276
+ viewer.listener = listener;
277
+ viewer.lastSent = undefined;
278
+ this.send(state, viewer);
279
+ },
280
+ };
281
+ }
282
+ /** `viewer` sizes the window from now: copy mode is left (it froze everyone's view), the size follows. */
283
+ handOver(name, state, viewer) {
284
+ viewer.activeAt = ++this.seq;
285
+ const changed = state.owner !== viewer.id;
286
+ state.owner = viewer.id;
287
+ // Someone else's scroll no longer freezes the new sizing viewer's view; its own stays.
288
+ if (changed && (state.scrolled || state.maybeScrolled) && state.scroller !== viewer.id) {
289
+ void this.leaveCopyMode(name, state);
290
+ }
291
+ this.apply(name, state);
292
+ this.broadcast(state);
293
+ }
294
+ frameFor(state, viewer) {
295
+ const owner = state.owner === undefined ? undefined : state.viewers.get(state.owner);
296
+ if (!owner)
297
+ return undefined;
298
+ return {
299
+ type: 'sizer',
300
+ kind: owner.client?.kind ?? 'unknown',
301
+ label: owner.client?.label ?? null,
302
+ cols: owner.cols,
303
+ rows: owner.rows,
304
+ you: owner.id === viewer.id,
305
+ scrolled: state.scrolled,
306
+ };
307
+ }
308
+ /** Only to a viewer that said hello, and only what changed since its last frame. */
309
+ send(state, viewer) {
310
+ if (!viewer.client || !viewer.listener)
311
+ return;
312
+ const frame = this.frameFor(state, viewer);
313
+ if (!frame)
314
+ return;
315
+ const text = JSON.stringify(frame);
316
+ if (text === viewer.lastSent)
317
+ return;
318
+ viewer.lastSent = text;
319
+ try {
320
+ viewer.listener(frame);
321
+ }
322
+ catch {
323
+ /* a closing socket; its leave follows */
324
+ }
325
+ }
326
+ broadcast(state) {
327
+ for (const viewer of state.viewers.values())
328
+ this.send(state, viewer);
329
+ }
330
+ /** Leave copy mode (if the pane is in it) and tell every viewer. */
331
+ async leaveCopyMode(name, state) {
332
+ state.cancelModeCheck?.();
333
+ state.cancelModeCheck = undefined;
334
+ const { scroller, maybeScrolled } = state;
335
+ state.maybeScrolled = false;
336
+ state.scroller = undefined;
337
+ // Not left (tmux failed): who scrolled is kept (their leaving still exits it), and what the
338
+ // pane is in is read again, never assumed.
339
+ if (!(await this.options.tmux(leaveCopyModeArgs(name))).ok) {
340
+ state.scroller ??= scroller;
341
+ state.maybeScrolled ||= maybeScrolled;
342
+ this.checkMode(name, state);
343
+ return;
344
+ }
345
+ if (state.scrolled) {
346
+ state.scrolled = false;
347
+ this.broadcast(state);
348
+ }
349
+ }
350
+ /** Read the pane's copy mode once input has been quiet {@link MODE_CHECK_MS}; tell viewers when it changed. */
351
+ checkMode(name, state) {
352
+ state.cancelModeCheck?.();
353
+ state.cancelModeCheck = this.setTimer(() => {
354
+ state.cancelModeCheck = undefined;
355
+ void (async () => {
356
+ const read = await this.options.tmux(paneInModeArgs(name));
357
+ if (!read.ok)
358
+ return;
359
+ const scrolled = (read.stdout ?? '').trim() === '1';
360
+ state.maybeScrolled = false;
361
+ if (!scrolled)
362
+ state.scroller = undefined;
363
+ if (scrolled !== state.scrolled) {
364
+ state.scrolled = scrolled;
365
+ this.broadcast(state);
366
+ }
367
+ })();
368
+ }, MODE_CHECK_MS);
369
+ }
370
+ /**
371
+ * Size the window to its sizing viewer once the asks have been quiet {@link HANDOVER_DEBOUNCE_MS}:
372
+ * one `resize-window` at a time, the latest size last, none for a size it already has.
373
+ */
374
+ apply(name, state) {
375
+ const owner = state.owner === undefined ? undefined : state.viewers.get(state.owner);
376
+ if (!owner)
377
+ return;
378
+ state.desired = { cols: owner.cols, rows: owner.rows };
379
+ this.last = { ...state.desired };
380
+ state.cancelApply?.();
381
+ state.cancelApply = this.setTimer(() => {
382
+ state.cancelApply = undefined;
383
+ this.pump(name, state);
384
+ }, HANDOVER_DEBOUNCE_MS);
385
+ }
386
+ pump(name, state) {
387
+ if (state.applying)
388
+ return;
389
+ const run = async () => {
390
+ while (state.desired && key(state.desired) !== state.applied) {
391
+ const want = state.desired;
392
+ if (!(await this.options.tmux(resizeWindowArgs(name, want.cols, want.rows))).ok)
393
+ return;
394
+ state.applied = key(want);
395
+ }
396
+ };
397
+ state.applying = run().finally(() => {
398
+ state.applying = undefined;
399
+ });
400
+ }
401
+ settle(name, state) {
402
+ const now = this.now();
403
+ state.settleDeadline ??= now + SETTLE_MAX_MS;
404
+ state.cancelSettle?.();
405
+ const wait = Math.max(0, Math.min(SETTLE_MS, state.settleDeadline - now));
406
+ state.cancelSettle = this.setTimer(() => void this.release(name, state), wait);
407
+ }
408
+ async release(name, state) {
409
+ if (state.settled)
410
+ return;
411
+ state.settled = true;
412
+ state.cancelSettle?.();
413
+ state.cancelFallback?.();
414
+ // At its size first: a debounced resize goes now, and the shell's first prompt is drawn once,
415
+ // at the size it keeps.
416
+ if (state.cancelApply) {
417
+ state.cancelApply();
418
+ state.cancelApply = undefined;
419
+ this.pump(name, state);
420
+ }
421
+ await state.applying;
422
+ await this.options.tmux(releaseHoldArgs(name));
423
+ }
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
+ }