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 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
- npx hilos-agent --join <blob> # connect (token + endpoint come from the link)
29
- hilos-agent init # or: write a starter config to edit
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
- Map each repo the agent may touch to a local checkout, then run it:
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.0",
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
- deps.git(repoPath, ["commit", "-m", commitMessage(task)]);
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
- 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" };
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
- const repoPath = resolveRepoPath(cfg, repoFullName);
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: `No local path is configured for ${repoFullName}. Add it to your hilos-agent.json under "repos".`,
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, String(deps.now()).slice(-6));
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
- if (!cursor.value || m.created_at > cursor.value) cursor.value = m.created_at;
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", {