hilos-agent 0.1.5 → 0.1.7
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 +53 -19
- package/package.json +2 -2
- package/src/cli.mjs +50 -3
- package/src/config.mjs +14 -3
- package/src/handler.mjs +196 -40
- package/src/mcp.mjs +15 -1
- package/src/queue.mjs +133 -0
- package/src/run.mjs +58 -17
package/README.md
CHANGED
|
@@ -4,21 +4,24 @@ Run **your own** coding agent — Claude Code, Codex, Cursor, or any command —
|
|
|
4
4
|
an autonomous teammate inside a [hilos](https://hilos.sh) channel.
|
|
5
5
|
|
|
6
6
|
It connects to hilos over MCP, watches for `@mentions` of your agent in a
|
|
7
|
-
git-linked channel, runs your coding agent in a
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
only relays messages.
|
|
7
|
+
git-linked channel (including thread replies), runs your coding agent in a
|
|
8
|
+
**local** checkout, and — by default — **opens a PR for review**. Your code and
|
|
9
|
+
your git/`gh` credentials never leave your machine — hilos only relays messages.
|
|
11
10
|
|
|
12
11
|
```
|
|
13
12
|
hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
|
|
14
13
|
human: "@scout fix the navbar overflow"
|
|
15
|
-
agent: branches, runs your coding CLI,
|
|
16
|
-
agent: posts a
|
|
17
|
-
human: Approve ▶
|
|
18
|
-
Reject ▶
|
|
19
|
-
Changes ▶ agent
|
|
14
|
+
agent: branches, runs your coding CLI, commits + pushes, opens a PR
|
|
15
|
+
agent: posts a report card with the PR link
|
|
16
|
+
human: Approve ▶ hilos merges the PR
|
|
17
|
+
Reject ▶ hilos closes the PR
|
|
18
|
+
Changes ▶ agent re-works with your note
|
|
20
19
|
```
|
|
21
20
|
|
|
21
|
+
Prefer **approve-before-push**? Set `"gate": true` — the agent then posts the
|
|
22
|
+
proposed diff as a card and pushes only after you Approve (nothing leaves your
|
|
23
|
+
machine until then).
|
|
24
|
+
|
|
22
25
|
## Quick start
|
|
23
26
|
|
|
24
27
|
In hilos: open your agent's profile → **Connect** → copy the
|
|
@@ -39,9 +42,9 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
39
42
|
"url": "https://hilos.sh/api/mcp",
|
|
40
43
|
"token": "mgo_…",
|
|
41
44
|
"repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
|
|
42
|
-
"codingCmd": "claude -p",
|
|
45
|
+
"codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent", any command
|
|
43
46
|
"defaultBranch": "main",
|
|
44
|
-
"gate":
|
|
47
|
+
"gate": false // default: open a PR directly. true = approve-before-push
|
|
45
48
|
}
|
|
46
49
|
```
|
|
47
50
|
|
|
@@ -57,18 +60,49 @@ hilos-agent --channel <id> # scope to one channel
|
|
|
57
60
|
`repos`. No mapping → the agent says so and stops.
|
|
58
61
|
- **Run** — it branches off `defaultBranch` (refuses a dirty tree), runs
|
|
59
62
|
`codingCmd` with the task, and stages the result.
|
|
60
|
-
- **
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
- **Open a PR** (default) — it commits, pushes with *your* `git`/`gh`, opens a PR,
|
|
64
|
+
and posts a report card with the link. Review on the card: **Approve** merges,
|
|
65
|
+
**Reject** closes, **Request changes** re-works.
|
|
66
|
+
- **Approve-before-push** (`gate:true`) — instead, it posts the staged diff as a
|
|
67
|
+
card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
|
|
68
|
+
discards the branch, **Request changes** re-runs with your note (bounded rounds).
|
|
69
|
+
|
|
70
|
+
## Permissions / autonomy
|
|
71
|
+
|
|
72
|
+
`codingCmd` decides how much the coding agent can do on its own. Three levels,
|
|
73
|
+
safest first:
|
|
74
|
+
|
|
75
|
+
- **`--permission-mode acceptEdits` (default).** The agent edits files without
|
|
76
|
+
prompting, but in headless `claude -p` a step that needs bash — run the tests,
|
|
77
|
+
install a dep — has no interactive prompt to grant, so the task can **stall**.
|
|
78
|
+
Good when the work is edit-only; frustrating for anything that needs to run
|
|
79
|
+
commands.
|
|
80
|
+
- **`--dangerously-skip-permissions` (recommended for independent agents).** Full
|
|
81
|
+
autonomy: the agent can run the tests, install deps, and finish hands-off.
|
|
82
|
+
Caution: it can run **any** command in the repo you point it at — only use it
|
|
83
|
+
on a repo and machine where that's acceptable. This is the option to pick if
|
|
84
|
+
you want the agent to actually work on its own.
|
|
85
|
+
|
|
86
|
+
```jsonc
|
|
87
|
+
"codingCmd": "claude -p --dangerously-skip-permissions"
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
- **Approve-before-push (`gate:true`), most cautious.** Independent of the two
|
|
91
|
+
above — the agent still runs locally, but posts the proposed diff as a card and
|
|
92
|
+
pushes only after you Approve. Pair it with either permission mode.
|
|
93
|
+
|
|
94
|
+
The default stays `acceptEdits`. Reach for `--dangerously-skip-permissions` when
|
|
95
|
+
you want a truly hands-off teammate, and keep `gate:true` if you'd rather review
|
|
96
|
+
before anything is pushed.
|
|
65
97
|
|
|
66
98
|
## Security
|
|
67
99
|
|
|
68
100
|
The daemon runs a coding agent that can execute code in your repo — exactly as if
|
|
69
|
-
you ran it in your terminal
|
|
70
|
-
|
|
71
|
-
|
|
101
|
+
you ran it in your terminal — and uses *your* local `git`/`gh` to push. By default
|
|
102
|
+
it opens a PR (nothing is force-merged; you review the PR, and merge/close run via
|
|
103
|
+
hilos's GitHub App only for workspace owners/admins). Want a human checkpoint
|
|
104
|
+
before anything is pushed? Set `"gate": true`. Keep your token in the config file
|
|
105
|
+
or `HILOS_TOKEN`, never in shared shell history.
|
|
72
106
|
|
|
73
107
|
## Flags
|
|
74
108
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions,
|
|
3
|
+
"version": "0.1.7",
|
|
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": {
|
|
7
7
|
"hilos-agent": "bin/hilos-agent.mjs"
|
package/src/cli.mjs
CHANGED
|
@@ -28,6 +28,8 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
|
|
|
28
28
|
* @property {number} [heartbeatMs] - heartbeat interval (0 = no heartbeat)
|
|
29
29
|
* @property {() => number} [now] - clock, injectable for tests
|
|
30
30
|
* @property {{ log: (m: string) => void }} [log] - logger, injectable for tests
|
|
31
|
+
* @property {AbortSignal} [signal] - abort to cancel the run (kills the child's
|
|
32
|
+
* whole process group: SIGTERM, then SIGKILL after a short grace)
|
|
31
33
|
*/
|
|
32
34
|
|
|
33
35
|
/**
|
|
@@ -50,11 +52,20 @@ export function runCli(opts) {
|
|
|
50
52
|
heartbeatMs = 15000,
|
|
51
53
|
now = Date.now,
|
|
52
54
|
log = console,
|
|
55
|
+
signal,
|
|
53
56
|
} = opts || {};
|
|
54
57
|
return new Promise((resolve) => {
|
|
58
|
+
// Already cancelled before we even start.
|
|
59
|
+
if (signal?.aborted) {
|
|
60
|
+
resolve({ status: null, stdout: "", stderr: "", aborted: true, error: new Error("cancelled") });
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
55
63
|
let child;
|
|
56
64
|
try {
|
|
57
|
-
child
|
|
65
|
+
// detached:true makes the child its own process-group leader, so we can
|
|
66
|
+
// kill the WHOLE tree (claude → node → git …) with process.kill(-pid) on
|
|
67
|
+
// cancel/timeout instead of orphaning its subprocesses.
|
|
68
|
+
child = spawn(cmd, args, { cwd, detached: true });
|
|
58
69
|
} catch (error) {
|
|
59
70
|
resolve({ status: null, stdout: "", stderr: "", error });
|
|
60
71
|
return;
|
|
@@ -80,24 +91,60 @@ export function runCli(opts) {
|
|
|
80
91
|
: null;
|
|
81
92
|
if (beat?.unref) beat.unref();
|
|
82
93
|
|
|
94
|
+
// Kill the child's whole process group; fall back to a bare kill if the
|
|
95
|
+
// group signal isn't permitted (e.g. Windows / unusual setups).
|
|
96
|
+
const killGroup = (sig) => {
|
|
97
|
+
if (child.pid == null) return; // never spawned a pid → nothing to kill
|
|
98
|
+
try {
|
|
99
|
+
process.kill(-child.pid, sig);
|
|
100
|
+
} catch {
|
|
101
|
+
try {
|
|
102
|
+
child.kill(sig);
|
|
103
|
+
} catch {
|
|
104
|
+
/* already gone */
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
83
109
|
let timedOut = false;
|
|
84
110
|
const timer =
|
|
85
111
|
timeoutMs > 0
|
|
86
112
|
? setTimeout(() => {
|
|
87
113
|
timedOut = true;
|
|
88
|
-
|
|
114
|
+
killGroup("SIGKILL");
|
|
89
115
|
}, timeoutMs)
|
|
90
116
|
: null;
|
|
91
117
|
if (timer?.unref) timer.unref();
|
|
92
118
|
|
|
119
|
+
// Cancel via AbortSignal: SIGTERM the group, then SIGKILL after a grace so a
|
|
120
|
+
// well-behaved CLI can clean up but a stuck one still dies.
|
|
121
|
+
let aborted = false;
|
|
122
|
+
let graceTimer = null;
|
|
123
|
+
const onAbort = () => {
|
|
124
|
+
aborted = true;
|
|
125
|
+
killGroup("SIGTERM");
|
|
126
|
+
graceTimer = setTimeout(() => killGroup("SIGKILL"), 3000);
|
|
127
|
+
if (graceTimer?.unref) graceTimer.unref();
|
|
128
|
+
};
|
|
129
|
+
if (signal) signal.addEventListener("abort", onAbort, { once: true });
|
|
130
|
+
|
|
93
131
|
const finish = (status, error) => {
|
|
94
132
|
if (beat) clearInterval(beat);
|
|
95
133
|
if (timer) clearTimeout(timer);
|
|
134
|
+
if (graceTimer) clearTimeout(graceTimer);
|
|
135
|
+
if (signal) signal.removeEventListener("abort", onAbort);
|
|
96
136
|
resolve({
|
|
97
137
|
status,
|
|
98
138
|
stdout,
|
|
99
139
|
stderr,
|
|
100
|
-
|
|
140
|
+
aborted,
|
|
141
|
+
error:
|
|
142
|
+
error ||
|
|
143
|
+
(aborted
|
|
144
|
+
? new Error("cancelled")
|
|
145
|
+
: timedOut
|
|
146
|
+
? new Error(`timed out after ${fmtElapsed(timeoutMs)}`)
|
|
147
|
+
: null),
|
|
101
148
|
});
|
|
102
149
|
};
|
|
103
150
|
child.on("error", (error) => finish(null, error));
|
package/src/config.mjs
CHANGED
|
@@ -43,11 +43,21 @@ const DEFAULTS = {
|
|
|
43
43
|
token: "",
|
|
44
44
|
channelId: "", // when set, watch only this channel (the per-channel override)
|
|
45
45
|
repos: {},
|
|
46
|
-
|
|
46
|
+
// acceptEdits lets the CLI make file edits without prompting (bias to action);
|
|
47
|
+
// it still won't run arbitrary commands. Override in hilos-agent.json if you
|
|
48
|
+
// want a stricter (or `--dangerously-skip-permissions`) command.
|
|
49
|
+
codingCmd: "claude -p --permission-mode acceptEdits",
|
|
47
50
|
defaultBranch: "main",
|
|
48
|
-
|
|
51
|
+
// Bias to action: open a PR directly for review (approve = merge on the card).
|
|
52
|
+
// Set gate:true for the older approve-before-push flow (propose a diff, wait).
|
|
53
|
+
gate: false,
|
|
49
54
|
maxRounds: 3,
|
|
50
55
|
pollMs: 5000,
|
|
56
|
+
// Work queue: run mentions one at a time (concurrency 1 — parallel CLI runs on
|
|
57
|
+
// one checkout would collide on git state). queueAcks posts "queued behind the
|
|
58
|
+
// current task" when a mention lands during a run; set false to keep it quiet.
|
|
59
|
+
queueConcurrency: 1,
|
|
60
|
+
queueAcks: true,
|
|
51
61
|
runTimeoutMs: 600000,
|
|
52
62
|
decisionTimeoutMs: 1800000,
|
|
53
63
|
decisionPollMs: 5000,
|
|
@@ -89,7 +99,8 @@ export function writeStarterConfig(path, partial = {}) {
|
|
|
89
99
|
repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
|
|
90
100
|
codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
|
|
91
101
|
defaultBranch: DEFAULTS.defaultBranch,
|
|
92
|
-
|
|
102
|
+
// false = open a PR directly (bias to action); true = approve-before-push.
|
|
103
|
+
gate: false,
|
|
93
104
|
};
|
|
94
105
|
writeFileSync(target, JSON.stringify(starter, null, 2) + "\n");
|
|
95
106
|
return target;
|
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,
|
|
@@ -41,18 +48,31 @@ function defaultDeps() {
|
|
|
41
48
|
};
|
|
42
49
|
}
|
|
43
50
|
|
|
44
|
-
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
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;
|
|
51
61
|
if (!report) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
62
|
+
// Fallback for servers without get_report. In a thread the report lives
|
|
63
|
+
// under the thread root (read_channel only returns top-level), so scan the
|
|
64
|
+
// thread there; otherwise scan the channel tail.
|
|
65
|
+
let candidates = [];
|
|
66
|
+
if (parentId) {
|
|
67
|
+
const t = await tool("get_thread", { parentId }).catch(() => null);
|
|
68
|
+
candidates = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
|
|
69
|
+
} else {
|
|
70
|
+
const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
|
|
71
|
+
() => ({ messages: [] }),
|
|
72
|
+
);
|
|
73
|
+
candidates = messages;
|
|
74
|
+
}
|
|
75
|
+
const m = candidates.find((x) => x.id === reportMessageId);
|
|
56
76
|
report = m && m.report ? m.report : null;
|
|
57
77
|
}
|
|
58
78
|
const kind = report ? decisionKind(report) : null;
|
|
@@ -62,7 +82,15 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
|
62
82
|
return { kind: "timeout" };
|
|
63
83
|
}
|
|
64
84
|
|
|
65
|
-
|
|
85
|
+
/** Attach a just-opened PR to the channel (best-effort) so its live pill shows. */
|
|
86
|
+
async function linkPrFromUrl({ tool, channelId, url }) {
|
|
87
|
+
if (!url) return;
|
|
88
|
+
const m = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(url);
|
|
89
|
+
if (!m) return;
|
|
90
|
+
await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
66
94
|
const tag = requesterTag(requester);
|
|
67
95
|
const lead = tag ? `${tag} — ` : "";
|
|
68
96
|
if (decision.kind === "approved") {
|
|
@@ -70,6 +98,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
70
98
|
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
71
99
|
await tool("post_message", {
|
|
72
100
|
channelId,
|
|
101
|
+
parentId,
|
|
73
102
|
body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
|
|
74
103
|
});
|
|
75
104
|
return { status: "nothing-staged", branch };
|
|
@@ -78,6 +107,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
78
107
|
if (commit.status !== 0) {
|
|
79
108
|
await tool("post_message", {
|
|
80
109
|
channelId,
|
|
110
|
+
parentId,
|
|
81
111
|
body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
|
|
82
112
|
});
|
|
83
113
|
return { status: "commit-failed", branch };
|
|
@@ -86,6 +116,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
86
116
|
if (push.status !== 0) {
|
|
87
117
|
await tool("post_message", {
|
|
88
118
|
channelId,
|
|
119
|
+
parentId,
|
|
89
120
|
body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
|
|
90
121
|
});
|
|
91
122
|
return { status: "push-failed", branch };
|
|
@@ -94,6 +125,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
94
125
|
const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
95
126
|
await tool("post_report", {
|
|
96
127
|
channelId,
|
|
128
|
+
parentId,
|
|
97
129
|
title: `Shipped: ${title}`,
|
|
98
130
|
summary:
|
|
99
131
|
pr.ok && pr.url
|
|
@@ -102,6 +134,8 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
102
134
|
prUrl: pr.ok && pr.url ? pr.url : undefined,
|
|
103
135
|
caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
|
|
104
136
|
});
|
|
137
|
+
// Work produced a PR → attach it to the channel so its live pill shows up.
|
|
138
|
+
await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
|
|
105
139
|
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
|
|
106
140
|
}
|
|
107
141
|
|
|
@@ -110,13 +144,14 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
110
144
|
deps.git(repoPath, ["clean", "-fd"]);
|
|
111
145
|
deps.git(repoPath, ["switch", "-"]);
|
|
112
146
|
deps.git(repoPath, ["branch", "-D", branch]);
|
|
113
|
-
await tool("post_message", { channelId, body: `Rejected — discarded \`${branch}\`.` });
|
|
147
|
+
await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
|
|
114
148
|
return { status: "discarded", branch };
|
|
115
149
|
}
|
|
116
150
|
|
|
117
151
|
if (decision.kind === "changes") {
|
|
118
152
|
await tool("post_message", {
|
|
119
153
|
channelId,
|
|
154
|
+
parentId,
|
|
120
155
|
body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
|
|
121
156
|
});
|
|
122
157
|
return { status: "changes", branch, note: decision.note };
|
|
@@ -124,6 +159,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
124
159
|
|
|
125
160
|
await tool("post_message", {
|
|
126
161
|
channelId,
|
|
162
|
+
parentId,
|
|
127
163
|
body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
|
|
128
164
|
});
|
|
129
165
|
return { status: "timeout", branch };
|
|
@@ -162,21 +198,39 @@ export function normalizeRemote(url) {
|
|
|
162
198
|
return m ? m[1] : null;
|
|
163
199
|
}
|
|
164
200
|
|
|
201
|
+
/** A short workspace-memory preamble for a CLI prompt, or "" when there is none. */
|
|
202
|
+
function memoryPreamble(workspaceMemory) {
|
|
203
|
+
const m = (workspaceMemory || "").trim();
|
|
204
|
+
return m ? `Workspace context (the project's soul):\n${m}\n\n` : "";
|
|
205
|
+
}
|
|
206
|
+
|
|
165
207
|
/** Run the configured CLI to produce a chat reply, using recent channel context. */
|
|
166
|
-
async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
208
|
+
async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal }) {
|
|
167
209
|
void message;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
.
|
|
210
|
+
// In a thread, use the thread's own messages as context (and reply there);
|
|
211
|
+
// otherwise the recent channel tail. Threads are where conversations live, so
|
|
212
|
+
// the agent must follow the thread it was pinged in, not the whole channel.
|
|
213
|
+
let transcript;
|
|
214
|
+
if (parentId) {
|
|
215
|
+
const t = await tool("get_thread", { parentId }).catch(() => null);
|
|
216
|
+
const rows = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
|
|
217
|
+
transcript = rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
|
|
218
|
+
} else {
|
|
219
|
+
const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
|
|
220
|
+
messages: [],
|
|
221
|
+
}));
|
|
222
|
+
transcript = messages.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
|
|
223
|
+
}
|
|
175
224
|
const name = me?.agentName || "an assistant";
|
|
225
|
+
// Tell the agent what the room is connected to so it doesn't ask "which repo?".
|
|
226
|
+
const repoLine = repoLink
|
|
227
|
+
? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
|
|
228
|
+
: `If asked to change code, note that a repo isn't linked to this channel yet.`;
|
|
176
229
|
const prompt =
|
|
177
230
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
178
231
|
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
179
|
-
|
|
232
|
+
`${repoLine}\n\n` +
|
|
233
|
+
`${memoryPreamble(workspaceMemory)}` +
|
|
180
234
|
`Conversation so far:\n${transcript}`;
|
|
181
235
|
|
|
182
236
|
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
@@ -186,27 +240,41 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
|
186
240
|
args: [...parts.slice(1), prompt],
|
|
187
241
|
timeoutMs: cfg.runTimeoutMs,
|
|
188
242
|
label: "thinking",
|
|
243
|
+
signal,
|
|
189
244
|
});
|
|
245
|
+
if (run.aborted || signal?.aborted) {
|
|
246
|
+
await tool("post_message", { channelId, parentId: parentId ?? null, body: "Stopped." });
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
190
249
|
const reply = (run.stdout || "").trim();
|
|
191
250
|
if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
|
|
192
251
|
console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
|
|
193
252
|
await tool("post_message", {
|
|
194
253
|
channelId,
|
|
254
|
+
parentId: parentId ?? null,
|
|
195
255
|
body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
|
|
196
256
|
});
|
|
197
257
|
}
|
|
198
258
|
|
|
199
|
-
/** Handle one task. cfg/deps injectable for tests.
|
|
200
|
-
|
|
259
|
+
/** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
|
|
260
|
+
* cancels an in-flight run — the queue fires it when a human says "stop". */
|
|
261
|
+
export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride, opts = {}) {
|
|
201
262
|
const deps = depsOverride || defaultDeps();
|
|
202
263
|
const git = deps.git;
|
|
264
|
+
const signal = opts.signal;
|
|
265
|
+
// When the mention was a thread reply, keep the whole exchange in that thread.
|
|
266
|
+
const parentId = message.parentId ?? null;
|
|
203
267
|
|
|
204
268
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
205
269
|
const repoLink = links.find((l) => l.repo_full_name);
|
|
270
|
+
// Workspace memory ("soul") — shared project context the agent should know.
|
|
271
|
+
const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
|
|
272
|
+
memory: null,
|
|
273
|
+
}));
|
|
206
274
|
// Chat (no repo linked, OR a greeting/question even in a repo channel) →
|
|
207
275
|
// reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
|
|
208
276
|
if (!repoLink || !looksLikeCodeTask(message.body)) {
|
|
209
|
-
await respondConversationally({ message, channelId, tool, me, cfg });
|
|
277
|
+
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal });
|
|
210
278
|
return { status: "chat" };
|
|
211
279
|
}
|
|
212
280
|
const repoFullName = repoLink.repo_full_name;
|
|
@@ -224,6 +292,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
224
292
|
if (!repoPath) {
|
|
225
293
|
await tool("post_message", {
|
|
226
294
|
channelId,
|
|
295
|
+
parentId,
|
|
227
296
|
body:
|
|
228
297
|
`I don't have a local checkout of ${repoFullName}. Either run me from inside that ` +
|
|
229
298
|
`repo, or add it to your hilos-agent.json: "repos": { "${repoFullName}": "/abs/path" }.`,
|
|
@@ -233,23 +302,33 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
233
302
|
|
|
234
303
|
const status = git(repoPath, ["status", "--porcelain"]);
|
|
235
304
|
if (status.status !== 0) {
|
|
236
|
-
await tool("post_message", { channelId, body: `Can't read git status in ${repoPath}.` });
|
|
305
|
+
await tool("post_message", { channelId, parentId, body: `Can't read git status in ${repoPath}.` });
|
|
237
306
|
return { status: "git-error" };
|
|
238
307
|
}
|
|
239
308
|
if (status.stdout.trim()) {
|
|
240
309
|
await tool("post_message", {
|
|
241
310
|
channelId,
|
|
311
|
+
parentId,
|
|
242
312
|
body: `Working tree at ${repoPath} is dirty — commit or stash first.`,
|
|
243
313
|
});
|
|
244
314
|
return { status: "dirty" };
|
|
245
315
|
}
|
|
246
316
|
|
|
317
|
+
// Remember where we started so cancel/cleanup can switch back explicitly
|
|
318
|
+
// (relying on `git switch -` breaks from a detached HEAD).
|
|
319
|
+
const symref = git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
320
|
+
const startRef =
|
|
321
|
+
symref.status === 0 && symref.stdout.trim()
|
|
322
|
+
? { kind: "branch", ref: symref.stdout.trim() }
|
|
323
|
+
: { kind: "detached", ref: (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() };
|
|
324
|
+
|
|
247
325
|
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
248
326
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
249
327
|
const co = git(repoPath, ["switch", "-c", branch]);
|
|
250
328
|
if (co.status !== 0) {
|
|
251
329
|
await tool("post_message", {
|
|
252
330
|
channelId,
|
|
331
|
+
parentId,
|
|
253
332
|
body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
|
|
254
333
|
});
|
|
255
334
|
return { status: "branch-error" };
|
|
@@ -257,21 +336,30 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
257
336
|
|
|
258
337
|
await tool("post_message", {
|
|
259
338
|
channelId,
|
|
339
|
+
parentId,
|
|
260
340
|
body: `On it — working in ${repoFullName} on \`${branch}\`.`,
|
|
261
341
|
});
|
|
262
342
|
|
|
263
343
|
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
264
|
-
|
|
344
|
+
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
345
|
+
// Workspace memory (the project's soul) is prepended so the coding agent has
|
|
346
|
+
// the shared context before it starts.
|
|
347
|
+
const runAndStage = async (promptText) => {
|
|
265
348
|
console.log(
|
|
266
349
|
` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
|
|
267
350
|
);
|
|
268
351
|
const run = await runCli({
|
|
269
352
|
cmd: parts[0],
|
|
270
|
-
args: [...parts.slice(1), promptText],
|
|
353
|
+
args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
|
|
271
354
|
cwd: repoPath,
|
|
272
355
|
timeoutMs: cfg.runTimeoutMs,
|
|
273
356
|
label: "coding",
|
|
357
|
+
signal,
|
|
274
358
|
});
|
|
359
|
+
if (run.aborted || signal?.aborted) {
|
|
360
|
+
console.log(" code → cancelled");
|
|
361
|
+
return { aborted: true };
|
|
362
|
+
}
|
|
275
363
|
if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
|
|
276
364
|
git(repoPath, ["add", "-A"]);
|
|
277
365
|
const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
|
|
@@ -279,28 +367,70 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
279
367
|
console.log(" code → no changes produced");
|
|
280
368
|
return { empty: true };
|
|
281
369
|
}
|
|
282
|
-
console.log(" code → diff captured
|
|
370
|
+
console.log(" code → diff captured");
|
|
283
371
|
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
284
372
|
const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
|
|
373
|
+
return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// Post a proposal card (approve-before-push mode) from a staged change.
|
|
377
|
+
const postProposal = async (staged) => {
|
|
285
378
|
const report = buildProposalReport({
|
|
286
379
|
task: message.body,
|
|
287
380
|
requester: message.author,
|
|
288
381
|
repoFullName,
|
|
289
382
|
branch,
|
|
290
|
-
diffText,
|
|
291
|
-
truncated,
|
|
292
|
-
omittedLines,
|
|
293
|
-
stat,
|
|
294
|
-
runFailed:
|
|
383
|
+
diffText: staged.diffText,
|
|
384
|
+
truncated: staged.truncated,
|
|
385
|
+
omittedLines: staged.omittedLines,
|
|
386
|
+
stat: staged.stat,
|
|
387
|
+
runFailed: staged.runFailed,
|
|
388
|
+
});
|
|
389
|
+
const res = await tool("post_report", { channelId, parentId, ...report });
|
|
390
|
+
return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
|
|
391
|
+
};
|
|
392
|
+
|
|
393
|
+
// Cancelled mid-run: discard whatever the CLI wrote + the branch, so a stopped
|
|
394
|
+
// run never leaves a dirty tree that blocks the next job (handleTask refuses on
|
|
395
|
+
// a dirty tree). The CLI child is dead by the time we get here (runCli resolves
|
|
396
|
+
// on the process close), so a leftover .git/index.lock is stale — clear it so
|
|
397
|
+
// reset/clean can run. Returns whether the tree ended up clean.
|
|
398
|
+
const discardBranch = () => {
|
|
399
|
+
try {
|
|
400
|
+
rmSync(join(repoPath, ".git", "index.lock"), { force: true });
|
|
401
|
+
} catch {
|
|
402
|
+
/* nothing to clear */
|
|
403
|
+
}
|
|
404
|
+
git(repoPath, ["reset", "--hard", "HEAD"]);
|
|
405
|
+
git(repoPath, ["clean", "-fd"]);
|
|
406
|
+
if (startRef.ref) {
|
|
407
|
+
git(repoPath, startRef.kind === "branch" ? ["switch", "--force", startRef.ref] : ["switch", "--detach", startRef.ref]);
|
|
408
|
+
} else {
|
|
409
|
+
git(repoPath, ["switch", "-"]);
|
|
410
|
+
}
|
|
411
|
+
git(repoPath, ["branch", "-D", branch]);
|
|
412
|
+
return (git(repoPath, ["status", "--porcelain"]).stdout || "").trim() === "";
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
// Post the right cancel message: honest about whether cleanup actually worked.
|
|
416
|
+
const postStopped = async () => {
|
|
417
|
+
const clean = discardBranch();
|
|
418
|
+
await tool("post_message", {
|
|
419
|
+
channelId,
|
|
420
|
+
parentId,
|
|
421
|
+
body: clean
|
|
422
|
+
? `Stopped — discarded \`${branch}\`.`
|
|
423
|
+
: `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
|
|
295
424
|
});
|
|
296
|
-
|
|
297
|
-
return { reportMessageId: res?.messageId ?? null, stat };
|
|
425
|
+
return { status: "cancelled", branch };
|
|
298
426
|
};
|
|
299
427
|
|
|
300
|
-
|
|
301
|
-
if (
|
|
428
|
+
const staged = await runAndStage(message.body);
|
|
429
|
+
if (staged.aborted) return await postStopped();
|
|
430
|
+
if (staged.empty) {
|
|
302
431
|
await tool("post_message", {
|
|
303
432
|
channelId,
|
|
433
|
+
parentId,
|
|
304
434
|
body: `No changes were produced. Cleaning up \`${branch}\`.`,
|
|
305
435
|
});
|
|
306
436
|
git(repoPath, ["switch", "-"]);
|
|
@@ -308,33 +438,58 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
308
438
|
return { status: "no-changes" };
|
|
309
439
|
}
|
|
310
440
|
|
|
441
|
+
// Bias to action (default): commit, push, and open a PR for review now — the
|
|
442
|
+
// report card's Approve merges it. `gate:true` keeps the older
|
|
443
|
+
// propose-a-diff-and-wait flow for users who want approve-before-push.
|
|
311
444
|
if (!cfg.gate) {
|
|
312
|
-
|
|
445
|
+
// A "stop" that lands between the run finishing and the ship must still win.
|
|
446
|
+
if (signal?.aborted) return await postStopped();
|
|
447
|
+
const result = await applyDecision({
|
|
448
|
+
decision: { kind: "approved" },
|
|
449
|
+
repoPath,
|
|
450
|
+
branch,
|
|
451
|
+
task: message.body,
|
|
452
|
+
requester: message.author,
|
|
453
|
+
cfg,
|
|
454
|
+
tool,
|
|
455
|
+
channelId,
|
|
456
|
+
deps,
|
|
457
|
+
parentId,
|
|
458
|
+
});
|
|
459
|
+
return { ...result, stat: staged.stat };
|
|
313
460
|
}
|
|
314
461
|
|
|
315
|
-
let
|
|
462
|
+
let proposal = await postProposal(staged);
|
|
463
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
464
|
+
if (decision.kind === "cancelled") return await postStopped();
|
|
316
465
|
const maxRounds = cfg.maxRounds || 3;
|
|
317
466
|
let round = 1;
|
|
318
467
|
while (decision.kind === "changes" && round < maxRounds) {
|
|
319
468
|
await tool("post_message", {
|
|
320
469
|
channelId,
|
|
470
|
+
parentId,
|
|
321
471
|
body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
322
472
|
});
|
|
323
|
-
const refined = await
|
|
473
|
+
const refined = await runAndStage(
|
|
324
474
|
`${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
|
|
325
475
|
);
|
|
476
|
+
if (refined.aborted) return await postStopped();
|
|
326
477
|
if (refined.empty) {
|
|
327
478
|
await tool("post_message", {
|
|
328
479
|
channelId,
|
|
480
|
+
parentId,
|
|
329
481
|
body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
|
|
330
482
|
});
|
|
331
483
|
break;
|
|
332
484
|
}
|
|
333
|
-
proposal = refined;
|
|
334
|
-
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
|
|
485
|
+
proposal = await postProposal(refined);
|
|
486
|
+
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
487
|
+
if (decision.kind === "cancelled") return await postStopped();
|
|
335
488
|
round += 1;
|
|
336
489
|
}
|
|
337
490
|
|
|
491
|
+
// A stop during the final wait, too — don't ship a cancelled run.
|
|
492
|
+
if (signal?.aborted) return await postStopped();
|
|
338
493
|
const result = await applyDecision({
|
|
339
494
|
decision,
|
|
340
495
|
repoPath,
|
|
@@ -345,6 +500,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
345
500
|
tool,
|
|
346
501
|
channelId,
|
|
347
502
|
deps,
|
|
503
|
+
parentId,
|
|
348
504
|
});
|
|
349
505
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
|
|
350
506
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
// Minimal MCP-over-HTTP client (JSON-RPC 2.0). Dependency-free: uses global
|
|
2
2
|
// fetch (Node >= 18). One bearer token, one endpoint.
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
// Best-effort host label so hilos can show "whose machine" this daemon runs on.
|
|
6
|
+
const CLIENT = (() => {
|
|
7
|
+
try {
|
|
8
|
+
return `${os.userInfo().username}@${os.hostname()}`;
|
|
9
|
+
} catch {
|
|
10
|
+
return os.hostname?.() || "";
|
|
11
|
+
}
|
|
12
|
+
})();
|
|
3
13
|
|
|
4
14
|
export function makeClient({ url, token }) {
|
|
5
15
|
let id = 0;
|
|
@@ -7,7 +17,11 @@ export function makeClient({ url, token }) {
|
|
|
7
17
|
async function rpc(method, params) {
|
|
8
18
|
const res = await fetch(url, {
|
|
9
19
|
method: "POST",
|
|
10
|
-
headers: {
|
|
20
|
+
headers: {
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
authorization: `Bearer ${token}`,
|
|
23
|
+
...(CLIENT ? { "x-hilos-client": CLIENT } : {}),
|
|
24
|
+
},
|
|
11
25
|
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
12
26
|
});
|
|
13
27
|
if (!res.ok) {
|
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,7 @@
|
|
|
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";
|
|
8
9
|
|
|
9
10
|
export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
10
11
|
if (!cfg.token) {
|
|
@@ -38,13 +39,61 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
38
39
|
const seen = new Set();
|
|
39
40
|
const cursor = { value: since ? new Date(since).toISOString() : null };
|
|
40
41
|
|
|
42
|
+
// One-at-a-time worker so the poll loop NEVER blocks on a multi-minute run.
|
|
43
|
+
// The loop only fetches + enqueues; the queue runs jobs in order. This is what
|
|
44
|
+
// lets a "stop" (and new mentions) be picked up while a task is still running.
|
|
45
|
+
const queue = createQueue({
|
|
46
|
+
concurrency: cfg.queueConcurrency || 1,
|
|
47
|
+
runJob: (job, { signal }) => safeHandle(job.message, job.channelId, signal),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
async function safeHandle(message, channelId, signal) {
|
|
51
|
+
try {
|
|
52
|
+
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
53
|
+
await handler({ message, channelId, tool, me }, cfg, undefined, { signal });
|
|
54
|
+
} catch (e) {
|
|
55
|
+
log.error(`handler error: ${e.message}`);
|
|
56
|
+
await tool("post_message", {
|
|
57
|
+
channelId,
|
|
58
|
+
body: `Hit an error working on that: ${e.message}`,
|
|
59
|
+
}).catch(() => {});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Route one fresh mention: a "stop" cancels the active run; anything else is
|
|
64
|
+
// enqueued (deduped against an identical in-flight/queued ask), with an ack
|
|
65
|
+
// when it lands behind work already in progress.
|
|
66
|
+
async function intake(m, channelId) {
|
|
67
|
+
if (looksLikeCancel(m.body) && (queue.active() || queue.size())) {
|
|
68
|
+
queue.cancelActive("user asked to stop");
|
|
69
|
+
await tool("post_message", {
|
|
70
|
+
channelId,
|
|
71
|
+
parentId: m.parentId ?? null,
|
|
72
|
+
body: "Stopping the current task.",
|
|
73
|
+
}).catch(() => {});
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const r = queue.enqueue({ message: m, channelId, key: dedupeKey(m, channelId) });
|
|
77
|
+
if (r.deduped) {
|
|
78
|
+
log.log(" (deduped a repeat of an in-flight/queued ask)");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (cfg.queueAcks && !r.startedImmediately) {
|
|
82
|
+
await tool("post_message", {
|
|
83
|
+
channelId,
|
|
84
|
+
parentId: m.parentId ?? null,
|
|
85
|
+
body: "Got it — queued behind the current task.",
|
|
86
|
+
}).catch(() => {});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
41
90
|
async function passViaMentions() {
|
|
42
91
|
const out = await tool("list_mentions", cursor.value ? { since: cursor.value } : {});
|
|
43
92
|
const mentions = (out?.mentions ?? []).slice().reverse();
|
|
44
93
|
for (const m of mentions) {
|
|
45
94
|
if (seen.has(m.id)) continue;
|
|
46
95
|
seen.add(m.id);
|
|
47
|
-
await
|
|
96
|
+
await intake(m, m.channelId);
|
|
48
97
|
// Compare numerically — created_at formats differ (Z vs +00:00 offset),
|
|
49
98
|
// so a lexicographic string compare can fail to advance the cursor.
|
|
50
99
|
if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
|
|
@@ -61,24 +110,11 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
61
110
|
seen.add(m.id);
|
|
62
111
|
if (m.author === me.agentName) continue;
|
|
63
112
|
const isMention = new RegExp(`@${me.handle}\\b`, "i").test(m.body || "");
|
|
64
|
-
if (isMention && new Date(m.created_at).getTime() >= since) await
|
|
113
|
+
if (isMention && new Date(m.created_at).getTime() >= since) await intake(m, channelId);
|
|
65
114
|
}
|
|
66
115
|
}
|
|
67
116
|
}
|
|
68
117
|
|
|
69
|
-
async function safeHandle(message, channelId) {
|
|
70
|
-
try {
|
|
71
|
-
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
72
|
-
await handler({ message, channelId, tool, me }, cfg);
|
|
73
|
-
} catch (e) {
|
|
74
|
-
log.error(`handler error: ${e.message}`);
|
|
75
|
-
await tool("post_message", {
|
|
76
|
-
channelId,
|
|
77
|
-
body: `Hit an error working on that: ${e.message}`,
|
|
78
|
-
}).catch(() => {});
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
118
|
do {
|
|
83
119
|
try {
|
|
84
120
|
if (useMentions) await passViaMentions();
|
|
@@ -86,6 +122,11 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
86
122
|
} catch (e) {
|
|
87
123
|
log.error(`poll error: ${e.message}`);
|
|
88
124
|
}
|
|
89
|
-
|
|
90
|
-
|
|
125
|
+
// --once is for cron-style single passes: drain the queued work before exit.
|
|
126
|
+
if (cfg.once) {
|
|
127
|
+
await queue.idle();
|
|
128
|
+
break;
|
|
129
|
+
}
|
|
130
|
+
await new Promise((r) => setTimeout(r, cfg.pollMs));
|
|
131
|
+
} while (true);
|
|
91
132
|
}
|