agent-board-runner 0.1.1 → 0.1.3

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