hilos-agent 0.7.0 → 0.9.1

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,26 +1,27 @@
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: `hooks install` writes project-local
15
+ // Codex, Claude, and Cursor hook files, so only repos you opt in ever stream.
16
+ // HILOS_HOOKS=off is the global kill switch.
17
17
  // - Pure helpers (event parsing, step labels, throttling decisions) are
18
18
  // exported for offline unit tests; I/O lives only in runHook/main.
19
19
 
20
- import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from "node:fs";
20
+ import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync, chmodSync } from "node:fs";
21
+ import { createHash } from "node:crypto";
21
22
  import { homedir } from "node:os";
22
23
  import { join, dirname, delimiter } from "node:path";
23
- import { sanitizeText } from "./agent-events.mjs";
24
+ import { sanitizeText, webTarget } from "./agent-events.mjs";
24
25
  import { resolveConfig } from "./config.mjs";
25
26
 
26
27
  export const HOOK_STATE_DIR = join(homedir(), ".hilos", "hook-state");
@@ -35,29 +36,183 @@ const MAX_FILES = 20;
35
36
  const STATE_TTL_MS = 48 * 60 * 60 * 1000;
36
37
 
37
38
  /**
38
- * Parse a Claude Code hook event from stdin text. Returns a normalized
39
- * { event, sessionId, toolName, toolInput, cwd } or null when unusable.
39
+ * Scope local hook state to one hilos agent credential without ever writing the
40
+ * bearer token itself to disk. Two agent identities can share a machine, so a
41
+ * global hook-state directory alone is not an authority boundary.
42
+ */
43
+ export function hookConnectionKey(token) {
44
+ const value = typeof token === "string" ? token.trim() : "";
45
+ return value ? createHash("sha256").update(value).digest("hex").slice(0, 24) : "";
46
+ }
47
+
48
+ /**
49
+ * Parse a Codex / Claude Code / Cursor hook event from stdin text. Returns a
50
+ * normalized event or null when unusable. The providers share common fields;
51
+ * toolResponse is additive for PostToolUse and ignored by the progress lane.
40
52
  * PURE — never throws.
41
53
  */
42
54
  export function parseHookEvent(raw) {
43
55
  try {
44
56
  const o = JSON.parse(String(raw));
45
57
  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 : "";
58
+ const rawEvent = typeof o.hook_event_name === "string" ? o.hook_event_name : "";
59
+ const event = ({
60
+ sessionStart: "SessionStart",
61
+ beforeSubmitPrompt: "UserPromptSubmit",
62
+ postToolUse: "PostToolUse",
63
+ afterMCPExecution: "PostToolUse",
64
+ stop: "Stop",
65
+ sessionEnd: "SessionEnd",
66
+ })[rawEvent] || rawEvent;
67
+ const cursorEvent = Object.hasOwn({
68
+ sessionStart: true,
69
+ beforeSubmitPrompt: true,
70
+ postToolUse: true,
71
+ afterMCPExecution: true,
72
+ stop: true,
73
+ sessionEnd: true,
74
+ }, rawEvent);
75
+ // Cursor's resumable chat id is conversation_id. Some lifecycle payloads
76
+ // also carry a hook/session id; preferring that would write the anchor under
77
+ // an identifier `cursor-agent --resume` does not accept.
78
+ const sessionId = cursorEvent && typeof o.conversation_id === "string"
79
+ ? o.conversation_id
80
+ : typeof o.session_id === "string"
81
+ ? o.session_id
82
+ : typeof o.conversation_id === "string"
83
+ ? o.conversation_id
84
+ : "";
48
85
  if (!event || !sessionId) return null;
86
+ const jsonObject = (value) => {
87
+ if (value && typeof value === "object") return value;
88
+ if (typeof value !== "string") return {};
89
+ try {
90
+ const parsed = JSON.parse(value);
91
+ return parsed && typeof parsed === "object" ? parsed : {};
92
+ } catch {
93
+ return {};
94
+ }
95
+ };
96
+ const cursorMcp = rawEvent === "afterMCPExecution";
97
+ const server = typeof o.mcp_server_name === "string" ? o.mcp_server_name : "";
98
+ const rawToolName = typeof o.tool_name === "string" ? o.tool_name : "";
49
99
  return {
50
100
  event,
51
101
  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 : "",
102
+ toolName: cursorMcp && server && rawToolName
103
+ ? `mcp__${server}__${rawToolName}`
104
+ : rawToolName,
105
+ toolInput: jsonObject(o.tool_input),
106
+ ...(o.tool_response !== undefined
107
+ ? { toolResponse: o.tool_response }
108
+ : o.tool_output !== undefined
109
+ ? { toolResponse: o.tool_output }
110
+ : o.result_json !== undefined
111
+ ? { toolResponse: o.result_json }
112
+ : {}),
113
+ cwd: typeof o.cwd === "string"
114
+ ? o.cwd
115
+ : Array.isArray(o.workspace_roots) && typeof o.workspace_roots[0] === "string"
116
+ ? o.workspace_roots[0]
117
+ : "",
55
118
  };
56
119
  } catch {
57
120
  return null;
58
121
  }
59
122
  }
60
123
 
124
+ /** A JSON-looking MCP text block, direct payload, or nested tool result. */
125
+ export function mcpResponsePayload(value, depth = 0) {
126
+ if (depth > 5 || value == null) return null;
127
+ if (typeof value === "string") {
128
+ try {
129
+ return mcpResponsePayload(JSON.parse(value), depth + 1);
130
+ } catch {
131
+ return null;
132
+ }
133
+ }
134
+ if (Array.isArray(value)) {
135
+ for (const item of value) {
136
+ const found = mcpResponsePayload(item, depth + 1);
137
+ if (found) return found;
138
+ }
139
+ return null;
140
+ }
141
+ if (typeof value !== "object") return null;
142
+ if (typeof value.messageId === "string") return value;
143
+ if (typeof value.text === "string") {
144
+ const parsed = mcpResponsePayload(value.text, depth + 1);
145
+ if (parsed) return parsed;
146
+ }
147
+ for (const key of ["structuredContent", "content", "result", "output"]) {
148
+ const found = mcpResponsePayload(value[key], depth + 1);
149
+ if (found) return found;
150
+ }
151
+ return null;
152
+ }
153
+
154
+ /**
155
+ * A successful hilos post made by this local session becomes its reply anchor.
156
+ * Match the hilos MCP server segment as well as the tool suffix so another MCP
157
+ * server's coincidentally named post_message can never bind a hilos session.
158
+ */
159
+ export function bindingFromHookEvent(ev, at = new Date().toISOString()) {
160
+ if (!ev || ev.event !== "PostToolUse") return null;
161
+ const match = String(ev.toolName || "").match(/^mcp__(.+?)__(post_message|post_report)$/i);
162
+ if (!match || !/hilos/i.test(match[1])) return null;
163
+ const channelId = typeof ev.toolInput?.channelId === "string" ? ev.toolInput.channelId : "";
164
+ if (!channelId) return null;
165
+ const payload = mcpResponsePayload(ev.toolResponse);
166
+ const messageId = typeof payload?.messageId === "string" ? payload.messageId : "";
167
+ const agentId = typeof payload?.agentId === "string" ? payload.agentId : "";
168
+ // The server-authenticated author is part of the result. Requiring it keeps a
169
+ // differently configured hilos MCP connection from being resumed and posted
170
+ // through this daemon's identity merely because both can access the room.
171
+ if (!messageId || !agentId) return null;
172
+ const parentId = typeof ev.toolInput?.parentId === "string" ? ev.toolInput.parentId : "";
173
+ return {
174
+ threadRootId: parentId || messageId,
175
+ anchorMessageId: messageId,
176
+ channelId,
177
+ agentId,
178
+ boundAt: at,
179
+ processedReplyIds: [],
180
+ };
181
+ }
182
+
183
+ /** Keep the newest anchor for a thread and preserve already-processed replies. */
184
+ export function upsertBinding(state, binding, limit = 8) {
185
+ if (!state || !binding?.threadRootId || !binding?.anchorMessageId) return state;
186
+ const current = Array.isArray(state.bindings) ? state.bindings : [];
187
+ const prior = current.find((item) => item?.threadRootId === binding.threadRootId);
188
+ const next = {
189
+ ...binding,
190
+ processedReplyIds: Array.isArray(prior?.processedReplyIds)
191
+ ? prior.processedReplyIds.slice(-100)
192
+ : Array.isArray(binding.processedReplyIds)
193
+ ? binding.processedReplyIds.slice(-100)
194
+ : [],
195
+ };
196
+ state.bindings = [
197
+ ...current.filter((item) => item?.threadRootId !== binding.threadRootId),
198
+ next,
199
+ ].slice(-limit);
200
+ return state;
201
+ }
202
+
203
+ function sessionState(state, ev, vendor, now, token) {
204
+ const out = state && typeof state === "object"
205
+ ? state
206
+ : { startedAt: now(), lastSentAt: 0, steps: [], files: [], messageId: null };
207
+ out.sessionId = ev.sessionId;
208
+ out.vendor = vendor || out.vendor || "unknown";
209
+ out.cwd = ev.cwd || out.cwd || "";
210
+ out.connectionKey = hookConnectionKey(token);
211
+ out.updatedAt = new Date(now()).toISOString();
212
+ if (!Array.isArray(out.bindings)) out.bindings = [];
213
+ return out;
214
+ }
215
+
61
216
  /** Strip the session cwd prefix so step labels read as repo-relative paths. */
62
217
  function relPath(p, cwd) {
63
218
  const s = String(p || "");
@@ -97,7 +252,8 @@ export function hookStep(toolName, toolInput, cwd = "") {
97
252
  return file ? { label: `Editing ${file}`, file } : { label: "Editing files" };
98
253
  case "Read":
99
254
  return file ? { label: `Reading ${file}`, file } : { label: "Reading files" };
100
- case "Bash": {
255
+ case "Bash":
256
+ case "Shell": {
101
257
  const c = cmdLabel(input.command);
102
258
  return { label: c ? `Running ${c}` : "Running a command" };
103
259
  }
@@ -106,22 +262,26 @@ export function hookStep(toolName, toolInput, cwd = "") {
106
262
  const q = typeof input.pattern === "string" ? input.pattern : "";
107
263
  return { label: q ? `Searching for ${q.slice(0, 60)}` : "Searching the repo" };
108
264
  }
265
+ // `Agent` is what Claude Code 2.1.233 calls the subagent tool; `Task` is the
266
+ // older name. Before 0789 only `Task` was here, so a spawn on a current
267
+ // build fell through to the default arm and narrated "Using Agent".
268
+ case "Agent":
109
269
  case "Task": {
110
270
  const d = typeof input.description === "string" ? input.description : "";
111
- return { label: d ? `Delegating: ${d.slice(0, 60)}` : "Delegating a task" };
271
+ // Same sentence the stream mappers emit (0789) — one room, one wording,
272
+ // whichever lane the run came through.
273
+ return { label: d ? `Started a helper: ${d.slice(0, 60)}` : "Started a helper" };
112
274
  }
113
275
  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" };
276
+ // webTarget (0789) keeps host + PATH and drops the query string, the
277
+ // fragment, and userinfo, so a signed URL's token can't ride a hook step
278
+ // into the room.
279
+ const target = webTarget(input.url);
280
+ return { label: target ? `Read ${target}` : "Read a web page" };
121
281
  }
122
282
  case "WebSearch": {
123
283
  const q = typeof input.query === "string" ? input.query : "";
124
- return { label: q ? `Searching the web: ${q.slice(0, 60)}` : "Searching the web" };
284
+ return { label: q ? `Searched the web: ${q.slice(0, 60)}` : "Searched the web" };
125
285
  }
126
286
  case "TodoWrite":
127
287
  return null; // planning chatter — too noisy to narrate
@@ -180,8 +340,11 @@ export function readState(sessionId, dir = HOOK_STATE_DIR) {
180
340
 
181
341
  export function writeState(sessionId, state, dir = HOOK_STATE_DIR) {
182
342
  try {
183
- mkdirSync(dir, { recursive: true });
184
- writeFileSync(statePath(sessionId, dir), JSON.stringify(state));
343
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
344
+ chmodSync(dir, 0o700);
345
+ const path = statePath(sessionId, dir);
346
+ writeFileSync(path, JSON.stringify(state), { mode: 0o600 });
347
+ chmodSync(path, 0o600);
185
348
  } catch {
186
349
  /* best-effort */
187
350
  }
@@ -211,7 +374,7 @@ export function gcStateDir(dir = HOOK_STATE_DIR, now = Date.now()) {
211
374
  }
212
375
  }
213
376
 
214
- /** One bounded post_progress call. Returns the server's messageId, or null. */
377
+ /** One bounded post_progress call. Returns its authenticated anchor, or null. */
215
378
  async function sendProgress({ url, token, messageId, channelId, progress }) {
216
379
  const controller = new AbortController();
217
380
  const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
@@ -232,7 +395,15 @@ async function sendProgress({ url, token, messageId, channelId, progress }) {
232
395
  const json = await res.json();
233
396
  const text = json?.result?.content?.[0]?.text;
234
397
  const parsed = typeof text === "string" ? JSON.parse(text) : null;
235
- return parsed && typeof parsed.messageId === "string" ? parsed.messageId : null;
398
+ // Older hilos servers do not return agentId yet. Keep progress-card reuse
399
+ // backward compatible, but refuse to turn that unauthenticated result into
400
+ // a reply binding below.
401
+ return parsed && typeof parsed.messageId === "string"
402
+ ? {
403
+ messageId: parsed.messageId,
404
+ agentId: typeof parsed.agentId === "string" ? parsed.agentId : "",
405
+ }
406
+ : null;
236
407
  } catch {
237
408
  return null;
238
409
  } finally {
@@ -244,47 +415,77 @@ async function sendProgress({ url, token, messageId, channelId, progress }) {
244
415
  * Handle one hook invocation end to end. Reads nothing from process.* so the
245
416
  * caller (bin) owns stdin/env; returns quietly on every failure path.
246
417
  */
247
- export async function runHook({ raw, cfg, now = Date.now, stateDir = HOOK_STATE_DIR }) {
418
+ export async function runHook({ raw, cfg, vendor = "unknown", now = Date.now, stateDir = HOOK_STATE_DIR }) {
248
419
  const ev = parseHookEvent(raw);
249
420
  if (!ev) return;
250
421
  const { url, token, channelId } = cfg;
251
- if (!url || !token || !channelId) return; // not connected to hilos — no-op
422
+ if (!url || !token) return; // not connected to hilos — no-op
423
+
424
+ let state = sessionState(readState(ev.sessionId, stateDir), ev, vendor, now, token);
425
+ const binding = bindingFromHookEvent(ev, new Date(now()).toISOString());
426
+ if (binding) upsertBinding(state, binding);
427
+
428
+ if (ev.event === "SessionStart") {
429
+ state.active = false;
430
+ writeState(ev.sessionId, state, stateDir);
431
+ return;
432
+ }
433
+
434
+ if (ev.event === "UserPromptSubmit") {
435
+ state.active = true;
436
+ writeState(ev.sessionId, state, stateDir);
437
+ return;
438
+ }
252
439
 
253
440
  if (ev.event === "Stop" || ev.event === "SessionEnd") {
254
441
  // End of a turn (or the session): settle the card honestly — "not doing
255
442
  // anything right now". The next tool call revives the SAME card to working
256
443
  // 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);
444
+ state.active = false;
445
+ if (state.messageId) {
446
+ await sendProgress({
447
+ url,
448
+ token,
449
+ messageId: state.messageId,
450
+ progress: {
451
+ state: "done",
452
+ elapsedMs: Math.max(0, now() - (state.startedAt || now())),
453
+ },
454
+ });
272
455
  }
456
+ // A provider SessionEnd does not make its id unresumable. Keep the local
457
+ // binding until the normal 48h GC so a reply received after the terminal
458
+ // window closes can still continue it.
459
+ state.lastSentAt = now();
460
+ writeState(ev.sessionId, state, stateDir);
273
461
  return;
274
462
  }
275
463
 
276
464
  if (ev.event !== "PostToolUse") return;
277
465
 
466
+ state.active = true;
467
+
278
468
  const step = hookStep(ev.toolName, ev.toolInput, ev.cwd);
279
- if (!step) return;
469
+ if (!step) {
470
+ // hilos MCP calls are deliberately not narrated as progress, but their
471
+ // returned message id is the most important state this hook records.
472
+ writeState(ev.sessionId, state, stateDir);
473
+ return;
474
+ }
280
475
 
281
- let state = readState(ev.sessionId, stateDir);
282
- if (!state) {
476
+ if (!state.startedAt) {
283
477
  gcStateDir(stateDir, now()); // new session — a cheap moment to sweep old ones
284
- state = { startedAt: now(), lastSentAt: 0, steps: [], files: [], messageId: null };
478
+ state.startedAt = now();
285
479
  }
286
480
  foldStep(state, step);
287
481
 
482
+ // A config without a default channel can still bind explicit hilos MCP posts
483
+ // in any accessible room; it simply has nowhere to create an ambient card.
484
+ if (!channelId && !state.messageId) {
485
+ writeState(ev.sessionId, state, stateDir);
486
+ return;
487
+ }
488
+
288
489
  if (!shouldSendNow(state, now())) {
289
490
  // Inside the coalesce window: fold only. The next event (or Stop) flushes.
290
491
  writeState(ev.sessionId, state, stateDir);
@@ -298,19 +499,31 @@ export async function runHook({ raw, cfg, now = Date.now, stateDir = HOOK_STATE_
298
499
  filesTouched: state.files,
299
500
  elapsedMs: Math.max(0, now() - (state.startedAt || now())),
300
501
  };
301
- const messageId = await sendProgress({
502
+ const anchor = await sendProgress({
302
503
  url,
303
504
  token,
304
505
  messageId: state.messageId || undefined,
305
506
  channelId,
306
507
  progress,
307
508
  });
308
- if (messageId) state.messageId = messageId;
509
+ if (anchor) {
510
+ state.messageId = anchor.messageId;
511
+ if (channelId && anchor.agentId) {
512
+ upsertBinding(state, {
513
+ threadRootId: anchor.messageId,
514
+ anchorMessageId: anchor.messageId,
515
+ channelId,
516
+ agentId: anchor.agentId,
517
+ boundAt: new Date(now()).toISOString(),
518
+ processedReplyIds: [],
519
+ });
520
+ }
521
+ }
309
522
  state.lastSentAt = now();
310
523
  writeState(ev.sessionId, state, stateDir);
311
524
  }
312
525
 
313
- /** Read all of stdin (the hook event JSON Claude Code pipes in). */
526
+ /** Read all of stdin (the hook event JSON the coding tool pipes in). */
314
527
  function readStdin() {
315
528
  return new Promise((resolve) => {
316
529
  let data = "";
@@ -327,13 +540,13 @@ function readStdin() {
327
540
  });
328
541
  }
329
542
 
330
- /** `hilos-agent hook` — the command Claude Code invokes. Always exits 0. */
331
- export async function hookMain() {
543
+ /** `hilos-agent hook` — the command each provider invokes. Always exits 0. */
544
+ export async function hookMain({ vendor = "unknown" } = {}) {
332
545
  try {
333
546
  if (/^(off|0|false)$/i.test(process.env.HILOS_HOOKS || "")) return;
334
547
  const raw = await readStdin();
335
548
  const cfg = resolveConfig({});
336
- await runHook({ raw, cfg });
549
+ await runHook({ raw, cfg, vendor });
337
550
  } catch {
338
551
  /* a hook must never fail the user's CLI */
339
552
  }
@@ -341,33 +554,98 @@ export async function hookMain() {
341
554
 
342
555
  // --- `hilos-agent hooks print|install [--global]` ---------------------------
343
556
 
344
- const HOOK_COMMAND = "hilos-agent hook";
557
+ const CLAUDE_HOOK_COMMAND = "hilos-agent hook --vendor claude_code";
558
+ const CODEX_HOOK_COMMAND = "hilos-agent hook --vendor codex";
559
+ const CURSOR_HOOK_COMMAND = "hilos-agent hook --vendor cursor";
345
560
 
346
561
  /** The hooks block hilos needs inside a Claude Code settings.json. */
347
562
  export function hilosHooksBlock() {
348
- const entry = { hooks: [{ type: "command", command: HOOK_COMMAND }] };
563
+ const entry = { hooks: [{ type: "command", command: CLAUDE_HOOK_COMMAND }] };
349
564
  return {
565
+ SessionStart: [{ matcher: "startup|resume|clear|compact", ...entry }],
566
+ UserPromptSubmit: [entry],
350
567
  PostToolUse: [{ matcher: "*", ...entry }],
351
568
  Stop: [entry],
352
569
  SessionEnd: [entry],
353
570
  };
354
571
  }
355
572
 
573
+ /** 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 }] };
577
+ return {
578
+ SessionStart: [{ matcher: "startup|resume|clear|compact", ...entry }],
579
+ UserPromptSubmit: [entry],
580
+ PostToolUse: [{ matcher: "*", ...entry }],
581
+ Stop: [entry],
582
+ SessionEnd: [sessionEnd],
583
+ };
584
+ }
585
+
586
+ /** 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 };
589
+ return {
590
+ sessionStart: [entry],
591
+ beforeSubmitPrompt: [entry],
592
+ postToolUse: [entry],
593
+ // This event carries mcp_server_name + the full result; postToolUse alone
594
+ // has no server identity on Cursor 2026.07.23.
595
+ afterMCPExecution: [entry],
596
+ stop: [entry],
597
+ sessionEnd: [entry],
598
+ };
599
+ }
600
+
601
+ function mergeHookBlock(settings, block, command) {
602
+ const out = settings && typeof settings === "object" ? settings : {};
603
+ const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
604
+ let changed = false;
605
+ for (const [event, entries] of Object.entries(block)) {
606
+ const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
607
+ let found = false;
608
+ for (const group of existing) {
609
+ for (const handler of group?.hooks || []) {
610
+ 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") {
614
+ handler.command = command;
615
+ found = true;
616
+ changed = true;
617
+ }
618
+ }
619
+ }
620
+ if (!found) {
621
+ hooks[event] = [...existing, ...entries];
622
+ changed = true;
623
+ }
624
+ }
625
+ return { settings: out, changed };
626
+ }
627
+
356
628
  /**
357
629
  * Idempotently merge the hilos hooks into a settings object (parsed
358
630
  * settings.json). Existing user hooks are preserved; a second install is a
359
631
  * no-op. PURE — returns { settings, changed }.
360
632
  */
361
633
  export function mergeHooksIntoSettings(settings) {
634
+ return mergeHookBlock(settings, hilosHooksBlock(), CLAUDE_HOOK_COMMAND);
635
+ }
636
+
637
+ export function mergeCodexHooksIntoSettings(settings) {
638
+ return mergeHookBlock(settings, hilosCodexHooksBlock(), CODEX_HOOK_COMMAND);
639
+ }
640
+
641
+ export function mergeCursorHooksIntoSettings(settings) {
362
642
  const out = settings && typeof settings === "object" ? settings : {};
643
+ let changed = out.version !== 1;
644
+ if (changed) out.version = 1;
363
645
  const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
364
- let changed = false;
365
- for (const [event, entries] of Object.entries(hilosHooksBlock())) {
646
+ for (const [event, entries] of Object.entries(hilosCursorHooksBlock())) {
366
647
  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) {
648
+ if (!existing.some((handler) => handler?.command === CURSOR_HOOK_COMMAND)) {
371
649
  hooks[event] = [...existing, ...entries];
372
650
  changed = true;
373
651
  }
@@ -376,19 +654,7 @@ export function mergeHooksIntoSettings(settings) {
376
654
  }
377
655
 
378
656
  /** `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");
657
+ function installHooksFile({ target, merge, label, scope }) {
392
658
  let current = {};
393
659
  if (existsSync(target)) {
394
660
  try {
@@ -396,24 +662,75 @@ export function hooksMain(sub, { global: isGlobal = false } = {}) {
396
662
  } catch {
397
663
  console.error(`${target} exists but isn't valid JSON — fix it first (nothing written).`);
398
664
  process.exitCode = 1;
399
- return;
665
+ return false;
400
666
  }
401
667
  }
402
- const { settings, changed } = mergeHooksIntoSettings(current);
668
+ const { settings, changed } = merge(current);
403
669
  if (!changed) {
404
- console.log(`hilos hooks already installed in ${target}.`);
405
- return;
670
+ console.log(`${label} hilos hooks already installed in ${target}.`);
671
+ return true;
406
672
  }
407
673
  if (existsSync(target)) writeFileSync(target + ".bak", readFileSync(target));
408
674
  mkdirSync(dirname(target), { recursive: true });
409
675
  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.");
676
+ console.log(`Installed ${label} hilos hooks into ${target}${existsSync(target + ".bak") ? ` (backup: ${target}.bak)` : ""}.`);
677
+ console.log(`${scope} ${label} sessions can now stream to hilos and bind replies to the same local session.`);
678
+ return true;
679
+ }
680
+
681
+ export function hooksMain(sub, { global: isGlobal = false, client = "all" } = {}) {
682
+ if (sub === "print") {
683
+ if (client !== "codex" && client !== "cursor") {
684
+ console.log("Claude Code (.claude/settings.json):");
685
+ console.log(JSON.stringify({ hooks: hilosHooksBlock() }, null, 2));
686
+ }
687
+ if (client !== "claude" && client !== "cursor") {
688
+ console.log("\nCodex (.codex/hooks.json):");
689
+ console.log(JSON.stringify({ description: "hilos local session bridge", hooks: hilosCodexHooksBlock() }, null, 2));
690
+ }
691
+ if (client !== "claude" && client !== "codex") {
692
+ console.log("\nCursor (.cursor/hooks.json):");
693
+ console.log(JSON.stringify({ version: 1, hooks: hilosCursorHooksBlock() }, null, 2));
694
+ }
695
+ return;
696
+ }
697
+ if (sub !== "install") {
698
+ console.log("Usage: hilos-agent hooks <install|print> [--global] [--claude|--codex|--cursor]");
699
+ return;
700
+ }
701
+ const scope = isGlobal ? "Every opted-in" : "This repo's";
702
+ if (client !== "codex" && client !== "cursor") {
703
+ installHooksFile({
704
+ target: isGlobal
705
+ ? join(homedir(), ".claude", "settings.json")
706
+ : join(process.cwd(), ".claude", "settings.json"),
707
+ merge: mergeHooksIntoSettings,
708
+ label: "Claude Code",
709
+ scope,
710
+ });
711
+ }
712
+ 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.");
722
+ }
723
+ if (client !== "claude" && client !== "codex") {
724
+ installHooksFile({
725
+ target: isGlobal
726
+ ? join(homedir(), ".cursor", "hooks.json")
727
+ : join(process.cwd(), ".cursor", "hooks.json"),
728
+ merge: mergeCursorHooksIntoSettings,
729
+ label: "Cursor",
730
+ scope,
731
+ });
732
+ }
733
+ console.log("Kill switches: HILOS_HOOKS=off pauses streaming; HILOS_REPLY_BRIDGE=off pauses reply pickup.");
417
734
  // The installed entry runs `hilos-agent hook` directly (npx per tool call
418
735
  // would add cold-start latency) — warn now if that won't resolve.
419
736
  const onPath = (process.env.PATH || "")