hilos-agent 0.1.6 → 0.1.8
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 +57 -3
- package/bin/hilos-agent.mjs +6 -0
- package/package.json +1 -1
- package/src/cli.mjs +93 -4
- package/src/config.mjs +69 -0
- package/src/handler.mjs +362 -55
- package/src/queue.mjs +133 -0
- package/src/run.mjs +74 -15
package/README.md
CHANGED
|
@@ -42,12 +42,28 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
42
42
|
"url": "https://hilos.sh/api/mcp",
|
|
43
43
|
"token": "mgo_…",
|
|
44
44
|
"repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
|
|
45
|
-
"codingCmd": "claude -p --permission-mode acceptEdits", // or "codex exec", "cursor-agent", any command
|
|
45
|
+
"codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent", any command
|
|
46
|
+
"chatCmd": "claude -p --model claude-haiku-4-5", // FAST command for chat replies + the plan-ack (set if your stack isn't Claude)
|
|
46
47
|
"defaultBranch": "main",
|
|
47
|
-
"gate": false
|
|
48
|
+
"gate": false, // default: open a PR directly. true = approve-before-push
|
|
49
|
+
"heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
|
|
50
|
+
"chatTimeoutMs": 90000 // cap a chat reply / plan-ack so a stalled model can't go silent
|
|
48
51
|
}
|
|
49
52
|
```
|
|
50
53
|
|
|
54
|
+
**Staying responsive.** Every code task posts an **instant acknowledgement**
|
|
55
|
+
(under a second), and — if your server exposes `edit_message` and a `chatCmd` is
|
|
56
|
+
set — a quick **plan** ("On it — I'll do X, then open a PR") edits into it. On a
|
|
57
|
+
run longer than `heartbeatMs` (default 3 min; env `HILOS_HEARTBEAT_MS`, `0`
|
|
58
|
+
disables, clamped to ≥15s) the agent posts **one progress reply** in the thread
|
|
59
|
+
then edits it in place with elapsed time + the CLI's latest line — so the channel
|
|
60
|
+
shows it's alive without thread spam. When the run ends, that message is retired
|
|
61
|
+
to a short "done" line. A run that **times out or errors** says so honestly (with
|
|
62
|
+
a stderr tail) instead of claiming "no changes". Chat replies use the faster
|
|
63
|
+
`chatCmd` (default Haiku; falls back to `codingCmd` if unset) bounded by
|
|
64
|
+
`chatTimeoutMs`. The responsive surface needs a hilos server new enough to expose
|
|
65
|
+
`edit_message`; older servers just skip the live edits.
|
|
66
|
+
|
|
51
67
|
```sh
|
|
52
68
|
hilos-agent # watch every channel the agent is in
|
|
53
69
|
hilos-agent --channel <id> # scope to one channel
|
|
@@ -67,6 +83,44 @@ hilos-agent --channel <id> # scope to one channel
|
|
|
67
83
|
card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
|
|
68
84
|
discards the branch, **Request changes** re-runs with your note (bounded rounds).
|
|
69
85
|
|
|
86
|
+
## Model & permissions
|
|
87
|
+
|
|
88
|
+
You don't have to hand-write `codingCmd`: the agent's **Connect via MCP** panel in
|
|
89
|
+
hilos has **Model** (Vendor default / Opus / Sonnet / Haiku) and **Permissions**
|
|
90
|
+
(Ask before edits / Auto-approve edits / Skip all prompts) pickers that bake your
|
|
91
|
+
choice into the generated `--coding-cmd`. Change it later by editing `codingCmd` in
|
|
92
|
+
`hilos-agent.json` — the daemon re-reads the file between polls and applies it
|
|
93
|
+
without a restart (your `url`/`token` are never affected). The next section
|
|
94
|
+
explains what each permission level means.
|
|
95
|
+
|
|
96
|
+
## Permissions / autonomy
|
|
97
|
+
|
|
98
|
+
`codingCmd` decides how much the coding agent can do on its own. Three levels,
|
|
99
|
+
safest first:
|
|
100
|
+
|
|
101
|
+
- **`--permission-mode acceptEdits` (default).** The agent edits files without
|
|
102
|
+
prompting, but in headless `claude -p` a step that needs bash — run the tests,
|
|
103
|
+
install a dep — has no interactive prompt to grant, so the task can **stall**.
|
|
104
|
+
Good when the work is edit-only; frustrating for anything that needs to run
|
|
105
|
+
commands.
|
|
106
|
+
- **`--dangerously-skip-permissions` (recommended for independent agents).** Full
|
|
107
|
+
autonomy: the agent can run the tests, install deps, and finish hands-off.
|
|
108
|
+
Caution: it can run **any** command in the repo you point it at — only use it
|
|
109
|
+
on a repo and machine where that's acceptable. This is the option to pick if
|
|
110
|
+
you want the agent to actually work on its own.
|
|
111
|
+
|
|
112
|
+
```jsonc
|
|
113
|
+
"codingCmd": "claude -p --dangerously-skip-permissions"
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
- **Approve-before-push (`gate:true`), most cautious.** Independent of the two
|
|
117
|
+
above — the agent still runs locally, but posts the proposed diff as a card and
|
|
118
|
+
pushes only after you Approve. Pair it with either permission mode.
|
|
119
|
+
|
|
120
|
+
The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
|
|
121
|
+
you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
|
|
122
|
+
before anything is pushed.
|
|
123
|
+
|
|
70
124
|
## Security
|
|
71
125
|
|
|
72
126
|
The daemon runs a coding agent that can execute code in your repo — exactly as if
|
|
@@ -79,7 +133,7 @@ or `HILOS_TOKEN`, never in shared shell history.
|
|
|
79
133
|
## Flags
|
|
80
134
|
|
|
81
135
|
`--join <blob>` · `--channel <id>` · `--config <path>` · `--coding-cmd <cmd>` ·
|
|
82
|
-
`--once` · `--backfill` · `--no-gate` · `--help`
|
|
136
|
+
`--chat-cmd <cmd>` · `--once` · `--backfill` · `--no-gate` · `--help`
|
|
83
137
|
|
|
84
138
|
Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
|
|
85
139
|
`HILOS_BACKFILL=1`.
|
package/bin/hilos-agent.mjs
CHANGED
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// Config (./hilos-agent.json or ~/.hilos/agent.json), overlaid by env + flags:
|
|
13
13
|
// { "url", "token", "channelId", "repos": {"owner/name":"/abs/path"},
|
|
14
14
|
// "codingCmd": "claude -p", "defaultBranch": "main", "gate": true }
|
|
15
|
+
// The connect link bakes your model + permission choice into --coding-cmd; edit
|
|
16
|
+
// codingCmd in hilos-agent.json to change it and the daemon picks it up on its
|
|
17
|
+
// next poll — no restart needed.
|
|
15
18
|
|
|
16
19
|
import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "../src/config.mjs";
|
|
17
20
|
import { run } from "../src/run.mjs";
|
|
@@ -27,6 +30,7 @@ function parseArgs(argv) {
|
|
|
27
30
|
else if (a === "--url") flags.url = argv[++i];
|
|
28
31
|
else if (a === "--token") flags.token = argv[++i];
|
|
29
32
|
else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
|
|
33
|
+
else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
|
|
30
34
|
else if (a === "--once") flags.once = true;
|
|
31
35
|
else if (a === "--backfill") flags.backfill = true;
|
|
32
36
|
else if (a === "--no-gate") flags.gate = false;
|
|
@@ -46,6 +50,8 @@ Options:
|
|
|
46
50
|
--channel <id> watch only one channel (per-channel override)
|
|
47
51
|
--config <path> use a specific config file
|
|
48
52
|
--coding-cmd <cmd> the coding agent to run (default: "claude -p")
|
|
53
|
+
--chat-cmd <cmd> fast command for chat replies + the plan-ack
|
|
54
|
+
(default: "claude -p --model claude-haiku-4-5")
|
|
49
55
|
--once one poll then exit (cron-friendly)
|
|
50
56
|
--backfill also act on mentions that predate startup
|
|
51
57
|
--no-gate propose only; don't wait for approval / push
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli.mjs
CHANGED
|
@@ -15,6 +15,36 @@ export function fmtElapsed(ms) {
|
|
|
15
15
|
return m > 0 ? `${m}m ${s}s` : `${s}s`;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Collapse to one trimmed, length-capped line (for a heartbeat's "Latest:" tail). */
|
|
19
|
+
export function oneLine(s, max = 140) {
|
|
20
|
+
const t = String(s || "").replace(/\s+/g, " ").trim();
|
|
21
|
+
return t.length > max ? t.slice(0, max - 1) + "…" : t;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Low-noise "still working…" status body for a long run (pure; used by the
|
|
26
|
+
* daemon's chat-facing heartbeat). Appends the CLI's latest output line when one
|
|
27
|
+
* is available, else stays generic.
|
|
28
|
+
* @param {{ branch: string, elapsedMs: number, repoFullName?: string, lastLine?: string }} o
|
|
29
|
+
*/
|
|
30
|
+
export function buildHeartbeat(o) {
|
|
31
|
+
const { repoFullName, branch, elapsedMs, lastLine } = o;
|
|
32
|
+
const where = repoFullName ? ` in ${repoFullName}` : "";
|
|
33
|
+
const base = `Still working on \`${branch}\`${where} — ${fmtElapsed(elapsedMs)} elapsed. I'll post a report when it's done.`;
|
|
34
|
+
const tail = oneLine(lastLine);
|
|
35
|
+
return tail ? `${base}\n\nLatest: ${tail}` : base;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The instant, template acknowledgement posted the moment a code task starts —
|
|
40
|
+
* so the channel shows life in <1s, before any model call. A bounded plan-ack
|
|
41
|
+
* may later edit this message in place with a sentence of specifics.
|
|
42
|
+
*/
|
|
43
|
+
export function ackText(repoFullName) {
|
|
44
|
+
const where = repoFullName ? ` in ${repoFullName}` : "";
|
|
45
|
+
return `On it — taking a look${where} now. I'll open a PR and report back.`;
|
|
46
|
+
}
|
|
47
|
+
|
|
18
48
|
// Guard against a pathological run flooding memory; the CLI's result is small.
|
|
19
49
|
const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
20
50
|
|
|
@@ -28,6 +58,10 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
|
28
58
|
* @property {number} [heartbeatMs] - heartbeat interval (0 = no heartbeat)
|
|
29
59
|
* @property {() => number} [now] - clock, injectable for tests
|
|
30
60
|
* @property {{ log: (m: string) => void }} [log] - logger, injectable for tests
|
|
61
|
+
* @property {AbortSignal} [signal] - abort to cancel the run (kills the child's
|
|
62
|
+
* whole process group: SIGTERM, then SIGKILL after a short grace)
|
|
63
|
+
* @property {(chunk: string) => void} [onData] - called with each stdout chunk as
|
|
64
|
+
* it arrives (lets a caller track the latest output line for a heartbeat)
|
|
31
65
|
*/
|
|
32
66
|
|
|
33
67
|
/**
|
|
@@ -50,11 +84,21 @@ export function runCli(opts) {
|
|
|
50
84
|
heartbeatMs = 15000,
|
|
51
85
|
now = Date.now,
|
|
52
86
|
log = console,
|
|
87
|
+
signal,
|
|
88
|
+
onData,
|
|
53
89
|
} = opts || {};
|
|
54
90
|
return new Promise((resolve) => {
|
|
91
|
+
// Already cancelled before we even start.
|
|
92
|
+
if (signal?.aborted) {
|
|
93
|
+
resolve({ status: null, stdout: "", stderr: "", aborted: true, error: new Error("cancelled") });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
55
96
|
let child;
|
|
56
97
|
try {
|
|
57
|
-
child
|
|
98
|
+
// detached:true makes the child its own process-group leader, so we can
|
|
99
|
+
// kill the WHOLE tree (claude → node → git …) with process.kill(-pid) on
|
|
100
|
+
// cancel/timeout instead of orphaning its subprocesses.
|
|
101
|
+
child = spawn(cmd, args, { cwd, detached: true });
|
|
58
102
|
} catch (error) {
|
|
59
103
|
resolve({ status: null, stdout: "", stderr: "", error });
|
|
60
104
|
return;
|
|
@@ -68,7 +112,16 @@ export function runCli(opts) {
|
|
|
68
112
|
child.stderr?.setEncoding("utf8");
|
|
69
113
|
const append = (buf, chunk) =>
|
|
70
114
|
buf.length >= MAX_CAPTURE_BYTES ? buf : buf + chunk;
|
|
71
|
-
child.stdout?.on("data", (c) =>
|
|
115
|
+
child.stdout?.on("data", (c) => {
|
|
116
|
+
stdout = append(stdout, c);
|
|
117
|
+
if (onData) {
|
|
118
|
+
try {
|
|
119
|
+
onData(c);
|
|
120
|
+
} catch {
|
|
121
|
+
/* a heartbeat tracker must never break the run */
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
});
|
|
72
125
|
child.stderr?.on("data", (c) => (stderr = append(stderr, c)));
|
|
73
126
|
|
|
74
127
|
const start = now();
|
|
@@ -80,24 +133,60 @@ export function runCli(opts) {
|
|
|
80
133
|
: null;
|
|
81
134
|
if (beat?.unref) beat.unref();
|
|
82
135
|
|
|
136
|
+
// Kill the child's whole process group; fall back to a bare kill if the
|
|
137
|
+
// group signal isn't permitted (e.g. Windows / unusual setups).
|
|
138
|
+
const killGroup = (sig) => {
|
|
139
|
+
if (child.pid == null) return; // never spawned a pid → nothing to kill
|
|
140
|
+
try {
|
|
141
|
+
process.kill(-child.pid, sig);
|
|
142
|
+
} catch {
|
|
143
|
+
try {
|
|
144
|
+
child.kill(sig);
|
|
145
|
+
} catch {
|
|
146
|
+
/* already gone */
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
|
|
83
151
|
let timedOut = false;
|
|
84
152
|
const timer =
|
|
85
153
|
timeoutMs > 0
|
|
86
154
|
? setTimeout(() => {
|
|
87
155
|
timedOut = true;
|
|
88
|
-
|
|
156
|
+
killGroup("SIGKILL");
|
|
89
157
|
}, timeoutMs)
|
|
90
158
|
: null;
|
|
91
159
|
if (timer?.unref) timer.unref();
|
|
92
160
|
|
|
161
|
+
// Cancel via AbortSignal: SIGTERM the group, then SIGKILL after a grace so a
|
|
162
|
+
// well-behaved CLI can clean up but a stuck one still dies.
|
|
163
|
+
let aborted = false;
|
|
164
|
+
let graceTimer = null;
|
|
165
|
+
const onAbort = () => {
|
|
166
|
+
aborted = true;
|
|
167
|
+
killGroup("SIGTERM");
|
|
168
|
+
graceTimer = setTimeout(() => killGroup("SIGKILL"), 3000);
|
|
169
|
+
if (graceTimer?.unref) graceTimer.unref();
|
|
170
|
+
};
|
|
171
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
172
|
+
|
|
93
173
|
const finish = (status, error) => {
|
|
94
174
|
if (beat) clearInterval(beat);
|
|
95
175
|
if (timer) clearTimeout(timer);
|
|
176
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
177
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
96
178
|
resolve({
|
|
97
179
|
status,
|
|
98
180
|
stdout,
|
|
99
181
|
stderr,
|
|
100
|
-
|
|
182
|
+
aborted,
|
|
183
|
+
error:
|
|
184
|
+
error ||
|
|
185
|
+
(aborted
|
|
186
|
+
? new Error("cancelled")
|
|
187
|
+
: timedOut
|
|
188
|
+
? new Error(`timed out after ${fmtElapsed(timeoutMs)}`)
|
|
189
|
+
: null),
|
|
101
190
|
});
|
|
102
191
|
};
|
|
103
192
|
child.on("error", (error) => finish(null, error));
|
package/src/config.mjs
CHANGED
|
@@ -47,12 +47,30 @@ const DEFAULTS = {
|
|
|
47
47
|
// it still won't run arbitrary commands. Override in hilos-agent.json if you
|
|
48
48
|
// want a stricter (or `--dangerously-skip-permissions`) command.
|
|
49
49
|
codingCmd: "claude -p --permission-mode acceptEdits",
|
|
50
|
+
// Chat replies + the code-task plan-ack use a FAST one-shot model so a casual
|
|
51
|
+
// reply (or "I see it, here's my plan") comes back in seconds, not minutes.
|
|
52
|
+
// Bounded by chatTimeoutMs with a template fallback so it can never dead-air.
|
|
53
|
+
// If your coding stack isn't Claude, set this to your own fast reply command.
|
|
54
|
+
chatCmd: "claude -p --model claude-haiku-4-5",
|
|
50
55
|
defaultBranch: "main",
|
|
51
56
|
// Bias to action: open a PR directly for review (approve = merge on the card).
|
|
52
57
|
// Set gate:true for the older approve-before-push flow (propose a diff, wait).
|
|
53
58
|
gate: false,
|
|
54
59
|
maxRounds: 3,
|
|
55
60
|
pollMs: 5000,
|
|
61
|
+
// On a long code run, post ONE thread progress reply at the first beat then
|
|
62
|
+
// edit it on later beats, so a human sees the agent is alive without thread
|
|
63
|
+
// spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
|
|
64
|
+
// UPDATEs. (Distinct from cli.mjs's 15s LOCAL terminal heartbeat.)
|
|
65
|
+
heartbeatMs: 180000,
|
|
66
|
+
// Cap a chat reply / plan-ack so a stalled model can't dead-air the channel;
|
|
67
|
+
// on timeout we post an honest "taking longer than expected" line.
|
|
68
|
+
chatTimeoutMs: 90000,
|
|
69
|
+
// Work queue: run mentions one at a time (concurrency 1 — parallel CLI runs on
|
|
70
|
+
// one checkout would collide on git state). queueAcks posts "queued behind the
|
|
71
|
+
// current task" when a mention lands during a run; set false to keep it quiet.
|
|
72
|
+
queueConcurrency: 1,
|
|
73
|
+
queueAcks: true,
|
|
56
74
|
runTimeoutMs: 600000,
|
|
57
75
|
decisionTimeoutMs: 1800000,
|
|
58
76
|
decisionPollMs: 5000,
|
|
@@ -68,11 +86,16 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
68
86
|
token: process.env.HILOS_TOKEN,
|
|
69
87
|
channelId: process.env.HILOS_CHANNEL,
|
|
70
88
|
codingCmd: process.env.CODING_CMD,
|
|
89
|
+
chatCmd: process.env.HILOS_CHAT_CMD,
|
|
90
|
+
heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
|
|
91
|
+
chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
|
|
71
92
|
backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
|
|
72
93
|
once: process.env.HILOS_ONCE === "1" ? true : undefined,
|
|
73
94
|
};
|
|
74
95
|
const merged = { ...DEFAULTS, ...file };
|
|
75
96
|
for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
|
|
97
|
+
// Clamp the chat heartbeat: 0 (off) stays off, otherwise never below 15s.
|
|
98
|
+
if (merged.heartbeatMs > 0) merged.heartbeatMs = Math.max(15000, Number(merged.heartbeatMs) || 0);
|
|
76
99
|
if (joinPayload) {
|
|
77
100
|
merged.url = joinPayload.url;
|
|
78
101
|
merged.token = joinPayload.token;
|
|
@@ -80,9 +103,55 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
|
80
103
|
}
|
|
81
104
|
for (const [k, v] of Object.entries(flags)) if (v !== undefined) merged[k] = v;
|
|
82
105
|
merged.repos = { ...DEFAULTS.repos, ...(file.repos || {}) };
|
|
106
|
+
// Remember where the file lives so reloadConfig can re-read it live.
|
|
107
|
+
merged.configPath = findConfigPath(flags.config) || null;
|
|
83
108
|
return merged;
|
|
84
109
|
}
|
|
85
110
|
|
|
111
|
+
// Run-behavior fields that may change live (edit hilos-agent.json, no restart).
|
|
112
|
+
// Connection identity (url/token/channelId) is deliberately excluded so a bad or
|
|
113
|
+
// edited file can NEVER drop the daemon's connection.
|
|
114
|
+
const LIVE_FIELDS = [
|
|
115
|
+
"codingCmd",
|
|
116
|
+
"chatCmd",
|
|
117
|
+
"defaultBranch",
|
|
118
|
+
"gate",
|
|
119
|
+
"maxRounds",
|
|
120
|
+
"pollMs",
|
|
121
|
+
"runTimeoutMs",
|
|
122
|
+
"decisionTimeoutMs",
|
|
123
|
+
"decisionPollMs",
|
|
124
|
+
"heartbeatMs",
|
|
125
|
+
"chatTimeoutMs",
|
|
126
|
+
// NOT queueConcurrency: the queue is built once at startup, so it can't change
|
|
127
|
+
// live. queueAcks IS live (intake() reads it per-mention from liveCfg).
|
|
128
|
+
"queueAcks",
|
|
129
|
+
];
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Re-read the config file and overlay ONLY the run-behavior fields onto the
|
|
133
|
+
* previous config, so a running daemon picks up model/permission/codingCmd edits
|
|
134
|
+
* without a restart. Identity (url/token/channelId) is pinned from `prev`. Env
|
|
135
|
+
* still overrides (operator wins). On any read error, returns `prev` unchanged.
|
|
136
|
+
*/
|
|
137
|
+
export function reloadConfig(prev) {
|
|
138
|
+
// Only ever re-read the file CHOSEN AT LAUNCH. A daemon started without a config
|
|
139
|
+
// file (e.g. `--join` + env) stays file-free — we don't re-discover cwd each
|
|
140
|
+
// poll, so a hilos-agent.json dropped in later can't start steering which binary
|
|
141
|
+
// the daemon spawns. (Restart to adopt a newly-created file.)
|
|
142
|
+
const path = prev.configPath;
|
|
143
|
+
const file = (path && readJson(path)) || {};
|
|
144
|
+
const next = { ...prev };
|
|
145
|
+
for (const k of LIVE_FIELDS) if (file[k] !== undefined) next[k] = file[k];
|
|
146
|
+
if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
|
|
147
|
+
if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
|
|
148
|
+
if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
|
|
149
|
+
if (process.env.HILOS_CHAT_TIMEOUT_MS) next.chatTimeoutMs = Number(process.env.HILOS_CHAT_TIMEOUT_MS);
|
|
150
|
+
if (next.heartbeatMs > 0) next.heartbeatMs = Math.max(15000, Number(next.heartbeatMs) || 0);
|
|
151
|
+
if (file.repos) next.repos = { ...DEFAULTS.repos, ...file.repos };
|
|
152
|
+
return next;
|
|
153
|
+
}
|
|
154
|
+
|
|
86
155
|
/** Write a starter config file (used by `hilos-agent init`). */
|
|
87
156
|
export function writeStarterConfig(path, partial = {}) {
|
|
88
157
|
const target = path || GLOBAL_CONFIG;
|
package/src/handler.mjs
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// CANONICAL HANDLER — this is what the published `hilos-agent` package ships and
|
|
2
|
+
// runs. Bias-to-PR by default (gate:false opens a PR; gate:true falls back to
|
|
3
|
+
// approve-before-push). The legacy, propose-only reference lives at
|
|
4
|
+
// scripts/examples/coding-agent-handler.mjs and is NOT what users run.
|
|
5
|
+
//
|
|
6
|
+
// Turn an @mention in a git-linked channel into a branch + a coding-agent run.
|
|
7
|
+
// By default it opens a PR for review; with gate:true it posts a proposed diff
|
|
8
|
+
// and pushes only after approval. Your code + credentials stay local.
|
|
4
9
|
|
|
5
10
|
import { spawnSync } from "node:child_process";
|
|
6
11
|
import { randomBytes } from "node:crypto";
|
|
12
|
+
import { rmSync } from "node:fs";
|
|
13
|
+
import { join } from "node:path";
|
|
7
14
|
import {
|
|
8
15
|
branchSlug,
|
|
9
16
|
truncateDiff,
|
|
@@ -15,7 +22,7 @@ import {
|
|
|
15
22
|
prTitleBody,
|
|
16
23
|
mentionHandle,
|
|
17
24
|
} from "./daemon.mjs";
|
|
18
|
-
import { runCli } from "./cli.mjs";
|
|
25
|
+
import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
|
|
19
26
|
|
|
20
27
|
/** `@handle` for the person who asked, so the report tags them. */
|
|
21
28
|
function requesterTag(author) {
|
|
@@ -41,10 +48,13 @@ function defaultDeps() {
|
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId }) {
|
|
51
|
+
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
|
|
45
52
|
if (!reportMessageId) return { kind: "timeout" };
|
|
46
53
|
const deadline = deps.now() + cfg.decisionTimeoutMs;
|
|
47
54
|
while (deps.now() < deadline) {
|
|
55
|
+
// A "stop" while we wait for review must end the wait — otherwise a later
|
|
56
|
+
// Approve would still commit/push/open a PR despite the cancel.
|
|
57
|
+
if (signal?.aborted) return { kind: "cancelled" };
|
|
48
58
|
let report = null;
|
|
49
59
|
const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
|
|
50
60
|
if (gr && gr.found && gr.report) report = gr.report;
|
|
@@ -176,10 +186,46 @@ export function looksLikeCodeTask(text) {
|
|
|
176
186
|
return false;
|
|
177
187
|
}
|
|
178
188
|
const verb =
|
|
179
|
-
/\b(fix|add|implement|refactor|change|update|create|remove|delete|rename|write|build|bump|migrate|wire|patch|optimi[sz]e|debug|revert|rebase|configure|improve|replace|extract|split|move|generate|scaffold|make|set up|hook up|adjust|tweak)\b/;
|
|
189
|
+
/\b(fix|add|implement|refactor|change|update|create|remove|delete|rename|write|build|bump|migrate|wire|patch|optimi[sz]e|debug|revert|rebase|configure|improve|replace|extract|split|move|generate|scaffold|make|set up|hook up|adjust|tweak|ship|commit|push|merge)\b/;
|
|
180
190
|
return verb.test(t);
|
|
181
191
|
}
|
|
182
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A short go-ahead that approves whatever was just discussed — "go", "ship it",
|
|
195
|
+
* "do it", "lfg", "send a PR". On its own this isn't a code task (no verb), but
|
|
196
|
+
* after the agent has proposed a plan it MEANS "execute it". handleTask treats
|
|
197
|
+
* an affirmation as code only when a repo is linked AND the agent's last message
|
|
198
|
+
* looked like a plan — so casual "yes"/"cool" in a repo channel stays chat.
|
|
199
|
+
*/
|
|
200
|
+
export function isAffirmation(text) {
|
|
201
|
+
const t = String(text || "")
|
|
202
|
+
.toLowerCase()
|
|
203
|
+
.replace(/@[a-z0-9-]+/g, " ")
|
|
204
|
+
.replace(/[^a-z0-9'\s]/g, " ")
|
|
205
|
+
.replace(/\s+/g, " ")
|
|
206
|
+
.trim();
|
|
207
|
+
if (!t) return false;
|
|
208
|
+
// A strong go-ahead phrase ANYWHERE means "execute it" even inside a longer,
|
|
209
|
+
// excited message ("…so it's safe to start. go for it, babyyyy"). Safe because
|
|
210
|
+
// handleTask only treats it as code when the agent had just proposed a plan.
|
|
211
|
+
if (/\b(go for it|go ahead|ship it|send it|send a pr|open a pr|make it so|lfg|let'?s go|fire away)\b/.test(t)) {
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
// Otherwise only a SHORT message counts — a long one carries its own intent.
|
|
215
|
+
if (t.length > 40) return false;
|
|
216
|
+
return /^(go|do it|do that|yes|yep|yeah|yup|sure|ship|proceed|sounds good|sgtm|please do|please)\b/.test(t);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Does the agent's most recent message read like a plan/proposal it offered? */
|
|
220
|
+
export function agentProposedWork(rows, agentName) {
|
|
221
|
+
const mine = (rows || []).filter((m) => m && m.author === agentName);
|
|
222
|
+
const last = mine[mine.length - 1];
|
|
223
|
+
if (!last) return false;
|
|
224
|
+
return /\b(i'?ll|i will|plan|propose|pr\b|pull request|branch|here'?s|let me|on it|i'?d|i can)\b/i.test(
|
|
225
|
+
String(last.body || ""),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
183
229
|
/** owner/name from a GitHub remote URL (https or ssh), or null. */
|
|
184
230
|
export function normalizeRemote(url) {
|
|
185
231
|
const m = String(url || "")
|
|
@@ -188,23 +234,62 @@ export function normalizeRemote(url) {
|
|
|
188
234
|
return m ? m[1] : null;
|
|
189
235
|
}
|
|
190
236
|
|
|
191
|
-
/**
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
237
|
+
/** A short workspace-memory preamble for a CLI prompt, or "" when there is none. */
|
|
238
|
+
function memoryPreamble(workspaceMemory) {
|
|
239
|
+
const m = (workspaceMemory || "").trim();
|
|
240
|
+
return m ? `Workspace context (the project's soul):\n${m}\n\n` : "";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Recent conversation the agent should reply within: the thread it was pinged in
|
|
245
|
+
* (threads are where conversations live), else the channel tail. Returns the raw
|
|
246
|
+
* rows (for intent routing) and a transcript string (for the prompt).
|
|
247
|
+
*/
|
|
248
|
+
async function fetchContext({ channelId, tool, parentId }) {
|
|
249
|
+
let rows = [];
|
|
198
250
|
if (parentId) {
|
|
199
251
|
const t = await tool("get_thread", { parentId }).catch(() => null);
|
|
200
|
-
|
|
201
|
-
transcript = rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
|
|
252
|
+
rows = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
|
|
202
253
|
} else {
|
|
203
254
|
const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
|
|
204
255
|
messages: [],
|
|
205
256
|
}));
|
|
206
|
-
|
|
257
|
+
rows = messages;
|
|
207
258
|
}
|
|
259
|
+
return { rows, transcript: rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000) };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* A 1–2 sentence "here's my plan" line for the instant ack, via the FAST chat
|
|
264
|
+
* CLI. Bounded (≤45s) + best-effort: returns trimmed text, or null on
|
|
265
|
+
* empty/timeout/error so the run keeps the instant template and never stalls.
|
|
266
|
+
*/
|
|
267
|
+
async function proposePlanAck({ task, repoFullName, cfg, signal }) {
|
|
268
|
+
const cmd = cfg.chatCmd;
|
|
269
|
+
if (!cmd) return null;
|
|
270
|
+
const parts = cmd.split(" ").filter(Boolean);
|
|
271
|
+
const prompt =
|
|
272
|
+
`A teammate asked you to do this in the repo ${repoFullName}:\n"${task}"\n\n` +
|
|
273
|
+
`Reply with ONE or TWO short sentences, first person: acknowledge, state your ` +
|
|
274
|
+
`plan, and say you'll open a PR and report back. No preamble, no lists, no code. ` +
|
|
275
|
+
`Tone: "On it — I'll add X by doing Y. I'll open a PR and report back."`;
|
|
276
|
+
const run = await runCli({
|
|
277
|
+
cmd: parts[0],
|
|
278
|
+
args: [...parts.slice(1), prompt],
|
|
279
|
+
timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
|
|
280
|
+
label: "thinking",
|
|
281
|
+
signal,
|
|
282
|
+
});
|
|
283
|
+
if (run.aborted || signal?.aborted) return null;
|
|
284
|
+
const text = (run.stdout || "").trim();
|
|
285
|
+
if (!text) return null;
|
|
286
|
+
return text.length > 400 ? text.slice(0, 399) + "…" : text;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Run the FAST chat CLI to produce a reply, using recent channel context. */
|
|
290
|
+
async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps = {} }) {
|
|
291
|
+
void message;
|
|
292
|
+
const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
|
|
208
293
|
const name = me?.agentName || "an assistant";
|
|
209
294
|
// Tell the agent what the room is connected to so it doesn't ask "which repo?".
|
|
210
295
|
const repoLine = repoLink
|
|
@@ -214,39 +299,113 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
|
|
|
214
299
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
215
300
|
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
216
301
|
`${repoLine}\n\n` +
|
|
302
|
+
`${memoryPreamble(workspaceMemory)}` +
|
|
217
303
|
`Conversation so far:\n${transcript}`;
|
|
218
304
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
305
|
+
// Chat uses the FAST one-shot command (fall back to codingCmd if unset) bounded
|
|
306
|
+
// by chatTimeoutMs, so a casual reply lands in seconds and a stalled model can't
|
|
307
|
+
// dead-air the channel for the full coding timeout.
|
|
308
|
+
const cmd = cfg.chatCmd || cfg.codingCmd;
|
|
309
|
+
const parts = cmd.split(" ").filter(Boolean);
|
|
310
|
+
console.log(` chat → running \`${cmd}\` (output appears when it finishes)…`);
|
|
311
|
+
|
|
312
|
+
// If the reply is slow, post ONE "still thinking…" ping and then edit it into
|
|
313
|
+
// the answer (single message, no dead air). Needs edit_message; otherwise we
|
|
314
|
+
// just post the reply when it's ready. A fast reply never trips the timer.
|
|
315
|
+
let thinkingId = null;
|
|
316
|
+
let beatStopped = false;
|
|
317
|
+
let beatBusy = false;
|
|
318
|
+
const beatMs = Math.min(cfg.chatTimeoutMs || 90000, 30000);
|
|
319
|
+
const beat =
|
|
320
|
+
caps.editMessage && beatMs > 0
|
|
321
|
+
? setInterval(async () => {
|
|
322
|
+
if (beatStopped || beatBusy || thinkingId) return;
|
|
323
|
+
beatBusy = true;
|
|
324
|
+
try {
|
|
325
|
+
const r = await tool("post_message", { channelId, parentId: parentId ?? null, body: "Still thinking…" });
|
|
326
|
+
thinkingId = r?.messageId ?? null;
|
|
327
|
+
} catch {
|
|
328
|
+
/* a heartbeat must never break the reply */
|
|
329
|
+
} finally {
|
|
330
|
+
beatBusy = false;
|
|
331
|
+
}
|
|
332
|
+
}, beatMs)
|
|
333
|
+
: null;
|
|
334
|
+
if (beat?.unref) beat.unref();
|
|
335
|
+
|
|
336
|
+
let run;
|
|
337
|
+
try {
|
|
338
|
+
run = await runCli({
|
|
339
|
+
cmd: parts[0],
|
|
340
|
+
args: [...parts.slice(1), prompt],
|
|
341
|
+
timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
|
|
342
|
+
label: "thinking",
|
|
343
|
+
signal,
|
|
344
|
+
});
|
|
345
|
+
} finally {
|
|
346
|
+
beatStopped = true;
|
|
347
|
+
if (beat) clearInterval(beat);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Deliver the final text: edit the "still thinking…" ping if we posted one, else
|
|
351
|
+
// post fresh. Keeps a slow reply to a single, in-place message.
|
|
352
|
+
const deliver = (body) =>
|
|
353
|
+
thinkingId
|
|
354
|
+
? tool("edit_message", { messageId: thinkingId, body }).catch(() =>
|
|
355
|
+
tool("post_message", { channelId, parentId: parentId ?? null, body }),
|
|
356
|
+
)
|
|
357
|
+
: tool("post_message", { channelId, parentId: parentId ?? null, body });
|
|
358
|
+
|
|
359
|
+
if (run.aborted || signal?.aborted) {
|
|
360
|
+
await deliver("Stopped.");
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
227
363
|
const reply = (run.stdout || "").trim();
|
|
228
|
-
if (run.error) console.log(` ! ${parts[0]}
|
|
364
|
+
if (run.error) console.log(` ! ${parts[0]}: ${run.error.message}`);
|
|
229
365
|
console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
366
|
+
// Honest fallback: a timeout is NOT a missing binary. Only a real spawn failure
|
|
367
|
+
// (ENOENT) means the command isn't on PATH.
|
|
368
|
+
let body = reply;
|
|
369
|
+
if (!body) {
|
|
370
|
+
if (run.error?.code === "ENOENT") {
|
|
371
|
+
body = `(my chat command \`${cmd}\` isn't installed or on PATH.)`;
|
|
372
|
+
} else if (run.error) {
|
|
373
|
+
body = `Still thinking on this — it's taking longer than usual. I'll follow up shortly.`;
|
|
374
|
+
} else {
|
|
375
|
+
body = `(I didn't get a reply out of \`${cmd}\` that time — mention me again?)`;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
await deliver(body);
|
|
235
379
|
}
|
|
236
380
|
|
|
237
|
-
/** Handle one task. cfg/deps injectable for tests.
|
|
238
|
-
|
|
381
|
+
/** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
|
|
382
|
+
* cancels an in-flight run — the queue fires it when a human says "stop". */
|
|
383
|
+
export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
|
|
239
384
|
const deps = depsOverride || defaultDeps();
|
|
240
385
|
const git = deps.git;
|
|
386
|
+
const signal = opts.signal;
|
|
241
387
|
// When the mention was a thread reply, keep the whole exchange in that thread.
|
|
242
388
|
const parentId = message.parentId ?? null;
|
|
243
389
|
|
|
244
390
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
245
391
|
const repoLink = links.find((l) => l.repo_full_name);
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
392
|
+
// Workspace memory ("soul") — shared project context the agent should know.
|
|
393
|
+
const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
|
|
394
|
+
memory: null,
|
|
395
|
+
}));
|
|
396
|
+
// Chat vs code. A clear action verb → code. With a repo linked, a bare
|
|
397
|
+
// affirmation ("go", "ship it") that follows a plan the agent just proposed
|
|
398
|
+
// ALSO means "execute it" → code path. Otherwise (greeting/question/no repo) →
|
|
399
|
+
// a conversational reply. We fetch context once here and hand it to the chat
|
|
400
|
+
// reply so it isn't fetched twice.
|
|
401
|
+
let isCode = looksLikeCodeTask(message.body);
|
|
402
|
+
let context = null;
|
|
403
|
+
if (!isCode && repoLink && isAffirmation(message.body)) {
|
|
404
|
+
context = await fetchContext({ channelId, tool, parentId });
|
|
405
|
+
if (agentProposedWork(context.rows, me?.agentName)) isCode = true;
|
|
406
|
+
}
|
|
407
|
+
if (!repoLink || !isCode) {
|
|
408
|
+
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
|
|
250
409
|
return { status: "chat" };
|
|
251
410
|
}
|
|
252
411
|
const repoFullName = repoLink.repo_full_name;
|
|
@@ -286,6 +445,14 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
286
445
|
return { status: "dirty" };
|
|
287
446
|
}
|
|
288
447
|
|
|
448
|
+
// Remember where we started so cancel/cleanup can switch back explicitly
|
|
449
|
+
// (relying on `git switch -` breaks from a detached HEAD).
|
|
450
|
+
const symref = git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
451
|
+
const startRef =
|
|
452
|
+
symref.status === 0 && symref.stdout.trim()
|
|
453
|
+
? { kind: "branch", ref: symref.stdout.trim() }
|
|
454
|
+
: { kind: "detached", ref: (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() };
|
|
455
|
+
|
|
289
456
|
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
290
457
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
291
458
|
const co = git(repoPath, ["switch", "-c", branch]);
|
|
@@ -298,31 +465,112 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
298
465
|
return { status: "branch-error" };
|
|
299
466
|
}
|
|
300
467
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
});
|
|
468
|
+
// Instant acknowledgement: post a template in <1s so the channel shows life
|
|
469
|
+
// before any model call. If the server supports edit_message and a fast chatCmd
|
|
470
|
+
// is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
|
|
471
|
+
// Best-effort — a slow or failed ack just leaves the template, never blocks.
|
|
472
|
+
const ack = await tool("post_message", { channelId, parentId, body: ackText(repoFullName) });
|
|
473
|
+
const ackId = ack?.messageId ?? null;
|
|
474
|
+
if (ackId && caps.editMessage && cfg.chatCmd) {
|
|
475
|
+
const plan = await proposePlanAck({ task: message.body, repoFullName, cfg, signal }).catch(() => null);
|
|
476
|
+
if (plan && !signal?.aborted) await tool("edit_message", { messageId: ackId, body: plan }).catch(() => {});
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// Long-run progress: post ONE thread reply at the first beat (a visible ping)
|
|
480
|
+
// then EDIT it on later beats — alive without thread spam. Needs edit_message;
|
|
481
|
+
// without it we skip rather than post a fresh message every beat. lastLine
|
|
482
|
+
// carries the CLI's latest output into the beat.
|
|
483
|
+
const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0;
|
|
484
|
+
let lastLine = "";
|
|
485
|
+
let progressId = null; // the thread progress reply, once a beat has posted it
|
|
486
|
+
const startHeartbeat = () => {
|
|
487
|
+
if (!heartbeatOn) return () => {};
|
|
488
|
+
const start = deps.now();
|
|
489
|
+
let stopped = false;
|
|
490
|
+
let busy = false;
|
|
491
|
+
const beat = setInterval(async () => {
|
|
492
|
+
if (stopped || busy) return;
|
|
493
|
+
busy = true;
|
|
494
|
+
const body = buildHeartbeat({ repoFullName, branch, elapsedMs: deps.now() - start, lastLine });
|
|
495
|
+
try {
|
|
496
|
+
if (!progressId) {
|
|
497
|
+
const r = await tool("post_message", { channelId, parentId, body });
|
|
498
|
+
progressId = r?.messageId ?? null;
|
|
499
|
+
} else {
|
|
500
|
+
await tool("edit_message", { messageId: progressId, body });
|
|
501
|
+
}
|
|
502
|
+
} catch {
|
|
503
|
+
/* a heartbeat must never break the run */
|
|
504
|
+
} finally {
|
|
505
|
+
busy = false;
|
|
506
|
+
}
|
|
507
|
+
}, cfg.heartbeatMs);
|
|
508
|
+
if (beat.unref) beat.unref();
|
|
509
|
+
return () => {
|
|
510
|
+
stopped = true;
|
|
511
|
+
clearInterval(beat);
|
|
512
|
+
};
|
|
513
|
+
};
|
|
514
|
+
// Once the run ends, retire the "still working…" progress reply so it doesn't
|
|
515
|
+
// sit there claiming the agent is alive. No-op when no beat ever fired (short
|
|
516
|
+
// run) or without edit_message. Best-effort.
|
|
517
|
+
const finalizeProgress = async (text) => {
|
|
518
|
+
if (progressId && caps.editMessage) {
|
|
519
|
+
await tool("edit_message", { messageId: progressId, body: text }).catch(() => {});
|
|
520
|
+
}
|
|
521
|
+
};
|
|
306
522
|
|
|
307
523
|
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
308
524
|
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
525
|
+
// Workspace memory (the project's soul) is prepended so the coding agent has
|
|
526
|
+
// the shared context before it starts.
|
|
309
527
|
const runAndStage = async (promptText) => {
|
|
310
528
|
console.log(
|
|
311
529
|
` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
|
|
312
530
|
);
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
531
|
+
const stopHeartbeat = startHeartbeat();
|
|
532
|
+
let run;
|
|
533
|
+
try {
|
|
534
|
+
run = await runCli({
|
|
535
|
+
cmd: parts[0],
|
|
536
|
+
args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
|
|
537
|
+
cwd: repoPath,
|
|
538
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
539
|
+
label: "coding",
|
|
540
|
+
signal,
|
|
541
|
+
onData: (c) => {
|
|
542
|
+
const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
|
|
543
|
+
if (lines.length) lastLine = lines[lines.length - 1];
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
} finally {
|
|
547
|
+
stopHeartbeat();
|
|
548
|
+
}
|
|
549
|
+
if (run.aborted || signal?.aborted) {
|
|
550
|
+
console.log(" code → cancelled");
|
|
551
|
+
return { aborted: true };
|
|
552
|
+
}
|
|
320
553
|
if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
|
|
321
554
|
git(repoPath, ["add", "-A"]);
|
|
322
555
|
const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
|
|
323
556
|
if (!diff.trim()) {
|
|
557
|
+
// An empty diff after a FAILED run (timeout / non-zero exit / spawn error)
|
|
558
|
+
// is not "no changes were needed" — be honest about it instead of claiming
|
|
559
|
+
// the agent decided nothing was required.
|
|
560
|
+
const failed = Boolean(run.error) || run.status !== 0;
|
|
561
|
+
if (failed) {
|
|
562
|
+
console.log(` code → run failed, no diff (status=${run.status}, err=${run.error?.message || "—"})`);
|
|
563
|
+
return {
|
|
564
|
+
empty: true,
|
|
565
|
+
failed: true,
|
|
566
|
+
errCode: run.error?.code || null,
|
|
567
|
+
errMessage: run.error?.message || null,
|
|
568
|
+
status: run.status,
|
|
569
|
+
stderrTail: oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 300),
|
|
570
|
+
};
|
|
571
|
+
}
|
|
324
572
|
console.log(" code → no changes produced");
|
|
325
|
-
return { empty: true };
|
|
573
|
+
return { empty: true, failed: false };
|
|
326
574
|
}
|
|
327
575
|
console.log(" code → diff captured");
|
|
328
576
|
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
@@ -347,22 +595,73 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
347
595
|
return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
|
|
348
596
|
};
|
|
349
597
|
|
|
350
|
-
|
|
351
|
-
|
|
598
|
+
// Cancelled mid-run: discard whatever the CLI wrote + the branch, so a stopped
|
|
599
|
+
// run never leaves a dirty tree that blocks the next job (handleTask refuses on
|
|
600
|
+
// a dirty tree). The CLI child is dead by the time we get here (runCli resolves
|
|
601
|
+
// on the process close), so a leftover .git/index.lock is stale — clear it so
|
|
602
|
+
// reset/clean can run. Returns whether the tree ended up clean.
|
|
603
|
+
const discardBranch = () => {
|
|
604
|
+
try {
|
|
605
|
+
rmSync(join(repoPath, ".git", "index.lock"), { force: true });
|
|
606
|
+
} catch {
|
|
607
|
+
/* nothing to clear */
|
|
608
|
+
}
|
|
609
|
+
git(repoPath, ["reset", "--hard", "HEAD"]);
|
|
610
|
+
git(repoPath, ["clean", "-fd"]);
|
|
611
|
+
if (startRef.ref) {
|
|
612
|
+
git(repoPath, startRef.kind === "branch" ? ["switch", "--force", startRef.ref] : ["switch", "--detach", startRef.ref]);
|
|
613
|
+
} else {
|
|
614
|
+
git(repoPath, ["switch", "-"]);
|
|
615
|
+
}
|
|
616
|
+
git(repoPath, ["branch", "-D", branch]);
|
|
617
|
+
return (git(repoPath, ["status", "--porcelain"]).stdout || "").trim() === "";
|
|
618
|
+
};
|
|
619
|
+
|
|
620
|
+
// Post the right cancel message: honest about whether cleanup actually worked.
|
|
621
|
+
const postStopped = async () => {
|
|
622
|
+
const clean = discardBranch();
|
|
352
623
|
await tool("post_message", {
|
|
353
624
|
channelId,
|
|
354
625
|
parentId,
|
|
355
|
-
body:
|
|
626
|
+
body: clean
|
|
627
|
+
? `Stopped — discarded \`${branch}\`.`
|
|
628
|
+
: `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
|
|
356
629
|
});
|
|
630
|
+
return { status: "cancelled", branch };
|
|
631
|
+
};
|
|
632
|
+
|
|
633
|
+
const staged = await runAndStage(message.body);
|
|
634
|
+
if (staged.aborted) return await postStopped();
|
|
635
|
+
if (staged.empty) {
|
|
636
|
+
let body;
|
|
637
|
+
if (staged.failed && staged.errCode === "ENOENT") {
|
|
638
|
+
body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH? Cleaning up \`${branch}\`.`;
|
|
639
|
+
} else if (staged.failed) {
|
|
640
|
+
const why = staged.errMessage
|
|
641
|
+
? ` (${staged.errMessage})`
|
|
642
|
+
: staged.status != null
|
|
643
|
+
? ` (the CLI exited ${staged.status})`
|
|
644
|
+
: "";
|
|
645
|
+
const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
|
|
646
|
+
body = `The run didn't finish${why}${tail}. Cleaning up \`${branch}\` — mention me to retry.`;
|
|
647
|
+
} else {
|
|
648
|
+
body = `No changes were produced. Cleaning up \`${branch}\`.`;
|
|
649
|
+
}
|
|
650
|
+
await tool("post_message", { channelId, parentId, body });
|
|
651
|
+
await finalizeProgress(
|
|
652
|
+
staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
|
|
653
|
+
);
|
|
357
654
|
git(repoPath, ["switch", "-"]);
|
|
358
655
|
git(repoPath, ["branch", "-D", branch]);
|
|
359
|
-
return { status: "no-changes" };
|
|
656
|
+
return { status: staged.failed ? "run-failed" : "no-changes" };
|
|
360
657
|
}
|
|
361
658
|
|
|
362
659
|
// Bias to action (default): commit, push, and open a PR for review now — the
|
|
363
660
|
// report card's Approve merges it. `gate:true` keeps the older
|
|
364
661
|
// propose-a-diff-and-wait flow for users who want approve-before-push.
|
|
365
662
|
if (!cfg.gate) {
|
|
663
|
+
// A "stop" that lands between the run finishing and the ship must still win.
|
|
664
|
+
if (signal?.aborted) return await postStopped();
|
|
366
665
|
const result = await applyDecision({
|
|
367
666
|
decision: { kind: "approved" },
|
|
368
667
|
repoPath,
|
|
@@ -375,11 +674,14 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
375
674
|
deps,
|
|
376
675
|
parentId,
|
|
377
676
|
});
|
|
677
|
+
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
378
678
|
return { ...result, stat: staged.stat };
|
|
379
679
|
}
|
|
380
680
|
|
|
681
|
+
await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
|
|
381
682
|
let proposal = await postProposal(staged);
|
|
382
|
-
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
|
|
683
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
684
|
+
if (decision.kind === "cancelled") return await postStopped();
|
|
383
685
|
const maxRounds = cfg.maxRounds || 3;
|
|
384
686
|
let round = 1;
|
|
385
687
|
while (decision.kind === "changes" && round < maxRounds) {
|
|
@@ -391,6 +693,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
391
693
|
const refined = await runAndStage(
|
|
392
694
|
`${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
|
|
393
695
|
);
|
|
696
|
+
if (refined.aborted) return await postStopped();
|
|
394
697
|
if (refined.empty) {
|
|
395
698
|
await tool("post_message", {
|
|
396
699
|
channelId,
|
|
@@ -400,10 +703,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
400
703
|
break;
|
|
401
704
|
}
|
|
402
705
|
proposal = await postProposal(refined);
|
|
403
|
-
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
|
|
706
|
+
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
707
|
+
if (decision.kind === "cancelled") return await postStopped();
|
|
404
708
|
round += 1;
|
|
405
709
|
}
|
|
406
710
|
|
|
711
|
+
// A stop during the final wait, too — don't ship a cancelled run.
|
|
712
|
+
if (signal?.aborted) return await postStopped();
|
|
407
713
|
const result = await applyDecision({
|
|
408
714
|
decision,
|
|
409
715
|
repoPath,
|
|
@@ -416,5 +722,6 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
416
722
|
deps,
|
|
417
723
|
parentId,
|
|
418
724
|
});
|
|
725
|
+
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
419
726
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
|
|
420
727
|
}
|
package/src/queue.mjs
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// Per-agent work queue + cancel/dedupe intent helpers. Pure + dependency-free
|
|
2
|
+
// so it stands alone and is unit-testable. (Mirror of scripts/lib/queue.mjs —
|
|
3
|
+
// keep the two equivalent.)
|
|
4
|
+
//
|
|
5
|
+
// Why: the poll loop used to `await` each multi-minute run inline, so the daemon
|
|
6
|
+
// stopped fetching while it worked — rapid mentions from several people queued up
|
|
7
|
+
// invisibly (or were dropped), and there was no way to interrupt. This queue lets
|
|
8
|
+
// the loop keep polling: it enqueues work, the queue runs it one at a time, and a
|
|
9
|
+
// plain-text "stop" picked up on the same poll can cancel the active run.
|
|
10
|
+
|
|
11
|
+
/** Normalize task text for dedupe: lowercase, strip @mentions, collapse space. */
|
|
12
|
+
export function normalizeTask(text) {
|
|
13
|
+
return String(text || "")
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/@[a-z0-9-]+/g, " ")
|
|
16
|
+
.replace(/\s+/g, " ")
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Burst-dedupe key: same requester + same thread (or channel) + same normalized
|
|
21
|
+
* ask. A burst of identical pings from ONE person collapses to one job (one ask
|
|
22
|
+
* never spawns N branches), but two different people asking the same thing stay
|
|
23
|
+
* distinct. `author` is a display name (not a stable id), so same-named users can
|
|
24
|
+
* still collide — acceptable until the mention payload carries a user id. */
|
|
25
|
+
export function dedupeKey(message, channelId) {
|
|
26
|
+
const scope = (message && message.parentId) || channelId || "";
|
|
27
|
+
const who = (message && message.author) || "";
|
|
28
|
+
return `${scope}::${who}::${normalizeTask(message && message.body)}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CANCEL_LEADING =
|
|
32
|
+
/^(stop|cancel|abort|halt|nvm|never ?mind|hold on|forget (it|that)|scrap (it|that)|stop (it|that|working))\b/;
|
|
33
|
+
const CANCEL_WHOLE = /^(stop|cancel|abort|halt|nvm|never ?mind|forget it)[.!]?$/;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Is this message a "stop what you're doing" intent — not a task that merely
|
|
37
|
+
* contains the word "stop" (e.g. "stop the navbar from overflowing")? Tight by
|
|
38
|
+
* design: a whole-message stop/cancel, or a short message led by a cancel verb.
|
|
39
|
+
* Callers MUST also gate on "is something actually running" so a stray "stop"
|
|
40
|
+
* with nothing active stays ordinary chat.
|
|
41
|
+
*/
|
|
42
|
+
export function looksLikeCancel(text) {
|
|
43
|
+
const t = normalizeTask(text);
|
|
44
|
+
if (!t) return false;
|
|
45
|
+
if (CANCEL_WHOLE.test(t)) return true;
|
|
46
|
+
if (t.split(" ").length <= 4 && CANCEL_LEADING.test(t)) return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* In-process FIFO worker. `runJob(job, { signal })` is awaited one at a time
|
|
52
|
+
* (concurrency 1 — parallel CLI runs on one checkout would collide on git state).
|
|
53
|
+
*
|
|
54
|
+
* - enqueue(job) → { accepted, deduped, position, startedImmediately }. `job.key`
|
|
55
|
+
* (optional) dedupes against the active + queued jobs.
|
|
56
|
+
* - cancelActive(reason) aborts ONLY the in-flight job's AbortSignal; queued jobs
|
|
57
|
+
* are untouched.
|
|
58
|
+
* - idle() resolves when nothing is active or queued (used to drain on --once).
|
|
59
|
+
*/
|
|
60
|
+
/**
|
|
61
|
+
* @param {{
|
|
62
|
+
* runJob: (job: any, ctx: { signal: AbortSignal }) => Promise<void> | void,
|
|
63
|
+
* concurrency?: number,
|
|
64
|
+
* }} [opts]
|
|
65
|
+
*/
|
|
66
|
+
export function createQueue(opts = {}) {
|
|
67
|
+
const { runJob, concurrency = 1 } = opts;
|
|
68
|
+
void concurrency; // single-slot in v1; documented in the ticket.
|
|
69
|
+
const queued = [];
|
|
70
|
+
let active = null; // { job, controller }
|
|
71
|
+
let draining = false;
|
|
72
|
+
let idleResolvers = [];
|
|
73
|
+
|
|
74
|
+
function has(key) {
|
|
75
|
+
if (key == null) return false;
|
|
76
|
+
// A cancelled job is on its way out (it stays in `active` until its abort
|
|
77
|
+
// tears the run down), so it must NOT dedupe-block a re-ask of the same thing
|
|
78
|
+
// — otherwise "stop" + re-issue the same task gets silently swallowed.
|
|
79
|
+
if (active && !active.cancelled && active.job.key === key) return true;
|
|
80
|
+
return queued.some((j) => j.key === key);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function drain() {
|
|
84
|
+
if (draining) return;
|
|
85
|
+
draining = true;
|
|
86
|
+
while (queued.length) {
|
|
87
|
+
const job = queued.shift();
|
|
88
|
+
const controller = new AbortController();
|
|
89
|
+
active = { job, controller };
|
|
90
|
+
try {
|
|
91
|
+
await runJob(job, { signal: controller.signal });
|
|
92
|
+
} catch {
|
|
93
|
+
// runJob is expected to handle its own errors; never break the loop.
|
|
94
|
+
}
|
|
95
|
+
active = null;
|
|
96
|
+
}
|
|
97
|
+
draining = false;
|
|
98
|
+
const resolvers = idleResolvers;
|
|
99
|
+
idleResolvers = [];
|
|
100
|
+
for (const r of resolvers) r();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function enqueue(job) {
|
|
104
|
+
if (job && job.key != null && has(job.key)) {
|
|
105
|
+
return { accepted: false, deduped: true, position: 0, startedImmediately: false };
|
|
106
|
+
}
|
|
107
|
+
const startedImmediately = !active && queued.length === 0;
|
|
108
|
+
queued.push(job);
|
|
109
|
+
const position = queued.length + (active ? 1 : 0); // 1-based, including active
|
|
110
|
+
void drain();
|
|
111
|
+
return { accepted: true, deduped: false, position, startedImmediately };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cancelActive(reason) {
|
|
115
|
+
if (!active) return false;
|
|
116
|
+
active.cancelled = true; // stop it from dedupe-blocking a same-key re-ask
|
|
117
|
+
active.controller.abort(reason || "cancelled");
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function size() {
|
|
122
|
+
return queued.length;
|
|
123
|
+
}
|
|
124
|
+
function activeCount() {
|
|
125
|
+
return active ? 1 : 0;
|
|
126
|
+
}
|
|
127
|
+
function idle() {
|
|
128
|
+
if (!active && queued.length === 0) return Promise.resolve();
|
|
129
|
+
return new Promise((r) => idleResolvers.push(r));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { enqueue, cancelActive, has, size, active: activeCount, idle };
|
|
133
|
+
}
|
package/src/run.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { makeClient } from "./mcp.mjs";
|
|
6
6
|
import { mentionHandle } from "./daemon.mjs";
|
|
7
7
|
import { handleTask } from "./handler.mjs";
|
|
8
|
+
import { createQueue, looksLikeCancel, dedupeKey } from "./queue.mjs";
|
|
9
|
+
import { reloadConfig } from "./config.mjs";
|
|
8
10
|
|
|
9
11
|
export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
10
12
|
if (!cfg.token) {
|
|
@@ -27,6 +29,9 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
27
29
|
const since = cfg.backfill ? 0 : Date.now();
|
|
28
30
|
const toolNames = await listToolNames();
|
|
29
31
|
const useMentions = !cfg.channelId && toolNames.includes("list_mentions");
|
|
32
|
+
// Capabilities of THIS server, so the handler degrades gracefully on older
|
|
33
|
+
// deploys (e.g. no edit_message → no live heartbeat, rather than erroring).
|
|
34
|
+
const caps = { editMessage: toolNames.includes("edit_message") };
|
|
30
35
|
|
|
31
36
|
let channelIds = [];
|
|
32
37
|
if (!useMentions) {
|
|
@@ -38,13 +43,66 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
38
43
|
const seen = new Set();
|
|
39
44
|
const cursor = { value: since ? new Date(since).toISOString() : null };
|
|
40
45
|
|
|
46
|
+
// Live config: re-read between polls so model/permission/codingCmd edits to
|
|
47
|
+
// hilos-agent.json take effect without a restart. Identity stays pinned.
|
|
48
|
+
let liveCfg = cfg;
|
|
49
|
+
|
|
50
|
+
// One-at-a-time worker so the poll loop NEVER blocks on a multi-minute run.
|
|
51
|
+
// The loop only fetches + enqueues; the queue runs jobs in order. This is what
|
|
52
|
+
// lets a "stop" (and new mentions) be picked up while a task is still running.
|
|
53
|
+
const queue = createQueue({
|
|
54
|
+
concurrency: cfg.queueConcurrency || 1,
|
|
55
|
+
runJob: (job, { signal }) => safeHandle(job.message, job.channelId, signal),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
async function safeHandle(message, channelId, signal) {
|
|
59
|
+
try {
|
|
60
|
+
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
61
|
+
// liveCfg so a job uses the latest model/permission/codingCmd at run time.
|
|
62
|
+
await handler({ message, channelId, tool, me, caps }, liveCfg, undefined, { signal });
|
|
63
|
+
} catch (e) {
|
|
64
|
+
log.error(`handler error: ${e.message}`);
|
|
65
|
+
await tool("post_message", {
|
|
66
|
+
channelId,
|
|
67
|
+
body: `Hit an error working on that: ${e.message}`,
|
|
68
|
+
}).catch(() => {});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Route one fresh mention: a "stop" cancels the active run; anything else is
|
|
73
|
+
// enqueued (deduped against an identical in-flight/queued ask), with an ack
|
|
74
|
+
// when it lands behind work already in progress.
|
|
75
|
+
async function intake(m, channelId) {
|
|
76
|
+
if (looksLikeCancel(m.body) && (queue.active() || queue.size())) {
|
|
77
|
+
queue.cancelActive("user asked to stop");
|
|
78
|
+
await tool("post_message", {
|
|
79
|
+
channelId,
|
|
80
|
+
parentId: m.parentId ?? null,
|
|
81
|
+
body: "Stopping the current task.",
|
|
82
|
+
}).catch(() => {});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const r = queue.enqueue({ message: m, channelId, key: dedupeKey(m, channelId) });
|
|
86
|
+
if (r.deduped) {
|
|
87
|
+
log.log(" (deduped a repeat of an in-flight/queued ask)");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (liveCfg.queueAcks && !r.startedImmediately) {
|
|
91
|
+
await tool("post_message", {
|
|
92
|
+
channelId,
|
|
93
|
+
parentId: m.parentId ?? null,
|
|
94
|
+
body: "Got it — queued behind the current task.",
|
|
95
|
+
}).catch(() => {});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
41
99
|
async function passViaMentions() {
|
|
42
100
|
const out = await tool("list_mentions", cursor.value ? { since: cursor.value } : {});
|
|
43
101
|
const mentions = (out?.mentions ?? []).slice().reverse();
|
|
44
102
|
for (const m of mentions) {
|
|
45
103
|
if (seen.has(m.id)) continue;
|
|
46
104
|
seen.add(m.id);
|
|
47
|
-
await
|
|
105
|
+
await intake(m, m.channelId);
|
|
48
106
|
// Compare numerically — created_at formats differ (Z vs +00:00 offset),
|
|
49
107
|
// so a lexicographic string compare can fail to advance the cursor.
|
|
50
108
|
if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
|
|
@@ -61,31 +119,32 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
61
119
|
seen.add(m.id);
|
|
62
120
|
if (m.author === me.agentName) continue;
|
|
63
121
|
const isMention = new RegExp(`@${me.handle}\\b`, "i").test(m.body || "");
|
|
64
|
-
if (isMention && new Date(m.created_at).getTime() >= since) await
|
|
122
|
+
if (isMention && new Date(m.created_at).getTime() >= since) await intake(m, channelId);
|
|
65
123
|
}
|
|
66
124
|
}
|
|
67
125
|
}
|
|
68
126
|
|
|
69
|
-
|
|
127
|
+
do {
|
|
128
|
+
// Pick up live edits to hilos-agent.json (model/permission/codingCmd/etc.)
|
|
129
|
+
// before each pass. Guarded: a bad file leaves liveCfg untouched.
|
|
70
130
|
try {
|
|
71
|
-
|
|
72
|
-
|
|
131
|
+
const next = reloadConfig(liveCfg);
|
|
132
|
+
if (next.codingCmd !== liveCfg.codingCmd) log.log(`daemon: coding command → ${next.codingCmd}`);
|
|
133
|
+
liveCfg = next;
|
|
73
134
|
} catch (e) {
|
|
74
|
-
log.error(`
|
|
75
|
-
await tool("post_message", {
|
|
76
|
-
channelId,
|
|
77
|
-
body: `Hit an error working on that: ${e.message}`,
|
|
78
|
-
}).catch(() => {});
|
|
135
|
+
log.error(`config reload error (keeping previous): ${e.message}`);
|
|
79
136
|
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
do {
|
|
83
137
|
try {
|
|
84
138
|
if (useMentions) await passViaMentions();
|
|
85
139
|
else await passViaScan();
|
|
86
140
|
} catch (e) {
|
|
87
141
|
log.error(`poll error: ${e.message}`);
|
|
88
142
|
}
|
|
89
|
-
|
|
90
|
-
|
|
143
|
+
// --once is for cron-style single passes: drain the queued work before exit.
|
|
144
|
+
if (cfg.once) {
|
|
145
|
+
await queue.idle();
|
|
146
|
+
break;
|
|
147
|
+
}
|
|
148
|
+
await new Promise((r) => setTimeout(r, liveCfg.pollMs));
|
|
149
|
+
} while (true);
|
|
91
150
|
}
|