agent-board-runner 0.1.4 → 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 +325 -75
  2. package/dist/index.js +325 -75
  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
  }
@@ -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);
@@ -2098,11 +2214,18 @@ function attachFilesServer(httpServer, supabase, config) {
2098
2214
  }
2099
2215
 
2100
2216
  // src/agentEventsServer.ts
2217
+ var EMPTY_QUEUE = { queuedCardIds: [], pausedCardIds: [] };
2101
2218
  var stateByProject = /* @__PURE__ */ new Map();
2219
+ var queueByProject = /* @__PURE__ */ new Map();
2220
+ var branchesByProject = /* @__PURE__ */ new Map();
2102
2221
  var socketsByProject = /* @__PURE__ */ new Map();
2103
2222
  function sendSnapshot(socket, projectId) {
2104
2223
  const states = Array.from(stateByProject.get(projectId)?.values() ?? []);
2105
- 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 }));
2106
2229
  }
2107
2230
  function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
2108
2231
  let projectState = stateByProject.get(projectId);
@@ -2118,6 +2241,29 @@ function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
2118
2241
  if (socket.readyState === socket.OPEN) socket.send(payload);
2119
2242
  }
2120
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
+ }
2121
2267
  function attachAgentEventsServer() {
2122
2268
  const wss = new WebSocketServer({ noServer: true });
2123
2269
  wss.on("connection", (socket, request) => {
@@ -2144,11 +2290,77 @@ function attachAgentEventsServer() {
2144
2290
  return wss;
2145
2291
  }
2146
2292
 
2147
- // src/columnActions.ts
2148
- 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";
2149
2296
  import { join as join10 } from "node:path";
2150
2297
  import { promisify as promisify3 } from "node:util";
2151
- 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);
2152
2364
  var OUTPUT_TAIL_CHARS = 4e3;
2153
2365
  function tail(text) {
2154
2366
  return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
@@ -2156,7 +2368,7 @@ function tail(text) {
2156
2368
  function resolveTargets(project, children, repoRoot, column, timing) {
2157
2369
  if (children.length > 0) {
2158
2370
  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 }))
2371
+ (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join11(repoRoot, child2.subpath), command: action.command }))
2160
2372
  );
2161
2373
  }
2162
2374
  return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command }));
@@ -2262,7 +2474,7 @@ async function checkPendingDeployActionReruns(supabase, config) {
2262
2474
  }
2263
2475
 
2264
2476
  // src/deploy.ts
2265
- import { join as join13 } from "node:path";
2477
+ import { join as join14 } from "node:path";
2266
2478
 
2267
2479
  // src/devServer.ts
2268
2480
  import { spawn as spawn4 } from "node:child_process";
@@ -2271,7 +2483,7 @@ import { spawn as spawn4 } from "node:child_process";
2271
2483
  import { spawn as spawn3 } from "node:child_process";
2272
2484
  import { accessSync, constants } from "node:fs";
2273
2485
  import { homedir as homedir5 } from "node:os";
2274
- import { delimiter, join as join11 } from "node:path";
2486
+ import { delimiter, join as join12 } from "node:path";
2275
2487
  import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
2276
2488
  var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2277
2489
  var STARTUP_TIMEOUT_MS = 2e4;
@@ -2282,15 +2494,15 @@ function wellKnownDirectories() {
2282
2494
  const home = homedir5();
2283
2495
  if (IS_WINDOWS2) {
2284
2496
  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")
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")
2290
2502
  ];
2291
2503
  return candidates.filter((dir) => Boolean(dir));
2292
2504
  }
2293
- 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")];
2294
2506
  }
2295
2507
  function pathDirectories() {
2296
2508
  return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
@@ -2306,7 +2518,7 @@ function isExecutableFile(path) {
2306
2518
  function findInstalledBinary() {
2307
2519
  const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
2308
2520
  const directories = [...pathDirectories(), ...wellKnownDirectories()];
2309
- const candidates = [...override ? [override] : [], ...directories.map((dir) => join11(dir, BINARY_NAME))];
2521
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join12(dir, BINARY_NAME))];
2310
2522
  return candidates.find(isExecutableFile) ?? null;
2311
2523
  }
2312
2524
  async function locateCloudflared() {
@@ -2493,12 +2705,12 @@ function stopAll() {
2493
2705
  }
2494
2706
 
2495
2707
  // 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);
2708
+ import { execFile as execFile4 } from "node:child_process";
2709
+ import { promisify as promisify5 } from "node:util";
2710
+ var execFileAsync4 = promisify5(execFile4);
2499
2711
  async function pushBranchToMain(repoPath) {
2500
2712
  try {
2501
- await execFileAsync3("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2713
+ await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2502
2714
  return { ok: true };
2503
2715
  } catch (err) {
2504
2716
  const stderr = err.stderr;
@@ -2660,8 +2872,8 @@ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2660
2872
  // src/cardAttachments.ts
2661
2873
  import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2662
2874
  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");
2875
+ import { extname, join as join13 } from "node:path";
2876
+ var ATTACHMENTS_ROOT = join13(tmpdir6(), "agent-board", "card-attachments");
2665
2877
  function sanitizeFileName(fileName) {
2666
2878
  return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2667
2879
  }
@@ -2673,13 +2885,13 @@ async function downloadCardAttachments(supabase, cardId) {
2673
2885
  }
2674
2886
  const rows = data ?? [];
2675
2887
  if (rows.length === 0) return [];
2676
- const targetDir = join12(ATTACHMENTS_ROOT, cardId);
2888
+ const targetDir = join13(ATTACHMENTS_ROOT, cardId);
2677
2889
  await mkdir2(targetDir, { recursive: true });
2678
2890
  const paths = [];
2679
2891
  for (const row of rows) {
2680
2892
  const baseName = sanitizeFileName(row.file_name);
2681
2893
  const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2682
- const localPath = join12(targetDir, `${row.id}-${fileName}`);
2894
+ const localPath = join13(targetDir, `${row.id}-${fileName}`);
2683
2895
  try {
2684
2896
  const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2685
2897
  if (downloadError) throw downloadError;
@@ -2714,7 +2926,7 @@ ${list}`;
2714
2926
  function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2715
2927
  return `${instructions}
2716
2928
 
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.`;
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.`;
2718
2930
  }
2719
2931
 
2720
2932
  // src/usageResume.ts
@@ -2824,8 +3036,9 @@ async function reconcileStaleSessions(supabase, config, projectId) {
2824
3036
  async function dispatchRunnerQueue(supabase, config) {
2825
3037
  if (dispatchingRunner) return;
2826
3038
  dispatchingRunner = true;
3039
+ let queued = [];
2827
3040
  try {
2828
- const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
3041
+ queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
2829
3042
  if (queued.length === 0) return;
2830
3043
  const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
2831
3044
  const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
@@ -2846,6 +3059,17 @@ async function dispatchRunnerQueue(supabase, config) {
2846
3059
  const projectId = entry.cards.project_id;
2847
3060
  if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
2848
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
+ }
2849
3073
  try {
2850
3074
  await promoteQueuedSession(supabase, config, entry);
2851
3075
  promotedThisPass.add(projectId);
@@ -2855,9 +3079,33 @@ async function dispatchRunnerQueue(supabase, config) {
2855
3079
  }
2856
3080
  }
2857
3081
  } finally {
3082
+ await publishQueueSnapshots(supabase, config, queued).catch(
3083
+ (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3084
+ );
2858
3085
  dispatchingRunner = false;
2859
3086
  }
2860
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
+ }
2861
3109
  async function promoteQueuedSession(supabase, config, entry) {
2862
3110
  const existingHandle = getPtySession(entry.id);
2863
3111
  if (existingHandle) {
@@ -3106,7 +3354,7 @@ async function handleDeployRequested(supabase, config, project) {
3106
3354
  }
3107
3355
  const children = await getProjectRepos(supabase, project.id);
3108
3356
  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 }];
3357
+ const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join14(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3110
3358
  const failures = [];
3111
3359
  for (const target of targets) {
3112
3360
  const result = await pushBranchToMain(target.path);
@@ -3240,7 +3488,7 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
3240
3488
  }
3241
3489
 
3242
3490
  // src/review.ts
3243
- import { join as join14 } from "node:path";
3491
+ import { join as join15 } from "node:path";
3244
3492
  var inFlightCardIds2 = /* @__PURE__ */ new Set();
3245
3493
  async function handleCardEnteredRevisao(supabase, config, card) {
3246
3494
  if (inFlightCardIds2.has(card.id)) return;
@@ -3249,7 +3497,7 @@ async function handleCardEnteredRevisao(supabase, config, card) {
3249
3497
  const project = await getProject(supabase, card.project_id);
3250
3498
  const children = await getProjectRepos(supabase, project.id);
3251
3499
  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 }] : [];
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 }] : [];
3253
3501
  if (targets.length === 0) {
3254
3502
  logger.card.info(card.id, `No dev_command configured for project ${project.id}, skipping automatic preview.`);
3255
3503
  return;
@@ -3518,6 +3766,7 @@ async function startRunner() {
3518
3766
  void ensureFilesTickets(supabase, config).catch((err) => logger.runner.error("FILES", "ensureFilesTickets failed:", err));
3519
3767
  void ensureSetupTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureSetupTicket failed:", err));
3520
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));
3521
3770
  const presenceChannel = supabase.channel(`runner-presence:${config.runnerId}`);
3522
3771
  presenceChannel.subscribe((status) => {
3523
3772
  if (status === "SUBSCRIBED") {
@@ -3614,6 +3863,7 @@ async function startRunner() {
3614
3863
  await ensureFilesTickets(supabase, config);
3615
3864
  await ensureSetupTicket(supabase, config);
3616
3865
  await ensureAgentTicket(supabase, config);
3866
+ await ensureBranchWatchers(supabase, config).catch((err) => logger.runner.error("BRANCH_WATCH", "ensureBranchWatchers failed:", err));
3617
3867
  await scanForWork(supabase, config);
3618
3868
  await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
3619
3869
  await checkPendingDeploys(supabase, config).catch((err) => logger.runner.error("DEPLOY", "checkPendingDeploys failed:", err));