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/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,
@@ -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
  }
@@ -1169,11 +1285,16 @@ function formatPercent(value) {
1169
1285
  }
1170
1286
 
1171
1287
  function buildDisplayText(data) {
1172
- const rateLimits = data && data.rate_limits;
1173
- if (!rateLimits) return "";
1174
1288
  const parts = [];
1175
- if (rateLimits.five_hour) parts.push(\`5h \${formatPercent(rateLimits.five_hour.used_percentage)}\`);
1176
- if (rateLimits.seven_day) parts.push(\`7d \${formatPercent(rateLimits.seven_day.used_percentage)}\`);
1289
+ const contextWindow = data && data.context_window;
1290
+ if (contextWindow && typeof contextWindow.used_percentage === "number") {
1291
+ parts.push(\`ctx \${formatPercent(contextWindow.used_percentage)}\`);
1292
+ }
1293
+ const rateLimits = data && data.rate_limits;
1294
+ if (rateLimits) {
1295
+ if (rateLimits.five_hour) parts.push(\`5h \${formatPercent(rateLimits.five_hour.used_percentage)}\`);
1296
+ if (rateLimits.seven_day) parts.push(\`7d \${formatPercent(rateLimits.seven_day.used_percentage)}\`);
1297
+ }
1177
1298
  return parts.join(" \xB7 ");
1178
1299
  }
1179
1300
 
@@ -1784,47 +1905,51 @@ async function handleAgentHookReport(req, res) {
1784
1905
  res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
1785
1906
  res.end();
1786
1907
  }
1787
- async function handleBranchRequest(entry, res, relPath) {
1788
- let cwd;
1789
- try {
1790
- cwd = relPath ? resolveWithinRepo(entry.repoRoot, relPath) : entry.repoRoot;
1791
- } catch (err) {
1792
- if (err instanceof HttpError) {
1793
- sendJson(res, err.status, { error: err.message });
1794
- return;
1795
- }
1796
- throw err;
1797
- }
1798
- try {
1799
- const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
1800
- sendJson(res, 200, { branch: stdout.trim() });
1801
- } catch (err) {
1802
- logger.runner.error("FILES", "Unexpected error handling a branch request:", err);
1803
- sendJson(res, 500, { error: "Erro interno do runner." });
1804
- }
1805
- }
1806
1908
  var DIFF_EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
1807
- function parseGitStatusPorcelain(output) {
1909
+ function parseGitDiffNameStatus(output) {
1808
1910
  return output.split("\n").filter((line) => line.length > 0).map((line) => {
1809
- const status = line.slice(0, 2).trim();
1810
- const rest2 = line.slice(3);
1811
- const arrowIndex = rest2.indexOf(" -> ");
1812
- 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];
1813
1914
  return { path, status };
1814
1915
  });
1815
1916
  }
1816
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}`;
1817
1942
  let files;
1818
1943
  try {
1819
- const { stdout } = await execFileAsync2("git", ["status", "--porcelain=v1"], { cwd });
1820
- files = parseGitStatusPorcelain(stdout);
1944
+ const { stdout } = await execFileAsync2("git", ["diff", "--name-status", remoteRef, "HEAD"], { cwd });
1945
+ files = parseGitDiffNameStatus(stdout);
1821
1946
  } catch (err) {
1822
1947
  const message = err instanceof Error ? err.message : String(err);
1823
1948
  return { label, files: [], diff: "", error: message };
1824
1949
  }
1825
1950
  if (files.length === 0) return { label, files, diff: "" };
1826
1951
  try {
1827
- const { stdout } = await execFileAsync2("git", ["diff", "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1952
+ const { stdout } = await execFileAsync2("git", ["diff", remoteRef, "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1828
1953
  return { label, files, diff: stdout };
1829
1954
  } catch {
1830
1955
  return { label, files, diff: "" };
@@ -2007,10 +2132,6 @@ function handleRequest(req, res) {
2007
2132
  sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou projeto desconhecido." });
2008
2133
  return;
2009
2134
  }
2010
- if (pathname === "/branch") {
2011
- void handleBranchRequest(entry, res, relPath);
2012
- return;
2013
- }
2014
2135
  if (pathname === "/diff") {
2015
2136
  const projectId = typeof query.projectId === "string" ? query.projectId : "";
2016
2137
  void handleDiffRequest(entry, res, projectId);
@@ -2093,11 +2214,18 @@ function attachFilesServer(httpServer, supabase, config) {
2093
2214
  }
2094
2215
 
2095
2216
  // src/agentEventsServer.ts
2217
+ var EMPTY_QUEUE = { queuedCardIds: [], pausedCardIds: [] };
2096
2218
  var stateByProject = /* @__PURE__ */ new Map();
2219
+ var queueByProject = /* @__PURE__ */ new Map();
2220
+ var branchesByProject = /* @__PURE__ */ new Map();
2097
2221
  var socketsByProject = /* @__PURE__ */ new Map();
2098
2222
  function sendSnapshot(socket, projectId) {
2099
2223
  const states = Array.from(stateByProject.get(projectId)?.values() ?? []);
2100
- socket.send(JSON.stringify({ type: "snapshot", states }));
2224
+ const queue = queueByProject.get(projectId) ?? EMPTY_QUEUE;
2225
+ const branches = Array.from(branchesByProject.get(projectId)?.entries() ?? []).map(
2226
+ ([label, branch]) => ({ label: label || null, branch })
2227
+ );
2228
+ socket.send(JSON.stringify({ type: "snapshot", states, queue, branches }));
2101
2229
  }
2102
2230
  function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
2103
2231
  let projectState = stateByProject.get(projectId);
@@ -2113,6 +2241,29 @@ function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
2113
2241
  if (socket.readyState === socket.OPEN) socket.send(payload);
2114
2242
  }
2115
2243
  }
2244
+ function publishQueueEvent(projectId, queue) {
2245
+ queueByProject.set(projectId, queue);
2246
+ const sockets = socketsByProject.get(projectId);
2247
+ if (!sockets || sockets.size === 0) return;
2248
+ const payload = JSON.stringify({ type: "queue", ...queue });
2249
+ for (const socket of sockets) {
2250
+ if (socket.readyState === socket.OPEN) socket.send(payload);
2251
+ }
2252
+ }
2253
+ function publishBranchEvent(projectId, label, branch) {
2254
+ let projectBranches = branchesByProject.get(projectId);
2255
+ if (!projectBranches) {
2256
+ projectBranches = /* @__PURE__ */ new Map();
2257
+ branchesByProject.set(projectId, projectBranches);
2258
+ }
2259
+ projectBranches.set(label ?? "", branch);
2260
+ const sockets = socketsByProject.get(projectId);
2261
+ if (!sockets || sockets.size === 0) return;
2262
+ const payload = JSON.stringify({ type: "branch", label, branch });
2263
+ for (const socket of sockets) {
2264
+ if (socket.readyState === socket.OPEN) socket.send(payload);
2265
+ }
2266
+ }
2116
2267
  function attachAgentEventsServer() {
2117
2268
  const wss = new WebSocketServer({ noServer: true });
2118
2269
  wss.on("connection", (socket, request) => {
@@ -2139,11 +2290,77 @@ function attachAgentEventsServer() {
2139
2290
  return wss;
2140
2291
  }
2141
2292
 
2142
- // src/columnActions.ts
2143
- import { exec } from "node:child_process";
2293
+ // src/branchWatcher.ts
2294
+ import { execFile as execFile3 } from "node:child_process";
2295
+ import { watch } from "node:fs";
2144
2296
  import { join as join10 } from "node:path";
2145
2297
  import { promisify as promisify3 } from "node:util";
2146
- var execAsync = promisify3(exec);
2298
+ var execFileAsync3 = promisify3(execFile3);
2299
+ var DEBOUNCE_MS = 300;
2300
+ var watchedProjects = /* @__PURE__ */ new Map();
2301
+ async function readCurrentBranch(cwd) {
2302
+ try {
2303
+ const { stdout } = await execFileAsync3("git", ["branch", "--show-current"], { cwd });
2304
+ return stdout.trim();
2305
+ } catch {
2306
+ return "";
2307
+ }
2308
+ }
2309
+ function watchTarget(projectId, label, cwd, state) {
2310
+ let debounceTimer = null;
2311
+ function check() {
2312
+ void readCurrentBranch(cwd).then((branch) => {
2313
+ if (!branch || state.lastBranch.get(label) === branch) return;
2314
+ state.lastBranch.set(label, branch);
2315
+ publishBranchEvent(projectId, label || null, branch);
2316
+ });
2317
+ }
2318
+ let watcher;
2319
+ try {
2320
+ watcher = watch(join10(cwd, ".git", "HEAD"), () => {
2321
+ if (debounceTimer !== null) clearTimeout(debounceTimer);
2322
+ debounceTimer = setTimeout(check, DEBOUNCE_MS);
2323
+ });
2324
+ } catch (err) {
2325
+ logger.runner.warn("BRANCH_WATCH", `Could not watch .git/HEAD under ${cwd}:`, err instanceof Error ? err.message : err);
2326
+ return;
2327
+ }
2328
+ state.watchers.push(watcher);
2329
+ check();
2330
+ }
2331
+ async function ensureBranchWatchers(supabase, config) {
2332
+ for (const project of config.registeredProjects) {
2333
+ if (watchedProjects.has(project.id)) continue;
2334
+ const state = { watchers: [], lastBranch: /* @__PURE__ */ new Map() };
2335
+ watchedProjects.set(project.id, state);
2336
+ let children;
2337
+ try {
2338
+ children = (await getProjectRepos(supabase, project.id)).map((repo) => repo.subpath);
2339
+ } catch (err) {
2340
+ logger.runner.error("BRANCH_WATCH", `Failed to list child repos for project ${project.id}:`, err);
2341
+ children = [];
2342
+ }
2343
+ if (children.length === 0) {
2344
+ watchTarget(project.id, "", project.repoPath, state);
2345
+ } else {
2346
+ for (const subpath of children) {
2347
+ watchTarget(project.id, subpath, join10(project.repoPath, subpath), state);
2348
+ }
2349
+ }
2350
+ }
2351
+ const stillRegistered = new Set(config.registeredProjects.map((project) => project.id));
2352
+ for (const [projectId, state] of watchedProjects) {
2353
+ if (stillRegistered.has(projectId)) continue;
2354
+ for (const watcher of state.watchers) watcher.close();
2355
+ watchedProjects.delete(projectId);
2356
+ }
2357
+ }
2358
+
2359
+ // src/columnActions.ts
2360
+ import { exec } from "node:child_process";
2361
+ import { join as join11 } from "node:path";
2362
+ import { promisify as promisify4 } from "node:util";
2363
+ var execAsync = promisify4(exec);
2147
2364
  var OUTPUT_TAIL_CHARS = 4e3;
2148
2365
  function tail(text) {
2149
2366
  return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
@@ -2151,7 +2368,7 @@ function tail(text) {
2151
2368
  function resolveTargets(project, children, repoRoot, column, timing) {
2152
2369
  if (children.length > 0) {
2153
2370
  return children.flatMap(
2154
- (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join10(repoRoot, child2.subpath), command: action.command }))
2371
+ (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join11(repoRoot, child2.subpath), command: action.command }))
2155
2372
  );
2156
2373
  }
2157
2374
  return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command }));
@@ -2257,7 +2474,7 @@ async function checkPendingDeployActionReruns(supabase, config) {
2257
2474
  }
2258
2475
 
2259
2476
  // src/deploy.ts
2260
- import { join as join13 } from "node:path";
2477
+ import { join as join14 } from "node:path";
2261
2478
 
2262
2479
  // src/devServer.ts
2263
2480
  import { spawn as spawn4 } from "node:child_process";
@@ -2266,7 +2483,7 @@ import { spawn as spawn4 } from "node:child_process";
2266
2483
  import { spawn as spawn3 } from "node:child_process";
2267
2484
  import { accessSync, constants } from "node:fs";
2268
2485
  import { homedir as homedir5 } from "node:os";
2269
- import { delimiter, join as join11 } from "node:path";
2486
+ import { delimiter, join as join12 } from "node:path";
2270
2487
  import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
2271
2488
  var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2272
2489
  var STARTUP_TIMEOUT_MS = 2e4;
@@ -2277,15 +2494,15 @@ function wellKnownDirectories() {
2277
2494
  const home = homedir5();
2278
2495
  if (IS_WINDOWS2) {
2279
2496
  const candidates = [
2280
- process.env.ProgramFiles && join11(process.env.ProgramFiles, "cloudflared"),
2281
- process.env["ProgramFiles(x86)"] && join11(process.env["ProgramFiles(x86)"], "cloudflared"),
2282
- process.env.LOCALAPPDATA && join11(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2283
- join11(home, "scoop", "shims"),
2284
- process.env.ChocolateyInstall && join11(process.env.ChocolateyInstall, "bin")
2497
+ process.env.ProgramFiles && join12(process.env.ProgramFiles, "cloudflared"),
2498
+ process.env["ProgramFiles(x86)"] && join12(process.env["ProgramFiles(x86)"], "cloudflared"),
2499
+ process.env.LOCALAPPDATA && join12(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2500
+ join12(home, "scoop", "shims"),
2501
+ process.env.ChocolateyInstall && join12(process.env.ChocolateyInstall, "bin")
2285
2502
  ];
2286
2503
  return candidates.filter((dir) => Boolean(dir));
2287
2504
  }
2288
- return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join11(home, ".local", "bin")];
2505
+ return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join12(home, ".local", "bin")];
2289
2506
  }
2290
2507
  function pathDirectories() {
2291
2508
  return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
@@ -2301,7 +2518,7 @@ function isExecutableFile(path) {
2301
2518
  function findInstalledBinary() {
2302
2519
  const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
2303
2520
  const directories = [...pathDirectories(), ...wellKnownDirectories()];
2304
- const candidates = [...override ? [override] : [], ...directories.map((dir) => join11(dir, BINARY_NAME))];
2521
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join12(dir, BINARY_NAME))];
2305
2522
  return candidates.find(isExecutableFile) ?? null;
2306
2523
  }
2307
2524
  async function locateCloudflared() {
@@ -2488,12 +2705,12 @@ function stopAll() {
2488
2705
  }
2489
2706
 
2490
2707
  // src/git.ts
2491
- import { execFile as execFile3 } from "node:child_process";
2492
- import { promisify as promisify4 } from "node:util";
2493
- var execFileAsync3 = promisify4(execFile3);
2708
+ import { execFile as execFile4 } from "node:child_process";
2709
+ import { promisify as promisify5 } from "node:util";
2710
+ var execFileAsync4 = promisify5(execFile4);
2494
2711
  async function pushBranchToMain(repoPath) {
2495
2712
  try {
2496
- await execFileAsync3("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2713
+ await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2497
2714
  return { ok: true };
2498
2715
  } catch (err) {
2499
2716
  const stderr = err.stderr;
@@ -2655,8 +2872,8 @@ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2655
2872
  // src/cardAttachments.ts
2656
2873
  import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2657
2874
  import { tmpdir as tmpdir6 } from "node:os";
2658
- import { extname, join as join12 } from "node:path";
2659
- var ATTACHMENTS_ROOT = join12(tmpdir6(), "agent-board", "card-attachments");
2875
+ import { extname, join as join13 } from "node:path";
2876
+ var ATTACHMENTS_ROOT = join13(tmpdir6(), "agent-board", "card-attachments");
2660
2877
  function sanitizeFileName(fileName) {
2661
2878
  return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2662
2879
  }
@@ -2668,13 +2885,13 @@ async function downloadCardAttachments(supabase, cardId) {
2668
2885
  }
2669
2886
  const rows = data ?? [];
2670
2887
  if (rows.length === 0) return [];
2671
- const targetDir = join12(ATTACHMENTS_ROOT, cardId);
2888
+ const targetDir = join13(ATTACHMENTS_ROOT, cardId);
2672
2889
  await mkdir2(targetDir, { recursive: true });
2673
2890
  const paths = [];
2674
2891
  for (const row of rows) {
2675
2892
  const baseName = sanitizeFileName(row.file_name);
2676
2893
  const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2677
- const localPath = join12(targetDir, `${row.id}-${fileName}`);
2894
+ const localPath = join13(targetDir, `${row.id}-${fileName}`);
2678
2895
  try {
2679
2896
  const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2680
2897
  if (downloadError) throw downloadError;
@@ -2709,7 +2926,7 @@ ${list}`;
2709
2926
  function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2710
2927
  return `${instructions}
2711
2928
 
2712
- 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.`;
2929
+ 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.`;
2713
2930
  }
2714
2931
 
2715
2932
  // src/usageResume.ts
@@ -2819,8 +3036,9 @@ async function reconcileStaleSessions(supabase, config, projectId) {
2819
3036
  async function dispatchRunnerQueue(supabase, config) {
2820
3037
  if (dispatchingRunner) return;
2821
3038
  dispatchingRunner = true;
3039
+ let queued = [];
2822
3040
  try {
2823
- const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
3041
+ queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
2824
3042
  if (queued.length === 0) return;
2825
3043
  const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
2826
3044
  const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
@@ -2841,6 +3059,17 @@ async function dispatchRunnerQueue(supabase, config) {
2841
3059
  const projectId = entry.cards.project_id;
2842
3060
  if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
2843
3061
  if (freeProjectSlots <= 0) break;
3062
+ const prerequisiteCardId = entry.cards.prerequisite_card_id;
3063
+ if (prerequisiteCardId) {
3064
+ const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
3065
+ if (prerequisiteAgentState !== "done") {
3066
+ logger.card.info(
3067
+ entry.card_id,
3068
+ `Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
3069
+ );
3070
+ continue;
3071
+ }
3072
+ }
2844
3073
  try {
2845
3074
  await promoteQueuedSession(supabase, config, entry);
2846
3075
  promotedThisPass.add(projectId);
@@ -2850,9 +3079,33 @@ async function dispatchRunnerQueue(supabase, config) {
2850
3079
  }
2851
3080
  }
2852
3081
  } finally {
3082
+ await publishQueueSnapshots(supabase, config, queued).catch(
3083
+ (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3084
+ );
2853
3085
  dispatchingRunner = false;
2854
3086
  }
2855
3087
  }
3088
+ async function publishQueueSnapshots(supabase, config, queued) {
3089
+ const paused = await listPausedSessionsForRunner(supabase, config.ownerUserId);
3090
+ const queuedByProject = /* @__PURE__ */ new Map();
3091
+ for (const entry of queued) {
3092
+ const list = queuedByProject.get(entry.cards.project_id) ?? [];
3093
+ list.push(entry.card_id);
3094
+ queuedByProject.set(entry.cards.project_id, list);
3095
+ }
3096
+ const pausedByProject = /* @__PURE__ */ new Map();
3097
+ for (const { card_id: cardId, project_id: projectId } of paused) {
3098
+ const list = pausedByProject.get(projectId) ?? [];
3099
+ list.push(cardId);
3100
+ pausedByProject.set(projectId, list);
3101
+ }
3102
+ for (const project of config.registeredProjects) {
3103
+ publishQueueEvent(project.id, {
3104
+ queuedCardIds: queuedByProject.get(project.id) ?? [],
3105
+ pausedCardIds: pausedByProject.get(project.id) ?? []
3106
+ });
3107
+ }
3108
+ }
2856
3109
  async function promoteQueuedSession(supabase, config, entry) {
2857
3110
  const existingHandle = getPtySession(entry.id);
2858
3111
  if (existingHandle) {
@@ -3101,7 +3354,7 @@ async function handleDeployRequested(supabase, config, project) {
3101
3354
  }
3102
3355
  const children = await getProjectRepos(supabase, project.id);
3103
3356
  const repoRoot = resolveRepoPath(config, project.repo_path);
3104
- const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join13(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3357
+ const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join14(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3105
3358
  const failures = [];
3106
3359
  for (const target of targets) {
3107
3360
  const result = await pushBranchToMain(target.path);
@@ -3235,7 +3488,7 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
3235
3488
  }
3236
3489
 
3237
3490
  // src/review.ts
3238
- import { join as join14 } from "node:path";
3491
+ import { join as join15 } from "node:path";
3239
3492
  var inFlightCardIds2 = /* @__PURE__ */ new Set();
3240
3493
  async function handleCardEnteredRevisao(supabase, config, card) {
3241
3494
  if (inFlightCardIds2.has(card.id)) return;
@@ -3244,7 +3497,7 @@ async function handleCardEnteredRevisao(supabase, config, card) {
3244
3497
  const project = await getProject(supabase, card.project_id);
3245
3498
  const children = await getProjectRepos(supabase, project.id);
3246
3499
  const repoRoot = resolveRepoPath(config, project.repo_path);
3247
- 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 }] : [];
3500
+ 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 }] : [];
3248
3501
  if (targets.length === 0) {
3249
3502
  logger.card.info(card.id, `No dev_command configured for project ${project.id}, skipping automatic preview.`);
3250
3503
  return;
@@ -3513,6 +3766,7 @@ async function startRunner() {
3513
3766
  void ensureFilesTickets(supabase, config).catch((err) => logger.runner.error("FILES", "ensureFilesTickets failed:", err));
3514
3767
  void ensureSetupTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureSetupTicket failed:", err));
3515
3768
  void ensureAgentTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureAgentTicket failed:", err));
3769
+ void ensureBranchWatchers(supabase, config).catch((err) => logger.runner.error("BRANCH_WATCH", "ensureBranchWatchers failed:", err));
3516
3770
  const presenceChannel = supabase.channel(`runner-presence:${config.runnerId}`);
3517
3771
  presenceChannel.subscribe((status) => {
3518
3772
  if (status === "SUBSCRIBED") {
@@ -3609,6 +3863,7 @@ async function startRunner() {
3609
3863
  await ensureFilesTickets(supabase, config);
3610
3864
  await ensureSetupTicket(supabase, config);
3611
3865
  await ensureAgentTicket(supabase, config);
3866
+ await ensureBranchWatchers(supabase, config).catch((err) => logger.runner.error("BRANCH_WATCH", "ensureBranchWatchers failed:", err));
3612
3867
  await scanForWork(supabase, config);
3613
3868
  await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
3614
3869
  await checkPendingDeploys(supabase, config).catch((err) => logger.runner.error("DEPLOY", "checkPendingDeploys failed:", err));