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/README.md +80 -16
- package/bin/hilos-agent.mjs +16 -6
- package/package.json +1 -1
- package/src/acp-session.mjs +69 -54
- package/src/agent-events.mjs +645 -45
- package/src/argv.mjs +61 -0
- package/src/attachments.mjs +310 -0
- package/src/claude-permissions.mjs +445 -0
- package/src/cli.mjs +56 -0
- package/src/codex-mcp-session.mjs +619 -0
- package/src/config.mjs +83 -7
- package/src/handler.mjs +914 -77
- package/src/hook.mjs +793 -108
- package/src/mcp-loopback.mjs +142 -0
- package/src/mcp.mjs +3 -2
- package/src/model-resolve.mjs +180 -11
- package/src/permission-gate.mjs +269 -0
- package/src/progress-emitter.mjs +100 -5
- package/src/queue.mjs +21 -5
- package/src/redact.mjs +11 -1
- package/src/reply-bridge.mjs +847 -0
- package/src/resume.mjs +48 -11
- package/src/run.mjs +130 -4
- package/src/transcript.mjs +153 -0
package/src/hook.mjs
CHANGED
|
@@ -1,29 +1,34 @@
|
|
|
1
|
-
//
|
|
2
|
-
// agent's hands move" path for a
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
|
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:
|
|
15
|
-
//
|
|
16
|
-
//
|
|
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
|
|
23
|
-
import {
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
|
47
|
-
const
|
|
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:
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 ? `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
if (
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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)
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
912
|
+
const hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {};
|
|
364
913
|
let changed = false;
|
|
365
|
-
for (const [event,
|
|
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
|
|
368
|
-
|
|
369
|
-
)
|
|
370
|
-
|
|
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
|
|
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 } =
|
|
968
|
+
const { settings, changed } = merge(current);
|
|
403
969
|
if (!changed) {
|
|
404
|
-
console.log(
|
|
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 (
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
}
|