@prohost/cli 0.13.2 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +129 -0
  3. package/dist/agent/claude.d.ts +28 -0
  4. package/dist/agent/claude.js +83 -4
  5. package/dist/agent/command.d.ts +8 -0
  6. package/dist/agent/command.js +40 -7
  7. package/dist/agent/daemon.d.ts +3 -1
  8. package/dist/agent/daemon.js +1 -2
  9. package/dist/agent/run.d.ts +30 -1
  10. package/dist/agent/run.js +311 -16
  11. package/dist/agent/runtime_status.d.ts +99 -3
  12. package/dist/agent/runtime_status.js +173 -5
  13. package/dist/agent/worktrees.d.ts +2 -0
  14. package/dist/agent/worktrees.js +3 -2
  15. package/dist/codehost/activity.d.ts +64 -0
  16. package/dist/codehost/activity.js +135 -0
  17. package/dist/codehost/agents.d.ts +31 -0
  18. package/dist/codehost/agents.js +57 -0
  19. package/dist/codehost/checkouts.d.ts +130 -0
  20. package/dist/codehost/checkouts.js +270 -0
  21. package/dist/codehost/claude_trust.d.ts +40 -0
  22. package/dist/codehost/claude_trust.js +129 -0
  23. package/dist/codehost/command.d.ts +25 -0
  24. package/dist/codehost/command.js +136 -0
  25. package/dist/codehost/decision.d.ts +156 -0
  26. package/dist/codehost/decision.js +553 -0
  27. package/dist/codehost/events.d.ts +49 -0
  28. package/dist/codehost/events.js +87 -0
  29. package/dist/codehost/folders.d.ts +122 -0
  30. package/dist/codehost/folders.js +317 -0
  31. package/dist/codehost/hooks.d.ts +178 -0
  32. package/dist/codehost/hooks.js +726 -0
  33. package/dist/codehost/host.d.ts +290 -0
  34. package/dist/codehost/host.js +625 -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 +170 -0
  48. package/dist/codehost/relay.js +548 -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 +163 -0
  52. package/dist/codehost/sizing.js +394 -0
  53. package/dist/codehost/terminals.d.ts +390 -0
  54. package/dist/codehost/terminals.js +965 -0
  55. package/dist/codehost/tmux.d.ts +196 -0
  56. package/dist/codehost/tmux.js +391 -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,71 @@
1
+ /**
2
+ * How busy this machine is — the "Machine" card on a Code thread.
3
+ *
4
+ * Sent only while Code-thread hosting is on (the owner's opt-in), in the
5
+ * heartbeat's `runtime_status.host` and on the relay's control socket. Every
6
+ * number comes from a stock tool or Node's `os` module; anything that can't be
7
+ * read is `null`, never 0. macOS sources: `vm_stat` (memory in use),
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.
11
+ */
12
+ import os from 'node:os';
13
+ export type MemoryPressure = 'normal' | 'elevated' | 'critical';
14
+ export interface HostStats {
15
+ cpuPct: number | null;
16
+ memUsedGb: number | null;
17
+ memTotalGb: number | null;
18
+ memoryPressure: MemoryPressure | null;
19
+ batteryPct: number | null;
20
+ onBattery: boolean | null;
21
+ buildSlotsBusy: number | null;
22
+ buildSlotsTotal: number | null;
23
+ spec: string | null;
24
+ }
25
+ /** Bytes in use per `vm_stat`: active + wired + compressed (what Activity Monitor calls used). */
26
+ export declare function parseVmStat(text: string): number | undefined;
27
+ /** `kern.memorystatus_vm_pressure_level`: 1 normal, 2 warn, 4 critical. */
28
+ export declare function pressureFromLevel(raw: string): MemoryPressure | null;
29
+ /** `pmset -g batt`. A desktop Mac (no battery) reports AC power and no percentage. */
30
+ export declare function parsePmset(text: string): {
31
+ batteryPct: number | null;
32
+ onBattery: boolean | null;
33
+ };
34
+ /** e.g. `Apple M3 Max · 64 GB`. */
35
+ export declare function specFrom(brand: string | undefined, totalBytes: number | undefined): string | null;
36
+ interface CpuSample {
37
+ idle: number;
38
+ total: number;
39
+ }
40
+ /** Busy share between two samples, 0–100; `undefined` when the clock did not move. */
41
+ export declare function cpuPctBetween(before: CpuSample, after: CpuSample): number | undefined;
42
+ export type CommandRunner = (file: string, args: string[]) => Promise<string | undefined>;
43
+ export interface HostStatsSamplerOptions {
44
+ run?: CommandRunner;
45
+ platform?: NodeJS.Platform;
46
+ cpus?: () => os.CpuInfo[];
47
+ totalmem?: () => number;
48
+ freemem?: () => number;
49
+ /** Agent runs executing / allowed at once on this host. */
50
+ slots?: () => {
51
+ busy: number;
52
+ total: number;
53
+ } | undefined;
54
+ }
55
+ /** Samples on demand. Keeps the previous CPU sample so each call reports the interval since the last. */
56
+ export declare class HostStatsSampler {
57
+ private readonly options;
58
+ private readonly run;
59
+ private readonly platform;
60
+ private readonly cpus;
61
+ private readonly totalmem;
62
+ private readonly freemem;
63
+ private previous;
64
+ private spec;
65
+ private last;
66
+ constructor(options?: HostStatsSamplerOptions);
67
+ /** The most recent sample, if one has been taken. */
68
+ latest(): HostStats | undefined;
69
+ sample(): Promise<HostStats>;
70
+ }
71
+ export {};
@@ -0,0 +1,166 @@
1
+ /**
2
+ * How busy this machine is — the "Machine" card on a Code thread.
3
+ *
4
+ * Sent only while Code-thread hosting is on (the owner's opt-in), in the
5
+ * heartbeat's `runtime_status.host` and on the relay's control socket. Every
6
+ * number comes from a stock tool or Node's `os` module; anything that can't be
7
+ * read is `null`, never 0. macOS sources: `vm_stat` (memory in use),
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.
11
+ */
12
+ import { execFile } from 'node:child_process';
13
+ import os from 'node:os';
14
+ const GB = 1024 ** 3;
15
+ const round1 = (value) => Math.round(value * 10) / 10;
16
+ /** Bytes in use per `vm_stat`: active + wired + compressed (what Activity Monitor calls used). */
17
+ export function parseVmStat(text) {
18
+ const pageSize = Number.parseInt(/page size of (\d+) bytes/.exec(text)?.[1] ?? '', 10);
19
+ if (!Number.isFinite(pageSize) || pageSize <= 0)
20
+ return undefined;
21
+ const pages = (label) => {
22
+ const match = new RegExp(`${label}:\\s+(\\d+)`).exec(text);
23
+ return match ? Number.parseInt(match[1], 10) : undefined;
24
+ };
25
+ const active = pages('Pages active');
26
+ const wired = pages('Pages wired down');
27
+ const compressed = pages('Pages occupied by compressor') ?? 0;
28
+ if (active === undefined || wired === undefined)
29
+ return undefined;
30
+ return (active + wired + compressed) * pageSize;
31
+ }
32
+ /** `kern.memorystatus_vm_pressure_level`: 1 normal, 2 warn, 4 critical. */
33
+ export function pressureFromLevel(raw) {
34
+ const level = Number.parseInt(raw.trim(), 10);
35
+ if (level === 1)
36
+ return 'normal';
37
+ if (level === 2)
38
+ return 'elevated';
39
+ if (level === 4)
40
+ return 'critical';
41
+ return null;
42
+ }
43
+ /** `pmset -g batt`. A desktop Mac (no battery) reports AC power and no percentage. */
44
+ export function parsePmset(text) {
45
+ const source = /Now drawing from '([^']+)'/.exec(text)?.[1];
46
+ const pct = /(\d{1,3})%/.exec(text)?.[1];
47
+ return {
48
+ batteryPct: pct === undefined ? null : Math.min(100, Number.parseInt(pct, 10)),
49
+ onBattery: source === undefined ? null : source === 'Battery Power',
50
+ };
51
+ }
52
+ /** e.g. `Apple M3 Max · 64 GB`. */
53
+ export function specFrom(brand, totalBytes) {
54
+ const parts = [brand?.trim(), totalBytes ? `${Math.round(totalBytes / GB)} GB` : undefined].filter(Boolean);
55
+ return parts.length > 0 ? parts.join(' · ') : null;
56
+ }
57
+ function cpuSample(cpus) {
58
+ let idle = 0;
59
+ let total = 0;
60
+ for (const cpu of cpus) {
61
+ const t = cpu.times;
62
+ idle += t.idle;
63
+ total += t.user + t.nice + t.sys + t.irq + t.idle;
64
+ }
65
+ return { idle, total };
66
+ }
67
+ /** Busy share between two samples, 0–100; `undefined` when the clock did not move. */
68
+ export function cpuPctBetween(before, after) {
69
+ const total = after.total - before.total;
70
+ if (total <= 0)
71
+ return undefined;
72
+ return round1(Math.min(100, Math.max(0, (1 - (after.idle - before.idle) / total) * 100)));
73
+ }
74
+ const defaultRun = (file, args) => new Promise((resolve) => {
75
+ execFile(file, args, { timeout: 2_000, maxBuffer: 256 * 1024 }, (error, stdout) => {
76
+ resolve(error ? undefined : String(stdout));
77
+ });
78
+ });
79
+ /** Samples on demand. Keeps the previous CPU sample so each call reports the interval since the last. */
80
+ export class HostStatsSampler {
81
+ options;
82
+ run;
83
+ platform;
84
+ cpus;
85
+ totalmem;
86
+ freemem;
87
+ previous;
88
+ spec;
89
+ last;
90
+ constructor(options = {}) {
91
+ this.options = options;
92
+ this.run = options.run ?? defaultRun;
93
+ this.platform = options.platform ?? process.platform;
94
+ this.cpus = options.cpus ?? os.cpus;
95
+ this.totalmem = options.totalmem ?? os.totalmem;
96
+ this.freemem = options.freemem ?? os.freemem;
97
+ try {
98
+ this.previous = cpuSample(this.cpus());
99
+ }
100
+ catch {
101
+ this.previous = undefined;
102
+ }
103
+ }
104
+ /** The most recent sample, if one has been taken. */
105
+ latest() {
106
+ return this.last;
107
+ }
108
+ async sample() {
109
+ let cpuPct = null;
110
+ try {
111
+ const now = cpuSample(this.cpus());
112
+ if (this.previous)
113
+ cpuPct = cpuPctBetween(this.previous, now) ?? null;
114
+ this.previous = now;
115
+ }
116
+ catch {
117
+ cpuPct = null;
118
+ }
119
+ const total = safe(this.totalmem);
120
+ let used;
121
+ let memoryPressure = null;
122
+ let battery = { batteryPct: null, onBattery: null };
123
+ if (this.platform === 'darwin') {
124
+ const [vm, pressure, pmset] = await Promise.all([
125
+ this.run('vm_stat', []),
126
+ this.run('sysctl', ['-n', 'kern.memorystatus_vm_pressure_level']),
127
+ this.run('pmset', ['-g', 'batt']),
128
+ ]);
129
+ used = vm ? parseVmStat(vm) : undefined;
130
+ memoryPressure = pressure ? pressureFromLevel(pressure) : null;
131
+ if (pmset)
132
+ battery = parsePmset(pmset);
133
+ if (this.spec === undefined) {
134
+ const brand = (await this.run('sysctl', ['-n', 'machdep.cpu.brand_string']))?.trim();
135
+ this.spec = specFrom(brand || undefined, total);
136
+ }
137
+ }
138
+ else {
139
+ const free = safe(this.freemem);
140
+ used = total !== undefined && free !== undefined ? total - free : undefined;
141
+ if (this.spec === undefined)
142
+ this.spec = specFrom(safe(() => os.cpus()[0]?.model), total);
143
+ }
144
+ const slots = this.options.slots?.();
145
+ this.last = {
146
+ cpuPct,
147
+ memUsedGb: used === undefined ? null : round1(used / GB),
148
+ memTotalGb: total === undefined ? null : round1(total / GB),
149
+ memoryPressure,
150
+ batteryPct: battery.batteryPct,
151
+ onBattery: battery.onBattery,
152
+ buildSlotsBusy: slots ? slots.busy : null,
153
+ buildSlotsTotal: slots ? slots.total : null,
154
+ spec: this.spec ?? null,
155
+ };
156
+ return this.last;
157
+ }
158
+ }
159
+ function safe(read) {
160
+ try {
161
+ return read();
162
+ }
163
+ catch {
164
+ return undefined;
165
+ }
166
+ }
@@ -0,0 +1,12 @@
1
+ export declare const INVOCATION_PROTOCOL_ENV = "PROHOST_INVOCATION_PROTOCOL";
2
+ /** Must finish successfully before the critical PreToolUse hook returns. */
3
+ export declare function recordInvocation(terminalId: string, input: Record<string, unknown>, env: NodeJS.ProcessEnv): void;
4
+ /**
5
+ * The held invocation is among these IDs because its successful PreToolUse completed first.
6
+ * Retain all same-tool IDs, including identical parallel calls; a parent agent may prompt too.
7
+ */
8
+ export declare function permissionInvocationIds(terminalId: string, input: Record<string, unknown>, env: NodeJS.ProcessEnv): string[];
9
+ /** Turn/user progress retires both waiters and the IDs from the previous turn. */
10
+ export declare function forgetInvocations(terminalId: string, env: NodeJS.ProcessEnv): void;
11
+ /** Failure-blocking command hooks first became a supported contract in this version. */
12
+ export declare function supportsInvocationProtocol(version: string | undefined): boolean;
@@ -0,0 +1,82 @@
1
+ /** Invocation IDs recorded before Claude Code can ask permission; no transcript timing inference. */
2
+ import { createHash } from 'node:crypto';
3
+ import { readdirSync, readFileSync, rmSync } from 'node:fs';
4
+ import path from 'node:path';
5
+ import { codeHostDir, writePrivateJson } from './settings.js';
6
+ import { isValidId } from './tmux.js';
7
+ export const INVOCATION_PROTOCOL_ENV = 'PROHOST_INVOCATION_PROTOCOL';
8
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
9
+ const opaqueId = (value) => typeof value === 'string' && value.length > 0 && value.length <= 512 && !value.includes('\0');
10
+ const transcriptPath = (value) => typeof value === 'string' && path.isAbsolute(value) && value.endsWith('.jsonl');
11
+ function terminalDir(terminalId, env) {
12
+ if (!isValidId(terminalId))
13
+ throw new Error('Invalid invocation terminal');
14
+ return path.join(codeHostDir(env), 'invocations', terminalId);
15
+ }
16
+ function parseInvocation(input) {
17
+ if (!opaqueId(input.session_id) || !opaqueId(input.tool_use_id) ||
18
+ !opaqueId(input.tool_name) || !transcriptPath(input.transcript_path)) {
19
+ throw new Error('Missing invocation identity');
20
+ }
21
+ return {
22
+ sessionId: input.session_id,
23
+ toolUseId: input.tool_use_id,
24
+ toolName: input.tool_name,
25
+ transcript: path.normalize(input.transcript_path),
26
+ };
27
+ }
28
+ /** Must finish successfully before the critical PreToolUse hook returns. */
29
+ export function recordInvocation(terminalId, input, env) {
30
+ const invocation = parseInvocation(input);
31
+ // Child results may live outside transcript_path; their permission waiters require turn progress.
32
+ if (input.agent_id !== undefined)
33
+ return;
34
+ const file = path.join(terminalDir(terminalId, env), hash(invocation.sessionId), `${hash(invocation.toolUseId)}.json`);
35
+ writePrivateJson(file, invocation);
36
+ }
37
+ /**
38
+ * The held invocation is among these IDs because its successful PreToolUse completed first.
39
+ * Retain all same-tool IDs, including identical parallel calls; a parent agent may prompt too.
40
+ */
41
+ export function permissionInvocationIds(terminalId, input, env) {
42
+ if (!opaqueId(input.session_id) || !opaqueId(input.tool_name) || !transcriptPath(input.transcript_path)) {
43
+ throw new Error('Missing permission invocation scope');
44
+ }
45
+ const dir = path.join(terminalDir(terminalId, env), hash(input.session_id));
46
+ const transcript = path.normalize(input.transcript_path);
47
+ const ids = [];
48
+ for (const filename of readdirSync(dir)) {
49
+ if (!filename.endsWith('.json'))
50
+ continue;
51
+ const raw = JSON.parse(readFileSync(path.join(dir, filename), 'utf8'));
52
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
53
+ throw new Error('Invalid invocation record');
54
+ const value = raw;
55
+ const invocation = parseInvocation({
56
+ session_id: value.sessionId, tool_use_id: value.toolUseId,
57
+ tool_name: value.toolName, transcript_path: value.transcript,
58
+ });
59
+ if (invocation.sessionId !== input.session_id || filename !== `${hash(invocation.toolUseId)}.json`) {
60
+ throw new Error('Invocation scope mismatch');
61
+ }
62
+ if (invocation.transcript === transcript &&
63
+ (invocation.toolName === input.tool_name || ['Task', 'Agent'].includes(invocation.toolName))) {
64
+ ids.push(invocation.toolUseId);
65
+ }
66
+ }
67
+ if (ids.length === 0)
68
+ throw new Error('No recorded permission invocation');
69
+ return ids.sort();
70
+ }
71
+ /** Turn/user progress retires both waiters and the IDs from the previous turn. */
72
+ export function forgetInvocations(terminalId, env) {
73
+ rmSync(terminalDir(terminalId, env), { recursive: true, force: true });
74
+ }
75
+ /** Failure-blocking command hooks first became a supported contract in this version. */
76
+ export function supportsInvocationProtocol(version) {
77
+ const match = /^(\d+)\.(\d+)\.(\d+)(?:\s|$)/.exec(version?.trim() ?? '');
78
+ if (!match)
79
+ return false;
80
+ const [major, minor, patch] = match.slice(1).map(Number);
81
+ return major > 2 || (major === 2 && (minor > 1 || (minor === 1 && patch >= 295)));
82
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Deliveries of `code_thread.input_requested` / `.approval_decided` this
3
+ * machine has acted on, kept across restarts.
4
+ *
5
+ * Typing into a pane is not idempotent: a reply whose ack was lost in a
6
+ * reconnect is redelivered under the same `delivery_id`, and acting on it again
7
+ * would submit it twice — or press a stale approval's keys on a later prompt.
8
+ * So a delivery is recorded *before* its keys are sent (at most once: a crash
9
+ * in between loses one reply, which the person can resend, rather than typing
10
+ * it twice), and a recorded one is acknowledged without acting.
11
+ */
12
+ /** Plenty for any redelivery window; old ids fall off the front. */
13
+ export declare const MAX_REMEMBERED_DELIVERIES = 1000;
14
+ export declare function deliveryLedgerPath(env?: NodeJS.ProcessEnv): string;
15
+ export declare class DeliveryLedger {
16
+ private readonly env;
17
+ private ids;
18
+ private readonly known;
19
+ /** Claimed deliveries that ended in a refusal the server hands back, by id: a repeat says it again. */
20
+ private readonly refused;
21
+ constructor(env?: NodeJS.ProcessEnv);
22
+ /** Whether a prior attempt has an outcome to replay, without claiming a fresh delivery. */
23
+ has(deliveryId: string): boolean;
24
+ /** True the first time `deliveryId` is claimed (and records it); false for a repeat. */
25
+ claim(deliveryId: string): boolean;
26
+ /**
27
+ * Forget a claim that typed nothing (refused before any key): a redelivery is
28
+ * judged afresh, so a refusal whose ack was lost is never answered "done".
29
+ */
30
+ release(deliveryId: string): void;
31
+ /** A claimed delivery that may have typed something and was then refused: a repeat refuses it the same way. */
32
+ refuse(deliveryId: string, reason: string): void;
33
+ refusalFor(deliveryId: string): string | undefined;
34
+ private save;
35
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Deliveries of `code_thread.input_requested` / `.approval_decided` this
3
+ * machine has acted on, kept across restarts.
4
+ *
5
+ * Typing into a pane is not idempotent: a reply whose ack was lost in a
6
+ * reconnect is redelivered under the same `delivery_id`, and acting on it again
7
+ * would submit it twice — or press a stale approval's keys on a later prompt.
8
+ * So a delivery is recorded *before* its keys are sent (at most once: a crash
9
+ * in between loses one reply, which the person can resend, rather than typing
10
+ * it twice), and a recorded one is acknowledged without acting.
11
+ */
12
+ import path from 'node:path';
13
+ import { codeHostDir, readJsonObject, writePrivateJson } from './settings.js';
14
+ /** Plenty for any redelivery window; old ids fall off the front. */
15
+ export const MAX_REMEMBERED_DELIVERIES = 1_000;
16
+ export function deliveryLedgerPath(env = process.env) {
17
+ return path.join(codeHostDir(env), 'state', 'deliveries.json');
18
+ }
19
+ export class DeliveryLedger {
20
+ env;
21
+ ids;
22
+ known;
23
+ /** Claimed deliveries that ended in a refusal the server hands back, by id: a repeat says it again. */
24
+ refused = new Map();
25
+ constructor(env = process.env) {
26
+ this.env = env;
27
+ const file = readJsonObject(deliveryLedgerPath(env));
28
+ const raw = file?.ids;
29
+ this.ids = Array.isArray(raw) ? raw.filter((id) => typeof id === 'string') : [];
30
+ this.known = new Set(this.ids);
31
+ const refused = file?.refused;
32
+ if (refused && typeof refused === 'object') {
33
+ for (const [id, reason] of Object.entries(refused)) {
34
+ if (this.known.has(id) && typeof reason === 'string')
35
+ this.refused.set(id, reason);
36
+ }
37
+ }
38
+ }
39
+ /** Whether a prior attempt has an outcome to replay, without claiming a fresh delivery. */
40
+ has(deliveryId) {
41
+ return this.known.has(deliveryId);
42
+ }
43
+ /** True the first time `deliveryId` is claimed (and records it); false for a repeat. */
44
+ claim(deliveryId) {
45
+ if (!deliveryId || this.known.has(deliveryId))
46
+ return false;
47
+ this.known.add(deliveryId);
48
+ this.ids.push(deliveryId);
49
+ if (this.ids.length > MAX_REMEMBERED_DELIVERIES) {
50
+ for (const dropped of this.ids.splice(0, this.ids.length - MAX_REMEMBERED_DELIVERIES)) {
51
+ this.known.delete(dropped);
52
+ this.refused.delete(dropped);
53
+ }
54
+ }
55
+ this.save();
56
+ return true;
57
+ }
58
+ /**
59
+ * Forget a claim that typed nothing (refused before any key): a redelivery is
60
+ * judged afresh, so a refusal whose ack was lost is never answered "done".
61
+ */
62
+ release(deliveryId) {
63
+ if (!this.known.delete(deliveryId))
64
+ return;
65
+ this.ids = this.ids.filter((id) => id !== deliveryId);
66
+ this.refused.delete(deliveryId);
67
+ this.save();
68
+ }
69
+ /** A claimed delivery that may have typed something and was then refused: a repeat refuses it the same way. */
70
+ refuse(deliveryId, reason) {
71
+ if (!this.known.has(deliveryId))
72
+ return;
73
+ this.refused.set(deliveryId, reason);
74
+ this.save();
75
+ }
76
+ refusalFor(deliveryId) {
77
+ return this.refused.get(deliveryId);
78
+ }
79
+ save() {
80
+ try {
81
+ writePrivateJson(deliveryLedgerPath(this.env), {
82
+ version: 1,
83
+ ids: this.ids,
84
+ refused: Object.fromEntries(this.refused),
85
+ });
86
+ }
87
+ catch {
88
+ /* in memory it still dedupes for this process */
89
+ }
90
+ }
91
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The desktop app's fast path to this machine's Code-thread terminals.
3
+ *
4
+ * The ProhostAI desktop app runs this CLI with its own `$PROHOST_HOME`, so it
5
+ * can read `code-host/local.json` and attach over loopback instead of a round
6
+ * trip through the relay. Served whenever this Mac hosts Code threads, with the
7
+ * remote switch on or off — the desktop app on this Mac is how its owner opens
8
+ * the terminals here at all. What the switch decides is which attaches it
9
+ * admits:
10
+ *
11
+ * - **Remote on:** any attach whose viewer token the relay vouches for.
12
+ * - **Remote off:** only one the relay vouches for as `local: true` — a token
13
+ * minted for this Mac's own desktop app (`loc`), which the relay refuses for
14
+ * any relayed attach. A verdict without `local` (an older server) is not
15
+ * local. Turning the switch off ends every viewer admitted as remote.
16
+ *
17
+ * Who can connect:
18
+ *
19
+ * - **Loopback only** (`127.0.0.1`, ephemeral port), and the `Host` header must
20
+ * name it — a DNS-rebinding page can't pass for it.
21
+ * - **Never a browser:** any request carrying an `Origin` header is refused.
22
+ * Browsers always send one on a WebSocket handshake; Electron's main
23
+ * process (Node's WebSocket) sends none.
24
+ * - **The token in `local.json`** (0600, owner-only, new every start), in the
25
+ * first frame, compared in constant time.
26
+ *
27
+ * After `ready` it speaks the relay's viewer protocol: binary frames both
28
+ * ways, `{"type":"input"}`, `{"type":"resize"}`, ping/pong, and who sizes the
29
+ * window (`{"type":"hello"}` / `{"type":"claim"}` in, `{"type":"sizer"}` out to
30
+ * a viewer that said hello; `sizing.ts`).
31
+ */
32
+ import http from 'node:http';
33
+ import type { Duplex } from 'node:stream';
34
+ import type { ViewerVerdict } from './relay.js';
35
+ import type { CodeHostSettings } from './settings.js';
36
+ import type { CodeTerminals } from './terminals.js';
37
+ import type { TerminalWindow } from './tmux.js';
38
+ export declare const LOCAL_TERMINAL_PATH = "/code-host/terminal";
39
+ export declare const LOCAL_TERMINALS_PATH = "/code-host/terminals";
40
+ export declare function localConfigPath(env?: NodeJS.ProcessEnv): string;
41
+ export declare function tokensMatch(expected: string, given: unknown): boolean;
42
+ /** Why a handshake must be refused, or `undefined` to accept it. */
43
+ export declare function refuseHandshake(headers: http.IncomingHttpHeaders, url: string | undefined, port: number): {
44
+ status: number;
45
+ reason: string;
46
+ } | undefined;
47
+ export interface LocalAuth {
48
+ terminalId: string;
49
+ window: TerminalWindow;
50
+ cols: number;
51
+ rows: number;
52
+ /** The short-lived relay viewer token the page holds; verified through the relay, never trusted as-is. */
53
+ viewerToken: string;
54
+ }
55
+ export declare function parseLocalAuth(text: string, token: string): LocalAuth | {
56
+ error: string;
57
+ };
58
+ export interface LocalServerOptions {
59
+ env?: NodeJS.ProcessEnv;
60
+ /**
61
+ * The workspace this machine is paired with, written to `local.json`: the
62
+ * desktop app attaches only while it is signed in to that same workspace.
63
+ */
64
+ accountId: string;
65
+ /** The pairing's owner, written beside it: only that user's app window attaches. */
66
+ ownerUserId: string;
67
+ settings: () => CodeHostSettings;
68
+ terminals: Pick<CodeTerminals, 'attach' | 'list'>;
69
+ /**
70
+ * Who the attach is for, as the relay vouches for it (`CodeHostRelay.verifyViewer`):
71
+ * the same owner-only, flag-gated check a relay viewer gets.
72
+ */
73
+ verifyViewer: (token: string) => Promise<ViewerVerdict>;
74
+ log?: (line: string) => void;
75
+ }
76
+ export declare class LocalTerminalServer {
77
+ private readonly options;
78
+ private server;
79
+ private wss;
80
+ private token;
81
+ private port;
82
+ private readonly env;
83
+ /** Viewers admitted on a verdict that was not `local`: the ones `remote off` ends. */
84
+ private readonly remoteClients;
85
+ constructor(options: LocalServerOptions);
86
+ /** The bound port, once listening. */
87
+ boundPort(): number;
88
+ /** The current token. Exposed for tests; never logged. */
89
+ currentToken(): string;
90
+ start(): Promise<void>;
91
+ /** End every attached desktop viewer now (the pairing refused, the host stopping). */
92
+ dropClients(): void;
93
+ /** End every viewer admitted as remote (the remote switch turned off); local ones stay. */
94
+ dropRemoteClients(): void;
95
+ /** Whether `verdict` may attach now: the remote switch, or a token the relay vouched for as local. */
96
+ private admits;
97
+ stop(): Promise<void>;
98
+ /** Every WebSocket handshake comes through here; a refused one never reaches `ws`. */
99
+ onUpgrade(req: Pick<http.IncomingMessage, 'headers' | 'url'>, socket: Pick<Duplex, 'write' | 'destroy'>, head: Buffer): void;
100
+ private onRequest;
101
+ private onSocket;
102
+ /** Hand a verified attach to its socket, re-checking what may have changed while it opened. */
103
+ private serve;
104
+ /** Same heartbeat as the relay: a ping every 30 s, closed after 60 s without a pong. */
105
+ private heartbeat;
106
+ }