hilos-agent 0.1.16 → 0.4.0
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 +104 -0
- package/bin/hilos-agent.mjs +20 -2
- package/package.json +1 -1
- package/src/config.mjs +14 -0
- package/src/daemon.mjs +49 -0
- package/src/handler.mjs +561 -30
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +26 -2
- package/src/run.mjs +117 -11
package/src/hook.mjs
ADDED
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
// Claude Code hook → hilos live progress (0448). This is the "watch your
|
|
2
|
+
// agent's hands move" path for a RAW CLI session (no daemon): install the
|
|
3
|
+
// hilos hooks in a repo (`hilos-agent hooks install`) and every tool call your
|
|
4
|
+
// Claude Code session makes streams a step into the agent's live status card
|
|
5
|
+
// in its hilos channel, via the `post_progress` MCP tool's channelId mode.
|
|
6
|
+
//
|
|
7
|
+
// Design rules:
|
|
8
|
+
// - NEVER block or break the user's CLI. Every entry point swallows every
|
|
9
|
+
// error and exits 0; the network send is capped by a hard timeout.
|
|
10
|
+
// - Cheap by default: a state file per Claude session carries the message id,
|
|
11
|
+
// step ring and last-send time, so most invocations are one small read +
|
|
12
|
+
// at most one bounded fetch. Sends are throttled (default 2s) with pending
|
|
13
|
+
// steps carried over — a burst of tool calls becomes one coalesced update.
|
|
14
|
+
// - Privacy is the install default: `hooks install` writes to the PROJECT's
|
|
15
|
+
// .claude/settings.json, so only repos you opt in ever stream. HILOS_HOOKS=off
|
|
16
|
+
// is the global kill switch.
|
|
17
|
+
// - Pure helpers (event parsing, step labels, throttling decisions) are
|
|
18
|
+
// exported for offline unit tests; I/O lives only in runHook/main.
|
|
19
|
+
|
|
20
|
+
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, unlinkSync, existsSync } from "node:fs";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
import { join, dirname, delimiter } from "node:path";
|
|
23
|
+
import { sanitizeText } from "./agent-events.mjs";
|
|
24
|
+
import { resolveConfig } from "./config.mjs";
|
|
25
|
+
|
|
26
|
+
export const HOOK_STATE_DIR = join(homedir(), ".hilos", "hook-state");
|
|
27
|
+
/** Coalesce window between sends; Stop/SessionEnd always flush. */
|
|
28
|
+
const MIN_SEND_MS = 2000;
|
|
29
|
+
/** A hook must never hang the CLI on a slow network. */
|
|
30
|
+
const FETCH_TIMEOUT_MS = 3000;
|
|
31
|
+
/** Step/file bounds — mirror the server's caps (lib/agent-progress.ts). */
|
|
32
|
+
const MAX_STEPS = 8;
|
|
33
|
+
const MAX_FILES = 20;
|
|
34
|
+
/** Session state older than this is dead — GC'd opportunistically. */
|
|
35
|
+
const STATE_TTL_MS = 48 * 60 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Parse a Claude Code hook event from stdin text. Returns a normalized
|
|
39
|
+
* { event, sessionId, toolName, toolInput, cwd } or null when unusable.
|
|
40
|
+
* PURE — never throws.
|
|
41
|
+
*/
|
|
42
|
+
export function parseHookEvent(raw) {
|
|
43
|
+
try {
|
|
44
|
+
const o = JSON.parse(String(raw));
|
|
45
|
+
if (!o || typeof o !== "object") return null;
|
|
46
|
+
const event = typeof o.hook_event_name === "string" ? o.hook_event_name : "";
|
|
47
|
+
const sessionId = typeof o.session_id === "string" ? o.session_id : "";
|
|
48
|
+
if (!event || !sessionId) return null;
|
|
49
|
+
return {
|
|
50
|
+
event,
|
|
51
|
+
sessionId,
|
|
52
|
+
toolName: typeof o.tool_name === "string" ? o.tool_name : "",
|
|
53
|
+
toolInput: o.tool_input && typeof o.tool_input === "object" ? o.tool_input : {},
|
|
54
|
+
cwd: typeof o.cwd === "string" ? o.cwd : "",
|
|
55
|
+
};
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Strip the session cwd prefix so step labels read as repo-relative paths. */
|
|
62
|
+
function relPath(p, cwd) {
|
|
63
|
+
const s = String(p || "");
|
|
64
|
+
if (cwd && s.startsWith(cwd + "/")) return s.slice(cwd.length + 1);
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** First line of a shell command, trimmed to a readable label. */
|
|
69
|
+
function cmdLabel(cmd) {
|
|
70
|
+
const first = String(cmd || "").split("\n")[0].trim();
|
|
71
|
+
return first.length > 60 ? first.slice(0, 59) + "…" : first;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Map a PostToolUse event to a human step label (and the file it touched, if
|
|
76
|
+
* any), or null for tools too noisy to narrate (TodoWrite etc). PURE.
|
|
77
|
+
* Mirrors the daemon's vocabulary (agent-events stepLabel) so a hook-streamed
|
|
78
|
+
* card and a daemon-streamed card read the same.
|
|
79
|
+
*/
|
|
80
|
+
export function hookStep(toolName, toolInput, cwd = "") {
|
|
81
|
+
const name = String(toolName || "");
|
|
82
|
+
const input = toolInput && typeof toolInput === "object" ? toolInput : {};
|
|
83
|
+
const file = typeof input.file_path === "string" ? relPath(input.file_path, cwd) : "";
|
|
84
|
+
// MCP tools arrive as `mcp__<server>__<tool>`. Skip hilos's own tools — a
|
|
85
|
+
// session posting to hilos narrating "posting to hilos" is self-referential
|
|
86
|
+
// noise — and label other servers by their short tool name.
|
|
87
|
+
const mcp = name.match(/^mcp__([^_]+(?:_[^_]+)*)__(.+)$/);
|
|
88
|
+
if (mcp) {
|
|
89
|
+
if (/hilos/i.test(mcp[1])) return null;
|
|
90
|
+
return { label: `Using ${mcp[2].replace(/_/g, " ")} (${mcp[1]})` };
|
|
91
|
+
}
|
|
92
|
+
switch (name) {
|
|
93
|
+
case "Edit":
|
|
94
|
+
case "Write":
|
|
95
|
+
case "MultiEdit":
|
|
96
|
+
case "NotebookEdit":
|
|
97
|
+
return file ? { label: `Editing ${file}`, file } : { label: "Editing files" };
|
|
98
|
+
case "Read":
|
|
99
|
+
return file ? { label: `Reading ${file}`, file } : { label: "Reading files" };
|
|
100
|
+
case "Bash": {
|
|
101
|
+
const c = cmdLabel(input.command);
|
|
102
|
+
return { label: c ? `Running ${c}` : "Running a command" };
|
|
103
|
+
}
|
|
104
|
+
case "Grep":
|
|
105
|
+
case "Glob": {
|
|
106
|
+
const q = typeof input.pattern === "string" ? input.pattern : "";
|
|
107
|
+
return { label: q ? `Searching for ${q.slice(0, 60)}` : "Searching the repo" };
|
|
108
|
+
}
|
|
109
|
+
case "Task": {
|
|
110
|
+
const d = typeof input.description === "string" ? input.description : "";
|
|
111
|
+
return { label: d ? `Delegating: ${d.slice(0, 60)}` : "Delegating a task" };
|
|
112
|
+
}
|
|
113
|
+
case "WebFetch": {
|
|
114
|
+
let host = "";
|
|
115
|
+
try {
|
|
116
|
+
host = new URL(String(input.url || "")).host;
|
|
117
|
+
} catch {
|
|
118
|
+
/* not a url */
|
|
119
|
+
}
|
|
120
|
+
return { label: host ? `Fetching ${host}` : "Fetching a page" };
|
|
121
|
+
}
|
|
122
|
+
case "WebSearch": {
|
|
123
|
+
const q = typeof input.query === "string" ? input.query : "";
|
|
124
|
+
return { label: q ? `Searching the web: ${q.slice(0, 60)}` : "Searching the web" };
|
|
125
|
+
}
|
|
126
|
+
case "TodoWrite":
|
|
127
|
+
return null; // planning chatter — too noisy to narrate
|
|
128
|
+
default:
|
|
129
|
+
return { label: `Using ${name || "a tool"}` };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Fold a step into the session state: dedupe an immediate repeat (a run of
|
|
135
|
+
* edits to one file stays one step), ring-bound steps, set-bound files. PURE —
|
|
136
|
+
* mutates and returns `state`.
|
|
137
|
+
*/
|
|
138
|
+
export function foldStep(state, step) {
|
|
139
|
+
if (!step) return state;
|
|
140
|
+
const label = sanitizeText(step.label);
|
|
141
|
+
if (!label) return state;
|
|
142
|
+
const steps = Array.isArray(state.steps) ? state.steps : [];
|
|
143
|
+
if (steps[steps.length - 1] !== label) steps.push(label);
|
|
144
|
+
while (steps.length > MAX_STEPS) steps.shift();
|
|
145
|
+
state.steps = steps;
|
|
146
|
+
if (step.file) {
|
|
147
|
+
const file = sanitizeText(step.file);
|
|
148
|
+
const files = Array.isArray(state.files) ? state.files : [];
|
|
149
|
+
if (file && !files.includes(file)) {
|
|
150
|
+
files.push(file);
|
|
151
|
+
while (files.length > MAX_FILES) files.shift();
|
|
152
|
+
}
|
|
153
|
+
state.files = files;
|
|
154
|
+
}
|
|
155
|
+
return state;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Throttle decision: send now, or fold and wait for a later event? PURE. */
|
|
159
|
+
export function shouldSendNow(state, now, minSendMs = MIN_SEND_MS) {
|
|
160
|
+
const last = typeof state.lastSentAt === "number" ? state.lastSentAt : 0;
|
|
161
|
+
return now - last >= minSendMs;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Filename-safe session key (a session_id is a uuid, but never trust input). */
|
|
165
|
+
function sessionKey(sessionId) {
|
|
166
|
+
return String(sessionId).replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 80);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function statePath(sessionId, dir = HOOK_STATE_DIR) {
|
|
170
|
+
return join(dir, sessionKey(sessionId) + ".json");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function readState(sessionId, dir = HOOK_STATE_DIR) {
|
|
174
|
+
try {
|
|
175
|
+
return JSON.parse(readFileSync(statePath(sessionId, dir), "utf8"));
|
|
176
|
+
} catch {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export function writeState(sessionId, state, dir = HOOK_STATE_DIR) {
|
|
182
|
+
try {
|
|
183
|
+
mkdirSync(dir, { recursive: true });
|
|
184
|
+
writeFileSync(statePath(sessionId, dir), JSON.stringify(state));
|
|
185
|
+
} catch {
|
|
186
|
+
/* best-effort */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function deleteState(sessionId, dir = HOOK_STATE_DIR) {
|
|
191
|
+
try {
|
|
192
|
+
unlinkSync(statePath(sessionId, dir));
|
|
193
|
+
} catch {
|
|
194
|
+
/* already gone */
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Drop state files from long-dead sessions. Best-effort, bounded, silent. */
|
|
199
|
+
export function gcStateDir(dir = HOOK_STATE_DIR, now = Date.now()) {
|
|
200
|
+
try {
|
|
201
|
+
for (const f of readdirSync(dir).slice(0, 200)) {
|
|
202
|
+
const p = join(dir, f);
|
|
203
|
+
try {
|
|
204
|
+
if (now - statSync(p).mtimeMs > STATE_TTL_MS) unlinkSync(p);
|
|
205
|
+
} catch {
|
|
206
|
+
/* skip */
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
/* no dir yet */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** One bounded post_progress call. Returns the server's messageId, or null. */
|
|
215
|
+
async function sendProgress({ url, token, messageId, channelId, progress }) {
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
218
|
+
try {
|
|
219
|
+
const args = messageId ? { messageId, progress } : { channelId, progress };
|
|
220
|
+
const res = await fetch(url, {
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
223
|
+
body: JSON.stringify({
|
|
224
|
+
jsonrpc: "2.0",
|
|
225
|
+
id: 1,
|
|
226
|
+
method: "tools/call",
|
|
227
|
+
params: { name: "post_progress", arguments: args },
|
|
228
|
+
}),
|
|
229
|
+
signal: controller.signal,
|
|
230
|
+
});
|
|
231
|
+
if (!res.ok) return null;
|
|
232
|
+
const json = await res.json();
|
|
233
|
+
const text = json?.result?.content?.[0]?.text;
|
|
234
|
+
const parsed = typeof text === "string" ? JSON.parse(text) : null;
|
|
235
|
+
return parsed && typeof parsed.messageId === "string" ? parsed.messageId : null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
} finally {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Handle one hook invocation end to end. Reads nothing from process.* so the
|
|
245
|
+
* caller (bin) owns stdin/env; returns quietly on every failure path.
|
|
246
|
+
*/
|
|
247
|
+
export async function runHook({ raw, cfg, now = Date.now, stateDir = HOOK_STATE_DIR }) {
|
|
248
|
+
const ev = parseHookEvent(raw);
|
|
249
|
+
if (!ev) return;
|
|
250
|
+
const { url, token, channelId } = cfg;
|
|
251
|
+
if (!url || !token || !channelId) return; // not connected to hilos — no-op
|
|
252
|
+
|
|
253
|
+
if (ev.event === "Stop" || ev.event === "SessionEnd") {
|
|
254
|
+
// End of a turn (or the session): settle the card honestly — "not doing
|
|
255
|
+
// anything right now". The next tool call revives the SAME card to working
|
|
256
|
+
// via its stored messageId, so a session stays one card, not one per turn.
|
|
257
|
+
const state = readState(ev.sessionId, stateDir);
|
|
258
|
+
if (!state || !state.messageId) return;
|
|
259
|
+
await sendProgress({
|
|
260
|
+
url,
|
|
261
|
+
token,
|
|
262
|
+
messageId: state.messageId,
|
|
263
|
+
progress: {
|
|
264
|
+
state: "done",
|
|
265
|
+
elapsedMs: Math.max(0, now() - (state.startedAt || now())),
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
if (ev.event === "SessionEnd") deleteState(ev.sessionId, stateDir);
|
|
269
|
+
else {
|
|
270
|
+
state.lastSentAt = now();
|
|
271
|
+
writeState(ev.sessionId, state, stateDir);
|
|
272
|
+
}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (ev.event !== "PostToolUse") return;
|
|
277
|
+
|
|
278
|
+
const step = hookStep(ev.toolName, ev.toolInput, ev.cwd);
|
|
279
|
+
if (!step) return;
|
|
280
|
+
|
|
281
|
+
let state = readState(ev.sessionId, stateDir);
|
|
282
|
+
if (!state) {
|
|
283
|
+
gcStateDir(stateDir, now()); // new session — a cheap moment to sweep old ones
|
|
284
|
+
state = { startedAt: now(), lastSentAt: 0, steps: [], files: [], messageId: null };
|
|
285
|
+
}
|
|
286
|
+
foldStep(state, step);
|
|
287
|
+
|
|
288
|
+
if (!shouldSendNow(state, now())) {
|
|
289
|
+
// Inside the coalesce window: fold only. The next event (or Stop) flushes.
|
|
290
|
+
writeState(ev.sessionId, state, stateDir);
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const progress = {
|
|
295
|
+
state: "working",
|
|
296
|
+
step: state.steps[state.steps.length - 1],
|
|
297
|
+
steps: state.steps,
|
|
298
|
+
filesTouched: state.files,
|
|
299
|
+
elapsedMs: Math.max(0, now() - (state.startedAt || now())),
|
|
300
|
+
};
|
|
301
|
+
const messageId = await sendProgress({
|
|
302
|
+
url,
|
|
303
|
+
token,
|
|
304
|
+
messageId: state.messageId || undefined,
|
|
305
|
+
channelId,
|
|
306
|
+
progress,
|
|
307
|
+
});
|
|
308
|
+
if (messageId) state.messageId = messageId;
|
|
309
|
+
state.lastSentAt = now();
|
|
310
|
+
writeState(ev.sessionId, state, stateDir);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Read all of stdin (the hook event JSON Claude Code pipes in). */
|
|
314
|
+
function readStdin() {
|
|
315
|
+
return new Promise((resolve) => {
|
|
316
|
+
let data = "";
|
|
317
|
+
const timer = setTimeout(() => resolve(data), 2000); // stdin must never hang the hook
|
|
318
|
+
process.stdin.on("data", (c) => (data += c));
|
|
319
|
+
process.stdin.on("end", () => {
|
|
320
|
+
clearTimeout(timer);
|
|
321
|
+
resolve(data);
|
|
322
|
+
});
|
|
323
|
+
process.stdin.on("error", () => {
|
|
324
|
+
clearTimeout(timer);
|
|
325
|
+
resolve(data);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** `hilos-agent hook` — the command Claude Code invokes. Always exits 0. */
|
|
331
|
+
export async function hookMain() {
|
|
332
|
+
try {
|
|
333
|
+
if (/^(off|0|false)$/i.test(process.env.HILOS_HOOKS || "")) return;
|
|
334
|
+
const raw = await readStdin();
|
|
335
|
+
const cfg = resolveConfig({});
|
|
336
|
+
await runHook({ raw, cfg });
|
|
337
|
+
} catch {
|
|
338
|
+
/* a hook must never fail the user's CLI */
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// --- `hilos-agent hooks print|install [--global]` ---------------------------
|
|
343
|
+
|
|
344
|
+
const HOOK_COMMAND = "hilos-agent hook";
|
|
345
|
+
|
|
346
|
+
/** The hooks block hilos needs inside a Claude Code settings.json. */
|
|
347
|
+
export function hilosHooksBlock() {
|
|
348
|
+
const entry = { hooks: [{ type: "command", command: HOOK_COMMAND }] };
|
|
349
|
+
return {
|
|
350
|
+
PostToolUse: [{ matcher: "*", ...entry }],
|
|
351
|
+
Stop: [entry],
|
|
352
|
+
SessionEnd: [entry],
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Idempotently merge the hilos hooks into a settings object (parsed
|
|
358
|
+
* settings.json). Existing user hooks are preserved; a second install is a
|
|
359
|
+
* no-op. PURE — returns { settings, changed }.
|
|
360
|
+
*/
|
|
361
|
+
export function mergeHooksIntoSettings(settings) {
|
|
362
|
+
const out = settings && typeof settings === "object" ? settings : {};
|
|
363
|
+
const hooks = (out.hooks = out.hooks && typeof out.hooks === "object" ? out.hooks : {});
|
|
364
|
+
let changed = false;
|
|
365
|
+
for (const [event, entries] of Object.entries(hilosHooksBlock())) {
|
|
366
|
+
const existing = Array.isArray(hooks[event]) ? hooks[event] : [];
|
|
367
|
+
const already = existing.some((e) =>
|
|
368
|
+
(e?.hooks || []).some((h) => h?.command === HOOK_COMMAND),
|
|
369
|
+
);
|
|
370
|
+
if (!already) {
|
|
371
|
+
hooks[event] = [...existing, ...entries];
|
|
372
|
+
changed = true;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return { settings: out, changed };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** `hilos-agent hooks install [--global]` / `hilos-agent hooks print`. */
|
|
379
|
+
export function hooksMain(sub, { global: isGlobal = false } = {}) {
|
|
380
|
+
if (sub === "print") {
|
|
381
|
+
console.log(JSON.stringify({ hooks: hilosHooksBlock() }, null, 2));
|
|
382
|
+
console.log("\nMerge this into .claude/settings.json in the repo you want to stream.");
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (sub !== "install") {
|
|
386
|
+
console.log("Usage: hilos-agent hooks <install|print> [--global]");
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
const target = isGlobal
|
|
390
|
+
? join(homedir(), ".claude", "settings.json")
|
|
391
|
+
: join(process.cwd(), ".claude", "settings.json");
|
|
392
|
+
let current = {};
|
|
393
|
+
if (existsSync(target)) {
|
|
394
|
+
try {
|
|
395
|
+
current = JSON.parse(readFileSync(target, "utf8"));
|
|
396
|
+
} catch {
|
|
397
|
+
console.error(`${target} exists but isn't valid JSON — fix it first (nothing written).`);
|
|
398
|
+
process.exitCode = 1;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
const { settings, changed } = mergeHooksIntoSettings(current);
|
|
403
|
+
if (!changed) {
|
|
404
|
+
console.log(`hilos hooks already installed in ${target}.`);
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (existsSync(target)) writeFileSync(target + ".bak", readFileSync(target));
|
|
408
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
409
|
+
writeFileSync(target, JSON.stringify(settings, null, 2) + "\n");
|
|
410
|
+
console.log(`Installed hilos hooks into ${target}${existsSync(target + ".bak") ? ` (backup: ${target}.bak)` : ""}.`);
|
|
411
|
+
console.log(
|
|
412
|
+
isGlobal
|
|
413
|
+
? "Every Claude Code session on this machine will now stream its steps to your hilos channel."
|
|
414
|
+
: "Claude Code sessions in THIS repo will now stream their steps to your hilos channel.",
|
|
415
|
+
);
|
|
416
|
+
console.log("Kill switch: HILOS_HOOKS=off.");
|
|
417
|
+
// The installed entry runs `hilos-agent hook` directly (npx per tool call
|
|
418
|
+
// would add cold-start latency) — warn now if that won't resolve.
|
|
419
|
+
const onPath = (process.env.PATH || "")
|
|
420
|
+
.split(delimiter)
|
|
421
|
+
.some((dir) => dir && existsSync(join(dir, "hilos-agent")));
|
|
422
|
+
if (!onPath) {
|
|
423
|
+
console.log(
|
|
424
|
+
"NOTE: hilos-agent isn't on your PATH — the hooks won't fire until you run: npm i -g hilos-agent",
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
}
|
package/src/memory.mjs
CHANGED
|
@@ -8,9 +8,17 @@ const REMEMBER_NOTE =
|
|
|
8
8
|
"To record durable new learnings from this task, use the `remember` MCP tool " +
|
|
9
9
|
"(call it if your MCP config includes the hilos server).";
|
|
10
10
|
|
|
11
|
+
// Budgets are deliberately TIGHTER than lib/memory.ts formatMemoryBlock
|
|
12
|
+
// (25/6000/400): the daemon injects this into a coding-agent prompt that already
|
|
13
|
+
// carries the repo + task context, so it keeps a smaller footprint (25/4000/300)
|
|
14
|
+
// to leave headroom for the code.
|
|
11
15
|
const DEFAULT_MAX_ENTRIES = 25;
|
|
12
16
|
const DEFAULT_MAX_CHARS = 4000;
|
|
13
17
|
const DEFAULT_BODY_CHARS = 300;
|
|
18
|
+
// Reserve up to this many entry slots for workspace-wide learnings when both
|
|
19
|
+
// scopes have entries, so channel-first ordering can't starve team-wide
|
|
20
|
+
// conventions out of the cap. Mirrors lib/memory.ts. Channel entries still lead.
|
|
21
|
+
const RESERVED_WS_SLOTS = 5;
|
|
14
22
|
|
|
15
23
|
/** Truncate `text` to `max` chars, appending "…" when clipped. */
|
|
16
24
|
function clip(text, max) {
|
|
@@ -27,7 +35,8 @@ function clip(text, max) {
|
|
|
27
35
|
* line: `- [kind] title: body`. Bodies are truncated to ~300 chars. The whole
|
|
28
36
|
* block is capped at ~4000 chars (entries that would overflow are dropped, but
|
|
29
37
|
* at least one entry is always kept). Ends with a note reminding the agent to
|
|
30
|
-
* record new learnings via the `remember` MCP tool.
|
|
38
|
+
* record new learnings via the `remember` MCP tool. Saved memory is active
|
|
39
|
+
* immediately; people can correct or hide it later in hilos.
|
|
31
40
|
*
|
|
32
41
|
* Returns "" when entries is empty or null so callers can concatenate
|
|
33
42
|
* unconditionally (`block ? block + "\n\n" : ""`).
|
|
@@ -51,10 +60,25 @@ export function buildMemoryBlock(entries, opts = {}) {
|
|
|
51
60
|
return (b.updated_at || "").localeCompare(a.updated_at || "");
|
|
52
61
|
});
|
|
53
62
|
|
|
63
|
+
// Entry-count selection with a reserved workspace quota (see RESERVED_WS_SLOTS):
|
|
64
|
+
// hold back a few slots for workspace-wide entries when both scopes are present
|
|
65
|
+
// so channel entries can't claim every slot. Channel entries still lead.
|
|
66
|
+
const channelEntries = ordered.filter((e) => e.scope === "channel");
|
|
67
|
+
const workspaceEntries = ordered.filter((e) => e.scope !== "channel");
|
|
68
|
+
let picked;
|
|
69
|
+
if (channelEntries.length > 0 && workspaceEntries.length > 0) {
|
|
70
|
+
const wsReserve = Math.min(RESERVED_WS_SLOTS, workspaceEntries.length);
|
|
71
|
+
const chosenChannel = channelEntries.slice(0, Math.max(0, maxEntries - wsReserve));
|
|
72
|
+
const chosenWs = workspaceEntries.slice(0, maxEntries - chosenChannel.length);
|
|
73
|
+
picked = [...chosenChannel, ...chosenWs];
|
|
74
|
+
} else {
|
|
75
|
+
picked = ordered.slice(0, maxEntries);
|
|
76
|
+
}
|
|
77
|
+
|
|
54
78
|
const lines = [];
|
|
55
79
|
// Reserve chars for: header line + newline + 2 newlines before footer + footer.
|
|
56
80
|
let total = HEADER.length + 1 + 2 + REMEMBER_NOTE.length;
|
|
57
|
-
for (const e of
|
|
81
|
+
for (const e of picked) {
|
|
58
82
|
const kind = (e.kind || "note").trim();
|
|
59
83
|
const title = (e.title || "").replace(/\s+/g, " ").trim();
|
|
60
84
|
const body = clip(e.body || "", bodyChars);
|
package/src/run.mjs
CHANGED
|
@@ -2,16 +2,66 @@
|
|
|
2
2
|
// each one to the task handler. Prefers the workspace-wide list_mentions tool
|
|
3
3
|
// (one poll, cursor-based); falls back to per-channel scanning on older servers.
|
|
4
4
|
|
|
5
|
+
import { hostname } from "node:os";
|
|
6
|
+
import { basename } from "node:path";
|
|
5
7
|
import { makeClient } from "./mcp.mjs";
|
|
6
8
|
import { mentionHandle } from "./daemon.mjs";
|
|
7
9
|
import { handleTask } from "./handler.mjs";
|
|
8
10
|
import { createQueue, looksLikeCancel, dedupeKey } from "./queue.mjs";
|
|
9
11
|
import { reloadConfig } from "./config.mjs";
|
|
10
12
|
|
|
11
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The poll loop. Embeddable: pass a `signal` to stop it cleanly (interrupts the
|
|
15
|
+
* inter-poll sleep, cancels the active job, then resolves) and an `onEvent`
|
|
16
|
+
* listener to observe lifecycle events (status / task-start / task-done /
|
|
17
|
+
* task-error). Both are optional and backward compatible.
|
|
18
|
+
*
|
|
19
|
+
* @param {any} cfg
|
|
20
|
+
* @param {{
|
|
21
|
+
* handler?: (ctx: any, cfg: any, deps?: any, opts?: any) => Promise<any>,
|
|
22
|
+
* log?: any,
|
|
23
|
+
* signal?: AbortSignal,
|
|
24
|
+
* onEvent?: (event: { type: string, [k: string]: any }) => void,
|
|
25
|
+
* }} [opts]
|
|
26
|
+
*/
|
|
27
|
+
export async function run(cfg, { handler = handleTask, log = console, signal, onEvent } = {}) {
|
|
12
28
|
if (!cfg.token) {
|
|
13
|
-
|
|
14
|
-
|
|
29
|
+
// Throw, don't process.exit — the CLI's main().catch prints + exits 1 exactly
|
|
30
|
+
// as before, and an embedding host (the desktop Local Agents runner) must
|
|
31
|
+
// never be killed by the daemon.
|
|
32
|
+
throw new Error("No token. Run `hilos-agent init`, set HILOS_TOKEN, or use --join <blob>.");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Lifecycle events for an embedding host (e.g. the desktop Local Agents window).
|
|
36
|
+
// EVERY listener call is wrapped so a bad listener can never crash the loop.
|
|
37
|
+
const emit = (event) => {
|
|
38
|
+
if (!onEvent) return;
|
|
39
|
+
try {
|
|
40
|
+
onEvent(event);
|
|
41
|
+
} catch {
|
|
42
|
+
/* a bad event listener must never break the daemon */
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
// A sleep that resolves immediately when the signal aborts, so "stop" doesn't
|
|
46
|
+
// wait out the whole poll interval.
|
|
47
|
+
const interruptibleSleep = (ms) =>
|
|
48
|
+
new Promise((resolve) => {
|
|
49
|
+
if (signal?.aborted) return resolve();
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
signal?.removeEventListener?.("abort", onAbort);
|
|
52
|
+
resolve();
|
|
53
|
+
}, ms);
|
|
54
|
+
function onAbort() {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
resolve();
|
|
57
|
+
}
|
|
58
|
+
signal?.addEventListener?.("abort", onAbort, { once: true });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Already asked to stop before we even connect → return cleanly, do nothing.
|
|
62
|
+
if (signal?.aborted) {
|
|
63
|
+
emit({ type: "status", text: "stopping" });
|
|
64
|
+
return;
|
|
15
65
|
}
|
|
16
66
|
|
|
17
67
|
const { tool, listToolNames } = makeClient({ url: cfg.url, token: cfg.token });
|
|
@@ -19,8 +69,8 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
19
69
|
const who = await tool("whoami");
|
|
20
70
|
const me = { ...who, handle: mentionHandle(who.agentName) };
|
|
21
71
|
if (!me.handle) {
|
|
22
|
-
|
|
23
|
-
|
|
72
|
+
// Throw for the same reason as the token guard above — embed-safe, CLI-equal.
|
|
73
|
+
throw new Error("This agent has no display name / handle — set one in hilos, then reconnect.");
|
|
24
74
|
}
|
|
25
75
|
log.log(`hilos-agent: ${me.agentName} (@${me.handle}) — ${cfg.url}`);
|
|
26
76
|
if (cfg.channelId) log.log(`scope: channel ${cfg.channelId}`);
|
|
@@ -48,12 +98,33 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
48
98
|
recall: toolNames.includes("recall"),
|
|
49
99
|
};
|
|
50
100
|
|
|
101
|
+
// Register local folders (0324/0325): a folder-mode daemon announces each
|
|
102
|
+
// channel→folder mapping to the server so the channel shows a folder chip
|
|
103
|
+
// without any manual step. Capability-gated on the server advertising
|
|
104
|
+
// link_folder (older deploys skip silently, exactly like recall). Best-effort:
|
|
105
|
+
// each call is wrapped so a failure logs one line and NEVER blocks startup.
|
|
106
|
+
// Done ONCE here at startup — config live-reload (reloadConfig, below) does NOT
|
|
107
|
+
// re-register, so a folders entry added while running needs a restart to link.
|
|
108
|
+
if (toolNames.includes("link_folder") && cfg.folders && Object.keys(cfg.folders).length) {
|
|
109
|
+
for (const [channelId, path] of Object.entries(cfg.folders)) {
|
|
110
|
+
if (!channelId || !path) continue;
|
|
111
|
+
const title = basename(path);
|
|
112
|
+
try {
|
|
113
|
+
await tool("link_folder", { channelId, path, title, hostname: hostname() });
|
|
114
|
+
emit({ type: "status", text: `linked folder ${title} to its channel` });
|
|
115
|
+
} catch (e) {
|
|
116
|
+
log.error(`link_folder failed for ${path}: ${e?.message || e}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
51
121
|
let channelIds = [];
|
|
52
122
|
if (!useMentions) {
|
|
53
123
|
if (cfg.channelId) channelIds = [cfg.channelId];
|
|
54
124
|
else channelIds = ((await tool("list_channels"))?.channels ?? []).map((c) => c.id);
|
|
55
125
|
}
|
|
56
126
|
log.log(useMentions ? "watching: @-mentions" : `watching: ${channelIds.length} channel(s)`);
|
|
127
|
+
emit({ type: "status", text: useMentions ? "watching @-mentions" : `watching ${channelIds.length} channel(s)` });
|
|
57
128
|
|
|
58
129
|
const seen = new Set();
|
|
59
130
|
const cursor = { value: since ? new Date(since).toISOString() : null };
|
|
@@ -67,15 +138,34 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
67
138
|
// lets a "stop" (and new mentions) be picked up while a task is still running.
|
|
68
139
|
const queue = createQueue({
|
|
69
140
|
concurrency: cfg.queueConcurrency || 1,
|
|
70
|
-
runJob: (job, { signal }) => safeHandle(job.message, job.channelId,
|
|
141
|
+
runJob: (job, { signal: jobSignal }) => safeHandle(job.message, job.channelId, jobSignal),
|
|
71
142
|
});
|
|
72
143
|
|
|
73
|
-
|
|
144
|
+
// Stopping the embedded daemon: the moment the caller aborts, cancel the active
|
|
145
|
+
// job (via the queue's existing cancel path — it aborts the in-flight job's
|
|
146
|
+
// signal) so a multi-minute run is torn down promptly, and emit a stopping
|
|
147
|
+
// event. New tasks are refused in intake(); the loop below exits on the same
|
|
148
|
+
// signal and run() resolves cleanly.
|
|
149
|
+
if (signal) {
|
|
150
|
+
signal.addEventListener(
|
|
151
|
+
"abort",
|
|
152
|
+
() => {
|
|
153
|
+
emit({ type: "status", text: "stopping" });
|
|
154
|
+
queue.cancelActive("run() aborted");
|
|
155
|
+
},
|
|
156
|
+
{ once: true },
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function safeHandle(message, channelId, jobSignal) {
|
|
161
|
+
emit({ type: "task-start", channelId, messageId: message.id, text: (message.body || "").slice(0, 200) });
|
|
74
162
|
try {
|
|
75
163
|
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
76
164
|
// liveCfg so a job uses the latest model/permission/codingCmd at run time.
|
|
77
|
-
await handler({ message, channelId, tool, me, caps }, liveCfg, undefined, { signal });
|
|
165
|
+
const result = await handler({ message, channelId, tool, me, caps }, liveCfg, undefined, { signal: jobSignal });
|
|
166
|
+
emit({ type: "task-done", channelId, status: result && result.status });
|
|
78
167
|
} catch (e) {
|
|
168
|
+
emit({ type: "task-error", channelId, error: e?.message || String(e) });
|
|
79
169
|
log.error(`handler error: ${e.message}`);
|
|
80
170
|
await tool("post_message", {
|
|
81
171
|
channelId,
|
|
@@ -88,6 +178,8 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
88
178
|
// enqueued (deduped against an identical in-flight/queued ask), with an ack
|
|
89
179
|
// when it lands behind work already in progress.
|
|
90
180
|
async function intake(m, channelId) {
|
|
181
|
+
// Once we're stopping, don't start anything new.
|
|
182
|
+
if (signal?.aborted) return;
|
|
91
183
|
if (looksLikeCancel(m.body) && (queue.active() || queue.size())) {
|
|
92
184
|
queue.cancelActive("user asked to stop");
|
|
93
185
|
await tool("post_message", {
|
|
@@ -140,11 +232,15 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
140
232
|
}
|
|
141
233
|
|
|
142
234
|
do {
|
|
235
|
+
if (signal?.aborted) break;
|
|
143
236
|
// Pick up live edits to hilos-agent.json (model/permission/codingCmd/etc.)
|
|
144
237
|
// before each pass. Guarded: a bad file leaves liveCfg untouched.
|
|
145
238
|
try {
|
|
146
239
|
const next = reloadConfig(liveCfg);
|
|
147
|
-
if (next.codingCmd !== liveCfg.codingCmd)
|
|
240
|
+
if (next.codingCmd !== liveCfg.codingCmd) {
|
|
241
|
+
log.log(`daemon: coding command → ${next.codingCmd}`);
|
|
242
|
+
emit({ type: "status", text: "config reloaded" });
|
|
243
|
+
}
|
|
148
244
|
liveCfg = next;
|
|
149
245
|
} catch (e) {
|
|
150
246
|
log.error(`config reload error (keeping previous): ${e.message}`);
|
|
@@ -160,6 +256,16 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
160
256
|
await queue.idle();
|
|
161
257
|
break;
|
|
162
258
|
}
|
|
163
|
-
|
|
164
|
-
|
|
259
|
+
if (signal?.aborted) break;
|
|
260
|
+
// Interruptible so an abort during the sleep returns promptly instead of
|
|
261
|
+
// waiting out the full poll interval.
|
|
262
|
+
await interruptibleSleep(liveCfg.pollMs);
|
|
263
|
+
} while (!signal?.aborted);
|
|
264
|
+
|
|
265
|
+
// Clean stop: make sure any active job is cancelled (the abort listener already
|
|
266
|
+
// fired for the common case, but this covers a break for other reasons) and let
|
|
267
|
+
// in-flight teardown settle before returning.
|
|
268
|
+
if (signal?.aborted) {
|
|
269
|
+
queue.cancelActive("run() aborted");
|
|
270
|
+
}
|
|
165
271
|
}
|