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.
- package/dist/cli.js +170 -25
- package/dist/index.js +170 -25
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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": {
|