agent-board-runner 0.1.4 → 0.1.6

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 (3) hide show
  1. package/dist/cli.js +333 -81
  2. package/dist/index.js +333 -81
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -55,8 +55,8 @@ function resolveRepoPath(config, relativeRepoPath) {
55
55
 
56
56
  // src/auth.ts
57
57
  var SESSION_FILE = join2(CONFIG_DIR, "session.json");
58
- var DEFAULT_SUPABASE_URL = "https://brpxshvnmxgkbffgdfcc.supabase.co";
59
- var DEFAULT_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJycHhzaHZubXhna2JmZmdkZmNjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODc1OTQwMTEsImV4cCI6MjEwMzE3MDAxMX0.ErsAGZysLFgNvijetR7w1YvIziXC-1D2sx04Hm2UW9c";
58
+ var DEFAULT_SUPABASE_URL = "https://supabase.lealtek.com";
59
+ var DEFAULT_SUPABASE_ANON_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc5MjAwNTE4MCwiZXhwIjo0OTQ3Njc4NzgwLCJyb2xlIjoiYW5vbiJ9.gxNuRa1STVA4lQPLghoGbuqV9_21lVOG1yXSu9WNmJ4";
60
60
  function resolveSupabaseUrl() {
61
61
  return process.env.AGENT_BOARD_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
62
62
  }
@@ -200,8 +200,9 @@ var EVAL_INTERVAL_MS = 2e3;
200
200
  var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
201
201
  var BOX_DRAWING_PATTERN = /[─-╿]/g;
202
202
  var WORKING_PATTERN = /esc to interrupt/i;
203
+ var BACKGROUND_SHELL_PATTERN = /\b[1-9]\d*\s+shells?\b/i;
203
204
  function isAgentHookMoment(value) {
204
- return value === "implementing" || value === "waiting_plan_approval" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
205
+ return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
205
206
  }
206
207
  var USAGE_LIMIT_PATTERN = /(usage limit reached|limit reached[^\n]{0,60}resets|reached your (?:usage|session) limit|out of (?:weekly|session) (?:usage|limit))/i;
207
208
  var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
@@ -225,7 +226,10 @@ function classifyAgentOutput(recentOutput, options) {
225
226
  if (options.planApprovalPending) return "waiting_plan_approval";
226
227
  if (USER_PROMPT_PATTERNS.some((pattern) => pattern.test(recentOutput))) return "waiting_user";
227
228
  const idleThreshold = options.toolCallPending ? IDLE_AFTER_MS_TOOL_PENDING : IDLE_AFTER_MS;
228
- if (options.silentForMs >= idleThreshold) return options.commitDetected ? "done" : "waiting_user";
229
+ if (options.silentForMs >= idleThreshold) {
230
+ if (BACKGROUND_SHELL_PATTERN.test(recentOutput)) return "waiting_background";
231
+ return options.commitDetected ? "done" : "waiting_user";
232
+ }
229
233
  return options.previous;
230
234
  }
231
235
  var AgentActivityWatcher = class {
@@ -286,6 +290,10 @@ var AgentActivityWatcher = class {
286
290
  this.commitDetected = false;
287
291
  this.setState("implementing");
288
292
  break;
293
+ case "tool_active":
294
+ if (this.usageLimited) break;
295
+ this.setState("implementing");
296
+ break;
289
297
  case "waiting_plan_approval":
290
298
  this.planApprovalPending = true;
291
299
  this.setState("waiting_plan_approval");
@@ -295,17 +303,29 @@ var AgentActivityWatcher = class {
295
303
  break;
296
304
  case "turn_stopped":
297
305
  if (this.usageLimited) break;
306
+ if (BACKGROUND_SHELL_PATTERN.test(this.recentOutput())) {
307
+ this.setState("waiting_background");
308
+ break;
309
+ }
298
310
  this.setState(this.commitDetected ? "done" : "waiting_user");
299
311
  break;
300
312
  case "notification":
301
313
  if (this.usageLimited || this.planApprovalPending) break;
302
- if (this.state === "implementing" || this.state === "starting") this.setState("waiting_user");
314
+ if (this.state === "implementing" || this.state === "starting") {
315
+ this.setState(BACKGROUND_SHELL_PATTERN.test(this.recentOutput()) ? "waiting_background" : "waiting_user");
316
+ }
303
317
  break;
304
318
  }
305
319
  }
306
- evaluate(now = Date.now()) {
320
+ /** Text still inside RECENT_WINDOW_MS as of `now`, pruning anything older. Shared by evaluate()
321
+ * (the text/idle-timer path) and reportHookMoment() (the hook path), which both need to know
322
+ * whether the background-shell indicator is currently on screen. */
323
+ recentOutput(now = Date.now()) {
307
324
  while (this.chunks.length > 0 && now - this.chunks[0].at > RECENT_WINDOW_MS) this.chunks.shift();
308
- const next = classifyAgentOutput(this.chunks.map((chunk) => chunk.text).join(""), {
325
+ return this.chunks.map((chunk) => chunk.text).join("");
326
+ }
327
+ evaluate(now = Date.now()) {
328
+ const next = classifyAgentOutput(this.recentOutput(now), {
309
329
  silentForMs: now - this.lastOutputAt,
310
330
  usageLimited: this.usageLimited,
311
331
  planApprovalPending: this.planApprovalPending,
@@ -451,7 +471,7 @@ function stopPtySession(sessionId) {
451
471
  import { randomUUID as randomUUID3 } from "node:crypto";
452
472
  import { spawn } from "node-pty";
453
473
  var SHELL_COMMAND = process.platform === "win32" ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "bash";
454
- function spawnShellPty(repoPath, sessionIdPrefix) {
474
+ function spawnShellPty(repoPath, sessionIdPrefix, initialCommand) {
455
475
  const sessionId = `${sessionIdPrefix}:${randomUUID3()}`;
456
476
  const ticket = randomUUID3();
457
477
  const pty = spawn(SHELL_COMMAND, [], {
@@ -461,14 +481,15 @@ function spawnShellPty(repoPath, sessionIdPrefix) {
461
481
  cwd: repoPath
462
482
  });
463
483
  registerPtySession(sessionId, pty, ticket);
484
+ if (initialCommand) pty.write(`${initialCommand}\r`);
464
485
  pty.onExit(({ exitCode }) => {
465
486
  logger.runner.info("SHELL", `Shell ${sessionId} exited (code ${exitCode}).`);
466
487
  stopPtySession(sessionId);
467
488
  });
468
489
  return { sessionId, ticket };
469
490
  }
470
- function openShellSession(repoPath) {
471
- return spawnShellPty(repoPath, "shell");
491
+ function openShellSession(repoPath, initialCommand) {
492
+ return spawnShellPty(repoPath, "shell", initialCommand);
472
493
  }
473
494
  function closeShellSession(sessionId) {
474
495
  stopPtySession(sessionId);
@@ -477,9 +498,8 @@ var columnShells = /* @__PURE__ */ new Map();
477
498
  function openOrReuseColumnShell(key, repoPath, initialCommand) {
478
499
  const existing = columnShells.get(key);
479
500
  if (existing && getPtySession(existing.sessionId)) return existing;
480
- const shell = spawnShellPty(repoPath, "column-shell");
501
+ const shell = spawnShellPty(repoPath, "column-shell", initialCommand);
481
502
  columnShells.set(key, shell);
482
- if (initialCommand) getPtySession(shell.sessionId)?.pty.write(`${initialCommand}\r`);
483
503
  return shell;
484
504
  }
485
505
  function closeColumnShell(key) {
@@ -747,11 +767,19 @@ async function getRunningSessionForProject(supabase, projectId, ownerUserId) {
747
767
  }
748
768
  async function listQueuedSessionsForRunner(supabase, ownerUserId) {
749
769
  const { data, error } = await supabase.from("sessions").select(
750
- "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
770
+ "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first, prerequisite_card_id)"
751
771
  ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
752
772
  if (error) throw error;
753
773
  return data;
754
774
  }
775
+ async function listPausedSessionsForRunner(supabase, ownerUserId) {
776
+ const { data, error } = await supabase.from("sessions").select("card_id, cards!card_id!inner(project_id, assigned_user_id)").eq("cards.assigned_user_id", ownerUserId).eq("status", "paused");
777
+ if (error) throw error;
778
+ return data.map((row) => ({
779
+ card_id: row.card_id,
780
+ project_id: row.cards.project_id
781
+ }));
782
+ }
755
783
  async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
756
784
  const { data, error } = await supabase.from("sessions").insert({
757
785
  card_id: cardId,
@@ -810,6 +838,11 @@ async function getLastClaudeSessionIdForCard(supabase, cardId) {
810
838
  if (error) throw error;
811
839
  return data?.claude_session_id ?? null;
812
840
  }
841
+ async function getLatestAgentStateForCard(supabase, cardId) {
842
+ const { data, error } = await supabase.from("sessions").select("agent_state").eq("card_id", cardId).order("created_at", { ascending: false }).limit(1).maybeSingle();
843
+ if (error) throw error;
844
+ return data?.agent_state ?? null;
845
+ }
813
846
  async function updateCard(supabase, cardId, changes) {
814
847
  const { error } = await supabase.from("cards").update({
815
848
  ...changes.column !== void 0 ? { column_name: changes.column } : {},
@@ -930,6 +963,9 @@ var AGENT_MOMENT_HOOK_SCRIPT_PATH = join4(tmpdir2(), "agent-board-agent-moment-h
930
963
  var SCRIPT_SOURCE = `#!/usr/bin/env node
931
964
  "use strict";
932
965
  const http = require("node:http");
966
+ const fs = require("node:fs");
967
+ const os = require("node:os");
968
+ const path = require("node:path");
933
969
 
934
970
  function readStdin() {
935
971
  return new Promise((resolve) => {
@@ -948,6 +984,58 @@ function readStdin() {
948
984
  const COMMIT_SUCCESS_PATTERN = /\\[\\S+\\s+[0-9a-f]{7,40}\\]/;
949
985
  const GIT_COMMIT_COMMAND_PATTERN = /\\bgit\\b[^\\n]*\\bcommit\\b/i;
950
986
 
987
+ // Catches the agent writing a tool invocation as plain prose (e.g. "Agent({ subagent_type:
988
+ // ... })") in what was meant to be its final message, instead of actually emitting a
989
+ // structured tool call \u2014 Claude Code then just ends the turn, having "announced" work it
990
+ // never did. Restricted to names that look like a real tool (PascalCase built-ins, or
991
+ // "mcp__server__tool") rather than any "Word({" in the text, so ordinary prose describing
992
+ // hypothetical code doesn't trip it.
993
+ const KNOWN_TOOL_NAMES = new Set([
994
+ "Agent", "AskUserQuestion", "Bash", "PowerShell", "Edit", "Write", "Read", "Glob", "Grep",
995
+ "NotebookEdit", "WebFetch", "WebSearch", "Skill", "ExitPlanMode", "EnterPlanMode",
996
+ "ToolSearch", "ListAgents", "SendMessage", "ScheduleWakeup", "SendFeedback", "ReportFindings",
997
+ "Artifact", "CronCreate", "CronDelete", "CronList", "DesignSync", "EndConversation",
998
+ "EnterWorktree", "ExitWorktree", "LSP", "ListMcpResourcesTool", "Monitor",
999
+ "PushNotification", "ReadMcpResourceDirTool", "ReadMcpResourceTool", "RemoteTrigger",
1000
+ "TaskOutput", "TaskStop", "TodoWrite", "SlashCommand", "Task",
1001
+ ]);
1002
+ const TOOL_CALL_LOOKALIKE_PATTERN = /\\b([A-Z][A-Za-z0-9]*|mcp__[A-Za-z0-9_]+)\\(\\s*\\{/g;
1003
+
1004
+ function findFakeToolCallName(text) {
1005
+ if (!text) return null;
1006
+ TOOL_CALL_LOOKALIKE_PATTERN.lastIndex = 0;
1007
+ let match;
1008
+ while ((match = TOOL_CALL_LOOKALIKE_PATTERN.exec(text))) {
1009
+ const name = match[1];
1010
+ if (name.startsWith("mcp__") || KNOWN_TOOL_NAMES.has(name)) return name;
1011
+ }
1012
+ return null;
1013
+ }
1014
+
1015
+ // Bounds the auto-continue below to at most one nudge per session: if the model fakes a
1016
+ // tool call again right after being told to actually call it, something stranger than this
1017
+ // specific slip is going on, and blocking forever would risk an unbounded loop. One file,
1018
+ // shared by every session on this machine, keyed by AGENT_BOARD_SESSION_ID \u2014 a miscount
1019
+ // under concurrent sessions just means the cap is off by one, never a correctness issue.
1020
+ const FAKE_CALL_STATE_PATH = path.join(os.tmpdir(), "agent-board-fake-tool-call-hook-state.json");
1021
+ const MAX_AUTO_CONTINUES_PER_SESSION = 1;
1022
+
1023
+ function fakeCallAttemptsSoFar(sessionId) {
1024
+ try {
1025
+ const state = JSON.parse(fs.readFileSync(FAKE_CALL_STATE_PATH, "utf8"));
1026
+ return state[sessionId] || 0;
1027
+ } catch {
1028
+ return 0;
1029
+ }
1030
+ }
1031
+
1032
+ function recordFakeCallAttempt(sessionId) {
1033
+ let state = {};
1034
+ try { state = JSON.parse(fs.readFileSync(FAKE_CALL_STATE_PATH, "utf8")); } catch {}
1035
+ state[sessionId] = (state[sessionId] || 0) + 1;
1036
+ try { fs.writeFileSync(FAKE_CALL_STATE_PATH, JSON.stringify(state)); } catch {}
1037
+ }
1038
+
951
1039
  // Which "moment" (if any) this hook invocation reports, from the single JSON payload Claude
952
1040
  // Code gives a hook on stdin. Returning null means "nothing conclusive" \u2014 the hook exits
953
1041
  // quietly and the runner's text-based fallback (agentActivity.ts) stays in charge.
@@ -963,6 +1051,12 @@ function detectMoment(input) {
963
1051
  // "would you like to proceed?", before the CLI even renders the prompt.
964
1052
  if (eventName === "PreToolUse" && input.tool_name === "ExitPlanMode") return "waiting_plan_approval";
965
1053
 
1054
+ // Any other tool about to run is also a precise "the turn is active" signal \u2014 notably the
1055
+ // one that resumes a permission prompt (y/n) or an AskUserQuestion answer: neither of those
1056
+ // is a new UserPromptSubmit, so without this the badge had nothing but the text fallback to
1057
+ // notice the agent is working again once the human replies.
1058
+ if (eventName === "PreToolUse") return "tool_active";
1059
+
966
1060
  // The plan prompt resolving one way or the other: approved, the turn resumes implementing
967
1061
  // right away with no new UserPromptSubmit to catch it; asked to revise, a fresh
968
1062
  // UserPromptSubmit follows almost immediately anyway. Either way "implementing" is correct
@@ -971,14 +1065,22 @@ function detectMoment(input) {
971
1065
 
972
1066
  // A commit that actually succeeded, read off the Bash tool's own structured result instead
973
1067
  // of guessing from scrollback text (exact, and immune to locale/color/format changes to
974
- // git's own banner).
1068
+ // git's own banner). Any other Bash completion still reports "tool_active" below, same as
1069
+ // every other tool.
975
1070
  if (eventName === "PostToolUse" && input.tool_name === "Bash") {
976
1071
  const command = String((input.tool_input && input.tool_input.command) || "");
977
- if (!GIT_COMMIT_COMMAND_PATTERN.test(command)) return null;
978
- const response = JSON.stringify(input.tool_response || "");
979
- return COMMIT_SUCCESS_PATTERN.test(response) ? "commit_detected" : null;
1072
+ if (GIT_COMMIT_COMMAND_PATTERN.test(command)) {
1073
+ const response = JSON.stringify(input.tool_response || "");
1074
+ if (COMMIT_SUCCESS_PATTERN.test(response)) return "commit_detected";
1075
+ }
980
1076
  }
981
1077
 
1078
+ // Any other tool finishing is the same "still active" signal as PreToolUse above \u2014 this is
1079
+ // what actually clears a yellow badge once a permission-gated or AskUserQuestion tool call
1080
+ // resumes and completes (there's no hook fired at the exact instant permission is granted,
1081
+ // only at the tool call's start and end).
1082
+ if (eventName === "PostToolUse") return "tool_active";
1083
+
982
1084
  // The turn actually ending \u2014 Claude Code back at its input box, no idle timer needed.
983
1085
  if (eventName === "Stop") return "turn_stopped";
984
1086
 
@@ -1020,6 +1122,23 @@ function reportToRunner(sessionId, ticket, port, moment) {
1020
1122
  (async () => {
1021
1123
  try {
1022
1124
  const input = JSON.parse(await readStdin());
1125
+ if (input.hook_event_name === "Stop") {
1126
+ const fakeCallName = findFakeToolCallName(input.lastAssistantMessage);
1127
+ const sessionId = process.env.AGENT_BOARD_SESSION_ID || "unknown";
1128
+ if (fakeCallName && fakeCallAttemptsSoFar(sessionId) < MAX_AUTO_CONTINUES_PER_SESSION) {
1129
+ recordFakeCallAttempt(sessionId);
1130
+ process.stdout.write(JSON.stringify({
1131
+ decision: "block",
1132
+ reason:
1133
+ \`You wrote "\${fakeCallName}({...})" as plain text in your final message instead of \` +
1134
+ "actually calling that tool \u2014 no such call happened, so nothing was delegated/executed. " +
1135
+ "If you meant to use it, call it for real now as a structured tool call, don't just " +
1136
+ "describe it. If you already finished without needing it, say so explicitly instead of " +
1137
+ "writing pseudocode that looks like a call.",
1138
+ }));
1139
+ process.exit(0);
1140
+ }
1141
+ }
1023
1142
  const moment = detectMoment(input);
1024
1143
  if (moment) {
1025
1144
  const sessionId = process.env.AGENT_BOARD_SESSION_ID;
@@ -1045,11 +1164,8 @@ function agentMomentHookSettingsFragment() {
1045
1164
  return {
1046
1165
  hooks: {
1047
1166
  UserPromptSubmit: [{ hooks: [hookEntry] }],
1048
- PreToolUse: [{ matcher: "ExitPlanMode", hooks: [hookEntry] }],
1049
- PostToolUse: [
1050
- { matcher: "ExitPlanMode", hooks: [hookEntry] },
1051
- { matcher: "Bash", hooks: [hookEntry] }
1052
- ],
1167
+ PreToolUse: [{ matcher: "*", hooks: [hookEntry] }],
1168
+ PostToolUse: [{ matcher: "*", hooks: [hookEntry] }],
1053
1169
  Stop: [{ hooks: [hookEntry] }],
1054
1170
  Notification: [{ hooks: [hookEntry] }]
1055
1171
  }
@@ -1789,47 +1905,51 @@ async function handleAgentHookReport(req, res) {
1789
1905
  res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
1790
1906
  res.end();
1791
1907
  }
1792
- async function handleBranchRequest(entry, res, relPath) {
1793
- let cwd;
1794
- try {
1795
- cwd = relPath ? resolveWithinRepo(entry.repoRoot, relPath) : entry.repoRoot;
1796
- } catch (err) {
1797
- if (err instanceof HttpError) {
1798
- sendJson(res, err.status, { error: err.message });
1799
- return;
1800
- }
1801
- throw err;
1802
- }
1803
- try {
1804
- const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
1805
- sendJson(res, 200, { branch: stdout.trim() });
1806
- } catch (err) {
1807
- logger.runner.error("FILES", "Unexpected error handling a branch request:", err);
1808
- sendJson(res, 500, { error: "Erro interno do runner." });
1809
- }
1810
- }
1811
1908
  var DIFF_EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
1812
- function parseGitStatusPorcelain(output) {
1909
+ function parseGitDiffNameStatus(output) {
1813
1910
  return output.split("\n").filter((line) => line.length > 0).map((line) => {
1814
- const status = line.slice(0, 2).trim();
1815
- const rest2 = line.slice(3);
1816
- const arrowIndex = rest2.indexOf(" -> ");
1817
- const path = arrowIndex >= 0 ? rest2.slice(arrowIndex + 4) : rest2;
1911
+ const columns = line.split(" ");
1912
+ const status = columns[0].slice(0, 1);
1913
+ const path = columns[columns.length - 1];
1818
1914
  return { path, status };
1819
1915
  });
1820
1916
  }
1821
1917
  async function getRepoDiffForTarget(label, cwd) {
1918
+ let branch;
1919
+ try {
1920
+ const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
1921
+ branch = stdout.trim();
1922
+ } catch (err) {
1923
+ const message = err instanceof Error ? err.message : String(err);
1924
+ return { label, files: [], diff: "", error: message };
1925
+ }
1926
+ if (!branch) {
1927
+ return { label, files: [], diff: "", error: "HEAD destacado: sem branch local para comparar com a origin." };
1928
+ }
1929
+ try {
1930
+ await execFileAsync2("git", ["fetch", "origin", branch], { cwd });
1931
+ } catch (err) {
1932
+ const message = err instanceof Error ? err.message : String(err);
1933
+ const notOnRemote = /couldn't find remote ref/i.test(message);
1934
+ return {
1935
+ label,
1936
+ files: [],
1937
+ diff: "",
1938
+ error: notOnRemote ? `A branch "${branch}" ainda n\xE3o existe na origin.` : message
1939
+ };
1940
+ }
1941
+ const remoteRef = `origin/${branch}`;
1822
1942
  let files;
1823
1943
  try {
1824
- const { stdout } = await execFileAsync2("git", ["status", "--porcelain=v1"], { cwd });
1825
- files = parseGitStatusPorcelain(stdout);
1944
+ const { stdout } = await execFileAsync2("git", ["diff", "--name-status", remoteRef, "HEAD"], { cwd });
1945
+ files = parseGitDiffNameStatus(stdout);
1826
1946
  } catch (err) {
1827
1947
  const message = err instanceof Error ? err.message : String(err);
1828
1948
  return { label, files: [], diff: "", error: message };
1829
1949
  }
1830
1950
  if (files.length === 0) return { label, files, diff: "" };
1831
1951
  try {
1832
- const { stdout } = await execFileAsync2("git", ["diff", "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1952
+ const { stdout } = await execFileAsync2("git", ["diff", remoteRef, "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1833
1953
  return { label, files, diff: stdout };
1834
1954
  } catch {
1835
1955
  return { label, files, diff: "" };
@@ -2012,10 +2132,6 @@ function handleRequest(req, res) {
2012
2132
  sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou projeto desconhecido." });
2013
2133
  return;
2014
2134
  }
2015
- if (pathname === "/branch") {
2016
- void handleBranchRequest(entry, res, relPath);
2017
- return;
2018
- }
2019
2135
  if (pathname === "/diff") {
2020
2136
  const projectId = typeof query.projectId === "string" ? query.projectId : "";
2021
2137
  void handleDiffRequest(entry, res, projectId);
@@ -2052,7 +2168,9 @@ function handleRequest(req, res) {
2052
2168
  return;
2053
2169
  }
2054
2170
  if (pathname === "/shell/open") {
2055
- sendJson(res, 200, openShellSession(entry.repoRoot));
2171
+ const kind = typeof query.kind === "string" ? query.kind : "shell";
2172
+ if (!COLUMN_SHELL_KINDS.includes(kind)) throw new HttpError(400, "Par\xE2metro kind inv\xE1lido.");
2173
+ sendJson(res, 200, openShellSession(entry.repoRoot, kind === "claude" ? CLAUDE_BYPASS_COMMAND : void 0));
2056
2174
  return;
2057
2175
  }
2058
2176
  if (pathname === "/shell/close") {
@@ -2098,11 +2216,18 @@ function attachFilesServer(httpServer, supabase, config) {
2098
2216
  }
2099
2217
 
2100
2218
  // src/agentEventsServer.ts
2219
+ var EMPTY_QUEUE = { queuedCardIds: [], pausedCardIds: [] };
2101
2220
  var stateByProject = /* @__PURE__ */ new Map();
2221
+ var queueByProject = /* @__PURE__ */ new Map();
2222
+ var branchesByProject = /* @__PURE__ */ new Map();
2102
2223
  var socketsByProject = /* @__PURE__ */ new Map();
2103
2224
  function sendSnapshot(socket, projectId) {
2104
2225
  const states = Array.from(stateByProject.get(projectId)?.values() ?? []);
2105
- socket.send(JSON.stringify({ type: "snapshot", states }));
2226
+ const queue = queueByProject.get(projectId) ?? EMPTY_QUEUE;
2227
+ const branches = Array.from(branchesByProject.get(projectId)?.entries() ?? []).map(
2228
+ ([label, branch]) => ({ label: label || null, branch })
2229
+ );
2230
+ socket.send(JSON.stringify({ type: "snapshot", states, queue, branches }));
2106
2231
  }
2107
2232
  function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
2108
2233
  let projectState = stateByProject.get(projectId);
@@ -2118,6 +2243,29 @@ function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
2118
2243
  if (socket.readyState === socket.OPEN) socket.send(payload);
2119
2244
  }
2120
2245
  }
2246
+ function publishQueueEvent(projectId, queue) {
2247
+ queueByProject.set(projectId, queue);
2248
+ const sockets = socketsByProject.get(projectId);
2249
+ if (!sockets || sockets.size === 0) return;
2250
+ const payload = JSON.stringify({ type: "queue", ...queue });
2251
+ for (const socket of sockets) {
2252
+ if (socket.readyState === socket.OPEN) socket.send(payload);
2253
+ }
2254
+ }
2255
+ function publishBranchEvent(projectId, label, branch) {
2256
+ let projectBranches = branchesByProject.get(projectId);
2257
+ if (!projectBranches) {
2258
+ projectBranches = /* @__PURE__ */ new Map();
2259
+ branchesByProject.set(projectId, projectBranches);
2260
+ }
2261
+ projectBranches.set(label ?? "", branch);
2262
+ const sockets = socketsByProject.get(projectId);
2263
+ if (!sockets || sockets.size === 0) return;
2264
+ const payload = JSON.stringify({ type: "branch", label, branch });
2265
+ for (const socket of sockets) {
2266
+ if (socket.readyState === socket.OPEN) socket.send(payload);
2267
+ }
2268
+ }
2121
2269
  function attachAgentEventsServer() {
2122
2270
  const wss = new WebSocketServer({ noServer: true });
2123
2271
  wss.on("connection", (socket, request) => {
@@ -2144,11 +2292,77 @@ function attachAgentEventsServer() {
2144
2292
  return wss;
2145
2293
  }
2146
2294
 
2147
- // src/columnActions.ts
2148
- import { exec } from "node:child_process";
2295
+ // src/branchWatcher.ts
2296
+ import { execFile as execFile3 } from "node:child_process";
2297
+ import { watch } from "node:fs";
2149
2298
  import { join as join10 } from "node:path";
2150
2299
  import { promisify as promisify3 } from "node:util";
2151
- var execAsync = promisify3(exec);
2300
+ var execFileAsync3 = promisify3(execFile3);
2301
+ var DEBOUNCE_MS = 300;
2302
+ var watchedProjects = /* @__PURE__ */ new Map();
2303
+ async function readCurrentBranch(cwd) {
2304
+ try {
2305
+ const { stdout } = await execFileAsync3("git", ["branch", "--show-current"], { cwd });
2306
+ return stdout.trim();
2307
+ } catch {
2308
+ return "";
2309
+ }
2310
+ }
2311
+ function watchTarget(projectId, label, cwd, state) {
2312
+ let debounceTimer = null;
2313
+ function check() {
2314
+ void readCurrentBranch(cwd).then((branch) => {
2315
+ if (!branch || state.lastBranch.get(label) === branch) return;
2316
+ state.lastBranch.set(label, branch);
2317
+ publishBranchEvent(projectId, label || null, branch);
2318
+ });
2319
+ }
2320
+ let watcher;
2321
+ try {
2322
+ watcher = watch(join10(cwd, ".git", "HEAD"), () => {
2323
+ if (debounceTimer !== null) clearTimeout(debounceTimer);
2324
+ debounceTimer = setTimeout(check, DEBOUNCE_MS);
2325
+ });
2326
+ } catch (err) {
2327
+ logger.runner.warn("BRANCH_WATCH", `Could not watch .git/HEAD under ${cwd}:`, err instanceof Error ? err.message : err);
2328
+ return;
2329
+ }
2330
+ state.watchers.push(watcher);
2331
+ check();
2332
+ }
2333
+ async function ensureBranchWatchers(supabase, config) {
2334
+ for (const project of config.registeredProjects) {
2335
+ if (watchedProjects.has(project.id)) continue;
2336
+ const state = { watchers: [], lastBranch: /* @__PURE__ */ new Map() };
2337
+ watchedProjects.set(project.id, state);
2338
+ let children;
2339
+ try {
2340
+ children = (await getProjectRepos(supabase, project.id)).map((repo) => repo.subpath);
2341
+ } catch (err) {
2342
+ logger.runner.error("BRANCH_WATCH", `Failed to list child repos for project ${project.id}:`, err);
2343
+ children = [];
2344
+ }
2345
+ if (children.length === 0) {
2346
+ watchTarget(project.id, "", project.repoPath, state);
2347
+ } else {
2348
+ for (const subpath of children) {
2349
+ watchTarget(project.id, subpath, join10(project.repoPath, subpath), state);
2350
+ }
2351
+ }
2352
+ }
2353
+ const stillRegistered = new Set(config.registeredProjects.map((project) => project.id));
2354
+ for (const [projectId, state] of watchedProjects) {
2355
+ if (stillRegistered.has(projectId)) continue;
2356
+ for (const watcher of state.watchers) watcher.close();
2357
+ watchedProjects.delete(projectId);
2358
+ }
2359
+ }
2360
+
2361
+ // src/columnActions.ts
2362
+ import { exec } from "node:child_process";
2363
+ import { join as join11 } from "node:path";
2364
+ import { promisify as promisify4 } from "node:util";
2365
+ var execAsync = promisify4(exec);
2152
2366
  var OUTPUT_TAIL_CHARS = 4e3;
2153
2367
  function tail(text) {
2154
2368
  return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
@@ -2156,7 +2370,7 @@ function tail(text) {
2156
2370
  function resolveTargets(project, children, repoRoot, column, timing) {
2157
2371
  if (children.length > 0) {
2158
2372
  return children.flatMap(
2159
- (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join10(repoRoot, child2.subpath), command: action.command }))
2373
+ (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join11(repoRoot, child2.subpath), command: action.command }))
2160
2374
  );
2161
2375
  }
2162
2376
  return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command }));
@@ -2262,7 +2476,7 @@ async function checkPendingDeployActionReruns(supabase, config) {
2262
2476
  }
2263
2477
 
2264
2478
  // src/deploy.ts
2265
- import { join as join13 } from "node:path";
2479
+ import { join as join14 } from "node:path";
2266
2480
 
2267
2481
  // src/devServer.ts
2268
2482
  import { spawn as spawn4 } from "node:child_process";
@@ -2271,7 +2485,7 @@ import { spawn as spawn4 } from "node:child_process";
2271
2485
  import { spawn as spawn3 } from "node:child_process";
2272
2486
  import { accessSync, constants } from "node:fs";
2273
2487
  import { homedir as homedir5 } from "node:os";
2274
- import { delimiter, join as join11 } from "node:path";
2488
+ import { delimiter, join as join12 } from "node:path";
2275
2489
  import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
2276
2490
  var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2277
2491
  var STARTUP_TIMEOUT_MS = 2e4;
@@ -2282,15 +2496,15 @@ function wellKnownDirectories() {
2282
2496
  const home = homedir5();
2283
2497
  if (IS_WINDOWS2) {
2284
2498
  const candidates = [
2285
- process.env.ProgramFiles && join11(process.env.ProgramFiles, "cloudflared"),
2286
- process.env["ProgramFiles(x86)"] && join11(process.env["ProgramFiles(x86)"], "cloudflared"),
2287
- process.env.LOCALAPPDATA && join11(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2288
- join11(home, "scoop", "shims"),
2289
- process.env.ChocolateyInstall && join11(process.env.ChocolateyInstall, "bin")
2499
+ process.env.ProgramFiles && join12(process.env.ProgramFiles, "cloudflared"),
2500
+ process.env["ProgramFiles(x86)"] && join12(process.env["ProgramFiles(x86)"], "cloudflared"),
2501
+ process.env.LOCALAPPDATA && join12(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2502
+ join12(home, "scoop", "shims"),
2503
+ process.env.ChocolateyInstall && join12(process.env.ChocolateyInstall, "bin")
2290
2504
  ];
2291
2505
  return candidates.filter((dir) => Boolean(dir));
2292
2506
  }
2293
- return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join11(home, ".local", "bin")];
2507
+ return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join12(home, ".local", "bin")];
2294
2508
  }
2295
2509
  function pathDirectories() {
2296
2510
  return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
@@ -2306,7 +2520,7 @@ function isExecutableFile(path) {
2306
2520
  function findInstalledBinary() {
2307
2521
  const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
2308
2522
  const directories = [...pathDirectories(), ...wellKnownDirectories()];
2309
- const candidates = [...override ? [override] : [], ...directories.map((dir) => join11(dir, BINARY_NAME))];
2523
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join12(dir, BINARY_NAME))];
2310
2524
  return candidates.find(isExecutableFile) ?? null;
2311
2525
  }
2312
2526
  async function locateCloudflared() {
@@ -2493,12 +2707,12 @@ function stopAll() {
2493
2707
  }
2494
2708
 
2495
2709
  // src/git.ts
2496
- import { execFile as execFile3 } from "node:child_process";
2497
- import { promisify as promisify4 } from "node:util";
2498
- var execFileAsync3 = promisify4(execFile3);
2710
+ import { execFile as execFile4 } from "node:child_process";
2711
+ import { promisify as promisify5 } from "node:util";
2712
+ var execFileAsync4 = promisify5(execFile4);
2499
2713
  async function pushBranchToMain(repoPath) {
2500
2714
  try {
2501
- await execFileAsync3("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2715
+ await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2502
2716
  return { ok: true };
2503
2717
  } catch (err) {
2504
2718
  const stderr = err.stderr;
@@ -2660,8 +2874,8 @@ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2660
2874
  // src/cardAttachments.ts
2661
2875
  import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2662
2876
  import { tmpdir as tmpdir6 } from "node:os";
2663
- import { extname, join as join12 } from "node:path";
2664
- var ATTACHMENTS_ROOT = join12(tmpdir6(), "agent-board", "card-attachments");
2877
+ import { extname, join as join13 } from "node:path";
2878
+ var ATTACHMENTS_ROOT = join13(tmpdir6(), "agent-board", "card-attachments");
2665
2879
  function sanitizeFileName(fileName) {
2666
2880
  return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2667
2881
  }
@@ -2673,13 +2887,13 @@ async function downloadCardAttachments(supabase, cardId) {
2673
2887
  }
2674
2888
  const rows = data ?? [];
2675
2889
  if (rows.length === 0) return [];
2676
- const targetDir = join12(ATTACHMENTS_ROOT, cardId);
2890
+ const targetDir = join13(ATTACHMENTS_ROOT, cardId);
2677
2891
  await mkdir2(targetDir, { recursive: true });
2678
2892
  const paths = [];
2679
2893
  for (const row of rows) {
2680
2894
  const baseName = sanitizeFileName(row.file_name);
2681
2895
  const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2682
- const localPath = join12(targetDir, `${row.id}-${fileName}`);
2896
+ const localPath = join13(targetDir, `${row.id}-${fileName}`);
2683
2897
  try {
2684
2898
  const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2685
2899
  if (downloadError) throw downloadError;
@@ -2714,7 +2928,7 @@ ${list}`;
2714
2928
  function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2715
2929
  return `${instructions}
2716
2930
 
2717
- Agent Board MCP: this card's id is \`${cardId}\` and its project id is \`${projectId}\` \u2014 pass these directly to board tools (\`update_card\`, \`create_card\`, \`add_card_comment\`, etc.), no lookup needed. If this task turns out too large for one session, use \`create_card\` to split it into subtask cards (chain them with \`prerequisiteCardId\` when one depends on another) instead of trying to fit everything here. Before finishing, post a short summary of what you did as a comment on this card via \`add_card_comment\` so the project's humans have visibility without opening this terminal.`;
2931
+ Agent Board MCP: this card's id is \`${cardId}\` and its project id is \`${projectId}\` \u2014 pass these directly to board tools (\`update_card\`, \`create_card\`, \`add_card_comment\`, etc.), no lookup needed. If this task turns out too large for one session, use \`create_card\` to split it into subtask cards (chain them with \`prerequisiteCardId\` when one depends on another) instead of trying to fit everything here. If part of the delivery needs a human to do something you can't do yourself (rotate a secret, approve something in a third-party dashboard, delete a resource manually, etc.), register each step with \`add_manual_actions\` instead of just mentioning it in your final message \u2014 that's the only place humans will reliably see it. Before finishing, post a short summary of what you did as a comment on this card via \`add_card_comment\` so the project's humans have visibility without opening this terminal.`;
2718
2932
  }
2719
2933
 
2720
2934
  // src/usageResume.ts
@@ -2824,8 +3038,9 @@ async function reconcileStaleSessions(supabase, config, projectId) {
2824
3038
  async function dispatchRunnerQueue(supabase, config) {
2825
3039
  if (dispatchingRunner) return;
2826
3040
  dispatchingRunner = true;
3041
+ let queued = [];
2827
3042
  try {
2828
- const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
3043
+ queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
2829
3044
  if (queued.length === 0) return;
2830
3045
  const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
2831
3046
  const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
@@ -2846,6 +3061,17 @@ async function dispatchRunnerQueue(supabase, config) {
2846
3061
  const projectId = entry.cards.project_id;
2847
3062
  if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
2848
3063
  if (freeProjectSlots <= 0) break;
3064
+ const prerequisiteCardId = entry.cards.prerequisite_card_id;
3065
+ if (prerequisiteCardId) {
3066
+ const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
3067
+ if (prerequisiteAgentState !== "done") {
3068
+ logger.card.info(
3069
+ entry.card_id,
3070
+ `Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
3071
+ );
3072
+ continue;
3073
+ }
3074
+ }
2849
3075
  try {
2850
3076
  await promoteQueuedSession(supabase, config, entry);
2851
3077
  promotedThisPass.add(projectId);
@@ -2855,9 +3081,33 @@ async function dispatchRunnerQueue(supabase, config) {
2855
3081
  }
2856
3082
  }
2857
3083
  } finally {
3084
+ await publishQueueSnapshots(supabase, config, queued).catch(
3085
+ (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3086
+ );
2858
3087
  dispatchingRunner = false;
2859
3088
  }
2860
3089
  }
3090
+ async function publishQueueSnapshots(supabase, config, queued) {
3091
+ const paused = await listPausedSessionsForRunner(supabase, config.ownerUserId);
3092
+ const queuedByProject = /* @__PURE__ */ new Map();
3093
+ for (const entry of queued) {
3094
+ const list = queuedByProject.get(entry.cards.project_id) ?? [];
3095
+ list.push(entry.card_id);
3096
+ queuedByProject.set(entry.cards.project_id, list);
3097
+ }
3098
+ const pausedByProject = /* @__PURE__ */ new Map();
3099
+ for (const { card_id: cardId, project_id: projectId } of paused) {
3100
+ const list = pausedByProject.get(projectId) ?? [];
3101
+ list.push(cardId);
3102
+ pausedByProject.set(projectId, list);
3103
+ }
3104
+ for (const project of config.registeredProjects) {
3105
+ publishQueueEvent(project.id, {
3106
+ queuedCardIds: queuedByProject.get(project.id) ?? [],
3107
+ pausedCardIds: pausedByProject.get(project.id) ?? []
3108
+ });
3109
+ }
3110
+ }
2861
3111
  async function promoteQueuedSession(supabase, config, entry) {
2862
3112
  const existingHandle = getPtySession(entry.id);
2863
3113
  if (existingHandle) {
@@ -3106,7 +3356,7 @@ async function handleDeployRequested(supabase, config, project) {
3106
3356
  }
3107
3357
  const children = await getProjectRepos(supabase, project.id);
3108
3358
  const repoRoot = resolveRepoPath(config, project.repo_path);
3109
- const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join13(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3359
+ const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join14(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3110
3360
  const failures = [];
3111
3361
  for (const target of targets) {
3112
3362
  const result = await pushBranchToMain(target.path);
@@ -3240,7 +3490,7 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
3240
3490
  }
3241
3491
 
3242
3492
  // src/review.ts
3243
- import { join as join14 } from "node:path";
3493
+ import { join as join15 } from "node:path";
3244
3494
  var inFlightCardIds2 = /* @__PURE__ */ new Set();
3245
3495
  async function handleCardEnteredRevisao(supabase, config, card) {
3246
3496
  if (inFlightCardIds2.has(card.id)) return;
@@ -3249,7 +3499,7 @@ async function handleCardEnteredRevisao(supabase, config, card) {
3249
3499
  const project = await getProject(supabase, card.project_id);
3250
3500
  const children = await getProjectRepos(supabase, project.id);
3251
3501
  const repoRoot = resolveRepoPath(config, project.repo_path);
3252
- const targets = children.length > 0 ? children.filter((child2) => child2.dev_command).map((child2) => ({ label: child2.subpath, cwd: join14(repoRoot, child2.subpath), command: child2.dev_command })) : project.dev_command ? [{ label: "", cwd: repoRoot, command: project.dev_command }] : [];
3502
+ const targets = children.length > 0 ? children.filter((child2) => child2.dev_command).map((child2) => ({ label: child2.subpath, cwd: join15(repoRoot, child2.subpath), command: child2.dev_command })) : project.dev_command ? [{ label: "", cwd: repoRoot, command: project.dev_command }] : [];
3253
3503
  if (targets.length === 0) {
3254
3504
  logger.card.info(card.id, `No dev_command configured for project ${project.id}, skipping automatic preview.`);
3255
3505
  return;
@@ -3518,6 +3768,7 @@ async function startRunner() {
3518
3768
  void ensureFilesTickets(supabase, config).catch((err) => logger.runner.error("FILES", "ensureFilesTickets failed:", err));
3519
3769
  void ensureSetupTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureSetupTicket failed:", err));
3520
3770
  void ensureAgentTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureAgentTicket failed:", err));
3771
+ void ensureBranchWatchers(supabase, config).catch((err) => logger.runner.error("BRANCH_WATCH", "ensureBranchWatchers failed:", err));
3521
3772
  const presenceChannel = supabase.channel(`runner-presence:${config.runnerId}`);
3522
3773
  presenceChannel.subscribe((status) => {
3523
3774
  if (status === "SUBSCRIBED") {
@@ -3614,6 +3865,7 @@ async function startRunner() {
3614
3865
  await ensureFilesTickets(supabase, config);
3615
3866
  await ensureSetupTicket(supabase, config);
3616
3867
  await ensureAgentTicket(supabase, config);
3868
+ await ensureBranchWatchers(supabase, config).catch((err) => logger.runner.error("BRANCH_WATCH", "ensureBranchWatchers failed:", err));
3617
3869
  await scanForWork(supabase, config);
3618
3870
  await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
3619
3871
  await checkPendingDeploys(supabase, config).catch((err) => logger.runner.error("DEPLOY", "checkPendingDeploys failed:", err));