agent-board-runner 0.1.12 → 0.1.13

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 +998 -976
  2. package/dist/index.js +998 -976
  3. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -610,32 +610,33 @@ async function logActivity(supabase, params) {
610
610
  }
611
611
 
612
612
  // src/sessions.ts
613
- async function getRunningSessionForProject(supabase, projectId, ownerUserId) {
614
- 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();
613
+ async function getRunningSessionForProject(supabase, projectId, runnerId) {
614
+ 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();
615
615
  if (error) throw error;
616
616
  if (!data) return null;
617
617
  const row = data;
618
618
  return { id: row.id, cardId: row.card_id, agentState: row.agent_state };
619
619
  }
620
- async function listQueuedSessionsForRunner(supabase, ownerUserId) {
620
+ async function listQueuedSessionsForRunner(supabase, runnerId) {
621
621
  const { data, error } = await supabase.from("sessions").select(
622
- "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)"
623
- ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
622
+ "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)"
623
+ ).eq("runner_id", runnerId).eq("status", "queued").order("created_at", { ascending: true });
624
624
  if (error) throw error;
625
625
  return data;
626
626
  }
627
- async function listPausedSessionsForRunner(supabase, ownerUserId) {
628
- 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");
627
+ async function listPausedSessionsForRunner(supabase, runnerId) {
628
+ const { data, error } = await supabase.from("sessions").select("card_id, cards!card_id!inner(project_id)").eq("runner_id", runnerId).eq("status", "paused");
629
629
  if (error) throw error;
630
630
  return data.map((row) => ({
631
631
  card_id: row.card_id,
632
632
  project_id: row.cards.project_id
633
633
  }));
634
634
  }
635
- async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
635
+ async function createSession(supabase, userId, cardId, runnerId, resumeClaudeSessionId) {
636
636
  const { data, error } = await supabase.from("sessions").insert({
637
637
  card_id: cardId,
638
638
  user_id: userId,
639
+ runner_id: runnerId,
639
640
  status: "queued",
640
641
  // Pre-set so spawnSessionPty takes the resume path (see its doc comment) instead of
641
642
  // starting a blank conversation and resending the card's instructions.
@@ -644,6 +645,11 @@ async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
644
645
  if (error) throw error;
645
646
  return data.id;
646
647
  }
648
+ async function getDefaultRunnerIdForUser(supabase, ownerUserId) {
649
+ const { data, error } = await supabase.from("runners").select("id").eq("user_id", ownerUserId).eq("is_default", true).maybeSingle();
650
+ if (error) throw error;
651
+ return data?.id ?? null;
652
+ }
647
653
  async function updateSession(supabase, sessionId, changes, activityContext) {
648
654
  const { error } = await supabase.from("sessions").update({
649
655
  ...changes.status !== void 0 ? { status: changes.status } : {},
@@ -725,7 +731,7 @@ async function getProjectRepos(supabase, projectId) {
725
731
  }
726
732
  async function listCardsInColumn(supabase, projectId, column) {
727
733
  const { data, error } = await supabase.from("cards").select(
728
- "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"
734
+ "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"
729
735
  ).eq("project_id", projectId).eq("column_name", column);
730
736
  if (error) throw error;
731
737
  return data;
@@ -2326,1086 +2332,1098 @@ async function ensureBranchWatchers(supabase, config) {
2326
2332
 
2327
2333
  // src/columnActions.ts
2328
2334
  import { exec } from "node:child_process";
2329
- import { join as join11 } from "node:path";
2335
+ import { join as join12 } from "node:path";
2330
2336
  import { promisify as promisify4 } from "node:util";
2331
2337
 
2332
- // src/vaultSecrets.ts
2333
- async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
2334
- try {
2335
- const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
2336
- if (error) throw error;
2337
- const rows = data ?? [];
2338
- const env = {};
2339
- for (const row of rows) env[row.name] = row.value;
2340
- if (rows.length > 0) {
2341
- logger.runner.info(
2342
- "VAULT",
2343
- `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
2344
- );
2345
- }
2346
- return env;
2347
- } catch (err) {
2348
- logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
2349
- return {};
2350
- }
2351
- }
2338
+ // src/orchestrator.ts
2339
+ import { randomUUID as randomUUID6 } from "node:crypto";
2352
2340
 
2353
- // src/columnActions.ts
2354
- var execAsync = promisify4(exec);
2355
- var OUTPUT_TAIL_CHARS = 4e3;
2356
- function tail(text) {
2357
- return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
2341
+ // src/agentSession.ts
2342
+ import { randomUUID as randomUUID5 } from "node:crypto";
2343
+ import { spawn as spawn3 } from "node-pty";
2344
+
2345
+ // src/sessionName.ts
2346
+ var MAX_LENGTH = 60;
2347
+ var FALLBACK_NAME = "card";
2348
+ function toSessionName(title) {
2349
+ const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2350
+ if (slug.length === 0) return FALLBACK_NAME;
2351
+ if (slug.length <= MAX_LENGTH) return slug;
2352
+ const truncated = slug.slice(0, MAX_LENGTH);
2353
+ const lastHyphen = truncated.lastIndexOf("-");
2354
+ return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
2358
2355
  }
2359
- function resolveTargets(project, children, repoRoot, column, timing) {
2360
- if (children.length > 0) {
2361
- return children.flatMap(
2362
- (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 }))
2363
- );
2364
- }
2365
- return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command, repoId: null }));
2356
+
2357
+ // src/agentSession.ts
2358
+ var TUI_IDLE_MS = 1500;
2359
+ var TUI_READY_TIMEOUT_MS = 6e4;
2360
+ var SUBMIT_DELAY_MS = 150;
2361
+ var PASTE_START = "\x1B[200~";
2362
+ var PASTE_END = "\x1B[201~";
2363
+ var SHIFT_TAB = "\x1B[Z";
2364
+ var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
2365
+ var SHIFT_TAB_DELAY_MS = 400;
2366
+ var PLAN_MODE_INDICATOR = /plan mode on/i;
2367
+ function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2368
+ ensureDirTrusted(repoPath);
2369
+ ensureMcpServersTrusted(repoPath);
2370
+ const claudeSessionId = randomUUID5();
2371
+ const pty = spawn3(
2372
+ CLAUDE_BINARY,
2373
+ [
2374
+ "--dangerously-skip-permissions",
2375
+ "--model",
2376
+ "claude-sonnet-5",
2377
+ "--session-id",
2378
+ claudeSessionId,
2379
+ ...claudeCliSettingsArgs(),
2380
+ `/rename ${toSessionName(cardTitle)}`
2381
+ ],
2382
+ {
2383
+ name: "xterm-color",
2384
+ cols: 120,
2385
+ rows: 32,
2386
+ cwd: repoPath,
2387
+ env: statuslineEnv(sessionId, ticket)
2388
+ }
2389
+ );
2390
+ sendMessageWhenIdle(pty, cardId, instructions, planFirst);
2391
+ return { pty, claudeSessionId };
2366
2392
  }
2367
- var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
2368
- async function runCommand(target, extraEnv) {
2369
- try {
2370
- const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
2371
- return { ok: true, output: tail(`${stdout}${stderr}`) };
2372
- } catch (err) {
2373
- const { stdout, stderr } = err;
2374
- const message = err instanceof Error ? err.message : String(err);
2375
- return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
2376
- }
2393
+ function sendMessageWhenIdle(pty, cardId, message, planFirst) {
2394
+ let idleTimer;
2395
+ let deadlineTimer;
2396
+ let dataSubscription;
2397
+ let settled = false;
2398
+ const stopWaiting = () => {
2399
+ if (settled) return false;
2400
+ settled = true;
2401
+ clearTimeout(idleTimer);
2402
+ clearTimeout(deadlineTimer);
2403
+ dataSubscription?.dispose();
2404
+ return true;
2405
+ };
2406
+ const send = async () => {
2407
+ if (!stopWaiting()) return;
2408
+ if (planFirst) await enterPlanMode(pty, cardId);
2409
+ try {
2410
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2411
+ setTimeout(() => {
2412
+ try {
2413
+ pty.write("\r");
2414
+ } catch (err) {
2415
+ logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
2416
+ }
2417
+ }, SUBMIT_DELAY_MS);
2418
+ } catch (err) {
2419
+ logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
2420
+ }
2421
+ };
2422
+ deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
2423
+ dataSubscription = pty.onData(() => {
2424
+ clearTimeout(idleTimer);
2425
+ idleTimer = setTimeout(send, TUI_IDLE_MS);
2426
+ });
2427
+ pty.onExit(() => stopWaiting());
2377
2428
  }
2378
- var inFlightColumns = /* @__PURE__ */ new Set();
2379
- async function handleColumnEntered(supabase, config, card) {
2380
- const key = `${card.project_id}:${card.column_name}`;
2381
- if (inFlightColumns.has(key)) return;
2382
- inFlightColumns.add(key);
2429
+ async function enterPlanMode(pty, cardId) {
2430
+ let recentOutput = "";
2431
+ const subscription = pty.onData((chunk) => {
2432
+ recentOutput += normalizeTerminalOutput(chunk);
2433
+ });
2383
2434
  try {
2384
- const project = await getProject(supabase, card.project_id);
2385
- const children = await getProjectRepos(supabase, project.id);
2386
- const repoRoot = resolveRepoPath(config, project.repo_path);
2387
- const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
2388
- if (targets.length === 0) return;
2389
- const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
2390
- const cards = allCards.filter((candidate) => candidate.assigned_user_id === config.ownerUserId);
2391
- if (cards.length === 0) return;
2392
- await Promise.all(
2393
- cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
2394
- );
2395
- let failure = null;
2396
- for (const target of targets) {
2397
- const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
2398
- const result = await runCommand(target, secretsEnv);
2399
- if (!result.ok) {
2400
- failure = { label: target.label, output: result.output };
2401
- break;
2402
- }
2435
+ for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
2436
+ pty.write(SHIFT_TAB);
2437
+ await new Promise((resolve4) => setTimeout(resolve4, SHIFT_TAB_DELAY_MS));
2403
2438
  }
2404
- if (failure) {
2405
- const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
2406
- logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
2407
- await Promise.all(
2408
- cards.map(
2409
- (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
2410
- )
2411
- );
2412
- } else {
2413
- logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
2414
- await Promise.all(
2415
- cards.map(
2416
- (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
2417
- )
2439
+ if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
2440
+ logger.card.warn(
2441
+ cardId,
2442
+ `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.`
2418
2443
  );
2419
2444
  }
2445
+ } catch (err) {
2446
+ logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
2420
2447
  } finally {
2421
- inFlightColumns.delete(key);
2422
- }
2423
- }
2424
- async function clearColumnActionStatus(supabase, card) {
2425
- if (card.custom_action_status === null) return;
2426
- await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
2427
- }
2428
- async function runAfterColumnActions(supabase, project, children, repoRoot, column) {
2429
- const targets = resolveTargets(project, children, repoRoot, column, "after");
2430
- for (const target of targets) {
2431
- const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
2432
- const result = await runCommand(target, secretsEnv);
2433
- if (!result.ok) {
2434
- return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
2435
- }
2436
- }
2437
- return { ok: true };
2438
- }
2439
- async function checkPendingColumnActions(supabase, config) {
2440
- for (const registered of config.registeredProjects) {
2441
- for (const column of BOARD_COLUMNS) {
2442
- const cards = await listCardsInColumn(supabase, registered.id, column);
2443
- const pending2 = cards.find(
2444
- (card) => card.assigned_user_id === config.ownerUserId && card.custom_action_status === null
2445
- );
2446
- if (pending2) await handleColumnEntered(supabase, config, pending2);
2447
- }
2448
+ subscription.dispose();
2448
2449
  }
2449
2450
  }
2450
- async function handleDeployActionRerunRequested(supabase, config, project) {
2451
- const allCards = await listCardsInColumn(supabase, project.id, "deploy");
2452
- const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
2453
- if (cards.length === 0) {
2454
- 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.`);
2455
- } else {
2456
- await handleColumnEntered(supabase, config, cards[0]);
2451
+ function sendMessageToIdleSession(pty, cardId, message) {
2452
+ try {
2453
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2454
+ setTimeout(() => {
2455
+ try {
2456
+ pty.write("\r");
2457
+ } catch (err) {
2458
+ logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2459
+ }
2460
+ }, SUBMIT_DELAY_MS);
2461
+ } catch (err) {
2462
+ logger.card.error(cardId, "Failed to send a message to the idle session:", err);
2457
2463
  }
2458
- await clearDeployActionRerunRequest(supabase, project.id);
2459
2464
  }
2460
- async function checkPendingDeployActionReruns(supabase, config) {
2461
- for (const registered of config.registeredProjects) {
2462
- const project = await getProject(supabase, registered.id);
2463
- if (project.deploy_action_rerun_requested_at) {
2464
- await handleDeployActionRerunRequested(supabase, config, project);
2465
+ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2466
+ ensureDirTrusted(repoPath);
2467
+ ensureMcpServersTrusted(repoPath);
2468
+ return spawn3(
2469
+ CLAUDE_BINARY,
2470
+ [
2471
+ "--dangerously-skip-permissions",
2472
+ "--model",
2473
+ "claude-sonnet-5",
2474
+ "--resume",
2475
+ claudeSessionId,
2476
+ ...claudeCliSettingsArgs()
2477
+ ],
2478
+ {
2479
+ name: "xterm-color",
2480
+ cols: 120,
2481
+ rows: 32,
2482
+ cwd: repoPath,
2483
+ env: statuslineEnv(sessionId, ticket)
2465
2484
  }
2466
- }
2485
+ );
2467
2486
  }
2468
2487
 
2469
- // src/deploy.ts
2470
- import { join as join14 } from "node:path";
2471
-
2472
- // src/devServer.ts
2473
- import { spawn as spawn4 } from "node:child_process";
2474
-
2475
- // src/cloudflared.ts
2476
- import { spawn as spawn3 } from "node:child_process";
2477
- import { accessSync, constants } from "node:fs";
2478
- import { homedir as homedir5 } from "node:os";
2479
- import { delimiter, join as join12 } from "node:path";
2480
- import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
2481
- var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2482
- var STARTUP_TIMEOUT_MS = 2e4;
2483
- var IS_WINDOWS2 = process.platform === "win32";
2484
- var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
2485
- var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
2486
- function wellKnownDirectories() {
2487
- const home = homedir5();
2488
- if (IS_WINDOWS2) {
2489
- const candidates = [
2490
- process.env.ProgramFiles && join12(process.env.ProgramFiles, "cloudflared"),
2491
- process.env["ProgramFiles(x86)"] && join12(process.env["ProgramFiles(x86)"], "cloudflared"),
2492
- process.env.LOCALAPPDATA && join12(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2493
- join12(home, "scoop", "shims"),
2494
- process.env.ChocolateyInstall && join12(process.env.ChocolateyInstall, "bin")
2495
- ];
2496
- return candidates.filter((dir) => Boolean(dir));
2497
- }
2498
- return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join12(home, ".local", "bin")];
2499
- }
2500
- function pathDirectories() {
2501
- return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
2488
+ // src/cardAttachments.ts
2489
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2490
+ import { tmpdir as tmpdir6 } from "node:os";
2491
+ import { extname, join as join11 } from "node:path";
2492
+ var ATTACHMENTS_ROOT = join11(tmpdir6(), "agent-board", "card-attachments");
2493
+ function sanitizeFileName(fileName) {
2494
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2502
2495
  }
2503
- function isExecutableFile(path) {
2504
- try {
2505
- accessSync(path, constants.X_OK);
2506
- return true;
2507
- } catch {
2508
- return false;
2496
+ async function downloadCardAttachments(supabase, cardId) {
2497
+ const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
2498
+ if (error) {
2499
+ logger.card.error(cardId, "Failed to list attachments:", error.message);
2500
+ return [];
2501
+ }
2502
+ const rows = data ?? [];
2503
+ if (rows.length === 0) return [];
2504
+ const targetDir = join11(ATTACHMENTS_ROOT, cardId);
2505
+ await mkdir2(targetDir, { recursive: true });
2506
+ const paths = [];
2507
+ for (const row of rows) {
2508
+ const baseName = sanitizeFileName(row.file_name);
2509
+ const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2510
+ const localPath = join11(targetDir, `${row.id}-${fileName}`);
2511
+ try {
2512
+ const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2513
+ if (downloadError) throw downloadError;
2514
+ await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
2515
+ paths.push(localPath);
2516
+ } catch (err) {
2517
+ logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
2518
+ }
2509
2519
  }
2520
+ return paths;
2510
2521
  }
2511
- function findInstalledBinary() {
2512
- const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
2513
- const directories = [...pathDirectories(), ...wellKnownDirectories()];
2514
- const candidates = [...override ? [override] : [], ...directories.map((dir) => join12(dir, BINARY_NAME))];
2515
- return candidates.find(isExecutableFile) ?? null;
2522
+ function buildInstructionsWithAttachments(instructions, attachmentPaths) {
2523
+ if (attachmentPaths.length === 0) return instructions;
2524
+ const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
2525
+ return `${instructions}
2526
+
2527
+ Files attached to this card (use each one as part of the task):
2528
+ ${list}`;
2516
2529
  }
2517
- async function locateCloudflared() {
2518
- const installed = findInstalledBinary();
2519
- if (installed) return installed;
2520
- if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
2521
- try {
2522
- logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
2523
- return await downloadCloudflared(bundledBinaryPath);
2524
- } catch (err) {
2525
- logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
2526
- return null;
2527
- }
2530
+
2531
+ // src/fileReferences.ts
2532
+ function buildInstructionsWithFileReferences(instructions, fileReferences) {
2533
+ if (fileReferences.length === 0) return instructions;
2534
+ const list = fileReferences.map((path) => `- ${path}`).join("\n");
2535
+ return `${instructions}
2536
+
2537
+ Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
2538
+ ${list}`;
2528
2539
  }
2529
- var lookup = null;
2530
- function resolveCloudflaredPath() {
2531
- lookup ??= locateCloudflared().then((path) => {
2532
- if (!path) lookup = null;
2533
- return path;
2534
- });
2535
- return lookup;
2540
+
2541
+ // src/mcpContextNote.ts
2542
+ function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2543
+ return `${instructions}
2544
+
2545
+ 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.`;
2536
2546
  }
2537
- async function startQuickTunnel(label, port) {
2538
- const executable = await resolveCloudflaredPath();
2539
- if (!executable) {
2540
- logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
2541
- return null;
2542
- }
2543
- return new Promise((resolve4) => {
2544
- let child2;
2545
- try {
2546
- child2 = spawn3(executable, [
2547
- "tunnel",
2548
- "--url",
2549
- `http://localhost:${port}`,
2550
- "--http-host-header",
2551
- `localhost:${port}`
2552
- ]);
2553
- } catch (err) {
2554
- logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
2555
- resolve4(null);
2556
- return;
2557
- }
2558
- let settled = false;
2559
- const settle = (value) => {
2560
- if (settled) return;
2561
- settled = true;
2562
- clearTimeout(timer);
2563
- resolve4(value);
2564
- };
2565
- const timer = setTimeout(() => {
2566
- logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
2567
- settle(null);
2568
- }, STARTUP_TIMEOUT_MS);
2569
- const onOutput = (chunk) => {
2570
- if (settled) return;
2571
- const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
2572
- if (match) settle({ url: match[0], child: child2 });
2573
- };
2574
- child2.stdout?.on("data", onOutput);
2575
- child2.stderr?.on("data", onOutput);
2576
- child2.on("error", (err) => {
2577
- logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
2578
- settle(null);
2579
- });
2580
- child2.on("exit", (code) => {
2581
- logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
2582
- settle(null);
2547
+
2548
+ // src/prototypeGateNote.ts
2549
+ function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
2550
+ if (!prototypeFirst) return instructions;
2551
+ return `${instructions}
2552
+
2553
+ Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
2554
+
2555
+ 1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
2556
+ Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
2557
+ Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
2558
+ tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
2559
+ desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
2560
+ antes de publicar.
2561
+ 2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
2562
+ ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
2563
+ quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
2564
+ persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
2565
+ \`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
2566
+ para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
2567
+ aprovarem um resultado ainda.
2568
+ 3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
2569
+ Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
2570
+ add_card_comment manualmente tamb\xE9m, seria duplicado.
2571
+ 4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
2572
+ aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
2573
+ houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
2574
+ antes disso \xE9 desperd\xEDcio.
2575
+ 5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
2576
+ - **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
2577
+ caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
2578
+ banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
2579
+ suficiente para valer separar, quebre em cards novos (\`create_card\` +
2580
+ \`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
2581
+ prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
2582
+ pequeno.
2583
+ - **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
2584
+ anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
2585
+ \`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
2586
+ }
2587
+
2588
+ // src/usageResume.ts
2589
+ var RESET_BUFFER_MS = 3e4;
2590
+ var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
2591
+ var pending = /* @__PURE__ */ new Map();
2592
+ function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
2593
+ if (pending.has(sessionId)) return;
2594
+ void (async () => {
2595
+ const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
2596
+ logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
2597
+ return null;
2583
2598
  });
2584
- });
2599
+ const now = Date.now();
2600
+ const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
2601
+ const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
2602
+ const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
2603
+ logger.card.info(
2604
+ cardId,
2605
+ 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.`
2606
+ );
2607
+ const timer = setTimeout(() => {
2608
+ pending.delete(sessionId);
2609
+ sendMessageToIdleSession(pty, cardId, "continue");
2610
+ }, delay);
2611
+ timer.unref();
2612
+ pending.set(sessionId, timer);
2613
+ })();
2585
2614
  }
2586
- function toTunnelHostname(url) {
2587
- return new URL(url).host;
2615
+ function cancelUsageResume(sessionId) {
2616
+ const timer = pending.get(sessionId);
2617
+ if (!timer) return;
2618
+ clearTimeout(timer);
2619
+ pending.delete(sessionId);
2588
2620
  }
2589
2621
 
2590
- // src/devServer.ts
2591
- var STARTUP_TIMEOUT_MS2 = 45e3;
2592
- var IS_WINDOWS3 = process.platform === "win32";
2593
- var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
2594
- var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
2595
- var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
2596
- function detectPortFromOutput(chunk) {
2597
- const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
2598
- const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
2599
- return match ? Number(match[1]) : null;
2622
+ // src/orchestrator.ts
2623
+ var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
2624
+ var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
2625
+ var inFlightCardIds = /* @__PURE__ */ new Set();
2626
+ var activeSessionByCard = /* @__PURE__ */ new Map();
2627
+ var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
2628
+ var dispatchingRunner = false;
2629
+ async function isEffectiveOwner(supabase, card, config) {
2630
+ if (card.assigned_user_id !== config.ownerUserId) return false;
2631
+ if (card.runner_id) return card.runner_id === config.runnerId;
2632
+ const defaultRunnerId = await getDefaultRunnerIdForUser(supabase, config.ownerUserId);
2633
+ if (defaultRunnerId) return defaultRunnerId === config.runnerId;
2634
+ return true;
2600
2635
  }
2601
- function killProcessTree(child2) {
2602
- if (child2.pid == null) return;
2603
- if (IS_WINDOWS3) {
2604
- spawn4("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
2605
- } else {
2606
- child2.kill();
2636
+ async function scanForWork(supabase, config) {
2637
+ for (const project of config.registeredProjects) {
2638
+ await reconcileStaleSessions(supabase, config, project.id);
2639
+ const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
2640
+ if (error) {
2641
+ logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
2642
+ continue;
2643
+ }
2644
+ for (const card of cards ?? []) {
2645
+ void enqueueCard(supabase, config, card);
2646
+ }
2607
2647
  }
2648
+ void dispatchRunnerQueue(supabase, config);
2608
2649
  }
2609
- var previews = /* @__PURE__ */ new Map();
2610
- function ensurePreview(key, label, cwd, command, cardId, extraEnv = {}) {
2611
- const existing = previews.get(key);
2612
- if (existing) {
2613
- existing.cardIds.add(cardId);
2614
- return existing.result;
2615
- }
2616
- let child2;
2650
+ async function enqueueCard(supabase, config, card) {
2651
+ if (inFlightCardIds.has(card.id)) return;
2652
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
2653
+ if (!registered) {
2654
+ logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
2655
+ return;
2656
+ }
2657
+ if (!await isEffectiveOwner(supabase, card, config)) return;
2658
+ inFlightCardIds.add(card.id);
2617
2659
  try {
2618
- child2 = spawn4(command, { cwd, shell: true, windowsHide: true, env: { ...process.env, ...extraEnv } });
2619
- } catch (err) {
2620
- logger.runner.error("DEV_SERVER", `Failed to spawn "${command}" in ${cwd}:`, err);
2621
- return Promise.resolve(null);
2660
+ const active = await getActiveSessionForCard(supabase, card.id);
2661
+ if (active) return;
2662
+ await createQueuedSession(supabase, config, card);
2663
+ } finally {
2664
+ inFlightCardIds.delete(card.id);
2622
2665
  }
2623
- const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
2624
- entry.result = new Promise((resolve4) => {
2625
- let settled = false;
2626
- const settle = (value) => {
2627
- if (settled) return;
2628
- settled = true;
2629
- clearTimeout(timer);
2630
- if (!value) {
2631
- killProcessTree(child2);
2632
- previews.delete(key);
2633
- }
2634
- resolve4(value);
2635
- };
2636
- const timer = setTimeout(() => {
2637
- logger.runner.warn("DEV_SERVER", `"${command}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
2638
- settle(null);
2639
- }, STARTUP_TIMEOUT_MS2);
2640
- const onOutput = async (chunk) => {
2641
- if (settled) return;
2642
- const port = detectPortFromOutput(chunk.toString("utf-8"));
2643
- if (port == null) return;
2644
- settled = true;
2645
- clearTimeout(timer);
2646
- const tunnel = await startQuickTunnel(`dev server ${key}`, port);
2647
- if (!tunnel) {
2648
- logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
2649
- killProcessTree(child2);
2650
- previews.delete(key);
2651
- resolve4(null);
2652
- return;
2653
- }
2654
- entry.tunnelChild = tunnel.child;
2655
- logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
2656
- resolve4({ label, url: tunnel.url });
2657
- };
2658
- child2.stdout?.on("data", onOutput);
2659
- child2.stderr?.on("data", onOutput);
2660
- child2.on("error", (err) => {
2661
- logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command} in ${cwd}):`, err);
2662
- settle(null);
2663
- });
2664
- child2.on("exit", (code) => {
2665
- logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
2666
- settle(null);
2667
- });
2668
- });
2669
- previews.set(key, entry);
2670
- return entry.result;
2666
+ await dispatchRunnerQueue(supabase, config);
2671
2667
  }
2672
- function releaseCard(cardId) {
2673
- for (const [key, entry] of previews) {
2674
- if (!entry.cardIds.delete(cardId)) continue;
2675
- if (entry.cardIds.size > 0) continue;
2676
- killProcessTree(entry.child);
2677
- entry.tunnelChild?.kill();
2678
- previews.delete(key);
2679
- }
2668
+ async function createQueuedSession(supabase, config, card) {
2669
+ const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
2670
+ const sessionId = await createSession(supabase, config.ownerUserId, card.id, config.runnerId, resumeClaudeSessionId);
2671
+ logger.card.info(
2672
+ card.id,
2673
+ resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
2674
+ );
2680
2675
  }
2681
- function stopProjectPreviews(projectId) {
2682
- const prefix = `${projectId}:`;
2683
- for (const [key, entry] of previews) {
2684
- if (!key.startsWith(prefix)) continue;
2685
- killProcessTree(entry.child);
2686
- entry.tunnelChild?.kill();
2687
- previews.delete(key);
2676
+ async function reconcileStaleSessions(supabase, config, projectId) {
2677
+ 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");
2678
+ if (error) {
2679
+ logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
2680
+ return;
2688
2681
  }
2689
- }
2690
- function stopAll() {
2691
- for (const key of [...previews.keys()]) {
2692
- const entry = previews.get(key);
2693
- if (!entry) continue;
2694
- killProcessTree(entry.child);
2695
- entry.tunnelChild?.kill();
2696
- previews.delete(key);
2682
+ const stale = data ?? [];
2683
+ if (stale.length === 0) return;
2684
+ for (const { id: sessionId, card_id: cardId, cards } of stale) {
2685
+ intentionallyStoppedSessionIds.add(sessionId);
2686
+ stopPtySession(sessionId);
2687
+ cancelUsageResume(sessionId);
2688
+ activeSessionByCard.delete(cardId);
2689
+ await updateSession(
2690
+ supabase,
2691
+ sessionId,
2692
+ {
2693
+ status: "done",
2694
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
2695
+ // See stopCardSession's doc comment: "fim" is the authoritative completion signal for
2696
+ // dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
2697
+ ...cards.column_name === "fim" ? { agentState: "done" } : {}
2698
+ },
2699
+ { projectId, cardId, userId: config.ownerUserId }
2700
+ );
2701
+ logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
2697
2702
  }
2703
+ await dispatchRunnerQueue(supabase, config);
2698
2704
  }
2699
-
2700
- // src/git.ts
2701
- import { execFile as execFile4 } from "node:child_process";
2702
- import { promisify as promisify5 } from "node:util";
2703
- var execFileAsync4 = promisify5(execFile4);
2704
- async function pushBranchToMain(repoPath) {
2705
+ async function dispatchRunnerQueue(supabase, config) {
2706
+ if (dispatchingRunner) return;
2707
+ dispatchingRunner = true;
2708
+ let queued = [];
2709
+ const promotedCardIds = /* @__PURE__ */ new Set();
2705
2710
  try {
2706
- await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2707
- return { ok: true };
2708
- } catch (err) {
2709
- const stderr = err.stderr;
2710
- const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
2711
- return { ok: false, error };
2711
+ queued = await listQueuedSessionsForRunner(supabase, config.runnerId);
2712
+ if (queued.length === 0) return;
2713
+ const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
2714
+ const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
2715
+ const runningProjectIds = /* @__PURE__ */ new Set();
2716
+ for (const project of config.registeredProjects) {
2717
+ const running = await getRunningSessionForProject(supabase, project.id, config.runnerId);
2718
+ if (!running) continue;
2719
+ if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
2720
+ await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
2721
+ continue;
2722
+ }
2723
+ runningProjectIds.add(project.id);
2724
+ }
2725
+ let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
2726
+ if (freeProjectSlots <= 0) return;
2727
+ const promotedThisPass = /* @__PURE__ */ new Set();
2728
+ for (const entry of queued) {
2729
+ const projectId = entry.cards.project_id;
2730
+ if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
2731
+ if (freeProjectSlots <= 0) break;
2732
+ const prerequisiteCardId = entry.cards.prerequisite_card_id;
2733
+ if (prerequisiteCardId) {
2734
+ const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
2735
+ if (prerequisiteAgentState !== "done") {
2736
+ logger.card.info(
2737
+ entry.card_id,
2738
+ `Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
2739
+ );
2740
+ continue;
2741
+ }
2742
+ }
2743
+ try {
2744
+ await promoteQueuedSession(supabase, config, entry);
2745
+ promotedThisPass.add(projectId);
2746
+ promotedCardIds.add(entry.card_id);
2747
+ freeProjectSlots -= 1;
2748
+ } catch (err) {
2749
+ logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
2750
+ }
2751
+ }
2752
+ } finally {
2753
+ await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
2754
+ (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
2755
+ );
2756
+ dispatchingRunner = false;
2712
2757
  }
2713
2758
  }
2714
-
2715
- // src/orchestrator.ts
2716
- import { randomUUID as randomUUID6 } from "node:crypto";
2717
-
2718
- // src/agentSession.ts
2719
- import { randomUUID as randomUUID5 } from "node:crypto";
2720
- import { spawn as spawn5 } from "node-pty";
2721
-
2722
- // src/sessionName.ts
2723
- var MAX_LENGTH = 60;
2724
- var FALLBACK_NAME = "card";
2725
- function toSessionName(title) {
2726
- const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2727
- if (slug.length === 0) return FALLBACK_NAME;
2728
- if (slug.length <= MAX_LENGTH) return slug;
2729
- const truncated = slug.slice(0, MAX_LENGTH);
2730
- const lastHyphen = truncated.lastIndexOf("-");
2731
- return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
2759
+ async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
2760
+ const paused = await listPausedSessionsForRunner(supabase, config.runnerId);
2761
+ const queuedByProject = /* @__PURE__ */ new Map();
2762
+ for (const entry of queued) {
2763
+ if (promotedCardIds.has(entry.card_id)) continue;
2764
+ const list = queuedByProject.get(entry.cards.project_id) ?? [];
2765
+ list.push(entry.card_id);
2766
+ queuedByProject.set(entry.cards.project_id, list);
2767
+ }
2768
+ const pausedByProject = /* @__PURE__ */ new Map();
2769
+ for (const { card_id: cardId, project_id: projectId } of paused) {
2770
+ const list = pausedByProject.get(projectId) ?? [];
2771
+ list.push(cardId);
2772
+ pausedByProject.set(projectId, list);
2773
+ }
2774
+ for (const project of config.registeredProjects) {
2775
+ publishQueueEvent(project.id, {
2776
+ queuedCardIds: queuedByProject.get(project.id) ?? [],
2777
+ pausedCardIds: pausedByProject.get(project.id) ?? []
2778
+ });
2779
+ }
2732
2780
  }
2733
-
2734
- // src/agentSession.ts
2735
- var TUI_IDLE_MS = 1500;
2736
- var TUI_READY_TIMEOUT_MS = 6e4;
2737
- var SUBMIT_DELAY_MS = 150;
2738
- var PASTE_START = "\x1B[200~";
2739
- var PASTE_END = "\x1B[201~";
2740
- var SHIFT_TAB = "\x1B[Z";
2741
- var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
2742
- var SHIFT_TAB_DELAY_MS = 400;
2743
- var PLAN_MODE_INDICATOR = /plan mode on/i;
2744
- function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2745
- ensureDirTrusted(repoPath);
2746
- ensureMcpServersTrusted(repoPath);
2747
- const claudeSessionId = randomUUID5();
2748
- const pty = spawn5(
2749
- CLAUDE_BINARY,
2750
- [
2751
- "--dangerously-skip-permissions",
2752
- "--model",
2753
- "claude-sonnet-5",
2754
- "--session-id",
2755
- claudeSessionId,
2756
- ...claudeCliSettingsArgs(),
2757
- `/rename ${toSessionName(cardTitle)}`
2758
- ],
2759
- {
2760
- name: "xterm-color",
2761
- cols: 120,
2762
- rows: 32,
2763
- cwd: repoPath,
2764
- env: statuslineEnv(sessionId, ticket)
2765
- }
2766
- );
2767
- sendMessageWhenIdle(pty, cardId, instructions, planFirst);
2768
- return { pty, claudeSessionId };
2781
+ function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
2782
+ 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.`;
2769
2783
  }
2770
- function sendMessageWhenIdle(pty, cardId, message, planFirst) {
2771
- let idleTimer;
2772
- let deadlineTimer;
2773
- let dataSubscription;
2774
- let settled = false;
2775
- const stopWaiting = () => {
2776
- if (settled) return false;
2777
- settled = true;
2778
- clearTimeout(idleTimer);
2779
- clearTimeout(deadlineTimer);
2780
- dataSubscription?.dispose();
2781
- return true;
2782
- };
2783
- const send = async () => {
2784
- if (!stopWaiting()) return;
2785
- if (planFirst) await enterPlanMode(pty, cardId);
2786
- try {
2787
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2788
- setTimeout(() => {
2789
- try {
2790
- pty.write("\r");
2791
- } catch (err) {
2792
- logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
2793
- }
2794
- }, SUBMIT_DELAY_MS);
2795
- } catch (err) {
2796
- logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
2784
+ async function promoteQueuedSession(supabase, config, entry) {
2785
+ const existingHandle = getPtySession(entry.id);
2786
+ if (existingHandle) {
2787
+ setPtySessionReadOnly(entry.id, false);
2788
+ await updateSession(
2789
+ supabase,
2790
+ entry.id,
2791
+ { status: "running" },
2792
+ { projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
2793
+ );
2794
+ activeSessionByCard.set(entry.card_id, entry.id);
2795
+ logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
2796
+ if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
2797
+ sendMessageToIdleSession(
2798
+ existingHandle.pty,
2799
+ entry.card_id,
2800
+ buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
2801
+ );
2797
2802
  }
2798
- };
2799
- deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
2800
- dataSubscription = pty.onData(() => {
2801
- clearTimeout(idleTimer);
2802
- idleTimer = setTimeout(send, TUI_IDLE_MS);
2803
- });
2804
- pty.onExit(() => stopWaiting());
2803
+ return;
2804
+ }
2805
+ const spawned = await spawnSessionPty(supabase, config, entry);
2806
+ if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
2805
2807
  }
2806
- async function enterPlanMode(pty, cardId) {
2807
- let recentOutput = "";
2808
- const subscription = pty.onData((chunk) => {
2809
- recentOutput += normalizeTerminalOutput(chunk);
2810
- });
2808
+ async function spawnSessionPty(supabase, config, entry) {
2809
+ const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
2810
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
2811
+ if (!registered) {
2812
+ logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
2813
+ await updateSession(
2814
+ supabase,
2815
+ sessionId,
2816
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
2817
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2818
+ );
2819
+ return false;
2820
+ }
2821
+ const ticket = randomUUID6();
2822
+ let pty;
2823
+ let newClaudeSessionId = null;
2824
+ let resumeConversationMissing = false;
2811
2825
  try {
2812
- for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
2813
- pty.write(SHIFT_TAB);
2814
- await new Promise((resolve4) => setTimeout(resolve4, SHIFT_TAB_DELAY_MS));
2815
- }
2816
- if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
2817
- logger.card.warn(
2826
+ if (claudeSessionId) {
2827
+ pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
2828
+ pty.onData((chunk) => {
2829
+ if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
2830
+ });
2831
+ } else {
2832
+ const attachmentPaths = await downloadCardAttachments(supabase, cardId);
2833
+ const instructions = buildInstructionsWithMcpContext(
2834
+ buildInstructionsWithPrototypeGate(
2835
+ buildInstructionsWithFileReferences(
2836
+ buildInstructionsWithAttachments(card.instructions, attachmentPaths),
2837
+ card.file_references
2838
+ ),
2839
+ card.prototype_first
2840
+ ),
2818
2841
  cardId,
2819
- `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.`
2842
+ card.project_id
2820
2843
  );
2844
+ const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
2845
+ pty = spawned.pty;
2846
+ newClaudeSessionId = spawned.claudeSessionId;
2821
2847
  }
2822
2848
  } catch (err) {
2823
- logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
2824
- } finally {
2825
- subscription.dispose();
2826
- }
2827
- }
2828
- function sendMessageToIdleSession(pty, cardId, message) {
2829
- try {
2830
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2831
- setTimeout(() => {
2832
- try {
2833
- pty.write("\r");
2834
- } catch (err) {
2835
- logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2836
- }
2837
- }, SUBMIT_DELAY_MS);
2838
- } catch (err) {
2839
- logger.card.error(cardId, "Failed to send a message to the idle session:", err);
2849
+ logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
2850
+ await updateSession(
2851
+ supabase,
2852
+ sessionId,
2853
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
2854
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2855
+ );
2856
+ return false;
2840
2857
  }
2841
- }
2842
- function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2843
- ensureDirTrusted(repoPath);
2844
- ensureMcpServersTrusted(repoPath);
2845
- return spawn5(
2846
- CLAUDE_BINARY,
2847
- [
2848
- "--dangerously-skip-permissions",
2849
- "--model",
2850
- "claude-sonnet-5",
2851
- "--resume",
2852
- claudeSessionId,
2853
- ...claudeCliSettingsArgs()
2854
- ],
2858
+ registerPtySession(sessionId, pty, ticket);
2859
+ activeSessionByCard.set(cardId, sessionId);
2860
+ publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
2861
+ const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
2862
+ await updateSession(
2863
+ supabase,
2864
+ sessionId,
2855
2865
  {
2856
- name: "xterm-color",
2857
- cols: 120,
2858
- rows: 32,
2859
- cwd: repoPath,
2860
- env: statuslineEnv(sessionId, ticket)
2861
- }
2866
+ status: "running",
2867
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2868
+ terminalTicket: ticket,
2869
+ terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
2870
+ // Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
2871
+ // already set) keeps whatever agent_state it had before the restart (e.g.
2872
+ // "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
2873
+ // freshly (re)constructed AgentActivityWatcher below will correct it the moment real
2874
+ // output gives it something conclusive to classify.
2875
+ ...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
2876
+ },
2877
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2862
2878
  );
2863
- }
2864
-
2865
- // src/cardAttachments.ts
2866
- import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2867
- import { tmpdir as tmpdir6 } from "node:os";
2868
- import { extname, join as join13 } from "node:path";
2869
- var ATTACHMENTS_ROOT = join13(tmpdir6(), "agent-board", "card-attachments");
2870
- function sanitizeFileName(fileName) {
2871
- return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2872
- }
2873
- async function downloadCardAttachments(supabase, cardId) {
2874
- const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
2875
- if (error) {
2876
- logger.card.error(cardId, "Failed to list attachments:", error.message);
2877
- return [];
2878
- }
2879
- const rows = data ?? [];
2880
- if (rows.length === 0) return [];
2881
- const targetDir = join13(ATTACHMENTS_ROOT, cardId);
2882
- await mkdir2(targetDir, { recursive: true });
2883
- const paths = [];
2884
- for (const row of rows) {
2885
- const baseName = sanitizeFileName(row.file_name);
2886
- const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2887
- const localPath = join13(targetDir, `${row.id}-${fileName}`);
2888
- try {
2889
- const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2890
- if (downloadError) throw downloadError;
2891
- await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
2892
- paths.push(localPath);
2893
- } catch (err) {
2894
- logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
2879
+ pty.onExit(({ exitCode }) => {
2880
+ const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
2881
+ activityWatcher.stop();
2882
+ unregisterAgentActivityWatcher(sessionId);
2883
+ cancelUsageResume(sessionId);
2884
+ activeSessionByCard.delete(cardId);
2885
+ if (resumeConversationMissing) {
2886
+ logger.card.warn(
2887
+ cardId,
2888
+ `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.`
2889
+ );
2890
+ void updateSession(
2891
+ supabase,
2892
+ sessionId,
2893
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
2894
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2895
+ );
2896
+ return;
2895
2897
  }
2896
- }
2897
- return paths;
2898
- }
2899
- function buildInstructionsWithAttachments(instructions, attachmentPaths) {
2900
- if (attachmentPaths.length === 0) return instructions;
2901
- const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
2902
- return `${instructions}
2903
-
2904
- Files attached to this card (use each one as part of the task):
2905
- ${list}`;
2898
+ 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.`);
2899
+ if (!wasIntentional && exitCode !== 0) {
2900
+ publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
2901
+ }
2902
+ });
2903
+ return true;
2906
2904
  }
2907
-
2908
- // src/fileReferences.ts
2909
- function buildInstructionsWithFileReferences(instructions, fileReferences) {
2910
- if (fileReferences.length === 0) return instructions;
2911
- const list = fileReferences.map((path) => `- ${path}`).join("\n");
2912
- return `${instructions}
2913
-
2914
- Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
2915
- ${list}`;
2905
+ function publishAgentState(supabase, projectId, cardId, sessionId, state) {
2906
+ publishAgentStateEvent(projectId, cardId, sessionId, state);
2907
+ updateSession(supabase, sessionId, { agentState: state }).catch(
2908
+ (err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
2909
+ );
2916
2910
  }
2917
-
2918
- // src/mcpContextNote.ts
2919
- function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2920
- return `${instructions}
2921
-
2922
- 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.`;
2911
+ function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
2912
+ const watcher = new AgentActivityWatcher((state) => {
2913
+ publishAgentState(supabase, projectId, cardId, sessionId, state);
2914
+ if (state === "usage_limited") {
2915
+ scheduleUsageResume(supabase, config, cardId, sessionId, pty);
2916
+ } else {
2917
+ cancelUsageResume(sessionId);
2918
+ }
2919
+ if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
2920
+ });
2921
+ pty.onData((chunk) => watcher.push(chunk));
2922
+ registerAgentActivityWatcher(sessionId, watcher);
2923
+ return watcher;
2923
2924
  }
2924
-
2925
- // src/prototypeGateNote.ts
2926
- function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
2927
- if (!prototypeFirst) return instructions;
2928
- return `${instructions}
2929
-
2930
- Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
2931
-
2932
- 1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
2933
- Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
2934
- Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
2935
- tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
2936
- desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
2937
- antes de publicar.
2938
- 2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
2939
- ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
2940
- quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
2941
- persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
2942
- \`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
2943
- para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
2944
- aprovarem um resultado ainda.
2945
- 3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
2946
- Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
2947
- add_card_comment manualmente tamb\xE9m, seria duplicado.
2948
- 4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
2949
- aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
2950
- houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
2951
- antes disso \xE9 desperd\xEDcio.
2952
- 5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
2953
- - **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
2954
- caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
2955
- banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
2956
- suficiente para valer separar, quebre em cards novos (\`create_card\` +
2957
- \`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
2958
- prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
2959
- pequeno.
2960
- - **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
2961
- anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
2962
- \`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
2925
+ async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
2926
+ const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
2927
+ if (!paused) return;
2928
+ setPtySessionReadOnly(sessionId, true);
2929
+ logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done", another session needs this project's WIP slot).`);
2963
2930
  }
2964
-
2965
- // src/usageResume.ts
2966
- var RESET_BUFFER_MS = 3e4;
2967
- var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
2968
- var pending = /* @__PURE__ */ new Map();
2969
- function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
2970
- if (pending.has(sessionId)) return;
2971
- void (async () => {
2972
- const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
2973
- logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
2974
- return null;
2975
- });
2976
- const now = Date.now();
2977
- const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
2978
- const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
2979
- const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
2980
- logger.card.info(
2981
- cardId,
2982
- 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.`
2931
+ async function requeuePausedCard(supabase, config, card) {
2932
+ const active = await getActiveSessionForCard(supabase, card.id);
2933
+ if (!active) return;
2934
+ if (active.status === "paused") {
2935
+ await updateSession(
2936
+ supabase,
2937
+ active.id,
2938
+ { status: "queued" },
2939
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
2983
2940
  );
2984
- const timer = setTimeout(() => {
2985
- pending.delete(sessionId);
2986
- sendMessageToIdleSession(pty, cardId, "continue");
2987
- }, delay);
2988
- timer.unref();
2989
- pending.set(sessionId, timer);
2990
- })();
2991
- }
2992
- function cancelUsageResume(sessionId) {
2993
- const timer = pending.get(sessionId);
2994
- if (!timer) return;
2995
- clearTimeout(timer);
2996
- pending.delete(sessionId);
2941
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
2942
+ logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
2943
+ await dispatchRunnerQueue(supabase, config);
2944
+ return;
2945
+ }
2946
+ if (active.status === "running" && active.agentState === "waiting_prototype_review") {
2947
+ const handle = getPtySession(active.id);
2948
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
2949
+ if (handle && !handle.exited) {
2950
+ sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
2951
+ logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
2952
+ }
2953
+ }
2997
2954
  }
2998
-
2999
- // src/orchestrator.ts
3000
- var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
3001
- var RESUME_CONVERSATION_MISSING_PATTERN = /no conversation found with session id/i;
3002
- var inFlightCardIds = /* @__PURE__ */ new Set();
3003
- var activeSessionByCard = /* @__PURE__ */ new Map();
3004
- var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
3005
- var dispatchingRunner = false;
3006
- async function scanForWork(supabase, config) {
2955
+ async function reconcileOrphanedSessions(supabase, config) {
3007
2956
  for (const project of config.registeredProjects) {
3008
- await reconcileStaleSessions(supabase, config, project.id);
3009
- const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
2957
+ const { data, error } = await supabase.from("sessions").select(
2958
+ "id, card_id, claude_session_id, agent_state, created_at, status, cards!card_id!inner(project_id, title, instructions, file_references, plan_first)"
2959
+ ).eq("cards.project_id", project.id).eq("runner_id", config.runnerId).in("status", ["running", "paused"]);
3010
2960
  if (error) {
3011
- logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
2961
+ logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
3012
2962
  continue;
3013
2963
  }
3014
- for (const card of cards ?? []) {
3015
- void enqueueCard(supabase, config, card);
2964
+ for (const entry of data ?? []) {
2965
+ let spawned;
2966
+ try {
2967
+ spawned = await spawnSessionPty(supabase, config, entry);
2968
+ } catch (err) {
2969
+ logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
2970
+ continue;
2971
+ }
2972
+ if (!spawned) continue;
2973
+ if (entry.status === "paused") {
2974
+ await updateSession(
2975
+ supabase,
2976
+ entry.id,
2977
+ { status: "paused" },
2978
+ { projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
2979
+ );
2980
+ setPtySessionReadOnly(entry.id, true);
2981
+ logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
2982
+ } else {
2983
+ logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
2984
+ }
3016
2985
  }
3017
2986
  }
3018
- void dispatchRunnerQueue(supabase, config);
3019
2987
  }
3020
- async function enqueueCard(supabase, config, card) {
3021
- if (inFlightCardIds.has(card.id)) return;
3022
- const registered = config.registeredProjects.find((project) => project.id === card.project_id);
3023
- if (!registered) {
3024
- logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
3025
- return;
3026
- }
3027
- if (card.assigned_user_id !== config.ownerUserId) return;
3028
- inFlightCardIds.add(card.id);
3029
- try {
3030
- const active = await getActiveSessionForCard(supabase, card.id);
3031
- if (active) return;
3032
- await createQueuedSession(supabase, config, card);
3033
- } finally {
3034
- inFlightCardIds.delete(card.id);
2988
+ async function reconcileUsageLimitedSessions(supabase, config) {
2989
+ for (const project of config.registeredProjects) {
2990
+ 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");
2991
+ if (error) {
2992
+ logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
2993
+ continue;
2994
+ }
2995
+ for (const { id: sessionId, card_id: cardId } of data ?? []) {
2996
+ const handle = getPtySession(sessionId);
2997
+ if (!handle) continue;
2998
+ scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
2999
+ }
3035
3000
  }
3036
- await dispatchRunnerQueue(supabase, config);
3037
3001
  }
3038
- async function createQueuedSession(supabase, config, card) {
3039
- const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
3040
- const sessionId = await createSession(supabase, config.ownerUserId, card.id, resumeClaudeSessionId);
3041
- logger.card.info(
3042
- card.id,
3043
- resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
3002
+ async function stopCardSession(supabase, config, card) {
3003
+ const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3004
+ if (!sessionId) return;
3005
+ intentionallyStoppedSessionIds.add(sessionId);
3006
+ stopPtySession(sessionId);
3007
+ cancelUsageResume(sessionId);
3008
+ activeSessionByCard.delete(card.id);
3009
+ await updateSession(
3010
+ supabase,
3011
+ sessionId,
3012
+ {
3013
+ status: "done",
3014
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3015
+ ...card.column_name === "fim" ? { agentState: "done" } : {}
3016
+ },
3017
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3044
3018
  );
3019
+ logger.card.info(card.id, `Session ${sessionId} stopped.`);
3020
+ await dispatchRunnerQueue(supabase, config);
3045
3021
  }
3046
- async function reconcileStaleSessions(supabase, config, projectId) {
3047
- 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");
3048
- if (error) {
3049
- logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
3050
- return;
3051
- }
3052
- const stale = data ?? [];
3053
- if (stale.length === 0) return;
3054
- for (const { id: sessionId, card_id: cardId, cards } of stale) {
3022
+ async function restartCardSession(supabase, config, card) {
3023
+ const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3024
+ if (sessionId) {
3055
3025
  intentionallyStoppedSessionIds.add(sessionId);
3056
3026
  stopPtySession(sessionId);
3057
3027
  cancelUsageResume(sessionId);
3058
- activeSessionByCard.delete(cardId);
3028
+ activeSessionByCard.delete(card.id);
3059
3029
  await updateSession(
3060
3030
  supabase,
3061
3031
  sessionId,
3062
- {
3063
- status: "done",
3064
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3065
- // See stopCardSession's doc comment: "fim" is the authoritative completion signal for
3066
- // dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
3067
- ...cards.column_name === "fim" ? { agentState: "done" } : {}
3068
- },
3069
- { projectId, cardId, userId: config.ownerUserId }
3032
+ { status: "failed", claudeSessionId: null, endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3033
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3070
3034
  );
3071
- logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
3035
+ logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
3072
3036
  }
3073
- await dispatchRunnerQueue(supabase, config);
3037
+ await updateCard(supabase, card.id, { restartRequestedAt: null });
3038
+ await enqueueCard(supabase, config, card);
3074
3039
  }
3075
- async function dispatchRunnerQueue(supabase, config) {
3076
- if (dispatchingRunner) return;
3077
- dispatchingRunner = true;
3078
- let queued = [];
3079
- const promotedCardIds = /* @__PURE__ */ new Set();
3040
+
3041
+ // src/vaultSecrets.ts
3042
+ async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
3080
3043
  try {
3081
- queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
3082
- if (queued.length === 0) return;
3083
- const queuedProjectIds = new Set(queued.map((entry) => entry.cards.project_id));
3084
- const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
3085
- const runningProjectIds = /* @__PURE__ */ new Set();
3086
- for (const project of config.registeredProjects) {
3087
- const running = await getRunningSessionForProject(supabase, project.id, config.ownerUserId);
3088
- if (!running) continue;
3089
- if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
3090
- await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
3091
- continue;
3092
- }
3093
- runningProjectIds.add(project.id);
3044
+ const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
3045
+ if (error) throw error;
3046
+ const rows = data ?? [];
3047
+ const env = {};
3048
+ for (const row of rows) env[row.name] = row.value;
3049
+ if (rows.length > 0) {
3050
+ logger.runner.info(
3051
+ "VAULT",
3052
+ `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
3053
+ );
3094
3054
  }
3095
- let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
3096
- if (freeProjectSlots <= 0) return;
3097
- const promotedThisPass = /* @__PURE__ */ new Set();
3098
- for (const entry of queued) {
3099
- const projectId = entry.cards.project_id;
3100
- if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
3101
- if (freeProjectSlots <= 0) break;
3102
- const prerequisiteCardId = entry.cards.prerequisite_card_id;
3103
- if (prerequisiteCardId) {
3104
- const prerequisiteAgentState = await getLatestAgentStateForCard(supabase, prerequisiteCardId);
3105
- if (prerequisiteAgentState !== "done") {
3106
- logger.card.info(
3107
- entry.card_id,
3108
- `Waiting on prerequisite card ${prerequisiteCardId} to reach agent state "done" (currently "${prerequisiteAgentState ?? "none"}") before starting \u2014 leaving queued.`
3109
- );
3110
- continue;
3111
- }
3112
- }
3113
- try {
3114
- await promoteQueuedSession(supabase, config, entry);
3115
- promotedThisPass.add(projectId);
3116
- promotedCardIds.add(entry.card_id);
3117
- freeProjectSlots -= 1;
3118
- } catch (err) {
3119
- logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
3055
+ return env;
3056
+ } catch (err) {
3057
+ logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
3058
+ return {};
3059
+ }
3060
+ }
3061
+
3062
+ // src/columnActions.ts
3063
+ var execAsync = promisify4(exec);
3064
+ var OUTPUT_TAIL_CHARS = 4e3;
3065
+ function tail(text) {
3066
+ return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
3067
+ }
3068
+ function resolveTargets(project, children, repoRoot, column, timing) {
3069
+ if (children.length > 0) {
3070
+ return children.flatMap(
3071
+ (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 }))
3072
+ );
3073
+ }
3074
+ return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command, repoId: null }));
3075
+ }
3076
+ var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
3077
+ async function runCommand(target, extraEnv) {
3078
+ try {
3079
+ const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
3080
+ return { ok: true, output: tail(`${stdout}${stderr}`) };
3081
+ } catch (err) {
3082
+ const { stdout, stderr } = err;
3083
+ const message = err instanceof Error ? err.message : String(err);
3084
+ return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
3085
+ }
3086
+ }
3087
+ var inFlightColumns = /* @__PURE__ */ new Set();
3088
+ async function handleColumnEntered(supabase, config, card) {
3089
+ const key = `${card.project_id}:${card.column_name}`;
3090
+ if (inFlightColumns.has(key)) return;
3091
+ inFlightColumns.add(key);
3092
+ try {
3093
+ const project = await getProject(supabase, card.project_id);
3094
+ const children = await getProjectRepos(supabase, project.id);
3095
+ const repoRoot = resolveRepoPath(config, project.repo_path);
3096
+ const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
3097
+ if (targets.length === 0) return;
3098
+ const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
3099
+ const cards = (await Promise.all(allCards.map(async (candidate) => await isEffectiveOwner(supabase, candidate, config) ? candidate : null))).filter((candidate) => candidate !== null);
3100
+ if (cards.length === 0) return;
3101
+ await Promise.all(
3102
+ cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
3103
+ );
3104
+ let failure = null;
3105
+ for (const target of targets) {
3106
+ const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
3107
+ const result = await runCommand(target, secretsEnv);
3108
+ if (!result.ok) {
3109
+ failure = { label: target.label, output: result.output };
3110
+ break;
3120
3111
  }
3121
3112
  }
3113
+ if (failure) {
3114
+ const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
3115
+ logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
3116
+ await Promise.all(
3117
+ cards.map(
3118
+ (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
3119
+ )
3120
+ );
3121
+ } else {
3122
+ logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
3123
+ await Promise.all(
3124
+ cards.map(
3125
+ (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
3126
+ )
3127
+ );
3128
+ }
3122
3129
  } finally {
3123
- await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
3124
- (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3125
- );
3126
- dispatchingRunner = false;
3130
+ inFlightColumns.delete(key);
3127
3131
  }
3128
3132
  }
3129
- async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
3130
- const paused = await listPausedSessionsForRunner(supabase, config.ownerUserId);
3131
- const queuedByProject = /* @__PURE__ */ new Map();
3132
- for (const entry of queued) {
3133
- if (promotedCardIds.has(entry.card_id)) continue;
3134
- const list = queuedByProject.get(entry.cards.project_id) ?? [];
3135
- list.push(entry.card_id);
3136
- queuedByProject.set(entry.cards.project_id, list);
3137
- }
3138
- const pausedByProject = /* @__PURE__ */ new Map();
3139
- for (const { card_id: cardId, project_id: projectId } of paused) {
3140
- const list = pausedByProject.get(projectId) ?? [];
3141
- list.push(cardId);
3142
- pausedByProject.set(projectId, list);
3133
+ async function clearColumnActionStatus(supabase, card) {
3134
+ if (card.custom_action_status === null) return;
3135
+ await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
3136
+ }
3137
+ async function runAfterColumnActions(supabase, project, children, repoRoot, column) {
3138
+ const targets = resolveTargets(project, children, repoRoot, column, "after");
3139
+ for (const target of targets) {
3140
+ const secretsEnv = await resolveProjectSecretsEnv(supabase, project.id, target.repoId);
3141
+ const result = await runCommand(target, secretsEnv);
3142
+ if (!result.ok) {
3143
+ return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
3144
+ }
3143
3145
  }
3144
- for (const project of config.registeredProjects) {
3145
- publishQueueEvent(project.id, {
3146
- queuedCardIds: queuedByProject.get(project.id) ?? [],
3147
- pausedCardIds: pausedByProject.get(project.id) ?? []
3148
- });
3146
+ return { ok: true };
3147
+ }
3148
+ async function checkPendingColumnActions(supabase, config) {
3149
+ for (const registered of config.registeredProjects) {
3150
+ for (const column of BOARD_COLUMNS) {
3151
+ const cards = await listCardsInColumn(supabase, registered.id, column);
3152
+ let pending2;
3153
+ for (const card of cards) {
3154
+ if (card.custom_action_status !== null) continue;
3155
+ if (await isEffectiveOwner(supabase, card, config)) {
3156
+ pending2 = card;
3157
+ break;
3158
+ }
3159
+ }
3160
+ if (pending2) await handleColumnEntered(supabase, config, pending2);
3161
+ }
3149
3162
  }
3150
3163
  }
3151
- function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
3152
- 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.`;
3164
+ async function handleDeployActionRerunRequested(supabase, config, project) {
3165
+ const allCards = await listCardsInColumn(supabase, project.id, "deploy");
3166
+ const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter((card) => card !== null);
3167
+ if (cards.length === 0) {
3168
+ 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.`);
3169
+ } else {
3170
+ await handleColumnEntered(supabase, config, cards[0]);
3171
+ }
3172
+ await clearDeployActionRerunRequest(supabase, project.id);
3153
3173
  }
3154
- async function promoteQueuedSession(supabase, config, entry) {
3155
- const existingHandle = getPtySession(entry.id);
3156
- if (existingHandle) {
3157
- setPtySessionReadOnly(entry.id, false);
3158
- await updateSession(
3159
- supabase,
3160
- entry.id,
3161
- { status: "running" },
3162
- { projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
3163
- );
3164
- activeSessionByCard.set(entry.card_id, entry.id);
3165
- logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
3166
- if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
3167
- sendMessageToIdleSession(
3168
- existingHandle.pty,
3169
- entry.card_id,
3170
- buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
3171
- );
3174
+ async function checkPendingDeployActionReruns(supabase, config) {
3175
+ for (const registered of config.registeredProjects) {
3176
+ const project = await getProject(supabase, registered.id);
3177
+ if (project.deploy_action_rerun_requested_at) {
3178
+ await handleDeployActionRerunRequested(supabase, config, project);
3172
3179
  }
3173
- return;
3174
3180
  }
3175
- const spawned = await spawnSessionPty(supabase, config, entry);
3176
- if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
3177
3181
  }
3178
- async function spawnSessionPty(supabase, config, entry) {
3179
- const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
3180
- const registered = config.registeredProjects.find((project) => project.id === card.project_id);
3181
- if (!registered) {
3182
- logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
3183
- await updateSession(
3184
- supabase,
3185
- sessionId,
3186
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3187
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3188
- );
3182
+
3183
+ // src/deploy.ts
3184
+ import { join as join14 } from "node:path";
3185
+
3186
+ // src/devServer.ts
3187
+ import { spawn as spawn5 } from "node:child_process";
3188
+
3189
+ // src/cloudflared.ts
3190
+ import { spawn as spawn4 } from "node:child_process";
3191
+ import { accessSync, constants } from "node:fs";
3192
+ import { homedir as homedir5 } from "node:os";
3193
+ import { delimiter, join as join13 } from "node:path";
3194
+ import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
3195
+ var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
3196
+ var STARTUP_TIMEOUT_MS = 2e4;
3197
+ var IS_WINDOWS2 = process.platform === "win32";
3198
+ var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
3199
+ var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
3200
+ function wellKnownDirectories() {
3201
+ const home = homedir5();
3202
+ if (IS_WINDOWS2) {
3203
+ const candidates = [
3204
+ process.env.ProgramFiles && join13(process.env.ProgramFiles, "cloudflared"),
3205
+ process.env["ProgramFiles(x86)"] && join13(process.env["ProgramFiles(x86)"], "cloudflared"),
3206
+ process.env.LOCALAPPDATA && join13(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
3207
+ join13(home, "scoop", "shims"),
3208
+ process.env.ChocolateyInstall && join13(process.env.ChocolateyInstall, "bin")
3209
+ ];
3210
+ return candidates.filter((dir) => Boolean(dir));
3211
+ }
3212
+ return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join13(home, ".local", "bin")];
3213
+ }
3214
+ function pathDirectories() {
3215
+ return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
3216
+ }
3217
+ function isExecutableFile(path) {
3218
+ try {
3219
+ accessSync(path, constants.X_OK);
3220
+ return true;
3221
+ } catch {
3189
3222
  return false;
3190
3223
  }
3191
- const ticket = randomUUID6();
3192
- let pty;
3193
- let newClaudeSessionId = null;
3194
- let resumeConversationMissing = false;
3224
+ }
3225
+ function findInstalledBinary() {
3226
+ const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
3227
+ const directories = [...pathDirectories(), ...wellKnownDirectories()];
3228
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join13(dir, BINARY_NAME))];
3229
+ return candidates.find(isExecutableFile) ?? null;
3230
+ }
3231
+ async function locateCloudflared() {
3232
+ const installed = findInstalledBinary();
3233
+ if (installed) return installed;
3234
+ if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
3195
3235
  try {
3196
- if (claudeSessionId) {
3197
- pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
3198
- pty.onData((chunk) => {
3199
- if (RESUME_CONVERSATION_MISSING_PATTERN.test(normalizeTerminalOutput(chunk))) resumeConversationMissing = true;
3200
- });
3201
- } else {
3202
- const attachmentPaths = await downloadCardAttachments(supabase, cardId);
3203
- const instructions = buildInstructionsWithMcpContext(
3204
- buildInstructionsWithPrototypeGate(
3205
- buildInstructionsWithFileReferences(
3206
- buildInstructionsWithAttachments(card.instructions, attachmentPaths),
3207
- card.file_references
3208
- ),
3209
- card.prototype_first
3210
- ),
3211
- cardId,
3212
- card.project_id
3213
- );
3214
- const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
3215
- pty = spawned.pty;
3216
- newClaudeSessionId = spawned.claudeSessionId;
3217
- }
3236
+ logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
3237
+ return await downloadCloudflared(bundledBinaryPath);
3218
3238
  } catch (err) {
3219
- logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
3220
- await updateSession(
3221
- supabase,
3222
- sessionId,
3223
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3224
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3225
- );
3226
- return false;
3239
+ logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
3240
+ return null;
3227
3241
  }
3228
- registerPtySession(sessionId, pty, ticket);
3229
- activeSessionByCard.set(cardId, sessionId);
3230
- publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
3231
- const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
3232
- await updateSession(
3233
- supabase,
3234
- sessionId,
3235
- {
3236
- status: "running",
3237
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3238
- terminalTicket: ticket,
3239
- terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
3240
- // Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
3241
- // already set) keeps whatever agent_state it had before the restart (e.g.
3242
- // "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
3243
- // freshly (re)constructed AgentActivityWatcher below will correct it the moment real
3244
- // output gives it something conclusive to classify.
3245
- ...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
3246
- },
3247
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3248
- );
3249
- pty.onExit(({ exitCode }) => {
3250
- const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
3251
- activityWatcher.stop();
3252
- unregisterAgentActivityWatcher(sessionId);
3253
- cancelUsageResume(sessionId);
3254
- activeSessionByCard.delete(cardId);
3255
- if (resumeConversationMissing) {
3256
- logger.card.warn(
3257
- cardId,
3258
- `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.`
3259
- );
3260
- void updateSession(
3261
- supabase,
3262
- sessionId,
3263
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString(), claudeSessionId: null },
3264
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
3265
- );
3242
+ }
3243
+ var lookup = null;
3244
+ function resolveCloudflaredPath() {
3245
+ lookup ??= locateCloudflared().then((path) => {
3246
+ if (!path) lookup = null;
3247
+ return path;
3248
+ });
3249
+ return lookup;
3250
+ }
3251
+ async function startQuickTunnel(label, port) {
3252
+ const executable = await resolveCloudflaredPath();
3253
+ if (!executable) {
3254
+ logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
3255
+ return null;
3256
+ }
3257
+ return new Promise((resolve4) => {
3258
+ let child2;
3259
+ try {
3260
+ child2 = spawn4(executable, [
3261
+ "tunnel",
3262
+ "--url",
3263
+ `http://localhost:${port}`,
3264
+ "--http-host-header",
3265
+ `localhost:${port}`
3266
+ ]);
3267
+ } catch (err) {
3268
+ logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
3269
+ resolve4(null);
3266
3270
  return;
3267
3271
  }
3268
- 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.`);
3269
- if (!wasIntentional && exitCode !== 0) {
3270
- publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
3271
- }
3272
+ let settled = false;
3273
+ const settle = (value) => {
3274
+ if (settled) return;
3275
+ settled = true;
3276
+ clearTimeout(timer);
3277
+ resolve4(value);
3278
+ };
3279
+ const timer = setTimeout(() => {
3280
+ logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
3281
+ settle(null);
3282
+ }, STARTUP_TIMEOUT_MS);
3283
+ const onOutput = (chunk) => {
3284
+ if (settled) return;
3285
+ const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
3286
+ if (match) settle({ url: match[0], child: child2 });
3287
+ };
3288
+ child2.stdout?.on("data", onOutput);
3289
+ child2.stderr?.on("data", onOutput);
3290
+ child2.on("error", (err) => {
3291
+ logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
3292
+ settle(null);
3293
+ });
3294
+ child2.on("exit", (code) => {
3295
+ logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
3296
+ settle(null);
3297
+ });
3272
3298
  });
3273
- return true;
3274
3299
  }
3275
- function publishAgentState(supabase, projectId, cardId, sessionId, state) {
3276
- publishAgentStateEvent(projectId, cardId, sessionId, state);
3277
- updateSession(supabase, sessionId, { agentState: state }).catch(
3278
- (err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
3279
- );
3300
+ function toTunnelHostname(url) {
3301
+ return new URL(url).host;
3280
3302
  }
3281
- function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
3282
- const watcher = new AgentActivityWatcher((state) => {
3283
- publishAgentState(supabase, projectId, cardId, sessionId, state);
3284
- if (state === "usage_limited") {
3285
- scheduleUsageResume(supabase, config, cardId, sessionId, pty);
3286
- } else {
3287
- cancelUsageResume(sessionId);
3288
- }
3289
- if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
3290
- });
3291
- pty.onData((chunk) => watcher.push(chunk));
3292
- registerAgentActivityWatcher(sessionId, watcher);
3293
- return watcher;
3303
+
3304
+ // src/devServer.ts
3305
+ var STARTUP_TIMEOUT_MS2 = 45e3;
3306
+ var IS_WINDOWS3 = process.platform === "win32";
3307
+ var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
3308
+ var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
3309
+ var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
3310
+ function detectPortFromOutput(chunk) {
3311
+ const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
3312
+ const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
3313
+ return match ? Number(match[1]) : null;
3294
3314
  }
3295
- async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
3296
- const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
3297
- if (!paused) return;
3298
- setPtySessionReadOnly(sessionId, true);
3299
- logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done", another session needs this project's WIP slot).`);
3315
+ function killProcessTree(child2) {
3316
+ if (child2.pid == null) return;
3317
+ if (IS_WINDOWS3) {
3318
+ spawn5("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
3319
+ } else {
3320
+ child2.kill();
3321
+ }
3300
3322
  }
3301
- async function requeuePausedCard(supabase, config, card) {
3302
- const active = await getActiveSessionForCard(supabase, card.id);
3303
- if (!active) return;
3304
- if (active.status === "paused") {
3305
- await updateSession(
3306
- supabase,
3307
- active.id,
3308
- { status: "queued" },
3309
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3310
- );
3311
- await updateCard(supabase, card.id, { requeueRequestedAt: null });
3312
- logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
3313
- await dispatchRunnerQueue(supabase, config);
3314
- return;
3323
+ var previews = /* @__PURE__ */ new Map();
3324
+ function ensurePreview(key, label, cwd, command, cardId, extraEnv = {}) {
3325
+ const existing = previews.get(key);
3326
+ if (existing) {
3327
+ existing.cardIds.add(cardId);
3328
+ return existing.result;
3315
3329
  }
3316
- if (active.status === "running" && active.agentState === "waiting_prototype_review") {
3317
- const handle = getPtySession(active.id);
3318
- await updateCard(supabase, card.id, { requeueRequestedAt: null });
3319
- if (handle && !handle.exited) {
3320
- sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
3321
- logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
3322
- }
3330
+ let child2;
3331
+ try {
3332
+ child2 = spawn5(command, { cwd, shell: true, windowsHide: true, env: { ...process.env, ...extraEnv } });
3333
+ } catch (err) {
3334
+ logger.runner.error("DEV_SERVER", `Failed to spawn "${command}" in ${cwd}:`, err);
3335
+ return Promise.resolve(null);
3323
3336
  }
3324
- }
3325
- async function reconcileOrphanedSessions(supabase, config) {
3326
- for (const project of config.registeredProjects) {
3327
- const { data, error } = await supabase.from("sessions").select(
3328
- "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)"
3329
- ).eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).in("status", ["running", "paused"]);
3330
- if (error) {
3331
- logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
3332
- continue;
3333
- }
3334
- for (const entry of data ?? []) {
3335
- let spawned;
3336
- try {
3337
- spawned = await spawnSessionPty(supabase, config, entry);
3338
- } catch (err) {
3339
- logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
3340
- continue;
3337
+ const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
3338
+ entry.result = new Promise((resolve4) => {
3339
+ let settled = false;
3340
+ const settle = (value) => {
3341
+ if (settled) return;
3342
+ settled = true;
3343
+ clearTimeout(timer);
3344
+ if (!value) {
3345
+ killProcessTree(child2);
3346
+ previews.delete(key);
3341
3347
  }
3342
- if (!spawned) continue;
3343
- if (entry.status === "paused") {
3344
- await updateSession(
3345
- supabase,
3346
- entry.id,
3347
- { status: "paused" },
3348
- { projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
3349
- );
3350
- setPtySessionReadOnly(entry.id, true);
3351
- logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
3352
- } else {
3353
- logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
3348
+ resolve4(value);
3349
+ };
3350
+ const timer = setTimeout(() => {
3351
+ logger.runner.warn("DEV_SERVER", `"${command}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
3352
+ settle(null);
3353
+ }, STARTUP_TIMEOUT_MS2);
3354
+ const onOutput = async (chunk) => {
3355
+ if (settled) return;
3356
+ const port = detectPortFromOutput(chunk.toString("utf-8"));
3357
+ if (port == null) return;
3358
+ settled = true;
3359
+ clearTimeout(timer);
3360
+ const tunnel = await startQuickTunnel(`dev server ${key}`, port);
3361
+ if (!tunnel) {
3362
+ logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
3363
+ killProcessTree(child2);
3364
+ previews.delete(key);
3365
+ resolve4(null);
3366
+ return;
3354
3367
  }
3355
- }
3368
+ entry.tunnelChild = tunnel.child;
3369
+ logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
3370
+ resolve4({ label, url: tunnel.url });
3371
+ };
3372
+ child2.stdout?.on("data", onOutput);
3373
+ child2.stderr?.on("data", onOutput);
3374
+ child2.on("error", (err) => {
3375
+ logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command} in ${cwd}):`, err);
3376
+ settle(null);
3377
+ });
3378
+ child2.on("exit", (code) => {
3379
+ logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
3380
+ settle(null);
3381
+ });
3382
+ });
3383
+ previews.set(key, entry);
3384
+ return entry.result;
3385
+ }
3386
+ function releaseCard(cardId) {
3387
+ for (const [key, entry] of previews) {
3388
+ if (!entry.cardIds.delete(cardId)) continue;
3389
+ if (entry.cardIds.size > 0) continue;
3390
+ killProcessTree(entry.child);
3391
+ entry.tunnelChild?.kill();
3392
+ previews.delete(key);
3356
3393
  }
3357
3394
  }
3358
- async function reconcileUsageLimitedSessions(supabase, config) {
3359
- for (const project of config.registeredProjects) {
3360
- 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");
3361
- if (error) {
3362
- logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
3363
- continue;
3364
- }
3365
- for (const { id: sessionId, card_id: cardId } of data ?? []) {
3366
- const handle = getPtySession(sessionId);
3367
- if (!handle) continue;
3368
- scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
3369
- }
3395
+ function stopProjectPreviews(projectId) {
3396
+ const prefix = `${projectId}:`;
3397
+ for (const [key, entry] of previews) {
3398
+ if (!key.startsWith(prefix)) continue;
3399
+ killProcessTree(entry.child);
3400
+ entry.tunnelChild?.kill();
3401
+ previews.delete(key);
3370
3402
  }
3371
3403
  }
3372
- async function stopCardSession(supabase, config, card) {
3373
- const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3374
- if (!sessionId) return;
3375
- intentionallyStoppedSessionIds.add(sessionId);
3376
- stopPtySession(sessionId);
3377
- cancelUsageResume(sessionId);
3378
- activeSessionByCard.delete(card.id);
3379
- await updateSession(
3380
- supabase,
3381
- sessionId,
3382
- {
3383
- status: "done",
3384
- endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3385
- ...card.column_name === "fim" ? { agentState: "done" } : {}
3386
- },
3387
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3388
- );
3389
- logger.card.info(card.id, `Session ${sessionId} stopped.`);
3390
- await dispatchRunnerQueue(supabase, config);
3404
+ function stopAll() {
3405
+ for (const key of [...previews.keys()]) {
3406
+ const entry = previews.get(key);
3407
+ if (!entry) continue;
3408
+ killProcessTree(entry.child);
3409
+ entry.tunnelChild?.kill();
3410
+ previews.delete(key);
3411
+ }
3391
3412
  }
3392
- async function restartCardSession(supabase, config, card) {
3393
- const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
3394
- if (sessionId) {
3395
- intentionallyStoppedSessionIds.add(sessionId);
3396
- stopPtySession(sessionId);
3397
- cancelUsageResume(sessionId);
3398
- activeSessionByCard.delete(card.id);
3399
- await updateSession(
3400
- supabase,
3401
- sessionId,
3402
- { status: "failed", claudeSessionId: null, endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3403
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3404
- );
3405
- logger.card.info(card.id, `Session ${sessionId} abandoned by "Reiniciar sess\xE3o"; starting a fresh one.`);
3413
+
3414
+ // src/git.ts
3415
+ import { execFile as execFile4 } from "node:child_process";
3416
+ import { promisify as promisify5 } from "node:util";
3417
+ var execFileAsync4 = promisify5(execFile4);
3418
+ async function pushBranchToMain(repoPath) {
3419
+ try {
3420
+ await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
3421
+ return { ok: true };
3422
+ } catch (err) {
3423
+ const stderr = err.stderr;
3424
+ const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
3425
+ return { ok: false, error };
3406
3426
  }
3407
- await updateCard(supabase, card.id, { restartRequestedAt: null });
3408
- await enqueueCard(supabase, config, card);
3409
3427
  }
3410
3428
 
3411
3429
  // src/deploy.ts
@@ -3429,7 +3447,9 @@ async function handleDeployRequested(supabase, config, project) {
3429
3447
  }
3430
3448
  async function runDeploy(supabase, config, project) {
3431
3449
  const allCards = await listCardsInColumn(supabase, project.id, "deploy");
3432
- const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
3450
+ const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
3451
+ (card) => card !== null
3452
+ );
3433
3453
  if (cards.length === 0) {
3434
3454
  logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
3435
3455
  await clearProjectDeployRequest(supabase, project.id);
@@ -3606,7 +3626,7 @@ async function checkPendingPreviews(supabase, config) {
3606
3626
  for (const registered of config.registeredProjects) {
3607
3627
  const cards = await listCardsInColumn(supabase, registered.id, "revisao");
3608
3628
  for (const card of cards) {
3609
- if (card.assigned_user_id !== config.ownerUserId) continue;
3629
+ if (!await isEffectiveOwner(supabase, card, config)) continue;
3610
3630
  if (card.preview_urls.length > 0) continue;
3611
3631
  await handleCardEnteredRevisao(supabase, config, card);
3612
3632
  }
@@ -3621,7 +3641,9 @@ async function handleDevServerRestartRequested(supabase, config, project) {
3621
3641
  }
3622
3642
  stopProjectPreviews(project.id);
3623
3643
  const allCards = await listCardsInColumn(supabase, project.id, "revisao");
3624
- const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
3644
+ const cards = (await Promise.all(allCards.map(async (card) => await isEffectiveOwner(supabase, card, config) ? card : null))).filter(
3645
+ (card) => card !== null
3646
+ );
3625
3647
  if (cards.length === 0) {
3626
3648
  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.`);
3627
3649
  await clearDevServerRestartRequest(supabase, project.id);