hilos-agent 0.9.0 → 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
@@ -1,29 +1,34 @@
1
- // Claude Code hook → hilos live progress (0448). This is the "watch your
2
- // agent's hands move" path for a RAW CLI session (no daemon): install the
3
- // hilos hooks in a repo (`hilos-agent hooks install`) and every tool call your
4
- // Claude Code session makes streams a step into the agent's live status card
5
- // in its hilos channel, via the `post_progress` MCP tool's channelId mode.
1
+ // Local coding-session hooks → hilos live progress (0448) + thread binding
2
+ // (0847). This is the "watch your agent's hands move" path for a raw Codex,
3
+ // Claude Code, or Cursor session. Every tool call can stream a step into the agent's live
4
+ // card; a successful hilos post also binds that provider session to the exact
5
+ // hilos thread so the daemon can carry a later human reply back into it.
6
6
  //
7
7
  // Design rules:
8
8
  // - NEVER block or break the user's CLI. Every entry point swallows every
9
9
  // error and exits 0; the network send is capped by a hard timeout.
10
- // - Cheap by default: a state file per Claude session carries the message id,
10
+ // - Cheap by default: a state file per provider session carries the message id,
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 to the PROJECT's
15
- // .claude/settings.json, so only repos you opt in ever stream. HILOS_HOOKS=off
16
- // is the global kill switch.
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.
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 } from "node:fs";
21
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync, chmodSync, realpathSync, cpSync, renameSync, rmSync } from "node:fs";
22
+ import { createHash } from "node:crypto";
21
23
  import { homedir } from "node:os";
22
- import { join, dirname, delimiter } from "node:path";
23
- import { sanitizeText } from "./agent-events.mjs";
24
+ import { join, dirname } from "node:path";
25
+ import { fileURLToPath } from "node:url";
26
+ import { sanitizeText, webTarget } from "./agent-events.mjs";
24
27
  import { resolveConfig } from "./config.mjs";
25
28
 
26
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");
27
32
  /** Coalesce window between sends; Stop/SessionEnd always flush. */
28
33
  const MIN_SEND_MS = 2000;
29
34
  /** A hook must never hang the CLI on a slow network. */
@@ -34,30 +39,322 @@ const MAX_FILES = 20;
34
39
  /** Session state older than this is dead — GC'd opportunistically. */
35
40
  const STATE_TTL_MS = 48 * 60 * 60 * 1000;
36
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
+
37
81
  /**
38
- * Parse a Claude Code hook event from stdin text. Returns a normalized
39
- * { event, sessionId, toolName, toolInput, cwd } or null when unusable.
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
+
158
+ /**
159
+ * Scope local hook state to one hilos agent credential without ever writing the
160
+ * bearer token itself to disk. Two agent identities can share a machine, so a
161
+ * global hook-state directory alone is not an authority boundary.
162
+ */
163
+ export function hookConnectionKey(token) {
164
+ const value = typeof token === "string" ? token.trim() : "";
165
+ return value ? createHash("sha256").update(value).digest("hex").slice(0, 24) : "";
166
+ }
167
+
168
+ /**
169
+ * Parse a Codex / Claude Code / Cursor hook event from stdin text. Returns a
170
+ * normalized event or null when unusable. The providers share common fields;
171
+ * toolResponse is additive for PostToolUse and ignored by the progress lane.
40
172
  * PURE — never throws.
41
173
  */
42
174
  export function parseHookEvent(raw) {
43
175
  try {
44
176
  const o = JSON.parse(String(raw));
45
177
  if (!o || typeof o !== "object") return null;
46
- const event = typeof o.hook_event_name === "string" ? o.hook_event_name : "";
47
- const sessionId = typeof o.session_id === "string" ? o.session_id : "";
178
+ const rawEvent = typeof o.hook_event_name === "string" ? o.hook_event_name : "";
179
+ const event = ({
180
+ sessionStart: "SessionStart",
181
+ beforeSubmitPrompt: "UserPromptSubmit",
182
+ postToolUse: "PostToolUse",
183
+ afterMCPExecution: "PostToolUse",
184
+ stop: "Stop",
185
+ sessionEnd: "SessionEnd",
186
+ })[rawEvent] || rawEvent;
187
+ const cursorEvent = Object.hasOwn({
188
+ sessionStart: true,
189
+ beforeSubmitPrompt: true,
190
+ postToolUse: true,
191
+ afterMCPExecution: true,
192
+ stop: true,
193
+ sessionEnd: true,
194
+ }, rawEvent);
195
+ // Cursor's resumable chat id is conversation_id. Some lifecycle payloads
196
+ // also carry a hook/session id; preferring that would write the anchor under
197
+ // an identifier `cursor-agent --resume` does not accept.
198
+ const sessionId = cursorEvent && typeof o.conversation_id === "string"
199
+ ? o.conversation_id
200
+ : typeof o.session_id === "string"
201
+ ? o.session_id
202
+ : typeof o.conversation_id === "string"
203
+ ? o.conversation_id
204
+ : "";
48
205
  if (!event || !sessionId) return null;
206
+ const jsonObject = (value) => {
207
+ if (value && typeof value === "object") return value;
208
+ if (typeof value !== "string") return {};
209
+ try {
210
+ const parsed = JSON.parse(value);
211
+ return parsed && typeof parsed === "object" ? parsed : {};
212
+ } catch {
213
+ return {};
214
+ }
215
+ };
216
+ const cursorMcp = rawEvent === "afterMCPExecution";
217
+ const server = typeof o.mcp_server_name === "string" ? o.mcp_server_name : "";
218
+ const rawToolName = typeof o.tool_name === "string" ? o.tool_name : "";
49
219
  return {
50
220
  event,
51
221
  sessionId,
52
- toolName: typeof o.tool_name === "string" ? o.tool_name : "",
53
- toolInput: o.tool_input && typeof o.tool_input === "object" ? o.tool_input : {},
54
- cwd: typeof o.cwd === "string" ? o.cwd : "",
222
+ toolName: cursorMcp && server && rawToolName
223
+ ? `mcp__${server}__${rawToolName}`
224
+ : rawToolName,
225
+ toolInput: jsonObject(o.tool_input),
226
+ ...(o.tool_response !== undefined
227
+ ? { toolResponse: o.tool_response }
228
+ : o.tool_output !== undefined
229
+ ? { toolResponse: o.tool_output }
230
+ : o.result_json !== undefined
231
+ ? { toolResponse: o.result_json }
232
+ : {}),
233
+ cwd: typeof o.cwd === "string"
234
+ ? o.cwd
235
+ : Array.isArray(o.workspace_roots) && typeof o.workspace_roots[0] === "string"
236
+ ? o.workspace_roots[0]
237
+ : "",
55
238
  };
56
239
  } catch {
57
240
  return null;
58
241
  }
59
242
  }
60
243
 
244
+ /** A JSON-looking MCP text block, direct payload, or nested tool result. */
245
+ export function mcpResponsePayload(value, depth = 0) {
246
+ if (depth > 5 || value == null) return null;
247
+ if (typeof value === "string") {
248
+ try {
249
+ return mcpResponsePayload(JSON.parse(value), depth + 1);
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+ if (Array.isArray(value)) {
255
+ for (const item of value) {
256
+ const found = mcpResponsePayload(item, depth + 1);
257
+ if (found) return found;
258
+ }
259
+ return null;
260
+ }
261
+ if (typeof value !== "object") return null;
262
+ if (typeof value.messageId === "string") return value;
263
+ if (typeof value.text === "string") {
264
+ const parsed = mcpResponsePayload(value.text, depth + 1);
265
+ if (parsed) return parsed;
266
+ }
267
+ for (const key of ["structuredContent", "content", "result", "output"]) {
268
+ const found = mcpResponsePayload(value[key], depth + 1);
269
+ if (found) return found;
270
+ }
271
+ return null;
272
+ }
273
+
274
+ /**
275
+ * A successful hilos post made by this local session becomes its reply anchor.
276
+ * Match the hilos MCP server segment as well as the tool suffix so another MCP
277
+ * server's coincidentally named post_message can never bind a hilos session.
278
+ */
279
+ export function bindingFromHookEvent(ev, at = new Date().toISOString()) {
280
+ if (!ev || ev.event !== "PostToolUse") return null;
281
+ const match = String(ev.toolName || "").match(/^mcp__(.+?)__(post_message|post_report)$/i);
282
+ if (!match) return null;
283
+ const channelId = typeof ev.toolInput?.channelId === "string" ? ev.toolInput.channelId : "";
284
+ if (!channelId) return null;
285
+ const payload = mcpResponsePayload(ev.toolResponse);
286
+ const messageId = typeof payload?.messageId === "string" ? payload.messageId : "";
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;
303
+ // The server-authenticated author is part of the result. Requiring it keeps a
304
+ // differently configured hilos MCP connection from being resumed and posted
305
+ // through this daemon's identity merely because both can access the room.
306
+ if (!messageId || !agentId) return null;
307
+ const parentId = typeof ev.toolInput?.parentId === "string" ? ev.toolInput.parentId : "";
308
+ return {
309
+ threadRootId: parentId || messageId,
310
+ anchorMessageId: messageId,
311
+ channelId,
312
+ agentId,
313
+ ...(bindingClaim ? { bindingClaim } : {}),
314
+ boundAt: at,
315
+ processedReplyIds: [],
316
+ };
317
+ }
318
+
319
+ /** Keep the newest anchor for a thread and preserve already-processed replies. */
320
+ export function upsertBinding(state, binding, limit = 8) {
321
+ if (!state || !binding?.threadRootId || !binding?.anchorMessageId) return state;
322
+ const current = Array.isArray(state.bindings) ? state.bindings : [];
323
+ const prior = current.find((item) => item?.threadRootId === binding.threadRootId);
324
+ const next = {
325
+ ...binding,
326
+ processedReplyIds: Array.isArray(prior?.processedReplyIds)
327
+ ? prior.processedReplyIds.slice(-100)
328
+ : Array.isArray(binding.processedReplyIds)
329
+ ? binding.processedReplyIds.slice(-100)
330
+ : [],
331
+ };
332
+ state.bindings = [
333
+ ...current.filter((item) => item?.threadRootId !== binding.threadRootId),
334
+ next,
335
+ ].slice(-limit);
336
+ return state;
337
+ }
338
+
339
+ function sessionState(state, ev, vendor, now, token) {
340
+ const out = state && typeof state === "object"
341
+ ? state
342
+ : { startedAt: now(), lastSentAt: 0, steps: [], files: [], messageId: null };
343
+ out.sessionId = ev.sessionId;
344
+ out.vendor = vendor || out.vendor || "unknown";
345
+ out.cwd = ev.cwd || out.cwd || "";
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 = "";
353
+ out.updatedAt = new Date(now()).toISOString();
354
+ if (!Array.isArray(out.bindings)) out.bindings = [];
355
+ return out;
356
+ }
357
+
61
358
  /** Strip the session cwd prefix so step labels read as repo-relative paths. */
62
359
  function relPath(p, cwd) {
63
360
  const s = String(p || "");
@@ -97,7 +394,8 @@ export function hookStep(toolName, toolInput, cwd = "") {
97
394
  return file ? { label: `Editing ${file}`, file } : { label: "Editing files" };
98
395
  case "Read":
99
396
  return file ? { label: `Reading ${file}`, file } : { label: "Reading files" };
100
- case "Bash": {
397
+ case "Bash":
398
+ case "Shell": {
101
399
  const c = cmdLabel(input.command);
102
400
  return { label: c ? `Running ${c}` : "Running a command" };
103
401
  }
@@ -106,22 +404,26 @@ export function hookStep(toolName, toolInput, cwd = "") {
106
404
  const q = typeof input.pattern === "string" ? input.pattern : "";
107
405
  return { label: q ? `Searching for ${q.slice(0, 60)}` : "Searching the repo" };
108
406
  }
407
+ // `Agent` is what Claude Code 2.1.233 calls the subagent tool; `Task` is the
408
+ // older name. Before 0789 only `Task` was here, so a spawn on a current
409
+ // build fell through to the default arm and narrated "Using Agent".
410
+ case "Agent":
109
411
  case "Task": {
110
412
  const d = typeof input.description === "string" ? input.description : "";
111
- return { label: d ? `Delegating: ${d.slice(0, 60)}` : "Delegating a task" };
413
+ // Same sentence the stream mappers emit (0789) — one room, one wording,
414
+ // whichever lane the run came through.
415
+ return { label: d ? `Started a helper: ${d.slice(0, 60)}` : "Started a helper" };
112
416
  }
113
417
  case "WebFetch": {
114
- let host = "";
115
- try {
116
- host = new URL(String(input.url || "")).host;
117
- } catch {
118
- /* not a url */
119
- }
120
- return { label: host ? `Fetching ${host}` : "Fetching a page" };
418
+ // webTarget (0789) keeps host + PATH and drops the query string, the
419
+ // fragment, and userinfo, so a signed URL's token can't ride a hook step
420
+ // into the room.
421
+ const target = webTarget(input.url);
422
+ return { label: target ? `Read ${target}` : "Read a web page" };
121
423
  }
122
424
  case "WebSearch": {
123
425
  const q = typeof input.query === "string" ? input.query : "";
124
- return { label: q ? `Searching the web: ${q.slice(0, 60)}` : "Searching the web" };
426
+ return { label: q ? `Searched the web: ${q.slice(0, 60)}` : "Searched the web" };
125
427
  }
126
428
  case "TodoWrite":
127
429
  return null; // planning chatter — too noisy to narrate
@@ -180,8 +482,11 @@ export function readState(sessionId, dir = HOOK_STATE_DIR) {
180
482
 
181
483
  export function writeState(sessionId, state, dir = HOOK_STATE_DIR) {
182
484
  try {
183
- mkdirSync(dir, { recursive: true });
184
- writeFileSync(statePath(sessionId, dir), JSON.stringify(state));
485
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
486
+ chmodSync(dir, 0o700);
487
+ const path = statePath(sessionId, dir);
488
+ writeFileSync(path, JSON.stringify(state), { mode: 0o600 });
489
+ chmodSync(path, 0o600);
185
490
  } catch {
186
491
  /* best-effort */
187
492
  }
@@ -195,10 +500,13 @@ export function deleteState(sessionId, dir = HOOK_STATE_DIR) {
195
500
  }
196
501
  }
197
502
 
198
- /** Drop state files from long-dead sessions. Best-effort, bounded, silent. */
503
+ /** Drop state files from long-dead sessions. Best-effort and silent. */
199
504
  export function gcStateDir(dir = HOOK_STATE_DIR, now = Date.now()) {
200
505
  try {
201
- 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"))) {
202
510
  const p = join(dir, f);
203
511
  try {
204
512
  if (now - statSync(p).mtimeMs > STATE_TTL_MS) unlinkSync(p);
@@ -211,7 +519,7 @@ export function gcStateDir(dir = HOOK_STATE_DIR, now = Date.now()) {
211
519
  }
212
520
  }
213
521
 
214
- /** One bounded post_progress call. Returns the server's messageId, or null. */
522
+ /** One bounded post_progress call. Returns its authenticated anchor, or null. */
215
523
  async function sendProgress({ url, token, messageId, channelId, progress }) {
216
524
  const controller = new AbortController();
217
525
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -232,7 +540,15 @@ async function sendProgress({ url, token, messageId, channelId, progress }) {
232
540
  const json = await res.json();
233
541
  const text = json?.result?.content?.[0]?.text;
234
542
  const parsed = typeof text === "string" ? JSON.parse(text) : null;
235
- return parsed && typeof parsed.messageId === "string" ? parsed.messageId : null;
543
+ // Older hilos servers do not return agentId yet. Keep progress-card reuse
544
+ // backward compatible, but refuse to turn that unauthenticated result into
545
+ // a reply binding below.
546
+ return parsed && typeof parsed.messageId === "string"
547
+ ? {
548
+ messageId: parsed.messageId,
549
+ agentId: typeof parsed.agentId === "string" ? parsed.agentId : "",
550
+ }
551
+ : null;
236
552
  } catch {
237
553
  return null;
238
554
  } finally {
@@ -244,47 +560,86 @@ async function sendProgress({ url, token, messageId, channelId, progress }) {
244
560
  * Handle one hook invocation end to end. Reads nothing from process.* so the
245
561
  * caller (bin) owns stdin/env; returns quietly on every failure path.
246
562
  */
247
- export async function runHook({ raw, cfg, now = Date.now, stateDir = HOOK_STATE_DIR }) {
563
+ export async function runHook({ raw, cfg, vendor = "unknown", now = Date.now, stateDir = HOOK_STATE_DIR }) {
248
564
  const ev = parseHookEvent(raw);
249
565
  if (!ev) return;
250
566
  const { url, token, channelId } = cfg;
251
- if (!url || !token || !channelId) return; // not connected to hilos — no-op
567
+
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);
574
+ const binding = bindingFromHookEvent(ev, new Date(now()).toISOString());
575
+ if (binding) upsertBinding(state, binding);
576
+
577
+ if (ev.event === "SessionStart") {
578
+ state.active = false;
579
+ writeState(ev.sessionId, state, stateDir);
580
+ return;
581
+ }
582
+
583
+ if (ev.event === "UserPromptSubmit") {
584
+ state.active = true;
585
+ writeState(ev.sessionId, state, stateDir);
586
+ return;
587
+ }
252
588
 
253
589
  if (ev.event === "Stop" || ev.event === "SessionEnd") {
254
590
  // End of a turn (or the session): settle the card honestly — "not doing
255
591
  // anything right now". The next tool call revives the SAME card to working
256
592
  // via its stored messageId, so a session stays one card, not one per turn.
257
- const state = readState(ev.sessionId, stateDir);
258
- if (!state || !state.messageId) return;
259
- await sendProgress({
260
- url,
261
- token,
262
- messageId: state.messageId,
263
- progress: {
264
- state: "done",
265
- elapsedMs: Math.max(0, now() - (state.startedAt || now())),
266
- },
267
- });
268
- if (ev.event === "SessionEnd") deleteState(ev.sessionId, stateDir);
269
- else {
270
- state.lastSentAt = now();
271
- writeState(ev.sessionId, state, stateDir);
593
+ state.active = false;
594
+ if (url && token && state.messageId) {
595
+ await sendProgress({
596
+ url,
597
+ token,
598
+ messageId: state.messageId,
599
+ progress: {
600
+ state: "done",
601
+ elapsedMs: Math.max(0, now() - (state.startedAt || now())),
602
+ },
603
+ });
272
604
  }
605
+ // A provider SessionEnd does not make its id unresumable. Keep the local
606
+ // binding until the normal 48h GC so a reply received after the terminal
607
+ // window closes can still continue it.
608
+ state.lastSentAt = now();
609
+ writeState(ev.sessionId, state, stateDir);
273
610
  return;
274
611
  }
275
612
 
276
613
  if (ev.event !== "PostToolUse") return;
277
614
 
615
+ state.active = true;
616
+
278
617
  const step = hookStep(ev.toolName, ev.toolInput, ev.cwd);
279
- if (!step) return;
618
+ if (!step) {
619
+ // hilos MCP calls are deliberately not narrated as progress, but their
620
+ // returned message id is the most important state this hook records.
621
+ writeState(ev.sessionId, state, stateDir);
622
+ return;
623
+ }
280
624
 
281
- let state = readState(ev.sessionId, stateDir);
282
- if (!state) {
283
- gcStateDir(stateDir, now()); // new session — a cheap moment to sweep old ones
284
- state = { startedAt: now(), lastSentAt: 0, steps: [], files: [], messageId: null };
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;
285
631
  }
632
+
633
+ if (!state.startedAt) state.startedAt = now();
286
634
  foldStep(state, step);
287
635
 
636
+ // A config without a default channel can still bind explicit hilos MCP posts
637
+ // in any accessible room; it simply has nowhere to create an ambient card.
638
+ if (!channelId && !state.messageId) {
639
+ writeState(ev.sessionId, state, stateDir);
640
+ return;
641
+ }
642
+
288
643
  if (!shouldSendNow(state, now())) {
289
644
  // Inside the coalesce window: fold only. The next event (or Stop) flushes.
290
645
  writeState(ev.sessionId, state, stateDir);
@@ -298,19 +653,31 @@ export async function runHook({ raw, cfg, now = Date.now, stateDir = HOOK_STATE_
298
653
  filesTouched: state.files,
299
654
  elapsedMs: Math.max(0, now() - (state.startedAt || now())),
300
655
  };
301
- const messageId = await sendProgress({
656
+ const anchor = await sendProgress({
302
657
  url,
303
658
  token,
304
659
  messageId: state.messageId || undefined,
305
660
  channelId,
306
661
  progress,
307
662
  });
308
- if (messageId) state.messageId = messageId;
663
+ if (anchor) {
664
+ state.messageId = anchor.messageId;
665
+ if (channelId && anchor.agentId) {
666
+ upsertBinding(state, {
667
+ threadRootId: anchor.messageId,
668
+ anchorMessageId: anchor.messageId,
669
+ channelId,
670
+ agentId: anchor.agentId,
671
+ boundAt: new Date(now()).toISOString(),
672
+ processedReplyIds: [],
673
+ });
674
+ }
675
+ }
309
676
  state.lastSentAt = now();
310
677
  writeState(ev.sessionId, state, stateDir);
311
678
  }
312
679
 
313
- /** Read all of stdin (the hook event JSON Claude Code pipes in). */
680
+ /** Read all of stdin (the hook event JSON the coding tool pipes in). */
314
681
  function readStdin() {
315
682
  return new Promise((resolve) => {
316
683
  let data = "";
@@ -327,13 +694,27 @@ function readStdin() {
327
694
  });
328
695
  }
329
696
 
330
- /** `hilos-agent hook` — the command Claude Code invokes. Always exits 0. */
331
- export async function hookMain() {
697
+ /** `hilos-agent hook` — the command each provider invokes. Always exits 0. */
698
+ export async function hookMain({ vendor = "unknown", scopeManaged = false } = {}) {
332
699
  try {
333
700
  if (/^(off|0|false)$/i.test(process.env.HILOS_HOOKS || "")) return;
334
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
+ }
335
716
  const cfg = resolveConfig({});
336
- await runHook({ raw, cfg });
717
+ await runHook({ raw, cfg, vendor });
337
718
  } catch {
338
719
  /* a hook must never fail the user's CLI */
339
720
  }
@@ -341,33 +722,230 @@ export async function hookMain() {
341
722
 
342
723
  // --- `hilos-agent hooks print|install [--global]` ---------------------------
343
724
 
344
- const HOOK_COMMAND = "hilos-agent hook";
725
+ const CLAUDE_HOOK_COMMAND = "hilos-agent hook --vendor claude_code";
726
+ const LEGACY_CODEX_HOOK_COMMAND = "hilos-agent hook --vendor codex";
727
+ const CODEX_HOOK_COMMAND = "hilos-agent hook --vendor codex --scope-managed";
728
+ const CURSOR_HOOK_COMMAND = "hilos-agent hook --vendor cursor";
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
+ }
345
805
 
346
806
  /** The hooks block hilos needs inside a Claude Code settings.json. */
347
- export function hilosHooksBlock() {
348
- const entry = { hooks: [{ type: "command", command: HOOK_COMMAND }] };
807
+ export function hilosHooksBlock(command = CLAUDE_HOOK_COMMAND) {
808
+ const entry = { hooks: [{ type: "command", command }] };
349
809
  return {
810
+ SessionStart: [{ matcher: "startup|resume|clear|compact", ...entry }],
811
+ UserPromptSubmit: [entry],
350
812
  PostToolUse: [{ matcher: "*", ...entry }],
351
813
  Stop: [entry],
352
814
  SessionEnd: [entry],
353
815
  };
354
816
  }
355
817
 
818
+ /** The equivalent project/global hooks file Codex discovers in `.codex/`. */
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 }] };
822
+ return {
823
+ SessionStart: [{ matcher: "startup|resume|clear|compact", ...entry }],
824
+ UserPromptSubmit: [entry],
825
+ PostToolUse: [{ matcher: "*", ...entry }],
826
+ Stop: [entry],
827
+ SessionEnd: [sessionEnd],
828
+ };
829
+ }
830
+
831
+ /** Cursor's native hooks.json uses lower-camel event names and flat commands. */
832
+ export function hilosCursorHooksBlock(command = CURSOR_HOOK_COMMAND) {
833
+ const entry = { command, timeout: 5 };
834
+ return {
835
+ sessionStart: [entry],
836
+ beforeSubmitPrompt: [entry],
837
+ postToolUse: [entry],
838
+ // This event carries mcp_server_name + the full result; postToolUse alone
839
+ // has no server identity on Cursor 2026.07.23.
840
+ afterMCPExecution: [entry],
841
+ stop: [entry],
842
+ sessionEnd: [entry],
843
+ };
844
+ }
845
+
846
+ function mergeHookBlock(settings, block, command, { vendor = "", legacy = [] } = {}) {
847
+ const out = settings && typeof settings === "object" ? settings : {};
848
+ const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
849
+ let changed = false;
850
+ for (const [event, entries] of Object.entries(block)) {
851
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
852
+ let found = false;
853
+ for (const group of existing) {
854
+ for (const handler of group?.hooks || []) {
855
+ if (handler?.command === command) found = true;
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
+ ) {
864
+ handler.command = command;
865
+ found = true;
866
+ changed = true;
867
+ }
868
+ }
869
+ }
870
+ if (!found) {
871
+ hooks[event] = [...existing, ...entries];
872
+ changed = true;
873
+ }
874
+ }
875
+ return { settings: out, changed };
876
+ }
877
+
356
878
  /**
357
879
  * Idempotently merge the hilos hooks into a settings object (parsed
358
880
  * settings.json). Existing user hooks are preserved; a second install is a
359
881
  * no-op. PURE — returns { settings, changed }.
360
882
  */
361
- export function mergeHooksIntoSettings(settings) {
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
+ });
888
+ }
889
+
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
+ ) {
362
911
  const out = settings && typeof settings === "object" ? settings : {};
363
- const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
912
+ const hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {};
364
913
  let changed = false;
365
- for (const [event, entries] of Object.entries(hilosHooksBlock())) {
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 };
932
+ }
933
+
934
+ export function mergeCursorHooksIntoSettings(settings, command = CURSOR_HOOK_COMMAND) {
935
+ const out = settings && typeof settings === "object" ? settings : {};
936
+ let changed = out.version !== 1;
937
+ if (changed) out.version = 1;
938
+ const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
939
+ for (const [event, entries] of Object.entries(hilosCursorHooksBlock(command))) {
366
940
  const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
367
- const already = existing.some((e) =>
368
- (e?.hooks || []).some((h) => h?.command === HOOK_COMMAND),
369
- );
370
- if (!already) {
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 {
371
949
  hooks[event] = [...existing, ...entries];
372
950
  changed = true;
373
951
  }
@@ -376,19 +954,7 @@ export function mergeHooksIntoSettings(settings) {
376
954
  }
377
955
 
378
956
  /** `hilos-agent hooks install [--global]` / `hilos-agent hooks print`. */
379
- export function hooksMain(sub, { global: isGlobal = false } = {}) {
380
- if (sub === "print") {
381
- console.log(JSON.stringify({ hooks: hilosHooksBlock() }, null, 2));
382
- console.log("\nMerge this into .claude/settings.json in the repo you want to stream.");
383
- return;
384
- }
385
- if (sub !== "install") {
386
- console.log("Usage: hilos-agent hooks <install|print> [--global]");
387
- return;
388
- }
389
- const target = isGlobal
390
- ? join(homedir(), ".claude", "settings.json")
391
- : join(process.cwd(), ".claude", "settings.json");
957
+ export function installHooksFile({ target, merge, label, scope }) {
392
958
  let current = {};
393
959
  if (existsSync(target)) {
394
960
  try {
@@ -396,32 +962,151 @@ export function hooksMain(sub, { global: isGlobal = false } = {}) {
396
962
  } catch {
397
963
  console.error(`${target} exists but isn't valid JSON — fix it first (nothing written).`);
398
964
  process.exitCode = 1;
399
- return;
965
+ return false;
400
966
  }
401
967
  }
402
- const { settings, changed } = mergeHooksIntoSettings(current);
968
+ const { settings, changed } = merge(current);
403
969
  if (!changed) {
404
- console.log(`hilos hooks already installed in ${target}.`);
970
+ console.log(`${label} hilos hooks already installed in ${target}.`);
971
+ return true;
972
+ }
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
+ }
985
+ console.log(`Installed ${label} hilos hooks into ${target}${existsSync(target + ".bak") ? ` (backup: ${target}.bak)` : ""}.`);
986
+ console.log(`${scope} ${label} sessions can now stream to hilos and bind replies to the same local session.`);
987
+ return true;
988
+ }
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
+
1007
+ export function hooksMain(sub, { global: isGlobal = false, client = "all" } = {}) {
1008
+ if (sub === "print") {
1009
+ if (client !== "codex" && client !== "cursor") {
1010
+ console.log("Claude Code (.claude/settings.json):");
1011
+ console.log(JSON.stringify({ hooks: hilosHooksBlock() }, null, 2));
1012
+ }
1013
+ if (client !== "claude" && client !== "cursor") {
1014
+ console.log("\nCodex (~/.codex/hooks.json, project access allowlisted by hilos-agent):");
1015
+ console.log(JSON.stringify({ description: "hilos local session bridge", hooks: hilosCodexHooksBlock() }, null, 2));
1016
+ }
1017
+ if (client !== "claude" && client !== "codex") {
1018
+ console.log("\nCursor (.cursor/hooks.json):");
1019
+ console.log(JSON.stringify({ version: 1, hooks: hilosCursorHooksBlock() }, null, 2));
1020
+ }
405
1021
  return;
406
1022
  }
407
- if (existsSync(target)) writeFileSync(target + ".bak", readFileSync(target));
408
- mkdirSync(dirname(target), { recursive: true });
409
- writeFileSync(target, JSON.stringify(settings, null, 2) + "\n");
410
- console.log(`Installed hilos hooks into ${target}${existsSync(target + ".bak") ? ` (backup: ${target}.bak)` : ""}.`);
411
- console.log(
412
- isGlobal
413
- ? "Every Claude Code session on this machine will now stream its steps to your hilos channel."
414
- : "Claude Code sessions in THIS repo will now stream their steps to your hilos channel.",
415
- );
416
- console.log("Kill switch: HILOS_HOOKS=off.");
417
- // The installed entry runs `hilos-agent hook` directly (npx per tool call
418
- // would add cold-start latency) — warn now if that won't resolve.
419
- const onPath = (process.env.PATH || "")
420
- .split(delimiter)
421
- .some((dir) => dir && existsSync(join(dir, "hilos-agent")));
422
- if (!onPath) {
423
- console.log(
424
- "NOTE: hilos-agent isn't on your PATH — the hooks won't fire until you run: npm i -g hilos-agent",
425
- );
1023
+ if (sub !== "install") {
1024
+ console.log("Usage: hilos-agent hooks <install|print> [--global] [--claude|--codex|--cursor]");
1025
+ return;
1026
+ }
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";
1035
+ if (client !== "codex" && client !== "cursor") {
1036
+ installHooksFile({
1037
+ target: isGlobal
1038
+ ? join(homedir(), ".claude", "settings.json")
1039
+ : join(process.cwd(), ".claude", "settings.json"),
1040
+ merge: (settings) => mergeHooksIntoSettings(settings, commands.claude),
1041
+ label: "Claude Code",
1042
+ scope,
1043
+ });
1044
+ }
1045
+ if (client !== "claude" && client !== "cursor") {
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
+ }
1099
+ }
1100
+ if (client !== "claude" && client !== "codex") {
1101
+ installHooksFile({
1102
+ target: isGlobal
1103
+ ? join(homedir(), ".cursor", "hooks.json")
1104
+ : join(process.cwd(), ".cursor", "hooks.json"),
1105
+ merge: (settings) => mergeCursorHooksIntoSettings(settings, commands.cursor),
1106
+ label: "Cursor",
1107
+ scope,
1108
+ });
426
1109
  }
1110
+ console.log("Kill switches: HILOS_HOOKS=off pauses streaming; HILOS_REPLY_BRIDGE=off pauses reply pickup.");
1111
+ console.log(`Private hook runtime: ${runtime}. No global hilos-agent install is required.`);
427
1112
  }