hilos-agent 0.1.7 → 0.1.9

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
@@ -43,11 +43,27 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
43
43
  "token": "mgo_…",
44
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
45
45
  "codingCmd": "claude -p --permission-mode acceptEdits", // safe default; see Permissions / autonomy. or "codex exec", "cursor-agent", any command
46
+ "chatCmd": "claude -p --model claude-haiku-4-5", // FAST command for chat replies + the plan-ack (set if your stack isn't Claude)
46
47
  "defaultBranch": "main",
47
- "gate": false // default: open a PR directly. true = approve-before-push
48
+ "gate": false, // default: open a PR directly. true = approve-before-push
49
+ "heartbeatMs": 180000, // long runs post one "still working…" thread reply this often (0 = off, min 15s)
50
+ "chatTimeoutMs": 90000 // cap a chat reply / plan-ack so a stalled model can't go silent
48
51
  }
49
52
  ```
50
53
 
54
+ **Staying responsive.** Every code task posts an **instant acknowledgement**
55
+ (under a second), and — if your server exposes `edit_message` and a `chatCmd` is
56
+ set — a quick **plan** ("On it — I'll do X, then open a PR") edits into it. On a
57
+ run longer than `heartbeatMs` (default 3 min; env `HILOS_HEARTBEAT_MS`, `0`
58
+ disables, clamped to ≥15s) the agent posts **one progress reply** in the thread
59
+ then edits it in place with elapsed time + the CLI's latest line — so the channel
60
+ shows it's alive without thread spam. When the run ends, that message is retired
61
+ to a short "done" line. A run that **times out or errors** says so honestly (with
62
+ a stderr tail) instead of claiming "no changes". Chat replies use the faster
63
+ `chatCmd` (default Haiku; falls back to `codingCmd` if unset) bounded by
64
+ `chatTimeoutMs`. The responsive surface needs a hilos server new enough to expose
65
+ `edit_message`; older servers just skip the live edits.
66
+
51
67
  ```sh
52
68
  hilos-agent # watch every channel the agent is in
53
69
  hilos-agent --channel <id> # scope to one channel
@@ -67,6 +83,16 @@ hilos-agent --channel <id> # scope to one channel
67
83
  card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
68
84
  discards the branch, **Request changes** re-runs with your note (bounded rounds).
69
85
 
86
+ ## Model & permissions
87
+
88
+ You don't have to hand-write `codingCmd`: the agent's **Connect via MCP** panel in
89
+ hilos has **Model** (Vendor default / Opus / Sonnet / Haiku) and **Permissions**
90
+ (Ask before edits / Auto-approve edits / Skip all prompts) pickers that bake your
91
+ choice into the generated `--coding-cmd`. Change it later by editing `codingCmd` in
92
+ `hilos-agent.json` — the daemon re-reads the file between polls and applies it
93
+ without a restart (your `url`/`token` are never affected). The next section
94
+ explains what each permission level means.
95
+
70
96
  ## Permissions / autonomy
71
97
 
72
98
  `codingCmd` decides how much the coding agent can do on its own. Three levels,
@@ -107,7 +133,7 @@ or `HILOS_TOKEN`, never in shared shell history.
107
133
  ## Flags
108
134
 
109
135
  `--join <blob>` · `--channel <id>` · `--config <path>` · `--coding-cmd <cmd>` ·
110
- `--once` · `--backfill` · `--no-gate` · `--help`
136
+ `--chat-cmd <cmd>` · `--once` · `--backfill` · `--no-gate` · `--help`
111
137
 
112
138
  Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
113
139
  `HILOS_BACKFILL=1`.
@@ -12,6 +12,9 @@
12
12
  // Config (./hilos-agent.json or ~/.hilos/agent.json), overlaid by env + flags:
13
13
  // { "url", "token", "channelId", "repos": {"owner/name":"/abs/path"},
14
14
  // "codingCmd": "claude -p", "defaultBranch": "main", "gate": true }
15
+ // The connect link bakes your model + permission choice into --coding-cmd; edit
16
+ // codingCmd in hilos-agent.json to change it and the daemon picks it up on its
17
+ // next poll — no restart needed.
15
18
 
16
19
  import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "../src/config.mjs";
17
20
  import { run } from "../src/run.mjs";
@@ -27,6 +30,7 @@ function parseArgs(argv) {
27
30
  else if (a === "--url") flags.url = argv[++i];
28
31
  else if (a === "--token") flags.token = argv[++i];
29
32
  else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
33
+ else if (a === "--chat-cmd") flags.chatCmd = argv[++i];
30
34
  else if (a === "--once") flags.once = true;
31
35
  else if (a === "--backfill") flags.backfill = true;
32
36
  else if (a === "--no-gate") flags.gate = false;
@@ -46,6 +50,8 @@ Options:
46
50
  --channel <id> watch only one channel (per-channel override)
47
51
  --config <path> use a specific config file
48
52
  --coding-cmd <cmd> the coding agent to run (default: "claude -p")
53
+ --chat-cmd <cmd> fast command for chat replies + the plan-ack
54
+ (default: "claude -p --model claude-haiku-4-5")
49
55
  --once one poll then exit (cron-friendly)
50
56
  --backfill also act on mentions that predate startup
51
57
  --no-gate propose only; don't wait for approval / push
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs CHANGED
@@ -15,6 +15,36 @@ export function fmtElapsed(ms) {
15
15
  return m > 0 ? `${m}m ${s}s` : `${s}s`;
16
16
  }
17
17
 
18
+ /** Collapse to one trimmed, length-capped line (for a heartbeat's "Latest:" tail). */
19
+ export function oneLine(s, max = 140) {
20
+ const t = String(s || "").replace(/\s+/g, " ").trim();
21
+ return t.length > max ? t.slice(0, max - 1) + "…" : t;
22
+ }
23
+
24
+ /**
25
+ * Low-noise "still working…" status body for a long run (pure; used by the
26
+ * daemon's chat-facing heartbeat). Appends the CLI's latest output line when one
27
+ * is available, else stays generic.
28
+ * @param {{ branch: string, elapsedMs: number, repoFullName?: string, lastLine?: string }} o
29
+ */
30
+ export function buildHeartbeat(o) {
31
+ const { repoFullName, branch, elapsedMs, lastLine } = o;
32
+ const where = repoFullName ? ` in ${repoFullName}` : "";
33
+ const base = `Still working on \`${branch}\`${where} — ${fmtElapsed(elapsedMs)} elapsed. I'll post a report when it's done.`;
34
+ const tail = oneLine(lastLine);
35
+ return tail ? `${base}\n\nLatest: ${tail}` : base;
36
+ }
37
+
38
+ /**
39
+ * The instant, template acknowledgement posted the moment a code task starts —
40
+ * so the channel shows life in <1s, before any model call. A bounded plan-ack
41
+ * may later edit this message in place with a sentence of specifics.
42
+ */
43
+ export function ackText(repoFullName) {
44
+ const where = repoFullName ? ` in ${repoFullName}` : "";
45
+ return `On it — taking a look${where} now. I'll open a PR and report back.`;
46
+ }
47
+
18
48
  // Guard against a pathological run flooding memory; the CLI's result is small.
19
49
  const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
20
50
 
@@ -30,6 +60,8 @@ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
30
60
  * @property {{ log: (m: string) => void }} [log] - logger, injectable for tests
31
61
  * @property {AbortSignal} [signal] - abort to cancel the run (kills the child's
32
62
  * whole process group: SIGTERM, then SIGKILL after a short grace)
63
+ * @property {(chunk: string) => void} [onData] - called with each stdout chunk as
64
+ * it arrives (lets a caller track the latest output line for a heartbeat)
33
65
  */
34
66
 
35
67
  /**
@@ -53,6 +85,7 @@ export function runCli(opts) {
53
85
  now = Date.now,
54
86
  log = console,
55
87
  signal,
88
+ onData,
56
89
  } = opts || {};
57
90
  return new Promise((resolve) => {
58
91
  // Already cancelled before we even start.
@@ -79,7 +112,16 @@ export function runCli(opts) {
79
112
  child.stderr?.setEncoding("utf8");
80
113
  const append = (buf, chunk) =>
81
114
  buf.length >= MAX_CAPTURE_BYTES ? buf : buf + chunk;
82
- child.stdout?.on("data", (c) => (stdout = append(stdout, c)));
115
+ child.stdout?.on("data", (c) => {
116
+ stdout = append(stdout, c);
117
+ if (onData) {
118
+ try {
119
+ onData(c);
120
+ } catch {
121
+ /* a heartbeat tracker must never break the run */
122
+ }
123
+ }
124
+ });
83
125
  child.stderr?.on("data", (c) => (stderr = append(stderr, c)));
84
126
 
85
127
  const start = now();
package/src/config.mjs CHANGED
@@ -47,12 +47,25 @@ const DEFAULTS = {
47
47
  // it still won't run arbitrary commands. Override in hilos-agent.json if you
48
48
  // want a stricter (or `--dangerously-skip-permissions`) command.
49
49
  codingCmd: "claude -p --permission-mode acceptEdits",
50
+ // Chat replies + the code-task plan-ack use a FAST one-shot model so a casual
51
+ // reply (or "I see it, here's my plan") comes back in seconds, not minutes.
52
+ // Bounded by chatTimeoutMs with a template fallback so it can never dead-air.
53
+ // If your coding stack isn't Claude, set this to your own fast reply command.
54
+ chatCmd: "claude -p --model claude-haiku-4-5",
50
55
  defaultBranch: "main",
51
56
  // Bias to action: open a PR directly for review (approve = merge on the card).
52
57
  // Set gate:true for the older approve-before-push flow (propose a diff, wait).
53
58
  gate: false,
54
59
  maxRounds: 3,
55
60
  pollMs: 5000,
61
+ // On a long code run, post ONE thread progress reply at the first beat then
62
+ // edit it on later beats, so a human sees the agent is alive without thread
63
+ // spam. 0 disables. Clamped to >=15s so a misconfig can't spam realtime
64
+ // UPDATEs. (Distinct from cli.mjs's 15s LOCAL terminal heartbeat.)
65
+ heartbeatMs: 180000,
66
+ // Cap a chat reply / plan-ack so a stalled model can't dead-air the channel;
67
+ // on timeout we post an honest "taking longer than expected" line.
68
+ chatTimeoutMs: 90000,
56
69
  // Work queue: run mentions one at a time (concurrency 1 — parallel CLI runs on
57
70
  // one checkout would collide on git state). queueAcks posts "queued behind the
58
71
  // current task" when a mention lands during a run; set false to keep it quiet.
@@ -73,11 +86,16 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
73
86
  token: process.env.HILOS_TOKEN,
74
87
  channelId: process.env.HILOS_CHANNEL,
75
88
  codingCmd: process.env.CODING_CMD,
89
+ chatCmd: process.env.HILOS_CHAT_CMD,
90
+ heartbeatMs: process.env.HILOS_HEARTBEAT_MS ? Number(process.env.HILOS_HEARTBEAT_MS) : undefined,
91
+ chatTimeoutMs: process.env.HILOS_CHAT_TIMEOUT_MS ? Number(process.env.HILOS_CHAT_TIMEOUT_MS) : undefined,
76
92
  backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
77
93
  once: process.env.HILOS_ONCE === "1" ? true : undefined,
78
94
  };
79
95
  const merged = { ...DEFAULTS, ...file };
80
96
  for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
97
+ // Clamp the chat heartbeat: 0 (off) stays off, otherwise never below 15s.
98
+ if (merged.heartbeatMs > 0) merged.heartbeatMs = Math.max(15000, Number(merged.heartbeatMs) || 0);
81
99
  if (joinPayload) {
82
100
  merged.url = joinPayload.url;
83
101
  merged.token = joinPayload.token;
@@ -85,9 +103,55 @@ export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
85
103
  }
86
104
  for (const [k, v] of Object.entries(flags)) if (v !== undefined) merged[k] = v;
87
105
  merged.repos = { ...DEFAULTS.repos, ...(file.repos || {}) };
106
+ // Remember where the file lives so reloadConfig can re-read it live.
107
+ merged.configPath = findConfigPath(flags.config) || null;
88
108
  return merged;
89
109
  }
90
110
 
111
+ // Run-behavior fields that may change live (edit hilos-agent.json, no restart).
112
+ // Connection identity (url/token/channelId) is deliberately excluded so a bad or
113
+ // edited file can NEVER drop the daemon's connection.
114
+ const LIVE_FIELDS = [
115
+ "codingCmd",
116
+ "chatCmd",
117
+ "defaultBranch",
118
+ "gate",
119
+ "maxRounds",
120
+ "pollMs",
121
+ "runTimeoutMs",
122
+ "decisionTimeoutMs",
123
+ "decisionPollMs",
124
+ "heartbeatMs",
125
+ "chatTimeoutMs",
126
+ // NOT queueConcurrency: the queue is built once at startup, so it can't change
127
+ // live. queueAcks IS live (intake() reads it per-mention from liveCfg).
128
+ "queueAcks",
129
+ ];
130
+
131
+ /**
132
+ * Re-read the config file and overlay ONLY the run-behavior fields onto the
133
+ * previous config, so a running daemon picks up model/permission/codingCmd edits
134
+ * without a restart. Identity (url/token/channelId) is pinned from `prev`. Env
135
+ * still overrides (operator wins). On any read error, returns `prev` unchanged.
136
+ */
137
+ export function reloadConfig(prev) {
138
+ // Only ever re-read the file CHOSEN AT LAUNCH. A daemon started without a config
139
+ // file (e.g. `--join` + env) stays file-free — we don't re-discover cwd each
140
+ // poll, so a hilos-agent.json dropped in later can't start steering which binary
141
+ // the daemon spawns. (Restart to adopt a newly-created file.)
142
+ const path = prev.configPath;
143
+ const file = (path && readJson(path)) || {};
144
+ const next = { ...prev };
145
+ for (const k of LIVE_FIELDS) if (file[k] !== undefined) next[k] = file[k];
146
+ if (process.env.CODING_CMD) next.codingCmd = process.env.CODING_CMD;
147
+ if (process.env.HILOS_CHAT_CMD) next.chatCmd = process.env.HILOS_CHAT_CMD;
148
+ if (process.env.HILOS_HEARTBEAT_MS) next.heartbeatMs = Number(process.env.HILOS_HEARTBEAT_MS);
149
+ if (process.env.HILOS_CHAT_TIMEOUT_MS) next.chatTimeoutMs = Number(process.env.HILOS_CHAT_TIMEOUT_MS);
150
+ if (next.heartbeatMs > 0) next.heartbeatMs = Math.max(15000, Number(next.heartbeatMs) || 0);
151
+ if (file.repos) next.repos = { ...DEFAULTS.repos, ...file.repos };
152
+ return next;
153
+ }
154
+
91
155
  /** Write a starter config file (used by `hilos-agent init`). */
92
156
  export function writeStarterConfig(path, partial = {}) {
93
157
  const target = path || GLOBAL_CONFIG;
package/src/handler.mjs CHANGED
@@ -22,7 +22,7 @@ import {
22
22
  prTitleBody,
23
23
  mentionHandle,
24
24
  } from "./daemon.mjs";
25
- import { runCli } from "./cli.mjs";
25
+ import { runCli, buildHeartbeat, ackText, oneLine } from "./cli.mjs";
26
26
 
27
27
  /** `@handle` for the person who asked, so the report tags them. */
28
28
  function requesterTag(author) {
@@ -186,10 +186,68 @@ export function looksLikeCodeTask(text) {
186
186
  return false;
187
187
  }
188
188
  const verb =
189
- /\b(fix|add|implement|refactor|change|update|create|remove|delete|rename|write|build|bump|migrate|wire|patch|optimi[sz]e|debug|revert|rebase|configure|improve|replace|extract|split|move|generate|scaffold|make|set up|hook up|adjust|tweak)\b/;
189
+ /\b(fix|add|implement|refactor|change|update|create|remove|delete|rename|write|build|bump|migrate|wire|patch|optimi[sz]e|debug|revert|rebase|configure|improve|replace|extract|split|move|generate|scaffold|make|set up|hook up|adjust|tweak|ship|commit|push|merge)\b/;
190
190
  return verb.test(t);
191
191
  }
192
192
 
193
+ /**
194
+ * A short go-ahead that approves whatever was just discussed — "go", "ship it",
195
+ * "do it", "lfg", "send a PR". On its own this isn't a code task (no verb), but
196
+ * after the agent has proposed a plan it MEANS "execute it". handleTask treats
197
+ * an affirmation as code only when a repo is linked AND the agent's last message
198
+ * looked like a plan — so casual "yes"/"cool" in a repo channel stays chat.
199
+ */
200
+ export function isAffirmation(text) {
201
+ const t = String(text || "")
202
+ .toLowerCase()
203
+ .replace(/@[a-z0-9-]+/g, " ")
204
+ .replace(/[^a-z0-9'\s]/g, " ")
205
+ .replace(/\s+/g, " ")
206
+ .trim();
207
+ if (!t) return false;
208
+ // A strong go-ahead phrase ANYWHERE means "execute it" even inside a longer,
209
+ // excited message ("…so it's safe to start. go for it, babyyyy"). Safe because
210
+ // handleTask only treats it as code when the agent had just proposed a plan.
211
+ if (/\b(go for it|go ahead|ship it|send it|send a pr|open a pr|make it so|lfg|let'?s go|fire away)\b/.test(t)) {
212
+ return true;
213
+ }
214
+ // Otherwise only a SHORT message counts — a long one carries its own intent.
215
+ if (t.length > 40) return false;
216
+ return /^(go|do it|do that|yes|yep|yeah|yup|sure|ship|proceed|sounds good|sgtm|please do|please)\b/.test(t);
217
+ }
218
+
219
+ /** Does the agent's most recent message read like a plan/proposal it offered? */
220
+ export function agentProposedWork(rows, agentName) {
221
+ const mine = (rows || []).filter((m) => m && m.author === agentName);
222
+ const last = mine[mine.length - 1];
223
+ if (!last) return false;
224
+ return /\b(i'?ll|i will|plan|propose|pr\b|pull request|branch|here'?s|let me|on it|i'?d|i can)\b/i.test(
225
+ String(last.body || ""),
226
+ );
227
+ }
228
+
229
+ /**
230
+ * The task text handed to the coding CLI. The mention is always the instruction,
231
+ * but it's almost never self-contained: a reply like "yeah, do it" only means
232
+ * something against what was just said, and even a direct request is clearer with
233
+ * the surrounding discussion. So we ALWAYS prepend the recent conversation when we
234
+ * have it — the same context the chat path already gets. Falls back to the bare
235
+ * mention only when there's no transcript at all.
236
+ */
237
+ export function codeTaskPrompt({ message, context }) {
238
+ const body = String(message?.body || "").trim();
239
+ const transcript = context?.transcript?.trim();
240
+ if (!transcript) return body;
241
+ return (
242
+ `You're acting on a request in a team chat. Recent conversation (oldest first):\n` +
243
+ `${transcript}\n\n` +
244
+ `The latest message is your instruction: "${body}". Do what it asks, using the ` +
245
+ `conversation above for context — it often carries the actual spec (e.g. a reply ` +
246
+ `like "go for it" refers to what was just discussed). Make the change directly; ` +
247
+ `it will be opened as a PR for review.`
248
+ );
249
+ }
250
+
193
251
  /** owner/name from a GitHub remote URL (https or ssh), or null. */
194
252
  export function normalizeRemote(url) {
195
253
  const m = String(url || "")
@@ -204,23 +262,56 @@ function memoryPreamble(workspaceMemory) {
204
262
  return m ? `Workspace context (the project's soul):\n${m}\n\n` : "";
205
263
  }
206
264
 
207
- /** Run the configured CLI to produce a chat reply, using recent channel context. */
208
- async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal }) {
209
- void message;
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;
265
+ /**
266
+ * Recent conversation the agent should reply within: the thread it was pinged in
267
+ * (threads are where conversations live), else the channel tail. Returns the raw
268
+ * rows (for intent routing) and a transcript string (for the prompt).
269
+ */
270
+ async function fetchContext({ channelId, tool, parentId }) {
271
+ let rows = [];
214
272
  if (parentId) {
215
273
  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);
274
+ rows = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
218
275
  } else {
219
276
  const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
220
277
  messages: [],
221
278
  }));
222
- transcript = messages.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
279
+ rows = messages;
223
280
  }
281
+ return { rows, transcript: rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000) };
282
+ }
283
+
284
+ /**
285
+ * A 1–2 sentence "here's my plan" line for the instant ack, via the FAST chat
286
+ * CLI. Bounded (≤45s) + best-effort: returns trimmed text, or null on
287
+ * empty/timeout/error so the run keeps the instant template and never stalls.
288
+ */
289
+ async function proposePlanAck({ task, repoFullName, cfg, signal }) {
290
+ const cmd = cfg.chatCmd;
291
+ if (!cmd) return null;
292
+ const parts = cmd.split(" ").filter(Boolean);
293
+ const prompt =
294
+ `A teammate asked you to do this in the repo ${repoFullName}:\n"${task}"\n\n` +
295
+ `Reply with ONE or TWO short sentences, first person: acknowledge, state your ` +
296
+ `plan, and say you'll open a PR and report back. No preamble, no lists, no code. ` +
297
+ `Tone: "On it — I'll add X by doing Y. I'll open a PR and report back."`;
298
+ const run = await runCli({
299
+ cmd: parts[0],
300
+ args: [...parts.slice(1), prompt],
301
+ timeoutMs: Math.min(cfg.chatTimeoutMs || 90000, 45000),
302
+ label: "thinking",
303
+ signal,
304
+ });
305
+ if (run.aborted || signal?.aborted) return null;
306
+ const text = (run.stdout || "").trim();
307
+ if (!text) return null;
308
+ return text.length > 400 ? text.slice(0, 399) + "…" : text;
309
+ }
310
+
311
+ /** Run the FAST chat CLI to produce a reply, using recent channel context. */
312
+ async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps = {} }) {
313
+ void message;
314
+ const { transcript } = context || (await fetchContext({ channelId, tool, parentId }));
224
315
  const name = me?.agentName || "an assistant";
225
316
  // Tell the agent what the room is connected to so it doesn't ask "which repo?".
226
317
  const repoLine = repoLink
@@ -233,32 +324,85 @@ async function respondConversationally({ message, channelId, tool, me, cfg, repo
233
324
  `${memoryPreamble(workspaceMemory)}` +
234
325
  `Conversation so far:\n${transcript}`;
235
326
 
236
- const parts = cfg.codingCmd.split(" ").filter(Boolean);
237
- console.log(` chat running \`${cfg.codingCmd}\` (output appears when it finishes)…`);
238
- const run = await runCli({
239
- cmd: parts[0],
240
- args: [...parts.slice(1), prompt],
241
- timeoutMs: cfg.runTimeoutMs,
242
- label: "thinking",
243
- signal,
244
- });
327
+ // Chat uses the FAST one-shot command (fall back to codingCmd if unset) bounded
328
+ // by chatTimeoutMs, so a casual reply lands in seconds and a stalled model can't
329
+ // dead-air the channel for the full coding timeout.
330
+ const cmd = cfg.chatCmd || cfg.codingCmd;
331
+ const parts = cmd.split(" ").filter(Boolean);
332
+ console.log(` chat → running \`${cmd}\` (output appears when it finishes)…`);
333
+
334
+ // If the reply is slow, post ONE "still thinking…" ping and then edit it into
335
+ // the answer (single message, no dead air). Needs edit_message; otherwise we
336
+ // just post the reply when it's ready. A fast reply never trips the timer.
337
+ let thinkingId = null;
338
+ let beatStopped = false;
339
+ let beatBusy = false;
340
+ const beatMs = Math.min(cfg.chatTimeoutMs || 90000, 30000);
341
+ const beat =
342
+ caps.editMessage && beatMs > 0
343
+ ? setInterval(async () => {
344
+ if (beatStopped || beatBusy || thinkingId) return;
345
+ beatBusy = true;
346
+ try {
347
+ const r = await tool("post_message", { channelId, parentId: parentId ?? null, body: "Still thinking…" });
348
+ thinkingId = r?.messageId ?? null;
349
+ } catch {
350
+ /* a heartbeat must never break the reply */
351
+ } finally {
352
+ beatBusy = false;
353
+ }
354
+ }, beatMs)
355
+ : null;
356
+ if (beat?.unref) beat.unref();
357
+
358
+ let run;
359
+ try {
360
+ run = await runCli({
361
+ cmd: parts[0],
362
+ args: [...parts.slice(1), prompt],
363
+ timeoutMs: cfg.chatTimeoutMs || cfg.runTimeoutMs,
364
+ label: "thinking",
365
+ signal,
366
+ });
367
+ } finally {
368
+ beatStopped = true;
369
+ if (beat) clearInterval(beat);
370
+ }
371
+
372
+ // Deliver the final text: edit the "still thinking…" ping if we posted one, else
373
+ // post fresh. Keeps a slow reply to a single, in-place message.
374
+ const deliver = (body) =>
375
+ thinkingId
376
+ ? tool("edit_message", { messageId: thinkingId, body }).catch(() =>
377
+ tool("post_message", { channelId, parentId: parentId ?? null, body }),
378
+ )
379
+ : tool("post_message", { channelId, parentId: parentId ?? null, body });
380
+
245
381
  if (run.aborted || signal?.aborted) {
246
- await tool("post_message", { channelId, parentId: parentId ?? null, body: "Stopped." });
382
+ await deliver("Stopped.");
247
383
  return;
248
384
  }
249
385
  const reply = (run.stdout || "").trim();
250
- if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
386
+ if (run.error) console.log(` ! ${parts[0]}: ${run.error.message}`);
251
387
  console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
252
- await tool("post_message", {
253
- channelId,
254
- parentId: parentId ?? null,
255
- body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
256
- });
388
+ // Honest fallback: a timeout is NOT a missing binary. Only a real spawn failure
389
+ // (ENOENT) means the command isn't on PATH.
390
+ let body = reply;
391
+ if (!body) {
392
+ if (run.error?.code === "ENOENT") {
393
+ body = `(my chat command \`${cmd}\` isn't installed or on PATH.)`;
394
+ } else if (run.error) {
395
+ body = `Still thinking on this — it's taking longer than usual. I'll follow up shortly.`;
396
+ } else {
397
+ body = `(I didn't get a reply out of \`${cmd}\` that time — mention me again?)`;
398
+ }
399
+ }
400
+ await deliver(body);
257
401
  }
258
402
 
259
403
  /** Handle one task. cfg/deps injectable for tests. `opts.signal` (AbortSignal)
260
404
  * 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 = {}) {
405
+ export async function handleTask({ message, channelId, tool, me, caps = {} }, cfg, depsOverride, opts = {}) {
262
406
  const deps = depsOverride || defaultDeps();
263
407
  const git = deps.git;
264
408
  const signal = opts.signal;
@@ -271,10 +415,23 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
271
415
  const { memory: workspaceMemory = null } = await tool("get_workspace_memory", {}).catch(() => ({
272
416
  memory: null,
273
417
  }));
274
- // Chat (no repo linked, OR a greeting/question even in a repo channel)
275
- // reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
276
- if (!repoLink || !looksLikeCodeTask(message.body)) {
277
- await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal });
418
+ // The conversation the agent is replying within fetched ONCE and handed to
419
+ // BOTH the chat reply and the coding prompt. Context is always crucial: a reply
420
+ // like "yeah, do it" only means something against what was just said, and even
421
+ // a direct request is clearer with the surrounding discussion. The chat path
422
+ // always had this; the code path used to run on the bare mention alone (so
423
+ // "go for it" reached the agent with no spec → nothing built). Now both share it.
424
+ const context = await fetchContext({ channelId, tool, parentId });
425
+ // Chat vs code. A clear action verb → code. With a repo linked, a bare
426
+ // affirmation ("go", "ship it") that follows a plan the agent just proposed
427
+ // ALSO means "execute it" → code path. Otherwise (greeting/question/no repo) →
428
+ // a conversational reply.
429
+ let isCode = looksLikeCodeTask(message.body);
430
+ if (!isCode && repoLink && isAffirmation(message.body) && agentProposedWork(context.rows, me?.agentName)) {
431
+ isCode = true;
432
+ }
433
+ if (!repoLink || !isCode) {
434
+ await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId, workspaceMemory, signal, context, caps });
278
435
  return { status: "chat" };
279
436
  }
280
437
  const repoFullName = repoLink.repo_full_name;
@@ -334,11 +491,60 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
334
491
  return { status: "branch-error" };
335
492
  }
336
493
 
337
- await tool("post_message", {
338
- channelId,
339
- parentId,
340
- body: `On it working in ${repoFullName} on \`${branch}\`.`,
341
- });
494
+ // Instant acknowledgement: post a template in <1s so the channel shows life
495
+ // before any model call. If the server supports edit_message and a fast chatCmd
496
+ // is set, a bounded plan-ack edits in a sentence of specifics ("I'll do X…").
497
+ // Best-efforta slow or failed ack just leaves the template, never blocks.
498
+ const ack = await tool("post_message", { channelId, parentId, body: ackText(repoFullName) });
499
+ const ackId = ack?.messageId ?? null;
500
+ if (ackId && caps.editMessage && cfg.chatCmd) {
501
+ const plan = await proposePlanAck({ task: message.body, repoFullName, cfg, signal }).catch(() => null);
502
+ if (plan && !signal?.aborted) await tool("edit_message", { messageId: ackId, body: plan }).catch(() => {});
503
+ }
504
+
505
+ // Long-run progress: post ONE thread reply at the first beat (a visible ping)
506
+ // then EDIT it on later beats — alive without thread spam. Needs edit_message;
507
+ // without it we skip rather than post a fresh message every beat. lastLine
508
+ // carries the CLI's latest output into the beat.
509
+ const heartbeatOn = Boolean(caps.editMessage) && cfg.heartbeatMs > 0;
510
+ let lastLine = "";
511
+ let progressId = null; // the thread progress reply, once a beat has posted it
512
+ const startHeartbeat = () => {
513
+ if (!heartbeatOn) return () => {};
514
+ const start = deps.now();
515
+ let stopped = false;
516
+ let busy = false;
517
+ const beat = setInterval(async () => {
518
+ if (stopped || busy) return;
519
+ busy = true;
520
+ const body = buildHeartbeat({ repoFullName, branch, elapsedMs: deps.now() - start, lastLine });
521
+ try {
522
+ if (!progressId) {
523
+ const r = await tool("post_message", { channelId, parentId, body });
524
+ progressId = r?.messageId ?? null;
525
+ } else {
526
+ await tool("edit_message", { messageId: progressId, body });
527
+ }
528
+ } catch {
529
+ /* a heartbeat must never break the run */
530
+ } finally {
531
+ busy = false;
532
+ }
533
+ }, cfg.heartbeatMs);
534
+ if (beat.unref) beat.unref();
535
+ return () => {
536
+ stopped = true;
537
+ clearInterval(beat);
538
+ };
539
+ };
540
+ // Once the run ends, retire the "still working…" progress reply so it doesn't
541
+ // sit there claiming the agent is alive. No-op when no beat ever fired (short
542
+ // run) or without edit_message. Best-effort.
543
+ const finalizeProgress = async (text) => {
544
+ if (progressId && caps.editMessage) {
545
+ await tool("edit_message", { messageId: progressId, body: text }).catch(() => {});
546
+ }
547
+ };
342
548
 
343
549
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
344
550
  // Run the CLI and stage everything it changed; return the diff stats (no post).
@@ -348,14 +554,24 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
348
554
  console.log(
349
555
  ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
350
556
  );
351
- const run = await runCli({
352
- cmd: parts[0],
353
- args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
354
- cwd: repoPath,
355
- timeoutMs: cfg.runTimeoutMs,
356
- label: "coding",
357
- signal,
358
- });
557
+ const stopHeartbeat = startHeartbeat();
558
+ let run;
559
+ try {
560
+ run = await runCli({
561
+ cmd: parts[0],
562
+ args: [...parts.slice(1), memoryPreamble(workspaceMemory) + promptText],
563
+ cwd: repoPath,
564
+ timeoutMs: cfg.runTimeoutMs,
565
+ label: "coding",
566
+ signal,
567
+ onData: (c) => {
568
+ const lines = String(c).split("\n").map((s) => s.trim()).filter(Boolean);
569
+ if (lines.length) lastLine = lines[lines.length - 1];
570
+ },
571
+ });
572
+ } finally {
573
+ stopHeartbeat();
574
+ }
359
575
  if (run.aborted || signal?.aborted) {
360
576
  console.log(" code → cancelled");
361
577
  return { aborted: true };
@@ -364,8 +580,23 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
364
580
  git(repoPath, ["add", "-A"]);
365
581
  const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
366
582
  if (!diff.trim()) {
583
+ // An empty diff after a FAILED run (timeout / non-zero exit / spawn error)
584
+ // is not "no changes were needed" — be honest about it instead of claiming
585
+ // the agent decided nothing was required.
586
+ const failed = Boolean(run.error) || run.status !== 0;
587
+ if (failed) {
588
+ console.log(` code → run failed, no diff (status=${run.status}, err=${run.error?.message || "—"})`);
589
+ return {
590
+ empty: true,
591
+ failed: true,
592
+ errCode: run.error?.code || null,
593
+ errMessage: run.error?.message || null,
594
+ status: run.status,
595
+ stderrTail: oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 300),
596
+ };
597
+ }
367
598
  console.log(" code → no changes produced");
368
- return { empty: true };
599
+ return { empty: true, failed: false };
369
600
  }
370
601
  console.log(" code → diff captured");
371
602
  const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
@@ -425,17 +656,30 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
425
656
  return { status: "cancelled", branch };
426
657
  };
427
658
 
428
- const staged = await runAndStage(message.body);
659
+ const staged = await runAndStage(codeTaskPrompt({ message, context }));
429
660
  if (staged.aborted) return await postStopped();
430
661
  if (staged.empty) {
431
- await tool("post_message", {
432
- channelId,
433
- parentId,
434
- body: `No changes were produced. Cleaning up \`${branch}\`.`,
435
- });
662
+ let body;
663
+ if (staged.failed && staged.errCode === "ENOENT") {
664
+ body = `I couldn't start \`${cfg.codingCmd}\` — is it installed and on PATH? Cleaning up \`${branch}\`.`;
665
+ } else if (staged.failed) {
666
+ const why = staged.errMessage
667
+ ? ` (${staged.errMessage})`
668
+ : staged.status != null
669
+ ? ` (the CLI exited ${staged.status})`
670
+ : "";
671
+ const tail = staged.stderrTail ? `: ${staged.stderrTail}` : "";
672
+ body = `The run didn't finish${why}${tail}. Cleaning up \`${branch}\` — mention me to retry.`;
673
+ } else {
674
+ body = `No changes were produced. Cleaning up \`${branch}\`.`;
675
+ }
676
+ await tool("post_message", { channelId, parentId, body });
677
+ await finalizeProgress(
678
+ staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
679
+ );
436
680
  git(repoPath, ["switch", "-"]);
437
681
  git(repoPath, ["branch", "-D", branch]);
438
- return { status: "no-changes" };
682
+ return { status: staged.failed ? "run-failed" : "no-changes" };
439
683
  }
440
684
 
441
685
  // Bias to action (default): commit, push, and open a PR for review now — the
@@ -456,9 +700,11 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
456
700
  deps,
457
701
  parentId,
458
702
  });
703
+ await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
459
704
  return { ...result, stat: staged.stat };
460
705
  }
461
706
 
707
+ await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
462
708
  let proposal = await postProposal(staged);
463
709
  let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
464
710
  if (decision.kind === "cancelled") return await postStopped();
@@ -502,5 +748,6 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
502
748
  deps,
503
749
  parentId,
504
750
  });
751
+ await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
505
752
  return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
506
753
  }
package/src/run.mjs CHANGED
@@ -6,6 +6,7 @@ import { makeClient } from "./mcp.mjs";
6
6
  import { mentionHandle } from "./daemon.mjs";
7
7
  import { handleTask } from "./handler.mjs";
8
8
  import { createQueue, looksLikeCancel, dedupeKey } from "./queue.mjs";
9
+ import { reloadConfig } from "./config.mjs";
9
10
 
10
11
  export async function run(cfg, { handler = handleTask, log = console } = {}) {
11
12
  if (!cfg.token) {
@@ -28,6 +29,9 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
28
29
  const since = cfg.backfill ? 0 : Date.now();
29
30
  const toolNames = await listToolNames();
30
31
  const useMentions = !cfg.channelId && toolNames.includes("list_mentions");
32
+ // Capabilities of THIS server, so the handler degrades gracefully on older
33
+ // deploys (e.g. no edit_message → no live heartbeat, rather than erroring).
34
+ const caps = { editMessage: toolNames.includes("edit_message") };
31
35
 
32
36
  let channelIds = [];
33
37
  if (!useMentions) {
@@ -39,6 +43,10 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
39
43
  const seen = new Set();
40
44
  const cursor = { value: since ? new Date(since).toISOString() : null };
41
45
 
46
+ // Live config: re-read between polls so model/permission/codingCmd edits to
47
+ // hilos-agent.json take effect without a restart. Identity stays pinned.
48
+ let liveCfg = cfg;
49
+
42
50
  // One-at-a-time worker so the poll loop NEVER blocks on a multi-minute run.
43
51
  // The loop only fetches + enqueues; the queue runs jobs in order. This is what
44
52
  // lets a "stop" (and new mentions) be picked up while a task is still running.
@@ -50,7 +58,8 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
50
58
  async function safeHandle(message, channelId, signal) {
51
59
  try {
52
60
  log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
53
- await handler({ message, channelId, tool, me }, cfg, undefined, { signal });
61
+ // liveCfg so a job uses the latest model/permission/codingCmd at run time.
62
+ await handler({ message, channelId, tool, me, caps }, liveCfg, undefined, { signal });
54
63
  } catch (e) {
55
64
  log.error(`handler error: ${e.message}`);
56
65
  await tool("post_message", {
@@ -78,7 +87,7 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
78
87
  log.log(" (deduped a repeat of an in-flight/queued ask)");
79
88
  return;
80
89
  }
81
- if (cfg.queueAcks && !r.startedImmediately) {
90
+ if (liveCfg.queueAcks && !r.startedImmediately) {
82
91
  await tool("post_message", {
83
92
  channelId,
84
93
  parentId: m.parentId ?? null,
@@ -116,6 +125,15 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
116
125
  }
117
126
 
118
127
  do {
128
+ // Pick up live edits to hilos-agent.json (model/permission/codingCmd/etc.)
129
+ // before each pass. Guarded: a bad file leaves liveCfg untouched.
130
+ try {
131
+ const next = reloadConfig(liveCfg);
132
+ if (next.codingCmd !== liveCfg.codingCmd) log.log(`daemon: coding command → ${next.codingCmd}`);
133
+ liveCfg = next;
134
+ } catch (e) {
135
+ log.error(`config reload error (keeping previous): ${e.message}`);
136
+ }
119
137
  try {
120
138
  if (useMentions) await passViaMentions();
121
139
  else await passViaScan();
@@ -127,6 +145,6 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
127
145
  await queue.idle();
128
146
  break;
129
147
  }
130
- await new Promise((r) => setTimeout(r, cfg.pollMs));
148
+ await new Promise((r) => setTimeout(r, liveCfg.pollMs));
131
149
  } while (true);
132
150
  }