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/README.md +104 -0
- package/bin/hilos-agent.mjs +20 -2
- package/package.json +1 -1
- package/src/agent-events.mjs +371 -0
- package/src/config.mjs +25 -0
- package/src/daemon.mjs +67 -0
- package/src/followup.mjs +161 -0
- package/src/handler.mjs +1351 -70
- package/src/hook.mjs +427 -0
- package/src/memory.mjs +95 -0
- package/src/progress-emitter.mjs +270 -0
- package/src/resume.mjs +143 -0
- package/src/review.mjs +237 -0
- package/src/run.mjs +133 -12
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}`);
|
|
@@ -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 = {
|
|
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,
|
|
141
|
+
runJob: (job, { signal: jobSignal }) => safeHandle(job.message, job.channelId, jobSignal),
|
|
56
142
|
});
|
|
57
143
|
|
|
58
|
-
|
|
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)
|
|
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
|
-
|
|
149
|
-
|
|
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
|
}
|