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/README.md +74 -15
- package/bin/hilos-agent.mjs +14 -6
- package/package.json +1 -1
- package/src/acp-session.mjs +553 -0
- package/src/agent-events.mjs +764 -45
- 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 +84 -0
- package/src/daemon.mjs +23 -0
- package/src/handler.mjs +1210 -100
- package/src/hook.mjs +408 -91
- package/src/mcp.mjs +1 -0
- package/src/model-resolve.mjs +180 -11
- package/src/permission-gate.mjs +269 -0
- package/src/progress-emitter.mjs +105 -3
- package/src/queue.mjs +21 -5
- package/src/redact.mjs +11 -1
- package/src/reply-bridge.mjs +484 -0
- package/src/resume.mjs +48 -11
- package/src/run.mjs +135 -4
- package/src/thread-pr.mjs +305 -0
- package/src/transcript.mjs +153 -0
package/src/hook.mjs
CHANGED
|
@@ -1,26 +1,27 @@
|
|
|
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: `hooks install` writes
|
|
15
|
-
//
|
|
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
|
-
*
|
|
39
|
-
*
|
|
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
|
|
47
|
-
const
|
|
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:
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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 ? `
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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);
|
|
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)
|
|
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
|
-
|
|
282
|
-
if (!state) {
|
|
476
|
+
if (!state.startedAt) {
|
|
283
477
|
gcStateDir(stateDir, now()); // new session — a cheap moment to sweep old ones
|
|
284
|
-
state =
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 } =
|
|
668
|
+
const { settings, changed } = merge(current);
|
|
403
669
|
if (!changed) {
|
|
404
|
-
console.log(
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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 || "")
|