agent-board-runner 0.1.0 → 0.1.2

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 +14 -0
  2. package/dist/cli.js +2647 -1311
  3. package/dist/index.js +2720 -1384
  4. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -163,21 +163,178 @@ async function logout() {
163
163
  if (existsSync2(SESSION_FILE)) rmSync(SESSION_FILE);
164
164
  }
165
165
 
166
+ // ../../packages/shared-types/dist/index.js
167
+ var BOARD_COLUMNS = [
168
+ "backlog",
169
+ "desenvolvimento",
170
+ "revisao",
171
+ "deploy",
172
+ "fim"
173
+ ];
174
+ var COLUMN_SHELL_KINDS = ["shell", "claude"];
175
+ var RUNNER_VERSION = "0.1.0";
176
+ var CARD_ATTACHMENTS_BUCKET = "agent-board-card-images";
177
+ var MAX_CARD_ATTACHMENT_BYTES = 25 * 1024 * 1024;
178
+
166
179
  // src/runner.ts
167
180
  import { createServer as createServer2 } from "node:http";
168
181
 
169
- // src/deploy.ts
170
- import { join as join6 } from "node:path";
182
+ // src/agentEventsServer.ts
183
+ import { parse as parse2 } from "node:url";
184
+ import { WebSocketServer } from "ws";
171
185
 
172
- // src/devServer.ts
173
- import { spawn as spawn2 } from "node:child_process";
186
+ // src/filesServer.ts
187
+ import { execFile as execFile2 } from "node:child_process";
188
+ import { randomUUID as randomUUID5 } from "node:crypto";
189
+ import { existsSync as existsSync4, lstatSync, mkdirSync as mkdirSync4, readdirSync, readFileSync as readFileSync4, rmSync as rmSync2, writeFileSync as writeFileSync7 } from "node:fs";
190
+ import { homedir as homedir4 } from "node:os";
191
+ import { join as join8, resolve as resolve2, sep } from "node:path";
192
+ import { parse } from "node:url";
193
+ import { promisify as promisify2 } from "node:util";
174
194
 
175
- // src/cloudflared.ts
176
- import { spawn } from "node:child_process";
177
- import { accessSync, constants } from "node:fs";
178
- import { homedir as homedir2 } from "node:os";
179
- import { delimiter, join as join3 } from "node:path";
180
- import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
195
+ // src/agentActivity.ts
196
+ var RECENT_WINDOW_MS = 6e3;
197
+ var IDLE_AFTER_MS = 45e3;
198
+ var IDLE_AFTER_MS_TOOL_PENDING = 3e5;
199
+ var EVAL_INTERVAL_MS = 2e3;
200
+ var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
201
+ var BOX_DRAWING_PATTERN = /[─-╿]/g;
202
+ var WORKING_PATTERN = /esc to interrupt/i;
203
+ function isAgentHookMoment(value) {
204
+ return value === "implementing" || value === "waiting_plan_approval" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
205
+ }
206
+ var USAGE_LIMIT_PATTERN = /(usage limit reached|limit reached[^\n]{0,60}resets|reached your (?:usage|session) limit|out of (?:weekly|session) (?:usage|limit))/i;
207
+ var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
208
+ var USER_PROMPT_PATTERNS = [
209
+ /do you want to\b/i,
210
+ /would you like to\b/i,
211
+ /do you trust the files\b/i,
212
+ /press enter to continue/i,
213
+ /\(y\/n\)/i,
214
+ /❯\s*1\.\s/
215
+ ];
216
+ var TOOL_CALL_MARKER = "\u23FA";
217
+ var TOOL_RESULT_MARKER = "\u23BF";
218
+ var COMMIT_SUCCESS_PATTERN = /\[\S+\s+[0-9a-f]{7,40}\]/;
219
+ function normalizeTerminalOutput(raw) {
220
+ return raw.replace(ANSI_PATTERN, "").replace(BOX_DRAWING_PATTERN, " ").replace(/[ \t]+/g, " ");
221
+ }
222
+ function classifyAgentOutput(recentOutput, options) {
223
+ if (WORKING_PATTERN.test(recentOutput)) return "implementing";
224
+ if (options.usageLimited) return "usage_limited";
225
+ if (options.planApprovalPending) return "waiting_plan_approval";
226
+ if (USER_PROMPT_PATTERNS.some((pattern) => pattern.test(recentOutput))) return "waiting_user";
227
+ const idleThreshold = options.toolCallPending ? IDLE_AFTER_MS_TOOL_PENDING : IDLE_AFTER_MS;
228
+ if (options.silentForMs >= idleThreshold) return options.commitDetected ? "done" : "waiting_user";
229
+ return options.previous;
230
+ }
231
+ var AgentActivityWatcher = class {
232
+ constructor(onChange) {
233
+ this.onChange = onChange;
234
+ this.timer = setInterval(() => this.evaluate(), EVAL_INTERVAL_MS);
235
+ this.timer.unref();
236
+ }
237
+ chunks = [];
238
+ // Starts as "starting" (not "implementing"): nothing classifiable has arrived yet, and the
239
+ // caller stamps the session row the same way right when the pty is spawned — see
240
+ // orchestrator.spawnSessionPty.
241
+ state = "starting";
242
+ lastOutputAt = Date.now();
243
+ usageLimited = false;
244
+ planApprovalPending = false;
245
+ toolCallPending = false;
246
+ commitDetected = false;
247
+ timer;
248
+ push(chunk) {
249
+ const now = Date.now();
250
+ const text = normalizeTerminalOutput(chunk);
251
+ this.lastOutputAt = now;
252
+ this.chunks.push({ at: now, text });
253
+ if (USAGE_LIMIT_PATTERN.test(text)) this.usageLimited = true;
254
+ if (PLAN_APPROVAL_PATTERN.test(text)) this.planApprovalPending = true;
255
+ if (COMMIT_SUCCESS_PATTERN.test(text)) this.commitDetected = true;
256
+ const lastToolCallIdx = text.lastIndexOf(TOOL_CALL_MARKER);
257
+ const lastToolResultIdx = text.lastIndexOf(TOOL_RESULT_MARKER);
258
+ if (lastToolCallIdx !== -1 || lastToolResultIdx !== -1) this.toolCallPending = lastToolCallIdx > lastToolResultIdx;
259
+ if (WORKING_PATTERN.test(text)) {
260
+ this.usageLimited = false;
261
+ this.planApprovalPending = false;
262
+ this.commitDetected = false;
263
+ }
264
+ this.evaluate(now);
265
+ }
266
+ stop() {
267
+ clearInterval(this.timer);
268
+ }
269
+ /**
270
+ * Applies a precise moment reported by the agent-moment hook script (see
271
+ * agentMomentHookScript.ts, routed here via filesServer.ts's /agent-hook route and
272
+ * orchestrator.ts's registerAgentActivityWatcher) — takes priority over the text/idle-timer
273
+ * path below rather than racing it, since a hook firing means Claude Code itself just told
274
+ * us exactly what happened.
275
+ *
276
+ * `usageLimited` is still owned by the text path (no hook event exists for it — see
277
+ * USAGE_LIMIT_PATTERN): `turn_stopped`/`notification` must not clobber that latch just
278
+ * because the CLI also happens to stop/notify around a limit message.
279
+ */
280
+ reportHookMoment(moment) {
281
+ this.lastOutputAt = Date.now();
282
+ switch (moment) {
283
+ case "implementing":
284
+ this.usageLimited = false;
285
+ this.planApprovalPending = false;
286
+ this.commitDetected = false;
287
+ this.setState("implementing");
288
+ break;
289
+ case "waiting_plan_approval":
290
+ this.planApprovalPending = true;
291
+ this.setState("waiting_plan_approval");
292
+ break;
293
+ case "commit_detected":
294
+ this.commitDetected = true;
295
+ break;
296
+ case "turn_stopped":
297
+ if (this.usageLimited) break;
298
+ this.setState(this.commitDetected ? "done" : "waiting_user");
299
+ break;
300
+ case "notification":
301
+ if (this.usageLimited || this.planApprovalPending) break;
302
+ if (this.state === "implementing" || this.state === "starting") this.setState("waiting_user");
303
+ break;
304
+ }
305
+ }
306
+ evaluate(now = Date.now()) {
307
+ while (this.chunks.length > 0 && now - this.chunks[0].at > RECENT_WINDOW_MS) this.chunks.shift();
308
+ const next = classifyAgentOutput(this.chunks.map((chunk) => chunk.text).join(""), {
309
+ silentForMs: now - this.lastOutputAt,
310
+ usageLimited: this.usageLimited,
311
+ planApprovalPending: this.planApprovalPending,
312
+ toolCallPending: this.toolCallPending,
313
+ commitDetected: this.commitDetected,
314
+ previous: this.state
315
+ });
316
+ this.setState(next);
317
+ }
318
+ setState(next) {
319
+ if (next === this.state) return;
320
+ this.state = next;
321
+ this.onChange(next);
322
+ }
323
+ };
324
+ var activeWatchers = /* @__PURE__ */ new Map();
325
+ function registerAgentActivityWatcher(sessionId, watcher) {
326
+ activeWatchers.set(sessionId, watcher);
327
+ }
328
+ function unregisterAgentActivityWatcher(sessionId) {
329
+ activeWatchers.delete(sessionId);
330
+ }
331
+ function handleAgentHookEvent(sessionId, moment) {
332
+ if (!isAgentHookMoment(moment)) return false;
333
+ const watcher = activeWatchers.get(sessionId);
334
+ if (!watcher) return false;
335
+ watcher.reportHookMoment(moment);
336
+ return true;
337
+ }
181
338
 
182
339
  // src/logger.ts
183
340
  var ANSI = {
@@ -237,336 +394,533 @@ var logger = {
237
394
  card: scoped("CARD")
238
395
  };
239
396
 
240
- // src/cloudflared.ts
241
- var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
242
- var STARTUP_TIMEOUT_MS = 2e4;
397
+ // src/ptyRegistry.ts
398
+ import { randomUUID as randomUUID2 } from "node:crypto";
399
+ import { mkdir, rm, writeFile } from "node:fs/promises";
400
+ import { tmpdir } from "node:os";
401
+ import { join as join3 } from "node:path";
402
+ var MAX_BUFFERED_CHUNKS = 2e3;
403
+ var registry = /* @__PURE__ */ new Map();
404
+ var PASTE_ROOT = join3(tmpdir(), "agent-board-pastes");
405
+ var IMAGE_EXT_BY_CODE = ["png", "jpg", "gif", "webp"];
406
+ async function savePastedImage(sessionId, extCode, bytes) {
407
+ const dir = join3(PASTE_ROOT, sessionId);
408
+ await mkdir(dir, { recursive: true });
409
+ const ext = IMAGE_EXT_BY_CODE[extCode] ?? "png";
410
+ const path = join3(dir, `paste-${Date.now()}-${randomUUID2().slice(0, 8)}.${ext}`);
411
+ await writeFile(path, bytes);
412
+ return path;
413
+ }
414
+ function registerPtySession(sessionId, pty, ticket) {
415
+ const handle = { pty, ticket, outputBuffer: [], sockets: /* @__PURE__ */ new Set(), readOnly: false };
416
+ pty.onData((data) => {
417
+ handle.outputBuffer.push(data);
418
+ if (handle.outputBuffer.length > MAX_BUFFERED_CHUNKS) handle.outputBuffer.shift();
419
+ for (const socket of handle.sockets) {
420
+ if (socket.readyState === socket.OPEN) socket.send(data);
421
+ }
422
+ });
423
+ registry.set(sessionId, handle);
424
+ }
425
+ function getPtySession(sessionId) {
426
+ return registry.get(sessionId);
427
+ }
428
+ function setPtySessionReadOnly(sessionId, readOnly) {
429
+ const handle = registry.get(sessionId);
430
+ if (handle) handle.readOnly = readOnly;
431
+ }
432
+ function resizePtySession(sessionId, cols, rows) {
433
+ const handle = registry.get(sessionId);
434
+ if (!handle) return;
435
+ handle.pty.resize(cols, rows);
436
+ }
437
+ function stopPtySession(sessionId) {
438
+ const handle = registry.get(sessionId);
439
+ if (!handle) return;
440
+ for (const socket of handle.sockets) socket.close(4e3, "Session ended");
441
+ handle.pty.kill();
442
+ registry.delete(sessionId);
443
+ void rm(join3(PASTE_ROOT, sessionId), { recursive: true, force: true }).catch(() => {
444
+ });
445
+ }
446
+
447
+ // src/shellSession.ts
448
+ import { randomUUID as randomUUID3 } from "node:crypto";
449
+ import { spawn } from "node-pty";
450
+ var SHELL_COMMAND = process.platform === "win32" ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "bash";
451
+ function spawnShellPty(repoPath, sessionIdPrefix) {
452
+ const sessionId = `${sessionIdPrefix}:${randomUUID3()}`;
453
+ const ticket = randomUUID3();
454
+ const pty = spawn(SHELL_COMMAND, [], {
455
+ name: "xterm-color",
456
+ cols: 120,
457
+ rows: 32,
458
+ cwd: repoPath
459
+ });
460
+ registerPtySession(sessionId, pty, ticket);
461
+ pty.onExit(({ exitCode }) => {
462
+ logger.runner.info("SHELL", `Shell ${sessionId} exited (code ${exitCode}).`);
463
+ stopPtySession(sessionId);
464
+ });
465
+ return { sessionId, ticket };
466
+ }
467
+ function openShellSession(repoPath) {
468
+ return spawnShellPty(repoPath, "shell");
469
+ }
470
+ function closeShellSession(sessionId) {
471
+ stopPtySession(sessionId);
472
+ }
473
+ var columnShells = /* @__PURE__ */ new Map();
474
+ function openOrReuseColumnShell(key, repoPath, initialCommand) {
475
+ const existing = columnShells.get(key);
476
+ if (existing && getPtySession(existing.sessionId)) return existing;
477
+ const shell = spawnShellPty(repoPath, "column-shell");
478
+ columnShells.set(key, shell);
479
+ if (initialCommand) getPtySession(shell.sessionId)?.pty.write(`${initialCommand}\r`);
480
+ return shell;
481
+ }
482
+ function closeColumnShell(key) {
483
+ const existing = columnShells.get(key);
484
+ if (existing) stopPtySession(existing.sessionId);
485
+ columnShells.delete(key);
486
+ }
487
+
488
+ // src/skillsServer.ts
489
+ import { execFile } from "node:child_process";
490
+ import { homedir as homedir2 } from "node:os";
491
+ import { promisify } from "node:util";
492
+ var execFileAsync = promisify(execFile);
243
493
  var IS_WINDOWS = process.platform === "win32";
244
- var BINARY_NAME = IS_WINDOWS ? "cloudflared.exe" : "cloudflared";
245
- var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
246
- function wellKnownDirectories() {
247
- const home = homedir2();
248
- if (IS_WINDOWS) {
249
- const candidates = [
250
- process.env.ProgramFiles && join3(process.env.ProgramFiles, "cloudflared"),
251
- process.env["ProgramFiles(x86)"] && join3(process.env["ProgramFiles(x86)"], "cloudflared"),
252
- process.env.LOCALAPPDATA && join3(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
253
- join3(home, "scoop", "shims"),
254
- process.env.ChocolateyInstall && join3(process.env.ChocolateyInstall, "bin")
255
- ];
256
- return candidates.filter((dir) => Boolean(dir));
494
+ var ANSI_ESCAPE_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
495
+ function stripAnsi(text) {
496
+ return text.replace(ANSI_ESCAPE_RE, "");
497
+ }
498
+ var SKILLS_LIST_TIMEOUT_MS = 2e4;
499
+ var SKILLS_FIND_TIMEOUT_MS = 2e4;
500
+ var SKILLS_REMOVE_TIMEOUT_MS = 2e4;
501
+ var SKILLS_INSTALL_TIMEOUT_MS = 6e4;
502
+ var SKILLS_UPDATE_TIMEOUT_MS = 6e4;
503
+ var MAX_SKILLS_BODY_BYTES = 1e4;
504
+ var CLI_ERROR_MESSAGE_MAX_LENGTH = 2e3;
505
+ var mutationLocksByScope = /* @__PURE__ */ new Map();
506
+ function withScopeLock(scopeKey, task) {
507
+ const previous = mutationLocksByScope.get(scopeKey) ?? Promise.resolve();
508
+ const current = previous.then(task, task);
509
+ mutationLocksByScope.set(
510
+ scopeKey,
511
+ current.then(
512
+ () => void 0,
513
+ () => void 0
514
+ )
515
+ );
516
+ return current;
517
+ }
518
+ function resolveScope(query, isGlobal) {
519
+ if (isGlobal) {
520
+ if (!authenticateAgent(query)) throw new HttpError(401, "Ticket inv\xE1lido ou expirado.");
521
+ return { cwd: homedir2(), lockKey: "global", globalFlag: ["-g"] };
257
522
  }
258
- return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join3(home, ".local", "bin")];
523
+ const entry = authenticate(query);
524
+ if (!entry) throw new HttpError(401, "Ticket inv\xE1lido, expirado, ou projeto desconhecido.");
525
+ return { cwd: entry.repoRoot, lockKey: entry.repoRoot, globalFlag: [] };
259
526
  }
260
- function pathDirectories() {
261
- return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
527
+ async function runSkillsCli(args2, cwd, timeoutMs) {
528
+ try {
529
+ const { stdout } = await execFileAsync("npx", ["skills", ...args2], { cwd, timeout: timeoutMs, shell: IS_WINDOWS });
530
+ return stripAnsi(stdout);
531
+ } catch (err) {
532
+ const cliErr = err;
533
+ if (cliErr.killed) {
534
+ throw new HttpError(504, `Comando da CLI skills excedeu o tempo limite de ${Math.round(timeoutMs / 1e3)}s.`);
535
+ }
536
+ if (typeof cliErr.stderr === "string" && cliErr.stderr.trim()) {
537
+ throw new HttpError(400, cliErr.stderr.trim().slice(0, CLI_ERROR_MESSAGE_MAX_LENGTH));
538
+ }
539
+ throw new HttpError(500, err instanceof Error ? err.message : "Erro desconhecido ao executar a CLI skills.");
540
+ }
262
541
  }
263
- function isExecutableFile(path) {
542
+ function sendSkillsError(res, err) {
543
+ if (err instanceof HttpError) {
544
+ sendJson(res, err.status, { error: err.message });
545
+ return;
546
+ }
547
+ logger.runner.error("SKILLS", "Unexpected error handling a skills request:", err);
548
+ sendJson(res, 500, { error: "Erro interno do runner." });
549
+ }
550
+ function parseListOutput(stdout) {
551
+ let parsed;
264
552
  try {
265
- accessSync(path, constants.X_OK);
266
- return true;
553
+ parsed = JSON.parse(stdout);
267
554
  } catch {
268
- return false;
555
+ throw new HttpError(500, "Resposta inesperada da CLI skills (list).");
556
+ }
557
+ if (!Array.isArray(parsed)) throw new HttpError(500, "Resposta inesperada da CLI skills (list).");
558
+ return parsed;
559
+ }
560
+ var FIND_ENTRY_RE = /^(?<package>[^\s@]+)@(?<skill>\S+)\s+(?<installs>[\d.,]+[KM]?)\s+installs?$/;
561
+ var FIND_URL_RE = /^└\s+(?<url>\S+)$/;
562
+ function parseFindOutput(stdout) {
563
+ const lines = stdout.split(/\r?\n/);
564
+ const results = [];
565
+ for (let i = 0; i < lines.length; i++) {
566
+ const match = FIND_ENTRY_RE.exec(lines[i].trim());
567
+ if (!match?.groups) continue;
568
+ const urlMatch = i + 1 < lines.length ? FIND_URL_RE.exec(lines[i + 1].trim()) : null;
569
+ results.push({
570
+ package: match.groups.package,
571
+ skill: match.groups.skill,
572
+ installs: match.groups.installs,
573
+ url: urlMatch?.groups?.url ?? ""
574
+ });
575
+ }
576
+ return results;
577
+ }
578
+ var POPULAR_SEARCH_TERMS = ["design", "test", "code", "git", "docs", "review", "data", "deploy"];
579
+ var POPULAR_SKILLS_LIMIT = 10;
580
+ var POPULAR_SKILLS_TTL_MS = 12 * 60 * 60 * 1e3;
581
+ function parseInstallsCount(installs) {
582
+ const match = /^([\d.]+)\s*([KM])?$/i.exec(installs.trim());
583
+ if (!match) return 0;
584
+ const value = Number.parseFloat(match[1]);
585
+ if (Number.isNaN(value)) return 0;
586
+ const suffix = match[2]?.toUpperCase();
587
+ if (suffix === "K") return value * 1e3;
588
+ if (suffix === "M") return value * 1e6;
589
+ return value;
590
+ }
591
+ var popularSkillsCache = null;
592
+ var popularSkillsPromise = null;
593
+ async function computePopularSkills(cwd) {
594
+ const perTerm = await Promise.all(
595
+ POPULAR_SEARCH_TERMS.map(
596
+ (term) => runSkillsCli(["find", term], cwd, SKILLS_FIND_TIMEOUT_MS).then(parseFindOutput).catch(() => [])
597
+ )
598
+ );
599
+ const byKey = /* @__PURE__ */ new Map();
600
+ for (const entry of perTerm.flat()) {
601
+ const key = `${entry.package}@${entry.skill}`;
602
+ if (!byKey.has(key)) byKey.set(key, entry);
603
+ }
604
+ return [...byKey.values()].sort((a, b) => parseInstallsCount(b.installs) - parseInstallsCount(a.installs)).slice(0, POPULAR_SKILLS_LIMIT);
605
+ }
606
+ function getPopularSkills(cwd) {
607
+ if (popularSkillsCache && Date.now() - popularSkillsCache.computedAt < POPULAR_SKILLS_TTL_MS) {
608
+ return Promise.resolve(popularSkillsCache.entries);
609
+ }
610
+ if (!popularSkillsPromise) {
611
+ popularSkillsPromise = computePopularSkills(cwd).then((entries) => {
612
+ popularSkillsCache = { computedAt: Date.now(), entries };
613
+ return entries;
614
+ }).finally(() => {
615
+ popularSkillsPromise = null;
616
+ });
269
617
  }
618
+ return popularSkillsPromise;
270
619
  }
271
- function findInstalledBinary() {
272
- const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
273
- const directories = [...pathDirectories(), ...wellKnownDirectories()];
274
- const candidates = [...override ? [override] : [], ...directories.map((dir) => join3(dir, BINARY_NAME))];
275
- return candidates.find(isExecutableFile) ?? null;
620
+ function parseCliResultOutput(stdout) {
621
+ const trimmed = stdout.trim();
622
+ if (!trimmed) return { output: "" };
623
+ try {
624
+ return JSON.parse(trimmed);
625
+ } catch {
626
+ return { output: trimmed };
627
+ }
276
628
  }
277
- async function locateCloudflared() {
278
- const installed = findInstalledBinary();
279
- if (installed) return installed;
280
- if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
629
+ async function handleSkillsList(res, query, isGlobal) {
281
630
  try {
282
- logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
283
- return await downloadCloudflared(bundledBinaryPath);
631
+ const { cwd, globalFlag } = resolveScope(query, isGlobal);
632
+ const stdout = await runSkillsCli(["list", "--json", ...globalFlag], cwd, SKILLS_LIST_TIMEOUT_MS);
633
+ sendJson(res, 200, { skills: parseListOutput(stdout) });
284
634
  } catch (err) {
285
- logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
286
- return null;
635
+ sendSkillsError(res, err);
287
636
  }
288
637
  }
289
- var lookup = null;
290
- function resolveCloudflaredPath() {
291
- lookup ??= locateCloudflared().then((path) => {
292
- if (!path) lookup = null;
293
- return path;
294
- });
295
- return lookup;
638
+ async function handleSkillsFind(res, query, isGlobal) {
639
+ try {
640
+ const { cwd } = resolveScope(query, isGlobal);
641
+ const q = typeof query.q === "string" ? query.q : "";
642
+ const owner = typeof query.owner === "string" ? query.owner : "";
643
+ const args2 = ["find"];
644
+ if (q) args2.push(q);
645
+ if (owner) args2.push("--owner", owner);
646
+ const stdout = await runSkillsCli(args2, cwd, SKILLS_FIND_TIMEOUT_MS);
647
+ sendJson(res, 200, { results: parseFindOutput(stdout) });
648
+ } catch (err) {
649
+ sendSkillsError(res, err);
650
+ }
296
651
  }
297
- async function startQuickTunnel(label, port) {
298
- const executable = await resolveCloudflaredPath();
299
- if (!executable) {
300
- logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
301
- return null;
652
+ async function handleSkillsPopular(res, query, isGlobal) {
653
+ try {
654
+ const { cwd } = resolveScope(query, isGlobal);
655
+ const entries = await getPopularSkills(cwd);
656
+ sendJson(res, 200, { results: entries });
657
+ } catch (err) {
658
+ sendSkillsError(res, err);
302
659
  }
303
- return new Promise((resolve5) => {
304
- let child2;
660
+ }
661
+ async function handleSkillsInstall(req, res, query, isGlobal) {
662
+ try {
663
+ const { cwd, lockKey, globalFlag } = resolveScope(query, isGlobal);
664
+ const raw = await readRequestBody(req, MAX_SKILLS_BODY_BYTES);
665
+ let body;
305
666
  try {
306
- child2 = spawn(executable, [
307
- "tunnel",
308
- "--url",
309
- `http://localhost:${port}`,
310
- "--http-host-header",
311
- `localhost:${port}`
312
- ]);
313
- } catch (err) {
314
- logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
315
- resolve5(null);
316
- return;
667
+ body = JSON.parse(raw);
668
+ } catch {
669
+ throw new HttpError(400, "Corpo inv\xE1lido.");
317
670
  }
318
- let settled = false;
319
- const settle = (value) => {
320
- if (settled) return;
321
- settled = true;
322
- clearTimeout(timer);
323
- resolve5(value);
324
- };
325
- const timer = setTimeout(() => {
326
- logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
327
- settle(null);
328
- }, STARTUP_TIMEOUT_MS);
329
- const onOutput = (chunk) => {
330
- if (settled) return;
331
- const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
332
- if (match) settle({ url: match[0], child: child2 });
333
- };
334
- child2.stdout?.on("data", onOutput);
335
- child2.stderr?.on("data", onOutput);
336
- child2.on("error", (err) => {
337
- logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
338
- settle(null);
339
- });
340
- child2.on("exit", (code) => {
341
- logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
342
- settle(null);
343
- });
344
- });
345
- }
346
- function toTunnelHostname(url) {
347
- return new URL(url).host;
348
- }
349
-
350
- // src/devServer.ts
351
- var STARTUP_TIMEOUT_MS2 = 45e3;
352
- var IS_WINDOWS2 = process.platform === "win32";
353
- var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
354
- var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
355
- var ANSI_ESCAPE_RE = /\x1b\[[0-9;]*m/g;
356
- function detectPortFromOutput(chunk) {
357
- const clean = chunk.replace(ANSI_ESCAPE_RE, "");
358
- const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
359
- return match ? Number(match[1]) : null;
360
- }
361
- function killProcessTree(child2) {
362
- if (child2.pid == null) return;
363
- if (IS_WINDOWS2) {
364
- spawn2("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
365
- } else {
366
- child2.kill();
671
+ if (!body.package || typeof body.package !== "string") throw new HttpError(400, "Campo package \xE9 obrigat\xF3rio.");
672
+ const args2 = ["add", body.package];
673
+ if (body.skill) args2.push("-s", body.skill);
674
+ if (body.agent) args2.push("-a", body.agent);
675
+ args2.push(...globalFlag, "-y", "--json");
676
+ const stdout = await withScopeLock(lockKey, () => runSkillsCli(args2, cwd, SKILLS_INSTALL_TIMEOUT_MS));
677
+ sendJson(res, 200, { result: parseCliResultOutput(stdout) });
678
+ } catch (err) {
679
+ sendSkillsError(res, err);
367
680
  }
368
681
  }
369
- var previews = /* @__PURE__ */ new Map();
370
- function ensurePreview(key, label, cwd, command2, cardId) {
371
- const existing = previews.get(key);
372
- if (existing) {
373
- existing.cardIds.add(cardId);
374
- return existing.result;
375
- }
376
- let child2;
682
+ async function handleSkillsRemove(req, res, query, isGlobal) {
377
683
  try {
378
- child2 = spawn2(command2, { cwd, shell: true, windowsHide: true });
684
+ const { cwd, lockKey, globalFlag } = resolveScope(query, isGlobal);
685
+ const raw = await readRequestBody(req, MAX_SKILLS_BODY_BYTES);
686
+ let body;
687
+ try {
688
+ body = JSON.parse(raw);
689
+ } catch {
690
+ throw new HttpError(400, "Corpo inv\xE1lido.");
691
+ }
692
+ if (!body.name || typeof body.name !== "string") throw new HttpError(400, "Campo name \xE9 obrigat\xF3rio.");
693
+ const args2 = ["remove", body.name, ...globalFlag, "--json"];
694
+ const stdout = await withScopeLock(lockKey, () => runSkillsCli(args2, cwd, SKILLS_REMOVE_TIMEOUT_MS));
695
+ sendJson(res, 200, { result: parseCliResultOutput(stdout) });
379
696
  } catch (err) {
380
- logger.runner.error("DEV_SERVER", `Failed to spawn "${command2}" in ${cwd}:`, err);
381
- return Promise.resolve(null);
382
- }
383
- const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
384
- entry.result = new Promise((resolve5) => {
385
- let settled = false;
386
- const settle = (value) => {
387
- if (settled) return;
388
- settled = true;
389
- clearTimeout(timer);
390
- if (!value) {
391
- killProcessTree(child2);
392
- previews.delete(key);
393
- }
394
- resolve5(value);
395
- };
396
- const timer = setTimeout(() => {
397
- logger.runner.warn("DEV_SERVER", `"${command2}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
398
- settle(null);
399
- }, STARTUP_TIMEOUT_MS2);
400
- const onOutput = async (chunk) => {
401
- if (settled) return;
402
- const port = detectPortFromOutput(chunk.toString("utf-8"));
403
- if (port == null) return;
404
- settled = true;
405
- clearTimeout(timer);
406
- const tunnel = await startQuickTunnel(`dev server ${key}`, port);
407
- if (!tunnel) {
408
- logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
409
- killProcessTree(child2);
410
- previews.delete(key);
411
- resolve5(null);
412
- return;
413
- }
414
- entry.tunnelChild = tunnel.child;
415
- logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
416
- resolve5({ label, url: tunnel.url });
417
- };
418
- child2.stdout?.on("data", onOutput);
419
- child2.stderr?.on("data", onOutput);
420
- child2.on("error", (err) => {
421
- logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command2} in ${cwd}):`, err);
422
- settle(null);
423
- });
424
- child2.on("exit", (code) => {
425
- logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
426
- settle(null);
427
- });
428
- });
429
- previews.set(key, entry);
430
- return entry.result;
431
- }
432
- function releaseCard(cardId) {
433
- for (const [key, entry] of previews) {
434
- if (!entry.cardIds.delete(cardId)) continue;
435
- if (entry.cardIds.size > 0) continue;
436
- killProcessTree(entry.child);
437
- entry.tunnelChild?.kill();
438
- previews.delete(key);
439
- }
440
- }
441
- function stopProjectPreviews(projectId) {
442
- const prefix = `${projectId}:`;
443
- for (const [key, entry] of previews) {
444
- if (!key.startsWith(prefix)) continue;
445
- killProcessTree(entry.child);
446
- entry.tunnelChild?.kill();
447
- previews.delete(key);
697
+ sendSkillsError(res, err);
448
698
  }
449
699
  }
450
- function stopAll() {
451
- for (const key of [...previews.keys()]) {
452
- const entry = previews.get(key);
453
- if (!entry) continue;
454
- killProcessTree(entry.child);
455
- entry.tunnelChild?.kill();
456
- previews.delete(key);
700
+ async function handleSkillsUpdate(req, res, query, isGlobal) {
701
+ try {
702
+ const { cwd, lockKey, globalFlag } = resolveScope(query, isGlobal);
703
+ const raw = await readRequestBody(req, MAX_SKILLS_BODY_BYTES);
704
+ let body = {};
705
+ if (raw.trim()) {
706
+ try {
707
+ body = JSON.parse(raw);
708
+ } catch {
709
+ throw new HttpError(400, "Corpo inv\xE1lido.");
710
+ }
711
+ }
712
+ const names = Array.isArray(body.names) ? body.names.filter((name) => typeof name === "string" && name.length > 0) : [];
713
+ const args2 = ["update", ...names, ...globalFlag, "-y", "--json"];
714
+ const stdout = await withScopeLock(lockKey, () => runSkillsCli(args2, cwd, SKILLS_UPDATE_TIMEOUT_MS));
715
+ sendJson(res, 200, { result: parseCliResultOutput(stdout) });
716
+ } catch (err) {
717
+ sendSkillsError(res, err);
457
718
  }
458
719
  }
459
720
 
460
- // src/git.ts
461
- import { execFile } from "node:child_process";
462
- import { promisify } from "node:util";
463
- var execFileAsync = promisify(execFile);
464
- async function pushBranchToMain(repoPath) {
721
+ // src/activityLog.ts
722
+ async function logActivity(supabase, params) {
465
723
  try {
466
- await execFileAsync("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
467
- return { ok: true };
724
+ const { error } = await supabase.from("activity_log").insert({
725
+ project_id: params.projectId,
726
+ card_id: params.cardId,
727
+ user_id: params.userId,
728
+ event_type: params.eventType,
729
+ payload: params.payload ?? {}
730
+ });
731
+ if (error) throw error;
468
732
  } catch (err) {
469
- const stderr = err.stderr;
470
- const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
471
- return { ok: false, error };
733
+ logger.runner.error("ACTIVITY", `Failed to record activity log entry (${params.eventType}):`, err);
472
734
  }
473
735
  }
474
736
 
475
- // src/orchestrator.ts
476
- import { randomUUID as randomUUID3 } from "node:crypto";
477
-
478
- // src/agentActivity.ts
479
- var RECENT_WINDOW_MS = 6e3;
480
- var IDLE_AFTER_MS = 45e3;
481
- var EVAL_INTERVAL_MS = 2e3;
482
- var ANSI_PATTERN = /\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g;
483
- var BOX_DRAWING_PATTERN = /[─-╿]/g;
484
- var WORKING_PATTERN = /esc to interrupt/i;
485
- 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;
486
- var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
487
- var USER_PROMPT_PATTERNS = [
488
- /do you want to\b/i,
489
- /would you like to\b/i,
490
- /do you trust the files\b/i,
491
- /press enter to continue/i,
492
- /\(y\/n\)/i,
493
- /❯\s*1\.\s/
494
- ];
495
- function normalizeTerminalOutput(raw) {
496
- return raw.replace(ANSI_PATTERN, "").replace(BOX_DRAWING_PATTERN, " ").replace(/[ \t]+/g, " ");
737
+ // src/sessions.ts
738
+ async function countRunningSessions(supabase, projectId, ownerUserId) {
739
+ const { count, error } = await supabase.from("sessions").select("id, cards!card_id!inner(project_id, assigned_user_id)", { count: "exact", head: true }).eq("cards.project_id", projectId).eq("cards.assigned_user_id", ownerUserId).eq("status", "running");
740
+ if (error) throw error;
741
+ return count ?? 0;
497
742
  }
498
- function classifyAgentOutput(recentOutput, options) {
499
- if (WORKING_PATTERN.test(recentOutput)) return "implementing";
500
- if (options.usageLimited) return "usage_limited";
501
- if (options.planApprovalPending) return "waiting_plan_approval";
502
- if (USER_PROMPT_PATTERNS.some((pattern) => pattern.test(recentOutput))) return "waiting_user";
503
- if (options.silentForMs >= IDLE_AFTER_MS) return "waiting_user";
504
- return options.previous;
743
+ async function listQueuedSessionsForRunner(supabase, ownerUserId) {
744
+ const { data, error } = await supabase.from("sessions").select(
745
+ "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
746
+ ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
747
+ if (error) throw error;
748
+ return data;
505
749
  }
506
- var AgentActivityWatcher = class {
507
- constructor(onChange) {
508
- this.onChange = onChange;
509
- this.timer = setInterval(() => this.evaluate(), EVAL_INTERVAL_MS);
510
- this.timer.unref();
511
- }
512
- chunks = [];
513
- state = "implementing";
514
- lastOutputAt = Date.now();
515
- usageLimited = false;
516
- planApprovalPending = false;
517
- timer;
518
- push(chunk) {
519
- const now = Date.now();
520
- const text = normalizeTerminalOutput(chunk);
521
- this.lastOutputAt = now;
522
- this.chunks.push({ at: now, text });
523
- if (USAGE_LIMIT_PATTERN.test(text)) this.usageLimited = true;
524
- if (PLAN_APPROVAL_PATTERN.test(text)) this.planApprovalPending = true;
525
- if (WORKING_PATTERN.test(text)) {
526
- this.usageLimited = false;
527
- this.planApprovalPending = false;
528
- }
529
- this.evaluate(now);
530
- }
531
- stop() {
532
- clearInterval(this.timer);
750
+ async function createSession(supabase, userId, cardId, resumeClaudeSessionId) {
751
+ const { data, error } = await supabase.from("sessions").insert({
752
+ card_id: cardId,
753
+ user_id: userId,
754
+ status: "queued",
755
+ // Pre-set so spawnSessionPty takes the resume path (see its doc comment) instead of
756
+ // starting a blank conversation and resending the card's instructions.
757
+ ...resumeClaudeSessionId ? { claude_session_id: resumeClaudeSessionId } : {}
758
+ }).select("id").single();
759
+ if (error) throw error;
760
+ return data.id;
761
+ }
762
+ async function updateSession(supabase, sessionId, changes, activityContext) {
763
+ const { error } = await supabase.from("sessions").update({
764
+ ...changes.status !== void 0 ? { status: changes.status } : {},
765
+ ...changes.agentState !== void 0 ? { agent_state: changes.agentState } : {},
766
+ ...changes.startedAt !== void 0 ? { started_at: changes.startedAt } : {},
767
+ ...changes.endedAt !== void 0 ? { ended_at: changes.endedAt } : {},
768
+ ...changes.terminalTicket !== void 0 ? { terminal_ticket: changes.terminalTicket } : {},
769
+ ...changes.terminalTicketExpiresAt !== void 0 ? { terminal_ticket_expires_at: changes.terminalTicketExpiresAt } : {},
770
+ ...changes.claudeSessionId !== void 0 ? { claude_session_id: changes.claudeSessionId } : {}
771
+ }).eq("id", sessionId);
772
+ if (error) throw error;
773
+ if (changes.status !== void 0 && activityContext) {
774
+ await logActivity(supabase, {
775
+ projectId: activityContext.projectId,
776
+ cardId: activityContext.cardId,
777
+ userId: activityContext.userId,
778
+ eventType: "session_status_changed",
779
+ payload: { status: changes.status }
780
+ });
533
781
  }
534
- evaluate(now = Date.now()) {
535
- while (this.chunks.length > 0 && now - this.chunks[0].at > RECENT_WINDOW_MS) this.chunks.shift();
536
- const next = classifyAgentOutput(this.chunks.map((chunk) => chunk.text).join(""), {
537
- silentForMs: now - this.lastOutputAt,
538
- usageLimited: this.usageLimited,
539
- planApprovalPending: this.planApprovalPending,
540
- previous: this.state
782
+ }
783
+ async function getActiveSessionForCard(supabase, cardId) {
784
+ const { data, error } = await supabase.from("sessions").select("id, status").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
785
+ if (error) throw error;
786
+ return data;
787
+ }
788
+ async function pauseRunningSession(supabase, sessionId, activityContext) {
789
+ const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
790
+ if (error) throw error;
791
+ const applied = (data ?? []).length > 0;
792
+ if (applied) {
793
+ await logActivity(supabase, {
794
+ projectId: activityContext.projectId,
795
+ cardId: activityContext.cardId,
796
+ userId: activityContext.userId,
797
+ eventType: "session_status_changed",
798
+ payload: { status: "paused" }
541
799
  });
542
- if (next === this.state) return;
543
- this.state = next;
544
- this.onChange(next);
545
800
  }
546
- };
547
-
548
- // src/agentSession.ts
549
- import { randomUUID as randomUUID2 } from "node:crypto";
550
- import { spawn as spawn3 } from "node-pty";
551
-
552
- // src/sessionName.ts
553
- var MAX_LENGTH = 60;
554
- var FALLBACK_NAME = "card";
555
- function toSessionName(title) {
556
- const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
557
- if (slug.length === 0) return FALLBACK_NAME;
558
- if (slug.length <= MAX_LENGTH) return slug;
559
- const truncated = slug.slice(0, MAX_LENGTH);
560
- const lastHyphen = truncated.lastIndexOf("-");
561
- return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
801
+ return applied;
562
802
  }
563
-
564
- // src/statuslineScript.ts
565
- import { writeFileSync as writeFileSync3 } from "node:fs";
566
- import { tmpdir } from "node:os";
803
+ async function getLastClaudeSessionIdForCard(supabase, cardId) {
804
+ const { data, error } = await supabase.from("sessions").select("claude_session_id").eq("card_id", cardId).order("created_at", { ascending: false }).limit(1).maybeSingle();
805
+ if (error) throw error;
806
+ return data?.claude_session_id ?? null;
807
+ }
808
+ async function updateCard(supabase, cardId, changes) {
809
+ const { error } = await supabase.from("cards").update({
810
+ ...changes.column !== void 0 ? { column_name: changes.column } : {},
811
+ ...changes.previewUrls !== void 0 ? { preview_urls: changes.previewUrls } : {},
812
+ ...changes.deployError !== void 0 ? { deploy_error: changes.deployError } : {},
813
+ ...changes.customActionStatus !== void 0 ? { custom_action_status: changes.customActionStatus } : {},
814
+ ...changes.customActionOutput !== void 0 ? { custom_action_output: changes.customActionOutput } : {},
815
+ ...changes.requeueRequestedAt !== void 0 ? { requeue_requested_at: changes.requeueRequestedAt } : {}
816
+ }).eq("id", cardId);
817
+ if (error) throw error;
818
+ }
819
+ async function getProject(supabase, projectId) {
820
+ const { data, error } = await supabase.from("projects").select("*").eq("id", projectId).single();
821
+ if (error) throw error;
822
+ return data;
823
+ }
824
+ async function getProjectRepos(supabase, projectId) {
825
+ const { data, error } = await supabase.from("project_repos").select("subpath, dev_command, column_actions").eq("project_id", projectId);
826
+ if (error) throw error;
827
+ return data;
828
+ }
829
+ async function listCardsInColumn(supabase, projectId, column) {
830
+ const { data, error } = await supabase.from("cards").select(
831
+ "id, project_id, title, instructions, column_name, assigned_user_id, deploy_error, file_references, preview_urls, custom_action_status, requeue_requested_at"
832
+ ).eq("project_id", projectId).eq("column_name", column);
833
+ if (error) throw error;
834
+ return data;
835
+ }
836
+ async function clearProjectDeployRequest(supabase, projectId) {
837
+ const { error } = await supabase.from("projects").update({ deploy_requested_at: null }).eq("id", projectId);
838
+ if (error) throw error;
839
+ }
840
+ async function clearDevServerRestartRequest(supabase, projectId) {
841
+ const { error } = await supabase.from("projects").update({ dev_server_restart_requested_at: null }).eq("id", projectId);
842
+ if (error) throw error;
843
+ }
844
+ async function clearDeployActionRerunRequest(supabase, projectId) {
845
+ const { error } = await supabase.from("projects").update({ deploy_action_rerun_requested_at: null }).eq("id", projectId);
846
+ if (error) throw error;
847
+ }
848
+ async function updateProjectFilesTicket(supabase, projectId, ticket, expiresAt) {
849
+ const { error } = await supabase.from("projects").update({ files_ticket: ticket, files_ticket_expires_at: expiresAt }).eq("id", projectId);
850
+ if (error) throw error;
851
+ }
852
+ async function updateRunnerSetupTicket(supabase, runnerId, ticket, expiresAt, allowedRootPath) {
853
+ const { error } = await supabase.from("runners").update({ setup_ticket: ticket, setup_ticket_expires_at: expiresAt, allowed_root_path: allowedRootPath }).eq("id", runnerId);
854
+ if (error) throw error;
855
+ }
856
+ async function updateRunnerAgentTicket(supabase, runnerId, ticket, expiresAt) {
857
+ const { error } = await supabase.from("runners").update({ agent_ticket: ticket, agent_ticket_expires_at: expiresAt }).eq("id", runnerId);
858
+ if (error) throw error;
859
+ }
860
+ async function listMyProjects(supabase) {
861
+ const { data, error } = await supabase.from("projects").select("id, runner_id, repo_path, logo_path");
862
+ if (error) throw error;
863
+ return data;
864
+ }
865
+ async function listProjectRunnerIds(supabase, runnerId) {
866
+ const { data, error } = await supabase.from("project_runners").select("project_id").eq("runner_id", runnerId);
867
+ if (error) throw error;
868
+ return new Set(data.map((row) => row.project_id));
869
+ }
870
+ async function addProjectRunner(supabase, projectId, runnerId) {
871
+ const { error } = await supabase.from("project_runners").insert({ project_id: projectId, runner_id: runnerId });
872
+ if (error) throw error;
873
+ }
874
+ async function getRunnerMaxConcurrentProjects(supabase, runnerId) {
875
+ const { data, error } = await supabase.from("runners").select("max_concurrent_projects").eq("id", runnerId).single();
876
+ if (error) throw error;
877
+ return data.max_concurrent_projects;
878
+ }
879
+ async function updateProjectLogoPath(supabase, projectId, logoPath) {
880
+ const { error } = await supabase.from("projects").update({ logo_path: logoPath }).eq("id", projectId);
881
+ if (error) throw error;
882
+ }
883
+ async function upsertColumnShell(supabase, projectId, column, kind, sessionId, ticket) {
884
+ const { error } = await supabase.from("column_shells").upsert(
885
+ { project_id: projectId, column_name: column, kind, session_id: sessionId, ticket },
886
+ { onConflict: "project_id,column_name,kind" }
887
+ );
888
+ if (error) throw error;
889
+ }
890
+ async function deleteColumnShell(supabase, projectId, column, kind) {
891
+ const { error } = await supabase.from("column_shells").delete().eq("project_id", projectId).eq("column_name", column).eq("kind", kind);
892
+ if (error) throw error;
893
+ }
894
+ async function getClaudeUsage(supabase, userId) {
895
+ const { data, error } = await supabase.from("claude_usage").select("session_resets_at, week_resets_at").eq("user_id", userId).maybeSingle();
896
+ if (error) throw error;
897
+ if (!data) return null;
898
+ return { sessionResetsAt: data.session_resets_at, weekResetsAt: data.week_resets_at };
899
+ }
900
+ async function upsertClaudeUsage(supabase, userId, changes) {
901
+ const { error } = await supabase.from("claude_usage").upsert({
902
+ user_id: userId,
903
+ ...changes.sessionPercent !== void 0 ? { session_pct: changes.sessionPercent } : {},
904
+ ...changes.sessionResetsAt !== void 0 ? { session_resets_at: changes.sessionResetsAt } : {},
905
+ ...changes.weekPercent !== void 0 ? { week_pct: changes.weekPercent } : {},
906
+ ...changes.weekResetsAt !== void 0 ? { week_resets_at: changes.weekResetsAt } : {},
907
+ updated_at: (/* @__PURE__ */ new Date()).toISOString()
908
+ });
909
+ if (error) throw error;
910
+ }
911
+
912
+ // src/usageMonitor.ts
913
+ import { randomUUID as randomUUID4 } from "node:crypto";
914
+ import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync6 } from "node:fs";
915
+ import { homedir as homedir3, tmpdir as tmpdir5 } from "node:os";
916
+ import { join as join7 } from "node:path";
917
+ import { spawn as spawn2 } from "node-pty";
918
+
919
+ // src/agentMomentHookScript.ts
920
+ import { writeFileSync as writeFileSync3 } from "node:fs";
921
+ import { tmpdir as tmpdir2 } from "node:os";
567
922
  import { join as join4 } from "node:path";
568
- var CLAUDE_BINARY = process.platform === "win32" ? "claude.exe" : "claude";
569
- var STATUSLINE_SCRIPT_PATH = join4(tmpdir(), "agent-board-statusline.cjs");
923
+ var AGENT_MOMENT_HOOK_SCRIPT_PATH = join4(tmpdir2(), "agent-board-agent-moment-hook.cjs");
570
924
  var SCRIPT_SOURCE = `#!/usr/bin/env node
571
925
  "use strict";
572
926
  const http = require("node:http");
@@ -581,6 +935,229 @@ function readStdin() {
581
935
  });
582
936
  }
583
937
 
938
+ // Mirrors agentActivity.ts's COMMIT_SUCCESS_PATTERN (git's own success banner, e.g.
939
+ // "[main 1a2b3c4] fix: ..."), checked against the tool_response Claude Code hands back for a
940
+ // Bash call rather than the raw scrollback \u2014 the banner can't have scrolled out of a sliding
941
+ // text window if it's read straight off the command's own result.
942
+ const COMMIT_SUCCESS_PATTERN = /\\[\\S+\\s+[0-9a-f]{7,40}\\]/;
943
+ const GIT_COMMIT_COMMAND_PATTERN = /\\bgit\\b[^\\n]*\\bcommit\\b/i;
944
+
945
+ // Which "moment" (if any) this hook invocation reports, from the single JSON payload Claude
946
+ // Code gives a hook on stdin. Returning null means "nothing conclusive" \u2014 the hook exits
947
+ // quietly and the runner's text-based fallback (agentActivity.ts) stays in charge.
948
+ function detectMoment(input) {
949
+ const eventName = input.hook_event_name;
950
+
951
+ // A new turn actually starting \u2014 whether typed by a human in the terminal or submitted
952
+ // programmatically (the card's opening instructions, or the auto "continue" after a
953
+ // usage-limit wait \u2014 see agentSession.ts).
954
+ if (eventName === "UserPromptSubmit") return "implementing";
955
+
956
+ // Claude Code's own exit-plan-mode tool call: this is the precise instant it stops to ask
957
+ // "would you like to proceed?", before the CLI even renders the prompt.
958
+ if (eventName === "PreToolUse" && input.tool_name === "ExitPlanMode") return "waiting_plan_approval";
959
+
960
+ // The plan prompt resolving one way or the other: approved, the turn resumes implementing
961
+ // right away with no new UserPromptSubmit to catch it; asked to revise, a fresh
962
+ // UserPromptSubmit follows almost immediately anyway. Either way "implementing" is correct
963
+ // or self-corrects within a beat \u2014 never gets stuck on "waiting_plan_approval" forever.
964
+ if (eventName === "PostToolUse" && input.tool_name === "ExitPlanMode") return "implementing";
965
+
966
+ // A commit that actually succeeded, read off the Bash tool's own structured result instead
967
+ // of guessing from scrollback text (exact, and immune to locale/color/format changes to
968
+ // git's own banner).
969
+ if (eventName === "PostToolUse" && input.tool_name === "Bash") {
970
+ const command = String((input.tool_input && input.tool_input.command) || "");
971
+ if (!GIT_COMMIT_COMMAND_PATTERN.test(command)) return null;
972
+ const response = JSON.stringify(input.tool_response || "");
973
+ return COMMIT_SUCCESS_PATTERN.test(response) ? "commit_detected" : null;
974
+ }
975
+
976
+ // The turn actually ending \u2014 Claude Code back at its input box, no idle timer needed.
977
+ if (eventName === "Stop") return "turn_stopped";
978
+
979
+ // Claude Code's own notice that it's waiting on the user (permission prompts under normal
980
+ // permission modes, or its 60s-idle nudge) \u2014 a generic fallback for whatever specific
981
+ // prompt isn't one of the moments above.
982
+ if (eventName === "Notification") return "notification";
983
+
984
+ return null;
985
+ }
986
+
987
+ function reportToRunner(sessionId, ticket, port, moment) {
988
+ return new Promise((resolve) => {
989
+ try {
990
+ const body = JSON.stringify({ sessionId, ticket, moment });
991
+ const req = http.request(
992
+ {
993
+ host: "127.0.0.1",
994
+ port,
995
+ path: "/agent-hook",
996
+ method: "POST",
997
+ headers: { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) },
998
+ timeout: 1000,
999
+ },
1000
+ (res) => {
1001
+ res.resume();
1002
+ resolve();
1003
+ },
1004
+ );
1005
+ req.on("error", () => resolve());
1006
+ req.on("timeout", () => { req.destroy(); resolve(); });
1007
+ req.end(body);
1008
+ } catch {
1009
+ resolve();
1010
+ }
1011
+ });
1012
+ }
1013
+
1014
+ (async () => {
1015
+ try {
1016
+ const input = JSON.parse(await readStdin());
1017
+ const moment = detectMoment(input);
1018
+ if (moment) {
1019
+ const sessionId = process.env.AGENT_BOARD_SESSION_ID;
1020
+ const ticket = process.env.AGENT_BOARD_STATUSLINE_TICKET;
1021
+ const port = Number(process.env.AGENT_BOARD_STATUSLINE_PORT);
1022
+ if (sessionId && ticket && port) await reportToRunner(sessionId, ticket, port, moment);
1023
+ }
1024
+ } catch {
1025
+ // A malformed/unexpected hook payload must never block the agent's own turn.
1026
+ }
1027
+ process.exit(0);
1028
+ })();
1029
+ `;
1030
+ function ensureAgentMomentHookScriptWritten() {
1031
+ writeFileSync3(AGENT_MOMENT_HOOK_SCRIPT_PATH, SCRIPT_SOURCE, { mode: 493 });
1032
+ logger.runner.info("AGENT-MOMENT-HOOK", `Agent-moment hook script written to ${AGENT_MOMENT_HOOK_SCRIPT_PATH}.`);
1033
+ }
1034
+ function agentMomentHookSettingsFragment() {
1035
+ const hookEntry = {
1036
+ type: "command",
1037
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(AGENT_MOMENT_HOOK_SCRIPT_PATH)}`
1038
+ };
1039
+ return {
1040
+ hooks: {
1041
+ UserPromptSubmit: [{ hooks: [hookEntry] }],
1042
+ PreToolUse: [{ matcher: "ExitPlanMode", hooks: [hookEntry] }],
1043
+ PostToolUse: [
1044
+ { matcher: "ExitPlanMode", hooks: [hookEntry] },
1045
+ { matcher: "Bash", hooks: [hookEntry] }
1046
+ ],
1047
+ Stop: [{ hooks: [hookEntry] }],
1048
+ Notification: [{ hooks: [hookEntry] }]
1049
+ }
1050
+ };
1051
+ }
1052
+
1053
+ // src/safetyHookScript.ts
1054
+ import { writeFileSync as writeFileSync4 } from "node:fs";
1055
+ import { tmpdir as tmpdir3 } from "node:os";
1056
+ import { join as join5 } from "node:path";
1057
+ var SAFETY_HOOK_SCRIPT_PATH = join5(tmpdir3(), "agent-board-safety-hook.cjs");
1058
+ var SCRIPT_SOURCE2 = `#!/usr/bin/env node
1059
+ "use strict";
1060
+
1061
+ function readStdin() {
1062
+ return new Promise((resolve) => {
1063
+ let raw = "";
1064
+ process.stdin.setEncoding("utf8");
1065
+ process.stdin.on("data", (chunk) => { raw += chunk; });
1066
+ process.stdin.on("end", () => resolve(raw));
1067
+ process.stdin.on("error", () => resolve(raw));
1068
+ });
1069
+ }
1070
+
1071
+ const PROTECTED_NAMES = ["node", "claude", "pm2", "cloudflared"];
1072
+
1073
+ // Each one matches a construct that kills by process name/pattern rather than by PID.
1074
+ const KILL_BY_NAME_CONSTRUCTS = [
1075
+ /\\btaskkill\\b[^\\n]*\\/im\\b/i, // Windows: taskkill ... /IM <name>
1076
+ /\\bstop-process\\b[^\\n]*-(?:process)?name\\b/i, // PowerShell: Stop-Process -Name/-ProcessName <name>
1077
+ /\\bget-process\\b[^\\n]*\\|[^\\n]*\\bstop-process\\b/i, // PowerShell: Get-Process <name> | Stop-Process
1078
+ /\\bwmic\\b[^\\n]*\\bprocess\\b[^\\n]*\\bcall\\s+terminate\\b/i, // wmic process where name='<name>' call terminate
1079
+ /\\b(?:pkill|killall)\\b/i, // POSIX: pkill/killall <name or -f pattern>
1080
+ ];
1081
+
1082
+ function mentionsProtectedName(command) {
1083
+ return PROTECTED_NAMES.some((name) => new RegExp(\`\\\\b\${name}(?:\\\\.exe)?\\\\b\`, "i").test(command));
1084
+ }
1085
+
1086
+ function findViolation(command) {
1087
+ if (!mentionsProtectedName(command)) return null;
1088
+ const matched = KILL_BY_NAME_CONSTRUCTS.find((pattern) => pattern.test(command));
1089
+ return matched ? PROTECTED_NAMES.find((name) => new RegExp(\`\\\\b\${name}(?:\\\\.exe)?\\\\b\`, "i").test(command)) : null;
1090
+ }
1091
+
1092
+ (async () => {
1093
+ try {
1094
+ const input = JSON.parse(await readStdin());
1095
+ if (input.hook_event_name === "PreToolUse" && input.tool_name === "Bash") {
1096
+ const command = String((input.tool_input && input.tool_input.command) || "");
1097
+ const violation = findViolation(command);
1098
+ if (violation) {
1099
+ process.stdout.write(JSON.stringify({
1100
+ hookSpecificOutput: {
1101
+ hookEventName: "PreToolUse",
1102
+ permissionDecision: "deny",
1103
+ permissionDecisionReason:
1104
+ \`Blocked by Agent Board: this would kill every "\${violation}" process on the machine, not \` +
1105
+ "just this project's \u2014 that includes the Agent Board runner itself, every other card's " +
1106
+ "session, and other projects' dev-server previews. Target the specific PID instead (find it " +
1107
+ 'with "netstat -ano | findstr :<port>" or "Get-Process", then "taskkill /pid <pid> /t /f" or ' +
1108
+ '"Stop-Process -Id <pid> -Force").',
1109
+ },
1110
+ }));
1111
+ }
1112
+ }
1113
+ } catch {
1114
+ // A malformed/unexpected hook payload must never block a legitimate command.
1115
+ }
1116
+ process.exit(0);
1117
+ })();
1118
+ `;
1119
+ function ensureSafetyHookScriptWritten() {
1120
+ writeFileSync4(SAFETY_HOOK_SCRIPT_PATH, SCRIPT_SOURCE2, { mode: 493 });
1121
+ logger.runner.info("SAFETY-HOOK", `Process-kill safety hook written to ${SAFETY_HOOK_SCRIPT_PATH}.`);
1122
+ }
1123
+ function safetyHookSettingsFragment() {
1124
+ return {
1125
+ hooks: {
1126
+ PreToolUse: [
1127
+ {
1128
+ matcher: "Bash",
1129
+ hooks: [
1130
+ {
1131
+ type: "command",
1132
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(SAFETY_HOOK_SCRIPT_PATH)}`
1133
+ }
1134
+ ]
1135
+ }
1136
+ ]
1137
+ }
1138
+ };
1139
+ }
1140
+
1141
+ // src/statuslineScript.ts
1142
+ import { writeFileSync as writeFileSync5 } from "node:fs";
1143
+ import { tmpdir as tmpdir4 } from "node:os";
1144
+ import { join as join6 } from "node:path";
1145
+ var CLAUDE_BINARY = process.platform === "win32" ? "claude.exe" : "claude";
1146
+ var STATUSLINE_SCRIPT_PATH = join6(tmpdir4(), "agent-board-statusline.cjs");
1147
+ var SCRIPT_SOURCE3 = `#!/usr/bin/env node
1148
+ "use strict";
1149
+ const http = require("node:http");
1150
+
1151
+ function readStdin() {
1152
+ return new Promise((resolve) => {
1153
+ let raw = "";
1154
+ process.stdin.setEncoding("utf8");
1155
+ process.stdin.on("data", (chunk) => { raw += chunk; });
1156
+ process.stdin.on("end", () => resolve(raw));
1157
+ process.stdin.on("error", () => resolve(raw));
1158
+ });
1159
+ }
1160
+
584
1161
  function formatPercent(value) {
585
1162
  return typeof value === "number" ? \`\${Math.round(value)}%\` : "?";
586
1163
  }
@@ -640,19 +1217,16 @@ function reportToRunner(sessionId, ticket, port, rateLimits) {
640
1217
  })();
641
1218
  `;
642
1219
  function ensureStatuslineScriptWritten() {
643
- writeFileSync3(STATUSLINE_SCRIPT_PATH, SCRIPT_SOURCE, { mode: 493 });
1220
+ writeFileSync5(STATUSLINE_SCRIPT_PATH, SCRIPT_SOURCE3, { mode: 493 });
644
1221
  logger.runner.info("STATUSLINE", `Status line script written to ${STATUSLINE_SCRIPT_PATH}.`);
645
1222
  }
646
- function statuslineSettingsArgs() {
647
- return [
648
- "--settings",
649
- JSON.stringify({
650
- statusLine: {
651
- type: "command",
652
- command: `${JSON.stringify(process.execPath)} ${JSON.stringify(STATUSLINE_SCRIPT_PATH)}`
653
- }
654
- })
655
- ];
1223
+ function statuslineSettingsFragment() {
1224
+ return {
1225
+ statusLine: {
1226
+ type: "command",
1227
+ command: `${JSON.stringify(process.execPath)} ${JSON.stringify(STATUSLINE_SCRIPT_PATH)}`
1228
+ }
1229
+ };
656
1230
  }
657
1231
  function statuslineEnv(sessionId, ticket) {
658
1232
  return {
@@ -668,1122 +1242,1812 @@ function statuslineEnv(sessionId, ticket) {
668
1242
  };
669
1243
  }
670
1244
 
671
- // src/agentSession.ts
672
- var TUI_IDLE_MS = 1500;
673
- var TUI_READY_TIMEOUT_MS = 6e4;
674
- var SUBMIT_DELAY_MS = 150;
675
- var PASTE_START = "\x1B[200~";
676
- var PASTE_END = "\x1B[201~";
677
- var SHIFT_TAB = "\x1B[Z";
678
- var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
679
- var SHIFT_TAB_DELAY_MS = 400;
680
- var PLAN_MODE_INDICATOR = /plan mode on/i;
681
- function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
682
- const claudeSessionId = randomUUID2();
683
- const pty = spawn3(
684
- CLAUDE_BINARY,
685
- [
686
- "--dangerously-skip-permissions",
687
- "--model",
688
- "claude-sonnet-5",
689
- "--session-id",
690
- claudeSessionId,
691
- ...statuslineSettingsArgs(),
692
- `/rename ${toSessionName(cardTitle)}`
693
- ],
694
- {
695
- name: "xterm-color",
696
- cols: 120,
697
- rows: 32,
698
- cwd: repoPath,
699
- env: statuslineEnv(sessionId, ticket)
1245
+ // src/claudeCliSettings.ts
1246
+ function mergeHookFragments(...fragments) {
1247
+ const merged = {};
1248
+ for (const fragment of fragments) {
1249
+ const hooks = fragment.hooks ?? {};
1250
+ for (const [eventName, entries] of Object.entries(hooks)) {
1251
+ merged[eventName] = [...merged[eventName] ?? [], ...entries];
700
1252
  }
701
- );
702
- sendMessageWhenIdle(pty, cardId, instructions, planFirst);
703
- return { pty, claudeSessionId };
1253
+ }
1254
+ return merged;
704
1255
  }
705
- function sendMessageWhenIdle(pty, cardId, message, planFirst) {
706
- let idleTimer;
707
- let deadlineTimer;
708
- let dataSubscription;
709
- let settled = false;
710
- const stopWaiting = () => {
711
- if (settled) return false;
712
- settled = true;
713
- clearTimeout(idleTimer);
714
- clearTimeout(deadlineTimer);
715
- dataSubscription?.dispose();
716
- return true;
717
- };
718
- const send = async () => {
719
- if (!stopWaiting()) return;
720
- if (planFirst) await enterPlanMode(pty, cardId);
721
- try {
722
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
723
- setTimeout(() => {
724
- try {
725
- pty.write("\r");
726
- } catch (err) {
727
- logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
728
- }
729
- }, SUBMIT_DELAY_MS);
730
- } catch (err) {
731
- logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
732
- }
733
- };
734
- deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
735
- dataSubscription = pty.onData(() => {
736
- clearTimeout(idleTimer);
737
- idleTimer = setTimeout(send, TUI_IDLE_MS);
738
- });
739
- pty.onExit(() => stopWaiting());
1256
+ function claudeCliSettingsArgs() {
1257
+ return [
1258
+ "--settings",
1259
+ JSON.stringify({
1260
+ ...statuslineSettingsFragment(),
1261
+ hooks: mergeHookFragments(safetyHookSettingsFragment(), agentMomentHookSettingsFragment())
1262
+ })
1263
+ ];
740
1264
  }
741
- async function enterPlanMode(pty, cardId) {
742
- let recentOutput = "";
743
- const subscription = pty.onData((chunk) => {
744
- recentOutput += normalizeTerminalOutput(chunk);
745
- });
1265
+
1266
+ // src/usageMonitor.ts
1267
+ var SESSION_ID = "usage-monitor";
1268
+ var TICKET = randomUUID4();
1269
+ var SCRATCH_DIR = join7(tmpdir5(), "agent-board-usage-monitor");
1270
+ var POLL_INTERVAL_MS = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
1271
+ var IDLE_MS = 2e3;
1272
+ var HARD_TIMEOUT_MS = 6e4;
1273
+ function usageMonitorSessionId() {
1274
+ return SESSION_ID;
1275
+ }
1276
+ function usageMonitorTicket() {
1277
+ return TICKET;
1278
+ }
1279
+ function ensureScratchDirTrusted() {
1280
+ const claudeJsonPath = join7(homedir3(), ".claude.json");
1281
+ if (!existsSync3(claudeJsonPath)) return;
746
1282
  try {
747
- for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
748
- pty.write(SHIFT_TAB);
749
- await new Promise((resolve5) => setTimeout(resolve5, SHIFT_TAB_DELAY_MS));
750
- }
751
- if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
752
- logger.card.warn(
753
- cardId,
754
- `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.`
755
- );
1283
+ const raw = readFileSync3(claudeJsonPath, "utf8");
1284
+ const data = JSON.parse(raw);
1285
+ data.projects ??= {};
1286
+ const keys = [SCRATCH_DIR, SCRATCH_DIR.replace(/\\/g, "/")];
1287
+ const alreadyTrusted = keys.every((key) => data.projects[key]?.hasTrustDialogAccepted === true);
1288
+ if (alreadyTrusted) return;
1289
+ for (const key of keys) {
1290
+ data.projects[key] = { ...data.projects[key], hasTrustDialogAccepted: true };
756
1291
  }
1292
+ writeFileSync6(claudeJsonPath, JSON.stringify(data, null, 2));
1293
+ logger.runner.info("USAGE_MONITOR", `Pre-trusted scratch directory ${SCRATCH_DIR} in ~/.claude.json.`);
757
1294
  } catch (err) {
758
- logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
759
- } finally {
760
- subscription.dispose();
1295
+ logger.runner.error("USAGE_MONITOR", "Failed to pre-trust the usage-monitor scratch directory:", err);
761
1296
  }
762
1297
  }
763
- function sendContinue(pty, cardId) {
1298
+ function pollUsageOnce() {
1299
+ let pty;
764
1300
  try {
765
- pty.write(`${PASTE_START}continue${PASTE_END}`);
766
- setTimeout(() => {
767
- try {
768
- pty.write("\r");
769
- } catch (err) {
770
- logger.card.error(cardId, 'Failed to submit the auto-resume "continue" message:', err);
1301
+ pty = spawn2(
1302
+ CLAUDE_BINARY,
1303
+ [
1304
+ "--dangerously-skip-permissions",
1305
+ "--model",
1306
+ "claude-haiku-4-5",
1307
+ // cheapest model — this exchange exists only to trigger a statusLine update
1308
+ ...claudeCliSettingsArgs(),
1309
+ "reply with just the word: ok"
1310
+ ],
1311
+ {
1312
+ name: "xterm-color",
1313
+ cols: 80,
1314
+ rows: 24,
1315
+ cwd: SCRATCH_DIR,
1316
+ env: statuslineEnv(SESSION_ID, TICKET)
771
1317
  }
772
- }, SUBMIT_DELAY_MS);
1318
+ );
773
1319
  } catch (err) {
774
- logger.card.error(cardId, 'Failed to send the auto-resume "continue" message:', err);
1320
+ logger.runner.error("USAGE_MONITOR", "Failed to spawn the usage-poll session:", err);
1321
+ return;
775
1322
  }
776
- }
777
- function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
778
- return spawn3(
779
- CLAUDE_BINARY,
780
- [
781
- "--dangerously-skip-permissions",
782
- "--model",
783
- "claude-sonnet-5",
784
- "--resume",
785
- claudeSessionId,
786
- ...statuslineSettingsArgs()
787
- ],
788
- {
789
- name: "xterm-color",
790
- cols: 120,
791
- rows: 32,
792
- cwd: repoPath,
793
- env: statuslineEnv(sessionId, ticket)
1323
+ let settled = false;
1324
+ let idleTimer;
1325
+ const hardTimer = setTimeout(finish, HARD_TIMEOUT_MS);
1326
+ function finish() {
1327
+ if (settled) return;
1328
+ settled = true;
1329
+ clearTimeout(idleTimer);
1330
+ clearTimeout(hardTimer);
1331
+ try {
1332
+ pty.kill();
1333
+ } catch {
794
1334
  }
795
- );
1335
+ }
1336
+ pty.onData(() => {
1337
+ clearTimeout(idleTimer);
1338
+ idleTimer = setTimeout(finish, IDLE_MS);
1339
+ });
1340
+ pty.onExit(() => finish());
1341
+ }
1342
+ function startUsageMonitor() {
1343
+ mkdirSync3(SCRATCH_DIR, { recursive: true });
1344
+ ensureScratchDirTrusted();
1345
+ pollUsageOnce();
1346
+ setInterval(pollUsageOnce, POLL_INTERVAL_MS);
796
1347
  }
797
1348
 
798
- // ../../packages/shared-types/dist/index.js
799
- var CARD_IMAGES_BUCKET = "agent-board-card-images";
800
- var MAX_CARD_IMAGE_BYTES = 10 * 1024 * 1024;
801
-
802
- // src/cardImages.ts
803
- import { mkdir, writeFile } from "node:fs/promises";
804
- import { tmpdir as tmpdir2 } from "node:os";
805
- import { extname, join as join5 } from "node:path";
806
- var IMAGES_ROOT = join5(tmpdir2(), "agent-board", "card-images");
807
- function sanitizeFileName(fileName) {
808
- return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
1349
+ // src/filesServer.ts
1350
+ var execFileAsync2 = promisify2(execFile2);
1351
+ var CLAUDE_BYPASS_COMMAND = "claude --dangerously-skip-permissions";
1352
+ var FILES_TICKET_TTL_MS = 15 * 60 * 1e3;
1353
+ var FILES_TICKET_REFRESH_MARGIN_MS = FILES_TICKET_TTL_MS / 3;
1354
+ var MAX_FILE_SIZE_BYTES = 1e6;
1355
+ var MAX_LOGO_SIZE_BYTES = 5e6;
1356
+ var IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", ".claude", "node_modules", "dist", "build"]);
1357
+ var AGENT_SAFE_SUBDIR_NAMES = /* @__PURE__ */ new Set(["skills", "commands", "agents", "rules"]);
1358
+ function ensureSkillsDirExists(claudeDir) {
1359
+ try {
1360
+ mkdirSync4(join8(claudeDir, "skills"), { recursive: true });
1361
+ } catch (err) {
1362
+ logger.runner.warn("FILES", "Could not create .claude/skills:", err);
1363
+ }
809
1364
  }
810
- async function downloadCardImages(supabase, cardId) {
811
- const { data, error } = await supabase.from("card_images").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
812
- if (error) {
813
- logger.card.error(cardId, "Failed to list images:", error.message);
814
- return [];
1365
+ var BRAND_ICON_BASE_NAMES = ["logo", "favicon"];
1366
+ var MIME_TYPES_BY_EXT = {
1367
+ svg: "image/svg+xml",
1368
+ png: "image/png",
1369
+ jpg: "image/jpeg",
1370
+ jpeg: "image/jpeg",
1371
+ webp: "image/webp",
1372
+ gif: "image/gif",
1373
+ ico: "image/x-icon"
1374
+ };
1375
+ var ticketsByProject = /* @__PURE__ */ new Map();
1376
+ var setupTicket = null;
1377
+ var agentTicket = null;
1378
+ var statuslineSupabase = null;
1379
+ var statuslineConfig = null;
1380
+ var MAX_STATUSLINE_BODY_BYTES = 16e3;
1381
+ var HttpError = class extends Error {
1382
+ constructor(status, message) {
1383
+ super(message);
1384
+ this.status = status;
815
1385
  }
816
- const rows = data ?? [];
817
- if (rows.length === 0) return [];
818
- const targetDir = join5(IMAGES_ROOT, cardId);
819
- await mkdir(targetDir, { recursive: true });
820
- const paths = [];
821
- for (const row of rows) {
822
- const baseName = sanitizeFileName(row.file_name);
823
- const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
824
- const localPath = join5(targetDir, `${row.id}-${fileName}`);
1386
+ };
1387
+ async function ensureFilesTickets(supabase, config) {
1388
+ for (const project of config.registeredProjects) {
1389
+ if (!project.isReferenceRunner) continue;
1390
+ const existing = ticketsByProject.get(project.id);
1391
+ if (existing && existing.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) continue;
1392
+ const ticket = randomUUID5();
1393
+ const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
1394
+ ticketsByProject.set(project.id, { ticket, expiresAt: expiresAtMs, repoRoot: resolve2(project.repoPath) });
825
1395
  try {
826
- const { data: blob, error: downloadError } = await supabase.storage.from(CARD_IMAGES_BUCKET).download(row.storage_path);
827
- if (downloadError) throw downloadError;
828
- await writeFile(localPath, Buffer.from(await blob.arrayBuffer()));
829
- paths.push(localPath);
1396
+ await updateProjectFilesTicket(supabase, project.id, ticket, new Date(expiresAtMs).toISOString());
830
1397
  } catch (err) {
831
- logger.card.error(cardId, `Failed to download image ${row.storage_path}:`, err);
1398
+ logger.runner.error("FILES", `Failed to publish files ticket for project ${project.id}:`, err);
832
1399
  }
833
1400
  }
834
- return paths;
835
- }
836
- function buildInstructionsWithImages(instructions, imagePaths) {
837
- if (imagePaths.length === 0) return instructions;
838
- const list = imagePaths.map((path) => `- ${path}`).join("\n");
839
- return `${instructions}
840
-
841
- Images attached to this card (read each one before starting; they are part of the task):
842
- ${list}`;
843
- }
844
-
845
- // src/fileReferences.ts
846
- function buildInstructionsWithFileReferences(instructions, fileReferences) {
847
- if (fileReferences.length === 0) return instructions;
848
- const list = fileReferences.map((path) => `- ${path}`).join("\n");
849
- return `${instructions}
850
-
851
- Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
852
- ${list}`;
853
- }
854
-
855
- // src/ptyRegistry.ts
856
- var MAX_BUFFERED_CHUNKS = 2e3;
857
- var registry = /* @__PURE__ */ new Map();
858
- function registerPtySession(sessionId, pty, ticket) {
859
- const handle = { pty, ticket, outputBuffer: [], sockets: /* @__PURE__ */ new Set() };
860
- pty.onData((data) => {
861
- handle.outputBuffer.push(data);
862
- if (handle.outputBuffer.length > MAX_BUFFERED_CHUNKS) handle.outputBuffer.shift();
863
- for (const socket of handle.sockets) {
864
- if (socket.readyState === socket.OPEN) socket.send(data);
865
- }
866
- });
867
- registry.set(sessionId, handle);
868
- }
869
- function getPtySession(sessionId) {
870
- return registry.get(sessionId);
871
- }
872
- function resizePtySession(sessionId, cols, rows) {
873
- const handle = registry.get(sessionId);
874
- if (!handle) return;
875
- handle.pty.resize(cols, rows);
876
1401
  }
877
- function stopPtySession(sessionId) {
878
- const handle = registry.get(sessionId);
879
- if (!handle) return;
880
- for (const socket of handle.sockets) socket.close(4e3, "Session ended");
881
- handle.pty.kill();
882
- registry.delete(sessionId);
1402
+ function authenticate(query) {
1403
+ const projectId = typeof query.projectId === "string" ? query.projectId : null;
1404
+ const ticket = typeof query.ticket === "string" ? query.ticket : null;
1405
+ if (!projectId || !ticket) return null;
1406
+ const entry = ticketsByProject.get(projectId);
1407
+ if (!entry || entry.ticket !== ticket || entry.expiresAt < Date.now()) return null;
1408
+ return entry;
883
1409
  }
884
-
885
- // src/activityLog.ts
886
- async function logActivity(supabase, params) {
1410
+ async function ensureSetupTicket(supabase, config) {
1411
+ if (!config.allowedRootPath) return;
1412
+ if (setupTicket && setupTicket.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) return;
1413
+ const ticket = randomUUID5();
1414
+ const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
1415
+ setupTicket = { ticket, expiresAt: expiresAtMs, rootPath: resolve2(config.allowedRootPath) };
887
1416
  try {
888
- const { error } = await supabase.from("activity_log").insert({
889
- project_id: params.projectId,
890
- card_id: params.cardId,
891
- user_id: params.userId,
892
- event_type: params.eventType,
893
- payload: params.payload ?? {}
894
- });
895
- if (error) throw error;
1417
+ await updateRunnerSetupTicket(supabase, config.runnerId, ticket, new Date(expiresAtMs).toISOString(), config.allowedRootPath);
896
1418
  } catch (err) {
897
- logger.runner.error("ACTIVITY", `Failed to record activity log entry (${params.eventType}):`, err);
1419
+ logger.runner.error("FILES", `Failed to publish setup ticket for runner ${config.runnerId}:`, err);
898
1420
  }
899
1421
  }
900
-
901
- // src/sessions.ts
902
- async function countRunningSessions(supabase, projectId, ownerUserId) {
903
- 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");
904
- if (error) throw error;
905
- return count ?? 0;
906
- }
907
- async function listQueuedSessionsForRunner(supabase, ownerUserId) {
908
- const { data, error } = await supabase.from("sessions").select(
909
- "id, card_id, claude_session_id, created_at, cards!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
910
- ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
911
- if (error) throw error;
912
- return data;
913
- }
914
- async function createSession(supabase, userId, cardId) {
915
- const { data, error } = await supabase.from("sessions").insert({ card_id: cardId, user_id: userId, status: "queued" }).select("id").single();
916
- if (error) throw error;
917
- return data.id;
1422
+ function authenticateSetup(query) {
1423
+ const ticket = typeof query.ticket === "string" ? query.ticket : null;
1424
+ if (!ticket || !setupTicket) return null;
1425
+ if (setupTicket.ticket !== ticket || setupTicket.expiresAt < Date.now()) return null;
1426
+ return setupTicket;
918
1427
  }
919
- async function updateSession(supabase, sessionId, changes, activityContext) {
920
- const { error } = await supabase.from("sessions").update({
921
- ...changes.status !== void 0 ? { status: changes.status } : {},
922
- ...changes.agentState !== void 0 ? { agent_state: changes.agentState } : {},
923
- ...changes.startedAt !== void 0 ? { started_at: changes.startedAt } : {},
924
- ...changes.endedAt !== void 0 ? { ended_at: changes.endedAt } : {},
925
- ...changes.terminalTicket !== void 0 ? { terminal_ticket: changes.terminalTicket } : {},
926
- ...changes.terminalTicketExpiresAt !== void 0 ? { terminal_ticket_expires_at: changes.terminalTicketExpiresAt } : {},
927
- ...changes.claudeSessionId !== void 0 ? { claude_session_id: changes.claudeSessionId } : {}
928
- }).eq("id", sessionId);
929
- if (error) throw error;
930
- if (changes.status !== void 0 && activityContext) {
931
- await logActivity(supabase, {
932
- projectId: activityContext.projectId,
933
- cardId: activityContext.cardId,
934
- userId: activityContext.userId,
935
- eventType: "session_status_changed",
936
- payload: { status: changes.status }
937
- });
1428
+ async function ensureAgentTicket(supabase, config) {
1429
+ if (agentTicket && agentTicket.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) return;
1430
+ const ticket = randomUUID5();
1431
+ const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
1432
+ agentTicket = { ticket, expiresAt: expiresAtMs, repoRoot: resolve2(homedir4(), ".claude") };
1433
+ try {
1434
+ await updateRunnerAgentTicket(supabase, config.runnerId, ticket, new Date(expiresAtMs).toISOString());
1435
+ } catch (err) {
1436
+ logger.runner.error("FILES", `Failed to publish agent ticket for runner ${config.runnerId}:`, err);
938
1437
  }
939
1438
  }
940
- async function getActiveSessionForCard(supabase, cardId) {
941
- 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();
942
- if (error) throw error;
943
- return data;
944
- }
945
- async function updateCard(supabase, cardId, changes) {
946
- const { error } = await supabase.from("cards").update({
947
- ...changes.column !== void 0 ? { column_name: changes.column } : {},
948
- ...changes.previewUrls !== void 0 ? { preview_urls: changes.previewUrls } : {},
949
- ...changes.deployError !== void 0 ? { deploy_error: changes.deployError } : {}
950
- }).eq("id", cardId);
951
- if (error) throw error;
952
- }
953
- async function getProject(supabase, projectId) {
954
- const { data, error } = await supabase.from("projects").select("*").eq("id", projectId).single();
955
- if (error) throw error;
956
- return data;
957
- }
958
- async function getProjectRepos(supabase, projectId) {
959
- const { data, error } = await supabase.from("project_repos").select("subpath, dev_command").eq("project_id", projectId);
960
- if (error) throw error;
961
- return data;
962
- }
963
- async function listCardsInColumn(supabase, projectId, column) {
964
- 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);
965
- if (error) throw error;
966
- return data;
967
- }
968
- async function clearProjectDeployRequest(supabase, projectId) {
969
- const { error } = await supabase.from("projects").update({ deploy_requested_at: null }).eq("id", projectId);
970
- if (error) throw error;
971
- }
972
- async function clearDevServerRestartRequest(supabase, projectId) {
973
- const { error } = await supabase.from("projects").update({ dev_server_restart_requested_at: null }).eq("id", projectId);
974
- if (error) throw error;
975
- }
976
- async function updateProjectFilesTicket(supabase, projectId, ticket, expiresAt) {
977
- const { error } = await supabase.from("projects").update({ files_ticket: ticket, files_ticket_expires_at: expiresAt }).eq("id", projectId);
978
- if (error) throw error;
979
- }
980
- async function updateRunnerSetupTicket(supabase, runnerId, ticket, expiresAt, allowedRootPath) {
981
- const { error } = await supabase.from("runners").update({ setup_ticket: ticket, setup_ticket_expires_at: expiresAt, allowed_root_path: allowedRootPath }).eq("id", runnerId);
982
- if (error) throw error;
983
- }
984
- async function listMyProjects(supabase) {
985
- const { data, error } = await supabase.from("projects").select("id, runner_id, repo_path, logo_path");
986
- if (error) throw error;
987
- return data;
988
- }
989
- async function listProjectRunnerIds(supabase, runnerId) {
990
- const { data, error } = await supabase.from("project_runners").select("project_id").eq("runner_id", runnerId);
991
- if (error) throw error;
992
- return new Set(data.map((row) => row.project_id));
1439
+ function authenticateAgent(query) {
1440
+ const ticket = typeof query.ticket === "string" ? query.ticket : null;
1441
+ if (!ticket || !agentTicket) return null;
1442
+ if (agentTicket.ticket !== ticket || agentTicket.expiresAt < Date.now()) return null;
1443
+ return agentTicket;
993
1444
  }
994
- async function addProjectRunner(supabase, projectId, runnerId) {
995
- const { error } = await supabase.from("project_runners").insert({ project_id: projectId, runner_id: runnerId });
996
- if (error) throw error;
1445
+ function resolveWithinRepo(repoRoot, relPath) {
1446
+ const normalizedRel = relPath.replace(/^[/\\]+/, "");
1447
+ const resolved = resolve2(repoRoot, normalizedRel);
1448
+ const rootWithSep = repoRoot.endsWith(sep) ? repoRoot : repoRoot + sep;
1449
+ if (resolved !== repoRoot && !resolved.startsWith(rootWithSep)) {
1450
+ throw new HttpError(403, "Caminho fora do reposit\xF3rio.");
1451
+ }
1452
+ return resolved;
997
1453
  }
998
- async function getRunnerMaxConcurrentProjects(supabase, runnerId) {
999
- const { data, error } = await supabase.from("runners").select("max_concurrent_projects").eq("id", runnerId).single();
1000
- if (error) throw error;
1001
- return data.max_concurrent_projects;
1454
+ function listDirectory(repoRoot, relPath, options) {
1455
+ const target = resolveWithinRepo(repoRoot, relPath);
1456
+ let stat;
1457
+ try {
1458
+ stat = lstatSync(target);
1459
+ } catch {
1460
+ throw new HttpError(404, "Diret\xF3rio n\xE3o encontrado.");
1461
+ }
1462
+ if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1463
+ if (!stat.isDirectory()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um diret\xF3rio.");
1464
+ const prefix = relPath.replace(/\/+$/, "");
1465
+ return readdirSync(target, { withFileTypes: true }).filter(
1466
+ (dirent) => !dirent.isSymbolicLink() && (!IGNORED_ENTRY_NAMES.has(dirent.name) || options?.includeDotClaude && dirent.name === ".claude")
1467
+ ).map((dirent) => ({
1468
+ name: dirent.name,
1469
+ path: prefix ? `${prefix}/${dirent.name}` : dirent.name,
1470
+ type: dirent.isDirectory() ? "dir" : "file"
1471
+ })).sort((a, b) => a.type !== b.type ? a.type === "dir" ? -1 : 1 : a.name.localeCompare(b.name));
1002
1472
  }
1003
- async function updateProjectLogoPath(supabase, projectId, logoPath) {
1004
- const { error } = await supabase.from("projects").update({ logo_path: logoPath }).eq("id", projectId);
1005
- if (error) throw error;
1473
+ function listSafeAgentDirectory(baseDir, relPath, atClaudeRoot) {
1474
+ const segments = relPath === "" ? [] : relPath.split("/");
1475
+ if (atClaudeRoot) {
1476
+ if (segments.length === 0) {
1477
+ ensureSkillsDirExists(baseDir);
1478
+ const entries2 = listDirectory(baseDir, relPath, { includeDotClaude: true });
1479
+ return entries2.filter(
1480
+ (entry) => entry.type === "file" && entry.name === "CLAUDE.md" || entry.type === "dir" && AGENT_SAFE_SUBDIR_NAMES.has(entry.name)
1481
+ );
1482
+ }
1483
+ return listDirectory(baseDir, relPath, { includeDotClaude: true });
1484
+ }
1485
+ const isRepoRootLevel = segments.length === 0 || segments.length === 1 && segments[0] !== ".claude";
1486
+ if (isRepoRootLevel) {
1487
+ ensureSkillsDirExists(join8(baseDir, relPath, ".claude"));
1488
+ const entries2 = listDirectory(baseDir, relPath, { includeDotClaude: true });
1489
+ return entries2.filter((entry) => entry.type === "file" && entry.name === "CLAUDE.md" || entry.type === "dir" && entry.name === ".claude");
1490
+ }
1491
+ const entries = listDirectory(baseDir, relPath, { includeDotClaude: true });
1492
+ if (segments[segments.length - 1] === ".claude") {
1493
+ return entries.filter((entry) => entry.type === "dir" && AGENT_SAFE_SUBDIR_NAMES.has(entry.name));
1494
+ }
1495
+ return entries;
1006
1496
  }
1007
- async function getClaudeUsage(supabase, userId) {
1008
- const { data, error } = await supabase.from("claude_usage").select("session_resets_at, week_resets_at").eq("user_id", userId).maybeSingle();
1009
- if (error) throw error;
1010
- if (!data) return null;
1011
- return { sessionResetsAt: data.session_resets_at, weekResetsAt: data.week_resets_at };
1497
+ function assertSafeAgentPath(relPath, atClaudeRoot) {
1498
+ const segments = relPath.split("/");
1499
+ function matches(rest2) {
1500
+ if (rest2.length === 1 && rest2[0] === "CLAUDE.md") return true;
1501
+ if (atClaudeRoot) return rest2.length >= 2 && AGENT_SAFE_SUBDIR_NAMES.has(rest2[0]);
1502
+ return rest2.length >= 3 && rest2[0] === ".claude" && AGENT_SAFE_SUBDIR_NAMES.has(rest2[1]);
1503
+ }
1504
+ if (matches(segments)) return;
1505
+ if (!atClaudeRoot && segments.length > 1 && matches(segments.slice(1))) return;
1506
+ throw new HttpError(403, "Caminho fora da \xE1rea permitida para o Agente.");
1012
1507
  }
1013
- async function upsertClaudeUsage(supabase, userId, changes) {
1014
- const { error } = await supabase.from("claude_usage").upsert({
1015
- user_id: userId,
1016
- ...changes.sessionPercent !== void 0 ? { session_pct: changes.sessionPercent } : {},
1017
- ...changes.sessionResetsAt !== void 0 ? { session_resets_at: changes.sessionResetsAt } : {},
1018
- ...changes.weekPercent !== void 0 ? { week_pct: changes.weekPercent } : {},
1019
- ...changes.weekResetsAt !== void 0 ? { week_resets_at: changes.weekResetsAt } : {},
1020
- updated_at: (/* @__PURE__ */ new Date()).toISOString()
1021
- });
1022
- if (error) throw error;
1508
+ function assertWritableAgentPath(relPath, atClaudeRoot) {
1509
+ assertSafeAgentPath(relPath, atClaudeRoot);
1510
+ const segments = relPath.split("/");
1511
+ if (segments[segments.length - 1] !== "CLAUDE.md") {
1512
+ throw new HttpError(403, "Apenas CLAUDE.md pode ser criado ou editado por aqui.");
1513
+ }
1023
1514
  }
1024
-
1025
- // src/usageResume.ts
1026
- var RESET_BUFFER_MS = 3e4;
1027
- var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
1028
- var pending = /* @__PURE__ */ new Map();
1029
- function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
1030
- if (pending.has(sessionId)) return;
1031
- void (async () => {
1032
- const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
1033
- logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
1034
- return null;
1035
- });
1036
- const now = Date.now();
1037
- const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
1038
- const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
1039
- const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
1040
- logger.card.info(
1041
- cardId,
1042
- 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.`
1043
- );
1044
- const timer = setTimeout(() => {
1045
- pending.delete(sessionId);
1046
- sendContinue(pty, cardId);
1047
- }, delay);
1048
- timer.unref();
1049
- pending.set(sessionId, timer);
1050
- })();
1515
+ function listDirectoryForBrowse(rootPath, relPath) {
1516
+ return listDirectory(rootPath, relPath).map((entry) => ({
1517
+ ...entry,
1518
+ hasGit: entry.type === "dir" && existsSync4(join8(rootPath, entry.path, ".git"))
1519
+ }));
1051
1520
  }
1052
- function cancelUsageResume(sessionId) {
1053
- const timer = pending.get(sessionId);
1054
- if (!timer) return;
1055
- clearTimeout(timer);
1056
- pending.delete(sessionId);
1521
+ var SAFE_CLONE_URL_PATTERN = /^(https?:\/\/|git:\/\/|ssh:\/\/|git@[\w.-]+:)\S+$/i;
1522
+ function isSafeGitUrl(url) {
1523
+ return url.length > 0 && !/\s/.test(url) && SAFE_CLONE_URL_PATTERN.test(url);
1057
1524
  }
1058
-
1059
- // src/orchestrator.ts
1060
- var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
1061
- var inFlightCardIds = /* @__PURE__ */ new Set();
1062
- var activeSessionByCard = /* @__PURE__ */ new Map();
1063
- var dispatchingRunner = false;
1064
- async function scanForWork(supabase, config) {
1065
- for (const project of config.registeredProjects) {
1066
- await reconcileStaleSessions(supabase, config, project.id);
1067
- const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
1068
- if (error) {
1069
- logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
1070
- continue;
1071
- }
1072
- for (const card of cards ?? []) {
1073
- void enqueueCard(supabase, config, card);
1074
- }
1075
- }
1076
- void dispatchRunnerQueue(supabase, config);
1525
+ function deriveRepoFolderName(url) {
1526
+ const lastSegment = url.replace(/\/+$/, "").split(/[/:]/).pop() ?? "";
1527
+ const name = lastSegment.replace(/\.git$/i, "");
1528
+ if (!/^[\w.-]+$/.test(name) || name === "." || name === "..") return null;
1529
+ return name;
1077
1530
  }
1078
- async function enqueueCard(supabase, config, card) {
1079
- if (inFlightCardIds.has(card.id)) return;
1080
- const registered = config.registeredProjects.find((project) => project.id === card.project_id);
1081
- if (!registered) {
1082
- logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
1531
+ async function handleCloneRequest(req, res, query) {
1532
+ const setupEntry = authenticateSetup(query);
1533
+ if (!setupEntry) {
1534
+ sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou navega\xE7\xE3o de pastas desativada." });
1083
1535
  return;
1084
1536
  }
1085
- if (card.assigned_user_id !== config.ownerUserId) return;
1086
- inFlightCardIds.add(card.id);
1537
+ let body;
1087
1538
  try {
1088
- const active = await getActiveSessionForCard(supabase, card.id);
1089
- if (active) return;
1090
- await createQueuedSession(supabase, config, card);
1091
- } finally {
1092
- inFlightCardIds.delete(card.id);
1539
+ const raw = await readRequestBody(req, 2e3);
1540
+ body = JSON.parse(raw);
1541
+ } catch (err) {
1542
+ const status = err instanceof HttpError ? err.status : 400;
1543
+ sendJson(res, status, { error: "Corpo inv\xE1lido." });
1544
+ return;
1093
1545
  }
1094
- await dispatchRunnerQueue(supabase, config);
1095
- }
1096
- async function createQueuedSession(supabase, config, card) {
1097
- const sessionId = await createSession(supabase, config.ownerUserId, card.id);
1098
- logger.card.info(card.id, `"${card.title}" queued (session ${sessionId}).`);
1099
- }
1100
- async function reconcileStaleSessions(supabase, config, projectId) {
1101
- 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");
1102
- if (error) {
1103
- logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
1546
+ const url = typeof body.url === "string" ? body.url.trim() : "";
1547
+ if (!isSafeGitUrl(url)) {
1548
+ sendJson(res, 400, { error: "URL de git inv\xE1lida. Use https://, ssh:// ou o formato git@host:caminho." });
1104
1549
  return;
1105
1550
  }
1106
- const stale = data ?? [];
1107
- if (stale.length === 0) return;
1108
- for (const { id: sessionId, card_id: cardId } of stale) {
1109
- stopPtySession(sessionId);
1110
- cancelUsageResume(sessionId);
1111
- activeSessionByCard.delete(cardId);
1112
- await updateSession(
1113
- supabase,
1114
- sessionId,
1115
- { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1116
- { projectId, cardId, userId: config.ownerUserId }
1117
- );
1118
- logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
1551
+ const folderName = deriveRepoFolderName(url);
1552
+ if (!folderName) {
1553
+ sendJson(res, 400, { error: "N\xE3o foi poss\xEDvel determinar o nome da pasta a partir da URL." });
1554
+ return;
1555
+ }
1556
+ const destPath = join8(setupEntry.rootPath, folderName);
1557
+ if (existsSync4(destPath)) {
1558
+ sendJson(res, 409, { error: `J\xE1 existe uma pasta "${folderName}" na raiz permitida.` });
1559
+ return;
1119
1560
  }
1120
- await dispatchRunnerQueue(supabase, config);
1121
- }
1122
- async function dispatchRunnerQueue(supabase, config) {
1123
- if (dispatchingRunner) return;
1124
- dispatchingRunner = true;
1125
1561
  try {
1126
- const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
1127
- if (queued.length === 0) return;
1128
- const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
1129
- const runningProjectIds = /* @__PURE__ */ new Set();
1130
- for (const project of config.registeredProjects) {
1131
- const runningCount = await countRunningSessions(supabase, project.id, config.ownerUserId);
1132
- if (runningCount > 0) runningProjectIds.add(project.id);
1562
+ await execFileAsync2("git", ["clone", "--", url, destPath], { timeout: 9e4 });
1563
+ sendJson(res, 200, { name: folderName, path: folderName, type: "dir", hasGit: true });
1564
+ } catch (err) {
1565
+ try {
1566
+ rmSync2(destPath, { recursive: true, force: true });
1567
+ } catch {
1133
1568
  }
1134
- let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
1135
- if (freeProjectSlots <= 0) return;
1136
- const promotedThisPass = /* @__PURE__ */ new Set();
1137
- for (const entry of queued) {
1138
- const projectId = entry.cards.project_id;
1139
- if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
1140
- if (freeProjectSlots <= 0) break;
1141
- try {
1142
- await promoteQueuedSession(supabase, config, entry);
1143
- promotedThisPass.add(projectId);
1144
- freeProjectSlots -= 1;
1145
- } catch (err) {
1146
- logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
1147
- }
1148
- }
1149
- } finally {
1150
- dispatchingRunner = false;
1569
+ logger.runner.error("FILES", `git clone failed for ${url}:`, err);
1570
+ const stderr = err && typeof err === "object" && "stderr" in err ? String(err.stderr ?? "").trim() : "";
1571
+ const timedOut = err && typeof err === "object" && "killed" in err && err.killed;
1572
+ sendJson(res, 502, {
1573
+ error: timedOut ? "O clone excedeu o tempo limite do t\xFAnel (reposit\xF3rio muito grande?)." : stderr || "Falha ao clonar o reposit\xF3rio."
1574
+ });
1151
1575
  }
1152
1576
  }
1153
- async function promoteQueuedSession(supabase, config, entry) {
1154
- const spawned = await spawnSessionPty(supabase, config, entry);
1155
- if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
1577
+ function looksBinary(buffer) {
1578
+ const sample = buffer.subarray(0, Math.min(buffer.length, 8e3));
1579
+ return sample.includes(0);
1156
1580
  }
1157
- async function spawnSessionPty(supabase, config, entry) {
1158
- const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
1159
- const registered = config.registeredProjects.find((project) => project.id === card.project_id);
1160
- if (!registered) {
1161
- logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
1162
- await updateSession(
1163
- supabase,
1164
- sessionId,
1165
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1166
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
1167
- );
1168
- return false;
1169
- }
1170
- const ticket = randomUUID3();
1171
- let pty;
1172
- let newClaudeSessionId = null;
1581
+ function readTextFile(repoRoot, relPath) {
1582
+ const target = resolveWithinRepo(repoRoot, relPath);
1583
+ let stat;
1173
1584
  try {
1174
- if (claudeSessionId) {
1175
- pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
1176
- } else {
1177
- const imagePaths = await downloadCardImages(supabase, cardId);
1178
- const instructions = buildInstructionsWithFileReferences(
1179
- buildInstructionsWithImages(card.instructions, imagePaths),
1180
- card.file_references
1181
- );
1182
- const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
1183
- pty = spawned.pty;
1184
- newClaudeSessionId = spawned.claudeSessionId;
1185
- }
1186
- } catch (err) {
1187
- logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
1188
- await updateSession(
1189
- supabase,
1190
- sessionId,
1191
- { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1192
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
1193
- );
1194
- return false;
1585
+ stat = lstatSync(target);
1586
+ } catch {
1587
+ throw new HttpError(404, "Arquivo n\xE3o encontrado.");
1195
1588
  }
1196
- registerPtySession(sessionId, pty, ticket);
1197
- activeSessionByCard.set(cardId, sessionId);
1198
- const activityWatcher = watchAgentActivity(supabase, config, cardId, sessionId, pty);
1199
- await updateSession(
1200
- supabase,
1201
- sessionId,
1202
- {
1203
- status: "running",
1204
- startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1205
- terminalTicket: ticket,
1206
- terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
1207
- // Only stamp a fresh spawn as "implementing" — a resumed session (claudeSessionId was
1208
- // already set) keeps whatever agent_state it had before the restart (e.g.
1209
- // "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
1210
- // freshly (re)constructed AgentActivityWatcher below will correct it the moment real
1211
- // output gives it something conclusive to classify.
1212
- ...newClaudeSessionId ? { agentState: "implementing", claudeSessionId: newClaudeSessionId } : {}
1213
- },
1214
- { projectId: card.project_id, cardId, userId: config.ownerUserId }
1215
- );
1216
- pty.onExit(({ exitCode }) => {
1217
- 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.`);
1218
- activityWatcher.stop();
1219
- cancelUsageResume(sessionId);
1220
- activeSessionByCard.delete(cardId);
1221
- });
1222
- return true;
1589
+ if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1590
+ if (!stat.isFile()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um arquivo.");
1591
+ if (stat.size > MAX_FILE_SIZE_BYTES) {
1592
+ throw new HttpError(413, `Arquivo maior que o limite de ${MAX_FILE_SIZE_BYTES / 1e6}MB para visualiza\xE7\xE3o.`);
1593
+ }
1594
+ const buffer = readFileSync4(target);
1595
+ if (looksBinary(buffer)) throw new HttpError(415, "Arquivo bin\xE1rio n\xE3o pode ser exibido.");
1596
+ return { content: buffer.toString("utf8"), size: stat.size };
1223
1597
  }
1224
- function watchAgentActivity(supabase, config, cardId, sessionId, pty) {
1225
- const watcher = new AgentActivityWatcher((state) => {
1226
- updateSession(supabase, sessionId, { agentState: state }).catch(
1227
- (err) => logger.card.error(cardId, `Failed to publish agent state "${state}" for session ${sessionId}:`, err)
1228
- );
1229
- if (state === "usage_limited") {
1230
- scheduleUsageResume(supabase, config, cardId, sessionId, pty);
1231
- } else {
1232
- cancelUsageResume(sessionId);
1233
- }
1234
- });
1235
- pty.onData((chunk) => watcher.push(chunk));
1236
- return watcher;
1598
+ function writeTextFile(repoRoot, relPath, content) {
1599
+ if (Buffer.byteLength(content, "utf8") > MAX_FILE_SIZE_BYTES) {
1600
+ throw new HttpError(413, `Conte\xFAdo maior que o limite de ${MAX_FILE_SIZE_BYTES / 1e6}MB.`);
1601
+ }
1602
+ const target = resolveWithinRepo(repoRoot, relPath);
1603
+ writeFileSync7(target, content, "utf8");
1237
1604
  }
1238
- async function reconcileOrphanedSessions(supabase, config) {
1239
- for (const project of config.registeredProjects) {
1240
- 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");
1241
- if (error) {
1242
- logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
1243
- continue;
1605
+ function findBrandIconPath(repoRoot) {
1606
+ const patterns = BRAND_ICON_BASE_NAMES.map((baseName) => ({
1607
+ baseName,
1608
+ pattern: new RegExp(`^${baseName}\\.[a-z0-9]+$`, "i")
1609
+ }));
1610
+ const candidatesByBaseName = new Map(
1611
+ BRAND_ICON_BASE_NAMES.map((baseName) => [baseName, []])
1612
+ );
1613
+ function walk(dir, relDir, depth) {
1614
+ let entries;
1615
+ try {
1616
+ entries = readdirSync(dir, { withFileTypes: true });
1617
+ } catch {
1618
+ return;
1244
1619
  }
1245
- for (const entry of data ?? []) {
1246
- let spawned;
1247
- try {
1248
- spawned = await spawnSessionPty(supabase, config, entry);
1249
- } catch (err) {
1250
- logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
1251
- continue;
1252
- }
1253
- if (spawned) {
1254
- logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
1620
+ for (const entry of entries) {
1621
+ if (entry.isSymbolicLink() || IGNORED_ENTRY_NAMES.has(entry.name)) continue;
1622
+ const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
1623
+ if (entry.isDirectory()) {
1624
+ walk(join8(dir, entry.name), relPath, depth + 1);
1625
+ } else if (entry.isFile()) {
1626
+ const match = patterns.find(({ pattern }) => pattern.test(entry.name));
1627
+ if (match) candidatesByBaseName.get(match.baseName).push({ relPath, depth });
1255
1628
  }
1256
1629
  }
1257
1630
  }
1631
+ walk(repoRoot, "", 0);
1632
+ for (const baseName of BRAND_ICON_BASE_NAMES) {
1633
+ const candidates = candidatesByBaseName.get(baseName);
1634
+ if (candidates.length === 0) continue;
1635
+ candidates.sort((a, b) => a.depth - b.depth || a.relPath.localeCompare(b.relPath));
1636
+ return candidates[0].relPath;
1637
+ }
1638
+ return null;
1258
1639
  }
1259
- async function reconcileUsageLimitedSessions(supabase, config) {
1260
- for (const project of config.registeredProjects) {
1261
- 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");
1262
- if (error) {
1263
- logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
1264
- continue;
1265
- }
1266
- for (const { id: sessionId, card_id: cardId } of data ?? []) {
1267
- const handle = getPtySession(sessionId);
1268
- if (!handle) continue;
1269
- scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
1270
- }
1640
+ function readImageFile(repoRoot, relPath) {
1641
+ const target = resolveWithinRepo(repoRoot, relPath);
1642
+ let stat;
1643
+ try {
1644
+ stat = lstatSync(target);
1645
+ } catch {
1646
+ throw new HttpError(404, "Arquivo de logo n\xE3o encontrado.");
1271
1647
  }
1648
+ if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1649
+ if (!stat.isFile()) throw new HttpError(400, "O caminho configurado n\xE3o \xE9 um arquivo.");
1650
+ if (stat.size > MAX_LOGO_SIZE_BYTES) {
1651
+ throw new HttpError(413, `Arquivo maior que o limite de ${MAX_LOGO_SIZE_BYTES / 1e6}MB.`);
1652
+ }
1653
+ const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
1654
+ return { buffer: readFileSync4(target), contentType: MIME_TYPES_BY_EXT[ext] ?? "application/octet-stream" };
1272
1655
  }
1273
- async function stopCardSession(supabase, config, card) {
1274
- const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
1275
- if (!sessionId) return;
1276
- stopPtySession(sessionId);
1277
- cancelUsageResume(sessionId);
1278
- activeSessionByCard.delete(card.id);
1279
- await updateSession(
1280
- supabase,
1281
- sessionId,
1282
- { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
1283
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
1284
- );
1285
- logger.card.info(card.id, `Session ${sessionId} stopped.`);
1286
- await dispatchRunnerQueue(supabase, config);
1656
+ function sendJson(res, status, body) {
1657
+ const payload = JSON.stringify(body);
1658
+ res.writeHead(status, {
1659
+ "Content-Type": "application/json; charset=utf-8",
1660
+ // This runner is a trusted process on the user's own machine and the web app is served
1661
+ // from a different origin (e.g. Vercel) — the ticket is the actual security boundary,
1662
+ // so CORS is left wide open rather than trying to keep an allowlist of web origins.
1663
+ "Access-Control-Allow-Origin": "*"
1664
+ });
1665
+ res.end(payload);
1287
1666
  }
1288
-
1289
- // src/deploy.ts
1290
- async function handleDeployRequested(supabase, config, project) {
1291
- const registered = config.registeredProjects.find((candidate) => candidate.id === project.id);
1292
- if (!registered) {
1293
- logger.runner.warn("DEPLOY", `Ignoring deploy request: project ${project.id} is not registered on this runner.`);
1667
+ function readRequestBody(req, maxBytes) {
1668
+ return new Promise((resolve5, reject) => {
1669
+ let received = 0;
1670
+ const chunks = [];
1671
+ req.on("data", (chunk) => {
1672
+ received += chunk.length;
1673
+ if (received > maxBytes) {
1674
+ req.destroy();
1675
+ reject(new HttpError(413, "Corpo da requisi\xE7\xE3o excede o limite."));
1676
+ return;
1677
+ }
1678
+ chunks.push(chunk);
1679
+ });
1680
+ req.on("end", () => resolve5(Buffer.concat(chunks).toString("utf8")));
1681
+ req.on("error", reject);
1682
+ });
1683
+ }
1684
+ async function handleStatuslineReport(req, res, supabase, config) {
1685
+ let body;
1686
+ try {
1687
+ const raw = await readRequestBody(req, MAX_STATUSLINE_BODY_BYTES);
1688
+ body = JSON.parse(raw);
1689
+ } catch (err) {
1690
+ const status = err instanceof HttpError ? err.status : 400;
1691
+ sendJson(res, status, { error: "Corpo inv\xE1lido." });
1294
1692
  return;
1295
1693
  }
1296
- const allCards = await listCardsInColumn(supabase, project.id, "deploy");
1297
- const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
1298
- if (cards.length === 0) {
1299
- logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
1300
- await clearProjectDeployRequest(supabase, project.id);
1694
+ if (!body.sessionId || !body.ticket) {
1695
+ sendJson(res, 400, { error: "sessionId e ticket s\xE3o obrigat\xF3rios." });
1301
1696
  return;
1302
1697
  }
1303
- const children = await getProjectRepos(supabase, project.id);
1304
- const repoRoot = resolveRepoPath(config, project.repo_path);
1305
- const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join6(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
1306
- const failures = [];
1307
- for (const target of targets) {
1308
- const result = await pushBranchToMain(target.path);
1309
- if (result.ok) {
1310
- logger.runner.info("DEPLOY", target.label ? `Pushed HEAD to main in "${target.label}".` : "Pushed HEAD to main.");
1311
- } else {
1312
- logger.runner.error("DEPLOY", target.label ? `Push to main failed in "${target.label}": ${result.error}` : `Push to main failed: ${result.error}`);
1313
- failures.push(target.label ? `${target.label}: ${result.error}` : result.error);
1314
- }
1698
+ const isMonitor = body.sessionId === usageMonitorSessionId();
1699
+ const ticketValid = isMonitor ? body.ticket === usageMonitorTicket() : getPtySession(body.sessionId)?.ticket === body.ticket;
1700
+ if (!ticketValid) {
1701
+ sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
1702
+ return;
1315
1703
  }
1316
- if (failures.length > 0) {
1317
- const message = failures.join("; ");
1318
- await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: message })));
1319
- await clearProjectDeployRequest(supabase, project.id);
1320
- return;
1321
- }
1322
- await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: null, column: "fim" })));
1323
- await clearProjectDeployRequest(supabase, project.id);
1324
- }
1325
- async function checkPendingDeploys(supabase, config) {
1326
- for (const registered of config.registeredProjects) {
1327
- const project = await getProject(supabase, registered.id);
1328
- if (project.deploy_requested_at) {
1329
- await handleDeployRequested(supabase, config, project);
1330
- }
1331
- }
1332
- }
1333
- async function handleCardReachedFim(supabase, config, card) {
1334
- await stopCardSession(supabase, config, card);
1335
- releaseCard(card.id);
1336
- logger.card.info(card.id, 'Reached "fim" \u2014 session stopped, preview tunnel (if any) closed.');
1337
- }
1338
-
1339
- // src/filesServer.ts
1340
- import { execFile as execFile2 } from "node:child_process";
1341
- import { randomUUID as randomUUID6 } from "node:crypto";
1342
- import { existsSync as existsSync4, lstatSync, readdirSync, readFileSync as readFileSync4 } from "node:fs";
1343
- import { join as join8, resolve as resolve2, sep } from "node:path";
1344
- import { parse } from "node:url";
1345
- import { promisify as promisify2 } from "node:util";
1346
-
1347
- // src/shellSession.ts
1348
- import { randomUUID as randomUUID4 } from "node:crypto";
1349
- import { spawn as spawn4 } from "node-pty";
1350
- var SHELL_COMMAND = process.platform === "win32" ? process.env.COMSPEC ?? "cmd.exe" : process.env.SHELL ?? "bash";
1351
- function openShellSession(repoPath) {
1352
- const sessionId = `shell:${randomUUID4()}`;
1353
- const ticket = randomUUID4();
1354
- const pty = spawn4(SHELL_COMMAND, [], {
1355
- name: "xterm-color",
1356
- cols: 120,
1357
- rows: 32,
1358
- cwd: repoPath
1359
- });
1360
- registerPtySession(sessionId, pty, ticket);
1361
- pty.onExit(({ exitCode }) => {
1362
- logger.runner.info("SHELL", `Standalone shell ${sessionId} exited (code ${exitCode}).`);
1363
- stopPtySession(sessionId);
1364
- });
1365
- return { sessionId, ticket };
1366
- }
1367
-
1368
- // src/usageMonitor.ts
1369
- import { randomUUID as randomUUID5 } from "node:crypto";
1370
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync4 } from "node:fs";
1371
- import { homedir as homedir3, tmpdir as tmpdir3 } from "node:os";
1372
- import { join as join7 } from "node:path";
1373
- import { spawn as spawn5 } from "node-pty";
1374
- var SESSION_ID = "usage-monitor";
1375
- var TICKET = randomUUID5();
1376
- var SCRATCH_DIR = join7(tmpdir3(), "agent-board-usage-monitor");
1377
- var POLL_INTERVAL_MS = Number(process.env.AGENT_BOARD_USAGE_POLL_INTERVAL_MS ?? 15 * 6e4);
1378
- var IDLE_MS = 2e3;
1379
- var HARD_TIMEOUT_MS = 6e4;
1380
- function usageMonitorSessionId() {
1381
- return SESSION_ID;
1382
- }
1383
- function usageMonitorTicket() {
1384
- return TICKET;
1385
- }
1386
- function ensureScratchDirTrusted() {
1387
- const claudeJsonPath = join7(homedir3(), ".claude.json");
1388
- if (!existsSync3(claudeJsonPath)) return;
1389
1704
  try {
1390
- const raw = readFileSync3(claudeJsonPath, "utf8");
1391
- const data = JSON.parse(raw);
1392
- data.projects ??= {};
1393
- const keys = [SCRATCH_DIR, SCRATCH_DIR.replace(/\\/g, "/")];
1394
- const alreadyTrusted = keys.every((key) => data.projects[key]?.hasTrustDialogAccepted === true);
1395
- if (alreadyTrusted) return;
1396
- for (const key of keys) {
1397
- data.projects[key] = { ...data.projects[key], hasTrustDialogAccepted: true };
1398
- }
1399
- writeFileSync4(claudeJsonPath, JSON.stringify(data, null, 2));
1400
- logger.runner.info("USAGE_MONITOR", `Pre-trusted scratch directory ${SCRATCH_DIR} in ~/.claude.json.`);
1705
+ await upsertClaudeUsage(supabase, config.ownerUserId, {
1706
+ ...typeof body.fiveHour?.used_percentage === "number" ? { sessionPercent: body.fiveHour.used_percentage } : {},
1707
+ ...typeof body.fiveHour?.resets_at === "number" ? { sessionResetsAt: new Date(body.fiveHour.resets_at * 1e3).toISOString() } : {},
1708
+ ...typeof body.sevenDay?.used_percentage === "number" ? { weekPercent: body.sevenDay.used_percentage } : {},
1709
+ ...typeof body.sevenDay?.resets_at === "number" ? { weekResetsAt: new Date(body.sevenDay.resets_at * 1e3).toISOString() } : {}
1710
+ });
1711
+ res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
1712
+ res.end();
1401
1713
  } catch (err) {
1402
- logger.runner.error("USAGE_MONITOR", "Failed to pre-trust the usage-monitor scratch directory:", err);
1714
+ logger.runner.error("STATUSLINE", `Failed to persist Claude usage from session ${body.sessionId}:`, err);
1715
+ sendJson(res, 500, { error: "Erro interno do runner." });
1403
1716
  }
1404
1717
  }
1405
- function pollUsageOnce() {
1406
- let pty;
1718
+ async function handleAgentHookReport(req, res) {
1719
+ let body;
1407
1720
  try {
1408
- pty = spawn5(
1409
- CLAUDE_BINARY,
1410
- [
1411
- "--dangerously-skip-permissions",
1412
- "--model",
1413
- "claude-haiku-4-5",
1414
- // cheapest model — this exchange exists only to trigger a statusLine update
1415
- ...statuslineSettingsArgs(),
1416
- "reply with just the word: ok"
1417
- ],
1418
- {
1419
- name: "xterm-color",
1420
- cols: 80,
1421
- rows: 24,
1422
- cwd: SCRATCH_DIR,
1423
- env: statuslineEnv(SESSION_ID, TICKET)
1424
- }
1425
- );
1721
+ const raw = await readRequestBody(req, MAX_STATUSLINE_BODY_BYTES);
1722
+ body = JSON.parse(raw);
1426
1723
  } catch (err) {
1427
- logger.runner.error("USAGE_MONITOR", "Failed to spawn the usage-poll session:", err);
1724
+ const status = err instanceof HttpError ? err.status : 400;
1725
+ sendJson(res, status, { error: "Corpo inv\xE1lido." });
1428
1726
  return;
1429
1727
  }
1430
- let settled = false;
1431
- let idleTimer;
1432
- const hardTimer = setTimeout(finish, HARD_TIMEOUT_MS);
1433
- function finish() {
1434
- if (settled) return;
1435
- settled = true;
1436
- clearTimeout(idleTimer);
1437
- clearTimeout(hardTimer);
1438
- try {
1439
- pty.kill();
1440
- } catch {
1441
- }
1728
+ if (!body.sessionId || !body.ticket || !body.moment) {
1729
+ sendJson(res, 400, { error: "sessionId, ticket e moment s\xE3o obrigat\xF3rios." });
1730
+ return;
1442
1731
  }
1443
- pty.onData(() => {
1444
- clearTimeout(idleTimer);
1445
- idleTimer = setTimeout(finish, IDLE_MS);
1446
- });
1447
- pty.onExit(() => finish());
1448
- }
1449
- function startUsageMonitor() {
1450
- mkdirSync3(SCRATCH_DIR, { recursive: true });
1451
- ensureScratchDirTrusted();
1452
- pollUsageOnce();
1453
- setInterval(pollUsageOnce, POLL_INTERVAL_MS);
1454
- }
1455
-
1456
- // src/filesServer.ts
1457
- var execFileAsync2 = promisify2(execFile2);
1458
- var FILES_TICKET_TTL_MS = 15 * 60 * 1e3;
1459
- var FILES_TICKET_REFRESH_MARGIN_MS = FILES_TICKET_TTL_MS / 3;
1460
- var MAX_FILE_SIZE_BYTES = 1e6;
1461
- var MAX_LOGO_SIZE_BYTES = 5e6;
1462
- var IGNORED_ENTRY_NAMES = /* @__PURE__ */ new Set([".git", ".claude", "node_modules", "dist", "build"]);
1463
- var BRAND_ICON_BASE_NAMES = ["logo", "favicon"];
1464
- var MIME_TYPES_BY_EXT = {
1465
- svg: "image/svg+xml",
1466
- png: "image/png",
1467
- jpg: "image/jpeg",
1468
- jpeg: "image/jpeg",
1469
- webp: "image/webp",
1470
- gif: "image/gif",
1471
- ico: "image/x-icon"
1472
- };
1473
- var ticketsByProject = /* @__PURE__ */ new Map();
1474
- var setupTicket = null;
1475
- var statuslineSupabase = null;
1476
- var statuslineConfig = null;
1477
- var MAX_STATUSLINE_BODY_BYTES = 16e3;
1478
- var HttpError = class extends Error {
1479
- constructor(status, message) {
1480
- super(message);
1481
- this.status = status;
1732
+ if (getPtySession(body.sessionId)?.ticket !== body.ticket) {
1733
+ sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
1734
+ return;
1482
1735
  }
1483
- };
1484
- async function ensureFilesTickets(supabase, config) {
1485
- for (const project of config.registeredProjects) {
1486
- if (!project.isReferenceRunner) continue;
1487
- const existing = ticketsByProject.get(project.id);
1488
- if (existing && existing.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) continue;
1489
- const ticket = randomUUID6();
1490
- const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
1491
- ticketsByProject.set(project.id, { ticket, expiresAt: expiresAtMs, repoRoot: resolve2(project.repoPath) });
1492
- try {
1493
- await updateProjectFilesTicket(supabase, project.id, ticket, new Date(expiresAtMs).toISOString());
1494
- } catch (err) {
1495
- logger.runner.error("FILES", `Failed to publish files ticket for project ${project.id}:`, err);
1736
+ handleAgentHookEvent(body.sessionId, body.moment);
1737
+ res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
1738
+ res.end();
1739
+ }
1740
+ async function handleBranchRequest(entry, res, relPath) {
1741
+ let cwd;
1742
+ try {
1743
+ cwd = relPath ? resolveWithinRepo(entry.repoRoot, relPath) : entry.repoRoot;
1744
+ } catch (err) {
1745
+ if (err instanceof HttpError) {
1746
+ sendJson(res, err.status, { error: err.message });
1747
+ return;
1496
1748
  }
1749
+ throw err;
1497
1750
  }
1498
- }
1499
- function authenticate(query) {
1500
- const projectId = typeof query.projectId === "string" ? query.projectId : null;
1501
- const ticket = typeof query.ticket === "string" ? query.ticket : null;
1502
- if (!projectId || !ticket) return null;
1503
- const entry = ticketsByProject.get(projectId);
1504
- if (!entry || entry.ticket !== ticket || entry.expiresAt < Date.now()) return null;
1505
- return entry;
1506
- }
1507
- async function ensureSetupTicket(supabase, config) {
1508
- if (!config.allowedRootPath) return;
1509
- if (setupTicket && setupTicket.expiresAt - Date.now() > FILES_TICKET_REFRESH_MARGIN_MS) return;
1510
- const ticket = randomUUID6();
1511
- const expiresAtMs = Date.now() + FILES_TICKET_TTL_MS;
1512
- setupTicket = { ticket, expiresAt: expiresAtMs, rootPath: resolve2(config.allowedRootPath) };
1513
1751
  try {
1514
- await updateRunnerSetupTicket(supabase, config.runnerId, ticket, new Date(expiresAtMs).toISOString(), config.allowedRootPath);
1752
+ const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
1753
+ sendJson(res, 200, { branch: stdout.trim() });
1515
1754
  } catch (err) {
1516
- logger.runner.error("FILES", `Failed to publish setup ticket for runner ${config.runnerId}:`, err);
1755
+ logger.runner.error("FILES", "Unexpected error handling a branch request:", err);
1756
+ sendJson(res, 500, { error: "Erro interno do runner." });
1517
1757
  }
1518
1758
  }
1519
- function authenticateSetup(query) {
1520
- const ticket = typeof query.ticket === "string" ? query.ticket : null;
1521
- if (!ticket || !setupTicket) return null;
1522
- if (setupTicket.ticket !== ticket || setupTicket.expiresAt < Date.now()) return null;
1523
- return setupTicket;
1759
+ var DIFF_EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
1760
+ function parseGitStatusPorcelain(output) {
1761
+ return output.split("\n").filter((line) => line.length > 0).map((line) => {
1762
+ const status = line.slice(0, 2).trim();
1763
+ const rest2 = line.slice(3);
1764
+ const arrowIndex = rest2.indexOf(" -> ");
1765
+ const path = arrowIndex >= 0 ? rest2.slice(arrowIndex + 4) : rest2;
1766
+ return { path, status };
1767
+ });
1524
1768
  }
1525
- function resolveWithinRepo(repoRoot, relPath) {
1526
- const normalizedRel = relPath.replace(/^[/\\]+/, "");
1527
- const resolved = resolve2(repoRoot, normalizedRel);
1528
- const rootWithSep = repoRoot.endsWith(sep) ? repoRoot : repoRoot + sep;
1529
- if (resolved !== repoRoot && !resolved.startsWith(rootWithSep)) {
1530
- throw new HttpError(403, "Caminho fora do reposit\xF3rio.");
1769
+ async function getRepoDiffForTarget(label, cwd) {
1770
+ let files;
1771
+ try {
1772
+ const { stdout } = await execFileAsync2("git", ["status", "--porcelain=v1"], { cwd });
1773
+ files = parseGitStatusPorcelain(stdout);
1774
+ } catch (err) {
1775
+ const message = err instanceof Error ? err.message : String(err);
1776
+ return { label, files: [], diff: "", error: message };
1531
1777
  }
1532
- return resolved;
1533
- }
1534
- function listDirectory(repoRoot, relPath) {
1535
- const target = resolveWithinRepo(repoRoot, relPath);
1536
- let stat;
1778
+ if (files.length === 0) return { label, files, diff: "" };
1537
1779
  try {
1538
- stat = lstatSync(target);
1780
+ const { stdout } = await execFileAsync2("git", ["diff", "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1781
+ return { label, files, diff: stdout };
1539
1782
  } catch {
1540
- throw new HttpError(404, "Diret\xF3rio n\xE3o encontrado.");
1783
+ return { label, files, diff: "" };
1541
1784
  }
1542
- if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1543
- if (!stat.isDirectory()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um diret\xF3rio.");
1544
- const prefix = relPath.replace(/\/+$/, "");
1545
- return readdirSync(target, { withFileTypes: true }).filter((dirent) => !dirent.isSymbolicLink() && !IGNORED_ENTRY_NAMES.has(dirent.name)).map((dirent) => ({
1546
- name: dirent.name,
1547
- path: prefix ? `${prefix}/${dirent.name}` : dirent.name,
1548
- type: dirent.isDirectory() ? "dir" : "file"
1549
- })).sort((a, b) => a.type !== b.type ? a.type === "dir" ? -1 : 1 : a.name.localeCompare(b.name));
1550
- }
1551
- function listDirectoryForBrowse(rootPath, relPath) {
1552
- return listDirectory(rootPath, relPath).map((entry) => ({
1553
- ...entry,
1554
- hasGit: entry.type === "dir" && existsSync4(join8(rootPath, entry.path, ".git"))
1555
- }));
1556
1785
  }
1557
- function looksBinary(buffer) {
1558
- const sample = buffer.subarray(0, Math.min(buffer.length, 8e3));
1559
- return sample.includes(0);
1786
+ async function handleDiffRequest(entry, res, projectId) {
1787
+ try {
1788
+ const children = statuslineSupabase && projectId ? await getProjectRepos(statuslineSupabase, projectId) : [];
1789
+ const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, cwd: join8(entry.repoRoot, child2.subpath) })) : [{ label: null, cwd: entry.repoRoot }];
1790
+ const repos = await Promise.all(targets.map((target) => getRepoDiffForTarget(target.label, target.cwd)));
1791
+ sendJson(res, 200, { repos });
1792
+ } catch (err) {
1793
+ logger.runner.error("FILES", "Unexpected error handling a diff request:", err);
1794
+ sendJson(res, 500, { error: "Erro interno do runner." });
1795
+ }
1560
1796
  }
1561
- function readTextFile(repoRoot, relPath) {
1562
- const target = resolveWithinRepo(repoRoot, relPath);
1563
- let stat;
1797
+ async function handleAgentFileWrite(req, res, atClaudeRoot, query) {
1798
+ const entry = atClaudeRoot ? authenticateAgent(query) : authenticate(query);
1799
+ if (!entry) {
1800
+ sendJson(res, 401, { error: "Ticket inv\xE1lido ou expirado." });
1801
+ return;
1802
+ }
1803
+ let body;
1564
1804
  try {
1565
- stat = lstatSync(target);
1566
- } catch {
1567
- throw new HttpError(404, "Arquivo n\xE3o encontrado.");
1805
+ const raw = await readRequestBody(req, MAX_FILE_SIZE_BYTES + 1e3);
1806
+ body = JSON.parse(raw);
1807
+ } catch (err) {
1808
+ const status = err instanceof HttpError ? err.status : 400;
1809
+ sendJson(res, status, { error: "Corpo inv\xE1lido." });
1810
+ return;
1568
1811
  }
1569
- if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1570
- if (!stat.isFile()) throw new HttpError(400, "O caminho informado n\xE3o \xE9 um arquivo.");
1571
- if (stat.size > MAX_FILE_SIZE_BYTES) {
1572
- throw new HttpError(413, `Arquivo maior que o limite de ${MAX_FILE_SIZE_BYTES / 1e6}MB para visualiza\xE7\xE3o.`);
1812
+ if (typeof body.content !== "string") {
1813
+ sendJson(res, 400, { error: "Campo content \xE9 obrigat\xF3rio." });
1814
+ return;
1815
+ }
1816
+ const relPath = typeof query.path === "string" ? query.path : "";
1817
+ try {
1818
+ assertWritableAgentPath(relPath, atClaudeRoot);
1819
+ writeTextFile(entry.repoRoot, relPath, body.content);
1820
+ sendJson(res, 200, { ok: true });
1821
+ } catch (err) {
1822
+ if (err instanceof HttpError) {
1823
+ sendJson(res, err.status, { error: err.message });
1824
+ return;
1825
+ }
1826
+ logger.runner.error("FILES", "Unexpected error handling an agent file write:", err);
1827
+ sendJson(res, 500, { error: "Erro interno do runner." });
1573
1828
  }
1574
- const buffer = readFileSync4(target);
1575
- if (looksBinary(buffer)) throw new HttpError(415, "Arquivo bin\xE1rio n\xE3o pode ser exibido.");
1576
- return { content: buffer.toString("utf8"), size: stat.size };
1577
1829
  }
1578
- function findBrandIconPath(repoRoot) {
1579
- const patterns = BRAND_ICON_BASE_NAMES.map((baseName) => ({
1580
- baseName,
1581
- pattern: new RegExp(`^${baseName}\\.[a-z0-9]+$`, "i")
1582
- }));
1583
- const candidatesByBaseName = new Map(
1584
- BRAND_ICON_BASE_NAMES.map((baseName) => [baseName, []])
1585
- );
1586
- function walk(dir, relDir, depth) {
1587
- let entries;
1830
+ function handleRequest(req, res) {
1831
+ if (req.method === "OPTIONS") {
1832
+ res.writeHead(204, {
1833
+ "Access-Control-Allow-Origin": "*",
1834
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1835
+ // Needed since the new write routes below send a JSON body (Content-Type:
1836
+ // application/json), which makes the browser preflight the request — unlike every
1837
+ // other route here, which is a plain GET the browser never preflights.
1838
+ "Access-Control-Allow-Headers": "Content-Type"
1839
+ });
1840
+ res.end();
1841
+ return;
1842
+ }
1843
+ const { pathname, query } = parse(req.url ?? "", true);
1844
+ if (pathname === "/agent-skills-list" || pathname === "/global-agent-skills-list") {
1845
+ if (req.method !== "GET") {
1846
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1847
+ return;
1848
+ }
1849
+ void handleSkillsList(res, query, pathname === "/global-agent-skills-list");
1850
+ return;
1851
+ }
1852
+ if (pathname === "/agent-skills-find" || pathname === "/global-agent-skills-find") {
1853
+ if (req.method !== "GET") {
1854
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1855
+ return;
1856
+ }
1857
+ void handleSkillsFind(res, query, pathname === "/global-agent-skills-find");
1858
+ return;
1859
+ }
1860
+ if (pathname === "/agent-skills-popular" || pathname === "/global-agent-skills-popular") {
1861
+ if (req.method !== "GET") {
1862
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1863
+ return;
1864
+ }
1865
+ void handleSkillsPopular(res, query, pathname === "/global-agent-skills-popular");
1866
+ return;
1867
+ }
1868
+ if (pathname === "/agent-skills-install" || pathname === "/global-agent-skills-install") {
1869
+ if (req.method !== "POST") {
1870
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1871
+ return;
1872
+ }
1873
+ void handleSkillsInstall(req, res, query, pathname === "/global-agent-skills-install");
1874
+ return;
1875
+ }
1876
+ if (pathname === "/agent-skills-remove" || pathname === "/global-agent-skills-remove") {
1877
+ if (req.method !== "POST") {
1878
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1879
+ return;
1880
+ }
1881
+ void handleSkillsRemove(req, res, query, pathname === "/global-agent-skills-remove");
1882
+ return;
1883
+ }
1884
+ if (pathname === "/agent-skills-update" || pathname === "/global-agent-skills-update") {
1885
+ if (req.method !== "POST") {
1886
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1887
+ return;
1888
+ }
1889
+ void handleSkillsUpdate(req, res, query, pathname === "/global-agent-skills-update");
1890
+ return;
1891
+ }
1892
+ if (req.method === "POST" && pathname === "/statusline") {
1893
+ if (!statuslineSupabase || !statuslineConfig) {
1894
+ sendJson(res, 500, { error: "Runner ainda n\xE3o inicializado." });
1895
+ return;
1896
+ }
1897
+ void handleStatuslineReport(req, res, statuslineSupabase, statuslineConfig);
1898
+ return;
1899
+ }
1900
+ if (req.method === "POST" && pathname === "/agent-hook") {
1901
+ void handleAgentHookReport(req, res);
1902
+ return;
1903
+ }
1904
+ if (req.method === "POST" && (pathname === "/agent-file" || pathname === "/global-agent-file")) {
1905
+ void handleAgentFileWrite(req, res, pathname === "/global-agent-file", query);
1906
+ return;
1907
+ }
1908
+ if (req.method === "POST" && pathname === "/clone") {
1909
+ void handleCloneRequest(req, res, query);
1910
+ return;
1911
+ }
1912
+ if (req.method !== "GET") {
1913
+ sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
1914
+ return;
1915
+ }
1916
+ const relPath = typeof query.path === "string" ? query.path : "";
1917
+ if (pathname === "/browse") {
1918
+ const setupEntry = authenticateSetup(query);
1919
+ if (!setupEntry) {
1920
+ sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou navega\xE7\xE3o de pastas desativada." });
1921
+ return;
1922
+ }
1588
1923
  try {
1589
- entries = readdirSync(dir, { withFileTypes: true });
1590
- } catch {
1924
+ sendJson(res, 200, { entries: listDirectoryForBrowse(setupEntry.rootPath, relPath) });
1925
+ } catch (err) {
1926
+ if (err instanceof HttpError) {
1927
+ sendJson(res, err.status, { error: err.message });
1928
+ return;
1929
+ }
1930
+ logger.runner.error("FILES", "Unexpected error handling a browse request:", err);
1931
+ sendJson(res, 500, { error: "Erro interno do runner." });
1932
+ }
1933
+ return;
1934
+ }
1935
+ if (pathname === "/global-agent-tree" || pathname === "/global-agent-file") {
1936
+ const agentEntry = authenticateAgent(query);
1937
+ if (!agentEntry) {
1938
+ sendJson(res, 401, { error: "Ticket inv\xE1lido ou expirado." });
1591
1939
  return;
1592
1940
  }
1593
- for (const entry of entries) {
1594
- if (entry.isSymbolicLink() || IGNORED_ENTRY_NAMES.has(entry.name)) continue;
1595
- const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
1596
- if (entry.isDirectory()) {
1597
- walk(join8(dir, entry.name), relPath, depth + 1);
1598
- } else if (entry.isFile()) {
1599
- const match = patterns.find(({ pattern }) => pattern.test(entry.name));
1600
- if (match) candidatesByBaseName.get(match.baseName).push({ relPath, depth });
1941
+ try {
1942
+ if (pathname === "/global-agent-tree") {
1943
+ sendJson(res, 200, { entries: listSafeAgentDirectory(agentEntry.repoRoot, relPath, true) });
1944
+ } else {
1945
+ assertSafeAgentPath(relPath, true);
1946
+ sendJson(res, 200, readTextFile(agentEntry.repoRoot, relPath));
1947
+ }
1948
+ } catch (err) {
1949
+ if (err instanceof HttpError) {
1950
+ sendJson(res, err.status, { error: err.message });
1951
+ return;
1601
1952
  }
1953
+ logger.runner.error("FILES", "Unexpected error handling a global agent request:", err);
1954
+ sendJson(res, 500, { error: "Erro interno do runner." });
1602
1955
  }
1956
+ return;
1603
1957
  }
1604
- walk(repoRoot, "", 0);
1605
- for (const baseName of BRAND_ICON_BASE_NAMES) {
1606
- const candidates = candidatesByBaseName.get(baseName);
1607
- if (candidates.length === 0) continue;
1608
- candidates.sort((a, b) => a.depth - b.depth || a.relPath.localeCompare(b.relPath));
1609
- return candidates[0].relPath;
1958
+ const entry = authenticate(query);
1959
+ if (!entry) {
1960
+ sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou projeto desconhecido." });
1961
+ return;
1610
1962
  }
1611
- return null;
1612
- }
1613
- function readImageFile(repoRoot, relPath) {
1614
- const target = resolveWithinRepo(repoRoot, relPath);
1615
- let stat;
1616
- try {
1617
- stat = lstatSync(target);
1618
- } catch {
1619
- throw new HttpError(404, "Arquivo de logo n\xE3o encontrado.");
1963
+ if (pathname === "/branch") {
1964
+ void handleBranchRequest(entry, res, relPath);
1965
+ return;
1620
1966
  }
1621
- if (stat.isSymbolicLink()) throw new HttpError(403, "Links simb\xF3licos n\xE3o s\xE3o suportados.");
1622
- if (!stat.isFile()) throw new HttpError(400, "O caminho configurado n\xE3o \xE9 um arquivo.");
1623
- if (stat.size > MAX_LOGO_SIZE_BYTES) {
1624
- throw new HttpError(413, `Arquivo maior que o limite de ${MAX_LOGO_SIZE_BYTES / 1e6}MB.`);
1967
+ if (pathname === "/diff") {
1968
+ const projectId = typeof query.projectId === "string" ? query.projectId : "";
1969
+ void handleDiffRequest(entry, res, projectId);
1970
+ return;
1625
1971
  }
1626
- const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
1627
- return { buffer: readFileSync4(target), contentType: MIME_TYPES_BY_EXT[ext] ?? "application/octet-stream" };
1628
- }
1629
- function sendJson(res, status, body) {
1630
- const payload = JSON.stringify(body);
1631
- res.writeHead(status, {
1632
- "Content-Type": "application/json; charset=utf-8",
1633
- // This runner is a trusted process on the user's own machine and the web app is served
1634
- // from a different origin (e.g. Vercel) — the ticket is the actual security boundary,
1635
- // so CORS is left wide open rather than trying to keep an allowlist of web origins.
1636
- "Access-Control-Allow-Origin": "*"
1637
- });
1638
- res.end(payload);
1639
- }
1640
- function readRequestBody(req, maxBytes) {
1641
- return new Promise((resolve5, reject) => {
1642
- let received = 0;
1643
- const chunks = [];
1644
- req.on("data", (chunk) => {
1645
- received += chunk.length;
1646
- if (received > maxBytes) {
1647
- req.destroy();
1648
- reject(new HttpError(413, "Corpo da requisi\xE7\xE3o excede o limite."));
1972
+ try {
1973
+ if (pathname === "/tree") {
1974
+ sendJson(res, 200, { entries: listDirectory(entry.repoRoot, relPath) });
1975
+ return;
1976
+ }
1977
+ if (pathname === "/file") {
1978
+ sendJson(res, 200, readTextFile(entry.repoRoot, relPath));
1979
+ return;
1980
+ }
1981
+ if (pathname === "/agent-tree") {
1982
+ sendJson(res, 200, { entries: listSafeAgentDirectory(entry.repoRoot, relPath, false) });
1983
+ return;
1984
+ }
1985
+ if (pathname === "/agent-file") {
1986
+ assertSafeAgentPath(relPath, false);
1987
+ sendJson(res, 200, readTextFile(entry.repoRoot, relPath));
1988
+ return;
1989
+ }
1990
+ if (pathname === "/logo") {
1991
+ if (!relPath) throw new HttpError(404, "Nenhuma logo configurada para este projeto.");
1992
+ const { buffer, contentType } = readImageFile(entry.repoRoot, relPath);
1993
+ res.writeHead(200, {
1994
+ "Content-Type": contentType,
1995
+ "Content-Length": buffer.length,
1996
+ "Cache-Control": "no-cache",
1997
+ "Access-Control-Allow-Origin": "*"
1998
+ });
1999
+ res.end(buffer);
2000
+ return;
2001
+ }
2002
+ if (pathname === "/shell/open") {
2003
+ sendJson(res, 200, openShellSession(entry.repoRoot));
2004
+ return;
2005
+ }
2006
+ if (pathname === "/shell/close") {
2007
+ const sessionId = typeof query.sessionId === "string" ? query.sessionId : "";
2008
+ if (!sessionId) throw new HttpError(400, "Par\xE2metro sessionId \xE9 obrigat\xF3rio.");
2009
+ closeShellSession(sessionId);
2010
+ sendJson(res, 200, { ok: true });
2011
+ return;
2012
+ }
2013
+ if (pathname === "/column-shell/open" || pathname === "/column-shell/close") {
2014
+ const projectId = typeof query.projectId === "string" ? query.projectId : "";
2015
+ const column = typeof query.column === "string" ? query.column : null;
2016
+ if (!column || !BOARD_COLUMNS.includes(column)) throw new HttpError(400, "Par\xE2metro column inv\xE1lido ou ausente.");
2017
+ const kind = typeof query.kind === "string" ? query.kind : "shell";
2018
+ if (!COLUMN_SHELL_KINDS.includes(kind)) throw new HttpError(400, "Par\xE2metro kind inv\xE1lido.");
2019
+ const key = `${projectId}:${column}:${kind}`;
2020
+ if (pathname === "/column-shell/open") {
2021
+ const shell = openOrReuseColumnShell(key, entry.repoRoot, kind === "claude" ? CLAUDE_BYPASS_COMMAND : void 0);
2022
+ if (statuslineSupabase) void upsertColumnShell(statuslineSupabase, projectId, column, kind, shell.sessionId, shell.ticket);
2023
+ sendJson(res, 200, shell);
1649
2024
  return;
1650
2025
  }
1651
- chunks.push(chunk);
2026
+ closeColumnShell(key);
2027
+ if (statuslineSupabase) void deleteColumnShell(statuslineSupabase, projectId, column, kind);
2028
+ sendJson(res, 200, { ok: true });
2029
+ return;
2030
+ }
2031
+ sendJson(res, 404, { error: "Rota desconhecida." });
2032
+ } catch (err) {
2033
+ if (err instanceof HttpError) {
2034
+ sendJson(res, err.status, { error: err.message });
2035
+ return;
2036
+ }
2037
+ logger.runner.error("FILES", "Unexpected error handling a files request:", err);
2038
+ sendJson(res, 500, { error: "Erro interno do runner." });
2039
+ }
2040
+ }
2041
+ function attachFilesServer(httpServer, supabase, config) {
2042
+ statuslineSupabase = supabase;
2043
+ statuslineConfig = config;
2044
+ httpServer.on("request", handleRequest);
2045
+ logger.runner.info("FILES", "Files HTTP handler attached.");
2046
+ }
2047
+
2048
+ // src/agentEventsServer.ts
2049
+ var stateByProject = /* @__PURE__ */ new Map();
2050
+ var socketsByProject = /* @__PURE__ */ new Map();
2051
+ function sendSnapshot(socket, projectId) {
2052
+ const states = Array.from(stateByProject.get(projectId)?.values() ?? []);
2053
+ socket.send(JSON.stringify({ type: "snapshot", states }));
2054
+ }
2055
+ function publishAgentStateEvent(projectId, cardId, sessionId, agentState) {
2056
+ let projectState = stateByProject.get(projectId);
2057
+ if (!projectState) {
2058
+ projectState = /* @__PURE__ */ new Map();
2059
+ stateByProject.set(projectId, projectState);
2060
+ }
2061
+ projectState.set(cardId, { sessionId, cardId, agentState });
2062
+ const sockets = socketsByProject.get(projectId);
2063
+ if (!sockets || sockets.size === 0) return;
2064
+ const payload = JSON.stringify({ type: "state", sessionId, cardId, agentState });
2065
+ for (const socket of sockets) {
2066
+ if (socket.readyState === socket.OPEN) socket.send(payload);
2067
+ }
2068
+ }
2069
+ function attachAgentEventsServer() {
2070
+ const wss = new WebSocketServer({ noServer: true });
2071
+ wss.on("connection", (socket, request) => {
2072
+ const { query } = parse2(request.url ?? "", true);
2073
+ const projectId = typeof query.projectId === "string" ? query.projectId : null;
2074
+ const entry = authenticate(query);
2075
+ if (!projectId || !entry) {
2076
+ logger.runner.warn("AGENT-EVENTS", "Agent events WS rejected: invalid or expired ticket.");
2077
+ socket.close(4401, "Invalid or expired ticket");
2078
+ return;
2079
+ }
2080
+ let sockets = socketsByProject.get(projectId);
2081
+ if (!sockets) {
2082
+ sockets = /* @__PURE__ */ new Set();
2083
+ socketsByProject.set(projectId, sockets);
2084
+ }
2085
+ sockets.add(socket);
2086
+ sendSnapshot(socket, projectId);
2087
+ socket.on("close", () => {
2088
+ sockets.delete(socket);
1652
2089
  });
1653
- req.on("end", () => resolve5(Buffer.concat(chunks).toString("utf8")));
1654
- req.on("error", reject);
1655
2090
  });
2091
+ logger.runner.info("AGENT-EVENTS", "Agent events WebSocket handler attached.");
2092
+ return wss;
1656
2093
  }
1657
- async function handleStatuslineReport(req, res, supabase, config) {
1658
- let body;
2094
+
2095
+ // src/columnActions.ts
2096
+ import { exec } from "node:child_process";
2097
+ import { join as join9 } from "node:path";
2098
+ import { promisify as promisify3 } from "node:util";
2099
+ var execAsync = promisify3(exec);
2100
+ var OUTPUT_TAIL_CHARS = 4e3;
2101
+ function tail(text) {
2102
+ return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
2103
+ }
2104
+ function resolveTargets(project, children, repoRoot, column, timing) {
2105
+ if (children.length > 0) {
2106
+ return children.flatMap(
2107
+ (child2) => (child2.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: child2.subpath, cwd: join9(repoRoot, child2.subpath), command: action.command }))
2108
+ );
2109
+ }
2110
+ return (project.column_actions[column] ?? []).filter((action) => action.timing === timing).map((action) => ({ label: null, cwd: repoRoot, command: action.command }));
2111
+ }
2112
+ var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
2113
+ async function runCommand(target) {
1659
2114
  try {
1660
- const raw = await readRequestBody(req, MAX_STATUSLINE_BODY_BYTES);
1661
- body = JSON.parse(raw);
2115
+ const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd });
2116
+ return { ok: true, output: tail(`${stdout}${stderr}`) };
1662
2117
  } catch (err) {
1663
- const status = err instanceof HttpError ? err.status : 400;
1664
- sendJson(res, status, { error: "Corpo inv\xE1lido." });
1665
- return;
2118
+ const { stdout, stderr } = err;
2119
+ const message = err instanceof Error ? err.message : String(err);
2120
+ return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
1666
2121
  }
1667
- if (!body.sessionId || !body.ticket) {
1668
- sendJson(res, 400, { error: "sessionId e ticket s\xE3o obrigat\xF3rios." });
1669
- return;
2122
+ }
2123
+ var inFlightColumns = /* @__PURE__ */ new Set();
2124
+ async function handleColumnEntered(supabase, config, card) {
2125
+ const key = `${card.project_id}:${card.column_name}`;
2126
+ if (inFlightColumns.has(key)) return;
2127
+ inFlightColumns.add(key);
2128
+ try {
2129
+ const project = await getProject(supabase, card.project_id);
2130
+ const children = await getProjectRepos(supabase, project.id);
2131
+ const repoRoot = resolveRepoPath(config, project.repo_path);
2132
+ const targets = resolveTargets(project, children, repoRoot, card.column_name, "before");
2133
+ if (targets.length === 0) return;
2134
+ const allCards = await listCardsInColumn(supabase, project.id, card.column_name);
2135
+ const cards = allCards.filter((candidate) => candidate.assigned_user_id === config.ownerUserId);
2136
+ if (cards.length === 0) return;
2137
+ await Promise.all(
2138
+ cards.map((candidate) => updateCard(supabase, candidate.id, { customActionStatus: "running", customActionOutput: null }))
2139
+ );
2140
+ let failure = null;
2141
+ for (const target of targets) {
2142
+ const result = await runCommand(target);
2143
+ if (!result.ok) {
2144
+ failure = { label: target.label, output: result.output };
2145
+ break;
2146
+ }
2147
+ }
2148
+ if (failure) {
2149
+ const message = failure.label ? `[${failure.label}] ${failure.output}` : failure.output;
2150
+ logger.runner.warn("COLUMN_ACTION", `Command failed for project ${project.id} in "${card.column_name}": ${message}`);
2151
+ await Promise.all(
2152
+ cards.map(
2153
+ (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "failed", customActionOutput: message })
2154
+ )
2155
+ );
2156
+ } else {
2157
+ logger.runner.info("COLUMN_ACTION", `Command passed for project ${project.id} in "${card.column_name}".`);
2158
+ await Promise.all(
2159
+ cards.map(
2160
+ (candidate) => updateCard(supabase, candidate.id, { customActionStatus: "passed", customActionOutput: null })
2161
+ )
2162
+ );
2163
+ }
2164
+ } finally {
2165
+ inFlightColumns.delete(key);
1670
2166
  }
1671
- const isMonitor = body.sessionId === usageMonitorSessionId();
1672
- const ticketValid = isMonitor ? body.ticket === usageMonitorTicket() : getPtySession(body.sessionId)?.ticket === body.ticket;
1673
- if (!ticketValid) {
1674
- sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
1675
- return;
2167
+ }
2168
+ async function clearColumnActionStatus(supabase, card) {
2169
+ if (card.custom_action_status === null) return;
2170
+ await updateCard(supabase, card.id, { customActionStatus: null, customActionOutput: null });
2171
+ }
2172
+ async function runAfterColumnActions(project, children, repoRoot, column) {
2173
+ const targets = resolveTargets(project, children, repoRoot, column, "after");
2174
+ for (const target of targets) {
2175
+ const result = await runCommand(target);
2176
+ if (!result.ok) {
2177
+ return { ok: false, error: target.label ? `${target.label}: ${result.output}` : result.output };
2178
+ }
1676
2179
  }
1677
- try {
1678
- await upsertClaudeUsage(supabase, config.ownerUserId, {
1679
- ...typeof body.fiveHour?.used_percentage === "number" ? { sessionPercent: body.fiveHour.used_percentage } : {},
1680
- ...typeof body.fiveHour?.resets_at === "number" ? { sessionResetsAt: new Date(body.fiveHour.resets_at * 1e3).toISOString() } : {},
1681
- ...typeof body.sevenDay?.used_percentage === "number" ? { weekPercent: body.sevenDay.used_percentage } : {},
1682
- ...typeof body.sevenDay?.resets_at === "number" ? { weekResetsAt: new Date(body.sevenDay.resets_at * 1e3).toISOString() } : {}
2180
+ return { ok: true };
2181
+ }
2182
+ async function checkPendingColumnActions(supabase, config) {
2183
+ for (const registered of config.registeredProjects) {
2184
+ for (const column of BOARD_COLUMNS) {
2185
+ const cards = await listCardsInColumn(supabase, registered.id, column);
2186
+ const pending2 = cards.find(
2187
+ (card) => card.assigned_user_id === config.ownerUserId && card.custom_action_status === null
2188
+ );
2189
+ if (pending2) await handleColumnEntered(supabase, config, pending2);
2190
+ }
2191
+ }
2192
+ }
2193
+ async function handleDeployActionRerunRequested(supabase, config, project) {
2194
+ const allCards = await listCardsInColumn(supabase, project.id, "deploy");
2195
+ const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
2196
+ if (cards.length === 0) {
2197
+ logger.runner.info("COLUMN_ACTION", `Rerun requested for project ${project.id}'s "deploy" column but no card assigned to this runner's user is waiting there.`);
2198
+ } else {
2199
+ await handleColumnEntered(supabase, config, cards[0]);
2200
+ }
2201
+ await clearDeployActionRerunRequest(supabase, project.id);
2202
+ }
2203
+ async function checkPendingDeployActionReruns(supabase, config) {
2204
+ for (const registered of config.registeredProjects) {
2205
+ const project = await getProject(supabase, registered.id);
2206
+ if (project.deploy_action_rerun_requested_at) {
2207
+ await handleDeployActionRerunRequested(supabase, config, project);
2208
+ }
2209
+ }
2210
+ }
2211
+
2212
+ // src/deploy.ts
2213
+ import { join as join12 } from "node:path";
2214
+
2215
+ // src/devServer.ts
2216
+ import { spawn as spawn4 } from "node:child_process";
2217
+
2218
+ // src/cloudflared.ts
2219
+ import { spawn as spawn3 } from "node:child_process";
2220
+ import { accessSync, constants } from "node:fs";
2221
+ import { homedir as homedir5 } from "node:os";
2222
+ import { delimiter, join as join10 } from "node:path";
2223
+ import { bin as bundledBinaryPath, install as downloadCloudflared } from "cloudflared";
2224
+ var TRYCLOUDFLARE_URL_RE = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
2225
+ var STARTUP_TIMEOUT_MS = 2e4;
2226
+ var IS_WINDOWS2 = process.platform === "win32";
2227
+ var BINARY_NAME = IS_WINDOWS2 ? "cloudflared.exe" : "cloudflared";
2228
+ var CLOUDFLARED_INSTALL_DOCS_URL = "https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/";
2229
+ function wellKnownDirectories() {
2230
+ const home = homedir5();
2231
+ if (IS_WINDOWS2) {
2232
+ const candidates = [
2233
+ process.env.ProgramFiles && join10(process.env.ProgramFiles, "cloudflared"),
2234
+ process.env["ProgramFiles(x86)"] && join10(process.env["ProgramFiles(x86)"], "cloudflared"),
2235
+ process.env.LOCALAPPDATA && join10(process.env.LOCALAPPDATA, "Microsoft", "WinGet", "Links"),
2236
+ join10(home, "scoop", "shims"),
2237
+ process.env.ChocolateyInstall && join10(process.env.ChocolateyInstall, "bin")
2238
+ ];
2239
+ return candidates.filter((dir) => Boolean(dir));
2240
+ }
2241
+ return ["/usr/local/bin", "/usr/bin", "/opt/homebrew/bin", join10(home, ".local", "bin")];
2242
+ }
2243
+ function pathDirectories() {
2244
+ return (process.env.PATH ?? "").split(delimiter).map((dir) => dir.trim().replace(/^"|"$/g, "")).filter(Boolean);
2245
+ }
2246
+ function isExecutableFile(path) {
2247
+ try {
2248
+ accessSync(path, constants.X_OK);
2249
+ return true;
2250
+ } catch {
2251
+ return false;
2252
+ }
2253
+ }
2254
+ function findInstalledBinary() {
2255
+ const override = process.env.AGENT_BOARD_CLOUDFLARED_PATH;
2256
+ const directories = [...pathDirectories(), ...wellKnownDirectories()];
2257
+ const candidates = [...override ? [override] : [], ...directories.map((dir) => join10(dir, BINARY_NAME))];
2258
+ return candidates.find(isExecutableFile) ?? null;
2259
+ }
2260
+ async function locateCloudflared() {
2261
+ const installed = findInstalledBinary();
2262
+ if (installed) return installed;
2263
+ if (isExecutableFile(bundledBinaryPath)) return bundledBinaryPath;
2264
+ try {
2265
+ logger.runner.info("CLOUDFLARE", "Downloading the cloudflared binary (first run only)...");
2266
+ return await downloadCloudflared(bundledBinaryPath);
2267
+ } catch (err) {
2268
+ logger.runner.error("CLOUDFLARE", `Failed to download cloudflared. Install it manually: ${CLOUDFLARED_INSTALL_DOCS_URL}`, err);
2269
+ return null;
2270
+ }
2271
+ }
2272
+ var lookup = null;
2273
+ function resolveCloudflaredPath() {
2274
+ lookup ??= locateCloudflared().then((path) => {
2275
+ if (!path) lookup = null;
2276
+ return path;
2277
+ });
2278
+ return lookup;
2279
+ }
2280
+ async function startQuickTunnel(label, port) {
2281
+ const executable = await resolveCloudflaredPath();
2282
+ if (!executable) {
2283
+ logger.runner.error("CLOUDFLARE", `No cloudflared binary available, so ${label} cannot be exposed.`);
2284
+ return null;
2285
+ }
2286
+ return new Promise((resolve5) => {
2287
+ let child2;
2288
+ try {
2289
+ child2 = spawn3(executable, [
2290
+ "tunnel",
2291
+ "--url",
2292
+ `http://localhost:${port}`,
2293
+ "--http-host-header",
2294
+ `localhost:${port}`
2295
+ ]);
2296
+ } catch (err) {
2297
+ logger.runner.error("CLOUDFLARE", `Failed to spawn cloudflared for ${label}:`, err);
2298
+ resolve5(null);
2299
+ return;
2300
+ }
2301
+ let settled = false;
2302
+ const settle = (value) => {
2303
+ if (settled) return;
2304
+ settled = true;
2305
+ clearTimeout(timer);
2306
+ resolve5(value);
2307
+ };
2308
+ const timer = setTimeout(() => {
2309
+ logger.runner.warn("CLOUDFLARE", `cloudflared for ${label} did not print a trycloudflare.com URL within ${STARTUP_TIMEOUT_MS}ms.`);
2310
+ settle(null);
2311
+ }, STARTUP_TIMEOUT_MS);
2312
+ const onOutput = (chunk) => {
2313
+ if (settled) return;
2314
+ const match = chunk.toString("utf-8").match(TRYCLOUDFLARE_URL_RE);
2315
+ if (match) settle({ url: match[0], child: child2 });
2316
+ };
2317
+ child2.stdout?.on("data", onOutput);
2318
+ child2.stderr?.on("data", onOutput);
2319
+ child2.on("error", (err) => {
2320
+ logger.runner.error("CLOUDFLARE", `cloudflared process error for ${label} (${executable}):`, err);
2321
+ settle(null);
1683
2322
  });
1684
- res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
1685
- res.end();
2323
+ child2.on("exit", (code) => {
2324
+ logger.runner.info("CLOUDFLARE", `cloudflared tunnel for ${label} exited (code ${code}).`);
2325
+ settle(null);
2326
+ });
2327
+ });
2328
+ }
2329
+ function toTunnelHostname(url) {
2330
+ return new URL(url).host;
2331
+ }
2332
+
2333
+ // src/devServer.ts
2334
+ var STARTUP_TIMEOUT_MS2 = 45e3;
2335
+ var IS_WINDOWS3 = process.platform === "win32";
2336
+ var LOCAL_URL_PORT_RE = /https?:\/\/(?:localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0):(\d{2,5})/i;
2337
+ var GENERIC_PORT_RE = /(?:listening|running|server).{0,40}?:?\s*(\d{4,5})\b/i;
2338
+ var ANSI_ESCAPE_RE2 = /\x1b\[[0-9;]*m/g;
2339
+ function detectPortFromOutput(chunk) {
2340
+ const clean = chunk.replace(ANSI_ESCAPE_RE2, "");
2341
+ const match = LOCAL_URL_PORT_RE.exec(clean) ?? GENERIC_PORT_RE.exec(clean);
2342
+ return match ? Number(match[1]) : null;
2343
+ }
2344
+ function killProcessTree(child2) {
2345
+ if (child2.pid == null) return;
2346
+ if (IS_WINDOWS3) {
2347
+ spawn4("taskkill", ["/pid", String(child2.pid), "/t", "/f"], { stdio: "ignore" });
2348
+ } else {
2349
+ child2.kill();
2350
+ }
2351
+ }
2352
+ var previews = /* @__PURE__ */ new Map();
2353
+ function ensurePreview(key, label, cwd, command2, cardId) {
2354
+ const existing = previews.get(key);
2355
+ if (existing) {
2356
+ existing.cardIds.add(cardId);
2357
+ return existing.result;
2358
+ }
2359
+ let child2;
2360
+ try {
2361
+ child2 = spawn4(command2, { cwd, shell: true, windowsHide: true });
1686
2362
  } catch (err) {
1687
- logger.runner.error("STATUSLINE", `Failed to persist Claude usage from session ${body.sessionId}:`, err);
1688
- sendJson(res, 500, { error: "Erro interno do runner." });
2363
+ logger.runner.error("DEV_SERVER", `Failed to spawn "${command2}" in ${cwd}:`, err);
2364
+ return Promise.resolve(null);
2365
+ }
2366
+ const entry = { child: child2, tunnelChild: null, cardIds: /* @__PURE__ */ new Set([cardId]), result: null };
2367
+ entry.result = new Promise((resolve5) => {
2368
+ let settled = false;
2369
+ const settle = (value) => {
2370
+ if (settled) return;
2371
+ settled = true;
2372
+ clearTimeout(timer);
2373
+ if (!value) {
2374
+ killProcessTree(child2);
2375
+ previews.delete(key);
2376
+ }
2377
+ resolve5(value);
2378
+ };
2379
+ const timer = setTimeout(() => {
2380
+ logger.runner.warn("DEV_SERVER", `"${command2}" in ${cwd} did not reveal a port within ${STARTUP_TIMEOUT_MS2}ms.`);
2381
+ settle(null);
2382
+ }, STARTUP_TIMEOUT_MS2);
2383
+ const onOutput = async (chunk) => {
2384
+ if (settled) return;
2385
+ const port = detectPortFromOutput(chunk.toString("utf-8"));
2386
+ if (port == null) return;
2387
+ settled = true;
2388
+ clearTimeout(timer);
2389
+ const tunnel = await startQuickTunnel(`dev server ${key}`, port);
2390
+ if (!tunnel) {
2391
+ logger.runner.warn("DEV_SERVER", `Detected port ${port} for ${key} but cloudflared could not tunnel it.`);
2392
+ killProcessTree(child2);
2393
+ previews.delete(key);
2394
+ resolve5(null);
2395
+ return;
2396
+ }
2397
+ entry.tunnelChild = tunnel.child;
2398
+ logger.runner.info("DEV_SERVER", `${key} is up on port ${port}, tunneled at ${tunnel.url}.`);
2399
+ resolve5({ label, url: tunnel.url });
2400
+ };
2401
+ child2.stdout?.on("data", onOutput);
2402
+ child2.stderr?.on("data", onOutput);
2403
+ child2.on("error", (err) => {
2404
+ logger.runner.error("DEV_SERVER", `Dev server process error for ${key} (${command2} in ${cwd}):`, err);
2405
+ settle(null);
2406
+ });
2407
+ child2.on("exit", (code) => {
2408
+ logger.runner.info("DEV_SERVER", `Dev server for ${key} exited (code ${code}) before/without a usable preview.`);
2409
+ settle(null);
2410
+ });
2411
+ });
2412
+ previews.set(key, entry);
2413
+ return entry.result;
2414
+ }
2415
+ function releaseCard(cardId) {
2416
+ for (const [key, entry] of previews) {
2417
+ if (!entry.cardIds.delete(cardId)) continue;
2418
+ if (entry.cardIds.size > 0) continue;
2419
+ killProcessTree(entry.child);
2420
+ entry.tunnelChild?.kill();
2421
+ previews.delete(key);
2422
+ }
2423
+ }
2424
+ function stopProjectPreviews(projectId) {
2425
+ const prefix = `${projectId}:`;
2426
+ for (const [key, entry] of previews) {
2427
+ if (!key.startsWith(prefix)) continue;
2428
+ killProcessTree(entry.child);
2429
+ entry.tunnelChild?.kill();
2430
+ previews.delete(key);
2431
+ }
2432
+ }
2433
+ function stopAll() {
2434
+ for (const key of [...previews.keys()]) {
2435
+ const entry = previews.get(key);
2436
+ if (!entry) continue;
2437
+ killProcessTree(entry.child);
2438
+ entry.tunnelChild?.kill();
2439
+ previews.delete(key);
2440
+ }
2441
+ }
2442
+
2443
+ // src/git.ts
2444
+ import { execFile as execFile3 } from "node:child_process";
2445
+ import { promisify as promisify4 } from "node:util";
2446
+ var execFileAsync3 = promisify4(execFile3);
2447
+ async function pushBranchToMain(repoPath) {
2448
+ try {
2449
+ await execFileAsync3("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
2450
+ return { ok: true };
2451
+ } catch (err) {
2452
+ const stderr = err.stderr;
2453
+ const error = stderr?.trim() || (err instanceof Error ? err.message : String(err));
2454
+ return { ok: false, error };
2455
+ }
2456
+ }
2457
+
2458
+ // src/orchestrator.ts
2459
+ import { randomUUID as randomUUID7 } from "node:crypto";
2460
+
2461
+ // src/agentSession.ts
2462
+ import { randomUUID as randomUUID6 } from "node:crypto";
2463
+ import { spawn as spawn5 } from "node-pty";
2464
+
2465
+ // src/sessionName.ts
2466
+ var MAX_LENGTH = 60;
2467
+ var FALLBACK_NAME = "card";
2468
+ function toSessionName(title) {
2469
+ const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2470
+ if (slug.length === 0) return FALLBACK_NAME;
2471
+ if (slug.length <= MAX_LENGTH) return slug;
2472
+ const truncated = slug.slice(0, MAX_LENGTH);
2473
+ const lastHyphen = truncated.lastIndexOf("-");
2474
+ return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
2475
+ }
2476
+
2477
+ // src/agentSession.ts
2478
+ var TUI_IDLE_MS = 1500;
2479
+ var TUI_READY_TIMEOUT_MS = 6e4;
2480
+ var SUBMIT_DELAY_MS = 150;
2481
+ var PASTE_START = "\x1B[200~";
2482
+ var PASTE_END = "\x1B[201~";
2483
+ var SHIFT_TAB = "\x1B[Z";
2484
+ var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
2485
+ var SHIFT_TAB_DELAY_MS = 400;
2486
+ var PLAN_MODE_INDICATOR = /plan mode on/i;
2487
+ function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2488
+ const claudeSessionId = randomUUID6();
2489
+ const pty = spawn5(
2490
+ CLAUDE_BINARY,
2491
+ [
2492
+ "--dangerously-skip-permissions",
2493
+ "--model",
2494
+ "claude-sonnet-5",
2495
+ "--session-id",
2496
+ claudeSessionId,
2497
+ ...claudeCliSettingsArgs(),
2498
+ `/rename ${toSessionName(cardTitle)}`
2499
+ ],
2500
+ {
2501
+ name: "xterm-color",
2502
+ cols: 120,
2503
+ rows: 32,
2504
+ cwd: repoPath,
2505
+ env: statuslineEnv(sessionId, ticket)
2506
+ }
2507
+ );
2508
+ sendMessageWhenIdle(pty, cardId, instructions, planFirst);
2509
+ return { pty, claudeSessionId };
2510
+ }
2511
+ function sendMessageWhenIdle(pty, cardId, message, planFirst) {
2512
+ let idleTimer;
2513
+ let deadlineTimer;
2514
+ let dataSubscription;
2515
+ let settled = false;
2516
+ const stopWaiting = () => {
2517
+ if (settled) return false;
2518
+ settled = true;
2519
+ clearTimeout(idleTimer);
2520
+ clearTimeout(deadlineTimer);
2521
+ dataSubscription?.dispose();
2522
+ return true;
2523
+ };
2524
+ const send = async () => {
2525
+ if (!stopWaiting()) return;
2526
+ if (planFirst) await enterPlanMode(pty, cardId);
2527
+ try {
2528
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2529
+ setTimeout(() => {
2530
+ try {
2531
+ pty.write("\r");
2532
+ } catch (err) {
2533
+ logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
2534
+ }
2535
+ }, SUBMIT_DELAY_MS);
2536
+ } catch (err) {
2537
+ logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
2538
+ }
2539
+ };
2540
+ deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
2541
+ dataSubscription = pty.onData(() => {
2542
+ clearTimeout(idleTimer);
2543
+ idleTimer = setTimeout(send, TUI_IDLE_MS);
2544
+ });
2545
+ pty.onExit(() => stopWaiting());
2546
+ }
2547
+ async function enterPlanMode(pty, cardId) {
2548
+ let recentOutput = "";
2549
+ const subscription = pty.onData((chunk) => {
2550
+ recentOutput += normalizeTerminalOutput(chunk);
2551
+ });
2552
+ try {
2553
+ for (let attempt = 1; attempt <= SHIFT_TAB_PRESSES_TO_PLAN_MODE; attempt++) {
2554
+ pty.write(SHIFT_TAB);
2555
+ await new Promise((resolve5) => setTimeout(resolve5, SHIFT_TAB_DELAY_MS));
2556
+ }
2557
+ if (!PLAN_MODE_INDICATOR.test(recentOutput)) {
2558
+ logger.card.warn(
2559
+ cardId,
2560
+ `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.`
2561
+ );
2562
+ }
2563
+ } catch (err) {
2564
+ logger.card.error(cardId, "Failed to switch the session into Plan Mode:", err);
2565
+ } finally {
2566
+ subscription.dispose();
2567
+ }
2568
+ }
2569
+ function sendContinue(pty, cardId) {
2570
+ try {
2571
+ pty.write(`${PASTE_START}continue${PASTE_END}`);
2572
+ setTimeout(() => {
2573
+ try {
2574
+ pty.write("\r");
2575
+ } catch (err) {
2576
+ logger.card.error(cardId, 'Failed to submit the auto-resume "continue" message:', err);
2577
+ }
2578
+ }, SUBMIT_DELAY_MS);
2579
+ } catch (err) {
2580
+ logger.card.error(cardId, 'Failed to send the auto-resume "continue" message:', err);
2581
+ }
2582
+ }
2583
+ function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
2584
+ return spawn5(
2585
+ CLAUDE_BINARY,
2586
+ [
2587
+ "--dangerously-skip-permissions",
2588
+ "--model",
2589
+ "claude-sonnet-5",
2590
+ "--resume",
2591
+ claudeSessionId,
2592
+ ...claudeCliSettingsArgs()
2593
+ ],
2594
+ {
2595
+ name: "xterm-color",
2596
+ cols: 120,
2597
+ rows: 32,
2598
+ cwd: repoPath,
2599
+ env: statuslineEnv(sessionId, ticket)
2600
+ }
2601
+ );
2602
+ }
2603
+
2604
+ // src/cardAttachments.ts
2605
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
2606
+ import { tmpdir as tmpdir6 } from "node:os";
2607
+ import { extname, join as join11 } from "node:path";
2608
+ var ATTACHMENTS_ROOT = join11(tmpdir6(), "agent-board", "card-attachments");
2609
+ function sanitizeFileName(fileName) {
2610
+ return fileName.replace(/[^a-zA-Z0-9._-]/g, "_");
2611
+ }
2612
+ async function downloadCardAttachments(supabase, cardId) {
2613
+ const { data, error } = await supabase.from("card_attachments").select("id, storage_path, file_name").eq("card_id", cardId).order("created_at", { ascending: true });
2614
+ if (error) {
2615
+ logger.card.error(cardId, "Failed to list attachments:", error.message);
2616
+ return [];
2617
+ }
2618
+ const rows = data ?? [];
2619
+ if (rows.length === 0) return [];
2620
+ const targetDir = join11(ATTACHMENTS_ROOT, cardId);
2621
+ await mkdir2(targetDir, { recursive: true });
2622
+ const paths = [];
2623
+ for (const row of rows) {
2624
+ const baseName = sanitizeFileName(row.file_name);
2625
+ const fileName = extname(baseName) ? baseName : `${baseName}${extname(row.storage_path)}`;
2626
+ const localPath = join11(targetDir, `${row.id}-${fileName}`);
2627
+ try {
2628
+ const { data: blob, error: downloadError } = await supabase.storage.from(CARD_ATTACHMENTS_BUCKET).download(row.storage_path);
2629
+ if (downloadError) throw downloadError;
2630
+ await writeFile2(localPath, Buffer.from(await blob.arrayBuffer()));
2631
+ paths.push(localPath);
2632
+ } catch (err) {
2633
+ logger.card.error(cardId, `Failed to download attachment ${row.storage_path}:`, err);
2634
+ }
2635
+ }
2636
+ return paths;
2637
+ }
2638
+ function buildInstructionsWithAttachments(instructions, attachmentPaths) {
2639
+ if (attachmentPaths.length === 0) return instructions;
2640
+ const list = attachmentPaths.map((path) => `- ${path}`).join("\n");
2641
+ return `${instructions}
2642
+
2643
+ Files attached to this card (use each one as part of the task):
2644
+ ${list}`;
2645
+ }
2646
+
2647
+ // src/fileReferences.ts
2648
+ function buildInstructionsWithFileReferences(instructions, fileReferences) {
2649
+ if (fileReferences.length === 0) return instructions;
2650
+ const list = fileReferences.map((path) => `- ${path}`).join("\n");
2651
+ return `${instructions}
2652
+
2653
+ Files/folders referenced on this card (read them before starting \u2014 they are part of the task):
2654
+ ${list}`;
2655
+ }
2656
+
2657
+ // src/mcpContextNote.ts
2658
+ function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2659
+ return `${instructions}
2660
+
2661
+ Agent Board MCP: this card's id is \`${cardId}\` and its project id is \`${projectId}\` \u2014 pass these directly to board tools (\`update_card\`, \`create_card\`, \`add_card_comment\`, etc.), no lookup needed. If this task turns out too large for one session, use \`create_card\` to split it into subtask cards (chain them with \`prerequisiteCardId\` when one depends on another) instead of trying to fit everything here. Before finishing, post a short summary of what you did as a comment on this card via \`add_card_comment\` so the project's humans have visibility without opening this terminal.`;
2662
+ }
2663
+
2664
+ // src/usageResume.ts
2665
+ var RESET_BUFFER_MS = 3e4;
2666
+ var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
2667
+ var pending = /* @__PURE__ */ new Map();
2668
+ function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
2669
+ if (pending.has(sessionId)) return;
2670
+ void (async () => {
2671
+ const usage = await getClaudeUsage(supabase, config.ownerUserId).catch((err) => {
2672
+ logger.card.error(cardId, "Failed to read claude_usage for usage-limit resume:", err);
2673
+ return null;
2674
+ });
2675
+ const now = Date.now();
2676
+ const candidates = [usage?.sessionResetsAt, usage?.weekResetsAt].map((iso) => iso ? new Date(iso).getTime() : null).filter((ms) => ms !== null && ms > now);
2677
+ const resetAt = candidates.length > 0 ? Math.min(...candidates) : null;
2678
+ const delay = resetAt !== null ? resetAt - now + RESET_BUFFER_MS : UNKNOWN_RESET_RETRY_MS;
2679
+ logger.card.info(
2680
+ cardId,
2681
+ 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.`
2682
+ );
2683
+ const timer = setTimeout(() => {
2684
+ pending.delete(sessionId);
2685
+ sendContinue(pty, cardId);
2686
+ }, delay);
2687
+ timer.unref();
2688
+ pending.set(sessionId, timer);
2689
+ })();
2690
+ }
2691
+ function cancelUsageResume(sessionId) {
2692
+ const timer = pending.get(sessionId);
2693
+ if (!timer) return;
2694
+ clearTimeout(timer);
2695
+ pending.delete(sessionId);
2696
+ }
2697
+
2698
+ // src/orchestrator.ts
2699
+ var TERMINAL_TICKET_TTL_MS = 60 * 60 * 1e3;
2700
+ var inFlightCardIds = /* @__PURE__ */ new Set();
2701
+ var activeSessionByCard = /* @__PURE__ */ new Map();
2702
+ var intentionallyStoppedSessionIds = /* @__PURE__ */ new Set();
2703
+ var dispatchingRunner = false;
2704
+ async function scanForWork(supabase, config) {
2705
+ for (const project of config.registeredProjects) {
2706
+ await reconcileStaleSessions(supabase, config, project.id);
2707
+ const { data: cards, error } = await supabase.from("cards").select("*").eq("project_id", project.id).eq("column_name", "desenvolvimento").eq("assigned_user_id", config.ownerUserId);
2708
+ if (error) {
2709
+ logger.runner.error("ORCHESTRATOR", `Failed to scan cards for project ${project.id}:`, error.message);
2710
+ continue;
2711
+ }
2712
+ for (const card of cards ?? []) {
2713
+ void enqueueCard(supabase, config, card);
2714
+ }
2715
+ }
2716
+ void dispatchRunnerQueue(supabase, config);
2717
+ }
2718
+ async function enqueueCard(supabase, config, card) {
2719
+ if (inFlightCardIds.has(card.id)) return;
2720
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
2721
+ if (!registered) {
2722
+ logger.card.warn(card.id, `Ignoring card: project ${card.project_id} is not registered on this runner.`);
2723
+ return;
2724
+ }
2725
+ if (card.assigned_user_id !== config.ownerUserId) return;
2726
+ inFlightCardIds.add(card.id);
2727
+ try {
2728
+ const active = await getActiveSessionForCard(supabase, card.id);
2729
+ if (active) return;
2730
+ await createQueuedSession(supabase, config, card);
2731
+ } finally {
2732
+ inFlightCardIds.delete(card.id);
2733
+ }
2734
+ await dispatchRunnerQueue(supabase, config);
2735
+ }
2736
+ async function createQueuedSession(supabase, config, card) {
2737
+ const resumeClaudeSessionId = await getLastClaudeSessionIdForCard(supabase, card.id);
2738
+ const sessionId = await createSession(supabase, config.ownerUserId, card.id, resumeClaudeSessionId);
2739
+ logger.card.info(
2740
+ card.id,
2741
+ resumeClaudeSessionId ? `"${card.title}" queued (session ${sessionId}, will resume prior conversation ${resumeClaudeSessionId}).` : `"${card.title}" queued (session ${sessionId}).`
2742
+ );
2743
+ }
2744
+ async function reconcileStaleSessions(supabase, config, projectId) {
2745
+ const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, column_name, assigned_user_id)").eq("cards.project_id", projectId).eq("cards.assigned_user_id", config.ownerUserId).in("status", ["queued", "running", "waiting_input", "paused"]).neq("cards.column_name", "desenvolvimento");
2746
+ if (error) {
2747
+ logger.runner.error("ORCHESTRATOR", `Failed to scan stale sessions for project ${projectId}:`, error.message);
2748
+ return;
2749
+ }
2750
+ const stale = data ?? [];
2751
+ if (stale.length === 0) return;
2752
+ for (const { id: sessionId, card_id: cardId } of stale) {
2753
+ intentionallyStoppedSessionIds.add(sessionId);
2754
+ stopPtySession(sessionId);
2755
+ cancelUsageResume(sessionId);
2756
+ activeSessionByCard.delete(cardId);
2757
+ await updateSession(
2758
+ supabase,
2759
+ sessionId,
2760
+ { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
2761
+ { projectId, cardId, userId: config.ownerUserId }
2762
+ );
2763
+ logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
2764
+ }
2765
+ await dispatchRunnerQueue(supabase, config);
2766
+ }
2767
+ async function dispatchRunnerQueue(supabase, config) {
2768
+ if (dispatchingRunner) return;
2769
+ dispatchingRunner = true;
2770
+ try {
2771
+ const queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
2772
+ if (queued.length === 0) return;
2773
+ const maxConcurrentProjects = await getRunnerMaxConcurrentProjects(supabase, config.runnerId);
2774
+ const runningProjectIds = /* @__PURE__ */ new Set();
2775
+ for (const project of config.registeredProjects) {
2776
+ const runningCount = await countRunningSessions(supabase, project.id, config.ownerUserId);
2777
+ if (runningCount > 0) runningProjectIds.add(project.id);
2778
+ }
2779
+ let freeProjectSlots = maxConcurrentProjects - runningProjectIds.size;
2780
+ if (freeProjectSlots <= 0) return;
2781
+ const promotedThisPass = /* @__PURE__ */ new Set();
2782
+ for (const entry of queued) {
2783
+ const projectId = entry.cards.project_id;
2784
+ if (runningProjectIds.has(projectId) || promotedThisPass.has(projectId)) continue;
2785
+ if (freeProjectSlots <= 0) break;
2786
+ try {
2787
+ await promoteQueuedSession(supabase, config, entry);
2788
+ promotedThisPass.add(projectId);
2789
+ freeProjectSlots -= 1;
2790
+ } catch (err) {
2791
+ logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
2792
+ }
2793
+ }
2794
+ } finally {
2795
+ dispatchingRunner = false;
2796
+ }
2797
+ }
2798
+ async function promoteQueuedSession(supabase, config, entry) {
2799
+ const existingHandle = getPtySession(entry.id);
2800
+ if (existingHandle) {
2801
+ setPtySessionReadOnly(entry.id, false);
2802
+ await updateSession(
2803
+ supabase,
2804
+ entry.id,
2805
+ { status: "running" },
2806
+ { projectId: entry.cards.project_id, cardId: entry.card_id, userId: config.ownerUserId }
2807
+ );
2808
+ activeSessionByCard.set(entry.card_id, entry.id);
2809
+ logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
2810
+ return;
1689
2811
  }
2812
+ const spawned = await spawnSessionPty(supabase, config, entry);
2813
+ if (spawned) logger.card.info(entry.card_id, "Promoted from queue.");
1690
2814
  }
1691
- async function handleBranchRequest(entry, res) {
2815
+ async function spawnSessionPty(supabase, config, entry) {
2816
+ const { id: sessionId, card_id: cardId, cards: card, claude_session_id: claudeSessionId } = entry;
2817
+ const registered = config.registeredProjects.find((project) => project.id === card.project_id);
2818
+ if (!registered) {
2819
+ logger.card.error(cardId, `Session ${sessionId}: project ${card.project_id} is not registered \u2014 skipping.`);
2820
+ await updateSession(
2821
+ supabase,
2822
+ sessionId,
2823
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
2824
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2825
+ );
2826
+ return false;
2827
+ }
2828
+ const ticket = randomUUID7();
2829
+ let pty;
2830
+ let newClaudeSessionId = null;
1692
2831
  try {
1693
- const { stdout } = await execFileAsync2("git", ["branch", "--show-current"], { cwd: entry.repoRoot });
1694
- sendJson(res, 200, { branch: stdout.trim() });
2832
+ if (claudeSessionId) {
2833
+ pty = resumeAgentPty(registered.repoPath, claudeSessionId, sessionId, ticket);
2834
+ } else {
2835
+ const attachmentPaths = await downloadCardAttachments(supabase, cardId);
2836
+ const instructions = buildInstructionsWithMcpContext(
2837
+ buildInstructionsWithFileReferences(
2838
+ buildInstructionsWithAttachments(card.instructions, attachmentPaths),
2839
+ card.file_references
2840
+ ),
2841
+ cardId,
2842
+ card.project_id
2843
+ );
2844
+ const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
2845
+ pty = spawned.pty;
2846
+ newClaudeSessionId = spawned.claudeSessionId;
2847
+ }
1695
2848
  } catch (err) {
1696
- logger.runner.error("FILES", "Unexpected error handling a branch request:", err);
1697
- sendJson(res, 500, { error: "Erro interno do runner." });
2849
+ logger.card.error(cardId, `Failed to spawn pty for session ${sessionId}:`, err);
2850
+ await updateSession(
2851
+ supabase,
2852
+ sessionId,
2853
+ { status: "failed", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
2854
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2855
+ );
2856
+ return false;
1698
2857
  }
2858
+ registerPtySession(sessionId, pty, ticket);
2859
+ activeSessionByCard.set(cardId, sessionId);
2860
+ publishAgentStateEvent(card.project_id, cardId, sessionId, newClaudeSessionId ? "starting" : entry.agent_state ?? "starting");
2861
+ const activityWatcher = watchAgentActivity(supabase, config, card.project_id, cardId, sessionId, pty);
2862
+ await updateSession(
2863
+ supabase,
2864
+ sessionId,
2865
+ {
2866
+ status: "running",
2867
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2868
+ terminalTicket: ticket,
2869
+ terminalTicketExpiresAt: new Date(Date.now() + TERMINAL_TICKET_TTL_MS).toISOString(),
2870
+ // Only stamp a fresh spawn as "starting" — a resumed session (claudeSessionId was
2871
+ // already set) keeps whatever agent_state it had before the restart (e.g.
2872
+ // "usage_limited"), so reconcileUsageLimitedSessions can find it afterwards. The
2873
+ // freshly (re)constructed AgentActivityWatcher below will correct it the moment real
2874
+ // output gives it something conclusive to classify.
2875
+ ...newClaudeSessionId ? { agentState: "starting", claudeSessionId: newClaudeSessionId } : {}
2876
+ },
2877
+ { projectId: card.project_id, cardId, userId: config.ownerUserId }
2878
+ );
2879
+ pty.onExit(({ exitCode }) => {
2880
+ const wasIntentional = intentionallyStoppedSessionIds.delete(sessionId);
2881
+ 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.`);
2882
+ activityWatcher.stop();
2883
+ unregisterAgentActivityWatcher(sessionId);
2884
+ cancelUsageResume(sessionId);
2885
+ activeSessionByCard.delete(cardId);
2886
+ if (!wasIntentional && exitCode !== 0) {
2887
+ publishAgentState(supabase, card.project_id, cardId, sessionId, "error");
2888
+ }
2889
+ });
2890
+ return true;
1699
2891
  }
1700
- function handleRequest(req, res) {
1701
- if (req.method === "OPTIONS") {
1702
- res.writeHead(204, { "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, POST, OPTIONS" });
1703
- res.end();
1704
- return;
2892
+ function publishAgentState(supabase, projectId, cardId, sessionId, state) {
2893
+ publishAgentStateEvent(projectId, cardId, sessionId, state);
2894
+ updateSession(supabase, sessionId, { agentState: state }).catch(
2895
+ (err) => logger.card.error(cardId, `Failed to persist agent state "${state}" for session ${sessionId}:`, err)
2896
+ );
2897
+ }
2898
+ function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty) {
2899
+ const watcher = new AgentActivityWatcher((state) => {
2900
+ publishAgentState(supabase, projectId, cardId, sessionId, state);
2901
+ if (state === "usage_limited") {
2902
+ scheduleUsageResume(supabase, config, cardId, sessionId, pty);
2903
+ } else {
2904
+ cancelUsageResume(sessionId);
2905
+ }
2906
+ if (state === "done") void pauseFinishedSession(supabase, config, projectId, cardId, sessionId);
2907
+ });
2908
+ pty.onData((chunk) => watcher.push(chunk));
2909
+ registerAgentActivityWatcher(sessionId, watcher);
2910
+ return watcher;
2911
+ }
2912
+ async function pauseFinishedSession(supabase, config, projectId, cardId, sessionId) {
2913
+ const paused = await pauseRunningSession(supabase, sessionId, { projectId, cardId, userId: config.ownerUserId });
2914
+ if (!paused) return;
2915
+ setPtySessionReadOnly(sessionId, true);
2916
+ logger.card.info(cardId, `Session ${sessionId} paused (agent reached "done") \u2014 WIP slot freed, pty stays viewable read-only.`);
2917
+ await dispatchRunnerQueue(supabase, config);
2918
+ }
2919
+ async function requeuePausedCard(supabase, config, card) {
2920
+ const active = await getActiveSessionForCard(supabase, card.id);
2921
+ if (!active || active.status !== "paused") return;
2922
+ await updateSession(
2923
+ supabase,
2924
+ active.id,
2925
+ { status: "queued" },
2926
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
2927
+ );
2928
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
2929
+ logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
2930
+ await dispatchRunnerQueue(supabase, config);
2931
+ }
2932
+ async function reconcileOrphanedSessions(supabase, config) {
2933
+ for (const project of config.registeredProjects) {
2934
+ const { data, error } = await supabase.from("sessions").select(
2935
+ "id, card_id, claude_session_id, agent_state, created_at, status, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first)"
2936
+ ).eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).in("status", ["running", "paused"]);
2937
+ if (error) {
2938
+ logger.runner.error("ORCHESTRATOR", `Failed to scan orphaned sessions for project ${project.id}:`, error.message);
2939
+ continue;
2940
+ }
2941
+ for (const entry of data ?? []) {
2942
+ let spawned;
2943
+ try {
2944
+ spawned = await spawnSessionPty(supabase, config, entry);
2945
+ } catch (err) {
2946
+ logger.card.error(entry.card_id, `Failed to re-spawn orphaned session ${entry.id}:`, err);
2947
+ continue;
2948
+ }
2949
+ if (!spawned) continue;
2950
+ if (entry.status === "paused") {
2951
+ await updateSession(
2952
+ supabase,
2953
+ entry.id,
2954
+ { status: "paused" },
2955
+ { projectId: project.id, cardId: entry.card_id, userId: config.ownerUserId }
2956
+ );
2957
+ setPtySessionReadOnly(entry.id, true);
2958
+ logger.card.info(entry.card_id, "Re-spawned paused session (runner restarted) \u2014 still read-only until requeued.");
2959
+ } else {
2960
+ logger.card.info(entry.card_id, "Re-spawned orphaned session (runner restarted since it was last running).");
2961
+ }
2962
+ }
1705
2963
  }
1706
- const { pathname, query } = parse(req.url ?? "", true);
1707
- if (req.method === "POST" && pathname === "/statusline") {
1708
- if (!statuslineSupabase || !statuslineConfig) {
1709
- sendJson(res, 500, { error: "Runner ainda n\xE3o inicializado." });
1710
- return;
2964
+ }
2965
+ async function reconcileUsageLimitedSessions(supabase, config) {
2966
+ for (const project of config.registeredProjects) {
2967
+ const { data, error } = await supabase.from("sessions").select("id, card_id, cards!card_id!inner(project_id, assigned_user_id)").eq("cards.project_id", project.id).eq("cards.assigned_user_id", config.ownerUserId).eq("status", "running").eq("agent_state", "usage_limited");
2968
+ if (error) {
2969
+ logger.runner.error("ORCHESTRATOR", `Failed to scan usage-limited sessions for project ${project.id}:`, error.message);
2970
+ continue;
1711
2971
  }
1712
- void handleStatuslineReport(req, res, statuslineSupabase, statuslineConfig);
2972
+ for (const { id: sessionId, card_id: cardId } of data ?? []) {
2973
+ const handle = getPtySession(sessionId);
2974
+ if (!handle) continue;
2975
+ scheduleUsageResume(supabase, config, cardId, sessionId, handle.pty);
2976
+ }
2977
+ }
2978
+ }
2979
+ async function stopCardSession(supabase, config, card) {
2980
+ const sessionId = activeSessionByCard.get(card.id) ?? (await getActiveSessionForCard(supabase, card.id))?.id;
2981
+ if (!sessionId) return;
2982
+ intentionallyStoppedSessionIds.add(sessionId);
2983
+ stopPtySession(sessionId);
2984
+ cancelUsageResume(sessionId);
2985
+ activeSessionByCard.delete(card.id);
2986
+ await updateSession(
2987
+ supabase,
2988
+ sessionId,
2989
+ { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
2990
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
2991
+ );
2992
+ logger.card.info(card.id, `Session ${sessionId} stopped.`);
2993
+ await dispatchRunnerQueue(supabase, config);
2994
+ }
2995
+
2996
+ // src/deploy.ts
2997
+ async function handleDeployRequested(supabase, config, project) {
2998
+ const registered = config.registeredProjects.find((candidate) => candidate.id === project.id);
2999
+ if (!registered) {
3000
+ logger.runner.warn("DEPLOY", `Ignoring deploy request: project ${project.id} is not registered on this runner.`);
1713
3001
  return;
1714
3002
  }
1715
- if (req.method !== "GET") {
1716
- sendJson(res, 405, { error: "M\xE9todo n\xE3o suportado." });
3003
+ const allCards = await listCardsInColumn(supabase, project.id, "deploy");
3004
+ const cards = allCards.filter((card) => card.assigned_user_id === config.ownerUserId);
3005
+ if (cards.length === 0) {
3006
+ logger.runner.info("DEPLOY", `Deploy requested for project ${project.id} but no cards assigned to this runner's user are waiting in "deploy".`);
3007
+ await clearProjectDeployRequest(supabase, project.id);
1717
3008
  return;
1718
3009
  }
1719
- const relPath = typeof query.path === "string" ? query.path : "";
1720
- if (pathname === "/browse") {
1721
- const setupEntry = authenticateSetup(query);
1722
- if (!setupEntry) {
1723
- sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou navega\xE7\xE3o de pastas desativada." });
1724
- return;
1725
- }
1726
- try {
1727
- sendJson(res, 200, { entries: listDirectoryForBrowse(setupEntry.rootPath, relPath) });
1728
- } catch (err) {
1729
- if (err instanceof HttpError) {
1730
- sendJson(res, err.status, { error: err.message });
1731
- return;
1732
- }
1733
- logger.runner.error("FILES", "Unexpected error handling a browse request:", err);
1734
- sendJson(res, 500, { error: "Erro interno do runner." });
3010
+ const children = await getProjectRepos(supabase, project.id);
3011
+ const repoRoot = resolveRepoPath(config, project.repo_path);
3012
+ const targets = children.length > 0 ? children.map((child2) => ({ label: child2.subpath, path: join12(repoRoot, child2.subpath) })) : [{ label: null, path: repoRoot }];
3013
+ const failures = [];
3014
+ for (const target of targets) {
3015
+ const result = await pushBranchToMain(target.path);
3016
+ if (result.ok) {
3017
+ logger.runner.info("DEPLOY", target.label ? `Pushed HEAD to main in "${target.label}".` : "Pushed HEAD to main.");
3018
+ } else {
3019
+ logger.runner.error("DEPLOY", target.label ? `Push to main failed in "${target.label}": ${result.error}` : `Push to main failed: ${result.error}`);
3020
+ failures.push(target.label ? `${target.label}: ${result.error}` : result.error);
1735
3021
  }
1736
- return;
1737
3022
  }
1738
- const entry = authenticate(query);
1739
- if (!entry) {
1740
- sendJson(res, 401, { error: "Ticket inv\xE1lido, expirado, ou projeto desconhecido." });
3023
+ if (failures.length > 0) {
3024
+ const message = failures.join("; ");
3025
+ await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: message })));
3026
+ await clearProjectDeployRequest(supabase, project.id);
1741
3027
  return;
1742
3028
  }
1743
- if (pathname === "/branch") {
1744
- void handleBranchRequest(entry, res);
3029
+ const columnActionResult = await runAfterColumnActions(project, children, repoRoot, "deploy");
3030
+ if (!columnActionResult.ok) {
3031
+ logger.runner.error("DEPLOY", `Deploy column action failed for project ${project.id}: ${columnActionResult.error}`);
3032
+ await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: columnActionResult.error })));
3033
+ await clearProjectDeployRequest(supabase, project.id);
1745
3034
  return;
1746
3035
  }
1747
- try {
1748
- if (pathname === "/tree") {
1749
- sendJson(res, 200, { entries: listDirectory(entry.repoRoot, relPath) });
1750
- return;
1751
- }
1752
- if (pathname === "/file") {
1753
- sendJson(res, 200, readTextFile(entry.repoRoot, relPath));
1754
- return;
1755
- }
1756
- if (pathname === "/logo") {
1757
- if (!relPath) throw new HttpError(404, "Nenhuma logo configurada para este projeto.");
1758
- const { buffer, contentType } = readImageFile(entry.repoRoot, relPath);
1759
- res.writeHead(200, {
1760
- "Content-Type": contentType,
1761
- "Content-Length": buffer.length,
1762
- "Cache-Control": "no-cache",
1763
- "Access-Control-Allow-Origin": "*"
1764
- });
1765
- res.end(buffer);
1766
- return;
1767
- }
1768
- if (pathname === "/shell/open") {
1769
- sendJson(res, 200, openShellSession(entry.repoRoot));
1770
- return;
1771
- }
1772
- sendJson(res, 404, { error: "Rota desconhecida." });
1773
- } catch (err) {
1774
- if (err instanceof HttpError) {
1775
- sendJson(res, err.status, { error: err.message });
1776
- return;
3036
+ await Promise.all(cards.map((card) => updateCard(supabase, card.id, { deployError: null, column: "fim" })));
3037
+ await clearProjectDeployRequest(supabase, project.id);
3038
+ }
3039
+ async function checkPendingDeploys(supabase, config) {
3040
+ for (const registered of config.registeredProjects) {
3041
+ const project = await getProject(supabase, registered.id);
3042
+ if (project.deploy_requested_at) {
3043
+ await handleDeployRequested(supabase, config, project);
1777
3044
  }
1778
- logger.runner.error("FILES", "Unexpected error handling a files request:", err);
1779
- sendJson(res, 500, { error: "Erro interno do runner." });
1780
3045
  }
1781
3046
  }
1782
- function attachFilesServer(httpServer, supabase, config) {
1783
- statuslineSupabase = supabase;
1784
- statuslineConfig = config;
1785
- httpServer.on("request", handleRequest);
1786
- logger.runner.info("FILES", "Files HTTP handler attached.");
3047
+ async function handleCardReachedFim(supabase, config, card) {
3048
+ await stopCardSession(supabase, config, card);
3049
+ releaseCard(card.id);
3050
+ logger.card.info(card.id, 'Reached "fim" \u2014 session stopped, preview tunnel (if any) closed.');
1787
3051
  }
1788
3052
 
1789
3053
  // src/repoValidation.ts
@@ -1879,7 +3143,7 @@ async function syncRegisteredProjects(supabase, config, registeredPaths) {
1879
3143
  }
1880
3144
 
1881
3145
  // src/review.ts
1882
- import { join as join9 } from "node:path";
3146
+ import { join as join13 } from "node:path";
1883
3147
  var inFlightCardIds2 = /* @__PURE__ */ new Set();
1884
3148
  async function handleCardEnteredRevisao(supabase, config, card) {
1885
3149
  if (inFlightCardIds2.has(card.id)) return;
@@ -1888,7 +3152,7 @@ async function handleCardEnteredRevisao(supabase, config, card) {
1888
3152
  const project = await getProject(supabase, card.project_id);
1889
3153
  const children = await getProjectRepos(supabase, project.id);
1890
3154
  const repoRoot = resolveRepoPath(config, project.repo_path);
1891
- 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 }] : [];
3155
+ const targets = children.length > 0 ? children.filter((child2) => child2.dev_command).map((child2) => ({ label: child2.subpath, cwd: join13(repoRoot, child2.subpath), command: child2.dev_command })) : project.dev_command ? [{ label: "", cwd: repoRoot, command: project.dev_command }] : [];
1892
3156
  if (targets.length === 0) {
1893
3157
  logger.card.info(card.id, `No dev_command configured for project ${project.id}, skipping automatic preview.`);
1894
3158
  return;
@@ -1946,12 +3210,14 @@ async function checkPendingDevServerRestarts(supabase, config) {
1946
3210
  }
1947
3211
 
1948
3212
  // src/terminalServer.ts
1949
- import { parse as parse2 } from "node:url";
1950
- import { WebSocketServer } from "ws";
1951
- function attachTerminalServer(httpServer) {
1952
- const wss = new WebSocketServer({ server: httpServer });
3213
+ import { parse as parse3 } from "node:url";
3214
+ import { WebSocketServer as WebSocketServer2 } from "ws";
3215
+ var FRAME_TYPE_RESIZE = 0;
3216
+ var FRAME_TYPE_IMAGE = 1;
3217
+ function attachTerminalServer() {
3218
+ const wss = new WebSocketServer2({ noServer: true });
1953
3219
  wss.on("connection", (socket, request) => {
1954
- const { query } = parse2(request.url ?? "", true);
3220
+ const { query } = parse3(request.url ?? "", true);
1955
3221
  const sessionId = typeof query.sessionId === "string" ? query.sessionId : null;
1956
3222
  const ticket = typeof query.ticket === "string" ? query.ticket : null;
1957
3223
  if (!sessionId || !ticket) {
@@ -1969,14 +3235,28 @@ function attachTerminalServer(httpServer) {
1969
3235
  for (const chunk of handle.outputBuffer) socket.send(chunk);
1970
3236
  socket.on("message", (data, isBinary) => {
1971
3237
  if (isBinary) {
1972
- try {
1973
- const { cols, rows } = JSON.parse(data.toString("utf8"));
1974
- if (typeof cols === "number" && typeof rows === "number") resizePtySession(sessionId, cols, rows);
1975
- } catch (err) {
1976
- logger.runner.warn("TERMINAL", `Terminal WS: ignoring malformed resize frame for session ${sessionId}:`, err);
3238
+ const buffer = data;
3239
+ const frameType = buffer[0];
3240
+ if (frameType === FRAME_TYPE_IMAGE) {
3241
+ if (handle.readOnly) return;
3242
+ const extCode = buffer[1];
3243
+ const bytes = buffer.subarray(2);
3244
+ savePastedImage(sessionId, extCode, bytes).then((path) => handle.pty.write(`${path} `)).catch((err) => logger.runner.warn("TERMINAL", `Terminal WS: failed to save pasted image for session ${sessionId}:`, err));
3245
+ return;
3246
+ }
3247
+ if (frameType === FRAME_TYPE_RESIZE) {
3248
+ try {
3249
+ const { cols, rows } = JSON.parse(buffer.subarray(1).toString("utf8"));
3250
+ if (typeof cols === "number" && typeof rows === "number") resizePtySession(sessionId, cols, rows);
3251
+ } catch (err) {
3252
+ logger.runner.warn("TERMINAL", `Terminal WS: ignoring malformed resize frame for session ${sessionId}:`, err);
3253
+ }
3254
+ return;
1977
3255
  }
3256
+ logger.runner.warn("TERMINAL", `Terminal WS: ignoring binary frame with unknown type ${frameType} for session ${sessionId}.`);
1978
3257
  return;
1979
3258
  }
3259
+ if (handle.readOnly) return;
1980
3260
  handle.pty.write(data.toString());
1981
3261
  });
1982
3262
  socket.on("close", () => {
@@ -1984,6 +3264,7 @@ function attachTerminalServer(httpServer) {
1984
3264
  });
1985
3265
  });
1986
3266
  logger.runner.info("TERMINAL", "Terminal WebSocket handler attached.");
3267
+ return wss;
1987
3268
  }
1988
3269
 
1989
3270
  // src/terminalTunnel.ts
@@ -2058,6 +3339,16 @@ async function startRunner() {
2058
3339
  }
2059
3340
  config.ownerUserId = session.user.id;
2060
3341
  await supabase.auth.startAutoRefresh();
3342
+ supabase.auth.onAuthStateChange((event) => {
3343
+ logger.runner.info("AUTH", `Session state changed: ${event}.`);
3344
+ if (event === "SIGNED_OUT") {
3345
+ logger.runner.error(
3346
+ "AUTH",
3347
+ 'Supabase session was lost (refresh failed or was revoked) \u2014 every write from now on will fail row-level security checks. Run "agent-board-runner login" again and restart the runner.'
3348
+ );
3349
+ process.exit(1);
3350
+ }
3351
+ });
2061
3352
  const { error: linkError } = await supabase.rpc("link_my_membership");
2062
3353
  if (linkError) {
2063
3354
  logger.runner.error("BOOT", "Failed to link pending project invites:", linkError);
@@ -2077,13 +3368,16 @@ async function startRunner() {
2077
3368
  id: config.runnerId,
2078
3369
  user_id: config.ownerUserId,
2079
3370
  name: config.runnerName,
2080
- auth_token_hash: config.authToken
3371
+ auth_token_hash: config.authToken,
3372
+ version: RUNNER_VERSION
2081
3373
  });
2082
3374
  if (runnerError) {
2083
3375
  logger.runner.error("BOOT", "Failed to upsert this runner's row in Supabase:", runnerError);
2084
3376
  process.exit(1);
2085
3377
  }
2086
3378
  const registeredPaths = new Set(config.registeredProjects.map((project) => project.id));
3379
+ const lastKnownColumn = /* @__PURE__ */ new Map();
3380
+ const lastKnownRequeueRequestedAt = /* @__PURE__ */ new Map();
2087
3381
  await syncRegisteredProjects(supabase, config, registeredPaths).catch((err) => logger.runner.error("PROJECT_SYNC", "syncRegisteredProjects failed:", err));
2088
3382
  logger.runner.info("BOOT", `Agent Board runner "${config.runnerName}" starting (${registeredPaths.size} project(s) registered).`);
2089
3383
  for (const project of config.registeredProjects) {
@@ -2093,9 +3387,22 @@ async function startRunner() {
2093
3387
  await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
2094
3388
  const terminalPort = Number(process.env.AGENT_BOARD_TERMINAL_PORT ?? 8787);
2095
3389
  ensureStatuslineScriptWritten();
3390
+ ensureSafetyHookScriptWritten();
3391
+ ensureAgentMomentHookScriptWritten();
2096
3392
  const httpServer = createServer2();
2097
- attachTerminalServer(httpServer);
3393
+ const terminalWss = attachTerminalServer();
3394
+ const agentEventsWss = attachAgentEventsServer();
2098
3395
  attachFilesServer(httpServer, supabase, config);
3396
+ httpServer.on("upgrade", (request, socket, head) => {
3397
+ const pathname = (request.url ?? "").split("?")[0];
3398
+ if (pathname === "/terminal") {
3399
+ terminalWss.handleUpgrade(request, socket, head, (ws) => terminalWss.emit("connection", ws, request));
3400
+ } else if (pathname === "/agent-events") {
3401
+ agentEventsWss.handleUpgrade(request, socket, head, (ws) => agentEventsWss.emit("connection", ws, request));
3402
+ } else {
3403
+ socket.destroy();
3404
+ }
3405
+ });
2099
3406
  let httpServerReady = false;
2100
3407
  let realtimeReady = false;
2101
3408
  function logRunnerReadyOnceBothUp() {
@@ -2112,16 +3419,29 @@ async function startRunner() {
2112
3419
  void ensureTerminalTunnel(supabase, config, terminalPort).catch((err) => logger.runner.error("CLOUDFLARE", "ensureTerminalTunnel failed:", err));
2113
3420
  void ensureFilesTickets(supabase, config).catch((err) => logger.runner.error("FILES", "ensureFilesTickets failed:", err));
2114
3421
  void ensureSetupTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureSetupTicket failed:", err));
3422
+ void ensureAgentTicket(supabase, config).catch((err) => logger.runner.error("FILES", "ensureAgentTicket failed:", err));
3423
+ const presenceChannel = supabase.channel(`runner-presence:${config.runnerId}`);
3424
+ presenceChannel.subscribe((status) => {
3425
+ if (status === "SUBSCRIBED") {
3426
+ void presenceChannel.track({ online: true, version: RUNNER_VERSION });
3427
+ }
3428
+ });
2115
3429
  let shuttingDown = false;
2116
3430
  for (const signal of ["SIGINT", "SIGTERM"]) {
2117
3431
  process.on(signal, () => {
2118
3432
  if (shuttingDown) return;
2119
3433
  shuttingDown = true;
2120
3434
  stopAll();
3435
+ void presenceChannel.untrack();
2121
3436
  void stopTerminalTunnel(supabase, config).finally(() => process.exit(0));
2122
3437
  });
2123
3438
  }
3439
+ const HEARTBEAT_WRITE_INTERVAL_MS = 5 * 6e4;
3440
+ let lastHeartbeatWriteAt = 0;
2124
3441
  async function heartbeat() {
3442
+ const now = Date.now();
3443
+ if (now - lastHeartbeatWriteAt < HEARTBEAT_WRITE_INTERVAL_MS) return;
3444
+ lastHeartbeatWriteAt = now;
2125
3445
  await supabase.from("runners").update({ last_heartbeat_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", config.runnerId);
2126
3446
  }
2127
3447
  supabase.channel(`runner:${config.runnerId}`).on(
@@ -2129,24 +3449,34 @@ async function startRunner() {
2129
3449
  { event: "UPDATE", schema: "agent_board", table: "cards" },
2130
3450
  (payload) => {
2131
3451
  const card = payload.new;
2132
- const previous = payload.old;
2133
3452
  if (!registeredPaths.has(card.project_id)) return;
2134
3453
  if (card.assigned_user_id !== config.ownerUserId) return;
3454
+ const previousColumn = lastKnownColumn.get(card.id);
3455
+ lastKnownColumn.set(card.id, card.column_name);
2135
3456
  if (card.column_name === "desenvolvimento") {
2136
3457
  void enqueueCard(supabase, config, card);
2137
3458
  }
2138
- if (previous.column_name === "desenvolvimento" && card.column_name !== "desenvolvimento") {
3459
+ if (previousColumn === "desenvolvimento" && card.column_name !== "desenvolvimento") {
2139
3460
  void stopCardSession(supabase, config, card);
2140
3461
  }
2141
- if (card.column_name === "revisao" && previous.column_name !== "revisao") {
3462
+ if (card.column_name === "revisao" && previousColumn !== "revisao") {
2142
3463
  void handleCardEnteredRevisao(supabase, config, card);
2143
3464
  }
2144
- if (previous.column_name === "revisao" && card.column_name !== "revisao") {
3465
+ if (previousColumn === "revisao" && card.column_name !== "revisao") {
2145
3466
  releaseCard(card.id);
2146
3467
  }
2147
- if (card.column_name === "fim" && previous.column_name !== "fim") {
3468
+ if (card.column_name === "fim" && previousColumn !== "fim") {
2148
3469
  void handleCardReachedFim(supabase, config, card);
2149
3470
  }
3471
+ const previousRequeueRequestedAt = lastKnownRequeueRequestedAt.get(card.id);
3472
+ lastKnownRequeueRequestedAt.set(card.id, card.requeue_requested_at);
3473
+ if (card.requeue_requested_at && card.requeue_requested_at !== previousRequeueRequestedAt) {
3474
+ void requeuePausedCard(supabase, config, card);
3475
+ }
3476
+ if (previousColumn !== void 0 && previousColumn !== card.column_name) {
3477
+ void clearColumnActionStatus(supabase, card);
3478
+ void handleColumnEntered(supabase, config, card);
3479
+ }
2150
3480
  }
2151
3481
  ).on(
2152
3482
  "postgres_changes",
@@ -2161,6 +3491,9 @@ async function startRunner() {
2161
3491
  if (project.dev_server_restart_requested_at && project.dev_server_restart_requested_at !== previous.dev_server_restart_requested_at) {
2162
3492
  void handleDevServerRestartRequested(supabase, config, project);
2163
3493
  }
3494
+ if (project.deploy_action_rerun_requested_at && project.deploy_action_rerun_requested_at !== previous.deploy_action_rerun_requested_at) {
3495
+ void handleDeployActionRerunRequested(supabase, config, project);
3496
+ }
2164
3497
  }
2165
3498
  ).subscribe((status) => {
2166
3499
  if (status === "SUBSCRIBED") {
@@ -2177,11 +3510,14 @@ async function startRunner() {
2177
3510
  await ensureTerminalTunnel(supabase, config, terminalPort);
2178
3511
  await ensureFilesTickets(supabase, config);
2179
3512
  await ensureSetupTicket(supabase, config);
3513
+ await ensureAgentTicket(supabase, config);
2180
3514
  await scanForWork(supabase, config);
2181
3515
  await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
2182
3516
  await checkPendingDeploys(supabase, config).catch((err) => logger.runner.error("DEPLOY", "checkPendingDeploys failed:", err));
2183
3517
  await checkPendingPreviews(supabase, config).catch((err) => logger.runner.error("REVIEW", "checkPendingPreviews failed:", err));
2184
3518
  await checkPendingDevServerRestarts(supabase, config).catch((err) => logger.runner.error("REVIEW", "checkPendingDevServerRestarts failed:", err));
3519
+ await checkPendingColumnActions(supabase, config).catch((err) => logger.runner.error("COLUMN_ACTION", "checkPendingColumnActions failed:", err));
3520
+ await checkPendingDeployActionReruns(supabase, config).catch((err) => logger.runner.error("COLUMN_ACTION", "checkPendingDeployActionReruns failed:", err));
2185
3521
  }
2186
3522
  void tick();
2187
3523
  setInterval(() => void tick(), 6e4);