agent-board-runner 0.1.0

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 (4) hide show
  1. package/README.md +60 -0
  2. package/dist/cli.js +2270 -0
  3. package/dist/index.js +2133 -0
  4. package/package.json +40 -0
package/dist/index.js ADDED
@@ -0,0 +1,2133 @@
1
+ // src/index.ts
2
+ import { config as loadEnv } from "dotenv";
3
+
4
+ // src/runner.ts
5
+ import { createServer } from "node:http";
6
+
7
+ // src/auth.ts
8
+ import { createClient } from "@supabase/supabase-js";
9
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
10
+ import { join as join2 } from "node:path";
11
+ import open from "open";
12
+
13
+ // src/config.ts
14
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
15
+ import { hostname, homedir } from "node:os";
16
+ import { join, resolve } from "node:path";
17
+ var CONFIG_DIR = join(homedir(), ".agent-board");
18
+ var CONFIG_FILE = join(CONFIG_DIR, "credentials.json");
19
+ function loadConfig() {
20
+ if (!existsSync(CONFIG_FILE)) return null;
21
+ return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
22
+ }
23
+ function saveConfig(config) {
24
+ if (!existsSync(CONFIG_DIR)) mkdirSync(CONFIG_DIR, { recursive: true });
25
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
26
+ }
27
+ function applyAllowedRoot(config, explicitPath) {
28
+ const raw = explicitPath ?? process.env.AGENT_BOARD_ALLOWED_ROOT;
29
+ if (!raw) return config;
30
+ const resolved = resolve(raw);
31
+ if (config.allowedRootPath === resolved) return config;
32
+ config.allowedRootPath = resolved;
33
+ saveConfig(config);
34
+ return config;
35
+ }
36
+ function applyRunnerName(config) {
37
+ const name = process.env.AGENT_BOARD_RUNNER_NAME ?? hostname();
38
+ if (config.runnerName === name) return config;
39
+ config.runnerName = name;
40
+ saveConfig(config);
41
+ return config;
42
+ }
43
+ function resolveRepoPath(config, relativeRepoPath) {
44
+ if (!config.allowedRootPath) {
45
+ throw new Error("Runner has no repos root configured (AGENT_BOARD_ALLOWED_ROOT or `start --path`).");
46
+ }
47
+ return resolve(config.allowedRootPath, relativeRepoPath);
48
+ }
49
+
50
+ // src/auth.ts
51
+ var SESSION_FILE = join2(CONFIG_DIR, "session.json");
52
+ var DEFAULT_SUPABASE_URL = "https://brpxshvnmxgkbffgdfcc.supabase.co";
53
+ var DEFAULT_SUPABASE_ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImJycHhzaHZubXhna2JmZmdkZmNjIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODc1OTQwMTEsImV4cCI6MjEwMzE3MDAxMX0.ErsAGZysLFgNvijetR7w1YvIziXC-1D2sx04Hm2UW9c";
54
+ function resolveSupabaseUrl() {
55
+ return process.env.AGENT_BOARD_SUPABASE_URL ?? DEFAULT_SUPABASE_URL;
56
+ }
57
+ function resolveSupabaseAnonKey() {
58
+ return process.env.AGENT_BOARD_SUPABASE_ANON_KEY ?? DEFAULT_SUPABASE_ANON_KEY;
59
+ }
60
+ function readSessionFile() {
61
+ if (!existsSync2(SESSION_FILE)) return {};
62
+ try {
63
+ return JSON.parse(readFileSync2(SESSION_FILE, "utf-8"));
64
+ } catch {
65
+ return {};
66
+ }
67
+ }
68
+ var fileStorage = {
69
+ getItem(key) {
70
+ return readSessionFile()[key] ?? null;
71
+ },
72
+ setItem(key, value) {
73
+ if (!existsSync2(CONFIG_DIR)) mkdirSync2(CONFIG_DIR, { recursive: true });
74
+ const all = readSessionFile();
75
+ all[key] = value;
76
+ writeFileSync2(SESSION_FILE, JSON.stringify(all), "utf-8");
77
+ },
78
+ removeItem(key) {
79
+ const all = readSessionFile();
80
+ delete all[key];
81
+ writeFileSync2(SESSION_FILE, JSON.stringify(all), "utf-8");
82
+ }
83
+ };
84
+ function createAuthedSupabaseClient() {
85
+ return createClient(resolveSupabaseUrl(), resolveSupabaseAnonKey(), {
86
+ db: { schema: "agent_board" },
87
+ auth: {
88
+ storage: fileStorage,
89
+ persistSession: true,
90
+ autoRefreshToken: true,
91
+ detectSessionInUrl: false,
92
+ flowType: "pkce"
93
+ }
94
+ });
95
+ }
96
+ async function getSession(supabase) {
97
+ const { data } = await supabase.auth.getSession();
98
+ return data.session;
99
+ }
100
+ async function requireSession(supabase) {
101
+ const session = await getSession(supabase);
102
+ if (!session) {
103
+ throw new Error('Not logged in. Run "agent-board-runner login" first.');
104
+ }
105
+ return session;
106
+ }
107
+
108
+ // src/deploy.ts
109
+ import { join as join6 } from "node:path";
110
+
111
+ // src/devServer.ts
112
+ import { spawn as spawn2 } from "node:child_process";
113
+
114
+ // src/cloudflared.ts
115
+ import { spawn } from "node:child_process";
116
+ import { accessSync, constants } from "node:fs";
117
+ import { homedir as homedir2 } from "node:os";
118
+ import { delimiter, join as join3 } from "node:path";
119
+ import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
120
+
121
+ // src/logger.ts
122
+ var ANSI = {
123
+ reset: "\x1B[0m",
124
+ bold: "\x1B[1m",
125
+ dim: "\x1B[2m",
126
+ cyan: "\x1B[36m",
127
+ yellow: "\x1B[33m",
128
+ red: "\x1B[31m",
129
+ gray: "\x1B[90m",
130
+ blue: "\x1B[34m",
131
+ green: "\x1B[32m"
132
+ };
133
+ var LEVEL_COLOR = {
134
+ INF: ANSI.cyan,
135
+ WAR: ANSI.yellow,
136
+ ERR: ANSI.red,
137
+ DEB: ANSI.gray
138
+ };
139
+ var KIND_COLOR = {
140
+ RUNNER: ANSI.blue,
141
+ CARD: ANSI.green
142
+ };
143
+ var useColor = process.stdout.isTTY;
144
+ function paint(text, ...codes) {
145
+ return useColor ? `${codes.join("")}${text}${ANSI.reset}` : text;
146
+ }
147
+ function pad2(value) {
148
+ return value.toString().padStart(2, "0");
149
+ }
150
+ function timestamp() {
151
+ const now = /* @__PURE__ */ new Date();
152
+ const date = `${pad2(now.getDate())}-${pad2(now.getMonth() + 1)}-${now.getFullYear()}`;
153
+ const time = `${pad2(now.getHours())}:${pad2(now.getMinutes())}:${pad2(now.getSeconds())}`;
154
+ return `${date} ${time}`;
155
+ }
156
+ function emit(level, kind, id, message, extra) {
157
+ const stream = level === "ERR" ? console.error : level === "WAR" ? console.warn : console.log;
158
+ const prefix = [
159
+ paint(`[${timestamp()}]`, ANSI.dim),
160
+ paint(`[${level}]`, ANSI.bold, LEVEL_COLOR[level]),
161
+ paint(`[${kind}]`, ANSI.bold, KIND_COLOR[kind]),
162
+ paint(`[${id}]`, ANSI.dim)
163
+ ].join(" ");
164
+ stream(`${prefix} ${message}`, ...extra);
165
+ }
166
+ function scoped(kind) {
167
+ return {
168
+ info: (id, message, ...extra) => emit("INF", kind, id, message, extra),
169
+ warn: (id, message, ...extra) => emit("WAR", kind, id, message, extra),
170
+ error: (id, message, ...extra) => emit("ERR", kind, id, message, extra),
171
+ debug: (id, message, ...extra) => emit("DEB", kind, id, message, extra)
172
+ };
173
+ }
174
+ var logger = {
175
+ runner: scoped("RUNNER"),
176
+ card: scoped("CARD")
177
+ };
178
+
179
+ // src/cloudflared.ts
180
+ var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
181
+ var STARTUP_TIMEOUT_MS = 2e4;
182
+ var IS_WINDOWS = process.platform === "win32";
183
+ var BINARY_NAME = IS_WINDOWS ? "cloudflared.exe" : "cloudflared";
184
+ var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
185
+ function wellKnownDirectories() {
186
+ const home = homedir2();
187
+ if (IS_WINDOWS) {
188
+ const candidates = [
189
+ process.env.ProgramFiles && join3(process.env.ProgramFiles, "cloudflared"),
190
+ process.env["ProgramFiles(x86)"] && join3(process.env["ProgramFiles(x86)"], "cloudflared"),
191
+ process.env.LOCALAPPDATA && join3(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
192
+ join3(home, "scoop", "shims"),
193
+ process.env.ChocolateyInstall && join3(process.env.ChocolateyInstall, "bin")
194
+ ];
195
+ return candidates.filter((dir) => Boolean(dir));
196
+ }
197
+ return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join3(home, ".local", "bin")];
198
+ }
199
+ function pathDirectories() {
200
+ return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
201
+ }
202
+ function isExecutableFile(path) {
203
+ try {
204
+ accessSync(path, constants.X_OK);
205
+ return true;
206
+ } catch {
207
+ return false;
208
+ }
209
+ }
210
+ function findInstalledBinary() {
211
+ const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
212
+ const directories = [...pathDirectories(), ...wellKnownDirectories()];
213
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join3(dir, BINARY_NAME))];
214
+ return candidates.find(isExecutableFile) ?? null;
215
+ }
216
+ async function locateCloudflared() {
217
+ const installed = findInstalledBinary();
218
+ if (installed) return installed;
219
+ if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
220
+ try {
221
+ logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
222
+ return await downloadCloudflared(bundledBinaryPath);
223
+ } catch (err) {
224
+ logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
225
+ return null;
226
+ }
227
+ }
228
+ var lookup = null;
229
+ function resolveCloudflaredPath() {
230
+ lookup ??= locateCloudflared().then((path) => {
231
+ if (!path) lookup = null;
232
+ return path;
233
+ });
234
+ return lookup;
235
+ }
236
+ async function startQuickTunnel(label, port) {
237
+ const executable = await resolveCloudflaredPath();
238
+ if (!executable) {
239
+ logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
240
+ return null;
241
+ }
242
+ return new Promise((resolve4) => {
243
+ let child2;
244
+ try {
245
+ child2 = spawn(executable, [
246
+ "tunnel",
247
+ "--url",
248
+ `http://localhost:${port}`,
249
+ "--http-host-header",
250
+ `localhost:${port}`
251
+ ]);
252
+ } catch (err) {
253
+ logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
254
+ resolve4(null);
255
+ return;
256
+ }
257
+ let settled = false;
258
+ const settle = (value) => {
259
+ if (settled) return;
260
+ settled = true;
261
+ clearTimeout(timer);
262
+ resolve4(value);
263
+ };
264
+ const timer = setTimeout(() => {
265
+ logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
266
+ settle(null);
267
+ }, STARTUP_TIMEOUT_MS);
268
+ const onOutput = (chunk) => {
269
+ if (settled) return;
270
+ const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
271
+ if (match) settle({ url: match[0], child: child2 });
272
+ };
273
+ child2.stdout?.on("data", onOutput);
274
+ child2.stderr?.on("data", onOutput);
275
+ child2.on("error", (err) => {
276
+ logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
277
+ settle(null);
278
+ });
279
+ child2.on("exit", (code) => {
280
+ logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
281
+ settle(null);
282
+ });
283
+ });
284
+ }
285
+ function toTunnelHostname(url) {
286
+ return new URL(url).host;
287
+ }
288
+
289
+ // src/devServer.ts
290
+ var STARTUP_TIMEOUT_MS2 = 45e3;
291
+ var IS_WINDOWS2 = process.platform === "win32";
292
+ var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
293
+ var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
294
+ var ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
295
+ function detectPortFromOutput(chunk) {
296
+ const clean = chunk.replace(ANSI_ESCAPE_RE, "");
297
+ const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
298
+ return match ? Number(match[1]) : null;
299
+ }
300
+ function killProcessTree(child2) {
301
+ if (child2.pid == null) return;
302
+ if (IS_WINDOWS2) {
303
+ spawn2("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
304
+ } else {
305
+ child2.kill();
306
+ }
307
+ }
308
+ var previews = /* @__PURE__ */ new Map();
309
+ function ensurePreview(key, label, cwd, command, cardId) {
310
+ const existing = previews.get(key);
311
+ if (existing) {
312
+ existing.cardIds.add(cardId);
313
+ return existing.result;
314
+ }
315
+ let child2;
316
+ try {
317
+ child2 = spawn2(command, { cwd, shell: true, windowsHide: true });
318
+ } catch (err) {
319
+ logger.runner.error("DEV_SERVER", `Failed to spawn "${command}" in ${cwd}:`, err);
320
+ return Promise.resolve(null);
321
+ }
322
+ const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
323
+ entry.result = new Promise((resolve4) => {
324
+ let settled = false;
325
+ const settle = (value) => {
326
+ if (settled) return;
327
+ settled = true;
328
+ clearTimeout(timer);
329
+ if (!value) {
330
+ killProcessTree(child2);
331
+ previews.delete(key);
332
+ }
333
+ resolve4(value);
334
+ };
335
+ const timer = setTimeout(() => {
336
+ logger.runner.warn("DEV_SERVER", `"${command}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
337
+ settle(null);
338
+ }, STARTUP_TIMEOUT_MS2);
339
+ const onOutput = async (chunk) => {
340
+ if (settled) return;
341
+ const port = detectPortFromOutput(chunk.toString("utf-8"));
342
+ if (port == null) return;
343
+ settled = true;
344
+ clearTimeout(timer);
345
+ const tunnel = await startQuickTunnel(`dev server ${key}`, port);
346
+ if (!tunnel) {
347
+ logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
348
+ killProcessTree(child2);
349
+ previews.delete(key);
350
+ resolve4(null);
351
+ return;
352
+ }
353
+ entry.tunnelChild = tunnel.child;
354
+ logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
355
+ resolve4({ label, url: tunnel.url });
356
+ };
357
+ child2.stdout?.on("data", onOutput);
358
+ child2.stderr?.on("data", onOutput);
359
+ child2.on("error", (err) => {
360
+ logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command} in ${cwd}):`, err);
361
+ settle(null);
362
+ });
363
+ child2.on("exit", (code) => {
364
+ logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
365
+ settle(null);
366
+ });
367
+ });
368
+ previews.set(key, entry);
369
+ return entry.result;
370
+ }
371
+ function releaseCard(cardId) {
372
+ for (const [key, entry] of previews) {
373
+ if (!entry.cardIds.delete(cardId)) continue;
374
+ if (entry.cardIds.size > 0) continue;
375
+ killProcessTree(entry.child);
376
+ entry.tunnelChild?.kill();
377
+ previews.delete(key);
378
+ }
379
+ }
380
+ function stopProjectPreviews(projectId) {
381
+ const prefix = `${projectId}:`;
382
+ for (const [key, entry] of previews) {
383
+ if (!key.startsWith(prefix)) continue;
384
+ killProcessTree(entry.child);
385
+ entry.tunnelChild?.kill();
386
+ previews.delete(key);
387
+ }
388
+ }
389
+ function stopAll() {
390
+ for (const key of [...previews.keys()]) {
391
+ const entry = previews.get(key);
392
+ if (!entry) continue;
393
+ killProcessTree(entry.child);
394
+ entry.tunnelChild?.kill();
395
+ previews.delete(key);
396
+ }
397
+ }
398
+
399
+ // src/git.ts
400
+ import { execFile } from "node:child_process";
401
+ import { promisify } from "node:util";
402
+ var execFileAsync = promisify(execFile);
403
+ async function pushBranchToMain(repoPath) {
404
+ try {
405
+ await execFileAsync("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
406
+ return { ok: true };
407
+ } catch (err) {
408
+ const stderr = err.stderr;
409
+ const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
410
+ return { ok: false, error };
411
+ }
412
+ }
413
+
414
+ // src/orchestrator.ts
415
+ import { randomUUID as randomUUID2 } from "node:crypto";
416
+
417
+ // src/agentActivity.ts
418
+ var RECENT_WINDOW_MS = 6e3;
419
+ var IDLE_AFTER_MS = 45e3;
420
+ var EVAL_INTERVAL_MS = 2e3;
421
+ var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
422
+ var BOX_DRAWING_PATTERN = /[─-╿]/g;
423
+ var WORKING_PATTERN = /esc to interrupt/i;
424
+ var USAGE_LIMIT_PATTERN = /(usage limit reached|limit reached[^\n]{0,60}resets|reached your (?:usage|session) limit|out of (?:weekly|session) (?:usage|limit))/i;
425
+ var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
426
+ var USER_PROMPT_PATTERNS = [
427
+ /do you want to\b/i,
428
+ /would you like to\b/i,
429
+ /do you trust the files\b/i,
430
+ /press enter to continue/i,
431
+ /\(y\/n\)/i,
432
+ /❯\s*1\.\s/
433
+ ];
434
+ function normalizeTerminalOutput(raw) {
435
+ return raw.replace(ANSI_PATTERN, "").replace(BOX_DRAWING_PATTERN, " ").replace(/[ \t]+/g, " ");
436
+ }
437
+ function classifyAgentOutput(recentOutput, options) {
438
+ if (WORKING_PATTERN.test(recentOutput)) return "implementing";
439
+ if (options.usageLimited) return "usage_limited";
440
+ if (options.planApprovalPending) return "waiting_plan_approval";
441
+ if (USER_PROMPT_PATTERNS.some((pattern) => pattern.test(recentOutput))) return "waiting_user";
442
+ if (options.silentForMs >= IDLE_AFTER_MS) return "waiting_user";
443
+ return options.previous;
444
+ }
445
+ var AgentActivityWatcher = class {
446
+ constructor(onChange) {
447
+ this.onChange = onChange;
448
+ this.timer = setInterval(() => this.evaluate(), EVAL_INTERVAL_MS);
449
+ this.timer.unref();
450
+ }
451
+ chunks = [];
452
+ state = "implementing";
453
+ lastOutputAt = Date.now();
454
+ usageLimited = false;
455
+ planApprovalPending = false;
456
+ timer;
457
+ push(chunk) {
458
+ const now = Date.now();
459
+ const text = normalizeTerminalOutput(chunk);
460
+ this.lastOutputAt = now;
461
+ this.chunks.push({ at: now, text });
462
+ if (USAGE_LIMIT_PATTERN.test(text)) this.usageLimited = true;
463
+ if (PLAN_APPROVAL_PATTERN.test(text)) this.planApprovalPending = true;
464
+ if (WORKING_PATTERN.test(text)) {
465
+ this.usageLimited = false;
466
+ this.planApprovalPending = false;
467
+ }
468
+ this.evaluate(now);
469
+ }
470
+ stop() {
471
+ clearInterval(this.timer);
472
+ }
473
+ evaluate(now = Date.now()) {
474
+ while (this.chunks.length > 0 && now - this.chunks[0].at > RECENT_WINDOW_MS) this.chunks.shift();
475
+ const next = classifyAgentOutput(this.chunks.map((chunk) => chunk.text).join(""), {
476
+ silentForMs: now - this.lastOutputAt,
477
+ usageLimited: this.usageLimited,
478
+ planApprovalPending: this.planApprovalPending,
479
+ previous: this.state
480
+ });
481
+ if (next === this.state) return;
482
+ this.state = next;
483
+ this.onChange(next);
484
+ }
485
+ };
486
+
487
+ // src/agentSession.ts
488
+ import { randomUUID } from "node:crypto";
489
+ import { spawn as spawn3 } from "node-pty";
490
+
491
+ // src/sessionName.ts
492
+ var MAX_LENGTH = 60;
493
+ var FALLBACK_NAME = "card";
494
+ function toSessionName(title) {
495
+ const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
496
+ if (slug.length === 0) return FALLBACK_NAME;
497
+ if (slug.length <= MAX_LENGTH) return slug;
498
+ const truncated = slug.slice(0, MAX_LENGTH);
499
+ const lastHyphen = truncated.lastIndexOf("-");
500
+ return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
501
+ }
502
+
503
+ // src/statuslineScript.ts
504
+ import { writeFileSync as writeFileSync3 } from "node:fs";
505
+ import { tmpdir } from "node:os";
506
+ import { join as join4 } from "node:path";
507
+ var CLAUDE_BINARY = process.platform === "win32" ? "claude.exe" : "claude";
508
+ var STATUSLINE_SCRIPT_PATH = join4(tmpdir(), "agent-board-statusline.cjs");
509
+ var SCRIPT_SOURCE = `#!/usr/bin/env node
510
+ "use strict";
511
+ const http = require("node:http");
512
+
513
+ function readStdin() {
514
+ return new Promise((resolve) => {
515
+ let raw = "";
516
+ process.stdin.setEncoding("utf8");
517
+ process.stdin.on("data", (chunk) => { raw += chunk; });
518
+ process.stdin.on("end", () => resolve(raw));
519
+ process.stdin.on("error", () => resolve(raw));
520
+ });
521
+ }
522
+
523
+ function formatPercent(value) {
524
+ return typeof value === "number" ? \`\${Math.round(value)}%\` : "?";
525
+ }
526
+
527
+ function buildDisplayText(data) {
528
+ const rateLimits = data && data.rate_limits;
529
+ if (!rateLimits) return "";
530
+ const parts = [];
531
+ if (rateLimits.five_hour) parts.push(\`5h \${formatPercent(rateLimits.five_hour.used_percentage)}\`);
532
+ if (rateLimits.seven_day) parts.push(\`7d \${formatPercent(rateLimits.seven_day.used_percentage)}\`);
533
+ return parts.join(" \xB7 ");
534
+ }
535
+
536
+ function reportToRunner(sessionId, ticket, port, rateLimits) {
537
+ return new Promise((resolve) => {
538
+ try {
539
+ const body = JSON.stringify({ sessionId, ticket, fiveHour: rateLimits.five_hour, sevenDay: rateLimits.seven_day });
540
+ const req = http.request(
541
+ {
542
+ host: "127.0.0.1",
543
+ port,
544
+ path: "/statusline",
545
+ method: "POST",
546
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
547
+ timeout: 1000,
548
+ },
549
+ (res) => {
550
+ res.resume();
551
+ resolve();
552
+ },
553
+ );
554
+ req.on("error", () => resolve());
555
+ req.on("timeout", () => { req.destroy(); resolve(); });
556
+ req.end(body);
557
+ } catch {
558
+ resolve();
559
+ }
560
+ });
561
+ }
562
+
563
+ (async () => {
564
+ try {
565
+ const raw = await readStdin();
566
+ const data = JSON.parse(raw);
567
+ process.stdout.write(buildDisplayText(data));
568
+
569
+ const sessionId = process.env.AGENT_BOARD_SESSION_ID;
570
+ const ticket = process.env.AGENT_BOARD_STATUSLINE_TICKET;
571
+ const port = Number(process.env.AGENT_BOARD_STATUSLINE_PORT);
572
+ if (sessionId && ticket && port && data && data.rate_limits) {
573
+ await reportToRunner(sessionId, ticket, port, data.rate_limits);
574
+ }
575
+ } catch {
576
+ // Never let a malformed/unexpected payload break the status line itself.
577
+ }
578
+ process.exit(0);
579
+ })();
580
+ `;
581
+ function ensureStatuslineScriptWritten() {
582
+ writeFileSync3(STATUSLINE_SCRIPT_PATH, SCRIPT_SOURCE, { mode: 493 });
583
+ logger.runner.info("STATUSLINE", `Status line script written to ${STATUSLINE_SCRIPT_PATH}.`);
584
+ }
585
+ function statuslineSettingsArgs() {
586
+ return [
587
+ "--settings",
588
+ JSON.stringify({
589
+ statusLine: {
590
+ type: "command",
591
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(STATUSLINE_SCRIPT_PATH)}`
592
+ }
593
+ })
594
+ ];
595
+ }
596
+ function statuslineEnv(sessionId, ticket) {
597
+ return {
598
+ ...process.env,
599
+ AGENT_BOARD_SESSION_ID: sessionId,
600
+ AGENT_BOARD_STATUSLINE_TICKET: ticket,
601
+ AGENT_BOARD_STATUSLINE_PORT: String(process.env.AGENT_BOARD_TERMINAL_PORT ?? 8787),
602
+ // The Claude CLI itself (not our code) occasionally logs Node's DEP0169 url.parse()
603
+ // deprecation warning into the pty, which shows up in the card terminal looking like
604
+ // it came from the runner. --no-deprecation only reaches this child process's Node
605
+ // runtime, appended so it doesn't clobber a NODE_OPTIONS the user already has set.
606
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, "--no-deprecation"].filter(Boolean).join(" ")
607
+ };
608
+ }
609
+
610
+ // src/agentSession.ts
611
+ var TUI_IDLE_MS = 1500;
612
+ var TUI_READY_TIMEOUT_MS = 6e4;
613
+ var SUBMIT_DELAY_MS = 150;
614
+ var PASTE_START = "\x1B[200~";
615
+ var PASTE_END = "\x1B[201~";
616
+ var SHIFT_TAB = "\x1B[Z";
617
+ var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
618
+ var SHIFT_TAB_DELAY_MS = 400;
619
+ var PLAN_MODE_INDICATOR = /plan mode on/i;
620
+ function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
621
+ const claudeSessionId = randomUUID();
622
+ const pty = spawn3(
623
+ CLAUDE_BINARY,
624
+ [
625
+ "--dangerously-skip-permissions",
626
+ "--model",
627
+ "claude-sonnet-5",
628
+ "--session-id",
629
+ claudeSessionId,
630
+ ...statuslineSettingsArgs(),
631
+ `/rename ${toSessionName(cardTitle)}`
632
+ ],
633
+ {
634
+ name: "xterm-color",
635
+ cols: 120,
636
+ rows: 32,
637
+ cwd: repoPath,
638
+ env: statuslineEnv(sessionId, ticket)
639
+ }
640
+ );
641
+ sendMessageWhenIdle(pty, cardId, instructions, planFirst);
642
+ return { pty, claudeSessionId };
643
+ }
644
+ function sendMessageWhenIdle(pty, cardId, message, planFirst) {
645
+ let idleTimer;
646
+ let deadlineTimer;
647
+ let dataSubscription;
648
+ let settled = false;
649
+ const stopWaiting = () => {
650
+ if (settled) return false;
651
+ settled = true;
652
+ clearTimeout(idleTimer);
653
+ clearTimeout(deadlineTimer);
654
+ dataSubscription?.dispose();
655
+ return true;
656
+ };
657
+ const send = async () => {
658
+ if (!stopWaiting()) return;
659
+ if (planFirst) await enterPlanMode(pty, cardId);
660
+ try {
661
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
662
+ setTimeout(() => {
663
+ try {
664
+ pty.write("\r");
665
+ } catch (err) {
666
+ logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
667
+ }
668
+ }, SUBMIT_DELAY_MS);
669
+ } catch (err) {
670
+ logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
671
+ }
672
+ };
673
+ deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
674
+ dataSubscription = pty.onData(() => {
675
+ clearTimeout(idleTimer);
676
+ idleTimer = setTimeout(send, TUI_IDLE_MS);
677
+ });
678
+ pty.onExit(() => stopWaiting());
679
+ }
680
+ async function enterPlanMode(pty, cardId) {
681
+ let recentOutput = "";
682
+ const subscription = pty.onData((chunk) => {
683
+ recentOutput += normalizeTerminalOutput(chunk);
684
+ });
685
+ try {
686
+ for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
687
+ pty.write(SHIFT_TAB);
688
+ await new Promise((resolve4) => setTimeout(resolve4, SHIFT_TAB_DELAY_MS));
689
+ }
690
+ if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
691
+ logger.card.warn(
692
+ cardId,
693
+ `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.`
694
+ );
695
+ }
696
+ } catch (err) {
697
+ logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
698
+ } finally {
699
+ subscription.dispose();
700
+ }
701
+ }
702
+ function sendContinue(pty, cardId) {
703
+ try {
704
+ pty.write(`${PASTE_START}continue${PASTE_END}`);
705
+ setTimeout(() => {
706
+ try {
707
+ pty.write("\r");
708
+ } catch (err) {
709
+ logger.card.error(cardId, 'Failed to submit the auto-resume "continue" message:', err);
710
+ }
711
+ }, SUBMIT_DELAY_MS);
712
+ } catch (err) {
713
+ logger.card.error(cardId, 'Failed to send the auto-resume "continue" message:', err);
714
+ }
715
+ }
716
+ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
717
+ return spawn3(
718
+ CLAUDE_BINARY,
719
+ [
720
+ "--dangerously-skip-permissions",
721
+ "--model",
722
+ "claude-sonnet-5",
723
+ "--resume",
724
+ claudeSessionId,
725
+ ...statuslineSettingsArgs()
726
+ ],
727
+ {
728
+ name: "xterm-color",
729
+ cols: 120,
730
+ rows: 32,
731
+ cwd: repoPath,
732
+ env: statuslineEnv(sessionId, ticket)
733
+ }
734
+ );
735
+ }
736
+
737
+ // ../../packages/shared-types/dist/index.js
738
+ var CARD_IMAGES_BUCKET = "agent-board-card-images";
739
+ var MAX_CARD_IMAGE_BYTES = 10 * 1024 * 1024;
740
+
741
+ // src/cardImages.ts
742
+ import { mkdir, writeFile } from "node:fs/promises";
743
+ import { tmpdir as tmpdir2 } from "node:os";
744
+ import { extname, join as join5 } from "node:path";
745
+ var IMAGES_ROOT = join5(tmpdir2(), "agent-board", "card-images");
746
+ function sanitizeFileName(fileName) {
747
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
748
+ }
749
+ async function downloadCardImages(supabase, cardId) {
750
+ const { data, error } = await supabase.from("card_images").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
751
+ if (error) {
752
+ logger.card.error(cardId, "Failed to list images:", error.message);
753
+ return [];
754
+ }
755
+ const rows = data ?? [];
756
+ if (rows.length === 0) return [];
757
+ const targetDir = join5(IMAGES_ROOT, cardId);
758
+ await mkdir(targetDir, { recursive: true });
759
+ const paths = [];
760
+ for (const row of rows) {
761
+ const baseName = sanitizeFileName(row.file_name);
762
+ const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
763
+ const localPath = join5(targetDir, `${row.id}-${fileName}`);
764
+ try {
765
+ const { data: blob, error: downloadError } = await supabase.storage.from(CARD_IMAGES_BUCKET).download(row.storage_path);
766
+ if (downloadError) throw downloadError;
767
+ await writeFile(localPath, Buffer.from(await blob.arrayBuffer()));
768
+ paths.push(localPath);
769
+ } catch (err) {
770
+ logger.card.error(cardId, `Failed to download image ${row.storage_path}:`, err);
771
+ }
772
+ }
773
+ return paths;
774
+ }
775
+ function buildInstructionsWithImages(instructions, imagePaths) {
776
+ if (imagePaths.length === 0) return instructions;
777
+ const list = imagePaths.map((path) => `- ${path}`).join("\n");
778
+ return `${instructions}
779
+
780
+ Images attached to this card (read each one before starting; they are part of the task):
781
+ ${list}`;
782
+ }
783
+
784
+ // src/fileReferences.ts
785
+ function buildInstructionsWithFileReferences(instructions, fileReferences) {
786
+ if (fileReferences.length === 0) return instructions;
787
+ const list = fileReferences.map((path) => `- ${path}`).join("\n");
788
+ return `${instructions}
789
+
790
+ Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
791
+ ${list}`;
792
+ }
793
+
794
+ // src/ptyRegistry.ts
795
+ var MAX_BUFFERED_CHUNKS = 2e3;
796
+ var registry = /* @__PURE__ */ new Map();
797
+ function registerPtySession(sessionId, pty, ticket) {
798
+ const handle = { pty, ticket, outputBuffer: [], sockets: /* @__PURE__ */ new Set() };
799
+ pty.onData((data) => {
800
+ handle.outputBuffer.push(data);
801
+ if (handle.outputBuffer.length > MAX_BUFFERED_CHUNKS) handle.outputBuffer.shift();
802
+ for (const socket of handle.sockets) {
803
+ if (socket.readyState === socket.OPEN) socket.send(data);
804
+ }
805
+ });
806
+ registry.set(sessionId, handle);
807
+ }
808
+ function getPtySession(sessionId) {
809
+ return registry.get(sessionId);
810
+ }
811
+ function resizePtySession(sessionId, cols, rows) {
812
+ const handle = registry.get(sessionId);
813
+ if (!handle) return;
814
+ handle.pty.resize(cols, rows);
815
+ }
816
+ function stopPtySession(sessionId) {
817
+ const handle = registry.get(sessionId);
818
+ if (!handle) return;
819
+ for (const socket of handle.sockets) socket.close(4e3, "Session ended");
820
+ handle.pty.kill();
821
+ registry.delete(sessionId);
822
+ }
823
+
824
+ // src/activityLog.ts
825
+ async function logActivity(supabase, params) {
826
+ try {
827
+ const { error } = await supabase.from("activity_log").insert({
828
+ project_id: params.projectId,
829
+ card_id: params.cardId,
830
+ user_id: params.userId,
831
+ event_type: params.eventType,
832
+ payload: params.payload ?? {}
833
+ });
834
+ if (error) throw error;
835
+ } catch (err) {
836
+ logger.runner.error("ACTIVITY", `Failed to record activity log entry (${params.eventType}):`, err);
837
+ }
838
+ }
839
+
840
+ // src/sessions.ts
841
+ async function countRunningSessions(supabase, projectId, ownerUserId) {
842
+ const { count, error } = await supabase.from("sessions").select("id, cards!inner(project_id, assigned_user_id)", { count: "exact", head: true }).eq("cards.project_id", projectId).eq("cards.assigned_user_id", ownerUserId).eq("status", "running");
843
+ if (error) throw error;
844
+ return count ?? 0;
845
+ }
846
+ async function listQueuedSessionsForRunner(supabase, ownerUserId) {
847
+ const { data, error } = await supabase.from("sessions").select(
848
+ "id, card_id, claude_session_id, created_at, cards!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
849
+ ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
850
+ if (error) throw error;
851
+ return data;
852
+ }
853
+ async function createSession(supabase, userId, cardId) {
854
+ const { data, error } = await supabase.from("sessions").insert({ card_id: cardId, user_id: userId, status: "queued" }).select("id").single();
855
+ if (error) throw error;
856
+ return data.id;
857
+ }
858
+ async function updateSession(supabase, sessionId, changes, activityContext) {
859
+ const { error } = await supabase.from("sessions").update({
860
+ ...changes.status !== void 0 ? { status: changes.status } : {},
861
+ ...changes.agentState !== void 0 ? { agent_state: changes.agentState } : {},
862
+ ...changes.startedAt !== void 0 ? { started_at: changes.startedAt } : {},
863
+ ...changes.endedAt !== void 0 ? { ended_at: changes.endedAt } : {},
864
+ ...changes.terminalTicket !== void 0 ? { terminal_ticket: changes.terminalTicket } : {},
865
+ ...changes.terminalTicketExpiresAt !== void 0 ? { terminal_ticket_expires_at: changes.terminalTicketExpiresAt } : {},
866
+ ...changes.claudeSessionId !== void 0 ? { claude_session_id: changes.claudeSessionId } : {}
867
+ }).eq("id", sessionId);
868
+ if (error) throw error;
869
+ if (changes.status !== void 0 && activityContext) {
870
+ await logActivity(supabase, {
871
+ projectId: activityContext.projectId,
872
+ cardId: activityContext.cardId,
873
+ userId: activityContext.userId,
874
+ eventType: "session_status_changed",
875
+ payload: { status: changes.status }
876
+ });
877
+ }
878
+ }
879
+ async function getActiveSessionForCard(supabase, cardId) {
880
+ const { data, error } = await supabase.from("sessions").select("id").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
881
+ if (error) throw error;
882
+ return data;
883
+ }
884
+ async function updateCard(supabase, cardId, changes) {
885
+ const { error } = await supabase.from("cards").update({
886
+ ...changes.column !== void 0 ? { column_name: changes.column } : {},
887
+ ...changes.previewUrls !== void 0 ? { preview_urls: changes.previewUrls } : {},
888
+ ...changes.deployError !== void 0 ? { deploy_error: changes.deployError } : {}
889
+ }).eq("id", cardId);
890
+ if (error) throw error;
891
+ }
892
+ async function getProject(supabase, projectId) {
893
+ const { data, error } = await supabase.from("projects").select("*").eq("id", projectId).single();
894
+ if (error) throw error;
895
+ return data;
896
+ }
897
+ async function getProjectRepos(supabase, projectId) {
898
+ const { data, error } = await supabase.from("project_repos").select("subpath, dev_command").eq("project_id", projectId);
899
+ if (error) throw error;
900
+ return data;
901
+ }
902
+ async function listCardsInColumn(supabase, projectId, column) {
903
+ const { data, error } = await supabase.from("cards").select("id, project_id, title, instructions, column_name, assigned_user_id, deploy_error, file_references, preview_urls").eq("project_id", projectId).eq("column_name", column);
904
+ if (error) throw error;
905
+ return data;
906
+ }
907
+ async function clearProjectDeployRequest(supabase, projectId) {
908
+ const { error } = await supabase.from("projects").update({ deploy_requested_at: null }).eq("id", projectId);
909
+ if (error) throw error;
910
+ }
911
+ async function clearDevServerRestartRequest(supabase, projectId) {
912
+ const { error } = await supabase.from("projects").update({ dev_server_restart_requested_at: null }).eq("id", projectId);
913
+ if (error) throw error;
914
+ }
915
+ async function updateProjectFilesTicket(supabase, projectId, ticket, expiresAt) {
916
+ const { error } = await supabase.from("projects").update({ files_ticket: ticket, files_ticket_expires_at: expiresAt }).eq("id", projectId);
917
+ if (error) throw error;
918
+ }
919
+ async function updateRunnerSetupTicket(supabase, runnerId, ticket, expiresAt, allowedRootPath) {
920
+ const { error } = await supabase.from("runners").update({ setup_ticket: ticket, setup_ticket_expires_at: expiresAt, allowed_root_path: allowedRootPath }).eq("id", runnerId);
921
+ if (error) throw error;
922
+ }
923
+ async function listMyProjects(supabase) {
924
+ const { data, error } = await supabase.from("projects").select("id, runner_id, repo_path, logo_path");
925
+ if (error) throw error;
926
+ return data;
927
+ }
928
+ async function listProjectRunnerIds(supabase, runnerId) {
929
+ const { data, error } = await supabase.from("project_runners").select("project_id").eq("runner_id", runnerId);
930
+ if (error) throw error;
931
+ return new Set(data.map((row) => row.project_id));
932
+ }
933
+ async function addProjectRunner(supabase, projectId, runnerId) {
934
+ const { error } = await supabase.from("project_runners").insert({ project_id: projectId, runner_id: runnerId });
935
+ if (error) throw error;
936
+ }
937
+ async function getRunnerMaxConcurrentProjects(supabase, runnerId) {
938
+ const { data, error } = await supabase.from("runners").select("max_concurrent_projects").eq("id", runnerId).single();
939
+ if (error) throw error;
940
+ return data.max_concurrent_projects;
941
+ }
942
+ async function updateProjectLogoPath(supabase, projectId, logoPath) {
943
+ const { error } = await supabase.from("projects").update({ logo_path: logoPath }).eq("id", projectId);
944
+ if (error) throw error;
945
+ }
946
+ async function getClaudeUsage(supabase, userId) {
947
+ const { data, error } = await supabase.from("claude_usage").select("session_resets_at, week_resets_at").eq("user_id", userId).maybeSingle();
948
+ if (error) throw error;
949
+ if (!data) return null;
950
+ return { sessionResetsAt: data.session_resets_at, weekResetsAt: data.week_resets_at };
951
+ }
952
+ async function upsertClaudeUsage(supabase, userId, changes) {
953
+ const { error } = await supabase.from("claude_usage").upsert({
954
+ user_id: userId,
955
+ ...changes.sessionPercent !== void 0 ? { session_pct: changes.sessionPercent } : {},
956
+ ...changes.sessionResetsAt !== void 0 ? { session_resets_at: changes.sessionResetsAt } : {},
957
+ ...changes.weekPercent !== void 0 ? { week_pct: changes.weekPercent } : {},
958
+ ...changes.weekResetsAt !== void 0 ? { week_resets_at: changes.weekResetsAt } : {},
959
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
960
+ });
961
+ if (error) throw error;
962
+ }
963
+
964
+ // src/usageResume.ts
965
+ var RESET_BUFFER_MS = 3e4;
966
+ var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
967
+ var pending = /* @__PURE__ */ new Map();
968
+ function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
969
+ if (pending.has(sessionId)) return;
970
+ void (async () => {
971
+ const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
972
+ logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
973
+ return null;
974
+ });
975
+ const now = Date.now();
976
+ const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
977
+ const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
978
+ const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
979
+ logger.card.info(
980
+ cardId,
981
+ 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.`
982
+ );
983
+ const timer = setTimeout(() => {
984
+ pending.delete(sessionId);
985
+ sendContinue(pty, cardId);
986
+ }, delay);
987
+ timer.unref();
988
+ pending.set(sessionId, timer);
989
+ })();
990
+ }
991
+ function cancelUsageResume(sessionId) {
992
+ const timer = pending.get(sessionId);
993
+ if (!timer) return;
994
+ clearTimeout(timer);
995
+ pending.delete(sessionId);
996
+ }
997
+
998
+ // src/orchestrator.ts
999
+ var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
1000
+ var inFlightCardIds = /* @__PURE__ */ new Set();
1001
+ var activeSessionByCard = /* @__PURE__ */ new Map();
1002
+ var dispatchingRunner = false;
1003
+ async function scanForWork(supabase, config) {
1004
+ for (const project of config.registeredProjects) {
1005
+ await reconcileStaleSessions(supabase, config, project.id);
1006
+ const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
1007
+ if (error) {
1008
+ logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
1009
+ continue;
1010
+ }
1011
+ for (const card of cards ?? []) {
1012
+ void enqueueCard(supabase, config, card);
1013
+ }
1014
+ }
1015
+ void dispatchRunnerQueue(supabase, config);
1016
+ }
1017
+ async function enqueueCard(supabase, config, card) {
1018
+ if (inFlightCardIds.has(card.id)) return;
1019
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
1020
+ if (!registered) {
1021
+ logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
1022
+ return;
1023
+ }
1024
+ if (card.assigned_user_id !== config.ownerUserId) return;
1025
+ inFlightCardIds.add(card.id);
1026
+ try {
1027
+ const active = await getActiveSessionForCard(supabase, card.id);
1028
+ if (active) return;
1029
+ await createQueuedSession(supabase, config, card);
1030
+ } finally {
1031
+ inFlightCardIds.delete(card.id);
1032
+ }
1033
+ await dispatchRunnerQueue(supabase, config);
1034
+ }
1035
+ async function createQueuedSession(supabase, config, card) {
1036
+ const sessionId = await createSession(supabase, config.ownerUserId, card.id);
1037
+ logger.card.info(card.id, `"${card.title}" queued (session ${sessionId}).`);
1038
+ }
1039
+ async function reconcileStaleSessions(supabase, config, projectId) {
1040
+ const { data, error } = await supabase.from("sessions").select("id, card_id, cards!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"]).neq("cards.column_name", "desenvolvimento");
1041
+ if (error) {
1042
+ logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
1043
+ return;
1044
+ }
1045
+ const stale = data ?? [];
1046
+ if (stale.length === 0) return;
1047
+ for (const { id: sessionId, card_id: cardId } of stale) {
1048
+ stopPtySession(sessionId);
1049
+ cancelUsageResume(sessionId);
1050
+ activeSessionByCard.delete(cardId);
1051
+ await updateSession(
1052
+ supabase,
1053
+ sessionId,
1054
+ { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1055
+ { projectId, cardId, userId: config.ownerUserId }
1056
+ );
1057
+ logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
1058
+ }
1059
+ await dispatchRunnerQueue(supabase, config);
1060
+ }
1061
+ async function dispatchRunnerQueue(supabase, config) {
1062
+ if (dispatchingRunner) return;
1063
+ dispatchingRunner = true;
1064
+ try {
1065
+ const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
1066
+ if (queued.length === 0) return;
1067
+ const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
1068
+ const runningProjectIds = /* @__PURE__ */ new Set();
1069
+ for (const project of config.registeredProjects) {
1070
+ const runningCount = await countRunningSessions(supabase, project.id, config.ownerUserId);
1071
+ if (runningCount > 0) runningProjectIds.add(project.id);
1072
+ }
1073
+ let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
1074
+ if (freeProjectSlots <= 0) return;
1075
+ const promotedThisPass = /* @__PURE__ */ new Set();
1076
+ for (const entry of queued) {
1077
+ const projectId = entry.cards.project_id;
1078
+ if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
1079
+ if (freeProjectSlots <= 0) break;
1080
+ try {
1081
+ await promoteQueuedSession(supabase, config, entry);
1082
+ promotedThisPass.add(projectId);
1083
+ freeProjectSlots -= 1;
1084
+ } catch (err) {
1085
+ logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
1086
+ }
1087
+ }
1088
+ } finally {
1089
+ dispatchingRunner = false;
1090
+ }
1091
+ }
1092
+ async function promoteQueuedSession(supabase, config, entry) {
1093
+ const spawned = await spawnSessionPty(supabase, config, entry);
1094
+ if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
1095
+ }
1096
+ async function spawnSessionPty(supabase, config, entry) {
1097
+ const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
1098
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
1099
+ if (!registered) {
1100
+ logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
1101
+ await updateSession(
1102
+ supabase,
1103
+ sessionId,
1104
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1105
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
1106
+ );
1107
+ return false;
1108
+ }
1109
+ const ticket = randomUUID2();
1110
+ let pty;
1111
+ let newClaudeSessionId = null;
1112
+ try {
1113
+ if (claudeSessionId) {
1114
+ pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
1115
+ } else {
1116
+ const imagePaths = await downloadCardImages(supabase, cardId);
1117
+ const instructions = buildInstructionsWithFileReferences(
1118
+ buildInstructionsWithImages(card.instructions, imagePaths),
1119
+ card.file_references
1120
+ );
1121
+ const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
1122
+ pty = spawned.pty;
1123
+ newClaudeSessionId = spawned.claudeSessionId;
1124
+ }
1125
+ } catch (err) {
1126
+ logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
1127
+ await updateSession(
1128
+ supabase,
1129
+ sessionId,
1130
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1131
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
1132
+ );
1133
+ return false;
1134
+ }
1135
+ registerPtySession(sessionId, pty, ticket);
1136
+ activeSessionByCard.set(cardId, sessionId);
1137
+ const activityWatcher = watchAgentActivity(supabase, config, cardId, sessionId, pty);
1138
+ await updateSession(
1139
+ supabase,
1140
+ sessionId,
1141
+ {
1142
+ status: "running",
1143
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1144
+ terminalTicket: ticket,
1145
+ terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
1146
+ // Only stamp a fresh spawn as "implementing" — a resumed session (claudeSessionId was
1147
+ // already set) keeps whatever agent_state it had before the restart (e.g.
1148
+ // "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
1149
+ // freshly (re)constructed AgentActivityWatcher below will correct it the moment real
1150
+ // output gives it something conclusive to classify.
1151
+ ...newClaudeSessionId ? { agentState: "implementing", claudeSessionId: newClaudeSessionId } : {}
1152
+ },
1153
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
1154
+ );
1155
+ pty.onExit(({ exitCode }) => {
1156
+ 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.`);
1157
+ activityWatcher.stop();
1158
+ cancelUsageResume(sessionId);
1159
+ activeSessionByCard.delete(cardId);
1160
+ });
1161
+ return true;
1162
+ }
1163
+ function watchAgentActivity(supabase, config, cardId, sessionId, pty) {
1164
+ const watcher = new AgentActivityWatcher((state) => {
1165
+ updateSession(supabase, sessionId, { agentState: state }).catch(
1166
+ (err) => logger.card.error(cardId, `Failed to publish agent state "${state}" for session ${sessionId}:`, err)
1167
+ );
1168
+ if (state === "usage_limited") {
1169
+ scheduleUsageResume(supabase, config, cardId, sessionId, pty);
1170
+ } else {
1171
+ cancelUsageResume(sessionId);
1172
+ }
1173
+ });
1174
+ pty.onData((chunk) => watcher.push(chunk));
1175
+ return watcher;
1176
+ }
1177
+ async function reconcileOrphanedSessions(supabase, config) {
1178
+ for (const project of config.registeredProjects) {
1179
+ const { data, error } = await supabase.from("sessions").select("id, card_id, claude_session_id, created_at, cards!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)").eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).eq("status", "running");
1180
+ if (error) {
1181
+ logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
1182
+ continue;
1183
+ }
1184
+ for (const entry of data ?? []) {
1185
+ let spawned;
1186
+ try {
1187
+ spawned = await spawnSessionPty(supabase, config, entry);
1188
+ } catch (err) {
1189
+ logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
1190
+ continue;
1191
+ }
1192
+ if (spawned) {
1193
+ logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
1194
+ }
1195
+ }
1196
+ }
1197
+ }
1198
+ async function reconcileUsageLimitedSessions(supabase, config) {
1199
+ for (const project of config.registeredProjects) {
1200
+ const { data, error } = await supabase.from("sessions").select("id, card_id, cards!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");
1201
+ if (error) {
1202
+ logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
1203
+ continue;
1204
+ }
1205
+ for (const { id: sessionId, card_id: cardId } of data ?? []) {
1206
+ const handle = getPtySession(sessionId);
1207
+ if (!handle) continue;
1208
+ scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
1209
+ }
1210
+ }
1211
+ }
1212
+ async function stopCardSession(supabase, config, card) {
1213
+ const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
1214
+ if (!sessionId) return;
1215
+ stopPtySession(sessionId);
1216
+ cancelUsageResume(sessionId);
1217
+ activeSessionByCard.delete(card.id);
1218
+ await updateSession(
1219
+ supabase,
1220
+ sessionId,
1221
+ { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1222
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
1223
+ );
1224
+ logger.card.info(card.id, `Session ${sessionId} stopped.`);
1225
+ await dispatchRunnerQueue(supabase, config);
1226
+ }
1227
+
1228
+ // src/deploy.ts
1229
+ async function handleDeployRequested(supabase, config, project) {
1230
+ const registered = config.registeredProjects.find((candidate) => candidate.id === project.id);
1231
+ if (!registered) {
1232
+ logger.runner.warn("DEPLOY", `Ignoring deploy request: project ${project.id} is not registered on this runner.`);
1233
+ return;
1234
+ }
1235
+ const allCards = await listCardsInColumn(supabase, project.id, "deploy");
1236
+ const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
1237
+ if (cards.length === 0) {
1238
+ logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
1239
+ await clearProjectDeployRequest(supabase, project.id);
1240
+ return;
1241
+ }
1242
+ const children = await getProjectRepos(supabase, project.id);
1243
+ const repoRoot = resolveRepoPath(config, project.repo_path);
1244
+ const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join6(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
1245
+ const failures = [];
1246
+ for (const target of targets) {
1247
+ const result = await pushBranchToMain(target.path);
1248
+ if (result.ok) {
1249
+ logger.runner.info("DEPLOY", target.label ? `Pushed HEAD to main in "${target.label}".` : "Pushed HEAD to main.");
1250
+ } else {
1251
+ logger.runner.error("DEPLOY", target.label ? `Push to main failed in "${target.label}": ${result.error}` : `Push to main failed: ${result.error}`);
1252
+ failures.push(target.label ? `${target.label}: ${result.error}` : result.error);
1253
+ }
1254
+ }
1255
+ if (failures.length > 0) {
1256
+ const message = failures.join("; ");
1257
+ await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: message })));
1258
+ await clearProjectDeployRequest(supabase, project.id);
1259
+ return;
1260
+ }
1261
+ await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: null, column: "fim" })));
1262
+ await clearProjectDeployRequest(supabase, project.id);
1263
+ }
1264
+ async function checkPendingDeploys(supabase, config) {
1265
+ for (const registered of config.registeredProjects) {
1266
+ const project = await getProject(supabase, registered.id);
1267
+ if (project.deploy_requested_at) {
1268
+ await handleDeployRequested(supabase, config, project);
1269
+ }
1270
+ }
1271
+ }
1272
+ async function handleCardReachedFim(supabase, config, card) {
1273
+ await stopCardSession(supabase, config, card);
1274
+ releaseCard(card.id);
1275
+ logger.card.info(card.id, 'Reached "fim" \u2014 session stopped, preview tunnel (if any) closed.');
1276
+ }
1277
+
1278
+ // src/filesServer.ts
1279
+ import { execFile as execFile2 } from "node:child_process";
1280
+ import { randomUUID as randomUUID5 } from "node:crypto";
1281
+ import { existsSync as existsSync4, lstatSync, readdirSync, readFileSync as readFileSync4 } from "node:fs";
1282
+ import { join as join8, resolve as resolve2, sep } from "node:path";
1283
+ import { parse } from "node:url";
1284
+ import { promisify as promisify2 } from "node:util";
1285
+
1286
+ // src/shellSession.ts
1287
+ import { randomUUID as randomUUID3 } from "node:crypto";
1288
+ import { spawn as spawn4 } from "node-pty";
1289
+ var SHELL_COMMAND = process.platform === "win32" ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "bash";
1290
+ function openShellSession(repoPath) {
1291
+ const sessionId = `shell:${randomUUID3()}`;
1292
+ const ticket = randomUUID3();
1293
+ const pty = spawn4(SHELL_COMMAND, [], {
1294
+ name: "xterm-color",
1295
+ cols: 120,
1296
+ rows: 32,
1297
+ cwd: repoPath
1298
+ });
1299
+ registerPtySession(sessionId, pty, ticket);
1300
+ pty.onExit(({ exitCode }) => {
1301
+ logger.runner.info("SHELL", `Standalone shell ${sessionId} exited (code ${exitCode}).`);
1302
+ stopPtySession(sessionId);
1303
+ });
1304
+ return { sessionId, ticket };
1305
+ }
1306
+
1307
+ // src/usageMonitor.ts
1308
+ import { randomUUID as randomUUID4 } from "node:crypto";
1309
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1310
+ import { homedir as homedir3, tmpdir as tmpdir3 } from "node:os";
1311
+ import { join as join7 } from "node:path";
1312
+ import { spawn as spawn5 } from "node-pty";
1313
+ var SESSION_ID = "usage-monitor";
1314
+ var TICKET = randomUUID4();
1315
+ var SCRATCH_DIR = join7(tmpdir3(), "agent-board-usage-monitor");
1316
+ var POLL_INTERVAL_MS = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
1317
+ var IDLE_MS = 2e3;
1318
+ var HARD_TIMEOUT_MS = 6e4;
1319
+ function usageMonitorSessionId() {
1320
+ return SESSION_ID;
1321
+ }
1322
+ function usageMonitorTicket() {
1323
+ return TICKET;
1324
+ }
1325
+ function ensureScratchDirTrusted() {
1326
+ const claudeJsonPath = join7(homedir3(), ".claude.json");
1327
+ if (!existsSync3(claudeJsonPath)) return;
1328
+ try {
1329
+ const raw = readFileSync3(claudeJsonPath, "utf8");
1330
+ const data = JSON.parse(raw);
1331
+ data.projects ??= {};
1332
+ const keys = [SCRATCH_DIR, SCRATCH_DIR.replace(/\\/g, "/")];
1333
+ const alreadyTrusted = keys.every((key) => data.projects[key]?.hasTrustDialogAccepted === true);
1334
+ if (alreadyTrusted) return;
1335
+ for (const key of keys) {
1336
+ data.projects[key] = { ...data.projects[key], hasTrustDialogAccepted: true };
1337
+ }
1338
+ writeFileSync4(claudeJsonPath, JSON.stringify(data, null, 2));
1339
+ logger.runner.info("USAGE_MONITOR", `Pre-trusted scratch directory ${SCRATCH_DIR} in ~/.claude.json.`);
1340
+ } catch (err) {
1341
+ logger.runner.error("USAGE_MONITOR", "Failed to pre-trust the usage-monitor scratch directory:", err);
1342
+ }
1343
+ }
1344
+ function pollUsageOnce() {
1345
+ let pty;
1346
+ try {
1347
+ pty = spawn5(
1348
+ CLAUDE_BINARY,
1349
+ [
1350
+ "--dangerously-skip-permissions",
1351
+ "--model",
1352
+ "claude-haiku-4-5",
1353
+ // cheapest model — this exchange exists only to trigger a statusLine update
1354
+ ...statuslineSettingsArgs(),
1355
+ "reply with just the word: ok"
1356
+ ],
1357
+ {
1358
+ name: "xterm-color",
1359
+ cols: 80,
1360
+ rows: 24,
1361
+ cwd: SCRATCH_DIR,
1362
+ env: statuslineEnv(SESSION_ID, TICKET)
1363
+ }
1364
+ );
1365
+ } catch (err) {
1366
+ logger.runner.error("USAGE_MONITOR", "Failed to spawn the usage-poll session:", err);
1367
+ return;
1368
+ }
1369
+ let settled = false;
1370
+ let idleTimer;
1371
+ const hardTimer = setTimeout(finish, HARD_TIMEOUT_MS);
1372
+ function finish() {
1373
+ if (settled) return;
1374
+ settled = true;
1375
+ clearTimeout(idleTimer);
1376
+ clearTimeout(hardTimer);
1377
+ try {
1378
+ pty.kill();
1379
+ } catch {
1380
+ }
1381
+ }
1382
+ pty.onData(() => {
1383
+ clearTimeout(idleTimer);
1384
+ idleTimer = setTimeout(finish, IDLE_MS);
1385
+ });
1386
+ pty.onExit(() => finish());
1387
+ }
1388
+ function startUsageMonitor() {
1389
+ mkdirSync3(SCRATCH_DIR, { recursive: true });
1390
+ ensureScratchDirTrusted();
1391
+ pollUsageOnce();
1392
+ setInterval(pollUsageOnce, POLL_INTERVAL_MS);
1393
+ }
1394
+
1395
+ // src/filesServer.ts
1396
+ var execFileAsync2 = promisify2(execFile2);
1397
+ var FILES_TICKET_TTL_MS = 15 * 60 * 1e3;
1398
+ var FILES_TICKET_REFRESH_MARGIN_MS = FILES_TICKET_TTL_MS / 3;
1399
+ var MAX_FILE_SIZE_BYTES = 1e6;
1400
+ var MAX_LOGO_SIZE_BYTES = 5e6;
1401
+ var IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", ".claude", "node_modules", "dist", "build"]);
1402
+ var BRAND_ICON_BASE_NAMES = ["logo", "favicon"];
1403
+ var MIME_TYPES_BY_EXT = {
1404
+ svg: "image/svg+xml",
1405
+ png: "image/png",
1406
+ jpg: "image/jpeg",
1407
+ jpeg: "image/jpeg",
1408
+ webp: "image/webp",
1409
+ gif: "image/gif",
1410
+ ico: "image/x-icon"
1411
+ };
1412
+ var ticketsByProject = /* @__PURE__ */ new Map();
1413
+ var setupTicket = null;
1414
+ var statuslineSupabase = null;
1415
+ var statuslineConfig = null;
1416
+ var MAX_STATUSLINE_BODY_BYTES = 16e3;
1417
+ var HttpError = class extends Error {
1418
+ constructor(status, message) {
1419
+ super(message);
1420
+ this.status = status;
1421
+ }
1422
+ };
1423
+ async function ensureFilesTickets(supabase, config) {
1424
+ for (const project of config.registeredProjects) {
1425
+ if (!project.isReferenceRunner) continue;
1426
+ const existing = ticketsByProject.get(project.id);
1427
+ if (existing && existing.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) continue;
1428
+ const ticket = randomUUID5();
1429
+ const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
1430
+ ticketsByProject.set(project.id, { ticket, expiresAt: expiresAtMs, repoRoot: resolve2(project.repoPath) });
1431
+ try {
1432
+ await updateProjectFilesTicket(supabase, project.id, ticket, new Date(expiresAtMs).toISOString());
1433
+ } catch (err) {
1434
+ logger.runner.error("FILES", `Failed to publish files ticket for project ${project.id}:`, err);
1435
+ }
1436
+ }
1437
+ }
1438
+ function authenticate(query) {
1439
+ const projectId = typeof query.projectId === "string" ? query.projectId : null;
1440
+ const ticket = typeof query.ticket === "string" ? query.ticket : null;
1441
+ if (!projectId || !ticket) return null;
1442
+ const entry = ticketsByProject.get(projectId);
1443
+ if (!entry || entry.ticket !== ticket || entry.expiresAt < Date.now()) return null;
1444
+ return entry;
1445
+ }
1446
+ async function ensureSetupTicket(supabase, config) {
1447
+ if (!config.allowedRootPath) return;
1448
+ if (setupTicket && setupTicket.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) return;
1449
+ const ticket = randomUUID5();
1450
+ const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
1451
+ setupTicket = { ticket, expiresAt: expiresAtMs, rootPath: resolve2(config.allowedRootPath) };
1452
+ try {
1453
+ await updateRunnerSetupTicket(supabase, config.runnerId, ticket, new Date(expiresAtMs).toISOString(), config.allowedRootPath);
1454
+ } catch (err) {
1455
+ logger.runner.error("FILES", `Failed to publish setup ticket for runner ${config.runnerId}:`, err);
1456
+ }
1457
+ }
1458
+ function authenticateSetup(query) {
1459
+ const ticket = typeof query.ticket === "string" ? query.ticket : null;
1460
+ if (!ticket || !setupTicket) return null;
1461
+ if (setupTicket.ticket !== ticket || setupTicket.expiresAt < Date.now()) return null;
1462
+ return setupTicket;
1463
+ }
1464
+ function resolveWithinRepo(repoRoot, relPath) {
1465
+ const normalizedRel = relPath.replace(/^[/\\]+/, "");
1466
+ const resolved = resolve2(repoRoot, normalizedRel);
1467
+ const rootWithSep = repoRoot.endsWith(sep) ? repoRoot : repoRoot + sep;
1468
+ if (resolved !== repoRoot && !resolved.startsWith(rootWithSep)) {
1469
+ throw new HttpError(403, "Caminho fora do reposit\xF3rio.");
1470
+ }
1471
+ return resolved;
1472
+ }
1473
+ function listDirectory(repoRoot, relPath) {
1474
+ const target = resolveWithinRepo(repoRoot, relPath);
1475
+ let stat;
1476
+ try {
1477
+ stat = lstatSync(target);
1478
+ } catch {
1479
+ throw new HttpError(404, "Diret\xF3rio n\xE3o encontrado.");
1480
+ }
1481
+ if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1482
+ if (!stat.isDirectory()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um diret\xF3rio.");
1483
+ const prefix = relPath.replace(/\/+$/, "");
1484
+ return readdirSync(target, { withFileTypes: true }).filter((dirent) => !dirent.isSymbolicLink() && !IGNORED_ENTRY_NAMES.has(dirent.name)).map((dirent) => ({
1485
+ name: dirent.name,
1486
+ path: prefix ? `${prefix}/${dirent.name}` : dirent.name,
1487
+ type: dirent.isDirectory() ? "dir" : "file"
1488
+ })).sort((a, b) => a.type !== b.type ? a.type === "dir" ? -1 : 1 : a.name.localeCompare(b.name));
1489
+ }
1490
+ function listDirectoryForBrowse(rootPath, relPath) {
1491
+ return listDirectory(rootPath, relPath).map((entry) => ({
1492
+ ...entry,
1493
+ hasGit: entry.type === "dir" && existsSync4(join8(rootPath, entry.path, ".git"))
1494
+ }));
1495
+ }
1496
+ function looksBinary(buffer) {
1497
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8e3));
1498
+ return sample.includes(0);
1499
+ }
1500
+ function readTextFile(repoRoot, relPath) {
1501
+ const target = resolveWithinRepo(repoRoot, relPath);
1502
+ let stat;
1503
+ try {
1504
+ stat = lstatSync(target);
1505
+ } catch {
1506
+ throw new HttpError(404, "Arquivo n\xE3o encontrado.");
1507
+ }
1508
+ if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1509
+ if (!stat.isFile()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um arquivo.");
1510
+ if (stat.size > MAX_FILE_SIZE_BYTES) {
1511
+ throw new HttpError(413, `Arquivo maior que o limite de ${MAX_FILE_SIZE_BYTES / 1e6}MB para visualiza\xE7\xE3o.`);
1512
+ }
1513
+ const buffer = readFileSync4(target);
1514
+ if (looksBinary(buffer)) throw new HttpError(415, "Arquivo bin\xE1rio n\xE3o pode ser exibido.");
1515
+ return { content: buffer.toString("utf8"), size: stat.size };
1516
+ }
1517
+ function findBrandIconPath(repoRoot) {
1518
+ const patterns = BRAND_ICON_BASE_NAMES.map((baseName) => ({
1519
+ baseName,
1520
+ pattern: new RegExp(`^${baseName}\\.[a-z0-9]+$`, "i")
1521
+ }));
1522
+ const candidatesByBaseName = new Map(
1523
+ BRAND_ICON_BASE_NAMES.map((baseName) => [baseName, []])
1524
+ );
1525
+ function walk(dir, relDir, depth) {
1526
+ let entries;
1527
+ try {
1528
+ entries = readdirSync(dir, { withFileTypes: true });
1529
+ } catch {
1530
+ return;
1531
+ }
1532
+ for (const entry of entries) {
1533
+ if (entry.isSymbolicLink() || IGNORED_ENTRY_NAMES.has(entry.name)) continue;
1534
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
1535
+ if (entry.isDirectory()) {
1536
+ walk(join8(dir, entry.name), relPath, depth + 1);
1537
+ } else if (entry.isFile()) {
1538
+ const match = patterns.find(({ pattern }) => pattern.test(entry.name));
1539
+ if (match) candidatesByBaseName.get(match.baseName).push({ relPath, depth });
1540
+ }
1541
+ }
1542
+ }
1543
+ walk(repoRoot, "", 0);
1544
+ for (const baseName of BRAND_ICON_BASE_NAMES) {
1545
+ const candidates = candidatesByBaseName.get(baseName);
1546
+ if (candidates.length === 0) continue;
1547
+ candidates.sort((a, b) => a.depth - b.depth || a.relPath.localeCompare(b.relPath));
1548
+ return candidates[0].relPath;
1549
+ }
1550
+ return null;
1551
+ }
1552
+ function readImageFile(repoRoot, relPath) {
1553
+ const target = resolveWithinRepo(repoRoot, relPath);
1554
+ let stat;
1555
+ try {
1556
+ stat = lstatSync(target);
1557
+ } catch {
1558
+ throw new HttpError(404, "Arquivo de logo n\xE3o encontrado.");
1559
+ }
1560
+ if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1561
+ if (!stat.isFile()) throw new HttpError(400, "O caminho configurado n\xE3o \xE9 um arquivo.");
1562
+ if (stat.size > MAX_LOGO_SIZE_BYTES) {
1563
+ throw new HttpError(413, `Arquivo maior que o limite de ${MAX_LOGO_SIZE_BYTES / 1e6}MB.`);
1564
+ }
1565
+ const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
1566
+ return { buffer: readFileSync4(target), contentType: MIME_TYPES_BY_EXT[ext] ?? "application/octet-stream" };
1567
+ }
1568
+ function sendJson(res, status, body) {
1569
+ const payload = JSON.stringify(body);
1570
+ res.writeHead(status, {
1571
+ "Content-Type": "application/json; charset=utf-8",
1572
+ // This runner is a trusted process on the user's own machine and the web app is served
1573
+ // from a different origin (e.g. Vercel) — the ticket is the actual security boundary,
1574
+ // so CORS is left wide open rather than trying to keep an allowlist of web origins.
1575
+ "Access-Control-Allow-Origin": "*"
1576
+ });
1577
+ res.end(payload);
1578
+ }
1579
+ function readRequestBody(req, maxBytes) {
1580
+ return new Promise((resolve4, reject) => {
1581
+ let received = 0;
1582
+ const chunks = [];
1583
+ req.on("data", (chunk) => {
1584
+ received += chunk.length;
1585
+ if (received > maxBytes) {
1586
+ req.destroy();
1587
+ reject(new HttpError(413, "Corpo da requisi\xE7\xE3o excede o limite."));
1588
+ return;
1589
+ }
1590
+ chunks.push(chunk);
1591
+ });
1592
+ req.on("end", () => resolve4(Buffer.concat(chunks).toString("utf8")));
1593
+ req.on("error", reject);
1594
+ });
1595
+ }
1596
+ async function handleStatuslineReport(req, res, supabase, config) {
1597
+ let body;
1598
+ try {
1599
+ const raw = await readRequestBody(req, MAX_STATUSLINE_BODY_BYTES);
1600
+ body = JSON.parse(raw);
1601
+ } catch (err) {
1602
+ const status = err instanceof HttpError ? err.status : 400;
1603
+ sendJson(res, status, { error: "Corpo inv\xE1lido." });
1604
+ return;
1605
+ }
1606
+ if (!body.sessionId || !body.ticket) {
1607
+ sendJson(res, 400, { error: "sessionId e ticket s\xE3o obrigat\xF3rios." });
1608
+ return;
1609
+ }
1610
+ const isMonitor = body.sessionId === usageMonitorSessionId();
1611
+ const ticketValid = isMonitor ? body.ticket === usageMonitorTicket() : getPtySession(body.sessionId)?.ticket === body.ticket;
1612
+ if (!ticketValid) {
1613
+ sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
1614
+ return;
1615
+ }
1616
+ try {
1617
+ await upsertClaudeUsage(supabase, config.ownerUserId, {
1618
+ ...typeof body.fiveHour?.used_percentage === "number" ? { sessionPercent: body.fiveHour.used_percentage } : {},
1619
+ ...typeof body.fiveHour?.resets_at === "number" ? { sessionResetsAt: new Date(body.fiveHour.resets_at * 1e3).toISOString() } : {},
1620
+ ...typeof body.sevenDay?.used_percentage === "number" ? { weekPercent: body.sevenDay.used_percentage } : {},
1621
+ ...typeof body.sevenDay?.resets_at === "number" ? { weekResetsAt: new Date(body.sevenDay.resets_at * 1e3).toISOString() } : {}
1622
+ });
1623
+ res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
1624
+ res.end();
1625
+ } catch (err) {
1626
+ logger.runner.error("STATUSLINE", `Failed to persist Claude usage from session ${body.sessionId}:`, err);
1627
+ sendJson(res, 500, { error: "Erro interno do runner." });
1628
+ }
1629
+ }
1630
+ async function handleBranchRequest(entry, res) {
1631
+ try {
1632
+ const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd: entry.repoRoot });
1633
+ sendJson(res, 200, { branch: stdout.trim() });
1634
+ } catch (err) {
1635
+ logger.runner.error("FILES", "Unexpected error handling a branch request:", err);
1636
+ sendJson(res, 500, { error: "Erro interno do runner." });
1637
+ }
1638
+ }
1639
+ function handleRequest(req, res) {
1640
+ if (req.method === "OPTIONS") {
1641
+ res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS" });
1642
+ res.end();
1643
+ return;
1644
+ }
1645
+ const { pathname, query } = parse(req.url ?? "", true);
1646
+ if (req.method === "POST" && pathname === "/statusline") {
1647
+ if (!statuslineSupabase || !statuslineConfig) {
1648
+ sendJson(res, 500, { error: "Runner ainda n\xE3o inicializado." });
1649
+ return;
1650
+ }
1651
+ void handleStatuslineReport(req, res, statuslineSupabase, statuslineConfig);
1652
+ return;
1653
+ }
1654
+ if (req.method !== "GET") {
1655
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1656
+ return;
1657
+ }
1658
+ const relPath = typeof query.path === "string" ? query.path : "";
1659
+ if (pathname === "/browse") {
1660
+ const setupEntry = authenticateSetup(query);
1661
+ if (!setupEntry) {
1662
+ sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou navega\xE7\xE3o de pastas desativada." });
1663
+ return;
1664
+ }
1665
+ try {
1666
+ sendJson(res, 200, { entries: listDirectoryForBrowse(setupEntry.rootPath, relPath) });
1667
+ } catch (err) {
1668
+ if (err instanceof HttpError) {
1669
+ sendJson(res, err.status, { error: err.message });
1670
+ return;
1671
+ }
1672
+ logger.runner.error("FILES", "Unexpected error handling a browse request:", err);
1673
+ sendJson(res, 500, { error: "Erro interno do runner." });
1674
+ }
1675
+ return;
1676
+ }
1677
+ const entry = authenticate(query);
1678
+ if (!entry) {
1679
+ sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou projeto desconhecido." });
1680
+ return;
1681
+ }
1682
+ if (pathname === "/branch") {
1683
+ void handleBranchRequest(entry, res);
1684
+ return;
1685
+ }
1686
+ try {
1687
+ if (pathname === "/tree") {
1688
+ sendJson(res, 200, { entries: listDirectory(entry.repoRoot, relPath) });
1689
+ return;
1690
+ }
1691
+ if (pathname === "/file") {
1692
+ sendJson(res, 200, readTextFile(entry.repoRoot, relPath));
1693
+ return;
1694
+ }
1695
+ if (pathname === "/logo") {
1696
+ if (!relPath) throw new HttpError(404, "Nenhuma logo configurada para este projeto.");
1697
+ const { buffer, contentType } = readImageFile(entry.repoRoot, relPath);
1698
+ res.writeHead(200, {
1699
+ "Content-Type": contentType,
1700
+ "Content-Length": buffer.length,
1701
+ "Cache-Control": "no-cache",
1702
+ "Access-Control-Allow-Origin": "*"
1703
+ });
1704
+ res.end(buffer);
1705
+ return;
1706
+ }
1707
+ if (pathname === "/shell/open") {
1708
+ sendJson(res, 200, openShellSession(entry.repoRoot));
1709
+ return;
1710
+ }
1711
+ sendJson(res, 404, { error: "Rota desconhecida." });
1712
+ } catch (err) {
1713
+ if (err instanceof HttpError) {
1714
+ sendJson(res, err.status, { error: err.message });
1715
+ return;
1716
+ }
1717
+ logger.runner.error("FILES", "Unexpected error handling a files request:", err);
1718
+ sendJson(res, 500, { error: "Erro interno do runner." });
1719
+ }
1720
+ }
1721
+ function attachFilesServer(httpServer, supabase, config) {
1722
+ statuslineSupabase = supabase;
1723
+ statuslineConfig = config;
1724
+ httpServer.on("request", handleRequest);
1725
+ logger.runner.info("FILES", "Files HTTP handler attached.");
1726
+ }
1727
+
1728
+ // src/repoValidation.ts
1729
+ import { existsSync as existsSync5 } from "node:fs";
1730
+ import { resolve as resolve3 } from "node:path";
1731
+ function validateRepoOnDisk(repoPath, children) {
1732
+ if (children.length > 0) {
1733
+ for (const child2 of children) {
1734
+ if (!existsSync5(resolve3(repoPath, child2, ".git"))) {
1735
+ return {
1736
+ ok: false,
1737
+ reason: `"${resolve3(repoPath, child2)}" does not look like a git repository (no .git folder found).`
1738
+ };
1739
+ }
1740
+ }
1741
+ return { ok: true };
1742
+ }
1743
+ if (!existsSync5(resolve3(repoPath, ".git"))) {
1744
+ return {
1745
+ ok: false,
1746
+ reason: `"${repoPath}" does not look like a git repository (no .git folder found). If this is a monolith container of independent repos, register it with child repos.`
1747
+ };
1748
+ }
1749
+ return { ok: true };
1750
+ }
1751
+
1752
+ // src/projectSync.ts
1753
+ async function syncRegisteredProjects(supabase, config, registeredPaths) {
1754
+ const remoteProjects = await listMyProjects(supabase);
1755
+ const remoteById = new Map(remoteProjects.map((project) => [project.id, project]));
1756
+ const localById = new Map(config.registeredProjects.map((project) => [project.id, project]));
1757
+ const linkedRunnerIds = await listProjectRunnerIds(supabase, config.runnerId);
1758
+ let changed = false;
1759
+ for (const remote of remoteProjects) {
1760
+ const local = localById.get(remote.id);
1761
+ if (local) {
1762
+ const isReferenceRunner = remote.runner_id === config.runnerId;
1763
+ if (local.isReferenceRunner !== isReferenceRunner) {
1764
+ local.isReferenceRunner = isReferenceRunner;
1765
+ changed = true;
1766
+ }
1767
+ continue;
1768
+ }
1769
+ const children = (await getProjectRepos(supabase, remote.id)).map((repo) => repo.subpath);
1770
+ const repoPath = resolveRepoPath(config, remote.repo_path);
1771
+ const validation = validateRepoOnDisk(repoPath, children);
1772
+ if (!validation.ok) {
1773
+ continue;
1774
+ }
1775
+ if (!linkedRunnerIds.has(remote.id)) {
1776
+ try {
1777
+ await addProjectRunner(supabase, remote.id, config.runnerId);
1778
+ } catch (err) {
1779
+ logger.runner.error("PROJECT_SYNC", `Failed to register this runner for project ${remote.id}:`, err);
1780
+ continue;
1781
+ }
1782
+ }
1783
+ const entry = { id: remote.id, repoPath, logoPathChecked: false, isReferenceRunner: remote.runner_id === config.runnerId };
1784
+ config.registeredProjects.push(entry);
1785
+ localById.set(remote.id, entry);
1786
+ registeredPaths.add(remote.id);
1787
+ changed = true;
1788
+ logger.runner.info("PROJECT_SYNC", `Registered project ${remote.id} (${repoPath}) \u2014 found on this runner's disk.`);
1789
+ }
1790
+ for (const local of config.registeredProjects) {
1791
+ if (local.logoPathChecked) continue;
1792
+ const remote = remoteById.get(local.id);
1793
+ if (!remote) continue;
1794
+ if (!remote.logo_path) {
1795
+ const found = findBrandIconPath(resolveRepoPath(config, remote.repo_path));
1796
+ if (found) {
1797
+ try {
1798
+ await updateProjectLogoPath(supabase, local.id, found);
1799
+ logger.runner.info("PROJECT_SYNC", `Auto-detected logo "${found}" for project ${local.id}.`);
1800
+ } catch (err) {
1801
+ logger.runner.error("PROJECT_SYNC", `Failed to publish auto-detected logo for project ${local.id}:`, err);
1802
+ continue;
1803
+ }
1804
+ }
1805
+ }
1806
+ local.logoPathChecked = true;
1807
+ changed = true;
1808
+ }
1809
+ const stillRegistered = config.registeredProjects.filter((project) => {
1810
+ if (remoteById.has(project.id)) return true;
1811
+ registeredPaths.delete(project.id);
1812
+ changed = true;
1813
+ logger.runner.info("PROJECT_SYNC", `Project ${project.id} (${project.repoPath}) was removed from the web app; no longer watching it.`);
1814
+ return false;
1815
+ });
1816
+ config.registeredProjects = stillRegistered;
1817
+ if (changed) saveConfig(config);
1818
+ }
1819
+
1820
+ // src/review.ts
1821
+ import { join as join9 } from "node:path";
1822
+ var inFlightCardIds2 = /* @__PURE__ */ new Set();
1823
+ async function handleCardEnteredRevisao(supabase, config, card) {
1824
+ if (inFlightCardIds2.has(card.id)) return;
1825
+ inFlightCardIds2.add(card.id);
1826
+ try {
1827
+ const project = await getProject(supabase, card.project_id);
1828
+ const children = await getProjectRepos(supabase, project.id);
1829
+ const repoRoot = resolveRepoPath(config, project.repo_path);
1830
+ const targets = children.length > 0 ? children.filter((child2) => child2.dev_command).map((child2) => ({ label: child2.subpath, cwd: join9(repoRoot, child2.subpath), command: child2.dev_command })) : project.dev_command ? [{ label: "", cwd: repoRoot, command: project.dev_command }] : [];
1831
+ if (targets.length === 0) {
1832
+ logger.card.info(card.id, `No dev_command configured for project ${project.id}, skipping automatic preview.`);
1833
+ return;
1834
+ }
1835
+ const results = await Promise.all(
1836
+ targets.map((target) => ensurePreview(`${project.id}:${target.label}`, target.label, target.cwd, target.command, card.id))
1837
+ );
1838
+ const previews2 = results.filter((result) => result !== null);
1839
+ if (previews2.length === 0) {
1840
+ logger.card.warn(card.id, `None of the ${targets.length} configured dev command(s) produced a usable preview.`);
1841
+ return;
1842
+ }
1843
+ await updateCard(supabase, card.id, { previewUrls: previews2 });
1844
+ logger.card.info(card.id, `Preview URLs set: ${previews2.map((preview) => preview.url).join(", ")}.`);
1845
+ } finally {
1846
+ inFlightCardIds2.delete(card.id);
1847
+ }
1848
+ }
1849
+ async function checkPendingPreviews(supabase, config) {
1850
+ for (const registered of config.registeredProjects) {
1851
+ const cards = await listCardsInColumn(supabase, registered.id, "revisao");
1852
+ for (const card of cards) {
1853
+ if (card.assigned_user_id !== config.ownerUserId) continue;
1854
+ if (card.preview_urls.length > 0) continue;
1855
+ await handleCardEnteredRevisao(supabase, config, card);
1856
+ }
1857
+ }
1858
+ }
1859
+ async function handleDevServerRestartRequested(supabase, config, project) {
1860
+ const registered = config.registeredProjects.find((candidate) => candidate.id === project.id);
1861
+ if (!registered) {
1862
+ logger.runner.warn("REVIEW", `Ignoring dev server restart request: project ${project.id} is not registered on this runner.`);
1863
+ await clearDevServerRestartRequest(supabase, project.id);
1864
+ return;
1865
+ }
1866
+ stopProjectPreviews(project.id);
1867
+ const allCards = await listCardsInColumn(supabase, project.id, "revisao");
1868
+ const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
1869
+ if (cards.length === 0) {
1870
+ 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.`);
1871
+ await clearDevServerRestartRequest(supabase, project.id);
1872
+ return;
1873
+ }
1874
+ await Promise.all(cards.map((card) => updateCard(supabase, card.id, { previewUrls: [] })));
1875
+ await Promise.all(cards.map((card) => handleCardEnteredRevisao(supabase, config, card)));
1876
+ await clearDevServerRestartRequest(supabase, project.id);
1877
+ }
1878
+ async function checkPendingDevServerRestarts(supabase, config) {
1879
+ for (const registered of config.registeredProjects) {
1880
+ const project = await getProject(supabase, registered.id);
1881
+ if (project.dev_server_restart_requested_at) {
1882
+ await handleDevServerRestartRequested(supabase, config, project);
1883
+ }
1884
+ }
1885
+ }
1886
+
1887
+ // src/terminalServer.ts
1888
+ import { parse as parse2 } from "node:url";
1889
+ import { WebSocketServer } from "ws";
1890
+ function attachTerminalServer(httpServer) {
1891
+ const wss = new WebSocketServer({ server: httpServer });
1892
+ wss.on("connection", (socket, request) => {
1893
+ const { query } = parse2(request.url ?? "", true);
1894
+ const sessionId = typeof query.sessionId === "string" ? query.sessionId : null;
1895
+ const ticket = typeof query.ticket === "string" ? query.ticket : null;
1896
+ if (!sessionId || !ticket) {
1897
+ logger.runner.warn("TERMINAL", "Terminal WS rejected: missing sessionId or ticket in request URL.");
1898
+ socket.close(4400, "Missing sessionId or ticket");
1899
+ return;
1900
+ }
1901
+ const handle = getPtySession(sessionId);
1902
+ if (!handle || handle.ticket !== ticket) {
1903
+ logger.runner.warn("TERMINAL", `Terminal WS rejected for session ${sessionId}: ${handle ? "ticket mismatch" : "no pty registered for this session"}.`);
1904
+ socket.close(4401, "Invalid session or ticket");
1905
+ return;
1906
+ }
1907
+ handle.sockets.add(socket);
1908
+ for (const chunk of handle.outputBuffer) socket.send(chunk);
1909
+ socket.on("message", (data, isBinary) => {
1910
+ if (isBinary) {
1911
+ try {
1912
+ const { cols, rows } = JSON.parse(data.toString("utf8"));
1913
+ if (typeof cols === "number" && typeof rows === "number") resizePtySession(sessionId, cols, rows);
1914
+ } catch (err) {
1915
+ logger.runner.warn("TERMINAL", `Terminal WS: ignoring malformed resize frame for session ${sessionId}:`, err);
1916
+ }
1917
+ return;
1918
+ }
1919
+ handle.pty.write(data.toString());
1920
+ });
1921
+ socket.on("close", () => {
1922
+ handle.sockets.delete(socket);
1923
+ });
1924
+ });
1925
+ logger.runner.info("TERMINAL", "Terminal WebSocket handler attached.");
1926
+ }
1927
+
1928
+ // src/terminalTunnel.ts
1929
+ var RESTART_DELAY_MS = 5e3;
1930
+ var child = null;
1931
+ var stopping = false;
1932
+ var tunnelPending = false;
1933
+ async function publishHostname(supabase, config, hostname2) {
1934
+ const { error } = await supabase.from("runners").update({ tunnel_hostname: hostname2 }).eq("id", config.runnerId);
1935
+ if (error) logger.runner.error("CLOUDFLARE", "Failed to update runners.tunnel_hostname:", error);
1936
+ }
1937
+ async function openQuickTunnel(supabase, config, port) {
1938
+ tunnelPending = true;
1939
+ const tunnel = await startQuickTunnel("the terminal server", port);
1940
+ if (!tunnel) {
1941
+ tunnelPending = false;
1942
+ await publishHostname(supabase, config, null);
1943
+ logger.runner.warn("CLOUDFLARE", "Terminal tunnel unavailable \u2014 the web terminal will not be able to reach this runner.");
1944
+ return;
1945
+ }
1946
+ child = tunnel.child;
1947
+ tunnelPending = false;
1948
+ const hostname2 = toTunnelHostname(tunnel.url);
1949
+ await publishHostname(supabase, config, hostname2);
1950
+ logger.runner.info("CLOUDFLARE", `Terminal tunnel up: ${hostname2} -> localhost:${port}.`);
1951
+ tunnel.child.on("exit", () => {
1952
+ child = null;
1953
+ if (stopping) return;
1954
+ void publishHostname(supabase, config, null);
1955
+ logger.runner.warn("CLOUDFLARE", `Terminal tunnel dropped, restarting in ${RESTART_DELAY_MS}ms.`);
1956
+ setTimeout(() => {
1957
+ if (!stopping) void openQuickTunnel(supabase, config, port);
1958
+ else tunnelPending = false;
1959
+ }, RESTART_DELAY_MS);
1960
+ });
1961
+ }
1962
+ async function ensureTerminalTunnel(supabase, config, port) {
1963
+ stopping = false;
1964
+ if (child || tunnelPending) return;
1965
+ await openQuickTunnel(supabase, config, port);
1966
+ }
1967
+ async function stopTerminalTunnel(supabase, config) {
1968
+ stopping = true;
1969
+ child?.kill();
1970
+ child = null;
1971
+ tunnelPending = false;
1972
+ await publishHostname(supabase, config, null);
1973
+ }
1974
+
1975
+ // src/runner.ts
1976
+ async function startRunner() {
1977
+ const loadedConfig = loadConfig();
1978
+ if (!loadedConfig) {
1979
+ logger.runner.error("BOOT", 'No runner set up yet. Run "agent-board-runner start --path <repos-root>" first.');
1980
+ process.exit(1);
1981
+ }
1982
+ const config = applyRunnerName(applyAllowedRoot(loadedConfig));
1983
+ if (!config.allowedRootPath) {
1984
+ logger.runner.error(
1985
+ "BOOT",
1986
+ "No repos root configured. Pass --path <folder> to `start` or set AGENT_BOARD_ALLOWED_ROOT."
1987
+ );
1988
+ process.exit(1);
1989
+ }
1990
+ const supabase = createAuthedSupabaseClient();
1991
+ let session;
1992
+ try {
1993
+ session = await requireSession(supabase);
1994
+ } catch (error) {
1995
+ logger.runner.error("BOOT", error instanceof Error ? error.message : String(error));
1996
+ process.exit(1);
1997
+ }
1998
+ config.ownerUserId = session.user.id;
1999
+ await supabase.auth.startAutoRefresh();
2000
+ const { error: linkError } = await supabase.rpc("link_my_membership");
2001
+ if (linkError) {
2002
+ logger.runner.error("BOOT", "Failed to link pending project invites:", linkError);
2003
+ }
2004
+ const { data: existingRunner, error: existingRunnerError } = await supabase.from("runners").select("id").eq("user_id", config.ownerUserId).eq("name", config.runnerName).neq("id", config.runnerId).maybeSingle();
2005
+ if (existingRunnerError) {
2006
+ logger.runner.error("BOOT", "Failed to check for an existing runner row to adopt:", existingRunnerError);
2007
+ } else if (existingRunner) {
2008
+ logger.runner.warn(
2009
+ "BOOT",
2010
+ `Local identity (${config.runnerId}) has no matching Supabase row for "${config.runnerName}" \u2014 adopting the existing one (${existingRunner.id}) instead of registering a duplicate.`
2011
+ );
2012
+ config.runnerId = existingRunner.id;
2013
+ saveConfig(config);
2014
+ }
2015
+ const { error: runnerError } = await supabase.from("runners").upsert({
2016
+ id: config.runnerId,
2017
+ user_id: config.ownerUserId,
2018
+ name: config.runnerName,
2019
+ auth_token_hash: config.authToken
2020
+ });
2021
+ if (runnerError) {
2022
+ logger.runner.error("BOOT", "Failed to upsert this runner's row in Supabase:", runnerError);
2023
+ process.exit(1);
2024
+ }
2025
+ const registeredPaths = new Set(config.registeredProjects.map((project) => project.id));
2026
+ await syncRegisteredProjects(supabase, config, registeredPaths).catch((err) => logger.runner.error("PROJECT_SYNC", "syncRegisteredProjects failed:", err));
2027
+ logger.runner.info("BOOT", `Agent Board runner "${config.runnerName}" starting (${registeredPaths.size} project(s) registered).`);
2028
+ for (const project of config.registeredProjects) {
2029
+ await reconcileStaleSessions(supabase, config, project.id).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileStaleSessions failed:", err));
2030
+ }
2031
+ await reconcileOrphanedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileOrphanedSessions failed:", err));
2032
+ await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
2033
+ const terminalPort = Number(process.env.AGENT_BOARD_TERMINAL_PORT ?? 8787);
2034
+ ensureStatuslineScriptWritten();
2035
+ const httpServer = createServer();
2036
+ attachTerminalServer(httpServer);
2037
+ attachFilesServer(httpServer, supabase, config);
2038
+ let httpServerReady = false;
2039
+ let realtimeReady = false;
2040
+ function logRunnerReadyOnceBothUp() {
2041
+ if (httpServerReady && realtimeReady) {
2042
+ logger.runner.info("BOOT", "Runner ready.");
2043
+ }
2044
+ }
2045
+ httpServer.listen(terminalPort, () => {
2046
+ logger.runner.info("BOOT", `Runner HTTP/WebSocket server listening on port ${terminalPort}.`);
2047
+ httpServerReady = true;
2048
+ logRunnerReadyOnceBothUp();
2049
+ });
2050
+ startUsageMonitor();
2051
+ void ensureTerminalTunnel(supabase, config, terminalPort).catch((err) => logger.runner.error("CLOUDFLARE", "ensureTerminalTunnel failed:", err));
2052
+ void ensureFilesTickets(supabase, config).catch((err) => logger.runner.error("FILES", "ensureFilesTickets failed:", err));
2053
+ void ensureSetupTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureSetupTicket failed:", err));
2054
+ let shuttingDown = false;
2055
+ for (const signal of ["SIGINT", "SIGTERM"]) {
2056
+ process.on(signal, () => {
2057
+ if (shuttingDown) return;
2058
+ shuttingDown = true;
2059
+ stopAll();
2060
+ void stopTerminalTunnel(supabase, config).finally(() => process.exit(0));
2061
+ });
2062
+ }
2063
+ async function heartbeat() {
2064
+ await supabase.from("runners").update({ last_heartbeat_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", config.runnerId);
2065
+ }
2066
+ supabase.channel(`runner:${config.runnerId}`).on(
2067
+ "postgres_changes",
2068
+ { event: "UPDATE", schema: "agent_board", table: "cards" },
2069
+ (payload) => {
2070
+ const card = payload.new;
2071
+ const previous = payload.old;
2072
+ if (!registeredPaths.has(card.project_id)) return;
2073
+ if (card.assigned_user_id !== config.ownerUserId) return;
2074
+ if (card.column_name === "desenvolvimento") {
2075
+ void enqueueCard(supabase, config, card);
2076
+ }
2077
+ if (previous.column_name === "desenvolvimento" && card.column_name !== "desenvolvimento") {
2078
+ void stopCardSession(supabase, config, card);
2079
+ }
2080
+ if (card.column_name === "revisao" && previous.column_name !== "revisao") {
2081
+ void handleCardEnteredRevisao(supabase, config, card);
2082
+ }
2083
+ if (previous.column_name === "revisao" && card.column_name !== "revisao") {
2084
+ releaseCard(card.id);
2085
+ }
2086
+ if (card.column_name === "fim" && previous.column_name !== "fim") {
2087
+ void handleCardReachedFim(supabase, config, card);
2088
+ }
2089
+ }
2090
+ ).on(
2091
+ "postgres_changes",
2092
+ { event: "UPDATE", schema: "agent_board", table: "projects" },
2093
+ (payload) => {
2094
+ const project = payload.new;
2095
+ const previous = payload.old;
2096
+ if (!registeredPaths.has(project.id)) return;
2097
+ if (project.deploy_requested_at && project.deploy_requested_at !== previous.deploy_requested_at) {
2098
+ void handleDeployRequested(supabase, config, project);
2099
+ }
2100
+ if (project.dev_server_restart_requested_at && project.dev_server_restart_requested_at !== previous.dev_server_restart_requested_at) {
2101
+ void handleDevServerRestartRequested(supabase, config, project);
2102
+ }
2103
+ }
2104
+ ).subscribe((status) => {
2105
+ if (status === "SUBSCRIBED") {
2106
+ logger.runner.info("REALTIME", "Subscribed to cards/projects changes.");
2107
+ realtimeReady = true;
2108
+ logRunnerReadyOnceBothUp();
2109
+ } else {
2110
+ logger.runner.warn("REALTIME", `Subscription status changed to "${status}" \u2014 card moves may only be picked up by the 60s tick fallback until this recovers.`);
2111
+ }
2112
+ });
2113
+ async function tick() {
2114
+ await heartbeat();
2115
+ await syncRegisteredProjects(supabase, config, registeredPaths).catch((err) => logger.runner.error("PROJECT_SYNC", "syncRegisteredProjects failed:", err));
2116
+ await ensureTerminalTunnel(supabase, config, terminalPort);
2117
+ await ensureFilesTickets(supabase, config);
2118
+ await ensureSetupTicket(supabase, config);
2119
+ await scanForWork(supabase, config);
2120
+ await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
2121
+ await checkPendingDeploys(supabase, config).catch((err) => logger.runner.error("DEPLOY", "checkPendingDeploys failed:", err));
2122
+ await checkPendingPreviews(supabase, config).catch((err) => logger.runner.error("REVIEW", "checkPendingPreviews failed:", err));
2123
+ await checkPendingDevServerRestarts(supabase, config).catch((err) => logger.runner.error("REVIEW", "checkPendingDevServerRestarts failed:", err));
2124
+ }
2125
+ void tick();
2126
+ setInterval(() => void tick(), 6e4);
2127
+ }
2128
+
2129
+ // src/index.ts
2130
+ process.noDeprecation = true;
2131
+ loadEnv();
2132
+ loadEnv({ path: ".env.local", override: true });
2133
+ void startRunner();