@prohost/cli 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +265 -1
- package/dist/agent/accounts.d.ts +11 -0
- package/dist/agent/accounts.js +12 -1
- package/dist/agent/api.d.ts +6 -0
- package/dist/agent/api.js +5 -0
- package/dist/agent/claude.d.ts +6 -0
- package/dist/agent/claude.js +2 -2
- package/dist/agent/daemon.d.ts +33 -0
- package/dist/agent/daemon.js +56 -0
- package/dist/agent/prompt.d.ts +12 -0
- package/dist/agent/prompt.js +5 -0
- package/dist/agent/run.d.ts +110 -1
- package/dist/agent/run.js +198 -22
- package/dist/codehost/history.d.ts +80 -0
- package/dist/codehost/history.js +98 -0
- package/dist/codehost/hooks.d.ts +10 -2
- package/dist/codehost/hooks.js +15 -4
- package/dist/codehost/host.d.ts +32 -0
- package/dist/codehost/host.js +26 -1
- package/dist/codehost/host_stats.d.ts +49 -2
- package/dist/codehost/host_stats.js +153 -2
- package/dist/codehost/relay.d.ts +16 -1
- package/dist/codehost/relay.js +69 -3
- package/dist/codehost/sizing.d.ts +22 -4
- package/dist/codehost/sizing.js +46 -6
- package/dist/codehost/terminals.d.ts +33 -2
- package/dist/codehost/terminals.js +92 -30
- package/dist/codehost/tmux.d.ts +6 -1
- package/dist/codehost/tmux.js +3 -0
- package/dist/host/api.d.ts +82 -0
- package/dist/host/api.js +122 -0
- package/dist/host/capacity.d.ts +41 -0
- package/dist/host/capacity.js +48 -0
- package/dist/host/command.d.ts +59 -0
- package/dist/host/command.js +584 -0
- package/dist/host/computer.d.ts +120 -0
- package/dist/host/computer.js +291 -0
- package/dist/host/events.d.ts +24 -0
- package/dist/host/events.js +47 -0
- package/dist/host/install.d.ts +152 -0
- package/dist/host/install.js +755 -0
- package/dist/host/ledger.d.ts +52 -0
- package/dist/host/ledger.js +131 -0
- package/dist/host/legacy.d.ts +38 -0
- package/dist/host/legacy.js +115 -0
- package/dist/host/logins.d.ts +109 -0
- package/dist/host/logins.js +190 -0
- package/dist/host/migrate.d.ts +165 -0
- package/dist/host/migrate.js +364 -0
- package/dist/host/pair.d.ts +62 -0
- package/dist/host/pair.js +219 -0
- package/dist/host/peak_memory.d.ts +44 -0
- package/dist/host/peak_memory.js +139 -0
- package/dist/host/scheduler.d.ts +86 -0
- package/dist/host/scheduler.js +186 -0
- package/dist/host/service.d.ts +114 -0
- package/dist/host/service.js +1027 -0
- package/dist/host/session_move.d.ts +48 -0
- package/dist/host/session_move.js +106 -0
- package/dist/host/skills.d.ts +29 -0
- package/dist/host/skills.js +135 -0
- package/dist/host/status.d.ts +91 -0
- package/dist/host/status.js +130 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +47 -3
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/codehost/hooks.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
77
|
-
|
|
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;
|
package/dist/codehost/hooks.js
CHANGED
|
@@ -123,8 +123,14 @@ export function hookShimScript(nodePath, cliEntrypoint) {
|
|
|
123
123
|
'',
|
|
124
124
|
].join('\n');
|
|
125
125
|
}
|
|
126
|
-
/**
|
|
127
|
-
|
|
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
|
-
|
|
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) {
|
package/dist/codehost/host.d.ts
CHANGED
|
@@ -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>;
|
package/dist/codehost/host.js
CHANGED
|
@@ -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).
|
|
10
|
-
* `
|
|
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).
|
|
10
|
-
* `
|
|
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;
|
package/dist/codehost/relay.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|