agent-board-runner 0.1.3 → 0.1.5

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 +334 -79
  2. package/dist/index.js +334 -79
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -38,8 +38,9 @@ var EVAL_INTERVAL_MS = 2e3;
38
38
  var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
39
39
  var BOX_DRAWING_PATTERN = /[─-╿]/g;
40
40
  var WORKING_PATTERN = /esc to interrupt/i;
41
+ var BACKGROUND_SHELL_PATTERN = /\b[1-9]\d*\s+shells?\b/i;
41
42
  function isAgentHookMoment(value) {
42
- return value === "implementing" || value === "waiting_plan_approval" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
43
+ return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
43
44
  }
44
45
  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;
45
46
  var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
@@ -63,7 +64,10 @@ function classifyAgentOutput(recentOutput, options) {
63
64
  if (options.planApprovalPending) return "waiting_plan_approval";
64
65
  if (USER_PROMPT_PATTERNS.some((pattern) => pattern.test(recentOutput))) return "waiting_user";
65
66
  const idleThreshold = options.toolCallPending ? IDLE_AFTER_MS_TOOL_PENDING : IDLE_AFTER_MS;
66
- if (options.silentForMs >= idleThreshold) return options.commitDetected ? "done" : "waiting_user";
67
+ if (options.silentForMs >= idleThreshold) {
68
+ if (BACKGROUND_SHELL_PATTERN.test(recentOutput)) return "waiting_background";
69
+ return options.commitDetected ? "done" : "waiting_user";
70
+ }
67
71
  return options.previous;
68
72
  }
69
73
  var AgentActivityWatcher = class {
@@ -124,6 +128,10 @@ var AgentActivityWatcher = class {
124
128
  this.commitDetected = false;
125
129
  this.setState("implementing");
126
130
  break;
131
+ case "tool_active":
132
+ if (this.usageLimited) break;
133
+ this.setState("implementing");
134
+ break;
127
135
  case "waiting_plan_approval":
128
136
  this.planApprovalPending = true;
129
137
  this.setState("waiting_plan_approval");
@@ -133,17 +141,29 @@ var AgentActivityWatcher = class {
133
141
  break;
134
142
  case "turn_stopped":
135
143
  if (this.usageLimited) break;
144
+ if (BACKGROUND_SHELL_PATTERN.test(this.recentOutput())) {
145
+ this.setState("waiting_background");
146
+ break;
147
+ }
136
148
  this.setState(this.commitDetected ? "done" : "waiting_user");
137
149
  break;
138
150
  case "notification":
139
151
  if (this.usageLimited || this.planApprovalPending) break;
140
- if (this.state === "implementing" || this.state === "starting") this.setState("waiting_user");
152
+ if (this.state === "implementing" || this.state === "starting") {
153
+ this.setState(BACKGROUND_SHELL_PATTERN.test(this.recentOutput()) ? "waiting_background" : "waiting_user");
154
+ }
141
155
  break;
142
156
  }
143
157
  }
144
- evaluate(now = Date.now()) {
158
+ /** Text still inside RECENT_WINDOW_MS as of `now`, pruning anything older. Shared by evaluate()
159
+ * (the text/idle-timer path) and reportHookMoment() (the hook path), which both need to know
160
+ * whether the background-shell indicator is currently on screen. */
161
+ recentOutput(now = Date.now()) {
145
162
  while (this.chunks.length > 0 && now - this.chunks[0].at > RECENT_WINDOW_MS) this.chunks.shift();
146
- const next = classifyAgentOutput(this.chunks.map((chunk) => chunk.text).join(""), {
163
+ return this.chunks.map((chunk) => chunk.text).join("");
164
+ }
165
+ evaluate(now = Date.now()) {
166
+ const next = classifyAgentOutput(this.recentOutput(now), {
147
167
  silentForMs: now - this.lastOutputAt,
148
168
  usageLimited: this.usageLimited,
149
169
  planApprovalPending: this.planApprovalPending,
@@ -585,11 +605,19 @@ async function getRunningSessionForProject(supabase, projectId, ownerUserId) {
585
605
  }
586
606
  async function listQueuedSessionsForRunner(supabase, ownerUserId) {
587
607
  const { data, error } = await supabase.from("sessions").select(
588
- "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)"
608
+ "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)"
589
609
  ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
590
610
  if (error) throw error;
591
611
  return data;
592
612
  }
613
+ async function listPausedSessionsForRunner(supabase, ownerUserId) {
614
+ 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");
615
+ if (error) throw error;
616
+ return data.map((row) => ({
617
+ card_id: row.card_id,
618
+ project_id: row.cards.project_id
619
+ }));
620
+ }
593
621
  async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
594
622
  const { data, error } = await supabase.from("sessions").insert({
595
623
  card_id: cardId,
@@ -648,6 +676,11 @@ async function getLastClaudeSessionIdForCard(supabase, cardId) {
648
676
  if (error) throw error;
649
677
  return data?.claude_session_id ?? null;
650
678
  }
679
+ async function getLatestAgentStateForCard(supabase, cardId) {
680
+ const { data, error } = await supabase.from("sessions").select("agent_state").eq("card_id", cardId).order("created_at", { ascending: false }).limit(1).maybeSingle();
681
+ if (error) throw error;
682
+ return data?.agent_state ?? null;
683
+ }
651
684
  async function updateCard(supabase, cardId, changes) {
652
685
  const { error } = await supabase.from("cards").update({
653
686
  ...changes.column !== void 0 ? { column_name: changes.column } : {},
@@ -768,6 +801,9 @@ var AGENT_MOMENT_HOOK_SCRIPT_PATH = join2(tmpdir2(), "agent-board-agent-moment-h
768
801
  var SCRIPT_SOURCE = `#!/usr/bin/env node
769
802
  "use strict";
770
803
  const http = require("node:http");
804
+ const fs = require("node:fs");
805
+ const os = require("node:os");
806
+ const path = require("node:path");
771
807
 
772
808
  function readStdin() {
773
809
  return new Promise((resolve) => {
@@ -786,6 +822,58 @@ function readStdin() {
786
822
  const COMMIT_SUCCESS_PATTERN = /\\[\\S+\\s+[0-9a-f]{7,40}\\]/;
787
823
  const GIT_COMMIT_COMMAND_PATTERN = /\\bgit\\b[^\\n]*\\bcommit\\b/i;
788
824
 
825
+ // Catches the agent writing a tool invocation as plain prose (e.g. "Agent({ subagent_type:
826
+ // ... })") in what was meant to be its final message, instead of actually emitting a
827
+ // structured tool call \u2014 Claude Code then just ends the turn, having "announced" work it
828
+ // never did. Restricted to names that look like a real tool (PascalCase built-ins, or
829
+ // "mcp__server__tool") rather than any "Word({" in the text, so ordinary prose describing
830
+ // hypothetical code doesn't trip it.
831
+ const KNOWN_TOOL_NAMES = new Set([
832
+ "Agent", "AskUserQuestion", "Bash", "PowerShell", "Edit", "Write", "Read", "Glob", "Grep",
833
+ "NotebookEdit", "WebFetch", "WebSearch", "Skill", "ExitPlanMode", "EnterPlanMode",
834
+ "ToolSearch", "ListAgents", "SendMessage", "ScheduleWakeup", "SendFeedback", "ReportFindings",
835
+ "Artifact", "CronCreate", "CronDelete", "CronList", "DesignSync", "EndConversation",
836
+ "EnterWorktree", "ExitWorktree", "LSP", "ListMcpResourcesTool", "Monitor",
837
+ "PushNotification", "ReadMcpResourceDirTool", "ReadMcpResourceTool", "RemoteTrigger",
838
+ "TaskOutput", "TaskStop", "TodoWrite", "SlashCommand", "Task",
839
+ ]);
840
+ const TOOL_CALL_LOOKALIKE_PATTERN = /\\b([A-Z][A-Za-z0-9]*|mcp__[A-Za-z0-9_]+)\\(\\s*\\{/g;
841
+
842
+ function findFakeToolCallName(text) {
843
+ if (!text) return null;
844
+ TOOL_CALL_LOOKALIKE_PATTERN.lastIndex = 0;
845
+ let match;
846
+ while ((match = TOOL_CALL_LOOKALIKE_PATTERN.exec(text))) {
847
+ const name = match[1];
848
+ if (name.startsWith("mcp__") || KNOWN_TOOL_NAMES.has(name)) return name;
849
+ }
850
+ return null;
851
+ }
852
+
853
+ // Bounds the auto-continue below to at most one nudge per session: if the model fakes a
854
+ // tool call again right after being told to actually call it, something stranger than this
855
+ // specific slip is going on, and blocking forever would risk an unbounded loop. One file,
856
+ // shared by every session on this machine, keyed by AGENT_BOARD_SESSION_ID \u2014 a miscount
857
+ // under concurrent sessions just means the cap is off by one, never a correctness issue.
858
+ const FAKE_CALL_STATE_PATH = path.join(os.tmpdir(), "agent-board-fake-tool-call-hook-state.json");
859
+ const MAX_AUTO_CONTINUES_PER_SESSION = 1;
860
+
861
+ function fakeCallAttemptsSoFar(sessionId) {
862
+ try {
863
+ const state = JSON.parse(fs.readFileSync(FAKE_CALL_STATE_PATH, "utf8"));
864
+ return state[sessionId] || 0;
865
+ } catch {
866
+ return 0;
867
+ }
868
+ }
869
+
870
+ function recordFakeCallAttempt(sessionId) {
871
+ let state = {};
872
+ try { state = JSON.parse(fs.readFileSync(FAKE_CALL_STATE_PATH, "utf8")); } catch {}
873
+ state[sessionId] = (state[sessionId] || 0) + 1;
874
+ try { fs.writeFileSync(FAKE_CALL_STATE_PATH, JSON.stringify(state)); } catch {}
875
+ }
876
+
789
877
  // Which "moment" (if any) this hook invocation reports, from the single JSON payload Claude
790
878
  // Code gives a hook on stdin. Returning null means "nothing conclusive" \u2014 the hook exits
791
879
  // quietly and the runner's text-based fallback (agentActivity.ts) stays in charge.
@@ -801,6 +889,12 @@ function detectMoment(input) {
801
889
  // "would you like to proceed?", before the CLI even renders the prompt.
802
890
  if (eventName === "PreToolUse" && input.tool_name === "ExitPlanMode") return "waiting_plan_approval";
803
891
 
892
+ // Any other tool about to run is also a precise "the turn is active" signal \u2014 notably the
893
+ // one that resumes a permission prompt (y/n) or an AskUserQuestion answer: neither of those
894
+ // is a new UserPromptSubmit, so without this the badge had nothing but the text fallback to
895
+ // notice the agent is working again once the human replies.
896
+ if (eventName === "PreToolUse") return "tool_active";
897
+
804
898
  // The plan prompt resolving one way or the other: approved, the turn resumes implementing
805
899
  // right away with no new UserPromptSubmit to catch it; asked to revise, a fresh
806
900
  // UserPromptSubmit follows almost immediately anyway. Either way "implementing" is correct
@@ -809,14 +903,22 @@ function detectMoment(input) {
809
903
 
810
904
  // A commit that actually succeeded, read off the Bash tool's own structured result instead
811
905
  // of guessing from scrollback text (exact, and immune to locale/color/format changes to
812
- // git's own banner).
906
+ // git's own banner). Any other Bash completion still reports "tool_active" below, same as
907
+ // every other tool.
813
908
  if (eventName === "PostToolUse" && input.tool_name === "Bash") {
814
909
  const command = String((input.tool_input && input.tool_input.command) || "");
815
- if (!GIT_COMMIT_COMMAND_PATTERN.test(command)) return null;
816
- const response = JSON.stringify(input.tool_response || "");
817
- return COMMIT_SUCCESS_PATTERN.test(response) ? "commit_detected" : null;
910
+ if (GIT_COMMIT_COMMAND_PATTERN.test(command)) {
911
+ const response = JSON.stringify(input.tool_response || "");
912
+ if (COMMIT_SUCCESS_PATTERN.test(response)) return "commit_detected";
913
+ }
818
914
  }
819
915
 
916
+ // Any other tool finishing is the same "still active" signal as PreToolUse above \u2014 this is
917
+ // what actually clears a yellow badge once a permission-gated or AskUserQuestion tool call
918
+ // resumes and completes (there's no hook fired at the exact instant permission is granted,
919
+ // only at the tool call's start and end).
920
+ if (eventName === "PostToolUse") return "tool_active";
921
+
820
922
  // The turn actually ending \u2014 Claude Code back at its input box, no idle timer needed.
821
923
  if (eventName === "Stop") return "turn_stopped";
822
924
 
@@ -858,6 +960,23 @@ function reportToRunner(sessionId, ticket, port, moment) {
858
960
  (async () => {
859
961
  try {
860
962
  const input = JSON.parse(await readStdin());
963
+ if (input.hook_event_name === "Stop") {
964
+ const fakeCallName = findFakeToolCallName(input.lastAssistantMessage);
965
+ const sessionId = process.env.AGENT_BOARD_SESSION_ID || "unknown";
966
+ if (fakeCallName && fakeCallAttemptsSoFar(sessionId) < MAX_AUTO_CONTINUES_PER_SESSION) {
967
+ recordFakeCallAttempt(sessionId);
968
+ process.stdout.write(JSON.stringify({
969
+ decision: "block",
970
+ reason:
971
+ \`You wrote "\${fakeCallName}({...})" as plain text in your final message instead of \` +
972
+ "actually calling that tool \u2014 no such call happened, so nothing was delegated/executed. " +
973
+ "If you meant to use it, call it for real now as a structured tool call, don't just " +
974
+ "describe it. If you already finished without needing it, say so explicitly instead of " +
975
+ "writing pseudocode that looks like a call.",
976
+ }));
977
+ process.exit(0);
978
+ }
979
+ }
861
980
  const moment = detectMoment(input);
862
981
  if (moment) {
863
982
  const sessionId = process.env.AGENT_BOARD_SESSION_ID;
@@ -883,11 +1002,8 @@ function agentMomentHookSettingsFragment() {
883
1002
  return {
884
1003
  hooks: {
885
1004
  UserPromptSubmit: [{ hooks: [hookEntry] }],
886
- PreToolUse: [{ matcher: "ExitPlanMode", hooks: [hookEntry] }],
887
- PostToolUse: [
888
- { matcher: "ExitPlanMode", hooks: [hookEntry] },
889
- { matcher: "Bash", hooks: [hookEntry] }
890
- ],
1005
+ PreToolUse: [{ matcher: "*", hooks: [hookEntry] }],
1006
+ PostToolUse: [{ matcher: "*", hooks: [hookEntry] }],
891
1007
  Stop: [{ hooks: [hookEntry] }],
892
1008
  Notification: [{ hooks: [hookEntry] }]
893
1009
  }
@@ -1007,11 +1123,16 @@ function formatPercent(value) {
1007
1123
  }
1008
1124
 
1009
1125
  function buildDisplayText(data) {
1010
- const rateLimits = data && data.rate_limits;
1011
- if (!rateLimits) return "";
1012
1126
  const parts = [];
1013
- if (rateLimits.five_hour) parts.push(\`5h \${formatPercent(rateLimits.five_hour.used_percentage)}\`);
1014
- if (rateLimits.seven_day) parts.push(\`7d \${formatPercent(rateLimits.seven_day.used_percentage)}\`);
1127
+ const contextWindow = data && data.context_window;
1128
+ if (contextWindow && typeof contextWindow.used_percentage === "number") {
1129
+ parts.push(\`ctx \${formatPercent(contextWindow.used_percentage)}\`);
1130
+ }
1131
+ const rateLimits = data && data.rate_limits;
1132
+ if (rateLimits) {
1133
+ if (rateLimits.five_hour) parts.push(\`5h \${formatPercent(rateLimits.five_hour.used_percentage)}\`);
1134
+ if (rateLimits.seven_day) parts.push(\`7d \${formatPercent(rateLimits.seven_day.used_percentage)}\`);
1135
+ }
1015
1136
  return parts.join(" \xB7 ");
1016
1137
  }
1017
1138
 
@@ -1622,47 +1743,51 @@ async function handleAgentHookReport(req, res) {
1622
1743
  res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
1623
1744
  res.end();
1624
1745
  }
1625
- async function handleBranchRequest(entry, res, relPath) {
1626
- let cwd;
1627
- try {
1628
- cwd = relPath ? resolveWithinRepo(entry.repoRoot, relPath) : entry.repoRoot;
1629
- } catch (err) {
1630
- if (err instanceof HttpError) {
1631
- sendJson(res, err.status, { error: err.message });
1632
- return;
1633
- }
1634
- throw err;
1635
- }
1636
- try {
1637
- const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
1638
- sendJson(res, 200, { branch: stdout.trim() });
1639
- } catch (err) {
1640
- logger.runner.error("FILES", "Unexpected error handling a branch request:", err);
1641
- sendJson(res, 500, { error: "Erro interno do runner." });
1642
- }
1643
- }
1644
1746
  var DIFF_EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
1645
- function parseGitStatusPorcelain(output) {
1747
+ function parseGitDiffNameStatus(output) {
1646
1748
  return output.split("\n").filter((line) => line.length > 0).map((line) => {
1647
- const status = line.slice(0, 2).trim();
1648
- const rest = line.slice(3);
1649
- const arrowIndex = rest.indexOf(" -> ");
1650
- const path = arrowIndex >= 0 ? rest.slice(arrowIndex + 4) : rest;
1749
+ const columns = line.split(" ");
1750
+ const status = columns[0].slice(0, 1);
1751
+ const path = columns[columns.length - 1];
1651
1752
  return { path, status };
1652
1753
  });
1653
1754
  }
1654
1755
  async function getRepoDiffForTarget(label, cwd) {
1756
+ let branch;
1757
+ try {
1758
+ const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
1759
+ branch = stdout.trim();
1760
+ } catch (err) {
1761
+ const message = err instanceof Error ? err.message : String(err);
1762
+ return { label, files: [], diff: "", error: message };
1763
+ }
1764
+ if (!branch) {
1765
+ return { label, files: [], diff: "", error: "HEAD destacado: sem branch local para comparar com a origin." };
1766
+ }
1767
+ try {
1768
+ await execFileAsync2("git", ["fetch", "origin", branch], { cwd });
1769
+ } catch (err) {
1770
+ const message = err instanceof Error ? err.message : String(err);
1771
+ const notOnRemote = /couldn't find remote ref/i.test(message);
1772
+ return {
1773
+ label,
1774
+ files: [],
1775
+ diff: "",
1776
+ error: notOnRemote ? `A branch "${branch}" ainda n\xE3o existe na origin.` : message
1777
+ };
1778
+ }
1779
+ const remoteRef = `origin/${branch}`;
1655
1780
  let files;
1656
1781
  try {
1657
- const { stdout } = await execFileAsync2("git", ["status", "--porcelain=v1"], { cwd });
1658
- files = parseGitStatusPorcelain(stdout);
1782
+ const { stdout } = await execFileAsync2("git", ["diff", "--name-status", remoteRef, "HEAD"], { cwd });
1783
+ files = parseGitDiffNameStatus(stdout);
1659
1784
  } catch (err) {
1660
1785
  const message = err instanceof Error ? err.message : String(err);
1661
1786
  return { label, files: [], diff: "", error: message };
1662
1787
  }
1663
1788
  if (files.length === 0) return { label, files, diff: "" };
1664
1789
  try {
1665
- const { stdout } = await execFileAsync2("git", ["diff", "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1790
+ const { stdout } = await execFileAsync2("git", ["diff", remoteRef, "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1666
1791
  return { label, files, diff: stdout };
1667
1792
  } catch {
1668
1793
  return { label, files, diff: "" };
@@ -1845,10 +1970,6 @@ function handleRequest(req, res) {
1845
1970
  sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou projeto desconhecido." });
1846
1971
  return;
1847
1972
  }
1848
- if (pathname === "/branch") {
1849
- void handleBranchRequest(entry, res, relPath);
1850
- return;
1851
- }
1852
1973
  if (pathname === "/diff") {
1853
1974
  const projectId = typeof query.projectId === "string" ? query.projectId : "";
1854
1975
  void handleDiffRequest(entry, res, projectId);
@@ -1931,11 +2052,18 @@ function attachFilesServer(httpServer, supabase, config) {
1931
2052
  }
1932
2053
 
1933
2054
  // src/agentEventsServer.ts
2055
+ var EMPTY_QUEUE = { queuedCardIds: [], pausedCardIds: [] };
1934
2056
  var stateByProject = /* @__PURE__ */ new Map();
2057
+ var queueByProject = /* @__PURE__ */ new Map();
2058
+ var branchesByProject = /* @__PURE__ */ new Map();
1935
2059
  var socketsByProject = /* @__PURE__ */ new Map();
1936
2060
  function sendSnapshot(socket, projectId) {
1937
2061
  const states = Array.from(stateByProject.get(projectId)?.values() ?? []);
1938
- socket.send(JSON.stringify({ type: "snapshot", states }));
2062
+ const queue = queueByProject.get(projectId) ?? EMPTY_QUEUE;
2063
+ const branches = Array.from(branchesByProject.get(projectId)?.entries() ?? []).map(
2064
+ ([label, branch]) => ({ label: label || null, branch })
2065
+ );
2066
+ socket.send(JSON.stringify({ type: "snapshot", states, queue, branches }));
1939
2067
  }
1940
2068
  function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
1941
2069
  let projectState = stateByProject.get(projectId);
@@ -1951,6 +2079,29 @@ function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
1951
2079
  if (socket.readyState === socket.OPEN) socket.send(payload);
1952
2080
  }
1953
2081
  }
2082
+ function publishQueueEvent(projectId, queue) {
2083
+ queueByProject.set(projectId, queue);
2084
+ const sockets = socketsByProject.get(projectId);
2085
+ if (!sockets || sockets.size === 0) return;
2086
+ const payload = JSON.stringify({ type: "queue", ...queue });
2087
+ for (const socket of sockets) {
2088
+ if (socket.readyState === socket.OPEN) socket.send(payload);
2089
+ }
2090
+ }
2091
+ function publishBranchEvent(projectId, label, branch) {
2092
+ let projectBranches = branchesByProject.get(projectId);
2093
+ if (!projectBranches) {
2094
+ projectBranches = /* @__PURE__ */ new Map();
2095
+ branchesByProject.set(projectId, projectBranches);
2096
+ }
2097
+ projectBranches.set(label ?? "", branch);
2098
+ const sockets = socketsByProject.get(projectId);
2099
+ if (!sockets || sockets.size === 0) return;
2100
+ const payload = JSON.stringify({ type: "branch", label, branch });
2101
+ for (const socket of sockets) {
2102
+ if (socket.readyState === socket.OPEN) socket.send(payload);
2103
+ }
2104
+ }
1954
2105
  function attachAgentEventsServer() {
1955
2106
  const wss = new WebSocketServer({ noServer: true });
1956
2107
  wss.on("connection", (socket, request) => {
@@ -2022,8 +2173,8 @@ function resolveRepoPath(config, relativeRepoPath) {
2022
2173
 
2023
2174
  // src/auth.ts
2024
2175
  var SESSION_FILE = join9(CONFIG_DIR, "session.json");
2025
- var DEFAULT_SUPABASE_URL = "https://brpxshvnmxgkbffgdfcc.supabase.co";
2026
- var DEFAULT_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJycHhzaHZubXhna2JmZmdkZmNjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODc1OTQwMTEsImV4cCI6MjEwMzE3MDAxMX0.ErsAGZysLFgNvijetR7w1YvIziXC-1D2sx04Hm2UW9c";
2176
+ var DEFAULT_SUPABASE_URL = "https://supabase.lealtek.com";
2177
+ var DEFAULT_SUPABASE_ANON_KEY = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc5MjAwNTE4MCwiZXhwIjo0OTQ3Njc4NzgwLCJyb2xlIjoiYW5vbiJ9.gxNuRa1STVA4lQPLghoGbuqV9_21lVOG1yXSu9WNmJ4";
2027
2178
  function resolveSupabaseUrl() {
2028
2179
  return process.env.AGENT_BOARD_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
2029
2180
  }
@@ -2078,11 +2229,77 @@ async function requireSession(supabase) {
2078
2229
  return session;
2079
2230
  }
2080
2231
 
2081
- // src/columnActions.ts
2082
- import { exec } from "node:child_process";
2232
+ // src/branchWatcher.ts
2233
+ import { execFile as execFile3 } from "node:child_process";
2234
+ import { watch } from "node:fs";
2083
2235
  import { join as join10 } from "node:path";
2084
2236
  import { promisify as promisify3 } from "node:util";
2085
- var execAsync = promisify3(exec);
2237
+ var execFileAsync3 = promisify3(execFile3);
2238
+ var DEBOUNCE_MS = 300;
2239
+ var watchedProjects = /* @__PURE__ */ new Map();
2240
+ async function readCurrentBranch(cwd) {
2241
+ try {
2242
+ const { stdout } = await execFileAsync3("git", ["branch", "--show-current"], { cwd });
2243
+ return stdout.trim();
2244
+ } catch {
2245
+ return "";
2246
+ }
2247
+ }
2248
+ function watchTarget(projectId, label, cwd, state) {
2249
+ let debounceTimer = null;
2250
+ function check() {
2251
+ void readCurrentBranch(cwd).then((branch) => {
2252
+ if (!branch || state.lastBranch.get(label) === branch) return;
2253
+ state.lastBranch.set(label, branch);
2254
+ publishBranchEvent(projectId, label || null, branch);
2255
+ });
2256
+ }
2257
+ let watcher;
2258
+ try {
2259
+ watcher = watch(join10(cwd, ".git", "HEAD"), () => {
2260
+ if (debounceTimer !== null) clearTimeout(debounceTimer);
2261
+ debounceTimer = setTimeout(check, DEBOUNCE_MS);
2262
+ });
2263
+ } catch (err) {
2264
+ logger.runner.warn("BRANCH_WATCH", `Could not watch .git/HEAD under ${cwd}:`, err instanceof Error ? err.message : err);
2265
+ return;
2266
+ }
2267
+ state.watchers.push(watcher);
2268
+ check();
2269
+ }
2270
+ async function ensureBranchWatchers(supabase, config) {
2271
+ for (const project of config.registeredProjects) {
2272
+ if (watchedProjects.has(project.id)) continue;
2273
+ const state = { watchers: [], lastBranch: /* @__PURE__ */ new Map() };
2274
+ watchedProjects.set(project.id, state);
2275
+ let children;
2276
+ try {
2277
+ children = (await getProjectRepos(supabase, project.id)).map((repo) => repo.subpath);
2278
+ } catch (err) {
2279
+ logger.runner.error("BRANCH_WATCH", `Failed to list child repos for project ${project.id}:`, err);
2280
+ children = [];
2281
+ }
2282
+ if (children.length === 0) {
2283
+ watchTarget(project.id, "", project.repoPath, state);
2284
+ } else {
2285
+ for (const subpath of children) {
2286
+ watchTarget(project.id, subpath, join10(project.repoPath, subpath), state);
2287
+ }
2288
+ }
2289
+ }
2290
+ const stillRegistered = new Set(config.registeredProjects.map((project) => project.id));
2291
+ for (const [projectId, state] of watchedProjects) {
2292
+ if (stillRegistered.has(projectId)) continue;
2293
+ for (const watcher of state.watchers) watcher.close();
2294
+ watchedProjects.delete(projectId);
2295
+ }
2296
+ }
2297
+
2298
+ // src/columnActions.ts
2299
+ import { exec } from "node:child_process";
2300
+ import { join as join11 } from "node:path";
2301
+ import { promisify as promisify4 } from "node:util";
2302
+ var execAsync = promisify4(exec);
2086
2303
  var OUTPUT_TAIL_CHARS = 4e3;
2087
2304
  function tail(text) {
2088
2305
  return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
@@ -2090,7 +2307,7 @@ function tail(text) {
2090
2307
  function resolveTargets(project, children, repoRoot, column, timing) {
2091
2308
  if (children.length > 0) {
2092
2309
  return children.flatMap(
2093
- (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join10(repoRoot, child2.subpath), command: action.command }))
2310
+ (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join11(repoRoot, child2.subpath), command: action.command }))
2094
2311
  );
2095
2312
  }
2096
2313
  return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command }));
@@ -2196,7 +2413,7 @@ async function checkPendingDeployActionReruns(supabase, config) {
2196
2413
  }
2197
2414
 
2198
2415
  // src/deploy.ts
2199
- import { join as join13 } from "node:path";
2416
+ import { join as join14 } from "node:path";
2200
2417
 
2201
2418
  // src/devServer.ts
2202
2419
  import { spawn as spawn4 } from "node:child_process";
@@ -2205,7 +2422,7 @@ import { spawn as spawn4 } from "node:child_process";
2205
2422
  import { spawn as spawn3 } from "node:child_process";
2206
2423
  import { accessSync, constants } from "node:fs";
2207
2424
  import { homedir as homedir5 } from "node:os";
2208
- import { delimiter, join as join11 } from "node:path";
2425
+ import { delimiter, join as join12 } from "node:path";
2209
2426
  import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
2210
2427
  var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2211
2428
  var STARTUP_TIMEOUT_MS = 2e4;
@@ -2216,15 +2433,15 @@ function wellKnownDirectories() {
2216
2433
  const home = homedir5();
2217
2434
  if (IS_WINDOWS2) {
2218
2435
  const candidates = [
2219
- process.env.ProgramFiles && join11(process.env.ProgramFiles, "cloudflared"),
2220
- process.env["ProgramFiles(x86)"] && join11(process.env["ProgramFiles(x86)"], "cloudflared"),
2221
- process.env.LOCALAPPDATA && join11(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2222
- join11(home, "scoop", "shims"),
2223
- process.env.ChocolateyInstall && join11(process.env.ChocolateyInstall, "bin")
2436
+ process.env.ProgramFiles && join12(process.env.ProgramFiles, "cloudflared"),
2437
+ process.env["ProgramFiles(x86)"] && join12(process.env["ProgramFiles(x86)"], "cloudflared"),
2438
+ process.env.LOCALAPPDATA && join12(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2439
+ join12(home, "scoop", "shims"),
2440
+ process.env.ChocolateyInstall && join12(process.env.ChocolateyInstall, "bin")
2224
2441
  ];
2225
2442
  return candidates.filter((dir) => Boolean(dir));
2226
2443
  }
2227
- return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join11(home, ".local", "bin")];
2444
+ return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join12(home, ".local", "bin")];
2228
2445
  }
2229
2446
  function pathDirectories() {
2230
2447
  return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
@@ -2240,7 +2457,7 @@ function isExecutableFile(path) {
2240
2457
  function findInstalledBinary() {
2241
2458
  const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
2242
2459
  const directories = [...pathDirectories(), ...wellKnownDirectories()];
2243
- const candidates = [...override ? [override] : [], ...directories.map((dir) => join11(dir, BINARY_NAME))];
2460
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join12(dir, BINARY_NAME))];
2244
2461
  return candidates.find(isExecutableFile) ?? null;
2245
2462
  }
2246
2463
  async function locateCloudflared() {
@@ -2427,12 +2644,12 @@ function stopAll() {
2427
2644
  }
2428
2645
 
2429
2646
  // src/git.ts
2430
- import { execFile as execFile3 } from "node:child_process";
2431
- import { promisify as promisify4 } from "node:util";
2432
- var execFileAsync3 = promisify4(execFile3);
2647
+ import { execFile as execFile4 } from "node:child_process";
2648
+ import { promisify as promisify5 } from "node:util";
2649
+ var execFileAsync4 = promisify5(execFile4);
2433
2650
  async function pushBranchToMain(repoPath) {
2434
2651
  try {
2435
- await execFileAsync3("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2652
+ await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2436
2653
  return { ok: true };
2437
2654
  } catch (err) {
2438
2655
  const stderr = err.stderr;
@@ -2594,8 +2811,8 @@ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2594
2811
  // src/cardAttachments.ts
2595
2812
  import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2596
2813
  import { tmpdir as tmpdir6 } from "node:os";
2597
- import { extname, join as join12 } from "node:path";
2598
- var ATTACHMENTS_ROOT = join12(tmpdir6(), "agent-board", "card-attachments");
2814
+ import { extname, join as join13 } from "node:path";
2815
+ var ATTACHMENTS_ROOT = join13(tmpdir6(), "agent-board", "card-attachments");
2599
2816
  function sanitizeFileName(fileName) {
2600
2817
  return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2601
2818
  }
@@ -2607,13 +2824,13 @@ async function downloadCardAttachments(supabase, cardId) {
2607
2824
  }
2608
2825
  const rows = data ?? [];
2609
2826
  if (rows.length === 0) return [];
2610
- const targetDir = join12(ATTACHMENTS_ROOT, cardId);
2827
+ const targetDir = join13(ATTACHMENTS_ROOT, cardId);
2611
2828
  await mkdir2(targetDir, { recursive: true });
2612
2829
  const paths = [];
2613
2830
  for (const row of rows) {
2614
2831
  const baseName = sanitizeFileName(row.file_name);
2615
2832
  const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2616
- const localPath = join12(targetDir, `${row.id}-${fileName}`);
2833
+ const localPath = join13(targetDir, `${row.id}-${fileName}`);
2617
2834
  try {
2618
2835
  const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2619
2836
  if (downloadError) throw downloadError;
@@ -2648,7 +2865,7 @@ ${list}`;
2648
2865
  function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2649
2866
  return `${instructions}
2650
2867
 
2651
- 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.`;
2868
+ 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.`;
2652
2869
  }
2653
2870
 
2654
2871
  // src/usageResume.ts
@@ -2758,8 +2975,9 @@ async function reconcileStaleSessions(supabase, config, projectId) {
2758
2975
  async function dispatchRunnerQueue(supabase, config) {
2759
2976
  if (dispatchingRunner) return;
2760
2977
  dispatchingRunner = true;
2978
+ let queued = [];
2761
2979
  try {
2762
- const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
2980
+ queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
2763
2981
  if (queued.length === 0) return;
2764
2982
  const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
2765
2983
  const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
@@ -2780,6 +2998,17 @@ async function dispatchRunnerQueue(supabase, config) {
2780
2998
  const projectId = entry.cards.project_id;
2781
2999
  if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
2782
3000
  if (freeProjectSlots <= 0) break;
3001
+ const prerequisiteCardId = entry.cards.prerequisite_card_id;
3002
+ if (prerequisiteCardId) {
3003
+ const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
3004
+ if (prerequisiteAgentState !== "done") {
3005
+ logger.card.info(
3006
+ entry.card_id,
3007
+ `Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
3008
+ );
3009
+ continue;
3010
+ }
3011
+ }
2783
3012
  try {
2784
3013
  await promoteQueuedSession(supabase, config, entry);
2785
3014
  promotedThisPass.add(projectId);
@@ -2789,9 +3018,33 @@ async function dispatchRunnerQueue(supabase, config) {
2789
3018
  }
2790
3019
  }
2791
3020
  } finally {
3021
+ await publishQueueSnapshots(supabase, config, queued).catch(
3022
+ (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3023
+ );
2792
3024
  dispatchingRunner = false;
2793
3025
  }
2794
3026
  }
3027
+ async function publishQueueSnapshots(supabase, config, queued) {
3028
+ const paused = await listPausedSessionsForRunner(supabase, config.ownerUserId);
3029
+ const queuedByProject = /* @__PURE__ */ new Map();
3030
+ for (const entry of queued) {
3031
+ const list = queuedByProject.get(entry.cards.project_id) ?? [];
3032
+ list.push(entry.card_id);
3033
+ queuedByProject.set(entry.cards.project_id, list);
3034
+ }
3035
+ const pausedByProject = /* @__PURE__ */ new Map();
3036
+ for (const { card_id: cardId, project_id: projectId } of paused) {
3037
+ const list = pausedByProject.get(projectId) ?? [];
3038
+ list.push(cardId);
3039
+ pausedByProject.set(projectId, list);
3040
+ }
3041
+ for (const project of config.registeredProjects) {
3042
+ publishQueueEvent(project.id, {
3043
+ queuedCardIds: queuedByProject.get(project.id) ?? [],
3044
+ pausedCardIds: pausedByProject.get(project.id) ?? []
3045
+ });
3046
+ }
3047
+ }
2795
3048
  async function promoteQueuedSession(supabase, config, entry) {
2796
3049
  const existingHandle = getPtySession(entry.id);
2797
3050
  if (existingHandle) {
@@ -3040,7 +3293,7 @@ async function handleDeployRequested(supabase, config, project) {
3040
3293
  }
3041
3294
  const children = await getProjectRepos(supabase, project.id);
3042
3295
  const repoRoot = resolveRepoPath(config, project.repo_path);
3043
- const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join13(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3296
+ const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join14(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3044
3297
  const failures = [];
3045
3298
  for (const target of targets) {
3046
3299
  const result = await pushBranchToMain(target.path);
@@ -3174,7 +3427,7 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
3174
3427
  }
3175
3428
 
3176
3429
  // src/review.ts
3177
- import { join as join14 } from "node:path";
3430
+ import { join as join15 } from "node:path";
3178
3431
  var inFlightCardIds2 = /* @__PURE__ */ new Set();
3179
3432
  async function handleCardEnteredRevisao(supabase, config, card) {
3180
3433
  if (inFlightCardIds2.has(card.id)) return;
@@ -3183,7 +3436,7 @@ async function handleCardEnteredRevisao(supabase, config, card) {
3183
3436
  const project = await getProject(supabase, card.project_id);
3184
3437
  const children = await getProjectRepos(supabase, project.id);
3185
3438
  const repoRoot = resolveRepoPath(config, project.repo_path);
3186
- 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 }] : [];
3439
+ 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 }] : [];
3187
3440
  if (targets.length === 0) {
3188
3441
  logger.card.info(card.id, `No dev_command configured for project ${project.id}, skipping automatic preview.`);
3189
3442
  return;
@@ -3452,6 +3705,7 @@ async function startRunner() {
3452
3705
  void ensureFilesTickets(supabase, config).catch((err) => logger.runner.error("FILES", "ensureFilesTickets failed:", err));
3453
3706
  void ensureSetupTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureSetupTicket failed:", err));
3454
3707
  void ensureAgentTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureAgentTicket failed:", err));
3708
+ void ensureBranchWatchers(supabase, config).catch((err) => logger.runner.error("BRANCH_WATCH", "ensureBranchWatchers failed:", err));
3455
3709
  const presenceChannel = supabase.channel(`runner-presence:${config.runnerId}`);
3456
3710
  presenceChannel.subscribe((status) => {
3457
3711
  if (status === "SUBSCRIBED") {
@@ -3548,6 +3802,7 @@ async function startRunner() {
3548
3802
  await ensureFilesTickets(supabase, config);
3549
3803
  await ensureSetupTicket(supabase, config);
3550
3804
  await ensureAgentTicket(supabase, config);
3805
+ await ensureBranchWatchers(supabase, config).catch((err) => logger.runner.error("BRANCH_WATCH", "ensureBranchWatchers failed:", err));
3551
3806
  await scanForWork(supabase, config);
3552
3807
  await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
3553
3808
  await checkPendingDeploys(supabase, config).catch((err) => logger.runner.error("DEPLOY", "checkPendingDeploys failed:", err));