hilos-agent 0.1.6 → 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
@@ -42,7 +42,7 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
42
42
  "url": "https://hilos.sh/api/mcp",
43
43
  "token": "mgo_…",
44
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
45
- "codingCmd": "claude -p --permission-mode acceptEdits", // or "codex exec", "cursor-agent", any command
45
+ "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent", any command
46
46
  "defaultBranch": "main",
47
47
  "gate": false // default: open a PR directly. true = approve-before-push
48
48
  }
@@ -67,6 +67,34 @@ hilos-agent --channel <id> # scope to one channel
67
67
  card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
68
68
  discards the branch, **Request changes** re-runs with your note (bounded rounds).
69
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.
97
+
70
98
  ## Security
71
99
 
72
100
  The daemon runs a coding agent that can execute code in your repo — exactly as if
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -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
@@ -53,6 +53,11 @@ const DEFAULTS = {
53
53
  gate: false,
54
54
  maxRounds: 3,
55
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,
56
61
  runTimeoutMs: 600000,
57
62
  decisionTimeoutMs: 1800000,
58
63
  decisionPollMs: 5000,
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,10 +48,13 @@ function defaultDeps() {
41
48
  };
42
49
  }
43
50
 
44
- async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId }) {
51
+ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId, signal }) {
45
52
  if (!reportMessageId) return { kind: "timeout" };
46
53
  const deadline = deps.now() + cfg.decisionTimeoutMs;
47
54
  while (deps.now() < deadline) {
55
+ // A "stop" while we wait for review must end the wait — otherwise a later
56
+ // Approve would still commit/push/open a PR despite the cancel.
57
+ if (signal?.aborted) return { kind: "cancelled" };
48
58
  let report = null;
49
59
  const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
50
60
  if (gr && gr.found && gr.report) report = gr.report;
@@ -188,8 +198,14 @@ export function normalizeRemote(url) {
188
198
  return m ? m[1] : null;
189
199
  }
190
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
+
191
207
  /** Run the configured CLI to produce a chat reply, using recent channel context. */
192
- async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId }) {
208
+ async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal }) {
193
209
  void message;
194
210
  // In a thread, use the thread's own messages as context (and reply there);
195
211
  // otherwise the recent channel tail. Threads are where conversations live, so
@@ -214,6 +230,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
214
230
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
215
231
  `concisely and directly as a single chat message — no preamble, no headings. ` +
216
232
  `${repoLine}\n\n` +
233
+ `${memoryPreamble(workspaceMemory)}` +
217
234
  `Conversation so far:\n${transcript}`;
218
235
 
219
236
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
@@ -223,7 +240,12 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
223
240
  args: [...parts.slice(1), prompt],
224
241
  timeoutMs: cfg.runTimeoutMs,
225
242
  label: "thinking",
243
+ signal,
226
244
  });
245
+ if (run.aborted || signal?.aborted) {
246
+ await tool("post_message", { channelId, parentId: parentId ?? null, body: "Stopped." });
247
+ return;
248
+ }
227
249
  const reply = (run.stdout || "").trim();
228
250
  if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
229
251
  console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
@@ -234,19 +256,25 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
234
256
  });
235
257
  }
236
258
 
237
- /** Handle one task. cfg/deps injectable for tests. */
238
- 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 = {}) {
239
262
  const deps = depsOverride || defaultDeps();
240
263
  const git = deps.git;
264
+ const signal = opts.signal;
241
265
  // When the mention was a thread reply, keep the whole exchange in that thread.
242
266
  const parentId = message.parentId ?? null;
243
267
 
244
268
  const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
245
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
+ }));
246
274
  // Chat (no repo linked, OR a greeting/question even in a repo channel) →
247
275
  // reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
248
276
  if (!repoLink || !looksLikeCodeTask(message.body)) {
249
- await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId });
277
+ await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal });
250
278
  return { status: "chat" };
251
279
  }
252
280
  const repoFullName = repoLink.repo_full_name;
@@ -286,6 +314,14 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
286
314
  return { status: "dirty" };
287
315
  }
288
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
+
289
325
  const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
290
326
  git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
291
327
  const co = git(repoPath, ["switch", "-c", branch]);
@@ -306,17 +342,24 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
306
342
 
307
343
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
308
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.
309
347
  const runAndStage = async (promptText) => {
310
348
  console.log(
311
349
  ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
312
350
  );
313
351
  const run = await runCli({
314
352
  cmd: parts[0],
315
- args: [...parts.slice(1), promptText],
353
+ args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
316
354
  cwd: repoPath,
317
355
  timeoutMs: cfg.runTimeoutMs,
318
356
  label: "coding",
357
+ signal,
319
358
  });
359
+ if (run.aborted || signal?.aborted) {
360
+ console.log(" code → cancelled");
361
+ return { aborted: true };
362
+ }
320
363
  if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
321
364
  git(repoPath, ["add", "-A"]);
322
365
  const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
@@ -347,7 +390,43 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
347
390
  return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
348
391
  };
349
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}.`,
424
+ });
425
+ return { status: "cancelled", branch };
426
+ };
427
+
350
428
  const staged = await runAndStage(message.body);
429
+ if (staged.aborted) return await postStopped();
351
430
  if (staged.empty) {
352
431
  await tool("post_message", {
353
432
  channelId,
@@ -363,6 +442,8 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
363
442
  // report card's Approve merges it. `gate:true` keeps the older
364
443
  // propose-a-diff-and-wait flow for users who want approve-before-push.
365
444
  if (!cfg.gate) {
445
+ // A "stop" that lands between the run finishing and the ship must still win.
446
+ if (signal?.aborted) return await postStopped();
366
447
  const result = await applyDecision({
367
448
  decision: { kind: "approved" },
368
449
  repoPath,
@@ -379,7 +460,8 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
379
460
  }
380
461
 
381
462
  let proposal = await postProposal(staged);
382
- let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
463
+ let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
464
+ if (decision.kind === "cancelled") return await postStopped();
383
465
  const maxRounds = cfg.maxRounds || 3;
384
466
  let round = 1;
385
467
  while (decision.kind === "changes" && round < maxRounds) {
@@ -391,6 +473,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
391
473
  const refined = await runAndStage(
392
474
  `${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
393
475
  );
476
+ if (refined.aborted) return await postStopped();
394
477
  if (refined.empty) {
395
478
  await tool("post_message", {
396
479
  channelId,
@@ -400,10 +483,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
400
483
  break;
401
484
  }
402
485
  proposal = await postProposal(refined);
403
- decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
486
+ decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
487
+ if (decision.kind === "cancelled") return await postStopped();
404
488
  round += 1;
405
489
  }
406
490
 
491
+ // A stop during the final wait, too — don't ship a cancelled run.
492
+ if (signal?.aborted) return await postStopped();
407
493
  const result = await applyDecision({
408
494
  decision,
409
495
  repoPath,
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
  }