@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,339 @@
|
|
|
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 { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
33
|
+
import { rmSync } from 'node:fs';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
import WebSocket, { WebSocketServer } from 'ws';
|
|
37
|
+
import { pipeStream } from './relay.js';
|
|
38
|
+
import { codeHostDir, writePrivateJson } from './settings.js';
|
|
39
|
+
import { clampDim, DEFAULT_COLS, DEFAULT_ROWS, isValidId, parseControl, TERMINAL_WINDOWS } from './tmux.js';
|
|
40
|
+
export const LOCAL_TERMINAL_PATH = '/code-host/terminal';
|
|
41
|
+
export const LOCAL_TERMINALS_PATH = '/code-host/terminals';
|
|
42
|
+
const AUTH_TIMEOUT_MS = 10_000;
|
|
43
|
+
const PING_INTERVAL_MS = 30_000;
|
|
44
|
+
const PONG_TIMEOUT_MS = 60_000;
|
|
45
|
+
const STOP_GRACE_MS = 1_000;
|
|
46
|
+
async function closeServer(server) {
|
|
47
|
+
const closed = new Promise((resolve) => server.close(() => resolve()));
|
|
48
|
+
// Upgraded sockets must not hold the daemon's shutdown open.
|
|
49
|
+
server.closeAllConnections();
|
|
50
|
+
await Promise.race([closed, new Promise((resolve) => setTimeout(resolve, STOP_GRACE_MS).unref?.())]);
|
|
51
|
+
}
|
|
52
|
+
export function localConfigPath(env = process.env) {
|
|
53
|
+
return path.join(codeHostDir(env), 'local.json');
|
|
54
|
+
}
|
|
55
|
+
export function tokensMatch(expected, given) {
|
|
56
|
+
if (typeof given !== 'string' || !given || !expected)
|
|
57
|
+
return false;
|
|
58
|
+
const a = Buffer.from(expected, 'utf8');
|
|
59
|
+
const b = Buffer.from(given, 'utf8');
|
|
60
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
61
|
+
}
|
|
62
|
+
/** Why a handshake must be refused, or `undefined` to accept it. */
|
|
63
|
+
export function refuseHandshake(headers, url, port) {
|
|
64
|
+
if (headers.origin !== undefined)
|
|
65
|
+
return { status: 403, reason: 'origin_not_allowed' };
|
|
66
|
+
const host = headers.host;
|
|
67
|
+
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`)
|
|
68
|
+
return { status: 403, reason: 'bad_host' };
|
|
69
|
+
let pathname;
|
|
70
|
+
try {
|
|
71
|
+
pathname = new URL(url ?? '/', 'http://127.0.0.1').pathname;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return { status: 400, reason: 'bad_url' };
|
|
75
|
+
}
|
|
76
|
+
if (pathname !== LOCAL_TERMINAL_PATH)
|
|
77
|
+
return { status: 404, reason: 'not_found' };
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
80
|
+
export function parseLocalAuth(text, token) {
|
|
81
|
+
let frame;
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(text);
|
|
84
|
+
if (!parsed || typeof parsed !== 'object')
|
|
85
|
+
return { error: 'expected_auth_frame' };
|
|
86
|
+
frame = parsed;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return { error: 'invalid_json' };
|
|
90
|
+
}
|
|
91
|
+
if (frame.type !== 'auth')
|
|
92
|
+
return { error: 'expected_auth_frame' };
|
|
93
|
+
if (!tokensMatch(token, frame.token))
|
|
94
|
+
return { error: 'invalid_token' };
|
|
95
|
+
if (!isValidId(frame.terminal_id))
|
|
96
|
+
return { error: 'unknown_terminal' };
|
|
97
|
+
const window = frame.window === undefined ? 'agent' : frame.window;
|
|
98
|
+
if (typeof window !== 'string' || !TERMINAL_WINDOWS.has(window))
|
|
99
|
+
return { error: 'invalid_window' };
|
|
100
|
+
if (typeof frame.viewer_token !== 'string' || !frame.viewer_token)
|
|
101
|
+
return { error: 'viewer_token_required' };
|
|
102
|
+
return {
|
|
103
|
+
terminalId: frame.terminal_id,
|
|
104
|
+
window: window,
|
|
105
|
+
cols: clampDim(frame.cols, DEFAULT_COLS),
|
|
106
|
+
rows: clampDim(frame.rows, DEFAULT_ROWS),
|
|
107
|
+
viewerToken: frame.viewer_token,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
export class LocalTerminalServer {
|
|
111
|
+
options;
|
|
112
|
+
server;
|
|
113
|
+
wss;
|
|
114
|
+
token = '';
|
|
115
|
+
port = 0;
|
|
116
|
+
env;
|
|
117
|
+
/** Viewers admitted on a verdict that was not `local`: the ones `remote off` ends. */
|
|
118
|
+
remoteClients = new Set();
|
|
119
|
+
constructor(options) {
|
|
120
|
+
this.options = options;
|
|
121
|
+
this.env = options.env ?? process.env;
|
|
122
|
+
}
|
|
123
|
+
/** The bound port, once listening. */
|
|
124
|
+
boundPort() {
|
|
125
|
+
return this.port;
|
|
126
|
+
}
|
|
127
|
+
/** The current token. Exposed for tests; never logged. */
|
|
128
|
+
currentToken() {
|
|
129
|
+
return this.token;
|
|
130
|
+
}
|
|
131
|
+
async start() {
|
|
132
|
+
if (this.server)
|
|
133
|
+
return;
|
|
134
|
+
this.token = randomBytes(32).toString('hex');
|
|
135
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024 });
|
|
136
|
+
this.wss = wss;
|
|
137
|
+
const server = http.createServer((req, res) => this.onRequest(req, res));
|
|
138
|
+
server.on('upgrade', (req, socket, head) => this.onUpgrade(req, socket, head));
|
|
139
|
+
// Own the listener before awaiting it, so stop() can invalidate this start.
|
|
140
|
+
this.server = server;
|
|
141
|
+
try {
|
|
142
|
+
await new Promise((resolve, reject) => {
|
|
143
|
+
const complete = () => {
|
|
144
|
+
server.off('error', failed);
|
|
145
|
+
server.off('close', complete);
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
const failed = (error) => {
|
|
149
|
+
server.off('close', complete);
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
server.once('error', failed);
|
|
153
|
+
server.once('close', complete);
|
|
154
|
+
server.listen(0, '127.0.0.1', complete);
|
|
155
|
+
});
|
|
156
|
+
if (this.server !== server) {
|
|
157
|
+
await closeServer(server);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const address = server.address();
|
|
161
|
+
this.port = typeof address === 'object' && address ? address.port : 0;
|
|
162
|
+
writePrivateJson(localConfigPath(this.env), {
|
|
163
|
+
// v2: every attach's viewer token is verified through the relay (a v1
|
|
164
|
+
// desktop, which never sends one, gets `viewer_token_required`).
|
|
165
|
+
version: 2,
|
|
166
|
+
port: this.port,
|
|
167
|
+
token: this.token,
|
|
168
|
+
pid: process.pid,
|
|
169
|
+
account_id: this.options.accountId.toLowerCase(),
|
|
170
|
+
owner_user_id: this.options.ownerUserId.toLowerCase(),
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
if (this.server === server)
|
|
175
|
+
await this.stop();
|
|
176
|
+
else
|
|
177
|
+
await closeServer(server);
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** End every attached desktop viewer now (the pairing refused, the host stopping). */
|
|
182
|
+
dropClients() {
|
|
183
|
+
for (const client of this.wss?.clients ?? [])
|
|
184
|
+
client.terminate();
|
|
185
|
+
}
|
|
186
|
+
/** End every viewer admitted as remote (the remote switch turned off); local ones stay. */
|
|
187
|
+
dropRemoteClients() {
|
|
188
|
+
for (const client of this.remoteClients)
|
|
189
|
+
client.terminate();
|
|
190
|
+
this.remoteClients.clear();
|
|
191
|
+
}
|
|
192
|
+
/** Whether `verdict` may attach now: the remote switch, or a token the relay vouched for as local. */
|
|
193
|
+
admits(verdict) {
|
|
194
|
+
return verdict.local || this.options.settings().allowRemoteTerminal;
|
|
195
|
+
}
|
|
196
|
+
async stop() {
|
|
197
|
+
// Clients first: a file that can't be removed must not keep a viewer attached.
|
|
198
|
+
for (const client of this.wss?.clients ?? [])
|
|
199
|
+
client.terminate();
|
|
200
|
+
try {
|
|
201
|
+
rmSync(localConfigPath(this.env), { force: true });
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
/* the port and token in it are dead once the server closes below */
|
|
205
|
+
}
|
|
206
|
+
this.wss?.close();
|
|
207
|
+
const server = this.server;
|
|
208
|
+
this.server = undefined;
|
|
209
|
+
this.wss = undefined;
|
|
210
|
+
if (server)
|
|
211
|
+
await closeServer(server);
|
|
212
|
+
}
|
|
213
|
+
/** Every WebSocket handshake comes through here; a refused one never reaches `ws`. */
|
|
214
|
+
onUpgrade(req, socket, head) {
|
|
215
|
+
const refusal = refuseHandshake(req.headers, req.url, this.port);
|
|
216
|
+
if (refusal || !this.wss) {
|
|
217
|
+
const { status, reason } = refusal ?? { status: 503, reason: 'stopping' };
|
|
218
|
+
socket.write(`HTTP/1.1 ${status} ${reason}\r\nConnection: close\r\n\r\n`);
|
|
219
|
+
socket.destroy();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
this.wss.handleUpgrade(req, socket, head, (ws) => this.onSocket(ws));
|
|
223
|
+
}
|
|
224
|
+
onRequest(req, res) {
|
|
225
|
+
const refuse = (status) => {
|
|
226
|
+
res.writeHead(status, { 'content-type': 'application/json' });
|
|
227
|
+
res.end('{}');
|
|
228
|
+
};
|
|
229
|
+
if (req.headers.origin !== undefined)
|
|
230
|
+
return refuse(403);
|
|
231
|
+
if (req.headers.host !== `127.0.0.1:${this.port}` && req.headers.host !== `localhost:${this.port}`)
|
|
232
|
+
return refuse(403);
|
|
233
|
+
if (req.method !== 'GET' || new URL(req.url ?? '/', 'http://127.0.0.1').pathname !== LOCAL_TERMINALS_PATH) {
|
|
234
|
+
return refuse(404);
|
|
235
|
+
}
|
|
236
|
+
const auth = req.headers.authorization ?? '';
|
|
237
|
+
if (!auth.startsWith('Bearer ') || !tokensMatch(this.token, auth.slice('Bearer '.length)))
|
|
238
|
+
return refuse(401);
|
|
239
|
+
const terminals = this.options.settings().allowRemoteTerminal
|
|
240
|
+
? this.options.terminals.list().map((t) => ({
|
|
241
|
+
terminalId: t.terminalId,
|
|
242
|
+
codeThreadId: t.codeThreadId,
|
|
243
|
+
agent: t.agent,
|
|
244
|
+
windows: t.windows,
|
|
245
|
+
}))
|
|
246
|
+
: [];
|
|
247
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
248
|
+
res.end(JSON.stringify({ terminals }));
|
|
249
|
+
}
|
|
250
|
+
onSocket(ws) {
|
|
251
|
+
const fail = (reason, code = 1008) => {
|
|
252
|
+
try {
|
|
253
|
+
ws.send(JSON.stringify({ type: 'error', reason }));
|
|
254
|
+
ws.close(code, reason);
|
|
255
|
+
}
|
|
256
|
+
catch {
|
|
257
|
+
ws.terminate();
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
const timer = setTimeout(() => fail('auth_timeout'), AUTH_TIMEOUT_MS);
|
|
261
|
+
timer.unref?.();
|
|
262
|
+
ws.once('message', (data, isBinary) => {
|
|
263
|
+
clearTimeout(timer);
|
|
264
|
+
if (isBinary)
|
|
265
|
+
return fail('expected_auth_frame');
|
|
266
|
+
const parsed = parseLocalAuth(data.toString(), this.token);
|
|
267
|
+
if ('error' in parsed)
|
|
268
|
+
return fail(parsed.error);
|
|
269
|
+
void this.options
|
|
270
|
+
.verifyViewer(parsed.viewerToken)
|
|
271
|
+
.then((verdict) => {
|
|
272
|
+
if (!verdict.ok)
|
|
273
|
+
return fail(verdict.reason);
|
|
274
|
+
// Remote off: only a token minted for this Mac's own desktop app opens anything.
|
|
275
|
+
if (!this.admits(verdict))
|
|
276
|
+
return fail('remote_terminal_disabled');
|
|
277
|
+
// The token names one terminal (and maybe one window); it opens nothing else.
|
|
278
|
+
if (verdict.terminalId !== parsed.terminalId)
|
|
279
|
+
return fail('not_allowed');
|
|
280
|
+
if (verdict.window !== null && verdict.window !== parsed.window)
|
|
281
|
+
return fail('invalid_window');
|
|
282
|
+
return this.options.terminals
|
|
283
|
+
.attach({
|
|
284
|
+
terminalId: parsed.terminalId,
|
|
285
|
+
window: parsed.window,
|
|
286
|
+
cols: parsed.cols,
|
|
287
|
+
rows: parsed.rows,
|
|
288
|
+
mode: verdict.mode,
|
|
289
|
+
})
|
|
290
|
+
.then((attached) => this.serve(ws, attached, fail, verdict.local, `${parsed.terminalId.slice(0, 12)} (${parsed.window}, ${verdict.mode}${verdict.local ? ', local' : ''})`));
|
|
291
|
+
})
|
|
292
|
+
// Fail closed on anything unexpected: no verdict, no terminal.
|
|
293
|
+
.catch(() => fail('relay_unavailable', 1011));
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
/** Hand a verified attach to its socket, re-checking what may have changed while it opened. */
|
|
297
|
+
serve(ws, attached, fail, local, what = '') {
|
|
298
|
+
if (!attached.ok)
|
|
299
|
+
return fail(attached.reason, 1011);
|
|
300
|
+
// Re-checked after the attach: the switch may have turned off, or the server stopped, meanwhile.
|
|
301
|
+
if (!this.wss || !this.admits({ local })) {
|
|
302
|
+
attached.stream.close();
|
|
303
|
+
return fail('remote_terminal_disabled');
|
|
304
|
+
}
|
|
305
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
306
|
+
attached.stream.close();
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (!local) {
|
|
310
|
+
this.remoteClients.add(ws);
|
|
311
|
+
ws.on('close', () => this.remoteClients.delete(ws));
|
|
312
|
+
}
|
|
313
|
+
ws.send(JSON.stringify({ type: 'ready' }));
|
|
314
|
+
// The desktop fast path, not the relay: the one line that tells the two apart on this side.
|
|
315
|
+
this.options.log?.(` ⇄ terminal ${what} attached by the desktop app on this Mac (no relay)`);
|
|
316
|
+
this.heartbeat(ws);
|
|
317
|
+
pipeStream(ws, attached.stream, () => { });
|
|
318
|
+
}
|
|
319
|
+
/** Same heartbeat as the relay: a ping every 30 s, closed after 60 s without a pong. */
|
|
320
|
+
heartbeat(ws) {
|
|
321
|
+
let lastPong = Date.now();
|
|
322
|
+
ws.on('message', (data, isBinary) => {
|
|
323
|
+
if (isBinary)
|
|
324
|
+
return;
|
|
325
|
+
if (parseControl(data.toString())?.type === 'pong')
|
|
326
|
+
lastPong = Date.now();
|
|
327
|
+
});
|
|
328
|
+
const interval = setInterval(() => {
|
|
329
|
+
if (Date.now() - lastPong >= PONG_TIMEOUT_MS) {
|
|
330
|
+
ws.close(1011, 'heartbeat_timeout');
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (ws.readyState === WebSocket.OPEN)
|
|
334
|
+
ws.send(JSON.stringify({ type: 'ping' }));
|
|
335
|
+
}, PING_INTERVAL_MS);
|
|
336
|
+
interval.unref?.();
|
|
337
|
+
ws.on('close', () => clearInterval(interval));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node-pty, loaded only when a terminal is attached.
|
|
3
|
+
*
|
|
4
|
+
* It is a native module, so it is an *optional* dependency: a CLI installed
|
|
5
|
+
* where its prebuilt binary doesn't load still pairs, runs agents and hosts
|
|
6
|
+
* threads — only attaching a viewer answers `pty_unavailable`. Pinned to the
|
|
7
|
+
* version the cloud sandbox's terminal server uses (1.1.0); 1.2 is still beta.
|
|
8
|
+
*
|
|
9
|
+
* Each attach owns exactly one pty, killed when its viewer goes. node-pty 1.1
|
|
10
|
+
* has been seen to leak `/dev/ptmx` descriptors in hosts that churn ptys
|
|
11
|
+
* without killing them, so the host caps how many are open at once
|
|
12
|
+
* ({@link MAX_OPEN_PTYS}) and always kills on close.
|
|
13
|
+
*/
|
|
14
|
+
export declare const MAX_OPEN_PTYS = 16;
|
|
15
|
+
export interface PtyDisposable {
|
|
16
|
+
dispose(): void;
|
|
17
|
+
}
|
|
18
|
+
/** The slice of node-pty's `IPty` the host uses. */
|
|
19
|
+
export interface PtyProcess {
|
|
20
|
+
onData(listener: (data: string) => void): PtyDisposable | void;
|
|
21
|
+
onExit(listener: (event: {
|
|
22
|
+
exitCode: number;
|
|
23
|
+
signal?: number;
|
|
24
|
+
}) => void): PtyDisposable | void;
|
|
25
|
+
write(data: string): void;
|
|
26
|
+
resize(cols: number, rows: number): void;
|
|
27
|
+
kill(signal?: string): void;
|
|
28
|
+
}
|
|
29
|
+
export interface PtySpawnOptions {
|
|
30
|
+
name: string;
|
|
31
|
+
cols: number;
|
|
32
|
+
rows: number;
|
|
33
|
+
cwd: string;
|
|
34
|
+
env: Record<string, string>;
|
|
35
|
+
}
|
|
36
|
+
export type PtySpawn = (file: string, args: string[], options: PtySpawnOptions) => PtyProcess;
|
|
37
|
+
/** node-pty's `spawn`, or `undefined` when the module is not installed or won't load. */
|
|
38
|
+
export declare function loadNodePty(): PtySpawn | undefined;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* node-pty, loaded only when a terminal is attached.
|
|
3
|
+
*
|
|
4
|
+
* It is a native module, so it is an *optional* dependency: a CLI installed
|
|
5
|
+
* where its prebuilt binary doesn't load still pairs, runs agents and hosts
|
|
6
|
+
* threads — only attaching a viewer answers `pty_unavailable`. Pinned to the
|
|
7
|
+
* version the cloud sandbox's terminal server uses (1.1.0); 1.2 is still beta.
|
|
8
|
+
*
|
|
9
|
+
* Each attach owns exactly one pty, killed when its viewer goes. node-pty 1.1
|
|
10
|
+
* has been seen to leak `/dev/ptmx` descriptors in hosts that churn ptys
|
|
11
|
+
* without killing them, so the host caps how many are open at once
|
|
12
|
+
* ({@link MAX_OPEN_PTYS}) and always kills on close.
|
|
13
|
+
*/
|
|
14
|
+
import { chmodSync, existsSync, statSync } from 'node:fs';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
export const MAX_OPEN_PTYS = 16;
|
|
18
|
+
let cached;
|
|
19
|
+
/**
|
|
20
|
+
* npm sometimes unpacks node-pty's prebuilt `spawn-helper` without its execute
|
|
21
|
+
* bit, and every spawn then fails with `posix_spawnp failed`. Restore it.
|
|
22
|
+
*/
|
|
23
|
+
function ensureSpawnHelperExecutable(packageJsonPath) {
|
|
24
|
+
const helper = path.join(path.dirname(packageJsonPath), 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper');
|
|
25
|
+
try {
|
|
26
|
+
if (existsSync(helper) && (statSync(helper).mode & 0o111) === 0)
|
|
27
|
+
chmodSync(helper, 0o755);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
/* best effort — the spawn will say what is wrong */
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** node-pty's `spawn`, or `undefined` when the module is not installed or won't load. */
|
|
34
|
+
export function loadNodePty() {
|
|
35
|
+
if (cached !== undefined)
|
|
36
|
+
return cached ?? undefined;
|
|
37
|
+
try {
|
|
38
|
+
const require = createRequire(import.meta.url);
|
|
39
|
+
ensureSpawnHelperExecutable(require.resolve('node-pty/package.json'));
|
|
40
|
+
const mod = require('node-pty');
|
|
41
|
+
cached = typeof mod.spawn === 'function' ? mod.spawn : null;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
cached = null;
|
|
45
|
+
}
|
|
46
|
+
return cached ?? undefined;
|
|
47
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a thread's Claude Code is past its workspace trust dialog.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code runs no hook until its folder is trusted (checked against
|
|
5
|
+
* 2.1.314: with the dialog up, not even `SessionStart` fires; once it is
|
|
6
|
+
* accepted, `SessionStart` comes first). So the first hook that runs in a
|
|
7
|
+
* thread's window writes `<state>/<terminal>.ready`, and that mark, never the
|
|
8
|
+
* screen, is what lets a relayed reply be typed: before it, the dialog may be
|
|
9
|
+
* up or about to be drawn, and an Enter would answer it. The mark is a file,
|
|
10
|
+
* so it outlives a daemon restart.
|
|
11
|
+
*/
|
|
12
|
+
export declare function readyPath(terminalId: string, env?: NodeJS.ProcessEnv): string;
|
|
13
|
+
/**
|
|
14
|
+
* Mark the terminal's Claude Code past its trust dialog (a hook ran). Atomic (`writePrivateJson`).
|
|
15
|
+
* Returns whether it is marked afterwards; `false` means the write failed, and the caller says so.
|
|
16
|
+
*/
|
|
17
|
+
export declare function markReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean;
|
|
18
|
+
/**
|
|
19
|
+
* Whether Claude Code ran a session in exactly `cwd`, under `configDir`, that it last wrote at or
|
|
20
|
+
* after `sinceMs`: a `projects/<key>/<session>.jsonl` modified since then whose entries name `cwd`
|
|
21
|
+
* itself (read from inside the file: the dash-encoded `<key>` is lossy, `/a-b` and `/a/b` share it).
|
|
22
|
+
*
|
|
23
|
+
* Claude Code writes that transcript only once the folder is trusted (checked against 2.1.314:
|
|
24
|
+
* nothing under `projects/` while the dialog is up; the transcript the moment it is accepted and
|
|
25
|
+
* the first prompt runs). With `sinceMs` the window's start, it is proof, from a file Claude Code
|
|
26
|
+
* owns, that this window's Claude Code got past its dialog. Used only for a window an older build
|
|
27
|
+
* started, which never had a ready mark.
|
|
28
|
+
*/
|
|
29
|
+
export declare function claudeSessionSince(cwd: string, configDir: string, sinceMs: number): boolean;
|
|
30
|
+
/** `true` past the dialog, `false` not yet, `undefined` when the state can't be read. */
|
|
31
|
+
export declare function isReady(terminalId: string, env?: NodeJS.ProcessEnv): boolean | undefined;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether a thread's Claude Code is past its workspace trust dialog.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code runs no hook until its folder is trusted (checked against
|
|
5
|
+
* 2.1.314: with the dialog up, not even `SessionStart` fires; once it is
|
|
6
|
+
* accepted, `SessionStart` comes first). So the first hook that runs in a
|
|
7
|
+
* thread's window writes `<state>/<terminal>.ready`, and that mark, never the
|
|
8
|
+
* screen, is what lets a relayed reply be typed: before it, the dialog may be
|
|
9
|
+
* up or about to be drawn, and an Enter would answer it. The mark is a file,
|
|
10
|
+
* so it outlives a daemon restart.
|
|
11
|
+
*/
|
|
12
|
+
import { closeSync, openSync, readdirSync, readSync, realpathSync, statSync } from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { codeHostDir, writePrivateJson } from './settings.js';
|
|
15
|
+
import { isValidId } from './tmux.js';
|
|
16
|
+
export function readyPath(terminalId, env = process.env) {
|
|
17
|
+
if (!isValidId(terminalId))
|
|
18
|
+
throw new Error('invalid terminal id');
|
|
19
|
+
return path.join(codeHostDir(env), 'state', `${terminalId}.ready`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Mark the terminal's Claude Code past its trust dialog (a hook ran). Atomic (`writePrivateJson`).
|
|
23
|
+
* Returns whether it is marked afterwards; `false` means the write failed, and the caller says so.
|
|
24
|
+
*/
|
|
25
|
+
export function markReady(terminalId, env = process.env) {
|
|
26
|
+
if (!isValidId(terminalId))
|
|
27
|
+
return false;
|
|
28
|
+
if (isReady(terminalId, env) === true)
|
|
29
|
+
return true;
|
|
30
|
+
try {
|
|
31
|
+
writePrivateJson(readyPath(terminalId, env), Date.now());
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** How much of a transcript is read looking for its folder (its first entries carry `cwd`). */
|
|
39
|
+
const TRANSCRIPT_HEAD_BYTES = 256 * 1024;
|
|
40
|
+
/**
|
|
41
|
+
* Whether Claude Code ran a session in exactly `cwd`, under `configDir`, that it last wrote at or
|
|
42
|
+
* after `sinceMs`: a `projects/<key>/<session>.jsonl` modified since then whose entries name `cwd`
|
|
43
|
+
* itself (read from inside the file: the dash-encoded `<key>` is lossy, `/a-b` and `/a/b` share it).
|
|
44
|
+
*
|
|
45
|
+
* Claude Code writes that transcript only once the folder is trusted (checked against 2.1.314:
|
|
46
|
+
* nothing under `projects/` while the dialog is up; the transcript the moment it is accepted and
|
|
47
|
+
* the first prompt runs). With `sinceMs` the window's start, it is proof, from a file Claude Code
|
|
48
|
+
* owns, that this window's Claude Code got past its dialog. Used only for a window an older build
|
|
49
|
+
* started, which never had a ready mark.
|
|
50
|
+
*/
|
|
51
|
+
export function claudeSessionSince(cwd, configDir, sinceMs) {
|
|
52
|
+
let real;
|
|
53
|
+
try {
|
|
54
|
+
real = realpathSync(cwd);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const dir = path.join(configDir, 'projects', real.replace(/[^a-zA-Z0-9]/g, '-'));
|
|
60
|
+
let names;
|
|
61
|
+
try {
|
|
62
|
+
names = readdirSync(dir).filter((name) => name.endsWith('.jsonl'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
return names.some((name) => {
|
|
68
|
+
const file = path.join(dir, name);
|
|
69
|
+
let fd;
|
|
70
|
+
try {
|
|
71
|
+
if (statSync(file).mtimeMs < sinceMs)
|
|
72
|
+
return false;
|
|
73
|
+
fd = openSync(file, 'r');
|
|
74
|
+
const buffer = Buffer.alloc(TRANSCRIPT_HEAD_BYTES);
|
|
75
|
+
const read = readSync(fd, buffer, 0, buffer.length, 0);
|
|
76
|
+
return buffer
|
|
77
|
+
.subarray(0, read)
|
|
78
|
+
.toString('utf8')
|
|
79
|
+
.split('\n')
|
|
80
|
+
.some((line) => {
|
|
81
|
+
if (!line.includes('"cwd"'))
|
|
82
|
+
return false;
|
|
83
|
+
try {
|
|
84
|
+
return JSON.parse(line).cwd === real;
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return false; // a line cut at the read's end
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
if (fd !== undefined)
|
|
96
|
+
closeSync(fd);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
/** `true` past the dialog, `false` not yet, `undefined` when the state can't be read. */
|
|
101
|
+
export function isReady(terminalId, env = process.env) {
|
|
102
|
+
try {
|
|
103
|
+
statSync(readyPath(terminalId, env));
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return err.code === 'ENOENT' ? false : undefined;
|
|
108
|
+
}
|
|
109
|
+
}
|