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