hilos-agent 0.1.15 → 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/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}`);
@@ -31,7 +81,42 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
31
81
  const useMentions = !cfg.channelId && toolNames.includes("list_mentions");
32
82
  // Capabilities of THIS server, so the handler degrades gracefully on older
33
83
  // deploys (e.g. no edit_message → no live heartbeat, rather than erroring).
34
- const caps = { editMessage: toolNames.includes("edit_message") };
84
+ const caps = {
85
+ editMessage: toolNames.includes("edit_message"),
86
+ postProgress: toolNames.includes("post_progress"),
87
+ // The RUNS entity (0279/0280): when absent (older server) the handler skips
88
+ // start_run/update_run silently and behaves exactly as before.
89
+ runs: toolNames.includes("start_run"),
90
+ // Cross-agent REVIEW execution (0288): the reviewer needs to read a PR's diff
91
+ // AND post an advisory review. Absent on older servers → review-requests fall
92
+ // through to today's chat/code behavior, exactly as before.
93
+ review: toolNames.includes("post_review") && toolNames.includes("get_pr_diff"),
94
+ // Agent memory (0297): when present the handler best-effort recalls team
95
+ // learnings before each coding task and prepends them to the prompt so the
96
+ // coding agent has the workspace's conventions and gotchas from the start.
97
+ // Absent on older servers → silently skipped, no change in behavior.
98
+ recall: toolNames.includes("recall"),
99
+ };
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
+ }
35
120
 
36
121
  let channelIds = [];
37
122
  if (!useMentions) {
@@ -39,6 +124,7 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
39
124
  else channelIds = ((await tool("list_channels"))?.channels ?? []).map((c) => c.id);
40
125
  }
41
126
  log.log(useMentions ? "watching: @-mentions" : `watching: ${channelIds.length} channel(s)`);
127
+ emit({ type: "status", text: useMentions ? "watching @-mentions" : `watching ${channelIds.length} channel(s)` });
42
128
 
43
129
  const seen = new Set();
44
130
  const cursor = { value: since ? new Date(since).toISOString() : null };
@@ -52,15 +138,34 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
52
138
  // lets a "stop" (and new mentions) be picked up while a task is still running.
53
139
  const queue = createQueue({
54
140
  concurrency: cfg.queueConcurrency || 1,
55
- runJob: (job, { signal }) => safeHandle(job.message, job.channelId, signal),
141
+ runJob: (job, { signal: jobSignal }) => safeHandle(job.message, job.channelId, jobSignal),
56
142
  });
57
143
 
58
- 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) });
59
162
  try {
60
163
  log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
61
164
  // liveCfg so a job uses the latest model/permission/codingCmd at run time.
62
- 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 });
63
167
  } catch (e) {
168
+ emit({ type: "task-error", channelId, error: e?.message || String(e) });
64
169
  log.error(`handler error: ${e.message}`);
65
170
  await tool("post_message", {
66
171
  channelId,
@@ -73,6 +178,8 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
73
178
  // enqueued (deduped against an identical in-flight/queued ask), with an ack
74
179
  // when it lands behind work already in progress.
75
180
  async function intake(m, channelId) {
181
+ // Once we're stopping, don't start anything new.
182
+ if (signal?.aborted) return;
76
183
  if (looksLikeCancel(m.body) && (queue.active() || queue.size())) {
77
184
  queue.cancelActive("user asked to stop");
78
185
  await tool("post_message", {
@@ -125,11 +232,15 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
125
232
  }
126
233
 
127
234
  do {
235
+ if (signal?.aborted) break;
128
236
  // Pick up live edits to hilos-agent.json (model/permission/codingCmd/etc.)
129
237
  // before each pass. Guarded: a bad file leaves liveCfg untouched.
130
238
  try {
131
239
  const next = reloadConfig(liveCfg);
132
- 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
+ }
133
244
  liveCfg = next;
134
245
  } catch (e) {
135
246
  log.error(`config reload error (keeping previous): ${e.message}`);
@@ -145,6 +256,16 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
145
256
  await queue.idle();
146
257
  break;
147
258
  }
148
- await new Promise((r) => setTimeout(r, liveCfg.pollMs));
149
- } 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
+ }
150
271
  }