hilos-agent 0.1.13 → 0.1.15
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 +92 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hilos-agent",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
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
|
@@ -104,12 +104,16 @@ async function linkPrFromUrl({ tool, channelId, url }) {
|
|
|
104
104
|
async function applyDecision({ decision, repoPath, branch, task, requester, cfg, tool, channelId, deps, parentId }) {
|
|
105
105
|
const tag = requesterTag(requester);
|
|
106
106
|
const lead = tag ? `${tag} — ` : "";
|
|
107
|
+
// `parentId` here is the run's thread root: every outcome below is terminal, so
|
|
108
|
+
// it broadcasts back to the channel (visible in both thread and channel) — the
|
|
109
|
+
// hosted runner's reply_broadcast. A no-op when there's no thread root.
|
|
107
110
|
if (decision.kind === "approved") {
|
|
108
111
|
// Guard: nothing staged → don't push an empty branch and falsely report "shipped".
|
|
109
112
|
if (deps.git(repoPath, ["diff", "--cached", "--quiet"]).status === 0) {
|
|
110
113
|
await tool("post_message", {
|
|
111
114
|
channelId,
|
|
112
115
|
parentId,
|
|
116
|
+
broadcast: true,
|
|
113
117
|
body: `Approved, but there are no staged changes to commit on \`${branch}\`.`,
|
|
114
118
|
});
|
|
115
119
|
return { status: "nothing-staged", branch };
|
|
@@ -119,6 +123,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
119
123
|
await tool("post_message", {
|
|
120
124
|
channelId,
|
|
121
125
|
parentId,
|
|
126
|
+
broadcast: true,
|
|
122
127
|
body: `Approved, but the commit failed: ${(commit.stderr || "").trim().slice(0, 300)}`,
|
|
123
128
|
});
|
|
124
129
|
return { status: "commit-failed", branch };
|
|
@@ -128,6 +133,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
128
133
|
await tool("post_message", {
|
|
129
134
|
channelId,
|
|
130
135
|
parentId,
|
|
136
|
+
broadcast: true,
|
|
131
137
|
body: `Approved, but the push failed: ${(push.stderr || "").trim().slice(0, 300)}`,
|
|
132
138
|
});
|
|
133
139
|
return { status: "push-failed", branch };
|
|
@@ -137,6 +143,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
137
143
|
await tool("post_report", {
|
|
138
144
|
channelId,
|
|
139
145
|
parentId,
|
|
146
|
+
broadcast: true,
|
|
140
147
|
title: `Shipped: ${title}`,
|
|
141
148
|
summary:
|
|
142
149
|
pr.ok && pr.url
|
|
@@ -155,7 +162,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
155
162
|
deps.git(repoPath, ["clean", "-fd"]);
|
|
156
163
|
deps.git(repoPath, ["switch", "-"]);
|
|
157
164
|
deps.git(repoPath, ["branch", "-D", branch]);
|
|
158
|
-
await tool("post_message", { channelId, parentId, body: `Rejected — discarded \`${branch}\`.` });
|
|
165
|
+
await tool("post_message", { channelId, parentId, broadcast: true, body: `Rejected — discarded \`${branch}\`.` });
|
|
159
166
|
return { status: "discarded", branch };
|
|
160
167
|
}
|
|
161
168
|
|
|
@@ -163,6 +170,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
163
170
|
await tool("post_message", {
|
|
164
171
|
channelId,
|
|
165
172
|
parentId,
|
|
173
|
+
broadcast: true,
|
|
166
174
|
body: `Got it${decision.note ? `: ${decision.note}` : ""}. Leaving \`${branch}\` for a follow-up.`,
|
|
167
175
|
});
|
|
168
176
|
return { status: "changes", branch, note: decision.note };
|
|
@@ -171,6 +179,7 @@ async function applyDecision({ decision, repoPath, branch, task, requester, cfg,
|
|
|
171
179
|
await tool("post_message", {
|
|
172
180
|
channelId,
|
|
173
181
|
parentId,
|
|
182
|
+
broadcast: true,
|
|
174
183
|
body: `Still awaiting review on \`${branch}\`. Re-mention me once you've decided.`,
|
|
175
184
|
});
|
|
176
185
|
return { status: "timeout", branch };
|
|
@@ -201,6 +210,7 @@ async function shipSelfDriven({ repoPath, branch, task, requester, cfg, tool, ch
|
|
|
201
210
|
await tool("post_report", {
|
|
202
211
|
channelId,
|
|
203
212
|
parentId,
|
|
213
|
+
broadcast: true,
|
|
204
214
|
title: `Shipped: ${title}`,
|
|
205
215
|
summary: prUrl
|
|
206
216
|
? `${lead}the coding agent committed on \`${cur}\` and opened a pull request.`
|
|
@@ -254,6 +264,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
254
264
|
`Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
|
|
255
265
|
`yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
|
|
256
266
|
`headings). When unsure, prefer to act (${CODE_SIGNAL}); this is a build channel.\n\n` +
|
|
267
|
+
`You are only routing here — output ONLY your text response. Do NOT use any tools, do NOT ` +
|
|
268
|
+
`edit files, do NOT run commands; a separate step does the actual coding.\n\n` +
|
|
257
269
|
`${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
|
|
258
270
|
const run = await runCli({
|
|
259
271
|
cmd: parts[0],
|
|
@@ -265,9 +277,16 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
|
|
|
265
277
|
if (run.aborted || signal?.aborted) return { aborted: true };
|
|
266
278
|
const out = (run.stdout || "").trim();
|
|
267
279
|
if (!out) return { code: false, reply: null, error: run.error || new Error("no output") };
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
280
|
+
// Accept the sentinel ANYWHERE, not just the first line — models sometimes add a
|
|
281
|
+
// line of preamble before it ("Got it, …\n__CODE__\n<brief>"). If it appears,
|
|
282
|
+
// it's a code run; the brief is whatever follows the sentinel's line and any
|
|
283
|
+
// preamble before it is discarded. This is also what keeps the raw sentinel out
|
|
284
|
+
// of a chat message (markdown would render `__CODE__` as a bold "CODE").
|
|
285
|
+
const idx = out.indexOf(CODE_SIGNAL);
|
|
286
|
+
if (idx >= 0) {
|
|
287
|
+
const after = out.slice(idx + CODE_SIGNAL.length);
|
|
288
|
+
const nl = after.indexOf("\n");
|
|
289
|
+
return { code: true, task: (nl >= 0 ? after.slice(nl + 1) : after).trim() };
|
|
271
290
|
}
|
|
272
291
|
return { code: false, reply: out };
|
|
273
292
|
}
|
|
@@ -559,23 +578,55 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
559
578
|
await tool("post_message", { channelId, parentId, body: `Can't read git status in ${repoPath}.` });
|
|
560
579
|
return { status: "git-error" };
|
|
561
580
|
}
|
|
581
|
+
// Remember where we started so cancel/cleanup/stash-restore can switch back
|
|
582
|
+
// explicitly (relying on `git switch -` breaks from a detached HEAD).
|
|
583
|
+
const symref = git(repoPath, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
|
|
584
|
+
const startRef =
|
|
585
|
+
symref.status === 0 && symref.stdout.trim()
|
|
586
|
+
? { kind: "branch", ref: symref.stdout.trim() }
|
|
587
|
+
: { kind: "detached", ref: (git(repoPath, ["rev-parse", "HEAD"]).stdout || "").trim() };
|
|
588
|
+
|
|
589
|
+
// A dirty tree used to be a hard stop ("commit or stash first"). But the daemon
|
|
590
|
+
// runs in the user's own checkout, so that was a constant wall — and the user
|
|
591
|
+
// can't make the agent clear it. Instead: stash the uncommitted work, run on a
|
|
592
|
+
// clean branch, and restore it (git stash pop) on the original branch when done.
|
|
593
|
+
let stashed = false;
|
|
562
594
|
if (status.stdout.trim()) {
|
|
595
|
+
const st = git(repoPath, ["stash", "push", "-u", "-m", "hilos: auto-stash before agent run"]);
|
|
596
|
+
if (st.status !== 0) {
|
|
597
|
+
await tool("post_message", {
|
|
598
|
+
channelId,
|
|
599
|
+
parentId,
|
|
600
|
+
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.`,
|
|
601
|
+
});
|
|
602
|
+
return { status: "dirty" };
|
|
603
|
+
}
|
|
604
|
+
stashed = true;
|
|
563
605
|
await tool("post_message", {
|
|
564
606
|
channelId,
|
|
565
607
|
parentId,
|
|
566
|
-
body: `
|
|
608
|
+
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.`,
|
|
567
609
|
});
|
|
568
|
-
return { status: "dirty" };
|
|
569
610
|
}
|
|
611
|
+
// Restore the stash onto the original branch. Best-effort: a pop conflict (rare —
|
|
612
|
+
// the daemon's work lands on its own branch, not here) leaves the stash for the
|
|
613
|
+
// user. Called from `finally` so every exit path restores.
|
|
614
|
+
const restoreStash = async () => {
|
|
615
|
+
if (!stashed) return;
|
|
616
|
+
if (startRef.ref) {
|
|
617
|
+
git(repoPath, startRef.kind === "branch" ? ["switch", "--force", startRef.ref] : ["switch", "--detach", startRef.ref]);
|
|
618
|
+
}
|
|
619
|
+
const pop = git(repoPath, ["stash", "pop"]);
|
|
620
|
+
if (pop.status !== 0) {
|
|
621
|
+
await tool("post_message", {
|
|
622
|
+
channelId,
|
|
623
|
+
parentId,
|
|
624
|
+
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.`,
|
|
625
|
+
}).catch(() => {});
|
|
626
|
+
}
|
|
627
|
+
};
|
|
570
628
|
|
|
571
|
-
|
|
572
|
-
// (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
|
-
|
|
629
|
+
try {
|
|
579
630
|
const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
|
|
580
631
|
git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
|
|
581
632
|
const co = git(repoPath, ["switch", "-c", branch]);
|
|
@@ -594,6 +645,13 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
594
645
|
// Best-effort — a slow or failed ack just leaves the template, never blocks.
|
|
595
646
|
const ack = await tool("post_message", { channelId, parentId, body: ackText(repoFullName) });
|
|
596
647
|
const ackId = ack?.messageId ?? null;
|
|
648
|
+
// Where the run streams + reports. When the mention was already in a thread,
|
|
649
|
+
// stay in it. When it was top-level, the ack becomes the thread anchor so the
|
|
650
|
+
// progress worklog and the report card live UNDER it — one tidy thread instead
|
|
651
|
+
// of three separate top-level posts — exactly like the hosted/native runner.
|
|
652
|
+
// The ack itself stays top-level (the channel-visible "on it"); terminal
|
|
653
|
+
// outcomes + report cards broadcast back to the channel from the thread.
|
|
654
|
+
const threadRoot = parentId ?? ackId;
|
|
597
655
|
if (ackId && caps.editMessage && cfg.chatCmd) {
|
|
598
656
|
// Feed the ack the router's distilled brief AND the conversation — not the raw
|
|
599
657
|
// mention — so it states a real plan instead of "what's the task?".
|
|
@@ -625,7 +683,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
625
683
|
const body = buildHeartbeat({ repoFullName, branch, elapsedMs: deps.now() - start, lastLine });
|
|
626
684
|
try {
|
|
627
685
|
if (!progressId) {
|
|
628
|
-
|
|
686
|
+
// Progress is the worklog — it streams in the thread only (no broadcast).
|
|
687
|
+
const r = await tool("post_message", { channelId, parentId: threadRoot, body });
|
|
629
688
|
progressId = r?.messageId ?? null;
|
|
630
689
|
} else {
|
|
631
690
|
await tool("edit_message", { messageId: progressId, body });
|
|
@@ -733,7 +792,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
733
792
|
stat: staged.stat,
|
|
734
793
|
runFailed: staged.runFailed,
|
|
735
794
|
});
|
|
736
|
-
const res = await tool("post_report", { channelId, parentId, ...report });
|
|
795
|
+
const res = await tool("post_report", { channelId, parentId: threadRoot, broadcast: true, ...report });
|
|
737
796
|
return { reportMessageId: res?.messageId ?? null, stat: staged.stat };
|
|
738
797
|
};
|
|
739
798
|
|
|
@@ -764,7 +823,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
764
823
|
const clean = discardBranch();
|
|
765
824
|
await tool("post_message", {
|
|
766
825
|
channelId,
|
|
767
|
-
parentId,
|
|
826
|
+
parentId: threadRoot,
|
|
827
|
+
broadcast: true,
|
|
768
828
|
body: clean
|
|
769
829
|
? `Stopped — discarded \`${branch}\`.`
|
|
770
830
|
: `Stopped, but couldn't fully clean \`${branch}\` — run \`git reset --hard && git switch ${startRef.ref || cfg.defaultBranch}\` in ${repoPath}.`,
|
|
@@ -782,7 +842,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
782
842
|
// honest and let the human review instead of auto-shipping. Leave the branch.
|
|
783
843
|
await tool("post_message", {
|
|
784
844
|
channelId,
|
|
785
|
-
parentId,
|
|
845
|
+
parentId: threadRoot,
|
|
846
|
+
broadcast: true,
|
|
786
847
|
body:
|
|
787
848
|
`The coding agent committed on \`${branch}\` itself (I asked it to only edit files). ` +
|
|
788
849
|
`Under approve-before-push I won't auto-push it — review \`${branch}\` locally, then ` +
|
|
@@ -801,7 +862,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
801
862
|
tool,
|
|
802
863
|
channelId,
|
|
803
864
|
deps,
|
|
804
|
-
parentId,
|
|
865
|
+
parentId: threadRoot,
|
|
805
866
|
});
|
|
806
867
|
}
|
|
807
868
|
if (staged.empty) {
|
|
@@ -819,7 +880,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
819
880
|
} else {
|
|
820
881
|
body = `No changes were produced. Cleaning up \`${branch}\`.`;
|
|
821
882
|
}
|
|
822
|
-
await tool("post_message", { channelId, parentId, body });
|
|
883
|
+
await tool("post_message", { channelId, parentId: threadRoot, broadcast: true, body });
|
|
823
884
|
await finalizeProgress(
|
|
824
885
|
staged.failed ? `Run ended early on \`${branch}\` — see the note below.` : `No changes needed on \`${branch}\`.`,
|
|
825
886
|
);
|
|
@@ -844,7 +905,7 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
844
905
|
tool,
|
|
845
906
|
channelId,
|
|
846
907
|
deps,
|
|
847
|
-
parentId,
|
|
908
|
+
parentId: threadRoot,
|
|
848
909
|
});
|
|
849
910
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
850
911
|
return { ...result, stat: staged.stat };
|
|
@@ -852,14 +913,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
852
913
|
|
|
853
914
|
await finalizeProgress(`Coding done on \`${branch}\` — proposal below for review.`);
|
|
854
915
|
let proposal = await postProposal(staged);
|
|
855
|
-
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
916
|
+
let decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
|
|
856
917
|
if (decision.kind === "cancelled") return await postStopped();
|
|
857
918
|
const maxRounds = cfg.maxRounds || 3;
|
|
858
919
|
let round = 1;
|
|
859
920
|
while (decision.kind === "changes" && round < maxRounds) {
|
|
860
921
|
await tool("post_message", {
|
|
861
922
|
channelId,
|
|
862
|
-
parentId,
|
|
923
|
+
parentId: threadRoot,
|
|
863
924
|
body: `Revising with your feedback${decision.note ? `: ${decision.note}` : ""} (round ${round + 1}/${maxRounds}).`,
|
|
864
925
|
});
|
|
865
926
|
const refined = await runAndStage(
|
|
@@ -869,13 +930,14 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
869
930
|
if (refined.empty) {
|
|
870
931
|
await tool("post_message", {
|
|
871
932
|
channelId,
|
|
872
|
-
parentId,
|
|
933
|
+
parentId: threadRoot,
|
|
934
|
+
broadcast: true,
|
|
873
935
|
body: `That feedback produced no further changes — leaving \`${branch}\` as proposed.`,
|
|
874
936
|
});
|
|
875
937
|
break;
|
|
876
938
|
}
|
|
877
939
|
proposal = await postProposal(refined);
|
|
878
|
-
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId, signal });
|
|
940
|
+
decision = await awaitDecision({ tool, channelId, reportMessageId: proposal.reportMessageId, cfg, deps, parentId: threadRoot, signal });
|
|
879
941
|
if (decision.kind === "cancelled") return await postStopped();
|
|
880
942
|
round += 1;
|
|
881
943
|
}
|
|
@@ -892,8 +954,12 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
|
|
|
892
954
|
tool,
|
|
893
955
|
channelId,
|
|
894
956
|
deps,
|
|
895
|
-
parentId,
|
|
957
|
+
parentId: threadRoot,
|
|
896
958
|
});
|
|
897
959
|
await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
|
|
898
960
|
return { ...result, reportMessageId: proposal.reportMessageId, stat: proposal.stat, rounds: round };
|
|
961
|
+
} finally {
|
|
962
|
+
// Whatever happened, give the user their uncommitted work back.
|
|
963
|
+
await restoreStash();
|
|
964
|
+
}
|
|
899
965
|
}
|