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 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
+ }
@@ -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
+ }