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/README.md +34 -14
- package/bin/hilos-agent.mjs +3 -1
- package/package.json +1 -1
- package/src/argv.mjs +61 -0
- package/src/hook.mjs +429 -61
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +2 -2
- package/src/progress-emitter.mjs +5 -2
- package/src/reply-bridge.mjs +412 -49
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:
|
|
15
|
-
// Codex
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
|
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
|
|
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
|
|
576
|
-
const sessionEnd = { hooks: [{ type: "command", command
|
|
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
|
|
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
|
|
612
|
-
//
|
|
613
|
-
if (
|
|
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(),
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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 (
|
|
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
|
|
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
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
scope
|
|
720
|
-
|
|
721
|
-
|
|
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
|
-
|
|
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
|
}
|