agent-board-runner 0.1.20 → 0.1.22
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 +157 -45
- package/dist/index.js +157 -45
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -572,7 +572,15 @@ async function savePastedImage(sessionId, extCode, bytes) {
|
|
|
572
572
|
return path;
|
|
573
573
|
}
|
|
574
574
|
function registerPtySession(sessionId, pty, ticket) {
|
|
575
|
-
const handle = {
|
|
575
|
+
const handle = {
|
|
576
|
+
pty,
|
|
577
|
+
ticket,
|
|
578
|
+
outputBuffer: [],
|
|
579
|
+
sockets: /* @__PURE__ */ new Set(),
|
|
580
|
+
readOnly: false,
|
|
581
|
+
exited: false,
|
|
582
|
+
lastAssistantMessage: null
|
|
583
|
+
};
|
|
576
584
|
pty.onExit(() => {
|
|
577
585
|
handle.exited = true;
|
|
578
586
|
});
|
|
@@ -588,6 +596,10 @@ function registerPtySession(sessionId, pty, ticket) {
|
|
|
588
596
|
function getPtySession(sessionId) {
|
|
589
597
|
return registry.get(sessionId);
|
|
590
598
|
}
|
|
599
|
+
function recordLastAssistantMessage(sessionId, message) {
|
|
600
|
+
const handle = registry.get(sessionId);
|
|
601
|
+
if (handle) handle.lastAssistantMessage = message;
|
|
602
|
+
}
|
|
591
603
|
function setPtySessionReadOnly(sessionId, readOnly) {
|
|
592
604
|
const handle = registry.get(sessionId);
|
|
593
605
|
if (handle) handle.readOnly = readOnly;
|
|
@@ -1297,10 +1309,10 @@ function detectMoment(input) {
|
|
|
1297
1309
|
return null;
|
|
1298
1310
|
}
|
|
1299
1311
|
|
|
1300
|
-
function reportToRunner(sessionId, ticket, port, moment) {
|
|
1312
|
+
function reportToRunner(sessionId, ticket, port, moment, lastAssistantMessage) {
|
|
1301
1313
|
return new Promise((resolve) => {
|
|
1302
1314
|
try {
|
|
1303
|
-
const body = JSON.stringify({ sessionId, ticket, moment });
|
|
1315
|
+
const body = JSON.stringify({ sessionId, ticket, moment, lastAssistantMessage });
|
|
1304
1316
|
const req = http.request(
|
|
1305
1317
|
{
|
|
1306
1318
|
host: "127.0.0.1",
|
|
@@ -1349,7 +1361,10 @@ function reportToRunner(sessionId, ticket, port, moment) {
|
|
|
1349
1361
|
const sessionId = process.env.AGENT_BOARD_SESSION_ID;
|
|
1350
1362
|
const ticket = process.env.AGENT_BOARD_STATUSLINE_TICKET;
|
|
1351
1363
|
const port = Number(process.env.AGENT_BOARD_STATUSLINE_PORT);
|
|
1352
|
-
if (sessionId && ticket && port)
|
|
1364
|
+
if (sessionId && ticket && port) {
|
|
1365
|
+
const lastAssistantMessage = moment === "turn_stopped" && typeof input.lastAssistantMessage === "string" ? input.lastAssistantMessage : undefined;
|
|
1366
|
+
await reportToRunner(sessionId, ticket, port, moment, lastAssistantMessage);
|
|
1367
|
+
}
|
|
1353
1368
|
}
|
|
1354
1369
|
} catch {
|
|
1355
1370
|
// A malformed/unexpected hook payload must never block the agent's own turn.
|
|
@@ -2088,6 +2103,7 @@ async function handleStatuslineReport(req, res, supabase, config) {
|
|
|
2088
2103
|
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
2089
2104
|
}
|
|
2090
2105
|
}
|
|
2106
|
+
var MAX_LAST_ASSISTANT_MESSAGE_CHARS = 4e3;
|
|
2091
2107
|
async function handleAgentHookReport(req, res) {
|
|
2092
2108
|
let body;
|
|
2093
2109
|
try {
|
|
@@ -2106,6 +2122,9 @@ async function handleAgentHookReport(req, res) {
|
|
|
2106
2122
|
sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
|
|
2107
2123
|
return;
|
|
2108
2124
|
}
|
|
2125
|
+
if (typeof body.lastAssistantMessage === "string" && body.lastAssistantMessage.length > 0) {
|
|
2126
|
+
recordLastAssistantMessage(body.sessionId, body.lastAssistantMessage.slice(0, MAX_LAST_ASSISTANT_MESSAGE_CHARS));
|
|
2127
|
+
}
|
|
2109
2128
|
handleAgentHookEvent(body.sessionId, body.moment);
|
|
2110
2129
|
res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
|
|
2111
2130
|
res.end();
|
|
@@ -2666,30 +2685,14 @@ import { randomUUID as randomUUID7 } from "node:crypto";
|
|
|
2666
2685
|
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2667
2686
|
import { spawn as spawn3 } from "node-pty";
|
|
2668
2687
|
|
|
2669
|
-
// src/
|
|
2670
|
-
var MAX_LENGTH = 60;
|
|
2671
|
-
var FALLBACK_NAME = "card";
|
|
2672
|
-
function toSessionName(title) {
|
|
2673
|
-
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2674
|
-
if (slug.length === 0) return FALLBACK_NAME;
|
|
2675
|
-
if (slug.length <= MAX_LENGTH) return slug;
|
|
2676
|
-
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2677
|
-
const lastHyphen = truncated.lastIndexOf("-");
|
|
2678
|
-
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2679
|
-
}
|
|
2680
|
-
|
|
2681
|
-
// src/agentSession.ts
|
|
2688
|
+
// src/ptyMessaging.ts
|
|
2682
2689
|
var TUI_IDLE_MS = 1500;
|
|
2683
2690
|
var TUI_READY_TIMEOUT_MS = 6e4;
|
|
2684
2691
|
var PASTE_SETTLE_MS = 200;
|
|
2685
2692
|
var PASTE_SETTLE_TIMEOUT_MS = 5e3;
|
|
2686
2693
|
var PASTE_START = "\x1B[200~";
|
|
2687
2694
|
var PASTE_END = "\x1B[201~";
|
|
2688
|
-
|
|
2689
|
-
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2690
|
-
var SHIFT_TAB_DELAY_MS = 400;
|
|
2691
|
-
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2692
|
-
function writePasteAndSubmit(pty, cardId, message, context) {
|
|
2695
|
+
function writePasteAndSubmit(pty, logId, message, context, log) {
|
|
2693
2696
|
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
2694
2697
|
let settleTimer;
|
|
2695
2698
|
let ceilingTimer;
|
|
@@ -2706,7 +2709,7 @@ function writePasteAndSubmit(pty, cardId, message, context) {
|
|
|
2706
2709
|
try {
|
|
2707
2710
|
pty.write("\r");
|
|
2708
2711
|
} catch (err) {
|
|
2709
|
-
|
|
2712
|
+
log.error(logId, `Failed to submit ${context} to the agent:`, err);
|
|
2710
2713
|
}
|
|
2711
2714
|
};
|
|
2712
2715
|
const cancel = () => {
|
|
@@ -2724,6 +2727,48 @@ function writePasteAndSubmit(pty, cardId, message, context) {
|
|
|
2724
2727
|
ceilingTimer = setTimeout(submit, PASTE_SETTLE_TIMEOUT_MS);
|
|
2725
2728
|
exitSubscription = pty.onExit(cancel);
|
|
2726
2729
|
}
|
|
2730
|
+
function waitForIdleThenRun(pty, run) {
|
|
2731
|
+
let idleTimer;
|
|
2732
|
+
let deadlineTimer;
|
|
2733
|
+
let dataSubscription;
|
|
2734
|
+
let settled = false;
|
|
2735
|
+
const stopWaiting = () => {
|
|
2736
|
+
if (settled) return false;
|
|
2737
|
+
settled = true;
|
|
2738
|
+
clearTimeout(idleTimer);
|
|
2739
|
+
clearTimeout(deadlineTimer);
|
|
2740
|
+
dataSubscription?.dispose();
|
|
2741
|
+
return true;
|
|
2742
|
+
};
|
|
2743
|
+
const fire = () => {
|
|
2744
|
+
if (!stopWaiting()) return;
|
|
2745
|
+
void run();
|
|
2746
|
+
};
|
|
2747
|
+
deadlineTimer = setTimeout(fire, TUI_READY_TIMEOUT_MS);
|
|
2748
|
+
dataSubscription = pty.onData(() => {
|
|
2749
|
+
clearTimeout(idleTimer);
|
|
2750
|
+
idleTimer = setTimeout(fire, TUI_IDLE_MS);
|
|
2751
|
+
});
|
|
2752
|
+
pty.onExit(() => stopWaiting());
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
// src/sessionName.ts
|
|
2756
|
+
var MAX_LENGTH = 60;
|
|
2757
|
+
var FALLBACK_NAME = "card";
|
|
2758
|
+
function toSessionName(title) {
|
|
2759
|
+
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2760
|
+
if (slug.length === 0) return FALLBACK_NAME;
|
|
2761
|
+
if (slug.length <= MAX_LENGTH) return slug;
|
|
2762
|
+
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2763
|
+
const lastHyphen = truncated.lastIndexOf("-");
|
|
2764
|
+
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
// src/agentSession.ts
|
|
2768
|
+
var SHIFT_TAB = "\x1B[Z";
|
|
2769
|
+
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2770
|
+
var SHIFT_TAB_DELAY_MS = 400;
|
|
2771
|
+
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2727
2772
|
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
2728
2773
|
ensureDirTrusted(repoPath);
|
|
2729
2774
|
ensureMcpServersTrusted(repoPath);
|
|
@@ -2751,33 +2796,14 @@ function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, tic
|
|
|
2751
2796
|
return { pty, claudeSessionId };
|
|
2752
2797
|
}
|
|
2753
2798
|
function sendMessageWhenIdle(pty, cardId, message, planFirst) {
|
|
2754
|
-
|
|
2755
|
-
let deadlineTimer;
|
|
2756
|
-
let dataSubscription;
|
|
2757
|
-
let settled = false;
|
|
2758
|
-
const stopWaiting = () => {
|
|
2759
|
-
if (settled) return false;
|
|
2760
|
-
settled = true;
|
|
2761
|
-
clearTimeout(idleTimer);
|
|
2762
|
-
clearTimeout(deadlineTimer);
|
|
2763
|
-
dataSubscription?.dispose();
|
|
2764
|
-
return true;
|
|
2765
|
-
};
|
|
2766
|
-
const send = async () => {
|
|
2767
|
-
if (!stopWaiting()) return;
|
|
2799
|
+
waitForIdleThenRun(pty, async () => {
|
|
2768
2800
|
if (planFirst) await enterPlanMode(pty, cardId);
|
|
2769
2801
|
try {
|
|
2770
|
-
writePasteAndSubmit(pty, cardId, message, "the card instructions");
|
|
2802
|
+
writePasteAndSubmit(pty, cardId, message, "the card instructions", logger.card);
|
|
2771
2803
|
} catch (err) {
|
|
2772
2804
|
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
2773
2805
|
}
|
|
2774
|
-
};
|
|
2775
|
-
deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
|
|
2776
|
-
dataSubscription = pty.onData(() => {
|
|
2777
|
-
clearTimeout(idleTimer);
|
|
2778
|
-
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
2779
2806
|
});
|
|
2780
|
-
pty.onExit(() => stopWaiting());
|
|
2781
2807
|
}
|
|
2782
2808
|
async function enterPlanMode(pty, cardId) {
|
|
2783
2809
|
let recentOutput = "";
|
|
@@ -2803,7 +2829,7 @@ async function enterPlanMode(pty, cardId) {
|
|
|
2803
2829
|
}
|
|
2804
2830
|
function sendMessageToIdleSession(pty, cardId, message) {
|
|
2805
2831
|
try {
|
|
2806
|
-
writePasteAndSubmit(pty, cardId, message, "a message");
|
|
2832
|
+
writePasteAndSubmit(pty, cardId, message, "a message", logger.card);
|
|
2807
2833
|
} catch (err) {
|
|
2808
2834
|
logger.card.error(cardId, "Failed to send a message to the idle session:", err);
|
|
2809
2835
|
}
|
|
@@ -4327,6 +4353,88 @@ async function stopTerminalTunnel(supabase, config) {
|
|
|
4327
4353
|
await publishHostname(supabase, config, null);
|
|
4328
4354
|
}
|
|
4329
4355
|
|
|
4356
|
+
// src/workflowShell.ts
|
|
4357
|
+
import { randomUUID as randomUUID8 } from "node:crypto";
|
|
4358
|
+
import { spawn as spawn6 } from "node-pty";
|
|
4359
|
+
function registerWorkflowShellRpcHandler(supabase, config) {
|
|
4360
|
+
registerRunnerRpcHandler("open_workflow_shell", (payload) => openWorkflowShell(supabase, config, payload));
|
|
4361
|
+
}
|
|
4362
|
+
function isOpenWorkflowShellPayload(value) {
|
|
4363
|
+
if (!value || typeof value !== "object") return false;
|
|
4364
|
+
const candidate = value;
|
|
4365
|
+
return typeof candidate.projectId === "string" && typeof candidate.executionId === "string" && typeof candidate.instructions === "string" && candidate.instructions.length > 0;
|
|
4366
|
+
}
|
|
4367
|
+
async function openWorkflowShell(supabase, config, payload) {
|
|
4368
|
+
if (!isOpenWorkflowShellPayload(payload)) throw new Error("Malformed open_workflow_shell payload.");
|
|
4369
|
+
const { projectId, executionId, instructions } = payload;
|
|
4370
|
+
const registered = config.registeredProjects.find((project) => project.id === projectId);
|
|
4371
|
+
if (!registered) throw new Error("This runner does not have that project registered.");
|
|
4372
|
+
ensureDirTrusted(registered.repoPath);
|
|
4373
|
+
ensureMcpServersTrusted(registered.repoPath);
|
|
4374
|
+
const claudeSessionId = randomUUID8();
|
|
4375
|
+
const shellTicket = randomUUID8();
|
|
4376
|
+
const pty = spawn6(
|
|
4377
|
+
CLAUDE_BINARY,
|
|
4378
|
+
[
|
|
4379
|
+
"--dangerously-skip-permissions",
|
|
4380
|
+
"--model",
|
|
4381
|
+
"claude-sonnet-5",
|
|
4382
|
+
"--session-id",
|
|
4383
|
+
claudeSessionId,
|
|
4384
|
+
...claudeCliSettingsArgs()
|
|
4385
|
+
],
|
|
4386
|
+
{
|
|
4387
|
+
name: "xterm-color",
|
|
4388
|
+
cols: 120,
|
|
4389
|
+
rows: 32,
|
|
4390
|
+
cwd: registered.repoPath,
|
|
4391
|
+
env: statuslineEnv(executionId, shellTicket)
|
|
4392
|
+
}
|
|
4393
|
+
);
|
|
4394
|
+
registerPtySession(executionId, pty, shellTicket);
|
|
4395
|
+
pty.onExit(({ exitCode }) => void handleWorkflowShellExit(supabase, executionId, exitCode));
|
|
4396
|
+
waitForIdleThenRun(pty, () => {
|
|
4397
|
+
try {
|
|
4398
|
+
writePasteAndSubmit(pty, executionId, instructions, "the workflow runbook", logger.runner);
|
|
4399
|
+
} catch (err) {
|
|
4400
|
+
logger.runner.error(executionId, "Failed to send the workflow runbook to the agent:", err);
|
|
4401
|
+
}
|
|
4402
|
+
});
|
|
4403
|
+
return { claudeSessionId, shellTicket };
|
|
4404
|
+
}
|
|
4405
|
+
async function handleWorkflowShellExit(supabase, executionId, exitCode) {
|
|
4406
|
+
const status = exitCode === 0 ? "done" : "failed";
|
|
4407
|
+
const summary = getPtySession(executionId)?.lastAssistantMessage ?? null;
|
|
4408
|
+
const { error } = await supabase.rpc("complete_workflow_execution", { p_execution_id: executionId, p_status: status, p_summary: summary });
|
|
4409
|
+
if (error) {
|
|
4410
|
+
logger.runner.error("WORKFLOW_SHELL", `Failed to record exit status for workflow execution ${executionId}:`, error);
|
|
4411
|
+
}
|
|
4412
|
+
}
|
|
4413
|
+
async function reconcileOrphanedWorkflowExecutions(supabase, config) {
|
|
4414
|
+
const { data, error } = await supabase.from("workflow_executions").select("id, project_id, claude_session_id, shell_ticket").eq("runner_id", config.runnerId).in("status", ["running", "paused_on_question"]);
|
|
4415
|
+
if (error) {
|
|
4416
|
+
logger.runner.error("WORKFLOW_SHELL", "Failed to scan orphaned workflow executions:", error.message);
|
|
4417
|
+
return;
|
|
4418
|
+
}
|
|
4419
|
+
for (const execution of data ?? []) {
|
|
4420
|
+
const { id: executionId, project_id: projectId, claude_session_id: claudeSessionId, shell_ticket: shellTicket } = execution;
|
|
4421
|
+
if (getPtySession(executionId)) continue;
|
|
4422
|
+
if (!claudeSessionId || !shellTicket) {
|
|
4423
|
+
logger.runner.warn("WORKFLOW_SHELL", `Workflow execution ${executionId} is missing claude_session_id/shell_ticket \u2014 cannot resume it.`);
|
|
4424
|
+
continue;
|
|
4425
|
+
}
|
|
4426
|
+
const registered = config.registeredProjects.find((project) => project.id === projectId);
|
|
4427
|
+
if (!registered) {
|
|
4428
|
+
logger.runner.warn("WORKFLOW_SHELL", `Workflow execution ${executionId}: project ${projectId} is not registered on this runner \u2014 skipping.`);
|
|
4429
|
+
continue;
|
|
4430
|
+
}
|
|
4431
|
+
const pty = resumeAgentPty(registered.repoPath, claudeSessionId, executionId, shellTicket);
|
|
4432
|
+
registerPtySession(executionId, pty, shellTicket);
|
|
4433
|
+
pty.onExit(({ exitCode }) => void handleWorkflowShellExit(supabase, executionId, exitCode));
|
|
4434
|
+
logger.runner.info("WORKFLOW_SHELL", `Re-spawned orphaned workflow execution ${executionId} (runner restarted while it was still active).`);
|
|
4435
|
+
}
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4330
4438
|
// src/runner.ts
|
|
4331
4439
|
async function startRunner() {
|
|
4332
4440
|
const loadedConfig = loadConfig();
|
|
@@ -4403,6 +4511,9 @@ async function startRunner() {
|
|
|
4403
4511
|
await reconcileStaleSessions(supabase, config, project.id).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileStaleSessions failed:", err));
|
|
4404
4512
|
}
|
|
4405
4513
|
await reconcileOrphanedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileOrphanedSessions failed:", err));
|
|
4514
|
+
await reconcileOrphanedWorkflowExecutions(supabase, config).catch(
|
|
4515
|
+
(err) => logger.runner.error("WORKFLOW_SHELL", "reconcileOrphanedWorkflowExecutions failed:", err)
|
|
4516
|
+
);
|
|
4406
4517
|
await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
|
|
4407
4518
|
const terminalPort = Number(process.env.AGENT_BOARD_TERMINAL_PORT ?? 8787);
|
|
4408
4519
|
ensureStatuslineScriptWritten();
|
|
@@ -4413,6 +4524,7 @@ async function startRunner() {
|
|
|
4413
4524
|
const agentEventsWss = attachAgentEventsServer();
|
|
4414
4525
|
const runnerRpcWss = attachRunnerRpcServer();
|
|
4415
4526
|
registerProjectCommandRpcHandler(supabase, config);
|
|
4527
|
+
registerWorkflowShellRpcHandler(supabase, config);
|
|
4416
4528
|
attachFilesServer(httpServer, supabase, config);
|
|
4417
4529
|
httpServer.on("upgrade", (request, socket, head) => {
|
|
4418
4530
|
const pathname = (request.url ?? "").split("?")[0];
|
package/dist/index.js
CHANGED
|
@@ -292,7 +292,15 @@ async function savePastedImage(sessionId, extCode, bytes) {
|
|
|
292
292
|
return path;
|
|
293
293
|
}
|
|
294
294
|
function registerPtySession(sessionId, pty, ticket) {
|
|
295
|
-
const handle = {
|
|
295
|
+
const handle = {
|
|
296
|
+
pty,
|
|
297
|
+
ticket,
|
|
298
|
+
outputBuffer: [],
|
|
299
|
+
sockets: /* @__PURE__ */ new Set(),
|
|
300
|
+
readOnly: false,
|
|
301
|
+
exited: false,
|
|
302
|
+
lastAssistantMessage: null
|
|
303
|
+
};
|
|
296
304
|
pty.onExit(() => {
|
|
297
305
|
handle.exited = true;
|
|
298
306
|
});
|
|
@@ -308,6 +316,10 @@ function registerPtySession(sessionId, pty, ticket) {
|
|
|
308
316
|
function getPtySession(sessionId) {
|
|
309
317
|
return registry.get(sessionId);
|
|
310
318
|
}
|
|
319
|
+
function recordLastAssistantMessage(sessionId, message) {
|
|
320
|
+
const handle = registry.get(sessionId);
|
|
321
|
+
if (handle) handle.lastAssistantMessage = message;
|
|
322
|
+
}
|
|
311
323
|
function setPtySessionReadOnly(sessionId, readOnly) {
|
|
312
324
|
const handle = registry.get(sessionId);
|
|
313
325
|
if (handle) handle.readOnly = readOnly;
|
|
@@ -1017,10 +1029,10 @@ function detectMoment(input) {
|
|
|
1017
1029
|
return null;
|
|
1018
1030
|
}
|
|
1019
1031
|
|
|
1020
|
-
function reportToRunner(sessionId, ticket, port, moment) {
|
|
1032
|
+
function reportToRunner(sessionId, ticket, port, moment, lastAssistantMessage) {
|
|
1021
1033
|
return new Promise((resolve) => {
|
|
1022
1034
|
try {
|
|
1023
|
-
const body = JSON.stringify({ sessionId, ticket, moment });
|
|
1035
|
+
const body = JSON.stringify({ sessionId, ticket, moment, lastAssistantMessage });
|
|
1024
1036
|
const req = http.request(
|
|
1025
1037
|
{
|
|
1026
1038
|
host: "127.0.0.1",
|
|
@@ -1069,7 +1081,10 @@ function reportToRunner(sessionId, ticket, port, moment) {
|
|
|
1069
1081
|
const sessionId = process.env.AGENT_BOARD_SESSION_ID;
|
|
1070
1082
|
const ticket = process.env.AGENT_BOARD_STATUSLINE_TICKET;
|
|
1071
1083
|
const port = Number(process.env.AGENT_BOARD_STATUSLINE_PORT);
|
|
1072
|
-
if (sessionId && ticket && port)
|
|
1084
|
+
if (sessionId && ticket && port) {
|
|
1085
|
+
const lastAssistantMessage = moment === "turn_stopped" && typeof input.lastAssistantMessage === "string" ? input.lastAssistantMessage : undefined;
|
|
1086
|
+
await reportToRunner(sessionId, ticket, port, moment, lastAssistantMessage);
|
|
1087
|
+
}
|
|
1073
1088
|
}
|
|
1074
1089
|
} catch {
|
|
1075
1090
|
// A malformed/unexpected hook payload must never block the agent's own turn.
|
|
@@ -1808,6 +1823,7 @@ async function handleStatuslineReport(req, res, supabase, config) {
|
|
|
1808
1823
|
sendJson(res, 500, { error: "Erro interno do runner." });
|
|
1809
1824
|
}
|
|
1810
1825
|
}
|
|
1826
|
+
var MAX_LAST_ASSISTANT_MESSAGE_CHARS = 4e3;
|
|
1811
1827
|
async function handleAgentHookReport(req, res) {
|
|
1812
1828
|
let body;
|
|
1813
1829
|
try {
|
|
@@ -1826,6 +1842,9 @@ async function handleAgentHookReport(req, res) {
|
|
|
1826
1842
|
sendJson(res, 403, { error: "Ticket inv\xE1lido para esta sess\xE3o." });
|
|
1827
1843
|
return;
|
|
1828
1844
|
}
|
|
1845
|
+
if (typeof body.lastAssistantMessage === "string" && body.lastAssistantMessage.length > 0) {
|
|
1846
|
+
recordLastAssistantMessage(body.sessionId, body.lastAssistantMessage.slice(0, MAX_LAST_ASSISTANT_MESSAGE_CHARS));
|
|
1847
|
+
}
|
|
1829
1848
|
handleAgentHookEvent(body.sessionId, body.moment);
|
|
1830
1849
|
res.writeHead(204, { "Access-Control-Allow-Origin": "*" });
|
|
1831
1850
|
res.end();
|
|
@@ -2554,30 +2573,14 @@ import { randomUUID as randomUUID7 } from "node:crypto";
|
|
|
2554
2573
|
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2555
2574
|
import { spawn as spawn3 } from "node-pty";
|
|
2556
2575
|
|
|
2557
|
-
// src/
|
|
2558
|
-
var MAX_LENGTH = 60;
|
|
2559
|
-
var FALLBACK_NAME = "card";
|
|
2560
|
-
function toSessionName(title) {
|
|
2561
|
-
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2562
|
-
if (slug.length === 0) return FALLBACK_NAME;
|
|
2563
|
-
if (slug.length <= MAX_LENGTH) return slug;
|
|
2564
|
-
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2565
|
-
const lastHyphen = truncated.lastIndexOf("-");
|
|
2566
|
-
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2567
|
-
}
|
|
2568
|
-
|
|
2569
|
-
// src/agentSession.ts
|
|
2576
|
+
// src/ptyMessaging.ts
|
|
2570
2577
|
var TUI_IDLE_MS = 1500;
|
|
2571
2578
|
var TUI_READY_TIMEOUT_MS = 6e4;
|
|
2572
2579
|
var PASTE_SETTLE_MS = 200;
|
|
2573
2580
|
var PASTE_SETTLE_TIMEOUT_MS = 5e3;
|
|
2574
2581
|
var PASTE_START = "\x1B[200~";
|
|
2575
2582
|
var PASTE_END = "\x1B[201~";
|
|
2576
|
-
|
|
2577
|
-
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2578
|
-
var SHIFT_TAB_DELAY_MS = 400;
|
|
2579
|
-
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2580
|
-
function writePasteAndSubmit(pty, cardId, message, context) {
|
|
2583
|
+
function writePasteAndSubmit(pty, logId, message, context, log) {
|
|
2581
2584
|
pty.write(`${PASTE_START}${message}${PASTE_END}`);
|
|
2582
2585
|
let settleTimer;
|
|
2583
2586
|
let ceilingTimer;
|
|
@@ -2594,7 +2597,7 @@ function writePasteAndSubmit(pty, cardId, message, context) {
|
|
|
2594
2597
|
try {
|
|
2595
2598
|
pty.write("\r");
|
|
2596
2599
|
} catch (err) {
|
|
2597
|
-
|
|
2600
|
+
log.error(logId, `Failed to submit ${context} to the agent:`, err);
|
|
2598
2601
|
}
|
|
2599
2602
|
};
|
|
2600
2603
|
const cancel = () => {
|
|
@@ -2612,6 +2615,48 @@ function writePasteAndSubmit(pty, cardId, message, context) {
|
|
|
2612
2615
|
ceilingTimer = setTimeout(submit, PASTE_SETTLE_TIMEOUT_MS);
|
|
2613
2616
|
exitSubscription = pty.onExit(cancel);
|
|
2614
2617
|
}
|
|
2618
|
+
function waitForIdleThenRun(pty, run) {
|
|
2619
|
+
let idleTimer;
|
|
2620
|
+
let deadlineTimer;
|
|
2621
|
+
let dataSubscription;
|
|
2622
|
+
let settled = false;
|
|
2623
|
+
const stopWaiting = () => {
|
|
2624
|
+
if (settled) return false;
|
|
2625
|
+
settled = true;
|
|
2626
|
+
clearTimeout(idleTimer);
|
|
2627
|
+
clearTimeout(deadlineTimer);
|
|
2628
|
+
dataSubscription?.dispose();
|
|
2629
|
+
return true;
|
|
2630
|
+
};
|
|
2631
|
+
const fire = () => {
|
|
2632
|
+
if (!stopWaiting()) return;
|
|
2633
|
+
void run();
|
|
2634
|
+
};
|
|
2635
|
+
deadlineTimer = setTimeout(fire, TUI_READY_TIMEOUT_MS);
|
|
2636
|
+
dataSubscription = pty.onData(() => {
|
|
2637
|
+
clearTimeout(idleTimer);
|
|
2638
|
+
idleTimer = setTimeout(fire, TUI_IDLE_MS);
|
|
2639
|
+
});
|
|
2640
|
+
pty.onExit(() => stopWaiting());
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
// src/sessionName.ts
|
|
2644
|
+
var MAX_LENGTH = 60;
|
|
2645
|
+
var FALLBACK_NAME = "card";
|
|
2646
|
+
function toSessionName(title) {
|
|
2647
|
+
const slug = title.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2648
|
+
if (slug.length === 0) return FALLBACK_NAME;
|
|
2649
|
+
if (slug.length <= MAX_LENGTH) return slug;
|
|
2650
|
+
const truncated = slug.slice(0, MAX_LENGTH);
|
|
2651
|
+
const lastHyphen = truncated.lastIndexOf("-");
|
|
2652
|
+
return (lastHyphen > 0 ? truncated.slice(0, lastHyphen) : truncated).replace(/-+$/, "");
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
// src/agentSession.ts
|
|
2656
|
+
var SHIFT_TAB = "\x1B[Z";
|
|
2657
|
+
var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
|
|
2658
|
+
var SHIFT_TAB_DELAY_MS = 400;
|
|
2659
|
+
var PLAN_MODE_INDICATOR = /plan mode on/i;
|
|
2615
2660
|
function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
|
|
2616
2661
|
ensureDirTrusted(repoPath);
|
|
2617
2662
|
ensureMcpServersTrusted(repoPath);
|
|
@@ -2639,33 +2684,14 @@ function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, tic
|
|
|
2639
2684
|
return { pty, claudeSessionId };
|
|
2640
2685
|
}
|
|
2641
2686
|
function sendMessageWhenIdle(pty, cardId, message, planFirst) {
|
|
2642
|
-
|
|
2643
|
-
let deadlineTimer;
|
|
2644
|
-
let dataSubscription;
|
|
2645
|
-
let settled = false;
|
|
2646
|
-
const stopWaiting = () => {
|
|
2647
|
-
if (settled) return false;
|
|
2648
|
-
settled = true;
|
|
2649
|
-
clearTimeout(idleTimer);
|
|
2650
|
-
clearTimeout(deadlineTimer);
|
|
2651
|
-
dataSubscription?.dispose();
|
|
2652
|
-
return true;
|
|
2653
|
-
};
|
|
2654
|
-
const send = async () => {
|
|
2655
|
-
if (!stopWaiting()) return;
|
|
2687
|
+
waitForIdleThenRun(pty, async () => {
|
|
2656
2688
|
if (planFirst) await enterPlanMode(pty, cardId);
|
|
2657
2689
|
try {
|
|
2658
|
-
writePasteAndSubmit(pty, cardId, message, "the card instructions");
|
|
2690
|
+
writePasteAndSubmit(pty, cardId, message, "the card instructions", logger.card);
|
|
2659
2691
|
} catch (err) {
|
|
2660
2692
|
logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
|
|
2661
2693
|
}
|
|
2662
|
-
};
|
|
2663
|
-
deadlineTimer = setTimeout(send, TUI_READY_TIMEOUT_MS);
|
|
2664
|
-
dataSubscription = pty.onData(() => {
|
|
2665
|
-
clearTimeout(idleTimer);
|
|
2666
|
-
idleTimer = setTimeout(send, TUI_IDLE_MS);
|
|
2667
2694
|
});
|
|
2668
|
-
pty.onExit(() => stopWaiting());
|
|
2669
2695
|
}
|
|
2670
2696
|
async function enterPlanMode(pty, cardId) {
|
|
2671
2697
|
let recentOutput = "";
|
|
@@ -2691,7 +2717,7 @@ async function enterPlanMode(pty, cardId) {
|
|
|
2691
2717
|
}
|
|
2692
2718
|
function sendMessageToIdleSession(pty, cardId, message) {
|
|
2693
2719
|
try {
|
|
2694
|
-
writePasteAndSubmit(pty, cardId, message, "a message");
|
|
2720
|
+
writePasteAndSubmit(pty, cardId, message, "a message", logger.card);
|
|
2695
2721
|
} catch (err) {
|
|
2696
2722
|
logger.card.error(cardId, "Failed to send a message to the idle session:", err);
|
|
2697
2723
|
}
|
|
@@ -4215,6 +4241,88 @@ async function stopTerminalTunnel(supabase, config) {
|
|
|
4215
4241
|
await publishHostname(supabase, config, null);
|
|
4216
4242
|
}
|
|
4217
4243
|
|
|
4244
|
+
// src/workflowShell.ts
|
|
4245
|
+
import { randomUUID as randomUUID8 } from "node:crypto";
|
|
4246
|
+
import { spawn as spawn6 } from "node-pty";
|
|
4247
|
+
function registerWorkflowShellRpcHandler(supabase, config) {
|
|
4248
|
+
registerRunnerRpcHandler("open_workflow_shell", (payload) => openWorkflowShell(supabase, config, payload));
|
|
4249
|
+
}
|
|
4250
|
+
function isOpenWorkflowShellPayload(value) {
|
|
4251
|
+
if (!value || typeof value !== "object") return false;
|
|
4252
|
+
const candidate = value;
|
|
4253
|
+
return typeof candidate.projectId === "string" && typeof candidate.executionId === "string" && typeof candidate.instructions === "string" && candidate.instructions.length > 0;
|
|
4254
|
+
}
|
|
4255
|
+
async function openWorkflowShell(supabase, config, payload) {
|
|
4256
|
+
if (!isOpenWorkflowShellPayload(payload)) throw new Error("Malformed open_workflow_shell payload.");
|
|
4257
|
+
const { projectId, executionId, instructions } = payload;
|
|
4258
|
+
const registered = config.registeredProjects.find((project) => project.id === projectId);
|
|
4259
|
+
if (!registered) throw new Error("This runner does not have that project registered.");
|
|
4260
|
+
ensureDirTrusted(registered.repoPath);
|
|
4261
|
+
ensureMcpServersTrusted(registered.repoPath);
|
|
4262
|
+
const claudeSessionId = randomUUID8();
|
|
4263
|
+
const shellTicket = randomUUID8();
|
|
4264
|
+
const pty = spawn6(
|
|
4265
|
+
CLAUDE_BINARY,
|
|
4266
|
+
[
|
|
4267
|
+
"--dangerously-skip-permissions",
|
|
4268
|
+
"--model",
|
|
4269
|
+
"claude-sonnet-5",
|
|
4270
|
+
"--session-id",
|
|
4271
|
+
claudeSessionId,
|
|
4272
|
+
...claudeCliSettingsArgs()
|
|
4273
|
+
],
|
|
4274
|
+
{
|
|
4275
|
+
name: "xterm-color",
|
|
4276
|
+
cols: 120,
|
|
4277
|
+
rows: 32,
|
|
4278
|
+
cwd: registered.repoPath,
|
|
4279
|
+
env: statuslineEnv(executionId, shellTicket)
|
|
4280
|
+
}
|
|
4281
|
+
);
|
|
4282
|
+
registerPtySession(executionId, pty, shellTicket);
|
|
4283
|
+
pty.onExit(({ exitCode }) => void handleWorkflowShellExit(supabase, executionId, exitCode));
|
|
4284
|
+
waitForIdleThenRun(pty, () => {
|
|
4285
|
+
try {
|
|
4286
|
+
writePasteAndSubmit(pty, executionId, instructions, "the workflow runbook", logger.runner);
|
|
4287
|
+
} catch (err) {
|
|
4288
|
+
logger.runner.error(executionId, "Failed to send the workflow runbook to the agent:", err);
|
|
4289
|
+
}
|
|
4290
|
+
});
|
|
4291
|
+
return { claudeSessionId, shellTicket };
|
|
4292
|
+
}
|
|
4293
|
+
async function handleWorkflowShellExit(supabase, executionId, exitCode) {
|
|
4294
|
+
const status = exitCode === 0 ? "done" : "failed";
|
|
4295
|
+
const summary = getPtySession(executionId)?.lastAssistantMessage ?? null;
|
|
4296
|
+
const { error } = await supabase.rpc("complete_workflow_execution", { p_execution_id: executionId, p_status: status, p_summary: summary });
|
|
4297
|
+
if (error) {
|
|
4298
|
+
logger.runner.error("WORKFLOW_SHELL", `Failed to record exit status for workflow execution ${executionId}:`, error);
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
async function reconcileOrphanedWorkflowExecutions(supabase, config) {
|
|
4302
|
+
const { data, error } = await supabase.from("workflow_executions").select("id, project_id, claude_session_id, shell_ticket").eq("runner_id", config.runnerId).in("status", ["running", "paused_on_question"]);
|
|
4303
|
+
if (error) {
|
|
4304
|
+
logger.runner.error("WORKFLOW_SHELL", "Failed to scan orphaned workflow executions:", error.message);
|
|
4305
|
+
return;
|
|
4306
|
+
}
|
|
4307
|
+
for (const execution of data ?? []) {
|
|
4308
|
+
const { id: executionId, project_id: projectId, claude_session_id: claudeSessionId, shell_ticket: shellTicket } = execution;
|
|
4309
|
+
if (getPtySession(executionId)) continue;
|
|
4310
|
+
if (!claudeSessionId || !shellTicket) {
|
|
4311
|
+
logger.runner.warn("WORKFLOW_SHELL", `Workflow execution ${executionId} is missing claude_session_id/shell_ticket \u2014 cannot resume it.`);
|
|
4312
|
+
continue;
|
|
4313
|
+
}
|
|
4314
|
+
const registered = config.registeredProjects.find((project) => project.id === projectId);
|
|
4315
|
+
if (!registered) {
|
|
4316
|
+
logger.runner.warn("WORKFLOW_SHELL", `Workflow execution ${executionId}: project ${projectId} is not registered on this runner \u2014 skipping.`);
|
|
4317
|
+
continue;
|
|
4318
|
+
}
|
|
4319
|
+
const pty = resumeAgentPty(registered.repoPath, claudeSessionId, executionId, shellTicket);
|
|
4320
|
+
registerPtySession(executionId, pty, shellTicket);
|
|
4321
|
+
pty.onExit(({ exitCode }) => void handleWorkflowShellExit(supabase, executionId, exitCode));
|
|
4322
|
+
logger.runner.info("WORKFLOW_SHELL", `Re-spawned orphaned workflow execution ${executionId} (runner restarted while it was still active).`);
|
|
4323
|
+
}
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4218
4326
|
// src/runner.ts
|
|
4219
4327
|
async function startRunner() {
|
|
4220
4328
|
const loadedConfig = loadConfig();
|
|
@@ -4291,6 +4399,9 @@ async function startRunner() {
|
|
|
4291
4399
|
await reconcileStaleSessions(supabase, config, project.id).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileStaleSessions failed:", err));
|
|
4292
4400
|
}
|
|
4293
4401
|
await reconcileOrphanedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileOrphanedSessions failed:", err));
|
|
4402
|
+
await reconcileOrphanedWorkflowExecutions(supabase, config).catch(
|
|
4403
|
+
(err) => logger.runner.error("WORKFLOW_SHELL", "reconcileOrphanedWorkflowExecutions failed:", err)
|
|
4404
|
+
);
|
|
4294
4405
|
await reconcileUsageLimitedSessions(supabase, config).catch((err) => logger.runner.error("ORCHESTRATOR", "reconcileUsageLimitedSessions failed:", err));
|
|
4295
4406
|
const terminalPort = Number(process.env.AGENT_BOARD_TERMINAL_PORT ?? 8787);
|
|
4296
4407
|
ensureStatuslineScriptWritten();
|
|
@@ -4301,6 +4412,7 @@ async function startRunner() {
|
|
|
4301
4412
|
const agentEventsWss = attachAgentEventsServer();
|
|
4302
4413
|
const runnerRpcWss = attachRunnerRpcServer();
|
|
4303
4414
|
registerProjectCommandRpcHandler(supabase, config);
|
|
4415
|
+
registerWorkflowShellRpcHandler(supabase, config);
|
|
4304
4416
|
attachFilesServer(httpServer, supabase, config);
|
|
4305
4417
|
httpServer.on("upgrade", (request, socket, head) => {
|
|
4306
4418
|
const pathname = (request.url ?? "").split("?")[0];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-board-runner",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.22",
|
|
4
4
|
"description": "Local runner for Agent Board — signs in with your own Supabase account (no shared secrets) and drives Claude Code against repos under your local repos root.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|