hilos-agent 0.1.13 → 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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/handler.mjs +58 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hilos-agent",
3
- "version": "0.1.13",
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
@@ -254,6 +254,8 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
254
254
  `Otherwise — a question, a greeting, general discussion, or they explicitly don't want code ` +
255
255
  `yet — just reply to them concisely and directly as a single chat message (no preamble, no ` +
256
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` +
257
259
  `${memoryPreamble(workspaceMemory)}Conversation so far:\n${transcript}`;
258
260
  const run = await runCli({
259
261
  cmd: parts[0],
@@ -265,9 +267,16 @@ async function routeIntent({ name, repoFullName, transcript, workspaceMemory, cf
265
267
  if (run.aborted || signal?.aborted) return { aborted: true };
266
268
  const out = (run.stdout || "").trim();
267
269
  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() : "" };
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() };
271
280
  }
272
281
  return { code: false, reply: out };
273
282
  }
@@ -559,23 +568,55 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
559
568
  await tool("post_message", { channelId, parentId, body: `Can't read git status in ${repoPath}.` });
560
569
  return { status: "git-error" };
561
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;
562
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;
563
595
  await tool("post_message", {
564
596
  channelId,
565
597
  parentId,
566
- body: `Working tree at ${repoPath} is dirty — commit or stash first.`,
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.`,
567
599
  });
568
- return { status: "dirty" };
569
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
+ };
570
618
 
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
-
619
+ try {
579
620
  const branch = branchSlug(message.body, randomBytes(3).toString("hex"));
580
621
  git(repoPath, ["fetch", "origin", cfg.defaultBranch]);
581
622
  const co = git(repoPath, ["switch", "-c", branch]);
@@ -896,4 +937,8 @@ export async function handleTask({ message, channelId, tool, me, caps = {} }, cf
896
937
  });
897
938
  await finalizeProgress(`Done on \`${branch}\` — see the report below.`);
898
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
+ }
899
944
  }