hilos-agent 0.1.0
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 +77 -0
- package/bin/hilos-agent.mjs +88 -0
- package/package.json +27 -0
- package/src/config.mjs +96 -0
- package/src/daemon.mjs +87 -0
- package/src/handler.mjs +236 -0
- package/src/mcp.mjs +38 -0
- package/src/run.mjs +83 -0
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# hilos-agent
|
|
2
|
+
|
|
3
|
+
Run **your own** coding agent — Claude Code, Codex, Cursor, or any command — as
|
|
4
|
+
an autonomous teammate inside a [hilos](https://hilos.sh) channel.
|
|
5
|
+
|
|
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 **local** checkout, and posts the
|
|
8
|
+
proposed diff as a report card. **Nothing is pushed until a human approves in
|
|
9
|
+
hilos.** Your code and your git/`gh` credentials never leave your machine — hilos
|
|
10
|
+
only relays messages.
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
|
|
14
|
+
human: "@scout fix the navbar overflow"
|
|
15
|
+
agent: branches, runs your coding CLI, captures the diff
|
|
16
|
+
agent: posts a proposal card (NOT pushed)
|
|
17
|
+
human: Approve ▶ agent: git push + gh pr create ▶ posts the PR link
|
|
18
|
+
Reject ▶ agent: discards the branch
|
|
19
|
+
Changes ▶ agent: re-runs with your note, proposes again
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Quick start
|
|
23
|
+
|
|
24
|
+
In hilos: open your agent's profile → **Connect** → copy the
|
|
25
|
+
`hilos-agent --join …` command. Then on your machine:
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx hilos-agent --join <blob> # connect (token + endpoint come from the link)
|
|
29
|
+
hilos-agent init # or: write a starter config to edit
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Map each repo the agent may touch to a local checkout, then run it:
|
|
33
|
+
|
|
34
|
+
```jsonc
|
|
35
|
+
// ~/.hilos/agent.json (or ./hilos-agent.json)
|
|
36
|
+
{
|
|
37
|
+
"url": "https://hilos.sh/api/mcp",
|
|
38
|
+
"token": "mgo_…",
|
|
39
|
+
"repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
|
|
40
|
+
"codingCmd": "claude -p", // or "codex exec", "cursor-agent", any shell command
|
|
41
|
+
"defaultBranch": "main",
|
|
42
|
+
"gate": true // false = propose only, never push
|
|
43
|
+
}
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```sh
|
|
47
|
+
hilos-agent # watch every channel the agent is in
|
|
48
|
+
hilos-agent --channel <id> # scope to one channel
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## How it works
|
|
52
|
+
|
|
53
|
+
- **Trigger** — an `@mention` of your agent in a channel that's linked to a repo.
|
|
54
|
+
- **Repo resolution** — the channel's linked repo is mapped to a local path via
|
|
55
|
+
`repos`. No mapping → the agent says so and stops.
|
|
56
|
+
- **Run** — it branches off `defaultBranch` (refuses a dirty tree), runs
|
|
57
|
+
`codingCmd` with the task, and stages the result.
|
|
58
|
+
- **Propose** — the staged diff is posted as a report card. The agent then polls
|
|
59
|
+
for your decision.
|
|
60
|
+
- **Approve / Reject / Request changes** — approve pushes with *your* `git`/`gh`
|
|
61
|
+
and opens a PR; reject discards the branch; request-changes re-runs with your
|
|
62
|
+
note (bounded rounds).
|
|
63
|
+
|
|
64
|
+
## Security
|
|
65
|
+
|
|
66
|
+
The daemon runs a coding agent that can execute code in your repo — exactly as if
|
|
67
|
+
you ran it in your terminal. hilos can only send text; **push is gated on a human
|
|
68
|
+
approval recorded in hilos**, and uses your local credentials. Keep your token in
|
|
69
|
+
the config file or `HILOS_TOKEN`, never in shared shell history.
|
|
70
|
+
|
|
71
|
+
## Flags
|
|
72
|
+
|
|
73
|
+
`--join <blob>` · `--channel <id>` · `--config <path>` · `--coding-cmd <cmd>` ·
|
|
74
|
+
`--once` · `--backfill` · `--no-gate` · `--help`
|
|
75
|
+
|
|
76
|
+
Env: `HILOS_TOKEN`, `HILOS_URL`, `HILOS_CHANNEL`, `CODING_CMD`, `HILOS_ONCE=1`,
|
|
77
|
+
`HILOS_BACKFILL=1`.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hilos-agent — run your coding agent as an autonomous teammate in a hilos
|
|
3
|
+
// channel. Picks up @mentions, proposes a diff, pushes only after a human
|
|
4
|
+
// approves in hilos. Your code + credentials never leave your machine.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// hilos-agent --join <blob> connect with a copy-paste link from hilos
|
|
8
|
+
// hilos-agent init write a starter config (~/.hilos/agent.json)
|
|
9
|
+
// hilos-agent run with the resolved config (default)
|
|
10
|
+
// hilos-agent run same as above, explicit
|
|
11
|
+
//
|
|
12
|
+
// Config (./hilos-agent.json or ~/.hilos/agent.json), overlaid by env + flags:
|
|
13
|
+
// { "url", "token", "channelId", "repos": {"owner/name":"/abs/path"},
|
|
14
|
+
// "codingCmd": "claude -p", "defaultBranch": "main", "gate": true }
|
|
15
|
+
|
|
16
|
+
import { resolveConfig, decodeJoin, writeStarterConfig, GLOBAL_CONFIG } from "../src/config.mjs";
|
|
17
|
+
import { run } from "../src/run.mjs";
|
|
18
|
+
|
|
19
|
+
function parseArgs(argv) {
|
|
20
|
+
const flags = {};
|
|
21
|
+
const positional = [];
|
|
22
|
+
for (let i = 0; i < argv.length; i++) {
|
|
23
|
+
const a = argv[i];
|
|
24
|
+
if (a === "--join") flags.join = argv[++i];
|
|
25
|
+
else if (a === "--config") flags.config = argv[++i];
|
|
26
|
+
else if (a === "--channel") flags.channelId = argv[++i];
|
|
27
|
+
else if (a === "--url") flags.url = argv[++i];
|
|
28
|
+
else if (a === "--token") flags.token = argv[++i];
|
|
29
|
+
else if (a === "--coding-cmd") flags.codingCmd = argv[++i];
|
|
30
|
+
else if (a === "--once") flags.once = true;
|
|
31
|
+
else if (a === "--backfill") flags.backfill = true;
|
|
32
|
+
else if (a === "--no-gate") flags.gate = false;
|
|
33
|
+
else if (a === "-h" || a === "--help") flags.help = true;
|
|
34
|
+
else positional.push(a);
|
|
35
|
+
}
|
|
36
|
+
return { cmd: positional[0] || "run", flags };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HELP = `hilos-agent — your coding agent as a teammate in hilos
|
|
40
|
+
|
|
41
|
+
hilos-agent --join <blob> connect using a link copied from hilos
|
|
42
|
+
hilos-agent init write a starter config to ~/.hilos/agent.json
|
|
43
|
+
hilos-agent run the daemon (watch @mentions, propose diffs)
|
|
44
|
+
|
|
45
|
+
Options:
|
|
46
|
+
--channel <id> watch only one channel (per-channel override)
|
|
47
|
+
--config <path> use a specific config file
|
|
48
|
+
--coding-cmd <cmd> the coding agent to run (default: "claude -p")
|
|
49
|
+
--once one poll then exit (cron-friendly)
|
|
50
|
+
--backfill also act on mentions that predate startup
|
|
51
|
+
--no-gate propose only; don't wait for approval / push
|
|
52
|
+
-h, --help this help
|
|
53
|
+
|
|
54
|
+
Docs: https://hilos.sh · https://www.npmjs.com/package/hilos-agent`;
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const { cmd, flags } = parseArgs(process.argv.slice(2));
|
|
58
|
+
if (flags.help || cmd === "help") {
|
|
59
|
+
console.log(HELP);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const joinPayload = flags.join ? decodeJoin(flags.join) : undefined;
|
|
64
|
+
if (flags.join && !joinPayload) {
|
|
65
|
+
console.error("That --join link is invalid. Re-copy it from hilos.");
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (cmd === "init") {
|
|
70
|
+
const path = writeStarterConfig(joinPayload ? GLOBAL_CONFIG : flags.config, joinPayload || {});
|
|
71
|
+
console.log(`Wrote ${path}.`);
|
|
72
|
+
console.log(joinPayload ? "Token + endpoint set from your link." : "Fill in token + repos, then run `hilos-agent`.");
|
|
73
|
+
console.log('Map your repos: "repos": { "owner/name": "/abs/path/to/checkout" }');
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// run (default) — when --join is passed without init, connect straight away.
|
|
78
|
+
const cliFlags = { ...flags };
|
|
79
|
+
delete cliFlags.join;
|
|
80
|
+
delete cliFlags.help;
|
|
81
|
+
const cfg = resolveConfig({ flags: cliFlags, join: joinPayload });
|
|
82
|
+
await run(cfg);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
main().catch((e) => {
|
|
86
|
+
console.error(e?.message || e);
|
|
87
|
+
process.exit(1);
|
|
88
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hilos-agent",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"hilos-agent": "bin/hilos-agent.mjs"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"src",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=18"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"hilos",
|
|
19
|
+
"mcp",
|
|
20
|
+
"agent",
|
|
21
|
+
"claude-code",
|
|
22
|
+
"codex",
|
|
23
|
+
"cursor",
|
|
24
|
+
"coding-agent"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT"
|
|
27
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// Config resolution: a JSON file (./hilos-agent.json or ~/.hilos/agent.json),
|
|
2
|
+
// overlaid by env vars and CLI flags / a --join blob. The join blob carries the
|
|
3
|
+
// MCP url + token (+ optional channel) so a user can paste one command.
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join, dirname } from "node:path";
|
|
8
|
+
|
|
9
|
+
export const GLOBAL_CONFIG = join(homedir(), ".hilos", "agent.json");
|
|
10
|
+
export const LOCAL_CONFIG = "hilos-agent.json";
|
|
11
|
+
|
|
12
|
+
/** Decode a base64url join blob → { url, token, channelId }, or null. */
|
|
13
|
+
export function decodeJoin(blob) {
|
|
14
|
+
try {
|
|
15
|
+
const b64 = String(blob).trim().replace(/-/g, "+").replace(/_/g, "/");
|
|
16
|
+
const json = Buffer.from(b64, "base64").toString("utf8");
|
|
17
|
+
const o = JSON.parse(json);
|
|
18
|
+
if (!o.u || !o.t) return null;
|
|
19
|
+
return { url: o.u, token: o.t, channelId: o.c };
|
|
20
|
+
} catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function readJson(path) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** First config file that exists: explicit path → ./hilos-agent.json → ~/.hilos/agent.json. */
|
|
34
|
+
export function findConfigPath(explicit) {
|
|
35
|
+
if (explicit) return explicit;
|
|
36
|
+
if (existsSync(LOCAL_CONFIG)) return LOCAL_CONFIG;
|
|
37
|
+
if (existsSync(GLOBAL_CONFIG)) return GLOBAL_CONFIG;
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const DEFAULTS = {
|
|
42
|
+
url: "https://hilos.sh/api/mcp",
|
|
43
|
+
token: "",
|
|
44
|
+
channelId: "", // when set, watch only this channel (the per-channel override)
|
|
45
|
+
repos: {},
|
|
46
|
+
codingCmd: "claude -p",
|
|
47
|
+
defaultBranch: "main",
|
|
48
|
+
gate: true,
|
|
49
|
+
maxRounds: 3,
|
|
50
|
+
pollMs: 5000,
|
|
51
|
+
runTimeoutMs: 600000,
|
|
52
|
+
decisionTimeoutMs: 1800000,
|
|
53
|
+
decisionPollMs: 5000,
|
|
54
|
+
backfill: false,
|
|
55
|
+
once: false,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** Merge defaults ← file ← env ← join ← flags into one config object. */
|
|
59
|
+
export function resolveConfig({ flags = {}, join: joinPayload } = {}) {
|
|
60
|
+
const file = readJson(findConfigPath(flags.config)) || {};
|
|
61
|
+
const env = {
|
|
62
|
+
url: process.env.HILOS_URL,
|
|
63
|
+
token: process.env.HILOS_TOKEN,
|
|
64
|
+
channelId: process.env.HILOS_CHANNEL,
|
|
65
|
+
codingCmd: process.env.CODING_CMD,
|
|
66
|
+
backfill: process.env.HILOS_BACKFILL === "1" ? true : undefined,
|
|
67
|
+
once: process.env.HILOS_ONCE === "1" ? true : undefined,
|
|
68
|
+
};
|
|
69
|
+
const merged = { ...DEFAULTS, ...file };
|
|
70
|
+
for (const [k, v] of Object.entries(env)) if (v !== undefined && v !== "") merged[k] = v;
|
|
71
|
+
if (joinPayload) {
|
|
72
|
+
merged.url = joinPayload.url;
|
|
73
|
+
merged.token = joinPayload.token;
|
|
74
|
+
if (joinPayload.channelId) merged.channelId = joinPayload.channelId;
|
|
75
|
+
}
|
|
76
|
+
for (const [k, v] of Object.entries(flags)) if (v !== undefined) merged[k] = v;
|
|
77
|
+
merged.repos = { ...DEFAULTS.repos, ...(file.repos || {}) };
|
|
78
|
+
return merged;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Write a starter config file (used by `hilos-agent init`). */
|
|
82
|
+
export function writeStarterConfig(path, partial = {}) {
|
|
83
|
+
const target = path || GLOBAL_CONFIG;
|
|
84
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
85
|
+
const starter = {
|
|
86
|
+
url: partial.url || DEFAULTS.url,
|
|
87
|
+
token: partial.token || "",
|
|
88
|
+
channelId: partial.channelId || "",
|
|
89
|
+
repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
|
|
90
|
+
codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
|
|
91
|
+
defaultBranch: DEFAULTS.defaultBranch,
|
|
92
|
+
gate: true,
|
|
93
|
+
};
|
|
94
|
+
writeFileSync(target, JSON.stringify(starter, null, 2) + "\n");
|
|
95
|
+
return target;
|
|
96
|
+
}
|
package/src/daemon.mjs
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Pure daemon helpers — no I/O. (Mirror of the app's scripts/lib/daemon.mjs so
|
|
2
|
+
// the package stands alone; keep them equivalent.)
|
|
3
|
+
|
|
4
|
+
/** Branch name from a task: `hilos/<kebab-first-words>-<suffix>`. */
|
|
5
|
+
export function branchSlug(text, suffix) {
|
|
6
|
+
const base =
|
|
7
|
+
String(text || "")
|
|
8
|
+
.toLowerCase()
|
|
9
|
+
.replace(/@[a-z0-9-]+/g, "")
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "")
|
|
12
|
+
.split("-")
|
|
13
|
+
.filter(Boolean)
|
|
14
|
+
.slice(0, 6)
|
|
15
|
+
.join("-") || "task";
|
|
16
|
+
return `hilos/${base}-${suffix}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Truncate a diff to a byte budget at a line boundary. */
|
|
20
|
+
export function truncateDiff(diff, maxBytes = 12000) {
|
|
21
|
+
if (diff.length <= maxBytes) return { text: diff, truncated: false, omittedLines: 0 };
|
|
22
|
+
const slice = diff.slice(0, maxBytes);
|
|
23
|
+
const lastNl = slice.lastIndexOf("\n");
|
|
24
|
+
const kept = lastNl > 0 ? slice.slice(0, lastNl) : slice;
|
|
25
|
+
const omittedLines = diff.slice(kept.length).split("\n").length - 1;
|
|
26
|
+
return { text: kept, truncated: true, omittedLines };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Local checkout path for a repo from config, or null. */
|
|
30
|
+
export function resolveRepoPath(config, repoFullName) {
|
|
31
|
+
return (config && config.repos && config.repos[repoFullName]) || null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Parse `git diff --shortstat` output into counts. */
|
|
35
|
+
export function parseShortstat(s) {
|
|
36
|
+
const files = /(\d+) files? changed/.exec(s || "");
|
|
37
|
+
const ins = /(\d+) insertions?\(\+\)/.exec(s || "");
|
|
38
|
+
const del = /(\d+) deletions?\(-\)/.exec(s || "");
|
|
39
|
+
return {
|
|
40
|
+
files: files ? Number(files[1]) : 0,
|
|
41
|
+
insertions: ins ? Number(ins[1]) : 0,
|
|
42
|
+
deletions: del ? Number(del[1]) : 0,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Read a decision kind off a report object, or null if undecided. */
|
|
47
|
+
export function decisionKind(report) {
|
|
48
|
+
return report && report.decision && report.decision.kind ? report.decision.kind : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Commit message for an approved proposal. */
|
|
52
|
+
export function commitMessage(task) {
|
|
53
|
+
const first = String(task || "").split("\n")[0].slice(0, 72) || "hilos change";
|
|
54
|
+
return `${first}\n\nProposed via hilos and approved by a human reviewer.`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** PR title + body for an approved proposal. */
|
|
58
|
+
export function prTitleBody(task, branch) {
|
|
59
|
+
const title = String(task || "").split("\n")[0].slice(0, 72) || branch;
|
|
60
|
+
const body =
|
|
61
|
+
"Proposed by a hilos agent from a channel request, approved by a human reviewer.\n\n" +
|
|
62
|
+
`Task: ${String(task || "").trim()}`;
|
|
63
|
+
return { title, body };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Build the post_report payload that serves as the approval card. */
|
|
67
|
+
export function buildProposalReport(o) {
|
|
68
|
+
const firstLine = String(o.task || "").split("\n")[0].slice(0, 72) || "task";
|
|
69
|
+
const statLine = `${o.stat.files} file(s), +${o.stat.insertions}/-${o.stat.deletions} on \`${o.branch}\` in ${o.repoFullName}`;
|
|
70
|
+
const diffBlock =
|
|
71
|
+
"```diff\n" +
|
|
72
|
+
o.diffText +
|
|
73
|
+
(o.truncated ? `\n… (+${o.omittedLines} more lines)` : "") +
|
|
74
|
+
"\n```";
|
|
75
|
+
const summary = `Proposed changes for: ${firstLine}\n\n${statLine}\n\n${diffBlock}`;
|
|
76
|
+
const caveats = ["Not pushed yet — approve to push + open a PR, or reject to discard."];
|
|
77
|
+
if (o.runFailed) caveats.push("The coding agent exited non-zero; review the diff carefully.");
|
|
78
|
+
return { title: `Proposal: ${firstLine}`, summary, caveats, todos: [] };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** kebab handle from a display name (matches the server's mention handle). */
|
|
82
|
+
export function mentionHandle(name) {
|
|
83
|
+
return String(name || "")
|
|
84
|
+
.toLowerCase()
|
|
85
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
86
|
+
.replace(/^-+|-+$/g, "");
|
|
87
|
+
}
|
package/src/handler.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// The default task handler: turn an @mention in a git-linked channel into a
|
|
2
|
+
// branch + a coding-agent run + a proposed diff, gated on a human approval in
|
|
3
|
+
// hilos. Nothing is pushed until approved. Your code + credentials stay local.
|
|
4
|
+
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import {
|
|
7
|
+
branchSlug,
|
|
8
|
+
truncateDiff,
|
|
9
|
+
resolveRepoPath,
|
|
10
|
+
parseShortstat,
|
|
11
|
+
buildProposalReport,
|
|
12
|
+
decisionKind,
|
|
13
|
+
commitMessage,
|
|
14
|
+
prTitleBody,
|
|
15
|
+
} from "./daemon.mjs";
|
|
16
|
+
|
|
17
|
+
function defaultDeps() {
|
|
18
|
+
return {
|
|
19
|
+
git: (cwd, args) =>
|
|
20
|
+
spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 50 * 1024 * 1024 }),
|
|
21
|
+
openPR: (cwd, { title, body, branch, base }) => {
|
|
22
|
+
const r = spawnSync(
|
|
23
|
+
"gh",
|
|
24
|
+
["pr", "create", "--title", title, "--body", body, "--head", branch, "--base", base],
|
|
25
|
+
{ cwd, encoding: "utf8" },
|
|
26
|
+
);
|
|
27
|
+
const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
|
|
28
|
+
return { ok: r.status === 0, url, stderr: r.stderr || "" };
|
|
29
|
+
},
|
|
30
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
31
|
+
now: () => Date.now(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
36
|
+
if (!reportMessageId) return { kind: "timeout" };
|
|
37
|
+
const deadline = deps.now() + cfg.decisionTimeoutMs;
|
|
38
|
+
while (deps.now() < deadline) {
|
|
39
|
+
let report = null;
|
|
40
|
+
const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
|
|
41
|
+
if (gr && gr.found && gr.report) report = gr.report;
|
|
42
|
+
if (!report) {
|
|
43
|
+
const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
|
|
44
|
+
() => ({ messages: [] }),
|
|
45
|
+
);
|
|
46
|
+
const m = messages.find((x) => x.id === reportMessageId);
|
|
47
|
+
report = m && m.report ? m.report : null;
|
|
48
|
+
}
|
|
49
|
+
const kind = report ? decisionKind(report) : null;
|
|
50
|
+
if (kind) return { kind, note: report.decision?.note || null };
|
|
51
|
+
await deps.sleep(cfg.decisionPollMs);
|
|
52
|
+
}
|
|
53
|
+
return { kind: "timeout" };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function applyDecision({ decision, repoPath, branch, task, cfg, tool, channelId, deps }) {
|
|
57
|
+
if (decision.kind === "approved") {
|
|
58
|
+
deps.git(repoPath, ["commit", "-m", commitMessage(task)]);
|
|
59
|
+
const push = deps.git(repoPath, ["push", "-u", "origin", branch]);
|
|
60
|
+
if (push.status !== 0) {
|
|
61
|
+
await tool("post_message", {
|
|
62
|
+
channelId,
|
|
63
|
+
body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
|
|
64
|
+
});
|
|
65
|
+
return { status: "push-failed", branch };
|
|
66
|
+
}
|
|
67
|
+
const { title, body } = prTitleBody(task, branch);
|
|
68
|
+
const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
69
|
+
await tool("post_report", {
|
|
70
|
+
channelId,
|
|
71
|
+
title: `Shipped: ${title}`,
|
|
72
|
+
summary:
|
|
73
|
+
pr.ok && pr.url
|
|
74
|
+
? `Pushed \`${branch}\` and opened a pull request.`
|
|
75
|
+
: `Pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
|
|
76
|
+
prUrl: pr.ok && pr.url ? pr.url : undefined,
|
|
77
|
+
caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
|
|
78
|
+
});
|
|
79
|
+
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (decision.kind === "rejected") {
|
|
83
|
+
deps.git(repoPath, ["reset", "--hard", "HEAD"]);
|
|
84
|
+
deps.git(repoPath, ["clean", "-fd"]);
|
|
85
|
+
deps.git(repoPath, ["switch", "-"]);
|
|
86
|
+
deps.git(repoPath, ["branch", "-D", branch]);
|
|
87
|
+
await tool("post_message", { channelId, body: `Rejected — discarded \`${branch}\`.` });
|
|
88
|
+
return { status: "discarded", branch };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (decision.kind === "changes") {
|
|
92
|
+
await tool("post_message", {
|
|
93
|
+
channelId,
|
|
94
|
+
body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
|
|
95
|
+
});
|
|
96
|
+
return { status: "changes", branch, note: decision.note };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
await tool("post_message", {
|
|
100
|
+
channelId,
|
|
101
|
+
body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
|
|
102
|
+
});
|
|
103
|
+
return { status: "timeout", branch };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Handle one task. cfg/deps injectable for tests. */
|
|
107
|
+
export async function handleTask({ message, channelId, tool }, cfg, depsOverride) {
|
|
108
|
+
const deps = depsOverride || defaultDeps();
|
|
109
|
+
const git = deps.git;
|
|
110
|
+
|
|
111
|
+
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
112
|
+
const repoLink = links.find((l) => l.repo_full_name);
|
|
113
|
+
if (!repoLink) {
|
|
114
|
+
await tool("post_message", {
|
|
115
|
+
channelId,
|
|
116
|
+
body: "No repo is linked to this channel — link one so I can work on it.",
|
|
117
|
+
});
|
|
118
|
+
return { status: "no-repo" };
|
|
119
|
+
}
|
|
120
|
+
const repoFullName = repoLink.repo_full_name;
|
|
121
|
+
|
|
122
|
+
const repoPath = resolveRepoPath(cfg, repoFullName);
|
|
123
|
+
if (!repoPath) {
|
|
124
|
+
await tool("post_message", {
|
|
125
|
+
channelId,
|
|
126
|
+
body: `No local path is configured for ${repoFullName}. Add it to your hilos-agent.json under "repos".`,
|
|
127
|
+
});
|
|
128
|
+
return { status: "no-path" };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const status = git(repoPath, ["status", "--porcelain"]);
|
|
132
|
+
if (status.status !== 0) {
|
|
133
|
+
await tool("post_message", { channelId, body: `Can't read git status in ${repoPath}.` });
|
|
134
|
+
return { status: "git-error" };
|
|
135
|
+
}
|
|
136
|
+
if (status.stdout.trim()) {
|
|
137
|
+
await tool("post_message", {
|
|
138
|
+
channelId,
|
|
139
|
+
body: `Working tree at ${repoPath} is dirty — commit or stash first.`,
|
|
140
|
+
});
|
|
141
|
+
return { status: "dirty" };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const branch = branchSlug(message.body, String(deps.now()).slice(-6));
|
|
145
|
+
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
146
|
+
const co = git(repoPath, ["switch", "-c", branch]);
|
|
147
|
+
if (co.status !== 0) {
|
|
148
|
+
await tool("post_message", {
|
|
149
|
+
channelId,
|
|
150
|
+
body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
|
|
151
|
+
});
|
|
152
|
+
return { status: "branch-error" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
await tool("post_message", {
|
|
156
|
+
channelId,
|
|
157
|
+
body: `On it — working in ${repoFullName} on \`${branch}\`.`,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
161
|
+
const proposeRound = async (promptText) => {
|
|
162
|
+
const run = spawnSync(parts[0], [...parts.slice(1), promptText], {
|
|
163
|
+
cwd: repoPath,
|
|
164
|
+
encoding: "utf8",
|
|
165
|
+
timeout: cfg.runTimeoutMs,
|
|
166
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
167
|
+
});
|
|
168
|
+
git(repoPath, ["add", "-A"]);
|
|
169
|
+
const diff = git(repoPath, ["diff", "--cached"]).stdout || "";
|
|
170
|
+
if (!diff.trim()) return { empty: true };
|
|
171
|
+
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
172
|
+
const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
|
|
173
|
+
const report = buildProposalReport({
|
|
174
|
+
task: message.body,
|
|
175
|
+
repoFullName,
|
|
176
|
+
branch,
|
|
177
|
+
diffText,
|
|
178
|
+
truncated,
|
|
179
|
+
omittedLines,
|
|
180
|
+
stat,
|
|
181
|
+
runFailed: run.status !== 0,
|
|
182
|
+
});
|
|
183
|
+
const res = await tool("post_report", { channelId, ...report });
|
|
184
|
+
return { reportMessageId: res?.messageId ?? null, stat };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
let proposal = await proposeRound(message.body);
|
|
188
|
+
if (proposal.empty) {
|
|
189
|
+
await tool("post_message", {
|
|
190
|
+
channelId,
|
|
191
|
+
body: `No changes were produced. Cleaning up \`${branch}\`.`,
|
|
192
|
+
});
|
|
193
|
+
git(repoPath, ["switch", "-"]);
|
|
194
|
+
git(repoPath, ["branch", "-D", branch]);
|
|
195
|
+
return { status: "no-changes" };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!cfg.gate) {
|
|
199
|
+
return { status: "proposed", branch, reportMessageId: proposal.reportMessageId, stat: proposal.stat };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
|
|
203
|
+
const maxRounds = cfg.maxRounds || 3;
|
|
204
|
+
let round = 1;
|
|
205
|
+
while (decision.kind === "changes" && round < maxRounds) {
|
|
206
|
+
await tool("post_message", {
|
|
207
|
+
channelId,
|
|
208
|
+
body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
209
|
+
});
|
|
210
|
+
const refined = await proposeRound(
|
|
211
|
+
`${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
|
|
212
|
+
);
|
|
213
|
+
if (refined.empty) {
|
|
214
|
+
await tool("post_message", {
|
|
215
|
+
channelId,
|
|
216
|
+
body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
|
|
217
|
+
});
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
proposal = refined;
|
|
221
|
+
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
|
|
222
|
+
round += 1;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const result = await applyDecision({
|
|
226
|
+
decision,
|
|
227
|
+
repoPath,
|
|
228
|
+
branch,
|
|
229
|
+
task: message.body,
|
|
230
|
+
cfg,
|
|
231
|
+
tool,
|
|
232
|
+
channelId,
|
|
233
|
+
deps,
|
|
234
|
+
});
|
|
235
|
+
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
|
|
236
|
+
}
|
package/src/mcp.mjs
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Minimal MCP-over-HTTP client (JSON-RPC 2.0). Dependency-free: uses global
|
|
2
|
+
// fetch (Node >= 18). One bearer token, one endpoint.
|
|
3
|
+
|
|
4
|
+
export function makeClient({ url, token }) {
|
|
5
|
+
let id = 0;
|
|
6
|
+
|
|
7
|
+
async function rpc(method, params) {
|
|
8
|
+
const res = await fetch(url, {
|
|
9
|
+
method: "POST",
|
|
10
|
+
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
11
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
12
|
+
});
|
|
13
|
+
const json = await res.json();
|
|
14
|
+
if (json.error) throw new Error(json.error.message || "rpc error");
|
|
15
|
+
return json.result;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Call a tool and parse its first text block (hilos tools return JSON text).
|
|
19
|
+
async function tool(name, args = {}) {
|
|
20
|
+
const r = await rpc("tools/call", { name, arguments: args });
|
|
21
|
+
const text = r?.content?.[0]?.text;
|
|
22
|
+
try {
|
|
23
|
+
return JSON.parse(text);
|
|
24
|
+
} catch {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function listToolNames() {
|
|
30
|
+
try {
|
|
31
|
+
return ((await rpc("tools/list"))?.tools ?? []).map((t) => t.name);
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { rpc, tool, listToolNames };
|
|
38
|
+
}
|
package/src/run.mjs
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// The poll loop: connect over MCP, watch for @mentions of this agent, and hand
|
|
2
|
+
// each one to the task handler. Prefers the workspace-wide list_mentions tool
|
|
3
|
+
// (one poll, cursor-based); falls back to per-channel scanning on older servers.
|
|
4
|
+
|
|
5
|
+
import { makeClient } from "./mcp.mjs";
|
|
6
|
+
import { mentionHandle } from "./daemon.mjs";
|
|
7
|
+
import { handleTask } from "./handler.mjs";
|
|
8
|
+
|
|
9
|
+
export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
10
|
+
if (!cfg.token) {
|
|
11
|
+
log.error("No token. Run `hilos-agent init`, set HILOS_TOKEN, or use --join <blob>.");
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const { tool, listToolNames } = makeClient({ url: cfg.url, token: cfg.token });
|
|
16
|
+
|
|
17
|
+
const who = await tool("whoami");
|
|
18
|
+
const me = { ...who, handle: mentionHandle(who.agentName) };
|
|
19
|
+
log.log(`hilos-agent: ${me.agentName} (@${me.handle}) — ${cfg.url}`);
|
|
20
|
+
if (cfg.channelId) log.log(`scope: channel ${cfg.channelId}`);
|
|
21
|
+
log.log(`repos: ${Object.keys(cfg.repos).join(", ") || "(none configured)"}`);
|
|
22
|
+
|
|
23
|
+
const since = cfg.backfill ? 0 : Date.now();
|
|
24
|
+
const toolNames = await listToolNames();
|
|
25
|
+
const useMentions = !cfg.channelId && toolNames.includes("list_mentions");
|
|
26
|
+
|
|
27
|
+
let channelIds = [];
|
|
28
|
+
if (!useMentions) {
|
|
29
|
+
if (cfg.channelId) channelIds = [cfg.channelId];
|
|
30
|
+
else channelIds = ((await tool("list_channels"))?.channels ?? []).map((c) => c.id);
|
|
31
|
+
}
|
|
32
|
+
log.log(useMentions ? "watching: @-mentions" : `watching: ${channelIds.length} channel(s)`);
|
|
33
|
+
|
|
34
|
+
const seen = new Set();
|
|
35
|
+
const cursor = { value: since ? new Date(since).toISOString() : null };
|
|
36
|
+
|
|
37
|
+
async function passViaMentions() {
|
|
38
|
+
const out = await tool("list_mentions", cursor.value ? { since: cursor.value } : {});
|
|
39
|
+
const mentions = (out?.mentions ?? []).slice().reverse();
|
|
40
|
+
for (const m of mentions) {
|
|
41
|
+
if (seen.has(m.id)) continue;
|
|
42
|
+
seen.add(m.id);
|
|
43
|
+
await safeHandle(m, m.channelId);
|
|
44
|
+
if (!cursor.value || m.created_at > cursor.value) cursor.value = m.created_at;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function passViaScan() {
|
|
49
|
+
for (const channelId of channelIds) {
|
|
50
|
+
const out = await tool("read_channel", { channelId, limit: 30 });
|
|
51
|
+
for (const m of out?.messages ?? []) {
|
|
52
|
+
if (seen.has(m.id)) continue;
|
|
53
|
+
seen.add(m.id);
|
|
54
|
+
if (m.author === me.agentName) continue;
|
|
55
|
+
const isMention = new RegExp(`@${me.handle}\\b`, "i").test(m.body || "");
|
|
56
|
+
if (isMention && new Date(m.created_at).getTime() >= since) await safeHandle(m, channelId);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function safeHandle(message, channelId) {
|
|
62
|
+
try {
|
|
63
|
+
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
64
|
+
await handler({ message, channelId, tool }, cfg);
|
|
65
|
+
} catch (e) {
|
|
66
|
+
log.error(`handler error: ${e.message}`);
|
|
67
|
+
await tool("post_message", {
|
|
68
|
+
channelId,
|
|
69
|
+
body: `Hit an error working on that: ${e.message}`,
|
|
70
|
+
}).catch(() => {});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
do {
|
|
75
|
+
try {
|
|
76
|
+
if (useMentions) await passViaMentions();
|
|
77
|
+
else await passViaScan();
|
|
78
|
+
} catch (e) {
|
|
79
|
+
log.error(`poll error: ${e.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (!cfg.once) await new Promise((r) => setTimeout(r, cfg.pollMs));
|
|
82
|
+
} while (!cfg.once);
|
|
83
|
+
}
|