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 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.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
- 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,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
- 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" };
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
- const repoPath = resolveRepoPath(cfg, repoFullName);
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: `No local path is configured for ${repoFullName}. Add it to your hilos-agent.json under "repos".`,
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, String(deps.now()).slice(-6));
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
- 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", {