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.
Files changed (3) hide show
  1. package/dist/cli.js +157 -45
  2. package/dist/index.js +157 -45
  3. 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 = { pty, ticket, outputBuffer: [], sockets: /* @__PURE__ */ new Set(), readOnly: false, exited: false };
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) await reportToRunner(sessionId, ticket, port, moment);
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/sessionName.ts
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
- var SHIFT_TAB = "\x1B[Z";
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
- logger.card.error(cardId, `Failed to submit ${context} to the agent:`, err);
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
- let idleTimer;
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 = { pty, ticket, outputBuffer: [], sockets: /* @__PURE__ */ new Set(), readOnly: false, exited: false };
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) await reportToRunner(sessionId, ticket, port, moment);
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/sessionName.ts
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
- var SHIFT_TAB = "\x1B[Z";
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
- logger.card.error(cardId, `Failed to submit ${context} to the agent:`, err);
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
- let idleTimer;
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.20",
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": {