hilos-agent 0.9.1 → 0.9.2

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/src/hook.mjs CHANGED
@@ -11,20 +11,24 @@
11
11
  // step ring and last-send time, so most invocations are one small read +
12
12
  // at most one bounded fetch. Sends are throttled (default 2s) with pending
13
13
  // steps carried over — a burst of tool calls becomes one coalesced update.
14
- // - Privacy is the install default: `hooks install` writes project-local
15
- // Codex, Claude, and Cursor hook files, so only repos you opt in ever stream.
14
+ // - Privacy is the install default: Claude/Cursor use project-local hook files.
15
+ // Codex needs one global hook because `codex exec` skips repository hooks;
16
+ // hilos enforces the same project-local consent with a 0600 path allowlist.
16
17
  // HILOS_HOOKS=off is the global kill switch.
17
18
  // - Pure helpers (event parsing, step labels, throttling decisions) are
18
19
  // exported for offline unit tests; I/O lives only in runHook/main.
19
20
 
20
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync, chmodSync } from "node:fs";
21
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync, chmodSync, realpathSync, cpSync, renameSync, rmSync } from "node:fs";
21
22
  import { createHash } from "node:crypto";
22
23
  import { homedir } from "node:os";
23
- import { join, dirname, delimiter } from "node:path";
24
+ import { join, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
24
26
  import { sanitizeText, webTarget } from "./agent-events.mjs";
25
27
  import { resolveConfig } from "./config.mjs";
26
28
 
27
29
  export const HOOK_STATE_DIR = join(homedir(), ".hilos", "hook-state");
30
+ export const CODEX_HOOK_SCOPE_FILE = join(homedir(), ".hilos", "codex-hook-scope.json");
31
+ export const HOOK_RUNTIME_ROOT = join(homedir(), ".hilos", "hook-runtime");
28
32
  /** Coalesce window between sends; Stop/SessionEnd always flush. */
29
33
  const MIN_SEND_MS = 2000;
30
34
  /** A hook must never hang the CLI on a slow network. */
@@ -35,6 +39,122 @@ const MAX_FILES = 20;
35
39
  /** Session state older than this is dead — GC'd opportunistically. */
36
40
  const STATE_TTL_MS = 48 * 60 * 60 * 1000;
37
41
 
42
+ function normalizedProjectPath(value) {
43
+ if (typeof value !== "string" || !value.trim()) return "";
44
+ try {
45
+ return realpathSync(value.trim());
46
+ } catch {
47
+ return value.trim();
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Resolve a working directory to the nearest repository/worktree root. Codex
53
+ * reports the directory it was launched from, which is often below the root
54
+ * where `hooks install` ran. Choosing the nearest `.git` boundary also keeps an
55
+ * opted-in parent repository from implicitly opting in a nested repository.
56
+ * Non-git folders remain exact-path opt-ins.
57
+ */
58
+ function codexProjectPath(value) {
59
+ const project = normalizedProjectPath(value);
60
+ if (!project) return "";
61
+ let current = project;
62
+ while (true) {
63
+ if (existsSync(join(current, ".git"))) return current;
64
+ const parent = dirname(current);
65
+ if (parent === current) return project;
66
+ current = parent;
67
+ }
68
+ }
69
+
70
+ function validCodexHookScope(scope) {
71
+ return Boolean(
72
+ scope &&
73
+ typeof scope === "object" &&
74
+ scope.version === 1 &&
75
+ typeof scope.global === "boolean" &&
76
+ Array.isArray(scope.projects) &&
77
+ scope.projects.every((project) => typeof project === "string" && project.trim()),
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Codex CLI 0.144 runs global hooks for both the interactive TUI and
83
+ * `codex exec`, but repository hooks only for the interactive lane. Keep one
84
+ * global command so both workflows work, then enforce project-local consent in
85
+ * our own small allowlist. `global:true` is the explicit --global opt-in.
86
+ */
87
+ export function codexHookScopeAllows(scope, cwd) {
88
+ if (!scope || typeof scope !== "object") return false;
89
+ if (scope.global === true) return true;
90
+ const project = codexProjectPath(cwd);
91
+ return Boolean(project) && (Array.isArray(scope.projects) ? scope.projects : [])
92
+ .map(codexProjectPath)
93
+ .includes(project);
94
+ }
95
+
96
+ /** Decide whether a Codex event may reach hilos. A present-but-malformed scope
97
+ * file is an explicit deny: corruption must never widen reporting to every
98
+ * repository. With no scope file, only an old unmarked command can preserve
99
+ * pre-0854 consent. New commands identify themselves as
100
+ * scope-managed and deny when their private state is lost.
101
+ * @param {{
102
+ * scopeFileExists?: boolean,
103
+ * scope?: {version?: unknown, global?: unknown, projects?: unknown} | null,
104
+ * cwd?: string,
105
+ * legacyUnscopedHook?: boolean,
106
+ * }} [options]
107
+ */
108
+ export function codexHookMayRun({
109
+ scopeFileExists = false,
110
+ scope = null,
111
+ cwd = "",
112
+ legacyUnscopedHook = false,
113
+ } = {}) {
114
+ if (!scopeFileExists) return legacyUnscopedHook === true;
115
+ if (!validCodexHookScope(scope)) return false;
116
+ const managedAllows = codexHookScopeAllows(scope, cwd);
117
+ // A managed home hook owns paths already in the allowlist. An old unmarked
118
+ // project hook owns only a path the new allowlist does not yet know about.
119
+ // Thus two pre-0.9.2 repo opt-ins keep working while A never double-fires
120
+ // after installing 0.9.2 and B can be migrated independently later.
121
+ return legacyUnscopedHook === true ? !managedAllows : managedAllows;
122
+ }
123
+
124
+ export function readCodexHookScope(path = CODEX_HOOK_SCOPE_FILE) {
125
+ try {
126
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
127
+ return parsed && typeof parsed === "object" ? parsed : null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ }
132
+
133
+ export function writeCodexHookScope({ cwd = "", global = false } = {}, path = CODEX_HOOK_SCOPE_FILE) {
134
+ try {
135
+ const prior = readCodexHookScope(path) || {};
136
+ const projects = new Set(
137
+ (Array.isArray(prior.projects) ? prior.projects : [])
138
+ .map(codexProjectPath)
139
+ .filter(Boolean),
140
+ );
141
+ const project = codexProjectPath(cwd);
142
+ if (project) projects.add(project);
143
+ const next = {
144
+ version: 1,
145
+ global: prior.global === true || global === true,
146
+ projects: [...projects].sort(),
147
+ };
148
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
149
+ chmodSync(dirname(path), 0o700);
150
+ writeFileSync(path, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
151
+ chmodSync(path, 0o600);
152
+ return next;
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
38
158
  /**
39
159
  * Scope local hook state to one hilos agent credential without ever writing the
40
160
  * bearer token itself to disk. Two agent identities can share a machine, so a
@@ -159,12 +279,27 @@ export function mcpResponsePayload(value, depth = 0) {
159
279
  export function bindingFromHookEvent(ev, at = new Date().toISOString()) {
160
280
  if (!ev || ev.event !== "PostToolUse") return null;
161
281
  const match = String(ev.toolName || "").match(/^mcp__(.+?)__(post_message|post_report)$/i);
162
- if (!match || !/hilos/i.test(match[1])) return null;
282
+ if (!match) return null;
163
283
  const channelId = typeof ev.toolInput?.channelId === "string" ? ev.toolInput.channelId : "";
164
284
  if (!channelId) return null;
165
285
  const payload = mcpResponsePayload(ev.toolResponse);
166
286
  const messageId = typeof payload?.messageId === "string" ? payload.messageId : "";
167
287
  const agentId = typeof payload?.agentId === "string" ? payload.agentId : "";
288
+ const bindingClaim = typeof payload?.bindingClaim === "string" ? payload.bindingClaim : "";
289
+ const serverName = match[1].toLowerCase();
290
+ // Codex config preserves `hilos-<compact uuid>`, but its PostToolUse event
291
+ // normalizes that hyphen to an underscore. Both spellings refer to the same
292
+ // configured server; the UUID still has to equal the authenticated result.
293
+ const canonical = /^hilos[-_]([0-9a-f]{32})$/.exec(serverName);
294
+ // The in-app Codex setup uses an immutable UUID-derived server name. Accept
295
+ // that (including Codex's event normalization) only when it names the
296
+ // authenticated result's agent exactly; retain
297
+ // the documented generic `hilos` name for Claude/Cursor/manual setups. A
298
+ // coincidental `hilos_fake` server is never a binding source.
299
+ if (
300
+ serverName !== "hilos" &&
301
+ (!canonical || agentId.replaceAll("-", "").toLowerCase() !== canonical[1])
302
+ ) return null;
168
303
  // The server-authenticated author is part of the result. Requiring it keeps a
169
304
  // differently configured hilos MCP connection from being resumed and posted
170
305
  // through this daemon's identity merely because both can access the room.
@@ -175,6 +310,7 @@ export function bindingFromHookEvent(ev, at = new Date().toISOString()) {
175
310
  anchorMessageId: messageId,
176
311
  channelId,
177
312
  agentId,
313
+ ...(bindingClaim ? { bindingClaim } : {}),
178
314
  boundAt: at,
179
315
  processedReplyIds: [],
180
316
  };
@@ -207,7 +343,13 @@ function sessionState(state, ev, vendor, now, token) {
207
343
  out.sessionId = ev.sessionId;
208
344
  out.vendor = vendor || out.vendor || "unknown";
209
345
  out.cwd = ev.cwd || out.cwd || "";
210
- out.connectionKey = hookConnectionKey(token);
346
+ // The zero-config `--join` path deliberately keeps its bearer token only in
347
+ // the daemon process. Lifecycle hooks are separate child processes, so they
348
+ // cannot fingerprint that credential. Preserve a prior fingerprint when one
349
+ // exists; otherwise the server-authenticated agent id on each MCP binding is
350
+ // the daemon's claim key (reply-bridge.mjs).
351
+ if (token) out.connectionKey = hookConnectionKey(token);
352
+ else if (typeof out.connectionKey !== "string") out.connectionKey = "";
211
353
  out.updatedAt = new Date(now()).toISOString();
212
354
  if (!Array.isArray(out.bindings)) out.bindings = [];
213
355
  return out;
@@ -358,10 +500,13 @@ export function deleteState(sessionId, dir = HOOK_STATE_DIR) {
358
500
  }
359
501
  }
360
502
 
361
- /** Drop state files from long-dead sessions. Best-effort, bounded, silent. */
503
+ /** Drop state files from long-dead sessions. Best-effort and silent. */
362
504
  export function gcStateDir(dir = HOOK_STATE_DIR, now = Date.now()) {
363
505
  try {
364
- for (const f of readdirSync(dir).slice(0, 200)) {
506
+ // readdir order is arbitrary, so slicing can leave the same expired tail
507
+ // forever. SessionStart is infrequent enough to inspect this private,
508
+ // metadata-only directory completely and keep zero-config use bounded.
509
+ for (const f of readdirSync(dir).filter((name) => name.endsWith(".json"))) {
365
510
  const p = join(dir, f);
366
511
  try {
367
512
  if (now - statSync(p).mtimeMs > STATE_TTL_MS) unlinkSync(p);
@@ -419,9 +564,13 @@ export async function runHook({ raw, cfg, vendor = "unknown", now = Date.now, st
419
564
  const ev = parseHookEvent(raw);
420
565
  if (!ev) return;
421
566
  const { url, token, channelId } = cfg;
422
- if (!url || !token) return; // not connected to hilos — no-op
423
567
 
424
- let state = sessionState(readState(ev.sessionId, stateDir), ev, vendor, now, token);
568
+ const priorState = readState(ev.sessionId, stateDir);
569
+ // Providers normally send SessionStart, but cleanup must not depend on that
570
+ // lifecycle guarantee. The first event for any new session is a bounded-cost
571
+ // opportunity to sweep old zero-config files too.
572
+ if (!priorState) gcStateDir(stateDir, now());
573
+ let state = sessionState(priorState, ev, vendor, now, token);
425
574
  const binding = bindingFromHookEvent(ev, new Date(now()).toISOString());
426
575
  if (binding) upsertBinding(state, binding);
427
576
 
@@ -442,7 +591,7 @@ export async function runHook({ raw, cfg, vendor = "unknown", now = Date.now, st
442
591
  // anything right now". The next tool call revives the SAME card to working
443
592
  // via its stored messageId, so a session stays one card, not one per turn.
444
593
  state.active = false;
445
- if (state.messageId) {
594
+ if (url && token && state.messageId) {
446
595
  await sendProgress({
447
596
  url,
448
597
  token,
@@ -473,10 +622,15 @@ export async function runHook({ raw, cfg, vendor = "unknown", now = Date.now, st
473
622
  return;
474
623
  }
475
624
 
476
- if (!state.startedAt) {
477
- gcStateDir(stateDir, now()); // new session — a cheap moment to sweep old ones
478
- state.startedAt = now();
625
+ // A raw session can still bind its successful Hilos MCP posts when the
626
+ // daemon was launched with memory-only `--join` credentials. There is no
627
+ // credential here for ambient progress, so keep only local lifecycle state.
628
+ if (!url || !token) {
629
+ writeState(ev.sessionId, state, stateDir);
630
+ return;
479
631
  }
632
+
633
+ if (!state.startedAt) state.startedAt = now();
480
634
  foldStep(state, step);
481
635
 
482
636
  // A config without a default channel can still bind explicit hilos MCP posts
@@ -541,10 +695,24 @@ function readStdin() {
541
695
  }
542
696
 
543
697
  /** `hilos-agent hook` — the command each provider invokes. Always exits 0. */
544
- export async function hookMain({ vendor = "unknown" } = {}) {
698
+ export async function hookMain({ vendor = "unknown", scopeManaged = false } = {}) {
545
699
  try {
546
700
  if (/^(off|0|false)$/i.test(process.env.HILOS_HOOKS || "")) return;
547
701
  const raw = await readStdin();
702
+ if (vendor === "codex") {
703
+ const event = parseHookEvent(raw);
704
+ if (!event) return;
705
+ const scope = readCodexHookScope();
706
+ if (!codexHookMayRun({
707
+ scopeFileExists: existsSync(CODEX_HOOK_SCOPE_FILE),
708
+ scope,
709
+ cwd: event.cwd,
710
+ // 0.9.1 and older wrote an unmarked command. A home-level instance was
711
+ // only created by explicit `--global`, so retain that consent. New
712
+ // installs carry --scope-managed and fail closed if their scope is lost.
713
+ legacyUnscopedHook: scopeManaged !== true,
714
+ })) return;
715
+ }
548
716
  const cfg = resolveConfig({});
549
717
  await runHook({ raw, cfg, vendor });
550
718
  } catch {
@@ -555,12 +723,89 @@ export async function hookMain({ vendor = "unknown" } = {}) {
555
723
  // --- `hilos-agent hooks print|install [--global]` ---------------------------
556
724
 
557
725
  const CLAUDE_HOOK_COMMAND = "hilos-agent hook --vendor claude_code";
558
- const CODEX_HOOK_COMMAND = "hilos-agent hook --vendor codex";
726
+ const LEGACY_CODEX_HOOK_COMMAND = "hilos-agent hook --vendor codex";
727
+ const CODEX_HOOK_COMMAND = "hilos-agent hook --vendor codex --scope-managed";
559
728
  const CURSOR_HOOK_COMMAND = "hilos-agent hook --vendor cursor";
560
729
 
730
+ function hookCommandArg(value, platform = process.platform) {
731
+ const text = String(value);
732
+ if (/^[a-zA-Z0-9_./:\\-]+$/.test(text)) return text;
733
+ if (platform === "win32") {
734
+ // Hook commands are interpreted by the provider's Windows command runner.
735
+ // Double quotes preserve spaces; doubled quotes and percent signs remain
736
+ // literal instead of becoming command syntax/environment expansion.
737
+ return `"${text.replaceAll("%", "%%").replaceAll('"', '""')}"`;
738
+ }
739
+ return `'${text.replaceAll("'", `'"'"'`)}'`;
740
+ }
741
+
742
+ /**
743
+ * Install a dependency-free, versioned copy of the hook entrypoint. `npx`
744
+ * exposes its temporary .bin directory only for the installer process, so a
745
+ * later Codex/Claude/Cursor hook cannot safely rely on `hilos-agent` being on
746
+ * PATH. The private runtime makes the documented npx flow durable and offline.
747
+ */
748
+ export function installHookRuntime({
749
+ sourceRoot = dirname(dirname(fileURLToPath(import.meta.url))),
750
+ runtimeRoot = HOOK_RUNTIME_ROOT,
751
+ nodePath = process.execPath,
752
+ } = {}) {
753
+ try {
754
+ const manifest = JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8"));
755
+ const version = typeof manifest?.version === "string" && /^[0-9A-Za-z.+-]+$/.test(manifest.version)
756
+ ? manifest.version
757
+ : "unknown";
758
+ mkdirSync(runtimeRoot, { recursive: true, mode: 0o700 });
759
+ chmodSync(runtimeRoot, 0o700);
760
+ const target = join(runtimeRoot, version);
761
+ const entry = join(target, "bin", "hilos-agent.mjs");
762
+ if (!existsSync(entry)) {
763
+ const temporary = join(runtimeRoot, `.install-${process.pid}-${Date.now()}`);
764
+ try {
765
+ mkdirSync(temporary, { recursive: true, mode: 0o700 });
766
+ cpSync(join(sourceRoot, "bin"), join(temporary, "bin"), { recursive: true });
767
+ cpSync(join(sourceRoot, "src"), join(temporary, "src"), { recursive: true });
768
+ cpSync(join(sourceRoot, "package.json"), join(temporary, "package.json"));
769
+ try {
770
+ renameSync(temporary, target);
771
+ } catch (error) {
772
+ // A simultaneous installer may have won the atomic rename. Its fully
773
+ // written entry is equivalent; any other failure remains fatal.
774
+ if (!existsSync(entry)) throw error;
775
+ }
776
+ } finally {
777
+ if (existsSync(temporary)) rmSync(temporary, { recursive: true, force: true });
778
+ }
779
+ }
780
+ if (!existsSync(entry)) return null;
781
+ return `${hookCommandArg(nodePath)} ${hookCommandArg(entry)}`;
782
+ } catch {
783
+ return null;
784
+ }
785
+ }
786
+
787
+ function managedHookCommand(command, vendor) {
788
+ return typeof command === "string" &&
789
+ command.includes(" hook --managed-runtime ") &&
790
+ command.includes(`--vendor ${vendor}`);
791
+ }
792
+
793
+ function hookCommands(commandBase = "hilos-agent") {
794
+ return {
795
+ claude: `${commandBase} hook --managed-runtime --vendor claude_code`,
796
+ codex: `${commandBase} hook --managed-runtime --vendor codex --scope-managed`,
797
+ cursor: `${commandBase} hook --managed-runtime --vendor cursor`,
798
+ };
799
+ }
800
+
801
+ function codexHome() {
802
+ const configured = typeof process.env.CODEX_HOME === "string" ? process.env.CODEX_HOME.trim() : "";
803
+ return configured || join(homedir(), ".codex");
804
+ }
805
+
561
806
  /** The hooks block hilos needs inside a Claude Code settings.json. */
562
- export function hilosHooksBlock() {
563
- const entry = { hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND }] };
807
+ export function hilosHooksBlock(command = CLAUDE_HOOK_COMMAND) {
808
+ const entry = { hooks: [{ type: "command", command }] };
564
809
  return {
565
810
  SessionStart: [{ matcher: "startup|resume|clear|compact", ...entry }],
566
811
  UserPromptSubmit: [entry],
@@ -571,9 +816,9 @@ export function hilosHooksBlock() {
571
816
  }
572
817
 
573
818
  /** The equivalent project/global hooks file Codex discovers in `.codex/`. */
574
- export function hilosCodexHooksBlock() {
575
- const entry = { hooks: [{ type: "command", command: CODEX_HOOK_COMMAND, timeout: 5 }] };
576
- const sessionEnd = { hooks: [{ type: "command", command: CODEX_HOOK_COMMAND, timeout: 3 }] };
819
+ export function hilosCodexHooksBlock(command = CODEX_HOOK_COMMAND) {
820
+ const entry = { hooks: [{ type: "command", command, timeout: 5 }] };
821
+ const sessionEnd = { hooks: [{ type: "command", command, timeout: 3 }] };
577
822
  return {
578
823
  SessionStart: [{ matcher: "startup|resume|clear|compact", ...entry }],
579
824
  UserPromptSubmit: [entry],
@@ -584,8 +829,8 @@ export function hilosCodexHooksBlock() {
584
829
  }
585
830
 
586
831
  /** Cursor's native hooks.json uses lower-camel event names and flat commands. */
587
- export function hilosCursorHooksBlock() {
588
- const entry = { command: CURSOR_HOOK_COMMAND, timeout: 5 };
832
+ export function hilosCursorHooksBlock(command = CURSOR_HOOK_COMMAND) {
833
+ const entry = { command, timeout: 5 };
589
834
  return {
590
835
  sessionStart: [entry],
591
836
  beforeSubmitPrompt: [entry],
@@ -598,7 +843,7 @@ export function hilosCursorHooksBlock() {
598
843
  };
599
844
  }
600
845
 
601
- function mergeHookBlock(settings, block, command) {
846
+ function mergeHookBlock(settings, block, command, { vendor = "", legacy = [] } = {}) {
602
847
  const out = settings && typeof settings === "object" ? settings : {};
603
848
  const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
604
849
  let changed = false;
@@ -608,9 +853,14 @@ function mergeHookBlock(settings, block, command) {
608
853
  for (const group of existing) {
609
854
  for (const handler of group?.hooks || []) {
610
855
  if (handler?.command === command) found = true;
611
- // Upgrade the pre-0847 Claude-only command in place. Copy-on-write is
612
- // not useful here: the old helper already mutates the passed settings.
613
- if (command === CLAUDE_HOOK_COMMAND && handler?.command === "hilos-agent hook") {
856
+ // Upgrade an earlier package command (including a versioned private
857
+ // runtime) in place while preserving the user's grouping/matcher.
858
+ if (
859
+ handler?.command !== command && (
860
+ legacy.includes(handler?.command) ||
861
+ (vendor && managedHookCommand(handler?.command, vendor))
862
+ )
863
+ ) {
614
864
  handler.command = command;
615
865
  found = true;
616
866
  changed = true;
@@ -630,22 +880,72 @@ function mergeHookBlock(settings, block, command) {
630
880
  * settings.json). Existing user hooks are preserved; a second install is a
631
881
  * no-op. PURE — returns { settings, changed }.
632
882
  */
633
- export function mergeHooksIntoSettings(settings) {
634
- return mergeHookBlock(settings, hilosHooksBlock(), CLAUDE_HOOK_COMMAND);
883
+ export function mergeHooksIntoSettings(settings, command = CLAUDE_HOOK_COMMAND) {
884
+ return mergeHookBlock(settings, hilosHooksBlock(command), command, {
885
+ vendor: "claude_code",
886
+ legacy: ["hilos-agent hook", CLAUDE_HOOK_COMMAND],
887
+ });
635
888
  }
636
889
 
637
- export function mergeCodexHooksIntoSettings(settings) {
638
- return mergeHookBlock(settings, hilosCodexHooksBlock(), CODEX_HOOK_COMMAND);
890
+ export function mergeCodexHooksIntoSettings(settings, command = CODEX_HOOK_COMMAND) {
891
+ // Upgrade the old unscoped command in place. Its old location still tells
892
+ // hookMain whether it was project-local or explicit-global until install is
893
+ // rerun, while every newly written command is scope-managed.
894
+ const migrated = removeCodexHooksFromSettings(
895
+ settings,
896
+ [LEGACY_CODEX_HOOK_COMMAND, CODEX_HOOK_COMMAND],
897
+ command,
898
+ );
899
+ const merged = mergeHookBlock(migrated.settings, hilosCodexHooksBlock(command), command, {
900
+ vendor: "codex",
901
+ });
902
+ return { settings: merged.settings, changed: migrated.changed || merged.changed };
903
+ }
904
+
905
+ /** Remove only hilos's Codex handlers, preserving every user handler/group. */
906
+ export function removeCodexHooksFromSettings(
907
+ settings,
908
+ commands = [CODEX_HOOK_COMMAND, LEGACY_CODEX_HOOK_COMMAND],
909
+ keepCommand = "",
910
+ ) {
911
+ const out = settings && typeof settings === "object" ? settings : {};
912
+ const hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {};
913
+ let changed = false;
914
+ for (const [event, groups] of Object.entries(hooks)) {
915
+ if (!Array.isArray(groups)) continue;
916
+ const nextGroups = [];
917
+ for (const group of groups) {
918
+ const handlers = Array.isArray(group?.hooks) ? group.hooks : [];
919
+ const kept = handlers.filter((handler) =>
920
+ handler?.command === keepCommand || (
921
+ !commands.includes(handler?.command) && !managedHookCommand(handler?.command, "codex")
922
+ ));
923
+ if (kept.length !== handlers.length) changed = true;
924
+ if (kept.length) nextGroups.push({ ...group, hooks: kept });
925
+ else if (!handlers.length) nextGroups.push(group);
926
+ }
927
+ if (nextGroups.length) hooks[event] = nextGroups;
928
+ else if (groups.length) delete hooks[event];
929
+ }
930
+ out.hooks = hooks;
931
+ return { settings: out, changed };
639
932
  }
640
933
 
641
- export function mergeCursorHooksIntoSettings(settings) {
934
+ export function mergeCursorHooksIntoSettings(settings, command = CURSOR_HOOK_COMMAND) {
642
935
  const out = settings && typeof settings === "object" ? settings : {};
643
936
  let changed = out.version !== 1;
644
937
  if (changed) out.version = 1;
645
938
  const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
646
- for (const [event, entries] of Object.entries(hilosCursorHooksBlock())) {
939
+ for (const [event, entries] of Object.entries(hilosCursorHooksBlock(command))) {
647
940
  const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
648
- if (!existing.some((handler) => handler?.command === CURSOR_HOOK_COMMAND)) {
941
+ const prior = existing.find((handler) =>
942
+ handler?.command === CURSOR_HOOK_COMMAND || managedHookCommand(handler?.command, "cursor"));
943
+ if (prior) {
944
+ if (prior.command !== command) {
945
+ prior.command = command;
946
+ changed = true;
947
+ }
948
+ } else {
649
949
  hooks[event] = [...existing, ...entries];
650
950
  changed = true;
651
951
  }
@@ -654,7 +954,7 @@ export function mergeCursorHooksIntoSettings(settings) {
654
954
  }
655
955
 
656
956
  /** `hilos-agent hooks install [--global]` / `hilos-agent hooks print`. */
657
- function installHooksFile({ target, merge, label, scope }) {
957
+ export function installHooksFile({ target, merge, label, scope }) {
658
958
  let current = {};
659
959
  if (existsSync(target)) {
660
960
  try {
@@ -670,14 +970,40 @@ function installHooksFile({ target, merge, label, scope }) {
670
970
  console.log(`${label} hilos hooks already installed in ${target}.`);
671
971
  return true;
672
972
  }
673
- if (existsSync(target)) writeFileSync(target + ".bak", readFileSync(target));
674
- mkdirSync(dirname(target), { recursive: true });
675
- writeFileSync(target, JSON.stringify(settings, null, 2) + "\n");
973
+ try {
974
+ if (existsSync(target)) writeFileSync(target + ".bak", readFileSync(target));
975
+ mkdirSync(dirname(target), { recursive: true });
976
+ writeFileSync(target, JSON.stringify(settings, null, 2) + "\n");
977
+ } catch {
978
+ // Callers use the boolean to roll back any authority state they changed
979
+ // before this write (Codex's repo allowlist). Filesystem failures are an
980
+ // install refusal, never an uncaught half-install.
981
+ console.error(`Could not write ${target}; no hook was installed. Check the file permissions and try again.`);
982
+ process.exitCode = 1;
983
+ return false;
984
+ }
676
985
  console.log(`Installed ${label} hilos hooks into ${target}${existsSync(target + ".bak") ? ` (backup: ${target}.bak)` : ""}.`);
677
986
  console.log(`${scope} ${label} sessions can now stream to hilos and bind replies to the same local session.`);
678
987
  return true;
679
988
  }
680
989
 
990
+ function removeProjectCodexHook(target) {
991
+ if (!existsSync(target)) return false;
992
+ try {
993
+ const current = JSON.parse(readFileSync(target, "utf8"));
994
+ const { settings, changed } = removeCodexHooksFromSettings(current);
995
+ if (!changed) return false;
996
+ writeFileSync(target + ".bak", readFileSync(target));
997
+ writeFileSync(target, JSON.stringify(settings, null, 2) + "\n");
998
+ console.log(`Moved the hilos Codex hook out of ${target}; other project hooks were preserved (backup: ${target}.bak).`);
999
+ return true;
1000
+ } catch {
1001
+ // installHooksFile reports malformed global files; a malformed legacy
1002
+ // project file is left untouched rather than risk deleting user config.
1003
+ return false;
1004
+ }
1005
+ }
1006
+
681
1007
  export function hooksMain(sub, { global: isGlobal = false, client = "all" } = {}) {
682
1008
  if (sub === "print") {
683
1009
  if (client !== "codex" && client !== "cursor") {
@@ -685,7 +1011,7 @@ export function hooksMain(sub, { global: isGlobal = false, client = "all" } = {}
685
1011
  console.log(JSON.stringify({ hooks: hilosHooksBlock() }, null, 2));
686
1012
  }
687
1013
  if (client !== "claude" && client !== "cursor") {
688
- console.log("\nCodex (.codex/hooks.json):");
1014
+ console.log("\nCodex (~/.codex/hooks.json, project access allowlisted by hilos-agent):");
689
1015
  console.log(JSON.stringify({ description: "hilos local session bridge", hooks: hilosCodexHooksBlock() }, null, 2));
690
1016
  }
691
1017
  if (client !== "claude" && client !== "codex") {
@@ -698,47 +1024,89 @@ export function hooksMain(sub, { global: isGlobal = false, client = "all" } = {}
698
1024
  console.log("Usage: hilos-agent hooks <install|print> [--global] [--claude|--codex|--cursor]");
699
1025
  return;
700
1026
  }
701
- const scope = isGlobal ? "Every opted-in" : "This repo's";
1027
+ const runtime = installHookRuntime();
1028
+ if (!runtime) {
1029
+ console.error("Could not install the private hilos hook runtime. Check ~/.hilos permissions and try again; no hook was changed.");
1030
+ process.exitCode = 1;
1031
+ return;
1032
+ }
1033
+ const commands = hookCommands(runtime);
1034
+ const scope = isGlobal ? "Every" : "This repo's";
702
1035
  if (client !== "codex" && client !== "cursor") {
703
1036
  installHooksFile({
704
1037
  target: isGlobal
705
1038
  ? join(homedir(), ".claude", "settings.json")
706
1039
  : join(process.cwd(), ".claude", "settings.json"),
707
- merge: mergeHooksIntoSettings,
1040
+ merge: (settings) => mergeHooksIntoSettings(settings, commands.claude),
708
1041
  label: "Claude Code",
709
1042
  scope,
710
1043
  });
711
1044
  }
712
1045
  if (client !== "claude" && client !== "cursor") {
713
- installHooksFile({
714
- target: isGlobal
715
- ? join(homedir(), ".codex", "hooks.json")
716
- : join(process.cwd(), ".codex", "hooks.json"),
717
- merge: mergeCodexHooksIntoSettings,
718
- label: "Codex",
719
- scope,
720
- });
721
- console.log("Codex asks you to review new project hooks once; open /hooks and trust the hilos entries.");
1046
+ const globalTarget = join(codexHome(), "hooks.json");
1047
+ const scopeExisted = existsSync(CODEX_HOOK_SCOPE_FILE);
1048
+ let priorScope = null;
1049
+ try {
1050
+ if (scopeExisted) priorScope = readFileSync(CODEX_HOOK_SCOPE_FILE);
1051
+ } catch {
1052
+ // A scope we cannot back up also cannot be safely replaced.
1053
+ priorScope = null;
1054
+ }
1055
+ // Record consent before installing a home-level executable hook. If the
1056
+ // private scope file cannot be written, nothing new is allowed to run.
1057
+ const recorded = scopeExisted && !priorScope
1058
+ ? null
1059
+ : writeCodexHookScope({ cwd: isGlobal ? "" : process.cwd(), global: isGlobal });
1060
+ if (!recorded) {
1061
+ console.error("Could not record the Codex hook scope; the Codex hook was not installed. Fix ~/.hilos permissions and rerun this command.");
1062
+ process.exitCode = 1;
1063
+ } else {
1064
+ const installed = installHooksFile({
1065
+ target: globalTarget,
1066
+ merge: (settings) => mergeCodexHooksIntoSettings(settings, commands.codex),
1067
+ label: "Codex",
1068
+ scope: isGlobal ? "Every" : "This repo's opted-in",
1069
+ });
1070
+ if (!installed) {
1071
+ // The scope is authority state. If the executable hook could not be
1072
+ // installed, restore it exactly so an old project hook is not disabled
1073
+ // merely because an unrelated home hooks.json was malformed.
1074
+ try {
1075
+ if (scopeExisted && priorScope) {
1076
+ writeFileSync(CODEX_HOOK_SCOPE_FILE, priorScope, { mode: 0o600 });
1077
+ chmodSync(CODEX_HOOK_SCOPE_FILE, 0o600);
1078
+ } else if (!scopeExisted) {
1079
+ unlinkSync(CODEX_HOOK_SCOPE_FILE);
1080
+ }
1081
+ } catch {
1082
+ console.error("Could not restore the prior Codex hook scope after the install failed.");
1083
+ process.exitCode = 1;
1084
+ }
1085
+ } else {
1086
+ // `codex exec` currently skips repository hooks while the TUI runs
1087
+ // them. One global hook + our allowlist covers both and avoids double
1088
+ // sends in the TUI. Migrate the pre-0854 project entry in place.
1089
+ const projectTarget = join(codexProjectPath(process.cwd()), ".codex", "hooks.json");
1090
+ if (normalizedProjectPath(projectTarget) !== normalizedProjectPath(globalTarget)) {
1091
+ removeProjectCodexHook(projectTarget);
1092
+ }
1093
+ if (!isGlobal) {
1094
+ console.log(`Allowed Codex hooks only for ${codexProjectPath(process.cwd())}.`);
1095
+ }
1096
+ console.log("Codex asks you to review this hook once; open /hooks and trust the hilos entry.");
1097
+ }
1098
+ }
722
1099
  }
723
1100
  if (client !== "claude" && client !== "codex") {
724
1101
  installHooksFile({
725
1102
  target: isGlobal
726
1103
  ? join(homedir(), ".cursor", "hooks.json")
727
1104
  : join(process.cwd(), ".cursor", "hooks.json"),
728
- merge: mergeCursorHooksIntoSettings,
1105
+ merge: (settings) => mergeCursorHooksIntoSettings(settings, commands.cursor),
729
1106
  label: "Cursor",
730
1107
  scope,
731
1108
  });
732
1109
  }
733
1110
  console.log("Kill switches: HILOS_HOOKS=off pauses streaming; HILOS_REPLY_BRIDGE=off pauses reply pickup.");
734
- // The installed entry runs `hilos-agent hook` directly (npx per tool call
735
- // would add cold-start latency) — warn now if that won't resolve.
736
- const onPath = (process.env.PATH || "")
737
- .split(delimiter)
738
- .some((dir) => dir && existsSync(join(dir, "hilos-agent")));
739
- if (!onPath) {
740
- console.log(
741
- "NOTE: hilos-agent isn't on your PATH — the hooks won't fire until you run: npm i -g hilos-agent",
742
- );
743
- }
1111
+ console.log(`Private hook runtime: ${runtime}. No global hilos-agent install is required.`);
744
1112
  }