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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. 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.13",
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
- if (new RegExp(`^${CODE_SIGNAL}\\b`).test(out)) {
269
- const nl = out.indexOf("\n");
270
- return { code: true, task: nl >= 0 ? out.slice(nl + 1).trim() : "" };
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: `Working tree at ${repoPath} is dirty — commit or stash first.`,
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
- // Remember where we started so cancel/cleanup can switch back explicitly
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
- const r = await tool("post_message", { channelId, parentId, body });
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
  }