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 +1 -1
- package/src/cli.mjs +106 -0
- package/src/daemon.mjs +5 -1
- package/src/handler.mjs +37 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
?
|
|
91
|
-
:
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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())
|
|
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,
|