hilos-agent 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
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.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // Async CLI runner with a heartbeat. The coding CLI (`claude -p`, `codex`, …)
2
+ // runs for minutes and — with the default output format — buffers everything
3
+ // until it exits. The old blocking `spawnSync` froze the event loop, so the
4
+ // terminal sat dead-silent the whole time and people assumed it had hung and
5
+ // hit Ctrl+C (losing the run). This spawns asynchronously, captures output, and
6
+ // logs a periodic "still working…" heartbeat so the run visibly stays alive.
7
+
8
+ import { spawn } from "node:child_process";
9
+
10
+ /** Human-readable elapsed time: "45s", "2m 3s". */
11
+ export function fmtElapsed(ms) {
12
+ const total = Math.max(0, Math.round(ms / 1000));
13
+ const m = Math.floor(total / 60);
14
+ const s = total % 60;
15
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
16
+ }
17
+
18
+ // Guard against a pathological run flooding memory; the CLI's result is small.
19
+ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
20
+
21
+ /**
22
+ * @typedef {Object} RunCliOptions
23
+ * @property {string} cmd - command to run
24
+ * @property {string[]} [args] - arguments
25
+ * @property {string} [cwd] - working directory
26
+ * @property {number} [timeoutMs] - kill the child after this long (0 = no timeout)
27
+ * @property {string} [label] - verb shown in the heartbeat ("coding"/"thinking")
28
+ * @property {number} [heartbeatMs] - heartbeat interval (0 = no heartbeat)
29
+ * @property {() => number} [now] - clock, injectable for tests
30
+ * @property {{ log: (m: string) => void }} [log] - logger, injectable for tests
31
+ */
32
+
33
+ /**
34
+ * Run `cmd args` to completion without blocking the event loop.
35
+ *
36
+ * Returns a spawnSync-shaped result so callers can swap it in directly:
37
+ * { status: number|null, stdout: string, stderr: string, error: Error|null }
38
+ * `status` is the exit code, or null if the process failed to start or was
39
+ * killed (timeout). On timeout the child is SIGKILLed and `error` is set.
40
+ *
41
+ * @param {RunCliOptions} opts
42
+ */
43
+ export function runCli(opts) {
44
+ const {
45
+ cmd,
46
+ args = [],
47
+ cwd,
48
+ timeoutMs = 0,
49
+ label = "working",
50
+ heartbeatMs = 15000,
51
+ now = Date.now,
52
+ log = console,
53
+ } = opts || {};
54
+ return new Promise((resolve) => {
55
+ let child;
56
+ try {
57
+ child = spawn(cmd, args, { cwd });
58
+ } catch (error) {
59
+ resolve({ status: null, stdout: "", stderr: "", error });
60
+ return;
61
+ }
62
+
63
+ let stdout = "";
64
+ let stderr = "";
65
+ // Decode at the stream level so a multibyte char split across chunks isn't
66
+ // corrupted (the chat path posts stdout verbatim). Matches spawnSync's utf8.
67
+ child.stdout?.setEncoding("utf8");
68
+ child.stderr?.setEncoding("utf8");
69
+ const append = (buf, chunk) =>
70
+ buf.length >= MAX_CAPTURE_BYTES ? buf : buf + chunk;
71
+ child.stdout?.on("data", (c) => (stdout = append(stdout, c)));
72
+ child.stderr?.on("data", (c) => (stderr = append(stderr, c)));
73
+
74
+ const start = now();
75
+ const beat =
76
+ heartbeatMs > 0
77
+ ? setInterval(() => {
78
+ log.log(` … still ${label} (${fmtElapsed(now() - start)}) — Ctrl+C to cancel`);
79
+ }, heartbeatMs)
80
+ : null;
81
+ if (beat?.unref) beat.unref();
82
+
83
+ let timedOut = false;
84
+ const timer =
85
+ timeoutMs > 0
86
+ ? setTimeout(() => {
87
+ timedOut = true;
88
+ child.kill("SIGKILL");
89
+ }, timeoutMs)
90
+ : null;
91
+ if (timer?.unref) timer.unref();
92
+
93
+ const finish = (status, error) => {
94
+ if (beat) clearInterval(beat);
95
+ if (timer) clearTimeout(timer);
96
+ resolve({
97
+ status,
98
+ stdout,
99
+ stderr,
100
+ error: error || (timedOut ? new Error(`timed out after ${fmtElapsed(timeoutMs)}`) : null),
101
+ });
102
+ };
103
+ child.on("error", (error) => finish(null, error));
104
+ child.on("close", (code) => finish(code, null));
105
+ });
106
+ }
package/src/daemon.mjs CHANGED
@@ -72,7 +72,11 @@ export function buildProposalReport(o) {
72
72
  o.diffText +
73
73
  (o.truncated ? `\n… (+${o.omittedLines} more lines)` : "") +
74
74
  "\n```";
75
- const summary = `Proposed changes for: ${firstLine}\n\n${statLine}\n\n${diffBlock}`;
75
+ // Tag whoever asked so the proposal lands in their notifications, not just
76
+ // the channel. Handle matches the server's @-mention format (kebab of name).
77
+ const handle = mentionHandle(o.requester);
78
+ const lead = handle ? `@${handle} — proposed changes for: ${firstLine}` : `Proposed changes for: ${firstLine}`;
79
+ const summary = `${lead}\n\n${statLine}\n\n${diffBlock}`;
76
80
  const caveats = ["Not pushed yet — approve to push + open a PR, or reject to discard."];
77
81
  if (o.runFailed) caveats.push("The coding agent exited non-zero; review the diff carefully.");
78
82
  return { title: `Proposal: ${firstLine}`, summary, caveats, todos: [] };
package/src/handler.mjs CHANGED
@@ -13,7 +13,15 @@ import {
13
13
  decisionKind,
14
14
  commitMessage,
15
15
  prTitleBody,
16
+ mentionHandle,
16
17
  } from "./daemon.mjs";
18
+ import { runCli } from "./cli.mjs";
19
+
20
+ /** `@handle` for the person who asked, so the report tags them. */
21
+ function requesterTag(author) {
22
+ const handle = mentionHandle(author);
23
+ return handle ? `@${handle}` : "";
24
+ }
17
25
 
18
26
  function defaultDeps() {
19
27
  return {
@@ -54,7 +62,9 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
54
62
  return { kind: "timeout" };
55
63
  }
56
64
 
57
- async function applyDecision({ decision, repoPath, branch, task, cfg, tool, channelId, deps }) {
65
+ async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps }) {
66
+ const tag = requesterTag(requester);
67
+ const lead = tag ? `${tag} — ` : "";
58
68
  if (decision.kind === "approved") {
59
69
  // Guard: nothing staged → don't push an empty branch and falsely report "shipped".
60
70
  if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
@@ -87,8 +97,8 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
87
97
  title: `Shipped: ${title}`,
88
98
  summary:
89
99
  pr.ok && pr.url
90
- ? `Pushed \`${branch}\` and opened a pull request.`
91
- : `Pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
100
+ ? `${lead}pushed \`${branch}\` and opened a pull request.`
101
+ : `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
92
102
  prUrl: pr.ok && pr.url ? pr.url : undefined,
93
103
  caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
94
104
  });
@@ -170,11 +180,12 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
170
180
  `Conversation so far:\n${transcript}`;
171
181
 
172
182
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
173
- console.log(` chat → running \`${cfg.codingCmd}\`…`);
174
- const run = spawnSync(parts[0], [...parts.slice(1), prompt], {
175
- encoding: "utf8",
176
- timeout: cfg.runTimeoutMs,
177
- maxBuffer: 50 * 1024 * 1024,
183
+ console.log(` chat → running \`${cfg.codingCmd}\` (output appears when it finishes)…`);
184
+ const run = await runCli({
185
+ cmd: parts[0],
186
+ args: [...parts.slice(1), prompt],
187
+ timeoutMs: cfg.runTimeoutMs,
188
+ label: "thinking",
178
189
  });
179
190
  const reply = (run.stdout || "").trim();
180
191
  if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
@@ -251,12 +262,15 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
251
262
 
252
263
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
253
264
  const proposeRound = async (promptText) => {
254
- console.log(` code → running \`${cfg.codingCmd}\` in ${repoPath}…`);
255
- const run = spawnSync(parts[0], [...parts.slice(1), promptText], {
265
+ console.log(
266
+ ` code running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
267
+ );
268
+ const run = await runCli({
269
+ cmd: parts[0],
270
+ args: [...parts.slice(1), promptText],
256
271
  cwd: repoPath,
257
- encoding: "utf8",
258
- timeout: cfg.runTimeoutMs,
259
- maxBuffer: 50 * 1024 * 1024,
272
+ timeoutMs: cfg.runTimeoutMs,
273
+ label: "coding",
260
274
  });
261
275
  if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
262
276
  git(repoPath, ["add", "-A"]);
@@ -270,6 +284,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
270
284
  const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
271
285
  const report = buildProposalReport({
272
286
  task: message.body,
287
+ requester: message.author,
273
288
  repoFullName,
274
289
  branch,
275
290
  diffText,
@@ -325,6 +340,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
325
340
  repoPath,
326
341
  branch,
327
342
  task: message.body,
343
+ requester: message.author,
328
344
  cfg,
329
345
  tool,
330
346
  channelId,