hilos-agent 0.1.0 → 0.1.2
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 +6 -4
- package/package.json +10 -1
- package/src/handler.mjs +74 -10
- package/src/mcp.mjs +7 -0
- package/src/run.mjs +10 -2
package/README.md
CHANGED
|
@@ -22,14 +22,16 @@ hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
|
|
|
22
22
|
## Quick start
|
|
23
23
|
|
|
24
24
|
In hilos: open your agent's profile → **Connect** → copy the
|
|
25
|
-
`hilos-agent --join …` command. Then on your machine
|
|
25
|
+
`hilos-agent --join …` command. Then on your machine, **run it from inside your
|
|
26
|
+
repo's folder** — the daemon matches the repo by its git remote, so no config is
|
|
27
|
+
needed:
|
|
26
28
|
|
|
27
29
|
```sh
|
|
28
|
-
|
|
29
|
-
hilos-agent
|
|
30
|
+
cd ~/code/your-repo
|
|
31
|
+
npx hilos-agent --join <blob> # token + endpoint from the link; repo auto-detected from cwd
|
|
30
32
|
```
|
|
31
33
|
|
|
32
|
-
|
|
34
|
+
Running from elsewhere, or want to map several repos explicitly? Use a config:
|
|
33
35
|
|
|
34
36
|
```jsonc
|
|
35
37
|
// ~/.hilos/agent.json (or ./hilos-agent.json)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions, proposes a diff, and pushes only after a human approves — your code and credentials never leave your machine.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,6 +14,15 @@
|
|
|
14
14
|
"engines": {
|
|
15
15
|
"node": ">=18"
|
|
16
16
|
},
|
|
17
|
+
"homepage": "https://hilos.sh",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/pablostanley/hilos.git",
|
|
21
|
+
"directory": "packages/hilos-agent"
|
|
22
|
+
},
|
|
23
|
+
"bugs": {
|
|
24
|
+
"url": "https://github.com/pablostanley/hilos/issues"
|
|
25
|
+
},
|
|
17
26
|
"keywords": [
|
|
18
27
|
"hilos",
|
|
19
28
|
"mcp",
|
package/src/handler.mjs
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// hilos. Nothing is pushed until approved. Your code + credentials stay local.
|
|
4
4
|
|
|
5
5
|
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { randomBytes } from "node:crypto";
|
|
6
7
|
import {
|
|
7
8
|
branchSlug,
|
|
8
9
|
truncateDiff,
|
|
@@ -55,7 +56,22 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
|
55
56
|
|
|
56
57
|
async function applyDecision({ decision, repoPath, branch, task, cfg, tool, channelId, deps }) {
|
|
57
58
|
if (decision.kind === "approved") {
|
|
58
|
-
|
|
59
|
+
// Guard: nothing staged → don't push an empty branch and falsely report "shipped".
|
|
60
|
+
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
61
|
+
await tool("post_message", {
|
|
62
|
+
channelId,
|
|
63
|
+
body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
|
|
64
|
+
});
|
|
65
|
+
return { status: "nothing-staged", branch };
|
|
66
|
+
}
|
|
67
|
+
const commit = deps.git(repoPath, ["commit", "-m", commitMessage(task)]);
|
|
68
|
+
if (commit.status !== 0) {
|
|
69
|
+
await tool("post_message", {
|
|
70
|
+
channelId,
|
|
71
|
+
body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
|
|
72
|
+
});
|
|
73
|
+
return { status: "commit-failed", branch };
|
|
74
|
+
}
|
|
59
75
|
const push = deps.git(repoPath, ["push", "-u", "origin", branch]);
|
|
60
76
|
if (push.status !== 0) {
|
|
61
77
|
await tool("post_message", {
|
|
@@ -103,27 +119,75 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
|
|
|
103
119
|
return { status: "timeout", branch };
|
|
104
120
|
}
|
|
105
121
|
|
|
122
|
+
/** owner/name from a GitHub remote URL (https or ssh), or null. */
|
|
123
|
+
export function normalizeRemote(url) {
|
|
124
|
+
const m = String(url || "")
|
|
125
|
+
.trim()
|
|
126
|
+
.match(/github\.com[:/]+([^/\s]+\/[^/\s]+?)(?:\.git)?$/i);
|
|
127
|
+
return m ? m[1] : null;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Run the configured CLI to produce a chat reply, using recent channel context. */
|
|
131
|
+
async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
132
|
+
void message;
|
|
133
|
+
const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
|
|
134
|
+
messages: [],
|
|
135
|
+
}));
|
|
136
|
+
const transcript = messages
|
|
137
|
+
.map((m) => `${m.author}: ${m.body}`)
|
|
138
|
+
.join("\n")
|
|
139
|
+
.slice(-6000);
|
|
140
|
+
const name = me?.agentName || "an assistant";
|
|
141
|
+
const prompt =
|
|
142
|
+
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
143
|
+
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
144
|
+
`If asked to change code, note that a repo isn't linked to this channel yet.\n\n` +
|
|
145
|
+
`Conversation so far:\n${transcript}`;
|
|
146
|
+
|
|
147
|
+
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
148
|
+
const run = spawnSync(parts[0], [...parts.slice(1), prompt], {
|
|
149
|
+
encoding: "utf8",
|
|
150
|
+
timeout: cfg.runTimeoutMs,
|
|
151
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
152
|
+
});
|
|
153
|
+
const reply = (run.stdout || "").trim();
|
|
154
|
+
await tool("post_message", {
|
|
155
|
+
channelId,
|
|
156
|
+
body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
106
160
|
/** Handle one task. cfg/deps injectable for tests. */
|
|
107
|
-
export async function handleTask({ message, channelId, tool }, cfg, depsOverride) {
|
|
161
|
+
export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride) {
|
|
108
162
|
const deps = depsOverride || defaultDeps();
|
|
109
163
|
const git = deps.git;
|
|
110
164
|
|
|
111
165
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
112
166
|
const repoLink = links.find((l) => l.repo_full_name);
|
|
113
167
|
if (!repoLink) {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
return { status: "no-repo" };
|
|
168
|
+
// No repo here → respond conversationally by running YOUR CLI. This is the
|
|
169
|
+
// agent genuinely answering in chat (the CLI is the brain), not a stub.
|
|
170
|
+
await respondConversationally({ message, channelId, tool, me, cfg });
|
|
171
|
+
return { status: "chat" };
|
|
119
172
|
}
|
|
120
173
|
const repoFullName = repoLink.repo_full_name;
|
|
121
174
|
|
|
122
|
-
|
|
175
|
+
let repoPath = resolveRepoPath(cfg, repoFullName);
|
|
176
|
+
if (!repoPath) {
|
|
177
|
+
// Zero-config path: if the daemon is running INSIDE a checkout of this repo
|
|
178
|
+
// (its origin matches), just use the current directory — no repos map needed.
|
|
179
|
+
const cwd = process.cwd();
|
|
180
|
+
const origin = git(cwd, ["remote", "get-url", "origin"]);
|
|
181
|
+
if (origin.status === 0 && normalizeRemote(origin.stdout) === repoFullName) {
|
|
182
|
+
repoPath = cwd;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
123
185
|
if (!repoPath) {
|
|
124
186
|
await tool("post_message", {
|
|
125
187
|
channelId,
|
|
126
|
-
body:
|
|
188
|
+
body:
|
|
189
|
+
`I don't have a local checkout of ${repoFullName}. Either run me from inside that ` +
|
|
190
|
+
`repo, or add it to your hilos-agent.json: "repos": { "${repoFullName}": "/abs/path" }.`,
|
|
127
191
|
});
|
|
128
192
|
return { status: "no-path" };
|
|
129
193
|
}
|
|
@@ -141,7 +205,7 @@ export async function handleTask({ message, channelId, tool }, cfg, depsOverride
|
|
|
141
205
|
return { status: "dirty" };
|
|
142
206
|
}
|
|
143
207
|
|
|
144
|
-
const branch = branchSlug(message.body,
|
|
208
|
+
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
145
209
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
146
210
|
const co = git(repoPath, ["switch", "-c", branch]);
|
|
147
211
|
if (co.status !== 0) {
|
package/src/mcp.mjs
CHANGED
|
@@ -10,6 +10,13 @@ export function makeClient({ url, token }) {
|
|
|
10
10
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
|
|
11
11
|
body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
|
|
12
12
|
});
|
|
13
|
+
if (!res.ok) {
|
|
14
|
+
const hint =
|
|
15
|
+
res.status === 401 || res.status === 403
|
|
16
|
+
? "token rejected — generate a fresh one in hilos (Connect)"
|
|
17
|
+
: await res.text().catch(() => "");
|
|
18
|
+
throw new Error(`hilos ${res.status}: ${hint.slice(0, 200)}`);
|
|
19
|
+
}
|
|
13
20
|
const json = await res.json();
|
|
14
21
|
if (json.error) throw new Error(json.error.message || "rpc error");
|
|
15
22
|
return json.result;
|
package/src/run.mjs
CHANGED
|
@@ -16,6 +16,10 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
16
16
|
|
|
17
17
|
const who = await tool("whoami");
|
|
18
18
|
const me = { ...who, handle: mentionHandle(who.agentName) };
|
|
19
|
+
if (!me.handle) {
|
|
20
|
+
log.error("This agent has no display name / handle — set one in hilos, then reconnect.");
|
|
21
|
+
process.exit(1);
|
|
22
|
+
}
|
|
19
23
|
log.log(`hilos-agent: ${me.agentName} (@${me.handle}) — ${cfg.url}`);
|
|
20
24
|
if (cfg.channelId) log.log(`scope: channel ${cfg.channelId}`);
|
|
21
25
|
log.log(`repos: ${Object.keys(cfg.repos).join(", ") || "(none configured)"}`);
|
|
@@ -41,7 +45,11 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
41
45
|
if (seen.has(m.id)) continue;
|
|
42
46
|
seen.add(m.id);
|
|
43
47
|
await safeHandle(m, m.channelId);
|
|
44
|
-
|
|
48
|
+
// Compare numerically — created_at formats differ (Z vs +00:00 offset),
|
|
49
|
+
// so a lexicographic string compare can fail to advance the cursor.
|
|
50
|
+
if (!cursor.value || new Date(m.created_at).getTime() > new Date(cursor.value).getTime()) {
|
|
51
|
+
cursor.value = m.created_at;
|
|
52
|
+
}
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
|
|
@@ -61,7 +69,7 @@ export async function run(cfg, { handler = handleTask, log = console } = {}) {
|
|
|
61
69
|
async function safeHandle(message, channelId) {
|
|
62
70
|
try {
|
|
63
71
|
log.log(`→ task in ${channelId}: "${(message.body || "").slice(0, 80)}"`);
|
|
64
|
-
await handler({ message, channelId, tool }, cfg);
|
|
72
|
+
await handler({ message, channelId, tool, me }, cfg);
|
|
65
73
|
} catch (e) {
|
|
66
74
|
log.error(`handler error: ${e.message}`);
|
|
67
75
|
await tool("post_message", {
|