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/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 ordered.slice(0, maxEntries)) {
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
- export async function run(cfg, { handler = handleTask, log = console } = {}) {
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
- log.error("No token. Run `hilos-agent init`, set HILOS_TOKEN, or use --join <blob>.");
14
- process.exit(1);
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
- log.error("This agent has no display name / handle set one in hilos, then reconnect.");
23
- process.exit(1);
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, signal),
141
+ runJob: (job, { signal: jobSignal }) => safeHandle(job.message, job.channelId, jobSignal),
71
142
  });
72
143
 
73
- async function safeHandle(message, channelId, signal) {
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) log.log(`daemon: coding command → ${next.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
- await new Promise((r) => setTimeout(r, liveCfg.pollMs));
164
- } while (true);
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
  }