@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +83 -0
  2. package/README.md +265 -1
  3. package/dist/agent/accounts.d.ts +11 -0
  4. package/dist/agent/accounts.js +12 -1
  5. package/dist/agent/api.d.ts +6 -0
  6. package/dist/agent/api.js +5 -0
  7. package/dist/agent/claude.d.ts +6 -0
  8. package/dist/agent/claude.js +2 -2
  9. package/dist/agent/daemon.d.ts +33 -0
  10. package/dist/agent/daemon.js +56 -0
  11. package/dist/agent/prompt.d.ts +12 -0
  12. package/dist/agent/prompt.js +5 -0
  13. package/dist/agent/run.d.ts +110 -1
  14. package/dist/agent/run.js +198 -22
  15. package/dist/codehost/history.d.ts +80 -0
  16. package/dist/codehost/history.js +98 -0
  17. package/dist/codehost/hooks.d.ts +10 -2
  18. package/dist/codehost/hooks.js +15 -4
  19. package/dist/codehost/host.d.ts +32 -0
  20. package/dist/codehost/host.js +26 -1
  21. package/dist/codehost/host_stats.d.ts +49 -2
  22. package/dist/codehost/host_stats.js +153 -2
  23. package/dist/codehost/relay.d.ts +16 -1
  24. package/dist/codehost/relay.js +69 -3
  25. package/dist/codehost/sizing.d.ts +22 -4
  26. package/dist/codehost/sizing.js +46 -6
  27. package/dist/codehost/terminals.d.ts +33 -2
  28. package/dist/codehost/terminals.js +92 -30
  29. package/dist/codehost/tmux.d.ts +6 -1
  30. package/dist/codehost/tmux.js +3 -0
  31. package/dist/host/api.d.ts +82 -0
  32. package/dist/host/api.js +122 -0
  33. package/dist/host/capacity.d.ts +41 -0
  34. package/dist/host/capacity.js +48 -0
  35. package/dist/host/command.d.ts +59 -0
  36. package/dist/host/command.js +584 -0
  37. package/dist/host/computer.d.ts +120 -0
  38. package/dist/host/computer.js +291 -0
  39. package/dist/host/events.d.ts +24 -0
  40. package/dist/host/events.js +47 -0
  41. package/dist/host/install.d.ts +152 -0
  42. package/dist/host/install.js +755 -0
  43. package/dist/host/ledger.d.ts +52 -0
  44. package/dist/host/ledger.js +131 -0
  45. package/dist/host/legacy.d.ts +38 -0
  46. package/dist/host/legacy.js +115 -0
  47. package/dist/host/logins.d.ts +109 -0
  48. package/dist/host/logins.js +190 -0
  49. package/dist/host/migrate.d.ts +165 -0
  50. package/dist/host/migrate.js +364 -0
  51. package/dist/host/pair.d.ts +62 -0
  52. package/dist/host/pair.js +219 -0
  53. package/dist/host/peak_memory.d.ts +44 -0
  54. package/dist/host/peak_memory.js +139 -0
  55. package/dist/host/scheduler.d.ts +86 -0
  56. package/dist/host/scheduler.js +186 -0
  57. package/dist/host/service.d.ts +114 -0
  58. package/dist/host/service.js +1027 -0
  59. package/dist/host/session_move.d.ts +48 -0
  60. package/dist/host/session_move.js +106 -0
  61. package/dist/host/skills.d.ts +29 -0
  62. package/dist/host/skills.js +135 -0
  63. package/dist/host/status.d.ts +91 -0
  64. package/dist/host/status.js +130 -0
  65. package/dist/index.d.ts +4 -0
  66. package/dist/index.js +47 -3
  67. package/dist/version.d.ts +2 -2
  68. package/dist/version.js +1 -1
  69. package/package.json +1 -1
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A viewer's local scroll-back (CONTRACTS-R2 R1): the pane's history as text, so the viewer
3
+ * scrolls it on its own screen instead of driving tmux copy mode over the network (one wheel
4
+ * step per round trip, and copy mode is the PANE's: one viewer's scroll froze everyone's view).
5
+ *
6
+ * - client → server: `{"type":"history","id":<string>,"lines":<int, capped 10000>}`;
7
+ * - server → that client only: `{"type":"history","id","text","cols","rows"}`, the history and
8
+ * visible screen as `tmux capture-pane -p -e -J -S -<lines>` prints them (SGR colours kept,
9
+ * wrapped lines joined), or `{"type":"history","id","error":"<code>"}`.
10
+ *
11
+ * Answered only to a viewer that said `hello` (an older page never sees one), and bounded: a
12
+ * frame never exceeds {@link HISTORY_MAX_FRAME_BYTES}; the oldest lines go first.
13
+ */
14
+ import { windowTarget } from './tmux.js';
15
+ /** Most lines a viewer may ask for. */
16
+ export const HISTORY_MAX_LINES = 10_000;
17
+ /** Lines asked for when a request names none (or an unusable number). */
18
+ export const HISTORY_DEFAULT_LINES = 2_000;
19
+ /**
20
+ * Largest answer, as sent (JSON): the relay carries it over Redis and the cloud pipe's upstream
21
+ * socket takes at most 1 MiB, so this stays well under both.
22
+ */
23
+ export const HISTORY_MAX_FRAME_BYTES = 768 * 1024;
24
+ /** A request id: what the viewer matches its answer by. */
25
+ export const HISTORY_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
26
+ /** `lines` as asked, within 1..{@link HISTORY_MAX_LINES}. */
27
+ export function clampHistoryLines(raw) {
28
+ const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.trunc(raw) : Number.NaN;
29
+ if (!Number.isFinite(n) || n < 1)
30
+ return HISTORY_DEFAULT_LINES;
31
+ return Math.min(n, HISTORY_MAX_LINES);
32
+ }
33
+ /** A `history` request frame's fields, or `null` when it can't be answered (no usable id). */
34
+ export function parseHistoryRequest(frame) {
35
+ if (frame.type !== 'history' || typeof frame.id !== 'string' || !HISTORY_ID_PATTERN.test(frame.id))
36
+ return null;
37
+ return { type: 'history', id: frame.id, lines: clampHistoryLines(frame.lines) };
38
+ }
39
+ /**
40
+ * `-p` print, `-e` keep the SGR escapes (colours, bold), `-J` join wrapped lines (the viewer
41
+ * wraps them at its own width), `-S -<lines>` from that far back in the history to the screen's end.
42
+ */
43
+ export function captureHistoryArgs(name, lines) {
44
+ return ['capture-pane', '-p', '-e', '-J', '-S', `-${clampHistoryLines(lines)}`, '-t', windowTarget(name)];
45
+ }
46
+ /** The pane's size, and whether a program (vim, less) holds its alternate screen: `<cols> <rows> <0|1>`. */
47
+ export function paneGeometryArgs(name) {
48
+ return ['display-message', '-p', '-t', windowTarget(name), '#{pane_width} #{pane_height} #{alternate_on}'];
49
+ }
50
+ export function parsePaneGeometry(stdout) {
51
+ const match = /^(\d+) (\d+) ([01])$/.exec(stdout.trim());
52
+ if (!match)
53
+ return null;
54
+ const cols = Number(match[1]);
55
+ const rows = Number(match[2]);
56
+ if (cols < 1 || rows < 1)
57
+ return null;
58
+ return { cols, rows, alternate: match[3] === '1' };
59
+ }
60
+ const frameBytes = (frame) => Buffer.byteLength(JSON.stringify(frame), 'utf8');
61
+ /**
62
+ * The answer to request `id`, at most `maxBytes` as JSON: whole lines are dropped from the top
63
+ * (the oldest) until it fits. tmux ends its capture with a newline, which is not a line.
64
+ */
65
+ export function historyFrame(id, result, maxBytes = HISTORY_MAX_FRAME_BYTES) {
66
+ if (!result.ok)
67
+ return { type: 'history', id, error: result.error };
68
+ let lines = result.text.replace(/\r/g, '').replace(/\n$/, '').split('\n');
69
+ const build = () => ({ type: 'history', id, text: lines.join('\n'), cols: result.cols, rows: result.rows });
70
+ let frame = build();
71
+ let size = frameBytes(frame);
72
+ while (size > maxBytes && lines.length > 1) {
73
+ // Proportionally, with a margin, so a big history takes a few passes rather than thousands.
74
+ const keep = Math.max(1, Math.min(lines.length - 1, Math.floor((lines.length * maxBytes) / size) - 1));
75
+ lines = lines.slice(lines.length - keep);
76
+ frame = build();
77
+ size = frameBytes(frame);
78
+ }
79
+ if (size > maxBytes)
80
+ return { type: 'history', id, error: 'capture_failed' };
81
+ return frame;
82
+ }
83
+ /**
84
+ * Read `lines` of window `name`'s history. A pane in the alternate screen (a full-screen program)
85
+ * has no scroll-back to show: the viewer falls back to sending it the wheel.
86
+ */
87
+ export async function readHistory(tmux, name, lines) {
88
+ const geometryRead = await tmux(paneGeometryArgs(name));
89
+ const geometry = geometryRead.ok ? parsePaneGeometry(geometryRead.stdout ?? '') : null;
90
+ if (!geometry)
91
+ return { ok: false, error: 'capture_failed' };
92
+ if (geometry.alternate)
93
+ return { ok: false, error: 'alternate_screen' };
94
+ const capture = await tmux(captureHistoryArgs(name, lines));
95
+ if (!capture.ok)
96
+ return { ok: false, error: 'capture_failed' };
97
+ return { ok: true, text: capture.stdout ?? '', cols: geometry.cols, rows: geometry.rows };
98
+ }
@@ -73,8 +73,14 @@ export declare function hookShimPath(env?: NodeJS.ProcessEnv): string;
73
73
  export declare function claudeSettingsPath(env?: NodeJS.ProcessEnv): string;
74
74
  /** The shim script: env-guarded, then the current CLI's `hook` command. */
75
75
  export declare function hookShimScript(nodePath: string, cliEntrypoint: string): string;
76
- /** The `--settings` document Claude Code is launched with: hooks only. */
77
- export declare function claudeHookSettings(shimPath: string, invocationProtocol?: boolean): Record<string, unknown>;
76
+ /**
77
+ * The `--settings` document Claude Code is launched with: hooks only, and, for a window started
78
+ * without permission prompts (`skipsPermissions`), the owner's acceptance of Claude Code's one-time
79
+ * bypass-mode warning: `skipDangerousModePermissionPrompt` ("whether the user has accepted the
80
+ * bypass permissions mode dialog", Claude Code's settings schema; read from `--settings` too). Only
81
+ * that window's document carries it: every other window launches without bypass mode, as before.
82
+ */
83
+ export declare function claudeHookSettings(shimPath: string, invocationProtocol?: boolean, skipsPermissions?: boolean): Record<string, unknown>;
78
84
  /** Codex `-c notify=[...]` value (TOML array), naming the shim. */
79
85
  export declare function codexNotifyOverride(shimPath: string): string;
80
86
  /** Write the shim and Claude settings. Called on every daemon start. */
@@ -82,6 +88,8 @@ export declare function installHookFiles(nodePath: string, cliEntrypoint: string
82
88
  shim: string;
83
89
  claudeSettings: string;
84
90
  claudeInvocationSettings: string;
91
+ claudeSkipSettings: string;
92
+ claudeInvocationSkipSettings: string;
85
93
  };
86
94
  /** Last non-empty line, clipped — the inbox row's preview. */
87
95
  export declare function previewOf(text: string): string | undefined;
@@ -123,8 +123,14 @@ export function hookShimScript(nodePath, cliEntrypoint) {
123
123
  '',
124
124
  ].join('\n');
125
125
  }
126
- /** The `--settings` document Claude Code is launched with: hooks only. */
127
- export function claudeHookSettings(shimPath, invocationProtocol = false) {
126
+ /**
127
+ * The `--settings` document Claude Code is launched with: hooks only, and, for a window started
128
+ * without permission prompts (`skipsPermissions`), the owner's acceptance of Claude Code's one-time
129
+ * bypass-mode warning: `skipDangerousModePermissionPrompt` ("whether the user has accepted the
130
+ * bypass permissions mode dialog", Claude Code's settings schema; read from `--settings` too). Only
131
+ * that window's document carries it: every other window launches without bypass mode, as before.
132
+ */
133
+ export function claudeHookSettings(shimPath, invocationProtocol = false, skipsPermissions = false) {
128
134
  const hooks = {};
129
135
  for (const name of CLAUDE_HOOK_EVENTS) {
130
136
  // The PermissionRequest hook waits for a decision from ProhostAI (`--wait`), within its own
@@ -154,7 +160,7 @@ export function claudeHookSettings(shimPath, invocationProtocol = false) {
154
160
  hooks.PostToolUse = [{ hooks: [{
155
161
  type: 'command', command: `${shellQuote(shimPath, 'darwin')} claude PostToolUse`, timeout: 10,
156
162
  }] }];
157
- return { hooks };
163
+ return skipsPermissions ? { hooks, skipDangerousModePermissionPrompt: true } : { hooks };
158
164
  }
159
165
  /** Codex `-c notify=[...]` value (TOML array), naming the shim. */
160
166
  export function codexNotifyOverride(shimPath) {
@@ -173,7 +179,12 @@ export function installHookFiles(nodePath, cliEntrypoint, env = process.env) {
173
179
  writePrivateJson(claudeSettings, claudeHookSettings(shim));
174
180
  const claudeInvocationSettings = path.join(codeHostDir(env), 'claude-invocation-settings.json');
175
181
  writePrivateJson(claudeInvocationSettings, claudeHookSettings(shim, true));
176
- return { shim, claudeSettings, claudeInvocationSettings };
182
+ // The same two for a window started without permission prompts (`agentCommandLine`).
183
+ const claudeSkipSettings = path.join(codeHostDir(env), 'claude-settings-skip-permissions.json');
184
+ writePrivateJson(claudeSkipSettings, claudeHookSettings(shim, false, true));
185
+ const claudeInvocationSkipSettings = path.join(codeHostDir(env), 'claude-invocation-settings-skip-permissions.json');
186
+ writePrivateJson(claudeInvocationSkipSettings, claudeHookSettings(shim, true, true));
187
+ return { shim, claudeSettings, claudeInvocationSettings, claudeSkipSettings, claudeInvocationSkipSettings };
177
188
  }
178
189
  // ── Parsing what the agents hand their hooks ───────────────────────────────
179
190
  function asObject(value) {
@@ -45,6 +45,13 @@ export declare const HOST_INSTANCE_CAPABILITY_PREFIX = "host_instance_";
45
45
  * sends a folder start only to a socket that declared it.
46
46
  */
47
47
  export declare const CODE_THREAD_FOLDERS_CAPABILITY = "code_thread_folders";
48
+ /**
49
+ * Declared beside {@link REMOTE_TERMINAL_CAPABILITY} (only while remote control is on) by a host
50
+ * that reads `skipPermissions` in `code_thread.start_requested`: Claude Code and Codex then start
51
+ * without permission prompts (`agentCommandLine`); with remote control off they always ask. The
52
+ * server records a thread as skipping only for a socket that declared it.
53
+ */
54
+ export declare const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = "code_thread_skip_permissions";
48
55
  /** `code_thread.start_requested` data (camelCase per the contract), or `null`. */
49
56
  export declare function parseStartRequest(data: Record<string, unknown>): StartRequest | null;
50
57
  /** `code_thread.input_requested` data, or `null`. */
@@ -86,6 +93,18 @@ export interface CodeHostLike {
86
93
  * (`agents.ts`), re-checked on every status frame: what the server offers for this Mac.
87
94
  */
88
95
  agentCapabilities?(): string[];
96
+ /** Terminal sessions open on this machine now (`prohost host` counts them against capacity). */
97
+ liveTerminals?(): Array<{
98
+ terminalId: string;
99
+ codeThreadId: string;
100
+ agent: string;
101
+ }>;
102
+ /**
103
+ * Report a start as failed without starting anything (the computer is
104
+ * paused), the way a failed start is reported: the thread never waits in
105
+ * `starting`. Resolves whether the report landed.
106
+ */
107
+ failStart?(data: Record<string, unknown>, reason: string): Promise<boolean>;
89
108
  }
90
109
  /** `code_thread.approval_decided` data, or `null`. */
91
110
  export declare function parseApprovalDecision(data: Record<string, unknown>): ApprovalRequest | null;
@@ -131,6 +150,13 @@ export interface CodeHostOptions {
131
150
  onCodeHostChanged?: () => void;
132
151
  /** The selected account's environment (see `CodeTerminalsOptions.accountEnv`). */
133
152
  accountEnv?: (agent: CodeAgent) => AccountEnvResult;
153
+ /** See `CodeHostRelayOptions.fallbackApiKey`. */
154
+ fallbackApiKey?: string;
155
+ /**
156
+ * The computer is paused (`prohost host pause`): no new viewer may dial in.
157
+ * Terminals already open, replies and approvals carry on.
158
+ */
159
+ paused?: () => boolean;
134
160
  tmux?: TmuxRunner;
135
161
  spawnPty?: () => PtySpawn | undefined;
136
162
  events?: CodeThreadEventSink;
@@ -183,6 +209,11 @@ export declare class CodeHost implements CodeHostLike {
183
209
  constructor(options: CodeHostOptions);
184
210
  settings(): CodeHostSettings;
185
211
  remoteTerminalAllowed(): boolean;
212
+ liveTerminals(): Array<{
213
+ terminalId: string;
214
+ codeThreadId: string;
215
+ agent: string;
216
+ }>;
186
217
  instanceCapability(): string;
187
218
  /**
188
219
  * Only the agents a start would accept: installed here, and runnable on this harness's login
@@ -265,6 +296,7 @@ export declare class CodeHost implements CodeHostLike {
265
296
  * Throws {@link UnreportedOutcomeError} when the outcome never reached the
266
297
  * server, so the harness asks for the delivery again rather than ack it.
267
298
  */
299
+ failStart(data: Record<string, unknown>, reason: string): Promise<boolean>;
268
300
  handleStart(data: Record<string, unknown>): Promise<boolean>;
269
301
  handleInput(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
270
302
  handleApproval(data: Record<string, unknown>, deliveryId?: string): Promise<boolean>;
@@ -24,7 +24,7 @@ import { LocalTerminalServer } from './local_server.js';
24
24
  import { loadNodePty } from './pty.js';
25
25
  import { CodeHostRelay } from './relay.js';
26
26
  import { codeHostDir, codeHostInstanceId, ensurePrivateDir, loadSettings } from './settings.js';
27
- import { CodeTerminals, execTmux } from './terminals.js';
27
+ import { CodeTerminals, execTmux, isDefinitive } from './terminals.js';
28
28
  import { APPROVAL_DECISIONS, isValidId, socketNameFor, TMUX_CONF } from './tmux.js';
29
29
  export const EVENT_CODE_THREAD_START_REQUESTED = 'code_thread.start_requested';
30
30
  export const EVENT_CODE_THREAD_INPUT_REQUESTED = 'code_thread.input_requested';
@@ -56,6 +56,13 @@ export const HOST_INSTANCE_CAPABILITY_PREFIX = 'host_instance_';
56
56
  * sends a folder start only to a socket that declared it.
57
57
  */
58
58
  export const CODE_THREAD_FOLDERS_CAPABILITY = 'code_thread_folders';
59
+ /**
60
+ * Declared beside {@link REMOTE_TERMINAL_CAPABILITY} (only while remote control is on) by a host
61
+ * that reads `skipPermissions` in `code_thread.start_requested`: Claude Code and Codex then start
62
+ * without permission prompts (`agentCommandLine`); with remote control off they always ask. The
63
+ * server records a thread as skipping only for a socket that declared it.
64
+ */
65
+ export const CODE_THREAD_SKIP_PERMISSIONS_CAPABILITY = 'code_thread_skip_permissions';
59
66
  function text(value) {
60
67
  return typeof value === 'string' ? value : undefined;
61
68
  }
@@ -78,6 +85,8 @@ export function parseStartRequest(data) {
78
85
  branch: text(data.branch) || undefined,
79
86
  agent,
80
87
  ...(folder ? { folder, separateCopy: data.separateCopy === true } : {}),
88
+ // Only an explicit `true` skips the prompts: a server before the choice sends nothing.
89
+ ...(data.skipPermissions === true ? { skipPermissions: true } : {}),
81
90
  };
82
91
  }
83
92
  /** `code_thread.input_requested` data, or `null`. */
@@ -180,6 +189,7 @@ export class CodeHost {
180
189
  checkouts: this.checkouts,
181
190
  events: this.events,
182
191
  hookFiles: { claudeSettings: hookFiles.claudeSettings, claudeInvocationSettings: hookFiles.claudeInvocationSettings,
192
+ claudeSkipSettings: hookFiles.claudeSkipSettings, claudeInvocationSkipSettings: hookFiles.claudeInvocationSkipSettings,
183
193
  hookShim: hookFiles.shim, codexNotify: codexNotifyOverride(hookFiles.shim) },
184
194
  accountEnv: options.accountEnv,
185
195
  agentInstalled: (agent) => resolvesOnPath(AGENT_BINARIES[agent], this.env),
@@ -194,6 +204,11 @@ export class CodeHost {
194
204
  remoteTerminalAllowed() {
195
205
  return this.settings().allowRemoteTerminal;
196
206
  }
207
+ liveTerminals() {
208
+ return this.terminals
209
+ .list()
210
+ .map((record) => ({ terminalId: record.terminalId, codeThreadId: record.codeThreadId, agent: record.agent }));
211
+ }
197
212
  instanceCapability() {
198
213
  return `${HOST_INSTANCE_CAPABILITY_PREFIX}${this.instanceId}`;
199
214
  }
@@ -233,6 +248,8 @@ export class CodeHost {
233
248
  this.relay = new CodeHostRelay({
234
249
  baseUrl: this.options.baseUrl,
235
250
  apiKey: this.options.apiKey,
251
+ ...(this.options.fallbackApiKey ? { fallbackApiKey: this.options.fallbackApiKey } : {}),
252
+ ...(this.options.paused ? { dialsPaused: this.options.paused } : {}),
236
253
  machineId: this.options.machineId,
237
254
  instanceId: this.instanceId,
238
255
  machine: this.options.machine,
@@ -517,6 +534,14 @@ export class CodeHost {
517
534
  * Throws {@link UnreportedOutcomeError} when the outcome never reached the
518
535
  * server, so the harness asks for the delivery again rather than ack it.
519
536
  */
537
+ async failStart(data, reason) {
538
+ const request = parseStartRequest(data);
539
+ if (!request)
540
+ return true;
541
+ const status = await this.events.post(request.codeThreadId, event('failed', { reason }, Date.now()));
542
+ // Retryable answers (401 while the token renews, 408, 429, 5xx) leave the delivery to be retried.
543
+ return isDefinitive(status);
544
+ }
520
545
  async handleStart(data) {
521
546
  const request = parseStartRequest(data);
522
547
  if (!request)
@@ -6,8 +6,10 @@
6
6
  * number comes from a stock tool or Node's `os` module; anything that can't be
7
7
  * read is `null`, never 0. macOS sources: `vm_stat` (memory in use),
8
8
  * `sysctl kern.memorystatus_vm_pressure_level` (pressure), `pmset -g batt`
9
- * (battery), `sysctl machdep.cpu.brand_string` (spec). Elsewhere only the
10
- * `os`-module numbers are reported.
9
+ * (battery), `sysctl machdep.cpu.brand_string` (spec). Linux: `/proc/meminfo`
10
+ * (memory in use), `/proc/pressure/memory` (pressure, PSI), and
11
+ * `/sys/class/power_supply` (battery). Windows: `typeperf` performance counters
12
+ * (memory in use, commit charge → pressure) and PowerShell's `Win32_Battery`.
11
13
  */
12
14
  import os from 'node:os';
13
15
  export type MemoryPressure = 'normal' | 'elevated' | 'critical';
@@ -31,6 +33,47 @@ export declare function parsePmset(text: string): {
31
33
  batteryPct: number | null;
32
34
  onBattery: boolean | null;
33
35
  };
36
+ /**
37
+ * Linux PSI (`/proc/pressure/memory`): the share of the last 10 s some / all
38
+ * runnable tasks stalled on memory. `full` stalls mean the machine is
39
+ * thrashing; sustained `some` stalls mean reclaim is getting in the way.
40
+ */
41
+ export declare function pressureFromPsi(text: string): MemoryPressure | null;
42
+ /** Bytes in use per `/proc/meminfo`: MemTotal − MemAvailable (what `free` calls used). */
43
+ export declare function usedFromMeminfo(text: string): number | undefined;
44
+ /** `/sys/class/power_supply/BAT*`: its `capacity` and `status`, and whether any AC supply is online. */
45
+ export declare function linuxBattery(supplies: Array<{
46
+ name: string;
47
+ type?: string;
48
+ capacity?: string;
49
+ status?: string;
50
+ online?: string;
51
+ }>): {
52
+ batteryPct: number | null;
53
+ onBattery: boolean | null;
54
+ };
55
+ /**
56
+ * One `typeperf -sc 1` sample of `\Memory\Available MBytes` and
57
+ * `\Memory\% Committed Bytes In Use`: the last CSV row, quoted values.
58
+ */
59
+ export declare function parseTypeperf(text: string): {
60
+ availableMb?: number;
61
+ committedPct?: number;
62
+ };
63
+ /**
64
+ * Windows has no single pressure level, so it is read from what the memory
65
+ * manager is short of: physical memory left, and the commit charge against
66
+ * its limit (past which allocations fail outright).
67
+ */
68
+ export declare function windowsPressure(sample: {
69
+ availableMb?: number;
70
+ committedPct?: number;
71
+ }, totalBytes: number | undefined): MemoryPressure | null;
72
+ /** PowerShell `Win32_Battery` as `<BatteryStatus>,<EstimatedChargeRemaining>`; 1 = discharging. */
73
+ export declare function parseWindowsBattery(text: string): {
74
+ batteryPct: number | null;
75
+ onBattery: boolean | null;
76
+ };
34
77
  /** e.g. `Apple M3 Max · 64 GB`. */
35
78
  export declare function specFrom(brand: string | undefined, totalBytes: number | undefined): string | null;
36
79
  interface CpuSample {
@@ -51,6 +94,10 @@ export interface HostStatsSamplerOptions {
51
94
  busy: number;
52
95
  total: number;
53
96
  } | undefined;
97
+ /** Reads a small system file (`/proc`, `/sys`); `undefined` when it can't. */
98
+ readFile?: (file: string) => string | undefined;
99
+ /** Lists a directory (`/sys/class/power_supply`); empty when it can't. */
100
+ listDir?: (dir: string) => string[];
54
101
  }
55
102
  /** Samples on demand. Keeps the previous CPU sample so each call reports the interval since the last. */
56
103
  export declare class HostStatsSampler {
@@ -6,11 +6,15 @@
6
6
  * number comes from a stock tool or Node's `os` module; anything that can't be
7
7
  * read is `null`, never 0. macOS sources: `vm_stat` (memory in use),
8
8
  * `sysctl kern.memorystatus_vm_pressure_level` (pressure), `pmset -g batt`
9
- * (battery), `sysctl machdep.cpu.brand_string` (spec). Elsewhere only the
10
- * `os`-module numbers are reported.
9
+ * (battery), `sysctl machdep.cpu.brand_string` (spec). Linux: `/proc/meminfo`
10
+ * (memory in use), `/proc/pressure/memory` (pressure, PSI), and
11
+ * `/sys/class/power_supply` (battery). Windows: `typeperf` performance counters
12
+ * (memory in use, commit charge → pressure) and PowerShell's `Win32_Battery`.
11
13
  */
12
14
  import { execFile } from 'node:child_process';
15
+ import { readdirSync, readFileSync } from 'node:fs';
13
16
  import os from 'node:os';
17
+ import path from 'node:path';
14
18
  const GB = 1024 ** 3;
15
19
  const round1 = (value) => Math.round(value * 10) / 10;
16
20
  /** Bytes in use per `vm_stat`: active + wired + compressed (what Activity Monitor calls used). */
@@ -49,6 +53,96 @@ export function parsePmset(text) {
49
53
  onBattery: source === undefined ? null : source === 'Battery Power',
50
54
  };
51
55
  }
56
+ /**
57
+ * Linux PSI (`/proc/pressure/memory`): the share of the last 10 s some / all
58
+ * runnable tasks stalled on memory. `full` stalls mean the machine is
59
+ * thrashing; sustained `some` stalls mean reclaim is getting in the way.
60
+ */
61
+ export function pressureFromPsi(text) {
62
+ const avg10 = (kind) => {
63
+ const match = new RegExp(`^${kind}\\s.*?avg10=([0-9.]+)`, 'm').exec(text);
64
+ return match ? Number.parseFloat(match[1]) : undefined;
65
+ };
66
+ const some = avg10('some');
67
+ const full = avg10('full');
68
+ if (some === undefined && full === undefined)
69
+ return null;
70
+ if ((full ?? 0) >= 10 || (some ?? 0) >= 50)
71
+ return 'critical';
72
+ if ((full ?? 0) >= 2 || (some ?? 0) >= 10)
73
+ return 'elevated';
74
+ return 'normal';
75
+ }
76
+ /** Bytes in use per `/proc/meminfo`: MemTotal − MemAvailable (what `free` calls used). */
77
+ export function usedFromMeminfo(text) {
78
+ const kb = (label) => {
79
+ const match = new RegExp(`^${label}:\\s+(\\d+) kB`, 'm').exec(text);
80
+ return match ? Number.parseInt(match[1], 10) * 1024 : undefined;
81
+ };
82
+ const total = kb('MemTotal');
83
+ const available = kb('MemAvailable');
84
+ return total !== undefined && available !== undefined ? Math.max(0, total - available) : undefined;
85
+ }
86
+ /** `/sys/class/power_supply/BAT*`: its `capacity` and `status`, and whether any AC supply is online. */
87
+ export function linuxBattery(supplies) {
88
+ const battery = supplies.find((s) => (s.type ?? '').trim() === 'Battery' || /^BAT/i.test(s.name));
89
+ if (!battery)
90
+ return { batteryPct: null, onBattery: null };
91
+ const pct = Number.parseInt((battery.capacity ?? '').trim(), 10);
92
+ const acOnline = supplies.some((s) => (s.type ?? '').trim() === 'Mains' && (s.online ?? '').trim() === '1');
93
+ const status = (battery.status ?? '').trim();
94
+ return {
95
+ batteryPct: Number.isFinite(pct) ? Math.min(100, Math.max(0, pct)) : null,
96
+ onBattery: status ? status === 'Discharging' && !acOnline : null,
97
+ };
98
+ }
99
+ /**
100
+ * One `typeperf -sc 1` sample of `\Memory\Available MBytes` and
101
+ * `\Memory\% Committed Bytes In Use`: the last CSV row, quoted values.
102
+ */
103
+ export function parseTypeperf(text) {
104
+ const rows = text
105
+ .split(/\r?\n/)
106
+ .map((line) => line.trim())
107
+ .filter((line) => line.startsWith('"'));
108
+ if (rows.length < 2)
109
+ return {};
110
+ const cells = (row) => row.split('","').map((cell) => cell.replace(/^"|"$/g, ''));
111
+ const header = cells(rows[0]);
112
+ const values = cells(rows[rows.length - 1]);
113
+ const read = (needle) => {
114
+ const index = header.findIndex((name) => name.toLowerCase().includes(needle));
115
+ const value = index >= 0 ? Number.parseFloat(values[index] ?? '') : Number.NaN;
116
+ return Number.isFinite(value) ? value : undefined;
117
+ };
118
+ return { availableMb: read('available mbytes'), committedPct: read('% committed bytes in use') };
119
+ }
120
+ /**
121
+ * Windows has no single pressure level, so it is read from what the memory
122
+ * manager is short of: physical memory left, and the commit charge against
123
+ * its limit (past which allocations fail outright).
124
+ */
125
+ export function windowsPressure(sample, totalBytes) {
126
+ const { availableMb, committedPct } = sample;
127
+ if (availableMb === undefined && committedPct === undefined)
128
+ return null;
129
+ const availablePct = availableMb !== undefined && totalBytes ? (availableMb * 1024 * 1024 * 100) / totalBytes : undefined;
130
+ if ((availablePct !== undefined && availablePct < 5) || (committedPct ?? 0) >= 95)
131
+ return 'critical';
132
+ if ((availablePct !== undefined && availablePct < 12) || (committedPct ?? 0) >= 85)
133
+ return 'elevated';
134
+ return 'normal';
135
+ }
136
+ /** PowerShell `Win32_Battery` as `<BatteryStatus>,<EstimatedChargeRemaining>`; 1 = discharging. */
137
+ export function parseWindowsBattery(text) {
138
+ const match = /(\d+)\s*,\s*(\d+)/.exec(text.trim());
139
+ if (!match)
140
+ return { batteryPct: null, onBattery: null };
141
+ return {
142
+ batteryPct: Math.min(100, Number.parseInt(match[2], 10)),
143
+ onBattery: Number.parseInt(match[1], 10) === 1,
144
+ };
145
+ }
52
146
  /** e.g. `Apple M3 Max · 64 GB`. */
53
147
  export function specFrom(brand, totalBytes) {
54
148
  const parts = [brand?.trim(), totalBytes ? `${Math.round(totalBytes / GB)} GB` : undefined].filter(Boolean);
@@ -76,6 +170,25 @@ const defaultRun = (file, args) => new Promise((resolve) => {
76
170
  resolve(error ? undefined : String(stdout));
77
171
  });
78
172
  });
173
+ const defaultReadFile = (file) => {
174
+ try {
175
+ return readFileSync(file, 'utf8');
176
+ }
177
+ catch {
178
+ return undefined;
179
+ }
180
+ };
181
+ const defaultListDir = (dir) => {
182
+ try {
183
+ return readdirSync(dir);
184
+ }
185
+ catch {
186
+ return [];
187
+ }
188
+ };
189
+ const POWER_SUPPLY_DIR = '/sys/class/power_supply';
190
+ const TYPEPERF_COUNTERS = ['\\Memory\\Available MBytes', '\\Memory\\% Committed Bytes In Use'];
191
+ const WINDOWS_BATTERY_SCRIPT = 'Get-CimInstance Win32_Battery | Select-Object -First 1 | ForEach-Object { "$($_.BatteryStatus),$($_.EstimatedChargeRemaining)" }';
79
192
  /** Samples on demand. Keeps the previous CPU sample so each call reports the interval since the last. */
80
193
  export class HostStatsSampler {
81
194
  options;
@@ -135,6 +248,44 @@ export class HostStatsSampler {
135
248
  this.spec = specFrom(brand || undefined, total);
136
249
  }
137
250
  }
251
+ else if (this.platform === 'linux') {
252
+ const read = this.options.readFile ?? defaultReadFile;
253
+ const meminfo = read('/proc/meminfo');
254
+ used = meminfo ? usedFromMeminfo(meminfo) : undefined;
255
+ if (used === undefined) {
256
+ const free = safe(this.freemem);
257
+ used = total !== undefined && free !== undefined ? total - free : undefined;
258
+ }
259
+ const psi = read('/proc/pressure/memory');
260
+ memoryPressure = psi ? pressureFromPsi(psi) : null;
261
+ const list = this.options.listDir ?? defaultListDir;
262
+ battery = linuxBattery(list(POWER_SUPPLY_DIR).map((name) => {
263
+ const field = (key) => read(path.join(POWER_SUPPLY_DIR, name, key));
264
+ return { name, type: field('type'), capacity: field('capacity'), status: field('status'), online: field('online') };
265
+ }));
266
+ if (this.spec === undefined)
267
+ this.spec = specFrom(safe(() => os.cpus()[0]?.model), total);
268
+ }
269
+ else if (this.platform === 'win32') {
270
+ const [counters, power] = await Promise.all([
271
+ this.run('typeperf', [...TYPEPERF_COUNTERS, '-sc', '1']),
272
+ this.run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_BATTERY_SCRIPT]),
273
+ ]);
274
+ const sample = counters ? parseTypeperf(counters) : {};
275
+ used =
276
+ sample.availableMb !== undefined && total !== undefined
277
+ ? Math.max(0, total - sample.availableMb * 1024 * 1024)
278
+ : undefined;
279
+ if (used === undefined) {
280
+ const free = safe(this.freemem);
281
+ used = total !== undefined && free !== undefined ? total - free : undefined;
282
+ }
283
+ memoryPressure = windowsPressure(sample, total);
284
+ if (power)
285
+ battery = parseWindowsBattery(power);
286
+ if (this.spec === undefined)
287
+ this.spec = specFrom(safe(() => os.cpus()[0]?.model), total);
288
+ }
138
289
  else {
139
290
  const free = safe(this.freemem);
140
291
  used = total !== undefined && free !== undefined ? total - free : undefined;
@@ -53,7 +53,12 @@ export interface DialFrame {
53
53
  }
54
54
  /** A `dial` frame, or `null` when it is not one this host can act on. */
55
55
  export declare function parseDial(frame: Record<string, unknown>): DialFrame | null;
56
- /** Pipe one terminal stream over one open socket until either side ends. */
56
+ /**
57
+ * Pipe one terminal stream over one open socket until either side ends.
58
+ *
59
+ * Text controls in: `resize`, `input`, `hello`, `claim`, `history` (answered with the pane's
60
+ * scroll-back, only once the viewer said hello: an older page never sees one), `ping`.
61
+ */
57
62
  export declare function pipeStream(ws: WebSocket, stream: TerminalStream, onClosed: (reason: string) => void): void;
58
63
  /** Whose pairing this is, as the relay's `auth_ok` names it. */
59
64
  export interface PairingIdentity {
@@ -86,6 +91,14 @@ export declare function parseViewerVerdict(frame: Record<string, unknown>): View
86
91
  export interface CodeHostRelayOptions {
87
92
  baseUrl: string;
88
93
  apiKey: string;
94
+ /**
95
+ * A second credential for a relay that refuses `apiKey`: `prohost host`
96
+ * authenticates as the computer, and falls back to the adopted agent
97
+ * pairing's key on a server that doesn't take computer keys here yet.
98
+ */
99
+ fallbackApiKey?: string;
100
+ /** No new viewer dials in while this says so (the computer is paused); open streams carry on. */
101
+ dialsPaused?: () => boolean;
89
102
  machineId: string;
90
103
  /**
91
104
  * This home's CLI instance (see `codeHostInstanceId`): its presence and control
@@ -130,6 +143,8 @@ export declare class CodeHostRelay {
130
143
  /** Bumped by {@link dropStreams}: a dial begun before a drop never opens. */
131
144
  private generation;
132
145
  private refused;
146
+ /** Authenticating with {@link CodeHostRelayOptions.fallbackApiKey} after `apiKey` was refused. */
147
+ private usingFallback;
133
148
  private readonly log;
134
149
  private readonly Ctor;
135
150
  private readonly setTimer;