@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,726 @@
1
+ /**
2
+ * Claude Code hooks and Codex `notify` for Mac-hosted Code threads.
3
+ *
4
+ * **Nothing global is installed.** The operator's `~/.claude/settings.json`
5
+ * and `~/.codex/config.toml` are never edited. Each thread's agent is launched
6
+ * with the hooks on its own command line — `claude --settings <file>`, `codex -c
7
+ * notify=[...]` — so they exist only in the sessions this host starts, and the
8
+ * one command they run is a shim that exits at once unless
9
+ * `$PROHOST_TERMINAL_ID` is set. A `claude` the operator starts in their own
10
+ * terminal sees none of it.
11
+ *
12
+ * The shim (`$PROHOST_HOME/code-host/bin/prohost-hook`) is rewritten on every
13
+ * daemon start to name the CLI that is installed now, so a session started
14
+ * under an older CLI (npx caches move) keeps working after an upgrade.
15
+ *
16
+ * What each hook reports (contract "Hook / host ingest"):
17
+ *
18
+ * | Hook | Event |
19
+ * | --- | --- |
20
+ * | `UserPromptSubmit` | `prompt {text}` |
21
+ * | `Stop` | `stop {finalText, question}` then `stats {...}` |
22
+ * | `PermissionRequest --wait <s>` | `permission_request {approvalId, command, options, capabilities: ['hook_decision'], waitMs, deadlineMs, sentAtMs}`, then waits for the decision and prints it (`decision.ts`); `prompt_local` if its wait runs out |
23
+ * | `PermissionRequest` (a window launched before `--wait`) | `permission_request`, then at once `prompt_local {reason: 'launched_before_update'}`: its Claude Code would kill a waiting hook at 10 s |
24
+ * | `Notification` (`permission_prompt`) | for a Claude Code without `PermissionRequest`: `permission_request`, then `prompt_local {reason: 'no_hook'}` |
25
+ * | `SessionStart` | nothing posted; like every hook, it marks the window past the trust dialog (`ready.ts`) |
26
+ * | Codex `agent-turn-complete` | `prompt` (when it carries one) then `stop` |
27
+ *
28
+ * `question` is always true for an interactive turn: when an interactive agent
29
+ * stops, its turn is over and nothing more happens until a person types — that
30
+ * is a hand-off by construction, not a reading of what it said.
31
+ *
32
+ * Ordinary hooks print nothing (a `UserPromptSubmit` hook's stdout becomes part of the
33
+ * prompt), except a waiting `PermissionRequest` hook's decision from ProhostAI,
34
+ * which never blocks the menu (Claude Code draws it while the hook runs, and
35
+ * applies whichever answers first). The verified modern profile also records
36
+ * PreToolUse IDs; recording failures block the tool rather than returning success. Claude Code and
37
+ * Codex credentials are never read; the only Claude file read is the session's
38
+ * own transcript, for the turn's final text and token counts, and for whether a
39
+ * held prompt's call is over (`decision.ts`).
40
+ */
41
+ import { execFile } from 'node:child_process';
42
+ import { createHash, randomUUID } from 'node:crypto';
43
+ import { chmodSync, closeSync, fstatSync, openSync, readSync, renameSync, writeFileSync } from 'node:fs';
44
+ import path from 'node:path';
45
+ import { shellQuote } from '../agent/shell.js';
46
+ import { markTurn } from './activity.js';
47
+ import { event, EVENT_POST_TIMEOUT_MS, postCodeThreadEvent, readHookConfig } from './events.js';
48
+ import { awaitSettlement, beatenDeny, markTurnAnswered, recentPrompt, registerWaiter, settle, toolFingerprint } from './decision.js';
49
+ import { INVOCATION_PROTOCOL_ENV, permissionInvocationIds, recordInvocation, supportsInvocationProtocol } from './invocations.js';
50
+ import { markReady, readyPath } from './ready.js';
51
+ import { codeHostDir, ensurePrivateDir, loadSettings, writePrivateJson } from './settings.js';
52
+ import { isValidId } from './tmux.js';
53
+ export const TERMINAL_ID_ENV = 'PROHOST_TERMINAL_ID';
54
+ export const CODE_THREAD_ID_ENV = 'PROHOST_CODE_THREAD_ID';
55
+ /** Claude Code hook events this host registers. */
56
+ export const CLAUDE_HOOK_EVENTS = [
57
+ 'SessionStart',
58
+ 'UserPromptSubmit',
59
+ 'Stop',
60
+ 'PermissionRequest',
61
+ 'Notification',
62
+ ];
63
+ export const APPROVAL_OPTIONS = ['allow', 'allow_session', 'deny'];
64
+ /**
65
+ * Declared on a `permission_request` whose decision the hook itself returns
66
+ * (`decision.ts`): the server may relay a decision the moment it has the
67
+ * prompt, since it can only ever answer that prompt.
68
+ */
69
+ export const HOOK_DECISION_CAPABILITY = 'hook_decision';
70
+ /** Waits between `permission_request` attempts whose outcome is unknown, within the hook's wait. */
71
+ export const POST_RETRY_DELAYS_MS = [500, 1_000, 2_000, 4_000, 8_000];
72
+ /** Waits between `approval_overridden_locally` attempts, all within {@link OVERRIDE_BUDGET_MS}. */
73
+ export const OVERRIDE_RETRY_DELAYS_MS = [500, 1_000, 2_000];
74
+ /** Every correction attempt ends this soon after the PostToolUse hook started, under its 10 s timeout. */
75
+ export const OVERRIDE_BUDGET_MS = 8_000;
76
+ /** An attempt is only started with at least this much of the budget left. */
77
+ const OVERRIDE_MIN_ATTEMPT_MS = 500;
78
+ /**
79
+ * How long the `PermissionRequest` hook waits for a decision from ProhostAI.
80
+ * The menu is up in the terminal all along (Claude Code draws it while the hook
81
+ * runs), so the person at the Mac is never kept waiting by it: this bounds only
82
+ * how long a decision from ProhostAI may still answer the prompt.
83
+ */
84
+ export const HOOK_DECISION_WAIT_S = 120;
85
+ /** The hook's own timeout, past which Claude Code kills it: above its wait. */
86
+ export const PERMISSION_HOOK_TIMEOUT_S = 150;
87
+ /**
88
+ * A POST outcome that settles it: an answer the server would give again. A
89
+ * network failure or timeout (0), 408, 429 or a 5xx may not have landed, or
90
+ * may have landed with its answer lost, and is worth sending again (ingest
91
+ * is idempotent per `approvalId`).
92
+ */
93
+ const settled = (status) => status >= 200 && status < 500 && status !== 408 && status !== 429;
94
+ const MAX_PROMPT_CHARS = 8_000;
95
+ const MAX_FINAL_TEXT_CHARS = 8_000;
96
+ const MAX_COMMAND_CHARS = 500;
97
+ export const MAX_PREVIEW_CHARS = 280;
98
+ /** Only the end of a transcript is read: the last turn is all we want. */
99
+ const TRANSCRIPT_TAIL_BYTES = 512 * 1024;
100
+ /** A `permission_prompt` notification this soon after a `PermissionRequest` is the same prompt. */
101
+ const PERMISSION_DEDUP_MS = 30_000;
102
+ export function hookBinDir(env = process.env) {
103
+ return path.join(codeHostDir(env), 'bin');
104
+ }
105
+ export function hookShimPath(env = process.env) {
106
+ return path.join(hookBinDir(env), 'prohost-hook');
107
+ }
108
+ export function claudeSettingsPath(env = process.env) {
109
+ return path.join(codeHostDir(env), 'claude-settings.json');
110
+ }
111
+ /** The shim script: env-guarded, then the current CLI's `hook` command. */
112
+ export function hookShimScript(nodePath, cliEntrypoint) {
113
+ const q = (value) => shellQuote(value, 'darwin');
114
+ return [
115
+ '#!/bin/sh',
116
+ '# Written by `prohost agent run` on every start; it names the CLI installed now.',
117
+ '# Runs only inside a Code-thread terminal this machine hosts.',
118
+ `[ -n "$${TERMINAL_ID_ENV}" ] || exit 0`,
119
+ `[ -n "$${CODE_THREAD_ID_ENV}" ] || exit 0`,
120
+ // The pane's env has no `ELECTRON_RUN_AS_NODE` (`paneEnv`): set here, for this process only, so
121
+ // a CLI the desktop app runs on its Electron binary still runs the hook as Node. Node ignores it.
122
+ `ELECTRON_RUN_AS_NODE=1 exec ${q(nodePath)} ${q(cliEntrypoint)} hook "$@"`,
123
+ '',
124
+ ].join('\n');
125
+ }
126
+ /** The `--settings` document Claude Code is launched with: hooks only. */
127
+ export function claudeHookSettings(shimPath, invocationProtocol = false) {
128
+ const hooks = {};
129
+ for (const name of CLAUDE_HOOK_EVENTS) {
130
+ // The PermissionRequest hook waits for a decision from ProhostAI (`--wait`), within its own
131
+ // timeout: past it Claude Code would kill the hook mid-wait.
132
+ const waits = name === 'PermissionRequest';
133
+ hooks[name] = [
134
+ {
135
+ hooks: [
136
+ {
137
+ type: 'command',
138
+ command: `${shellQuote(shimPath, 'darwin')} claude ${name}${waits ? ` --wait ${HOOK_DECISION_WAIT_S}` : ''}`,
139
+ timeout: waits ? PERMISSION_HOOK_TIMEOUT_S : 10,
140
+ ...(invocationProtocol && waits ? { onFailure: 'block' } : {}),
141
+ },
142
+ ],
143
+ },
144
+ ];
145
+ }
146
+ if (invocationProtocol)
147
+ hooks.PreToolUse = [{ hooks: [{
148
+ type: 'command', command: `${shellQuote(shimPath, 'darwin')} claude PreToolUse`,
149
+ timeout: 10, onFailure: 'block',
150
+ }] }];
151
+ // The tool ran: reports a deny from ProhostAI that a "Yes" in the terminal beat (`beatenDeny`, by
152
+ // the exact invocation ids PreToolUse recorded). Reporting only, never blocking.
153
+ if (invocationProtocol)
154
+ hooks.PostToolUse = [{ hooks: [{
155
+ type: 'command', command: `${shellQuote(shimPath, 'darwin')} claude PostToolUse`, timeout: 10,
156
+ }] }];
157
+ return { hooks };
158
+ }
159
+ /** Codex `-c notify=[...]` value (TOML array), naming the shim. */
160
+ export function codexNotifyOverride(shimPath) {
161
+ return `notify=${JSON.stringify([shimPath, 'codex'])}`;
162
+ }
163
+ /** Write the shim and Claude settings. Called on every daemon start. */
164
+ export function installHookFiles(nodePath, cliEntrypoint, env = process.env) {
165
+ const shim = hookShimPath(env);
166
+ ensurePrivateDir(path.dirname(shim));
167
+ // A temp file and a rename: a hook running right now never reads half a script.
168
+ const temp = `${shim}.${process.pid}.tmp`;
169
+ writeFileSync(temp, hookShimScript(nodePath, cliEntrypoint), { mode: 0o700 });
170
+ chmodSync(temp, 0o700);
171
+ renameSync(temp, shim);
172
+ const claudeSettings = claudeSettingsPath(env);
173
+ writePrivateJson(claudeSettings, claudeHookSettings(shim));
174
+ const claudeInvocationSettings = path.join(codeHostDir(env), 'claude-invocation-settings.json');
175
+ writePrivateJson(claudeInvocationSettings, claudeHookSettings(shim, true));
176
+ return { shim, claudeSettings, claudeInvocationSettings };
177
+ }
178
+ // ── Parsing what the agents hand their hooks ───────────────────────────────
179
+ function asObject(value) {
180
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : undefined;
181
+ }
182
+ function str(value) {
183
+ return typeof value === 'string' ? value : undefined;
184
+ }
185
+ function clip(text, max) {
186
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
187
+ }
188
+ /** Last non-empty line, clipped — the inbox row's preview. */
189
+ export function previewOf(text) {
190
+ const lines = text.split('\n').map((line) => line.trim()).filter(Boolean);
191
+ const last = lines[lines.length - 1];
192
+ return last ? clip(last, MAX_PREVIEW_CHARS) : undefined;
193
+ }
194
+ /**
195
+ * The last assistant turn of a Claude Code transcript (JSONL): its text and
196
+ * token usage. Unknown shapes yield nothing rather than zeros.
197
+ */
198
+ export function parseTranscriptTail(content) {
199
+ const lines = content.split('\n');
200
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
201
+ const line = lines[i].trim();
202
+ if (!line.startsWith('{'))
203
+ continue;
204
+ let entry;
205
+ try {
206
+ entry = asObject(JSON.parse(line));
207
+ }
208
+ catch {
209
+ continue;
210
+ }
211
+ if (!entry || entry.type !== 'assistant')
212
+ continue;
213
+ const message = asObject(entry.message);
214
+ const content = Array.isArray(message?.content) ? message.content : [];
215
+ const text = content
216
+ .map((part) => asObject(part))
217
+ .filter((part) => part?.type === 'text' && typeof part.text === 'string')
218
+ .map((part) => part.text)
219
+ .join('\n')
220
+ .trim();
221
+ if (!text)
222
+ continue;
223
+ const turn = { finalText: text };
224
+ const usage = asObject(message?.usage);
225
+ const n = (key) => {
226
+ const value = usage?.[key];
227
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : undefined;
228
+ };
229
+ const input = n('input_tokens');
230
+ const output = n('output_tokens');
231
+ const created = n('cache_creation_input_tokens') ?? 0;
232
+ const read = n('cache_read_input_tokens') ?? 0;
233
+ if (input !== undefined && output !== undefined) {
234
+ turn.tokensTotal = input + created + read + output;
235
+ const prompt = input + created + read;
236
+ if (prompt > 0)
237
+ turn.tokensCachedPct = Math.round((read / prompt) * 1000) / 10;
238
+ }
239
+ return turn;
240
+ }
241
+ return {};
242
+ }
243
+ function readTail(file, bytes = TRANSCRIPT_TAIL_BYTES) {
244
+ let fd;
245
+ try {
246
+ fd = openSync(file, 'r');
247
+ const size = fstatSync(fd).size;
248
+ const length = Math.min(size, bytes);
249
+ const buffer = Buffer.alloc(length);
250
+ readSync(fd, buffer, 0, length, size - length);
251
+ return buffer.toString('utf8');
252
+ }
253
+ catch {
254
+ return undefined;
255
+ }
256
+ finally {
257
+ if (fd !== undefined)
258
+ closeSync(fd);
259
+ }
260
+ }
261
+ /** `git diff --shortstat` numbers for a worktree, against where its branch left the default branch. */
262
+ export function parseShortstat(text) {
263
+ const num = (re) => Number.parseInt(re.exec(text)?.[1] ?? '0', 10);
264
+ return {
265
+ filesChanged: num(/(\d+) files? changed/),
266
+ additions: num(/(\d+) insertions?\(\+\)/),
267
+ deletions: num(/(\d+) deletions?\(-\)/),
268
+ };
269
+ }
270
+ const defaultGit = (args, cwd) => new Promise((resolve) => {
271
+ execFile('git', ['-C', cwd, ...args], { timeout: 3_000, maxBuffer: 1024 * 1024 }, (error, stdout) => {
272
+ resolve(error ? undefined : String(stdout));
273
+ });
274
+ });
275
+ /** Untracked files counted, and the bytes of each read for its lines; past these they count as one file, no lines. */
276
+ const MAX_UNTRACKED_FILES = 500;
277
+ const MAX_UNTRACKED_BYTES = 1024 * 1024;
278
+ /** Lines in a new file as `git diff` would count them added: none for a binary one. */
279
+ function linesIn(file) {
280
+ let fd;
281
+ try {
282
+ fd = openSync(file, 'r');
283
+ const size = fstatSync(fd).size;
284
+ if (size === 0 || size > MAX_UNTRACKED_BYTES)
285
+ return 0;
286
+ const buffer = Buffer.alloc(size);
287
+ readSync(fd, buffer, 0, size, 0);
288
+ if (buffer.includes(0))
289
+ return 0;
290
+ let lines = 0;
291
+ for (const byte of buffer)
292
+ if (byte === 0x0a)
293
+ lines += 1;
294
+ return buffer[size - 1] === 0x0a ? lines : lines + 1;
295
+ }
296
+ catch {
297
+ return 0;
298
+ }
299
+ finally {
300
+ if (fd !== undefined)
301
+ closeSync(fd);
302
+ }
303
+ }
304
+ /**
305
+ * What the thread changed in its worktree, against where its branch left the
306
+ * default branch: commits, uncommitted edits, and new files not yet added
307
+ * (`git diff` alone never counts an untracked file, so a thread that made one
308
+ * would read "No changes yet").
309
+ */
310
+ export async function diffStats(cwd, git = defaultGit, countLines = linesIn) {
311
+ let base;
312
+ for (const ref of ['origin/HEAD', 'origin/main', 'origin/master']) {
313
+ base = (await git(['merge-base', 'HEAD', ref], cwd))?.trim();
314
+ if (base)
315
+ break;
316
+ }
317
+ const out = await git(['diff', '--shortstat', base || 'HEAD'], cwd);
318
+ if (out === undefined)
319
+ return undefined;
320
+ const stats = parseShortstat(out);
321
+ const untracked = (await git(['ls-files', '--others', '--exclude-standard', '-z'], cwd))?.split('\0').filter(Boolean) ?? [];
322
+ untracked.forEach((file, index) => {
323
+ stats.filesChanged += 1;
324
+ if (index < MAX_UNTRACKED_FILES)
325
+ stats.additions += countLines(path.join(cwd, file));
326
+ });
327
+ return stats;
328
+ }
329
+ /** Stable approval id for one permission prompt, so a retried hook doesn't make two. */
330
+ export function approvalIdFor(parts) {
331
+ const material = parts.filter(Boolean).join('\u0000');
332
+ if (!material)
333
+ return randomUUID();
334
+ return createHash('sha256').update(material).digest('hex').slice(0, 32);
335
+ }
336
+ /** A one-line description of what a tool wants to do. */
337
+ export function permissionCommand(toolName, toolInput) {
338
+ const input = asObject(toolInput) ?? {};
339
+ const command = str(input.command);
340
+ if (command)
341
+ return clip(command, MAX_COMMAND_CHARS);
342
+ const target = str(input.file_path) ?? str(input.path) ?? str(input.url) ?? '';
343
+ return clip(`${toolName ?? 'Tool'} ${target}`.trim(), MAX_COMMAND_CHARS);
344
+ }
345
+ /** Events for one Claude Code hook invocation. Pure apart from the context's readers. */
346
+ export async function claudeHookEvents(hook, input, ctx) {
347
+ const now = ctx.now();
348
+ const terminalId = ctx.env[TERMINAL_ID_ENV] ?? '';
349
+ const hasTerminal = isValidId(terminalId);
350
+ // PermissionRequest has no invocation ID. A matching per-tool hook may belong
351
+ // to an unregistered call, so only turn/user progress can retire these guards.
352
+ const turnProgress = () => {
353
+ if (!hasTerminal)
354
+ return;
355
+ try {
356
+ markTurnAnswered(terminalId, ctx.env, ctx.now);
357
+ }
358
+ catch {
359
+ // Unreadable state keeps replies guarded.
360
+ }
361
+ };
362
+ if (hook === 'PostToolUse') {
363
+ // Retires nothing: an identical call that needed no prompt reaches here too.
364
+ if (!hasTerminal)
365
+ return [];
366
+ try {
367
+ const approvalId = beatenDeny(terminalId, input.tool_use_id, input.tool_name, input.tool_input, ctx.env);
368
+ return approvalId ? [event('approval_overridden_locally', { approvalId, outcome: 'allowed' }, now)] : [];
369
+ }
370
+ catch {
371
+ return [];
372
+ }
373
+ }
374
+ if (hook === 'UserPromptSubmit') {
375
+ turnProgress();
376
+ const text = str(input.prompt);
377
+ return text ? [event('prompt', { text: clip(text, MAX_PROMPT_CHARS) }, now)] : [];
378
+ }
379
+ if (hook === 'Stop') {
380
+ turnProgress();
381
+ const transcript = str(input.transcript_path);
382
+ const turn = transcript ? parseTranscriptTail(ctx.readTranscript(transcript) ?? '') : {};
383
+ const finalText = str(input.last_assistant_message) ?? turn.finalText ?? '';
384
+ const events = [event('stop', { finalText: clip(finalText, MAX_FINAL_TEXT_CHARS), question: true }, now)];
385
+ const cwd = str(input.cwd);
386
+ const diff = cwd ? await diffStats(cwd, ctx.git) : undefined;
387
+ const stats = {};
388
+ if (turn.tokensTotal !== undefined)
389
+ stats.tokensTotal = turn.tokensTotal;
390
+ if (turn.tokensCachedPct !== undefined)
391
+ stats.tokensCachedPct = turn.tokensCachedPct;
392
+ if (diff)
393
+ Object.assign(stats, diff);
394
+ const preview = previewOf(finalText);
395
+ if (preview)
396
+ stats.preview = preview;
397
+ if (Object.keys(stats).length > 0)
398
+ events.push(event('stats', stats, now));
399
+ return events;
400
+ }
401
+ if (hook === 'Notification') {
402
+ if (str(input.notification_type) !== 'permission_prompt')
403
+ return [];
404
+ // A Claude Code with the PermissionRequest hook has already reported this prompt (its hook
405
+ // is waiting on it, or settled it a moment ago).
406
+ if (hasTerminal && recentPrompt(terminalId, ctx.env, now, PERMISSION_DEDUP_MS))
407
+ return [];
408
+ // A Claude Code without it: nothing here can return a decision, so the prompt is the terminal's.
409
+ const message = clip(str(input.message) ?? 'Permission needed', MAX_COMMAND_CHARS);
410
+ const approvalId = approvalIdFor([str(input.session_id), message, String(Math.floor(now / PERMISSION_DEDUP_MS))]);
411
+ return [
412
+ event('permission_request', { approvalId, command: message, options: [...APPROVAL_OPTIONS] }, now),
413
+ event('prompt_local', { approvalId, reason: 'no_hook' }, now),
414
+ ];
415
+ }
416
+ return [];
417
+ }
418
+ /** Events for one Codex `notify` call; `payload` is the JSON Codex passes as the last argument. */
419
+ export function codexNotifyEvents(payload, now) {
420
+ let data;
421
+ try {
422
+ data = asObject(JSON.parse(payload));
423
+ }
424
+ catch {
425
+ return [];
426
+ }
427
+ if (!data || data.type !== 'agent-turn-complete')
428
+ return [];
429
+ const events = [];
430
+ const inputs = Array.isArray(data['input-messages']) ? data['input-messages'] : [];
431
+ const prompt = inputs.filter((m) => typeof m === 'string').join('\n').trim();
432
+ if (prompt)
433
+ events.push(event('prompt', { text: clip(prompt, MAX_PROMPT_CHARS) }, now));
434
+ const finalText = str(data['last-assistant-message']) ?? '';
435
+ events.push(event('stop', { finalText: clip(finalText, MAX_FINAL_TEXT_CHARS), question: true }, now));
436
+ const preview = previewOf(finalText);
437
+ if (preview)
438
+ events.push(event('stats', { preview }, now));
439
+ return events;
440
+ }
441
+ async function readStdin(stream, limit = 4 * 1024 * 1024) {
442
+ if (stream.isTTY)
443
+ return '';
444
+ const chunks = [];
445
+ let size = 0;
446
+ for await (const chunk of stream) {
447
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
448
+ size += buffer.length;
449
+ if (size > limit)
450
+ break;
451
+ chunks.push(buffer);
452
+ }
453
+ return Buffer.concat(chunks).toString('utf8');
454
+ }
455
+ /**
456
+ * One hook can report several events at the moment it fired (`stop` then
457
+ * `stats`). Ingest drops an event older than the newest it applied for the
458
+ * thread, so each later event gets a later `at` — a millisecond apart.
459
+ */
460
+ export function strictlyIncreasing(events) {
461
+ let last = -Infinity;
462
+ return events.map((body) => {
463
+ const at = Math.max(Date.parse(body.at), last + 1);
464
+ last = at;
465
+ return { ...body, at: new Date(at).toISOString() };
466
+ });
467
+ }
468
+ /** Claude Code stops a hook with a signal (Escape, or "No" in the menu): settle, then exit quietly. */
469
+ const onStopSignals = (handler) => {
470
+ for (const signal of ['SIGTERM', 'SIGINT', 'SIGHUP']) {
471
+ process.once(signal, () => {
472
+ try {
473
+ handler();
474
+ }
475
+ finally {
476
+ process.exit(0);
477
+ }
478
+ });
479
+ }
480
+ };
481
+ // ── A decision answered by the hook (`decision.ts`) ────────────────────────
482
+ /** `--wait <seconds>` on the hook's command line, capped below its timeout; 0 without one. */
483
+ export function waitArg(args) {
484
+ const at = args.indexOf('--wait');
485
+ const seconds = at >= 0 ? Number(args[at + 1]) : 0;
486
+ if (!Number.isFinite(seconds) || seconds <= 0)
487
+ return 0;
488
+ return Math.min(seconds, PERMISSION_HOOK_TIMEOUT_S - 10) * 1000;
489
+ }
490
+ /**
491
+ * "Allow for this session", as Claude Code offers it for this prompt: its first
492
+ * suggestion that adds an allow rule, kept to the session. Only that kind: a
493
+ * suggestion to add a directory or switch mode grants more than the one call
494
+ * the person decided on. None: the option isn't offered.
495
+ */
496
+ export function sessionRule(suggestions) {
497
+ if (!Array.isArray(suggestions))
498
+ return undefined;
499
+ for (const raw of suggestions) {
500
+ const suggestion = asObject(raw);
501
+ if (suggestion?.type !== 'addRules' || suggestion.behavior !== 'allow' || !Array.isArray(suggestion.rules))
502
+ continue;
503
+ return { type: 'addRules', rules: suggestion.rules, behavior: 'allow', destination: 'session' };
504
+ }
505
+ return undefined;
506
+ }
507
+ /** The hook's stdout for a decision: what Claude Code applies to the prompt. */
508
+ export function decisionOutput(decision, rule) {
509
+ const body = decision === 'deny'
510
+ ? { behavior: 'deny', message: 'Denied in ProhostAI.' }
511
+ : decision === 'allow_session' && rule
512
+ ? { behavior: 'allow', updatedPermissions: [rule] }
513
+ : { behavior: 'allow' };
514
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: 'PermissionRequest', decision: body } });
515
+ }
516
+ /**
517
+ * The `PermissionRequest` hook, waiting: report the prompt, then wait for its
518
+ * settlement and print ProhostAI's decision if that is what settled it. Any
519
+ * other ending prints nothing, so the menu in the terminal decides; when the
520
+ * wait simply ran out, the server is told the prompt is the terminal's now.
521
+ */
522
+ async function answerPermissionRequest(input, ctx, codeThreadId, wait, options) {
523
+ const { env } = ctx;
524
+ const terminalId = env[TERMINAL_ID_ENV] ?? '';
525
+ const start = ctx.now();
526
+ const toolName = str(input.tool_name);
527
+ const command = permissionCommand(toolName, input.tool_input);
528
+ // One hook run is one prompt: two identical calls in a session are two prompts.
529
+ const approvalId = approvalIdFor([str(input.session_id), toolName, command, String(start), String(process.pid), randomUUID()]);
530
+ const rule = sessionRule(input.permission_suggestions);
531
+ const deadline = start + wait.waitMs;
532
+ // `deadlineMs` and `sentAtMs` are this Mac's clock: the server reads how long is left
533
+ // (`deadlineMs - sentAtMs`, at the attempt it took) against its own, so a request that took
534
+ // a few attempts never looks as though its hook waits longer than it does.
535
+ const request = () => event('permission_request', {
536
+ approvalId,
537
+ command,
538
+ options: rule ? [...APPROVAL_OPTIONS] : ['allow', 'deny'],
539
+ capabilities: [HOOK_DECISION_CAPABILITY],
540
+ waitMs: wait.waitMs,
541
+ deadlineMs: deadline,
542
+ sentAtMs: ctx.now(),
543
+ }, start);
544
+ const initialRequest = request();
545
+ try {
546
+ const causal = env[INVOCATION_PROTOCOL_ENV] === '1' && input.agent_id === undefined;
547
+ const toolUseIds = causal ? permissionInvocationIds(terminalId, input, env) : undefined;
548
+ const transcript = causal ? str(input.transcript_path) : undefined;
549
+ registerWaiter(terminalId, {
550
+ approvalId,
551
+ pid: process.pid,
552
+ deadline,
553
+ fingerprint: toolFingerprint(toolName, input.tool_input),
554
+ permissionRequest: initialRequest,
555
+ // Where Claude Code records this call's end, for a menu no hook will report gone.
556
+ ...(transcript && toolUseIds ? { transcript, toolUseIds } : {}),
557
+ }, env);
558
+ }
559
+ catch {
560
+ if (env[INVOCATION_PROTOCOL_ENV] === '1') {
561
+ process.stderr.write('prohost: permission invocation could not be recorded; refusing the request\n');
562
+ return 1; // The modern profile's onFailure:block denies this request.
563
+ }
564
+ return 0; // nowhere to wait: the terminal answers it
565
+ }
566
+ wait.onStop(() => settle(terminalId, approvalId, { by: 'cancelled', at: ctx.now() }, env));
567
+ const post = async (body) => {
568
+ const config = readHookConfig(env, ctx.now());
569
+ return config ? postCodeThreadEvent(config, codeThreadId, body, options.fetchImpl) : 0;
570
+ };
571
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
572
+ // Sent until it settles (ingest is idempotent per approvalId), within the wait.
573
+ let status = await post(initialRequest);
574
+ for (const delay of POST_RETRY_DELAYS_MS) {
575
+ if (settled(status) || ctx.now() + delay >= deadline)
576
+ break;
577
+ await sleep(delay);
578
+ status = await post(request());
579
+ }
580
+ const stored = status >= 200 && status < 300;
581
+ // Not known to be stored: stop waiting. Only the answers may have been lost, so a decision can
582
+ // already stand, and the server may still be offering a prompt nothing here waits on.
583
+ const settlement = stored
584
+ ? await awaitSettlement(terminalId, approvalId, deadline, env, ctx.now, sleep)
585
+ : settle(terminalId, approvalId, { by: 'timeout', at: ctx.now() }, env).settlement;
586
+ if (settlement?.by === 'host') {
587
+ wait.stdout(decisionOutput(settlement.decision, rule));
588
+ return 0;
589
+ }
590
+ // A request the server refused outright has no prompt there to correct.
591
+ const refused = settled(status) && !stored;
592
+ if (settlement?.by === 'timeout' && !refused) {
593
+ const reason = wait.waitMs > 0 ? 'timeout' : wait.localReason;
594
+ await post(event('prompt_local', { approvalId, reason }, Math.max(ctx.now(), start + 1)));
595
+ }
596
+ return 0;
597
+ }
598
+ /**
599
+ * `prohost hook claude <Event>` / `prohost hook codex <payload>`. Critical invocation
600
+ * recording failures are nonzero; ordinary reporting failures stay nonblocking.
601
+ */
602
+ export async function runHook(args, options = {}) {
603
+ const env = options.env ?? process.env;
604
+ const codeThreadId = env[CODE_THREAD_ID_ENV];
605
+ const [kind, name] = args;
606
+ const preflight = kind === 'claude' && name === 'CheckInvocationProtocol';
607
+ const critical = kind === 'claude' && name === 'PreToolUse';
608
+ if (!isValidId(env[TERMINAL_ID_ENV]) || !isValidId(codeThreadId))
609
+ return critical ? 2 : preflight ? 1 : 0;
610
+ if (preflight) {
611
+ try {
612
+ const version = await (options.claudeVersion ?? (() => new Promise((resolve) => {
613
+ execFile('claude', ['--version'], { env, timeout: 3_000, maxBuffer: 64 * 1024 }, (error, stdout) => {
614
+ resolve(error ? undefined : String(stdout));
615
+ });
616
+ })))();
617
+ if (supportsInvocationProtocol(version))
618
+ return 0;
619
+ process.stderr.write('prohost: immediate reply recovery requires Claude Code 2.1.295+. After Escape, this session may wait for a new prompt or completed turn.\n');
620
+ return 1;
621
+ }
622
+ catch {
623
+ return 1;
624
+ }
625
+ }
626
+ if (critical) {
627
+ try {
628
+ if (env[INVOCATION_PROTOCOL_ENV] !== '1')
629
+ throw new Error('Unsupported invocation protocol');
630
+ const input = asObject(JSON.parse(await readStdin(options.stdin ?? process.stdin)));
631
+ if (!input)
632
+ throw new Error('Invalid invocation input');
633
+ recordInvocation(env[TERMINAL_ID_ENV], input, env);
634
+ markReady(env[TERMINAL_ID_ENV], env);
635
+ return 0;
636
+ }
637
+ catch {
638
+ process.stderr.write('prohost: tool invocation could not be recorded; blocking the tool call\n');
639
+ return 2;
640
+ }
641
+ }
642
+ const now = options.now ?? Date.now;
643
+ const config = readHookConfig(env, now());
644
+ const ctx = {
645
+ env,
646
+ now,
647
+ git: options.git ?? defaultGit,
648
+ readTranscript: (file) => readTail(file),
649
+ };
650
+ let events = [];
651
+ try {
652
+ if (kind === 'claude' && name) {
653
+ // A hook runs only once the folder is trusted: from here a relayed reply may be typed (`ready.ts`).
654
+ // Only a valid terminal id names a path (no `..` from a hook's environment reaches the state dir).
655
+ const terminalId = env[TERMINAL_ID_ENV];
656
+ if (isValidId(terminalId) && !markReady(terminalId, env)) {
657
+ // Said where an operator looks (Claude Code shows hook stderr in its verbose log), never swallowed.
658
+ process.stderr.write(`prohost: could not write ${readyPath(terminalId, env)}; replies from ProhostAI can't be typed into this terminal\n`);
659
+ }
660
+ // Keep-awake (`activity.ts`): a turn runs from its prompt to Claude Code's stop. Marked first,
661
+ // before stdin and the Stop hook's transcript and git work, which may outlast its timeout.
662
+ if (name === 'UserPromptSubmit' || name === 'Stop')
663
+ markTurn(terminalId, name === 'UserPromptSubmit', env);
664
+ const raw = await readStdin(options.stdin ?? process.stdin);
665
+ let input = {};
666
+ try {
667
+ input = asObject(JSON.parse(raw)) ?? {};
668
+ }
669
+ catch {
670
+ input = {};
671
+ }
672
+ if (name === 'PermissionRequest') {
673
+ // This host's settings ask the hook to wait for a decision (`--wait`). It waits only while
674
+ // the owner allows remote control. A window launched before that (no `--wait`) keeps a
675
+ // Claude Code that kills its hooks at 10 s: its prompts are the terminal's.
676
+ const waitMs = waitArg(args);
677
+ const remote = loadSettings(env).allowRemoteTerminal;
678
+ return await answerPermissionRequest(input, ctx, codeThreadId, {
679
+ waitMs: remote ? waitMs : 0,
680
+ localReason: waitMs === 0 ? 'launched_before_update' : 'remote_off',
681
+ stdout: options.stdout ?? ((text) => void process.stdout.write(text)),
682
+ onStop: options.onStop ?? onStopSignals,
683
+ }, options);
684
+ }
685
+ events = await claudeHookEvents(name, input, ctx);
686
+ }
687
+ else if (kind === 'codex') {
688
+ events = codexNotifyEvents(args[args.length - 1] ?? '', ctx.now());
689
+ }
690
+ }
691
+ catch {
692
+ if (kind === 'claude' && name === 'PermissionRequest' && env[INVOCATION_PROTOCOL_ENV] === '1') {
693
+ process.stderr.write('prohost: permission hook failed; refusing the request\n');
694
+ return 1;
695
+ }
696
+ return 0;
697
+ }
698
+ // Keep-awake (`activity.ts`): Codex says only when a turn ends; the host marks the turns it
699
+ // starts or types (`terminals.ts`). Claude Code's are marked above.
700
+ if (kind === 'codex' && events.some((body) => body.type === 'stop'))
701
+ markTurn(env[TERMINAL_ID_ENV], false, env);
702
+ if (!config)
703
+ return 0;
704
+ const sleep = options.sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
705
+ const started = now();
706
+ // Sequential, in order: ingest drops an event older than the newest it applied.
707
+ for (const body of strictlyIncreasing(events)) {
708
+ if (body.type !== 'approval_overridden_locally') {
709
+ await postCodeThreadEvent(config, codeThreadId, body, options.fetchImpl);
710
+ continue;
711
+ }
712
+ // A correction is said once (`beatenDeny` marks it reported): sent again while its answer may be
713
+ // lost or the server asks for it later (the deny's delivery busy, 503). Every attempt, waits
714
+ // included, ends within OVERRIDE_BUDGET_MS of the hook's start: Claude Code holds the tool's
715
+ // result for PostToolUse and kills it at 10 s.
716
+ const left = () => started + OVERRIDE_BUDGET_MS - now();
717
+ let status = await postCodeThreadEvent(config, codeThreadId, body, options.fetchImpl, Math.min(EVENT_POST_TIMEOUT_MS, left()));
718
+ for (const delay of OVERRIDE_RETRY_DELAYS_MS) {
719
+ if (settled(status) || left() - delay <= OVERRIDE_MIN_ATTEMPT_MS)
720
+ break;
721
+ await sleep(delay);
722
+ status = await postCodeThreadEvent(config, codeThreadId, body, options.fetchImpl, Math.min(EVENT_POST_TIMEOUT_MS, left()));
723
+ }
724
+ }
725
+ return 0;
726
+ }