hilos-agent 0.1.4 → 0.1.6
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 +25 -19
- package/package.json +2 -2
- package/src/cli.mjs +106 -0
- package/src/config.mjs +9 -3
- package/src/daemon.mjs +5 -1
- package/src/handler.mjs +132 -46
- package/src/mcp.mjs +15 -1
package/README.md
CHANGED
|
@@ -4,21 +4,24 @@ Run **your own** coding agent — Claude Code, Codex, Cursor, or any command —
|
|
|
4
4
|
an autonomous teammate inside a [hilos](https://hilos.sh) channel.
|
|
5
5
|
|
|
6
6
|
It connects to hilos over MCP, watches for `@mentions` of your agent in a
|
|
7
|
-
git-linked channel, runs your coding agent in a
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
only relays messages.
|
|
7
|
+
git-linked channel (including thread replies), runs your coding agent in a
|
|
8
|
+
**local** checkout, and — by default — **opens a PR for review**. Your code and
|
|
9
|
+
your git/`gh` credentials never leave your machine — hilos only relays messages.
|
|
11
10
|
|
|
12
11
|
```
|
|
13
12
|
hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
|
|
14
13
|
human: "@scout fix the navbar overflow"
|
|
15
|
-
agent: branches, runs your coding CLI,
|
|
16
|
-
agent: posts a
|
|
17
|
-
human: Approve ▶
|
|
18
|
-
Reject ▶
|
|
19
|
-
Changes ▶ agent
|
|
14
|
+
agent: branches, runs your coding CLI, commits + pushes, opens a PR
|
|
15
|
+
agent: posts a report card with the PR link
|
|
16
|
+
human: Approve ▶ hilos merges the PR
|
|
17
|
+
Reject ▶ hilos closes the PR
|
|
18
|
+
Changes ▶ agent re-works with your note
|
|
20
19
|
```
|
|
21
20
|
|
|
21
|
+
Prefer **approve-before-push**? Set `"gate": true` — the agent then posts the
|
|
22
|
+
proposed diff as a card and pushes only after you Approve (nothing leaves your
|
|
23
|
+
machine until then).
|
|
24
|
+
|
|
22
25
|
## Quick start
|
|
23
26
|
|
|
24
27
|
In hilos: open your agent's profile → **Connect** → copy the
|
|
@@ -39,9 +42,9 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
|
39
42
|
"url": "https://hilos.sh/api/mcp",
|
|
40
43
|
"token": "mgo_…",
|
|
41
44
|
"repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
|
|
42
|
-
"codingCmd": "claude -p",
|
|
45
|
+
"codingCmd": "claude -p --permission-mode acceptEdits", // or "codex exec", "cursor-agent", any command
|
|
43
46
|
"defaultBranch": "main",
|
|
44
|
-
"gate":
|
|
47
|
+
"gate": false // default: open a PR directly. true = approve-before-push
|
|
45
48
|
}
|
|
46
49
|
```
|
|
47
50
|
|
|
@@ -57,18 +60,21 @@ hilos-agent --channel <id> # scope to one channel
|
|
|
57
60
|
`repos`. No mapping → the agent says so and stops.
|
|
58
61
|
- **Run** — it branches off `defaultBranch` (refuses a dirty tree), runs
|
|
59
62
|
`codingCmd` with the task, and stages the result.
|
|
60
|
-
- **
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
- **Open a PR** (default) — it commits, pushes with *your* `git`/`gh`, opens a PR,
|
|
64
|
+
and posts a report card with the link. Review on the card: **Approve** merges,
|
|
65
|
+
**Reject** closes, **Request changes** re-works.
|
|
66
|
+
- **Approve-before-push** (`gate:true`) — instead, it posts the staged diff as a
|
|
67
|
+
card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
|
|
68
|
+
discards the branch, **Request changes** re-runs with your note (bounded rounds).
|
|
65
69
|
|
|
66
70
|
## Security
|
|
67
71
|
|
|
68
72
|
The daemon runs a coding agent that can execute code in your repo — exactly as if
|
|
69
|
-
you ran it in your terminal
|
|
70
|
-
|
|
71
|
-
|
|
73
|
+
you ran it in your terminal — and uses *your* local `git`/`gh` to push. By default
|
|
74
|
+
it opens a PR (nothing is force-merged; you review the PR, and merge/close run via
|
|
75
|
+
hilos's GitHub App only for workspace owners/admins). Want a human checkpoint
|
|
76
|
+
before anything is pushed? Set `"gate": true`. Keep your token in the config file
|
|
77
|
+
or `HILOS_TOKEN`, never in shared shell history.
|
|
72
78
|
|
|
73
79
|
## Flags
|
|
74
80
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions,
|
|
3
|
+
"version": "0.1.6",
|
|
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": {
|
|
7
7
|
"hilos-agent": "bin/hilos-agent.mjs"
|
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/config.mjs
CHANGED
|
@@ -43,9 +43,14 @@ const DEFAULTS = {
|
|
|
43
43
|
token: "",
|
|
44
44
|
channelId: "", // when set, watch only this channel (the per-channel override)
|
|
45
45
|
repos: {},
|
|
46
|
-
|
|
46
|
+
// acceptEdits lets the CLI make file edits without prompting (bias to action);
|
|
47
|
+
// it still won't run arbitrary commands. Override in hilos-agent.json if you
|
|
48
|
+
// want a stricter (or `--dangerously-skip-permissions`) command.
|
|
49
|
+
codingCmd: "claude -p --permission-mode acceptEdits",
|
|
47
50
|
defaultBranch: "main",
|
|
48
|
-
|
|
51
|
+
// Bias to action: open a PR directly for review (approve = merge on the card).
|
|
52
|
+
// Set gate:true for the older approve-before-push flow (propose a diff, wait).
|
|
53
|
+
gate: false,
|
|
49
54
|
maxRounds: 3,
|
|
50
55
|
pollMs: 5000,
|
|
51
56
|
runTimeoutMs: 600000,
|
|
@@ -89,7 +94,8 @@ export function writeStarterConfig(path, partial = {}) {
|
|
|
89
94
|
repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
|
|
90
95
|
codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
|
|
91
96
|
defaultBranch: DEFAULTS.defaultBranch,
|
|
92
|
-
|
|
97
|
+
// false = open a PR directly (bias to action); true = approve-before-push.
|
|
98
|
+
gate: false,
|
|
93
99
|
};
|
|
94
100
|
writeFileSync(target, JSON.stringify(starter, null, 2) + "\n");
|
|
95
101
|
return target;
|
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 {
|
|
@@ -33,7 +41,7 @@ function defaultDeps() {
|
|
|
33
41
|
};
|
|
34
42
|
}
|
|
35
43
|
|
|
36
|
-
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
44
|
+
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId }) {
|
|
37
45
|
if (!reportMessageId) return { kind: "timeout" };
|
|
38
46
|
const deadline = deps.now() + cfg.decisionTimeoutMs;
|
|
39
47
|
while (deps.now() < deadline) {
|
|
@@ -41,10 +49,20 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
|
41
49
|
const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
|
|
42
50
|
if (gr && gr.found && gr.report) report = gr.report;
|
|
43
51
|
if (!report) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
52
|
+
// Fallback for servers without get_report. In a thread the report lives
|
|
53
|
+
// under the thread root (read_channel only returns top-level), so scan the
|
|
54
|
+
// thread there; otherwise scan the channel tail.
|
|
55
|
+
let candidates = [];
|
|
56
|
+
if (parentId) {
|
|
57
|
+
const t = await tool("get_thread", { parentId }).catch(() => null);
|
|
58
|
+
candidates = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
|
|
59
|
+
} else {
|
|
60
|
+
const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
|
|
61
|
+
() => ({ messages: [] }),
|
|
62
|
+
);
|
|
63
|
+
candidates = messages;
|
|
64
|
+
}
|
|
65
|
+
const m = candidates.find((x) => x.id === reportMessageId);
|
|
48
66
|
report = m && m.report ? m.report : null;
|
|
49
67
|
}
|
|
50
68
|
const kind = report ? decisionKind(report) : null;
|
|
@@ -54,12 +72,23 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
|
54
72
|
return { kind: "timeout" };
|
|
55
73
|
}
|
|
56
74
|
|
|
57
|
-
|
|
75
|
+
/** Attach a just-opened PR to the channel (best-effort) so its live pill shows. */
|
|
76
|
+
async function linkPrFromUrl({ tool, channelId, url }) {
|
|
77
|
+
if (!url) return;
|
|
78
|
+
const m = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(url);
|
|
79
|
+
if (!m) return;
|
|
80
|
+
await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
84
|
+
const tag = requesterTag(requester);
|
|
85
|
+
const lead = tag ? `${tag} — ` : "";
|
|
58
86
|
if (decision.kind === "approved") {
|
|
59
87
|
// Guard: nothing staged → don't push an empty branch and falsely report "shipped".
|
|
60
88
|
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
61
89
|
await tool("post_message", {
|
|
62
90
|
channelId,
|
|
91
|
+
parentId,
|
|
63
92
|
body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
|
|
64
93
|
});
|
|
65
94
|
return { status: "nothing-staged", branch };
|
|
@@ -68,6 +97,7 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
|
|
|
68
97
|
if (commit.status !== 0) {
|
|
69
98
|
await tool("post_message", {
|
|
70
99
|
channelId,
|
|
100
|
+
parentId,
|
|
71
101
|
body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
|
|
72
102
|
});
|
|
73
103
|
return { status: "commit-failed", branch };
|
|
@@ -76,6 +106,7 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
|
|
|
76
106
|
if (push.status !== 0) {
|
|
77
107
|
await tool("post_message", {
|
|
78
108
|
channelId,
|
|
109
|
+
parentId,
|
|
79
110
|
body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
|
|
80
111
|
});
|
|
81
112
|
return { status: "push-failed", branch };
|
|
@@ -84,14 +115,17 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
|
|
|
84
115
|
const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
85
116
|
await tool("post_report", {
|
|
86
117
|
channelId,
|
|
118
|
+
parentId,
|
|
87
119
|
title: `Shipped: ${title}`,
|
|
88
120
|
summary:
|
|
89
121
|
pr.ok && pr.url
|
|
90
|
-
?
|
|
91
|
-
:
|
|
122
|
+
? `${lead}pushed \`${branch}\` and opened a pull request.`
|
|
123
|
+
: `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
|
|
92
124
|
prUrl: pr.ok && pr.url ? pr.url : undefined,
|
|
93
125
|
caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
|
|
94
126
|
});
|
|
127
|
+
// Work produced a PR → attach it to the channel so its live pill shows up.
|
|
128
|
+
await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
|
|
95
129
|
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
|
|
96
130
|
}
|
|
97
131
|
|
|
@@ -100,13 +134,14 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
|
|
|
100
134
|
deps.git(repoPath, ["clean", "-fd"]);
|
|
101
135
|
deps.git(repoPath, ["switch", "-"]);
|
|
102
136
|
deps.git(repoPath, ["branch", "-D", branch]);
|
|
103
|
-
await tool("post_message", { channelId, body: `Rejected — discarded \`${branch}\`.` });
|
|
137
|
+
await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
|
|
104
138
|
return { status: "discarded", branch };
|
|
105
139
|
}
|
|
106
140
|
|
|
107
141
|
if (decision.kind === "changes") {
|
|
108
142
|
await tool("post_message", {
|
|
109
143
|
channelId,
|
|
144
|
+
parentId,
|
|
110
145
|
body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
|
|
111
146
|
});
|
|
112
147
|
return { status: "changes", branch, note: decision.note };
|
|
@@ -114,6 +149,7 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
|
|
|
114
149
|
|
|
115
150
|
await tool("post_message", {
|
|
116
151
|
channelId,
|
|
152
|
+
parentId,
|
|
117
153
|
body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
|
|
118
154
|
});
|
|
119
155
|
return { status: "timeout", branch };
|
|
@@ -153,34 +189,47 @@ export function normalizeRemote(url) {
|
|
|
153
189
|
}
|
|
154
190
|
|
|
155
191
|
/** Run the configured CLI to produce a chat reply, using recent channel context. */
|
|
156
|
-
async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
192
|
+
async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId }) {
|
|
157
193
|
void message;
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
.
|
|
194
|
+
// In a thread, use the thread's own messages as context (and reply there);
|
|
195
|
+
// otherwise the recent channel tail. Threads are where conversations live, so
|
|
196
|
+
// the agent must follow the thread it was pinged in, not the whole channel.
|
|
197
|
+
let transcript;
|
|
198
|
+
if (parentId) {
|
|
199
|
+
const t = await tool("get_thread", { parentId }).catch(() => null);
|
|
200
|
+
const rows = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
|
|
201
|
+
transcript = rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
|
|
202
|
+
} else {
|
|
203
|
+
const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
|
|
204
|
+
messages: [],
|
|
205
|
+
}));
|
|
206
|
+
transcript = messages.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
|
|
207
|
+
}
|
|
165
208
|
const name = me?.agentName || "an assistant";
|
|
209
|
+
// Tell the agent what the room is connected to so it doesn't ask "which repo?".
|
|
210
|
+
const repoLine = repoLink
|
|
211
|
+
? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
|
|
212
|
+
: `If asked to change code, note that a repo isn't linked to this channel yet.`;
|
|
166
213
|
const prompt =
|
|
167
214
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
168
215
|
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
169
|
-
|
|
216
|
+
`${repoLine}\n\n` +
|
|
170
217
|
`Conversation so far:\n${transcript}`;
|
|
171
218
|
|
|
172
219
|
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
173
|
-
console.log(` chat → running \`${cfg.codingCmd}
|
|
174
|
-
const run =
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
220
|
+
console.log(` chat → running \`${cfg.codingCmd}\` (output appears when it finishes)…`);
|
|
221
|
+
const run = await runCli({
|
|
222
|
+
cmd: parts[0],
|
|
223
|
+
args: [...parts.slice(1), prompt],
|
|
224
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
225
|
+
label: "thinking",
|
|
178
226
|
});
|
|
179
227
|
const reply = (run.stdout || "").trim();
|
|
180
228
|
if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
|
|
181
229
|
console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
|
|
182
230
|
await tool("post_message", {
|
|
183
231
|
channelId,
|
|
232
|
+
parentId: parentId ?? null,
|
|
184
233
|
body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
|
|
185
234
|
});
|
|
186
235
|
}
|
|
@@ -189,13 +238,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
|
189
238
|
export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride) {
|
|
190
239
|
const deps = depsOverride || defaultDeps();
|
|
191
240
|
const git = deps.git;
|
|
241
|
+
// When the mention was a thread reply, keep the whole exchange in that thread.
|
|
242
|
+
const parentId = message.parentId ?? null;
|
|
192
243
|
|
|
193
244
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
194
245
|
const repoLink = links.find((l) => l.repo_full_name);
|
|
195
246
|
// Chat (no repo linked, OR a greeting/question even in a repo channel) →
|
|
196
247
|
// reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
|
|
197
248
|
if (!repoLink || !looksLikeCodeTask(message.body)) {
|
|
198
|
-
await respondConversationally({ message, channelId, tool, me, cfg });
|
|
249
|
+
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId });
|
|
199
250
|
return { status: "chat" };
|
|
200
251
|
}
|
|
201
252
|
const repoFullName = repoLink.repo_full_name;
|
|
@@ -213,6 +264,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
213
264
|
if (!repoPath) {
|
|
214
265
|
await tool("post_message", {
|
|
215
266
|
channelId,
|
|
267
|
+
parentId,
|
|
216
268
|
body:
|
|
217
269
|
`I don't have a local checkout of ${repoFullName}. Either run me from inside that ` +
|
|
218
270
|
`repo, or add it to your hilos-agent.json: "repos": { "${repoFullName}": "/abs/path" }.`,
|
|
@@ -222,12 +274,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
222
274
|
|
|
223
275
|
const status = git(repoPath, ["status", "--porcelain"]);
|
|
224
276
|
if (status.status !== 0) {
|
|
225
|
-
await tool("post_message", { channelId, body: `Can't read git status in ${repoPath}.` });
|
|
277
|
+
await tool("post_message", { channelId, parentId, body: `Can't read git status in ${repoPath}.` });
|
|
226
278
|
return { status: "git-error" };
|
|
227
279
|
}
|
|
228
280
|
if (status.stdout.trim()) {
|
|
229
281
|
await tool("post_message", {
|
|
230
282
|
channelId,
|
|
283
|
+
parentId,
|
|
231
284
|
body: `Working tree at ${repoPath} is dirty — commit or stash first.`,
|
|
232
285
|
});
|
|
233
286
|
return { status: "dirty" };
|
|
@@ -239,6 +292,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
239
292
|
if (co.status !== 0) {
|
|
240
293
|
await tool("post_message", {
|
|
241
294
|
channelId,
|
|
295
|
+
parentId,
|
|
242
296
|
body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
|
|
243
297
|
});
|
|
244
298
|
return { status: "branch-error" };
|
|
@@ -246,17 +300,22 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
246
300
|
|
|
247
301
|
await tool("post_message", {
|
|
248
302
|
channelId,
|
|
303
|
+
parentId,
|
|
249
304
|
body: `On it — working in ${repoFullName} on \`${branch}\`.`,
|
|
250
305
|
});
|
|
251
306
|
|
|
252
307
|
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
308
|
+
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
309
|
+
const runAndStage = async (promptText) => {
|
|
310
|
+
console.log(
|
|
311
|
+
` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
|
|
312
|
+
);
|
|
313
|
+
const run = await runCli({
|
|
314
|
+
cmd: parts[0],
|
|
315
|
+
args: [...parts.slice(1), promptText],
|
|
256
316
|
cwd: repoPath,
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
maxBuffer: 50 * 1024 * 1024,
|
|
317
|
+
timeoutMs: cfg.runTimeoutMs,
|
|
318
|
+
label: "coding",
|
|
260
319
|
});
|
|
261
320
|
if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
|
|
262
321
|
git(repoPath, ["add", "-A"]);
|
|
@@ -265,27 +324,34 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
265
324
|
console.log(" code → no changes produced");
|
|
266
325
|
return { empty: true };
|
|
267
326
|
}
|
|
268
|
-
console.log(" code → diff captured
|
|
327
|
+
console.log(" code → diff captured");
|
|
269
328
|
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
270
329
|
const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
|
|
330
|
+
return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
// Post a proposal card (approve-before-push mode) from a staged change.
|
|
334
|
+
const postProposal = async (staged) => {
|
|
271
335
|
const report = buildProposalReport({
|
|
272
336
|
task: message.body,
|
|
337
|
+
requester: message.author,
|
|
273
338
|
repoFullName,
|
|
274
339
|
branch,
|
|
275
|
-
diffText,
|
|
276
|
-
truncated,
|
|
277
|
-
omittedLines,
|
|
278
|
-
stat,
|
|
279
|
-
runFailed:
|
|
340
|
+
diffText: staged.diffText,
|
|
341
|
+
truncated: staged.truncated,
|
|
342
|
+
omittedLines: staged.omittedLines,
|
|
343
|
+
stat: staged.stat,
|
|
344
|
+
runFailed: staged.runFailed,
|
|
280
345
|
});
|
|
281
|
-
const res = await tool("post_report", { channelId, ...report });
|
|
282
|
-
return { reportMessageId: res?.messageId ?? null, stat };
|
|
346
|
+
const res = await tool("post_report", { channelId, parentId, ...report });
|
|
347
|
+
return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
|
|
283
348
|
};
|
|
284
349
|
|
|
285
|
-
|
|
286
|
-
if (
|
|
350
|
+
const staged = await runAndStage(message.body);
|
|
351
|
+
if (staged.empty) {
|
|
287
352
|
await tool("post_message", {
|
|
288
353
|
channelId,
|
|
354
|
+
parentId,
|
|
289
355
|
body: `No changes were produced. Cleaning up \`${branch}\`.`,
|
|
290
356
|
});
|
|
291
357
|
git(repoPath, ["switch", "-"]);
|
|
@@ -293,30 +359,48 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
293
359
|
return { status: "no-changes" };
|
|
294
360
|
}
|
|
295
361
|
|
|
362
|
+
// Bias to action (default): commit, push, and open a PR for review now — the
|
|
363
|
+
// report card's Approve merges it. `gate:true` keeps the older
|
|
364
|
+
// propose-a-diff-and-wait flow for users who want approve-before-push.
|
|
296
365
|
if (!cfg.gate) {
|
|
297
|
-
|
|
366
|
+
const result = await applyDecision({
|
|
367
|
+
decision: { kind: "approved" },
|
|
368
|
+
repoPath,
|
|
369
|
+
branch,
|
|
370
|
+
task: message.body,
|
|
371
|
+
requester: message.author,
|
|
372
|
+
cfg,
|
|
373
|
+
tool,
|
|
374
|
+
channelId,
|
|
375
|
+
deps,
|
|
376
|
+
parentId,
|
|
377
|
+
});
|
|
378
|
+
return { ...result, stat: staged.stat };
|
|
298
379
|
}
|
|
299
380
|
|
|
300
|
-
let
|
|
381
|
+
let proposal = await postProposal(staged);
|
|
382
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
|
|
301
383
|
const maxRounds = cfg.maxRounds || 3;
|
|
302
384
|
let round = 1;
|
|
303
385
|
while (decision.kind === "changes" && round < maxRounds) {
|
|
304
386
|
await tool("post_message", {
|
|
305
387
|
channelId,
|
|
388
|
+
parentId,
|
|
306
389
|
body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
307
390
|
});
|
|
308
|
-
const refined = await
|
|
391
|
+
const refined = await runAndStage(
|
|
309
392
|
`${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
|
|
310
393
|
);
|
|
311
394
|
if (refined.empty) {
|
|
312
395
|
await tool("post_message", {
|
|
313
396
|
channelId,
|
|
397
|
+
parentId,
|
|
314
398
|
body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
|
|
315
399
|
});
|
|
316
400
|
break;
|
|
317
401
|
}
|
|
318
|
-
proposal = refined;
|
|
319
|
-
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
|
|
402
|
+
proposal = await postProposal(refined);
|
|
403
|
+
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
|
|
320
404
|
round += 1;
|
|
321
405
|
}
|
|
322
406
|
|
|
@@ -325,10 +409,12 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
325
409
|
repoPath,
|
|
326
410
|
branch,
|
|
327
411
|
task: message.body,
|
|
412
|
+
requester: message.author,
|
|
328
413
|
cfg,
|
|
329
414
|
tool,
|
|
330
415
|
channelId,
|
|
331
416
|
deps,
|
|
417
|
+
parentId,
|
|
332
418
|
});
|
|
333
419
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
|
|
334
420
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
// Minimal MCP-over-HTTP client (JSON-RPC 2.0). Dependency-free: uses global
|
|
2
2
|
// fetch (Node >= 18). One bearer token, one endpoint.
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
|
|
5
|
+
// Best-effort host label so hilos can show "whose machine" this daemon runs on.
|
|
6
|
+
const CLIENT = (() => {
|
|
7
|
+
try {
|
|
8
|
+
return `${os.userInfo().username}@${os.hostname()}`;
|
|
9
|
+
} catch {
|
|
10
|
+
return os.hostname?.() || "";
|
|
11
|
+
}
|
|
12
|
+
})();
|
|
3
13
|
|
|
4
14
|
export function makeClient({ url, token }) {
|
|
5
15
|
let id = 0;
|
|
@@ -7,7 +17,11 @@ export function makeClient({ url, token }) {
|
|
|
7
17
|
async function rpc(method, params) {
|
|
8
18
|
const res = await fetch(url, {
|
|
9
19
|
method: "POST",
|
|
10
|
-
headers: {
|
|
20
|
+
headers: {
|
|
21
|
+
"content-type": "application/json",
|
|
22
|
+
authorization: `Bearer ${token}`,
|
|
23
|
+
...(CLIENT ? { "x-hilos-client": CLIENT } : {}),
|
|
24
|
+
},
|
|
11
25
|
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
12
26
|
});
|
|
13
27
|
if (!res.ok) {
|