agent-board-runner 0.1.17 → 0.1.19

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 +170 -25
  2. package/dist/index.js +170 -25
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -320,7 +320,7 @@ var BOX_DRAWING_PATTERN = /[─-╿]/g;
320
320
  var WORKING_PATTERN = /esc to interrupt/i;
321
321
  var BACKGROUND_SHELL_PATTERN = /\b[1-9]\d*\s+shells?\b/i;
322
322
  function isAgentHookMoment(value) {
323
- return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "waiting_prototype_review" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
323
+ return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "waiting_prototype_review" || value === "waiting_workflow_answer" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
324
324
  }
325
325
  var USAGE_LIMIT_PATTERN = /(usage limit reached|limit reached[^\n]{0,60}resets|reached your (?:usage|session) limit|out of (?:weekly|session) (?:usage|limit))/i;
326
326
  var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
@@ -372,6 +372,8 @@ var AgentActivityWatcher = class {
372
372
  * "waiting_user" — see the guard in reportHookMoment's "turn_stopped" case.
373
373
  */
374
374
  prototypeReviewPending = false;
375
+ /** Same shape as prototypeReviewPending, for ask_workflow_question (see docs/ai-knowledges/workflows-visuais-mecanismo.md). */
376
+ workflowAnswerPending = false;
375
377
  toolCallPending = false;
376
378
  commitDetected = false;
377
379
  timer;
@@ -414,6 +416,7 @@ var AgentActivityWatcher = class {
414
416
  this.usageLimited = false;
415
417
  this.planApprovalPending = false;
416
418
  this.prototypeReviewPending = false;
419
+ this.workflowAnswerPending = false;
417
420
  this.commitDetected = false;
418
421
  this.setState("implementing");
419
422
  break;
@@ -429,12 +432,17 @@ var AgentActivityWatcher = class {
429
432
  this.prototypeReviewPending = true;
430
433
  this.setState("waiting_prototype_review");
431
434
  break;
435
+ case "waiting_workflow_answer":
436
+ this.workflowAnswerPending = true;
437
+ this.setState("waiting_workflow_answer");
438
+ break;
432
439
  case "commit_detected":
433
440
  this.commitDetected = true;
434
441
  break;
435
442
  case "turn_stopped":
436
443
  if (this.usageLimited) break;
437
444
  if (this.prototypeReviewPending) break;
445
+ if (this.workflowAnswerPending) break;
438
446
  if (BACKGROUND_SHELL_PATTERN.test(this.recentOutput())) {
439
447
  this.setState("waiting_background");
440
448
  break;
@@ -1255,6 +1263,11 @@ function detectMoment(input) {
1255
1263
  // prototypeReviewPending latch in agentActivity.ts's reportHookMoment.
1256
1264
  if (eventName === "PostToolUse" && input.tool_name === "mcp__agent-board__publish_prototype") return "waiting_prototype_review";
1257
1265
 
1266
+ // Same shape as publish_prototype above, but for a workflow graph's "ask" node (see
1267
+ // ask_workflow_question in boardTools.ts and docs/ai-knowledges/workflows-visuais-mecanismo.md)
1268
+ // \u2014 see the workflowAnswerPending latch in agentActivity.ts's reportHookMoment.
1269
+ if (eventName === "PostToolUse" && input.tool_name === "mcp__agent-board__ask_workflow_question") return "waiting_workflow_answer";
1270
+
1258
1271
  // A commit that actually succeeded, read off the Bash tool's own structured result instead
1259
1272
  // of guessing from scrollback text (exact, and immune to locale/color/format changes to
1260
1273
  // git's own banner). Any other Bash completion still reports "tool_active" below, same as
@@ -2103,7 +2116,22 @@ function parseGitDiffNameStatus(output) {
2103
2116
  const columns = line.split(" ");
2104
2117
  const status = columns[0].slice(0, 1);
2105
2118
  const path = columns[columns.length - 1];
2106
- return { path, status };
2119
+ return { path, status, additions: null, deletions: null, binary: false };
2120
+ });
2121
+ }
2122
+ function parseGitDiffNumstat(output) {
2123
+ return output.split("\n").filter((line) => line.length > 0).map((line) => {
2124
+ const [added, deleted, path] = line.split(" ");
2125
+ const binary = added === "-" && deleted === "-";
2126
+ return { path, additions: binary ? null : Number(added), deletions: binary ? null : Number(deleted), binary };
2127
+ });
2128
+ }
2129
+ function mergeNumstat(files, numstat) {
2130
+ const byPath = new Map(numstat.map((entry) => [entry.path, entry]));
2131
+ return files.map((file) => {
2132
+ const stat = byPath.get(file.path);
2133
+ if (!stat) return file;
2134
+ return { ...file, additions: stat.additions, deletions: stat.deletions, binary: stat.binary };
2107
2135
  });
2108
2136
  }
2109
2137
  async function getRepoDiffForTarget(label, cwd) {
@@ -2137,14 +2165,84 @@ async function getRepoDiffForTarget(label, cwd) {
2137
2165
  files = parseGitDiffNameStatus(stdout);
2138
2166
  } catch (err) {
2139
2167
  const message = err instanceof Error ? err.message : String(err);
2140
- return { label, files: [], diff: "", error: message };
2168
+ return { label, files: [], diff: "", branch, error: message };
2169
+ }
2170
+ if (files.length === 0) return { label, files, diff: "", branch };
2171
+ try {
2172
+ const { stdout: numstatOut } = await execFileAsync2("git", ["diff", "--numstat", remoteRef, "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
2173
+ files = mergeNumstat(files, parseGitDiffNumstat(numstatOut));
2174
+ } catch {
2141
2175
  }
2142
- if (files.length === 0) return { label, files, diff: "" };
2143
2176
  try {
2144
2177
  const { stdout } = await execFileAsync2("git", ["diff", remoteRef, "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
2145
- return { label, files, diff: stdout };
2178
+ return { label, files, diff: stdout, branch };
2146
2179
  } catch {
2147
- return { label, files, diff: "" };
2180
+ return { label, files, diff: "", branch };
2181
+ }
2182
+ }
2183
+ async function resolveDiffTargetCwd(entry, projectId, label) {
2184
+ if (!label) return entry.repoRoot;
2185
+ if (!statuslineSupabase) throw new HttpError(400, "Reposit\xF3rio filho n\xE3o dispon\xEDvel.");
2186
+ const children = await getProjectRepos(statuslineSupabase, projectId);
2187
+ const child2 = children.find((candidate) => candidate.subpath === label);
2188
+ if (!child2) throw new HttpError(400, "Reposit\xF3rio filho desconhecido.");
2189
+ return join9(entry.repoRoot, child2.subpath);
2190
+ }
2191
+ async function readFileAtRevision(cwd, ref, relPath) {
2192
+ try {
2193
+ const { stdout } = await execFileAsync2("git", ["show", `${ref}:${relPath}`], {
2194
+ cwd,
2195
+ maxBuffer: DIFF_EXEC_OPTIONS.maxBuffer,
2196
+ encoding: "buffer"
2197
+ });
2198
+ return stdout;
2199
+ } catch {
2200
+ return null;
2201
+ }
2202
+ }
2203
+ function toFileRevisionJson(buffer, imageContentType) {
2204
+ if (buffer === null) return { exists: false, content: null, binary: false };
2205
+ if (looksBinary(buffer)) {
2206
+ return {
2207
+ exists: true,
2208
+ content: null,
2209
+ binary: true,
2210
+ ...imageContentType ? { imageBase64: buffer.toString("base64"), imageContentType } : {}
2211
+ };
2212
+ }
2213
+ return { exists: true, content: buffer.toString("utf8"), binary: false };
2214
+ }
2215
+ async function handleDiffFileRequest(entry, res, projectId, relPath, label) {
2216
+ if (!relPath) {
2217
+ sendJson(res, 400, { error: "Par\xE2metro path \xE9 obrigat\xF3rio." });
2218
+ return;
2219
+ }
2220
+ try {
2221
+ const cwd = await resolveDiffTargetCwd(entry, projectId, label);
2222
+ resolveWithinRepo(cwd, relPath);
2223
+ const { stdout: branchOut } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
2224
+ const branch = branchOut.trim();
2225
+ if (!branch) {
2226
+ sendJson(res, 400, { error: "HEAD destacado: sem branch local para comparar com a origin." });
2227
+ return;
2228
+ }
2229
+ const [originalBuffer, modifiedBuffer] = await Promise.all([
2230
+ readFileAtRevision(cwd, `origin/${branch}`, relPath),
2231
+ readFileAtRevision(cwd, "HEAD", relPath)
2232
+ ]);
2233
+ const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
2234
+ const imageContentType = MIME_TYPES_BY_EXT[ext];
2235
+ sendJson(res, 200, {
2236
+ original: toFileRevisionJson(originalBuffer, imageContentType),
2237
+ modified: toFileRevisionJson(modifiedBuffer, imageContentType)
2238
+ });
2239
+ } catch (err) {
2240
+ if (err instanceof HttpError) {
2241
+ sendJson(res, err.status, { error: err.message });
2242
+ return;
2243
+ }
2244
+ logger.runner.error("FILES", "Unexpected error handling a diff-file request:", err);
2245
+ sendJson(res, 500, { error: "Erro interno do runner." });
2148
2246
  }
2149
2247
  }
2150
2248
  async function handleDiffRequest(entry, res, projectId) {
@@ -2329,6 +2427,12 @@ function handleRequest(req, res) {
2329
2427
  void handleDiffRequest(entry, res, projectId);
2330
2428
  return;
2331
2429
  }
2430
+ if (pathname === "/diff-file") {
2431
+ const projectId = typeof query.projectId === "string" ? query.projectId : "";
2432
+ const label = typeof query.label === "string" ? query.label : null;
2433
+ void handleDiffFileRequest(entry, res, projectId, relPath, label);
2434
+ return;
2435
+ }
2332
2436
  try {
2333
2437
  if (pathname === "/tree") {
2334
2438
  sendJson(res, 200, { entries: listDirectory(entry.repoRoot, relPath) });
@@ -2577,13 +2681,49 @@ function toSessionName(title) {
2577
2681
  // src/agentSession.ts
2578
2682
  var TUI_IDLE_MS = 1500;
2579
2683
  var TUI_READY_TIMEOUT_MS = 6e4;
2580
- var SUBMIT_DELAY_MS = 150;
2684
+ var PASTE_SETTLE_MS = 200;
2685
+ var PASTE_SETTLE_TIMEOUT_MS = 5e3;
2581
2686
  var PASTE_START = "\x1B[200~";
2582
2687
  var PASTE_END = "\x1B[201~";
2583
2688
  var SHIFT_TAB = "\x1B[Z";
2584
2689
  var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
2585
2690
  var SHIFT_TAB_DELAY_MS = 400;
2586
2691
  var PLAN_MODE_INDICATOR = /plan mode on/i;
2692
+ function writePasteAndSubmit(pty, cardId, message, context) {
2693
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2694
+ let settleTimer;
2695
+ let ceilingTimer;
2696
+ let dataSubscription;
2697
+ let exitSubscription;
2698
+ let submitted = false;
2699
+ const submit = () => {
2700
+ if (submitted) return;
2701
+ submitted = true;
2702
+ clearTimeout(settleTimer);
2703
+ clearTimeout(ceilingTimer);
2704
+ dataSubscription?.dispose();
2705
+ exitSubscription?.dispose();
2706
+ try {
2707
+ pty.write("\r");
2708
+ } catch (err) {
2709
+ logger.card.error(cardId, `Failed to submit ${context} to the agent:`, err);
2710
+ }
2711
+ };
2712
+ const cancel = () => {
2713
+ submitted = true;
2714
+ clearTimeout(settleTimer);
2715
+ clearTimeout(ceilingTimer);
2716
+ dataSubscription?.dispose();
2717
+ exitSubscription?.dispose();
2718
+ };
2719
+ dataSubscription = pty.onData(() => {
2720
+ clearTimeout(settleTimer);
2721
+ settleTimer = setTimeout(submit, PASTE_SETTLE_MS);
2722
+ });
2723
+ settleTimer = setTimeout(submit, PASTE_SETTLE_MS);
2724
+ ceilingTimer = setTimeout(submit, PASTE_SETTLE_TIMEOUT_MS);
2725
+ exitSubscription = pty.onExit(cancel);
2726
+ }
2587
2727
  function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2588
2728
  ensureDirTrusted(repoPath);
2589
2729
  ensureMcpServersTrusted(repoPath);
@@ -2627,14 +2767,7 @@ function sendMessageWhenIdle(pty, cardId, message, planFirst) {
2627
2767
  if (!stopWaiting()) return;
2628
2768
  if (planFirst) await enterPlanMode(pty, cardId);
2629
2769
  try {
2630
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2631
- setTimeout(() => {
2632
- try {
2633
- pty.write("\r");
2634
- } catch (err) {
2635
- logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
2636
- }
2637
- }, SUBMIT_DELAY_MS);
2770
+ writePasteAndSubmit(pty, cardId, message, "the card instructions");
2638
2771
  } catch (err) {
2639
2772
  logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
2640
2773
  }
@@ -2670,14 +2803,7 @@ async function enterPlanMode(pty, cardId) {
2670
2803
  }
2671
2804
  function sendMessageToIdleSession(pty, cardId, message) {
2672
2805
  try {
2673
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2674
- setTimeout(() => {
2675
- try {
2676
- pty.write("\r");
2677
- } catch (err) {
2678
- logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2679
- }
2680
- }, SUBMIT_DELAY_MS);
2806
+ writePasteAndSubmit(pty, cardId, message, "a message");
2681
2807
  } catch (err) {
2682
2808
  logger.card.error(cardId, "Failed to send a message to the idle session:", err);
2683
2809
  }
@@ -3116,7 +3242,7 @@ async function dispatchRunnerQueue(supabase, config) {
3116
3242
  for (const project of config.registeredProjects) {
3117
3243
  const running = await getRunningSessionForProject(supabase, project.id, config.runnerId);
3118
3244
  if (!running) continue;
3119
- if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
3245
+ if ((running.agentState === "done" || running.agentState === "waiting_prototype_review" || running.agentState === "waiting_workflow_answer") && queuedProjectIds.has(project.id)) {
3120
3246
  await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
3121
3247
  continue;
3122
3248
  }
@@ -3181,6 +3307,14 @@ async function publishQueueSnapshots(supabase, config, queued, promotedCardIds)
3181
3307
  function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
3182
3308
  return prototypeApproved ? "O prot\xF3tipo foi revisado e aprovado \u2014 prossiga para a implementa\xE7\xE3o real, usando-o como refer\xEAncia." : `O prot\xF3tipo ainda n\xE3o foi aprovado${prototypeUrl ? ` (${prototypeUrl})` : ""} \u2014 confira os coment\xE1rios deixados nele (e, se for um prot\xF3tipo de marca\xE7\xE3o de pontos, os pontos salvos) e ajuste, publicando a nova vers\xE3o com publish_prototype. Depois, pare o turno de novo: a sess\xE3o ser\xE1 retomada quando houver outra revis\xE3o.`;
3183
3309
  }
3310
+ async function buildWorkflowAnswerFollowUp(supabase, cardId) {
3311
+ const { data } = await supabase.from("card_workflow_questions").select("question, answer").eq("card_id", cardId).eq("status", "answered").order("answered_at", { ascending: false }).limit(1).maybeSingle();
3312
+ if (!data) return "A pergunta foi respondida \u2014 confira os coment\xE1rios do card e prossiga.";
3313
+ return `Pergunta: "${data.question}"
3314
+ Resposta do humano: "${data.answer}"
3315
+
3316
+ Prossiga com o workflow a partir daqui.`;
3317
+ }
3184
3318
  async function promoteQueuedSession(supabase, config, entry) {
3185
3319
  const existingHandle = getPtySession(entry.id);
3186
3320
  if (existingHandle) {
@@ -3200,6 +3334,9 @@ async function promoteQueuedSession(supabase, config, entry) {
3200
3334
  buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
3201
3335
  );
3202
3336
  }
3337
+ if (entry.agent_state === "waiting_workflow_answer" && !existingHandle.exited) {
3338
+ sendMessageToIdleSession(existingHandle.pty, entry.card_id, await buildWorkflowAnswerFollowUp(supabase, entry.card_id));
3339
+ }
3203
3340
  return;
3204
3341
  }
3205
3342
  const spawned = await spawnSessionPty(supabase, config, entry);
@@ -3318,7 +3455,7 @@ function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty)
3318
3455
  } else {
3319
3456
  cancelUsageResume(sessionId);
3320
3457
  }
3321
- if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
3458
+ if (state === "done" || state === "waiting_prototype_review" || state === "waiting_workflow_answer") void dispatchRunnerQueue(supabase, config);
3322
3459
  });
3323
3460
  pty.onData((chunk) => watcher.push(chunk));
3324
3461
  registerAgentActivityWatcher(sessionId, watcher);
@@ -3353,6 +3490,14 @@ async function requeuePausedCard(supabase, config, card) {
3353
3490
  logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
3354
3491
  }
3355
3492
  }
3493
+ if (active.status === "running" && active.agentState === "waiting_workflow_answer") {
3494
+ const handle = getPtySession(active.id);
3495
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3496
+ if (handle && !handle.exited) {
3497
+ sendMessageToIdleSession(handle.pty, card.id, await buildWorkflowAnswerFollowUp(supabase, card.id));
3498
+ logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the workflow answer follow-up directly.`);
3499
+ }
3500
+ }
3356
3501
  }
3357
3502
  async function reconcileOrphanedSessions(supabase, config) {
3358
3503
  for (const project of config.registeredProjects) {
package/dist/index.js CHANGED
@@ -40,7 +40,7 @@ var BOX_DRAWING_PATTERN = /[─-╿]/g;
40
40
  var WORKING_PATTERN = /esc to interrupt/i;
41
41
  var BACKGROUND_SHELL_PATTERN = /\b[1-9]\d*\s+shells?\b/i;
42
42
  function isAgentHookMoment(value) {
43
- return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "waiting_prototype_review" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
43
+ return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "waiting_prototype_review" || value === "waiting_workflow_answer" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
44
44
  }
45
45
  var USAGE_LIMIT_PATTERN = /(usage limit reached|limit reached[^\n]{0,60}resets|reached your (?:usage|session) limit|out of (?:weekly|session) (?:usage|limit))/i;
46
46
  var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
@@ -92,6 +92,8 @@ var AgentActivityWatcher = class {
92
92
  * "waiting_user" — see the guard in reportHookMoment's "turn_stopped" case.
93
93
  */
94
94
  prototypeReviewPending = false;
95
+ /** Same shape as prototypeReviewPending, for ask_workflow_question (see docs/ai-knowledges/workflows-visuais-mecanismo.md). */
96
+ workflowAnswerPending = false;
95
97
  toolCallPending = false;
96
98
  commitDetected = false;
97
99
  timer;
@@ -134,6 +136,7 @@ var AgentActivityWatcher = class {
134
136
  this.usageLimited = false;
135
137
  this.planApprovalPending = false;
136
138
  this.prototypeReviewPending = false;
139
+ this.workflowAnswerPending = false;
137
140
  this.commitDetected = false;
138
141
  this.setState("implementing");
139
142
  break;
@@ -149,12 +152,17 @@ var AgentActivityWatcher = class {
149
152
  this.prototypeReviewPending = true;
150
153
  this.setState("waiting_prototype_review");
151
154
  break;
155
+ case "waiting_workflow_answer":
156
+ this.workflowAnswerPending = true;
157
+ this.setState("waiting_workflow_answer");
158
+ break;
152
159
  case "commit_detected":
153
160
  this.commitDetected = true;
154
161
  break;
155
162
  case "turn_stopped":
156
163
  if (this.usageLimited) break;
157
164
  if (this.prototypeReviewPending) break;
165
+ if (this.workflowAnswerPending) break;
158
166
  if (BACKGROUND_SHELL_PATTERN.test(this.recentOutput())) {
159
167
  this.setState("waiting_background");
160
168
  break;
@@ -975,6 +983,11 @@ function detectMoment(input) {
975
983
  // prototypeReviewPending latch in agentActivity.ts's reportHookMoment.
976
984
  if (eventName === "PostToolUse" && input.tool_name === "mcp__agent-board__publish_prototype") return "waiting_prototype_review";
977
985
 
986
+ // Same shape as publish_prototype above, but for a workflow graph's "ask" node (see
987
+ // ask_workflow_question in boardTools.ts and docs/ai-knowledges/workflows-visuais-mecanismo.md)
988
+ // \u2014 see the workflowAnswerPending latch in agentActivity.ts's reportHookMoment.
989
+ if (eventName === "PostToolUse" && input.tool_name === "mcp__agent-board__ask_workflow_question") return "waiting_workflow_answer";
990
+
978
991
  // A commit that actually succeeded, read off the Bash tool's own structured result instead
979
992
  // of guessing from scrollback text (exact, and immune to locale/color/format changes to
980
993
  // git's own banner). Any other Bash completion still reports "tool_active" below, same as
@@ -1823,7 +1836,22 @@ function parseGitDiffNameStatus(output) {
1823
1836
  const columns = line.split(" ");
1824
1837
  const status = columns[0].slice(0, 1);
1825
1838
  const path = columns[columns.length - 1];
1826
- return { path, status };
1839
+ return { path, status, additions: null, deletions: null, binary: false };
1840
+ });
1841
+ }
1842
+ function parseGitDiffNumstat(output) {
1843
+ return output.split("\n").filter((line) => line.length > 0).map((line) => {
1844
+ const [added, deleted, path] = line.split(" ");
1845
+ const binary = added === "-" && deleted === "-";
1846
+ return { path, additions: binary ? null : Number(added), deletions: binary ? null : Number(deleted), binary };
1847
+ });
1848
+ }
1849
+ function mergeNumstat(files, numstat) {
1850
+ const byPath = new Map(numstat.map((entry) => [entry.path, entry]));
1851
+ return files.map((file) => {
1852
+ const stat = byPath.get(file.path);
1853
+ if (!stat) return file;
1854
+ return { ...file, additions: stat.additions, deletions: stat.deletions, binary: stat.binary };
1827
1855
  });
1828
1856
  }
1829
1857
  async function getRepoDiffForTarget(label, cwd) {
@@ -1857,14 +1885,84 @@ async function getRepoDiffForTarget(label, cwd) {
1857
1885
  files = parseGitDiffNameStatus(stdout);
1858
1886
  } catch (err) {
1859
1887
  const message = err instanceof Error ? err.message : String(err);
1860
- return { label, files: [], diff: "", error: message };
1888
+ return { label, files: [], diff: "", branch, error: message };
1889
+ }
1890
+ if (files.length === 0) return { label, files, diff: "", branch };
1891
+ try {
1892
+ const { stdout: numstatOut } = await execFileAsync2("git", ["diff", "--numstat", remoteRef, "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1893
+ files = mergeNumstat(files, parseGitDiffNumstat(numstatOut));
1894
+ } catch {
1861
1895
  }
1862
- if (files.length === 0) return { label, files, diff: "" };
1863
1896
  try {
1864
1897
  const { stdout } = await execFileAsync2("git", ["diff", remoteRef, "HEAD"], { ...DIFF_EXEC_OPTIONS, cwd });
1865
- return { label, files, diff: stdout };
1898
+ return { label, files, diff: stdout, branch };
1866
1899
  } catch {
1867
- return { label, files, diff: "" };
1900
+ return { label, files, diff: "", branch };
1901
+ }
1902
+ }
1903
+ async function resolveDiffTargetCwd(entry, projectId, label) {
1904
+ if (!label) return entry.repoRoot;
1905
+ if (!statuslineSupabase) throw new HttpError(400, "Reposit\xF3rio filho n\xE3o dispon\xEDvel.");
1906
+ const children = await getProjectRepos(statuslineSupabase, projectId);
1907
+ const child2 = children.find((candidate) => candidate.subpath === label);
1908
+ if (!child2) throw new HttpError(400, "Reposit\xF3rio filho desconhecido.");
1909
+ return join7(entry.repoRoot, child2.subpath);
1910
+ }
1911
+ async function readFileAtRevision(cwd, ref, relPath) {
1912
+ try {
1913
+ const { stdout } = await execFileAsync2("git", ["show", `${ref}:${relPath}`], {
1914
+ cwd,
1915
+ maxBuffer: DIFF_EXEC_OPTIONS.maxBuffer,
1916
+ encoding: "buffer"
1917
+ });
1918
+ return stdout;
1919
+ } catch {
1920
+ return null;
1921
+ }
1922
+ }
1923
+ function toFileRevisionJson(buffer, imageContentType) {
1924
+ if (buffer === null) return { exists: false, content: null, binary: false };
1925
+ if (looksBinary(buffer)) {
1926
+ return {
1927
+ exists: true,
1928
+ content: null,
1929
+ binary: true,
1930
+ ...imageContentType ? { imageBase64: buffer.toString("base64"), imageContentType } : {}
1931
+ };
1932
+ }
1933
+ return { exists: true, content: buffer.toString("utf8"), binary: false };
1934
+ }
1935
+ async function handleDiffFileRequest(entry, res, projectId, relPath, label) {
1936
+ if (!relPath) {
1937
+ sendJson(res, 400, { error: "Par\xE2metro path \xE9 obrigat\xF3rio." });
1938
+ return;
1939
+ }
1940
+ try {
1941
+ const cwd = await resolveDiffTargetCwd(entry, projectId, label);
1942
+ resolveWithinRepo(cwd, relPath);
1943
+ const { stdout: branchOut } = await execFileAsync2("git", ["branch", "--show-current"], { cwd });
1944
+ const branch = branchOut.trim();
1945
+ if (!branch) {
1946
+ sendJson(res, 400, { error: "HEAD destacado: sem branch local para comparar com a origin." });
1947
+ return;
1948
+ }
1949
+ const [originalBuffer, modifiedBuffer] = await Promise.all([
1950
+ readFileAtRevision(cwd, `origin/${branch}`, relPath),
1951
+ readFileAtRevision(cwd, "HEAD", relPath)
1952
+ ]);
1953
+ const ext = relPath.split(".").pop()?.toLowerCase() ?? "";
1954
+ const imageContentType = MIME_TYPES_BY_EXT[ext];
1955
+ sendJson(res, 200, {
1956
+ original: toFileRevisionJson(originalBuffer, imageContentType),
1957
+ modified: toFileRevisionJson(modifiedBuffer, imageContentType)
1958
+ });
1959
+ } catch (err) {
1960
+ if (err instanceof HttpError) {
1961
+ sendJson(res, err.status, { error: err.message });
1962
+ return;
1963
+ }
1964
+ logger.runner.error("FILES", "Unexpected error handling a diff-file request:", err);
1965
+ sendJson(res, 500, { error: "Erro interno do runner." });
1868
1966
  }
1869
1967
  }
1870
1968
  async function handleDiffRequest(entry, res, projectId) {
@@ -2049,6 +2147,12 @@ function handleRequest(req, res) {
2049
2147
  void handleDiffRequest(entry, res, projectId);
2050
2148
  return;
2051
2149
  }
2150
+ if (pathname === "/diff-file") {
2151
+ const projectId = typeof query.projectId === "string" ? query.projectId : "";
2152
+ const label = typeof query.label === "string" ? query.label : null;
2153
+ void handleDiffFileRequest(entry, res, projectId, relPath, label);
2154
+ return;
2155
+ }
2052
2156
  try {
2053
2157
  if (pathname === "/tree") {
2054
2158
  sendJson(res, 200, { entries: listDirectory(entry.repoRoot, relPath) });
@@ -2465,13 +2569,49 @@ function toSessionName(title) {
2465
2569
  // src/agentSession.ts
2466
2570
  var TUI_IDLE_MS = 1500;
2467
2571
  var TUI_READY_TIMEOUT_MS = 6e4;
2468
- var SUBMIT_DELAY_MS = 150;
2572
+ var PASTE_SETTLE_MS = 200;
2573
+ var PASTE_SETTLE_TIMEOUT_MS = 5e3;
2469
2574
  var PASTE_START = "\x1B[200~";
2470
2575
  var PASTE_END = "\x1B[201~";
2471
2576
  var SHIFT_TAB = "\x1B[Z";
2472
2577
  var SHIFT_TAB_PRESSES_TO_PLAN_MODE = 4;
2473
2578
  var SHIFT_TAB_DELAY_MS = 400;
2474
2579
  var PLAN_MODE_INDICATOR = /plan mode on/i;
2580
+ function writePasteAndSubmit(pty, cardId, message, context) {
2581
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2582
+ let settleTimer;
2583
+ let ceilingTimer;
2584
+ let dataSubscription;
2585
+ let exitSubscription;
2586
+ let submitted = false;
2587
+ const submit = () => {
2588
+ if (submitted) return;
2589
+ submitted = true;
2590
+ clearTimeout(settleTimer);
2591
+ clearTimeout(ceilingTimer);
2592
+ dataSubscription?.dispose();
2593
+ exitSubscription?.dispose();
2594
+ try {
2595
+ pty.write("\r");
2596
+ } catch (err) {
2597
+ logger.card.error(cardId, `Failed to submit ${context} to the agent:`, err);
2598
+ }
2599
+ };
2600
+ const cancel = () => {
2601
+ submitted = true;
2602
+ clearTimeout(settleTimer);
2603
+ clearTimeout(ceilingTimer);
2604
+ dataSubscription?.dispose();
2605
+ exitSubscription?.dispose();
2606
+ };
2607
+ dataSubscription = pty.onData(() => {
2608
+ clearTimeout(settleTimer);
2609
+ settleTimer = setTimeout(submit, PASTE_SETTLE_MS);
2610
+ });
2611
+ settleTimer = setTimeout(submit, PASTE_SETTLE_MS);
2612
+ ceilingTimer = setTimeout(submit, PASTE_SETTLE_TIMEOUT_MS);
2613
+ exitSubscription = pty.onExit(cancel);
2614
+ }
2475
2615
  function spawnAgentPty(repoPath, cardId, cardTitle, instructions, sessionId, ticket, planFirst) {
2476
2616
  ensureDirTrusted(repoPath);
2477
2617
  ensureMcpServersTrusted(repoPath);
@@ -2515,14 +2655,7 @@ function sendMessageWhenIdle(pty, cardId, message, planFirst) {
2515
2655
  if (!stopWaiting()) return;
2516
2656
  if (planFirst) await enterPlanMode(pty, cardId);
2517
2657
  try {
2518
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2519
- setTimeout(() => {
2520
- try {
2521
- pty.write("\r");
2522
- } catch (err) {
2523
- logger.card.error(cardId, "Failed to submit the card instructions to the agent:", err);
2524
- }
2525
- }, SUBMIT_DELAY_MS);
2658
+ writePasteAndSubmit(pty, cardId, message, "the card instructions");
2526
2659
  } catch (err) {
2527
2660
  logger.card.error(cardId, "Failed to send the card instructions to the agent:", err);
2528
2661
  }
@@ -2558,14 +2691,7 @@ async function enterPlanMode(pty, cardId) {
2558
2691
  }
2559
2692
  function sendMessageToIdleSession(pty, cardId, message) {
2560
2693
  try {
2561
- pty.write(`${PASTE_START}${message}${PASTE_END}`);
2562
- setTimeout(() => {
2563
- try {
2564
- pty.write("\r");
2565
- } catch (err) {
2566
- logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2567
- }
2568
- }, SUBMIT_DELAY_MS);
2694
+ writePasteAndSubmit(pty, cardId, message, "a message");
2569
2695
  } catch (err) {
2570
2696
  logger.card.error(cardId, "Failed to send a message to the idle session:", err);
2571
2697
  }
@@ -3004,7 +3130,7 @@ async function dispatchRunnerQueue(supabase, config) {
3004
3130
  for (const project of config.registeredProjects) {
3005
3131
  const running = await getRunningSessionForProject(supabase, project.id, config.runnerId);
3006
3132
  if (!running) continue;
3007
- if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
3133
+ if ((running.agentState === "done" || running.agentState === "waiting_prototype_review" || running.agentState === "waiting_workflow_answer") && queuedProjectIds.has(project.id)) {
3008
3134
  await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
3009
3135
  continue;
3010
3136
  }
@@ -3069,6 +3195,14 @@ async function publishQueueSnapshots(supabase, config, queued, promotedCardIds)
3069
3195
  function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
3070
3196
  return prototypeApproved ? "O prot\xF3tipo foi revisado e aprovado \u2014 prossiga para a implementa\xE7\xE3o real, usando-o como refer\xEAncia." : `O prot\xF3tipo ainda n\xE3o foi aprovado${prototypeUrl ? ` (${prototypeUrl})` : ""} \u2014 confira os coment\xE1rios deixados nele (e, se for um prot\xF3tipo de marca\xE7\xE3o de pontos, os pontos salvos) e ajuste, publicando a nova vers\xE3o com publish_prototype. Depois, pare o turno de novo: a sess\xE3o ser\xE1 retomada quando houver outra revis\xE3o.`;
3071
3197
  }
3198
+ async function buildWorkflowAnswerFollowUp(supabase, cardId) {
3199
+ const { data } = await supabase.from("card_workflow_questions").select("question, answer").eq("card_id", cardId).eq("status", "answered").order("answered_at", { ascending: false }).limit(1).maybeSingle();
3200
+ if (!data) return "A pergunta foi respondida \u2014 confira os coment\xE1rios do card e prossiga.";
3201
+ return `Pergunta: "${data.question}"
3202
+ Resposta do humano: "${data.answer}"
3203
+
3204
+ Prossiga com o workflow a partir daqui.`;
3205
+ }
3072
3206
  async function promoteQueuedSession(supabase, config, entry) {
3073
3207
  const existingHandle = getPtySession(entry.id);
3074
3208
  if (existingHandle) {
@@ -3088,6 +3222,9 @@ async function promoteQueuedSession(supabase, config, entry) {
3088
3222
  buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
3089
3223
  );
3090
3224
  }
3225
+ if (entry.agent_state === "waiting_workflow_answer" && !existingHandle.exited) {
3226
+ sendMessageToIdleSession(existingHandle.pty, entry.card_id, await buildWorkflowAnswerFollowUp(supabase, entry.card_id));
3227
+ }
3091
3228
  return;
3092
3229
  }
3093
3230
  const spawned = await spawnSessionPty(supabase, config, entry);
@@ -3206,7 +3343,7 @@ function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty)
3206
3343
  } else {
3207
3344
  cancelUsageResume(sessionId);
3208
3345
  }
3209
- if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
3346
+ if (state === "done" || state === "waiting_prototype_review" || state === "waiting_workflow_answer") void dispatchRunnerQueue(supabase, config);
3210
3347
  });
3211
3348
  pty.onData((chunk) => watcher.push(chunk));
3212
3349
  registerAgentActivityWatcher(sessionId, watcher);
@@ -3241,6 +3378,14 @@ async function requeuePausedCard(supabase, config, card) {
3241
3378
  logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
3242
3379
  }
3243
3380
  }
3381
+ if (active.status === "running" && active.agentState === "waiting_workflow_answer") {
3382
+ const handle = getPtySession(active.id);
3383
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3384
+ if (handle && !handle.exited) {
3385
+ sendMessageToIdleSession(handle.pty, card.id, await buildWorkflowAnswerFollowUp(supabase, card.id));
3386
+ logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the workflow answer follow-up directly.`);
3387
+ }
3388
+ }
3244
3389
  }
3245
3390
  async function reconcileOrphanedSessions(supabase, config) {
3246
3391
  for (const project of config.registeredProjects) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-board-runner",
3
- "version": "0.1.17",
3
+ "version": "0.1.19",
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": {