bughunters 0.1.0 → 0.1.1

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/dist/bin.js CHANGED
@@ -364,7 +364,12 @@ var agentsSchema = z.object({
364
364
  patrol: z.object({
365
365
  intervalMinutes: z.number().positive().default(30),
366
366
  /** Stop after this many cycles; 0 means run until stopped. */
367
- cycles: z.number().int().nonnegative().default(0)
367
+ cycles: z.number().int().nonnegative().default(0),
368
+ /**
369
+ * At the start of each cycle, fetch this `remote/branch` and check out its
370
+ * latest commit (detached) in the source repository. false keeps the checkout.
371
+ */
372
+ pull: z.union([z.string().regex(/^[^/]+\/.+$/, "Use remote/branch, e.g. origin/main"), z.literal(false)]).default("origin/main")
368
373
  }).default({})
369
374
  }).default({});
370
375
  var bughuntersConfigSchema = z.object({
@@ -697,7 +702,7 @@ Agents
697
702
  bughunters publish [--issue <id>] [--dry-run]
698
703
  bughunters github sync
699
704
  bughunters worktrees clean
700
- bughunters patrol [--once]
705
+ bughunters patrol [--once] [--force]
701
706
  bughunters replay <routine-id>
702
707
  bughunters issue list | dismiss <id> --reason "..." [--by name] | reopen <id>
703
708
  bughunters memory list [--role r] | add --role r "text" [--scope s]
@@ -772,10 +777,12 @@ var COMMAND_HELP = {
772
777
  --dry-run write the reports to .bughunters/runs/publish/, and open nothing
773
778
  --issue <id> publish this issue only
774
779
  `,
775
- patrol: `bughunters patrol [--once]
780
+ patrol: `bughunters patrol [--once] [--force]
776
781
  Run the full cycle again and again: setup, explore, judge, teardown, fix, retest, publish.
777
782
  The fixer and GitHub steps run only when you turn them on.
783
+ A cycle runs only when the commit changed since the last full cycle. Otherwise it only syncs GitHub.
778
784
  --once run one cycle, then stop
785
+ --force run the first cycle also when the commit did not change
779
786
  `,
780
787
  github: `bughunters github sync
781
788
  Read the state of each PR and issue from GitHub. A closed PR becomes a lesson. An issue closed as not planned becomes a dismissal.
@@ -910,9 +917,9 @@ async function startApp(app, opts) {
910
917
  if (command2.readyWhen) {
911
918
  const ready = new RegExp(command2.readyWhen);
912
919
  await new Promise((done, reject) => {
913
- const wait = setTimeout(() => finish(new Error("timed out waiting for ready output")), command2.timeoutMs);
920
+ const wait2 = setTimeout(() => finish(new Error("timed out waiting for ready output")), command2.timeoutMs);
914
921
  const finish = (error) => {
915
- clearTimeout(wait);
922
+ clearTimeout(wait2);
916
923
  child.stdout?.off("data", check);
917
924
  child.stderr?.off("data", check);
918
925
  child.off("exit", onExit);
@@ -3227,12 +3234,12 @@ function stepWords(step) {
3227
3234
  }
3228
3235
 
3229
3236
  // ../agents/dist/github.js
3230
- var defaultGh = (args2, opts) => new Promise((resolve10, reject) => {
3237
+ var defaultGh = (args2, opts) => new Promise((resolve11, reject) => {
3231
3238
  const child = execFile2("gh", args2, { cwd: opts?.cwd, maxBuffer: 16 * 1024 * 1024 }, (error, stdout, stderr) => {
3232
3239
  if (error)
3233
3240
  reject(Object.assign(error, { stderr, stdout }));
3234
3241
  else
3235
- resolve10(stdout.trim());
3242
+ resolve11(stdout.trim());
3236
3243
  });
3237
3244
  child.stdin?.end(opts?.input);
3238
3245
  });
@@ -4851,7 +4858,7 @@ async function actWhenReady(driver, action, session, windowMs) {
4851
4858
  if (result3.ok || !retry || Date.now() >= deadline || session.cancelled) {
4852
4859
  return { ...result3, degraded };
4853
4860
  }
4854
- await new Promise((resolve10) => setTimeout(resolve10, 500));
4861
+ await new Promise((resolve11) => setTimeout(resolve11, 500));
4855
4862
  }
4856
4863
  }
4857
4864
  function toAction(step, session) {
@@ -6952,7 +6959,7 @@ var MaestroDriver = class {
6952
6959
  await this.run([`openLink: ${yamlString(action.url)}`]);
6953
6960
  }
6954
6961
  async wait(action) {
6955
- await new Promise((resolve10) => setTimeout(resolve10, action.ms));
6962
+ await new Promise((resolve11) => setTimeout(resolve11, action.ms));
6956
6963
  }
6957
6964
  async switchWindow() {
6958
6965
  throw new Error("Window switching is unsupported on mobile");
@@ -7013,7 +7020,7 @@ var MaestroDriver = class {
7013
7020
  return { frames, stable: true };
7014
7021
  }
7015
7022
  previous = current;
7016
- await new Promise((resolve10) => setTimeout(resolve10, interval));
7023
+ await new Promise((resolve11) => setTimeout(resolve11, interval));
7017
7024
  }
7018
7025
  return { frames, stable: false };
7019
7026
  }
@@ -7952,9 +7959,75 @@ ${items.map((item) => `${item.issue.id}: ${item.issue.title}`).join("\n")}`),
7952
7959
  }
7953
7960
 
7954
7961
  // ../agents/dist/patrol.js
7962
+ import { execFile as execFile6 } from "node:child_process";
7963
+ import { resolve as resolve9 } from "node:path";
7964
+ import { promisify as promisify6 } from "node:util";
7965
+ var exec5 = promisify6(execFile6);
7966
+ async function git4(cwd, ...args2) {
7967
+ return (await exec5("git", args2, { cwd })).stdout.trim();
7968
+ }
7969
+ async function pullSource(root2, config, onLog) {
7970
+ const target = config.agents.patrol.pull;
7971
+ if (!target)
7972
+ return false;
7973
+ const slash = target.indexOf("/");
7974
+ const remote = target.slice(0, slash);
7975
+ const branch = target.slice(slash + 1);
7976
+ const source = resolve9(root2, config.app.source);
7977
+ try {
7978
+ if (await git4(source, "status", "--porcelain", "--untracked-files=no")) {
7979
+ onLog?.(`Did not pull ${target}: the source repository has uncommitted changes.`);
7980
+ return false;
7981
+ }
7982
+ await git4(source, "fetch", "-q", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`);
7983
+ await git4(source, "checkout", "-q", "--detach", `${remote}/${branch}`);
7984
+ onLog?.(`Pulled ${target} at ${await git4(source, "rev-parse", "--short", "HEAD")}.`);
7985
+ return true;
7986
+ } catch (error) {
7987
+ const reason = error.stderr?.trim() || error.message;
7988
+ onLog?.(`Did not pull ${target}: ${reason}. The cycle uses the current checkout.`);
7989
+ return false;
7990
+ }
7991
+ }
7992
+ async function sourceCommit(root2, config) {
7993
+ const source = resolve9(root2, config.app.source);
7994
+ try {
7995
+ if (await git4(source, "status", "--porcelain", "--untracked-files=no"))
7996
+ return void 0;
7997
+ return await git4(source, "rev-parse", "HEAD");
7998
+ } catch {
7999
+ return void 0;
8000
+ }
8001
+ }
8002
+ function alive(pid) {
8003
+ try {
8004
+ process.kill(pid, 0);
8005
+ return true;
8006
+ } catch {
8007
+ return false;
8008
+ }
8009
+ }
8010
+ function wait(minutes) {
8011
+ return new Promise((done) => {
8012
+ const finish = () => {
8013
+ clearTimeout(timer);
8014
+ process.off("SIGINT", finish);
8015
+ process.off("SIGTERM", finish);
8016
+ done();
8017
+ };
8018
+ const timer = setTimeout(finish, minutes * 6e4);
8019
+ process.once("SIGINT", finish);
8020
+ process.once("SIGTERM", finish);
8021
+ });
8022
+ }
7955
8023
  async function runPatrol(options) {
7956
8024
  const { root: root2, config } = options;
7957
8025
  const workspace = new Workspace(root2);
8026
+ const previous = (await workspace.readAgents()).patrol;
8027
+ if (previous?.state === "running" && previous.pid && previous.pid !== process.pid && alive(previous.pid)) {
8028
+ options.onLog?.(`A patrol already runs (pid ${previous.pid}): did not start another.`);
8029
+ return;
8030
+ }
7958
8031
  const runtime = options.createRuntime ?? createRuntime;
7959
8032
  let interrupted = false;
7960
8033
  let activeSession;
@@ -7964,6 +8037,11 @@ async function runPatrol(options) {
7964
8037
  activeSession.cancelled = true;
7965
8038
  }
7966
8039
  };
8040
+ const idle = () => workspace.setPatrol({
8041
+ state: "stopped",
8042
+ nextAt: new Date(Date.now() + config.agents.patrol.intervalMinutes * 6e4).toISOString()
8043
+ });
8044
+ let tested = options.force ? void 0 : previous?.commit;
7967
8045
  process.on("SIGINT", onSignal);
7968
8046
  process.on("SIGTERM", onSignal);
7969
8047
  try {
@@ -7971,10 +8049,24 @@ async function runPatrol(options) {
7971
8049
  if (config.agents.patrol.cycles && cycle > config.agents.patrol.cycles) {
7972
8050
  break;
7973
8051
  }
8052
+ if (cycle > 1) {
8053
+ await wait(config.agents.patrol.intervalMinutes);
8054
+ if (interrupted)
8055
+ break;
8056
+ }
7974
8057
  await workspace.setPatrol({ state: "running", cycle, startedAt: (/* @__PURE__ */ new Date()).toISOString() });
8058
+ await pullSource(root2, config, options.onLog);
7975
8059
  if (config.agents.github.enabled)
7976
8060
  await syncGitHub(root2, config, { onLog: options.onLog });
7977
8061
  await cleanWorktrees(root2, config, { onLog: options.onLog });
8062
+ const commit = await sourceCommit(root2, config);
8063
+ if (commit && commit === tested) {
8064
+ options.onLog?.(`No new commit since the last cycle (${commit.slice(0, 7)}): skipped the cycle.`);
8065
+ await idle();
8066
+ if (options.once)
8067
+ break;
8068
+ continue;
8069
+ }
7978
8070
  for (const role of ["explorer", "judge", "fixer"]) {
7979
8071
  if (!config.agents[role].enabled) {
7980
8072
  await workspace.setAgentStatus(role, { state: "off" });
@@ -8044,26 +8136,17 @@ async function runPatrol(options) {
8044
8136
  });
8045
8137
  if (!interrupted && config.agents.github.enabled)
8046
8138
  await syncGitHub(root2, config, { onLog: options.onLog });
8047
- const nextAt = new Date(Date.now() + config.agents.patrol.intervalMinutes * 6e4).toISOString();
8048
- await workspace.setPatrol({ state: "stopped", nextAt });
8139
+ if (!interrupted && commit) {
8140
+ tested = commit;
8141
+ await workspace.setPatrol({ commit });
8142
+ }
8143
+ await idle();
8049
8144
  if (options.once || interrupted) {
8050
8145
  break;
8051
8146
  }
8052
- await new Promise((done) => {
8053
- const finish = () => {
8054
- clearTimeout(timer);
8055
- process.off("SIGINT", stop);
8056
- process.off("SIGTERM", stop);
8057
- done();
8058
- };
8059
- const timer = setTimeout(finish, config.agents.patrol.intervalMinutes * 6e4);
8060
- const stop = () => finish();
8061
- process.once("SIGINT", stop);
8062
- process.once("SIGTERM", stop);
8063
- });
8064
8147
  }
8065
8148
  } finally {
8066
- await workspace.setPatrol({ state: "stopped" });
8149
+ await workspace.setPatrol({ state: "stopped", nextAt: void 0 });
8067
8150
  process.off("SIGINT", onSignal);
8068
8151
  process.off("SIGTERM", onSignal);
8069
8152
  }
@@ -8243,9 +8326,9 @@ var JEV_ROUTES2 = {
8243
8326
  };
8244
8327
  var CLI_LABELS = { claude: "Claude Code", codex: "Codex", kimi: "Kimi CLI", pi: "pi" };
8245
8328
  var run = (command2, args2) => spawnSync2(command2, args2, { encoding: "utf8", timeout: 1e4 }).stdout ?? "";
8246
- function detectProviders(env = process.env, exec5 = run) {
8329
+ function detectProviders(env = process.env, exec6 = run) {
8247
8330
  const found = CLI_AGENTS.filter((agent) => onPath(agent, env));
8248
- const piList = found.includes("pi") ? exec5("pi", ["list"]) : "";
8331
+ const piList = found.includes("pi") ? exec6("pi", ["list"]) : "";
8249
8332
  const piWithoutMcp = found.includes("pi") && !piList.includes("pi-mcp-adapter");
8250
8333
  return {
8251
8334
  clis: found.filter((agent) => agent !== "pi" || !piWithoutMcp),
@@ -9215,7 +9298,7 @@ function bodyMentions(body, selector) {
9215
9298
  async function stopProcess(child) {
9216
9299
  if (child.exitCode !== null || child.pid === void 0)
9217
9300
  return;
9218
- const done = new Promise((resolve10) => child.once("exit", () => resolve10()));
9301
+ const done = new Promise((resolve11) => child.once("exit", () => resolve11()));
9219
9302
  try {
9220
9303
  process.kill(-child.pid, "SIGTERM");
9221
9304
  } catch {
@@ -10241,7 +10324,7 @@ function formatRunSummary(result3) {
10241
10324
  // ../dashboard/dist/server.js
10242
10325
  import { createServer as createServer2 } from "node:http";
10243
10326
  import { createReadStream, existsSync as existsSync10, realpathSync, statSync as statSync2, watch } from "node:fs";
10244
- import { extname, join as join18, relative as relative4, resolve as resolve9, sep as sep3 } from "node:path";
10327
+ import { extname, join as join18, relative as relative4, resolve as resolve10, sep as sep3 } from "node:path";
10245
10328
  import { fileURLToPath } from "node:url";
10246
10329
 
10247
10330
  // ../dashboard/dist/project.js
@@ -10859,7 +10942,7 @@ function hostOf(href) {
10859
10942
  }
10860
10943
 
10861
10944
  // ../dashboard/dist/server.js
10862
- var UI_DIR = resolve9(fileURLToPath(new URL(".", import.meta.url)), "..", "src", "ui");
10945
+ var UI_DIR = resolve10(fileURLToPath(new URL(".", import.meta.url)), "..", "src", "ui");
10863
10946
  var MIME = {
10864
10947
  ".html": "text/html; charset=utf-8",
10865
10948
  ".js": "text/javascript; charset=utf-8",
@@ -10870,7 +10953,7 @@ var MIME = {
10870
10953
  ".json": "application/json; charset=utf-8"
10871
10954
  };
10872
10955
  async function startDashboard(options) {
10873
- const root2 = resolve9(options.root);
10956
+ const root2 = resolve10(options.root);
10874
10957
  const host = options.host ?? "127.0.0.1";
10875
10958
  const reader = new ProjectReader(root2);
10876
10959
  const agents = new AgentReader(root2);
@@ -10965,7 +11048,7 @@ async function handle2(req, res, ctx) {
10965
11048
  return serveUi(res, path);
10966
11049
  }
10967
11050
  function resolveArtifactPath(root2, requested) {
10968
- const candidate = resolve9(root2, requested);
11051
+ const candidate = resolve10(root2, requested);
10969
11052
  if (!existsSync10(candidate))
10970
11053
  return void 0;
10971
11054
  let real;
@@ -10998,7 +11081,7 @@ function serveArtifact(res, root2, requested) {
10998
11081
  }
10999
11082
  function serveUi(res, path) {
11000
11083
  const name = path === "/" ? "index.html" : path.replace(/^\//, "");
11001
- const file = resolve9(UI_DIR, name);
11084
+ const file = resolve10(UI_DIR, name);
11002
11085
  if (!file.startsWith(UI_DIR) || !existsSync10(file) || !statSync2(file).isFile()) {
11003
11086
  return serveUi(res, "/");
11004
11087
  }
@@ -11137,8 +11220,8 @@ function parseAgentFlags(command2, args2) {
11137
11220
  throw new ConfigError(`Unknown agent command ${command2}`);
11138
11221
  for (let index = 0; index < args2.length; index++) {
11139
11222
  const flag2 = args2[index];
11140
- if (command2 === "patrol" && flag2 === "--once") {
11141
- flags.once = true;
11223
+ if (command2 === "patrol" && (flag2 === "--once" || flag2 === "--force")) {
11224
+ flags[flag2.slice(2)] = true;
11142
11225
  continue;
11143
11226
  }
11144
11227
  if (command2 === "publish" && flag2 === "--dry-run") {
@@ -11200,7 +11283,7 @@ async function runAgentCommand(command2, args2, root2, config, log) {
11200
11283
  log(line);
11201
11284
  const workspace = new Workspace(root2);
11202
11285
  if (command2 === "patrol") {
11203
- await runPatrol({ root: root2, config, once: Boolean(flags.once), onLog: log });
11286
+ await runPatrol({ root: root2, config, once: Boolean(flags.once), force: Boolean(flags.force), onLog: log });
11204
11287
  return;
11205
11288
  }
11206
11289
  if (command2 === "publish") {
@@ -11449,7 +11532,7 @@ var [command, ...args] = process.argv.slice(2);
11449
11532
  var root = findProjectRoot(process.cwd());
11450
11533
  function version() {
11451
11534
  if (true)
11452
- return "0.1.0";
11535
+ return "0.1.1";
11453
11536
  try {
11454
11537
  return JSON.parse(readFileSync10(new URL("../package.json", import.meta.url), "utf8")).version;
11455
11538
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bughunters",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "AI agents that explore your app, find bugs, and fix them.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
package/src/ui/app.js CHANGED
@@ -562,7 +562,7 @@ function renderOverview() {
562
562
  data.patrol?.nextAt ? el('p', { class: 'muted',
563
563
  text: `Next patrol at ${new Date(data.patrol.nextAt).toLocaleTimeString([], {
564
564
  hour: '2-digit', minute: '2-digit',
565
- })}`,
565
+ })}${data.patrol.commit ? ` · last tested commit ${data.patrol.commit.slice(0, 7)}` : ''}`,
566
566
  }) : null,
567
567
  ]),
568
568
  ]);