prism-mcp-server 20.9.3 → 20.11.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/README.md CHANGED
@@ -116,7 +116,7 @@ or by re-enabling after each run.
116
116
  <details>
117
117
  <summary>Release history (optional)</summary>
118
118
 
119
- ## What's New in v20.9.0
119
+ ## What's New in v20.9.0 – v20.9.3
120
120
 
121
121
  - **Your skills follow your account.** `skill_save` stores a skill at the
122
122
  scope you choose: this machine only (`local`, works offline and signed out),
@@ -128,6 +128,19 @@ or by re-enabling after each run.
128
128
  them any time, losslessly. Deleting a scoped skill archives its final
129
129
  content locally first, so nothing is ever silently unrecoverable.
130
130
 
131
+ - **Delivery that queues instead of failing.** Concurrent sessions no longer
132
+ starve skill sync on the local config store (WAL + busy-timeout) — a failure
133
+ that previously reported only "partial" where nobody could see it.
134
+ - **Withheld rules still bind.** When the context budget can't inline a
135
+ skill's text, the manifest of withheld names now states that those skills
136
+ still govern the work and names every way to load them before completion
137
+ claims.
138
+ - **The budget the floor never spent.** A long-standing accounting bug meant
139
+ no unprotected skill ever inlined at any normal context level — the
140
+ always-inlined protected floor was debiting the budget meant for everything
141
+ else. Task-matched skills (like the completion-evidence checklist) now
142
+ actually arrive.
143
+
131
144
  ## What's New in v20.8.2
132
145
 
133
146
  - **Skill delivery now admits failure instead of hiding it.** A filesystem
package/dist/cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { readFileSync, mkdirSync, readdirSync, statSync, rmSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
3
6
  import { SqliteStorage } from './storage/sqlite.js';
4
7
  import { handleVerifyStatus, handleGenerateHarness } from './verification/cliHandler.js';
5
8
  import * as path from 'path';
@@ -172,8 +175,31 @@ program
172
175
  .option('--all', 'Target all supported hosts instead of auto-detecting installed hosts')
173
176
  .option('--dry-run', 'Preview configuration changes without writing files')
174
177
  .option('--refresh', 'Refresh only entries previously created by Prism; custom entries stay untouched')
178
+ .option('--no-self-update', 'Skip the npm self-update check; configure with the currently installed version')
175
179
  .action(async (options) => {
176
180
  try {
181
+ // ── Converge the PACKAGE first, then the configs ──────────────
182
+ // connect is the one command the operator runs to make a machine
183
+ // current; leaving it configuring with stale code produced the
184
+ // "fresh hook, stale CLI" state observed live on 2026-08-13. After a
185
+ // successful update we RE-EXEC the new binary so the remainder of
186
+ // connect runs the code it just installed. Dry runs never update.
187
+ if (options.selfUpdate !== false && !options.dryRun) {
188
+ const { maybeSelfUpdate } = await import('./selfUpdate.js');
189
+ const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
190
+ const upd = maybeSelfUpdate({ currentVersion: pkg.version, invokedFrom: process.argv[1], log: (l) => console.log(l) });
191
+ if (upd.action === 'updated') {
192
+ console.log(`✓ prism updated to ${upd.latest}; re-running connect with the new version`);
193
+ const rerun = spawnSync(process.execPath, [process.argv[1], 'connect', ...process.argv.slice(3), '--no-self-update'], { stdio: 'inherit' });
194
+ process.exit(rerun.status ?? 0);
195
+ }
196
+ else if (upd.action === 'failed') {
197
+ console.log(`⚠ self-update: ${upd.detail}`);
198
+ }
199
+ else if (upd.action === 'skipped') {
200
+ console.log(`− self-update skipped: ${upd.detail}`);
201
+ }
202
+ }
177
203
  if (!options.dryRun) {
178
204
  console.log('Close target MCP hosts before registration so they cannot edit configuration concurrently.');
179
205
  }
@@ -275,6 +301,43 @@ program
275
301
  if (skillSync.status !== 'disabled') {
276
302
  const changed = skillSync.installed.length + skillSync.updated.length + skillSync.pruned.length;
277
303
  console.log(`✓ Synalux skills: ${skillSync.tier || 'free'} tier (${changed} changed)`);
304
+ // prism-route hook: AFTER sync success on purpose — a connect that
305
+ // fails must leave the machine untouched (pinned by the
306
+ // "keeps legacy Claude hooks when the snapshot fails" test).
307
+ {
308
+ const hookHosts = [];
309
+ if (summary.results.some((r) => (r.host === 'claude-code' || r.host === 'claude-desktop') && r.status !== 'error'))
310
+ hookHosts.push('claude');
311
+ if (summary.results.some((r) => r.host === 'codex' && r.status !== 'error'))
312
+ hookHosts.push('codex');
313
+ if (hookHosts.length > 0) {
314
+ try {
315
+ const { ensurePromptRouteHook } = await import('./promptRouteHostHook.js');
316
+ for (const r of ensurePromptRouteHook({ hosts: hookHosts, mode: 'explicit' })) {
317
+ const state = r.script === 'unchanged' && r.config === 'unchanged' ? 'up to date' : 'installed';
318
+ if (r.host === 'codex' && r.codexApproval === 'pending-or-unknown') {
319
+ // Codex silently skips untrusted hooks — a green "installed"
320
+ // here would be the "configured and inert" lie.
321
+ console.log(`⚠ codex: prism-route hook ${state}, AWAITING TRUST — run codex, then /hooks, and trust the entry ending prism-route/on_prompt.py`);
322
+ }
323
+ else if (r.host === 'codex' && r.codexApproval === 'state-present-unverifiable') {
324
+ // Approvals are keyed by definition hash, whose algorithm is
325
+ // not public — once ANY trust state exists we cannot tell
326
+ // ours apart from here. Say exactly that; asserting AWAITING
327
+ // after the operator pressed t reads as "the trust didn't
328
+ // take", which is a false alarm against their own action.
329
+ console.log(`− codex: prism-route hook ${state}; trust state exists but is not verifiable from here — confirm once in /hooks`);
330
+ }
331
+ else {
332
+ console.log(`✓ ${r.host}: prism-route prompt hook ${state} (${r.scriptPath})`);
333
+ }
334
+ }
335
+ }
336
+ catch {
337
+ console.error('⚠ prism-route hook installation failed — skills still route at session start');
338
+ }
339
+ }
340
+ }
278
341
  if (skillSync.conflicts.length > 0) {
279
342
  console.error(`⚠ Preserved locally modified skill conflicts: ${skillSync.conflicts.join(', ')}`);
280
343
  }
@@ -380,6 +443,78 @@ program
380
443
  //
381
444
  // JSON MODE: Structured envelope for programmatic consumption
382
445
  // (session loader scripts, CI/CD pipelines, etc.).
446
+ // ── route-prompt ──────────────────────────────────────────────
447
+ // Called by the prism-route UserPromptSubmit hook on EVERY prompt in both
448
+ // Claude Code and Codex, so the contract is: always exit 0, always print one
449
+ // JSON object, and stay off the network (cached settings DB only). A hook
450
+ // that can fail a turn gets uninstalled; a hook that is slow gets noticed.
451
+ /** Deliberate offload for payloads over the host inline cap. The host's own
452
+ * overflow path swaps the payload for a 2KB preview with no instruction to
453
+ * read the rest; this file plus the inline pointer is the recoverable form. */
454
+ function writeRouteOffload(fullText) {
455
+ try {
456
+ const dir = path.join(homedir(), '.prism-mcp', 'route-context');
457
+ mkdirSync(dir, { recursive: true });
458
+ try {
459
+ // Best-effort prune: one file per over-budget routed prompt, kept a week.
460
+ for (const f of readdirSync(dir)) {
461
+ const p = path.join(dir, f);
462
+ try {
463
+ if (Date.now() - statSync(p).mtimeMs > 7 * 86_400_000)
464
+ rmSync(p);
465
+ }
466
+ catch { /* skip unstat-able entries */ }
467
+ }
468
+ }
469
+ catch { /* prune failure never blocks the write */ }
470
+ const target = path.join(dir, `route-${Date.now()}-${process.pid}.md`);
471
+ writeFileSync(target, fullText);
472
+ return target;
473
+ }
474
+ catch {
475
+ return undefined; // reshape degrades to the loud in-band fallback
476
+ }
477
+ }
478
+ program
479
+ .command('route-prompt')
480
+ .description('Match a prompt (stdin) against skill triggers; prints {names, text} JSON. Used by the prism-route host hook.')
481
+ .option('--loaded <names>', 'Comma-separated skill names already active in the session')
482
+ .action(async (options) => {
483
+ try {
484
+ const chunks = [];
485
+ for await (const chunk of process.stdin)
486
+ chunks.push(chunk);
487
+ // A pasted log can be megabytes; triggers live in the first human-sized
488
+ // stretch of a prompt, and unbounded input is regex food.
489
+ const prompt = Buffer.concat(chunks).toString('utf8').slice(0, 100_000);
490
+ const loaded = (options.loaded ?? '')
491
+ .split(',')
492
+ .map((n) => n.trim())
493
+ .filter(Boolean);
494
+ const { runPromptRouteFromCache } = await import('./tools/ledgerHandlers.js');
495
+ const { reshapeForInlineBudget, HOOK_INLINE_SAFE_CHARS } = await import('./tools/promptRouteHandler.js');
496
+ const result = await runPromptRouteFromCache(prompt, loaded);
497
+ // The hook path must fit the host's inline cap; the MCP tool path
498
+ // (session_route_prompt) keeps the full 30k — tool results inline far
499
+ // higher than hook context does.
500
+ const shaped = result.names.length > 0
501
+ ? reshapeForInlineBudget(result, HOOK_INLINE_SAFE_CHARS, writeRouteOffload)
502
+ : { text: '' };
503
+ const payload = JSON.stringify({ names: result.names, text: result.names.length > 0 ? shaped.text : '' });
504
+ await new Promise((resolveWrite) => process.stdout.write(payload + '\n', () => resolveWrite()));
505
+ }
506
+ catch {
507
+ // Never break the hook: an empty result is a routing miss, not an error.
508
+ await new Promise((resolveWrite) => process.stdout.write('{"names":[],"text":""}\n', () => resolveWrite()));
509
+ }
510
+ finally {
511
+ try {
512
+ await closeStorage();
513
+ }
514
+ catch { /* exit anyway */ }
515
+ process.exit(0);
516
+ }
517
+ });
383
518
  program
384
519
  .command('load <project>')
385
520
  .description('Load session context for a project (same output as session_load_context MCP tool)')
package/dist/connect.js CHANGED
@@ -45,7 +45,7 @@ const CODEX_STARTUP_BODY = [
45
45
  "after the verbatim startup display. If `session_bootstrap` is deferred, use native tool discovery to load that",
46
46
  "exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection as",
47
47
  "a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
48
- "`Prism startup failure` and stop. Reuse the `conversation_id` returned in structuredContent for every",
48
+ "`Prism startup failure` and stop. Reuse the `conversation_id` returned on the `<prism_session />` line for every",
49
49
  "session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This hook-free",
50
50
  "block is managed by `prism connect`; do not edit it manually.",
51
51
  "",
@@ -579,7 +579,7 @@ function serializeClaudeStartupBlock(newline) {
579
579
  "deferred, use native tool discovery/ToolSearch to load that",
580
580
  "exact tool, then invoke it. Do not use shell commands, file reads, subagents, or unrelated tool inspection",
581
581
  "as a substitute. Do not call `session_load_context`. If discovery or invocation fails, report",
582
- "`Prism startup failure` and stop. Reuse the `conversation_id` returned in structuredContent for every",
582
+ "`Prism startup failure` and stop. Reuse the `conversation_id` returned on the `<prism_session />` line for every",
583
583
  "session_save_ledger, session_save_handoff, and session_detect_drift call in this conversation. This block is",
584
584
  "managed by `prism connect`; do not edit it manually.",
585
585
  "",
@@ -660,7 +660,7 @@ function serializeGeminiStartupBlock(newline) {
660
660
  "to load that exact tool, then invoke it.",
661
661
  "Do not use shell commands, file reads, subagents, or unrelated tool inspection as a substitute. Do not call",
662
662
  "`session_load_context`. If discovery or invocation fails, report `Prism startup failure` and stop. Reuse the",
663
- "`conversation_id` returned in structuredContent for session_save_ledger, session_save_handoff, and",
663
+ "`conversation_id` returned on the `<prism_session />` line for session_save_ledger, session_save_handoff, and",
664
664
  "session_detect_drift calls. This block is managed by `prism connect`; do not edit it manually.",
665
665
  "",
666
666
  ...LOCAL_FIRST_POLICY_LINES,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * npm postinstall — the upgrade path for the prism-route hook.
3
+ *
4
+ * `prism connect` is only typed once per machine, so an upgrade that adds or
5
+ * fixes the hook would otherwise reach no one until they reconnect. Silent
6
+ * and always-exit-0: a hook installer must never break `npm install`.
7
+ */
8
+ import { ensurePromptRouteHook } from "./promptRouteHostHook.js";
9
+ try {
10
+ const results = ensurePromptRouteHook({ mode: "auto" });
11
+ if (process.env.PRISM_DEBUG) {
12
+ for (const r of results)
13
+ console.error(`[prism postinstall] ${r.host}: script=${r.script} config=${r.config}`);
14
+ }
15
+ // The ONE step install cannot do for the operator, said at the only moment
16
+ // they are certainly watching. Codex's hook-trust gate exists so software
17
+ // cannot approve its own execution — prism will never write that trust
18
+ // state (a compromised release would otherwise gain silent
19
+ // execute-on-every-prompt), so the honest maximum is to make the pending
20
+ // approval impossible to miss. Approval is per hook-version, not per
21
+ // release: it recurs only when the hook script itself changes.
22
+ const codex = results.find((r) => r.host === "codex");
23
+ if (codex && codex.codexApproval === "pending-or-unknown") {
24
+ console.error("\n[prism] Codex hook installed but NOT yet trusted — Codex silently skips it until you approve it once:\n" +
25
+ "[prism] codex -> /hooks -> entry ending prism-route/on_prompt.py -> press t\n");
26
+ }
27
+ }
28
+ catch {
29
+ /* never fail an install */
30
+ }
@@ -0,0 +1,394 @@
1
+ /**
2
+ * prism-route — self-installing UserPromptSubmit hook for Claude Code + Codex.
3
+ *
4
+ * WHY A HOST HOOK. An MCP server never sees the user's prompt; the protocol
5
+ * carries only what a tool call carries. session_route_prompt (the MCP tool)
6
+ * therefore depends on the model deciding to call it — near-automatic at
7
+ * best. A UserPromptSubmit hook is the only mechanism that fires on EVERY
8
+ * prompt regardless of model behaviour, on both hosts, which is what the
9
+ * operator requires ("i need automatic").
10
+ *
11
+ * WHY SELF-INSTALLING. The previous generation of prism hooks was provisioned
12
+ * by a bootstrap script once, then hand-maintained per machine — which is why
13
+ * this machine has them and the other team machines do not. This module is
14
+ * called from three places so no machine can miss it:
15
+ * 1. `prism connect` — the explicit path,
16
+ * 2. npm postinstall — the upgrade path,
17
+ * 3. MCP server startup — the safety net for installs that skip scripts.
18
+ * All three converge here and the operation is idempotent: same version →
19
+ * no writes; registered → not re-registered; other people's hooks untouched.
20
+ *
21
+ * WHY THE HOOK SHELLS OUT TO `prism route-prompt` instead of matching in
22
+ * Python: the trigger table, scoped-frontmatter triggers, entitlement and
23
+ * caps live in the TypeScript matcher. A Python reimplementation would drift,
24
+ * and a table that matches differently in the hook than in the server is
25
+ * worse than no hook at all.
26
+ */
27
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
28
+ import { homedir } from "node:os";
29
+ import { dirname, join, resolve } from "node:path";
30
+ /** Bump to force the on-disk script to be rewritten on the next ensure. */
31
+ export const PROMPT_ROUTE_HOOK_VERSION = "3";
32
+ const MARKER_FILE = ".prism-managed.json";
33
+ const SCRIPT_FILE = "on_prompt.py";
34
+ const HOOK_DIR = "prism-route";
35
+ /** Substring that identifies our entry inside a host hooks config. */
36
+ const COMMAND_SIGNATURE = `${HOOK_DIR}/${SCRIPT_FILE}`;
37
+ /**
38
+ * The command registered in the host config carries the version as an
39
+ * argument (the script ignores argv — it reads stdin). This is a SECURITY
40
+ * property, found by an external probe of Codex 0.146: Codex's hook-trust
41
+ * hash covers the CONFIGURED DEFINITION, not the file the command points at.
42
+ * With a stable command and a version-refreshed script, every prism upgrade
43
+ * would silently swap the executable content behind an already-trusted hash —
44
+ * exactly what the trust gate exists to prevent. Versioning the command
45
+ * changes the definition on every script change, forcing Codex to re-prompt.
46
+ * Cost: one approval per release, which is Codex's consent model working.
47
+ */
48
+ function hookCommand(scriptPath) {
49
+ return `python3 ${scriptPath} --v${PROMPT_ROUTE_HOOK_VERSION}`;
50
+ }
51
+ /**
52
+ * The hook script. Python because both hosts' existing hook fleets are
53
+ * Python and the runtime is guaranteed present on macOS.
54
+ *
55
+ * Contract notes:
56
+ * - stdin carries the host's JSON payload; `prompt` is the Claude Code key
57
+ * and the fallbacks cover Codex's Claude-compatible hook payloads.
58
+ * - It must NEVER fail the turn: every path ends in continue:true, and an
59
+ * unexpected exception exits 0 with a pass-through.
60
+ * - Per-session dedupe lives HERE (state/<session>.json), because the hook
61
+ * is the only party that knows what it already injected. `loaded` is
62
+ * passed to the CLI so the matcher never returns the same skill twice.
63
+ */
64
+ export const PROMPT_ROUTE_HOOK_SCRIPT = `#!/usr/bin/env python3
65
+ """Prism-managed hook (prism-route v${PROMPT_ROUTE_HOOK_VERSION}).
66
+
67
+ Routes every user prompt through the on-device skill matcher via
68
+ 'prism route-prompt'. Injects newly matched skill bodies as context.
69
+ Managed by prism; edits are overwritten on version bumps.
70
+ """
71
+ import json
72
+ import os
73
+ import re
74
+ import shutil
75
+ import subprocess
76
+ import sys
77
+
78
+
79
+ def emit(extra=None):
80
+ out = {"continue": True, "suppressOutput": True}
81
+ if extra:
82
+ out["hookSpecificOutput"] = {
83
+ "hookEventName": "UserPromptSubmit",
84
+ "additionalContext": extra,
85
+ }
86
+ print(json.dumps(out))
87
+
88
+
89
+ def find_cli():
90
+ override = os.environ.get("PRISM_ROUTE_CLI")
91
+ if override and os.path.exists(override):
92
+ return override
93
+ found = shutil.which("prism")
94
+ if found:
95
+ return found
96
+ home = os.path.expanduser("~")
97
+ for candidate in (
98
+ os.path.join(home, ".npm-global", "bin", "prism"),
99
+ "/opt/homebrew/bin/prism",
100
+ "/usr/local/bin/prism",
101
+ os.path.join(home, "bin", "prism"),
102
+ ):
103
+ if os.path.exists(candidate):
104
+ return candidate
105
+ return None
106
+
107
+
108
+ def main():
109
+ try:
110
+ raw = sys.stdin.read()
111
+ payload = json.loads(raw) if raw.strip() else {}
112
+ except Exception:
113
+ payload = {}
114
+
115
+ prompt = str(
116
+ payload.get("prompt")
117
+ or payload.get("message")
118
+ or payload.get("user_prompt")
119
+ or ""
120
+ ).strip()
121
+ # Slash commands and micro-prompts ("ok", "merge") never route; skipping
122
+ # them keeps the common turn free.
123
+ if len(prompt) < 6 or prompt.startswith("/"):
124
+ emit()
125
+ return
126
+ # A pasted log can be megabytes; triggers live in the first human-sized
127
+ # stretch, and the CLI caps identically on its side.
128
+ prompt = prompt[:100_000]
129
+
130
+ session = str(
131
+ payload.get("session_id")
132
+ or payload.get("sessionId")
133
+ or payload.get("conversation_id")
134
+ or "default"
135
+ )
136
+ session = re.sub(r"[^A-Za-z0-9._-]", "_", session).lstrip(".")[:80] or "default"
137
+
138
+ state_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "state")
139
+ state_path = os.path.join(state_dir, session + ".json")
140
+ loaded = []
141
+ try:
142
+ with open(state_path) as fh:
143
+ data = json.load(fh)
144
+ if isinstance(data, list):
145
+ loaded = [n for n in data if isinstance(n, str)]
146
+ except Exception:
147
+ pass
148
+
149
+ cli = find_cli()
150
+ if not cli:
151
+ emit()
152
+ return
153
+
154
+ try:
155
+ result = subprocess.run(
156
+ [cli, "route-prompt", "--loaded", ",".join(loaded)],
157
+ input=prompt,
158
+ capture_output=True,
159
+ text=True,
160
+ timeout=10,
161
+ )
162
+ except Exception:
163
+ emit()
164
+ return
165
+ if result.returncode != 0:
166
+ emit()
167
+ return
168
+
169
+ # Parse the LAST line that is JSON: wrappers hooked into node via
170
+ # NODE_OPTIONS (dotenv banners and the like) print to stdout BEFORE the
171
+ # CLI's own output, and one polluted line must not kill routing.
172
+ data = None
173
+ for line in reversed(result.stdout.strip().splitlines()):
174
+ line = line.strip()
175
+ if line.startswith("{"):
176
+ try:
177
+ data = json.loads(line)
178
+ break
179
+ except Exception:
180
+ continue
181
+ if not isinstance(data, dict):
182
+ emit()
183
+ return
184
+ names = [n for n in (data.get("names") or []) if isinstance(n, str)]
185
+ text = data.get("text") or ""
186
+ if not names or not text:
187
+ emit()
188
+ return
189
+
190
+ try:
191
+ os.makedirs(state_dir, exist_ok=True)
192
+ merged = loaded + [n for n in names if n not in loaded]
193
+ with open(state_path, "w") as fh:
194
+ json.dump(merged, fh)
195
+ except Exception:
196
+ pass # dedupe degrades, injection still happens
197
+
198
+ emit(text)
199
+
200
+
201
+ if __name__ == "__main__":
202
+ try:
203
+ main()
204
+ except Exception:
205
+ print(json.dumps({"continue": True, "suppressOutput": True}))
206
+ sys.exit(0)
207
+ `;
208
+ /** Evidence that this host was already prism-integrated by explicit action. */
209
+ function hostShowsPriorConsent(spec, homeDir) {
210
+ if (existsSync(join(spec.root, "hooks", HOOK_DIR, MARKER_FILE)))
211
+ return true;
212
+ const evidenceFiles = spec.host === "claude"
213
+ ? [join(homeDir, ".claude.json"), spec.configPath]
214
+ : [join(spec.root, "config.toml"), spec.configPath];
215
+ for (const file of evidenceFiles) {
216
+ try {
217
+ if (/prism/i.test(readFileSync(file, "utf8")))
218
+ return true;
219
+ }
220
+ catch { /* unreadable = no evidence */ }
221
+ }
222
+ return false;
223
+ }
224
+ function hostSpecs(homeDir, env) {
225
+ const codexHome = env.CODEX_HOME?.trim() ? resolve(env.CODEX_HOME.trim()) : join(homeDir, ".codex");
226
+ return [
227
+ { host: "claude", root: join(homeDir, ".claude"), configPath: join(homeDir, ".claude", "settings.json") },
228
+ // Codex keeps hooks in hooks.json, not settings.json — same schema.
229
+ { host: "codex", root: codexHome, configPath: join(codexHome, "hooks.json") },
230
+ ];
231
+ }
232
+ /**
233
+ * Coarse Codex approval detection. Codex persists hook approvals as a
234
+ * [hooks.state] table in config.toml keyed by definition hash; the hashing
235
+ * algorithm is not public, so the only honest signals are "a state section
236
+ * exists and mentions our hook path" (detected) or anything else
237
+ * (pending-or-unknown). Never treat unknown as approved.
238
+ */
239
+ function detectCodexApproval(codexRoot) {
240
+ try {
241
+ const toml = readFileSync(join(codexRoot, "config.toml"), "utf8");
242
+ const hasState = /\[hooks\.state/.test(toml);
243
+ if (hasState && toml.includes(COMMAND_SIGNATURE))
244
+ return "detected";
245
+ // Approvals are keyed by definition hash (algorithm not public). Once ANY
246
+ // trust state exists we cannot distinguish ours from here — and claiming
247
+ // AWAITING TRUST after the operator pressed t would be a false alarm
248
+ // against their own action. Distinct state, distinct wording.
249
+ if (hasState)
250
+ return "state-present-unverifiable";
251
+ }
252
+ catch { /* unreadable = no evidence */ }
253
+ return "pending-or-unknown";
254
+ }
255
+ function writeAtomically(path, content) {
256
+ mkdirSync(dirname(path), { recursive: true });
257
+ const tmp = `${path}.prism-tmp-${process.pid}`;
258
+ writeFileSync(tmp, content);
259
+ renameSync(tmp, path);
260
+ }
261
+ function ensureScript(hookDir) {
262
+ const markerPath = join(hookDir, MARKER_FILE);
263
+ const scriptPath = join(hookDir, SCRIPT_FILE);
264
+ let existingVersion;
265
+ try {
266
+ const marker = JSON.parse(readFileSync(markerPath, "utf8"));
267
+ // The durable off switch. Without it, an operator who deletes the entry
268
+ // or edits the script gets silently re-enabled by the next upgrade —
269
+ // self-healing becomes self-reinfecting. {"disabled": true} in the
270
+ // marker survives every ensure path, including version bumps.
271
+ if (marker.disabled === true)
272
+ return "disabled";
273
+ existingVersion = marker.version;
274
+ }
275
+ catch {
276
+ /* no marker — install */
277
+ }
278
+ const scriptExists = existsSync(scriptPath);
279
+ if (scriptExists && existingVersion === PROMPT_ROUTE_HOOK_VERSION)
280
+ return "unchanged";
281
+ writeAtomically(scriptPath, PROMPT_ROUTE_HOOK_SCRIPT);
282
+ chmodSync(scriptPath, 0o755);
283
+ mkdirSync(join(hookDir, "state"), { recursive: true });
284
+ writeAtomically(markerPath, `${JSON.stringify({ managedBy: "prism", feature: "prism-route", version: PROMPT_ROUTE_HOOK_VERSION }, null, 2)}\n`);
285
+ return scriptExists ? "refreshed" : "installed";
286
+ }
287
+ function ensureRegistered(configPath, scriptPath, host) {
288
+ // Codex truncates hook additionalContext at ~2,500 tokens by default —
289
+ // a head-and-tail preview of our payload, which defeats the injection.
290
+ // additionalContextLimit: 0 passes the full context through — per the Codex
291
+ // hooks reference (learn.chatgpt.com/docs/hooks, verified 2026-08-13):
292
+ // "Setting to 0 passes full context directly to the model". NOT an in-repo
293
+ // guarantee: if Codex ever re-reads 0 as a literal zero cap, injection dies
294
+ // silently there — re-verify with a live codex probe after any Codex
295
+ // upgrade. The payload is already bounded by HOOK_INLINE_SAFE_CHARS on the
296
+ // emitting side, so the pass-through is not unbounded. Claude Code has no
297
+ // such field (its 10k-char cap is not configurable) — never write unknown
298
+ // keys into settings.json (a manually-added stray field there is left
299
+ // alone, not stripped).
300
+ const wantsLimit = host === "codex";
301
+ let config = {};
302
+ let originalText;
303
+ try {
304
+ originalText = readFileSync(configPath, "utf8");
305
+ const parsed = JSON.parse(originalText);
306
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
307
+ config = parsed;
308
+ }
309
+ }
310
+ catch {
311
+ /* missing or unreadable — create minimal */
312
+ }
313
+ const hooks = (config.hooks && typeof config.hooks === "object" && !Array.isArray(config.hooks)
314
+ ? config.hooks
315
+ : {});
316
+ const entries = Array.isArray(hooks.UserPromptSubmit) ? hooks.UserPromptSubmit : [];
317
+ const wanted = hookCommand(scriptPath);
318
+ let stale = false;
319
+ for (const entry of entries) {
320
+ if (!entry || typeof entry !== "object")
321
+ continue;
322
+ const inner = entry.hooks;
323
+ if (!Array.isArray(inner))
324
+ continue;
325
+ for (const h of inner) {
326
+ if (!h || typeof h !== "object")
327
+ continue;
328
+ // Normalize separators: on Windows join() registers a backslash path,
329
+ // and a forward-slash signature would never match — so every ensure
330
+ // would re-register a duplicate entry.
331
+ const command = String(h.command ?? "");
332
+ if (!command.replace(/\\/g, "/").includes(COMMAND_SIGNATURE))
333
+ continue;
334
+ const limitCurrent = !wantsLimit || h.additionalContextLimit === 0;
335
+ if (command === wanted && limitCurrent)
336
+ return "unchanged";
337
+ // Same hook, older definition: UPDATE it in place. This is what makes a
338
+ // refresh visible to Codex's definition-hash — and on Claude it is a
339
+ // harmless argv change.
340
+ h.command = wanted;
341
+ if (wantsLimit)
342
+ h.additionalContextLimit = 0;
343
+ stale = true;
344
+ }
345
+ }
346
+ if (!stale) {
347
+ entries.push({
348
+ matcher: "*",
349
+ hooks: [{ type: "command", command: wanted, timeout: 15, ...(wantsLimit ? { additionalContextLimit: 0 } : {}) }],
350
+ });
351
+ }
352
+ hooks.UserPromptSubmit = entries;
353
+ config.hooks = hooks;
354
+ writeAtomically(configPath, `${JSON.stringify(config, null, 2)}\n`);
355
+ return stale ? "updated" : "registered";
356
+ }
357
+ /**
358
+ * Idempotently install the prism-route hook for both hosts.
359
+ * Never throws for a single host's failure — the other host still gets it.
360
+ */
361
+ export function ensurePromptRouteHook(options = {}) {
362
+ const homeDir = options.homeDir ?? homedir();
363
+ const env = options.env ?? process.env;
364
+ const wanted = new Set(options.hosts ?? ["claude", "codex"]);
365
+ const onlyExisting = options.onlyExistingRoots ?? true;
366
+ const results = [];
367
+ for (const spec of hostSpecs(homeDir, env)) {
368
+ if (!wanted.has(spec.host))
369
+ continue;
370
+ if (onlyExisting && !existsSync(spec.root))
371
+ continue;
372
+ if ((options.mode ?? "explicit") === "auto" && !hostShowsPriorConsent(spec, homeDir))
373
+ continue;
374
+ try {
375
+ const hookDir = join(spec.root, "hooks", HOOK_DIR);
376
+ const script = ensureScript(hookDir);
377
+ if (script === "disabled")
378
+ continue; // operator opt-out — do not re-register either
379
+ const config = ensureRegistered(spec.configPath, join(hookDir, SCRIPT_FILE), spec.host);
380
+ const result = { host: spec.host, script, config, scriptPath: join(hookDir, SCRIPT_FILE), configPath: spec.configPath };
381
+ if (spec.host === "codex") {
382
+ // Never report a green "registered" as if it were active: Codex
383
+ // SILENTLY SKIPS untrusted hooks, and "installed but inert" is the
384
+ // exact failure class this feature exists to end.
385
+ result.codexApproval = detectCodexApproval(spec.root);
386
+ }
387
+ results.push(result);
388
+ }
389
+ catch {
390
+ // One host failing (permissions, odd config) must not block the other.
391
+ }
392
+ }
393
+ return results;
394
+ }