hilos-agent 0.1.3 → 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.3",
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,12 +180,16 @@ 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
- const run = spawnSync(parts[0], [...parts.slice(1), prompt], {
174
- encoding: "utf8",
175
- timeout: cfg.runTimeoutMs,
176
- 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",
177
189
  });
178
190
  const reply = (run.stdout || "").trim();
191
+ if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
192
+ console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
179
193
  await tool("post_message", {
180
194
  channelId,
181
195
  body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
@@ -248,19 +262,29 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
248
262
 
249
263
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
250
264
  const proposeRound = async (promptText) => {
251
- 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],
252
271
  cwd: repoPath,
253
- encoding: "utf8",
254
- timeout: cfg.runTimeoutMs,
255
- maxBuffer: 50 * 1024 * 1024,
272
+ timeoutMs: cfg.runTimeoutMs,
273
+ label: "coding",
256
274
  });
275
+ if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
257
276
  git(repoPath, ["add", "-A"]);
258
277
  const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
259
- if (!diff.trim()) return { empty: true };
278
+ if (!diff.trim()) {
279
+ console.log(" code → no changes produced");
280
+ return { empty: true };
281
+ }
282
+ console.log(" code → diff captured; posting proposal");
260
283
  const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
261
284
  const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
262
285
  const report = buildProposalReport({
263
286
  task: message.body,
287
+ requester: message.author,
264
288
  repoFullName,
265
289
  branch,
266
290
  diffText,
@@ -316,6 +340,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
316
340
  repoPath,
317
341
  branch,
318
342
  task: message.body,
343
+ requester: message.author,
319
344
  cfg,
320
345
  tool,
321
346
  channelId,