Skip to content

Commit f90573b

Browse files
centdixclaudegithub-actions[bot]
authored
Add 'Ask agent to fix' for CI failures (#8)
* Add "Ask agent to fix" for CI failures, fix sendPrompt truncation Replace chunked tmux send-keys with load-buffer/paste-buffer to avoid flag interpretation errors (e.g. chunks starting with "-" parsed as tmux flags). The preamble is typed via send-keys so it shows inline, while logs are pasted via buffer (collapsed as [pasted text]). Also adds --log with --log-failed fallback for the CI logs endpoint. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Restore "done" status icon in AgentStatusIcon The {:else if status === "done"} branch was accidentally removed during the formatter refactor, leaving no icon rendered for completed agents. Add back the green checkmark SVG between the "waiting" and "error" branches. Co-authored-by: centdix <centdix@users.noreply.github.com> * Use unique tmux buffer name per sendPrompt invocation Hardcoded "wm-prompt" buffer could be overwritten by a concurrent sendPrompt call before paste-buffer runs. Use a timestamp+random suffix (e.g. wm-prompt-1234567890-abc12) to avoid the race. Co-authored-by: centdix <centdix@users.noreply.github.com> * Cap CI logs sent to agent at 30000 chars Large CI outputs (verbose test suites, etc.) could overwhelm the tmux paste buffer or the agent context. Truncate from the start, keeping the last 30000 chars (most relevant tail), with a [... truncated] marker. Co-authored-by: centdix <centdix@users.noreply.github.com> * Always use --log-failed for CI log fetching --log returns logs for all jobs (including passing ones), producing far more data than needed. Use --log-failed exclusively to stay focused on failing job output. Co-authored-by: centdix <centdix@users.noreply.github.com> * Nits: blank line between functions, defensive colon split - Add missing blank line between handleFix and handleCopy - Use indexOf(":") in findWorktreeSession so session/window names containing colons don't silently mis-parse Co-authored-by: centdix <centdix@users.noreply.github.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: centdix <centdix@users.noreply.github.com>
1 parent a3c010b commit f90573b

6 files changed

Lines changed: 263 additions & 39 deletions

File tree

backend/src/server.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -285,10 +285,10 @@ async function handleApi(req: Request, url: URL): Promise<Response> {
285285
// POST /api/worktrees/:name/send
286286
if (parts[0] === "worktrees" && parts.length === 3 && parts[2] === "send" && method === "POST") {
287287
const name = decodeURIComponent(parts[1]);
288-
const body = await req.json() as { text?: string };
288+
const body = await req.json() as { text?: string; preamble?: string };
289289
if (!body.text) return errorResponse("Missing 'text' field", 400);
290290
console.log(`[worktree:send] name=${name} text="${body.text.slice(0, 80)}"`);
291-
const result = sendPrompt(name, body.text);
291+
const result = sendPrompt(name, body.text, 0, body.preamble);
292292
if (!result.ok) return errorResponse(result.error, 404);
293293
return jsonResponse({ ok: true });
294294
}
@@ -310,12 +310,14 @@ async function handleApi(req: Request, url: URL): Promise<Response> {
310310
stdout: "pipe",
311311
stderr: "pipe",
312312
});
313-
const logs = new TextDecoder().decode(result.stdout);
314-
if (result.exitCode !== 0) {
315-
const stderr = new TextDecoder().decode(result.stderr).trim();
316-
return errorResponse(`Failed to fetch logs: ${stderr}`, 502);
313+
314+
if (result.exitCode === 0) {
315+
const logs = new TextDecoder().decode(result.stdout);
316+
return jsonResponse({ logs });
317317
}
318-
return jsonResponse({ logs });
318+
319+
const stderr = new TextDecoder().decode(result.stderr).trim();
320+
return errorResponse(`Failed to fetch logs: ${stderr || "unknown error"}`, 502);
319321
}
320322

321323
// GET /api/worktrees/:name/status

backend/src/workmux.ts

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -325,19 +325,83 @@ export async function removeWorktree(name: string): Promise<string> {
325325
return result;
326326
}
327327

328-
export function sendPrompt(branch: string, text: string, pane = 0): { ok: true } | { ok: false; error: string } {
329-
const session = `wm-${branch}`;
330-
const check = Bun.spawnSync(["tmux", "has-session", "-t", session], { stderr: "pipe" });
331-
if (check.exitCode !== 0) {
332-
return { ok: false, error: `tmux session "${session}" not found` };
328+
export function sendPrompt(
329+
branch: string,
330+
text: string,
331+
pane = 0,
332+
preamble?: string,
333+
): { ok: true } | { ok: false; error: string } {
334+
const windowName = `wm-${branch}`;
335+
const session = findWorktreeSession(windowName);
336+
if (!session) {
337+
return { ok: false, error: `tmux window "${windowName}" not found` };
333338
}
334-
const result = Bun.spawnSync(["tmux", "send-keys", "-t", `${session}.${pane}`, text, "Enter"]);
335-
if (result.exitCode !== 0) {
336-
return { ok: false, error: `send-keys failed (exit ${result.exitCode})` };
339+
const target = `${session}:${windowName}.${pane}`;
340+
341+
// Type the preamble as regular keystrokes so it shows inline in the agent,
342+
// then paste the bulk payload via a tmux buffer (appears as [pasted text]).
343+
if (preamble) {
344+
const pre = Bun.spawnSync(["tmux", "send-keys", "-t", target, "-l", "--", preamble], { stderr: "pipe" });
345+
if (pre.exitCode !== 0) {
346+
const stderr = new TextDecoder().decode(pre.stderr).trim();
347+
return { ok: false, error: `send-keys preamble failed (exit ${pre.exitCode})${stderr ? `: ${stderr}` : ""}` };
348+
}
337349
}
350+
351+
const cleaned = text.replace(/\0/g, "");
352+
353+
// Use a unique buffer name per invocation to avoid races when concurrent
354+
// sendPrompt calls overlap (e.g. two worktrees sending at the same time).
355+
const bufName = `wm-prompt-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
356+
357+
// Load text into a named tmux buffer via stdin — avoids all send-keys
358+
// escaping/chunking issues and handles any text size in a single operation.
359+
const load = Bun.spawnSync(["tmux", "load-buffer", "-b", bufName, "-"], {
360+
stdin: new TextEncoder().encode(cleaned),
361+
stderr: "pipe",
362+
});
363+
if (load.exitCode !== 0) {
364+
const stderr = new TextDecoder().decode(load.stderr).trim();
365+
return { ok: false, error: `load-buffer failed (exit ${load.exitCode})${stderr ? `: ${stderr}` : ""}` };
366+
}
367+
368+
// Paste buffer into target pane; -d deletes the buffer after pasting.
369+
const paste = Bun.spawnSync(["tmux", "paste-buffer", "-b", bufName, "-t", target, "-d"], {
370+
stderr: "pipe",
371+
});
372+
if (paste.exitCode !== 0) {
373+
const stderr = new TextDecoder().decode(paste.stderr).trim();
374+
return { ok: false, error: `paste-buffer failed (exit ${paste.exitCode})${stderr ? `: ${stderr}` : ""}` };
375+
}
376+
377+
// Submit
378+
const enter = Bun.spawnSync(["tmux", "send-keys", "-t", target, "Enter"], { stderr: "pipe" });
379+
if (enter.exitCode !== 0) {
380+
const stderr = new TextDecoder().decode(enter.stderr).trim();
381+
return { ok: false, error: `send-keys Enter failed (exit ${enter.exitCode})${stderr ? `: ${stderr}` : ""}` };
382+
}
383+
338384
return { ok: true };
339385
}
340386

387+
function findWorktreeSession(windowName: string): string | null {
388+
const result = Bun.spawnSync(
389+
["tmux", "list-windows", "-a", "-F", "#{session_name}:#{window_name}"],
390+
{ stdout: "pipe", stderr: "pipe" }
391+
);
392+
if (result.exitCode !== 0) return null;
393+
const output = new TextDecoder().decode(result.stdout).trim();
394+
if (!output) return null;
395+
for (const line of output.split("\n")) {
396+
const colonIdx = line.indexOf(":");
397+
if (colonIdx === -1) continue;
398+
const session = line.slice(0, colonIdx);
399+
const name = line.slice(colonIdx + 1);
400+
if (name === windowName) return session;
401+
}
402+
return null;
403+
}
404+
341405
export async function openWorktree(name: string): Promise<string> {
342406
return runChecked(["workmux", "open", name]);
343407
}

frontend/src/App.svelte

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,5 +302,9 @@
302302
{/if}
303303

304304
{#if ciDetailsPr}
305-
<CiDetailsDialog pr={ciDetailsPr} onclose={() => (ciDetailsPr = null)} />
305+
<CiDetailsDialog
306+
pr={ciDetailsPr}
307+
branch={selectedWorktree?.branch ?? ""}
308+
onclose={() => (ciDetailsPr = null)}
309+
/>
306310
{/if}

frontend/src/lib/AgentStatusIcon.svelte

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
<script lang="ts">
2-
let { status, size = 10, pill = false }: { status: string; size?: number; pill?: boolean } = $props();
2+
let {
3+
status,
4+
size = 10,
5+
pill = false,
6+
}: { status: string; size?: number; pill?: boolean } = $props();
37
48
function pillClass(s: string): string {
59
if (s === "working") return "bg-success/15 text-success";
@@ -12,18 +16,68 @@
1216

1317
{#snippet icon()}
1418
{#if status === "working"}
15-
<span class="spinner text-success" style="width:{size}px;height:{size}px;border-width:1.5px;"></span>
19+
<span
20+
class="spinner text-success"
21+
style="width:{size}px;height:{size}px;border-width:1.5px;"
22+
></span>
1623
{:else if status === "waiting"}
17-
<svg class="text-warning" xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/><path d="M12 7v2"/><path d="M12 13h.01"/></svg>
24+
<svg
25+
class="text-warning"
26+
xmlns="http://www.w3.org/2000/svg"
27+
width={size}
28+
height={size}
29+
viewBox="0 0 24 24"
30+
fill="none"
31+
stroke="currentColor"
32+
stroke-width="2"
33+
stroke-linecap="round"
34+
stroke-linejoin="round"
35+
><path
36+
d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"
37+
/><path d="M12 7v2" /><path d="M12 13h.01" /></svg
38+
>
1839
{:else if status === "done"}
19-
<svg class="text-success" xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>
40+
<svg
41+
class="text-success"
42+
xmlns="http://www.w3.org/2000/svg"
43+
width={size}
44+
height={size}
45+
viewBox="0 0 24 24"
46+
fill="none"
47+
stroke="currentColor"
48+
stroke-width="3"
49+
stroke-linecap="round"
50+
stroke-linejoin="round"
51+
><polyline points="20 6 9 17 4 12" /></svg
52+
>
2053
{:else if status === "error"}
21-
<svg class="text-danger" xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
54+
<svg
55+
class="text-danger"
56+
xmlns="http://www.w3.org/2000/svg"
57+
width={size}
58+
height={size}
59+
viewBox="0 0 24 24"
60+
fill="none"
61+
stroke="currentColor"
62+
stroke-width="3"
63+
stroke-linecap="round"
64+
stroke-linejoin="round"
65+
><line x1="18" y1="6" x2="6" y2="18" /><line
66+
x1="6"
67+
y1="6"
68+
x2="18"
69+
y2="18"
70+
/></svg
71+
>
2272
{/if}
2373
{/snippet}
2474

2575
{#if pill}
26-
<span class="text-xs px-2 py-0.5 rounded-xl flex items-center gap-1 {pillClass(status)}">
76+
<span
77+
class="text-xs px-2 py-0.5 rounded-xl flex items-center gap-1 {pillClass(
78+
status,
79+
)}"
80+
>
2781
{@render icon()}
2882
{status || "idle"}
2983
</span>

0 commit comments

Comments
 (0)