Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

835 lines · 41.7 KBRaw History
1/** @jsxImportSource hono/jsx */
2import type { FC } from "hono/jsx";
3import { requireAccount, type AppContext, requireRecentSignIn } from "../auth/principal.js";
4import { loadRepo } from "../http/common.js";
5import { HttpError } from "../lib/util.js";
6import { SettingsNav } from "../web/account.js";
7import { csrfToken } from "../web/csrf.js";
8import { KCheckbox, KRadio, KSelect } from "../web/kumo.js";
9import { Form, Markdown, Time, act, post, prm, render, repoUrl, statusOf, type WebApp } from "../web/ui.js";
10import { isProvider, ModelKeys, PROVIDERS, type Provider } from "./keys.js";
11import type { TaskRow, TaskView } from "./ProjectDO.js";
12import { fixGoal } from "./review.js";
13import { MAX_ATTEMPTS, MAX_CHECK_FIXES } from "./project.js";
14import { formatCost, formatTokens, totalUsage, type RunUsage } from "./usage.js";
15import { OPENROUTER_DEFAULT_MODEL, parseRuntimeChoice, runtimeChoices, runtimeLabel, transcriptItem, type TranscriptItem } from "./runtimes.js";
16import { approvePlan, compareAttempts, drivableAgents, getTask, pickAttempt, setTaskOptions, type AttemptCompare, mayStop, messageTask, projectStub, requireTaskReader, retryTask, startTask, stopTask, taskNumber } from "./tasks.js";
17import { splitPlan } from "./projects.js";
18import { runTaskInTerminal, terminalForTask, terminalId } from "./terminals.js";
19import { usageReport, type UsageLine, type UsageReport } from "./ledger.js";
20
21/**
22 * Web pages for agent tasks: Settings → Model access, and a repository's
23 * Tasks tab (list, new task, a task's live transcript with steer, stop and retry).
24 */
25
26type Loaded = Awaited<ReturnType<typeof loadRepo>>;
27
28export const TASK_STATE: Record<TaskRow["state"], { label: string; tone: string }> = {
29 queued: { label: "Queued", tone: "" },
30 running: { label: "Running", tone: "blue" },
31 ready: { label: "Ready", tone: "green" },
32 failed: { label: "Failed", tone: "red" },
33 stopped: { label: "Stopped", tone: "" },
34};
35
36const providerNames = Object.fromEntries(Object.values(PROVIDERS).map((p) => [p.id, p.name])) as Record<Provider, string>;
37
38/** The model field for a runtime that goes through OpenRouter (tasks and agent review share it). */
39export const OpenRouterModel: FC<{ value?: string | null }> = ({ value }) => (
40 <div class="field">
41 <label for="model">
42 OpenRouter model <span class="faint">(through OpenRouter only)</span>
43 </label>
44 <input id="model" name="model" type="text" maxlength={200} value={value ?? ""} placeholder={`${OPENROUTER_DEFAULT_MODEL["claude-code"]} for Claude Code, ${OPENROUTER_DEFAULT_MODEL.codex} for Codex`} />
45 <div class="hint">
46 A model id from <a href="https://openrouter.ai/models" rel="noopener noreferrer" target="_blank">openrouter.ai/models</a>. Left empty, Claude Code uses {OPENROUTER_DEFAULT_MODEL["claude-code"]} and Codex {OPENROUTER_DEFAULT_MODEL.codex}. OpenRouter supports Claude Code fully only on Anthropic models, and Codex on OpenAI ones.
47 </div>
48 </div>
49);
50
51export const TaskBadge: FC<{ state: TaskRow["state"] }> = ({ state }) => <span class={`badge ${TASK_STATE[state].tone}`}>{TASK_STATE[state].label}</span>;
52
53// ---------------------------------------------------------------- usage (phase 5a)
54
55const RUN_KIND: Record<TaskView["runs"][number]["kind"], string> = { start: "Start", message: "Follow-up", retry: "Retry", review: "Review", coordinate: "Coordinate", plan: "Plan", build: "Build", fix: "Fix checks" };
56
57export const UsageCells: FC<{ u: Omit<RunUsage, "source"> | null; subscription?: boolean }> = ({ u, subscription }) =>
58 u ? (
59 <>
60 <td class="r">{u.requests ? u.requests.toLocaleString("en-US") : "—"}</td>
61 <td class="r">{formatTokens(u.input)}</td>
62 <td class="r">{formatTokens(u.output)}</td>
63 <td class="r">
64 {formatTokens(u.cacheRead)} / {formatTokens(u.cacheWrite)}
65 </td>
66 <td class="r">{u.costUsd === null ? "—" : `${formatCost(u.costUsd)}${subscription ? "*" : ""}`}</td>
67 </>
68 ) : (
69 <td colspan={5} class="muted">
70 None reported
71 </td>
72 );
73
74/** A task's model usage, per run and in all: counted by Diffinery's model proxy, or as the CLI in a terminal reported it. */
75const UsageBox: FC<{ t: TaskView }> = ({ t }) => {
76 const total = totalUsage(t.runs.map((r) => r.usage));
77 if (!total) return null;
78 const subscription = t.runs.some((r) => r.usage?.source === "runtime" && r.usage.costUsd !== null);
79 return (
80 <details class="box" id="usage">
81 <summary class="head">
82 <h2>Usage</h2>
83 <span class="badge">
84 {formatTokens(total.input + total.output + total.cacheRead + total.cacheWrite)} tokens{total.costUsd !== null ? ` · ${formatCost(total.costUsd)}` : ""}
85 </span>
86 </summary>
87 <div class="body">
88 <div class="xscroll">
89 <table class="tbl">
90 <thead>
91 <tr>
92 <th>Run</th>
93 <th>Counted by</th>
94 <th class="r">Requests</th>
95 <th class="r">Input</th>
96 <th class="r">Output</th>
97 <th class="r">Cache read / write</th>
98 <th class="r">Cost</th>
99 </tr>
100 </thead>
101 <tbody>
102 {t.runs.map((r) => (
103 <tr>
104 <td>
105 {r.seq} · {RUN_KIND[r.kind]}
106 {r.attempt ? ` · attempt ${r.attempt}` : ""}
107 </td>
108 <td class="muted">{r.usage ? (r.usage.source === "proxy" ? "Diffinery" : `${runtimeLabel(t.runtime)} in the terminal`) : "—"}</td>
109 <UsageCells u={r.usage} subscription={r.usage?.source === "runtime"} />
110 </tr>
111 ))}
112 {t.runs.length > 1 ? (
113 <tr>
114 <th>Total</th>
115 <td />
116 <UsageCells u={total} />
117 </tr>
118 ) : null}
119 </tbody>
120 </table>
121 </div>
122 <p class="muted small mt">
123 {t.key_owner && t.key_owner !== t.person_id ? "The organization's key paid for this task's model requests. " : ""}Requests through a key are counted by Diffinery from the provider's answers. A run in your terminal is counted by the CLI signed in there. Cost is only ever Claude Code's own figure: Codex and custom commands report none.
124 {subscription ? " * At API prices: a subscription covers it." : ""} Your usage across repositories is in <a href="/settings/model-access#usage">Settings → Model access</a>.
125 </p>
126 </div>
127 </details>
128 );
129};
130
131// ---------------------------------------------------------------- Settings → Model access
132
133const ModelAccessPage = async (c: AppContext) => {
134 const me = requireAccount(c);
135 const t = csrfToken(c);
136 const keys = new Map((await new ModelKeys(c.env).list(me.id)).map((k) => [k.provider, k]));
137 const usage = await usageReport(c.env, me.id);
138 return render(
139 c,
140 { title: "Model access" },
141 <div class="grid left">
142 <SettingsNav me={me} tab="model-access" db={c.env.DB} />
143 <div>
144 <h1>Model access</h1>
145 <p class="muted">
146 Agent tasks you start run Claude Code or Codex with your own API key: Anthropic's or OpenAI's, or OpenRouter's to run them on a model you choose there. Diffinery keeps the key encrypted, never shows it again, and never puts it in a sandbox: the agent calls the provider through Diffinery, which adds your key. See <a href="/docs/agents/tasks">Agent tasks</a>.
147 </p>
148 <div class="flash info">
149 Anything an agent runs in its sandbox, including code from the repository and its dependencies, can use your model access while the task runs, and could be tricked into misusing it. Use a <b>dedicated key with a spending limit</b> for Diffinery, and revoke it with the provider if you suspect misuse.
150 </div>
151 {(Object.keys(PROVIDERS) as Provider[]).map((id) => {
152 const p = PROVIDERS[id];
153 const k = keys.get(id);
154 return (
155 <div class="box">
156 <div class="head">
157 <h2>{p.name}</h2>
158 {k ? <span class="badge green">Key saved</span> : <span class="badge">No key</span>}
159 </div>
160 <div class="body">
161 {k ? (
162 <div class="row mb">
163 <div class="grow">
164 <span class="mono">…{k.hint}</span>
165 <span class="muted small">
166 {" "}
167 · added <Time at={k.createdAt} /> · updated <Time at={k.updatedAt} /> · terms accepted <Time at={k.optedInAt} />
168 </span>
169 </div>
170 <Form action={`/settings/model-access/${id}/delete`} t={t} class="inline" confirm={`Delete your ${p.name} key? Running tasks that use it stop getting model access.`}>
171 <button class="btn sm danger">Delete</button>
172 </Form>
173 </div>
174 ) : null}
175 <Form action={`/settings/model-access/${id}`} t={t}>
176 <div class="field">
177 <label for={`key-${id}`}>{k ? `Replace your ${p.name} API key` : `Your ${p.name} API key`}</label>
178 <input id={`key-${id}`} name="key" type="password" autocomplete="off" required placeholder={id === "anthropic" ? "sk-ant-…" : id === "openrouter" ? "sk-or-…" : "sk-…"} />
179 <div class="hint">
180 Create one at <a href={p.keysUrl} rel="noopener noreferrer" target="_blank">{p.keysUrl.replace("https://", "")}</a>.
181 </div>
182 </div>
183 <div class="field">
184 <KCheckbox
185 name="accept"
186 label={
187 <>
188 I use {p.name} through Diffinery under my own {p.name} account and its terms ({p.terms.map((x, i) => (
189 <>
190 {i ? ", " : ""}
191 <a href={x.url} rel="noopener noreferrer" target="_blank">
192 {x.label}
193 </a>
194 </>
195 ))}
196 ). Usage is billed to my account. Diffinery only provides the ability.
197 </>
198 }
199 />
200 </div>
201 <button class="btn primary">{k ? "Replace key" : "Save key"}</button>
202 </Form>
203 </div>
204 </div>
205 );
206 })}
207 <UsageReportBox report={usage} />
208 </div>
209 </div>,
210 );
211};
212
213const USAGE_KIND: Record<UsageLine["kind"], string> = { task: "Tasks", review: "Agent reviews", coordinator: "Coordinator" };
214
215/** Phase 5a: what the person's agents used, per repository and kind, from the agent_usage ledger (ledger.ts). */
216const UsageReportBox: FC<{ report: UsageReport }> = ({ report }) => {
217 const total = report.lines.reduce(
218 (a, l) => ({ runs: a.runs + l.runs, requests: a.requests + l.requests, input: a.input + l.input, output: a.output + l.output, cacheRead: a.cacheRead + l.cacheRead, cacheWrite: a.cacheWrite + l.cacheWrite, costUsd: l.costUsd === null ? a.costUsd : (a.costUsd ?? 0) + l.costUsd }),
219 { runs: 0, requests: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: null as number | null },
220 );
221 return (
222 <div class="box" id="usage">
223 <div class="head">
224 <h2>Usage in the last {report.days} days</h2>
225 {report.lines.length ? <span class="badge">{formatTokens(total.input + total.output + total.cacheRead + total.cacheWrite)} tokens</span> : null}
226 </div>
227 <div class="body">
228 {report.lines.length ? (
229 <div class="xscroll">
230 <table class="tbl">
231 <thead>
232 <tr>
233 <th>Repository</th>
234 <th>For</th>
235 <th class="r">Runs</th>
236 <th class="r">Requests</th>
237 <th class="r">Input</th>
238 <th class="r">Output</th>
239 <th class="r">Cache read / write</th>
240 <th class="r">Cost</th>
241 </tr>
242 </thead>
243 <tbody>
244 {report.lines.map((l) => (
245 <tr>
246 <td>{l.repository ? <a href={`/${l.repository}/tasks`}>{l.repository}</a> : <span class="muted">Unknown</span>}</td>
247 <td>{USAGE_KIND[l.kind]}</td>
248 <td class="r">
249 {l.runs}
250 {l.reportedRuns ? <span class="muted"> ({l.reportedRuns} in a terminal)</span> : null}
251 </td>
252 <UsageCells u={l} />
253 </tr>
254 ))}
255 {report.lines.length > 1 ? (
256 <tr>
257 <th>Total</th>
258 <td />
259 <td class="r">{total.runs}</td>
260 <UsageCells u={total} />
261 </tr>
262 ) : null}
263 </tbody>
264 </table>
265 </div>
266 ) : (
267 <p class="muted">None of your agents' runs used a model in the last {report.days} days.</p>
268 )}
269 <p class="muted small mt">
270 Runs you pay for: your tasks and your coordinator's, and agent reviews in repositories where you turned them on. Runs an organization's key paid for are on that organization's Settings tab, for its owners. Repositories are named as they were when the runs ended. Diffinery counts requests through your keys from the provider's answers; runs in your terminal count what the CLI signed in there reported. Cost is only Claude Code's own figure, at API prices; check your provider's console for what you were billed. Also at <code>GET /api/v3/diffinery/model-usage</code>.
271 </p>
272 </div>
273 </div>
274 );
275};
276
277// ---------------------------------------------------------------- repository Tasks tab
278
279async function forTasks(c: AppContext): Promise<Loaded> {
280 if (c.get("principal").type !== "account") throw new HttpError(401, "sign in required");
281 const l = await loadRepo(c, prm(c, "owner"), prm(c, "repo"), "read");
282 requireTaskReader(l);
283 return l;
284}
285
286const TaskList: FC<{ l: Loaded; tasks: TaskRow[] }> = ({ l, tasks }) => {
287 const base = repoUrl(l.repo);
288 return tasks.length ? (
289 <ul class="list">
290 {tasks.map((t) => (
291 <li>
292 <div class="grow">
293 <a href={`${base}/tasks/${t.number}`}>
294 <b>{t.title}</b>
295 </a>{" "}
296 {t.kind === "review" ? <span class="badge purple">Review</span> : t.kind === "coordinator" ? <span class="badge purple">Coordinator</span> : t.coordinator_post ? <span class="badge">From a coordinator</span> : null} <TaskBadge state={t.state} />
297 <div class="muted small">
298 #{t.number} · {runtimeLabel(t.runtime, t)} as <a href={`/${t.agent_login}`}>{t.agent_login}</a> · started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} />
299 {t.pr_number ? (
300 <>
301 {" "}
302 · <a href={`${base}/pull/${t.pr_number}`}>#{t.pr_number}</a>
303 </>
304 ) : null}
305 </div>
306 </div>
307 <span class="muted small">
308 <Time at={t.updated_at} />
309 </span>
310 </li>
311 ))}
312 </ul>
313 ) : (
314 <div class="empty">
315 No agent tasks yet. Give one of your agents a goal; it works in a sandbox and opens a pull request for review.
316 </div>
317 );
318};
319
320const ItemView: FC<{ item: TranscriptItem; base: string }> = ({ item, base }) => {
321 if (item.kind === "text") return <div class="tx-text"><Markdown text={item.body} /></div>;
322 if (item.kind === "result")
323 return (
324 <div class="tx-result">
325 <div class="small muted">{item.title}</div>
326 {item.body ? <Markdown text={item.body} /> : null}
327 </div>
328 );
329 if (item.kind === "note") return <div class="tx-note muted small">{item.body}</div>;
330 if (item.kind === "tool")
331 return (
332 <details class="tx-tool">
333 <summary class="mono small">{item.title}</summary>
334 <pre class="tx-pre">{item.body}</pre>
335 </details>
336 );
337 return <pre class={`tx-pre${item.kind === "error" ? " tx-err" : ""}`}>{item.body}</pre>;
338};
339
340function taskPage(c: AppContext, l: Loaded, t: TaskView, items: Array<{ run: number; item: TranscriptItem }>, truncated: boolean, canSplit = false, attempts: AttemptCompare[] = [], terminal: string | null = null) {
341 const base = repoUrl(l.repo);
342 const tk = csrfToken(c);
343 const me = requireAccount(c);
344 const review = t.kind === "review";
345 const starter = me.id === t.person_id && t.kind === "task";
346 const running = t.state === "running" || t.state === "queued";
347 return render(
348 c,
349 { title: `${t.title} · Task #${t.number}`, repo: { repo: l.repo, caller: l.caller, tab: "project" }, refresh: running ? 5 : undefined },
350 <div>
351 <div class="row mb" style="align-items:flex-start">
352 <div class="grow">
353 <h1 class="m0">
354 {t.title} <span class="faint">#{t.number}</span>
355 </h1>
356 <div class="muted small mt">
357 <TaskBadge state={t.state} /> {runtimeLabel(t.runtime, t)} as <a href={`/${t.agent_login}`}>{t.agent_login}</a>, started by <a href={`/${t.person_login}`}>{t.person_login}</a> <Time at={t.created_at} /> · branch <span class="mono">{t.branch}</span> from <span class="mono">{t.base}</span>
358 {t.pr_number ? (
359 <>
360 {" "}
361 · pull request <a href={`${base}/pull/${t.pr_number}`}>#{t.pr_number}</a>
362 </>
363 ) : null}
364 </div>
365 </div>
366 {running && mayStop(l.caller, t) ? (
367 <Form action={`${base}/tasks/${t.number}/stop`} t={tk} class="inline" confirm="Stop this task? The agent is stopped and its sandbox closed; work it already pushed stays.">
368 <button class="btn sm danger">Stop</button>
369 </Form>
370 ) : null}
371 {!running && starter ? (
372 <Form action={`${base}/tasks/${t.number}/retry`} t={tk} class="inline">
373 <button class="btn sm">Retry</button>
374 </Form>
375 ) : null}
376 {!running && terminal ? (
377 <Form action={`${base}/tasks/${t.number}/terminal`} t={tk} class="inline">
378 <input type="hidden" name="terminal" value={terminal} />
379 <button class="btn sm" title={`Runs it again in your open terminal, with the ${runtimeLabel(t.runtime, t)} you're signed in to there, on your own subscription.`}>
380 Run in my terminal
381 </button>
382 </Form>
383 ) : null}
384 </div>
385 {t.coordinator_post ? (
386 <div class="flash info">
387 {t.person_login}'s coordinator started this task while handling <a href={`${base}/project#post-${t.coordinator_post}`}>post #{t.coordinator_post}</a>. It wrote the goal below; each run may use at most {t.token_budget ? `${t.token_budget.toLocaleString("en-US")} tokens` : "its budget"}.
388 </div>
389 ) : null}
390 <div class="box">
391 <div class="head">
392 <h2>Goal</h2>
393 </div>
394 <div class="body"><Markdown text={t.goal} /></div>
395 </div>
396 {t.error ? <div class="flash error">{t.error}</div> : null}
397 {t.plan && t.plan_state === "waiting" ? (
398 <div class="box" id="plan">
399 <div class="head">
400 <h2>Plan</h2>
401 <span class="badge amber">Waiting for {t.person_login}</span>
402 </div>
403 <div class="body">
404 <Markdown text={t.plan} />
405 {starter ? (
406 <div class="row mt" style="gap:8px;flex-wrap:wrap">
407 <Form action={`${base}/tasks/${t.number}/plan/approve`} t={tk} class="inline">
408 <button class="btn primary">Approve plan</button>
409 </Form>
410 {canSplit ? (
411 <Form action={`${base}/tasks/${t.number}/plan/split`} t={tk} class="inline" confirm="Hand this plan to your coordinator to split into tasks? This task is then done with.">
412 <button class="btn">Split into tasks</button>
413 </Form>
414 ) : (
415 <span class="muted small">
416 <a href={`${base}/project/coordinator`}>Set up your coordinator</a> to split plans into tasks.
417 </span>
418 )}
419 <span class="muted small">Or ask for changes below.</span>
420 </div>
421 ) : null}
422 </div>
423 </div>
424 ) : t.plan ? (
425 <details class="box">
426 <summary class="head">
427 <h2>Plan</h2>
428 <span class="badge">{t.plan_state === "split" ? "Split into tasks" : t.plan_state === "approved" ? "Approved" : "Being revised"}</span>
429 </summary>
430 <div class="body">
431 <Markdown text={t.plan} />
432 </div>
433 </details>
434 ) : t.plan_state === "planning" ? (
435 <div class="flash info">This task plans first: its run reads the code in a read-only sandbox and writes a plan for {t.person_login} to approve, change or split into tasks.</div>
436 ) : null}
437 {attempts.length ? <Attempts t={t} attempts={attempts} base={base} tk={tk} starter={starter} /> : null}
438 {t.parent_task ? (
439 <div class="flash info">
440 Split from the plan of <a href={`${base}/tasks/${t.parent_task}`}>task #{t.parent_task}</a>.
441 </div>
442 ) : null}
443 {t.kind === "task" ? (
444 <Form action={`${base}/tasks/${t.number}/options`} t={tk} class="box" id="options">
445 <div class="head">
446 <h2>Verify and land</h2>
447 {t.check_fixes ? <span class="badge">{t.check_fixes} of {MAX_CHECK_FIXES} fix runs used</span> : null}
448 </div>
449 <div class="body">
450 <KCheckbox name="fix_checks" checked={!!t.fix_checks} disabled={!starter} label={<>Fix failing checks: a fix run starts when the pull request's checks fail on its head (up to {MAX_CHECK_FIXES})</>} />
451 <KCheckbox name="land" checked={!!t.land} disabled={!starter} label={<>Land when approved: auto-merge, once a person approves its latest commit and its checks pass</>} />
452 {starter ? (
453 <button class="btn sm mt">Save</button>
454 ) : null}
455 </div>
456 </Form>
457 ) : null}
458 <UsageBox t={t} />
459 {t.queued ? <div class="flash info">A message is waiting for the current run to end: {t.queued}</div> : null}
460 <div class="box">
461 <div class="head">
462 <h2>Transcript</h2>
463 {running ? <span class="badge blue">Live</span> : null}
464 </div>
465 <div class="body tx">
466 {truncated ? (
467 <p class="muted small">
468 Showing the latest lines. The whole transcript is at <a href={`/api/v3/repos/${l.repo.owner_login}/${l.repo.name}/diffinery/tasks/${t.number}/transcript`}>the API</a>.
469 </p>
470 ) : null}
471 {items.length ? items.map((x, i) => (
472 <>
473 {i === 0 || items[i - 1]!.run !== x.run ? <h4 class="tx-run">Run {x.run}{t.runs[x.run - 1]?.attempt ? ` · attempt ${t.runs[x.run - 1]!.attempt}` : ""}{t.runs[x.run - 1]?.message ? " · follow-up" : ""}{t.runs[x.run - 1]?.terminal_id ? ` · in ${t.person_login}'s terminal` : ""}</h4> : null}
474 <ItemView item={x.item} base={base} />
475 </>
476 )) : <div class="empty">Starting…</div>}
477 </div>
478 </div>
479 {starter && t.attempts > 1 && (t.pick_state === "running" || t.pick_state === "waiting") ? (
480 <p class="muted small">{t.pick_state === "running" ? `Follow-ups open once you've picked one of the ${t.attempts} attempts.` : "Pick an attempt to continue: follow-ups go to the picked attempt's branch."}</p>
481 ) : starter ? (
482 <Form action={`${base}/tasks/${t.number}/messages`} t={tk} class="box">
483 <div class="head">
484 <h2>{t.plan_state === "waiting" ? "Ask for changes to the plan" : running ? "Send a follow-up" : "Continue the task"}</h2>
485 </div>
486 <div class="body">
487 <div class="field">
488 <textarea name="body" rows={4} required maxlength={20000} placeholder={running ? "It starts a new run on the same branch when this run ends." : "What should the agent do next? It continues on the same branch and pull request."} />
489 </div>
490 <button class="btn primary">Send</button>
491 {!running && terminal ? (
492 <>
493 {" "}
494 <button class="btn" formaction={`${base}/tasks/${t.number}/terminal`} name="terminal" value={terminal} title="Runs it in your open terminal, with the CLI you're signed in to there, on your own subscription.">
495 Send to my terminal
496 </button>
497 </>
498 ) : null}
499 </div>
500 </Form>
501 ) : review ? (
502 <p class="muted small">
503 Each run is one agent review of <a href={`${base}/pull/${t.pr_number}`}>#{t.pr_number}</a>, using {t.person_login}'s model key. To review again, use Request agent review on the pull request.
504 </p>
505 ) : t.kind === "coordinator" ? (
506 <p class="muted small">
507 Each run is {t.person_login}'s coordinator handling posts in the <a href={`${base}/project`}>project conversation</a>, using {t.person_login}'s model key.
508 </p>
509 ) : (
510 <p class="muted small">Only {t.person_login}, who started this task, can steer or retry it: each run spends their own model key.</p>
511 )}
512 </div>,
513 );
514}
515
516/** Best of N: the attempts side by side, with Pick. */
517const Attempts: FC<{ t: TaskView; attempts: AttemptCompare[]; base: string; tk: string; starter: boolean }> = ({ t, attempts, base, tk, starter }) => {
518 const waiting = t.pick_state === "waiting";
519 return (
520 <div class="box" id="attempts">
521 <div class="head">
522 <h2>Attempts</h2>
523 {waiting ? <span class="badge amber">Pick one</span> : t.pick_state === "picked" ? <span class="badge green">Attempt {t.picked} picked</span> : t.pick_state === "running" ? <span class="badge blue">Running</span> : null}
524 </div>
525 <div class="body">
526 <table class="tbl">
527 <thead>
528 <tr>
529 <th>Attempt</th>
530 <th>Changes</th>
531 <th>Checks</th>
532 <th>Tokens</th>
533 <th></th>
534 </tr>
535 </thead>
536 <tbody>
537 {attempts.map((a) => (
538 <tr id={`attempt-${a.attempt}`}>
539 <td>
540 <strong>Attempt {a.attempt}</strong>
541 <div class="muted small mono">{a.head_sha && t.pick_state !== "picked" ? <a href={`${base}/compare/${t.base}...${a.branch}`}>{a.branch}</a> : a.branch}</div>
542 </td>
543 <td>{a.head_sha ? <span>{a.files} {a.files === 1 ? "file" : "files"}, <span style="color:var(--green)">+{a.additions}</span> <span style="color:var(--red)">−{a.deletions}</span></span> : <span class="muted">{a.step === "done" ? "No changes" : a.step === "failed" ? `Failed${a.error ? `: ${a.error}` : ""}` : a.step === "stopped" ? "Stopped" : "Running"}</span>}</td>
544 <td>{a.head_sha ? <span class={`badge ${a.checks.state === "success" ? "green" : a.checks.state === "failure" ? "red" : a.checks.state === "pending" ? "blue" : ""}`}>{a.checks.state === "none" ? "No checks" : a.checks.state === "success" ? "Passing" : a.checks.state === "failure" ? `Failing: ${a.checks.failing.join(", ")}` : "Running"}</span> : null}</td>
545 <td class="muted small">{a.tokens ? `${formatTokens(a.tokens)} (${a.model_calls} ${a.model_calls === 1 ? "request" : "requests"})` : "—"}</td>
546 <td>
547 {waiting && starter && a.head_sha && a.step === "done" ? (
548 <Form action={`${base}/tasks/${t.number}/attempts/${a.attempt}/pick`} t={tk} class="inline" confirm={`Pick attempt ${a.attempt}? Its branch becomes the task's branch and opens a pull request; the other attempts' branches are deleted.`}>
549 <button class="btn sm primary">Pick</button>
550 </Form>
551 ) : t.picked === a.attempt ? (
552 <span class="badge green">Picked</span>
553 ) : null}
554 </td>
555 </tr>
556 ))}
557 </tbody>
558 </table>
559 {attempts.map((a) =>
560 a.summary ? (
561 <details class="mt">
562 <summary>What attempt {a.attempt} said last</summary>
563 <Markdown text={a.summary} />
564 </details>
565 ) : null,
566 )}
567 </div>
568 </div>
569 );
570};
571
572async function newTaskPage(c: AppContext, l: Loaded, error?: string, v: { goal?: string; runtime?: string; agent?: string; base?: string; command?: string; model?: string; pull?: string; plan_first?: string; attempts?: string; fix_checks?: string; land?: string } = {}) {
573 const me = requireAccount(c);
574 const base = repoUrl(l.repo);
575 const tk = csrfToken(c);
576 const [agents, keys, branches, settings] = await Promise.all([drivableAgents(c, me, l), new ModelKeys(c.env).providers(me.id), l.stub.refs("refs/heads/"), projectStub(c.env, l.repo.id).projectSettings()]);
577 const runtimes = runtimeChoices(keys, providerNames);
578 const firstUsable = runtimes.find((r) => !r.disabled)?.value ?? "custom";
579 return render(
580 c,
581 { title: "New agent task", repo: { repo: l.repo, caller: l.caller, tab: "project" }, status: error ? 422 : undefined },
582 <div style="max-width:780px">
583 <h1>New agent task</h1>
584 {v.pull ? (
585 <p class="muted">
586 The agent works in a fresh sandbox on the branch of pull request <a href={`${base}/pull/${v.pull}`}>#{v.pull}</a> and pushes its fix there as itself, under that branch's protection rules. The model calls use <a href="/settings/model-access">your own key</a>.
587 </p>
588 ) : (
589 <p class="muted">
590 The agent works in a fresh sandbox with this repository checked out, pushes to a new branch as itself and opens a pull request, which needs a person's approval like any agent pull request. The model calls use <a href="/settings/model-access">your own key</a>.
591 </p>
592 )}
593 {error ? <div class="flash error">{error}</div> : null}
594 {!agents.length ? (
595 <div class="flash info">
596 You don't have an agent with write access here yet. <a href="/settings/agents">Create an agent</a>, then give it write access to this repository (or let it inherit yours).
597 </div>
598 ) : null}
599 <Form action={`${base}/tasks`} t={tk} class="box">
600 <div class="body">
601 <div class="field">
602 <label for="goal">Goal</label>
603 <textarea id="goal" name="goal" rows={8} required maxlength={20000} placeholder="Fix the Contact sales link on the pricing page so it goes to /contact, and add a test.">
604 {v.goal ?? ""}
605 </textarea>
606 <div class="hint">What you want done, as you would ask a colleague. Markdown works.</div>
607 </div>
608 <div class="field">
609 <label>Runtime</label>
610 <KRadio name="runtime" label="Runtime" value={v.runtime ?? firstUsable} items={runtimes} />
611 </div>
612 <OpenRouterModel value={v.model} />
613 <div class="field">
614 <label for="command">
615 Command <span class="faint">(custom runtime only)</span>
616 </label>
617 <input id="command" name="command" type="text" maxlength={2000} value={v.command ?? ""} placeholder='aider --yes --message "$DIFFINERY_TASK_PROMPT"' />
618 <div class="hint">Runs in the repository with the goal in $DIFFINERY_TASK_PROMPT, and ANTHROPIC_BASE_URL, OPENAI_BASE_URL or OPENROUTER_BASE_URL (with the matching _API_KEY) set for the providers you have keys for.</div>
619 </div>
620 <div class="row" style="align-items:flex-start">
621 <div class="field grow">
622 <label for="agent">Agent</label>
623 <KSelect id="agent" name="agent" block label="Agent" value={v.agent ?? agents[0]?.login} options={agents.map((a) => ({ value: a.login, label: a.name ? `${a.login} (${a.name})` : a.login }))} placeholder="Choose an agent" />
624 </div>
625 {v.pull ? (
626 <input type="hidden" name="pull" value={v.pull} />
627 ) : (
628 <div class="field grow">
629 <label for="base">Base branch</label>
630 <KSelect id="base" name="base" block label="Base branch" value={v.base ?? l.repo.default_branch} options={branches.slice(0, 500).map((b) => ({ value: b.name.slice("refs/heads/".length), label: b.name.slice("refs/heads/".length) }))} />
631 </div>
632 )}
633 </div>
634 <div class="field">
635 <KCheckbox
636 name="plan_first"
637 checked={settings.planFirst === "required" || (v.plan_first ?? (settings.planFirst === "default" ? "1" : "")) === "1"}
638 disabled={settings.planFirst === "required"}
639 label={<>Plan first: the agent reads the code and writes a plan in a read-only sandbox; it builds once you approve the plan{settings.planFirst === "required" ? " (this project requires it)" : ""}</>}
640 />
641 </div>
642 {v.pull ? null : (
643 <div class="field" style="max-width:320px">
644 <label for="attempts">Attempts</label>
645 <KSelect id="attempts" name="attempts" block label="Attempts" value={v.attempts ?? "1"} options={Array.from({ length: MAX_ATTEMPTS }, (_, i) => ({ value: String(i + 1), label: i === 0 ? "1 attempt" : `${i + 1} attempts, then pick the best` }))} />
646 <p class="muted small">Each attempt runs in its own sandbox on its own branch, at the same time, and counts toward the project's limit. Each spends your model key.</p>
647 </div>
648 )}
649 <div class="field">
650 <KCheckbox name="fix_checks" checked={(v.fix_checks ?? (v.pull ? "" : "1")) === "1"} label={<>Fix failing checks: when its pull request's checks fail, the agent gets their logs and fixes them (up to {MAX_CHECK_FIXES} runs)</>} />
651 </div>
652 <div class="field">
653 <KCheckbox name="land" checked={v.land === "1"} label={<>Land when approved: turn on auto-merge, so the pull request merges once a person approves it and its checks pass</>} />
654 </div>
655 <button class="btn primary" disabled={!agents.length}>
656 Start task
657 </button>
658 </div>
659 </Form>
660 </div>,
661 );
662}
663
664export function registerTasks(web: WebApp): void {
665 web.get("/settings/model-access", (c) => ModelAccessPage(c));
666
667 post(web, "/settings/model-access/:provider", async (c, f) => {
668 const me = requireAccount(c);
669 const provider = prm(c, "provider");
670 if (!isProvider(provider)) throw new HttpError(404, "not found");
671 // Another key would send this person's later prompts and code to its account: a recent sign-in, as for organizations' keys.
672 requireRecentSignIn(c, "/settings/model-access");
673 return act(
674 c,
675 "/settings/model-access",
676 async () => {
677 const k = await new ModelKeys(c.env).set(me, provider, f.s("key"), f.all("accept").length > 0);
678 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.set", target: provider, detail: { hint: k.hint, opted_in_at: k.optedInAt } }));
679 },
680 `${PROVIDERS[provider].name} key saved.`,
681 );
682 });
683
684 post(web, "/settings/model-access/:provider/delete", async (c) => {
685 const me = requireAccount(c);
686 const provider = prm(c, "provider");
687 if (!isProvider(provider)) throw new HttpError(404, "not found");
688 return act(
689 c,
690 "/settings/model-access",
691 async () => {
692 await new ModelKeys(c.env).delete(me.id, provider);
693 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "model_key.delete", target: provider }));
694 },
695 `${PROVIDERS[provider].name} key deleted.`,
696 );
697 });
698
699 web.get("/:owner/:repo/tasks", async (c) => {
700 const l = await forTasks(c);
701 const tasks = await projectStub(c.env, l.repo.id).list({ limit: 100 });
702 return render(
703 c,
704 { title: `Tasks · ${l.repo.owner_login}/${l.repo.name}`, repo: { repo: l.repo, caller: l.caller, tab: "project" }, refresh: tasks.some((t) => t.state === "running" || t.state === "queued") ? 10 : undefined },
705 <div>
706 <div class="row mb">
707 <h1 class="grow m0">Agent tasks</h1>
708 <a class="btn" href={`${repoUrl(l.repo)}/terminals`}>
709 Terminals
710 </a>
711 {l.repo.archived ? null : (
712 <a class="btn primary" href={`${repoUrl(l.repo)}/tasks/new`}>
713 New task
714 </a>
715 )}
716 </div>
717 <div class="box">
718 <TaskList l={l} tasks={tasks} />
719 </div>
720 </div>,
721 );
722 });
723
724 web.get("/:owner/:repo/tasks/new", async (c) => {
725 const l = await forTasks(c);
726 // Fix it on an agent review finding: work on that pull request's branch, with the finding as the goal.
727 const pull = c.req.query("pull");
728 // "Start a task" on a project post: the post is the goal.
729 const fromPost = c.req.query("post");
730 if (fromPost && /^\d{1,9}$/.test(fromPost)) {
731 const [p] = await projectStub(c.env, l.repo.id).posts(Number(fromPost) - 1, 1);
732 return newTaskPage(c, l, undefined, p && p.n === Number(fromPost) && p.kind === "person" ? { goal: p.body } : {});
733 }
734 if (!pull || !/^\d{1,9}$/.test(pull)) return newTaskPage(c, l);
735 const f = c.req.query("finding") ? await projectStub(c.env, l.repo.id).finding(c.req.query("finding")!) : null;
736 const goal = f && f.pr === Number(pull) ? fixGoal(f.pr, [f]) : `Work on pull request #${pull}: `;
737 return newTaskPage(c, l, undefined, { pull, goal });
738 });
739
740 post(web, "/:owner/:repo/tasks", async (c, f) => {
741 const l = await forTasks(c);
742 const v = { goal: f.s("goal"), runtime: f.s("runtime") || "claude-code", agent: f.s("agent"), base: f.s("base"), command: f.s("command"), model: f.s("model"), pull: f.s("pull") || undefined, plan_first: f.all("plan_first").length ? "1" : "", attempts: f.s("attempts") || "1", fix_checks: f.all("fix_checks").length ? "1" : "", land: f.all("land").length ? "1" : "" };
743 try {
744 const { runtime, provider } = parseRuntimeChoice(v.runtime);
745 const t = await startTask(c, l, { ...v, runtime, provider, model: provider ? v.model : undefined, plan_first: v.plan_first === "1", fix_checks: v.fix_checks === "1", land: v.land === "1" });
746 return c.redirect(`${repoUrl(l.repo)}/tasks/${t.number}`, 303);
747 } catch (e) {
748 const st = statusOf(e);
749 if (st.status >= 500) throw e;
750 return newTaskPage(c, l, st.message, v);
751 }
752 });
753
754 web.get("/:owner/:repo/tasks/:number", async (c) => {
755 const l = await forTasks(c);
756 const n = taskNumber(c.req.param("number"));
757 const t = await getTask(c, l, n);
758 const stub = projectStub(c.env, l.repo.id);
759 // The latest lines: page backwards from the end would need an index; fetch the head and keep the tail.
760 const lines = await stub.lines(n, 0, 5000);
761 const tail = lines.slice(-1500);
762 const items = tail.flatMap((line) => transcriptItem(line).map((item) => ({ run: line.run, item })));
763 const canSplit = t.plan_state === "waiting" && !!(await stub.coordinator(requireAccount(c).id));
764 const attempts = t.attempts > 1 ? await compareAttempts(c, l, n) : [];
765 const terminal = await terminalForTask(c.env, requireAccount(c), l.repo, t).catch(() => null);
766 return taskPage(c, l, t, items, lines.length > tail.length, canSplit, attempts, terminal);
767 });
768
769 post(web, "/:owner/:repo/tasks/:number/messages", async (c, f) => {
770 const l = await forTasks(c);
771 const n = taskNumber(c.req.param("number"));
772 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
773 await messageTask(c, l, n, f.s("body"));
774 });
775 });
776
777 post(web, "/:owner/:repo/tasks/:number/options", async (c, f) => {
778 const l = await forTasks(c);
779 const n = taskNumber(c.req.param("number"));
780 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
781 await setTaskOptions(c, l, n, { fix_checks: f.all("fix_checks").length > 0, land: f.all("land").length > 0 });
782 }, "Saved.");
783 });
784
785 post(web, "/:owner/:repo/tasks/:number/attempts/:attempt/pick", async (c) => {
786 const l = await forTasks(c);
787 const n = taskNumber(c.req.param("number"));
788 const k = Number(prm(c, "attempt"));
789 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
790 await pickAttempt(c, l, n, k);
791 }, `Attempt ${k} picked.`);
792 });
793
794 post(web, "/:owner/:repo/tasks/:number/plan/approve", async (c) => {
795 const l = await forTasks(c);
796 const n = taskNumber(c.req.param("number"));
797 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
798 await approvePlan(c, l, n);
799 }, "Plan approved: building it.");
800 });
801
802 post(web, "/:owner/:repo/tasks/:number/plan/split", async (c) => {
803 const l = await forTasks(c);
804 const n = taskNumber(c.req.param("number"));
805 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
806 const out = await splitPlan(c, l, n);
807 return `${repoUrl(l.repo)}/project#post-${out.post}`;
808 }, "Your coordinator is splitting the plan into tasks.");
809 });
810
811 post(web, "/:owner/:repo/tasks/:number/retry", async (c) => {
812 const l = await forTasks(c);
813 const n = taskNumber(c.req.param("number"));
814 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
815 await retryTask(c, l, n);
816 }, "Retrying.");
817 });
818
819 // Phase 4b: only the person's own click here runs a task in their terminal (with the follow-up, if they wrote one).
820 post(web, "/:owner/:repo/tasks/:number/terminal", async (c, f) => {
821 const l = await forTasks(c);
822 const n = taskNumber(c.req.param("number"));
823 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
824 await runTaskInTerminal(c, l, n, terminalId(f.s("terminal")), f.s("body"));
825 }, "Running in your terminal.");
826 });
827
828 post(web, "/:owner/:repo/tasks/:number/stop", async (c) => {
829 const l = await forTasks(c);
830 const n = taskNumber(c.req.param("number"));
831 return act(c, `${repoUrl(l.repo)}/tasks/${n}`, async () => {
832 await stopTask(c, l, n);
833 }, "Task stopped.");
834 });
835}