hilos-agent 0.1.0 → 0.1.3
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 +100 -11
- 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.3",
|
|
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,100 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
|
|
|
103
119
|
return { status: "timeout", branch };
|
|
104
120
|
}
|
|
105
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Does this mention ask for a code change (→ branch/diff/PR flow), or is it
|
|
124
|
+
* chat (→ a conversational reply)? Greetings and questions are chat even when
|
|
125
|
+
* they mention code; an action verb means work. Keeps casual talk in a
|
|
126
|
+
* repo-linked channel from spawning empty branches.
|
|
127
|
+
*/
|
|
128
|
+
export function looksLikeCodeTask(text) {
|
|
129
|
+
const t = String(text || "")
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
.replace(/@[a-z0-9-]+/g, " ")
|
|
132
|
+
.trim();
|
|
133
|
+
if (!t) return false;
|
|
134
|
+
// Greetings / questions → chat (even if they name code things).
|
|
135
|
+
if (
|
|
136
|
+
/^(hi|hey|hello|yo|sup|gm|thanks|thank you|nice|cool|ok|okay|how|what|why|when|who|where|which|is\b|are\b|do you|does|did|could you (tell|explain|show|describe)|can you (tell|explain|show|describe))/.test(
|
|
137
|
+
t,
|
|
138
|
+
)
|
|
139
|
+
) {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
const verb =
|
|
143
|
+
/\b(fix|add|implement|refactor|change|update|create|remove|delete|rename|write|build|bump|migrate|wire|patch|optimi[sz]e|debug|revert|rebase|configure|improve|replace|extract|split|move|generate|scaffold|make|set up|hook up|adjust|tweak)\b/;
|
|
144
|
+
return verb.test(t);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** owner/name from a GitHub remote URL (https or ssh), or null. */
|
|
148
|
+
export function normalizeRemote(url) {
|
|
149
|
+
const m = String(url || "")
|
|
150
|
+
.trim()
|
|
151
|
+
.match(/github\.com[:/]+([^/\s]+\/[^/\s]+?)(?:\.git)?$/i);
|
|
152
|
+
return m ? m[1] : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Run the configured CLI to produce a chat reply, using recent channel context. */
|
|
156
|
+
async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
157
|
+
void message;
|
|
158
|
+
const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
|
|
159
|
+
messages: [],
|
|
160
|
+
}));
|
|
161
|
+
const transcript = messages
|
|
162
|
+
.map((m) => `${m.author}: ${m.body}`)
|
|
163
|
+
.join("\n")
|
|
164
|
+
.slice(-6000);
|
|
165
|
+
const name = me?.agentName || "an assistant";
|
|
166
|
+
const prompt =
|
|
167
|
+
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
168
|
+
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
169
|
+
`If asked to change code, note that a repo isn't linked to this channel yet.\n\n` +
|
|
170
|
+
`Conversation so far:\n${transcript}`;
|
|
171
|
+
|
|
172
|
+
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
173
|
+
const run = spawnSync(parts[0], [...parts.slice(1), prompt], {
|
|
174
|
+
encoding: "utf8",
|
|
175
|
+
timeout: cfg.runTimeoutMs,
|
|
176
|
+
maxBuffer: 50 * 1024 * 1024,
|
|
177
|
+
});
|
|
178
|
+
const reply = (run.stdout || "").trim();
|
|
179
|
+
await tool("post_message", {
|
|
180
|
+
channelId,
|
|
181
|
+
body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
106
185
|
/** Handle one task. cfg/deps injectable for tests. */
|
|
107
|
-
export async function handleTask({ message, channelId, tool }, cfg, depsOverride) {
|
|
186
|
+
export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride) {
|
|
108
187
|
const deps = depsOverride || defaultDeps();
|
|
109
188
|
const git = deps.git;
|
|
110
189
|
|
|
111
190
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
112
191
|
const repoLink = links.find((l) => l.repo_full_name);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
}
|
|
118
|
-
return { status: "no-repo" };
|
|
192
|
+
// Chat (no repo linked, OR a greeting/question even in a repo channel) →
|
|
193
|
+
// reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
|
|
194
|
+
if (!repoLink || !looksLikeCodeTask(message.body)) {
|
|
195
|
+
await respondConversationally({ message, channelId, tool, me, cfg });
|
|
196
|
+
return { status: "chat" };
|
|
119
197
|
}
|
|
120
198
|
const repoFullName = repoLink.repo_full_name;
|
|
121
199
|
|
|
122
|
-
|
|
200
|
+
let repoPath = resolveRepoPath(cfg, repoFullName);
|
|
201
|
+
if (!repoPath) {
|
|
202
|
+
// Zero-config path: if the daemon is running INSIDE a checkout of this repo
|
|
203
|
+
// (its origin matches), just use the current directory — no repos map needed.
|
|
204
|
+
const cwd = process.cwd();
|
|
205
|
+
const origin = git(cwd, ["remote", "get-url", "origin"]);
|
|
206
|
+
if (origin.status === 0 && normalizeRemote(origin.stdout) === repoFullName) {
|
|
207
|
+
repoPath = cwd;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
123
210
|
if (!repoPath) {
|
|
124
211
|
await tool("post_message", {
|
|
125
212
|
channelId,
|
|
126
|
-
body:
|
|
213
|
+
body:
|
|
214
|
+
`I don't have a local checkout of ${repoFullName}. Either run me from inside that ` +
|
|
215
|
+
`repo, or add it to your hilos-agent.json: "repos": { "${repoFullName}": "/abs/path" }.`,
|
|
127
216
|
});
|
|
128
217
|
return { status: "no-path" };
|
|
129
218
|
}
|
|
@@ -141,7 +230,7 @@ export async function handleTask({ message, channelId, tool }, cfg, depsOverride
|
|
|
141
230
|
return { status: "dirty" };
|
|
142
231
|
}
|
|
143
232
|
|
|
144
|
-
const branch = branchSlug(message.body,
|
|
233
|
+
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
145
234
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
146
235
|
const co = git(repoPath, ["switch", "-c", branch]);
|
|
147
236
|
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", {
|