@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.
- package/CHANGELOG.md +95 -0
- package/README.md +129 -0
- package/dist/agent/claude.d.ts +28 -0
- package/dist/agent/claude.js +83 -4
- package/dist/agent/command.d.ts +8 -0
- package/dist/agent/command.js +40 -7
- package/dist/agent/daemon.d.ts +3 -1
- package/dist/agent/daemon.js +1 -2
- package/dist/agent/run.d.ts +30 -1
- package/dist/agent/run.js +311 -16
- package/dist/agent/runtime_status.d.ts +99 -3
- package/dist/agent/runtime_status.js +173 -5
- package/dist/agent/worktrees.d.ts +2 -0
- package/dist/agent/worktrees.js +3 -2
- package/dist/codehost/activity.d.ts +64 -0
- package/dist/codehost/activity.js +135 -0
- package/dist/codehost/agents.d.ts +31 -0
- package/dist/codehost/agents.js +57 -0
- package/dist/codehost/checkouts.d.ts +130 -0
- package/dist/codehost/checkouts.js +270 -0
- package/dist/codehost/claude_trust.d.ts +40 -0
- package/dist/codehost/claude_trust.js +129 -0
- package/dist/codehost/command.d.ts +25 -0
- package/dist/codehost/command.js +136 -0
- package/dist/codehost/decision.d.ts +156 -0
- package/dist/codehost/decision.js +553 -0
- package/dist/codehost/events.d.ts +49 -0
- package/dist/codehost/events.js +87 -0
- package/dist/codehost/folders.d.ts +122 -0
- package/dist/codehost/folders.js +317 -0
- package/dist/codehost/hooks.d.ts +178 -0
- package/dist/codehost/hooks.js +726 -0
- package/dist/codehost/host.d.ts +290 -0
- package/dist/codehost/host.js +625 -0
- package/dist/codehost/host_stats.d.ts +71 -0
- package/dist/codehost/host_stats.js +166 -0
- package/dist/codehost/invocations.d.ts +12 -0
- package/dist/codehost/invocations.js +82 -0
- package/dist/codehost/ledger.d.ts +35 -0
- package/dist/codehost/ledger.js +91 -0
- package/dist/codehost/local_server.d.ts +106 -0
- package/dist/codehost/local_server.js +339 -0
- package/dist/codehost/pty.d.ts +38 -0
- package/dist/codehost/pty.js +47 -0
- package/dist/codehost/ready.d.ts +31 -0
- package/dist/codehost/ready.js +109 -0
- package/dist/codehost/relay.d.ts +170 -0
- package/dist/codehost/relay.js +548 -0
- package/dist/codehost/settings.d.ts +52 -0
- package/dist/codehost/settings.js +114 -0
- package/dist/codehost/sizing.d.ts +163 -0
- package/dist/codehost/sizing.js +394 -0
- package/dist/codehost/terminals.d.ts +390 -0
- package/dist/codehost/terminals.js +965 -0
- package/dist/codehost/tmux.d.ts +196 -0
- package/dist/codehost/tmux.js +391 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +21 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/package.json +4 -1
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Code-thread host inside `prohost agent run`: one object the harness
|
|
3
|
+
* starts when this machine's owner has turned hosting on
|
|
4
|
+
* (`prohost agent code-host enable`), and hands the three events it serves —
|
|
5
|
+
* `code_thread.start_requested`, `.input_requested` and `.approval_decided`.
|
|
6
|
+
*
|
|
7
|
+
* Off — the default — the harness never builds one: no tmux, no relay socket,
|
|
8
|
+
* no local server, no host stats, and the events are acknowledged as ignored.
|
|
9
|
+
*/
|
|
10
|
+
import { writeFileSync } from 'node:fs';
|
|
11
|
+
import os from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { prohostHome } from '../agent/credentials.js';
|
|
14
|
+
import { ActivityReporter } from './activity.js';
|
|
15
|
+
import { AGENT_BINARIES, agentCapabilities, installedAgents, resolvesOnPath } from './agents.js';
|
|
16
|
+
import { CodeThreadCheckouts } from './checkouts.js';
|
|
17
|
+
import { event, fileEventSink, writeHookConfig } from './events.js';
|
|
18
|
+
import { foldersStamp, reportFolders } from './folders.js';
|
|
19
|
+
import { codexNotifyOverride, installHookFiles } from './hooks.js';
|
|
20
|
+
import { HostStatsSampler } from './host_stats.js';
|
|
21
|
+
import { acknowledgeCompletedApproval, completedApprovals, decide, hookDecides, menuState, readWaiter } from './decision.js';
|
|
22
|
+
import { DeliveryLedger } from './ledger.js';
|
|
23
|
+
import { LocalTerminalServer } from './local_server.js';
|
|
24
|
+
import { loadNodePty } from './pty.js';
|
|
25
|
+
import { CodeHostRelay } from './relay.js';
|
|
26
|
+
import { codeHostDir, codeHostInstanceId, ensurePrivateDir, loadSettings } from './settings.js';
|
|
27
|
+
import { CodeTerminals, execTmux } from './terminals.js';
|
|
28
|
+
import { APPROVAL_DECISIONS, isValidId, socketNameFor, TMUX_CONF } from './tmux.js';
|
|
29
|
+
export const EVENT_CODE_THREAD_START_REQUESTED = 'code_thread.start_requested';
|
|
30
|
+
export const EVENT_CODE_THREAD_INPUT_REQUESTED = 'code_thread.input_requested';
|
|
31
|
+
export const EVENT_CODE_THREAD_APPROVAL_DECIDED = 'code_thread.approval_decided';
|
|
32
|
+
/**
|
|
33
|
+
* Declared in `runtime_status.capabilities` while this harness hosts threads.
|
|
34
|
+
* Versioned with the contract the server relies on (`remote_terminal`, 409
|
|
35
|
+
* refusals; a prompt's own `hook_decision` capability says how its decision is
|
|
36
|
+
* answered): an older CLI's `code_threads` is not listed.
|
|
37
|
+
*/
|
|
38
|
+
export const CODE_THREADS_CAPABILITY = 'code_threads_v2';
|
|
39
|
+
/**
|
|
40
|
+
* Declared beside {@link CODE_THREADS_CAPABILITY} while the owner allows remote
|
|
41
|
+
* terminals. It rides the `/ws/webhooks` socket into the pairing's row in
|
|
42
|
+
* Postgres, where the backend API reads it (hosts listing, shell create,
|
|
43
|
+
* terminal-token) whatever fleet and Redis it runs against.
|
|
44
|
+
*/
|
|
45
|
+
export const REMOTE_TERMINAL_CAPABILITY = 'remote_terminal';
|
|
46
|
+
/**
|
|
47
|
+
* Declared with the instance id appended (`host_instance_<32 hex>`) beside
|
|
48
|
+
* {@link CODE_THREADS_CAPABILITY}: the server binds each thread to the
|
|
49
|
+
* instance that accepted its start and routes its frames only there.
|
|
50
|
+
*/
|
|
51
|
+
export const HOST_INSTANCE_CAPABILITY_PREFIX = 'host_instance_';
|
|
52
|
+
/**
|
|
53
|
+
* Declared beside {@link CODE_THREADS_CAPABILITY} by a host that starts threads
|
|
54
|
+
* in a folder (`folder` / `separateCopy` in `code_thread.start_requested`, the
|
|
55
|
+
* opened-folders list, `code_host_folders` on the status frame). The server
|
|
56
|
+
* sends a folder start only to a socket that declared it.
|
|
57
|
+
*/
|
|
58
|
+
export const CODE_THREAD_FOLDERS_CAPABILITY = 'code_thread_folders';
|
|
59
|
+
function text(value) {
|
|
60
|
+
return typeof value === 'string' ? value : undefined;
|
|
61
|
+
}
|
|
62
|
+
/** `code_thread.start_requested` data (camelCase per the contract), or `null`. */
|
|
63
|
+
export function parseStartRequest(data) {
|
|
64
|
+
const codeThreadId = data.codeThreadId;
|
|
65
|
+
const terminalId = data.terminalId;
|
|
66
|
+
if (!isValidId(codeThreadId) || !isValidId(terminalId))
|
|
67
|
+
return null;
|
|
68
|
+
// An agent this CLI doesn't know is reported as a failed start, not dropped.
|
|
69
|
+
const agent = (text(data.agent) ?? 'claude_code');
|
|
70
|
+
// A folder start (`folder` set): in place, or in a separate copy. Anything but a non-empty string
|
|
71
|
+
// is a repository thread, as from a server before folders.
|
|
72
|
+
const folder = text(data.folder) || undefined;
|
|
73
|
+
return {
|
|
74
|
+
codeThreadId,
|
|
75
|
+
terminalId,
|
|
76
|
+
goal: text(data.goal) ?? '',
|
|
77
|
+
repo: text(data.repo) ?? '',
|
|
78
|
+
branch: text(data.branch) || undefined,
|
|
79
|
+
agent,
|
|
80
|
+
...(folder ? { folder, separateCopy: data.separateCopy === true } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
/** `code_thread.input_requested` data, or `null`. */
|
|
84
|
+
export function parseInputRequest(data) {
|
|
85
|
+
const { codeThreadId, terminalId } = data;
|
|
86
|
+
const value = text(data.text);
|
|
87
|
+
if (!isValidId(codeThreadId) || !isValidId(terminalId) || value === undefined)
|
|
88
|
+
return null;
|
|
89
|
+
return { codeThreadId, terminalId, text: value };
|
|
90
|
+
}
|
|
91
|
+
/** `code_thread.approval_decided` data, or `null`. */
|
|
92
|
+
export function parseApprovalDecision(data) {
|
|
93
|
+
const { codeThreadId, terminalId, approvalId, decision } = data;
|
|
94
|
+
if (!isValidId(codeThreadId) || !isValidId(terminalId))
|
|
95
|
+
return null;
|
|
96
|
+
if (typeof approvalId !== 'string' || !approvalId || approvalId.length > 128)
|
|
97
|
+
return null;
|
|
98
|
+
if (typeof decision !== 'string' || !APPROVAL_DECISIONS.has(decision))
|
|
99
|
+
return null;
|
|
100
|
+
return { codeThreadId, terminalId, approvalId, decision: decision };
|
|
101
|
+
}
|
|
102
|
+
/** A start ran (or failed) but ProhostAI hasn't heard: redeliver, don't ack. */
|
|
103
|
+
/**
|
|
104
|
+
* A reply or decision this Mac refuses before typing anything, with the reason
|
|
105
|
+
* the server shows on the thread (the harness acks it 409 + `reason`, and the
|
|
106
|
+
* server hands the reply or decision back instead of counting it delivered).
|
|
107
|
+
*/
|
|
108
|
+
export class RefusedDeliveryError extends Error {
|
|
109
|
+
reason;
|
|
110
|
+
constructor(reason) {
|
|
111
|
+
super(`refused: ${reason}`);
|
|
112
|
+
this.reason = reason;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
export class UnreportedOutcomeError extends Error {
|
|
116
|
+
constructor(codeThreadId) {
|
|
117
|
+
super(`the outcome of code thread ${codeThreadId.slice(0, 12)}'s start has not reached ProhostAI yet`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* How long a reply waits for a hook to apply the decision it was handed (it prints it and exits
|
|
122
|
+
* within a poll, 100 ms) before it is sent again later instead of typed.
|
|
123
|
+
*/
|
|
124
|
+
export const DECISION_APPLY_WAIT_MS = 2_000;
|
|
125
|
+
/** A `remote off` ends open terminals within this long. */
|
|
126
|
+
export const SETTINGS_POLL_MS = 2_000;
|
|
127
|
+
/** How often live terminals' worktree lanes are marked in use (pruning is after 14 idle days). */
|
|
128
|
+
export const LANE_REFRESH_MS = 3_600_000;
|
|
129
|
+
export class CodeHost {
|
|
130
|
+
options;
|
|
131
|
+
terminals;
|
|
132
|
+
env;
|
|
133
|
+
stats;
|
|
134
|
+
deliveries;
|
|
135
|
+
relay;
|
|
136
|
+
local;
|
|
137
|
+
watcher;
|
|
138
|
+
laneKeeper;
|
|
139
|
+
remoteAllowed = false;
|
|
140
|
+
recoveringPermissions = false;
|
|
141
|
+
events;
|
|
142
|
+
/** Set while the relay refuses this pairing (revoked key, inactive pairing). */
|
|
143
|
+
pairingRefused;
|
|
144
|
+
/** Whose pairing this is, from the relay's last `auth_ok`; the desktop socket is bound to it. */
|
|
145
|
+
identity;
|
|
146
|
+
stopped = false;
|
|
147
|
+
instanceId;
|
|
148
|
+
log;
|
|
149
|
+
checkouts;
|
|
150
|
+
/** What {@link offeredRepos} answers, read at {@link start}. */
|
|
151
|
+
offered;
|
|
152
|
+
/** `folders.json` as last seen by the settings poll: a change re-sends the status frame. */
|
|
153
|
+
foldersSeen = '';
|
|
154
|
+
/** Hosting (`enabled`) as last seen on disk: `code-host disable` closes the desktop socket at once. */
|
|
155
|
+
enabledSeen = true;
|
|
156
|
+
/** `code-host/activity.json`, for the desktop app's keep-awake (`activity.ts`). */
|
|
157
|
+
activity;
|
|
158
|
+
constructor(options) {
|
|
159
|
+
this.options = options;
|
|
160
|
+
this.env = options.env ?? process.env;
|
|
161
|
+
this.log = options.log ?? (() => { });
|
|
162
|
+
const home = prohostHome(this.env);
|
|
163
|
+
const dir = codeHostDir(this.env);
|
|
164
|
+
const confPath = path.join(dir, 'tmux.conf');
|
|
165
|
+
ensurePrivateDir(dir);
|
|
166
|
+
writeFileSync(confPath, TMUX_CONF, { mode: 0o600 });
|
|
167
|
+
const hookFiles = installHookFiles(options.nodePath ?? process.execPath, options.cliEntrypoint, this.env);
|
|
168
|
+
this.stats = new HostStatsSampler({ slots: options.slots });
|
|
169
|
+
this.deliveries = new DeliveryLedger(this.env);
|
|
170
|
+
this.checkouts = new CodeThreadCheckouts({ repos: options.repos, env: this.env, log: this.log });
|
|
171
|
+
this.instanceId = codeHostInstanceId(this.env);
|
|
172
|
+
this.activity = new ActivityReporter(this.env);
|
|
173
|
+
this.events = options.events ?? fileEventSink(this.env);
|
|
174
|
+
this.terminals = new CodeTerminals({
|
|
175
|
+
home,
|
|
176
|
+
socket: socketNameFor(home),
|
|
177
|
+
confPath,
|
|
178
|
+
tmux: options.tmux ?? execTmux(),
|
|
179
|
+
spawnPty: options.spawnPty ?? loadNodePty,
|
|
180
|
+
checkouts: this.checkouts,
|
|
181
|
+
events: this.events,
|
|
182
|
+
hookFiles: { claudeSettings: hookFiles.claudeSettings, claudeInvocationSettings: hookFiles.claudeInvocationSettings,
|
|
183
|
+
hookShim: hookFiles.shim, codexNotify: codexNotifyOverride(hookFiles.shim) },
|
|
184
|
+
accountEnv: options.accountEnv,
|
|
185
|
+
agentInstalled: (agent) => resolvesOnPath(AGENT_BINARIES[agent], this.env),
|
|
186
|
+
authorized: () => this.authorized(),
|
|
187
|
+
remoteControlAllowed: () => this.settings().allowRemoteTerminal,
|
|
188
|
+
log: this.log,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
settings() {
|
|
192
|
+
return loadSettings(this.env);
|
|
193
|
+
}
|
|
194
|
+
remoteTerminalAllowed() {
|
|
195
|
+
return this.settings().allowRemoteTerminal;
|
|
196
|
+
}
|
|
197
|
+
instanceCapability() {
|
|
198
|
+
return `${HOST_INSTANCE_CAPABILITY_PREFIX}${this.instanceId}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Only the agents a start would accept: installed here, and runnable on this harness's login
|
|
202
|
+
* (`accountEnv`: a Claude Code harness hosts no Codex thread). A host-only daemon has no login of
|
|
203
|
+
* its own and runs whichever is installed.
|
|
204
|
+
*/
|
|
205
|
+
agentCapabilities() {
|
|
206
|
+
const accountEnv = this.options.accountEnv;
|
|
207
|
+
return agentCapabilities(installedAgents(this.env).filter((agent) => !accountEnv || accountEnv(agent).ok));
|
|
208
|
+
}
|
|
209
|
+
offeredRepos() {
|
|
210
|
+
return this.offered;
|
|
211
|
+
}
|
|
212
|
+
openedFolders() {
|
|
213
|
+
return { folders: reportFolders(this.env), home: os.homedir() };
|
|
214
|
+
}
|
|
215
|
+
/** The latest host stats, for the heartbeat. Sampled by the relay loop and on demand. */
|
|
216
|
+
latestStats() {
|
|
217
|
+
return this.stats.latest();
|
|
218
|
+
}
|
|
219
|
+
async sampleStats() {
|
|
220
|
+
return this.stats.sample();
|
|
221
|
+
}
|
|
222
|
+
async start() {
|
|
223
|
+
// What a person may pick for a thread here: read once, the checkouts are this process's.
|
|
224
|
+
this.offered = await this.checkouts.offered();
|
|
225
|
+
// The tmux.conf was just rewritten (constructor): a server from before this start still runs the old one.
|
|
226
|
+
await this.terminals.reloadConfig();
|
|
227
|
+
const adopted = await this.terminals.adopt();
|
|
228
|
+
const settings = this.settings();
|
|
229
|
+
this.log(` ⚙ hosting Code threads${adopted ? ` (${adopted} running, re-adopted)` : ''} — remote terminal ` +
|
|
230
|
+
(settings.allowRemoteTerminal ? 'ALLOWED' : 'off (prohost agent code-host remote on)'));
|
|
231
|
+
void this.stats.sample();
|
|
232
|
+
if (this.options.relay !== false) {
|
|
233
|
+
this.relay = new CodeHostRelay({
|
|
234
|
+
baseUrl: this.options.baseUrl,
|
|
235
|
+
apiKey: this.options.apiKey,
|
|
236
|
+
machineId: this.options.machineId,
|
|
237
|
+
instanceId: this.instanceId,
|
|
238
|
+
machine: this.options.machine,
|
|
239
|
+
clientVersion: this.options.clientVersion,
|
|
240
|
+
settings: () => this.settings(),
|
|
241
|
+
stats: () => this.stats.sample(),
|
|
242
|
+
terminals: this.terminals,
|
|
243
|
+
onHostToken: (token, expiresAt) => writeHookConfig({ version: 1, endpoint: this.options.baseUrl, token, expires_at: expiresAt }, this.env),
|
|
244
|
+
onAuthorization: (authorized, reason, identity) => void this.onAuthorization(authorized, reason, identity),
|
|
245
|
+
onDisconnected: () => void this.onRelayDisconnected(),
|
|
246
|
+
log: this.log,
|
|
247
|
+
});
|
|
248
|
+
this.relay.start();
|
|
249
|
+
}
|
|
250
|
+
this.remoteAllowed = settings.allowRemoteTerminal;
|
|
251
|
+
this.enabledSeen = settings.enabled;
|
|
252
|
+
this.foldersSeen = foldersStamp(this.env);
|
|
253
|
+
await this.startLocal();
|
|
254
|
+
this.reportActivity();
|
|
255
|
+
this.watcher = setInterval(() => {
|
|
256
|
+
void this.applySettings();
|
|
257
|
+
void this.recoverCompletedPermissions();
|
|
258
|
+
this.reportActivity();
|
|
259
|
+
}, this.options.settingsPollMs ?? SETTINGS_POLL_MS);
|
|
260
|
+
this.watcher.unref?.();
|
|
261
|
+
// A terminal left alone for weeks still lives in tmux: keep its worktree from being pruned as idle.
|
|
262
|
+
this.laneKeeper = setInterval(() => void this.terminals.refreshLiveLanes(), LANE_REFRESH_MS);
|
|
263
|
+
this.laneKeeper.unref?.();
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Whether an agent here is mid-turn, written for the desktop app's keep-awake (`activity.ts`) —
|
|
267
|
+
* only while hosting is on; turned off, the file goes.
|
|
268
|
+
*/
|
|
269
|
+
reportActivity() {
|
|
270
|
+
if (this.stopped || !this.settings().enabled) {
|
|
271
|
+
this.activity.clear();
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
this.activity.tick(this.terminals.list().map((record) => record.terminalId));
|
|
275
|
+
}
|
|
276
|
+
/** The daemon outlives timed-out/killed hooks and retries recovery after reconnect or restart. */
|
|
277
|
+
async recoverCompletedPermissions() {
|
|
278
|
+
if (!this.authorized() || this.recoveringPermissions)
|
|
279
|
+
return;
|
|
280
|
+
this.recoveringPermissions = true;
|
|
281
|
+
try {
|
|
282
|
+
for (const record of this.terminals.list()) {
|
|
283
|
+
if (record.agent !== 'claude_code')
|
|
284
|
+
continue;
|
|
285
|
+
for (const approvalId of completedApprovals(record.terminalId, this.env)) {
|
|
286
|
+
if (!this.authorized())
|
|
287
|
+
return;
|
|
288
|
+
const original = readWaiter(record.terminalId, approvalId, this.env)?.permissionRequest;
|
|
289
|
+
if (!original)
|
|
290
|
+
continue;
|
|
291
|
+
// Keep its original event fence and deadline; a late replay must not renew the hook's wait.
|
|
292
|
+
const ingest = await this.events.post(record.codeThreadId, {
|
|
293
|
+
...original, data: { ...original.data, sentAtMs: Date.now() },
|
|
294
|
+
});
|
|
295
|
+
if (ingest < 200 || ingest >= 300)
|
|
296
|
+
break;
|
|
297
|
+
const status = await this.events.post(record.codeThreadId, event('permission_resolved', { approvalId }));
|
|
298
|
+
if (status < 200 || status >= 300)
|
|
299
|
+
break;
|
|
300
|
+
acknowledgeCompletedApproval(record.terminalId, approvalId, this.env);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
this.log(' ! permission recovery could not be reported; retrying on the next poll');
|
|
306
|
+
}
|
|
307
|
+
finally {
|
|
308
|
+
this.recoveringPermissions = false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* The switches as the desktop socket must see them: no remote terminal while
|
|
313
|
+
* the relay refuses this pairing, whatever the file says — a revoked
|
|
314
|
+
* pairing closes every way in, not only the relay's.
|
|
315
|
+
*/
|
|
316
|
+
desktopSettings() {
|
|
317
|
+
const settings = this.settings();
|
|
318
|
+
return this.pairingRefused ? { ...settings, allowRemoteTerminal: false } : settings;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Whether this host may act for ProhostAI now: running, and the relay has
|
|
322
|
+
* not refused the pairing. A refusal holds until the next `auth_ok`, and
|
|
323
|
+
* gates everything the event socket asks for as well as every attach.
|
|
324
|
+
*/
|
|
325
|
+
authorized() {
|
|
326
|
+
return !this.stopped && this.pairingRefused === undefined;
|
|
327
|
+
}
|
|
328
|
+
/** The relay authorized or refused this machine (see `CodeHostRelayOptions.onAuthorization`). */
|
|
329
|
+
async onAuthorization(authorized, reason, identity) {
|
|
330
|
+
// A frame already in flight when the host stopped reopens nothing.
|
|
331
|
+
if (this.stopped)
|
|
332
|
+
return;
|
|
333
|
+
const wasAuthorized = this.authorized();
|
|
334
|
+
if (!authorized) {
|
|
335
|
+
const first = this.pairingRefused === undefined;
|
|
336
|
+
this.pairingRefused = reason ?? 'refused';
|
|
337
|
+
// A refused pairing stops saying it hosts Code threads, at once: the server lists and
|
|
338
|
+
// admits a Mac on that declaration, and this host would refuse every start.
|
|
339
|
+
if (wasAuthorized)
|
|
340
|
+
this.options.onRemoteChanged?.();
|
|
341
|
+
if (first && this.local)
|
|
342
|
+
this.log(` ⏹ closing the desktop fast path: the relay refused this pairing (${this.pairingRefused})`);
|
|
343
|
+
await this.closeLocal();
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
this.pairingRefused = undefined;
|
|
347
|
+
if (!wasAuthorized)
|
|
348
|
+
this.options.onRemoteChanged?.();
|
|
349
|
+
const changed = identity?.accountId !== this.identity?.accountId || identity?.ownerUserId !== this.identity?.ownerUserId;
|
|
350
|
+
this.identity = identity;
|
|
351
|
+
// A different owner (a re-pairing): the old advertisement must go first.
|
|
352
|
+
if (changed)
|
|
353
|
+
await this.closeLocal();
|
|
354
|
+
await this.startLocal();
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* The relay went away. Whether this pairing is still authorized can't be
|
|
358
|
+
* known until it answers again, so the desktop socket closes now (its
|
|
359
|
+
* identity is forgotten) and reopens only on a fresh `auth_ok`. The event
|
|
360
|
+
* socket's work is not paused: that socket authenticates on its own, and
|
|
361
|
+
* an explicit refusal (`auth_error`) is what gates it.
|
|
362
|
+
*/
|
|
363
|
+
async onRelayDisconnected() {
|
|
364
|
+
if (this.stopped)
|
|
365
|
+
return;
|
|
366
|
+
if (this.local)
|
|
367
|
+
this.log(' ⏸ desktop fast path closed until the relay confirms this pairing again');
|
|
368
|
+
this.identity = undefined;
|
|
369
|
+
await this.closeLocal();
|
|
370
|
+
}
|
|
371
|
+
async closeLocal() {
|
|
372
|
+
const local = this.local;
|
|
373
|
+
this.local = undefined;
|
|
374
|
+
local?.dropClients();
|
|
375
|
+
await local?.stop();
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* The desktop app's socket on 127.0.0.1, served whenever hosting is on — with
|
|
379
|
+
* the remote switch off as well: then it admits only attaches the relay
|
|
380
|
+
* vouches for as local (`local_server.ts`).
|
|
381
|
+
*/
|
|
382
|
+
async startLocal() {
|
|
383
|
+
if (this.options.localServer === false || this.local || !this.authorized())
|
|
384
|
+
return;
|
|
385
|
+
if (!this.settings().enabled)
|
|
386
|
+
return;
|
|
387
|
+
// Bound to whose pairing this is, as the relay last confirmed it: only that
|
|
388
|
+
// user's app window, signed in to that workspace, attaches. Until the
|
|
389
|
+
// relay has said (or from a relay that doesn't say), there is no socket.
|
|
390
|
+
const identity = this.identity;
|
|
391
|
+
if (!identity)
|
|
392
|
+
return;
|
|
393
|
+
if (this.options.accountId && this.options.accountId.toLowerCase() !== identity.accountId) {
|
|
394
|
+
this.log(' ! desktop fast path off: agent.json and the relay disagree on this pairing’s workspace');
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const local = new LocalTerminalServer({
|
|
398
|
+
env: this.env,
|
|
399
|
+
accountId: identity.accountId,
|
|
400
|
+
ownerUserId: identity.ownerUserId,
|
|
401
|
+
settings: () => this.desktopSettings(),
|
|
402
|
+
terminals: this.terminals,
|
|
403
|
+
log: this.log,
|
|
404
|
+
// Identity comes from the relay, never from the app page.
|
|
405
|
+
verifyViewer: (token) => this.relay ? this.relay.verifyViewer(token) : Promise.resolve({ ok: false, reason: 'relay_unavailable' }),
|
|
406
|
+
});
|
|
407
|
+
this.local = local;
|
|
408
|
+
try {
|
|
409
|
+
await local.start();
|
|
410
|
+
}
|
|
411
|
+
catch (err) {
|
|
412
|
+
if (this.local === local)
|
|
413
|
+
this.local = undefined;
|
|
414
|
+
this.log(` ! desktop fast path unavailable: ${err instanceof Error ? err.message : String(err)}`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/**
|
|
418
|
+
* The owner's switches, re-read: a terminal attached while remote access was
|
|
419
|
+
* allowed must not outlive it. Turning it off closes every relay viewer and
|
|
420
|
+
* every desktop viewer that was admitted as remote; the desktop socket stays
|
|
421
|
+
* up for local attaches. Turning hosting off (`code-host disable`) closes the
|
|
422
|
+
* desktop socket (and its `local.json`) too. The opened folders changing
|
|
423
|
+
* re-sends the status frame.
|
|
424
|
+
*/
|
|
425
|
+
async applySettings() {
|
|
426
|
+
const settings = this.settings();
|
|
427
|
+
const stamp = foldersStamp(this.env);
|
|
428
|
+
if (stamp !== this.foldersSeen) {
|
|
429
|
+
this.foldersSeen = stamp;
|
|
430
|
+
this.options.onCodeHostChanged?.();
|
|
431
|
+
}
|
|
432
|
+
if (settings.enabled !== this.enabledSeen) {
|
|
433
|
+
this.enabledSeen = settings.enabled;
|
|
434
|
+
if (settings.enabled)
|
|
435
|
+
await this.startLocal();
|
|
436
|
+
else {
|
|
437
|
+
if (this.local)
|
|
438
|
+
this.log(' ⏹ closing the desktop fast path: hosting was turned off');
|
|
439
|
+
await this.closeLocal();
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
const allowed = settings.allowRemoteTerminal;
|
|
443
|
+
if (allowed === this.remoteAllowed)
|
|
444
|
+
return;
|
|
445
|
+
this.remoteAllowed = allowed;
|
|
446
|
+
// Both places the switch is read hear it now: the pairing's socket (the API's hosts list and
|
|
447
|
+
// terminal-token mint) and the relay's presence (its attach check).
|
|
448
|
+
this.options.onRemoteChanged?.();
|
|
449
|
+
this.relay?.statusChanged();
|
|
450
|
+
// Already up while hosting is on; tried again here in case its last start failed.
|
|
451
|
+
if (allowed) {
|
|
452
|
+
await this.startLocal();
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
this.relay?.dropStreams('the remote terminal was turned off');
|
|
456
|
+
this.local?.dropRemoteClients();
|
|
457
|
+
}
|
|
458
|
+
/** Every closer is attempted, whatever the one before it did; never rejects. */
|
|
459
|
+
async stop() {
|
|
460
|
+
this.stopped = true;
|
|
461
|
+
if (this.watcher)
|
|
462
|
+
clearInterval(this.watcher);
|
|
463
|
+
if (this.laneKeeper)
|
|
464
|
+
clearInterval(this.laneKeeper);
|
|
465
|
+
this.laneKeeper = undefined;
|
|
466
|
+
this.watcher = undefined;
|
|
467
|
+
// Hosting stopped: nothing here says this Mac is busy any more.
|
|
468
|
+
this.activity.clear();
|
|
469
|
+
const relay = this.relay;
|
|
470
|
+
const local = this.local;
|
|
471
|
+
this.relay = undefined;
|
|
472
|
+
this.local = undefined;
|
|
473
|
+
try {
|
|
474
|
+
relay?.stop();
|
|
475
|
+
}
|
|
476
|
+
catch (err) {
|
|
477
|
+
this.log(` ! stopping the code-host relay: ${err instanceof Error ? err.message : String(err)}`);
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
local?.dropClients();
|
|
481
|
+
await local?.stop();
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
this.log(` ! stopping the desktop fast path: ${err instanceof Error ? err.message : String(err)}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Whether this Mac takes `event` at all, read from the owner's switches on
|
|
489
|
+
* disk now — never cached. Anything that types into a terminal from
|
|
490
|
+
* ProhostAI (a reply, an approval) or opens a raw shell is remote control of
|
|
491
|
+
* this machine, so it needs the remote-terminal switch as well as hosting.
|
|
492
|
+
* The harness answers a refusal 204, as for a machine that doesn't host.
|
|
493
|
+
*
|
|
494
|
+
* Hosting itself (`enabled`) is latched for the process: this host exists only
|
|
495
|
+
* because it was on at startup, and `code-host disable` takes effect when the
|
|
496
|
+
* agent restarts, as the command says. Never re-read here: the runtime keeps
|
|
497
|
+
* advertising `code_threads_v2` until then, so a start refused for it would be
|
|
498
|
+
* acked 204 and leave its thread `starting` for good.
|
|
499
|
+
*/
|
|
500
|
+
admits(event, data) {
|
|
501
|
+
if (!this.authorized())
|
|
502
|
+
return false;
|
|
503
|
+
const settings = this.settings();
|
|
504
|
+
// Every start is taken: one this Mac won't run (a shell with the remote switch off) is
|
|
505
|
+
// reported failed by `handleStart`, never acked 204 and left `starting`.
|
|
506
|
+
if (event === EVENT_CODE_THREAD_START_REQUESTED)
|
|
507
|
+
return true;
|
|
508
|
+
return settings.allowRemoteTerminal;
|
|
509
|
+
}
|
|
510
|
+
refusal(event) {
|
|
511
|
+
if (event === EVENT_CODE_THREAD_START_REQUESTED || !this.authorized())
|
|
512
|
+
return undefined;
|
|
513
|
+
const settings = this.settings();
|
|
514
|
+
return settings.allowRemoteTerminal ? undefined : 'remote_terminal_disabled';
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Throws {@link UnreportedOutcomeError} when the outcome never reached the
|
|
518
|
+
* server, so the harness asks for the delivery again rather than ack it.
|
|
519
|
+
*/
|
|
520
|
+
async handleStart(data) {
|
|
521
|
+
const request = parseStartRequest(data);
|
|
522
|
+
if (!request)
|
|
523
|
+
return false;
|
|
524
|
+
const result = await this.terminals.start(request);
|
|
525
|
+
if (!result.reported)
|
|
526
|
+
throw new UnreportedOutcomeError(request.codeThreadId);
|
|
527
|
+
return result.ok;
|
|
528
|
+
}
|
|
529
|
+
async handleInput(data, deliveryId) {
|
|
530
|
+
const request = parseInputRequest(data);
|
|
531
|
+
if (!request)
|
|
532
|
+
return false;
|
|
533
|
+
if (deliveryId && this.deliveries.has(deliveryId))
|
|
534
|
+
return this.repeat(deliveryId);
|
|
535
|
+
// Re-checked here as well as by the harness: the switch is read at the
|
|
536
|
+
// moment of typing.
|
|
537
|
+
if (!this.settings().allowRemoteTerminal)
|
|
538
|
+
throw new RefusedDeliveryError('remote_terminal_disabled');
|
|
539
|
+
// A permission menu may be up, and this reply's keys would answer it (its Enter picks the
|
|
540
|
+
// highlighted "Yes"). One the terminal owns: refused, so the server hands the reply back. One
|
|
541
|
+
// whose hook is still deciding: waited on a moment; past that, nothing is typed and it comes again.
|
|
542
|
+
const menu = await this.menuSettled(request.terminalId);
|
|
543
|
+
// Unknown application is a reportable refusal, not proof the menu is still open. The
|
|
544
|
+
// existing reason tells the server nothing was typed and the prompt MAY remain.
|
|
545
|
+
if (menu === 'open' || menu === 'unconfirmed')
|
|
546
|
+
throw new RefusedDeliveryError('prompt_open');
|
|
547
|
+
if (menu === 'deciding')
|
|
548
|
+
return this.retryLater(undefined, request.codeThreadId);
|
|
549
|
+
if (deliveryId && !this.deliveries.claim(deliveryId))
|
|
550
|
+
return this.repeat(deliveryId);
|
|
551
|
+
// Turned off while it waited behind another reply, before a key: refused, so handed back.
|
|
552
|
+
const outcome = {};
|
|
553
|
+
const typed = await this.terminals.input(request, outcome);
|
|
554
|
+
if (!typed && outcome.refused)
|
|
555
|
+
this.refuse(deliveryId, outcome.refused);
|
|
556
|
+
if (!typed && outcome.retry)
|
|
557
|
+
this.retryLater(deliveryId, request.codeThreadId);
|
|
558
|
+
return typed;
|
|
559
|
+
}
|
|
560
|
+
async handleApproval(data, deliveryId) {
|
|
561
|
+
if (!this.settings().allowRemoteTerminal)
|
|
562
|
+
throw new RefusedDeliveryError('remote_terminal_disabled');
|
|
563
|
+
const request = parseApprovalDecision(data);
|
|
564
|
+
if (!request)
|
|
565
|
+
return false;
|
|
566
|
+
// The decision is handed to the prompt's own hook, which answers exactly that prompt; no key
|
|
567
|
+
// is ever pressed. Refused once nothing waits for it: answered in the terminal, its wait ran
|
|
568
|
+
// out, or a window launched before hooks waited (whose prompts are the terminal's).
|
|
569
|
+
if (deliveryId && !this.deliveries.claim(deliveryId))
|
|
570
|
+
return this.repeat(deliveryId);
|
|
571
|
+
const outcome = hookDecides(request.terminalId, request.approvalId, this.env)
|
|
572
|
+
? decide(request.terminalId, request.approvalId, request.decision, this.env)
|
|
573
|
+
: 'local';
|
|
574
|
+
if (outcome === 'local')
|
|
575
|
+
this.refuse(deliveryId, 'prompt_local');
|
|
576
|
+
this.log(` ✓ code thread ${request.codeThreadId.slice(0, 12)}: ${request.decision} handed to its hook (approval ${request.approvalId.slice(0, 12)})`);
|
|
577
|
+
return true;
|
|
578
|
+
}
|
|
579
|
+
/** The terminal's menu state (`menuState`) once a hook that is still deciding has had a moment to finish. */
|
|
580
|
+
async menuSettled(terminalId) {
|
|
581
|
+
if (!isValidId(terminalId))
|
|
582
|
+
return 'clear';
|
|
583
|
+
const deadline = Date.now() + DECISION_APPLY_WAIT_MS;
|
|
584
|
+
for (;;) {
|
|
585
|
+
const state = menuState(terminalId, this.env, Date.now(), this.options.alive);
|
|
586
|
+
if (state !== 'deciding' || Date.now() >= deadline)
|
|
587
|
+
return state;
|
|
588
|
+
await (this.options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms))))(50);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/**
|
|
592
|
+
* A refusal the server hands back. Nothing typed (the switch): the claim is
|
|
593
|
+
* released, so a redelivery after a lost ack is judged afresh rather than
|
|
594
|
+
* answered "done". Possibly typed (`reply_interrupted`): the claim stays, so it
|
|
595
|
+
* is never typed twice, and a redelivery is refused the same way.
|
|
596
|
+
*/
|
|
597
|
+
refuse(deliveryId, reason) {
|
|
598
|
+
if (deliveryId) {
|
|
599
|
+
if (reason === 'reply_interrupted')
|
|
600
|
+
this.deliveries.refuse(deliveryId, reason);
|
|
601
|
+
else
|
|
602
|
+
this.deliveries.release(deliveryId);
|
|
603
|
+
}
|
|
604
|
+
throw new RefusedDeliveryError(reason);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* A local failure before anything reached the pane: the claim is released and
|
|
608
|
+
* the harness acks 503, so the outbox sends it again and it is typed once.
|
|
609
|
+
*/
|
|
610
|
+
retryLater(deliveryId, codeThreadId) {
|
|
611
|
+
if (deliveryId)
|
|
612
|
+
this.deliveries.release(deliveryId);
|
|
613
|
+
throw new Error(`code thread ${codeThreadId.slice(0, 12)}: could not reach its terminal; retrying`);
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* A delivery already acted on: done, unless it ended in a refusal, which is said again
|
|
617
|
+
* (`decision_unconfirmed`: a decision an older CLI pressed keys for, which may have landed).
|
|
618
|
+
*/
|
|
619
|
+
repeat(deliveryId) {
|
|
620
|
+
const reason = this.deliveries.refusalFor(deliveryId);
|
|
621
|
+
if (reason === 'reply_interrupted' || reason === 'decision_unconfirmed')
|
|
622
|
+
throw new RefusedDeliveryError(reason);
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
}
|