agent-board-runner 0.1.12 → 0.1.14

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 +1198 -991
  2. package/dist/index.js +1118 -984
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7,13 +7,14 @@ import { resolve as resolve4 } from "node:path";
7
7
 
8
8
  // src/auth.ts
9
9
  import { createClient } from "@supabase/supabase-js";
10
+ import { createPrivateKey, randomInt, sign } from "node:crypto";
10
11
  import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
11
12
  import { createServer } from "node:http";
12
13
  import { join as join2 } from "node:path";
13
14
  import open from "open";
14
15
 
15
16
  // src/config.ts
16
- import { randomUUID } from "node:crypto";
17
+ import { generateKeyPairSync, randomUUID } from "node:crypto";
17
18
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
18
19
  import { hostname, homedir } from "node:os";
19
20
  import { join, resolve } from "node:path";
@@ -27,8 +28,21 @@ function saveConfig(config) {
27
28
  if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
28
29
  writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
29
30
  }
31
+ function generateRecoveryKeypair() {
32
+ const { publicKey, privateKey } = generateKeyPairSync("ed25519", {
33
+ publicKeyEncoding: { type: "spki", format: "pem" },
34
+ privateKeyEncoding: { type: "pkcs8", format: "pem" }
35
+ });
36
+ return { recoveryPublicKey: publicKey, recoveryPrivateKey: privateKey };
37
+ }
30
38
  function createRunnerIdentity() {
31
- return { runnerId: randomUUID(), authToken: randomUUID() };
39
+ return { runnerId: randomUUID(), authToken: randomUUID(), ...generateRecoveryKeypair() };
40
+ }
41
+ function ensureRecoveryKeypair(config) {
42
+ if (config.recoveryPublicKey && config.recoveryPrivateKey) return config;
43
+ Object.assign(config, generateRecoveryKeypair());
44
+ saveConfig(config);
45
+ return config;
32
46
  }
33
47
  function applyAllowedRoot(config, explicitPath) {
34
48
  const raw = explicitPath ?? process.env.AGENT_BOARD_ALLOWED_ROOT;
@@ -162,6 +176,110 @@ async function logout() {
162
176
  await supabase.auth.signOut();
163
177
  if (existsSync2(SESSION_FILE)) rmSync(SESSION_FILE);
164
178
  }
179
+ function resolveWebUrl() {
180
+ const url = process.env.AGENT_BOARD_WEB_URL;
181
+ if (!url) {
182
+ throw new Error(
183
+ "AGENT_BOARD_WEB_URL is not set \u2014 point it at the deployed agent-board web app (e.g. https://board.example.com) to use `request-approval` on a machine with no browser."
184
+ );
185
+ }
186
+ return url.replace(/\/+$/, "");
187
+ }
188
+ var CONFIRMATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
189
+ function generateConfirmationCode() {
190
+ let code = "";
191
+ for (let i = 0; i < 6; i += 1) {
192
+ code += CONFIRMATION_CODE_ALPHABET[randomInt(CONFIRMATION_CODE_ALPHABET.length)];
193
+ }
194
+ return code;
195
+ }
196
+ async function postJson(url, body) {
197
+ const response = await fetch(url, {
198
+ method: "POST",
199
+ headers: { "Content-Type": "application/json" },
200
+ body: JSON.stringify(body)
201
+ });
202
+ const data = await response.json().catch(() => ({}));
203
+ if (!response.ok) throw new Error(data.error ?? `Request to ${url} failed with status ${response.status}`);
204
+ return data;
205
+ }
206
+ var POLL_INTERVAL_MS = 4e3;
207
+ var POLL_TIMEOUT_MS = 15 * 60 * 1e3;
208
+ async function requestApproval(options) {
209
+ const webUrl = resolveWebUrl();
210
+ const confirmationCode = generateConfirmationCode();
211
+ const { requestId } = await postJson(`${webUrl}/api/public/runner-approval/request`, {
212
+ runnerName: options.runnerName,
213
+ hostname: options.hostname,
214
+ confirmationCode,
215
+ expectedEmail: options.expectedEmail
216
+ });
217
+ console.log("");
218
+ console.log("Este runner precisa ser aprovado pela web antes de continuar.");
219
+ console.log(` C\xF3digo de confirma\xE7\xE3o: ${confirmationCode}`);
220
+ console.log(` Runner: ${options.runnerName} (${options.hostname})`);
221
+ console.log(` Abra ${webUrl}/perfil, encontre este runner em "Runners pendentes de aprova\xE7\xE3o" e digite o c\xF3digo acima.`);
222
+ console.log("Aguardando aprova\xE7\xE3o...");
223
+ console.log("");
224
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
225
+ let approved = false;
226
+ while (Date.now() < deadline) {
227
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
228
+ const statusUrl = `${webUrl}/api/public/runner-approval/status?requestId=${encodeURIComponent(requestId)}`;
229
+ const statusResponse = await fetch(statusUrl);
230
+ const { status } = await statusResponse.json();
231
+ if (status === "approved") {
232
+ approved = true;
233
+ break;
234
+ }
235
+ if (status === "expired" || !statusResponse.ok) {
236
+ throw new Error("A solicita\xE7\xE3o de aprova\xE7\xE3o expirou antes de ser aprovada. Rode `request-approval` de novo.");
237
+ }
238
+ }
239
+ if (!approved) {
240
+ throw new Error("Tempo esgotado esperando aprova\xE7\xE3o. Rode `request-approval` de novo.");
241
+ }
242
+ const { accessToken, refreshToken, email } = await postJson(
243
+ `${webUrl}/api/public/runner-approval/claim`,
244
+ { requestId, confirmationCode }
245
+ );
246
+ const supabase = createAuthedSupabaseClient();
247
+ const { error } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
248
+ if (error) throw error;
249
+ return { email };
250
+ }
251
+ async function recoverSession(config) {
252
+ const webUrl = resolveWebUrl();
253
+ let nonce;
254
+ try {
255
+ ({ nonce } = await postJson(`${webUrl}/api/public/runner-recovery/challenge`, {
256
+ runnerId: config.runnerId
257
+ }));
258
+ } catch (error2) {
259
+ throw new Error(
260
+ `Recupera\xE7\xE3o autom\xE1tica indispon\xEDvel para este runner (${error2 instanceof Error ? error2.message : error2}). Rode "agent-board-runner request-approval --email <seu-email>" para configur\xE1-lo (uma \xFAnica vez).`
261
+ );
262
+ }
263
+ const signature = sign(null, Buffer.from(nonce, "utf8"), createPrivateKey(config.recoveryPrivateKey)).toString("base64");
264
+ const { accessToken, refreshToken, email } = await postJson(
265
+ `${webUrl}/api/public/runner-recovery/verify`,
266
+ { runnerId: config.runnerId, signature }
267
+ );
268
+ const supabase = createAuthedSupabaseClient();
269
+ const { error } = await supabase.auth.setSession({ access_token: accessToken, refresh_token: refreshToken });
270
+ if (error) throw error;
271
+ console.log(`Sess\xE3o recuperada automaticamente (${email}) \u2014 sem interven\xE7\xE3o manual.`);
272
+ return { email };
273
+ }
274
+ async function ensureSession(config) {
275
+ const probe = createAuthedSupabaseClient();
276
+ const existing = await getSession(probe);
277
+ if (existing) return { client: probe, session: existing };
278
+ await recoverSession(config);
279
+ const client = createAuthedSupabaseClient();
280
+ const session = await requireSession(client);
281
+ return { client, session };
282
+ }
165
283
 
166
284
  // ../../packages/shared-types/dist/index.js
167
285
  var BOARD_COLUMNS = [
@@ -772,32 +890,33 @@ async function logActivity(supabase, params) {
772
890
  }
773
891
 
774
892
  // src/sessions.ts
775
- async function getRunningSessionForProject(supabase, projectId, ownerUserId) {
776
- const { data, error } = await supabase.from("sessions").select("id, card_id, agent_state, cards!card_id!inner(project_id, assigned_user_id)").eq("cards.project_id", projectId).eq("cards.assigned_user_id", ownerUserId).eq("status", "running").limit(1).maybeSingle();
893
+ async function getRunningSessionForProject(supabase, projectId, runnerId) {
894
+ const { data, error } = await supabase.from("sessions").select("id, card_id, agent_state, cards!card_id!inner(project_id)").eq("cards.project_id", projectId).eq("runner_id", runnerId).eq("status", "running").limit(1).maybeSingle();
777
895
  if (error) throw error;
778
896
  if (!data) return null;
779
897
  const row = data;
780
898
  return { id: row.id, cardId: row.card_id, agentState: row.agent_state };
781
899
  }
782
- async function listQueuedSessionsForRunner(supabase, ownerUserId) {
900
+ async function listQueuedSessionsForRunner(supabase, runnerId) {
783
901
  const { data, error } = await supabase.from("sessions").select(
784
- "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, prototype_first, prototype_url, prototype_approved)"
785
- ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
902
+ "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, title, instructions, file_references, plan_first, prerequisite_card_id, prototype_first, prototype_url, prototype_approved)"
903
+ ).eq("runner_id", runnerId).eq("status", "queued").order("created_at", { ascending: true });
786
904
  if (error) throw error;
787
905
  return data;
788
906
  }
789
- async function listPausedSessionsForRunner(supabase, ownerUserId) {
790
- 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");
907
+ async function listPausedSessionsForRunner(supabase, runnerId) {
908
+ const { data, error } = await supabase.from("sessions").select("card_id, cards!card_id!inner(project_id)").eq("runner_id", runnerId).eq("status", "paused");
791
909
  if (error) throw error;
792
910
  return data.map((row) => ({
793
911
  card_id: row.card_id,
794
912
  project_id: row.cards.project_id
795
913
  }));
796
914
  }
797
- async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
915
+ async function createSession(supabase, userId, cardId, runnerId, resumeClaudeSessionId) {
798
916
  const { data, error } = await supabase.from("sessions").insert({
799
917
  card_id: cardId,
800
918
  user_id: userId,
919
+ runner_id: runnerId,
801
920
  status: "queued",
802
921
  // Pre-set so spawnSessionPty takes the resume path (see its doc comment) instead of
803
922
  // starting a blank conversation and resending the card's instructions.
@@ -806,6 +925,11 @@ async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
806
925
  if (error) throw error;
807
926
  return data.id;
808
927
  }
928
+ async function getDefaultRunnerIdForUser(supabase, ownerUserId) {
929
+ const { data, error } = await supabase.from("runners").select("id").eq("user_id", ownerUserId).eq("is_default", true).maybeSingle();
930
+ if (error) throw error;
931
+ return data?.id ?? null;
932
+ }
809
933
  async function updateSession(supabase, sessionId, changes, activityContext) {
810
934
  const { error } = await supabase.from("sessions").update({
811
935
  ...changes.status !== void 0 ? { status: changes.status } : {},
@@ -828,11 +952,26 @@ async function updateSession(supabase, sessionId, changes, activityContext) {
828
952
  }
829
953
  }
830
954
  async function getActiveSessionForCard(supabase, cardId) {
831
- const { data, error } = await supabase.from("sessions").select("id, status, agent_state").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
955
+ const { data, error } = await supabase.from("sessions").select("id, status, agent_state, runner_id").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
832
956
  if (error) throw error;
833
957
  if (!data) return null;
834
958
  const row = data;
835
- return { id: row.id, status: row.status, agentState: row.agent_state };
959
+ return { id: row.id, status: row.status, agentState: row.agent_state, runnerId: row.runner_id };
960
+ }
961
+ var STALE_RUNNER_HEARTBEAT_MS = 8 * 6e4;
962
+ async function getRunnerHeartbeats(supabase, runnerIds) {
963
+ const uniqueIds = Array.from(new Set(runnerIds));
964
+ if (uniqueIds.length === 0) return /* @__PURE__ */ new Map();
965
+ const { data, error } = await supabase.from("runners").select("id, last_heartbeat_at").in("id", uniqueIds);
966
+ if (error) throw error;
967
+ return new Map(data.map((row) => [row.id, row.last_heartbeat_at]));
968
+ }
969
+ function isRunnerHeartbeatStale(lastHeartbeatAt) {
970
+ return lastHeartbeatAt === null || Date.now() - new Date(lastHeartbeatAt).getTime() > STALE_RUNNER_HEARTBEAT_MS;
971
+ }
972
+ async function claimReferenceRunner(supabase, projectId, runnerId) {
973
+ const { error } = await supabase.from("projects").update({ runner_id: runnerId }).eq("id", projectId);
974
+ if (error) throw error;
836
975
  }
837
976
  async function pauseRunningSession(supabase, sessionId, activityContext) {
838
977
  const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
@@ -887,7 +1026,7 @@ async function getProjectRepos(supabase, projectId) {
887
1026
  }
888
1027
  async function listCardsInColumn(supabase, projectId, column) {
889
1028
  const { data, error } = await supabase.from("cards").select(
890
- "id, project_id, title, instructions, column_name, assigned_user_id, deploy_error, file_references, preview_urls, custom_action_status, requeue_requested_at, restart_requested_at"
1029
+ "id, project_id, title, instructions, column_name, assigned_user_id, runner_id, deploy_error, file_references, preview_urls, custom_action_status, requeue_requested_at, restart_requested_at"
891
1030
  ).eq("project_id", projectId).eq("column_name", column);
892
1031
  if (error) throw error;
893
1032
  return data;
@@ -1481,7 +1620,7 @@ function ensureMcpServersTrusted(dirPath) {
1481
1620
  var SESSION_ID = "usage-monitor";
1482
1621
  var TICKET = randomUUID4();
1483
1622
  var SCRATCH_DIR = join8(tmpdir5(), "agent-board-usage-monitor");
1484
- var POLL_INTERVAL_MS = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
1623
+ var POLL_INTERVAL_MS2 = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
1485
1624
  var IDLE_MS = 2e3;
1486
1625
  var HARD_TIMEOUT_MS = 6e4;
1487
1626
  function usageMonitorSessionId() {
@@ -1538,7 +1677,7 @@ function startUsageMonitor() {
1538
1677
  mkdirSync3(SCRATCH_DIR, { recursive: true });
1539
1678
  ensureDirTrusted(SCRATCH_DIR);
1540
1679
  pollUsageOnce();
1541
- setInterval(pollUsageOnce, POLL_INTERVAL_MS);
1680
+ setInterval(pollUsageOnce, POLL_INTERVAL_MS2);
1542
1681
  }
1543
1682
 
1544
1683
  // src/filesServer.ts
@@ -2387,1086 +2526,1111 @@ async function ensureBranchWatchers(supabase, config) {
2387
2526
 
2388
2527
  // src/columnActions.ts
2389
2528
  import { exec } from "node:child_process";
2390
- import { join as join11 } from "node:path";
2529
+ import { join as join12 } from "node:path";
2391
2530
  import { promisify as promisify4 } from "node:util";
2392
2531
 
2393
- // src/vaultSecrets.ts
2394
- async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
2395
- try {
2396
- const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
2397
- if (error) throw error;
2398
- const rows = data ?? [];
2399
- const env = {};
2400
- for (const row of rows) env[row.name] = row.value;
2401
- if (rows.length > 0) {
2402
- logger.runner.info(
2403
- "VAULT",
2404
- `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
2405
- );
2406
- }
2407
- return env;
2408
- } catch (err) {
2409
- logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
2410
- return {};
2411
- }
2412
- }
2532
+ // src/orchestrator.ts
2533
+ import { randomUUID as randomUUID7 } from "node:crypto";
2413
2534
 
2414
- // src/columnActions.ts
2415
- var execAsync = promisify4(exec);
2416
- var OUTPUT_TAIL_CHARS = 4e3;
2417
- function tail(text) {
2418
- return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
2535
+ // src/agentSession.ts
2536
+ import { randomUUID as randomUUID6 } from "node:crypto";
2537
+ import { spawn as spawn3 } from "node-pty";
2538
+
2539
+ // src/sessionName.ts
2540
+ var MAX_LENGTH = 60;
2541
+ var FALLBACK_NAME = "card";
2542
+ function toSessionName(title) {
2543
+ const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2544
+ if (slug.length === 0) return FALLBACK_NAME;
2545
+ if (slug.length <= MAX_LENGTH) return slug;
2546
+ const truncated = slug.slice(0, MAX_LENGTH);
2547
+ const lastHyphen = truncated.lastIndexOf("-");
2548
+ return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
2419
2549
  }
2420
- function resolveTargets(project, children, repoRoot, column, timing) {
2421
- if (children.length > 0) {
2422
- return children.flatMap(
2423
- (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join11(repoRoot, child2.subpath), command: action.command, repoId: child2.id }))
2424
- );
2425
- }
2426
- return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command, repoId: null }));
2550
+
2551
+ // src/agentSession.ts
2552
+ var TUI_IDLE_MS = 1500;
2553
+ var TUI_READY_TIMEOUT_MS = 6e4;
2554
+ var SUBMIT_DELAY_MS = 150;
2555
+ var PASTE_START = "\x1B[200~";
2556
+ var PASTE_END = "\x1B[201~";
2557
+ var SHIFT_TAB = "\x1B[Z";
2558
+ var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
2559
+ var SHIFT_TAB_DELAY_MS = 400;
2560
+ var PLAN_MODE_INDICATOR = /plan mode on/i;
2561
+ function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2562
+ ensureDirTrusted(repoPath);
2563
+ ensureMcpServersTrusted(repoPath);
2564
+ const claudeSessionId = randomUUID6();
2565
+ const pty = spawn3(
2566
+ CLAUDE_BINARY,
2567
+ [
2568
+ "--dangerously-skip-permissions",
2569
+ "--model",
2570
+ "claude-sonnet-5",
2571
+ "--session-id",
2572
+ claudeSessionId,
2573
+ ...claudeCliSettingsArgs(),
2574
+ `/rename ${toSessionName(cardTitle)}`
2575
+ ],
2576
+ {
2577
+ name: "xterm-color",
2578
+ cols: 120,
2579
+ rows: 32,
2580
+ cwd: repoPath,
2581
+ env: statuslineEnv(sessionId, ticket)
2582
+ }
2583
+ );
2584
+ sendMessageWhenIdle(pty, cardId, instructions, planFirst);
2585
+ return { pty, claudeSessionId };
2427
2586
  }
2428
- var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
2429
- async function runCommand(target, extraEnv) {
2430
- try {
2431
- const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
2432
- return { ok: true, output: tail(`${stdout}${stderr}`) };
2433
- } catch (err) {
2434
- const { stdout, stderr } = err;
2435
- const message = err instanceof Error ? err.message : String(err);
2436
- return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
2437
- }
2587
+ function sendMessageWhenIdle(pty, cardId, message, planFirst) {
2588
+ let idleTimer;
2589
+ let deadlineTimer;
2590
+ let dataSubscription;
2591
+ let settled = false;
2592
+ const stopWaiting = () => {
2593
+ if (settled) return false;
2594
+ settled = true;
2595
+ clearTimeout(idleTimer);
2596
+ clearTimeout(deadlineTimer);
2597
+ dataSubscription?.dispose();
2598
+ return true;
2599
+ };
2600
+ const send = async () => {
2601
+ if (!stopWaiting()) return;
2602
+ if (planFirst) await enterPlanMode(pty, cardId);
2603
+ try {
2604
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2605
+ setTimeout(() => {
2606
+ try {
2607
+ pty.write("\r");
2608
+ } catch (err) {
2609
+ logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
2610
+ }
2611
+ }, SUBMIT_DELAY_MS);
2612
+ } catch (err) {
2613
+ logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
2614
+ }
2615
+ };
2616
+ deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
2617
+ dataSubscription = pty.onData(() => {
2618
+ clearTimeout(idleTimer);
2619
+ idleTimer = setTimeout(send, TUI_IDLE_MS);
2620
+ });
2621
+ pty.onExit(() => stopWaiting());
2438
2622
  }
2439
- var inFlightColumns = /* @__PURE__ */ new Set();
2440
- async function handleColumnEntered(supabase, config, card) {
2441
- const key = `${card.project_id}:${card.column_name}`;
2442
- if (inFlightColumns.has(key)) return;
2443
- inFlightColumns.add(key);
2623
+ async function enterPlanMode(pty, cardId) {
2624
+ let recentOutput = "";
2625
+ const subscription = pty.onData((chunk) => {
2626
+ recentOutput += normalizeTerminalOutput(chunk);
2627
+ });
2444
2628
  try {
2445
- const project = await getProject(supabase, card.project_id);
2446
- const children = await getProjectRepos(supabase, project.id);
2447
- const repoRoot = resolveRepoPath(config, project.repo_path);
2448
- const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
2449
- if (targets.length === 0) return;
2450
- const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
2451
- const cards = allCards.filter((candidate) => candidate.assigned_user_id === config.ownerUserId);
2452
- if (cards.length === 0) return;
2453
- await Promise.all(
2454
- cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
2455
- );
2456
- let failure = null;
2457
- for (const target of targets) {
2458
- const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
2459
- const result = await runCommand(target, secretsEnv);
2460
- if (!result.ok) {
2461
- failure = { label: target.label, output: result.output };
2462
- break;
2463
- }
2629
+ for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
2630
+ pty.write(SHIFT_TAB);
2631
+ await new Promise((resolve5) => setTimeout(resolve5, SHIFT_TAB_DELAY_MS));
2464
2632
  }
2465
- if (failure) {
2466
- const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
2467
- logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
2468
- await Promise.all(
2469
- cards.map(
2470
- (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
2471
- )
2472
- );
2473
- } else {
2474
- logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
2475
- await Promise.all(
2476
- cards.map(
2477
- (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
2478
- )
2633
+ if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
2634
+ logger.card.warn(
2635
+ cardId,
2636
+ `Sent ${SHIFT_TAB_PRESSES_TO_PLAN_MODE} Shift+Tab presses but never saw the plan-mode indicator \u2014 the session may not actually be in Plan Mode.`
2479
2637
  );
2480
2638
  }
2639
+ } catch (err) {
2640
+ logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
2481
2641
  } finally {
2482
- inFlightColumns.delete(key);
2483
- }
2484
- }
2485
- async function clearColumnActionStatus(supabase, card) {
2486
- if (card.custom_action_status === null) return;
2487
- await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
2488
- }
2489
- async function runAfterColumnActions(supabase, project, children, repoRoot, column) {
2490
- const targets = resolveTargets(project, children, repoRoot, column, "after");
2491
- for (const target of targets) {
2492
- const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
2493
- const result = await runCommand(target, secretsEnv);
2494
- if (!result.ok) {
2495
- return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
2496
- }
2642
+ subscription.dispose();
2497
2643
  }
2498
- return { ok: true };
2499
2644
  }
2500
- async function checkPendingColumnActions(supabase, config) {
2501
- for (const registered of config.registeredProjects) {
2502
- for (const column of BOARD_COLUMNS) {
2503
- const cards = await listCardsInColumn(supabase, registered.id, column);
2504
- const pending2 = cards.find(
2505
- (card) => card.assigned_user_id === config.ownerUserId && card.custom_action_status === null
2506
- );
2507
- if (pending2) await handleColumnEntered(supabase, config, pending2);
2508
- }
2645
+ function sendMessageToIdleSession(pty, cardId, message) {
2646
+ try {
2647
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2648
+ setTimeout(() => {
2649
+ try {
2650
+ pty.write("\r");
2651
+ } catch (err) {
2652
+ logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2653
+ }
2654
+ }, SUBMIT_DELAY_MS);
2655
+ } catch (err) {
2656
+ logger.card.error(cardId, "Failed to send a message to the idle session:", err);
2509
2657
  }
2510
2658
  }
2511
- async function handleDeployActionRerunRequested(supabase, config, project) {
2512
- const allCards = await listCardsInColumn(supabase, project.id, "deploy");
2513
- const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
2514
- if (cards.length === 0) {
2515
- logger.runner.info("COLUMN_ACTION", `Rerun requested for project ${project.id}'s "deploy" column but no card assigned to this runner's user is waiting there.`);
2516
- } else {
2517
- await handleColumnEntered(supabase, config, cards[0]);
2518
- }
2519
- await clearDeployActionRerunRequest(supabase, project.id);
2659
+ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2660
+ ensureDirTrusted(repoPath);
2661
+ ensureMcpServersTrusted(repoPath);
2662
+ return spawn3(
2663
+ CLAUDE_BINARY,
2664
+ [
2665
+ "--dangerously-skip-permissions",
2666
+ "--model",
2667
+ "claude-sonnet-5",
2668
+ "--resume",
2669
+ claudeSessionId,
2670
+ ...claudeCliSettingsArgs()
2671
+ ],
2672
+ {
2673
+ name: "xterm-color",
2674
+ cols: 120,
2675
+ rows: 32,
2676
+ cwd: repoPath,
2677
+ env: statuslineEnv(sessionId, ticket)
2678
+ }
2679
+ );
2520
2680
  }
2521
- async function checkPendingDeployActionReruns(supabase, config) {
2522
- for (const registered of config.registeredProjects) {
2523
- const project = await getProject(supabase, registered.id);
2524
- if (project.deploy_action_rerun_requested_at) {
2525
- await handleDeployActionRerunRequested(supabase, config, project);
2681
+
2682
+ // src/cardAttachments.ts
2683
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2684
+ import { tmpdir as tmpdir6 } from "node:os";
2685
+ import { extname, join as join11 } from "node:path";
2686
+ var ATTACHMENTS_ROOT = join11(tmpdir6(), "agent-board", "card-attachments");
2687
+ function sanitizeFileName(fileName) {
2688
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2689
+ }
2690
+ async function downloadCardAttachments(supabase, cardId) {
2691
+ const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
2692
+ if (error) {
2693
+ logger.card.error(cardId, "Failed to list attachments:", error.message);
2694
+ return [];
2695
+ }
2696
+ const rows = data ?? [];
2697
+ if (rows.length === 0) return [];
2698
+ const targetDir = join11(ATTACHMENTS_ROOT, cardId);
2699
+ await mkdir2(targetDir, { recursive: true });
2700
+ const paths = [];
2701
+ for (const row of rows) {
2702
+ const baseName = sanitizeFileName(row.file_name);
2703
+ const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2704
+ const localPath = join11(targetDir, `${row.id}-${fileName}`);
2705
+ try {
2706
+ const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2707
+ if (downloadError) throw downloadError;
2708
+ await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
2709
+ paths.push(localPath);
2710
+ } catch (err) {
2711
+ logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
2526
2712
  }
2527
2713
  }
2714
+ return paths;
2528
2715
  }
2716
+ function buildInstructionsWithAttachments(instructions, attachmentPaths) {
2717
+ if (attachmentPaths.length === 0) return instructions;
2718
+ const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
2719
+ return `${instructions}
2529
2720
 
2530
- // src/deploy.ts
2531
- import { join as join14 } from "node:path";
2721
+ Files attached to this card (use each one as part of the task):
2722
+ ${list}`;
2723
+ }
2532
2724
 
2533
- // src/devServer.ts
2534
- import { spawn as spawn4 } from "node:child_process";
2725
+ // src/fileReferences.ts
2726
+ function buildInstructionsWithFileReferences(instructions, fileReferences) {
2727
+ if (fileReferences.length === 0) return instructions;
2728
+ const list = fileReferences.map((path) => `- ${path}`).join("\n");
2729
+ return `${instructions}
2535
2730
 
2536
- // src/cloudflared.ts
2537
- import { spawn as spawn3 } from "node:child_process";
2538
- import { accessSync, constants } from "node:fs";
2539
- import { homedir as homedir5 } from "node:os";
2540
- import { delimiter, join as join12 } from "node:path";
2541
- import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
2542
- var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2543
- var STARTUP_TIMEOUT_MS = 2e4;
2544
- var IS_WINDOWS2 = process.platform === "win32";
2545
- var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
2546
- var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
2547
- function wellKnownDirectories() {
2548
- const home = homedir5();
2549
- if (IS_WINDOWS2) {
2550
- const candidates = [
2551
- process.env.ProgramFiles && join12(process.env.ProgramFiles, "cloudflared"),
2552
- process.env["ProgramFiles(x86)"] && join12(process.env["ProgramFiles(x86)"], "cloudflared"),
2553
- process.env.LOCALAPPDATA && join12(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2554
- join12(home, "scoop", "shims"),
2555
- process.env.ChocolateyInstall && join12(process.env.ChocolateyInstall, "bin")
2556
- ];
2557
- return candidates.filter((dir) => Boolean(dir));
2558
- }
2559
- return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join12(home, ".local", "bin")];
2731
+ Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
2732
+ ${list}`;
2560
2733
  }
2561
- function pathDirectories() {
2562
- return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
2734
+
2735
+ // src/mcpContextNote.ts
2736
+ function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2737
+ return `${instructions}
2738
+
2739
+ 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.`;
2563
2740
  }
2564
- function isExecutableFile(path) {
2565
- try {
2566
- accessSync(path, constants.X_OK);
2567
- return true;
2568
- } catch {
2569
- return false;
2570
- }
2741
+
2742
+ // src/prototypeGateNote.ts
2743
+ function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
2744
+ if (!prototypeFirst) return instructions;
2745
+ return `${instructions}
2746
+
2747
+ Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
2748
+
2749
+ 1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
2750
+ Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
2751
+ Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
2752
+ tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
2753
+ desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
2754
+ antes de publicar.
2755
+ 2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
2756
+ ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
2757
+ quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
2758
+ persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
2759
+ \`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
2760
+ para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
2761
+ aprovarem um resultado ainda.
2762
+ 3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
2763
+ Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
2764
+ add_card_comment manualmente tamb\xE9m, seria duplicado.
2765
+ 4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
2766
+ aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
2767
+ houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
2768
+ antes disso \xE9 desperd\xEDcio.
2769
+ 5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
2770
+ - **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
2771
+ caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
2772
+ banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
2773
+ suficiente para valer separar, quebre em cards novos (\`create_card\` +
2774
+ \`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
2775
+ prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
2776
+ pequeno.
2777
+ - **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
2778
+ anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
2779
+ \`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
2571
2780
  }
2572
- function findInstalledBinary() {
2573
- const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
2574
- const directories = [...pathDirectories(), ...wellKnownDirectories()];
2575
- const candidates = [...override ? [override] : [], ...directories.map((dir) => join12(dir, BINARY_NAME))];
2576
- return candidates.find(isExecutableFile) ?? null;
2781
+
2782
+ // src/usageResume.ts
2783
+ var RESET_BUFFER_MS = 3e4;
2784
+ var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
2785
+ var pending = /* @__PURE__ */ new Map();
2786
+ function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
2787
+ if (pending.has(sessionId)) return;
2788
+ void (async () => {
2789
+ const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
2790
+ logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
2791
+ return null;
2792
+ });
2793
+ const now = Date.now();
2794
+ const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
2795
+ const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
2796
+ const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
2797
+ logger.card.info(
2798
+ cardId,
2799
+ resetAt !== null ? `Usage limit hit \u2014 will send "continue" automatically at ${new Date(resetAt).toISOString()}.` : `Usage limit hit but no reset time known yet \u2014 will retry in ${Math.round(delay / 6e4)}min.`
2800
+ );
2801
+ const timer = setTimeout(() => {
2802
+ pending.delete(sessionId);
2803
+ sendMessageToIdleSession(pty, cardId, "continue");
2804
+ }, delay);
2805
+ timer.unref();
2806
+ pending.set(sessionId, timer);
2807
+ })();
2577
2808
  }
2578
- async function locateCloudflared() {
2579
- const installed = findInstalledBinary();
2580
- if (installed) return installed;
2581
- if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
2809
+ function cancelUsageResume(sessionId) {
2810
+ const timer = pending.get(sessionId);
2811
+ if (!timer) return;
2812
+ clearTimeout(timer);
2813
+ pending.delete(sessionId);
2814
+ }
2815
+
2816
+ // src/orchestrator.ts
2817
+ var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
2818
+ var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
2819
+ var inFlightCardIds = /* @__PURE__ */ new Set();
2820
+ var activeSessionByCard = /* @__PURE__ */ new Map();
2821
+ var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
2822
+ var dispatchingRunner = false;
2823
+ async function isEffectiveOwner(supabase, card, config) {
2824
+ if (card.assigned_user_id !== config.ownerUserId) return false;
2825
+ if (card.runner_id) return card.runner_id === config.runnerId;
2826
+ const defaultRunnerId = await getDefaultRunnerIdForUser(supabase, config.ownerUserId);
2827
+ if (defaultRunnerId) return defaultRunnerId === config.runnerId;
2828
+ return true;
2829
+ }
2830
+ async function scanForWork(supabase, config) {
2831
+ for (const project of config.registeredProjects) {
2832
+ await reconcileStaleSessions(supabase, config, project.id);
2833
+ const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
2834
+ if (error) {
2835
+ logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
2836
+ continue;
2837
+ }
2838
+ for (const card of cards ?? []) {
2839
+ void enqueueCard(supabase, config, card);
2840
+ }
2841
+ }
2842
+ void dispatchRunnerQueue(supabase, config);
2843
+ }
2844
+ async function enqueueCard(supabase, config, card) {
2845
+ if (inFlightCardIds.has(card.id)) return;
2846
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
2847
+ if (!registered) {
2848
+ logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
2849
+ return;
2850
+ }
2851
+ if (!await isEffectiveOwner(supabase, card, config)) return;
2852
+ inFlightCardIds.add(card.id);
2582
2853
  try {
2583
- logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
2584
- return await downloadCloudflared(bundledBinaryPath);
2585
- } catch (err) {
2586
- logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
2587
- return null;
2854
+ const active = await getActiveSessionForCard(supabase, card.id);
2855
+ if (active) {
2856
+ if (active.runnerId !== config.runnerId) {
2857
+ const holderHeartbeat = active.runnerId === null ? null : (await getRunnerHeartbeats(supabase, [active.runnerId])).get(active.runnerId) ?? null;
2858
+ if (active.runnerId !== null && !isRunnerHeartbeatStale(holderHeartbeat)) return;
2859
+ logger.card.warn(
2860
+ card.id,
2861
+ `Active session ${active.id} belongs to runner ${active.runnerId ?? "(none)"}, which looks offline \u2014 reclaiming for ${config.runnerId}.`
2862
+ );
2863
+ await updateSession(supabase, active.id, { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() });
2864
+ activeSessionByCard.delete(card.id);
2865
+ } else {
2866
+ return;
2867
+ }
2868
+ }
2869
+ await createQueuedSession(supabase, config, card);
2870
+ } finally {
2871
+ inFlightCardIds.delete(card.id);
2588
2872
  }
2873
+ await dispatchRunnerQueue(supabase, config);
2589
2874
  }
2590
- var lookup = null;
2591
- function resolveCloudflaredPath() {
2592
- lookup ??= locateCloudflared().then((path) => {
2593
- if (!path) lookup = null;
2594
- return path;
2595
- });
2596
- return lookup;
2875
+ async function createQueuedSession(supabase, config, card) {
2876
+ const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
2877
+ const sessionId = await createSession(supabase, config.ownerUserId, card.id, config.runnerId, resumeClaudeSessionId);
2878
+ logger.card.info(
2879
+ card.id,
2880
+ resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
2881
+ );
2597
2882
  }
2598
- async function startQuickTunnel(label, port) {
2599
- const executable = await resolveCloudflaredPath();
2600
- if (!executable) {
2601
- logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
2602
- return null;
2883
+ async function reconcileStaleSessions(supabase, config, projectId) {
2884
+ const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, column_name)").eq("cards.project_id", projectId).eq("runner_id", config.runnerId).in("status", ["queued", "running", "waiting_input", "paused"]).neq("cards.column_name", "desenvolvimento");
2885
+ if (error) {
2886
+ logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
2887
+ return;
2603
2888
  }
2604
- return new Promise((resolve5) => {
2605
- let child2;
2606
- try {
2607
- child2 = spawn3(executable, [
2608
- "tunnel",
2609
- "--url",
2610
- `http://localhost:${port}`,
2611
- "--http-host-header",
2612
- `localhost:${port}`
2613
- ]);
2614
- } catch (err) {
2615
- logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
2616
- resolve5(null);
2617
- return;
2618
- }
2619
- let settled = false;
2620
- const settle = (value) => {
2621
- if (settled) return;
2622
- settled = true;
2623
- clearTimeout(timer);
2624
- resolve5(value);
2625
- };
2626
- const timer = setTimeout(() => {
2627
- logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
2628
- settle(null);
2629
- }, STARTUP_TIMEOUT_MS);
2630
- const onOutput = (chunk) => {
2631
- if (settled) return;
2632
- const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
2633
- if (match) settle({ url: match[0], child: child2 });
2634
- };
2635
- child2.stdout?.on("data", onOutput);
2636
- child2.stderr?.on("data", onOutput);
2637
- child2.on("error", (err) => {
2638
- logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
2639
- settle(null);
2640
- });
2641
- child2.on("exit", (code) => {
2642
- logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
2643
- settle(null);
2644
- });
2645
- });
2646
- }
2647
- function toTunnelHostname(url) {
2648
- return new URL(url).host;
2649
- }
2650
-
2651
- // src/devServer.ts
2652
- var STARTUP_TIMEOUT_MS2 = 45e3;
2653
- var IS_WINDOWS3 = process.platform === "win32";
2654
- var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
2655
- var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
2656
- var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
2657
- function detectPortFromOutput(chunk) {
2658
- const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
2659
- const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
2660
- return match ? Number(match[1]) : null;
2661
- }
2662
- function killProcessTree(child2) {
2663
- if (child2.pid == null) return;
2664
- if (IS_WINDOWS3) {
2665
- spawn4("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
2666
- } else {
2667
- child2.kill();
2889
+ const stale = data ?? [];
2890
+ if (stale.length === 0) return;
2891
+ for (const { id: sessionId, card_id: cardId, cards } of stale) {
2892
+ intentionallyStoppedSessionIds.add(sessionId);
2893
+ stopPtySession(sessionId);
2894
+ cancelUsageResume(sessionId);
2895
+ activeSessionByCard.delete(cardId);
2896
+ await updateSession(
2897
+ supabase,
2898
+ sessionId,
2899
+ {
2900
+ status: "done",
2901
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
2902
+ // See stopCardSession's doc comment: "fim" is the authoritative completion signal for
2903
+ // dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
2904
+ ...cards.column_name === "fim" ? { agentState: "done" } : {}
2905
+ },
2906
+ { projectId, cardId, userId: config.ownerUserId }
2907
+ );
2908
+ logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
2668
2909
  }
2910
+ await dispatchRunnerQueue(supabase, config);
2669
2911
  }
2670
- var previews = /* @__PURE__ */ new Map();
2671
- function ensurePreview(key, label, cwd, command2, cardId, extraEnv = {}) {
2672
- const existing = previews.get(key);
2673
- if (existing) {
2674
- existing.cardIds.add(cardId);
2675
- return existing.result;
2676
- }
2677
- let child2;
2912
+ async function dispatchRunnerQueue(supabase, config) {
2913
+ if (dispatchingRunner) return;
2914
+ dispatchingRunner = true;
2915
+ let queued = [];
2916
+ const promotedCardIds = /* @__PURE__ */ new Set();
2678
2917
  try {
2679
- child2 = spawn4(command2, { cwd, shell: true, windowsHide: true, env: { ...process.env, ...extraEnv } });
2680
- } catch (err) {
2681
- logger.runner.error("DEV_SERVER", `Failed to spawn "${command2}" in ${cwd}:`, err);
2682
- return Promise.resolve(null);
2683
- }
2684
- const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
2685
- entry.result = new Promise((resolve5) => {
2686
- let settled = false;
2687
- const settle = (value) => {
2688
- if (settled) return;
2689
- settled = true;
2690
- clearTimeout(timer);
2691
- if (!value) {
2692
- killProcessTree(child2);
2693
- previews.delete(key);
2918
+ queued = await listQueuedSessionsForRunner(supabase, config.runnerId);
2919
+ if (queued.length === 0) return;
2920
+ const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
2921
+ const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
2922
+ const runningProjectIds = /* @__PURE__ */ new Set();
2923
+ for (const project of config.registeredProjects) {
2924
+ const running = await getRunningSessionForProject(supabase, project.id, config.runnerId);
2925
+ if (!running) continue;
2926
+ if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
2927
+ await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
2928
+ continue;
2694
2929
  }
2695
- resolve5(value);
2696
- };
2697
- const timer = setTimeout(() => {
2698
- logger.runner.warn("DEV_SERVER", `"${command2}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
2699
- settle(null);
2700
- }, STARTUP_TIMEOUT_MS2);
2701
- const onOutput = async (chunk) => {
2702
- if (settled) return;
2703
- const port = detectPortFromOutput(chunk.toString("utf-8"));
2704
- if (port == null) return;
2705
- settled = true;
2706
- clearTimeout(timer);
2707
- const tunnel = await startQuickTunnel(`dev server ${key}`, port);
2708
- if (!tunnel) {
2709
- logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
2710
- killProcessTree(child2);
2711
- previews.delete(key);
2712
- resolve5(null);
2713
- return;
2930
+ runningProjectIds.add(project.id);
2931
+ }
2932
+ let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
2933
+ if (freeProjectSlots <= 0) return;
2934
+ const promotedThisPass = /* @__PURE__ */ new Set();
2935
+ for (const entry of queued) {
2936
+ const projectId = entry.cards.project_id;
2937
+ if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
2938
+ if (freeProjectSlots <= 0) break;
2939
+ const prerequisiteCardId = entry.cards.prerequisite_card_id;
2940
+ if (prerequisiteCardId) {
2941
+ const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
2942
+ if (prerequisiteAgentState !== "done") {
2943
+ logger.card.info(
2944
+ entry.card_id,
2945
+ `Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
2946
+ );
2947
+ continue;
2948
+ }
2714
2949
  }
2715
- entry.tunnelChild = tunnel.child;
2716
- logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
2717
- resolve5({ label, url: tunnel.url });
2718
- };
2719
- child2.stdout?.on("data", onOutput);
2720
- child2.stderr?.on("data", onOutput);
2721
- child2.on("error", (err) => {
2722
- logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command2} in ${cwd}):`, err);
2723
- settle(null);
2724
- });
2725
- child2.on("exit", (code) => {
2726
- logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
2727
- settle(null);
2728
- });
2729
- });
2730
- previews.set(key, entry);
2731
- return entry.result;
2732
- }
2733
- function releaseCard(cardId) {
2734
- for (const [key, entry] of previews) {
2735
- if (!entry.cardIds.delete(cardId)) continue;
2736
- if (entry.cardIds.size > 0) continue;
2737
- killProcessTree(entry.child);
2738
- entry.tunnelChild?.kill();
2739
- previews.delete(key);
2950
+ try {
2951
+ await promoteQueuedSession(supabase, config, entry);
2952
+ promotedThisPass.add(projectId);
2953
+ promotedCardIds.add(entry.card_id);
2954
+ freeProjectSlots -= 1;
2955
+ } catch (err) {
2956
+ logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
2957
+ }
2958
+ }
2959
+ } finally {
2960
+ await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
2961
+ (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
2962
+ );
2963
+ dispatchingRunner = false;
2740
2964
  }
2741
2965
  }
2742
- function stopProjectPreviews(projectId) {
2743
- const prefix = `${projectId}:`;
2744
- for (const [key, entry] of previews) {
2745
- if (!key.startsWith(prefix)) continue;
2746
- killProcessTree(entry.child);
2747
- entry.tunnelChild?.kill();
2748
- previews.delete(key);
2966
+ async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
2967
+ const paused = await listPausedSessionsForRunner(supabase, config.runnerId);
2968
+ const queuedByProject = /* @__PURE__ */ new Map();
2969
+ for (const entry of queued) {
2970
+ if (promotedCardIds.has(entry.card_id)) continue;
2971
+ const list = queuedByProject.get(entry.cards.project_id) ?? [];
2972
+ list.push(entry.card_id);
2973
+ queuedByProject.set(entry.cards.project_id, list);
2749
2974
  }
2750
- }
2751
- function stopAll() {
2752
- for (const key of [...previews.keys()]) {
2753
- const entry = previews.get(key);
2754
- if (!entry) continue;
2755
- killProcessTree(entry.child);
2756
- entry.tunnelChild?.kill();
2757
- previews.delete(key);
2975
+ const pausedByProject = /* @__PURE__ */ new Map();
2976
+ for (const { card_id: cardId, project_id: projectId } of paused) {
2977
+ const list = pausedByProject.get(projectId) ?? [];
2978
+ list.push(cardId);
2979
+ pausedByProject.set(projectId, list);
2758
2980
  }
2759
- }
2760
-
2761
- // src/git.ts
2762
- import { execFile as execFile4 } from "node:child_process";
2763
- import { promisify as promisify5 } from "node:util";
2764
- var execFileAsync4 = promisify5(execFile4);
2765
- async function pushBranchToMain(repoPath) {
2766
- try {
2767
- await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2768
- return { ok: true };
2769
- } catch (err) {
2770
- const stderr = err.stderr;
2771
- const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
2772
- return { ok: false, error };
2981
+ for (const project of config.registeredProjects) {
2982
+ publishQueueEvent(project.id, {
2983
+ queuedCardIds: queuedByProject.get(project.id) ?? [],
2984
+ pausedCardIds: pausedByProject.get(project.id) ?? []
2985
+ });
2773
2986
  }
2774
2987
  }
2775
-
2776
- // src/orchestrator.ts
2777
- import { randomUUID as randomUUID7 } from "node:crypto";
2778
-
2779
- // src/agentSession.ts
2780
- import { randomUUID as randomUUID6 } from "node:crypto";
2781
- import { spawn as spawn5 } from "node-pty";
2782
-
2783
- // src/sessionName.ts
2784
- var MAX_LENGTH = 60;
2785
- var FALLBACK_NAME = "card";
2786
- function toSessionName(title) {
2787
- const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2788
- if (slug.length === 0) return FALLBACK_NAME;
2789
- if (slug.length <= MAX_LENGTH) return slug;
2790
- const truncated = slug.slice(0, MAX_LENGTH);
2791
- const lastHyphen = truncated.lastIndexOf("-");
2792
- return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
2793
- }
2794
-
2795
- // src/agentSession.ts
2796
- var TUI_IDLE_MS = 1500;
2797
- var TUI_READY_TIMEOUT_MS = 6e4;
2798
- var SUBMIT_DELAY_MS = 150;
2799
- var PASTE_START = "\x1B[200~";
2800
- var PASTE_END = "\x1B[201~";
2801
- var SHIFT_TAB = "\x1B[Z";
2802
- var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
2803
- var SHIFT_TAB_DELAY_MS = 400;
2804
- var PLAN_MODE_INDICATOR = /plan mode on/i;
2805
- function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2806
- ensureDirTrusted(repoPath);
2807
- ensureMcpServersTrusted(repoPath);
2808
- const claudeSessionId = randomUUID6();
2809
- const pty = spawn5(
2810
- CLAUDE_BINARY,
2811
- [
2812
- "--dangerously-skip-permissions",
2813
- "--model",
2814
- "claude-sonnet-5",
2815
- "--session-id",
2816
- claudeSessionId,
2817
- ...claudeCliSettingsArgs(),
2818
- `/rename ${toSessionName(cardTitle)}`
2819
- ],
2820
- {
2821
- name: "xterm-color",
2822
- cols: 120,
2823
- rows: 32,
2824
- cwd: repoPath,
2825
- env: statuslineEnv(sessionId, ticket)
2826
- }
2827
- );
2828
- sendMessageWhenIdle(pty, cardId, instructions, planFirst);
2829
- return { pty, claudeSessionId };
2988
+ function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
2989
+ return prototypeApproved ? "O prot\xF3tipo foi revisado e aprovado \u2014 prossiga para a implementa\xE7\xE3o real, usando-o como refer\xEAncia." : `O prot\xF3tipo ainda n\xE3o foi aprovado${prototypeUrl ? ` (${prototypeUrl})` : ""} \u2014 confira os coment\xE1rios deixados nele (e, se for um prot\xF3tipo de marca\xE7\xE3o de pontos, os pontos salvos) e ajuste, publicando a nova vers\xE3o com publish_prototype. Depois, pare o turno de novo: a sess\xE3o ser\xE1 retomada quando houver outra revis\xE3o.`;
2830
2990
  }
2831
- function sendMessageWhenIdle(pty, cardId, message, planFirst) {
2832
- let idleTimer;
2833
- let deadlineTimer;
2834
- let dataSubscription;
2835
- let settled = false;
2836
- const stopWaiting = () => {
2837
- if (settled) return false;
2838
- settled = true;
2839
- clearTimeout(idleTimer);
2840
- clearTimeout(deadlineTimer);
2841
- dataSubscription?.dispose();
2842
- return true;
2843
- };
2844
- const send = async () => {
2845
- if (!stopWaiting()) return;
2846
- if (planFirst) await enterPlanMode(pty, cardId);
2847
- try {
2848
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2849
- setTimeout(() => {
2850
- try {
2851
- pty.write("\r");
2852
- } catch (err) {
2853
- logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
2854
- }
2855
- }, SUBMIT_DELAY_MS);
2856
- } catch (err) {
2857
- logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
2991
+ async function promoteQueuedSession(supabase, config, entry) {
2992
+ const existingHandle = getPtySession(entry.id);
2993
+ if (existingHandle) {
2994
+ setPtySessionReadOnly(entry.id, false);
2995
+ await updateSession(
2996
+ supabase,
2997
+ entry.id,
2998
+ { status: "running" },
2999
+ { projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
3000
+ );
3001
+ activeSessionByCard.set(entry.card_id, entry.id);
3002
+ logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
3003
+ if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
3004
+ sendMessageToIdleSession(
3005
+ existingHandle.pty,
3006
+ entry.card_id,
3007
+ buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
3008
+ );
2858
3009
  }
2859
- };
2860
- deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
2861
- dataSubscription = pty.onData(() => {
2862
- clearTimeout(idleTimer);
2863
- idleTimer = setTimeout(send, TUI_IDLE_MS);
2864
- });
2865
- pty.onExit(() => stopWaiting());
3010
+ return;
3011
+ }
3012
+ const spawned = await spawnSessionPty(supabase, config, entry);
3013
+ if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
2866
3014
  }
2867
- async function enterPlanMode(pty, cardId) {
2868
- let recentOutput = "";
2869
- const subscription = pty.onData((chunk) => {
2870
- recentOutput += normalizeTerminalOutput(chunk);
2871
- });
3015
+ async function spawnSessionPty(supabase, config, entry) {
3016
+ const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
3017
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
3018
+ if (!registered) {
3019
+ logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
3020
+ await updateSession(
3021
+ supabase,
3022
+ sessionId,
3023
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3024
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
3025
+ );
3026
+ return false;
3027
+ }
3028
+ const ticket = randomUUID7();
3029
+ let pty;
3030
+ let newClaudeSessionId = null;
3031
+ let resumeConversationMissing = false;
2872
3032
  try {
2873
- for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
2874
- pty.write(SHIFT_TAB);
2875
- await new Promise((resolve5) => setTimeout(resolve5, SHIFT_TAB_DELAY_MS));
2876
- }
2877
- if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
2878
- logger.card.warn(
3033
+ if (claudeSessionId) {
3034
+ pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
3035
+ pty.onData((chunk) => {
3036
+ if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
3037
+ });
3038
+ } else {
3039
+ const attachmentPaths = await downloadCardAttachments(supabase, cardId);
3040
+ const instructions = buildInstructionsWithMcpContext(
3041
+ buildInstructionsWithPrototypeGate(
3042
+ buildInstructionsWithFileReferences(
3043
+ buildInstructionsWithAttachments(card.instructions, attachmentPaths),
3044
+ card.file_references
3045
+ ),
3046
+ card.prototype_first
3047
+ ),
2879
3048
  cardId,
2880
- `Sent ${SHIFT_TAB_PRESSES_TO_PLAN_MODE} Shift+Tab presses but never saw the plan-mode indicator \u2014 the session may not actually be in Plan Mode.`
3049
+ card.project_id
2881
3050
  );
3051
+ const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
3052
+ pty = spawned.pty;
3053
+ newClaudeSessionId = spawned.claudeSessionId;
2882
3054
  }
2883
3055
  } catch (err) {
2884
- logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
2885
- } finally {
2886
- subscription.dispose();
2887
- }
2888
- }
2889
- function sendMessageToIdleSession(pty, cardId, message) {
2890
- try {
2891
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2892
- setTimeout(() => {
2893
- try {
2894
- pty.write("\r");
2895
- } catch (err) {
2896
- logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2897
- }
2898
- }, SUBMIT_DELAY_MS);
2899
- } catch (err) {
2900
- logger.card.error(cardId, "Failed to send a message to the idle session:", err);
3056
+ logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
3057
+ await updateSession(
3058
+ supabase,
3059
+ sessionId,
3060
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3061
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
3062
+ );
3063
+ return false;
2901
3064
  }
2902
- }
2903
- function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2904
- ensureDirTrusted(repoPath);
2905
- ensureMcpServersTrusted(repoPath);
2906
- return spawn5(
2907
- CLAUDE_BINARY,
2908
- [
2909
- "--dangerously-skip-permissions",
2910
- "--model",
2911
- "claude-sonnet-5",
2912
- "--resume",
2913
- claudeSessionId,
2914
- ...claudeCliSettingsArgs()
2915
- ],
3065
+ registerPtySession(sessionId, pty, ticket);
3066
+ activeSessionByCard.set(cardId, sessionId);
3067
+ publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
3068
+ const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
3069
+ await updateSession(
3070
+ supabase,
3071
+ sessionId,
2916
3072
  {
2917
- name: "xterm-color",
2918
- cols: 120,
2919
- rows: 32,
2920
- cwd: repoPath,
2921
- env: statuslineEnv(sessionId, ticket)
2922
- }
3073
+ status: "running",
3074
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3075
+ terminalTicket: ticket,
3076
+ terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
3077
+ // Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
3078
+ // already set) keeps whatever agent_state it had before the restart (e.g.
3079
+ // "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
3080
+ // freshly (re)constructed AgentActivityWatcher below will correct it the moment real
3081
+ // output gives it something conclusive to classify.
3082
+ ...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
3083
+ },
3084
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2923
3085
  );
2924
- }
2925
-
2926
- // src/cardAttachments.ts
2927
- import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2928
- import { tmpdir as tmpdir6 } from "node:os";
2929
- import { extname, join as join13 } from "node:path";
2930
- var ATTACHMENTS_ROOT = join13(tmpdir6(), "agent-board", "card-attachments");
2931
- function sanitizeFileName(fileName) {
2932
- return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2933
- }
2934
- async function downloadCardAttachments(supabase, cardId) {
2935
- const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
2936
- if (error) {
2937
- logger.card.error(cardId, "Failed to list attachments:", error.message);
2938
- return [];
2939
- }
2940
- const rows = data ?? [];
2941
- if (rows.length === 0) return [];
2942
- const targetDir = join13(ATTACHMENTS_ROOT, cardId);
2943
- await mkdir2(targetDir, { recursive: true });
2944
- const paths = [];
2945
- for (const row of rows) {
2946
- const baseName = sanitizeFileName(row.file_name);
2947
- const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2948
- const localPath = join13(targetDir, `${row.id}-${fileName}`);
2949
- try {
2950
- const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2951
- if (downloadError) throw downloadError;
2952
- await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
2953
- paths.push(localPath);
2954
- } catch (err) {
2955
- logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
3086
+ pty.onExit(({ exitCode }) => {
3087
+ const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
3088
+ activityWatcher.stop();
3089
+ unregisterAgentActivityWatcher(sessionId);
3090
+ cancelUsageResume(sessionId);
3091
+ activeSessionByCard.delete(cardId);
3092
+ if (resumeConversationMissing) {
3093
+ logger.card.warn(
3094
+ cardId,
3095
+ `Session ${sessionId}: --resume ${claudeSessionId} found no such conversation \u2014 clearing it so the next attempt starts a fresh one instead of repeating the same failure.`
3096
+ );
3097
+ void updateSession(
3098
+ supabase,
3099
+ sessionId,
3100
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
3101
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
3102
+ );
3103
+ return;
2956
3104
  }
2957
- }
2958
- return paths;
2959
- }
2960
- function buildInstructionsWithAttachments(instructions, attachmentPaths) {
2961
- if (attachmentPaths.length === 0) return instructions;
2962
- const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
2963
- return `${instructions}
2964
-
2965
- Files attached to this card (use each one as part of the task):
2966
- ${list}`;
3105
+ logger.card.warn(cardId, `Session exited (code ${exitCode}) \u2014 this only happens if the process crashed or was killed, the card doesn't auto-advance.`);
3106
+ if (!wasIntentional && exitCode !== 0) {
3107
+ publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
3108
+ }
3109
+ });
3110
+ return true;
2967
3111
  }
2968
-
2969
- // src/fileReferences.ts
2970
- function buildInstructionsWithFileReferences(instructions, fileReferences) {
2971
- if (fileReferences.length === 0) return instructions;
2972
- const list = fileReferences.map((path) => `- ${path}`).join("\n");
2973
- return `${instructions}
2974
-
2975
- Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
2976
- ${list}`;
3112
+ function publishAgentState(supabase, projectId, cardId, sessionId, state) {
3113
+ publishAgentStateEvent(projectId, cardId, sessionId, state);
3114
+ updateSession(supabase, sessionId, { agentState: state }).catch(
3115
+ (err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
3116
+ );
2977
3117
  }
2978
-
2979
- // src/mcpContextNote.ts
2980
- function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2981
- return `${instructions}
2982
-
2983
- 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.`;
3118
+ function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
3119
+ const watcher = new AgentActivityWatcher((state) => {
3120
+ publishAgentState(supabase, projectId, cardId, sessionId, state);
3121
+ if (state === "usage_limited") {
3122
+ scheduleUsageResume(supabase, config, cardId, sessionId, pty);
3123
+ } else {
3124
+ cancelUsageResume(sessionId);
3125
+ }
3126
+ if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
3127
+ });
3128
+ pty.onData((chunk) => watcher.push(chunk));
3129
+ registerAgentActivityWatcher(sessionId, watcher);
3130
+ return watcher;
2984
3131
  }
2985
-
2986
- // src/prototypeGateNote.ts
2987
- function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
2988
- if (!prototypeFirst) return instructions;
2989
- return `${instructions}
2990
-
2991
- Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
2992
-
2993
- 1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
2994
- Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
2995
- Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
2996
- tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
2997
- desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
2998
- antes de publicar.
2999
- 2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
3000
- ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
3001
- quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
3002
- persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
3003
- \`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
3004
- para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
3005
- aprovarem um resultado ainda.
3006
- 3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
3007
- Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
3008
- add_card_comment manualmente tamb\xE9m, seria duplicado.
3009
- 4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
3010
- aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
3011
- houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
3012
- antes disso \xE9 desperd\xEDcio.
3013
- 5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
3014
- - **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
3015
- caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
3016
- banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
3017
- suficiente para valer separar, quebre em cards novos (\`create_card\` +
3018
- \`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
3019
- prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
3020
- pequeno.
3021
- - **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
3022
- anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
3023
- \`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
3132
+ async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
3133
+ const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
3134
+ if (!paused) return;
3135
+ setPtySessionReadOnly(sessionId, true);
3136
+ logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done", another session needs this project's WIP slot).`);
3024
3137
  }
3025
-
3026
- // src/usageResume.ts
3027
- var RESET_BUFFER_MS = 3e4;
3028
- var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
3029
- var pending = /* @__PURE__ */ new Map();
3030
- function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
3031
- if (pending.has(sessionId)) return;
3032
- void (async () => {
3033
- const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
3034
- logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
3035
- return null;
3036
- });
3037
- const now = Date.now();
3038
- const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
3039
- const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
3040
- const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
3041
- logger.card.info(
3042
- cardId,
3043
- resetAt !== null ? `Usage limit hit \u2014 will send "continue" automatically at ${new Date(resetAt).toISOString()}.` : `Usage limit hit but no reset time known yet \u2014 will retry in ${Math.round(delay / 6e4)}min.`
3138
+ async function requeuePausedCard(supabase, config, card) {
3139
+ const active = await getActiveSessionForCard(supabase, card.id);
3140
+ if (!active) return;
3141
+ if (active.status === "paused") {
3142
+ await updateSession(
3143
+ supabase,
3144
+ active.id,
3145
+ { status: "queued" },
3146
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3044
3147
  );
3045
- const timer = setTimeout(() => {
3046
- pending.delete(sessionId);
3047
- sendMessageToIdleSession(pty, cardId, "continue");
3048
- }, delay);
3049
- timer.unref();
3050
- pending.set(sessionId, timer);
3051
- })();
3052
- }
3053
- function cancelUsageResume(sessionId) {
3054
- const timer = pending.get(sessionId);
3055
- if (!timer) return;
3056
- clearTimeout(timer);
3057
- pending.delete(sessionId);
3148
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3149
+ logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
3150
+ await dispatchRunnerQueue(supabase, config);
3151
+ return;
3152
+ }
3153
+ if (active.status === "running" && active.agentState === "waiting_prototype_review") {
3154
+ const handle = getPtySession(active.id);
3155
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3156
+ if (handle && !handle.exited) {
3157
+ sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
3158
+ logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
3159
+ }
3160
+ }
3058
3161
  }
3059
-
3060
- // src/orchestrator.ts
3061
- var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
3062
- var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
3063
- var inFlightCardIds = /* @__PURE__ */ new Set();
3064
- var activeSessionByCard = /* @__PURE__ */ new Map();
3065
- var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
3066
- var dispatchingRunner = false;
3067
- async function scanForWork(supabase, config) {
3162
+ async function reconcileOrphanedSessions(supabase, config) {
3068
3163
  for (const project of config.registeredProjects) {
3069
- await reconcileStaleSessions(supabase, config, project.id);
3070
- const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
3164
+ const { data, error } = await supabase.from("sessions").select(
3165
+ "id, card_id, claude_session_id, agent_state, created_at, status, cards!card_id!inner(project_id, title, instructions, file_references, plan_first)"
3166
+ ).eq("cards.project_id", project.id).eq("runner_id", config.runnerId).in("status", ["running", "paused"]);
3071
3167
  if (error) {
3072
- logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
3168
+ logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
3073
3169
  continue;
3074
3170
  }
3075
- for (const card of cards ?? []) {
3076
- void enqueueCard(supabase, config, card);
3171
+ for (const entry of data ?? []) {
3172
+ let spawned;
3173
+ try {
3174
+ spawned = await spawnSessionPty(supabase, config, entry);
3175
+ } catch (err) {
3176
+ logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
3177
+ continue;
3178
+ }
3179
+ if (!spawned) continue;
3180
+ if (entry.status === "paused") {
3181
+ await updateSession(
3182
+ supabase,
3183
+ entry.id,
3184
+ { status: "paused" },
3185
+ { projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
3186
+ );
3187
+ setPtySessionReadOnly(entry.id, true);
3188
+ logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
3189
+ } else {
3190
+ logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
3191
+ }
3077
3192
  }
3078
3193
  }
3079
- void dispatchRunnerQueue(supabase, config);
3080
3194
  }
3081
- async function enqueueCard(supabase, config, card) {
3082
- if (inFlightCardIds.has(card.id)) return;
3083
- const registered = config.registeredProjects.find((project) => project.id === card.project_id);
3084
- if (!registered) {
3085
- logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
3086
- return;
3087
- }
3088
- if (card.assigned_user_id !== config.ownerUserId) return;
3089
- inFlightCardIds.add(card.id);
3090
- try {
3091
- const active = await getActiveSessionForCard(supabase, card.id);
3092
- if (active) return;
3093
- await createQueuedSession(supabase, config, card);
3094
- } finally {
3095
- inFlightCardIds.delete(card.id);
3195
+ async function reconcileUsageLimitedSessions(supabase, config) {
3196
+ for (const project of config.registeredProjects) {
3197
+ const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id)").eq("cards.project_id", project.id).eq("runner_id", config.runnerId).eq("status", "running").eq("agent_state", "usage_limited");
3198
+ if (error) {
3199
+ logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
3200
+ continue;
3201
+ }
3202
+ for (const { id: sessionId, card_id: cardId } of data ?? []) {
3203
+ const handle = getPtySession(sessionId);
3204
+ if (!handle) continue;
3205
+ scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
3206
+ }
3096
3207
  }
3097
- await dispatchRunnerQueue(supabase, config);
3098
3208
  }
3099
- async function createQueuedSession(supabase, config, card) {
3100
- const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
3101
- const sessionId = await createSession(supabase, config.ownerUserId, card.id, resumeClaudeSessionId);
3102
- logger.card.info(
3103
- card.id,
3104
- resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
3209
+ async function stopCardSession(supabase, config, card) {
3210
+ const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3211
+ if (!sessionId) return;
3212
+ intentionallyStoppedSessionIds.add(sessionId);
3213
+ stopPtySession(sessionId);
3214
+ cancelUsageResume(sessionId);
3215
+ activeSessionByCard.delete(card.id);
3216
+ await updateSession(
3217
+ supabase,
3218
+ sessionId,
3219
+ {
3220
+ status: "done",
3221
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3222
+ ...card.column_name === "fim" ? { agentState: "done" } : {}
3223
+ },
3224
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3105
3225
  );
3226
+ logger.card.info(card.id, `Session ${sessionId} stopped.`);
3227
+ await dispatchRunnerQueue(supabase, config);
3106
3228
  }
3107
- async function reconcileStaleSessions(supabase, config, projectId) {
3108
- const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, column_name, assigned_user_id)").eq("cards.project_id", projectId).eq("cards.assigned_user_id", config.ownerUserId).in("status", ["queued", "running", "waiting_input", "paused"]).neq("cards.column_name", "desenvolvimento");
3109
- if (error) {
3110
- logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
3111
- return;
3112
- }
3113
- const stale = data ?? [];
3114
- if (stale.length === 0) return;
3115
- for (const { id: sessionId, card_id: cardId, cards } of stale) {
3229
+ async function restartCardSession(supabase, config, card) {
3230
+ const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3231
+ if (sessionId) {
3116
3232
  intentionallyStoppedSessionIds.add(sessionId);
3117
3233
  stopPtySession(sessionId);
3118
3234
  cancelUsageResume(sessionId);
3119
- activeSessionByCard.delete(cardId);
3235
+ activeSessionByCard.delete(card.id);
3120
3236
  await updateSession(
3121
3237
  supabase,
3122
3238
  sessionId,
3123
- {
3124
- status: "done",
3125
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3126
- // See stopCardSession's doc comment: "fim" is the authoritative completion signal for
3127
- // dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
3128
- ...cards.column_name === "fim" ? { agentState: "done" } : {}
3129
- },
3130
- { projectId, cardId, userId: config.ownerUserId }
3239
+ { status: "failed", claudeSessionId: null, endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3240
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3131
3241
  );
3132
- logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
3242
+ logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
3133
3243
  }
3134
- await dispatchRunnerQueue(supabase, config);
3244
+ await updateCard(supabase, card.id, { restartRequestedAt: null });
3245
+ await enqueueCard(supabase, config, card);
3135
3246
  }
3136
- async function dispatchRunnerQueue(supabase, config) {
3137
- if (dispatchingRunner) return;
3138
- dispatchingRunner = true;
3139
- let queued = [];
3140
- const promotedCardIds = /* @__PURE__ */ new Set();
3247
+
3248
+ // src/vaultSecrets.ts
3249
+ async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
3141
3250
  try {
3142
- queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
3143
- if (queued.length === 0) return;
3144
- const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
3145
- const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
3146
- const runningProjectIds = /* @__PURE__ */ new Set();
3147
- for (const project of config.registeredProjects) {
3148
- const running = await getRunningSessionForProject(supabase, project.id, config.ownerUserId);
3149
- if (!running) continue;
3150
- if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
3151
- await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
3152
- continue;
3251
+ const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
3252
+ if (error) throw error;
3253
+ const rows = data ?? [];
3254
+ const env = {};
3255
+ for (const row of rows) env[row.name] = row.value;
3256
+ if (rows.length > 0) {
3257
+ logger.runner.info(
3258
+ "VAULT",
3259
+ `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
3260
+ );
3261
+ }
3262
+ return env;
3263
+ } catch (err) {
3264
+ logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
3265
+ return {};
3266
+ }
3267
+ }
3268
+
3269
+ // src/columnActions.ts
3270
+ var execAsync = promisify4(exec);
3271
+ var OUTPUT_TAIL_CHARS = 4e3;
3272
+ function tail(text) {
3273
+ return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
3274
+ }
3275
+ function resolveTargets(project, children, repoRoot, column, timing) {
3276
+ if (children.length > 0) {
3277
+ return children.flatMap(
3278
+ (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join12(repoRoot, child2.subpath), command: action.command, repoId: child2.id }))
3279
+ );
3280
+ }
3281
+ return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command, repoId: null }));
3282
+ }
3283
+ var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
3284
+ async function runCommand(target, extraEnv) {
3285
+ try {
3286
+ const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
3287
+ return { ok: true, output: tail(`${stdout}${stderr}`) };
3288
+ } catch (err) {
3289
+ const { stdout, stderr } = err;
3290
+ const message = err instanceof Error ? err.message : String(err);
3291
+ return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
3292
+ }
3293
+ }
3294
+ var inFlightColumns = /* @__PURE__ */ new Set();
3295
+ async function handleColumnEntered(supabase, config, card) {
3296
+ const key = `${card.project_id}:${card.column_name}`;
3297
+ if (inFlightColumns.has(key)) return;
3298
+ inFlightColumns.add(key);
3299
+ try {
3300
+ const project = await getProject(supabase, card.project_id);
3301
+ const children = await getProjectRepos(supabase, project.id);
3302
+ const repoRoot = resolveRepoPath(config, project.repo_path);
3303
+ const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
3304
+ if (targets.length === 0) return;
3305
+ const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
3306
+ const cards = (await Promise.all(allCards.map(async (candidate) => await isEffectiveOwner(supabase, candidate, config) ? candidate : null))).filter((candidate) => candidate !== null);
3307
+ if (cards.length === 0) return;
3308
+ await Promise.all(
3309
+ cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
3310
+ );
3311
+ let failure = null;
3312
+ for (const target of targets) {
3313
+ const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
3314
+ const result = await runCommand(target, secretsEnv);
3315
+ if (!result.ok) {
3316
+ failure = { label: target.label, output: result.output };
3317
+ break;
3153
3318
  }
3154
- runningProjectIds.add(project.id);
3155
3319
  }
3156
- let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
3157
- if (freeProjectSlots <= 0) return;
3158
- const promotedThisPass = /* @__PURE__ */ new Set();
3159
- for (const entry of queued) {
3160
- const projectId = entry.cards.project_id;
3161
- if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
3162
- if (freeProjectSlots <= 0) break;
3163
- const prerequisiteCardId = entry.cards.prerequisite_card_id;
3164
- if (prerequisiteCardId) {
3165
- const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
3166
- if (prerequisiteAgentState !== "done") {
3167
- logger.card.info(
3168
- entry.card_id,
3169
- `Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
3170
- );
3171
- continue;
3320
+ if (failure) {
3321
+ const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
3322
+ logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
3323
+ await Promise.all(
3324
+ cards.map(
3325
+ (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
3326
+ )
3327
+ );
3328
+ } else {
3329
+ logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
3330
+ await Promise.all(
3331
+ cards.map(
3332
+ (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
3333
+ )
3334
+ );
3335
+ }
3336
+ } finally {
3337
+ inFlightColumns.delete(key);
3338
+ }
3339
+ }
3340
+ async function clearColumnActionStatus(supabase, card) {
3341
+ if (card.custom_action_status === null) return;
3342
+ await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
3343
+ }
3344
+ async function runAfterColumnActions(supabase, project, children, repoRoot, column) {
3345
+ const targets = resolveTargets(project, children, repoRoot, column, "after");
3346
+ for (const target of targets) {
3347
+ const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
3348
+ const result = await runCommand(target, secretsEnv);
3349
+ if (!result.ok) {
3350
+ return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
3351
+ }
3352
+ }
3353
+ return { ok: true };
3354
+ }
3355
+ async function checkPendingColumnActions(supabase, config) {
3356
+ for (const registered of config.registeredProjects) {
3357
+ for (const column of BOARD_COLUMNS) {
3358
+ const cards = await listCardsInColumn(supabase, registered.id, column);
3359
+ let pending2;
3360
+ for (const card of cards) {
3361
+ if (card.custom_action_status !== null) continue;
3362
+ if (await isEffectiveOwner(supabase, card, config)) {
3363
+ pending2 = card;
3364
+ break;
3172
3365
  }
3173
3366
  }
3174
- try {
3175
- await promoteQueuedSession(supabase, config, entry);
3176
- promotedThisPass.add(projectId);
3177
- promotedCardIds.add(entry.card_id);
3178
- freeProjectSlots -= 1;
3179
- } catch (err) {
3180
- logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
3181
- }
3367
+ if (pending2) await handleColumnEntered(supabase, config, pending2);
3182
3368
  }
3183
- } finally {
3184
- await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
3185
- (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3186
- );
3187
- dispatchingRunner = false;
3188
3369
  }
3189
3370
  }
3190
- async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
3191
- const paused = await listPausedSessionsForRunner(supabase, config.ownerUserId);
3192
- const queuedByProject = /* @__PURE__ */ new Map();
3193
- for (const entry of queued) {
3194
- if (promotedCardIds.has(entry.card_id)) continue;
3195
- const list = queuedByProject.get(entry.cards.project_id) ?? [];
3196
- list.push(entry.card_id);
3197
- queuedByProject.set(entry.cards.project_id, list);
3198
- }
3199
- const pausedByProject = /* @__PURE__ */ new Map();
3200
- for (const { card_id: cardId, project_id: projectId } of paused) {
3201
- const list = pausedByProject.get(projectId) ?? [];
3202
- list.push(cardId);
3203
- pausedByProject.set(projectId, list);
3204
- }
3205
- for (const project of config.registeredProjects) {
3206
- publishQueueEvent(project.id, {
3207
- queuedCardIds: queuedByProject.get(project.id) ?? [],
3208
- pausedCardIds: pausedByProject.get(project.id) ?? []
3209
- });
3371
+ async function handleDeployActionRerunRequested(supabase, config, project) {
3372
+ const allCards = await listCardsInColumn(supabase, project.id, "deploy");
3373
+ const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter((card) => card !== null);
3374
+ if (cards.length === 0) {
3375
+ logger.runner.info("COLUMN_ACTION", `Rerun requested for project ${project.id}'s "deploy" column but no card assigned to this runner's user is waiting there.`);
3376
+ } else {
3377
+ await handleColumnEntered(supabase, config, cards[0]);
3210
3378
  }
3379
+ await clearDeployActionRerunRequest(supabase, project.id);
3211
3380
  }
3212
- function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
3213
- return prototypeApproved ? "O prot\xF3tipo foi revisado e aprovado \u2014 prossiga para a implementa\xE7\xE3o real, usando-o como refer\xEAncia." : `O prot\xF3tipo ainda n\xE3o foi aprovado${prototypeUrl ? ` (${prototypeUrl})` : ""} \u2014 confira os coment\xE1rios deixados nele (e, se for um prot\xF3tipo de marca\xE7\xE3o de pontos, os pontos salvos) e ajuste, publicando a nova vers\xE3o com publish_prototype. Depois, pare o turno de novo: a sess\xE3o ser\xE1 retomada quando houver outra revis\xE3o.`;
3214
- }
3215
- async function promoteQueuedSession(supabase, config, entry) {
3216
- const existingHandle = getPtySession(entry.id);
3217
- if (existingHandle) {
3218
- setPtySessionReadOnly(entry.id, false);
3219
- await updateSession(
3220
- supabase,
3221
- entry.id,
3222
- { status: "running" },
3223
- { projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
3224
- );
3225
- activeSessionByCard.set(entry.card_id, entry.id);
3226
- logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
3227
- if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
3228
- sendMessageToIdleSession(
3229
- existingHandle.pty,
3230
- entry.card_id,
3231
- buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
3232
- );
3381
+ async function checkPendingDeployActionReruns(supabase, config) {
3382
+ for (const registered of config.registeredProjects) {
3383
+ const project = await getProject(supabase, registered.id);
3384
+ if (project.deploy_action_rerun_requested_at) {
3385
+ await handleDeployActionRerunRequested(supabase, config, project);
3233
3386
  }
3234
- return;
3235
3387
  }
3236
- const spawned = await spawnSessionPty(supabase, config, entry);
3237
- if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
3238
3388
  }
3239
- async function spawnSessionPty(supabase, config, entry) {
3240
- const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
3241
- const registered = config.registeredProjects.find((project) => project.id === card.project_id);
3242
- if (!registered) {
3243
- logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
3244
- await updateSession(
3245
- supabase,
3246
- sessionId,
3247
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3248
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3249
- );
3389
+
3390
+ // src/deploy.ts
3391
+ import { join as join14 } from "node:path";
3392
+
3393
+ // src/devServer.ts
3394
+ import { spawn as spawn5 } from "node:child_process";
3395
+
3396
+ // src/cloudflared.ts
3397
+ import { spawn as spawn4 } from "node:child_process";
3398
+ import { accessSync, constants } from "node:fs";
3399
+ import { homedir as homedir5 } from "node:os";
3400
+ import { delimiter, join as join13 } from "node:path";
3401
+ import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
3402
+ var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
3403
+ var STARTUP_TIMEOUT_MS = 2e4;
3404
+ var IS_WINDOWS2 = process.platform === "win32";
3405
+ var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
3406
+ var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
3407
+ function wellKnownDirectories() {
3408
+ const home = homedir5();
3409
+ if (IS_WINDOWS2) {
3410
+ const candidates = [
3411
+ process.env.ProgramFiles && join13(process.env.ProgramFiles, "cloudflared"),
3412
+ process.env["ProgramFiles(x86)"] && join13(process.env["ProgramFiles(x86)"], "cloudflared"),
3413
+ process.env.LOCALAPPDATA && join13(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
3414
+ join13(home, "scoop", "shims"),
3415
+ process.env.ChocolateyInstall && join13(process.env.ChocolateyInstall, "bin")
3416
+ ];
3417
+ return candidates.filter((dir) => Boolean(dir));
3418
+ }
3419
+ return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join13(home, ".local", "bin")];
3420
+ }
3421
+ function pathDirectories() {
3422
+ return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
3423
+ }
3424
+ function isExecutableFile(path) {
3425
+ try {
3426
+ accessSync(path, constants.X_OK);
3427
+ return true;
3428
+ } catch {
3250
3429
  return false;
3251
3430
  }
3252
- const ticket = randomUUID7();
3253
- let pty;
3254
- let newClaudeSessionId = null;
3255
- let resumeConversationMissing = false;
3431
+ }
3432
+ function findInstalledBinary() {
3433
+ const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
3434
+ const directories = [...pathDirectories(), ...wellKnownDirectories()];
3435
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join13(dir, BINARY_NAME))];
3436
+ return candidates.find(isExecutableFile) ?? null;
3437
+ }
3438
+ async function locateCloudflared() {
3439
+ const installed = findInstalledBinary();
3440
+ if (installed) return installed;
3441
+ if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
3256
3442
  try {
3257
- if (claudeSessionId) {
3258
- pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
3259
- pty.onData((chunk) => {
3260
- if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
3261
- });
3262
- } else {
3263
- const attachmentPaths = await downloadCardAttachments(supabase, cardId);
3264
- const instructions = buildInstructionsWithMcpContext(
3265
- buildInstructionsWithPrototypeGate(
3266
- buildInstructionsWithFileReferences(
3267
- buildInstructionsWithAttachments(card.instructions, attachmentPaths),
3268
- card.file_references
3269
- ),
3270
- card.prototype_first
3271
- ),
3272
- cardId,
3273
- card.project_id
3274
- );
3275
- const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
3276
- pty = spawned.pty;
3277
- newClaudeSessionId = spawned.claudeSessionId;
3278
- }
3443
+ logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
3444
+ return await downloadCloudflared(bundledBinaryPath);
3279
3445
  } catch (err) {
3280
- logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
3281
- await updateSession(
3282
- supabase,
3283
- sessionId,
3284
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3285
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3286
- );
3287
- return false;
3446
+ logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
3447
+ return null;
3288
3448
  }
3289
- registerPtySession(sessionId, pty, ticket);
3290
- activeSessionByCard.set(cardId, sessionId);
3291
- publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
3292
- const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
3293
- await updateSession(
3294
- supabase,
3295
- sessionId,
3296
- {
3297
- status: "running",
3298
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3299
- terminalTicket: ticket,
3300
- terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
3301
- // Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
3302
- // already set) keeps whatever agent_state it had before the restart (e.g.
3303
- // "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
3304
- // freshly (re)constructed AgentActivityWatcher below will correct it the moment real
3305
- // output gives it something conclusive to classify.
3306
- ...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
3307
- },
3308
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3309
- );
3310
- pty.onExit(({ exitCode }) => {
3311
- const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
3312
- activityWatcher.stop();
3313
- unregisterAgentActivityWatcher(sessionId);
3314
- cancelUsageResume(sessionId);
3315
- activeSessionByCard.delete(cardId);
3316
- if (resumeConversationMissing) {
3317
- logger.card.warn(
3318
- cardId,
3319
- `Session ${sessionId}: --resume ${claudeSessionId} found no such conversation \u2014 clearing it so the next attempt starts a fresh one instead of repeating the same failure.`
3320
- );
3321
- void updateSession(
3322
- supabase,
3323
- sessionId,
3324
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
3325
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3326
- );
3449
+ }
3450
+ var lookup = null;
3451
+ function resolveCloudflaredPath() {
3452
+ lookup ??= locateCloudflared().then((path) => {
3453
+ if (!path) lookup = null;
3454
+ return path;
3455
+ });
3456
+ return lookup;
3457
+ }
3458
+ async function startQuickTunnel(label, port) {
3459
+ const executable = await resolveCloudflaredPath();
3460
+ if (!executable) {
3461
+ logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
3462
+ return null;
3463
+ }
3464
+ return new Promise((resolve5) => {
3465
+ let child2;
3466
+ try {
3467
+ child2 = spawn4(executable, [
3468
+ "tunnel",
3469
+ "--url",
3470
+ `http://localhost:${port}`,
3471
+ "--http-host-header",
3472
+ `localhost:${port}`
3473
+ ]);
3474
+ } catch (err) {
3475
+ logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
3476
+ resolve5(null);
3327
3477
  return;
3328
3478
  }
3329
- logger.card.warn(cardId, `Session exited (code ${exitCode}) \u2014 this only happens if the process crashed or was killed, the card doesn't auto-advance.`);
3330
- if (!wasIntentional && exitCode !== 0) {
3331
- publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
3332
- }
3479
+ let settled = false;
3480
+ const settle = (value) => {
3481
+ if (settled) return;
3482
+ settled = true;
3483
+ clearTimeout(timer);
3484
+ resolve5(value);
3485
+ };
3486
+ const timer = setTimeout(() => {
3487
+ logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
3488
+ settle(null);
3489
+ }, STARTUP_TIMEOUT_MS);
3490
+ const onOutput = (chunk) => {
3491
+ if (settled) return;
3492
+ const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
3493
+ if (match) settle({ url: match[0], child: child2 });
3494
+ };
3495
+ child2.stdout?.on("data", onOutput);
3496
+ child2.stderr?.on("data", onOutput);
3497
+ child2.on("error", (err) => {
3498
+ logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
3499
+ settle(null);
3500
+ });
3501
+ child2.on("exit", (code) => {
3502
+ logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
3503
+ settle(null);
3504
+ });
3333
3505
  });
3334
- return true;
3335
3506
  }
3336
- function publishAgentState(supabase, projectId, cardId, sessionId, state) {
3337
- publishAgentStateEvent(projectId, cardId, sessionId, state);
3338
- updateSession(supabase, sessionId, { agentState: state }).catch(
3339
- (err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
3340
- );
3507
+ function toTunnelHostname(url) {
3508
+ return new URL(url).host;
3341
3509
  }
3342
- function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
3343
- const watcher = new AgentActivityWatcher((state) => {
3344
- publishAgentState(supabase, projectId, cardId, sessionId, state);
3345
- if (state === "usage_limited") {
3346
- scheduleUsageResume(supabase, config, cardId, sessionId, pty);
3347
- } else {
3348
- cancelUsageResume(sessionId);
3349
- }
3350
- if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
3351
- });
3352
- pty.onData((chunk) => watcher.push(chunk));
3353
- registerAgentActivityWatcher(sessionId, watcher);
3354
- return watcher;
3510
+
3511
+ // src/devServer.ts
3512
+ var STARTUP_TIMEOUT_MS2 = 45e3;
3513
+ var IS_WINDOWS3 = process.platform === "win32";
3514
+ var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
3515
+ var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
3516
+ var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
3517
+ function detectPortFromOutput(chunk) {
3518
+ const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
3519
+ const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
3520
+ return match ? Number(match[1]) : null;
3355
3521
  }
3356
- async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
3357
- const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
3358
- if (!paused) return;
3359
- setPtySessionReadOnly(sessionId, true);
3360
- logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done", another session needs this project's WIP slot).`);
3522
+ function killProcessTree(child2) {
3523
+ if (child2.pid == null) return;
3524
+ if (IS_WINDOWS3) {
3525
+ spawn5("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
3526
+ } else {
3527
+ child2.kill();
3528
+ }
3361
3529
  }
3362
- async function requeuePausedCard(supabase, config, card) {
3363
- const active = await getActiveSessionForCard(supabase, card.id);
3364
- if (!active) return;
3365
- if (active.status === "paused") {
3366
- await updateSession(
3367
- supabase,
3368
- active.id,
3369
- { status: "queued" },
3370
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3371
- );
3372
- await updateCard(supabase, card.id, { requeueRequestedAt: null });
3373
- logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
3374
- await dispatchRunnerQueue(supabase, config);
3375
- return;
3530
+ var previews = /* @__PURE__ */ new Map();
3531
+ function ensurePreview(key, label, cwd, command2, cardId, extraEnv = {}) {
3532
+ const existing = previews.get(key);
3533
+ if (existing) {
3534
+ existing.cardIds.add(cardId);
3535
+ return existing.result;
3376
3536
  }
3377
- if (active.status === "running" && active.agentState === "waiting_prototype_review") {
3378
- const handle = getPtySession(active.id);
3379
- await updateCard(supabase, card.id, { requeueRequestedAt: null });
3380
- if (handle && !handle.exited) {
3381
- sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
3382
- logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
3383
- }
3537
+ let child2;
3538
+ try {
3539
+ child2 = spawn5(command2, { cwd, shell: true, windowsHide: true, env: { ...process.env, ...extraEnv } });
3540
+ } catch (err) {
3541
+ logger.runner.error("DEV_SERVER", `Failed to spawn "${command2}" in ${cwd}:`, err);
3542
+ return Promise.resolve(null);
3384
3543
  }
3385
- }
3386
- async function reconcileOrphanedSessions(supabase, config) {
3387
- for (const project of config.registeredProjects) {
3388
- const { data, error } = await supabase.from("sessions").select(
3389
- "id, card_id, claude_session_id, agent_state, created_at, status, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
3390
- ).eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).in("status", ["running", "paused"]);
3391
- if (error) {
3392
- logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
3393
- continue;
3394
- }
3395
- for (const entry of data ?? []) {
3396
- let spawned;
3397
- try {
3398
- spawned = await spawnSessionPty(supabase, config, entry);
3399
- } catch (err) {
3400
- logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
3401
- continue;
3544
+ const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
3545
+ entry.result = new Promise((resolve5) => {
3546
+ let settled = false;
3547
+ const settle = (value) => {
3548
+ if (settled) return;
3549
+ settled = true;
3550
+ clearTimeout(timer);
3551
+ if (!value) {
3552
+ killProcessTree(child2);
3553
+ previews.delete(key);
3402
3554
  }
3403
- if (!spawned) continue;
3404
- if (entry.status === "paused") {
3405
- await updateSession(
3406
- supabase,
3407
- entry.id,
3408
- { status: "paused" },
3409
- { projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
3410
- );
3411
- setPtySessionReadOnly(entry.id, true);
3412
- logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
3413
- } else {
3414
- logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
3555
+ resolve5(value);
3556
+ };
3557
+ const timer = setTimeout(() => {
3558
+ logger.runner.warn("DEV_SERVER", `"${command2}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
3559
+ settle(null);
3560
+ }, STARTUP_TIMEOUT_MS2);
3561
+ const onOutput = async (chunk) => {
3562
+ if (settled) return;
3563
+ const port = detectPortFromOutput(chunk.toString("utf-8"));
3564
+ if (port == null) return;
3565
+ settled = true;
3566
+ clearTimeout(timer);
3567
+ const tunnel = await startQuickTunnel(`dev server ${key}`, port);
3568
+ if (!tunnel) {
3569
+ logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
3570
+ killProcessTree(child2);
3571
+ previews.delete(key);
3572
+ resolve5(null);
3573
+ return;
3415
3574
  }
3416
- }
3575
+ entry.tunnelChild = tunnel.child;
3576
+ logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
3577
+ resolve5({ label, url: tunnel.url });
3578
+ };
3579
+ child2.stdout?.on("data", onOutput);
3580
+ child2.stderr?.on("data", onOutput);
3581
+ child2.on("error", (err) => {
3582
+ logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command2} in ${cwd}):`, err);
3583
+ settle(null);
3584
+ });
3585
+ child2.on("exit", (code) => {
3586
+ logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
3587
+ settle(null);
3588
+ });
3589
+ });
3590
+ previews.set(key, entry);
3591
+ return entry.result;
3592
+ }
3593
+ function releaseCard(cardId) {
3594
+ for (const [key, entry] of previews) {
3595
+ if (!entry.cardIds.delete(cardId)) continue;
3596
+ if (entry.cardIds.size > 0) continue;
3597
+ killProcessTree(entry.child);
3598
+ entry.tunnelChild?.kill();
3599
+ previews.delete(key);
3417
3600
  }
3418
3601
  }
3419
- async function reconcileUsageLimitedSessions(supabase, config) {
3420
- for (const project of config.registeredProjects) {
3421
- const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, assigned_user_id)").eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).eq("status", "running").eq("agent_state", "usage_limited");
3422
- if (error) {
3423
- logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
3424
- continue;
3425
- }
3426
- for (const { id: sessionId, card_id: cardId } of data ?? []) {
3427
- const handle = getPtySession(sessionId);
3428
- if (!handle) continue;
3429
- scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
3430
- }
3602
+ function stopProjectPreviews(projectId) {
3603
+ const prefix = `${projectId}:`;
3604
+ for (const [key, entry] of previews) {
3605
+ if (!key.startsWith(prefix)) continue;
3606
+ killProcessTree(entry.child);
3607
+ entry.tunnelChild?.kill();
3608
+ previews.delete(key);
3431
3609
  }
3432
3610
  }
3433
- async function stopCardSession(supabase, config, card) {
3434
- const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3435
- if (!sessionId) return;
3436
- intentionallyStoppedSessionIds.add(sessionId);
3437
- stopPtySession(sessionId);
3438
- cancelUsageResume(sessionId);
3439
- activeSessionByCard.delete(card.id);
3440
- await updateSession(
3441
- supabase,
3442
- sessionId,
3443
- {
3444
- status: "done",
3445
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3446
- ...card.column_name === "fim" ? { agentState: "done" } : {}
3447
- },
3448
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3449
- );
3450
- logger.card.info(card.id, `Session ${sessionId} stopped.`);
3451
- await dispatchRunnerQueue(supabase, config);
3611
+ function stopAll() {
3612
+ for (const key of [...previews.keys()]) {
3613
+ const entry = previews.get(key);
3614
+ if (!entry) continue;
3615
+ killProcessTree(entry.child);
3616
+ entry.tunnelChild?.kill();
3617
+ previews.delete(key);
3618
+ }
3452
3619
  }
3453
- async function restartCardSession(supabase, config, card) {
3454
- const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3455
- if (sessionId) {
3456
- intentionallyStoppedSessionIds.add(sessionId);
3457
- stopPtySession(sessionId);
3458
- cancelUsageResume(sessionId);
3459
- activeSessionByCard.delete(card.id);
3460
- await updateSession(
3461
- supabase,
3462
- sessionId,
3463
- { status: "failed", claudeSessionId: null, endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3464
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3465
- );
3466
- logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
3620
+
3621
+ // src/git.ts
3622
+ import { execFile as execFile4 } from "node:child_process";
3623
+ import { promisify as promisify5 } from "node:util";
3624
+ var execFileAsync4 = promisify5(execFile4);
3625
+ async function pushBranchToMain(repoPath) {
3626
+ try {
3627
+ await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
3628
+ return { ok: true };
3629
+ } catch (err) {
3630
+ const stderr = err.stderr;
3631
+ const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
3632
+ return { ok: false, error };
3467
3633
  }
3468
- await updateCard(supabase, card.id, { restartRequestedAt: null });
3469
- await enqueueCard(supabase, config, card);
3470
3634
  }
3471
3635
 
3472
3636
  // src/deploy.ts
@@ -3490,7 +3654,9 @@ async function handleDeployRequested(supabase, config, project) {
3490
3654
  }
3491
3655
  async function runDeploy(supabase, config, project) {
3492
3656
  const allCards = await listCardsInColumn(supabase, project.id, "deploy");
3493
- const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
3657
+ const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
3658
+ (card) => card !== null
3659
+ );
3494
3660
  if (cards.length === 0) {
3495
3661
  logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
3496
3662
  await clearProjectDeployRequest(supabase, project.id);
@@ -3570,10 +3736,24 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
3570
3736
  const localById = new Map(config.registeredProjects.map((project) => [project.id, project]));
3571
3737
  const linkedRunnerIds = await listProjectRunnerIds(supabase, config.runnerId);
3572
3738
  let changed = false;
3739
+ const otherReferenceRunnerIds = remoteProjects.filter((remote) => localById.has(remote.id) && remote.runner_id !== config.runnerId).map((remote) => remote.runner_id);
3740
+ const referenceHeartbeats = await getRunnerHeartbeats(supabase, otherReferenceRunnerIds);
3573
3741
  for (const remote of remoteProjects) {
3574
3742
  const local = localById.get(remote.id);
3575
3743
  if (local) {
3576
- const isReferenceRunner = remote.runner_id === config.runnerId;
3744
+ let isReferenceRunner = remote.runner_id === config.runnerId;
3745
+ if (!isReferenceRunner && isRunnerHeartbeatStale(referenceHeartbeats.get(remote.runner_id) ?? null)) {
3746
+ try {
3747
+ await claimReferenceRunner(supabase, remote.id, config.runnerId);
3748
+ isReferenceRunner = true;
3749
+ logger.runner.info(
3750
+ "PROJECT_SYNC",
3751
+ `Claimed reference runner for project ${remote.id} \u2014 previous one (${remote.runner_id}) looks offline.`
3752
+ );
3753
+ } catch (err) {
3754
+ logger.runner.error("PROJECT_SYNC", `Failed to claim reference runner for project ${remote.id}:`, err);
3755
+ }
3756
+ }
3577
3757
  if (local.isReferenceRunner !== isReferenceRunner) {
3578
3758
  local.isReferenceRunner = isReferenceRunner;
3579
3759
  changed = true;
@@ -3667,7 +3847,7 @@ async function checkPendingPreviews(supabase, config) {
3667
3847
  for (const registered of config.registeredProjects) {
3668
3848
  const cards = await listCardsInColumn(supabase, registered.id, "revisao");
3669
3849
  for (const card of cards) {
3670
- if (card.assigned_user_id !== config.ownerUserId) continue;
3850
+ if (!await isEffectiveOwner(supabase, card, config)) continue;
3671
3851
  if (card.preview_urls.length > 0) continue;
3672
3852
  await handleCardEnteredRevisao(supabase, config, card);
3673
3853
  }
@@ -3682,7 +3862,9 @@ async function handleDevServerRestartRequested(supabase, config, project) {
3682
3862
  }
3683
3863
  stopProjectPreviews(project.id);
3684
3864
  const allCards = await listCardsInColumn(supabase, project.id, "revisao");
3685
- const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
3865
+ const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
3866
+ (card) => card !== null
3867
+ );
3686
3868
  if (cards.length === 0) {
3687
3869
  logger.runner.info("REVIEW", `Dev server restart requested for project ${project.id} but no card assigned to this runner's user is in "revisao" \u2014 nothing to respawn.`);
3688
3870
  await clearDevServerRestartRequest(supabase, project.id);
@@ -3815,7 +3997,7 @@ async function startRunner() {
3815
3997
  logger.runner.error("BOOT", 'No runner set up yet. Run "agent-board-runner start --path <repos-root>" first.');
3816
3998
  process.exit(1);
3817
3999
  }
3818
- const config = applyRunnerName(applyAllowedRoot(loadedConfig));
4000
+ const config = ensureRecoveryKeypair(applyRunnerName(applyAllowedRoot(loadedConfig)));
3819
4001
  if (!config.allowedRootPath) {
3820
4002
  logger.runner.error(
3821
4003
  "BOOT",
@@ -3823,10 +4005,9 @@ async function startRunner() {
3823
4005
  );
3824
4006
  process.exit(1);
3825
4007
  }
3826
- const supabase = createAuthedSupabaseClient();
3827
- let session;
4008
+ let supabase, session;
3828
4009
  try {
3829
- session = await requireSession(supabase);
4010
+ ({ client: supabase, session } = await ensureSession(config));
3830
4011
  } catch (error) {
3831
4012
  logger.runner.error("BOOT", error instanceof Error ? error.message : String(error));
3832
4013
  process.exit(1);
@@ -3838,7 +4019,7 @@ async function startRunner() {
3838
4019
  if (event === "SIGNED_OUT") {
3839
4020
  logger.runner.error(
3840
4021
  "AUTH",
3841
- 'Supabase session was lost (refresh failed or was revoked) \u2014 every write from now on will fail row-level security checks. Run "agent-board-runner login" again and restart the runner.'
4022
+ "Supabase session was lost (refresh failed or was revoked) \u2014 exiting so the process manager restarts this runner; the next boot recovers automatically."
3842
4023
  );
3843
4024
  process.exit(1);
3844
4025
  }
@@ -3863,6 +4044,10 @@ async function startRunner() {
3863
4044
  user_id: config.ownerUserId,
3864
4045
  name: config.runnerName,
3865
4046
  auth_token_hash: config.authToken,
4047
+ // Re-sent on every boot with a real session — idempotent (same local key every time) and
4048
+ // what makes automatic recovery possible the next time this runner loses its session, see
4049
+ // ensureSession()/recoverSession() in auth.ts.
4050
+ recovery_public_key: config.recoveryPublicKey,
3866
4051
  version: RUNNER_VERSION
3867
4052
  });
3868
4053
  if (runnerError) {
@@ -4042,7 +4227,7 @@ function parseArgs(argv) {
4042
4227
  }
4043
4228
  function bootstrapConfig() {
4044
4229
  const existing = loadConfig();
4045
- if (existing) return existing;
4230
+ if (existing) return ensureRecoveryKeypair(existing);
4046
4231
  const identity = createRunnerIdentity();
4047
4232
  const config = {
4048
4233
  ...identity,
@@ -4059,6 +4244,22 @@ async function cmdLogin() {
4059
4244
  const { email } = await login();
4060
4245
  console.log(`Signed in as ${email}.`);
4061
4246
  }
4247
+ async function cmdRequestApproval(args2) {
4248
+ if (!args2.email) {
4249
+ console.error(
4250
+ "Use: agent-board-runner request-approval --email <seu-email-no-agent-board> [--name <nome-do-runner>]\nSem --email, ningu\xE9m saberia de quem \xE9 esta solicita\xE7\xE3o na tela de aprova\xE7\xE3o."
4251
+ );
4252
+ process.exit(1);
4253
+ }
4254
+ const config = bootstrapConfig();
4255
+ const { email } = await requestApproval({
4256
+ runnerName: args2.name ?? config.runnerName,
4257
+ hostname: hostname2(),
4258
+ expectedEmail: args2.email
4259
+ });
4260
+ console.log(`Aprovado! Sess\xE3o criada para ${email}.`);
4261
+ console.log("A partir de agora, se este runner perder a sess\xE3o, ele se recupera sozinho (sem precisar rodar isto de novo).");
4262
+ }
4062
4263
  async function cmdLogout() {
4063
4264
  await logout();
4064
4265
  console.log("Signed out.");
@@ -4089,6 +4290,9 @@ switch (command) {
4089
4290
  case "login":
4090
4291
  cmdLogin().catch(fail);
4091
4292
  break;
4293
+ case "request-approval":
4294
+ cmdRequestApproval(args).catch(fail);
4295
+ break;
4092
4296
  case "logout":
4093
4297
  cmdLogout().catch(fail);
4094
4298
  break;
@@ -4099,8 +4303,11 @@ switch (command) {
4099
4303
  cmdStart(args).catch(fail);
4100
4304
  break;
4101
4305
  default:
4102
- console.log("Usage: agent-board-runner <login|logout|whoami|start>");
4306
+ console.log("Usage: agent-board-runner <login|request-approval|logout|whoami|start>");
4103
4307
  console.log(" login sign in with Google");
4308
+ console.log(" request-approval --email <email> [--name <nome>]");
4309
+ console.log(" login sem navegador: aprove pela web (AGENT_BOARD_WEB_URL precisa estar configurada)");
4310
+ console.log(" s\xF3 precisa ser rodado uma vez \u2014 depois disso, `start` recupera a sess\xE3o sozinho se ela cair");
4104
4311
  console.log(" logout sign out and clear the local session");
4105
4312
  console.log(" whoami show the signed-in account");
4106
4313
  console.log(" start [--path <repos-root>] run the runner (repos root can also come from AGENT_BOARD_ALLOWED_ROOT)");