agent-board-runner 0.1.7 → 0.1.9

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 +135 -29
  2. package/dist/index.js +135 -29
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -202,7 +202,7 @@ var BOX_DRAWING_PATTERN = /[─-╿]/g;
202
202
  var WORKING_PATTERN = /esc to interrupt/i;
203
203
  var BACKGROUND_SHELL_PATTERN = /\b[1-9]\d*\s+shells?\b/i;
204
204
  function isAgentHookMoment(value) {
205
- return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
205
+ return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "waiting_prototype_review" || value === "commit_detected" || value === "turn_stopped" || value === "notification";
206
206
  }
207
207
  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;
208
208
  var PLAN_APPROVAL_PATTERN = /would you like to proceed\??/i;
@@ -246,6 +246,14 @@ var AgentActivityWatcher = class {
246
246
  lastOutputAt = Date.now();
247
247
  usageLimited = false;
248
248
  planApprovalPending = false;
249
+ /**
250
+ * Latched like planApprovalPending, but with no text-fallback path: there is no on-screen CLI
251
+ * prompt for this (see publish_prototype / docs/ai-knowledges/prototipacao-visual.md), only the
252
+ * PostToolUse hook. The agent's own instructions have it stop the turn right after calling that
253
+ * tool, so the Stop event normally right behind it must not downgrade this to "done"/
254
+ * "waiting_user" — see the guard in reportHookMoment's "turn_stopped" case.
255
+ */
256
+ prototypeReviewPending = false;
249
257
  toolCallPending = false;
250
258
  commitDetected = false;
251
259
  timer;
@@ -287,6 +295,7 @@ var AgentActivityWatcher = class {
287
295
  case "implementing":
288
296
  this.usageLimited = false;
289
297
  this.planApprovalPending = false;
298
+ this.prototypeReviewPending = false;
290
299
  this.commitDetected = false;
291
300
  this.setState("implementing");
292
301
  break;
@@ -298,11 +307,16 @@ var AgentActivityWatcher = class {
298
307
  this.planApprovalPending = true;
299
308
  this.setState("waiting_plan_approval");
300
309
  break;
310
+ case "waiting_prototype_review":
311
+ this.prototypeReviewPending = true;
312
+ this.setState("waiting_prototype_review");
313
+ break;
301
314
  case "commit_detected":
302
315
  this.commitDetected = true;
303
316
  break;
304
317
  case "turn_stopped":
305
318
  if (this.usageLimited) break;
319
+ if (this.prototypeReviewPending) break;
306
320
  if (BACKGROUND_SHELL_PATTERN.test(this.recentOutput())) {
307
321
  this.setState("waiting_background");
308
322
  break;
@@ -767,7 +781,7 @@ async function getRunningSessionForProject(supabase, projectId, ownerUserId) {
767
781
  }
768
782
  async function listQueuedSessionsForRunner(supabase, ownerUserId) {
769
783
  const { data, error } = await supabase.from("sessions").select(
770
- "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first, prerequisite_card_id)"
784
+ "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first, prerequisite_card_id, prototype_first, prototype_url, prototype_approved)"
771
785
  ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
772
786
  if (error) throw error;
773
787
  return data;
@@ -814,9 +828,11 @@ async function updateSession(supabase, sessionId, changes, activityContext) {
814
828
  }
815
829
  }
816
830
  async function getActiveSessionForCard(supabase, cardId) {
817
- const { data, error } = await supabase.from("sessions").select("id, status").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
831
+ const { data, error } = await supabase.from("sessions").select("id, status, agent_state").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
818
832
  if (error) throw error;
819
- return data;
833
+ if (!data) return null;
834
+ const row = data;
835
+ return { id: row.id, status: row.status, agentState: row.agent_state };
820
836
  }
821
837
  async function pauseRunningSession(supabase, sessionId, activityContext) {
822
838
  const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
@@ -839,6 +855,10 @@ async function getLastClaudeSessionIdForCard(supabase, cardId) {
839
855
  return data?.claude_session_id ?? null;
840
856
  }
841
857
  async function getLatestAgentStateForCard(supabase, cardId) {
858
+ const { data: card, error: cardError } = await supabase.from("cards").select("column_name").eq("id", cardId).maybeSingle();
859
+ if (cardError) throw cardError;
860
+ const column = card?.column_name;
861
+ if (column && BOARD_COLUMNS.indexOf(column) > BOARD_COLUMNS.indexOf("desenvolvimento")) return "done";
842
862
  const { data, error } = await supabase.from("sessions").select("agent_state").eq("card_id", cardId).order("created_at", { ascending: false }).limit(1).maybeSingle();
843
863
  if (error) throw error;
844
864
  return data?.agent_state ?? null;
@@ -1063,6 +1083,13 @@ function detectMoment(input) {
1063
1083
  // or self-corrects within a beat \u2014 never gets stuck on "waiting_plan_approval" forever.
1064
1084
  if (eventName === "PostToolUse" && input.tool_name === "ExitPlanMode") return "implementing";
1065
1085
 
1086
+ // The agent just published a prototype Artifact and recorded it on the card (see
1087
+ // publish_prototype in boardTools.ts and docs/ai-knowledges/prototipacao-visual.md). Its own
1088
+ // instructions tell it to stop the turn right after this call, so the Stop event normally
1089
+ // right behind this one must not downgrade this to "turn_stopped" \u2014 see the
1090
+ // prototypeReviewPending latch in agentActivity.ts's reportHookMoment.
1091
+ if (eventName === "PostToolUse" && input.tool_name === "mcp__agent-board__publish_prototype") return "waiting_prototype_review";
1092
+
1066
1093
  // A commit that actually succeeded, read off the Bash tool's own structured result instead
1067
1094
  // of guessing from scrollback text (exact, and immune to locale/color/format changes to
1068
1095
  // git's own banner). Any other Bash completion still reports "tool_active" below, same as
@@ -2834,18 +2861,18 @@ async function enterPlanMode(pty, cardId) {
2834
2861
  subscription.dispose();
2835
2862
  }
2836
2863
  }
2837
- function sendContinue(pty, cardId) {
2864
+ function sendMessageToIdleSession(pty, cardId, message) {
2838
2865
  try {
2839
- pty.write(`${PASTE_START}continue${PASTE_END}`);
2866
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2840
2867
  setTimeout(() => {
2841
2868
  try {
2842
2869
  pty.write("\r");
2843
2870
  } catch (err) {
2844
- logger.card.error(cardId, 'Failed to submit the auto-resume "continue" message:', err);
2871
+ logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2845
2872
  }
2846
2873
  }, SUBMIT_DELAY_MS);
2847
2874
  } catch (err) {
2848
- logger.card.error(cardId, 'Failed to send the auto-resume "continue" message:', err);
2875
+ logger.card.error(cardId, "Failed to send a message to the idle session:", err);
2849
2876
  }
2850
2877
  }
2851
2878
  function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
@@ -2931,6 +2958,46 @@ function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2931
2958
  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.`;
2932
2959
  }
2933
2960
 
2961
+ // src/prototypeGateNote.ts
2962
+ function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
2963
+ if (!prototypeFirst) return instructions;
2964
+ return `${instructions}
2965
+
2966
+ Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
2967
+
2968
+ 1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
2969
+ Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
2970
+ Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
2971
+ tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
2972
+ desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
2973
+ antes de publicar.
2974
+ 2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
2975
+ ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
2976
+ quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
2977
+ persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
2978
+ \`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
2979
+ para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
2980
+ aprovarem um resultado ainda.
2981
+ 3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
2982
+ Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
2983
+ add_card_comment manualmente tamb\xE9m, seria duplicado.
2984
+ 4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
2985
+ aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
2986
+ houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
2987
+ antes disso \xE9 desperd\xEDcio.
2988
+ 5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
2989
+ - **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
2990
+ caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
2991
+ banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
2992
+ suficiente para valer separar, quebre em cards novos (\`create_card\` +
2993
+ \`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
2994
+ prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
2995
+ pequeno.
2996
+ - **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
2997
+ anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
2998
+ \`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
2999
+ }
3000
+
2934
3001
  // src/usageResume.ts
2935
3002
  var RESET_BUFFER_MS = 3e4;
2936
3003
  var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
@@ -2952,7 +3019,7 @@ function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
2952
3019
  );
2953
3020
  const timer = setTimeout(() => {
2954
3021
  pending.delete(sessionId);
2955
- sendContinue(pty, cardId);
3022
+ sendMessageToIdleSession(pty, cardId, "continue");
2956
3023
  }, delay);
2957
3024
  timer.unref();
2958
3025
  pending.set(sessionId, timer);
@@ -3020,7 +3087,7 @@ async function reconcileStaleSessions(supabase, config, projectId) {
3020
3087
  }
3021
3088
  const stale = data ?? [];
3022
3089
  if (stale.length === 0) return;
3023
- for (const { id: sessionId, card_id: cardId } of stale) {
3090
+ for (const { id: sessionId, card_id: cardId, cards } of stale) {
3024
3091
  intentionallyStoppedSessionIds.add(sessionId);
3025
3092
  stopPtySession(sessionId);
3026
3093
  cancelUsageResume(sessionId);
@@ -3028,7 +3095,13 @@ async function reconcileStaleSessions(supabase, config, projectId) {
3028
3095
  await updateSession(
3029
3096
  supabase,
3030
3097
  sessionId,
3031
- { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3098
+ {
3099
+ status: "done",
3100
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3101
+ // See stopCardSession's doc comment: "fim" is the authoritative completion signal for
3102
+ // dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
3103
+ ...cards.column_name === "fim" ? { agentState: "done" } : {}
3104
+ },
3032
3105
  { projectId, cardId, userId: config.ownerUserId }
3033
3106
  );
3034
3107
  logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
@@ -3039,6 +3112,7 @@ async function dispatchRunnerQueue(supabase, config) {
3039
3112
  if (dispatchingRunner) return;
3040
3113
  dispatchingRunner = true;
3041
3114
  let queued = [];
3115
+ const promotedCardIds = /* @__PURE__ */ new Set();
3042
3116
  try {
3043
3117
  queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
3044
3118
  if (queued.length === 0) return;
@@ -3048,7 +3122,7 @@ async function dispatchRunnerQueue(supabase, config) {
3048
3122
  for (const project of config.registeredProjects) {
3049
3123
  const running = await getRunningSessionForProject(supabase, project.id, config.ownerUserId);
3050
3124
  if (!running) continue;
3051
- if (running.agentState === "done" && queuedProjectIds.has(project.id)) {
3125
+ if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
3052
3126
  await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
3053
3127
  continue;
3054
3128
  }
@@ -3075,22 +3149,24 @@ async function dispatchRunnerQueue(supabase, config) {
3075
3149
  try {
3076
3150
  await promoteQueuedSession(supabase, config, entry);
3077
3151
  promotedThisPass.add(projectId);
3152
+ promotedCardIds.add(entry.card_id);
3078
3153
  freeProjectSlots -= 1;
3079
3154
  } catch (err) {
3080
3155
  logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
3081
3156
  }
3082
3157
  }
3083
3158
  } finally {
3084
- await publishQueueSnapshots(supabase, config, queued).catch(
3159
+ await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
3085
3160
  (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3086
3161
  );
3087
3162
  dispatchingRunner = false;
3088
3163
  }
3089
3164
  }
3090
- async function publishQueueSnapshots(supabase, config, queued) {
3165
+ async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
3091
3166
  const paused = await listPausedSessionsForRunner(supabase, config.ownerUserId);
3092
3167
  const queuedByProject = /* @__PURE__ */ new Map();
3093
3168
  for (const entry of queued) {
3169
+ if (promotedCardIds.has(entry.card_id)) continue;
3094
3170
  const list = queuedByProject.get(entry.cards.project_id) ?? [];
3095
3171
  list.push(entry.card_id);
3096
3172
  queuedByProject.set(entry.cards.project_id, list);
@@ -3108,6 +3184,9 @@ async function publishQueueSnapshots(supabase, config, queued) {
3108
3184
  });
3109
3185
  }
3110
3186
  }
3187
+ function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
3188
+ 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.`;
3189
+ }
3111
3190
  async function promoteQueuedSession(supabase, config, entry) {
3112
3191
  const existingHandle = getPtySession(entry.id);
3113
3192
  if (existingHandle) {
@@ -3120,6 +3199,13 @@ async function promoteQueuedSession(supabase, config, entry) {
3120
3199
  );
3121
3200
  activeSessionByCard.set(entry.card_id, entry.id);
3122
3201
  logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
3202
+ if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
3203
+ sendMessageToIdleSession(
3204
+ existingHandle.pty,
3205
+ entry.card_id,
3206
+ buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
3207
+ );
3208
+ }
3123
3209
  return;
3124
3210
  }
3125
3211
  const spawned = await spawnSessionPty(supabase, config, entry);
@@ -3151,9 +3237,12 @@ async function spawnSessionPty(supabase, config, entry) {
3151
3237
  } else {
3152
3238
  const attachmentPaths = await downloadCardAttachments(supabase, cardId);
3153
3239
  const instructions = buildInstructionsWithMcpContext(
3154
- buildInstructionsWithFileReferences(
3155
- buildInstructionsWithAttachments(card.instructions, attachmentPaths),
3156
- card.file_references
3240
+ buildInstructionsWithPrototypeGate(
3241
+ buildInstructionsWithFileReferences(
3242
+ buildInstructionsWithAttachments(card.instructions, attachmentPaths),
3243
+ card.file_references
3244
+ ),
3245
+ card.prototype_first
3157
3246
  ),
3158
3247
  cardId,
3159
3248
  card.project_id
@@ -3233,7 +3322,7 @@ function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty)
3233
3322
  } else {
3234
3323
  cancelUsageResume(sessionId);
3235
3324
  }
3236
- if (state === "done") void dispatchRunnerQueue(supabase, config);
3325
+ if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
3237
3326
  });
3238
3327
  pty.onData((chunk) => watcher.push(chunk));
3239
3328
  registerAgentActivityWatcher(sessionId, watcher);
@@ -3247,16 +3336,27 @@ async function pauseFinishedSession(supabase, config, projectId, cardId, session
3247
3336
  }
3248
3337
  async function requeuePausedCard(supabase, config, card) {
3249
3338
  const active = await getActiveSessionForCard(supabase, card.id);
3250
- if (!active || active.status !== "paused") return;
3251
- await updateSession(
3252
- supabase,
3253
- active.id,
3254
- { status: "queued" },
3255
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3256
- );
3257
- await updateCard(supabase, card.id, { requeueRequestedAt: null });
3258
- logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
3259
- await dispatchRunnerQueue(supabase, config);
3339
+ if (!active) return;
3340
+ if (active.status === "paused") {
3341
+ await updateSession(
3342
+ supabase,
3343
+ active.id,
3344
+ { status: "queued" },
3345
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3346
+ );
3347
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3348
+ logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
3349
+ await dispatchRunnerQueue(supabase, config);
3350
+ return;
3351
+ }
3352
+ if (active.status === "running" && active.agentState === "waiting_prototype_review") {
3353
+ const handle = getPtySession(active.id);
3354
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3355
+ if (handle && !handle.exited) {
3356
+ sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
3357
+ logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
3358
+ }
3359
+ }
3260
3360
  }
3261
3361
  async function reconcileOrphanedSessions(supabase, config) {
3262
3362
  for (const project of config.registeredProjects) {
@@ -3315,7 +3415,11 @@ async function stopCardSession(supabase, config, card) {
3315
3415
  await updateSession(
3316
3416
  supabase,
3317
3417
  sessionId,
3318
- { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3418
+ {
3419
+ status: "done",
3420
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3421
+ ...card.column_name === "fim" ? { agentState: "done" } : {}
3422
+ },
3319
3423
  { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3320
3424
  );
3321
3425
  logger.card.info(card.id, `Session ${sessionId} stopped.`);
@@ -3591,6 +3695,8 @@ function attachTerminalServer() {
3591
3695
  socket.close(4401, "Invalid session or ticket");
3592
3696
  return;
3593
3697
  }
3698
+ for (const existing of handle.sockets) existing.close(4002, "Session opened from another location");
3699
+ handle.sockets.clear();
3594
3700
  handle.sockets.add(socket);
3595
3701
  for (const chunk of handle.outputBuffer) socket.send(chunk);
3596
3702
  socket.on("message", (data, isBinary) => {
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 === "commit_detected" || value === "turn_stopped" || value === "notification";
43
+ return value === "implementing" || value === "tool_active" || value === "waiting_plan_approval" || value === "waiting_prototype_review" || 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;
@@ -84,6 +84,14 @@ var AgentActivityWatcher = class {
84
84
  lastOutputAt = Date.now();
85
85
  usageLimited = false;
86
86
  planApprovalPending = false;
87
+ /**
88
+ * Latched like planApprovalPending, but with no text-fallback path: there is no on-screen CLI
89
+ * prompt for this (see publish_prototype / docs/ai-knowledges/prototipacao-visual.md), only the
90
+ * PostToolUse hook. The agent's own instructions have it stop the turn right after calling that
91
+ * tool, so the Stop event normally right behind it must not downgrade this to "done"/
92
+ * "waiting_user" — see the guard in reportHookMoment's "turn_stopped" case.
93
+ */
94
+ prototypeReviewPending = false;
87
95
  toolCallPending = false;
88
96
  commitDetected = false;
89
97
  timer;
@@ -125,6 +133,7 @@ var AgentActivityWatcher = class {
125
133
  case "implementing":
126
134
  this.usageLimited = false;
127
135
  this.planApprovalPending = false;
136
+ this.prototypeReviewPending = false;
128
137
  this.commitDetected = false;
129
138
  this.setState("implementing");
130
139
  break;
@@ -136,11 +145,16 @@ var AgentActivityWatcher = class {
136
145
  this.planApprovalPending = true;
137
146
  this.setState("waiting_plan_approval");
138
147
  break;
148
+ case "waiting_prototype_review":
149
+ this.prototypeReviewPending = true;
150
+ this.setState("waiting_prototype_review");
151
+ break;
139
152
  case "commit_detected":
140
153
  this.commitDetected = true;
141
154
  break;
142
155
  case "turn_stopped":
143
156
  if (this.usageLimited) break;
157
+ if (this.prototypeReviewPending) break;
144
158
  if (BACKGROUND_SHELL_PATTERN.test(this.recentOutput())) {
145
159
  this.setState("waiting_background");
146
160
  break;
@@ -605,7 +619,7 @@ async function getRunningSessionForProject(supabase, projectId, ownerUserId) {
605
619
  }
606
620
  async function listQueuedSessionsForRunner(supabase, ownerUserId) {
607
621
  const { data, error } = await supabase.from("sessions").select(
608
- "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first, prerequisite_card_id)"
622
+ "id, card_id, claude_session_id, agent_state, created_at, cards!card_id!inner(project_id, assigned_user_id, title, instructions, file_references, plan_first, prerequisite_card_id, prototype_first, prototype_url, prototype_approved)"
609
623
  ).eq("cards.assigned_user_id", ownerUserId).eq("status", "queued").order("created_at", { ascending: true });
610
624
  if (error) throw error;
611
625
  return data;
@@ -652,9 +666,11 @@ async function updateSession(supabase, sessionId, changes, activityContext) {
652
666
  }
653
667
  }
654
668
  async function getActiveSessionForCard(supabase, cardId) {
655
- const { data, error } = await supabase.from("sessions").select("id, status").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
669
+ const { data, error } = await supabase.from("sessions").select("id, status, agent_state").eq("card_id", cardId).in("status", ["queued", "running", "waiting_input", "paused"]).order("created_at", { ascending: false }).limit(1).maybeSingle();
656
670
  if (error) throw error;
657
- return data;
671
+ if (!data) return null;
672
+ const row = data;
673
+ return { id: row.id, status: row.status, agentState: row.agent_state };
658
674
  }
659
675
  async function pauseRunningSession(supabase, sessionId, activityContext) {
660
676
  const { data, error } = await supabase.from("sessions").update({ status: "paused" }).eq("id", sessionId).eq("status", "running").select("id");
@@ -677,6 +693,10 @@ async function getLastClaudeSessionIdForCard(supabase, cardId) {
677
693
  return data?.claude_session_id ?? null;
678
694
  }
679
695
  async function getLatestAgentStateForCard(supabase, cardId) {
696
+ const { data: card, error: cardError } = await supabase.from("cards").select("column_name").eq("id", cardId).maybeSingle();
697
+ if (cardError) throw cardError;
698
+ const column = card?.column_name;
699
+ if (column && BOARD_COLUMNS.indexOf(column) > BOARD_COLUMNS.indexOf("desenvolvimento")) return "done";
680
700
  const { data, error } = await supabase.from("sessions").select("agent_state").eq("card_id", cardId).order("created_at", { ascending: false }).limit(1).maybeSingle();
681
701
  if (error) throw error;
682
702
  return data?.agent_state ?? null;
@@ -901,6 +921,13 @@ function detectMoment(input) {
901
921
  // or self-corrects within a beat \u2014 never gets stuck on "waiting_plan_approval" forever.
902
922
  if (eventName === "PostToolUse" && input.tool_name === "ExitPlanMode") return "implementing";
903
923
 
924
+ // The agent just published a prototype Artifact and recorded it on the card (see
925
+ // publish_prototype in boardTools.ts and docs/ai-knowledges/prototipacao-visual.md). Its own
926
+ // instructions tell it to stop the turn right after this call, so the Stop event normally
927
+ // right behind this one must not downgrade this to "turn_stopped" \u2014 see the
928
+ // prototypeReviewPending latch in agentActivity.ts's reportHookMoment.
929
+ if (eventName === "PostToolUse" && input.tool_name === "mcp__agent-board__publish_prototype") return "waiting_prototype_review";
930
+
904
931
  // A commit that actually succeeded, read off the Bash tool's own structured result instead
905
932
  // of guessing from scrollback text (exact, and immune to locale/color/format changes to
906
933
  // git's own banner). Any other Bash completion still reports "tool_active" below, same as
@@ -2773,18 +2800,18 @@ async function enterPlanMode(pty, cardId) {
2773
2800
  subscription.dispose();
2774
2801
  }
2775
2802
  }
2776
- function sendContinue(pty, cardId) {
2803
+ function sendMessageToIdleSession(pty, cardId, message) {
2777
2804
  try {
2778
- pty.write(`${PASTE_START}continue${PASTE_END}`);
2805
+ pty.write(`${PASTE_START}${message}${PASTE_END}`);
2779
2806
  setTimeout(() => {
2780
2807
  try {
2781
2808
  pty.write("\r");
2782
2809
  } catch (err) {
2783
- logger.card.error(cardId, 'Failed to submit the auto-resume "continue" message:', err);
2810
+ logger.card.error(cardId, "Failed to submit a message to the idle session:", err);
2784
2811
  }
2785
2812
  }, SUBMIT_DELAY_MS);
2786
2813
  } catch (err) {
2787
- logger.card.error(cardId, 'Failed to send the auto-resume "continue" message:', err);
2814
+ logger.card.error(cardId, "Failed to send a message to the idle session:", err);
2788
2815
  }
2789
2816
  }
2790
2817
  function resumeAgentPty(repoPath, claudeSessionId, sessionId, ticket) {
@@ -2870,6 +2897,46 @@ function buildInstructionsWithMcpContext(instructions, cardId, projectId) {
2870
2897
  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.`;
2871
2898
  }
2872
2899
 
2900
+ // src/prototypeGateNote.ts
2901
+ function buildInstructionsWithPrototypeGate(instructions, prototypeFirst) {
2902
+ if (!prototypeFirst) return instructions;
2903
+ return `${instructions}
2904
+
2905
+ Prototipagem visual obrigat\xF3ria antes de implementar (prototypeFirst est\xE1 ligado neste card):
2906
+
2907
+ 1. Antes de qualquer altera\xE7\xE3o de c\xF3digo real, publique SOMENTE um prot\xF3tipo visual (um Claude
2908
+ Artifact \u2014 HTML est\xE1tico, sem l\xF3gica/integra\xE7\xE3o de verdade) do que voc\xEA vai construir.
2909
+ Reaproveite ao m\xE1ximo o design system REAL deste reposit\xF3rio (n\xE3o o do agent-board): leia o
2910
+ tailwind config, as CSS vars/tema e os componentes j\xE1 existentes neste \`cwd\` antes de
2911
+ desenhar qualquer coisa. Use as skills \`frontend-design\`/\`artifact-design\` j\xE1 dispon\xEDveis
2912
+ antes de publicar.
2913
+ 2. Se os anexos/instru\xE7\xF5es deste card inclu\xEDrem um print de uma tela j\xE1 existente pedindo
2914
+ ajustes, o prot\xF3tipo \xE9 diferente: publique uma p\xE1gina INTERATIVA que mostra o print e deixa
2915
+ quem est\xE1 revisando clicar nele para soltar um pino numerado com um campo de texto por pino \u2014
2916
+ persista cada pino usando a capability \`db\` do pr\xF3prio Artifact (carregue a skill
2917
+ \`artifact-capabilities\` antes de declarar a capability). Nesse caso, a nota que voc\xEA passar
2918
+ para \`publish_prototype\` deve pedir para marcarem os pontos de ajuste nele, n\xE3o para
2919
+ aprovarem um resultado ainda.
2920
+ 3. Chame a tool MCP \`publish_prototype\` com o link do Artifact e uma nota curta do que fez/pede.
2921
+ Essa chamada j\xE1 zera a aprova\xE7\xE3o e avisa os humanos do projeto \u2014 n\xE3o poste o link via
2922
+ add_card_comment manualmente tamb\xE9m, seria duplicado.
2923
+ 4. Depois de chamar \`publish_prototype\`, PARE o turno \u2014 n\xE3o fique num loop verificando
2924
+ aprova\xE7\xE3o nem monitorando o artifact sozinho. A sess\xE3o ser\xE1 retomada automaticamente quando
2925
+ houver uma revis\xE3o nova (aprova\xE7\xE3o ou pedido de ajuste); tentar continuar por conta pr\xF3pria
2926
+ antes disso \xE9 desperd\xEDcio.
2927
+ 5. Quando a sess\xE3o for retomada, a mensagem vai dizer se o prot\xF3tipo foi aprovado:
2928
+ - **Aprovado**: prossiga para a implementa\xE7\xE3o real agora, usando o prot\xF3tipo (e, se for o
2929
+ caso de ajuste em tela existente, os pontos marcados nele \u2014 leia-os de volta via leitura do
2930
+ banco do artifact) como refer\xEAncia. Se os pontos marcados forem grandes/numerosos o
2931
+ suficiente para valer separar, quebre em cards novos (\`create_card\` +
2932
+ \`prerequisiteCardId\`, encadeando os que dependem um do outro) e aplique o mesmo fluxo de
2933
+ prot\xF3tipo em cada um antes de implementar; resolva no pr\xF3prio card quando o ajuste for
2934
+ pequeno.
2935
+ - **N\xE3o aprovado**: confira os coment\xE1rios deixados no Artifact (e, se for a p\xE1gina de
2936
+ anota\xE7\xE3o, os pontos salvos), ajuste o prot\xF3tipo e publique a nova vers\xE3o de novo via
2937
+ \`publish_prototype\` na MESMA url \u2014 depois pare o turno de novo, voltando ao passo 4.`;
2938
+ }
2939
+
2873
2940
  // src/usageResume.ts
2874
2941
  var RESET_BUFFER_MS = 3e4;
2875
2942
  var UNKNOWN_RESET_RETRY_MS = 5 * 6e4;
@@ -2891,7 +2958,7 @@ function scheduleUsageResume(supabase, config, cardId, sessionId, pty) {
2891
2958
  );
2892
2959
  const timer = setTimeout(() => {
2893
2960
  pending.delete(sessionId);
2894
- sendContinue(pty, cardId);
2961
+ sendMessageToIdleSession(pty, cardId, "continue");
2895
2962
  }, delay);
2896
2963
  timer.unref();
2897
2964
  pending.set(sessionId, timer);
@@ -2959,7 +3026,7 @@ async function reconcileStaleSessions(supabase, config, projectId) {
2959
3026
  }
2960
3027
  const stale = data ?? [];
2961
3028
  if (stale.length === 0) return;
2962
- for (const { id: sessionId, card_id: cardId } of stale) {
3029
+ for (const { id: sessionId, card_id: cardId, cards } of stale) {
2963
3030
  intentionallyStoppedSessionIds.add(sessionId);
2964
3031
  stopPtySession(sessionId);
2965
3032
  cancelUsageResume(sessionId);
@@ -2967,7 +3034,13 @@ async function reconcileStaleSessions(supabase, config, projectId) {
2967
3034
  await updateSession(
2968
3035
  supabase,
2969
3036
  sessionId,
2970
- { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3037
+ {
3038
+ status: "done",
3039
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3040
+ // See stopCardSession's doc comment: "fim" is the authoritative completion signal for
3041
+ // dispatchRunnerQueue's prerequisite gate, which only ever reads agent_state.
3042
+ ...cards.column_name === "fim" ? { agentState: "done" } : {}
3043
+ },
2971
3044
  { projectId, cardId, userId: config.ownerUserId }
2972
3045
  );
2973
3046
  logger.card.info(cardId, `Ended stale session ${sessionId} (its card left "desenvolvimento" while this runner missed the event).`);
@@ -2978,6 +3051,7 @@ async function dispatchRunnerQueue(supabase, config) {
2978
3051
  if (dispatchingRunner) return;
2979
3052
  dispatchingRunner = true;
2980
3053
  let queued = [];
3054
+ const promotedCardIds = /* @__PURE__ */ new Set();
2981
3055
  try {
2982
3056
  queued = await listQueuedSessionsForRunner(supabase, config.ownerUserId);
2983
3057
  if (queued.length === 0) return;
@@ -2987,7 +3061,7 @@ async function dispatchRunnerQueue(supabase, config) {
2987
3061
  for (const project of config.registeredProjects) {
2988
3062
  const running = await getRunningSessionForProject(supabase, project.id, config.ownerUserId);
2989
3063
  if (!running) continue;
2990
- if (running.agentState === "done" && queuedProjectIds.has(project.id)) {
3064
+ if ((running.agentState === "done" || running.agentState === "waiting_prototype_review") && queuedProjectIds.has(project.id)) {
2991
3065
  await pauseFinishedSession(supabase, config, project.id, running.cardId, running.id);
2992
3066
  continue;
2993
3067
  }
@@ -3014,22 +3088,24 @@ async function dispatchRunnerQueue(supabase, config) {
3014
3088
  try {
3015
3089
  await promoteQueuedSession(supabase, config, entry);
3016
3090
  promotedThisPass.add(projectId);
3091
+ promotedCardIds.add(entry.card_id);
3017
3092
  freeProjectSlots -= 1;
3018
3093
  } catch (err) {
3019
3094
  logger.card.error(entry.card_id, `Failed to promote queued session ${entry.id}:`, err);
3020
3095
  }
3021
3096
  }
3022
3097
  } finally {
3023
- await publishQueueSnapshots(supabase, config, queued).catch(
3098
+ await publishQueueSnapshots(supabase, config, queued, promotedCardIds).catch(
3024
3099
  (err) => logger.runner.error("ORCHESTRATOR", "Failed to publish queue snapshots:", err)
3025
3100
  );
3026
3101
  dispatchingRunner = false;
3027
3102
  }
3028
3103
  }
3029
- async function publishQueueSnapshots(supabase, config, queued) {
3104
+ async function publishQueueSnapshots(supabase, config, queued, promotedCardIds) {
3030
3105
  const paused = await listPausedSessionsForRunner(supabase, config.ownerUserId);
3031
3106
  const queuedByProject = /* @__PURE__ */ new Map();
3032
3107
  for (const entry of queued) {
3108
+ if (promotedCardIds.has(entry.card_id)) continue;
3033
3109
  const list = queuedByProject.get(entry.cards.project_id) ?? [];
3034
3110
  list.push(entry.card_id);
3035
3111
  queuedByProject.set(entry.cards.project_id, list);
@@ -3047,6 +3123,9 @@ async function publishQueueSnapshots(supabase, config, queued) {
3047
3123
  });
3048
3124
  }
3049
3125
  }
3126
+ function buildPrototypeReviewFollowUp(prototypeApproved, prototypeUrl) {
3127
+ 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.`;
3128
+ }
3050
3129
  async function promoteQueuedSession(supabase, config, entry) {
3051
3130
  const existingHandle = getPtySession(entry.id);
3052
3131
  if (existingHandle) {
@@ -3059,6 +3138,13 @@ async function promoteQueuedSession(supabase, config, entry) {
3059
3138
  );
3060
3139
  activeSessionByCard.set(entry.card_id, entry.id);
3061
3140
  logger.card.info(entry.card_id, "Resumed paused session from the front of the queue.");
3141
+ if (entry.agent_state === "waiting_prototype_review" && !existingHandle.exited) {
3142
+ sendMessageToIdleSession(
3143
+ existingHandle.pty,
3144
+ entry.card_id,
3145
+ buildPrototypeReviewFollowUp(entry.cards.prototype_approved, entry.cards.prototype_url)
3146
+ );
3147
+ }
3062
3148
  return;
3063
3149
  }
3064
3150
  const spawned = await spawnSessionPty(supabase, config, entry);
@@ -3090,9 +3176,12 @@ async function spawnSessionPty(supabase, config, entry) {
3090
3176
  } else {
3091
3177
  const attachmentPaths = await downloadCardAttachments(supabase, cardId);
3092
3178
  const instructions = buildInstructionsWithMcpContext(
3093
- buildInstructionsWithFileReferences(
3094
- buildInstructionsWithAttachments(card.instructions, attachmentPaths),
3095
- card.file_references
3179
+ buildInstructionsWithPrototypeGate(
3180
+ buildInstructionsWithFileReferences(
3181
+ buildInstructionsWithAttachments(card.instructions, attachmentPaths),
3182
+ card.file_references
3183
+ ),
3184
+ card.prototype_first
3096
3185
  ),
3097
3186
  cardId,
3098
3187
  card.project_id
@@ -3172,7 +3261,7 @@ function watchAgentActivity(supabase, config, projectId, cardId, sessionId, pty)
3172
3261
  } else {
3173
3262
  cancelUsageResume(sessionId);
3174
3263
  }
3175
- if (state === "done") void dispatchRunnerQueue(supabase, config);
3264
+ if (state === "done" || state === "waiting_prototype_review") void dispatchRunnerQueue(supabase, config);
3176
3265
  });
3177
3266
  pty.onData((chunk) => watcher.push(chunk));
3178
3267
  registerAgentActivityWatcher(sessionId, watcher);
@@ -3186,16 +3275,27 @@ async function pauseFinishedSession(supabase, config, projectId, cardId, session
3186
3275
  }
3187
3276
  async function requeuePausedCard(supabase, config, card) {
3188
3277
  const active = await getActiveSessionForCard(supabase, card.id);
3189
- if (!active || active.status !== "paused") return;
3190
- await updateSession(
3191
- supabase,
3192
- active.id,
3193
- { status: "queued" },
3194
- { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3195
- );
3196
- await updateCard(supabase, card.id, { requeueRequestedAt: null });
3197
- logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
3198
- await dispatchRunnerQueue(supabase, config);
3278
+ if (!active) return;
3279
+ if (active.status === "paused") {
3280
+ await updateSession(
3281
+ supabase,
3282
+ active.id,
3283
+ { status: "queued" },
3284
+ { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3285
+ );
3286
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3287
+ logger.card.info(card.id, `Session ${active.id} requeued at the front of the line (was paused).`);
3288
+ await dispatchRunnerQueue(supabase, config);
3289
+ return;
3290
+ }
3291
+ if (active.status === "running" && active.agentState === "waiting_prototype_review") {
3292
+ const handle = getPtySession(active.id);
3293
+ await updateCard(supabase, card.id, { requeueRequestedAt: null });
3294
+ if (handle && !handle.exited) {
3295
+ sendMessageToIdleSession(handle.pty, card.id, buildPrototypeReviewFollowUp(card.prototype_approved, card.prototype_url));
3296
+ logger.card.info(card.id, `Session ${active.id} was never paused (alone in its project) \u2014 sent the prototype review follow-up directly.`);
3297
+ }
3298
+ }
3199
3299
  }
3200
3300
  async function reconcileOrphanedSessions(supabase, config) {
3201
3301
  for (const project of config.registeredProjects) {
@@ -3254,7 +3354,11 @@ async function stopCardSession(supabase, config, card) {
3254
3354
  await updateSession(
3255
3355
  supabase,
3256
3356
  sessionId,
3257
- { status: "done", endedAt: (/* @__PURE__ */ new Date()).toISOString() },
3357
+ {
3358
+ status: "done",
3359
+ endedAt: (/* @__PURE__ */ new Date()).toISOString(),
3360
+ ...card.column_name === "fim" ? { agentState: "done" } : {}
3361
+ },
3258
3362
  { projectId: card.project_id, cardId: card.id, userId: config.ownerUserId }
3259
3363
  );
3260
3364
  logger.card.info(card.id, `Session ${sessionId} stopped.`);
@@ -3530,6 +3634,8 @@ function attachTerminalServer() {
3530
3634
  socket.close(4401, "Invalid session or ticket");
3531
3635
  return;
3532
3636
  }
3637
+ for (const existing of handle.sockets) existing.close(4002, "Session opened from another location");
3638
+ handle.sockets.clear();
3533
3639
  handle.sockets.add(socket);
3534
3640
  for (const chunk of handle.outputBuffer) socket.send(chunk);
3535
3641
  socket.on("message", (data, isBinary) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-board-runner",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
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": {