hilos-agent 0.1.4 → 0.1.6

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
@@ -4,21 +4,24 @@ Run **your own** coding agent — Claude Code, Codex, Cursor, or any command —
4
4
  an autonomous teammate inside a [hilos](https://hilos.sh) channel.
5
5
 
6
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.
7
+ git-linked channel (including thread replies), runs your coding agent in a
8
+ **local** checkout, and by default **opens a PR for review**. Your code and
9
+ your git/`gh` credentials never leave your machine — hilos only relays messages.
11
10
 
12
11
  ```
13
12
  hilos channel ──MCP/HTTPS──▶ hilos-agent (your laptop)
14
13
  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
14
+ agent: branches, runs your coding CLI, commits + pushes, opens a PR
15
+ agent: posts a report card with the PR link
16
+ human: Approve ▶ hilos merges the PR
17
+ Reject ▶ hilos closes the PR
18
+ Changes ▶ agent re-works with your note
20
19
  ```
21
20
 
21
+ Prefer **approve-before-push**? Set `"gate": true` — the agent then posts the
22
+ proposed diff as a card and pushes only after you Approve (nothing leaves your
23
+ machine until then).
24
+
22
25
  ## Quick start
23
26
 
24
27
  In hilos: open your agent's profile → **Connect** → copy the
@@ -39,9 +42,9 @@ Running from elsewhere, or want to map several repos explicitly? Use a config:
39
42
  "url": "https://hilos.sh/api/mcp",
40
43
  "token": "mgo_…",
41
44
  "repos": { "your-org/your-repo": "/Users/you/code/your-repo" },
42
- "codingCmd": "claude -p", // or "codex exec", "cursor-agent", any shell command
45
+ "codingCmd": "claude -p --permission-mode acceptEdits", // or "codex exec", "cursor-agent", any command
43
46
  "defaultBranch": "main",
44
- "gate": true // false = propose only, never push
47
+ "gate": false // default: open a PR directly. true = approve-before-push
45
48
  }
46
49
  ```
47
50
 
@@ -57,18 +60,21 @@ hilos-agent --channel <id> # scope to one channel
57
60
  `repos`. No mapping → the agent says so and stops.
58
61
  - **Run** — it branches off `defaultBranch` (refuses a dirty tree), runs
59
62
  `codingCmd` with the task, and stages the result.
60
- - **Propose** the staged diff is posted as a report card. The agent then polls
61
- for your decision.
62
- - **Approve / Reject / Request changes** — approve pushes with *your* `git`/`gh`
63
- and opens a PR; reject discards the branch; request-changes re-runs with your
64
- note (bounded rounds).
63
+ - **Open a PR** (default) it commits, pushes with *your* `git`/`gh`, opens a PR,
64
+ and posts a report card with the link. Review on the card: **Approve** merges,
65
+ **Reject** closes, **Request changes** re-works.
66
+ - **Approve-before-push** (`gate:true`) instead, it posts the staged diff as a
67
+ card and polls for your decision; **Approve** pushes + opens the PR, **Reject**
68
+ discards the branch, **Request changes** re-runs with your note (bounded rounds).
65
69
 
66
70
  ## Security
67
71
 
68
72
  The daemon runs a coding agent that can execute code in your repo — exactly as if
69
- you ran it in your terminal. hilos can only send text; **push is gated on a human
70
- approval recorded in hilos**, and uses your local credentials. Keep your token in
71
- the config file or `HILOS_TOKEN`, never in shared shell history.
73
+ you ran it in your terminal and uses *your* local `git`/`gh` to push. By default
74
+ it opens a PR (nothing is force-merged; you review the PR, and merge/close run via
75
+ hilos's GitHub App only for workspace owners/admins). Want a human checkpoint
76
+ before anything is pushed? Set `"gate": true`. Keep your token in the config file
77
+ or `HILOS_TOKEN`, never in shared shell history.
72
78
 
73
79
  ## Flags
74
80
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.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.",
3
+ "version": "0.1.6",
4
+ "description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions in channels and threads, makes the change, and opens a PR for review — your code and credentials never leave your machine. (Approve-before-push is available via gate:true.)",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "hilos-agent": "bin/hilos-agent.mjs"
package/src/cli.mjs ADDED
@@ -0,0 +1,106 @@
1
+ // Async CLI runner with a heartbeat. The coding CLI (`claude -p`, `codex`, …)
2
+ // runs for minutes and — with the default output format — buffers everything
3
+ // until it exits. The old blocking `spawnSync` froze the event loop, so the
4
+ // terminal sat dead-silent the whole time and people assumed it had hung and
5
+ // hit Ctrl+C (losing the run). This spawns asynchronously, captures output, and
6
+ // logs a periodic "still working…" heartbeat so the run visibly stays alive.
7
+
8
+ import { spawn } from "node:child_process";
9
+
10
+ /** Human-readable elapsed time: "45s", "2m 3s". */
11
+ export function fmtElapsed(ms) {
12
+ const total = Math.max(0, Math.round(ms / 1000));
13
+ const m = Math.floor(total / 60);
14
+ const s = total % 60;
15
+ return m > 0 ? `${m}m ${s}s` : `${s}s`;
16
+ }
17
+
18
+ // Guard against a pathological run flooding memory; the CLI's result is small.
19
+ const MAX_CAPTURE_BYTES = 50 * 1024 * 1024;
20
+
21
+ /**
22
+ * @typedef {Object} RunCliOptions
23
+ * @property {string} cmd - command to run
24
+ * @property {string[]} [args] - arguments
25
+ * @property {string} [cwd] - working directory
26
+ * @property {number} [timeoutMs] - kill the child after this long (0 = no timeout)
27
+ * @property {string} [label] - verb shown in the heartbeat ("coding"/"thinking")
28
+ * @property {number} [heartbeatMs] - heartbeat interval (0 = no heartbeat)
29
+ * @property {() => number} [now] - clock, injectable for tests
30
+ * @property {{ log: (m: string) => void }} [log] - logger, injectable for tests
31
+ */
32
+
33
+ /**
34
+ * Run `cmd args` to completion without blocking the event loop.
35
+ *
36
+ * Returns a spawnSync-shaped result so callers can swap it in directly:
37
+ * { status: number|null, stdout: string, stderr: string, error: Error|null }
38
+ * `status` is the exit code, or null if the process failed to start or was
39
+ * killed (timeout). On timeout the child is SIGKILLed and `error` is set.
40
+ *
41
+ * @param {RunCliOptions} opts
42
+ */
43
+ export function runCli(opts) {
44
+ const {
45
+ cmd,
46
+ args = [],
47
+ cwd,
48
+ timeoutMs = 0,
49
+ label = "working",
50
+ heartbeatMs = 15000,
51
+ now = Date.now,
52
+ log = console,
53
+ } = opts || {};
54
+ return new Promise((resolve) => {
55
+ let child;
56
+ try {
57
+ child = spawn(cmd, args, { cwd });
58
+ } catch (error) {
59
+ resolve({ status: null, stdout: "", stderr: "", error });
60
+ return;
61
+ }
62
+
63
+ let stdout = "";
64
+ let stderr = "";
65
+ // Decode at the stream level so a multibyte char split across chunks isn't
66
+ // corrupted (the chat path posts stdout verbatim). Matches spawnSync's utf8.
67
+ child.stdout?.setEncoding("utf8");
68
+ child.stderr?.setEncoding("utf8");
69
+ const append = (buf, chunk) =>
70
+ buf.length >= MAX_CAPTURE_BYTES ? buf : buf + chunk;
71
+ child.stdout?.on("data", (c) => (stdout = append(stdout, c)));
72
+ child.stderr?.on("data", (c) => (stderr = append(stderr, c)));
73
+
74
+ const start = now();
75
+ const beat =
76
+ heartbeatMs > 0
77
+ ? setInterval(() => {
78
+ log.log(` … still ${label} (${fmtElapsed(now() - start)}) — Ctrl+C to cancel`);
79
+ }, heartbeatMs)
80
+ : null;
81
+ if (beat?.unref) beat.unref();
82
+
83
+ let timedOut = false;
84
+ const timer =
85
+ timeoutMs > 0
86
+ ? setTimeout(() => {
87
+ timedOut = true;
88
+ child.kill("SIGKILL");
89
+ }, timeoutMs)
90
+ : null;
91
+ if (timer?.unref) timer.unref();
92
+
93
+ const finish = (status, error) => {
94
+ if (beat) clearInterval(beat);
95
+ if (timer) clearTimeout(timer);
96
+ resolve({
97
+ status,
98
+ stdout,
99
+ stderr,
100
+ error: error || (timedOut ? new Error(`timed out after ${fmtElapsed(timeoutMs)}`) : null),
101
+ });
102
+ };
103
+ child.on("error", (error) => finish(null, error));
104
+ child.on("close", (code) => finish(code, null));
105
+ });
106
+ }
package/src/config.mjs CHANGED
@@ -43,9 +43,14 @@ const DEFAULTS = {
43
43
  token: "",
44
44
  channelId: "", // when set, watch only this channel (the per-channel override)
45
45
  repos: {},
46
- codingCmd: "claude -p",
46
+ // acceptEdits lets the CLI make file edits without prompting (bias to action);
47
+ // it still won't run arbitrary commands. Override in hilos-agent.json if you
48
+ // want a stricter (or `--dangerously-skip-permissions`) command.
49
+ codingCmd: "claude -p --permission-mode acceptEdits",
47
50
  defaultBranch: "main",
48
- gate: true,
51
+ // Bias to action: open a PR directly for review (approve = merge on the card).
52
+ // Set gate:true for the older approve-before-push flow (propose a diff, wait).
53
+ gate: false,
49
54
  maxRounds: 3,
50
55
  pollMs: 5000,
51
56
  runTimeoutMs: 600000,
@@ -89,7 +94,8 @@ export function writeStarterConfig(path, partial = {}) {
89
94
  repos: partial.repos || { "owner/name": "/absolute/path/to/checkout" },
90
95
  codingCmd: partial.codingCmd || DEFAULTS.codingCmd,
91
96
  defaultBranch: DEFAULTS.defaultBranch,
92
- gate: true,
97
+ // false = open a PR directly (bias to action); true = approve-before-push.
98
+ gate: false,
93
99
  };
94
100
  writeFileSync(target, JSON.stringify(starter, null, 2) + "\n");
95
101
  return target;
package/src/daemon.mjs CHANGED
@@ -72,7 +72,11 @@ export function buildProposalReport(o) {
72
72
  o.diffText +
73
73
  (o.truncated ? `\n… (+${o.omittedLines} more lines)` : "") +
74
74
  "\n```";
75
- const summary = `Proposed changes for: ${firstLine}\n\n${statLine}\n\n${diffBlock}`;
75
+ // Tag whoever asked so the proposal lands in their notifications, not just
76
+ // the channel. Handle matches the server's @-mention format (kebab of name).
77
+ const handle = mentionHandle(o.requester);
78
+ const lead = handle ? `@${handle} — proposed changes for: ${firstLine}` : `Proposed changes for: ${firstLine}`;
79
+ const summary = `${lead}\n\n${statLine}\n\n${diffBlock}`;
76
80
  const caveats = ["Not pushed yet — approve to push + open a PR, or reject to discard."];
77
81
  if (o.runFailed) caveats.push("The coding agent exited non-zero; review the diff carefully.");
78
82
  return { title: `Proposal: ${firstLine}`, summary, caveats, todos: [] };
package/src/handler.mjs CHANGED
@@ -13,7 +13,15 @@ import {
13
13
  decisionKind,
14
14
  commitMessage,
15
15
  prTitleBody,
16
+ mentionHandle,
16
17
  } from "./daemon.mjs";
18
+ import { runCli } from "./cli.mjs";
19
+
20
+ /** `@handle` for the person who asked, so the report tags them. */
21
+ function requesterTag(author) {
22
+ const handle = mentionHandle(author);
23
+ return handle ? `@${handle}` : "";
24
+ }
17
25
 
18
26
  function defaultDeps() {
19
27
  return {
@@ -33,7 +41,7 @@ function defaultDeps() {
33
41
  };
34
42
  }
35
43
 
36
- async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
44
+ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId }) {
37
45
  if (!reportMessageId) return { kind: "timeout" };
38
46
  const deadline = deps.now() + cfg.decisionTimeoutMs;
39
47
  while (deps.now() < deadline) {
@@ -41,10 +49,20 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
41
49
  const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
42
50
  if (gr && gr.found && gr.report) report = gr.report;
43
51
  if (!report) {
44
- const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
45
- () => ({ messages: [] }),
46
- );
47
- const m = messages.find((x) => x.id === reportMessageId);
52
+ // Fallback for servers without get_report. In a thread the report lives
53
+ // under the thread root (read_channel only returns top-level), so scan the
54
+ // thread there; otherwise scan the channel tail.
55
+ let candidates = [];
56
+ if (parentId) {
57
+ const t = await tool("get_thread", { parentId }).catch(() => null);
58
+ candidates = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
59
+ } else {
60
+ const { messages = [] } = await tool("read_channel", { channelId, limit: 200 }).catch(
61
+ () => ({ messages: [] }),
62
+ );
63
+ candidates = messages;
64
+ }
65
+ const m = candidates.find((x) => x.id === reportMessageId);
48
66
  report = m && m.report ? m.report : null;
49
67
  }
50
68
  const kind = report ? decisionKind(report) : null;
@@ -54,12 +72,23 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
54
72
  return { kind: "timeout" };
55
73
  }
56
74
 
57
- async function applyDecision({ decision, repoPath, branch, task, cfg, tool, channelId, deps }) {
75
+ /** Attach a just-opened PR to the channel (best-effort) so its live pill shows. */
76
+ async function linkPrFromUrl({ tool, channelId, url }) {
77
+ if (!url) return;
78
+ const m = /github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/.exec(url);
79
+ if (!m) return;
80
+ await tool("link_pr", { channelId, repoFullName: m[1], prNumber: Number(m[2]) }).catch(() => {});
81
+ }
82
+
83
+ async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
84
+ const tag = requesterTag(requester);
85
+ const lead = tag ? `${tag} — ` : "";
58
86
  if (decision.kind === "approved") {
59
87
  // Guard: nothing staged → don't push an empty branch and falsely report "shipped".
60
88
  if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
61
89
  await tool("post_message", {
62
90
  channelId,
91
+ parentId,
63
92
  body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
64
93
  });
65
94
  return { status: "nothing-staged", branch };
@@ -68,6 +97,7 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
68
97
  if (commit.status !== 0) {
69
98
  await tool("post_message", {
70
99
  channelId,
100
+ parentId,
71
101
  body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
72
102
  });
73
103
  return { status: "commit-failed", branch };
@@ -76,6 +106,7 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
76
106
  if (push.status !== 0) {
77
107
  await tool("post_message", {
78
108
  channelId,
109
+ parentId,
79
110
  body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
80
111
  });
81
112
  return { status: "push-failed", branch };
@@ -84,14 +115,17 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
84
115
  const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
85
116
  await tool("post_report", {
86
117
  channelId,
118
+ parentId,
87
119
  title: `Shipped: ${title}`,
88
120
  summary:
89
121
  pr.ok && pr.url
90
- ? `Pushed \`${branch}\` and opened a pull request.`
91
- : `Pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
122
+ ? `${lead}pushed \`${branch}\` and opened a pull request.`
123
+ : `${lead}pushed \`${branch}\`. Open a PR manually — \`gh\` failed.`,
92
124
  prUrl: pr.ok && pr.url ? pr.url : undefined,
93
125
  caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
94
126
  });
127
+ // Work produced a PR → attach it to the channel so its live pill shows up.
128
+ await linkPrFromUrl({ tool, channelId, url: pr.ok ? pr.url : null });
95
129
  return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
96
130
  }
97
131
 
@@ -100,13 +134,14 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
100
134
  deps.git(repoPath, ["clean", "-fd"]);
101
135
  deps.git(repoPath, ["switch", "-"]);
102
136
  deps.git(repoPath, ["branch", "-D", branch]);
103
- await tool("post_message", { channelId, body: `Rejected — discarded \`${branch}\`.` });
137
+ await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
104
138
  return { status: "discarded", branch };
105
139
  }
106
140
 
107
141
  if (decision.kind === "changes") {
108
142
  await tool("post_message", {
109
143
  channelId,
144
+ parentId,
110
145
  body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
111
146
  });
112
147
  return { status: "changes", branch, note: decision.note };
@@ -114,6 +149,7 @@ async function applyDecision({ decision, repoPath, branch, task, cfg, tool, chan
114
149
 
115
150
  await tool("post_message", {
116
151
  channelId,
152
+ parentId,
117
153
  body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
118
154
  });
119
155
  return { status: "timeout", branch };
@@ -153,34 +189,47 @@ export function normalizeRemote(url) {
153
189
  }
154
190
 
155
191
  /** Run the configured CLI to produce a chat reply, using recent channel context. */
156
- async function respondConversationally({ message, channelId, tool, me, cfg }) {
192
+ async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId }) {
157
193
  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);
194
+ // In a thread, use the thread's own messages as context (and reply there);
195
+ // otherwise the recent channel tail. Threads are where conversations live, so
196
+ // the agent must follow the thread it was pinged in, not the whole channel.
197
+ let transcript;
198
+ if (parentId) {
199
+ const t = await tool("get_thread", { parentId }).catch(() => null);
200
+ const rows = t && t.found ? [t.root, ...(t.replies ?? [])].filter(Boolean) : [];
201
+ transcript = rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
202
+ } else {
203
+ const { messages = [] } = await tool("read_channel", { channelId, limit: 20 }).catch(() => ({
204
+ messages: [],
205
+ }));
206
+ transcript = messages.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000);
207
+ }
165
208
  const name = me?.agentName || "an assistant";
209
+ // Tell the agent what the room is connected to so it doesn't ask "which repo?".
210
+ const repoLine = repoLink
211
+ ? `This channel is connected to the repository ${repoLink.repo_full_name}; assume that repo for any code work — don't ask which one.`
212
+ : `If asked to change code, note that a repo isn't linked to this channel yet.`;
166
213
  const prompt =
167
214
  `You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
168
215
  `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` +
216
+ `${repoLine}\n\n` +
170
217
  `Conversation so far:\n${transcript}`;
171
218
 
172
219
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
173
- console.log(` chat → running \`${cfg.codingCmd}\`…`);
174
- const run = spawnSync(parts[0], [...parts.slice(1), prompt], {
175
- encoding: "utf8",
176
- timeout: cfg.runTimeoutMs,
177
- maxBuffer: 50 * 1024 * 1024,
220
+ console.log(` chat → running \`${cfg.codingCmd}\` (output appears when it finishes)…`);
221
+ const run = await runCli({
222
+ cmd: parts[0],
223
+ args: [...parts.slice(1), prompt],
224
+ timeoutMs: cfg.runTimeoutMs,
225
+ label: "thinking",
178
226
  });
179
227
  const reply = (run.stdout || "").trim();
180
228
  if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
181
229
  console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
182
230
  await tool("post_message", {
183
231
  channelId,
232
+ parentId: parentId ?? null,
184
233
  body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
185
234
  });
186
235
  }
@@ -189,13 +238,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
189
238
  export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride) {
190
239
  const deps = depsOverride || defaultDeps();
191
240
  const git = deps.git;
241
+ // When the mention was a thread reply, keep the whole exchange in that thread.
242
+ const parentId = message.parentId ?? null;
192
243
 
193
244
  const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
194
245
  const repoLink = links.find((l) => l.repo_full_name);
195
246
  // Chat (no repo linked, OR a greeting/question even in a repo channel) →
196
247
  // reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
197
248
  if (!repoLink || !looksLikeCodeTask(message.body)) {
198
- await respondConversationally({ message, channelId, tool, me, cfg });
249
+ await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId });
199
250
  return { status: "chat" };
200
251
  }
201
252
  const repoFullName = repoLink.repo_full_name;
@@ -213,6 +264,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
213
264
  if (!repoPath) {
214
265
  await tool("post_message", {
215
266
  channelId,
267
+ parentId,
216
268
  body:
217
269
  `I don't have a local checkout of ${repoFullName}. Either run me from inside that ` +
218
270
  `repo, or add it to your hilos-agent.json: "repos": { "${repoFullName}": "/abs/path" }.`,
@@ -222,12 +274,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
222
274
 
223
275
  const status = git(repoPath, ["status", "--porcelain"]);
224
276
  if (status.status !== 0) {
225
- await tool("post_message", { channelId, body: `Can't read git status in ${repoPath}.` });
277
+ await tool("post_message", { channelId, parentId, body: `Can't read git status in ${repoPath}.` });
226
278
  return { status: "git-error" };
227
279
  }
228
280
  if (status.stdout.trim()) {
229
281
  await tool("post_message", {
230
282
  channelId,
283
+ parentId,
231
284
  body: `Working tree at ${repoPath} is dirty — commit or stash first.`,
232
285
  });
233
286
  return { status: "dirty" };
@@ -239,6 +292,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
239
292
  if (co.status !== 0) {
240
293
  await tool("post_message", {
241
294
  channelId,
295
+ parentId,
242
296
  body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
243
297
  });
244
298
  return { status: "branch-error" };
@@ -246,17 +300,22 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
246
300
 
247
301
  await tool("post_message", {
248
302
  channelId,
303
+ parentId,
249
304
  body: `On it — working in ${repoFullName} on \`${branch}\`.`,
250
305
  });
251
306
 
252
307
  const parts = cfg.codingCmd.split(" ").filter(Boolean);
253
- const proposeRound = async (promptText) => {
254
- console.log(` code running \`${cfg.codingCmd}\` in ${repoPath}…`);
255
- const run = spawnSync(parts[0], [...parts.slice(1), promptText], {
308
+ // Run the CLI and stage everything it changed; return the diff stats (no post).
309
+ const runAndStage = async (promptText) => {
310
+ console.log(
311
+ ` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
312
+ );
313
+ const run = await runCli({
314
+ cmd: parts[0],
315
+ args: [...parts.slice(1), promptText],
256
316
  cwd: repoPath,
257
- encoding: "utf8",
258
- timeout: cfg.runTimeoutMs,
259
- maxBuffer: 50 * 1024 * 1024,
317
+ timeoutMs: cfg.runTimeoutMs,
318
+ label: "coding",
260
319
  });
261
320
  if (run.error) console.log(` ! ${parts[0]} failed to start: ${run.error.message}`);
262
321
  git(repoPath, ["add", "-A"]);
@@ -265,27 +324,34 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
265
324
  console.log(" code → no changes produced");
266
325
  return { empty: true };
267
326
  }
268
- console.log(" code → diff captured; posting proposal");
327
+ console.log(" code → diff captured");
269
328
  const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
270
329
  const { text: diffText, truncated, omittedLines } = truncateDiff(diff);
330
+ return { empty: false, diffText, truncated, omittedLines, stat, runFailed: run.status !== 0 };
331
+ };
332
+
333
+ // Post a proposal card (approve-before-push mode) from a staged change.
334
+ const postProposal = async (staged) => {
271
335
  const report = buildProposalReport({
272
336
  task: message.body,
337
+ requester: message.author,
273
338
  repoFullName,
274
339
  branch,
275
- diffText,
276
- truncated,
277
- omittedLines,
278
- stat,
279
- runFailed: run.status !== 0,
340
+ diffText: staged.diffText,
341
+ truncated: staged.truncated,
342
+ omittedLines: staged.omittedLines,
343
+ stat: staged.stat,
344
+ runFailed: staged.runFailed,
280
345
  });
281
- const res = await tool("post_report", { channelId, ...report });
282
- return { reportMessageId: res?.messageId ?? null, stat };
346
+ const res = await tool("post_report", { channelId, parentId, ...report });
347
+ return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
283
348
  };
284
349
 
285
- let proposal = await proposeRound(message.body);
286
- if (proposal.empty) {
350
+ const staged = await runAndStage(message.body);
351
+ if (staged.empty) {
287
352
  await tool("post_message", {
288
353
  channelId,
354
+ parentId,
289
355
  body: `No changes were produced. Cleaning up \`${branch}\`.`,
290
356
  });
291
357
  git(repoPath, ["switch", "-"]);
@@ -293,30 +359,48 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
293
359
  return { status: "no-changes" };
294
360
  }
295
361
 
362
+ // Bias to action (default): commit, push, and open a PR for review now — the
363
+ // report card's Approve merges it. `gate:true` keeps the older
364
+ // propose-a-diff-and-wait flow for users who want approve-before-push.
296
365
  if (!cfg.gate) {
297
- return { status: "proposed", branch, reportMessageId: proposal.reportMessageId, stat: proposal.stat };
366
+ const result = await applyDecision({
367
+ decision: { kind: "approved" },
368
+ repoPath,
369
+ branch,
370
+ task: message.body,
371
+ requester: message.author,
372
+ cfg,
373
+ tool,
374
+ channelId,
375
+ deps,
376
+ parentId,
377
+ });
378
+ return { ...result, stat: staged.stat };
298
379
  }
299
380
 
300
- let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
381
+ let proposal = await postProposal(staged);
382
+ let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
301
383
  const maxRounds = cfg.maxRounds || 3;
302
384
  let round = 1;
303
385
  while (decision.kind === "changes" && round < maxRounds) {
304
386
  await tool("post_message", {
305
387
  channelId,
388
+ parentId,
306
389
  body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
307
390
  });
308
- const refined = await proposeRound(
391
+ const refined = await runAndStage(
309
392
  `${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
310
393
  );
311
394
  if (refined.empty) {
312
395
  await tool("post_message", {
313
396
  channelId,
397
+ parentId,
314
398
  body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
315
399
  });
316
400
  break;
317
401
  }
318
- proposal = refined;
319
- decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps });
402
+ proposal = await postProposal(refined);
403
+ decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
320
404
  round += 1;
321
405
  }
322
406
 
@@ -325,10 +409,12 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
325
409
  repoPath,
326
410
  branch,
327
411
  task: message.body,
412
+ requester: message.author,
328
413
  cfg,
329
414
  tool,
330
415
  channelId,
331
416
  deps,
417
+ parentId,
332
418
  });
333
419
  return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
334
420
  }
package/src/mcp.mjs CHANGED
@@ -1,5 +1,15 @@
1
1
  // Minimal MCP-over-HTTP client (JSON-RPC 2.0). Dependency-free: uses global
2
2
  // fetch (Node >= 18). One bearer token, one endpoint.
3
+ import os from "node:os";
4
+
5
+ // Best-effort host label so hilos can show "whose machine" this daemon runs on.
6
+ const CLIENT = (() => {
7
+ try {
8
+ return `${os.userInfo().username}@${os.hostname()}`;
9
+ } catch {
10
+ return os.hostname?.() || "";
11
+ }
12
+ })();
3
13
 
4
14
  export function makeClient({ url, token }) {
5
15
  let id = 0;
@@ -7,7 +17,11 @@ export function makeClient({ url, token }) {
7
17
  async function rpc(method, params) {
8
18
  const res = await fetch(url, {
9
19
  method: "POST",
10
- headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
20
+ headers: {
21
+ "content-type": "application/json",
22
+ authorization: `Bearer ${token}`,
23
+ ...(CLIENT ? { "x-hilos-client": CLIENT } : {}),
24
+ },
11
25
  body: JSON.stringify({ jsonrpc: "2.0", id: ++id, method, params }),
12
26
  });
13
27
  if (!res.ok) {