hilos-agent 0.1.12 → 0.1.14
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 +159 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.14",
|
|
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__";
|
|
@@ -205,6 +254,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
205
254
|
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
206
255
|
`yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
|
|
207
256
|
`headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel.\n\n` +
|
|
257
|
+
`You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
|
|
258
|
+
`edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
|
|
208
259
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
209
260
|
const run = await runCli({
|
|
210
261
|
cmd: parts[0],
|
|
@@ -216,9 +267,16 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
216
267
|
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
217
268
|
const out = (run.stdout || "").trim();
|
|
218
269
|
if (!out) return { code: false, reply: null, error: run.error || new Error("no output") };
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
270
|
+
// Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
|
|
271
|
+
// line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
|
|
272
|
+
// it's a code run; the brief is whatever follows the sentinel's line and any
|
|
273
|
+
// preamble before it is discarded. This is also what keeps the raw sentinel out
|
|
274
|
+
// of a chat message (markdown would render `__CODE__` as a bold "CODE").
|
|
275
|
+
const idx = out.indexOf(CODE_SIGNAL);
|
|
276
|
+
if (idx >= 0) {
|
|
277
|
+
const after = out.slice(idx + CODE_SIGNAL.length);
|
|
278
|
+
const nl = after.indexOf("\n");
|
|
279
|
+
return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim() };
|
|
222
280
|
}
|
|
223
281
|
return { code: false, reply: out };
|
|
224
282
|
}
|
|
@@ -241,6 +299,16 @@ export function codeTaskPrompt(o) {
|
|
|
241
299
|
let p =
|
|
242
300
|
`You are a coding agent working${where}. Implement the following by EDITING FILES now — ` +
|
|
243
301
|
`make the changes directly, do not just describe them, do not ask questions:\n\n${task}`;
|
|
302
|
+
// The daemon owns git: it stages, commits, pushes, and opens the PR after the
|
|
303
|
+
// CLI finishes. An autonomous CLI run with skip-permissions inside a repo whose
|
|
304
|
+
// docs prescribe a commit+PR workflow will otherwise do all of that itself,
|
|
305
|
+
// leaving a clean tree the daemon reads as "no changes" — so it must not.
|
|
306
|
+
p +=
|
|
307
|
+
`\n\nIMPORTANT: edit files ONLY. Do NOT run git; do NOT stage, commit, push, ` +
|
|
308
|
+
`create branches, or open pull requests; do NOT use the \`gh\` CLI. hilos commits ` +
|
|
309
|
+
`your changes, pushes the branch, and opens the PR for you after you finish. ` +
|
|
310
|
+
`Ignore any repository instructions (e.g. CLAUDE.md / CONTRIBUTING) that tell ` +
|
|
311
|
+
`you to commit or open a PR yourself — just leave your edits in the working tree.`;
|
|
244
312
|
if (transcript) {
|
|
245
313
|
p += `\n\nBackground from the team's discussion (context only — the task above is what to do):\n${transcript}`;
|
|
246
314
|
}
|
|
@@ -500,23 +568,55 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
500
568
|
await tool("post_message", { channelId, parentId, body: `Can't read git status in ${repoPath}.` });
|
|
501
569
|
return { status: "git-error" };
|
|
502
570
|
}
|
|
571
|
+
// Remember where we started so cancel/cleanup/stash-restore can switch back
|
|
572
|
+
// explicitly (relying on `git switch -` breaks from a detached HEAD).
|
|
573
|
+
const symref = git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
574
|
+
const startRef =
|
|
575
|
+
symref.status === 0 && symref.stdout.trim()
|
|
576
|
+
? { kind: "branch", ref: symref.stdout.trim() }
|
|
577
|
+
: { kind: "detached", ref: (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() };
|
|
578
|
+
|
|
579
|
+
// A dirty tree used to be a hard stop ("commit or stash first"). But the daemon
|
|
580
|
+
// runs in the user's own checkout, so that was a constant wall — and the user
|
|
581
|
+
// can't make the agent clear it. Instead: stash the uncommitted work, run on a
|
|
582
|
+
// clean branch, and restore it (git stash pop) on the original branch when done.
|
|
583
|
+
let stashed = false;
|
|
503
584
|
if (status.stdout.trim()) {
|
|
585
|
+
const st = git(repoPath, ["stash", "push", "-u", "-m", "hilos: auto-stash before agent run"]);
|
|
586
|
+
if (st.status !== 0) {
|
|
587
|
+
await tool("post_message", {
|
|
588
|
+
channelId,
|
|
589
|
+
parentId,
|
|
590
|
+
body: `Working tree at ${repoPath} is dirty and I couldn't stash it (${(st.stderr || "").trim().slice(0, 200)}). Commit or stash, then mention me again.`,
|
|
591
|
+
});
|
|
592
|
+
return { status: "dirty" };
|
|
593
|
+
}
|
|
594
|
+
stashed = true;
|
|
504
595
|
await tool("post_message", {
|
|
505
596
|
channelId,
|
|
506
597
|
parentId,
|
|
507
|
-
body: `
|
|
598
|
+
body: `Your working tree was dirty — I stashed your uncommitted changes so I can work on a clean branch, and I'll restore them when I'm done.`,
|
|
508
599
|
});
|
|
509
|
-
return { status: "dirty" };
|
|
510
600
|
}
|
|
601
|
+
// Restore the stash onto the original branch. Best-effort: a pop conflict (rare —
|
|
602
|
+
// the daemon's work lands on its own branch, not here) leaves the stash for the
|
|
603
|
+
// user. Called from `finally` so every exit path restores.
|
|
604
|
+
const restoreStash = async () => {
|
|
605
|
+
if (!stashed) return;
|
|
606
|
+
if (startRef.ref) {
|
|
607
|
+
git(repoPath, startRef.kind === "branch" ? ["switch", "--force", startRef.ref] : ["switch", "--detach", startRef.ref]);
|
|
608
|
+
}
|
|
609
|
+
const pop = git(repoPath, ["stash", "pop"]);
|
|
610
|
+
if (pop.status !== 0) {
|
|
611
|
+
await tool("post_message", {
|
|
612
|
+
channelId,
|
|
613
|
+
parentId,
|
|
614
|
+
body: `Heads up: I couldn't auto-restore your stashed changes (conflict). They're safe — run \`git stash pop\` in ${repoPath} when you're ready.`,
|
|
615
|
+
}).catch(() => {});
|
|
616
|
+
}
|
|
617
|
+
};
|
|
511
618
|
|
|
512
|
-
|
|
513
|
-
// (relying on `git switch -` breaks from a detached HEAD).
|
|
514
|
-
const symref = git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
515
|
-
const startRef =
|
|
516
|
-
symref.status === 0 && symref.stdout.trim()
|
|
517
|
-
? { kind: "branch", ref: symref.stdout.trim() }
|
|
518
|
-
: { kind: "detached", ref: (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() };
|
|
519
|
-
|
|
619
|
+
try {
|
|
520
620
|
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
521
621
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
522
622
|
const co = git(repoPath, ["switch", "-c", branch]);
|
|
@@ -641,8 +741,19 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
641
741
|
stderrTail: oneLine((run.stderr || "").trim().split("\n").slice(-3).join(" "), 300),
|
|
642
742
|
};
|
|
643
743
|
}
|
|
744
|
+
// Not failed, but nothing staged — the CLI may have done its OWN git
|
|
745
|
+
// (commit/push/PR) despite being told only to edit. Detect commits it made
|
|
746
|
+
// on this branch so we report + ship them instead of falsely claiming "no
|
|
747
|
+
// changes" and deleting a branch that has real work.
|
|
748
|
+
const base = startRef.ref || cfg.defaultBranch;
|
|
749
|
+
const ahead =
|
|
750
|
+
Number((git(repoPath, ["rev-list", "--count", `${base}..HEAD`]).stdout || "0").trim()) || 0;
|
|
751
|
+
if (ahead > 0) {
|
|
752
|
+
console.log(` code → agent self-committed (${ahead} commit(s) ahead); reconciling`);
|
|
753
|
+
return { empty: true, failed: false, ahead };
|
|
754
|
+
}
|
|
644
755
|
console.log(" code → no changes produced");
|
|
645
|
-
return { empty: true, failed: false };
|
|
756
|
+
return { empty: true, failed: false, ahead: 0 };
|
|
646
757
|
}
|
|
647
758
|
console.log(" code → diff captured");
|
|
648
759
|
const stat = parseShortstat(git(repoPath, ["diff", "--cached", "--shortstat"]).stdout);
|
|
@@ -704,6 +815,36 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
704
815
|
|
|
705
816
|
const staged = await runAndStage(codeTaskPrompt({ message, context, brief: routed.task, repoFullName }));
|
|
706
817
|
if (staged.aborted) return await postStopped();
|
|
818
|
+
// The CLI committed on its own (clean tree, but commits ahead of base). Don't
|
|
819
|
+
// report "no changes" or delete the branch — surface the real work.
|
|
820
|
+
if (staged.empty && !staged.failed && staged.ahead > 0) {
|
|
821
|
+
if (cfg.gate) {
|
|
822
|
+
// Approve-before-push: a self-pushing CLI already bypassed the gate; be
|
|
823
|
+
// honest and let the human review instead of auto-shipping. Leave the branch.
|
|
824
|
+
await tool("post_message", {
|
|
825
|
+
channelId,
|
|
826
|
+
parentId,
|
|
827
|
+
body:
|
|
828
|
+
`The coding agent committed on \`${branch}\` itself (I asked it to only edit files). ` +
|
|
829
|
+
`Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
|
|
830
|
+
`re-mention me to ship or discard.`,
|
|
831
|
+
});
|
|
832
|
+
await finalizeProgress(`\`${branch}\` has the agent's own commits — needs review.`);
|
|
833
|
+
return { status: "self-committed-gated", branch };
|
|
834
|
+
}
|
|
835
|
+
await finalizeProgress(`Agent shipped \`${branch}\` itself — report below.`);
|
|
836
|
+
return await shipSelfDriven({
|
|
837
|
+
repoPath,
|
|
838
|
+
branch,
|
|
839
|
+
task: routed.task || message.body,
|
|
840
|
+
requester: message.author,
|
|
841
|
+
cfg,
|
|
842
|
+
tool,
|
|
843
|
+
channelId,
|
|
844
|
+
deps,
|
|
845
|
+
parentId,
|
|
846
|
+
});
|
|
847
|
+
}
|
|
707
848
|
if (staged.empty) {
|
|
708
849
|
let body;
|
|
709
850
|
if (staged.failed && staged.errCode === "ENOENT") {
|
|
@@ -796,4 +937,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
796
937
|
});
|
|
797
938
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
798
939
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
|
|
940
|
+
} finally {
|
|
941
|
+
// Whatever happened, give the user their uncommitted work back.
|
|
942
|
+
await restoreStash();
|
|
943
|
+
}
|
|
799
944
|
}
|