hilos-agent 0.1.11 → 0.1.13
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/package.json +1 -1
- package/src/handler.mjs +134 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.13",
|
|
4
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": {
|
package/src/handler.mjs
CHANGED
|
@@ -43,6 +43,17 @@ function defaultDeps() {
|
|
|
43
43
|
const url = (r.stdout || "").trim().split("\n").filter(Boolean).pop() || null;
|
|
44
44
|
return { ok: r.status === 0, url, stderr: r.stderr || "" };
|
|
45
45
|
},
|
|
46
|
+
// The open PR for a branch, if one already exists — so when the CLI opened a
|
|
47
|
+
// PR itself we report THAT instead of opening a duplicate.
|
|
48
|
+
findPR: (cwd, branch) => {
|
|
49
|
+
const r = spawnSync(
|
|
50
|
+
"gh",
|
|
51
|
+
["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url // empty"],
|
|
52
|
+
{ cwd, encoding: "utf8" },
|
|
53
|
+
);
|
|
54
|
+
const url = (r.stdout || "").trim();
|
|
55
|
+
return r.status === 0 && url ? url : null;
|
|
56
|
+
},
|
|
46
57
|
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
47
58
|
now: () => Date.now(),
|
|
48
59
|
};
|
|
@@ -165,6 +176,44 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
165
176
|
return { status: "timeout", branch };
|
|
166
177
|
}
|
|
167
178
|
|
|
179
|
+
/**
|
|
180
|
+
* The coding CLI committed the work itself (autonomous run with skip-permissions
|
|
181
|
+
* in a repo whose docs prescribe a commit+PR flow), so the working tree is clean
|
|
182
|
+
* and the daemon's diff is empty. Don't claim "no changes": adopt what it did —
|
|
183
|
+
* push the branch it's on (no-op if already pushed), reuse the PR it opened or
|
|
184
|
+
* open one, and report it. Used only when NOT gated (bias-to-action).
|
|
185
|
+
*/
|
|
186
|
+
async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
187
|
+
const tag = requesterTag(requester);
|
|
188
|
+
const lead = tag ? `${tag} — ` : "";
|
|
189
|
+
// The agent may have committed on the daemon's branch or switched to one of its
|
|
190
|
+
// own — push whatever HEAD is on now.
|
|
191
|
+
const cur =
|
|
192
|
+
(deps.git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]).stdout || "").trim() || branch;
|
|
193
|
+
const push = deps.git(repoPath, ["push", "-u", "origin", cur]);
|
|
194
|
+
let prUrl = deps.findPR ? deps.findPR(repoPath, cur) : null;
|
|
195
|
+
if (!prUrl && push.status === 0) {
|
|
196
|
+
const { title, body } = prTitleBody(task, cur);
|
|
197
|
+
const pr = deps.openPR(repoPath, { title, body, branch: cur, base: cfg.defaultBranch });
|
|
198
|
+
prUrl = pr.ok && pr.url ? pr.url : null;
|
|
199
|
+
}
|
|
200
|
+
const { title } = prTitleBody(task, cur);
|
|
201
|
+
await tool("post_report", {
|
|
202
|
+
channelId,
|
|
203
|
+
parentId,
|
|
204
|
+
title: `Shipped: ${title}`,
|
|
205
|
+
summary: prUrl
|
|
206
|
+
? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
|
|
207
|
+
: push.status === 0
|
|
208
|
+
? `${lead}the coding agent committed and pushed \`${cur}\`. Open a PR manually — \`gh\` didn't return one.`
|
|
209
|
+
: `${lead}the coding agent committed on \`${cur}\` locally, but pushing it failed.`,
|
|
210
|
+
prUrl: prUrl || undefined,
|
|
211
|
+
caveats: push.status === 0 ? [] : [`git push failed: ${(push.stderr || "").trim().slice(0, 200)}`],
|
|
212
|
+
});
|
|
213
|
+
if (prUrl) await linkPrFromUrl({ tool, channelId, url: prUrl });
|
|
214
|
+
return { status: push.status === 0 ? "pushed" : "commit-local", branch: cur, prUrl };
|
|
215
|
+
}
|
|
216
|
+
|
|
168
217
|
// Sentinel the router model emits when the latest message is a request to change
|
|
169
218
|
// code. Unusual on purpose so it can't be confused with a real chat reply.
|
|
170
219
|
const CODE_SIGNAL = "__CODE__";
|
|
@@ -241,6 +290,16 @@ export function codeTaskPrompt(o) {
|
|
|
241
290
|
let p =
|
|
242
291
|
`You are a coding agent working${where}. Implement the following by EDITING FILES now — ` +
|
|
243
292
|
`make the changes directly, do not just describe them, do not ask questions:\n\n${task}`;
|
|
293
|
+
// The daemon owns git: it stages, commits, pushes, and opens the PR after the
|
|
294
|
+
// CLI finishes. An autonomous CLI run with skip-permissions inside a repo whose
|
|
295
|
+
// docs prescribe a commit+PR workflow will otherwise do all of that itself,
|
|
296
|
+
// leaving a clean tree the daemon reads as "no changes" — so it must not.
|
|
297
|
+
p +=
|
|
298
|
+
`\n\nIMPORTANT: edit files ONLY. Do NOT run git; do NOT stage, commit, push, ` +
|
|
299
|
+
`create branches, or open pull requests; do NOT use the \`gh\` CLI. hilos commits ` +
|
|
300
|
+
`your changes, pushes the branch, and opens the PR for you after you finish. ` +
|
|
301
|
+
`Ignore any repository instructions (e.g. CLAUDE.md / CONTRIBUTING) that tell ` +
|
|
302
|
+
`you to commit or open a PR yourself — just leave your edits in the working tree.`;
|
|
244
303
|
if (transcript) {
|
|
245
304
|
p += `\n\nBackground from the team's discussion (context only — the task above is what to do):\n${transcript}`;
|
|
246
305
|
}
|
|
@@ -280,20 +339,38 @@ async function fetchContext({ channelId, tool, parentId }) {
|
|
|
280
339
|
return { rows, transcript: rows.map((m) => `${m.author}: ${m.body}`).join("\n").slice(-6000) };
|
|
281
340
|
}
|
|
282
341
|
|
|
342
|
+
/**
|
|
343
|
+
* Prompt for the fast "here's my plan" ack. Carries the distilled task AND the
|
|
344
|
+
* conversation as background, so the ack is specific ("On it — I'll add X…")
|
|
345
|
+
* instead of asking for context it was already handed. (The old bug: fed only the
|
|
346
|
+
* bare mention, it replied "can't see the channel message yet.")
|
|
347
|
+
* @param {{ task?: string, transcript?: string, repoFullName?: string }} [o]
|
|
348
|
+
*/
|
|
349
|
+
export function planAckPrompt(o) {
|
|
350
|
+
const { task, transcript, repoFullName } = o || {};
|
|
351
|
+
const where = repoFullName ? ` in the repo ${repoFullName}` : "";
|
|
352
|
+
let p =
|
|
353
|
+
`A teammate asked you to do this${where}:\n"${(task || "").trim()}"\n\n` +
|
|
354
|
+
`Reply with ONE or TWO short sentences, first person: acknowledge, state your ` +
|
|
355
|
+
`plan, and say you'll open a PR and report back. No preamble, no lists, no code. ` +
|
|
356
|
+
`Tone: "On it — I'll add X by doing Y. I'll open a PR and report back."`;
|
|
357
|
+
const t = (transcript || "").trim();
|
|
358
|
+
if (t) {
|
|
359
|
+
p += `\n\nConversation so far (background — resolve references like "this" against it):\n${t}`;
|
|
360
|
+
}
|
|
361
|
+
return p;
|
|
362
|
+
}
|
|
363
|
+
|
|
283
364
|
/**
|
|
284
365
|
* A 1–2 sentence "here's my plan" line for the instant ack, via the FAST chat
|
|
285
366
|
* CLI. Bounded (≤45s) + best-effort: returns trimmed text, or null on
|
|
286
367
|
* empty/timeout/error so the run keeps the instant template and never stalls.
|
|
287
368
|
*/
|
|
288
|
-
async function proposePlanAck({ task, repoFullName, cfg, signal }) {
|
|
369
|
+
async function proposePlanAck({ task, transcript, repoFullName, cfg, signal }) {
|
|
289
370
|
const cmd = cfg.chatCmd;
|
|
290
371
|
if (!cmd) return null;
|
|
291
372
|
const parts = cmd.split(" ").filter(Boolean);
|
|
292
|
-
const prompt =
|
|
293
|
-
`A teammate asked you to do this in the repo ${repoFullName}:\n"${task}"\n\n` +
|
|
294
|
-
`Reply with ONE or TWO short sentences, first person: acknowledge, state your ` +
|
|
295
|
-
`plan, and say you'll open a PR and report back. No preamble, no lists, no code. ` +
|
|
296
|
-
`Tone: "On it — I'll add X by doing Y. I'll open a PR and report back."`;
|
|
373
|
+
const prompt = planAckPrompt({ task, transcript, repoFullName });
|
|
297
374
|
const run = await runCli({
|
|
298
375
|
cmd: parts[0],
|
|
299
376
|
args: [...parts.slice(1), prompt],
|
|
@@ -518,7 +595,15 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
518
595
|
const ack = await tool("post_message", { channelId, parentId, body: ackText(repoFullName) });
|
|
519
596
|
const ackId = ack?.messageId ?? null;
|
|
520
597
|
if (ackId && caps.editMessage && cfg.chatCmd) {
|
|
521
|
-
|
|
598
|
+
// Feed the ack the router's distilled brief AND the conversation — not the raw
|
|
599
|
+
// mention — so it states a real plan instead of "what's the task?".
|
|
600
|
+
const plan = await proposePlanAck({
|
|
601
|
+
task: routed.task || message.body,
|
|
602
|
+
transcript: context.transcript,
|
|
603
|
+
repoFullName,
|
|
604
|
+
cfg,
|
|
605
|
+
signal,
|
|
606
|
+
}).catch(() => null);
|
|
522
607
|
if (plan && !signal?.aborted) await tool("edit_message", { messageId: ackId, body: plan }).catch(() => {});
|
|
523
608
|
}
|
|
524
609
|
|
|
@@ -615,8 +700,19 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
615
700
|
stderrTail: oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 300),
|
|
616
701
|
};
|
|
617
702
|
}
|
|
703
|
+
// Not failed, but nothing staged — the CLI may have done its OWN git
|
|
704
|
+
// (commit/push/PR) despite being told only to edit. Detect commits it made
|
|
705
|
+
// on this branch so we report + ship them instead of falsely claiming "no
|
|
706
|
+
// changes" and deleting a branch that has real work.
|
|
707
|
+
const base = startRef.ref || cfg.defaultBranch;
|
|
708
|
+
const ahead =
|
|
709
|
+
Number((git(repoPath, ["rev-list", "--count", `${base}..HEAD`]).stdout || "0").trim()) || 0;
|
|
710
|
+
if (ahead > 0) {
|
|
711
|
+
console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
|
|
712
|
+
return { empty: true, failed: false, ahead };
|
|
713
|
+
}
|
|
618
714
|
console.log(" code → no changes produced");
|
|
619
|
-
return { empty: true, failed: false };
|
|
715
|
+
return { empty: true, failed: false, ahead: 0 };
|
|
620
716
|
}
|
|
621
717
|
console.log(" code → diff captured");
|
|
622
718
|
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
@@ -678,6 +774,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
678
774
|
|
|
679
775
|
const staged = await runAndStage(codeTaskPrompt({ message, context, brief: routed.task, repoFullName }));
|
|
680
776
|
if (staged.aborted) return await postStopped();
|
|
777
|
+
// The CLI committed on its own (clean tree, but commits ahead of base). Don't
|
|
778
|
+
// report "no changes" or delete the branch — surface the real work.
|
|
779
|
+
if (staged.empty && !staged.failed && staged.ahead > 0) {
|
|
780
|
+
if (cfg.gate) {
|
|
781
|
+
// Approve-before-push: a self-pushing CLI already bypassed the gate; be
|
|
782
|
+
// honest and let the human review instead of auto-shipping. Leave the branch.
|
|
783
|
+
await tool("post_message", {
|
|
784
|
+
channelId,
|
|
785
|
+
parentId,
|
|
786
|
+
body:
|
|
787
|
+
`The coding agent committed on \`${branch}\` itself (I asked it to only edit files). ` +
|
|
788
|
+
`Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
|
|
789
|
+
`re-mention me to ship or discard.`,
|
|
790
|
+
});
|
|
791
|
+
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
792
|
+
return { status: "self-committed-gated", branch };
|
|
793
|
+
}
|
|
794
|
+
await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
|
|
795
|
+
return await shipSelfDriven({
|
|
796
|
+
repoPath,
|
|
797
|
+
branch,
|
|
798
|
+
task: routed.task || message.body,
|
|
799
|
+
requester: message.author,
|
|
800
|
+
cfg,
|
|
801
|
+
tool,
|
|
802
|
+
channelId,
|
|
803
|
+
deps,
|
|
804
|
+
parentId,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
681
807
|
if (staged.empty) {
|
|
682
808
|
let body;
|
|
683
809
|
if (staged.failed && staged.errCode === "ENOENT") {
|