agent-board-runner 0.1.14 → 0.1.15

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 +178 -39
  2. package/dist/index.js +178 -39
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -2527,7 +2527,7 @@ async function ensureBranchWatchers(supabase, config) {
2527
2527
  // src/columnActions.ts
2528
2528
  import { exec } from "node:child_process";
2529
2529
  import { join as join12 } from "node:path";
2530
- import { promisify as promisify4 } from "node:util";
2530
+ import { promisify as promisify5 } from "node:util";
2531
2531
 
2532
2532
  // src/orchestrator.ts
2533
2533
  import { randomUUID as randomUUID7 } from "node:crypto";
@@ -2733,10 +2733,164 @@ ${list}`;
2733
2733
  }
2734
2734
 
2735
2735
  // src/mcpContextNote.ts
2736
- function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2737
- return `${instructions}
2736
+ function buildInstructionsWithMcpContext(instructions, cardId, projectId, approvedCommands) {
2737
+ const base = `${instructions}
2738
2738
 
2739
2739
  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. If part of the delivery needs a human to do something you can't do yourself (rotate a secret, approve something in a third-party dashboard, delete a resource manually, etc.), register each step with \`add_manual_actions\` instead of just mentioning it in your final message \u2014 that's the only place humans will reliably see it. 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.`;
2740
+ if (approvedCommands.length === 0) return base;
2741
+ const names = approvedCommands.map((command2) => command2.description ? `${command2.name} (${command2.description})` : command2.name).join(", ");
2742
+ return `${base}
2743
+
2744
+ This project already has approved custom command templates you can invoke instead of running the equivalent shell command yourself or proposing a near-duplicate: ${names}. Call \`list_project_commands\` first to see each one's declared arguments, then \`run_project_command\` to invoke it.`;
2745
+ }
2746
+
2747
+ // src/projectCommands.ts
2748
+ import { execFile as execFile4 } from "node:child_process";
2749
+ import { promisify as promisify4 } from "node:util";
2750
+
2751
+ // src/runnerRpcServer.ts
2752
+ import { parse as parse3 } from "node:url";
2753
+ import { WebSocketServer as WebSocketServer2 } from "ws";
2754
+ var handlers = /* @__PURE__ */ new Map();
2755
+ function registerRunnerRpcHandler(method, handler) {
2756
+ handlers.set(method, handler);
2757
+ }
2758
+ function isRpcRequestMessage(value) {
2759
+ if (!value || typeof value !== "object") return false;
2760
+ const candidate = value;
2761
+ return candidate.type === "request" && typeof candidate.id === "string" && typeof candidate.method === "string";
2762
+ }
2763
+ async function handleRequest2(socket, message) {
2764
+ const handler = handlers.get(message.method);
2765
+ let response;
2766
+ if (!handler) {
2767
+ response = { type: "response", id: message.id, ok: false, error: `Unknown runner RPC method: ${message.method}` };
2768
+ } else {
2769
+ try {
2770
+ const result = await handler(message.payload);
2771
+ response = { type: "response", id: message.id, ok: true, result };
2772
+ } catch (err) {
2773
+ response = { type: "response", id: message.id, ok: false, error: err instanceof Error ? err.message : "Internal runner error." };
2774
+ }
2775
+ }
2776
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(response));
2777
+ }
2778
+ function attachRunnerRpcServer() {
2779
+ const wss = new WebSocketServer2({ noServer: true });
2780
+ wss.on("connection", (socket, request) => {
2781
+ const { query } = parse3(request.url ?? "", true);
2782
+ const entry = authenticateAgent(query);
2783
+ if (!entry) {
2784
+ logger.runner.warn("RUNNER-RPC", "Runner RPC WS rejected: invalid or expired agent ticket.");
2785
+ socket.close(4401, "Invalid or expired ticket");
2786
+ return;
2787
+ }
2788
+ socket.on("message", (raw) => {
2789
+ let parsed;
2790
+ try {
2791
+ parsed = JSON.parse(raw.toString());
2792
+ } catch {
2793
+ return;
2794
+ }
2795
+ if (!isRpcRequestMessage(parsed)) return;
2796
+ void handleRequest2(socket, parsed);
2797
+ });
2798
+ });
2799
+ logger.runner.info("RUNNER-RPC", "Runner RPC WebSocket handler attached.");
2800
+ return wss;
2801
+ }
2802
+
2803
+ // src/vaultSecrets.ts
2804
+ async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
2805
+ try {
2806
+ const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
2807
+ if (error) throw error;
2808
+ const rows = data ?? [];
2809
+ const env = {};
2810
+ for (const row of rows) env[row.name] = row.value;
2811
+ if (rows.length > 0) {
2812
+ logger.runner.info(
2813
+ "VAULT",
2814
+ `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
2815
+ );
2816
+ }
2817
+ return env;
2818
+ } catch (err) {
2819
+ logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
2820
+ return {};
2821
+ }
2822
+ }
2823
+
2824
+ // src/projectCommands.ts
2825
+ function registerProjectCommandRpcHandler(supabase, config) {
2826
+ registerRunnerRpcHandler("run_project_command", (payload) => runProjectCommand(supabase, config, payload));
2827
+ }
2828
+ async function listApprovedProjectCommands(supabase, projectId) {
2829
+ try {
2830
+ const { data, error } = await supabase.rpc("resolve_project_commands", { p_project_id: projectId, p_repo_id: null });
2831
+ if (error) throw error;
2832
+ return (data ?? []).map((row) => ({ name: row.name, description: row.description }));
2833
+ } catch (err) {
2834
+ logger.runner.warn("PROJECT_COMMAND", `Failed to resolve approved commands for project ${projectId}:`, err);
2835
+ return [];
2836
+ }
2837
+ }
2838
+ var execFileAsync4 = promisify4(execFile4);
2839
+ var OUTPUT_TAIL_CHARS = 2e4;
2840
+ var COMMAND_TIMEOUT_MS = 5 * 60 * 1e3;
2841
+ var MAX_BUFFER = 10 * 1024 * 1024;
2842
+ var SECRET_PLACEHOLDER_RE = /\{\{secret:([A-Z][A-Z0-9_]*)\}\}/g;
2843
+ function isRunProjectCommandPayload(value) {
2844
+ if (!value || typeof value !== "object") return false;
2845
+ const candidate = value;
2846
+ return typeof candidate.projectId === "string" && Array.isArray(candidate.argv) && candidate.argv.length > 0 && candidate.argv.every((entry) => typeof entry === "string");
2847
+ }
2848
+ function tail(text) {
2849
+ return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
2850
+ }
2851
+ async function runProjectCommand(supabase, config, payload) {
2852
+ if (!isRunProjectCommandPayload(payload)) throw new Error("Malformed run_project_command payload.");
2853
+ const { projectId, argv } = payload;
2854
+ const registered = config.registeredProjects.find((project) => project.id === projectId);
2855
+ if (!registered) throw new Error("This runner does not have that project registered.");
2856
+ const secretsEnv = await resolveProjectSecretsEnv(supabase, projectId, null);
2857
+ const referencedSecretNames = /* @__PURE__ */ new Set();
2858
+ const resolvedArgv = argv.map(
2859
+ (part) => part.replace(SECRET_PLACEHOLDER_RE, (_match, name) => {
2860
+ const value = secretsEnv[name];
2861
+ if (value === void 0) throw new Error(`Secret "${name}" referenced by this command is no longer configured for this project.`);
2862
+ referencedSecretNames.add(name);
2863
+ return value;
2864
+ })
2865
+ );
2866
+ function redact(text) {
2867
+ let redacted = text;
2868
+ for (const name of referencedSecretNames) {
2869
+ const value = secretsEnv[name];
2870
+ if (value) redacted = redacted.split(value).join("***");
2871
+ }
2872
+ return redacted;
2873
+ }
2874
+ try {
2875
+ const { stdout, stderr } = await execFileAsync4(resolvedArgv[0], resolvedArgv.slice(1), {
2876
+ cwd: registered.repoPath,
2877
+ env: process.env,
2878
+ timeout: COMMAND_TIMEOUT_MS,
2879
+ maxBuffer: MAX_BUFFER
2880
+ });
2881
+ logger.runner.info("PROJECT_COMMAND", `Ran "${argv[0]}" for project ${projectId} (exit 0).`);
2882
+ return { ok: true, exitCode: 0, stdout: tail(redact(stdout)), stderr: tail(redact(stderr)) };
2883
+ } catch (err) {
2884
+ const failure = err;
2885
+ const exitCode = typeof failure.code === "number" ? failure.code : null;
2886
+ logger.runner.warn("PROJECT_COMMAND", `"${argv[0]}" failed for project ${projectId} (exit ${exitCode ?? "?"}).`);
2887
+ return {
2888
+ ok: false,
2889
+ exitCode,
2890
+ stdout: tail(redact(failure.stdout ?? "")),
2891
+ stderr: tail(redact(failure.stderr ?? failure.message))
2892
+ };
2893
+ }
2740
2894
  }
2741
2895
 
2742
2896
  // src/prototypeGateNote.ts
@@ -3037,6 +3191,7 @@ async function spawnSessionPty(supabase, config, entry) {
3037
3191
  });
3038
3192
  } else {
3039
3193
  const attachmentPaths = await downloadCardAttachments(supabase, cardId);
3194
+ const approvedCommands = await listApprovedProjectCommands(supabase, card.project_id);
3040
3195
  const instructions = buildInstructionsWithMcpContext(
3041
3196
  buildInstructionsWithPrototypeGate(
3042
3197
  buildInstructionsWithFileReferences(
@@ -3046,7 +3201,8 @@ async function spawnSessionPty(supabase, config, entry) {
3046
3201
  card.prototype_first
3047
3202
  ),
3048
3203
  cardId,
3049
- card.project_id
3204
+ card.project_id,
3205
+ approvedCommands
3050
3206
  );
3051
3207
  const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
3052
3208
  pty = spawned.pty;
@@ -3245,32 +3401,11 @@ async function restartCardSession(supabase, config, card) {
3245
3401
  await enqueueCard(supabase, config, card);
3246
3402
  }
3247
3403
 
3248
- // src/vaultSecrets.ts
3249
- async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
3250
- try {
3251
- const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
3252
- if (error) throw error;
3253
- const rows = data ?? [];
3254
- const env = {};
3255
- for (const row of rows) env[row.name] = row.value;
3256
- if (rows.length > 0) {
3257
- logger.runner.info(
3258
- "VAULT",
3259
- `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
3260
- );
3261
- }
3262
- return env;
3263
- } catch (err) {
3264
- logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
3265
- return {};
3266
- }
3267
- }
3268
-
3269
3404
  // src/columnActions.ts
3270
- var execAsync = promisify4(exec);
3271
- var OUTPUT_TAIL_CHARS = 4e3;
3272
- function tail(text) {
3273
- return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
3405
+ var execAsync = promisify5(exec);
3406
+ var OUTPUT_TAIL_CHARS2 = 4e3;
3407
+ function tail2(text) {
3408
+ return text.length > OUTPUT_TAIL_CHARS2 ? text.slice(text.length - OUTPUT_TAIL_CHARS2) : text;
3274
3409
  }
3275
3410
  function resolveTargets(project, children, repoRoot, column, timing) {
3276
3411
  if (children.length > 0) {
@@ -3284,11 +3419,11 @@ var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
3284
3419
  async function runCommand(target, extraEnv) {
3285
3420
  try {
3286
3421
  const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
3287
- return { ok: true, output: tail(`${stdout}${stderr}`) };
3422
+ return { ok: true, output: tail2(`${stdout}${stderr}`) };
3288
3423
  } catch (err) {
3289
3424
  const { stdout, stderr } = err;
3290
3425
  const message = err instanceof Error ? err.message : String(err);
3291
- return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
3426
+ return { ok: false, output: tail2(`${stdout ?? ""}${stderr ?? ""}` || message) };
3292
3427
  }
3293
3428
  }
3294
3429
  var inFlightColumns = /* @__PURE__ */ new Set();
@@ -3619,12 +3754,12 @@ function stopAll() {
3619
3754
  }
3620
3755
 
3621
3756
  // src/git.ts
3622
- import { execFile as execFile4 } from "node:child_process";
3623
- import { promisify as promisify5 } from "node:util";
3624
- var execFileAsync4 = promisify5(execFile4);
3757
+ import { execFile as execFile5 } from "node:child_process";
3758
+ import { promisify as promisify6 } from "node:util";
3759
+ var execFileAsync5 = promisify6(execFile5);
3625
3760
  async function pushBranchToMain(repoPath) {
3626
3761
  try {
3627
- await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
3762
+ await execFileAsync5("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
3628
3763
  return { ok: true };
3629
3764
  } catch (err) {
3630
3765
  const stderr = err.stderr;
@@ -3884,14 +4019,14 @@ async function checkPendingDevServerRestarts(supabase, config) {
3884
4019
  }
3885
4020
 
3886
4021
  // src/terminalServer.ts
3887
- import { parse as parse3 } from "node:url";
3888
- import { WebSocketServer as WebSocketServer2 } from "ws";
4022
+ import { parse as parse4 } from "node:url";
4023
+ import { WebSocketServer as WebSocketServer3 } from "ws";
3889
4024
  var FRAME_TYPE_RESIZE = 0;
3890
4025
  var FRAME_TYPE_IMAGE = 1;
3891
4026
  function attachTerminalServer() {
3892
- const wss = new WebSocketServer2({ noServer: true });
4027
+ const wss = new WebSocketServer3({ noServer: true });
3893
4028
  wss.on("connection", (socket, request) => {
3894
- const { query } = parse3(request.url ?? "", true);
4029
+ const { query } = parse4(request.url ?? "", true);
3895
4030
  const sessionId = typeof query.sessionId === "string" ? query.sessionId : null;
3896
4031
  const ticket = typeof query.ticket === "string" ? query.ticket : null;
3897
4032
  if (!sessionId || !ticket) {
@@ -4072,6 +4207,8 @@ async function startRunner() {
4072
4207
  const httpServer = createServer2();
4073
4208
  const terminalWss = attachTerminalServer();
4074
4209
  const agentEventsWss = attachAgentEventsServer();
4210
+ const runnerRpcWss = attachRunnerRpcServer();
4211
+ registerProjectCommandRpcHandler(supabase, config);
4075
4212
  attachFilesServer(httpServer, supabase, config);
4076
4213
  httpServer.on("upgrade", (request, socket, head) => {
4077
4214
  const pathname = (request.url ?? "").split("?")[0];
@@ -4079,6 +4216,8 @@ async function startRunner() {
4079
4216
  terminalWss.handleUpgrade(request, socket, head, (ws) => terminalWss.emit("connection", ws, request));
4080
4217
  } else if (pathname === "/agent-events") {
4081
4218
  agentEventsWss.handleUpgrade(request, socket, head, (ws) => agentEventsWss.emit("connection", ws, request));
4219
+ } else if (pathname === "/runner-rpc") {
4220
+ runnerRpcWss.handleUpgrade(request, socket, head, (ws) => runnerRpcWss.emit("connection", ws, request));
4082
4221
  } else {
4083
4222
  socket.destroy();
4084
4223
  }
package/dist/index.js CHANGED
@@ -2415,7 +2415,7 @@ async function ensureBranchWatchers(supabase, config) {
2415
2415
  // src/columnActions.ts
2416
2416
  import { exec } from "node:child_process";
2417
2417
  import { join as join12 } from "node:path";
2418
- import { promisify as promisify4 } from "node:util";
2418
+ import { promisify as promisify5 } from "node:util";
2419
2419
 
2420
2420
  // src/orchestrator.ts
2421
2421
  import { randomUUID as randomUUID7 } from "node:crypto";
@@ -2621,10 +2621,164 @@ ${list}`;
2621
2621
  }
2622
2622
 
2623
2623
  // src/mcpContextNote.ts
2624
- function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2625
- return `${instructions}
2624
+ function buildInstructionsWithMcpContext(instructions, cardId, projectId, approvedCommands) {
2625
+ const base = `${instructions}
2626
2626
 
2627
2627
  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. If part of the delivery needs a human to do something you can't do yourself (rotate a secret, approve something in a third-party dashboard, delete a resource manually, etc.), register each step with \`add_manual_actions\` instead of just mentioning it in your final message \u2014 that's the only place humans will reliably see it. 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.`;
2628
+ if (approvedCommands.length === 0) return base;
2629
+ const names = approvedCommands.map((command) => command.description ? `${command.name} (${command.description})` : command.name).join(", ");
2630
+ return `${base}
2631
+
2632
+ This project already has approved custom command templates you can invoke instead of running the equivalent shell command yourself or proposing a near-duplicate: ${names}. Call \`list_project_commands\` first to see each one's declared arguments, then \`run_project_command\` to invoke it.`;
2633
+ }
2634
+
2635
+ // src/projectCommands.ts
2636
+ import { execFile as execFile4 } from "node:child_process";
2637
+ import { promisify as promisify4 } from "node:util";
2638
+
2639
+ // src/runnerRpcServer.ts
2640
+ import { parse as parse3 } from "node:url";
2641
+ import { WebSocketServer as WebSocketServer2 } from "ws";
2642
+ var handlers = /* @__PURE__ */ new Map();
2643
+ function registerRunnerRpcHandler(method, handler) {
2644
+ handlers.set(method, handler);
2645
+ }
2646
+ function isRpcRequestMessage(value) {
2647
+ if (!value || typeof value !== "object") return false;
2648
+ const candidate = value;
2649
+ return candidate.type === "request" && typeof candidate.id === "string" && typeof candidate.method === "string";
2650
+ }
2651
+ async function handleRequest2(socket, message) {
2652
+ const handler = handlers.get(message.method);
2653
+ let response;
2654
+ if (!handler) {
2655
+ response = { type: "response", id: message.id, ok: false, error: `Unknown runner RPC method: ${message.method}` };
2656
+ } else {
2657
+ try {
2658
+ const result = await handler(message.payload);
2659
+ response = { type: "response", id: message.id, ok: true, result };
2660
+ } catch (err) {
2661
+ response = { type: "response", id: message.id, ok: false, error: err instanceof Error ? err.message : "Internal runner error." };
2662
+ }
2663
+ }
2664
+ if (socket.readyState === socket.OPEN) socket.send(JSON.stringify(response));
2665
+ }
2666
+ function attachRunnerRpcServer() {
2667
+ const wss = new WebSocketServer2({ noServer: true });
2668
+ wss.on("connection", (socket, request) => {
2669
+ const { query } = parse3(request.url ?? "", true);
2670
+ const entry = authenticateAgent(query);
2671
+ if (!entry) {
2672
+ logger.runner.warn("RUNNER-RPC", "Runner RPC WS rejected: invalid or expired agent ticket.");
2673
+ socket.close(4401, "Invalid or expired ticket");
2674
+ return;
2675
+ }
2676
+ socket.on("message", (raw) => {
2677
+ let parsed;
2678
+ try {
2679
+ parsed = JSON.parse(raw.toString());
2680
+ } catch {
2681
+ return;
2682
+ }
2683
+ if (!isRpcRequestMessage(parsed)) return;
2684
+ void handleRequest2(socket, parsed);
2685
+ });
2686
+ });
2687
+ logger.runner.info("RUNNER-RPC", "Runner RPC WebSocket handler attached.");
2688
+ return wss;
2689
+ }
2690
+
2691
+ // src/vaultSecrets.ts
2692
+ async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
2693
+ try {
2694
+ const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
2695
+ if (error) throw error;
2696
+ const rows = data ?? [];
2697
+ const env = {};
2698
+ for (const row of rows) env[row.name] = row.value;
2699
+ if (rows.length > 0) {
2700
+ logger.runner.info(
2701
+ "VAULT",
2702
+ `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
2703
+ );
2704
+ }
2705
+ return env;
2706
+ } catch (err) {
2707
+ logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
2708
+ return {};
2709
+ }
2710
+ }
2711
+
2712
+ // src/projectCommands.ts
2713
+ function registerProjectCommandRpcHandler(supabase, config) {
2714
+ registerRunnerRpcHandler("run_project_command", (payload) => runProjectCommand(supabase, config, payload));
2715
+ }
2716
+ async function listApprovedProjectCommands(supabase, projectId) {
2717
+ try {
2718
+ const { data, error } = await supabase.rpc("resolve_project_commands", { p_project_id: projectId, p_repo_id: null });
2719
+ if (error) throw error;
2720
+ return (data ?? []).map((row) => ({ name: row.name, description: row.description }));
2721
+ } catch (err) {
2722
+ logger.runner.warn("PROJECT_COMMAND", `Failed to resolve approved commands for project ${projectId}:`, err);
2723
+ return [];
2724
+ }
2725
+ }
2726
+ var execFileAsync4 = promisify4(execFile4);
2727
+ var OUTPUT_TAIL_CHARS = 2e4;
2728
+ var COMMAND_TIMEOUT_MS = 5 * 60 * 1e3;
2729
+ var MAX_BUFFER = 10 * 1024 * 1024;
2730
+ var SECRET_PLACEHOLDER_RE = /\{\{secret:([A-Z][A-Z0-9_]*)\}\}/g;
2731
+ function isRunProjectCommandPayload(value) {
2732
+ if (!value || typeof value !== "object") return false;
2733
+ const candidate = value;
2734
+ return typeof candidate.projectId === "string" && Array.isArray(candidate.argv) && candidate.argv.length > 0 && candidate.argv.every((entry) => typeof entry === "string");
2735
+ }
2736
+ function tail(text) {
2737
+ return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
2738
+ }
2739
+ async function runProjectCommand(supabase, config, payload) {
2740
+ if (!isRunProjectCommandPayload(payload)) throw new Error("Malformed run_project_command payload.");
2741
+ const { projectId, argv } = payload;
2742
+ const registered = config.registeredProjects.find((project) => project.id === projectId);
2743
+ if (!registered) throw new Error("This runner does not have that project registered.");
2744
+ const secretsEnv = await resolveProjectSecretsEnv(supabase, projectId, null);
2745
+ const referencedSecretNames = /* @__PURE__ */ new Set();
2746
+ const resolvedArgv = argv.map(
2747
+ (part) => part.replace(SECRET_PLACEHOLDER_RE, (_match, name) => {
2748
+ const value = secretsEnv[name];
2749
+ if (value === void 0) throw new Error(`Secret "${name}" referenced by this command is no longer configured for this project.`);
2750
+ referencedSecretNames.add(name);
2751
+ return value;
2752
+ })
2753
+ );
2754
+ function redact(text) {
2755
+ let redacted = text;
2756
+ for (const name of referencedSecretNames) {
2757
+ const value = secretsEnv[name];
2758
+ if (value) redacted = redacted.split(value).join("***");
2759
+ }
2760
+ return redacted;
2761
+ }
2762
+ try {
2763
+ const { stdout, stderr } = await execFileAsync4(resolvedArgv[0], resolvedArgv.slice(1), {
2764
+ cwd: registered.repoPath,
2765
+ env: process.env,
2766
+ timeout: COMMAND_TIMEOUT_MS,
2767
+ maxBuffer: MAX_BUFFER
2768
+ });
2769
+ logger.runner.info("PROJECT_COMMAND", `Ran "${argv[0]}" for project ${projectId} (exit 0).`);
2770
+ return { ok: true, exitCode: 0, stdout: tail(redact(stdout)), stderr: tail(redact(stderr)) };
2771
+ } catch (err) {
2772
+ const failure = err;
2773
+ const exitCode = typeof failure.code === "number" ? failure.code : null;
2774
+ logger.runner.warn("PROJECT_COMMAND", `"${argv[0]}" failed for project ${projectId} (exit ${exitCode ?? "?"}).`);
2775
+ return {
2776
+ ok: false,
2777
+ exitCode,
2778
+ stdout: tail(redact(failure.stdout ?? "")),
2779
+ stderr: tail(redact(failure.stderr ?? failure.message))
2780
+ };
2781
+ }
2628
2782
  }
2629
2783
 
2630
2784
  // src/prototypeGateNote.ts
@@ -2925,6 +3079,7 @@ async function spawnSessionPty(supabase, config, entry) {
2925
3079
  });
2926
3080
  } else {
2927
3081
  const attachmentPaths = await downloadCardAttachments(supabase, cardId);
3082
+ const approvedCommands = await listApprovedProjectCommands(supabase, card.project_id);
2928
3083
  const instructions = buildInstructionsWithMcpContext(
2929
3084
  buildInstructionsWithPrototypeGate(
2930
3085
  buildInstructionsWithFileReferences(
@@ -2934,7 +3089,8 @@ async function spawnSessionPty(supabase, config, entry) {
2934
3089
  card.prototype_first
2935
3090
  ),
2936
3091
  cardId,
2937
- card.project_id
3092
+ card.project_id,
3093
+ approvedCommands
2938
3094
  );
2939
3095
  const spawned = spawnAgentPty(registered.repoPath, cardId, card.title, instructions, sessionId, ticket, card.plan_first);
2940
3096
  pty = spawned.pty;
@@ -3133,32 +3289,11 @@ async function restartCardSession(supabase, config, card) {
3133
3289
  await enqueueCard(supabase, config, card);
3134
3290
  }
3135
3291
 
3136
- // src/vaultSecrets.ts
3137
- async function resolveProjectSecretsEnv(supabase, projectId, repoId) {
3138
- try {
3139
- const { data, error } = await supabase.rpc("resolve_project_secrets", { p_project_id: projectId, p_repo_id: repoId });
3140
- if (error) throw error;
3141
- const rows = data ?? [];
3142
- const env = {};
3143
- for (const row of rows) env[row.name] = row.value;
3144
- if (rows.length > 0) {
3145
- logger.runner.info(
3146
- "VAULT",
3147
- `Resolved ${rows.length} secret(s) for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}: ${rows.map((r) => r.name).join(", ")}.`
3148
- );
3149
- }
3150
- return env;
3151
- } catch (err) {
3152
- logger.runner.error("VAULT", `Failed to resolve secrets for project ${projectId}${repoId ? ` (repo ${repoId})` : ""}:`, err);
3153
- return {};
3154
- }
3155
- }
3156
-
3157
3292
  // src/columnActions.ts
3158
- var execAsync = promisify4(exec);
3159
- var OUTPUT_TAIL_CHARS = 4e3;
3160
- function tail(text) {
3161
- return text.length > OUTPUT_TAIL_CHARS ? text.slice(text.length - OUTPUT_TAIL_CHARS) : text;
3293
+ var execAsync = promisify5(exec);
3294
+ var OUTPUT_TAIL_CHARS2 = 4e3;
3295
+ function tail2(text) {
3296
+ return text.length > OUTPUT_TAIL_CHARS2 ? text.slice(text.length - OUTPUT_TAIL_CHARS2) : text;
3162
3297
  }
3163
3298
  function resolveTargets(project, children, repoRoot, column, timing) {
3164
3299
  if (children.length > 0) {
@@ -3172,11 +3307,11 @@ var EXEC_OPTIONS = { maxBuffer: 10 * 1024 * 1024 };
3172
3307
  async function runCommand(target, extraEnv) {
3173
3308
  try {
3174
3309
  const { stdout, stderr } = await execAsync(target.command, { ...EXEC_OPTIONS, cwd: target.cwd, env: { ...process.env, ...extraEnv } });
3175
- return { ok: true, output: tail(`${stdout}${stderr}`) };
3310
+ return { ok: true, output: tail2(`${stdout}${stderr}`) };
3176
3311
  } catch (err) {
3177
3312
  const { stdout, stderr } = err;
3178
3313
  const message = err instanceof Error ? err.message : String(err);
3179
- return { ok: false, output: tail(`${stdout ?? ""}${stderr ?? ""}` || message) };
3314
+ return { ok: false, output: tail2(`${stdout ?? ""}${stderr ?? ""}` || message) };
3180
3315
  }
3181
3316
  }
3182
3317
  var inFlightColumns = /* @__PURE__ */ new Set();
@@ -3507,12 +3642,12 @@ function stopAll() {
3507
3642
  }
3508
3643
 
3509
3644
  // src/git.ts
3510
- import { execFile as execFile4 } from "node:child_process";
3511
- import { promisify as promisify5 } from "node:util";
3512
- var execFileAsync4 = promisify5(execFile4);
3645
+ import { execFile as execFile5 } from "node:child_process";
3646
+ import { promisify as promisify6 } from "node:util";
3647
+ var execFileAsync5 = promisify6(execFile5);
3513
3648
  async function pushBranchToMain(repoPath) {
3514
3649
  try {
3515
- await execFileAsync4("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
3650
+ await execFileAsync5("git", ["push", "origin", "HEAD:main"], { cwd: repoPath });
3516
3651
  return { ok: true };
3517
3652
  } catch (err) {
3518
3653
  const stderr = err.stderr;
@@ -3772,14 +3907,14 @@ async function checkPendingDevServerRestarts(supabase, config) {
3772
3907
  }
3773
3908
 
3774
3909
  // src/terminalServer.ts
3775
- import { parse as parse3 } from "node:url";
3776
- import { WebSocketServer as WebSocketServer2 } from "ws";
3910
+ import { parse as parse4 } from "node:url";
3911
+ import { WebSocketServer as WebSocketServer3 } from "ws";
3777
3912
  var FRAME_TYPE_RESIZE = 0;
3778
3913
  var FRAME_TYPE_IMAGE = 1;
3779
3914
  function attachTerminalServer() {
3780
- const wss = new WebSocketServer2({ noServer: true });
3915
+ const wss = new WebSocketServer3({ noServer: true });
3781
3916
  wss.on("connection", (socket, request) => {
3782
- const { query } = parse3(request.url ?? "", true);
3917
+ const { query } = parse4(request.url ?? "", true);
3783
3918
  const sessionId = typeof query.sessionId === "string" ? query.sessionId : null;
3784
3919
  const ticket = typeof query.ticket === "string" ? query.ticket : null;
3785
3920
  if (!sessionId || !ticket) {
@@ -3960,6 +4095,8 @@ async function startRunner() {
3960
4095
  const httpServer = createServer();
3961
4096
  const terminalWss = attachTerminalServer();
3962
4097
  const agentEventsWss = attachAgentEventsServer();
4098
+ const runnerRpcWss = attachRunnerRpcServer();
4099
+ registerProjectCommandRpcHandler(supabase, config);
3963
4100
  attachFilesServer(httpServer, supabase, config);
3964
4101
  httpServer.on("upgrade", (request, socket, head) => {
3965
4102
  const pathname = (request.url ?? "").split("?")[0];
@@ -3967,6 +4104,8 @@ async function startRunner() {
3967
4104
  terminalWss.handleUpgrade(request, socket, head, (ws) => terminalWss.emit("connection", ws, request));
3968
4105
  } else if (pathname === "/agent-events") {
3969
4106
  agentEventsWss.handleUpgrade(request, socket, head, (ws) => agentEventsWss.emit("connection", ws, request));
4107
+ } else if (pathname === "/runner-rpc") {
4108
+ runnerRpcWss.handleUpgrade(request, socket, head, (ws) => runnerRpcWss.emit("connection", ws, request));
3970
4109
  } else {
3971
4110
  socket.destroy();
3972
4111
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-board-runner",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
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": {