hilos-agent 0.1.5 → 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 +25 -19
- package/package.json +2 -2
- package/src/config.mjs +9 -3
- package/src/handler.mjs +104 -34
- package/src/mcp.mjs +15 -1
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
|
|
8
|
-
|
|
9
|
-
|
|
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,
|
|
16
|
-
agent: posts a
|
|
17
|
-
human: Approve ▶
|
|
18
|
-
Reject ▶
|
|
19
|
-
Changes ▶ agent
|
|
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",
|
|
45
|
+
"codingCmd": "claude -p --permission-mode acceptEdits", // or "codex exec", "cursor-agent", any command
|
|
43
46
|
"defaultBranch": "main",
|
|
44
|
-
"gate":
|
|
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
|
-
- **
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
"description": "Run your own coding agent (Claude Code / Codex / Cursor) as an autonomous teammate in a hilos channel. Picks up @mentions,
|
|
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/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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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/handler.mjs
CHANGED
|
@@ -41,7 +41,7 @@ function defaultDeps() {
|
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
44
|
+
async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps, parentId }) {
|
|
45
45
|
if (!reportMessageId) return { kind: "timeout" };
|
|
46
46
|
const deadline = deps.now() + cfg.decisionTimeoutMs;
|
|
47
47
|
while (deps.now() < deadline) {
|
|
@@ -49,10 +49,20 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
|
49
49
|
const gr = await tool("get_report", { messageId: reportMessageId }).catch(() => null);
|
|
50
50
|
if (gr && gr.found && gr.report) report = gr.report;
|
|
51
51
|
if (!report) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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);
|
|
56
66
|
report = m && m.report ? m.report : null;
|
|
57
67
|
}
|
|
58
68
|
const kind = report ? decisionKind(report) : null;
|
|
@@ -62,7 +72,15 @@ async function awaitDecision({ tool, channelId, reportMessageId, cfg, deps }) {
|
|
|
62
72
|
return { kind: "timeout" };
|
|
63
73
|
}
|
|
64
74
|
|
|
65
|
-
|
|
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 }) {
|
|
66
84
|
const tag = requesterTag(requester);
|
|
67
85
|
const lead = tag ? `${tag} — ` : "";
|
|
68
86
|
if (decision.kind === "approved") {
|
|
@@ -70,6 +88,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
70
88
|
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
71
89
|
await tool("post_message", {
|
|
72
90
|
channelId,
|
|
91
|
+
parentId,
|
|
73
92
|
body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
|
|
74
93
|
});
|
|
75
94
|
return { status: "nothing-staged", branch };
|
|
@@ -78,6 +97,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
78
97
|
if (commit.status !== 0) {
|
|
79
98
|
await tool("post_message", {
|
|
80
99
|
channelId,
|
|
100
|
+
parentId,
|
|
81
101
|
body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
|
|
82
102
|
});
|
|
83
103
|
return { status: "commit-failed", branch };
|
|
@@ -86,6 +106,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
86
106
|
if (push.status !== 0) {
|
|
87
107
|
await tool("post_message", {
|
|
88
108
|
channelId,
|
|
109
|
+
parentId,
|
|
89
110
|
body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
|
|
90
111
|
});
|
|
91
112
|
return { status: "push-failed", branch };
|
|
@@ -94,6 +115,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
94
115
|
const pr = deps.openPR(repoPath, { title, body, branch, base: cfg.defaultBranch });
|
|
95
116
|
await tool("post_report", {
|
|
96
117
|
channelId,
|
|
118
|
+
parentId,
|
|
97
119
|
title: `Shipped: ${title}`,
|
|
98
120
|
summary:
|
|
99
121
|
pr.ok && pr.url
|
|
@@ -102,6 +124,8 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
102
124
|
prUrl: pr.ok && pr.url ? pr.url : undefined,
|
|
103
125
|
caveats: pr.ok ? [] : [`gh pr create failed: ${(pr.stderr || "").trim().slice(0, 200)}`],
|
|
104
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 });
|
|
105
129
|
return { status: "pushed", branch, prUrl: pr.ok ? pr.url : null };
|
|
106
130
|
}
|
|
107
131
|
|
|
@@ -110,13 +134,14 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
110
134
|
deps.git(repoPath, ["clean", "-fd"]);
|
|
111
135
|
deps.git(repoPath, ["switch", "-"]);
|
|
112
136
|
deps.git(repoPath, ["branch", "-D", branch]);
|
|
113
|
-
await tool("post_message", { channelId, body: `Rejected — discarded \`${branch}\`.` });
|
|
137
|
+
await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
|
|
114
138
|
return { status: "discarded", branch };
|
|
115
139
|
}
|
|
116
140
|
|
|
117
141
|
if (decision.kind === "changes") {
|
|
118
142
|
await tool("post_message", {
|
|
119
143
|
channelId,
|
|
144
|
+
parentId,
|
|
120
145
|
body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
|
|
121
146
|
});
|
|
122
147
|
return { status: "changes", branch, note: decision.note };
|
|
@@ -124,6 +149,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
124
149
|
|
|
125
150
|
await tool("post_message", {
|
|
126
151
|
channelId,
|
|
152
|
+
parentId,
|
|
127
153
|
body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
|
|
128
154
|
});
|
|
129
155
|
return { status: "timeout", branch };
|
|
@@ -163,20 +189,31 @@ export function normalizeRemote(url) {
|
|
|
163
189
|
}
|
|
164
190
|
|
|
165
191
|
/** Run the configured CLI to produce a chat reply, using recent channel context. */
|
|
166
|
-
async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
192
|
+
async function respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId }) {
|
|
167
193
|
void message;
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
.
|
|
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
|
+
}
|
|
175
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.`;
|
|
176
213
|
const prompt =
|
|
177
214
|
`You are ${name}, a teammate in a team chat (hilos). Reply to the latest message ` +
|
|
178
215
|
`concisely and directly as a single chat message — no preamble, no headings. ` +
|
|
179
|
-
|
|
216
|
+
`${repoLine}\n\n` +
|
|
180
217
|
`Conversation so far:\n${transcript}`;
|
|
181
218
|
|
|
182
219
|
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
@@ -192,6 +229,7 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
|
192
229
|
console.log(` chat → ${reply ? `replied (${reply.length} chars)` : "no output"}; posting`);
|
|
193
230
|
await tool("post_message", {
|
|
194
231
|
channelId,
|
|
232
|
+
parentId: parentId ?? null,
|
|
195
233
|
body: reply || `(my CLI returned nothing — is \`${cfg.codingCmd}\` installed and on PATH?)`,
|
|
196
234
|
});
|
|
197
235
|
}
|
|
@@ -200,13 +238,15 @@ async function respondConversationally({ message, channelId, tool, me, cfg }) {
|
|
|
200
238
|
export async function handleTask({ message, channelId, tool, me }, cfg, depsOverride) {
|
|
201
239
|
const deps = depsOverride || defaultDeps();
|
|
202
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;
|
|
203
243
|
|
|
204
244
|
const { links = [] } = await tool("get_links", { channelId }).catch(() => ({ links: [] }));
|
|
205
245
|
const repoLink = links.find((l) => l.repo_full_name);
|
|
206
246
|
// Chat (no repo linked, OR a greeting/question even in a repo channel) →
|
|
207
247
|
// reply via the CLI. Only an actual code request runs the branch/diff/PR flow.
|
|
208
248
|
if (!repoLink || !looksLikeCodeTask(message.body)) {
|
|
209
|
-
await respondConversationally({ message, channelId, tool, me, cfg });
|
|
249
|
+
await respondConversationally({ message, channelId, tool, me, cfg, repoLink, parentId });
|
|
210
250
|
return { status: "chat" };
|
|
211
251
|
}
|
|
212
252
|
const repoFullName = repoLink.repo_full_name;
|
|
@@ -224,6 +264,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
224
264
|
if (!repoPath) {
|
|
225
265
|
await tool("post_message", {
|
|
226
266
|
channelId,
|
|
267
|
+
parentId,
|
|
227
268
|
body:
|
|
228
269
|
`I don't have a local checkout of ${repoFullName}. Either run me from inside that ` +
|
|
229
270
|
`repo, or add it to your hilos-agent.json: "repos": { "${repoFullName}": "/abs/path" }.`,
|
|
@@ -233,12 +274,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
233
274
|
|
|
234
275
|
const status = git(repoPath, ["status", "--porcelain"]);
|
|
235
276
|
if (status.status !== 0) {
|
|
236
|
-
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}.` });
|
|
237
278
|
return { status: "git-error" };
|
|
238
279
|
}
|
|
239
280
|
if (status.stdout.trim()) {
|
|
240
281
|
await tool("post_message", {
|
|
241
282
|
channelId,
|
|
283
|
+
parentId,
|
|
242
284
|
body: `Working tree at ${repoPath} is dirty — commit or stash first.`,
|
|
243
285
|
});
|
|
244
286
|
return { status: "dirty" };
|
|
@@ -250,6 +292,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
250
292
|
if (co.status !== 0) {
|
|
251
293
|
await tool("post_message", {
|
|
252
294
|
channelId,
|
|
295
|
+
parentId,
|
|
253
296
|
body: `Couldn't create branch \`${branch}\`: ${co.stderr.trim()}`,
|
|
254
297
|
});
|
|
255
298
|
return { status: "branch-error" };
|
|
@@ -257,11 +300,13 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
257
300
|
|
|
258
301
|
await tool("post_message", {
|
|
259
302
|
channelId,
|
|
303
|
+
parentId,
|
|
260
304
|
body: `On it — working in ${repoFullName} on \`${branch}\`.`,
|
|
261
305
|
});
|
|
262
306
|
|
|
263
307
|
const parts = cfg.codingCmd.split(" ").filter(Boolean);
|
|
264
|
-
|
|
308
|
+
// Run the CLI and stage everything it changed; return the diff stats (no post).
|
|
309
|
+
const runAndStage = async (promptText) => {
|
|
265
310
|
console.log(
|
|
266
311
|
` code → running \`${cfg.codingCmd}\` in ${repoPath} (this can take a few minutes; output appears when it finishes)…`,
|
|
267
312
|
);
|
|
@@ -279,28 +324,34 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
279
324
|
console.log(" code → no changes produced");
|
|
280
325
|
return { empty: true };
|
|
281
326
|
}
|
|
282
|
-
console.log(" code → diff captured
|
|
327
|
+
console.log(" code → diff captured");
|
|
283
328
|
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
284
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) => {
|
|
285
335
|
const report = buildProposalReport({
|
|
286
336
|
task: message.body,
|
|
287
337
|
requester: message.author,
|
|
288
338
|
repoFullName,
|
|
289
339
|
branch,
|
|
290
|
-
diffText,
|
|
291
|
-
truncated,
|
|
292
|
-
omittedLines,
|
|
293
|
-
stat,
|
|
294
|
-
runFailed:
|
|
340
|
+
diffText: staged.diffText,
|
|
341
|
+
truncated: staged.truncated,
|
|
342
|
+
omittedLines: staged.omittedLines,
|
|
343
|
+
stat: staged.stat,
|
|
344
|
+
runFailed: staged.runFailed,
|
|
295
345
|
});
|
|
296
|
-
const res = await tool("post_report", { channelId, ...report });
|
|
297
|
-
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 };
|
|
298
348
|
};
|
|
299
349
|
|
|
300
|
-
|
|
301
|
-
if (
|
|
350
|
+
const staged = await runAndStage(message.body);
|
|
351
|
+
if (staged.empty) {
|
|
302
352
|
await tool("post_message", {
|
|
303
353
|
channelId,
|
|
354
|
+
parentId,
|
|
304
355
|
body: `No changes were produced. Cleaning up \`${branch}\`.`,
|
|
305
356
|
});
|
|
306
357
|
git(repoPath, ["switch", "-"]);
|
|
@@ -308,30 +359,48 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
308
359
|
return { status: "no-changes" };
|
|
309
360
|
}
|
|
310
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.
|
|
311
365
|
if (!cfg.gate) {
|
|
312
|
-
|
|
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 };
|
|
313
379
|
}
|
|
314
380
|
|
|
315
|
-
let
|
|
381
|
+
let proposal = await postProposal(staged);
|
|
382
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId });
|
|
316
383
|
const maxRounds = cfg.maxRounds || 3;
|
|
317
384
|
let round = 1;
|
|
318
385
|
while (decision.kind === "changes" && round < maxRounds) {
|
|
319
386
|
await tool("post_message", {
|
|
320
387
|
channelId,
|
|
388
|
+
parentId,
|
|
321
389
|
body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
322
390
|
});
|
|
323
|
-
const refined = await
|
|
391
|
+
const refined = await runAndStage(
|
|
324
392
|
`${message.body}\n\nReviewer feedback to address: ${decision.note || "(see the channel)"}`,
|
|
325
393
|
);
|
|
326
394
|
if (refined.empty) {
|
|
327
395
|
await tool("post_message", {
|
|
328
396
|
channelId,
|
|
397
|
+
parentId,
|
|
329
398
|
body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
|
|
330
399
|
});
|
|
331
400
|
break;
|
|
332
401
|
}
|
|
333
|
-
proposal = refined;
|
|
334
|
-
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 });
|
|
335
404
|
round += 1;
|
|
336
405
|
}
|
|
337
406
|
|
|
@@ -345,6 +414,7 @@ export async function handleTask({ message, channelId, tool, me }, cfg, depsOver
|
|
|
345
414
|
tool,
|
|
346
415
|
channelId,
|
|
347
416
|
deps,
|
|
417
|
+
parentId,
|
|
348
418
|
});
|
|
349
419
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
|
|
350
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: {
|
|
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) {
|