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 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 **local** checkout, and posts the
8
- proposed diff as a report card. **Nothing is pushed until a human approves in
9
- hilos.** Your code and your git/`gh` credentials never leave your machine — hilos
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, captures the diff
16
- agent: posts a proposal card (NOT pushed)
17
- human: Approve ▶ agent: git push + gh pr create ▶ posts the PR link
18
- Reject ▶ agent: discards the branch
19
- Changes ▶ agent: re-runs with your note, proposes again
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", // or "codex exec", "cursor-agent", any shell command
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": true // false = propose only, never push
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
- - **Propose** the staged diff is posted as a report card. The agent then polls
61
- for your decision.
62
- - **Approve / Reject / Request changes** — approve pushes with *your* `git`/`gh`
63
- and opens a PR; reject discards the branch; request-changes re-runs with your
64
- note (bounded rounds).
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. hilos can only send text; **push is gated on a human
70
- approval recorded in hilos**, and uses your local credentials. Keep your token in
71
- the config file or `HILOS_TOKEN`, never in shared shell history.
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.5",
4
- "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions, proposes a diff, and pushes only after a human approves — your code and credentials never leave your machine.",
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 = spawn(cmd, args, { cwd });
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
- child.kill("SIGKILL");
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
- error: error || (timedOut ? new Error(`timed out after ${fmtElapsed(timeoutMs)}`) : null),
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
- codingCmd: "claude -p",
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
- gate: true,
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
- gate: true,
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
- // The default task handler: turn an @mention in a git-linked channel into a
2
- // branch + a coding-agent run + a proposed diff, gated on a human approval in
3
- // hilos. Nothing is pushed until approved. Your code + credentials stay local.
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
- const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
53
- () => ({ messages: [] }),
54
- );
55
- const m = messages.find((x) => x.id === reportMessageId);
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
- async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps }) {
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
- const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
169
- messages: [],
170
- }));
171
- const transcript = messages
172
- .map((m) => `${m.author}: ${m.body}`)
173
- .join("\n")
174
- .slice(-6000);
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
- `If asked to change code, note that a repo isn't linked to this channel yet.\n\n` +
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
- export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride) {
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
- const proposeRound = async (promptText) => {
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; posting proposal");
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: run.status !== 0,
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
- const res = await tool("post_report", { channelId, ...report });
297
- return { reportMessageId: res?.messageId ?? null, stat };
425
+ return { status: "cancelled", branch };
298
426
  };
299
427
 
300
- let proposal = await proposeRound(message.body);
301
- if (proposal.empty) {
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
- return { status: "proposed", branch, reportMessageId: proposal.reportMessageId, stat: proposal.stat };
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 decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
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 proposeRound(
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: { "content-type": "application/json", authorization: `Bearer ${token}` },
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 safeHandle(m, m.channelId);
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 safeHandle(m, channelId);
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
- if (!cfg.once) await new Promise((r) => setTimeout(r, cfg.pollMs));
90
- } while (!cfg.once);
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
  }