@prohost/cli 0.13.3 → 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 (59) hide show
  1. package/CHANGELOG.md +75 -0
  2. package/README.md +129 -0
  3. package/dist/agent/command.d.ts +8 -0
  4. package/dist/agent/command.js +40 -7
  5. package/dist/agent/daemon.d.ts +3 -1
  6. package/dist/agent/daemon.js +1 -2
  7. package/dist/agent/run.d.ts +30 -1
  8. package/dist/agent/run.js +221 -9
  9. package/dist/agent/runtime_status.d.ts +99 -3
  10. package/dist/agent/runtime_status.js +173 -5
  11. package/dist/agent/worktrees.d.ts +2 -0
  12. package/dist/agent/worktrees.js +3 -2
  13. package/dist/codehost/activity.d.ts +64 -0
  14. package/dist/codehost/activity.js +135 -0
  15. package/dist/codehost/agents.d.ts +31 -0
  16. package/dist/codehost/agents.js +57 -0
  17. package/dist/codehost/checkouts.d.ts +130 -0
  18. package/dist/codehost/checkouts.js +270 -0
  19. package/dist/codehost/claude_trust.d.ts +40 -0
  20. package/dist/codehost/claude_trust.js +129 -0
  21. package/dist/codehost/command.d.ts +25 -0
  22. package/dist/codehost/command.js +136 -0
  23. package/dist/codehost/decision.d.ts +156 -0
  24. package/dist/codehost/decision.js +553 -0
  25. package/dist/codehost/events.d.ts +49 -0
  26. package/dist/codehost/events.js +87 -0
  27. package/dist/codehost/folders.d.ts +122 -0
  28. package/dist/codehost/folders.js +317 -0
  29. package/dist/codehost/hooks.d.ts +178 -0
  30. package/dist/codehost/hooks.js +726 -0
  31. package/dist/codehost/host.d.ts +290 -0
  32. package/dist/codehost/host.js +625 -0
  33. package/dist/codehost/host_stats.d.ts +71 -0
  34. package/dist/codehost/host_stats.js +166 -0
  35. package/dist/codehost/invocations.d.ts +12 -0
  36. package/dist/codehost/invocations.js +82 -0
  37. package/dist/codehost/ledger.d.ts +35 -0
  38. package/dist/codehost/ledger.js +91 -0
  39. package/dist/codehost/local_server.d.ts +106 -0
  40. package/dist/codehost/local_server.js +339 -0
  41. package/dist/codehost/pty.d.ts +38 -0
  42. package/dist/codehost/pty.js +47 -0
  43. package/dist/codehost/ready.d.ts +31 -0
  44. package/dist/codehost/ready.js +109 -0
  45. package/dist/codehost/relay.d.ts +170 -0
  46. package/dist/codehost/relay.js +548 -0
  47. package/dist/codehost/settings.d.ts +52 -0
  48. package/dist/codehost/settings.js +114 -0
  49. package/dist/codehost/sizing.d.ts +163 -0
  50. package/dist/codehost/sizing.js +394 -0
  51. package/dist/codehost/terminals.d.ts +390 -0
  52. package/dist/codehost/terminals.js +965 -0
  53. package/dist/codehost/tmux.d.ts +196 -0
  54. package/dist/codehost/tmux.js +391 -0
  55. package/dist/index.d.ts +5 -0
  56. package/dist/index.js +21 -0
  57. package/dist/version.d.ts +2 -2
  58. package/dist/version.js +1 -1
  59. package/package.json +4 -1
@@ -0,0 +1,553 @@
1
+ /**
2
+ * A decision made in ProhostAI, answered by the agent's own `PermissionRequest`
3
+ * hook rather than by keys pressed into its pane.
4
+ *
5
+ * Claude Code draws its permission menu while that hook is still running, and
6
+ * applies whichever comes first: the hook's printed decision or an answer in
7
+ * the terminal (checked against 2.1.314). So the hook waits, bounded, for a
8
+ * decision for its own prompt, and a decision can never land on another one.
9
+ *
10
+ * Two files per prompt, in the private state dir, named by its `approvalId`:
11
+ *
12
+ * - `<terminal>.waiter-<id>.json`: the hook waiting on it (`pid`, `deadline`,
13
+ * tool fingerprint). Fingerprints are diagnostic only: they do not establish
14
+ * invocation identity or application.
15
+ * - `<terminal>.decision-<id>.json`: **the settlement**, created exactly once
16
+ * (a temp file `link`ed into place, which never overwrites) by whoever
17
+ * settles the prompt first:
18
+ * - `host`: the decision from ProhostAI, which the waiting hook prints;
19
+ * - `timeout`: the hook's wait ran out; the menu is answered in the terminal;
20
+ * - `local`: turn/user progress retired the prompt;
21
+ * - `cancelled`: the hook received a stop signal; not proof the menu closed.
22
+ *
23
+ * A host acknowledgement records handoff to a hook, not proof the parent
24
+ * applied it. PermissionRequest lacks invocation identity. A verified PreToolUse
25
+ * hook records the candidate IDs first; only their own transcript completions,
26
+ * a new user prompt or a completed turn release replies. Legacy per-tool
27
+ * receipts are not trusted.
28
+ */
29
+ import { createHash, randomBytes } from 'node:crypto';
30
+ import { closeSync, fstatSync, linkSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from 'node:fs';
31
+ import path from 'node:path';
32
+ import { forgetInvocations } from './invocations.js';
33
+ import { codeHostDir, ensurePrivateDir, writePrivateJson } from './settings.js';
34
+ import { isValidId } from './tmux.js';
35
+ /** Approval ids as `permission_request` mints them (`approvalIdFor`). */
36
+ const APPROVAL_ID = /^[A-Za-z0-9-]{1,64}$/;
37
+ const DECISIONS = new Set(['allow', 'allow_session', 'deny']);
38
+ function stateDir(env) {
39
+ return path.join(codeHostDir(env), 'state');
40
+ }
41
+ function checked(terminalId, approvalId) {
42
+ if (!isValidId(terminalId) || !APPROVAL_ID.test(approvalId))
43
+ throw new Error('invalid approval name');
44
+ }
45
+ export function waiterPath(terminalId, approvalId, env = process.env) {
46
+ checked(terminalId, approvalId);
47
+ return path.join(stateDir(env), `${terminalId}.waiter-${approvalId}.json`);
48
+ }
49
+ export function settlementPath(terminalId, approvalId, env = process.env) {
50
+ checked(terminalId, approvalId);
51
+ return path.join(stateDir(env), `${terminalId}.decision-${approvalId}.json`);
52
+ }
53
+ /** A tool call as `PermissionRequest` saw it. Not an invocation's identity: identical calls share it. */
54
+ export function toolFingerprint(toolName, toolInput) {
55
+ return createHash('sha256')
56
+ .update(JSON.stringify([typeof toolName === 'string' ? toolName : '', toolInput ?? null]))
57
+ .digest('hex')
58
+ .slice(0, 32);
59
+ }
60
+ /**
61
+ * A transcript is read whole up to this bound, preserving complete ID/result evidence.
62
+ * Beyond it nothing is released; a later turn-progress hook still clears.
63
+ */
64
+ const TRANSCRIPT_MAX_BYTES = 64 * 1024 * 1024;
65
+ const transcriptCache = new Map();
66
+ /** Verification reads the whole cached prefix with bounded temporary storage. */
67
+ const TRANSCRIPT_HASH_CHUNK_BYTES = 64 * 1024;
68
+ /** A finished session's transcript is forgotten once no prompt has asked about it for this long. */
69
+ const TRANSCRIPT_CACHE_IDLE_MS = 30 * 60_000;
70
+ /** The hash of every byte already read from `fd`, rather than a sampled window. */
71
+ function prefixHash(fd, end) {
72
+ const hash = createHash('sha256');
73
+ const bytes = Buffer.alloc(Math.min(end, TRANSCRIPT_HASH_CHUNK_BYTES));
74
+ for (let offset = 0; offset < end;) {
75
+ const length = Math.min(bytes.length, end - offset);
76
+ const read = readSync(fd, bytes, 0, length, offset);
77
+ if (read === 0)
78
+ throw new Error('Transcript changed during verification');
79
+ hash.update(bytes.subarray(0, read));
80
+ offset += read;
81
+ }
82
+ return hash.digest('hex');
83
+ }
84
+ /** Drops every cached transcript no prompt has read for {@link TRANSCRIPT_CACHE_IDLE_MS}. */
85
+ function pruneTranscripts(now) {
86
+ for (const [file, cache] of transcriptCache) {
87
+ if (now - cache.readAt > TRANSCRIPT_CACHE_IDLE_MS)
88
+ transcriptCache.delete(file);
89
+ }
90
+ }
91
+ /** Lines of a transcript parsed, ever: for tests that a second read parses only what was appended. */
92
+ export const transcriptParseCounter = { lines: 0 };
93
+ /** For tests: forget every transcript read so far. */
94
+ export function forgetTranscriptsForTests() {
95
+ transcriptCache.clear();
96
+ transcriptParseCounter.lines = 0;
97
+ }
98
+ /**
99
+ * Adds one transcript line's calls and results to `record`. `false`: not a readable entry, which may
100
+ * have been a call, so every line is parsed: one cut short needn't say `tool_use` to have been one.
101
+ */
102
+ function addTranscriptLine(record, line) {
103
+ if (line.trim() === '')
104
+ return true;
105
+ transcriptParseCounter.lines += 1;
106
+ let entry;
107
+ try {
108
+ entry = JSON.parse(line);
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry))
114
+ return false;
115
+ const message = entry.message;
116
+ const content = Array.isArray(message?.content) ? message.content : [];
117
+ const at = typeof entry.timestamp === 'string' ? Date.parse(entry.timestamp) : Number.NaN;
118
+ if (!Number.isFinite(at)) {
119
+ // `api-request-blob`s are copies of what was sent, not a record of when. Any other call that can't
120
+ // be placed may be the held one.
121
+ return entry.type === 'api-request-blob' || !content.some((part) => part?.type === 'tool_use');
122
+ }
123
+ for (const part of content) {
124
+ if (part?.type === 'tool_use' && typeof part.id === 'string') {
125
+ record.uses.set(part.id, ++record.order);
126
+ }
127
+ else if (part?.type === 'tool_result' && typeof part.tool_use_id === 'string') {
128
+ record.results.set(part.tool_use_id, ++record.order);
129
+ }
130
+ }
131
+ return true;
132
+ }
133
+ /**
134
+ * Every call and result in the transcript at `file` (absolute, `.jsonl`), read whole the first time
135
+ * and then only as it grows. `undefined` (closed: nothing is released) when it can't be read, is
136
+ * larger than {@link TRANSCRIPT_MAX_BYTES}, has a complete line that isn't readable, or ends in a line
137
+ * still being written, which may be the held call's own.
138
+ */
139
+ function readTranscriptRecord(file, now = Date.now()) {
140
+ if (!path.isAbsolute(file) || !file.endsWith('.jsonl'))
141
+ return undefined;
142
+ pruneTranscripts(now);
143
+ let fd;
144
+ try {
145
+ let stat;
146
+ try {
147
+ stat = statSync(file);
148
+ }
149
+ catch (err) {
150
+ // Gone (a finished session's transcript deleted), or unreadable: nothing of it is kept.
151
+ transcriptCache.delete(file);
152
+ throw err;
153
+ }
154
+ if (!stat.isFile() || stat.size > TRANSCRIPT_MAX_BYTES) {
155
+ transcriptCache.delete(file);
156
+ return undefined;
157
+ }
158
+ fd = openSync(file, 'r');
159
+ // Bind the snapshot to the opened file if the path was replaced after statSync.
160
+ stat = fstatSync(fd);
161
+ if (!stat.isFile() || stat.size > TRANSCRIPT_MAX_BYTES) {
162
+ transcriptCache.delete(file);
163
+ return undefined;
164
+ }
165
+ let cache = transcriptCache.get(file);
166
+ const rewritten = cache !== undefined &&
167
+ (cache.ino !== stat.ino ||
168
+ stat.size < cache.offset + cache.partial.length ||
169
+ prefixHash(fd, cache.size) !== cache.prefix.copy().digest('hex'));
170
+ if (!cache || rewritten) {
171
+ cache = {
172
+ ino: stat.ino,
173
+ size: 0,
174
+ mtimeMs: 0,
175
+ offset: 0,
176
+ partial: Buffer.alloc(0),
177
+ broken: false,
178
+ record: { uses: new Map(), results: new Map(), order: 0 },
179
+ prefix: createHash('sha256'),
180
+ readAt: now,
181
+ };
182
+ transcriptCache.set(file, cache);
183
+ }
184
+ cache.readAt = now;
185
+ if (stat.size !== cache.size || stat.mtimeMs !== cache.mtimeMs) {
186
+ const start = cache.offset + cache.partial.length;
187
+ const added = Buffer.alloc(stat.size - start);
188
+ for (let offset = 0; offset < added.length;) {
189
+ const read = readSync(fd, added, offset, added.length - offset, start + offset);
190
+ if (read === 0)
191
+ throw new Error('Transcript changed during read');
192
+ offset += read;
193
+ }
194
+ const bytes = Buffer.concat([cache.partial, added]);
195
+ const end = bytes.lastIndexOf(0x0a) + 1;
196
+ for (const line of bytes.subarray(0, end).toString('utf8').split('\n')) {
197
+ if (!cache.broken && !addTranscriptLine(cache.record, line))
198
+ cache.broken = true;
199
+ }
200
+ cache.offset += end;
201
+ cache.partial = Buffer.from(bytes.subarray(end));
202
+ cache.size = stat.size;
203
+ cache.mtimeMs = stat.mtimeMs;
204
+ // Hash the bytes used by the parser, not a second file read that could already be a rewrite.
205
+ cache.prefix.update(added);
206
+ }
207
+ // A rewrite may change an already-verified chunk while later chunks are being read.
208
+ const after = fstatSync(fd);
209
+ if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size ||
210
+ after.mtimeMs !== stat.mtimeMs || after.ctimeMs !== stat.ctimeMs) {
211
+ transcriptCache.delete(file);
212
+ return undefined;
213
+ }
214
+ return cache.broken || cache.partial.length > 0 ? undefined : cache.record;
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ finally {
220
+ if (fd !== undefined)
221
+ closeSync(fd);
222
+ }
223
+ }
224
+ /** Every invocation that could own this permission must have its own subsequent result. */
225
+ function callIsOver(record, ids) {
226
+ return ids.length > 0 && ids.every((id) => {
227
+ const use = record.uses.get(id);
228
+ const result = record.results.get(id);
229
+ return use !== undefined && result !== undefined && result > use;
230
+ });
231
+ }
232
+ /** Exact IDs, not content, counts or timestamp proximity, establish transcript completion. */
233
+ export function transcriptReleases(transcript, ids, now = Date.now()) {
234
+ const record = readTranscriptRecord(transcript, now);
235
+ return record !== undefined && callIsOver(record, ids);
236
+ }
237
+ /** For tests: the transcripts the cache holds now. */
238
+ export function cachedTranscriptsForTests() {
239
+ return [...transcriptCache.keys()];
240
+ }
241
+ function processAlive(pid) {
242
+ try {
243
+ process.kill(pid, 0);
244
+ return true;
245
+ }
246
+ catch (err) {
247
+ return err.code === 'EPERM';
248
+ }
249
+ }
250
+ function parseSettlement(text) {
251
+ try {
252
+ const value = JSON.parse(text);
253
+ const at = typeof value.at === 'number' ? value.at : 0;
254
+ if (value.by === 'host') {
255
+ return typeof value.decision === 'string' && DECISIONS.has(value.decision)
256
+ ? { by: 'host', decision: value.decision, at }
257
+ : undefined;
258
+ }
259
+ if (value.by === 'timeout' || value.by === 'local' || value.by === 'cancelled')
260
+ return { by: value.by, at };
261
+ return undefined;
262
+ }
263
+ catch {
264
+ return undefined;
265
+ }
266
+ }
267
+ export function readSettlement(terminalId, approvalId, env = process.env) {
268
+ try {
269
+ return parseSettlement(readFileSync(settlementPath(terminalId, approvalId, env), 'utf8'));
270
+ }
271
+ catch {
272
+ return undefined;
273
+ }
274
+ }
275
+ /**
276
+ * Settle the prompt with `settlement` unless it is settled already. Created
277
+ * whole and never overwritten: its contents go to a unique temp file, linked
278
+ * into place (EEXIST: someone settled it first). Returns the settlement that
279
+ * stands afterwards, ours or theirs; `undefined` only when the state can't be
280
+ * written or read, which every caller treats as "not by ProhostAI".
281
+ */
282
+ export function settle(terminalId, approvalId, settlement, env = process.env) {
283
+ const file = settlementPath(terminalId, approvalId, env);
284
+ try {
285
+ ensurePrivateDir(path.dirname(file));
286
+ const temp = `${file}.new-${randomBytes(8).toString('hex')}`;
287
+ writeFileSync(temp, JSON.stringify(settlement), { mode: 0o600, flag: 'wx' });
288
+ try {
289
+ linkSync(temp, file);
290
+ return { settlement, ours: true };
291
+ }
292
+ catch (err) {
293
+ if (err.code !== 'EEXIST')
294
+ throw err;
295
+ }
296
+ finally {
297
+ rmSync(temp, { force: true });
298
+ }
299
+ }
300
+ catch {
301
+ return { settlement: readSettlement(terminalId, approvalId, env), ours: false };
302
+ }
303
+ return { settlement: readSettlement(terminalId, approvalId, env), ours: false };
304
+ }
305
+ export function readWaiter(terminalId, approvalId, env = process.env) {
306
+ try {
307
+ const value = JSON.parse(readFileSync(waiterPath(terminalId, approvalId, env), 'utf8'));
308
+ if (value.approvalId !== approvalId)
309
+ return undefined;
310
+ if (typeof value.pid !== 'number' || typeof value.deadline !== 'number' || typeof value.fingerprint !== 'string') {
311
+ return undefined;
312
+ }
313
+ const saved = value.permissionRequest;
314
+ const data = saved?.data;
315
+ const permissionRequest = saved?.type === 'permission_request' && typeof saved.at === 'string' &&
316
+ Number.isFinite(Date.parse(saved.at)) && data && typeof data === 'object' && !Array.isArray(data) && data.approvalId === approvalId
317
+ ? { type: 'permission_request', at: saved.at, data } : undefined;
318
+ return {
319
+ approvalId, pid: value.pid, deadline: value.deadline, fingerprint: value.fingerprint,
320
+ ...(permissionRequest ? { permissionRequest } : {}),
321
+ ...(typeof value.transcript === 'string' ? { transcript: value.transcript } : {}),
322
+ ...(Array.isArray(value.toolUseIds) && value.toolUseIds.length > 0 && value.toolUseIds.every((id) => typeof id === 'string' && id.length > 0 && id.length <= 512) ? { toolUseIds: value.toolUseIds } : {}),
323
+ ...(value.overrideReported === true ? { overrideReported: true } : {}),
324
+ };
325
+ }
326
+ catch {
327
+ return undefined;
328
+ }
329
+ }
330
+ /** The approval ids with a `kind` file for `terminalId`. */
331
+ function idsWith(kind, terminalId, env) {
332
+ if (!isValidId(terminalId))
333
+ return [];
334
+ const prefix = `${terminalId}.${kind}-`;
335
+ try {
336
+ return readdirSync(stateDir(env))
337
+ .filter((name) => name.startsWith(prefix) && name.endsWith('.json'))
338
+ .map((name) => name.slice(prefix.length, -'.json'.length))
339
+ .filter((id) => APPROVAL_ID.test(id));
340
+ }
341
+ catch {
342
+ return [];
343
+ }
344
+ }
345
+ const waiterIds = (terminalId, env) => idsWith('waiter', terminalId, env);
346
+ /** A settlement no hook waits on any more is kept this long (a redelivery of its decision still reads it). */
347
+ const SETTLEMENT_KEEP_MS = 30 * 60_000;
348
+ /** Remove one prompt's files. */
349
+ function forget(terminalId, approvalId, env) {
350
+ rmSync(waiterPath(terminalId, approvalId, env), { force: true });
351
+ rmSync(settlementPath(terminalId, approvalId, env), { force: true });
352
+ }
353
+ /**
354
+ * The hook registers itself as waiting for `waiter.approvalId`, before the
355
+ * server can hear of the prompt (so a decision that comes back at once finds
356
+ * it). Prior prompts, including completed ones, retain their fingerprints until
357
+ * turn progress: a delayed completion must never be mistaken for a new identical call.
358
+ */
359
+ export function registerWaiter(terminalId, waiter, env = process.env, now = Date.now) {
360
+ for (const id of idsWith('decision', terminalId, env)) {
361
+ if (id === waiter.approvalId || readWaiter(terminalId, id, env))
362
+ continue;
363
+ const at = readSettlement(terminalId, id, env)?.at ?? 0;
364
+ if (now() - at > SETTLEMENT_KEEP_MS)
365
+ forget(terminalId, id, env);
366
+ }
367
+ writePrivateJson(waiterPath(terminalId, waiter.approvalId, env), waiter);
368
+ }
369
+ /**
370
+ * Whether a `PermissionRequest` hook in this terminal reported a prompt within `withinMs`: one
371
+ * waits now, or one was settled that recently. A `permission_prompt` notification then is the
372
+ * same prompt, already reported.
373
+ */
374
+ export function recentPrompt(terminalId, env, now, withinMs, alive = processAlive) {
375
+ const waiting = waiterIds(terminalId, env).some((id) => {
376
+ const waiter = readWaiter(terminalId, id, env);
377
+ return waiter !== undefined && now < waiter.deadline && alive(waiter.pid);
378
+ });
379
+ if (waiting)
380
+ return true;
381
+ return idsWith('decision', terminalId, env).some((id) => now - (readSettlement(terminalId, id, env)?.at ?? 0) < withinMs);
382
+ }
383
+ export function menuState(terminalId, env = process.env, now = Date.now(), alive = processAlive) {
384
+ let state = 'clear';
385
+ const records = new Map();
386
+ for (const id of waiterIds(terminalId, env)) {
387
+ const waiter = readWaiter(terminalId, id, env);
388
+ if (!waiter)
389
+ continue;
390
+ const settlement = readSettlement(terminalId, id, env);
391
+ const running = alive(waiter.pid);
392
+ let held;
393
+ if (settlement === undefined) {
394
+ held = !running || now >= waiter.deadline ? 'open' : 'deciding';
395
+ // Answered in the terminal while its hook still waits (a "Yes" leaves the hook running): once
396
+ // Claude Code records the call over, the prompt is settled `local`, so its hook stops without
397
+ // printing and a decision arriving now is refused (`decide`).
398
+ if (held === 'deciding' && settleIfOver(terminalId, waiter, records, now, env))
399
+ held = undefined;
400
+ }
401
+ else if (settlement.by !== 'host') {
402
+ held = 'open';
403
+ }
404
+ else {
405
+ held = running ? 'deciding' : 'unconfirmed';
406
+ }
407
+ // A hook that no longer runs can't say its menu went: Escape or Ctrl+C after its wait ran out
408
+ // fires no hook. Claude Code's transcript does, once the call's permission flow is over.
409
+ if ((held === 'open' || held === 'unconfirmed') && resolvedInTranscript(waiter, records))
410
+ held = undefined;
411
+ if (held === 'open')
412
+ return 'open';
413
+ if (held === 'unconfirmed')
414
+ state = 'unconfirmed';
415
+ else if (held === 'deciding' && state === 'clear')
416
+ state = 'deciding';
417
+ }
418
+ return state;
419
+ }
420
+ /** Completed IDs are reported only once every local permission barrier has cleared. */
421
+ export function completedApprovals(terminalId, env = process.env) {
422
+ if (menuState(terminalId, env) !== 'clear')
423
+ return [];
424
+ const records = new Map();
425
+ return waiterIds(terminalId, env).filter((id) => {
426
+ const waiter = readWaiter(terminalId, id, env);
427
+ return waiter?.permissionRequest !== undefined && resolvedInTranscript(waiter, records);
428
+ });
429
+ }
430
+ /** Retain the waiter until the server acknowledges the approval-scoped recovery event. */
431
+ export function acknowledgeCompletedApproval(terminalId, approvalId, env) {
432
+ checked(terminalId, approvalId);
433
+ settle(terminalId, approvalId, { by: 'local', at: Date.now() }, env);
434
+ rmSync(waiterPath(terminalId, approvalId, env), { force: true });
435
+ }
436
+ /**
437
+ * Settle an unsettled prompt `local` if Claude Code's transcript shows its call over (`callIsOver`).
438
+ * Returns whether it is settled other than by ProhostAI afterwards (a decision may have won the race).
439
+ */
440
+ function settleIfOver(terminalId, waiter, records, now, env) {
441
+ if (!resolvedInTranscript(waiter, records))
442
+ return false;
443
+ return settle(terminalId, waiter.approvalId, { by: 'local', at: now }, env).settlement?.by !== 'host';
444
+ }
445
+ /** `transcriptReleases` for a held prompt; each transcript is read once per `menuState`. */
446
+ function resolvedInTranscript(waiter, records) {
447
+ const { transcript, toolUseIds } = waiter;
448
+ if (transcript === undefined || toolUseIds === undefined)
449
+ return false;
450
+ if (!records.has(transcript))
451
+ records.set(transcript, readTranscriptRecord(transcript));
452
+ const record = records.get(transcript);
453
+ return record !== undefined && callIsOver(record, toolUseIds);
454
+ }
455
+ /** Whether `approvalId` is a prompt a hook waits on (or waited on) here, so the hook answers it. */
456
+ export function hookDecides(terminalId, approvalId, env = process.env) {
457
+ if (!isValidId(terminalId) || !APPROVAL_ID.test(approvalId))
458
+ return false;
459
+ return readWaiter(terminalId, approvalId, env) !== undefined || readSettlement(terminalId, approvalId, env) !== undefined;
460
+ }
461
+ /**
462
+ * The host, for a decision from ProhostAI: settle the prompt with it, only
463
+ * while its hook still waits (alive, before its deadline, unsettled). Writes
464
+ * nothing otherwise, and presses no key on any path.
465
+ */
466
+ export function decide(terminalId, approvalId, decision, env = process.env, now = Date.now, alive = processAlive) {
467
+ if (!isValidId(terminalId) || !APPROVAL_ID.test(approvalId))
468
+ return 'local';
469
+ const standing = readSettlement(terminalId, approvalId, env);
470
+ if (standing)
471
+ return standing.by === 'host' ? 'already' : 'local';
472
+ const waiter = readWaiter(terminalId, approvalId, env);
473
+ if (!waiter || !alive(waiter.pid) || now() >= waiter.deadline)
474
+ return 'local';
475
+ // Its hook still waits after a "Yes" in the terminal: Claude Code's record of the call being over
476
+ // settles it there, so this decision, which would be ignored, is refused instead of acknowledged.
477
+ if (settleIfOver(terminalId, waiter, new Map(), now(), env))
478
+ return 'local';
479
+ const result = settle(terminalId, approvalId, { by: 'host', decision, at: now() }, env);
480
+ if (result.ours)
481
+ return 'delivered';
482
+ return result.settlement?.by === 'host' ? 'already' : 'local';
483
+ }
484
+ /**
485
+ * The hook, after registering: wait until its prompt is settled or `deadline`
486
+ * passes, then settle it `timeout` itself (unless a decision beat it to it).
487
+ * Returns ProhostAI's decision, or how it was otherwise settled.
488
+ */
489
+ export async function awaitSettlement(terminalId, approvalId, deadline, env = process.env, now = Date.now, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), pollMs = 100) {
490
+ let checkedAt = Number.NEGATIVE_INFINITY;
491
+ for (;;) {
492
+ const standing = readSettlement(terminalId, approvalId, env);
493
+ if (standing)
494
+ return standing;
495
+ if (now() >= deadline)
496
+ return settle(terminalId, approvalId, { by: 'timeout', at: now() }, env).settlement;
497
+ // Answered "Yes" in the terminal, the hook still runs: once Claude Code records the call over, it
498
+ // is settled here, and the hook stops quietly instead of waiting out its deadline to post a stale
499
+ // "answer it in the terminal". About once a second: the transcript is parsed only as it grows.
500
+ if (now() - checkedAt >= TRANSCRIPT_RECHECK_MS) {
501
+ checkedAt = now();
502
+ const waiter = readWaiter(terminalId, approvalId, env);
503
+ if (waiter && resolvedInTranscript(waiter, new Map())) {
504
+ return settle(terminalId, approvalId, { by: 'local', at: now() }, env).settlement;
505
+ }
506
+ }
507
+ await sleep(Math.min(pollMs, Math.max(0, deadline - now())));
508
+ }
509
+ }
510
+ /** How often a waiting hook looks in the transcript for its call's end. */
511
+ export const TRANSCRIPT_RECHECK_MS = 1_000;
512
+ /**
513
+ * A `PostToolUse` for invocation `toolUseId`: that tool ran. If ProhostAI had settled its prompt with a
514
+ * deny, the terminal's "Yes" came first (Claude Code ignores a decision once the prompt is answered),
515
+ * and the record must not say "denied" for a command that ran.
516
+ *
517
+ * Exact IDs only, as for releases: exactly one held prompt names `toolUseId` among the invocations
518
+ * that could own it, and it names no other, so no other call (an identical one, or a subagent's) could
519
+ * be the prompt's own; the call that ran is the prompt's own call (its tool and input, `fingerprint`):
520
+ * a lone candidate may be a `Task`/`Agent` whose subagent prompted, and that Task finishing says
521
+ * nothing about the denied command; it was settled with a deny from ProhostAI, not reported yet.
522
+ * Anything less returns `undefined`: no correction, never a false one. Marks the prompt reported and
523
+ * returns its approval id. Retires nothing: only turn progress does (`markTurnAnswered`).
524
+ */
525
+ export function beatenDeny(terminalId, toolUseId, toolName, toolInput, env = process.env) {
526
+ if (typeof toolUseId !== 'string' || !toolUseId)
527
+ return undefined;
528
+ const naming = waiterIds(terminalId, env)
529
+ .map((id) => readWaiter(terminalId, id, env))
530
+ .filter((waiter) => waiter?.toolUseIds?.includes(toolUseId) === true);
531
+ if (naming.length !== 1)
532
+ return undefined;
533
+ const [waiter] = naming;
534
+ if (waiter.overrideReported || waiter.toolUseIds.length !== 1)
535
+ return undefined;
536
+ if (toolFingerprint(toolName, toolInput) !== waiter.fingerprint)
537
+ return undefined;
538
+ const settlement = readSettlement(terminalId, waiter.approvalId, env);
539
+ if (settlement?.by !== 'host' || settlement.decision !== 'deny')
540
+ return undefined;
541
+ writePrivateJson(waiterPath(terminalId, waiter.approvalId, env), { ...waiter, overrideReported: true });
542
+ return waiter.approvalId;
543
+ }
544
+ /** A new user prompt or completed turn retires its permission waiters. */
545
+ export function markTurnAnswered(terminalId, env = process.env, now = Date.now) {
546
+ for (const id of waiterIds(terminalId, env)) {
547
+ if (!readWaiter(terminalId, id, env))
548
+ continue;
549
+ settle(terminalId, id, { by: 'local', at: now() }, env);
550
+ rmSync(waiterPath(terminalId, id, env), { force: true });
551
+ }
552
+ forgetInvocations(terminalId, env);
553
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Code-thread events: what a Mac-hosted terminal tells ProhostAI about its
3
+ * thread — a prompt, a finished turn, a permission prompt, an interrupt.
4
+ *
5
+ * Both the daemon and the hook commands Claude Code / Codex run post here. The
6
+ * credential is not the paired API key: it is a narrow, expiring `host_events`
7
+ * token the relay mints for this machine over its control socket, kept in
8
+ * `$PROHOST_HOME/code-host/hook.json` (0600) and re-read on every post. So a
9
+ * hook in a shell that has been open for days uses whatever token the current
10
+ * daemon last wrote, and a machine whose daemon has been off for a day posts
11
+ * nothing rather than holding a long-lived secret in a file.
12
+ *
13
+ * Posting never costs the agent anything: every failure — no token yet, an
14
+ * expired one, a server without the endpoint (404), a closed thread — is
15
+ * swallowed. Never throws.
16
+ */
17
+ export type CodeThreadEventType = 'start' | 'prompt' | 'permission_request' | 'prompt_local' | 'permission_resolved' | 'approval_overridden_locally' | 'stop' | 'failed' | 'interrupted' | 'stats';
18
+ export interface CodeThreadEvent {
19
+ type: CodeThreadEventType;
20
+ /** ISO-8601. */
21
+ at: string;
22
+ data: Record<string, unknown>;
23
+ }
24
+ export interface HookConfig {
25
+ version: 1;
26
+ /** API base, e.g. `https://connect.prohost.ai`. */
27
+ endpoint: string;
28
+ token: string;
29
+ /** Epoch seconds. */
30
+ expires_at: number;
31
+ }
32
+ export declare const EVENT_POST_TIMEOUT_MS = 5000;
33
+ export declare function hookConfigPath(env?: NodeJS.ProcessEnv): string;
34
+ export declare function writeHookConfig(config: HookConfig, env?: NodeJS.ProcessEnv): void;
35
+ /** The current config, or `undefined` when there is none, it is malformed, or it has expired. */
36
+ export declare function readHookConfig(env?: NodeJS.ProcessEnv, nowMs?: number): HookConfig | undefined;
37
+ export declare function codeThreadEventsPath(codeThreadId: string): string;
38
+ export declare function event(type: CodeThreadEventType, data: Record<string, unknown>, nowMs?: number): CodeThreadEvent;
39
+ /**
40
+ * POST one event. Returns the HTTP status, or 0 when nothing was sent or no
41
+ * answer came back. Never throws.
42
+ */
43
+ export declare function postCodeThreadEvent(config: HookConfig | undefined, codeThreadId: string, body: CodeThreadEvent, fetchImpl?: typeof fetch, timeoutMs?: number): Promise<number>;
44
+ /** Something that takes a thread's events and posts them, fail-soft. */
45
+ export interface CodeThreadEventSink {
46
+ post(codeThreadId: string, body: CodeThreadEvent): Promise<number>;
47
+ }
48
+ /** The daemon's sink: re-reads `hook.json` for every post, like the hooks do. */
49
+ export declare function fileEventSink(env?: NodeJS.ProcessEnv, fetchImpl?: typeof fetch): CodeThreadEventSink;